Skip to content

Use utoipa crates to generate and serve basic OpenAPI description #10186

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Dec 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
43 changes: 25 additions & 18 deletions src/controllers/krate/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ErasedJson> {
// 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;
Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
44 changes: 44 additions & 0 deletions src/openapi.rs
Original file line number Diff line number Diff line change
@@ -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 = "[email protected]"),
license(),
version = "0.0.0",
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the OpenAPI spec does not specify the format of the version field other than that it's a "string". we could potentially use the git SHA1 or commit timestamp as the version, if that seems useful 🤔

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thinking a combination of both would probably be most informative: something like 2024-12-11-21-45-00-4b33fdda250e7a8ebb4c40eff4d6205ce09c7e2a.

),
servers(
(url = "https://crates.io"),
(url = "https://staging.crates.io"),
),
)]
pub struct BaseOpenApi;

impl BaseOpenApi {
pub fn router<S>() -> OpenApiRouter<S>
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());
}
}
16 changes: 12 additions & 4 deletions src/router.rs
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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(),
Expand Down
47 changes: 47 additions & 0 deletions src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
source: src/openapi.rs
expression: response.json()
snapshot_kind: text
---
{
"components": {},
"info": {
"contact": {
"email": "[email protected]",
"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"
}
]
}