OXYGENHUB
# de505df5cf8d oxygenhub / crates / hub / src / api / mod.rs
6.7 KiB · Rust · last changed Milestone 0.3: proposals and review 1 month ago source blame history raw
1
//! The JSON API (hub spec §12.1) under `/api/v1`. Milestone 0.1 exposes
2
//! repositories, bookmarks and tags; the rest follows the pages.
3
4
use crate::repos::{view, RepoError, RepoName};
5
use crate::{db, Shared};
6
use axum::extract::{Path, State};
7
use axum::http::StatusCode;
8
use axum::response::{IntoResponse, Response};
9
use axum::routing::get;
10
use axum::{Json, Router};
11
use hub_api::{Bookmark, Problem, Repository, Tag};
12
13
pub fn router() -> Router<Shared> {
14
    Router::new()
15
        .route("/repos", get(list_repos))
16
        .route("/repos/{owner}/{name}", get(get_repo))
17
        .route("/repos/{owner}/{name}/bookmarks", get(bookmarks))
18
        .route("/repos/{owner}/{name}/tags", get(tags))
19
        .route("/repos/{owner}/{name}/proposals", get(proposals))
20
        .route("/repos/{owner}/{name}/proposals/{number}", get(proposal))
21
}
22
23
/// The account a bearer token resolves to, for API calls that must see
24
/// what the caller may see.
25
async fn caller(app: &Shared, headers: &axum::http::HeaderMap) -> Result<Option<db::accounts::Account>, ApiError> {
26
    let Some(t) = oxy_server::bearer_token(headers) else { return Ok(None) };
27
    if app.config.auth.push_tokens.iter().any(|x| crate::auth::mac_equal(x, t)) {
28
        // A site token acts as an administrator.
29
        return Ok(db::accounts::accounts(&app.db).await?.into_iter().find(|a| a.is_admin));
30
    }
31
    let token = db::accounts::token_by_hash(&app.db, &crate::auth::token_hash(t)).await?;
32
    match token {
33
        Some(tok) if !tok.expired(db::now_ms()) => Ok(db::accounts::account_by_id(&app.db, tok.account_id).await?),
34
        _ => Err(ApiError(StatusCode::UNAUTHORIZED, "unknown or expired token".into())),
35
    }
36
}
37
38
async fn readable(
39
    app: &Shared,
40
    headers: &axum::http::HeaderMap,
41
    owner: &str,
42
    name: &str,
43
) -> Result<(RepoName, db::repos::RepoRow), ApiError> {
44
    let repo = RepoName::new(owner, name).ok_or(ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
45
    let row = db::repos::get(&app.db, owner, name).await?.ok_or(ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
46
    let account = caller(app, headers).await?;
47
    let role = crate::auth::roles::role_for(&app.db, account.as_ref(), &row).await?;
48
    if !crate::auth::roles::allows(role, crate::auth::Access::Read) {
49
        return Err(ApiError(StatusCode::NOT_FOUND, "no such repository".into()));
50
    }
51
    Ok((repo, row))
52
}
53
54
async fn proposals(
55
    State(app): State<Shared>,
56
    headers: axum::http::HeaderMap,
57
    Path((owner, name)): Path<(String, String)>,
58
) -> Result<Json<Vec<hub_api::Proposal>>, ApiError> {
59
    let (repo, row) = readable(&app, &headers, &owner, &name).await?;
60
    let mut out = Vec::new();
61
    for p in db::proposals::list(&app.db, row.id, &["open", "draft"]).await? {
62
        let loaded = crate::web::proposals::load(&app, &repo, p).await?;
63
        let v = crate::web::proposals::verdict(&app, &loaded, row.id).await?;
64
        out.push(crate::web::proposals::to_api(&app, &owner, &name, &loaded, &v, None).await);
65
    }
66
    Ok(Json(out))
67
}
68
69
async fn proposal(
70
    State(app): State<Shared>,
71
    headers: axum::http::HeaderMap,
72
    Path((owner, name, number)): Path<(String, String, i64)>,
73
) -> Result<Json<hub_api::Proposal>, ApiError> {
74
    let (repo, row) = readable(&app, &headers, &owner, &name).await?;
75
    let p = db::proposals::get(&app.db, row.id, number).await?.ok_or(ApiError(StatusCode::NOT_FOUND, "no such proposal".into()))?;
76
    let source_url = match p.source_repo_id {
77
        Some(id) => db::repos::get_by_id(&app.db, id).await?.map(|r| format!("{}/{}", app.config.base_url(), r.slug())),
78
        None => None,
79
    };
80
    let loaded = crate::web::proposals::load(&app, &repo, p).await?;
81
    let v = crate::web::proposals::verdict(&app, &loaded, row.id).await?;
82
    Ok(Json(crate::web::proposals::to_api(&app, &owner, &name, &loaded, &v, source_url).await))
83
}
84
85
pub struct ApiError(StatusCode, String);
86
87
impl IntoResponse for ApiError {
88
    fn into_response(self) -> Response {
89
        let body = Problem { status: self.0.as_u16(), title: self.0.canonical_reason().unwrap_or("error").into(), detail: self.1 };
90
        (self.0, [("content-type", "application/problem+json")], Json(body)).into_response()
91
    }
92
}
93
94
impl From<RepoError> for ApiError {
95
    fn from(e: RepoError) -> ApiError {
96
        match e {
97
            RepoError::NotFound => ApiError(StatusCode::NOT_FOUND, "no such repository".into()),
98
            other => ApiError(StatusCode::INTERNAL_SERVER_ERROR, other.to_string()),
99
        }
100
    }
101
}
102
103
impl From<sqlx::Error> for ApiError {
104
    fn from(e: sqlx::Error) -> ApiError {
105
        ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
106
    }
107
}
108
109
fn repository(app: &Shared, r: &db::repos::RepoRow) -> Repository {
110
    Repository {
111
        owner: r.owner.clone(),
112
        name: r.name.clone(),
113
        description: r.description.clone(),
114
        default_bookmark: r.default_bookmark.clone(),
115
        visibility: r.visibility(),
116
        created_ms: r.created_ms,
117
        updated_ms: r.updated_ms,
118
        clone_url: format!("{}/{}/{}", app.config.base_url(), r.owner, r.name),
119
    }
120
}
121
122
async fn public_row(app: &Shared, owner: &str, name: &str) -> Result<(RepoName, db::repos::RepoRow), ApiError> {
123
    let repo = RepoName::new(owner, name).ok_or(ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
124
    let row = db::repos::get(&app.db, owner, name).await?.filter(|r| r.visibility() == hub_api::Visibility::Public);
125
    Ok((repo, row.ok_or(ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?))
126
}
127
128
async fn list_repos(State(app): State<Shared>) -> Result<Json<Vec<Repository>>, ApiError> {
129
    let rows = db::repos::list(&app.db).await?;
130
    Ok(Json(rows.iter().filter(|r| r.visibility() == hub_api::Visibility::Public).map(|r| repository(&app, r)).collect()))
131
}
132
133
async fn get_repo(State(app): State<Shared>, Path((owner, name)): Path<(String, String)>) -> Result<Json<Repository>, ApiError> {
134
    let (_, row) = public_row(&app, &owner, &name).await?;
135
    Ok(Json(repository(&app, &row)))
136
}
137
138
async fn bookmarks(State(app): State<Shared>, Path((owner, name)): Path<(String, String)>) -> Result<Json<Vec<Bookmark>>, ApiError> {
139
    let (repo, row) = public_row(&app, &owner, &name).await?;
140
    let default = row.default_bookmark.clone();
141
    let list = app.repos.read(&repo, move |r, cache| view::bookmarks(r, cache, &default)).await?;
142
    Ok(Json(list.into_iter().map(|b| Bookmark { name: b.name, heads: b.heads, changes: b.changes, default: b.default }).collect()))
143
}
144
145
async fn tags(State(app): State<Shared>, Path((owner, name)): Path<(String, String)>) -> Result<Json<Vec<Tag>>, ApiError> {
146
    let (repo, _) = public_row(&app, &owner, &name).await?;
147
    let list = app.repos.read(&repo, |r, _| view::tags(r)).await?;
148
    Ok(Json(list.into_iter().map(|t| Tag { name: t.name, heads: t.heads, message: t.message, signer: t.signer }).collect()))
149
}