diff --git a/Cargo.lock b/Cargo.lock index dba2a93e5e..5f5cca4ec4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1125,6 +1125,8 @@ dependencies = [ "typomania", "unicode-xid", "url", + "utoipa", + "utoipa-axum", "zip", ] @@ -5569,6 +5571,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 c08e1edef1..59a421c22f 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/controllers/krate/search.rs b/src/controllers/krate/search.rs index 86e44afed1..3c494c3961 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/lib.rs b/src/lib.rs index 2296fa16ff..0e7c90aa2e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -42,6 +42,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 0000000000..11f6eec821 --- /dev/null +++ b/src/openapi.rs @@ -0,0 +1,44 @@ +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", + ), + servers( + (url = "https://crates.io"), + (url = "https://staging.crates.io"), + ), +)] +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 16b50b62a0..572f617f27 100644 --- a/src/router.rs +++ b/src/router.rs @@ -1,21 +1,28 @@ 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 utoipa_axum::routes; 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() - // Route used by both `cargo search` and the frontend - .route("/api/v1/crates", get(krate::search::search)) + 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 // Routes used by `cargo` .route( "/api/v1/crates/new", @@ -174,6 +181,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 0000000000..bb539c3965 --- /dev/null +++ b/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap @@ -0,0 +1,47 @@ +--- +source: src/openapi.rs +expression: response.json() +snapshot_kind: text +--- +{ + "components": {}, + "info": { + "contact": { + "email": "help@crates.io", + "name": "the crates.io team" + }, + "description": "API documentation for the [crates.io](https://crates.io/) package registry", + "license": { + "name": "" + }, + "termsOfService": "https://crates.io/policies", + "title": "crates.io", + "version": "0.0.0" + }, + "openapi": "3.1.0", + "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" + }, + { + "url": "https://staging.crates.io" + } + ] +}