apicasso 0.1.5 → 0.1.6

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA256:
3
- metadata.gz: cd79fffcb4c7c86e39a0873b71139d6bf8f2e7c4ebf181d68083b2963c3e9a66
4
- data.tar.gz: 90b1009449d217a580518ad0957d99215cedf838042948cfcc1b7b08cbbfc692
2
+ SHA1:
3
+ metadata.gz: 8053255daaad3ecbc6f8b4d14ae1aeb38671b5b9
4
+ data.tar.gz: d904f5dbc5d8114b26a26620b9b85410fd6d37d6
5
5
  SHA512:
6
- metadata.gz: 845781b36267c16168ddeca5fe9e053eb2e4e35be1abaacc544ae3d4e98f3aafe9f92af0caa31d8c8ed41cbdd7fbba19fb07e90412b2f71d39deef5ae0a8ca0a
7
- data.tar.gz: 168c5576cb188b4aee794187acc3c1af622130f011aad842d2aedbe4bc6da0a72d288c8108db659dec381d3ef6a3420f8955ab3ff3a6e3871a83613c1aba4df6
6
+ metadata.gz: 02fcc26644d279ba49a58651e7135e644f2477dcb0d6b01988d27a67bd7a3867e1ff8f298273121b3ccff005225a1b44e0186aa8780bbacb8418369b89ee2a21
7
+ data.tar.gz: c6c194ff5532e62e6e36d797b143b89520c71fa1b1a470ad1e44343dffede7d740a0cd0a12d190a6bc3125b6030830aea16602513d740f39ebdcd57291344536
data/README.md CHANGED
@@ -1,85 +1,85 @@
1
- <img src="https://raw.githubusercontent.com/ErvalhouS/APIcasso/master/APIcasso.png" width="300" /> [![Gem Version](https://badge.fury.io/rb/apicasso.svg)](https://badge.fury.io/rb/apicasso) [![Docs Coverage](https://inch-ci.org/github/autoforce/APIcasso.svg?branch=master)](https://inch-ci.org/github/autoforce/APIcasso.svg?branch=master) [![Maintainability](https://api.codeclimate.com/v1/badges/b58bbd6b9a0376f7cfc8/maintainability)](https://codeclimate.com/github/autoforce/APIcasso/maintainability) [![codecov](https://codecov.io/gh/autoforce/APIcasso/branch/master/graph/badge.svg)](https://codecov.io/gh/autoforce/APIcasso) [![Build Status](https://travis-ci.org/autoforce/APIcasso.svg?branch=master)](https://travis-ci.org/autoforce/APIcasso)
2
-
3
- JSON API development can get boring and time consuming. If you think it through, every time you make one you use almost the same route structure, pointing to the same controller actions, with the same ordering, filtering and pagination features.
4
-
5
- **APIcasso** is intended to be used as a full-fledged CRUD JSON API or as a base controller to speed-up development.
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
-
8
- ## Installation
9
- Add this line to your application's `Gemfile`:
10
-
11
- ```ruby
12
- gem 'apicasso'
13
- ```
14
-
15
- And then execute this to generate the required migrations:
16
- ```bash
17
- $ rails g apicasso:install
18
- ```
19
- You will need to use a database with JSON fields support to use this gem.
20
-
21
- ## Usage
22
- After installing APIcasso into your application 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`:
23
- ```ruby
24
- # To mount your APIcasso routes under the path scope `/api/v1`
25
- mount Apicasso::Engine, at: "/api/v1"
26
- # or, if you prefer subdomain scope isolation
27
- constraints subdomain: 'apiv1' do
28
- mount Apicasso::Engine, at: "/"
29
- end
30
- ```
31
- Your API will reflect very similarly a `resources :resource` statement with the following routes:
32
- ```ruby
33
- get '/:resource/' # Index action, listing a `:resource` collection from your application
34
- post '/:resource/' # Create action for one `:resource` from your application
35
- get '/:resource/:id' # Show action for one `:resource` from your application
36
- patch '/:resource/:id' # Update action for one `:resource` from your application
37
- delete '/:resource/:id' # Destroy action for one `:resource` from your application
38
- get '/:resource/:id/:nested/' # Index action, listing a collection of a `:nested` relation from one of your application's `:resource`
39
- options '/:resource/' # A schema dump for the required `:resource`
40
- options '/:resource/:id/:nested/' # A schema dump for the required `:nested` relation from one of your application's `:resource`
41
- ```
42
- This means all your application's models will be exposed as `:resource` and it's relations will be exposed as `:nested`. It will enable you to CRUD and get schema metadata from your records.
43
-
44
- > But this is permissive as hell! I do not want to expose my entire application like this, haven't you thought about security?
45
-
46
- *Sure!* The API is being exposed 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).
47
-
48
- Your API is then exposed based on each `Apicasso::Key.scope` definition
49
- ```ruby
50
- Apicasso::Key.create(scope:
51
- { manage:
52
- [{ order: true }, { user: { account_id: 1 } }],
53
- read:
54
- [{ account: { id: 1 } }]
55
- })
56
- ```
57
- This translates directly into which parts of your application is exposed to each APIcasso keys.
58
-
59
- 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`.
60
-
61
- This saves you the trouble of having to setup each and every controller for each model. And even if your application really need it, just make your controllers inherit from `Apicasso::CrudController` and extend it's functionalities. This authorization feature is why one of the dependencies for this gem is [CanCanCan](https://github.com/CanCanCommunity/cancancan), that abstracts the scope field into authorization for your application's resources.
62
-
63
- The `crud#index` and `crud#nested_index` actions are already equipped with pagination, ordering and filtering.
64
-
65
- - You can pass `params[:sort]` with field names preffixed with `+` or `-` to configure custom ordering per request. I.E.: `?sort=+updated_at,-name`
66
- - You can pass `params[:q]` using [ransack's search matchers](https://github.com/activerecord-hackery/ransack#search-matchers) to build a search query. I.E.: `?q[full_name_start]=Picasso`
67
- - You can pass `params[:page]` and `params[:per_page]` to build pagination options. I.E.: `?page=2&per_page=12`
68
-
69
- ## Contributing
70
- 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/).
71
-
72
- ## License
73
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
74
-
75
- ## Code of conduct
76
- 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).
77
-
78
- ## TODO
79
-
80
- - Abstract a configurable CORS approach.
81
- - Add gem options like: Token rotation, Alternative authentication methods
82
- - Response fields selecting
83
- - Rate limiting
84
- - Testing suite
85
- - Travis CI
1
+ <img src="https://raw.githubusercontent.com/ErvalhouS/APIcasso/master/APIcasso.png" width="300" /> [![Gem Version](https://badge.fury.io/rb/apicasso.svg)](https://badge.fury.io/rb/apicasso) [![Docs Coverage](https://inch-ci.org/github/autoforce/APIcasso.svg?branch=master)](https://inch-ci.org/github/autoforce/APIcasso.svg?branch=master) [![Maintainability](https://api.codeclimate.com/v1/badges/b58bbd6b9a0376f7cfc8/maintainability)](https://codeclimate.com/github/autoforce/APIcasso/maintainability) [![codecov](https://codecov.io/gh/autoforce/APIcasso/branch/master/graph/badge.svg)](https://codecov.io/gh/autoforce/APIcasso) [![Build Status](https://travis-ci.org/autoforce/APIcasso.svg?branch=master)](https://travis-ci.org/autoforce/APIcasso)
2
+
3
+ JSON API development can get boring and time consuming. If you think it through, every time you make one you use almost the same route structure, pointing to the same controller actions, with the same ordering, filtering and pagination features.
4
+
5
+ **APIcasso** is intended to be used as a full-fledged CRUD JSON API or as a base controller to speed-up development.
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
+
8
+ ## Installation
9
+ Add this line to your application's `Gemfile`:
10
+
11
+ ```ruby
12
+ gem 'apicasso'
13
+ ```
14
+
15
+ And then execute this to generate the required migrations:
16
+ ```bash
17
+ $ rails g apicasso:install
18
+ ```
19
+ You will need to use a database with JSON fields support to use this gem.
20
+
21
+ ## Usage
22
+ After installing APIcasso into your application 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`:
23
+ ```ruby
24
+ # To mount your APIcasso routes under the path scope `/api/v1`
25
+ mount Apicasso::Engine, at: "/api/v1"
26
+ # or, if you prefer subdomain scope isolation
27
+ constraints subdomain: 'apiv1' do
28
+ mount Apicasso::Engine, at: "/"
29
+ end
30
+ ```
31
+ Your API will reflect very similarly a `resources :resource` statement with the following routes:
32
+ ```ruby
33
+ get '/:resource/' # Index action, listing a `:resource` collection from your application
34
+ post '/:resource/' # Create action for one `:resource` from your application
35
+ get '/:resource/:id' # Show action for one `:resource` from your application
36
+ patch '/:resource/:id' # Update action for one `:resource` from your application
37
+ delete '/:resource/:id' # Destroy action for one `:resource` from your application
38
+ get '/:resource/:id/:nested/' # Index action, listing a collection of a `:nested` relation from one of your application's `:resource`
39
+ options '/:resource/' # A schema dump for the required `:resource`
40
+ options '/:resource/:id/:nested/' # A schema dump for the required `:nested` relation from one of your application's `:resource`
41
+ ```
42
+ This means all your application's models will be exposed as `:resource` and it's relations will be exposed as `:nested`. It will enable you to CRUD and get schema metadata from your records.
43
+
44
+ > But this is permissive as hell! I do not want to expose my entire application like this, haven't you thought about security?
45
+
46
+ *Sure!* The API is being exposed 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).
47
+
48
+ Your API is then exposed based on each `Apicasso::Key.scope` definition
49
+ ```ruby
50
+ Apicasso::Key.create(scope:
51
+ { manage:
52
+ [{ order: true }, { user: { account_id: 1 } }],
53
+ read:
54
+ [{ account: { id: 1 } }]
55
+ })
56
+ ```
57
+ This translates directly into which parts of your application is exposed to each APIcasso keys.
58
+
59
+ 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`.
60
+
61
+ This saves you the trouble of having to setup each and every controller for each model. And even if your application really need it, just make your controllers inherit from `Apicasso::CrudController` and extend it's functionalities. This authorization feature is why one of the dependencies for this gem is [CanCanCan](https://github.com/CanCanCommunity/cancancan), that abstracts the scope field into authorization for your application's resources.
62
+
63
+ The `crud#index` and `crud#nested_index` actions are already equipped with pagination, ordering and filtering.
64
+
65
+ - You can pass `params[:sort]` with field names preffixed with `+` or `-` to configure custom ordering per request. I.E.: `?sort=+updated_at,-name`
66
+ - You can pass `params[:q]` using [ransack's search matchers](https://github.com/activerecord-hackery/ransack#search-matchers) to build a search query. I.E.: `?q[full_name_start]=Picasso`
67
+ - You can pass `params[:page]` and `params[:per_page]` to build pagination options. I.E.: `?page=2&per_page=12`
68
+
69
+ ## Contributing
70
+ 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/).
71
+
72
+ ## License
73
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
74
+
75
+ ## Code of conduct
76
+ 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).
77
+
78
+ ## TODO
79
+
80
+ - Abstract a configurable CORS approach.
81
+ - Add gem options like: Token rotation, Alternative authentication methods
82
+ - Response fields selecting
83
+ - Rate limiting
84
+ - Testing suite
85
+ - Travis CI
data/Rakefile CHANGED
@@ -1,32 +1,32 @@
1
- begin
2
- require 'bundler/setup'
3
- rescue LoadError
4
- puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
5
- end
6
-
7
- require 'rdoc/task'
8
-
9
- RDoc::Task.new(:rdoc) do |rdoc|
10
- rdoc.rdoc_dir = 'rdoc'
11
- rdoc.title = 'Apicasso'
12
- rdoc.options << '--line-numbers'
13
- rdoc.rdoc_files.include('README.md')
14
- rdoc.rdoc_files.include('lib/**/*.rb')
15
- end
16
-
17
- APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
18
- load 'rails/tasks/engine.rake'
19
-
20
- load 'rails/tasks/statistics.rake'
21
-
22
- require 'bundler/gem_tasks'
23
-
24
- require 'rake/testtask'
25
-
26
- Rake::TestTask.new(:test) do |t|
27
- t.libs << 'test'
28
- t.pattern = 'test/**/*_test.rb'
29
- t.verbose = false
30
- end
31
-
32
- task default: :test
1
+ begin
2
+ require 'bundler/setup'
3
+ rescue LoadError
4
+ puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
5
+ end
6
+
7
+ require 'rdoc/task'
8
+
9
+ RDoc::Task.new(:rdoc) do |rdoc|
10
+ rdoc.rdoc_dir = 'rdoc'
11
+ rdoc.title = 'Apicasso'
12
+ rdoc.options << '--line-numbers'
13
+ rdoc.rdoc_files.include('README.md')
14
+ rdoc.rdoc_files.include('lib/**/*.rb')
15
+ end
16
+
17
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
18
+ load 'rails/tasks/engine.rake'
19
+
20
+ load 'rails/tasks/statistics.rake'
21
+
22
+ require 'bundler/gem_tasks'
23
+
24
+ require 'rake/testtask'
25
+
26
+ Rake::TestTask.new(:test) do |t|
27
+ t.libs << 'test'
28
+ t.pattern = 'test/**/*_test.rb'
29
+ t.verbose = false
30
+ end
31
+
32
+ task default: :test
@@ -1,104 +1,104 @@
1
- # frozen_string_literal: true
2
-
3
- module Apicasso
4
- # Controller to extract common API features,
5
- # such as authentication and authorization
6
- class ApplicationController < ActionController::API
7
- include ActionController::HttpAuthentication::Token::ControllerMethods
8
- prepend_before_action :restrict_access
9
- after_action :register_api_request
10
-
11
- # Sets the authorization scope for the current API key
12
- def current_ability
13
- @current_ability ||= Apicasso::Ability.new(@api_key)
14
- end
15
-
16
- private
17
-
18
- # Identifies API key used in the request, avoiding unauthenticated access
19
- def restrict_access
20
- authenticate_or_request_with_http_token do |token, _options|
21
- @api_key = Apicasso::Key.find_by!(token: token)
22
- end
23
- end
24
-
25
- # Creates a request object in databse, registering the API key and
26
- # a hash of the request and the response
27
- def register_api_request
28
- Apicasso::Request.delay.create(api_key_id: @api_key.id,
29
- object: { request: request_hash,
30
- response: response_hash })
31
- end
32
-
33
- # Request data built as a hash.
34
- # Returns UUID, URL, HTTP Headers and origin IP
35
- def request_hash
36
- {
37
- uuid: request.uuid,
38
- url: request.original_url,
39
- headers: request.env.select { |key, _v| key =~ /^HTTP_/ },
40
- ip: request.remote_ip
41
- }
42
- end
43
-
44
- # Resonse data built as a hash.
45
- # Returns HTTP Status and request body
46
- def response_hash
47
- {
48
- status: response.status,
49
- body: JSON.parse(response.body)
50
- }
51
- end
52
-
53
- # Used to avoid errors parsing the search query,
54
- # which can be passed as a JSON or as a key-value param
55
- def parsed_query
56
- JSON.parse(params[:q])
57
- rescue JSON::ParserError, TypeError
58
- params[:q]
59
- end
60
-
61
- # Used to avoid errors in included associations parsing
62
- def parsed_include
63
- params[:include].split(',')
64
- rescue NoMethodError
65
- []
66
- end
67
-
68
- # Receives a `.paginate`d collection and returns the pagination
69
- # metadata to be merged into response
70
- def pagination_metadata_for(records)
71
- { total: records.total_entries,
72
- total_pages: records.total_pages,
73
- last_page: records.next_page.blank?,
74
- previous_page: previous_link_for(records),
75
- next_page: next_link_for(records),
76
- out_of_bounds: records.out_of_bounds?,
77
- offset: records.offset }
78
- end
79
-
80
- # Generates a contextualized URL of the next page for this request
81
- def next_link_for(records)
82
- uri = URI.parse(request.original_url)
83
- query = Rack::Utils.parse_query(uri.query)
84
- query['page'] = records.next_page
85
- uri.query = Rack::Utils.build_query(query)
86
- uri.to_s
87
- end
88
-
89
- # Generates a contextualized URL of the previous page for this request
90
- def previous_link_for(records)
91
- uri = URI.parse(request.original_url)
92
- query = Rack::Utils.parse_query(uri.query)
93
- query['page'] = records.previous_page
94
- uri.query = Rack::Utils.build_query(query)
95
- uri.to_s
96
- end
97
-
98
- # Receives a `:action, :resource, :object` hash to validate authorization
99
- def authorize_for(opts = {})
100
- authorize! opts[:action], opts[:resource] if opts[:resource].present?
101
- authorize! opts[:action], opts[:object] if opts[:object].present?
102
- end
103
- end
104
- end
1
+ # frozen_string_literal: true
2
+
3
+ module Apicasso
4
+ # Controller to extract common API features,
5
+ # such as authentication and authorization
6
+ class ApplicationController < ActionController::API
7
+ include ActionController::HttpAuthentication::Token::ControllerMethods
8
+ prepend_before_action :restrict_access
9
+ after_action :register_api_request
10
+
11
+ # Sets the authorization scope for the current API key
12
+ def current_ability
13
+ @current_ability ||= Apicasso::Ability.new(@api_key)
14
+ end
15
+
16
+ private
17
+
18
+ # Identifies API key used in the request, avoiding unauthenticated access
19
+ def restrict_access
20
+ authenticate_or_request_with_http_token do |token, _options|
21
+ @api_key = Apicasso::Key.find_by!(token: token)
22
+ end
23
+ end
24
+
25
+ # Creates a request object in databse, registering the API key and
26
+ # a hash of the request and the response
27
+ def register_api_request
28
+ Apicasso::Request.delay.create(api_key_id: @api_key.id,
29
+ object: { request: request_hash,
30
+ response: response_hash })
31
+ end
32
+
33
+ # Request data built as a hash.
34
+ # Returns UUID, URL, HTTP Headers and origin IP
35
+ def request_hash
36
+ {
37
+ uuid: request.uuid,
38
+ url: request.original_url,
39
+ headers: request.env.select { |key, _v| key =~ /^HTTP_/ },
40
+ ip: request.remote_ip
41
+ }
42
+ end
43
+
44
+ # Resonse data built as a hash.
45
+ # Returns HTTP Status and request body
46
+ def response_hash
47
+ {
48
+ status: response.status,
49
+ body: JSON.parse(response.body)
50
+ }
51
+ end
52
+
53
+ # Used to avoid errors parsing the search query,
54
+ # which can be passed as a JSON or as a key-value param
55
+ def parsed_query
56
+ JSON.parse(params[:q])
57
+ rescue JSON::ParserError, TypeError
58
+ params[:q]
59
+ end
60
+
61
+ # Used to avoid errors in included associations parsing
62
+ def parsed_include
63
+ params[:include].split(',')
64
+ rescue NoMethodError
65
+ []
66
+ end
67
+
68
+ # Receives a `.paginate`d collection and returns the pagination
69
+ # metadata to be merged into response
70
+ def pagination_metadata_for(records)
71
+ { total: records.total_entries,
72
+ total_pages: records.total_pages,
73
+ last_page: records.next_page.blank?,
74
+ previous_page: previous_link_for(records),
75
+ next_page: next_link_for(records),
76
+ out_of_bounds: records.out_of_bounds?,
77
+ offset: records.offset }
78
+ end
79
+
80
+ # Generates a contextualized URL of the next page for this request
81
+ def next_link_for(records)
82
+ uri = URI.parse(request.original_url)
83
+ query = Rack::Utils.parse_query(uri.query)
84
+ query['page'] = records.next_page
85
+ uri.query = Rack::Utils.build_query(query)
86
+ uri.to_s
87
+ end
88
+
89
+ # Generates a contextualized URL of the previous page for this request
90
+ def previous_link_for(records)
91
+ uri = URI.parse(request.original_url)
92
+ query = Rack::Utils.parse_query(uri.query)
93
+ query['page'] = records.previous_page
94
+ uri.query = Rack::Utils.build_query(query)
95
+ uri.to_s
96
+ end
97
+
98
+ # Receives a `:action, :resource, :object` hash to validate authorization
99
+ def authorize_for(opts = {})
100
+ authorize! opts[:action], opts[:resource] if opts[:resource].present?
101
+ authorize! opts[:action], opts[:object] if opts[:object].present?
102
+ end
103
+ end
104
+ end
@@ -1,156 +1,155 @@
1
- # frozen_string_literal: true
2
-
3
- module Apicasso
4
- # Controller to consume read-only data to be used on client's frontend
5
- class CrudController < Apicasso::ApplicationController
6
- before_action :set_root_resource
7
- before_action :set_object, except: %i[index schema create]
8
- before_action :set_nested_resource, only: %i[nested_index]
9
- before_action :set_records, only: %i[index nested_index]
10
- before_action :set_schema, only: %i[schema]
11
-
12
- include Orderable
13
-
14
- # GET /:resource
15
- # Returns a paginated, ordered and filtered query based response.
16
- # Consider this
17
- # To get all `Channel` sorted by ascending `name` and descending
18
- # `updated_at`, filtered by the ones that have a `domain` that matches
19
- # exactly `"domain.com"`, paginating records 42 per page and retrieving
20
- # the page 42 of that collection. Usage:
21
- # GET /sites?sort=+name,-updated_at&q[domain_eq]=domain.com&page=42&per_page=42
22
- def index
23
- render json: response_json
24
- end
25
-
26
- # GET /:resource/1
27
- def show
28
- render json: @object.to_json(include: parsed_include)
29
- end
30
-
31
- # PATCH/PUT /:resource/1
32
- def update
33
- authorize_for(action: :update,
34
- resource: resource.name.underscore.to_sym,
35
- object: @object)
36
- if @object.update(object_params)
37
- render json: @object
38
- else
39
- render json: @object.errors, status: :unprocessable_entity
40
- end
41
- end
42
-
43
- # DELETE /:resource/1
44
- def destroy
45
- authorize_for(action: :destroy,
46
- resource: resource.name.underscore.to_sym,
47
- object: @object)
48
- if @object.destroy
49
- head :no_content
50
- else
51
- render json: @object.errors, status: :unprocessable_entity
52
- end
53
- end
54
-
55
- # GET /:resource/1/:nested_resource
56
- alias nested_index index
57
-
58
- # POST /:resource
59
- def create
60
- @object = resource.new(resource_params)
61
- authorize_for(action: :create,
62
- resource: resource.name.underscore.to_sym,
63
- object: @object)
64
- if @object.save
65
- render json: @object, status: :created, location: @object
66
- else
67
- render json: @object.errors, status: :unprocessable_entity
68
- end
69
- end
70
-
71
- # OPTIONS /:resource
72
- # OPTIONS /:resource/1/:nested_resource
73
- # Will return a JSON with the schema of the current resource, using
74
- # attribute names as keys and attirbute types as values.
75
- def schema
76
- set_access_control_headers
77
- render json: resource_schema.to_json
78
- end
79
-
80
- private
81
-
82
- def set_access_control_headers
83
- response.headers['Access-Control-Allow-Origin'] = '*'
84
- response.headers['Access-Control-Allow-Methods'] = 'POST, GET, PUT, PATCH, DELETE, OPTIONS'
85
- response.headers['Access-Control-Allow-Headers'] = 'Origin, Content-Type, Accept, Authorization, Token, Auth-Token, Email, X-User-Token, X-User-Email'
86
- response.headers['Access-Control-Max-Age'] = '1728000'
87
- end
88
-
89
- # Common setup to stablish which model is the resource of this request
90
- def set_root_resource
91
- @root_resource = params[:resource].classify.constantize
92
- end
93
-
94
- # Common setup to stablish which object this request is querying
95
- def set_object
96
- id = params[:id]
97
- @object = resource.friendly.find(id)
98
- rescue NoMethodError
99
- @object = resource.find(id)
100
- ensure
101
- authorize! :read, @object
102
- end
103
-
104
- # Setup to stablish the nested model to be queried
105
- def set_nested_resource
106
- @nested_resource = @object.send(params[:nested].underscore.pluralize)
107
- end
108
-
109
- # Reutrns root_resource if nested_resource is not set scoped by permissions
110
- def resource
111
- (@nested_resource || @root_resource)
112
- end
113
-
114
- # Used to setup the resource's schema, mapping attributes and it's types
115
- def resource_schema
116
- schemated = {}
117
- resource.columns_hash.each { |key, value| schemated[key] = value.type }
118
- schemated
119
- end
120
-
121
- # Used to setup the records from the selected resource that are
122
- # going to be rendered, if authorized
123
- def set_records
124
- authorize! :read, resource.name.underscore.to_sym
125
- @records = resource.ransack(parsed_query).result
126
- reorder_records if params[:sort].present?
127
- end
128
-
129
- # Reordering of records which happens when receiving `params[:sort]`
130
- def reorder_records
131
- @records = @records.unscope(:order).order(ordering_params(params))
132
- end
133
-
134
- # Raw paginated records object
135
- def paginated_records
136
- @records.accessible_by(current_ability)
137
- .paginate(page: params[:page], per_page: params[:per_page])
138
- end
139
-
140
- # Parsing of `paginated_records` with pagination variables metadata
141
- def response_json
142
- { entries: entries_json }.merge(pagination_metadata_for(paginated_records))
143
- end
144
-
145
- # Parsed JSON to be used as response payload
146
- def entries_json
147
- JSON.parse(paginated_records.to_json(include: parsed_include))
148
- end
149
-
150
- # Only allow a trusted parameter "white list" through,
151
- # based on resource's schema.
152
- def object_params
153
- params.fetch(resource.name.underscore.to_sym, resource_schema.keys)
154
- end
155
- end
156
- end
1
+ # frozen_string_literal: true
2
+
3
+ module Apicasso
4
+ # Controller to consume read-only data to be used on client's frontend
5
+ class CrudController < Apicasso::ApplicationController
6
+ before_action :set_root_resource
7
+ before_action :set_object, except: %i[index schema create]
8
+ before_action :set_nested_resource, only: %i[nested_index]
9
+ before_action :set_records, only: %i[index nested_index]
10
+
11
+ include Orderable
12
+
13
+ # GET /:resource
14
+ # Returns a paginated, ordered and filtered query based response.
15
+ # Consider this
16
+ # To get all `Channel` sorted by ascending `name` and descending
17
+ # `updated_at`, filtered by the ones that have a `domain` that matches
18
+ # exactly `"domain.com"`, paginating records 42 per page and retrieving
19
+ # the page 42 of that collection. Usage:
20
+ # GET /sites?sort=+name,-updated_at&q[domain_eq]=domain.com&page=42&per_page=42
21
+ def index
22
+ render json: response_json
23
+ end
24
+
25
+ # GET /:resource/1
26
+ def show
27
+ render json: @object.to_json(include: parsed_include)
28
+ end
29
+
30
+ # PATCH/PUT /:resource/1
31
+ def update
32
+ authorize_for(action: :update,
33
+ resource: resource.name.underscore.to_sym,
34
+ object: @object)
35
+ if @object.update(object_params)
36
+ render json: @object
37
+ else
38
+ render json: @object.errors, status: :unprocessable_entity
39
+ end
40
+ end
41
+
42
+ # DELETE /:resource/1
43
+ def destroy
44
+ authorize_for(action: :destroy,
45
+ resource: resource.name.underscore.to_sym,
46
+ object: @object)
47
+ if @object.destroy
48
+ head :no_content
49
+ else
50
+ render json: @object.errors, status: :unprocessable_entity
51
+ end
52
+ end
53
+
54
+ # GET /:resource/1/:nested_resource
55
+ alias nested_index index
56
+
57
+ # POST /:resource
58
+ def create
59
+ @object = resource.new(resource_params)
60
+ authorize_for(action: :create,
61
+ resource: resource.name.underscore.to_sym,
62
+ object: @object)
63
+ if @object.save
64
+ render json: @object, status: :created, location: @object
65
+ else
66
+ render json: @object.errors, status: :unprocessable_entity
67
+ end
68
+ end
69
+
70
+ # OPTIONS /:resource
71
+ # OPTIONS /:resource/1/:nested_resource
72
+ # Will return a JSON with the schema of the current resource, using
73
+ # attribute names as keys and attirbute types as values.
74
+ def schema
75
+ set_access_control_headers
76
+ render json: resource_schema.to_json
77
+ end
78
+
79
+ private
80
+
81
+ def set_access_control_headers
82
+ response.headers['Access-Control-Allow-Origin'] = '*'
83
+ response.headers['Access-Control-Allow-Methods'] = 'POST, GET, PUT, PATCH, DELETE, OPTIONS'
84
+ response.headers['Access-Control-Allow-Headers'] = 'Origin, Content-Type, Accept, Authorization, Token, Auth-Token, Email, X-User-Token, X-User-Email'
85
+ response.headers['Access-Control-Max-Age'] = '1728000'
86
+ end
87
+
88
+ # Common setup to stablish which model is the resource of this request
89
+ def set_root_resource
90
+ @root_resource = params[:resource].classify.constantize
91
+ end
92
+
93
+ # Common setup to stablish which object this request is querying
94
+ def set_object
95
+ id = params[:id]
96
+ @object = resource.friendly.find(id)
97
+ rescue NoMethodError
98
+ @object = resource.find(id)
99
+ ensure
100
+ authorize! :read, @object
101
+ end
102
+
103
+ # Setup to stablish the nested model to be queried
104
+ def set_nested_resource
105
+ @nested_resource = @object.send(params[:nested].underscore.pluralize)
106
+ end
107
+
108
+ # Reutrns root_resource if nested_resource is not set scoped by permissions
109
+ def resource
110
+ (@nested_resource || @root_resource)
111
+ end
112
+
113
+ # Used to setup the resource's schema, mapping attributes and it's types
114
+ def resource_schema
115
+ schemated = {}
116
+ resource.columns_hash.each { |key, value| schemated[key] = value.type }
117
+ schemated
118
+ end
119
+
120
+ # Used to setup the records from the selected resource that are
121
+ # going to be rendered, if authorized
122
+ def set_records
123
+ authorize! :read, resource.name.underscore.to_sym
124
+ @records = resource.ransack(parsed_query).result
125
+ reorder_records if params[:sort].present?
126
+ end
127
+
128
+ # Reordering of records which happens when receiving `params[:sort]`
129
+ def reorder_records
130
+ @records = @records.unscope(:order).order(ordering_params(params))
131
+ end
132
+
133
+ # Raw paginated records object
134
+ def paginated_records
135
+ @records.accessible_by(current_ability)
136
+ .paginate(page: params[:page], per_page: params[:per_page])
137
+ end
138
+
139
+ # Parsing of `paginated_records` with pagination variables metadata
140
+ def response_json
141
+ { entries: entries_json }.merge(pagination_metadata_for(paginated_records))
142
+ end
143
+
144
+ # Parsed JSON to be used as response payload
145
+ def entries_json
146
+ JSON.parse(paginated_records.to_json(include: parsed_include))
147
+ end
148
+
149
+ # Only allow a trusted parameter "white list" through,
150
+ # based on resource's schema.
151
+ def object_params
152
+ params.fetch(resource.name.underscore.to_sym, resource_schema.keys)
153
+ end
154
+ end
155
+ end
@@ -1,44 +1,44 @@
1
- # frozen_string_literal: true
2
-
3
- # This concern is used to provide abstract ordering based on `params[:sort]`
4
- module Orderable
5
- extend ActiveSupport::Concern
6
- SORT_ORDER = { '+' => :asc, '-' => :desc }.freeze
7
-
8
- # A list of the param names that can be used for ordering the model list
9
- def ordering_params(params)
10
- # For example it retrieves a list of orders in descending order of total_value.
11
- # Within a specific total_value, older orders are ordered first
12
- #
13
- # GET /orders?sort=-total_value,created_at
14
- # ordering_params(params) # => { total_value: :desc, created_at: :asc }
15
- #
16
- # Usage:
17
- # Order.order(ordering_params(params))
18
- ordering = {}
19
- params[:sort].try(:split, ',').try(:each) do |attr|
20
- parsed_attr = parse_attr attr
21
- if model.attribute_names.include?(parsed_attr)
22
- ordering[parsed_attr] = SORT_ORDER[parse_sign attr]
23
- end
24
- end
25
- ordering
26
- end
27
-
28
- private
29
-
30
- # Parsing of attributes to avoid empty starts in case browser passes "+" as " "
31
- def parse_attr(attr)
32
- return attr.gsub(/^\ (.*)/, '\1') if attr.starts_with?(' ')
33
- attr[1..-1]
34
- end
35
-
36
- # Ordering sign parse, which separates
37
- def parse_sign(attr)
38
- attr =~ /\A[+-]/ ? attr.slice!(0) : '+'
39
- end
40
-
41
- def model
42
- (params[:resource] || params[:nested] || controller_name).classify.constantize
43
- end
44
- end
1
+ # frozen_string_literal: true
2
+
3
+ # This concern is used to provide abstract ordering based on `params[:sort]`
4
+ module Orderable
5
+ extend ActiveSupport::Concern
6
+ SORT_ORDER = { '+' => :asc, '-' => :desc }.freeze
7
+
8
+ # A list of the param names that can be used for ordering the model list
9
+ def ordering_params(params)
10
+ # For example it retrieves a list of orders in descending order of total_value.
11
+ # Within a specific total_value, older orders are ordered first
12
+ #
13
+ # GET /orders?sort=-total_value,created_at
14
+ # ordering_params(params) # => { total_value: :desc, created_at: :asc }
15
+ #
16
+ # Usage:
17
+ # Order.order(ordering_params(params))
18
+ ordering = {}
19
+ params[:sort].try(:split, ',').try(:each) do |attr|
20
+ parsed_attr = parse_attr attr
21
+ if model.attribute_names.include?(parsed_attr)
22
+ ordering[parsed_attr] = SORT_ORDER[parse_sign attr]
23
+ end
24
+ end
25
+ ordering
26
+ end
27
+
28
+ private
29
+
30
+ # Parsing of attributes to avoid empty starts in case browser passes "+" as " "
31
+ def parse_attr(attr)
32
+ return attr.gsub(/^\ (.*)/, '\1') if attr.starts_with?(' ')
33
+ attr[1..-1]
34
+ end
35
+
36
+ # Ordering sign parse, which separates
37
+ def parse_sign(attr)
38
+ attr =~ /\A[+-]/ ? attr.slice!(0) : '+'
39
+ end
40
+
41
+ def model
42
+ (params[:resource] || params[:nested] || controller_name).classify.constantize
43
+ end
44
+ end
@@ -1,40 +1,40 @@
1
- # frozen_string_literal: true
2
-
3
- module Apicasso
4
- # Ability to parse a scope object from Apicasso::Key
5
- class Ability
6
- include CanCan::Ability
7
-
8
- def initialize(key)
9
- key ||= Apicasso::Key.new
10
- cannot :manage, :all
11
- cannot :read, :all
12
- key.scope.each do |permission, klasses_clearances|
13
- klasses_clearances.each do |klasses|
14
- klasses.each do |klass, clearance|
15
- if clearance == true
16
- # Usage:
17
- # To have a key reading all channels and all accouts
18
- # you would have a scope:
19
- # => `{read: [{channel: true}, {accout: true}]}`
20
- can permission.to_sym, klass.underscore.to_sym
21
- can permission.to_sym, klass.classify.constantize
22
- elsif clearance.class == Hash
23
- # Usage:
24
- # To have a key reading all banners from a channel with id 999
25
- # you would have a scope:
26
- # => `{read: [{banner: {owner_id: [999]}}]}`
27
- can permission.to_sym,
28
- klass.underscore.to_sym
29
- clearance.each do |by_field, values|
30
- can permission.to_sym,
31
- klass.classify.constantize,
32
- by_field => values
33
- end
34
- end
35
- end
36
- end
37
- end
38
- end
39
- end
40
- end
1
+ # frozen_string_literal: true
2
+
3
+ module Apicasso
4
+ # Ability to parse a scope object from Apicasso::Key
5
+ class Ability
6
+ include CanCan::Ability
7
+
8
+ def initialize(key)
9
+ key ||= Apicasso::Key.new
10
+ cannot :manage, :all
11
+ cannot :read, :all
12
+ key.scope.each do |permission, klasses_clearances|
13
+ klasses_clearances.each do |klasses|
14
+ klasses.each do |klass, clearance|
15
+ if clearance == true
16
+ # Usage:
17
+ # To have a key reading all channels and all accouts
18
+ # you would have a scope:
19
+ # => `{read: [{channel: true}, {accout: true}]}`
20
+ can permission.to_sym, klass.underscore.to_sym
21
+ can permission.to_sym, klass.classify.constantize
22
+ elsif clearance.class == Hash
23
+ # Usage:
24
+ # To have a key reading all banners from a channel with id 999
25
+ # you would have a scope:
26
+ # => `{read: [{banner: {owner_id: [999]}}]}`
27
+ can permission.to_sym,
28
+ klass.underscore.to_sym
29
+ clearance.each do |by_field, values|
30
+ can permission.to_sym,
31
+ klass.classify.constantize,
32
+ by_field => values
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -1,6 +1,6 @@
1
- module Apicasso
2
- class ApplicationRecord < ActiveRecord::Base
3
- self.abstract_class = true
4
- self.table_name_prefix = 'apicasso_'
5
- end
6
- end
1
+ module Apicasso
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ self.table_name_prefix = 'apicasso_'
5
+ end
6
+ end
@@ -1,25 +1,25 @@
1
- # frozen_string_literal: true
2
-
3
- require 'securerandom'
4
- module Apicasso
5
- # A model to abstract API access, with scope options, token generation, request limiting
6
- class Key < ApplicationRecord
7
- before_create :set_auth_token
8
-
9
- private
10
-
11
- # Method that generates `SecureRandom.uuid` as token until
12
- # an unique one has been acquired
13
- def set_auth_token
14
- loop do
15
- self.token = generate_auth_token
16
- break unless self.class.exists?(token: token)
17
- end
18
- end
19
-
20
- # RFC4122 style token
21
- def generate_auth_token
22
- SecureRandom.uuid.delete('-')
23
- end
24
- end
25
- end
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ module Apicasso
5
+ # A model to abstract API access, with scope options, token generation, request limiting
6
+ class Key < ApplicationRecord
7
+ before_create :set_auth_token
8
+
9
+ private
10
+
11
+ # Method that generates `SecureRandom.uuid` as token until
12
+ # an unique one has been acquired
13
+ def set_auth_token
14
+ loop do
15
+ self.token = generate_auth_token
16
+ break unless self.class.exists?(token: token)
17
+ end
18
+ end
19
+
20
+ # RFC4122 style token
21
+ def generate_auth_token
22
+ SecureRandom.uuid.delete('-')
23
+ end
24
+ end
25
+ end
@@ -1,8 +1,8 @@
1
- # frozen_string_literal: true
2
-
3
- module Apicasso
4
- # A model to abstract API access, with scope options, token generation, request limiting
5
- class Request < ApplicationRecord
6
- belongs_to :api_key, class_name: 'Apicasso::Key'
7
- end
8
- end
1
+ # frozen_string_literal: true
2
+
3
+ module Apicasso
4
+ # A model to abstract API access, with scope options, token generation, request limiting
5
+ class Request < ApplicationRecord
6
+ belongs_to :api_key, class_name: 'Apicasso::Key'
7
+ end
8
+ end
data/config/routes.rb CHANGED
@@ -1,13 +1,13 @@
1
- Apicasso::Engine.routes.draw do
2
- scope module: :apicasso do
3
- resources :apidocs, only: [:index]
4
- get '/:resource/', to: 'crud#index', via: :get
5
- match '/:resource/', to: 'crud#create', via: :post
6
- get '/:resource/:id', to: 'crud#show', via: :get
7
- match '/:resource/:id', to: 'crud#update', via: :patch
8
- match '/:resource/:id', to: 'crud#destroy', via: :delete
9
- match '/:resource/:id/:nested/', to: 'crud#nested_index', via: :get
10
- match '/:resource/', to: 'crud#schema', via: :options
11
- match '/:resource/:id/:nested/', to: 'crud#schema', via: :options
12
- end
13
- end
1
+ Apicasso::Engine.routes.draw do
2
+ scope module: :apicasso do
3
+ resources :apidocs, only: [:index]
4
+ get '/:resource/', to: 'crud#index', via: :get
5
+ match '/:resource/', to: 'crud#create', via: :post
6
+ get '/:resource/:id', to: 'crud#show', via: :get
7
+ match '/:resource/:id', to: 'crud#update', via: :patch
8
+ match '/:resource/:id', to: 'crud#destroy', via: :delete
9
+ match '/:resource/:id/:nested/', to: 'crud#nested_index', via: :get
10
+ match '/:resource/', to: 'crud#schema', via: :options
11
+ match '/:resource/:id/:nested/', to: 'crud#schema', via: :options
12
+ end
13
+ end
@@ -1,6 +1,6 @@
1
- # frozen_string_literal: true
2
-
3
- module Apicasso
4
- class Engine < ::Rails::Engine
5
- end
6
- end
1
+ # frozen_string_literal: true
2
+
3
+ module Apicasso
4
+ class Engine < ::Rails::Engine
5
+ end
6
+ end
@@ -1,3 +1,3 @@
1
- module Apicasso
2
- VERSION = '0.1.5'
3
- end
1
+ module Apicasso
2
+ VERSION = '0.1.6'
3
+ end
data/lib/apicasso.rb CHANGED
@@ -1,9 +1,9 @@
1
- # frozen_string_literal: true
2
-
3
- require 'apicasso/version'
4
- require 'apicasso/engine'
5
- require 'apicasso/active_record_extension'
6
-
7
- module Apicasso
8
- # Your code goes here...
9
- end
1
+ # frozen_string_literal: true
2
+
3
+ require 'apicasso/version'
4
+ require 'apicasso/engine'
5
+ require 'apicasso/active_record_extension'
6
+
7
+ module Apicasso
8
+ # Your code goes here...
9
+ end
@@ -1,25 +1,25 @@
1
- require 'rails/generators/migration'
2
-
3
- module Apicasso
4
- module Generators
5
- class InstallGenerator < ::Rails::Generators::Base
6
- include Rails::Generators::Migration
7
- source_root File.expand_path('../templates', __FILE__)
8
- desc 'Add the required migrations to run APIcasso'
9
-
10
- def self.next_migration_number(path)
11
- if @prev_migration_nr
12
- @prev_migration_nr += 1
13
- else
14
- @prev_migration_nr = Time.now.utc.strftime("%Y%m%d%H%M%S").to_i
15
- end
16
- @prev_migration_nr.to_s
17
- end
18
-
19
- def copy_migrations
20
- migration_template 'create_apicasso_tables.rb',
21
- 'db/migrate/create_apicasso_tables.rb'
22
- end
23
- end
24
- end
25
- end
1
+ require 'rails/generators/migration'
2
+
3
+ module Apicasso
4
+ module Generators
5
+ class InstallGenerator < ::Rails::Generators::Base
6
+ include Rails::Generators::Migration
7
+ source_root File.expand_path('../templates', __FILE__)
8
+ desc 'Add the required migrations to run APIcasso'
9
+
10
+ def self.next_migration_number(path)
11
+ if @prev_migration_nr
12
+ @prev_migration_nr += 1
13
+ else
14
+ @prev_migration_nr = Time.now.utc.strftime("%Y%m%d%H%M%S").to_i
15
+ end
16
+ @prev_migration_nr.to_s
17
+ end
18
+
19
+ def copy_migrations
20
+ migration_template 'create_apicasso_tables.rb',
21
+ 'db/migrate/create_apicasso_tables.rb'
22
+ end
23
+ end
24
+ end
25
+ end
@@ -1,17 +1,17 @@
1
- class CreateApicassoTables < ActiveRecord::Migration[5.0]
2
- def change
3
- create_table :apicasso_keys, id: :uuid do |t|
4
- t.json :scope
5
- t.integer :scope_type
6
- t.json :request_limiting
7
- t.text :token
8
- t.datetime :deleted_at
9
- t.timestamps null: false
10
- end
11
- create_table :apicasso_requests, id: :uuid do |t|
12
- t.text :api_key_id
13
- t.json :object
14
- t.timestamps null: false
15
- end
16
- end
17
- end
1
+ class CreateApicassoTables < ActiveRecord::Migration[5.0]
2
+ def change
3
+ create_table :apicasso_keys, id: :uuid do |t|
4
+ t.json :scope
5
+ t.integer :scope_type
6
+ t.json :request_limiting
7
+ t.text :token
8
+ t.datetime :deleted_at
9
+ t.timestamps null: false
10
+ end
11
+ create_table :apicasso_requests, id: :uuid do |t|
12
+ t.text :api_key_id
13
+ t.json :object
14
+ t.timestamps null: false
15
+ end
16
+ end
17
+ end
@@ -1,4 +1,4 @@
1
- # desc "Explaining what the task does"
2
- # task :apicasso do
3
- # # Task goes here
4
- # end
1
+ # desc "Explaining what the task does"
2
+ # task :apicasso do
3
+ # # Task goes here
4
+ # end
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.1.5
4
+ version: 0.1.6
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-08-25 00:00:00.000000000 Z
11
+ date: 2018-08-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: cancancan
@@ -133,7 +133,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
133
133
  version: '0'
134
134
  requirements: []
135
135
  rubyforge_project:
136
- rubygems_version: 2.7.6
136
+ rubygems_version: 2.6.14
137
137
  signing_key:
138
138
  specification_version: 4
139
139
  summary: An abstract API design as a mountable engine