├── .github └── workflows │ └── check.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.toml ├── LICENSE ├── README.md ├── src ├── client │ ├── builder.rs │ ├── mod.rs │ ├── scopes.rs │ └── token.rs ├── error.rs ├── future │ ├── mod.rs │ ├── request_generator.rs │ ├── stage.rs │ ├── token.rs │ └── traits.rs ├── lib.rs ├── metrics.rs ├── model │ ├── beatmap.rs │ ├── comments.rs │ ├── event.rs │ ├── forum.rs │ ├── grade.rs │ ├── kudosu.rs │ ├── matches.rs │ ├── mod.rs │ ├── mods.rs │ ├── news.rs │ ├── ranking.rs │ ├── score.rs │ ├── seasonal_backgrounds.rs │ ├── serde_util.rs │ ├── user.rs │ └── wiki.rs ├── request │ ├── beatmap.rs │ ├── comments.rs │ ├── event.rs │ ├── forum.rs │ ├── matches.rs │ ├── mod.rs │ ├── news.rs │ ├── ranking.rs │ ├── replay.rs │ ├── score.rs │ ├── seasonal_backgrounds.rs │ ├── serialize.rs │ ├── user.rs │ └── wiki.rs └── routing.rs └── tests ├── requests.rs └── serde.rs /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: Checks 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - next 8 | - lazer 9 | pull_request: 10 | 11 | jobs: 12 | build-docs: 13 | name: Build docs 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Checkout sources 18 | uses: actions/checkout@v4 19 | 20 | - name: Cache dependencies 21 | uses: Swatinem/rust-cache@v2 22 | 23 | - name: Install nightly toolchain 24 | uses: dtolnay/rust-toolchain@nightly 25 | 26 | - name: Build docs 27 | env: 28 | RUSTDOCFLAGS: --cfg docsrs 29 | run: cargo doc --no-deps --all-features 30 | 31 | clippy: 32 | runs-on: ubuntu-latest 33 | 34 | strategy: 35 | matrix: 36 | include: 37 | - kind: default-features 38 | features: default 39 | - kind: full-features 40 | features: cache,macros,metrics,replay,serialize,local_oauth 41 | 42 | steps: 43 | - name: Checkout project 44 | uses: actions/checkout@v4 45 | 46 | - name: Install stable toolchain 47 | uses: dtolnay/rust-toolchain@stable 48 | with: 49 | components: clippy 50 | 51 | - name: Cache dependencies 52 | uses: Swatinem/rust-cache@v2 53 | 54 | - name: Run clippy 55 | env: 56 | RUSTFLAGS: -D warnings 57 | run: cargo clippy --features ${{ matrix.features }} --all-targets --no-deps 58 | 59 | rustfmt: 60 | name: Format 61 | runs-on: ubuntu-latest 62 | 63 | steps: 64 | - name: Checkout sources 65 | uses: actions/checkout@v4 66 | 67 | - name: Install nightly toolchain 68 | uses: dtolnay/rust-toolchain@stable 69 | with: 70 | components: rustfmt 71 | toolchain: nightly 72 | 73 | - name: Check code formatting 74 | run: cargo fmt -- --check 75 | 76 | feature-combinations: 77 | name: Feature combinations 78 | runs-on: ubuntu-latest 79 | 80 | steps: 81 | - name: Checkout project 82 | uses: actions/checkout@v4 83 | 84 | - name: Install stable toolchain 85 | uses: dtolnay/rust-toolchain@stable 86 | 87 | - name: Cache dependencies 88 | uses: Swatinem/rust-cache@v2 89 | 90 | - name: Install cargo-hack 91 | uses: taiki-e/install-action@cargo-hack 92 | 93 | - name: Check feature-combinations 94 | run: > 95 | cargo hack check 96 | --feature-powerset --no-dev-deps 97 | --optional-deps metrics 98 | --group-features default,cache,macros,local_oauth,deny_unknown_fields 99 | 100 | readme: 101 | name: Readme 102 | runs-on: ubuntu-latest 103 | 104 | steps: 105 | - name: Checkout project 106 | uses: actions/checkout@v4 107 | 108 | - name: Install stable toolchain 109 | uses: dtolnay/rust-toolchain@stable 110 | 111 | - name: Cache dependencies 112 | uses: Swatinem/rust-cache@v2 113 | 114 | - name: Check if README is up to date 115 | run: | 116 | cargo install cargo-rdme 117 | cargo rdme --check 118 | 119 | test: 120 | name: Tests 121 | runs-on: ubuntu-latest 122 | 123 | steps: 124 | - name: Checkout project 125 | uses: actions/checkout@v4 126 | 127 | - name: Install stable toolchain 128 | uses: dtolnay/rust-toolchain@stable 129 | 130 | - name: Cache dependencies 131 | uses: Swatinem/rust-cache@v2 132 | 133 | - name: Install nextest 134 | uses: taiki-e/install-action@nextest 135 | 136 | - name: Run tests with nextest 137 | run: > 138 | cargo nextest run 139 | --all-features 140 | --no-fail-fast 141 | --failure-output "immediate-final" 142 | --filter-expr 'not binary(requests)' 143 | 144 | - name: Run doctests 145 | run: cargo test --doc --all-features 146 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | .env 3 | Cargo.lock 4 | output.* 5 | expanded.* 6 | /tests/custom.rs 7 | /.vscode 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # v0.11.0 (2025-05-21) 2 | 3 | - __Breaking:__ 4 | - `Score.classic_score` is now a `u64` instead of `u32` 5 | - The type `rosu_v2::model::matches::Team` was renamed to `MatchTeam`, the variant `ParsingError::Team` was renamed to `MatchTeam`, and added the field `team: Option` to `User` and `UserExtended` ([#44]) 6 | - Adjusted fields in the `GameModeAttributes` enum ([#47]) 7 | - The method `Osu::user_beatmapsets` now takes an additional `UserBeatmapsetsKind` argument, `GetUserBeatmapsets::status` was replaced by `kind` and `GetUserBeatmapsets::loved`, `ranked`, `pending`, and `graveyard` were removed ([#52]) 8 | - The method `Osu::token` is no longer async ([#53]) 9 | - The endpoint structs (e.g. `GetUser`, `GetBeatmapScores`, ...) no longer implement `Future` and instead they implement `IntoFuture` by transforming into the struct `OsuFuture`. Also, methods on `Osu` that took `u32` user ids now take `Into`, the `body: String` fields of the `OsuError::Parsing` and `OsuError::Response` variants are replaced by `bytes: Bytes` and the variant `OsuError::ServiceUnavailable` now contains a `body: hyper::body::Incoming` field instead of an unnamed String ([#54]) 10 | - `Osu::beatmap_scores` now returns `BeatmapScores` instead of `Vec` ([#55]) 11 | 12 | - __Additions:__ 13 | - Added the method `Osu::team_rankings` ([#45]) 14 | 15 | - __Adjustments:__ 16 | - Use proper rfc3339 to (de)serialize datetimes ([#49]) 17 | 18 | ## v0.10.0 (2025-02-05) 19 | 20 | - __Breaking:__ 21 | - Added field `UserStatistics::rank_change_since_30_days` ([#30] - [@damaredayo]) 22 | - Added field `Score::total_score_without_mods` ([#31]) 23 | - Added field `Score::set_on_lazer` 24 | - Added field `UserExtended::daily_challenge_stats` ([#33]) 25 | - Changed field types from `f32` to `f64` for `BeatmapDifficultyAttributes::stars`, `GameModeAttributes::Osu::{aim_difficulty, flashlight_difficulty, slider_factor, speed_difficulty}`, `GameModeAttributes::Taiko::{stamina_difficulty, rhythm_difficulty, colour_difficulty, peak_difficulty}` ([#35]) 26 | - Added field `GameModeAttributes::Osu::speed_note_count` ([#35]) 27 | - Removed field `GameModeAttributes::Mania::score_multiplier` ([#35]) 28 | - `OsuBuilder::with{_local}_authorization` now takes an additional argument of type `Scopes` ([#37]) 29 | - The method `ScoreStatistics::accuracy` now takes an additional argument `max_statistics: &ScoreStatistics` 30 | - Changed field type from `u32` to `i32` for `BeatmapsetVote::score` 31 | - Bumped `rosu-mods` to `0.2.0` ([#40] - [@natsukagami]) 32 | - Bumped `hyper` to `1.0.0`, as well as a bump for `bytes`, `http-body-util`, `hyper-util`, and `hyper-rustls` ([#42]) 33 | 34 | - __Additions:__ 35 | - Added method `Osu::friends` to fetch the authorized user's friends list ([#38]) 36 | - Added methods `{Score, ScoreStatistics}::legacy_accuracy` as opposed to the types' `accuracy` methods 37 | - Added method `OsuBuilder::with_token` ([#41]) 38 | - Added method `Osu::scores` to fetch recently processed scores (passes) ([#43]) 39 | 40 | ## v0.9.0 (2024-07-10) 41 | 42 | - __Breaking:__ 43 | - All mods types are now re-exports from [`rosu-mods`](https://github.com/MaxOhn/rosu-mods) ([#28]) 44 | - `User`, `Beatmap`, and `Beatmapset` have been renamed to `UserExtended`, `BeatmapExtended`, and `BeatmapsetExtended`; 45 | `UserCompact`, `BeatmapCompact`, and `BeatmapsetCompact` have been renamed to `User`, `Beatmap`, and `Beatmapset` 46 | - The fields `FailTimes::fail` and `FailTimes::exit` are now of type `Option>` instead of `Option>` 47 | - Metrics are now recorded using the [`metrics`](https://github.com/metrics-rs/metrics/tree/main/metrics) crate instead of prometheus directly. 48 | That means they're no longer exposed through a method, but are recorded in the global metrics registry instead. 49 | You can install a global metrics registry e.g. through the [metrics-exporter-prometheus](https://github.com/metrics-rs/metrics/tree/main/metrics-exporter-prometheus) crate. 50 | - Most fields of (optional) `User(Extended)`, `Beatmap(Extended)`, and `Beatmapset(Extended)` are now wrapped in a `Box`. ([#11]) 51 | - The field `Medal::instructions` is now a `Option` instead of `String` ([#12] - [@natsukagami]) 52 | - Removed the method `GetBeatmapsetSearch::any_status` and instead the method `status` now takes an `Option`; `None` means "any". Also renamed the variant `BeatmapsetSearchSort::RankedDate` to `ApprovedDate` and added the variants `Creator`, `Nominations`, and `LastUpdate`. ([#18]) 53 | - Renamed the struct `RecentEvent` to `Event` and the method `Osu::recent_events` to `recent_activity`. Also added the method `Osu::events`. ([#19]) 54 | - Removed the `Cursor` type. The osu!api now uses encoded strings as cursor value. ([#20]) 55 | - The methods `Osu::{replay, replay_raw, score}` no longer take a `GameMode` as argument. Instead, their builders now have a `mode` method which allows setting a mode optionally. ([#24] - [@natsukagami]) 56 | - Removed the `rkyv` feature ([#27]) 57 | - Added fields: 58 | - `Beatmap::mapset_id` 59 | - `Score::classic_score` 60 | - `UserExtended::statistics_modes` 61 | - Removed the field `BeatmapsetNominations::required` and added `BeatmapsetNominations::{eligible_main_rulesets, required_meta}`. 62 | 63 | - __Fixes:__ 64 | - Fixed deserializing `FailTimes` for `Beatmap` and `BeatmapExtended` 65 | 66 | - __Additions:__ 67 | - The endpoint `Osu::users` is now usable without deprecation warning to retrieve up to 50 users at once. ([#16]) 68 | - Endpoints to retrieve scores now provide a `legacy_scores` method to request score data in its legacy format. ([#14]) 69 | - Endpoints to retrieve scores now provide a `legacy_only` method to only request non-lazer scores. ([#21]) 70 | - Added the feature `local_oauth` to add the method `OsuBuilder::with_local_authorization` to perform the whole OAuth process locally. ([#29]) 71 | 72 | ## v0.8.0 (2023-06-27) 73 | 74 | - __Breaking:__ 75 | - Added the field `map_id` to `Score` 76 | - Added the fields `description` and `permanent` to `AccountHistory` 77 | - Added the variant `TournamentBan` to `HistoryType` 78 | - Added the variant `TagsEdit` to `BeatmapsetEvent` 79 | - Types no longer implement `serde::Serialize` unless the `serialize` feature is specified ([#4]) 80 | - Replaced the method `GetBeatmapScores::score_type` with `GetBeatmapScores::global` and `GetBeatmapScores::country` 81 | 82 | - __Fixes:__ 83 | - Anticipate `null` when deserializing user's `default_group` 84 | 85 | - __Additions:__ 86 | - Added the method `GetBeatmapScores::limit` 87 | - The method `GetBeatmapScores::mods` no longer shows the deprecation notice 88 | 89 | ## v0.7.0 (2022-12-25) 90 | 91 | - __Adjustments:__ 92 | - Implemented `rkyv::{Archive, Serialize, Deserialize}` for `BeatmapsetSearchSort` 93 | 94 | - __Additions:__ 95 | - Added the method `GameMods::clock_rate` 96 | - Added `Ord` and `PartialOrd` implementation for `GameMode` 97 | - Added the method `Osu::replay_raw` to request the bytes of a replay. If the `replay` feature is enabled, the new method `Osu::replay` requests the replay and parses it into a [`osu_db::Replay`](https://docs.rs/osu-db/latest/osu_db/replay/struct.Replay.html). Note that both of these methods **require OAuth** through `OsuBuilder::with_authorization`. ([#2] - [@mezo]) 98 | 99 | - __Breaking:__ 100 | - Added the field `passed` to `Score` ([#3] - [@Jeglerjeg]) 101 | - Instead of introducing custom archived types, some types now archive into themselves. 102 | Impacted types are: `Grade`, `KudosuAction`, `CommentSort`, `HistoryType`, `Playstyle`, `ProfilePage`, `BeatmapDifficultyAttributes`, and `GameModeAttributes`. 103 | 104 | ## v0.6.2 (2022-10-28) 105 | 106 | - __Fixes:__ 107 | - Fixed deserialization of datetimes and made them mode robust against future changes 108 | 109 | - __Additions:__ 110 | - Added the field `highest_rank` to `User` and `UserCompact` 111 | 112 | ## v0.6.1 (2022-10-24) 113 | 114 | - __Fixes:__ 115 | - Fixed deserialization when requesting mapset id 3 116 | - Fixed deserialization of datetimes in comments 117 | 118 | - __Breaking changes:__ 119 | - The serialization of all `OffsetDateTime` was changed. They used to be serialized into the amount of unix timestamp nanoseconds which was an i128. Since those could not be serialized into a `serde_json::Value` without significant performance loss, all datetimes are now serialized into a string of the same format given by the osu!api. 120 | 121 | ## v0.5.0 (2022-10-08) 122 | 123 | - __Adjustments:__ 124 | - If the `cache` feature is enabled, the cache now fills proactively and also updates with respect to username changes 125 | - __Additions:__ 126 | - Added a metric for the amount of Username-UserId pairs that are currently cached 127 | - Added method `Osu::beatmap_difficulty_attributes` to retrieve the `BeatmapDifficultyAttributes` of a beatmap. 128 | - Added method `OsuBuilder::retries` to specify how often requests should be retried in case they timeout. Defaults to 2 i.e. 3 attempts in total. 129 | - Added method `OsuBuilder::ratelimit` to specify how many requests per seconds can be made. Value will be clamped between 1 and 20 and defaults to 15. 130 | - Added method `Osu::beatmapset_from_map_id` to retrieve a mapset using a map ID. ([#1] - [@Jeglerjeg]) 131 | - __Breaking changes:__ 132 | - Renamed the `GameMode` variants to make them more idiomatic 133 | - Replaced the `chrono` dependency with `time` so all datetime fields now come from the `time` crate. This includes fields being (de)serialized differently. 134 | - Now using the specific api version 20220705 which renamed a few fields but only one of those made it through to the interface: `Score::created_at` is now called `Score::ended_at` 135 | - The `Score::score_id` field is now of type `Option` instead of `u64` 136 | - `GameModeAttributes::Taiko` now has an additional field `peak_difficulty` and no longer has the field `ar` 137 | 138 | ## v0.4.0 139 | 140 | - __Breaking:__ 141 | - `MatchEvent::Create`'s `user_id` field is now of type `Option` (previously just `u32`) 142 | - `Score::replay` is now of type `Option` (previously just `bool`) 143 | - Added the field `guest_mapset_count` to `User` and `UserCompact` 144 | - Added the field `creator_id` to `Beatmap` and `BeatmapCompact` 145 | - The field `user_id` of `Comment` is now an `Option` instead of just `u32`. 146 | - The method `get_user` of `Comment` now returns `Option>` instead of `GetUser<'_>` 147 | 148 | - __Fixes:__ 149 | - Now deserializing `medal` recent events properly 150 | - Added deserialization for mods in form of objects 151 | 152 | ## v0.3.2 153 | 154 | - Fixed `Grade` calculation for taiko `Score`s 155 | - Added feature `rkyv` to provide `Archive`, `Deserialize`, and `Serialize` impls of rkyv for insanely fast (de)serialization 156 | - Bumped dashmap to v5.1.0 157 | - Added `Osu::beatmap_user_scores` to get scores for all mod combinations of a user on a map 158 | 159 | ## v0.3.1 160 | 161 | - Added method `Osu::beatmaps` to retrieve multiple maps at once (up to 50). 162 | - Added method `Osu::score` to retrieve a specific score. 163 | - Removed metrics for multiplayer endpoints. 164 | - Added `UserId` to the prelude module 165 | - Added `Clone`, `Eq`, `PartialEq`, and `Hash` impls for `UserId` 166 | - Improved compile time by removing `build.rs` file 167 | - Added method `GetUserScores::pinned` to retrieve the pinned scores of a user 168 | 169 | ## v0.3.0 170 | 171 | - Added a bunch of documentation 172 | - [Breaking] Adjusted some struct fields: 173 | - Added `Group::has_modes` 174 | - Added `WikiPage::available_locales` 175 | - Removed `User::skype` 176 | - Removed `User::is_restricted` and `UserCompact::is_restricted` 177 | - [Breaking] Removed `Osu` methods `multiplayer_score`, `multiplayer_scores`, and `multiplayer_user_highscore` 178 | - [Breaking] All fields representing a username are no longer `String` but `SmallString<[u8; 15]>` instead. 179 | Since usernames can't be longer than 15 characters, this type will prevent allocations. It's aliased as `Username`. 180 | Affected fields are: 181 | - `Beatmapset.creator_name` 182 | - `BeatmapsetCommentOwnerChange.new_username` 183 | - `BeatmapsetCompact.creator_name` 184 | - `Comment.legacy_name` 185 | - `KudosuAction::KudosuGiver.username` 186 | - `NewsPost.author` 187 | - `EventUser.username` 188 | - `EventUser.previous_username` 189 | - `User::username` 190 | - `User::previous_usernames` 191 | - `UserCompact.username` 192 | - `UserCompact.previous_usernames` 193 | - Added `float` method to `UserLevel` 194 | - [Breaking] All fields representing a country code are no longer `String` but `SmallString<[u8; 2]>` instead. 195 | Since country codes can't be longer than 2 characters, this type will prevent allocations. It's aliased as `CountryCode`. 196 | Affected fields are: 197 | - `CountryRanking.country_code` 198 | - `User.country_code` 199 | - `UserCompact.country_code` 200 | - `GetPerformanceRankings::country` 201 | 202 | ## v0.2.0 203 | 204 | - Dont only consider HD when calculating grade but also Flashlight and FadeIn 205 | - Implemented `Default` for `Language`, `Genre`, `ScoringType`, `TeamType`, and `Team` enums 206 | - Made checking for `Score` equivalency more lenient w.r.t. their timestamps 207 | - [Breaking] Removed deprecated `cover_url` field from `User`; use `cover.url` instead 208 | - [Breaking] `description` field of `Group` is now `Option` instead of `String` 209 | - [Breaking] Added new `BeatmapsetEvent` variant `OwnerChange` and declared `BeatmapsetEvent` as non-exhaustive 210 | - [Breaking] `OsuBuilder` no longer accepts a reqwest client since its now using a hyper client 211 | - [Breaking] Removed all endpoint-specific cursor structs and replaced them by a single struct `Cursor` 212 | - [Breaking] Adjusted / Renamed / Added some `OsuError` variants 213 | - [Breaking] `User` and `UserCompact` fields `ranked_and_approved_beatmapset_count`, `unranked_beatmapset_count`, `favourite_beatmapset_count`, `graveyard_beatmapset_count`, and `loved_beatmapset_count` where replaced with `ranked_mapset_count`, `pending_mapset_count`, `favourite_mapset_count`, `graveyard_mapset_count`, and `loved_mapset_count`, respectively 214 | - [Breaking] `GetUserBeatmapsets` methods `ranked_and_approved` and `unranked` were replaced with `ranked` and `pending`, respectively 215 | - [Breaking] Removed `GetUserBeatmapset::favourite` method 216 | 217 | ## v0.1.0 218 | 219 | - Initial release 220 | 221 | [@Jeglerjeg]: https://github.com/Jeglerjeg 222 | [@mezo]: https://github.com/mezodev0 223 | [@natsukagami]: https://github.com/natsukagami 224 | [@damaredayo]: https://github.com/damaredayo 225 | 226 | [#1]: https://github.com/MaxOhn/rosu-v2/pull/1 227 | [#2]: https://github.com/MaxOhn/rosu-v2/pull/2 228 | [#3]: https://github.com/MaxOhn/rosu-v2/pull/3 229 | [#4]: https://github.com/MaxOhn/rosu-v2/pull/4 230 | [#11]: https://github.com/MaxOhn/rosu-v2/pull/11 231 | [#12]: https://github.com/MaxOhn/rosu-v2/pull/12 232 | [#14]: https://github.com/MaxOhn/rosu-v2/pull/14 233 | [#16]: https://github.com/MaxOhn/rosu-v2/pull/16 234 | [#18]: https://github.com/MaxOhn/rosu-v2/pull/18 235 | [#19]: https://github.com/MaxOhn/rosu-v2/pull/19 236 | [#20]: https://github.com/MaxOhn/rosu-v2/pull/20 237 | [#21]: https://github.com/MaxOhn/rosu-v2/pull/21 238 | [#24]: https://github.com/MaxOhn/rosu-v2/pull/24 239 | [#27]: https://github.com/MaxOhn/rosu-v2/pull/27 240 | [#28]: https://github.com/MaxOhn/rosu-v2/pull/28 241 | [#29]: https://github.com/MaxOhn/rosu-v2/pull/29 242 | [#30]: https://github.com/MaxOhn/rosu-v2/pull/30 243 | [#31]: https://github.com/MaxOhn/rosu-v2/pull/31 244 | [#33]: https://github.com/MaxOhn/rosu-v2/pull/33 245 | [#35]: https://github.com/MaxOhn/rosu-v2/pull/35 246 | [#37]: https://github.com/MaxOhn/rosu-v2/pull/37 247 | [#38]: https://github.com/MaxOhn/rosu-v2/pull/38 248 | [#40]: https://github.com/MaxOhn/rosu-v2/pull/40 249 | [#41]: https://github.com/MaxOhn/rosu-v2/pull/41 250 | [#42]: https://github.com/MaxOhn/rosu-v2/pull/42 251 | [#43]: https://github.com/MaxOhn/rosu-v2/pull/43 252 | [#44]: https://github.com/MaxOhn/rosu-v2/pull/44 253 | [#45]: https://github.com/MaxOhn/rosu-v2/pull/45 254 | [#47]: https://github.com/MaxOhn/rosu-v2/pull/47 255 | [#49]: https://github.com/MaxOhn/rosu-v2/pull/49 256 | [#52]: https://github.com/MaxOhn/rosu-v2/pull/52 257 | [#53]: https://github.com/MaxOhn/rosu-v2/pull/53 258 | [#54]: https://github.com/MaxOhn/rosu-v2/pull/54 259 | [#55]: https://github.com/MaxOhn/rosu-v2/pull/55 -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rosu-v2" 3 | version = "0.11.0" 4 | description = "An osu! API v2 wrapper" 5 | readme = "README.md" 6 | keywords = ["osu", "api", "wrapper"] 7 | repository = "https://github.com/MaxOhn/rosu-v2" 8 | documentation = "https://docs.rs/rosu-v2/" 9 | authors = ["MaxOhn "] 10 | edition = "2021" 11 | license = "MIT" 12 | 13 | # --- Features --- 14 | 15 | [features] 16 | default = ["cache", "macros"] 17 | cache = ["dashmap"] 18 | macros = ["rosu-mods/macros"] 19 | replay = ["osu-db"] 20 | serialize = [] 21 | local_oauth = ["tokio/net"] 22 | deny_unknown_fields = [] 23 | 24 | # --- Dependencies --- 25 | 26 | [dependencies] 27 | bytes = { version = "1.10.0", default-features = false } 28 | futures = { version = "0.3", default-features = false } 29 | leaky-bucket = { version = "1.1.2" } 30 | http-body-util = { version = "0.1.2", default-features = false } 31 | hyper = { version = "1.6.0", default-features = false } 32 | hyper-util = { version = "0.1.10", default-features = false, features = ["client-legacy", "http1", "http2", "tokio"] } 33 | hyper-rustls = { version = "0.27.5", default-features = false, features = ["http1", "http2", "native-tokio", "ring"] } 34 | itoa = { version = "1.0.9" } 35 | pin-project = { version = "1.1.10" } 36 | rosu-mods = { version = "0.3.0", features = ["serde"] } 37 | serde = { version = "1.0.203", default-features = false, features = ["derive"] } 38 | serde_json = { version = "1.0", default-features = false, features = ["std", "raw_value"] } 39 | serde_urlencoded = { version = "0.7.1" } 40 | smallstr = { version = "0.3.0", features = ["serde"] } 41 | thiserror = { version = "2.0.11" } 42 | time = { version = "0.3", features = ["formatting", "parsing", "serde"] } 43 | tokio = { version = "1.0", default-features = false, features = ["macros"] } 44 | tracing = { version = "0.1.40", default-features = false } 45 | url = { version = "2.0", default-features = false } 46 | 47 | # --- Feature dependencies --- 48 | 49 | dashmap = { version = "6.0.1", default-features = false, optional = true } 50 | osu-db = { version = "0.3.0", optional = true } 51 | metrics = { version = "0.24.1", optional = true } 52 | 53 | # --- Dev dependencies --- 54 | 55 | [dev-dependencies] 56 | dotenvy = { version = "0.15" } 57 | eyre = { version = "0.6" } 58 | once_cell = { version = "1.7" } 59 | tokio = { version = "1.0", default-features = false, features = ["rt", "macros"] } 60 | tracing-subscriber = { version = "0.3.18", default-features = false, features = ["env-filter", "fmt", "smallvec"] } 61 | 62 | # --- Metadata --- 63 | 64 | [package.metadata.docs.rs] 65 | all-features = true 66 | rustdoc-args = ["--cfg", "docsrs"] 67 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Max 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![crates.io](https://img.shields.io/crates/v/rosu-v2.svg)](https://crates.io/crates/rosu-v2) [![docs](https://docs.rs/rosu-v2/badge.svg)](https://docs.rs/rosu-v2) 2 | 3 | # rosu-v2 4 | 5 | 6 | 7 | rosu-v2 is a wrapper for the [osu!api v2]. 8 | As such, it provides a bunch of additional endpoints and data over [`rosu`] which wraps the [osu!api v1]. 9 | 10 | Feel free to open an issue when things don't work as expected. 11 | 12 | The branch `rosu-v2/main` should mirror the last published version. Upcoming changes 13 | will generally be added to the `rosu-v2/lazer` branch. If you want to stay up-to-date 14 | and use the `lazer` branch, you can add this in your `Cargo.toml`: 15 | 16 | ```toml 17 | rosu-v2 = { git = "https://github.com/MaxOhn/rosu-v2", branch = "lazer" } 18 | ``` 19 | 20 | ### Authentication 21 | 22 | Unlike api v1, api v2 does not require an api key by users. Instead, it requires a client id and a client secret. 23 | 24 | To get those, you must register an application [here](https://osu.ppy.sh/home/account/edit#new-oauth-application). 25 | Unless you're interested in logging into the API through an osu! account, the callback URL here does not matter and can be left blank. 26 | 27 | If you went through the OAuth process for a user, you can provide the callback URL and received code 28 | when creating the client in order to make requests on behalf of the authenticated user. 29 | 30 | ### Endpoints 31 | 32 | The following endpoints are currently supported: 33 | 34 | - `beatmaps/lookup`: A specific beatmap including its beatmapset 35 | - `beatmaps`: Up to 50 beatmaps at once including their beatmapsets 36 | - `beatmaps/{map_id}/attributes`: The difficulty attributes of a beatmap 37 | - `beatmaps/{map_id}/scores`: The global score leaderboard for a beatmap 38 | - `beatmaps/{map_id}/scores/users/{user_id}[/all]`: Get (all) top score(s) of a user on a beatmap. Defaults to the play with the __max score__, not pp 39 | - `beatmapsets/{mapset_id}`: The beatmapset including all of its difficulty beatmaps 40 | - `beatmapsets/events`: Various events around a beatmapset such as status, genre, or language updates, kudosu transfers, or new issues 41 | - `beatmapsets/search`: Search for beatmapsets; the same search as on the osu! website 42 | - `comments`: Most recent comments and their replies up to two levels deep 43 | - `events`: Collection of events in order of creation time 44 | - `forums/topics/{topic_id}`: A forum topic and its posts 45 | - `friends`: List of authenticated user's friends 46 | - `matches`: List of currently open multiplayer lobbies 47 | - `matches/{match_id}`: More specific data about a specific multiplayer lobby including participating players and occured events 48 | - `me[/{mode}]`: Detailed info about the authenticated user [in the specified mode] (requires OAuth) 49 | - `news`: Recent news 50 | - `rankings/{mode}/{ranking_type}`: The global leaderboard of either performance points, ranked score, countries, or a spotlight 51 | - `users/{user_id}/{recent_activity}`: List of a user's recent events like achieved medals, ranks on a beatmaps, username changes, supporter status updates, beatmapset status updates, ... 52 | - `scores/{mode}/{score_id}`: A specific score including its beatmap, beatmapset, and user 53 | - `scores`: Up to 1000 most recently processed scores (passes) 54 | - `seasonal-backgrounds`: List of seasonal backgrounds i.e. their URL and artists 55 | - `spotlights`: List of overviews of all spotlights 56 | - `users/{user_id}[/{mode}]`: Detailed info about a user [in the specified mode] 57 | - `users/{user_id}/{beatmapsets/{map_type}`: List of beatmapsets either created, favourited, or most played by the user 58 | - `users/{user_id}/kudosu`: A user's recent kudosu transfers 59 | - `users/{user_id}/scores/{score_type}`: Either top, recent, pinned, or global #1 scores of a user 60 | - `users`: Up to 50 users at once including statistics for all modes 61 | - `wiki/{locale}[/{path}]`: The general wiki page or a specific topic if the path is specified 62 | 63 | The api itself provides a bunch more endpoints which are not yet implemented because they're either niche and/or missing any documentation. 64 | 65 | If you find an endpoint on the [api page](https://osu.ppy.sh/docs/index.html) that you want to use but is missing in rosu-v2, feel free to open an issue. 66 | 67 | ### Usage 68 | 69 | ```rust 70 | // For convenience sake, all types can be found in the prelude module 71 | use rosu_v2::prelude::*; 72 | 73 | #[tokio::main] 74 | async fn main() { 75 | // Initialize the client 76 | let client_id: u64 = 123; 77 | let client_secret = String::from("my_secret"); 78 | let osu = Osu::new(client_id, client_secret).await.unwrap(); 79 | 80 | // Get peppy's top 10-15 scores in osu!standard. 81 | // Note that the username here can only be used because of the `cache` feature. 82 | // If you are fine with just providing user ids, consider disabling this feature. 83 | let scores: Vec = osu.user_scores("peppy") 84 | .mode(GameMode::Osu) 85 | .best() // top scores; alternatively .recent(), .pinned(), or .firsts() 86 | .offset(10) 87 | .limit(5) 88 | .await 89 | .unwrap(); 90 | 91 | // Search non-nsfw loved mania maps matching the given query. 92 | // Note that the order of called methods doesn't matter for any endpoint. 93 | let search_result: BeatmapsetSearchResult = osu.beatmapset_search() 94 | .nsfw(false) 95 | .status(Some(RankStatus::Loved)) 96 | .mode(GameMode::Mania) 97 | .query("blue army stars>3") 98 | .await 99 | .unwrap(); 100 | 101 | // Get the french wiki page on the osu file format 102 | let wiki_page: WikiPage = osu.wiki("fr") 103 | .page("Client/File_formats/osu_%28file_format%29") 104 | .await 105 | .unwrap(); 106 | } 107 | ``` 108 | 109 | ### Features 110 | 111 | | Flag | Description | Dependencies 112 | | ------------- | ---------------------------------------- | ------------ 113 | | `default` | Enable the `cache` and `macros` features | 114 | | `cache` | Cache username-userid pairs so that fetching data by username does one instead of two requests | [`dashmap`] 115 | | `macros` | Re-exports `rosu-mods`'s `mods!` macro to easily create mods for a given mode | [`paste`] 116 | | `serialize` | Implement `serde::Serialize` for most types, allowing for manual serialization | 117 | | `metrics` | Uses the global metrics registry to store response time for each endpoint | [`metrics`] 118 | | `replay` | Enables the method `Osu::replay` to parse a replay. Note that `Osu::replay_raw` is available without this feature but provides raw bytes instead of a parsed replay | [`osu-db`] 119 | | `local_oauth` | Enables the method `OsuBuilder::with_local_authorization` to perform the full OAuth procedure | `tokio/net` feature 120 | 121 | [osu!api v2]: https://osu.ppy.sh/docs/index.html 122 | [`rosu`]: https://github.com/MaxOhn/rosu 123 | [osu!api v1]: https://github.com/ppy/osu-api/wiki 124 | [`dashmap`]: https://docs.rs/dashmap 125 | [`paste`]: https://docs.rs/paste 126 | [`metrics`]: https://docs.rs/metrics 127 | [`osu-db`]: https://docs.rs/osu-db 128 | 129 | 130 | -------------------------------------------------------------------------------- /src/client/builder.rs: -------------------------------------------------------------------------------- 1 | use super::{ 2 | token::{AuthorizationBuilder, CurrentToken}, 3 | Authorization, AuthorizationKind, Osu, OsuInner, Scopes, Token, 4 | }; 5 | use crate::{error::OsuError, OsuResult}; 6 | 7 | use hyper_rustls::HttpsConnectorBuilder; 8 | use hyper_util::{client::legacy::connect::HttpConnector, rt::TokioExecutor}; 9 | use leaky_bucket::RateLimiter; 10 | use std::{sync::Arc, time::Duration}; 11 | use tokio::sync::oneshot; 12 | 13 | /// Builder struct for an [`Osu`](crate::Osu) client. 14 | /// 15 | /// `client_id` as well as `client_secret` **must** be specified before building. 16 | /// 17 | /// For more info, check out 18 | #[must_use] 19 | pub struct OsuBuilder { 20 | auth: Option, 21 | client_id: Option, 22 | client_secret: Option, 23 | retries: u8, 24 | timeout: Duration, 25 | per_second: u32, 26 | } 27 | 28 | impl Default for OsuBuilder { 29 | fn default() -> Self { 30 | Self { 31 | auth: None, 32 | client_id: None, 33 | client_secret: None, 34 | retries: 2, 35 | timeout: Duration::from_secs(10), 36 | per_second: 15, 37 | } 38 | } 39 | } 40 | 41 | impl OsuBuilder { 42 | /// Create a new [`OsuBuilder`](crate::OsuBuilder) 43 | pub fn new() -> Self { 44 | Self::default() 45 | } 46 | 47 | /// Build an [`Osu`] client. 48 | /// 49 | /// To build the client, the client id and secret are being used 50 | /// to acquire a token from the API which expires after a certain time. 51 | /// The client will from then on update the token regularly on its own. 52 | /// 53 | /// # Errors 54 | /// 55 | /// Returns an error if 56 | /// - client id was not set 57 | /// - client secret was not set 58 | /// - API did not provide a token for the given client id and client secret 59 | /// - native roots are missing to build the https connector 60 | pub async fn build(self) -> OsuResult { 61 | let client_id = self.client_id.ok_or(OsuError::BuilderMissingId)?; 62 | let client_secret = self.client_secret.ok_or(OsuError::BuilderMissingSecret)?; 63 | 64 | let mut http = HttpConnector::new(); 65 | http.enforce_http(false); 66 | 67 | let connector = HttpsConnectorBuilder::new() 68 | .with_native_roots() 69 | .map_err(|source| OsuError::ConnectorRoots { source })? 70 | .https_or_http() 71 | .enable_http1() 72 | .enable_http2() 73 | .wrap_connector(http); 74 | 75 | let http = 76 | hyper_util::client::legacy::Client::builder(TokioExecutor::new()).build(connector); 77 | 78 | let ratelimiter = RateLimiter::builder() 79 | .max(self.per_second as usize) 80 | .initial(self.per_second as usize) 81 | .interval(Duration::from_millis(1000 / u64::from(self.per_second))) 82 | .refill(1) 83 | .build(); 84 | 85 | let inner = Arc::new(OsuInner { 86 | client_id, 87 | client_secret: client_secret.into_boxed_str(), 88 | http, 89 | ratelimiter: Arc::new(ratelimiter), 90 | timeout: self.timeout, 91 | token: CurrentToken::new(), 92 | retries: self.retries, 93 | #[cfg(feature = "cache")] 94 | cache: dashmap::DashMap::new(), 95 | }); 96 | 97 | #[cfg(feature = "metrics")] 98 | crate::metrics::init_metrics(); 99 | 100 | match self.auth { 101 | Some(AuthorizationBuilder::Kind(kind)) => build_with_refresh(inner, kind).await, 102 | #[cfg(feature = "local_oauth")] 103 | Some(AuthorizationBuilder::LocalOauth { 104 | redirect_uri, 105 | scopes, 106 | }) => { 107 | let auth_kind = 108 | AuthorizationBuilder::perform_local_oauth(redirect_uri, client_id, scopes) 109 | .await 110 | .map(AuthorizationKind::User)?; 111 | 112 | build_with_refresh(inner, auth_kind).await 113 | } 114 | 115 | Some(AuthorizationBuilder::Given { 116 | token, 117 | expires_in: Some(expires_in), 118 | }) => { 119 | let (tx, dropped_rx) = oneshot::channel(); 120 | 121 | inner.token.set(token); 122 | let auth_kind = AuthorizationKind::BareToken; 123 | 124 | // Let an async worker update the token regularly 125 | CurrentToken::update_worker(Arc::clone(&inner), auth_kind, expires_in, dropped_rx); 126 | 127 | Ok(Osu { 128 | inner, 129 | token_loop_tx: Some(tx), 130 | }) 131 | } 132 | Some(AuthorizationBuilder::Given { token, .. }) => { 133 | inner.token.set(token); 134 | 135 | Ok(Osu { 136 | inner, 137 | token_loop_tx: None, 138 | }) 139 | } 140 | None => build_with_refresh(inner, AuthorizationKind::default()).await, 141 | } 142 | } 143 | 144 | /// Set the client id of the application. 145 | /// 146 | /// For more info, check out 147 | pub const fn client_id(mut self, client_id: u64) -> Self { 148 | self.client_id = Some(client_id); 149 | 150 | self 151 | } 152 | 153 | /// Set the client secret of the application. 154 | /// 155 | /// For more info, check out 156 | pub fn client_secret(mut self, client_secret: impl Into) -> Self { 157 | self.client_secret = Some(client_secret.into()); 158 | 159 | self 160 | } 161 | 162 | /// Upon calling [`build`], `rosu-v2` will print a url to authorize a local 163 | /// osu! profile. 164 | /// 165 | /// Be sure that the specified client id matches the OAuth application's 166 | /// redirect uri. 167 | /// 168 | /// If the authorization code has already been acquired, use 169 | /// [`with_authorization`] instead. 170 | /// 171 | /// For more info, check out 172 | /// 173 | /// 174 | /// [`build`]: OsuBuilder::build 175 | /// [`with_authorization`]: OsuBuilder::with_authorization 176 | #[cfg(feature = "local_oauth")] 177 | #[cfg_attr(docsrs, doc(cfg(feature = "local_oauth")))] 178 | pub fn with_local_authorization( 179 | mut self, 180 | redirect_uri: impl Into, 181 | scopes: Scopes, 182 | ) -> Self { 183 | self.auth = Some(AuthorizationBuilder::LocalOauth { 184 | redirect_uri: redirect_uri.into(), 185 | scopes, 186 | }); 187 | 188 | self 189 | } 190 | 191 | /// After acquiring the authorization code from a user through OAuth, 192 | /// use this method to provide the given code, and specified redirect uri. 193 | /// 194 | /// To perform the full OAuth procedure for a local osu! profile, enable the 195 | /// `local_oauth` feature and use `OsuBuilder::with_local_authorization` 196 | /// instead. 197 | /// 198 | /// For more info, check out 199 | /// 200 | pub fn with_authorization( 201 | mut self, 202 | code: impl Into, 203 | redirect_uri: impl Into, 204 | scopes: Scopes, 205 | ) -> Self { 206 | let authorization = Authorization { 207 | code: code.into().into_boxed_str(), 208 | redirect_uri: redirect_uri.into().into_boxed_str(), 209 | scopes, 210 | }; 211 | 212 | self.auth = Some(AuthorizationBuilder::Kind(AuthorizationKind::User( 213 | authorization, 214 | ))); 215 | 216 | self 217 | } 218 | 219 | /// Instead of acquiring a token upon building the client, use the given 220 | /// token. 221 | /// 222 | /// If `Token::refresh` and `expires_in` are `Some`, the token will be 223 | /// refreshed automatically. 224 | /// 225 | /// For more info, check out 226 | /// 227 | pub fn with_token(mut self, token: Token, expires_in: Option) -> Self { 228 | self.auth = Some(AuthorizationBuilder::Given { token, expires_in }); 229 | 230 | self 231 | } 232 | 233 | /// In case the request times out, retry up to this many times, defaults to 2. 234 | pub const fn retries(mut self, retries: u8) -> Self { 235 | self.retries = retries; 236 | 237 | self 238 | } 239 | 240 | /// Set the timeout for requests, defaults to 10 seconds. 241 | pub const fn timeout(mut self, duration: Duration) -> Self { 242 | self.timeout = duration; 243 | 244 | self 245 | } 246 | 247 | /// Set the amount of requests that can be made in one second, defaults to 15. 248 | /// The given value will be clamped between 1 and 20. 249 | /// 250 | /// Check out the osu!api's [terms of use] for acceptable values. 251 | /// 252 | /// [terms of use]: https://osu.ppy.sh/docs/index.html#terms-of-use 253 | pub fn ratelimit(mut self, reqs_per_sec: u32) -> Self { 254 | self.per_second = reqs_per_sec.clamp(1, 20); 255 | 256 | self 257 | } 258 | } 259 | 260 | async fn build_with_refresh(inner: Arc, auth_kind: AuthorizationKind) -> OsuResult { 261 | let (tx, dropped_rx) = oneshot::channel(); 262 | 263 | // Acquire the initial API token 264 | let token = auth_kind 265 | .request_token(Arc::clone(&inner)) 266 | .await 267 | .map_err(Box::new) 268 | .map_err(|source| OsuError::UpdateToken { source })?; 269 | 270 | let expires_in = token.expires_in; 271 | inner.token.update(token); 272 | 273 | // Let an async worker update the token regularly 274 | CurrentToken::update_worker(Arc::clone(&inner), auth_kind, expires_in, dropped_rx); 275 | 276 | Ok(Osu { 277 | inner, 278 | token_loop_tx: Some(tx), 279 | }) 280 | } 281 | -------------------------------------------------------------------------------- /src/client/scopes.rs: -------------------------------------------------------------------------------- 1 | use std::ops::{BitOr, BitOrAssign}; 2 | 3 | /// Scopes bitflags for an [`Osu`] client. 4 | /// 5 | /// To specify multiple scopes, create a union using the `|` operator. 6 | /// 7 | /// See . 8 | /// 9 | /// [`Osu`]: crate::Osu 10 | #[derive(Copy, Clone, PartialEq, Eq)] 11 | pub struct Scopes(u16); 12 | 13 | macro_rules! define_scopes { 14 | ( $( 15 | #[doc = $doc:literal] 16 | $scope:ident: $shift:literal, $str:literal; 17 | )* ) => { 18 | define_scopes! {@ $( 19 | #[doc = $doc] 20 | $scope: 1 << $shift, $str; 21 | )* } 22 | }; 23 | (@ $( 24 | #[doc = $doc:literal] 25 | $scope:ident: $bit:expr, $str:literal; 26 | )* ) => { 27 | $( 28 | #[allow(non_upper_case_globals)] 29 | impl Scopes { 30 | #[doc = $doc] 31 | pub const $scope: Self = Self($bit); 32 | } 33 | )* 34 | 35 | impl Scopes { 36 | const fn contains(self, bit: u16) -> bool { 37 | (self.0 & bit) > 0 38 | } 39 | 40 | pub(crate) fn format(self, s: &mut String, separator: char) { 41 | let mut first_scope = true; 42 | 43 | $( 44 | if self.contains($bit) { 45 | if !first_scope { 46 | s.push(separator); 47 | } 48 | 49 | s.push_str($str); 50 | 51 | #[allow(unused_assignments)] 52 | { 53 | first_scope = false; 54 | } 55 | } 56 | )* 57 | } 58 | } 59 | }; 60 | } 61 | 62 | define_scopes! { 63 | /// Allows reading chat messages on a user's behalf. 64 | ChatRead: 0, "chat.read"; 65 | /// Allows sending chat messages on a user's behalf. 66 | ChatWrite: 1, "chat.write"; 67 | /// Allows joining and leaving chat channels on a user's behalf. 68 | ChatWriteManage: 2, "chat.write_manage"; 69 | /// Allows acting as the owner of a client. 70 | Delegate: 3, "delegate"; 71 | /// Allows creating and editing forum posts on a user's behalf. 72 | ForumWrite: 4, "forum.write"; 73 | /// Allows reading of the user's friend list. 74 | FriendsRead: 5, "friends.read"; 75 | /// Allows reading of the public profile of the user. 76 | Identify: 6, "identify"; 77 | /// Allows reading of publicly available data on behalf of the user. 78 | Public: 7, "public"; 79 | } 80 | 81 | impl Default for Scopes { 82 | fn default() -> Self { 83 | Self::Public 84 | } 85 | } 86 | 87 | impl BitOr for Scopes { 88 | type Output = Self; 89 | 90 | fn bitor(self, rhs: Self) -> Self::Output { 91 | Self(self.0.bitor(rhs.0)) 92 | } 93 | } 94 | 95 | impl BitOrAssign for Scopes { 96 | fn bitor_assign(&mut self, rhs: Self) { 97 | self.0.bitor_assign(rhs.0); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/client/token.rs: -------------------------------------------------------------------------------- 1 | use std::{error::Error, sync::Arc, time::Duration}; 2 | use tokio::{ 3 | sync::oneshot::{self, Receiver}, 4 | time::sleep, 5 | }; 6 | 7 | use serde::Deserialize; 8 | 9 | use crate::{future::TokenFuture, OsuResult}; 10 | 11 | use super::{OsuInner, Scopes}; 12 | 13 | /// The current [`Token`] to interact with the osu! API. 14 | pub(crate) struct CurrentToken { 15 | inner: current_token::CurrentToken, 16 | } 17 | 18 | mod current_token { 19 | use std::sync::{PoisonError, RwLock, RwLockReadGuard, RwLockWriteGuard}; 20 | 21 | use super::Token; 22 | 23 | pub(super) struct CurrentToken { 24 | token: RwLock, 25 | } 26 | 27 | impl CurrentToken { 28 | /// Create a new token. 29 | pub const fn new() -> Self { 30 | Self { 31 | token: RwLock::new(Token::DEFAULT), 32 | } 33 | } 34 | 35 | /// Read access to the current token. 36 | pub fn read(&self) -> RwLockReadGuard<'_, Token> { 37 | self.token.read().unwrap_or_else(PoisonError::into_inner) 38 | } 39 | 40 | /// Write access to the current token. 41 | pub fn write(&self) -> RwLockWriteGuard<'_, Token> { 42 | self.token.write().unwrap_or_else(PoisonError::into_inner) 43 | } 44 | } 45 | } 46 | 47 | impl CurrentToken { 48 | pub const fn new() -> Self { 49 | Self { 50 | inner: current_token::CurrentToken::new(), 51 | } 52 | } 53 | 54 | /// Set the current token. 55 | pub fn set(&self, token: Token) { 56 | *self.inner.write() = token; 57 | } 58 | 59 | /// Update the current token. 60 | pub fn update(&self, token: TokenResponse) { 61 | self.inner 62 | .write() 63 | .update(token.access_token.as_ref(), token.refresh_token); 64 | } 65 | 66 | /// Remove the current access token to prevent future requests. 67 | fn prevent_access(&self) { 68 | self.inner.write().access.take(); 69 | } 70 | 71 | /// Accesses the current token. 72 | /// 73 | /// `f` should perform as little work as possible as to not block the inner 74 | /// lock for too long. 75 | pub fn get(&self, f: F) -> O 76 | where 77 | F: FnOnce(&Token) -> O, 78 | { 79 | f(&self.inner.read()) 80 | } 81 | 82 | /// Returns the current refresh token. 83 | pub fn get_refresh(&self) -> Option> { 84 | self.get(|token| token.refresh.clone()) 85 | } 86 | 87 | pub(super) fn update_worker( 88 | osu: Arc, 89 | auth_kind: AuthorizationKind, 90 | mut expire: i64, 91 | mut dropped_rx: Receiver<()>, 92 | ) { 93 | tokio::spawn(async move { 94 | loop { 95 | // In case acquiring a new token takes too long, 96 | // remove the previous token as soon as it expires 97 | // so that new requests will not be sent until 98 | // a new token has been acquired 99 | let (expire_tx, expire_rx) = oneshot::channel::<()>(); 100 | 101 | tokio::select! { 102 | _ = &mut dropped_rx => { 103 | let _ = expire_tx.send(()); 104 | return debug!("Osu dropped; exiting token update loop"); 105 | } 106 | token = Self::update_routine(Arc::clone(&osu), &auth_kind, expire, expire_rx) => { 107 | let _ = expire_tx.send(()); 108 | debug!("Successfully acquired new token"); 109 | 110 | expire = token.expires_in; 111 | osu.token.update(token); 112 | } 113 | } 114 | } 115 | }); 116 | } 117 | 118 | async fn update_routine( 119 | osu: Arc, 120 | auth_kind: &AuthorizationKind, 121 | expire: i64, 122 | mut expire_rx: Receiver<()>, 123 | ) -> TokenResponse { 124 | let osu_clone = Arc::clone(&osu); 125 | tokio::spawn(async move { 126 | tokio::select! { 127 | _ = &mut expire_rx => {} 128 | _ = sleep(Duration::from_secs(expire.max(0) as u64)) => { 129 | osu_clone.token.prevent_access(); 130 | warn!("Acquiring new token took too long, removed current token"); 131 | } 132 | } 133 | }); 134 | 135 | let adjusted_expire = adjust_token_expire(expire); 136 | debug!("Acquire new API token in {} seconds", adjusted_expire); 137 | 138 | sleep(Duration::from_secs(adjusted_expire.max(0) as u64)).await; 139 | debug!("API token expired, acquiring new one..."); 140 | 141 | CurrentToken::request_loop(osu, auth_kind).await 142 | } 143 | 144 | // Acquire a new token through exponential backoff 145 | async fn request_loop(osu: Arc, auth_kind: &AuthorizationKind) -> TokenResponse { 146 | let mut backoff = 400; 147 | 148 | loop { 149 | match auth_kind.request_token(Arc::clone(&osu)).await { 150 | Ok(token) if token.token_type.as_ref() == "Bearer" => return token, 151 | Ok(token) => { 152 | warn!( 153 | r#"Failed to acquire new token, "{}" != "Bearer"; retry in {backoff}ms"#, 154 | token.token_type 155 | ); 156 | } 157 | Err(err) => { 158 | warn!(?err, "Failed to acquire new token; retry in {backoff}ms"); 159 | 160 | let mut err: &dyn Error = &err; 161 | 162 | while let Some(src) = err.source() { 163 | warn!(" - caused by: {src}"); 164 | err = src; 165 | } 166 | } 167 | } 168 | 169 | sleep(Duration::from_millis(backoff)).await; 170 | backoff = (backoff * 2).min(60_000); 171 | } 172 | } 173 | } 174 | 175 | /// Token to interact with the osu! API. 176 | #[derive(Clone, Debug, PartialEq, Eq)] 177 | pub struct Token { 178 | pub(crate) access: Option>, 179 | pub(crate) refresh: Option>, 180 | } 181 | 182 | impl Default for Token { 183 | fn default() -> Self { 184 | Self::DEFAULT 185 | } 186 | } 187 | 188 | impl Token { 189 | const DEFAULT: Self = Self { 190 | access: None, 191 | refresh: None, 192 | }; 193 | 194 | /// Value used to access the API. 195 | /// 196 | /// `None` if the token has not been refreshed. 197 | pub fn access(&self) -> Option<&str> { 198 | self.access.as_deref() 199 | } 200 | 201 | /// Value used to refresh the token. 202 | pub fn refresh(&self) -> Option<&str> { 203 | self.refresh.as_deref() 204 | } 205 | 206 | /// Create a new [`Token`] with the given values. 207 | pub fn new(access: &str, refresh: Option>) -> Self { 208 | let mut token = Self::default(); 209 | token.update(access, refresh); 210 | 211 | token 212 | } 213 | 214 | pub(super) fn update(&mut self, access: &str, refresh: Option>) { 215 | let access = if access.starts_with("Bearer ") { 216 | access.into() 217 | } else { 218 | format!("Bearer {access}").into_boxed_str() 219 | }; 220 | 221 | self.access = Some(access); 222 | self.refresh = refresh; 223 | } 224 | } 225 | 226 | #[inline] 227 | fn adjust_token_expire(expires_in: i64) -> i64 { 228 | expires_in - (expires_in as f64 * 0.05) as i64 229 | } 230 | 231 | pub(super) enum AuthorizationBuilder { 232 | Kind(AuthorizationKind), 233 | #[cfg(feature = "local_oauth")] 234 | LocalOauth { 235 | redirect_uri: String, 236 | scopes: Scopes, 237 | }, 238 | Given { 239 | token: Token, 240 | expires_in: Option, 241 | }, 242 | } 243 | 244 | impl AuthorizationBuilder { 245 | #[cfg(feature = "local_oauth")] 246 | pub(super) async fn perform_local_oauth( 247 | redirect_uri: String, 248 | client_id: u64, 249 | scopes: Scopes, 250 | ) -> Result { 251 | use std::{ 252 | io::{Error as IoError, ErrorKind}, 253 | str::from_utf8 as str_from_utf8, 254 | }; 255 | use tokio::{ 256 | io::AsyncWriteExt, 257 | net::{TcpListener, TcpStream}, 258 | }; 259 | 260 | use crate::error::OAuthError; 261 | 262 | let port: u16 = redirect_uri 263 | .rsplit_once(':') 264 | .and_then(|(prefix, suffix)| { 265 | suffix 266 | .split('/') 267 | .next() 268 | .filter(|_| prefix.ends_with("localhost")) 269 | }) 270 | .map(str::parse) 271 | .and_then(Result::ok) 272 | .ok_or(OAuthError::Url)?; 273 | 274 | let listener = TcpListener::bind(("localhost", port)) 275 | .await 276 | .map_err(OAuthError::Listener)?; 277 | 278 | let mut url = format!( 279 | "https://osu.ppy.sh/oauth/authorize?\ 280 | client_id={client_id}\ 281 | &redirect_uri={redirect_uri}\ 282 | &response_type=code", 283 | ); 284 | 285 | url.push_str("&scope="); 286 | scopes.format(&mut url, '+'); 287 | 288 | println!("Authorize yourself through the following url:\n{url}"); 289 | info!("Awaiting manual authorization..."); 290 | 291 | let (mut stream, _) = listener.accept().await.map_err(OAuthError::Accept)?; 292 | let mut data = Vec::new(); 293 | 294 | loop { 295 | stream.readable().await.map_err(OAuthError::Read)?; 296 | 297 | match stream.try_read_buf(&mut data) { 298 | Ok(0) => break, 299 | Ok(_) => { 300 | if data.ends_with(b"\n\n") || data.ends_with(b"\r\n\r\n") { 301 | break; 302 | } 303 | } 304 | Err(ref e) if e.kind() == ErrorKind::WouldBlock => {} 305 | Err(e) => return Err(OAuthError::Read(e)), 306 | } 307 | } 308 | 309 | let code = str_from_utf8(&data) 310 | .ok() 311 | .and_then(|data| { 312 | const KEY: &str = "code="; 313 | 314 | if let Some(mut start) = data.find(KEY) { 315 | start += KEY.len(); 316 | 317 | if let Some(end) = data[start..].find(char::is_whitespace) { 318 | return Some(Box::from(&data[start..][..end])); 319 | } 320 | } 321 | 322 | None 323 | }) 324 | .ok_or(OAuthError::NoCode { data })?; 325 | 326 | info!("Authorization succeeded"); 327 | 328 | #[allow(clippy::items_after_statements)] 329 | async fn respond(stream: &mut TcpStream) -> Result<(), IoError> { 330 | let response = b"HTTP/1.0 200 OK 331 | Content-Type: text/html 332 | 333 | 334 |

rosu-v2 authentication succeeded

335 | You may close this tab 336 | "; 337 | 338 | stream.writable().await?; 339 | stream.write_all(response).await?; 340 | stream.shutdown().await?; 341 | 342 | Ok(()) 343 | } 344 | 345 | respond(&mut stream).await.map_err(OAuthError::Write)?; 346 | 347 | Ok(Authorization { 348 | code, 349 | redirect_uri: redirect_uri.into_boxed_str(), 350 | scopes, 351 | }) 352 | } 353 | } 354 | 355 | pub(super) enum AuthorizationKind { 356 | User(Authorization), 357 | Client, 358 | BareToken, 359 | } 360 | 361 | impl AuthorizationKind { 362 | pub async fn request_token(&self, osu: Arc) -> OsuResult { 363 | match self { 364 | AuthorizationKind::User(auth) => match osu.token.get_refresh() { 365 | Some(refresh) => TokenFuture::new_refresh(osu, &refresh).await, 366 | None => TokenFuture::new_user(osu, auth).await, 367 | }, 368 | AuthorizationKind::Client => TokenFuture::new_client(osu).await, 369 | AuthorizationKind::BareToken => { 370 | let Some(refresh) = osu.token.get_refresh() else { 371 | error!("Missing refresh token on bare authentication; all future requests will fail"); 372 | 373 | futures::future::pending::<()>().await; 374 | unreachable!(); 375 | }; 376 | 377 | TokenFuture::new_refresh(osu, &refresh).await 378 | } 379 | } 380 | } 381 | } 382 | 383 | impl Default for AuthorizationKind { 384 | fn default() -> Self { 385 | Self::Client 386 | } 387 | } 388 | 389 | pub(crate) struct Authorization { 390 | pub code: Box, 391 | pub redirect_uri: Box, 392 | pub scopes: Scopes, 393 | } 394 | 395 | #[derive(Deserialize)] 396 | pub(crate) struct TokenResponse { 397 | pub access_token: Box, 398 | pub expires_in: i64, 399 | #[serde(default)] 400 | pub refresh_token: Option>, 401 | pub token_type: Box, 402 | } 403 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | use hyper::{ 2 | body::Bytes, header::InvalidHeaderValue, http::Error as HttpError, Error as HyperError, 3 | StatusCode, 4 | }; 5 | use serde::Deserialize; 6 | use serde_json::Error as SerdeError; 7 | use std::fmt; 8 | 9 | #[cfg(feature = "local_oauth")] 10 | #[cfg_attr(docsrs, doc(cfg(feature = "local_oauth")))] 11 | #[derive(Debug, thiserror::Error)] 12 | pub enum OAuthError { 13 | #[error("failed to accept request")] 14 | Accept(#[source] tokio::io::Error), 15 | #[error("failed to create tcp listener")] 16 | Listener(#[source] tokio::io::Error), 17 | #[error("missing code in request")] 18 | NoCode { data: Vec }, 19 | #[error("failed to read data")] 20 | Read(#[source] tokio::io::Error), 21 | #[error("redirect uri must contain localhost and a port number")] 22 | Url, 23 | #[error("failed to write data")] 24 | Write(#[source] tokio::io::Error), 25 | } 26 | 27 | /// The API response was of the form `{ "error": ... }` 28 | #[derive(Debug, Deserialize, thiserror::Error)] 29 | pub struct ApiError { 30 | /// Error specified by the API 31 | pub error: Option, 32 | } 33 | 34 | impl fmt::Display for ApiError { 35 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 36 | match self.error { 37 | Some(ref msg) => f.write_str(msg), 38 | None => f.write_str("empty error message"), 39 | } 40 | } 41 | } 42 | 43 | /// The main error type 44 | #[derive(Debug, thiserror::Error)] 45 | #[non_exhaustive] 46 | pub enum OsuError { 47 | /// Failed to create a request body 48 | #[error("failed to create request body")] 49 | BodyError { 50 | #[from] 51 | source: HttpError, 52 | }, 53 | /// Failed to build an [`Osu`](crate::Osu) client because no client id was provided 54 | #[error("failed to build osu client, no client id was provided")] 55 | BuilderMissingId, 56 | /// Failed to build an [`Osu`](crate::Osu) client because no client secret was provided 57 | #[error("failed to build osu client, no client secret was provided")] 58 | BuilderMissingSecret, 59 | /// Error while handling response from the API 60 | #[error("failed to chunk the response")] 61 | ChunkingResponse { 62 | #[source] 63 | source: HyperError, 64 | }, 65 | /// No usable cipher suites in crypto provider 66 | #[error("no usable cipher suites in crypto provider")] 67 | ConnectorRoots { 68 | #[source] 69 | source: std::io::Error, 70 | }, 71 | /// Failed to create the token header for a request 72 | #[error("failed to parse token for authorization header")] 73 | CreatingTokenHeader { 74 | #[from] 75 | source: InvalidHeaderValue, 76 | }, 77 | /// The API returned a 404 78 | #[error("the osu!api returned a 404 implying a missing score, incorrect name, id, etc")] 79 | NotFound, 80 | /// Attempted to make request without valid token 81 | #[error( 82 | "The previous osu!api token expired and the client \ 83 | has not yet succeeded in acquiring a new one. \ 84 | Can not send requests until a new token has been acquired. \ 85 | This should only occur during an extended downtime of the osu!api." 86 | )] 87 | NoToken, 88 | #[cfg(feature = "local_oauth")] 89 | #[cfg_attr(docsrs, doc(cfg(feature = "local_oauth")))] 90 | /// Failed to perform OAuth 91 | #[error("failed to perform oauth")] 92 | OAuth { 93 | #[from] 94 | source: OAuthError, 95 | }, 96 | #[cfg(feature = "replay")] 97 | #[cfg_attr(docsrs, doc(cfg(feature = "replay")))] 98 | /// There was an error while trying to use osu-db 99 | #[error("osu-db error")] 100 | OsuDbError { 101 | #[from] 102 | source: osu_db::Error, 103 | }, 104 | /// Failed to deserialize response 105 | #[error("failed to deserialize response: {:?}", .bytes)] 106 | Parsing { 107 | bytes: Bytes, 108 | #[source] 109 | source: SerdeError, 110 | }, 111 | /// Failed to parse a value 112 | #[error("failed to parse value")] 113 | ParsingValue { 114 | #[from] 115 | source: ParsingError, 116 | }, 117 | /// Failed to send request 118 | #[error("failed to send request")] 119 | Request { 120 | #[source] 121 | source: hyper_util::client::legacy::Error, 122 | }, 123 | /// Timeout while requesting from API 124 | #[error("osu!api did not respond in time")] 125 | RequestTimeout, 126 | /// API returned an error 127 | #[error("response error, status {}", .status)] 128 | Response { 129 | bytes: Bytes, 130 | #[source] 131 | source: ApiError, 132 | status: StatusCode, 133 | }, 134 | /// Temporal (?) downtime of the osu API 135 | #[error("osu!api may be temporarily unavailable (received 503)")] 136 | ServiceUnavailable { body: hyper::body::Incoming }, 137 | /// The client's authentication is not sufficient for the endpoint 138 | #[error("the endpoint is not available for the client's authorization level")] 139 | UnavailableEndpoint, 140 | /// Failed to update token 141 | #[error("failed to update osu!api token")] 142 | UpdateToken { 143 | #[source] 144 | source: Box, 145 | }, 146 | /// Failed to parse the URL for a request 147 | #[error("failed to parse URL of a request; url: `{}`", .url)] 148 | Url { 149 | #[source] 150 | source: url::ParseError, 151 | /// URL that was attempted to be parsed 152 | url: String, 153 | }, 154 | } 155 | 156 | impl OsuError { 157 | pub(crate) fn invalid_mods( 158 | mods: &serde_json::value::RawValue, 159 | err: &SerdeError, 160 | ) -> E { 161 | E::custom(format!("invalid mods `{mods}`: {err}")) 162 | } 163 | } 164 | 165 | /// Failed some [`TryFrom`] parsing 166 | #[derive(Debug, thiserror::Error)] 167 | pub enum ParsingError { 168 | /// Failed to parse a str into an [`Acronym`](crate::model::mods::Acronym) 169 | #[error("failed to parse `{}` into an Acronym", .0)] 170 | Acronym(Box), 171 | /// Failed to parse a u8 into a [`Genre`](crate::model::beatmap::Genre) 172 | #[error("failed to parse {} into Genre", .0)] 173 | Genre(u8), 174 | /// Failed to parse a String into a [`Grade`](crate::model::Grade) 175 | #[error("failed to parse `{}` into Grade", .0)] 176 | Grade(String), // TODO: make Box 177 | /// Failed to parse a u8 into a [`Language`](crate::model::beatmap::Language) 178 | #[error("failed to parse {} into Language", .0)] 179 | Language(u8), 180 | /// Failed to parse a u8 into a [`MatchTeam`](crate::model::matches::MatchTeam) 181 | #[error("failed to parse {} into MatchTeam", .0)] 182 | MatchTeam(u8), 183 | /// Failed to parse an i8 into a [`RankStatus`](crate::model::beatmap::RankStatus) 184 | #[error("failed to parse {} into RankStatus", .0)] 185 | RankStatus(i8), 186 | /// Failed to parse a u8 into a [`ScoringType`](crate::model::matches::ScoringType) 187 | #[error("failed to parse {} into ScoringType", .0)] 188 | ScoringType(u8), 189 | /// Failed to parse a u8 into a [`TeamType`](crate::model::matches::TeamType) 190 | #[error("failed to parse {} into TeamType", .0)] 191 | TeamType(u8), 192 | } 193 | -------------------------------------------------------------------------------- /src/future/mod.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | future::{Future, IntoFuture}, 3 | ops::ControlFlow, 4 | pin::Pin, 5 | sync::Arc, 6 | task::{Context, Poll}, 7 | }; 8 | 9 | use pin_project::pin_project; 10 | 11 | use crate::{ 12 | request::{GetUser, Request, UserId}, 13 | Osu, OsuResult, 14 | }; 15 | 16 | use self::stage::{OsuFutureStage, OsuRequestStageInner}; 17 | 18 | pub(crate) use self::token::TokenFuture; 19 | 20 | pub use self::traits::*; 21 | 22 | mod request_generator; 23 | mod stage; 24 | mod token; 25 | mod traits; 26 | 27 | type FromUserFn = fn(u32, ::FromUserData) -> Request; 28 | 29 | struct FromUser { 30 | data: T::FromUserData, 31 | f: FromUserFn, 32 | } 33 | 34 | type PostProcessFn = fn( 35 | ::FromBytes, 36 | ::PostProcessData, 37 | ) -> OsuResult<::OsuOutput>; 38 | 39 | struct PostProcess { 40 | data: T::PostProcessData, 41 | f: PostProcessFn, 42 | } 43 | 44 | /// Awaitable [`Future`] to fetch and process data from an endpoint. 45 | /// 46 | /// When fetching from user endpoints by name instead of id, the [`OsuFuture`] 47 | /// might first perform a request to fetch the user itself, and then perform 48 | /// the actual request by using the fetched user id. If the `cache` feature 49 | /// is enabled, fetched user data will be stored to potentially prevent 50 | /// intermediate user requests later on. 51 | #[pin_project] 52 | pub struct OsuFuture { 53 | #[pin] 54 | stage: OsuFutureStage, 55 | from_user: Option>, 56 | post_process: Option>, 57 | } 58 | 59 | impl OsuFuture { 60 | /// Creates a new [`OsuFuture`] from the given [`Request`]. 61 | pub(crate) fn new( 62 | osu: &Osu, 63 | req: Request, 64 | post_process_data: T::PostProcessData, 65 | post_process_fn: PostProcessFn, 66 | ) -> Self { 67 | let osu = Arc::clone(&osu.inner); 68 | 69 | Self { 70 | stage: OsuRequestStageInner::new(osu, req) 71 | .map_or_else(OsuFutureStage::Failed, OsuFutureStage::Final), 72 | from_user: None, 73 | post_process: Some(PostProcess { 74 | data: post_process_data, 75 | f: post_process_fn, 76 | }), 77 | } 78 | } 79 | 80 | /// Creates a new [`OsuFuture`] which might fetch a user first if the 81 | /// given [`UserId`] is a name that has not been cached yet. 82 | pub(crate) fn from_user_id( 83 | osu: &Osu, 84 | user_id: UserId, 85 | from_user_data: T::FromUserData, 86 | from_user_fn: FromUserFn, 87 | post_process_data: T::PostProcessData, 88 | post_process_fn: PostProcessFn, 89 | ) -> Self { 90 | #[cfg(not(feature = "cache"))] 91 | let get_user_id: fn(UserId) -> UserId = std::convert::identity; 92 | 93 | #[cfg(feature = "cache")] 94 | fn get_user_id(mut user_id: UserId, osu: &Osu) -> UserId { 95 | if let UserId::Name(ref mut name) = user_id { 96 | name.make_ascii_lowercase(); 97 | 98 | if let Some(id) = osu.inner.cache.get(name) { 99 | return UserId::Id(*id); 100 | } 101 | } 102 | 103 | user_id 104 | } 105 | 106 | match get_user_id( 107 | user_id, 108 | #[cfg(feature = "cache")] 109 | osu, 110 | ) { 111 | UserId::Id(user_id) => { 112 | let req = from_user_fn(user_id, from_user_data); 113 | 114 | Self::new(osu, req, post_process_data, post_process_fn) 115 | } 116 | user_id @ UserId::Name(_) => { 117 | #[cfg(not(feature = "cache"))] 118 | { 119 | static NOTIF: std::sync::Once = std::sync::Once::new(); 120 | 121 | // In case users intend to fetch frequently from user 122 | // endpoints by username but weren't aware either that 123 | // they have the `cache` feature disabled or that 124 | // disabling the feature will add an additional request 125 | // on every fetch, let's remind them a single time. 126 | NOTIF.call_once(|| { 127 | warn!( 128 | "Fetching from a user endpoint by username will \ 129 | always perform two requests because the `cache` \ 130 | feature is not enabled" 131 | ); 132 | }); 133 | } 134 | 135 | let osu = Arc::clone(&osu.inner); 136 | let req = GetUser::create_request(user_id, None); 137 | 138 | Self { 139 | stage: OsuRequestStageInner::new(osu, req) 140 | .map_or_else(OsuFutureStage::Failed, OsuFutureStage::User), 141 | from_user: Some(FromUser { 142 | data: from_user_data, 143 | f: from_user_fn, 144 | }), 145 | post_process: Some(PostProcess { 146 | data: post_process_data, 147 | f: post_process_fn, 148 | }), 149 | } 150 | } 151 | } 152 | } 153 | } 154 | 155 | impl Future for OsuFuture { 156 | type Output = ::Output; 157 | 158 | fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { 159 | let mut this = self.as_mut().project(); 160 | 161 | match this.stage.as_mut().poll(cx) { 162 | Poll::Ready(ControlFlow::Break(Ok((bytes, osu)))) => { 163 | let res = ::from_bytes(bytes)?; 164 | let PostProcess { data, f } = 165 | this.post_process.take().expect("missing post_process"); 166 | 167 | let value = f(res, data)?; 168 | 169 | #[cfg(feature = "cache")] 170 | crate::model::ContainedUsers::apply_to_users(&value, |id, name| { 171 | osu.update_cache(id, name); 172 | }); 173 | 174 | // Preventing "unused variable" lint w/o `cache` feature 175 | let _ = osu; 176 | 177 | Poll::Ready(Ok(value)) 178 | } 179 | Poll::Ready(ControlFlow::Continue((user, osu))) => { 180 | #[cfg(feature = "cache")] 181 | osu.update_cache(user.user_id, &user.username); 182 | 183 | #[cfg(feature = "metrics")] 184 | // Technically, using a gauge and setting it to 185 | // `osu.cache.len()` would be more correct but since 186 | // `DashMap::len` is a non-trivial call, it should be fine 187 | // to increment a counter. This works because we're only in 188 | // this path if the cache did not contain the username in 189 | // the first place, meaning we indeed add a new entry. 190 | ::metrics::counter!(crate::metrics::USERNAME_CACHE_SIZE).increment(1); 191 | 192 | let FromUser { data, f } = this.from_user.take().expect("missing from_user"); 193 | let req = f(user.user_id, data); 194 | 195 | let next = OsuRequestStageInner::new(osu, req)?; 196 | this.stage.project_replace(OsuFutureStage::Final(next)); 197 | 198 | self.poll(cx) 199 | } 200 | Poll::Ready(ControlFlow::Break(Err(err))) => Poll::Ready(Err(err)), 201 | Poll::Pending => Poll::Pending, 202 | } 203 | } 204 | } 205 | 206 | #[allow(clippy::unnecessary_wraps)] 207 | pub(crate) const fn noop_post_process(value: T, _: ()) -> OsuResult { 208 | Ok(value) 209 | } 210 | -------------------------------------------------------------------------------- /src/future/request_generator.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use bytes::Bytes; 4 | use http_body_util::Full; 5 | use hyper::{ 6 | header::{HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_LENGTH, CONTENT_TYPE, USER_AGENT}, 7 | Request as HyperRequest, 8 | }; 9 | use url::Url; 10 | 11 | use crate::{ 12 | client::OsuInner, 13 | error::OsuError, 14 | request::{Method, Request}, 15 | OsuResult, 16 | }; 17 | 18 | pub(super) struct FutureRequestGenerator { 19 | pub(super) osu: Arc, 20 | method: Method, 21 | uri: Box, 22 | token: HeaderValue, 23 | api_version: u32, 24 | body: Vec, 25 | pub(super) attempt: u8, 26 | #[cfg(feature = "metrics")] 27 | pub(super) route: &'static str, 28 | } 29 | 30 | pub(super) static MY_USER_AGENT: &str = concat!( 31 | "Rust API v2 (", 32 | env!("CARGO_PKG_REPOSITORY"), 33 | " v", 34 | env!("CARGO_PKG_VERSION"), 35 | ")", 36 | ); 37 | 38 | pub(super) const APPLICATION_JSON: &str = "application/json"; 39 | pub(super) const X_API_VERSION: &str = "x-api-version"; 40 | 41 | impl FutureRequestGenerator { 42 | pub(super) fn new(osu: Arc, req: Request) -> OsuResult { 43 | let Request { 44 | query, 45 | route, 46 | body, 47 | api_version, 48 | } = req; 49 | 50 | let (method, path) = route.as_parts(); 51 | 52 | let mut url = format!("https://osu.ppy.sh/api/v2/{path}"); 53 | 54 | if let Some(ref query) = query { 55 | url.push('?'); 56 | url.push_str(query); 57 | } 58 | 59 | let url = Url::parse(&url).map_err(|source| OsuError::Url { source, url })?; 60 | debug!(%url, "Performing request..."); 61 | 62 | let token_res = osu.token.get(|token| match token.access { 63 | Some(ref access) => match HeaderValue::from_str(access) { 64 | Ok(header) => Ok(header), 65 | Err(source) => Err(OsuError::CreatingTokenHeader { source }), 66 | }, 67 | None => Err(OsuError::NoToken), 68 | }); 69 | 70 | let token = token_res?; 71 | 72 | // `Url`'s parsing allocates a string based on the input length so we 73 | // are generally dealing with a `String` that has equal length and 74 | // capacity meaning we don't re-allocate when boxing the string. 75 | let uri = String::from(url).into_boxed_str(); 76 | 77 | Ok(Self { 78 | osu, 79 | method, 80 | uri, 81 | token, 82 | api_version, 83 | body: body.into_bytes(), 84 | attempt: 0, 85 | #[cfg(feature = "metrics")] 86 | route: route.name(), 87 | }) 88 | } 89 | 90 | pub(super) fn generate(&self) -> OsuResult>> { 91 | let len = self.body.len(); 92 | 93 | let mut req = HyperRequest::builder() 94 | .method(self.method.into_hyper()) 95 | .uri(self.uri.as_ref()) 96 | .header(AUTHORIZATION, self.token.clone()) 97 | .header(USER_AGENT, MY_USER_AGENT) 98 | .header(X_API_VERSION, self.api_version) 99 | .header(ACCEPT, APPLICATION_JSON) 100 | .header(CONTENT_LENGTH, len); 101 | 102 | if len > 0 { 103 | req = req.header(CONTENT_TYPE, APPLICATION_JSON); 104 | } 105 | 106 | let body = Full::new(Bytes::copy_from_slice(&self.body)); 107 | 108 | req.body(body).map_err(OsuError::from) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/future/stage.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | future::Future, 3 | ops::ControlFlow, 4 | pin::Pin, 5 | sync::Arc, 6 | task::{Context, Poll}, 7 | }; 8 | 9 | #[cfg(feature = "metrics")] 10 | use std::time::Instant; 11 | 12 | use http_body_util::{combinators::Collect, BodyExt}; 13 | use hyper::{ 14 | body::{Bytes, Incoming}, 15 | Response as HyperResponse, StatusCode, 16 | }; 17 | use hyper_util::client::legacy::ResponseFuture as HyperResponseFuture; 18 | use leaky_bucket::{AcquireOwned, RateLimiter}; 19 | use pin_project::pin_project; 20 | use tokio::time::Timeout; 21 | 22 | use crate::{ 23 | client::OsuInner, 24 | error::{ApiError, OsuError}, 25 | prelude::UserExtended, 26 | request::Request, 27 | OsuResult, 28 | }; 29 | 30 | use super::request_generator::FutureRequestGenerator; 31 | 32 | #[pin_project] 33 | struct Ratelimit { 34 | #[pin] 35 | acquire: AcquireOwned, 36 | generator: Option, 37 | } 38 | 39 | impl Ratelimit { 40 | fn new(ratelimiter: Arc, generator: FutureRequestGenerator) -> Self { 41 | Self { 42 | acquire: ratelimiter.acquire_owned(1), 43 | generator: Some(generator), 44 | } 45 | } 46 | } 47 | 48 | impl Future for Ratelimit { 49 | type Output = FutureRequestGenerator; 50 | 51 | fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { 52 | let this = self.project(); 53 | 54 | match this.acquire.poll(cx) { 55 | Poll::Ready(_) => Poll::Ready(this.generator.take().expect("missing generator")), 56 | Poll::Pending => Poll::Pending, 57 | } 58 | } 59 | } 60 | 61 | #[pin_project] 62 | struct InFlight { 63 | #[pin] 64 | future: Timeout, 65 | generator: Option, 66 | #[cfg(feature = "metrics")] 67 | start: Option, 68 | } 69 | 70 | impl InFlight { 71 | fn new(future: HyperResponseFuture, generator: FutureRequestGenerator) -> Self { 72 | Self { 73 | future: tokio::time::timeout(generator.osu.timeout, future), 74 | generator: Some(generator), 75 | #[cfg(feature = "metrics")] 76 | start: None, 77 | } 78 | } 79 | } 80 | 81 | enum InFlightOutput { 82 | Ratelimit(Ratelimit), 83 | Chunking(Chunking), 84 | Failed(OsuError), 85 | } 86 | 87 | impl Future for InFlight { 88 | type Output = InFlightOutput; 89 | 90 | fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { 91 | let this = self.project(); 92 | 93 | #[cfg(feature = "metrics")] 94 | let start = *this.start.get_or_insert_with(Instant::now); 95 | 96 | match this.future.poll(cx) { 97 | Poll::Ready(Ok(Ok(resp))) => { 98 | let status = resp.status(); 99 | 100 | match status { 101 | StatusCode::NOT_FOUND => { 102 | return Poll::Ready(InFlightOutput::Failed(OsuError::NotFound)) 103 | } 104 | StatusCode::SERVICE_UNAVAILABLE => { 105 | return Poll::Ready(InFlightOutput::Failed(OsuError::ServiceUnavailable { 106 | body: resp.into_body(), 107 | })) 108 | } 109 | StatusCode::TOO_MANY_REQUESTS => warn!("429 response: {resp:?}"), 110 | _ => {} 111 | } 112 | 113 | let generator = this.generator.take().expect("missing generator"); 114 | 115 | let chunking = Chunking::new( 116 | resp, 117 | generator.osu, 118 | #[cfg(feature = "metrics")] 119 | ChunkingMetrics { 120 | start, 121 | route: generator.route, 122 | }, 123 | ); 124 | 125 | Poll::Ready(InFlightOutput::Chunking(chunking)) 126 | } 127 | Poll::Ready(Ok(Err(source))) => { 128 | Poll::Ready(InFlightOutput::Failed(OsuError::Request { source })) 129 | } 130 | Poll::Ready(Err(_)) => { 131 | let mut generator = this.generator.take().expect("missing generator"); 132 | let max_retries = generator.osu.retries; 133 | 134 | if generator.attempt >= max_retries { 135 | return Poll::Ready(InFlightOutput::Failed(OsuError::RequestTimeout)); 136 | } 137 | 138 | generator.attempt += 1; 139 | 140 | warn!( 141 | "Timed out on attempt {}/{max_retries}, retrying...", 142 | generator.attempt 143 | ); 144 | 145 | let ratelimiter = Arc::clone(&generator.osu.ratelimiter); 146 | 147 | Poll::Ready(InFlightOutput::Ratelimit(Ratelimit::new( 148 | ratelimiter, 149 | generator, 150 | ))) 151 | } 152 | Poll::Pending => Poll::Pending, 153 | } 154 | } 155 | } 156 | 157 | #[pin_project] 158 | pub(super) struct Chunking { 159 | #[pin] 160 | future: Collect, 161 | status: StatusCode, 162 | osu: Arc, 163 | #[cfg(feature = "metrics")] 164 | metrics: ChunkingMetrics, 165 | } 166 | 167 | #[cfg(feature = "metrics")] 168 | pub(super) struct ChunkingMetrics { 169 | pub(super) start: Instant, 170 | pub(super) route: &'static str, 171 | } 172 | 173 | impl Chunking { 174 | pub(super) fn new( 175 | resp: HyperResponse, 176 | osu: Arc, 177 | #[cfg(feature = "metrics")] metrics: ChunkingMetrics, 178 | ) -> Self { 179 | Self { 180 | status: resp.status(), 181 | future: resp.into_body().collect(), 182 | osu, 183 | #[cfg(feature = "metrics")] 184 | metrics, 185 | } 186 | } 187 | } 188 | 189 | impl Future for Chunking { 190 | type Output = OsuResult<(Bytes, Arc)>; 191 | 192 | fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { 193 | let this = self.project(); 194 | 195 | let bytes = match this.future.poll(cx) { 196 | Poll::Ready(Ok(collected)) => collected.to_bytes(), 197 | Poll::Ready(Err(source)) => { 198 | return Poll::Ready(Err(OsuError::ChunkingResponse { source })); 199 | } 200 | Poll::Pending => return Poll::Pending, 201 | }; 202 | 203 | #[cfg(feature = "metrics")] 204 | ::metrics::histogram!(crate::metrics::RESPONSE_TIME, "route" => this.metrics.route) 205 | .record(this.metrics.start.elapsed()); 206 | 207 | // let text = String::from_utf8_lossy(&bytes); 208 | // println!("Response:\n{text}"); 209 | 210 | let status = *this.status; 211 | let osu = Arc::clone(this.osu); 212 | 213 | if status.is_success() { 214 | return Poll::Ready(Ok((bytes, osu))); 215 | } 216 | 217 | let err = match serde_json::from_slice::(&bytes) { 218 | Ok(source) => OsuError::Response { 219 | bytes, 220 | source, 221 | status, 222 | }, 223 | Err(source) => OsuError::Parsing { bytes, source }, 224 | }; 225 | 226 | Poll::Ready(Err(err)) 227 | } 228 | } 229 | 230 | #[pin_project(project = StageInnerProject, project_replace = StageInnerReplace)] 231 | #[allow( 232 | private_interfaces, 233 | reason = "we need this enum to be `pub(super)` solely to access its `new` \ 234 | method; not to use its variants" 235 | )] 236 | pub(super) enum OsuRequestStageInner { 237 | Ratelimit(#[pin] Ratelimit), 238 | InFlight(#[pin] InFlight), 239 | Chunking(#[pin] Chunking), 240 | } 241 | 242 | impl OsuRequestStageInner { 243 | pub(super) fn new(osu: Arc, req: Request) -> OsuResult { 244 | let ratelimiter = Arc::clone(&osu.ratelimiter); 245 | let generator = FutureRequestGenerator::new(osu, req)?; 246 | 247 | Ok(Self::Ratelimit(Ratelimit::new(ratelimiter, generator))) 248 | } 249 | } 250 | 251 | impl Future for OsuRequestStageInner { 252 | type Output = ControlFlow), OsuError>, OsuRequestStageInner>; 253 | 254 | fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { 255 | match self.project() { 256 | StageInnerProject::Ratelimit(ratelimit) => match ratelimit.poll(cx) { 257 | Poll::Ready(generator) => match generator.generate() { 258 | Ok(req) => { 259 | let future = generator.osu.http.request(req); 260 | let in_flight = InFlight::new(future, generator); 261 | 262 | Poll::Ready(ControlFlow::Continue(Self::InFlight(in_flight))) 263 | } 264 | Err(err) => Poll::Ready(ControlFlow::Break(Err(err))), 265 | }, 266 | Poll::Pending => Poll::Pending, 267 | }, 268 | StageInnerProject::InFlight(in_flight) => match in_flight.poll(cx) { 269 | Poll::Ready(InFlightOutput::Ratelimit(ratelimit)) => { 270 | Poll::Ready(ControlFlow::Continue(Self::Ratelimit(ratelimit))) 271 | } 272 | Poll::Ready(InFlightOutput::Chunking(chunking)) => { 273 | Poll::Ready(ControlFlow::Continue(Self::Chunking(chunking))) 274 | } 275 | Poll::Ready(InFlightOutput::Failed(err)) => { 276 | Poll::Ready(ControlFlow::Break(Err(err))) 277 | } 278 | Poll::Pending => Poll::Pending, 279 | }, 280 | StageInnerProject::Chunking(chunking) => chunking.poll(cx).map(ControlFlow::Break), 281 | } 282 | } 283 | } 284 | 285 | #[pin_project(project = StageProject, project_replace = StageReplace)] 286 | pub(super) enum OsuFutureStage { 287 | User(#[pin] OsuRequestStageInner), 288 | Final(#[pin] OsuRequestStageInner), 289 | Failed(OsuError), 290 | Completed, 291 | } 292 | 293 | impl Future for OsuFutureStage { 294 | type Output = ControlFlow)>, (UserExtended, Arc)>; 295 | 296 | fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { 297 | loop { 298 | match self.as_mut().project() { 299 | StageProject::User(mut stage) => match stage.as_mut().poll(cx) { 300 | Poll::Ready(ControlFlow::Continue(next)) => { 301 | stage.project_replace(next); 302 | } 303 | Poll::Ready(ControlFlow::Break(Ok((bytes, osu)))) => { 304 | return match serde_json::from_slice(&bytes) { 305 | Ok(user) => Poll::Ready(ControlFlow::Continue((user, osu))), 306 | Err(source) => { 307 | Poll::Ready(ControlFlow::Break(Err(OsuError::Parsing { 308 | bytes, 309 | source, 310 | }))) 311 | } 312 | } 313 | } 314 | Poll::Ready(ControlFlow::Break(Err(err))) => { 315 | return Poll::Ready(ControlFlow::Break(Err(err))) 316 | } 317 | Poll::Pending => return Poll::Pending, 318 | }, 319 | StageProject::Final(mut stage) => match stage.as_mut().poll(cx) { 320 | Poll::Ready(ControlFlow::Continue(next)) => { 321 | stage.project_replace(next); 322 | } 323 | Poll::Ready(ControlFlow::Break(res)) => { 324 | return Poll::Ready(ControlFlow::Break(res)) 325 | } 326 | Poll::Pending => return Poll::Pending, 327 | }, 328 | StageProject::Failed(_) => { 329 | let StageReplace::Failed(err) = self.project_replace(Self::Completed) else { 330 | unreachable!() 331 | }; 332 | 333 | return Poll::Ready(ControlFlow::Break(Err(err))); 334 | } 335 | StageProject::Completed => panic!("future already completed"), 336 | } 337 | } 338 | } 339 | } 340 | -------------------------------------------------------------------------------- /src/future/token.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | future::Future, 3 | pin::Pin, 4 | sync::Arc, 5 | task::{Context, Poll}, 6 | }; 7 | 8 | #[cfg(feature = "metrics")] 9 | use std::time::Instant; 10 | 11 | use http_body_util::Full; 12 | use hyper::{ 13 | body::Bytes, 14 | header::{ACCEPT, CONTENT_LENGTH, CONTENT_TYPE, USER_AGENT}, 15 | Request as HyperRequest, StatusCode, 16 | }; 17 | use hyper_util::client::legacy::ResponseFuture as HyperResponseFuture; 18 | use pin_project::pin_project; 19 | use tokio::time::Timeout; 20 | 21 | use crate::{ 22 | client::{Authorization, OsuInner, Scopes, TokenResponse}, 23 | error::OsuError, 24 | request::JsonBody, 25 | OsuResult, 26 | }; 27 | 28 | use super::{ 29 | request_generator::{APPLICATION_JSON, MY_USER_AGENT}, 30 | stage::Chunking, 31 | }; 32 | 33 | struct TokenRequestGenerator { 34 | body: Vec, 35 | } 36 | 37 | impl TokenRequestGenerator { 38 | fn new(osu: &OsuInner, mut body: JsonBody) -> Self { 39 | body.push_int("client_id", osu.client_id); 40 | body.push_str("client_secret", &osu.client_secret); 41 | 42 | Self { 43 | body: body.into_bytes(), 44 | } 45 | } 46 | 47 | fn generate(self) -> OsuResult>> { 48 | let len = self.body.len(); 49 | let body = Full::new(Bytes::from(self.body)); 50 | let url = "https://osu.ppy.sh/oauth/token"; 51 | 52 | HyperRequest::post(url) 53 | .header(USER_AGENT, MY_USER_AGENT) 54 | .header(ACCEPT, APPLICATION_JSON) 55 | .header(CONTENT_TYPE, APPLICATION_JSON) 56 | .header(CONTENT_LENGTH, len) 57 | .body(body) 58 | .map_err(OsuError::from) 59 | } 60 | } 61 | 62 | #[pin_project] 63 | struct TokenInFlight { 64 | #[pin] 65 | future: Timeout, 66 | osu: Option>, 67 | #[cfg(feature = "metrics")] 68 | start: Option, 69 | } 70 | 71 | impl TokenInFlight { 72 | fn new(future: HyperResponseFuture, osu: Arc) -> Self { 73 | Self { 74 | future: tokio::time::timeout(osu.timeout, future), 75 | osu: Some(osu), 76 | #[cfg(feature = "metrics")] 77 | start: None, 78 | } 79 | } 80 | } 81 | 82 | impl Future for TokenInFlight { 83 | type Output = OsuResult; 84 | 85 | fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { 86 | let this = self.project(); 87 | 88 | #[cfg(feature = "metrics")] 89 | let start = *this.start.get_or_insert_with(Instant::now); 90 | 91 | match this.future.poll(cx) { 92 | Poll::Ready(Ok(Ok(resp))) => { 93 | match resp.status() { 94 | StatusCode::SERVICE_UNAVAILABLE => { 95 | return Poll::Ready(Err(OsuError::ServiceUnavailable { 96 | body: resp.into_body(), 97 | })) 98 | } 99 | StatusCode::TOO_MANY_REQUESTS => warn!("429 response: {resp:?}"), 100 | _ => {} 101 | } 102 | 103 | let osu = this.osu.take().expect("missing osu"); 104 | 105 | Poll::Ready(Ok(Chunking::new( 106 | resp, 107 | osu, 108 | #[cfg(feature = "metrics")] 109 | super::stage::ChunkingMetrics { 110 | start, 111 | route: "PostToken", 112 | }, 113 | ))) 114 | } 115 | Poll::Ready(Ok(Err(source))) => Poll::Ready(Err(OsuError::Request { source })), 116 | Poll::Ready(Err(_)) => Poll::Ready(Err(OsuError::RequestTimeout)), 117 | Poll::Pending => Poll::Pending, 118 | } 119 | } 120 | } 121 | 122 | #[pin_project(project = TokenProject, project_replace = TokenReplace)] 123 | enum TokenFutureInner { 124 | InFlight(#[pin] TokenInFlight), 125 | Chunking(#[pin] Chunking), 126 | Completed(Option), 127 | } 128 | 129 | #[pin_project] 130 | pub(crate) struct TokenFuture { 131 | #[pin] 132 | inner: TokenFutureInner, 133 | } 134 | 135 | impl TokenFuture { 136 | pub(crate) fn new_client(osu: Arc) -> Self { 137 | let mut body = JsonBody::new(); 138 | 139 | body.push_str("grant_type", "client_credentials"); 140 | let mut scopes = String::new(); 141 | Scopes::Public.format(&mut scopes, ' '); 142 | body.push_str("scope", &scopes); 143 | 144 | Self::new(osu, body) 145 | } 146 | 147 | pub(crate) fn new_user(osu: Arc, auth: &Authorization) -> Self { 148 | let mut body = JsonBody::new(); 149 | 150 | body.push_str("grant_type", "authorization_code"); 151 | body.push_str("redirect_uri", &auth.redirect_uri); 152 | body.push_str("code", &auth.code); 153 | let mut scopes = String::new(); 154 | auth.scopes.format(&mut scopes, ' '); 155 | body.push_str("scope", &scopes); 156 | 157 | Self::new(osu, body) 158 | } 159 | 160 | pub(crate) fn new_refresh(osu: Arc, refresh: &str) -> Self { 161 | let mut body = JsonBody::new(); 162 | 163 | body.push_str("grant_type", "refresh_token"); 164 | body.push_str("refresh_token", refresh); 165 | 166 | Self::new(osu, body) 167 | } 168 | 169 | fn new(osu: Arc, body: JsonBody) -> Self { 170 | let inner = match TokenRequestGenerator::new(&osu, body).generate() { 171 | Ok(req) => TokenFutureInner::InFlight(TokenInFlight::new(osu.http.request(req), osu)), 172 | Err(err) => TokenFutureInner::Completed(Some(err)), 173 | }; 174 | 175 | Self { inner } 176 | } 177 | } 178 | 179 | impl Future for TokenFuture { 180 | type Output = OsuResult; 181 | 182 | fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { 183 | let mut this = self.as_mut().project(); 184 | 185 | match this.inner.as_mut().project() { 186 | TokenProject::InFlight(in_flight) => match in_flight.poll(cx) { 187 | Poll::Ready(Ok(chunking)) => { 188 | this.inner 189 | .project_replace(TokenFutureInner::Chunking(chunking)); 190 | 191 | self.poll(cx) 192 | } 193 | Poll::Ready(Err(err)) => Poll::Ready(Err(err)), 194 | Poll::Pending => Poll::Pending, 195 | }, 196 | TokenProject::Chunking(chunking) => match chunking.poll(cx) { 197 | Poll::Ready(Ok((bytes, _))) => { 198 | let res = serde_json::from_slice(&bytes) 199 | .map_err(|source| OsuError::Parsing { bytes, source }); 200 | 201 | Poll::Ready(res) 202 | } 203 | Poll::Ready(Err(err)) => Poll::Ready(Err(err)), 204 | Poll::Pending => Poll::Pending, 205 | }, 206 | TokenProject::Completed(err) => match err.take() { 207 | Some(source) => Poll::Ready(Err(source)), 208 | None => panic!("future already completed"), 209 | }, 210 | } 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /src/future/traits.rs: -------------------------------------------------------------------------------- 1 | use std::future::IntoFuture; 2 | 3 | use bytes::Bytes; 4 | use serde::de::DeserializeOwned; 5 | 6 | use crate::{error::OsuError, model::ContainedUsers, request::Request, OsuResult}; 7 | 8 | /// Converting `Self` into an [`OsuFuture`]. 9 | /// 10 | /// [`OsuFuture`]: crate::future::OsuFuture 11 | #[allow( 12 | private_bounds, 13 | reason = "users should not implement this trait anyway" 14 | )] 15 | pub trait OsuFutureData: IntoFuture> { 16 | /// The received [`Bytes`] will be turned into this type. 17 | type FromBytes: FromBytes; 18 | /// Post processing will convert [`Self::FromBytes`] into this type. 19 | type OsuOutput: ContainedUsers; 20 | /// Auxiliary data to create a request from a user. 21 | type FromUserData; 22 | /// Auxiliary data used for post processing. 23 | type PostProcessData; 24 | } 25 | 26 | /// Converting [`Bytes`] into `OsuResult`. 27 | pub(crate) trait FromBytes: Sized { 28 | fn from_bytes(bytes: Bytes) -> OsuResult; 29 | } 30 | 31 | /// [`Bytes`] wrapper to implement [`FromBytes`] for bytes. 32 | #[doc(hidden)] 33 | pub struct BytesWrap(pub(crate) Bytes); 34 | 35 | impl FromBytes for BytesWrap { 36 | fn from_bytes(bytes: Bytes) -> OsuResult { 37 | Ok(Self(bytes)) 38 | } 39 | } 40 | 41 | impl FromBytes for T { 42 | fn from_bytes(bytes: Bytes) -> OsuResult { 43 | serde_json::from_slice(&bytes).map_err(|source| OsuError::Parsing { bytes, source }) 44 | } 45 | } 46 | 47 | /// Auxiliary trait to simplify logic within the [`into_future!`] macro. 48 | pub(crate) trait IntoPostProcessData { 49 | fn into_data(self) -> (Request, D); 50 | } 51 | 52 | impl IntoPostProcessData<()> for Request { 53 | fn into_data(self) -> (Request, ()) { 54 | (self, ()) 55 | } 56 | } 57 | 58 | impl IntoPostProcessData for (Request, D) { 59 | fn into_data(self) -> (Request, D) { 60 | self 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! rosu-v2 is a wrapper for the [osu!api v2]. 2 | //! As such, it provides a bunch of additional endpoints and data over [`rosu`] which wraps the [osu!api v1]. 3 | //! 4 | //! Feel free to open an issue when things don't work as expected. 5 | //! 6 | //! The branch `rosu-v2/main` should mirror the last published version. Upcoming changes 7 | //! will generally be added to the `rosu-v2/lazer` branch. If you want to stay up-to-date 8 | //! and use the `lazer` branch, you can add this in your `Cargo.toml`: 9 | //! 10 | //! ```toml 11 | //! rosu-v2 = { git = "https://github.com/MaxOhn/rosu-v2", branch = "lazer" } 12 | //! ``` 13 | //! 14 | //! ## Authentication 15 | //! 16 | //! Unlike api v1, api v2 does not require an api key by users. Instead, it requires a client id and a client secret. 17 | //! 18 | //! To get those, you must register an application [here](https://osu.ppy.sh/home/account/edit#new-oauth-application). 19 | //! Unless you're interested in logging into the API through an osu! account, the callback URL here does not matter and can be left blank. 20 | //! 21 | //! If you went through the OAuth process for a user, you can provide the callback URL and received code 22 | //! when creating the client in order to make requests on behalf of the authenticated user. 23 | //! 24 | //! ## Endpoints 25 | //! 26 | //! The following endpoints are currently supported: 27 | //! 28 | //! - `beatmaps/lookup`: A specific beatmap including its beatmapset 29 | //! - `beatmaps`: Up to 50 beatmaps at once including their beatmapsets 30 | //! - `beatmaps/{map_id}/attributes`: The difficulty attributes of a beatmap 31 | //! - `beatmaps/{map_id}/scores`: The global score leaderboard for a beatmap 32 | //! - `beatmaps/{map_id}/scores/users/{user_id}[/all]`: Get (all) top score(s) of a user on a beatmap. Defaults to the play with the __max score__, not pp 33 | //! - `beatmapsets/{mapset_id}`: The beatmapset including all of its difficulty beatmaps 34 | //! - `beatmapsets/events`: Various events around a beatmapset such as status, genre, or language updates, kudosu transfers, or new issues 35 | //! - `beatmapsets/search`: Search for beatmapsets; the same search as on the osu! website 36 | //! - `comments`: Most recent comments and their replies up to two levels deep 37 | //! - `events`: Collection of events in order of creation time 38 | //! - `forums/topics/{topic_id}`: A forum topic and its posts 39 | //! - `friends`: List of authenticated user's friends 40 | //! - `matches`: List of currently open multiplayer lobbies 41 | //! - `matches/{match_id}`: More specific data about a specific multiplayer lobby including participating players and occured events 42 | //! - `me[/{mode}]`: Detailed info about the authenticated user [in the specified mode] (requires OAuth) 43 | //! - `news`: Recent news 44 | //! - `rankings/{mode}/{ranking_type}`: The global leaderboard of either performance points, ranked score, countries, or a spotlight 45 | //! - `users/{user_id}/{recent_activity}`: List of a user's recent events like achieved medals, ranks on a beatmaps, username changes, supporter status updates, beatmapset status updates, ... 46 | //! - `scores/{mode}/{score_id}`: A specific score including its beatmap, beatmapset, and user 47 | //! - `scores`: Up to 1000 most recently processed scores (passes) 48 | //! - `seasonal-backgrounds`: List of seasonal backgrounds i.e. their URL and artists 49 | //! - `spotlights`: List of overviews of all spotlights 50 | //! - `users/{user_id}[/{mode}]`: Detailed info about a user [in the specified mode] 51 | //! - `users/{user_id}/{beatmapsets/{map_type}`: List of beatmapsets either created, favourited, or most played by the user 52 | //! - `users/{user_id}/kudosu`: A user's recent kudosu transfers 53 | //! - `users/{user_id}/scores/{score_type}`: Either top, recent, pinned, or global #1 scores of a user 54 | //! - `users`: Up to 50 users at once including statistics for all modes 55 | //! - `wiki/{locale}[/{path}]`: The general wiki page or a specific topic if the path is specified 56 | //! 57 | //! The api itself provides a bunch more endpoints which are not yet implemented because they're either niche and/or missing any documentation. 58 | //! 59 | //! If you find an endpoint on the [api page](https://osu.ppy.sh/docs/index.html) that you want to use but is missing in rosu-v2, feel free to open an issue. 60 | //! 61 | //! ## Usage 62 | //! 63 | //! ```no_run 64 | //! // For convenience sake, all types can be found in the prelude module 65 | //! use rosu_v2::prelude::*; 66 | //! 67 | //! # fn main() { 68 | //! # /* 69 | //! #[tokio::main] 70 | //! async fn main() { 71 | //! # */ 72 | //! # let _ = async { 73 | //! // Initialize the client 74 | //! let client_id: u64 = 123; 75 | //! let client_secret = String::from("my_secret"); 76 | //! let osu = Osu::new(client_id, client_secret).await.unwrap(); 77 | //! 78 | //! // Get peppy's top 10-15 scores in osu!standard. 79 | //! // Note that the username here can only be used because of the `cache` feature. 80 | //! // If you are fine with just providing user ids, consider disabling this feature. 81 | //! let scores: Vec = osu.user_scores("peppy") 82 | //! .mode(GameMode::Osu) 83 | //! .best() // top scores; alternatively .recent(), .pinned(), or .firsts() 84 | //! .offset(10) 85 | //! .limit(5) 86 | //! .await 87 | //! .unwrap(); 88 | //! 89 | //! // Search non-nsfw loved mania maps matching the given query. 90 | //! // Note that the order of called methods doesn't matter for any endpoint. 91 | //! let search_result: BeatmapsetSearchResult = osu.beatmapset_search() 92 | //! .nsfw(false) 93 | //! .status(Some(RankStatus::Loved)) 94 | //! .mode(GameMode::Mania) 95 | //! .query("blue army stars>3") 96 | //! .await 97 | //! .unwrap(); 98 | //! 99 | //! // Get the french wiki page on the osu file format 100 | //! let wiki_page: WikiPage = osu.wiki("fr") 101 | //! .page("Client/File_formats/osu_%28file_format%29") 102 | //! .await 103 | //! .unwrap(); 104 | //! # }; 105 | //! } 106 | //! ``` 107 | //! 108 | //! ## Features 109 | //! 110 | //! | Flag | Description | Dependencies 111 | //! | ------------- | ---------------------------------------- | ------------ 112 | //! | `default` | Enable the `cache` and `macros` features | 113 | //! | `cache` | Cache username-userid pairs so that fetching data by username does one instead of two requests | [`dashmap`] 114 | //! | `macros` | Re-exports `rosu-mods`'s `mods!` macro to easily create mods for a given mode | [`paste`] 115 | //! | `serialize` | Implement `serde::Serialize` for most types, allowing for manual serialization | 116 | //! | `metrics` | Uses the global metrics registry to store response time for each endpoint | [`metrics`] 117 | //! | `replay` | Enables the method `Osu::replay` to parse a replay. Note that `Osu::replay_raw` is available without this feature but provides raw bytes instead of a parsed replay | [`osu-db`] 118 | //! | `local_oauth` | Enables the method `OsuBuilder::with_local_authorization` to perform the full OAuth procedure | `tokio/net` feature 119 | //! 120 | //! [osu!api v2]: https://osu.ppy.sh/docs/index.html 121 | //! [`rosu`]: https://github.com/MaxOhn/rosu 122 | //! [osu!api v1]: https://github.com/ppy/osu-api/wiki 123 | //! [`dashmap`]: https://docs.rs/dashmap 124 | //! [`paste`]: https://docs.rs/paste 125 | //! [`metrics`]: https://docs.rs/metrics 126 | //! [`osu-db`]: https://docs.rs/osu-db 127 | 128 | #![cfg_attr(docsrs, feature(doc_cfg))] 129 | #![deny(rustdoc::broken_intra_doc_links, rustdoc::missing_crate_level_docs)] 130 | #![warn(clippy::missing_const_for_fn, clippy::pedantic)] 131 | #![allow( 132 | clippy::missing_errors_doc, 133 | clippy::module_name_repetitions, 134 | clippy::must_use_candidate, 135 | clippy::struct_excessive_bools, 136 | clippy::match_same_arms, 137 | clippy::cast_possible_truncation, 138 | clippy::cast_precision_loss, 139 | clippy::cast_sign_loss, 140 | clippy::explicit_iter_loop, 141 | clippy::similar_names, 142 | clippy::cast_possible_wrap, 143 | clippy::default_trait_access, 144 | clippy::ignored_unit_patterns 145 | )] 146 | 147 | mod client; 148 | mod future; 149 | mod routing; 150 | 151 | /// Errors types 152 | pub mod error; 153 | 154 | /// All available data types provided by the api 155 | pub mod model; 156 | 157 | /// Request related types to fetch from endpoints 158 | pub mod request; 159 | 160 | mod metrics; 161 | 162 | pub use self::client::{Osu, OsuBuilder}; 163 | 164 | #[macro_use] 165 | extern crate tracing; 166 | 167 | #[cfg(feature = "macros")] 168 | extern crate rosu_mods; 169 | 170 | #[cfg(feature = "macros")] 171 | #[cfg_attr(docsrs, doc(cfg(feature = "macros")))] 172 | pub use rosu_mods::mods; 173 | 174 | /// `Result<_, OsuError>` 175 | pub type OsuResult = Result; 176 | 177 | /// All types except requesting, stuffed into one module 178 | pub mod prelude { 179 | pub use crate::{ 180 | client::{Scopes, Token}, 181 | error::OsuError, 182 | model::{ 183 | beatmap::*, 184 | comments::*, 185 | event::*, 186 | forum::*, 187 | kudosu::*, 188 | matches::*, 189 | mods::{generated_mods::*, Acronym, GameMods, GameModsIntermode, GameModsLegacy}, 190 | news::*, 191 | ranking::*, 192 | score::*, 193 | seasonal_backgrounds::*, 194 | user::*, 195 | wiki::*, 196 | GameMode, Grade, 197 | }, 198 | request::UserId, 199 | Osu, OsuBuilder, OsuResult, 200 | }; 201 | 202 | pub use hyper::StatusCode; 203 | pub use smallstr::SmallString; 204 | 205 | #[cfg(feature = "macros")] 206 | #[cfg_attr(docsrs, doc(cfg(feature = "macros")))] 207 | pub use rosu_mods::mods; 208 | } 209 | -------------------------------------------------------------------------------- /src/metrics.rs: -------------------------------------------------------------------------------- 1 | #![cfg(feature = "metrics")] 2 | 3 | use metrics::{describe_histogram, Unit}; 4 | 5 | pub(crate) const RESPONSE_TIME: &str = "osu_response_time"; 6 | pub(crate) const USERNAME_CACHE_SIZE: &str = "osu_username_cache_size"; 7 | 8 | pub(crate) fn init_metrics() { 9 | describe_histogram!( 10 | RESPONSE_TIME, 11 | Unit::Seconds, 12 | "Response time for requests in seconds" 13 | ); 14 | 15 | #[cfg(feature = "cache")] 16 | metrics::describe_counter!( 17 | USERNAME_CACHE_SIZE, 18 | Unit::Count, 19 | "Number of cached usernames" 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/model/comments.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | 3 | use serde::{Deserialize, Serializer}; 4 | use time::OffsetDateTime; 5 | 6 | use crate::{prelude::Username, request::GetUser, Osu, OsuResult}; 7 | 8 | use super::{serde_util, user::User, CacheUserFn, ContainedUsers}; 9 | 10 | /// Represents an single comment. 11 | #[derive(Clone, Debug, Deserialize)] 12 | #[cfg_attr(feature = "serialize", derive(serde::Serialize))] 13 | pub struct Comment { 14 | /// the ID of the comment 15 | #[serde(rename = "id")] 16 | pub comment_id: u32, 17 | /// ID of the object the comment is attached to 18 | pub commentable_id: u32, 19 | /// type of object the comment is attached to 20 | pub commentable_type: String, 21 | /// ISO 8601 date 22 | #[serde(with = "serde_util::datetime")] 23 | pub created_at: OffsetDateTime, 24 | /// ISO 8601 date if the comment was deleted; `None`, otherwise 25 | #[serde( 26 | default, 27 | skip_serializing_if = "Option::is_none", 28 | with = "serde_util::option_datetime" 29 | )] 30 | pub deleted_at: Option, 31 | /// ISO 8601 date if the comment was edited; `None`, otherwise 32 | #[serde( 33 | default, 34 | skip_serializing_if = "Option::is_none", 35 | with = "serde_util::option_datetime" 36 | )] 37 | pub edited_at: Option, 38 | /// user id of the user that edited the post; `None`, otherwise 39 | #[serde(default, skip_serializing_if = "Option::is_none")] 40 | pub edited_by_id: Option, 41 | /// username displayed on legacy comments 42 | #[serde(default, skip_serializing_if = "Option::is_none")] 43 | pub legacy_name: Option, 44 | /// markdown of the comment's content 45 | #[serde(default, skip_serializing_if = "Option::is_none")] 46 | pub message: Option, 47 | /// html version of the comment's content 48 | #[serde(default, skip_serializing_if = "Option::is_none")] 49 | pub message_html: Option, 50 | /// ID of the comment's parent 51 | #[serde(default, skip_serializing_if = "Option::is_none")] 52 | pub parent_id: Option, 53 | /// Pin status of the comment 54 | pub pinned: bool, 55 | /// number of replies to the comment 56 | pub replies_count: u32, 57 | /// ISO 8601 date 58 | #[serde(with = "serde_util::datetime")] 59 | pub updated_at: OffsetDateTime, 60 | /// user ID of the poster 61 | pub user_id: Option, 62 | /// number of votes 63 | pub votes_count: u32, 64 | } 65 | 66 | impl Comment { 67 | /// Request the [`UserExtended`](crate::model::user::UserExtended) of a comment. 68 | /// 69 | /// Only works if `user_id` is Some. 70 | #[inline] 71 | pub fn get_user<'o>(&self, osu: &'o Osu) -> Option> { 72 | self.user_id.map(|id| osu.user(id)) 73 | } 74 | } 75 | 76 | impl PartialEq for Comment { 77 | #[inline] 78 | fn eq(&self, other: &Self) -> bool { 79 | self.comment_id == other.comment_id && self.user_id == other.user_id 80 | } 81 | } 82 | 83 | impl Eq for Comment {} 84 | 85 | /// Comments and related data. 86 | #[derive(Clone, Debug, Deserialize, PartialEq)] 87 | #[cfg_attr(feature = "serialize", derive(serde::Serialize))] 88 | pub struct CommentBundle { 89 | /// ID of the object the comment is attached to 90 | pub commentable_meta: Vec, 91 | /// List of comments ordered according to `sort` 92 | pub comments: Vec, 93 | #[serde( 94 | default, 95 | rename = "cursor_string", 96 | skip_serializing_if = "Option::is_none" 97 | )] 98 | pub(crate) cursor: Option>, 99 | /// If there are more comments or replies available 100 | pub(crate) has_more: bool, 101 | #[serde(default, skip_serializing_if = "Option::is_none")] 102 | pub has_more_id: Option, 103 | /// Related comments; e.g. parent comments and nested replies 104 | pub included_comments: Vec, 105 | /// Pinned comments 106 | #[serde(default, skip_serializing_if = "Option::is_none")] 107 | pub pinned_comments: Option>, 108 | /// order of comments 109 | pub sort: CommentSort, 110 | /// Number of comments at the top level. Not returned for replies. 111 | #[serde(default, skip_serializing_if = "Option::is_none")] 112 | pub top_level_count: Option, 113 | /// Total number of comments. Not retuned for replies. 114 | #[serde(default, skip_serializing_if = "Option::is_none")] 115 | pub total: Option, 116 | /// is the current user watching the comment thread? 117 | pub user_follow: bool, 118 | /// IDs of the comments in the bundle the current user has upvoted 119 | pub user_votes: Vec, 120 | /// List of users related to the comments 121 | pub users: Vec, 122 | } 123 | 124 | impl CommentBundle { 125 | /// Returns whether there is a next page of comments, 126 | /// retrievable via [`get_next`](CommentBundle::get_next). 127 | #[inline] 128 | pub const fn has_more(&self) -> bool { 129 | self.has_more 130 | } 131 | 132 | /// If [`has_more`](CommentBundle::has_more) is true, the API can provide 133 | /// the next set of comments and this method will request them. Otherwise, 134 | /// this method returns `None`. 135 | #[inline] 136 | pub async fn get_next(&self, osu: &Osu) -> Option> { 137 | debug_assert!(self.has_more == self.cursor.is_some()); 138 | 139 | Some(osu.comments().cursor(self.cursor.as_deref()?).await) 140 | } 141 | } 142 | 143 | impl ContainedUsers for CommentBundle { 144 | fn apply_to_users(&self, f: impl CacheUserFn) { 145 | self.users.apply_to_users(f); 146 | } 147 | } 148 | 149 | /// Available orders for comments 150 | #[derive(Copy, Clone, Debug, Deserialize, Eq, PartialEq)] 151 | #[cfg_attr(feature = "serialize", derive(serde::Serialize))] 152 | pub enum CommentSort { 153 | /// Sort by date, newest first 154 | #[serde(rename = "new")] 155 | New, 156 | /// Sort by date, oldest first 157 | #[serde(rename = "old")] 158 | Old, 159 | /// Sort by vote count 160 | #[serde(rename = "top")] 161 | Top, 162 | } 163 | 164 | impl CommentSort { 165 | pub const fn as_str(self) -> &'static str { 166 | match self { 167 | Self::New => "new", 168 | Self::Old => "old", 169 | Self::Top => "top", 170 | } 171 | } 172 | 173 | #[allow(clippy::trivially_copy_pass_by_ref)] 174 | pub(crate) fn serialize_as_query( 175 | &self, 176 | serializer: S, 177 | ) -> Result { 178 | serializer.serialize_str(self.as_str()) 179 | } 180 | } 181 | 182 | impl fmt::Display for CommentSort { 183 | #[inline] 184 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 185 | f.write_str(self.as_str()) 186 | } 187 | } 188 | 189 | /// Metadata of the object that a comment is attached to. 190 | #[derive(Clone, Debug, Deserialize, Eq, PartialEq)] 191 | #[cfg_attr(feature = "serialize", derive(serde::Serialize))] 192 | #[serde(untagged)] 193 | pub enum CommentableMeta { 194 | Full { 195 | /// the ID of the object 196 | id: u32, 197 | /// the type of the object 198 | #[serde(rename = "type")] 199 | kind: String, 200 | owner_id: u32, 201 | owner_title: String, 202 | /// display title 203 | title: String, 204 | /// url of the object 205 | url: String, 206 | }, 207 | Title { 208 | /// display title 209 | title: String, 210 | }, 211 | } 212 | -------------------------------------------------------------------------------- /src/model/event.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | 3 | use serde::{de, Deserialize}; 4 | use time::OffsetDateTime; 5 | 6 | use crate::{Osu, OsuResult}; 7 | 8 | use super::{ 9 | beatmap::RankStatus, 10 | serde_util, 11 | user::{Medal, Username}, 12 | CacheUserFn, ContainedUsers, GameMode, Grade, 13 | }; 14 | 15 | #[derive(Clone, Debug, Deserialize)] 16 | #[cfg_attr(feature = "serialize", derive(serde::Serialize))] 17 | pub struct Events { 18 | pub events: Vec, 19 | #[serde(rename = "cursor_string", skip_serializing_if = "Option::is_none")] 20 | pub cursor: Option, 21 | #[serde(skip_serializing_if = "Option::is_none")] 22 | pub(crate) sort: Option, 23 | } 24 | 25 | impl Events { 26 | /// Returns whether there is a next page of events, retrievable via 27 | /// [`get_next`](Events::get_next). 28 | #[inline] 29 | pub const fn has_more(&self) -> bool { 30 | self.cursor.is_some() 31 | } 32 | 33 | /// If [`has_more`](Events::has_more) is true, the API can provide the next 34 | /// set of events and this method will request them. Otherwise, this method 35 | /// returns `None`. 36 | pub async fn get_next(&self, osu: &Osu) -> Option> { 37 | let cursor = self.cursor.as_deref()?; 38 | 39 | let fut = osu 40 | .events() 41 | .cursor(cursor) 42 | .sort(self.sort.unwrap_or_default()); 43 | 44 | Some(fut.await) 45 | } 46 | } 47 | 48 | impl ContainedUsers for Events { 49 | fn apply_to_users(&self, f: impl CacheUserFn) { 50 | self.events.apply_to_users(f); 51 | } 52 | } 53 | 54 | /// The object has different attributes depending on its type. 55 | #[derive(Clone, Debug, Deserialize)] 56 | #[cfg_attr(feature = "serialize", derive(serde::Serialize))] 57 | pub struct Event { 58 | #[serde(with = "serde_util::datetime")] 59 | pub created_at: OffsetDateTime, 60 | #[serde(rename = "id")] 61 | pub event_id: u32, 62 | #[serde(flatten)] 63 | pub event_type: EventType, 64 | } 65 | 66 | impl ContainedUsers for Event { 67 | fn apply_to_users(&self, _: impl CacheUserFn) {} 68 | } 69 | 70 | #[derive(Clone, Debug, Deserialize)] 71 | #[cfg_attr(feature = "serialize", derive(serde::Serialize))] 72 | pub struct EventBeatmap { 73 | pub title: String, 74 | pub url: String, 75 | } 76 | 77 | #[derive(Clone, Debug, Deserialize)] 78 | #[cfg_attr(feature = "serialize", derive(serde::Serialize))] 79 | pub struct EventBeatmapset { 80 | pub title: String, 81 | pub url: String, 82 | } 83 | 84 | #[derive(Clone, Debug, Deserialize)] 85 | #[cfg_attr(feature = "serialize", derive(serde::Serialize))] 86 | #[serde(rename_all = "camelCase", tag = "type")] 87 | pub enum EventType { 88 | /// When a beatmap has been played for a certain amount of times 89 | BeatmapPlaycount { beatmap: EventBeatmap, count: u32 }, 90 | /// When a beatmapset changes state 91 | BeatmapsetApprove { 92 | approval: RankStatus, 93 | beatmapset: EventBeatmapset, 94 | /// Beatmapset owner 95 | user: EventUser, 96 | }, 97 | /// When a beatmapset is deleted 98 | BeatmapsetDelete { beatmapset: EventBeatmapset }, 99 | /// When a beatmapset in graveyard is updated 100 | BeatmapsetRevive { 101 | beatmapset: EventBeatmapset, 102 | /// Beatmapset owner 103 | user: EventUser, 104 | }, 105 | /// When a beatmapset is updated 106 | BeatmapsetUpdate { 107 | beatmapset: EventBeatmapset, 108 | /// Beatmapset owner 109 | user: EventUser, 110 | }, 111 | /// When a new beatmapset is uploaded 112 | BeatmapsetUpload { 113 | beatmapset: EventBeatmapset, 114 | /// Beatmapset owner 115 | user: EventUser, 116 | }, 117 | /// When a user obtained a medal 118 | #[serde(rename = "achievement")] 119 | Medal { 120 | #[serde(rename = "achievement")] 121 | medal: Medal, 122 | user: EventUser, 123 | }, 124 | /// When a user achieves a certain rank on a beatmap 125 | Rank { 126 | #[serde(rename = "scoreRank")] 127 | grade: Grade, 128 | rank: u32, 129 | mode: GameMode, 130 | beatmap: EventBeatmap, 131 | user: EventUser, 132 | }, 133 | /// When a user loses first place to another user 134 | RankLost { 135 | mode: GameMode, 136 | beatmap: EventBeatmap, 137 | user: EventUser, 138 | }, 139 | /// When a user supports osu! for the second time and onwards 140 | #[serde(rename = "userSupportAgain")] 141 | SupportAgain { user: EventUser }, 142 | /// When a user becomes a supporter for the first time 143 | #[serde(rename = "userSupportFirst")] 144 | SupportFirst { user: EventUser }, 145 | /// When a user is gifted a supporter tag by another user 146 | #[serde(rename = "userSupportGift")] 147 | SupportGift { 148 | /// Recipient user 149 | user: EventUser, 150 | }, 151 | /// When a user changes their username 152 | UsernameChange { 153 | /// Includes `previous_username` 154 | user: EventUser, 155 | }, 156 | } 157 | 158 | #[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] 159 | pub enum EventSort { 160 | #[default] 161 | IdDescending, 162 | IdAscending, 163 | } 164 | 165 | impl EventSort { 166 | pub const fn as_str(&self) -> &'static str { 167 | match self { 168 | Self::IdDescending => "id_desc", 169 | Self::IdAscending => "id_asc", 170 | } 171 | } 172 | } 173 | 174 | impl<'de> de::Deserialize<'de> for EventSort { 175 | fn deserialize>(d: D) -> Result { 176 | struct EventSortVisitor; 177 | 178 | impl de::Visitor<'_> for EventSortVisitor { 179 | type Value = EventSort; 180 | 181 | fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { 182 | f.write_str("id_desc or id_asc") 183 | } 184 | 185 | fn visit_str(self, v: &str) -> Result { 186 | match v { 187 | "id_desc" => Ok(EventSort::IdDescending), 188 | "id_asc" => Ok(EventSort::IdAscending), 189 | _ => Err(de::Error::invalid_value(de::Unexpected::Str(v), &self)), 190 | } 191 | } 192 | } 193 | 194 | d.deserialize_str(EventSortVisitor) 195 | } 196 | } 197 | 198 | impl serde::Serialize for EventSort { 199 | fn serialize(&self, s: S) -> Result { 200 | s.serialize_str(self.as_str()) 201 | } 202 | } 203 | 204 | #[derive(Clone, Debug, Deserialize)] 205 | #[cfg_attr(feature = "serialize", derive(serde::Serialize))] 206 | pub struct EventUser { 207 | pub username: Username, 208 | pub url: String, 209 | /// Only for `UsernameChange` events 210 | #[serde( 211 | default, 212 | rename = "previousUsername", 213 | skip_serializing_if = "Option::is_none" 214 | )] 215 | pub previous_username: Option, 216 | } 217 | -------------------------------------------------------------------------------- /src/model/forum.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | 3 | use serde::{ 4 | de::{Deserializer, Error, IgnoredAny, MapAccess, Visitor}, 5 | Deserialize, 6 | }; 7 | use time::OffsetDateTime; 8 | 9 | use super::{serde_util, CacheUserFn, ContainedUsers}; 10 | 11 | #[derive(Clone, Debug, Deserialize, Eq, PartialEq)] 12 | #[cfg_attr(feature = "serialize", derive(serde::Serialize))] 13 | pub struct ForumPosts { 14 | #[serde( 15 | default, 16 | rename = "cursor_string", 17 | skip_serializing_if = "Option::is_none" 18 | )] 19 | pub cursor: Option, 20 | pub posts: Vec, 21 | pub search: ForumPostsSearch, 22 | pub topic: ForumTopic, 23 | } 24 | 25 | impl ForumPosts { 26 | /// Checks whether the cursor field is `Some` which in turn 27 | /// can be used to retrieve the next set of posts. 28 | /// 29 | /// The next set can then be retrieved by providing this cursor to 30 | /// [`GetForumPosts::cursor`](crate::request::GetForumPosts::cursor). 31 | /// Be sure all other parameters stay the same. 32 | #[inline] 33 | pub const fn has_more(&self) -> bool { 34 | self.cursor.is_some() 35 | } 36 | } 37 | 38 | impl ContainedUsers for ForumPosts { 39 | fn apply_to_users(&self, _: impl CacheUserFn) {} 40 | } 41 | 42 | #[derive(Clone, Debug)] 43 | #[cfg_attr(feature = "serialize", derive(serde::Serialize))] 44 | pub struct ForumPost { 45 | #[cfg_attr(feature = "serialize", serde(with = "serde_util::datetime"))] 46 | pub created_at: OffsetDateTime, 47 | #[cfg_attr( 48 | feature = "serialize", 49 | serde( 50 | default, 51 | skip_serializing_if = "Option::is_none", 52 | with = "serde_util::option_datetime" 53 | ) 54 | )] 55 | pub deleted_at: Option, 56 | #[cfg_attr( 57 | feature = "serialize", 58 | serde( 59 | default, 60 | skip_serializing_if = "Option::is_none", 61 | with = "serde_util::option_datetime" 62 | ) 63 | )] 64 | pub edited_at: Option, 65 | #[cfg_attr(feature = "serialize", serde(skip_serializing_if = "Option::is_none"))] 66 | pub edited_by_id: Option, 67 | pub forum_id: u32, 68 | /// Post content in HTML format 69 | pub html: String, 70 | #[cfg_attr(feature = "serialize", serde(rename = "id"))] 71 | pub post_id: u64, 72 | /// Post content in `BBCode` format 73 | pub raw: String, 74 | pub topic_id: u64, 75 | pub user_id: u32, 76 | } 77 | 78 | struct ForumPostVisitor; 79 | 80 | #[derive(Deserialize)] 81 | struct ForumPostBody { 82 | html: String, 83 | raw: String, 84 | } 85 | 86 | impl<'de> Visitor<'de> for ForumPostVisitor { 87 | type Value = ForumPost; 88 | 89 | #[inline] 90 | fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 91 | f.write_str("a ForumPost struct") 92 | } 93 | 94 | fn visit_map>(self, mut map: A) -> Result { 95 | #[derive(Deserialize)] 96 | struct DateTimeWrapper(#[serde(with = "serde_util::datetime")] OffsetDateTime); 97 | 98 | #[derive(Deserialize)] 99 | struct OptionDateTimeWrapper( 100 | #[serde(with = "serde_util::option_datetime")] Option, 101 | ); 102 | 103 | let mut created_at: Option = None; 104 | let mut deleted_at: Option = None; 105 | let mut edited_at: Option = None; 106 | let mut edited_by_id = None; 107 | let mut forum_id = None; 108 | let mut html = None; 109 | let mut post_id = None; 110 | let mut raw = None; 111 | let mut topic_id = None; 112 | let mut user_id = None; 113 | 114 | while let Some(key) = map.next_key()? { 115 | match key { 116 | "body" => { 117 | let body: ForumPostBody = map.next_value()?; 118 | 119 | html = Some(body.html); 120 | raw = Some(body.raw); 121 | } 122 | "created_at" => created_at = Some(map.next_value()?), 123 | "deleted_at" => deleted_at = Some(map.next_value()?), 124 | "edited_at" => edited_at = Some(map.next_value()?), 125 | "edited_by_id" => edited_by_id = map.next_value()?, 126 | "forum_id" => forum_id = Some(map.next_value()?), 127 | "html" => html = Some(map.next_value()?), 128 | "id" => post_id = Some(map.next_value()?), 129 | "raw" => raw = Some(map.next_value()?), 130 | "topic_id" => topic_id = Some(map.next_value()?), 131 | "user_id" => user_id = Some(map.next_value()?), 132 | _ => { 133 | let _: IgnoredAny = map.next_value()?; 134 | } 135 | } 136 | } 137 | 138 | let DateTimeWrapper(created_at) = 139 | created_at.ok_or_else(|| Error::missing_field("created_at"))?; 140 | let forum_id = forum_id.ok_or_else(|| Error::missing_field("forum_id"))?; 141 | let html = html.ok_or_else(|| Error::missing_field("body or html"))?; 142 | let post_id = post_id.ok_or_else(|| Error::missing_field("id"))?; 143 | let raw = raw.ok_or_else(|| Error::missing_field("body or raw"))?; 144 | let topic_id = topic_id.ok_or_else(|| Error::missing_field("topic_id"))?; 145 | let user_id = user_id.ok_or_else(|| Error::missing_field("user_id"))?; 146 | 147 | Ok(ForumPost { 148 | created_at, 149 | deleted_at: deleted_at.and_then(|wrapper| wrapper.0), 150 | edited_at: edited_at.and_then(|wrapper| wrapper.0), 151 | edited_by_id, 152 | forum_id, 153 | html, 154 | post_id, 155 | raw, 156 | topic_id, 157 | user_id, 158 | }) 159 | } 160 | } 161 | 162 | impl<'de> Deserialize<'de> for ForumPost { 163 | #[inline] 164 | fn deserialize>(d: D) -> Result { 165 | d.deserialize_map(ForumPostVisitor) 166 | } 167 | } 168 | 169 | impl PartialEq for ForumPost { 170 | #[inline] 171 | fn eq(&self, other: &Self) -> bool { 172 | self.post_id == other.post_id && self.edited_at == other.edited_at 173 | } 174 | } 175 | 176 | impl Eq for ForumPost {} 177 | 178 | #[derive(Clone, Debug, Deserialize, Eq, PartialEq)] 179 | #[cfg_attr(feature = "serialize", derive(serde::Serialize))] 180 | pub struct ForumPostsSearch { 181 | pub limit: u32, 182 | pub sort: String, 183 | } 184 | 185 | #[derive(Clone, Debug, Deserialize)] 186 | #[cfg_attr(feature = "serialize", derive(serde::Serialize))] 187 | pub struct ForumTopic { 188 | #[serde(with = "serde_util::datetime")] 189 | pub created_at: OffsetDateTime, 190 | #[serde( 191 | default, 192 | skip_serializing_if = "Option::is_none", 193 | with = "serde_util::option_datetime" 194 | )] 195 | pub deleted_at: Option, 196 | pub first_post_id: u64, 197 | pub forum_id: u32, 198 | pub is_locked: bool, 199 | #[serde(rename = "type")] 200 | pub kind: String, // TODO 201 | pub last_post_id: u64, 202 | pub post_count: u32, 203 | pub title: String, 204 | #[serde(rename = "id")] 205 | pub topic_id: u64, 206 | #[serde( 207 | default, 208 | skip_serializing_if = "Option::is_none", 209 | with = "serde_util::option_datetime" 210 | )] 211 | pub updated_at: Option, 212 | pub user_id: u32, 213 | } 214 | 215 | impl PartialEq for ForumTopic { 216 | #[inline] 217 | fn eq(&self, other: &Self) -> bool { 218 | self.topic_id == other.topic_id && self.updated_at == other.updated_at 219 | } 220 | } 221 | 222 | impl Eq for ForumTopic {} 223 | -------------------------------------------------------------------------------- /src/model/grade.rs: -------------------------------------------------------------------------------- 1 | use crate::error::{OsuError, ParsingError}; 2 | 3 | use serde::{ 4 | de::{Error, Unexpected, Visitor}, 5 | Deserialize, Deserializer, 6 | }; 7 | use std::{fmt, str::FromStr}; 8 | 9 | /// Enum for a [`Score`](crate::model::score::Score)'s grade (sometimes called rank) 10 | #[allow(clippy::upper_case_acronyms, missing_docs)] 11 | #[derive(Copy, Clone, Hash, Debug, Eq, PartialEq, PartialOrd)] 12 | #[cfg_attr(feature = "serialize", derive(serde::Serialize))] 13 | pub enum Grade { 14 | F, 15 | D, 16 | C, 17 | B, 18 | A, 19 | S, 20 | SH, 21 | X, 22 | XH, 23 | } 24 | 25 | impl Grade { 26 | /// Check two grades for equality, ignoring silver-/regular-S difference 27 | /// 28 | /// # Example 29 | /// ``` 30 | /// use rosu_v2::model::Grade; 31 | /// 32 | /// assert!(Grade::S.eq_letter(Grade::SH)); 33 | /// assert!(!Grade::X.eq_letter(Grade::SH)); 34 | /// ``` 35 | #[inline] 36 | pub fn eq_letter(self, other: Grade) -> bool { 37 | match self { 38 | Grade::XH | Grade::X => other == Grade::XH || other == Grade::X, 39 | Grade::SH | Grade::S => other == Grade::SH || other == Grade::S, 40 | _ => self == other, 41 | } 42 | } 43 | } 44 | 45 | impl FromStr for Grade { 46 | type Err = OsuError; 47 | 48 | fn from_str(grade: &str) -> Result { 49 | let grade = match grade.to_uppercase().as_str() { 50 | "XH" | "SSH" => Self::XH, 51 | "X" | "SS" => Self::X, 52 | "SH" => Self::SH, 53 | "S" => Self::S, 54 | "A" => Self::A, 55 | "B" => Self::B, 56 | "C" => Self::C, 57 | "D" => Self::D, 58 | "F" => Self::F, 59 | _ => return Err(ParsingError::Grade(grade.to_owned()).into()), 60 | }; 61 | 62 | Ok(grade) 63 | } 64 | } 65 | 66 | impl fmt::Display for Grade { 67 | #[inline] 68 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 69 | fmt::Debug::fmt(self, f) 70 | } 71 | } 72 | 73 | struct GradeVisitor; 74 | 75 | impl Visitor<'_> for GradeVisitor { 76 | type Value = Grade; 77 | 78 | #[inline] 79 | fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 80 | formatter.write_str("a string") 81 | } 82 | 83 | #[inline] 84 | fn visit_str(self, v: &str) -> Result { 85 | Grade::from_str(v).map_err(|_| Error::invalid_value(Unexpected::Str(v), &"a grade string")) 86 | } 87 | } 88 | 89 | impl<'de> Deserialize<'de> for Grade { 90 | #[inline] 91 | fn deserialize>(d: D) -> Result { 92 | d.deserialize_any(GradeVisitor) 93 | } 94 | } 95 | 96 | #[cfg(test)] 97 | mod tests { 98 | use super::*; 99 | 100 | #[test] 101 | fn grade_eq() { 102 | assert!(Grade::SH.eq_letter(Grade::S)); 103 | } 104 | 105 | #[test] 106 | fn grade_neq() { 107 | assert!(!Grade::S.eq_letter(Grade::A)); 108 | } 109 | 110 | #[test] 111 | fn grade_ord() { 112 | assert!(Grade::S > Grade::A); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/model/kudosu.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | use time::OffsetDateTime; 3 | 4 | use crate::model::user::Username; 5 | 6 | use super::{CacheUserFn, ContainedUsers}; 7 | 8 | #[derive(Copy, Clone, Debug, Deserialize, Eq, PartialEq)] 9 | #[cfg_attr(feature = "serialize", derive(serde::Serialize))] 10 | pub enum KudosuAction { 11 | #[serde(rename = "recalculate.reset")] 12 | RecalculateReset, 13 | #[serde(rename = "vote.give")] 14 | VoteGive, 15 | #[serde(rename = "vote.revoke")] 16 | VoteRevoke, 17 | #[serde(rename = "vote.reset")] 18 | VoteReset, 19 | } 20 | 21 | #[derive(Clone, Debug, Deserialize, Eq, PartialEq)] 22 | #[cfg_attr(feature = "serialize", derive(serde::Serialize))] 23 | pub struct KudosuGiver { 24 | pub url: String, 25 | pub username: Username, 26 | } 27 | 28 | #[derive(Clone, Debug, Deserialize)] 29 | #[cfg_attr(feature = "serialize", derive(serde::Serialize))] 30 | pub struct KudosuHistory { 31 | pub id: u32, 32 | /// Either `give`, `reset`, or `revoke`. 33 | pub action: KudosuAction, 34 | pub amount: i32, 35 | // pub details: _; // TODO 36 | /// Object type which the exchange happened on (`forum_post`, etc). 37 | pub model: String, 38 | #[serde(with = "super::serde_util::datetime")] 39 | pub created_at: OffsetDateTime, 40 | /// Simple detail of the user who started the exchange. 41 | #[serde(default, skip_serializing_if = "Option::is_none")] 42 | pub giver: Option, 43 | /// Simple detail of the object for display. 44 | pub post: KudosuPost, 45 | } 46 | 47 | impl ContainedUsers for KudosuHistory { 48 | fn apply_to_users(&self, _: impl CacheUserFn) {} 49 | } 50 | 51 | impl PartialEq for KudosuHistory { 52 | #[inline] 53 | fn eq(&self, other: &Self) -> bool { 54 | self.id == other.id 55 | } 56 | } 57 | 58 | impl Eq for KudosuHistory {} 59 | 60 | #[derive(Clone, Debug, Deserialize, Eq, PartialEq)] 61 | #[cfg_attr(feature = "serialize", derive(serde::Serialize))] 62 | pub struct KudosuPost { 63 | /// Url of the object. 64 | #[serde(default, skip_serializing_if = "Option::is_none")] 65 | pub url: Option, 66 | /// Title of the object. It'll be "[deleted beatmap]" for deleted beatmaps. 67 | pub title: String, 68 | } 69 | -------------------------------------------------------------------------------- /src/model/mod.rs: -------------------------------------------------------------------------------- 1 | macro_rules! def_enum { 2 | (@BASE $type:tt { $($variant:ident = $n:literal,)* }) => { 3 | #[allow(clippy::upper_case_acronyms, missing_docs)] 4 | #[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] 5 | #[repr(u8)] 6 | pub enum $type { 7 | $($variant = $n,)* 8 | } 9 | 10 | impl<'de> serde::Deserialize<'de> for $type { 11 | #[inline] 12 | fn deserialize>(d: D) -> Result { 13 | d.deserialize_option(super::EnumVisitor::<$type>::new()) 14 | } 15 | } 16 | 17 | impl From<$type> for u8 { 18 | #[inline] 19 | fn from(v: $type) -> Self { 20 | v as u8 21 | } 22 | } 23 | 24 | impl std::convert::TryFrom for $type { 25 | type Error = crate::error::OsuError; 26 | 27 | #[inline] 28 | fn try_from(value: u8) -> Result { 29 | match value { 30 | $($n => Ok(<$type>::$variant),)* 31 | _ => Err(crate::error::ParsingError::$type(value).into()), 32 | } 33 | } 34 | } 35 | 36 | #[cfg(feature = "serialize")] 37 | impl serde::Serialize for $type { 38 | #[inline] 39 | fn serialize(&self, s: S) -> Result { 40 | s.serialize_u8(*self as u8) 41 | } 42 | } 43 | }; 44 | 45 | (@VISIT $type:tt { $($variant:ident = $n:literal,)* }) => { 46 | #[inline] 47 | fn visit_u64(self, v: u64) -> Result { 48 | match v { 49 | $($n => Ok(<$type>::$variant),)* 50 | _ => { 51 | Err(serde::de::Error::invalid_value(serde::de::Unexpected::Unsigned(v), &stringify!($($n),*))) 52 | }, 53 | } 54 | } 55 | 56 | #[inline] 57 | fn visit_some>(self, d: D) -> Result { 58 | d.deserialize_any(self) 59 | } 60 | 61 | #[inline] 62 | fn visit_none(self) -> Result { 63 | Ok($type::default()) 64 | } 65 | 66 | fn visit_map>(self, mut map: A) -> Result { 67 | let mut result = None; 68 | 69 | while let Some(key) = map.next_key::<&str>()? { 70 | match key { 71 | "id" | "name" => result = Some(map.next_value()?), 72 | _ => { 73 | let _: serde::de::IgnoredAny = map.next_value()?; 74 | } 75 | } 76 | } 77 | 78 | result.ok_or_else(|| serde::de::Error::missing_field("id or name")) 79 | } 80 | }; 81 | 82 | // Main macro with variants as serde strings 83 | ($type:tt { $($variant:ident = $n:literal,)* }) => { 84 | def_enum!(@BASE $type { $($variant = $n,)* }); 85 | 86 | impl<'de> serde::de::Visitor<'de> for super::EnumVisitor<$type> { 87 | type Value = $type; 88 | 89 | #[inline] 90 | fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 91 | f.write_str(concat!(stringify!($($n),*),$(", \"",stringify!($variant),"\"",)*)) 92 | } 93 | 94 | #[inline] 95 | fn visit_str(self, s: &str) -> Result { 96 | match s { 97 | $(stringify!($variant) => Ok(<$type>::$variant),)* 98 | _ => { 99 | Err(serde::de::Error::unknown_variant(s, &[stringify!($($variant),*)])) 100 | }, 101 | } 102 | } 103 | 104 | def_enum!(@VISIT $type { $($variant = $n,)* }); 105 | } 106 | }; 107 | 108 | // Main macro with specified serde strings 109 | ($type:tt { $($variant:ident = $n:literal ($alt:literal),)* }) => { 110 | def_enum!(@BASE $type { $($variant = $n,)* }); 111 | 112 | impl<'de> serde::de::Visitor<'de> for super::EnumVisitor<$type> { 113 | type Value = $type; 114 | 115 | #[inline] 116 | fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 117 | f.write_str(concat!(stringify!($($n),*),", ",stringify!($($alt),*))) 118 | } 119 | 120 | #[inline] 121 | fn visit_str(self, s: &str) -> Result { 122 | match s { 123 | $($alt => Ok(<$type>::$variant),)* 124 | _ => { 125 | Err(serde::de::Error::unknown_variant(s, &[stringify!($($alt),*)])) 126 | }, 127 | } 128 | } 129 | 130 | def_enum!(@VISIT $type { $($variant = $n,)* }); 131 | } 132 | } 133 | } 134 | 135 | mod grade; 136 | mod serde_util; 137 | 138 | /// Beatmap(set) related types 139 | pub mod beatmap; 140 | 141 | /// Comment related types 142 | pub mod comments; 143 | 144 | /// Event related types 145 | pub mod event; 146 | 147 | /// Forum post related types 148 | pub mod forum; 149 | 150 | /// User kudosu related types 151 | pub mod kudosu; 152 | 153 | /// Multiplayer match related types 154 | pub mod matches; 155 | 156 | /// Re-exports of `rosu-mods` 157 | pub mod mods; 158 | 159 | /// News related types 160 | pub mod news; 161 | 162 | /// Ranking related types 163 | pub mod ranking; 164 | 165 | /// Score related types 166 | pub mod score; 167 | 168 | /// Seasonal background related types 169 | pub mod seasonal_backgrounds; 170 | 171 | /// User related types 172 | pub mod user; 173 | 174 | /// Wiki related types 175 | pub mod wiki; 176 | 177 | use std::{collections::HashMap, marker::PhantomData}; 178 | 179 | pub use rosu_mods::GameMode; 180 | 181 | pub use self::{grade::Grade, serde_util::DeserializedList}; 182 | 183 | use self::user::Username; 184 | 185 | struct EnumVisitor(PhantomData); 186 | 187 | impl EnumVisitor { 188 | const fn new() -> Self { 189 | Self(PhantomData) 190 | } 191 | } 192 | 193 | /// Trait alias for `Copy + Fn(u32, &str)`. 194 | /// 195 | /// Awaiting 196 | pub(crate) trait CacheUserFn: Copy + Fn(u32, &Username) {} 197 | 198 | impl CacheUserFn for T {} 199 | 200 | /// A way to apply a given function to all users contained in `Self`. 201 | pub(crate) trait ContainedUsers { 202 | /// Applies `f` to all (user id, username) pairs contained in `self`. 203 | #[cfg_attr( 204 | not(feature = "cache"), 205 | allow(dead_code, reason = "its only used to put all users in the cache") 206 | )] 207 | fn apply_to_users(&self, f: impl CacheUserFn); 208 | } 209 | 210 | impl ContainedUsers for Box { 211 | fn apply_to_users(&self, f: impl CacheUserFn) { 212 | (**self).apply_to_users(f); 213 | } 214 | } 215 | 216 | impl ContainedUsers for Option { 217 | fn apply_to_users(&self, f: impl CacheUserFn) { 218 | if let Some(item) = self { 219 | item.apply_to_users(f); 220 | } 221 | } 222 | } 223 | 224 | impl ContainedUsers for Result { 225 | fn apply_to_users(&self, f: impl CacheUserFn) { 226 | if let Ok(ok) = self { 227 | ok.apply_to_users(f); 228 | } 229 | } 230 | } 231 | 232 | impl ContainedUsers for Vec { 233 | fn apply_to_users(&self, f: impl CacheUserFn) { 234 | for item in self { 235 | item.apply_to_users(f); 236 | } 237 | } 238 | } 239 | 240 | impl ContainedUsers for HashMap { 241 | fn apply_to_users(&self, f: impl CacheUserFn) { 242 | for value in self.values() { 243 | value.apply_to_users(f); 244 | } 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /src/model/mods.rs: -------------------------------------------------------------------------------- 1 | pub use rosu_mods::{ 2 | error, generated_mods, intersection, iter, serde, Acronym, GameMod, GameModIntermode, 3 | GameModKind, GameMods, GameModsIntermode, GameModsLegacy, 4 | }; 5 | 6 | #[cfg(feature = "macros")] 7 | #[cfg_attr(docsrs, doc(cfg(feature = "macros")))] 8 | pub use rosu_mods::mods; 9 | -------------------------------------------------------------------------------- /src/model/news.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | use time::OffsetDateTime; 3 | 4 | use crate::{prelude::Username, Osu, OsuResult}; 5 | 6 | use super::{serde_util, CacheUserFn, ContainedUsers}; 7 | 8 | #[derive(Clone, Debug, Deserialize, Eq, PartialEq)] 9 | #[cfg_attr(feature = "serialize", derive(serde::Serialize))] 10 | pub struct News { 11 | #[serde( 12 | default, 13 | rename = "cursor_string", 14 | skip_serializing_if = "Option::is_none" 15 | )] 16 | pub(crate) cursor: Option, 17 | #[serde(rename = "news_posts")] 18 | pub posts: Vec, 19 | pub search: NewsSearch, 20 | #[serde(rename = "news_sidebar")] 21 | pub sidebar: NewsSidebar, 22 | } 23 | 24 | impl News { 25 | /// Returns whether there is a next page of news results, 26 | /// retrievable via [`get_next`](News::get_next). 27 | #[inline] 28 | pub const fn has_more(&self) -> bool { 29 | self.cursor.is_some() 30 | } 31 | 32 | /// If [`has_more`](News::has_more) is true, the API can provide the next set of news and this method will request them. 33 | /// Otherwise, this method returns `None`. 34 | #[inline] 35 | pub async fn get_next(&self, osu: &Osu) -> Option> { 36 | Some(osu.news().cursor(self.cursor.as_deref()?).await) 37 | } 38 | } 39 | 40 | impl ContainedUsers for News { 41 | fn apply_to_users(&self, _: impl CacheUserFn) {} 42 | } 43 | 44 | #[derive(Clone, Debug, Deserialize)] 45 | #[cfg_attr(feature = "serialize", derive(serde::Serialize))] 46 | pub struct NewsPost { 47 | #[serde(rename = "id")] 48 | pub post_id: u32, 49 | pub author: Username, 50 | /// Link to the file view on GitHub. 51 | pub edit_url: String, 52 | /// Link to the first image in the document. 53 | pub first_image: String, 54 | #[serde(with = "serde_util::datetime")] 55 | pub published_at: OffsetDateTime, 56 | #[serde( 57 | default, 58 | skip_serializing_if = "Option::is_none", 59 | with = "serde_util::option_datetime" 60 | )] 61 | pub updated_at: Option, 62 | /// Filename without the extension, used in URLs. 63 | pub slug: String, 64 | pub title: String, 65 | /// First paragraph of `content` with HTML markup stripped. 66 | #[serde(default, skip_serializing_if = "Option::is_none")] 67 | pub preview: Option, 68 | } 69 | 70 | impl PartialEq for NewsPost { 71 | #[inline] 72 | fn eq(&self, other: &Self) -> bool { 73 | self.post_id == other.post_id && self.updated_at == other.updated_at 74 | } 75 | } 76 | 77 | impl Eq for NewsPost {} 78 | 79 | #[derive(Clone, Debug, Deserialize, Eq, PartialEq)] 80 | #[cfg_attr(feature = "serialize", derive(serde::Serialize))] 81 | pub struct NewsSearch { 82 | #[serde( 83 | default, 84 | rename = "cursor_string", 85 | skip_serializing_if = "Option::is_none" 86 | )] 87 | pub(crate) cursor: Option>, 88 | pub limit: u32, 89 | } 90 | 91 | #[derive(Clone, Debug, Deserialize, Eq, PartialEq)] 92 | #[cfg_attr(feature = "serialize", derive(serde::Serialize))] 93 | pub struct NewsSidebar { 94 | pub current_year: u32, 95 | #[serde(rename = "news_posts")] 96 | pub posts: Vec, 97 | pub years: Vec, 98 | } 99 | -------------------------------------------------------------------------------- /src/model/seasonal_backgrounds.rs: -------------------------------------------------------------------------------- 1 | use crate::model::user::User; 2 | 3 | use super::{serde_util, CacheUserFn, ContainedUsers}; 4 | 5 | use serde::Deserialize; 6 | use time::OffsetDateTime; 7 | 8 | /// Details of a background 9 | #[derive(Clone, Debug, Deserialize, PartialEq)] 10 | #[cfg_attr(feature = "serialize", derive(serde::Serialize))] 11 | pub struct SeasonalBackground { 12 | /// URL to the image 13 | pub url: String, 14 | /// [`User`] of the artist of the art 15 | #[serde(rename = "user")] 16 | pub artist: User, 17 | } 18 | 19 | impl ContainedUsers for SeasonalBackground { 20 | fn apply_to_users(&self, f: impl CacheUserFn) { 21 | self.artist.apply_to_users(f); 22 | } 23 | } 24 | 25 | /// Collection of seasonal backgrounds 26 | #[derive(Clone, Debug, Deserialize, PartialEq)] 27 | #[cfg_attr(feature = "serialize", derive(serde::Serialize))] 28 | pub struct SeasonalBackgrounds { 29 | /// End date of the backgrounds 30 | #[serde(with = "serde_util::datetime")] 31 | pub ends_at: OffsetDateTime, 32 | /// List of backgrounds 33 | pub backgrounds: Vec, 34 | } 35 | 36 | impl ContainedUsers for SeasonalBackgrounds { 37 | fn apply_to_users(&self, f: impl CacheUserFn) { 38 | self.backgrounds.apply_to_users(f); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/model/serde_util.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fmt::{Formatter, Result as FmtResult}, 3 | marker::PhantomData, 4 | }; 5 | 6 | use serde::{ 7 | de::{Error as DeError, IgnoredAny, MapAccess, Visitor}, 8 | Deserialize, Deserializer, 9 | }; 10 | use time::format_description::{ 11 | modifier::{Day, Month, Year}, 12 | Component, FormatItem, 13 | }; 14 | 15 | const DATE_FORMAT: &[FormatItem<'_>] = &[ 16 | FormatItem::Component(Component::Year(Year::default())), 17 | FormatItem::Literal(b"-"), 18 | FormatItem::Component(Component::Month(Month::default())), 19 | FormatItem::Literal(b"-"), 20 | FormatItem::Component(Component::Day(Day::default())), 21 | ]; 22 | 23 | pub(super) mod datetime { 24 | 25 | use serde::Deserializer; 26 | use time::{serde::rfc3339, OffsetDateTime}; 27 | 28 | pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result { 29 | rfc3339::deserialize(d) 30 | } 31 | 32 | #[cfg(feature = "serialize")] 33 | pub fn serialize( 34 | datetime: &OffsetDateTime, 35 | s: S, 36 | ) -> Result { 37 | rfc3339::serialize(datetime, s) 38 | } 39 | } 40 | 41 | pub(super) mod option_datetime { 42 | 43 | use serde::Deserializer; 44 | use time::{serde::rfc3339, OffsetDateTime}; 45 | 46 | pub fn deserialize<'de, D: Deserializer<'de>>( 47 | d: D, 48 | ) -> Result, D::Error> { 49 | rfc3339::option::deserialize(d) 50 | } 51 | 52 | #[cfg(feature = "serialize")] 53 | #[allow(clippy::ref_option, reason = "required by serde")] 54 | pub fn serialize( 55 | datetime: &Option, 56 | s: S, 57 | ) -> Result { 58 | rfc3339::option::serialize(datetime, s) 59 | } 60 | } 61 | 62 | pub(super) mod adjust_acc { 63 | use serde::{Deserialize, Deserializer}; 64 | 65 | pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result { 66 | let acc = ::deserialize(d)?; 67 | 68 | Ok(100.0 * acc) 69 | } 70 | 71 | #[cfg(feature = "serialize")] 72 | // Required to take a reference by serde 73 | #[allow(clippy::trivially_copy_pass_by_ref)] 74 | pub fn serialize(f: &f32, s: S) -> Result { 75 | s.serialize_f32(*f / 100.0) 76 | } 77 | } 78 | 79 | pub(super) mod from_option { 80 | 81 | use serde::{Deserialize, Deserializer}; 82 | 83 | pub fn deserialize<'de, D: Deserializer<'de>, T>(d: D) -> Result 84 | where 85 | T: Default + Deserialize<'de>, 86 | { 87 | Option::::deserialize(d).map(Option::unwrap_or_default) 88 | } 89 | } 90 | 91 | pub(super) mod date { 92 | use std::fmt; 93 | 94 | use serde::{ 95 | de::{Error, SeqAccess, Visitor}, 96 | Deserializer, 97 | }; 98 | use time::Date; 99 | 100 | use super::DATE_FORMAT; 101 | 102 | pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result { 103 | d.deserialize_any(DateVisitor) 104 | } 105 | 106 | #[cfg(feature = "serialize")] 107 | // Required to take a reference by serde 108 | #[allow(clippy::trivially_copy_pass_by_ref)] 109 | pub fn serialize(date: &Date, s: S) -> Result { 110 | use serde::Serialize; 111 | 112 | (date.year(), date.ordinal()).serialize(s) 113 | } 114 | 115 | pub(super) struct DateVisitor; 116 | 117 | impl<'de> Visitor<'de> for DateVisitor { 118 | type Value = Date; 119 | 120 | fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 121 | f.write_str("a `Date`") 122 | } 123 | 124 | #[inline] 125 | fn visit_str(self, v: &str) -> Result { 126 | Date::parse(v, DATE_FORMAT).map_err(Error::custom) 127 | } 128 | 129 | #[inline] 130 | fn visit_seq>(self, mut seq: A) -> Result { 131 | let year = seq 132 | .next_element()? 133 | .ok_or_else(|| Error::custom("expected year"))?; 134 | 135 | let ordinal = seq 136 | .next_element()? 137 | .ok_or_else(|| Error::custom("expected day of the year"))?; 138 | 139 | Date::from_ordinal_date(year, ordinal).map_err(Error::custom) 140 | } 141 | } 142 | } 143 | 144 | #[doc(hidden)] 145 | pub struct DeserializedList(pub(crate) Vec); 146 | 147 | impl<'de, T: Deserialize<'de>> Deserialize<'de> for DeserializedList { 148 | fn deserialize>(d: D) -> Result { 149 | struct ListVisitor(PhantomData); 150 | 151 | impl<'de, T: Deserialize<'de>> Visitor<'de> for ListVisitor { 152 | type Value = Vec; 153 | 154 | fn expecting(&self, f: &mut Formatter<'_>) -> FmtResult { 155 | f.write_str("a struct with a single list field") 156 | } 157 | 158 | fn visit_map>(self, mut map: A) -> Result { 159 | const ERR: &str = "struct must contain exactly one field"; 160 | 161 | let _: IgnoredAny = map.next_key()?.ok_or_else(|| DeError::custom(ERR))?; 162 | let list = map.next_value()?; 163 | 164 | if map.next_key::()?.is_some() { 165 | return Err(DeError::custom(ERR)); 166 | } 167 | 168 | Ok(list) 169 | } 170 | } 171 | 172 | d.deserialize_map(ListVisitor(PhantomData)).map(Self) 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/model/wiki.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | 3 | use super::{CacheUserFn, ContainedUsers}; 4 | 5 | /// Represents a wiki article 6 | #[derive(Clone, Debug, Deserialize, Eq, PartialEq)] 7 | #[cfg_attr(feature = "serialize", derive(serde::Serialize))] 8 | pub struct WikiPage { 9 | /// All available locales for the article 10 | #[serde(default, skip_serializing_if = "Vec::is_empty")] 11 | pub available_locales: Vec, 12 | /// The layout type for the page 13 | pub layout: String, 14 | /// All lowercase BCP 47 language tag 15 | pub locale: String, 16 | /// Markdown content 17 | pub markdown: String, 18 | /// Path of the article 19 | pub path: String, 20 | /// The article's subtitle 21 | #[serde(default, skip_serializing_if = "Option::is_none")] 22 | pub subtitle: Option, 23 | /// Associated tags for the article 24 | #[serde(default, skip_serializing_if = "Vec::is_empty")] 25 | pub tags: Vec, 26 | /// The article's title 27 | pub title: String, 28 | } 29 | 30 | impl ContainedUsers for WikiPage { 31 | fn apply_to_users(&self, _: impl CacheUserFn) {} 32 | } 33 | -------------------------------------------------------------------------------- /src/request/comments.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | model::comments::{CommentBundle, CommentSort}, 3 | request::{serialize::maybe_comment_sort, Query, Request}, 4 | routing::Route, 5 | Osu, 6 | }; 7 | 8 | use serde::Serialize; 9 | 10 | /// Get a list of comments and their replies up to two levels deep in form of a 11 | /// [`CommentBundle`]. 12 | #[must_use = "requests must be configured and executed"] 13 | #[derive(Serialize)] 14 | pub struct GetComments<'a> { 15 | #[serde(skip)] 16 | osu: &'a Osu, 17 | commentable_type: Option, 18 | commentable_id: Option, 19 | parent_id: Option, 20 | #[serde(serialize_with = "maybe_comment_sort")] 21 | sort: Option, 22 | #[serde(rename = "cursor_string")] 23 | cursor: Option<&'a str>, 24 | } 25 | 26 | impl<'a> GetComments<'a> { 27 | pub(crate) const fn new(osu: &'a Osu) -> Self { 28 | Self { 29 | osu, 30 | commentable_type: None, 31 | commentable_id: None, 32 | parent_id: None, 33 | sort: None, 34 | cursor: None, 35 | } 36 | } 37 | 38 | /// Sort the result by date, newest first 39 | #[inline] 40 | pub const fn sort_new(mut self) -> Self { 41 | self.sort = Some(CommentSort::New); 42 | 43 | self 44 | } 45 | 46 | /// Sort the result by vote count 47 | #[inline] 48 | pub const fn sort_top(mut self) -> Self { 49 | self.sort = Some(CommentSort::Top); 50 | 51 | self 52 | } 53 | 54 | /// Sort the result by date, oldest first 55 | #[inline] 56 | pub const fn sort_old(mut self) -> Self { 57 | self.sort = Some(CommentSort::Old); 58 | 59 | self 60 | } 61 | 62 | /// Limit to comments which are reply to the specified id. Specify 0 to get top level comments 63 | #[inline] 64 | pub const fn parent(mut self, parent_id: u32) -> Self { 65 | self.parent_id = Some(parent_id); 66 | 67 | self 68 | } 69 | 70 | /// The id of the resource to get comments for 71 | #[inline] 72 | pub const fn commentable_id(mut self, commentable_id: u32) -> Self { 73 | self.commentable_id = Some(commentable_id); 74 | 75 | self 76 | } 77 | 78 | /// The type of resource to get comments for 79 | #[inline] 80 | pub fn commentable_type(mut self, commentable_type: impl Into) -> Self { 81 | self.commentable_type = Some(commentable_type.into()); 82 | 83 | self 84 | } 85 | 86 | #[inline] 87 | pub(crate) const fn cursor(mut self, cursor: &'a str) -> Self { 88 | self.cursor = Some(cursor); 89 | 90 | self 91 | } 92 | } 93 | 94 | into_future! { 95 | |self: GetComments<'_>| -> CommentBundle { 96 | Request::with_query(Route::GetComments, Query::encode(&self)) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/request/event.rs: -------------------------------------------------------------------------------- 1 | use serde::Serialize; 2 | 3 | use crate::{ 4 | model::event::{EventSort, Events}, 5 | routing::Route, 6 | Osu, 7 | }; 8 | 9 | use super::{Query, Request}; 10 | 11 | /// Get [`Events`]. 12 | #[must_use = "requests must be configured and executed"] 13 | #[derive(Serialize)] 14 | pub struct GetEvents<'a> { 15 | #[serde(skip)] 16 | osu: &'a Osu, 17 | sort: Option, 18 | #[serde(rename = "cursor_string")] 19 | cursor: Option<&'a str>, 20 | } 21 | 22 | impl<'a> GetEvents<'a> { 23 | pub(crate) const fn new(osu: &'a Osu) -> Self { 24 | Self { 25 | osu, 26 | sort: None, 27 | cursor: None, 28 | } 29 | } 30 | 31 | /// Sorting option 32 | #[inline] 33 | pub const fn sort(mut self, sort: EventSort) -> Self { 34 | self.sort = Some(sort); 35 | 36 | self 37 | } 38 | 39 | /// Cursor for pagination 40 | #[inline] 41 | pub const fn cursor(mut self, cursor: &'a str) -> Self { 42 | self.cursor = Some(cursor); 43 | 44 | self 45 | } 46 | } 47 | 48 | into_future! { 49 | |self: GetEvents<'_>| -> Events { 50 | ( 51 | Request::with_query(Route::GetEvents, Query::encode(&self)), 52 | self.sort, 53 | ) 54 | } => |events, sort: Option| -> Events { 55 | events.sort = sort; 56 | 57 | Ok(events) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/request/forum.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | model::forum::ForumPosts, 3 | request::{Query, Request}, 4 | routing::Route, 5 | Osu, 6 | }; 7 | 8 | use serde::Serialize; 9 | 10 | /// Get a [`ForumPosts`] struct for a forum topic 11 | #[must_use = "requests must be configured and executed"] 12 | #[derive(Serialize)] 13 | pub struct GetForumPosts<'a> { 14 | #[serde(skip)] 15 | osu: &'a Osu, 16 | #[serde(skip)] 17 | topic_id: u64, 18 | sort: Option<&'static str>, 19 | limit: Option, 20 | start: Option, 21 | end: Option, 22 | #[serde(rename = "cursor_string")] 23 | cursor: Option<&'a str>, 24 | } 25 | 26 | impl<'a> GetForumPosts<'a> { 27 | pub(crate) const fn new(osu: &'a Osu, topic_id: u64) -> Self { 28 | Self { 29 | osu, 30 | topic_id, 31 | sort: None, 32 | limit: None, 33 | start: None, 34 | end: None, 35 | cursor: None, 36 | } 37 | } 38 | 39 | /// Maximum number of posts to be returned (20 default, 50 at most) 40 | #[inline] 41 | pub fn limit(mut self, limit: usize) -> Self { 42 | self.limit = Some(limit.min(50)); 43 | 44 | self 45 | } 46 | 47 | /// Sort by ascending post ids. This is the default. 48 | #[inline] 49 | pub const fn sort_ascending(mut self) -> Self { 50 | self.sort = Some("id_asc"); 51 | 52 | self 53 | } 54 | 55 | /// Sort by descending post ids 56 | #[inline] 57 | pub const fn sort_descending(mut self) -> Self { 58 | self.sort = Some("id_desc"); 59 | 60 | self 61 | } 62 | 63 | /// First post id to be returned if sorted ascendingly. 64 | /// Parameter is ignored if `cursor` is specified. 65 | #[inline] 66 | pub const fn start_id(mut self, start: u64) -> Self { 67 | self.start = Some(start); 68 | 69 | self 70 | } 71 | 72 | /// First post id to be returned if sorted descendingly. 73 | /// Parameter is ignored if `cursor` is specified. 74 | #[inline] 75 | pub const fn end_id(mut self, end: u64) -> Self { 76 | self.end = Some(end); 77 | 78 | self 79 | } 80 | 81 | /// Specify a page by providing a cursor 82 | #[inline] 83 | pub const fn cursor(mut self, cursor: &'a str) -> Self { 84 | self.cursor = Some(cursor); 85 | 86 | self 87 | } 88 | } 89 | 90 | into_future! { 91 | |self: GetForumPosts<'_>| -> ForumPosts { 92 | Request::with_query( 93 | Route::GetForumPosts { topic_id: self.topic_id }, 94 | Query::encode(&self), 95 | ) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/request/matches.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | model::matches::{MatchList, OsuMatch}, 3 | request::{Query, Request}, 4 | routing::Route, 5 | Osu, 6 | }; 7 | 8 | use serde::Serialize; 9 | 10 | /// Get an [`OsuMatch`]. 11 | #[must_use = "requests must be configured and executed"] 12 | #[derive(Serialize)] 13 | pub struct GetMatch<'a> { 14 | #[serde(skip)] 15 | osu: &'a Osu, 16 | #[serde(skip)] 17 | match_id: u32, 18 | after: Option, 19 | before: Option, 20 | limit: Option, 21 | } 22 | 23 | impl<'a> GetMatch<'a> { 24 | pub(crate) const fn new(osu: &'a Osu, match_id: u32) -> Self { 25 | Self { 26 | osu, 27 | match_id, 28 | after: None, 29 | before: None, 30 | limit: None, 31 | } 32 | } 33 | 34 | /// Get the match state containing only events after the given event id. 35 | /// 36 | /// Note: The given event id won't be included. 37 | #[inline] 38 | pub const fn after(mut self, after: u64) -> Self { 39 | self.after = Some(after); 40 | 41 | self 42 | } 43 | 44 | /// Get the match state containing only events before the given event id. 45 | /// 46 | /// Note: The given event id won't be included. 47 | #[inline] 48 | pub const fn before(mut self, before: u64) -> Self { 49 | self.before = Some(before); 50 | 51 | self 52 | } 53 | 54 | /// Get the match state after at most `limit` many new events. 55 | #[inline] 56 | pub const fn limit(mut self, limit: usize) -> Self { 57 | self.limit = Some(limit); 58 | 59 | self 60 | } 61 | } 62 | 63 | into_future! { 64 | |self: GetMatch<'_>| -> OsuMatch { 65 | let route = Route::GetMatch { 66 | match_id: Some(self.match_id), 67 | }; 68 | 69 | Request::with_query(route, Query::encode(&self)) 70 | } 71 | } 72 | 73 | /// Get a [`MatchList`] containing all currently open multiplayer lobbies. 74 | #[must_use = "requests must be configured and executed"] 75 | #[derive(Serialize)] 76 | pub struct GetMatches<'a> { 77 | #[serde(skip)] 78 | osu: &'a Osu, 79 | #[serde(rename = "cursor_string")] 80 | cursor: Option<&'a str>, 81 | } 82 | 83 | impl<'a> GetMatches<'a> { 84 | pub(crate) const fn new(osu: &'a Osu) -> Self { 85 | Self { osu, cursor: None } 86 | } 87 | 88 | #[inline] 89 | pub(crate) const fn cursor(mut self, cursor: &'a str) -> Self { 90 | self.cursor = Some(cursor); 91 | 92 | self 93 | } 94 | } 95 | 96 | into_future! { 97 | |self: GetMatches<'_>| -> MatchList { 98 | Request::with_query( 99 | Route::GetMatch { match_id: None }, 100 | Query::encode(&self), 101 | ) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/request/mod.rs: -------------------------------------------------------------------------------- 1 | /// Implements [`OsuFutureData`] and [`IntoFuture`] for requests. 2 | /// 3 | /// [`OsuFutureData`]: crate::future::OsuFutureData 4 | /// [`IntoFuture`]: std::future::IntoFuture 5 | macro_rules! into_future { 6 | // New OsuFuture with optional post processing 7 | ( 8 | |$self:ident: $ty:ty| -> $from_bytes:ty { $( $req_body:tt )+ } 9 | $( => 10 | | 11 | $from_bytes_arg:tt, 12 | $post_process_data_arg:tt $(: $post_process_data:ty )? 13 | | -> $output:ty { $( $post_process_body:tt )+ } 14 | )? 15 | ) => { 16 | impl crate::future::OsuFutureData for $ty { 17 | type FromBytes = $from_bytes; 18 | type OsuOutput = into_future!(OUTPUT_TY $from_bytes $( | $output )?); 19 | type FromUserData = (); 20 | type PostProcessData = into_future!(POST_PROCESS_DATA $( $( $post_process_data )? )?); 21 | } 22 | 23 | impl std::future::IntoFuture for $ty { 24 | type Output = crate::OsuResult; 25 | type IntoFuture = crate::future::OsuFuture; 26 | 27 | fn into_future($self) -> Self::IntoFuture { 28 | let res = { $( $req_body )* }; 29 | let (req, data) = 30 | crate::future::IntoPostProcessData::into_data(res); 31 | 32 | let post_process_fn = into_future!(POST_PROCESS_FN $( 33 | | 34 | $from_bytes_arg: $from_bytes, 35 | $post_process_data_arg $(: $post_process_data )? 36 | | { $( $post_process_body )* } 37 | )?); 38 | 39 | crate::future::OsuFuture::new( 40 | $self.osu, 41 | req, 42 | data, 43 | post_process_fn, 44 | ) 45 | } 46 | } 47 | }; 48 | 49 | // OsuFuture from UserId with optional post processing 50 | ( 51 | |$self:ident: $ty:ty| -> $from_bytes:ty { 52 | $from_user_data:ident { 53 | $( $data_field_name:ident: $data_field_ty:ty = $data_field_value:expr, )* 54 | } 55 | } => |$user_id_arg:tt, $from_user_data_arg:tt| { $( $req_body:tt )* } 56 | $( 57 | => |$from_bytes_arg:tt, $post_process_data_arg:tt $(: $post_process_data:ty )?| 58 | -> $output:ty { $( $post_process_body:tt )* } 59 | )? 60 | ) => { 61 | #[doc(hidden)] 62 | pub struct $from_user_data { 63 | $( $data_field_name: $data_field_ty, )* 64 | } 65 | 66 | impl crate::future::OsuFutureData for $ty { 67 | type FromBytes = $from_bytes; 68 | type OsuOutput = into_future!(OUTPUT_TY $from_bytes $( | $output )?); 69 | type FromUserData = $from_user_data; 70 | type PostProcessData = into_future!(POST_PROCESS_DATA $( $( $post_process_data )? )?); 71 | } 72 | 73 | impl std::future::IntoFuture for $ty { 74 | type Output = crate::OsuResult; 75 | type IntoFuture = crate::future::OsuFuture; 76 | 77 | fn into_future($self) -> Self::IntoFuture { 78 | let from_user_data = $from_user_data { 79 | $( $data_field_name: $data_field_value, )* 80 | }; 81 | 82 | let from_user_fn = |$user_id_arg: u32, $from_user_data_arg: $from_user_data| { 83 | $( $req_body )* 84 | }; 85 | 86 | let post_process_fn = into_future!(POST_PROCESS_FN $( 87 | | 88 | $from_bytes_arg: $from_bytes, 89 | $post_process_data_arg $(: $post_process_data )? 90 | | { $( $post_process_body )* } 91 | )?); 92 | 93 | crate::future::OsuFuture::from_user_id( 94 | $self.osu, 95 | $self.user_id, 96 | from_user_data, 97 | from_user_fn, 98 | (), // FIXME: handle different post process data types 99 | post_process_fn, 100 | ) 101 | } 102 | } 103 | }; 104 | 105 | // Helper rules 106 | 107 | ( OUTPUT_TY $output:ty ) => { 108 | $output 109 | }; 110 | ( OUTPUT_TY $from_bytes:ty | $output:ty ) => { 111 | $output 112 | }; 113 | ( POST_PROCESS_DATA ) => { 114 | () 115 | }; 116 | ( POST_PROCESS_DATA $data:ty ) => { 117 | $data 118 | }; 119 | ( POST_PROCESS_FN ) => { 120 | crate::future::noop_post_process 121 | }; 122 | ( POST_PROCESS_FN 123 | |$from_bytes_arg:tt: $from_bytes:ty, $data_arg:tt $(: $data:ty )?| 124 | { $( $post_process_body:tt )* } 125 | ) => { 126 | | 127 | #[allow(unused_mut)] 128 | mut $from_bytes_arg: $from_bytes, 129 | $data_arg: into_future!(POST_PROCESS_DATA $( $data )?), 130 | | { $( $post_process_body )* } 131 | }; 132 | } 133 | 134 | use itoa::{Buffer, Integer}; 135 | use serde::Serialize; 136 | 137 | use crate::routing::Route; 138 | 139 | pub use crate::future::OsuFuture; 140 | 141 | pub use self::{ 142 | beatmap::*, comments::*, event::*, forum::*, matches::*, news::*, ranking::*, replay::*, 143 | score::*, seasonal_backgrounds::*, user::*, wiki::*, 144 | }; 145 | 146 | mod beatmap; 147 | mod comments; 148 | mod event; 149 | mod forum; 150 | mod matches; 151 | mod news; 152 | mod ranking; 153 | mod replay; 154 | mod score; 155 | mod seasonal_backgrounds; 156 | mod serialize; 157 | mod user; 158 | mod wiki; 159 | 160 | #[derive(Copy, Clone)] 161 | pub(crate) enum Method { 162 | Get, 163 | Post, 164 | } 165 | 166 | impl Method { 167 | pub const fn into_hyper(self) -> hyper::Method { 168 | match self { 169 | Method::Get => hyper::Method::GET, 170 | Method::Post => hyper::Method::POST, 171 | } 172 | } 173 | } 174 | 175 | pub(crate) struct Request { 176 | pub query: Option, 177 | pub route: Route, 178 | pub body: JsonBody, 179 | pub api_version: u32, 180 | } 181 | 182 | impl Request { 183 | #[allow(clippy::unreadable_literal)] 184 | const API_VERSION: u32 = 20220705; 185 | 186 | const fn new(route: Route) -> Self { 187 | Self::with_body(route, JsonBody::new()) 188 | } 189 | 190 | const fn with_body(route: Route, body: JsonBody) -> Self { 191 | Self { 192 | query: None, 193 | route, 194 | body, 195 | api_version: Self::API_VERSION, 196 | } 197 | } 198 | 199 | const fn with_query(route: Route, query: String) -> Self { 200 | Self::with_query_and_body(route, query, JsonBody::new()) 201 | } 202 | 203 | const fn with_query_and_body(route: Route, query: String, body: JsonBody) -> Self { 204 | Self { 205 | query: Some(query), 206 | route, 207 | body, 208 | api_version: Self::API_VERSION, 209 | } 210 | } 211 | 212 | const fn api_version(&mut self, api_version: u32) { 213 | self.api_version = api_version; 214 | } 215 | } 216 | 217 | pub(crate) struct JsonBody { 218 | inner: Vec, 219 | } 220 | 221 | impl JsonBody { 222 | pub(crate) const fn new() -> Self { 223 | Self { inner: Vec::new() } 224 | } 225 | 226 | fn push_prefix(&mut self) { 227 | let prefix = if self.inner.is_empty() { b'{' } else { b',' }; 228 | self.inner.push(prefix); 229 | } 230 | 231 | fn push_key(&mut self, key: &[u8]) { 232 | self.push_prefix(); 233 | self.inner.push(b'\"'); 234 | self.inner.extend_from_slice(key); 235 | self.inner.extend_from_slice(b"\":"); 236 | } 237 | 238 | fn push_value(&mut self, value: &[u8]) { 239 | self.inner.push(b'\"'); 240 | self.inner.extend_from_slice(value); 241 | self.inner.push(b'\"'); 242 | } 243 | 244 | pub(crate) fn push_str(&mut self, key: &str, value: &str) { 245 | self.inner.reserve(4 + key.len() + 2 + value.len()); 246 | 247 | self.push_key(key.as_bytes()); 248 | self.push_value(value.as_bytes()); 249 | } 250 | 251 | pub(crate) fn push_int(&mut self, key: &str, int: impl Integer) { 252 | let mut buf = Buffer::new(); 253 | let int = buf.format(int); 254 | 255 | self.inner.reserve(4 + key.len() + int.len()); 256 | 257 | self.push_key(key.as_bytes()); 258 | self.push_value(int.as_bytes()); 259 | } 260 | 261 | pub(crate) fn into_bytes(mut self) -> Vec { 262 | if !self.inner.is_empty() { 263 | self.inner.push(b'}'); 264 | } 265 | 266 | self.inner 267 | } 268 | } 269 | 270 | struct Query; 271 | 272 | impl Query { 273 | fn encode(query: &T) -> String { 274 | serde_urlencoded::to_string(query).expect("serde_urlencoded should not fail") 275 | } 276 | } 277 | -------------------------------------------------------------------------------- /src/request/news.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | model::news::News, 3 | request::{Query, Request}, 4 | routing::Route, 5 | Osu, 6 | }; 7 | 8 | use serde::Serialize; 9 | 10 | /// Get a [`News`] struct. 11 | #[must_use = "requests must be configured and executed"] 12 | #[derive(Serialize)] 13 | pub struct GetNews<'a> { 14 | #[serde(skip)] 15 | osu: &'a Osu, 16 | news: Option<()>, // TODO 17 | #[serde(rename = "cursor_string")] 18 | cursor: Option<&'a str>, 19 | } 20 | 21 | impl<'a> GetNews<'a> { 22 | pub(crate) const fn new(osu: &'a Osu) -> Self { 23 | Self { 24 | osu, 25 | news: None, 26 | cursor: None, 27 | } 28 | } 29 | 30 | #[inline] 31 | pub(crate) const fn cursor(mut self, cursor: &'a str) -> Self { 32 | self.cursor = Some(cursor); 33 | 34 | self 35 | } 36 | } 37 | 38 | into_future! { 39 | |self: GetNews<'_>| -> News { 40 | Request::with_query( 41 | Route::GetNews { news: self.news }, 42 | Query::encode(&self), 43 | ) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/request/ranking.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | model::{ 3 | ranking::{ChartRankings, CountryRankings, RankingType, Rankings, Spotlight}, 4 | user::CountryCode, 5 | DeserializedList, GameMode, 6 | }, 7 | prelude::TeamRankings, 8 | request::{Query, Request}, 9 | routing::Route, 10 | Osu, 11 | }; 12 | 13 | use serde::Serialize; 14 | 15 | /// Get a [`ChartRankings`] struct containing a [`Spotlight`], its 16 | /// [`BeatmapsetExtended`]s, and participating [`User`]s. 17 | /// 18 | /// The mapset will have their `maps` option filled. 19 | /// 20 | /// The user statistics contain specific, spotlight related data. 21 | /// All fields depends only on scores on maps of the spotlight. 22 | /// The statistics vector is ordered by `ranked_score`. 23 | /// The `user` option is filled. 24 | /// 25 | /// [`BeatmapsetExtended`]: crate::model::beatmap::BeatmapsetExtended 26 | /// [`User`]: crate::model::user::User 27 | #[must_use = "requests must be configured and executed"] 28 | #[derive(Serialize)] 29 | pub struct GetChartRankings<'a> { 30 | #[serde(skip)] 31 | osu: &'a Osu, 32 | #[serde(skip)] 33 | mode: GameMode, 34 | spotlight: Option, 35 | } 36 | 37 | impl<'a> GetChartRankings<'a> { 38 | pub(crate) const fn new(osu: &'a Osu, mode: GameMode) -> Self { 39 | Self { 40 | osu, 41 | mode, 42 | spotlight: None, 43 | } 44 | } 45 | 46 | /// Specify the spotlight id. If none is given, 47 | /// the latest spotlight will be returned. 48 | #[inline] 49 | pub const fn spotlight(mut self, spotlight_id: u32) -> Self { 50 | self.spotlight = Some(spotlight_id); 51 | 52 | self 53 | } 54 | } 55 | 56 | into_future! { 57 | |self: GetChartRankings<'_>| -> ChartRankings { 58 | Request::with_query( 59 | Route::GetRankings { 60 | mode: self.mode, 61 | ranking_type: RankingType::Charts, 62 | }, 63 | Query::encode(&self), 64 | ) 65 | } 66 | } 67 | 68 | /// Get a [`CountryRankings`] struct containing a vec of [`CountryRanking`]s 69 | /// which will be sorted by the country's total pp. 70 | /// 71 | /// [`CountryRanking`]: crate::model::ranking::CountryRanking 72 | #[must_use = "requests must be configured and executed"] 73 | #[derive(Serialize)] 74 | pub struct GetCountryRankings<'a> { 75 | #[serde(skip)] 76 | osu: &'a Osu, 77 | #[serde(skip)] 78 | mode: GameMode, 79 | #[serde(rename(serialize = "cursor[page]"))] 80 | page: Option, 81 | } 82 | 83 | impl<'a> GetCountryRankings<'a> { 84 | pub(crate) const fn new(osu: &'a Osu, mode: GameMode) -> Self { 85 | Self { 86 | osu, 87 | mode, 88 | page: None, 89 | } 90 | } 91 | 92 | /// Specify a page 93 | #[inline] 94 | pub const fn page(mut self, page: u32) -> Self { 95 | self.page = Some(page); 96 | 97 | self 98 | } 99 | } 100 | 101 | into_future! { 102 | |self: GetCountryRankings<'_>| -> CountryRankings { 103 | Request::with_query( 104 | Route::GetRankings { 105 | mode: self.mode, 106 | ranking_type: RankingType::Country, 107 | }, 108 | Query::encode(&self), 109 | ) 110 | } 111 | } 112 | 113 | /// Get a [`Rankings`] struct whose [`User`]s are sorted by their pp, i.e. the 114 | /// current pp leaderboard. 115 | /// 116 | /// [`User`]: crate::model::user::User 117 | #[must_use = "requests must be configured and executed"] 118 | #[derive(Serialize)] 119 | pub struct GetPerformanceRankings<'a> { 120 | #[serde(skip)] 121 | osu: &'a Osu, 122 | #[serde(skip)] 123 | mode: GameMode, 124 | country: Option, 125 | variant: Option<&'static str>, 126 | #[serde(rename(serialize = "cursor[page]"))] 127 | page: Option, 128 | } 129 | 130 | impl<'a> GetPerformanceRankings<'a> { 131 | pub(crate) const fn new(osu: &'a Osu, mode: GameMode) -> Self { 132 | Self { 133 | osu, 134 | mode, 135 | country: None, 136 | variant: None, 137 | page: None, 138 | } 139 | } 140 | 141 | /// Specify a country code. 142 | #[inline] 143 | pub fn country(mut self, country: impl Into) -> Self { 144 | self.country = Some(country.into()); 145 | 146 | self 147 | } 148 | 149 | /// Consider only 4K scores. Only relevant for osu!mania. 150 | #[inline] 151 | pub const fn variant_4k(mut self) -> Self { 152 | self.variant = Some("4k"); 153 | 154 | self 155 | } 156 | 157 | /// Consider only 7K scores. Only relevant for osu!mania. 158 | #[inline] 159 | pub const fn variant_7k(mut self) -> Self { 160 | self.variant = Some("7k"); 161 | 162 | self 163 | } 164 | 165 | /// Pages range from 1 to 200. 166 | #[inline] 167 | pub const fn page(mut self, page: u32) -> Self { 168 | self.page = Some(page); 169 | 170 | self 171 | } 172 | } 173 | 174 | into_future! { 175 | |self: GetPerformanceRankings<'_>| -> Rankings { 176 | let req = Request::with_query( 177 | Route::GetRankings { 178 | mode: self.mode, 179 | ranking_type: RankingType::Performance, 180 | }, 181 | Query::encode(&self), 182 | ); 183 | 184 | (req, self.mode) 185 | } => |rankings, mode: GameMode| -> Rankings { 186 | rankings.mode = Some(mode); 187 | rankings.ranking_type = Some(RankingType::Performance); 188 | 189 | Ok(rankings) 190 | } 191 | } 192 | 193 | /// Get a [`Rankings`] struct whose [`User`]s are sorted by their ranked score, 194 | /// i.e. the current ranked score leaderboard. 195 | /// 196 | /// [`User`]: crate::model::user::User 197 | #[must_use = "requests must be configured and executed"] 198 | #[derive(Serialize)] 199 | pub struct GetScoreRankings<'a> { 200 | #[serde(skip)] 201 | osu: &'a Osu, 202 | #[serde(skip)] 203 | mode: GameMode, 204 | #[serde(rename(serialize = "cursor[page]"))] 205 | page: Option, 206 | } 207 | 208 | impl<'a> GetScoreRankings<'a> { 209 | pub(crate) const fn new(osu: &'a Osu, mode: GameMode) -> Self { 210 | Self { 211 | osu, 212 | mode, 213 | page: None, 214 | } 215 | } 216 | 217 | /// Pages range from 1 to 200. 218 | #[inline] 219 | pub const fn page(mut self, page: u32) -> Self { 220 | self.page = Some(page); 221 | 222 | self 223 | } 224 | } 225 | 226 | into_future! { 227 | |self: GetScoreRankings<'_>| -> Rankings { 228 | let req = Request::with_query( 229 | Route::GetRankings { 230 | mode: self.mode, 231 | ranking_type: RankingType::Score, 232 | }, 233 | Query::encode(&self) 234 | ); 235 | 236 | (req, self.mode) 237 | } => |rankings, mode: GameMode| -> Rankings { 238 | rankings.mode = Some(mode); 239 | rankings.ranking_type = Some(RankingType::Score); 240 | 241 | Ok(rankings) 242 | } 243 | } 244 | 245 | /// Get a vec of [`Spotlight`]s. 246 | #[must_use = "requests must be configured and executed"] 247 | pub struct GetSpotlights<'a> { 248 | osu: &'a Osu, 249 | } 250 | 251 | impl<'a> GetSpotlights<'a> { 252 | pub(crate) const fn new(osu: &'a Osu) -> Self { 253 | Self { osu } 254 | } 255 | } 256 | 257 | into_future! { 258 | |self: GetSpotlights<'_>| -> DeserializedList { 259 | Request::new(Route::GetSpotlights) 260 | } => |spotlights, _| -> Vec { 261 | Ok(spotlights.0) 262 | } 263 | } 264 | 265 | /// Get a [`TeamRankings`] struct whose entries are sorted by their pp. 266 | #[must_use = "requests must be configured and executed"] 267 | #[derive(Serialize)] 268 | pub struct GetTeamRankings<'a> { 269 | #[serde(skip)] 270 | osu: &'a Osu, 271 | #[serde(skip)] 272 | mode: GameMode, 273 | #[serde(rename(serialize = "cursor[page]"))] 274 | page: Option, 275 | } 276 | 277 | impl<'a> GetTeamRankings<'a> { 278 | pub(crate) const fn new(osu: &'a Osu, mode: GameMode) -> Self { 279 | Self { 280 | osu, 281 | mode, 282 | page: None, 283 | } 284 | } 285 | 286 | /// Pages range from 1 to 200. 287 | #[inline] 288 | pub const fn page(mut self, page: u32) -> Self { 289 | self.page = Some(page); 290 | 291 | self 292 | } 293 | } 294 | 295 | into_future! { 296 | |self: GetTeamRankings<'_>| -> TeamRankings { 297 | let req = Request::with_query( 298 | Route::GetRankings { 299 | mode: self.mode, 300 | ranking_type: RankingType::Team, 301 | }, 302 | Query::encode(&self), 303 | ); 304 | 305 | (req, self.mode) 306 | } => |rankings, mode: GameMode| -> TeamRankings { 307 | rankings.mode = Some(mode); 308 | 309 | Ok(rankings) 310 | } 311 | } 312 | -------------------------------------------------------------------------------- /src/request/replay.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | future::BytesWrap, 3 | model::{CacheUserFn, ContainedUsers, GameMode}, 4 | routing::Route, 5 | Osu, 6 | }; 7 | 8 | use super::Request; 9 | 10 | /// Get a raw replay as a `Vec` 11 | #[must_use = "requests must be configured and executed"] 12 | pub struct GetReplayRaw<'a> { 13 | osu: &'a Osu, 14 | mode: Option, 15 | score_id: u64, 16 | } 17 | 18 | impl<'a> GetReplayRaw<'a> { 19 | pub(crate) const fn new(osu: &'a Osu, score_id: u64) -> Self { 20 | Self { 21 | osu, 22 | mode: None, 23 | score_id, 24 | } 25 | } 26 | 27 | /// Specify the mode 28 | #[inline] 29 | pub const fn mode(mut self, mode: GameMode) -> Self { 30 | self.mode = Some(mode); 31 | 32 | self 33 | } 34 | } 35 | 36 | into_future! { 37 | |self: GetReplayRaw<'_>| -> BytesWrap { 38 | Request::new(Route::GetReplay { 39 | mode: self.mode, 40 | score_id: self.score_id, 41 | }) 42 | } => |bytes, _| -> Vec { 43 | Ok(Vec::from(bytes.0)) 44 | } 45 | } 46 | 47 | impl ContainedUsers for Vec { 48 | fn apply_to_users(&self, _: impl CacheUserFn) {} 49 | } 50 | 51 | /// Get a [`Replay`]. 52 | /// 53 | /// [`Replay`]: osu_db::Replay 54 | #[cfg(feature = "replay")] 55 | #[cfg_attr(docsrs, doc(cfg(feature = "replay")))] 56 | #[must_use = "requests must be configured and executed"] 57 | pub struct GetReplay<'a> { 58 | osu: &'a Osu, 59 | mode: Option, 60 | score_id: u64, 61 | } 62 | 63 | #[cfg(feature = "replay")] 64 | impl<'a> GetReplay<'a> { 65 | pub(crate) const fn new(osu: &'a Osu, score_id: u64) -> Self { 66 | Self { 67 | osu, 68 | mode: None, 69 | score_id, 70 | } 71 | } 72 | 73 | /// Specify the mode 74 | #[inline] 75 | pub const fn mode(mut self, mode: GameMode) -> Self { 76 | self.mode = Some(mode); 77 | 78 | self 79 | } 80 | } 81 | 82 | #[cfg(feature = "replay")] 83 | into_future! { 84 | |self: GetReplay<'_>| -> BytesWrap { 85 | Request::new(Route::GetReplay { 86 | mode: self.mode, 87 | score_id: self.score_id, 88 | }) 89 | } => |bytes, _| -> osu_db::Replay { 90 | osu_db::Replay::from_bytes(&bytes.0).map_err(crate::error::OsuError::from) 91 | } 92 | } 93 | 94 | #[cfg(feature = "replay")] 95 | impl ContainedUsers for osu_db::Replay { 96 | fn apply_to_users(&self, _: impl CacheUserFn) {} 97 | } 98 | -------------------------------------------------------------------------------- /src/request/score.rs: -------------------------------------------------------------------------------- 1 | use rosu_mods::GameMode; 2 | use serde::Serialize; 3 | 4 | use crate::{ 5 | prelude::{ProcessedScores, Score}, 6 | routing::Route, 7 | Osu, 8 | }; 9 | 10 | use super::{serialize::maybe_mode_as_str, Query, Request}; 11 | 12 | /// Get a [`Score`] struct. 13 | #[must_use = "requests must be configured and executed"] 14 | pub struct GetScore<'a> { 15 | osu: &'a Osu, 16 | mode: Option, 17 | score_id: u64, 18 | legacy_scores: bool, 19 | } 20 | 21 | impl<'a> GetScore<'a> { 22 | pub(crate) const fn new(osu: &'a Osu, score_id: u64) -> Self { 23 | Self { 24 | osu, 25 | mode: None, 26 | score_id, 27 | legacy_scores: false, 28 | } 29 | } 30 | 31 | /// Specify the mode 32 | #[inline] 33 | pub const fn mode(mut self, mode: GameMode) -> Self { 34 | self.mode = Some(mode); 35 | 36 | self 37 | } 38 | 39 | /// Specify whether the score should contain legacy data or not. 40 | /// 41 | /// Legacy data consists of a different grade calculation, less 42 | /// populated statistics, legacy mods, and a different score kind. 43 | #[inline] 44 | pub const fn legacy_scores(mut self, legacy_scores: bool) -> Self { 45 | self.legacy_scores = legacy_scores; 46 | 47 | self 48 | } 49 | } 50 | 51 | into_future! { 52 | |self: GetScore<'_>| -> Score { 53 | let route = Route::GetScore { 54 | mode: self.mode, 55 | score_id: self.score_id, 56 | }; 57 | 58 | let mut req = Request::new(route); 59 | 60 | if self.legacy_scores { 61 | req.api_version(0); 62 | } 63 | 64 | req 65 | } 66 | } 67 | 68 | /// Get a list of recently processed [`Score`] structs. 69 | #[must_use = "requests must be configured and executed"] 70 | #[derive(Serialize)] 71 | pub struct GetScores<'a> { 72 | #[serde(skip)] 73 | osu: &'a Osu, 74 | #[serde(rename(serialize = "ruleset"), serialize_with = "maybe_mode_as_str")] 75 | mode: Option, 76 | #[serde(rename(serialize = "cursor[id]"))] 77 | score_id: Option, 78 | #[serde(rename(serialize = "cursor_string"))] 79 | cursor: Option>, 80 | } 81 | 82 | impl<'a> GetScores<'a> { 83 | pub(crate) const fn new(osu: &'a Osu) -> Self { 84 | Self { 85 | osu, 86 | mode: None, 87 | score_id: None, 88 | cursor: None, 89 | } 90 | } 91 | 92 | /// Specify the mode 93 | pub const fn mode(mut self, mode: GameMode) -> Self { 94 | self.mode = Some(mode); 95 | 96 | self 97 | } 98 | 99 | /// Fetch from the given score id onward 100 | pub const fn score_id(mut self, score_id: u64) -> Self { 101 | self.score_id = Some(score_id); 102 | 103 | self 104 | } 105 | 106 | /// Specify a cursor 107 | pub fn cursor(mut self, cursor: Box) -> Self { 108 | self.cursor = Some(cursor); 109 | 110 | self 111 | } 112 | } 113 | 114 | into_future! { 115 | |self: GetScores<'_>| -> ProcessedScores { 116 | (Request::with_query(Route::GetScores, Query::encode(&self)), self.mode) 117 | } => |scores, mode: Option| -> ProcessedScores { 118 | scores.mode = mode; 119 | 120 | Ok(scores) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/request/seasonal_backgrounds.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | model::seasonal_backgrounds::SeasonalBackgrounds, request::Request, routing::Route, Osu, 3 | }; 4 | 5 | /// Get [`SeasonalBackgrounds`]. 6 | #[must_use = "requests must be configured and executed"] 7 | pub struct GetSeasonalBackgrounds<'a> { 8 | osu: &'a Osu, 9 | } 10 | 11 | impl<'a> GetSeasonalBackgrounds<'a> { 12 | pub(crate) const fn new(osu: &'a Osu) -> Self { 13 | Self { osu } 14 | } 15 | } 16 | 17 | into_future! { 18 | |self: GetSeasonalBackgrounds<'_>| -> SeasonalBackgrounds { 19 | Request::new(Route::GetSeasonalBackgrounds) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/request/serialize.rs: -------------------------------------------------------------------------------- 1 | use crate::model::GameMode; 2 | use crate::prelude::{CommentSort, GameModsIntermode}; 3 | 4 | use crate::request::UserId; 5 | use serde::ser::{SerializeMap, Serializer}; 6 | use std::cmp; 7 | 8 | #[allow(clippy::ref_option)] 9 | fn maybe(option: &Option, serializer: S, f: F) -> Result 10 | where 11 | S: Serializer, 12 | F: FnOnce(&T, S) -> Result, 13 | { 14 | match option { 15 | Some(some) => f(some, serializer), 16 | None => serializer.serialize_none(), 17 | } 18 | } 19 | 20 | #[allow(clippy::ref_option, clippy::trivially_copy_pass_by_ref)] 21 | pub(crate) fn maybe_mode_as_str( 22 | mode: &Option, 23 | serializer: S, 24 | ) -> Result { 25 | maybe(mode, serializer, mode_as_str) 26 | } 27 | 28 | #[allow(clippy::trivially_copy_pass_by_ref)] 29 | pub(crate) fn mode_as_str( 30 | mode: &GameMode, 31 | serializer: S, 32 | ) -> Result { 33 | serializer.serialize_str(mode.as_str()) 34 | } 35 | 36 | #[allow(clippy::ref_option)] 37 | pub(crate) fn maybe_mods_as_list( 38 | mods: &Option, 39 | serializer: S, 40 | ) -> Result { 41 | maybe(mods, serializer, mods_as_list) 42 | } 43 | 44 | pub(crate) fn mods_as_list( 45 | mods: &GameModsIntermode, 46 | serializer: S, 47 | ) -> Result { 48 | let mut map = serializer.serialize_map(Some(cmp::max(mods.len(), 1)))?; 49 | 50 | if mods.is_empty() { 51 | map.serialize_entry("mods[]", "NM")?; 52 | } else { 53 | for m in mods.iter() { 54 | map.serialize_entry("mods[]", m.acronym().as_str())?; 55 | } 56 | } 57 | 58 | map.end() 59 | } 60 | 61 | #[allow(clippy::ref_option, clippy::trivially_copy_pass_by_ref)] 62 | pub(crate) fn maybe_comment_sort( 63 | sort: &Option, 64 | serializer: S, 65 | ) -> Result { 66 | maybe(sort, serializer, CommentSort::serialize_as_query) 67 | } 68 | 69 | pub(crate) fn user_id_type( 70 | user_id: &UserId, 71 | serializer: S, 72 | ) -> Result { 73 | match user_id { 74 | UserId::Id(_) => serializer.serialize_str("id"), 75 | UserId::Name(_) => serializer.serialize_str("username"), 76 | } 77 | } 78 | 79 | #[allow(clippy::ref_option, clippy::trivially_copy_pass_by_ref)] 80 | pub(crate) fn maybe_bool_as_u8( 81 | b: &Option, 82 | serializer: S, 83 | ) -> Result { 84 | maybe(b, serializer, bool_as_u8) 85 | } 86 | 87 | #[allow(clippy::trivially_copy_pass_by_ref)] 88 | pub(crate) fn bool_as_u8(b: &bool, serializer: S) -> Result { 89 | serializer.serialize_u8(u8::from(*b)) 90 | } 91 | -------------------------------------------------------------------------------- /src/request/user.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | 3 | use itoa::Buffer; 4 | use serde::Serialize; 5 | use smallstr::SmallString; 6 | 7 | use crate::{ 8 | model::{ 9 | beatmap::{BeatmapsetExtended, MostPlayedMap}, 10 | event::Event, 11 | kudosu::KudosuHistory, 12 | score::Score, 13 | user::{User, UserBeatmapsetsKind, UserExtended, Username}, 14 | DeserializedList, GameMode, 15 | }, 16 | request::{ 17 | serialize::{maybe_bool_as_u8, maybe_mode_as_str, user_id_type}, 18 | Query, Request, 19 | }, 20 | routing::Route, 21 | Osu, 22 | }; 23 | 24 | /// Either a user id as `u32` or a username as [`Username`]. 25 | /// 26 | /// Use the `From` implementations to create this enum. 27 | /// 28 | /// # Example 29 | /// 30 | /// ``` 31 | /// use rosu_v2::request::UserId; 32 | /// 33 | /// let user_id: UserId = 123_456.into(); 34 | /// let user_id: UserId = "my username".into(); 35 | /// ``` 36 | #[derive(Clone, Debug, Eq, Hash, PartialEq)] 37 | pub enum UserId { 38 | /// Represents a user through their user id 39 | Id(u32), 40 | /// Represents a user through their username 41 | Name(Username), 42 | } 43 | 44 | impl From for UserId { 45 | #[inline] 46 | fn from(id: u32) -> Self { 47 | Self::Id(id) 48 | } 49 | } 50 | 51 | impl From<&str> for UserId { 52 | #[inline] 53 | fn from(name: &str) -> Self { 54 | Self::Name(SmallString::from_str(name)) 55 | } 56 | } 57 | 58 | impl From<&String> for UserId { 59 | #[inline] 60 | fn from(name: &String) -> Self { 61 | Self::Name(SmallString::from_str(name)) 62 | } 63 | } 64 | 65 | impl From for UserId { 66 | #[inline] 67 | fn from(name: String) -> Self { 68 | Self::Name(SmallString::from_string(name)) 69 | } 70 | } 71 | 72 | impl fmt::Display for UserId { 73 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 74 | match self { 75 | Self::Id(id) => write!(f, "{id}"), 76 | Self::Name(name) => f.write_str(name), 77 | } 78 | } 79 | } 80 | 81 | /// Get the [`UserExtended`] of the authenticated user. 82 | /// 83 | /// Note that the client has to be initialized with the `Identify` scope 84 | /// through the OAuth process in order for this endpoint to not return an error. 85 | /// 86 | /// See [`OsuBuilder::with_authorization`](crate::OsuBuilder::with_authorization). 87 | #[must_use = "requests must be configured and executed"] 88 | pub struct GetOwnData<'a> { 89 | osu: &'a Osu, 90 | mode: Option, 91 | } 92 | 93 | impl<'a> GetOwnData<'a> { 94 | pub(crate) const fn new(osu: &'a Osu) -> Self { 95 | Self { osu, mode: None } 96 | } 97 | 98 | /// Specify the mode for which the user data should be retrieved 99 | #[inline] 100 | pub const fn mode(mut self, mode: GameMode) -> Self { 101 | self.mode = Some(mode); 102 | 103 | self 104 | } 105 | } 106 | 107 | into_future! { 108 | |self: GetOwnData<'_>| -> UserExtended { 109 | Request::new(Route::GetOwnData { mode: self.mode }) 110 | } 111 | } 112 | 113 | /// Get all friends of the authenticated user as a vec of [`User`]. 114 | /// 115 | /// Note that the client has to be initialized with the `FriendsRead` scope 116 | /// through the OAuth process in order for this endpoint to not return an error. 117 | /// 118 | /// See [`OsuBuilder::with_authorization`](crate::OsuBuilder::with_authorization). 119 | #[must_use = "requests must be configured and executed"] 120 | pub struct GetFriends<'a> { 121 | osu: &'a Osu, 122 | } 123 | 124 | impl<'a> GetFriends<'a> { 125 | pub(crate) const fn new(osu: &'a Osu) -> Self { 126 | Self { osu } 127 | } 128 | } 129 | 130 | into_future! { 131 | |self: GetFriends<'_>| -> Vec { 132 | Request::new(Route::GetFriends) 133 | } 134 | } 135 | 136 | /// Get a [`UserExtended`]. 137 | #[must_use = "requests must be configured and executed"] 138 | pub struct GetUser<'a> { 139 | osu: &'a Osu, 140 | user_id: UserId, 141 | mode: Option, 142 | } 143 | 144 | impl<'a> GetUser<'a> { 145 | pub(crate) const fn new(osu: &'a Osu, user_id: UserId) -> Self { 146 | Self { 147 | osu, 148 | user_id, 149 | mode: None, 150 | } 151 | } 152 | 153 | /// Specify the mode for which the user data should be retrieved 154 | #[inline] 155 | pub const fn mode(mut self, mode: GameMode) -> Self { 156 | self.mode = Some(mode); 157 | 158 | self 159 | } 160 | 161 | /// Auxiliary function so that [`GetUser`]'s future can be created without 162 | /// an actual [`GetUser`] instance. 163 | /// 164 | /// Used for username caching. 165 | pub(crate) fn create_request(user_id: UserId, mode: Option) -> Request { 166 | #[derive(Serialize)] 167 | pub struct UserQuery { 168 | #[serde(rename(serialize = "key"), serialize_with = "user_id_type")] 169 | user_id: UserId, 170 | } 171 | 172 | let user_query = UserQuery { user_id }; 173 | let query = Query::encode(&user_query); 174 | let user_id = user_query.user_id; 175 | 176 | let route = Route::GetUser { user_id, mode }; 177 | 178 | Request::with_query(route, query) 179 | } 180 | } 181 | 182 | into_future! { 183 | |self: GetUser<'_>| -> UserExtended { 184 | Self::create_request(self.user_id, self.mode) 185 | } 186 | } 187 | 188 | /// Get the [`BeatmapsetExtended`]s of a user. 189 | #[must_use = "requests must be configured and executed"] 190 | #[derive(Serialize)] 191 | pub struct GetUserBeatmapsets<'a> { 192 | #[serde(skip)] 193 | osu: &'a Osu, 194 | #[serde(skip)] 195 | map_kind: UserBeatmapsetsKind, 196 | limit: Option, 197 | offset: Option, 198 | #[serde(skip)] 199 | user_id: UserId, 200 | } 201 | 202 | impl<'a> GetUserBeatmapsets<'a> { 203 | pub(crate) const fn new(osu: &'a Osu, user_id: UserId, kind: UserBeatmapsetsKind) -> Self { 204 | Self { 205 | osu, 206 | user_id, 207 | map_kind: kind, 208 | limit: None, 209 | offset: None, 210 | } 211 | } 212 | 213 | /// Limit the amount of results in the response 214 | #[inline] 215 | pub const fn limit(mut self, limit: usize) -> Self { 216 | self.limit = Some(limit); 217 | 218 | self 219 | } 220 | 221 | /// Set an offset for the requested elements 222 | /// e.g. skip the first `offset` amount in the list 223 | #[inline] 224 | pub const fn offset(mut self, offset: usize) -> Self { 225 | self.offset = Some(offset); 226 | 227 | self 228 | } 229 | 230 | /// Only include mapsets of the specified type 231 | #[inline] 232 | pub const fn kind(mut self, kind: UserBeatmapsetsKind) -> Self { 233 | self.map_kind = kind; 234 | 235 | self 236 | } 237 | } 238 | 239 | into_future! { 240 | |self: GetUserBeatmapsets<'_>| -> Vec { 241 | GetUserBeatmapsetsData { 242 | map_kind: UserBeatmapsetsKind = self.map_kind, 243 | query: String = Query::encode(&self), 244 | } 245 | } => |user_id, data| { 246 | Request::with_query( 247 | Route::GetUserBeatmapsets { 248 | user_id, 249 | map_type: data.map_kind.as_str(), 250 | }, 251 | data.query, 252 | ) 253 | } 254 | } 255 | 256 | /// Get a user's kudosu history as a vec of [`KudosuHistory`]. 257 | #[must_use = "requests must be configured and executed"] 258 | #[derive(Serialize)] 259 | pub struct GetUserKudosu<'a> { 260 | #[serde(skip)] 261 | osu: &'a Osu, 262 | limit: Option, 263 | offset: Option, 264 | #[serde(skip)] 265 | user_id: UserId, 266 | } 267 | 268 | impl<'a> GetUserKudosu<'a> { 269 | pub(crate) const fn new(osu: &'a Osu, user_id: UserId) -> Self { 270 | Self { 271 | osu, 272 | user_id, 273 | limit: None, 274 | offset: None, 275 | } 276 | } 277 | 278 | /// Limit the amount of results in the response 279 | #[inline] 280 | pub const fn limit(mut self, limit: usize) -> Self { 281 | self.limit = Some(limit); 282 | 283 | self 284 | } 285 | 286 | /// Set an offset for the requested elements 287 | /// e.g. skip the first `offset` amount in the list 288 | #[inline] 289 | pub const fn offset(mut self, offset: usize) -> Self { 290 | self.offset = Some(offset); 291 | 292 | self 293 | } 294 | } 295 | 296 | into_future! { 297 | |self: GetUserKudosu<'_>| -> Vec { 298 | GetUserKudosuData { 299 | query: String = Query::encode(&self), 300 | } 301 | } => |user_id, data| { 302 | Request::with_query(Route::GetUserKudosu { user_id }, data.query) 303 | } 304 | } 305 | 306 | /// Get the most played beatmaps of a user as a vec of [`MostPlayedMap`]. 307 | #[must_use = "requests must be configured and executed"] 308 | #[derive(Serialize)] 309 | pub struct GetUserMostPlayed<'a> { 310 | #[serde(skip)] 311 | osu: &'a Osu, 312 | limit: Option, 313 | offset: Option, 314 | #[serde(skip)] 315 | user_id: UserId, 316 | } 317 | 318 | impl<'a> GetUserMostPlayed<'a> { 319 | pub(crate) const fn new(osu: &'a Osu, user_id: UserId) -> Self { 320 | Self { 321 | osu, 322 | user_id, 323 | limit: None, 324 | offset: None, 325 | } 326 | } 327 | 328 | /// The API provides at most 51 results per requests. 329 | #[inline] 330 | pub const fn limit(mut self, limit: usize) -> Self { 331 | self.limit = Some(limit); 332 | 333 | self 334 | } 335 | 336 | /// Set an offset for the requested elements 337 | /// e.g. skip the first `offset` amount in the list 338 | #[inline] 339 | pub const fn offset(mut self, offset: usize) -> Self { 340 | self.offset = Some(offset); 341 | 342 | self 343 | } 344 | } 345 | 346 | into_future! { 347 | |self: GetUserMostPlayed<'_>| -> Vec { 348 | GetUserMostPlayedData { 349 | query: String = Query::encode(&self), 350 | } 351 | } => |user_id, data| { 352 | let route = Route::GetUserBeatmapsets { 353 | user_id, 354 | map_type: "most_played", 355 | }; 356 | 357 | Request::with_query(route, data.query) 358 | } 359 | } 360 | 361 | /// Get a vec of [`Event`] of a user. 362 | #[must_use = "requests must be configured and executed"] 363 | #[derive(Serialize)] 364 | pub struct GetRecentActivity<'a> { 365 | #[serde(skip)] 366 | osu: &'a Osu, 367 | limit: Option, 368 | offset: Option, 369 | #[serde(skip)] 370 | user_id: UserId, 371 | } 372 | 373 | impl<'a> GetRecentActivity<'a> { 374 | pub(crate) const fn new(osu: &'a Osu, user_id: UserId) -> Self { 375 | Self { 376 | osu, 377 | user_id, 378 | limit: None, 379 | offset: None, 380 | } 381 | } 382 | 383 | /// Limit the amount of results in the response 384 | #[inline] 385 | pub const fn limit(mut self, limit: usize) -> Self { 386 | self.limit = Some(limit); 387 | 388 | self 389 | } 390 | 391 | /// Set an offset for the requested elements 392 | /// e.g. skip the first `offset` amount in the list 393 | #[inline] 394 | pub const fn offset(mut self, offset: usize) -> Self { 395 | self.offset = Some(offset); 396 | 397 | self 398 | } 399 | } 400 | 401 | into_future! { 402 | |self: GetRecentActivity<'_>| -> Vec { 403 | GetRecentActivityData { 404 | query: String = Query::encode(&self), 405 | } 406 | } => |user_id, data| { 407 | Request::with_query(Route::GetRecentActivity { user_id }, data.query) 408 | } 409 | } 410 | 411 | #[derive(Copy, Clone, Debug)] 412 | pub(crate) enum ScoreType { 413 | Best, 414 | First, 415 | Pinned, 416 | Recent, 417 | } 418 | 419 | impl ScoreType { 420 | pub(crate) const fn as_str(self) -> &'static str { 421 | match self { 422 | Self::Best => "best", 423 | Self::First => "firsts", 424 | Self::Pinned => "pinned", 425 | Self::Recent => "recent", 426 | } 427 | } 428 | } 429 | 430 | /// Get a vec of [`Score`]s of a user. 431 | /// 432 | /// If no score type is specified by either 433 | /// [`best`](crate::request::GetUserScores::best), 434 | /// [`firsts`](crate::request::GetUserScores::firsts), 435 | /// or [`recent`](crate::request::GetUserScores::recent), it defaults to `best`. 436 | #[must_use = "requests must be configured and executed"] 437 | #[derive(Serialize)] 438 | pub struct GetUserScores<'a> { 439 | #[serde(skip)] 440 | osu: &'a Osu, 441 | #[serde(skip)] 442 | score_type: ScoreType, 443 | limit: Option, 444 | offset: Option, 445 | #[serde(serialize_with = "maybe_bool_as_u8")] 446 | include_fails: Option, 447 | #[serde(serialize_with = "maybe_mode_as_str")] 448 | mode: Option, 449 | legacy_only: bool, 450 | #[serde(skip)] 451 | legacy_scores: bool, 452 | #[serde(skip)] 453 | user_id: UserId, 454 | } 455 | 456 | impl<'a> GetUserScores<'a> { 457 | pub(crate) const fn new(osu: &'a Osu, user_id: UserId) -> Self { 458 | Self { 459 | osu, 460 | user_id, 461 | score_type: ScoreType::Best, 462 | limit: None, 463 | offset: None, 464 | include_fails: None, 465 | mode: None, 466 | legacy_only: false, 467 | legacy_scores: false, 468 | } 469 | } 470 | 471 | /// The API provides at most 100 results per requests. 472 | #[inline] 473 | pub const fn limit(mut self, limit: usize) -> Self { 474 | self.limit = Some(limit); 475 | 476 | self 477 | } 478 | 479 | /// Set an offset for the requested elements 480 | /// e.g. skip the first `offset` amount in the list 481 | #[inline] 482 | pub const fn offset(mut self, offset: usize) -> Self { 483 | self.offset = Some(offset); 484 | 485 | self 486 | } 487 | 488 | /// Specify the mode of the scores 489 | #[inline] 490 | pub const fn mode(mut self, mode: GameMode) -> Self { 491 | self.mode = Some(mode); 492 | 493 | self 494 | } 495 | 496 | /// Specify whether failed scores can be included. 497 | /// 498 | /// Only relevant for [`recent`](GetUserScores::recent) 499 | #[inline] 500 | pub const fn include_fails(mut self, include_fails: bool) -> Self { 501 | self.include_fails = Some(include_fails); 502 | 503 | self 504 | } 505 | 506 | /// Get top scores of a user 507 | #[inline] 508 | pub const fn best(mut self) -> Self { 509 | self.score_type = ScoreType::Best; 510 | 511 | self 512 | } 513 | 514 | /// Get global #1 scores of a user. 515 | #[inline] 516 | pub const fn firsts(mut self) -> Self { 517 | self.score_type = ScoreType::First; 518 | 519 | self 520 | } 521 | 522 | /// Get the pinned scores of a user. 523 | #[inline] 524 | pub const fn pinned(mut self) -> Self { 525 | self.score_type = ScoreType::Pinned; 526 | 527 | self 528 | } 529 | 530 | /// Get recent scores of a user. 531 | #[inline] 532 | pub const fn recent(mut self) -> Self { 533 | self.score_type = ScoreType::Recent; 534 | 535 | self 536 | } 537 | 538 | /// Whether or not to exclude lazer scores. 539 | #[inline] 540 | pub const fn legacy_only(mut self, legacy_only: bool) -> Self { 541 | self.legacy_only = legacy_only; 542 | 543 | self 544 | } 545 | 546 | /// Specify whether the scores should contain legacy data or not. 547 | /// 548 | /// Legacy data consists of a different grade calculation, less 549 | /// populated statistics, legacy mods, and a different score kind. 550 | #[inline] 551 | pub const fn legacy_scores(mut self, legacy_scores: bool) -> Self { 552 | self.legacy_scores = legacy_scores; 553 | 554 | self 555 | } 556 | } 557 | 558 | into_future! { 559 | |self: GetUserScores<'_>| -> Vec { 560 | GetUserScoresData { 561 | query: String = Query::encode(&self), 562 | score_type: ScoreType = self.score_type, 563 | legacy_scores: bool = self.legacy_scores, 564 | } 565 | } => |user_id, data| { 566 | let route = Route::GetUserScores { 567 | user_id, 568 | score_type: data.score_type, 569 | }; 570 | 571 | let mut req = Request::with_query(route, data.query); 572 | 573 | if data.legacy_scores { 574 | req.api_version(0); 575 | } 576 | 577 | req 578 | } 579 | } 580 | 581 | /// Get a vec of [`User`]. 582 | #[must_use = "requests must be configured and executed"] 583 | pub struct GetUsers<'a> { 584 | osu: &'a Osu, 585 | query: String, 586 | } 587 | 588 | impl<'a> GetUsers<'a> { 589 | pub(crate) fn new(osu: &'a Osu, user_ids: I) -> Self 590 | where 591 | I: IntoIterator, 592 | { 593 | let mut query = String::new(); 594 | let mut buf = Buffer::new(); 595 | 596 | let mut iter = user_ids.into_iter().take(50); 597 | 598 | if let Some(user_id) = iter.next() { 599 | query.push_str("ids[]="); 600 | query.push_str(buf.format(user_id)); 601 | 602 | for user_id in iter { 603 | query.push_str("&ids[]="); 604 | query.push_str(buf.format(user_id)); 605 | } 606 | } 607 | 608 | Self { osu, query } 609 | } 610 | } 611 | 612 | into_future! { 613 | |self: GetUsers<'_>| -> DeserializedList { 614 | Request::with_query(Route::GetUsers, self.query) 615 | } => |users, _| -> Vec { 616 | Ok(users.0) 617 | } 618 | } 619 | -------------------------------------------------------------------------------- /src/request/wiki.rs: -------------------------------------------------------------------------------- 1 | use crate::{model::wiki::WikiPage, request::Request, routing::Route, Osu}; 2 | 3 | /// Get a [`WikiPage`] or image data. 4 | #[must_use = "requests must be configured and executed"] 5 | pub struct GetWikiPage<'a> { 6 | osu: &'a Osu, 7 | locale: Box, 8 | page: Option>, 9 | } 10 | 11 | impl<'a> GetWikiPage<'a> { 12 | pub(crate) fn new(osu: &'a Osu, locale: impl Into) -> Self { 13 | Self { 14 | osu, 15 | locale: Box::from(locale.into()), 16 | page: None, 17 | } 18 | } 19 | 20 | /// Specify the page 21 | #[inline] 22 | pub fn page(mut self, page: impl Into) -> Self { 23 | self.page = Some(Box::from(page.into())); 24 | 25 | self 26 | } 27 | } 28 | 29 | into_future! { 30 | |self: GetWikiPage<'_>| -> WikiPage { 31 | Request::new(Route::GetWikiPage { 32 | locale: self.locale, 33 | page: self.page, 34 | }) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/routing.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | model::{ranking::RankingType, GameMode}, 3 | request::{Method, ScoreType, UserId}, 4 | }; 5 | 6 | use std::{borrow::Cow, fmt::Write}; 7 | 8 | #[allow(clippy::enum_variant_names)] 9 | #[non_exhaustive] 10 | pub(crate) enum Route { 11 | GetBeatmap, 12 | GetBeatmaps, 13 | PostBeatmapDifficultyAttributes { 14 | map_id: u32, 15 | }, 16 | GetBeatmapScores { 17 | map_id: u32, 18 | }, 19 | GetBeatmapUserScore { 20 | user_id: u32, 21 | map_id: u32, 22 | }, 23 | GetBeatmapUserScores { 24 | user_id: u32, 25 | map_id: u32, 26 | }, 27 | GetBeatmapset { 28 | mapset_id: u32, 29 | }, 30 | GetBeatmapsetFromMapId, 31 | GetBeatmapsetEvents, 32 | GetBeatmapsetSearch, 33 | GetComments, 34 | GetEvents, 35 | GetForumPosts { 36 | topic_id: u64, 37 | }, 38 | GetFriends, 39 | GetMatch { 40 | match_id: Option, 41 | }, 42 | GetNews { 43 | news: Option<()>, 44 | }, 45 | GetOwnData { 46 | mode: Option, 47 | }, 48 | GetRankings { 49 | mode: GameMode, 50 | ranking_type: RankingType, 51 | }, 52 | GetRecentActivity { 53 | user_id: u32, 54 | }, 55 | GetReplay { 56 | mode: Option, 57 | score_id: u64, 58 | }, 59 | GetScore { 60 | mode: Option, 61 | score_id: u64, 62 | }, 63 | GetScores, 64 | GetSeasonalBackgrounds, 65 | GetSpotlights, 66 | GetUser { 67 | user_id: UserId, 68 | mode: Option, 69 | }, 70 | GetUserBeatmapsets { 71 | user_id: u32, 72 | map_type: &'static str, 73 | }, 74 | GetUserKudosu { 75 | user_id: u32, 76 | }, 77 | GetUserScores { 78 | user_id: u32, 79 | score_type: ScoreType, 80 | }, 81 | GetUsers, 82 | GetWikiPage { 83 | locale: Box, 84 | page: Option>, 85 | }, 86 | } 87 | 88 | impl Route { 89 | /// Separate a route into its parts: the HTTP method and the URI path. 90 | #[allow(clippy::too_many_lines)] 91 | pub(crate) fn as_parts(&self) -> (Method, Cow<'static, str>) { 92 | match self { 93 | Self::GetBeatmap => (Method::Get, "beatmaps/lookup".into()), 94 | Self::GetBeatmaps => (Method::Get, "beatmaps".into()), 95 | Self::PostBeatmapDifficultyAttributes { map_id } => { 96 | (Method::Post, format!("beatmaps/{map_id}/attributes").into()) 97 | } 98 | Self::GetBeatmapScores { map_id } => { 99 | (Method::Get, format!("beatmaps/{map_id}/scores").into()) 100 | } 101 | Self::GetBeatmapUserScore { map_id, user_id } => ( 102 | Method::Get, 103 | format!("beatmaps/{map_id}/scores/users/{user_id}").into(), 104 | ), 105 | Self::GetBeatmapUserScores { map_id, user_id } => ( 106 | Method::Get, 107 | format!("beatmaps/{map_id}/scores/users/{user_id}/all").into(), 108 | ), 109 | Self::GetBeatmapset { mapset_id } => { 110 | (Method::Get, format!("beatmapsets/{mapset_id}").into()) 111 | } 112 | Self::GetBeatmapsetFromMapId => (Method::Get, "beatmapsets/lookup".into()), 113 | Self::GetBeatmapsetEvents => (Method::Get, "beatmapsets/events".into()), 114 | Self::GetBeatmapsetSearch => (Method::Get, "beatmapsets/search".into()), 115 | Self::GetComments => (Method::Get, "comments".into()), 116 | Self::GetEvents => (Method::Get, "events".into()), 117 | Self::GetForumPosts { topic_id } => { 118 | (Method::Get, format!("forums/topics/{topic_id}").into()) 119 | } 120 | Self::GetFriends => (Method::Get, "friends".into()), 121 | Self::GetMatch { match_id } => { 122 | let path = match match_id { 123 | Some(id) => format!("matches/{id}").into(), 124 | None => "matches".into(), 125 | }; 126 | 127 | (Method::Get, path) 128 | } 129 | Self::GetNews { news } => { 130 | let path = match news { 131 | Some(_news) => unimplemented!(), 132 | None => "news".into(), 133 | }; 134 | 135 | (Method::Get, path) 136 | } 137 | Self::GetOwnData { mode } => { 138 | let path = match mode { 139 | Some(mode) => format!("me/{mode}").into(), 140 | None => "me".into(), 141 | }; 142 | 143 | (Method::Get, path) 144 | } 145 | Self::GetRankings { mode, ranking_type } => ( 146 | Method::Get, 147 | format!("rankings/{mode}/{}", ranking_type.as_str()).into(), 148 | ), 149 | Self::GetRecentActivity { user_id } => ( 150 | Method::Get, 151 | format!("users/{user_id}/recent_activity").into(), 152 | ), 153 | Self::GetReplay { mode, score_id } => { 154 | let path = match mode { 155 | Some(mode) => format!("scores/{mode}/{score_id}/download").into(), 156 | None => format!("scores/{score_id}/download").into(), 157 | }; 158 | (Method::Get, path) 159 | } 160 | Self::GetScore { mode, score_id } => { 161 | let path = match mode { 162 | Some(mode) => format!("scores/{mode}/{score_id}").into(), 163 | None => format!("scores/{score_id}").into(), 164 | }; 165 | (Method::Get, path) 166 | } 167 | Self::GetScores => (Method::Get, "scores".into()), 168 | Self::GetSeasonalBackgrounds => (Method::Get, "seasonal-backgrounds".into()), 169 | Self::GetSpotlights => (Method::Get, "spotlights".into()), 170 | Self::GetUser { user_id, mode } => { 171 | let mut path = format!("users/{user_id}"); 172 | 173 | if let Some(mode) = mode { 174 | let _ = write!(path, "/{mode}"); 175 | } 176 | 177 | (Method::Get, path.into()) 178 | } 179 | Self::GetUserBeatmapsets { user_id, map_type } => ( 180 | Method::Get, 181 | format!("users/{user_id}/beatmapsets/{map_type}").into(), 182 | ), 183 | Self::GetUserKudosu { user_id } => { 184 | (Method::Get, format!("users/{user_id}/kudosu").into()) 185 | } 186 | Self::GetUserScores { 187 | user_id, 188 | score_type, 189 | } => ( 190 | Method::Get, 191 | format!("users/{user_id}/scores/{}", score_type.as_str()).into(), 192 | ), 193 | Self::GetUsers => (Method::Get, "users".into()), 194 | Self::GetWikiPage { locale, page } => { 195 | let mut path = format!("wiki/{locale}/"); 196 | 197 | if let Some(page) = page { 198 | path.push_str(page); 199 | } 200 | 201 | (Method::Get, path.into()) 202 | } 203 | } 204 | } 205 | 206 | #[cfg(feature = "metrics")] 207 | pub(crate) const fn name(&self) -> &'static str { 208 | match self { 209 | Self::GetBeatmap => "GetBeatmap", 210 | Self::GetBeatmaps => "GetBeatmaps", 211 | Self::PostBeatmapDifficultyAttributes { .. } => "PostBeatmapDifficultyAttributes", 212 | Self::GetBeatmapScores { .. } => "GetBeatmapScores", 213 | Self::GetBeatmapUserScore { .. } => "GetBeatmapUserScore", 214 | Self::GetBeatmapUserScores { .. } => "GetBeatmapUserScores", 215 | Self::GetBeatmapset { .. } => "GetBeatmapset", 216 | Self::GetBeatmapsetFromMapId => "GetBeatmapsetFromMapId", 217 | Self::GetBeatmapsetEvents => "GetBeatmapsetEvents", 218 | Self::GetBeatmapsetSearch => "GetBeatmapsetSearch", 219 | Self::GetComments => "GetComments", 220 | Self::GetEvents => "GetEvents", 221 | Self::GetForumPosts { .. } => "GetForumPosts", 222 | Self::GetFriends => "GetFriends", 223 | Self::GetMatch { match_id } => match match_id { 224 | Some(_) => "GetMatch/match_id", 225 | None => "GetMatch", 226 | }, 227 | Self::GetNews { .. } => "GetNews", 228 | Self::GetOwnData { .. } => "GetOwnData", 229 | Self::GetRankings { ranking_type, .. } => match ranking_type { 230 | RankingType::Charts => "GetRankings/Charts", 231 | RankingType::Country => "GetRankings/Country", 232 | RankingType::Performance => "GetRankings/Performance", 233 | RankingType::Score => "GetRankings/Score", 234 | RankingType::Team => "GetRankings/Team", 235 | }, 236 | Self::GetRecentActivity { .. } => "GetRecentActivity", 237 | Self::GetReplay { .. } => "GetReplay", 238 | Self::GetScore { .. } => "GetScore", 239 | Self::GetScores => "GetScores", 240 | Self::GetSeasonalBackgrounds => "GetSeasonalBackgrounds", 241 | Self::GetSpotlights => "GetSpotlights", 242 | Self::GetUser { .. } => "GetUser", 243 | Self::GetUserBeatmapsets { .. } => "GetUserBeatmapsets", 244 | Self::GetUserKudosu { .. } => "GetUserKudosu", 245 | Self::GetUserScores { score_type, .. } => match score_type { 246 | ScoreType::Best => "GetUserScores/Best", 247 | ScoreType::First => "GetUserScores/First", 248 | ScoreType::Pinned => "GetUserScores/Pinned", 249 | ScoreType::Recent => "GetUserScores/Recent", 250 | }, 251 | Self::GetUsers => "GetUsers", 252 | Self::GetWikiPage { .. } => "GetWikiPage", 253 | } 254 | } 255 | } 256 | --------------------------------------------------------------------------------