Skip to main content

phrona_api/
tools.rs

1//! Page extraction and engine-test endpoints.
2
3use std::sync::Arc;
4
5use axum::Json;
6use axum::extract::State;
7use axum::response::IntoResponse;
8use serde::Deserialize;
9use serde_json::{Value, json};
10
11use phrona::models::Category;
12
13use crate::{AppError, AppResult, AppState, HeaderAuth, JsonBody, JsonQuery};
14
15/// GET /v1/extract?url=...&max_chars=...&query=... - readable-text
16/// extraction of a page (the same feature as `phrona extract`). Auth is
17/// header-only: query-string credentials are rejected.
18#[derive(Deserialize)]
19pub struct ExtractGetParams {
20    url: String,
21    #[serde(default)]
22    max_chars: Option<usize>,
23    #[serde(default)]
24    query: Option<String>,
25}
26
27/// POST /v1/extract - same feature, credentials via headers or body.
28#[derive(Deserialize)]
29pub struct ExtractPostParams {
30    url: String,
31    #[serde(default)]
32    max_chars: Option<usize>,
33    #[serde(default)]
34    query: Option<String>,
35    #[serde(default)]
36    api_key: Option<String>,
37}
38
39/// GET /v1/test?query=...&category=...&max_results=... - availability probe
40/// across every category (the same feature as `phrona test`).
41#[derive(Deserialize)]
42pub struct TestParams {
43    #[serde(default)]
44    query: Option<String>,
45    #[serde(default)]
46    category: Option<String>,
47    #[serde(default)]
48    max_results: Option<usize>,
49}
50
51/// `GET /v1/extract?url=...`: header-auth variant of the page extraction
52/// endpoint.
53pub async fn extract_get(
54    State(state): State<Arc<AppState>>,
55    auth: HeaderAuth,
56    JsonQuery(p): JsonQuery<ExtractGetParams>,
57) -> AppResult<impl IntoResponse> {
58    if !state.authorized(auth.key()) {
59        return Err(AppError::unauthorized());
60    }
61    run_extract(&state, &p.url, p.max_chars, p.query.as_deref()).await
62}
63
64/// `POST /v1/extract`: body variant of the page extraction endpoint.
65pub async fn extract_post(
66    State(state): State<Arc<AppState>>,
67    headers: axum::http::HeaderMap,
68    JsonBody(p): JsonBody<ExtractPostParams>,
69) -> AppResult<impl IntoResponse> {
70    if !state.authorized(crate::auth_key(&headers, p.api_key.as_deref()).as_deref()) {
71        return Err(AppError::unauthorized());
72    }
73    run_extract(&state, &p.url, p.max_chars, p.query.as_deref()).await
74}
75
76async fn run_extract(
77    state: &AppState,
78    url: &str,
79    max_chars: Option<usize>,
80    query: Option<&str>,
81) -> AppResult<Json<phrona::ExtractedPage>> {
82    let max_chars = max_chars.unwrap_or(5000).clamp(1, 100_000);
83    let page = phrona::extract(state.client.http(), url, max_chars, query).await?;
84    Ok(Json(page))
85}
86
87/// `GET /v1/test`: probe engine availability across every category (or a
88/// single one) and return a per-category/per-engine report.
89pub async fn test(
90    State(state): State<Arc<AppState>>,
91    auth: HeaderAuth,
92    JsonQuery(p): JsonQuery<TestParams>,
93) -> AppResult<Json<Value>> {
94    if !state.authorized(auth.key()) {
95        return Err(AppError::unauthorized());
96    }
97    let cats: Vec<Category> = match p.category.as_deref() {
98        Some(c) => vec![c.parse::<Category>().map_err(|_| {
99            AppError::bad_request(format!(
100                "invalid category, expected one of: {}",
101                Category::list_str()
102            ))
103        })?],
104        None => Category::ALL.to_vec(),
105    };
106    let query = p.query.unwrap_or_else(|| "rust programming".to_string());
107    let max_results = p.max_results.unwrap_or(5).clamp(1, 10);
108
109    // categories probe concurrently: sequential probing stacks 8
110    // per-category deadlines into a minute-plus response (`join_all`
111    // preserves input order, so the report stays stable)
112    let client = &state.client;
113    let probes = cats.into_iter().map(|cat| {
114        let mut opts = phrona::SearchOptions::new(query.clone());
115        opts.category = cat;
116        opts.max_results = max_results;
117        // availability probing must observe every engine, not stop at the
118        // first ones that fill max_results
119        opts.probe_all = true;
120        async move {
121            match client.search(opts).await {
122                Ok(resp) => json!({
123                    "category": cat.as_str(),
124                    "total": resp.total,
125                    "elapsed_ms": resp.elapsed_ms,
126                    "answer": resp.answer,
127                    "engines": resp.engines,
128                }),
129                Err(e) => json!({
130                    "category": cat.as_str(),
131                    "total": 0,
132                    "elapsed_ms": 0,
133                    "answer": null,
134                    "engines": [],
135                    "error": e.to_string(),
136                }),
137            }
138        }
139    });
140    let out: Vec<Value> = futures::future::join_all(probes).await;
141    Ok(Json(Value::Array(out)))
142}