x 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +6 -0
  3. data/README.md +4 -4
  4. data/lib/x/account_uploader.rb +168 -0
  5. data/lib/x/authenticator.rb +12 -0
  6. data/lib/x/bearer_token_authenticator.rb +22 -1
  7. data/lib/x/client.rb +95 -57
  8. data/lib/x/client_credentials.rb +208 -0
  9. data/lib/x/connection.rb +88 -2
  10. data/lib/x/errors/bad_gateway.rb +1 -0
  11. data/lib/x/errors/bad_request.rb +1 -0
  12. data/lib/x/errors/client_error.rb +1 -0
  13. data/lib/x/errors/connection_exception.rb +1 -0
  14. data/lib/x/errors/error.rb +1 -0
  15. data/lib/x/errors/forbidden.rb +1 -0
  16. data/lib/x/errors/gateway_timeout.rb +1 -0
  17. data/lib/x/errors/gone.rb +1 -0
  18. data/lib/x/errors/http_error.rb +47 -4
  19. data/lib/x/errors/internal_server_error.rb +1 -0
  20. data/lib/x/errors/invalid_media_type.rb +6 -0
  21. data/lib/x/errors/network_error.rb +1 -0
  22. data/lib/x/errors/not_acceptable.rb +1 -0
  23. data/lib/x/errors/not_found.rb +1 -0
  24. data/lib/x/errors/payload_too_large.rb +1 -0
  25. data/lib/x/errors/server_error.rb +1 -0
  26. data/lib/x/errors/service_unavailable.rb +1 -0
  27. data/lib/x/errors/too_many_redirects.rb +1 -0
  28. data/lib/x/errors/too_many_requests.rb +32 -0
  29. data/lib/x/errors/unauthorized.rb +1 -0
  30. data/lib/x/errors/unprocessable_entity.rb +1 -0
  31. data/lib/x/media_upload_validator.rb +19 -0
  32. data/lib/x/media_uploader.rb +117 -5
  33. data/lib/x/oauth2_authenticator.rb +169 -0
  34. data/lib/x/oauth_authenticator.rb +99 -2
  35. data/lib/x/rate_limit.rb +57 -1
  36. data/lib/x/redirect_handler.rb +55 -1
  37. data/lib/x/request_builder.rb +36 -0
  38. data/lib/x/response_parser.rb +21 -0
  39. data/lib/x/version.rb +2 -1
  40. data/sig/x.rbs +78 -17
  41. metadata +6 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4df136d018c47058c7524faada6292a1464b34f2f0e98f2a76e8491630b28d1c
4
- data.tar.gz: f7101280a194f40bef57a82e9670cd6ddf409e80b7563072384b8f8c7687cb63
3
+ metadata.gz: 017d3f20bd339939e92fd91d8e1034ded6b3165c31734eb6b7b092e17cafb15a
4
+ data.tar.gz: 8d4bf8295848cba641b9bc9ab6583eee426dcaed0eccfbedcfa982aa82423808
5
5
  SHA512:
6
- metadata.gz: 24599b2cb2604a711a1a810cb21b0631ce26519dcee977e12bf494fdde1be55c08bf4e2716b6c281918010847e8c1bd72cd236a44f0343ece70a66f4a908ab1f
7
- data.tar.gz: 84cdf631377ed38f0400a0f82d6dc42609839eeb4ba81d77526017af8541365152adb4f3e73f2722ae2645a494cf697976c1ae85039c9bd2b82edb1efe97136f
6
+ metadata.gz: '0597ac0dcb4633c682c237e24d701e8972ff5f7390feab5fa16d320654ecbdbe97ec356ebc6aaa11861d54267fe7ea14f89b43b5290c58202a0a65318940a268'
7
+ data.tar.gz: f48b37b5042dd349bd822f1d8c2c4e27574e48494e1e8610f265e95eb608b33adee081b57c9b7f5234e7e799c6820aebc77e4f931e99feb80a3e2d77ffb8e523
data/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## [0.18.0] - 2026-01-06
2
+ * Add OAuth 2.0 authentication with token refresh support (d4c03cb)
3
+ * Add AccountUploader for profile image and banner uploads (7833dd2)
4
+ * Raise InvalidMediaType error for unsupported file extensions (2b6eacc)
5
+ * Prioritize errors array over title/detail in error messages (75279b9)
6
+
1
7
  ## [0.17.0] - 2025-12-02
2
8
  * Add MediaUploader.upload_binary method (9f2f108)
3
9
  * Don't forward filename during media upload (492214d)
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
  [![mutation tests](https://github.com/sferik/x-ruby/actions/workflows/mutant.yml/badge.svg)](https://github.com/sferik/x-ruby/actions/workflows/mutant.yml)
3
3
  [![linter](https://github.com/sferik/x-ruby/actions/workflows/lint.yml/badge.svg)](https://github.com/sferik/x-ruby/actions/workflows/lint.yml)
4
4
  [![typer checker](https://github.com/sferik/x-ruby/actions/workflows/steep.yml/badge.svg)](https://github.com/sferik/x-ruby/actions/workflows/steep.yml)
5
- [![maintainability](https://api.codeclimate.com/v1/badges/40bbddf2c9170742ca9e/maintainability)](https://codeclimate.com/github/sferik/x-ruby/maintainability)
5
+ [![maintainability](https://qlty.sh/gh/sferik/projects/x-ruby/maintainability.svg)](https://qlty.sh/gh/sferik/projects/x-ruby)
6
6
  [![gem version](https://badge.fury.io/rb/x.svg)](https://rubygems.org/gems/x)
7
7
 
8
8
  # A [Ruby](https://www.ruby-lang.org) interface to the [X API](https://developer.x.com)
@@ -75,7 +75,7 @@ See other common usage [examples](https://github.com/sferik/x-ruby/tree/main/exa
75
75
 
76
76
  ## History and Philosophy
77
77
 
78
- 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:
78
+ 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 750 lines of code (plus 1335 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:
79
79
 
80
80
  * Less code is easier to maintain.
81
81
  * Less code means fewer bugs.
@@ -91,10 +91,10 @@ This code is not littered with comments that are intended to generate documentat
91
91
 
92
92
  ## Features
93
93
 
94
- If this entire library is implemented in just 500 lines of code, why should you use it at all vs. writing your own library that suits your needs? If you feel inspired to do that, don’t let me discourage you, but this library has some advanced features that may not be apparent without diving into the code:
94
+ If this entire library is implemented in just 750 lines of code, why should you use it at all vs. writing your own library that suits your needs? If you feel inspired to do that, don’t let me discourage you, but this library has some advanced features that may not be apparent without diving into the code:
95
95
 
96
96
  * OAuth 1.0 Revision A
97
- * OAuth 2.0 Bearer Token
97
+ * OAuth 2.0
98
98
  * Thread safety
99
99
  * HTTP redirect following
100
100
  * HTTP proxy support
@@ -0,0 +1,168 @@
1
+ require "base64"
2
+ require "securerandom"
3
+ require_relative "errors/invalid_media_type"
4
+
5
+ module X
6
+ # Uploads profile images and banners to the X API v1.1
7
+ # @api public
8
+ module AccountUploader
9
+ extend self
10
+
11
+ # Base URL for X API v1.1 account endpoints
12
+ V1_BASE_URL = "https://api.x.com/1.1/".freeze
13
+ # Supported image extensions for profile uploads
14
+ SUPPORTED_EXTENSIONS = %w[gif jpg jpeg png].freeze
15
+ # Mapping of file extensions to MIME types
16
+ MIME_TYPE_MAP = {"gif" => "image/gif", "jpg" => "image/jpeg", "jpeg" => "image/jpeg", "png" => "image/png"}.freeze
17
+
18
+ # Update the authenticating user's profile image
19
+ #
20
+ # @api public
21
+ # @param client [Client] the X API client
22
+ # @param file_path [String] the path to the image file
23
+ # @param boundary [String] the multipart boundary
24
+ # @return [Hash, nil] the updated user object
25
+ # @raise [RuntimeError] if the file does not exist
26
+ # @raise [InvalidMediaType] if the file type is not supported
27
+ # @example Update profile image from a file
28
+ # AccountUploader.update_profile_image(client: client, file_path: "avatar.png")
29
+ def update_profile_image(client:, file_path:, boundary: SecureRandom.hex)
30
+ validate_file!(file_path)
31
+ upload_profile_image_binary(client:, content: File.binread(file_path), boundary:)
32
+ end
33
+
34
+ # Update the authenticating user's profile image from binary content
35
+ #
36
+ # @api public
37
+ # @param client [Client] the X API client
38
+ # @param content [String] the binary image content
39
+ # @param boundary [String] the multipart boundary
40
+ # @return [Hash, nil] the updated user object
41
+ # @example Update profile image from binary content
42
+ # AccountUploader.upload_profile_image_binary(client: client, content: image_data)
43
+ def upload_profile_image_binary(client:, content:, boundary: SecureRandom.hex)
44
+ body = construct_multipart_body(field_name: "image", content:, boundary:)
45
+ headers = {"Content-Type" => "multipart/form-data; boundary=#{boundary}"}
46
+ v1_client(client).post("account/update_profile_image.json", body, headers:)
47
+ end
48
+
49
+ # Update the authenticating user's profile banner
50
+ #
51
+ # @api public
52
+ # @param client [Client] the X API client
53
+ # @param file_path [String] the path to the image file
54
+ # @param width [Integer, nil] the width of the banner
55
+ # @param height [Integer, nil] the height of the banner
56
+ # @param offset_left [Integer, nil] the left offset of the banner
57
+ # @param offset_top [Integer, nil] the top offset of the banner
58
+ # @param boundary [String] the multipart boundary
59
+ # @return [Hash, nil] nil on success (204 No Content)
60
+ # @raise [RuntimeError] if the file does not exist
61
+ # @raise [InvalidMediaType] if the file type is not supported
62
+ # @example Update profile banner from a file
63
+ # AccountUploader.update_profile_banner(client: client, file_path: "banner.png")
64
+ # @example Update profile banner with dimensions
65
+ # AccountUploader.update_profile_banner(client: client, file_path: "banner.png", width: 1500, height: 500)
66
+ def update_profile_banner(client:, file_path:, width: nil, height: nil, offset_left: nil, offset_top: nil,
67
+ boundary: SecureRandom.hex)
68
+ validate_file!(file_path)
69
+ upload_profile_banner_binary(client:, content: File.binread(file_path), width:, height:, offset_left:,
70
+ offset_top:, boundary:)
71
+ end
72
+
73
+ # Update the authenticating user's profile banner from binary content
74
+ #
75
+ # @api public
76
+ # @param client [Client] the X API client
77
+ # @param content [String] the binary image content
78
+ # @param width [Integer, nil] the width of the banner
79
+ # @param height [Integer, nil] the height of the banner
80
+ # @param offset_left [Integer, nil] the left offset of the banner
81
+ # @param offset_top [Integer, nil] the top offset of the banner
82
+ # @param boundary [String] the multipart boundary
83
+ # @return [Hash, nil] nil on success (204 No Content)
84
+ # @example Update profile banner from binary content
85
+ # AccountUploader.upload_profile_banner_binary(client: client, content: image_data)
86
+ def upload_profile_banner_binary(client:, content:, width: nil, height: nil, offset_left: nil, offset_top: nil,
87
+ boundary: SecureRandom.hex)
88
+ body = construct_banner_body(content:, width:, height:, offset_left:, offset_top:, boundary:)
89
+ headers = {"Content-Type" => "multipart/form-data; boundary=#{boundary}"}
90
+ v1_client(client).post("account/update_profile_banner.json", body, headers:)
91
+ end
92
+
93
+ private
94
+
95
+ # Create a v1.1 API client from an existing client
96
+ # @api private
97
+ # @param client [Client] the original client
98
+ # @return [Client] a client configured for v1.1 API
99
+ def v1_client(client)
100
+ Client.new(
101
+ api_key: client.api_key,
102
+ api_key_secret: client.api_key_secret,
103
+ access_token: client.access_token,
104
+ access_token_secret: client.access_token_secret,
105
+ base_url: V1_BASE_URL
106
+ )
107
+ end
108
+
109
+ # Validate that the file exists and has a supported extension
110
+ # @api private
111
+ # @param file_path [String] the file path
112
+ # @return [nil]
113
+ # @raise [RuntimeError] if the file does not exist
114
+ # @raise [InvalidMediaType] if the file type is not supported
115
+ def validate_file!(file_path)
116
+ raise "File not found: #{file_path}" unless File.exist?(file_path)
117
+
118
+ extension = File.extname(file_path).delete(".").downcase
119
+ return if SUPPORTED_EXTENSIONS.include?(extension)
120
+
121
+ raise InvalidMediaType, "Unsupported file type: #{extension}. Supported types: #{SUPPORTED_EXTENSIONS.join(", ")}"
122
+ end
123
+
124
+ # Construct multipart body for profile image upload
125
+ # @api private
126
+ # @param field_name [String] the form field name
127
+ # @param content [String] the binary content
128
+ # @param boundary [String] the multipart boundary
129
+ # @return [String] the multipart body
130
+ def construct_multipart_body(field_name:, content:, boundary:)
131
+ "--#{boundary}\r\n" \
132
+ "Content-Disposition: form-data; name=\"#{field_name}\"\r\n" \
133
+ "Content-Type: application/octet-stream\r\n\r\n" \
134
+ "#{content}\r\n" \
135
+ "--#{boundary}--\r\n"
136
+ end
137
+
138
+ # Construct multipart body for profile banner upload with optional dimensions
139
+ # @api private
140
+ # @param content [String] the binary content
141
+ # @param width [Integer, nil] the width
142
+ # @param height [Integer, nil] the height
143
+ # @param offset_left [Integer, nil] the left offset
144
+ # @param offset_top [Integer, nil] the top offset
145
+ # @param boundary [String] the multipart boundary
146
+ # @return [String] the multipart body
147
+ def construct_banner_body(content:, width:, height:, offset_left:, offset_top:, boundary:)
148
+ body = ""
149
+ body += multipart_field("width", width, boundary) if width
150
+ body += multipart_field("height", height, boundary) if height
151
+ body += multipart_field("offset_left", offset_left, boundary) if offset_left
152
+ body += multipart_field("offset_top", offset_top, boundary) if offset_top
153
+ body + construct_multipart_body(field_name: "banner", content:, boundary:)
154
+ end
155
+
156
+ # Create a multipart form field
157
+ # @api private
158
+ # @param name [String] the field name
159
+ # @param value [Object] the field value
160
+ # @param boundary [String] the multipart boundary
161
+ # @return [String] the multipart field
162
+ def multipart_field(name, value, boundary)
163
+ "--#{boundary}\r\n" \
164
+ "Content-Disposition: form-data; name=\"#{name}\"\r\n\r\n" \
165
+ "#{value}\r\n"
166
+ end
167
+ end
168
+ end
@@ -1,7 +1,19 @@
1
+ # A Ruby client for the X API
1
2
  module X
3
+ # Base class for authentication
4
+ # @api public
2
5
  class Authenticator
6
+ # The HTTP header name for authentication
3
7
  AUTHENTICATION_HEADER = "Authorization".freeze
4
8
 
9
+ # Generate the authentication header for a request
10
+ #
11
+ # @api public
12
+ # @param _request [Net::HTTPRequest] the HTTP request
13
+ # @return [Hash{String => String}] the authentication header
14
+ # @example Generate an empty authentication header
15
+ # authenticator = X::Authenticator.new
16
+ # authenticator.header(request)
5
17
  def header(_request)
6
18
  {AUTHENTICATION_HEADER => ""}
7
19
  end
@@ -1,13 +1,34 @@
1
1
  require_relative "authenticator"
2
2
 
3
3
  module X
4
+ # Authenticator for Bearer token authentication
5
+ # @api public
4
6
  class BearerTokenAuthenticator < Authenticator
7
+ # The bearer token for authentication
8
+ # @api public
9
+ # @return [String] the bearer token
10
+ # @example Get the bearer token
11
+ # authenticator.bearer_token
5
12
  attr_accessor :bearer_token
6
13
 
7
- def initialize(bearer_token:) # rubocop:disable Lint/MissingSuper
14
+ # Initialize a new BearerTokenAuthenticator
15
+ #
16
+ # @api public
17
+ # @param bearer_token [String] the bearer token for authentication
18
+ # @return [BearerTokenAuthenticator] a new instance
19
+ # @example Create a new bearer token authenticator
20
+ # authenticator = X::BearerTokenAuthenticator.new(bearer_token: "token")
21
+ def initialize(bearer_token:)
8
22
  @bearer_token = bearer_token
9
23
  end
10
24
 
25
+ # Generate the authentication header for a request
26
+ #
27
+ # @api public
28
+ # @param _request [Net::HTTPRequest] the HTTP request
29
+ # @return [Hash{String => String}] the authentication header with bearer token
30
+ # @example Generate a bearer authentication header
31
+ # authenticator.header(request)
11
32
  def header(_request)
12
33
  {AUTHENTICATION_HEADER => "Bearer #{bearer_token}"}
13
34
  end
data/lib/x/client.rb CHANGED
@@ -1,29 +1,88 @@
1
1
  require "forwardable"
2
+ require_relative "authenticator"
2
3
  require_relative "bearer_token_authenticator"
4
+ require_relative "client_credentials"
3
5
  require_relative "connection"
4
6
  require_relative "oauth_authenticator"
7
+ require_relative "oauth2_authenticator"
5
8
  require_relative "redirect_handler"
6
9
  require_relative "request_builder"
7
10
  require_relative "response_parser"
8
11
 
9
12
  module X
13
+ # A client for interacting with the X API
14
+ # @api public
10
15
  class Client
11
16
  extend Forwardable
17
+ include ClientCredentials
12
18
 
19
+ # Default base URL for the X API
13
20
  DEFAULT_BASE_URL = "https://api.twitter.com/2/".freeze
21
+ # Default class for parsing JSON arrays
14
22
  DEFAULT_ARRAY_CLASS = Array
23
+ # Default class for parsing JSON objects
15
24
  DEFAULT_OBJECT_CLASS = Hash
16
25
 
17
- attr_accessor :base_url, :default_array_class, :default_object_class
18
- attr_reader :api_key, :api_key_secret, :access_token, :access_token_secret, :bearer_token
26
+ # The base URL for API requests
27
+ # @api public
28
+ # @return [String] the base URL for API requests
29
+ # @example Get or set the base URL
30
+ # client.base_url = "https://api.twitter.com/1.1/"
31
+ attr_accessor :base_url
32
+
33
+ # The default class for parsing JSON arrays
34
+ # @api public
35
+ # @return [Class] the default class for parsing JSON arrays
36
+ # @example Get or set the default array class
37
+ # client.default_array_class = Set
38
+ attr_accessor :default_array_class
39
+
40
+ # The default class for parsing JSON objects
41
+ # @api public
42
+ # @return [Class] the default class for parsing JSON objects
43
+ # @example Get or set the default object class
44
+ # client.default_object_class = OpenStruct
45
+ attr_accessor :default_object_class
46
+
47
+ # The authenticator for API requests
48
+ # @api public
49
+ # @return [Authenticator] the authenticator instance
50
+ # @example Check if the OAuth 2.0 token has expired
51
+ # client.authenticator.token_expired?
52
+ attr_reader :authenticator
19
53
 
20
54
  def_delegators :@connection, :open_timeout, :read_timeout, :write_timeout, :proxy_url, :debug_output
21
55
  def_delegators :@connection, :open_timeout=, :read_timeout=, :write_timeout=, :proxy_url=, :debug_output=
22
56
  def_delegators :@redirect_handler, :max_redirects
23
57
  def_delegators :@redirect_handler, :max_redirects=
24
58
 
59
+ # Initialize a new X API client
60
+ #
61
+ # @api public
62
+ # @param api_key [String, nil] the API key for OAuth 1.0a authentication
63
+ # @param api_key_secret [String, nil] the API key secret for OAuth 1.0a authentication
64
+ # @param access_token [String, nil] the access token for OAuth authentication
65
+ # @param access_token_secret [String, nil] the access token secret for OAuth 1.0a authentication
66
+ # @param bearer_token [String, nil] the bearer token for authentication
67
+ # @param client_id [String, nil] the OAuth 2.0 client ID
68
+ # @param client_secret [String, nil] the OAuth 2.0 client secret
69
+ # @param refresh_token [String, nil] the OAuth 2.0 refresh token
70
+ # @param base_url [String] the base URL for API requests
71
+ # @param open_timeout [Integer] the timeout for opening connections in seconds
72
+ # @param read_timeout [Integer] the timeout for reading responses in seconds
73
+ # @param write_timeout [Integer] the timeout for writing requests in seconds
74
+ # @param debug_output [IO] the IO object for debug output
75
+ # @param proxy_url [String, nil] the proxy URL for requests
76
+ # @param default_array_class [Class] the default class for parsing JSON arrays
77
+ # @param default_object_class [Class] the default class for parsing JSON objects
78
+ # @param max_redirects [Integer] the maximum number of redirects to follow
79
+ # @return [Client] a new client instance
80
+ # @example Create a client with bearer token authentication
81
+ # client = X::Client.new(bearer_token: "your_bearer_token")
82
+ # @example Create a client with OAuth 1.0a authentication
83
+ # client = X::Client.new(api_key: "key", api_key_secret: "secret", access_token: "token", access_token_secret: "token_secret")
25
84
  def initialize(api_key: nil, api_key_secret: nil, access_token: nil, access_token_secret: nil,
26
- bearer_token: nil,
85
+ bearer_token: nil, client_id: nil, client_secret: nil, refresh_token: nil,
27
86
  base_url: DEFAULT_BASE_URL,
28
87
  open_timeout: Connection::DEFAULT_OPEN_TIMEOUT,
29
88
  read_timeout: Connection::DEFAULT_READ_TIMEOUT,
@@ -33,89 +92,68 @@ module X
33
92
  default_array_class: DEFAULT_ARRAY_CLASS,
34
93
  default_object_class: DEFAULT_OBJECT_CLASS,
35
94
  max_redirects: RedirectHandler::DEFAULT_MAX_REDIRECTS)
36
- initialize_oauth(api_key, api_key_secret, access_token, access_token_secret, bearer_token)
95
+ initialize_credentials(api_key:, api_key_secret:, access_token:, access_token_secret:, bearer_token:,
96
+ client_id:, client_secret:, refresh_token:)
37
97
  initialize_authenticator
38
98
  @base_url = base_url
39
- initialize_default_classes(default_array_class, default_object_class)
99
+ @default_array_class = default_array_class
100
+ @default_object_class = default_object_class
40
101
  @connection = Connection.new(open_timeout:, read_timeout:, write_timeout:, debug_output:, proxy_url:)
41
102
  @request_builder = RequestBuilder.new
42
103
  @redirect_handler = RedirectHandler.new(connection: @connection, request_builder: @request_builder, max_redirects:)
43
104
  @response_parser = ResponseParser.new
44
105
  end
45
106
 
107
+ # Perform a GET request to the X API
108
+ #
109
+ # @api public
110
+ # @return [Hash, Array, nil] the parsed response body
111
+ # @example Get a user by username
112
+ # client.get("users/by/username/sferik")
46
113
  def get(endpoint, headers: {}, array_class: default_array_class, object_class: default_object_class)
47
114
  execute_request(:get, endpoint, headers:, array_class:, object_class:)
48
115
  end
49
116
 
117
+ # Perform a POST request to the X API
118
+ #
119
+ # @api public
120
+ # @return [Hash, Array, nil] the parsed response body
121
+ # @example Create a tweet
122
+ # client.post("tweets", '{"text": "Hello, World!"}')
50
123
  def post(endpoint, body = nil, headers: {}, array_class: default_array_class, object_class: default_object_class)
51
124
  execute_request(:post, endpoint, body:, headers:, array_class:, object_class:)
52
125
  end
53
126
 
127
+ # Perform a PUT request to the X API
128
+ #
129
+ # @api public
130
+ # @return [Hash, Array, nil] the parsed response body
131
+ # @example Update a resource
132
+ # client.put("some/endpoint", '{"key": "value"}')
54
133
  def put(endpoint, body = nil, headers: {}, array_class: default_array_class, object_class: default_object_class)
55
134
  execute_request(:put, endpoint, body:, headers:, array_class:, object_class:)
56
135
  end
57
136
 
137
+ # Perform a DELETE request to the X API
138
+ #
139
+ # @api public
140
+ # @return [Hash, Array, nil] the parsed response body
141
+ # @example Delete a tweet
142
+ # client.delete("tweets/1234567890")
58
143
  def delete(endpoint, headers: {}, array_class: default_array_class, object_class: default_object_class)
59
144
  execute_request(:delete, endpoint, headers:, array_class:, object_class:)
60
145
  end
61
146
 
62
- def api_key=(api_key)
63
- @api_key = api_key
64
- initialize_authenticator
65
- end
66
-
67
- def api_key_secret=(api_key_secret)
68
- @api_key_secret = api_key_secret
69
- initialize_authenticator
70
- end
71
-
72
- def access_token=(access_token)
73
- @access_token = access_token
74
- initialize_authenticator
75
- end
76
-
77
- def access_token_secret=(access_token_secret)
78
- @access_token_secret = access_token_secret
79
- initialize_authenticator
80
- end
81
-
82
- def bearer_token=(bearer_token)
83
- @bearer_token = bearer_token
84
- initialize_authenticator
85
- end
86
-
87
147
  private
88
148
 
89
- def initialize_oauth(api_key, api_key_secret, access_token, access_token_secret, bearer_token)
90
- @api_key = api_key
91
- @api_key_secret = api_key_secret
92
- @access_token = access_token
93
- @access_token_secret = access_token_secret
94
- @bearer_token = bearer_token
95
- end
96
-
97
- def initialize_default_classes(default_array_class, default_object_class)
98
- @default_array_class = default_array_class
99
- @default_object_class = default_object_class
100
- end
101
-
102
- def initialize_authenticator
103
- @authenticator = if api_key && api_key_secret && access_token && access_token_secret
104
- OAuthAuthenticator.new(api_key:, api_key_secret:, access_token:, access_token_secret:)
105
- elsif bearer_token
106
- BearerTokenAuthenticator.new(bearer_token:)
107
- elsif @authenticator.nil?
108
- Authenticator.new
109
- else
110
- @authenticator
111
- end
112
- end
113
-
149
+ # Execute an HTTP request to the X API
150
+ # @api private
151
+ # @return [Hash, Array, nil] the parsed response body
114
152
  def execute_request(http_method, endpoint, body: nil, headers: {}, array_class: default_array_class, object_class: default_object_class)
115
153
  uri = URI.join(base_url, endpoint)
116
- request = @request_builder.build(http_method:, uri:, body:, headers:, authenticator: @authenticator)
154
+ request = @request_builder.build(http_method:, uri:, body:, headers:, authenticator:)
117
155
  response = @connection.perform(request:)
118
- response = @redirect_handler.handle(response:, request:, base_url:, authenticator: @authenticator)
156
+ response = @redirect_handler.handle(response:, request:, base_url:, authenticator:)
119
157
  @response_parser.parse(response:, array_class:, object_class:)
120
158
  end
121
159
  end