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