From 189e4964ebee751ef543e91079bf960ed47856a6 Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Wed, 11 Dec 2024 14:37:23 +0100 Subject: [PATCH 1/4] Use `utoipa` crate to generate and serve basic OpenAPI description --- Cargo.lock | 38 +++++++++++++++++++ Cargo.toml | 2 + src/lib.rs | 1 + src/openapi.rs | 30 +++++++++++++++ src/router.rs | 8 +++- ..._io__openapi__tests__openapi_snapshot.snap | 22 +++++++++++ 6 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 src/openapi.rs create mode 100644 src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap diff --git a/Cargo.lock b/Cargo.lock index 91639c3312a..6e539dca158 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1125,6 +1125,8 @@ dependencies = [ "typomania", "unicode-xid", "url", + "utoipa", + "utoipa-axum", "zip", ] @@ -5571,6 +5573,42 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "utoipa" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "514a48569e4e21c86d0b84b5612b5e73c0b2cf09db63260134ba426d4e8ea714" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-axum" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1370cc4a8eee751c4d2a729566d83d1568212320a20581c7c72c2d76ab80ed37" +dependencies = [ + "axum", + "paste", + "tower-layer", + "tower-service", + "utoipa", +] + +[[package]] +name = "utoipa-gen" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5629efe65599d0ccd5d493688cbf6e03aa7c1da07fe59ff97cf5977ed0637f66" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + [[package]] name = "uuid" version = "1.11.0" diff --git a/Cargo.toml b/Cargo.toml index 97ae0bd590e..62efdaa1e54 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -125,6 +125,8 @@ tracing-subscriber = { version = "=0.3.19", features = ["env-filter", "json"] } typomania = { version = "=0.1.2", default-features = false } url = "=2.5.4" unicode-xid = "=0.2.6" +utoipa = "=5.2.0" +utoipa-axum = "=0.1.2" [dev-dependencies] bytes = "=1.9.0" diff --git a/src/lib.rs b/src/lib.rs index bd01bc36399..11f439591d8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -44,6 +44,7 @@ mod licenses; pub mod metrics; pub mod middleware; pub mod models; +pub mod openapi; pub mod rate_limiter; mod real_ip; mod router; diff --git a/src/openapi.rs b/src/openapi.rs new file mode 100644 index 00000000000..d64f5d555bb --- /dev/null +++ b/src/openapi.rs @@ -0,0 +1,30 @@ +use utoipa::OpenApi; +use utoipa_axum::router::OpenApiRouter; + +#[derive(OpenApi)] +pub struct BaseOpenApi; + +impl BaseOpenApi { + pub fn router() -> OpenApiRouter + where + S: Send + Sync + Clone + 'static, + { + OpenApiRouter::with_openapi(Self::openapi()) + } +} + +#[cfg(test)] +mod tests { + use crate::tests::util::{RequestHelper, TestApp}; + use http::StatusCode; + use insta::assert_json_snapshot; + + #[tokio::test(flavor = "multi_thread")] + async fn test_openapi_snapshot() { + let (_app, anon) = TestApp::init().empty().await; + + let response = anon.get::<()>("/api/openapi.json").await; + assert_eq!(response.status(), StatusCode::OK); + assert_json_snapshot!(response.json()); + } +} diff --git a/src/router.rs b/src/router.rs index 16b50b62a07..9deb1fcba69 100644 --- a/src/router.rs +++ b/src/router.rs @@ -1,19 +1,22 @@ use axum::extract::DefaultBodyLimit; use axum::response::IntoResponse; use axum::routing::{delete, get, post, put}; -use axum::Router; +use axum::{Json, Router}; use http::{Method, StatusCode}; use crate::app::AppState; use crate::controllers::user::update_user; use crate::controllers::*; +use crate::openapi::BaseOpenApi; use crate::util::errors::not_found; use crate::Env; const MAX_PUBLISH_CONTENT_LENGTH: usize = 128 * 1024 * 1024; // 128 MB pub fn build_axum_router(state: AppState) -> Router<()> { - let mut router = Router::new() + let (router, openapi) = BaseOpenApi::router().split_for_parts(); + + let mut router = router // Route used by both `cargo search` and the frontend .route("/api/v1/crates", get(krate::search::search)) // Routes used by `cargo` @@ -174,6 +177,7 @@ pub fn build_axum_router(state: AppState) -> Router<()> { } router + .route("/api/openapi.json", get(|| async { Json(openapi) })) .fallback(|method: Method| async move { match method { Method::HEAD => StatusCode::NOT_FOUND.into_response(), diff --git a/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap b/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap new file mode 100644 index 00000000000..041aad45c6d --- /dev/null +++ b/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap @@ -0,0 +1,22 @@ +--- +source: src/openapi.rs +expression: response.json() +snapshot_kind: text +--- +{ + "components": {}, + "info": { + "contact": { + "email": "alex@alexcrichton.com", + "name": "Alex Crichton" + }, + "description": "Backend of crates.io", + "license": { + "name": "MIT OR Apache-2.0" + }, + "title": "crates_io", + "version": "0.0.0" + }, + "openapi": "3.1.0", + "paths": {} +} From 2f484ccc45f854a3b864001f0b0a7ab2ef8a44db Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Wed, 11 Dec 2024 15:46:06 +0100 Subject: [PATCH 2/4] openapi: Override derived metadata --- src/openapi.rs | 8 ++++++++ .../crates_io__openapi__tests__openapi_snapshot.snap | 11 ++++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/openapi.rs b/src/openapi.rs index d64f5d555bb..a8b4df5d4a8 100644 --- a/src/openapi.rs +++ b/src/openapi.rs @@ -2,6 +2,14 @@ use utoipa::OpenApi; use utoipa_axum::router::OpenApiRouter; #[derive(OpenApi)] +#[openapi(info( + title = "crates.io", + description = "API documentation for the [crates.io](https://crates.io/) package registry", + terms_of_service = "https://crates.io/policies", + contact(name = "the crates.io team", email = "help@crates.io"), + license(), + version = "0.0.0", +))] pub struct BaseOpenApi; impl BaseOpenApi { diff --git a/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap b/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap index 041aad45c6d..0c678e76665 100644 --- a/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap +++ b/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap @@ -7,14 +7,15 @@ snapshot_kind: text "components": {}, "info": { "contact": { - "email": "alex@alexcrichton.com", - "name": "Alex Crichton" + "email": "help@crates.io", + "name": "the crates.io team" }, - "description": "Backend of crates.io", + "description": "API documentation for the [crates.io](https://crates.io/) package registry", "license": { - "name": "MIT OR Apache-2.0" + "name": "" }, - "title": "crates_io", + "termsOfService": "https://crates.io/policies", + "title": "crates.io", "version": "0.0.0" }, "openapi": "3.1.0", From f9176c884126b970365267009889343e4690f26a Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Wed, 11 Dec 2024 16:20:27 +0100 Subject: [PATCH 3/4] openapi: Add explicit `servers` declaration --- src/openapi.rs | 22 ++++++++++++------- ..._io__openapi__tests__openapi_snapshot.snap | 10 ++++++++- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/openapi.rs b/src/openapi.rs index a8b4df5d4a8..11f6eec821d 100644 --- a/src/openapi.rs +++ b/src/openapi.rs @@ -2,14 +2,20 @@ use utoipa::OpenApi; use utoipa_axum::router::OpenApiRouter; #[derive(OpenApi)] -#[openapi(info( - title = "crates.io", - description = "API documentation for the [crates.io](https://crates.io/) package registry", - terms_of_service = "https://crates.io/policies", - contact(name = "the crates.io team", email = "help@crates.io"), - license(), - version = "0.0.0", -))] +#[openapi( + info( + title = "crates.io", + description = "API documentation for the [crates.io](https://crates.io/) package registry", + terms_of_service = "https://crates.io/policies", + contact(name = "the crates.io team", email = "help@crates.io"), + license(), + version = "0.0.0", + ), + servers( + (url = "https://crates.io"), + (url = "https://staging.crates.io"), + ), +)] pub struct BaseOpenApi; impl BaseOpenApi { diff --git a/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap b/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap index 0c678e76665..eaf837f2501 100644 --- a/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap +++ b/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap @@ -19,5 +19,13 @@ snapshot_kind: text "version": "0.0.0" }, "openapi": "3.1.0", - "paths": {} + "paths": {}, + "servers": [ + { + "url": "https://crates.io" + }, + { + "url": "https://staging.crates.io" + } + ] } From a09508208094747d0f41ef5842965fa1ee1de5c2 Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Wed, 11 Dec 2024 15:46:23 +0100 Subject: [PATCH 4/4] controllers/krate/search: Use basic `utoipa` annotation --- src/controllers/krate/search.rs | 43 +++++++++++-------- src/router.rs | 10 +++-- ..._io__openapi__tests__openapi_snapshot.snap | 18 +++++++- 3 files changed, 49 insertions(+), 22 deletions(-) diff --git a/src/controllers/krate/search.rs b/src/controllers/krate/search.rs index 86e44afed19..3c494c3961c 100644 --- a/src/controllers/krate/search.rs +++ b/src/controllers/krate/search.rs @@ -24,28 +24,35 @@ use crate::models::krate::ALL_COLUMNS; use crate::sql::{array_agg, canon_crate_name, lower}; use crate::util::RequestUtils; -/// Handles the `GET /crates` route. -/// Returns a list of crates. Called in a variety of scenarios in the -/// front end, including: +/// Returns a list of crates. +/// +/// Called in a variety of scenarios in the front end, including: /// - Alphabetical listing of crates /// - List of crates under a specific owner /// - Listing a user's followed crates -/// -/// Notes: -/// The different use cases this function covers is handled through passing -/// in parameters in the GET request. -/// -/// We would like to stop adding functionality in here. It was built like -/// this to keep the number of database queries low, though given Rust's -/// low performance overhead, this is a soft goal to have, and can afford -/// more database transactions if it aids understandability. -/// -/// All of the edge cases for this function are not currently covered -/// in testing, and if they fail, it is difficult to determine what -/// caused the break. In the future, we should look at splitting this -/// function out to cover the different use cases, and create unit tests -/// for them. +#[utoipa::path( + get, + path = "/api/v1/crates", + operation_id = "crates_list", + tag = "crates", + responses((status = 200, description = "Successful Response")), +)] pub async fn search(app: AppState, req: Parts) -> AppResult { + // Notes: + // The different use cases this function covers is handled through passing + // in parameters in the GET request. + // + // We would like to stop adding functionality in here. It was built like + // this to keep the number of database queries low, though given Rust's + // low performance overhead, this is a soft goal to have, and can afford + // more database transactions if it aids understandability. + // + // All of the edge cases for this function are not currently covered + // in testing, and if they fail, it is difficult to determine what + // caused the break. In the future, we should look at splitting this + // function out to cover the different use cases, and create unit tests + // for them. + let mut conn = app.db_read().await?; use diesel::sql_types::Float; diff --git a/src/router.rs b/src/router.rs index 9deb1fcba69..572f617f273 100644 --- a/src/router.rs +++ b/src/router.rs @@ -3,6 +3,7 @@ use axum::response::IntoResponse; use axum::routing::{delete, get, post, put}; use axum::{Json, Router}; use http::{Method, StatusCode}; +use utoipa_axum::routes; use crate::app::AppState; use crate::controllers::user::update_user; @@ -14,11 +15,14 @@ use crate::Env; const MAX_PUBLISH_CONTENT_LENGTH: usize = 128 * 1024 * 1024; // 128 MB pub fn build_axum_router(state: AppState) -> Router<()> { - let (router, openapi) = BaseOpenApi::router().split_for_parts(); + let (router, openapi) = BaseOpenApi::router() + .routes(routes!( + // Route used by both `cargo search` and the frontend + krate::search::search + )) + .split_for_parts(); let mut router = router - // Route used by both `cargo search` and the frontend - .route("/api/v1/crates", get(krate::search::search)) // Routes used by `cargo` .route( "/api/v1/crates/new", diff --git a/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap b/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap index eaf837f2501..bb539c39650 100644 --- a/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap +++ b/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap @@ -19,7 +19,23 @@ snapshot_kind: text "version": "0.0.0" }, "openapi": "3.1.0", - "paths": {}, + "paths": { + "/api/v1/crates": { + "get": { + "description": "Called in a variety of scenarios in the front end, including:\n- Alphabetical listing of crates\n- List of crates under a specific owner\n- Listing a user's followed crates", + "operationId": "crates_list", + "responses": { + "200": { + "description": "Successful Response" + } + }, + "summary": "Returns a list of crates.", + "tags": [ + "crates" + ] + } + } + }, "servers": [ { "url": "https://crates.io"