x 0.11.0 → 0.12.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e5af820e6e3cd8b04becf201ad7af96c8d95d9206112673888f91c5ecf923187
4
- data.tar.gz: 6f0f561be192153eca2cc7356c823c87bd98f0987256875892c0a2e9ea12b82d
3
+ metadata.gz: 553e0e0e9a87126211bf62942b02cb2c7c8b6d9380ee8fd4336058038c9735b0
4
+ data.tar.gz: 34922d1e52a0c04de3c6fe090e854174d78d437c8e14b061a95ca4ab5e1a0438
5
5
  SHA512:
6
- metadata.gz: 8b208d5ba10604a34a57b0dd2ee5dc0992eb801cfbcc287a7f1b0d54444f214050a2c25b2ec6291868a8c35fff09260ad91a82641be830b87d3e6abf0207fc3e
7
- data.tar.gz: 24c6a1083358b60065698320372d53404bab9c319aaad13b097508a7041653ac9d45aabd8e6f7a99204accddfc1beeeb2bce18bd67fa9ba816eb6282cbab4227
6
+ metadata.gz: e28c53b17bb053c26326d46940eb539c4612d6f4a547b074e974c2b6ddee616bb37af6c2dd2d4d84b05a21aa2946306de074889b90bd411b2a268793e40d72a0
7
+ data.tar.gz: 79ab655009e07915cf71158bb2976301d1519093c753b6f8fc1d128781b4844e867551c3d430bb0226aa113d3c7342efdd401e08d4f3d920a7437dfa8c432ba6
data/CHANGELOG.md CHANGED
@@ -1,3 +1,13 @@
1
+ ## [0.12.1] - 2023-11-28
2
+ * Ensure split chunks are written as binary (c6e257f)
3
+ * Require tmpdir in X::MediaUploader (9e7c7f1)
4
+
5
+ ## [0.12.0] - 2023-11-02
6
+ * Ensure Authenticator is passed to RedirectHandler (fc8557b)
7
+ * Add AUTHENTICATION_HEADER to X::Authenticator base class (85a2818)
8
+ * Introduce X::HTTPError (90ae132)
9
+ * Add `code` attribute to error classes (b003639)
10
+
1
11
  ## [0.11.0] - 2023-10-24
2
12
 
3
13
  * Add base Authenticator class (8c66ce2)
data/README.md CHANGED
@@ -1,3 +1,9 @@
1
+ ![Tests](https://github.com/sferik/x-ruby/actions/workflows/test.yml/badge.svg)
2
+ ![Linter](https://github.com/sferik/x-ruby/actions/workflows/lint.yml/badge.svg)
3
+ ![Mutant](https://github.com/sferik/x-ruby/actions/workflows/mutant.yml/badge.svg)
4
+ ![Typer Checker](https://github.com/sferik/x-ruby/actions/workflows/type_check.yml/badge.svg)
5
+ [![Gem Version](https://badge.fury.io/rb/x.svg)](https://rubygems.org/gems/x)
6
+
1
7
  # A [Ruby](https://www.ruby-lang.org) interface to the [X API](https://developer.x.com)
2
8
 
3
9
  ## Follow
@@ -35,24 +41,24 @@ x_client = X::Client.new(**x_credentials)
35
41
  x_client.get("users/me")
36
42
  # {"data"=>{"id"=>"7505382", "name"=>"Erik Berlin", "username"=>"sferik"}}
37
43
 
38
- # Post a tweet
39
- tweet = x_client.post("tweets", '{"text":"Hello, World! (from @gem)"}')
44
+ # Post
45
+ post = x_client.post("tweets", '{"text":"Hello, World! (from @gem)"}')
40
46
  # {"data"=>{"edit_history_tweet_ids"=>["1234567890123456789"], "id"=>"1234567890123456789", "text"=>"Hello, World! (from @gem)"}}
41
47
 
42
- # Delete the tweet you just posted
43
- x_client.delete("tweets/#{tweet["data"]["id"]}")
48
+ # Delete the post
49
+ x_client.delete("tweets/#{post["data"]["id"]}")
44
50
  # {"data"=>{"deleted"=>true}}
45
51
 
46
52
  # Initialize an API v1.1 client
47
53
  v1_client = X::Client.new(base_url: "https://api.twitter.com/1.1/", **x_credentials)
48
54
 
49
- # Request your account settings
55
+ # Get your account settings
50
56
  v1_client.get("account/settings.json")
51
57
 
52
58
  # Initialize an X Ads API client
53
59
  ads_client = X::Client.new(base_url: "https://ads-api.twitter.com/12/", **x_credentials)
54
60
 
55
- # Request your ad accounts
61
+ # Get your ad accounts
56
62
  ads_client.get("accounts")
57
63
  ```
58
64
 
@@ -60,7 +66,7 @@ See other common usage [examples](https://github.com/sferik/x-ruby/tree/main/exa
60
66
 
61
67
  ## History and Philosophy
62
68
 
63
- This library is a rewrite of the [Twitter Ruby library](https://github.com/sferik/twitter). Over 16 years of development, that library ballooned to over 3,000 lines of code (plus 7,500 lines of tests). At the time of writing, this library is about 300 lines of code (plus 200 test lines) and I’d like to keep it that way. That doesn’t mean new features won’t be added over time, but the benefits of more code must be weighted against the benefits of less:
69
+ This library is a rewrite of the [Twitter Ruby library](https://github.com/sferik/twitter). Over 16 years of development, that library ballooned to over 3,000 lines of code (plus 7,500 lines of tests), not counting dependencies. This library is about 500 lines of code (plus 1000 test lines) and has no runtime dependencies. That doesn’t mean new features won’t be added over time, but the benefits of more code must be weighed against the benefits of less:
64
70
 
65
71
  * Less code is easier to maintain.
66
72
  * Less code means fewer bugs.
@@ -70,9 +76,7 @@ In the immortal words of [Ezra Zygmuntowicz](https://github.com/ezmobius) and hi
70
76
 
71
77
  > No code is faster than no code.
72
78
 
73
- The fastest code is the code that is never executed because it doesn’t exist. That principle should apply not just to this library itself but to third-party dependencies. At present, this library has no runtime dependencies and I’d like to keep it that way.
74
-
75
- The tests for the previous version of this library executed in about 2 seconds. That sounds pretty fast until you see that tests for this library run in 2 hundredths of a second. This means you can automatically run the tests any time you write a file and receive immediate feedback. For such of workflows, 2 seconds feels painfully slow.
79
+ The tests for the previous version of this library executed in about 2 seconds. That sounds pretty fast until you see that tests for this library run in one-twentieth of a second. This means you can automatically run the tests any time you write a file and receive immediate feedback. For such of workflows, 2 seconds feels painfully slow.
76
80
 
77
81
  This code is not littered with comments that are intended to generate documentation. Rather, this code is intended to be simple enough to serve as its own documentation. If you want to understand how something works, don’t read the documentation—it might be wrong—read the code. The code is always right.
78
82
 
@@ -97,6 +101,8 @@ Many thanks to our sponsors (listed in order of when they sponsored this project
97
101
  <a href="https://betterstack.com"><img src="https://raw.githubusercontent.com/sferik/x-ruby/main/sponsor_logos/better_stack.svg" alt="Better Stack" width="200" align="middle"></a>
98
102
  <img src="https://raw.githubusercontent.com/sferik/x-ruby/main/sponsor_logos/spacer.png" width="20" align="middle">
99
103
  <a href="https://sentry.io"><img src="https://raw.githubusercontent.com/sferik/x-ruby/main/sponsor_logos/sentry.svg" alt="Sentry" width="200" align="middle"></a>
104
+ <img src="https://raw.githubusercontent.com/sferik/x-ruby/main/sponsor_logos/spacer.png" width="20" align="middle">
105
+ <a href="https://ifttt.com"><img src="https://raw.githubusercontent.com/sferik/x-ruby/main/sponsor_logos/ifttt.svg" alt="IFTTT" width="200" align="middle"></a>
100
106
 
101
107
  ## Development
102
108
 
@@ -1,8 +1,9 @@
1
1
  module X
2
- # Base Authenticator class
3
2
  class Authenticator
3
+ AUTHENTICATION_HEADER = "Authorization".freeze
4
+
4
5
  def header(_request)
5
- {"Authorization" => ""}
6
+ {AUTHENTICATION_HEADER => ""}
6
7
  end
7
8
  end
8
9
  end
@@ -1,7 +1,6 @@
1
1
  require_relative "authenticator"
2
2
 
3
3
  module X
4
- # Handles bearer token authentication
5
4
  class BearerTokenAuthenticator < Authenticator
6
5
  attr_accessor :bearer_token
7
6
 
@@ -10,7 +9,7 @@ module X
10
9
  end
11
10
 
12
11
  def header(_request)
13
- {"Authorization" => "Bearer #{bearer_token}"}
12
+ {AUTHENTICATION_HEADER => "Bearer #{bearer_token}"}
14
13
  end
15
14
  end
16
15
  end
data/lib/x/cgi.rb CHANGED
@@ -1,7 +1,6 @@
1
1
  require "cgi"
2
2
 
3
3
  module X
4
- # Namespaced CGI class
5
4
  class CGI
6
5
  # TODO: Replace CGI.escape with CGI.escapeURIComponent when support for Ruby 3.1 is dropped
7
6
  def self.escape(value)
data/lib/x/client.rb CHANGED
@@ -7,16 +7,14 @@ require_relative "request_builder"
7
7
  require_relative "response_parser"
8
8
 
9
9
  module X
10
- # Main public interface
11
10
  class Client
12
11
  extend Forwardable
13
12
 
14
13
  DEFAULT_BASE_URL = "https://api.twitter.com/2/".freeze
15
14
 
16
15
  attr_accessor :base_url
16
+ attr_reader :api_key, :api_key_secret, :access_token, :access_token_secret, :bearer_token
17
17
 
18
- def_delegators :@authenticator, :bearer_token, :api_key, :api_key_secret, :access_token, :access_token_secret
19
- def_delegators :@authenticator, :bearer_token=, :api_key=, :api_key_secret=, :access_token=, :access_token_secret=
20
18
  def_delegators :@connection, :open_timeout, :read_timeout, :write_timeout, :proxy_url, :debug_output
21
19
  def_delegators :@connection, :open_timeout=, :read_timeout=, :write_timeout=, :proxy_url=, :debug_output=
22
20
  def_delegators :@redirect_handler, :max_redirects
@@ -24,25 +22,27 @@ module X
24
22
  def_delegators :@response_parser, :array_class, :object_class
25
23
  def_delegators :@response_parser, :array_class=, :object_class=
26
24
 
27
- def initialize(bearer_token: nil,
28
- api_key: nil, api_key_secret: nil, access_token: nil, access_token_secret: nil,
25
+ def initialize(api_key: nil, api_key_secret: nil, access_token: nil, access_token_secret: nil,
26
+ bearer_token: nil,
29
27
  base_url: DEFAULT_BASE_URL,
30
28
  open_timeout: Connection::DEFAULT_OPEN_TIMEOUT,
31
29
  read_timeout: Connection::DEFAULT_READ_TIMEOUT,
32
30
  write_timeout: Connection::DEFAULT_WRITE_TIMEOUT,
31
+ debug_output: Connection::DEFAULT_DEBUG_OUTPUT,
33
32
  proxy_url: nil,
34
- debug_output: nil,
35
33
  array_class: nil,
36
34
  object_class: nil,
37
35
  max_redirects: RedirectHandler::DEFAULT_MAX_REDIRECTS)
38
36
 
37
+ initialize_oauth(api_key, api_key_secret, access_token, access_token_secret)
38
+ @bearer_token = bearer_token
39
+ initialize_authenticator
39
40
  @base_url = base_url
40
- initialize_authenticator(bearer_token, api_key, api_key_secret, access_token, access_token_secret)
41
41
  @connection = Connection.new(open_timeout: open_timeout, read_timeout: read_timeout,
42
42
  write_timeout: write_timeout, debug_output: debug_output, proxy_url: proxy_url)
43
43
  @request_builder = RequestBuilder.new
44
- @redirect_handler = RedirectHandler.new(authenticator: @authenticator, connection: @connection,
45
- request_builder: @request_builder, max_redirects: max_redirects)
44
+ @redirect_handler = RedirectHandler.new(connection: @connection, request_builder: @request_builder,
45
+ max_redirects: max_redirects)
46
46
  @response_parser = ResponseParser.new(array_class: array_class, object_class: object_class)
47
47
  end
48
48
 
@@ -62,25 +62,60 @@ module X
62
62
  execute_request(:delete, endpoint, headers: headers)
63
63
  end
64
64
 
65
+ def api_key=(api_key)
66
+ @api_key = api_key
67
+ initialize_authenticator
68
+ end
69
+
70
+ def api_key_secret=(api_key_secret)
71
+ @api_key_secret = api_key_secret
72
+ initialize_authenticator
73
+ end
74
+
75
+ def access_token=(access_token)
76
+ @access_token = access_token
77
+ initialize_authenticator
78
+ end
79
+
80
+ def access_token_secret=(access_token_secret)
81
+ @access_token_secret = access_token_secret
82
+ initialize_authenticator
83
+ end
84
+
85
+ def bearer_token=(bearer_token)
86
+ @bearer_token = bearer_token
87
+ initialize_authenticator
88
+ end
89
+
65
90
  private
66
91
 
67
- def initialize_authenticator(bearer_token, api_key, api_key_secret, access_token, access_token_secret)
68
- @authenticator = if bearer_token
69
- BearerTokenAuthenticator.new(bearer_token: bearer_token)
70
- elsif api_key && api_key_secret && access_token && access_token_secret
92
+ def initialize_oauth(api_key, api_key_secret, access_token, access_token_secret)
93
+ @api_key = api_key
94
+ @api_key_secret = api_key_secret
95
+ @access_token = access_token
96
+ @access_token_secret = access_token_secret
97
+ end
98
+
99
+ def initialize_authenticator
100
+ @authenticator = if api_key && api_key_secret && access_token && access_token_secret
71
101
  OAuthAuthenticator.new(api_key: api_key, api_key_secret: api_key_secret, access_token: access_token,
72
102
  access_token_secret: access_token_secret)
73
- else
103
+ elsif bearer_token
104
+ BearerTokenAuthenticator.new(bearer_token: bearer_token)
105
+ elsif @authenticator.nil?
74
106
  Authenticator.new
107
+ else
108
+ @authenticator
75
109
  end
76
110
  end
77
111
 
78
112
  def execute_request(http_method, endpoint, headers:, body: nil)
79
113
  uri = URI.join(base_url, endpoint)
80
- request = @request_builder.build(authenticator: @authenticator, http_method: http_method, uri: uri, body: body,
81
- headers: headers)
114
+ request = @request_builder.build(http_method: http_method, uri: uri, body: body, headers: headers,
115
+ authenticator: @authenticator)
82
116
  response = @connection.perform(request: request)
83
- response = @redirect_handler.handle(response: response, request: request, base_url: base_url)
117
+ response = @redirect_handler.handle(response: response, request: request, base_url: base_url,
118
+ authenticator: @authenticator)
84
119
  @response_parser.parse(response: response)
85
120
  end
86
121
  end
data/lib/x/connection.rb CHANGED
@@ -5,7 +5,6 @@ require "uri"
5
5
  require_relative "errors/network_error"
6
6
 
7
7
  module X
8
- # Sends HTTP requests
9
8
  class Connection
10
9
  extend Forwardable
11
10
 
@@ -1,5 +1,5 @@
1
- require_relative "error"
1
+ require_relative "http_error"
2
2
 
3
3
  module X
4
- class ClientError < Error; end
4
+ class ClientError < HTTPError; end
5
5
  end
@@ -1,8 +1,3 @@
1
1
  module X
2
- # Base error class
3
- class Error < ::StandardError
4
- def initialize(msg, _response = nil)
5
- super(msg)
6
- end
7
- end
2
+ class Error < StandardError; end
8
3
  end
@@ -0,0 +1,41 @@
1
+ require "json"
2
+ require_relative "error"
3
+
4
+ module X
5
+ class HTTPError < Error
6
+ JSON_CONTENT_TYPE_REGEXP = %r{application/(problem\+|)json}
7
+
8
+ attr_reader :response, :code
9
+
10
+ def initialize(response:)
11
+ super(error_message(response))
12
+ @response = response
13
+ @code = response.code
14
+ end
15
+
16
+ def error_message(response)
17
+ if json?(response)
18
+ message_from_json_response(response)
19
+ else
20
+ response.message
21
+ end
22
+ end
23
+
24
+ def message_from_json_response(response)
25
+ response_object = JSON.parse(response.body)
26
+ if response_object.key?("title") && response_object.key?("detail")
27
+ "#{response_object.fetch("title")}: #{response_object.fetch("detail")}"
28
+ elsif response_object.key?("error")
29
+ response_object.fetch("error")
30
+ elsif response_object["errors"].instance_of?(Array)
31
+ response_object.fetch("errors").map { |error| error.fetch("message") }.join(", ")
32
+ else
33
+ response.message
34
+ end
35
+ end
36
+
37
+ def json?(response)
38
+ JSON_CONTENT_TYPE_REGEXP.match?(response["content-type"])
39
+ end
40
+ end
41
+ end
@@ -1,5 +1,5 @@
1
- require_relative "error"
1
+ require_relative "http_error"
2
2
 
3
3
  module X
4
- class ServerError < Error; end
4
+ class ServerError < HTTPError; end
5
5
  end
@@ -1,5 +1,5 @@
1
- require_relative "client_error"
1
+ require_relative "error"
2
2
 
3
3
  module X
4
- class TooManyRedirects < ClientError; end
4
+ class TooManyRedirects < Error; end
5
5
  end
@@ -1,23 +1,17 @@
1
1
  require_relative "client_error"
2
2
 
3
3
  module X
4
- # Rate limit error
5
4
  class TooManyRequests < ClientError
6
- def initialize(msg, response)
7
- @response = response
8
- super(msg)
9
- end
10
-
11
5
  def limit
12
- @response["x-rate-limit-limit"].to_i
6
+ response["x-rate-limit-limit"].to_i
13
7
  end
14
8
 
15
9
  def remaining
16
- @response["x-rate-limit-remaining"].to_i
10
+ response["x-rate-limit-remaining"].to_i
17
11
  end
18
12
 
19
13
  def reset_at
20
- Time.at(@response["x-rate-limit-reset"].to_i)
14
+ Time.at(response["x-rate-limit-reset"].to_i)
21
15
  end
22
16
 
23
17
  def reset_in
@@ -1,7 +1,7 @@
1
1
  require "securerandom"
2
+ require "tmpdir"
2
3
 
3
4
  module X
4
- # Helper module for uploading images and videos
5
5
  module MediaUploader
6
6
  extend self
7
7
 
@@ -19,7 +19,7 @@ module X
19
19
  boundary: SecureRandom.hex)
20
20
  validate!(file_path: file_path, media_category: media_category)
21
21
  upload_client = client.dup.tap { |c| c.base_url = "https://upload.twitter.com/1.1/" }
22
- upload_body = construct_upload_body(file_path, media_type, boundary)
22
+ upload_body = construct_upload_body(file_path: file_path, media_type: media_type, boundary: boundary)
23
23
  headers = {"Content-Type" => "multipart/form-data, boundary=#{boundary}"}
24
24
  upload_client.post("media/upload.json?media_category=#{media_category}", upload_body, headers: headers)
25
25
  end
@@ -28,10 +28,11 @@ module X
28
28
  media_category), boundary: SecureRandom.hex, chunk_size_mb: 8)
29
29
  validate!(file_path: file_path, media_category: media_category)
30
30
  upload_client = client.dup.tap { |c| c.base_url = "https://upload.twitter.com/1.1/" }
31
- media = init(upload_client, file_path, media_type, media_category)
31
+ media = init(upload_client: upload_client, file_path: file_path, media_type: media_type,
32
+ media_category: media_category)
32
33
  chunk_size = chunk_size_mb * BYTES_PER_MB
33
- chunk_paths = split(file_path, chunk_size)
34
- append(upload_client, chunk_paths, media, media_type, boundary)
34
+ append(upload_client: upload_client, file_paths: split(file_path, chunk_size), media: media,
35
+ media_type: media_type, boundary: boundary)
35
36
  upload_client.post("media/upload.json?command=FINALIZE&media_id=#{media["media_id"]}")
36
37
  end
37
38
 
@@ -64,54 +65,55 @@ module X
64
65
  end
65
66
  end
66
67
 
67
- def init(upload_client, file_path, media_type, media_category)
68
- total_bytes = File.size(file_path)
69
- query = "command=INIT&media_type=#{media_type}&media_category=#{media_category}&total_bytes=#{total_bytes}"
70
- upload_client.post("media/upload.json?#{query}")
71
- end
72
-
73
68
  def split(file_path, chunk_size)
74
69
  file_number = -1
75
70
 
76
- [].tap do |chunk_paths|
71
+ [].tap do |file_paths|
77
72
  File.open(file_path, "rb") do |f|
78
73
  while (chunk = f.read(chunk_size))
79
- chunk_paths << "#{Dir.mktmpdir}/x#{format("%03d", file_number += 1)}".tap do |path|
80
- File.write(path, chunk)
74
+ file_paths << "#{Dir.mktmpdir}/x#{format("%03d", file_number += 1)}".tap do |path|
75
+ File.binwrite(path, chunk)
81
76
  end
82
77
  end
83
78
  end
84
79
  end
85
80
  end
86
81
 
87
- def append(upload_client, chunk_paths, media, media_type, boundary = SecureRandom.hex)
88
- threads = chunk_paths.map.with_index do |chunk_path, index|
82
+ def init(upload_client:, file_path:, media_type:, media_category:)
83
+ total_bytes = File.size(file_path)
84
+ query = "command=INIT&media_type=#{media_type}&media_category=#{media_category}&total_bytes=#{total_bytes}"
85
+ upload_client.post("media/upload.json?#{query}")
86
+ end
87
+
88
+ def append(upload_client:, file_paths:, media:, media_type:, boundary: SecureRandom.hex)
89
+ threads = file_paths.map.with_index do |file_path, index|
89
90
  Thread.new do
90
- upload_body = construct_upload_body(chunk_path, media_type, boundary)
91
+ upload_body = construct_upload_body(file_path: file_path, media_type: media_type, boundary: boundary)
91
92
  query = "command=APPEND&media_id=#{media["media_id"]}&segment_index=#{index}"
92
93
  headers = {"Content-Type" => "multipart/form-data, boundary=#{boundary}"}
93
- upload_chunk(upload_client, query, upload_body, chunk_path, headers)
94
+ upload_chunk(upload_client: upload_client, query: query, upload_body: upload_body, file_path: file_path,
95
+ headers: headers)
94
96
  end
95
97
  end
96
98
  threads.each(&:join)
97
99
  end
98
100
 
99
- def upload_chunk(upload_client, query, upload_body, chunk_path, headers = {})
101
+ def upload_chunk(upload_client:, query:, upload_body:, file_path:, headers: {})
100
102
  upload_client.post("media/upload.json?#{query}", upload_body, headers: headers)
101
103
  rescue NetworkError, ServerError
102
104
  retries ||= 0
103
105
  ((retries += 1) < MAX_RETRIES) ? retry : raise
104
106
  ensure
105
- cleanup_chunk(chunk_path)
107
+ cleanup_file(file_path)
106
108
  end
107
109
 
108
- def cleanup_chunk(chunk_path)
109
- dirname = File.dirname(chunk_path)
110
- File.delete(chunk_path)
110
+ def cleanup_file(file_path)
111
+ dirname = File.dirname(file_path)
112
+ File.delete(file_path)
111
113
  Dir.delete(dirname) if Dir.empty?(dirname)
112
114
  end
113
115
 
114
- def construct_upload_body(file_path, media_type, boundary = SecureRandom.hex)
116
+ def construct_upload_body(file_path:, media_type:, boundary: SecureRandom.hex)
115
117
  "--#{boundary}\r\n" \
116
118
  "Content-Disposition: form-data; name=\"media\"; filename=\"#{File.basename(file_path)}\"\r\n" \
117
119
  "Content-Type: #{media_type}\r\n\r\n" \
@@ -7,7 +7,6 @@ require_relative "authenticator"
7
7
  require_relative "cgi"
8
8
 
9
9
  module X
10
- # Handles OAuth authentication
11
10
  class OAuthAuthenticator < Authenticator
12
11
  OAUTH_VERSION = "1.0".freeze
13
12
  OAUTH_SIGNATURE_METHOD = "HMAC-SHA1".freeze
@@ -24,7 +23,7 @@ module X
24
23
 
25
24
  def header(request)
26
25
  method, url, query_params = parse_request(request)
27
- {"Authorization" => build_oauth_header(method, url, query_params)}
26
+ {AUTHENTICATION_HEADER => build_oauth_header(method, url, query_params)}
28
27
  end
29
28
 
30
29
  private
@@ -6,28 +6,26 @@ require_relative "errors/too_many_redirects"
6
6
  require_relative "request_builder"
7
7
 
8
8
  module X
9
- # Handles HTTP redirects
10
9
  class RedirectHandler
11
10
  DEFAULT_MAX_REDIRECTS = 10
12
11
 
13
12
  attr_accessor :max_redirects
14
- attr_reader :authenticator, :connection, :request_builder
13
+ attr_reader :connection, :request_builder
15
14
 
16
- def initialize(authenticator: Authenticator.new, connection: Connection.new, request_builder: RequestBuilder.new,
15
+ def initialize(connection: Connection.new, request_builder: RequestBuilder.new,
17
16
  max_redirects: DEFAULT_MAX_REDIRECTS)
18
- @authenticator = authenticator
19
17
  @connection = connection
20
18
  @request_builder = request_builder
21
19
  @max_redirects = max_redirects
22
20
  end
23
21
 
24
- def handle(response:, request:, base_url:, redirect_count: 0)
22
+ def handle(response:, request:, base_url:, authenticator: Authenticator.new, redirect_count: 0)
25
23
  if response.is_a?(Net::HTTPRedirection)
26
24
  raise TooManyRedirects, "Too many redirects" if redirect_count > max_redirects
27
25
 
28
26
  new_uri = build_new_uri(response, base_url)
29
27
 
30
- new_request = build_request(request, new_uri, Integer(response.code))
28
+ new_request = build_request(request, new_uri, Integer(response.code), authenticator)
31
29
  new_response = connection.perform(request: new_request)
32
30
 
33
31
  handle(response: new_response, request: new_request, base_url: base_url, redirect_count: redirect_count + 1)
@@ -44,7 +42,7 @@ module X
44
42
  URI.join(base_url, location)
45
43
  end
46
44
 
47
- def build_request(request, new_uri, response_code)
45
+ def build_request(request, new_uri, response_code, authenticator)
48
46
  http_method, body = case response_code
49
47
  in 307 | 308
50
48
  [request.method.downcase.to_sym, request.body]
@@ -5,7 +5,6 @@ require_relative "cgi"
5
5
  require_relative "version"
6
6
 
7
7
  module X
8
- # Creates HTTP requests
9
8
  class RequestBuilder
10
9
  DEFAULT_HEADERS = {
11
10
  "Content-Type" => "application/json; charset=utf-8",
@@ -3,7 +3,7 @@ require "net/http"
3
3
  require_relative "errors/bad_gateway"
4
4
  require_relative "errors/bad_request"
5
5
  require_relative "errors/connection_exception"
6
- require_relative "errors/error"
6
+ require_relative "errors/http_error"
7
7
  require_relative "errors/forbidden"
8
8
  require_relative "errors/gateway_timeout"
9
9
  require_relative "errors/gone"
@@ -17,9 +17,8 @@ require_relative "errors/unauthorized"
17
17
  require_relative "errors/unprocessable_entity"
18
18
 
19
19
  module X
20
- # Process HTTP responses
21
20
  class ResponseParser
22
- ERROR_CLASSES = {
21
+ ERROR_MAP = {
23
22
  400 => BadRequest,
24
23
  401 => Unauthorized,
25
24
  403 => Forbidden,
@@ -35,7 +34,7 @@ module X
35
34
  503 => ServiceUnavailable,
36
35
  504 => GatewayTimeout
37
36
  }.freeze
38
- JSON_CONTENT_TYPE_REGEXP = %r{application/(problem\+|)json}
37
+ JSON_CONTENT_TYPE_REGEXP = %r{application/json}
39
38
 
40
39
  attr_accessor :array_class, :object_class
41
40
 
@@ -45,48 +44,25 @@ module X
45
44
  end
46
45
 
47
46
  def parse(response:)
48
- raise error(response) unless success?(response)
47
+ raise error(response) unless response.is_a?(Net::HTTPSuccess)
49
48
 
50
- JSON.parse(response.body, array_class: array_class, object_class: object_class) if json?(response)
49
+ return unless json?(response)
50
+
51
+ JSON.parse(response.body, array_class: array_class, object_class: object_class)
51
52
  end
52
53
 
53
54
  private
54
55
 
55
- def success?(response)
56
- response.is_a?(Net::HTTPSuccess)
57
- end
58
-
59
56
  def error(response)
60
- error_class(response).new(error_message(response), response)
57
+ error_class(response).new(response: response)
61
58
  end
62
59
 
63
60
  def error_class(response)
64
- ERROR_CLASSES[Integer(response.code)] || Error
65
- end
66
-
67
- def error_message(response)
68
- if json?(response)
69
- message_from_json_response(response)
70
- else
71
- response.message
72
- end
73
- end
74
-
75
- def message_from_json_response(response)
76
- response_object = JSON.parse(response.body)
77
- if response_object.key?("title") && response_object.key?("detail")
78
- "#{response_object.fetch("title")}: #{response_object.fetch("detail")}"
79
- elsif response_object.key?("error")
80
- response_object.fetch("error")
81
- elsif response_object["errors"].instance_of?(Array)
82
- response_object.fetch("errors").map { |error| error.fetch("message") }.join(", ")
83
- else
84
- response.message
85
- end
61
+ ERROR_MAP[Integer(response.code)] || HTTPError
86
62
  end
87
63
 
88
64
  def json?(response)
89
- response.body && JSON_CONTENT_TYPE_REGEXP.match?(response["content-type"])
65
+ JSON_CONTENT_TYPE_REGEXP.match?(response["content-type"])
90
66
  end
91
67
  end
92
68
  end
data/lib/x/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  require "rubygems/version"
2
2
 
3
3
  module X
4
- VERSION = Gem::Version.create("0.11.0")
4
+ VERSION = Gem::Version.create("0.12.1")
5
5
  end
data/sig/x.rbs CHANGED
@@ -2,16 +2,18 @@ module X
2
2
  VERSION: Gem::Version
3
3
 
4
4
  class Authenticator
5
+ AUTHENTICATION_HEADER: String
6
+
5
7
  def header: (Net::HTTPRequest? request) -> Hash[String, String]
6
8
  end
7
9
 
8
- class BearerTokenAuthenticator
10
+ class BearerTokenAuthenticator < Authenticator
9
11
  attr_accessor bearer_token: String
10
12
  def initialize: (bearer_token: String) -> void
11
13
  def header: (Net::HTTPRequest? request) -> Hash[String, String]
12
14
  end
13
15
 
14
- class OAuthAuthenticator
16
+ class OAuthAuthenticator < Authenticator
15
17
  OAUTH_VERSION: String
16
18
  OAUTH_SIGNATURE_METHOD: String
17
19
  OAUTH_SIGNATURE_ALGORITHM: String
@@ -37,7 +39,10 @@ module X
37
39
  def escape: (String value) -> String
38
40
  end
39
41
 
40
- class ClientError < Error
42
+ class Error < StandardError
43
+ end
44
+
45
+ class ClientError < HTTPError
41
46
  end
42
47
 
43
48
  class BadGateway < ClientError
@@ -49,8 +54,18 @@ module X
49
54
  class ConnectionException < ClientError
50
55
  end
51
56
 
52
- class Error < StandardError
53
- def initialize: (String msg, ?Net::HTTPResponse? response) -> void
57
+ class HTTPError < Error
58
+ JSON_CONTENT_TYPE_REGEXP: Regexp
59
+
60
+ attr_reader response : Net::HTTPResponse
61
+ attr_reader code : String
62
+
63
+ def initialize: (response: Net::HTTPResponse) -> void
64
+
65
+ private
66
+ def error_message: (Net::HTTPResponse response) -> String
67
+ def message_from_json_response: (Net::HTTPResponse response) -> String
68
+ def json?: (Net::HTTPResponse response) -> bool
54
69
  end
55
70
 
56
71
  class Forbidden < ClientError
@@ -77,19 +92,18 @@ module X
77
92
  class PayloadTooLarge < ClientError
78
93
  end
79
94
 
80
- class ServerError < Error
95
+ class ServerError < HTTPError
81
96
  end
82
97
 
83
98
  class ServiceUnavailable < ServerError
84
99
  end
85
100
 
86
- class TooManyRedirects < ClientError
101
+ class TooManyRedirects < Error
87
102
  end
88
103
 
89
104
  class TooManyRequests < ClientError
90
105
  @response: Net::HTTPResponse
91
106
 
92
- def initialize: (String msg, Net::HTTPResponse response) -> void
93
107
  def limit: -> Integer
94
108
  def remaining: -> Integer
95
109
  def reset_at: -> Time
@@ -154,19 +168,19 @@ module X
154
168
  attr_reader connection: Connection
155
169
  attr_reader request_builder: RequestBuilder
156
170
  attr_reader max_redirects: Integer
157
- def initialize: (authenticator: Authenticator, connection: Connection, request_builder: RequestBuilder, ?max_redirects: Integer) -> void
158
- def handle: (response: Net::HTTPResponse, request: Net::HTTPRequest, base_url: String, ?redirect_count: Integer) -> Net::HTTPResponse
171
+ def initialize: (connection: Connection, request_builder: RequestBuilder, ?max_redirects: Integer) -> void
172
+ def handle: (response: Net::HTTPResponse, request: Net::HTTPRequest, base_url: String, ?authenticator: Authenticator, ?redirect_count: Integer) -> Net::HTTPResponse
159
173
 
160
174
  private
161
175
  def build_new_uri: (Net::HTTPResponse response, String base_url) -> URI::Generic
162
- def build_request: (Net::HTTPRequest request, URI::Generic new_uri, Integer response_code) -> Net::HTTPRequest
176
+ def build_request: (Net::HTTPRequest request, URI::Generic new_uri, Integer response_code, Authenticator authenticator) -> Net::HTTPRequest
163
177
  def send_new_request: (URI::Generic new_uri, Net::HTTPRequest new_request) -> Net::HTTPResponse
164
178
  end
165
179
 
166
180
  class ResponseParser
167
181
  DEFAULT_ARRAY_CLASS: Class
168
182
  DEFAULT_OBJECT_CLASS: Class
169
- ERROR_CLASSES: Hash[Integer, singleton(Unauthorized) | singleton(BadRequest) | singleton(Forbidden) | singleton(InternalServerError) | singleton(NotFound) | singleton(PayloadTooLarge) | singleton(ServiceUnavailable) | singleton(TooManyRequests)]
183
+ ERROR_MAP: Hash[Integer, singleton(Unauthorized) | singleton(BadRequest) | singleton(Forbidden) | singleton(InternalServerError) | singleton(NotFound) | singleton(PayloadTooLarge) | singleton(ServiceUnavailable) | singleton(TooManyRequests)]
170
184
  JSON_CONTENT_TYPE_REGEXP: Regexp
171
185
 
172
186
  attr_accessor array_class: Class
@@ -175,11 +189,8 @@ module X
175
189
  def parse: (response: Net::HTTPResponse) -> untyped
176
190
 
177
191
  private
178
- def success?: (Net::HTTPResponse response) -> bool
179
192
  def error: (Net::HTTPResponse response) -> (Unauthorized | BadRequest | Forbidden | InternalServerError | NotFound | PayloadTooLarge | ServiceUnavailable | TooManyRequests)
180
193
  def error_class: (Net::HTTPResponse response) -> (singleton(Unauthorized) | singleton(BadRequest) | singleton(Forbidden) | singleton(InternalServerError) | singleton(NotFound) | singleton(PayloadTooLarge) | singleton(ServiceUnavailable) | singleton(TooManyRequests))
181
- def error_message: (Net::HTTPResponse response) -> String
182
- def message_from_json_response: (Net::HTTPResponse response) -> String
183
194
  def json?: (Net::HTTPResponse response) -> bool
184
195
  end
185
196
 
@@ -213,14 +224,15 @@ module X
213
224
  attr_accessor redirect_handler: RedirectHandler
214
225
  attr_accessor response_parser: ResponseParser
215
226
 
216
- def initialize: (?bearer_token: String?, ?api_key: String?, ?api_key_secret: String?, ?access_token: String?, ?access_token_secret: String?, ?base_url: String, ?open_timeout: Float | Integer, ?read_timeout: Float | Integer, ?write_timeout: Float | Integer, ?proxy_url: URI::Generic? | String?, ?debug_output: IO, ?array_class: Class, ?object_class: Class, ?max_redirects: Integer) -> void
227
+ def initialize: (?api_key: String?, ?api_key_secret: String?, ?access_token: String?, ?access_token_secret: String?, ?bearer_token: String?, ?base_url: String, ?open_timeout: Float | Integer, ?read_timeout: Float | Integer, ?write_timeout: Float | Integer, ?proxy_url: URI::Generic? | String?, ?debug_output: IO, ?array_class: Class, ?object_class: Class, ?max_redirects: Integer) -> void
217
228
  def get: (String endpoint, ?headers: Hash[String, String]) -> untyped
218
229
  def post: (String endpoint, ?String? body, ?headers: Hash[String, String]) -> untyped
219
230
  def put: (String endpoint, ?String? body, ?headers: Hash[String, String]) -> untyped
220
231
  def delete: (String endpoint, ?headers: Hash[String, String]) -> untyped
221
232
 
222
233
  private
223
- def initialize_authenticator: (String? bearer_token, String? api_key, String? api_key_secret, String? access_token, String? access_token_secret) -> (BearerTokenAuthenticator | OAuthAuthenticator | Authenticator)
234
+ def initialize_oauth: (String? api_key, String? api_key_secret, String? access_token, String? access_token_secret) -> void
235
+ def initialize_authenticator: -> Authenticator
224
236
  def execute_request: (Symbol http_method, String endpoint, headers: Hash[String, String], ?body: String?) -> untyped
225
237
  end
226
238
 
@@ -253,13 +265,13 @@ module X
253
265
  private
254
266
  def validate!: (file_path: String, media_category: String) -> nil
255
267
  def infer_media_type: (String file_path, String media_category) -> String
256
- def init: (Client upload_client, String file_path, String media_type, String media_category) -> untyped
257
268
  def split: (String file_path, Integer chunk_size) -> Array[String]
258
- def append: (Client upload_client, Array[String] chunk_paths, untyped media, String media_type, ?String boundary) -> Array[String]
259
- def upload_chunk: (Client upload_client, String query, String chunk_path, String media_type, ?Hash[String, String] headers) -> Integer?
260
- def cleanup_chunk: (String chunk_path) -> Integer?
261
- def finalize: (Client upload_client, untyped media) -> untyped
262
- def construct_upload_body: (String file_path, String media_type, ?String boundary) -> String
269
+ def init: (upload_client: Client, file_path: String, media_type: String, media_category: String) -> untyped
270
+ def append: (upload_client: Client, file_paths: Array[String], media: untyped, media_type: String, ?boundary: String) -> Array[String]
271
+ def upload_chunk: (upload_client: Client, query: String, upload_body: String, file_path: String, ?headers: Hash[String, String]) -> Integer?
272
+ def cleanup_file: (String file_path) -> Integer?
273
+ def finalize: (upload_client: Client, media: untyped) -> untyped
274
+ def construct_upload_body: (file_path: String, media_type: String, ?boundary: String) -> String
263
275
  end
264
276
 
265
277
  class CGI
@@ -267,7 +279,3 @@ module X
267
279
  def self.escape_params: (Hash[String, String] | Array[[String, String]] params) -> String
268
280
  end
269
281
  end
270
-
271
- class Dir
272
- def self.mktmpdir: (?String? prefix_suffix) -> String
273
- end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: x
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.0
4
+ version: 0.12.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Erik Berlin
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2023-10-24 00:00:00.000000000 Z
11
+ date: 2023-11-28 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description:
14
14
  email:
@@ -36,6 +36,7 @@ files:
36
36
  - lib/x/errors/forbidden.rb
37
37
  - lib/x/errors/gateway_timeout.rb
38
38
  - lib/x/errors/gone.rb
39
+ - lib/x/errors/http_error.rb
39
40
  - lib/x/errors/internal_server_error.rb
40
41
  - lib/x/errors/network_error.rb
41
42
  - lib/x/errors/not_acceptable.rb
@@ -80,7 +81,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
80
81
  - !ruby/object:Gem::Version
81
82
  version: '0'
82
83
  requirements: []
83
- rubygems_version: 3.4.21
84
+ rubygems_version: 3.4.22
84
85
  signing_key:
85
86
  specification_version: 4
86
87
  summary: A Ruby interface to the X API.