fmrest 0.1.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,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
@@ -0,0 +1,33 @@
1
+ lib = File.expand_path("../lib", __FILE__)
2
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
+ require "fmrest/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "fmrest"
7
+ spec.version = FmRest::VERSION
8
+ spec.authors = ["Pedro Carbajal"]
9
+ spec.email = ["pedro_c@beezwax.net"]
10
+
11
+ spec.summary = %q{FileMaker Data API client using Faraday}
12
+ spec.description = %q{FileMaker Data API client using Faraday, with optional ActiveRecord-like ORM based on Spyke}
13
+ spec.homepage = "https://github.com/beezwax/fmrest-ruby"
14
+ spec.license = "MIT"
15
+
16
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
17
+ f.match(%r{^(test|spec|features|bin)/})
18
+ end
19
+ spec.bindir = "exe"
20
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
21
+ spec.require_paths = ["lib"]
22
+
23
+ spec.add_dependency 'faraday', '>= 0.9.0', '< 2.0'
24
+ spec.add_dependency 'faraday_middleware', '>= 0.9.1', '< 2.0'
25
+
26
+ spec.add_development_dependency "bundler", "~> 1.16"
27
+ spec.add_development_dependency "rake", "~> 10.0"
28
+ spec.add_development_dependency "rspec", "~> 3.0"
29
+ spec.add_development_dependency "spyke"
30
+ spec.add_development_dependency "webmock"
31
+ spec.add_development_dependency "multi_json"
32
+ spec.add_development_dependency "pry-byebug"
33
+ end
@@ -0,0 +1,13 @@
1
+ require "faraday"
2
+ require "faraday_middleware"
3
+
4
+ require "fmrest/version"
5
+ require "fmrest/v1"
6
+
7
+ module FmRest
8
+ class << self
9
+ attr_accessor :token_store
10
+
11
+ attr_accessor :config
12
+ end
13
+ end
@@ -0,0 +1,11 @@
1
+ require "fmrest/spyke/json_parser"
2
+ require "fmrest/spyke/model"
3
+ require "fmrest/spyke/base"
4
+
5
+ module FmRest
6
+ module Spyke
7
+ def self.included(base)
8
+ base.include Model
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,15 @@
1
+ module FmRest
2
+ module Spyke
3
+ class Base < ::Spyke::Base
4
+ include FmRest::Spyke::Model
5
+ end
6
+
7
+ class << self
8
+ def Base(config = {})
9
+ Class.new(::FmRest::Spyke::Base) do
10
+ self.fmrest_config = config
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,143 @@
1
+ module FmRest
2
+ module Spyke
3
+ class JsonParser < ::Faraday::Response::Middleware
4
+ SINGLE_RECORD_RE = %r(/records/\d+\Z).freeze
5
+ FIND_RECORDS_RE = %r(/_find\b).freeze
6
+
7
+ def initialize(app, model)
8
+ super(app)
9
+ @model = model
10
+ end
11
+
12
+ def on_complete(env)
13
+ json = parse_json(env.body)
14
+
15
+ env.body =
16
+ if env.method == :get || find_results?(env.url)
17
+ if single_record_url?(env.url)
18
+ prepare_single_record(json)
19
+ else
20
+ prepare_collection(json)
21
+ end
22
+ else
23
+ prepare_save_response(json)
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def prepare_save_response(json)
30
+ response = json[:response]
31
+
32
+ data = {}
33
+ data[:mod_id] = response[:modId].to_i if response[:modId]
34
+ data[:id] = response[:recordId].to_i if response[:recordId]
35
+
36
+ base_hash(json).merge!(data: data)
37
+ end
38
+
39
+ def prepare_single_record(json)
40
+ data =
41
+ json[:response][:data] &&
42
+ prepare_record_data(json[:response][:data].first)
43
+
44
+ base_hash(json).merge!(data: data)
45
+ end
46
+
47
+ def prepare_collection(json)
48
+ data =
49
+ json[:response][:data] &&
50
+ json[:response][:data].map { |record_data| prepare_record_data(record_data) }
51
+
52
+ base_hash(json).merge!(data: data)
53
+ end
54
+
55
+ def base_hash(json)
56
+ {
57
+ metadata: { messages: json[:messages] },
58
+ errors: {}
59
+ }
60
+ end
61
+
62
+ # json_data is expected in this format:
63
+ #
64
+ # {
65
+ # "fieldData": {
66
+ # "fieldName1" : "fieldValue1",
67
+ # "fieldName2" : "fieldValue2",
68
+ # ...
69
+ # },
70
+ # "portalData": {
71
+ # "portal1" : [
72
+ # { <portalRecord1> },
73
+ # { <portalRecord2> },
74
+ # ...
75
+ # ],
76
+ # "portal2" : [
77
+ # { <portalRecord1> },
78
+ # { <portalRecord2> },
79
+ # ...
80
+ # ]
81
+ # },
82
+ # "modId": <Id_for_last_modification>,
83
+ # "recordId": <Unique_internal_ID_for_this_record>
84
+ # }
85
+ #
86
+ def prepare_record_data(json_data)
87
+ out = { id: json_data[:recordId].to_i, mod_id: json_data[:modId].to_i }
88
+ out.merge!(json_data[:fieldData])
89
+ out.merge!(prepare_portal_data(json_data[:portalData])) if json_data[:portalData]
90
+ out
91
+ end
92
+
93
+ # Extracts recordId and strips the PortalName:: field prefix for each
94
+ # portal
95
+ #
96
+ # Sample json_portal_data:
97
+ #
98
+ # "portalData": {
99
+ # "Orders":[
100
+ # { "Orders::DeliveryDate":"3/7/2017", "recordId":"23" }
101
+ # ]
102
+ # }
103
+ #
104
+ def prepare_portal_data(json_portal_data)
105
+ json_portal_data.each_with_object({}) do |(portal_name, portal_records), out|
106
+ portal_options = @model.portal_options[portal_name.to_s] || {}
107
+
108
+ out[portal_name] =
109
+ portal_records.map do |portal_fields|
110
+ attributes = { id: portal_fields[:recordId].to_i }
111
+ attributes[:mod_id] = portal_fields[:modId].to_i if portal_fields[:modId]
112
+
113
+ prefix = portal_options[:attribute_prefix] || portal_name
114
+ prefix_matcher = /\A#{prefix}::/
115
+
116
+ portal_fields.each do |k, v|
117
+ next if :recordId == k || :modId == k
118
+ attributes[k.to_s.gsub(prefix_matcher, "")] = v
119
+ end
120
+
121
+ attributes
122
+ end
123
+ end
124
+ end
125
+
126
+ def find_results?(url)
127
+ url.path.match(FIND_RECORDS_RE)
128
+ end
129
+
130
+ def single_record_url?(url)
131
+ url.path.match(SINGLE_RECORD_RE)
132
+ end
133
+
134
+ def parse_json(source)
135
+ if defined?(::MultiJson)
136
+ ::MultiJson.load(source, symbolize_keys: true)
137
+ else
138
+ ::JSON.parse(source, symbolize_names: true)
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,23 @@
1
+ require "fmrest/spyke/model/connection"
2
+ require "fmrest/spyke/model/uri"
3
+ require "fmrest/spyke/model/attributes"
4
+ require "fmrest/spyke/model/associations"
5
+ require "fmrest/spyke/model/orm"
6
+
7
+ module FmRest
8
+ module Spyke
9
+ module Model
10
+ extend ::ActiveSupport::Concern
11
+
12
+ include Connection
13
+ include Uri
14
+ include Attributes
15
+ include Associations
16
+ include Orm
17
+
18
+ included do
19
+ attr_accessor :mod_id
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,77 @@
1
+ require "fmrest/spyke/portal"
2
+
3
+ module FmRest
4
+ module Spyke
5
+ module Model
6
+ module Associations
7
+ extend ::ActiveSupport::Concern
8
+
9
+ included do
10
+ # Keep track of portal options by their FM keys as we could need it
11
+ # to parse the portalData JSON in JsonParser
12
+ class_attribute :portal_options, instance_accessor: false,
13
+ instance_predicate: false,
14
+ default: {}.freeze
15
+
16
+ class << self; private :portal_options=; end
17
+ end
18
+
19
+ class_methods do
20
+ # Based on +has_many+, but creates a special Portal association
21
+ # instead.
22
+ #
23
+ # Custom options:
24
+ #
25
+ # * <tt>:portal_key</tt> - The key used for the portal in the FM Data JSON portalData.
26
+ # * <tt>:attribute_prefix</tt> - The prefix used for portal attributes in the FM Data JSON.
27
+ #
28
+ # Example:
29
+ #
30
+ # has_portal :jobs, portal_key: "JobsTable", attribute_prefix: "Job"
31
+ #
32
+ def has_portal(name, options = {})
33
+ create_association(name, Portal, options)
34
+
35
+ # Store options for JsonParser to use if needed
36
+ portal_key = options[:portal_key] || name
37
+ self.portal_options = portal_options.merge(portal_key.to_s => options.dup.merge(name: name.to_s)).freeze
38
+
39
+ define_method "#{name.to_s.singularize}_ids" do
40
+ association(name).map(&:id)
41
+ end
42
+ end
43
+ end
44
+
45
+ # Override Spyke's association reader to keep a cache of loaded
46
+ # portals. Spyke's default behavior is to reload the association
47
+ # each time.
48
+ #
49
+ def association(name)
50
+ @loaded_portals ||= {}
51
+
52
+ if @loaded_portals.has_key?(name.to_sym)
53
+ return @loaded_portals[name.to_sym]
54
+ end
55
+
56
+ super.tap do |assoc|
57
+ next unless assoc.kind_of?(FmRest::Spyke::Portal)
58
+ @loaded_portals[name.to_sym] = assoc
59
+ end
60
+ end
61
+
62
+ def reload
63
+ super.tap { @loaded_portals = nil }
64
+ end
65
+
66
+ def portals
67
+ self.class.associations.each_with_object([]) do |(key, _), portals|
68
+ candidate = association(key)
69
+ next unless candidate.kind_of?(FmRest::Spyke::Portal)
70
+ portals << candidate
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
77
+
@@ -0,0 +1,198 @@
1
+ module FmRest
2
+ module Spyke
3
+ module Model
4
+ module Attributes
5
+ extend ::ActiveSupport::Concern
6
+
7
+ include ::ActiveModel::Dirty
8
+ include ::ActiveModel::ForbiddenAttributesProtection
9
+
10
+ included do
11
+ # Keep mod_id as a separate, custom accessor
12
+ attr_accessor :mod_id
13
+
14
+ # Prevent the creation of plain (no prefix/suffix) attribute methods
15
+ # when calling ActiveModels' define_attribute_method, otherwise it
16
+ # will define an `attribute` method which overrides the one provided
17
+ # by Spyke
18
+ self.attribute_method_matchers.shift
19
+
20
+ # ActiveModel::Dirty methods for id
21
+ define_attribute_method(:id)
22
+
23
+ # Keep track of attribute mappings so we can get the FM field names
24
+ # for changed attributes
25
+ class_attribute :mapped_attributes, instance_writer: false,
26
+ instance_predicate: false,
27
+ default: ::ActiveSupport::HashWithIndifferentAccess.new.freeze
28
+
29
+ class << self; private :mapped_attributes=; end
30
+ end
31
+
32
+ class_methods do
33
+ # Similar to Spyke::Base.attributes, but allows defining attribute
34
+ # methods that map to FM attributes with different names.
35
+ #
36
+ # Example:
37
+ #
38
+ # class Person < Spyke::Base
39
+ # include FmRest::Spyke::Model
40
+ #
41
+ # attributes first_name: "FstName", last_name: "LstName"
42
+ # end
43
+ #
44
+ # p = Person.new
45
+ # p.first_name = "Jojo"
46
+ # p.attributes # => { "FstName" => "Jojo" }
47
+ #
48
+ def attributes(*attrs)
49
+ if attrs.length == 1 && attrs.first.kind_of?(Hash)
50
+ attrs.first.each { |from, to| _fmrest_define_attribute(from, to) }
51
+ else
52
+ attrs.each { |attr| _fmrest_define_attribute(attr, attr) }
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ # Override Spyke::Base.new_or_return (private), called whenever
59
+ # loading records from the HTTP API, so we can reset dirty info on
60
+ # freshly loaded records
61
+ #
62
+ # See: https://github.com/balvig/spyke/blob/master/lib/spyke/http.rb
63
+ #
64
+ def new_or_return(attributes_or_object, *_)
65
+ # In case of an existing Spyke object return it as is so that we
66
+ # don't accidentally remove dirty data from associations
67
+ return super if attributes_or_object.is_a?(::Spyke::Base)
68
+ super.tap { |record| record.clear_changes_information }
69
+ end
70
+
71
+ def _fmrest_attribute_methods_container
72
+ @fmrest_attribute_methods_container ||= Module.new.tap { |mod| include mod }
73
+ end
74
+
75
+ def _fmrest_define_attribute(from, to)
76
+ # We use a setter here instead of injecting the hash key/value pair
77
+ # directly with #[]= so that we don't change the mapped_attributes
78
+ # hash on the parent class. The resulting hash is frozen for the
79
+ # same reason.
80
+ self.mapped_attributes = mapped_attributes.merge(from => to).freeze
81
+
82
+ _fmrest_attribute_methods_container.module_eval do
83
+ define_method(from) do
84
+ attribute(to)
85
+ end
86
+
87
+ define_method(:"#{from}=") do |value|
88
+ send("#{from}_will_change!") unless value == send(from)
89
+ set_attribute(to, value)
90
+ end
91
+ end
92
+
93
+ # Define ActiveModel::Dirty's methods
94
+ define_attribute_method(from)
95
+ end
96
+ end
97
+
98
+ def id=(value)
99
+ id_will_change! unless value == id
100
+ super
101
+ end
102
+
103
+ def save(*_args)
104
+ super.tap do |r|
105
+ next unless r.present?
106
+ changes_applied
107
+ portals.each(&:parent_changes_applied)
108
+ end
109
+ end
110
+
111
+ def reload
112
+ super.tap { |r| clear_changes_information }
113
+ end
114
+
115
+ # Override to_params to return FM Data API's expected JSON format, and
116
+ # including only modified fields
117
+ #
118
+ def to_params
119
+ params = { fieldData: changed_params_not_embedded_in_url }
120
+ params[:modId] = mod_id if mod_id
121
+
122
+ portal_data = serialize_portals
123
+ params[:portalData] = portal_data unless portal_data.empty?
124
+
125
+ params
126
+ end
127
+
128
+ # ActiveModel::Dirty since version 5.2 assumes that if there's an
129
+ # @attributes instance variable set we must be using ActiveRecord, so
130
+ # we override the instance variable name used by Spyke to avoid issues.
131
+ #
132
+ # TODO: Submit a pull request to Spyke so this isn't needed
133
+ #
134
+ def attributes
135
+ @_spyke_attributes
136
+ end
137
+
138
+ # In addition to the comments above on `attributes`, this also adds
139
+ # support for forbidden attributes
140
+ #
141
+ def attributes=(new_attributes)
142
+ @_spyke_attributes ||= ::Spyke::Attributes.new(scope.params)
143
+ use_setters(sanitize_for_mass_assignment(new_attributes)) if new_attributes && !new_attributes.empty?
144
+ end
145
+
146
+ protected
147
+
148
+ def serialize_for_portal(portal)
149
+ params =
150
+ changed_params.except(:id).transform_keys do |key|
151
+ "#{portal.attribute_prefix}::#{key}"
152
+ end
153
+
154
+ params[:recordId] = id if id
155
+ params[:modId] = mod_id if mod_id
156
+
157
+ params
158
+ end
159
+
160
+ private
161
+
162
+ def serialize_portals
163
+ portal_data = {}
164
+
165
+ portals.each do |portal|
166
+ portal.each do |portal_record|
167
+ next unless portal_record.changed?
168
+ portal_params = portal_data[portal.portal_key] ||= []
169
+ portal_params << portal_record.serialize_for_portal(portal)
170
+ end
171
+ end
172
+
173
+ portal_data
174
+ end
175
+
176
+ def changed_params
177
+ attributes.to_params.slice(*mapped_changed)
178
+ end
179
+
180
+ def changed_params_not_embedded_in_url
181
+ params_not_embedded_in_url.slice(*mapped_changed)
182
+ end
183
+
184
+ def mapped_changed
185
+ mapped_attributes.values_at(*changed)
186
+ end
187
+
188
+ # Use known mapped_attributes for inspect
189
+ #
190
+ def inspect_attributes
191
+ mapped_attributes.except(primary_key).map do |k, v|
192
+ "#{k}: #{attribute(v).inspect}"
193
+ end.join(', ')
194
+ end
195
+ end
196
+ end
197
+ end
198
+ end