| fac6853c Andy McCall ↶ |
1 |
|
| 2 |
|
| 3 |
|
| 617c2840 Andy McCall ↶ |
4 |
|
| 5 |
use super::{caller, not_found, reach, ApiError}; |
| 6 |
use crate::auth::Access; |
| fac6853c Andy McCall ↶ |
7 |
use crate::{coverage, junit, reports, Shared}; |
| 617c2840 Andy McCall ↶ |
8 |
use axum::extract::{Path, Query, State}; |
| fac6853c Andy McCall ↶ |
9 |
use axum::http::{header, HeaderMap, HeaderValue}; |
| 10 |
use axum::response::{IntoResponse, Response}; |
| 617c2840 Andy McCall ↶ |
11 |
use axum::Json; |
| fac6853c Andy McCall ↶ |
12 |
use hub_api::{CoverageDetail, CoverageFile, CoverageReport, Report, ReportFile, ReportMetric, TestCase, TestReport, TestReportDetail}; |
| 617c2840 Andy McCall ↶ |
13 |
use serde::Deserialize; |
| 14 |
|
| 15 |
#[derive(Deserialize, Default)] |
| 16 |
pub struct ListQuery { |
| 17 |
|
| 18 |
bookmark: Option<String>, |
| 19 |
|
| 20 |
limit: Option<i64>, |
| 21 |
} |
| 22 |
|
| 23 |
#[derive(Deserialize, Default)] |
| 24 |
pub struct CasesQuery { |
| 25 |
|
| 26 |
status: Option<String>, |
| 27 |
} |
| 28 |
|
| 29 |
fn coverage_of(app: &Shared, base: &str, r: &coverage::Report) -> CoverageReport { |
| 30 |
CoverageReport { |
| 31 |
id: r.id, |
| 32 |
run: r.run_number, |
| 33 |
job: r.job.clone(), |
| 34 |
bookmark: r.bookmark.clone(), |
| 35 |
state: r.state.clone(), |
| 36 |
format: r.format.clone(), |
| 37 |
lines_found: r.lines_found, |
| 38 |
lines_hit: r.lines_hit, |
| 39 |
percent: r.percent(), |
| 40 |
functions_found: r.functions_found, |
| 41 |
functions_hit: r.functions_hit, |
| 42 |
branches_found: r.branches_found, |
| 43 |
branches_hit: r.branches_hit, |
| 44 |
files: r.files, |
| 45 |
dropped: r.dropped, |
| 46 |
created_ms: r.created_ms, |
| 47 |
url: format!("{}{base}/coverage?report={}", app.config.base_url().trim_end_matches('/'), r.id), |
| 40dd3f86 Andy McCall ↶ |
48 |
uploaded_by: r.uploaded().then(|| r.uploader.clone()), |
| 617c2840 Andy McCall ↶ |
49 |
} |
| 50 |
} |
| 51 |
|
| 52 |
fn tests_of(app: &Shared, base: &str, r: &junit::Report) -> TestReport { |
| 53 |
TestReport { |
| 54 |
id: r.id, |
| 55 |
run: r.run_number, |
| 56 |
job: r.job.clone(), |
| 57 |
bookmark: r.bookmark.clone(), |
| 58 |
state: r.state.clone(), |
| 59 |
tests: r.tests, |
| 60 |
passed: r.passed, |
| 61 |
failed: r.failed, |
| 62 |
errors: r.errors, |
| 63 |
skipped: r.skipped, |
| 64 |
time_ms: r.time_ms, |
| 65 |
created_ms: r.created_ms, |
| 66 |
url: format!("{}{base}/tests?report={}", app.config.base_url().trim_end_matches('/'), r.id), |
| 67 |
} |
| 68 |
} |
| 69 |
|
| 70 |
|
| 71 |
pub async fn coverage_list( |
| 72 |
State(app): State<Shared>, |
| 73 |
headers: HeaderMap, |
| 74 |
Path((owner, name)): Path<(String, String)>, |
| 75 |
Query(q): Query<ListQuery>, |
| 76 |
) -> Result<Json<Vec<CoverageReport>>, ApiError> { |
| 77 |
let c = caller(&app, &headers).await?; |
| 78 |
let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 79 |
let bookmark = q.bookmark.unwrap_or_else(|| row.default_bookmark.clone()); |
| 80 |
let list = coverage::on_bookmark(&app.db, row.id, &bookmark, q.limit.unwrap_or(20).clamp(1, 200)).await?; |
| 81 |
let base = format!("/{owner}/{name}"); |
| 82 |
Ok(Json(list.iter().map(|r| coverage_of(&app, &base, r)).collect())) |
| 83 |
} |
| 84 |
|
| 40dd3f86 Andy McCall ↶ |
85 |
#[derive(Deserialize)] |
| 86 |
pub struct Upload { |
| 87 |
|
| 88 |
|
| 89 |
#[serde(default)] |
| 90 |
bookmark: Option<String>, |
| 91 |
#[serde(default)] |
| 92 |
change: Option<String>, |
| 17dc8de4 Andy McCall ↶ |
93 |
|
| 40dd3f86 Andy McCall ↶ |
94 |
content: String, |
| 95 |
} |
| 96 |
|
| 97 |
|
| 98 |
|
| 99 |
|
| 100 |
|
| 101 |
pub async fn coverage_upload( |
| 102 |
State(app): State<Shared>, |
| 103 |
headers: HeaderMap, |
| 104 |
Path((owner, name)): Path<(String, String)>, |
| 105 |
Json(u): Json<Upload>, |
| 106 |
) -> Result<(axum::http::StatusCode, Json<CoverageReport>), ApiError> { |
| 107 |
use axum::http::StatusCode; |
| 108 |
use oxy_core::hash::ChangeHash; |
| 109 |
let c = caller(&app, &headers).await?; |
| 110 |
let me = c.require("repo:write")?.clone(); |
| 111 |
let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Write).await?; |
| 112 |
if u.content.len() > coverage::MAX_REPORT { |
| 113 |
return Err(ApiError(StatusCode::PAYLOAD_TOO_LARGE, format!("at most {} MiB of coverage is read", coverage::MAX_REPORT >> 20))); |
| 114 |
} |
| 115 |
let bad = |m: String| ApiError(StatusCode::BAD_REQUEST, m); |
| 116 |
|
| 117 |
let (bookmark, heads) = match u.change.as_deref().map(str::trim).filter(|c| !c.is_empty()) { |
| 118 |
Some(ch) => { |
| 119 |
let h = ChangeHash::from_hex(ch.trim_start_matches('#')).ok_or_else(|| bad("give the full hash of a change".into()))?; |
| 120 |
(String::new(), vec![h]) |
| 121 |
} |
| 122 |
None => { |
| 123 |
let b = u.bookmark.clone().filter(|b| !b.is_empty()).unwrap_or_else(|| row.default_bookmark.clone()); |
| 124 |
let bb = b.clone(); |
| 125 |
let heads = app |
| 126 |
.repos |
| 127 |
.read(&repo, move |r, _| match r.view()?.bookmarks.get(&bb) { |
| 128 |
Some(st) => Ok(Some(r.store().get_state(st)?.heads)), |
| 129 |
None => Ok(None), |
| 130 |
}) |
| 131 |
.await |
| 132 |
.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? |
| 133 |
.ok_or_else(|| bad(format!("no bookmark {b}")))?; |
| 134 |
(b, heads) |
| 135 |
} |
| 136 |
}; |
| 137 |
let state = |
| 138 |
oxy_core::hash::hash_object(oxy_core::hash::ObjectType::State, &oxy_core::objects::StateObject::new(heads.clone()).encode()); |
| 139 |
let text = u.content; |
| 140 |
let (files, outside, format) = tokio::task::spawn_blocking({ |
| 141 |
let t = text.clone(); |
| 142 |
move || coverage::parse(&t) |
| 143 |
}) |
| 144 |
.await |
| 145 |
.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? |
| 146 |
.map_err(|e| bad(format!("coverage: {e}")))?; |
| 147 |
let h = heads.clone(); |
| 148 |
let (blob, in_state) = app |
| 149 |
.repos |
| 150 |
.write(&repo, move |r, cache| { |
| 151 |
let files: std::collections::HashSet<String> = cache.load(r, &h)?.snapshot.files.keys().cloned().collect(); |
| 152 |
Ok((r.store_blob(text.as_bytes())?, files)) |
| 153 |
}) |
| 154 |
.await |
| 155 |
.map_err(|e| bad(format!("that state's files could not be read: {e}")))?; |
| 156 |
let hex: Vec<String> = heads.iter().map(|h| h.to_hex()).collect(); |
| 157 |
let origin = coverage::Origin { |
| 158 |
repo_id: row.id, |
| 159 |
run_id: None, |
| 160 |
run_number: 0, |
| 161 |
job_id: None, |
| 162 |
uploader: &me.username, |
| 163 |
job: "upload", |
| 164 |
bookmark: &bookmark, |
| 165 |
state: &state.to_hex(), |
| 166 |
heads: &hex, |
| 167 |
blob: &blob.to_hex(), |
| 168 |
format, |
| 169 |
}; |
| 170 |
let id = coverage::keep(&app, &origin, files, outside, &in_state).await?; |
| 171 |
let r = coverage::report(&app.db, row.id, id).await?.ok_or_else(|| not_found("the report was not kept"))?; |
| 172 |
crate::db::accounts::audit(&app.db, Some(&me), "coverage.upload", &format!("{owner}/{name}"), &r.source()).await.ok(); |
| 173 |
Ok((StatusCode::CREATED, Json(coverage_of(&app, &format!("/{owner}/{name}"), &r)))) |
| 174 |
} |
| 175 |
|
| 617c2840 Andy McCall ↶ |
176 |
|
| 177 |
pub async fn coverage_one( |
| 178 |
State(app): State<Shared>, |
| 179 |
headers: HeaderMap, |
| 180 |
Path((owner, name, id)): Path<(String, String, i64)>, |
| 181 |
) -> Result<Json<CoverageDetail>, ApiError> { |
| 182 |
let c = caller(&app, &headers).await?; |
| 183 |
let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 184 |
let r = coverage::report(&app.db, row.id, id).await?.ok_or_else(|| not_found("no such coverage report"))?; |
| 185 |
let files = coverage::files(&app.db, r.id) |
| 186 |
.await? |
| 187 |
.into_iter() |
| 188 |
.map(|f| CoverageFile { |
| 189 |
percent: f.percent(), |
| 190 |
path: f.path, |
| 191 |
lines_found: f.lines_found, |
| 192 |
lines_hit: f.lines_hit, |
| 193 |
functions_found: f.functions_found, |
| 194 |
functions_hit: f.functions_hit, |
| 195 |
branches_found: f.branches_found, |
| 196 |
branches_hit: f.branches_hit, |
| 197 |
}) |
| 198 |
.collect(); |
| 199 |
Ok(Json(CoverageDetail { report: coverage_of(&app, &format!("/{owner}/{name}"), &r), files })) |
| 200 |
} |
| 201 |
|
| 202 |
|
| 203 |
pub async fn tests_list( |
| 204 |
State(app): State<Shared>, |
| 205 |
headers: HeaderMap, |
| 206 |
Path((owner, name)): Path<(String, String)>, |
| 207 |
Query(q): Query<ListQuery>, |
| 208 |
) -> Result<Json<Vec<TestReport>>, ApiError> { |
| 209 |
let c = caller(&app, &headers).await?; |
| 210 |
let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 211 |
let bookmark = q.bookmark.unwrap_or_else(|| row.default_bookmark.clone()); |
| 212 |
let list = junit::on_bookmark(&app.db, row.id, &bookmark, q.limit.unwrap_or(20).clamp(1, 200)).await?; |
| 213 |
let base = format!("/{owner}/{name}"); |
| 214 |
Ok(Json(list.iter().map(|r| tests_of(&app, &base, r)).collect())) |
| 215 |
} |
| 216 |
|
| 217 |
|
| 218 |
pub async fn tests_one( |
| 219 |
State(app): State<Shared>, |
| 220 |
headers: HeaderMap, |
| 221 |
Path((owner, name, id)): Path<(String, String, i64)>, |
| 222 |
Query(q): Query<CasesQuery>, |
| 223 |
) -> Result<Json<TestReportDetail>, ApiError> { |
| 224 |
let c = caller(&app, &headers).await?; |
| 225 |
let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 226 |
let r = junit::report(&app.db, row.id, id).await?.ok_or_else(|| not_found("no such test report"))?; |
| 227 |
let statuses: &[&str] = match q.status.as_deref() { |
| 228 |
Some("failed") => &["failed", "error"], |
| 229 |
_ => &[], |
| 230 |
}; |
| 231 |
let cases = junit::cases(&app.db, r.id, statuses, junit::MAX_CASES as i64) |
| 232 |
.await? |
| 233 |
.into_iter() |
| 234 |
.map(|c| TestCase { |
| 235 |
suite: c.suite, |
| 236 |
classname: c.classname, |
| 237 |
name: c.name, |
| 238 |
status: c.status, |
| 239 |
time_ms: c.time_ms, |
| 240 |
message: c.message, |
| 241 |
output: c.output, |
| 242 |
}) |
| 243 |
.collect(); |
| 244 |
Ok(Json(TestReportDetail { report: tests_of(&app, &format!("/{owner}/{name}"), &r), cases })) |
| 245 |
} |
| fac6853c Andy McCall ↶ |
246 |
|
| 247 |
#[derive(Deserialize, Default)] |
| 248 |
pub struct ReportQuery { |
| 249 |
|
| 250 |
name: Option<String>, |
| 251 |
|
| 252 |
id: Option<i64>, |
| 253 |
|
| 254 |
|
| 255 |
bookmark: Option<String>, |
| 256 |
} |
| 257 |
|
| 258 |
|
| 259 |
|
| 260 |
async fn report_of(app: &Shared, base: &str, r: &reports::Report, files: bool) -> Result<Report, ApiError> { |
| 261 |
let metrics = reports::metrics(&app.db, r.id) |
| 262 |
.await? |
| 263 |
.into_iter() |
| 264 |
.map(|m| ReportMetric { |
| 265 |
key: m.key, |
| 266 |
value: m.value, |
| 267 |
unit: m.unit, |
| 268 |
better: m.better, |
| 269 |
fail_above: m.fail_above, |
| 270 |
fail_below: m.fail_below, |
| 271 |
}) |
| 272 |
.collect(); |
| 273 |
let manifest = if files { |
| 274 |
reports::files_of(&app.db, r.id).await?.into_iter().map(|(path, size)| ReportFile { path, size }).collect() |
| 275 |
} else { |
| 276 |
vec![] |
| 277 |
}; |
| 278 |
Ok(Report { |
| 279 |
id: r.id, |
| 280 |
name: r.name.clone(), |
| 281 |
kind: r.kind.clone(), |
| 282 |
title: r.title.clone(), |
| 283 |
summary: r.summary.clone(), |
| 284 |
metrics, |
| 285 |
context: serde_json::from_str(&r.context).unwrap_or_default(), |
| 286 |
run: r.run_number, |
| 287 |
job: r.job.clone(), |
| 288 |
bookmark: r.bookmark.clone(), |
| 289 |
state: r.state.clone(), |
| 290 |
files: r.files, |
| 291 |
bytes: r.bytes, |
| 292 |
created_ms: r.created_ms, |
| 293 |
files_expired_ms: r.files_expired_ms, |
| 294 |
url: format!("{}{base}/reports/{}?id={}", app.config.base_url().trim_end_matches('/'), r.name, r.id), |
| 295 |
manifest, |
| 296 |
}) |
| 297 |
} |
| 298 |
|
| 299 |
|
| 300 |
|
| 301 |
async fn job_report(app: &Shared, repo_id: i64, number: i64, job: &str, name: Option<&str>) -> Result<reports::Report, ApiError> { |
| 302 |
let run = crate::db::flow::run(&app.db, repo_id, number).await?.ok_or_else(|| not_found(&format!("no run #{number}")))?; |
| 303 |
let jobs = crate::db::flow::jobs(&app.db, run.id).await?; |
| 304 |
let Some(j) = jobs.iter().find(|j| j.name == job) else { |
| 305 |
let names: Vec<&str> = jobs.iter().map(|j| j.name.as_str()).collect(); |
| 306 |
return Err(not_found(&format!("run #{number} has no job {job}; its jobs: {}", names.join(", ")))); |
| 307 |
}; |
| 308 |
let mut list = reports::of_job(&app.db, j.id).await?; |
| 309 |
let names = || list.iter().map(|r| r.name.as_str()).collect::<Vec<_>>().join(", "); |
| 310 |
match name { |
| 311 |
Some(n) => match list.iter().position(|r| r.name == n) { |
| 312 |
Some(i) => Ok(list.swap_remove(i)), |
| 313 |
None if list.is_empty() => Err(not_found(&format!("job {job} of run #{number} published no report"))), |
| 314 |
None => Err(not_found(&format!("job {job} of run #{number} published no report {n}; it published {}", names()))), |
| 315 |
}, |
| 316 |
None if list.len() == 1 => Ok(list.remove(0)), |
| 317 |
None if list.is_empty() => Err(not_found(&format!("job {job} of run #{number} published no report"))), |
| 318 |
None => Err(ApiError( |
| 319 |
axum::http::StatusCode::BAD_REQUEST, |
| 320 |
format!("job {job} of run #{number} published {} reports ({}): say which with ?name=", list.len(), names()), |
| 321 |
)), |
| 322 |
} |
| 323 |
} |
| 324 |
|
| 325 |
|
| 326 |
|
| 327 |
|
| 328 |
async fn named_report(app: &Shared, row: &crate::db::repos::RepoRow, name: &str, q: &ReportQuery) -> Result<reports::Report, ApiError> { |
| 329 |
if let Some(id) = q.id { |
| 330 |
return reports::get(&app.db, row.id, id) |
| 331 |
.await? |
| 332 |
.filter(|r| r.name == name) |
| 333 |
.ok_or_else(|| not_found(&format!("report {name} has no run with id {id}"))); |
| 334 |
} |
| 335 |
let bookmarks = reports::bookmarks_of(&app.db, row.id, name).await?; |
| 336 |
if bookmarks.is_empty() { |
| 337 |
return Err(not_found(&format!("no report {name} in {}/{}", row.owner, row.name))); |
| 338 |
} |
| 339 |
let bookmark = match q.bookmark.clone().filter(|b| !b.is_empty()) { |
| 340 |
Some(b) if bookmarks.contains(&b) => b, |
| 341 |
Some(b) => return Err(not_found(&format!("report {name} never ran on {b}; it ran on {}", bookmarks.join(", ")))), |
| 342 |
None => bookmarks.iter().find(|b| **b == row.default_bookmark).unwrap_or(&bookmarks[0]).clone(), |
| 343 |
}; |
| 344 |
reports::history(&app.db, row.id, name, &bookmark, 1).await?.into_iter().next().ok_or_else(|| not_found("no such report")) |
| 345 |
} |
| 346 |
|
| 347 |
|
| 348 |
|
| 349 |
|
| 350 |
async fn report_file(app: &Shared, repo: &crate::repos::RepoName, r: &reports::Report, path: &str) -> Result<Response, ApiError> { |
| 351 |
if let Some(when) = r.files_expired_ms { |
| 352 |
return Err(ApiError( |
| 353 |
axum::http::StatusCode::GONE, |
| 354 |
format!("report {} (id {}) lost its files under retention at {when} ms; its metrics stay", r.name, r.id), |
| 355 |
)); |
| 356 |
} |
| 357 |
let Some((blob, _)) = reports::file(&app.db, r.id, path).await? else { |
| 358 |
let files: Vec<String> = reports::files_of(&app.db, r.id).await?.into_iter().map(|(p, _)| p).take(20).collect(); |
| 359 |
return Err(not_found(&format!("report {} has no file {path}; it has {}", r.name, files.join(", ")))); |
| 360 |
}; |
| 361 |
let oref = oxy_core::hash::ObjectRef::from_hex(&blob).ok_or_else(|| not_found("the file's blob is unreadable"))?; |
| 362 |
let bytes = app |
| 363 |
.repos |
| 364 |
.read(repo, move |r, _| Ok(r.read_blob(&oref)?)) |
| 365 |
.await |
| 366 |
.map_err(|e| ApiError(axum::http::StatusCode::INTERNAL_SERVER_ERROR, format!("the file could not be read: {e}")))?; |
| 367 |
let mut res = (axum::http::StatusCode::OK, bytes).into_response(); |
| 368 |
let h = res.headers_mut(); |
| 369 |
h.insert(header::CONTENT_TYPE, HeaderValue::from_static(crate::web::reports::content_type(path))); |
| 370 |
h.insert(header::CONTENT_SECURITY_POLICY, HeaderValue::from_static("sandbox; default-src 'none'")); |
| 371 |
h.insert(header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff")); |
| 372 |
Ok(res) |
| 373 |
} |
| 374 |
|
| 375 |
|
| 376 |
|
| 377 |
pub async fn job_reports( |
| 378 |
State(app): State<Shared>, |
| 379 |
headers: HeaderMap, |
| 380 |
Path((owner, name, number, job)): Path<(String, String, i64, String)>, |
| 381 |
) -> Result<Json<Vec<Report>>, ApiError> { |
| 382 |
let c = caller(&app, &headers).await?; |
| 383 |
let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 384 |
let run = crate::db::flow::run(&app.db, row.id, number).await?.ok_or_else(|| not_found(&format!("no run #{number}")))?; |
| 385 |
let j = crate::db::flow::jobs(&app.db, run.id) |
| 386 |
.await? |
| 387 |
.into_iter() |
| 388 |
.find(|j| j.name == job) |
| 389 |
.ok_or_else(|| not_found(&format!("run #{number} has no job {job}")))?; |
| 390 |
let base = format!("/{owner}/{name}"); |
| 391 |
let mut out = vec![]; |
| 392 |
for r in reports::of_job(&app.db, j.id).await? { |
| 393 |
out.push(report_of(&app, &base, &r, false).await?); |
| 394 |
} |
| 395 |
Ok(Json(out)) |
| 396 |
} |
| 397 |
|
| 398 |
|
| 399 |
|
| 400 |
pub async fn job_report_one( |
| 401 |
State(app): State<Shared>, |
| 402 |
headers: HeaderMap, |
| 403 |
Path((owner, name, number, job)): Path<(String, String, i64, String)>, |
| 404 |
Query(q): Query<ReportQuery>, |
| 405 |
) -> Result<Json<Report>, ApiError> { |
| 406 |
let c = caller(&app, &headers).await?; |
| 407 |
let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 408 |
let r = job_report(&app, row.id, number, &job, q.name.as_deref()).await?; |
| 409 |
Ok(Json(report_of(&app, &format!("/{owner}/{name}"), &r, true).await?)) |
| 410 |
} |
| 411 |
|
| 412 |
|
| 413 |
|
| 414 |
pub async fn job_report_file( |
| 415 |
State(app): State<Shared>, |
| 416 |
headers: HeaderMap, |
| 417 |
Path((owner, name, number, job, path)): Path<(String, String, i64, String, String)>, |
| 418 |
Query(q): Query<ReportQuery>, |
| 419 |
) -> Result<Response, ApiError> { |
| 420 |
let c = caller(&app, &headers).await?; |
| 421 |
let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 422 |
let r = job_report(&app, row.id, number, &job, q.name.as_deref()).await?; |
| 423 |
report_file(&app, &repo, &r, &path).await |
| 424 |
} |
| 425 |
|
| 426 |
|
| 427 |
|
| 428 |
pub async fn latest_reports( |
| 429 |
State(app): State<Shared>, |
| 430 |
headers: HeaderMap, |
| 431 |
Path((owner, name)): Path<(String, String)>, |
| 432 |
Query(q): Query<ReportQuery>, |
| 433 |
) -> Result<Json<Vec<Report>>, ApiError> { |
| 434 |
let c = caller(&app, &headers).await?; |
| 435 |
let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 436 |
let base = format!("/{owner}/{name}"); |
| 437 |
let mut out = vec![]; |
| 438 |
for r in reports::latest(&app.db, row.id, q.bookmark.as_deref().filter(|b| !b.is_empty())).await? { |
| 439 |
out.push(report_of(&app, &base, &r, false).await?); |
| 440 |
} |
| 441 |
Ok(Json(out)) |
| 442 |
} |
| 443 |
|
| 444 |
|
| 445 |
|
| 446 |
pub async fn named_report_one( |
| 447 |
State(app): State<Shared>, |
| 448 |
headers: HeaderMap, |
| 449 |
Path((owner, name, report)): Path<(String, String, String)>, |
| 450 |
Query(q): Query<ReportQuery>, |
| 451 |
) -> Result<Json<Report>, ApiError> { |
| 452 |
let c = caller(&app, &headers).await?; |
| 453 |
let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 454 |
let r = named_report(&app, &row, &report, &q).await?; |
| 455 |
Ok(Json(report_of(&app, &format!("/{owner}/{name}"), &r, true).await?)) |
| 456 |
} |
| 457 |
|
| 458 |
|
| 459 |
pub async fn named_report_file( |
| 460 |
State(app): State<Shared>, |
| 461 |
headers: HeaderMap, |
| 462 |
Path((owner, name, report, path)): Path<(String, String, String, String)>, |
| 463 |
Query(q): Query<ReportQuery>, |
| 464 |
) -> Result<Response, ApiError> { |
| 465 |
let c = caller(&app, &headers).await?; |
| 466 |
let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 467 |
let r = named_report(&app, &row, &report, &q).await?; |
| 468 |
report_file(&app, &repo, &r, &path).await |
| 469 |
} |