x 0.17.0 → 0.19.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +9 -0
  3. data/README.md +17 -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 +121 -52
  8. data/lib/x/client_credentials.rb +208 -0
  9. data/lib/x/connection.rb +107 -4
  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/stream_parser.rb +75 -0
  40. data/lib/x/version.rb +2 -1
  41. data/sig/x.rbs +94 -18
  42. metadata +7 -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: 5aaa181ca277a20cc6ddb1ec8d4219535357611891da51642471f54c032a2b47
4
+ data.tar.gz: 43f3949ff58bb6e3248adad99324f25f6709ad507fc96bb75ff47eba832bddba
5
5
  SHA512:
6
- metadata.gz: 24599b2cb2604a711a1a810cb21b0631ce26519dcee977e12bf494fdde1be55c08bf4e2716b6c281918010847e8c1bd72cd236a44f0343ece70a66f4a908ab1f
7
- data.tar.gz: 84cdf631377ed38f0400a0f82d6dc42609839eeb4ba81d77526017af8541365152adb4f3e73f2722ae2645a494cf697976c1ae85039c9bd2b82edb1efe97136f
6
+ metadata.gz: 612f01c108c9c219363f2910fe393795881151b008c6dc452210ead69c02d1421141061575e8d92f5108fbdc18a3949bc6c3330c9c73444495ed37b4df587e03
7
+ data.tar.gz: 1a5759aa9862066f735632e0ff63defeb6827bd63657013b88dd33d97792ca177f95859784f08ac32f0f31249ee6a26bdfab582218aee1abd79fcac71281b067
data/CHANGELOG.md CHANGED
@@ -1,3 +1,12 @@
1
+ ## [0.19.0] - 2026-03-01
2
+ * Add streaming support for filtered stream and volume stream endpoints
3
+
4
+ ## [0.18.0] - 2026-01-06
5
+ * Add OAuth 2.0 authentication with token refresh support (d4c03cb)
6
+ * Add AccountUploader for profile image and banner uploads (7833dd2)
7
+ * Raise InvalidMediaType error for unsupported file extensions (2b6eacc)
8
+ * Prioritize errors array over title/detail in error messages (75279b9)
9
+
1
10
  ## [0.17.0] - 2025-12-02
2
11
  * Add MediaUploader.upload_binary method (9f2f108)
3
12
  * 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)
@@ -71,11 +71,23 @@ ads_client = X::Client.new(base_url: "https://ads-api.twitter.com/12/", **x_cred
71
71
  ads_client.get("accounts")
72
72
  ```
73
73
 
74
+ ### Streaming
75
+
76
+ ```ruby
77
+ # Set up rules for filtered stream
78
+ x_client.post("tweets/search/stream/rules", '{"add": [{"value": "ruby"}]}')
79
+
80
+ # Stream matching posts in real time
81
+ x_client.stream("tweets/search/stream") do |tweet|
82
+ puts tweet["data"]["text"]
83
+ end
84
+ ```
85
+
74
86
  See other common usage [examples](https://github.com/sferik/x-ruby/tree/main/examples).
75
87
 
76
88
  ## History and Philosophy
77
89
 
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:
90
+ 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 less than 1,000 lines of code (plus 2,000 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
91
 
80
92
  * Less code is easier to maintain.
81
93
  * Less code means fewer bugs.
@@ -91,10 +103,10 @@ This code is not littered with comments that are intended to generate documentat
91
103
 
92
104
  ## Features
93
105
 
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:
106
+ If this entire library is implemented in under 1,000 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
107
 
96
108
  * OAuth 1.0 Revision A
97
- * OAuth 2.0 Bearer Token
109
+ * OAuth 2.0
98
110
  * Thread safety
99
111
  * HTTP redirect following
100
112
  * HTTP proxy support
@@ -102,6 +114,7 @@ If this entire library is implemented in just 500 lines of code, why should you
102
114
  * HTTP timeout configuration
103
115
  * HTTP error handling
104
116
  * Rate limit handling
117
+ * Streaming (filtered stream, volume stream)
105
118
  * Parsing JSON into custom response objects (e.g. OpenStruct)
106
119
  * Configurable base URLs for accessing different APIs/versions
107
120
  * Parallel uploading of large media files in chunks
@@ -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,121 +1,190 @@
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"
11
+ require_relative "stream_parser"
8
12
 
9
13
  module X
14
+ # A client for interacting with the X API
15
+ # @api public
10
16
  class Client
11
17
  extend Forwardable
18
+ include ClientCredentials
12
19
 
20
+ # Default base URL for the X API
13
21
  DEFAULT_BASE_URL = "https://api.twitter.com/2/".freeze
22
+ # Default class for parsing JSON arrays
14
23
  DEFAULT_ARRAY_CLASS = Array
24
+ # Default class for parsing JSON objects
15
25
  DEFAULT_OBJECT_CLASS = Hash
16
26
 
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
27
+ # The base URL for API requests
28
+ # @api public
29
+ # @return [String] the base URL for API requests
30
+ # @example Get or set the base URL
31
+ # client.base_url = "https://api.twitter.com/1.1/"
32
+ attr_accessor :base_url
33
+
34
+ # The default class for parsing JSON arrays
35
+ # @api public
36
+ # @return [Class] the default class for parsing JSON arrays
37
+ # @example Get or set the default array class
38
+ # client.default_array_class = Set
39
+ attr_accessor :default_array_class
40
+
41
+ # The default class for parsing JSON objects
42
+ # @api public
43
+ # @return [Class] the default class for parsing JSON objects
44
+ # @example Get or set the default object class
45
+ # client.default_object_class = OpenStruct
46
+ attr_accessor :default_object_class
47
+
48
+ # The authenticator for API requests
49
+ # @api public
50
+ # @return [Authenticator] the authenticator instance
51
+ # @example Check if the OAuth 2.0 token has expired
52
+ # client.authenticator.token_expired?
53
+ attr_reader :authenticator
19
54
 
20
55
  def_delegators :@connection, :open_timeout, :read_timeout, :write_timeout, :proxy_url, :debug_output
21
56
  def_delegators :@connection, :open_timeout=, :read_timeout=, :write_timeout=, :proxy_url=, :debug_output=
22
57
  def_delegators :@redirect_handler, :max_redirects
23
58
  def_delegators :@redirect_handler, :max_redirects=
24
59
 
60
+ # Initialize a new X API client
61
+ #
62
+ # @api public
63
+ # @param api_key [String, nil] the API key for OAuth 1.0a authentication
64
+ # @param api_key_secret [String, nil] the API key secret for OAuth 1.0a authentication
65
+ # @param access_token [String, nil] the access token for OAuth authentication
66
+ # @param access_token_secret [String, nil] the access token secret for OAuth 1.0a authentication
67
+ # @param bearer_token [String, nil] the bearer token for authentication
68
+ # @param client_id [String, nil] the OAuth 2.0 client ID
69
+ # @param client_secret [String, nil] the OAuth 2.0 client secret
70
+ # @param refresh_token [String, nil] the OAuth 2.0 refresh token
71
+ # @param base_url [String] the base URL for API requests
72
+ # @param open_timeout [Integer] the timeout for opening connections in seconds
73
+ # @param read_timeout [Integer] the timeout for reading responses in seconds
74
+ # @param write_timeout [Integer] the timeout for writing requests in seconds
75
+ # @param debug_output [IO] the IO object for debug output
76
+ # @param proxy_url [String, nil] the proxy URL for requests
77
+ # @param default_array_class [Class] the default class for parsing JSON arrays
78
+ # @param default_object_class [Class] the default class for parsing JSON objects
79
+ # @param max_redirects [Integer] the maximum number of redirects to follow
80
+ # @return [Client] a new client instance
81
+ # @example Create a client with bearer token authentication
82
+ # client = X::Client.new(bearer_token: "your_bearer_token")
83
+ # @example Create a client with OAuth 1.0a authentication
84
+ # client = X::Client.new(api_key: "key", api_key_secret: "secret", access_token: "token", access_token_secret: "token_secret")
25
85
  def initialize(api_key: nil, api_key_secret: nil, access_token: nil, access_token_secret: nil,
26
- bearer_token: nil,
86
+ bearer_token: nil, client_id: nil, client_secret: nil, refresh_token: nil,
27
87
  base_url: DEFAULT_BASE_URL,
28
88
  open_timeout: Connection::DEFAULT_OPEN_TIMEOUT,
29
89
  read_timeout: Connection::DEFAULT_READ_TIMEOUT,
30
90
  write_timeout: Connection::DEFAULT_WRITE_TIMEOUT,
31
- debug_output: Connection::DEFAULT_DEBUG_OUTPUT,
91
+ debug_output: nil,
32
92
  proxy_url: nil,
33
93
  default_array_class: DEFAULT_ARRAY_CLASS,
34
94
  default_object_class: DEFAULT_OBJECT_CLASS,
35
95
  max_redirects: RedirectHandler::DEFAULT_MAX_REDIRECTS)
36
- initialize_oauth(api_key, api_key_secret, access_token, access_token_secret, bearer_token)
96
+ initialize_credentials(api_key:, api_key_secret:, access_token:, access_token_secret:, bearer_token:,
97
+ client_id:, client_secret:, refresh_token:)
37
98
  initialize_authenticator
38
99
  @base_url = base_url
39
- initialize_default_classes(default_array_class, default_object_class)
100
+ initialize_default_classes(default_array_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
105
+ @stream_parser = StreamParser.new
44
106
  end
45
107
 
108
+ # Perform a GET request to the X API
109
+ #
110
+ # @api public
111
+ # @return [Hash, Array, nil] the parsed response body
112
+ # @example Get a user by username
113
+ # client.get("users/by/username/sferik")
46
114
  def get(endpoint, headers: {}, array_class: default_array_class, object_class: default_object_class)
47
115
  execute_request(:get, endpoint, headers:, array_class:, object_class:)
48
116
  end
49
117
 
118
+ # Perform a POST request to the X API
119
+ #
120
+ # @api public
121
+ # @return [Hash, Array, nil] the parsed response body
122
+ # @example Create a tweet
123
+ # client.post("tweets", '{"text": "Hello, World!"}')
50
124
  def post(endpoint, body = nil, headers: {}, array_class: default_array_class, object_class: default_object_class)
51
125
  execute_request(:post, endpoint, body:, headers:, array_class:, object_class:)
52
126
  end
53
127
 
128
+ # Perform a PUT request to the X API
129
+ #
130
+ # @api public
131
+ # @return [Hash, Array, nil] the parsed response body
132
+ # @example Update a resource
133
+ # client.put("some/endpoint", '{"key": "value"}')
54
134
  def put(endpoint, body = nil, headers: {}, array_class: default_array_class, object_class: default_object_class)
55
135
  execute_request(:put, endpoint, body:, headers:, array_class:, object_class:)
56
136
  end
57
137
 
138
+ # Perform a DELETE request to the X API
139
+ #
140
+ # @api public
141
+ # @return [Hash, Array, nil] the parsed response body
142
+ # @example Delete a tweet
143
+ # client.delete("tweets/1234567890")
58
144
  def delete(endpoint, headers: {}, array_class: default_array_class, object_class: default_object_class)
59
145
  execute_request(:delete, endpoint, headers:, array_class:, object_class:)
60
146
  end
61
147
 
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
148
+ # Stream data from the X API
149
+ #
150
+ # @api public
151
+ # @param endpoint [String] the streaming API endpoint
152
+ # @param headers [Hash] additional headers for the request
153
+ # @param array_class [Class] the class for parsing JSON arrays
154
+ # @param object_class [Class] the class for parsing JSON objects
155
+ # @yield [Hash, Array] each parsed JSON object from the stream
156
+ # @return [void]
157
+ # @raise [HTTPError] if the response is not successful
158
+ # @example Stream filtered tweets
159
+ # client.stream("tweets/search/stream") { |tweet| puts tweet }
160
+ def stream(endpoint, headers: {}, array_class: default_array_class, object_class: default_object_class, &block)
161
+ uri = URI.join(base_url, endpoint)
162
+ request = @request_builder.build(http_method: :get, uri:, headers:, authenticator:)
163
+ @connection.perform_stream(request:) do |response|
164
+ @stream_parser.process(response:, response_parser: @response_parser, array_class:, object_class:, &block)
165
+ end
85
166
  end
86
167
 
87
168
  private
88
169
 
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)
170
+ # Initialize default JSON parsing classes
171
+ # @api private
172
+ # @param default_array_class [Class] the default class for parsing JSON arrays
173
+ # @param default_object_class [Class] the default class for parsing JSON objects
174
+ # @return [void]
175
+ def initialize_default_classes(default_array_class:, default_object_class:)
98
176
  @default_array_class = default_array_class
99
177
  @default_object_class = default_object_class
100
178
  end
101
179
 
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
-
180
+ # Execute an HTTP request to the X API
181
+ # @api private
182
+ # @return [Hash, Array, nil] the parsed response body
114
183
  def execute_request(http_method, endpoint, body: nil, headers: {}, array_class: default_array_class, object_class: default_object_class)
115
184
  uri = URI.join(base_url, endpoint)
116
- request = @request_builder.build(http_method:, uri:, body:, headers:, authenticator: @authenticator)
185
+ request = @request_builder.build(http_method:, uri:, body:, headers:, authenticator:)
117
186
  response = @connection.perform(request:)
118
- response = @redirect_handler.handle(response:, request:, base_url:, authenticator: @authenticator)
187
+ response = @redirect_handler.handle(response:, request:, base_url:, authenticator:)
119
188
  @response_parser.parse(response:, array_class:, object_class:)
120
189
  end
121
190
  end