apicasso 0.3.2 → 0.3.3
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +68 -22
- data/app/controllers/apicasso/application_controller.rb +42 -19
- data/app/controllers/apicasso/crud_controller.rb +9 -9
- data/lib/apicasso/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: ab5b6196d2e1a9675b5b4eca26522f6ff1dabe63
|
4
|
+
data.tar.gz: 1715dc2d29b09e97deba8f41b811709059a3520c
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: f9a542fe03655f97043183cb6040d0ff92a0d533a337faf43a4a4771cc0ba75e2ce73c2494d98b0c8436b049bcf548ae0e14cdd8ba26ae0a1b42be2f3186f502
|
7
|
+
data.tar.gz: 5ff7a98726754f89f1cf6f50038b2f5c062f49d204626b5dda58da7eb85d08643ddbc0aa0d9c057f5241a3cb47044f6146431be6453159f933464b47c6b49892
|
data/README.md
CHANGED
@@ -6,6 +6,7 @@ JSON API development can get boring and time consuming. If you think it through,
|
|
6
6
|
It is a route-based resource abstraction using API key scoping. This makes it possible to make CRUD-only applications just by creating functional Rails' models. It is a perfect candidate for legacy Rails projects that do not have an API. Access to your application's resources is managed by a `.scope` JSON object per API key. It uses that permission scope to restrict and extend access.
|
7
7
|
|
8
8
|
# Installation
|
9
|
+
|
9
10
|
Add this line to your application's `Gemfile`:
|
10
11
|
|
11
12
|
```ruby
|
@@ -13,9 +14,11 @@ gem 'apicasso'
|
|
13
14
|
```
|
14
15
|
|
15
16
|
And then execute this to generate the required migrations:
|
17
|
+
|
16
18
|
```bash
|
17
19
|
$ bundle install && rails g apicasso:install
|
18
20
|
```
|
21
|
+
|
19
22
|
You will need to use a database with JSON fields support to use this gem.
|
20
23
|
|
21
24
|
# Usage
|
@@ -25,6 +28,7 @@ You will need to use a database with JSON fields support to use this gem.
|
|
25
28
|
## Mounting engine into `config/routes.rb`
|
26
29
|
|
27
30
|
After installing it, you can mount a full-fledged CRUD JSON API just by attaching into some route. Usually you will have it under a scoped route like `/api/v1` or a subdomain. You can do that by adding this into your `config/routes.rb`:
|
31
|
+
|
28
32
|
```ruby
|
29
33
|
# To mount your APIcasso routes under the path scope `/api/v1`
|
30
34
|
mount Apicasso::Engine, at: "/api/v1"
|
@@ -33,7 +37,9 @@ After installing it, you can mount a full-fledged CRUD JSON API just by attachin
|
|
33
37
|
mount Apicasso::Engine, at: "/"
|
34
38
|
end
|
35
39
|
```
|
40
|
+
|
36
41
|
Your API will reflect very similarly a `resources :resource` statement with the following routes:
|
42
|
+
|
37
43
|
```ruby
|
38
44
|
get '/:resource/' # Index action, listing a `:resource` collection from your application
|
39
45
|
post '/:resource/' # Create action for one `:resource` from your application
|
@@ -50,11 +56,14 @@ This means all your application's models will be exposed as `:resource` and it's
|
|
50
56
|
## Extending base API actions
|
51
57
|
|
52
58
|
When your application needs some kind of custom interaction that is not covered by APIcasso's CRUD approach you can make your own actions using our base classes and objects to go straight into your logic. If you have built the APIcasso's engine into a route it is important that your custom action takes precedence over the gem's ones. To do that you need to declare your custom route before the engine on you `config/routes.rb`
|
59
|
+
|
53
60
|
```ruby
|
54
61
|
match '/:resource/:id/a-custom-action' => 'custom#not_a_crud', via: :get
|
55
62
|
mount Apicasso::Engine, at: "/api/v1"
|
56
63
|
```
|
64
|
+
|
57
65
|
And in your `app/controllers/custom_controller.rb` you would have something like:
|
66
|
+
|
58
67
|
```ruby
|
59
68
|
class CustomController < Apicasso::CrudController
|
60
69
|
def not_a_crud
|
@@ -62,17 +71,27 @@ And in your `app/controllers/custom_controller.rb` you would have something like
|
|
62
71
|
end
|
63
72
|
end
|
64
73
|
```
|
74
|
+
|
65
75
|
This way you enjoy all our object finder, authorization and authentication features, making your job more straight into your business logic.
|
66
76
|
|
67
|
-
##
|
77
|
+
## Authentication
|
68
78
|
|
69
|
-
|
79
|
+
> But exposing my models to the internet is permissive as hell! Haven't you thought about security?
|
70
80
|
|
71
|
-
|
81
|
+
_Sure!_ The **APIcasso** suite is exposing your application using authentication through `Authorization: Token` [HTTP header authentication](http://tools.ietf.org/html/draft-hammer-http-token-auth-01). The API key objects are manageable through the `Apicasso::Key` model, which gets setup at install. When a new key is created a `.token` is generated using an [Universally Unique Identifier(RFC 4122)](https://tools.ietf.org/html/rfc4122). A authenticated request looks like this:
|
72
82
|
|
73
|
-
|
83
|
+
```
|
84
|
+
curl -X GET \
|
85
|
+
https://apixample.com/v1/your_app_resource \
|
86
|
+
-H 'authorization: Token token=cda4e9f633c123ef9ddce5e6564292b3'
|
87
|
+
```
|
88
|
+
|
89
|
+
Each `Apicasso::Key` object has a token attribute, which is used on this header to authorize access. For now, there is no plans for a login/JWT logic, you should implement this in your project's scope.
|
90
|
+
|
91
|
+
## Authorization
|
74
92
|
|
75
93
|
Your Models are then exposed based on each `Apicasso::Key.scope` definition, which is a way to configure how much of your application each key can access. I.E.:
|
94
|
+
|
76
95
|
```ruby
|
77
96
|
Apicasso::Key.create(scope:
|
78
97
|
{ manage:
|
@@ -81,13 +100,15 @@ Your Models are then exposed based on each `Apicasso::Key.scope` definition, whi
|
|
81
100
|
{ account: { manager_id: 1 } }
|
82
101
|
})
|
83
102
|
```
|
103
|
+
|
84
104
|
> The key from this example will have full access to all orders and to users with `account_id == 1`. It will have also read-only access to accounts with `id == 1`.
|
85
105
|
|
86
|
-
A scope configured like this translates directly into which kind of access each key has on all
|
106
|
+
A scope configured like this translates directly into which kind of access each key has on all of your application's models. This kind of authorization is why one of the dependencies for this gem is [CanCanCan](https://github.com/CanCanCommunity/cancancan), which abstracts the scope field into your API access control.
|
87
107
|
|
88
108
|
You can have two kind of access control:
|
89
|
-
|
90
|
-
|
109
|
+
|
110
|
+
- `true` - This will mean the key will have the declared clearance on **ALL** of this model's records
|
111
|
+
- `Hash` - This will build a condition to what records this key have access to. A scope as `{ read: [{ account: { manager_id: 1 } }] }` will have read access into accounts with `manager_id == 1`
|
91
112
|
|
92
113
|
This saves you the trouble of having to setup every controller for each model. And even if your application really needs it, just make your controllers inherit from `Apicasso::CrudController` extending it and enabling the use of `@object` and `@resource` variables to access what is being resquested.
|
93
114
|
|
@@ -100,6 +121,7 @@ The index actions present in the gem are already equipped with pagination, order
|
|
100
121
|
You can sort a collection query by using a URL parameter with field names preffixed with `+` or `-` to configure custom ordering per request.
|
101
122
|
|
102
123
|
To order a collection with ascending `updated_at` and descending `name` you can add the `sort` parameter with those fields as options, indicating which kind of ordination you want to give to each one:
|
124
|
+
|
103
125
|
```
|
104
126
|
?sort=+updated_at,-name
|
105
127
|
```
|
@@ -107,18 +129,23 @@ To order a collection with ascending `updated_at` and descending `name` you can
|
|
107
129
|
### Filtering/Search
|
108
130
|
|
109
131
|
APIcasso has [ransack's search matchers](https://github.com/activerecord-hackery/ransack#search-matchers) on it's index actions. This means you can dynamically build search queries with any of your resource's fields, this will be done by using a `?q` parameter which groups all your filtering options on your requests. If you wanted to search all your records and return only the ones with `full_name` starting with `Picasso` your query would look something like this:
|
132
|
+
|
110
133
|
```
|
111
134
|
?q[full_name_start]=Picasso
|
112
135
|
```
|
136
|
+
|
113
137
|
To build complex search queries you can chain many parameter options or check [ransack's wiki](https://github.com/activerecord-hackery/ransack/wiki/) on how to adapt this feature into your project's needs.
|
114
138
|
|
115
139
|
### Pagination
|
116
140
|
|
117
141
|
Automatic pagination is done in index actions, with the adittion of some metadata to help on the data consumption. You can pass page and per page parameters to build pagination options into your needs. And on requests that you need unpaginated collections, just pass a lower than zero `per_page`. Example of a pagination query string:
|
142
|
+
|
118
143
|
```
|
119
144
|
?page=2&per_page=12
|
120
145
|
```
|
146
|
+
|
121
147
|
Your colletion will be build inside a JSON along with some metadata about it. The response structure is:
|
148
|
+
|
122
149
|
```
|
123
150
|
{ entries: [{Record1}, {Record2}, {Record3} ... {Record12}],
|
124
151
|
total: 1234,
|
@@ -134,43 +161,62 @@ Your colletion will be build inside a JSON along with some metadata about it. Th
|
|
134
161
|
|
135
162
|
Sometimes your data can grow large in some tables and you need to consumed only a limited set of data on a given frontend application. To avoid large requests and filtering a lot of unused data with JS you can restrict which fields you need on your API's reponse. This is done adding a `?select` parameter. Just pass the field names you desire splitted by `,`
|
136
163
|
Let's say you are building a user list with their name, e-mails and phones, to get only those fields your URL query would look something like:
|
164
|
+
|
137
165
|
```
|
138
166
|
?select=name,email,phone
|
139
167
|
```
|
140
|
-
|
168
|
+
|
169
|
+
This will change the response to return only the requested attributes. You need to observe that your business logic may require some fields for a valid response to be returned. **This method can be used both on index and show actions**
|
170
|
+
|
171
|
+
### Including relations or methods on response
|
172
|
+
|
173
|
+
If there is any method or relation that you want to be inserted on the payload, you just need to pass them as a part of the URL query like this:
|
174
|
+
|
175
|
+
```
|
176
|
+
?include=pictures,suggestions
|
177
|
+
```
|
178
|
+
|
179
|
+
This will insert the contents of `.pictures` and `.suggestions` on the payload, along with the records' data. **This method can be used both on index and show actions**
|
141
180
|
|
142
181
|
### Grouping operations
|
143
182
|
|
144
183
|
If you need to make grouping calculations, like:
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
184
|
+
|
185
|
+
- Counting of all records, or by one **optional** field presence
|
186
|
+
- Maximum value of one field
|
187
|
+
- Minimum value of one field
|
188
|
+
- Average value of one field
|
189
|
+
- Value sum of one field
|
150
190
|
|
151
191
|
Grouping is done by the combination of 3 parameters
|
192
|
+
|
152
193
|
```
|
153
194
|
?group[by]=a_field&group[calculate]=count&group[fields]=another_field
|
154
195
|
```
|
196
|
+
|
155
197
|
Each of those attributes on the `?group` parameter represent an option of the query being made.
|
156
|
-
|
157
|
-
|
158
|
-
|
198
|
+
|
199
|
+
- `group[by]` - Represents which field will be the key for the grouping behavior
|
200
|
+
- `group[calculate]` - Which calculation will be sent in the response. Options are: `count`, `maximum`, `minimum`, `average`, `sum`
|
201
|
+
- `group[fields]` - Represents which field will be the base for the response calculation.
|
159
202
|
|
160
203
|
# Contributing
|
204
|
+
|
161
205
|
Bug reports and pull requests are welcome on GitHub at https://github.com/ErvalhouS/APIcasso. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant code of conduct](http://contributor-covenant.org/). To find good places to start contributing, try looking into our issue list and our Codeclimate profile, or if you want to participate actively on what the core team is working on checkout our todo list:
|
162
206
|
|
163
207
|
### TODO
|
164
208
|
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
209
|
+
- Abstract a configurable CORS approach, maybe using middleware.
|
210
|
+
- Add gem options like: Token rotation, Alternative authentication methods
|
211
|
+
- Add latest auto-documentation feature into README
|
212
|
+
- Rate limiting
|
213
|
+
- Testing suite
|
214
|
+
- Travis CI
|
171
215
|
|
172
216
|
# Code of conduct
|
217
|
+
|
173
218
|
Everyone interacting in the APIcasso project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/ErvalhouS/APIcasso/blob/master/CODE_OF_CONDUCT.md).
|
174
219
|
|
175
220
|
# License
|
221
|
+
|
176
222
|
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
@@ -1,39 +1,53 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module Apicasso
|
4
|
-
# Controller to extract common API features,
|
5
|
-
#
|
4
|
+
# Controller to extract common API features, such as authentication and
|
5
|
+
# authorization. Used to be inherited by non-CRUD controllers when your
|
6
|
+
# application needs to create custom actions.
|
6
7
|
class ApplicationController < ActionController::API
|
7
8
|
include ActionController::HttpAuthentication::Token::ControllerMethods
|
8
9
|
prepend_before_action :restrict_access, unless: -> { preflight? }
|
9
10
|
before_action :set_access_control_headers
|
10
11
|
after_action :register_api_request
|
11
12
|
|
12
|
-
# Sets the authorization scope for the current API key
|
13
|
+
# Sets the authorization scope for the current API key, it's a getter
|
14
|
+
# to make scoping easier
|
13
15
|
def current_ability
|
14
16
|
@current_ability ||= Apicasso::Ability.new(@api_key)
|
15
17
|
end
|
16
18
|
|
17
19
|
private
|
18
20
|
|
19
|
-
# Identifies API key used in the request, avoiding unauthenticated access
|
21
|
+
# Identifies API key used in the request, avoiding unauthenticated access.
|
22
|
+
# Responds with status 401 when token is not present or not found.
|
23
|
+
# Access restriction happens on the `Authentication` HTTP header.
|
24
|
+
# Example:
|
25
|
+
# curl -X GET http://example.com/objects -H 'authorization: Token token=f1e048a0b0ef4071a9a64ceecd48c64b'
|
20
26
|
def restrict_access
|
21
27
|
authenticate_or_request_with_http_token do |token, _options|
|
22
|
-
@api_key = Apicasso::Key.find_by
|
28
|
+
@api_key = Apicasso::Key.find_by(token: token)
|
23
29
|
end
|
24
30
|
end
|
25
31
|
|
26
32
|
# Creates a request object in databse, registering the API key and
|
27
|
-
# a hash of the request and the response
|
33
|
+
# a hash of the request and the response. It's an auditing proccess,
|
34
|
+
# all relevant information about the requests and it's reponses get
|
35
|
+
# recorded within the `Apicasso::Request`. This method assumes that
|
36
|
+
# your project is using some kind of ActiveRecord extension with a
|
37
|
+
# `.delay` method, which when not present makes your API very slow.
|
28
38
|
def register_api_request
|
29
39
|
Apicasso::Request.delay.create(api_key_id: @api_key.try(:id),
|
30
|
-
object: { request:
|
31
|
-
response:
|
40
|
+
object: { request: request_metadata,
|
41
|
+
response: response_metadata })
|
42
|
+
rescue NoMethodError
|
43
|
+
Apicasso::Request.create(api_key_id: @api_key.try(:id),
|
44
|
+
object: { request: request_metadata,
|
45
|
+
response: response_metadata })
|
32
46
|
end
|
33
47
|
|
34
|
-
#
|
35
|
-
# Returns UUID, URL, HTTP Headers and
|
36
|
-
def
|
48
|
+
# Information that gets inserted on `register_api_request` as auditing data
|
49
|
+
# about the request. Returns a Hash with UUID, URL, HTTP Headers and IP
|
50
|
+
def request_metadata
|
37
51
|
{
|
38
52
|
uuid: request.uuid,
|
39
53
|
url: request.original_url,
|
@@ -42,30 +56,34 @@ module Apicasso
|
|
42
56
|
}
|
43
57
|
end
|
44
58
|
|
45
|
-
#
|
46
|
-
# Returns HTTP Status and request body
|
47
|
-
def
|
59
|
+
# Information that gets inserted on `register_api_request` as auditing data
|
60
|
+
# about the response sent back to the client. Returns HTTP Status and request body
|
61
|
+
def response_metadata
|
48
62
|
{
|
49
63
|
status: response.status,
|
50
64
|
body: (response.body.present? ? JSON.parse(response.body) : '')
|
51
65
|
}
|
52
66
|
end
|
53
67
|
|
54
|
-
# Used to avoid errors parsing the search query,
|
55
|
-
#
|
68
|
+
# Used to avoid errors parsing the search query, which can be passed as
|
69
|
+
# a JSON or as a key-value param. JSON is preferred because it generates
|
70
|
+
# shorter URLs on GET parameters.
|
56
71
|
def parsed_query
|
57
72
|
JSON.parse(params[:q])
|
58
73
|
rescue JSON::ParserError, TypeError
|
59
74
|
params[:q]
|
60
75
|
end
|
61
76
|
|
62
|
-
# Used to avoid errors in included associations parsing
|
77
|
+
# Used to avoid errors in included associations parsing and to enable a
|
78
|
+
# insertion point for a change on splitting method.
|
63
79
|
def parsed_include
|
64
80
|
params[:include].split(',')
|
65
81
|
rescue NoMethodError
|
66
82
|
[]
|
67
83
|
end
|
68
84
|
|
85
|
+
# Used to avoid errors in fieldset selection parsing and to enable a
|
86
|
+
# insertion point for a change on splitting method.
|
69
87
|
def parsed_select
|
70
88
|
params[:select].split(',')
|
71
89
|
rescue NoMethodError
|
@@ -84,7 +102,7 @@ module Apicasso
|
|
84
102
|
offset: records.offset }
|
85
103
|
end
|
86
104
|
|
87
|
-
# Generates a contextualized URL of the next page for
|
105
|
+
# Generates a contextualized URL of the next page for the request
|
88
106
|
def next_link_for(records)
|
89
107
|
uri = URI.parse(request.original_url)
|
90
108
|
query = Rack::Utils.parse_query(uri.query)
|
@@ -93,7 +111,7 @@ module Apicasso
|
|
93
111
|
uri.to_s
|
94
112
|
end
|
95
113
|
|
96
|
-
# Generates a contextualized URL of the previous page for
|
114
|
+
# Generates a contextualized URL of the previous page for the request
|
97
115
|
def previous_link_for(records)
|
98
116
|
uri = URI.parse(request.original_url)
|
99
117
|
query = Rack::Utils.parse_query(uri.query)
|
@@ -103,11 +121,15 @@ module Apicasso
|
|
103
121
|
end
|
104
122
|
|
105
123
|
# Receives a `:action, :resource, :object` hash to validate authorization
|
124
|
+
# Example:
|
125
|
+
# > authorize_for action: :read, resource: :object_class, object: :object
|
106
126
|
def authorize_for(opts = {})
|
107
127
|
authorize! opts[:action], opts[:resource] if opts[:resource].present?
|
108
128
|
authorize! opts[:action], opts[:object] if opts[:object].present?
|
109
129
|
end
|
110
130
|
|
131
|
+
# @TODO
|
132
|
+
# Remove this in favor of a more controllable aproach of CORS
|
111
133
|
def set_access_control_headers
|
112
134
|
response.headers['Access-Control-Allow-Origin'] = request.headers["Origin"]
|
113
135
|
response.headers['Access-Control-Allow-Methods'] = 'POST, GET, PUT, PATCH, DELETE, OPTIONS'
|
@@ -116,6 +138,7 @@ module Apicasso
|
|
116
138
|
response.headers['Access-Control-Max-Age'] = '1728000'
|
117
139
|
end
|
118
140
|
|
141
|
+
# Checks if current request is a CORS preflight check
|
119
142
|
def preflight?
|
120
143
|
request.request_method == 'OPTIONS' &&
|
121
144
|
!request.headers['Authorization'].present?
|
@@ -4,7 +4,7 @@ module Apicasso
|
|
4
4
|
# Controller to consume read-only data to be used on client's frontend
|
5
5
|
class CrudController < Apicasso::ApplicationController
|
6
6
|
before_action :set_root_resource
|
7
|
-
before_action :set_object,
|
7
|
+
before_action :set_object, only: %i[show update destroy]
|
8
8
|
before_action :set_nested_resource, only: %i[nested_index]
|
9
9
|
before_action :set_records, only: %i[index nested_index]
|
10
10
|
|
@@ -13,11 +13,11 @@ module Apicasso
|
|
13
13
|
# GET /:resource
|
14
14
|
# Returns a paginated, ordered and filtered query based response.
|
15
15
|
# Consider this
|
16
|
-
# To get all `Channel` sorted by ascending `name`
|
17
|
-
#
|
18
|
-
#
|
19
|
-
#
|
20
|
-
#
|
16
|
+
# To get all `Channel` sorted by ascending `name` , filtered by
|
17
|
+
# the ones that have a `domain` that matches exactly `"domain.com"`,
|
18
|
+
# paginating records 42 per page and retrieving the page 42.
|
19
|
+
# Example:
|
20
|
+
# GET /sites?sort=+name,-updated_at&q[domain_eq]=domain.com&page=42&per_page=42
|
21
21
|
def index
|
22
22
|
set_access_control_headers
|
23
23
|
render json: index_json
|
@@ -51,7 +51,7 @@ module Apicasso
|
|
51
51
|
resource: resource.name.underscore.to_sym,
|
52
52
|
object: @object)
|
53
53
|
if @object.destroy
|
54
|
-
|
54
|
+
head :no_content, status: :ok
|
55
55
|
else
|
56
56
|
render json: @object.errors, status: :unprocessable_entity
|
57
57
|
end
|
@@ -62,7 +62,7 @@ module Apicasso
|
|
62
62
|
|
63
63
|
# POST /:resource
|
64
64
|
def create
|
65
|
-
@object = resource.new(
|
65
|
+
@object = resource.new(object_params)
|
66
66
|
authorize_for(action: :create,
|
67
67
|
resource: resource.name.underscore.to_sym,
|
68
68
|
object: @object)
|
@@ -153,7 +153,7 @@ module Apicasso
|
|
153
153
|
# or a grouped count of attributes
|
154
154
|
def index_json
|
155
155
|
if params[:group].present?
|
156
|
-
@records.group(params[:group][:by].split(',')).send(params[:group][:calculate], params[:group][:fields])
|
156
|
+
@records.group(params[:group][:by].split(',')).send(params[:group][:calculate], params[:group][:fields]||params[:group][:by].split(','))
|
157
157
|
else
|
158
158
|
collection_response
|
159
159
|
end
|
data/lib/apicasso/version.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: apicasso
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.3.
|
4
|
+
version: 0.3.3
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Fernando Bellincanta
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2018-09-
|
11
|
+
date: 2018-09-18 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: cancancan
|