From efd8cc7bf2006b5c3fc43c271b31f7f31ed320ff Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Thu, 27 Feb 2025 16:19:22 +0100 Subject: [PATCH 1/3] Improve OpenAPI documentation for `GET /api/v1/crates/{name}/downloads` endpoint --- src/controllers/krate/downloads.rs | 72 ++++++++++++------- ..._io__openapi__tests__openapi_snapshot.snap | 61 ++++++++++++++++ 2 files changed, 106 insertions(+), 27 deletions(-) diff --git a/src/controllers/krate/downloads.rs b/src/controllers/krate/downloads.rs index 9172d3c2eb1..b15b533e975 100644 --- a/src/controllers/krate/downloads.rs +++ b/src/controllers/krate/downloads.rs @@ -10,10 +10,9 @@ use crate::models::{User, Version as FullVersion, VersionDownload, VersionOwnerA use crate::schema::{version_downloads, version_owner_actions, versions}; use crate::util::errors::{AppResult, BoxedAppError, bad_request}; use crate::views::{EncodableVersion, EncodableVersionDownload}; +use axum::Json; use axum::extract::FromRequestParts; use axum_extra::extract::Query; -use axum_extra::json; -use axum_extra::response::ErasedJson; use crates_io_database::schema::users; use crates_io_diesel_helpers::to_char; use diesel::prelude::*; @@ -37,6 +36,37 @@ pub struct DownloadsQueryParams { include: Option, } +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct DownloadsResponse { + /// The per-day download counts for the last 90 days. + pub version_downloads: Vec, + + /// The versions referenced in the download counts, if `?include=versions` + /// was requested. + #[serde(skip_serializing_if = "Option::is_none")] + pub versions: Option>, + + #[schema(inline)] + pub meta: DownloadsMeta, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct DownloadsMeta { + #[schema(inline)] + pub extra_downloads: Vec, +} + +#[derive(Debug, Serialize, Queryable, utoipa::ToSchema)] +pub struct ExtraDownload { + /// The date this download count is for. + #[schema(example = "2019-12-13")] + date: String, + + /// The number of downloads on the given date. + #[schema(example = 123)] + downloads: i64, +} + /// Get the download counts for a crate. /// /// This includes the per-day downloads for the last 90 days and for the @@ -46,14 +76,13 @@ pub struct DownloadsQueryParams { path = "/api/v1/crates/{name}/downloads", params(CratePath, DownloadsQueryParams), tag = "crates", - responses((status = 200, description = "Successful Response")), + responses((status = 200, description = "Successful Response", body = inline(DownloadsResponse))), )] - pub async fn get_crate_downloads( state: AppState, path: CratePath, params: DownloadsQueryParams, -) -> AppResult { +) -> AppResult> { let mut conn = state.db_read().await?; use diesel::dsl::*; @@ -78,7 +107,7 @@ pub async fn get_crate_downloads( .unwrap_or_default(); let sum_downloads = sql::("SUM(version_downloads.downloads)"); - let (downloads, extra, versions_and_publishers, actions) = tokio::try_join!( + let (downloads, extra_downloads, versions_and_publishers, actions) = tokio::try_join!( VersionDownload::belonging_to(latest_five) .filter(version_downloads::date.gt(date(now - 90.days()))) .order(( @@ -101,18 +130,12 @@ pub async fn get_crate_downloads( load_actions(&mut conn, latest_five, include.versions), )?; - let downloads = downloads + let version_downloads = downloads .into_iter() .map(VersionDownload::into) .collect::>(); - #[derive(Serialize, Queryable)] - struct ExtraDownload { - date: String, - downloads: i64, - } - - if include.versions { + let versions = if include.versions { let versions_and_publishers = versions_and_publishers.grouped_by(latest_five); let actions = actions.grouped_by(latest_five); let versions = versions_and_publishers @@ -125,20 +148,15 @@ pub async fn get_crate_downloads( }) .collect::>(); - return Ok(json!({ - "version_downloads": downloads, - "versions": versions, - "meta": { - "extra_downloads": extra, - }, - })); - } + Some(versions) + } else { + None + }; - Ok(json!({ - "version_downloads": downloads, - "meta": { - "extra_downloads": extra, - }, + Ok(Json(DownloadsResponse { + version_downloads, + versions, + meta: DownloadsMeta { extra_downloads }, })) } diff --git a/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap b/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap index 913055fbf69..45a4f8519e5 100644 --- a/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap +++ b/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap @@ -1397,6 +1397,67 @@ expression: response.json() ], "responses": { "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "meta": { + "properties": { + "extra_downloads": { + "items": { + "properties": { + "date": { + "description": "The date this download count is for.", + "example": "2019-12-13", + "type": "string" + }, + "downloads": { + "description": "The number of downloads on the given date.", + "example": 123, + "format": "int64", + "type": "integer" + } + }, + "required": [ + "date", + "downloads" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "extra_downloads" + ], + "type": "object" + }, + "version_downloads": { + "description": "The per-day download counts for the last 90 days.", + "items": { + "$ref": "#/components/schemas/VersionDownload" + }, + "type": "array" + }, + "versions": { + "description": "The versions referenced in the download counts, if `?include=versions`\nwas requested.", + "items": { + "$ref": "#/components/schemas/Version" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "version_downloads", + "meta" + ], + "type": "object" + } + } + }, "description": "Successful Response" } }, From 145579d661b70db9bbed3812d2b458de492538d8 Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Thu, 27 Feb 2025 16:23:46 +0100 Subject: [PATCH 2/3] Improve OpenAPI documentation for `GET /api/v1/crates/{name}/following` endpoint --- src/controllers/krate/follow.rs | 17 +++++++++++------ ...es_io__openapi__tests__openapi_snapshot.snap | 16 ++++++++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/controllers/krate/follow.rs b/src/controllers/krate/follow.rs index 7475dc1f4b3..168bc8e807f 100644 --- a/src/controllers/krate/follow.rs +++ b/src/controllers/krate/follow.rs @@ -7,8 +7,7 @@ use crate::controllers::krate::CratePath; use crate::models::{Crate, Follow}; use crate::schema::*; use crate::util::errors::{AppResult, crate_not_found}; -use axum_extra::json; -use axum_extra::response::ErasedJson; +use axum::Json; use diesel::prelude::*; use diesel_async::{AsyncPgConnection, RunQueryDsl}; use http::request::Parts; @@ -74,6 +73,12 @@ pub async fn unfollow_crate(app: AppState, path: CratePath, req: Parts) -> AppRe Ok(OkResponse::new()) } +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct FollowingResponse { + /// Whether the authenticated user is following the crate. + pub following: bool, +} + /// Check if a crate is followed. #[utoipa::path( get, @@ -81,13 +86,13 @@ pub async fn unfollow_crate(app: AppState, path: CratePath, req: Parts) -> AppRe params(CratePath), security(("cookie" = [])), tag = "crates", - responses((status = 200, description = "Successful Response")), + responses((status = 200, description = "Successful Response", body = inline(FollowingResponse))), )] pub async fn get_following_crate( app: AppState, path: CratePath, req: Parts, -) -> AppResult { +) -> AppResult> { use diesel::dsl::exists; let mut conn = app.db_read_prefer_primary().await?; @@ -98,8 +103,8 @@ pub async fn get_following_crate( let follow = follow_target(&path.name, &mut conn, user_id).await?; let following = diesel::select(exists(follows::table.find(follow.id()))) - .get_result::(&mut conn) + .get_result(&mut conn) .await?; - Ok(json!({ "following": following })) + Ok(Json(FollowingResponse { following })) } diff --git a/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap b/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap index 45a4f8519e5..ba57cd07c15 100644 --- a/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap +++ b/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap @@ -1579,6 +1579,22 @@ expression: response.json() ], "responses": { "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "following": { + "description": "Whether the authenticated user is following the crate.", + "type": "boolean" + } + }, + "required": [ + "following" + ], + "type": "object" + } + } + }, "description": "Successful Response" } }, From e99762b59c5c538d68890b1899764ff8dfbddfa2 Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Thu, 27 Feb 2025 16:56:28 +0100 Subject: [PATCH 3/3] Improve OpenAPI documentation for `GET /api/v1/crates/{name}` endpoint --- src/controllers/krate/metadata.rs | 43 ++- ..._io__openapi__tests__openapi_snapshot.snap | 327 ++++++++++++++++++ src/views.rs | 91 ++++- 3 files changed, 445 insertions(+), 16 deletions(-) diff --git a/src/controllers/krate/metadata.rs b/src/controllers/krate/metadata.rs index 7e2068dd54c..7f00f00d885 100644 --- a/src/controllers/krate/metadata.rs +++ b/src/controllers/krate/metadata.rs @@ -15,9 +15,8 @@ use crate::util::errors::{ AppResult, BoxedAppError, bad_request, crate_not_found, version_not_found, }; use crate::views::{EncodableCategory, EncodableCrate, EncodableKeyword, EncodableVersion}; +use axum::Json; use axum::extract::{FromRequestParts, Query}; -use axum_extra::json; -use axum_extra::response::ErasedJson; use diesel::prelude::*; use diesel_async::{AsyncPgConnection, RunQueryDsl}; use futures_util::FutureExt; @@ -41,6 +40,25 @@ pub struct FindQueryParams { include: Option, } +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct GetResponse { + /// The crate metadata. + #[serde(rename = "crate")] + krate: EncodableCrate, + + /// The versions of the crate. + #[schema(example = json!(null))] + versions: Option>, + + /// The keywords of the crate. + #[schema(example = json!(null))] + keywords: Option>, + + /// The categories of the crate. + #[schema(example = json!(null))] + categories: Option>, +} + /// Get crate metadata (for the `new` crate). /// /// This endpoint works around a small limitation in `axum` and is delegating @@ -49,9 +67,12 @@ pub struct FindQueryParams { get, path = "/api/v1/crates/new", tag = "crates", - responses((status = 200, description = "Successful Response")), + responses((status = 200, description = "Successful Response", body = inline(GetResponse))), )] -pub async fn find_new_crate(app: AppState, params: FindQueryParams) -> AppResult { +pub async fn find_new_crate( + app: AppState, + params: FindQueryParams, +) -> AppResult> { let name = "new".to_string(); find_crate(app, CratePath { name }, params).await } @@ -62,13 +83,13 @@ pub async fn find_new_crate(app: AppState, params: FindQueryParams) -> AppResult path = "/api/v1/crates/{name}", params(CratePath, FindQueryParams), tag = "crates", - responses((status = 200, description = "Successful Response")), + responses((status = 200, description = "Successful Response", body = inline(GetResponse))), )] pub async fn find_crate( app: AppState, path: CratePath, params: FindQueryParams, -) -> AppResult { +) -> AppResult> { let mut conn = app.db_read().await?; let include = params @@ -186,11 +207,11 @@ pub async fn find_crate( .collect::>() }); - Ok(json!({ - "crate": encodable_crate, - "versions": encodable_versions, - "keywords": encodable_keywords, - "categories": encodable_cats, + Ok(Json(GetResponse { + krate: encodable_crate, + versions: encodable_versions, + keywords: encodable_keywords, + categories: encodable_cats, })) } diff --git a/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap b/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap index ba57cd07c15..f1e7bd68ecf 100644 --- a/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap +++ b/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap @@ -148,6 +148,235 @@ expression: response.json() ], "type": "object" }, + "Crate": { + "properties": { + "badges": { + "deprecated": true, + "example": [], + "items": { + "type": "object" + }, + "type": "array" + }, + "categories": { + "description": "The list of categories belonging to this crate.", + "example": null, + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "created_at": { + "description": "The date and time this crate was created.", + "example": "2019-12-13T13:46:41Z", + "format": "date-time", + "type": "string" + }, + "default_version": { + "description": "The \"default\" version of this crate.\n\nThis version will be displayed by default on the crate's page.", + "example": "1.3.0", + "type": [ + "string", + "null" + ] + }, + "description": { + "description": "Description of the crate.", + "example": "A generic serialization/deserialization framework", + "type": [ + "string", + "null" + ] + }, + "documentation": { + "description": "The URL to the crate's documentation, if set.", + "example": "https://docs.rs/serde", + "type": [ + "string", + "null" + ] + }, + "downloads": { + "description": "The total number of downloads for this crate.", + "example": 123456789, + "format": "int64", + "type": "integer" + }, + "exact_match": { + "deprecated": true, + "description": "Whether the crate name was an exact match.", + "type": "boolean" + }, + "homepage": { + "description": "The URL to the crate's homepage, if set.", + "example": "https://serde.rs", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "An opaque identifier for the crate.", + "example": "serde", + "type": "string" + }, + "keywords": { + "description": "The list of keywords belonging to this crate.", + "example": null, + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "links": { + "$ref": "#/components/schemas/CrateLinks", + "description": "Links to other API endpoints related to this crate." + }, + "max_stable_version": { + "deprecated": true, + "description": "The highest version number for this crate that is not a pre-release.", + "example": "1.3.0", + "type": [ + "string", + "null" + ] + }, + "max_version": { + "deprecated": true, + "description": "The highest version number for this crate.", + "example": "2.0.0-beta.1", + "type": "string" + }, + "name": { + "description": "The name of the crate.", + "example": "serde", + "type": "string" + }, + "newest_version": { + "deprecated": true, + "description": "The most recently published version for this crate.", + "example": "1.2.3", + "type": "string" + }, + "num_versions": { + "description": "The total number of versions for this crate.", + "example": 13, + "format": "int32", + "type": "integer" + }, + "recent_downloads": { + "description": "The total number of downloads for this crate in the last 90 days.", + "example": 456789, + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "repository": { + "description": "The URL to the crate's repository, if set.", + "example": "https://github.com/serde-rs/serde", + "type": [ + "string", + "null" + ] + }, + "updated_at": { + "description": "The date and time this crate was last updated.", + "example": "2019-12-13T13:46:41Z", + "format": "date-time", + "type": "string" + }, + "versions": { + "description": "The list of version IDs belonging to this crate.", + "example": null, + "items": { + "format": "int32", + "type": "integer" + }, + "type": [ + "array", + "null" + ] + }, + "yanked": { + "description": "Whether all versions of this crate have been yanked.", + "type": "boolean" + } + }, + "required": [ + "id", + "name", + "updated_at", + "badges", + "created_at", + "downloads", + "num_versions", + "yanked", + "max_version", + "newest_version", + "links", + "exact_match" + ], + "type": "object" + }, + "CrateLinks": { + "properties": { + "owner_team": { + "description": "The API path to this crate's team owners.", + "example": "/api/v1/crates/serde/owner_team", + "type": [ + "string", + "null" + ] + }, + "owner_user": { + "description": "The API path to this crate's user owners.", + "example": "/api/v1/crates/serde/owner_user", + "type": [ + "string", + "null" + ] + }, + "owners": { + "description": "The API path to this crate's owners.", + "example": "/api/v1/crates/serde/owners", + "type": [ + "string", + "null" + ] + }, + "reverse_dependencies": { + "description": "The API path to this crate's reverse dependencies.", + "example": "/api/v1/crates/serde/reverse_dependencies", + "type": "string" + }, + "version_downloads": { + "description": "The API path to this crate's download statistics.", + "example": "/api/v1/crates/serde/downloads", + "type": "string" + }, + "versions": { + "description": "The API path to this crate's versions.", + "example": "/api/v1/crates/serde/versions", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "version_downloads", + "reverse_dependencies" + ], + "type": "object" + }, "CrateOwnerInvitation": { "properties": { "crate_id": { @@ -1270,6 +1499,55 @@ expression: response.json() "operationId": "find_new_crate", "responses": { "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "categories": { + "description": "The categories of the crate.", + "example": null, + "items": { + "$ref": "#/components/schemas/Category" + }, + "type": [ + "array", + "null" + ] + }, + "crate": { + "$ref": "#/components/schemas/Crate", + "description": "The crate metadata." + }, + "keywords": { + "description": "The keywords of the crate.", + "example": null, + "items": { + "$ref": "#/components/schemas/Keyword" + }, + "type": [ + "array", + "null" + ] + }, + "versions": { + "description": "The versions of the crate.", + "example": null, + "items": { + "$ref": "#/components/schemas/Version" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "crate" + ], + "type": "object" + } + } + }, "description": "Successful Response" } }, @@ -1362,6 +1640,55 @@ expression: response.json() ], "responses": { "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "categories": { + "description": "The categories of the crate.", + "example": null, + "items": { + "$ref": "#/components/schemas/Category" + }, + "type": [ + "array", + "null" + ] + }, + "crate": { + "$ref": "#/components/schemas/Crate", + "description": "The crate metadata." + }, + "keywords": { + "description": "The keywords of the crate.", + "example": null, + "items": { + "$ref": "#/components/schemas/Keyword" + }, + "type": [ + "array", + "null" + ] + }, + "versions": { + "description": "The versions of the crate.", + "example": null, + "items": { + "$ref": "#/components/schemas/Version" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "crate" + ], + "type": "object" + } + } + }, "description": "Successful Response" } }, diff --git a/src/views.rs b/src/views.rs index 2d4ecef3476..97be068fea6 100644 --- a/src/views.rs +++ b/src/views.rs @@ -264,31 +264,94 @@ impl From for EncodableKeyword { } } -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, utoipa::ToSchema)] +#[schema(as = Crate)] pub struct EncodableCrate { + /// An opaque identifier for the crate. + #[schema(example = "serde")] pub id: String, + + /// The name of the crate. + #[schema(example = "serde")] pub name: String, + + /// The date and time this crate was last updated. + #[schema(example = "2019-12-13T13:46:41Z")] pub updated_at: DateTime, + + /// The list of version IDs belonging to this crate. + #[schema(example = json!(null))] pub versions: Option>, + + /// The list of keywords belonging to this crate. + #[schema(example = json!(null))] pub keywords: Option>, + + /// The list of categories belonging to this crate. + #[schema(example = json!(null))] pub categories: Option>, + + #[schema(deprecated, value_type = Vec, example = json!([]))] pub badges: [(); 0], + + /// The date and time this crate was created. + #[schema(example = "2019-12-13T13:46:41Z")] pub created_at: DateTime, - // NOTE: Used by shields.io, altering `downloads` requires a PR with shields.io + + /// The total number of downloads for this crate. + #[schema(example = 123_456_789)] pub downloads: i64, + + /// The total number of downloads for this crate in the last 90 days. + #[schema(example = 456_789)] pub recent_downloads: Option, + + /// The "default" version of this crate. + /// + /// This version will be displayed by default on the crate's page. + #[schema(example = "1.3.0")] pub default_version: Option, + + /// The total number of versions for this crate. + #[schema(example = 13)] pub num_versions: i32, + + /// Whether all versions of this crate have been yanked. pub yanked: bool, - // NOTE: Used by shields.io, altering `max_version` requires a PR with shields.io + + /// The highest version number for this crate. + #[schema(deprecated, example = "2.0.0-beta.1")] pub max_version: String, - pub newest_version: String, // Most recently updated version, which may not be max + + /// The most recently published version for this crate. + #[schema(deprecated, example = "1.2.3")] + pub newest_version: String, + + /// The highest version number for this crate that is not a pre-release. + #[schema(deprecated, example = "1.3.0")] pub max_stable_version: Option, + + /// Description of the crate. + #[schema(example = "A generic serialization/deserialization framework")] pub description: Option, + + /// The URL to the crate's homepage, if set. + #[schema(example = "https://serde.rs")] pub homepage: Option, + + /// The URL to the crate's documentation, if set. + #[schema(example = "https://docs.rs/serde")] pub documentation: Option, + + /// The URL to the crate's repository, if set. + #[schema(example = "https://github.com/serde-rs/serde")] pub repository: Option, + + /// Links to other API endpoints related to this crate. pub links: EncodableCrateLinks, + + /// Whether the crate name was an exact match. + #[schema(deprecated)] pub exact_match: bool, } @@ -418,13 +481,31 @@ impl EncodableCrate { } } -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, utoipa::ToSchema)] +#[schema(as = CrateLinks)] pub struct EncodableCrateLinks { + /// The API path to this crate's download statistics. + #[schema(example = "/api/v1/crates/serde/downloads")] pub version_downloads: String, + + /// The API path to this crate's versions. + #[schema(example = "/api/v1/crates/serde/versions")] pub versions: Option, + + /// The API path to this crate's owners. + #[schema(example = "/api/v1/crates/serde/owners")] pub owners: Option, + + /// The API path to this crate's team owners. + #[schema(example = "/api/v1/crates/serde/owner_team")] pub owner_team: Option, + + /// The API path to this crate's user owners. + #[schema(example = "/api/v1/crates/serde/owner_user")] pub owner_user: Option, + + /// The API path to this crate's reverse dependencies. + #[schema(example = "/api/v1/crates/serde/reverse_dependencies")] pub reverse_dependencies: String, }