Add field endpoint
This commit is contained in:
150
crates/pile-dataset/src/serve/extract.rs
Normal file
150
crates/pile-dataset/src/serve/extract.rs
Normal file
@@ -0,0 +1,150 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, RawQuery, State},
|
||||
http::{StatusCode, header},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use pile_config::{Label, objectpath::ObjectPath};
|
||||
use pile_value::{extract::traits::ExtractState, value::PileValue};
|
||||
use serde::Deserialize;
|
||||
use std::{sync::Arc, time::Instant};
|
||||
use tracing::debug;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::Datasets;
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct ExtractQuery {
|
||||
source: String,
|
||||
key: String,
|
||||
|
||||
#[serde(default)]
|
||||
download: bool,
|
||||
}
|
||||
|
||||
/// 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 = "/extract",
|
||||
params(
|
||||
("source" = String, Query, description = "Source label"),
|
||||
("key" = String, Query, description = "Item key"),
|
||||
("path" = String, Query, description = "Object path (e.g. $.flac.title); repeat for fallbacks"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Field value as JSON"),
|
||||
(status = 400, description = "Invalid source label or path"),
|
||||
(status = 404, description = "Item or field not found"),
|
||||
(status = 500, description = "Internal server error"),
|
||||
)
|
||||
)]
|
||||
pub async fn get_extract(
|
||||
State(state): State<Arc<Datasets>>,
|
||||
Query(params): Query<ExtractQuery>,
|
||||
RawQuery(raw_query): RawQuery,
|
||||
) -> Response {
|
||||
let start = Instant::now();
|
||||
|
||||
let label = match Label::try_from(params.source.clone()) {
|
||||
Ok(l) => l,
|
||||
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<ObjectPath> = {
|
||||
let mut result = Vec::new();
|
||||
for part in raw.split('&') {
|
||||
if let Some((k, v)) = part.split_once('=')
|
||||
&& k == "path"
|
||||
{
|
||||
match v.parse::<ObjectPath>() {
|
||||
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 /extract",
|
||||
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 extract_state = ExtractState { ignore_mime: false };
|
||||
let item = PileValue::Item(item);
|
||||
|
||||
// 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 /extract",
|
||||
source = params.source,
|
||||
key = params.key,
|
||||
time_ms = start.elapsed().as_millis()
|
||||
);
|
||||
|
||||
let disposition = if params.download {
|
||||
"attachment"
|
||||
} else {
|
||||
"inline"
|
||||
};
|
||||
|
||||
match value {
|
||||
PileValue::String(s) => (
|
||||
StatusCode::OK,
|
||||
[
|
||||
(header::CONTENT_TYPE, "text/plain".to_owned()),
|
||||
(header::CONTENT_DISPOSITION, disposition.to_owned()),
|
||||
],
|
||||
s.to_string(),
|
||||
)
|
||||
.into_response(),
|
||||
PileValue::Blob { mime, bytes } => (
|
||||
StatusCode::OK,
|
||||
[
|
||||
(header::CONTENT_TYPE, mime.to_string()),
|
||||
(header::CONTENT_DISPOSITION, disposition.to_owned()),
|
||||
],
|
||||
bytes.as_ref().clone(),
|
||||
)
|
||||
.into_response(),
|
||||
_ => match value.to_json(&extract_state).await {
|
||||
Ok(json) => (
|
||||
StatusCode::OK,
|
||||
[(header::CONTENT_DISPOSITION, disposition.to_owned())],
|
||||
Json(json),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")).into_response(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,35 +1,36 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, RawQuery, State},
|
||||
extract::{Query, State},
|
||||
http::{StatusCode, header},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use pile_config::{Label, objectpath::ObjectPath};
|
||||
use pile_config::Label;
|
||||
use pile_value::{extract::traits::ExtractState, value::PileValue};
|
||||
use serde::Deserialize;
|
||||
use std::{sync::Arc, time::Instant};
|
||||
use tracing::debug;
|
||||
use utoipa::ToSchema;
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
use crate::Datasets;
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
#[derive(Deserialize, ToSchema, IntoParams)]
|
||||
pub struct FieldQuery {
|
||||
source: String,
|
||||
key: String,
|
||||
field: String,
|
||||
|
||||
#[serde(default)]
|
||||
download: bool,
|
||||
}
|
||||
|
||||
/// 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); repeat for fallbacks"),
|
||||
("field" = String, Query, description = "Schema field"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Field value as JSON"),
|
||||
@@ -41,7 +42,6 @@ pub struct FieldQuery {
|
||||
pub async fn get_field(
|
||||
State(state): State<Arc<Datasets>>,
|
||||
Query(params): Query<FieldQuery>,
|
||||
RawQuery(raw_query): RawQuery,
|
||||
) -> Response {
|
||||
let start = Instant::now();
|
||||
|
||||
@@ -50,46 +50,32 @@ pub async fn get_field(
|
||||
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<ObjectPath> = {
|
||||
let mut result = Vec::new();
|
||||
for part in raw.split('&') {
|
||||
if let Some((k, v)) = part.split_once('=')
|
||||
&& k == "path"
|
||||
{
|
||||
match v.parse::<ObjectPath>() {
|
||||
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(),
|
||||
field = params.field,
|
||||
);
|
||||
|
||||
let Some(item) = state.get(&label, ¶ms.key).await else {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
};
|
||||
|
||||
let field = match Label::new(¶ms.field) {
|
||||
Some(x) => x,
|
||||
None => return StatusCode::NOT_FOUND.into_response(),
|
||||
};
|
||||
|
||||
let paths = match state.config.schema.get(&field) {
|
||||
Some(x) => &x.path,
|
||||
None => return StatusCode::NOT_FOUND.into_response(),
|
||||
};
|
||||
|
||||
let extract_state = ExtractState { ignore_mime: false };
|
||||
let item = PileValue::Item(item);
|
||||
|
||||
// Try each path in order, returning the first non-null result
|
||||
let mut value = None;
|
||||
for path in &paths {
|
||||
for path in paths {
|
||||
match item.query(&extract_state, path).await {
|
||||
Ok(Some(PileValue::Null)) | Ok(None) => continue,
|
||||
Ok(Some(v)) => {
|
||||
@@ -108,6 +94,7 @@ pub async fn get_field(
|
||||
message = "Served /field",
|
||||
source = params.source,
|
||||
key = params.key,
|
||||
field = params.field,
|
||||
time_ms = start.elapsed().as_millis()
|
||||
);
|
||||
|
||||
|
||||
@@ -14,6 +14,9 @@ pub use lookup::*;
|
||||
mod item;
|
||||
pub use item::*;
|
||||
|
||||
mod extract;
|
||||
pub use extract::*;
|
||||
|
||||
mod field;
|
||||
pub use field::*;
|
||||
|
||||
@@ -23,12 +26,13 @@ pub use items::*;
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
tags(),
|
||||
paths(lookup, item_get, get_field, items_list),
|
||||
paths(lookup, item_get, get_extract, items_list, get_field),
|
||||
components(schemas(
|
||||
LookupRequest,
|
||||
LookupResponse,
|
||||
LookupResult,
|
||||
ItemQuery,
|
||||
ExtractQuery,
|
||||
FieldQuery,
|
||||
ItemsQuery,
|
||||
ItemsResponse,
|
||||
@@ -48,6 +52,7 @@ impl Datasets {
|
||||
let mut router = Router::new()
|
||||
.route("/lookup", post(lookup))
|
||||
.route("/item", get(item_get))
|
||||
.route("/extract", get(get_extract))
|
||||
.route("/field", get(get_field))
|
||||
.route("/items", get(items_list))
|
||||
.with_state(self.clone());
|
||||
|
||||
Reference in New Issue
Block a user