Skip to main content

kellnr_docs/
api.rs

1use axum::Json;
2use axum::extract::{Path, State};
3use axum::response::Redirect;
4use kellnr_appstate::{AppState, DbState, SettingsState};
5use kellnr_auth::token::Token;
6use kellnr_common::original_name::OriginalName;
7use kellnr_common::version::Version;
8use kellnr_error::api_error::ApiResult;
9use kellnr_registry::kellnr_api::check_ownership;
10
11use crate::doc_archive::DocArchive;
12use crate::doc_queue_response::DocQueueResponse;
13use crate::docs_error::DocsError;
14use crate::upload_response::DocUploadResponse;
15use crate::{compute_doc_url, get_latest_version_with_doc};
16
17/// Get documentation build queue
18///
19/// Returns the list of crates currently in the documentation build queue.
20#[utoipa::path(
21    get,
22    path = "/builds",
23    tag = "docs",
24    responses(
25        (status = 200, description = "Documentation build queue", body = DocQueueResponse)
26    ),
27    security(("session_cookie" = []))
28)]
29pub async fn docs_in_queue(State(db): DbState) -> ApiResult<Json<DocQueueResponse>> {
30    let doc = db.get_doc_queue().await?;
31    Ok(Json(DocQueueResponse::from(doc)))
32}
33
34/// Redirect to latest documentation
35///
36/// Redirects to the latest documentation for a given package.
37#[utoipa::path(
38    get,
39    path = "/{package}/latest",
40    tag = "docs",
41    params(
42        ("package" = String, Path, description = "Package name")
43    ),
44    responses(
45        (status = 302, description = "Redirect to latest documentation")
46    ),
47    security(("session_cookie" = []))
48)]
49pub async fn latest_docs(
50    Path(package): Path<OriginalName>,
51    State(settings): SettingsState,
52    State(db): DbState,
53) -> Redirect {
54    let name = package.to_normalized();
55    let opt_doc_version = get_latest_version_with_doc(&name, &settings);
56    let res_db_version = db.get_max_version_from_name(&name).await;
57
58    if let Some(doc_version) = opt_doc_version
59        && let Ok(db_version) = res_db_version
60        && doc_version == db_version
61    {
62        return Redirect::temporary(&compute_doc_url(&name, &db_version, &settings.origin.path));
63    }
64
65    Redirect::temporary("/")
66}
67
68/// Publish documentation for a crate version
69///
70/// Upload documentation for a specific crate and version.
71/// Requires ownership of the crate (via cargo token).
72#[utoipa::path(
73    put,
74    path = "/{package}/{version}",
75    tag = "docs",
76    params(
77        ("package" = String, Path, description = "Package name"),
78        ("version" = String, Path, description = "Package version")
79    ),
80    request_body(content = Vec<u8>, description = "Documentation archive (tar.gz or zip)", content_type = "application/octet-stream"),
81    responses(
82        (status = 200, description = "Documentation published successfully", body = DocUploadResponse),
83        (status = 400, description = "Crate or version does not exist"),
84        (status = 401, description = "Not authorized"),
85        (status = 403, description = "Not an owner of the crate")
86    ),
87    security(("cargo_token" = []))
88)]
89pub async fn publish_docs(
90    Path((package, version)): Path<(OriginalName, Version)>,
91    token: Token,
92    State(state): AppState,
93    mut docs: DocArchive,
94) -> ApiResult<Json<DocUploadResponse>> {
95    let db = state.db;
96    let settings = state.settings;
97    let normalized_name = package.to_normalized();
98    let crate_version = &version.to_string();
99
100    // Check if crate with the version exists.
101    if let Some(id) = db.get_crate_id(&normalized_name).await? {
102        if !db.crate_version_exists(id, crate_version).await? {
103            return crate_does_not_exist(&normalized_name, crate_version);
104        }
105    } else {
106        return crate_does_not_exist(&normalized_name, crate_version);
107    }
108
109    // Check if user from token is an owner of the crate.
110    // If not, he is not allowed to push the docs.
111    let user = kellnr_auth::maybe_user::MaybeUser::from_token(token);
112    check_ownership(&normalized_name, &user, &db).await?;
113
114    let doc_path = settings.docs_path().join(&*package).join(crate_version);
115
116    let _ = tokio::task::spawn_blocking(move || docs.extract(&doc_path))
117        .await
118        .map_err(|_| DocsError::ExtractFailed)?;
119
120    db.update_docs_link(
121        &normalized_name,
122        &version,
123        &compute_doc_url(&package, &version, &settings.origin.path),
124    )
125    .await?;
126
127    Ok(Json(DocUploadResponse::new(
128        "Successfully published docs.".to_string(),
129        &package,
130        &version,
131        &settings.origin.path,
132    )))
133}
134
135fn crate_does_not_exist(
136    crate_name: &str,
137    crate_version: &str,
138) -> ApiResult<Json<DocUploadResponse>> {
139    Err(DocsError::CrateDoesNotExist(crate_name.to_string(), crate_version.to_string()).into())
140}
141
142#[cfg(test)]
143mod tests {
144    use std::path::PathBuf;
145    use std::sync::Arc;
146
147    use axum::Router;
148    use axum::body::Body;
149    use axum::http::Request;
150    use axum::routing::get;
151    use http_body_util::BodyExt;
152    use kellnr_appstate::AppStateData;
153    use kellnr_common::normalized_name::NormalizedName;
154    use kellnr_db::mock::MockDb;
155    use kellnr_db::{DbProvider, DocQueueEntry};
156    use tower::ServiceExt;
157
158    use super::*;
159    use crate::doc_queue_response::DocQueueEntryResponse;
160
161    #[tokio::test]
162    async fn doc_in_queue_returns_queue_entries() {
163        let mut db = MockDb::new();
164        db.expect_get_doc_queue().returning(|| {
165            Ok(vec![
166                DocQueueEntry {
167                    id: 0,
168                    normalized_name: NormalizedName::from_unchecked("crate1".to_string()),
169                    version: "0.0.1".to_string(),
170                    path: PathBuf::default(),
171                },
172                DocQueueEntry {
173                    id: 1,
174                    normalized_name: NormalizedName::from_unchecked("crate2".to_string()),
175                    version: "0.0.2".to_string(),
176                    path: PathBuf::default(),
177                },
178            ])
179        });
180
181        let kellnr = app(Arc::new(db));
182        let r = kellnr
183            .oneshot(Request::get("/queue").body(Body::empty()).unwrap())
184            .await
185            .unwrap();
186
187        let actual = r.into_body().collect().await.unwrap().to_bytes();
188        let actual = serde_json::from_slice::<DocQueueResponse>(&actual).unwrap();
189        assert_eq!(
190            DocQueueResponse {
191                queue: vec![
192                    DocQueueEntryResponse {
193                        name: "crate1".to_string(),
194                        version: "0.0.1".to_string()
195                    },
196                    DocQueueEntryResponse {
197                        name: "crate2".to_string(),
198                        version: "0.0.2".to_string()
199                    }
200                ]
201            },
202            actual
203        );
204    }
205
206    fn app(db: Arc<dyn DbProvider>) -> Router {
207        Router::new()
208            .route("/queue", get(docs_in_queue))
209            .with_state(AppStateData {
210                db,
211                ..kellnr_appstate::test_state()
212            })
213    }
214}