hootenanny 0.0.1 → 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.
Files changed (112) hide show
  1. data/.gitignore +1 -0
  2. data/.travis.yml +5 -0
  3. data/Gemfile +5 -7
  4. data/Gemfile.lock +46 -28
  5. data/app/controllers/hootenanny/notifications_controller.rb +31 -0
  6. data/app/controllers/hootenanny/parameters.rb +16 -0
  7. data/app/controllers/hootenanny/subscriptions_controller.rb +24 -3
  8. data/app/models/hootenanny/publish_notification.rb +90 -0
  9. data/app/models/hootenanny/subscription.rb +72 -23
  10. data/config/routes.rb +2 -1
  11. data/db/migrate/20130607182642_add_started_at_and_lease_duration_to_subscriptions.rb +15 -0
  12. data/db/migrate/20130608225621_add_hmac_secret_to_subscription.rb +5 -0
  13. data/db/migrate/20130611235218_add_publish_notifications.rb +9 -0
  14. data/db/migrate/20130612153138_add_timestamps_to_publish_notification.rb +5 -0
  15. data/db/migrate/20130705200729_add_processed_flag_to_publish_notifications.rb +6 -0
  16. data/db/migrate/20130711061329_switch_publish_notification_process_state_from_boolean_to_string.rb +11 -0
  17. data/db/migrate/20130711061558_add_index_to_notifications_state.rb +5 -0
  18. data/hootenanny.gemspec +6 -2
  19. data/lib/hootenanny/configuration.rb +43 -0
  20. data/lib/hootenanny/correspondent.rb +44 -0
  21. data/lib/hootenanny/errors.rb +42 -1
  22. data/lib/hootenanny/feed.rb +75 -0
  23. data/lib/hootenanny/feed/atom_feed.rb +18 -0
  24. data/lib/hootenanny/feed/atom_feed_item.rb +8 -0
  25. data/lib/hootenanny/feed/digest_feed.rb +14 -0
  26. data/lib/hootenanny/feed/digest_feed_item.rb +11 -0
  27. data/lib/hootenanny/feed/feed_item.rb +30 -0
  28. data/lib/hootenanny/feed/file.rb +66 -0
  29. data/lib/hootenanny/feed/json_feed.rb +48 -0
  30. data/lib/hootenanny/feed/json_feed_item.rb +8 -0
  31. data/lib/hootenanny/feed/null_feed.rb +27 -0
  32. data/lib/hootenanny/feed/rss_feed.rb +52 -0
  33. data/lib/hootenanny/feed/rss_feed_item.rb +8 -0
  34. data/lib/hootenanny/feed_store.rb +30 -0
  35. data/lib/hootenanny/feed_store/file_feed_store.rb +55 -0
  36. data/lib/hootenanny/feed_store/web_feed_store.rb +42 -0
  37. data/lib/hootenanny/hub.rb +116 -22
  38. data/lib/hootenanny/publish_notification_expiration_policy.rb +17 -0
  39. data/lib/hootenanny/request.rb +40 -0
  40. data/lib/hootenanny/request/publish_notification.rb +94 -0
  41. data/lib/hootenanny/request/subscription.rb +153 -0
  42. data/lib/hootenanny/subscription_delivery.rb +47 -0
  43. data/lib/hootenanny/topic.rb +38 -0
  44. data/lib/hootenanny/topic_synchronizer.rb +71 -0
  45. data/lib/hootenanny/uri.rb +53 -0
  46. data/lib/hootenanny/verification.rb +108 -0
  47. data/lib/hootenanny/version.rb +1 -1
  48. data/spec/dummy/config/database.yml +12 -6
  49. data/spec/dummy/db/migrate/20130607183149_add_started_at_and_lease_duration_to_subscriptions.hootenanny.rb +16 -0
  50. data/spec/dummy/db/migrate/20130608231253_add_hmac_secret_to_subscription.hootenanny.rb +6 -0
  51. data/spec/dummy/db/migrate/20130611235546_add_publish_notifications.hootenanny.rb +10 -0
  52. data/spec/dummy/db/migrate/20130612153353_add_timestamps_to_publish_notification.hootenanny.rb +6 -0
  53. data/spec/dummy/db/migrate/20130705200832_add_processed_flag_to_publish_notifications.hootenanny.rb +7 -0
  54. data/spec/dummy/db/migrate/20130711061518_switch_publish_notification_process_state_from_boolean_to_string.hootenanny.rb +12 -0
  55. data/spec/dummy/db/migrate/20130711061629_add_index_to_notifications_state.hootenanny.rb +6 -0
  56. data/spec/factories/publish_notification.rb +13 -0
  57. data/spec/factories/requests/publish_notification.rb +11 -0
  58. data/spec/factories/requests/subscription.rb +46 -0
  59. data/spec/factories/subscription.rb +14 -2
  60. data/spec/factories/verification.rb +15 -0
  61. data/spec/features/publishers/can_notify_the_hub_of_content_updates_spec.rb +58 -0
  62. data/spec/features/subscribers/are_protected_from_unwarranted_subscriptions_spec.rb +59 -0
  63. data/spec/features/subscribers/can_receive_distributions_of_topic_content_spec.rb +175 -0
  64. data/spec/features/subscribers/can_subscribe_to_a_topic_spec.rb +76 -7
  65. data/spec/fixtures/feeds/atom/97d79220f68b4bf27.atom +0 -0
  66. data/spec/fixtures/feeds/atom/sample_feed.atom +51 -0
  67. data/spec/fixtures/feeds/digest/97d79220f68b4bf27.digest +1 -0
  68. data/spec/fixtures/feeds/digest/complete_broadcasted_items/5b187098da59f077f/97d79220f68b4bf27.digest +6 -0
  69. data/spec/fixtures/feeds/digest/incomplete_broadcasted_items/5b187098da59f077f/97d79220f68b4bf27.digest +5 -0
  70. data/spec/fixtures/feeds/digest/sample_feed.digest +6 -0
  71. data/spec/fixtures/feeds/json/97d79220f68b4bf27.json +1 -0
  72. data/spec/fixtures/feeds/json/feed_with_one_item.json +21 -0
  73. data/spec/fixtures/feeds/json/sample_feed.json +30 -0
  74. data/spec/fixtures/feeds/rss/5b187098da59f077f/97d79220f68b4bf27.rss +21 -0
  75. data/spec/fixtures/feeds/rss/97d79220f68b4bf27.rss +0 -0
  76. data/spec/fixtures/feeds/rss/feed_with_one_item.rss +15 -0
  77. data/spec/fixtures/feeds/rss/minimal_feed.rss +12 -0
  78. data/spec/fixtures/feeds/rss/sample_feed.rss +22 -0
  79. data/spec/fixtures/feeds/rss/sample_feed_2.rss +22 -0
  80. data/spec/lib/hootenanny/configuration_spec.rb +7 -0
  81. data/spec/lib/hootenanny/correspondent_spec.rb +94 -0
  82. data/spec/lib/hootenanny/errors_spec.rb +21 -0
  83. data/spec/lib/hootenanny/feed/atom_feed_item_spec.rb +9 -0
  84. data/spec/lib/hootenanny/feed/atom_feed_spec.rb +40 -0
  85. data/spec/lib/hootenanny/feed/digest_feed_item_spec.rb +9 -0
  86. data/spec/lib/hootenanny/feed/digest_feed_spec.rb +40 -0
  87. data/spec/lib/hootenanny/feed/feed_item_spec.rb +49 -0
  88. data/spec/lib/hootenanny/feed/file_spec.rb +66 -0
  89. data/spec/lib/hootenanny/feed/json_feed_item_spec.rb +9 -0
  90. data/spec/lib/hootenanny/feed/json_feed_spec.rb +128 -0
  91. data/spec/lib/hootenanny/feed/null_feed_spec.rb +9 -0
  92. data/spec/lib/hootenanny/feed/rss_feed_item_spec.rb +9 -0
  93. data/spec/lib/hootenanny/feed/rss_feed_spec.rb +143 -0
  94. data/spec/lib/hootenanny/feed_spec.rb +159 -0
  95. data/spec/lib/hootenanny/feed_store/file_feed_store_spec.rb +58 -0
  96. data/spec/lib/hootenanny/feed_store/web_feed_store_spec.rb +47 -0
  97. data/spec/lib/hootenanny/feed_store_spec.rb +27 -0
  98. data/spec/lib/hootenanny/hub_spec.rb +73 -0
  99. data/spec/lib/hootenanny/publish_notification_expiration_policy_spec.rb +35 -0
  100. data/spec/lib/hootenanny/request/publish_notification_spec.rb +43 -0
  101. data/spec/lib/hootenanny/request/subscription_spec.rb +89 -0
  102. data/spec/lib/hootenanny/request_spec.rb +21 -0
  103. data/spec/lib/hootenanny/subscription_delivery_spec.rb +54 -0
  104. data/spec/lib/hootenanny/topic_spec.rb +15 -0
  105. data/spec/lib/hootenanny/topic_synchronizer_spec.rb +98 -0
  106. data/spec/lib/hootenanny/uri_spec.rb +32 -0
  107. data/spec/lib/hootenanny/verification_spec.rb +92 -0
  108. data/spec/models/hootenanny/publish_notification_spec.rb +55 -0
  109. data/spec/models/hootenanny/subscription_spec.rb +58 -19
  110. data/spec/support/verification.rb +63 -0
  111. metadata +231 -14
  112. data/spec/controllers/hootenanny/hub_spec.rb +0 -15
@@ -0,0 +1,40 @@
1
+ require 'active_support/inflector'
2
+ require 'hootenanny/request/subscription'
3
+ require 'hootenanny/request/publish_notification'
4
+
5
+ module Hootenanny
6
+ class Request
7
+ attr_accessor :type
8
+
9
+ ###
10
+ # Private: Builds a specific request object based on the options passed in.
11
+ #
12
+ # options - A Hash of options representing the request that will be built. The
13
+ # only option that is required for _this_ method is :type, however
14
+ # depending on the class that is to be built, it may require other
15
+ # options. See each class in turn for details.
16
+ #
17
+ # :type - The type of request that should be built. Currently, valid
18
+ # opitons are:
19
+ #
20
+ # * :subscription
21
+ #
22
+ # Examples:
23
+ #
24
+ # Hootenanny::Request.build(type: :subscription,
25
+ # callback: 'http://example.com',)
26
+ # # => <Hootenanny::Request::Subscription>
27
+ #
28
+ # Returns an indeterminate type of object based on the :type option
29
+ # Raises Hootenanny::Request::BuildError if there is a problem
30
+ #
31
+ def self.build(options = {})
32
+ type = options.fetch(:type).to_s.camelize
33
+ klass = "Hootenanny::Request::#{type}".constantize
34
+
35
+ klass.build options
36
+ rescue KeyError => e
37
+ raise Hootenanny::Request::BuildError.wrap(e)
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,94 @@
1
+ require 'active_support/core_ext/array/wrap'
2
+ require 'hootenanny/request'
3
+ require 'hootenanny/publish_notification'
4
+
5
+ module Hootenanny
6
+ class Request
7
+ class PublishNotification < Hootenanny::Request
8
+
9
+ attr_reader :topics
10
+
11
+ def initialize(options = {})
12
+ self.topics = options.fetch(:topics)
13
+ rescue KeyError => e
14
+ raise Hootenanny::Request::BuildError.wrap(e)
15
+ end
16
+
17
+ ###
18
+ # Private: Builds a publication request object based on the options passed in.
19
+ #
20
+ # options - A Hash of options representing the publish notification request
21
+ # that will be built.
22
+ #
23
+ # :topics - A String or Array representing the topics which have
24
+ # been proposed to have been updated.
25
+ #
26
+ # Examples:
27
+ #
28
+ # # When more than one topic is passed in
29
+ #
30
+ # Hootenanny::Request::PublishNotification.build(topics: [
31
+ # 'http://another.org',
32
+ # 'http://other.com'
33
+ # ])
34
+ # # => <Hootenanny::Request::PublishNotification topics: [
35
+ # 'http://another.org',
36
+ # 'http://other.com'
37
+ # ]>
38
+ #
39
+ #
40
+ # # When only one topic is passed in
41
+ #
42
+ # Hootenanny::Request::PublishNotification.build(topics: 'http://another.org')
43
+ # # => <Hootenanny::Request::Publish topics: ['http://another.org']>
44
+ #
45
+ # Returns a Hootenanny::Request::PublishNotification
46
+ # Raises Hootenanny::Request::BuildError if there is a problem
47
+ #
48
+ def self.build(options = {})
49
+ options = normalize_build_options(options)
50
+ request = allocate
51
+
52
+ request.send(:initialize, options)
53
+
54
+ request
55
+ end
56
+
57
+ ###
58
+ # Private: Generates one ore more (one for each topic in the request) records
59
+ # of the publish notification to be consumed at a later time by the hub. If
60
+ # the hub has already been notified of the content update, a second item is
61
+ # not created but rather the original one is retrieved and returned.
62
+ #
63
+ # Example:
64
+ #
65
+ # Hootenanny::Request::PublishNotification.build(topics: 'http://another.org')
66
+ #
67
+ # publish_notification_request.apply
68
+ #
69
+ # # => <Hootenanny::PublishNotification topic: 'http://another.org'>
70
+ #
71
+ # Returns a Hootenanny::PublishNotification if the request is verified
72
+ # Raises Hootenanny::PublishNotificationError if there is a problem
73
+ #
74
+ def apply
75
+ topics.map do |topic|
76
+ Hootenanny::PublishNotification.notify(topic)
77
+ end
78
+ end
79
+
80
+ protected
81
+
82
+ attr_writer :topics
83
+
84
+ private
85
+
86
+ def self.normalize_build_options(options)
87
+ options[:topics] = options[:url] if options[:url]
88
+ options[:topics] = Array.wrap(options[:topics]) if options[:topics]
89
+
90
+ options
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,153 @@
1
+ require 'hootenanny/request'
2
+ require 'hootenanny/subscription'
3
+ require 'hootenanny/verification'
4
+
5
+ module Hootenanny
6
+ class Request
7
+ class Subscription < Hootenanny::Request
8
+
9
+ attr_reader :subscriber,
10
+ :topic,
11
+ :lease_duration,
12
+ :requester_token,
13
+ :digest_secret,
14
+ :verification_class
15
+
16
+ def initialize(options = {})
17
+ self.subscriber = options.fetch(:subscriber)
18
+ self.topic = options.fetch(:topic)
19
+ self.lease_duration = options.fetch(:lease_duration)
20
+ self.requester_token = options.fetch(:requester_token, nil)
21
+ self.digest_secret = options.fetch(:secret, nil)
22
+ self.verification_class = options.fetch(:verification_class,
23
+ Hootenanny::Verification)
24
+ rescue KeyError => e
25
+ raise Hootenanny::Request::BuildError.wrap(e)
26
+ end
27
+
28
+ ###
29
+ # Private: Builds a subscription request object based on the options passed
30
+ # in.
31
+ #
32
+ # options - A Hash of options representing the subscription request that will
33
+ # be built.
34
+ #
35
+ # :subscriber - A String or Ruby URI representing the URI which
36
+ # the topic updates will be sent to.
37
+ # :topic - A String or Ruby URI representing the URI which
38
+ # publish the activities for a given topic.
39
+ # :requester_token - A String token sent from the requester which
40
+ # they can use to identify the request on their
41
+ # system (optional).
42
+ # :digest_secret - A String token of no more than 200 bytes which
43
+ # will be used during content distribution so
44
+ # that the subscriber can verify that the request
45
+ # is from the hub and not a undesired 3rd party
46
+ # (optional).
47
+ # :lease_duration - A Number representing the amount of time after
48
+ # the subscription is created during which time
49
+ # the subscription will be active. This is
50
+ # a _request_ and may be overridden (optional).
51
+ #
52
+ # Examples:
53
+ #
54
+ # Hootenanny::Request::Subscription.build(callback: 'http://example.com',
55
+ # topic: 'http://another.org')
56
+ #
57
+ # # => <Hootenanny::Request::Subscription subscriber: 'http://example.com',
58
+ # topic: 'http://another.org'>
59
+ #
60
+ # Returns a Hootenanny::Request::Subscription
61
+ # Raises Hootenanny::Request::BuildError if there is a problem
62
+ #
63
+ def self.build(options = {})
64
+ options = normalize_build_options(options)
65
+ request = allocate
66
+
67
+ request.send(:initialize, options)
68
+
69
+ request
70
+ end
71
+
72
+ ###
73
+ # Private: Applies the subscription that the request is asking for. It does
74
+ # this in an idempotent way. If the subscription being requested already
75
+ # exists, it is not modified but is instead retrieved and returned.
76
+ #
77
+ # The request itself must be verified prior to the request being applied. An
78
+ # unverifiable request will raise a Hootenanny::SubscriptionError.
79
+ #
80
+ # Example:
81
+ #
82
+ # # If the request for the subscription is verified
83
+ #
84
+ # subscription_request = Hootenanny::Request::Subscription.build(
85
+ # callback: 'http://example.com',
86
+ # topic: 'http://another.org')
87
+ #
88
+ # subscription_request.apply
89
+ #
90
+ # # => <Hootenanny::Subscription subscriber: 'http://example.com',
91
+ # topic: 'http://another.org'>
92
+ #
93
+ #
94
+ #
95
+ # # If the request for the subscription is unverifiable
96
+ #
97
+ # subscription_request = Hootenanny::Request::Subscription.build(
98
+ # callback: 'http://iamahacker.com',
99
+ # topic: 'http://thisdoesnotexist.org')
100
+ #
101
+ # subscription_request.apply
102
+ #
103
+ # # => <Hootenanny::SubscriptionError>
104
+ #
105
+ # Returns a Hootenanny::Subscription if the request is verified
106
+ # Raises Hootenanny::SubscriptionError if there is a problem
107
+ #
108
+ def apply
109
+ raise Hootenanny::SubscriptionError.new('Request could not be verified.') unless verified?
110
+
111
+ Hootenanny::Subscription.subscribe( subscriber: subscriber,
112
+ to: topic,
113
+ lease_duration: lease_duration,
114
+ digest_secret: digest_secret)
115
+ end
116
+
117
+ def verified?
118
+ verification.verified?
119
+ end
120
+
121
+ def verification
122
+ @verification ||= verification_class.new(
123
+ url: subscriber,
124
+ mode: :subscribe,
125
+ topic: topic,
126
+ requester_token: requester_token,
127
+ )
128
+ end
129
+
130
+ protected
131
+
132
+ attr_writer :subscriber,
133
+ :topic,
134
+ :lease_duration,
135
+ :requester_token,
136
+ :digest_secret,
137
+ :verification_class
138
+
139
+ private
140
+
141
+ def self.normalize_build_options(options)
142
+ options[:subscriber] = options[:callback] if options.has_key?(:callback)
143
+ options[:lease_duration] = options[:lease_seconds] if options.has_key?(:lease_seconds)
144
+
145
+ options[:lease_duration] = options[:lease_duration] ?
146
+ options[:lease_duration].to_i :
147
+ 1_577_880_000 # 50 Year Duration = 60 * 60 * 24 * 365.25 * 50
148
+
149
+ options
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,47 @@
1
+ require 'faraday'
2
+ require 'digest'
3
+
4
+ module Hootenanny
5
+ class SubscriptionDelivery
6
+ def initialize(options = {})
7
+ self.subscriber_url = options.fetch(:subscriber)
8
+ self.digest_secret = options.fetch(:digest_secret, nil)
9
+ self.feed = options.fetch(:feed)
10
+ end
11
+
12
+ def self.deliver(options = {})
13
+ delivery = allocate
14
+ delivery.send(:initialize, options)
15
+
16
+ delivery.deliver
17
+ end
18
+
19
+ def deliver
20
+ self.response = Faraday.post subscriber_url.to_s do |request|
21
+ request.headers['Content-Type'] = feed.content_type
22
+ request.headers['X-Hub-Signature'] = "sha1=#{signature}" unless signature == ''
23
+ request.body = feed.to_s
24
+ end
25
+
26
+ self.successful = response.success?
27
+ end
28
+
29
+ def successful?
30
+ successful
31
+ end
32
+
33
+ def signature
34
+ return '' if digest_secret == '' or digest_secret.nil?
35
+
36
+ @signature ||= OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('SHA'), digest_secret, feed.to_s)
37
+ end
38
+
39
+ protected
40
+
41
+ attr_accessor :subscriber_url,
42
+ :digest_secret,
43
+ :feed,
44
+ :response,
45
+ :successful
46
+ end
47
+ end
@@ -0,0 +1,38 @@
1
+ require 'hootenanny/uri'
2
+
3
+ module Hootenanny
4
+ class Topic
5
+
6
+ attr_reader :url
7
+
8
+ def initialize(options = {})
9
+ self.url = Hootenanny::URI.parse(options.fetch(:url))
10
+ end
11
+
12
+ ###
13
+ # Private: Creates a topic from a String representing the URL of the topic
14
+ #
15
+ # url - A String representing the URL of the topic
16
+ #
17
+ # Examples:
18
+ #
19
+ # Hootenanny::Topic.from_url('http://example.com')
20
+ #
21
+ # Returns a Hootenanny::Topic
22
+ # Raises a Hootenanny::URI::InvalidError if the URI cannot be parsed
23
+ # Raises a Hootenanny::URI::InvalidSchemeError if the URI is not either HTTP
24
+ # or HTTPS
25
+ #
26
+ def self.from_url(url, options = {})
27
+ topic = allocate
28
+
29
+ topic.send(:initialize, options.merge(:url => url))
30
+
31
+ topic
32
+ end
33
+
34
+ protected
35
+
36
+ attr_writer :url
37
+ end
38
+ end
@@ -0,0 +1,71 @@
1
+ require 'hootenanny/configuration'
2
+
3
+ module Hootenanny
4
+ class TopicSynchronizer
5
+ def initialize(options = {})
6
+ self.subscriber_url = options.fetch(:subscriber)
7
+ self.topic_url = options.fetch(:topic)
8
+ self.digest_secret = options.fetch(:digest_secret, nil)
9
+
10
+ self.local_feed_store = options[:local_feed_store] ||
11
+ Hootenanny.config.default_local_feed_store
12
+ self.remote_feed_store = options[:remote_feed_store] ||
13
+ Hootenanny.config.default_remote_feed_store
14
+ self.subscription_delivery = options[:subscription_delivery] ||
15
+ Hootenanny.config.default_subscription_delivery
16
+ end
17
+
18
+ def self.sync(options = {})
19
+ self.new(options).sync
20
+ end
21
+
22
+ def sync
23
+ return true if unsynchronized_feed.empty?
24
+
25
+ if subscription_delivery.deliver( feed: unsynchronized_feed,
26
+ url: topic_url,
27
+ subscriber: subscriber_url)
28
+
29
+ local_feed_store.store( feed: remote_feed.to_digest_feed,
30
+ url: topic_url,
31
+ type: 'digest',
32
+ path: subscriber_url.to_digest)
33
+ else
34
+ false
35
+ end
36
+ end
37
+
38
+ protected
39
+
40
+ attr_accessor :subscriber_url,
41
+ :topic_url,
42
+ :digest_secret,
43
+ :local_feed_store,
44
+ :remote_feed_store,
45
+ :subscription_delivery
46
+
47
+ private
48
+
49
+ def unsynchronized_feed
50
+ remote_feed - local_feed
51
+ end
52
+
53
+ def subscriber_url=(other)
54
+ @subscriber_url = Hootenanny::URI.parse(other)
55
+ end
56
+
57
+ def topic_url=(other)
58
+ @topic_url = Hootenanny::URI.parse(other)
59
+ end
60
+
61
+ def local_feed
62
+ local_feed_store.fetch(url: topic_url,
63
+ path: subscriber_url.to_digest)
64
+ end
65
+
66
+ def remote_feed
67
+ remote_feed_store.fetch(url: topic_url,
68
+ path: subscriber_url.to_digest)
69
+ end
70
+ end
71
+ end