redd 0.7.10 → 0.8.0.pre.1

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 (91) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +5 -30
  3. data/.rspec +1 -1
  4. data/.rubocop.yml +16 -3
  5. data/.travis.yml +13 -7
  6. data/Gemfile +3 -1
  7. data/LICENSE.txt +21 -0
  8. data/README.md +40 -126
  9. data/Rakefile +10 -3
  10. data/TODO.md +11 -0
  11. data/bin/console +84 -0
  12. data/bin/setup +8 -0
  13. data/lib/redd.rb +84 -46
  14. data/lib/redd/api_client.rb +109 -0
  15. data/lib/redd/auth_strategies/auth_strategy.rb +60 -0
  16. data/lib/redd/auth_strategies/installed.rb +22 -0
  17. data/lib/redd/auth_strategies/script.rb +23 -0
  18. data/lib/redd/auth_strategies/userless.rb +17 -0
  19. data/lib/redd/auth_strategies/web.rb +29 -0
  20. data/lib/redd/client.rb +88 -0
  21. data/lib/redd/error.rb +19 -142
  22. data/lib/redd/models/access.rb +20 -0
  23. data/lib/redd/models/basic_model.rb +124 -0
  24. data/lib/redd/models/comment.rb +51 -0
  25. data/lib/redd/models/front_page.rb +71 -0
  26. data/lib/redd/models/inboxable.rb +23 -0
  27. data/lib/redd/models/lazy_model.rb +63 -0
  28. data/lib/redd/models/listing.rb +26 -0
  29. data/lib/redd/models/messageable.rb +20 -0
  30. data/lib/redd/models/moderatable.rb +41 -0
  31. data/lib/redd/models/more_comments.rb +10 -0
  32. data/lib/redd/models/multireddit.rb +32 -0
  33. data/lib/redd/models/postable.rb +70 -0
  34. data/lib/redd/models/private_message.rb +29 -0
  35. data/lib/redd/models/replyable.rb +16 -0
  36. data/lib/redd/models/session.rb +86 -0
  37. data/lib/redd/models/submission.rb +40 -0
  38. data/lib/redd/models/subreddit.rb +201 -0
  39. data/lib/redd/models/user.rb +72 -0
  40. data/lib/redd/models/wiki_page.rb +24 -0
  41. data/lib/redd/utilities/error_handler.rb +35 -0
  42. data/lib/redd/utilities/rate_limiter.rb +21 -0
  43. data/lib/redd/utilities/stream.rb +63 -0
  44. data/lib/redd/utilities/unmarshaller.rb +39 -0
  45. data/lib/redd/version.rb +4 -3
  46. data/logo.png +0 -0
  47. data/redd.gemspec +26 -22
  48. metadata +73 -99
  49. data/LICENSE.md +0 -22
  50. data/RedditKit.LICENSE.md +0 -9
  51. data/lib/redd/access.rb +0 -76
  52. data/lib/redd/clients/base.rb +0 -188
  53. data/lib/redd/clients/base/account.rb +0 -20
  54. data/lib/redd/clients/base/identity.rb +0 -22
  55. data/lib/redd/clients/base/none.rb +0 -27
  56. data/lib/redd/clients/base/privatemessages.rb +0 -33
  57. data/lib/redd/clients/base/read.rb +0 -113
  58. data/lib/redd/clients/base/stream.rb +0 -81
  59. data/lib/redd/clients/base/submit.rb +0 -19
  60. data/lib/redd/clients/base/utilities.rb +0 -104
  61. data/lib/redd/clients/base/wikiread.rb +0 -33
  62. data/lib/redd/clients/installed.rb +0 -57
  63. data/lib/redd/clients/script.rb +0 -41
  64. data/lib/redd/clients/userless.rb +0 -32
  65. data/lib/redd/clients/web.rb +0 -58
  66. data/lib/redd/objects/base.rb +0 -39
  67. data/lib/redd/objects/comment.rb +0 -22
  68. data/lib/redd/objects/labeled_multi.rb +0 -13
  69. data/lib/redd/objects/listing.rb +0 -29
  70. data/lib/redd/objects/more_comments.rb +0 -11
  71. data/lib/redd/objects/private_message.rb +0 -28
  72. data/lib/redd/objects/submission.rb +0 -139
  73. data/lib/redd/objects/subreddit.rb +0 -330
  74. data/lib/redd/objects/thing.rb +0 -26
  75. data/lib/redd/objects/thing/editable.rb +0 -22
  76. data/lib/redd/objects/thing/hideable.rb +0 -18
  77. data/lib/redd/objects/thing/inboxable.rb +0 -25
  78. data/lib/redd/objects/thing/messageable.rb +0 -34
  79. data/lib/redd/objects/thing/moderatable.rb +0 -43
  80. data/lib/redd/objects/thing/refreshable.rb +0 -14
  81. data/lib/redd/objects/thing/saveable.rb +0 -21
  82. data/lib/redd/objects/thing/votable.rb +0 -33
  83. data/lib/redd/objects/user.rb +0 -52
  84. data/lib/redd/objects/wiki_page.rb +0 -15
  85. data/lib/redd/rate_limit.rb +0 -88
  86. data/lib/redd/response/parse_json.rb +0 -18
  87. data/lib/redd/response/raise_error.rb +0 -16
  88. data/spec/redd/objects/base_spec.rb +0 -1
  89. data/spec/redd/response/raise_error_spec.rb +0 -11
  90. data/spec/redd_spec.rb +0 -5
  91. data/spec/spec_helper.rb +0 -71
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'client'
4
+ require_relative 'error'
5
+ require_relative 'utilities/error_handler'
6
+ require_relative 'utilities/rate_limiter'
7
+ require_relative 'utilities/unmarshaller'
8
+
9
+ module Redd
10
+ # The class for API clients.
11
+ class APIClient < Client
12
+ # The endpoint to make API requests to.
13
+ API_ENDPOINT = 'https://oauth.reddit.com'
14
+
15
+ # @return [APIClient] the access the client uses
16
+ attr_reader :access
17
+
18
+ # Create a new API client from an auth strategy.
19
+ # @param auth [AuthStrategies::AuthStrategy] the auth strategy to use
20
+ # @param endpoint [String] the API endpoint
21
+ # @param user_agent [String] the user agent to send
22
+ # @param limit_time [Integer] the minimum number of seconds between each request
23
+ # @param max_retries [Integer] number of times to retry requests that may be successful if
24
+ # retried
25
+ # @param auto_login [Boolean] (for script and userless) automatically authenticate if not done
26
+ # so already
27
+ # @param auto_refresh [Boolean] (for script and userless) automatically refresh access token if
28
+ # nearing expiration
29
+ def initialize(auth, endpoint: API_ENDPOINT, user_agent: USER_AGENT, limit_time: 1,
30
+ max_retries: 5, auto_login: true, auto_refresh: true)
31
+ super(endpoint: endpoint, user_agent: user_agent)
32
+
33
+ @auth = auth
34
+ @access = nil
35
+ @max_retries = max_retries
36
+ @failures = 0
37
+ @error_handler = Utilities::ErrorHandler.new
38
+ @rate_limiter = Utilities::RateLimiter.new(limit_time)
39
+ @unmarshaller = Utilities::Unmarshaller.new(self)
40
+
41
+ # FIXME: hard dependencies on Script and Userless types
42
+ can_auto = auth.is_a?(AuthStrategies::Script) || auth.is_a?(AuthStrategies::Userless)
43
+ @auto_login = can_auto && auto_login
44
+ @auto_refresh = can_auto && auto_refresh
45
+ end
46
+
47
+ # Authenticate the client using the provided auth.
48
+ def authenticate(*args)
49
+ @access = @auth.authenticate(*args)
50
+ end
51
+
52
+ # Refresh the access currently in use.
53
+ def refresh(*args)
54
+ @access = @auth.refresh(*args)
55
+ end
56
+
57
+ # Revoke the current access and remove it from the client.
58
+ def revoke
59
+ @auth.revoke(@access)
60
+ @access = nil
61
+ end
62
+
63
+ def unmarshal(object)
64
+ @unmarshaller.unmarshal(object)
65
+ end
66
+
67
+ def model(verb, path, params = {})
68
+ # XXX: make unmarshal explicit in methods?
69
+ unmarshal(send(verb, path, params).body)
70
+ end
71
+
72
+ private
73
+
74
+ # Makes sure a valid access is present, raising an error if nil
75
+ def ensure_access_is_valid
76
+ # Authenticate first if auto_login is enabled
77
+ authenticate if @access.nil? && @auto_login
78
+ # Refresh access if auto_refresh is enabled
79
+ refresh if @access.expired? && @auto_refresh
80
+ # Fuck it, panic
81
+ raise 'client access is nil, try calling #authenticate' if @access.nil?
82
+ end
83
+
84
+ def connection
85
+ super.auth("Bearer #{@access.access_token}")
86
+ end
87
+
88
+ # Makes a request, ensuring not to break the rate limit by sleeping.
89
+ # @see Client#request
90
+ def request(verb, path, params: {}, form: {})
91
+ # Make sure @access is populated by a valid access
92
+ ensure_access_is_valid
93
+ # Setup base API params and make request
94
+ api_params = { api_type: 'json', raw_json: 1 }.merge(params)
95
+ response = @rate_limiter.after_limit { super(verb, path, params: api_params, form: form) }
96
+ # Check for errors in the returned response
97
+ response_error = @error_handler.check_error(response)
98
+ raise response_error unless response_error.nil?
99
+ # All done, return the response
100
+ @failures = 0
101
+ response
102
+ rescue Redd::ServerError, HTTP::TimeoutError => e
103
+ @failures += 1
104
+ raise e if @failures > @max_retries
105
+ warn "Redd got a #{e.class.name} error (#{e.message}), retrying..."
106
+ retry
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../client'
4
+
5
+ module Redd
6
+ module AuthStrategies
7
+ # The API client for authentication to reddit.
8
+ class AuthStrategy < Client
9
+ # The API to make authentication requests to.
10
+ AUTH_ENDPOINT = 'https://www.reddit.com'
11
+
12
+ # @param client_id [String] the client id of the reddit app
13
+ # @param secret [String] the app's secret string
14
+ # @param endpoint [String] the url to contact for authentication requests
15
+ # @param user_agent [String] the user agent to send with requests
16
+ def initialize(client_id:, secret:, endpoint: AUTH_ENDPOINT, user_agent: USER_AGENT)
17
+ super(endpoint: endpoint, user_agent: user_agent)
18
+ @client_id = client_id
19
+ @secret = secret
20
+ end
21
+
22
+ # @abstract Perform authentication and return the resulting access object
23
+ # @return [Access] the access token object
24
+ def authenticate(*)
25
+ raise 'abstract method: this strategy cannot authenticate with reddit'
26
+ end
27
+
28
+ # @abstract Refresh the authentication and return the refreshed access
29
+ # @return [Access] the new access
30
+ def refresh(*)
31
+ raise 'abstract method: this strategy cannot refresh access'
32
+ end
33
+
34
+ # Revoke the access token, making it invalid for future requests.
35
+ # @param access [Access] the access object to revoke
36
+ def revoke(access)
37
+ token =
38
+ if access.is_a?(String)
39
+ access
40
+ elsif access.respond_to?(:refresh_token)
41
+ access.refresh_token
42
+ else
43
+ access.access_token
44
+ end
45
+ post('/api/v1/revoke_token', token: token).body
46
+ end
47
+
48
+ private
49
+
50
+ def connection
51
+ @connection ||= super.basic_auth(user: @client_id, pass: @secret)
52
+ end
53
+
54
+ def request_access(grant_type, options = {})
55
+ response = post('/api/v1/access_token', { grant_type: grant_type }.merge(options))
56
+ Models::Access.new(self, response.body)
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'auth_strategy'
4
+
5
+ module Redd
6
+ module AuthStrategies
7
+ # For non-confidential apps. Different from the implicit grant.
8
+ class Installed < AuthStrategy
9
+ def initialize(client_id:, redirect_uri:, **kwargs)
10
+ super(client_id: client_id, secret: '', **kwargs)
11
+ @redirect_uri = redirect_uri
12
+ end
13
+
14
+ # Authenticate with a code using the "web" flow.
15
+ # @param code [String] the code returned by reddit
16
+ # @return [Access]
17
+ def authenticate(code)
18
+ request_access('authorization_code', code: code, redirect_uri: @redirect_uri)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'auth_strategy'
4
+
5
+ module Redd
6
+ module AuthStrategies
7
+ # A password-based authentication scheme. Requests all scopes.
8
+ class Script < AuthStrategy
9
+ def initialize(client_id:, secret:, username:, password:, **kwargs)
10
+ super(client_id: client_id, secret: secret, **kwargs)
11
+ @username = username
12
+ @password = password
13
+ end
14
+
15
+ # Perform authentication and return the resulting access object
16
+ # @return [Access] the access token object
17
+ def authenticate
18
+ request_access('password', username: @username, password: @password)
19
+ end
20
+ alias refresh authenticate
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'auth_strategy'
4
+
5
+ module Redd
6
+ module AuthStrategies
7
+ # A userless authentication scheme.
8
+ class Userless < AuthStrategy
9
+ # Perform authentication and return the resulting access object
10
+ # @return [Access] the access token object
11
+ def authenticate
12
+ request_access('client_credentials')
13
+ end
14
+ alias refresh authenticate
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'auth_strategy'
4
+
5
+ module Redd
6
+ module AuthStrategies
7
+ # A typical code-based authentication. I genuinely recommend this for bots.
8
+ # Only confidential web apps can be refreshed.
9
+ class Web < AuthStrategy
10
+ def initialize(client_id:, secret:, redirect_uri:, **kwargs)
11
+ super(client_id: client_id, secret: secret, **kwargs)
12
+ @redirect_uri = redirect_uri
13
+ end
14
+
15
+ # Authenticate with a code using the "web" flow.
16
+ # @param code [String] the code returned by reddit
17
+ # @return [Access]
18
+ def authenticate(code)
19
+ request_access('authorization_code', code: code, redirect_uri: @redirect_uri)
20
+ end
21
+
22
+ # Refresh the authentication and return a new refreshed access
23
+ # @return [Access] the new access
24
+ def refresh(access)
25
+ request_access('refresh_token', refresh_token: must_have(access, :refresh_token))
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'http'
4
+ require 'json'
5
+
6
+ module Redd
7
+ # The base class for JSON-based HTTP clients. Generic enough to be used for basically anything.
8
+ class Client
9
+ # The default User-Agent to use if none was provided.
10
+ USER_AGENT = "Ruby:Redd:v#{Redd::VERSION} (by unknown)"
11
+
12
+ # Holds a returned HTTP response.
13
+ Response = Struct.new(:code, :headers, :raw_body) do
14
+ def body
15
+ @body ||= JSON.parse(raw_body, symbolize_names: true)
16
+ end
17
+ end
18
+
19
+ # Create a new client.
20
+ # @param endpoint [String] the base endpoint to make all requests from
21
+ # @param user_agent [String] a user agent string
22
+ def initialize(endpoint:, user_agent: USER_AGENT)
23
+ @endpoint = endpoint
24
+ @user_agent = user_agent
25
+ end
26
+
27
+ # Make a GET request.
28
+ # @param path [String] the path relative to the endpoint
29
+ # @param options [Hash] the parameters to supply
30
+ # @return [Response] the response
31
+ def get(path, options = {})
32
+ request(:get, path, params: options)
33
+ end
34
+
35
+ # Make a POST request.
36
+ # @param path [String] the path relative to the endpoint
37
+ # @param options [Hash] the parameters to supply
38
+ # @return [Response] the response
39
+ def post(path, options = {})
40
+ request(:post, path, form: options)
41
+ end
42
+
43
+ # Make a PUT request.
44
+ # @param path [String] the path relative to the endpoint
45
+ # @param options [Hash] the parameters to supply
46
+ # @return [Response] the response
47
+ def put(path, options = {})
48
+ request(:put, path, form: options)
49
+ end
50
+
51
+ # Make a PATCH request.
52
+ # @param path [String] the path relative to the endpoint
53
+ # @param options [Hash] the parameters to supply
54
+ # @return [Response] the response
55
+ def patch(path, options = {})
56
+ request(:patch, path, form: options)
57
+ end
58
+
59
+ # Make a DELETE request.
60
+ # @param path [String] the path relative to the endpoint
61
+ # @param options [Hash] the parameters to supply
62
+ # @return [Response] the response
63
+ def delete(path, options = {})
64
+ request(:delete, path, form: options)
65
+ end
66
+
67
+ private
68
+
69
+ # @return [HTTP::Connection] the base connection object
70
+ def connection
71
+ # TODO: Make timeouts configurable
72
+ @connection ||= HTTP.persistent(@endpoint)
73
+ .headers('User-Agent' => @user_agent)
74
+ .timeout(:per_operation, write: 5, connect: 5, read: 5)
75
+ end
76
+
77
+ # Make an HTTP request.
78
+ # @param verb [:get, :post, :put, :patch, :delete] the HTTP verb to use
79
+ # @param path [String] the path relative to the endpoint
80
+ # @param params [Hash] the parameters to supply with the url
81
+ # @param form [Hash] the parameters to supply in the body
82
+ # @return [Response] the response
83
+ def request(verb, path, params: {}, form: {})
84
+ response = connection.request(verb, path, params: params, form: form)
85
+ Response.new(response.status.code, response.headers, response.body.to_s)
86
+ end
87
+ end
88
+ end
@@ -1,151 +1,28 @@
1
- module Redd
2
- # An error from reddit
3
- # TODO: Move Error to an Errors module in next minor version?
4
- class Error < StandardError
5
- attr_reader :code
6
- attr_reader :headers
7
- attr_reader :body
8
-
9
- def initialize(env)
10
- @code = env[:status]
11
- @headers = env[:response_headers]
12
- @body = env[:body]
13
- end
14
-
15
- def self.from_response(env) # rubocop:disable all
16
- status = env[:status]
17
- body = parse_error(env[:body]).to_s
18
- case status
19
- when 200
20
- case body
21
- when /access_denied/i then OAuth2AccessDenied
22
- when /unsupported_response_type/i then InvalidResponseType
23
- when /unsupported_grant_type/i then InvalidGrantType
24
- when /invalid_scope/i then InvalidScope
25
- when /invalid_request/i then InvalidRequest
26
- when /no_text/i then NoTokenGiven
27
- when /invalid_grant/i then ExpiredCode
28
- when /wrong_password/i then InvalidCredentials
29
- when /bad_captcha/i then InvalidCaptcha
30
- when /ratelimit/i then RateLimited
31
- when /quota_filled/i then QuotaFilled
32
- when /bad_css_name/i then InvalidClassName
33
- when /too_old/i then Archived
34
- when /too_much_flair_css/i then TooManyClassNames
35
- when /user_required/i then AuthenticationRequired
36
- end
37
- when 400 then BadRequest
38
- when 401 then InvalidOAuth2Credentials
39
- when 403
40
- if /user_required/i =~ body
41
- AuthenticationRequired
42
- else
43
- PermissionDenied
44
- end
45
- when 404 then NotFound
46
- when 409 then Conflict
47
- when 500 then InternalServerError
48
- when 502 then BadGateway
49
- when 503 then ServiceUnavailable
50
- when 504 then TimedOut
51
- end
52
- end
53
-
54
- def self.parse_error(body) # rubocop:disable all
55
- return body unless body.is_a?(Hash)
56
-
57
- if body.key?(:json) && body[:json].key?(:errors)
58
- body[:json][:errors].first
59
- elsif body.key?(:jquery)
60
- body[:jquery]
61
- elsif body.key?(:error)
62
- body[:error]
63
- elsif body.key?(:code) && body[:code] == "NO_TEXT"
64
- "NO_TEXT"
65
- end
66
- end
67
-
68
- # This item has been archived and can no longer be edited.
69
- Archived = Class.new(Error)
70
-
71
- AuthenticationRequired = Class.new(Error)
72
-
73
- # Bad Gateway. Either a network or a reddit error. Either way, try again.
74
- BadGateway = Class.new(Error)
75
-
76
- BadRequest = Class.new(Error)
77
-
78
- Conflict = Class.new(Error)
79
-
80
- # You already received an access token using this code. The user should
81
- # grant you access again to get a new code.
82
- ExpiredCode = Class.new(Error)
83
-
84
- # There is an issue on reddit's end. Try again.
85
- InternalServerError = Class.new(Error)
86
-
87
- InvalidCaptcha = Class.new(Error)
88
-
89
- InvalidClassName = Class.new(Error)
1
+ # frozen_string_literal: true
90
2
 
91
- # Either your username or your password is wrong.
92
- InvalidCredentials = Class.new(Error)
93
-
94
- InvalidGrantType = Class.new(Error)
95
-
96
- InvalidMultiredditName = Class.new(Error)
97
-
98
- # Your client id or your secret is wrong.
99
- InvalidOAuth2Credentials = Class.new(Error)
100
-
101
- InvalidResponseType = Class.new(Error)
102
-
103
- InvalidRequest = Class.new(Error)
104
-
105
- # You don't have the proper scope to perform this request.
106
- InvalidScope = Class.new(Error)
107
-
108
- # Looks like we didn't get a JSON response. Raise this error.
109
- JSONError = Class.new(Error)
110
-
111
- # Four, oh four! The thing you're looking for wasn't found.
112
- NotFound = Class.new(Error)
113
-
114
- # No access token was given.
115
- NoTokenGiven = Class.new(Error)
116
-
117
- OAuth2AccessDenied = Class.new(Error)
118
-
119
- PermissionDenied = Class.new(Error)
120
-
121
- # Raised when the client needs to wait before making another request
122
- class RateLimited < Error
123
- # @!attribute [r] time
124
- # @return [Integer] the seconds to wait before making another request.
125
- attr_reader :time
3
+ module Redd
4
+ # Represents an error from reddit returned in a response.
5
+ class ResponseError < StandardError
6
+ attr_accessor :response
126
7
 
127
- def initialize(env)
128
- super
129
- @time = env[:body][:json][:ratelimit] || 3600
130
- end
8
+ def initialize(response)
9
+ super(response.raw_body.length <= 80 ? response.raw_body : "#{response.raw_body[0..80]}...")
10
+ @response = response
131
11
  end
12
+ end
132
13
 
133
- # This seems to be a more OAuth2-focused error.
134
- class QuotaFilled < RateLimited
135
- def initialize(env)
136
- super
137
- @time = 3600
138
- end
139
- end
14
+ # An error with Redd, probably (let me know!)
15
+ class BadRequest < ResponseError; end
140
16
 
141
- RequestError = Class.new(Error)
17
+ # You don't have the correct scope to do this.
18
+ class InsufficientScope < ResponseError; end
142
19
 
143
- # Issue on reddit's end. Try again.
144
- ServiceUnavailable = Class.new(Error)
20
+ # The access object supplied was invalid.
21
+ class InvalidAccess < ResponseError; end
145
22
 
146
- # The connection timed out. Try again.
147
- TimedOut = Class.new(Error)
23
+ # Returned when reddit raises a 404 error.
24
+ class NotFound < ResponseError; end
148
25
 
149
- TooManyClassNames = Class.new(Error)
150
- end
26
+ # An unknown error on reddit's end. Usually fixed with a retry.
27
+ class ServerError < ResponseError; end
151
28
  end