app_rail-airtable 0.1.0 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,49 +1,67 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'airrecord'
4
+ require 'app_rail/steps'
5
+ require 'active_support/core_ext/string/inflections'
2
6
 
3
7
  module AppRail
4
8
  module Airtable
5
9
  class ApplicationRecord < Airrecord::Table
6
-
7
- def self.ar_list_item(text:, detail_text:, image: nil, sf_symbol: nil, material_icon: nil)
10
+ include ActiveSupport::Inflector
11
+ include AppRail::Steps::Displayable
12
+
13
+ def self.base_key
14
+ ENV.fetch('AIRTABLE_BASE_ID')
15
+ end
16
+
17
+ def self.table_name
18
+ name.pluralize
19
+ end
20
+
21
+ def self.airtable_attr(*attributes)
22
+ attributes.each do |attribute|
23
+ define_method(attribute.to_s.snake_case) { self[attribute] }
24
+ define_method("#{attribute.to_s.snake_case}=") { |value| self[attribute] = value }
25
+ end
26
+ end
27
+
28
+ # Step utilities
29
+ def self.ar_list_item(text:, detail_text: nil, image: nil, sf_symbol: nil, material_icon: nil)
8
30
  define_method(:ar_list_item_as_json) do
9
31
  {
10
- id: self.id,
11
- text: method_value_or_property(text).to_s,
12
- detailText: method_value_or_property(detail_text).to_s,
13
- imageURL: method_value_or_property(image),
14
- sfSymbolName: sf_symbol,
15
- materialIconName: material_icon
32
+ id: id,
33
+ text: method_value(text).to_s,
34
+ detailText: method_value(detail_text).to_s,
35
+ imageURL: method_value(image),
36
+ sfSymbolName: method_value(sf_symbol),
37
+ materialIconName: method_value(material_icon)
16
38
  }.compact
17
39
  end
18
40
  end
19
-
41
+
20
42
  def self.ar_stack(text_items:)
21
43
  define_method(:ar_stack_as_json) do
22
- text_items.map{|ti| {type: :text, label: ti, text: self[ti].to_s} }
44
+ text_items.map { |ti| { type: :text, label: ti, text: self[ti].to_s } }
23
45
  end
24
46
  end
25
-
47
+
26
48
  # Customisable behaviour
27
-
49
+
28
50
  # Override to provide custom sorting or filtering for index
29
- def self.index
51
+ def self.index(user:)
30
52
  all
31
53
  end
32
-
54
+
33
55
  private
34
-
35
- # TODO: Method call not working?
36
- def method_value_or_property(name)
37
- return nil unless name
38
-
39
- respond_to?(name) ? send(name) : self[name]
40
- end
41
-
56
+
57
+ def method_value(name)
58
+ send(name) if name.present?
59
+ end
60
+
42
61
  # size is either :small, :large or :full
43
62
  def image(name, index: 0, size: :full)
44
- self[name][index]["thumbnails"][size.to_s]["url"]
63
+ self[name][index]['thumbnails'][size.to_s]['url'] if self[name] && self[name].length > index
45
64
  end
46
-
47
65
  end
48
66
  end
49
- end
67
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bcrypt'
4
+ require 'securerandom'
5
+
6
+ module AppRail
7
+ module Airtable
8
+ module Authenticatable
9
+ class AlreadyExistsError < StandardError; end
10
+
11
+ include BCrypt
12
+
13
+ def self.included(klass)
14
+ klass.extend(ClassMethods)
15
+ klass.prepend(InstanceMethods)
16
+ end
17
+
18
+ module ClassMethods
19
+ def create(email:, password:)
20
+ raise AlreadyExistsError if find_by_email(email)
21
+
22
+ user = new('Email' => email, 'Password Hash' => password_hash(password), 'Access Token' => next_access_token)
23
+ user.create
24
+ user
25
+ end
26
+
27
+ def find_by_email_and_password(email, password)
28
+ user = find_by_email(email)
29
+ user&.valid_password?(password) ? user : nil
30
+ end
31
+
32
+ def find_by_email(email)
33
+ all(filter: "{Email} = \"#{email}\"").first
34
+ end
35
+
36
+ def find_by_access_token(access_token)
37
+ all(filter: "{Access Token} = \"#{access_token}\"").first
38
+ end
39
+
40
+ def password_hash(password)
41
+ BCrypt::Password.create(password)
42
+ end
43
+
44
+ def next_access_token
45
+ SecureRandom.hex
46
+ end
47
+ end
48
+
49
+ module InstanceMethods
50
+ def valid_password?(password)
51
+ BCrypt::Password.new(password_hash) == password
52
+ end
53
+
54
+ def password_hash
55
+ self['Password Hash']
56
+ end
57
+
58
+ def oauth_session
59
+ ensure_access_token!
60
+
61
+ {
62
+ access_token: self['Access Token'],
63
+ scope: :user,
64
+ token_type: :bearer,
65
+ expires_in: 31_536_000 # 1 year
66
+ }
67
+ end
68
+
69
+ def ensure_access_token!
70
+ unless self['Access Token']
71
+ self['Access Token'] = User.next_access_token
72
+ save
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AppRail
4
+ module Airtable
5
+ module AuthenticationHelpers
6
+ def current_user
7
+ @current_user ||= find_current_user
8
+ end
9
+
10
+ def find_current_user
11
+ authorization_header && bearer_token ? find_authenticatable_resource(access_token: bearer_token) : nil
12
+ end
13
+
14
+ def bearer_token
15
+ authorization_values = authorization_header.split(' ')
16
+ return nil unless authorization_values.count > 1
17
+
18
+ authorization_values[1]
19
+ end
20
+
21
+ def authorization_header
22
+ request.env['HTTP_AUTHORIZATION']
23
+ end
24
+
25
+ def oauth_client_id
26
+ ENV.fetch('OAUTH_CLIENT_ID')
27
+ end
28
+
29
+ def oauth_client_secret
30
+ ENV.fetch('OAUTH_CLIENT_SECRET')
31
+ end
32
+
33
+ def authenticate!
34
+ halt 401 unless current_user
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/string/inflections'
4
+ require 'app_rail/airtable/string_ext'
5
+ require 'thor'
6
+ require 'airrecord'
7
+
8
+ module AppRail
9
+ module Airtable
10
+ class Generator < Thor::Group
11
+ include Thor::Actions
12
+
13
+ argument :output_directory
14
+ argument :api_key
15
+ argument :base_id
16
+ argument :schema, required: false
17
+
18
+ def self.source_root
19
+ File.join(File.dirname(__FILE__), '..', '..', '..')
20
+ end
21
+
22
+ def create_project
23
+ validate_airtable_schema
24
+ directory('templates/project', output_directory)
25
+ end
26
+
27
+ def self.exit_on_failure?
28
+ true
29
+ end
30
+
31
+ private
32
+
33
+ def validate_airtable_schema
34
+ tables.each do |table|
35
+ table_name = table['name']
36
+ table_fields = table['fields'].concat(table['associations']).map { |field| field['name'] }.compact
37
+
38
+ airtable_table = find_table(table_name)
39
+ compare_fields(table_fields, airtable_table)
40
+ end
41
+ end
42
+
43
+ def tables
44
+ @tables ||= schema ? JSON.parse(schema)['tables'] : []
45
+ end
46
+
47
+ def find_table(table_name)
48
+ Airrecord.table(api_key, base_id, table_name)
49
+ end
50
+
51
+ def compare_fields(table_fields, airtable_table)
52
+ airtable_table.records.first.fields.each_key do |key|
53
+ next if table_fields.include? key
54
+
55
+ raise "ERROR! The key \"#{key}\" is not present in the schema definition."
56
+ end
57
+ end
58
+
59
+ def fields_as_params_examples(fields)
60
+ fields.each_with_object({}) do |field, hsh|
61
+ hsh[field['name']] = case field['type']
62
+ when 'date'
63
+ '01/01/2022'
64
+ when 'string', 'text'
65
+ 'MyString'
66
+ when 'integer'
67
+ 10
68
+ else
69
+ 'MyString'
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -1,46 +1,134 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'sinatra'
2
4
  require 'json'
3
5
  require 'active_support'
4
6
 
5
- Airrecord.api_key = ENV.fetch("AIRTABLE_API_KEY")
7
+ # TODO: MBS - move to configure block or other
8
+ Airrecord.api_key = ENV.fetch('AIRTABLE_API_KEY')
6
9
 
7
10
  class Symbol
8
-
9
11
  def pluralize
10
12
  ActiveSupport::Inflector.pluralize(self)
11
13
  end
12
-
14
+
15
+ def singularize
16
+ ActiveSupport::Inflector.singularize(self)
17
+ end
18
+
13
19
  def classify
14
- ActiveSupport::Inflector.classify(self)
20
+ ActiveSupport::Inflector.classify(self)
15
21
  end
16
-
22
+
17
23
  def classify_constantize
18
24
  ActiveSupport::Inflector.constantize(classify)
19
25
  end
20
-
21
26
  end
22
27
 
23
28
  module AppRail
24
29
  module Airtable
25
30
  class Sinatra < Sinatra::Base
31
+ @@authenticated_route = false
32
+
33
+ helpers do
34
+ def request_body_as_json
35
+ request.body.rewind
36
+ JSON.parse(request.body.read)
37
+ end
38
+
39
+ def params_and_body_as_json
40
+ request_body_as_json.merge(params)
41
+ end
42
+ end
43
+
44
+ def self.authenticatable_resources(name, only: %i[index show create update])
45
+ # If authentication is used then include the correct helpers
46
+ # Allowing the routes to use `authenticate!` and `current_user`
47
+ helpers AppRail::Airtable::AuthenticationHelpers
48
+
49
+ sign_in_route(name)
50
+ sign_out_route(name)
51
+ resources(name, only: only)
52
+
53
+ return unless block_given?
54
+
55
+ @@authenticated_route = true
56
+ yield
57
+ @@authenticated_route = false
58
+ end
59
+
60
+ def self.resources(name, only: %i[index show create update])
61
+ only = [only] if only.is_a?(Symbol)
62
+
63
+ index_route(name, authenticated_route?) if only.include?(:index)
64
+ show_route(name, authenticated_route?) if only.include?(:show)
65
+ create_route(name, authenticated_route?) if only.include?(:create)
66
+ update_route(name, authenticated_route?) if only.include?(:update)
67
+ end
26
68
 
27
- def self.resource(name)
28
- get "/#{name.to_s}" do
29
- name.classify_constantize.index.map(&:ar_list_item_as_json).to_json
69
+ def self.sign_in_route(name)
70
+ post "/#{name}/session" do
71
+ halt [401, { error: 'Invalid client_id' }.to_json] unless params['client_id'] == oauth_client_id
72
+ halt [401, { error: 'Invalid client_secret' }.to_json] unless params['client_secret'] == oauth_client_secret
73
+
74
+ resource = name.classify_constantize.authenticate_by_params(params)
75
+ halt [401, { error: 'Invalid credentials' }.to_json] unless resource
76
+
77
+ resource.oauth_session.to_json
78
+ end
79
+ end
80
+
81
+ def self.sign_out_route(name)
82
+ delete "/#{name}/session" do
83
+ authenticate!
84
+ current_user.access_token = nil
85
+ current_user.save
86
+
87
+ # Assume that the client will reload and trigger a 401
88
+ [].to_json
89
+ end
90
+ end
91
+
92
+ def self.index_route(name, authenticated_route)
93
+ get "/#{name}" do
94
+ authenticate! if authenticated_route
95
+ name.classify_constantize.index(user: authenticated_route ? current_user : nil).map(&:ar_list_item_as_json).to_json
30
96
  end
31
-
32
- get "/#{name.to_s}/:id" do
97
+ end
98
+
99
+ def self.show_route(name, authenticated_route)
100
+ get "/#{name}/:id" do
101
+ authenticate! if authenticated_route
33
102
  name.classify_constantize.find(params['id']).ar_stack_as_json.to_json
34
103
  end
35
-
36
- post "/#{name.to_s}" do
37
- request.body.rewind # in case someone already read it
38
- data = JSON.parse(request.body.read)
39
- new_record = name.classify_constantize.create_from_params(data.merge(params))
40
- [201, {id: new_record.id}.to_json]
104
+ end
105
+
106
+ def self.create_route(name, authenticated_route)
107
+ post "/#{name}" do
108
+ authenticate! if authenticated_route
109
+ begin
110
+ as_json = name.classify_constantize.create_as_json(current_user: authenticated_route ? current_user : nil,
111
+ params: params_and_body_as_json)
112
+ [201, as_json.to_json]
113
+ rescue AppRail::Airtable::Authenticatable::AlreadyExistsError
114
+ [422, { error: "#{name.singularize} already exists" }.to_json]
115
+ end
41
116
  end
42
117
  end
43
118
 
119
+ def self.update_route(name, authenticated_route)
120
+ put "/#{name}/:id" do
121
+ authenticate! if authenticated_route
122
+ record = name.classify_constantize.find(params['id'])
123
+ as_json = record.update_as_json(current_user: authenticated_route ? current_user : nil,
124
+ params: params_and_body_as_json)
125
+ [200, as_json.to_json]
126
+ end
127
+ end
128
+
129
+ def self.authenticated_route?
130
+ @@authenticated_route
131
+ end
44
132
  end
45
133
  end
46
- end
134
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class String
4
+ def snake_case
5
+ gsub(' ', '_').underscore
6
+ end
7
+ end
@@ -1,5 +1,7 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module AppRail
2
4
  module Airtable
3
- VERSION = "0.1.0"
5
+ VERSION = '0.5.0'
4
6
  end
5
7
  end
@@ -1,6 +1,12 @@
1
- require "app_rail/airtable/version"
2
- require "app_rail/airtable/application_record"
3
- require "app_rail/airtable/sinatra"
1
+ # frozen_string_literal: true
2
+
3
+ require 'app_rail/airtable/version'
4
+ require 'app_rail/airtable/string_ext'
5
+ require 'app_rail/airtable/application_record'
6
+ require 'app_rail/airtable/authenticatable'
7
+ require 'app_rail/airtable/authentication_helpers'
8
+ require 'app_rail/airtable/sinatra'
9
+ require 'app_rail/airtable/generator'
4
10
 
5
11
  module AppRail
6
12
  module Airtable
@@ -0,0 +1,2 @@
1
+ AIRTABLE_API_KEY=<%= api_key %>
2
+ AIRTABLE_BASE_ID=<%= base_id %>
@@ -0,0 +1,19 @@
1
+ .DS_STORE
2
+ *.swp
3
+ *.rbc
4
+ *.sass-cache
5
+ /.bundle/
6
+ /.yardoc
7
+ /_yardoc/
8
+ /coverage/
9
+ /doc/
10
+ /pkg/
11
+ /spec/reports/
12
+ /tmp/
13
+
14
+ # rspec failure tracking
15
+ .rspec_status
16
+ .byebug_history
17
+
18
+ # dotenv
19
+ .env
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+ git_source(:github) { |repo| "https://github.com/#{repo}.git" }
5
+
6
+ ruby '3.1.2'
7
+
8
+ gem 'app_rail-airtable'
9
+ gem 'app_rail-steps'
10
+ gem 'puma'
11
+ gem 'sinatra', '~> 3.0', '>= 3.0.2'
12
+
13
+ group :development do
14
+ gem 'dotenv'
15
+ end
16
+
17
+ group :test do
18
+ gem 'rack-test'
19
+ gem 'rspec'
20
+ end
21
+
22
+ group :development, :test do
23
+ gem 'byebug'
24
+ end
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "App Rail Airtable Service",
3
+ "description": "A barebones template to convert Airtable data to App Rail format",
4
+ "buildpacks": [
5
+ {
6
+ "url": "heroku/ruby"
7
+ }
8
+ ],
9
+ "env": {
10
+ "AIRTABLE_API_KEY": {
11
+ "description": "Your Airtable API key."
12
+ },
13
+ "AIRTABLE_BASE_ID": {
14
+ "description": "The ID of the Base you'd like to connect to."
15
+ }
16
+ }
17
+ }
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ ENV['RACK_ENV'] ||= 'development'
4
+
5
+ require 'dotenv/load' if ENV['RACK_ENV'] == 'development'
6
+
7
+ require 'bundler'
8
+ Bundler.require(:default, ENV['RACK_ENV'].to_sym)
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ $stdout.sync = true
4
+ require './boot'
5
+ require './lib/server'
6
+ run Server
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'app_rail/airtable'
4
+ require 'active_support/inflector'
5
+
6
+ <% @tables.each do |table| -%>
7
+ <% keys = table['fields'].map { |field| field['name'] if field['type'] != 'association' }.compact -%>
8
+ class <%= table['name'].singularize %> < AppRail::Airtable::ApplicationRecord
9
+ <% if table['authenticatable'] == 'True' -%>
10
+ include AppRail::Airtable::Authenticatable
11
+
12
+ <% end -%>
13
+ airtable_attr <%= keys.map{|f| "\"#{f}\""}.join(", ") %>
14
+ <% table['ar_class_methods']&.each do |method| -%>
15
+ <%= method['name'] %> <%= method['properties'].map {|prop| "#{prop['type']}: :#{prop['value']}" }.join(', ') %>
16
+ <% end -%>
17
+
18
+ <% table['associations']&.each do |association| -%>
19
+ <% word_ending_method = association['type'] == 'has_many' ? :pluralize : :singularize -%>
20
+ <%= association['type'] %> <%= ":#{association['model'].snake_case.send(word_ending_method)}, class: \"#{association['model'].camelize.singularize}\", column: \"#{association['model'].camelize.send(word_ending_method)}\"" %>
21
+ <% end -%>
22
+ <% if table['authenticatable'] == 'True' -%>
23
+
24
+ def self.create_as_json(params:, current_user:)
25
+ record = <%= table['name'].singularize %>.create(email: params["payload"]["email"], password: params["payload"]["password"])
26
+ { response: { id: record.id }, oauth_session: record.oauth_session}
27
+ end
28
+
29
+ def update_as_json(params:, current_user:)
30
+ params['payload'].each { |attr, v| public_send("#{attr}=", v) }
31
+ save
32
+ { response: { id: id }, oauth_session: oauth_session }
33
+ end
34
+ <% end -%>
35
+ end
36
+
37
+ <% end -%>
38
+
39
+ <% authenticatable_table = @tables.select { |table| table['authenticatable'] == 'True' }&.first -%>
40
+ <% authenticatable_resource = authenticatable_table['name'] if authenticatable_table -%>
41
+ class Server < AppRail::Airtable::Sinatra
42
+ <% if authenticatable_resource -%>
43
+ <% model_name = authenticatable_resource.singularize -%>
44
+ helpers do
45
+ def find_authenticatable_resource(access_token:)
46
+ <%= model_name %>.find_by_access_token(access_token)
47
+ end
48
+ end
49
+
50
+ authenticatable_resources :<%= authenticatable_resource.snake_case %> do
51
+ <% @tables.each do |table| -%>
52
+ <% next if table['name'] == authenticatable_resource -%>
53
+ resources :<%= table['name'].snake_case %>
54
+ <% end -%>
55
+ end
56
+
57
+ post "/sessions" do
58
+ oauth_session = <%= model_name %>.create_session_as_json(email: params["username"], password: params["password"])
59
+ halt 401 unless oauth_session
60
+
61
+ oauth_session.to_json
62
+ end
63
+ <% else -%>
64
+ <% @tables.each do |table| -%>
65
+ resources :<%= table['name'].snake_case %>
66
+ <% end -%>
67
+ <% end -%>
68
+ end