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.
- checksums.yaml +7 -0
- data/.gitignore +25 -0
- data/.rspec +3 -0
- data/.travis.yml +5 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +3 -0
- data/LICENSE.txt +21 -0
- data/README.md +559 -0
- data/Rakefile +6 -0
- data/fmrest.gemspec +33 -0
- data/lib/fmrest.rb +13 -0
- data/lib/fmrest/spyke.rb +11 -0
- data/lib/fmrest/spyke/base.rb +15 -0
- data/lib/fmrest/spyke/json_parser.rb +143 -0
- data/lib/fmrest/spyke/model.rb +23 -0
- data/lib/fmrest/spyke/model/associations.rb +77 -0
- data/lib/fmrest/spyke/model/attributes.rb +198 -0
- data/lib/fmrest/spyke/model/connection.rb +53 -0
- data/lib/fmrest/spyke/model/orm.rb +81 -0
- data/lib/fmrest/spyke/model/uri.rb +28 -0
- data/lib/fmrest/spyke/portal.rb +53 -0
- data/lib/fmrest/spyke/relation.rb +140 -0
- data/lib/fmrest/v1.rb +54 -0
- data/lib/fmrest/v1/token_session.rb +91 -0
- data/lib/fmrest/v1/token_store.rb +6 -0
- data/lib/fmrest/v1/token_store/active_record.rb +73 -0
- data/lib/fmrest/v1/token_store/base.rb +14 -0
- data/lib/fmrest/v1/token_store/memory.rb +26 -0
- data/lib/fmrest/version.rb +3 -0
- metadata +211 -0
data/Rakefile
ADDED
data/fmrest.gemspec
ADDED
@@ -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
|
data/lib/fmrest.rb
ADDED
data/lib/fmrest/spyke.rb
ADDED
@@ -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
|