fmrest-spyke 0.13.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.
@@ -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