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,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