apicasso 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,106 @@
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: {
30
+ request: request_hash,
31
+ response: response_hash
32
+ })
33
+ end
34
+
35
+ # Request data built as a hash.
36
+ # Returns UUID, URL, HTTP Headers and origin IP
37
+ def request_hash
38
+ {
39
+ uuid: request.uuid,
40
+ url: request.original_url,
41
+ headers: request.env.select { |key, _v| key =~ /^HTTP_/ },
42
+ ip: request.remote_ip
43
+ }
44
+ end
45
+
46
+ # Resonse data built as a hash.
47
+ # Returns HTTP Status and request body
48
+ def response_hash
49
+ {
50
+ status: response.status,
51
+ body: JSON.parse(response.body)
52
+ }
53
+ end
54
+
55
+ # Used to avoid errors parsing the search query,
56
+ # which can be passed as a JSON or as a key-value param
57
+ def parsed_query
58
+ JSON.parse(params[:q])
59
+ rescue JSON::ParserError, TypeError
60
+ params[:q]
61
+ end
62
+
63
+ # Used to avoid errors in included associations parsing
64
+ def parsed_include
65
+ params[:include].split(',')
66
+ rescue NoMethodError
67
+ []
68
+ end
69
+
70
+ # Receives a `.paginate`d collection and returns the pagination
71
+ # metadata to be merged into response
72
+ def pagination_metadata_for(records)
73
+ { total: records.total_entries,
74
+ total_pages: records.total_pages,
75
+ last_page: records.next_page.blank?,
76
+ previous_page: previous_link_for(records),
77
+ next_page: next_link_for(records),
78
+ out_of_bounds: records.out_of_bounds?,
79
+ offset: records.offset }
80
+ end
81
+
82
+ # Generates a contextualized URL of the next page for this request
83
+ def next_link_for(records)
84
+ uri = URI.parse(request.original_url)
85
+ query = Rack::Utils.parse_query(uri.query)
86
+ query['page'] = records.next_page
87
+ uri.query = Rack::Utils.build_query(query)
88
+ uri.to_s
89
+ end
90
+
91
+ # Generates a contextualized URL of the previous page for this request
92
+ def previous_link_for(records)
93
+ uri = URI.parse(request.original_url)
94
+ query = Rack::Utils.parse_query(uri.query)
95
+ query['page'] = records.previous_page
96
+ uri.query = Rack::Utils.build_query(query)
97
+ uri.to_s
98
+ end
99
+
100
+ # Receives a `:action, :resource, :object` hash to validate authorization
101
+ def authorize_for(opts = {})
102
+ authorize! opts[:action], opts[:resource] if opts[:resource].present?
103
+ authorize! opts[:action], opts[:object] if opts[:object].present?
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,148 @@
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 < 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
+ render json: resource_schema.to_json
77
+ end
78
+
79
+ private
80
+
81
+ # Common setup to stablish which model is the resource of this request
82
+ def set_root_resource
83
+ @root_resource = params[:resource].classify.constantize
84
+ end
85
+
86
+ # Common setup to stablish which object this request is querying
87
+ def set_object
88
+ id = params[:id]
89
+ @object = resource.friendly.find(id)
90
+ rescue NoMethodError
91
+ @object = resource.find(id)
92
+ ensure
93
+ authorize! :read, @object
94
+ end
95
+
96
+ # Setup to stablish the nested model to be queried
97
+ def set_nested_resource
98
+ @nested_resource = @object.send(params[:nested].underscore.pluralize)
99
+ end
100
+
101
+ # Reutrns root_resource if nested_resource is not set scoped by permissions
102
+ def resource
103
+ (@nested_resource || @root_resource)
104
+ end
105
+
106
+ # Used to setup the resource's schema, mapping attributes and it's types
107
+ def resource_schema
108
+ schemated = {}
109
+ resource.columns_hash.each { |key, value| schemated[key] = value.type }
110
+ schemated
111
+ end
112
+
113
+ # Used to setup the records from the selected resource that are
114
+ # going to be rendered, if authorized
115
+ def set_records
116
+ authorize! :read, resource.name.underscore.to_sym
117
+ @records = resource.ransack(parsed_query).result
118
+ reorder_records if params[:sort].present?
119
+ end
120
+
121
+ # Reordering of records which happens when receiving `params[:sort]`
122
+ def reorder_records
123
+ @records = @records.unscope(:order).order(ordering_params(params))
124
+ end
125
+
126
+ # Raw paginated records object
127
+ def paginated_records
128
+ @records.accessible_by(current_ability)
129
+ .paginate(page: params[:page], per_page: params[:per_page])
130
+ end
131
+
132
+ # Parsing of `paginated_records` with pagination variables metadata
133
+ def response_json
134
+ { entries: entries_json }.merge(pagination_metadata_for(paginated_records))
135
+ end
136
+
137
+ # Parsed JSON to be used as response payload
138
+ def entries_json
139
+ JSON.parse(paginated_records.to_json(include: parsed_include))
140
+ end
141
+
142
+ # Only allow a trusted parameter "white list" through,
143
+ # based on resource's schema.
144
+ def object_params
145
+ params.fetch(resource.name.underscore.to_sym, resource_schema.keys)
146
+ end
147
+ end
148
+ end
@@ -0,0 +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 parsed_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
@@ -0,0 +1,4 @@
1
+ module Apicasso
2
+ module ApplicationHelper
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module Apicasso
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,6 @@
1
+ module Apicasso
2
+ class ApplicationMailer < ActionMailer::Base
3
+ default from: 'from@example.com'
4
+ layout 'mailer'
5
+ end
6
+ end
@@ -0,0 +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
@@ -0,0 +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
@@ -0,0 +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
@@ -0,0 +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
@@ -0,0 +1,16 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Apicasso</title>
5
+ <%= csrf_meta_tags %>
6
+ <%= csp_meta_tag %>
7
+
8
+ <%= stylesheet_link_tag "apicasso/application", media: "all" %>
9
+ <%= javascript_include_tag "apicasso/application" %>
10
+ </head>
11
+ <body>
12
+
13
+ <%= yield %>
14
+
15
+ </body>
16
+ </html>
data/config/routes.rb ADDED
@@ -0,0 +1,13 @@
1
+ Apicasso::Engine.routes.draw do
2
+ scope module: :apicasso do
3
+ get '/:resource/', to: 'crud#index', via: :get
4
+ match '/:resource/', to: 'crud#create', via: :post
5
+ get '/:resource/:id', to: 'crud#show', via: :get
6
+ match '/:resource/:id', to: 'crud#update', via: :patch
7
+ match '/:resource/:id', to: 'crud#destroy', via: :delete
8
+ match '/:resource/:id/:nested/', to: 'crud#nested_index', via: :get
9
+ match '/:resource/', to: 'crud#schema', via: :options
10
+ match '/:resource/:id/:nested/', to: 'crud#schema', via: :options
11
+ resources :apidocs, only: [:index]
12
+ end
13
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'swagger/blocks'
4
+
5
+ module Apicasso
6
+ # This class is injected into `ActiveRecord` to enable Swagger docs to be
7
+ # generated automatically based on schema and I18n definitions in your
8
+ # own application.
9
+ module ActiveRecordExtension
10
+ extend ActiveSupport::Concern
11
+ module ClassMethods
12
+ def validated_attrs_for(validation)
13
+ if validation.is_a?(String) || validation.is_a?(Symbol)
14
+ klass = 'ActiveRecord::Validations::' \
15
+ "#{validation.to_s.camelize}Validator"
16
+ validation = klass.constantize
17
+ end
18
+ validators.select { |v| v.is_a?(validation) }
19
+ .map(&:attributes)
20
+ .flatten
21
+ .map(&:to_sym)
22
+ end
23
+
24
+ def presence_validators?
25
+ presence_validators.present?
26
+ end
27
+
28
+ def presence_validators
29
+ validated_attrs_for(:presence)
30
+ end
31
+ end
32
+ included do
33
+ include ::Swagger::Blocks
34
+ @@klass = self
35
+ swagger_schema @@klass.name.to_sym do
36
+ key :required, %i[*presence_validators] if @@klass.presence_validators?
37
+ @@klass.columns_hash.each do |name, type|
38
+ property name.to_sym do
39
+ key :type, type.type
40
+ end
41
+ end
42
+ end
43
+ swagger_schema "#{@@klass.name}Input".to_sym do
44
+ allOf do
45
+ schema do
46
+ key :'$ref', "#{@@klass.name}Input".to_sym
47
+ end
48
+ schema do
49
+ key :required, %i[*presence_validators] if @@klass.presence_validators?
50
+ @@klass.columns_hash.each do |name, type|
51
+ property name.to_sym do
52
+ key :type, type.type
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+ swagger_schema "#{@@klass.name}Metadata".to_sym do
59
+ allOf do
60
+ schema do
61
+ key :'$ref', "#{@@klass.name}Metadata".to_sym
62
+ end
63
+ schema do
64
+ @@klass.columns_hash.each do |name, type|
65
+ property name.to_sym do
66
+ key :description, type.type
67
+ key :type, :string
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+ rescue ActiveRecord::ConnectionNotEstablished
74
+ puts "No database connection to setup APIcasso routes in documentation"
75
+ end
76
+ end
77
+ end
78
+
79
+ # Include the extension to avoid including on all files mannually
80
+ ActiveRecord::Base.send(:include, Apicasso::ActiveRecordExtension)
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Apicasso
4
+ class Engine < ::Rails::Engine
5
+ end
6
+ end
@@ -0,0 +1,3 @@
1
+ module Apicasso
2
+ VERSION = '0.1.0'
3
+ end
data/lib/apicasso.rb ADDED
@@ -0,0 +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
@@ -0,0 +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
@@ -0,0 +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
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :apicasso do
3
+ # # Task goes here
4
+ # end