fmrest 0.7.1 → 0.11.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.
@@ -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
@@ -31,34 +31,62 @@ module FmRest
31
31
  conn.request :multipart
32
32
  conn.request :json
33
33
 
34
- if options[:log]
35
- conn.response :logger, nil, bodies: true, headers: true
36
- end
37
-
38
34
  # Allow overriding the default response middleware
39
35
  if block_given?
40
- yield conn, options
36
+ yield conn, settings
41
37
  else
42
- conn.use TypeCoercer, options
38
+ conn.use TypeCoercer, settings
43
39
  conn.response :json
44
40
  end
45
41
 
42
+ if settings.log
43
+ conn.response :logger, nil, bodies: true, headers: true
44
+ end
45
+
46
+ conn.adapter Faraday.default_adapter
47
+ end
48
+ end
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
46
67
  conn.adapter Faraday.default_adapter
47
68
  end
48
69
  end
49
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"
@@ -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
@@ -23,9 +23,11 @@ module FmRest
23
23
  402..499 => APIError::ParameterError,
24
24
  500..599 => APIError::ValidationError,
25
25
  800..899 => APIError::SystemError,
26
+ 952 => APIError::InvalidToken,
27
+ 953 => APIError::MaximumDataAPICallsExceeded,
26
28
  1200..1299 => APIError::ScriptError,
27
29
  1400..1499 => APIError::ODBCError
28
- }
30
+ }.freeze
29
31
 
30
32
  def on_complete(env)
31
33
  # Sniff for either straight JSON parsing or Spyke's format
@@ -10,15 +10,15 @@ module FmRest
10
10
  class TokenSession < Faraday::Middleware
11
11
  class NoSessionTokenSet < FmRest::Error; end
12
12
 
13
- HEADER_KEY = "Authorization".freeze
13
+ HEADER_KEY = "Authorization"
14
14
  TOKEN_STORE_INTERFACE = [:load, :store, :delete].freeze
15
15
  LOGOUT_PATH_MATCHER = %r{\A(#{FmRest::V1::Connection::BASE_PATH}/[^/]+/sessions/)[^/]+\Z}.freeze
16
16
 
17
17
  # @param app [#call]
18
- # @param options [Hash]
19
- def initialize(app, options = FmRest.default_connection_settings)
18
+ # @param settings [FmRest::ConnectionSettings]
19
+ def initialize(app, settings)
20
20
  super(app)
21
- @options = options
21
+ @settings = settings
22
22
  end
23
23
 
24
24
  # Entry point for the middleware when sending a request
@@ -32,27 +32,37 @@ module FmRest
32
32
 
33
33
  @app.call(env).on_complete do |response_env|
34
34
  if response_env[:status] == 401 # Unauthorized
35
- env[:body] = request_body
36
- token_store.delete(token_store_key)
37
- set_auth_header(env)
38
- return @app.call(env)
35
+ delete_token_store_key
36
+
37
+ if @settings.autologin
38
+ env[:body] = request_body
39
+ set_auth_header(env)
40
+ return @app.call(env)
41
+ end
39
42
  end
40
43
  end
41
44
  end
42
45
 
43
46
  private
44
47
 
48
+ def delete_token_store_key
49
+ token_store.delete(token_store_key)
50
+ # Sometimes we may want to pass the :token in settings manually, and
51
+ # refrain from passing a :username. In that case the call to
52
+ # #token_store_key above would fail as it tries to fetch :username, so
53
+ # we purposely ignore that error.
54
+ rescue FmRest::ConnectionSettings::MissingSetting
55
+ end
56
+
45
57
  def handle_logout(env)
46
- token = token_store.load(token_store_key)
58
+ token = @settings.token? ? @settings.token : token_store.load(token_store_key)
47
59
 
48
60
  raise NoSessionTokenSet, "Couldn't send logout request because no session token was set" unless token
49
61
 
50
62
  env.url.path = env.url.path.gsub(LOGOUT_PATH_MATCHER, "\\1#{token}")
51
63
 
52
64
  @app.call(env).on_complete do |response_env|
53
- if response_env[:status] == 200
54
- token_store.delete(token_store_key)
55
- end
65
+ delete_token_store_key if response_env[:status] == 200
56
66
  end
57
67
  end
58
68
 
@@ -65,43 +75,33 @@ module FmRest
65
75
  env.request_headers[HEADER_KEY] = "Bearer #{token}"
66
76
  end
67
77
 
68
- # Tries to get an existing token from the token store,
78
+ # Uses the token given in connection settings if available,
79
+ # otherwisek tries to get an existing token from the token store,
69
80
  # otherwise requests one through basic auth,
70
81
  # otherwise raises an exception.
71
82
  #
72
83
  def token
84
+ return @settings.token if @settings.token?
85
+
73
86
  token = token_store.load(token_store_key)
74
87
  return token if token
75
88
 
76
- if token = request_token
77
- token_store.store(token_store_key, token)
78
- return token
79
- end
89
+ return nil unless @settings.autologin
80
90
 
81
- # TODO: Make this a custom exception class
82
- raise "Filemaker auth failed"
91
+ token = V1.request_auth_token!(auth_connection)
92
+ token_store.store(token_store_key, token)
93
+ token
83
94
  end
84
95
 
85
- # Requests a token through basic auth
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
96
+ # The key to use to store a token, uses the format host:database:username
97
97
  #
98
98
  def token_store_key
99
99
  @token_store_key ||=
100
100
  begin
101
101
  # Strip the host part to just the hostname (i.e. no scheme or port)
102
- host = @options.fetch(:host)
102
+ host = @settings.host!
103
103
  host = URI(host).hostname if host =~ /\Ahttps?:\/\//
104
- "#{host}:#{@options.fetch(:database)}"
104
+ "#{host}:#{@settings.database!}:#{@settings.username!}"
105
105
  end
106
106
  end
107
107
 
@@ -111,31 +111,23 @@ module FmRest
111
111
  if TOKEN_STORE_INTERFACE.all? { |method| token_store_option.respond_to?(method) }
112
112
  token_store_option
113
113
  elsif token_store_option.kind_of?(Class)
114
- token_store_option.new
114
+ if token_store_option.respond_to?(:instance)
115
+ token_store_option.instance
116
+ else
117
+ token_store_option.new
118
+ end
115
119
  else
116
- require "fmrest/token_store/memory"
117
- TokenStore::Memory.new
120
+ FmRest::TokenStore::Memory.new
118
121
  end
119
122
  end
120
123
  end
121
124
 
122
125
  def token_store_option
123
- @options[:token_store] || FmRest.token_store
126
+ @settings.token_store || FmRest.token_store
124
127
  end
125
128
 
126
129
  def auth_connection
127
- @auth_connection ||= V1.base_connection(@options) do |conn|
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
130
+ @auth_connection ||= V1.auth_connection(@settings)
139
131
  end
140
132
  end
141
133
  end