diff --git a/Cargo.lock b/Cargo.lock index 19f6627e78..028da5aaf8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -59,9 +59,9 @@ dependencies = [ [[package]] name = "actix-http" -version = "3.11.2" +version = "3.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7926860314cbe2fb5d1f13731e387ab43bd32bca224e82e6e2db85de0a3dba49" +checksum = "48e2faa3e7418ed780cca54829d32782a4008a077230f67457caa063415e99c2" dependencies = [ "actix-codec", "actix-rt", @@ -75,7 +75,7 @@ dependencies = [ "derive_more 2.1.1", "encoding_rs", "flate2", - "foldhash 0.1.5", + "foldhash 0.2.0", "futures-core", "h2 0.3.27", "http 0.2.12", @@ -87,8 +87,8 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "rand 0.9.2", - "sha1", + "rand 0.10.1", + "sha1 0.11.0", "smallvec", "tokio", "tokio-util", @@ -146,9 +146,9 @@ dependencies = [ [[package]] name = "actix-router" -version = "0.5.3" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13d324164c51f63867b57e73ba5936ea151b8a41a1d23d1031eeb9f70d0236f8" +checksum = "14f8c75c51892f18d9c46150c5ac7beb81c95f78c8b83a634d49f4ca32551fe7" dependencies = [ "bytestring", "cfg-if", @@ -209,9 +209,9 @@ dependencies = [ [[package]] name = "actix-web" -version = "4.11.0" +version = "4.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a597b77b5c6d6a1e1097fddde329a83665e25c5437c696a3a9a4aa514a614dea" +checksum = "df09e2d9239703dd64056359c920c7f3fba6535ec61a0059e0f44e095ffe02b4" dependencies = [ "actix-codec", "actix-http", @@ -228,7 +228,7 @@ dependencies = [ "cookie 0.16.2", "derive_more 2.1.1", "encoding_rs", - "foldhash 0.1.5", + "foldhash 0.2.0", "futures-core", "futures-util", "impl-more", @@ -244,7 +244,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "smallvec", - "socket2 0.5.10", + "socket2 0.6.1", "time", "tracing", "url", @@ -1127,7 +1127,7 @@ dependencies = [ "http-body-util", "md-5", "pin-project-lite", - "sha1", + "sha1 0.10.6", "sha2", "tracing", ] @@ -1499,7 +1499,7 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -1511,6 +1511,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block2" version = "0.5.1" @@ -2224,6 +2233,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "const-random" version = "0.1.18" @@ -2424,7 +2439,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fd92aca2c6001b1bf5ba0ff84ee74ec8501b52bbef0cac80bf25a6c1d87a83d" dependencies = [ "crc", - "digest", + "digest 0.10.7", "rustversion", "spin 0.10.0", ] @@ -2515,6 +2530,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "cssparser" version = "0.29.6" @@ -2791,7 +2815,7 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", "pem-rfc7468", "zeroize", ] @@ -2904,12 +2928,23 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.6", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", +] + [[package]] name = "directories" version = "6.0.0" @@ -3127,7 +3162,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ "der", - "digest", + "digest 0.10.7", "elliptic-curve", "rfc6979", "signature", @@ -3151,7 +3186,7 @@ checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ "base16ct", "crypto-bigint", - "digest", + "digest 0.10.7", "ff", "generic-array", "group", @@ -4402,7 +4437,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -4532,6 +4567,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "0.14.32" @@ -4893,9 +4937,9 @@ checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" [[package]] name = "impl-more" -version = "0.1.9" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" +checksum = "35a84fd5aa25fae5c0f4a33d9cac2ca017fc622cbd089be2229993514990f870" [[package]] name = "indenter" @@ -5390,11 +5434,12 @@ dependencies = [ "rust_iso3166", "rustls 0.23.32", "rusty-money", + "scalar_api_reference", "sentry", "serde", "serde_json", "serde_with", - "sha1", + "sha1 0.10.6", "sha2", "spdx", "sqlx", @@ -5411,8 +5456,6 @@ dependencies = [ "url", "urlencoding", "utoipa", - "utoipa-actix-web", - "utoipa-scalar", "uuid 1.23.3", "validator", "webauthn-rs", @@ -5822,7 +5865,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ "cfg-if", - "digest", + "digest 0.10.7", ] [[package]] @@ -8494,8 +8537,8 @@ version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b" dependencies = [ - "const-oid", - "digest", + "const-oid 0.9.6", + "digest 0.10.7", "num-bigint-dig", "num-integer", "num-traits", @@ -8508,6 +8551,40 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rust-embed" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.106", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" +dependencies = [ + "sha2", + "walkdir", +] + [[package]] name = "rust-ini" version = "0.21.3" @@ -8827,6 +8904,18 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scalar_api_reference" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d67c85edd56c41f364603eff688a330f01dee99bdfc84c005d3bc3d0721d6b2" +dependencies = [ + "actix-web", + "rust-embed", + "serde", + "serde_json", +] + [[package]] name = "scc" version = "2.4.0" @@ -9252,15 +9341,15 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.145" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", - "ryu", "serde", "serde_core", + "zmij", ] [[package]] @@ -9455,7 +9544,18 @@ checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -9472,7 +9572,7 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", ] [[package]] @@ -9505,7 +9605,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest", + "digest 0.10.7", "rand_core 0.6.4", ] @@ -9782,7 +9882,7 @@ dependencies = [ "bytes", "chrono", "crc", - "digest", + "digest 0.10.7", "dotenvy", "either", "futures-channel", @@ -9803,7 +9903,7 @@ dependencies = [ "rsa", "rust_decimal", "serde", - "sha1", + "sha1 0.10.6", "sha2", "smallvec", "sqlx-core", @@ -11290,7 +11390,7 @@ dependencies = [ "constant_time_eq", "hmac", "rand 0.9.2", - "sha1", + "sha1 0.10.6", "sha2", ] @@ -11505,7 +11605,7 @@ dependencies = [ "rand 0.9.2", "rustls 0.23.32", "rustls-pki-types", - "sha1", + "sha1 0.10.6", "thiserror 2.0.17", "utf-8", ] @@ -11524,9 +11624,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "uds_windows" @@ -11757,17 +11857,6 @@ dependencies = [ "utoipa-gen", ] -[[package]] -name = "utoipa-actix-web" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7eda9c23c05af0fb812f6a177514047331dac4851a2c8e9c4b895d6d826967f" -dependencies = [ - "actix-service", - "actix-web", - "utoipa", -] - [[package]] name = "utoipa-gen" version = "5.4.0" @@ -11782,18 +11871,6 @@ dependencies = [ "uuid 1.23.3", ] -[[package]] -name = "utoipa-scalar" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59559e1509172f6b26c1cdbc7247c4ddd1ac6560fe94b584f81ee489b141f719" -dependencies = [ - "actix-web", - "serde", - "serde_json", - "utoipa", -] - [[package]] name = "uuid" version = "0.8.2" @@ -13450,6 +13527,12 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f06ae92f42f5e5c42443fd094f245eb656abf56dd7cce9b8b263236565e00f2" +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + [[package]] name = "zopfli" version = "0.8.2" diff --git a/Cargo.toml b/Cargo.toml index ac9044a826..a7a6d055b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -158,6 +158,7 @@ rust-s3 = { version = "0.37.0", default-features = false, features = [ ] } rustls = "0.23.32" rusty-money = "0.4.1" +scalar_api_reference = { version = "0.2.2", default-features = false } secrecy = "0.10.3" sentry = { version = "0.45.0", default-features = false, features = [ "backtrace", @@ -217,8 +218,6 @@ typed-path = "0.12.0" url = "2.5.7" urlencoding = "2.1.3" utoipa = { version = "5.4.0", features = ["actix_extras", "chrono", "decimal"] } -utoipa-actix-web = { version = "0.1.2" } -utoipa-scalar = { version = "0.3.0", default-features = false } uuid = "1.18.1" validator = "0.20.0" webauthn-rs = "0.5.5" diff --git a/apps/labrinth/Cargo.toml b/apps/labrinth/Cargo.toml index 1c1ba0c42c..4f702d6bab 100644 --- a/apps/labrinth/Cargo.toml +++ b/apps/labrinth/Cargo.toml @@ -100,6 +100,7 @@ rust_decimal = { workspace = true, features = [ rust_iso3166 = { workspace = true } rustls.workspace = true rusty-money = { workspace = true } +scalar_api_reference = { workspace = true, features = ["actix-web"] } sentry = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } @@ -128,8 +129,6 @@ tracing-actix-web = { workspace = true } url = { workspace = true } urlencoding = { workspace = true } utoipa = { workspace = true, features = ["url"] } -utoipa-actix-web = { workspace = true } -utoipa-scalar = { workspace = true, features = ["actix-web"] } uuid = { workspace = true, features = ["fast-rng", "serde", "v4", "v7"] } validator = { workspace = true, features = ["derive"] } webauthn-rs = { workspace = true, features = [ diff --git a/apps/labrinth/src/api_v2_description.md b/apps/labrinth/src/api_v2_description.md new file mode 100644 index 0000000000..268a6b3cee --- /dev/null +++ b/apps/labrinth/src/api_v2_description.md @@ -0,0 +1,117 @@ +API v2 is Modrinth's official API. It is the recommended API for integrations and applications, and it will receive long-term support. + +## Authentication +This API has two options for authentication: personal access tokens and [OAuth2](https://en.wikipedia.org/wiki/OAuth). +All tokens are tied to a Modrinth user and use the `Authorization` header of the request. + +Example: +``` +Authorization: mrp_RNtLRSPmGj2pd1v1ubi52nX7TJJM9sznrmwhAuj511oe4t1jAqAQ3D6Wc8Ic +``` + +You do not need a token for most requests. Generally speaking, only the following types of requests require a token: +- those which create data (such as version creation) +- those which modify data (such as editing a project) +- those which access private data (such as draft projects, notifications, emails, and payout data) + +Each request requiring authentication has a certain scope. For example, to view the email of the user being requested, the token must have the `USER_READ_EMAIL` scope. +You can find the list of available scopes [on GitHub](https://github.com/modrinth/labrinth/blob/master/src/models/pats.rs#L15). Making a request with an invalid scope will return a 401 error. + +Please note that certain scopes and requests cannot be completed with a personal access token or using OAuth. +For example, deleting a user account can only be done through Modrinth's frontend. + +A detailed guide on OAuth has been published in [Modrinth's technical documentation](https://docs.modrinth.com/guide/oauth). + +### Personal access tokens +Personal access tokens (PATs) can be generated in from [the user settings](https://modrinth.com/settings/account). + +### GitHub tokens +For backwards compatibility purposes, some types of GitHub tokens also work for authenticating a user with Modrinth's API, granting all scopes. +**We urge any application still using GitHub tokens to start using personal access tokens for security and reliability purposes.** + +## Cross-Origin Resource Sharing +This API features Cross-Origin Resource Sharing (CORS) implemented in compliance with the [W3C spec](https://www.w3.org/TR/cors/). +This allows for cross-domain communication from the browser. +All responses have a wildcard same-origin which makes them completely public and accessible to everyone, including any code on any site. + +## Identifiers +The majority of items you can interact with in the API have a unique eight-digit base62 ID. +Projects, versions, users, threads, teams, and reports all use this same way of identifying themselves. +Version files use the sha1 or sha512 file hashes as identifiers. + +Each project and user has a friendlier way of identifying them; slugs and usernames, respectively. +While unique IDs are constant, slugs and usernames can change at any moment. +If you want to store something in the long term, it is recommended to use the unique ID. + +## Ratelimits +The API has a ratelimit defined per IP. Limits and remaining amounts are given in the response headers. +- `X-Ratelimit-Limit`: the maximum number of requests that can be made in a minute +- `X-Ratelimit-Remaining`: the number of requests remaining in the current ratelimit window +- `X-Ratelimit-Reset`: the time in seconds until the ratelimit window resets + +Ratelimits are the same no matter whether you use a token or not. +The ratelimit is currently 300 requests per minute. If you have a use case requiring a higher limit, please [contact us](mailto:support@modrinth.com). + +## User Agents +To access the Modrinth API, you **must** use provide a uniquely-identifying `User-Agent` header. +Providing a user agent that only identifies your HTTP client library (such as "okhttp/4.9.3") increases the likelihood that we will block your traffic. +It is recommended, but not required, to include contact information in your user agent. +This allows us to contact you if we would like a change in your application's behavior without having to block your traffic. +- Bad: `User-Agent: okhttp/4.9.3` +- Good: `User-Agent: project_name` +- Better: `User-Agent: github_username/project_name/1.56.0` +- Best: `User-Agent: github_username/project_name/1.56.0 (launcher.com)` or `User-Agent: github_username/project_name/1.56.0 (contact@launcher.com)` + +## Versioning +Modrinth follows a simple pattern for its API versioning. +In the event of a breaking API change, the API version in the URL path is bumped, and migration steps will be published below. + +API v2 is the current official API and will receive long-term support. + +### Migrations +Inside the following spoiler, you will be able to find all changes between versions of the Modrinth API, accompanied by tips and a guide to migrate applications to newer versions. + +Here, you can also find changes for [Minotaur](https://github.com/modrinth/minotaur), Modrinth's official Gradle plugin. Major versions of Minotaur directly correspond to major versions of the Modrinth API. + +
API v1 to API v2 + +These bullet points cover most changes in the v2 API, but please note that fields containing `mod` in most contexts have been shifted to `project`. For example, in the search route, the field `mod_id` was renamed to `project_id`. + +- The search route has been moved from `/api/v1/mod` to `/v2/search` +- New project fields: `project_type` (may be `mod` or `modpack`), `moderation_message` (which has a `message` and `body`), `gallery` +- New search facet: `project_type` +- Alphabetical sort removed (it didn't work and is not possible due to limits in MeiliSearch) +- New search fields: `project_type`, `gallery` + - The gallery field is an array of URLs to images that are part of the project's gallery +- The gallery is a new feature which allows the user to upload images showcasing their mod to the CDN which will be displayed on their mod page +- Internal change: Any project file uploaded to Modrinth is now validated to make sure it's a valid Minecraft mod, Modpack, etc. + - For example, a Forge 1.17 mod with a JAR not containing a mods.toml will not be allowed to be uploaded to Modrinth +- In project creation, projects may not upload a mod with no versions to review, however they can be saved as a draft + - Similarly, for version creation, a version may not be uploaded without any files +- Donation URLs have been enabled +- New project status: `archived`. Projects with this status do not appear in search +- Tags (such as categories, loaders) now have icons (SVGs) and specific project types attached +- Dependencies have been wiped and replaced with a new system +- Notifications now have a `type` field, such as `project_update` + +Along with this, project subroutes (such as `/v2/project/{id}/version`) now allow the slug to be used as the ID. This is also the case with user routes. + +
Minotaur v1 to Minotaur v2 + +Minotaur 2.x introduced a few breaking changes to how your buildscript is formatted. + +First, instead of registering your own `publishModrinth` task, Minotaur now automatically creates a `modrinth` task. As such, you can replace the `task publishModrinth(type: TaskModrinthUpload) {` line with just `modrinth {`. + +To declare supported Minecraft versions and mod loaders, the `gameVersions` and `loaders` arrays must now be used. The syntax for these are pretty self-explanatory. + +Instead of using `releaseType`, you must now use `versionType`. This was actually changed in v1.2.0, but very few buildscripts have moved on from v1.1.0. + +Dependencies have been changed to a special DSL. Create a `dependencies` block within the `modrinth` block, and then use `scope.type("project/version")`. For example, `required.project("fabric-api")` adds a required project dependency on Fabric API. + +You may now use the slug anywhere that a project ID was previously required. + +
+ +
+ +The above snippet about User Agents was adapted from https://crates.io/policies, copyright (c) 2014 The Rust Project Developers under MIT license. diff --git a/apps/labrinth/src/auth/oauth/mod.rs b/apps/labrinth/src/auth/oauth/mod.rs index d3fc5df6ea..95bec03a90 100644 --- a/apps/labrinth/src/auth/oauth/mod.rs +++ b/apps/labrinth/src/auth/oauth/mod.rs @@ -18,7 +18,7 @@ use crate::models::ids::OAuthClientId; use crate::models::pats::Scopes; use crate::queue::session::AuthQueue; use actix_web::http::header::{CACHE_CONTROL, LOCATION, PRAGMA}; -use actix_web::web::{Data, Query, ServiceConfig}; +use actix_web::web::{Data, Query}; use actix_web::{HttpRequest, HttpResponse, get, post, web}; use chrono::{DateTime, Duration}; use rand::distributions::Alphanumeric; @@ -33,7 +33,7 @@ use super::AuthenticationError; pub mod errors; pub mod uris; -pub fn config(cfg: &mut ServiceConfig) { +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(init_oauth) .service(accept_client_scopes) .service(reject_client_scopes) @@ -57,6 +57,7 @@ pub struct OAuthClientAccessRequest { pub requested_scopes: Scopes, } +#[utoipa::path(path = "/authorize", tag = "oauth", responses((status = OK)))] #[get("authorize")] pub async fn init_oauth( req: HttpRequest, @@ -165,11 +166,12 @@ pub async fn init_oauth( } } -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, utoipa::ToSchema)] pub struct RespondToOAuthClientScopes { pub flow: String, } +#[utoipa::path(path = "/accept", tag = "oauth", responses((status = OK)))] #[post("accept")] pub async fn accept_client_scopes( req: HttpRequest, @@ -189,6 +191,7 @@ pub async fn accept_client_scopes( .await } +#[utoipa::path(path = "/reject", tag = "oauth", responses((status = OK)))] #[post("reject")] pub async fn reject_client_scopes( req: HttpRequest, @@ -201,7 +204,7 @@ pub async fn reject_client_scopes( .await } -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, utoipa::ToSchema)] pub struct TokenRequest { pub grant_type: String, pub code: String, @@ -216,6 +219,7 @@ pub struct TokenResponse { pub expires_in: i64, } +#[utoipa::path(path = "/token", tag = "oauth", responses((status = OK)))] #[post("token")] /// Params should be in the urlencoded request body /// And client secret should be in the HTTP basic authorization header @@ -464,3 +468,13 @@ fn append_params_to_uri(uri: &str, params: &[impl AsRef]) -> String { uri } + +#[derive(utoipa::OpenApi)] +#[openapi(paths( + init_oauth, + accept_client_scopes, + reject_client_scopes, + request_token, +))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; diff --git a/apps/labrinth/src/lib.rs b/apps/labrinth/src/lib.rs index 076a19dd4a..ce2054ce3d 100644 --- a/apps/labrinth/src/lib.rs +++ b/apps/labrinth/src/lib.rs @@ -361,6 +361,14 @@ pub fn app_setup( pub fn app_config( cfg: &mut web::ServiceConfig, labrinth_config: LabrinthConfig, +) { + app_data_config(cfg, labrinth_config); + app_fallback_config(cfg); +} + +pub fn app_data_config( + cfg: &mut web::ServiceConfig, + labrinth_config: LabrinthConfig, ) { cfg.app_data(web::FormConfig::default().error_handler(|err, _req| { routes::ApiError::Validation(err.to_string()).into() @@ -398,16 +406,17 @@ pub fn app_config( .app_data(labrinth_config.rate_limiter.clone()) .app_data(labrinth_config.kafka_client.clone()) .app_data(labrinth_config.search_state.clone()) - .app_data(labrinth_config.webauthn.clone()) - .configure(routes::v3::config) - .configure(routes::internal::config) - .configure(routes::root_config) - .default_service(web::get().wrap(default_cors()).to(routes::not_found)); + .app_data(labrinth_config.webauthn.clone()); } -pub fn utoipa_app_config( - cfg: &mut utoipa_actix_web::service_config::ServiceConfig, - _labrinth_config: LabrinthConfig, +pub fn app_fallback_config(cfg: &mut web::ServiceConfig) { + cfg.configure(routes::root_config) + .default_service(web::get().to(routes::not_found).wrap(default_cors())); +} + +pub fn app_routes_config( + cfg: &mut web::ServiceConfig, + labrinth_config: LabrinthConfig, ) { cfg.configure({ #[cfg(target_os = "linux")] @@ -419,7 +428,29 @@ pub fn utoipa_app_config( |_cfg| () } }) - .configure(routes::v2::utoipa_config) - .configure(routes::v3::utoipa_config) - .configure(routes::internal::utoipa_config); + .configure(|cfg| app_routes_config_v2(cfg, labrinth_config.clone())) + .configure(|cfg| app_routes_config_v3(cfg, labrinth_config.clone())) + .configure(|cfg| app_routes_config_internal(cfg, labrinth_config)); +} + +pub fn app_routes_config_v2( + cfg: &mut web::ServiceConfig, + _labrinth_config: LabrinthConfig, +) { + cfg.configure(routes::v2::config); +} + +pub fn app_routes_config_v3( + cfg: &mut web::ServiceConfig, + _labrinth_config: LabrinthConfig, +) { + cfg.configure(routes::public_config) + .configure(routes::v3::config); +} + +pub fn app_routes_config_internal( + cfg: &mut web::ServiceConfig, + _labrinth_config: LabrinthConfig, +) { + cfg.configure(routes::internal::config); } diff --git a/apps/labrinth/src/main.rs b/apps/labrinth/src/main.rs index 8b636f485e..2a75f394d7 100644 --- a/apps/labrinth/src/main.rs +++ b/apps/labrinth/src/main.rs @@ -2,7 +2,7 @@ use actix_web::dev::Service; use actix_web::middleware::from_fn; -use actix_web::{App, HttpServer, web}; +use actix_web::{App, HttpResponse, HttpServer, web}; use actix_web_prom::PrometheusMetricsBuilder; use clap::Parser; @@ -15,17 +15,20 @@ use labrinth::search; use labrinth::util::anrok; use labrinth::util::gotenberg::GotenbergClient; use labrinth::util::ratelimit::rate_limit_middleware; -use labrinth::utoipa_app_config; -use labrinth::{app_config, env}; +use labrinth::{app_data_config, app_fallback_config, env}; +use labrinth::{ + app_routes_config_internal, app_routes_config_v2, app_routes_config_v3, +}; use labrinth::{clickhouse, database, file_hosting}; +use scalar_api_reference::actix_web::config as scalar_config; +use serde_json::json; use std::ffi::CStr; use std::sync::Arc; use tracing::{Instrument, info, info_span}; use tracing_actix_web::TracingLogger; use utoipa::OpenApi; -use utoipa::openapi::security::{ApiKey, ApiKeyValue, SecurityScheme}; -use utoipa_actix_web::AppExt; -use utoipa_scalar::Servable; +use utoipa::openapi::extensions::ExtensionsBuilder; +use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme}; #[cfg(target_os = "linux")] #[global_allocator] @@ -223,7 +226,15 @@ async fn app() -> std::io::Result<()> { info!("Starting Actix HTTP server!"); HttpServer::new(move || { - App::new() + let docs_v2 = DocsV2::openapi() + .merge_from(labrinth::routes::v2::ApiDoc::openapi()); + let docs_v3 = DocsV3::openapi() + .merge_from(labrinth::routes::PublicApiDoc::openapi()) + .merge_from(labrinth::routes::v3::ApiDoc::openapi()); + let docs_internal = DocsInternal::openapi() + .merge_from(labrinth::routes::internal::ApiDoc::openapi()); + + let app = App::new() .wrap(TracingLogger::default()) .wrap_fn(|req, srv| { // We capture the same fields as `tracing-actix-web`'s `RootSpanBuilder`. @@ -260,12 +271,76 @@ async fn app() -> std::io::Result<()> { // transactions out of HTTP requests. However, we have to use our // own - See `sentry::SentryErrorReporting` for why. .wrap(labrinth::util::sentry::SentryErrorReporting) - // Use `utoipa` for OpenAPI generation - .into_utoipa_app() - .configure(|cfg| utoipa_app_config(cfg, labrinth_config.clone())) - .openapi_service(|api| utoipa_scalar::Scalar::with_url("/docs", ApiDoc::openapi().merge_from(api))) - .into_app() - .configure(|cfg| app_config(cfg, labrinth_config.clone())) + .configure(|cfg| app_data_config(cfg, labrinth_config.clone())) + .configure(|cfg| app_routes_config_v2(cfg, labrinth_config.clone())) + .configure(|cfg| app_routes_config_v3(cfg, labrinth_config.clone())) + .configure(|cfg| { + app_routes_config_internal(cfg, labrinth_config.clone()) + }); + + let scalar_configuration = json!({ + "sources": [ + { + "title": "API v2", + "slug": "v2", + "url": "/openapi/v2.json", + "default": true + }, + { + "title": "API v3 (UNSTABLE - DO NOT USE)", + "slug": "v3", + "url": "/openapi/v3.json" + }, + { + "title": "Internal API - HIGHLY UNSTABLE - DO NOT USE", + "slug": "internal", + "url": "/openapi/internal.json" + } + ], + "agent": { + "disabled": true + }, + "mcp": { + "disabled": true + }, + "telemetry": false, + + "metaData": { + "title": "Modrinth API Documentation", + "description": "Reference documentation for the Modrinth API.", + "ogTitle": "Modrinth API Documentation", + "ogDescription": "Reference documentation for the Modrinth API." + }, + + "modelsSectionLabel": "Schemas", + "defaultOpenFirstTag": true, + "defaultOpenAllTags": false, + "expandAllResponses": false, + "expandAllSchemaProperties": false, + "expandAllModelSections": false, + "orderSchemaPropertiesBy": "preserve", + "orderRequiredPropertiesFirst": true, + "hideSearch": false, + "searchHotKey": "k", + "showOperationId": false, + + "defaultHttpClient": { + "targetKey": "shell", + "clientKey": "curl" + }, + + "persistAuth": false, + "showDeveloperTools": "never", + }); + + app.service(openapi_json_service("/openapi/v2.json", docs_v2)) + .service(openapi_json_service("/openapi/v3.json", docs_v3)) + .service(openapi_json_service( + "/openapi/internal.json", + docs_internal, + )) + .configure(scalar_config("/docs", &scalar_configuration)) + .configure(app_fallback_config) }) .bind(&ENV.BIND_ADDR)? .run() @@ -273,19 +348,87 @@ async fn app() -> std::io::Result<()> { } #[derive(utoipa::OpenApi)] -#[openapi(info(title = "Labrinth"), modifiers(&SecurityAddon))] -struct ApiDoc; +#[openapi( + info(title = "Modrinth API v2", version = "2.0.0"), + modifiers(&SecurityAddon, &V2DescriptionAddon) +)] +struct DocsV2; + +const API_V2_DESCRIPTION: &str = include_str!("api_v2_description.md"); + +#[derive(utoipa::OpenApi)] +#[openapi( + info( + title = "API v3 (UNSTABLE - DO NOT USE)", + version = "3.0.0" + ), + modifiers(&SecurityAddon) +)] +struct DocsV3; + +#[derive(utoipa::OpenApi)] +#[openapi( + info( + title = "Internal API - HIGHLY UNSTABLE - DO NOT USE", + version = "internal" + ), + modifiers(&SecurityAddon) +)] +struct DocsInternal; + +struct V2DescriptionAddon; + +impl utoipa::Modify for V2DescriptionAddon { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + openapi.info.description = Some(API_V2_DESCRIPTION.to_string()); + } +} + +fn openapi_json_service( + path: &'static str, + openapi: utoipa::openapi::OpenApi, +) -> actix_web::Resource { + web::resource(path).route(web::get().to(move || { + let openapi = openapi.clone(); + async move { openapi_json(openapi) } + })) +} + +fn openapi_json(openapi: utoipa::openapi::OpenApi) -> HttpResponse { + match serde_json::to_string_pretty(&openapi) { + Ok(body) => HttpResponse::Ok() + .content_type("application/json; charset=utf-8") + .body(body), + Err(error) => { + tracing::error!(%error, "Failed to serialize OpenAPI schema"); + HttpResponse::InternalServerError().finish() + } + } +} struct SecurityAddon; impl utoipa::Modify for SecurityAddon { fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { let components = openapi.components.as_mut().unwrap(); + let mut bearer_auth = HttpBuilder::new() + .scheme(HttpAuthScheme::Bearer) + .description(Some( + "Use a personal access token. Example: `mrp_RNtLRSPmGj2pd1v1ubi52nX7TJJM9sznrmwhAuj511oe4t1jAqAQ3D6Wc8Ic`.", + )) + .build(); + bearer_auth.extensions = Some( + ExtensionsBuilder::new() + .add( + "x-example", + "mrp_RNtLRSPmGj2pd1v1ubi52nX7TJJM9sznrmwhAuj511oe4t1jAqAQ3D6Wc8Ic", + ) + .build(), + ); + components.add_security_scheme( "bearer_auth", - SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::new( - "authorization", - ))), + SecurityScheme::Http(bearer_auth), ); } } diff --git a/apps/labrinth/src/models/v2/notifications.rs b/apps/labrinth/src/models/v2/notifications.rs index 3e60cdbe89..044adf554e 100644 --- a/apps/labrinth/src/models/v2/notifications.rs +++ b/apps/labrinth/src/models/v2/notifications.rs @@ -1,3 +1,5 @@ +#![allow(clippy::large_stack_arrays)] + use crate::models::ids::{ThreadMessageId, VersionId}; use crate::models::v3::billing::PriceDuration; use crate::models::{ @@ -13,7 +15,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, utoipa::ToSchema)] pub struct LegacyNotification { pub id: NotificationId, pub user_id: UserId, @@ -30,14 +32,14 @@ pub struct LegacyNotification { pub actions: Vec, } -#[derive(Serialize, Deserialize, Clone)] +#[derive(Serialize, Deserialize, Clone, utoipa::ToSchema)] pub struct LegacyNotificationAction { pub title: String, /// The route to call when this notification action is called. Formatted HTTP Method, route pub action_route: (String, String), } -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, utoipa::ToSchema)] #[serde(tag = "type", rename_all = "snake_case")] pub enum LegacyNotificationBody { TaxNotification { diff --git a/apps/labrinth/src/models/v2/reports.rs b/apps/labrinth/src/models/v2/reports.rs index 7682941fb7..7fedbb59af 100644 --- a/apps/labrinth/src/models/v2/reports.rs +++ b/apps/labrinth/src/models/v2/reports.rs @@ -4,7 +4,7 @@ use ariadne::ids::UserId; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, utoipa::ToSchema)] pub struct LegacyReport { pub id: ReportId, pub report_type: String, @@ -17,7 +17,7 @@ pub struct LegacyReport { pub thread_id: ThreadId, } -#[derive(Serialize, Deserialize, Clone)] +#[derive(Serialize, Deserialize, Clone, utoipa::ToSchema)] #[serde(rename_all = "kebab-case")] pub enum LegacyItemType { Project, diff --git a/apps/labrinth/src/models/v2/search.rs b/apps/labrinth/src/models/v2/search.rs index 392dac7d17..c105fa3dc1 100644 --- a/apps/labrinth/src/models/v2/search.rs +++ b/apps/labrinth/src/models/v2/search.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use crate::{routes::v2_reroute, search::ResultSearchProject}; -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, utoipa::ToSchema)] pub struct LegacySearchResults { pub hits: Vec, pub offset: usize, @@ -11,7 +11,7 @@ pub struct LegacySearchResults { pub total_hits: usize, } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, utoipa::ToSchema)] pub struct LegacyResultSearchProject { pub project_id: String, pub project_type: String, diff --git a/apps/labrinth/src/models/v2/teams.rs b/apps/labrinth/src/models/v2/teams.rs index f265b77013..db7f2b9a7a 100644 --- a/apps/labrinth/src/models/v2/teams.rs +++ b/apps/labrinth/src/models/v2/teams.rs @@ -8,7 +8,7 @@ use crate::models::{ }; /// A member of a team -#[derive(Serialize, Deserialize, Clone)] +#[derive(Serialize, Deserialize, Clone, utoipa::ToSchema)] pub struct LegacyTeamMember { pub role: String, // is_owner removed, and role hardcoded to Owner if true, diff --git a/apps/labrinth/src/models/v2/threads.rs b/apps/labrinth/src/models/v2/threads.rs index 9a041277b8..ddaa6d0729 100644 --- a/apps/labrinth/src/models/v2/threads.rs +++ b/apps/labrinth/src/models/v2/threads.rs @@ -7,7 +7,7 @@ use ariadne::ids::UserId; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, utoipa::ToSchema)] pub struct LegacyThread { pub id: ThreadId, #[serde(rename = "type")] @@ -18,7 +18,7 @@ pub struct LegacyThread { pub members: Vec, } -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, utoipa::ToSchema)] pub struct LegacyThreadMessage { pub id: ThreadMessageId, pub author_id: Option, @@ -26,7 +26,7 @@ pub struct LegacyThreadMessage { pub created: DateTime, } -#[derive(Serialize, Deserialize, Clone)] +#[derive(Serialize, Deserialize, Clone, utoipa::ToSchema)] #[serde(tag = "type", rename_all = "snake_case")] pub enum LegacyMessageBody { Text { @@ -49,7 +49,9 @@ pub enum LegacyMessageBody { }, } -#[derive(Serialize, Deserialize, Eq, PartialEq, Copy, Clone)] +#[derive( + Serialize, Deserialize, Eq, PartialEq, Copy, Clone, utoipa::ToSchema, +)] #[serde(rename_all = "snake_case")] pub enum LegacyThreadType { Report, diff --git a/apps/labrinth/src/models/v2/user.rs b/apps/labrinth/src/models/v2/user.rs index f8e9d0d0db..d8fae78f38 100644 --- a/apps/labrinth/src/models/v2/user.rs +++ b/apps/labrinth/src/models/v2/user.rs @@ -6,7 +6,7 @@ use ariadne::ids::UserId; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -#[derive(Serialize, Deserialize, Clone, Debug)] +#[derive(Serialize, Deserialize, Clone, Debug, utoipa::ToSchema)] pub struct LegacyUser { pub id: UserId, pub username: String, diff --git a/apps/labrinth/src/models/v3/billing.rs b/apps/labrinth/src/models/v3/billing.rs index b7dd2a00ba..c3ef13facb 100644 --- a/apps/labrinth/src/models/v3/billing.rs +++ b/apps/labrinth/src/models/v3/billing.rs @@ -75,7 +75,17 @@ impl Price { } } -#[derive(Serialize, Deserialize, Hash, Eq, PartialEq, Debug, Copy, Clone)] +#[derive( + Serialize, + Deserialize, + Hash, + Eq, + PartialEq, + Debug, + Copy, + Clone, + utoipa::ToSchema, +)] #[serde(rename_all = "kebab-case")] pub enum PriceDuration { FiveDays, diff --git a/apps/labrinth/src/models/v3/moderation_notes.rs b/apps/labrinth/src/models/v3/moderation_notes.rs index 8e972a1379..b6621dc8ea 100644 --- a/apps/labrinth/src/models/v3/moderation_notes.rs +++ b/apps/labrinth/src/models/v3/moderation_notes.rs @@ -28,7 +28,7 @@ impl From for ModerationNote { } } -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] pub struct PatchModerationNote { pub notes: Option, pub user_rating: Option, diff --git a/apps/labrinth/src/models/v3/notifications.rs b/apps/labrinth/src/models/v3/notifications.rs index ebef09548f..75904061d1 100644 --- a/apps/labrinth/src/models/v3/notifications.rs +++ b/apps/labrinth/src/models/v3/notifications.rs @@ -706,7 +706,7 @@ impl From for Notification { } } -#[derive(Serialize, Deserialize, Clone)] +#[derive(Serialize, Deserialize, Clone, utoipa::ToSchema)] pub struct NotificationAction { pub name: String, /// The route to call when this notification action is called. Formatted HTTP Method, route diff --git a/apps/labrinth/src/models/v3/oauth_clients.rs b/apps/labrinth/src/models/v3/oauth_clients.rs index 6790bb1f7e..efaddc0544 100644 --- a/apps/labrinth/src/models/v3/oauth_clients.rs +++ b/apps/labrinth/src/models/v3/oauth_clients.rs @@ -10,14 +10,14 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use serde_with::serde_as; -#[derive(Deserialize, Serialize)] +#[derive(Deserialize, Serialize, utoipa::ToSchema)] pub struct OAuthRedirectUri { pub id: OAuthRedirectUriId, pub client_id: OAuthClientId, pub uri: String, } -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, utoipa::ToSchema)] pub struct OAuthClientCreationResult { #[serde(flatten)] pub client: OAuthClient, @@ -25,7 +25,7 @@ pub struct OAuthClientCreationResult { pub client_secret: String, } -#[derive(Deserialize, Serialize)] +#[derive(Deserialize, Serialize, utoipa::ToSchema)] pub struct OAuthClient { pub id: OAuthClientId, pub name: String, @@ -48,7 +48,7 @@ pub struct OAuthClient { pub description: Option, } -#[derive(Deserialize, Serialize)] +#[derive(Deserialize, Serialize, utoipa::ToSchema)] pub struct OAuthClientAuthorization { pub id: OAuthClientAuthorizationId, pub app_id: OAuthClientId, diff --git a/apps/labrinth/src/models/v3/organizations.rs b/apps/labrinth/src/models/v3/organizations.rs index aaa8c3a5e9..f7fa699bec 100644 --- a/apps/labrinth/src/models/v3/organizations.rs +++ b/apps/labrinth/src/models/v3/organizations.rs @@ -4,7 +4,7 @@ use crate::models::ids::{OrganizationId, TeamId}; use serde::{Deserialize, Serialize}; /// An organization of users who control a project -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, utoipa::ToSchema)] pub struct Organization { /// The id of the organization pub id: OrganizationId, diff --git a/apps/labrinth/src/models/v3/payouts.rs b/apps/labrinth/src/models/v3/payouts.rs index 574dde676d..221205e95c 100644 --- a/apps/labrinth/src/models/v3/payouts.rs +++ b/apps/labrinth/src/models/v3/payouts.rs @@ -234,7 +234,7 @@ impl PayoutStatus { } } -#[derive(Serialize, Deserialize, Clone)] +#[derive(Serialize, Deserialize, Clone, utoipa::ToSchema)] pub struct PayoutMethod { pub id: String, #[serde(rename = "type")] @@ -252,7 +252,7 @@ pub struct PayoutMethod { pub exchange_rate: Option, } -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, utoipa::ToSchema)] pub struct PayoutMethodFee { #[serde(with = "rust_decimal::serde::float")] pub percentage: Decimal, @@ -274,6 +274,17 @@ impl PayoutMethodFee { #[derive(Clone)] pub struct PayoutDecimal(pub Decimal); +impl utoipa::PartialSchema for PayoutDecimal { + fn schema() -> utoipa::openapi::RefOr { + utoipa::openapi::ObjectBuilder::new() + .schema_type(utoipa::openapi::schema::Type::Number) + .build() + .into() + } +} + +impl utoipa::ToSchema for PayoutDecimal {} + impl Serialize for PayoutDecimal { fn serialize(&self, serializer: S) -> Result where @@ -293,7 +304,7 @@ impl<'de> Deserialize<'de> for PayoutDecimal { } } -#[derive(Serialize, Deserialize, Clone)] +#[derive(Serialize, Deserialize, Clone, utoipa::ToSchema)] #[serde(rename_all = "snake_case")] pub enum PayoutInterval { Standard { diff --git a/apps/labrinth/src/models/v3/shared_instances.rs b/apps/labrinth/src/models/v3/shared_instances.rs index abbf773f93..a97003ad1e 100644 --- a/apps/labrinth/src/models/v3/shared_instances.rs +++ b/apps/labrinth/src/models/v3/shared_instances.rs @@ -38,7 +38,7 @@ impl SharedInstance { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct SharedInstanceVersion { pub id: SharedInstanceVersionId, pub shared_instance: SharedInstanceId, diff --git a/apps/labrinth/src/models/v3/teams.rs b/apps/labrinth/src/models/v3/teams.rs index b579eaacb4..97d866f5ac 100644 --- a/apps/labrinth/src/models/v3/teams.rs +++ b/apps/labrinth/src/models/v3/teams.rs @@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize}; pub const DEFAULT_ROLE: &str = "Member"; /// A team of users who control a project -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, utoipa::ToSchema)] pub struct Team { /// The id of the team pub id: TeamId, @@ -157,7 +157,7 @@ impl OrganizationPermissions { } /// A member of a team -#[derive(Serialize, Deserialize, Clone)] +#[derive(Serialize, Deserialize, Clone, utoipa::ToSchema)] pub struct TeamMember { /// The ID of the team this team member is a member of pub team_id: TeamId, diff --git a/apps/labrinth/src/models/v3/users.rs b/apps/labrinth/src/models/v3/users.rs index 3bea96cde2..456faa87ea 100644 --- a/apps/labrinth/src/models/v3/users.rs +++ b/apps/labrinth/src/models/v3/users.rs @@ -242,7 +242,7 @@ impl Role { } } -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, utoipa::ToSchema)] pub struct UserFriend { // The user who accepted the friend request pub id: UserId, diff --git a/apps/labrinth/src/routes/analytics.rs b/apps/labrinth/src/routes/analytics.rs index ed25ef45aa..1b321870fa 100644 --- a/apps/labrinth/src/routes/analytics.rs +++ b/apps/labrinth/src/routes/analytics.rs @@ -46,19 +46,20 @@ pub const FILTERED_HEADERS: &[&str] = &[ "x-vercel-ip-country", ]; -pub fn config(cfg: &mut web::ServiceConfig) { +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(page_view_ingest) .service(playtime_ingest) .service(minecraft_server_play_ingest); } -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] pub struct UrlInput { url: String, } //this route should be behind the cloudflare WAF to prevent non-browsers from calling it -#[post("view")] +#[utoipa::path(tag = "analytics", responses((status = NO_CONTENT)))] +#[post("/view")] async fn page_view_ingest( req: HttpRequest, analytics_queue: web::Data>, @@ -171,7 +172,7 @@ async fn page_view_ingest( Ok(HttpResponse::NoContent().body("")) } -#[derive(Deserialize, Debug)] +#[derive(Deserialize, Debug, utoipa::ToSchema)] pub struct PlaytimeInput { seconds: u16, loader: String, @@ -179,7 +180,8 @@ pub struct PlaytimeInput { parent: Option, } -#[post("playtime")] +#[utoipa::path(tag = "analytics", responses((status = NO_CONTENT)))] +#[post("/playtime")] async fn playtime_ingest( req: HttpRequest, analytics_queue: web::Data>, @@ -249,7 +251,7 @@ struct MinecraftProfile { name: String, } -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] pub struct MinecraftJavaServerPlayInput { project_id: ProjectId, username: String, @@ -258,7 +260,8 @@ pub struct MinecraftJavaServerPlayInput { pub const MINECRAFT_SERVER_PLAYS: &str = "minecraft_server_plays"; -#[post("minecraft-server-play")] +#[utoipa::path(tag = "analytics", responses((status = NO_CONTENT)))] +#[post("/minecraft-server-play")] async fn minecraft_server_play_ingest( req: HttpRequest, analytics_queue: web::Data>, @@ -355,3 +358,12 @@ async fn minecraft_server_play_ingest( Ok(()) } + +#[derive(utoipa::OpenApi)] +#[openapi(paths( + page_view_ingest, + playtime_ingest, + minecraft_server_play_ingest, +))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; diff --git a/apps/labrinth/src/routes/debug/mod.rs b/apps/labrinth/src/routes/debug/mod.rs index 074beb6fb2..17c46037b8 100644 --- a/apps/labrinth/src/routes/debug/mod.rs +++ b/apps/labrinth/src/routes/debug/mod.rs @@ -1,5 +1,6 @@ use std::time::Duration; +use actix_web::web; use eyre::Context; use eyre::eyre; use prometheus::IntGauge; @@ -9,21 +10,17 @@ use crate::util::cors::default_cors; #[cfg(target_os = "linux")] mod pprof; -pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { - cfg.service( - utoipa_actix_web::scope("/debug") - .wrap(default_cors()) - .configure({ - #[cfg(target_os = "linux")] - { - pprof::config - } - #[cfg(not(target_os = "linux"))] - { - |_cfg| () - } - }), - ); +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { + cfg.service(web::scope("/debug").wrap(default_cors()).configure({ + #[cfg(target_os = "linux")] + { + pprof::config + } + #[cfg(not(target_os = "linux"))] + { + |_cfg| () + } + })); } pub fn register_and_set_metrics( diff --git a/apps/labrinth/src/routes/debug/pprof.rs b/apps/labrinth/src/routes/debug/pprof.rs index 5b583af383..3948a05a60 100644 --- a/apps/labrinth/src/routes/debug/pprof.rs +++ b/apps/labrinth/src/routes/debug/pprof.rs @@ -5,11 +5,19 @@ use eyre::{Context, eyre}; use prometheus::{IntGauge, Registry}; use std::time::Duration; -pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(heap).service(flame_graph); } -#[utoipa::path] +/// Get a heap profile. +#[utoipa::path( + tag = "debug", + responses(( + status = OK, + body = Vec, + content_type = "application/octet-stream" + )) +)] #[get("/pprof/heap", guard = "admin_key_guard")] pub async fn heap() -> Result { let mut prof_ctl = jemalloc_pprof::PROF_CTL.as_ref().unwrap().lock().await; @@ -23,7 +31,11 @@ pub async fn heap() -> Result { .body(pprof)) } -#[utoipa::path] +/// Get a heap flame graph. +#[utoipa::path( + tag = "debug", + responses((status = OK, body = String, content_type = "image/svg+xml")) +)] #[get("/pprof/heap/flamegraph", guard = "admin_key_guard")] pub async fn flame_graph() -> Result { let mut prof_ctl = jemalloc_pprof::PROF_CTL.as_ref().unwrap().lock().await; @@ -100,3 +112,8 @@ pub fn register_and_set_metrics(registry: &Registry) -> eyre::Result<()> { Ok(()) } + +#[derive(utoipa::OpenApi)] +#[openapi(paths(heap, flame_graph,))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; diff --git a/apps/labrinth/src/routes/internal/admin.rs b/apps/labrinth/src/routes/internal/admin.rs index 00eb793568..0f9b9f8863 100644 --- a/apps/labrinth/src/routes/internal/admin.rs +++ b/apps/labrinth/src/routes/internal/admin.rs @@ -23,9 +23,9 @@ use std::str::FromStr; use std::sync::Arc; use tracing::trace; -pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service( - utoipa_actix_web::scope("/admin") + web::scope("/admin") .service(count_download) .service(force_reindex) .service(force_reindex_project), @@ -130,7 +130,9 @@ async fn resolve_download_attribution_version( } // This is an internal route, cannot be used without key +/// Count a download. #[utoipa::path( + tag = "v2 admin", patch, operation_id = "countDownload", responses( @@ -308,7 +310,9 @@ pub async fn count_download( Ok(HttpResponse::NoContent().body("")) } +/// Reindex all projects. #[utoipa::path( + tag = "v2 admin", post, operation_id = "forceReindex", responses( @@ -330,7 +334,9 @@ pub async fn force_reindex( Ok(HttpResponse::NoContent().finish()) } +/// Reindex a project. #[utoipa::path( + tag = "v2 admin", post, operation_id = "forceReindexProject", responses( @@ -359,3 +365,16 @@ pub async fn force_reindex_project( Ok(HttpResponse::NoContent().finish()) } + +#[derive(utoipa::OpenApi)] +#[openapi(paths(count_download, force_reindex, force_reindex_project,))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; + +#[derive(utoipa::OpenApi)] +#[openapi( + nest( + (path = "/admin", api = RouteDoc), + ) +)] +pub(crate) struct ApiDoc; diff --git a/apps/labrinth/src/routes/internal/affiliate.rs b/apps/labrinth/src/routes/internal/affiliate.rs index 5dca27b38f..aac12eb024 100644 --- a/apps/labrinth/src/routes/internal/affiliate.rs +++ b/apps/labrinth/src/routes/internal/affiliate.rs @@ -25,7 +25,7 @@ use url::Url; use crate::routes::ApiError; -pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(ingest_click) .service(get_all) .service(create) @@ -40,7 +40,11 @@ pub struct IngestClick { pub affiliate_code_id: AffiliateCodeId, } -#[utoipa::path] +/// Ingest an affiliate click. +#[utoipa::path( + tag = "affiliates", + responses((status = NO_CONTENT)) +)] #[post("/ingest-click")] async fn ingest_click( req: HttpRequest, @@ -136,7 +140,9 @@ async fn ingest_click( Ok(()) } +/// List affiliate codes. #[utoipa::path( + tag = "affiliates", responses((status = OK, body = inline(Vec))) )] #[get("")] @@ -187,7 +193,9 @@ pub struct CreateRequest { pub source_name: String, } +/// Create an affiliate code. #[utoipa::path( + tag = "affiliates", responses((status = OK, body = inline(AffiliateCode))) )] #[put("")] @@ -263,7 +271,9 @@ async fn create( Ok(web::Json(AffiliateCode::from(code, is_admin))) } +/// Get an affiliate code. #[utoipa::path( + tag = "affiliates", responses((status = OK, body = inline(AffiliateCode))) )] #[get("/{id}")] @@ -302,7 +312,11 @@ async fn get( } } -#[utoipa::path] +/// Delete an affiliate code. +#[utoipa::path( + tag = "affiliates", + responses((status = NO_CONTENT)) +)] #[delete("/{id}")] async fn delete( req: HttpRequest, @@ -350,7 +364,11 @@ pub struct PatchRequest { pub source_name: String, } -#[utoipa::path] +/// Update an affiliate code. +#[utoipa::path( + tag = "affiliates", + responses((status = NO_CONTENT)) +)] #[patch("/{id}")] async fn patch( req: HttpRequest, @@ -399,3 +417,8 @@ async fn patch( Ok(()) } + +#[derive(utoipa::OpenApi)] +#[openapi(paths(ingest_click, get_all, create, get, delete, patch,))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; diff --git a/apps/labrinth/src/routes/internal/attribution.rs b/apps/labrinth/src/routes/internal/attribution.rs index 7a0f37afcb..3063e08598 100644 --- a/apps/labrinth/src/routes/internal/attribution.rs +++ b/apps/labrinth/src/routes/internal/attribution.rs @@ -26,7 +26,7 @@ use crate::queue::session::AuthQueue; use crate::routes::ApiError; use crate::util::error::Context; -pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(list) .service(update_group) .service(scan) @@ -34,7 +34,7 @@ pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { .service(split); } -#[derive(Serialize)] +#[derive(Serialize, utoipa::ToSchema)] struct AttributionGroupResponse { id: crate::models::ids::AttributionGroupId, flame_project: Option, @@ -45,7 +45,7 @@ struct AttributionGroupResponse { versions: Vec, } -#[derive(Clone, Serialize)] +#[derive(Clone, Serialize, utoipa::ToSchema)] struct VersionInfo { id: VersionId, name: String, @@ -53,7 +53,7 @@ struct VersionInfo { date_created: chrono::DateTime, } -#[derive(Serialize)] +#[derive(Serialize, utoipa::ToSchema)] struct AttributionFileResponse { name: String, sha1: String, @@ -64,7 +64,7 @@ struct AttributionFileResponse { moderation_external_license: Option, } -#[derive(Clone, Serialize)] +#[derive(Clone, Serialize, utoipa::ToSchema)] struct ModerationExternalLicenseResponse { id: i64, title: Option, @@ -89,7 +89,11 @@ struct ScanResponse { queued_files: u64, } -#[utoipa::path] +/// Queue an attribution scan. +#[utoipa::path( + tag = "attribution", + responses((status = OK, body = ScanResponse)) +)] #[post("/scan")] async fn scan( req: HttpRequest, @@ -201,7 +205,12 @@ async fn scan( })) } -#[utoipa::path] +/// List project attribution groups. +#[utoipa::path( + tag = "attribution", + params(("project_id" = ProjectId, Path)), + responses((status = OK, body = inline(Vec))) +)] #[get("/{project_id}")] async fn list( req: HttpRequest, @@ -451,7 +460,11 @@ struct UpdateGroupBody { attribution: AttributionResolution, } -#[utoipa::path] +/// Update an attribution group. +#[utoipa::path( + tag = "attribution", + responses((status = NO_CONTENT)) +)] #[patch("/group/{group_id}")] async fn update_group( req: HttpRequest, @@ -532,7 +545,11 @@ struct AssignBody { project_id: ProjectId, } -#[utoipa::path] +/// Move a file to an attribution group. +#[utoipa::path( + tag = "attribution", + responses((status = NO_CONTENT)) +)] #[post("/assign")] async fn assign( req: HttpRequest, @@ -688,7 +705,11 @@ struct SplitBody { project_id: ProjectId, } -#[utoipa::path] +/// Split a file into a new attribution group. +#[utoipa::path( + tag = "attribution", + responses((status = NO_CONTENT)) +)] #[post("/split")] async fn split( req: HttpRequest, @@ -966,3 +987,8 @@ fn hex_to_bytes(hex: &str) -> Option> { .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).ok()) .collect() } + +#[derive(utoipa::OpenApi)] +#[openapi(paths(scan, list, update_group, assign, split,))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; diff --git a/apps/labrinth/src/routes/internal/billing.rs b/apps/labrinth/src/routes/internal/billing.rs index 6d3f8f6ec8..ad06707625 100644 --- a/apps/labrinth/src/routes/internal/billing.rs +++ b/apps/labrinth/src/routes/internal/billing.rs @@ -42,7 +42,7 @@ use stripe::{ }; use tracing::warn; -pub fn config(cfg: &mut web::ServiceConfig) { +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service( web::scope("/billing") .service(products) @@ -58,11 +58,17 @@ pub fn config(cfg: &mut web::ServiceConfig) { .service(active_servers) .service(initiate_payment) .service(stripe_webhook) - .service(refund_charge), + .service(refund_charge) + .service(reprocess_charge_tax), ); } -#[get("products")] +/// List products. +#[utoipa::path( + tag = "billing", + responses((status = OK, body = serde_json::Value)) +)] +#[get("/products")] pub async fn products( pool: web::Data, redis: web::Data, @@ -99,7 +105,12 @@ struct SubscriptionsQuery { pub user_id: Option, } -#[get("subscriptions")] +/// List subscriptions. +#[utoipa::path( + tag = "billing", + responses((status = OK, body = serde_json::Value)) +)] +#[get("/subscriptions")] pub async fn subscriptions( req: HttpRequest, pool: web::Data, @@ -141,7 +152,7 @@ pub async fn subscriptions( Ok(HttpResponse::Ok().json(subscriptions)) } -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ChargeRefundAmount { Full, @@ -149,14 +160,19 @@ pub enum ChargeRefundAmount { None, } -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] pub struct ChargeRefund { #[serde(flatten)] pub amount: ChargeRefundAmount, pub unprovision: Option, } -#[post("charge/{id}/refund")] +/// Refund a charge. +#[utoipa::path( + tag = "billing", + responses((status = NO_CONTENT)) +)] +#[post("/charge/{id}/refund")] #[allow(clippy::too_many_arguments)] pub async fn refund_charge( req: HttpRequest, @@ -419,7 +435,12 @@ pub async fn refund_charge( Ok(HttpResponse::NoContent().finish()) } -#[post("charge/{id}/tax/reprocess")] +/// Reprocess tax for a charge. +#[utoipa::path( + tag = "billing", + responses((status = NO_CONTENT)) +)] +#[post("/charge/{id}/tax/reprocess")] pub async fn reprocess_charge_tax( req: HttpRequest, pool: web::Data, @@ -587,8 +608,9 @@ pub async fn reprocess_charge_tax( Ok(HttpResponse::NoContent().finish()) } -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] pub struct SubscriptionEdit { + #[schema(value_type = String)] pub interval: Option, pub payment_method: Option, pub cancelled: Option, @@ -601,7 +623,15 @@ pub struct SubscriptionEditQuery { pub dry: Option, } -#[patch("subscription/{id}")] +/// Update a subscription. +#[utoipa::path( + tag = "billing", + responses( + (status = OK, body = serde_json::Value), + (status = NO_CONTENT), + ) +)] +#[patch("/subscription/{id}")] #[allow(clippy::too_many_arguments)] pub async fn edit_subscription( req: HttpRequest, @@ -1091,7 +1121,12 @@ pub async fn edit_subscription( } } -#[get("customer")] +/// Get the current customer. +#[utoipa::path( + tag = "billing", + responses((status = OK, body = serde_json::Value)) +)] +#[get("/customer")] pub async fn user_customer( req: HttpRequest, pool: web::Data, @@ -1129,7 +1164,12 @@ pub struct ChargesQuery { pub user_id: Option, } -#[get("payments")] +/// List payments. +#[utoipa::path( + tag = "billing", + responses((status = OK, body = serde_json::Value)) +)] +#[get("/payments")] pub async fn charges( req: HttpRequest, pool: web::Data, @@ -1188,7 +1228,12 @@ pub async fn charges( )) } -#[post("payment_method")] +/// Start a payment method flow. +#[utoipa::path( + tag = "billing", + responses((status = OK, body = serde_json::Value)) +)] +#[post("/payment_method")] pub async fn add_payment_method_flow( req: HttpRequest, pool: web::Data, @@ -1241,7 +1286,12 @@ pub struct EditPaymentMethod { pub primary: bool, } -#[patch("payment_method/{id}")] +/// Update a payment method. +#[utoipa::path( + tag = "billing", + responses((status = NO_CONTENT)) +)] +#[patch("/payment_method/{id}")] pub async fn edit_payment_method( req: HttpRequest, info: web::Path<(String,)>, @@ -1305,7 +1355,12 @@ pub async fn edit_payment_method( } } -#[delete("payment_method/{id}")] +/// Remove a payment method. +#[utoipa::path( + tag = "billing", + responses((status = NO_CONTENT)) +)] +#[delete("/payment_method/{id}")] pub async fn remove_payment_method( req: HttpRequest, info: web::Path<(String,)>, @@ -1388,7 +1443,15 @@ pub async fn remove_payment_method( } } -#[get("payment_methods")] +/// List payment methods. +#[utoipa::path( + tag = "billing", + responses( + (status = OK, body = serde_json::Value), + (status = NO_CONTENT), + ) +)] +#[get("/payment_methods")] pub async fn payment_methods( req: HttpRequest, pool: web::Data, @@ -1432,7 +1495,22 @@ pub struct ActiveServersQuery { pub subscription_status: Option, } -#[get("active_servers")] +#[derive(Serialize, utoipa::ToSchema)] +struct ActiveServerResponse { + pub user_id: ariadne::ids::UserId, + pub server_id: String, + pub price_id: crate::models::ids::ProductPriceId, + #[schema(value_type = String)] + pub interval: PriceDuration, + pub region: Option, +} + +/// List active servers. +#[utoipa::path( + tag = "billing", + responses((status = OK, body = inline(Vec))) +)] +#[get("/active_servers")] pub async fn active_servers( req: HttpRequest, pool: web::Data, @@ -1457,21 +1535,12 @@ pub async fn active_servers( ) .await?; - #[derive(Serialize)] - struct ActiveServer { - pub user_id: ariadne::ids::UserId, - pub server_id: String, - pub price_id: crate::models::ids::ProductPriceId, - pub interval: PriceDuration, - pub region: Option, - } - let server_ids = servers .into_iter() .filter_map(|x| { x.metadata.as_ref().and_then(|metadata| match metadata { SubscriptionMetadata::Pyro { id, region } => { - Some(ActiveServer { + Some(ActiveServerResponse { user_id: x.user_id.into(), server_id: id.clone(), price_id: x.price_id.into(), @@ -1482,12 +1551,12 @@ pub async fn active_servers( SubscriptionMetadata::Medal { .. } => None, }) }) - .collect::>(); + .collect::>(); Ok(HttpResponse::Ok().json(server_ids)) } -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] #[serde(tag = "type", rename_all = "snake_case")] pub enum PaymentRequestType { PaymentMethod { id: String }, @@ -1507,7 +1576,7 @@ impl PaymentRequestType { } } -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ChargeRequestType { Existing { @@ -1515,11 +1584,12 @@ pub enum ChargeRequestType { }, New { product_id: crate::models::ids::ProductId, + #[schema(value_type = String)] interval: Option, }, } -#[derive(Deserialize, Serialize)] +#[derive(Deserialize, Serialize, utoipa::ToSchema)] #[serde(rename_all = "snake_case")] pub struct PaymentRequestMetadata { #[serde(flatten)] @@ -1527,7 +1597,7 @@ pub struct PaymentRequestMetadata { pub affiliate_code: Option, } -#[derive(Deserialize, Serialize)] +#[derive(Deserialize, Serialize, utoipa::ToSchema)] #[serde(tag = "type", rename_all = "snake_case")] pub enum PaymentRequestMetadataKind { Pyro { @@ -1537,16 +1607,22 @@ pub enum PaymentRequestMetadataKind { }, } -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] pub struct PaymentRequest { #[serde(flatten)] pub type_: PaymentRequestType, pub charge: ChargeRequestType, + #[schema(value_type = String)] pub existing_payment_intent: Option, pub metadata: Option, } -#[post("payment")] +/// Initiate a payment. +#[utoipa::path( + tag = "billing", + responses((status = OK, body = serde_json::Value)) +)] +#[post("/payment")] pub async fn initiate_payment( req: HttpRequest, pool: web::Data, @@ -1610,7 +1686,12 @@ pub async fn initiate_payment( } } -#[post("_stripe")] +/// Receive a Stripe webhook. +#[utoipa::path( + tag = "billing", + responses((status = NO_CONTENT)) +)] +#[post("/_stripe")] pub async fn stripe_webhook( req: HttpRequest, payload: String, @@ -2503,7 +2584,7 @@ async fn apply_credit_many( Ok(()) } -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] pub struct CreditRequest { #[serde(flatten)] pub target: CreditTarget, @@ -2512,7 +2593,7 @@ pub struct CreditRequest { pub message: String, } -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] #[serde(untagged)] pub enum CreditTarget { Subscriptions { @@ -2526,7 +2607,12 @@ pub enum CreditTarget { }, } -#[post("credit")] +/// Credit subscriptions. +#[utoipa::path( + tag = "billing", + responses((status = NO_CONTENT)) +)] +#[post("/credit")] pub async fn credit( req: HttpRequest, pool: web::Data, @@ -2649,3 +2735,24 @@ pub async fn credit( } pub mod payments; + +#[derive(utoipa::OpenApi)] +#[openapi(paths( + products, + subscriptions, + refund_charge, + reprocess_charge_tax, + edit_subscription, + user_customer, + charges, + add_payment_method_flow, + edit_payment_method, + remove_payment_method, + payment_methods, + active_servers, + initiate_payment, + stripe_webhook, + credit, +))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; diff --git a/apps/labrinth/src/routes/internal/campaign.rs b/apps/labrinth/src/routes/internal/campaign.rs index 6bac2eb4cf..2d25486cdb 100644 --- a/apps/labrinth/src/routes/internal/campaign.rs +++ b/apps/labrinth/src/routes/internal/campaign.rs @@ -27,7 +27,7 @@ use crate::{ util::{error::Context, http::HttpClient, tiltify::TiltifyClient}, }; -pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(tiltify_webhook).service(pride_26); } @@ -143,7 +143,11 @@ impl CampaignDonation { } } -#[utoipa::path] +/// Receive a Tiltify webhook. +#[utoipa::path( + tag = "campaigns", + responses((status = NO_CONTENT)) +)] #[post("/webhook")] pub async fn tiltify_webhook( req: HttpRequest, @@ -301,7 +305,11 @@ fn verify_tiltify_webhook_signature( Ok(()) } -#[utoipa::path] +/// Get Pride campaign data. +#[utoipa::path( + tag = "campaigns", + responses((status = OK, body = CampaignInfo)) +)] #[get("/pride-26")] pub async fn pride_26( http: web::Data, @@ -472,3 +480,8 @@ async fn amount_raised_usd( Ok(amount.value / usd_to_currency) } + +#[derive(utoipa::OpenApi)] +#[openapi(paths(tiltify_webhook, pride_26,))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; diff --git a/apps/labrinth/src/routes/internal/delphi/mod.rs b/apps/labrinth/src/routes/internal/delphi/mod.rs index 5a9b403428..fb1e3659c5 100644 --- a/apps/labrinth/src/routes/internal/delphi/mod.rs +++ b/apps/labrinth/src/routes/internal/delphi/mod.rs @@ -35,7 +35,7 @@ use crate::{ pub mod rescan; -pub fn config(cfg: &mut web::ServiceConfig) { +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service( web::scope("/delphi") .service(ingest_report) @@ -141,7 +141,12 @@ pub struct DelphiRunParameters { pub file_id: crate::models::ids::FileId, } -#[post("ingest", guard = "admin_key_guard")] +/// Ingest a Delphi report. +#[utoipa::path( + tag = "delphi", + responses((status = NO_CONTENT)) +)] +#[post("/ingest", guard = "admin_key_guard")] async fn ingest_report( pool: web::Data, redis: web::Data, @@ -466,7 +471,12 @@ pub async fn send_tech_review_exit_file_deleted_message_if_exited( Ok(()) } -#[post("run")] +/// Run Delphi. +#[utoipa::path( + tag = "delphi", + responses((status = NO_CONTENT)) +)] +#[post("/run")] async fn _run( req: HttpRequest, pool: web::Data, @@ -487,7 +497,12 @@ async fn _run( run(&**pool, run_parameters.into_inner(), &http).await } -#[get("version")] +/// Get the Delphi version. +#[utoipa::path( + tag = "delphi", + responses((status = OK, body = inline(Option))) +)] +#[get("/version")] async fn version( req: HttpRequest, pool: web::Data, @@ -510,7 +525,12 @@ async fn version( )) } -#[get("issue_type/schema")] +/// Get the Delphi issue type schema. +#[utoipa::path( + tag = "delphi", + responses((status = OK, body = serde_json::Value)) +)] +#[get("/issue_type/schema")] async fn issue_type_schema( req: HttpRequest, pool: web::Data, @@ -556,3 +576,8 @@ async fn issue_type_schema( )), } } + +#[derive(utoipa::OpenApi)] +#[openapi(paths(ingest_report, _run, version, issue_type_schema,))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; diff --git a/apps/labrinth/src/routes/internal/external_notifications.rs b/apps/labrinth/src/routes/internal/external_notifications.rs index 942af86166..c674df43c3 100644 --- a/apps/labrinth/src/routes/internal/external_notifications.rs +++ b/apps/labrinth/src/routes/internal/external_notifications.rs @@ -26,20 +26,26 @@ use eyre::eyre; use lettre::message::Mailbox; use serde::Deserialize; -pub fn config(cfg: &mut web::ServiceConfig) { +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(create) .service(create_email_sync) .service(remove) .service(send_custom_email); } -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] struct CreateNotification { + #[schema(value_type = serde_json::Value)] pub body: NotificationBody, pub user_ids: Vec, } -#[post("external_notifications", guard = "external_notification_key_guard")] +/// Create external notifications. +#[utoipa::path( + tag = "external notifications", + responses((status = ACCEPTED)) +)] +#[post("/external_notifications", guard = "external_notification_key_guard")] pub async fn create( pool: web::Data, redis: web::Data, @@ -74,13 +80,21 @@ pub async fn create( Ok(HttpResponse::Accepted().finish()) } -/// Inserts notifications for all users and tries to send emails immediately. +/// Create notifications and send emails. /// /// Responds with the user IDs that could not be emailed: /// - `200` if every recipient was emailed (empty list) /// - `207` if some recipients could not be emailed (list of failed IDs) +/// Create email sync. +#[utoipa::path( + tag = "external notifications", + responses( + (status = OK, body = inline(Vec)), + (status = 207, body = inline(Vec)), + ) +)] #[post( - "external_notifications/email-sync", + "/external_notifications/email-sync", guard = "external_notification_key_guard" )] pub async fn create_email_sync( @@ -178,14 +192,19 @@ pub async fn create_email_sync( Ok(web::Json(failed).customize().with_status(status)) } -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] struct NotificationFilter { pub user_ids: Vec, #[serde(flatten)] pub body: serde_json::Map, } -#[delete("external_notifications", guard = "external_notification_key_guard")] +/// Remove external notifications. +#[utoipa::path( + tag = "external notifications", + responses((status = NO_CONTENT)) +)] +#[delete("/external_notifications", guard = "external_notification_key_guard")] pub async fn remove( pool: web::Data, redis: web::Data, @@ -225,7 +244,7 @@ pub async fn remove( Ok(HttpResponse::NoContent().finish()) } -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] struct SendEmail { pub users: Vec, pub key: String, @@ -233,7 +252,12 @@ struct SendEmail { pub title: String, } -#[post("external_notifications/send_custom_email")] +/// Send a custom email. +#[utoipa::path( + tag = "external notifications", + responses((status = ACCEPTED)) +)] +#[post("/external_notifications/send_custom_email")] pub async fn send_custom_email( req: HttpRequest, pool: web::Data, @@ -339,3 +363,8 @@ async fn broadcast_notifications( } } } + +#[derive(utoipa::OpenApi)] +#[openapi(paths(create, create_email_sync, remove, send_custom_email,))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; diff --git a/apps/labrinth/src/routes/internal/flows.rs b/apps/labrinth/src/routes/internal/flows.rs index 8a82b3dc8d..fd51ded61f 100644 --- a/apps/labrinth/src/routes/internal/flows.rs +++ b/apps/labrinth/src/routes/internal/flows.rs @@ -60,9 +60,9 @@ use webauthn_rs::prelude::{ }; use zxcvbn::Score; -pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service( - utoipa_actix_web::scope("/auth") + web::scope("/auth") .service(init) .service(auth_callback) .service(delete_auth_provider) @@ -1067,14 +1067,7 @@ pub struct Authorization { // Init link takes us to GitHub API and calls back to callback endpoint with a code and state // http://localhost:8000/auth/init?url=https://modrinth.com -#[utoipa::path( - get, - operation_id = "authInit", - responses( - (status = 307, description = "Redirect to OAuth provider"), - (status = 400, description = "Invalid input") - ) -)] +#[utoipa::path(tag = "auth", responses((status = TEMPORARY_REDIRECT), (status = OK)))] #[get("/init")] pub async fn init( req: HttpRequest, @@ -1165,14 +1158,7 @@ pub async fn init( .json(serde_json::json!({ "url": url }))) } -#[utoipa::path( - get, - operation_id = "authCallback", - responses( - (status = 307, description = "Redirect with auth code"), - (status = 401, description = "Authentication failed") - ) -)] +#[utoipa::path(tag = "auth", responses((status = OK)))] #[get("/callback")] pub async fn auth_callback( req: HttpRequest, @@ -1441,13 +1427,15 @@ struct NewOAuthAccount { pub sign_up_newsletter: bool, } +/// Create account with OAuth. #[utoipa::path( + tag = "auth", post, - operation_id = "createOAuthAccount", - responses( - (status = 200, description = "OAuth account created"), - (status = 400, description = "Invalid input") - ) + operation_id = "createOAuthAccount", + responses( + (status = 200, description = "OAuth account created", body = serde_json::Value), + (status = 400, description = "Invalid input") + ) )] #[post("/create/oauth")] async fn create_oauth_account( @@ -1525,7 +1513,9 @@ struct DiscordCommunityHandoffPayload { nonce: String, } +/// Link Discord community. #[utoipa::path( + tag = "auth", operation_id = "discordCommunityLink", responses( (status = 200, description = "Discord community bot handoff URL", body = DiscordCommunityLinkResponse), @@ -1599,7 +1589,9 @@ pub async fn discord_community_link( Ok(web::Json(DiscordCommunityLinkResponse { url })) } +/// Remove an auth provider. #[utoipa::path( + tag = "auth", delete, operation_id = "deleteAuthProvider", responses( @@ -1939,11 +1931,13 @@ impl ReadyAccountRegisterFlow { } } +/// Validate password account creation. #[utoipa::path( + tag = "auth", post, operation_id = "validateCreateAccountWithPassword", responses( - (status = 200, description = "Account input is valid"), + (status = NO_CONTENT, description = "Account input is valid"), (status = 400, description = "Invalid input") ) )] @@ -1964,13 +1958,15 @@ pub async fn validate_create_account_with_password( Ok(()) } +/// Create account with a password. #[utoipa::path( + tag = "auth", post, - operation_id = "createAccountPassword", - responses( - (status = 200, description = "Account created"), - (status = 400, description = "Invalid input") - ) + operation_id = "createAccountPassword", + responses( + (status = 200, description = "Account created", body = serde_json::Value), + (status = 400, description = "Invalid input") + ) )] #[post("/create")] pub async fn create_account_with_password( @@ -2010,13 +2006,15 @@ pub struct Login { pub challenge: String, } +/// Log in with a password. #[utoipa::path( + tag = "auth", post, - operation_id = "loginPassword", - responses( - (status = 200, description = "Login successful"), - (status = 401, description = "Invalid credentials") - ) + operation_id = "loginPassword", + responses( + (status = 200, description = "Login successful", body = serde_json::Value), + (status = 401, description = "Invalid credentials") + ) )] #[post("/login")] pub async fn login_password( @@ -2168,13 +2166,15 @@ async fn validate_2fa_code( } } +/// Complete login with 2FA. #[utoipa::path( + tag = "auth", post, - operation_id = "login2fa", - responses( - (status = 200, description = "2FA login successful"), - (status = 401, description = "Invalid credentials") - ) + operation_id = "login2fa", + responses( + (status = 200, description = "2FA login successful", body = serde_json::Value), + (status = 401, description = "Invalid credentials") + ) )] #[post("/login/2fa")] pub async fn login_2fa( @@ -2225,13 +2225,15 @@ pub async fn login_2fa( } } +/// Start 2FA setup. #[utoipa::path( + tag = "auth", post, - operation_id = "begin2faFlow", - responses( - (status = 200, description = "2FA secret generated"), - (status = 401, description = "Unauthorized") - ), + operation_id = "begin2faFlow", + responses( + (status = 200, description = "2FA secret generated", body = serde_json::Value), + (status = 401, description = "Unauthorized") + ), security(("bearer_auth" = [])) )] #[post("/2fa/get_secret")] @@ -2273,13 +2275,15 @@ pub async fn begin_2fa_flow( } } +/// Finish 2FA setup. #[utoipa::path( + tag = "auth", post, - operation_id = "finish2faFlow", - responses( - (status = 200, description = "2FA enabled"), - (status = 401, description = "Unauthorized") - ), + operation_id = "finish2faFlow", + responses( + (status = 200, description = "2FA enabled", body = serde_json::Value), + (status = 401, description = "Unauthorized") + ), security(("bearer_auth" = [])) )] #[post("/2fa")] @@ -2405,7 +2409,9 @@ pub struct Remove2FA { pub code: String, } +/// Remove 2FA. #[utoipa::path( + tag = "auth", delete, operation_id = "remove2fa", responses( @@ -2502,7 +2508,9 @@ pub struct ResetPassword { pub challenge: String, } +/// Start password reset. #[utoipa::path( + tag = "auth", post, operation_id = "resetPasswordBegin", responses( @@ -2605,7 +2613,9 @@ pub struct ChangePassword { pub new_password: Option, } +/// Change password. #[utoipa::path( + tag = "auth", patch, operation_id = "changePassword", responses( @@ -2768,7 +2778,9 @@ pub struct SetEmail { pub email: String, } +/// Set email address. #[utoipa::path( + tag = "auth", patch, operation_id = "setEmail", responses( @@ -2887,7 +2899,9 @@ pub async fn set_email( Ok(HttpResponse::Ok().finish()) } +/// Resend verification email. #[utoipa::path( + tag = "auth", post, operation_id = "resendVerifyEmail", responses( @@ -2959,7 +2973,9 @@ pub struct VerifyEmail { pub flow: String, } +/// Verify email address. #[utoipa::path( + tag = "auth", post, operation_id = "verifyEmail", responses( @@ -3022,7 +3038,9 @@ pub async fn verify_email( } } +/// Subscribe to the newsletter. #[utoipa::path( + tag = "auth", post, operation_id = "subscribeNewsletter", responses( @@ -3068,13 +3086,15 @@ pub async fn subscribe_newsletter( Ok(HttpResponse::NoContent().finish()) } +/// Get newsletter subscription status. #[utoipa::path( + tag = "auth", get, - operation_id = "getNewsletterSubscriptionStatus", - responses( - (status = 200, description = "Subscription status"), - (status = 401, description = "Unauthorized") - ), + operation_id = "getNewsletterSubscriptionStatus", + responses( + (status = 200, description = "Subscription status", body = serde_json::Value), + (status = 401, description = "Unauthorized") + ), security(("bearer_auth" = [])) )] #[get("/email/subscribe")] @@ -3115,7 +3135,9 @@ pub struct RegisterPasskeyResponse { pub flow: String, } +/// Start passkey registration. #[utoipa::path( + tag = "auth", post, operation_id = "registerPasskeyStart", responses( @@ -3212,7 +3234,9 @@ pub struct PasskeyResponse { pub last_used: Option>, } +/// Finish passkey registration. #[utoipa::path( + tag = "auth", post, operation_id = "registerPasskeyFinish", responses( @@ -3318,7 +3342,9 @@ pub struct AuthenticatePasskeyResponse { pub flow: String, } +/// Start passkey authentication. #[utoipa::path( + tag = "auth", post, operation_id = "authenticatePasskeyStart", responses( @@ -3358,13 +3384,15 @@ pub struct AuthenticatePasskeyFinish { pub credential: PublicKeyCredential, } +/// Finish passkey authentication. #[utoipa::path( + tag = "auth", post, - operation_id = "authenticatePasskeyFinish", - responses( - (status = 200, description = "Passkey authentication successful"), - (status = 400, description = "Invalid input") - ) + operation_id = "authenticatePasskeyFinish", + responses( + (status = 200, description = "Passkey authentication successful", body = serde_json::Value), + (status = 400, description = "Invalid input") + ) )] #[post("/passkey/finish")] pub async fn authenticate_passkey_finish( @@ -3472,7 +3500,9 @@ pub async fn authenticate_passkey_finish( } } +/// List passkeys. #[utoipa::path( + tag = "auth", get, operation_id = "listPasskeys", responses( @@ -3519,7 +3549,9 @@ pub struct RenamePasskey { pub name: String, } +/// Rename a passkey. #[utoipa::path( + tag = "auth", patch, operation_id = "renamePasskey", responses( @@ -3571,7 +3603,9 @@ pub async fn rename_passkey( Ok(HttpResponse::NoContent().finish()) } +/// Delete a passkey. #[utoipa::path( + tag = "auth", delete, operation_id = "deletePasskey", responses( @@ -3616,3 +3650,43 @@ pub async fn delete_passkey( transaction.commit().await?; Ok(HttpResponse::NoContent().finish()) } + +#[derive(utoipa::OpenApi)] +#[openapi(paths( + init, + auth_callback, + create_oauth_account, + discord_community_link, + delete_auth_provider, + validate_create_account_with_password, + create_account_with_password, + login_password, + login_2fa, + begin_2fa_flow, + finish_2fa_flow, + remove_2fa, + reset_password_begin, + change_password, + set_email, + resend_verify_email, + verify_email, + subscribe_newsletter, + get_newsletter_subscription_status, + register_passkey_start, + register_passkey_finish, + authenticate_passkey_start, + authenticate_passkey_finish, + list_passkeys, + rename_passkey, + delete_passkey, +))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; + +#[derive(utoipa::OpenApi)] +#[openapi( + nest( + (path = "/auth", api = RouteDoc), + ) +)] +pub(crate) struct ApiDoc; diff --git a/apps/labrinth/src/routes/internal/gdpr.rs b/apps/labrinth/src/routes/internal/gdpr.rs index 2412cf33ef..e8f0082ec1 100644 --- a/apps/labrinth/src/routes/internal/gdpr.rs +++ b/apps/labrinth/src/routes/internal/gdpr.rs @@ -6,10 +6,15 @@ use crate::queue::session::AuthQueue; use crate::routes::ApiError; use actix_web::{HttpRequest, HttpResponse, post, web}; -pub fn config(cfg: &mut web::ServiceConfig) { +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(web::scope("/gdpr").service(export)); } +/// Export GDPR data. +#[utoipa::path( + tag = "GDPR", + responses((status = OK, body = serde_json::Value)) +)] #[post("/export")] pub async fn export( req: HttpRequest, @@ -214,3 +219,8 @@ pub async fn export( "subscriptions": subscriptions, }))) } + +#[derive(utoipa::OpenApi)] +#[openapi(paths(export,))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; diff --git a/apps/labrinth/src/routes/internal/globals.rs b/apps/labrinth/src/routes/internal/globals.rs index 6dbb0c931d..c5b047493d 100644 --- a/apps/labrinth/src/routes/internal/globals.rs +++ b/apps/labrinth/src/routes/internal/globals.rs @@ -10,7 +10,7 @@ use chrono::{Datelike, Utc}; use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; -pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(get_globals); } @@ -89,8 +89,11 @@ pub fn tax_compliance_payout_threshold_for_year( value } -/// Gets configured global non-secret variables for this backend instance. -#[utoipa::path] +/// Get backend globals. +#[utoipa::path( + tag = "globals", + responses((status = OK, body = Globals)) +)] #[get("")] pub async fn get_globals() -> web::Json { web::Json(GLOBALS.clone()) @@ -118,3 +121,8 @@ mod tests { assert_eq!(second, Some(Decimal::from(2000_u64))); } } + +#[derive(utoipa::OpenApi)] +#[openapi(paths(get_globals,))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; diff --git a/apps/labrinth/src/routes/internal/gotenberg.rs b/apps/labrinth/src/routes/internal/gotenberg.rs index 149b254535..8955344b81 100644 --- a/apps/labrinth/src/routes/internal/gotenberg.rs +++ b/apps/labrinth/src/routes/internal/gotenberg.rs @@ -28,6 +28,12 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(success_callback).service(error_callback); } +/// Receive a Gotenberg success callback. +#[utoipa::path( + tag = "gotenberg", + request_body = Vec, + responses((status = NO_CONTENT)) +)] #[post("/gotenberg/success", guard = "internal_network_guard")] pub async fn success_callback( web::Header(header::ContentDisposition { @@ -82,7 +88,7 @@ pub async fn success_callback( Ok(()) } -#[derive(Debug, Clone, Serialize, Deserialize, Error)] +#[derive(Debug, Clone, Serialize, Deserialize, Error, utoipa::ToSchema)] pub struct GotenbergError { pub status: Option, pub message: Option, @@ -101,6 +107,11 @@ impl fmt::Display for GotenbergError { } } +/// Receive a Gotenberg error callback. +#[utoipa::path( + tag = "gotenberg", + responses((status = NO_CONTENT)) +)] #[post("/gotenberg/error", guard = "internal_network_guard")] pub async fn error_callback( web::Header(GotenbergTrace(trace)): web::Header, @@ -227,3 +238,8 @@ impl header::Header for ModrinthPaymentId { .map(|id| Self(PayoutId(id))) } } + +#[derive(utoipa::OpenApi)] +#[openapi(paths(success_callback, error_callback,))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; diff --git a/apps/labrinth/src/routes/internal/medal.rs b/apps/labrinth/src/routes/internal/medal.rs index 9c534a4823..02f92954e6 100644 --- a/apps/labrinth/src/routes/internal/medal.rs +++ b/apps/labrinth/src/routes/internal/medal.rs @@ -13,7 +13,7 @@ use crate::queue::billing::try_process_user_redeemal; use crate::routes::ApiError; use crate::util::guards::medal_key_guard; -pub fn config(cfg: &mut web::ServiceConfig) { +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(web::scope("/medal").service(verify).service(redeem)); } @@ -22,7 +22,18 @@ struct MedalQuery { username: String, } -#[post("verify", guard = "medal_key_guard")] +#[derive(Serialize, utoipa::ToSchema)] +struct VerifyResponse { + user_id: UserId, + redeemed: bool, +} + +/// Verify Medal credentials. +#[utoipa::path( + tag = "medal", + responses((status = OK, body = VerifyResponse)) +)] +#[post("/verify", guard = "medal_key_guard")] pub async fn verify( pool: web::Data, web::Query(MedalQuery { username }): web::Query, @@ -35,12 +46,6 @@ pub async fn verify( ) .await?; - #[derive(Serialize)] - struct VerifyResponse { - user_id: UserId, - redeemed: bool, - } - match maybe_fields { None => Err(ApiError::NotFound), Some(fields) => Ok(HttpResponse::Ok().json(VerifyResponse { @@ -50,7 +55,12 @@ pub async fn verify( } } -#[post("redeem", guard = "medal_key_guard")] +/// Redeem Medal credit. +#[utoipa::path( + tag = "medal", + responses((status = ACCEPTED), (status = CREATED)) +)] +#[post("/redeem", guard = "medal_key_guard")] pub async fn redeem( pool: web::Data, redis: web::Data, @@ -107,3 +117,8 @@ pub async fn redeem( Ok(HttpResponse::Created().finish()) } } + +#[derive(utoipa::OpenApi)] +#[openapi(paths(verify, redeem,))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; diff --git a/apps/labrinth/src/routes/internal/mod.rs b/apps/labrinth/src/routes/internal/mod.rs index 5bc6531856..b7244d9af3 100644 --- a/apps/labrinth/src/routes/internal/mod.rs +++ b/apps/labrinth/src/routes/internal/mod.rs @@ -21,97 +21,64 @@ pub mod statuses; pub use super::ApiError; use super::v3::oauth_clients; use crate::util::cors::default_cors; +use actix_web::web; -pub fn config(cfg: &mut actix_web::web::ServiceConfig) { +pub fn config(cfg: &mut web::ServiceConfig) { cfg.service( - actix_web::web::scope("/_internal") + web::scope("/_internal") .wrap(default_cors()) - .configure(|cfg| { - cfg.service( - actix_web::web::scope("/admin") - .service(admin::count_download) - .service(admin::force_reindex) - .service(admin::force_reindex_project), - ); - cfg.service( - actix_web::web::scope("/session") - .service(session::list) - .service(session::delete) - .service(session::refresh), - ); - cfg.service( - actix_web::web::scope("/auth") - .service(flows::init) - .service(flows::auth_callback) - .service(flows::delete_auth_provider) - .service(flows::create_account_with_password) - .service(flows::login_password) - .service(flows::login_2fa) - .service(flows::begin_2fa_flow) - .service(flows::finish_2fa_flow) - .service(flows::remove_2fa) - .service(flows::reset_password_begin) - .service(flows::change_password) - .service(flows::resend_verify_email) - .service(flows::set_email) - .service(flows::verify_email) - .service(flows::subscribe_newsletter) - .service(flows::get_newsletter_subscription_status) - .service(flows::discord_community_link), - ); - cfg.service(pats::get_pats); - cfg.service(pats::create_pat); - cfg.service(pats::edit_pat); - cfg.service(pats::delete_pat); - }) + .configure(admin::config) + .configure(session::config) + .configure(flows::config) + .configure(pats::config) .configure(oauth_clients::config) + .service(web::scope("/moderation").configure(moderation::config)) + .service(web::scope("/affiliate").configure(affiliate::config)) + .service(web::scope("/campaign").configure(campaign::config)) + .service(web::scope("/search-management").configure(search::config)) + .service(web::scope("/globals").configure(globals::config)) + .service(web::scope("/server-ping").configure(server_ping::config)) + .service(web::scope("/attribution").configure(attribution::config)) .configure(billing::config) + .configure(delphi::config) + .configure(external_notifications::config) .configure(gdpr::config) .configure(gotenberg::config) - .configure(statuses::config) .configure(medal::config) - .configure(external_notifications::config) .configure(mural::config) - .configure(delphi::config), - ); -} - -pub fn utoipa_config( - cfg: &mut utoipa_actix_web::service_config::ServiceConfig, -) { - cfg.service( - utoipa_actix_web::scope("/_internal/moderation") - .wrap(default_cors()) - .configure(moderation::config), - ) - .service( - utoipa_actix_web::scope("/_internal/affiliate") - .wrap(default_cors()) - .configure(affiliate::config), - ) - .service( - utoipa_actix_web::scope("/_internal/campaign") - .wrap(default_cors()) - .configure(campaign::config), - ) - .service( - utoipa_actix_web::scope("/_internal/search-management") - .wrap(default_cors()) - .configure(search::config), - ) - .service( - utoipa_actix_web::scope("/_internal/globals") - .wrap(default_cors()) - .configure(globals::config), - ) - .service( - utoipa_actix_web::scope("/_internal/server-ping") - .wrap(default_cors()) - .configure(server_ping::config), + .configure(statuses::config), ) .service( - utoipa_actix_web::scope("/_internal/attribution") + web::scope("/v3/analytics-event") .wrap(default_cors()) - .configure(attribution::config), + .configure(super::v3::analytics_event::config), ); } + +#[derive(utoipa::OpenApi)] +#[openapi( + nest( + (path = "/_internal", api = admin::ApiDoc), + (path = "/_internal", api = session::ApiDoc), + (path = "/_internal", api = flows::ApiDoc), + (path = "/_internal", api = pats::RouteDoc), + (path = "/_internal", api = oauth_clients::ApiDoc), + (path = "/_internal/moderation", api = moderation::ApiDoc), + (path = "/_internal/affiliate", api = affiliate::RouteDoc), + (path = "/_internal/campaign", api = campaign::RouteDoc), + (path = "/_internal/search-management", api = search::RouteDoc), + (path = "/_internal/globals", api = globals::RouteDoc), + (path = "/_internal/server-ping", api = server_ping::RouteDoc), + (path = "/_internal/attribution", api = attribution::RouteDoc), + (path = "/_internal/billing", api = billing::RouteDoc), + (path = "/_internal/delphi", api = delphi::RouteDoc), + (path = "/_internal", api = external_notifications::RouteDoc), + (path = "/_internal/gdpr", api = gdpr::RouteDoc), + (path = "/_internal", api = gotenberg::RouteDoc), + (path = "/_internal/medal", api = medal::RouteDoc), + (path = "/_internal", api = mural::RouteDoc), + (path = "/_internal", api = statuses::RouteDoc), + (path = "/v3/analytics-event", api = super::v3::analytics_event::RouteDoc), + ) +)] +pub struct ApiDoc; diff --git a/apps/labrinth/src/routes/internal/moderation/external_license.rs b/apps/labrinth/src/routes/internal/moderation/external_license.rs index 6ef567ee34..7c5167fe53 100644 --- a/apps/labrinth/src/routes/internal/moderation/external_license.rs +++ b/apps/labrinth/src/routes/internal/moderation/external_license.rs @@ -13,7 +13,7 @@ use crate::queue::moderation::ApprovalType; use crate::routes::ApiError; use crate::{auth::check_is_moderator_from_headers, queue::session::AuthQueue}; -pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(search) .service(get_by_sha1) .service(get_by_sha1_bulk) @@ -329,7 +329,11 @@ async fn fetch_by_flame_ids( Ok(results) } -#[utoipa::path] +/// Search external licenses. +#[utoipa::path( + tag = "moderation", + responses((status = OK, body = inline(Vec))) +)] #[post("/search")] async fn search( req: HttpRequest, @@ -393,7 +397,11 @@ async fn search( Ok(web::Json(results)) } -#[utoipa::path] +/// Look up external license metadata. +#[utoipa::path( + tag = "moderation", + responses((status = OK, body = ExternalLicenseLookupResponse)) +)] #[post("/lookup")] async fn lookup( req: HttpRequest, @@ -422,7 +430,11 @@ async fn lookup( })) } -#[utoipa::path] +/// Get external license by SHA-1. +#[utoipa::path( + tag = "moderation", + responses((status = OK, body = ExternalProject)) +)] #[get("/by-sha1/{sha1}")] async fn get_by_sha1( req: HttpRequest, @@ -448,7 +460,11 @@ async fn get_by_sha1( Ok(web::Json(result)) } -#[utoipa::path] +/// Get external licenses by SHA-1. +#[utoipa::path( + tag = "moderation", + responses((status = OK, body = inline(HashMap))) +)] #[post("/by-sha1")] async fn get_by_sha1_bulk( req: HttpRequest, @@ -472,7 +488,11 @@ async fn get_by_sha1_bulk( Ok(web::Json(results)) } -#[utoipa::path] +/// Add an external license file. +#[utoipa::path( + tag = "moderation", + responses((status = OK, body = ExternalProject)) +)] #[post("/file")] async fn add_file( req: HttpRequest, @@ -484,7 +504,11 @@ async fn add_file( upsert_file_license(req, pool, redis, session_queue, body).await } -#[utoipa::path] +/// Reassign an external license file. +#[utoipa::path( + tag = "moderation", + responses((status = OK, body = ExternalProject)) +)] #[post("/file/reassign")] async fn reassign_file( req: HttpRequest, @@ -584,7 +608,11 @@ async fn upsert_file_license( )) } -#[utoipa::path] +/// Update an external license. +#[utoipa::path( + tag = "moderation", + responses((status = OK, body = ExternalProject)) +)] #[patch("/{id}")] async fn update_license( req: HttpRequest, @@ -654,3 +682,16 @@ async fn update_license( .into_external_project(linked_files), )) } + +#[derive(utoipa::OpenApi)] +#[openapi(paths( + search, + lookup, + get_by_sha1, + get_by_sha1_bulk, + add_file, + reassign_file, + update_license, +))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; diff --git a/apps/labrinth/src/routes/internal/moderation/mod.rs b/apps/labrinth/src/routes/internal/moderation/mod.rs index ddcc775a7e..ee04e9b962 100644 --- a/apps/labrinth/src/routes/internal/moderation/mod.rs +++ b/apps/labrinth/src/routes/internal/moderation/mod.rs @@ -25,7 +25,7 @@ mod external_license; mod ownership; mod tech_review; -pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(get_projects) .service(get_project_meta) .service(set_project_meta) @@ -35,13 +35,9 @@ pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { .service(release_lock) .service(release_lock_beacon) .service(delete_all_locks) + .service(web::scope("/tech-review").configure(tech_review::config)) .service( - utoipa_actix_web::scope("/tech-review") - .configure(tech_review::config), - ) - .service( - utoipa_actix_web::scope("/external-license") - .configure(external_license::config), + web::scope("/external-license").configure(external_license::config), ); } @@ -162,8 +158,14 @@ pub struct DeleteAllLocksResponse { pub deleted_count: u64, } -/// Fetch all projects which are in the moderation queue. +/// List projects in the moderation queue. #[utoipa::path( + tag = "moderation", + params( + ("count" = Option, Query), + ("offset" = Option, Query), + ("has_external_dependencies" = Option, Query) + ), responses((status = OK, body = inline(Vec))) )] #[get("/projects")] @@ -291,9 +293,10 @@ pub async fn get_projects_internal( Ok(web::Json(projects)) } -/// Fetch moderation metadata for a specific project. +/// Get project moderation metadata. #[utoipa::path( - responses((status = OK, body = inline(Vec))) + tag = "moderation", + responses((status = OK, body = MissingMetadata)) )] #[get("/project/{id}")] async fn get_project_meta( @@ -447,8 +450,11 @@ pub enum Judgement { }, } -/// Update moderation judgements for projects in the review queue. -#[utoipa::path] +/// Update project moderation judgements. +#[utoipa::path( + tag = "moderation", + responses((status = NO_CONTENT)) +)] #[post("/project")] async fn set_project_meta( req: HttpRequest, @@ -536,9 +542,10 @@ async fn set_project_meta( Ok(()) } -/// Acquire or refresh a moderation lock on a project. +/// Acquire a moderation lock. /// Returns success if acquired, or info about who holds the lock if blocked. #[utoipa::path( + tag = "moderation", responses( (status = OK, body = LockAcquireResponse), (status = NOT_FOUND, description = "Project not found") @@ -594,8 +601,9 @@ async fn acquire_lock( } } -/// Force-acquire a moderation lock on a project (moderator override). +/// Override a moderation lock. #[utoipa::path( + tag = "moderation", responses( (status = OK, body = LockAcquireResponse), (status = NOT_FOUND, description = "Project not found") @@ -639,8 +647,9 @@ async fn override_lock( })) } -/// Check the lock status for a project +/// Get moderation lock status. #[utoipa::path( + tag = "moderation", responses( (status = OK, body = LockStatusResponse), (status = NOT_FOUND, description = "Project not found") @@ -699,8 +708,9 @@ async fn get_lock_status( } } -/// Release a moderation lock on a project +/// Release a moderation lock. #[utoipa::path( + tag = "moderation", responses( (status = OK, body = LockReleaseResponse), (status = NOT_FOUND, description = "Project not found") @@ -740,12 +750,13 @@ async fn release_lock( Ok(web::Json(LockReleaseResponse { success: released })) } -/// Release a moderation lock using credentials in the request body. +/// Release a moderation lock by beacon. /// /// For use with `navigator.sendBeacon`, which cannot set `Authorization` or send `DELETE`. /// The body must be `text/plain` containing the same token value as the `Authorization` header /// (optional `Bearer ` prefix). This avoids a CORS preflight compared to `application/json`. #[utoipa::path( + tag = "moderation", request_body( content = String, description = "Token value (same as Authorization header)", @@ -811,8 +822,9 @@ async fn release_lock_beacon( Ok(web::Json(LockReleaseResponse { success: released })) } -/// Delete all moderation locks (admin only) +/// Delete all moderation locks. #[utoipa::path( + tag = "moderation", responses( (status = OK, body = DeleteAllLocksResponse), (status = UNAUTHORIZED, description = "Not an admin") @@ -845,3 +857,29 @@ async fn delete_all_locks( Ok(web::Json(DeleteAllLocksResponse { deleted_count })) } + +#[derive(utoipa::OpenApi)] +#[openapi(paths( + get_projects, + get_project_meta, + set_project_meta, + acquire_lock, + override_lock, + get_lock_status, + release_lock, + release_lock_beacon, + delete_all_locks, +))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; + +pub(crate) struct ApiDoc; + +impl utoipa::OpenApi for ApiDoc { + fn openapi() -> utoipa::openapi::OpenApi { + let openapi = RouteDoc::openapi(); + openapi + .nest("/tech-review", tech_review::RouteDoc::openapi()) + .nest("/external-license", external_license::RouteDoc::openapi()) + } +} diff --git a/apps/labrinth/src/routes/internal/moderation/tech_review.rs b/apps/labrinth/src/routes/internal/moderation/tech_review.rs index cfa609a98e..eee7f1ab94 100644 --- a/apps/labrinth/src/routes/internal/moderation/tech_review.rs +++ b/apps/labrinth/src/routes/internal/moderation/tech_review.rs @@ -37,7 +37,7 @@ use crate::{ }; use eyre::eyre; -pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(search_projects) .service(get_project_report) .service(get_report) @@ -193,8 +193,9 @@ pub enum FlagReason { Delphi, } -/// Get info on an issue in a Delphi report. +/// Get a Delphi report issue. #[utoipa::path( + tag = "moderation", security(("bearer_auth" = [])), responses((status = OK, body = inline(FileIssue))) )] @@ -252,8 +253,9 @@ async fn get_issue( Ok(web::Json(row.data.0)) } -/// Get info on a specific report for a project. +/// Get a project technical report. #[utoipa::path( + tag = "moderation", security(("bearer_auth" = [])), responses((status = OK, body = inline(FileReport))) )] @@ -662,10 +664,11 @@ async fn fetch_project_reports( Ok(project_reports) } -/// Searches all projects which are awaiting technical review. +/// Search projects awaiting technical review. #[utoipa::path( + tag = "moderation", security(("bearer_auth" = [])), - responses((status = OK, body = inline(Vec))) + responses((status = OK, body = SearchResponse)) )] #[post("/search")] async fn search_projects( @@ -872,8 +875,9 @@ async fn search_projects( })) } -/// Gets the technical review report for a specific project. +/// Get a project technical review report. #[utoipa::path( + tag = "moderation", security(("bearer_auth" = [])), responses((status = OK, body = inline(ProjectReportResponse))) )] @@ -963,13 +967,14 @@ pub struct SubmitReport { pub message: Option, } -/// Submits a verdict for a project based on its technical reports. +/// Submit a technical review verdict. /// /// Before this is called, all issues for this project's reports must have been /// marked as either safe or unsafe. Otherwise, this will error with /// [`ApiError::TechReviewIssuesWithNoVerdict`], providing the issue IDs which /// are still unmarked. #[utoipa::path( + tag = "moderation", security(("bearer_auth" = [])), responses((status = NO_CONTENT)) )] @@ -1163,11 +1168,12 @@ pub struct UpdateIssue { pub verdict: DelphiVerdict, } -/// Updates the state of a technical review issue detail. +/// Update technical review issue details. /// /// This will not automatically reject the project for malware, but just flag /// this issue with a verdict. #[utoipa::path( + tag = "moderation", security(("bearer_auth" = [])), responses((status = NO_CONTENT)) )] @@ -1273,9 +1279,12 @@ pub struct AddReport { pub file_id: FileId, } -/// Adds a file to the technical review queue by adding an empty report, if one +/// Add a technical review report. /// does not already exist for it. -#[utoipa::path] +#[utoipa::path( + tag = "moderation", + responses((status = OK, body = DelphiReportId)) +)] #[put("/report")] async fn add_report( req: HttpRequest, @@ -1337,3 +1346,16 @@ async fn add_report( Ok(web::Json(report_id)) } + +#[derive(utoipa::OpenApi)] +#[openapi(paths( + get_issue, + get_report, + search_projects, + get_project_report, + submit_report, + update_issue_details, + add_report, +))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; diff --git a/apps/labrinth/src/routes/internal/mural.rs b/apps/labrinth/src/routes/internal/mural.rs index d10ff4e90c..3cf8c7d2ca 100644 --- a/apps/labrinth/src/routes/internal/mural.rs +++ b/apps/labrinth/src/routes/internal/mural.rs @@ -6,10 +6,15 @@ use crate::{ queue::payouts::PayoutsQueue, routes::ApiError, util::error::Context, }; -pub fn config(cfg: &mut web::ServiceConfig) { +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(get_bank_details); } +/// Get bank details. +#[utoipa::path( + tag = "mural", + responses((status = OK, body = serde_json::Value)) +)] #[get("/mural/bank-details")] async fn get_bank_details( payouts_queue: web::Data, @@ -26,3 +31,8 @@ async fn get_bank_details( .wrap_internal_err("failed to fetch bank details")?; Ok(web::Json(details)) } + +#[derive(utoipa::OpenApi)] +#[openapi(paths(get_bank_details,))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; diff --git a/apps/labrinth/src/routes/internal/pats.rs b/apps/labrinth/src/routes/internal/pats.rs index 253c9f7254..e708653bec 100644 --- a/apps/labrinth/src/routes/internal/pats.rs +++ b/apps/labrinth/src/routes/internal/pats.rs @@ -22,20 +22,22 @@ use crate::util::validate::validation_errors_to_string; use serde::Deserialize; use validator::Validate; -pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(get_pats); cfg.service(create_pat); cfg.service(edit_pat); cfg.service(delete_pat); } +/// List personal access tokens. #[utoipa::path( + tag = "personal access tokens", get, - operation_id = "getPats", - responses( - (status = 200, description = "List of PATs"), - (status = 401, description = "Unauthorized") - ), + operation_id = "getPats", + responses( + (status = 200, description = "List of PATs", body = serde_json::Value), + (status = 401, description = "Unauthorized") + ), security(("bearer_auth" = ["PAT_READ"])) )] #[get("/pat")] @@ -82,14 +84,16 @@ pub struct NewPersonalAccessToken { pub expires: DateTime, } +/// Create a personal access token. #[utoipa::path( + tag = "personal access tokens", post, - operation_id = "createPat", - responses( - (status = 200, description = "PAT created"), - (status = 400, description = "Invalid input"), - (status = 401, description = "Unauthorized") - ), + operation_id = "createPat", + responses( + (status = 200, description = "PAT created", body = serde_json::Value), + (status = 400, description = "Invalid input"), + (status = 401, description = "Unauthorized") + ), security(("bearer_auth" = ["PAT_CREATE"])) )] #[post("/pat")] @@ -185,7 +189,9 @@ pub struct ModifyPersonalAccessToken { pub expires: Option>, } +/// Update a personal access token. #[utoipa::path( + tag = "personal access tokens", patch, operation_id = "editPat", params(("id" = String, Path, description = "The PAT ID")), @@ -293,7 +299,9 @@ pub async fn edit_pat( Ok(HttpResponse::NoContent().finish()) } +/// Delete a personal access token. #[utoipa::path( + tag = "personal access tokens", delete, operation_id = "deletePat", params(("id" = String, Path, description = "The PAT ID")), @@ -345,3 +353,8 @@ pub async fn delete_pat( Ok(HttpResponse::NoContent().finish()) } + +#[derive(utoipa::OpenApi)] +#[openapi(paths(get_pats, create_pat, edit_pat, delete_pat,))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; diff --git a/apps/labrinth/src/routes/internal/search.rs b/apps/labrinth/src/routes/internal/search.rs index 86a3615d55..7a993a3371 100644 --- a/apps/labrinth/src/routes/internal/search.rs +++ b/apps/labrinth/src/routes/internal/search.rs @@ -5,20 +5,28 @@ use crate::{ }; use actix_web::{delete, get, web}; -pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(tasks).service(tasks_cancel); } -#[utoipa::path] -#[get("tasks", guard = "admin_key_guard")] +/// List search tasks. +#[utoipa::path( + tag = "search", + responses((status = OK, body = serde_json::Value)) +)] +#[get("/tasks", guard = "admin_key_guard")] pub async fn tasks( search: web::Data, ) -> Result, ApiError> { Ok(web::Json(search.tasks().await.map_err(ApiError::Internal)?)) } -#[utoipa::path] -#[delete("tasks", guard = "admin_key_guard")] +/// Cancel search tasks. +#[utoipa::path( + tag = "search", + responses((status = NO_CONTENT)) +)] +#[delete("/tasks", guard = "admin_key_guard")] pub async fn tasks_cancel( search: web::Data, body: web::Json, @@ -29,3 +37,8 @@ pub async fn tasks_cancel( .map_err(ApiError::Internal)?; Ok(()) } + +#[derive(utoipa::OpenApi)] +#[openapi(paths(tasks, tasks_cancel,))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; diff --git a/apps/labrinth/src/routes/internal/server_ping.rs b/apps/labrinth/src/routes/internal/server_ping.rs index 6f2d7e147f..a48c50c591 100644 --- a/apps/labrinth/src/routes/internal/server_ping.rs +++ b/apps/labrinth/src/routes/internal/server_ping.rs @@ -12,7 +12,7 @@ use crate::{ util::error::Context, }; -pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(ping_minecraft_java); } @@ -22,7 +22,11 @@ pub struct PingRequest { pub timeout_ms: Option, } -#[utoipa::path] +/// Ping Minecraft server. +#[utoipa::path( + tag = "server ping", + responses((status = NO_CONTENT)) +)] #[post("/minecraft-java")] pub async fn ping_minecraft_java( req: HttpRequest, @@ -47,3 +51,8 @@ pub async fn ping_minecraft_java( Ok(()) } + +#[derive(utoipa::OpenApi)] +#[openapi(paths(ping_minecraft_java,))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; diff --git a/apps/labrinth/src/routes/internal/session.rs b/apps/labrinth/src/routes/internal/session.rs index 405a1781d3..62503703f7 100644 --- a/apps/labrinth/src/routes/internal/session.rs +++ b/apps/labrinth/src/routes/internal/session.rs @@ -19,9 +19,9 @@ use rand::{Rng, SeedableRng}; use rand_chacha::ChaCha20Rng; use woothee::parser::Parser; -pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service( - utoipa_actix_web::scope("/session") + web::scope("/session") .service(list) .service(delete) .service(refresh), @@ -133,13 +133,15 @@ pub async fn issue_session( Ok(session) } +/// List sessions. #[utoipa::path( + tag = "sessions", get, - operation_id = "listSessions", - responses( - (status = 200, description = "List of active sessions"), - (status = 401, description = "Unauthorized") - ), + operation_id = "listSessions", + responses( + (status = 200, description = "List of active sessions", body = serde_json::Value), + (status = 401, description = "Unauthorized") + ), security(("bearer_auth" = ["SESSION_READ"])) )] #[get("/list")] @@ -178,7 +180,9 @@ pub async fn list( Ok(HttpResponse::Ok().json(sessions)) } +/// Delete a session. #[utoipa::path( + tag = "sessions", delete, operation_id = "deleteSession", params(("id" = String, Path, description = "The session ID")), @@ -228,13 +232,15 @@ pub async fn delete( Ok(HttpResponse::NoContent().body("")) } +/// Refresh a session. #[utoipa::path( + tag = "sessions", post, - operation_id = "refreshSession", - responses( - (status = 200, description = "Session refreshed"), - (status = 401, description = "Unauthorized") - ) + operation_id = "refreshSession", + responses( + (status = 200, description = "Session refreshed", body = serde_json::Value), + (status = 401, description = "Unauthorized") + ) )] #[post("/refresh")] pub async fn refresh( @@ -310,3 +316,16 @@ pub async fn refresh( )) } } + +#[derive(utoipa::OpenApi)] +#[openapi(paths(list, delete, refresh,))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; + +#[derive(utoipa::OpenApi)] +#[openapi( + nest( + (path = "/session", api = RouteDoc), + ) +)] +pub(crate) struct ApiDoc; diff --git a/apps/labrinth/src/routes/internal/statuses.rs b/apps/labrinth/src/routes/internal/statuses.rs index 9a589e5013..8f10172f4d 100644 --- a/apps/labrinth/src/routes/internal/statuses.rs +++ b/apps/labrinth/src/routes/internal/statuses.rs @@ -35,7 +35,7 @@ use std::sync::atomic::Ordering; use tokio::sync::oneshot::error::TryRecvError; use tokio::time::{Duration, sleep}; -pub fn config(cfg: &mut web::ServiceConfig) { +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(ws_init); } @@ -45,7 +45,12 @@ struct LauncherHeartbeatInit { } // TODO: Move launcher-specific tunnel traffic to a proper launcher websocket endpoint. -#[get("launcher_socket")] +/// Start launcher socket. +#[utoipa::path( + tag = "statuses", + responses((status = 101)) +)] +#[get("/launcher_socket")] pub async fn ws_init( req: HttpRequest, pool: Data, @@ -554,3 +559,8 @@ pub async fn close_socket( Ok(()) } + +#[derive(utoipa::OpenApi)] +#[openapi(paths(ws_init,))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; diff --git a/apps/labrinth/src/routes/maven.rs b/apps/labrinth/src/routes/maven.rs index 41757641cc..9259d1436a 100644 --- a/apps/labrinth/src/routes/maven.rs +++ b/apps/labrinth/src/routes/maven.rs @@ -18,7 +18,7 @@ use quick_xml::escape::escape; use std::collections::HashSet; use yaserde::YaSerialize; -pub fn config(cfg: &mut web::ServiceConfig) { +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(maven_metadata); cfg.service(version_file_sha512); cfg.service(version_file_sha1); @@ -70,7 +70,11 @@ pub struct MavenPom { description: String, } -#[get("maven/modrinth/{id}/maven-metadata.xml")] +#[utoipa::path( + tag = "maven", + responses((status = OK, body = String, content_type = "text/xml")) +)] +#[get("/maven/modrinth/{id}/maven-metadata.xml")] pub async fn maven_metadata( req: HttpRequest, params: web::Path<(String,)>, @@ -279,8 +283,15 @@ fn find_file<'a>( None } +#[utoipa::path( + tag = "maven", + responses( + (status = OK, body = String, content_type = "text/xml"), + (status = TEMPORARY_REDIRECT) + ) +)] #[route( - "maven/modrinth/{id}/{versionnum}/{file}", + "/maven/modrinth/{id}/{versionnum}/{file}", method = "GET", method = "HEAD" )] @@ -349,7 +360,8 @@ pub async fn version_file( Err(ApiError::NotFound) } -#[get("maven/modrinth/{id}/{versionnum}/{file}.sha1")] +#[utoipa::path(tag = "maven", responses((status = OK, body = String)))] +#[get("/maven/modrinth/{id}/{versionnum}/{file}.sha1")] pub async fn version_file_sha1( req: HttpRequest, params: web::Path<(String, String, String)>, @@ -396,7 +408,8 @@ pub async fn version_file_sha1( )) } -#[get("maven/modrinth/{id}/{versionnum}/{file}.sha512")] +#[utoipa::path(tag = "maven", responses((status = OK, body = String)))] +#[get("/maven/modrinth/{id}/{versionnum}/{file}.sha512")] pub async fn version_file_sha512( req: HttpRequest, params: web::Path<(String, String, String)>, @@ -442,3 +455,13 @@ pub async fn version_file_sha512( |hash_str| HttpResponse::Ok().body(hash_str.clone()), )) } + +#[derive(utoipa::OpenApi)] +#[openapi(paths( + maven_metadata, + version_file, + version_file_sha1, + version_file_sha512, +))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; diff --git a/apps/labrinth/src/routes/mod.rs b/apps/labrinth/src/routes/mod.rs index f5cfdc1ad9..bb01a0bf53 100644 --- a/apps/labrinth/src/routes/mod.rs +++ b/apps/labrinth/src/routes/mod.rs @@ -24,6 +24,28 @@ mod updates; pub use self::not_found::not_found; pub fn root_config(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("/api/v1") + .wrap(default_cors()) + .wrap_fn(|req, _srv| { + async { + Ok(req.into_response( + HttpResponse::Gone() + .content_type("application/json") + .body(r#"{"error":"api_deprecated","description":"You are using an application that uses an outdated version of Modrinth's API. Please either update it or switch to another application. For developers: https://docs.modrinth.com/api/#versioning"}"#) + )) + }.boxed_local() + }) + ); + cfg.service( + web::scope("") + .wrap(default_cors()) + .service(index::index_get) + .service(Files::new("/", "assets/")), + ); +} + +pub fn public_config(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("/maven") .wrap(default_cors()) @@ -58,27 +80,18 @@ pub fn root_config(cfg: &mut web::ServiceConfig) { ) .configure(analytics::config), ); - cfg.service( - web::scope("/api/v1") - .wrap(default_cors()) - .wrap_fn(|req, _srv| { - async { - Ok(req.into_response( - HttpResponse::Gone() - .content_type("application/json") - .body(r#"{"error":"api_deprecated","description":"You are using an application that uses an outdated version of Modrinth's API. Please either update it or switch to another application. For developers: https://docs.modrinth.com/api/#versioning"}"#) - )) - }.boxed_local() - }) - ); - cfg.service( - web::scope("") - .wrap(default_cors()) - .service(index::index_get) - .service(Files::new("/", "assets/")), - ); } +#[derive(utoipa::OpenApi)] +#[openapi( + nest( + (path = "/maven", api = maven::RouteDoc), + (path = "/updates", api = updates::RouteDoc), + (path = "/analytics", api = analytics::RouteDoc), + ) +)] +pub struct PublicApiDoc; + /// Error when calling an HTTP endpoint. #[derive(thiserror::Error, Debug)] pub enum ApiError { diff --git a/apps/labrinth/src/routes/updates.rs b/apps/labrinth/src/routes/updates.rs index 70c826ed64..a1f7bc1521 100644 --- a/apps/labrinth/src/routes/updates.rs +++ b/apps/labrinth/src/routes/updates.rs @@ -17,11 +17,11 @@ use crate::queue::session::AuthQueue; use super::ApiError; -pub fn config(cfg: &mut web::ServiceConfig) { +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(forge_updates); } -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, utoipa::ToSchema)] pub struct NeoForge { #[serde(default = "default_neoforge")] pub neoforge: String, @@ -31,7 +31,8 @@ fn default_neoforge() -> String { "none".into() } -#[get("{id}/forge_updates.json")] +#[utoipa::path(tag = "updates", responses((status = OK)))] +#[get("/{id}/forge_updates.json")] pub async fn forge_updates( req: HttpRequest, web::Query(neo): web::Query, @@ -134,3 +135,8 @@ pub async fn forge_updates( Ok(HttpResponse::Ok().json(response)) } + +#[derive(utoipa::OpenApi)] +#[openapi(paths(forge_updates,))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; diff --git a/apps/labrinth/src/routes/v2/mod.rs b/apps/labrinth/src/routes/v2/mod.rs index dbae422da2..d000426e05 100644 --- a/apps/labrinth/src/routes/v2/mod.rs +++ b/apps/labrinth/src/routes/v2/mod.rs @@ -1,4 +1,4 @@ -mod moderation; +pub(crate) mod moderation; mod notifications; pub(crate) mod project_creation; mod projects; @@ -14,17 +14,16 @@ mod versions; pub use super::ApiError; use crate::util::cors::default_cors; +use actix_web::web; -pub fn utoipa_config( - cfg: &mut utoipa_actix_web::service_config::ServiceConfig, -) { +pub fn config(cfg: &mut web::ServiceConfig) { cfg.service( - utoipa_actix_web::scope("/v2") + web::scope("/v2") .wrap(default_cors()) - .configure(super::internal::admin::config) .configure(super::internal::session::config) .configure(super::internal::flows::config) .configure(super::internal::pats::config) + .configure(super::internal::admin::config) .configure(moderation::config) .configure(notifications::config) .configure(project_creation::config) @@ -39,3 +38,26 @@ pub fn utoipa_config( .configure(versions::config), ); } + +#[derive(utoipa::OpenApi)] +#[openapi( + nest( + (path = "/v2", api = super::internal::session::ApiDoc), + (path = "/v2", api = super::internal::flows::ApiDoc), + (path = "/v2", api = super::internal::pats::RouteDoc), + (path = "/v2", api = super::internal::admin::ApiDoc), + (path = "/v2", api = moderation::ApiDoc), + (path = "/v2", api = notifications::ApiDoc), + (path = "/v2", api = project_creation::RouteDoc), + (path = "/v2", api = projects::ApiDoc), + (path = "/v2", api = reports::RouteDoc), + (path = "/v2", api = statistics::RouteDoc), + (path = "/v2", api = tags::ApiDoc), + (path = "/v2", api = teams::ApiDoc), + (path = "/v2", api = threads::ApiDoc), + (path = "/v2", api = users::ApiDoc), + (path = "/v2", api = version_file::ApiDoc), + (path = "/v2", api = versions::ApiDoc), + ) +)] +pub struct ApiDoc; diff --git a/apps/labrinth/src/routes/v2/moderation.rs b/apps/labrinth/src/routes/v2/moderation.rs index b79a6b7bd6..7b0280ff2a 100644 --- a/apps/labrinth/src/routes/v2/moderation.rs +++ b/apps/labrinth/src/routes/v2/moderation.rs @@ -8,8 +8,8 @@ use crate::{database::redis::RedisPool, routes::v2_reroute}; use actix_web::{HttpRequest, HttpResponse, get, web}; use serde::Deserialize; -pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { - cfg.service(utoipa_actix_web::scope("/moderation").service(get_projects)); +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { + cfg.service(web::scope("/moderation").service(get_projects)); } #[derive(Deserialize)] @@ -22,8 +22,9 @@ fn default_count() -> u16 { 100 } -/// Get projects in the moderation queue. +/// List projects in the moderation queue. #[utoipa::path( + tag = "v2 moderation", get, operation_id = "getModerationProjects", params( @@ -34,7 +35,7 @@ fn default_count() -> u16 { ) ), responses( - (status = 200, description = "Expected response to a valid request"), + (status = 200, description = "Expected response to a valid request", body = Vec), ( status = 401, description = "Incorrect token scopes or no authorization to access the requested item(s)" @@ -79,3 +80,16 @@ pub async fn get_projects( Err(response) => Ok(response), } } + +#[derive(utoipa::OpenApi)] +#[openapi(paths(get_projects,))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; + +#[derive(utoipa::OpenApi)] +#[openapi( + nest( + (path = "/moderation", api = RouteDoc), + ) +)] +pub(crate) struct ApiDoc; diff --git a/apps/labrinth/src/routes/v2/notifications.rs b/apps/labrinth/src/routes/v2/notifications.rs index 0bd52c0c02..f65554a0c4 100644 --- a/apps/labrinth/src/routes/v2/notifications.rs +++ b/apps/labrinth/src/routes/v2/notifications.rs @@ -10,12 +10,12 @@ use crate::routes::v3; use actix_web::{HttpRequest, HttpResponse, delete, get, patch, web}; use serde::{Deserialize, Serialize}; -pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(notifications_get); cfg.service(notifications_delete); cfg.service(notifications_read); cfg.service( - utoipa_actix_web::scope("/notification") + web::scope("/notification") .service(notification_get) .service(notification_read) .service(notification_delete), @@ -27,8 +27,9 @@ pub struct NotificationIds { pub ids: String, } -/// Get multiple notifications by ID. +/// Get multiple notifications by ID. #[utoipa::path( + tag = "notifications", get, operation_id = "getNotifications", params( @@ -39,7 +40,7 @@ pub struct NotificationIds { ) ), responses( - (status = 200, description = "Expected response to a valid request"), + (status = 200, description = "Expected response to a valid request", body = Vec), ( status = 401, description = "Incorrect token scopes or no authorization to access the requested item(s)" @@ -80,13 +81,14 @@ pub async fn notifications_get( } } -/// Get a notification by ID. +/// Get a notification by ID. #[utoipa::path( + tag = "notifications", get, operation_id = "getNotification", params(("id" = NotificationId, Path, description = "The ID of the notification")), responses( - (status = 200, description = "Expected response to a valid request"), + (status = 200, description = "Expected response to a valid request", body = LegacyNotification), ( status = 401, description = "Incorrect token scopes or no authorization to access the requested item(s)" @@ -124,8 +126,9 @@ pub async fn notification_get( } } -/// Mark a notification as read. +/// Mark a notification as read. #[utoipa::path( + tag = "notifications", patch, operation_id = "readNotification", params(("id" = NotificationId, Path, description = "The ID of the notification")), @@ -156,8 +159,9 @@ pub async fn notification_read( .or_else(v2_reroute::flatten_404_error) } -/// Delete a notification by ID. +/// Delete a notification by ID. #[utoipa::path( + tag = "notifications", delete, operation_id = "deleteNotification", params(("id" = NotificationId, Path, description = "The ID of the notification")), @@ -194,8 +198,9 @@ pub async fn notification_delete( .or_else(v2_reroute::flatten_404_error) } -/// Mark multiple notifications as read. +/// Mark multiple notifications as read. #[utoipa::path( + tag = "notifications", patch, operation_id = "readNotifications", params( @@ -238,8 +243,9 @@ pub async fn notifications_read( .or_else(v2_reroute::flatten_404_error) } -/// Delete multiple notifications by ID. +/// Delete multiple notifications by ID. #[utoipa::path( + tag = "notifications", delete, operation_id = "deleteNotifications", params( @@ -281,3 +287,32 @@ pub async fn notifications_delete( .await .or_else(v2_reroute::flatten_404_error) } + +#[derive(utoipa::OpenApi)] +#[openapi(paths( + notifications_get, + notification_get, + notification_read, + notification_delete, + notifications_read, + notifications_delete, +))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; + +#[derive(utoipa::OpenApi)] +#[openapi(paths(notifications_get, notifications_read, notifications_delete,))] +pub(crate) struct RootRoutesDoc; + +#[derive(utoipa::OpenApi)] +#[openapi(paths(notification_get, notification_read, notification_delete,))] +pub(crate) struct NotificationRoutesDoc; + +pub(crate) struct ApiDoc; + +impl utoipa::OpenApi for ApiDoc { + fn openapi() -> utoipa::openapi::OpenApi { + let openapi = RootRoutesDoc::openapi(); + openapi.nest("/notification", NotificationRoutesDoc::openapi()) + } +} diff --git a/apps/labrinth/src/routes/v2/project_creation.rs b/apps/labrinth/src/routes/v2/project_creation.rs index d6afd30cc8..3e2bf3b4c8 100644 --- a/apps/labrinth/src/routes/v2/project_creation.rs +++ b/apps/labrinth/src/routes/v2/project_creation.rs @@ -25,7 +25,7 @@ use validator::Validate; use super::version_creation::InitialVersionData; -pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(project_create); } @@ -134,8 +134,9 @@ struct ProjectCreateData { pub organization_id: Option, } -/// Create a new project with initial versions. +/// Create a new project with initial versions. #[utoipa::path( + tag = "project creation", post, operation_id = "createProject", request_body( @@ -143,7 +144,7 @@ struct ProjectCreateData { description = "Multipart payload containing `data` and uploaded files" ), responses( - (status = 200, description = "Expected response to a valid request"), + (status = 200, description = "Expected response to a valid request", body = LegacyProject), (status = 400, description = "Request was invalid, see given error"), ( status = 401, @@ -304,3 +305,8 @@ pub async fn project_create( Err(response) => Ok(response), } } + +#[derive(utoipa::OpenApi)] +#[openapi(paths(project_create,))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; diff --git a/apps/labrinth/src/routes/v2/projects.rs b/apps/labrinth/src/routes/v2/projects.rs index ded7348367..9add6a6abe 100644 --- a/apps/labrinth/src/routes/v2/projects.rs +++ b/apps/labrinth/src/routes/v2/projects.rs @@ -20,13 +20,13 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use validator::Validate; -pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(project_search); cfg.service(projects_get); cfg.service(projects_edit); cfg.service(random_projects_get); cfg.service( - utoipa_actix_web::scope("/project") + web::scope("/project") .service(project_get) .service(project_get_check) .service(project_delete) @@ -40,7 +40,7 @@ pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { .service(project_unfollow) .service(super::teams::team_members_get_project) .service( - utoipa_actix_web::scope("/{project_id}") + web::scope("/{project_id}") .service(super::versions::version_list) .service(super::versions::version_project_get) .service(dependency_list), @@ -48,8 +48,9 @@ pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { ); } -/// Search projects. +/// Search projects. #[utoipa::path( + tag = "search", get, operation_id = "searchProjects", params( @@ -77,10 +78,35 @@ pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { "limit" = Option, Query, description = "Maximum number of search results" + ), + ( + "show_metadata" = Option, + Query, + description = "Whether to include search metadata" + ), + ( + "typesense_config" = Option, + Query, + description = "Typesense request configuration" + ), + ( + "new_filters" = Option, + Query, + description = "Search filters" + ), + ( + "filters" = Option, + Query, + description = "Legacy search filters" + ), + ( + "version" = Option, + Query, + description = "Legacy search version" ) ), responses( - (status = 200, description = "Expected response to a valid request"), + (status = 200, description = "Expected response to a valid request", body = LegacySearchResults), (status = 400, description = "Request was invalid, see given error") ) )] @@ -181,8 +207,9 @@ pub struct RandomProjects { pub count: u32, } -/// Get random projects. +/// Get random projects. #[utoipa::path( + tag = "projects", get, operation_id = "randomProjects", params( @@ -193,7 +220,7 @@ pub struct RandomProjects { ) ), responses( - (status = 200, description = "Expected response to a valid request"), + (status = 200, description = "Expected response to a valid request", body = Vec), (status = 400, description = "Request was invalid, see given error") ) )] @@ -224,8 +251,9 @@ pub async fn random_projects_get( } } -/// Get multiple projects by ID or slug. +/// Get multiple projects by ID or slug. #[utoipa::path( + tag = "projects", get, operation_id = "getProjects", params( @@ -235,7 +263,7 @@ pub async fn random_projects_get( description = "The JSON array of project IDs or slugs" ) ), - responses((status = 200, description = "Expected response to a valid request")) + responses((status = 200, description = "Expected response to a valid request", body = Vec)) )] #[get("/projects")] pub async fn projects_get( @@ -268,13 +296,14 @@ pub async fn projects_get( } } -/// Get a project by ID or slug. +/// Get a project by ID or slug. #[utoipa::path( + tag = "projects", get, operation_id = "getProject", params(("id" = String, Path, description = "The ID or slug of the project")), responses( - (status = 200, description = "Expected response to a valid request"), + (status = 200, description = "Expected response to a valid request", body = LegacyProject), ( status = 404, description = "The requested item(s) were not found or no authorization to access the requested item(s)" @@ -317,13 +346,14 @@ pub async fn project_get( } //checks the validity of a project id or slug -/// Check that a project ID or slug exists. +/// Check that a project ID or slug exists. #[utoipa::path( + tag = "projects", get, operation_id = "checkProjectValidity", params(("id" = String, Path, description = "The ID or slug of the project")), responses( - (status = 200, description = "Expected response to a valid request"), + (status = 200, description = "Expected response to a valid request", body = v3::projects::ProjectCheckResponse), ( status = 404, description = "The requested item(s) were not found or no authorization to access the requested item(s)" @@ -348,13 +378,14 @@ struct DependencyInfo { pub versions: Vec, } -/// Get dependency projects and versions for a project. +/// Get dependency projects and versions for a project. #[utoipa::path( + tag = "projects", get, operation_id = "getDependencies", params(("id" = String, Path, description = "The ID or slug of the project")), responses( - (status = 200, description = "Expected response to a valid request"), + (status = 200, description = "Expected response to a valid request", body = DependencyInfo), ( status = 404, description = "The requested item(s) were not found or no authorization to access the requested item(s)" @@ -508,14 +539,15 @@ pub struct EditProject { pub monetization_status: Option, } -/// Modify a project. +/// Update a project. #[utoipa::path( + tag = "projects", patch, operation_id = "modifyProject", params(("id" = String, Path, description = "The ID or slug of the project")), request_body = EditProject, responses( - (status = 204, description = "Expected response to a valid request"), + (status = NO_CONTENT, description = "Expected response to a valid request"), ( status = 401, description = "Incorrect token scopes or no authorization to access the requested item(s)" @@ -766,8 +798,9 @@ pub struct BulkEditProject { pub discord_url: Option>, } -/// Bulk-edit multiple projects. +/// Bulk-edit multiple projects. #[utoipa::path( + tag = "projects", patch, operation_id = "patchProjects", params( @@ -779,7 +812,7 @@ pub struct BulkEditProject { ), request_body = BulkEditProject, responses( - (status = 204, description = "Expected response to a valid request"), + (status = NO_CONTENT, description = "Expected response to a valid request"), (status = 400, description = "Request was invalid, see given error"), ( status = 401, @@ -892,8 +925,9 @@ pub struct Extension { pub ext: String, } -/// Change a project's icon. +/// Change a project's icon. #[utoipa::path( + tag = "projects", patch, operation_id = "changeProjectIcon", params( @@ -915,7 +949,7 @@ pub struct Extension { ) ), responses( - (status = 204, description = "Expected response to a valid request"), + (status = NO_CONTENT, description = "Expected response to a valid request"), (status = 400, description = "Request was invalid, see given error") ), security(("bearer_auth" = ["PROJECT_WRITE"])) @@ -949,13 +983,14 @@ pub async fn project_icon_edit( .or_else(v2_reroute::flatten_404_error) } -/// Delete a project's icon. +/// Delete a project's icon. #[utoipa::path( + tag = "projects", delete, operation_id = "deleteProjectIcon", params(("id" = String, Path, description = "The ID or slug of the project")), responses( - (status = 204, description = "Expected response to a valid request"), + (status = NO_CONTENT, description = "Expected response to a valid request"), (status = 400, description = "Request was invalid, see given error"), ( status = 401, @@ -998,8 +1033,9 @@ pub struct GalleryCreateQuery { pub ordering: Option, } -/// Add a gallery image to a project. +/// Add a gallery image to a project. #[utoipa::path( + tag = "projects", post, operation_id = "addGalleryImage", params( @@ -1041,7 +1077,7 @@ pub struct GalleryCreateQuery { ) ), responses( - (status = 204, description = "Expected response to a valid request"), + (status = NO_CONTENT, description = "Expected response to a valid request"), (status = 400, description = "Request was invalid, see given error"), ( status = 401, @@ -1112,8 +1148,9 @@ pub struct GalleryEditQuery { pub ordering: Option, } -/// Modify a gallery image. +/// Update a gallery image. #[utoipa::path( + tag = "projects", patch, operation_id = "modifyGalleryImage", params( @@ -1141,7 +1178,7 @@ pub struct GalleryEditQuery { ) ), responses( - (status = 204, description = "Expected response to a valid request"), + (status = NO_CONTENT, description = "Expected response to a valid request"), ( status = 401, description = "Incorrect token scopes or no authorization to access the requested item(s)" @@ -1186,8 +1223,9 @@ pub struct GalleryDeleteQuery { pub url: String, } -/// Delete a gallery image. +/// Delete a gallery image. #[utoipa::path( + tag = "projects", delete, operation_id = "deleteGalleryImage", params( @@ -1195,7 +1233,7 @@ pub struct GalleryDeleteQuery { ("url" = String, Query, description = "URL of the image to delete") ), responses( - (status = 204, description = "Expected response to a valid request"), + (status = NO_CONTENT, description = "Expected response to a valid request"), (status = 400, description = "Request was invalid, see given error"), ( status = 401, @@ -1228,13 +1266,14 @@ pub async fn delete_gallery_item( .or_else(v2_reroute::flatten_404_error) } -/// Delete a project by ID or slug. +/// Delete a project by ID or slug. #[utoipa::path( + tag = "projects", delete, operation_id = "deleteProject", params(("id" = String, Path, description = "The ID or slug of the project")), responses( - (status = 204, description = "Expected response to a valid request"), + (status = NO_CONTENT, description = "Expected response to a valid request"), (status = 400, description = "Request was invalid, see given error"), ( status = 401, @@ -1266,13 +1305,14 @@ pub async fn project_delete( .or_else(v2_reroute::flatten_404_error) } -/// Follow a project. +/// Follow a project. #[utoipa::path( + tag = "projects", post, operation_id = "followProject", params(("id" = String, Path, description = "The ID or slug of the project")), responses( - (status = 204, description = "Expected response to a valid request"), + (status = NO_CONTENT, description = "Expected response to a valid request"), (status = 400, description = "Request was invalid, see given error"), ( status = 401, @@ -1295,13 +1335,14 @@ pub async fn project_follow( .or_else(v2_reroute::flatten_404_error) } -/// Unfollow a project. +/// Unfollow a project. #[utoipa::path( + tag = "projects", delete, operation_id = "unfollowProject", params(("id" = String, Path, description = "The ID or slug of the project")), responses( - (status = 204, description = "Expected response to a valid request"), + (status = NO_CONTENT, description = "Expected response to a valid request"), (status = 400, description = "Request was invalid, see given error"), ( status = 401, @@ -1329,3 +1370,65 @@ pub async fn project_unfollow( .await .or_else(v2_reroute::flatten_404_error) } + +#[derive(utoipa::OpenApi)] +#[openapi(paths( + project_search, + random_projects_get, + projects_get, + project_get, + project_get_check, + dependency_list, + project_edit, + projects_edit, + project_icon_edit, + delete_project_icon, + add_gallery_item, + edit_gallery_item, + delete_gallery_item, + project_delete, + project_follow, + project_unfollow, +))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; + +#[derive(utoipa::OpenApi)] +#[openapi(paths( + project_search, + random_projects_get, + projects_get, + projects_edit, +))] +pub(crate) struct RootRoutesDoc; + +#[derive(utoipa::OpenApi)] +#[openapi(paths( + project_get, + project_get_check, + project_edit, + project_icon_edit, + delete_project_icon, + add_gallery_item, + edit_gallery_item, + delete_gallery_item, + project_delete, + project_follow, + project_unfollow, +))] +pub(crate) struct ProjectRoutesDoc; + +#[derive(utoipa::OpenApi)] +#[openapi(paths(dependency_list,))] +pub(crate) struct ProjectIdRoutesDoc; + +pub(crate) struct ApiDoc; + +impl utoipa::OpenApi for ApiDoc { + fn openapi() -> utoipa::openapi::OpenApi { + let openapi = RootRoutesDoc::openapi(); + openapi + .nest("/project", ProjectRoutesDoc::openapi()) + .nest("/project/{project_id}", ProjectIdRoutesDoc::openapi()) + } +} diff --git a/apps/labrinth/src/routes/v2/reports.rs b/apps/labrinth/src/routes/v2/reports.rs index 2193ec61b4..94ff852446 100644 --- a/apps/labrinth/src/routes/v2/reports.rs +++ b/apps/labrinth/src/routes/v2/reports.rs @@ -8,7 +8,7 @@ use actix_web::{HttpRequest, HttpResponse, delete, get, patch, post, web}; use serde::Deserialize; use validator::Validate; -pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(reports_get); cfg.service(reports); cfg.service(report_create); @@ -17,12 +17,13 @@ pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { cfg.service(report_get); } -/// Create a report for a project, version, or user. +/// Create a report for a project, version, or user. #[utoipa::path( + tag = "reports", post, operation_id = "submitReport", responses( - (status = 200, description = "Expected response to a valid request"), + (status = 200, description = "Expected response to a valid request", body = LegacyReport), (status = 400, description = "Request was invalid, see given error"), ( status = 401, @@ -69,8 +70,9 @@ fn default_all() -> bool { true } -/// Get open reports for the current user. +/// Get open reports for the current user. #[utoipa::path( + tag = "reports", get, operation_id = "getOpenReports", params( @@ -81,7 +83,7 @@ fn default_all() -> bool { ) ), responses( - (status = 200, description = "Expected response to a valid request"), + (status = 200, description = "Expected response to a valid request", body = Vec), ( status = 401, description = "Incorrect token scopes or no authorization to access the requested item(s)" @@ -131,8 +133,9 @@ pub struct ReportIds { pub ids: String, } -/// Get multiple reports by ID. +/// Get multiple reports by ID. #[utoipa::path( + tag = "reports", get, operation_id = "getReports", params( @@ -143,7 +146,7 @@ pub struct ReportIds { ) ), responses( - (status = 200, description = "Expected response to a valid request"), + (status = 200, description = "Expected response to a valid request", body = Vec), ( status = 401, description = "Incorrect token scopes or no authorization to access the requested item(s)" @@ -184,13 +187,14 @@ pub async fn reports_get( } } -/// Get a report by ID. +/// Get a report by ID. #[utoipa::path( + tag = "reports", get, operation_id = "getReport", params(("id" = crate::models::ids::ReportId, Path, description = "The ID of the report")), responses( - (status = 200, description = "Expected response to a valid request"), + (status = 200, description = "Expected response to a valid request", body = LegacyReport), ( status = 401, description = "Incorrect token scopes or no authorization to access the requested item(s)" @@ -232,8 +236,9 @@ pub struct EditReport { pub closed: Option, } -/// Modify a report. +/// Update a report. #[utoipa::path( + tag = "reports", patch, operation_id = "modifyReport", params(("id" = crate::models::ids::ReportId, Path, description = "The ID of the report")), @@ -278,8 +283,9 @@ pub async fn report_edit( .or_else(v2_reroute::flatten_404_error) } -/// Delete a report by ID. +/// Delete a report by ID. #[utoipa::path( + tag = "reports", delete, operation_id = "deleteReport", params(("id" = crate::models::ids::ReportId, Path, description = "The ID of the report")), @@ -309,3 +315,15 @@ pub async fn report_delete( .await .or_else(v2_reroute::flatten_404_error) } + +#[derive(utoipa::OpenApi)] +#[openapi(paths( + report_create, + reports, + reports_get, + report_get, + report_edit, + report_delete, +))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; diff --git a/apps/labrinth/src/routes/v2/statistics.rs b/apps/labrinth/src/routes/v2/statistics.rs index 89045851aa..e6536e6fc0 100644 --- a/apps/labrinth/src/routes/v2/statistics.rs +++ b/apps/labrinth/src/routes/v2/statistics.rs @@ -5,7 +5,7 @@ use crate::routes::{ }; use actix_web::{HttpResponse, get, web}; -pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(get_stats); } @@ -17,8 +17,9 @@ pub struct V2Stats { pub files: Option, } -/// Get aggregate instance statistics. +/// Get aggregate instance statistics. #[utoipa::path( + tag = "statistics", get, operation_id = "statistics", responses( @@ -50,3 +51,8 @@ pub async fn get_stats( Err(response) => Ok(response), } } + +#[derive(utoipa::OpenApi)] +#[openapi(paths(get_stats,))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; diff --git a/apps/labrinth/src/routes/v2/tags.rs b/apps/labrinth/src/routes/v2/tags.rs index ee81a80578..4f807fed1a 100644 --- a/apps/labrinth/src/routes/v2/tags.rs +++ b/apps/labrinth/src/routes/v2/tags.rs @@ -12,9 +12,9 @@ use actix_web::{HttpResponse, get, web}; use chrono::{DateTime, Utc}; use itertools::Itertools; -pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service( - utoipa_actix_web::scope("/tag") + web::scope("/tag") .service(category_list) .service(loader_list) .service(game_version_list) @@ -35,8 +35,9 @@ pub struct CategoryData { pub header: String, } -/// Get the list of project categories. +/// List project categories. #[utoipa::path( + tag = "tags", get, operation_id = "categoryList", responses( @@ -81,8 +82,9 @@ pub struct LoaderData { pub supported_project_types: Vec, } -/// Get the list of loaders. +/// List loaders. #[utoipa::path( + tag = "tags", get, operation_id = "loaderList", responses( @@ -155,8 +157,9 @@ pub struct GameVersionQuery { major: Option, } -/// Get the list of game versions. +/// List game versions. #[utoipa::path( + tag = "tags", get, operation_id = "versionList", params( @@ -239,8 +242,9 @@ pub struct License { pub name: String, } -/// Get SPDX license identifiers and names. +/// List SPDX license identifiers and names. #[utoipa::path( + tag = "tags", get, operation_id = "licenseList", responses( @@ -278,8 +282,9 @@ pub struct LicenseText { pub body: String, } -/// Get full license text by SPDX ID. +/// Get full license text by SPDX ID. #[utoipa::path( + tag = "tags", get, operation_id = "licenseText", params(("id" = String, Path, description = "The license ID to get the text for")), @@ -325,8 +330,9 @@ pub struct DonationPlatformQueryData { pub name: String, } -/// Get available donation platforms. +/// List donation platforms. #[utoipa::path( + tag = "tags", get, operation_id = "donationPlatformList", responses( @@ -383,8 +389,9 @@ pub async fn donation_platform_list( .or_else(v2_reroute::flatten_404_error) } -/// Get valid report types. +/// List valid report types. #[utoipa::path( + tag = "tags", get, operation_id = "reportTypeList", responses( @@ -406,8 +413,9 @@ pub async fn report_type_list( .or_else(v2_reroute::flatten_404_error) } -/// Get valid project types. +/// List valid project types. #[utoipa::path( + tag = "tags", get, operation_id = "projectTypeList", responses( @@ -429,8 +437,9 @@ pub async fn project_type_list( .or_else(v2_reroute::flatten_404_error) } -/// Get valid side-type values. +/// List valid side-type values. #[utoipa::path( + tag = "tags", get, operation_id = "sideTypeList", responses( @@ -454,3 +463,26 @@ pub async fn side_type_list() -> Result { let side_types = side_types.iter().map(|s| s.to_string()).collect_vec(); Ok(HttpResponse::Ok().json(side_types)) } + +#[derive(utoipa::OpenApi)] +#[openapi(paths( + category_list, + loader_list, + game_version_list, + license_list, + license_text, + donation_platform_list, + report_type_list, + project_type_list, + side_type_list, +))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; + +#[derive(utoipa::OpenApi)] +#[openapi( + nest( + (path = "/tag", api = RouteDoc), + ) +)] +pub(crate) struct ApiDoc; diff --git a/apps/labrinth/src/routes/v2/teams.rs b/apps/labrinth/src/routes/v2/teams.rs index 21bc0c0c36..fe442ff2e9 100644 --- a/apps/labrinth/src/routes/v2/teams.rs +++ b/apps/labrinth/src/routes/v2/teams.rs @@ -12,10 +12,10 @@ use ariadne::ids::UserId; use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; -pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(teams_get); cfg.service( - utoipa_actix_web::scope("/team") + web::scope("/team") .service(team_members_get) .service(edit_team_member) .service(transfer_ownership) @@ -30,13 +30,14 @@ pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { // also the members of the organization's team if the project is associated with an organization // (Unlike team_members_get_project, which only returns the members of the project's team) // They can be differentiated by the "organization_permissions" field being null or not -/// Get a project's team members. +/// Get a project's team members. #[utoipa::path( + tag = "teams", get, operation_id = "getProjectTeamMembers", params(("id" = String, Path, description = "The ID or slug of the project")), responses( - (status = 200, description = "Expected response to a valid request"), + (status = 200, description = "Expected response to a valid request", body = Vec), ( status = 404, description = "The requested item(s) were not found or no authorization to access the requested item(s)" @@ -74,12 +75,13 @@ pub async fn team_members_get_project( } // Returns all members of a team, but not necessarily those of a project-team's organization (unlike team_members_get_project) -/// Get a team's members. +/// Get a team's members. #[utoipa::path( + tag = "teams", get, operation_id = "getTeamMembers", params(("id" = TeamId, Path, description = "The ID of the team")), - responses((status = 200, description = "Expected response to a valid request")), + responses((status = 200, description = "Expected response to a valid request", body = Vec)), security(("bearer_auth" = ["PROJECT_READ"])) )] #[get("/{id}/members")] @@ -112,12 +114,13 @@ pub struct TeamIds { pub ids: String, } -/// Get the members of multiple teams. +/// Get the members of multiple teams. #[utoipa::path( + tag = "teams", get, operation_id = "getTeams", params(("ids" = String, Query, description = "The JSON array of team IDs")), - responses((status = 200, description = "Expected response to a valid request")) + responses((status = 200, description = "Expected response to a valid request", body = Vec>)) )] #[get("/teams")] pub async fn teams_get( @@ -154,8 +157,9 @@ pub async fn teams_get( } } -/// Join a team with a pending invite. +/// Join a team with a pending invite. #[utoipa::path( + tag = "teams", post, operation_id = "joinTeam", params(("id" = TeamId, Path, description = "The ID of the team")), @@ -210,8 +214,9 @@ pub struct NewTeamMember { pub ordering: i64, } -/// Add a member to a team. +/// Add a member to a team. #[utoipa::path( + tag = "teams", post, operation_id = "addTeamMember", params(("id" = TeamId, Path, description = "The ID of the team")), @@ -267,8 +272,9 @@ pub struct EditTeamMember { pub ordering: Option, } -/// Modify a team member. +/// Update a team member. #[utoipa::path( + tag = "teams", patch, operation_id = "modifyTeamMember", params( @@ -326,8 +332,9 @@ pub struct TransferOwnership { pub user_id: UserId, } -/// Transfer team ownership. +/// Transfer team ownership. #[utoipa::path( + tag = "teams", patch, operation_id = "transferTeamOwnership", params(("id" = TeamId, Path, description = "The ID of the team")), @@ -369,8 +376,9 @@ pub async fn transfer_ownership( .or_else(v2_reroute::flatten_404_error) } -/// Remove a member from a team. +/// Remove a member from a team. #[utoipa::path( + tag = "teams", delete, operation_id = "deleteTeamMember", params( @@ -407,3 +415,47 @@ pub async fn remove_team_member( .await .or_else(v2_reroute::flatten_404_error) } + +#[derive(utoipa::OpenApi)] +#[openapi(paths( + team_members_get_project, + team_members_get, + teams_get, + join_team, + add_team_member, + edit_team_member, + transfer_ownership, + remove_team_member, +))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; + +#[derive(utoipa::OpenApi)] +#[openapi(paths(teams_get,))] +pub(crate) struct RootRoutesDoc; + +#[derive(utoipa::OpenApi)] +#[openapi(paths(team_members_get_project,))] +pub(crate) struct ProjectRoutesDoc; + +#[derive(utoipa::OpenApi)] +#[openapi(paths( + team_members_get, + join_team, + add_team_member, + edit_team_member, + transfer_ownership, + remove_team_member, +))] +pub(crate) struct TeamRoutesDoc; + +pub(crate) struct ApiDoc; + +impl utoipa::OpenApi for ApiDoc { + fn openapi() -> utoipa::openapi::OpenApi { + let openapi = RootRoutesDoc::openapi(); + openapi + .nest("/project", ProjectRoutesDoc::openapi()) + .nest("/team", TeamRoutesDoc::openapi()) + } +} diff --git a/apps/labrinth/src/routes/v2/threads.rs b/apps/labrinth/src/routes/v2/threads.rs index e04a369cc5..cc77e470af 100644 --- a/apps/labrinth/src/routes/v2/threads.rs +++ b/apps/labrinth/src/routes/v2/threads.rs @@ -9,23 +9,24 @@ use crate::routes::{ApiError, v2_reroute, v3}; use actix_web::{HttpRequest, HttpResponse, delete, get, post, web}; use serde::Deserialize; -pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service( - utoipa_actix_web::scope("/thread") + web::scope("/thread") .service(thread_get) .service(thread_send_message), ); - cfg.service(utoipa_actix_web::scope("/message").service(message_delete)); + cfg.service(web::scope("/message").service(message_delete)); cfg.service(threads_get); } -/// Get a thread by ID. +/// Get a thread by ID. #[utoipa::path( + tag = "threads", get, operation_id = "getThread", params(("id" = ThreadId, Path, description = "The ID of the thread")), responses( - (status = 200, description = "Expected response to a valid request"), + (status = 200, description = "Expected response to a valid request", body = Thread), ( status = 404, description = "The requested item(s) were not found or no authorization to access the requested item(s)" @@ -51,13 +52,14 @@ pub struct ThreadIds { pub ids: String, } -/// Get multiple threads by ID. +/// Get multiple threads by ID. #[utoipa::path( + tag = "threads", get, operation_id = "getThreads", params(("ids" = String, Query, description = "The JSON array of thread IDs")), responses( - (status = 200, description = "Expected response to a valid request"), + (status = 200, description = "Expected response to a valid request", body = Vec), ( status = 404, description = "The requested item(s) were not found or no authorization to access the requested item(s)" @@ -101,8 +103,9 @@ pub struct NewThreadMessage { pub body: MessageBody, } -/// Send a message to a thread. +/// Send a message to a thread. #[utoipa::path( + tag = "threads", post, operation_id = "sendThreadMessage", params(("id" = ThreadId, Path, description = "The ID of the thread")), @@ -142,8 +145,9 @@ pub async fn thread_send_message( .or_else(v2_reroute::flatten_404_error) } -/// Delete a thread message by ID. +/// Delete a thread message by ID. #[utoipa::path( + tag = "threads", delete, operation_id = "deleteThreadMessage", params(("id" = ThreadMessageId, Path, description = "The ID of the message")), @@ -181,3 +185,31 @@ pub async fn message_delete( .await .or_else(v2_reroute::flatten_404_error) } + +#[derive(utoipa::OpenApi)] +#[openapi(paths(thread_get, threads_get, thread_send_message, message_delete,))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; + +#[derive(utoipa::OpenApi)] +#[openapi(paths(threads_get,))] +pub(crate) struct RootRoutesDoc; + +#[derive(utoipa::OpenApi)] +#[openapi(paths(thread_get, thread_send_message,))] +pub(crate) struct ThreadRoutesDoc; + +#[derive(utoipa::OpenApi)] +#[openapi(paths(message_delete,))] +pub(crate) struct MessageRoutesDoc; + +pub(crate) struct ApiDoc; + +impl utoipa::OpenApi for ApiDoc { + fn openapi() -> utoipa::openapi::OpenApi { + let openapi = RootRoutesDoc::openapi(); + openapi + .nest("/thread", ThreadRoutesDoc::openapi()) + .nest("/message", MessageRoutesDoc::openapi()) + } +} diff --git a/apps/labrinth/src/routes/v2/users.rs b/apps/labrinth/src/routes/v2/users.rs index 21e80adfbe..b57430c8a0 100644 --- a/apps/labrinth/src/routes/v2/users.rs +++ b/apps/labrinth/src/routes/v2/users.rs @@ -13,11 +13,11 @@ use actix_web::{HttpRequest, HttpResponse, delete, get, patch, web}; use serde::{Deserialize, Serialize}; use validator::Validate; -pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(user_auth_get); cfg.service(users_get); cfg.service( - utoipa_actix_web::scope("/user") + web::scope("/user") .service(user_get) .service(projects_list) .service(user_delete) @@ -29,12 +29,13 @@ pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { ); } -/// Get the current user from the authorization header. +/// Get the current user. #[utoipa::path( + tag = "users", get, operation_id = "getUserFromAuth", responses( - (status = 200, description = "Expected response to a valid request"), + (status = 200, description = "Expected response to a valid request", body = LegacyUser), ( status = 401, description = "Incorrect token scopes or no authorization to access the requested item(s)" @@ -68,12 +69,13 @@ pub struct UserIds { pub ids: String, } -/// Get multiple users by ID. +/// Get multiple users by ID. #[utoipa::path( + tag = "users", get, operation_id = "getUsers", params(("ids" = String, Query, description = "The JSON array of user IDs")), - responses((status = 200, description = "Expected response to a valid request")) + responses((status = 200, description = "Expected response to a valid request", body = Vec)) )] #[get("/users")] pub async fn users_get( @@ -104,13 +106,14 @@ pub async fn users_get( } } -/// Get a user by ID or username. +/// Get a user by ID or username. #[utoipa::path( + tag = "users", get, operation_id = "getUser", params(("id" = String, Path, description = "The ID or username of the user")), responses( - (status = 200, description = "Expected response to a valid request"), + (status = 200, description = "Expected response to a valid request", body = LegacyUser), ( status = 404, description = "The requested item(s) were not found or no authorization to access the requested item(s)" @@ -139,13 +142,14 @@ pub async fn user_get( } } -/// Get a user's projects. +/// Get a user's projects. #[utoipa::path( + tag = "users", get, operation_id = "getUserProjects", params(("user_id" = String, Path, description = "The ID or username of the user")), responses( - (status = 200, description = "Expected response to a valid request"), + (status = 200, description = "Expected response to a valid request", body = Vec), ( status = 404, description = "The requested item(s) were not found or no authorization to access the requested item(s)" @@ -204,8 +208,9 @@ pub struct EditUser { pub allow_friend_requests: Option, } -/// Modify a user. +/// Update a user. #[utoipa::path( + tag = "users", patch, operation_id = "modifyUser", params(("id" = String, Path, description = "The ID or username of the user")), @@ -258,8 +263,9 @@ pub struct Extension { pub ext: String, } -/// Change a user's avatar. +/// Change a user's avatar. #[utoipa::path( + tag = "users", patch, operation_id = "changeUserIcon", params( @@ -317,8 +323,9 @@ pub async fn user_icon_edit( .or_else(v2_reroute::flatten_404_error) } -/// Remove a user's avatar. +/// Remove a user's avatar. #[utoipa::path( + tag = "users", delete, operation_id = "deleteUserIcon", params(("id" = String, Path, description = "The ID or username of the user")), @@ -354,8 +361,9 @@ pub async fn user_icon_delete( .or_else(v2_reroute::flatten_404_error) } -/// Delete a user by ID or username. +/// Delete a user by ID or username. #[utoipa::path( + tag = "users", delete, operation_id = "deleteUser", params(("id" = String, Path, description = "The ID or username of the user")), @@ -387,13 +395,14 @@ pub async fn user_delete( .or_else(v2_reroute::flatten_404_error) } -/// Get projects followed by a user. +/// Get projects followed by a user. #[utoipa::path( + tag = "users", get, operation_id = "getFollowedProjects", params(("id" = String, Path, description = "The ID or username of the user")), responses( - (status = 200, description = "Expected response to a valid request"), + (status = 200, description = "Expected response to a valid request", body = Vec), ( status = 401, description = "Incorrect token scopes or no authorization to access the requested item(s)" @@ -434,13 +443,14 @@ pub async fn user_follows( } } -/// Get notifications for a user. +/// Get notifications for a user. #[utoipa::path( + tag = "users", get, operation_id = "getUserNotifications", params(("id" = String, Path, description = "The ID or username of the user")), responses( - (status = 200, description = "Expected response to a valid request"), + (status = 200, description = "Expected response to a valid request", body = Vec), ( status = 401, description = "Incorrect token scopes or no authorization to access the requested item(s)" @@ -476,3 +486,46 @@ pub async fn user_notifications( Err(response) => Ok(response), } } + +#[derive(utoipa::OpenApi)] +#[openapi(paths( + user_auth_get, + users_get, + user_get, + projects_list, + user_edit, + user_icon_edit, + user_icon_delete, + user_delete, + user_follows, + user_notifications, +))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; + +#[derive(utoipa::OpenApi)] +#[openapi(paths(users_get,))] +pub(crate) struct RootRoutesDoc; + +#[derive(utoipa::OpenApi)] +#[openapi(paths( + user_auth_get, + user_get, + projects_list, + user_edit, + user_icon_edit, + user_icon_delete, + user_delete, + user_follows, + user_notifications, +))] +pub(crate) struct UserRoutesDoc; + +pub(crate) struct ApiDoc; + +impl utoipa::OpenApi for ApiDoc { + fn openapi() -> utoipa::openapi::OpenApi { + let openapi = RootRoutesDoc::openapi(); + openapi.nest("/user", UserRoutesDoc::openapi()) + } +} diff --git a/apps/labrinth/src/routes/v2/version_creation.rs b/apps/labrinth/src/routes/v2/version_creation.rs index 72da4004e0..9dacc4e2a9 100644 --- a/apps/labrinth/src/routes/v2/version_creation.rs +++ b/apps/labrinth/src/routes/v2/version_creation.rs @@ -76,8 +76,9 @@ pub struct InitialVersionData { } // under `/api/v1/version` -/// Create a version on an existing project. +/// Create a version on an existing project. #[utoipa::path( + tag = "version creation", post, operation_id = "createVersion", request_body( @@ -85,7 +86,7 @@ pub struct InitialVersionData { description = "Multipart payload containing `data` and uploaded files" ), responses( - (status = 200, description = "Expected response to a valid request"), + (status = 200, description = "Expected response to a valid request", body = LegacyVersion), (status = 400, description = "Request was invalid, see given error"), ( status = 401, @@ -305,8 +306,9 @@ async fn get_example_version_fields( } // under /api/v1/version/{version_id} -/// Add files to an existing version. +/// Add files to an existing version. #[utoipa::path( + tag = "version creation", post, operation_id = "addFilesToVersion", params(("version_id" = VersionId, Path, description = "The ID of the version")), @@ -315,7 +317,7 @@ async fn get_example_version_fields( description = "Multipart payload containing files to upload" ), responses( - (status = 204, description = "Expected response to a valid request"), + (status = NO_CONTENT, description = "Expected response to a valid request"), ( status = 401, description = "Incorrect token scopes or no authorization to access the requested item(s)" @@ -354,3 +356,16 @@ pub async fn upload_file_to_version( .await?; Ok(response) } + +#[derive(utoipa::OpenApi)] +#[openapi(paths(version_create, upload_file_to_version,))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; + +#[derive(utoipa::OpenApi)] +#[openapi(paths(version_create,))] +pub(crate) struct RootRoutesDoc; + +#[derive(utoipa::OpenApi)] +#[openapi(paths(upload_file_to_version,))] +pub(crate) struct VersionRoutesDoc; diff --git a/apps/labrinth/src/routes/v2/version_file.rs b/apps/labrinth/src/routes/v2/version_file.rs index ff8aa5edf3..bc9e4b52d3 100644 --- a/apps/labrinth/src/routes/v2/version_file.rs +++ b/apps/labrinth/src/routes/v2/version_file.rs @@ -5,15 +5,15 @@ use crate::database::redis::RedisPool; use crate::models::projects::{Project, Version, VersionType}; use crate::models::v2::projects::{LegacyProject, LegacyVersion}; use crate::queue::session::AuthQueue; -use crate::routes::v3::version_file::HashQuery; +use crate::routes::v3::version_file::{DownloadRedirect, HashQuery}; use crate::routes::{v2_reroute, v3}; use actix_web::{HttpRequest, HttpResponse, delete, get, post, web}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; -pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service( - utoipa_actix_web::scope("/version_file") + web::scope("/version_file") .service(delete_file) .service(get_version_from_hash) .service(download_version) @@ -22,7 +22,7 @@ pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { ); cfg.service( - utoipa_actix_web::scope("/version_files") + web::scope("/version_files") .service(get_versions_from_hashes) .service(update_files) .service(update_files_many) @@ -31,8 +31,9 @@ pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { } // under /api/v1/version_file/{hash} -/// Get version metadata by file hash. +/// Get version metadata by file hash. #[utoipa::path( + tag = "version file", get, operation_id = "versionFromHash", params( @@ -53,7 +54,7 @@ pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { ) ), responses( - (status = 200, description = "Expected response to a valid request"), + (status = 200, description = "Expected response to a valid request", body = LegacyVersion), ( status = 404, description = "The requested item(s) were not found or no authorization to access the requested item(s)" @@ -91,8 +92,9 @@ pub async fn get_version_from_hash( } // under /api/v1/version_file/{hash}/download -/// Download a file by hash. +/// Download a file by hash. #[utoipa::path( + tag = "version file", get, operation_id = "downloadVersionFromHash", params( @@ -113,7 +115,7 @@ pub async fn get_version_from_hash( ) ), responses( - (status = 302, description = "Temporary redirect to file URL"), + (status = 302, description = "Temporary redirect to file URL", body = DownloadRedirect), ( status = 404, description = "The requested item(s) were not found or no authorization to access the requested item(s)" @@ -143,8 +145,9 @@ pub async fn download_version( } // under /api/v1/version_file/{hash} -/// Delete a file by hash. +/// Delete a file by hash. #[utoipa::path( + tag = "version file", delete, operation_id = "deleteFileFromHash", params( @@ -165,7 +168,7 @@ pub async fn download_version( ) ), responses( - (status = 204, description = "Expected response to a valid request"), + (status = NO_CONTENT, description = "Expected response to a valid request"), ( status = 401, description = "Incorrect token scopes or no authorization to access the requested item(s)" @@ -206,8 +209,9 @@ pub struct UpdateData { pub version_types: Option>, } -/// Get the latest compatible version from a file hash. +/// Get the latest compatible version from a file hash. #[utoipa::path( + tag = "version file", post, operation_id = "getLatestVersionFromHash", params( @@ -229,7 +233,7 @@ pub struct UpdateData { ), request_body = UpdateData, responses( - (status = 200, description = "Expected response to a valid request"), + (status = 200, description = "Expected response to a valid request", body = LegacyVersion), (status = 400, description = "Request was invalid, see given error"), ( status = 404, @@ -292,13 +296,14 @@ pub struct FileHashes { } // under /api/v2/version_files -/// Get versions from file hashes. +/// Get versions from file hashes. #[utoipa::path( + tag = "version file", post, operation_id = "versionsFromHashes", request_body = FileHashes, responses( - (status = 200, description = "Expected response to a valid request"), + (status = 200, description = "Expected response to a valid request", body = HashMap), (status = 400, description = "Request was invalid, see given error") ) )] @@ -343,13 +348,14 @@ pub async fn get_versions_from_hashes( } } -/// Get projects from file hashes. +/// Get projects from file hashes. #[utoipa::path( + tag = "version file", post, operation_id = "projectsFromHashes", request_body = FileHashes, responses( - (status = 200, description = "Expected response to a valid request"), + (status = 200, description = "Expected response to a valid request", body = HashMap), (status = 400, description = "Request was invalid, see given error") ) )] @@ -420,13 +426,14 @@ pub struct ManyUpdateData { pub version_types: Option>, } -/// Get latest compatible versions for multiple hashes. +/// Get latest compatible versions for multiple hashes. #[utoipa::path( + tag = "version file", post, operation_id = "getLatestVersionsFromHashes", request_body = ManyUpdateData, responses( - (status = 200, description = "Expected response to a valid request"), + (status = 200, description = "Expected response to a valid request", body = HashMap), (status = 400, description = "Request was invalid, see given error") ) )] @@ -469,13 +476,14 @@ pub async fn update_files( Ok(HttpResponse::Ok().json(v3_versions)) } -/// Get all latest compatible versions for multiple hashes. +/// Get all latest compatible versions for multiple hashes. #[utoipa::path( + tag = "version file", post, operation_id = "getLatestVersionsFromHashesMany", request_body = ManyUpdateData, responses( - (status = 200, description = "Expected response to a valid request"), + (status = 200, description = "Expected response to a valid request", body = HashMap>), (status = 400, description = "Request was invalid, see given error") ) )] @@ -535,13 +543,14 @@ pub struct ManyFileUpdateData { pub hashes: Vec, } -/// Get latest versions with per-hash filters. +/// Get latest versions with per-hash filters. #[utoipa::path( + tag = "version file", post, operation_id = "getLatestVersionsFromHashesIndividual", request_body = ManyFileUpdateData, responses( - (status = 200, description = "Expected response to a valid request"), + (status = 200, description = "Expected response to a valid request", body = HashMap), (status = 400, description = "Request was invalid, see given error") ) )] @@ -606,3 +615,46 @@ pub async fn update_individual_files( Err(response) => Ok(response), } } + +#[derive(utoipa::OpenApi)] +#[openapi(paths( + get_version_from_hash, + download_version, + delete_file, + get_update_from_hash, + get_versions_from_hashes, + get_projects_from_hashes, + update_files, + update_files_many, + update_individual_files, +))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; + +#[derive(utoipa::OpenApi)] +#[openapi(paths( + get_version_from_hash, + download_version, + delete_file, + get_update_from_hash, + get_projects_from_hashes, +))] +pub(crate) struct VersionFileRoutesDoc; + +#[derive(utoipa::OpenApi)] +#[openapi(paths( + get_versions_from_hashes, + update_files, + update_files_many, + update_individual_files, +))] +pub(crate) struct VersionFilesRoutesDoc; + +#[derive(utoipa::OpenApi)] +#[openapi( + nest( + (path = "/version_file", api = VersionFileRoutesDoc), + (path = "/version_files", api = VersionFilesRoutesDoc), + ) +)] +pub(crate) struct ApiDoc; diff --git a/apps/labrinth/src/routes/v2/versions.rs b/apps/labrinth/src/routes/v2/versions.rs index d444bf8ed2..41e91a825a 100644 --- a/apps/labrinth/src/routes/v2/versions.rs +++ b/apps/labrinth/src/routes/v2/versions.rs @@ -16,11 +16,11 @@ use actix_web::{HttpRequest, HttpResponse, delete, get, patch, web}; use serde::{Deserialize, Serialize}; use validator::Validate; -pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(versions_get); cfg.service(super::version_creation::version_create); cfg.service( - utoipa_actix_web::scope("/version") + web::scope("/version") .service(version_get) .service(version_delete) .service(version_edit) @@ -44,8 +44,9 @@ fn default_true() -> bool { true } -/// List versions for a project. +/// List versions for a project. #[utoipa::path( + tag = "versions", get, operation_id = "getProjectVersions", params( @@ -69,6 +70,21 @@ fn default_true() -> bool { Query, description = "Filter by featured status" ), + ( + "version_type" = Option, + Query, + description = "Filter by version type" + ), + ( + "limit" = Option, + Query, + description = "Maximum number of versions" + ), + ( + "offset" = Option, + Query, + description = "Version offset" + ), ( "include_changelog" = Option, Query, @@ -76,7 +92,7 @@ fn default_true() -> bool { ) ), responses( - (status = 200, description = "Expected response to a valid request"), + (status = 200, description = "Expected response to a valid request", body = Vec), ( status = 404, description = "The requested item(s) were not found or no authorization to access the requested item(s)" @@ -169,8 +185,9 @@ pub async fn version_list( } // Given a project ID/slug and a version slug -/// Get a project version by ID or version number. +/// Get a project version by ID or version number. #[utoipa::path( + tag = "versions", get, operation_id = "getVersionFromIdOrNumber", params( @@ -186,7 +203,7 @@ pub async fn version_list( ) ), responses( - (status = 200, description = "Expected response to a valid request"), + (status = 200, description = "Expected response to a valid request", body = LegacyVersion), ( status = 404, description = "The requested item(s) were not found or no authorization to access the requested item(s)" @@ -230,12 +247,20 @@ pub struct VersionIds { pub include_changelog: bool, } -/// Get multiple versions by ID. +/// Get multiple versions by ID. #[utoipa::path( + tag = "versions", get, operation_id = "getVersions", - params(("ids" = String, Query, description = "The JSON array of version IDs")), - responses((status = 200, description = "Expected response to a valid request")) + params( + ("ids" = String, Query, description = "The JSON array of version IDs"), + ( + "include_changelog" = Option, + Query, + description = "Whether to include changelog fields" + ) + ), + responses((status = 200, description = "Expected response to a valid request", body = Vec)) )] #[get("/versions")] pub async fn versions_get( @@ -274,13 +299,14 @@ pub async fn versions_get( } } -/// Get a version by ID. +/// Get a version by ID. #[utoipa::path( + tag = "versions", get, operation_id = "getVersion", params(("version_id" = models::ids::VersionId, Path, description = "The ID of the version")), responses( - (status = 200, description = "Expected response to a valid request"), + (status = 200, description = "Expected response to a valid request", body = LegacyVersion), ( status = 404, description = "The requested item(s) were not found or no authorization to access the requested item(s)" @@ -353,14 +379,15 @@ pub struct EditVersionFileType { pub file_type: Option, } -/// Modify an existing version. +/// Update an existing version. #[utoipa::path( + tag = "versions", patch, operation_id = "modifyVersion", params(("id" = VersionId, Path, description = "The ID of the version")), request_body = EditVersion, responses( - (status = 204, description = "Expected response to a valid request"), + (status = NO_CONTENT, description = "Expected response to a valid request"), ( status = 401, description = "Incorrect token scopes or no authorization to access the requested item(s)" @@ -468,13 +495,14 @@ pub async fn version_edit( Ok(response) } -/// Delete a version by ID. +/// Delete a version by ID. #[utoipa::path( + tag = "versions", delete, operation_id = "deleteVersion", params(("version_id" = VersionId, Path, description = "The ID of the version")), responses( - (status = 204, description = "Expected response to a valid request"), + (status = NO_CONTENT, description = "Expected response to a valid request"), ( status = 401, description = "Incorrect token scopes or no authorization to access the requested item(s)" @@ -509,3 +537,43 @@ pub async fn version_delete( .await .or_else(v2_reroute::flatten_404_error) } + +#[derive(utoipa::OpenApi)] +#[openapi(paths( + version_list, + version_project_get, + versions_get, + version_get, + version_edit, + version_delete, +))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; + +#[derive(utoipa::OpenApi)] +#[openapi(paths(versions_get,))] +pub(crate) struct RootRoutesDoc; + +#[derive(utoipa::OpenApi)] +#[openapi(paths(version_get, version_edit, version_delete,))] +pub(crate) struct VersionRoutesDoc; + +#[derive(utoipa::OpenApi)] +#[openapi(paths(version_list, version_project_get,))] +pub(crate) struct ProjectRoutesDoc; + +pub(crate) struct ApiDoc; + +impl utoipa::OpenApi for ApiDoc { + fn openapi() -> utoipa::openapi::OpenApi { + let mut openapi = RootRoutesDoc::openapi(); + openapi.merge(super::version_creation::RootRoutesDoc::openapi()); + openapi + .nest("/version", VersionRoutesDoc::openapi()) + .nest("/project/{project_id}", ProjectRoutesDoc::openapi()) + .nest( + "/version", + super::version_creation::VersionRoutesDoc::openapi(), + ) + } +} diff --git a/apps/labrinth/src/routes/v3/analytics_event.rs b/apps/labrinth/src/routes/v3/analytics_event.rs index 9f2f4998b3..94939bed1f 100644 --- a/apps/labrinth/src/routes/v3/analytics_event.rs +++ b/apps/labrinth/src/routes/v3/analytics_event.rs @@ -22,7 +22,7 @@ use crate::{ util::error::Context, }; -pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(analytics_events_get) .service(analytics_event_create) .service(analytics_event_edit) @@ -37,8 +37,8 @@ pub struct AnalyticsEventUpsert { pub ends: DateTime, } -/// Fetches all analytics events. -#[utoipa::path(responses((status = OK, body = Vec)))] +/// List analytics events. +#[utoipa::path(tag = "v3 analytics", responses((status = OK, body = Vec)))] #[get("")] pub async fn analytics_events_get( pool: web::Data, @@ -54,8 +54,8 @@ pub async fn analytics_events_get( Ok(web::Json(events)) } -/// Creates an analytics event. -#[utoipa::path(responses((status = OK, body = AnalyticsEvent)))] +/// Create an analytics event. +#[utoipa::path(tag = "v3 analytics", responses((status = OK, body = AnalyticsEvent)))] #[post("")] pub async fn analytics_event_create( req: HttpRequest, @@ -110,8 +110,8 @@ pub async fn analytics_event_create( Ok(web::Json(event.into())) } -/// Edits an analytics event. -#[utoipa::path(responses((status = OK, body = AnalyticsEvent)))] +/// Update an analytics event. +#[utoipa::path(tag = "v3 analytics", responses((status = OK, body = AnalyticsEvent)))] #[patch("/{id}")] pub async fn analytics_event_edit( req: HttpRequest, @@ -158,8 +158,8 @@ pub async fn analytics_event_edit( Ok(web::Json(event.into())) } -/// Deletes an analytics event. -#[utoipa::path(responses((status = NO_CONTENT)))] +/// Delete an analytics event. +#[utoipa::path(tag = "v3 analytics", responses((status = NO_CONTENT)))] #[delete("/{id}")] pub async fn analytics_event_delete( req: HttpRequest, @@ -199,3 +199,13 @@ pub async fn analytics_event_delete( Ok(()) } + +#[derive(utoipa::OpenApi)] +#[openapi(paths( + analytics_events_get, + analytics_event_create, + analytics_event_edit, + analytics_event_delete, +))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; diff --git a/apps/labrinth/src/routes/v3/analytics_get/facets/mod.rs b/apps/labrinth/src/routes/v3/analytics_get/facets/mod.rs index 145384e897..598c2d9336 100644 --- a/apps/labrinth/src/routes/v3/analytics_get/facets/mod.rs +++ b/apps/labrinth/src/routes/v3/analytics_get/facets/mod.rs @@ -14,7 +14,7 @@ use crate::{ routes::ApiError, }; -pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(fetch_facets); } @@ -58,7 +58,9 @@ pub struct ProjectPlaytimeFacets { pub country: Vec, } +/// Get analytics facets. #[utoipa::path( + tag = "analytics", responses((status = OK, body = inline(FacetsResponse))), )] #[post("/facets")] @@ -82,3 +84,8 @@ pub async fn fetch_facets( Ok(web::Json(FacetsResponse { facets })) } + +#[derive(utoipa::OpenApi)] +#[openapi(paths(fetch_facets,))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; diff --git a/apps/labrinth/src/routes/v3/analytics_get/mod.rs b/apps/labrinth/src/routes/v3/analytics_get/mod.rs index 0df8fedf8d..91056044ab 100644 --- a/apps/labrinth/src/routes/v3/analytics_get/mod.rs +++ b/apps/labrinth/src/routes/v3/analytics_get/mod.rs @@ -54,7 +54,7 @@ use crate::{ pub(crate) use metrics::normalize_download_source; pub use metrics::*; -pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(fetch_analytics); cfg.configure(facets::config); cfg.configure(old::config); @@ -172,8 +172,9 @@ pub enum ProjectAnalyticsEventKind { // logic -/// Fetches analytics data for the authorized user's projects. +/// Fetch analytics data. #[utoipa::path( + tag = "analytics", responses((status = OK, body = inline(GetResponse))), )] #[post("")] @@ -1071,3 +1072,19 @@ mod tests { assert_eq!(serde_json::to_value(src).unwrap(), target); } } + +#[derive(utoipa::OpenApi)] +#[openapi(paths(fetch_analytics,))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; + +pub(crate) struct ApiDoc; + +impl utoipa::OpenApi for ApiDoc { + fn openapi() -> utoipa::openapi::OpenApi { + let mut openapi = RouteDoc::openapi(); + openapi.merge(facets::RouteDoc::openapi()); + openapi.merge(old::RouteDoc::openapi()); + openapi + } +} diff --git a/apps/labrinth/src/routes/v3/analytics_get/old.rs b/apps/labrinth/src/routes/v3/analytics_get/old.rs index 7a6c19b7b9..b7927b1213 100644 --- a/apps/labrinth/src/routes/v3/analytics_get/old.rs +++ b/apps/labrinth/src/routes/v3/analytics_get/old.rs @@ -21,7 +21,7 @@ use std::collections::HashMap; use std::convert::TryInto; use std::num::NonZeroU32; -pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(playtimes_get) .service(views_get) .service(downloads_get) @@ -48,7 +48,17 @@ pub struct GetData { pub resolution_minutes: Option, // defaults to 1 day. Ignored in routes that do not aggregate over a resolution (eg: /countries) } -#[utoipa::path] +/// Get playtime data. +#[utoipa::path( + tag = "analytics", + params( + ("project_ids" = Option, Query), + ("start_date" = Option, Query), + ("end_date" = Option, Query), + ("resolution_minutes" = Option, Query) + ), + responses((status = OK, body = HashMap>)), +)] #[get("/playtime")] pub async fn playtimes_get( req: HttpRequest, @@ -110,7 +120,7 @@ pub async fn playtimes_get( Ok(HttpResponse::Ok().json(hm)) } -/// Get view data for a set of projects or versions. +/// Get view data. /// /// Data is returned as a hashmap of project/version ids to a hashmap of days to views /// eg: @@ -120,7 +130,16 @@ pub async fn playtimes_get( /// } ///} /// Either a list of project_ids or version_ids can be used, but not both. Unauthorized projects/versions will be filtered out. -#[utoipa::path] +#[utoipa::path( + tag = "analytics", + params( + ("project_ids" = Option, Query), + ("start_date" = Option, Query), + ("end_date" = Option, Query), + ("resolution_minutes" = Option, Query) + ), + responses((status = OK, body = HashMap>)), +)] #[get("/views")] pub async fn views_get( req: HttpRequest, @@ -182,7 +201,7 @@ pub async fn views_get( Ok(HttpResponse::Ok().json(hm)) } -/// Get download data for a set of projects or versions. +/// Get download data. /// /// Data is returned as a hashmap of project/version ids to a hashmap of days to downloads /// eg: @@ -192,7 +211,16 @@ pub async fn views_get( /// } ///} /// Either a list of project_ids or version_ids can be used, but not both. Unauthorized projects/versions will be filtered out. -#[utoipa::path] +#[utoipa::path( + tag = "analytics", + params( + ("project_ids" = Option, Query), + ("start_date" = Option, Query), + ("end_date" = Option, Query), + ("resolution_minutes" = Option, Query) + ), + responses((status = OK, body = HashMap>)), +)] #[get("/downloads")] pub async fn downloads_get( req: HttpRequest, @@ -255,7 +283,7 @@ pub async fn downloads_get( Ok(HttpResponse::Ok().json(hm)) } -/// Get payout data for a set of projects. +/// Get payout data. /// /// Data is returned as a hashmap of project ids to a hashmap of days to amount earned per day /// eg: @@ -265,7 +293,16 @@ pub async fn downloads_get( /// } ///} /// ONLY project IDs can be used. Unauthorized projects will be filtered out. -#[utoipa::path] +#[utoipa::path( + tag = "analytics", + params( + ("project_ids" = Option, Query), + ("start_date" = Option, Query), + ("end_date" = Option, Query), + ("resolution_minutes" = Option, Query) + ), + responses((status = OK, body = HashMap>)), +)] #[get("/revenue")] pub async fn revenue_get( req: HttpRequest, @@ -394,7 +431,7 @@ pub async fn revenue_get( Ok(HttpResponse::Ok().json(hm)) } -/// Get country data for a set of projects or versions. +/// Get download country data. /// /// Data is returned as a hashmap of project/version ids to a hashmap of coutnry to downloads. /// Unknown countries are labeled "". @@ -407,7 +444,16 @@ pub async fn revenue_get( ///} /// Either a list of project_ids or version_ids can be used, but not both. Unauthorized projects/versions will be filtered out. /// For this endpoint, provided dates are a range to aggregate over, not specific days to fetch -#[utoipa::path] +#[utoipa::path( + tag = "analytics", + params( + ("project_ids" = Option, Query), + ("start_date" = Option, Query), + ("end_date" = Option, Query), + ("resolution_minutes" = Option, Query) + ), + responses((status = OK, body = HashMap>)), +)] #[get("/countries/downloads")] pub async fn countries_downloads_get( req: HttpRequest, @@ -470,7 +516,7 @@ pub async fn countries_downloads_get( Ok(HttpResponse::Ok().json(hm)) } -/// Get country data for a set of projects or versions. +/// Get view country data. /// /// Data is returned as a hashmap of project/version ids to a hashmap of coutnry to views. /// Unknown countries are labeled "". @@ -483,7 +529,16 @@ pub async fn countries_downloads_get( ///} /// Either a list of project_ids or version_ids can be used, but not both. Unauthorized projects/versions will be filtered out. /// For this endpoint, provided dates are a range to aggregate over, not specific days to fetch -#[utoipa::path] +#[utoipa::path( + tag = "analytics", + params( + ("project_ids" = Option, Query), + ("start_date" = Option, Query), + ("end_date" = Option, Query), + ("resolution_minutes" = Option, Query) + ), + responses((status = OK, body = HashMap>)), +)] #[get("/countries/views")] pub async fn countries_views_get( req: HttpRequest, @@ -666,3 +721,15 @@ async fn filter_allowed_ids( // Only one of project_ids or version_ids will be Some Ok(project_ids) } + +#[derive(utoipa::OpenApi)] +#[openapi(paths( + playtimes_get, + views_get, + downloads_get, + revenue_get, + countries_downloads_get, + countries_views_get, +))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; diff --git a/apps/labrinth/src/routes/v3/collections.rs b/apps/labrinth/src/routes/v3/collections.rs index 605f857065..a38e18bb2e 100644 --- a/apps/labrinth/src/routes/v3/collections.rs +++ b/apps/labrinth/src/routes/v3/collections.rs @@ -19,7 +19,7 @@ use crate::util::routes::read_limited_from_payload; use crate::util::validate::validation_errors_to_string; use crate::{database, models}; use actix_web::web::Data; -use actix_web::{HttpRequest, HttpResponse, web}; +use actix_web::{HttpRequest, HttpResponse, delete, get, patch, post, web}; use ariadne::ids::base62_impl::parse_base62; use chrono::Utc; use eyre::eyre; @@ -27,21 +27,17 @@ use itertools::Itertools; use serde::{Deserialize, Serialize}; use validator::Validate; -pub fn config(cfg: &mut web::ServiceConfig) { - cfg.route("collections", web::get().to(collections_get)); - cfg.route("collection", web::post().to(collection_create)); - - cfg.service( - web::scope("collection") - .route("{id}", web::get().to(collection_get)) - .route("{id}", web::delete().to(collection_delete)) - .route("{id}", web::patch().to(collection_edit)) - .route("{id}/icon", web::patch().to(collection_icon_edit)) - .route("{id}/icon", web::delete().to(delete_collection_icon)), - ); +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { + cfg.service(collections_get) + .service(collection_create) + .service(collection_get) + .service(collection_delete) + .service(collection_edit) + .service(collection_icon_edit) + .service(delete_collection_icon); } -#[derive(Serialize, Deserialize, Validate, Clone)] +#[derive(Serialize, Deserialize, Validate, Clone, utoipa::ToSchema)] pub struct CollectionCreateData { #[validate( length(min = 3, max = 64), @@ -58,6 +54,8 @@ pub struct CollectionCreateData { pub projects: Vec, } +#[utoipa::path(tag = "collections", responses((status = OK)))] +#[post("/collection")] pub async fn collection_create( req: HttpRequest, collection_create_data: web::Json, @@ -142,6 +140,8 @@ pub async fn collection_create( pub struct CollectionIds { pub ids: String, } +#[utoipa::path(tag = "collections", responses((status = OK)))] +#[get("/collections")] pub async fn collections_get( req: HttpRequest, web::Query(ids): web::Query, @@ -178,6 +178,8 @@ pub async fn collections_get( Ok(HttpResponse::Ok().json(collections)) } +#[utoipa::path(tag = "collections", responses((status = OK)))] +#[get("/collection/{id}")] pub async fn collection_get( req: HttpRequest, info: web::Path<(String,)>, @@ -209,7 +211,7 @@ pub async fn collection_get( Err(ApiError::NotFound) } -#[derive(Deserialize, Validate)] +#[derive(Deserialize, Validate, utoipa::ToSchema)] pub struct EditCollection { #[validate( length(min = 3, max = 64), @@ -223,11 +225,14 @@ pub struct EditCollection { with = "::serde_with::rust::double_option" )] pub description: Option>, + #[schema(value_type = Option)] pub status: Option, #[validate(length(max = 1024))] pub new_projects: Option>, } +#[utoipa::path(tag = "collections", responses((status = NO_CONTENT)))] +#[patch("/collection/{id}")] pub async fn collection_edit( req: HttpRequest, info: web::Path<(String,)>, @@ -383,6 +388,8 @@ pub struct Extension { } #[allow(clippy::too_many_arguments)] +#[utoipa::path(tag = "collections", responses((status = NO_CONTENT)))] +#[patch("/collection/{id}/icon")] pub async fn collection_icon_edit( web::Query(ext): web::Query, req: HttpRequest, @@ -468,6 +475,8 @@ pub async fn collection_icon_edit( Ok(HttpResponse::NoContent().body("")) } +#[utoipa::path(tag = "collections", responses((status = NO_CONTENT)))] +#[delete("/collection/{id}/icon")] pub async fn delete_collection_icon( req: HttpRequest, info: web::Path<(String,)>, @@ -527,6 +536,8 @@ pub async fn delete_collection_icon( Ok(HttpResponse::NoContent().body("")) } +#[utoipa::path(tag = "collections", responses((status = NO_CONTENT)))] +#[delete("/collection/{id}")] pub async fn collection_delete( req: HttpRequest, info: web::Path<(String,)>, @@ -581,3 +592,16 @@ fn can_modify_collection( ) -> bool { collection.user_id == user.id.into() || user.role.is_mod() } + +#[derive(utoipa::OpenApi)] +#[openapi(paths( + collection_create, + collections_get, + collection_get, + collection_edit, + collection_icon_edit, + delete_collection_icon, + collection_delete, +))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; diff --git a/apps/labrinth/src/routes/v3/content/mod.rs b/apps/labrinth/src/routes/v3/content/mod.rs index 766495013b..85d626e691 100644 --- a/apps/labrinth/src/routes/v3/content/mod.rs +++ b/apps/labrinth/src/routes/v3/content/mod.rs @@ -27,11 +27,17 @@ const CONTENT_RESOLVE_CACHE_HEAT_NAMESPACE: &str = "content_resolve_heat"; const CONTENT_RESOLVE_CACHE_SCHEMA_VERSION: &str = "v1"; const CONTENT_RESOLVE_CACHE_HEAT_WINDOW_SECONDS: i64 = 60 * 60 * 24; -pub fn config(cfg: &mut web::ServiceConfig) { - cfg.service(resolve_content); +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { + cfg.service(web::scope("/v3").service(resolve_content)); } -#[post("content/resolve")] +/// Resolve content. +#[utoipa::path( + tag = "content", + request_body = serde_json::Value, + responses((status = OK, body = serde_json::Value)), +)] +#[post("/content/resolve")] async fn resolve_content( req: HttpRequest, request: web::Json, @@ -642,3 +648,8 @@ fn resolve_error_to_api(error: ResolveError) -> ApiError { } } } + +#[derive(utoipa::OpenApi)] +#[openapi(paths(resolve_content,))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; diff --git a/apps/labrinth/src/routes/v3/friends.rs b/apps/labrinth/src/routes/v3/friends.rs index 359b21c603..e5bacae67c 100644 --- a/apps/labrinth/src/routes/v3/friends.rs +++ b/apps/labrinth/src/routes/v3/friends.rs @@ -17,13 +17,15 @@ use actix_web::{HttpRequest, HttpResponse, delete, get, post, web}; use ariadne::networking::message::ServerToClientMessage; use chrono::Utc; -pub fn config(cfg: &mut web::ServiceConfig) { +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(add_friend); cfg.service(remove_friend); cfg.service(friends); } -#[post("friend/{id}")] +/// Add a friend. +#[utoipa::path(tag = "friends", responses((status = NO_CONTENT)))] +#[post("/friend/{id}")] pub async fn add_friend( req: HttpRequest, info: web::Path<(String,)>, @@ -133,7 +135,9 @@ pub async fn add_friend( Ok(HttpResponse::NoContent().body("")) } -#[delete("friend/{id}")] +/// Remove a friend. +#[utoipa::path(tag = "friends", responses((status = NO_CONTENT)))] +#[delete("/friend/{id}")] pub async fn remove_friend( req: HttpRequest, info: web::Path<(String,)>, @@ -175,7 +179,9 @@ pub async fn remove_friend( } } -#[get("friends")] +/// List friends. +#[utoipa::path(tag = "friends", responses((status = OK, body = Vec)))] +#[get("/friends")] pub async fn friends( req: HttpRequest, pool: web::Data, @@ -200,3 +206,8 @@ pub async fn friends( Ok(HttpResponse::Ok().json(friends)) } + +#[derive(utoipa::OpenApi)] +#[openapi(paths(add_friend, remove_friend, friends,))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; diff --git a/apps/labrinth/src/routes/v3/images.rs b/apps/labrinth/src/routes/v3/images.rs index d00e03946c..324cd8189f 100644 --- a/apps/labrinth/src/routes/v3/images.rs +++ b/apps/labrinth/src/routes/v3/images.rs @@ -14,11 +14,11 @@ use crate::queue::session::AuthQueue; use crate::routes::ApiError; use crate::util::img::upload_image_optimized; use crate::util::routes::read_limited_from_payload; -use actix_web::{HttpRequest, HttpResponse, web}; +use actix_web::{HttpRequest, HttpResponse, post, web}; use serde::{Deserialize, Serialize}; -pub fn config(cfg: &mut web::ServiceConfig) { - cfg.route("image", web::post().to(images_add)); +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { + cfg.service(images_add); } #[derive(Serialize, Deserialize)] @@ -36,6 +36,8 @@ pub struct ImageUpload { pub report_id: Option, } +#[utoipa::path(tag = "images", responses((status = OK)))] +#[post("/image")] pub async fn images_add( req: HttpRequest, web::Query(data): web::Query, @@ -253,3 +255,8 @@ pub async fn images_add( Ok(HttpResponse::Ok().json(image)) } + +#[derive(utoipa::OpenApi)] +#[openapi(paths(images_add,))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; diff --git a/apps/labrinth/src/routes/v3/limits.rs b/apps/labrinth/src/routes/v3/limits.rs index 5dfbbef251..e8c419759d 100644 --- a/apps/labrinth/src/routes/v3/limits.rs +++ b/apps/labrinth/src/routes/v3/limits.rs @@ -6,17 +6,16 @@ use crate::{ queue::session::AuthQueue, routes::ApiError, }; -use actix_web::{HttpRequest, web}; +use actix_web::{HttpRequest, get, web}; -pub fn config(cfg: &mut web::ServiceConfig) { - cfg.service( - web::scope("limits") - .route("projects", web::get().to(get_project_limits)) - .route("organizations", web::get().to(get_organization_limits)) - .route("collections", web::get().to(get_collection_limits)), - ); +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { + cfg.service(get_project_limits) + .service(get_organization_limits) + .service(get_collection_limits); } +#[utoipa::path(tag = "limits", responses((status = OK)))] +#[get("/limits/projects")] async fn get_project_limits( req: HttpRequest, pool: web::Data, @@ -36,6 +35,8 @@ async fn get_project_limits( Ok(web::Json(limits)) } +#[utoipa::path(tag = "limits", responses((status = OK)))] +#[get("/limits/organizations")] async fn get_organization_limits( req: HttpRequest, pool: web::Data, @@ -55,6 +56,8 @@ async fn get_organization_limits( Ok(web::Json(limits)) } +#[utoipa::path(tag = "limits", responses((status = OK)))] +#[get("/limits/collections")] async fn get_collection_limits( req: HttpRequest, pool: web::Data, @@ -73,3 +76,12 @@ async fn get_collection_limits( let limits = UserLimits::get_for_collections(&user, &pool).await?; Ok(web::Json(limits)) } + +#[derive(utoipa::OpenApi)] +#[openapi(paths( + get_project_limits, + get_organization_limits, + get_collection_limits, +))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; diff --git a/apps/labrinth/src/routes/v3/mod.rs b/apps/labrinth/src/routes/v3/mod.rs index 247a2b654d..9a6494bbf2 100644 --- a/apps/labrinth/src/routes/v3/mod.rs +++ b/apps/labrinth/src/routes/v3/mod.rs @@ -31,14 +31,32 @@ pub mod oauth_clients; pub fn config(cfg: &mut web::ServiceConfig) { cfg.service( - web::scope("v3") + web::scope("/v3/analytics") + .wrap(default_cors()) + .configure(analytics_get::config), + ); + cfg.service( + web::scope("/v3/payout") + .wrap(default_cors()) + .configure(payouts::config), + ); + cfg.service( + web::scope("/v3/project") + .wrap(default_cors()) + .configure(projects::project_config) + .configure(project_creation::config), + ); + cfg.service( + web::scope("/v3") .wrap(default_cors()) .configure(limits::config) .configure(collections::config) - .configure(content::config) .configure(images::config) .configure(notifications::config) + .configure(oauth_clients::config) .configure(organizations::config) + .service(payouts::paypal_webhook) + .service(payouts::tremendous_webhook) .configure(projects::config) .configure(reports::config) .configure(shared_instance_version_creation::config) @@ -52,33 +70,42 @@ pub fn config(cfg: &mut web::ServiceConfig) { .configure(versions::config) .configure(friends::config), ); + cfg.configure(content::config); } -pub fn utoipa_config( - cfg: &mut utoipa_actix_web::service_config::ServiceConfig, -) { - cfg.service( - utoipa_actix_web::scope("/v3/analytics") - .wrap(default_cors()) - .configure(analytics_get::config), - ); - cfg.service( - utoipa_actix_web::scope("/v3/analytics-event") - .wrap(default_cors()) - .configure(analytics_event::config), - ); - cfg.service( - utoipa_actix_web::scope("/v3/payout") - .wrap(default_cors()) - .configure(payouts::config), - ); - cfg.service( - utoipa_actix_web::scope("/v3/project") - .wrap(default_cors()) - .configure(projects::utoipa_config) - .configure(project_creation::config), - ); -} +#[derive(utoipa::OpenApi)] +#[openapi( + nest( + (path = "/v3/analytics", api = analytics_get::ApiDoc), + (path = "/v3/payout", api = payouts::PayoutRoutesDoc), + (path = "/v3/project", api = projects::ProjectRoutesDoc), + (path = "/v3/project", api = project_creation::ApiDoc), + (path = "/v3/project", api = teams::ProjectRoutesDoc), + (path = "/v3/project", api = versions::ProjectRoutesDoc), + (path = "/v3", api = limits::RouteDoc), + (path = "/v3", api = collections::RouteDoc), + (path = "/v3", api = images::RouteDoc), + (path = "/v3", api = notifications::RouteDoc), + (path = "/v3", api = oauth_clients::ApiDoc), + (path = "/v3", api = organizations::RouteDoc), + (path = "/v3", api = payouts::WebhookRoutesDoc), + (path = "/v3", api = projects::RootRoutesDoc), + (path = "/v3", api = reports::RouteDoc), + (path = "/v3", api = shared_instance_version_creation::RouteDoc), + (path = "/v3", api = shared_instances::RouteDoc), + (path = "/v3", api = statistics::RouteDoc), + (path = "/v3", api = tags::RouteDoc), + (path = "/v3", api = teams::RootRoutesDoc), + (path = "/v3", api = threads::RouteDoc), + (path = "/v3", api = users::RouteDoc), + (path = "/v3", api = version_creation::RouteDoc), + (path = "/v3", api = version_file::RouteDoc), + (path = "/v3", api = versions::RootRoutesDoc), + (path = "/v3", api = friends::RouteDoc), + (path = "/v3", api = content::RouteDoc), + ) +)] +pub struct ApiDoc; pub async fn hello_world() -> Result { Ok(HttpResponse::Ok().json(json!({ diff --git a/apps/labrinth/src/routes/v3/notifications.rs b/apps/labrinth/src/routes/v3/notifications.rs index b04c83390b..5c808808f3 100644 --- a/apps/labrinth/src/routes/v3/notifications.rs +++ b/apps/labrinth/src/routes/v3/notifications.rs @@ -7,20 +7,16 @@ use crate::models::notifications::Notification; use crate::models::pats::Scopes; use crate::queue::session::AuthQueue; use crate::routes::ApiError; -use actix_web::{HttpRequest, HttpResponse, web}; +use actix_web::{HttpRequest, HttpResponse, delete, get, patch, web}; use serde::{Deserialize, Serialize}; -pub fn config(cfg: &mut web::ServiceConfig) { - cfg.route("notifications", web::get().to(notifications_get)); - cfg.route("notifications", web::patch().to(notifications_read)); - cfg.route("notifications", web::delete().to(notifications_delete)); - - cfg.service( - web::scope("notification") - .route("{id}", web::get().to(notification_get)) - .route("{id}", web::patch().to(notification_read)) - .route("{id}", web::delete().to(notification_delete)), - ); +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { + cfg.service(notifications_get_route) + .service(notifications_read_route) + .service(notifications_delete_route) + .service(notification_get_route) + .service(notification_read_route) + .service(notification_delete_route); } #[derive(Serialize, Deserialize)] @@ -28,6 +24,18 @@ pub struct NotificationIds { pub ids: String, } +#[utoipa::path(tag = "notifications", responses((status = OK)))] +#[get("/notifications")] +async fn notifications_get_route( + req: HttpRequest, + ids: web::Query, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + notifications_get(req, ids, pool, redis, session_queue).await +} + pub async fn notifications_get( req: HttpRequest, web::Query(ids): web::Query, @@ -70,6 +78,18 @@ pub async fn notifications_get( Ok(HttpResponse::Ok().json(notifications)) } +#[utoipa::path(tag = "notifications", responses((status = OK)))] +#[get("/notification/{id}")] +async fn notification_get_route( + req: HttpRequest, + info: web::Path<(NotificationId,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + notification_get(req, info, pool, redis, session_queue).await +} + pub async fn notification_get( req: HttpRequest, info: web::Path<(NotificationId,)>, @@ -107,6 +127,18 @@ pub async fn notification_get( } } +#[utoipa::path(tag = "notifications", responses((status = NO_CONTENT)))] +#[patch("/notification/{id}")] +async fn notification_read_route( + req: HttpRequest, + info: web::Path<(NotificationId,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + notification_read(req, info, pool, redis, session_queue).await +} + pub async fn notification_read( req: HttpRequest, info: web::Path<(NotificationId,)>, @@ -157,6 +189,18 @@ pub async fn notification_read( } } +#[utoipa::path(tag = "notifications", responses((status = NO_CONTENT)))] +#[delete("/notification/{id}")] +async fn notification_delete_route( + req: HttpRequest, + info: web::Path<(NotificationId,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + notification_delete(req, info, pool, redis, session_queue).await +} + pub async fn notification_delete( req: HttpRequest, info: web::Path<(NotificationId,)>, @@ -208,6 +252,18 @@ pub async fn notification_delete( } } +#[utoipa::path(tag = "notifications", responses((status = NO_CONTENT)))] +#[patch("/notifications")] +async fn notifications_read_route( + req: HttpRequest, + ids: web::Query, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + notifications_read(req, ids, pool, redis, session_queue).await +} + pub async fn notifications_read( req: HttpRequest, web::Query(ids): web::Query, @@ -261,6 +317,18 @@ pub async fn notifications_read( Ok(HttpResponse::NoContent().body("")) } +#[utoipa::path(tag = "notifications", responses((status = NO_CONTENT)))] +#[delete("/notifications")] +async fn notifications_delete_route( + req: HttpRequest, + ids: web::Query, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + notifications_delete(req, ids, pool, redis, session_queue).await +} + pub async fn notifications_delete( req: HttpRequest, web::Query(ids): web::Query, @@ -313,3 +381,15 @@ pub async fn notifications_delete( Ok(HttpResponse::NoContent().body("")) } + +#[derive(utoipa::OpenApi)] +#[openapi(paths( + notifications_get_route, + notification_get_route, + notification_read_route, + notification_delete_route, + notifications_read_route, + notifications_delete_route, +))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; diff --git a/apps/labrinth/src/routes/v3/oauth_clients.rs b/apps/labrinth/src/routes/v3/oauth_clients.rs index 7c16ea141d..c33f8c469f 100644 --- a/apps/labrinth/src/routes/v3/oauth_clients.rs +++ b/apps/labrinth/src/routes/v3/oauth_clients.rs @@ -29,10 +29,7 @@ use crate::{ file_hosting::FileHost, models::oauth_clients::DeleteOAuthClientQueryParam, util::routes::read_limited_from_payload, }; -use actix_web::{ - HttpRequest, HttpResponse, delete, get, patch, post, - web::{self, scope}, -}; +use actix_web::{HttpRequest, HttpResponse, delete, get, patch, post, web}; use ariadne::ids::base62_impl::parse_base62; use chrono::Utc; use itertools::Itertools; @@ -41,9 +38,9 @@ use rand_chacha::ChaCha20Rng; use serde::{Deserialize, Serialize}; use validator::Validate; -pub fn config(cfg: &mut web::ServiceConfig) { +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service( - scope("oauth") + web::scope("/oauth") .configure(crate::auth::oauth::config) .service(revoke_oauth_authorization) .service(oauth_client_create) @@ -57,6 +54,8 @@ pub fn config(cfg: &mut web::ServiceConfig) { ); } +#[utoipa::path(tag = "oauth clients", responses((status = OK)))] +#[get("/user/{id}/oauth_apps")] pub async fn get_user_clients( req: HttpRequest, info: web::Path, @@ -100,7 +99,12 @@ pub async fn get_user_clients( } } -#[get("app/{id}")] +/// Get an OAuth client. +#[utoipa::path( + tag = "oauth clients", + responses((status = OK, body = models::oauth_clients::OAuthClient)), +)] +#[get("/app/{id}")] pub async fn get_client( id: web::Path, pool: web::Data, @@ -113,7 +117,12 @@ pub async fn get_client( } } -#[get("apps")] +/// List OAuth clients. +#[utoipa::path( + tag = "oauth clients", + responses((status = OK, body = Vec)), +)] +#[get("/apps")] pub async fn get_clients( info: web::Query, pool: web::Data, @@ -129,7 +138,7 @@ pub async fn get_clients( Ok(HttpResponse::Ok().json(clients)) } -#[derive(Deserialize, Validate)] +#[derive(Deserialize, Validate, utoipa::ToSchema)] pub struct NewOAuthApp { #[validate( custom(function = "crate::util::validate::validate_name"), @@ -154,7 +163,12 @@ pub struct NewOAuthApp { pub description: Option, } -#[post("app")] +/// Create an OAuth client. +#[utoipa::path( + tag = "oauth clients", + responses((status = OK, body = OAuthClientCreationResult)), +)] +#[post("/app")] pub async fn oauth_client_create( req: HttpRequest, new_oauth_app: web::Json, @@ -215,7 +229,9 @@ pub async fn oauth_client_create( })) } -#[delete("app/{id}")] +/// Delete an OAuth client. +#[utoipa::path(tag = "oauth clients", responses((status = NO_CONTENT)))] +#[delete("/app/{id}")] pub async fn oauth_client_delete( req: HttpRequest, client_id: web::Path, @@ -245,7 +261,7 @@ pub async fn oauth_client_delete( } } -#[derive(Serialize, Deserialize, Validate)] +#[derive(Serialize, Deserialize, Validate, utoipa::ToSchema)] pub struct OAuthClientEdit { #[validate( custom(function = "crate::util::validate::validate_name"), @@ -271,7 +287,9 @@ pub struct OAuthClientEdit { pub description: Option>, } -#[patch("app/{id}")] +/// Update an OAuth client. +#[utoipa::path(tag = "oauth clients", responses((status = NO_CONTENT)))] +#[patch("/app/{id}")] pub async fn oauth_client_edit( req: HttpRequest, client_id: web::Path, @@ -346,7 +364,9 @@ pub struct Extension { pub ext: String, } -#[patch("app/{id}/icon")] +/// Update an OAuth client icon. +#[utoipa::path(tag = "oauth clients", responses((status = NO_CONTENT)))] +#[patch("/app/{id}/icon")] #[allow(clippy::too_many_arguments)] pub async fn oauth_client_icon_edit( web::Query(ext): web::Query, @@ -418,7 +438,9 @@ pub async fn oauth_client_icon_edit( Ok(HttpResponse::NoContent().body("")) } -#[delete("app/{id}/icon")] +/// Delete an OAuth client icon. +#[utoipa::path(tag = "oauth clients", responses((status = NO_CONTENT)))] +#[delete("/app/{id}/icon")] pub async fn oauth_client_icon_delete( req: HttpRequest, client_id: web::Path, @@ -468,7 +490,12 @@ pub async fn oauth_client_icon_delete( Ok(HttpResponse::NoContent().body("")) } -#[get("authorizations")] +/// List OAuth authorizations. +#[utoipa::path( + tag = "oauth clients", + responses((status = OK, body = Vec)), +)] +#[get("/authorizations")] pub async fn get_user_oauth_authorizations( req: HttpRequest, pool: web::Data, @@ -497,7 +524,9 @@ pub async fn get_user_oauth_authorizations( Ok(HttpResponse::Ok().json(mapped)) } -#[delete("authorizations")] +/// Revoke OAuth authorization. +#[utoipa::path(tag = "oauth clients", responses((status = NO_CONTENT)))] +#[delete("/authorizations")] pub async fn revoke_oauth_authorization( req: HttpRequest, info: web::Query, @@ -592,3 +621,28 @@ pub async fn get_clients_inner( Ok(clients.into_iter().map(|c| c.into()).collect_vec()) } + +#[derive(utoipa::OpenApi)] +#[openapi(paths( + get_user_clients, + get_client, + get_clients, + oauth_client_create, + oauth_client_delete, + oauth_client_edit, + oauth_client_icon_edit, + oauth_client_icon_delete, + get_user_oauth_authorizations, + revoke_oauth_authorization, +))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; + +#[derive(utoipa::OpenApi)] +#[openapi( + nest( + (path = "/oauth", api = RouteDoc), + (path = "/oauth", api = crate::auth::oauth::RouteDoc), + ) +)] +pub(crate) struct ApiDoc; diff --git a/apps/labrinth/src/routes/v3/organizations.rs b/apps/labrinth/src/routes/v3/organizations.rs index b59e74cefd..1ed66de412 100644 --- a/apps/labrinth/src/routes/v3/organizations.rs +++ b/apps/labrinth/src/routes/v3/organizations.rs @@ -21,37 +21,29 @@ use crate::util::img::delete_old_images; use crate::util::routes::read_limited_from_payload; use crate::util::validate::validation_errors_to_string; use crate::{database, models}; -use actix_web::{HttpRequest, HttpResponse, web}; +use actix_web::{HttpRequest, HttpResponse, delete, get, patch, post, web}; use ariadne::ids::UserId; use futures::TryStreamExt; use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; use validator::Validate; -pub fn config(cfg: &mut web::ServiceConfig) { - cfg.route("organizations", web::get().to(organizations_get)); - cfg.service( - web::scope("organization") - .route("", web::post().to(organization_create)) - .route("{id}/projects", web::get().to(organization_projects_get)) - .route("{id}/notes", web::patch().to(organization_notes_edit)) - .route("{id}", web::get().to(organization_get)) - .route("{id}", web::patch().to(organizations_edit)) - .route("{id}", web::delete().to(organization_delete)) - .route("{id}/projects", web::post().to(organization_projects_add)) - .route( - "{id}/projects/{project_id}", - web::delete().to(organization_projects_remove), - ) - .route("{id}/icon", web::patch().to(organization_icon_edit)) - .route("{id}/icon", web::delete().to(delete_organization_icon)) - .route( - "{id}/members", - web::get().to(super::teams::team_members_get_organization), - ), - ); +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { + cfg.service(organizations_get) + .service(organization_create) + .service(organization_projects_get) + .service(organization_notes_edit) + .service(organization_get) + .service(organizations_edit) + .service(organization_delete) + .service(organization_projects_add) + .service(organization_projects_remove) + .service(organization_icon_edit) + .service(delete_organization_icon); } +#[utoipa::path(tag = "organizations", responses((status = OK)))] +#[get("/organization/{id}/projects")] pub async fn organization_projects_get( req: HttpRequest, info: web::Path<(String,)>, @@ -106,7 +98,7 @@ pub async fn organization_projects_get( } } -#[derive(Deserialize, Validate)] +#[derive(Deserialize, Validate, utoipa::ToSchema)] pub struct NewOrganization { #[validate( length(min = 3, max = 64), @@ -120,6 +112,8 @@ pub struct NewOrganization { pub description: String, } +#[utoipa::path(tag = "organizations", responses((status = OK)))] +#[post("/organization")] pub async fn organization_create( req: HttpRequest, new_organization: web::Json, @@ -222,6 +216,8 @@ pub async fn organization_create( Ok(HttpResponse::Ok().json(organization)) } +#[utoipa::path(tag = "organizations", responses((status = OK)))] +#[get("/organization/{id}")] pub async fn organization_get( req: HttpRequest, info: web::Path<(String,)>, @@ -300,6 +296,8 @@ pub async fn organization_get( Err(ApiError::NotFound) } +#[utoipa::path(tag = "organizations", responses((status = NO_CONTENT)))] +#[patch("/organization/{id}/notes")] pub async fn organization_notes_edit( req: HttpRequest, info: web::Path<(String,)>, @@ -380,6 +378,8 @@ pub struct OrganizationIds { pub ids: String, } +#[utoipa::path(tag = "organizations", responses((status = OK)))] +#[get("/organizations")] pub async fn organizations_get( req: HttpRequest, web::Query(ids): web::Query, @@ -484,7 +484,7 @@ pub async fn organizations_get( Ok(HttpResponse::Ok().json(organizations)) } -#[derive(Serialize, Deserialize, Validate)] +#[derive(Serialize, Deserialize, Validate, utoipa::ToSchema)] pub struct OrganizationEdit { #[validate(length(min = 3, max = 256))] pub description: Option, @@ -497,6 +497,8 @@ pub struct OrganizationEdit { pub name: Option, } +#[utoipa::path(tag = "organizations", responses((status = NO_CONTENT)))] +#[patch("/organization/{id}")] pub async fn organizations_edit( req: HttpRequest, info: web::Path<(String,)>, @@ -658,6 +660,8 @@ pub async fn organizations_edit( } } +#[utoipa::path(tag = "organizations", responses((status = NO_CONTENT)))] +#[delete("/organization/{id}")] pub async fn organization_delete( req: HttpRequest, info: web::Path<(String,)>, @@ -823,10 +827,12 @@ pub async fn organization_delete( } } -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] pub struct OrganizationProjectAdd { pub project_id: String, // Also allow name/slug } +#[utoipa::path(tag = "organizations", responses((status = NO_CONTENT)))] +#[post("/organization/{id}/projects")] pub async fn organization_projects_add( req: HttpRequest, info: web::Path<(String,)>, @@ -985,13 +991,15 @@ pub async fn organization_projects_add( Ok(HttpResponse::Ok().finish()) } -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] pub struct OrganizationProjectRemoval { // A new owner must be supplied for the project. // That user must be a member of the organization, but not necessarily a member of the project. pub new_owner: UserId, } +#[utoipa::path(tag = "organizations", responses((status = NO_CONTENT)))] +#[delete("/organization/{id}/projects/{project_id}")] pub async fn organization_projects_remove( req: HttpRequest, info: web::Path<(String, String)>, @@ -1181,6 +1189,8 @@ pub struct Extension { } #[allow(clippy::too_many_arguments)] +#[utoipa::path(tag = "organizations", responses((status = NO_CONTENT)))] +#[patch("/organization/{id}/icon")] pub async fn organization_icon_edit( web::Query(ext): web::Query, req: HttpRequest, @@ -1288,6 +1298,8 @@ pub async fn organization_icon_edit( Ok(HttpResponse::NoContent().body("")) } +#[utoipa::path(tag = "organizations", responses((status = NO_CONTENT)))] +#[delete("/organization/{id}/icon")] pub async fn delete_organization_icon( req: HttpRequest, info: web::Path<(String,)>, @@ -1371,3 +1383,20 @@ pub async fn delete_organization_icon( Ok(HttpResponse::NoContent().body("")) } + +#[derive(utoipa::OpenApi)] +#[openapi(paths( + organization_projects_get, + organization_create, + organization_get, + organization_notes_edit, + organizations_get, + organizations_edit, + organization_delete, + organization_projects_add, + organization_projects_remove, + organization_icon_edit, + delete_organization_icon, +))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; diff --git a/apps/labrinth/src/routes/v3/payouts.rs b/apps/labrinth/src/routes/v3/payouts.rs index 77df4167ba..8c460084d5 100644 --- a/apps/labrinth/src/routes/v3/payouts.rs +++ b/apps/labrinth/src/routes/v3/payouts.rs @@ -7,7 +7,9 @@ use crate::database::redis::RedisPool; use crate::env::ENV; use crate::models::ids::PayoutId; use crate::models::pats::Scopes; -use crate::models::payouts::{PayoutMethodType, PayoutStatus, Withdrawal}; +use crate::models::payouts::{ + PayoutMethod, PayoutMethodType, PayoutStatus, Withdrawal, +}; use crate::queue::payouts::PayoutsQueue; use crate::queue::session::AuthQueue; use crate::routes::ApiError; @@ -31,10 +33,8 @@ use tracing::error; const COMPLIANCE_CHECK_DEBOUNCE: chrono::Duration = chrono::Duration::seconds(15); -pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { - cfg.service(paypal_webhook) - .service(tremendous_webhook) - .service(transaction_history) +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { + cfg.service(transaction_history) .service(calculate_fees) .service(create_payout) .service(cancel_payout) @@ -49,7 +49,11 @@ pub struct RequestForm { form_type: users_compliance::FormType, } -#[utoipa::path] +/// Submit a compliance form. +#[utoipa::path( + tag = "payouts", + responses((status = OK, body = serde_json::Value)), +)] #[post("/compliance")] pub async fn post_compliance_form( req: HttpRequest, @@ -148,7 +152,8 @@ pub async fn post_compliance_form( } } -#[utoipa::path] +/// Receive PayPal webhook. +#[utoipa::path(tag = "payouts", responses((status = NO_CONTENT)))] #[post("/_paypal")] pub async fn paypal_webhook( req: HttpRequest, @@ -306,7 +311,8 @@ pub async fn paypal_webhook( Ok(HttpResponse::NoContent().finish()) } -#[utoipa::path] +/// Receive Tremendous webhook. +#[utoipa::path(tag = "payouts", responses((status = NO_CONTENT)))] #[post("/_tremendous")] pub async fn tremendous_webhook( req: HttpRequest, @@ -417,14 +423,18 @@ pub async fn tremendous_webhook( Ok(HttpResponse::NoContent().finish()) } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] pub struct WithdrawalFees { pub net_usd: Decimal2dp, pub fee: Decimal2dp, pub exchange_rate: Option, } -#[utoipa::path] +/// Calculate payout fees. +#[utoipa::path( + tag = "payouts", + responses((status = OK, body = WithdrawalFees)), +)] #[post("/fees")] pub async fn calculate_fees( req: HttpRequest, @@ -457,7 +467,8 @@ pub async fn calculate_fees( })) } -#[utoipa::path] +/// Create a payout. +#[utoipa::path(tag = "payouts", responses((status = NO_CONTENT)))] #[post("")] pub async fn create_payout( req: HttpRequest, @@ -670,9 +681,9 @@ pub enum PayoutSource { Affilites, } -/// Get the history of when the authorized user got payouts available, and when +/// Get transaction history. /// the user withdrew their payouts. -#[utoipa::path(responses((status = OK, body = Vec)))] +#[utoipa::path(tag = "payouts", responses((status = OK, body = Vec)))] #[get("/history")] pub async fn transaction_history( req: HttpRequest, @@ -754,7 +765,8 @@ pub async fn transaction_history( Ok(web::Json(txn_items)) } -#[utoipa::path] +/// Cancel a payout. +#[utoipa::path(tag = "payouts", responses((status = NO_CONTENT)))] #[delete("/{id}")] pub async fn cancel_payout( info: web::Path<(PayoutId,)>, @@ -863,7 +875,7 @@ pub struct MethodFilter { pub country: Option, } -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "kebab-case")] pub enum FormCompletionStatus { Unknown, @@ -873,7 +885,12 @@ pub enum FormCompletionStatus { Complete, } -#[utoipa::path] +/// List payment methods. +#[utoipa::path( + tag = "payouts", + params(("country" = Option, Query)), + responses((status = OK, body = Vec)), +)] #[get("/methods")] pub async fn payment_methods( payouts_queue: web::Data, @@ -897,7 +914,7 @@ pub async fn payment_methods( Ok(HttpResponse::Ok().json(methods)) } -#[derive(Serialize)] +#[derive(Serialize, utoipa::ToSchema)] pub struct UserBalance { pub available: Decimal, pub withdrawn_lifetime: Decimal, @@ -906,7 +923,19 @@ pub struct UserBalance { pub dates: HashMap, Decimal>, } -#[utoipa::path] +#[derive(Serialize, utoipa::ToSchema)] +pub struct BalanceResponse { + #[serde(flatten)] + balance: UserBalance, + requested_form_type: Option, + form_completion_status: Option, +} + +/// Get account balance. +#[utoipa::path( + tag = "payouts", + responses((status = OK, body = BalanceResponse)), +)] #[get("/balance")] pub async fn get_balance( req: HttpRequest, @@ -924,14 +953,6 @@ pub async fn get_balance( .await? .1; - #[derive(Serialize)] - struct Response { - #[serde(flatten)] - balance: UserBalance, - requested_form_type: Option, - form_completion_status: Option, - } - let balance = get_user_balance(user.id.into(), &pool).await?; let mut requested_form_type = None; @@ -965,7 +986,7 @@ pub async fn get_balance( ); } - Ok(HttpResponse::Ok().json(Response { + Ok(HttpResponse::Ok().json(BalanceResponse { balance, requested_form_type, form_completion_status, @@ -1123,21 +1144,29 @@ pub struct RevenueQuery { pub end: Option>, } -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, utoipa::ToSchema)] pub struct RevenueResponse { pub all_time: Decimal, pub all_time_available: Decimal, pub data: Vec, } -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, utoipa::ToSchema)] pub struct RevenueData { pub time: u64, pub revenue: Decimal, pub creator_revenue: Decimal, } -#[utoipa::path] +/// Get platform revenue. +#[utoipa::path( + tag = "payouts", + params( + ("start" = Option, Query), + ("end" = Option, Query) + ), + responses((status = OK, body = RevenueResponse)), +)] #[get("/platform_revenue")] pub async fn platform_revenue( query: web::Query, @@ -1197,3 +1226,36 @@ pub async fn platform_revenue( Ok(HttpResponse::Ok().json(res)) } + +#[derive(utoipa::OpenApi)] +#[openapi(paths( + post_compliance_form, + paypal_webhook, + tremendous_webhook, + calculate_fees, + create_payout, + transaction_history, + cancel_payout, + payment_methods, + get_balance, + platform_revenue, +))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; + +#[derive(utoipa::OpenApi)] +#[openapi(paths( + post_compliance_form, + calculate_fees, + create_payout, + transaction_history, + cancel_payout, + payment_methods, + get_balance, + platform_revenue, +))] +pub(crate) struct PayoutRoutesDoc; + +#[derive(utoipa::OpenApi)] +#[openapi(paths(paypal_webhook, tremendous_webhook,))] +pub(crate) struct WebhookRoutesDoc; diff --git a/apps/labrinth/src/routes/v3/project_creation.rs b/apps/labrinth/src/routes/v3/project_creation.rs index 1088302d2f..5b77c0979f 100644 --- a/apps/labrinth/src/routes/v3/project_creation.rs +++ b/apps/labrinth/src/routes/v3/project_creation.rs @@ -15,7 +15,7 @@ use crate::models::ids::{ImageId, OrganizationId, ProjectId, VersionId}; use crate::models::images::{Image, ImageContext}; use crate::models::pats::Scopes; use crate::models::projects::{ - License, Link, MonetizationStatus, ProjectStatus, + License, Link, MonetizationStatus, Project, ProjectStatus, SideTypesMigrationReviewStatus, VersionStatus, }; use crate::models::teams::{OrganizationPermissions, ProjectPermissions}; @@ -46,7 +46,7 @@ use validator::Validate; mod new; -pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(project_create) .service(project_create_with_id) .configure(new::config); @@ -283,7 +283,8 @@ pub async fn undo_uploads( Ok(()) } -#[utoipa::path] +/// Create a project. +#[utoipa::path(tag = "projects", responses((status = OK, body = Project)))] #[post("")] pub async fn project_create( req: HttpRequest, @@ -361,10 +362,10 @@ pub async fn project_create_internal( result } -/// Allows creating a project with a specific ID. +/// Create a project with a specific ID. /// /// This is a testing endpoint only accessible behind an admin key. -#[utoipa::path] +#[utoipa::path(tag = "projects", responses((status = OK, body = Project)))] #[post("/{id}", guard = "admin_key_guard")] pub async fn project_create_with_id( req: HttpRequest, @@ -1177,3 +1178,18 @@ async fn process_icon_upload( upload_result.color, )) } + +#[derive(utoipa::OpenApi)] +#[openapi(paths(project_create, project_create_with_id,))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; + +pub(crate) struct ApiDoc; + +impl utoipa::OpenApi for ApiDoc { + fn openapi() -> utoipa::openapi::OpenApi { + let mut openapi = RouteDoc::openapi(); + openapi.merge(new::RouteDoc::openapi()); + openapi + } +} diff --git a/apps/labrinth/src/routes/v3/project_creation/new.rs b/apps/labrinth/src/routes/v3/project_creation/new.rs index 877ad9a70d..0fe952fcdd 100644 --- a/apps/labrinth/src/routes/v3/project_creation/new.rs +++ b/apps/labrinth/src/routes/v3/project_creation/new.rs @@ -36,7 +36,7 @@ use crate::{ }, }; -pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(create); } @@ -110,11 +110,11 @@ pub struct ProjectCreate { pub components: exp::ProjectEdit, } -/// Creates a new project with the given components. +/// Create a project from components. /// /// Components must include `base` ([`exp::base::Project`]), and at least one /// other component. -#[utoipa::path] +#[utoipa::path(tag = "projects", responses((status = OK, body = ProjectId)))] #[put("")] pub async fn create( req: HttpRequest, @@ -408,3 +408,8 @@ mod tests { } } } + +#[derive(utoipa::OpenApi)] +#[openapi(paths(create,))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; diff --git a/apps/labrinth/src/routes/v3/projects.rs b/apps/labrinth/src/routes/v3/projects.rs index 1606919089..3a7b10c595 100644 --- a/apps/labrinth/src/routes/v3/projects.rs +++ b/apps/labrinth/src/routes/v3/projects.rs @@ -43,20 +43,17 @@ use eyre::eyre; use futures::TryStreamExt; use itertools::Itertools; use serde::{Deserialize, Serialize}; -use serde_json::json; use validator::Validate; -pub fn config(cfg: &mut web::ServiceConfig) { - cfg.route("search", web::get().to(project_search)); - cfg.service(project_search_post); - cfg.route("projects", web::get().to(projects_get)); - cfg.route("projects", web::patch().to(projects_edit)); - cfg.route("projects_random", web::get().to(random_projects_get)); +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { + cfg.service(project_search) + .service(project_search_post) + .service(projects_get_route) + .service(projects_edit_route) + .service(random_projects_get_route); } -pub fn utoipa_config( - cfg: &mut utoipa_actix_web::service_config::ServiceConfig, -) { +pub fn project_config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(project_get) .service(project_get_check) .service(project_delete) @@ -100,6 +97,16 @@ pub struct RandomProjects { pub count: u32, } +#[utoipa::path(tag = "projects", responses((status = OK)))] +#[get("/projects_random")] +async fn random_projects_get_route( + count: web::Query, + pool: web::Data, + redis: web::Data, +) -> Result { + random_projects_get(count, pool, redis).await +} + pub async fn random_projects_get( web::Query(count): web::Query, pool: web::Data, @@ -141,11 +148,28 @@ pub async fn random_projects_get( Ok(HttpResponse::Ok().json(projects_data)) } -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, utoipa::ToSchema)] pub struct ProjectIds { pub ids: String, } +#[derive(Serialize, utoipa::ToSchema)] +pub struct ProjectCheckResponse { + pub id: ProjectId, +} + +#[utoipa::path(tag = "projects", responses((status = OK)))] +#[get("/projects")] +async fn projects_get_route( + req: HttpRequest, + ids: web::Query, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + projects_get(req, ids, pool, redis, session_queue).await +} + pub async fn projects_get( req: HttpRequest, web::Query(ids): web::Query, @@ -175,7 +199,8 @@ pub async fn projects_get( Ok(HttpResponse::Ok().json(projects)) } -#[utoipa::path] +/// Get a project. +#[utoipa::path(tag = "projects", responses((status = OK, body = Project)))] #[get("/{id}")] async fn project_get( req: HttpRequest, @@ -306,7 +331,8 @@ pub struct EditProject { } #[allow(clippy::too_many_arguments)] -#[utoipa::path] +/// Update a project. +#[utoipa::path(tag = "projects", responses((status = NO_CONTENT)))] #[patch("/{id}")] async fn project_edit( req: HttpRequest, @@ -1213,6 +1239,59 @@ pub async fn edit_project_categories( // pub total_hits: usize, // } +/// Search projects. +#[utoipa::path( + tag = "search", + get, + operation_id = "v3SearchProjects", + params( + ( + "query" = Option, + Query, + description = "The query to search for" + ), + ( + "facets" = Option, + Query, + description = "Search facets JSON" + ), + ( + "filters" = Option, + Query, + description = "Search filters JSON" + ), + ( + "new_filters" = Option, + Query, + description = "Search filters JSON" + ), + ( + "index" = Option, + Query, + description = "Search index to use" + ), + ( + "offset" = Option, + Query, + description = "Search result offset" + ), + ( + "limit" = Option, + Query, + description = "Maximum number of search results" + ), + ( + "version" = Option, + Query, + description = "Game version to filter for" + ) + ), + responses( + (status = 200, description = "Expected response to a valid request", body = SearchResults), + (status = 400, description = "Request was invalid, see given error") + ) +)] +#[get("/search")] pub async fn project_search( web::Query(info): web::Query, search_backend: web::Data, @@ -1238,6 +1317,12 @@ pub async fn project_search( } // for more complicated search queries +/// Search projects. +#[utoipa::path( + tag = "search", + request_body = serde_json::Value, + responses((status = OK, body = SearchResults)) +)] #[post("/search")] pub async fn project_search_post( web::Json(info): web::Json, @@ -1249,7 +1334,8 @@ pub async fn project_search_post( } //checks the validity of a project id or slug -#[utoipa::path] +/// Check project availability. +#[utoipa::path(tag = "projects", responses((status = OK, body = ProjectCheckResponse)))] #[get("/{id}/check")] async fn project_get_check( info: web::Path<(String,)>, @@ -1270,21 +1356,22 @@ pub async fn project_get_check_internal( db_models::DBProject::get(&slug, &**pool, &redis).await?; if let Some(project) = project_data { - Ok(HttpResponse::Ok().json(json! ({ - "id": models::ids::ProjectId::from(project.inner.id) - }))) + Ok(HttpResponse::Ok().json(ProjectCheckResponse { + id: models::ids::ProjectId::from(project.inner.id), + })) } else { Err(ApiError::NotFound) } } -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, utoipa::ToSchema)] pub struct DependencyInfo { pub projects: Vec, pub versions: Vec, } -#[utoipa::path] +/// List project dependencies. +#[utoipa::path(tag = "projects", responses((status = OK, body = DependencyInfo)))] #[get("/{project_id}/dependencies")] pub async fn dependency_list( req: HttpRequest, @@ -1408,7 +1495,7 @@ pub struct CategoryChanges<'a> { pub remove_categories: &'a Option>, } -#[derive(Deserialize, Validate)] +#[derive(Deserialize, Validate, utoipa::ToSchema)] pub struct BulkEditProject { #[validate(length(max = 3))] pub categories: Option>, @@ -1428,6 +1515,29 @@ pub struct BulkEditProject { pub link_urls: Option>>, } +#[utoipa::path(tag = "projects", responses((status = NO_CONTENT)))] +#[patch("/projects")] +async fn projects_edit_route( + req: HttpRequest, + ids: web::Query, + pool: web::Data, + bulk_edit_project: web::Json, + redis: web::Data, + session_queue: web::Data, + search_state: web::Data, +) -> Result { + projects_edit( + req, + ids, + pool, + bulk_edit_project, + redis, + session_queue, + search_state, + ) + .await +} + pub async fn projects_edit( req: HttpRequest, web::Query(ids): web::Query, @@ -1730,7 +1840,12 @@ pub struct Extension { } #[allow(clippy::too_many_arguments)] -#[utoipa::path] +/// Update a project icon. +#[utoipa::path( + tag = "projects", + params(("ext" = String, Query)), + responses((status = NO_CONTENT)) +)] #[patch("/{id}/icon")] async fn project_icon_edit( web::Query(ext): web::Query, @@ -1874,7 +1989,8 @@ pub async fn project_icon_edit_internal( Ok(HttpResponse::NoContent().body("")) } -#[utoipa::path] +/// Delete a project icon. +#[utoipa::path(tag = "projects", responses((status = NO_CONTENT)))] #[delete("/{id}/icon")] async fn delete_project_icon( req: HttpRequest, @@ -2000,7 +2116,18 @@ pub struct GalleryCreateQuery { } #[allow(clippy::too_many_arguments)] -#[utoipa::path] +/// Add a gallery item. +#[utoipa::path( + tag = "projects", + params( + ("ext" = String, Query), + ("featured" = bool, Query), + ("name" = Option, Query), + ("description" = Option, Query), + ("ordering" = Option, Query) + ), + responses((status = NO_CONTENT)) +)] #[post("/{id}/gallery")] pub async fn add_gallery_item( web::Query(ext): web::Query, @@ -2198,7 +2325,18 @@ pub struct GalleryEditQuery { pub ordering: Option, } -#[utoipa::path] +/// Update a gallery item. +#[utoipa::path( + tag = "projects", + params( + ("url" = String, Query), + ("featured" = Option, Query), + ("name" = Option, Query), + ("description" = Option, Query), + ("ordering" = Option, Query) + ), + responses((status = NO_CONTENT)) +)] #[patch("/{id}/gallery")] async fn edit_gallery_item( req: HttpRequest, @@ -2387,7 +2525,12 @@ pub struct GalleryDeleteQuery { pub url: String, } -#[utoipa::path] +/// Delete a gallery item. +#[utoipa::path( + tag = "projects", + params(("url" = String, Query)), + responses((status = NO_CONTENT)) +)] #[delete("/{id}/gallery")] async fn delete_gallery_item( req: HttpRequest, @@ -2522,7 +2665,8 @@ pub async fn delete_gallery_item_internal( Ok(HttpResponse::NoContent().body("")) } -#[utoipa::path] +/// Delete a project. +#[utoipa::path(tag = "projects", responses((status = NO_CONTENT)))] #[delete("/{id}")] async fn project_delete( req: HttpRequest, @@ -2680,7 +2824,8 @@ pub async fn project_delete_internal( } } -#[utoipa::path] +/// Follow a project. +#[utoipa::path(tag = "projects", responses((status = NO_CONTENT)))] #[post("/{id}/follow")] async fn project_follow( req: HttpRequest, @@ -2772,7 +2917,8 @@ pub async fn project_follow_internal( } } -#[utoipa::path] +/// Unfollow a project. +#[utoipa::path(tag = "projects", responses((status = NO_CONTENT)))] #[delete("/{id}/follow")] async fn project_unfollow( req: HttpRequest, @@ -2860,7 +3006,8 @@ pub async fn project_unfollow_internal( } } -#[utoipa::path] +/// Get a project's organization. +#[utoipa::path(tag = "projects", responses((status = OK, body = models::organizations::Organization)))] #[get("/{id}/organization")] pub async fn project_get_organization( req: HttpRequest, @@ -2954,3 +3101,55 @@ pub async fn project_get_organization( Err(ApiError::NotFound) } } + +#[derive(utoipa::OpenApi)] +#[openapi(paths( + random_projects_get_route, + projects_get_route, + project_get, + project_edit, + project_search, + project_search_post, + project_get_check, + dependency_list, + projects_edit_route, + project_icon_edit, + delete_project_icon, + add_gallery_item, + edit_gallery_item, + delete_gallery_item, + project_delete, + project_follow, + project_unfollow, + project_get_organization, +))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; + +#[derive(utoipa::OpenApi)] +#[openapi(paths( + project_search, + project_search_post, + projects_get_route, + projects_edit_route, + random_projects_get_route, +))] +pub(crate) struct RootRoutesDoc; + +#[derive(utoipa::OpenApi)] +#[openapi(paths( + project_get, + project_get_check, + project_delete, + project_edit, + project_icon_edit, + delete_project_icon, + add_gallery_item, + edit_gallery_item, + delete_gallery_item, + project_follow, + project_unfollow, + project_get_organization, + dependency_list, +))] +pub(crate) struct ProjectRoutesDoc; diff --git a/apps/labrinth/src/routes/v3/reports.rs b/apps/labrinth/src/routes/v3/reports.rs index e13f4ae572..29e2167bbc 100644 --- a/apps/labrinth/src/routes/v3/reports.rs +++ b/apps/labrinth/src/routes/v3/reports.rs @@ -18,20 +18,20 @@ use crate::queue::session::AuthQueue; use crate::routes::ApiError; use crate::util::img; use crate::util::routes::read_typed_from_payload; -use actix_web::{HttpRequest, HttpResponse, web}; +use actix_web::{HttpRequest, HttpResponse, delete, get, patch, post, web}; use ariadne::ids::UserId; use ariadne::ids::base62_impl::parse_base62; use chrono::Utc; use serde::Deserialize; use validator::Validate; -pub fn config(cfg: &mut web::ServiceConfig) { - cfg.route("report", web::post().to(report_create)); - cfg.route("report", web::get().to(reports)); - cfg.route("reports", web::get().to(reports_get)); - cfg.route("report/{id}", web::get().to(report_get)); - cfg.route("report/{id}", web::patch().to(report_edit)); - cfg.route("report/{id}", web::delete().to(report_delete)); +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { + cfg.service(report_create_route) + .service(reports_route) + .service(reports_get_route) + .service(report_get_route) + .service(report_edit_route) + .service(report_delete_route); } #[derive(Deserialize, Validate)] @@ -46,6 +46,18 @@ pub struct CreateReport { pub uploaded_images: Vec, } +#[utoipa::path(tag = "reports", responses((status = OK)))] +#[post("/report")] +async fn report_create_route( + req: HttpRequest, + pool: web::Data, + body: web::Payload, + redis: web::Data, + session_queue: web::Data, +) -> Result { + report_create(req, pool, body, redis, session_queue).await +} + pub async fn report_create( req: HttpRequest, pool: web::Data, @@ -247,6 +259,18 @@ fn default_all() -> bool { true } +#[utoipa::path(tag = "reports", responses((status = OK)))] +#[get("/report")] +async fn reports_route( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + request_opts: web::Query, + session_queue: web::Data, +) -> Result { + reports(req, pool, redis, request_opts, session_queue).await +} + pub async fn reports( req: HttpRequest, pool: web::Data, @@ -322,6 +346,18 @@ pub struct ReportIds { pub ids: String, } +#[utoipa::path(tag = "reports", responses((status = OK)))] +#[get("/reports")] +async fn reports_get_route( + req: HttpRequest, + ids: web::Query, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + reports_get(req, ids, pool, redis, session_queue).await +} + pub async fn reports_get( req: HttpRequest, web::Query(ids): web::Query, @@ -361,6 +397,18 @@ pub async fn reports_get( Ok(HttpResponse::Ok().json(all_reports)) } +#[utoipa::path(tag = "reports", responses((status = OK)))] +#[get("/report/{id}")] +async fn report_get_route( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + info: web::Path<(crate::models::ids::ReportId,)>, + session_queue: web::Data, +) -> Result { + report_get(req, pool, redis, info, session_queue).await +} + pub async fn report_get( req: HttpRequest, pool: web::Data, @@ -395,13 +443,26 @@ pub async fn report_get( } } -#[derive(Deserialize, Validate)] +#[derive(Deserialize, Validate, utoipa::ToSchema)] pub struct EditReport { #[validate(length(max = 65536))] pub body: Option, pub closed: Option, } +#[utoipa::path(tag = "reports", responses((status = NO_CONTENT)))] +#[patch("/report/{id}")] +async fn report_edit_route( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + info: web::Path<(crate::models::ids::ReportId,)>, + session_queue: web::Data, + edit_report: web::Json, +) -> Result { + report_edit(req, pool, redis, info, session_queue, edit_report).await +} + pub async fn report_edit( req: HttpRequest, pool: web::Data, @@ -511,6 +572,18 @@ pub async fn report_edit( } } +#[utoipa::path(tag = "reports", responses((status = NO_CONTENT)))] +#[delete("/report/{id}")] +async fn report_delete_route( + req: HttpRequest, + pool: web::Data, + info: web::Path<(crate::models::ids::ReportId,)>, + redis: web::Data, + session_queue: web::Data, +) -> Result { + report_delete(req, pool, info, redis, session_queue).await +} + pub async fn report_delete( req: HttpRequest, pool: web::Data, @@ -555,3 +628,15 @@ pub async fn report_delete( Err(ApiError::NotFound) } } + +#[derive(utoipa::OpenApi)] +#[openapi(paths( + report_create_route, + reports_route, + reports_get_route, + report_get_route, + report_edit_route, + report_delete_route, +))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; diff --git a/apps/labrinth/src/routes/v3/shared_instance_version_creation.rs b/apps/labrinth/src/routes/v3/shared_instance_version_creation.rs index 9fdc289025..602d9a23dc 100644 --- a/apps/labrinth/src/routes/v3/shared_instance_version_creation.rs +++ b/apps/labrinth/src/routes/v3/shared_instance_version_creation.rs @@ -20,7 +20,7 @@ use crate::routes::v3::project_creation::UploadedFile; use crate::util::ext::MRPACK_MIME_TYPE; use actix_web::http::header::ContentLength; use actix_web::web::Data; -use actix_web::{HttpRequest, HttpResponse, web}; +use actix_web::{HttpRequest, HttpResponse, post, web}; use bytes::BytesMut; use chrono::Utc; use futures_util::StreamExt; @@ -29,13 +29,30 @@ use hex::FromHex; const MAX_FILE_SIZE: usize = 500 * 1024 * 1024; const MAX_FILE_SIZE_TEXT: &str = "500 MB"; -pub fn config(cfg: &mut web::ServiceConfig) { - cfg.route( - "shared-instance/{id}/version", - web::post().to(shared_instance_version_create), - ); +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { + cfg.service(shared_instance_version_create); } +/// Create a shared instance version. +#[utoipa::path( + tag = "versions", + post, + params(("id" = SharedInstanceId, Path, description = "The ID of the shared instance")), + responses( + (status = 201, description = "Expected response to a valid request", body = SharedInstanceVersion), + (status = 400, description = "Request was invalid, see given error"), + ( + status = 401, + description = "Incorrect token scopes or no authorization to access the requested item(s)" + ), + ( + status = 404, + description = "The requested item(s) were not found or no authorization to access the requested item(s)" + ) + ), + security(("bearer_auth" = ["SHARED_INSTANCE_VERSION_CREATE"])) +)] +#[post("/shared-instance/{id}/version")] #[allow(clippy::too_many_arguments)] pub async fn shared_instance_version_create( req: HttpRequest, @@ -197,3 +214,8 @@ async fn shared_instance_version_create_inner( let version: SharedInstanceVersion = new_version.into(); Ok(HttpResponse::Created().json(version)) } + +#[derive(utoipa::OpenApi)] +#[openapi(paths(shared_instance_version_create,))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; diff --git a/apps/labrinth/src/routes/v3/shared_instances.rs b/apps/labrinth/src/routes/v3/shared_instances.rs index 3768777029..cdba321516 100644 --- a/apps/labrinth/src/routes/v3/shared_instances.rs +++ b/apps/labrinth/src/routes/v3/shared_instances.rs @@ -19,30 +19,21 @@ use crate::queue::session::AuthQueue; use crate::routes::ApiError; use crate::util::routes::read_typed_from_payload; use actix_web::web::{Data, Redirect}; -use actix_web::{HttpRequest, HttpResponse, web}; +use actix_web::{HttpRequest, HttpResponse, delete, get, patch, post, web}; use futures_util::future::try_join_all; use serde::Deserialize; use validator::Validate; -pub fn config(cfg: &mut web::ServiceConfig) { - cfg.route("shared-instance", web::post().to(shared_instance_create)); - cfg.route("shared-instance", web::get().to(shared_instance_list)); - cfg.service( - web::scope("shared-instance") - .route("{id}", web::get().to(shared_instance_get)) - .route("{id}", web::patch().to(shared_instance_edit)) - .route("{id}", web::delete().to(shared_instance_delete)) - .route("{id}/version", web::get().to(shared_instance_version_list)), - ); - cfg.service( - web::scope("shared-instance-version") - .route("{id}", web::get().to(shared_instance_version_get)) - .route("{id}", web::delete().to(shared_instance_version_delete)) - .route( - "{id}/download", - web::get().to(shared_instance_version_download), - ), - ); +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { + cfg.service(shared_instance_create) + .service(shared_instance_list) + .service(shared_instance_get) + .service(shared_instance_edit) + .service(shared_instance_delete) + .service(shared_instance_version_list) + .service(shared_instance_version_get) + .service(shared_instance_version_delete) + .service(shared_instance_version_download); } #[derive(Deserialize, Validate)] @@ -56,6 +47,8 @@ pub struct CreateSharedInstance { pub public: bool, } +#[utoipa::path(tag = "shared instances", responses((status = OK)))] +#[post("/shared-instance")] pub async fn shared_instance_create( req: HttpRequest, pool: Data, @@ -101,6 +94,8 @@ pub async fn shared_instance_create( })) } +#[utoipa::path(tag = "shared instances", responses((status = OK)))] +#[get("/shared-instance")] pub async fn shared_instance_list( req: HttpRequest, pool: Data, @@ -148,6 +143,8 @@ pub async fn shared_instance_list( Ok(HttpResponse::Ok().json(instances)) } +#[utoipa::path(tag = "shared instances", responses((status = OK)))] +#[get("/shared-instance/{id}")] pub async fn shared_instance_get( req: HttpRequest, pool: Data, @@ -219,6 +216,8 @@ pub struct EditSharedInstance { pub public: Option, } +#[utoipa::path(tag = "shared instances", responses((status = NO_CONTENT)))] +#[patch("/shared-instance/{id}")] pub async fn shared_instance_edit( req: HttpRequest, pool: Data, @@ -299,6 +298,8 @@ pub async fn shared_instance_edit( Ok(HttpResponse::NoContent().body("")) } +#[utoipa::path(tag = "shared instances", responses((status = NO_CONTENT)))] +#[delete("/shared-instance/{id}")] pub async fn shared_instance_delete( req: HttpRequest, pool: Data, @@ -355,6 +356,8 @@ pub async fn shared_instance_delete( Ok(HttpResponse::NoContent().body("")) } +#[utoipa::path(tag = "shared instances", responses((status = OK)))] +#[get("/shared-instance/{id}/version")] pub async fn shared_instance_version_list( req: HttpRequest, pool: Data, @@ -401,6 +404,8 @@ pub async fn shared_instance_version_list( } } +#[utoipa::path(tag = "shared instances", responses((status = OK)))] +#[get("/shared-instance-version/{id}")] pub async fn shared_instance_version_get( req: HttpRequest, pool: Data, @@ -461,6 +466,8 @@ async fn can_access_instance_as_maybe_user( })) } +#[utoipa::path(tag = "shared instances", responses((status = NO_CONTENT)))] +#[delete("/shared-instance-version/{id}")] pub async fn shared_instance_version_delete( req: HttpRequest, pool: Data, @@ -560,6 +567,8 @@ async fn delete_instance_version( Ok(()) } +#[utoipa::path(tag = "shared instances", responses((status = TEMPORARY_REDIRECT)))] +#[get("/shared-instance-version/{id}/download")] pub async fn shared_instance_version_download( req: HttpRequest, pool: Data, @@ -609,3 +618,18 @@ pub async fn shared_instance_version_download( Err(ApiError::NotFound) } } + +#[derive(utoipa::OpenApi)] +#[openapi(paths( + shared_instance_create, + shared_instance_list, + shared_instance_get, + shared_instance_edit, + shared_instance_delete, + shared_instance_version_list, + shared_instance_version_get, + shared_instance_version_delete, + shared_instance_version_download, +))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; diff --git a/apps/labrinth/src/routes/v3/statistics.rs b/apps/labrinth/src/routes/v3/statistics.rs index a0f7141d9d..a14183e14f 100644 --- a/apps/labrinth/src/routes/v3/statistics.rs +++ b/apps/labrinth/src/routes/v3/statistics.rs @@ -1,9 +1,9 @@ use crate::database::PgPool; use crate::routes::ApiError; -use actix_web::{HttpResponse, web}; +use actix_web::{HttpResponse, get, web}; -pub fn config(cfg: &mut web::ServiceConfig) { - cfg.route("statistics", web::get().to(get_stats)); +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { + cfg.service(get_stats_route); } #[derive(serde::Serialize, serde::Deserialize)] @@ -14,6 +14,14 @@ pub struct V3Stats { pub files: Option, } +#[utoipa::path(tag = "statistics", responses((status = OK)))] +#[get("/statistics")] +async fn get_stats_route( + pool: web::Data, +) -> Result { + get_stats(pool).await +} + pub async fn get_stats( pool: web::Data, ) -> Result { @@ -92,3 +100,8 @@ pub async fn get_stats( Ok(HttpResponse::Ok().json(v3_stats)) } + +#[derive(utoipa::OpenApi)] +#[openapi(paths(get_stats_route,))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; diff --git a/apps/labrinth/src/routes/v3/tags.rs b/apps/labrinth/src/routes/v3/tags.rs index 175ee06016..5d46e3d5c3 100644 --- a/apps/labrinth/src/routes/v3/tags.rs +++ b/apps/labrinth/src/routes/v3/tags.rs @@ -8,25 +8,22 @@ use crate::database::models::loader_fields::{ Game, Loader, LoaderField, LoaderFieldEnumValue, LoaderFieldType, }; use crate::database::redis::RedisPool; -use actix_web::{HttpResponse, web}; +use actix_web::{HttpResponse, get, web}; use crate::database::PgPool; use itertools::Itertools; use serde_json::Value; -pub fn config(cfg: &mut web::ServiceConfig) { - cfg.service( - web::scope("tag") - .route("category", web::get().to(category_list)) - .route("loader", web::get().to(loader_list)), - ) - .route("games", web::get().to(games_list)) - .route("loader_field", web::get().to(loader_fields_list)) - .route("license", web::get().to(license_list)) - .route("license/{id}", web::get().to(license_text)) - .route("link_platform", web::get().to(link_platform_list)) - .route("report_type", web::get().to(report_type_list)) - .route("project_type", web::get().to(project_type_list)); +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { + cfg.service(category_list_route) + .service(loader_list_route) + .service(games_list_route) + .service(loader_fields_list_route) + .service(license_list_route) + .service(license_text_route) + .service(link_platform_list_route) + .service(report_type_list_route) + .service(project_type_list_route); } #[derive(serde::Serialize, serde::Deserialize)] @@ -37,6 +34,15 @@ pub struct GameData { pub banner: Option, } +#[utoipa::path(tag = "tags", responses((status = OK)))] +#[get("/games")] +async fn games_list_route( + pool: web::Data, + redis: web::Data, +) -> Result { + games_list(pool, redis).await +} + pub async fn games_list( pool: web::Data, redis: web::Data, @@ -63,6 +69,15 @@ pub struct CategoryData { pub header: String, } +#[utoipa::path(tag = "tags", responses((status = OK)))] +#[get("/tag/category")] +async fn category_list_route( + pool: web::Data, + redis: web::Data, +) -> Result { + category_list(pool, redis).await +} + pub async fn category_list( pool: web::Data, redis: web::Data, @@ -91,6 +106,15 @@ pub struct LoaderData { pub metadata: Value, } +#[utoipa::path(tag = "tags", responses((status = OK)))] +#[get("/tag/loader")] +async fn loader_list_route( + pool: web::Data, + redis: web::Data, +) -> Result { + loader_list(pool, redis).await +} + pub async fn loader_list( pool: web::Data, redis: web::Data, @@ -131,6 +155,16 @@ pub struct LoaderFieldsEnumQuery { } // Provides the variants for any enumerable loader field. +#[utoipa::path(tag = "tags", responses((status = OK)))] +#[get("/loader_field")] +async fn loader_fields_list_route( + pool: web::Data, + web::Query(query): web::Query, + redis: web::Data, +) -> Result { + loader_fields_list(pool, web::Query(query), redis).await +} + pub async fn loader_fields_list( pool: web::Data, query: web::Query, @@ -181,6 +215,12 @@ pub struct License { pub name: String, } +#[utoipa::path(tag = "tags", responses((status = OK)))] +#[get("/license")] +async fn license_list_route() -> HttpResponse { + license_list().await +} + pub async fn license_list() -> HttpResponse { let licenses = spdx::identifiers::LICENSES; let mut results: Vec = Vec::with_capacity(licenses.len()); @@ -201,6 +241,14 @@ pub struct LicenseText { pub body: String, } +#[utoipa::path(tag = "tags", responses((status = OK)))] +#[get("/license/{id}")] +async fn license_text_route( + params: web::Path<(String,)>, +) -> Result { + license_text(params).await +} + pub async fn license_text( params: web::Path<(String,)>, ) -> Result { @@ -231,6 +279,15 @@ pub struct LinkPlatformQueryData { pub donation: bool, } +#[utoipa::path(tag = "tags", responses((status = OK)))] +#[get("/link_platform")] +async fn link_platform_list_route( + pool: web::Data, + redis: web::Data, +) -> Result { + link_platform_list(pool, redis).await +} + pub async fn link_platform_list( pool: web::Data, redis: web::Data, @@ -247,6 +304,15 @@ pub async fn link_platform_list( Ok(HttpResponse::Ok().json(results)) } +#[utoipa::path(tag = "tags", responses((status = OK)))] +#[get("/report_type")] +async fn report_type_list_route( + pool: web::Data, + redis: web::Data, +) -> Result { + report_type_list(pool, redis).await +} + pub async fn report_type_list( pool: web::Data, redis: web::Data, @@ -255,6 +321,15 @@ pub async fn report_type_list( Ok(HttpResponse::Ok().json(results)) } +#[utoipa::path(tag = "tags", responses((status = OK)))] +#[get("/project_type")] +async fn project_type_list_route( + pool: web::Data, + redis: web::Data, +) -> Result { + project_type_list(pool, redis).await +} + pub async fn project_type_list( pool: web::Data, redis: web::Data, @@ -262,3 +337,18 @@ pub async fn project_type_list( let results = ProjectType::list(&**pool, &redis).await?; Ok(HttpResponse::Ok().json(results)) } + +#[derive(utoipa::OpenApi)] +#[openapi(paths( + games_list_route, + category_list_route, + loader_list_route, + loader_fields_list_route, + license_list_route, + license_text_route, + link_platform_list_route, + report_type_list_route, + project_type_list_route, +))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; diff --git a/apps/labrinth/src/routes/v3/teams.rs b/apps/labrinth/src/routes/v3/teams.rs index 785e1df07c..03195d0bb4 100644 --- a/apps/labrinth/src/routes/v3/teams.rs +++ b/apps/labrinth/src/routes/v3/teams.rs @@ -13,26 +13,20 @@ use crate::models::teams::{OrganizationPermissions, ProjectPermissions}; use crate::queue::session::AuthQueue; use crate::routes::ApiError; use crate::util::error::Context; -use actix_web::{HttpRequest, HttpResponse, get, web}; +use actix_web::{HttpRequest, HttpResponse, delete, get, patch, post, web}; use ariadne::ids::UserId; use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; -pub fn config(cfg: &mut web::ServiceConfig) { - cfg.route("teams", web::get().to(teams_get)); - - cfg.service( - web::scope("team") - .route("{id}/members", web::get().to(team_members_get)) - .route("{id}/members/{user_id}", web::patch().to(edit_team_member)) - .route( - "{id}/members/{user_id}", - web::delete().to(remove_team_member), - ) - .route("{id}/members", web::post().to(add_team_member)) - .route("{id}/join", web::post().to(join_team)) - .route("{id}/owner", web::patch().to(transfer_ownership)), - ); +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { + cfg.service(teams_get_route) + .service(team_members_get_route) + .service(team_members_get_organization) + .service(edit_team_member_route) + .service(remove_team_member_route) + .service(add_team_member_route) + .service(join_team_route) + .service(transfer_ownership_route); } // Returns all members of a project, @@ -40,7 +34,8 @@ pub fn config(cfg: &mut web::ServiceConfig) { // also the members of the organization's team if the project is associated with an organization // (Unlike team_members_get_project, which only returns the members of the project's team) // They can be differentiated by the "organization_permissions" field being null or not -#[utoipa::path] +/// Get a project's team members. +#[utoipa::path(tag = "teams", responses((status = OK, body = Vec)))] #[get("/{project_id}/members")] async fn team_members_get_project( req: HttpRequest, @@ -136,6 +131,8 @@ pub async fn team_members_get_project_internal( } } +#[utoipa::path(tag = "teams", responses((status = OK, body = Vec)))] +#[get("/organization/{id}/members")] pub async fn team_members_get_organization( req: HttpRequest, info: web::Path<(String,)>, @@ -213,6 +210,18 @@ pub async fn team_members_get_organization( } // Returns all members of a team, but not necessarily those of a project-team's organization (unlike team_members_get_project) +#[utoipa::path(tag = "teams", responses((status = OK, body = Vec)))] +#[get("/team/{id}/members")] +async fn team_members_get_route( + req: HttpRequest, + info: web::Path<(TeamId,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + team_members_get(req, info, pool, redis, session_queue).await +} + pub async fn team_members_get( req: HttpRequest, info: web::Path<(TeamId,)>, @@ -278,6 +287,18 @@ pub struct TeamIds { pub ids: String, } +#[utoipa::path(tag = "teams", responses((status = OK)))] +#[get("/teams")] +async fn teams_get_route( + req: HttpRequest, + ids: web::Query, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + teams_get(req, ids, pool, redis, session_queue).await +} + pub async fn teams_get( req: HttpRequest, web::Query(ids): web::Query, @@ -348,6 +369,18 @@ pub async fn teams_get( Ok(HttpResponse::Ok().json(teams)) } +#[utoipa::path(tag = "teams", responses((status = NO_CONTENT)))] +#[post("/team/{id}/join")] +async fn join_team_route( + req: HttpRequest, + info: web::Path<(TeamId,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + join_team(req, info, pool, redis, session_queue).await +} + pub async fn join_team( req: HttpRequest, info: web::Path<(TeamId,)>, @@ -417,7 +450,7 @@ fn default_ordering() -> i64 { 0 } -#[derive(Serialize, Deserialize, Clone)] +#[derive(Serialize, Deserialize, Clone, utoipa::ToSchema)] pub struct NewTeamMember { pub user_id: UserId, #[serde(default = "default_role")] @@ -433,6 +466,19 @@ pub struct NewTeamMember { pub ordering: i64, } +#[utoipa::path(tag = "teams", responses((status = NO_CONTENT)))] +#[post("/team/{id}/members")] +async fn add_team_member_route( + req: HttpRequest, + info: web::Path<(TeamId,)>, + pool: web::Data, + new_member: web::Json, + redis: web::Data, + session_queue: web::Data, +) -> Result { + add_team_member(req, info, pool, new_member, redis, session_queue).await +} + pub async fn add_team_member( req: HttpRequest, info: web::Path<(TeamId,)>, @@ -678,7 +724,7 @@ pub async fn add_team_member( Ok(HttpResponse::NoContent().body("")) } -#[derive(Serialize, Deserialize, Clone)] +#[derive(Serialize, Deserialize, Clone, utoipa::ToSchema)] pub struct EditTeamMember { pub permissions: Option, pub organization_permissions: Option, @@ -687,6 +733,19 @@ pub struct EditTeamMember { pub ordering: Option, } +#[utoipa::path(tag = "teams", responses((status = NO_CONTENT)))] +#[patch("/team/{id}/members/{user_id}")] +async fn edit_team_member_route( + req: HttpRequest, + info: web::Path<(TeamId, String)>, + pool: web::Data, + edit_member: web::Json, + redis: web::Data, + session_queue: web::Data, +) -> Result { + edit_team_member(req, info, pool, edit_member, redis, session_queue).await +} + pub async fn edit_team_member( req: HttpRequest, info: web::Path<(TeamId, String)>, @@ -882,11 +941,24 @@ pub async fn edit_team_member( Ok(HttpResponse::NoContent().body("")) } -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] pub struct TransferOwnership { pub user_id: UserId, } +#[utoipa::path(tag = "teams", responses((status = NO_CONTENT)))] +#[patch("/team/{id}/owner")] +async fn transfer_ownership_route( + req: HttpRequest, + info: web::Path<(TeamId,)>, + pool: web::Data, + new_owner: web::Json, + redis: web::Data, + session_queue: web::Data, +) -> Result { + transfer_ownership(req, info, pool, new_owner, redis, session_queue).await +} + pub async fn transfer_ownership( req: HttpRequest, info: web::Path<(TeamId,)>, @@ -1072,6 +1144,18 @@ pub async fn transfer_ownership( Ok(HttpResponse::NoContent().body("")) } +#[utoipa::path(tag = "teams", responses((status = NO_CONTENT)))] +#[delete("/team/{id}/members/{user_id}")] +async fn remove_team_member_route( + req: HttpRequest, + info: web::Path<(TeamId, UserId)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + remove_team_member(req, info, pool, redis, session_queue).await +} + pub async fn remove_team_member( req: HttpRequest, info: web::Path<(TeamId, UserId)>, @@ -1225,3 +1309,35 @@ pub async fn remove_team_member( Err(ApiError::NotFound) } } + +#[derive(utoipa::OpenApi)] +#[openapi(paths( + team_members_get_project, + team_members_get_organization, + team_members_get_route, + teams_get_route, + join_team_route, + add_team_member_route, + edit_team_member_route, + transfer_ownership_route, + remove_team_member_route, +))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; + +#[derive(utoipa::OpenApi)] +#[openapi(paths(team_members_get_project,))] +pub(crate) struct ProjectRoutesDoc; + +#[derive(utoipa::OpenApi)] +#[openapi(paths( + team_members_get_organization, + team_members_get_route, + teams_get_route, + join_team_route, + add_team_member_route, + edit_team_member_route, + transfer_ownership_route, + remove_team_member_route, +))] +pub(crate) struct RootRoutesDoc; diff --git a/apps/labrinth/src/routes/v3/threads.rs b/apps/labrinth/src/routes/v3/threads.rs index 23ce400feb..724c84e123 100644 --- a/apps/labrinth/src/routes/v3/threads.rs +++ b/apps/labrinth/src/routes/v3/threads.rs @@ -16,20 +16,15 @@ use crate::models::threads::{MessageBody, Thread, ThreadType}; use crate::models::users::User; use crate::queue::session::AuthQueue; use crate::routes::ApiError; -use actix_web::{HttpRequest, HttpResponse, web}; +use actix_web::{HttpRequest, HttpResponse, delete, get, post, web}; use futures::TryStreamExt; use serde::Deserialize; -pub fn config(cfg: &mut web::ServiceConfig) { - cfg.service( - web::scope("thread") - .route("{id}", web::get().to(thread_get)) - .route("{id}", web::post().to(thread_send_message)), - ); - cfg.service( - web::scope("message").route("{id}", web::delete().to(message_delete)), - ); - cfg.route("threads", web::get().to(threads_get)); +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { + cfg.service(thread_get_route) + .service(thread_send_message_route) + .service(message_delete_route) + .service(threads_get_route); } pub async fn is_authorized_thread( @@ -267,6 +262,18 @@ pub async fn filter_authorized_threads( Ok(final_threads) } +#[utoipa::path(tag = "threads", responses((status = OK, body = Thread)))] +#[get("/thread/{id}")] +async fn thread_get_route( + req: HttpRequest, + info: web::Path<(ThreadId,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + thread_get(req, info, pool, redis, session_queue).await +} + pub async fn thread_get( req: HttpRequest, info: web::Path<(ThreadId,)>, @@ -324,6 +331,18 @@ pub struct ThreadIds { pub ids: String, } +#[utoipa::path(tag = "threads", responses((status = OK, body = Vec)))] +#[get("/threads")] +async fn threads_get_route( + req: HttpRequest, + ids: web::Query, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + threads_get(req, ids, pool, redis, session_queue).await +} + pub async fn threads_get( req: HttpRequest, web::Query(ids): web::Query, @@ -356,11 +375,25 @@ pub async fn threads_get( Ok(HttpResponse::Ok().json(threads)) } -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] pub struct NewThreadMessage { pub body: MessageBody, } +#[utoipa::path(tag = "threads", responses((status = NO_CONTENT)))] +#[post("/thread/{id}")] +async fn thread_send_message_route( + req: HttpRequest, + info: web::Path<(ThreadId,)>, + pool: web::Data, + new_message: web::Json, + redis: web::Data, + session_queue: web::Data, +) -> Result { + thread_send_message(req, info, pool, new_message, redis, session_queue) + .await +} + pub async fn thread_send_message( req: HttpRequest, info: web::Path<(ThreadId,)>, @@ -591,6 +624,19 @@ pub async fn thread_send_message_internal( } } +#[utoipa::path(tag = "threads", responses((status = NO_CONTENT)))] +#[delete("/message/{id}")] +async fn message_delete_route( + req: HttpRequest, + info: web::Path<(ThreadMessageId,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, + file_host: web::Data, +) -> Result { + message_delete(req, info, pool, redis, session_queue, file_host).await +} + pub async fn message_delete( req: HttpRequest, info: web::Path<(ThreadMessageId,)>, @@ -658,3 +704,13 @@ pub async fn message_delete( Err(ApiError::NotFound) } } + +#[derive(utoipa::OpenApi)] +#[openapi(paths( + thread_get_route, + threads_get_route, + thread_send_message_route, + message_delete_route, +))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; diff --git a/apps/labrinth/src/routes/v3/users.rs b/apps/labrinth/src/routes/v3/users.rs index c03a6088c8..305c82aaea 100644 --- a/apps/labrinth/src/routes/v3/users.rs +++ b/apps/labrinth/src/routes/v3/users.rs @@ -31,33 +31,29 @@ use crate::{ validate::validation_errors_to_string, }, }; -use actix_web::{HttpRequest, HttpResponse, web}; +use actix_web::{HttpRequest, HttpResponse, delete, get, patch, web}; use ariadne::ids::UserId; use serde::{Deserialize, Serialize}; use validator::Validate; -pub fn config(cfg: &mut web::ServiceConfig) { - cfg.route("user", web::get().to(user_auth_get)); - cfg.route("users", web::get().to(users_get)); - cfg.route("users/search", web::get().to(users_search)); - cfg.route("user_email", web::get().to(admin_user_email)); - - cfg.service( - web::scope("user") - .route("{user_id}/all-projects", web::get().to(all_projects)) - .route("{user_id}/projects", web::get().to(projects_list)) - .route("{id}/notes", web::patch().to(user_notes_edit)) - .route("{id}", web::get().to(user_get)) - .route("{user_id}/collections", web::get().to(collections_list)) - .route("{user_id}/organizations", web::get().to(orgs_list)) - .route("{id}", web::patch().to(user_edit)) - .route("{id}/icon", web::patch().to(user_icon_edit)) - .route("{id}/icon", web::delete().to(user_icon_delete)) - .route("{id}", web::delete().to(user_delete)) - .route("{id}/follows", web::get().to(user_follows)) - .route("{id}/notifications", web::get().to(user_notifications)) - .route("{id}/oauth_apps", web::get().to(get_user_clients)), - ); +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { + cfg.service(user_auth_get_route) + .service(users_get_route) + .service(users_search) + .service(admin_user_email) + .service(all_projects) + .service(projects_list_route) + .service(user_notes_edit) + .service(user_get_route) + .service(collections_list) + .service(orgs_list) + .service(user_edit_route) + .service(user_icon_edit_route) + .service(user_icon_delete_route) + .service(user_delete_route) + .service(user_follows_route) + .service(user_notifications_route) + .service(get_user_clients); } #[derive(Serialize)] @@ -71,6 +67,8 @@ pub struct UserEmailQuery { pub email: String, } +#[utoipa::path(tag = "users", responses((status = OK)))] +#[get("/user/{user_id}/all-projects")] pub async fn all_projects( req: HttpRequest, info: web::Path<(String,)>, @@ -200,6 +198,8 @@ pub async fn all_projects( })) } +#[utoipa::path(tag = "users", responses((status = OK)))] +#[get("/user_email")] pub async fn admin_user_email( req: HttpRequest, pool: web::Data, @@ -254,6 +254,18 @@ pub async fn admin_user_email( } } +#[utoipa::path(tag = "users", responses((status = OK)))] +#[get("/user/{user_id}/projects")] +async fn projects_list_route( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + projects_list(req, info, pool, redis, session_queue).await +} + pub async fn projects_list( req: HttpRequest, info: web::Path<(String,)>, @@ -291,6 +303,17 @@ pub async fn projects_list( } } +#[utoipa::path(tag = "users", responses((status = OK)))] +#[get("/user")] +async fn user_auth_get_route( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + user_auth_get(req, pool, redis, session_queue).await +} + pub async fn user_auth_get( req: HttpRequest, pool: web::Data, @@ -333,6 +356,8 @@ pub struct UserSearchQuery { pub query: String, } +#[utoipa::path(tag = "users", responses((status = OK)))] +#[get("/users/search")] pub async fn users_search( web::Query(query): web::Query, pool: web::Data, @@ -348,6 +373,18 @@ pub async fn users_search( Ok(web::Json(users)) } +#[utoipa::path(tag = "users", responses((status = OK)))] +#[get("/users")] +async fn users_get_route( + req: HttpRequest, + ids: web::Query, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + users_get(req, ids, pool, redis, session_queue).await +} + pub async fn users_get( req: HttpRequest, web::Query(ids): web::Query, @@ -396,6 +433,18 @@ pub async fn users_get( Ok(HttpResponse::Ok().json(users)) } +#[utoipa::path(tag = "users", responses((status = OK)))] +#[get("/user/{id}")] +async fn user_get_route( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + user_get(req, info, pool, redis, session_queue).await +} + pub async fn user_get( req: HttpRequest, info: web::Path<(String,)>, @@ -439,6 +488,8 @@ pub async fn user_get( } } +#[utoipa::path(tag = "users", responses((status = NO_CONTENT)))] +#[patch("/user/{id}/notes")] pub async fn user_notes_edit( req: HttpRequest, info: web::Path<(String,)>, @@ -506,6 +557,8 @@ pub async fn user_notes_edit( Ok(HttpResponse::NoContent().finish()) } +#[utoipa::path(tag = "users", responses((status = OK)))] +#[get("/user/{user_id}/collections")] pub async fn collections_list( req: HttpRequest, info: web::Path<(String,)>, @@ -545,6 +598,8 @@ pub async fn collections_list( } } +#[utoipa::path(tag = "users", responses((status = OK)))] +#[get("/user/{user_id}/organizations")] pub async fn orgs_list( req: HttpRequest, info: web::Path<(String,)>, @@ -640,7 +695,7 @@ pub async fn orgs_list( } } -#[derive(Serialize, Deserialize, Validate)] +#[derive(Serialize, Deserialize, Validate, utoipa::ToSchema)] pub struct EditUser { #[validate(length(min = 1, max = 39), regex(path = *crate::util::validate::RE_URL_SAFE))] pub username: Option, @@ -658,6 +713,19 @@ pub struct EditUser { pub allow_friend_requests: Option, } +#[utoipa::path(tag = "users", responses((status = NO_CONTENT)))] +#[patch("/user/{id}")] +async fn user_edit_route( + req: HttpRequest, + info: web::Path<(String,)>, + new_user: web::Json, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + user_edit(req, info, new_user, pool, redis, session_queue).await +} + pub async fn user_edit( req: HttpRequest, info: web::Path<(String,)>, @@ -827,6 +895,31 @@ pub struct Extension { } #[allow(clippy::too_many_arguments)] +#[utoipa::path(tag = "users", responses((status = NO_CONTENT)))] +#[patch("/user/{id}/icon")] +async fn user_icon_edit_route( + ext: web::Query, + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + file_host: web::Data, + payload: web::Payload, + session_queue: web::Data, +) -> Result { + user_icon_edit( + ext, + req, + info, + pool, + redis, + file_host, + payload, + session_queue, + ) + .await +} + pub async fn user_icon_edit( web::Query(ext): web::Query, req: HttpRequest, @@ -903,6 +996,19 @@ pub async fn user_icon_edit( } } +#[utoipa::path(tag = "users", responses((status = NO_CONTENT)))] +#[delete("/user/{id}/icon")] +async fn user_icon_delete_route( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + file_host: web::Data, + session_queue: web::Data, +) -> Result { + user_icon_delete(req, info, pool, redis, file_host, session_queue).await +} + pub async fn user_icon_delete( req: HttpRequest, info: web::Path<(String,)>, @@ -957,6 +1063,18 @@ pub async fn user_icon_delete( } } +#[utoipa::path(tag = "users", responses((status = NO_CONTENT)))] +#[delete("/user/{id}")] +async fn user_delete_route( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result<(), ApiError> { + user_delete(req, info, pool, redis, session_queue).await +} + pub async fn user_delete( req: HttpRequest, info: web::Path<(String,)>, @@ -1005,6 +1123,18 @@ pub async fn user_delete( } } +#[utoipa::path(tag = "users", responses((status = OK)))] +#[get("/user/{id}/follows")] +async fn user_follows_route( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + user_follows(req, info, pool, redis, session_queue).await +} + pub async fn user_follows( req: HttpRequest, info: web::Path<(String,)>, @@ -1047,6 +1177,18 @@ pub async fn user_follows( } } +#[utoipa::path(tag = "users", responses((status = OK)))] +#[get("/user/{id}/notifications")] +async fn user_notifications_route( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + user_notifications(req, info, pool, redis, session_queue).await +} + pub async fn user_notifications( req: HttpRequest, info: web::Path<(String,)>, @@ -1087,3 +1229,25 @@ pub async fn user_notifications( Err(ApiError::NotFound) } } + +#[derive(utoipa::OpenApi)] +#[openapi(paths( + all_projects, + admin_user_email, + projects_list_route, + user_auth_get_route, + users_search, + users_get_route, + user_get_route, + user_notes_edit, + collections_list, + orgs_list, + user_edit_route, + user_icon_edit_route, + user_icon_delete_route, + user_delete_route, + user_follows_route, + user_notifications_route, +))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; diff --git a/apps/labrinth/src/routes/v3/version_creation.rs b/apps/labrinth/src/routes/v3/version_creation.rs index 9749efee3c..7a46a0f95c 100644 --- a/apps/labrinth/src/routes/v3/version_creation.rs +++ b/apps/labrinth/src/routes/v3/version_creation.rs @@ -34,7 +34,7 @@ use crate::util::validate::validation_errors_to_string; use crate::validate::{ValidationResult, validate_file}; use actix_multipart::{Field, Multipart}; use actix_web::web::Data; -use actix_web::{HttpRequest, HttpResponse, web}; +use actix_web::{HttpRequest, HttpResponse, post, web}; use chrono::Utc; use futures::stream::StreamExt; use hex::ToHex; @@ -105,6 +105,50 @@ struct InitialFileData { } // under `/api/v1/version` +/// Create a version on an existing project. +#[utoipa::path( + tag = "versions", + post, + request_body( + content(("multipart/form-data")), + description = "Multipart payload containing `data` and uploaded files" + ), + responses( + (status = 200, description = "Expected response to a valid request", body = Version), + (status = 400, description = "Request was invalid, see given error"), + ( + status = 401, + description = "Incorrect token scopes or no authorization to access the requested item(s)" + ) + ), + security(("bearer_auth" = ["VERSION_CREATE"])) +)] +#[post("/version")] +pub async fn version_create_route( + req: HttpRequest, + payload: Multipart, + client: Data, + redis: Data, + file_host: Data, + session_queue: Data, + moderation_queue: web::Data, + http: web::Data, + search_state: Data, +) -> Result { + version_create( + req, + payload, + client, + redis, + file_host, + session_queue, + moderation_queue, + http, + search_state, + ) + .await +} + pub async fn version_create( req: HttpRequest, mut payload: Multipart, @@ -546,6 +590,54 @@ async fn version_create_inner( Ok((HttpResponse::Ok().json(response), project_id)) } +/// Add files to an existing version. +#[utoipa::path( + tag = "versions", + post, + params(("version_id" = VersionId, Path, description = "The ID of the version")), + request_body( + content(("multipart/form-data")), + description = "Multipart payload containing files to upload" + ), + responses( + (status = NO_CONTENT, description = "Expected response to a valid request"), + ( + status = 401, + description = "Incorrect token scopes or no authorization to access the requested item(s)" + ), + ( + status = 404, + description = "The requested item(s) were not found or no authorization to access the requested item(s)" + ) + ), + security(("bearer_auth" = ["VERSION_WRITE"])) +)] +#[post("/version/{version_id}/file")] +pub async fn upload_file_to_version_route( + req: HttpRequest, + url_data: web::Path<(VersionId,)>, + payload: Multipart, + client: Data, + redis: Data, + file_host: Data, + session_queue: web::Data, + http: web::Data, + search_state: Data, +) -> Result { + upload_file_to_version( + req, + url_data, + payload, + client, + redis, + file_host, + session_queue, + http, + search_state, + ) + .await +} + pub async fn upload_file_to_version( req: HttpRequest, url_data: web::Path<(VersionId,)>, @@ -1102,3 +1194,8 @@ pub fn try_create_version_fields( } Ok(version_fields) } + +#[derive(utoipa::OpenApi)] +#[openapi(paths(version_create_route, upload_file_to_version_route,))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; diff --git a/apps/labrinth/src/routes/v3/version_file.rs b/apps/labrinth/src/routes/v3/version_file.rs index 04c0d6f184..3651a57031 100644 --- a/apps/labrinth/src/routes/v3/version_file.rs +++ b/apps/labrinth/src/routes/v3/version_file.rs @@ -11,31 +11,66 @@ use crate::models::teams::ProjectPermissions; use crate::queue::session::AuthQueue; use crate::routes::internal::delphi; use crate::{database, models}; -use actix_web::{HttpRequest, HttpResponse, web}; +use actix_web::{HttpRequest, HttpResponse, delete, get, post, web}; use dashmap::DashMap; use futures::TryStreamExt; use itertools::Itertools; use serde::{Deserialize, Serialize}; use std::collections::HashMap; -pub fn config(cfg: &mut web::ServiceConfig) { - cfg.service( - web::scope("version_file") - .route("{version_id}", web::get().to(get_version_from_hash)) - .route("{version_id}/update", web::post().to(get_update_from_hash)) - .route("project", web::post().to(get_projects_from_hashes)) - .route("{version_id}", web::delete().to(delete_file)) - .route("{version_id}/download", web::get().to(download_version)), - ); - cfg.service( - web::scope("version_files") - // DEPRECATED - use `update_many` instead - // see `fn update_files` comment - .route("update", web::post().to(update_files)) - .route("update_many", web::post().to(update_files_many)) - .route("update_individual", web::post().to(update_individual_files)) - .route("", web::post().to(get_versions_from_hashes)), - ); +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { + cfg.service(get_version_from_hash_route) + .service(get_update_from_hash_route) + .service(get_projects_from_hashes_route) + .service(delete_file_route) + .service(download_version_route) + .service(update_files_route) + .service(update_files_many_route) + .service(update_individual_files_route) + .service(get_versions_from_hashes_route); +} + +/// Get version metadata by file hash. +#[utoipa::path( + tag = "version files", + get, + operation_id = "v3VersionFromHash", + params( + ( + "version_id" = String, + Path, + description = "The hexadecimal file hash" + ), + ( + "algorithm" = Option, + Query, + description = "Hash algorithm to use (sha1 or sha512)" + ), + ( + "version_id" = Option, + Query, + description = "Optional version ID when hash maps to multiple files" + ) + ), + responses( + (status = 200, description = "Expected response to a valid request", body = models::projects::Version), + ( + status = 404, + description = "The requested item(s) were not found or no authorization to access the requested item(s)" + ) + ) +)] +#[get("/version_file/{version_id}")] +async fn get_version_from_hash_route( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + hash_query: web::Query, + session_queue: web::Data, +) -> Result { + get_version_from_hash(req, info, pool, redis, hash_query, session_queue) + .await } pub async fn get_version_from_hash( @@ -89,7 +124,7 @@ pub async fn get_version_from_hash( } } -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, utoipa::ToSchema)] pub struct HashQuery { pub algorithm: Option, // Defaults to calculation based on size of hash pub version_id: Option, @@ -110,7 +145,7 @@ pub fn default_algorithm_from_hashes(hashes: &[String]) -> String { "sha1".into() } -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, utoipa::ToSchema)] pub struct UpdateData { pub loaders: Option>, pub version_types: Option>, @@ -123,6 +158,59 @@ pub struct UpdateData { pub loader_fields: Option>>, } +/// Get the latest matching version by file hash. +#[utoipa::path( + tag = "version files", + post, + operation_id = "v3UpdateFromHash", + params( + ( + "version_id" = String, + Path, + description = "The hexadecimal file hash" + ), + ( + "algorithm" = Option, + Query, + description = "Hash algorithm to use (sha1 or sha512)" + ), + ( + "version_id" = Option, + Query, + description = "Optional version ID when hash maps to multiple files" + ) + ), + request_body = UpdateData, + responses( + (status = 200, description = "Expected response to a valid request", body = models::projects::Version), + ( + status = 404, + description = "The requested item(s) were not found or no authorization to access the requested item(s)" + ) + ) +)] +#[post("/version_file/{version_id}/update")] +async fn get_update_from_hash_route( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + hash_query: web::Query, + update_data: web::Json, + session_queue: web::Data, +) -> Result { + get_update_from_hash( + req, + info, + pool, + redis, + hash_query, + update_data, + session_queue, + ) + .await +} + pub async fn get_update_from_hash( req: HttpRequest, info: web::Path<(String,)>, @@ -208,12 +296,37 @@ pub async fn get_update_from_hash( } // Requests above with multiple versions below -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] pub struct FileHashes { pub algorithm: Option, // Defaults to calculation based on size of hash pub hashes: Vec, } +/// Get versions by file hashes. +#[utoipa::path( + tag = "version files", + post, + operation_id = "v3VersionsFromHashes", + request_body = FileHashes, + responses( + (status = 200, description = "Expected response to a valid request", body = HashMap), + ( + status = 404, + description = "The requested item(s) were not found or no authorization to access the requested item(s)" + ) + ) +)] +#[post("/version_files")] +async fn get_versions_from_hashes_route( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + file_data: web::Json, + session_queue: web::Data, +) -> Result { + get_versions_from_hashes(req, pool, redis, file_data, session_queue).await +} + pub async fn get_versions_from_hashes( req: HttpRequest, pool: web::Data, @@ -269,6 +382,31 @@ pub async fn get_versions_from_hashes( Ok(HttpResponse::Ok().json(response)) } +/// Get projects by file hashes. +#[utoipa::path( + tag = "version files", + post, + operation_id = "v3ProjectsFromHashes", + request_body = FileHashes, + responses( + (status = 200, description = "Expected response to a valid request", body = HashMap), + ( + status = 404, + description = "The requested item(s) were not found or no authorization to access the requested item(s)" + ) + ) +)] +#[post("/version_file/project")] +async fn get_projects_from_hashes_route( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + file_data: web::Json, + session_queue: web::Data, +) -> Result { + get_projects_from_hashes(req, pool, redis, file_data, session_queue).await +} + pub async fn get_projects_from_hashes( req: HttpRequest, pool: web::Data, @@ -327,7 +465,7 @@ pub async fn get_projects_from_hashes( Ok(HttpResponse::Ok().json(response)) } -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] pub struct ManyUpdateData { pub algorithm: Option, // Defaults to calculation based on size of hash pub hashes: Vec, @@ -336,6 +474,26 @@ pub struct ManyUpdateData { pub version_types: Option>, } +/// Get latest matching versions by file hashes. +#[utoipa::path( + tag = "version files", + post, + operation_id = "v3UpdateFilesMany", + request_body = ManyUpdateData, + responses( + (status = 200, description = "Expected response to a valid request", body = HashMap>) + ) +)] +#[post("/version_files/update_many")] +async fn update_files_many_route( + pool: web::Data, + redis: web::Data, + update_data: web::Json, +) -> Result>>, ApiError> +{ + update_files_many(pool, redis, update_data).await +} + pub async fn update_files_many( pool: web::Data, redis: web::Data, @@ -368,6 +526,25 @@ pub async fn update_files_many( // This endpoint is kept for backwards compat, since it still works in 99% of // cases where H only maps to a single version, and for older clients. This // endpoint will only take the first version for each file hash. +/// Get the latest matching version by file hash. +#[utoipa::path( + tag = "version files", + post, + operation_id = "v3UpdateFiles", + request_body = ManyUpdateData, + responses( + (status = 200, description = "Expected response to a valid request", body = HashMap) + ) +)] +#[post("/version_files/update")] +async fn update_files_route( + pool: web::Data, + redis: web::Data, + update_data: web::Json, +) -> Result>, ApiError> { + update_files(pool, redis, update_data).await +} + pub async fn update_files( pool: web::Data, redis: web::Data, @@ -468,7 +645,7 @@ async fn update_files_internal( Ok(response) } -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, utoipa::ToSchema)] pub struct FileUpdateData { pub hash: String, pub loaders: Option>, @@ -476,12 +653,33 @@ pub struct FileUpdateData { pub version_types: Option>, } -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, utoipa::ToSchema)] pub struct ManyFileUpdateData { pub algorithm: Option, // Defaults to calculation based on size of hash pub hashes: Vec, } +/// Get latest matching versions by individual file filters. +#[utoipa::path( + tag = "version files", + post, + operation_id = "v3UpdateIndividualFiles", + request_body = ManyFileUpdateData, + responses( + (status = 200, description = "Expected response to a valid request", body = HashMap) + ) +)] +#[post("/version_files/update_individual")] +async fn update_individual_files_route( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + update_data: web::Json, + session_queue: web::Data, +) -> Result { + update_individual_files(req, pool, redis, update_data, session_queue).await +} + pub async fn update_individual_files( req: HttpRequest, pool: web::Data, @@ -603,6 +801,52 @@ pub async fn update_individual_files( } // under /api/v1/version_file/{hash} +/// Delete a file by hash. +#[utoipa::path( + tag = "version files", + delete, + operation_id = "v3DeleteFileFromHash", + params( + ( + "version_id" = String, + Path, + description = "The hexadecimal file hash" + ), + ( + "algorithm" = Option, + Query, + description = "Hash algorithm to use (sha1 or sha512)" + ), + ( + "version_id" = Option, + Query, + description = "Optional version ID to delete from" + ) + ), + responses( + (status = NO_CONTENT, description = "Expected response to a valid request"), + ( + status = 401, + description = "Incorrect token scopes or no authorization to access the requested item(s)" + ), + ( + status = 404, + description = "The requested item(s) were not found or no authorization to access the requested item(s)" + ) + ) +)] +#[delete("/version_file/{version_id}")] +async fn delete_file_route( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + hash_query: web::Query, + session_queue: web::Data, +) -> Result { + delete_file(req, info, pool, redis, hash_query, session_queue).await +} + pub async fn delete_file( req: HttpRequest, info: web::Path<(String,)>, @@ -740,12 +984,54 @@ pub async fn delete_file( } } -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, utoipa::ToSchema)] pub struct DownloadRedirect { pub url: String, } // under /api/v1/version_file/{hash}/download +/// Download a file by hash. +#[utoipa::path( + tag = "version files", + get, + operation_id = "v3DownloadVersionFromHash", + params( + ( + "version_id" = String, + Path, + description = "The hexadecimal file hash" + ), + ( + "algorithm" = Option, + Query, + description = "Hash algorithm to use (sha1 or sha512)" + ), + ( + "version_id" = Option, + Query, + description = "Optional version ID when hash maps to multiple files" + ) + ), + responses( + (status = 302, description = "Temporary redirect to file URL", body = DownloadRedirect), + ( + status = 404, + description = "The requested item(s) were not found or no authorization to access the requested item(s)" + ) + ) +)] +#[get("/version_file/{version_id}/download")] +async fn download_version_route( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + hash_query: web::Query, + session_queue: web::Data, +) -> Result { + download_version(req, info, pool, redis, hash_query, session_queue).await +} + pub async fn download_version( req: HttpRequest, info: web::Path<(String,)>, @@ -800,3 +1086,18 @@ pub async fn download_version( Err(ApiError::NotFound) } } + +#[derive(utoipa::OpenApi)] +#[openapi(paths( + get_version_from_hash_route, + get_update_from_hash_route, + get_versions_from_hashes_route, + get_projects_from_hashes_route, + update_files_many_route, + update_files_route, + update_individual_files_route, + delete_file_route, + download_version_route, +))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; diff --git a/apps/labrinth/src/routes/v3/versions.rs b/apps/labrinth/src/routes/v3/versions.rs index 0e322cde51..91d650ceeb 100644 --- a/apps/labrinth/src/routes/v3/versions.rs +++ b/apps/labrinth/src/routes/v3/versions.rs @@ -31,33 +31,33 @@ use crate::search::{SearchBackend, SearchState}; use crate::util::error::Context; use crate::util::img; use crate::util::validate::validation_errors_to_string; -use actix_web::{HttpRequest, HttpResponse, get, web}; +use actix_web::{HttpRequest, HttpResponse, delete, get, patch, web}; use ariadne::ids::base62_impl::parse_base62; use itertools::Itertools; use serde::{Deserialize, Serialize}; use validator::Validate; -pub fn config(cfg: &mut web::ServiceConfig) { - cfg.route( - "version", - web::post().to(super::version_creation::version_create), - ); - cfg.route("versions", web::get().to(versions_get)); - - cfg.service( - web::scope("version") - .route("{id}", web::get().to(version_get)) - .route("{id}", web::patch().to(version_edit)) - .route("{id}", web::delete().to(version_delete)) - .route( - "{version_id}/file", - web::post().to(super::version_creation::upload_file_to_version), - ), - ); +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { + cfg.service(super::version_creation::version_create_route) + .service(versions_get_route) + .service(version_get_route) + .service(version_edit_route) + .service(version_delete_route) + .service(super::version_creation::upload_file_to_version_route); } // Given a project ID/slug and a version slug -#[utoipa::path] +/// Get a project version. +#[utoipa::path( + tag = "versions", + responses( + (status = 200, description = "Expected response to a valid request", body = models::projects::Version), + ( + status = 404, + description = "The requested item(s) were not found or no authorization to access the requested item(s)" + ) + ) +)] #[get("/{project_id}/version/{slug}")] pub async fn version_project_get( req: HttpRequest, @@ -162,6 +162,32 @@ fn default_true() -> bool { true } +/// Get multiple versions by ID. +#[utoipa::path( + tag = "versions", + get, + params( + ("ids" = String, Query, description = "The JSON array of version IDs"), + ( + "include_changelog" = Option, + Query, + description = "Whether to include changelog fields" + ) + ), + responses((status = 200, description = "Expected response to a valid request", body = Vec)) +)] +#[get("/versions")] +async fn versions_get_route( + req: HttpRequest, + ids: web::Query, + pool: web::Data, + ro_pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + versions_get(req, ids, pool, ro_pool, redis, session_queue).await +} + pub async fn versions_get( req: HttpRequest, web::Query(ids): web::Query, @@ -211,6 +237,31 @@ pub async fn versions_get( Ok(HttpResponse::Ok().json(versions)) } +/// Get a version by ID. +#[utoipa::path( + tag = "versions", + get, + params(("id" = VersionId, Path, description = "The ID of the version")), + responses( + (status = 200, description = "Expected response to a valid request", body = models::projects::Version), + ( + status = 404, + description = "The requested item(s) were not found or no authorization to access the requested item(s)" + ) + ) +)] +#[get("/version/{id}")] +async fn version_get_route( + req: HttpRequest, + info: web::Path<(models::ids::VersionId,)>, + pool: web::Data, + ro_pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result, ApiError> { + version_get(req, info, pool, ro_pool, redis, session_queue).await +} + pub async fn version_get( req: HttpRequest, info: web::Path<(models::ids::VersionId,)>, @@ -330,6 +381,46 @@ pub struct EditVersionFileType { pub file_type: Option, } +/// Update an existing version. +#[utoipa::path( + tag = "versions", + patch, + params(("id" = VersionId, Path, description = "The ID of the version")), + responses( + (status = NO_CONTENT, description = "Expected response to a valid request"), + ( + status = 401, + description = "Incorrect token scopes or no authorization to access the requested item(s)" + ), + ( + status = 404, + description = "The requested item(s) were not found or no authorization to access the requested item(s)" + ) + ), + security(("bearer_auth" = ["VERSION_WRITE"])) +)] +#[patch("/version/{id}")] +async fn version_edit_route( + req: HttpRequest, + info: web::Path<(VersionId,)>, + pool: web::Data, + redis: web::Data, + new_version: web::Json, + session_queue: web::Data, + search_state: web::Data, +) -> Result { + version_edit( + req, + info, + pool, + redis, + new_version, + session_queue, + search_state, + ) + .await +} + pub async fn version_edit( req: HttpRequest, info: web::Path<(VersionId,)>, @@ -798,7 +889,26 @@ pub struct VersionListFilters { pub include_changelog: bool, } -#[utoipa::path] +/// List project versions. +#[utoipa::path( + tag = "versions", + params( + ("loaders" = Option, Query), + ("featured" = Option, Query), + ("version_type" = Option, Query), + ("limit" = Option, Query), + ("offset" = Option, Query), + ("loader_fields" = Option, Query), + ("include_changelog" = Option, Query) + ), + responses( + (status = 200, description = "Expected response to a valid request", body = Vec), + ( + status = 404, + description = "The requested item(s) were not found or no authorization to access the requested item(s)" + ) + ) +)] #[get("/{project_id}/version")] async fn version_list( req: HttpRequest, @@ -990,6 +1100,46 @@ pub async fn version_list_internal( } } +/// Delete a version by ID. +#[utoipa::path( + tag = "versions", + delete, + params(("id" = VersionId, Path, description = "The ID of the version")), + responses( + (status = NO_CONTENT, description = "Expected response to a valid request"), + ( + status = 401, + description = "Incorrect token scopes or no authorization to access the requested item(s)" + ), + ( + status = 404, + description = "The requested item(s) were not found or no authorization to access the requested item(s)" + ) + ), + security(("bearer_auth" = ["VERSION_DELETE"])) +)] +#[delete("/version/{id}")] +async fn version_delete_route( + req: HttpRequest, + info: web::Path<(VersionId,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, + search_backend: web::Data, + search_state: web::Data, +) -> Result { + version_delete( + req, + info, + pool, + redis, + session_queue, + search_backend, + search_state, + ) + .await +} + pub async fn version_delete( req: HttpRequest, info: web::Path<(VersionId,)>, @@ -1115,3 +1265,28 @@ pub async fn version_delete( Err(ApiError::NotFound) } } + +#[derive(utoipa::OpenApi)] +#[openapi(paths( + version_project_get, + versions_get_route, + version_get_route, + version_edit_route, + version_list, + version_delete_route, +))] +#[allow(dead_code)] +pub(crate) struct RouteDoc; + +#[derive(utoipa::OpenApi)] +#[openapi(paths(version_project_get, version_list,))] +pub(crate) struct ProjectRoutesDoc; + +#[derive(utoipa::OpenApi)] +#[openapi(paths( + versions_get_route, + version_get_route, + version_edit_route, + version_delete_route, +))] +pub(crate) struct RootRoutesDoc; diff --git a/apps/labrinth/src/search/mod.rs b/apps/labrinth/src/search/mod.rs index 9507aafc2e..cfef656bc1 100644 --- a/apps/labrinth/src/search/mod.rs +++ b/apps/labrinth/src/search/mod.rs @@ -285,7 +285,7 @@ pub struct UploadSearchProject { pub loader_fields: HashMap>, } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] pub struct SearchProjectDependency { pub project_id: String, pub dependency_type: DependencyType, @@ -294,7 +294,7 @@ pub struct SearchProjectDependency { pub icon_url: Option, } -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, ToSchema)] pub struct SearchResults { pub hits: Vec, pub page: usize, @@ -302,7 +302,7 @@ pub struct SearchResults { pub total_hits: usize, } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] pub struct ResultSearchProject { pub version_id: String, pub project_id: String, diff --git a/apps/labrinth/src/test/api_v2/mod.rs b/apps/labrinth/src/test/api_v2/mod.rs index 6ce5151881..3f00b17f1d 100644 --- a/apps/labrinth/src/test/api_v2/mod.rs +++ b/apps/labrinth/src/test/api_v2/mod.rs @@ -7,7 +7,6 @@ use crate::env::ENV; use actix_web::{App, dev::ServiceResponse, test}; use async_trait::async_trait; use std::rc::Rc; -use utoipa_actix_web::AppExt; pub mod project; pub mod request_data; @@ -25,12 +24,13 @@ pub struct ApiV2 { impl ApiBuildable for ApiV2 { async fn build(labrinth_config: LabrinthConfig) -> Self { let app = App::new() - .into_utoipa_app() .configure(|cfg| { - crate::utoipa_app_config(cfg, labrinth_config.clone()) + crate::app_data_config(cfg, labrinth_config.clone()) }) - .into_app() - .configure(|cfg| crate::app_config(cfg, labrinth_config.clone())); + .configure(|cfg| { + crate::app_routes_config(cfg, labrinth_config.clone()) + }) + .configure(crate::app_fallback_config); let test_app: Rc = Rc::new(test::init_service(app).await); diff --git a/apps/labrinth/src/test/api_v3/mod.rs b/apps/labrinth/src/test/api_v3/mod.rs index 3cfd373699..dfbb8b58e3 100644 --- a/apps/labrinth/src/test/api_v3/mod.rs +++ b/apps/labrinth/src/test/api_v3/mod.rs @@ -7,7 +7,6 @@ use crate::env::ENV; use actix_web::{App, dev::ServiceResponse, test}; use async_trait::async_trait; use std::rc::Rc; -use utoipa_actix_web::AppExt; pub mod collections; pub mod limits; @@ -30,12 +29,13 @@ pub struct ApiV3 { impl ApiBuildable for ApiV3 { async fn build(labrinth_config: LabrinthConfig) -> Self { let app = App::new() - .into_utoipa_app() .configure(|cfg| { - crate::utoipa_app_config(cfg, labrinth_config.clone()) + crate::app_data_config(cfg, labrinth_config.clone()) }) - .into_app() - .configure(|cfg| crate::app_config(cfg, labrinth_config.clone())); + .configure(|cfg| { + crate::app_routes_config(cfg, labrinth_config.clone()) + }) + .configure(crate::app_fallback_config); let test_app: Rc = Rc::new(test::init_service(app).await); diff --git a/apps/labrinth/tests/openapi_route_scan.rs b/apps/labrinth/tests/openapi_route_scan.rs new file mode 100644 index 0000000000..fbacb452d8 --- /dev/null +++ b/apps/labrinth/tests/openapi_route_scan.rs @@ -0,0 +1,117 @@ +use std::collections::BTreeSet; + +use utoipa::OpenApi; + +#[derive(OpenApi)] +struct DocsV3; + +#[derive(OpenApi)] +struct DocsInternal; + +fn collect_v3_paths() -> BTreeSet { + let docs = DocsV3::openapi() + .merge_from(labrinth::routes::PublicApiDoc::openapi()) + .merge_from(labrinth::routes::v3::ApiDoc::openapi()); + + docs.paths.paths.keys().cloned().collect() +} + +fn collect_internal_paths() -> BTreeSet { + let docs = DocsInternal::openapi() + .merge_from(labrinth::routes::internal::ApiDoc::openapi()); + + docs.paths.paths.keys().cloned().collect() +} + +fn assert_paths(paths: &BTreeSet, expected: &[&str]) { + let missing = expected + .iter() + .filter(|path| !paths.contains(**path)) + .copied() + .collect::>(); + + assert!( + missing.is_empty(), + "missing OpenAPI paths: {missing:#?}\nregistered paths:\n{}", + paths.iter().cloned().collect::>().join("\n") + ); +} + +#[test] +fn v3_openapi_includes_configured_routes() { + let paths = collect_v3_paths(); + + assert_paths( + &paths, + &[ + "/analytics/minecraft-server-play", + "/analytics/playtime", + "/analytics/view", + "/maven/maven/modrinth/{id}/maven-metadata.xml", + "/maven/maven/modrinth/{id}/{versionnum}/{file}", + "/updates/{id}/forge_updates.json", + "/v3/analytics", + "/v3/analytics/facets", + "/v3/collection", + "/v3/collection/{id}", + "/v3/content/resolve", + "/v3/games", + "/v3/image", + "/v3/limits/collections", + "/v3/notifications", + "/v3/oauth/app", + "/v3/organization", + "/v3/organization/{id}", + "/v3/project", + "/v3/project/{id}", + "/v3/project/{id}/gallery", + "/v3/project/{project_id}/dependencies", + "/v3/report", + "/v3/reports", + "/v3/shared-instance", + "/v3/shared-instance/{id}/version", + "/v3/statistics", + "/v3/tag/category", + "/v3/team/{id}/members", + "/v3/thread/{id}", + "/v3/user/{id}", + "/v3/users", + "/v3/version", + "/v3/version/{id}", + "/v3/version/{version_id}/file", + "/v3/version_file/{version_id}", + "/v3/version_files", + ], + ); +} + +#[test] +fn internal_openapi_includes_collapsed_route_groups() { + let paths = collect_internal_paths(); + + assert_paths( + &paths, + &[ + "/_internal/admin/_count-download", + "/_internal/affiliate", + "/_internal/attribution/assign", + "/_internal/auth/init", + "/_internal/billing/charge/{id}/refund", + "/_internal/delphi/ingest", + "/_internal/external_notifications", + "/_internal/gdpr/export", + "/_internal/globals", + "/_internal/gotenberg/success", + "/_internal/medal/verify", + "/_internal/moderation/project/{id}", + "/_internal/moderation/tech-review/report/{id}", + "/_internal/mural/bank-details", + "/_internal/pat", + "/_internal/search-management/tasks", + "/_internal/server-ping/minecraft-java", + "/_internal/session/list", + "/_internal/launcher_socket", + "/v3/analytics-event", + ], + ); +} diff --git a/packages/ariadne/src/ids.rs b/packages/ariadne/src/ids.rs index 6f91cecdd1..10e6ecb705 100644 --- a/packages/ariadne/src/ids.rs +++ b/packages/ariadne/src/ids.rs @@ -59,6 +59,21 @@ const MULTIPLES: [u64; 12] = [ #[derive(Copy, Clone, PartialEq, Eq)] pub struct Base62Id(pub u64); +impl utoipa::PartialSchema for Base62Id { + fn schema() -> utoipa::openapi::RefOr { + utoipa::openapi::ObjectBuilder::new() + .schema_type(utoipa::openapi::schema::Type::String) + .min_length(Some(8)) + .max_length(Some(8)) + .pattern(Some("^[A-Za-z0-9]{8}$")) + .examples([serde_json::json!("ABcd1234")]) + .build() + .into() + } +} + +impl utoipa::ToSchema for Base62Id {} + /// An error decoding a number from base62. #[derive(Error, Debug)] pub enum DecodingError { @@ -94,12 +109,20 @@ macro_rules! base62_id { serde::Deserialize, Debug, Hash, - utoipa::ToSchema, )] #[serde(from = "ariadne::ids::Base62Id")] #[serde(into = "ariadne::ids::Base62Id")] pub struct $struct(pub u64); + impl utoipa::PartialSchema for $struct { + fn schema() + -> utoipa::openapi::RefOr { + <$crate::ids::Base62Id as utoipa::PartialSchema>::schema() + } + } + + impl utoipa::ToSchema for $struct {} + $crate::ids::impl_base62_display!($struct); impl From<$crate::ids::Base62Id> for $struct { diff --git a/scripts/labrinth-openapi-check.ts b/scripts/labrinth-openapi-check.ts new file mode 100644 index 0000000000..2e75b75dbd --- /dev/null +++ b/scripts/labrinth-openapi-check.ts @@ -0,0 +1,1013 @@ +import chalk from 'chalk' +import * as fs from 'node:fs' +import * as path from 'node:path' +import { fileURLToPath } from 'node:url' + +const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'patch', 'head', 'options', 'trace'] as const +const BODY_EXTRACTORS = ['json', 'form', 'payload', 'bytes', 'multipart', 'text'] as const +const AUTH_HEADERS = { + authorization: 'Bearer mra_admin', + 'modrinth-admin': 'feedbeef', +} +const IGNORED_PROBE_OPERATIONS = new Set([ + 'POST /v2/admin/_force_reindex', + 'POST /v2/admin/_force_reindex/{project_id}', +]) + +type HttpMethod = (typeof HTTP_METHODS)[number] +type BodyExtractor = (typeof BODY_EXTRACTORS)[number] + +interface Args { + baseUrl: string + openapi?: string + sourceDir: string + probe: boolean + staticCheck: boolean + includeMutatingProbes: boolean + json: boolean + verbose: boolean +} + +interface OpenApiSpec { + paths?: Record> + components?: unknown +} + +interface OpenApiOperation { + operationId?: string + parameters?: OpenApiParameter[] + requestBody?: OpenApiRequestBody | { $ref: string } + responses?: unknown + [key: string]: unknown +} + +interface OpenApiParameter { + name?: string + in?: string + required?: boolean + schema?: OpenApiSchema +} + +interface OpenApiRequestBody { + required?: boolean + content?: Record +} + +interface OpenApiSchema { + type?: string | string[] + format?: string + $ref?: string + oneOf?: OpenApiSchema[] + anyOf?: OpenApiSchema[] + allOf?: OpenApiSchema[] +} + +interface Operation { + path: string + method: HttpMethod + operationId: string + operation: OpenApiOperation + pathParams: Set + queryParams: Set + requestBodyContentTypes: Set + hasRequestBody: boolean +} + +interface StructField { + name: string + type: string + optional: boolean +} + +interface StructInfo { + name: string + file: string + fields: StructField[] +} + +interface HandlerInfo { + operationId: string + functionName: string + file: string + line: number + importedTypes: Map + jsonTypes: string[] + queryTypes: string[] + pathTypes: string[] + formTypes: string[] + bodyExtractors: Set + routeMethods: Set + routePathParams: Set +} + +interface SourceExpectations { + handlers: HandlerInfo[] + structsByFileAndName: Map + structsByName: Map +} + +interface Issue { + severity: 'error' | 'warning' + code: string + message: string + file?: string + line?: number + operation?: string +} + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') + +function parseArgs(): Args { + const args = process.argv.slice(2) + const parsed: Args = { + baseUrl: 'http://127.0.0.1:8000', + sourceDir: path.join(repoRoot, 'apps/labrinth/src'), + probe: true, + staticCheck: true, + includeMutatingProbes: true, + json: false, + verbose: false, + } + + for (let i = 0; i < args.length; i++) { + const arg = args[i] + const next = () => { + const value = args[++i] + if (!value) throw new Error(`missing value for ${arg}`) + return value + } + + switch (arg) { + case '--base-url': + parsed.baseUrl = next().replace(/\/+$/, '') + break + case '--openapi': + parsed.openapi = next() + break + case '--source': + parsed.sourceDir = path.resolve(next()) + break + case '--no-probe': + parsed.probe = false + break + case '--no-static': + parsed.staticCheck = false + break + case '--safe-probes': + parsed.includeMutatingProbes = false + break + case '--include-mutating-probes': + parsed.includeMutatingProbes = true + break + case '--json': + parsed.json = true + break + case '--verbose': + parsed.verbose = true + break + case '--help': + case '-h': + printUsage() + process.exit(0) + default: + throw new Error(`unknown argument ${arg}`) + } + } + + return parsed +} + +function printUsage() { + console.log(`Usage: pnpm scripts labrinth-openapi-check [options] + +Options: + --base-url Running Labrinth URL. Default: http://127.0.0.1:8000 + --openapi OpenAPI JSON file or URL. Default: /docs + --source Labrinth source directory. Default: apps/labrinth/src + --no-probe Skip live route reachability checks + --no-static Skip Rust handler signature checks + --safe-probes Only probe GET/HEAD routes + --include-mutating-probes + Probe non-GET/HEAD routes. Enabled by default. + --json Print machine-readable JSON + --verbose Print skipped static comparisons`) +} + +async function main() { + const args = parseArgs() + const spec = await loadSpec(args) + const operations = collectOperations(spec) + const issues: Issue[] = [] + + if (args.probe) { + issues.push(...(await probeOperations(args.baseUrl, operations, args.includeMutatingProbes))) + } + + if (args.staticCheck) { + const source = collectSourceExpectations(args.sourceDir) + issues.push(...compareSourceToSpec(operations, source, args.verbose)) + } + + if (args.json) { + console.log(JSON.stringify({ operations: operations.length, issues }, null, 2)) + } else { + printReport(operations, issues) + } + + if (issues.some((issue) => issue.severity === 'error')) { + process.exit(1) + } +} + +async function loadSpec(args: Args): Promise { + const source = args.openapi ?? `${args.baseUrl}/docs` + const text = await readText(source) + const trimmed = text.trim() + + if (trimmed.startsWith('{')) { + return JSON.parse(trimmed) + } + + const sourceUrls = [...trimmed.matchAll(/"url"\s*:\s*"([^"]+\.json)"/g)].map((match) => match[1]) + if (sourceUrls.length) { + const specs = await Promise.all( + sourceUrls.map(async (url) => { + const resolved = resolveSource(url, source) + const specText = await readText(resolved) + return JSON.parse(specText) as OpenApiSpec + }), + ) + + return mergeSpecs(specs) + } + + const scalarScript = trimmed.match( + /]*\bid=(["'])api-reference\1[^>]*>([\s\S]*?)<\/script>/i, + ) + if (!scalarScript) { + throw new Error(`could not find OpenAPI JSON in ${source}`) + } + + return JSON.parse(decodeHtmlEntities(scalarScript[2].trim())) +} + +function resolveSource(source: string, baseSource: string): string { + if (/^https?:\/\//.test(source)) return source + + if (/^https?:\/\//.test(baseSource)) { + return new URL(source, baseSource).toString() + } + + return path.resolve(path.dirname(path.resolve(baseSource)), source) +} + +function mergeSpecs(specs: OpenApiSpec[]): OpenApiSpec { + const merged: OpenApiSpec = { paths: {} } + + for (const spec of specs) { + Object.assign(merged.paths ?? {}, spec.paths ?? {}) + } + + return merged +} + +async function readText(source: string): Promise { + if (/^https?:\/\//.test(source)) { + const response = await fetch(source, { headers: AUTH_HEADERS }) + if (!response.ok) { + throw new Error(`failed to fetch ${source}: HTTP ${response.status}`) + } + return response.text() + } + + return fs.readFileSync(path.resolve(source), 'utf8') +} + +function decodeHtmlEntities(value: string): string { + return value + .replace(/"/g, '"') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/'/g, "'") + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&') +} + +function collectOperations(spec: OpenApiSpec): Operation[] { + const operations: Operation[] = [] + + for (const [apiPath, pathItem] of Object.entries(spec.paths ?? {})) { + for (const method of HTTP_METHODS) { + const operation = pathItem[method] + if (!operation) continue + + const parameters = operation.parameters ?? [] + const requestBody = + operation.requestBody && !('$ref' in operation.requestBody) + ? operation.requestBody + : undefined + + operations.push({ + path: apiPath, + method, + operationId: operation.operationId ?? `${method} ${apiPath}`, + operation, + pathParams: new Set( + parameters + .filter((param) => param.in === 'path' && param.name) + .map((param) => param.name as string), + ), + queryParams: new Set( + parameters + .filter((param) => param.in === 'query' && param.name) + .map((param) => param.name as string), + ), + requestBodyContentTypes: new Set(Object.keys(requestBody?.content ?? {})), + hasRequestBody: Boolean(requestBody), + }) + } + } + + return operations.sort((a, b) => `${a.path} ${a.method}`.localeCompare(`${b.path} ${b.method}`)) +} + +async function probeOperations( + baseUrl: string, + operations: Operation[], + includeMutatingProbes: boolean, +): Promise { + const issues: Issue[] = [] + + for (const operation of operations) { + if (IGNORED_PROBE_OPERATIONS.has(operationKey(operation))) { + continue + } + + if (!includeMutatingProbes && !['get', 'head'].includes(operation.method)) { + continue + } + + const url = new URL(renderProbePath(operation), `${baseUrl}/`) + const { body, contentType } = bodyForProbe(operation) + const headers: Record = { + ...AUTH_HEADERS, + accept: 'application/json', + 'user-agent': 'modrinth-labrinth-openapi-check', + } + if (contentType) headers['content-type'] = contentType + + let response: Response + let text = '' + try { + response = await fetch(url, { + method: operation.method.toUpperCase(), + headers, + body, + redirect: 'manual', + }) + text = await response.text() + } catch (error) { + issues.push({ + severity: 'error', + code: 'probe_failed', + operation: formatOperation(operation), + message: `could not probe ${url}: ${String(error)}`, + }) + continue + } + + if (isActixMissingRoute(response, text)) { + issues.push({ + severity: 'error', + code: 'documented_route_unreachable', + operation: formatOperation(operation), + message: `${operation.method.toUpperCase()} ${operation.path} is in OpenAPI but the running backend returned the fallback 404`, + }) + } + } + + return issues +} + +function renderProbePath(operation: Operation): string { + return operation.path.replace(/\{([^}]+)\}/g, (_, name: string) => + encodeURIComponent(samplePathValue(name, operation)), + ) +} + +function samplePathValue(name: string, operation: Operation): string { + const param = (operation.operation.parameters ?? []).find( + (candidate) => candidate.in === 'path' && candidate.name === name, + ) + const schema = param?.schema + const ref = schema?.$ref?.split('/').at(-1)?.toLowerCase() ?? '' + const type = Array.isArray(schema?.type) ? schema?.type.join('|') : schema?.type + + if (name.toLowerCase().includes('sha1')) return '0000000000000000000000000000000000000000' + if (schema?.format === 'uuid') return '00000000-0000-0000-0000-000000000000' + if (type === 'integer' || ref.endsWith('id') || name.toLowerCase().endsWith('id')) return '1' + return 'test' +} + +function bodyForProbe(operation: Operation): { body?: BodyInit; contentType?: string } { + if (!operation.hasRequestBody) return {} + + if (operation.requestBodyContentTypes.has('application/json')) { + return { body: '{}', contentType: 'application/json' } + } + if (operation.requestBodyContentTypes.has('text/plain')) { + return { body: 'test', contentType: 'text/plain' } + } + if ( + [...operation.requestBodyContentTypes].some((type) => type.startsWith('multipart/form-data')) + ) { + return { body: '', contentType: 'multipart/form-data; boundary=openapi-check' } + } + + const first = [...operation.requestBodyContentTypes][0] + return { body: '', contentType: first } +} + +function isActixMissingRoute(response: Response, text: string): boolean { + if (response.status !== 404) return false + + try { + const parsed = JSON.parse(text) + return ( + parsed?.error === 'not_found' && parsed?.description === 'the requested route does not exist' + ) + } catch { + return false + } +} + +function collectSourceExpectations(sourceDir: string): SourceExpectations { + const files = findRustFiles(sourceDir) + const structsByFileAndName = new Map() + const structsByName = new Map() + const handlers: HandlerInfo[] = [] + + for (const file of files) { + const content = fs.readFileSync(file, 'utf8') + for (const structInfo of extractStructs(content, file)) { + structsByFileAndName.set(structKey(file, structInfo.name), structInfo) + const namedStructs = structsByName.get(structInfo.name) ?? [] + namedStructs.push(structInfo) + structsByName.set(structInfo.name, namedStructs) + } + handlers.push(...extractHandlers(content, file, sourceDir)) + } + + return { handlers, structsByFileAndName, structsByName } +} + +function findRustFiles(root: string): string[] { + const files: string[] = [] + + function walk(current: string) { + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const fullPath = path.join(current, entry.name) + if (entry.isDirectory()) { + if (!entry.name.startsWith('.') && entry.name !== 'target') walk(fullPath) + } else if (entry.isFile() && entry.name.endsWith('.rs')) { + files.push(fullPath) + } + } + } + + walk(root) + return files +} + +function extractStructs(content: string, file: string): StructInfo[] { + const structs: StructInfo[] = [] + const regex = + /((?:\s*#\[[\s\S]*?\]\s*)*)(?:pub(?:\([^)]*\))?\s+)?struct\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{/g + let match: RegExpExecArray | null + + while ((match = regex.exec(content))) { + const name = match[2] + const openBrace = content.indexOf('{', match.index + match[0].length - 1) + const closeBrace = findMatching(content, openBrace, '{', '}') + if (closeBrace === -1) continue + + const attrs = match[1] + const body = content.slice(openBrace + 1, closeBrace) + const renameAll = extractSerdeRenameAll(attrs) + const fields: StructField[] = [] + const fieldRegex = + /((?:\s*#\[[^\]]*\]\s*)*)\s*pub(?:\([^)]*\))?\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*([^,\n]+(?:<[^;{}]*>)?)/g + let fieldMatch: RegExpExecArray | null + + while ((fieldMatch = fieldRegex.exec(body))) { + const fieldAttrs = fieldMatch[1] + if (/\bskip_deserializing\b|\bskip\b/.test(fieldAttrs)) continue + + const rustName = fieldMatch[2] + const rustType = fieldMatch[3].trim() + fields.push({ + name: extractSerdeRename(fieldAttrs) ?? applyRenameAll(rustName, renameAll), + type: rustType, + optional: /^Option\s*() + const routePathParams = new Set() + + for (const routeAttr of routeAttrs) { + const method = routeAttr[1] + const body = routeAttr[2] + if (method === 'route') { + for (const methodMatch of body.matchAll(/method\s*=\s*"([A-Z]+)"/g)) { + const routeMethod = methodMatch[1].toLowerCase() as HttpMethod + if (HTTP_METHODS.includes(routeMethod)) routeMethods.add(routeMethod) + } + } else { + routeMethods.add(method as HttpMethod) + } + + const pathMatch = body.match(/"([^"]+)"/) + if (pathMatch) { + for (const paramMatch of pathMatch[1].matchAll(/\{([^}:]+)(?::[^}]+)?\}/g)) { + routePathParams.add(paramMatch[1]) + } + } + } + + const operationId = attrText.match(/\boperation_id\s*=\s*"([^"]+)"/)?.[1] ?? fnName + const jsonTypes = extractGenericExtractorTypes(params, 'Json') + const queryTypes = extractGenericExtractorTypes(params, 'Query') + const pathTypes = extractGenericExtractorTypes(params, 'Path') + const formTypes = extractGenericExtractorTypes(params, 'Form') + const bodyExtractors = new Set() + + if (jsonTypes.length) bodyExtractors.add('json') + if (formTypes.length) bodyExtractors.add('form') + if (/\b(?:web::)?Payload\b/.test(params)) bodyExtractors.add('payload') + if (/\b(?:web::)?Bytes\b/.test(params)) bodyExtractors.add('bytes') + if (/\bMultipart\b/.test(params)) bodyExtractors.add('multipart') + if (/(?:^|,)\s*(?:[A-Za-z_][A-Za-z0-9_]*\s*:\s*)?String\s*(?:,|$)/.test(params)) { + bodyExtractors.add('text') + } + + handlers.push({ + operationId, + functionName: fnName, + file, + line: lineNumber(content, attrStart), + importedTypes, + jsonTypes, + queryTypes, + pathTypes, + formTypes, + bodyExtractors, + routeMethods, + routePathParams, + }) + + index = parenEnd + 1 + } + + return handlers +} + +function extractImportedTypeFiles(content: string, sourceDir: string): Map { + const importedTypes = new Map() + + for (const importMatch of content.matchAll(/use\s+crate::([\w:]+)::\{([^}]+)\};/g)) { + const moduleFile = resolveModuleFile(sourceDir, importMatch[1]) + if (!moduleFile) continue + + for (const rawItem of importMatch[2].split(',')) { + const item = rawItem.trim() + if (!item || item === 'self' || item.includes('::')) continue + + const simpleName = item.match(/^([A-Za-z_][A-Za-z0-9_]*)(?:\s+as\s+\w+)?$/)?.[1] + if (simpleName) importedTypes.set(simpleName, moduleFile) + } + } + + for (const importMatch of content.matchAll( + /use\s+crate::([\w:]+)::([A-Za-z_][A-Za-z0-9_]*)(?:\s+as\s+\w+)?;/g, + )) { + const moduleFile = resolveModuleFile(sourceDir, importMatch[1]) + if (moduleFile) importedTypes.set(importMatch[2], moduleFile) + } + + return importedTypes +} + +function resolveModuleFile(sourceDir: string, modulePath: string): string | null { + const relativePath = modulePath.replace(/::/g, path.sep) + const directFile = path.join(sourceDir, `${relativePath}.rs`) + if (fs.existsSync(directFile)) return directFile + + const modFile = path.join(sourceDir, relativePath, 'mod.rs') + if (fs.existsSync(modFile)) return modFile + + return null +} + +function extractGenericExtractorTypes(params: string, extractor: string): string[] { + const types: string[] = [] + const regex = new RegExp(`(?:web::)?${extractor}\\s*<`, 'g') + let match: RegExpExecArray | null + + while ((match = regex.exec(params))) { + const open = params.indexOf('<', match.index) + const close = findMatching(params, open, '<', '>') + if (close === -1) continue + + types.push(params.slice(open + 1, close).trim()) + regex.lastIndex = close + 1 + } + + return types +} + +function compareSourceToSpec( + operations: Operation[], + source: SourceExpectations, + verbose: boolean, +): Issue[] { + const issues: Issue[] = [] + const operationsById = new Map() + + for (const operation of operations) { + const existing = operationsById.get(operation.operationId) ?? [] + existing.push(operation) + operationsById.set(operation.operationId, existing) + } + + for (const handler of source.handlers) { + const candidates = operationsById.get(handler.operationId) ?? [] + if (!candidates.length) { + issues.push({ + severity: 'error', + code: 'annotated_handler_missing_from_openapi', + file: handler.file, + line: handler.line, + operation: handler.operationId, + message: `handler ${handler.functionName} has #[utoipa::path] but no OpenAPI operation with operationId ${handler.operationId}`, + }) + continue + } + + const operation = chooseOperation(handler, candidates) + if (!operation) { + if (verbose) { + issues.push({ + severity: 'warning', + code: 'duplicate_operation_id_skipped', + file: handler.file, + line: handler.line, + operation: handler.operationId, + message: `operationId ${handler.operationId} appears ${candidates.length} times, so static signature comparison was skipped`, + }) + } + continue + } + + compareBody(handler, operation, issues) + compareQuery(handler, operation, source, issues) + comparePath(handler, operation, issues) + } + + return issues +} + +function chooseOperation(handler: HandlerInfo, candidates: Operation[]): Operation | null { + if (candidates.length === 1) return candidates[0] + + const methodMatches = candidates.filter( + (operation) => !handler.routeMethods.size || handler.routeMethods.has(operation.method), + ) + if (methodMatches.length === 1) return methodMatches[0] + + const pathMatches = methodMatches.filter((operation) => { + for (const param of handler.routePathParams) { + if (!operation.pathParams.has(param)) return false + } + return true + }) + if (pathMatches.length === 1) return pathMatches[0] + + return null +} + +function compareBody(handler: HandlerInfo, operation: Operation, issues: Issue[]) { + const expectsBody = handler.bodyExtractors.size > 0 + + if (expectsBody && !operation.hasRequestBody) { + issues.push({ + severity: 'error', + code: 'missing_request_body', + file: handler.file, + line: handler.line, + operation: formatOperation(operation), + message: `${handler.functionName} expects ${[...handler.bodyExtractors].join(', ')} body data, but OpenAPI has no requestBody`, + }) + } + + if (!expectsBody && operation.hasRequestBody) { + issues.push({ + severity: 'error', + code: 'unexpected_request_body', + file: handler.file, + line: handler.line, + operation: formatOperation(operation), + message: `OpenAPI documents a requestBody, but ${handler.functionName} has no recognized body extractor`, + }) + } + + if ( + handler.bodyExtractors.has('json') && + !operation.requestBodyContentTypes.has('application/json') + ) { + issues.push({ + severity: 'error', + code: 'request_body_content_type_mismatch', + file: handler.file, + line: handler.line, + operation: formatOperation(operation), + message: `${handler.functionName} expects web::Json, but OpenAPI content types are ${formatSet(operation.requestBodyContentTypes)}`, + }) + } + + if (handler.bodyExtractors.has('text') && !operation.requestBodyContentTypes.has('text/plain')) { + issues.push({ + severity: 'error', + code: 'request_body_content_type_mismatch', + file: handler.file, + line: handler.line, + operation: formatOperation(operation), + message: `${handler.functionName} expects a plain String body, but OpenAPI content types are ${formatSet(operation.requestBodyContentTypes)}`, + }) + } +} + +function compareQuery( + handler: HandlerInfo, + operation: Operation, + source: SourceExpectations, + issues: Issue[], +) { + const expectedQueryParams = new Set() + + for (const queryType of handler.queryTypes) { + const simpleType = simpleRustType(queryType) + if (isDynamicExtractorType(simpleType)) continue + + const structInfo = resolveStructInfo(handler, simpleType, source) + if (!structInfo) { + issues.push({ + severity: 'warning', + code: 'query_type_not_resolved', + file: handler.file, + line: handler.line, + operation: formatOperation(operation), + message: `could not resolve query type ${queryType} for ${handler.functionName}`, + }) + continue + } + + for (const field of structInfo.fields) expectedQueryParams.add(field.name) + } + + for (const expected of expectedQueryParams) { + if (!operation.queryParams.has(expected)) { + issues.push({ + severity: 'error', + code: 'missing_query_parameter', + file: handler.file, + line: handler.line, + operation: formatOperation(operation), + message: `${handler.functionName} expects query parameter ${expected}, but OpenAPI does not document it`, + }) + } + } +} + +function resolveStructInfo( + handler: HandlerInfo, + simpleType: string, + source: SourceExpectations, +): StructInfo | null { + const sameFileStruct = source.structsByFileAndName.get(structKey(handler.file, simpleType)) + if (sameFileStruct) return sameFileStruct + + const importedFile = handler.importedTypes.get(simpleType) + if (importedFile) { + const importedStruct = source.structsByFileAndName.get(structKey(importedFile, simpleType)) + if (importedStruct) return importedStruct + } + + const structs = source.structsByName.get(simpleType) ?? [] + if (structs.length === 1) return structs[0] + + return null +} + +function comparePath(handler: HandlerInfo, operation: Operation, issues: Issue[]) { + if (handler.pathTypes.length && !operation.pathParams.size) { + issues.push({ + severity: 'error', + code: 'missing_path_parameters', + file: handler.file, + line: handler.line, + operation: formatOperation(operation), + message: `${handler.functionName} extracts path data, but OpenAPI has no path parameters`, + }) + } + + for (const routeParam of handler.routePathParams) { + if (!operation.pathParams.has(routeParam)) { + issues.push({ + severity: 'error', + code: 'path_parameter_name_mismatch', + file: handler.file, + line: handler.line, + operation: formatOperation(operation), + message: `${handler.functionName} route macro uses path parameter ${routeParam}, but OpenAPI path parameters are ${formatSet(operation.pathParams)}`, + }) + } + } +} + +function simpleRustType(type: string): string { + const trimmed = type.trim() + if (trimmed.startsWith('Option<')) { + return simpleRustType(trimmed.slice('Option<'.length, -1)) + } + + const withoutGenerics = trimmed.replace(/<[\s\S]*>$/, '') + return withoutGenerics.split('::').at(-1)?.replace(/[^\w]/g, '') ?? withoutGenerics +} + +function structKey(file: string, name: string): string { + return `${file}::${name}` +} + +function isDynamicExtractorType(type: string): boolean { + return ['HashMap', 'Value', 'Map'].includes(type) +} + +function extractSerdeRename(attrs: string): string | undefined { + return attrs.match(/\brename\s*=\s*"([^"]+)"/)?.[1] +} + +function extractSerdeRenameAll(attrs: string): string | undefined { + return attrs.match(/\brename_all\s*=\s*"([^"]+)"/)?.[1] +} + +function applyRenameAll(name: string, renameAll?: string): string { + switch (renameAll) { + case 'camelCase': + return name.replace(/_([a-z])/g, (_, char: string) => char.toUpperCase()) + case 'kebab-case': + return name.replace(/_/g, '-') + default: + return name + } +} + +function findMatching(content: string, openIndex: number, open: string, close: string): number { + if (openIndex < 0 || content[openIndex] !== open) return -1 + + let depth = 0 + let quote: string | null = null + let escaped = false + + for (let i = openIndex; i < content.length; i++) { + const char = content[i] + + if (quote) { + if (escaped) { + escaped = false + } else if (char === '\\') { + escaped = true + } else if (char === quote) { + quote = null + } + continue + } + + if (char === '"') { + quote = char + continue + } + + if (char === open) depth++ + if (char === close) { + depth-- + if (depth === 0) return i + } + } + + return -1 +} + +function lineNumber(content: string, index: number): number { + let line = 1 + for (let i = 0; i < index; i++) { + if (content[i] === '\n') line++ + } + return line +} + +function printReport(operations: Operation[], issues: Issue[]) { + const errors = issues.filter((issue) => issue.severity === 'error') + const warnings = issues.filter((issue) => issue.severity === 'warning') + + console.log(chalk.bold(`Checked ${operations.length} OpenAPI operations`)) + + if (!issues.length) { + console.log(chalk.green('No OpenAPI mismatches found')) + return + } + + for (const issue of issues) { + const color = issue.severity === 'error' ? chalk.red : chalk.yellow + const location = issue.file ? ` ${formatLocation(issue.file, issue.line)}` : '' + const operation = issue.operation ? ` ${chalk.gray(issue.operation)}` : '' + console.log( + `${color(issue.severity.toUpperCase())} ${chalk.bold(issue.code)}${operation}${location}`, + ) + console.log(` ${issue.message}`) + } + + console.log() + console.log( + `${chalk.red(`${errors.length} error(s)`)} ${chalk.yellow(`${warnings.length} warning(s)`)}`, + ) +} + +function formatOperation(operation: Operation): string { + return `${operation.method.toUpperCase()} ${operation.path} (${operation.operationId})` +} + +function operationKey(operation: Operation): string { + return `${operation.method.toUpperCase()} ${operation.path}` +} + +function formatSet(values: Set): string { + return values.size ? [...values].join(', ') : '' +} + +function formatLocation(file: string, line?: number): string { + const relative = path.relative(repoRoot, file) + return line ? `${relative}:${line}` : relative +} + +main().catch((error) => { + console.error(chalk.red(error instanceof Error ? error.message : String(error))) + process.exit(1) +})