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.
@@ -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
 
@@ -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
@@ -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 ::ActiveSupport::Concern
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 was PUT)
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
- @fmrest_connection ||=
42
- begin
43
- config = fmrest_config || FmRest.default_connection_settings
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
- FmRest::V1.build_connection(config) do |conn|
46
- faraday_block.call(conn) if faraday_block
101
+ config = ConnectionSettings.wrap(fmrest_config)
47
102
 
48
- # Pass the class to SpykeFormatter's initializer so it can have
49
- # access to extra context defined in the model, e.g. a portal
50
- # where name of the portal and the attributes prefix don't match
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
- conn.use FmRest::V1::TypeCoercer, config
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
- # FmRest::Spyke::JsonParse expects symbol keys
57
- conn.response :json, parser_options: { symbolize_names: true }
58
- end
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
@@ -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".freeze
8
- FM_DATETIME_FORMAT = "#{FM_DATE_FORMAT} %H:%M:%S".freeze
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_classes
67
+ convert_datetime_timezone(value.to_datetime).strftime(FM_DATETIME_FORMAT)
68
+ when *date_classes
69
69
  value.strftime(FM_DATE_FORMAT)
70
70
  else
71
71
  value
@@ -74,6 +74,25 @@ 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
88
+
89
+ def datetime_classes
90
+ [DateTime, Time, defined?(FmRest::StringDateTime) && FmRest::StringDateTime].compact
91
+ end
92
+
93
+ def date_classes
94
+ [Date, defined?(FmRest::StringDate) && FmRest::StringDate].compact
95
+ end
77
96
  end
78
97
  end
79
98
  end
@@ -79,17 +79,24 @@ module FmRest
79
79
  class InvalidDate < ArgumentError; end
80
80
 
81
81
  class << self
82
- alias_method :strptime, :new
82
+ def strptime(str, date_format, *_)
83
+ begin
84
+ date = self::DELEGATE_CLASS.strptime(str, date_format)
85
+ rescue ArgumentError
86
+ raise InvalidDate
87
+ end
88
+
89
+ new(str, date)
90
+ end
83
91
  end
84
92
 
85
- def initialize(str, date_format, **str_args)
93
+ def initialize(str, date, **str_args)
94
+ raise ArgumentError, "str must be of class String" unless str.is_a?(String)
95
+ raise ArgumentError, "date must be of class #{self.class::DELEGATE_CLASS.name}" unless date.is_a?(self.class::DELEGATE_CLASS)
96
+
86
97
  super(str, **str_args)
87
98
 
88
- begin
89
- @delegate = self.class::DELEGATE_CLASS.strptime(str, date_format)
90
- rescue ArgumentError
91
- raise InvalidDate
92
- end
99
+ @delegate = date
93
100
 
94
101
  freeze
95
102
  end
@@ -178,4 +185,36 @@ module FmRest
178
185
  @delegate
179
186
  end
180
187
  end
188
+
189
+ module StringDateAwareness
190
+ def _parse(v, *_)
191
+ if v.is_a?(StringDateTime)
192
+ return { year: v.year, mon: v.month, mday: v.mday, hour: v.hour, min: v.min, sec: v.sec, sec_fraction: v.sec_fraction, offset: v.offset }
193
+ end
194
+ if v.is_a?(StringDate)
195
+ return { year: v.year, mon: v.month, mday: v.mday }
196
+ end
197
+ super
198
+ end
199
+
200
+ def parse(v, *_)
201
+ if v.is_a?(StringDate)
202
+ return self == ::DateTime ? v.to_datetime : v.to_date
203
+ end
204
+ super
205
+ end
206
+
207
+ # Overriding case equality method so that it returns true for
208
+ # `FmRest::StringDate` instances
209
+ #
210
+ # Calls superclass method
211
+ #
212
+ def ===(other)
213
+ super || other.is_a?(StringDate)
214
+ end
215
+
216
+ def self.enable(classes: [Date, DateTime])
217
+ classes.each { |klass| klass.singleton_class.prepend(self) }
218
+ end
219
+ end
181
220
  end
@@ -2,5 +2,11 @@
2
2
 
3
3
  module FmRest
4
4
  module TokenStore
5
+ autoload :Base, "fmrest/token_store/base"
6
+ autoload :Memory, "fmrest/token_store/memory"
7
+ autoload :Null, "fmrest/token_store/null"
8
+ autoload :ActiveRecord, "fmrest/token_store/active_record"
9
+ autoload :Moneta, "fmrest/token_store/moneta"
10
+ autoload :Redis, "fmrest/token_store/redis"
5
11
  end
6
12
  end
@@ -10,15 +10,15 @@ module FmRest
10
10
  end
11
11
 
12
12
  def load(key)
13
- raise "Not implemented"
13
+ raise NotImplementedError
14
14
  end
15
15
 
16
16
  def store(key, value)
17
- raise "Not implemented"
17
+ raise NotImplementedError
18
18
  end
19
19
 
20
20
  def delete(key)
21
- raise "Not implemented"
21
+ raise NotImplementedError
22
22
  end
23
23
  end
24
24
  end
@@ -4,16 +4,20 @@ require "fmrest/v1/connection"
4
4
  require "fmrest/v1/paths"
5
5
  require "fmrest/v1/container_fields"
6
6
  require "fmrest/v1/utils"
7
+ require "fmrest/v1/dates"
8
+ require "fmrest/v1/auth"
7
9
 
8
10
  module FmRest
9
11
  module V1
10
- DEFAULT_DATE_FORMAT = "MM/dd/yyyy"
11
- DEFAULT_TIME_FORMAT = "HH:mm:ss"
12
- DEFAULT_TIMESTAMP_FORMAT = "#{DEFAULT_DATE_FORMAT} #{DEFAULT_TIME_FORMAT}"
13
-
14
12
  extend Connection
15
13
  extend Paths
16
14
  extend ContainerFields
17
15
  extend Utils
16
+ extend Dates
17
+ extend Auth
18
+
19
+ autoload :TokenSession, "fmrest/v1/token_session"
20
+ autoload :RaiseErrors, "fmrest/v1/raise_errors"
21
+ autoload :TypeCoercer, "fmrest/v1/type_coercer"
18
22
  end
19
23
  end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FmRest
4
+ module V1
5
+ module Auth
6
+ # Requests a token through basic auth
7
+ #
8
+ # @param connection [Faraday] the auth connection to use for
9
+ # the request
10
+ # @return The token if successful
11
+ # @return `false` if authentication failed
12
+ def request_auth_token(connection = FmRest::V1.auth_connection)
13
+ request_auth_token!(connection)
14
+ rescue FmRest::APIError::AccountError
15
+ false
16
+ end
17
+
18
+ # Requests a token through basic auth, raising
19
+ # `FmRest::APIError::AccountError` if auth fails
20
+ #
21
+ # @param (see #request_auth_token)
22
+ # @return The token if successful
23
+ # @raise [FmRest::APIError::AccountError] if authentication failed
24
+ def request_auth_token!(connection = FmRest.V1.auth_connection)
25
+ resp = connection.post(V1.session_path)
26
+ resp.body["response"]["token"]
27
+ end
28
+ end
29
+ end
30
+ end
@@ -7,21 +7,21 @@ module FmRest
7
7
  module Connection
8
8
  BASE_PATH = "/fmi/data/v1/databases"
9
9
 
10
+ AUTH_HEADERS = { "Content-Type" => "application/json" }.freeze
11
+
10
12
  # Builds a complete DAPI Faraday connection with middleware already
11
13
  # configured to handle authentication, JSON parsing, logging and DAPI
12
14
  # error handling. A block can be optionally given for additional
13
15
  # middleware configuration
14
16
  #
15
- # @option options [String] :username The username for DAPI authentication
16
- # @option options [String] :account_name Alias of :username for
17
- # compatibility with Rfm gem
18
- # @option options [String] :password The password for DAPI authentication
19
17
  # @option (see #base_connection)
20
18
  # @return (see #base_connection)
21
- def build_connection(options = FmRest.default_connection_settings, &block)
22
- base_connection(options) do |conn|
19
+ def build_connection(settings = FmRest.default_connection_settings, &block)
20
+ settings = ConnectionSettings.wrap(settings)
21
+
22
+ base_connection(settings) do |conn|
23
23
  conn.use RaiseErrors
24
- conn.use TokenSession, options
24
+ conn.use TokenSession, settings
25
25
 
26
26
  # The EncodeJson and Multipart middlewares only encode the request
27
27
  # when the content type matches, so we can have them both here and
@@ -33,13 +33,13 @@ module FmRest
33
33
 
34
34
  # Allow overriding the default response middleware
35
35
  if block_given?
36
- yield conn, options
36
+ yield conn, settings
37
37
  else
38
- conn.use TypeCoercer, options
38
+ conn.use TypeCoercer, settings
39
39
  conn.response :json
40
40
  end
41
41
 
42
- if options[:log]
42
+ if settings.log
43
43
  conn.response :logger, nil, bodies: true, headers: true
44
44
  end
45
45
 
@@ -47,18 +47,46 @@ module FmRest
47
47
  end
48
48
  end
49
49
 
50
+ # Builds a Faraday connection to use for DAPI basic auth login
51
+ #
52
+ # @option (see #base_connection)
53
+ # @return (see #base_connection)
54
+ def auth_connection(settings = FmRest.default_connection_settings)
55
+ settings = ConnectionSettings.wrap(settings)
56
+
57
+ base_connection(settings, { headers: AUTH_HEADERS }) do |conn|
58
+ conn.use RaiseErrors
59
+
60
+ conn.basic_auth settings.username!, settings.password!
61
+
62
+ if settings.log
63
+ conn.response :logger, nil, bodies: true, headers: true
64
+ end
65
+
66
+ conn.response :json
67
+ conn.adapter Faraday.default_adapter
68
+ end
69
+ end
70
+
50
71
  # Builds a base Faraday connection with base URL constructed from
51
- # connection options and passes it the given block
72
+ # connection settings and passes it the given block
52
73
  #
53
- # @option options [String] :host The hostname for the FM server
54
- # @option options [String] :database The FM database name
55
- # @option options [String] :ssl SSL options to forward to the Faraday
74
+ # @option settings [String] :host The hostname for the FM server
75
+ # @option settings [String] :database The FM database name
76
+ # @option settings [String] :username The username for DAPI authentication
77
+ # @option settings [String] :account_name Alias of :username for
78
+ # compatibility with Rfm gem
79
+ # @option settings [String] :password The password for DAPI authentication
80
+ # @option settings [String] :ssl SSL settings to forward to the Faraday
56
81
  # connection
57
- # @option options [String] :proxy Proxy options to forward to the Faraday
82
+ # @option settings [String] :proxy Proxy options to forward to the Faraday
58
83
  # connection
84
+ # @param faraday_options [Hash] additional options for Faraday object
59
85
  # @return [Faraday] The new Faraday connection
60
- def base_connection(options = FmRest.default_connection_settings, &block)
61
- host = options.fetch(:host)
86
+ def base_connection(settings = FmRest.default_connection_settings, faraday_options = nil, &block)
87
+ settings = ConnectionSettings.wrap(settings)
88
+
89
+ host = settings.host!
62
90
 
63
91
  # Default to HTTPS
64
92
  scheme = "https"
@@ -70,12 +98,14 @@ module FmRest
70
98
  scheme = uri.scheme
71
99
  end
72
100
 
73
- faraday_options = {}
74
- faraday_options[:ssl] = options[:ssl] if options.key?(:ssl)
75
- faraday_options[:proxy] = options[:proxy] if options.key?(:proxy)
101
+ faraday_options = (faraday_options || {}).dup
102
+ faraday_options[:ssl] = settings.ssl if settings.ssl?
103
+ faraday_options[:proxy] = settings.proxy if settings.proxy?
104
+
105
+ database = URI.encode_www_form_component(settings.database!)
76
106
 
77
107
  Faraday.new(
78
- "#{scheme}://#{host}#{BASE_PATH}/#{URI.escape(options.fetch(:database))}/".freeze,
108
+ "#{scheme}://#{host}#{BASE_PATH}/#{database}/".freeze,
79
109
  faraday_options,
80
110
  &block
81
111
  )
@@ -83,7 +113,3 @@ module FmRest
83
113
  end
84
114
  end
85
115
  end
86
-
87
- require "fmrest/v1/token_session"
88
- require "fmrest/v1/raise_errors"
89
- require "fmrest/v1/type_coercer"