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,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
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FmRest
4
+ module V1
5
+ module Paths
6
+ def session_path(token = nil)
7
+ url = "sessions"
8
+ url += "/#{token}" if token
9
+ url
10
+ end
11
+
12
+ def record_path(layout, id = nil)
13
+ url = "layouts/#{V1.url_encode(layout)}/records"
14
+ url += "/#{id}" if id
15
+ url
16
+ end
17
+
18
+ def container_field_path(layout, id, field_name, field_repetition = 1)
19
+ url = record_path(layout, id)
20
+ url += "/containers/#{V1.url_encode(field_name)}"
21
+ url += "/#{field_repetition}" if field_repetition
22
+ url
23
+ end
24
+
25
+ def find_path(layout)
26
+ "layouts/#{V1.url_encode(layout)}/_find"
27
+ end
28
+
29
+ def script_path(layout, script)
30
+ "layouts/#{V1.url_encode(layout)}/script/#{V1.url_encode(script)}"
31
+ end
32
+
33
+ def globals_path
34
+ "globals"
35
+ end
36
+
37
+ def product_info_path
38
+ "#{V1::Connection::BASE_PATH}/productInfo"
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FmRest
4
+ module V1
5
+ # FM Data API response middleware for raising exceptions on API response
6
+ # errors
7
+ #
8
+ # https://fmhelp.filemaker.com/help/17/fmp/en/index.html#page/FMP_Help/error-codes.html
9
+ #
10
+ class RaiseErrors < Faraday::Response::Middleware
11
+ # https://fmhelp.filemaker.com/help/17/fmp/en/index.html#page/FMP_Help/error-codes.html
12
+ ERROR_RANGES = {
13
+ -1 => APIError::UnknownError,
14
+ 100 => APIError::ResourceMissingError,
15
+ 101 => APIError::RecordMissingError,
16
+ 102..199 => APIError::ResourceMissingError,
17
+ 200..299 => APIError::AccountError,
18
+ 300..399 => APIError::LockError,
19
+ 400 => APIError::ParameterError,
20
+ 401 => APIError::NoMatchingRecordsError,
21
+ 402..499 => APIError::ParameterError,
22
+ 500..599 => APIError::ValidationError,
23
+ 800..899 => APIError::SystemError,
24
+ 952 => APIError::InvalidToken,
25
+ 953 => APIError::MaximumDataAPICallsExceeded,
26
+ 1200..1299 => APIError::ScriptError,
27
+ 1400..1499 => APIError::ODBCError
28
+ }.freeze
29
+
30
+ def on_complete(env)
31
+ # Sniff for either straight JSON parsing or Spyke's format
32
+ if env.body[:metadata] && env.body[:metadata][:messages]
33
+ check_errors(env.body[:metadata][:messages])
34
+ elsif env.body["messages"]
35
+ check_errors(env.body["messages"])
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def check_errors(messages)
42
+ messages.each do |message|
43
+ error_code = (message["code"] || message[:code]).to_i
44
+
45
+ # Code 0 means "No Error"
46
+ next if error_code.zero?
47
+
48
+ error_message = message["message"] || message[:message]
49
+
50
+ *, exception_class = ERROR_RANGES.find { |k, v| k === error_code }
51
+
52
+ raise (exception_class || APIError).new(error_code, error_message)
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fmrest/v1/connection"
4
+
5
+ module FmRest
6
+ module V1
7
+ # FM Data API authentication middleware using the credentials strategy
8
+ #
9
+ class TokenSession < Faraday::Middleware
10
+ class NoSessionTokenSet < FmRest::Error; end
11
+
12
+ HEADER_KEY = "Authorization"
13
+ TOKEN_STORE_INTERFACE = [:load, :store, :delete].freeze
14
+ LOGOUT_PATH_MATCHER = %r{\A(#{FmRest::V1::Connection::DATABASES_PATH}/[^/]+/sessions/)[^/]+\Z}.freeze
15
+
16
+ # @param app [#call]
17
+ # @param settings [FmRest::ConnectionSettings]
18
+ def initialize(app, settings)
19
+ super(app)
20
+ @settings = settings
21
+ end
22
+
23
+ # Entry point for the middleware when sending a request
24
+ #
25
+ def call(env)
26
+ return handle_logout(env) if is_logout_request?(env)
27
+
28
+ set_auth_header(env)
29
+
30
+ request_body = env[:body] # After failure env[:body] is set to the response body
31
+
32
+ @app.call(env).on_complete do |response_env|
33
+ if response_env[:status] == 401 # Unauthorized
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
41
+ end
42
+ end
43
+ end
44
+
45
+ private
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
+
56
+ def handle_logout(env)
57
+ token = @settings.token? ? @settings.token : token_store.load(token_store_key)
58
+
59
+ raise NoSessionTokenSet, "Couldn't send logout request because no session token was set" unless token
60
+
61
+ env.url.path = env.url.path.gsub(LOGOUT_PATH_MATCHER, "\\1#{token}")
62
+
63
+ @app.call(env).on_complete do |response_env|
64
+ delete_token_store_key if response_env[:status] == 200
65
+ end
66
+ end
67
+
68
+ def is_logout_request?(env)
69
+ return false unless env.method == :delete
70
+ return env.url.path.match?(LOGOUT_PATH_MATCHER)
71
+ end
72
+
73
+ def set_auth_header(env)
74
+ env.request_headers[HEADER_KEY] = "Bearer #{token}"
75
+ end
76
+
77
+ # Uses the token given in connection settings if available,
78
+ # otherwisek tries to get an existing token from the token store,
79
+ # otherwise requests one through basic auth,
80
+ # otherwise raises an exception.
81
+ #
82
+ def token
83
+ return @settings.token if @settings.token?
84
+
85
+ token = token_store.load(token_store_key)
86
+ return token if token
87
+
88
+ return nil unless @settings.autologin
89
+
90
+ token = V1.request_auth_token!(auth_connection)
91
+ token_store.store(token_store_key, token)
92
+ token
93
+ end
94
+
95
+ # The key to use to store a token, uses the format host:database:username
96
+ #
97
+ def token_store_key
98
+ @token_store_key ||=
99
+ begin
100
+ # Strip the host part to just the hostname (i.e. no scheme or port)
101
+ host = @settings.host!
102
+ host = URI(host).hostname if host =~ /\Ahttps?:\/\//
103
+ "#{host}:#{@settings.database!}:#{@settings.username!}"
104
+ end
105
+ end
106
+
107
+ def token_store
108
+ @token_store ||=
109
+ begin
110
+ if TOKEN_STORE_INTERFACE.all? { |method| token_store_option.respond_to?(method) }
111
+ token_store_option
112
+ elsif token_store_option.kind_of?(Class)
113
+ if token_store_option.respond_to?(:instance)
114
+ token_store_option.instance
115
+ else
116
+ token_store_option.new
117
+ end
118
+ else
119
+ FmRest::TokenStore::Memory.new
120
+ end
121
+ end
122
+ end
123
+
124
+ def token_store_option
125
+ @settings.token_store || FmRest.token_store
126
+ end
127
+
128
+ def auth_connection
129
+ @auth_connection ||= V1.auth_connection(@settings)
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ warn "FmRest::V1::TokenStore::ActiveRecord is deprecated, use FmRest::TokenStore::ActiveRecord instead"
4
+
5
+ require "fmrest/token_store/active_record"
6
+
7
+ module FmRest
8
+ module V1
9
+ module TokenStore
10
+ ActiveRecord = ::FmRest::TokenStore::ActiveRecord
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ warn "FmRest::V1::TokenStore::Memory is deprecated, use FmRest::TokenStore::Memory instead"
4
+
5
+ require "fmrest/token_store/memory"
6
+
7
+ module FmRest
8
+ module V1
9
+ module TokenStore
10
+ Memory = ::FmRest::TokenStore::Memory
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fmrest/string_date"
4
+
5
+ module FmRest
6
+ module V1
7
+ class TypeCoercer < Faraday::Response::Middleware
8
+ # We use this date to represent a FileMaker time for consistency with
9
+ # ginjo-rfm
10
+ JULIAN_ZERO_DAY = "-4712/1/1"
11
+
12
+ COERCE_HYBRID = [:hybrid, "hybrid", true].freeze
13
+ COERCE_FULL = [:full, "full"].freeze
14
+
15
+ # @param app [#call]
16
+ # @param settings [FmRest::ConnectionSettings]
17
+ def initialize(app, settings)
18
+ super(app)
19
+ @settings = settings
20
+ end
21
+
22
+ def on_complete(env)
23
+ return unless enabled?
24
+ return unless env.body.kind_of?(Hash)
25
+
26
+ data = env.body.dig("response", "data") || env.body.dig(:response, :data)
27
+
28
+ return unless data
29
+
30
+ data.each do |record|
31
+ field_data = record["fieldData"] || record[:fieldData]
32
+ portal_data = record["portalData"] || record[:portalData]
33
+
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)
39
+ end
40
+ end
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def coerce_fields(hash)
47
+ hash.each do |k, v|
48
+ next unless v.is_a?(String)
49
+ next if k == "recordId" || k == :recordId || k == "modId" || k == :modId
50
+
51
+ if quick_check_timestamp(v)
52
+ begin
53
+ hash[k] = coerce_timestamp(v)
54
+ next
55
+ rescue ArgumentError
56
+ end
57
+ end
58
+
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
65
+ end
66
+
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
73
+ end
74
+ end
75
+ end
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
+
94
+ def date_class
95
+ @date_class ||=
96
+ case coerce_dates
97
+ when *COERCE_HYBRID
98
+ StringDate
99
+ when *COERCE_FULL
100
+ Date
101
+ end
102
+ end
103
+
104
+ def datetime_class
105
+ @datetime_class ||=
106
+ case coerce_dates
107
+ when *COERCE_HYBRID
108
+ StringDateTime
109
+ when *COERCE_FULL
110
+ DateTime
111
+ end
112
+ end
113
+
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
124
+ end
125
+
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
179
+ end
180
+
181
+ def local_timezone?
182
+ @local_timezone ||= @settings.timezone.try(:to_sym) == :local
183
+ end
184
+
185
+ def coerce_dates
186
+ @settings.coerce_dates
187
+ end
188
+
189
+ alias_method :enabled?, :coerce_dates
190
+ end
191
+ end
192
+ end