use axum::{ Extension, Router, extract::DefaultBodyLimit, middleware, routing::{get, patch, post}, }; use crate::{ editor_project::EDITOR_LAYOUT_REQUEST_BODY_MAX_BYTES, external_api_auth::{require_external_api_key, require_external_mcp_api_key}, external_assets_api::{ confirm_external_asset_object, create_external_direct_upload_ticket, get_external_asset_read_url, }, external_editor_api::{ create_external_editor_asset, create_external_editor_asset_folder, create_external_editor_project, create_external_editor_project_resource, delete_external_editor_asset, delete_external_editor_asset_folder, delete_external_editor_project, edit_external_editor_image, extract_external_editor_ui_design_assets, generate_external_editor_background_music, generate_external_editor_character_animation, generate_external_editor_icon_spritesheet, generate_external_editor_image, generate_external_editor_scene, generate_external_editor_sound_effect, 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, remove_external_editor_image_background, rename_external_editor_project, save_external_editor_canvas, update_external_editor_asset, update_external_editor_asset_folder, }, external_mcp, external_skill_api::{ download_external_skill_archive, get_external_agent_integration_manifest, get_external_skill_entry, }, state::AppState, }; pub fn router(state: AppState) -> Router { let mcp_router = Router::new() .nest_service("/api/external/v1/mcp", external_mcp::service()) .layer(Extension(state.clone())) .route_layer(middleware::from_fn_with_state( state.clone(), require_external_mcp_api_key, )); let auth = middleware::from_fn_with_state(state, require_external_api_key); let protected_routes = [ ( "/api/external/v1/assets/direct-upload-tickets", post(create_external_direct_upload_ticket), ), ( "/api/external/v1/assets/objects/confirm", post(confirm_external_asset_object), ), ( "/api/external/v1/assets/read-url", get(get_external_asset_read_url), ), ( "/api/external/v1/editor/projects", get(list_external_editor_projects).post(create_external_editor_project), ), ( "/api/external/v1/editor/projects/recent", get(load_recent_external_editor_project), ), ( "/api/external/v1/editor/projects/{project_id}", get(get_external_editor_project).delete(delete_external_editor_project), ), ( "/api/external/v1/editor/projects/{project_id}/metadata", patch(rename_external_editor_project), ), ( "/api/external/v1/editor/projects/{project_id}/canvas", patch(save_external_editor_canvas) .layer(DefaultBodyLimit::max(EDITOR_LAYOUT_REQUEST_BODY_MAX_BYTES)), ), ( "/api/external/v1/editor/projects/{project_id}/resources", post(create_external_editor_project_resource), ), ( "/api/external/v1/editor/assets/library", get(get_external_editor_asset_library), ), ( "/api/external/v1/editor/assets/folders", post(create_external_editor_asset_folder), ), ( "/api/external/v1/editor/assets/folders/{folder_id}", patch(update_external_editor_asset_folder).delete(delete_external_editor_asset_folder), ), ( "/api/external/v1/editor/assets", post(create_external_editor_asset), ), ( "/api/external/v1/editor/assets/{asset_id}", patch(update_external_editor_asset).delete(delete_external_editor_asset), ), ( "/api/external/v1/generations/{operation_id}", get(get_external_editor_generation_job), ), ( "/api/external/v1/editor/images/generations", post(generate_external_editor_image), ), ( "/api/external/v1/editor/scenes/generations", post(generate_external_editor_scene), ), ( "/api/external/v1/editor/images/edits", post(edit_external_editor_image), ), ( "/api/external/v1/editor/images/background-removals", post(remove_external_editor_image_background), ), ( "/api/external/v1/editor/icon-spritesheets/generations", post(generate_external_editor_icon_spritesheet), ), ( "/api/external/v1/editor/ui-designs/assets/extractions", post(extract_external_editor_ui_design_assets), ), ( "/api/external/v1/editor/character-animations/generations", post(generate_external_editor_character_animation), ), ( "/api/external/v1/editor/videos/generations", post(generate_external_editor_video), ), ( "/api/external/v1/editor/audios/sound-effects/generations", post(generate_external_editor_sound_effect), ), ( "/api/external/v1/editor/audios/background-music/generations", post(generate_external_editor_background_music), ), ]; // 在方法层统一鉴权,未支持的方法仍由 Axum 返回 405。 let protected_router = protected_routes .into_iter() .fold(Router::new(), |router, (path, methods)| { router.route(path, methods.route_layer(auth.clone())) }); Router::new() .route("/api/external/v1/openapi.json", get(openapi_json)) .route( "/api/external/v1/agent-integration.json", get(get_external_agent_integration_manifest), ) .route( "/api/external/v1/skill/SKILL.md", get(get_external_skill_entry), ) .route( "/api/external/v1/skill.zip", get(download_external_skill_archive), ) .merge(protected_router) .merge(mcp_router) } #[cfg(test)] mod route_contract_tests { use std::sync::Arc; use axum::{ body::Body, http::{Request, StatusCode, header}, middleware, }; use futures_util::future::BoxFuture; use http_body_util::BodyExt; use spacetime_client::{ ExternalApiKeyAuthenticateRecordInput, ExternalApiKeyRecord, SpacetimeClientError, }; use tower::ServiceExt; use super::router; use crate::{ config::AppConfig, request_context::attach_request_context, state::{AppState, ExternalApiAuthState, external_api_auth::ExternalApiKeyAuthenticator}, }; const VALID_KEY: &str = "tnr_sk_route-body-limit-fixture"; struct FixtureKeyAuthenticator; impl ExternalApiKeyAuthenticator for FixtureKeyAuthenticator { fn authenticate_external_api_key( &self, input: ExternalApiKeyAuthenticateRecordInput, ) -> BoxFuture<'_, Result> { Box::pin(async move { if input.key_hash != crate::external_api_keys::hash_external_api_key(VALID_KEY) { return Err(SpacetimeClientError::Procedure( "API Key 不存在".to_string(), )); } Ok(ExternalApiKeyRecord { key_id: "key-route-fixture".to_string(), owner_user_id: "owner-route-fixture".to_string(), name: "route fixture".to_string(), key_prefix: "tnr_sk_route".to_string(), scopes: Vec::new(), created_at: "2026-09-18T00:00:00Z".to_string(), last_used_at: None, revoked_at: None, updated_at: "2026-09-18T00:00:00Z".to_string(), }) }) } } const PROTECTED_ROUTES: &[(&str, &[&str])] = &[ ("/api/external/v1/assets/direct-upload-tickets", &["POST"]), ("/api/external/v1/assets/objects/confirm", &["POST"]), ("/api/external/v1/assets/read-url", &["GET"]), ("/api/external/v1/editor/projects", &["GET", "POST"]), ("/api/external/v1/editor/projects/recent", &["GET"]), ( "/api/external/v1/editor/projects/{project_id}", &["GET", "DELETE"], ), ( "/api/external/v1/editor/projects/{project_id}/metadata", &["PATCH"], ), ( "/api/external/v1/editor/projects/{project_id}/canvas", &["PATCH"], ), ( "/api/external/v1/editor/projects/{project_id}/resources", &["POST"], ), ("/api/external/v1/editor/assets/library", &["GET"]), ("/api/external/v1/editor/assets/folders", &["POST"]), ( "/api/external/v1/editor/assets/folders/{folder_id}", &["PATCH", "DELETE"], ), ("/api/external/v1/editor/assets", &["POST"]), ( "/api/external/v1/editor/assets/{asset_id}", &["PATCH", "DELETE"], ), ("/api/external/v1/generations/{operation_id}", &["GET"]), ("/api/external/v1/editor/images/generations", &["POST"]), ("/api/external/v1/editor/scenes/generations", &["POST"]), ("/api/external/v1/editor/images/edits", &["POST"]), ( "/api/external/v1/editor/images/background-removals", &["POST"], ), ( "/api/external/v1/editor/icon-spritesheets/generations", &["POST"], ), ( "/api/external/v1/editor/ui-designs/assets/extractions", &["POST"], ), ( "/api/external/v1/editor/character-animations/generations", &["POST"], ), ("/api/external/v1/editor/videos/generations", &["POST"]), ( "/api/external/v1/editor/audios/sound-effects/generations", &["POST"], ), ( "/api/external/v1/editor/audios/background-music/generations", &["POST"], ), ]; fn app() -> axum::Router { let state = AppState::new(AppConfig::default()) .expect("state should build") .with_external_api_auth_state(ExternalApiAuthState::new(Arc::new( FixtureKeyAuthenticator, ))); router(state.clone()) .layer(middleware::from_fn(attach_request_context)) .with_state(state) } #[tokio::test] async fn protected_route_matrix_keeps_auth_methods_and_head_behavior() { let app = app(); for (template, supported) in PROTECTED_ROUTES { let path = template .split('/') .map(|segment| { if segment.starts_with('{') { "fixture" } else { segment } }) .collect::>() .join("/"); let mut allowed = supported.to_vec(); if allowed.contains(&"GET") { allowed.push("HEAD"); } allowed.sort_unstable(); for credential in [None, Some("Bearer invalid-token")] { for method in [ "GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE", ] { let mut request = Request::builder().method(method).uri(&path); if let Some(credential) = credential { request = request.header(header::AUTHORIZATION, credential); } let response = app .clone() .oneshot(request.body(Body::empty()).expect("request should build")) .await .expect("request should complete"); let expected = if allowed.contains(&method) { StatusCode::UNAUTHORIZED } else { StatusCode::METHOD_NOT_ALLOWED }; assert_eq!( response.status(), expected, "{method} {path}, credential={credential:?}" ); if expected == StatusCode::METHOD_NOT_ALLOWED { let mut actual = response.headers()[header::ALLOW] .to_str() .expect("Allow should be ASCII") .split(',') .map(str::trim) .collect::>(); actual.sort_unstable(); assert_eq!(actual, allowed, "Allow for {method} {path}"); } if method == "HEAD" { assert!( response .into_body() .collect() .await .expect("HEAD body should collect") .to_bytes() .is_empty(), "HEAD {path} must not return a body" ); } } } } } #[tokio::test] async fn unknown_paths_remain_not_found_before_authentication() { for credential in [None, Some("Bearer invalid-token")] { let mut request = Request::builder().uri("/api/external/v1/missing/unknown/route"); if let Some(credential) = credential { request = request.header(header::AUTHORIZATION, credential); } let response = app() .oneshot(request.body(Body::empty()).expect("request should build")) .await .expect("request should complete"); assert_eq!(response.status(), StatusCode::NOT_FOUND); } } #[tokio::test] async fn integration_documents_remain_public_and_mcp_keeps_its_auth_challenge() { let app = app(); for path in [ "/api/external/v1/openapi.json", "/api/external/v1/agent-integration.json", "/api/external/v1/skill/SKILL.md", "/api/external/v1/skill.zip", ] { for method in ["GET", "HEAD"] { let response = app .clone() .oneshot( Request::builder() .method(method) .uri(path) .header(header::AUTHORIZATION, "Bearer invalid-token") .body(Body::empty()) .expect("document request should build"), ) .await .expect("document request should complete"); assert_eq!(response.status(), StatusCode::OK, "{method} {path}"); if method == "HEAD" { assert!( response .into_body() .collect() .await .expect("HEAD body should collect") .to_bytes() .is_empty() ); } } } for method in ["GET", "POST", "DELETE", "OPTIONS"] { let response = app .clone() .oneshot( Request::builder() .method(method) .uri("/api/external/v1/mcp") .body(Body::empty()) .expect("MCP request should build"), ) .await .expect("MCP request should complete"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "MCP {method}"); assert_eq!( response.headers()[header::WWW_AUTHENTICATE], "Bearer realm=\"genarrative-external-editor\"" ); } } #[tokio::test] async fn canvas_authentication_precedes_body_limit_rejection() { let response = app() .oneshot( Request::builder() .method("PATCH") .uri("/api/external/v1/editor/projects/fixture/canvas") .header(header::CONTENT_TYPE, "application/json") .body(Body::from("x".repeat( crate::editor_project::EDITOR_LAYOUT_REQUEST_BODY_MAX_BYTES + 1, ))) .expect("oversized request should build"), ) .await .expect("oversized request should complete"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn authenticated_canvas_keeps_its_four_mebibyte_body_limit() { let app = app(); for (size, expected) in [ (2 * 1024 * 1024 + 1, StatusCode::UNPROCESSABLE_ENTITY), ( crate::editor_project::EDITOR_LAYOUT_REQUEST_BODY_MAX_BYTES + 1, StatusCode::PAYLOAD_TOO_LARGE, ), ] { let response = app .clone() .oneshot( Request::builder() .method("PATCH") .uri("/api/external/v1/editor/projects/fixture/canvas") .header(header::AUTHORIZATION, format!("Bearer {VALID_KEY}")) .header(header::CONTENT_TYPE, "application/json") .body(Body::from(format!("\"{}\"", "x".repeat(size)))) .expect("canvas request should build"), ) .await .expect("canvas request should complete"); assert_eq!(response.status(), expected, "size={size}"); } } }