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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +33 -0
- data/CHANGELOG.md +35 -1
- data/README.md +121 -18
- data/fmrest.gemspec +2 -2
- data/lib/fmrest.rb +10 -3
- data/lib/fmrest/connection_settings.rb +124 -0
- data/lib/fmrest/errors.rb +2 -0
- data/lib/fmrest/spyke/base.rb +2 -0
- data/lib/fmrest/spyke/model.rb +2 -0
- data/lib/fmrest/spyke/model/auth.rb +8 -0
- data/lib/fmrest/spyke/model/connection.rb +88 -18
- data/lib/fmrest/spyke/model/global_fields.rb +40 -0
- data/lib/fmrest/spyke/model/serialization.rb +24 -5
- data/lib/fmrest/string_date.rb +46 -7
- data/lib/fmrest/token_store.rb +6 -0
- data/lib/fmrest/token_store/base.rb +3 -3
- data/lib/fmrest/v1.rb +8 -4
- data/lib/fmrest/v1/auth.rb +30 -0
- data/lib/fmrest/v1/connection.rb +51 -25
- data/lib/fmrest/v1/dates.rb +81 -0
- data/lib/fmrest/v1/raise_errors.rb +3 -3
- data/lib/fmrest/v1/token_session.rb +41 -50
- data/lib/fmrest/v1/type_coercer.rb +111 -36
- data/lib/fmrest/v1/utils.rb +0 -17
- data/lib/fmrest/version.rb +1 -1
- metadata +18 -13
@@ -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"
|
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
|
19
|
-
def initialize(app,
|
17
|
+
# @param settings [FmRest::ConnectionSettings]
|
18
|
+
def initialize(app, settings)
|
20
19
|
super(app)
|
21
|
-
@
|
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
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
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
|
-
#
|
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
|
-
|
77
|
-
token_store.store(token_store_key, token)
|
78
|
-
return token
|
79
|
-
end
|
88
|
+
return nil unless @settings.autologin
|
80
89
|
|
81
|
-
|
82
|
-
|
90
|
+
token = V1.request_auth_token!(auth_connection)
|
91
|
+
token_store.store(token_store_key, token)
|
92
|
+
token
|
83
93
|
end
|
84
94
|
|
85
|
-
#
|
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 = @
|
101
|
+
host = @settings.host!
|
103
102
|
host = URI(host).hostname if host =~ /\Ahttps?:\/\//
|
104
|
-
"#{host}:#{@
|
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.
|
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
|
-
|
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
|
-
@
|
125
|
+
@settings.token_store || FmRest.token_store
|
124
126
|
end
|
125
127
|
|
126
128
|
def auth_connection
|
127
|
-
@auth_connection ||= V1.
|
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
|
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
|
16
|
-
def initialize(app,
|
16
|
+
# @param settings [FmRest::ConnectionSettings]
|
17
|
+
def initialize(app, settings)
|
17
18
|
super(app)
|
18
|
-
@
|
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
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
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
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
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
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
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
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
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
|
96
|
-
@date_format
|
97
|
-
|
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
|
101
|
-
|
102
|
-
|
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
|
106
|
-
@
|
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
|
-
@
|
186
|
+
@settings.coerce_dates
|
112
187
|
end
|
113
188
|
|
114
189
|
alias_method :enabled?, :coerce_dates
|