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.
Files changed (74) hide show
  1. checksums.yaml +7 -0
  2. data/.yardopts +9 -0
  3. data/CHANGELOG.md +254 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +148 -0
  6. data/lib/x/resources/abstract_class.rb +29 -0
  7. data/lib/x/resources/actions/direct_messages.rb +99 -0
  8. data/lib/x/resources/actions/engagement.rb +95 -0
  9. data/lib/x/resources/actions/lists.rb +126 -0
  10. data/lib/x/resources/actions/posts.rb +77 -0
  11. data/lib/x/resources/actions/relationships.rb +91 -0
  12. data/lib/x/resources/actions.rb +22 -0
  13. data/lib/x/resources/api.rb +40 -0
  14. data/lib/x/resources/attributes.rb +203 -0
  15. data/lib/x/resources/batch.rb +43 -0
  16. data/lib/x/resources/batch_finders.rb +185 -0
  17. data/lib/x/resources/bookmark_folder.rb +23 -0
  18. data/lib/x/resources/community.rb +137 -0
  19. data/lib/x/resources/cursor.rb +493 -0
  20. data/lib/x/resources/direct_message.rb +325 -0
  21. data/lib/x/resources/direct_message_conversations.rb +147 -0
  22. data/lib/x/resources/errors.rb +104 -0
  23. data/lib/x/resources/finders.rb +255 -0
  24. data/lib/x/resources/identity.rb +65 -0
  25. data/lib/x/resources/includes.rb +216 -0
  26. data/lib/x/resources/list.rb +336 -0
  27. data/lib/x/resources/lookups/communities.rb +56 -0
  28. data/lib/x/resources/lookups/direct_messages.rb +86 -0
  29. data/lib/x/resources/lookups/lists.rb +44 -0
  30. data/lib/x/resources/lookups/media.rb +72 -0
  31. data/lib/x/resources/lookups/posts.rb +198 -0
  32. data/lib/x/resources/lookups/spaces.rb +87 -0
  33. data/lib/x/resources/lookups/trends.rb +38 -0
  34. data/lib/x/resources/lookups/users.rb +221 -0
  35. data/lib/x/resources/lookups.rb +25 -0
  36. data/lib/x/resources/marshalling.rb +93 -0
  37. data/lib/x/resources/matching_rule.rb +107 -0
  38. data/lib/x/resources/media.rb +278 -0
  39. data/lib/x/resources/media_ids.rb +74 -0
  40. data/lib/x/resources/memo.rb +54 -0
  41. data/lib/x/resources/page.rb +394 -0
  42. data/lib/x/resources/page_limit.rb +80 -0
  43. data/lib/x/resources/pages.rb +270 -0
  44. data/lib/x/resources/parallel.rb +82 -0
  45. data/lib/x/resources/personalized_trend.rb +124 -0
  46. data/lib/x/resources/place.rb +107 -0
  47. data/lib/x/resources/poll.rb +75 -0
  48. data/lib/x/resources/post.rb +615 -0
  49. data/lib/x/resources/post_collections.rb +67 -0
  50. data/lib/x/resources/post_counts.rb +215 -0
  51. data/lib/x/resources/post_search.rb +86 -0
  52. data/lib/x/resources/post_usage.rb +203 -0
  53. data/lib/x/resources/post_writes.rb +140 -0
  54. data/lib/x/resources/published_count.rb +31 -0
  55. data/lib/x/resources/references.rb +121 -0
  56. data/lib/x/resources/relation_writes.rb +54 -0
  57. data/lib/x/resources/relationships.rb +77 -0
  58. data/lib/x/resources/resource.rb +535 -0
  59. data/lib/x/resources/serialization.rb +58 -0
  60. data/lib/x/resources/shape.rb +167 -0
  61. data/lib/x/resources/space.rb +332 -0
  62. data/lib/x/resources/topic.rb +59 -0
  63. data/lib/x/resources/trend.rb +130 -0
  64. data/lib/x/resources/user.rb +502 -0
  65. data/lib/x/resources/user_collections.rb +213 -0
  66. data/lib/x/resources/user_finders.rb +282 -0
  67. data/lib/x/resources/utils.rb +358 -0
  68. data/lib/x/resources/value_equality.rb +38 -0
  69. data/lib/x/resources/value_marshalling.rb +89 -0
  70. data/lib/x/resources/version.rb +25 -0
  71. data/lib/x/resources.rb +22 -0
  72. data/sig/manifest.yaml +7 -0
  73. data/sig/x-resources.rbs +813 -0
  74. 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
@@ -0,0 +1,9 @@
1
+ --markup markdown
2
+ --readme README.md
3
+ --private
4
+ --hide-api private
5
+ --embed-mixins
6
+ lib/**/*.rb
7
+ -
8
+ CHANGELOG.md
9
+ LICENSE.txt
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 `&amp;`, `&lt;`, and `&gt;` 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 `&amp;`, `&lt;`, and `&gt;` 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 &amp; 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