x-resources 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 +7 -0
- data/.yardopts +9 -0
- data/CHANGELOG.md +254 -0
- data/LICENSE.txt +21 -0
- data/README.md +148 -0
- data/lib/x/resources/abstract_class.rb +29 -0
- data/lib/x/resources/actions/direct_messages.rb +99 -0
- data/lib/x/resources/actions/engagement.rb +95 -0
- data/lib/x/resources/actions/lists.rb +126 -0
- data/lib/x/resources/actions/posts.rb +77 -0
- data/lib/x/resources/actions/relationships.rb +91 -0
- data/lib/x/resources/actions.rb +22 -0
- data/lib/x/resources/api.rb +40 -0
- data/lib/x/resources/attributes.rb +203 -0
- data/lib/x/resources/batch.rb +43 -0
- data/lib/x/resources/batch_finders.rb +185 -0
- data/lib/x/resources/bookmark_folder.rb +23 -0
- data/lib/x/resources/community.rb +137 -0
- data/lib/x/resources/cursor.rb +493 -0
- data/lib/x/resources/direct_message.rb +325 -0
- data/lib/x/resources/direct_message_conversations.rb +147 -0
- data/lib/x/resources/errors.rb +104 -0
- data/lib/x/resources/finders.rb +255 -0
- data/lib/x/resources/identity.rb +65 -0
- data/lib/x/resources/includes.rb +216 -0
- data/lib/x/resources/list.rb +336 -0
- data/lib/x/resources/lookups/communities.rb +56 -0
- data/lib/x/resources/lookups/direct_messages.rb +86 -0
- data/lib/x/resources/lookups/lists.rb +44 -0
- data/lib/x/resources/lookups/media.rb +72 -0
- data/lib/x/resources/lookups/posts.rb +198 -0
- data/lib/x/resources/lookups/spaces.rb +87 -0
- data/lib/x/resources/lookups/trends.rb +38 -0
- data/lib/x/resources/lookups/users.rb +221 -0
- data/lib/x/resources/lookups.rb +25 -0
- data/lib/x/resources/marshalling.rb +93 -0
- data/lib/x/resources/matching_rule.rb +107 -0
- data/lib/x/resources/media.rb +278 -0
- data/lib/x/resources/media_ids.rb +74 -0
- data/lib/x/resources/memo.rb +54 -0
- data/lib/x/resources/page.rb +394 -0
- data/lib/x/resources/page_limit.rb +80 -0
- data/lib/x/resources/pages.rb +270 -0
- data/lib/x/resources/parallel.rb +82 -0
- data/lib/x/resources/personalized_trend.rb +124 -0
- data/lib/x/resources/place.rb +107 -0
- data/lib/x/resources/poll.rb +75 -0
- data/lib/x/resources/post.rb +615 -0
- data/lib/x/resources/post_collections.rb +67 -0
- data/lib/x/resources/post_counts.rb +215 -0
- data/lib/x/resources/post_search.rb +86 -0
- data/lib/x/resources/post_usage.rb +203 -0
- data/lib/x/resources/post_writes.rb +140 -0
- data/lib/x/resources/published_count.rb +31 -0
- data/lib/x/resources/references.rb +121 -0
- data/lib/x/resources/relation_writes.rb +54 -0
- data/lib/x/resources/relationships.rb +77 -0
- data/lib/x/resources/resource.rb +535 -0
- data/lib/x/resources/serialization.rb +58 -0
- data/lib/x/resources/shape.rb +167 -0
- data/lib/x/resources/space.rb +332 -0
- data/lib/x/resources/topic.rb +59 -0
- data/lib/x/resources/trend.rb +130 -0
- data/lib/x/resources/user.rb +502 -0
- data/lib/x/resources/user_collections.rb +213 -0
- data/lib/x/resources/user_finders.rb +282 -0
- data/lib/x/resources/utils.rb +358 -0
- data/lib/x/resources/value_equality.rb +38 -0
- data/lib/x/resources/value_marshalling.rb +89 -0
- data/lib/x/resources/version.rb +25 -0
- data/lib/x/resources.rb +22 -0
- data/sig/manifest.yaml +7 -0
- data/sig/x-resources.rbs +813 -0
- metadata +140 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: a6b57a66bc787c65784add424ad51dccbd7bf7de18adc0893615ba7083d9a933
|
|
4
|
+
data.tar.gz: 41ff085eb051581b32bac335293d266e56ea9f881253108e78290fce7ed38da8
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 54f1e117dea345b1847ef4180240a4a94083a339aa03b158164b549e544d7b100430c7a09526f43e69dbb121f6d0fdc1ae141953fac627125d7eb8714b099fd7
|
|
7
|
+
data.tar.gz: 7d4482a4ef3cbaa3edbdab3a0d03e70ac6b916ae144f1061e3768ce9bc4366e89cdec728dc3122f1879ef25713e0d766d0913ba7e6a75cf1c3c911e0e8195bcc
|
data/.yardopts
ADDED
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `x-resources` will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
`x-resources` is released in lockstep with the other gems of the [x-ruby](https://github.com/sferik/x-ruby) repository, at one version across `x-core`, `x-uploads`, `x-streams`, `x-resources`, and `x`. This file holds the changes to the object layer; [the changelog of the repository](https://github.com/sferik/x-ruby/blob/main/CHANGELOG.md) holds the changes to every gem.
|
|
9
|
+
|
|
10
|
+
## [1.0.0] - 2026-10-06
|
|
11
|
+
|
|
12
|
+
The first release of `x-resources`, which 1.0.0 split out of the `x` gem. `x` 0.19, the last release before the split, had no object layer, so every entry below is new; see [UPGRADING.md](https://github.com/sferik/x-ruby/blob/main/UPGRADING.md) for the changes that code written for 0.19 needs. It requires Ruby 3.4 or later.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
* Add `X::Resources.gem_version`, which returns `VERSION` as a `Gem::Version`
|
|
16
|
+
* Split `x` into gems released in lockstep: `x-core`, `x-uploads`, `x-streams`, `x-resources`, and the `x` meta-gem
|
|
17
|
+
* `x-core` is the HTTP client and declares `X::Error`, the base of every error the gems raise
|
|
18
|
+
* `x-resources` makes no request of its own; it asks the client it is given to make them
|
|
19
|
+
* Public classes are named directly under `X`, whichever gem declares them
|
|
20
|
+
* `x` depends on exactly its own version of the other four; `x-uploads`, `x-streams`, and `x-resources` each depend on `x-core` with `>= 1.0.0, < 2`
|
|
21
|
+
* Add `X::Resources::Error`, to rescue the failures of the object layer alone
|
|
22
|
+
* `X::MissingResource`, `X::UnreadableResponse`, `X::MissingClient`, and `X::PageLimitReached` descend from it
|
|
23
|
+
* `X::InvalidAttribute` descends from `X::UnreadableResponse`
|
|
24
|
+
* Add immutable, thread-safe resource classes that descend from `X::Resource`
|
|
25
|
+
* `X::User`, `X::Post` (aliased `X::Tweet`), `X::List`, `X::DirectMessage`, `X::Space`, `X::Media`, and `X::Poll`
|
|
26
|
+
* `X::Place`, and `X::Community`, found with `find_community(!)` and `search_communities`, and read as `post.community`
|
|
27
|
+
* Post in a community with the `community:` of `create_post`
|
|
28
|
+
* Lookups, cursors' `to_a`, and the collections a resource holds are frozen Arrays; copy one to change it
|
|
29
|
+
* A list the response omits, such as `post.urls`, reads as an empty Array; `user.connection_status` reads nil
|
|
30
|
+
* Text reads as the API sends it, so `post.text` and `direct_message.text` hold `&`, `<`, and `>` throughout 1.x
|
|
31
|
+
* Nested data, such as `post.entities` and `post.public_metrics`, reads as frozen Hashes keyed by String throughout 1.x
|
|
32
|
+
* Nested data the API sends as anything but an object, or a list of them, raises `X::InvalidAttribute`
|
|
33
|
+
* Compare resources by class and ID with `==`, `eql?`, and `hash`
|
|
34
|
+
* Resolve references such as `post.author` to included objects or ID stubs, sharing one object per resource
|
|
35
|
+
* Add `hydrate`, which fetches and memoizes the full resource, and `refresh`, which fetches it again
|
|
36
|
+
* A resource without a client, such as one read back by `Marshal`, raises `X::MissingClient` from any request
|
|
37
|
+
* A lookup whose parameters leave out default fields or expansions returns resources that are not hydrated
|
|
38
|
+
* `FIELDS` and `EXPANSIONS` may grow in a minor release; add to `default_params` rather than list every value
|
|
39
|
+
* Add `X::Cursor`, an `Enumerable` that fetches pages lazily, caches them, and offers `refresh` and `prefetch`
|
|
40
|
+
* A page a prefetch failed to fetch raises the prefetch's error once it is reached, rather than be requested again
|
|
41
|
+
* `first`, `take`, `any?`, `none?`, `one?`, and `empty?` answer from the pages already held, and keep what they read
|
|
42
|
+
* Without a block or pattern, `any?`, `none?`, and `empty?` request one resource and `one?` two, not a full page
|
|
43
|
+
* That is raised to the endpoint's minimum page size: 5 for a user's posts, mentions, and liked posts; 10 for post and community searches and quotes
|
|
44
|
+
* `first` and `take` request no larger a page than needed; a count that does not convert raises `TypeError`
|
|
45
|
+
* A page that names a token already read as its next raises `X::UnreadableResponse`, rather than page forever
|
|
46
|
+
* `page` takes an Integer index from zero; another type raises `TypeError`, a negative index `ArgumentError`
|
|
47
|
+
* Add `X::Cursor#each_page`, which yields each page as an `X::Page`
|
|
48
|
+
* A page holds `items`, `meta`, `result_count`, `next_token`, `previous_token`, and `problems`, and reads its items as an Array does
|
|
49
|
+
* `X::Page.new` takes an Array of resources and `meta:` and `problems:`; other problems raise `ArgumentError`
|
|
50
|
+
* Pages are equal when they hold the same resources, in order, meta, and problems
|
|
51
|
+
* Add `X::Cursor#published_count`, the number the API publishes for a collection, without paging it
|
|
52
|
+
* For a user's followers, followed users, and list memberships, and a list's members and followers
|
|
53
|
+
* It is nil for any other collection; `count` pages through the collection, as `Enumerable` does
|
|
54
|
+
* Add `X::Cursor#ids`, which requests identifiers alone, and `stubs`, which scans a collection as stubs
|
|
55
|
+
* Read the collections of a resource as cursors
|
|
56
|
+
* A user's `followers`, `following`, `affiliates`, `posts`, `mentions`, `liked_posts`, and `owned_lists`
|
|
57
|
+
* A user's `list_memberships`, `followed_lists`, `pinned_lists`, `home_timeline`, `blocking`, and `muting`
|
|
58
|
+
* A list's `members`, `followers`, and `posts`, and a post's `quotes`
|
|
59
|
+
* A space's `posts`, and its `buyers`, which the API reads for OAuth 2.0 user context alone
|
|
60
|
+
* Add `X::Post#reply?`, `quote?`, and `repost?`, and read the post referred to with `replied_to`, `quoted`, `reposted`
|
|
61
|
+
* Add `X::Post#liked_by`, `reposted_by`, and `reposts`, and `references`, the posts a post or direct message refers to
|
|
62
|
+
* Look users and posts up by ID in parallel batches of 100 with `X::User.find_all` and `X::Post.find_all`
|
|
63
|
+
* They return one resource per ID found, in the order asked, so an ID given twice comes back twice
|
|
64
|
+
* Once a batch fails, or the waiting thread is interrupted, no batch not yet begun is sent
|
|
65
|
+
* Look many resources up at once with `find_all_users`, `find_all_posts`, `find_all_spaces`, and `find_all_media`
|
|
66
|
+
* `find_all_users` takes a mix of IDs and usernames, batching each kind separately
|
|
67
|
+
* `find_all_users_by_username` takes usernames alone, even ones that are all digits
|
|
68
|
+
* Set how many batches a lookup requests at once with `concurrency:`, 4 by default
|
|
69
|
+
* Taken by `find_all`, `find_all_by_username`, `hydrate_all`, `find_all_by_creator`, and their client methods
|
|
70
|
+
* Anything but an Integer of at least 1 raises `ArgumentError`
|
|
71
|
+
* Hydrate many resources in parallel batches with `hydrate_all` on `X::User`, `X::Post`, `X::Space`, and `X::Media`
|
|
72
|
+
* It drops nil and resources not found, and looks up none that is already hydrated
|
|
73
|
+
* It stores what it finds in each resource given, unless the parameters leave out default fields or expansions
|
|
74
|
+
* A resource of another class raises `ArgumentError` before a request
|
|
75
|
+
* Hydrate the stubs of a page of `stubs`, or of a cursor that requests identifiers alone, together, in batch lookups of up to 100
|
|
76
|
+
* Lists, communities, and direct messages, which the API looks up one at a time, hydrate one at a time
|
|
77
|
+
* A reference a page did not include, such as the `author` of a post, hydrates one at a time; `hydrate_all` batches any
|
|
78
|
+
* Add object methods to `X::Client`, with `find_tweet` aliases for the finders
|
|
79
|
+
* `find_user`, `find_post`, `find_list`, `find_space`, `search_posts`, and `search_all_posts`
|
|
80
|
+
* `create_post`, `delete_post`, `direct_messages`, and `create_direct_message`
|
|
81
|
+
* `follow`, `unfollow`, `like`, `unlike`, `repost`, and `unrepost`
|
|
82
|
+
* `follow` returns true once it asks to follow a protected user; `repost` returns true once the user has reposted
|
|
83
|
+
* Add `block`, `unblock`, `mute`, `unmute`, `bookmark`, and `unbookmark` to `X::Client`
|
|
84
|
+
* Add `follow_list`, `unfollow_list`, `pin_list`, and `unpin_list` to `X::Client`
|
|
85
|
+
* Read the authenticated user's posts that others reposted with `X::Post.reposts_of_me` and `client.reposts_of_me`
|
|
86
|
+
* Both are aliased as `retweets_of_me`
|
|
87
|
+
* Read the authenticated user with `client.current_user` and `current_user!`, or `X::User.current` and `current!`
|
|
88
|
+
* `current_user` returns nil, yielding the problems the API reported; `current_user!` raises `X::MissingResource`
|
|
89
|
+
* `current_user_id` keeps the ID per credentials, even on a frozen `X::Client`; a frozen client without `memoize` looks it up each time
|
|
90
|
+
* With OAuth 1.0a, `current_user_id` reads the ID from the access token, without a request
|
|
91
|
+
* Look a user up by ID when given an Integer and by username when given a String
|
|
92
|
+
* Say which with `X::User.find_by_id(!)`, `find_all_by_id`, `find_by_username(!)`, and `find_all_by_username`
|
|
93
|
+
* On the client: `find_user_by_id(!)`, `find_all_users_by_id`, `find_user_by_username(!)`, `find_all_users_by_username`
|
|
94
|
+
* The `by_id` methods look a String of digits up as an ID, as read from a response or an environment variable
|
|
95
|
+
* Elsewhere, such as `follow`, `from_id`, or `find_post`, an ID that is not a number raises `ArgumentError`
|
|
96
|
+
* So does a resource of another class, as in `client.like(user)`, or another object that answers `id`
|
|
97
|
+
* Accept a username with a leading `@` in `find_user`, `find_all_users`, and `X::User.find_all_by_username`
|
|
98
|
+
* Validate a username or a non-numeric ID before building a path from it, raising `ArgumentError` without a request
|
|
99
|
+
* Such as `find_user("")`, `find_user("../tweets/20")`, `find_user("sferik?expansions=x")`, or `find_user("bad name")`
|
|
100
|
+
* Raise `X::MissingResource` from `current_user!` and every `find…!` method when a lookup finds nothing
|
|
101
|
+
* Its message names what was looked up, as in "Could not find X::User @sferik"
|
|
102
|
+
* It is not `X::NotFound`: a missing resource gives nil or `X::MissingResource` whether X answers 200 OK with no data or a 404 that reports the resource as not found
|
|
103
|
+
* The `X::NotFound` of such a 404 is its `cause`; any other 404, such as one from a client pointed at the wrong host or API version, or one to `current_user` or a `find_all…`, raises `X::NotFound`
|
|
104
|
+
* Data that holds no identifier counts as not found too, rather than raising `X::InvalidAttribute`
|
|
105
|
+
* Refer to a resource without a request with `X::User.from_id` and its equivalents, and tell stubs apart with `stub?`
|
|
106
|
+
* `X::Resource` itself keeps `new`, `from_id`, and `from_response` private
|
|
107
|
+
* Pass a resource class, such as `X::User`, as the `object_class` of any request to build objects from the response
|
|
108
|
+
* A list builds an `X::Page`, which holds the response's `meta` and problems, and is empty when there is no `data`
|
|
109
|
+
* Any `object_class` that responds to `from_response` is passed the parsed body and `client:`
|
|
110
|
+
* A `from_response` of your own must take unknown keywords with `**`, since a 1.x release may pass more
|
|
111
|
+
* Include the object methods in a class of your own with `X::Resources::API`
|
|
112
|
+
* The class is the client of each request, and answers `get`, `post`, `put`, and `delete` (`X::Resources::_Client`)
|
|
113
|
+
* Those methods must take unknown keywords, since a 1.x release may pass any keyword `X::Client` takes
|
|
114
|
+
* `X::Resources` names `API`, `Error`, and `VERSION` alone; the modules and helpers behind them are private
|
|
115
|
+
* Constants a resource class shares are private, so `X::Post::REPLIED_TO` raises `NameError`
|
|
116
|
+
* The signatures the gem ships declare its public interface alone
|
|
117
|
+
* Search users with `X::User.search` and `client.search_users`
|
|
118
|
+
* Search live and scheduled spaces with `X::Space.search` and `client.search_spaces`
|
|
119
|
+
* Request the largest page each search allows
|
|
120
|
+
* 500 posts from `search_all_posts`, or 100 when the request asks for context annotations, as the default fields do
|
|
121
|
+
* 1,000 users from `search_users`
|
|
122
|
+
* Check `list.member?` and `user.follows?` without fetching every page
|
|
123
|
+
* `member?` scans the smaller of a public list's members and the user's memberships; a private list scans members
|
|
124
|
+
* `follows?` looks up `X::User#connection_status` once when either user is the authenticated user
|
|
125
|
+
* A 401 or 403 to the lookup of the authenticated user falls back to a scan; any other failure, such as a rate limit, raises
|
|
126
|
+
* `max_pages:` limits the pages a scan reads, raising `X::PageLimitReached` if the API names another
|
|
127
|
+
* `max_pages:` defaults to nil, no limit; anything but an Integer of at least 1 or nil raises `ArgumentError`
|
|
128
|
+
* Count the posts that match a query with `X::Post.count`, `count_all`, `count_by_period`, and `count_all_by_period`
|
|
129
|
+
* On the client: `count_posts`, `count_all_posts`, `count_posts_by_period`, and `count_all_posts_by_period`
|
|
130
|
+
* Those have tweet-named aliases, and pass `max_pages:` through
|
|
131
|
+
* The by-period counts are in time order, keyed by the `Range` of `Time` each period spans
|
|
132
|
+
* A client that signs with OAuth 1.0a counts with a copy that authenticates as the app
|
|
133
|
+
* An OAuth 2.0 user client without app credentials counts as the user; the full archive refuses it with `X::Forbidden`
|
|
134
|
+
* Every count takes `max_pages:`, raising `X::PageLimitReached` past it, as `follows?` does
|
|
135
|
+
* A count by period that is not a String of digits or a non-negative Integer raises `X::InvalidAttribute`
|
|
136
|
+
* Report how many posts the app's project has read with `X::PostUsage.current` and `client.post_usage`
|
|
137
|
+
* It holds the monthly cap, the day it resets on, and usage by day and by app, each count an Integer
|
|
138
|
+
* They return nil, yielding the response's problems to a block, when it holds no usage
|
|
139
|
+
* `X::PostUsage.current!` and `client.post_usage!` raise `X::MissingResource` instead
|
|
140
|
+
* An OAuth 2.0 user client without app credentials requests as the user, which the API refuses with `X::Forbidden`
|
|
141
|
+
* Report the partial errors of a successful response as `X::Problem` objects, which `x-core` declares
|
|
142
|
+
* Read them with `problems` on a resource or a page, a block given to a finder, or `X::MissingResource#problems`
|
|
143
|
+
* A resource holds the problems about it, or a resource it refers to directly, and those that name no resource
|
|
144
|
+
* Look up a direct message with `find_direct_message`, and the conversation with a user with `direct_messages_with`
|
|
145
|
+
* Delete a direct message with `X::DirectMessage.delete`, `message.delete`, and `client.delete_direct_message`
|
|
146
|
+
* Add `X::DirectMessage#peer(user)`, the other participant of a one-to-one conversation as the user given sees it
|
|
147
|
+
* It is nil for a group conversation, as `group?` tells, and for a user not in the conversation
|
|
148
|
+
* Start a group conversation with `X::DirectMessage.create_group` and `client.create_group_direct_message`
|
|
149
|
+
* Send to and read any conversation with `X::DirectMessage.create_in` and `X::DirectMessage.in`
|
|
150
|
+
* On the client: `create_direct_message_in` and `direct_messages_in`
|
|
151
|
+
* Each takes a message of the conversation or its identifier
|
|
152
|
+
* Add `dm` aliases for the client methods named for direct messages
|
|
153
|
+
* `find_dm`, `find_dm!`, `dms`, `dms_with`, `create_dm`, `delete_dm`, `create_group_dm`, `create_dm_in`, and `dms_in`
|
|
154
|
+
* Attach uploaded media to a direct message with `media_ids:`, as `create_post` takes it
|
|
155
|
+
* Passing both `media_ids:` and `attachments:` raises `ArgumentError`
|
|
156
|
+
* An empty `media_ids:` attaches nothing to a post or a message, as nil does
|
|
157
|
+
* Post media without text, and send a direct message of attachments alone
|
|
158
|
+
* Without text, no `text` field is sent; a call with neither text nor any other field raises `ArgumentError`
|
|
159
|
+
* Build a new post's `reply` and `media` from the `reply_to:` and `media_ids:` of `create_post`
|
|
160
|
+
* Any other field of a `reply:` or `media:` passed beside them is kept
|
|
161
|
+
* A field passed with a String key, such as `"reply"` or `"attachments"`, is read as its Symbol, so it is sent once
|
|
162
|
+
* `media_ids:` takes a single value as well as an Array
|
|
163
|
+
* Accept what an upload returns, media, or a media key in the `media_ids:` of `create_post`
|
|
164
|
+
* An ID is sent only as 1 to 19 digits; anything else, such as a Hash without an `"id"`, raises `ArgumentError`
|
|
165
|
+
* Quote a post with the `quote:` of `create_post` and `X::Post.create`
|
|
166
|
+
* Raise `X::MissingResource`, holding the response's problems, when a request that creates a resource is answered without it
|
|
167
|
+
* From `X::Post.create`, `X::List.create`, `X::DirectMessage.create`, `create_group`, `create_in`, and their client methods
|
|
168
|
+
* So `create_post`, `create_list`, `create_dm`, and the rest never return nil
|
|
169
|
+
* A response whose data names no identifier raises it too
|
|
170
|
+
* Hide a reply to the authenticated user's post, and show it again, with `hide_reply` and `unhide_reply`
|
|
171
|
+
* On `X::Post` instances, on the `X::Post` class, and on the client
|
|
172
|
+
* Manage lists with `X::List.create`, `X::List.update`, `X::List.delete`, `list.update`, and `list.delete`
|
|
173
|
+
* Add and remove members with `list.add_member` and `list.remove_member`
|
|
174
|
+
* On the client: `create_list`, `update_list`, `delete_list`, `add_list_member`, and `remove_list_member`
|
|
175
|
+
* An update with no field to change raises `ArgumentError` before a request
|
|
176
|
+
* Add `X::User#bookmark_folders`, a cursor of `X::BookmarkFolder`, and read a folder's posts with `bookmarks(folder:)`
|
|
177
|
+
* Hydrating or refreshing a folder that is not hydrated raises `X::UnsupportedOperation`, since the API has no lookup
|
|
178
|
+
* Look up the spaces of many creators with `X::Space.find_all_by_creator` and `client.find_all_spaces_by_creator`
|
|
179
|
+
* They take users or their IDs, 100 at a time in parallel batches, and yield each problem the API reports
|
|
180
|
+
* Look up, search, and read the posts of spaces whatever the client authenticates with
|
|
181
|
+
* A client that signs with OAuth 1.0a makes those requests with its app-only client
|
|
182
|
+
* An OAuth 2.0 user client without app credentials makes them itself, and the spaces and posts returned act as that user; one that holds app credentials makes them with its app-only client
|
|
183
|
+
* Add `X::Space#topics`, each an `X::Topic` with a `name` and a `description`
|
|
184
|
+
* Look up media by media key with `X::Media.find`, `find!`, and `find_all`
|
|
185
|
+
* On the client: `find_media`, `find_media!`, and `find_all_media`
|
|
186
|
+
* They take a media key, media, or what an upload returned; the numeric media ID raises `ArgumentError`
|
|
187
|
+
* `client.find_media(uploaded)` reads what an upload became, with its URL and variants
|
|
188
|
+
* Add `X::Media#media_id`, the Integer its media key names, as `X::UploadedMedia#media_id` reads it
|
|
189
|
+
* `X::Media#id` is the media key, which the API looks media up by
|
|
190
|
+
* Read the trends of a place with `X::Trend.at` and `client.trends`, given its WOEID, such as 1 for the world
|
|
191
|
+
* A WOEID that is not a number raises `ArgumentError` before a request
|
|
192
|
+
* It returns up to 50 trends unless given `max_trends:`, each with a `name` and a `post_count`
|
|
193
|
+
* A client that signs with OAuth 1.0a, which the endpoint refuses, requests as the app
|
|
194
|
+
* Read the trends X picks for the authenticated user with `X::PersonalizedTrend.all` and `client.personalized_trends`
|
|
195
|
+
* Read `name`, `category`, and `post_count_text` and `trending_since_text`, the text X shows
|
|
196
|
+
* Neither trends endpoint has pages, so each returns a frozen Array; trends are equal when their attributes are
|
|
197
|
+
* Read the full text of a long post, over 280 characters, with `X::Post#text`, from its `note_post`
|
|
198
|
+
* `entities` and `urls` read the note's entities alone, so they lie where `text` holds them
|
|
199
|
+
* Add `X::Post#urls` and `expanded_text`, the text with each shortened link replaced by the URL it stands for, in one pass
|
|
200
|
+
* `expanded_text` HTML-escapes each URL it puts in, so the whole text reads escaped, as `text` does
|
|
201
|
+
* Add `X::Post#matching_rules`, the filtered stream rules a post matched, each an `X::MatchingRule`
|
|
202
|
+
* A rule reads the `id` the API gave it, as an Integer, and its `tag`; building one whose `id` is not digits raises `ArgumentError`, and reading one from a post raises `X::InvalidAttribute`
|
|
203
|
+
* A post that did not come from the filtered stream matched none
|
|
204
|
+
* Add `X::DirectMessage#from?`, `X::Post#coordinates`, and `permalink` and `uri`, the x.com address of a resource
|
|
205
|
+
* `permalink` and `uri` are on posts, users, lists, and communities
|
|
206
|
+
* Read more fields, which the lookups request
|
|
207
|
+
* `X::User#profile_banner_url`, `parody?`, `identity_verified?`, `subscription_type`, `verified_followers_count`
|
|
208
|
+
* `X::User#subscriber_count` and `media_count`
|
|
209
|
+
* `X::Post#display_text_range`, an exclusive `Range`, nil when absent, and `scopes`, `card_uri`, `article`, `article_title`, `media_metadata`, `paid_partnership?`
|
|
210
|
+
* `X::DirectMessage#entities`
|
|
211
|
+
* `X::User#affiliation`, `affiliated_with_ids`, and `affiliated_with`; a user included in another resource has none
|
|
212
|
+
* Fields only an author, an advertiser, or a program may read are not requested, since asking fails for others
|
|
213
|
+
* Add `X::User#receives_your_dm?`, `subscribes_to_you?`, and `subscription`; the predicates are false, and `subscription` nil, unless `user.fields` names them
|
|
214
|
+
* Add `X::Post#media_source_posts`, aliased `media_source_tweets`, the posts its attached media was first posted with
|
|
215
|
+
* Resolved from the `attachments.media_source_tweet` expansion, which lookups request
|
|
216
|
+
* Read the API's `is_` flags as `X::User#identity_verified` and `X::Space#ticketed`, beside their `?` predicates
|
|
217
|
+
* Match resources against `case/in` patterns by every attribute they declare, as in `post in {like_count: 100..}`
|
|
218
|
+
* Tweet-named aliases match too, as in `user in {pinned_tweet_id: Integer}`
|
|
219
|
+
* `X::Trend`, `X::PersonalizedTrend`, and `X::PostUsage` match by their readers, as in `trend in {post_count: 10..}`
|
|
220
|
+
* Write resources and value objects as JSON with `as_json` and `to_json`, never with the client's credentials
|
|
221
|
+
* On `X::Resource`, `X::Problem`, `X::Trend`, `X::PersonalizedTrend`, `X::PostUsage`, `X::MatchingRule`, and `X::Page`
|
|
222
|
+
* A page writes the shape of its response, which the `from_response` of its resource class reads back
|
|
223
|
+
* Marshal and YAML-dump them in a format every 1.x release reads
|
|
224
|
+
* An unknown format raises `X::UnsupportedFormat`, which `x-core` declares
|
|
225
|
+
* A resource keeps its attributes, the included objects it refers to, and its query, but not its client
|
|
226
|
+
* Raise from a cursor's `as_json`, `to_json`, and `to_h`, and from `Marshal.dump` and `YAML.dump` of one
|
|
227
|
+
* They raise `X::UnsupportedOperation` and `TypeError`, rather than read every page; serialize `first(n)` or `to_a`
|
|
228
|
+
* Name the interface after posts rather than tweets, as in `post_count`, `pinned_post_id`, and `repost_count`
|
|
229
|
+
* Tweet-named methods, such as `create_tweet`, `tweets`, `quote_tweets`, and `retweet_count`, remain as aliases
|
|
230
|
+
* A response that uses tweet names, as a stream does, is read where the post-named field is missing
|
|
231
|
+
* Request fields and expansions by the names the X API documentation gives, such as `post.fields` and `referenced_posts`
|
|
232
|
+
* The authors of referenced posts are not expanded, since the API reference names no expansion for them
|
|
233
|
+
* Leave out the `edit_history_post_ids` and `entities.mentions.username` expansions, whose includes nothing reads
|
|
234
|
+
* Read the IDs of users, posts, lists, direct messages, communities, and polls, and references to them, as Integers
|
|
235
|
+
* `X::Problem#resource_id` and `value` are Strings, so match a problem to a resource with `problem.about?(user)`
|
|
236
|
+
* IDs of spaces, places, and media, media keys, `dm_conversation_id`, and usernames are Strings
|
|
237
|
+
* A space or place ID is word characters alone; a `dm_conversation_id` that is not digits, or two numbers joined by a hyphen, raises `X::InvalidAttribute`
|
|
238
|
+
* Raise `X::UnsupportedOperation`, which `x-core` declares, for anything the API offers no way to do
|
|
239
|
+
* Such as hydrating or refreshing an `X::Poll` or an `X::Place` that is not hydrated
|
|
240
|
+
* A lookup the API lacks is not defined: `X::Poll` and `X::Place` answer no finder
|
|
241
|
+
* `X::List`, `X::Community`, and `X::DirectMessage` answer `find` and `find!`, but not `find_all` or `hydrate_all`
|
|
242
|
+
* Validate the attributes of a resource, a page, a trend, or the usage when it is built, raising `ArgumentError`
|
|
243
|
+
* As in `X::User.new({"id" => "abc"})`, `X::Trend.new(nil)`, or a page of items that are not resources
|
|
244
|
+
* Raise `X::InvalidAttribute` where a value of a response cannot be read as the API documents it
|
|
245
|
+
* Such as a timestamp that is not ISO 8601, a `public_metrics` that is a String, or a link whose `url` is not a String
|
|
246
|
+
* A count reads a String of digits as a number, and raises for a negative, signed, or fractional value
|
|
247
|
+
* A flag, such as `protected`, reads true, false, or nil, and raises for anything else
|
|
248
|
+
* Its cause is the `ArgumentError` that refused the value
|
|
249
|
+
* Type-check collections: `X::Cursor` and `X::Page` are generic in their signatures
|
|
250
|
+
* Ship a `sig/manifest.yaml` naming `uri` and `json`, so `rbs collection` loads them for code that depends on the gem
|
|
251
|
+
* Ship this changelog with the gem, linked from the `changelog_uri` of its gemspec
|
|
252
|
+
* Ship a `.yardopts` with the gem, so its documentation on rubydoc.info leaves out the private API
|
|
253
|
+
|
|
254
|
+
[1.0.0]: https://github.com/sferik/x-ruby/releases/tag/v1.0.0
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023 Erik Berlin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# x-resources
|
|
2
|
+
|
|
3
|
+
The object layer of the [`x` gem](https://github.com/sferik/x-ruby): immutable, thread-safe resource classes for the [X API](https://developer.x.com) with identity, references, hydration, cached pagination, and parallel batch lookups.
|
|
4
|
+
|
|
5
|
+
It makes no HTTP requests itself: it asks a client to make them. Its one runtime dependency is [`x-core`](https://github.com/sferik/x-ruby/tree/main/x-core), for `X::Error`, the base class of every error the X gems raise, `X::UnsupportedOperation`, which it raises for what the API offers no way to do, `X::UnsupportedFormat`, which it raises for what `Marshal` wrote in a format it does not read, and `X::Problem`, which describes the partial errors of a response.
|
|
6
|
+
|
|
7
|
+
Most applications should install [`x`](https://rubygems.org/gems/x), which wires this gem to the HTTP client from [`x-core`](https://github.com/sferik/x-ruby/tree/main/x-core).
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
`x-resources` requires Ruby 3.4 or later.
|
|
12
|
+
|
|
13
|
+
bundle add x-resources
|
|
14
|
+
|
|
15
|
+
## Resources
|
|
16
|
+
|
|
17
|
+
| Class | References | Collections | Class collections |
|
|
18
|
+
| --- | --- | --- | --- |
|
|
19
|
+
| `X::User` | `pinned_post`, `most_recent_post`, `affiliated_with` | `followers`, `following`, `affiliates`, `blocking`, `muting`, `posts`, `home_timeline`, `mentions`, `liked_posts`, `bookmarks`, `bookmark_folders`, `owned_lists`, `list_memberships`, `followed_lists`, `pinned_lists` | `search` |
|
|
20
|
+
| `X::Post` | `author`, `in_reply_to_user`, `community`, `replied_to`, `quoted`, `reposted`, `references`, `media`, `polls`, `place` | `liked_by`, `reposted_by`, `reposts`, `quotes` | `search`, `search_all`, `reposts_of_me` |
|
|
21
|
+
| `X::List` | `owner` | `members`, `followers`, `posts` | |
|
|
22
|
+
| `X::DirectMessage` | `sender`, `participants`, `references`, `media` | | `all`, `with`, `in` |
|
|
23
|
+
| `X::Space` | `creator`, `hosts`, `speakers`, `invited_users`, `topics` | `posts`, `buyers` | `search` |
|
|
24
|
+
| `X::Community` | | | `search` |
|
|
25
|
+
| `X::Media` | | | |
|
|
26
|
+
| `X::Poll`, `X::Place`, `X::Topic`, `X::BookmarkFolder` | | | |
|
|
27
|
+
|
|
28
|
+
Every class but `X::Poll`, `X::Place`, `X::Topic`, and `X::BookmarkFolder`, which the API has no lookup for, is looked up with `find`, as in `X::Media.find("3_1880028106020515840", client:)`, which looks media up by its media key. A collection is an `X::Cursor`, read from a resource, as in `user.followers`, and a class collection is one read from the class with a client, as in `X::Post.search("ruby", client:)`. `user.bookmarks(folder:)` reads the posts in one of the `bookmark_folders`, and `X::Space.find_all_by_creator` looks up the spaces of many users by the users who created them.
|
|
29
|
+
|
|
30
|
+
Trends are not resources, since they have no identifier: `X::Trend.at(1, client:)` reads the topics trending in a place, named by its Yahoo! Where On Earth identifier, and `X::PersonalizedTrend.all(client:)` the topics X picks for the authenticated user, each returning an Array. A post of the filtered stream reads the rules it matched with `matching_rules`, as `X::MatchingRule` values, and `X::Media#media_id` reads the numeric media ID that the media key of `X::Media#id` names.
|
|
31
|
+
|
|
32
|
+
`X::User.find` reads an Integer or a user as an identifier and a String as a username, so a handle of digits needs `X::User.find_by_username` (or `find_by_username!`, and `client.find_user_by_username`) to say which is meant, and an identifier read as a String, as from a response or an environment variable, needs `X::User.find_by_id` (or `find_by_id!`, and `client.find_user_by_id`).
|
|
33
|
+
|
|
34
|
+
A lookup of one resource that does not exist, or is deleted or suspended, returns nil, and its bang form, such as `X::User.find!` or `client.find_user!`, raises `X::MissingResource`, whether X answers 200 OK with no data or a 404 that reports the resource as not found. The `X::NotFound` of such a 404 is the `cause` of the error, and `hydrate` and `refresh` return nil for such a resource too. A client of your own must name the request its 404 answers: raise `X::NotFound` with the `http_method:` and the `uri:` (a `URI`, not a String) of the request, and with the response, either as `http_response:` or as `body:` plus `headers:` that hold its JSON `content-type`, as `X::Client` does. A 404 built without them raises `X::NotFound` from the finder, since a 404 is read as a missing resource only when it answers the lookup itself and reports the resource as not found. Any other 404, such as one from a client pointed at the wrong host or API version, or one to another request, such as `current_user`, a `find_all`, or a collection, raises `X::NotFound`.
|
|
35
|
+
|
|
36
|
+
The space endpoints refuse OAuth 1.0a, so `X::Space.find`, `find_all`, `find_all_by_creator`, `search`, and `space.posts` make their requests with the client's app-only client when it has one, which a client signed in with OAuth 2.0 as a user has when it holds the app's bearer token, or its API key and secret, and with the client itself when it has none, such as one signed in with OAuth 2.0 as a user that holds neither, which the space endpoints take. Bookmarking and unbookmarking a post, and `space.buyers`, take only OAuth 2.0 user context, which the gem cannot route around; reading bookmarks and bookmark folders takes OAuth 1.0a too.
|
|
37
|
+
|
|
38
|
+
## The client contract
|
|
39
|
+
|
|
40
|
+
Any object that responds to `get`, `post`, `put`, and `delete` can be the client. Each method takes a path relative to the API base URL and keyword options, and returns the parsed JSON body. The path carries the query, which the object layer builds, so a client is never passed a `params:` of its own. `post` and `put` also take the request body as an optional second argument: a Hash, which the client sends as JSON, as `X::Client` does, or a String the client sends as it is. The object layer always passes `array_class: Array, object_class: Hash`, so a client's own parsing defaults can't change what it receives. `X::Client` from `x-core` satisfies this contract, which the `X::Resources::_Client` interface in [`sig/x-resources.rbs`](https://github.com/sferik/x-ruby/blob/main/x-resources/sig/x-resources.rbs) states for a type checker.
|
|
41
|
+
|
|
42
|
+
`array_class:` and `object_class:` are the only keywords the object layer passes, but a release within 1.x may pass any other keyword `X::Client` takes for the same method, such as `params:` or `headers:`, so take the keywords with `**options`, as below, rather than name the two alone.
|
|
43
|
+
|
|
44
|
+
Five more methods are asked for where they save a request, and a client that answers none of them is asked for none:
|
|
45
|
+
|
|
46
|
+
| Method | What it is asked for | Without it |
|
|
47
|
+
| --- | --- | --- |
|
|
48
|
+
| `app_only` | a client that authenticates as the app, for the endpoints that refuse the OAuth 1.0a of a user, such as the spaces, counts, and usage endpoints | the request is made with the client itself, which the API refuses when it signs with OAuth 1.0a |
|
|
49
|
+
| `authenticator` | the `user_id` the authenticator names, since an OAuth 1.0a access token begins with the identifier of the user who authorized it | `current_user_id` looks the user up, which costs a request |
|
|
50
|
+
| `current_user_id` | the authenticated user of a check that either user may be, which `X::Resources::API` answers already | `user.follows?(other)` scans the users `user` follows rather than reading `connection_status` in one lookup |
|
|
51
|
+
| `memoized` | the value kept under a Symbol key, such as `:x_resources_current_user_id`, for the authenticator the client holds, or nil if none is kept | the value is read from the `@x_resources_current_user_id` instance variable of the client |
|
|
52
|
+
| `memoize` | to keep a value under a Symbol key, passed as `memoize(key, value)`, for the authenticator the client holds | the value is kept in the `@x_resources_current_user_id` instance variable of the client, or not at all when the client is frozen |
|
|
53
|
+
|
|
54
|
+
The object layer reads the errors of `x-core`, so a client raises them for a request that fails: `X::Unauthorized` or `X::Forbidden` for credentials the API refuses, which `follows?` reads as a client that knows no authenticated user, and scans instead, and `X::UnsupportedOperation` from an `app_only` that holds no credentials of the app, which the space endpoints, the trends of a place, and the count of recent posts read as a client that requests as itself, since they take OAuth 2.0 as a user too, and which the count of the full archive and the usage of the project read so too, though they take the app alone, so the API refuses them with 403 Forbidden, which raises `X::Forbidden`. Any other error ends the call it was raised in.
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
require "x/resources"
|
|
58
|
+
|
|
59
|
+
class MyClient
|
|
60
|
+
include X::Resources::API # adds find_user, find_user_by_username, find_user_by_id, find_all_users, current_user, current_user!, find_post, find_all_posts, search_posts, find_list, find_media, find_space, find_community, find_dm, follow, like, ...
|
|
61
|
+
|
|
62
|
+
def get(path, **options)
|
|
63
|
+
# Send the request and return the response body, parsed into options[:array_class] and options[:object_class]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def post(path, body = nil, **options)
|
|
67
|
+
# Send the body as JSON and return the response body, parsed as get parses it
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def put(path, body = nil, **options)
|
|
71
|
+
# Send the body as JSON and return the response body, parsed as get parses it
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def delete(path, **options)
|
|
75
|
+
# Send the request and return the response body, parsed as get parses it
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
You can also call the resource classes directly:
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
X::User.find("sferik", client:)
|
|
84
|
+
X::Post.find_all(ids, client:)
|
|
85
|
+
X::Post.search("ruby", client:)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Building objects from any response
|
|
89
|
+
|
|
90
|
+
`from_response` builds a resource from a parsed response, or an `X::Page` of them when its data is a list, which holds the `meta` of the response, such as its `next_token`, and the problems it reported. A lookup of several resources, such as `users?ids=`, that finds none of them builds an empty page of the problems it reported, as one that finds some builds a page of those. `X::Client` from `x-core` calls it when a resource class is the `object_class` of a request, passing the parsed body and itself:
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
user = client.get("users/by/username/sferik", object_class: X::User)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
An object built this way is not hydrated, because the request may have asked for only some fields, so `hydrate` fetches the full resource. The lookups, batch lookups, and cursors in this gem request every field, so what they return is already hydrated, unless they are given a parameter that overrides a default field or expansion parameter to leave some of its values out, such as `"user.fields": "name"`: what they return then is not hydrated either, so `hydrate` fetches the rest. A parameter that asks for every default value, in any order, and for more besides, such as `"post.fields": [*X::Post::FIELDS, "non_public_metrics"]`, still returns hydrated resources, which keep the fields it added.
|
|
97
|
+
|
|
98
|
+
The defaults are the `FIELDS` and `EXPANSIONS` of each class, and a minor release may add to them what the API adds, so that a lookup with the defaults asks for it too. A resource looked up with a list of your own, even one that named every field when it was written, then leaves out what was added, so it stops counting as hydrated, and `hydrate` costs a lookup it did not cost before, as does a resource written with `Marshal` or YAML by a release that asked for less. To ask for more than the defaults, add to what `default_params` gives, as the example above adds to `X::Post::FIELDS`, rather than list every value.
|
|
99
|
+
|
|
100
|
+
A resource reports the problems of its response that concern it with `problems`: those whose `resource_id` or `value` is its identifier, or that of a resource it refers to directly, such as the author of a post, and those that name no identifier. A page of a cursor reports every problem of its response, and a finder yields every one to its block. To tell which resource a problem is about, ask it with `X::Problem#about?`, as `quoted = post.quoted and post.problems.find { |problem| problem.about?(quoted) }` does, since `about?` takes a resource or an identifier, and not nil: it compares the identifiers as Strings, where `problem.resource_id == post.quoted.id` compares a String with an Integer and is always false.
|
|
101
|
+
|
|
102
|
+
## Text
|
|
103
|
+
|
|
104
|
+
A resource reads every String as the API sends it, and the API escapes `&`, `<`, and `>` as `&`, `<`, and `>` in the text of a post and of a direct message, so `X::Post#text`, `X::Post#expanded_text`, and `X::DirectMessage#text` hold them, and will throughout 1.x. Unescape the text to display it:
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
require "cgi/escape"
|
|
108
|
+
|
|
109
|
+
post.text # => "Ruby & Rails"
|
|
110
|
+
CGI.unescapeHTML(post.text) # => "Ruby & Rails"
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Nested data
|
|
114
|
+
|
|
115
|
+
A reader of an object the API nests in a resource, or of a list of them, returns it as the API sends it: a frozen Hash keyed by String, or an Array of them, as `Hash[String, untyped]` in the signatures. A response that holds anything else in its place, such as a String where the API documents an object, raises `X::InvalidAttribute` from the reader. Among them are the `entities`, `urls`, `public_metrics`, `edit_controls`, `attachments`, and `withheld` of a post, the `variants` of media, the `options` of a poll, and the `subscription` and `affiliation` of a user. Others return objects, as `post.matching_rules` returns `X::MatchingRule`s and `space.topics` returns `X::Topic`s.
|
|
116
|
+
|
|
117
|
+
```ruby
|
|
118
|
+
post.public_metrics["like_count"] # => 3, which post.like_count reads too
|
|
119
|
+
post.urls.map { |url| url["expanded_url"] }
|
|
120
|
+
media.variants.max_by { |variant| variant["bit_rate"].to_i }&.fetch("url")
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Each reader of nested data returns a frozen Hash keyed by String, or an Array of them, throughout 1.x. A release within 1.x may add a reader that returns an object for some of that data, but under a new name, never in place of one of these.
|
|
124
|
+
|
|
125
|
+
## How it works
|
|
126
|
+
|
|
127
|
+
* **Immutability.** Resources, pages, and cursors are frozen, and so are the arrays a lookup returns, those a cursor reads with `to_a`, `entries`, `first`, `take`, and `ids`, and the `items` of a page, which its `to_a` and `entries` return. Their attributes are deep-frozen copies of the response.
|
|
128
|
+
* **Identity.** `==`, `eql?`, and `hash` compare class and ID.
|
|
129
|
+
* **Identity map.** Each response gets one map. A reference resolves to the included object when the response expanded it, or to a stub otherwise, and every reference to the same resource in that response is the same object.
|
|
130
|
+
* **Hydration.** `hydrate` fetches the full resource once per object, under a lock, and memoizes it. `refresh` replaces the memoized value.
|
|
131
|
+
* **Cursors.** Pages are fetched lazily under a lock and cached, so concurrent iteration fetches each page once. A page that names the token of a page already fetched as its next raises `X::UnreadableResponse`, rather than page forever. `refresh` returns a cursor with an empty cache. `prefetch` returns a cursor that fetches the next page in a background thread; a page the thread fails to fetch raises the error it failed with when it is reached, rather than being requested again.
|
|
132
|
+
* **Parallelism.** `find_all` splits IDs into batches of 100 and fetches the batches on up to 4 threads, or the `concurrency:` it is given, preserving order: it returns a resource for each ID that was found, so an ID given twice comes back twice, though it is asked for once.
|
|
133
|
+
* **Reading part of a collection.** `first`, `take`, `any?`, `none?`, `empty?`, and `one?` request pages no larger than they need, and answer from the pages the cursor already holds. A cursor has no `size`, so `each_slice` and `lazy` do not page a whole collection to measure it; `count` reads every page and `published_count` reads the number the API publishes without reading any.
|
|
134
|
+
* **Serialization.** Resources, problems, trends, the usage, the rules a post matched, and pages answer `as_json` and `to_json` with their attributes, as plain data, so nothing carries a client or its credentials into a cache or a log. A page answers in the shape of the response it came from, its resources as the `data`, beside its `meta` and, when there are any, its problems as the `errors`, so the `from_response` of its resource class builds it again, though with stubs for the objects the response included. A cursor raises `X::UnsupportedOperation` from both, and `TypeError` from `Marshal.dump` and `YAML.dump`, since serializing it would read, and bill, every page of its collection; serialize `cursor.first(10)`, or `cursor.to_a` for all of it. `Marshal` writes each of the others as plain data led by the number of its format, so that every release of 1.x reads what another wrote, a later one adding only what an earlier one ignores, and raises `X::UnsupportedFormat` for a format it does not read, and each of them writes the same plain data as YAML, each part under its name, since YAML would otherwise write every instance variable, the client among them, and read back unfrozen what was frozen. A resource carries its attributes, whether it is hydrated, which it is read back as only if the query of its request asks for every field and expansion of the release that reads it, and of the response it came from, the included objects it refers to, and those they refer to in turn, the problems about any of them, and the query; a page carries its resources, metadata, and problems, and what the resources of one response refer to once, so a reference they shared is shared again; and each is frozen when it is read back. What it reads back resolves the references its response included, but has no client, so a `hydrate` that would make a request, a collection, or an action of it raises `X::MissingClient`, an `X::Resources::Error`.
|
|
135
|
+
|
|
136
|
+
## Development
|
|
137
|
+
|
|
138
|
+
This gem has its own `Gemfile`, `Steepfile`, signatures, test suite, and mutation config. It uses the `x-core` in this repository, whose errors and problems it raises and reports, and does not load `x-uploads`:
|
|
139
|
+
|
|
140
|
+
bundle install
|
|
141
|
+
bundle exec rake test
|
|
142
|
+
bundle exec rake mutant
|
|
143
|
+
bundle exec rake steep
|
|
144
|
+
bundle exec rake yardstick
|
|
145
|
+
|
|
146
|
+
## License
|
|
147
|
+
|
|
148
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module X
|
|
4
|
+
module Resources
|
|
5
|
+
# Makes new, from_id, and from_response, which Resource keeps private, public on each class of resource
|
|
6
|
+
#
|
|
7
|
+
# Resource is the class each resource descends from, and is not a resource itself: one built of it has no endpoint
|
|
8
|
+
# to hydrate from, and raises UnsupportedOperation for it, so it builds none.
|
|
9
|
+
#
|
|
10
|
+
# @api private
|
|
11
|
+
module AbstractClass
|
|
12
|
+
# The class methods that build a resource, which a class of resource makes public
|
|
13
|
+
BUILDERS = %i[new from_id from_response].freeze
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
# Make the builders public on each class that descends from this one
|
|
18
|
+
#
|
|
19
|
+
# @api private
|
|
20
|
+
# @param subclass [Class] the class that descends from it
|
|
21
|
+
# @return [void]
|
|
22
|
+
def inherited(subclass)
|
|
23
|
+
super
|
|
24
|
+
subclass.public_class_method(BUILDERS)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
private_constant :AbstractClass
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../direct_message"
|
|
4
|
+
|
|
5
|
+
module X
|
|
6
|
+
module Resources
|
|
7
|
+
module Actions
|
|
8
|
+
# Send and delete direct messages as the authenticated user
|
|
9
|
+
#
|
|
10
|
+
# Internal to x-resources: X::Resources::API includes it, and its methods are public API of the client that
|
|
11
|
+
# includes API, but the module is only how they are grouped, and some of them need the methods of another,
|
|
12
|
+
# so include API rather than this module alone.
|
|
13
|
+
#
|
|
14
|
+
# @api semipublic
|
|
15
|
+
module DirectMessages
|
|
16
|
+
# Send a direct message to a user as the authenticated user
|
|
17
|
+
#
|
|
18
|
+
# @api public
|
|
19
|
+
# @param user [User, String, Integer] the recipient or their identifier
|
|
20
|
+
# @param text [String, nil] the text of the message, or nil for a message of attachments alone
|
|
21
|
+
# @param params [Hash] additional request body fields, such as media_ids or attachments
|
|
22
|
+
# @option params [Array<String, Integer, #fetch, Media>, String, Integer, #fetch, Media] :media_ids the
|
|
23
|
+
# identifiers or media keys of uploaded media to attach, what the uploads returned, or media, such as that of
|
|
24
|
+
# a post, one or many
|
|
25
|
+
# @return [DirectMessage] the sent message, holding only its identifiers
|
|
26
|
+
# @raise [ArgumentError] if the message has neither text nor any other field, or has both media_ids and
|
|
27
|
+
# attachments
|
|
28
|
+
# @raise [MissingResource] if the API answers without the message
|
|
29
|
+
# @example Send a direct message
|
|
30
|
+
# client.create_direct_message(user, "Hello!")
|
|
31
|
+
# @example Send an image without text
|
|
32
|
+
# client.create_direct_message(user, media_ids: media)
|
|
33
|
+
def create_direct_message(user, text = nil, **params) # steep:ignore DifferentMethodParameterKind
|
|
34
|
+
DirectMessage.create(user, text, client: self, **params)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Start a group conversation, sending its first message as the authenticated user
|
|
38
|
+
#
|
|
39
|
+
# @api public
|
|
40
|
+
# @param users [Array<User, String, Integer>] the other participants or their identifiers
|
|
41
|
+
# @param text [String, nil] the text of the first message, or nil for a message of attachments alone
|
|
42
|
+
# @param params [Hash] additional fields of the message, such as media_ids or attachments
|
|
43
|
+
# @option params [Array<String, Integer, #fetch, Media>, String, Integer, #fetch, Media] :media_ids the
|
|
44
|
+
# identifiers or media keys of uploaded media to attach, what the uploads returned, or media, such as that of
|
|
45
|
+
# a post, one or many
|
|
46
|
+
# @return [DirectMessage] the sent message, holding only its identifiers, among them the conversation's
|
|
47
|
+
# @raise [ArgumentError] if the message has neither text nor any other field, or has both media_ids and
|
|
48
|
+
# attachments
|
|
49
|
+
# @raise [MissingResource] if the API answers without the message
|
|
50
|
+
# @example Start a group conversation
|
|
51
|
+
# client.create_group_direct_message([alice, bob], "Hello, both of you!")
|
|
52
|
+
# @example Start a group conversation with an image
|
|
53
|
+
# client.create_group_direct_message([alice, bob], media_ids: media)
|
|
54
|
+
def create_group_direct_message(users, text = nil, **params) # steep:ignore DifferentMethodParameterKind
|
|
55
|
+
DirectMessage.create_group(users, text, client: self, **params)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Send a direct message to a conversation as the authenticated user
|
|
59
|
+
#
|
|
60
|
+
# The conversation can be one-to-one or a group.
|
|
61
|
+
#
|
|
62
|
+
# @api public
|
|
63
|
+
# @param conversation [DirectMessage, String, Integer] a message of the conversation, or the conversation's identifier
|
|
64
|
+
# @param text [String, nil] the text of the message, or nil for a message of attachments alone
|
|
65
|
+
# @param params [Hash] additional request body fields, such as media_ids or attachments
|
|
66
|
+
# @option params [Array<String, Integer, #fetch, Media>, String, Integer, #fetch, Media] :media_ids the
|
|
67
|
+
# identifiers or media keys of uploaded media to attach, what the uploads returned, or media, such as that of
|
|
68
|
+
# a post, one or many
|
|
69
|
+
# @return [DirectMessage] the sent message, holding only its identifiers
|
|
70
|
+
# @raise [ArgumentError] if the conversation identifier is not one, the message has neither text nor any
|
|
71
|
+
# other field, or it has both media_ids and attachments
|
|
72
|
+
# @raise [MissingResource] if the API answers without the message
|
|
73
|
+
# @example Reply to the conversation of a message
|
|
74
|
+
# client.create_direct_message_in(message, "Sounds good")
|
|
75
|
+
# @example Reply with an image
|
|
76
|
+
# client.create_direct_message_in(message, media_ids: media)
|
|
77
|
+
def create_direct_message_in(conversation, text = nil, **params) # steep:ignore DifferentMethodParameterKind
|
|
78
|
+
DirectMessage.create_in(conversation, text, client: self, **params)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Delete a direct message event as the authenticated user
|
|
82
|
+
#
|
|
83
|
+
# @api public
|
|
84
|
+
# @param message [DirectMessage, String, Integer] the event or its identifier
|
|
85
|
+
# @return [Boolean] true if the event was deleted
|
|
86
|
+
# @example Delete a direct message
|
|
87
|
+
# client.delete_direct_message("1234567890")
|
|
88
|
+
def delete_direct_message(message)
|
|
89
|
+
DirectMessage.delete(message, client: self)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
alias_method :create_dm, :create_direct_message
|
|
93
|
+
alias_method :create_group_dm, :create_group_direct_message
|
|
94
|
+
alias_method :create_dm_in, :create_direct_message_in
|
|
95
|
+
alias_method :delete_dm, :delete_direct_message
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|