fmrest-core 0.13.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.
@@ -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