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,48 @@
1
+ require 'json'
2
+ require 'hootenanny/feed'
3
+ require 'hootenanny/feed/json_feed_item'
4
+
5
+ module Hootenanny
6
+ class Feed
7
+ class JSONFeed < ::Hootenanny::Feed
8
+
9
+ def items
10
+ @items ||= content.fetch('items', []).map { |i| Hootenanny::Feed::JSONFeedItem.new i }
11
+ end
12
+
13
+ def content_type
14
+ 'application/json'
15
+ end
16
+
17
+ def to_s
18
+ @to_s ||= -> do
19
+ content['items'] = items.map(&:content)
20
+
21
+ JSON.dump(content)
22
+ end.call
23
+ end
24
+
25
+ def content
26
+ @content
27
+ end
28
+
29
+ def content=(content)
30
+ @content = coerce_content(content)
31
+ rescue JSON::ParserError => e
32
+ raise Hootenanny::Feed::ParseError.wrap(e)
33
+ end
34
+
35
+ private
36
+
37
+ def coerce_content(content)
38
+ if content.is_a? String
39
+ JSON.parse(content)
40
+ elsif content.is_a? Hash
41
+ content.inject({}) { |hash, pair| hash[pair[0].to_s] = pair[1]; hash }
42
+ else
43
+ raise JSON::ParserError.new("Do not know how to parse '#{content}'")
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,8 @@
1
+ require 'hootenanny/feed/feed_item'
2
+
3
+ module Hootenanny
4
+ class Feed
5
+ class JSONFeedItem < ::Hootenanny::Feed::FeedItem
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,27 @@
1
+ module Hootenanny
2
+ class Feed
3
+ class NullFeed < ::Hootenanny::Feed
4
+
5
+ def items
6
+ []
7
+ end
8
+
9
+ def content_type
10
+ ''
11
+ end
12
+
13
+ def -(other)
14
+ other
15
+ end
16
+
17
+ def +(other)
18
+ other
19
+ end
20
+
21
+ protected
22
+
23
+ def content=(content)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,52 @@
1
+ require 'rss'
2
+ require 'hootenanny/feed'
3
+ require 'hootenanny/feed/rss_feed_item'
4
+
5
+ module Hootenanny
6
+ class Feed
7
+ class RSSFeed < ::Hootenanny::Feed
8
+
9
+ def items
10
+ @items ||= content.items.map { |i| Hootenanny::Feed::RSSFeedItem.new i }
11
+ end
12
+
13
+ def content_type
14
+ 'application/rss+xml'
15
+ end
16
+
17
+ def to_s
18
+ @to_s ||= -> do
19
+ item_string = items.map { |item| item.content.to_s }.join('')
20
+
21
+ feed_string = content.to_s
22
+ feed_string.gsub!(%r{<item>.*</item>}m, item_string)
23
+
24
+ feed_string
25
+ end.call
26
+ end
27
+
28
+ def content
29
+ @content
30
+ end
31
+
32
+ def content=(content)
33
+ @content = coerce_content(content)
34
+ rescue RSS::Error => e
35
+ raise Hootenanny::Feed::ParseError.wrap(e)
36
+ end
37
+
38
+ private
39
+
40
+ def coerce_content(content)
41
+ if content.is_a? String
42
+ RSS::Parser.parse(content) ||
43
+ RSS::Rss.new('2.0')
44
+ elsif content.is_a? RSS::Rss
45
+ content
46
+ else
47
+ raise RSS::Error.new("Do not know how to parse '#{content}'")
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,8 @@
1
+ require 'hootenanny/feed/feed_item'
2
+
3
+ module Hootenanny
4
+ class Feed
5
+ class RSSFeedItem < ::Hootenanny::Feed::FeedItem
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,30 @@
1
+ require 'active_support/inflector'
2
+ require 'hootenanny/feed_store/file_feed_store'
3
+ require 'hootenanny/feed_store/web_feed_store'
4
+
5
+ module Hootenanny
6
+ class FeedStore
7
+
8
+ def self.fetch(url, options = {})
9
+ storage_type = options.fetch(:location, :web)
10
+ feed_store = infer(type: storage_type)
11
+
12
+ feed_store.fetch(url)
13
+ end
14
+
15
+ def self.infer(options = {})
16
+ storage_type = options.fetch(:type)
17
+ storage_class = from_type(storage_type)
18
+
19
+ storage_class.new(options)
20
+ rescue KeyError => e
21
+ raise Hootenanny::FeedStore::InferenceError.wrap(e)
22
+ end
23
+
24
+ def self.from_type(type)
25
+ "Hootenanny::FeedStore::#{type.to_s.camelize}FeedStore".constantize
26
+ rescue NameError
27
+ raise Hootenanny::FeedStore::UnknownStorageTypeError
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,55 @@
1
+ require 'faraday'
2
+ require 'hootenanny/uri'
3
+ require 'hootenanny/feed'
4
+ require 'hootenanny/feed/file'
5
+
6
+ module Hootenanny
7
+ class FeedStore
8
+ class FileFeedStore
9
+ def initialize(options = {})
10
+ self.feed_file = options.fetch(:location)
11
+ end
12
+
13
+ def fetch(options = {})
14
+ self.url = options.fetch(:url)
15
+ feed_file = self.feed_file << options[:path] << url_file_glob
16
+
17
+ Hootenanny::Feed.infer(feed_file.read,
18
+ type: feed_file.type)
19
+ end
20
+
21
+ def store(options = {})
22
+ self.url = options.fetch(:url)
23
+ feed = options.fetch(:feed)
24
+ feed_type = feed.type
25
+
26
+ feed_file = self.feed_file << options[:path] << "#{url}.#{feed_type}"
27
+
28
+ feed_file.write(feed)
29
+ end
30
+
31
+ protected
32
+
33
+ attr_reader :feed_file,
34
+ :url
35
+
36
+ private
37
+
38
+ def url_file_glob
39
+ if @url.nil? || @url.to_s == ''
40
+ '*.*'
41
+ else
42
+ "#{@url}.*"
43
+ end
44
+ end
45
+
46
+ def url=(other_url)
47
+ @url = Hootenanny::URI.parse(other_url.to_s).to_digest
48
+ end
49
+
50
+ def feed_file=(location)
51
+ @feed_file = Hootenanny::Feed::File.new(location)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,42 @@
1
+ require 'faraday'
2
+ require 'hootenanny/feed'
3
+
4
+ module Hootenanny
5
+ class FeedStore
6
+ class WebFeedStore
7
+ CONTENT_TYPE_MAPPINGS = {
8
+ 'application/rss+xml' => 'RSS',
9
+ 'application/atom+xml' => 'Atom',
10
+ 'application/json' => 'JSON',
11
+ }
12
+
13
+ def initialize(options = {})
14
+ end
15
+
16
+ def fetch(options = {})
17
+ self.url = options.fetch(:url)
18
+
19
+ Hootenanny::Feed.infer(content,
20
+ type: feed_serialization_type)
21
+ end
22
+
23
+ protected
24
+
25
+ attr_accessor :url
26
+
27
+ private
28
+
29
+ def content
30
+ response.body
31
+ end
32
+
33
+ def feed_serialization_type
34
+ CONTENT_TYPE_MAPPINGS.fetch(response.env[:response_headers][:content_type])
35
+ end
36
+
37
+ def response
38
+ @response ||= Faraday.get(url.to_s)
39
+ end
40
+ end
41
+ end
42
+ end
@@ -1,41 +1,135 @@
1
- require 'hootenanny/subscription'
1
+ require 'hootenanny/request'
2
+ require 'hootenanny/correspondent'
3
+ require 'hootenanny/publish_notification'
4
+ require 'hootenanny/publish_notification_expiration_policy'
2
5
 
6
+ ###
7
+ # Public: The class that provides the interface for all of the PubSubHubbub
8
+ # server functionaliity.
9
+ #
10
+ # ## Subscribing to a Feed ##
11
+ #
12
+ # In order to subscribe to a topic URI, the subscription request must be passed
13
+ # in. This object contains all of the information necessary to actually apply
14
+ # the subscription between the subscriber URI and the topic URI.
15
+ #
16
+ # ```ruby
17
+ # request = Hootenanny::Hub.request(request_attribute_hash)
18
+ #
19
+ # Hootenanny::Hub.subscribe(request)
20
+ # ```
21
+ #
22
+ # ## Generating a Request ##
23
+ #
24
+ # Before an action may be taken, the proper request must be generated from all
25
+ # of the disparate information. Once generated, it can be passed to other
26
+ # methods on the class to perform that action.
27
+ #
28
+ # `:type` and `:action` are required options for all requests, other requests
29
+ # may have additional required options, depending on the `:type` passed in. For
30
+ # example, a subscription request requires a `:callback` and an `:action`.
31
+ #
32
+ # ```ruby
33
+ # Hootenanny::Hub.request(type: :subscription,
34
+ # action: :create,
35
+ # callback: 'http://example.com/callback',
36
+ # topic: 'http://example.org/topic')
37
+ # ```
38
+ #
39
+ # ## Notifying of a Content Update
40
+ #
41
+ # In order to remove the need for the hub to have to poll each of its
42
+ # publishers, each publisher can create a notification on the hub so that the
43
+ # hub can handle disseminating that information to its various subscribers.
44
+ #
45
+ # ```ruby
46
+ # Hootenanny::Hub.notify_of_publication(url: 'http://mytopic')
47
+ # ```
48
+ #
3
49
  module Hootenanny
4
50
  class Hub
5
51
 
6
52
  ###
7
- # Public: Subscribes the given callback to the given topic. Whenever the
8
- # given topic is updated, the hub will send all updates to the given callback
9
- # URL.
53
+ # Public: Subscribes a subscriber to a topic based on the information defined
54
+ # in a subscription request.
10
55
  #
11
56
  # This method is idempotent. If a subscription already exists, it will be
12
57
  # reused and sent back without notifying the user of that fact.
13
58
  #
14
- # callback - A String or URI representing the callback URI which should be
15
- # sent updates to the given topic. Whenever changes are discovered
16
- # to the topic URL's feed (see below), those changes will be sent
17
- # to this callback URI.
18
- # options - A Hash of options
59
+ # If the subscription does not already exist, it will be verified against the
60
+ # callback URL. If all is ok, a subscription will be created, if not, an
61
+ # error with details will be returned.
19
62
  #
20
- # :to - The topic which should be subscribed to. This should be
21
- # a String or URI representing the URI which will deliver the
22
- # activities for a given topic/item/resource.
63
+ # request - Any object which responds to #apply. Typically this should be
64
+ # a Hootenanny::Request::Subscription.
23
65
  #
24
- # For example if I have a site which handles blogs, I may
25
- # expose a feed for all of the activity on the blog at:
66
+ # Returns an indeterminate object (based on the request passed in) but is
67
+ # typically a Hootenanny::Subscription.
68
+ # Raises Hootenanny::SubscriptionAssignmentError with a message describing the
69
+ # problem
70
+ #
71
+ def self.subscribe(request)
72
+ request.apply
73
+ end
74
+
75
+ ###
76
+ # Public: Notifies the hub that topic has had its content updated since the
77
+ # last time the content was pulled.
26
78
  #
27
- # http://www.example.com/activities/blog/123
79
+ # This method is idempotent. If a notification already exists, it will be
80
+ # reused and sent back without notifying the user of that fact.
28
81
  #
29
- # Returns a Hootenanny::Subscription
30
- # Raises Hootenanny::SubscriptionAssignmentError with a message describing the
82
+ # If all is ok, a notification will be created, if not, an error with details
83
+ # will be returned.
84
+ #
85
+ # request - Any object which responds to #apply. Typically this should be
86
+ # a Hootenanny::Request::PublishNotification.
87
+ #
88
+ # Returns an indeterminate object (based on the request passed in) but is
89
+ # typically a Hootenanny::PublishNotification.
90
+ # Raises Hootenanny::PublishNotificationError with a message describing the
31
91
  # problem
32
92
  #
33
- def self.subscribe(callback, options = {})
34
- callback = callback.dup
35
- topic = options.fetch(:to).dup
93
+ def self.notify_of_publication(request)
94
+ request.apply
95
+ end
96
+
97
+ def self.broadcast
98
+ Hootenanny::PublishNotification.each_unprocessed do |notification|
99
+ if Hootenanny::Correspondent.broadcast(notification.topic)
100
+ notification.process
101
+ else
102
+ Hootenanny::PublishNotificationExpirationPolicy.apply notification
103
+ end
104
+ end
105
+ end
36
106
 
37
- Hootenanny::Subscription.assign(subscriber: callback,
38
- to: topic)
107
+ ###
108
+ # Public: Generates a request of varying types which can be used to perform
109
+ # actions such as subscribing to a topic.
110
+ #
111
+ # This method has zero side-effects and is only utilized to prepare a request.
112
+ #
113
+ # options - A Hash of options which can vary based on the type of request that
114
+ # is being... requested.
115
+ #
116
+ # :type - The type of request (eg: :subscription)
117
+ # :action - The action to be performed when the request is
118
+ # applied. This can vary based on the type of request
119
+ # being created.
120
+ # :callback - Used for :subscription requests. Is a URI which
121
+ # should be notified when the topic that it is
122
+ # interested in, is updated.
123
+ # :topic - Used for :subscription requests. Is a URI which
124
+ # represents all of the items which the subscriber is
125
+ # interested in.
126
+ #
127
+ # Returns an indeterminate object (based on the type of request that is
128
+ # generated, but all of them will inherit from Hootenanny::Request
129
+ # Raises a Hootenanny::Request::BuildError if any problems are encountered
130
+ #
131
+ def self.request(options = {})
132
+ Hootenanny::Request.build(options)
39
133
  end
40
134
  end
41
135
  end
@@ -0,0 +1,17 @@
1
+ require 'hootenanny/configuration'
2
+
3
+ module Hootenanny
4
+ class PublishNotificationExpirationPolicy
5
+ def self.apply(notification)
6
+ if notification.updated_at < expiration_threshold
7
+ notification.expire
8
+ end
9
+ end
10
+
11
+ private
12
+
13
+ def self.expiration_threshold
14
+ Hootenanny.config.default_publish_notification_expiration_threshold
15
+ end
16
+ end
17
+ end