app_rail-airtable 0.2.13 → 0.3.3

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b50f647ec9cbe6a4872a61db88b1bdb15eb9c960b90c219fc5023ea38078e940
4
- data.tar.gz: ab041ecd7134b4f611460f0e7cacf94fdb1e873066d148d7e69217dc904a018c
3
+ metadata.gz: 6a8973236f81565186f4a9cf047fbe516cd7f3f452b551ab0e02c9406fce1c88
4
+ data.tar.gz: 313a3e40f5eb79bd17c6561e2f65f4946ae4df80afdf16d901cf2bad21c23ad0
5
5
  SHA512:
6
- metadata.gz: dbbe3242c32cf4a94a045e58085024224c1b18423936f89228dc00f6672f6c76db979ac6bc242ec89792d3f330626d0a0b900154efd941cb9314545b4109e033
7
- data.tar.gz: 2c3827c50ebdda11aebd971160d45d2fedae642628f59f757f43c48ee020607eca527da0cc159ecb6b7387b371e5c74d27660e960746dff5bf4ad06c1f6442d9
6
+ metadata.gz: dc54fefbcb0f1ef89868e249b2c9b456322a3f78a39fe550e45c20716d1b723f989556281b91d5f437fdf21de0de1a1fbbe6bbd65dff14982d7aa93f570e2b26
7
+ data.tar.gz: b11d2a98504953e57c171b38f13f3e618bdcf8bb42b014e62c883c4deeb3d92dbf1bb20e70a30b70394ed5e83f9c330e25ce6da5627f46509d23f4da81ddc1d7
data/.gitignore CHANGED
@@ -1,3 +1,7 @@
1
+ .DS_STORE
2
+ *.swp
3
+ *.rbc
4
+ *.sass-cache
1
5
  /.bundle/
2
6
  /.yardoc
3
7
  /_yardoc/
@@ -10,3 +14,6 @@
10
14
  # rspec failure tracking
11
15
  .rspec_status
12
16
  .byebug_history
17
+
18
+ # dotenv
19
+ .env
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- app_rail-airtable (0.2.13)
4
+ app_rail-airtable (0.3.3)
5
5
  activesupport
6
6
  airrecord
7
7
  bcrypt
@@ -44,7 +44,7 @@ GEM
44
44
  faraday-net_http_persistent (1.2.0)
45
45
  faraday-patron (1.0.0)
46
46
  faraday-rack (1.0.0)
47
- i18n (1.8.10)
47
+ i18n (1.8.11)
48
48
  concurrent-ruby (~> 1.0)
49
49
  minitest (5.14.4)
50
50
  multipart-post (2.1.1)
@@ -81,7 +81,7 @@ GEM
81
81
  tilt (2.0.10)
82
82
  tzinfo (2.0.4)
83
83
  concurrent-ruby (~> 1.0)
84
- zeitwerk (2.4.2)
84
+ zeitwerk (2.5.1)
85
85
 
86
86
  PLATFORMS
87
87
  ruby
data/README.md CHANGED
@@ -36,7 +36,18 @@ To provide support for routes, models can implement
36
36
  In order to support authentication you should create a class (normally `User`) and inherit from `AppRail::Airtable::AuthenticationRecord`. Your table needs `Email`, `Password Hash` and `Access Token` columns.
37
37
 
38
38
  ### Servers
39
- You should create a single server which extends `AppRail::Airtable::Sinatra`, then add routes with the `resources` macro to add `index`, `show` and `create` routes. You can also provide your own routes.
39
+ You should create a single server which extends `AppRail::Airtable::Sinatra`.
40
+
41
+ App Rail Airtable provides two helpers to generate routes
42
+
43
+ **resources(name, only:)**
44
+ Creates routes that map to a table. It delegates from the route to a model method
45
+ * `index` to `ar_list_item`
46
+ * `show` to `ar_stack`
47
+ * `create` to `self.create_as_json`
48
+
49
+ **authenticable_resources(name, , only:)**
50
+ Acts as `resources` but also takes a block of routes. Those nested routes will all call the `authenticate!` helper method before running. The `authenticate!` helper will call a lookup helper `find_authenticatable_resource(access_token:)` with the bearer token found in the `HTTP_AUTHORIZATION` header, which should be used to look up the correct object or return nil if none is found (resulting in a 401 response).
40
51
 
41
52
  ## License
42
53
 
@@ -17,6 +17,7 @@ module AppRail
17
17
  def self.airtable_attr(*attributes)
18
18
  attributes.each do |attribute|
19
19
  define_method(attribute.to_s.snake_case) { self[attribute] }
20
+ define_method("#{attribute.to_s.snake_case}=") { |value| self[attribute] = value }
20
21
  end
21
22
  end
22
23
 
@@ -12,13 +12,15 @@ module AppRail
12
12
 
13
13
  module ClassMethods
14
14
  def create(email:, password:)
15
- user = User.new("Email" => email, "Password Hash" => password_hash(password), "Access Token" => access_token)
15
+ user = User.new("Email" => email, "Password Hash" => password_hash(password), "Access Token" => next_access_token)
16
16
  user.create
17
17
  user
18
18
  end
19
19
 
20
20
  def create_session_as_json(email:, password:)
21
21
  user = find_by_email_and_password(email, password)
22
+ user["Access Token"] = next_access_token
23
+ user.save
22
24
  user&.oauth_session
23
25
  end
24
26
 
@@ -35,7 +37,7 @@ module AppRail
35
37
  BCrypt::Password.create(password)
36
38
  end
37
39
 
38
- def access_token
40
+ def next_access_token
39
41
  SecureRandom.hex
40
42
  end
41
43
  end
@@ -6,7 +6,7 @@ module AppRail
6
6
  end
7
7
 
8
8
  def find_current_user
9
- authorization_header && bearer_token ? User.find_by_access_token(bearer_token) : nil
9
+ authorization_header && bearer_token ? find_authenticatable_resource(access_token: bearer_token) : nil
10
10
  end
11
11
 
12
12
  def bearer_token
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'active_support/core_ext/string/inflections'
2
4
  require 'app_rail/airtable/string_ext'
3
5
  require 'thor'
@@ -11,21 +13,48 @@ module AppRail
11
13
  argument :output_directory
12
14
  argument :api_key
13
15
  argument :base_id
14
- argument :tables
16
+ argument :schema
15
17
 
16
18
  def self.source_root
17
- File.join(File.dirname(__FILE__), "..", "..", "..")
19
+ File.join(File.dirname(__FILE__), '..', '..', '..')
18
20
  end
19
21
 
20
22
  def create_project
21
- # Build tables
22
- @table_definitions = tables.split(",").map {|t| Airrecord.table(api_key, base_id, t.strip) }
23
+ validate_airtable_schema
23
24
  directory('templates/project', output_directory)
24
25
  end
25
-
26
+
26
27
  def self.exit_on_failure?
27
28
  true
28
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 ||= 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
29
58
  end
30
59
  end
31
- end
60
+ end
@@ -24,6 +24,7 @@ end
24
24
  module AppRail
25
25
  module Airtable
26
26
  class Sinatra < Sinatra::Base
27
+ @@authenticated_route = false
27
28
 
28
29
  helpers do
29
30
  def request_body_as_json
@@ -35,36 +36,54 @@ module AppRail
35
36
  request_body_as_json.merge(params)
36
37
  end
37
38
  end
39
+
40
+ def self.authenticatable_resources(name, only: [:index, :show, :create])
41
+
42
+ # If authentication is used then include the correct helpers
43
+ # Allowing the routes to use `authenticate!` and `current_user`
44
+ helpers AppRail::Airtable::AuthenticationHelpers
45
+
46
+ resources(name, only: only)
47
+
48
+ return unless block_given?
49
+ @@authenticated_route = true
50
+ yield
51
+ @@authenticated_route = false
52
+ end
38
53
 
39
- def self.resources(name, only: [:index, :show, :create], authenticated: false)
54
+ def self.resources(name, only: [:index, :show, :create])
40
55
  only = [only] if only.is_a?(Symbol)
41
56
 
42
- index_route(name, authenticated) if only.include?(:index)
43
- show_route(name, authenticated) if only.include?(:show)
44
- create_route(name, authenticated) if only.include?(:create)
57
+ index_route(name, authenticated_route?) if only.include?(:index)
58
+ show_route(name, authenticated_route?) if only.include?(:show)
59
+ create_route(name, authenticated_route?) if only.include?(:create)
45
60
  end
46
61
 
47
- def self.index_route(name, authenticated)
62
+ def self.index_route(name, authenticated_route)
48
63
  get "/#{name.to_s}" do
49
- authenticate! if authenticated
50
- name.classify_constantize.index(user: authenticated ? current_user : nil).map(&:ar_list_item_as_json).to_json
64
+ authenticate! if authenticated_route
65
+ name.classify_constantize.index(user: authenticated_route ? current_user : nil).map(&:ar_list_item_as_json).to_json
51
66
  end
52
67
  end
53
68
 
54
- def self.show_route(name, authenticated)
69
+ def self.show_route(name, authenticated_route)
55
70
  get "/#{name.to_s}/:id" do
56
- authenticate! if authenticated
71
+ authenticate! if authenticated_route
57
72
  name.classify_constantize.find(params['id']).ar_stack_as_json.to_json
58
73
  end
59
74
  end
60
75
 
61
- def self.create_route(name, authenticated)
76
+ def self.create_route(name, authenticated_route)
62
77
  post "/#{name.to_s}" do
63
- authenticate! if authenticated
64
- as_json = name.classify_constantize.create_as_json(current_user: authenticated ? current_user : nil, params: params_and_body_as_json)
78
+ authenticate! if authenticated_route
79
+ as_json = name.classify_constantize.create_as_json(current_user: authenticated_route ? current_user : nil, params: params_and_body_as_json)
65
80
  [201, as_json.to_json]
66
81
  end
67
82
  end
83
+
84
+ def self.authenticated_route?
85
+ @@authenticated_route
86
+ end
68
87
  end
69
88
  end
70
89
  end
@@ -1,5 +1,5 @@
1
1
  module AppRail
2
2
  module Airtable
3
- VERSION = "0.2.13"
3
+ VERSION = "0.3.3"
4
4
  end
5
5
  end
@@ -2,7 +2,18 @@
2
2
  *.swp
3
3
  *.rbc
4
4
  *.sass-cache
5
- /pkg
6
- /coverage
7
- .yardoc
8
- /doc
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
@@ -5,11 +5,17 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" }
5
5
 
6
6
  ruby '2.7.4'
7
7
 
8
- gem 'app_rail-airtable'
8
+ gem 'app_rail-airtable', '~> 0.3.3'
9
9
 
10
- # dev
11
- gem 'dotenv'
10
+ group :development do
11
+ gem 'dotenv'
12
+ end
12
13
 
13
- # test
14
- gem "rspec"
15
- gem "rack-test"
14
+ group :test do
15
+ gem 'rack-test'
16
+ gem 'rspec'
17
+ end
18
+
19
+ group :development, :test do
20
+ gem 'byebug'
21
+ end
@@ -10,10 +10,14 @@ GEM
10
10
  airrecord (1.0.7)
11
11
  faraday (>= 0.10, < 2.0)
12
12
  net-http-persistent
13
- app_rail-airtable (0.2.2)
13
+ app_rail-airtable (0.3.3)
14
14
  activesupport
15
15
  airrecord
16
+ bcrypt
16
17
  sinatra
18
+ thor
19
+ bcrypt (3.1.16)
20
+ byebug (11.1.3)
17
21
  concurrent-ruby (1.1.9)
18
22
  connection_pool (2.2.5)
19
23
  diff-lcs (1.4.4)
@@ -37,7 +41,7 @@ GEM
37
41
  faraday-net_http_persistent (1.2.0)
38
42
  faraday-patron (1.0.0)
39
43
  faraday-rack (1.0.0)
40
- i18n (1.8.10)
44
+ i18n (1.8.11)
41
45
  concurrent-ruby (~> 1.0)
42
46
  minitest (5.14.4)
43
47
  multipart-post (2.1.1)
@@ -62,23 +66,25 @@ GEM
62
66
  rspec-mocks (3.10.2)
63
67
  diff-lcs (>= 1.2.0, < 2.0)
64
68
  rspec-support (~> 3.10.0)
65
- rspec-support (3.10.2)
69
+ rspec-support (3.10.3)
66
70
  ruby2_keywords (0.0.5)
67
71
  sinatra (2.1.0)
68
72
  mustermann (~> 1.0)
69
73
  rack (~> 2.2)
70
74
  rack-protection (= 2.1.0)
71
75
  tilt (~> 2.0)
76
+ thor (1.1.0)
72
77
  tilt (2.0.10)
73
78
  tzinfo (2.0.4)
74
79
  concurrent-ruby (~> 1.0)
75
- zeitwerk (2.4.2)
80
+ zeitwerk (2.5.1)
76
81
 
77
82
  PLATFORMS
78
83
  ruby
79
84
 
80
85
  DEPENDENCIES
81
- app_rail-airtable
86
+ app_rail-airtable (~> 0.3.3)
87
+ byebug
82
88
  dotenv
83
89
  rack-test
84
90
  rspec
@@ -1,5 +1,11 @@
1
1
  $stdout.sync = true
2
2
 
3
- require 'dotenv/load'
3
+ ENV['RACK_ENV'] ||= 'development'
4
+ require 'dotenv/load' if ENV['RACK_ENV'] == 'development'
5
+ require 'bundler'
6
+ Bundler.require(:default, ENV['RACK_ENV'].to_sym)
7
+
8
+ require 'dotenv/load' if ENV['RACK_ENV'] == 'development'
9
+
4
10
  require './lib/server'
5
11
  run Server
@@ -1,15 +1,62 @@
1
+ # frozen_string_literal: true
2
+
1
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
2
11
 
3
- <% @table_definitions.each do |td| -%>
4
- <% keys = td.all.first.fields.keys -%>
5
- class <%= td.table_name.singularize %> < AppRail::Airtable::ApplicationRecord
12
+ <% end -%>
6
13
  airtable_attr <%= keys.map{|f| "\"#{f}\""}.join(", ") %>
7
- ar_list_item text: :<%= keys.first.snake_case %>
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
+ <% end -%>
8
29
  end
9
30
 
10
31
  <% end -%>
32
+
33
+ <% authenticatable_table = @tables.select { |table| table['authenticatable'] == 'True' }&.first -%>
34
+ <% authenticatable_resource = authenticatable_table['name'] if authenticatable_table -%>
11
35
  class Server < AppRail::Airtable::Sinatra
12
- <% @table_definitions.each do |td| -%>
13
- resources :<%= td.table_name.snake_case %>
36
+ <% if authenticatable_resource -%>
37
+ <% model_name = authenticatable_resource.singularize -%>
38
+ helpers do
39
+ def find_authenticatable_resource(access_token:)
40
+ <%= model_name %>.find_by_access_token(access_token)
41
+ end
42
+ end
43
+
44
+ authenticatable_resources :<%= authenticatable_resource.snake_case %> do
45
+ <% @tables.each do |table| -%>
46
+ <% next if table['name'] == authenticatable_resource -%>
47
+ resources :<%= table['name'].snake_case %>
48
+ <% end -%>
49
+ end
50
+
51
+ post "/sessions" do
52
+ oauth_session = <%= model_name %>.create_session_as_json(email: params["username"], password: params["password"])
53
+ halt 401 unless oauth_session
54
+
55
+ oauth_session.to_json
56
+ end
57
+ <% else -%>
58
+ <% @tables.each do |table| -%>
59
+ resources :<%= table['name'].snake_case %>
60
+ <% end -%>
14
61
  <% end -%>
15
62
  end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ <% authenticatable_table = @tables.select { |table| table['authenticatable'] == 'True' }&.first -%>
4
+ <% authenticatable_resource = authenticatable_table['name'] if authenticatable_table -%>
5
+ require 'spec_helper'
6
+
7
+ RSpec.describe Server do
8
+ let(:json_response) { JSON.parse(response.body, symbolize_names: true) }
9
+ let(:access_token) { SecureRandom.hex }
10
+ let(:auth_headers) { { 'HTTP_AUTHORIZATION' => "Bearer: #{access_token}" } }
11
+
12
+ def app
13
+ Server
14
+ end
15
+
16
+ <% if authenticatable_resource -%>
17
+ <% auth_model_name = authenticatable_resource.singularize -%>
18
+ <% auth_path_name = authenticatable_resource.snake_case.pluralize -%>
19
+ describe 'POST /<%= auth_path_name %>' do
20
+ let(:params) { { payload: { email: 'test@test.com', password: 'Secret000' } } }
21
+ let(:response) { post "/<%= auth_path_name %>", params.to_json }
22
+ let(:mock_resource) { double(<%= auth_model_name %>) }
23
+
24
+ before { allow_any_instance_of(<%= auth_model_name %>).to receive(:create) { mock_resource } }
25
+
26
+ it { expect(response.status).to eq 201 }
27
+ it { expect(json_response[:oauth_session][:access_token]).to_not be_nil }
28
+ end
29
+
30
+ describe 'POST /sessions' do
31
+ let(:params) { { username: 'test@test.com', password: 'Secret000' } }
32
+ let(:response) { post "/sessions", params }
33
+
34
+ context 'ok' do
35
+ let(:oauth_session) { { access_token: SecureRandom.hex } }
36
+
37
+ before { allow(<%= auth_model_name %>).to receive(:create_session_as_json).with({ email: params[:username], password: params[:password] }) { oauth_session } }
38
+
39
+ it { expect(response.status).to eq 200 }
40
+ it { expect(json_response[:access_token]).to_not be_nil }
41
+ end
42
+
43
+ context 'invalid' do
44
+ before { allow(<%= auth_model_name %>).to receive(:create_session_as_json).with({ email: params[:username], password: params[:password] }) { nil } }
45
+
46
+ it { expect(response.status).to eq 401 }
47
+ it { expect(response.body).to eq '' }
48
+ end
49
+ end
50
+
51
+ <% @tables.each do |table| -%>
52
+ <% next if table['name'] == authenticatable_resource -%>
53
+ <% model_name = table['name'].singularize -%>
54
+ <% path_name = table['name'].snake_case.pluralize -%>
55
+ describe 'GET /<%= path_name %>' do
56
+ let(:params) { {} }
57
+ let(:response) { get '/<%= path_name %>', params.to_json, auth_headers}
58
+ let(:mock_auth_resource) { double(<%= auth_model_name %>) }
59
+ let(:mock_resource) { double(<%= model_name %>, id: '1') }
60
+ let(:resource_to_json) { { id: '1' } }
61
+
62
+ before do
63
+ allow(<%= auth_model_name %>).to receive(:find_by_access_token).with(access_token) { mock_auth_resource }
64
+ allow(<%= model_name %>).to receive(:all) { [mock_resource] }
65
+ allow(mock_resource).to receive(:ar_list_item_as_json) { resource_to_json }
66
+ end
67
+
68
+ it { expect(response.status).to eq 200 }
69
+ it { expect(json_response).to eq [resource_to_json] }
70
+ end
71
+
72
+ <% end -%>
73
+ <% else -%>
74
+ <% @tables.each do |table| -%>
75
+ <% model_name = table['name'].singularize -%>
76
+ <% path_name = table['name'].snake_case.pluralize -%>
77
+ describe 'GET /<%= path_name %>' do
78
+ let(:params) { {} }
79
+ let(:response) { get '/<%= path_name %>', params.to_json, auth_headers}
80
+ let(:mock_resource) { double(<%= model_name %>, id: '1') }
81
+ let(:resource_to_json) { { id: '1' } }
82
+
83
+ before do
84
+ allow(<%= model_name %>).to receive(:all) { [mock_resource] }
85
+ allow(mock_resource).to receive(:ar_list_item_as_json) { resource_to_json }
86
+ end
87
+
88
+ it { expect(response.status).to eq 200 }
89
+ it { expect(json_response).to eq [resource_to_json] }
90
+ end
91
+
92
+ <% end -%>
93
+ <% end -%>
94
+ end
@@ -23,7 +23,6 @@ require "rack/test"
23
23
  require "app_rail/airtable"
24
24
 
25
25
  require './lib/server'
26
- require './lib/item_recommender'
27
26
 
28
27
  RSpec.configure do |config|
29
28
  # rspec-expectations config goes here. You can use an alternate
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: app_rail-airtable
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.13
4
+ version: 0.3.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matt Brooke-Smith
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-10-06 00:00:00.000000000 Z
11
+ date: 2021-11-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -143,7 +143,7 @@ files:
143
143
  - templates/project/app.json
144
144
  - templates/project/config.ru
145
145
  - templates/project/lib/server.rb.tt
146
- - templates/project/spec/server_spec.rb
146
+ - templates/project/spec/server_spec.rb.tt
147
147
  - templates/project/spec/spec_helper.rb
148
148
  homepage: https://github.com/FutureWorkshops/app_rail-airtable
149
149
  licenses:
@@ -1,9 +0,0 @@
1
- require "spec_helper"
2
-
3
- RSpec.describe Server do
4
-
5
- def app
6
- Server
7
- end
8
-
9
- end