fmrest-spyke 0.13.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FmRest
4
+ module Spyke
5
+ module Model
6
+ module Auth
7
+ extend ::ActiveSupport::Concern
8
+
9
+ class_methods do
10
+ # Logs out the database session for this model (and other models
11
+ # using the same credentials).
12
+ #
13
+ # @raise [FmRest::V1::TokenSession::NoSessionTokenSet] if no session
14
+ # token was set (and no request is sent).
15
+ def logout!
16
+ connection.delete(FmRest::V1.session_path("dummy-token"))
17
+ end
18
+
19
+ # Logs out the database session for this model (and other models
20
+ # using the same credentials). Unlike `logout!`, no exception is
21
+ # raised in case of missing session token.
22
+ #
23
+ # @return [Boolean] Whether the logout request was sent (it's only
24
+ # sent if a session token was previously set)
25
+ def logout
26
+ logout!
27
+ true
28
+ rescue FmRest::V1::TokenSession::NoSessionTokenSet
29
+ false
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
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FmRest
4
+ module Spyke
5
+ module Model
6
+ # This module provides methods for configuring the Farday connection for
7
+ # the model, as well as setting up the connection itself.
8
+ #
9
+ module Connection
10
+ extend ActiveSupport::Concern
11
+
12
+ included do
13
+ class_attribute :faraday_block, instance_accessor: false, instance_predicate: false
14
+ class << self; private :faraday_block, :faraday_block=; end
15
+
16
+ # FM Data API expects PATCH for updates (Spyke uses PUT by default)
17
+ self.callback_methods = { create: :post, update: :patch }.freeze
18
+ end
19
+
20
+ class_methods do
21
+ def fmrest_config
22
+ if fmrest_config_overlay
23
+ return FmRest.default_connection_settings.merge(fmrest_config_overlay, skip_validation: true)
24
+ end
25
+
26
+ FmRest.default_connection_settings
27
+ end
28
+
29
+ # Sets the FileMaker connection settings for the model.
30
+ #
31
+ # Behaves similar to ActiveSupport's `class_attribute`, so it can be
32
+ # inherited and safely overwritten in subclasses.
33
+ #
34
+ # @param settings [Hash] The settings hash
35
+ #
36
+ def fmrest_config=(settings)
37
+ settings = ConnectionSettings.new(settings, skip_validation: true)
38
+
39
+ singleton_class.redefine_method(:fmrest_config) do
40
+ overlay = fmrest_config_overlay
41
+ return settings.merge(overlay, skip_validation: true) if overlay
42
+ settings
43
+ end
44
+ end
45
+
46
+ # Allows overriding some connection settings in a thread-local
47
+ # manner. Useful in the use case where you want to connect to the
48
+ # same database using different accounts (e.g. credentials provided
49
+ # by users in a web app context).
50
+ #
51
+ # @param (see #fmrest_config=)
52
+ #
53
+ def fmrest_config_overlay=(settings)
54
+ Thread.current[fmrest_config_overlay_key] = settings
55
+ end
56
+
57
+ # @return [FmRest::ConnectionSettings] the connection settings
58
+ # overlay if any is in use
59
+ #
60
+ def fmrest_config_overlay
61
+ Thread.current[fmrest_config_overlay_key] || begin
62
+ superclass.fmrest_config_overlay
63
+ rescue NoMethodError
64
+ nil
65
+ end
66
+ end
67
+
68
+ # Clears the connection settings overlay.
69
+ #
70
+ def clear_fmrest_config_overlay
71
+ Thread.current[fmrest_config_overlay_key] = nil
72
+ end
73
+
74
+ # Runs a block of code in the context of the given connection
75
+ # settings without affecting the connection settings outside said
76
+ # block.
77
+ #
78
+ # @param (see #fmrest_config=)
79
+ #
80
+ # @example
81
+ # Honeybee.with_overlay(username: "...", password: "...") do
82
+ # Honeybee.query(...)
83
+ # end
84
+ #
85
+ def with_overlay(settings, &block)
86
+ Fiber.new do
87
+ begin
88
+ self.fmrest_config_overlay = settings
89
+ yield
90
+ ensure
91
+ self.clear_fmrest_config_overlay
92
+ end
93
+ end.resume
94
+ end
95
+
96
+ # Spyke override -- Defaults to `fmrest_connection`
97
+ #
98
+ def connection
99
+ super || fmrest_connection
100
+ end
101
+
102
+ # Sets a block for injecting custom middleware into the Faraday
103
+ # connection.
104
+ #
105
+ # @example
106
+ # class MyModel < FmRest::Spyke::Base
107
+ # faraday do |conn|
108
+ # # Set up a custom logger for the model
109
+ # conn.response :logger, MyApp.logger, bodies: true
110
+ # end
111
+ # end
112
+ #
113
+ def faraday(&block)
114
+ self.faraday_block = block
115
+ end
116
+
117
+ private
118
+
119
+ def fmrest_connection
120
+ memoize = false
121
+
122
+ # Don't memoize the connection if there's an overlay, since
123
+ # overlays are thread-local and so should be the connection
124
+ unless fmrest_config_overlay
125
+ return @fmrest_connection if @fmrest_connection
126
+ memoize = true
127
+ end
128
+
129
+ config = ConnectionSettings.wrap(fmrest_config)
130
+
131
+ connection =
132
+ FmRest::V1.build_connection(config) do |conn|
133
+ faraday_block.call(conn) if faraday_block
134
+
135
+ # Pass the class to SpykeFormatter's initializer so it can have
136
+ # access to extra context defined in the model, e.g. a portal
137
+ # where name of the portal and the attributes prefix don't match
138
+ # and need to be specified as options to `portal`
139
+ conn.use FmRest::Spyke::SpykeFormatter, self
140
+
141
+ conn.use FmRest::V1::TypeCoercer, config
142
+
143
+ # FmRest::Spyke::JsonParse expects symbol keys
144
+ conn.response :json, parser_options: { symbolize_names: true }
145
+ end
146
+
147
+ @fmrest_connection = connection if memoize
148
+
149
+ connection
150
+ end
151
+
152
+ def fmrest_config_overlay_key
153
+ :"#{object_id}.fmrest_config_overlay"
154
+ end
155
+ end
156
+
157
+ def fmrest_config
158
+ self.class.fmrest_config
159
+ end
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fmrest/spyke/container_field"
4
+
5
+ module FmRest
6
+ module Spyke
7
+ module Model
8
+ # This module adds support for container fields.
9
+ #
10
+ module ContainerFields
11
+ extend ::ActiveSupport::Concern
12
+
13
+ class_methods do
14
+ # Defines a container field on the model.
15
+ #
16
+ # @param name [Symbol] the name of the container field
17
+ #
18
+ # @option options [String] :field_name (nil) the name of the container
19
+ # field in the FileMaker layout (only needed if it doesn't match
20
+ # the name given)
21
+ #
22
+ # @example
23
+ # class Honeybee < FmRest::Spyke::Base
24
+ # container :photo, field_name: "Beehive Photo ID"
25
+ # end
26
+ #
27
+ def container(name, options = {})
28
+ field_name = options[:field_name] || name
29
+
30
+ define_method(name) do
31
+ @container_fields ||= {}
32
+ @container_fields[name.to_sym] ||= ContainerField.new(self, field_name)
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+
@@ -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
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FmRest
4
+ module Spyke
5
+ module Model
6
+ module Http
7
+ extend ::ActiveSupport::Concern
8
+
9
+ class_methods do
10
+
11
+ # Override Spyke's request method to keep a thread-local copy of the
12
+ # last request's metadata, so that we can access things like script
13
+ # execution results after a save, etc.
14
+
15
+
16
+ # Spyke override -- Keeps metadata in thread-local class variable.
17
+ #
18
+ def request(*args)
19
+ super.tap do |r|
20
+ Thread.current[last_request_metadata_key] = r.metadata
21
+ end
22
+ end
23
+
24
+ def last_request_metadata(key: last_request_metadata_key)
25
+ Thread.current[key]
26
+ end
27
+
28
+ private
29
+
30
+ def last_request_metadata_key
31
+ "#{to_s}.last_request_metadata"
32
+ end
33
+ end
34
+ end
35
+
36
+ # Spyke override -- Uses `__record_id` for building the record URI.
37
+ #
38
+ def uri
39
+ ::Spyke::Path.new(@uri_template, fmrest_uri_attributes) if @uri_template
40
+ end
41
+
42
+ private
43
+
44
+ # Spyke override (private) -- Use `__record_id` instead of `id`
45
+ #
46
+ def resolve_path_from_action(action)
47
+ case action
48
+ when Symbol then uri.join(action)
49
+ when String then ::Spyke::Path.new(action, fmrest_uri_attributes)
50
+ else uri
51
+ end
52
+ end
53
+
54
+ def fmrest_uri_attributes
55
+ if persisted?
56
+ { __record_id: __record_id }
57
+ else
58
+ # NOTE: it seems silly to be calling attributes.slice(:__record_id)
59
+ # when the record is supposed to not have a record_id set (since
60
+ # persisted? is false here), but it makes sense in the context of how
61
+ # Spyke works:
62
+ #
63
+ # When calling Model.find(id), Spyke will internally create a scope
64
+ # with .where(primary_key => id) and call .find_one on it. Then,
65
+ # somewhere down the line Spyke creates a new empty instance of the
66
+ # current model class to get its .uri property (the one we're
67
+ # partially building through this method and which contains these URI
68
+ # attributes). When initializing a record Spyke first forcefully
69
+ # assigns the .where()-set attributes from the current scope onto
70
+ # that instance's attributes hash, which then leads us right here,
71
+ # where we might have __record_id assigned as a scope attribute:
72
+ attributes.slice(:__record_id)
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,256 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fmrest/spyke/relation"
4
+ require "fmrest/spyke/validation_error"
5
+
6
+ module FmRest
7
+ module Spyke
8
+ module Model
9
+ # This module adds and extends various ORM features in Spyke models,
10
+ # including custom query methods, remote script execution and
11
+ # exception-raising persistence methods.
12
+ #
13
+ module Orm
14
+ extend ::ActiveSupport::Concern
15
+
16
+ included do
17
+ # Allow overriding FM's default limit (by default it's 100)
18
+ class_attribute :default_limit, instance_accessor: false, instance_predicate: false
19
+
20
+ class_attribute :default_sort, instance_accessor: false, instance_predicate: false
21
+
22
+ # Whether to raise an FmRest::APIError::NoMatchingRecordsError when a
23
+ # _find request has no results
24
+ class_attribute :raise_on_no_matching_records, instance_accessor: false, instance_predicate: false
25
+ end
26
+
27
+ class_methods do
28
+ # Methods delegated to `FmRest::Spyke::Relation`
29
+ delegate :limit, :offset, :sort, :order, :query, :omit, :portal,
30
+ :portals, :includes, :with_all_portals, :without_portals,
31
+ :script, :find_one, :first, :any, :find_some,
32
+ :find_in_batches, :find_each, to: :all
33
+
34
+ # Spyke override -- Use FmRest's Relation instead of Spyke's vanilla
35
+ # one
36
+ #
37
+ def all
38
+ current_scope || Relation.new(self, uri: uri)
39
+ end
40
+
41
+ # Spyke override -- allows properly setting limit, offset and other
42
+ # options, as well as using the appropriate HTTP method/URL depending
43
+ # on whether there's a query present in the current scope.
44
+ #
45
+ # @example
46
+ # Person.query(first_name: "Stefan").fetch # -> POST .../_find
47
+ #
48
+ def fetch
49
+ if current_scope.has_query?
50
+ scope = extend_scope_with_fm_params(current_scope, prefixed: false)
51
+ scope = scope.where(query: scope.query_params)
52
+ scope = scope.with(FmRest::V1::find_path(layout))
53
+ else
54
+ scope = extend_scope_with_fm_params(current_scope, prefixed: true)
55
+ end
56
+
57
+ previous, self.current_scope = current_scope, scope
58
+
59
+ # The DAPI returns a 401 "No records match the request" error when
60
+ # nothing matches a _find request, so we need to catch it in order
61
+ # to provide sane behavior (i.e. return an empty resultset)
62
+ begin
63
+ current_scope.has_query? ? scoped_request(:post) : super
64
+ rescue FmRest::APIError::NoMatchingRecordsError => e
65
+ raise e if raise_on_no_matching_records
66
+ ::Spyke::Result.new({})
67
+ end
68
+ ensure
69
+ self.current_scope = previous
70
+ end
71
+
72
+ # Exception-raising version of `#create`
73
+ #
74
+ # @param attributes [Hash] the attributes to initialize the
75
+ # record with
76
+ #
77
+ def create!(attributes = {})
78
+ new(attributes).tap(&:save!)
79
+ end
80
+
81
+ # Requests execution of a FileMaker script.
82
+ #
83
+ # @param script_name [String] the name of the FileMaker script to
84
+ # execute
85
+ # @param param [String] an optional paramater for the script
86
+ #
87
+ def execute_script(script_name, param: nil)
88
+ params = {}
89
+ params = {"script.param" => param} unless param.nil?
90
+ request(:get, FmRest::V1::script_path(layout, script_name), params)
91
+ end
92
+
93
+ private
94
+
95
+ def extend_scope_with_fm_params(scope, prefixed: false)
96
+ prefix = prefixed ? "_" : nil
97
+
98
+ where_options = {}
99
+
100
+ where_options["#{prefix}limit"] = scope.limit_value if scope.limit_value
101
+ where_options["#{prefix}offset"] = scope.offset_value if scope.offset_value
102
+
103
+ if scope.sort_params.present?
104
+ where_options["#{prefix}sort"] =
105
+ prefixed ? scope.sort_params.to_json : scope.sort_params
106
+ end
107
+
108
+ unless scope.included_portals.nil?
109
+ where_options["portal"] =
110
+ prefixed ? scope.included_portals.to_json : scope.included_portals
111
+ end
112
+
113
+ if scope.portal_params.present?
114
+ scope.portal_params.each do |portal_param, value|
115
+ where_options["#{prefix}#{portal_param}"] = value
116
+ end
117
+ end
118
+
119
+ if scope.script_params.present?
120
+ where_options.merge!(scope.script_params)
121
+ end
122
+
123
+ scope.where(where_options)
124
+ end
125
+ end
126
+
127
+ # Spyke override -- Adds a number of features to original `#save`:
128
+ #
129
+ # * Validations
130
+ # * Data API scripts execution
131
+ # * Refresh of dirty attributes
132
+ #
133
+ # @option options [String] :script the name of a FileMaker script to execute
134
+ # upon saving
135
+ # @option options [Boolean] :raise_validation_errors whether to raise an
136
+ # exception if validations fail
137
+ #
138
+ # @return [true] if saved successfully
139
+ # @return [false] if validations or persistence failed
140
+ #
141
+ def save(options = {})
142
+ callback = persisted? ? :update : :create
143
+
144
+ return false unless perform_save_validations(callback, options)
145
+ return false unless perform_save_persistence(callback, options)
146
+
147
+ true
148
+ end
149
+
150
+ # Exception-raising version of `#save`.
151
+ #
152
+ # @option (see #save)
153
+ #
154
+ # @return [true] if saved successfully
155
+ #
156
+ # @raise if validations or presistence failed
157
+ #
158
+ def save!(options = {})
159
+ save(options.merge(raise_validation_errors: true))
160
+ end
161
+
162
+ # Exception-raising version of `#update`.
163
+ #
164
+ # @param new_attributes [Hash] a hash of record attributes to update
165
+ # the record with
166
+ #
167
+ # @option (see #save)
168
+ #
169
+ def update!(new_attributes, options = {})
170
+ self.attributes = new_attributes
171
+ save!(options)
172
+ end
173
+
174
+ # Spyke override -- Adds support for Data API script execution.
175
+ #
176
+ # @option options [String] :script the name of a FileMaker script to execute
177
+ # upon deletion
178
+ #
179
+ def destroy(options = {})
180
+ # For whatever reason the Data API wants the script params as query
181
+ # string params for DELETE requests, making this more complicated
182
+ # than it should be
183
+ script_query_string = if options.has_key?(:script)
184
+ "?" + Faraday::Utils.build_query(FmRest::V1.convert_script_params(options[:script]))
185
+ else
186
+ ""
187
+ end
188
+
189
+ self.attributes = delete(uri.to_s + script_query_string)
190
+ end
191
+
192
+ # (see #destroy)
193
+ #
194
+ # @option (see #destroy)
195
+ #
196
+ def reload(options = {})
197
+ scope = self.class
198
+ scope = scope.script(options[:script]) if options.has_key?(:script)
199
+ reloaded = scope.find(__record_id)
200
+ self.attributes = reloaded.attributes
201
+ self.__mod_id = reloaded.mod_id
202
+ end
203
+
204
+ # ActiveModel 5+ implements this method, so we only need it if we're in
205
+ # the older AM4
206
+ if ActiveModel::VERSION::MAJOR == 4
207
+ def validate!(context = nil)
208
+ valid?(context) || raise_validation_error
209
+ end
210
+ end
211
+
212
+ private
213
+
214
+ def perform_save_validations(context, options)
215
+ return true if options[:validate] == false
216
+ options[:raise_validation_errors] ? validate!(context) : validate(context)
217
+ end
218
+
219
+ def perform_save_persistence(callback, options)
220
+ run_callbacks :save do
221
+ run_callbacks(callback) do
222
+
223
+ begin
224
+ send self.class.method_for(callback), build_params_for_save(options)
225
+
226
+ rescue APIError::ValidationError => e
227
+ if options[:raise_validation_errors]
228
+ raise e
229
+ else
230
+ return false
231
+ end
232
+ end
233
+
234
+ end
235
+ end
236
+
237
+ true
238
+ end
239
+
240
+ def build_params_for_save(options)
241
+ to_params.tap do |params|
242
+ if options.has_key?(:script)
243
+ params.merge!(FmRest::V1.convert_script_params(options[:script]))
244
+ end
245
+ end
246
+ end
247
+
248
+ # Overwrite ActiveModel's raise_validation_error to use our own class
249
+ #
250
+ def raise_validation_error # :doc:
251
+ raise(ValidationError.new(self))
252
+ end
253
+ end
254
+ end
255
+ end
256
+ end