Skip to main content

codoseo_web/routes/
api.rs

1//! `/api/v1`: the REST API over the user's monitored sites. Every route is a thin layer over
2//! [`AgentService`], which the cloud MCP tools share, so both return the same JSON and count
3//! against the same daily quota.
4//!
5//! Authentication is `Authorization: Bearer <key>` and nothing else (no cookies). Errors are
6//! `{"error":{"code","message"}}`; every keyed response carries `X-RateLimit-Limit` and
7//! `X-RateLimit-Remaining` (calls left today, UTC), both left out on plans without a limit, and
8//! a 429 adds `Retry-After` (seconds to the next 00:00 UTC). The routes are exempt from the
9//! `Origin` check, since no browser session reaches them.
10
11use axum::Json;
12use axum::Router;
13use axum::extract::{FromRequestParts, Path, Query, State};
14use axum::http::request::Parts;
15use axum::http::{HeaderValue, StatusCode, header};
16use axum::response::{IntoResponse, Response};
17use axum::routing::{get, post};
18use serde::Deserialize;
19use serde::Serialize;
20
21use crate::agent::auth::ApiCaller;
22use crate::agent::error::AgentError;
23use crate::agent::service::{AgentService, Reply};
24use crate::metrics::{self, Surface, Tier};
25use crate::state::AppState;
26
27/// Where the API lives; the `Origin` check exempts everything under it.
28pub const PREFIX: &str = "/api/v1";
29
30pub fn routes() -> Router<AppState> {
31    let api = Router::new()
32        .route("/sites", get(list_sites))
33        .route("/sites/{site}", get(site_health))
34        .route("/sites/{site}/issues/{check}", get(issue_urls))
35        .route("/sites/{site}/page", get(page))
36        .route("/sites/{site}/changes", get(changes))
37        .route("/sites/{site}/crawls", post(run_crawl))
38        .route("/usage", get(usage))
39        .fallback(unknown)
40        .method_not_allowed_fallback(wrong_method);
41    Router::new().nest(PREFIX, api)
42}
43
44/// An unknown path under the API is a JSON 404 too, not the HTML page.
45async fn unknown() -> AgentError {
46    AgentError::NotFound("No such API endpoint.".to_owned())
47}
48
49/// A known path with the wrong HTTP method is a JSON error too, not an empty 405.
50async fn wrong_method() -> AgentError {
51    AgentError::MethodNotAllowed("That HTTP method is not allowed for this endpoint.".to_owned())
52}
53
54/// A query string, parsed but not yet judged: a malformed one is the API's JSON 400, which
55/// still costs the authenticated caller one call, so the verdict waits for [`counted`].
56struct ApiQuery<T>(Result<T, AgentError>);
57
58impl<T: serde::de::DeserializeOwned> FromRequestParts<AppState> for ApiQuery<T> {
59    type Rejection = std::convert::Infallible;
60
61    async fn from_request_parts(
62        parts: &mut Parts,
63        state: &AppState,
64    ) -> Result<ApiQuery<T>, Self::Rejection> {
65        Ok(ApiQuery(
66            Query::<T>::from_request_parts(parts, state)
67                .await
68                .map(|Query(q)| q)
69                .map_err(|e| AgentError::BadRequest(e.body_text())),
70        ))
71    }
72}
73
74/// Runs `call` with the parsed query, or charges one call and answers the 400 when the query
75/// didn't parse.
76async fn counted<Q, T: Serialize, F: Future<Output = Reply<T>>>(
77    service: &AgentService<'_>,
78    caller: &ApiCaller,
79    query: ApiQuery<Q>,
80    call: impl FnOnce(Q) -> F,
81) -> Response {
82    match query.0 {
83        Ok(q) => respond(StatusCode::OK, call(q).await),
84        Err(e) => respond(StatusCode::OK, service.refuse::<T>(caller, e).await),
85    }
86}
87
88/// The reply as a response: the body, or the JSON error, with the rate-limit headers.
89fn respond<T: Serialize>(status: StatusCode, reply: Reply<T>) -> Response {
90    metrics::api_request(
91        Surface::Rest,
92        Tier::Key,
93        reply
94            .outcome
95            .as_ref()
96            .map_or_else(AgentError::code, |_| "ok"),
97    );
98    let mut res = match reply.outcome {
99        Ok(value) => (status, Json(value)).into_response(),
100        Err(e) => e.into_response(),
101    };
102    let h = res.headers_mut();
103    h.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
104    if let Some(quota) = reply.quota {
105        if let Some(limit) = quota.limit {
106            h.insert("x-ratelimit-limit", HeaderValue::from(limit));
107        }
108        if let Some(remaining) = quota.remaining {
109            h.insert("x-ratelimit-remaining", HeaderValue::from(remaining));
110        }
111    }
112    res
113}
114
115async fn list_sites(State(state): State<AppState>, caller: ApiCaller) -> Response {
116    respond(
117        StatusCode::OK,
118        AgentService::new(&state).list_sites(&caller).await,
119    )
120}
121
122async fn site_health(
123    State(state): State<AppState>,
124    caller: ApiCaller,
125    Path(site): Path<String>,
126) -> Response {
127    respond(
128        StatusCode::OK,
129        AgentService::new(&state).site_health(&caller, &site).await,
130    )
131}
132
133#[derive(Deserialize)]
134struct PagingQuery {
135    limit: Option<u32>,
136    offset: Option<u32>,
137}
138
139async fn issue_urls(
140    State(state): State<AppState>,
141    caller: ApiCaller,
142    Path((site, check)): Path<(String, String)>,
143    q: ApiQuery<PagingQuery>,
144) -> Response {
145    let service = AgentService::new(&state);
146    counted(&service, &caller, q, |q| {
147        service.issue_urls(&caller, &site, &check, q.limit, q.offset)
148    })
149    .await
150}
151
152#[derive(Deserialize)]
153struct PageQuery {
154    url: Option<String>,
155}
156
157async fn page(
158    State(state): State<AppState>,
159    caller: ApiCaller,
160    Path(site): Path<String>,
161    q: ApiQuery<PageQuery>,
162) -> Response {
163    let service = AgentService::new(&state);
164    let (svc, who, site) = (&service, &caller, &site);
165    counted(&service, &caller, q, move |q| async move {
166        svc.page(who, site, q.url.as_deref().unwrap_or("")).await
167    })
168    .await
169}
170
171#[derive(Deserialize)]
172struct ChangesQuery {
173    severity: Option<String>,
174    limit: Option<u32>,
175    offset: Option<u32>,
176}
177
178async fn changes(
179    State(state): State<AppState>,
180    caller: ApiCaller,
181    Path(site): Path<String>,
182    q: ApiQuery<ChangesQuery>,
183) -> Response {
184    let service = AgentService::new(&state);
185    let (svc, who, site) = (&service, &caller, &site);
186    counted(&service, &caller, q, move |q| async move {
187        svc.changes(who, site, q.severity.as_deref(), q.limit, q.offset)
188            .await
189    })
190    .await
191}
192
193async fn run_crawl(
194    State(state): State<AppState>,
195    caller: ApiCaller,
196    Path(site): Path<String>,
197) -> Response {
198    respond(
199        StatusCode::ACCEPTED,
200        AgentService::new(&state).run_crawl(&caller, &site).await,
201    )
202}
203
204/// Not counted against the allowance.
205async fn usage(State(state): State<AppState>, caller: ApiCaller) -> Response {
206    respond(
207        StatusCode::OK,
208        AgentService::new(&state).usage(&caller).await,
209    )
210}