nifty_services 0.0.3 → 0.0.4
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +1015 -12
- data/daily_news.log +7 -0
- data/lib/nifty_services/base_create_service.rb +12 -12
- data/lib/nifty_services/base_delete_service.rb +9 -9
- data/lib/nifty_services/base_service.rb +44 -136
- data/lib/nifty_services/base_update_service.rb +15 -15
- data/lib/nifty_services/configuration.rb +4 -3
- data/lib/nifty_services/extensions/callbacks_interface.rb +171 -0
- data/lib/nifty_services/util.rb +10 -0
- data/lib/nifty_services/version.rb +1 -1
- data/lib/nifty_services.rb +5 -0
- data/test.rb +82 -0
- metadata +6 -2
data/README.md
CHANGED
@@ -1,8 +1,98 @@
|
|
1
|
-
# NiftyServices
|
1
|
+
# NiftyServices
|
2
2
|
|
3
|
-
|
3
|
+
## Introduction
|
4
4
|
|
5
|
-
|
5
|
+
Nifty Services comes to solve your Ruby applications(*including but not limited to* Rails, Grape, Sinatra, and plain Ruby) code mess with **simplicity in mind**!
|
6
|
+
|
7
|
+
NiftyServices provider a very nifty, simple & clear API to **organize and reuse** your application **domain logic in plain Ruby Services Objects** turning your codebase in a very extensible, standardized and reusable components.
|
8
|
+
|
9
|
+
**Most important:** You and your team win what I consider the best benefit when using Nifty Services: **Easily and scalable maintained code.**
|
10
|
+
Believe me, you'll fall in :heart_eyes: with this small piece of code, keep reading!
|
11
|
+
|
12
|
+
This gem was designed and conventioned to be used specially with **Web API applications**, but this is just a convention, you can use it's even with [shoes (for desktop apps)](https://github.com/shoes/shoes) applications if you want, for example.
|
13
|
+
|
14
|
+
#### :book: I know, this README is very huge
|
15
|
+
|
16
|
+
As you can see, this README needs some time to be full read, but is very difficulty to explain all things, concepts and philosophy of this gem without writing a lot, we can't escape this :(
|
17
|
+
|
18
|
+
But remember one thing: This is a **tecnical documentation**, not a blog post, I'm pretty sure you can take 1 or 2 hours + :coffee: to better understand all NiftyServices can do for you and your project. Good reading, and if you have some question, [please let me know](/issues/new).
|
19
|
+
|
20
|
+
---
|
21
|
+
|
22
|
+
## Table of Contents
|
23
|
+
|
24
|
+
* [Dafuck is this gem](#introduction)
|
25
|
+
* [Conventions](#conventions)
|
26
|
+
* [Single Responsability](#conventions-single-responsibility)
|
27
|
+
* [Method execution](#hammer-common-and-single-run-execution-method)
|
28
|
+
* [Rich Service Objects](#package-rich-service-objects)
|
29
|
+
* [Security & Access Level Control](#lock-security---access-control-level)
|
30
|
+
* [Installation](#installation)
|
31
|
+
* [Usage](#usage)
|
32
|
+
* [Basic Service Markup](#basic-service-markup)
|
33
|
+
* [How a Service must be created](#wrapping-things-up)
|
34
|
+
* [Services API](#services-public-api)
|
35
|
+
* [Full Public Service API Methods List](#full-public-api-methods-list)
|
36
|
+
* [Handling Success & Error Responses](#success--error-responses)
|
37
|
+
* [Success response](#white_check_mark-handling-success-zap)
|
38
|
+
* [Error response](#red_circle-handling-error-boom)
|
39
|
+
* [Custom error response methods](#custom-error-response-methods)
|
40
|
+
* [CRUD Services](#crud-services)
|
41
|
+
* [**Create** - BaseCreateService](#white_check_mark-crud-create)
|
42
|
+
* [I18n Setup](#earth_americas-i18n-setup)
|
43
|
+
* [Error - Invalid User](#alien-invalid-user)
|
44
|
+
* [Error - Not authorized](#no_entry_sign-not-authorized-to-create)
|
45
|
+
* [Error - Invalid record](#boom-record-is-invalid)
|
46
|
+
* [**Update** - BaseUpdateService](#white_check_mark-crud-update)
|
47
|
+
* [I18n Setup](#earth_asia-i18n-setup)
|
48
|
+
* [Error - Invalid User](#update-resource-user-invalid)
|
49
|
+
* [Error - Resource don't belongs to user](#update-resource-dont-belongs-to-user)
|
50
|
+
* [Error - Resource dont exists](#update-resource-dont-exists)
|
51
|
+
* [**Delete** - BaseDeleteService](#white_check_mark-crud-delete)
|
52
|
+
* [I18n Setup](#earth_africa-i18n-setup)
|
53
|
+
* [Error - Invalid User](#delete-resource-user-invalid)
|
54
|
+
* [Error - Resource don't belongs to user](#delete-resource-dont-belongs-to-user)
|
55
|
+
* [Error - Resource dont exists](#delete-resource-dont-exists)
|
56
|
+
* [I18n Setup](#us-fr-jp-i18n-support-uk-es-de)
|
57
|
+
* [Callbacks](#callbacks)
|
58
|
+
* [Using custom callbacks](#creating-custom-callbacks)
|
59
|
+
* [Configuration](#construction-configuration-construction)
|
60
|
+
* [Web Frameworks integration](#web-frameworks-integrations)
|
61
|
+
* [Ruby on Rails](#frameworks-rails)
|
62
|
+
* [Grape/Sinatra/Rack](#frameworks-rack)
|
63
|
+
* [CLI Generators](#cli-generators)
|
64
|
+
* [Roadmap](#roadmap)
|
65
|
+
* [Development](#computer-development)
|
66
|
+
* [Contributing](#thumbsup-contributing)
|
67
|
+
* [License - MIT](#memo-license)
|
68
|
+
|
69
|
+
---
|
70
|
+
|
71
|
+
## Conventions
|
72
|
+
|
73
|
+
Below, some very importants things about conventions for this cute :gem: :)
|
74
|
+
|
75
|
+
### :white_check_mark: Single responsibility <a name="conventions-single-responsibility"></a>
|
76
|
+
|
77
|
+
Each service class is responsible for perform exactly [one single task](https://en.wikipedia.org/wiki/Single_responsibility_principle), say goodbye for code (most important: logic) duplication in your code.
|
78
|
+
Beside this, one of the aim of NiftyServices is to provide a **very standardized** code architecture, allowing developers to quickly develop and implement new features keeping the application codebase organized and stable.
|
79
|
+
|
80
|
+
### :hammer: Common and single-run execution method
|
81
|
+
|
82
|
+
Each service object must respond to `#execute` instance method, which is allowed to be **called just one time** per instance.
|
83
|
+
`#execute` method is responsible to perform code validation(parameter validation, access level control), execution(send mail, register users) and fire callbacks so you can execute hooks actions **after/before success or execution fail**.
|
84
|
+
|
85
|
+
### :package: Rich Service Objects
|
86
|
+
|
87
|
+
When dealing with services objects, you will get a very rich objects to work with, forgot about getting only `true or false` return values, one of the main purpose of objects it's to keep your code domain logic accessible and reusable, so your application can really take the best approach when responding to actions.
|
88
|
+
|
89
|
+
### :lock: Security - Access Control Level
|
90
|
+
|
91
|
+
Think and implement security rules from the first minutes of live in your applications! NiftyServices strongly rely on **Access Control Level(ACL)** to perform actions, in other words, you will only **allow authorized users to read, create, update or delete records in your database**!
|
92
|
+
|
93
|
+
Now you know the basic concepts and philosophy of `NiftyServices`, lets start working with this candy library?
|
94
|
+
|
95
|
+
---
|
6
96
|
|
7
97
|
## Installation
|
8
98
|
|
@@ -14,28 +104,941 @@ gem 'nifty_services'
|
|
14
104
|
|
15
105
|
And then execute:
|
16
106
|
|
17
|
-
|
107
|
+
$ bundle
|
18
108
|
|
19
109
|
Or install it yourself as:
|
20
110
|
|
21
|
-
|
111
|
+
$ gem install nifty_services
|
112
|
+
|
113
|
+
---
|
22
114
|
|
23
115
|
## Usage
|
24
116
|
|
25
|
-
|
117
|
+
NiftyServices provide a start basic service class for generic code which is `NiftyServices::BaseService`, the very basic service markup is demonstrated below:
|
118
|
+
|
119
|
+
### Basic Service Markup
|
120
|
+
|
121
|
+
|
122
|
+
```ruby
|
123
|
+
class SemanticServiceName < NiftyServices::BaseService
|
124
|
+
|
125
|
+
def execute
|
126
|
+
execute_action do
|
127
|
+
success_response if do_something_complex
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
def do_something_complex
|
132
|
+
# (...) some complex bussiness logic
|
133
|
+
return true
|
134
|
+
end
|
135
|
+
|
136
|
+
private
|
137
|
+
def can_execute?
|
138
|
+
return forbidden_error!('errors.message_key') if some_condition
|
139
|
+
|
140
|
+
return not_found_error!('errors.message_key') if another_condition
|
141
|
+
|
142
|
+
return unprocessable_entity_error('errors.message_key') if other_condition
|
143
|
+
|
144
|
+
# ok, this service can be executed
|
145
|
+
return true
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
service = SemanticServiceName.new(options)
|
150
|
+
service.execute
|
151
|
+
```
|
152
|
+
|
153
|
+
---
|
154
|
+
|
155
|
+
### Ok, real world example plizzz
|
156
|
+
|
157
|
+
Lets work with a real and a little more complex example, an Service responsible to send daily news mail to users.
|
158
|
+
The code below shows basically everything you need to know about services structure, such: entry point, callbacks, authorization, error and success response handling, so after understanding this little piece of code, you will be **ready to code your own services**!
|
159
|
+
|
160
|
+
```ruby
|
161
|
+
class DailyNewsMailSendService < NiftyServices::BaseService
|
162
|
+
|
163
|
+
before_execute do
|
164
|
+
log.info('Routine started at: %s' % Time.now)
|
165
|
+
end
|
166
|
+
|
167
|
+
after_execute do
|
168
|
+
log.info('Routine ended at: %s' % Time.now)
|
169
|
+
end
|
170
|
+
|
171
|
+
after_initialize do
|
172
|
+
user_data = [@user.name, @user.email]
|
173
|
+
log.info('Routine Details: Send daily news email to user %s(%s)' % user_data)
|
174
|
+
end
|
175
|
+
|
176
|
+
after_success do
|
177
|
+
log.info('Success sent daily news feed email to user')
|
178
|
+
end
|
179
|
+
|
180
|
+
before_error do
|
181
|
+
log.warn('Something went wrong')
|
182
|
+
end
|
183
|
+
|
184
|
+
after_error do
|
185
|
+
log.error('Error sending email to user. See details below :(')
|
186
|
+
log.error(errors)
|
187
|
+
end
|
188
|
+
|
189
|
+
attr_reader :user
|
190
|
+
|
191
|
+
def initialize(user, options = {})
|
192
|
+
@user = user
|
193
|
+
super(options)
|
194
|
+
end
|
195
|
+
|
196
|
+
def execute
|
197
|
+
execute_action do
|
198
|
+
success_response if send_mail_to_user
|
199
|
+
end
|
200
|
+
end
|
201
|
+
|
202
|
+
private
|
203
|
+
def can_execute?
|
204
|
+
unless valid_user?
|
205
|
+
# returns false
|
206
|
+
return not_found_error!('users.not_found')
|
207
|
+
end
|
208
|
+
|
209
|
+
unless @user.abble_to_receive_daily_news_mail?
|
210
|
+
# returns false
|
211
|
+
return forbidden_error!('users.yet_received_daily_news_mail')
|
212
|
+
end
|
213
|
+
|
214
|
+
return true
|
215
|
+
end
|
216
|
+
def send_mail_to_user
|
217
|
+
# just to fake, a real implementation could be something like:
|
218
|
+
# @user.send_daily_news_mail!
|
219
|
+
return true
|
220
|
+
end
|
221
|
+
|
222
|
+
def valid_user?
|
223
|
+
# check if object is valid and is a User class type
|
224
|
+
valid_object?(@user, User)
|
225
|
+
end
|
226
|
+
end
|
227
|
+
|
228
|
+
class User < Struct.new(:name, :email)
|
229
|
+
# just to play around with results
|
230
|
+
def abble_to_receive_daily_news_mail?
|
231
|
+
rand(10) < 5
|
232
|
+
end
|
233
|
+
end
|
234
|
+
|
235
|
+
user = User.new('Rafael Fidelis', 'rafa_fidelis@yahoo.com.br')
|
236
|
+
|
237
|
+
# Default logger is NiftyService.config.logger = Logger.new('/dev/null')
|
238
|
+
service = DailyNewsMailSendService.new(user, logger: Logger.new('daily_news.log'))
|
239
|
+
service.execute
|
240
|
+
```
|
241
|
+
|
242
|
+
### Sample outputs results
|
243
|
+
|
244
|
+
#### :smile: Success:
|
245
|
+
|
246
|
+
```
|
247
|
+
I, [2016-07-15T17:13:40.092854 #2480] INFO -- : Routine Details: Send daily news email to user
|
248
|
+
Rafael Fidelis(rafa_fidelis@yahoo.com.br)
|
249
|
+
|
250
|
+
I, [2016-07-15T17:13:40.092987 #2480] INFO -- : Routine started at: 2016-07-15 17:13:40 -0300
|
251
|
+
|
252
|
+
I, [2016-07-15T17:13:40.093143 #2480] INFO -- : Success sent daily news feed email to user
|
253
|
+
|
254
|
+
I, [2016-07-15T17:13:40.093242 #2480] INFO -- : Routine ended at: 2016-07-15 17:13:40 -0300
|
255
|
+
|
256
|
+
|
257
|
+
```
|
258
|
+
|
259
|
+
#### :weary: Error:
|
260
|
+
|
261
|
+
```
|
262
|
+
I, [2016-07-15T17:12:10.954792 #756] INFO -- : Routine Details: Send daily news email to user
|
263
|
+
Rafael Fidelis(rafa_fidelis@yahoo.com.br)
|
264
|
+
|
265
|
+
I, [2016-07-15T17:12:10.955025 #756] INFO -- : Routine started at: 2016-07-15 17:12:10 -0300
|
266
|
+
|
267
|
+
W, [2016-07-15T17:12:10.955186 #756] WARN -- : Something went wrong
|
268
|
+
|
269
|
+
E, [2016-07-15T17:12:11.019645 #756] ERROR -- : Error sending email to user. See details below :(
|
270
|
+
|
271
|
+
E, [2016-07-15T17:12:11.019838 #756] ERROR -- : ["User yet received daily news mail today"]
|
272
|
+
|
273
|
+
I, [2016-07-15T17:12:11.020073 #756] INFO -- : Routine ended at: 2016-07-15 17:12:11 -0300
|
274
|
+
|
275
|
+
```
|
276
|
+
|
277
|
+
<br />
|
278
|
+
|
279
|
+
### Wrapping things up
|
280
|
+
|
281
|
+
The code above demonstrate a very basic example of **how dead easy** is to work with Services, let me clarify some things to your better understanding:
|
282
|
+
|
283
|
+
* ☑ All services classes must inherit from `NiftyServices::BaseService`
|
284
|
+
|
285
|
+
* ☑ For convention(but not a rule) all services must expose only `execute`(and of course, `initialize`) as public methods.
|
286
|
+
|
287
|
+
* ☑ `execute_action(&block)` **MUST** be called to properly setup things in execution context.
|
288
|
+
|
289
|
+
* ☑ `can_execute?` must be **ALWAYS** implemented in service classes, **ALWAYS**, this ensure that your code will **safely runned**.
|
290
|
+
Note: A `NotImplementedError` exception will be raised if service won't define your own `can_execute?` method.
|
291
|
+
|
292
|
+
* ☑ There's a very simple DSL for marking result as success/fail (eg: `unprocessable_entity_error!` or `success_response`).
|
293
|
+
|
294
|
+
* ☑ Simple DSL for actions callbacks inside current execution context. (eg: `after_success` or `before_error`)
|
295
|
+
Note: You don't need to use the DSL if you don't want, you can simply define the methods(such as: `private def after_success; do_something; end`
|
296
|
+
|
297
|
+
This is the very basic concept of creating and executing a service object, now we need to know how to work with responses to get the most of our services, for this, let's digg in the mainly public API methods of `NiftyService::BaseService` class:
|
298
|
+
|
299
|
+
---
|
300
|
+
|
301
|
+
## Services Public API
|
302
|
+
|
303
|
+
Below, a list of most common public accessible methods for any instance of service:
|
304
|
+
(Detailed usage and full API list is available below this section)
|
305
|
+
|
306
|
+
```ruby
|
307
|
+
service.success? # boolean
|
308
|
+
service.fail? # boolean
|
309
|
+
service.errors # array
|
310
|
+
service.response_status # symbol (eg: :ok)
|
311
|
+
service.response_status_code # integer (eg: 200)
|
312
|
+
```
|
313
|
+
|
314
|
+
So, grabbing our `DailyNewsMailSendService` service again, we could do:
|
315
|
+
|
316
|
+
```ruby
|
317
|
+
service = DailyNewsMailSendService.new(User.new('test', 'test@test.com'))
|
318
|
+
service.execute
|
319
|
+
|
320
|
+
if service.success? # or unless service.fail?
|
321
|
+
SentEmails.create(user: service.user, type: 'welcome_email')
|
322
|
+
else
|
323
|
+
puts 'Error sending email, details below:'
|
324
|
+
puts 'Status: %s' % service.response_status
|
325
|
+
puts 'Status code: %s' % service.response_status_code
|
326
|
+
puts service.errors
|
327
|
+
end
|
328
|
+
|
329
|
+
# trying to re-execute the service will return `nil`
|
330
|
+
service.execute
|
331
|
+
```
|
332
|
+
|
333
|
+
This is really great and nifty, no? But we already started, there's some really cool stuff when dealing with **Restful** API's actions, before entering this subject let's see how to handle error and success response.
|
334
|
+
|
335
|
+
---
|
336
|
+
|
337
|
+
## Success & Error Responses
|
338
|
+
|
339
|
+
### :white_check_mark: Handling Success :zap:
|
340
|
+
|
341
|
+
To mark a service running as successfully, you must call one of this methods (preferencially inside of `execute_action` block):
|
342
|
+
|
343
|
+
* `success_response # [200, :ok]`
|
344
|
+
* `success_created_response [201, :created]`
|
345
|
+
|
346
|
+
The first value in comments above is the value which will be defined to `service.response_status_code` and the last is the value set to `service.response_status`.
|
347
|
+
|
348
|
+
---
|
349
|
+
|
350
|
+
|
351
|
+
### :red_circle: Handling Error :boom:
|
352
|
+
|
353
|
+
By default, all services comes with following error methods:
|
354
|
+
(**Hint**: See all available error methods [`here`](lib/nifty_services/configuration.rb#L10-L16))
|
355
|
+
|
356
|
+
```ruby
|
357
|
+
bad_request_error(message_key) # set response_status_code to 400
|
358
|
+
|
359
|
+
not_authorized_error(message_key) # set response_status_code to 401,
|
360
|
+
|
361
|
+
forbidden_error(message_key) # set response_status_code to 403,
|
362
|
+
|
363
|
+
not_found_error(message_key) # set response_status_code to 404,
|
364
|
+
|
365
|
+
unprocessable_entity_error(message_key) # set response_status_code to 422,
|
366
|
+
|
367
|
+
internal_server_error(message_key) # set response_status_code to 500,
|
368
|
+
|
369
|
+
not_implemented_error(message_key) # set response_status_code to 501
|
370
|
+
```
|
371
|
+
|
372
|
+
Beside this methods, you can always use **low level** API to generate errors, just call the `error` method, ex:
|
373
|
+
|
374
|
+
```ruby
|
375
|
+
# API
|
376
|
+
error(status, message_key, options = {})
|
377
|
+
|
378
|
+
# eg:
|
379
|
+
error(409, :conflict_error, reason: 'unkown')
|
380
|
+
error!(409, :conflict_error, reason: 'unkown')
|
381
|
+
|
382
|
+
# suppose you YML locale file have the configuration:
|
383
|
+
# nifty_services:
|
384
|
+
# errors:
|
385
|
+
# conflict_error: 'Conflict! The reason is %{reason}'
|
386
|
+
```
|
387
|
+
|
388
|
+
#### Custom error response methods
|
389
|
+
|
390
|
+
But you can always add new convenience errors methods via API, this way you gain more expressivity and sintax sugar:
|
391
|
+
|
392
|
+
```ruby
|
393
|
+
## API
|
394
|
+
NiftyServices.add_response_error_method(status, status_code)
|
395
|
+
|
396
|
+
## eg:
|
397
|
+
|
398
|
+
NiftyServices.add_response_error_method(:conflict, 409)
|
399
|
+
|
400
|
+
## now you gain the methods:
|
401
|
+
|
402
|
+
## conflict_error(:conflict_error)
|
403
|
+
## conflit_error!(:conflict_error)
|
404
|
+
```
|
405
|
+
|
406
|
+
---
|
407
|
+
|
408
|
+
## CRUD Services
|
409
|
+
|
410
|
+
So, until now we saw how to use `NiftyServices::BaseService` to create generic services to couple specific domain logic for actions, this is very usefull, but things get a lot better when you're working with **CRUD** actions for your api.
|
411
|
+
|
412
|
+
Above, an example of **Create, Update and Delete** CRUD services for `Post` resource:
|
413
|
+
|
414
|
+
## :white_check_mark: CRUD: Create
|
415
|
+
|
416
|
+
```ruby
|
417
|
+
class PostCreateService < NiftyServices::BaseCreateService
|
418
|
+
|
419
|
+
# record_type must be a object respond to :build and :save methods
|
420
|
+
# is possible to access this record outside of service using
|
421
|
+
# `service.record` or `service.post`
|
422
|
+
# if you want to create a custom alias name, use:
|
423
|
+
# record_type Post, alias_name: :user_post
|
424
|
+
# This way, you can access the record using
|
425
|
+
# `service.user_post`
|
426
|
+
|
427
|
+
record_type Post
|
428
|
+
|
429
|
+
WHITELIST_ATTRIBUTES = [:title, :content]
|
430
|
+
|
431
|
+
def record_params_whitelist
|
432
|
+
WHITELIST_ATTRIBUTES
|
433
|
+
end
|
434
|
+
|
435
|
+
def build_record
|
436
|
+
# record_allowed_params auto magically use record_params_whitelist to remove unsafe attributes
|
437
|
+
@user.posts.build(record_allowed_params)
|
438
|
+
end
|
439
|
+
|
440
|
+
# this key is used for I18n translations
|
441
|
+
def record_error_key
|
442
|
+
:posts
|
443
|
+
end
|
444
|
+
|
445
|
+
def user_can_create_record?
|
446
|
+
# (here you can do any kind of validation, eg:)
|
447
|
+
# check if user is trying to recreate a recent resource
|
448
|
+
# this will return false if user yet created a post with
|
449
|
+
# this title in the last 30 seconds (usefull to ban bots)
|
450
|
+
@user.posts.exists(title: record_allowed_params[:title], created_at: "NOW() - interval(30 seconds)")
|
451
|
+
end
|
452
|
+
end
|
453
|
+
|
454
|
+
service = PostCreateService.new(User.first, title: 'Teste', content: 'Post example content')
|
455
|
+
|
456
|
+
service.execute
|
457
|
+
|
458
|
+
service.success? # true
|
459
|
+
service.response_status_code # 200
|
460
|
+
service.response_status # :created
|
461
|
+
```
|
462
|
+
|
463
|
+
#### :earth_americas: I18n setup
|
464
|
+
|
465
|
+
You must have the following keys setup up in your locales files:
|
466
|
+
|
467
|
+
```yml
|
468
|
+
nifty_services:
|
469
|
+
users:
|
470
|
+
not_found: "Invalid or not found user"
|
471
|
+
ip_temporarily_blocked: "This IP is temporarily blocked from creating records"
|
472
|
+
# note: posts is the key return in `record_error_key` service method
|
473
|
+
posts:
|
474
|
+
user_cant_create: "User cant create this record"
|
475
|
+
```
|
476
|
+
|
477
|
+
#### :alien: Invalid user <a name="create-resource-user-invalid"></a>
|
478
|
+
|
479
|
+
If you try to create a post for a invalid user, such as:
|
480
|
+
|
481
|
+
```ruby
|
482
|
+
# PostCreateService.new(user, options)
|
483
|
+
service = PostCreateService.new(nil, options)
|
484
|
+
service.execute
|
485
|
+
|
486
|
+
service.success? # false
|
487
|
+
service.response_status # :not_found
|
488
|
+
service.response_status_code # 404
|
489
|
+
service.errors # ["Invalid or not found user"]
|
490
|
+
```
|
491
|
+
|
492
|
+
#### :no_entry_sign: Not authorized to create
|
493
|
+
|
494
|
+
Or if user is trying to create a duplicate resource:
|
495
|
+
|
496
|
+
```ruby
|
497
|
+
# PostCreateService.new(user, options)
|
498
|
+
service = PostCreateService.new(User.first, options)
|
499
|
+
service.execute
|
500
|
+
|
501
|
+
service.success? # false
|
502
|
+
service.errors # ["User cant create this record"]
|
503
|
+
service.response_status # :forbidden_error
|
504
|
+
service.response_status_code # 400
|
505
|
+
```
|
506
|
+
|
507
|
+
#### :boom: Record is invalid
|
508
|
+
|
509
|
+
Eg: if any validation in Post model won't pass:
|
510
|
+
|
511
|
+
```ruby
|
512
|
+
# PostCreateService.new(user, options)
|
513
|
+
# Post model as the validation:
|
514
|
+
# validates_presence_of :title, :content
|
515
|
+
service = PostCreateService.new(User.first, title: nil, content: nil)
|
516
|
+
service.execute
|
517
|
+
|
518
|
+
service.success? # false
|
519
|
+
|
520
|
+
service.errors # => [{ title: 'is empty', content: 'is empty' }]
|
521
|
+
|
522
|
+
service.response_status # :unprocessable_entity
|
523
|
+
service.response_status_code # 422
|
524
|
+
```
|
525
|
+
---
|
526
|
+
|
527
|
+
## :white_check_mark: CRUD: Update
|
528
|
+
|
529
|
+
```ruby
|
530
|
+
class PostUpdateService < NiftyServices::BaseUpdateService
|
531
|
+
|
532
|
+
# service.post or service.record
|
533
|
+
record_type Post
|
534
|
+
|
535
|
+
WHITELIST_ATTRIBUTES = [:title, :content]
|
536
|
+
|
537
|
+
def record_allowed_params
|
538
|
+
WHITELIST_ATTRIBUTES
|
539
|
+
end
|
540
|
+
|
541
|
+
# by default, internally @record must respond to
|
542
|
+
# user_can_update(user)
|
543
|
+
# so you can do specific validations per resource
|
544
|
+
def user_can_update_record?
|
545
|
+
# only system admins and owner can update this record
|
546
|
+
@user.admin? || @user.id == @record.id
|
547
|
+
end
|
548
|
+
|
549
|
+
def record_error_key
|
550
|
+
:posts
|
551
|
+
end
|
552
|
+
end
|
553
|
+
|
554
|
+
# :user_id will be ignored since it's not in whitelisted attributes
|
555
|
+
# this can safe yourself from parameter inject attacks, by default
|
556
|
+
update_service = PostUpdateService.new(Post.first, User.first, title: 'Changing title', content: 'Updating content', user_id: 2)
|
557
|
+
|
558
|
+
update_service.execute
|
559
|
+
|
560
|
+
update_service.success? # true
|
561
|
+
update_service.response_status # :ok
|
562
|
+
update_service.response_status_code # 200
|
563
|
+
|
564
|
+
update_service.changed_attributes # [:title, :content]
|
565
|
+
update_service.changed? # true
|
566
|
+
```
|
567
|
+
|
568
|
+
#### :earth_asia: I18n setup
|
569
|
+
|
570
|
+
Your locale file must have the following keys:
|
571
|
+
|
572
|
+
```yml
|
573
|
+
posts:
|
574
|
+
not_found: "Invalid or not found post"
|
575
|
+
user_cant_update: "User can't update this record"
|
576
|
+
users:
|
577
|
+
not_found: "Invalid or not found user"
|
578
|
+
```
|
579
|
+
|
580
|
+
#### :alien: User is invalid <a name="update-resource-user-invalid"></a>
|
581
|
+
|
582
|
+
Response when owner user is not valid:
|
583
|
+
|
584
|
+
```ruby
|
585
|
+
# PostUpdateService.new(post, user, params)
|
586
|
+
update_service = PostUpdateService.new(Post.first, nil, title: 'Changing title', content: 'Updating content')
|
587
|
+
|
588
|
+
update_service.execute
|
589
|
+
|
590
|
+
update_service.success? # false
|
591
|
+
update_service.response_status # :not_found_error
|
592
|
+
update_service.response_status_code # 404
|
593
|
+
|
594
|
+
update_service.errors # ["Invalid or not found user"]
|
595
|
+
```
|
596
|
+
|
597
|
+
#### :closed_lock_with_key: Resource (Post) don't belongs to user <a name="update-resource-dont-belongs-to-user"></a>
|
598
|
+
|
599
|
+
Responses when trying to update to update a resource who don't belongs to owner:
|
600
|
+
|
601
|
+
```ruby
|
602
|
+
# PostUpdateService.new(post, user, params)
|
603
|
+
update_service = PostUpdateService.new(Post.first, User.last, title: 'Changing title', content: 'Updating content')
|
604
|
+
|
605
|
+
update_service.execute
|
606
|
+
|
607
|
+
update_service.success? # false
|
608
|
+
update_service.response_status # :forbidden
|
609
|
+
update_service.response_status_code # 400
|
610
|
+
|
611
|
+
update_service.changed_attributes # []
|
612
|
+
update_service.changed? # false
|
613
|
+
update_service.errors # ["User can't update this record"]
|
614
|
+
```
|
615
|
+
|
616
|
+
#### :santa: Resource don't exists <a name="update-resource-dont-exists"></a>
|
617
|
+
|
618
|
+
Response when post don't exists:
|
619
|
+
|
620
|
+
```ruby
|
621
|
+
# PostUpdateService.new(post, user, params)
|
622
|
+
update_service = PostUpdateService.new(nil, User.last, title: 'Changing title', content: 'Updating content')
|
623
|
+
|
624
|
+
update_service.execute
|
625
|
+
|
626
|
+
update_service.success? # false
|
627
|
+
update_service.response_status # :not_found_error
|
628
|
+
update_service.response_status_code # 404
|
629
|
+
|
630
|
+
update_service.errors # ["Invalid or not found post"]
|
631
|
+
```
|
632
|
+
|
633
|
+
---
|
634
|
+
|
635
|
+
## :white_check_mark: CRUD: Delete
|
636
|
+
|
637
|
+
```ruby
|
638
|
+
class PostDeleteService < NiftyServices::BaseDeleteService
|
639
|
+
# record_type object must respond to :destroy or :delete method
|
640
|
+
record_type Post
|
641
|
+
|
642
|
+
def record_error_key
|
643
|
+
:posts
|
644
|
+
end
|
645
|
+
|
646
|
+
# below the code used internally, you can override to
|
647
|
+
# create custom delete, but remembers that this method
|
648
|
+
# must return a boolean value
|
649
|
+
def destroy_record
|
650
|
+
@record.try(:destroy) || @record.try(:delete)
|
651
|
+
end
|
652
|
+
|
653
|
+
# by default, internally @record must respond to
|
654
|
+
# @record.user_can_delete?(user)
|
655
|
+
# so you can do specific validations per resource
|
656
|
+
def user_can_delete_record?
|
657
|
+
# only system admins and owner can delete this record
|
658
|
+
@user.admin? || @user.id == @record.id
|
659
|
+
end
|
660
|
+
end
|
661
|
+
```
|
662
|
+
|
663
|
+
#### :earth_africa: I18n setup
|
664
|
+
|
665
|
+
Your locale file must have the following keys:
|
666
|
+
|
667
|
+
```yml
|
668
|
+
posts:
|
669
|
+
not_found: "Invalid or not found post"
|
670
|
+
user_cant_delete: "User can't delete this record"
|
671
|
+
users:
|
672
|
+
not_found: "Invalid or not found user"
|
673
|
+
```
|
674
|
+
|
675
|
+
#### :alien: User is invalid <a name="delete-resource-user-invalid"></a>
|
676
|
+
|
677
|
+
Response when owner user is not valid:
|
678
|
+
|
679
|
+
```ruby
|
680
|
+
# PostDeleteService.new(post, user, params)
|
681
|
+
delete_service = PostDeleteService.new(Post.first, nil)
|
682
|
+
|
683
|
+
delete_service.execute
|
684
|
+
|
685
|
+
delete_service.success? # false
|
686
|
+
delete_service.response_status # :not_found_error
|
687
|
+
delete_service.response_status_code # 404
|
688
|
+
|
689
|
+
delete_service.errors # ["Invalid or not found user"]
|
690
|
+
```
|
691
|
+
|
692
|
+
#### :closed_lock_with_key: Resource don't belongs to user <a name="delete-resource-dont-belongs-to-user"></a>
|
693
|
+
|
694
|
+
Responses when trying to delete a resource who don't belongs to owner:
|
695
|
+
|
696
|
+
```ruby
|
697
|
+
# PostDeleteService.new(post, user, params)
|
698
|
+
delete_service = PostDeleteService.new(Post.first, User.last)
|
699
|
+
delete_service.execute
|
700
|
+
|
701
|
+
delete_service.success? # false
|
702
|
+
delete_service.response_status # :forbidden
|
703
|
+
delete_service.response_status_code # 400
|
704
|
+
delete_service.errors # ["User can't delete this record"]
|
705
|
+
```
|
706
|
+
|
707
|
+
#### :santa: Resource(Post) don't exists <a name="delete-resource-dont-exists"></a>
|
708
|
+
|
709
|
+
Response when post don't exists:
|
710
|
+
|
711
|
+
```ruby
|
712
|
+
# PostDeleteService.new(post, user, params)
|
713
|
+
delete_service = PostDeleteService.new(nil, User.last)
|
714
|
+
|
715
|
+
delete_service.execute
|
716
|
+
|
717
|
+
delete_service.success? # false
|
718
|
+
delete_service.response_status # :not_found_error
|
719
|
+
delete_service.response_status_code # 404
|
720
|
+
|
721
|
+
delete_service.errors # ["Invalid or not found post"]
|
722
|
+
```
|
723
|
+
|
724
|
+
|
725
|
+
---
|
726
|
+
|
727
|
+
## :us: :fr: :jp: I18n Support :uk: :es: :de:
|
728
|
+
|
729
|
+
As you see in the above examples, with `NiftyServices` you can respond in multiples languages for the same service error messages, by default your locales config file must be configured as:
|
730
|
+
|
731
|
+
```yml
|
732
|
+
# attention: dont use `resource_type`
|
733
|
+
# use the key setup up in `record_error_key` methods
|
734
|
+
resource_type:
|
735
|
+
not_found: "Invalid or not found post"
|
736
|
+
user_cant_create: "User can't delete this record"
|
737
|
+
user_cant_read: "User can't access this record"
|
738
|
+
user_cant_update: "User can't delete this record"
|
739
|
+
user_cant_delete: "User can't delete this record"
|
740
|
+
users:
|
741
|
+
not_found: "Invalid or not found user"
|
742
|
+
```
|
743
|
+
|
744
|
+
You can configure the default I18n namespace using configuration:
|
745
|
+
|
746
|
+
```ruby
|
747
|
+
NiftyServies.configure do |config|
|
748
|
+
config.i18n_namespace = :my_app
|
749
|
+
end
|
750
|
+
```
|
751
|
+
|
752
|
+
Example config for `Post` and `Comment` resources using `my_app` locale namespace:
|
753
|
+
|
754
|
+
```yml
|
755
|
+
# default is nifty_services
|
756
|
+
my_app:
|
757
|
+
errors:
|
758
|
+
default_crud: &default_crud
|
759
|
+
user_cant_create: "User can't delete this record"
|
760
|
+
user_cant_read: "User can't access this record"
|
761
|
+
user_cant_update: "User can't delete this record"
|
762
|
+
user_cant_delete: "User can't delete this record"
|
763
|
+
users:
|
764
|
+
not_found: "Invalid or not found user"
|
765
|
+
posts:
|
766
|
+
<<: *default_crud
|
767
|
+
not_found: "Invalid or not found post"
|
768
|
+
comments:
|
769
|
+
<<: *default_crud
|
770
|
+
not_found: "Invalid or not found comment"
|
771
|
+
```
|
772
|
+
|
773
|
+
---
|
774
|
+
|
775
|
+
## Callbacks
|
776
|
+
|
777
|
+
Here the most common callbacks list you can use to hook actions in run-time:
|
778
|
+
(**Hint**: See all existent callbacks definitions in [`extensions/callbacks_interface.rb`](lib/nifty_services/extensions/callbacks_interface.rb#L8-L24) file)
|
779
|
+
|
780
|
+
```
|
781
|
+
- before_initialize
|
782
|
+
- after_initialize
|
783
|
+
- before_execute
|
784
|
+
- after_execute
|
785
|
+
- before_error
|
786
|
+
- after_error
|
787
|
+
- before_success
|
788
|
+
- after_success
|
789
|
+
```
|
790
|
+
|
791
|
+
### Creating custom Callbacks
|
792
|
+
|
793
|
+
Well, probably you will need to add custom callbacks to your services, in my case I need to save in database an object which tracks information about the environment used to create **ALL RECORDS** in my application, I was able to do it with just a few lines of code, see for yourself:
|
794
|
+
|
795
|
+
```ruby
|
796
|
+
# Some monkey patch :)
|
797
|
+
|
798
|
+
NiftyServices::BaseCreateService.class_eval do
|
799
|
+
ORIGIN_WHITELIST_ATTRIBUTES = [:provider, :locale, :user_agent, :ip]
|
800
|
+
|
801
|
+
def origin_params(params = {})
|
802
|
+
filter_hash(params.fetch(:origin, {}).to_h, ORIGIN_WHITELIST_ATTRIBUTES)
|
803
|
+
end
|
804
|
+
|
805
|
+
def create_origin(originable, params = {})
|
806
|
+
return unless originable.respond_to?(:create_origin)
|
807
|
+
return unless create_origin?
|
808
|
+
|
809
|
+
originable.create_origin(origin_params(params))
|
810
|
+
end
|
811
|
+
|
812
|
+
# for records which we don't need to create origins, just
|
813
|
+
# overwrite this method inside service class turning it off with:
|
814
|
+
# return false
|
815
|
+
def create_origin?
|
816
|
+
Application::Config.create_origin_for_records
|
817
|
+
end
|
818
|
+
end
|
819
|
+
|
820
|
+
# This register an callback for ALL services who inherit from `NiftyServices::BaseCreateService`
|
821
|
+
# In other words: Every and all records created in my application will be tracked
|
822
|
+
# I can believe that's is easy like this, I need a beer right now!
|
823
|
+
NiftyServices::BaseCreateService.register_callback(:after_success, :create_origin_for_record) do
|
824
|
+
create_origin(@record, @options)
|
825
|
+
end
|
826
|
+
|
827
|
+
```
|
828
|
+
|
829
|
+
Now, every record created in my application will have an associated `origin` object, really simple and cool!
|
830
|
+
|
831
|
+
---
|
832
|
+
|
833
|
+
## :construction: Configuration :construction:
|
834
|
+
|
835
|
+
There are only a few things you must want and have to configure for your services work properly, below you can see all needed configuration:
|
836
|
+
|
837
|
+
```ruby
|
838
|
+
NiftyServices.config do |config|
|
839
|
+
# [optional - but very recommend! Please, do it]
|
840
|
+
# class used to control ACL
|
841
|
+
config.user_class = User
|
842
|
+
|
843
|
+
# [optional]
|
844
|
+
# global logger for all services
|
845
|
+
# [Default: Logger.new('/dev/null')]
|
846
|
+
config.logger = Logger.new('log/services_logger.log')
|
847
|
+
|
848
|
+
# [optional]
|
849
|
+
# Namespace to lookup when using concerns with services
|
850
|
+
# [Default: 'NitfyServices::Concerns']
|
851
|
+
config.service_concerns_namespace = "Services::V1::Concerns"
|
852
|
+
|
853
|
+
end
|
854
|
+
```
|
855
|
+
|
856
|
+
---
|
857
|
+
|
858
|
+
## Web Frameworks Integrations
|
859
|
+
|
860
|
+
### Rails <a name="frameworks-rails"></a>
|
861
|
+
|
862
|
+
You need a very minimal setup to integrate with you existing or new Rails application. I prefer to put my services files inside the `lib/services` folder, cause this allow better namespacing configuration over `app/services`, but this is up to you to decide.
|
863
|
+
|
864
|
+
First thing to do is add `lib/` folder in `autoload` path, place the following in your `config/application.rb`
|
865
|
+
|
866
|
+
```ruby
|
867
|
+
# config/application.rb
|
868
|
+
config.paths.add(File.join(Rails.root, 'lib'), glob: File.join('**', '*.rb'))
|
869
|
+
|
870
|
+
config.autoload_paths << Rails.root.join('lib')
|
871
|
+
```
|
872
|
+
|
873
|
+
Second, create `lib/services` directory:
|
874
|
+
|
875
|
+
`$ mkdir -p lib/services/v1/users`
|
876
|
+
|
877
|
+
Next, configure:
|
878
|
+
**Note**: See Configurations section below to see all available configs
|
879
|
+
|
880
|
+
```ruby
|
881
|
+
NiftyServices.configure do |config|
|
882
|
+
config.user_class = User
|
883
|
+
end
|
884
|
+
```
|
885
|
+
|
886
|
+
Create your first service:
|
887
|
+
|
888
|
+
```
|
889
|
+
$ touch lib/services/v1/users/create_service.rb
|
890
|
+
```
|
891
|
+
|
892
|
+
Use in your controller:
|
893
|
+
|
894
|
+
```ruby
|
895
|
+
class UsersController < BaseController
|
896
|
+
def create
|
897
|
+
service = Services::V1::Users::CreateService.new(params).execute
|
26
898
|
|
27
|
-
|
899
|
+
default_response = { status: service.response_status, status_code: service.response_status_code }
|
900
|
+
|
901
|
+
if service.success?
|
902
|
+
response = { user: service.user, subscription: service.subscription }
|
903
|
+
else
|
904
|
+
response = { error: true, errors: service.errors }
|
905
|
+
end
|
906
|
+
|
907
|
+
render json: default_response.merge(response), status: service.response_status
|
908
|
+
end
|
909
|
+
end
|
910
|
+
```
|
911
|
+
|
912
|
+
This can be even better if you move response code to an helper:
|
913
|
+
|
914
|
+
```ruby
|
915
|
+
# helpers/users_helper.rb
|
916
|
+
module UsersHelper
|
917
|
+
|
918
|
+
def response_for_user_create_service(service)
|
919
|
+
success_response = { user: service.user, subscription: service.subscription }
|
920
|
+
generic_response_for_service(service, success_response)
|
921
|
+
end
|
922
|
+
|
923
|
+
# THIS IS GREAT, you can use this method to standartize ALL of your
|
924
|
+
# endpoints responses, THIS IS SO FUCKING COOL!
|
925
|
+
def generic_response_for_service(service, success_response)
|
926
|
+
default_response = {
|
927
|
+
status: service.response_status,
|
928
|
+
status_code: service.response_status_code,
|
929
|
+
success: service.success?
|
930
|
+
}
|
931
|
+
|
932
|
+
if service.success?
|
933
|
+
response = success_response
|
934
|
+
else
|
935
|
+
response = {
|
936
|
+
error: true,
|
937
|
+
errors: service.errors
|
938
|
+
}
|
939
|
+
end
|
940
|
+
|
941
|
+
default_response.merge(response)
|
942
|
+
end
|
943
|
+
end
|
944
|
+
```
|
945
|
+
|
946
|
+
Changing controller again: (looks so readable now <3)
|
947
|
+
|
948
|
+
```ruby
|
949
|
+
# controllers/users_controller.rb
|
950
|
+
class UsersController < BaseController
|
951
|
+
def create
|
952
|
+
service = Services::V1::Users::CreateService.new(params).execute
|
953
|
+
|
954
|
+
render json: response_for_user_create_service(service), status: service.response_status
|
955
|
+
end
|
956
|
+
end
|
957
|
+
```
|
958
|
+
|
959
|
+
Well done sir! Did you read the comments in `generic_response_for_service`? Read it and think a little about this and prepare yourself for having orgasms when you realize how fuck awesome this will be for your API's. Need mode? Checkout [Sample Standartized API with NiftyServices Repository](http://github.com/fidelisrafael/nifty_services-api_sample)
|
960
|
+
|
961
|
+
---
|
962
|
+
|
963
|
+
### Grape/Sinatra/Padrino/Hanami/Rack <a name="frameworks-rack"></a>
|
964
|
+
|
965
|
+
Well, the integration here don't variate too much from Rails, just follow the steps:
|
966
|
+
|
967
|
+
**1 -** Decide where you'll put your services
|
968
|
+
**2 -** Code that dam amazing services!
|
969
|
+
**3 -** Instantiate the service in your framework entry point
|
970
|
+
**4 -** Create helpers to handle service response
|
971
|
+
**5 -** Be happy and go party!
|
972
|
+
|
973
|
+
Need examples? Check out one of the following repositories:
|
974
|
+
|
975
|
+
NiftyServices - Rails Sample
|
976
|
+
NiftyServices - Grape Sample
|
977
|
+
NiftyServices - Sinatra Sample
|
978
|
+
|
979
|
+
---
|
980
|
+
|
981
|
+
## Full Public API methods list
|
982
|
+
|
983
|
+
You can use any of the methods above with your `services instances`:
|
984
|
+
|
985
|
+
```ruby
|
986
|
+
service.success? # boolean
|
987
|
+
service.fail? # boolean
|
988
|
+
|
989
|
+
service.errors # hash
|
990
|
+
service.add_error(error) # array
|
991
|
+
|
992
|
+
service.response_status # symbol (eg: :ok)
|
993
|
+
service.response_status_code # integer (eg: 200)
|
994
|
+
|
995
|
+
service.changed_attributes # array
|
996
|
+
service.changed? # boolean
|
997
|
+
|
998
|
+
service.callback_fired?(callback_name) # boolean
|
999
|
+
service.register_callback(name, method, &block) # nil
|
1000
|
+
service.register_callback_action(&block) # nil
|
1001
|
+
|
1002
|
+
service.option_exists?(option_name) # boolean
|
1003
|
+
service.option_enabled?(option_name) # boolean
|
1004
|
+
service.option_disabled?(option_name) # boolean
|
1005
|
+
```
|
1006
|
+
|
1007
|
+
---
|
1008
|
+
|
1009
|
+
## :question: CLI Generators <a name="cli-generators"></a>
|
1010
|
+
|
1011
|
+
Currently NiftyServices don't have CLI(command line interface) generators, but is in the roadmap, so keep your eyes here!
|
1012
|
+
|
1013
|
+
---
|
1014
|
+
|
1015
|
+
## :calendar: Roadmap <a name="roadmap"></a>
|
1016
|
+
|
1017
|
+
- :white_medium_small_square: Remove ActiveSupport dependency
|
1018
|
+
- :white_medium_small_square: Create CLI Generators
|
1019
|
+
- :white_medium_small_square: Document `BaseActionService`
|
1020
|
+
- :white_medium_small_square: Write Sample Applications
|
1021
|
+
- :white_medium_small_square: Write better tests for all `Crud Services`
|
1022
|
+
- :white_medium_small_square: Write better tests for `BaseActionServices`
|
1023
|
+
- :white_medium_small_square: Write tests for Configuration
|
1024
|
+
- :white_medium_small_square: Write tests for Callbacks
|
1025
|
+
|
1026
|
+
---
|
1027
|
+
|
1028
|
+
## :computer: Development
|
28
1029
|
|
29
1030
|
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake rspec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
30
1031
|
|
31
|
-
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
1032
|
+
To install this gem(:gem:) onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
32
1033
|
|
33
|
-
|
1034
|
+
---
|
34
1035
|
|
35
|
-
|
1036
|
+
## :thumbsup: Contributing
|
36
1037
|
|
1038
|
+
Bug reports and pull requests are welcome on GitHub at http://github.com/fidelisrafael/nifty_services. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](contributor-covenant.org) code of conduct.
|
37
1039
|
|
38
|
-
|
1040
|
+
---
|
39
1041
|
|
40
|
-
|
1042
|
+
## :memo: License
|
41
1043
|
|
1044
|
+
The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
|