app_rail-airtable 0.1.0 → 0.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.
@@ -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