serel 1.0.0.rc1

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.
@@ -0,0 +1,10 @@
1
+ # Serel
2
+ [![Build Status](https://secure.travis-ci.org/thomas-mcdonald/serel.png?branch=master)](http://travis-ci.org/thomas-mcdonald/serel)
3
+
4
+ Serel is an [AREL][1] inspired library developed for v2.0 of the [Stack Exchange API][2]. It's decent, you should try it too.
5
+
6
+ Read more on the [Serel site][3] or on Stack Apps.
7
+
8
+ [1]: https://github.com/rails/arel
9
+ [2]: https://api.stackexchange.com/
10
+ [3]: http://serel.tom.is
@@ -0,0 +1,37 @@
1
+ # Utilities
2
+ require 'logger'
3
+ require 'serel/exts'
4
+ require 'serel/relation'
5
+ require 'serel/response'
6
+
7
+ # Types
8
+ require 'serel/base'
9
+ require 'serel/access_token'
10
+ require 'serel/answer'
11
+ require 'serel/badge'
12
+ require 'serel/comment'
13
+ require 'serel/event'
14
+ require 'serel/inbox'
15
+ require 'serel/info'
16
+ require 'serel/post'
17
+ require 'serel/privilege'
18
+ require 'serel/question'
19
+ require 'serel/reputation'
20
+ require 'serel/request'
21
+ require 'serel/revision'
22
+ require 'serel/site'
23
+ require 'serel/suggested_edit'
24
+ require 'serel/tag'
25
+ require 'serel/tag_score'
26
+ require 'serel/tag_synonym'
27
+ require 'serel/tag_wiki'
28
+ require 'serel/user'
29
+
30
+ # HTTP magic
31
+ require 'cgi'
32
+ require 'json'
33
+ require 'net/http'
34
+ require 'uri'
35
+ require 'zlib'
36
+
37
+ class Serel::NoAPIKeyError < StandardError; end
@@ -0,0 +1,54 @@
1
+ module Serel
2
+ # AccessToken is a special helper class designed to make it easier to use access tokens.
3
+ #
4
+ # It doesn't handle the retrieval of the token, merely provides a way to get it into Serel.
5
+ # Creating a new AccessToken provides a couple of helper methods to access authorized methods and
6
+ # identify who a user is.
7
+ class AccessToken < Base
8
+ attributes :token
9
+ finder_methods :none
10
+
11
+ # Create a new instance of AccessToken.
12
+ # @param [String] token The access token you wish to use.
13
+ # @return [Serel::AccessToken] An AccessToken intialized with the token.
14
+ def initialize(token)
15
+ @data = {}
16
+ @data[:token] = token
17
+ end
18
+
19
+ # Retrieve the users' inbox items.
20
+ # Serel::AccessToken.new(token).inbox.request
21
+ # Serel::AccessToken.new(token).scoping_methods.inbox.request
22
+ #
23
+ # This is a scoping method and can be combined with other scoping methods.
24
+ # @return [Serel::Response] A list of {Serel::Inbox Inbox} items wrapped in our Response wrapper.
25
+ def inbox
26
+ type(:inbox).access_token(self.token).url("me/inbox")
27
+ end
28
+
29
+ # Retrieve the users' unread inbox items.
30
+ # Serel::AccessToken.new(token).unread_inbox.request
31
+ #
32
+ # This is a scoping method and can be combined with other scoping methods.
33
+ # @return [Serel::Response] A list of {Serel::Inbox Inbox} wrapped in our Response wrapper.
34
+ def unread_inbox
35
+ type(:inbox).access_token(self.token).url("me/inbox/unread")
36
+ end
37
+
38
+ # Invalidates the access token
39
+ # Serel::AccessToken.new(token).invalidate
40
+ def invalidate
41
+ network.url("access-tokens/#{token}/invalidate").request
42
+ end
43
+
44
+ # Retrieve the user associated with this access token.
45
+ # Serel::AccessToken.new(token).user
46
+ #
47
+ # This does not return a {Response} object, rather it directly returns the User.
48
+ #
49
+ # @return [Serel::User] The user associated with the access token.
50
+ def user
51
+ type(:user, :singular).access_token(self.token).url("me").request
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,75 @@
1
+ module Serel
2
+ # The Answer class represents a Stack Exchange answer object.
3
+ #
4
+ # == Finding answers
5
+ #
6
+ # There are a few different ways of retrieving answers: +all+, +get+ & +find+, as well as the scopes
7
+ # on other classes.
8
+ #
9
+ # === all
10
+ # +Serel::Answer.all+
11
+ #
12
+ # This should probably never be used. +all+ loads _every_ answer on the site, which, for many sites,
13
+ # involves retrieving lots of pages. However, it's there. Feel free to use it.
14
+ #
15
+ # == find
16
+ # +Serel::Answer.find(id)+
17
+ #
18
+ # Find an answer by its ID.
19
+ #
20
+ # == get
21
+ # +Serel::Answer.get+
22
+ #
23
+ # Retrieves answers, applying (any) scope that has been set. In reality this won't be called as given
24
+ # above, rather it will called at the end of a chain of scoping functions, e.g:
25
+ #
26
+ # +Serel::Answer.pagesize(100).sort(:votes).get+
27
+ #
28
+ # which would return the top 100 answers on the site.
29
+ #
30
+ # == {Question#answers}
31
+ # +Serel::Question.find(id).answers.get+
32
+ #
33
+ # Retrieves the answers on a particular question. The call to +answers+ returns a {Relation}
34
+ # object, which accepts all the usual relation scopes. This is true for all of the methods listed
35
+ # below.
36
+ #
37
+ # == {User#answers}
38
+ # +Serel::User.find(id).answers.get+
39
+ #
40
+ # Retrieves answers by a given user
41
+ class Answer < Base
42
+ attributes :answer_id, :body, :community_owned_date, :creation_date, :down_vote_count, :is_accepted, :last_activity_date, :last_edit_date, :link, :locked_date, :question_id, :score, :title, :up_vote_count
43
+ alias :id :answer_id
44
+
45
+ associations :comments => :comment, :owner => :user
46
+ finder_methods :every
47
+
48
+ # Get the comments on an answer.
49
+ # @return [Serel::Relation] A {Comment} relation scoped to the answer.
50
+ def comments
51
+ type(:comment).url("answers/#{id}/comments")
52
+ end
53
+
54
+ # Get the question this answer answers.
55
+ #
56
+ # Note that this method returns a question not wrapped in the {Response} wrapper.
57
+ #
58
+ # @return [Serel::Question] The parent {Question} for this answer.
59
+ def question
60
+ type(:question, :singular).url("questions/#{question_id}").request
61
+ end
62
+
63
+ # Get the revisions on an answer.
64
+ # @return [Serel::Relation] A {Revision} relation scoped to the answer.
65
+ def revisions
66
+ type(:revision).url("posts/#{id}/revisions")
67
+ end
68
+
69
+ # Get the suggested edits on an answer
70
+ # @return [Serel::Relation] A {SuggestedEdit} relation scoped to the answer.
71
+ def suggested_edits
72
+ type(:suggested_edit).url("posts/#{id}/suggested-edits")
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,89 @@
1
+ module Serel
2
+ # The Badge class represents a Stack Exchange badge object.
3
+ #
4
+ # == Finding Badges
5
+ #
6
+ # Badges can be retrieved using any of the standard finder methods: +all+, +get+ & +find+.
7
+ #
8
+ # === all
9
+ # Serel::Badge.all
10
+ #
11
+ # Unlike several of the other classes ({Answer}, {Comment}, {Question} etc) where the usage of +all+
12
+ # is recommended against, +all+ is probably the most useful finder method for retrieving badges, since
13
+ # a partial set of badges is probably not useful, and only a few pages have to be retrieved each time
14
+ # this is called. At the time of writing this, Stack Overflow required 17 pages to be retrieved,
15
+ # Server Fault required 2, and Gaming required 1, and shouldn't make too much of a dent in your quota.
16
+ #
17
+ # ==find
18
+ # Serel::Badge.find(id)
19
+ #
20
+ # Retrieves a badge or badges by ID.
21
+ #
22
+ # == get
23
+ # Serel::Badge.get
24
+ #
25
+ # Retrieves a page of badge results, applying any scopes that have previously been defined.
26
+ #
27
+ # == {named}
28
+ # Serel::Badge.named.request
29
+ #
30
+ # See the documentation for {named} below.
31
+ #
32
+ # == {recipients}
33
+ # Serel::Badge.recipients.request
34
+ # Serel::Badge.recipients(1).request
35
+ # Serel::Badge.recipients(1,2,3).request
36
+ #
37
+ # See the documentation for {recipients} below.
38
+ #
39
+ # == {tags}
40
+ # Serel::Badge.tags.request
41
+ #
42
+ # See the documentation for {tags} below.
43
+ class Badge < Base
44
+ attributes :badge_id, :badge_type, :description, :link, :name, :rank
45
+ alias :id :badge_id
46
+
47
+ associations :user => :user
48
+ finder_methods :every
49
+
50
+ # Retrieves only badges that are explicitly defined.
51
+ # This is a scoping method, meaning that it can be used around/with other scoping methods, for
52
+ # example:
53
+ # Serel::Badge.pagesize(5).named.request
54
+ # Serel::Badge.named.sort('gold').request
55
+ #
56
+ # @return [Serel::Relation] A relation scoped to the named URL.
57
+ def self.named
58
+ url("badges/name")
59
+ end
60
+
61
+ # Retrieves recently awarded badges.
62
+ # Serel::Badge.recipients.request
63
+ # Serel::Badge.recipients(1).request
64
+ # Serel::Badge.recipients(1,2,3).request
65
+ #
66
+ # This is a scoping method and can be combined with other scoping methods.
67
+ #
68
+ # @param [Array] ids The ID or IDs of the badges you want information on. Not passing an ID means
69
+ # all recently awarded badges will be returned.
70
+ # @return [Serel::Relation] A relation scoped to the correct recipients URL
71
+ def self.recipients(*ids)
72
+ if ids.length > 0
73
+ arg = ids.length > 1 ? ids.join(';') : ids.pop
74
+ url("badges/#{arg}/recipients")
75
+ else
76
+ url("badges/recipients")
77
+ end
78
+ end
79
+
80
+ # Retrieves only badges that have been awarded due to participation in a tag.
81
+ # Serel::Badge.tags.request
82
+ #
83
+ # This is a scoping method and can be combined with other scoping methods.
84
+ # @return [Serel::Relation] A relation scoped to the tags URL.
85
+ def self.tags
86
+ url("badges/tags")
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,180 @@
1
+ module Serel
2
+ class Base
3
+ class_attribute :all_finder_methods, :api_key, :associations, :attributes, :finder_methods, :logger, :network, :site
4
+ self.all_finder_methods = %w(find get all)
5
+
6
+ def initialize(data)
7
+ @data = {}
8
+ attributes.each { |k| @data[k] = data[k.to_s] }
9
+ if associations
10
+ associations.each do |k,v|
11
+ if data[k.to_s]
12
+ @data[k] = find_constant(v).new(data[k.to_s])
13
+ end
14
+ end
15
+ end
16
+ end
17
+
18
+ # Internal: Provides access to the internal data
19
+ # Rather than store data using attr_writers (problems) we use a hash.
20
+ def [](attr)
21
+ @data[attr]
22
+ end
23
+
24
+ def []=(attr, value)
25
+ @data[attr] = value
26
+ end
27
+
28
+ # Public: Sets the global configuration values.
29
+ #
30
+ # site - The API site parameter that represents the site you wish to query.
31
+ # api_key - Your API key.
32
+ #
33
+ # Returns nothing.
34
+ def self.config(site, api_key = nil)
35
+ self.site = site.to_sym
36
+ self.api_key = api_key
37
+ self.logger = Logger.new(STDOUT)
38
+ self.logger.formatter = proc { |severity, datetime, progname, msg|
39
+ %([#{severity}][#{datetime.strftime("%Y-%m-%d %H:%M:%S")}] #{msg}\n)
40
+ }
41
+ end
42
+
43
+ # Public: Provides a nice and quick way to inspec the properties of a
44
+ # Serel instance inheriting from Serel::Base
45
+ #
46
+ # Returns a String representation of the class
47
+ def inspect
48
+ attribute_collector = {}
49
+ self.class.attributes.each { |attr| attribute_collector[attr] = self.send(attr) }
50
+ inspected_attributes = attribute_collector.select { |k,v| v != nil }.collect { |k,v| "@#{k}=#{v}" }.join(", ")
51
+ association_collector = {}
52
+ self.class.associations.each { |name, type| association_collector[name] = self[name] }
53
+ inspected_associations = association_collector.select { |k, v| v != nil }.collect { |k, v| "@#{k}=#<#{v.class}:#{v.object_id}>" }.join(", ")
54
+ "#<#{self.class}:#{self.object_id} #{inspected_attributes} #{inspected_associations}>"
55
+ end
56
+ alias :to_s :inspect
57
+
58
+ # Internal: Defines the attributes for a subclass of Serel::Base
59
+ #
60
+ # *splat - The Array or List of attributes for the class
61
+ #
62
+ # Returns nothing.
63
+ def self.attributes(*splat)
64
+ self.attributes = splat
65
+ splat.each do |meth|
66
+ define_method(meth) { self[meth.to_sym] }
67
+ define_method("#{meth}=") { |val| self[meth.to_sym] = val }
68
+ end
69
+ end
70
+
71
+ # Internal: Defines the associations for a subclass of Serel::Base
72
+ #
73
+ # options - The Hash options used to define associations
74
+ # { :name => :type }
75
+ # :name - The Symbol name that this association will be found as in the data
76
+ # :type - The Symbol type that this association should be parsed as
77
+ #
78
+ # Returns nothing.
79
+ def self.associations(options = {})
80
+ self.associations = options
81
+ options.each do |meth, type|
82
+ unless self.respond_to?(meth)
83
+ define_method(meth) { self[meth.to_sym] }
84
+ end
85
+ define_method("#{meth}=") { |val| self[meth.to_sym] = val }
86
+ end
87
+ end
88
+
89
+ # Public: Creates a new relation scoped to the class
90
+ #
91
+ # klass - the name of the class to scope the relation to
92
+ #
93
+ # Returns an instance of Serel::Relation
94
+ def self.new_relation(klass = nil, qty = :singular)
95
+ klass = name.split('::').last.to_snake unless klass
96
+ Serel::Relation.new(klass.to_s, qty)
97
+ end
98
+
99
+
100
+ # Public: Sets the finder methods available to this type
101
+ #
102
+ # *splat - The Array of methods this class accepts. Also accepts :every,
103
+ # which indicates that all finder methods are valid
104
+ #
105
+ # Returns nothing of value
106
+ def self.finder_methods(*splat)
107
+ self.finder_methods = splat
108
+ end
109
+
110
+ # Denotes that a class does not need the site parameter
111
+ def self.network_wide
112
+ self.network = true
113
+ end
114
+
115
+ # Public: Provides an interface to show which methods this class responds to
116
+ #
117
+ # method_sym - The Symbol representation of the method you are interested in
118
+ # include_private - Whether to include private methods. We ignore this, but
119
+ # it is part of how the core library handles respond_to
120
+ #
121
+ # Returns Boolean indicating whether the class responds to it or not
122
+ def self.respond_to?(method_sym, include_private = true)
123
+ if self.all_finder_methods.include?(method_sym.to_s)
124
+ if (self.finder_methods.include?(:every)) || (self.finder_methods.include?(method_sym))
125
+ true
126
+ else
127
+ false
128
+ end
129
+ else
130
+ super(method_sym, include_private)
131
+ end
132
+ end
133
+
134
+ def all
135
+ if self.respond_to?(:all)
136
+ new_relation.all
137
+ else
138
+ raise NoMethodError
139
+ end
140
+ end
141
+
142
+ def find(id)
143
+ if self.respond_to?(:find)
144
+ new_relation.find(id)
145
+ else
146
+ raise NoMethodError
147
+ end
148
+ end
149
+
150
+ def get
151
+ if self.respond_to?(:get)
152
+ new_relation.get
153
+ else
154
+ raise NoMethodError
155
+ end
156
+ end
157
+
158
+ ## Pass these methods direct to a new Relation
159
+ # TODO: clean these up
160
+ %w(access_token filter fromdate inname intitle min max nottagged order page pagesize since sort tagged title todate url).each do |meth|
161
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
162
+ def self.#{meth}(val)
163
+ new_relation.#{meth}(val)
164
+ end
165
+ RUBY
166
+ end
167
+ def network; self.class.new_relation.network; end
168
+ def self.request(path, type = nil); new_relation.request(path, type); end
169
+ def type(klass, qty = :plural)
170
+ self.class.new_relation(klass, qty)
171
+ end
172
+
173
+ def self.method_missing(sym, *attrs, &block)
174
+ # TODO: see if the new_relation responds to the method being called
175
+ # requires a rewrite of how method_missing works on the
176
+ # relation side.
177
+ new_relation.send(sym, *attrs, &block)
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,68 @@
1
+ module Serel
2
+ # The Comment class represents a Stack Exchange comment object.
3
+ #
4
+ # == Finding Comments
5
+ #
6
+ # Comments can be retrieved using any of the standard finder methods: +all+, +get+ & +find+.
7
+ #
8
+ # === all
9
+ # Serel::Comment.all
10
+ #
11
+ # It is recommended that you don't use this method, since most of the time it is overkill.
12
+ # Automatically paginating through all the comments on a site, even 100 comments at a time, will take
13
+ # a long time and a lot of requests. It is almost certainly better to use one of the other finder
14
+ # methods, but this is here for the sake of completion. If you need to access every comment, the data
15
+ # dump and data explorer are your friends.
16
+ #
17
+ # ==find
18
+ # Serel::Comment.find(id)
19
+ #
20
+ # Retrieves a comment or comments by ID.
21
+ #
22
+ # == get
23
+ # Serel::Comment.get
24
+ #
25
+ # Retrieves a page of comment results, applying any scopes that have previously been defined.
26
+ #
27
+ # == {Answer#comments}
28
+ # Serel::Answer.find(id).comments.request
29
+ #
30
+ # Retrieves a page of comments on the specified answer.
31
+ #
32
+ # == {Post#comments}
33
+ # Serel::Post.find(id).comments.request
34
+ #
35
+ # Retrieves a page of comments on the specified post.
36
+ #
37
+ # == {Question#comments}
38
+ # Serel::Question.find(id).comments.request
39
+ #
40
+ # Retrieves a page of comments on the specified question.
41
+ #
42
+ # == {User#comments}
43
+ # Serel::User.find(id).comments.request
44
+ # Serel::User.find(id).comments(other_id).request
45
+ #
46
+ # Retrieves a page of comments by the specified user. If the optional +other_id+ parameter is passed
47
+ # then only comments in reply to +other_id+ are included.
48
+ #
49
+ # == {User#mentioned}
50
+ # Serel::User.find(id).mentioned.request
51
+ #
52
+ # Retrieves a page of comments mentioning the specified user.
53
+ class Comment < Base
54
+ attributes :comment_id, :body, :creation_date, :edited, :link, :post_id, :post_type, :score
55
+ alias :id :comment_id
56
+
57
+ associations :owner => :user, :reply_to_user => :user
58
+ finder_methods :every
59
+
60
+ # Retrieves the post that this comment is on.
61
+ #
62
+ # Note that this returns the post, rather than returning a {Serel::Response} wrapped array.
63
+ # @return [Serel::Post] The post the comment is on.
64
+ def post
65
+ type(:post, :singular).url("posts/#{post_id}").request
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,25 @@
1
+ module Serel
2
+ # The Event class represents a Stack Exchange event.
3
+ #
4
+ # == Finding events
5
+ #
6
+ # You can use the +all+ and +get+ finder methods to retrieve events. Both of these require
7
+ # authentication, so {Relation#access_token access_token} must be called before the finders.
8
+ #
9
+ # === all
10
+ # Serel::Event.access_token(token).all
11
+ #
12
+ # Retrieves (by default) all the events on the site in the last 5 minutes. The +since+ method can
13
+ # be called to change this return events upto 15 minutes ago. This method is safe to call, since even
14
+ # on Stack Overflow only roughly 3 pages of events are generated every 5 minutes.
15
+ #
16
+ # == get
17
+ # Serel::Event.access_token(token).get
18
+ #
19
+ # Retrieves a page of Events. As per the +all+ method above, this defaults to events in the last 5
20
+ # minutes.
21
+ class Event < Base
22
+ attributes :event_type, :event_id, :creation_date, :link, :excerpt
23
+ finder_methods :all, :get
24
+ end
25
+ end
@@ -0,0 +1,88 @@
1
+ # TODO: make file less ugly.
2
+
3
+ def remove_possible_method(method)
4
+ if method_defined?(method) || private_method_defined?(method)
5
+ remove_method(method)
6
+ end
7
+ rescue NameError
8
+ # If the requested method is defined on a superclass or included module,
9
+ # method_defined? returns true but remove_method throws a NameError.
10
+ # Ignore this.
11
+ end
12
+
13
+ def singleton_class?
14
+ !name || '' == name
15
+ end
16
+
17
+ def singleton_class
18
+ class << self
19
+ self
20
+ end
21
+ end
22
+
23
+ def class_attribute(*attrs)
24
+ if attrs.last.is_a?(Hash)
25
+ options = attrs.pop
26
+ else
27
+ options = {}
28
+ end
29
+ instance_reader = options.fetch(:instance_reader, true)
30
+ instance_writer = options.fetch(:instance_writer, true)
31
+
32
+ attrs.each do |name|
33
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
34
+ def self.#{name}() nil end
35
+ def self.#{name}?() !!#{name} end
36
+
37
+ def self.#{name}=(val)
38
+ singleton_class.class_eval do
39
+ remove_possible_method(:#{name})
40
+ define_method(:#{name}) { val }
41
+ end
42
+
43
+ if singleton_class?
44
+ class_eval do
45
+ remove_possible_method(:#{name})
46
+ def #{name}
47
+ defined?(@#{name}) ? @#{name} : singleton_class.#{name}
48
+ end
49
+ end
50
+ end
51
+ val
52
+ end
53
+
54
+ if instance_reader
55
+ remove_possible_method :#{name}
56
+ def #{name}
57
+ defined?(@#{name}) ? @#{name} : self.class.#{name}
58
+ end
59
+
60
+ def #{name}?
61
+ !!#{name}
62
+ end
63
+ end
64
+ RUBY
65
+
66
+ attr_writer name if instance_writer
67
+ end
68
+ end
69
+
70
+ def find_constant(symbol)
71
+ str = symbol.to_s.dup
72
+ match = /_([a-z])/.match(str)
73
+ if match
74
+ str.gsub!(match[0], match[1].upcase)
75
+ end
76
+ str[0] = str[0].upcase
77
+ Serel.const_get(str)
78
+ end
79
+
80
+ class String
81
+ # Yeah, it's #underscore in Rails, but that's not half as fun.
82
+ def to_snake
83
+ gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2').
84
+ gsub(/([a-z\d])([A-Z])/,'\1_\2').
85
+ tr("-", "_").
86
+ downcase
87
+ end
88
+ end
@@ -0,0 +1,6 @@
1
+ module Serel
2
+ class Inbox < Base
3
+ attributes :item_type, :question_id, :answer_id, :comment_id, :title, :creation_date, :is_unread, :site, :body, :link
4
+ finder_methods :none
5
+ end
6
+ end
@@ -0,0 +1,21 @@
1
+ module Serel
2
+ # The Info class represents information about a Stack Exchange site.
3
+ #
4
+ # == Getting info
5
+ # The +/info+ route accepts no parameters and requires no IDs, so the default finder methods cannot be
6
+ # used. We therefore have a custom finder defined, {.get_info}.
7
+ class Info < Base
8
+ attributes :total_questions, :total_unanswered, :total_accepted, :total_answers, :questions_per_minute, :answers_per_minute, :total_comments, :total_votes, :total_badges, :badges_per_minute, :total_users, :new_active_users, :api_revision
9
+ associations :site => :site
10
+ finder_methods :none
11
+
12
+ # Gets information about the current site.
13
+ #
14
+ # It is best practice to aggresively cache the returned values with a TTL of at least 3600 seconds.
15
+ #
16
+ # @return [Serel::Info] Information about the site
17
+ def self.get_info
18
+ new_relation(:info, :singular).url("info").request
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,21 @@
1
+ module Serel
2
+ class Post < Base
3
+ attributes :post_id, :post_type, :body, :creation_date, :last_activity_date, :last_edit_date, :score, :up_vote_count, :down_vote_count
4
+ alias :id :post_id
5
+
6
+ associations :comments => :comment, :owner => :user
7
+ finder_methods :every
8
+
9
+ def comments
10
+ type(:comment).url("posts/#{id}/comments")
11
+ end
12
+
13
+ def revisions
14
+ type(:revision).url("posts/#{id}/revisions")
15
+ end
16
+
17
+ def suggested_edits
18
+ type(:suggested_edit).url("posts/#{id}/suggested-edits")
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,6 @@
1
+ module Serel
2
+ class Privilege < Base
3
+ attributes :short_description, :description, :reputation
4
+ finder_methods :all
5
+ end
6
+ end
@@ -0,0 +1,48 @@
1
+ module Serel
2
+ class Question < Base
3
+ attributes :question_id, :accepted_answer_id, :answer_count, :body, :bounty_amount, :bounty_closes_date, :closed_reason, :community_owned_date, :creation_date, :down_vote_count, :favourite_count, :last_activity_date, :last_edit_date, :link, :locked_date, :migrated_to, :migrated_from, :protected_date, :score, :tags, :title, :up_vote_count, :view_count
4
+ associations :answers => :answer, :comments => :comment, :owner => :user
5
+ alias :id :question_id
6
+ finder_methods :every
7
+
8
+ def self.featured
9
+ url("questions/featured")
10
+ end
11
+
12
+ def self.search
13
+ url("search")
14
+ end
15
+
16
+ def self.similar
17
+ url("similar")
18
+ end
19
+
20
+ def self.unanswered
21
+ url("questions/unanswered")
22
+ end
23
+
24
+ def self.no_answers
25
+ url("questions/no-answers")
26
+ end
27
+
28
+ def answers
29
+ type(:answer).url("questions/#{@id}/answers")
30
+ end
31
+
32
+ def comments
33
+ type(:comment).url("questions/#{@id}/comments")
34
+ end
35
+
36
+ def linked
37
+ type(:question).url("questions/#{@id}/linked")
38
+ end
39
+
40
+ def related
41
+ type(:question).url("questions/#{@id}/related")
42
+ end
43
+
44
+ def timeline
45
+ type(:timeline).url("questions/#{@id}/timeline")
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,125 @@
1
+ module Serel
2
+ class Relation
3
+ attr_reader :type, :klass, :qty
4
+
5
+ def initialize(type, qty)
6
+ @type = type
7
+ @klass = find_constant(type)
8
+ @scope = {
9
+ api_key: Serel::Base.api_key,
10
+ site: Serel::Base.site
11
+ }
12
+ @qty = qty
13
+ end
14
+
15
+ # Public: Merges two relation objects together. This is used in our awesome
16
+ # new scoping engine!
17
+ #
18
+ # relation - A Serel::Relation object with the same base class as the
19
+ # current relation
20
+ #
21
+ # Returns self
22
+ def merge(relation)
23
+ if relation.instance_variable_get(:@type) != @type
24
+ raise ArgumentError, 'You cannot merge two relation objects based on different classes'
25
+ end
26
+ @scope.merge!(relation.scoping)
27
+ end
28
+
29
+ # Scoping returns our internal scope defition. Things like url etc.
30
+ def scoping
31
+ @scope
32
+ end
33
+
34
+ def new_relation
35
+ self
36
+ end
37
+
38
+ def method_missing(sym, *attrs, &block)
39
+ # If the base relation class responds to the method, call
40
+ # it and merge in the resulting relation scope
41
+ if @klass.respond_to?(sym)
42
+ relation = @klass.send(sym, *attrs, &block)
43
+ merge(relation)
44
+ self
45
+ end
46
+ super(sym, *attrs, &block)
47
+ end
48
+
49
+ #
50
+ #
51
+ #
52
+ # Scope methods
53
+ %w(access_token filter fromdate inname intitle min max nottagged order page pagesize since sort tagged title todate url).each do |meth|
54
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
55
+ def #{meth}(val)
56
+ @scope[:#{meth}] = val.to_s
57
+ self
58
+ end
59
+ RUBY
60
+ end
61
+
62
+ def network
63
+ @network = true
64
+ self
65
+ end
66
+
67
+ #
68
+ #
69
+ #
70
+ # Finder methods
71
+ def all
72
+ if klass.respond_to?(:all)
73
+ all_helper(1)
74
+ else
75
+ raise NoMethodError
76
+ end
77
+ end
78
+
79
+ # Finds an object by an ID or list of IDs. For example:
80
+ # Serel::Answer.find(1) # Find the answer with an ID of 1
81
+ # Serel::Answer.find(1, 2, 3) # Find the answers with IDs of 1, 2 & 3
82
+ #
83
+ # @param [Array] ids The ID or IDs of the objects you want returning.
84
+ # @return [Serel::Response] The data returned by the Stack Exchange API, parsed and pushed into our
85
+ # handy response wrapper.
86
+ def find(*ids)
87
+ if klass.respond_to?(:find)
88
+ arg = ids.length > 1 ? ids.join(';') : ids.pop
89
+ url("#{@type}s/#{arg}").request
90
+ else
91
+ raise NoMethodError
92
+ end
93
+ end
94
+
95
+ def get
96
+ if klass.respond_to?(:get)
97
+ url("#{@type}s").request
98
+ else
99
+ raise NoMethodError
100
+ end
101
+ end
102
+
103
+ # Request stuff
104
+ def request
105
+ if (klass.network || @network)
106
+ @scope[:network] = true
107
+ end
108
+ Serel::Request.new(@type, scoping, @qty).execute
109
+ end
110
+
111
+ private
112
+
113
+ def all_helper(page)
114
+ response = page(page).pagesize(100).url("#{@type}s").request
115
+ # TODO: find a query that triggers backoff.
116
+ # if response.backoff
117
+ # Serel::Base.warn response.backoff
118
+ # end
119
+ if response.has_more
120
+ response.concat all_helper(page+1)
121
+ end
122
+ response
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,6 @@
1
+ module Serel
2
+ class Reputation < Base
3
+ attributes :user_id, :post_id, :post_type, :vote_type, :title, :link, :reputation_change, :on_date
4
+ finder_methods :none
5
+ end
6
+ end
@@ -0,0 +1,63 @@
1
+ module Serel
2
+ class Request
3
+ def initialize(type, scoping, qty)
4
+ @type = type
5
+ @scope = scoping.dup
6
+ @site = @scope.delete :site
7
+ @api_key = @scope.delete :api_key
8
+ raise Serel::NoAPIKeyError, 'You must configure Serel with an API key before you can make requests' if @api_key == nil
9
+ @method = @scope.delete :url
10
+ @network = @scope.delete :network
11
+ @qty = qty
12
+ end
13
+
14
+ def execute
15
+ build_query_string
16
+ build_request_path
17
+ make_request
18
+ end
19
+
20
+ def build_query_string
21
+ query_hash = @scope
22
+ unless @network
23
+ query_hash[:site] = @site
24
+ end
25
+ query_hash[:key] = @api_key
26
+ @query_string = query_hash.map { |k,v| "#{CGI::escape(k.to_s)}=#{CGI::escape(v.to_s)}"}.join('&')
27
+ end
28
+
29
+ def build_request_path
30
+ @path = "/2.0/#{@method}?#{@query_string}"
31
+ end
32
+
33
+ def make_request
34
+ Serel::Base.logger.info "Making request to #{@path}"
35
+ http = Net::HTTP.new('api.stackexchange.com', 443)
36
+ http.use_ssl = true
37
+ response = http.get(@path)
38
+ body = JSON.parse(response.body)
39
+
40
+ result = Serel::Response.new
41
+
42
+ # Set the values of the response wrapper attributes
43
+ %w(backoff error_id error_message error_name has_more page page_size quota_max quota_remaining total type).each do |attr|
44
+ result.send("#{attr}=", body[attr])
45
+ end
46
+
47
+ # Set some response values we know about but SE might not send back
48
+ result.page ||= (@scope[:page] || 1)
49
+ result.page_size ||= (@scope[:pagesize] || 30)
50
+
51
+ # Insert into the response array the items returned
52
+ body["items"].each do |item|
53
+ result << find_constant(@type).new(item)
54
+ end
55
+
56
+ if (@qty == :plural) || (result.length > 1)
57
+ result
58
+ else
59
+ result.pop
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,5 @@
1
+ module Serel
2
+ class Response < Array
3
+ attr_accessor :backoff, :error_id, :error_message, :error_name, :has_more, :page, :page_size, :quota_max, :quota_remaining, :total, :type
4
+ end
5
+ end
@@ -0,0 +1,9 @@
1
+ module Serel
2
+ class Revision < Base
3
+ attributes :revision_guid, :revision_number, :revision_type, :post_type, :post_id, :comment, :creation_date, :is_rollback, :last_body, :last_title, :last_tags, :body, :title, :tags, :set_community_wiki
4
+ alias :id :revision_guid
5
+
6
+ associations :user => :user
7
+ finder_methods :find
8
+ end
9
+ end
@@ -0,0 +1,22 @@
1
+ module Serel
2
+ # The Site class represents a Stack Exchange site.
3
+ #
4
+ # == Finding Sites
5
+ #
6
+ # Sites can be retrieved using +all+ and +get+.
7
+ #
8
+ # === all
9
+ # Serel::Site.all
10
+ #
11
+ # Automatically paginates through the list of sites and returns an array of every Stack Exchange site.
12
+ #
13
+ # == get
14
+ # Serel::Site.get
15
+ #
16
+ # Retrieves a page of sites, applying any scopes that have previously been defined.
17
+ class Site < Base
18
+ attributes :site_type, :name, :logo_url, :api_site_parameter, :site_url, :audience, :icon_url, :aliases, :site_state, :styling, :closed_beta_date, :open_beta_date, :launch_date, :favicon_url, :related_sites, :twitter_account, :markdown_extensions
19
+ finder_methods :all, :get
20
+ network_wide
21
+ end
22
+ end
@@ -0,0 +1,8 @@
1
+ module Serel
2
+ class SuggestedEdit < Base
3
+ attributes :suggested_edit_id, :post_id, :post_type, :body, :title, :tags, :comment, :creation_date, :approval_date, :rejection_date
4
+ alias :id :suggested_edit_id
5
+ associations :proposing_user => :user
6
+ finder_methods :every
7
+ end
8
+ end
@@ -0,0 +1,63 @@
1
+ module Serel
2
+ class Tag < Base
3
+ attributes :name, :count, :is_required, :is_moderator_only, :user_id, :has_synonyms, :last_activity_date
4
+ finder_methods :all, :get
5
+
6
+ # Finds a tag by name
7
+ # @param [String] name The name of the tag you wish to find
8
+ # @return [Serel::Tag] The tag returned by the Stack Exchange API
9
+ def self.find_by_name(name)
10
+ url("tags/#{name}/info").request
11
+ end
12
+
13
+ # Retrieves tags which can only be added or removed by a moderator
14
+ # Serel::Tag.moderator_only.request
15
+ #
16
+ # This is a scoping method and can be combined with other scoping methods
17
+ # @return [Serel::Relation] A relation scoped to the moderator only URL.
18
+ def self.moderator_only
19
+ url("tags/moderator-only")
20
+ end
21
+
22
+ # Retrieves tags that are required on the site
23
+ # Serel::Tag.required.request
24
+ #
25
+ # This is a scoping method and can be combined with other scoping methods.
26
+ # @return [Serel::Relation] A relation scoped to the required URL.
27
+ def self.required
28
+ url("tags/required")
29
+ end
30
+
31
+ # Retrieves all the tag synonyms on the site
32
+ # Serel::Tag.synonyms.request
33
+ #
34
+ # This is a scoping method and can be combined with other scoping methods.
35
+ # @return [Serel::Relation] A relation scoped to {Serel::TagSynonym TagSynonym} and the synonym URL.
36
+ def self.synonyms
37
+ new_relation(:tag_synonym).url("tags/synonyms")
38
+ end
39
+
40
+ # Retrieves related tags.
41
+ # Serel::Tag.find(1).related.request
42
+ #
43
+ # This is a scoping method and can be combined with other scoping methods.
44
+ # @return [Serel::Relation] A relation scoped to the related URL
45
+ def related
46
+ type(:tag).url("tags/#{name}/related")
47
+ end
48
+
49
+ def top_answerers(period)
50
+ raise ArgumentError, 'period must be :all_time or :month' unless [:all_time, :month].include? period
51
+ type(:tag_score).url("tags/#{name}/top-answerers/#{period}")
52
+ end
53
+
54
+ def top_askers(period)
55
+ raise ArgumentError, 'period must be :all_time or :month' unless [:all_time, :month].include? period
56
+ type(:tag_score).url("tags/#{name}/top-askers/#{period}")
57
+ end
58
+
59
+ def wiki
60
+ type(:tag_wiki, :singular).url("tags/#{name}/wikis").request
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,7 @@
1
+ module Serel
2
+ class TagScore < Base
3
+ attributes :score, :post_count
4
+ associations :user => :user
5
+ finder_methods :none
6
+ end
7
+ end
@@ -0,0 +1,6 @@
1
+ module Serel
2
+ class TagSynonym < Base
3
+ attributes :from_tag, :to_tag, :applied_count, :last_applied_date, :creation_date
4
+ finder_methods :none
5
+ end
6
+ end
@@ -0,0 +1,7 @@
1
+ module Serel
2
+ class TagWiki < Base
3
+ attributes :tag_name, :body, :excerpt, :body_last_edit_date, :excerpt_last_edit_date
4
+ associations :last_body_editor => :user, :last_excerpt_editor => :user
5
+ finder_methods :none
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ module Serel
2
+ class Timeline < Base
3
+ attributes :timeline_type, :question_id, :post_id, :comment_id, :revision_guid, :up_vote_count, :down_vote_count, :creation_date
4
+ associations :user => :user, :owner => :user
5
+ finder_methods :every
6
+ end
7
+ end
@@ -0,0 +1,85 @@
1
+ module Serel
2
+ class User < Base
3
+ attributes :user_id, :user_type, :creation_date, :display_name, :profile_image, :reputation, :reputation_change_day, :reputation_change_week, :reputation_change_month, :reputation_change_quarter, :reputation_change_year, :age, :last_access_date, :last_modified_date, :is_employee, :link, :website_url, :location, :account_id, :timed_penalty_date, :badge_counts, :question_counts, :answer_count, :up_vote_count, :down_vote_count, :about_me, :view_count, :accept_rate
4
+ alias :id :user_id
5
+ finder_methods :every
6
+
7
+ def self.moderators
8
+ url("users/moderators")
9
+ end
10
+
11
+ def self.elected_moderators
12
+ url("users/moderators/elected")
13
+ end
14
+
15
+ def answers
16
+ type(:answer).url("users/#{id}/answers")
17
+ end
18
+
19
+ def badges
20
+ type(:badge).url("users/#{id}/badges")
21
+ end
22
+
23
+ def comments(to_id = nil)
24
+ if to_id
25
+ type(:comment).url("users/#{id}/comments/#{to_id}")
26
+ else
27
+ type(:comment).url("users/#{id}/comments")
28
+ end
29
+ end
30
+
31
+ def favorites
32
+ type(:question).url("users/#{id}/favorites")
33
+ end
34
+
35
+ def mentioned
36
+ type(:comment).url("users/#{id}/mentioned")
37
+ end
38
+
39
+ def privileges
40
+ type(:privilege).url("users/#{id}/privileges")
41
+ end
42
+
43
+ def questions
44
+ type(:question).url("users/#{id}/questions")
45
+ end
46
+
47
+ def questions_featured
48
+ type(:question).url("users/#{id}/questions/featured")
49
+ end
50
+
51
+ def questions_no_answers
52
+ type(:question).url("users/#{id}/questions/no-answers")
53
+ end
54
+
55
+ def questions_unaccepted
56
+ type(:question).url("users/#{id}/questions/unaccepted")
57
+ end
58
+
59
+ def questions_unanswered
60
+ type(:question).url("users/#{id}/questions/unanswered")
61
+ end
62
+
63
+ def reputation
64
+ type(:reputation).url("users/#{id}/reputation")
65
+ end
66
+
67
+ def suggested_edits
68
+ type(:suggested_edit).url("users/#{id}/suggested-edits")
69
+ end
70
+
71
+ def tags
72
+ type(:tag).url("users/#{id}/tags")
73
+ end
74
+
75
+ def top_answers_on(*tags)
76
+ arg = tags.length > 1 ? tags.join(";") : tags.pop
77
+ type(:answer).url("users/#{id}/tags/#{arg}/top-answers")
78
+ end
79
+
80
+ def top_questions_on(*tags)
81
+ arg = tags.length > 1 ? tags.join(";") : tags.pop
82
+ type(:question).url("users/#{id}/tags/#{arg}/top-questions")
83
+ end
84
+ end
85
+ end
metadata ADDED
@@ -0,0 +1,105 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: serel
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0.rc1
5
+ prerelease: 6
6
+ platform: ruby
7
+ authors:
8
+ - Thomas McDonald
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2012-02-29 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: rspec
16
+ requirement: &70120458988780 !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ! '>='
20
+ - !ruby/object:Gem::Version
21
+ version: '0'
22
+ type: :development
23
+ prerelease: false
24
+ version_requirements: *70120458988780
25
+ - !ruby/object:Gem::Dependency
26
+ name: vcr
27
+ requirement: &70120458987680 !ruby/object:Gem::Requirement
28
+ none: false
29
+ requirements:
30
+ - - ! '>='
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: *70120458987680
36
+ - !ruby/object:Gem::Dependency
37
+ name: webmock
38
+ requirement: &70120458986560 !ruby/object:Gem::Requirement
39
+ none: false
40
+ requirements:
41
+ - - ! '>='
42
+ - !ruby/object:Gem::Version
43
+ version: '0'
44
+ type: :development
45
+ prerelease: false
46
+ version_requirements: *70120458986560
47
+ description:
48
+ email: tom@conceptcoding.co.uk
49
+ executables: []
50
+ extensions: []
51
+ extra_rdoc_files: []
52
+ files:
53
+ - lib/serel/access_token.rb
54
+ - lib/serel/answer.rb
55
+ - lib/serel/badge.rb
56
+ - lib/serel/base.rb
57
+ - lib/serel/comment.rb
58
+ - lib/serel/event.rb
59
+ - lib/serel/exts.rb
60
+ - lib/serel/inbox.rb
61
+ - lib/serel/info.rb
62
+ - lib/serel/post.rb
63
+ - lib/serel/privilege.rb
64
+ - lib/serel/question.rb
65
+ - lib/serel/relation.rb
66
+ - lib/serel/reputation.rb
67
+ - lib/serel/request.rb
68
+ - lib/serel/response.rb
69
+ - lib/serel/revision.rb
70
+ - lib/serel/site.rb
71
+ - lib/serel/suggested_edit.rb
72
+ - lib/serel/tag.rb
73
+ - lib/serel/tag_score.rb
74
+ - lib/serel/tag_synonym.rb
75
+ - lib/serel/tag_wiki.rb
76
+ - lib/serel/timeline.rb
77
+ - lib/serel/user.rb
78
+ - lib/serel.rb
79
+ - README.md
80
+ homepage: http://serel.tom.is
81
+ licenses: []
82
+ post_install_message:
83
+ rdoc_options: []
84
+ require_paths:
85
+ - lib
86
+ required_ruby_version: !ruby/object:Gem::Requirement
87
+ none: false
88
+ requirements:
89
+ - - ! '>='
90
+ - !ruby/object:Gem::Version
91
+ version: '0'
92
+ required_rubygems_version: !ruby/object:Gem::Requirement
93
+ none: false
94
+ requirements:
95
+ - - ! '>'
96
+ - !ruby/object:Gem::Version
97
+ version: 1.3.1
98
+ requirements: []
99
+ rubyforge_project:
100
+ rubygems_version: 1.8.12
101
+ signing_key:
102
+ specification_version: 3
103
+ summary: A Ruby library for the Stack Exchange API
104
+ test_files: []
105
+ has_rdoc: