From 6f267880c838096ce572892bab346c675e9e1a96 Mon Sep 17 00:00:00 2001 From: rm-dr <96270320+rm-dr@users.noreply.github.com> Date: Mon, 23 Mar 2026 22:41:07 -0700 Subject: [PATCH] Many field paths --- crates/pile-dataset/src/serve/field.rs | 72 ++++++++++++++++++-------- 1 file changed, 51 insertions(+), 21 deletions(-) diff --git a/crates/pile-dataset/src/serve/field.rs b/crates/pile-dataset/src/serve/field.rs index 9e2e6df..0eaac4d 100644 --- a/crates/pile-dataset/src/serve/field.rs +++ b/crates/pile-dataset/src/serve/field.rs @@ -1,6 +1,6 @@ use axum::{ Json, - extract::{Query, State}, + extract::{Query, RawQuery, State}, http::{StatusCode, header}, response::{IntoResponse, Response}, }; @@ -17,19 +17,19 @@ use crate::Datasets; pub struct FieldQuery { source: String, key: String, - path: String, #[serde(default)] download: bool, } -/// Extract a specific field from an item's metadata +/// Extract a specific field from an item's metadata. +/// Multiple `path` parameters may be provided; the first non-null result is returned. #[utoipa::path( get, path = "/field", params( ("source" = String, Query, description = "Source label"), ("key" = String, Query, description = "Item key"), - ("path" = String, Query, description = "Object path (e.g. $.flac.title)"), + ("path" = String, Query, description = "Object path (e.g. $.flac.title); repeat for fallbacks"), ), responses( (status = 200, description = "Field value as JSON"), @@ -41,43 +41,73 @@ pub struct FieldQuery { pub async fn get_field( State(state): State>, Query(params): Query, + RawQuery(raw_query): RawQuery, ) -> Response { let start = Instant::now(); - debug!( - message = "Serving /field", - source = params.source, - key = params.key, - path = params.path, - ); let label = match Label::try_from(params.source.clone()) { Ok(l) => l, Err(e) => return (StatusCode::BAD_REQUEST, format!("{e:?}")).into_response(), }; - let path: ObjectPath = match params.path.parse() { - Ok(p) => p, - Err(e) => return (StatusCode::BAD_REQUEST, format!("{e:?}")).into_response(), + // Collect all `path` query params in order (supports repeated ?path=...&path=...) + let raw = raw_query.as_deref().unwrap_or(""); + let paths: Vec = { + let mut result = Vec::new(); + for part in raw.split('&') { + if let Some((k, v)) = part.split_once('=') + && k == "path" + { + match v.parse::() { + Ok(p) => result.push(p), + Err(e) => { + return (StatusCode::BAD_REQUEST, format!("{e:?}")).into_response(); + } + } + } + } + result }; + if paths.is_empty() { + return (StatusCode::BAD_REQUEST, "Missing `path` query parameter").into_response(); + } + + debug!( + message = "Serving /field", + source = params.source, + key = params.key, + paths = paths.len(), + ); + let Some(item) = state.get(&label, ¶ms.key).await else { return StatusCode::NOT_FOUND.into_response(); }; - let state = ExtractState { ignore_mime: false }; - + let extract_state = ExtractState { ignore_mime: false }; let item = PileValue::Item(item); - let value = match item.query(&state, &path).await { - Ok(Some(v)) => v, - Ok(None) => return StatusCode::NOT_FOUND.into_response(), - Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")).into_response(), + + // Try each path in order, returning the first non-null result + let mut value = None; + for path in &paths { + match item.query(&extract_state, path).await { + Ok(Some(PileValue::Null)) | Ok(None) => continue, + Ok(Some(v)) => { + value = Some(v); + break; + } + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")).into_response(), + } + } + + let Some(value) = value else { + return StatusCode::NOT_FOUND.into_response(); }; debug!( message = "Served /field", source = params.source, key = params.key, - path = params.path, time_ms = start.elapsed().as_millis() ); @@ -106,7 +136,7 @@ pub async fn get_field( bytes.as_ref().clone(), ) .into_response(), - _ => match value.to_json(&state).await { + _ => match value.to_json(&extract_state).await { Ok(json) => ( StatusCode::OK, [(header::CONTENT_DISPOSITION, disposition.to_owned())],