serel 1.0.0.rc1

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