fmrest-core 0.13.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fmrest"
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fmrest/spyke"
data/lib/fmrest.rb ADDED
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "faraday_middleware"
5
+
6
+ require "fmrest/version"
7
+ require "fmrest/connection_settings"
8
+ require "fmrest/errors"
9
+
10
+ module FmRest
11
+ autoload :V1, "fmrest/v1"
12
+ autoload :TokenStore, "fmrest/token_store"
13
+ autoload :Spyke, "fmrest/spyke"
14
+
15
+ class << self
16
+ attr_accessor :token_store
17
+
18
+ def default_connection_settings=(settings)
19
+ @default_connection_settings = ConnectionSettings.wrap(settings, skip_validation: true)
20
+ end
21
+
22
+ def default_connection_settings
23
+ @default_connection_settings || ConnectionSettings.new({}, skip_validation: true)
24
+ end
25
+
26
+ def config=(connection_hash)
27
+ warn "[DEPRECATION] `FmRest.config=` is deprecated, use `FmRest.default_connection_settings=` instead"
28
+ self.default_connection_settings = connection_hash
29
+ end
30
+
31
+ def config
32
+ warn "[DEPRECATION] `FmRest.config` is deprecated, use `FmRest.default_connection_settings` instead"
33
+ default_connection_settings
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FmRest
4
+ # Wrapper class for connection settings hash, with a number of purposes:
5
+ #
6
+ # * Provide indifferent access (base hash can have either string or symbol
7
+ # keys)
8
+ # * Method access
9
+ # * Default values
10
+ # * Basic validation
11
+ # * Normalization (e.g. aliased settings)
12
+ # * Useful error messages
13
+ class ConnectionSettings
14
+ class MissingSetting < ArgumentError; end
15
+
16
+ PROPERTIES = %i(
17
+ host
18
+ database
19
+ username
20
+ password
21
+ token
22
+ token_store
23
+ autologin
24
+ ssl
25
+ proxy
26
+ log
27
+ coerce_dates
28
+ date_format
29
+ timestamp_format
30
+ time_format
31
+ timezone
32
+ ).freeze
33
+
34
+ # NOTE: password intentionally left non-required since it's only really
35
+ # needed when no token exists, and should only be required when logging in
36
+ REQUIRED = %i(
37
+ host
38
+ database
39
+ ).freeze
40
+
41
+ DEFAULT_DATE_FORMAT = "MM/dd/yyyy"
42
+ DEFAULT_TIME_FORMAT = "HH:mm:ss"
43
+ DEFAULT_TIMESTAMP_FORMAT = "#{DEFAULT_DATE_FORMAT} #{DEFAULT_TIME_FORMAT}"
44
+
45
+ DEFAULTS = {
46
+ autologin: true,
47
+ log: false,
48
+ date_format: DEFAULT_DATE_FORMAT,
49
+ time_format: DEFAULT_TIME_FORMAT,
50
+ timestamp_format: DEFAULT_TIMESTAMP_FORMAT,
51
+ coerce_dates: false
52
+ }.freeze
53
+
54
+ def self.wrap(settings, skip_validation: false)
55
+ if settings.kind_of?(self)
56
+ settings.validate unless skip_validation
57
+ return settings
58
+ end
59
+ new(settings, skip_validation: skip_validation)
60
+ end
61
+
62
+ def initialize(settings, skip_validation: false)
63
+ @settings = settings.to_h.dup
64
+ normalize
65
+ validate unless skip_validation
66
+ end
67
+
68
+ PROPERTIES.each do |p|
69
+ define_method(p) do
70
+ get(p)
71
+ end
72
+
73
+ define_method("#{p}!") do
74
+ r = get(p)
75
+ raise MissingSetting, "Missing required setting: `#{p}'" if r.nil?
76
+ r
77
+ end
78
+
79
+ define_method("#{p}?") do
80
+ !!get(p)
81
+ end
82
+ end
83
+
84
+ def [](key)
85
+ raise ArgumentError, "Unknown setting `#{key}'" unless PROPERTIES.include?(key.to_sym)
86
+ get(key)
87
+ end
88
+
89
+ def to_h
90
+ PROPERTIES.each_with_object({}) do |p, h|
91
+ v = get(p)
92
+ h[p] = v unless v == DEFAULTS[p]
93
+ end
94
+ end
95
+
96
+ def merge(other, **keyword_args)
97
+ other = self.class.wrap(other, skip_validation: true)
98
+ self.class.new(to_h.merge(other.to_h), **keyword_args)
99
+ end
100
+
101
+ def validate
102
+ missing = REQUIRED.select { |r| get(r).nil? }.map { |m| "`#{m}'" }
103
+ raise MissingSetting, "Missing required setting(s): #{missing.join(', ')}" unless missing.empty?
104
+
105
+ unless username? || token?
106
+ raise MissingSetting, "A minimum of `username' or `token' are required to be able to establish a connection"
107
+ end
108
+ end
109
+
110
+ private
111
+
112
+ def get(key)
113
+ return @settings[key.to_sym] if @settings.has_key?(key.to_sym)
114
+ return @settings[key.to_s] if @settings.has_key?(key.to_s)
115
+ DEFAULTS[key.to_sym]
116
+ end
117
+
118
+ def normalize
119
+ if !get(:username) && account_name = get(:account_name)
120
+ @settings[:username] = account_name
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FmRest
4
+ class Error < StandardError; end
5
+
6
+ class APIError < Error
7
+ attr_reader :code
8
+
9
+ def initialize(code, message = nil)
10
+ @code = code
11
+ super("FileMaker Data API responded with error #{code}: #{message}")
12
+ end
13
+ end
14
+
15
+ class APIError::UnknownError < APIError; end # error code -1
16
+ class APIError::ResourceMissingError < APIError; end # error codes 100..199
17
+ class APIError::RecordMissingError < APIError::ResourceMissingError; end
18
+ class APIError::AccountError < APIError; end # error codes 200..299
19
+ class APIError::LockError < APIError; end # error codes 300..399
20
+ class APIError::ParameterError < APIError; end # error codes 400..499
21
+ class APIError::NoMatchingRecordsError < APIError::ParameterError; end
22
+ class APIError::ValidationError < APIError; end # error codes 500..599
23
+ class APIError::SystemError < APIError; end # error codes 800..899
24
+ class APIError::InvalidToken < APIError; end # error code 952
25
+ class APIError::MaximumDataAPICallsExceeded < APIError; end # error code 953
26
+ class APIError::ScriptError < APIError; end # error codes 1200..1299
27
+ class APIError::ODBCError < APIError; end # error codes 1400..1499
28
+
29
+ class ContainerFieldError < Error; end
30
+ end
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module FmRest
6
+ # Gotchas:
7
+ #
8
+ # 1.
9
+ #
10
+ # Date === <StringDate instance> # => false
11
+ #
12
+ # The above can affect case conditions, as trying to match a StringDate
13
+ # with:
14
+ #
15
+ # case obj
16
+ # when Date
17
+ # ...
18
+ #
19
+ # ...will not work.
20
+ #
21
+ # Instead one must specify the FmRest::StringDate class:
22
+ #
23
+ # case obj
24
+ # when Date, FmRest::StringDate
25
+ # ...
26
+ #
27
+ # 2.
28
+ #
29
+ # StringDate#eql? only matches other strings, not dates.
30
+ #
31
+ # This could affect hash indexing when a StringDate is used as a key.
32
+ #
33
+ # TODO: Verify the above
34
+ #
35
+ # 3.
36
+ #
37
+ # StringDate#succ and StringDate#next return a String, despite Date#succ
38
+ # and Date#next also existing.
39
+ #
40
+ # Workaround: Use StringDate#next_day or strdate + 1
41
+ #
42
+ # 4.
43
+ #
44
+ # StringDate#to_s returns the original string, not the Date string
45
+ # representation.
46
+ #
47
+ # Workaround: Use strdate.to_date.to_s
48
+ #
49
+ # 5.
50
+ #
51
+ # StringDate#hash returns the hash for the string (important when using
52
+ # a StringDate as a hash key)
53
+ #
54
+ # 6.
55
+ #
56
+ # StringDate#as_json returns the string
57
+ #
58
+ # Workaround: Use strdate.to_date.as_json
59
+ #
60
+ # 7.
61
+ #
62
+ # Equality with Date is not reciprocal:
63
+ #
64
+ # str_date == date #=> true
65
+ # date == str_date #=> false
66
+ #
67
+ # NOTE: Potential workaround: Inherit StringDate from Date instead of String
68
+ #
69
+ # 8.
70
+ #
71
+ # Calling string transforming methods (e.g. .upcase) returns a StringDate
72
+ # instead of a String.
73
+ #
74
+ # NOTE: Potential workaround: Inherit StringDate from Date instead of String
75
+ #
76
+ class StringDate < String
77
+ DELEGATE_CLASS = ::Date
78
+
79
+ class InvalidDate < ArgumentError; end
80
+
81
+ class << self
82
+ def strptime(str, date_format, *_)
83
+ begin
84
+ date = self::DELEGATE_CLASS.strptime(str, date_format)
85
+ rescue ArgumentError
86
+ raise InvalidDate
87
+ end
88
+
89
+ new(str, date)
90
+ end
91
+ end
92
+
93
+ def initialize(str, date, **str_args)
94
+ raise ArgumentError, "str must be of class String" unless str.is_a?(String)
95
+ raise ArgumentError, "date must be of class #{self.class::DELEGATE_CLASS.name}" unless date.is_a?(self.class::DELEGATE_CLASS)
96
+
97
+ super(str, **str_args)
98
+
99
+ @delegate = date
100
+
101
+ freeze
102
+ end
103
+
104
+ def is_a?(klass)
105
+ klass == ::Date || super
106
+ end
107
+ alias_method :kind_of?, :is_a?
108
+
109
+ def to_date
110
+ @delegate
111
+ end
112
+
113
+ def to_datetime
114
+ @delegate.to_datetime
115
+ end
116
+
117
+ def to_time
118
+ @delegate.to_time
119
+ end
120
+
121
+ # ActiveSupport method
122
+ def in_time_zone(*_)
123
+ @delegate.in_time_zone(*_)
124
+ end
125
+
126
+ def inspect
127
+ "#<#{self.class.name} #{@delegate.inspect} - #{super}>"
128
+ end
129
+
130
+ def <=>(oth)
131
+ return @delegate <=> oth if oth.is_a?(::Date) || oth.is_a?(Numeric)
132
+ super
133
+ end
134
+
135
+ def +(val)
136
+ return @delegate + val if val.kind_of?(Numeric)
137
+ super
138
+ end
139
+
140
+ def <<(val)
141
+ return @delegate << val if val.kind_of?(Numeric)
142
+ super
143
+ end
144
+
145
+ def ==(oth)
146
+ return @delegate == oth if oth.kind_of?(::Date) || oth.kind_of?(Numeric)
147
+ super
148
+ end
149
+ alias_method :===, :==
150
+
151
+ def upto(oth, &blk)
152
+ return @delegate.upto(oth, &blk) if oth.kind_of?(::Date) || oth.kind_of?(Numeric)
153
+ super
154
+ end
155
+
156
+ def between?(a, b)
157
+ return @delegate.between?(a, b) if [a, b].any? {|o| o.is_a?(::Date) || o.is_a?(Numeric) }
158
+ super
159
+ end
160
+
161
+ private
162
+
163
+ def respond_to_missing?(name, include_private = false)
164
+ @delegate.respond_to?(name, include_private)
165
+ end
166
+
167
+ def method_missing(method, *args, &block)
168
+ @delegate.send(method, *args, &block)
169
+ end
170
+ end
171
+
172
+ class StringDateTime < StringDate
173
+ DELEGATE_CLASS = ::DateTime
174
+
175
+ def is_a?(klass)
176
+ klass == ::DateTime || super
177
+ end
178
+ alias_method :kind_of?, :is_a?
179
+
180
+ def to_date
181
+ @delegate.to_date
182
+ end
183
+
184
+ def to_datetime
185
+ @delegate
186
+ end
187
+ end
188
+
189
+ module StringDateAwareness
190
+ def _parse(v, *_)
191
+ if v.is_a?(StringDateTime)
192
+ return { year: v.year, mon: v.month, mday: v.mday, hour: v.hour, min: v.min, sec: v.sec, sec_fraction: v.sec_fraction, offset: v.offset }
193
+ end
194
+ if v.is_a?(StringDate)
195
+ return { year: v.year, mon: v.month, mday: v.mday }
196
+ end
197
+ super
198
+ end
199
+
200
+ def parse(v, *_)
201
+ if v.is_a?(StringDate)
202
+ return self == ::DateTime ? v.to_datetime : v.to_date
203
+ end
204
+ super
205
+ end
206
+
207
+ # Overriding case equality method so that it returns true for
208
+ # `FmRest::StringDate` instances
209
+ #
210
+ # Calls superclass method
211
+ #
212
+ def ===(other)
213
+ super || other.is_a?(StringDate)
214
+ end
215
+
216
+ def self.enable(classes: [Date, DateTime])
217
+ classes.each { |klass| klass.singleton_class.prepend(self) }
218
+ end
219
+ end
220
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FmRest
4
+ module TokenStore
5
+ autoload :Base, "fmrest/token_store/base"
6
+ autoload :Memory, "fmrest/token_store/memory"
7
+ autoload :Null, "fmrest/token_store/null"
8
+ autoload :ActiveRecord, "fmrest/token_store/active_record"
9
+ autoload :Moneta, "fmrest/token_store/moneta"
10
+ autoload :Redis, "fmrest/token_store/redis"
11
+ end
12
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fmrest/token_store/base"
4
+ require "active_record"
5
+
6
+ module FmRest
7
+ module TokenStore
8
+ # Heavily inspired by Moneta's ActiveRecord store:
9
+ #
10
+ # https://github.com/minad/moneta/blob/master/lib/moneta/adapters/activerecord.rb
11
+ #
12
+ class ActiveRecord < Base
13
+ DEFAULT_TABLE_NAME = "fmrest_session_tokens".freeze
14
+
15
+ @connection_lock = ::Mutex.new
16
+ class << self
17
+ attr_reader :connection_lock
18
+ end
19
+
20
+ attr_reader :connection_pool, :model
21
+
22
+ delegate :with_connection, to: :connection_pool
23
+
24
+ def initialize(options = {})
25
+ super
26
+
27
+ @connection_pool = ::ActiveRecord::Base.connection_pool
28
+
29
+ create_table
30
+
31
+ @model = Class.new(::ActiveRecord::Base)
32
+ @model.table_name = table_name
33
+ end
34
+
35
+ def delete(key)
36
+ model.where(scope: key).delete_all
37
+ end
38
+
39
+ def load(key)
40
+ model.where(scope: key).pluck(:token).first
41
+ end
42
+
43
+ def store(key, value)
44
+ record = model.find_or_initialize_by(scope: key)
45
+ record.token = value
46
+ record.save!
47
+ value
48
+ end
49
+
50
+ private
51
+
52
+ def create_table
53
+ with_connection do |conn|
54
+ return if conn.table_exists?(table_name)
55
+
56
+ # Prevent multiple connections from attempting to create the table simultaneously.
57
+ self.class.connection_lock.synchronize do
58
+ conn.create_table(table_name, id: false) do |t|
59
+ t.string :scope, null: false
60
+ t.string :token, null: false
61
+ t.datetime :updated_at
62
+ end
63
+ conn.add_index(table_name, :scope, unique: true)
64
+ conn.add_index(table_name, [:scope, :token])
65
+ end
66
+ end
67
+ end
68
+
69
+ def table_name
70
+ options[:table_name] || DEFAULT_TABLE_NAME
71
+ end
72
+ end
73
+ end
74
+ end