refactor: extract platform media crates
This commit is contained in:
67
server-rs/crates/platform-hyper3d/src/response/downloads.rs
Normal file
67
server-rs/crates/platform-hyper3d/src/response/downloads.rs
Normal file
@@ -0,0 +1,67 @@
|
||||
use serde_json::Value;
|
||||
|
||||
pub(crate) fn extract_download_files(
|
||||
payload: &Value,
|
||||
) -> Vec<shared_contracts::hyper3d::Hyper3dDownloadFilePayload> {
|
||||
let mut files = Vec::new();
|
||||
collect_download_files(payload, &mut files);
|
||||
let mut deduped = Vec::new();
|
||||
for file in files {
|
||||
if !deduped.iter().any(
|
||||
|entry: &shared_contracts::hyper3d::Hyper3dDownloadFilePayload| entry.url == file.url,
|
||||
) {
|
||||
deduped.push(file);
|
||||
}
|
||||
}
|
||||
deduped
|
||||
}
|
||||
|
||||
fn collect_download_files(
|
||||
value: &Value,
|
||||
output: &mut Vec<shared_contracts::hyper3d::Hyper3dDownloadFilePayload>,
|
||||
) {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
let maybe_url = object
|
||||
.get("url")
|
||||
.or_else(|| object.get("download_url"))
|
||||
.or_else(|| object.get("downloadUrl"))
|
||||
.or_else(|| object.get("file_url"))
|
||||
.or_else(|| object.get("fileUrl"))
|
||||
.or_else(|| object.get("signed_url"))
|
||||
.or_else(|| object.get("signedUrl"))
|
||||
.or_else(|| object.get("presigned_url"))
|
||||
.or_else(|| object.get("presignedUrl"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| value.starts_with("http://") || value.starts_with("https://"));
|
||||
if let Some(url) = maybe_url {
|
||||
let name = object
|
||||
.get("name")
|
||||
.or_else(|| object.get("file_name"))
|
||||
.or_else(|| object.get("filename"))
|
||||
.or_else(|| object.get("fileName"))
|
||||
.or_else(|| object.get("display_name"))
|
||||
.or_else(|| object.get("displayName"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("model")
|
||||
.to_string();
|
||||
output.push(shared_contracts::hyper3d::Hyper3dDownloadFilePayload {
|
||||
name,
|
||||
url: url.to_string(),
|
||||
});
|
||||
}
|
||||
for nested in object.values() {
|
||||
collect_download_files(nested, output);
|
||||
}
|
||||
}
|
||||
Value::Array(items) => {
|
||||
for item in items {
|
||||
collect_download_files(item, output);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
159
server-rs/crates/platform-hyper3d/src/response/parsing.rs
Normal file
159
server-rs/crates/platform-hyper3d/src/response/parsing.rs
Normal file
@@ -0,0 +1,159 @@
|
||||
use serde_json::Value;
|
||||
|
||||
pub(crate) fn parse_api_error_message(raw_text: &str, fallback_message: &str) -> String {
|
||||
if let Ok(parsed) = serde_json::from_str::<Value>(raw_text) {
|
||||
for key in ["message", "detail", "error"] {
|
||||
if let Some(message) = find_first_string_by_key(&parsed, key)
|
||||
&& !message.trim().is_empty()
|
||||
{
|
||||
return message;
|
||||
}
|
||||
}
|
||||
}
|
||||
raw_text
|
||||
.trim()
|
||||
.chars()
|
||||
.take(240)
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.to_string()
|
||||
.chars()
|
||||
.next()
|
||||
.map(|_| raw_text.trim().chars().take(240).collect())
|
||||
.unwrap_or_else(|| fallback_message.to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn find_first_array_by_keys<'a>(
|
||||
value: &'a Value,
|
||||
keys: &[&str],
|
||||
) -> Option<&'a Vec<Value>> {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
for (key, value) in object {
|
||||
if keys.iter().any(|target| key.eq_ignore_ascii_case(target))
|
||||
&& let Some(array) = value.as_array()
|
||||
{
|
||||
return Some(array);
|
||||
}
|
||||
if let Some(found) = find_first_array_by_keys(value, keys) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
Value::Array(items) => items
|
||||
.iter()
|
||||
.find_map(|item| find_first_array_by_keys(item, keys)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn find_first_string_by_keys(value: &Value, keys: &[&str]) -> Option<String> {
|
||||
keys.iter()
|
||||
.find_map(|key| find_first_string_by_key(value, key))
|
||||
}
|
||||
|
||||
pub(crate) fn find_first_f64_by_keys(value: &Value, keys: &[&str]) -> Option<f64> {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
for (key, value) in object {
|
||||
if keys.iter().any(|target| key.eq_ignore_ascii_case(target))
|
||||
&& let Some(number) = value.as_f64()
|
||||
{
|
||||
return Some(number);
|
||||
}
|
||||
if let Some(found) = find_first_f64_by_keys(value, keys) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
Value::Array(items) => items
|
||||
.iter()
|
||||
.find_map(|item| find_first_f64_by_keys(item, keys)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn find_first_string_by_key(value: &Value, target_key: &str) -> Option<String> {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
for (key, value) in object {
|
||||
if key.eq_ignore_ascii_case(target_key)
|
||||
&& let Some(text) = value.as_str()
|
||||
{
|
||||
return Some(text.trim().to_string());
|
||||
}
|
||||
if let Some(found) = find_first_string_by_key(value, target_key) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
Value::Array(items) => items
|
||||
.iter()
|
||||
.find_map(|item| find_first_string_by_key(item, target_key)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn find_root_string_by_keys(value: &Value, keys: &[&str]) -> Option<String> {
|
||||
let object = value.as_object()?;
|
||||
for key in keys {
|
||||
if let Some(text) = object
|
||||
.iter()
|
||||
.find(|(candidate, _)| candidate.eq_ignore_ascii_case(key))
|
||||
.and_then(|(_, value)| value.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
return Some(text.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn collect_strings_by_keys(value: &Value, keys: &[&str]) -> Vec<String> {
|
||||
let mut results = Vec::new();
|
||||
collect_strings(value, keys, &mut results);
|
||||
let mut deduped = Vec::new();
|
||||
for result in results {
|
||||
if !deduped.contains(&result) {
|
||||
deduped.push(result);
|
||||
}
|
||||
}
|
||||
deduped
|
||||
}
|
||||
|
||||
fn collect_strings(value: &Value, keys: &[&str], output: &mut Vec<String>) {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
for (key, value) in object {
|
||||
if keys.iter().any(|target| key.eq_ignore_ascii_case(target)) {
|
||||
match value {
|
||||
Value::String(text) if !text.trim().is_empty() => {
|
||||
output.push(text.trim().to_string());
|
||||
}
|
||||
Value::Array(items) => {
|
||||
for item in items {
|
||||
if let Some(text) = item.as_str().map(str::trim)
|
||||
&& !text.is_empty()
|
||||
{
|
||||
output.push(text.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
collect_strings(value, keys, output);
|
||||
}
|
||||
}
|
||||
Value::Array(items) => {
|
||||
for item in items {
|
||||
collect_strings(item, keys, output);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
69
server-rs/crates/platform-hyper3d/src/response/status.rs
Normal file
69
server-rs/crates/platform-hyper3d/src/response/status.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
use serde_json::Value;
|
||||
|
||||
pub(crate) fn normalize_task_status(status: &str) -> String {
|
||||
match status.trim().to_ascii_lowercase().as_str() {
|
||||
"waiting" | "pending" | "queued" => "waiting".to_string(),
|
||||
"generating" | "running" | "processing" => "generating".to_string(),
|
||||
"done" | "finished" | "completed" | "success" | "succeeded" => "done".to_string(),
|
||||
"failed" | "error" | "canceled" | "cancelled" => "failed".to_string(),
|
||||
_ => "unknown".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn extract_job_statuses(
|
||||
payload: &Value,
|
||||
) -> Vec<shared_contracts::hyper3d::Hyper3dJobStatusPayload> {
|
||||
let Some(array) = super::parsing::find_first_array_by_keys(payload, &["jobs", "tasks"]) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
array
|
||||
.iter()
|
||||
.filter_map(|value| {
|
||||
let status = super::parsing::find_first_string_by_keys(value, &["status", "state"])
|
||||
.map(|value| normalize_task_status(&value))?;
|
||||
Some(shared_contracts::hyper3d::Hyper3dJobStatusPayload {
|
||||
uuid: super::parsing::find_first_string_by_keys(
|
||||
value,
|
||||
&["uuid", "task_uuid", "taskUuid"],
|
||||
),
|
||||
progress: super::parsing::find_first_f64_by_keys(
|
||||
value,
|
||||
&["progress", "percentage"],
|
||||
)
|
||||
.map(|value| value as f32),
|
||||
message: super::parsing::find_first_string_by_keys(
|
||||
value,
|
||||
&["message", "detail", "error"],
|
||||
),
|
||||
status,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_hyper3d_overall_status(
|
||||
payload: &Value,
|
||||
jobs: &[shared_contracts::hyper3d::Hyper3dJobStatusPayload],
|
||||
) -> String {
|
||||
if !jobs.is_empty() {
|
||||
if jobs.iter().any(|job| job.status == "failed") {
|
||||
return "failed".to_string();
|
||||
}
|
||||
if jobs.iter().all(|job| job.status == "done") {
|
||||
return "done".to_string();
|
||||
}
|
||||
if jobs.iter().any(|job| job.status == "generating") {
|
||||
return "generating".to_string();
|
||||
}
|
||||
if jobs.iter().any(|job| job.status == "waiting") {
|
||||
return "waiting".to_string();
|
||||
}
|
||||
return "unknown".to_string();
|
||||
}
|
||||
normalize_task_status(
|
||||
super::parsing::find_first_string_by_key(payload, "status")
|
||||
.as_deref()
|
||||
.unwrap_or("unknown"),
|
||||
)
|
||||
}
|
||||
64
server-rs/crates/platform-hyper3d/src/response/submit.rs
Normal file
64
server-rs/crates/platform-hyper3d/src/response/submit.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
error::Hyper3dError,
|
||||
types::{HYPER3D_PROVIDER, RODIN_GEN2_TIER},
|
||||
};
|
||||
|
||||
pub(crate) fn build_submit_response(
|
||||
mode: shared_contracts::hyper3d::Hyper3dGenerationMode,
|
||||
response: Value,
|
||||
) -> Result<shared_contracts::hyper3d::Hyper3dTaskSubmitResponse, Hyper3dError> {
|
||||
let task_uuid =
|
||||
super::parsing::find_root_string_by_keys(&response, &["uuid", "task_uuid", "taskUuid"])
|
||||
.or_else(|| {
|
||||
super::parsing::find_first_string_by_keys(&response, &["task_uuid", "taskUuid"])
|
||||
})
|
||||
.ok_or_else(|| Hyper3dError::missing_field("Hyper3D 已响应,但未返回任务 uuid"))?;
|
||||
let subscription_key = super::parsing::find_root_string_by_keys(
|
||||
&response,
|
||||
&["subscription_key", "subscriptionKey"],
|
||||
)
|
||||
.or_else(|| {
|
||||
super::parsing::find_first_string_by_keys(
|
||||
&response,
|
||||
&["subscription_key", "subscriptionKey"],
|
||||
)
|
||||
})
|
||||
.ok_or_else(|| Hyper3dError::missing_field("Hyper3D 已响应,但未返回 subscription_key"))?;
|
||||
let job_uuids = extract_job_uuids(&response);
|
||||
let message = super::parsing::find_first_string_by_keys(&response, &["message", "detail"]);
|
||||
|
||||
Ok(shared_contracts::hyper3d::Hyper3dTaskSubmitResponse {
|
||||
ok: true,
|
||||
provider: HYPER3D_PROVIDER.to_string(),
|
||||
mode,
|
||||
task_uuid,
|
||||
subscription_key,
|
||||
job_uuids,
|
||||
message,
|
||||
tier: RODIN_GEN2_TIER.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_job_uuids(payload: &Value) -> Vec<String> {
|
||||
let mut job_uuids = Vec::new();
|
||||
if let Some(jobs) = payload.get("jobs") {
|
||||
for uuid in super::parsing::collect_strings_by_keys(
|
||||
jobs,
|
||||
&["uuid", "task_uuid", "taskUuid", "uuids"],
|
||||
) {
|
||||
if !job_uuids.contains(&uuid) {
|
||||
job_uuids.push(uuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
for uuid in
|
||||
super::parsing::collect_strings_by_keys(payload, &["job_uuids", "jobUuids", "uuids"])
|
||||
{
|
||||
if !job_uuids.contains(&uuid) {
|
||||
job_uuids.push(uuid);
|
||||
}
|
||||
}
|
||||
job_uuids
|
||||
}
|
||||
88
server-rs/crates/platform-hyper3d/src/response/tests.rs
Normal file
88
server-rs/crates/platform-hyper3d/src/response/tests.rs
Normal file
@@ -0,0 +1,88 @@
|
||||
use serde_json::json;
|
||||
use shared_contracts::hyper3d as contract;
|
||||
|
||||
use super::{
|
||||
build_submit_response, extract_download_files, extract_job_statuses,
|
||||
resolve_hyper3d_overall_status,
|
||||
};
|
||||
use super::status::normalize_task_status;
|
||||
|
||||
#[test]
|
||||
fn extracts_submit_response_from_nested_payload() {
|
||||
let response = build_submit_response(
|
||||
contract::Hyper3dGenerationMode::TextToModel,
|
||||
json!({
|
||||
"uuid": "task-1",
|
||||
"jobs": {
|
||||
"uuids": ["job-1", "job-2"],
|
||||
"subscription_key": "sub-1"
|
||||
},
|
||||
"message": "submitted"
|
||||
}),
|
||||
)
|
||||
.expect("submit response should build");
|
||||
|
||||
assert_eq!(response.task_uuid, "task-1");
|
||||
assert_eq!(response.subscription_key, "sub-1");
|
||||
assert_eq!(response.job_uuids, vec!["job-1", "job-2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_download_files_from_file_url_aliases() {
|
||||
let files = extract_download_files(&json!({
|
||||
"result": {
|
||||
"files": [
|
||||
{
|
||||
"fileName": "rodin-result.glb",
|
||||
"fileUrl": "https://cdn.example/rodin-result.glb?token=1"
|
||||
},
|
||||
{
|
||||
"displayName": "preview.png",
|
||||
"signedUrl": "https://cdn.example/preview.png?token=1"
|
||||
}
|
||||
]
|
||||
}
|
||||
}));
|
||||
|
||||
assert_eq!(files.len(), 2);
|
||||
assert_eq!(files[0].name, "rodin-result.glb");
|
||||
assert_eq!(files[0].url, "https://cdn.example/rodin-result.glb?token=1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_status_values() {
|
||||
assert_eq!(normalize_task_status("Waiting"), "waiting");
|
||||
assert_eq!(normalize_task_status("Generating"), "generating");
|
||||
assert_eq!(normalize_task_status("Done"), "done");
|
||||
assert_eq!(normalize_task_status("Failed"), "failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_status_done_only_when_all_jobs_done() {
|
||||
let jobs = extract_job_statuses(&json!({
|
||||
"jobs": [
|
||||
{ "uuid": "preview", "status": "Done" },
|
||||
{ "uuid": "model", "status": "Generating" }
|
||||
]
|
||||
}));
|
||||
|
||||
assert_eq!(
|
||||
resolve_hyper3d_overall_status(&json!({ "status": "Done" }), &jobs),
|
||||
"generating"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_status_failed_when_any_job_failed() {
|
||||
let jobs = extract_job_statuses(&json!({
|
||||
"jobs": [
|
||||
{ "uuid": "preview", "status": "Done" },
|
||||
{ "uuid": "model", "status": "Failed", "message": "bad input" }
|
||||
]
|
||||
}));
|
||||
|
||||
assert_eq!(
|
||||
resolve_hyper3d_overall_status(&json!({ "status": "Generating" }), &jobs),
|
||||
"failed"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user