pragma-operation 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +9 -0
- data/.rspec +3 -0
- data/.rubocop.yml +87 -0
- data/.travis.yml +5 -0
- data/Gemfile +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +43 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/doc/01-basic-usage.md +145 -0
- data/doc/02-contracts.md +92 -0
- data/doc/03-policies.md +94 -0
- data/lib/pragma/operation.rb +15 -0
- data/lib/pragma/operation/authorization.rb +78 -0
- data/lib/pragma/operation/base.rb +237 -0
- data/lib/pragma/operation/validation.rb +90 -0
- data/lib/pragma/operation/version.rb +6 -0
- data/pragma-operation.gemspec +31 -0
- metadata +161 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 7fcd5701aaa0aa49b20295bd71d6ddc588497703
|
4
|
+
data.tar.gz: 93ba0c1bc4babd8702c1cf60157ed2ea9d9ec0fd
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 0ef5a8d2a21ec06508545284dd72d51fe9a4a964b3003bb188def99b642a4c0f06213f308fbb69c4e0460f2eab33f36171e79b3d46d574829d0bfdb48315e937
|
7
|
+
data.tar.gz: 02c4231ed617768e829dc551c3e7d40e1da8aca12808d2f6c7813ffdf124a8216662bc847f83fd3b642211b30ec86c0e09671864c49aa35f5c25010787dd81bc
|
data/.gitignore
ADDED
data/.rspec
ADDED
data/.rubocop.yml
ADDED
@@ -0,0 +1,87 @@
|
|
1
|
+
require: rubocop-rspec
|
2
|
+
|
3
|
+
AllCops:
|
4
|
+
TargetRubyVersion: 2.3
|
5
|
+
Include:
|
6
|
+
- '**/Gemfile'
|
7
|
+
- '**/Rakefile'
|
8
|
+
Exclude:
|
9
|
+
- 'bin/*'
|
10
|
+
- 'db/**/*'
|
11
|
+
- 'vendor/bundle/**/*'
|
12
|
+
- 'spec/spec_helper.rb'
|
13
|
+
- 'spec/rails_helper.rb'
|
14
|
+
- 'spec/support/**/*'
|
15
|
+
- 'config/**/*'
|
16
|
+
- '**/Rakefile'
|
17
|
+
- '**/Gemfile'
|
18
|
+
|
19
|
+
RSpec/DescribeClass:
|
20
|
+
Exclude:
|
21
|
+
- 'spec/requests/**/*'
|
22
|
+
|
23
|
+
Style/BlockDelimiters:
|
24
|
+
Exclude:
|
25
|
+
- 'spec/**/*'
|
26
|
+
|
27
|
+
Style/AlignParameters:
|
28
|
+
EnforcedStyle: with_fixed_indentation
|
29
|
+
|
30
|
+
Style/ClosingParenthesisIndentation:
|
31
|
+
Enabled: false
|
32
|
+
|
33
|
+
Metrics/LineLength:
|
34
|
+
Max: 100
|
35
|
+
AllowURI: true
|
36
|
+
|
37
|
+
Style/FirstParameterIndentation:
|
38
|
+
Enabled: false
|
39
|
+
|
40
|
+
Style/MultilineMethodCallIndentation:
|
41
|
+
EnforcedStyle: indented
|
42
|
+
|
43
|
+
Style/IndentArray:
|
44
|
+
EnforcedStyle: consistent
|
45
|
+
|
46
|
+
Style/IndentHash:
|
47
|
+
EnforcedStyle: consistent
|
48
|
+
|
49
|
+
Style/SignalException:
|
50
|
+
EnforcedStyle: semantic
|
51
|
+
|
52
|
+
Style/BracesAroundHashParameters:
|
53
|
+
EnforcedStyle: context_dependent
|
54
|
+
|
55
|
+
Lint/EndAlignment:
|
56
|
+
AlignWith: variable
|
57
|
+
AutoCorrect: true
|
58
|
+
|
59
|
+
Style/AndOr:
|
60
|
+
EnforcedStyle: conditionals
|
61
|
+
|
62
|
+
Style/MultilineBlockChain:
|
63
|
+
Enabled: false
|
64
|
+
|
65
|
+
RSpec/NamedSubject:
|
66
|
+
Enabled: false
|
67
|
+
|
68
|
+
RSpec/ExampleLength:
|
69
|
+
Enabled: false
|
70
|
+
|
71
|
+
Style/MultilineMethodCallBraceLayout:
|
72
|
+
Enabled: false
|
73
|
+
|
74
|
+
Metrics/MethodLength:
|
75
|
+
Enabled: false
|
76
|
+
|
77
|
+
Metrics/AbcSize:
|
78
|
+
Enabled: false
|
79
|
+
|
80
|
+
Metrics/PerceivedComplexity:
|
81
|
+
Enabled: false
|
82
|
+
|
83
|
+
Metrics/CyclomaticComplexity:
|
84
|
+
Enabled: false
|
85
|
+
|
86
|
+
Metrics/ClassLength:
|
87
|
+
Enabled: false
|
data/.travis.yml
ADDED
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2016 Alessandro Desantis
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,43 @@
|
|
1
|
+
# Pragma::Operation
|
2
|
+
|
3
|
+
[](https://travis-ci.org/pragmarb/pragma-operation)
|
4
|
+
[](https://gemnasium.com/github.com/pragmarb/pragma-operation)
|
5
|
+
[](https://codeclimate.com/github/pragmarb/pragma-operation)
|
6
|
+
[](https://coveralls.io/github/pragmarb/pragma-operation)
|
7
|
+
|
8
|
+
Operations encapsulate the business logic of your JSON API.
|
9
|
+
|
10
|
+
They are built on top of the awesome [Interactor](https://github.com/collectiveidea/interactor) gem.
|
11
|
+
|
12
|
+
## Installation
|
13
|
+
|
14
|
+
Add this line to your application's Gemfile:
|
15
|
+
|
16
|
+
```ruby
|
17
|
+
gem 'pragma-operation'
|
18
|
+
```
|
19
|
+
|
20
|
+
And then execute:
|
21
|
+
|
22
|
+
```console
|
23
|
+
$ bundle
|
24
|
+
```
|
25
|
+
|
26
|
+
Or install it yourself as:
|
27
|
+
|
28
|
+
```console
|
29
|
+
$ gem install pragma-operation
|
30
|
+
```
|
31
|
+
|
32
|
+
## Usage
|
33
|
+
|
34
|
+
All documentation is in the [doc](https://github.com/pragmarb/pragma-operation/tree/master/doc)
|
35
|
+
folder.
|
36
|
+
|
37
|
+
## Contributing
|
38
|
+
|
39
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/pragmarb/pragma-operation.
|
40
|
+
|
41
|
+
## License
|
42
|
+
|
43
|
+
The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
data/bin/console
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "pragma/operation"
|
5
|
+
|
6
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
7
|
+
# with your gem easier. You can also use a different console, if you like.
|
8
|
+
|
9
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
10
|
+
# require "pry"
|
11
|
+
# Pry.start
|
12
|
+
|
13
|
+
require "irb"
|
14
|
+
IRB.start
|
data/bin/setup
ADDED
@@ -0,0 +1,145 @@
|
|
1
|
+
# Basic usage
|
2
|
+
|
3
|
+
Here's the simplest operation you can write:
|
4
|
+
|
5
|
+
```ruby
|
6
|
+
module API
|
7
|
+
module V1
|
8
|
+
module Ping
|
9
|
+
module Operation
|
10
|
+
class Create < Pragma::Operation::Base
|
11
|
+
def call
|
12
|
+
respond_with status: :ok, resource: { pong: params[:pong] }
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
```
|
20
|
+
|
21
|
+
Here's how you use it:
|
22
|
+
|
23
|
+
```ruby
|
24
|
+
result = API::V1::Ping::Operation::Create.call(params: { pong: 'HELLO' })
|
25
|
+
|
26
|
+
result.status # => :ok
|
27
|
+
result.resource # => { pong: 'HELLO' }
|
28
|
+
```
|
29
|
+
|
30
|
+
As you can see, an operation takes parameters as input and responds with:
|
31
|
+
|
32
|
+
- an HTTP status code;
|
33
|
+
- (optional) a resource (i.e. an object implementing `#to_json`).
|
34
|
+
|
35
|
+
If you don't want to return a resource, you can use the `#head` shortcut:
|
36
|
+
|
37
|
+
```ruby
|
38
|
+
module API
|
39
|
+
module V1
|
40
|
+
module Ping
|
41
|
+
module Operation
|
42
|
+
class Create < Pragma::Operation::Base
|
43
|
+
def call
|
44
|
+
head :no_content
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
```
|
52
|
+
|
53
|
+
Since Pragma::Operation is built on top of [Interactor](https://github.com/collectiveidea/interactor),
|
54
|
+
you should consult its documentation for the basic usage of operations; the rest of this section
|
55
|
+
only covers the features provided specifically by Pragma::Operation.
|
56
|
+
|
57
|
+
## Handling errors
|
58
|
+
|
59
|
+
You can use the `#success?` and `#failure?` method to check whether an operation was successful. An
|
60
|
+
operation is considered successful when it returns a 2xx or 3xx status code:
|
61
|
+
|
62
|
+
```ruby
|
63
|
+
module API
|
64
|
+
module V1
|
65
|
+
module Ping
|
66
|
+
module Operation
|
67
|
+
class Create < Pragma::Operation::Base
|
68
|
+
def call
|
69
|
+
if params[:pong].blank?
|
70
|
+
return respond_with(
|
71
|
+
status: :unprocessable_entity,
|
72
|
+
resource: {
|
73
|
+
error_type: :missing_pong,
|
74
|
+
error_message: "You must provide a 'pong' parameter."
|
75
|
+
}
|
76
|
+
)
|
77
|
+
end
|
78
|
+
|
79
|
+
respond_with status: :ok, resource: { pong: params[:pong] }
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
```
|
87
|
+
|
88
|
+
Once more, here's an example usage of the above operation:
|
89
|
+
|
90
|
+
```ruby
|
91
|
+
result1 = API::V1::Ping::Operation::Create.call(params: { pong: '' })
|
92
|
+
result1.success? # => false
|
93
|
+
|
94
|
+
result2 = API::V1::Ping::Operation::Create.call(params: { pong: 'HELLO' })
|
95
|
+
result2.success? # => true
|
96
|
+
```
|
97
|
+
|
98
|
+
## Halting the execution
|
99
|
+
|
100
|
+
Both `#respond_with` and `#head` provide bang counterparts that halt the execution of the operation.
|
101
|
+
They are useful, for instance, in before callbacks.
|
102
|
+
|
103
|
+
The above operation can be rewritten like this:
|
104
|
+
|
105
|
+
```ruby
|
106
|
+
module API
|
107
|
+
module V1
|
108
|
+
module Ping
|
109
|
+
module Operation
|
110
|
+
class Create < Pragma::Operation::Base
|
111
|
+
before :validate_params
|
112
|
+
|
113
|
+
def call
|
114
|
+
respond_with status: :ok, resource: { pong: params[:pong] }
|
115
|
+
end
|
116
|
+
|
117
|
+
private
|
118
|
+
|
119
|
+
def validate_params
|
120
|
+
if params[:pong].blank?
|
121
|
+
respond_with!(
|
122
|
+
status: :unprocessable_entity,
|
123
|
+
resource: {
|
124
|
+
error_type: :missing_pong,
|
125
|
+
error_message: "You must provide a 'pong' parameter."
|
126
|
+
}
|
127
|
+
)
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
135
|
+
```
|
136
|
+
|
137
|
+
The result is identical:
|
138
|
+
|
139
|
+
```ruby
|
140
|
+
result1 = API::V1::Ping::Operation::Create.call(params: { pong: '' })
|
141
|
+
result1.success? # => false
|
142
|
+
|
143
|
+
result2 = API::V1::Ping::Operation::Create.call(params: { pong: 'HELLO' })
|
144
|
+
result2.success? # => true
|
145
|
+
```
|
data/doc/02-contracts.md
ADDED
@@ -0,0 +1,92 @@
|
|
1
|
+
# Contracts
|
2
|
+
|
3
|
+
Operations integrate with [Pragma::Contract](https://github.com/pragmarb/pragma-contract). You can
|
4
|
+
specify the contract to use with `#contract` and get access to `#validate` and `#validate!` in your
|
5
|
+
operations:
|
6
|
+
|
7
|
+
```ruby
|
8
|
+
module API
|
9
|
+
module V1
|
10
|
+
module Post
|
11
|
+
module Operation
|
12
|
+
class Create < Pragma::Operation::Base
|
13
|
+
contract API::V1::Post::Contract::Create
|
14
|
+
|
15
|
+
def call
|
16
|
+
post = Post.new
|
17
|
+
|
18
|
+
validate! contract
|
19
|
+
contract.save
|
20
|
+
|
21
|
+
respond_with status: :created, resource: post
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
```
|
29
|
+
|
30
|
+
If the contract passes validation, then all is good. If not, an error is raised:
|
31
|
+
|
32
|
+
```ruby
|
33
|
+
result1 = API::V1::Post::Operation::Create.call(params: {
|
34
|
+
title: 'My First Post',
|
35
|
+
body: 'Hello everyone, this is my first post!'
|
36
|
+
})
|
37
|
+
|
38
|
+
result1.status # => :created
|
39
|
+
result1.resource
|
40
|
+
# => {
|
41
|
+
# 'title' => 'My First Post'
|
42
|
+
# 'body' => 'Hello everyone, this is my first post!'
|
43
|
+
# }
|
44
|
+
|
45
|
+
result2 = API::V1::Post::Operation::Create.call(params: {
|
46
|
+
title: 'My First Post'
|
47
|
+
})
|
48
|
+
|
49
|
+
result2.status # => :forbidden
|
50
|
+
result2.resource
|
51
|
+
# => {
|
52
|
+
# 'error_type' => 'unprocessable_entity',
|
53
|
+
# 'error_message' => 'The contract for this operation was not respected.',
|
54
|
+
# 'meta' => {
|
55
|
+
# 'errors' => {
|
56
|
+
# 'body' => ["can't be blank"]
|
57
|
+
# }
|
58
|
+
# }
|
59
|
+
# }
|
60
|
+
```
|
61
|
+
|
62
|
+
You can also use the non-bang method `#validate` to implement your own logic:
|
63
|
+
|
64
|
+
```ruby
|
65
|
+
module API
|
66
|
+
module V1
|
67
|
+
module Post
|
68
|
+
module Operation
|
69
|
+
class Create < Pragma::Operation::Base
|
70
|
+
contract API::V1::Post::Contract::Create
|
71
|
+
|
72
|
+
def call
|
73
|
+
post = Post.new
|
74
|
+
contract = build_contract(post)
|
75
|
+
|
76
|
+
unless validate(contract)
|
77
|
+
respond_with!(
|
78
|
+
status: :unprocessable_entity,
|
79
|
+
resource: nil
|
80
|
+
)
|
81
|
+
end
|
82
|
+
|
83
|
+
contract.save
|
84
|
+
|
85
|
+
respond_with status: :created, resource: post
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
```
|
data/doc/03-policies.md
ADDED
@@ -0,0 +1,94 @@
|
|
1
|
+
# Policies
|
2
|
+
|
3
|
+
Operations integrate with [Pragma::Policy](https://github.com/pragmarb/pragma-policy). All you have
|
4
|
+
to do is specify the policy class with `#policy`. This will give you access to `#authorize` and
|
5
|
+
`#authorize!`:
|
6
|
+
|
7
|
+
```ruby
|
8
|
+
module API
|
9
|
+
module V1
|
10
|
+
module Post
|
11
|
+
module Operation
|
12
|
+
class Create < Pragma::Operation::Base
|
13
|
+
policy API::V1::Post::Policy
|
14
|
+
|
15
|
+
def call
|
16
|
+
post = Post.new(params)
|
17
|
+
authorize! post
|
18
|
+
|
19
|
+
post.save!
|
20
|
+
|
21
|
+
respond_with status: :created, resource: post
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
```
|
29
|
+
|
30
|
+
Of course, you will now have to pass the user performing the operation in addition to the usual
|
31
|
+
parameters:
|
32
|
+
|
33
|
+
```ruby
|
34
|
+
result1 = API::V1::Post::Operation::Create.call(
|
35
|
+
params: {
|
36
|
+
title: 'My First Post',
|
37
|
+
body: 'Hello everyone, this is my first post!'
|
38
|
+
},
|
39
|
+
current_user: authorized_user
|
40
|
+
)
|
41
|
+
|
42
|
+
result1.status # => :created
|
43
|
+
result1.resource
|
44
|
+
# => {
|
45
|
+
# 'title' => 'My First Post'
|
46
|
+
# 'body' => 'Hello everyone, this is my first post!'
|
47
|
+
# }
|
48
|
+
|
49
|
+
result2 = API::V1::Post::Operation::Create.call(
|
50
|
+
params: {
|
51
|
+
title: 'My First Post',
|
52
|
+
body: 'Hello everyone, this is my first post!'
|
53
|
+
},
|
54
|
+
current_user: unauthorized_user
|
55
|
+
)
|
56
|
+
|
57
|
+
result2.status # => :forbidden
|
58
|
+
result2.resource
|
59
|
+
# => {
|
60
|
+
# 'error_type' => 'forbidden',
|
61
|
+
# 'error_message' => 'You are not authorized to perform this operation.'
|
62
|
+
# }
|
63
|
+
```
|
64
|
+
|
65
|
+
If you want to customize how you handle authorization, you can use the non-bang method `#authorize`:
|
66
|
+
|
67
|
+
```ruby
|
68
|
+
module API
|
69
|
+
module V1
|
70
|
+
module Post
|
71
|
+
module Operation
|
72
|
+
class Create < Pragma::Operation::Base
|
73
|
+
policy API::V1::Post::Policy
|
74
|
+
|
75
|
+
def call
|
76
|
+
post = Post.new(params)
|
77
|
+
|
78
|
+
unless authorize(post)
|
79
|
+
respond_with!(
|
80
|
+
status: :forbidden,
|
81
|
+
resource: nil # if you don't need error info
|
82
|
+
)
|
83
|
+
end
|
84
|
+
|
85
|
+
post.save!
|
86
|
+
|
87
|
+
respond_with status: :created, resource: post
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
94
|
+
```
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require 'interactor'
|
3
|
+
|
4
|
+
require 'pragma/operation/version'
|
5
|
+
require 'pragma/operation/base'
|
6
|
+
require 'pragma/operation/authorization'
|
7
|
+
require 'pragma/operation/validation'
|
8
|
+
|
9
|
+
module Pragma
|
10
|
+
# Operations provide business logic encapsulation for your JSON API.
|
11
|
+
#
|
12
|
+
# @author Alessandro Desantis
|
13
|
+
module Operation
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,78 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module Pragma
|
3
|
+
module Operation
|
4
|
+
module Authorization
|
5
|
+
def self.included(base)
|
6
|
+
base.extend ClassMethods
|
7
|
+
base.include InstanceMethods
|
8
|
+
end
|
9
|
+
|
10
|
+
module ClassMethods
|
11
|
+
# Sets the contract to use for validating this operation.
|
12
|
+
#
|
13
|
+
# @param klass [Class] a subclass of +Pragma::Contract::Base+
|
14
|
+
def contract(klass)
|
15
|
+
@contract = klass
|
16
|
+
end
|
17
|
+
|
18
|
+
# Builds the contract for the given resource, using the previous defined contract class.
|
19
|
+
#
|
20
|
+
# @param resource [Object]
|
21
|
+
#
|
22
|
+
# @return [Pragma::Contract::Base]
|
23
|
+
#
|
24
|
+
# @see #contract
|
25
|
+
def build_contract(resource)
|
26
|
+
@contract.new(resource)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
module InstanceMethods
|
31
|
+
# Builds the policy for the current user and the given resource, using the previously
|
32
|
+
# defined policy class.
|
33
|
+
#
|
34
|
+
# @param resource [Object]
|
35
|
+
#
|
36
|
+
# @return [Pragma::Policy::Base]
|
37
|
+
#
|
38
|
+
# @see .policy
|
39
|
+
# @see .build_policy
|
40
|
+
def build_policy(resource)
|
41
|
+
self.class.build_policy(user: current_user, resource: resource)
|
42
|
+
end
|
43
|
+
|
44
|
+
# Authorizes this operation on the provided resource or policy.
|
45
|
+
#
|
46
|
+
# @param authorizable [Pragma::Policy::Base|Object] resource or policy
|
47
|
+
#
|
48
|
+
# @return [Boolean] whether the operation is authorized
|
49
|
+
def authorize(authorizable)
|
50
|
+
policy = if defined?(Pragma::Policy::Base) && authorizable.is_a?(Pragma::Policy::Base)
|
51
|
+
authorizable
|
52
|
+
else
|
53
|
+
build_policy(authorizable)
|
54
|
+
end
|
55
|
+
|
56
|
+
policy.send("#{self.class.operation_name}?")
|
57
|
+
end
|
58
|
+
|
59
|
+
# Authorizes this operation on the provided resource or policy. If the user is not
|
60
|
+
# authorized to perform the operation, responds with 403 Forbidden and an error body and
|
61
|
+
# halts the execution.
|
62
|
+
#
|
63
|
+
# @param authorizable [Pragma::Policy::Base|Object] resource or policy
|
64
|
+
def authorize!(authorizable)
|
65
|
+
return if authorize(authorizable)
|
66
|
+
|
67
|
+
respond_with!(
|
68
|
+
status: :forbidden,
|
69
|
+
resource: {
|
70
|
+
error_type: :forbidden,
|
71
|
+
error_message: 'You are not authorized to perform this operation.'
|
72
|
+
}
|
73
|
+
)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
@@ -0,0 +1,237 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module Pragma
|
3
|
+
module Operation
|
4
|
+
# This is the base class all your operations should extend.
|
5
|
+
#
|
6
|
+
# @author Alessandro Desantis
|
7
|
+
#
|
8
|
+
# @abstract Subclass and override {#call} to implement an operation.
|
9
|
+
class Base
|
10
|
+
include Interactor
|
11
|
+
|
12
|
+
STATUSES = {
|
13
|
+
200 => :ok,
|
14
|
+
201 => :created,
|
15
|
+
202 => :accepted,
|
16
|
+
203 => :non_authoritative_information,
|
17
|
+
204 => :no_content,
|
18
|
+
205 => :reset_content,
|
19
|
+
206 => :partial_content,
|
20
|
+
207 => :multi_status,
|
21
|
+
208 => :already_reported,
|
22
|
+
300 => :multiple_choices,
|
23
|
+
301 => :moved_permanently,
|
24
|
+
302 => :found,
|
25
|
+
303 => :see_other,
|
26
|
+
304 => :not_modified,
|
27
|
+
305 => :use_proxy,
|
28
|
+
307 => :temporary_redirect,
|
29
|
+
400 => :bad_request,
|
30
|
+
401 => :unauthorized,
|
31
|
+
402 => :payment_required,
|
32
|
+
403 => :forbidden,
|
33
|
+
404 => :not_found,
|
34
|
+
405 => :method_not_allowed,
|
35
|
+
406 => :not_acceptable,
|
36
|
+
407 => :proxy_authentication_required,
|
37
|
+
408 => :request_timeout,
|
38
|
+
409 => :conflict,
|
39
|
+
410 => :gone,
|
40
|
+
411 => :length_required,
|
41
|
+
412 => :precondition_failed,
|
42
|
+
413 => :request_entity_too_large,
|
43
|
+
414 => :request_uri_too_large,
|
44
|
+
415 => :unsupported_media_type,
|
45
|
+
416 => :request_range_not_satisfiable,
|
46
|
+
417 => :expectation_failed,
|
47
|
+
418 => :im_a_teapot,
|
48
|
+
422 => :unprocessable_entity,
|
49
|
+
423 => :locked,
|
50
|
+
424 => :failed_dependency,
|
51
|
+
425 => :unordered_collection,
|
52
|
+
426 => :upgrade_required,
|
53
|
+
428 => :precondition_required,
|
54
|
+
429 => :too_many_requests,
|
55
|
+
431 => :request_header_fields_too_large,
|
56
|
+
449 => :retry_with,
|
57
|
+
500 => :internal_server_error,
|
58
|
+
501 => :not_implemented,
|
59
|
+
502 => :bad_gateway,
|
60
|
+
503 => :service_unavailable,
|
61
|
+
504 => :gateway_timeout,
|
62
|
+
505 => :http_version_not_supported,
|
63
|
+
506 => :variant_also_negotiates,
|
64
|
+
507 => :insufficient_storage,
|
65
|
+
509 => :bandwidth_limit_exceeded,
|
66
|
+
510 => :not_extended,
|
67
|
+
511 => :network_authentication_required
|
68
|
+
}.freeze
|
69
|
+
|
70
|
+
class << self
|
71
|
+
def inherited(child)
|
72
|
+
child.class_eval do
|
73
|
+
include Authorization
|
74
|
+
include Validation
|
75
|
+
|
76
|
+
before :setup_context
|
77
|
+
around :handle_halt
|
78
|
+
after :mark_result, :consolidate_status, :validate_status, :set_default_status
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
# Returns the name of this operation.
|
83
|
+
#
|
84
|
+
# For instance, if the operation is called +API::V1::Post::Operation::Create+, returns
|
85
|
+
# +create+.
|
86
|
+
#
|
87
|
+
# @return [Symbol]
|
88
|
+
def operation_name
|
89
|
+
name.split('::').last
|
90
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
91
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
92
|
+
.tr('-', '_')
|
93
|
+
.downcase
|
94
|
+
.to_sym
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
# Runs the operation.
|
99
|
+
def call
|
100
|
+
fail NotImplementedError
|
101
|
+
end
|
102
|
+
|
103
|
+
protected
|
104
|
+
|
105
|
+
# Returns the params this operation is being run with.
|
106
|
+
#
|
107
|
+
# This is just a shortcut for +context.params+.
|
108
|
+
#
|
109
|
+
# @return [Hash]
|
110
|
+
def params
|
111
|
+
context.params
|
112
|
+
end
|
113
|
+
|
114
|
+
# Sets the status and resource to respond with.
|
115
|
+
#
|
116
|
+
# You can achieve the same result by setting +context.status+ and +context.resource+ wherever
|
117
|
+
# you want in {#call}.
|
118
|
+
#
|
119
|
+
# Note that calling this method doesn't halt the execution of the operation and that this
|
120
|
+
# method can be called multiple times, overriding the previous context.
|
121
|
+
#
|
122
|
+
# @param status [Integer|Symbol] an HTTP status code
|
123
|
+
# @param resource [Object] an object responding to +#to_json+
|
124
|
+
def respond_with(status:, resource:)
|
125
|
+
context.status = status
|
126
|
+
context.resource = resource
|
127
|
+
end
|
128
|
+
|
129
|
+
# Same as {#respond_with}, but also halts the execution of the operation.
|
130
|
+
#
|
131
|
+
# @param status [Integer|Symbol] an HTTP status code
|
132
|
+
# @param resource [Object] an object responding to +#to_json+
|
133
|
+
#
|
134
|
+
# @see #respond_with
|
135
|
+
def respond_with!(status:, resource:)
|
136
|
+
respond_with status: status, resource: resource
|
137
|
+
fail Halt
|
138
|
+
end
|
139
|
+
|
140
|
+
# Sets the status to respond with.
|
141
|
+
#
|
142
|
+
# You can achieve the same result by setting +context.status+ wherever you want in {#call}.
|
143
|
+
#
|
144
|
+
# Note that calling this method doesn't halt the execution of the operation and that this
|
145
|
+
# method can be called multiple times, overriding the previous context.
|
146
|
+
#
|
147
|
+
# @param status [Integer|Symbol] an HTTP status code
|
148
|
+
def head(status)
|
149
|
+
context.status = status
|
150
|
+
end
|
151
|
+
|
152
|
+
# Same as {#head}, but also halts the execution of the operation.
|
153
|
+
#
|
154
|
+
# @param status [Integer|Symbol] an HTTP status code
|
155
|
+
#
|
156
|
+
# @see #head
|
157
|
+
def head!(status)
|
158
|
+
head status
|
159
|
+
fail Halt
|
160
|
+
end
|
161
|
+
|
162
|
+
# Returns the current user.
|
163
|
+
#
|
164
|
+
# This is just a shortcut for +context.current_user+.
|
165
|
+
#
|
166
|
+
# @return [Object]
|
167
|
+
def current_user
|
168
|
+
context.current_user
|
169
|
+
end
|
170
|
+
|
171
|
+
private
|
172
|
+
|
173
|
+
def with_hooks
|
174
|
+
# This overrides the default behavior, which is not to run after hooks if an exception is
|
175
|
+
# raised either in +#call+ or one of the before hooks. See:
|
176
|
+
# https://github.com/collectiveidea/interactor/blob/master/lib/interactor/hooks.rb#L210)
|
177
|
+
run_around_hooks do
|
178
|
+
begin
|
179
|
+
run_before_hooks
|
180
|
+
yield
|
181
|
+
ensure
|
182
|
+
run_after_hooks
|
183
|
+
end
|
184
|
+
end
|
185
|
+
end
|
186
|
+
|
187
|
+
def setup_context
|
188
|
+
context.params ||= {}
|
189
|
+
end
|
190
|
+
|
191
|
+
def handle_halt(interactor)
|
192
|
+
interactor.call
|
193
|
+
rescue Halt # rubocop:disable Lint/HandleExceptions
|
194
|
+
end
|
195
|
+
|
196
|
+
def set_default_status
|
197
|
+
return if context.status
|
198
|
+
context.status = context.resource ? :ok : :no_content
|
199
|
+
end
|
200
|
+
|
201
|
+
def validate_status
|
202
|
+
if context.status.is_a?(Integer)
|
203
|
+
fail InvalidStatusError, context.status unless STATUSES.key?(context.status)
|
204
|
+
else
|
205
|
+
fail InvalidStatusError, context.status unless STATUSES.invert.key?(context.status.to_sym)
|
206
|
+
end
|
207
|
+
end
|
208
|
+
|
209
|
+
def consolidate_status
|
210
|
+
context.status = if context.status.is_a?(Integer)
|
211
|
+
STATUSES[context.status]
|
212
|
+
else
|
213
|
+
context.status.to_sym
|
214
|
+
end
|
215
|
+
end
|
216
|
+
|
217
|
+
def mark_result
|
218
|
+
return if /\A(2|3)\d{2}\z/ =~ STATUSES.invert[context.status].to_s
|
219
|
+
context.fail!
|
220
|
+
end
|
221
|
+
end
|
222
|
+
|
223
|
+
Halt = Class.new(StandardError)
|
224
|
+
|
225
|
+
# This error is raised when an invalid status is set for an operation.
|
226
|
+
#
|
227
|
+
# @author Alessandro Desantis
|
228
|
+
class InvalidStatusError < StandardError
|
229
|
+
# Initializes the error.
|
230
|
+
#
|
231
|
+
# @param [Integer|Symbol] an invalid HTTP status code
|
232
|
+
def initialize(status)
|
233
|
+
super "'#{status}' is not a valid HTTP status code."
|
234
|
+
end
|
235
|
+
end
|
236
|
+
end
|
237
|
+
end
|
@@ -0,0 +1,90 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module Pragma
|
3
|
+
module Operation
|
4
|
+
module Validation
|
5
|
+
def self.included(base)
|
6
|
+
base.extend ClassMethods
|
7
|
+
base.include InstanceMethods
|
8
|
+
end
|
9
|
+
|
10
|
+
module ClassMethods
|
11
|
+
# Sets the policy to use for authorizing this operation.
|
12
|
+
#
|
13
|
+
# @param klass [Class] a subclass of +Pragma::Policy::Base+
|
14
|
+
def policy(klass)
|
15
|
+
@policy = klass
|
16
|
+
end
|
17
|
+
|
18
|
+
# Builds the policy for the given user and resource, using the previous defined policy
|
19
|
+
# class.
|
20
|
+
#
|
21
|
+
# @param user [Object]
|
22
|
+
# @param resource [Object]
|
23
|
+
#
|
24
|
+
# @return [Pragma::Policy::Base]
|
25
|
+
#
|
26
|
+
# @see #policy
|
27
|
+
def build_policy(user:, resource:)
|
28
|
+
@policy.new(user: user, resource: resource)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
module InstanceMethods
|
33
|
+
# Builds the contract for the given resource, using the previously defined contract class.
|
34
|
+
#
|
35
|
+
# This is just an instance-level alias of {.build_contract}. You should use this from inside
|
36
|
+
# the operation.
|
37
|
+
#
|
38
|
+
# @param resource [Object]
|
39
|
+
#
|
40
|
+
# @return [Pragma::Contract::Base]
|
41
|
+
#
|
42
|
+
# @see .contract
|
43
|
+
# @see .build_contract
|
44
|
+
def build_contract(resource)
|
45
|
+
self.class.build_contract(resource)
|
46
|
+
end
|
47
|
+
|
48
|
+
# Validates this operation on the provided contract or resource.
|
49
|
+
#
|
50
|
+
# @param validatable [Object|Pragma::Contract::Base] contract or resource
|
51
|
+
#
|
52
|
+
# @return [Boolean] whether the operation is valid
|
53
|
+
def validate(validatable)
|
54
|
+
contract = if defined?(Pragma::Contract::Base) && validatable.is_a?(Pragma::Contract::Base)
|
55
|
+
validatable
|
56
|
+
else
|
57
|
+
build_contract(validatable)
|
58
|
+
end
|
59
|
+
|
60
|
+
contract.validate(params)
|
61
|
+
end
|
62
|
+
|
63
|
+
# Validates this operation on the provided contract or resource. If the operation is not
|
64
|
+
# valid, responds with 422 Unprocessable Entity and an error body and halts the execution.
|
65
|
+
#
|
66
|
+
# @param validatable [Object|Pragma::Contract::Base] contract or resource
|
67
|
+
def validate!(validatable)
|
68
|
+
contract = if defined?(Pragma::Contract::Base) && validatable.is_a?(Pragma::Contract::Base)
|
69
|
+
validatable
|
70
|
+
else
|
71
|
+
build_contract(validatable)
|
72
|
+
end
|
73
|
+
|
74
|
+
return if validate(contract)
|
75
|
+
|
76
|
+
respond_with!(
|
77
|
+
status: :unprocessable_entity,
|
78
|
+
resource: {
|
79
|
+
error_type: :contract_not_respected,
|
80
|
+
error_message: 'The contract for this operation was not respected.',
|
81
|
+
meta: {
|
82
|
+
errors: contract.errors.messages
|
83
|
+
}
|
84
|
+
}
|
85
|
+
)
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'pragma/operation/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "pragma-operation"
|
8
|
+
spec.version = Pragma::Operation::VERSION
|
9
|
+
spec.authors = ["Alessandro Desantis"]
|
10
|
+
spec.email = ["desa.alessandro@gmail.com"]
|
11
|
+
|
12
|
+
spec.summary = 'Business logic encapsulation for your JSON API.'
|
13
|
+
spec.homepage = "https://github.com/pragmarb/pragma-operation"
|
14
|
+
spec.license = "MIT"
|
15
|
+
|
16
|
+
spec.files = `git ls-files -z`.split("\x0").reject do |f|
|
17
|
+
f.match(%r{^(test|spec|features)/})
|
18
|
+
end
|
19
|
+
spec.bindir = "exe"
|
20
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
21
|
+
spec.require_paths = ["lib"]
|
22
|
+
|
23
|
+
spec.add_dependency 'interactor', '~> 3.1.0'
|
24
|
+
|
25
|
+
spec.add_development_dependency "bundler"
|
26
|
+
spec.add_development_dependency "rake"
|
27
|
+
spec.add_development_dependency "rspec"
|
28
|
+
spec.add_development_dependency "rubocop"
|
29
|
+
spec.add_development_dependency "rubocop-rspec"
|
30
|
+
spec.add_development_dependency "coveralls"
|
31
|
+
end
|
metadata
ADDED
@@ -0,0 +1,161 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: pragma-operation
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Alessandro Desantis
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2016-12-26 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: interactor
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 3.1.0
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 3.1.0
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: bundler
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rake
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: rspec
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: rubocop
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - ">="
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '0'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - ">="
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '0'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: rubocop-rspec
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - ">="
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '0'
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - ">="
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '0'
|
97
|
+
- !ruby/object:Gem::Dependency
|
98
|
+
name: coveralls
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - ">="
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: '0'
|
104
|
+
type: :development
|
105
|
+
prerelease: false
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - ">="
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: '0'
|
111
|
+
description:
|
112
|
+
email:
|
113
|
+
- desa.alessandro@gmail.com
|
114
|
+
executables: []
|
115
|
+
extensions: []
|
116
|
+
extra_rdoc_files: []
|
117
|
+
files:
|
118
|
+
- ".gitignore"
|
119
|
+
- ".rspec"
|
120
|
+
- ".rubocop.yml"
|
121
|
+
- ".travis.yml"
|
122
|
+
- Gemfile
|
123
|
+
- LICENSE.txt
|
124
|
+
- README.md
|
125
|
+
- Rakefile
|
126
|
+
- bin/console
|
127
|
+
- bin/setup
|
128
|
+
- doc/01-basic-usage.md
|
129
|
+
- doc/02-contracts.md
|
130
|
+
- doc/03-policies.md
|
131
|
+
- lib/pragma/operation.rb
|
132
|
+
- lib/pragma/operation/authorization.rb
|
133
|
+
- lib/pragma/operation/base.rb
|
134
|
+
- lib/pragma/operation/validation.rb
|
135
|
+
- lib/pragma/operation/version.rb
|
136
|
+
- pragma-operation.gemspec
|
137
|
+
homepage: https://github.com/pragmarb/pragma-operation
|
138
|
+
licenses:
|
139
|
+
- MIT
|
140
|
+
metadata: {}
|
141
|
+
post_install_message:
|
142
|
+
rdoc_options: []
|
143
|
+
require_paths:
|
144
|
+
- lib
|
145
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
146
|
+
requirements:
|
147
|
+
- - ">="
|
148
|
+
- !ruby/object:Gem::Version
|
149
|
+
version: '0'
|
150
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
151
|
+
requirements:
|
152
|
+
- - ">="
|
153
|
+
- !ruby/object:Gem::Version
|
154
|
+
version: '0'
|
155
|
+
requirements: []
|
156
|
+
rubyforge_project:
|
157
|
+
rubygems_version: 2.5.2
|
158
|
+
signing_key:
|
159
|
+
specification_version: 4
|
160
|
+
summary: Business logic encapsulation for your JSON API.
|
161
|
+
test_files: []
|