fmrest 0.7.1 → 0.11.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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