acts_as_sdata 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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