model_driven_api 2.2.4

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f796ea92049910892b48c1904c58192bb7a4b0021a0c0beef89c8e6106f7f64b
4
+ data.tar.gz: 5054f69acc854b47ab9a5825bb950d035882b05ea6285b81592989ab31d29343
5
+ SHA512:
6
+ metadata.gz: bbea75413aa62fceddd25f003e23fd6ec16a37f7027be2af549e2f565d91fd8ad8ef93dc07e2bb5cb8b8121aeaf276370ec649222dec8b4893c87a815c4e8a73
7
+ data.tar.gz: 871479573af6861b9c0aaf9d323668aab7f75afc68452501dd9c1d9253a96cdfc90cd74891e46e0273dfa0abd14357260fe79ab0ffebc92a11766baf2fae28b5
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2020
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,205 @@
1
+ # Model Driven Api
2
+ I've always been interested in effortless, no-fuss, conventions' based development, DRYness, and pragmatic programming, I've always thought that at this point of the technology evolution, we need not to configure too much to have our software run, having the software adapt to data layers and from there building up APIs, visualizations, etc. in an automatic way. This is a first step to have a schema driven API or better model drive, based on the underlining database, the data it has to serve and some sane dafults, or conventions. This effort also gives, thanks to meta programming, an insight on the actual schema, via the info API, the translations available and the DSL which can change the way the data is presented, leading to a strong base for automatica built of UIs consuming the API (react, vue, angular based PWAs, maybe! ;-) ).
3
+
4
+ Doing this means also narrowing a bit the scope of the tools, taking decisions, at least for the first implementations and versions of this engine, so, this works well if the data is relational, this is a prerequisite (postgres, mysql, mssql, etc.).
5
+
6
+ # Goal
7
+
8
+ To have a comprehensive and meaningful API right out of the box by just creating migrations in your rails app or engine.
9
+
10
+ # v2?
11
+
12
+ Yes, this is the second version of such an effort and you can note it from the api calls, which are all under the ```/api/v2``` namespace the [/api/v1](https://github.com/gabrieletassoni/thecore_api) one, was were it all started, many ideas are ported from there, such as the generation of the automatic model based crud actions, as well as custom actions definitions and all the things that make also this gem useful for my daily job were already in place, but it was too coupled with [thecore](https://github.com/gabrieletassoni/thecore)'s [rails_admin](https://github.com/sferik/rails_admin) UI, making it impossible to create a complete UI-less, API only application, out of the box and directly based of the DB schema, with all the bells and whistles I needed (mainly self adapting, data and schema driven API functionalities).
13
+ So it all began again, making a better thecore_api gem into this model_driven_api gem, more polished, more functional and self contained.
14
+
15
+ # Standards Used
16
+
17
+ * [JWT](https://github.com/jwt/ruby-jwt) for authentication.
18
+ * [CanCanCan](https://github.com/CanCanCommunity/cancancan) for authorization.
19
+ * [Ransack](https://github.com/activerecord-hackery/ransack) query engine for complex searches going beyond CRUD's listing scope.
20
+ * Catch all routing rule to automatically add basic crud operations to any AR model in the app.
21
+
22
+ ## Usage
23
+ How to use my plugin.
24
+
25
+ ## Installation
26
+ Add this line to your application's Gemfile:
27
+
28
+ ```ruby
29
+ gem 'model_driven_api'
30
+ ```
31
+
32
+ And then execute:
33
+ ```bash
34
+ $ bundle
35
+ ```
36
+
37
+ Or install it yourself as:
38
+ ```bash
39
+ $ gem install model_driven_api
40
+ ```
41
+
42
+ Then run the migrations:
43
+ ```bash
44
+ $ rails db:migrate
45
+ ```
46
+
47
+ This will setup a User model, Role model and the HABTM table between the two.
48
+
49
+ Then, if you fire up your ```rails server``` you can already get a jwt and perform different operations.
50
+ The default admin user created during the migration step has a randomly generated password you can find in a .passwords file in the root of your project, that's the initial password, in production you can replace that one, but for testing it proved handy to have it promptly available.
51
+
52
+ ## Consuming the API
53
+
54
+ ### Getting the Token
55
+
56
+ The first thing that must be done by the client is to get a Token using the credentials:
57
+
58
+ ```bash
59
+ POST http://localhost:3000/api/v2/authenticate
60
+ ```
61
+
62
+ with a POST body like the one below:
63
+
64
+ ```json
65
+ {
66
+ "auth": {
67
+ "email": "<REPLACE>",
68
+ "password": "<REPLACE>"
69
+ }
70
+ }
71
+ ```
72
+
73
+ This action will return in the header a *Token* you can use for the following requests.
74
+ Bear in mind that the *Token* will expire within 15 minutes and that at each succesful request a new token is returned using the same *Token* header, so, at each interaction between client server, just making an authenticated and succesful request, will give you back a way of continuing to make authenticated requests without the extra overhead of an authentication for each one and without having to keep long expiry times for the *Token*.
75
+
76
+ ### Info API
77
+
78
+ The info API **api/v2/info/** can be used to retrieve general information about the REST API:
79
+
80
+ #### Version
81
+
82
+ By issuing a GET on this api, you will get a response containing the version of the model_driven_api.
83
+ This is a request which doesn't require authentication, it could be used as a checkpoint for consuming the resources exposed by this engine.
84
+
85
+ ```bash
86
+ GET http://localhost:3000/api/v2/info/version
87
+ ```
88
+
89
+ Would produce a response body like this one:
90
+
91
+ ```json
92
+ {
93
+ "version": "2.1.14"
94
+ }
95
+ ```
96
+
97
+ #### Roles
98
+
99
+ **Authenticated Request** by issuing a GET request to */api/v2/info/roles*:
100
+
101
+ ```bash
102
+ GET http://localhost:3000/api/v2/info/roles
103
+ ```
104
+
105
+ Something like this can be retrieved:
106
+
107
+ ```json
108
+ [
109
+ {
110
+ "id": 1,
111
+ "name": "role-1586521657646",
112
+ "created_at": "2020-04-10T12:27:38.061Z",
113
+ "updated_at": "2020-04-10T12:27:38.061Z",
114
+ "lock_version": 0
115
+ },
116
+ {
117
+ "id": 2,
118
+ "name": "role-1586522353509",
119
+ "created_at": "2020-04-10T12:39:14.276Z",
120
+ "updated_at": "2020-04-10T12:39:14.276Z",
121
+ "lock_version": 0
122
+ }
123
+ ]
124
+ ```
125
+
126
+ #### Schema
127
+
128
+ **Authenticated Request** This action will send back the *authorized* models accessible by the current user at least for the [:read ability](https://github.com/ryanb/cancan/wiki/checking-abilities). The list will also show the field types of the model and the associations.
129
+
130
+ By issuing this GET request:
131
+
132
+ ```bash
133
+ GET http://localhost:3000/api/v2/info/roles
134
+ ```
135
+
136
+ You will get something like:
137
+
138
+ ```json
139
+ {
140
+ "users": {
141
+ "id": "integer",
142
+ "email": "string",
143
+ "encrypted_password": "string",
144
+ "admin": "boolean",
145
+ "lock_version": "integer",
146
+ "associations": {
147
+ "has_many": [
148
+ "role_users",
149
+ "roles"
150
+ ],
151
+ "belongs_to": []
152
+ },
153
+ "methods": null
154
+ },
155
+ "role_users": {
156
+ "id": "integer",
157
+ "created_at": "datetime",
158
+ "updated_at": "datetime",
159
+ "associations": {
160
+ "has_many": [],
161
+ "belongs_to": [
162
+ "user",
163
+ "role"
164
+ ]
165
+ },
166
+ "methods": null
167
+ },
168
+ "roles": {
169
+ "id": "integer",
170
+ "name": "string",
171
+ "created_at": "datetime",
172
+ "updated_at": "datetime",
173
+ "lock_version": "integer",
174
+ "associations": {
175
+ "has_many": [
176
+ "role_users",
177
+ "users"
178
+ ],
179
+ "belongs_to": []
180
+ },
181
+ "methods": null
182
+ }
183
+ }
184
+ ```
185
+
186
+ The *methods* key will list the **custom actions** that can be used in addition to normal CRUD operations, these can be bulk actions and anything that can serve a purpose, usually to simplify the interaction between client and server (i.e. getting in one request the result of a complex computations which usually would be sorted out using more requests). Later on this topic.
187
+
188
+ ## Testing
189
+
190
+ If you want to manually test the API using [Insomnia](https://insomnia.rest/) you can find the chained request in Insomnia v4 json format inside the **test/insomnia** folder.
191
+ In the next few days, I'll publish also the rspec tests.
192
+
193
+ ## TODO
194
+
195
+ * Integrate a settings gem
196
+ * Add DSL for users and roles
197
+
198
+ ## References
199
+ THanks to all these people for ideas:
200
+
201
+ * [Billy Cheng](https://medium.com/@billy.sf.cheng/a-rails-6-application-part-1-api-1ee5ccf7ed01) For a way to have a nice and clean implementation of the JWT on top of Devise.
202
+ * [Daniel](https://medium.com/@tdaniel/passing-refreshed-jwts-from-rails-api-using-headers-859f1cfe88e9) For a smart way to manage token expiration.
203
+
204
+ ## License
205
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +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 = 'ModelDrivenApi'
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
@@ -0,0 +1,32 @@
1
+ class AuthenticateUser
2
+ class AccessDenied < StandardError
3
+ def message
4
+ "AuthenticationError"
5
+ end
6
+ end
7
+ prepend SimpleCommand
8
+
9
+ def initialize(email, password)
10
+ @email = email
11
+ @password = password
12
+ end
13
+
14
+ def call
15
+ JsonWebToken.encode(user_id: api_user.id) if api_user
16
+ end
17
+
18
+ private
19
+
20
+ attr_accessor :email, :password
21
+
22
+ def api_user
23
+ user = User.find_by_email(email)
24
+ raise AccessDenied unless user.present?
25
+
26
+ # Verify the password. You can create a blank method for now.
27
+ raise AccessDenied if user.authenticate(password).blank?
28
+
29
+ return user
30
+ end
31
+
32
+ end
@@ -0,0 +1,33 @@
1
+ class AuthorizeApiRequest
2
+ prepend SimpleCommand
3
+
4
+ def initialize(headers = {})
5
+ @headers = headers
6
+ end
7
+
8
+ def call
9
+ api_user
10
+ end
11
+
12
+ private
13
+
14
+ attr_reader :headers
15
+
16
+ def api_user
17
+ @api_user ||= User.find(decoded_auth_token[:user_id]) if decoded_auth_token
18
+ @api_user || errors.add(:token, "Invalid token") && nil
19
+ end
20
+
21
+ def decoded_auth_token
22
+ @decoded_auth_token ||= JsonWebToken.decode(http_auth_header)
23
+ end
24
+
25
+ def http_auth_header
26
+ if headers['Authorization'].present?
27
+ return headers['Authorization'].split(' ').last
28
+ else
29
+ errors.add(:token, "Missing token")
30
+ end
31
+ nil
32
+ end
33
+ end
@@ -0,0 +1,154 @@
1
+ class Api::V2::ApplicationController < ActionController::API
2
+ # Detect Locale from Accept-Language headers
3
+ include HttpAcceptLanguage::AutoLocale
4
+ # Actions will be authorized directly in the action
5
+ include CanCan::ControllerAdditions
6
+ include ::ApiExceptionManagement
7
+
8
+ attr_accessor :current_user
9
+
10
+ before_action :authenticate_request
11
+ before_action :extract_model
12
+ before_action :find_record, only: [ :show, :destroy, :update ]
13
+
14
+ # GET :controller/
15
+ def index
16
+ authorize! :index, @model
17
+
18
+ # Custom Action
19
+ status, result = check_for_custom_action
20
+ return render json: result, status: 200 if status == true
21
+
22
+ # Normal Index Action with Ransack querying
23
+ @q = (@model.column_names.include?("user_id") ? @model.where(user_id: current_user.id) : @model).ransack(@query.presence|| params[:q])
24
+ @records_all = @q.result(distinct: true)
25
+ page = (@page.presence || params[:page])
26
+ per = (@per.presence || params[:per])
27
+ pages_info = (@pages_info.presence || params[:pages_info])
28
+ count = (@count.presence || params[:count])
29
+ # Paging
30
+ @records = @records_all.page(page).per(per)
31
+
32
+ # If there's the keyword pagination_info, then return a pagination info object
33
+ return render json: MultiJson.dump({count: @records_all.count,current_page_count: @records.count,next_page: @records.next_page,prev_page: @records.prev_page,is_first_page: @records.first_page?,is_last_page: @records.last_page?,is_out_of_range: @records.out_of_range?,pages_count: @records.total_pages,current_page_number: @records.current_page }) if !pages_info.blank?
34
+
35
+ # puts "ALL RECORDS FOUND: #{@records_all.inspect}"
36
+ status = @records_all.blank? ? 404 : 200
37
+ # puts "If it's asked for page number, then paginate"
38
+ return render json: MultiJson.dump(@records, json_attrs), status: status if !page.blank? # (@json_attrs || {})
39
+ #puts "if you ask for count, then return a json object with just the number of objects"
40
+ return render json: MultiJson.dump({count: @records_all.count}) if !count.blank?
41
+ #puts "Default"
42
+ json_out = MultiJson.dump(@records_all, json_attrs)
43
+ #puts "JSON ATTRS: #{json_attrs}"
44
+ #puts "JSON OUT: #{json_out}"
45
+ render json: json_out, status: status #(@json_attrs || {})
46
+ end
47
+
48
+ def show
49
+ authorize! :show, @record_id
50
+
51
+ # Custom Show Action
52
+ status, result = check_for_custom_action
53
+ return render json: result, status: 200 if status == true
54
+
55
+ # Normal Show
56
+ result = @record.to_json(json_attrs)
57
+ render json: result, status: 200
58
+ end
59
+
60
+ def create
61
+ @record = @model.new(@body)
62
+ authorize! :create, @record
63
+
64
+ # Custom Action
65
+ status, result = check_for_custom_action
66
+ return render json: result, status: 200 if status == true
67
+
68
+ # Normal Create Action
69
+ @record.user_id = current_user.id if @model.column_names.include? "user_id"
70
+ @record.save!
71
+ render json: @record.to_json(json_attrs), status: 201
72
+ end
73
+
74
+ def update
75
+ authorize! :update, @record
76
+
77
+ # Custom Action
78
+ status, result = check_for_custom_action
79
+ return render json: result, status: 200 if status == true
80
+
81
+ # Normal Update Action
82
+ @record.update_attributes!(@body)
83
+ render json: @record.to_json(json_attrs), status: 200
84
+ end
85
+
86
+ def destroy
87
+ authorize! :destroy, @record
88
+
89
+ # Custom Action
90
+ status, result = check_for_custom_action
91
+ return render json: result, status: 200 if status == true
92
+
93
+ # Normal Destroy Action
94
+ return api_error(status: 500) unless @record.destroy
95
+ head :ok
96
+ end
97
+
98
+ private
99
+
100
+ def check_for_custom_action
101
+ ## CUSTOM ACTION
102
+ # [GET|PUT|POST|DELETE] :controller?do=:custom_action
103
+ # or
104
+ # [GET|PUT|POST|DELETE] :controller/:id?do=:custom_action
105
+ unless params[:do].blank?
106
+ # Poor man's solution to avoid the possibility to
107
+ # call an unwanted method in the AR Model.
108
+ resource = "custom_action_#{params[:do]}"
109
+ raise NoMethodError unless @model.respond_to?(resource)
110
+ return true, MultiJson.dump(params[:id].blank? ? @model.send(resource, params) : @model.send(resource, params[:id].to_i, params))
111
+ end
112
+ # if it's here there is no custom action in the request querystring
113
+ return false
114
+ end
115
+
116
+ def authenticate_request
117
+ @current_user = AuthorizeApiRequest.call(request.headers).result
118
+ return unauthenticated! unless @current_user
119
+ current_user = @current_user
120
+ params[:current_user_id] = @current_user.id
121
+ # Now every time the user fires off a successful GET request,
122
+ # a new token is generated and passed to them, and the clock resets.
123
+ response.headers['Token'] = JsonWebToken.encode(user_id: current_user.id)
124
+ end
125
+
126
+ def find_record
127
+ record_id ||= (params[:path].split("/").second.to_i rescue nil)
128
+ @record = @model.column_names.include?("user_id") ? @model.where(id: (record_id.presence || @record_id.presence || params[:id]), user_id: current_user.id).first : @model.find((@record_id.presence || params[:id]))
129
+ return not_found! if @record.blank?
130
+ end
131
+
132
+ def json_attrs
133
+ ((@model.json_attrs.presence || @json_attrs.presence || {}) rescue {})
134
+ end
135
+
136
+ def extract_model
137
+ # This method is only valid for ActiveRecords
138
+ # For any other model-less controller, the actions must be
139
+ # defined in the route, and must exist in the controller definition.
140
+ # So, if it's not an activerecord, the find model makes no sense at all
141
+ # thus must return 404.
142
+ @model = (params[:ctrl].classify.constantize rescue params[:path].split("/").first.classify.constantize rescue controller_path.classify.constantize rescue controller_name.classify.constantize rescue nil)
143
+ # Getting the body of the request if it exists, it's ok the singular or
144
+ # plural form, this helps with automatic tests with Insomnia.
145
+ @body = params[@model.model_name.singular].presence || params[@model.model_name.route_key]
146
+ # Only ActiveRecords can have this model caputed
147
+ return not_found! if (!@model.new.is_a? ActiveRecord::Base rescue false)
148
+ end
149
+
150
+ # Nullifying strong params for API
151
+ def params
152
+ request.parameters
153
+ end
154
+ end
@@ -0,0 +1,12 @@
1
+ class Api::V2::AuthenticationController < ActionController::API
2
+ include ::ApiExceptionManagement
3
+
4
+ def authenticate
5
+ command = AuthenticateUser.call(params[:auth][:email], params[:auth][:password])
6
+
7
+ if command.success?
8
+ response.headers['Token'] = command.result
9
+ head :ok
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,68 @@
1
+ class Api::V2::InfoController < Api::V2::ApplicationController
2
+ # Info uses a different auth method: username and password
3
+ skip_before_action :authenticate_request, only: [:version], raise: false
4
+ skip_before_action :extract_model
5
+
6
+ # api :GET, '/api/v2/info/version', "Just prints the APPVERSION."
7
+ def version
8
+ render json: { version: ModelDrivenApi::VERSION }.to_json, status: 200
9
+ end
10
+
11
+ # api :GET, '/api/v2/info/roles'
12
+ # it returns the roles list
13
+ def roles
14
+ render json: ::Role.all.to_json, status: 200
15
+ end
16
+
17
+ # GET '/api/v2/info/translations'
18
+ def translations
19
+ render json: I18n.t(".", locale: (params[:locale].presence || :it)).to_json, status: 200
20
+ end
21
+
22
+ # GET '/api/v2/info/schema'
23
+ def schema
24
+ pivot = {}
25
+ # if Rails.env.development?
26
+ # Rails.configuration.eager_load_namespaces.each(&:eager_load!) if Rails.version.to_i == 5 #Rails 5
27
+ # Zeitwerk::Loader.eager_load_all if Rails.version.to_i >= 6 #Rails 6
28
+ # end
29
+ ApplicationRecord.subclasses.each do |d|
30
+ # Only if current user can read the model
31
+ if can? :read, d
32
+ model = d.to_s.underscore.tableize
33
+ pivot[model] ||= {}
34
+ d.columns_hash.each_pair do |key, val|
35
+ pivot[model][key] = val.type unless key.ends_with? "_id"
36
+ end
37
+ # Only application record descendants to have a clean schema
38
+ pivot[model][:associations] ||= {
39
+ has_many: d.reflect_on_all_associations(:has_many).map { |a|
40
+ a.name if (((a.options[:class_name].presence || a.name).to_s.classify.constantize.new.is_a? ApplicationRecord) rescue false)
41
+ }.compact,
42
+ belongs_to: d.reflect_on_all_associations(:belongs_to).map { |a|
43
+ a.name if (((a.options[:class_name].presence || a.name).to_s.classify.constantize.new.is_a? ApplicationRecord) rescue false)
44
+ }.compact
45
+ }
46
+ pivot[model][:methods] ||= (d.instance_methods(false).include?(:json_attrs) && !d.json_attrs.blank?) ? d.json_attrs[:methods] : nil
47
+ end
48
+ end
49
+ render json: pivot.to_json, status: 200
50
+ end
51
+
52
+ # GET '/api/v2/info/dsl'
53
+ def dsl
54
+ pivot = {}
55
+ # if Rails.env.development?
56
+ # Rails.configuration.eager_load_namespaces.each(&:eager_load!) if Rails.version.to_i == 5 #Rails 5
57
+ # Zeitwerk::Loader.eager_load_all if Rails.version.to_i >= 6 #Rails 6
58
+ # end
59
+ ApplicationRecord.subclasses.each do |d|
60
+ # Only if current user can read the model
61
+ if can? :read, d
62
+ model = d.to_s.underscore.tableize
63
+ pivot[model] = (d.instance_methods(false).include?(:json_attrs) && !d.json_attrs.blank?) ? d.json_attrs : nil
64
+ end
65
+ end
66
+ render json: pivot.to_json, status: 200
67
+ end
68
+ end
@@ -0,0 +1,9 @@
1
+ class Api::V2::UsersController < Api::V2::ApplicationController
2
+ before_action :check_demoting, only: [ :update, :destroy ]
3
+
4
+ private
5
+
6
+ def check_demoting
7
+ unauthorized! StandardError.new("You cannot demote yourself") if (params[:id].to_i == current_user.id && (params[:user].keys.include?("admin") || params[:user].keys.include?("locked")))
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ require 'concerns/model_driven_api_user'
2
+ require 'concerns/model_driven_api_role'
3
+
4
+ Rails.application.configure do
5
+ config.after_initialize do
6
+ User.send(:include, ModelDrivenApiUser)
7
+ Role.send(:include, ModelDrivenApiRole)
8
+ end
9
+ end
@@ -0,0 +1,11 @@
1
+ # config/initializers/cors.rb
2
+ Rails.application.config.middleware.insert_before 0, Rack::Cors do
3
+ allow do
4
+ origins '*'
5
+ resource '*',
6
+ headers: %w(Token),
7
+ methods: :any,
8
+ expose: %w(Token),
9
+ max_age: 600
10
+ end
11
+ end
@@ -0,0 +1,59 @@
1
+ # Knock.setup do |config|
2
+
3
+ # ## Expiration claim
4
+ # ## ----------------
5
+ # ##
6
+ # ## How long before a token is expired. If nil is provided, token will
7
+ # ## last forever.
8
+ # ##
9
+ # ## Default:
10
+ # # config.token_lifetime = 1.day
11
+
12
+
13
+ # ## Audience claim
14
+ # ## --------------
15
+ # ##
16
+ # ## Configure the audience claim to identify the recipients that the token
17
+ # ## is intended for.
18
+ # ##
19
+ # ## Default:
20
+ # # config.token_audience = nil
21
+
22
+ # ## If using Auth0, uncomment the line below
23
+ # # config.token_audience = -> { Rails.application.secrets.auth0_client_id }
24
+
25
+ # ## Signature algorithm
26
+ # ## -------------------
27
+ # ##
28
+ # ## Configure the algorithm used to encode the token
29
+ # ##
30
+ # ## Default:
31
+ # # config.token_signature_algorithm = 'HS256'
32
+
33
+ # ## Signature key
34
+ # ## -------------
35
+ # ##
36
+ # ## Configure the key used to sign tokens.
37
+ # ##
38
+ # ## Default:
39
+ # # config.token_secret_signature_key = -> { Rails.application.secrets.secret_key_base }
40
+
41
+ # ## If using Auth0, uncomment the line below
42
+ # # config.token_secret_signature_key = -> { JWT.base64url_decode Rails.application.secrets.auth0_client_secret }
43
+
44
+ # ## Public key
45
+ # ## ----------
46
+ # ##
47
+ # ## Configure the public key used to decode tokens, if required.
48
+ # ##
49
+ # ## Default:
50
+ # # config.token_public_key = nil
51
+
52
+ # ## Exception Class
53
+ # ## ---------------
54
+ # ##
55
+ # ## Configure the exception to be used when user cannot be found.
56
+ # ##
57
+ # ## Default:
58
+ # # config.not_found_exception_class_name = 'ActiveRecord::RecordNotFound'
59
+ # end
@@ -0,0 +1,14 @@
1
+ # Be sure to restart your server when you modify this file.
2
+
3
+ # This file contains settings for ActionController::ParamsWrapper which
4
+ # is enabled by default.
5
+
6
+ # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array.
7
+ ActiveSupport.on_load(:action_controller) do
8
+ wrap_parameters format: [:json] if respond_to?(:wrap_parameters)
9
+ end
10
+
11
+ # To enable root element in JSON for ActiveRecord objects.
12
+ # ActiveSupport.on_load(:active_record) do
13
+ # self.include_root_in_json = true
14
+ # end
data/config/routes.rb ADDED
@@ -0,0 +1,33 @@
1
+ # require 'ransack'
2
+
3
+ Rails.application.routes.draw do
4
+ # REST API (Stateless)
5
+ namespace :api, constraints: { format: :json } do
6
+ namespace :v2 do
7
+ resources :users
8
+
9
+ namespace :info do
10
+ get :version
11
+ get :roles
12
+ get :translations
13
+ get :schema
14
+ get :dsl
15
+ end
16
+
17
+ post "authenticate" => "authentication#authenticate"
18
+ post ":ctrl/search" => 'application#index'
19
+
20
+ # Catchall routes
21
+ # # CRUD Show
22
+ get '*path/:id', to: 'application#show'
23
+ # # CRUD Index
24
+ get '*path', to: 'application#index'
25
+ # # CRUD Create
26
+ post '*path', to: 'application#create'
27
+ # # CRUD Update
28
+ put '*path/:id', to: 'application#update'
29
+ # # CRUD DElete
30
+ delete '*path/:id', to: 'application#destroy'
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,58 @@
1
+ module ApiExceptionManagement
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ rescue_from NoMethodError, with: :not_found!
6
+ rescue_from CanCan::AccessDenied, with: :unauthorized!
7
+ rescue_from AuthenticateUser::AccessDenied, with: :unauthenticated!
8
+ rescue_from ActionController::RoutingError, with: :not_found!
9
+ rescue_from ActiveModel::ForbiddenAttributesError, with: :fivehundred!
10
+ rescue_from ActiveRecord::RecordInvalid, with: :invalid!
11
+ rescue_from ActiveRecord::RecordNotFound, with: :not_found!
12
+
13
+ def unauthenticated! exception = AuthenticateUser::AccessDenied.new
14
+ response.headers['WWW-Authenticate'] = "Token realm=Application"
15
+ return api_error status: 401, errors: exception.message
16
+ end
17
+
18
+ def unauthorized! exception = CanCan::AccessDenied.new
19
+ return api_error status: 403, errors: exception.message
20
+ end
21
+
22
+ def not_found! exception = StandardError.new
23
+ return api_error status: 404, errors: exception.message
24
+ end
25
+
26
+ def invalid! exception = StandardError.new
27
+ return api_error status: 422, errors: exception.record.errors
28
+ end
29
+
30
+ def fivehundred! exception = StandardError.new
31
+ return api_error status: 500, errors: exception.message
32
+ end
33
+
34
+ def api_error(status: 500, errors: [])
35
+ # puts errors.full_messages if !Rails.env.production? && errors.respond_to?(:full_messages)
36
+ head status && return if errors.empty?
37
+
38
+ # For retrocompatibility, I try to send back only strings, as errors
39
+ errors_response = if errors.respond_to?(:full_messages)
40
+ # Validation Errors
41
+ errors.full_messages.join(", ")
42
+ elsif errors.respond_to?(:error)
43
+ # Generic uncatched error
44
+ errors.error
45
+ elsif errors.respond_to?(:exception)
46
+ # Generic uncatchd error, if the :error property does not exist, exception will
47
+ errors.exception
48
+ elsif errors.is_a? Array
49
+ # An array of values, I like to have them merged
50
+ errors.join(", ")
51
+ else
52
+ # Uncatched Error, comething I don't know, I must return the errors as it is
53
+ errors
54
+ end
55
+ render json: {error: errors_response}, status: status
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,47 @@
1
+ module ModelDrivenApiRole
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ ## DSL (AKA what to show in the returned JSON)
6
+ # Use @@json_attrs to drive json rendering for
7
+ # API model responses (index, show and update ones).
8
+ # For reference:
9
+ # https://api.rubyonrails.org/classes/ActiveModel/Serializers/JSON.html
10
+ # The object passed accepts only these keys:
11
+ # - only: list [] of model field names in symbol notation to be shown in JSON
12
+ # serialization.
13
+ # - except: exclude these fields from the JSON serialization, is a list []
14
+ # of model field names in symbol notation.
15
+ # - methods: include the result of some methods defined in the model (virtual
16
+ # fields).
17
+ # - include: include associated models, it's a list [] of hashes {} which also
18
+ # accepts the [:only, :except, :methods, :include] keys.
19
+ cattr_accessor :json_attrs
20
+ @@json_attrs = ModelDrivenApi.smart_merge((json_attrs || {}), {
21
+ except: [
22
+ :lock_version,
23
+ :created_at,
24
+ :updated_at
25
+ ],
26
+ include: [users: {
27
+ only: [:id]
28
+ }]
29
+ })
30
+
31
+ ## CUSTOM ACTIONS
32
+ # Here you can add custom actions to be called from the API
33
+ # The action must return an serializable (JSON) object.
34
+
35
+ # Here you can find an example *without* ID, in the API it could be called like this:
36
+ # GET /api/v2/:controller?do=test&parameter=sample
37
+ # def self.custom_action_test params=nil
38
+ # { test: [ :first, :second, :third ], params: params}
39
+ # end
40
+
41
+ # Here you can find an example *with* ID, in the API it could be called like this:
42
+ # GET /api/v2/:controller/:id?do=test_with_id&parameter=sample
43
+ # def self.custom_action_test_with_id id=nil, params=nil
44
+ # { test: [ :first, :second, :third ], id: id, params: params}
45
+ # end
46
+ end
47
+ end
@@ -0,0 +1,46 @@
1
+ module ModelDrivenApiUser
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+
6
+ ## DSL (AKA what to show in the returned JSON)
7
+ # Use @@json_attrs to drive json rendering for
8
+ # API model responses (index, show and update ones).
9
+ # For reference:
10
+ # https://api.rubyonrails.org/classes/ActiveModel/Serializers/JSON.html
11
+ # The object passed accepts only these keys:
12
+ # - only: list [] of model field names in symbol notation to be shown in JSON
13
+ # serialization.
14
+ # - except: exclude these fields from the JSON serialization, is a list []
15
+ # of model field names in symbol notation.
16
+ # - methods: include the result of some methods defined in the model (virtual
17
+ # fields).
18
+ # - include: include associated models, it's a list [] of hashes {} which also
19
+ # accepts the [:only, :except, :methods, :include] keys.
20
+ cattr_accessor :json_attrs
21
+ @@json_attrs = ModelDrivenApi.smart_merge((json_attrs || {}), {
22
+ except: [
23
+ :lock_version,
24
+ :created_at,
25
+ :updated_at
26
+ ],
27
+ include: [:roles]
28
+ })
29
+
30
+ ## CUSTOM ACTIONS
31
+ # Here you can add custom actions to be called from the API
32
+ # The action must return an serializable (JSON) object.
33
+
34
+ # Here you can find an example *without* ID, in the API it could be called like this:
35
+ # GET /api/v2/:controller?do=test&parameter=sample
36
+ # def self.custom_action_test params=nil
37
+ # { test: [ :first, :second, :third ], params: params}
38
+ # end
39
+
40
+ # Here you can find an example *with* ID, in the API it could be called like this:
41
+ # GET /api/v2/:controller/:id?do=test_with_id&parameter=sample
42
+ # def self.custom_action_test_with_id id=nil, params=nil
43
+ # { test: [ :first, :second, :third ], id: id, params: params}
44
+ # end
45
+ end
46
+ end
@@ -0,0 +1,14 @@
1
+ class JsonWebToken
2
+ class << self
3
+ def encode(payload, expiry = 15.minutes.from_now.to_i)
4
+ JWT.encode(payload.merge(exp: expiry), Rails.application.secrets.secret_key_base)
5
+ end
6
+
7
+ def decode(token)
8
+ body = JWT.decode(token, Rails.application.secrets.secret_key_base)[0]
9
+ HashWithIndifferentAccess.new body
10
+ rescue
11
+ nil
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,12 @@
1
+ module ModelDrivenApi
2
+ class Engine < ::Rails::Engine
3
+ # appending migrations to the main app's ones
4
+ initializer :append_migrations do |app|
5
+ unless app.root.to_s.match root.to_s
6
+ config.paths["db/migrate"].expanded.each do |expanded_path|
7
+ app.config.paths["db/migrate"] << expanded_path
8
+ end
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,3 @@
1
+ module ModelDrivenApi
2
+ VERSION = '2.2.4'
3
+ end
@@ -0,0 +1,23 @@
1
+ require 'thecore_backend_commons'
2
+ require 'rack/cors'
3
+ require 'ransack'
4
+ require 'json_web_token'
5
+ require "kaminari"
6
+ require "multi_json"
7
+ require "simple_command"
8
+
9
+ require 'concerns/api_exception_management'
10
+
11
+ require 'deep_merge/rails_compat'
12
+
13
+ require "model_driven_api/engine"
14
+
15
+ module ModelDrivenApi
16
+ def self.smart_merge src, dest
17
+ src.deeper_merge! dest, {
18
+ extend_existing_arrays: true,
19
+ merge_hash_arrays: true
20
+ }
21
+ src
22
+ end
23
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :model_driven_api do
3
+ # # Task goes here
4
+ # end
metadata ADDED
@@ -0,0 +1,180 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: model_driven_api
3
+ version: !ruby/object:Gem::Version
4
+ version: 2.2.4
5
+ platform: ruby
6
+ authors:
7
+ - Gabriele Tassoni
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2020-05-22 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: thecore_backend_commons
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: jwt
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '2.2'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '2.2'
41
+ - !ruby/object:Gem::Dependency
42
+ name: simple_command
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '0.1'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '0.1'
55
+ - !ruby/object:Gem::Dependency
56
+ name: kaminari
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.2'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.2'
69
+ - !ruby/object:Gem::Dependency
70
+ name: ransack
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '2.3'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '2.3'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rack-cors
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '1.1'
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '1.1'
97
+ - !ruby/object:Gem::Dependency
98
+ name: multi_json
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '1.14'
104
+ type: :runtime
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '1.14'
111
+ - !ruby/object:Gem::Dependency
112
+ name: deep_merge
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '1.2'
118
+ type: :runtime
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '1.2'
125
+ description: Ruby on Rails REST APIs built by convention using the DB schema as the
126
+ foundation.
127
+ email:
128
+ - gabriele.tassoni@gmail.com
129
+ executables: []
130
+ extensions: []
131
+ extra_rdoc_files: []
132
+ files:
133
+ - MIT-LICENSE
134
+ - README.md
135
+ - Rakefile
136
+ - app/commands/authenticate_user.rb
137
+ - app/commands/authorize_api_request.rb
138
+ - app/controllers/api/v2/application_controller.rb
139
+ - app/controllers/api/v2/authentication_controller.rb
140
+ - app/controllers/api/v2/info_controller.rb
141
+ - app/controllers/api/v2/users_controller.rb
142
+ - config/initializers/after_initialize_for_model_driven_api.rb
143
+ - config/initializers/cors_api_thecore.rb
144
+ - config/initializers/knock.rb
145
+ - config/initializers/wrap_parameters.rb
146
+ - config/routes.rb
147
+ - lib/concerns/api_exception_management.rb
148
+ - lib/concerns/model_driven_api_role.rb
149
+ - lib/concerns/model_driven_api_user.rb
150
+ - lib/json_web_token.rb
151
+ - lib/model_driven_api.rb
152
+ - lib/model_driven_api/engine.rb
153
+ - lib/model_driven_api/version.rb
154
+ - lib/tasks/model_driven_api_tasks.rake
155
+ homepage: https://github.com/gabrieletassoni/model_driven_api
156
+ licenses:
157
+ - MIT
158
+ metadata:
159
+ allowed_push_host: https://rubygems.org
160
+ post_install_message:
161
+ rdoc_options: []
162
+ require_paths:
163
+ - lib
164
+ required_ruby_version: !ruby/object:Gem::Requirement
165
+ requirements:
166
+ - - ">="
167
+ - !ruby/object:Gem::Version
168
+ version: '0'
169
+ required_rubygems_version: !ruby/object:Gem::Requirement
170
+ requirements:
171
+ - - ">="
172
+ - !ruby/object:Gem::Version
173
+ version: '0'
174
+ requirements: []
175
+ rubygems_version: 3.0.3
176
+ signing_key:
177
+ specification_version: 4
178
+ summary: Convention based RoR engine which uses DB schema introspection to create
179
+ REST APIs.
180
+ test_files: []