x 0.19.0 → 1.0.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.
- checksums.yaml +4 -4
- data/.yardopts +9 -0
- data/CHANGELOG.md +648 -15
- data/README.md +506 -73
- data/SECURITY.md +66 -0
- data/UPGRADING.md +670 -0
- data/lib/x/version.rb +16 -1
- data/lib/x.rb +13 -1
- data/sig/manifest.yaml +5 -0
- data/sig/x.rbs +5 -361
- metadata +57 -49
- data/bin/console +0 -10
- data/bin/setup +0 -6
- data/lib/x/account_uploader.rb +0 -168
- data/lib/x/authenticator.rb +0 -21
- data/lib/x/bearer_token_authenticator.rb +0 -36
- data/lib/x/client.rb +0 -191
- data/lib/x/client_credentials.rb +0 -208
- data/lib/x/connection.rb +0 -183
- data/lib/x/errors/bad_gateway.rb +0 -6
- data/lib/x/errors/bad_request.rb +0 -6
- data/lib/x/errors/client_error.rb +0 -6
- data/lib/x/errors/connection_exception.rb +0 -6
- data/lib/x/errors/error.rb +0 -4
- data/lib/x/errors/forbidden.rb +0 -6
- data/lib/x/errors/gateway_timeout.rb +0 -6
- data/lib/x/errors/gone.rb +0 -6
- data/lib/x/errors/http_error.rb +0 -84
- data/lib/x/errors/internal_server_error.rb +0 -6
- data/lib/x/errors/invalid_media_type.rb +0 -6
- data/lib/x/errors/network_error.rb +0 -6
- data/lib/x/errors/not_acceptable.rb +0 -6
- data/lib/x/errors/not_found.rb +0 -6
- data/lib/x/errors/payload_too_large.rb +0 -6
- data/lib/x/errors/server_error.rb +0 -6
- data/lib/x/errors/service_unavailable.rb +0 -6
- data/lib/x/errors/too_many_redirects.rb +0 -6
- data/lib/x/errors/too_many_requests.rb +0 -58
- data/lib/x/errors/unauthorized.rb +0 -6
- data/lib/x/errors/unprocessable_entity.rb +0 -6
- data/lib/x/media_upload_validator.rb +0 -36
- data/lib/x/media_uploader.rb +0 -234
- data/lib/x/oauth2_authenticator.rb +0 -169
- data/lib/x/oauth_authenticator.rb +0 -182
- data/lib/x/rate_limit.rb +0 -89
- data/lib/x/redirect_handler.rb +0 -109
- data/lib/x/request_builder.rb +0 -93
- data/lib/x/response_parser.rb +0 -81
- data/lib/x/stream_parser.rb +0 -75
data/UPGRADING.md
ADDED
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
# Upgrading
|
|
2
|
+
|
|
3
|
+
## From 0.19 to 1.0
|
|
4
|
+
|
|
5
|
+
Version 1.0 splits the gem into `x-core`, `x-uploads`, `x-streams`, and `x-resources`. The `x` gem depends on all four, and `require "x"` loads them.
|
|
6
|
+
|
|
7
|
+
Version 1.0 renames or removes what 0.19 had under old names, without deprecating them first. This guide covers what code written for 0.19 needs to change. See [CHANGELOG.md](https://github.com/sferik/x-ruby/blob/main/CHANGELOG.md) for everything that was added.
|
|
8
|
+
|
|
9
|
+
### Ruby
|
|
10
|
+
|
|
11
|
+
Version 1.0 requires Ruby 3.4 or later.
|
|
12
|
+
|
|
13
|
+
### Gems and requires
|
|
14
|
+
|
|
15
|
+
The files of 0.19 moved into the gem each belongs to, so a `require` of one of them raises `LoadError`:
|
|
16
|
+
|
|
17
|
+
* `require "x"` loads everything, as it did. Replace a require of a single file, such as `x/client`, `x/connection`, `x/oauth_authenticator`, `x/rate_limit`, or `x/errors/too_many_requests`, with it.
|
|
18
|
+
* Code that depends on one gem alone requires that gem: `x/core`, `x/uploads`, `x/streams`, or `x/resources`. Each of the last three loads `x/core`.
|
|
19
|
+
|
|
20
|
+
`x-core` depends on two gems that 0.19 did not:
|
|
21
|
+
|
|
22
|
+
* [net-http](https://rubygems.org/gems/net-http) `~> 0.9, >= 0.9.1`. An app that pins `net-http` below 0.9.1 must loosen the pin, or Bundler cannot resolve `x` 1.0. Where the Gemfile gives `x` no version, Bundler installs 0.19 instead, without saying so, so write `gem "x", "~> 1.0"`.
|
|
23
|
+
* [simple_oauth](https://rubygems.org/gems/simple_oauth) `~> 1.0`, which signs OAuth 1.0a requests.
|
|
24
|
+
|
|
25
|
+
`x` depends on exactly its own version of the other four gems, so `bundle update x` updates all five together.
|
|
26
|
+
|
|
27
|
+
### Credentials
|
|
28
|
+
|
|
29
|
+
`X::Client.new` checks its credentials, where 0.19 sent requests without the ones it could not use:
|
|
30
|
+
|
|
31
|
+
* Credentials that do not form a complete set raise `ArgumentError`. Pass one of these sets:
|
|
32
|
+
* OAuth 1.0a: `api_key`, `api_key_secret`, `access_token`, and `access_token_secret`.
|
|
33
|
+
* OAuth 2.0: `client_id` and `access_token`, plus the `refresh_token` that refreshes it and, for a confidential client, `client_secret`.
|
|
34
|
+
* App-only: `api_key` and `api_key_secret` alone.
|
|
35
|
+
* App-only: `bearer_token`.
|
|
36
|
+
* An OAuth 2.0 token issued without the `offline.access` scope has no refresh token. Pass `client_id` and `access_token` alone.
|
|
37
|
+
* Such a client acts for the user and refreshes nothing. 0.19 sent its requests without credentials.
|
|
38
|
+
* A `bearer_token` is the app's. Endpoints that take app-only authentication, such as streams, are sent it.
|
|
39
|
+
* A credential outside a complete set raises `ArgumentError`, rather than being ignored. Leave it out.
|
|
40
|
+
* For example, a `client_id` or an `api_key` beside a `bearer_token`.
|
|
41
|
+
* A client may hold several complete sets, such as an app's bearer token beside its API key and secret. It authenticates with the first, in the order above.
|
|
42
|
+
* A `bearer_token` beside an `api_key` and `api_key_secret` is sent until the API rejects it. The client then fetches another with the key and secret.
|
|
43
|
+
* OAuth 2.0 credentials beside a complete OAuth 1.0a set raise `ArgumentError`, since they would share its access token.
|
|
44
|
+
* `expires_at` is allowed only beside the OAuth 2.0 `client_id` and `access_token` the client authenticates with. Anywhere else it raises `ArgumentError`.
|
|
45
|
+
* A credential that is an empty String raises `ArgumentError`. 0.19 sent it for the API to refuse.
|
|
46
|
+
* Watch for an unset environment variable read as `ENV.fetch("X_BEARER_TOKEN", "")`.
|
|
47
|
+
|
|
48
|
+
A client, its authenticators, and `X::RateLimit` are read-only, and the Strings their readers return, such as `base_url`, `api_key`, and the values of `headers`, are frozen copies, so changing one in place raises `FrozenError`. Derive a changed client with `with`, which takes anything `X::Client.new` takes and checks it the same way:
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
# 0.19
|
|
52
|
+
client.authenticator.access_token = "new_token"
|
|
53
|
+
|
|
54
|
+
# 1.0
|
|
55
|
+
rotated = client.with(access_token: "new_token", access_token_secret: "new_secret")
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
* A client keeps its credentials and settings for life, so a request never signs with a mix of old and new credentials.
|
|
59
|
+
* These setters are gone. Pass the option to `with` instead, as `client.with(read_timeout: 30)`:
|
|
60
|
+
* On `X::Client`: `base_url=`, `default_array_class=`, `default_object_class=`, `open_timeout=`, `read_timeout=`, `write_timeout=`, `debug_output=`, `proxy_url=`, `max_redirects=`, and the setter of each credential.
|
|
61
|
+
* On the authenticators: the setter of each credential, and `connection=`.
|
|
62
|
+
* On `X::RateLimit`: `type=` and `response=`.
|
|
63
|
+
* The credentials of a copy must form a complete set. Clearing one so that the set is incomplete raises `ArgumentError`.
|
|
64
|
+
* A client and its copies with the same OAuth 2.0 credentials share one authenticator, so a refresh by any of them reaches all.
|
|
65
|
+
* A copy given other OAuth 2.0 credentials, such as another user's access token, drops the client's `refresh_token`, `expires_at`, `scopes`, `save_tokens`, and `load_tokens`.
|
|
66
|
+
* Before: a setter changed one credential and kept the rest.
|
|
67
|
+
* After: pass the refresh token, `save_tokens:`, and `load_tokens:` beside the access token they belong to.
|
|
68
|
+
* Clients built separately share an authenticator when each is given it as `authenticator:`, in place of credentials.
|
|
69
|
+
|
|
70
|
+
A client refreshes an OAuth 2.0 access token itself, where 0.19 never did:
|
|
71
|
+
|
|
72
|
+
* It refreshes when the `expires_at` it was given passes, or when X rejects the token.
|
|
73
|
+
* X issues a new refresh token with each refresh and no longer accepts the old one.
|
|
74
|
+
* Store the new tokens from `save_tokens:`, as below, or the stored refresh token stops working.
|
|
75
|
+
* Processes that share a user's tokens also pass `load_tokens:`, a callable that reads the store, so none refreshes with a refresh token another already spent.
|
|
76
|
+
|
|
77
|
+
A client and its authenticator no longer reveal secret credentials:
|
|
78
|
+
|
|
79
|
+
* These are private on `X::Client`, where 0.19 read them: `api_key_secret`, `access_token`, `access_token_secret`, `bearer_token`, `client_secret`, and `refresh_token`.
|
|
80
|
+
* These are private on the authenticators, so `client.authenticator` reads none of them by name:
|
|
81
|
+
* `api_key_secret`, `access_token_secret`, and `client_secret`.
|
|
82
|
+
* `bearer_token` of `X::BearerTokenAuthenticator` and `X::AppOnlyAuthenticator`.
|
|
83
|
+
* `access_token` and `refresh_token` of `X::OAuth2Authenticator`, which 0.19 read.
|
|
84
|
+
* `access_token` of `X::OAuth1Authenticator`, which 0.19 read off `X::OAuthAuthenticator`.
|
|
85
|
+
* `inspect` reveals no secret.
|
|
86
|
+
* An authenticator's `headers` still returns the headers it signs a request with, since a custom authenticator overrides it.
|
|
87
|
+
* The `Authorization` header of a bearer token, an app-only token, and an OAuth 2.0 access token holds the token itself, so guard an authenticator as you guard its client.
|
|
88
|
+
* Still public on a client: `api_key`, `client_id`, and the new `expires_at`. On an OAuth 2.0 authenticator: `client_id` and `expires_at`.
|
|
89
|
+
* Read the user an OAuth 1.0a access token names with `X::Authenticator#user_id`, rather than parsing the token.
|
|
90
|
+
* Read refreshed tokens from the frozen `X::OAuth2Tokens` passed to `save_tokens`, and carry credentials to another client with `with`:
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
# 0.19
|
|
94
|
+
store(client.access_token, client.refresh_token)
|
|
95
|
+
|
|
96
|
+
# 1.0
|
|
97
|
+
X::Client.new(**credentials, save_tokens: ->(tokens) { store(tokens.access_token, tokens.refresh_token, tokens.expires_at) })
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Clients and credentials refuse to be serialized, where 0.19 wrote them, credentials and all, in the clear:
|
|
101
|
+
|
|
102
|
+
* A client, a streaming client, an authenticator, and an `X::OAuth2Authorization` raise `TypeError` from `Marshal.dump`, `YAML.dump`, `as_json`, and `to_json`.
|
|
103
|
+
* This catches a client held in a cached Hash, a job argument a queue writes as YAML, and `render json:`.
|
|
104
|
+
* Keep credentials in a secret store, and build the client again from them.
|
|
105
|
+
* `X::OAuth2Tokens` still marshal, tokens and all, since they are meant to be stored.
|
|
106
|
+
* They write themselves as JSON with `to_json`, and `X::OAuth2Tokens.from_json` reads it back.
|
|
107
|
+
|
|
108
|
+
An OAuth 2.0 authenticator's `refresh_token!` is now `refresh!`:
|
|
109
|
+
|
|
110
|
+
* Before: it returned the Hash of the token response.
|
|
111
|
+
* After: it returns the frozen `X::OAuth2Tokens` of that refresh, the object `save_tokens` is passed.
|
|
112
|
+
* Read the new tokens from what it returns. The authenticator's tokens are private, and another thread's refresh may replace them.
|
|
113
|
+
* A refresh X refuses raises `X::AuthorizationError`, an `X::ClientError`, where 0.19 raised an `X::Error` whose message was the `error_description`.
|
|
114
|
+
* Its `error_code` is the OAuth 2.0 error, such as `invalid_grant`. Read it rather than the message.
|
|
115
|
+
* A token endpoint that fails to answer raises the `X::HTTPError` of its status, such as `X::ServerError`.
|
|
116
|
+
* `rescue X::Error` catches both, as it did.
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
# 0.19
|
|
120
|
+
store(client.authenticator.refresh_token!["refresh_token"])
|
|
121
|
+
|
|
122
|
+
# 1.0
|
|
123
|
+
store(client.authenticator.refresh!.refresh_token)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Token requests go to the origin of the base URL, where 0.19 sent them to `api.x.com` whatever the base URL:
|
|
127
|
+
|
|
128
|
+
* This is the app-only bearer token, an OAuth 2.0 refresh, and the code exchange of `X::OAuth2Authorization`.
|
|
129
|
+
* The path is the base URL's minus a trailing API version segment.
|
|
130
|
+
* With `base_url: "https://gateway.example/x/2/"`, an app-only token comes from `/x/oauth2/token` and a refresh goes to `/x/2/oauth2/token`.
|
|
131
|
+
* A client whose base URL names a gateway or proxy sends its tokens there, so it must forward the token endpoints to X too.
|
|
132
|
+
|
|
133
|
+
A custom authenticator overrides `headers`, where 0.19 overrode `header`:
|
|
134
|
+
|
|
135
|
+
* It returns the headers that authenticate a request, as `header` did.
|
|
136
|
+
* Before: it was passed the `Net::HTTPRequest`, whose `method` is a String such as `"POST"`.
|
|
137
|
+
* After: the request it is passed answers only `http_method` (a Symbol such as `:post`), `uri`, `body`, and `[]` for a header.
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
# 0.19
|
|
141
|
+
def header(request) = {"Authorization" => sign(request.method, request.uri)}
|
|
142
|
+
|
|
143
|
+
# 1.0
|
|
144
|
+
def headers(request) = {"Authorization" => sign(request.http_method.to_s.upcase, request.uri)}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Requests
|
|
148
|
+
|
|
149
|
+
An endpoint that begins with a slash is relative to the base URL, like one without:
|
|
150
|
+
|
|
151
|
+
* Before: `/1.1/...` resolved against the host, dropping the `/2/` path of the base URL.
|
|
152
|
+
* After: pass a whole URL, or derive a client with another `base_url`, to reach another API version.
|
|
153
|
+
|
|
154
|
+
```ruby
|
|
155
|
+
# 0.19
|
|
156
|
+
client.get("/1.1/account/settings.json")
|
|
157
|
+
|
|
158
|
+
# 1.0
|
|
159
|
+
client.with(base_url: "https://api.x.com/1.1/").get("account/settings.json")
|
|
160
|
+
client.get("https://api.x.com/1.1/account/settings.json")
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Credentials are sent only to the origin of the `base_url`:
|
|
164
|
+
|
|
165
|
+
* The default `base_url` is `https://api.x.com/2/`, where 0.19 defaulted to `https://api.twitter.com/2/`.
|
|
166
|
+
* Pass whole URLs on `api.x.com`, or a `base_url` on `api.twitter.com`, so whole URLs keep the client's credentials.
|
|
167
|
+
* An endpoint or stream is signed only when it has the scheme, host, and port of the `base_url`. 0.19 signed a request to any host.
|
|
168
|
+
* A request to another origin is sent without any `Authorization`, `Cookie`, or `Proxy-Authorization` header, the client's or the request's.
|
|
169
|
+
* A redirect off the origin drops them too. 0.19 signed the first redirect for whatever host it named.
|
|
170
|
+
* To reach another host with its own credentials, build a client for it with its own `base_url`.
|
|
171
|
+
* Token endpoints (app-only and OAuth 2.0) are requested at the origin of the `base_url`. 0.19 always used `api.x.com`.
|
|
172
|
+
* So a client pointed at a test server or recording proxy fetches and refreshes its tokens there.
|
|
173
|
+
|
|
174
|
+
Invalid endpoints raise `ArgumentError` before any request:
|
|
175
|
+
|
|
176
|
+
* An endpoint that is not a valid URL (such as one holding a space), or not an http or https URL with a host, raises `ArgumentError` naming it.
|
|
177
|
+
* Before: `URI::InvalidURIError`, or a `Net::HTTP` `ArgumentError` that named nothing.
|
|
178
|
+
* After: rescue `ArgumentError` where code rescued `URI::InvalidURIError`.
|
|
179
|
+
* An endpoint that is not a String, such as a Symbol or a `URI`, raises `ArgumentError`. Pass `uri.to_s`.
|
|
180
|
+
|
|
181
|
+
Requests are retried by the client, not by `Net::HTTP`:
|
|
182
|
+
|
|
183
|
+
* Before: `Net::HTTP` resent a GET, PUT, or DELETE after a timeout or dropped connection, reusing the OAuth 1.0a nonce and signature. A 5xx raised at once.
|
|
184
|
+
* After: the client resends a GET, PUT, or DELETE after `X::ServerError`, `X::RequestTimeout`, or an `X::NetworkError` for a request that never reached the API.
|
|
185
|
+
* It does not resend after a read timeout, since the API bills a read it answered.
|
|
186
|
+
* It retries `max_retries` times, twice by default, signing each attempt afresh.
|
|
187
|
+
* It waits as long as a `Retry-After` header asks, up to a minute. A response asking for longer raises at once.
|
|
188
|
+
* Pass `max_retries: 0` to raise at once, as code with its own retry loop may want. See the retries in [README.md](https://github.com/sferik/x-ruby/blob/main/README.md).
|
|
189
|
+
|
|
190
|
+
`max_redirects` is the number of redirects a request follows. 0.19 followed one more, so `max_redirects: 0` now follows none.
|
|
191
|
+
|
|
192
|
+
### Renamed classes
|
|
193
|
+
|
|
194
|
+
| 0.19 | 1.0 |
|
|
195
|
+
| --- | --- |
|
|
196
|
+
| `X::OAuthAuthenticator` | `X::OAuth1Authenticator` |
|
|
197
|
+
| `X::ConnectionException` | `X::Conflict` |
|
|
198
|
+
| `X::MediaUploader` | `X::Uploads::MediaUpload` |
|
|
199
|
+
| `X::AccountUploader` | `X::Uploads::Account` |
|
|
200
|
+
|
|
201
|
+
Remove `require "x/media_uploader"` and `require "x/account_uploader"`. `require "x"` loads the uploaders.
|
|
202
|
+
|
|
203
|
+
`X::MediaUploadValidator` is gone, with no public replacement:
|
|
204
|
+
|
|
205
|
+
* The uploaders validate their own arguments, raising `ArgumentError` for an invalid category.
|
|
206
|
+
* Pass a file to `upload`. It raises `X::InvalidMedia` for a missing file, and infers the type it sends.
|
|
207
|
+
* The media category constants of `X::Uploads::MediaUpload`, such as `TWEET_IMAGE`, remain public.
|
|
208
|
+
* These are now private constants: `X::Uploads::Validator`, `X::Uploads::Chunks`, `X::Uploads::Multipart`, and `X::Uploads::Utils`.
|
|
209
|
+
* These constants of `X::Uploads::MediaUpload` are private: `MIME_TYPES`, `MIME_TYPE_MAP`, `BYTES_PER_MB`, and the MIME type constants, such as `GIF_MIME_TYPE`, `MP4_MIME_TYPE`, and `SUBRIP_MIME_TYPE`.
|
|
210
|
+
* `PROCESSING_INFO_STATES` is gone. Use `X::UploadedMedia#processing?`.
|
|
211
|
+
|
|
212
|
+
### Uploads
|
|
213
|
+
|
|
214
|
+
The uploaders take the file, content, or media as their first argument, and the client as a keyword. `upload_profile_image_binary` and `upload_profile_banner_binary` are gone: pass the content to `update_profile_image` or `update_profile_banner` as a `StringIO`.
|
|
215
|
+
|
|
216
|
+
```ruby
|
|
217
|
+
# 0.19
|
|
218
|
+
media = X::MediaUploader.upload(client:, file_path: "cat.jpg", media_category: "tweet_image")
|
|
219
|
+
video = X::MediaUploader.chunked_upload(client:, file_path: "cat.mp4", media_category: "tweet_video")
|
|
220
|
+
X::MediaUploader.await_processing!(client:, media: video)
|
|
221
|
+
X::AccountUploader.update_profile_image(client:, file_path: "avatar.png")
|
|
222
|
+
X::AccountUploader.upload_profile_image_binary(client:, content:)
|
|
223
|
+
X::AccountUploader.upload_profile_banner_binary(client:, content:)
|
|
224
|
+
|
|
225
|
+
# 1.0
|
|
226
|
+
media = X::Uploads::MediaUpload.upload("cat.jpg", client:)
|
|
227
|
+
video = X::Uploads::MediaUpload.upload("cat.mp4", client:) # uploads in chunks and awaits processing
|
|
228
|
+
X::Uploads::Account.update_profile_image("avatar.png", client:)
|
|
229
|
+
X::Uploads::Account.update_profile_image(StringIO.new(content), client:)
|
|
230
|
+
X::Uploads::Account.update_profile_banner(StringIO.new(content), client:)
|
|
231
|
+
|
|
232
|
+
# 1.0, as methods of a client, which require "x" adds
|
|
233
|
+
media = client.upload_media("cat.jpg")
|
|
234
|
+
client.update_profile_image("avatar.png")
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
* `upload` infers the media category from the media, and still takes `media_category:`.
|
|
238
|
+
* No upload method takes `boundary:`. Each upload generates its own multipart boundary.
|
|
239
|
+
* `await_processing` and `await_processing!` take the media as one argument: an upload response, a media ID, or anything that answers `media_key`, such as an `X::Media`.
|
|
240
|
+
* A class that includes `X::Uploads::MediaUpload` gains only its public methods.
|
|
241
|
+
* Private ones it gained in 0.19, such as `init`, `append`, and `construct_upload_body`, are gone from it.
|
|
242
|
+
* `client.chunked_upload_media` uploads in chunks and returns without awaiting processing, as `chunked_upload` does.
|
|
243
|
+
|
|
244
|
+
Media can be a path or an IO, where 0.19 took a path alone:
|
|
245
|
+
|
|
246
|
+
* `upload` and `chunked_upload` take a `String` or `Pathname` path, or an IO open on the media.
|
|
247
|
+
* A path, `File`, or `Tempfile` is read a chunk at a time, so media of any size uploads without being held in memory.
|
|
248
|
+
* Any other IO, such as a `StringIO`, is read to its end and held in memory.
|
|
249
|
+
* Media that names no file is categorized by its leading bytes, so `client.upload_media(StringIO.new(png))` uploads an image.
|
|
250
|
+
* Media whose type neither its bytes nor its file name reveal raises `X::InvalidMediaType` unless `media_category:` names it.
|
|
251
|
+
* For example, SubRip subtitles in a `StringIO`, or a PDF.
|
|
252
|
+
|
|
253
|
+
The profile image and banner of `X::Uploads::Account` are checked before any request:
|
|
254
|
+
|
|
255
|
+
* They take a path or an IO, where 0.19 took a path alone.
|
|
256
|
+
* They must begin with the signature of a GIF, JPEG, or PNG, whatever the file name, or raise `X::InvalidMediaType`.
|
|
257
|
+
* Before: a file was taken by its extension, so a video named `.png` was sent.
|
|
258
|
+
* A profile image over 700 KB, or a banner over 5 MB, raises `X::InvalidMedia`.
|
|
259
|
+
* A banner's `width:` or `height:` that is not a positive `Integer` raises `ArgumentError`. 0.19 sent it as given.
|
|
260
|
+
* A banner's `offset_left:` or `offset_top:` that is not an `Integer` of at least 0, such as `"1500"`, raises `ArgumentError`.
|
|
261
|
+
* Content given as a `StringIO` raises `X::InvalidMedia` if empty, and `X::InvalidMediaType` if not a GIF, JPEG, or PNG.
|
|
262
|
+
* They are posted with the client given, and its credentials, to API v1.1 at the host of its `base_url`.
|
|
263
|
+
* Before: they built their own client for `https://api.x.com/1.1/` from the OAuth 1.0a credentials.
|
|
264
|
+
* `update_profile_image` returns nil, as `update_profile_banner` does. 0.19 returned the API v1.1 user Hash.
|
|
265
|
+
* Look the user up, as with `client.current_user!` of `x-resources`, to read the new profile image.
|
|
266
|
+
|
|
267
|
+
`upload_binary` is gone. A client has no `upload_media_binary`:
|
|
268
|
+
|
|
269
|
+
* Pass content in memory as a `StringIO` to `upload_media` or `X::Uploads::MediaUpload.upload`. They infer its category, or take `media_category:`.
|
|
270
|
+
* An image, or a GIF small enough, uploads in a single request, as `upload_binary` did.
|
|
271
|
+
* A video, subtitles, or a larger GIF uploads in chunks, which sends their media type and has no 5 MB limit. 0.19 sent them in a single request.
|
|
272
|
+
* They await processing of media that X processes.
|
|
273
|
+
|
|
274
|
+
```ruby
|
|
275
|
+
# 0.19
|
|
276
|
+
X::MediaUploader.upload_binary(client:, content:, media_category: "tweet_image")
|
|
277
|
+
|
|
278
|
+
# 1.0
|
|
279
|
+
client.upload_media(StringIO.new(content)) # the category read from the signature
|
|
280
|
+
client.upload_media(StringIO.new(content), media_category: "tweet_image")
|
|
281
|
+
X::Uploads::MediaUpload.upload(StringIO.new(content), client:, media_category: "tweet_image")
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
`upload`, `chunked_upload`, `await_processing`, and `await_processing!` return a frozen `X::UploadedMedia`, rather than a Hash:
|
|
285
|
+
|
|
286
|
+
* It reads like the Hash did, with `[]`, `fetch`, `dig`, and `key?`, so `media["size"]` and `media.dig("processing_info", "state")` still work.
|
|
287
|
+
* `to_h` returns the Hash, for code that compares it with a Hash or calls `merge`.
|
|
288
|
+
* It also answers `id`, `media_key`, `bytesize` (what `media["size"]` holds), `expires_after_secs`, `state`, `processing?`, `failed?`, and `ready?`.
|
|
289
|
+
* `media.id` is an Integer. `media["id"]`, `to_h`, `as_json`, and `to_json` keep the String the API gave, as 0.19 did.
|
|
290
|
+
* `media_ids:` still sends each ID as a String.
|
|
291
|
+
* For media X processes, such as a video or an animated GIF, `upload` returns the processing status rather than the upload response. Both hold the ID.
|
|
292
|
+
* The other uploaders return Hashes and Arrays, whatever the client's `default_object_class` and `default_array_class`.
|
|
293
|
+
* Before: `X::MediaUploader` parsed its responses with the client's classes.
|
|
294
|
+
|
|
295
|
+
The media type sent is read from the media:
|
|
296
|
+
|
|
297
|
+
* `infer_media_type` is internal, where 0.19 documented it. Pass `media_type:` to `upload` or `chunked_upload` to send another type.
|
|
298
|
+
* An upload sends the type the bytes name, or else the extension's.
|
|
299
|
+
* Before: `video/mp4` for every video. After: for example, `video/quicktime` for `clip.mov`.
|
|
300
|
+
* Before: `application/x-subrip` for SubRip subtitles. After: `text/srt`.
|
|
301
|
+
* Media of a type its category does not take raises `X::InvalidMediaType` before any request.
|
|
302
|
+
* For example, an MP4 uploaded as `tweet_gif`, `dm_gif`, or `subtitles`, or a PNG uploaded as `tweet_video`.
|
|
303
|
+
* Before: it was sent as the category's first type, such as an MP4 sent as a GIF.
|
|
304
|
+
* After: pass the right category, or `media_type:` to `chunked_upload` to send it as another type.
|
|
305
|
+
* Content in memory raises it too, when its signature names a type its category does not take, or names none for a GIF category.
|
|
306
|
+
* A file named as a type whose files all begin with a signature, such as `.png`, `.gif`, or `.ts`, raises `X::InvalidMediaType` if it lacks it.
|
|
307
|
+
* For example, TypeScript named `.ts`.
|
|
308
|
+
* Bytes win over the name, so a PNG named `.gif` uploads as an image.
|
|
309
|
+
* These raise `X::InvalidMediaType`, whatever `media_category:` says, unless uploaded in chunks with both a `media_category:` and a `media_type:` naming the type:
|
|
310
|
+
* An `.avi` or `.mkv` file that does not begin with the signature of a documented type, such as MP4 or WebM.
|
|
311
|
+
* Media whose name names no type, such as a `StringIO`, that begins with the header of Matroska and is not WebM.
|
|
312
|
+
* A `.glb` or `.usdz` file.
|
|
313
|
+
* Before: any of them uploaded as MP4 when told it was a video.
|
|
314
|
+
|
|
315
|
+
Processing and failures raise errors of their own:
|
|
316
|
+
|
|
317
|
+
* `await_processing!` raises `X::MediaProcessingFailed`, rather than `RuntimeError`.
|
|
318
|
+
* `await_processing` raises `X::MediaProcessingTimeout` after 600 seconds, rather than waiting forever.
|
|
319
|
+
* Pass `processing_timeout: nil` to wait as long as processing takes, as 0.19 did.
|
|
320
|
+
* Otherwise pass a finite number of seconds. `Float::INFINITY` raises `ArgumentError`.
|
|
321
|
+
* The same goes for `upload` and the client's `upload_media`, `await_media_processing`, and `await_media_processing!`.
|
|
322
|
+
* It waits until X reports processing succeeded or failed, as 0.19 did, but no longer than `processing_timeout:`.
|
|
323
|
+
* Media in any other state, even one X does not document, or in none, is still `processing?` and is checked again, each second if X asks for no wait.
|
|
324
|
+
* `await_processing!` and `upload` raise `X::MediaProcessingFailed` for failed processing alone.
|
|
325
|
+
* Media that already says its processing ended, or an image's upload response, is returned without a request. 0.19 checked it again.
|
|
326
|
+
* An upload response that describes no media, or has a nil or empty ID, raises `X::MissingMediaData`.
|
|
327
|
+
* Media given with no ID, such as nil or `{}`, raises `ArgumentError` before any request, as does `X::UploadedMedia.new`, which also refuses an ID that is neither an Integer nor a String of 1 to 19 digits.
|
|
328
|
+
* Before: `NoMethodError` or `KeyError`, or an empty `media_id` was sent.
|
|
329
|
+
* A missing file, or media that cannot be read, is empty, or is too large, raises `X::InvalidMedia` before any request.
|
|
330
|
+
* Before: the uploaders raised `RuntimeError` "File not found" for a missing file.
|
|
331
|
+
* After: rescue `X::InvalidMedia` where code rescued `RuntimeError`.
|
|
332
|
+
* An `X::InvalidMedia` whose `cause` is a system error, such as `Errno::EMFILE` or `Errno::EIO`, means the machine could not read the media, not that the media is wrong.
|
|
333
|
+
* A chunked upload that fails once initialized, at a chunk or at finalize, raises `X::ChunkedUploadFailed`.
|
|
334
|
+
* Its `media` is the media the upload initialized, and its `cause` is the error that failed it.
|
|
335
|
+
* Before: the request's error, such as `X::ServerError`, was raised, and the media was lost.
|
|
336
|
+
* After: rescue `X::ChunkedUploadFailed`, or read `error.cause`.
|
|
337
|
+
* An upload whose media is uploaded, but whose check of its processing fails, raises `X::MediaProcessingCheckFailed`.
|
|
338
|
+
* Its `media` is the media that was uploaded, and its `cause` is the error that failed the check.
|
|
339
|
+
* Before: `upload` did not await processing. Code that called `await_processing` itself saw the request's error, as it still does.
|
|
340
|
+
* After: rescue `X::MediaProcessingCheckFailed` around `upload`, and await `error.media` again.
|
|
341
|
+
* An upload given `alt_text:` whose media is uploaded, but whose alt text cannot be added, raises `X::AltTextFailed`.
|
|
342
|
+
* Its `media` is the media that was uploaded, and its `cause` is the error that failed to add the alt text.
|
|
343
|
+
* After: rescue `X::AltTextFailed`, and attach `error.media` or call `add_alt_text` with it again.
|
|
344
|
+
* An `X::TokenReportFailed`, which a `save_tokens` that raises during a request of an upload raises, is raised as it is, not as the `cause` of any of these.
|
|
345
|
+
* Rescue it around the upload to store `error.tokens`, as around any other request.
|
|
346
|
+
|
|
347
|
+
The errors x-uploads raises descend from `X::Uploads::Error`, an `X::Error`:
|
|
348
|
+
|
|
349
|
+
* These are `X::Uploads::Error`s: `X::MissingMediaData`, `X::MediaProcessingFailed`, `X::MediaProcessingTimeout`, `X::MediaProcessingCheckFailed`, `X::ChunkedUploadFailed`, `X::AltTextFailed`, and `X::InvalidMedia`.
|
|
350
|
+
* `X::InvalidMediaType` descends from `X::InvalidMedia`.
|
|
351
|
+
* So code that uploads user input can rescue it without rescuing the `ArgumentError` of its own mistakes.
|
|
352
|
+
* An `ArgumentError` is not an `X::Error`. Besides a mistake in the arguments, a request raises one when the proxy named in the environment cannot be parsed, or is not an `http` or `https` URL with a host. The INIT request, or the one request of a single-request upload, raises it as it is; a later request that opens a connection raises `X::ChunkedUploadFailed`, `X::MediaProcessingCheckFailed`, or `X::AltTextFailed` with it as the `cause`.
|
|
353
|
+
* An `X::Error` the API raises before there is media, such as an `X::BadRequest` of the INIT request, is not an `X::Uploads::Error`, as in 0.19.
|
|
354
|
+
* `rescue X::Error` catches both.
|
|
355
|
+
|
|
356
|
+
Chunk sizes are in bytes, and sizes are checked before any request:
|
|
357
|
+
|
|
358
|
+
* `chunk_size_mb:` is now `chunk_size:`, in bytes. Pass `chunk_size: 4 * 1024 * 1024` where 0.19 code passed `chunk_size_mb: 4`.
|
|
359
|
+
* A `chunk_size:` that is not a positive `Integer`, such as a Float, raises `ArgumentError`.
|
|
360
|
+
* `chunk_size:` above 5,242,880 bytes (5 MB), or one needing more than 10,000 segments, raises `ArgumentError`.
|
|
361
|
+
* 0.19 sent it for the server to refuse above 8 MB.
|
|
362
|
+
* `chunk_size:` defaults to nil, which uploads in chunks of 4 MB, `X::Uploads::MediaUpload::DEFAULT_CHUNK_SIZE`, where 0.19 used 1 MB chunks.
|
|
363
|
+
* Why: each chunk is a request a rate limit can refuse, and the API takes at most 10,000 segments, so 0.19's 1 MB chunks failed for files over 10,000 MiB.
|
|
364
|
+
* A file over 16 GB (17,179,869,184 bytes) raises `X::InvalidMedia` before anything is uploaded.
|
|
365
|
+
* An `alt_text:` that is empty or over 1,000 characters raises `ArgumentError`.
|
|
366
|
+
* An animated GIF over 5 MB uploads in chunks, up to 15 MB. 0.19 sent it in a single request for X to refuse.
|
|
367
|
+
* These raise `X::InvalidMedia` before any request, where 0.19 sent them for X to refuse:
|
|
368
|
+
* An image over 5 MB.
|
|
369
|
+
* A GIF over 15 MB.
|
|
370
|
+
* Subtitles over 1 MB.
|
|
371
|
+
* A megabyte is 1,048,576 bytes. A video's limit depends on the account and is left to X, up to 16 GB.
|
|
372
|
+
|
|
373
|
+
### Objects
|
|
374
|
+
|
|
375
|
+
`require "x"` loads `x-resources`, which has no counterpart in 0.19. `get`, `post`, `put`, and `delete` return what they did, so code written for 0.19 changes only where one of its own names is now one of the gem's:
|
|
376
|
+
|
|
377
|
+
* These constants are new under `X`:
|
|
378
|
+
* Resources: `X::User`, `X::Post` and its alias `X::Tweet`, `X::Media`, `X::List`, `X::Space`, `X::Community`, `X::DirectMessage`, `X::Poll`, `X::Place`, `X::Topic`, `X::BookmarkFolder`, and their base, `X::Resource`.
|
|
379
|
+
* Values: `X::Trend`, `X::PersonalizedTrend`, `X::MatchingRule`, and `X::PostUsage`.
|
|
380
|
+
* Collections: `X::Page` and `X::Cursor`.
|
|
381
|
+
* Errors: `X::MissingResource`, `X::MissingClient`, `X::UnreadableResponse`, `X::InvalidAttribute`, and `X::PageLimitReached`.
|
|
382
|
+
* `x-uploads` and `x-streams` add `X::UploadedMedia`, `X::StreamingClient`, `X::StreamRule`, `X::StreamError`, `X::RulesRejected`, and the errors named under Uploads.
|
|
383
|
+
* `x-core` adds `X::Response`, `X::StreamResponse`, `X::Problem`, `X::OAuth2Tokens`, `X::OAuth2Authorization`, `X::AppOnlyAuthenticator`, the new errors named under Errors, and `X::AuthorizationError`, `X::AuthorizationDenied`, `X::TokenReportFailed`, `X::UnsupportedOperation`, and `X::UnsupportedFormat`.
|
|
384
|
+
* Each gem adds a module of its own name: `X::Core`, `X::Uploads`, `X::Streams`, and `X::Resources`.
|
|
385
|
+
* An app that defined one of these itself, such as its own `X::User`, now reopens the gem's class. Rename it, or move it out of `X`.
|
|
386
|
+
* `X::Client` has about a hundred new public methods, which `X::Resources::API` documents:
|
|
387
|
+
* Lookups, such as `find_user`, `find_post`, `find_all_users`, `current_user`, and `current_user_id`.
|
|
388
|
+
* Searches and counts, such as `search_posts`, `search_users`, `count_posts`, and `trends`.
|
|
389
|
+
* Actions, such as `create_post`, `delete_post`, `follow`, `like`, `repost`, `bookmark`, `block`, `mute`, `create_list`, and `create_dm`.
|
|
390
|
+
* `x-uploads` adds `upload_media` and seven more, and `x-streams` adds `streaming`.
|
|
391
|
+
* `x-core` adds `with`, `app_only`, `close`, `headers`, `get_stream`, `with_retries`, `memoize`, `memoized`, `scopes`, `expires_at`, and a reader for each setting, such as `max_retries`.
|
|
392
|
+
* A subclass of `X::Client`, or code that reopens it, that defines a method of one of these names replaces the gem's. Rename it.
|
|
393
|
+
* Code that depends on `x-core` alone, and requires `x/core`, gets the names of `x-core` alone.
|
|
394
|
+
|
|
395
|
+
### Timeouts
|
|
396
|
+
|
|
397
|
+
`open_timeout` defaults to 10 seconds, rather than the 60 of 0.19:
|
|
398
|
+
|
|
399
|
+
* Why: a reachable host finishes the TCP and TLS handshakes in well under a second, so 10 seconds gives up on an unresponsive host sooner.
|
|
400
|
+
* `read_timeout` and `write_timeout` are still 60 seconds.
|
|
401
|
+
* Pass `open_timeout: 60` for the old default on a network where connecting is slow.
|
|
402
|
+
|
|
403
|
+
Timeouts are checked when the client is built:
|
|
404
|
+
|
|
405
|
+
* `open_timeout`, `read_timeout`, and `write_timeout` take a finite number of seconds of at least 0, or nil for none, as in 0.19.
|
|
406
|
+
* Anything else, such as a String read from an environment variable, raises `ArgumentError` from `X::Client.new`.
|
|
407
|
+
* Before: `Net::HTTP` raised once a request waited.
|
|
408
|
+
|
|
409
|
+
`debug_output` is checked when the client is built too:
|
|
410
|
+
|
|
411
|
+
* It takes nil, or what takes a String with `<<`, such as an IO, a `StringIO`, or a `Logger`. A value that does not, such as `true`, or whose `<<` does something else, such as an Integer or a Proc, raises `ArgumentError`.
|
|
412
|
+
* Before: `NoMethodError` from the first request for `true`, and `TypeError` for an Integer or a Proc.
|
|
413
|
+
* A String, such as `"debug.log"`, raises `ArgumentError` too. Pass `File.open("debug.log", "a")`, or a `StringIO` to collect the output.
|
|
414
|
+
* Before: every request, with its `Authorization` header, was appended to the String, and no file was written.
|
|
415
|
+
|
|
416
|
+
### Headers
|
|
417
|
+
|
|
418
|
+
A client takes `headers:`, which it sends with every request and stream:
|
|
419
|
+
|
|
420
|
+
* They are defaults. A header of the same name passed to a request replaces the client's.
|
|
421
|
+
* The client's headers replace the gem's defaults, such as its `User-Agent`.
|
|
422
|
+
* The gem's default `User-Agent` names the gem, Ruby, and the platform, as `x-ruby/1.0.0 ruby/3.4.0 (arm64-darwin24)`, where 0.19 sent `X-Client`.
|
|
423
|
+
* Code that tells the gem's requests apart by it, such as a proxy rule, matches the new value, or sends its own with `headers:`.
|
|
424
|
+
* A header that carries credentials is dropped on a redirect to another origin, whether given to the client or to the request.
|
|
425
|
+
* A header named by a Symbol is sent with its underscores as hyphens. `content_type:` names `content-type`, so it replaces that header.
|
|
426
|
+
* `client.headers` names each header by a String, the way it is sent, whether it was given by a String or a Symbol.
|
|
427
|
+
* They are sent with the client's token requests too, the app-only bearer token, an OAuth 2.0 refresh, and the code exchange, beneath the token request's own `Authorization`, `Content-Type`, and `Accept`.
|
|
428
|
+
|
|
429
|
+
```ruby
|
|
430
|
+
# 1.0
|
|
431
|
+
client = X::Client.new(headers: {"User-Agent" => "my-app/1.0"}, **x_credentials)
|
|
432
|
+
traced = client.with(headers: {"User-Agent" => "my-app/1.0", "X-Trace" => "abc"})
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
### Proxies
|
|
436
|
+
|
|
437
|
+
A client given no `proxy_url` picks the proxy for each request's scheme from the environment:
|
|
438
|
+
|
|
439
|
+
* It uses `https_proxy` for HTTPS requests, which is all these gems make, and `http_proxy` for plain HTTP.
|
|
440
|
+
* It connects directly to hosts listed in `no_proxy`.
|
|
441
|
+
* Before: `Net::HTTP` read `http_proxy` alone, whatever the scheme, so `https_proxy` was ignored.
|
|
442
|
+
* A proxy URL in one of these variables that cannot be parsed, or that is not an `http` or `https` URL with a host, as `proxy.example.com:8080` and `socks5://proxy.example.com:1080` are not, raises `ArgumentError` from each request that opens a connection. An empty variable names no proxy. The message names the variables read for the scheme of the request, as `https_proxy or HTTPS_PROXY`, and leaves out the value, which can hold a password.
|
|
443
|
+
* Set `no_proxy`, or pass a `proxy_url`, where this changes which proxy a process reaches X through.
|
|
444
|
+
|
|
445
|
+
A proxy URL can hold a user and password, so a client keeps it private:
|
|
446
|
+
|
|
447
|
+
* `proxy_url` no longer reads off `X::Client` or `X::Connection`, nor off the new `X::StreamingClient`.
|
|
448
|
+
* `proxy_uri`, `proxy_host`, `proxy_port`, `proxy_user`, and `proxy_pass` no longer read off a connection.
|
|
449
|
+
* `inspect` leaves the proxy out, and `with` carries the proxy to a copy.
|
|
450
|
+
* Keep the URL you built the client with if your code needs to read it again.
|
|
451
|
+
|
|
452
|
+
### Streaming
|
|
453
|
+
|
|
454
|
+
`stream` moved from `X::Client` to `X::StreamingClient`, from `x-streams`, which `streaming` builds from a client:
|
|
455
|
+
|
|
456
|
+
```ruby
|
|
457
|
+
# 0.19
|
|
458
|
+
client.stream("tweets/search/stream") { |post| puts post }
|
|
459
|
+
|
|
460
|
+
# 1.0
|
|
461
|
+
client.streaming.stream("tweets/search/stream") { |post| puts post }
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
A stream reconnects when it ends or drops:
|
|
465
|
+
|
|
466
|
+
* Before: a stream that ended returned, and one that dropped raised.
|
|
467
|
+
* After: it reconnects, and once it has no reconnects left raises `X::NetworkError`, whether it ended or dropped.
|
|
468
|
+
* Rescue `X::NetworkError` where 0.19 code waited for `stream` to return.
|
|
469
|
+
* Pass `max_reconnects: 0` to `streaming` to reconnect no stream, as 0.19 did.
|
|
470
|
+
* Pass `on_reconnect: ->(error, wait) { ... }` to `streaming` to hear of each reconnect, or to give up with `stop`.
|
|
471
|
+
* To stop a stream from another thread or the trap of a signal, call `stop` on its streaming client. Each stream it stops returns nil.
|
|
472
|
+
* Keep the streaming client in a variable, since each `streaming` call builds a new one.
|
|
473
|
+
* The streaming client stays stopped; build another with `streaming` to stream again.
|
|
474
|
+
* A block, `on_response`, or `on_reconnect` that is running finishes first.
|
|
475
|
+
|
|
476
|
+
A line that holds errors and no data is not yielded to the block:
|
|
477
|
+
|
|
478
|
+
* Before: every line X sent was yielded, so a block read `post["errors"]` to tell a problem from a post.
|
|
479
|
+
* After: such a line raises `X::StreamError`, an `X::Streams::Error`, whose `problems` are the `X::Problem`s of the line.
|
|
480
|
+
* A line of `operational-disconnect`s alone reconnects instead, and raises once no reconnects are left.
|
|
481
|
+
* Any other stops the stream. Rescue `X::StreamError` around `stream`, beside `X::NetworkError`, where the block handled those lines.
|
|
482
|
+
* A line that is not JSON reconnects, and raises `X::InvalidResponse` once no reconnects are left. 0.19 raised `JSON::ParserError`.
|
|
483
|
+
|
|
484
|
+
A stream has its own `read_timeout`, 30 seconds by default:
|
|
485
|
+
|
|
486
|
+
* Before: it read with the client's 60 seconds. Pass `streaming(read_timeout: 60)` for the old timeout.
|
|
487
|
+
* A `read_timeout` under 25 seconds raises `ArgumentError`. Pass nil for no timeout.
|
|
488
|
+
* Why: X sends a quiet stream a keep-alive every 20 seconds, so a shorter timeout would drop a live stream whenever one ran late.
|
|
489
|
+
|
|
490
|
+
Streams authenticate as the app, since the stream endpoints take app-only authentication:
|
|
491
|
+
|
|
492
|
+
* A client that signs with OAuth 1.0a fetches the app's bearer token with its API key and secret.
|
|
493
|
+
* A client that authenticates with OAuth 2.0 as a user streams with the app's `bearer_token`, or its `api_key` and `api_key_secret`, given beside the user's credentials.
|
|
494
|
+
* An OAuth 2.0 user client with neither streams as the user. X refuses it with 403, raising `X::Forbidden`.
|
|
495
|
+
* The same goes for reading and changing filtered-stream rules.
|
|
496
|
+
|
|
497
|
+
`require "x"` loads `x-streams` and gives every client `streaming`. Code that depends on `x-core` alone, and streamed with 0.19, adds `x-streams`:
|
|
498
|
+
|
|
499
|
+
* Include its methods into the client, as `x` does.
|
|
500
|
+
* Or build a streaming client of a client itself:
|
|
501
|
+
|
|
502
|
+
```ruby
|
|
503
|
+
require "x/core"
|
|
504
|
+
require "x/streams"
|
|
505
|
+
|
|
506
|
+
X::Client.include(X::Streams::API)
|
|
507
|
+
client.streaming.stream("tweets/search/stream") { |post| puts post }
|
|
508
|
+
|
|
509
|
+
# or, without changing X::Client
|
|
510
|
+
X::StreamingClient.new(client).stream("tweets/search/stream") { |post| puts post }
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
### Errors
|
|
514
|
+
|
|
515
|
+
Some statuses raise new or different errors. `rescue X::Error` still catches them all:
|
|
516
|
+
|
|
517
|
+
* New, each an `X::ClientError`: `X::PaymentRequired` (402), `X::MethodNotAllowed` (405), `X::RequestTimeout` (408), `X::UnsupportedMediaType` (415), and `X::UnavailableForLegalReasons` (451).
|
|
518
|
+
* A 4xx or 5xx status without a class of its own raises `X::ClientError` or `X::ServerError`, rather than `X::HTTPError`.
|
|
519
|
+
* `X::NetworkError` wraps every network failure:
|
|
520
|
+
* `IOError`, including `EOFError`.
|
|
521
|
+
* `SystemCallError`, including every `Errno` error.
|
|
522
|
+
* Net::HTTP's open, read, and write timeouts, `Net::ProtocolError`, `Net::HTTPBadResponse`, `Zlib::Error`, `OpenSSL::SSL::SSLError`, and `SocketError`.
|
|
523
|
+
|
|
524
|
+
Error messages name the request:
|
|
525
|
+
|
|
526
|
+
* Before: the API's message alone. After: `GET /2/users/1: Could not find user`.
|
|
527
|
+
* Code that matches a message against a String should allow for that prefix, or read `error.problem` instead.
|
|
528
|
+
* `X::TooManyRedirects` reads `GET /2/users/2: Too many redirects`, rather than `Too many redirects`.
|
|
529
|
+
* `error.http_method` and `error.uri` read the request on `X::HTTPError`, `X::NetworkError`, and `X::InvalidResponse`.
|
|
530
|
+
|
|
531
|
+
A successful response whose body is not JSON, such as a proxy's page, raises `X::InvalidResponse`:
|
|
532
|
+
|
|
533
|
+
* Before: it returned nil.
|
|
534
|
+
* `X::InvalidResponse` is an `X::HTTPError`.
|
|
535
|
+
* A successful response without a body still returns nil.
|
|
536
|
+
* `error.body` reads the body of the response when the error was built without one, once that response has been read whole.
|
|
537
|
+
|
|
538
|
+
A response body, which 0.19 read as `error.response.body`, is tagged UTF-8, where 0.19 left it binary:
|
|
539
|
+
|
|
540
|
+
* `error.body` and `error.http_response.body` can be joined with non-ASCII Strings without `Encoding::CompatibilityError`.
|
|
541
|
+
* Code that forced a body's encoding no longer needs to.
|
|
542
|
+
* A body that is not valid UTF-8 keeps its bytes. `valid_encoding?` tells it apart.
|
|
543
|
+
|
|
544
|
+
`X::TooManyRequests#reset_at`, `#reset_in`, and `#retry_after` return nil when the response does not say when the limit resets:
|
|
545
|
+
|
|
546
|
+
* Before: they returned `Time.at(0)` and 0, which retried at once.
|
|
547
|
+
* After: fall back to a wait of your own. X recommends a minute:
|
|
548
|
+
|
|
549
|
+
```ruby
|
|
550
|
+
# 0.19
|
|
551
|
+
sleep error.retry_after
|
|
552
|
+
|
|
553
|
+
# 1.0
|
|
554
|
+
sleep(error.retry_after || 60)
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
`retry_after` reads the `Retry-After` header:
|
|
558
|
+
|
|
559
|
+
* `X::HTTPError#retry_after` reads it from any refused response.
|
|
560
|
+
* `X::TooManyRequests#retry_after` reads it in place of the limit's reset time, which 0.19 read alone.
|
|
561
|
+
* It counts seconds from when the response was sent, so the wait is right however far the local clock is off.
|
|
562
|
+
* It answers for a refusal that reports no limit, such as a daily cap.
|
|
563
|
+
* `#reset_in` still reads the limit alone.
|
|
564
|
+
|
|
565
|
+
`X::RateLimit#retry_after` is gone:
|
|
566
|
+
|
|
567
|
+
* It was an alias for `#reset_in`, so one name would have meant two waits.
|
|
568
|
+
* Read a limit with `#reset_in`, and the wait a refusal asks for with `X::TooManyRequests#retry_after`:
|
|
569
|
+
|
|
570
|
+
```ruby
|
|
571
|
+
# 0.19
|
|
572
|
+
sleep error.rate_limit.retry_after
|
|
573
|
+
|
|
574
|
+
# 1.0
|
|
575
|
+
sleep(error.limiting_rate_limit&.reset_in || error.retry_after || 60)
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
Several readers of `X::HTTPError` are renamed or gone:
|
|
579
|
+
|
|
580
|
+
* `#error_message` and `#message_from_json_response` are gone. Read `message`.
|
|
581
|
+
* `#json?` is private.
|
|
582
|
+
* `#code` is gone. It read the status as the String `"404"`.
|
|
583
|
+
* `#status` reads it as the Integer `404`, as `X::Response#status` does.
|
|
584
|
+
* For the String, call `status.to_s` or read `error.http_response.code`.
|
|
585
|
+
* The `Net::HTTP` response is `http_response` on `X::HTTPError` and `X::InvalidResponse`, rather than `response`.
|
|
586
|
+
* `X::Response#http_response` reads the same object.
|
|
587
|
+
* It is an escape hatch: its class is the transport's, and not part of what 1.x promises.
|
|
588
|
+
* `X::RateLimit#response` is gone. Read `limit`, `remaining`, and `reset_at`, or the response of the error or `X::Response`.
|
|
589
|
+
* `#problem` is new, and returns the response's `X::Problem`, with `detail`, `title`, and `to_h`.
|
|
590
|
+
|
|
591
|
+
Building errors and limits changed:
|
|
592
|
+
|
|
593
|
+
* `X::HTTPError.new` no longer takes `response:`, so code that builds an error, such as a test double, raises `ArgumentError`.
|
|
594
|
+
* Pass `http_response:` with the `http_method:` and `uri:` of the request.
|
|
595
|
+
* Or pass `status:`, `headers:`, and `body:` without a response.
|
|
596
|
+
* Or raise it with a message alone.
|
|
597
|
+
* `X::RateLimit.new`, which took `type:` and `response:`, is private. Read the limits of a response or an error instead.
|
|
598
|
+
|
|
599
|
+
```ruby
|
|
600
|
+
# 0.19
|
|
601
|
+
X::NotFound.new(response: response)
|
|
602
|
+
|
|
603
|
+
# 1.0
|
|
604
|
+
X::NotFound.new(http_response: response, http_method: :get, uri: URI("https://api.x.com/2/users/1"))
|
|
605
|
+
X::NotFound.new(status: 404, headers: {"content-type" => "application/json"}, body: %({"title":"Not Found Error"}))
|
|
606
|
+
raise X::TooManyRequests, "Too Many Requests"
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
`X::TooManyRequests#rate_limits` and `#rate_limit` mean what they mean on `X::Response`:
|
|
610
|
+
|
|
611
|
+
* `#rate_limits` reports every limit the response names. 0.19 reported only the exhausted ones.
|
|
612
|
+
* `#rate_limit` is the 15-minute limit. 0.19 gave the exhausted limit that resets last.
|
|
613
|
+
* The exhausted limits are now `#exhausted_rate_limits`.
|
|
614
|
+
* The one a request waits for is `#limiting_rate_limit`.
|
|
615
|
+
* `reset_at` and `reset_in` read it, and so does `retry_after` without a `Retry-After` header, so code that sleeps for `retry_after` is unchanged.
|
|
616
|
+
|
|
617
|
+
```ruby
|
|
618
|
+
# 0.19
|
|
619
|
+
error.rate_limits # the exhausted limits
|
|
620
|
+
error.rate_limit # the exhausted limit that resets last
|
|
621
|
+
|
|
622
|
+
# 1.0
|
|
623
|
+
error.exhausted_rate_limits
|
|
624
|
+
error.limiting_rate_limit
|
|
625
|
+
```
|
|
626
|
+
|
|
627
|
+
### Version
|
|
628
|
+
|
|
629
|
+
`X::VERSION` is a String, rather than a `Gem::Version`:
|
|
630
|
+
|
|
631
|
+
* So are the `VERSION` constants of `X::Core`, `X::Uploads`, `X::Streams`, and `X::Resources`.
|
|
632
|
+
* Code that logs or sends a version reads the constant itself.
|
|
633
|
+
* Code that compares versions calls `gem_version`, which builds the `Gem::Version`:
|
|
634
|
+
|
|
635
|
+
```ruby
|
|
636
|
+
# 0.19
|
|
637
|
+
X::VERSION >= Gem::Version.new("0.19")
|
|
638
|
+
X::VERSION.segments.first
|
|
639
|
+
|
|
640
|
+
# 1.0
|
|
641
|
+
X.gem_version >= Gem::Version.new("1.0")
|
|
642
|
+
X.gem_version.segments.first
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
### Internals
|
|
646
|
+
|
|
647
|
+
Internal classes and modules are private constants under `X::Core` or `X::Streams`, so they can change within 1.x:
|
|
648
|
+
|
|
649
|
+
* `X::RequestBuilder`, `X::RedirectHandler`, `X::ResponseParser`, and `X::ClientCredentials` are under `X::Core`.
|
|
650
|
+
* `X::StreamParser` is `X::Streams::StreamParser`.
|
|
651
|
+
* `X::Connection` is `X::Core::Connection`.
|
|
652
|
+
* Build a client with the settings you gave a connection.
|
|
653
|
+
* Read timeout defaults from `X::Client::DEFAULT_OPEN_TIMEOUT`, `DEFAULT_READ_TIMEOUT`, `DEFAULT_WRITE_TIMEOUT`, and `DEFAULT_KEEP_ALIVE_TIMEOUT`.
|
|
654
|
+
* `X::OAuth2Authenticator` has no `connection`. A client refreshes its tokens over its own connection.
|
|
655
|
+
* Configure the internals through the settings of `X::Client` and `X::StreamingClient`, such as `max_redirects`.
|
|
656
|
+
* Read their defaults from `X::Client::DEFAULT_MAX_REDIRECTS`, `DEFAULT_MAX_RATE_LIMIT_RETRIES`, `DEFAULT_MAX_RATE_LIMIT_WAIT`, and `X::StreamingClient::DEFAULT_MAX_RECONNECTS`.
|
|
657
|
+
* `X::Client`, `X::BearerTokenAuthenticator`, `X::OAuth2Authenticator`, `X::RateLimit`, and the errors of 0.19 keep their names, except as the table above shows.
|
|
658
|
+
|
|
659
|
+
### Removed constants
|
|
660
|
+
|
|
661
|
+
* Gone, since [simple_oauth](https://github.com/laserlemon/simple_oauth) signs requests and builds token refreshes:
|
|
662
|
+
* `X::OAuthAuthenticator::OAUTH_VERSION`, `OAUTH_SIGNATURE_METHOD`, and `OAUTH_SIGNATURE_ALGORITHM`.
|
|
663
|
+
* `X::OAuth2Authenticator::REFRESH_GRANT_TYPE`.
|
|
664
|
+
* `X::OAuth2Authenticator::TOKEN_HOST` and `TOKEN_PATH` are gone. Tokens are requested at the origin of the client's `base_url`.
|
|
665
|
+
* `X::MediaUploader::MAX_RETRIES` is gone. A chunk is retried up to the client's `max_retries`.
|
|
666
|
+
* `X::AccountUploader::MIME_TYPE_MAP` and `SUPPORTED_EXTENSIONS` are gone. A profile image or banner is checked by its signature.
|
|
667
|
+
* `X::AccountUploader::V1_BASE_URL` is gone. The endpoints are private, relative to the client's `base_url`.
|
|
668
|
+
* `X::HTTPError::JSON_CONTENT_TYPE_REGEXP` and `X::OAuth2Authenticator::EXPIRATION_BUFFER` are private.
|
|
669
|
+
* `X::Connection::DEFAULT_HOST` and `DEFAULT_PORT` are gone, since every request names its host.
|
|
670
|
+
* The gems no longer depend on `base64`, which `x` 0.19 did. Add it to your own Gemfile if you use it.
|