fmrest 0.7.1 → 0.11.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +33 -0
- data/CHANGELOG.md +37 -0
- data/README.md +176 -26
- data/fmrest.gemspec +2 -2
- data/lib/fmrest.rb +8 -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/orm.rb +2 -1
- data/lib/fmrest/spyke/model/serialization.rb +16 -5
- data/lib/fmrest/spyke/relation.rb +73 -0
- data/lib/fmrest/spyke/spyke_formatter.rb +46 -9
- 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 +54 -28
- data/lib/fmrest/v1/dates.rb +81 -0
- data/lib/fmrest/v1/raise_errors.rb +3 -1
- data/lib/fmrest/v1/token_session.rb +41 -49
- 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
data/lib/fmrest/errors.rb
CHANGED
@@ -21,6 +21,8 @@ module FmRest
|
|
21
21
|
class APIError::NoMatchingRecordsError < APIError::ParameterError; end
|
22
22
|
class APIError::ValidationError < APIError; end # error codes 500..599
|
23
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
|
24
26
|
class APIError::ScriptError < APIError; end # error codes 1200..1299
|
25
27
|
class APIError::ODBCError < APIError; end # error codes 1400..1499
|
26
28
|
|
data/lib/fmrest/spyke/base.rb
CHANGED
@@ -8,6 +8,8 @@ module FmRest
|
|
8
8
|
|
9
9
|
class << self
|
10
10
|
def Base(config = nil)
|
11
|
+
warn "[DEPRECATION] Inheriting from `FmRest::Spyke::Base(config)` is deprecated and will be removed, inherit from `FmRest::Spyke::Base` (without arguments) and use `fmrest_config=` instead"
|
12
|
+
|
11
13
|
if config
|
12
14
|
return Class.new(::FmRest::Spyke::Base) do
|
13
15
|
self.fmrest_config = config
|
data/lib/fmrest/spyke/model.rb
CHANGED
@@ -7,6 +7,7 @@ require "fmrest/spyke/model/serialization"
|
|
7
7
|
require "fmrest/spyke/model/associations"
|
8
8
|
require "fmrest/spyke/model/orm"
|
9
9
|
require "fmrest/spyke/model/container_fields"
|
10
|
+
require "fmrest/spyke/model/global_fields"
|
10
11
|
require "fmrest/spyke/model/http"
|
11
12
|
require "fmrest/spyke/model/auth"
|
12
13
|
|
@@ -22,6 +23,7 @@ module FmRest
|
|
22
23
|
include Associations
|
23
24
|
include Orm
|
24
25
|
include ContainerFields
|
26
|
+
include GlobalFields
|
25
27
|
include Http
|
26
28
|
include Auth
|
27
29
|
|
@@ -28,6 +28,14 @@ module FmRest
|
|
28
28
|
rescue FmRest::V1::TokenSession::NoSessionTokenSet
|
29
29
|
false
|
30
30
|
end
|
31
|
+
|
32
|
+
def request_auth_token
|
33
|
+
FmRest::V1.request_auth_token(FmRest::V1.auth_connection(fmrest_config))
|
34
|
+
end
|
35
|
+
|
36
|
+
def request_auth_token!
|
37
|
+
FmRest::V1.request_auth_token!(FmRest::V1.auth_connection(fmrest_config))
|
38
|
+
end
|
31
39
|
end
|
32
40
|
end
|
33
41
|
end
|
@@ -4,19 +4,70 @@ module FmRest
|
|
4
4
|
module Spyke
|
5
5
|
module Model
|
6
6
|
module Connection
|
7
|
-
extend
|
7
|
+
extend ActiveSupport::Concern
|
8
8
|
|
9
9
|
included do
|
10
|
-
class_attribute :fmrest_config, instance_accessor: false, instance_predicate: false
|
11
|
-
|
12
10
|
class_attribute :faraday_block, instance_accessor: false, instance_predicate: false
|
13
11
|
class << self; private :faraday_block, :faraday_block=; end
|
14
12
|
|
15
|
-
# FM Data API expects PATCH for updates (Spyke's default
|
13
|
+
# FM Data API expects PATCH for updates (Spyke's default is PUT)
|
16
14
|
self.callback_methods = { create: :post, update: :patch }.freeze
|
17
15
|
end
|
18
16
|
|
19
17
|
class_methods do
|
18
|
+
def fmrest_config
|
19
|
+
if fmrest_config_overlay
|
20
|
+
return FmRest.default_connection_settings.merge(fmrest_config_overlay, skip_validation: true)
|
21
|
+
end
|
22
|
+
|
23
|
+
FmRest.default_connection_settings
|
24
|
+
end
|
25
|
+
|
26
|
+
# Behaves similar to ActiveSupport's class_attribute, redefining the
|
27
|
+
# reader method so it can be inherited and overwritten in subclasses
|
28
|
+
#
|
29
|
+
def fmrest_config=(settings)
|
30
|
+
settings = ConnectionSettings.new(settings, skip_validation: true)
|
31
|
+
|
32
|
+
redefine_singleton_method(:fmrest_config) do
|
33
|
+
overlay = fmrest_config_overlay
|
34
|
+
return settings.merge(overlay, skip_validation: true) if overlay
|
35
|
+
settings
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
# Allows overwriting some connection settings in a thread-local
|
40
|
+
# manner. Useful in the use case where you want to connect to the
|
41
|
+
# same database using different accounts (e.g. credentials provided
|
42
|
+
# by users in a web app context)
|
43
|
+
#
|
44
|
+
def fmrest_config_overlay=(settings)
|
45
|
+
Thread.current[fmrest_config_overlay_key] = settings
|
46
|
+
end
|
47
|
+
|
48
|
+
def fmrest_config_overlay
|
49
|
+
Thread.current[fmrest_config_overlay_key] || begin
|
50
|
+
superclass.fmrest_config_overlay
|
51
|
+
rescue NoMethodError
|
52
|
+
nil
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def clear_fmrest_config_overlay
|
57
|
+
Thread.current[fmrest_config_overlay_key] = nil
|
58
|
+
end
|
59
|
+
|
60
|
+
def with_overlay(settings, &block)
|
61
|
+
Fiber.new do
|
62
|
+
begin
|
63
|
+
self.fmrest_config_overlay = settings
|
64
|
+
yield
|
65
|
+
ensure
|
66
|
+
self.clear_fmrest_config_overlay
|
67
|
+
end
|
68
|
+
end.resume
|
69
|
+
end
|
70
|
+
|
20
71
|
def connection
|
21
72
|
super || fmrest_connection
|
22
73
|
end
|
@@ -38,26 +89,45 @@ module FmRest
|
|
38
89
|
private
|
39
90
|
|
40
91
|
def fmrest_connection
|
41
|
-
|
42
|
-
|
43
|
-
|
92
|
+
memoize = false
|
93
|
+
|
94
|
+
# Don't memoize the connection if there's an overlay, since
|
95
|
+
# overlays are thread-local and so should be the connection
|
96
|
+
unless fmrest_config_overlay
|
97
|
+
return @fmrest_connection if @fmrest_connection
|
98
|
+
memoize = true
|
99
|
+
end
|
44
100
|
|
45
|
-
|
46
|
-
faraday_block.call(conn) if faraday_block
|
101
|
+
config = ConnectionSettings.wrap(fmrest_config)
|
47
102
|
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
# and need to be specified as options to `portal`
|
52
|
-
conn.use FmRest::Spyke::SpykeFormatter, self
|
103
|
+
connection =
|
104
|
+
FmRest::V1.build_connection(config) do |conn|
|
105
|
+
faraday_block.call(conn) if faraday_block
|
53
106
|
|
54
|
-
|
107
|
+
# Pass the class to SpykeFormatter's initializer so it can have
|
108
|
+
# access to extra context defined in the model, e.g. a portal
|
109
|
+
# where name of the portal and the attributes prefix don't match
|
110
|
+
# and need to be specified as options to `portal`
|
111
|
+
conn.use FmRest::Spyke::SpykeFormatter, self
|
55
112
|
|
56
|
-
|
57
|
-
|
58
|
-
|
113
|
+
conn.use FmRest::V1::TypeCoercer, config
|
114
|
+
|
115
|
+
# FmRest::Spyke::JsonParse expects symbol keys
|
116
|
+
conn.response :json, parser_options: { symbolize_names: true }
|
59
117
|
end
|
118
|
+
|
119
|
+
@fmrest_connection = connection if memoize
|
120
|
+
|
121
|
+
connection
|
60
122
|
end
|
123
|
+
|
124
|
+
def fmrest_config_overlay_key
|
125
|
+
:"#{object_id}.fmrest_config_overlay"
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
def fmrest_config
|
130
|
+
self.class.fmrest_config
|
61
131
|
end
|
62
132
|
end
|
63
133
|
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module FmRest
|
4
|
+
module Spyke
|
5
|
+
module Model
|
6
|
+
module GlobalFields
|
7
|
+
extend ::ActiveSupport::Concern
|
8
|
+
|
9
|
+
FULLY_QUALIFIED_FIELD_NAME_MATCHER = /\A[^:]+::[^:]+\Z/.freeze
|
10
|
+
|
11
|
+
class_methods do
|
12
|
+
def set_globals(values_hash)
|
13
|
+
connection.patch(FmRest::V1.globals_path, {
|
14
|
+
globalFields: normalize_globals_hash(values_hash)
|
15
|
+
})
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
def normalize_globals_hash(hash)
|
21
|
+
hash.each_with_object({}) do |(k, v), normalized|
|
22
|
+
if v.kind_of?(Hash)
|
23
|
+
v.each do |k2, v2|
|
24
|
+
normalized["#{k}::#{k2}"] = v2
|
25
|
+
end
|
26
|
+
next
|
27
|
+
end
|
28
|
+
|
29
|
+
unless FULLY_QUALIFIED_FIELD_NAME_MATCHER === k.to_s
|
30
|
+
raise ArgumentError, "global fields must be given in fully qualified format (table name::field name)"
|
31
|
+
end
|
32
|
+
|
33
|
+
normalized[k] = v
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -24,7 +24,8 @@ module FmRest
|
|
24
24
|
# Methods delegated to FmRest::Spyke::Relation
|
25
25
|
delegate :limit, :offset, :sort, :order, :query, :omit, :portal,
|
26
26
|
:portals, :includes, :with_all_portals, :without_portals,
|
27
|
-
:script, :find_one, :
|
27
|
+
:script, :find_one, :first, :any, :find_some,
|
28
|
+
:find_in_batches, :find_each, to: :all
|
28
29
|
|
29
30
|
def all
|
30
31
|
# Use FmRest's Relation instead of Spyke's vanilla one
|
@@ -4,8 +4,8 @@ module FmRest
|
|
4
4
|
module Spyke
|
5
5
|
module Model
|
6
6
|
module Serialization
|
7
|
-
FM_DATE_FORMAT = "%m/%d/%Y"
|
8
|
-
FM_DATETIME_FORMAT = "#{FM_DATE_FORMAT} %H:%M:%S"
|
7
|
+
FM_DATE_FORMAT = "%m/%d/%Y"
|
8
|
+
FM_DATETIME_FORMAT = "#{FM_DATE_FORMAT} %H:%M:%S"
|
9
9
|
|
10
10
|
# Override Spyke's to_params to return FM Data API's expected JSON
|
11
11
|
# format, and including only modified fields
|
@@ -63,9 +63,9 @@ module FmRest
|
|
63
63
|
def serialize_values!(params)
|
64
64
|
params.transform_values! do |value|
|
65
65
|
case value
|
66
|
-
when DateTime, Time
|
67
|
-
value.strftime(FM_DATETIME_FORMAT)
|
68
|
-
when Date
|
66
|
+
when DateTime, Time, FmRest::StringDateTime
|
67
|
+
convert_datetime_timezone(value.to_datetime).strftime(FM_DATETIME_FORMAT)
|
68
|
+
when Date, FmRest::StringDate
|
69
69
|
value.strftime(FM_DATE_FORMAT)
|
70
70
|
else
|
71
71
|
value
|
@@ -74,6 +74,17 @@ module FmRest
|
|
74
74
|
|
75
75
|
params
|
76
76
|
end
|
77
|
+
|
78
|
+
def convert_datetime_timezone(dt)
|
79
|
+
case fmrest_config.timezone
|
80
|
+
when :utc, "utc"
|
81
|
+
dt.new_offset(0)
|
82
|
+
when :local, "local"
|
83
|
+
dt.new_offset(FmRest::V1.local_offset_for_datetime(dt))
|
84
|
+
when nil
|
85
|
+
dt
|
86
|
+
end
|
87
|
+
end
|
77
88
|
end
|
78
89
|
end
|
79
90
|
end
|
@@ -190,6 +190,79 @@ module FmRest
|
|
190
190
|
rescue ::Spyke::ConnectionError => error
|
191
191
|
fallback_or_reraise(error, default: nil)
|
192
192
|
end
|
193
|
+
alias_method :first, :find_one
|
194
|
+
alias_method :any, :find_one
|
195
|
+
|
196
|
+
# Yields each batch of records that was found by the find options.
|
197
|
+
#
|
198
|
+
# NOTE: By its nature, batch processing is subject to race conditions if
|
199
|
+
# other processes are modifying the database
|
200
|
+
#
|
201
|
+
# @param batch_size [Integer] Specifies the size of the batch.
|
202
|
+
# @return [Enumerator] if called without a block.
|
203
|
+
def find_in_batches(batch_size: 1000)
|
204
|
+
unless block_given?
|
205
|
+
return to_enum(:find_in_batches, batch_size: batch_size) do
|
206
|
+
total = limit(1).find_some.metadata.data_info.found_count
|
207
|
+
(total - 1).div(batch_size) + 1
|
208
|
+
end
|
209
|
+
end
|
210
|
+
|
211
|
+
offset = 1 # DAPI offset is 1-based
|
212
|
+
|
213
|
+
loop do
|
214
|
+
relation = offset(offset).limit(batch_size)
|
215
|
+
|
216
|
+
records = relation.find_some
|
217
|
+
|
218
|
+
yield records if records.length > 0
|
219
|
+
|
220
|
+
break if records.length < batch_size
|
221
|
+
|
222
|
+
# Save one iteration if the total is a multiple of batch_size
|
223
|
+
if found_count = records.metadata.data_info && records.metadata.data_info.found_count
|
224
|
+
break if found_count == (offset - 1) + batch_size
|
225
|
+
end
|
226
|
+
|
227
|
+
offset += batch_size
|
228
|
+
end
|
229
|
+
end
|
230
|
+
|
231
|
+
# Looping through a collection of records from the database (using the
|
232
|
+
# #all method, for example) is very inefficient since it will fetch and
|
233
|
+
# instantiate all the objects at once.
|
234
|
+
#
|
235
|
+
# In that case, batch processing methods allow you to work with the
|
236
|
+
# records in batches, thereby greatly reducing memory consumption and be
|
237
|
+
# lighter on the Data API server.
|
238
|
+
#
|
239
|
+
# The find_each method uses #find_in_batches with a batch size of 1000
|
240
|
+
# (or as specified by the :batch_size option).
|
241
|
+
#
|
242
|
+
# NOTE: By its nature, batch processing is subject to race conditions if
|
243
|
+
# other processes are modifying the database
|
244
|
+
#
|
245
|
+
# @param (see #find_in_batches)
|
246
|
+
# @example
|
247
|
+
# Person.find_each do |person|
|
248
|
+
# person.greet
|
249
|
+
# end
|
250
|
+
#
|
251
|
+
# Person.query(name: "==Mitch").find_each do |person|
|
252
|
+
# person.say_hi
|
253
|
+
# end
|
254
|
+
# @return (see #find_in_batches)
|
255
|
+
def find_each(batch_size: 1000)
|
256
|
+
unless block_given?
|
257
|
+
return to_enum(:find_each, batch_size: batch_size) do
|
258
|
+
limit(1).find_some.metadata.data_info.found_count
|
259
|
+
end
|
260
|
+
end
|
261
|
+
|
262
|
+
find_in_batches(batch_size: batch_size) do |records|
|
263
|
+
records.each { |r| yield r }
|
264
|
+
end
|
265
|
+
end
|
193
266
|
|
194
267
|
protected
|
195
268
|
|
@@ -1,9 +1,21 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require "json"
|
4
|
+
require "ostruct"
|
4
5
|
|
5
6
|
module FmRest
|
6
7
|
module Spyke
|
8
|
+
# Metadata class to be passed to Spyke::Collection#metadata
|
9
|
+
class Metadata < Struct.new(:messages, :script, :data_info)
|
10
|
+
alias_method :scripts, :script
|
11
|
+
end
|
12
|
+
|
13
|
+
class DataInfo < OpenStruct
|
14
|
+
def total_record_count; totalRecordCount; end
|
15
|
+
def found_count; foundCount; end
|
16
|
+
def returned_count; returnedCount; end
|
17
|
+
end
|
18
|
+
|
7
19
|
# Response Faraday middleware for converting FM API's response JSON into
|
8
20
|
# Spyke's expected format
|
9
21
|
class SpykeFormatter < ::Faraday::Response::Middleware
|
@@ -77,36 +89,61 @@ module FmRest
|
|
77
89
|
|
78
90
|
# @param json [Hash]
|
79
91
|
# @param include_errors [Boolean]
|
80
|
-
# @return [
|
92
|
+
# @return [FmRest::Spyke::Metadata] the skeleton structure for a
|
93
|
+
# Spyke-formatted response
|
81
94
|
def build_base_hash(json, include_errors = false)
|
82
95
|
{
|
83
|
-
metadata:
|
84
|
-
|
96
|
+
metadata: Metadata.new(
|
97
|
+
prepare_messages(json),
|
98
|
+
prepare_script_results(json),
|
99
|
+
prepare_data_info(json)
|
100
|
+
).freeze,
|
101
|
+
errors: include_errors ? prepare_errors(json) : {}
|
85
102
|
}
|
86
103
|
end
|
87
104
|
|
88
105
|
# @param json [Hash]
|
89
|
-
# @return [
|
106
|
+
# @return [Array<OpenStruct>] the skeleton structure for a
|
107
|
+
# Spyke-formatted response
|
108
|
+
def prepare_messages(json)
|
109
|
+
return [] unless json[:messages]
|
110
|
+
json[:messages].map { |m| OpenStruct.new(m).freeze }.freeze
|
111
|
+
end
|
112
|
+
|
113
|
+
# @param json [Hash]
|
114
|
+
# @return [OpenStruct] the script(s) execution results for Spyke metadata
|
115
|
+
# format
|
90
116
|
def prepare_script_results(json)
|
91
117
|
results = {}
|
92
118
|
|
93
119
|
[:prerequest, :presort].each do |s|
|
94
120
|
if json[:response][:"scriptError.#{s}"]
|
95
|
-
results[s] =
|
121
|
+
results[s] = OpenStruct.new(
|
96
122
|
result: json[:response][:"scriptResult.#{s}"],
|
97
123
|
error: json[:response][:"scriptError.#{s}"]
|
98
|
-
|
124
|
+
).freeze
|
99
125
|
end
|
100
126
|
end
|
101
127
|
|
102
128
|
if json[:response][:scriptError]
|
103
|
-
results[:after] =
|
129
|
+
results[:after] = OpenStruct.new(
|
104
130
|
result: json[:response][:scriptResult],
|
105
131
|
error: json[:response][:scriptError]
|
106
|
-
|
132
|
+
).freeze
|
107
133
|
end
|
108
134
|
|
109
|
-
results
|
135
|
+
results.present? ? OpenStruct.new(results).freeze : nil
|
136
|
+
end
|
137
|
+
|
138
|
+
# @param json [Hash]
|
139
|
+
# @return [OpenStruct] the script(s) execution results for
|
140
|
+
# Spyke metadata format
|
141
|
+
def prepare_data_info(json)
|
142
|
+
data_info = json[:response] && json[:response][:dataInfo]
|
143
|
+
|
144
|
+
return nil unless data_info.present?
|
145
|
+
|
146
|
+
DataInfo.new(data_info).freeze
|
110
147
|
end
|
111
148
|
|
112
149
|
# @param json [Hash]
|