//! The JSON API (hub spec ยง12.1) under `/api/v1`. Milestone 0.1 exposes //! repositories, bookmarks and tags; the rest follows the pages. use crate::repos::{view, RepoError, RepoName}; use crate::{db, Shared}; use axum::extract::{Path, State}; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use axum::routing::get; use axum::{Json, Router}; use hub_api::{Bookmark, Problem, Repository, Tag}; pub fn router() -> Router { Router::new() .route("/repos", get(list_repos)) .route("/repos/{owner}/{name}", get(get_repo)) .route("/repos/{owner}/{name}/bookmarks", get(bookmarks)) .route("/repos/{owner}/{name}/tags", get(tags)) .route("/repos/{owner}/{name}/proposals", get(proposals)) .route("/repos/{owner}/{name}/proposals/{number}", get(proposal)) } /// The account a bearer token resolves to, for API calls that must see /// what the caller may see. async fn caller(app: &Shared, headers: &axum::http::HeaderMap) -> Result, ApiError> { let Some(t) = oxy_server::bearer_token(headers) else { return Ok(None) }; if app.config.auth.push_tokens.iter().any(|x| crate::auth::mac_equal(x, t)) { // A site token acts as an administrator. return Ok(db::accounts::accounts(&app.db).await?.into_iter().find(|a| a.is_admin)); } let token = db::accounts::token_by_hash(&app.db, &crate::auth::token_hash(t)).await?; match token { Some(tok) if !tok.expired(db::now_ms()) => Ok(db::accounts::account_by_id(&app.db, tok.account_id).await?), _ => Err(ApiError(StatusCode::UNAUTHORIZED, "unknown or expired token".into())), } } async fn readable( app: &Shared, headers: &axum::http::HeaderMap, owner: &str, name: &str, ) -> Result<(RepoName, db::repos::RepoRow), ApiError> { let repo = RepoName::new(owner, name).ok_or(ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?; let row = db::repos::get(&app.db, owner, name).await?.ok_or(ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?; let account = caller(app, headers).await?; let role = crate::auth::roles::role_for(&app.db, account.as_ref(), &row).await?; if !crate::auth::roles::allows(role, crate::auth::Access::Read) { return Err(ApiError(StatusCode::NOT_FOUND, "no such repository".into())); } Ok((repo, row)) } async fn proposals( State(app): State, headers: axum::http::HeaderMap, Path((owner, name)): Path<(String, String)>, ) -> Result>, ApiError> { let (repo, row) = readable(&app, &headers, &owner, &name).await?; let mut out = Vec::new(); for p in db::proposals::list(&app.db, row.id, &["open", "draft"]).await? { let loaded = crate::web::proposals::load(&app, &repo, p).await?; let v = crate::web::proposals::verdict(&app, &loaded, row.id).await?; out.push(crate::web::proposals::to_api(&app, &owner, &name, &loaded, &v, None).await); } Ok(Json(out)) } async fn proposal( State(app): State, headers: axum::http::HeaderMap, Path((owner, name, number)): Path<(String, String, i64)>, ) -> Result, ApiError> { let (repo, row) = readable(&app, &headers, &owner, &name).await?; let p = db::proposals::get(&app.db, row.id, number).await?.ok_or(ApiError(StatusCode::NOT_FOUND, "no such proposal".into()))?; let source_url = match p.source_repo_id { Some(id) => db::repos::get_by_id(&app.db, id).await?.map(|r| format!("{}/{}", app.config.base_url(), r.slug())), None => None, }; let loaded = crate::web::proposals::load(&app, &repo, p).await?; let v = crate::web::proposals::verdict(&app, &loaded, row.id).await?; Ok(Json(crate::web::proposals::to_api(&app, &owner, &name, &loaded, &v, source_url).await)) } pub struct ApiError(StatusCode, String); impl IntoResponse for ApiError { fn into_response(self) -> Response { let body = Problem { status: self.0.as_u16(), title: self.0.canonical_reason().unwrap_or("error").into(), detail: self.1 }; (self.0, [("content-type", "application/problem+json")], Json(body)).into_response() } } impl From for ApiError { fn from(e: RepoError) -> ApiError { match e { RepoError::NotFound => ApiError(StatusCode::NOT_FOUND, "no such repository".into()), other => ApiError(StatusCode::INTERNAL_SERVER_ERROR, other.to_string()), } } } impl From for ApiError { fn from(e: sqlx::Error) -> ApiError { ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) } } fn repository(app: &Shared, r: &db::repos::RepoRow) -> Repository { Repository { owner: r.owner.clone(), name: r.name.clone(), description: r.description.clone(), default_bookmark: r.default_bookmark.clone(), visibility: r.visibility(), created_ms: r.created_ms, updated_ms: r.updated_ms, clone_url: format!("{}/{}/{}", app.config.base_url(), r.owner, r.name), } } async fn public_row(app: &Shared, owner: &str, name: &str) -> Result<(RepoName, db::repos::RepoRow), ApiError> { let repo = RepoName::new(owner, name).ok_or(ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?; let row = db::repos::get(&app.db, owner, name).await?.filter(|r| r.visibility() == hub_api::Visibility::Public); Ok((repo, row.ok_or(ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?)) } async fn list_repos(State(app): State) -> Result>, ApiError> { let rows = db::repos::list(&app.db).await?; Ok(Json(rows.iter().filter(|r| r.visibility() == hub_api::Visibility::Public).map(|r| repository(&app, r)).collect())) } async fn get_repo(State(app): State, Path((owner, name)): Path<(String, String)>) -> Result, ApiError> { let (_, row) = public_row(&app, &owner, &name).await?; Ok(Json(repository(&app, &row))) } async fn bookmarks(State(app): State, Path((owner, name)): Path<(String, String)>) -> Result>, ApiError> { let (repo, row) = public_row(&app, &owner, &name).await?; let default = row.default_bookmark.clone(); let list = app.repos.read(&repo, move |r, cache| view::bookmarks(r, cache, &default)).await?; Ok(Json(list.into_iter().map(|b| Bookmark { name: b.name, heads: b.heads, changes: b.changes, default: b.default }).collect())) } async fn tags(State(app): State, Path((owner, name)): Path<(String, String)>) -> Result>, ApiError> { let (repo, _) = public_row(&app, &owner, &name).await?; let list = app.repos.read(&repo, |r, _| view::tags(r)).await?; Ok(Json(list.into_iter().map(|t| Tag { name: t.name, heads: t.heads, message: t.message, signer: t.signer }).collect())) }