simpleadmin 1.4.0 → 1.5.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.
data/Rakefile CHANGED
@@ -1,6 +1,4 @@
1
- require 'bundler/gem_tasks'
2
- require 'rspec/core/rake_task'
3
-
4
- RSpec::Core::RakeTask.new(:spec)
1
+ # frozen_string_literal: true
5
2
 
3
+ require 'bundler/gem_tasks'
6
4
  task default: :spec
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
2
3
 
3
- require "bundler/setup"
4
- require "simpleadmin"
4
+ require 'bundler/setup'
5
+ require 'simpleadmin'
5
6
 
6
7
  # You can add fixtures and/or initialization code here to make experimenting
7
8
  # with your gem easier. You can also use a different console, if you like.
@@ -10,5 +11,5 @@ require "simpleadmin"
10
11
  # require "pry"
11
12
  # Pry.start
12
13
 
13
- require "irb"
14
+ require 'irb'
14
15
  IRB.start(__FILE__)
@@ -1,7 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rack/app'
4
+ require 'sequel'
5
+ require 'bcrypt'
6
+
1
7
  require 'simpleadmin/version'
2
- require 'simpleadmin/engine'
8
+ require 'simpleadmin/config'
9
+ require 'simpleadmin/database_connector'
3
10
 
4
- require 'simpleadmin/application'
11
+ # Administrative dashboard without wasting time
12
+ #
13
+ # @since 1.0.0
14
+ #
15
+ # @see https://getsimpleadmin.com
5
16
 
6
17
  module Simpleadmin
18
+ # API endpoints for integration with cloud version of getsimpleadmin.com
19
+ #
20
+ # This is used, Rack conception to connect it in any popular web frameworks
21
+ # like Ruby on Rails, Hanami, Sinatra and etc.
22
+ #
23
+ # @since 1.0.0
24
+ #
25
+ # @example
26
+ #
27
+ # # config/routes.rb
28
+ # Rails.application.routes.draw do
29
+ # mount Simpleadmin::Application, at: 'simpleadmin'
30
+ # end
31
+ class Application < Rack::App
32
+ WIDGETS = %w[quantity week_statistic].freeze
33
+
34
+ before { respond_forbidden! if secret_key_invalid? }
35
+ before { respond_forbidden! unless current_path?('/v1/tables') || allowed_table? }
36
+
37
+ namespace '/v1' do
38
+ get '/widgets/:widget_name' do
39
+ respond_forbidden!(message: 'Nonexistent widget') unless WIDGETS.include?(params['widget_name'])
40
+
41
+ client.public_send(params['widget_name'], params['table_name'])
42
+ end
43
+
44
+ get '/tables' do
45
+ client.tables
46
+ end
47
+
48
+ get '/tables/:table_name' do
49
+ client.table_columns(params['table_name'])
50
+ end
51
+
52
+ get '/resources' do
53
+ client.resources(params.values_at('table_name', 'table_fields', 'per_page',
54
+ 'page', 'query', 'model_attributes', 'sort'))
55
+ end
56
+
57
+ get '/resources/:id' do
58
+ client.resource(params['id'], params['table_name'], params['table_fields'])
59
+ end
60
+
61
+ post '/resources' do
62
+ client.create_resource(request_body_params['table_name'], request_body_params['resource'])
63
+ end
64
+
65
+ patch '/resources/:id' do
66
+ client.update_resource(request_body_params['table_name'], params['id'], request_body_params['resource'])
67
+ end
68
+
69
+ delete '/resources/:id' do
70
+ client.destroy_resouce(request_body_params['table_name'], params['id'])
71
+ end
72
+ end
73
+
74
+ private
75
+
76
+ def client
77
+ @client ||= connector.client
78
+ end
79
+
80
+ def respond_forbidden!(message: 'Forbidden')
81
+ response.status = 403
82
+ response.write(message)
83
+
84
+ finish!
85
+ end
86
+
87
+ def request_body_params
88
+ @request_body_params ||= Rack::Utils.parse_nested_query(request.body.read)
89
+ end
90
+
91
+ def connector
92
+ @connector ||= DatabaseConnector.new(
93
+ database_credentials: Config.database_credentials
94
+ )
95
+ end
96
+
97
+ def allowed_table?
98
+ Config.allowed_table?(params['table_name'] || request_body_params['table_name'])
99
+ end
100
+
101
+ def secret_key_invalid?
102
+ BCrypt::Password.new(request.get_header('HTTP_SIMPLEADMIN_SECRET_KEY')) != ENV['SIMPLE_ADMIN_SECRET_KEY']
103
+ end
104
+
105
+ def current_path?(path)
106
+ request.path_info == path
107
+ end
108
+ end
7
109
  end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'simpleadmin/decorators/fields_decorator'
4
+
5
+ module Simpleadmin
6
+ module Adapters
7
+ # Base class to provide a unified interface for each adapter
8
+ #
9
+ # @since 1.0.0
10
+ class Base
11
+ def initialize(database_credentials:)
12
+ @database_credentials = database_credentials
13
+ end
14
+
15
+ def tables
16
+ raise NotImplementedError, 'Please follow the unified interface, add method #tables'
17
+ end
18
+
19
+ def table_columns(*_args)
20
+ raise NotImplementedError, 'Please follow the unified interface, add method #table_columns'
21
+ end
22
+
23
+ def resources(*_args)
24
+ raise NotImplementedError, 'Please follow the unified interface, add method #resources'
25
+ end
26
+
27
+ private
28
+
29
+ attr_reader :database_credentials
30
+
31
+ def order_asc?(order)
32
+ order == 'asc'
33
+ end
34
+
35
+ def order_desc?(order)
36
+ order == 'desc'
37
+ end
38
+
39
+ def model_class_by_table_name(name)
40
+ name.classify.safe_constantize
41
+ end
42
+
43
+ def table_names
44
+ return client.tables if Config.allowed_tables.include?(:all)
45
+
46
+ Config.allowed_tables
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+
5
+ module Simpleadmin
6
+ module Adapters
7
+ # Postgres adapter for API endpoints
8
+ #
9
+ # @since 1.0.0
10
+ class Postgres < Base
11
+ def tables
12
+ table_names.map do |table_name|
13
+ {
14
+ table_name: table_name,
15
+ table_columns: columns_by_table_name(table_name)
16
+ }
17
+ end.to_json
18
+ end
19
+
20
+ def table_columns(table_name)
21
+ columns_by_table_name(table_name).to_json
22
+ end
23
+
24
+ def resources(permitted_params_values)
25
+ table_name, table_fields, per_page, page, query, model_attributes, sort = *permitted_params_values
26
+
27
+ per_page = per_page.to_i
28
+ page = page.to_i
29
+
30
+ table_fields_names = table_fields.map { |field| field['field_name'].to_sym }
31
+
32
+ total = client.from(table_name).count
33
+ resources = client.from(table_name).limit(per_page)
34
+
35
+ resources, total = *search(query, table_name, model_attributes) unless query.nil? || query.empty?
36
+
37
+ resources = resources.offset((per_page * page) - per_page).select(*table_fields_names)
38
+
39
+ if order_asc?(sort['order'])
40
+ resources = resources.order(Sequel.asc(sort['column_name'].to_sym))
41
+ elsif order_desc?(sort['order'])
42
+ resources = resources.order(Sequel.desc(sort['column_name'].to_sym))
43
+ end
44
+
45
+ {
46
+ resources: Decorators::FieldDecorator.call(resources, table_name, table_fields),
47
+ total: total
48
+ }.to_json
49
+ end
50
+
51
+ def resource(resource_id, table_name, table_fields)
52
+ client.from(table_name).first(id: resource_id).slice(*table_fields.map(&:to_sym)).to_json
53
+ end
54
+
55
+ def create_resource(table_name, resource_params)
56
+ Config.on_create.call(model_class_by_table_name(table_name), resource_params)
57
+ end
58
+
59
+ def update_resource(table_name, resource_id, resource_params, _primary_key=:id)
60
+ Config.on_update.call(model_class_by_table_name(table_name), resource_id, resource_params)
61
+ end
62
+
63
+ def destroy_resouce(table_name, resource_id, _primary_key=:id)
64
+ Config.on_destroy.call(model_class_by_table_name(table_name), resource_id)
65
+ end
66
+
67
+ def quantity(table_name)
68
+ {
69
+ widget_name: :quantity,
70
+ result: client.from(table_name).count
71
+ }.to_json
72
+ end
73
+
74
+ def week_statistic(table_name)
75
+ result = 6.downto(0).map do |day|
76
+ current_date_time = DateTime.now - day
77
+
78
+ client.from(table_name).where(
79
+ created_at: beggining_of_day(current_date_time)..at_end_of_day(current_date_time)
80
+ ).count
81
+ end
82
+
83
+ {
84
+ widget_name: :week_statistic,
85
+ result: result
86
+ }.to_json
87
+ end
88
+
89
+ private
90
+
91
+ def client
92
+ @client ||= Sequel.connect(database_credentials)
93
+ end
94
+
95
+ def beggining_of_day(date)
96
+ DateTime.new(date.year, date.month, date.day, 0, 0, 0, 0)
97
+ end
98
+
99
+ def at_end_of_day(date)
100
+ DateTime.new(date.year, date.month, date.day, 23, 59, 59, 59)
101
+ end
102
+
103
+ def columns_by_table_name(name)
104
+ client.schema(name).map do |column_attributes|
105
+ column_name, column_information = *column_attributes
106
+
107
+ {
108
+ column_name: column_name,
109
+ data_type: column_information[:db_type]
110
+ }
111
+ end
112
+ end
113
+
114
+ def search(search_query, table_name, model_attributes)
115
+ query_expressions = model_attributes.map do |model_attribute|
116
+ Sequel.like(model_attribute.to_sym, "%#{search_query}%")
117
+ end
118
+
119
+ query = query_expressions.first if query_expressions.one?
120
+
121
+ query_expressions.each_cons(2) do |current_value, next_value|
122
+ query = query.nil? ? current_value | next_value : query | (current_value | next_value)
123
+ end
124
+
125
+ [
126
+ client.from(table_name).where(query),
127
+ client.from(table_name).where(query).count
128
+ ]
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'singleton'
4
+
5
+ module Simpleadmin
6
+ # Configuration storage to customize allowed tables, to choose a database adapter and name
7
+ #
8
+ # @since 1.0.0
9
+ #
10
+ # @example
11
+ #
12
+ # Simpleadmin::Config.setup do |config|
13
+ # config.database_credentials = {
14
+ # adapter: :postgres,
15
+ # database: 'squiz_development'
16
+ # }
17
+ #
18
+ # config.allowed_tables = ['users'] # Allowed tables
19
+ # # config.allowed_tables = [:all] # Allow all tables
20
+ #
21
+ # config.on_create = lambda do |model_class, resource_params|
22
+ # model_class.create(resource_params)
23
+ # end
24
+ #
25
+ # config.on_update = lambda do |model_class, resource_id, resource_params|
26
+ # model_class.find(resource_id).update(resource_params)
27
+ # end
28
+ #
29
+ # config.on_destroy = lambda do |model_class, resource_id|
30
+ # model_class.find(resource_id).destroy
31
+ # end
32
+ # end
33
+ class Config
34
+ DEFAULT_TABLE_SCHEMAS = ['public'].freeze
35
+
36
+ include Singleton
37
+
38
+ attr_accessor :table_schemas, :database_credentials, :allowed_tables, :client
39
+ attr_accessor :on_create, :on_update, :on_destroy
40
+
41
+ class << self
42
+ def setup
43
+ yield(instance)
44
+ end
45
+
46
+ def allowed_table?(table_name)
47
+ return true if instance.allowed_tables.include?(:all)
48
+
49
+ instance.allowed_tables.include?(table_name)
50
+ end
51
+
52
+ def allowed_tables
53
+ instance.allowed_tables || []
54
+ end
55
+
56
+ def database_credentials
57
+ instance.database_credentials
58
+ end
59
+
60
+ def on_create
61
+ raise NotImplementedError, 'Please define #on_create in an initializer to use gem' if instance.on_create.nil?
62
+
63
+ instance.on_create
64
+ end
65
+
66
+ def on_update
67
+ raise NotImplementedError, 'Please define #on_update in an initializer to use gem' if instance.on_update.nil?
68
+
69
+ instance.on_update
70
+ end
71
+
72
+ def on_destroy
73
+ raise NotImplementedError, 'Please define #on_destroy in an initializer to use gem' if instance.on_destroy.nil?
74
+
75
+ instance.on_destroy
76
+ end
77
+
78
+ def table_schemas
79
+ instance.table_schemas || DEFAULT_TABLE_SCHEMAS
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'simpleadmin/adapters/base'
4
+ require 'simpleadmin/adapters/postgres'
5
+
6
+ module Simpleadmin
7
+ # Connector service that handles different databases to provide unified API endpoints
8
+ #
9
+ # @since 1.0.0
10
+ #
11
+ class DatabaseConnector
12
+ ADAPTERS_MAPPER = {
13
+ postgres: Adapters::Postgres
14
+ }.freeze
15
+
16
+ def initialize(database_credentials:)
17
+ @database_credentials = database_credentials
18
+ end
19
+
20
+ def client
21
+ adapter.new(database_credentials: database_credentials)
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :database_credentials
27
+
28
+ def adapter
29
+ adapter_class = ADAPTERS_MAPPER[database_credentials[:adapter]]
30
+ raise ArgumentError, 'Invalid adapter name or adapter not exist' if adapter_class.nil?
31
+
32
+ adapter_class
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Simpleadmin
4
+ module Decorators
5
+ module Fields
6
+ class Base
7
+ def initialize(table_name, table_field_name, resource)
8
+ @table_name = table_name
9
+ @table_field_name = table_field_name
10
+
11
+ @resource = resource
12
+ end
13
+
14
+ def call
15
+ raise NotImplementedError, 'Please follow the unified interface, add method #call'
16
+ end
17
+
18
+ private
19
+
20
+ attr_reader :table_name, :table_field_name, :resource
21
+
22
+ def model
23
+ model_class = table_name.classify.safe_constantize
24
+
25
+ if model_class.nil?
26
+ raise ArgumentError, "The model (#{table_name.classify}) does not exist"
27
+ else
28
+ model_class
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end