diff --git a/examples/plugin/README.md b/examples/plugin/README.md index 9ee78a7a7..8f4891031 100644 --- a/examples/plugin/README.md +++ b/examples/plugin/README.md @@ -20,8 +20,8 @@ This directory contains standard dynamic library plugin examples for the CLIProx - `thinking/`: thinking applier capability only. - `usage/`: usage observer capability only. - `cli/`: command-line capability only. -- `management-api/`: Management API capability only. -- `host-callback/`: minimal Management API route that demonstrates host callbacks. +- `management-api/`: Management API and resource capability only. +- `host-callback/`: minimal plugin resource that demonstrates host callbacks. Most standard capability examples contain `go/`, `c/`, and `rust/` subdirectories. Specialized examples may provide only the implementation language they need. @@ -68,4 +68,6 @@ Artifacts are written to `examples/plugin/bin`. `protocol-format` uses a minimal executor because format declarations belong to executor capabilities. -`host-callback` uses a minimal Management API route because host callbacks are invoked from plugin methods and are not standalone capabilities. +`host-callback` uses a minimal plugin resource because host callbacks are invoked from plugin methods and are not standalone capabilities. + +Menu resources returned by `management.register` through the `resources` field are exposed by CPA under `/v0/resource/plugins//...`. Authenticated plugin Management API routes remain under `/v0/management/...`. diff --git a/examples/plugin/README_CN.md b/examples/plugin/README_CN.md index f430aec60..304fdbf3c 100644 --- a/examples/plugin/README_CN.md +++ b/examples/plugin/README_CN.md @@ -20,8 +20,8 @@ - `thinking/`:只演示 Thinking 处理能力。 - `usage/`:只演示 Usage 观察能力。 - `cli/`:只演示命令行扩展能力。 -- `management-api/`:只演示 Management API 扩展能力。 -- `host-callback/`:使用最小 Management API 路由演示宿主回调。 +- `management-api/`:只演示 Management API 和资源扩展能力。 +- `host-callback/`:使用最小插件资源演示宿主回调。 多数标准能力示例都包含 `go/`、`c/` 和 `rust/` 三个子目录。专用示例可能只提供所需的实现语言。 @@ -68,4 +68,6 @@ make -C examples/plugin build `protocol-format` 使用最小执行器承载,因为格式声明属于执行器能力。 -`host-callback` 使用最小 Management API 路由承载,因为宿主回调只能从插件方法内部发起,不是独立能力。 +`host-callback` 使用最小插件资源承载,因为宿主回调只能从插件方法内部发起,不是独立能力。 + +`management.register` 通过 `resources` 字段返回的菜单资源会由 CPA 暴露在 `/v0/resource/plugins//...` 下。需要认证的插件自有 Management API 路由仍保留在 `/v0/management/...` 下。 diff --git a/examples/plugin/host-callback/c/src/plugin.c b/examples/plugin/host-callback/c/src/plugin.c index 6af0d598f..c45996fd5 100644 --- a/examples/plugin/host-callback/c/src/plugin.c +++ b/examples/plugin/host-callback/c/src/plugin.c @@ -84,14 +84,14 @@ static int plugin_call(const char* method, const uint8_t* request, size_t reques return 0; } if (strcmp(method, "management.register") == 0) { - write_response(response, "{\"ok\":true,\"result\":{\"routes\":[{\"Method\":\"GET\",\"Path\":\"/plugins/example-host-callback-c/status\",\"Menu\":\"Host Callback\",\"Description\":\"Host callback example carried by a minimal Management API route.\"}]}}"); + write_response(response, "{\"ok\":true,\"result\":{\"resources\":[{\"Path\":\"/status\",\"Menu\":\"Host Callback\",\"Description\":\"CPA exposes this menu resource under /v0/resource/plugins/example-host-callback-c/status.\"}]}}"); return 0; } if (strcmp(method, "management.handle") == 0) { call_host("host.log", "{\"level\":\"info\",\"message\":\"example-host-callback-c host callback log\",\"fields\":{\"plugin\":\"example-host-callback-c\"}}"); call_host("host.http.do", "{\"method\":\"GET\",\"url\":\"https://example.com\",\"headers\":{\"user-agent\":[\"example-host-callback-c\"]}}"); - write_response(response, "{\"ok\":true,\"result\":{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"application/json\"]},\"Body\":\"eyJwbHVnaW4iOiJleGFtcGxlLWhvc3QtY2FsbGJhY2stYyJ9\"}}"); + write_response(response, "{\"ok\":true,\"result\":{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"text/html; charset=utf-8\"]},\"Body\":\"PCFkb2N0eXBlIGh0bWw+PHRpdGxlPkhvc3QgQ2FsbGJhY2s8L3RpdGxlPjxtYWluPkhvc3QgQ2FsbGJhY2sgcmVzb3VyY2U8L21haW4+\"}}"); return 0; } write_response(response, "{\"ok\":false,\"error\":{\"code\":\"unknown_method\",\"message\":\"unknown method\"}}"); diff --git a/examples/plugin/host-callback/go/main.go b/examples/plugin/host-callback/go/main.go index 531da32af..8c004f785 100644 --- a/examples/plugin/host-callback/go/main.go +++ b/examples/plugin/host-callback/go/main.go @@ -131,11 +131,11 @@ func handleMethod(method string) ([]byte, error) { case "plugin.reconfigure": return okEnvelopeJSON("{\"schema_version\":1,\"metadata\":{\"Name\":\"example-host-callback-go\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-host-callback-go.png\",\"ConfigFields\":[]},\"capabilities\":{\"management_api\":true}}") case "management.register": - return okEnvelopeJSON("{\"routes\":[{\"Method\":\"GET\",\"Path\":\"/plugins/example-host-callback-go/status\",\"Menu\":\"Host Callback\",\"Description\":\"Host callback example carried by a minimal Management API route.\"}]}") + return okEnvelopeJSON("{\"resources\":[{\"Path\":\"/status\",\"Menu\":\"Host Callback\",\"Description\":\"CPA exposes this menu resource under /v0/resource/plugins/example-host-callback-go/status.\"}]}") case "management.handle": callHost("host.log", []byte(`{"level":"info","message":"example-host-callback-go host callback log","fields":{"plugin":"example-host-callback-go"}}`)) callHost("host.http.do", []byte(`{"method":"GET","url":"https://example.com","headers":{"user-agent":["example-host-callback-go"]}}`)) - return okEnvelopeJSON("{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"application/json\"]},\"Body\":\"eyJwbHVnaW4iOiJleGFtcGxlLWhvc3QtY2FsbGJhY2stZ28ifQ==\"}") + return okEnvelopeJSON("{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"text/html; charset=utf-8\"]},\"Body\":\"PCFkb2N0eXBlIGh0bWw+PHRpdGxlPkhvc3QgQ2FsbGJhY2s8L3RpdGxlPjxtYWluPkhvc3QgQ2FsbGJhY2sgcmVzb3VyY2U8L21haW4+\"}") default: return errorEnvelope("unknown_method", "unknown method: "+method), nil } diff --git a/examples/plugin/host-callback/rust/src/lib.rs b/examples/plugin/host-callback/rust/src/lib.rs index 8a0ce3585..49b358e7f 100644 --- a/examples/plugin/host-callback/rust/src/lib.rs +++ b/examples/plugin/host-callback/rust/src/lib.rs @@ -68,10 +68,10 @@ unsafe extern "C" fn plugin_call(method: *const c_char, request: *const u8, requ let _ = request; let _ = request_len; match method { - "plugin.register" => { write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-host-callback-rust\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-host-callback-rust.png\",\"ConfigFields\":[]},\"capabilities\":{\"management_api\":true}}}"); 0 },"plugin.reconfigure" => { write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-host-callback-rust\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-host-callback-rust.png\",\"ConfigFields\":[]},\"capabilities\":{\"management_api\":true}}}"); 0 },"management.register" => { write_response(response, "{\"ok\":true,\"result\":{\"routes\":[{\"Method\":\"GET\",\"Path\":\"/plugins/example-host-callback-rust/status\",\"Menu\":\"Host Callback\",\"Description\":\"Host callback example carried by a minimal Management API route.\"}]}}"); 0 },"management.handle" => { + "plugin.register" => { write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-host-callback-rust\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-host-callback-rust.png\",\"ConfigFields\":[]},\"capabilities\":{\"management_api\":true}}}"); 0 },"plugin.reconfigure" => { write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-host-callback-rust\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-host-callback-rust.png\",\"ConfigFields\":[]},\"capabilities\":{\"management_api\":true}}}"); 0 },"management.register" => { write_response(response, "{\"ok\":true,\"result\":{\"resources\":[{\"Path\":\"/status\",\"Menu\":\"Host Callback\",\"Description\":\"CPA exposes this menu resource under /v0/resource/plugins/example-host-callback-rust/status.\"}]}}"); 0 },"management.handle" => { call_host("host.log", r#"{"level":"info","message":"example-host-callback-rust host callback log","fields":{"plugin":"example-host-callback-rust"}}"#); call_host("host.http.do", r#"{"method":"GET","url":"https://example.com","headers":{"user-agent":["example-host-callback-rust"]}}"#); - write_response(response, "{\"ok\":true,\"result\":{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"application/json\"]},\"Body\":\"eyJwbHVnaW4iOiJleGFtcGxlLWhvc3QtY2FsbGJhY2stcnVzdCJ9\"}}"); 0 }, + write_response(response, "{\"ok\":true,\"result\":{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"text/html; charset=utf-8\"]},\"Body\":\"PCFkb2N0eXBlIGh0bWw+PHRpdGxlPkhvc3QgQ2FsbGJhY2s8L3RpdGxlPjxtYWluPkhvc3QgQ2FsbGJhY2sgcmVzb3VyY2U8L21haW4+\"}}"); 0 }, _ => { write_response(response, r#"{"ok":false,"error":{"code":"unknown_method","message":"unknown method"}}"#); 0 diff --git a/examples/plugin/management-api/c/src/plugin.c b/examples/plugin/management-api/c/src/plugin.c index b7c739c55..c5f454ec9 100644 --- a/examples/plugin/management-api/c/src/plugin.c +++ b/examples/plugin/management-api/c/src/plugin.c @@ -84,11 +84,11 @@ static int plugin_call(const char* method, const uint8_t* request, size_t reques return 0; } if (strcmp(method, "management.register") == 0) { - write_response(response, "{\"ok\":true,\"result\":{\"routes\":[{\"Method\":\"GET\",\"Path\":\"/plugins/example-management-api-c/status\",\"Menu\":\"Management API\",\"Description\":\"Management API capability example.\"}]}}"); + write_response(response, "{\"ok\":true,\"result\":{\"resources\":[{\"Path\":\"/status\",\"Menu\":\"Management API\",\"Description\":\"CPA exposes this menu resource under /v0/resource/plugins/example-management-api-c/status.\"}]}}"); return 0; } if (strcmp(method, "management.handle") == 0) { - write_response(response, "{\"ok\":true,\"result\":{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"application/json\"]},\"Body\":\"eyJwbHVnaW4iOiJleGFtcGxlLW1hbmFnZW1lbnQtYXBpLWMifQ==\"}}"); + write_response(response, "{\"ok\":true,\"result\":{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"text/html; charset=utf-8\"]},\"Body\":\"PCFkb2N0eXBlIGh0bWw+PHRpdGxlPk1hbmFnZW1lbnQgQVBJPC90aXRsZT48bWFpbj5NYW5hZ2VtZW50IEFQSSByZXNvdXJjZTwvbWFpbj4=\"}}"); return 0; } write_response(response, "{\"ok\":false,\"error\":{\"code\":\"unknown_method\",\"message\":\"unknown method\"}}"); diff --git a/examples/plugin/management-api/go/main.go b/examples/plugin/management-api/go/main.go index d2d01818b..94162345e 100644 --- a/examples/plugin/management-api/go/main.go +++ b/examples/plugin/management-api/go/main.go @@ -131,9 +131,9 @@ func handleMethod(method string) ([]byte, error) { case "plugin.reconfigure": return okEnvelopeJSON("{\"schema_version\":1,\"metadata\":{\"Name\":\"example-management-api-go\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-management-api-go.png\",\"ConfigFields\":[]},\"capabilities\":{\"management_api\":true}}") case "management.register": - return okEnvelopeJSON("{\"routes\":[{\"Method\":\"GET\",\"Path\":\"/plugins/example-management-api-go/status\",\"Menu\":\"Management API\",\"Description\":\"Management API capability example.\"}]}") + return okEnvelopeJSON("{\"resources\":[{\"Path\":\"/status\",\"Menu\":\"Management API\",\"Description\":\"CPA exposes this menu resource under /v0/resource/plugins/example-management-api-go/status.\"}]}") case "management.handle": - return okEnvelopeJSON("{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"application/json\"]},\"Body\":\"eyJwbHVnaW4iOiJleGFtcGxlLW1hbmFnZW1lbnQtYXBpLWdvIn0=\"}") + return okEnvelopeJSON("{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"text/html; charset=utf-8\"]},\"Body\":\"PCFkb2N0eXBlIGh0bWw+PHRpdGxlPk1hbmFnZW1lbnQgQVBJPC90aXRsZT48bWFpbj5NYW5hZ2VtZW50IEFQSSByZXNvdXJjZTwvbWFpbj4=\"}") default: return errorEnvelope("unknown_method", "unknown method: "+method), nil } diff --git a/examples/plugin/management-api/rust/src/lib.rs b/examples/plugin/management-api/rust/src/lib.rs index b16daf1d6..408281bae 100644 --- a/examples/plugin/management-api/rust/src/lib.rs +++ b/examples/plugin/management-api/rust/src/lib.rs @@ -68,7 +68,7 @@ unsafe extern "C" fn plugin_call(method: *const c_char, request: *const u8, requ let _ = request; let _ = request_len; match method { - "plugin.register" => { write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-management-api-rust\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-management-api-rust.png\",\"ConfigFields\":[]},\"capabilities\":{\"management_api\":true}}}"); 0 },"plugin.reconfigure" => { write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-management-api-rust\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-management-api-rust.png\",\"ConfigFields\":[]},\"capabilities\":{\"management_api\":true}}}"); 0 },"management.register" => { write_response(response, "{\"ok\":true,\"result\":{\"routes\":[{\"Method\":\"GET\",\"Path\":\"/plugins/example-management-api-rust/status\",\"Menu\":\"Management API\",\"Description\":\"Management API capability example.\"}]}}"); 0 },"management.handle" => { write_response(response, "{\"ok\":true,\"result\":{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"application/json\"]},\"Body\":\"eyJwbHVnaW4iOiJleGFtcGxlLW1hbmFnZW1lbnQtYXBpLXJ1c3QifQ==\"}}"); 0 }, + "plugin.register" => { write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-management-api-rust\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-management-api-rust.png\",\"ConfigFields\":[]},\"capabilities\":{\"management_api\":true}}}"); 0 },"plugin.reconfigure" => { write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-management-api-rust\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-management-api-rust.png\",\"ConfigFields\":[]},\"capabilities\":{\"management_api\":true}}}"); 0 },"management.register" => { write_response(response, "{\"ok\":true,\"result\":{\"resources\":[{\"Path\":\"/status\",\"Menu\":\"Management API\",\"Description\":\"CPA exposes this menu resource under /v0/resource/plugins/example-management-api-rust/status.\"}]}}"); 0 },"management.handle" => { write_response(response, "{\"ok\":true,\"result\":{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"text/html; charset=utf-8\"]},\"Body\":\"PCFkb2N0eXBlIGh0bWw+PHRpdGxlPk1hbmFnZW1lbnQgQVBJPC90aXRsZT48bWFpbj5NYW5hZ2VtZW50IEFQSSByZXNvdXJjZTwvbWFpbj4=\"}}"); 0 }, _ => { write_response(response, r#"{"ok":false,"error":{"code":"unknown_method","message":"unknown method"}}"#); 0 diff --git a/examples/plugin/simple/README.md b/examples/plugin/simple/README.md index 02b40fa78..b8a8895e8 100644 --- a/examples/plugin/simple/README.md +++ b/examples/plugin/simple/README.md @@ -187,7 +187,9 @@ PUT /v0/management/plugins/{pluginID}/config PATCH /v0/management/plugins/{pluginID}/config ``` -Plugin-owned Management API routes are registered through `management.register` and handled through `management.handle`. +Plugin-owned Management API routes are registered through the `routes` field of `management.register` and handled through `management.handle`. + +Browser-navigable menu resources are registered through the `resources` field of `management.register`. CPA exposes those resources under `/v0/resource/plugins//...`; for example, a plugin with ID `example` and resource path `/status` is served as `/v0/resource/plugins/example/status`. ## Trust Boundary diff --git a/examples/plugin/simple/README_CN.md b/examples/plugin/simple/README_CN.md index 7bb46e892..e1aca1ea5 100644 --- a/examples/plugin/simple/README_CN.md +++ b/examples/plugin/simple/README_CN.md @@ -185,7 +185,9 @@ PUT /v0/management/plugins/{pluginID}/config PATCH /v0/management/plugins/{pluginID}/config ``` -插件自有 Management API 路由通过 `management.register` 注册,通过 `management.handle` 处理。 +插件自有 Management API 路由通过 `management.register` 的 `routes` 字段注册,并通过 `management.handle` 处理。 + +可由浏览器直接访问的菜单资源通过 `management.register` 的 `resources` 字段注册。CPA 会将这些资源暴露在 `/v0/resource/plugins//...` 下;例如插件 ID 为 `example` 且资源路径为 `/status` 时,最终路径是 `/v0/resource/plugins/example/status`。 ## 信任边界 diff --git a/examples/plugin/simple/c/src/plugin.c b/examples/plugin/simple/c/src/plugin.c index 5620f47fc..a148d976b 100644 --- a/examples/plugin/simple/c/src/plugin.c +++ b/examples/plugin/simple/c/src/plugin.c @@ -84,8 +84,8 @@ static const char* CLI_REGISTER_RESPONSE = static const char* CLI_EXECUTE_RESPONSE = "{\"ok\":true,\"result\":{\"Stdout\":\"cGx1Z2luIGV4YW1wbGUgYyBjb21tYW5kCg==\",\"ExitCode\":0}}"; static const char* MANAGEMENT_REGISTER_RESPONSE = - "{\"ok\":true,\"result\":{\"Routes\":[{\"Method\":\"GET\",\"Path\":\"/plugins/example-c/status\"," - "\"Menu\":\"Example C Plugin\",\"Description\":\"Shows example C plugin status.\"}]}}"; + "{\"ok\":true,\"result\":{\"Resources\":[{\"Path\":\"/status\"," + "\"Menu\":\"Example C Plugin\",\"Description\":\"CPA exposes this menu resource under /v0/resource/plugins/example-c/status.\"}]}}"; static const char* UNKNOWN_METHOD_RESPONSE = "{\"ok\":false,\"error\":{\"code\":\"unknown_method\",\"message\":\"unknown method\"}}"; static const char* INVALID_METHOD_RESPONSE = @@ -421,7 +421,7 @@ static char* make_http_response(const uint8_t* request, size_t request_len) { char* url = extract_json_string(json, "URL"); char* path = extract_json_string(json, "Path"); char* method_escaped = json_escape(method == NULL ? "GET" : method); - char* target_escaped = json_escape(url != NULL ? url : (path == NULL ? "/plugins/example-c/status" : path)); + char* target_escaped = json_escape(url != NULL ? url : (path == NULL ? "/v0/resource/plugins/example-c/status" : path)); char* body_json = format_string( "{\"plugin\":\"example-c\",\"method\":\"%s\",\"target\":\"%s\"}", method_escaped == NULL ? "" : method_escaped, diff --git a/examples/plugin/simple/go/main.go b/examples/plugin/simple/go/main.go index 582cf93ba..6123fa5d1 100644 --- a/examples/plugin/simple/go/main.go +++ b/examples/plugin/simple/go/main.go @@ -100,7 +100,8 @@ type streamResponse struct { } type managementRegistrationResponse struct { - Routes []pluginapi.ManagementRoute `json:"routes,omitempty"` + Routes []pluginapi.ManagementRoute `json:"routes,omitempty"` + Resources []pluginapi.ResourceRoute `json:"resources,omitempty"` } func main() {} @@ -207,14 +208,18 @@ func handleMethod(method string, request []byte) ([]byte, error) { case pluginabi.MethodCommandLineExecute: return okEnvelope(pluginapi.CommandLineExecutionResponse{Stdout: []byte("plugin example command\n")}) case pluginabi.MethodManagementRegister: - return okEnvelope(managementRegistrationResponse{Routes: []pluginapi.ManagementRoute{{ - Method: http.MethodGet, - Path: "/plugins/example/status", + // CPA exposes menu resources under /v0/resource/plugins//. + return okEnvelope(managementRegistrationResponse{Resources: []pluginapi.ResourceRoute{{ + Path: "/status", Menu: "Example Plugin", - Description: "Shows example plugin status.", + Description: "Shows example plugin status as a browser-navigable resource.", }}}) case pluginabi.MethodManagementHandle: - return okEnvelope(pluginapi.ManagementResponse{StatusCode: http.StatusOK, Body: []byte(`{"plugin":"example"}`)}) + return okEnvelope(pluginapi.ManagementResponse{ + StatusCode: http.StatusOK, + Headers: http.Header{"Content-Type": []string{"text/html; charset=utf-8"}}, + Body: []byte(`Example Plugin
Example Plugin
`), + }) default: return errorEnvelope("unknown_method", "unknown method: "+method), nil } diff --git a/examples/plugin/simple/rust/src/lib.rs b/examples/plugin/simple/rust/src/lib.rs index 90fe9bec5..5e05ba8b8 100644 --- a/examples/plugin/simple/rust/src/lib.rs +++ b/examples/plugin/simple/rust/src/lib.rs @@ -18,7 +18,7 @@ const FRONTEND_AUTH_RESPONSE: &str = r#"{"ok":true,"result":{"Authenticated":tru const STREAM_RESPONSE: &str = r#"{"ok":true,"result":{"headers":{"content-type":["text/event-stream"]},"chunks":[{"Payload":"cGx1Z2luLWV4YW1wbGUtcnVzdAo="}]}}"#; const CLI_REGISTER_RESPONSE: &str = r#"{"ok":true,"result":{"Flags":[{"Name":"plugin-example-rust-command","Usage":"Run the example Rust ABI plugin command","Type":"bool"}]}}"#; const CLI_EXECUTE_RESPONSE: &str = r#"{"ok":true,"result":{"Stdout":"cGx1Z2luIGV4YW1wbGUgcnVzdCBjb21tYW5kCg==","ExitCode":0}}"#; -const MANAGEMENT_REGISTER_RESPONSE: &str = r#"{"ok":true,"result":{"Routes":[{"Method":"GET","Path":"/plugins/example-rust/status","Menu":"Example Rust Plugin","Description":"Shows example Rust plugin status."}]}}"#; +const MANAGEMENT_REGISTER_RESPONSE: &str = r#"{"ok":true,"result":{"Resources":[{"Path":"/status","Menu":"Example Rust Plugin","Description":"CPA exposes this menu resource under /v0/resource/plugins/example-rust/status."}]}}"#; const UNKNOWN_METHOD_RESPONSE: &str = r#"{"ok":false,"error":{"code":"unknown_method","message":"unknown method"}}"#; const INVALID_METHOD_RESPONSE: &str = r#"{"ok":false,"error":{"code":"invalid_method","message":"method is required"}}"#; @@ -170,7 +170,7 @@ fn make_http_response(request: &[u8]) -> String { let method = extract_json_string(&json, "Method").unwrap_or_else(|| "GET".to_string()); let target = extract_json_string(&json, "URL") .or_else(|| extract_json_string(&json, "Path")) - .unwrap_or_else(|| "/plugins/example-rust/status".to_string()); + .unwrap_or_else(|| "/v0/resource/plugins/example-rust/status".to_string()); let body = format!( r#"{{"plugin":"example-rust","method":"{}","target":"{}"}}"#, json_escape(&method), diff --git a/internal/api/server.go b/internal/api/server.go index f804553a9..d486c4f78 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -382,7 +382,7 @@ func (s *Server) homeHeartbeatMiddleware() gin.HandlerFunc { } if c != nil && c.Request != nil { path := c.Request.URL.Path - if strings.HasPrefix(path, "/v0/management/") || path == "/v0/management" || path == "/management.html" { + if strings.HasPrefix(path, "/v0/management/") || path == "/v0/management" || strings.HasPrefix(path, "/v0/resource/plugins/") || path == "/management.html" { c.Next() return } @@ -811,6 +811,10 @@ func (s *Server) pluginManagementNoRoute(c *gin.Context) { return } path := c.Request.URL.Path + if strings.HasPrefix(path, "/v0/resource/plugins/") { + s.pluginResourceNoRoute(c) + return + } if path != "/v0/management" && !strings.HasPrefix(path, "/v0/management/") { c.AbortWithStatus(http.StatusNotFound) return @@ -837,6 +841,24 @@ func (s *Server) pluginManagementNoRoute(c *gin.Context) { c.AbortWithStatus(http.StatusNotFound) } +func (s *Server) pluginResourceNoRoute(c *gin.Context) { + if s == nil || c == nil || c.Request == nil || c.Request.URL == nil { + if c != nil { + c.AbortWithStatus(http.StatusNotFound) + } + return + } + if s.cfg == nil || s.cfg.Home.Enabled || s.pluginHost == nil { + c.AbortWithStatus(http.StatusNotFound) + return + } + if s.pluginHost.ServeResourceHTTP(c.Writer, c.Request) { + c.Abort() + return + } + c.AbortWithStatus(http.StatusNotFound) +} + func (s *Server) serveManagementControlPanel(c *gin.Context) { cfg := s.cfg if cfg == nil || cfg.Home.Enabled || cfg.RemoteManagement.DisableControlPanel { diff --git a/internal/pluginhost/host.go b/internal/pluginhost/host.go index af0e8e501..7469f4472 100644 --- a/internal/pluginhost/host.go +++ b/internal/pluginhost/host.go @@ -37,6 +37,7 @@ type Host struct { commandLineFlags map[string]commandLineFlagRecord commandLineHits map[string]struct{} managementRoutes map[string]managementRouteRecord + resourceRoutes map[string]resourceRouteRecord streams *streamBridge httpStreams *hostHTTPStreamBridge callbackContexts *callbackContextRegistry @@ -58,6 +59,7 @@ func New() *Host { commandLineFlags: make(map[string]commandLineFlagRecord), commandLineHits: make(map[string]struct{}), managementRoutes: make(map[string]managementRouteRecord), + resourceRoutes: make(map[string]resourceRouteRecord), streams: newStreamBridge(), httpStreams: newHostHTTPStreamBridge(), callbackContexts: newCallbackContextRegistry(), @@ -93,6 +95,8 @@ func (h *Host) ApplyConfig(ctx context.Context, cfg *config.Config) { h.runtimeConfig = cfg if !rc.Enabled { + h.managementRoutes = make(map[string]managementRouteRecord) + h.resourceRoutes = make(map[string]resourceRouteRecord) h.snapshot.Store(emptySnapshot()) h.mu.Unlock() h.refreshThinkingProviders(nil) @@ -102,6 +106,8 @@ func (h *Host) ApplyConfig(ctx context.Context, cfg *config.Config) { files, errSelect := selectPluginFiles(rc.Dir) if errSelect != nil { log.Warnf("pluginhost: failed to select plugin files: %v", errSelect) + h.managementRoutes = make(map[string]managementRouteRecord) + h.resourceRoutes = make(map[string]resourceRouteRecord) h.snapshot.Store(emptySnapshot()) h.mu.Unlock() h.refreshThinkingProviders(nil) @@ -187,6 +193,7 @@ func (h *Host) ShutdownAll() { h.commandLineFlags = make(map[string]commandLineFlagRecord) h.commandLineHits = make(map[string]struct{}) h.managementRoutes = make(map[string]managementRouteRecord) + h.resourceRoutes = make(map[string]resourceRouteRecord) h.snapshot.Store(emptySnapshot()) h.mu.Unlock() diff --git a/internal/pluginhost/management.go b/internal/pluginhost/management.go index a0d764da6..a35b906cc 100644 --- a/internal/pluginhost/management.go +++ b/internal/pluginhost/management.go @@ -12,20 +12,30 @@ import ( log "github.com/sirupsen/logrus" ) -const managementBasePath = "/v0/management" +const ( + managementBasePath = "/v0/management" + resourcePluginBasePath = "/v0/resource/plugins" + legacyPluginRoutePrefix = "/plugins" +) type managementRouteRecord struct { pluginID string route pluginapi.ManagementRoute } -// RegisterManagementRoutes rebuilds the plugin-owned Management API route table. +type resourceRouteRecord struct { + pluginID string + route pluginapi.ResourceRoute +} + +// RegisterManagementRoutes rebuilds the plugin-owned Management API and resource route tables. func (h *Host) RegisterManagementRoutes(ctx context.Context, reserved map[string]struct{}) { if h == nil { return } nextRoutes := make(map[string]managementRouteRecord) + nextResources := make(map[string]resourceRouteRecord) for _, record := range h.Snapshot().records { plugin := record.plugin.Capabilities.ManagementAPI if plugin == nil || h.isPluginFused(record.id) { @@ -36,12 +46,19 @@ func (h *Host) RegisterManagementRoutes(ctx context.Context, reserved map[string log.Warnf("pluginhost: management registrar %s failed: %v", record.id, errRegister) continue } + for _, item := range resp.Routes { method, path, okRoute := normalizeManagementRoute(item) if !okRoute { log.Warnf("pluginhost: plugin %s declared invalid management route %s %s", record.id, item.Method, item.Path) continue } + if routeDeclaresLegacyMenuResource(method, item) { + if !registerResourceRoute(nextResources, record.id, resourceRouteFromManagementRoute(item)) { + log.Warnf("pluginhost: plugin %s declared invalid resource route %s", record.id, item.Path) + } + continue + } key := managementRouteKey(method, path) if _, exists := reserved[key]; exists { log.Warnf("pluginhost: plugin %s management route %s conflicts with an existing route and was skipped", record.id, key) @@ -58,10 +75,17 @@ func (h *Host) RegisterManagementRoutes(ctx context.Context, reserved map[string route: item, } } + + for _, item := range resp.Resources { + if !registerResourceRoute(nextResources, record.id, item) { + log.Warnf("pluginhost: plugin %s declared invalid resource route %s", record.id, item.Path) + } + } } h.mu.Lock() h.managementRoutes = nextRoutes + h.resourceRoutes = nextResources h.mu.Unlock() } @@ -77,8 +101,9 @@ func (h *Host) callManagementRegistrar(ctx context.Context, record capabilityRec } }() return plugin.RegisterManagement(ctx, pluginapi.ManagementRegistrationRequest{ - Plugin: record.meta, - BasePath: managementBasePath, + Plugin: record.meta, + BasePath: managementBasePath, + ResourceBasePath: resourcePluginBasePath + "/" + record.id, }) } @@ -118,6 +143,75 @@ func normalizeManagementRoute(item pluginapi.ManagementRoute) (string, string, b return method, fullPath, true } +func routeDeclaresLegacyMenuResource(method string, item pluginapi.ManagementRoute) bool { + return strings.EqualFold(strings.TrimSpace(method), http.MethodGet) && strings.TrimSpace(item.Menu) != "" +} + +func resourceRouteFromManagementRoute(item pluginapi.ManagementRoute) pluginapi.ResourceRoute { + return pluginapi.ResourceRoute{ + Path: item.Path, + Menu: item.Menu, + Description: item.Description, + Handler: item.Handler, + } +} + +func registerResourceRoute(routes map[string]resourceRouteRecord, pluginID string, item pluginapi.ResourceRoute) bool { + path, okRoute := normalizeResourceRoute(pluginID, item) + if !okRoute { + return false + } + key := managementRouteKey(http.MethodGet, path) + if _, exists := routes[key]; exists { + log.Warnf("pluginhost: plugin %s resource route %s conflicts with a higher-priority plugin and was skipped", pluginID, key) + return true + } + item.Path = path + routes[key] = resourceRouteRecord{ + pluginID: pluginID, + route: item, + } + return true +} + +func normalizeResourceRoute(pluginID string, item pluginapi.ResourceRoute) (string, bool) { + if item.Handler == nil { + return "", false + } + pluginID = strings.TrimSpace(pluginID) + if pluginID == "" { + return "", false + } + + path := strings.TrimSpace(item.Path) + if path == "" { + return "", false + } + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + + pluginBasePath := resourcePluginBasePath + "/" + pluginID + if strings.HasPrefix(path, pluginBasePath+"/") { + path = strings.TrimPrefix(path, pluginBasePath) + } else if strings.HasPrefix(path, legacyPluginRoutePrefix+"/"+pluginID+"/") { + path = strings.TrimPrefix(path, legacyPluginRoutePrefix+"/"+pluginID) + } + path = strings.TrimRight(path, "/") + if path == "" { + return "", false + } + + fullPath := pluginBasePath + path + if !strings.HasPrefix(fullPath, pluginBasePath+"/") { + return "", false + } + if strings.ContainsAny(fullPath, " \t\r\n") || strings.Contains(fullPath, ":") || strings.Contains(fullPath, "*") || strings.Contains(fullPath, "..") { + return "", false + } + return fullPath, true +} + func managementRouteKey(method, path string) string { return strings.ToUpper(strings.TrimSpace(method)) + " " + strings.TrimSpace(path) } @@ -178,6 +272,50 @@ func (h *Host) ServeManagementHTTP(w http.ResponseWriter, r *http.Request) bool return true } +// ServeResourceHTTP dispatches an unauthenticated browser-navigable resource request to a plugin route. +func (h *Host) ServeResourceHTTP(w http.ResponseWriter, r *http.Request) bool { + if h == nil || w == nil || r == nil || r.URL == nil { + return false + } + if !strings.EqualFold(r.Method, http.MethodGet) { + return false + } + key := managementRouteKey(http.MethodGet, r.URL.Path) + h.mu.Lock() + record, okRoute := h.resourceRoutes[key] + h.mu.Unlock() + if !okRoute || record.route.Handler == nil || h.isPluginFused(record.pluginID) { + return false + } + + resp, errHandle := h.callResourceHandler(r.Context(), record, pluginapi.ManagementRequest{ + Method: http.MethodGet, + Path: r.URL.Path, + Headers: cloneHeader(r.Header), + Query: cloneValues(r.URL.Query()), + }) + if errHandle != nil { + log.Warnf("pluginhost: resource handler %s failed: %v", record.pluginID, errHandle) + http.Error(w, "plugin resource handler failed", http.StatusBadGateway) + return true + } + + for keyHeader, values := range resp.Headers { + for _, value := range values { + w.Header().Add(keyHeader, value) + } + } + statusCode := resp.StatusCode + if statusCode == 0 { + statusCode = http.StatusOK + } + w.WriteHeader(statusCode) + if _, errWrite := w.Write(resp.Body); errWrite != nil { + log.Warnf("pluginhost: failed to write plugin resource response: %v", errWrite) + } + return true +} + func (h *Host) callManagementHandler(ctx context.Context, record managementRouteRecord, req pluginapi.ManagementRequest) (resp pluginapi.ManagementResponse, err error) { if h == nil || record.route.Handler == nil || h.isPluginFused(record.pluginID) { return pluginapi.ManagementResponse{}, nil @@ -191,3 +329,17 @@ func (h *Host) callManagementHandler(ctx context.Context, record managementRoute }() return record.route.Handler.HandleManagement(ctx, req) } + +func (h *Host) callResourceHandler(ctx context.Context, record resourceRouteRecord, req pluginapi.ManagementRequest) (resp pluginapi.ManagementResponse, err error) { + if h == nil || record.route.Handler == nil || h.isPluginFused(record.pluginID) { + return pluginapi.ManagementResponse{}, nil + } + defer func() { + if recovered := recover(); recovered != nil { + h.fusePlugin(record.pluginID, "ResourceHandler.HandleManagement", recovered) + resp = pluginapi.ManagementResponse{} + err = fmt.Errorf("resource handler panic: %v", recovered) + } + }() + return record.route.Handler.HandleManagement(ctx, req) +} diff --git a/internal/pluginhost/management_test.go b/internal/pluginhost/management_test.go index 2103e68fb..4e4507ab3 100644 --- a/internal/pluginhost/management_test.go +++ b/internal/pluginhost/management_test.go @@ -91,18 +91,77 @@ func TestManagementHandlerPanicFusesPlugin(t *testing.T) { } } -func TestRegisteredPluginsIncludesGETManagementMenus(t *testing.T) { - plugin := &managementPluginDouble{ - routes: []pluginapi.ManagementRoute{ - { - Method: http.MethodGet, - Path: "/plugins/menu/status", +func TestServeResourceHTTPDispatchesPluginResource(t *testing.T) { + host := newHostWithRecords(capabilityRecord{ + id: "resource", + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + ManagementAPI: &managementPluginDouble{resources: []pluginapi.ResourceRoute{{ + Path: "/status", Menu: "Status", Description: "Shows plugin status.", - Handler: managementHandlerFunc(func(context.Context, pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) { - return pluginapi.ManagementResponse{}, nil + Handler: managementHandlerFunc(func(_ context.Context, req pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) { + if req.Path != "/v0/resource/plugins/resource/status" { + t.Fatalf("resource request path = %q, want normalized resource path", req.Path) + } + return pluginapi.ManagementResponse{ + Headers: http.Header{"Content-Type": []string{"text/html; charset=utf-8"}}, + Body: []byte("resource"), + }, nil }), - }, + }}}, + }}, + }) + host.RegisterManagementRoutes(context.Background(), nil) + + req := httptest.NewRequest(http.MethodGet, "/v0/resource/plugins/resource/status", nil) + rec := httptest.NewRecorder() + if !host.ServeResourceHTTP(rec, req) { + t.Fatal("ServeResourceHTTP() = false, want true") + } + if rec.Code != http.StatusOK || rec.Body.String() != "resource" { + t.Fatalf("response = %d %q, want 200 html", rec.Code, rec.Body.String()) + } + if got := rec.Header().Get("Content-Type"); got != "text/html; charset=utf-8" { + t.Fatalf("Content-Type = %q, want text/html; charset=utf-8", got) + } +} + +func TestLegacyGETManagementMenuRegistersAsResource(t *testing.T) { + host := newHostWithRecords(capabilityRecord{ + id: "legacy", + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + ManagementAPI: &managementPluginDouble{routes: []pluginapi.ManagementRoute{{ + Method: http.MethodGet, + Path: "/plugins/legacy/status", + Menu: "Legacy Status", + Description: "Shows legacy plugin status.", + Handler: managementHandlerFunc(func(context.Context, pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) { + return pluginapi.ManagementResponse{Body: []byte("legacy")}, nil + }), + }}}, + }}, + }) + host.RegisterManagementRoutes(context.Background(), nil) + + managementReq := httptest.NewRequest(http.MethodGet, "/v0/management/plugins/legacy/status", nil) + managementRec := httptest.NewRecorder() + if host.ServeManagementHTTP(managementRec, managementReq) { + t.Fatal("legacy menu route was served as Management API route") + } + + resourceReq := httptest.NewRequest(http.MethodGet, "/v0/resource/plugins/legacy/status", nil) + resourceRec := httptest.NewRecorder() + if !host.ServeResourceHTTP(resourceRec, resourceReq) { + t.Fatal("legacy menu route was not served as resource route") + } + if resourceRec.Body.String() != "legacy" { + t.Fatalf("resource body = %q, want legacy", resourceRec.Body.String()) + } +} + +func TestRegisteredPluginsIncludesResourceMenus(t *testing.T) { + plugin := &managementPluginDouble{ + routes: []pluginapi.ManagementRoute{ { Method: http.MethodGet, Path: "/plugins/menu/hidden", @@ -110,11 +169,12 @@ func TestRegisteredPluginsIncludesGETManagementMenus(t *testing.T) { return pluginapi.ManagementResponse{}, nil }), }, + }, + resources: []pluginapi.ResourceRoute{ { - Method: http.MethodPost, - Path: "/plugins/menu/run", - Menu: "Run", - Description: "Runs a plugin action.", + Path: "/status", + Menu: "Status", + Description: "Shows plugin status.", Handler: managementHandlerFunc(func(context.Context, pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) { return pluginapi.ManagementResponse{}, nil }), @@ -136,17 +196,18 @@ func TestRegisteredPluginsIncludesGETManagementMenus(t *testing.T) { t.Fatalf("RegisteredPlugins()[0].Menus = %#v, want one visible GET menu", plugins[0].Menus) } menu := plugins[0].Menus[0] - if menu.Path != "/v0/management/plugins/menu/status" || menu.Menu != "Status" || menu.Description != "Shows plugin status." { + if menu.Path != "/v0/resource/plugins/menu/status" || menu.Menu != "Status" || menu.Description != "Shows plugin status." { t.Fatalf("menu = %#v, want normalized status menu", menu) } } type managementPluginDouble struct { - routes []pluginapi.ManagementRoute + routes []pluginapi.ManagementRoute + resources []pluginapi.ResourceRoute } func (p *managementPluginDouble) RegisterManagement(context.Context, pluginapi.ManagementRegistrationRequest) (pluginapi.ManagementRegistrationResponse, error) { - return pluginapi.ManagementRegistrationResponse{Routes: p.routes}, nil + return pluginapi.ManagementRegistrationResponse{Routes: p.routes, Resources: p.resources}, nil } type managementHandlerFunc func(context.Context, pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) diff --git a/internal/pluginhost/rpc_client.go b/internal/pluginhost/rpc_client.go index d84adb8f2..4519beff1 100644 --- a/internal/pluginhost/rpc_client.go +++ b/internal/pluginhost/rpc_client.go @@ -498,7 +498,12 @@ func (a *rpcPluginAdapter) RegisterManagement(ctx context.Context, req pluginapi route.Handler = a routes = append(routes, route) } - return pluginapi.ManagementRegistrationResponse{Routes: routes}, nil + resources := make([]pluginapi.ResourceRoute, 0, len(resp.Resources)) + for _, route := range resp.Resources { + route.Handler = a + resources = append(resources, route) + } + return pluginapi.ManagementRegistrationResponse{Routes: routes, Resources: resources}, nil } func (a *rpcPluginAdapter) HandleManagement(ctx context.Context, req pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) { diff --git a/internal/pluginhost/rpc_schema.go b/internal/pluginhost/rpc_schema.go index eb2963fb1..bf2527266 100644 --- a/internal/pluginhost/rpc_schema.go +++ b/internal/pluginhost/rpc_schema.go @@ -103,7 +103,8 @@ type rpcThinkingApplyRequest struct { } type rpcManagementRegistrationResponse struct { - Routes []pluginapi.ManagementRoute `json:"routes,omitempty"` + Routes []pluginapi.ManagementRoute `json:"routes,omitempty"` + Resources []pluginapi.ResourceRoute `json:"resources,omitempty"` } type rpcEmptyResponse struct{} diff --git a/internal/pluginhost/snapshot.go b/internal/pluginhost/snapshot.go index 053f774e7..4e4448eea 100644 --- a/internal/pluginhost/snapshot.go +++ b/internal/pluginhost/snapshot.go @@ -1,7 +1,6 @@ package pluginhost import ( - "net/http" "sort" "strings" @@ -29,7 +28,7 @@ type RegisteredPluginInfo struct { Menus []RegisteredPluginMenu } -// RegisteredPluginMenu describes a plugin-owned GET Management API menu entry. +// RegisteredPluginMenu describes a plugin-owned resource menu entry. type RegisteredPluginMenu struct { Path string Menu string @@ -67,10 +66,7 @@ func (h *Host) registeredPluginMenus() map[string][]RegisteredPluginMenu { } h.mu.Lock() defer h.mu.Unlock() - for _, record := range h.managementRoutes { - if !strings.EqualFold(strings.TrimSpace(record.route.Method), http.MethodGet) { - continue - } + for _, record := range h.resourceRoutes { menu := strings.TrimSpace(record.route.Menu) if menu == "" { continue diff --git a/sdk/pluginapi/types.go b/sdk/pluginapi/types.go index a0b749075..6e6e36f80 100644 --- a/sdk/pluginapi/types.go +++ b/sdk/pluginapi/types.go @@ -109,7 +109,7 @@ type Capabilities struct { UsagePlugin UsagePlugin // CommandLinePlugin declares and handles plugin-owned command-line flags. CommandLinePlugin CommandLinePlugin - // ManagementAPI declares plugin-owned diagnostic Management API routes. + // ManagementAPI declares plugin-owned diagnostic Management API and resource routes. ManagementAPI ManagementAPI } @@ -921,7 +921,7 @@ type CommandLineExecutionResponse struct { ExitCode int } -// ManagementAPI declares plugin-owned Management API routes. +// ManagementAPI declares plugin-owned Management API and resource routes. type ManagementAPI interface { RegisterManagement(context.Context, ManagementRegistrationRequest) (ManagementRegistrationResponse, error) } @@ -932,12 +932,16 @@ type ManagementRegistrationRequest struct { Plugin Metadata // BasePath is the only Management API prefix plugins may register under. BasePath string + // ResourceBasePath is the plugin resource prefix for browser-navigable resources. + ResourceBasePath string } -// ManagementRegistrationResponse lists plugin-owned Management API routes. +// ManagementRegistrationResponse lists plugin-owned Management API and resource routes. type ManagementRegistrationResponse struct { // Routes contains the exact Management API routes to expose. Routes []ManagementRoute + // Resources contains browser-navigable plugin resources exposed under /v0/resource/plugins//. + Resources []ResourceRoute } // ManagementRoute describes one plugin-owned Management API route. @@ -946,15 +950,27 @@ type ManagementRoute struct { Method string // Path is an exact path under /v0/management/. Relative paths are resolved under that prefix. Path string - // Menu is the optional management UI menu label for GET routes. + // Menu is a legacy resource menu label. GET routes with Menu are registered under /v0/resource/plugins//. Menu string - // Description explains the management route for UI display. + // Description explains the legacy resource menu entry for UI display. Description string // Handler processes matching Management API requests. Handler ManagementHandler } -// ManagementHandler handles one plugin-owned Management API route. +// ResourceRoute describes one plugin-owned browser-navigable resource route. +type ResourceRoute struct { + // Path is an exact path under /v0/resource/plugins//. Relative paths are resolved under that prefix. + Path string + // Menu is the management UI menu label for this GET resource. + Menu string + // Description explains the resource route for UI display. + Description string + // Handler processes matching resource requests. Resource requests are not management-authenticated. + Handler ManagementHandler +} + +// ManagementHandler handles one plugin-owned Management API or resource route. type ManagementHandler interface { HandleManagement(context.Context, ManagementRequest) (ManagementResponse, error) } diff --git a/sdk/pluginapi/types_test.go b/sdk/pluginapi/types_test.go index 3f4cd1683..497ef30e5 100644 --- a/sdk/pluginapi/types_test.go +++ b/sdk/pluginapi/types_test.go @@ -48,16 +48,15 @@ func TestMetadataConfigFieldsExposePluginSchema(t *testing.T) { } } -func TestManagementRouteMenuFieldsExposeManagementUIHints(t *testing.T) { - route := ManagementRoute{ - Method: "GET", - Path: "/plugins/example/status", +func TestResourceRouteMenuFieldsExposeManagementUIHints(t *testing.T) { + route := ResourceRoute{ + Path: "/status", Menu: "Example Status", Description: "Shows example plugin status.", Handler: compileTimePlugin{}, } if route.Menu == "" || route.Description == "" { - t.Fatalf("management route missing menu fields: %#v", route) + t.Fatalf("resource route missing menu fields: %#v", route) } }