fmrest-spyke 0.13.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.yardopts +4 -0
- data/CHANGELOG.md +127 -0
- data/LICENSE.txt +21 -0
- data/README.md +523 -0
- data/lib/fmrest-spyke.rb +3 -0
- data/lib/fmrest/spyke.rb +21 -0
- data/lib/fmrest/spyke/base.rb +9 -0
- data/lib/fmrest/spyke/container_field.rb +59 -0
- data/lib/fmrest/spyke/model.rb +33 -0
- data/lib/fmrest/spyke/model/associations.rb +166 -0
- data/lib/fmrest/spyke/model/attributes.rb +159 -0
- data/lib/fmrest/spyke/model/auth.rb +43 -0
- data/lib/fmrest/spyke/model/connection.rb +163 -0
- data/lib/fmrest/spyke/model/container_fields.rb +40 -0
- data/lib/fmrest/spyke/model/global_fields.rb +40 -0
- data/lib/fmrest/spyke/model/http.rb +77 -0
- data/lib/fmrest/spyke/model/orm.rb +256 -0
- data/lib/fmrest/spyke/model/record_id.rb +96 -0
- data/lib/fmrest/spyke/model/serialization.rb +115 -0
- data/lib/fmrest/spyke/model/uri.rb +29 -0
- data/lib/fmrest/spyke/portal.rb +65 -0
- data/lib/fmrest/spyke/relation.rb +359 -0
- data/lib/fmrest/spyke/spyke_formatter.rb +274 -0
- data/lib/fmrest/spyke/validation_error.rb +25 -0
- metadata +96 -0
data/lib/fmrest-spyke.rb
ADDED
data/lib/fmrest/spyke.rb
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
begin
|
4
|
+
require "spyke"
|
5
|
+
rescue LoadError => e
|
6
|
+
e.message << " (Did you include Spyke in your Gemfile?)" unless e.message.frozen?
|
7
|
+
raise e
|
8
|
+
end
|
9
|
+
|
10
|
+
require "fmrest"
|
11
|
+
require "fmrest/spyke/spyke_formatter"
|
12
|
+
require "fmrest/spyke/model"
|
13
|
+
require "fmrest/spyke/base"
|
14
|
+
|
15
|
+
module FmRest
|
16
|
+
module Spyke
|
17
|
+
def self.included(base)
|
18
|
+
base.include Model
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module FmRest
|
4
|
+
module Spyke
|
5
|
+
class ContainerField
|
6
|
+
|
7
|
+
# @return [String] the name of the container field
|
8
|
+
attr_reader :name
|
9
|
+
|
10
|
+
# @param base [FmRest::Spyke::Base] the record this container belongs to
|
11
|
+
# @param name [Symbol] the name of the container field
|
12
|
+
def initialize(base, name)
|
13
|
+
@base = base
|
14
|
+
@name = name
|
15
|
+
end
|
16
|
+
|
17
|
+
# @return [String] the URL for the container
|
18
|
+
def url
|
19
|
+
@base.attributes[name]
|
20
|
+
end
|
21
|
+
|
22
|
+
# @return (see FmRest::V1::ContainerFields#fetch_container_data)
|
23
|
+
def download
|
24
|
+
FmRest::V1.fetch_container_data(url, @base.class.connection)
|
25
|
+
end
|
26
|
+
|
27
|
+
# @param filename_or_io [String, IO] a path to the file to upload or an
|
28
|
+
# IO object
|
29
|
+
# @param options [Hash]
|
30
|
+
# @option options [Integer] :repetition (1) The repetition to pass to the
|
31
|
+
# upload URL
|
32
|
+
# @option (see FmRest::V1::ContainerFields#upload_container_data)
|
33
|
+
def upload(filename_or_io, options = {})
|
34
|
+
raise ArgumentError, "Record needs to be saved before uploading to a container field" unless @base.persisted?
|
35
|
+
|
36
|
+
response =
|
37
|
+
FmRest::V1.upload_container_data(
|
38
|
+
@base.class.connection,
|
39
|
+
upload_path(options[:repetition] || 1),
|
40
|
+
filename_or_io,
|
41
|
+
options
|
42
|
+
)
|
43
|
+
|
44
|
+
# Update mod id on record
|
45
|
+
@base.__mod_id = response.body[:data][:__mod_id]
|
46
|
+
|
47
|
+
true
|
48
|
+
end
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
# @param repetition [Integer]
|
53
|
+
# @return [String] the path for uploading a file to the container
|
54
|
+
def upload_path(repetition)
|
55
|
+
FmRest::V1.container_field_path(@base.class.layout, @base.__record_id, name, repetition)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "fmrest/spyke/model/connection"
|
4
|
+
require "fmrest/spyke/model/uri"
|
5
|
+
require "fmrest/spyke/model/record_id"
|
6
|
+
require "fmrest/spyke/model/attributes"
|
7
|
+
require "fmrest/spyke/model/serialization"
|
8
|
+
require "fmrest/spyke/model/associations"
|
9
|
+
require "fmrest/spyke/model/orm"
|
10
|
+
require "fmrest/spyke/model/container_fields"
|
11
|
+
require "fmrest/spyke/model/global_fields"
|
12
|
+
require "fmrest/spyke/model/http"
|
13
|
+
require "fmrest/spyke/model/auth"
|
14
|
+
|
15
|
+
module FmRest
|
16
|
+
module Spyke
|
17
|
+
module Model
|
18
|
+
extend ::ActiveSupport::Concern
|
19
|
+
|
20
|
+
include Connection
|
21
|
+
include URI
|
22
|
+
include RecordID
|
23
|
+
include Attributes
|
24
|
+
include Serialization
|
25
|
+
include Associations
|
26
|
+
include Orm
|
27
|
+
include ContainerFields
|
28
|
+
include GlobalFields
|
29
|
+
include Http
|
30
|
+
include Auth
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,166 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "fmrest/spyke/portal"
|
4
|
+
|
5
|
+
module FmRest
|
6
|
+
module Spyke
|
7
|
+
module Model
|
8
|
+
# This module adds portal support to Spyke models.
|
9
|
+
#
|
10
|
+
module Associations
|
11
|
+
extend ::ActiveSupport::Concern
|
12
|
+
|
13
|
+
included do
|
14
|
+
# Keep track of portal options by their FM keys as we could need it
|
15
|
+
# to parse the portalData JSON in SpykeFormatter
|
16
|
+
class_attribute :portal_options, instance_accessor: false, instance_predicate: false
|
17
|
+
|
18
|
+
# class_attribute supports a :default option since ActiveSupport 5.2,
|
19
|
+
# but we want to support previous versions too so we set the default
|
20
|
+
# manually instead
|
21
|
+
self.portal_options = {}.freeze
|
22
|
+
|
23
|
+
class << self; private :portal_options=; end
|
24
|
+
|
25
|
+
set_callback :save, :after, :remove_marked_for_destruction
|
26
|
+
end
|
27
|
+
|
28
|
+
class_methods do
|
29
|
+
# Based on Spyke's `has_many`, but creates a special Portal
|
30
|
+
# association instead.
|
31
|
+
#
|
32
|
+
# @option :portal_key [String] The key used for the portal in the FM
|
33
|
+
# Data JSON portalData
|
34
|
+
# @option :attribute_prefix [String] The prefix used for portal
|
35
|
+
# attributes in the FM Data JSON
|
36
|
+
#
|
37
|
+
# @example
|
38
|
+
# class Person < FmRest::Spyke::Base
|
39
|
+
# has_portal :jobs, portal_key: "JobsTable", attribute_prefix: "Job"
|
40
|
+
# end
|
41
|
+
#
|
42
|
+
def has_portal(name, options = {})
|
43
|
+
create_association(name, Portal, options)
|
44
|
+
|
45
|
+
# Store options for SpykeFormatter to use if needed
|
46
|
+
portal_key = options[:portal_key] || name
|
47
|
+
self.portal_options = portal_options.merge(portal_key.to_s => options.dup.merge(name: name.to_s)).freeze
|
48
|
+
|
49
|
+
define_method "#{name.to_s.singularize}_ids" do
|
50
|
+
association(name).map(&:id)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
# Spyke override -- Keep a cache of loaded portals. Spyke's default
|
56
|
+
# behavior is to reload the association each time.
|
57
|
+
#
|
58
|
+
def association(name)
|
59
|
+
@loaded_portals ||= {}
|
60
|
+
|
61
|
+
if @loaded_portals.has_key?(name.to_sym)
|
62
|
+
return @loaded_portals[name.to_sym]
|
63
|
+
end
|
64
|
+
|
65
|
+
super.tap do |assoc|
|
66
|
+
next unless assoc.kind_of?(FmRest::Spyke::Portal)
|
67
|
+
@loaded_portals[name.to_sym] = assoc
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
# Spyke override -- Add portals awareness
|
72
|
+
#
|
73
|
+
def reload(*_)
|
74
|
+
super.tap { @loaded_portals = nil }
|
75
|
+
end
|
76
|
+
|
77
|
+
# @return [Array<FmRest::Spyke::Portal>] A collection of portal
|
78
|
+
# relations for the record
|
79
|
+
#
|
80
|
+
def portals
|
81
|
+
self.class.associations.each_with_object([]) do |(key, _), portals|
|
82
|
+
candidate = association(key)
|
83
|
+
next unless candidate.kind_of?(FmRest::Spyke::Portal)
|
84
|
+
portals << candidate
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
# Signals that this record has been marked for being deleted next time
|
89
|
+
# its parent record is saved (e.g. in a portal association)
|
90
|
+
#
|
91
|
+
# This method is named after ActiveRecord's namesake
|
92
|
+
#
|
93
|
+
def mark_for_destruction
|
94
|
+
@marked_for_destruction = true
|
95
|
+
end
|
96
|
+
alias_method :mark_for_deletion, :mark_for_destruction
|
97
|
+
|
98
|
+
def marked_for_destruction?
|
99
|
+
!!@marked_for_destruction
|
100
|
+
end
|
101
|
+
alias_method :marked_for_deletion?, :marked_for_destruction?
|
102
|
+
|
103
|
+
# Signals that this record has been embedded in a portal so we can make
|
104
|
+
# sure to include it in the next update request
|
105
|
+
#
|
106
|
+
def embedded_in_portal
|
107
|
+
@embedded_in_portal = true
|
108
|
+
end
|
109
|
+
|
110
|
+
def embedded_in_portal?
|
111
|
+
!!@embedded_in_portal
|
112
|
+
end
|
113
|
+
|
114
|
+
# Override ActiveModel::Dirty's method to include clearing
|
115
|
+
# of `@embedded_in_portal` and `@marked_for_destruction`
|
116
|
+
#
|
117
|
+
def changes_applied
|
118
|
+
super
|
119
|
+
@embedded_in_portal = nil
|
120
|
+
@marked_for_destruction = nil
|
121
|
+
end
|
122
|
+
|
123
|
+
# Override ActiveModel::Dirty's method to include awareness
|
124
|
+
# of `@embedded_in_portal`
|
125
|
+
#
|
126
|
+
def changed?
|
127
|
+
super || embedded_in_portal?
|
128
|
+
end
|
129
|
+
|
130
|
+
# Takes care of updating the new portal record's recordIds and modIds.
|
131
|
+
#
|
132
|
+
# Called when saving a record with freshly added portal records, this
|
133
|
+
# method is not meant to be called manually.
|
134
|
+
#
|
135
|
+
# @param [Hash] data The hash containing newPortalData from the DAPI
|
136
|
+
# response
|
137
|
+
def __new_portal_record_info=(data)
|
138
|
+
data.each do |d|
|
139
|
+
table_name = d[:tableName]
|
140
|
+
|
141
|
+
portal_new_records =
|
142
|
+
portals.detect { |p| p.portal_key == table_name }.select { |r| !r.persisted? }
|
143
|
+
|
144
|
+
# The DAPI provides only one recordId for the entire portal in the
|
145
|
+
# newPortalRecordInfo object. This appears to be the recordId of
|
146
|
+
# the last portal record created, so we assume all portal records
|
147
|
+
# coming before it must have sequential recordIds up to the one we
|
148
|
+
# do have.
|
149
|
+
portal_new_records.reverse_each.with_index do |record, i|
|
150
|
+
record.__record_id = d[:recordId].to_i - i
|
151
|
+
|
152
|
+
# New records get a fresh modId
|
153
|
+
record.__mod_id = 0
|
154
|
+
end
|
155
|
+
end
|
156
|
+
end
|
157
|
+
|
158
|
+
private
|
159
|
+
|
160
|
+
def remove_marked_for_destruction
|
161
|
+
portals.each(&:_remove_marked_for_destruction)
|
162
|
+
end
|
163
|
+
end
|
164
|
+
end
|
165
|
+
end
|
166
|
+
end
|
@@ -0,0 +1,159 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "fmrest/spyke/model/orm"
|
4
|
+
|
5
|
+
module FmRest
|
6
|
+
module Spyke
|
7
|
+
module Model
|
8
|
+
# Extends Spyke models with support for mapped attributes,
|
9
|
+
# `ActiveModel::Dirty` and forbidden attributes (e.g. Rails'
|
10
|
+
# `params.permit`).
|
11
|
+
#
|
12
|
+
module Attributes
|
13
|
+
extend ::ActiveSupport::Concern
|
14
|
+
|
15
|
+
include Orm # Needed to extend custom save and reload
|
16
|
+
|
17
|
+
include ::ActiveModel::Dirty
|
18
|
+
include ::ActiveModel::ForbiddenAttributesProtection
|
19
|
+
|
20
|
+
included do
|
21
|
+
# Prevent the creation of plain (no prefix/suffix) attribute methods
|
22
|
+
# when calling ActiveModels' define_attribute_method, otherwise it
|
23
|
+
# will define an `attribute` method which overrides the one provided
|
24
|
+
# by Spyke
|
25
|
+
self.attribute_method_matchers.shift
|
26
|
+
|
27
|
+
# Keep track of attribute mappings so we can get the FM field names
|
28
|
+
# for changed attributes
|
29
|
+
class_attribute :mapped_attributes, instance_writer: false, instance_predicate: false
|
30
|
+
|
31
|
+
# class_attribute supports a :default option since ActiveSupport 5.2,
|
32
|
+
# but we want to support previous versions too so we set the default
|
33
|
+
# manually instead
|
34
|
+
self.mapped_attributes = ::ActiveSupport::HashWithIndifferentAccess.new.freeze
|
35
|
+
|
36
|
+
class << self; private :mapped_attributes=; end
|
37
|
+
|
38
|
+
set_callback :save, :after, :changes_applied_after_save
|
39
|
+
end
|
40
|
+
|
41
|
+
class_methods do
|
42
|
+
# Spyke override
|
43
|
+
#
|
44
|
+
# Similar to Spyke::Base.attributes, but allows defining attribute
|
45
|
+
# methods that map to FM attributes with different names.
|
46
|
+
#
|
47
|
+
# @example
|
48
|
+
#
|
49
|
+
# class Person < Spyke::Base
|
50
|
+
# include FmRest::Spyke::Model
|
51
|
+
#
|
52
|
+
# attributes first_name: "FstName", last_name: "LstName"
|
53
|
+
# end
|
54
|
+
#
|
55
|
+
# p = Person.new
|
56
|
+
# p.first_name = "Jojo"
|
57
|
+
# p.attributes # => { "FstName" => "Jojo" }
|
58
|
+
#
|
59
|
+
def attributes(*attrs)
|
60
|
+
if attrs.length == 1 && attrs.first.kind_of?(Hash)
|
61
|
+
attrs.first.each { |from, to| _fmrest_define_attribute(from, to) }
|
62
|
+
else
|
63
|
+
attrs.each { |attr| _fmrest_define_attribute(attr, attr) }
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
private
|
68
|
+
|
69
|
+
# Spyke override (private)
|
70
|
+
#
|
71
|
+
# Called whenever loading records from the HTTP API, so we can reset
|
72
|
+
# dirty info on freshly loaded records
|
73
|
+
#
|
74
|
+
# See: https://github.com/balvig/spyke/blob/master/lib/spyke/http.rb
|
75
|
+
#
|
76
|
+
def new_or_return(attributes_or_object, *_)
|
77
|
+
# In case of an existing Spyke object return it as is so that we
|
78
|
+
# don't accidentally remove dirty data from associations
|
79
|
+
return super if attributes_or_object.is_a?(::Spyke::Base)
|
80
|
+
super.tap do |record|
|
81
|
+
# In ActiveModel 4.x #clear_changes_information is a private
|
82
|
+
# method, so we need to call it with send() in that case, but
|
83
|
+
# keep calling it normally for AM5+
|
84
|
+
if record.respond_to?(:clear_changes_information)
|
85
|
+
record.clear_changes_information
|
86
|
+
else
|
87
|
+
record.send(:clear_changes_information)
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
def _fmrest_attribute_methods_container
|
93
|
+
@fmrest_attribute_methods_container ||= Module.new.tap { |mod| include mod }
|
94
|
+
end
|
95
|
+
|
96
|
+
def _fmrest_define_attribute(from, to)
|
97
|
+
# We use a setter here instead of injecting the hash key/value pair
|
98
|
+
# directly with #[]= so that we don't change the mapped_attributes
|
99
|
+
# hash on the parent class. The resulting hash is frozen for the
|
100
|
+
# same reason.
|
101
|
+
self.mapped_attributes = mapped_attributes.merge(from => to).freeze
|
102
|
+
|
103
|
+
_fmrest_attribute_methods_container.module_eval do
|
104
|
+
define_method(from) do
|
105
|
+
attribute(to)
|
106
|
+
end
|
107
|
+
|
108
|
+
define_method(:"#{from}=") do |value|
|
109
|
+
send("#{from}_will_change!") unless value == send(from)
|
110
|
+
set_attribute(to, value)
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
# Define ActiveModel::Dirty's methods
|
115
|
+
define_attribute_method(from)
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
# Spyke override -- Adds AM::Dirty support
|
120
|
+
#
|
121
|
+
def reload(*args)
|
122
|
+
super.tap { |r| clear_changes_information }
|
123
|
+
end
|
124
|
+
|
125
|
+
# Spyke override -- Adds support for forbidden attributes (i.e. Rails'
|
126
|
+
# `params.permit`, etc.)
|
127
|
+
#
|
128
|
+
def attributes=(new_attributes)
|
129
|
+
@spyke_attributes ||= ::Spyke::Attributes.new(scope.params)
|
130
|
+
return unless new_attributes && !new_attributes.empty?
|
131
|
+
use_setters(sanitize_for_mass_assignment(new_attributes))
|
132
|
+
end
|
133
|
+
|
134
|
+
private
|
135
|
+
|
136
|
+
def changed_params
|
137
|
+
attributes.to_params.slice(*mapped_changed)
|
138
|
+
end
|
139
|
+
|
140
|
+
def mapped_changed
|
141
|
+
mapped_attributes.values_at(*changed)
|
142
|
+
end
|
143
|
+
|
144
|
+
# Spyke override (private) -- Use known mapped_attributes for inspect
|
145
|
+
#
|
146
|
+
def inspect_attributes
|
147
|
+
mapped_attributes.except(primary_key).map do |k, v|
|
148
|
+
"#{k}: #{attribute(v).inspect}"
|
149
|
+
end.join(', ')
|
150
|
+
end
|
151
|
+
|
152
|
+
def changes_applied_after_save
|
153
|
+
changes_applied
|
154
|
+
portals.each(&:parent_changes_applied)
|
155
|
+
end
|
156
|
+
end
|
157
|
+
end
|
158
|
+
end
|
159
|
+
end
|