acts_as_sdata 1.0.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.
Files changed (79) hide show
  1. data/.gitignore +2 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.textile +200 -0
  4. data/Rakefile +20 -0
  5. data/VERSION +1 -0
  6. data/config/sdata.yml +13 -0
  7. data/config/sdata.yml.example +20 -0
  8. data/config/sdata.yml.tmpl.staging +14 -0
  9. data/generators/acts_as_sdata/acts_as_sdata_generator.rb +9 -0
  10. data/generators/acts_as_sdata/templates/migration.rb +69 -0
  11. data/init.rb +36 -0
  12. data/lib/s_data/active_record_extensions/base.rb +7 -0
  13. data/lib/s_data/active_record_extensions/mixin.rb +157 -0
  14. data/lib/s_data/active_record_extensions/sdata_uuid_mixin.rb +133 -0
  15. data/lib/s_data/atom_extensions/content_mixin.rb +14 -0
  16. data/lib/s_data/atom_extensions/entry_mixin.rb +41 -0
  17. data/lib/s_data/atom_extensions/nodes/digest.rb +48 -0
  18. data/lib/s_data/atom_extensions/nodes/payload.rb +34 -0
  19. data/lib/s_data/atom_extensions/nodes/sync_state.rb +14 -0
  20. data/lib/s_data/conditions_builder.rb +59 -0
  21. data/lib/s_data/controller_mixin.rb +11 -0
  22. data/lib/s_data/controller_mixin/actions.rb +87 -0
  23. data/lib/s_data/controller_mixin/collection_scope.rb +57 -0
  24. data/lib/s_data/controller_mixin/s_data_feed.rb +87 -0
  25. data/lib/s_data/controller_mixin/s_data_instance.rb +35 -0
  26. data/lib/s_data/diagnosis/application_controller_mixin.rb +16 -0
  27. data/lib/s_data/diagnosis/diagnosis.rb +130 -0
  28. data/lib/s_data/diagnosis/diagnosis_mapper.rb +39 -0
  29. data/lib/s_data/exceptions.rb +10 -0
  30. data/lib/s_data/formatting.rb +13 -0
  31. data/lib/s_data/namespace_definitions.rb +19 -0
  32. data/lib/s_data/payload.rb +158 -0
  33. data/lib/s_data/payload_map.rb +0 -0
  34. data/lib/s_data/payload_map/payload_map.rb +136 -0
  35. data/lib/s_data/payload_map/payload_map_hash.rb +39 -0
  36. data/lib/s_data/predicate.rb +31 -0
  37. data/lib/s_data/route_mapper.rb +143 -0
  38. data/lib/s_data/router_mixin.rb +10 -0
  39. data/lib/s_data/sync/controller_mixin.rb +122 -0
  40. data/lib/s_data/sync/sdata_syncing_mixin.rb +17 -0
  41. data/lib/s_data/virtual_base.rb +114 -0
  42. data/test/functional/Rakefile +0 -0
  43. data/test/unit/active_record_mixin/active_record_mixin_spec.rb +20 -0
  44. data/test/unit/active_record_mixin/acts_as_sdata_spec.rb +41 -0
  45. data/test/unit/active_record_mixin/find_by_sdata_instance_id_spec.rb +34 -0
  46. data/test/unit/active_record_mixin/payload_spec.rb +622 -0
  47. data/test/unit/active_record_mixin/to_atom_spec.rb +85 -0
  48. data/test/unit/atom_entry_mixin/atom_entry_mixin_spec.rb +11 -0
  49. data/test/unit/atom_entry_mixin/to_attributes_spec.rb +30 -0
  50. data/test/unit/class_stubs/address.rb +19 -0
  51. data/test/unit/class_stubs/contact.rb +25 -0
  52. data/test/unit/class_stubs/customer.rb +70 -0
  53. data/test/unit/class_stubs/model_base.rb +17 -0
  54. data/test/unit/class_stubs/payload.rb +15 -0
  55. data/test/unit/class_stubs/sd_uuid.rb +28 -0
  56. data/test/unit/class_stubs/user.rb +40 -0
  57. data/test/unit/conditions_builder_spec.rb +54 -0
  58. data/test/unit/controller_mixin/acts_as_sdata_spec.rb +29 -0
  59. data/test/unit/controller_mixin/build_sdata_feed_spec.rb +50 -0
  60. data/test/unit/controller_mixin/controller_mixin_spec.rb +22 -0
  61. data/test/unit/controller_mixin/diagnosis_spec.rb +232 -0
  62. data/test/unit/controller_mixin/sdata_collection_spec.rb +78 -0
  63. data/test/unit/controller_mixin/sdata_create_instance_spec.rb +173 -0
  64. data/test/unit/controller_mixin/sdata_opensearch_and_links_spec.rb +382 -0
  65. data/test/unit/controller_mixin/sdata_scope/linked_model_spec.rb +58 -0
  66. data/test/unit/controller_mixin/sdata_scope/non_linked_model_spec.rb +66 -0
  67. data/test/unit/controller_mixin/sdata_scope/scoping_in_config_spec.rb +64 -0
  68. data/test/unit/controller_mixin/sdata_show_instance_spec.rb +98 -0
  69. data/test/unit/controller_mixin/sdata_update_instance_spec.rb +65 -0
  70. data/test/unit/payload_map/payload_map_hash_spec.rb +84 -0
  71. data/test/unit/payload_map/payload_map_spec.rb +144 -0
  72. data/test/unit/predicate_spec.rb +59 -0
  73. data/test/unit/router_mixin/routes_spec.rb +138 -0
  74. data/test/unit/spec.opts +4 -0
  75. data/test/unit/spec_helper.rb +47 -0
  76. data/test/unit/spec_helpers/nokogiri_extensions.rb +16 -0
  77. data/test/unit/sync_controller_mixin/controller_mixin_spec.rb +22 -0
  78. data/test/unit/sync_controller_mixin/sdata_collection_sync_feed_spec.rb +69 -0
  79. metadata +175 -0
@@ -0,0 +1,133 @@
1
+ module SData
2
+ module ActiveRecordExtensions
3
+ module SdataUuidMixin
4
+
5
+ def acts_as_sdata_uuid
6
+ self.__send__ :extend, UuidClassMethods
7
+ end
8
+
9
+ module UuidClassMethods
10
+
11
+
12
+ def find_by_virtual_model_and_uuid(virtual_model, uuid)
13
+ sd_uuids = SData::SdUuid.find(:all, :conditions => {:sd_class => virtual_model.sdata_name, :bb_model_type => virtual_model.baze_class_name, :uuid => uuid})
14
+
15
+ sd_uuid = enforce_uniqueness(sd_uuids, virtual_model.sdata_name, virtual_model.baze_class_name)
16
+
17
+ raise "#{virtual_model.sdata_name} with UUID '#{uuid}' not found" unless sd_uuid
18
+ virtual_model.build_for(sd_uuid.bb_model)
19
+ end
20
+
21
+ # Handling multiple uuids: when linking and the resource already had a uuid on both sides, one provider wins and
22
+ # stores it's uuid for the resource on the other provider (http://interop.sage.com/daisy/sdataSync/Link/525-DSY.html)
23
+ # At a later date sdata will provide an algorithm for uuid propagation; for the time being we assume the last stored
24
+ # is the correct one.
25
+ def find_for_virtual_instance(virtual_instance, baze=nil)
26
+ baze ||= virtual_instance.baze
27
+ SData::SdUuid.first(:conditions => {:sd_class => virtual_instance.sdata_name,
28
+ :bb_model_type => baze.class.name.demodulize,
29
+ :bb_model_id => baze.id},
30
+ :order => "updated_at DESC" )
31
+ end
32
+
33
+ def enforce_uniqueness(sd_uuids, sd_class, baze_class_name)
34
+ return nil if sd_uuids.nil? || sd_uuids.empty?
35
+ if sd_uuids.count > 1
36
+ RAILS_DEFAULT_LOGGER.fatal("SdUuid uniqueness violation for #{sd_class} - #{baze_class_name} - #{uuid}. Using first created")
37
+ sd_uuids.sort_by!{|sid| sid.updated_at}
38
+ end
39
+ sd_uuids.first
40
+ end
41
+
42
+ # This method is used to respond to a PUT to $linked('uuid'), to change what bb_model instance a uuid points to
43
+ # TODO this method makes no attempt to handle multiple baze_classes
44
+ # RADAR this method also has a RACE, but which should only happen when the provider/linking engine
45
+ # is doing something fubarred
46
+ def reassign_uuid!(uuid, virtual_model, new_id)
47
+ raise "Cannot edit uuid for virtual_model (#{virtual_model.name}) with no fixed baze_class." if virtual_model.baze_class.nil?
48
+ sd_uuids = SData::SdUuid.find(:all, :conditions => {:sd_class => virtual_model.sdata_name, :bb_model_type => virtual_model.baze_class_name, :uuid => uuid})
49
+ sd_uuid = enforce_uniqueness(sd_uuids, virtual_model.sdata_name, virtual_model.baze_class_name)
50
+ sd_uuid.update_attributes!({:bb_model_id => new_id})
51
+ end
52
+
53
+ # TODO: handle case where virtual model depends on multiple models whose updated_at matter.
54
+ # This method can change the uuid of a given underlying BB model. It CANNOT set a uuid for the tuple [sd_class,
55
+ # bb_model_type] for a given bb_model_id if an instance of the tuple [sd_class, bb_model_type] with a different
56
+ # bb_model_id exists. To swap which bb_model_id of a [sd_class, bb_model_type] a uuid points to, use reassign_uuid!.
57
+ # Move the delete case to a separate method, and the controller should determine whether the request is deleting
58
+ # or not (does spec actually say you can delete a uuid by POST ing empty uuid to it? don't you have to do a DELETE?).
59
+ # Because of unique index on [sd_class, bb_model, uuid] this has a race condition and can theoretically throw, but
60
+ # only if a linking engine is mistakenly sending multiple requests rapidly. The controller can just report a
61
+ # generic error.
62
+ def create_or_update_uuid_for(virtual_instance, uuid)
63
+ raise "Cannot create_or_update_uuid_for virtual_model (#{virtual_model.name}) with no fixed baze_class." if virtual_instance.class.baze_class.nil?
64
+ raise "virtual_instance #{virtual_instance.inspect} has no baze" unless virtual_instance.baze
65
+ return if uuid.blank?
66
+
67
+ sd_uuid = SData::SdUuid.find_for_virtual_instance(virtual_instance)
68
+ params = {:sd_class => virtual_instance.sdata_name,
69
+ :bb_model_type => virtual_instance.baze_class_name,
70
+ :bb_model_id => virtual_instance.baze.id,
71
+ :uuid => uuid}
72
+ if sd_uuid
73
+ sd_uuid.update_attributes!(params)
74
+ else
75
+ begin
76
+ SData::SdUuid.create(params)
77
+ rescue ::ActiveRecord::StatementInvalid
78
+ raise Sage::BusinessLogic::Exception::IncompatibleDataException, "UUID already exists for another resource"
79
+ end
80
+ end
81
+ end
82
+
83
+ # return all the bb_records which form the basis of this resource and which have been linked
84
+ # MJ TODO -- check if there is a way to use the bb_model polymorphic assoc to autmatically create this query
85
+ def linked_bb_records(endpoint=nil)
86
+ klasses = baze_classes || [self]
87
+ records = {}
88
+ klasses.each do |klass|
89
+ klassname = klass.name.demodulize
90
+ tablename = klassname.tableize
91
+ conditions = { :bb_model_type => klassname }
92
+ # conditions[:endpoint] = endpoint # we don't need this now
93
+ records[klassname.to_sym] = klass.all(:joins => "INNER JOIN sd_uuids ON sd_uuids.bb_model_id = #{tablename}.id",
94
+ :conditions => {:sd_uuids => conditions})
95
+ end
96
+ records
97
+ end
98
+ end
99
+ end
100
+
101
+ module SdataUuidableMixin
102
+ def has_sdata_uuid
103
+ self.__send__ :include, UuidableInstanceMethods
104
+ end
105
+
106
+ module UuidableInstanceMethods
107
+
108
+ def uuid
109
+ record = sd_uuid
110
+ record ? record.uuid : nil
111
+ end
112
+
113
+ # WARN: don't cache this, it will potentially break things
114
+ # RADAR: This finds the most recently updated of potentially many sd_uuids -- see
115
+ # http://interop.sage.com/daisy/sdataSync/Link/525-DSY.html, linking scenario 3
116
+ def sd_uuid
117
+ SData::SdUuid.find_for_virtual_instance(self)
118
+ end
119
+
120
+ def create_or_update_uuid!(value)
121
+ SData::SdUuid.create_or_update_uuid_for(self, value)
122
+ end
123
+
124
+ def linked?
125
+ !sd_uuid.nil?
126
+ end
127
+
128
+ end
129
+ end
130
+
131
+ ::ActiveRecord::Base.extend SdataUuidMixin
132
+ end
133
+ end
@@ -0,0 +1,14 @@
1
+ module SData
2
+ module AtomExtensions
3
+ module ContentMixin
4
+ class Diagnosis < Atom::Content::Base
5
+ attribute :type, :'xml:lang'
6
+ def to_xml(*params)
7
+ self[0] #magic done in diagnosis.rb
8
+ end
9
+ end
10
+
11
+ end
12
+ end
13
+ end
14
+ Atom::Content.__send__ :include, SData::AtomExtensions::ContentMixin
@@ -0,0 +1,41 @@
1
+ # the following should stay together, to ensure that when adding custom nodes, the namespcaes
2
+ # they are in are available to ratom, or it will silently decide to make the node an Atom::Content
3
+ SData.config[:schemas].each do |prefix, namespace|
4
+ Atom::Feed.add_extension_namespace(prefix.to_s, namespace)
5
+ Atom::Entry.add_extension_namespace(prefix.to_s, namespace)
6
+ end
7
+
8
+
9
+ Atom::Entry.element "sdata:payload",
10
+ :class => SData::AtomExtensions::Nodes::Payload,
11
+ :namespace => SData.config[:schemas][:sdata]
12
+
13
+ #TODO the rest should be done like payload
14
+ Atom::Entry.element :diagnosis, :class => Atom::Content
15
+
16
+ Atom::Entry.element "sync:syncState",
17
+ :class => SData::AtomExtensions::Nodes::SyncState,
18
+ :namespace => SData.config[:schemas][:sync]
19
+ Atom::Feed.element "sync:digest",
20
+ :class => SData::AtomExtensions::Nodes::Digest,
21
+ :namespace => SData.config[:schemas][:sync]
22
+
23
+ module Atom
24
+ class Entry
25
+ def extended_element(element_with_namespace)
26
+ namespace, element = element_with_namespace.split(':')
27
+ self.simple_extensions.keys.each do |key|
28
+ return self.simple_extensions[key][0] if key == "{#{SData.config[:schemas][namespace]},#{element}}"
29
+ end
30
+ nil
31
+ end
32
+
33
+ def to_attributes
34
+ attributes = {}
35
+ self['http://sdata.sage.com/schemes/attributes'].each_pair do |name, values|
36
+ attributes[name] = values.first
37
+ end
38
+ attributes
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,48 @@
1
+ module SData
2
+ module AtomExtensions
3
+ module Nodes
4
+
5
+ class DigestEntry
6
+ include Atom::Xml::Parseable
7
+ add_extension_namespace "sdata", SData.config[:schemas][:sdata]
8
+ add_extension_namespace 'sync', SData.config[:schemas][:sync]
9
+ element "sync:endpoint", :namespace => SData.config[:schemas][:sync]
10
+ element "sync:tick", :namespace => SData.config[:schemas][:sync]
11
+ element "sync:stamp", :namespace => SData.config[:schemas][:sync]
12
+ element "sync:conflictPriority", :namespace => SData.config[:schemas][:sync]
13
+
14
+ def initialize(xml=nil)
15
+ if xml
16
+ # puts "DigestEntry call xml.read"
17
+ xml.read
18
+ parse(xml)
19
+ end
20
+ end
21
+
22
+ def self.parse(xml)
23
+ new(xml)
24
+ end
25
+ end
26
+
27
+ class Digest
28
+ include Atom::Xml::Parseable
29
+ add_extension_namespace "sdata", SData.config[:schemas][:sdata]
30
+ add_extension_namespace 'sync', SData.config[:schemas][:sync]
31
+ elements "sync:digestEntry", :class => SData::AtomExtensions::Nodes::DigestEntry, :namespace => SData.config[:schemas][:sync]
32
+ uri_attribute "sdata:url"
33
+ element "sync:origin", :namespace => SData.config[:schemas][:sync]
34
+
35
+ def initialize(xml=nil)
36
+ xml.read
37
+ parse(xml)
38
+ end
39
+
40
+ def self.parse(xml)
41
+ new(xml)
42
+ end
43
+ end
44
+
45
+
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,34 @@
1
+
2
+ module SData
3
+ module AtomExtensions
4
+ module Nodes
5
+ class Payload
6
+ attr_accessor :raw_xml
7
+ include Atom::Xml::Parseable
8
+ add_extension_namespace "sdata", SData.config[:schemas][:sdata]
9
+ add_extension_namespace 'sync', SData.config[:schemas][:sync]
10
+
11
+ element 'sync:digest', :class => SData::AtomExtensions::Nodes::Digest, :namespace => SData.config[:schemas][:sync]
12
+
13
+ def initialize(xml=nil)
14
+ # temporary, until have time to add Linking atom extension
15
+ @raw_xml = xml.read_inner_xml
16
+ xml.read
17
+ parse(xml)
18
+ end
19
+
20
+ def self.parse(xml)
21
+ new(xml)
22
+ end
23
+
24
+ def to_xml(*params)
25
+ node = XML::Node.new("sdata:payload")
26
+ node << content
27
+ return node
28
+ end
29
+
30
+ end
31
+ end
32
+ end
33
+ end
34
+
@@ -0,0 +1,14 @@
1
+ module SData
2
+ module AtomExtensions
3
+ module Nodes # the reason I didn't name this Atom is that I didn't feel like adding :: in a gawdjillion places ATM
4
+ class SyncState
5
+ def initialize(xml=nil)
6
+ end
7
+
8
+ def self.parse(xml)
9
+ # no need to parse sync states at this time
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,59 @@
1
+ module SData
2
+ class ConditionsBuilder
3
+ SQL_RELATIONS_MAP = {
4
+ :binary => {
5
+ :eq => '%s = ?',
6
+ :ne => '%s <> ?',
7
+ :lt => '%s < ?',
8
+ :lteq => '%s <= ?',
9
+ :gt => '%s > ?',
10
+ :gteq => '%s >= ?'
11
+ },
12
+
13
+ :ternary => {
14
+ :between => '%s BETWEEN ? AND ?'
15
+ }
16
+ }
17
+
18
+ attr_accessor :field, :relation, :values
19
+
20
+ def self.build_conditions(field, relation, *values)
21
+ self.new(field, relation, *values).conditions
22
+ end
23
+
24
+ def initialize(field, relation, *values)
25
+ @field = field
26
+ @relation = relation
27
+ @values = values
28
+ end
29
+
30
+ def conditions
31
+ arguments_invalid? ?
32
+ [] :
33
+ [template_with_field_name] + values
34
+ end
35
+
36
+ protected
37
+
38
+ def arguments_invalid?
39
+ field.nil? or relation.nil? or values.nil? or template.nil?
40
+ end
41
+
42
+ def template_with_field_name
43
+ template % quoted_field_name
44
+ end
45
+
46
+ def quoted_field_name
47
+ ActiveRecord::Base.connection.quote_column_name(@field)
48
+ end
49
+
50
+ def template
51
+ case values.size
52
+ when 1:
53
+ SQL_RELATIONS_MAP[:binary][relation]
54
+ when 2:
55
+ SQL_RELATIONS_MAP[:ternary][relation]
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,11 @@
1
+ module SData
2
+ module ControllerMixin
3
+ def acts_as_sdata(options)
4
+ cattr_accessor :sdata_options
5
+ self.sdata_options = options
6
+ include Actions
7
+ end
8
+ end
9
+ end
10
+
11
+ ActionController::Base.extend SData::ControllerMixin
@@ -0,0 +1,87 @@
1
+ require 'nokogiri'
2
+
3
+ module SData
4
+ module ControllerMixin
5
+ module Actions
6
+ def sdata_collection
7
+ begin
8
+ errors = []
9
+ collection = build_sdata_feed
10
+ sdata_scope.each do |entry|
11
+ begin
12
+ collection.entries << entry.to_atom(params)
13
+ rescue Exception => e
14
+ errors << ApplicationDiagnosis.new(:exception => e).to_xml(:feed)
15
+ end
16
+ end
17
+ #TODO: syntactic sugar if possible (such as diagnosing_errors(&block) which does the dirty work)
18
+ errors.each do |error|
19
+ collection[SData.config[:schemas]['sdata'], 'diagnosis'] << error
20
+ end
21
+ populate_open_search_for(collection)
22
+ build_feed_links_for(collection)
23
+ render :xml => collection, :content_type => "application/atom+xml; type=feed"
24
+ rescue Exception => e
25
+ handle_exception(e)
26
+ end
27
+ end
28
+
29
+ def sdata_show_instance
30
+ begin
31
+ instance = sdata_instance
32
+ assert_access_to instance
33
+ render :xml => instance.to_atom(params), :content_type => "application/atom+xml; type=entry"
34
+ rescue Exception => e
35
+ handle_exception(e)
36
+ end
37
+ end
38
+
39
+ def sdata_create_instance
40
+ raise "not currently supported"
41
+ end
42
+
43
+ def sdata_update_instance
44
+ raise "not currently supported"
45
+ end
46
+
47
+ def sdata_create_link
48
+ begin
49
+ payload_xml = params['entry'].sdata_payload.raw_xml
50
+ payload = Nokogiri::XML(payload_xml).root
51
+ id = payload.attributes['key'].value.to_i
52
+ uuid = payload.attributes['uuid'].value
53
+ instance = model_class.find(id)
54
+ assert_access_to instance
55
+ instance.create_or_update_uuid! uuid
56
+ render :xml => instance.to_atom(params), :content_type => "application/atom+xml; type=entry", :status => "201"
57
+ rescue Exception => e
58
+ handle_exception(e)
59
+ end
60
+ end
61
+
62
+ protected
63
+
64
+ def model_class
65
+ self.class.sdata_options[:model]
66
+ end
67
+
68
+ def assert_access_to(instance)
69
+ raise "Unauthenticated" unless logged_in?
70
+ # Not returning Access Denied on purpose so that users cannot fish for existence of emails or other data.
71
+ # As far as user should be concerned, all requests are scoped to his/her own data.
72
+ # Data which is found but which belongs to someone else should be as good as data that doesn't exist.
73
+ raise Sage::BusinessLogic::Exception::IncompatibleDataException, "Conditions scope must contain exactly one entry" if (instance.owner != target_user)
74
+ end
75
+
76
+
77
+ def handle_exception(exception)
78
+ diagnosis = SData::Diagnosis::DiagnosisMapper.map(exception)
79
+ render :xml => diagnosis.to_xml(:root), :status => diagnosis.http_status_code || 500
80
+ end
81
+
82
+ include SDataInstance
83
+ include SDataFeed
84
+ include CollectionScope
85
+ end
86
+ end
87
+ end