新增 External v1 去背景生成链路
新增外部去背景 API、MCP 工具与异步队列契约 补齐来源归属、媒体类型、幂等重放和画布原子持久化校验 修复 provenance 重建、assetKindOverride 门禁与 revision retry 竞态 同步 Python helper、Skill、OpenAPI 及项目文档
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ use axum::{
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use shared_contracts::assets::EditorCanvasGenerationCompletionPayload;
|
||||
use shared_contracts::external_generation::{
|
||||
ExternalEditorGenerationJobResponse, ExternalEditorGenerationSubmissionResponse,
|
||||
ExternalGenerationJobStatus,
|
||||
@@ -33,12 +34,13 @@ use crate::{
|
||||
editor_project::{
|
||||
EDITOR_ASSET_FOLDER_ID_PREFIX, EDITOR_ASSET_ID_PREFIX, EDITOR_PROJECT_DEFAULT_TITLE,
|
||||
EDITOR_PROJECT_ID_PREFIX, EDITOR_RESOURCE_ID_PREFIX, EditorAssetFolderPayload,
|
||||
EditorAssetLibraryPayload, EditorAssetPayload, EditorCanvasViewportPayload,
|
||||
EditorGenerationCaller, EditorImageEditRequest, EditorImageGenerationRequest,
|
||||
EditorProjectPayload, EditorProjectResourcePayload, EditorUiDesignAssetExtractionRequest,
|
||||
current_utc_micros, editor_asset_folder_payload_from_record,
|
||||
editor_asset_library_payload_from_record, editor_asset_payload_from_record,
|
||||
editor_project_payload_from_record, editor_project_resource_payload_from_record,
|
||||
EditorAssetLibraryPayload, EditorAssetPayload, EditorBackgroundRemovalRequest,
|
||||
EditorCanvasViewportPayload, EditorGenerationCaller, EditorImageEditRequest,
|
||||
EditorImageGenerationRequest, EditorProjectPayload, EditorProjectResourcePayload,
|
||||
EditorUiDesignAssetExtractionRequest, current_utc_micros,
|
||||
editor_asset_folder_payload_from_record, editor_asset_library_payload_from_record,
|
||||
editor_asset_payload_from_record, editor_project_payload_from_record,
|
||||
editor_project_resource_payload_from_record, enqueue_editor_background_removal_for_owner,
|
||||
enqueue_editor_image_edit_for_owner, enqueue_editor_image_generation_for_owner,
|
||||
enqueue_editor_ui_design_asset_extraction_for_owner,
|
||||
ensure_generic_editor_image_generation_contract, map_editor_project_error,
|
||||
@@ -161,6 +163,37 @@ pub struct ExternalEditorProjectResourceCreateRequest {
|
||||
image_sequence_duration_ms: Option<NonZeroU64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub(crate) struct ExternalEditorBackgroundRemovalRequest {
|
||||
source_image_src: String,
|
||||
project_id: Option<String>,
|
||||
target_layer_id: Option<String>,
|
||||
asset_kind: Option<String>,
|
||||
generation_inputs: Option<Value>,
|
||||
asset_folder_id: Option<String>,
|
||||
asset_label: Option<String>,
|
||||
source_resource_id: Option<String>,
|
||||
canvas_completion: Option<EditorCanvasGenerationCompletionPayload>,
|
||||
}
|
||||
|
||||
impl From<ExternalEditorBackgroundRemovalRequest> for EditorBackgroundRemovalRequest {
|
||||
fn from(payload: ExternalEditorBackgroundRemovalRequest) -> Self {
|
||||
Self {
|
||||
source_image_src: payload.source_image_src,
|
||||
project_id: payload.project_id,
|
||||
target_layer_id: payload.target_layer_id,
|
||||
asset_kind: payload.asset_kind,
|
||||
generation_inputs: payload.generation_inputs,
|
||||
asset_folder_id: payload.asset_folder_id,
|
||||
asset_label: payload.asset_label,
|
||||
source_resource_id: payload.source_resource_id,
|
||||
task_id: None,
|
||||
canvas_completion: payload.canvas_completion,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExternalEditorAssetUpdateRequest {
|
||||
@@ -774,6 +807,31 @@ pub async fn edit_external_editor_image(
|
||||
Ok(external_generation_accepted_response(&request_context, job))
|
||||
}
|
||||
|
||||
pub async fn remove_external_editor_image_background(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(principal): Extension<ExternalApiPrincipal>,
|
||||
headers: HeaderMap,
|
||||
payload: Result<Json<ExternalEditorBackgroundRemovalRequest>, JsonRejection>,
|
||||
) -> Result<Response, Response> {
|
||||
require_scope_response(&request_context, &principal, SCOPE_EDITOR_IMAGE_GENERATE)?;
|
||||
let idempotency_key = require_idempotency_key(&headers)
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
|
||||
let Json(payload) = parse_external_generation_json_payload(&request_context, payload)?;
|
||||
let payload = EditorBackgroundRemovalRequest::from(payload);
|
||||
let project_id = payload.project_id.clone();
|
||||
let job = enqueue_editor_background_removal_for_owner(
|
||||
&state,
|
||||
&request_context,
|
||||
&editor_generation_caller(&principal, project_id),
|
||||
payload,
|
||||
Some(idempotency_key),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
|
||||
Ok(external_generation_accepted_response(&request_context, job))
|
||||
}
|
||||
|
||||
pub async fn generate_external_editor_icon_spritesheet(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -1737,6 +1795,214 @@ mod tests {
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_background_removal_rejects_undocumented_fields_before_queueing() {
|
||||
let state = AppState::new(crate::config::AppConfig::default())
|
||||
.expect("external background removal test state should build");
|
||||
state.fail_test_editor_generation_enqueue();
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/api/external/v1/editor/images/background-removals",
|
||||
post(remove_external_editor_image_background),
|
||||
)
|
||||
.layer(Extension(request_context(false)))
|
||||
.layer(Extension(ExternalApiPrincipal::for_test(
|
||||
"user-external-background-removal",
|
||||
&[SCOPE_EDITOR_IMAGE_GENERATE],
|
||||
)))
|
||||
.with_state(state.clone());
|
||||
|
||||
for (case_name, extra_field) in [
|
||||
(
|
||||
"internal taskId",
|
||||
json!({"taskId": "caller-controlled-task"}),
|
||||
),
|
||||
("unknown field", json!({"unexpected": true})),
|
||||
] {
|
||||
let mut request_body = json!({"sourceImageSrc": "editor-upload/source.png"});
|
||||
request_body
|
||||
.as_object_mut()
|
||||
.expect("background removal body should be an object")
|
||||
.extend(
|
||||
extra_field
|
||||
.as_object()
|
||||
.expect("extra field fixture should be an object")
|
||||
.clone(),
|
||||
);
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/external/v1/editor/images/background-removals")
|
||||
.header("content-type", "application/json")
|
||||
.header(IDEMPOTENCY_KEY_HEADER, "background-removal-contract-test")
|
||||
.body(Body::from(request_body.to_string()))
|
||||
.expect("external background removal request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("external background removal response should return");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{case_name}");
|
||||
assert_eq!(
|
||||
state.test_editor_generation_enqueue_attempts(),
|
||||
0,
|
||||
"{case_name} must fail before queueing",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_background_removal_rejects_unstable_sources_before_queueing() {
|
||||
let state = AppState::new(crate::config::AppConfig::default())
|
||||
.expect("external background removal source test state should build");
|
||||
state.fail_test_editor_generation_enqueue();
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/api/external/v1/editor/images/background-removals",
|
||||
post(remove_external_editor_image_background),
|
||||
)
|
||||
.layer(Extension(request_context(false)))
|
||||
.layer(Extension(ExternalApiPrincipal::for_test(
|
||||
"user-external-background-removal-source",
|
||||
&[SCOPE_EDITOR_IMAGE_GENERATE],
|
||||
)))
|
||||
.with_state(state.clone());
|
||||
|
||||
for source_image_src in [
|
||||
"data:image/png;base64,AAAA",
|
||||
"blob:browser-only",
|
||||
"https://oss.example/private.png?signature=temporary",
|
||||
] {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/external/v1/editor/images/background-removals")
|
||||
.header("content-type", "application/json")
|
||||
.header(
|
||||
IDEMPOTENCY_KEY_HEADER,
|
||||
"background-removal-source-contract-test",
|
||||
)
|
||||
.body(Body::from(
|
||||
json!({"sourceImageSrc": source_image_src}).to_string(),
|
||||
))
|
||||
.expect("external background removal source request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("external background removal source response should return");
|
||||
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::BAD_REQUEST,
|
||||
"{source_image_src}"
|
||||
);
|
||||
assert_eq!(
|
||||
state.test_editor_generation_enqueue_attempts(),
|
||||
0,
|
||||
"unstable source must fail before queueing: {source_image_src}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_background_removal_rejects_non_static_asset_kind_before_queueing() {
|
||||
let state = AppState::new(crate::config::AppConfig::default())
|
||||
.expect("external background removal kind test state should build");
|
||||
state.fail_test_editor_generation_enqueue();
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/api/external/v1/editor/images/background-removals",
|
||||
post(remove_external_editor_image_background),
|
||||
)
|
||||
.layer(Extension(request_context(false)))
|
||||
.layer(Extension(ExternalApiPrincipal::for_test(
|
||||
"user-external-background-removal-kind",
|
||||
&[SCOPE_EDITOR_IMAGE_GENERATE],
|
||||
)))
|
||||
.with_state(state.clone());
|
||||
|
||||
for asset_kind in ["video", "audio", "character-animation", "image-sequence"] {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/external/v1/editor/images/background-removals")
|
||||
.header("content-type", "application/json")
|
||||
.header(
|
||||
IDEMPOTENCY_KEY_HEADER,
|
||||
format!("background-removal-kind-{asset_kind}"),
|
||||
)
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"sourceImageSrc": "editor-upload/source.png",
|
||||
"assetKind": asset_kind,
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("external background removal kind request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("external background removal kind response should return");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{asset_kind}");
|
||||
assert_eq!(
|
||||
state.test_editor_generation_enqueue_attempts(),
|
||||
0,
|
||||
"non-static kind must fail before queueing: {asset_kind}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_background_removal_rejects_target_without_project_before_queueing() {
|
||||
let state = AppState::new(crate::config::AppConfig::default())
|
||||
.expect("external background removal target test state should build");
|
||||
state.fail_test_editor_generation_enqueue();
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/api/external/v1/editor/images/background-removals",
|
||||
post(remove_external_editor_image_background),
|
||||
)
|
||||
.layer(Extension(request_context(false)))
|
||||
.layer(Extension(ExternalApiPrincipal::for_test(
|
||||
"user-external-background-removal-target",
|
||||
&[SCOPE_EDITOR_IMAGE_GENERATE],
|
||||
)))
|
||||
.with_state(state.clone());
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/external/v1/editor/images/background-removals")
|
||||
.header("content-type", "application/json")
|
||||
.header(
|
||||
IDEMPOTENCY_KEY_HEADER,
|
||||
"background-removal-target-contract-test",
|
||||
)
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"sourceImageSrc": "editor-upload/source.png",
|
||||
"targetLayerId": "layer-source",
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("external background removal target request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("external background removal target response should return");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
assert_eq!(
|
||||
state.test_editor_generation_enqueue_attempts(),
|
||||
0,
|
||||
"target without project must fail before queueing",
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_generic_image_generation_rejects_scene_asset_kind_before_queueing() {
|
||||
assert_external_generic_image_scene_bypass_is_rejected_before_queueing(
|
||||
@@ -1921,6 +2187,7 @@ mod tests {
|
||||
for path in [
|
||||
"/api/external/v1/editor/images/generations",
|
||||
"/api/external/v1/editor/images/edits",
|
||||
"/api/external/v1/editor/images/background-removals",
|
||||
"/api/external/v1/editor/icon-spritesheets/generations",
|
||||
"/api/external/v1/editor/ui-designs/assets/extractions",
|
||||
"/api/external/v1/editor/character-animations/generations",
|
||||
@@ -2346,6 +2613,55 @@ mod tests {
|
||||
.get("/api/external/v1/editor/audios/background-music/generations")
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
parsed["paths"]
|
||||
.get("/api/external/v1/editor/images/background-removals")
|
||||
.is_some()
|
||||
);
|
||||
assert_eq!(
|
||||
parsed["paths"]["/api/external/v1/editor/images/background-removals"]["post"]["operationId"],
|
||||
"removeExternalEditorImageBackground"
|
||||
);
|
||||
assert_eq!(
|
||||
parsed["components"]["schemas"]["EditorBackgroundRemovalRequest"]["required"],
|
||||
json!(["sourceImageSrc"])
|
||||
);
|
||||
assert_eq!(
|
||||
parsed["components"]["schemas"]["EditorBackgroundRemovalRequest"]["additionalProperties"],
|
||||
json!(false)
|
||||
);
|
||||
assert!(
|
||||
parsed["components"]["schemas"]["EditorBackgroundRemovalRequest"]["properties"]
|
||||
.get("taskId")
|
||||
.is_none()
|
||||
);
|
||||
let background_target_description = parsed["components"]["schemas"]
|
||||
["EditorBackgroundRemovalRequest"]["properties"]["targetLayerId"]["description"]
|
||||
.as_str()
|
||||
.expect("background removal targetLayerId should document placement semantics");
|
||||
assert!(background_target_description.contains("projectId"));
|
||||
assert!(background_target_description.contains("canvasCompletion"));
|
||||
assert!(background_target_description.contains("assetObjectId"));
|
||||
assert!(background_target_description.contains("bucket/objectKey"));
|
||||
assert!(background_target_description.contains("不自动写入画布"));
|
||||
let background_asset_kind = &parsed["components"]["schemas"]["EditorBackgroundRemovalRequest"]
|
||||
["properties"]["assetKind"];
|
||||
assert_eq!(
|
||||
background_asset_kind["x-genarrative-media-family"],
|
||||
"static-image"
|
||||
);
|
||||
let background_asset_kind_description = background_asset_kind["description"]
|
||||
.as_str()
|
||||
.expect("background removal assetKind should document authoritative static semantics");
|
||||
assert!(background_asset_kind_description.contains("权威来源类型"));
|
||||
assert!(background_asset_kind_description.contains("入队前返回 400"));
|
||||
let background_source_resource_description = parsed["components"]["schemas"]
|
||||
["EditorBackgroundRemovalRequest"]["properties"]["sourceResourceId"]["description"]
|
||||
.as_str()
|
||||
.expect("background removal sourceResourceId should document disambiguation");
|
||||
assert!(background_source_resource_description.contains("消歧"));
|
||||
assert!(background_source_resource_description.contains("targetLayerId"));
|
||||
assert!(background_source_resource_description.contains("Worker"));
|
||||
assert!(
|
||||
parsed["paths"]
|
||||
.get("/api/external/v1/editor/assets/library")
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::sync::{Arc, LazyLock};
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{
|
||||
Method, Request,
|
||||
HeaderMap, Method, Request,
|
||||
header::{AUTHORIZATION, CONTENT_TYPE},
|
||||
},
|
||||
};
|
||||
@@ -246,17 +246,8 @@ fn build_mcp_operations() -> Vec<McpOperation> {
|
||||
};
|
||||
let method = Method::from_bytes(method_name.to_ascii_uppercase().as_bytes())
|
||||
.expect("known HTTP method");
|
||||
let requires_idempotency_key = matches!(
|
||||
operation_id,
|
||||
"generateExternalEditorImage"
|
||||
| "editExternalEditorImage"
|
||||
| "generateExternalEditorIconSpritesheet"
|
||||
| "extractExternalEditorUiDesignAssets"
|
||||
| "generateExternalEditorCharacterAnimation"
|
||||
| "generateExternalEditorVideo"
|
||||
| "generateExternalEditorSoundEffect"
|
||||
| "generateExternalEditorBackgroundMusic"
|
||||
);
|
||||
let requires_idempotency_key =
|
||||
operation_requires_idempotency_key(&openapi, path_item, operation);
|
||||
let description = operation
|
||||
.get("description")
|
||||
.or_else(|| operation.get("summary"))
|
||||
@@ -287,6 +278,34 @@ fn build_mcp_operations() -> Vec<McpOperation> {
|
||||
operations
|
||||
}
|
||||
|
||||
fn operation_requires_idempotency_key(
|
||||
openapi: &Value,
|
||||
path_item: &Map<String, Value>,
|
||||
operation: &Map<String, Value>,
|
||||
) -> bool {
|
||||
path_item
|
||||
.get("parameters")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.chain(
|
||||
operation
|
||||
.get("parameters")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten(),
|
||||
)
|
||||
.filter_map(|parameter| resolve_openapi_reference(openapi, parameter))
|
||||
.any(|parameter| {
|
||||
parameter.get("in").and_then(Value::as_str) == Some("header")
|
||||
&& parameter
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|name| name.eq_ignore_ascii_case("Idempotency-Key"))
|
||||
&& parameter.get("required").and_then(Value::as_bool) == Some(true)
|
||||
})
|
||||
}
|
||||
|
||||
fn build_operation_input_schema(
|
||||
openapi: &Value,
|
||||
path_item: &Map<String, Value>,
|
||||
@@ -513,18 +532,7 @@ async fn dispatch_operation(
|
||||
"application/json".parse().expect("valid content type"),
|
||||
);
|
||||
}
|
||||
if operation.requires_idempotency_key {
|
||||
let idempotency_key = arguments
|
||||
.get("idempotencyKey")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| json!({"error": "生成工具必须提供 idempotencyKey"}))?;
|
||||
request.headers_mut().insert(
|
||||
"idempotency-key",
|
||||
idempotency_key
|
||||
.parse()
|
||||
.map_err(|_| json!({"error": "idempotencyKey 不是合法 HTTP 头值"}))?,
|
||||
);
|
||||
}
|
||||
apply_operation_headers(operation, &arguments, request.headers_mut())?;
|
||||
|
||||
let response = modules::external_api::router(state.clone())
|
||||
.with_state(state)
|
||||
@@ -557,6 +565,27 @@ async fn dispatch_operation(
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_operation_headers(
|
||||
operation: &McpOperation,
|
||||
arguments: &Map<String, Value>,
|
||||
headers: &mut HeaderMap,
|
||||
) -> Result<(), Value> {
|
||||
if !operation.requires_idempotency_key {
|
||||
return Ok(());
|
||||
}
|
||||
let idempotency_key = arguments
|
||||
.get("idempotencyKey")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| json!({"error": "生成工具必须提供 idempotencyKey"}))?;
|
||||
headers.insert(
|
||||
"idempotency-key",
|
||||
idempotency_key
|
||||
.parse()
|
||||
.map_err(|_| json!({"error": "idempotencyKey 不是合法 HTTP 头值"}))?,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_required_body(
|
||||
operation: &McpOperation,
|
||||
arguments: &Map<String, Value>,
|
||||
@@ -739,6 +768,7 @@ mod tests {
|
||||
.collect();
|
||||
assert_eq!(names.len(), MCP_OPERATIONS.len());
|
||||
assert!(names.contains_key("generate_external_editor_image"));
|
||||
assert!(names.contains_key("remove_external_editor_image_background"));
|
||||
assert!(names.contains_key("get_external_editor_generation_job"));
|
||||
|
||||
let list_projects = MCP_OPERATIONS
|
||||
@@ -877,19 +907,53 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn generation_tools_require_idempotency_key() {
|
||||
let operation = MCP_OPERATIONS
|
||||
for tool_name in [
|
||||
"generate_external_editor_image",
|
||||
"remove_external_editor_image_background",
|
||||
] {
|
||||
let operation = MCP_OPERATIONS
|
||||
.iter()
|
||||
.find(|operation| operation.tool_name == tool_name)
|
||||
.unwrap_or_else(|| panic!("{tool_name} should exist"));
|
||||
assert!(operation.requires_idempotency_key, "{tool_name}");
|
||||
assert_eq!(
|
||||
operation.input_schema.get("required"),
|
||||
Some(&json!(["body", "idempotencyKey"])),
|
||||
"{tool_name}"
|
||||
);
|
||||
}
|
||||
|
||||
let image_generation = MCP_OPERATIONS
|
||||
.iter()
|
||||
.find(|operation| operation.tool_name == "generate_external_editor_image")
|
||||
.expect("image generation tool should exist");
|
||||
assert!(operation.requires_idempotency_key);
|
||||
assert_eq!(
|
||||
operation.input_schema.get("required"),
|
||||
Some(&json!(["body", "idempotencyKey"]))
|
||||
);
|
||||
assert_eq!(
|
||||
operation.input_schema["properties"]["body"]["properties"]["projectId"]["type"],
|
||||
image_generation.input_schema["properties"]["body"]["properties"]["projectId"]["type"],
|
||||
json!(["string", "null"])
|
||||
);
|
||||
|
||||
let background_removal = MCP_OPERATIONS
|
||||
.iter()
|
||||
.find(|operation| operation.tool_name == "remove_external_editor_image_background")
|
||||
.expect("background removal tool should exist");
|
||||
assert_eq!(
|
||||
background_removal.input_schema["properties"]["body"]["required"],
|
||||
json!(["sourceImageSrc"])
|
||||
);
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
apply_operation_headers(
|
||||
background_removal,
|
||||
&Map::from_iter([("idempotencyKey".to_string(), json!("issue-178-request"))]),
|
||||
&mut headers,
|
||||
)
|
||||
.expect("background removal idempotency header should be forwarded");
|
||||
assert_eq!(
|
||||
headers
|
||||
.get("idempotency-key")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("issue-178-request")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1090,7 +1154,7 @@ mod tests {
|
||||
(
|
||||
4,
|
||||
SKILL_REQUESTS_AND_OUTPUTS_URI,
|
||||
"All eight generation POST routes require",
|
||||
"All nine generation POST routes require",
|
||||
),
|
||||
] {
|
||||
let request = Request::builder()
|
||||
|
||||
@@ -23,7 +23,8 @@ use crate::{
|
||||
generate_external_editor_video, get_external_editor_asset_library,
|
||||
get_external_editor_generation_job, get_external_editor_project,
|
||||
list_external_editor_projects, load_recent_external_editor_project, openapi_json,
|
||||
rename_external_editor_project, save_external_editor_canvas, update_external_editor_asset,
|
||||
remove_external_editor_image_background, rename_external_editor_project,
|
||||
save_external_editor_canvas, update_external_editor_asset,
|
||||
update_external_editor_asset_folder,
|
||||
},
|
||||
external_mcp,
|
||||
@@ -186,6 +187,12 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
require_external_api_key,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/external/v1/editor/images/background-removals",
|
||||
post(remove_external_editor_image_background).route_layer(
|
||||
middleware::from_fn_with_state(state.clone(), require_external_api_key),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/api/external/v1/editor/icon-spritesheets/generations",
|
||||
post(generate_external_editor_icon_spritesheet).route_layer(
|
||||
|
||||
Reference in New Issue
Block a user