fmrest 0.8.0 → 0.11.1

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,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FmRest
4
+ module V1
5
+ module Dates
6
+ FM_DATETIME_FORMAT_MATCHER = /MM|mm|dd|HH|ss|yyyy/.freeze
7
+
8
+ FM_DATE_TO_STRPTIME_SUBSTITUTIONS = {
9
+ "MM" => "%m",
10
+ "dd" => "%d",
11
+ "yyyy" => "%Y",
12
+ "HH" => "%H",
13
+ "mm" => "%M",
14
+ "ss" => "%S"
15
+ }.freeze
16
+
17
+ FM_DATE_TO_REGEXP_SUBSTITUTIONS = {
18
+ "MM" => '(?:0[1-9]|1[012])',
19
+ "dd" => '(?:0[1-9]|[12][0-9]|3[01])',
20
+ "yyyy" => '\d{4}',
21
+ "HH" => '(?:[01]\d|2[0123])',
22
+ "mm" => '[0-5]\d',
23
+ "ss" => '[0-5]\d'
24
+ }.freeze
25
+
26
+ def self.extended(mod)
27
+ mod.instance_eval do
28
+ @date_strptime = {}
29
+ @date_regexp = {}
30
+ end
31
+ end
32
+
33
+ # Converts a FM date-time format to `DateTime.strptime` format
34
+ #
35
+ # @param fm_format [String] The FileMaker date-time format
36
+ # @return [String] The `DateTime.strpdate` equivalent of the given FM
37
+ # date-time format
38
+ def fm_date_to_strptime_format(fm_format)
39
+ @date_strptime[fm_format] ||=
40
+ fm_format.gsub(FM_DATETIME_FORMAT_MATCHER, FM_DATE_TO_STRPTIME_SUBSTITUTIONS).freeze
41
+ end
42
+
43
+ # Converts a FM date-time format to a Regexp. This is mostly used a
44
+ # quicker way of checking whether a FM field is a date field than
45
+ # Date|DateTime.strptime
46
+ #
47
+ # @param fm_format [String] The FileMaker date-time format
48
+ # @return [Regexp] A reegular expression matching strings in the given FM
49
+ # date-time format
50
+ def fm_date_to_regexp(fm_format)
51
+ @date_regexp[fm_format] ||=
52
+ Regexp.new('\A' + fm_format.gsub(FM_DATETIME_FORMAT_MATCHER, FM_DATE_TO_REGEXP_SUBSTITUTIONS) + '\Z').freeze
53
+ end
54
+
55
+ # Takes a DateTime dt, and returns the correct local offset for that dt,
56
+ # daylight savings included, in fraction of a day.
57
+ #
58
+ # By default, if ActiveSupport's Time.zone is set it will be used instead
59
+ # of the system timezone.
60
+ #
61
+ # @param dt [DateTime] The DateTime to get the offset for
62
+ # @param zone [nil, String, TimeZone] The timezone to use to calculate
63
+ # the offset (defaults to system timezone, or ActiveSupport's Time.zone
64
+ # if set)
65
+ # @return [Rational] The offset in fraction of a day
66
+ def local_offset_for_datetime(dt, zone = nil)
67
+ dt = dt.new_offset(0)
68
+ time = ::Time.utc(dt.year, dt.month, dt.day, dt.hour, dt.min, dt.sec)
69
+
70
+ # Do we have ActiveSupport's TimeZone?
71
+ time = if time.respond_to?(:in_time_zone)
72
+ time.in_time_zone(zone || ::Time.zone)
73
+ else
74
+ time.localtime
75
+ end
76
+
77
+ Rational(time.utc_offset, 86400) # seconds in one day (24*60*60)
78
+ end
79
+ end
80
+ end
81
+ end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "fmrest/errors"
4
-
5
3
  module FmRest
6
4
  module V1
7
5
  # FM Data API response middleware for raising exceptions on API response
@@ -23,9 +21,11 @@ module FmRest
23
21
  402..499 => APIError::ParameterError,
24
22
  500..599 => APIError::ValidationError,
25
23
  800..899 => APIError::SystemError,
24
+ 952 => APIError::InvalidToken,
25
+ 953 => APIError::MaximumDataAPICallsExceeded,
26
26
  1200..1299 => APIError::ScriptError,
27
27
  1400..1499 => APIError::ODBCError
28
- }
28
+ }.freeze
29
29
 
30
30
  def on_complete(env)
31
31
  # Sniff for either straight JSON parsing or Spyke's format
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fmrest/v1/connection"
4
- require "fmrest/errors"
5
4
 
6
5
  module FmRest
7
6
  module V1
@@ -10,15 +9,15 @@ module FmRest
10
9
  class TokenSession < Faraday::Middleware
11
10
  class NoSessionTokenSet < FmRest::Error; end
12
11
 
13
- HEADER_KEY = "Authorization".freeze
12
+ HEADER_KEY = "Authorization"
14
13
  TOKEN_STORE_INTERFACE = [:load, :store, :delete].freeze
15
14
  LOGOUT_PATH_MATCHER = %r{\A(#{FmRest::V1::Connection::BASE_PATH}/[^/]+/sessions/)[^/]+\Z}.freeze
16
15
 
17
16
  # @param app [#call]
18
- # @param options [Hash]
19
- def initialize(app, options = FmRest.default_connection_settings)
17
+ # @param settings [FmRest::ConnectionSettings]
18
+ def initialize(app, settings)
20
19
  super(app)
21
- @options = options
20
+ @settings = settings
22
21
  end
23
22
 
24
23
  # Entry point for the middleware when sending a request
@@ -32,27 +31,37 @@ module FmRest
32
31
 
33
32
  @app.call(env).on_complete do |response_env|
34
33
  if response_env[:status] == 401 # Unauthorized
35
- env[:body] = request_body
36
- token_store.delete(token_store_key)
37
- set_auth_header(env)
38
- return @app.call(env)
34
+ delete_token_store_key
35
+
36
+ if @settings.autologin
37
+ env[:body] = request_body
38
+ set_auth_header(env)
39
+ return @app.call(env)
40
+ end
39
41
  end
40
42
  end
41
43
  end
42
44
 
43
45
  private
44
46
 
47
+ def delete_token_store_key
48
+ token_store.delete(token_store_key)
49
+ # Sometimes we may want to pass the :token in settings manually, and
50
+ # refrain from passing a :username. In that case the call to
51
+ # #token_store_key above would fail as it tries to fetch :username, so
52
+ # we purposely ignore that error.
53
+ rescue FmRest::ConnectionSettings::MissingSetting
54
+ end
55
+
45
56
  def handle_logout(env)
46
- token = token_store.load(token_store_key)
57
+ token = @settings.token? ? @settings.token : token_store.load(token_store_key)
47
58
 
48
59
  raise NoSessionTokenSet, "Couldn't send logout request because no session token was set" unless token
49
60
 
50
61
  env.url.path = env.url.path.gsub(LOGOUT_PATH_MATCHER, "\\1#{token}")
51
62
 
52
63
  @app.call(env).on_complete do |response_env|
53
- if response_env[:status] == 200
54
- token_store.delete(token_store_key)
55
- end
64
+ delete_token_store_key if response_env[:status] == 200
56
65
  end
57
66
  end
58
67
 
@@ -65,43 +74,33 @@ module FmRest
65
74
  env.request_headers[HEADER_KEY] = "Bearer #{token}"
66
75
  end
67
76
 
68
- # Tries to get an existing token from the token store,
77
+ # Uses the token given in connection settings if available,
78
+ # otherwisek tries to get an existing token from the token store,
69
79
  # otherwise requests one through basic auth,
70
80
  # otherwise raises an exception.
71
81
  #
72
82
  def token
83
+ return @settings.token if @settings.token?
84
+
73
85
  token = token_store.load(token_store_key)
74
86
  return token if token
75
87
 
76
- if token = request_token
77
- token_store.store(token_store_key, token)
78
- return token
79
- end
88
+ return nil unless @settings.autologin
80
89
 
81
- # TODO: Make this a custom exception class
82
- raise "Filemaker auth failed"
90
+ token = V1.request_auth_token!(auth_connection)
91
+ token_store.store(token_store_key, token)
92
+ token
83
93
  end
84
94
 
85
- # Requests a token through basic auth
86
- #
87
- def request_token
88
- resp = auth_connection.post do |req|
89
- req.url V1.session_path
90
- req.headers["Content-Type"] = "application/json"
91
- end
92
- return resp.body["response"]["token"] if resp.success?
93
- false
94
- end
95
-
96
- # The key to use to store a token, uses the format host:database
95
+ # The key to use to store a token, uses the format host:database:username
97
96
  #
98
97
  def token_store_key
99
98
  @token_store_key ||=
100
99
  begin
101
100
  # Strip the host part to just the hostname (i.e. no scheme or port)
102
- host = @options.fetch(:host)
101
+ host = @settings.host!
103
102
  host = URI(host).hostname if host =~ /\Ahttps?:\/\//
104
- "#{host}:#{@options.fetch(:database)}"
103
+ "#{host}:#{@settings.database!}:#{@settings.username!}"
105
104
  end
106
105
  end
107
106
 
@@ -111,31 +110,23 @@ module FmRest
111
110
  if TOKEN_STORE_INTERFACE.all? { |method| token_store_option.respond_to?(method) }
112
111
  token_store_option
113
112
  elsif token_store_option.kind_of?(Class)
114
- token_store_option.new
113
+ if token_store_option.respond_to?(:instance)
114
+ token_store_option.instance
115
+ else
116
+ token_store_option.new
117
+ end
115
118
  else
116
- require "fmrest/token_store/memory"
117
- TokenStore::Memory.new
119
+ FmRest::TokenStore::Memory.new
118
120
  end
119
121
  end
120
122
  end
121
123
 
122
124
  def token_store_option
123
- @options[:token_store] || FmRest.token_store
125
+ @settings.token_store || FmRest.token_store
124
126
  end
125
127
 
126
128
  def auth_connection
127
- @auth_connection ||= V1.base_connection(@options) do |conn|
128
- username = @options.fetch(:account_name) { @options.fetch(:username) }
129
-
130
- conn.basic_auth username, @options.fetch(:password)
131
-
132
- if @options[:log]
133
- conn.response :logger, nil, bodies: true, headers: true
134
- end
135
-
136
- conn.response :json
137
- conn.adapter Faraday.default_adapter
138
- end
129
+ @auth_connection ||= V1.auth_connection(@settings)
139
130
  end
140
131
  end
141
132
  end
@@ -5,17 +5,18 @@ require "fmrest/string_date"
5
5
  module FmRest
6
6
  module V1
7
7
  class TypeCoercer < Faraday::Response::Middleware
8
- # We use this date to represent a time for consistency with ginjo-rfm
8
+ # We use this date to represent a FileMaker time for consistency with
9
+ # ginjo-rfm
9
10
  JULIAN_ZERO_DAY = "-4712/1/1"
10
11
 
11
12
  COERCE_HYBRID = [:hybrid, "hybrid", true].freeze
12
13
  COERCE_FULL = [:full, "full"].freeze
13
14
 
14
15
  # @param app [#call]
15
- # @param options [Hash]
16
- def initialize(app, options = FmRest.default_connection_settings)
16
+ # @param settings [FmRest::ConnectionSettings]
17
+ def initialize(app, settings)
17
18
  super(app)
18
- @options = options
19
+ @settings = settings
19
20
  end
20
21
 
21
22
  def on_complete(env)
@@ -30,15 +31,13 @@ module FmRest
30
31
  field_data = record["fieldData"] || record[:fieldData]
31
32
  portal_data = record["portalData"] || record[:portalData]
32
33
 
33
- # Build an enumerator that iterates over hashes of fields
34
- enum = Enumerator.new { |y| y << field_data }
35
- if portal_data
36
- portal_data.each_value do |portal_records|
37
- enum += portal_records.to_enum
34
+ coerce_fields(field_data)
35
+
36
+ portal_data.try(:each_value) do |portal_records|
37
+ portal_records.each do |pr|
38
+ coerce_fields(pr)
38
39
  end
39
40
  end
40
-
41
- enum.each { |hash| coerce_fields(hash) }
42
41
  end
43
42
  end
44
43
 
@@ -49,29 +48,49 @@ module FmRest
49
48
  next unless v.is_a?(String)
50
49
  next if k == "recordId" || k == :recordId || k == "modId" || k == :modId
51
50
 
52
- begin
53
- str_timestamp = datetime_class.strptime(v, datetime_format)
54
- hash[k] = str_timestamp
55
- next
56
- rescue ArgumentError
51
+ if quick_check_timestamp(v)
52
+ begin
53
+ hash[k] = coerce_timestamp(v)
54
+ next
55
+ rescue ArgumentError
56
+ end
57
57
  end
58
58
 
59
- begin
60
- str_date = date_class.strptime(v, date_format)
61
- hash[k] = str_date
62
- next
63
- rescue ArgumentError
59
+ if quick_check_date(v)
60
+ begin
61
+ hash[k] = date_class.strptime(v, date_strptime_format)
62
+ next
63
+ rescue ArgumentError
64
+ end
64
65
  end
65
66
 
66
- begin
67
- str_time = datetime_class.strptime("#{JULIAN_ZERO_DAY} #{v}", time_format)
68
- hash[k] = str_time
69
- next
70
- rescue ArgumentError
67
+ if quick_check_time(v)
68
+ begin
69
+ hash[k] = datetime_class.strptime("#{JULIAN_ZERO_DAY} #{v}", time_strptime_format)
70
+ next
71
+ rescue ArgumentError
72
+ end
71
73
  end
72
74
  end
73
75
  end
74
76
 
77
+ def coerce_timestamp(str)
78
+ str_timestamp = DateTime.strptime(str, datetime_strptime_format)
79
+
80
+ if local_timezone?
81
+ # Change the DateTime to the local timezone, keeping the same
82
+ # time and just modifying the timezone
83
+ offset = FmRest::V1.local_offset_for_datetime(str_timestamp)
84
+ str_timestamp = str_timestamp.new_offset(offset) - offset
85
+ end
86
+
87
+ if datetime_class == StringDateTime
88
+ str_timestamp = StringDateTime.new(str, str_timestamp)
89
+ end
90
+
91
+ str_timestamp
92
+ end
93
+
75
94
  def date_class
76
95
  @date_class ||=
77
96
  case coerce_dates
@@ -92,23 +111,79 @@ module FmRest
92
111
  end
93
112
  end
94
113
 
95
- def date_format
96
- @date_format ||=
97
- FmRest::V1.convert_date_time_format(@options[:date_format] || DEFAULT_DATE_FORMAT)
114
+ def date_fm_format
115
+ @settings.date_format
116
+ end
117
+
118
+ def timestamp_fm_format
119
+ @settings.timestamp_format
120
+ end
121
+
122
+ def time_fm_format
123
+ @settings.time_format
98
124
  end
99
125
 
100
- def datetime_format
101
- @datetime_format ||=
102
- FmRest::V1.convert_date_time_format(@options[:timestamp_format] || DEFAULT_TIMESTAMP_FORMAT)
126
+ def date_strptime_format
127
+ FmRest::V1.fm_date_to_strptime_format(date_fm_format)
128
+ end
129
+
130
+ def datetime_strptime_format
131
+ FmRest::V1.fm_date_to_strptime_format(timestamp_fm_format)
132
+ end
133
+
134
+ def time_strptime_format
135
+ @time_strptime_format ||=
136
+ "%Y/%m/%d " + FmRest::V1.fm_date_to_strptime_format(time_fm_format)
137
+ end
138
+
139
+ # We use a string length test, followed by regexp match test to try to
140
+ # identify date fields. Benchmarking shows this should be between 1 and 3
141
+ # orders of magnitude faster for fails (i.e. non-dates) than just using
142
+ # Date.strptime.
143
+ #
144
+ # user system total real
145
+ # strptime: 0.268496 0.000962 0.269458 ( 0.270865)
146
+ # re=~: 0.024872 0.000070 0.024942 ( 0.025057)
147
+ # re.match?: 0.019745 0.000095 0.019840 ( 0.020058)
148
+ # strptime fail: 0.141309 0.000354 0.141663 ( 0.142266)
149
+ # re=~ fail: 0.031637 0.000095 0.031732 ( 0.031872)
150
+ # re.match? fail: 0.011249 0.000056 0.011305 ( 0.011375)
151
+ # length fail: 0.007177 0.000024 0.007201 ( 0.007222)
152
+ #
153
+ # NOTE: The faster Regexp#match? was introduced in Ruby 2.4.0, so we
154
+ # can't really rely on it being available
155
+ if //.respond_to?(:match?)
156
+ def quick_check_timestamp(v)
157
+ v.length == timestamp_fm_format.length && FmRest::V1::fm_date_to_regexp(timestamp_fm_format).match?(v)
158
+ end
159
+
160
+ def quick_check_date(v)
161
+ v.length == date_fm_format.length && FmRest::V1::fm_date_to_regexp(date_fm_format).match?(v)
162
+ end
163
+
164
+ def quick_check_time(v)
165
+ v.length == time_fm_format.length && FmRest::V1::fm_date_to_regexp(time_fm_format).match?(v)
166
+ end
167
+ else
168
+ def quick_check_timestamp(v)
169
+ v.length == timestamp_fm_format.length && FmRest::V1::fm_date_to_regexp(timestamp_fm_format) =~ v
170
+ end
171
+
172
+ def quick_check_date(v)
173
+ v.length == date_fm_format.length && FmRest::V1::fm_date_to_regexp(date_fm_format) =~ v
174
+ end
175
+
176
+ def quick_check_time(v)
177
+ v.length == time_fm_format.length && FmRest::V1::fm_date_to_regexp(time_fm_format) =~ v
178
+ end
103
179
  end
104
180
 
105
- def time_format
106
- @time_format ||=
107
- "%Y/%m/%d " + FmRest::V1.convert_date_time_format(@options[:time_format] || DEFAULT_TIME_FORMAT)
181
+ def local_timezone?
182
+ @local_timezone ||= @settings.timezone.try(:to_sym) == :local
108
183
  end
109
184
 
110
185
  def coerce_dates
111
- @options.fetch(:coerce_dates, false)
186
+ @settings.coerce_dates
112
187
  end
113
188
 
114
189
  alias_method :enabled?, :coerce_dates