- Added `resources` field in `management.register` for defining browser-accessible resources. - Updated examples and documentation to reflect resource-based paths under `/v0/resource/plugins/<pluginID>/...`. - Replaced legacy `GET` menu routes with resource-based implementations for consistent plugin behavior. - Enhanced request handling for resource paths, including proper response headers and streamlined test coverage.
Unified · Split
| @@ -20,8 +20,8 @@ This directory contains standard dynamic library plugin examples for the CLIProx · 19 unmodified lines | |||
| 20 | - `thinking/`: thinking applier capability only. | 20 | - `thinking/`: thinking applier capability only. |
| 21 | - `usage/`: usage observer capability only. | 21 | - `usage/`: usage observer capability only. |
| 22 | - `cli/`: command-line capability only. | 22 | - `cli/`: command-line capability only. |
| 23 | - `management-api/`: Management API capability only. | 23 | - `management-api/`: Management API and resource capability only. |
| 24 | - `host-callback/`: minimal Management API route that demonstrates host callbacks. | 24 | - `host-callback/`: minimal plugin resource that demonstrates host callbacks. |
| 25 | 25 | ||
| 26 | Most standard capability examples contain `go/`, `c/`, and `rust/` subdirectories. Specialized examples may provide only the implementation language they need. | 26 | Most standard capability examples contain `go/`, `c/`, and `rust/` subdirectories. Specialized examples may provide only the implementation language they need. |
| 27 | 27 | ||
| @@ -68,4 +68,6 @@ Artifacts are written to `examples/plugin/bin`. · 40 unmodified lines | |||
| 68 | 68 | ||
| 69 | `protocol-format` uses a minimal executor because format declarations belong to executor capabilities. | 69 | `protocol-format` uses a minimal executor because format declarations belong to executor capabilities. |
| 70 | 70 | ||
| 71 | `host-callback` uses a minimal Management API route because host callbacks are invoked from plugin methods and are not standalone capabilities. | 71 | `host-callback` uses a minimal plugin resource because host callbacks are invoked from plugin methods and are not standalone capabilities. |
| 72 | |||
| 73 | Menu resources returned by `management.register` through the `resources` field are exposed by CPA under `/v0/resource/plugins/<pluginID>/...`. Authenticated plugin Management API routes remain under `/v0/management/...`. | ||
| @@ -20,8 +20,8 @@ · 19 unmodified lines | |||
| 20 | - `thinking/`:只演示 Thinking 处理能力。 | 20 | - `thinking/`:只演示 Thinking 处理能力。 |
| 21 | - `usage/`:只演示 Usage 观察能力。 | 21 | - `usage/`:只演示 Usage 观察能力。 |
| 22 | - `cli/`:只演示命令行扩展能力。 | 22 | - `cli/`:只演示命令行扩展能力。 |
| 23 | - `management-api/`:只演示 Management API 扩展能力。 | 23 | - `management-api/`:只演示 Management API 和资源扩展能力。 |
| 24 | - `host-callback/`:使用最小 Management API 路由演示宿主回调。 | 24 | - `host-callback/`:使用最小插件资源演示宿主回调。 |
| 25 | 25 | ||
| 26 | 多数标准能力示例都包含 `go/`、`c/` 和 `rust/` 三个子目录。专用示例可能只提供所需的实现语言。 | 26 | 多数标准能力示例都包含 `go/`、`c/` 和 `rust/` 三个子目录。专用示例可能只提供所需的实现语言。 |
| 27 | 27 | ||
| @@ -68,4 +68,6 @@ make -C examples/plugin build · 40 unmodified lines | |||
| 68 | 68 | ||
| 69 | `protocol-format` 使用最小执行器承载,因为格式声明属于执行器能力。 | 69 | `protocol-format` 使用最小执行器承载,因为格式声明属于执行器能力。 |
| 70 | 70 | ||
| 71 | `host-callback` 使用最小 Management API 路由承载,因为宿主回调只能从插件方法内部发起,不是独立能力。 | 71 | `host-callback` 使用最小插件资源承载,因为宿主回调只能从插件方法内部发起,不是独立能力。 |
| 72 | |||
| 73 | `management.register` 通过 `resources` 字段返回的菜单资源会由 CPA 暴露在 `/v0/resource/plugins/<pluginID>/...` 下。需要认证的插件自有 Management API 路由仍保留在 `/v0/management/...` 下。 | ||
| @@ -84,14 +84,14 @@ static int plugin_call(const char* method, const uint8_t* request, size_t reques · 83 unmodified lines | |||
| 84 | return 0; | 84 | return 0; |
| 85 | } | 85 | } |
| 86 | if (strcmp(method, "management.register") == 0) { | 86 | if (strcmp(method, "management.register") == 0) { |
| 87 | 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.\"}]}}"); | 87 | 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.\"}]}}"); |
| 88 | return 0; | 88 | return 0; |
| 89 | } | 89 | } |
| 90 | if (strcmp(method, "management.handle") == 0) { | 90 | if (strcmp(method, "management.handle") == 0) { |
| 91 | call_host("host.log", "{\"level\":\"info\",\"message\":\"example-host-callback-c host callback log\",\"fields\":{\"plugin\":\"example-host-callback-c\"}}"); | 91 | call_host("host.log", "{\"level\":\"info\",\"message\":\"example-host-callback-c host callback log\",\"fields\":{\"plugin\":\"example-host-callback-c\"}}"); |
| 92 | call_host("host.http.do", "{\"method\":\"GET\",\"url\":\"https://example.com\",\"headers\":{\"user-agent\":[\"example-host-callback-c\"]}}"); | 92 | call_host("host.http.do", "{\"method\":\"GET\",\"url\":\"https://example.com\",\"headers\":{\"user-agent\":[\"example-host-callback-c\"]}}"); |
| 93 | 93 | ||
| 94 | write_response(response, "{\"ok\":true,\"result\":{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"application/json\"]},\"Body\":\"eyJwbHVnaW4iOiJleGFtcGxlLWhvc3QtY2FsbGJhY2stYyJ9\"}}"); | 94 | write_response(response, "{\"ok\":true,\"result\":{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"text/html; charset=utf-8\"]},\"Body\":\"PCFkb2N0eXBlIGh0bWw+PHRpdGxlPkhvc3QgQ2FsbGJhY2s8L3RpdGxlPjxtYWluPkhvc3QgQ2FsbGJhY2sgcmVzb3VyY2U8L21haW4+\"}}"); |
| 95 | return 0; | 95 | return 0; |
| 96 | } | 96 | } |
| 97 | write_response(response, "{\"ok\":false,\"error\":{\"code\":\"unknown_method\",\"message\":\"unknown method\"}}"); | 97 | write_response(response, "{\"ok\":false,\"error\":{\"code\":\"unknown_method\",\"message\":\"unknown method\"}}"); |
| @@ -131,11 +131,11 @@ func handleMethod(method string) ([]byte, error) { · 130 unmodified lines | |||
| 131 | case "plugin.reconfigure": | 131 | case "plugin.reconfigure": |
| 132 | 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}}") | 132 | 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}}") |
| 133 | case "management.register": | 133 | case "management.register": |
| 134 | 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.\"}]}") | 134 | return okEnvelopeJSON("{\"resources\":[{\"Path\":\"/status\",\"Menu\":\"Host Callback\",\"Description\":\"CPA exposes this menu resource under /v0/resource/plugins/example-host-callback-go/status.\"}]}") |
| 135 | case "management.handle": | 135 | case "management.handle": |
| 136 | callHost("host.log", []byte(`{"level":"info","message":"example-host-callback-go host callback log","fields":{"plugin":"example-host-callback-go"}}`)) | 136 | callHost("host.log", []byte(`{"level":"info","message":"example-host-callback-go host callback log","fields":{"plugin":"example-host-callback-go"}}`)) |
| 137 | callHost("host.http.do", []byte(`{"method":"GET","url":"https://example.com","headers":{"user-agent":["example-host-callback-go"]}}`)) | 137 | callHost("host.http.do", []byte(`{"method":"GET","url":"https://example.com","headers":{"user-agent":["example-host-callback-go"]}}`)) |
| 138 | return okEnvelopeJSON("{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"application/json\"]},\"Body\":\"eyJwbHVnaW4iOiJleGFtcGxlLWhvc3QtY2FsbGJhY2stZ28ifQ==\"}") | 138 | return okEnvelopeJSON("{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"text/html; charset=utf-8\"]},\"Body\":\"PCFkb2N0eXBlIGh0bWw+PHRpdGxlPkhvc3QgQ2FsbGJhY2s8L3RpdGxlPjxtYWluPkhvc3QgQ2FsbGJhY2sgcmVzb3VyY2U8L21haW4+\"}") |
| 139 | default: | 139 | default: |
| 140 | return errorEnvelope("unknown_method", "unknown method: "+method), nil | 140 | return errorEnvelope("unknown_method", "unknown method: "+method), nil |
| 141 | } | 141 | } |
| @@ -68,10 +68,10 @@ unsafe extern "C" fn plugin_call(method: *const c_char, request: *const u8, requ · 67 unmodified lines | |||
| 68 | let _ = request; | 68 | let _ = request; |
| 69 | let _ = request_len; | 69 | let _ = request_len; |
| 70 | match method { | 70 | match method { |
| 71 | "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" => { | 71 | "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" => { |
| 72 | call_host("host.log", r#"{"level":"info","message":"example-host-callback-rust host callback log","fields":{"plugin":"example-host-callback-rust"}}"#); | 72 | call_host("host.log", r#"{"level":"info","message":"example-host-callback-rust host callback log","fields":{"plugin":"example-host-callback-rust"}}"#); |
| 73 | call_host("host.http.do", r#"{"method":"GET","url":"https://example.com","headers":{"user-agent":["example-host-callback-rust"]}}"#); | 73 | call_host("host.http.do", r#"{"method":"GET","url":"https://example.com","headers":{"user-agent":["example-host-callback-rust"]}}"#); |
| 74 | write_response(response, "{\"ok\":true,\"result\":{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"application/json\"]},\"Body\":\"eyJwbHVnaW4iOiJleGFtcGxlLWhvc3QtY2FsbGJhY2stcnVzdCJ9\"}}"); 0 }, | 74 | write_response(response, "{\"ok\":true,\"result\":{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"text/html; charset=utf-8\"]},\"Body\":\"PCFkb2N0eXBlIGh0bWw+PHRpdGxlPkhvc3QgQ2FsbGJhY2s8L3RpdGxlPjxtYWluPkhvc3QgQ2FsbGJhY2sgcmVzb3VyY2U8L21haW4+\"}}"); 0 }, |
| 75 | _ => { | 75 | _ => { |
| 76 | write_response(response, r#"{"ok":false,"error":{"code":"unknown_method","message":"unknown method"}}"#); | 76 | write_response(response, r#"{"ok":false,"error":{"code":"unknown_method","message":"unknown method"}}"#); |
| 77 | 0 | 77 | 0 |
| @@ -84,11 +84,11 @@ static int plugin_call(const char* method, const uint8_t* request, size_t reques · 83 unmodified lines | |||
| 84 | return 0; | 84 | return 0; |
| 85 | } | 85 | } |
| 86 | if (strcmp(method, "management.register") == 0) { | 86 | if (strcmp(method, "management.register") == 0) { |
| 87 | write_response(response, "{\"ok\":true,\"result\":{\"routes\":[{\"Method\":\"GET\",\"Path\":\"/plugins/example-management-api-c/status\",\"Menu\":\"Management API\",\"Description\":\"Management API capability example.\"}]}}"); | 87 | 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.\"}]}}"); |
| 88 | return 0; | 88 | return 0; |
| 89 | } | 89 | } |
| 90 | if (strcmp(method, "management.handle") == 0) { | 90 | if (strcmp(method, "management.handle") == 0) { |
| 91 | write_response(response, "{\"ok\":true,\"result\":{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"application/json\"]},\"Body\":\"eyJwbHVnaW4iOiJleGFtcGxlLW1hbmFnZW1lbnQtYXBpLWMifQ==\"}}"); | 91 | write_response(response, "{\"ok\":true,\"result\":{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"text/html; charset=utf-8\"]},\"Body\":\"PCFkb2N0eXBlIGh0bWw+PHRpdGxlPk1hbmFnZW1lbnQgQVBJPC90aXRsZT48bWFpbj5NYW5hZ2VtZW50IEFQSSByZXNvdXJjZTwvbWFpbj4=\"}}"); |
| 92 | return 0; | 92 | return 0; |
| 93 | } | 93 | } |
| 94 | write_response(response, "{\"ok\":false,\"error\":{\"code\":\"unknown_method\",\"message\":\"unknown method\"}}"); | 94 | write_response(response, "{\"ok\":false,\"error\":{\"code\":\"unknown_method\",\"message\":\"unknown method\"}}"); |
| @@ -131,9 +131,9 @@ func handleMethod(method string) ([]byte, error) { · 130 unmodified lines | |||
| 131 | case "plugin.reconfigure": | 131 | case "plugin.reconfigure": |
| 132 | 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}}") | 132 | 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}}") |
| 133 | case "management.register": | 133 | case "management.register": |
| 134 | return okEnvelopeJSON("{\"routes\":[{\"Method\":\"GET\",\"Path\":\"/plugins/example-management-api-go/status\",\"Menu\":\"Management API\",\"Description\":\"Management API capability example.\"}]}") | 134 | return okEnvelopeJSON("{\"resources\":[{\"Path\":\"/status\",\"Menu\":\"Management API\",\"Description\":\"CPA exposes this menu resource under /v0/resource/plugins/example-management-api-go/status.\"}]}") |
| 135 | case "management.handle": | 135 | case "management.handle": |
| 136 | return okEnvelopeJSON("{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"application/json\"]},\"Body\":\"eyJwbHVnaW4iOiJleGFtcGxlLW1hbmFnZW1lbnQtYXBpLWdvIn0=\"}") | 136 | return okEnvelopeJSON("{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"text/html; charset=utf-8\"]},\"Body\":\"PCFkb2N0eXBlIGh0bWw+PHRpdGxlPk1hbmFnZW1lbnQgQVBJPC90aXRsZT48bWFpbj5NYW5hZ2VtZW50IEFQSSByZXNvdXJjZTwvbWFpbj4=\"}") |
| 137 | default: | 137 | default: |
| 138 | return errorEnvelope("unknown_method", "unknown method: "+method), nil | 138 | return errorEnvelope("unknown_method", "unknown method: "+method), nil |
| 139 | } | 139 | } |
| @@ -68,7 +68,7 @@ unsafe extern "C" fn plugin_call(method: *const c_char, request: *const u8, requ · 67 unmodified lines | |||
| 68 | let _ = request; | 68 | let _ = request; |
| 69 | let _ = request_len; | 69 | let _ = request_len; |
| 70 | match method { | 70 | match method { |
| 71 | "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 }, | 71 | "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 }, |
| 72 | _ => { | 72 | _ => { |
| 73 | write_response(response, r#"{"ok":false,"error":{"code":"unknown_method","message":"unknown method"}}"#); | 73 | write_response(response, r#"{"ok":false,"error":{"code":"unknown_method","message":"unknown method"}}"#); |
| 74 | 0 | 74 | 0 |
| @@ -187,7 +187,9 @@ PUT /v0/management/plugins/{pluginID}/config · 186 unmodified lines | |||
| 187 | PATCH /v0/management/plugins/{pluginID}/config | 187 | PATCH /v0/management/plugins/{pluginID}/config |
| 188 | ``` | 188 | ``` |
| 189 | 189 | ||
| 190 | Plugin-owned Management API routes are registered through `management.register` and handled through `management.handle`. | 190 | Plugin-owned Management API routes are registered through the `routes` field of `management.register` and handled through `management.handle`. |
| 191 | |||
| 192 | Browser-navigable menu resources are registered through the `resources` field of `management.register`. CPA exposes those resources under `/v0/resource/plugins/<pluginID>/...`; for example, a plugin with ID `example` and resource path `/status` is served as `/v0/resource/plugins/example/status`. | ||
| 191 | 193 | ||
| 192 | ## Trust Boundary | 194 | ## Trust Boundary |
| 193 | 195 | ||
| @@ -185,7 +185,9 @@ PUT /v0/management/plugins/{pluginID}/config · 184 unmodified lines | |||
| 185 | PATCH /v0/management/plugins/{pluginID}/config | 185 | PATCH /v0/management/plugins/{pluginID}/config |
| 186 | ``` | 186 | ``` |
| 187 | 187 | ||
| 188 | 插件自有 Management API 路由通过 `management.register` 注册,通过 `management.handle` 处理。 | 188 | 插件自有 Management API 路由通过 `management.register` 的 `routes` 字段注册,并通过 `management.handle` 处理。 |
| 189 | |||
| 190 | 可由浏览器直接访问的菜单资源通过 `management.register` 的 `resources` 字段注册。CPA 会将这些资源暴露在 `/v0/resource/plugins/<pluginID>/...` 下;例如插件 ID 为 `example` 且资源路径为 `/status` 时,最终路径是 `/v0/resource/plugins/example/status`。 | ||
| 189 | 191 | ||
| 190 | ## 信任边界 | 192 | ## 信任边界 |
| 191 | 193 | ||
| @@ -84,8 +84,8 @@ static const char* CLI_REGISTER_RESPONSE = · 83 unmodified lines | |||
| 84 | static const char* CLI_EXECUTE_RESPONSE = | 84 | static const char* CLI_EXECUTE_RESPONSE = |
| 85 | "{\"ok\":true,\"result\":{\"Stdout\":\"cGx1Z2luIGV4YW1wbGUgYyBjb21tYW5kCg==\",\"ExitCode\":0}}"; | 85 | "{\"ok\":true,\"result\":{\"Stdout\":\"cGx1Z2luIGV4YW1wbGUgYyBjb21tYW5kCg==\",\"ExitCode\":0}}"; |
| 86 | static const char* MANAGEMENT_REGISTER_RESPONSE = | 86 | static const char* MANAGEMENT_REGISTER_RESPONSE = |
| 87 | "{\"ok\":true,\"result\":{\"Routes\":[{\"Method\":\"GET\",\"Path\":\"/plugins/example-c/status\"," | 87 | "{\"ok\":true,\"result\":{\"Resources\":[{\"Path\":\"/status\"," |
| 88 | "\"Menu\":\"Example C Plugin\",\"Description\":\"Shows example C plugin status.\"}]}}"; | 88 | "\"Menu\":\"Example C Plugin\",\"Description\":\"CPA exposes this menu resource under /v0/resource/plugins/example-c/status.\"}]}}"; |
| 89 | static const char* UNKNOWN_METHOD_RESPONSE = | 89 | static const char* UNKNOWN_METHOD_RESPONSE = |
| 90 | "{\"ok\":false,\"error\":{\"code\":\"unknown_method\",\"message\":\"unknown method\"}}"; | 90 | "{\"ok\":false,\"error\":{\"code\":\"unknown_method\",\"message\":\"unknown method\"}}"; |
| 91 | static const char* INVALID_METHOD_RESPONSE = | 91 | static const char* INVALID_METHOD_RESPONSE = |
| @@ -421,7 +421,7 @@ static char* make_http_response(const uint8_t* request, size_t request_len) { · 329 unmodified lines | |||
| 421 | char* url = extract_json_string(json, "URL"); | 421 | char* url = extract_json_string(json, "URL"); |
| 422 | char* path = extract_json_string(json, "Path"); | 422 | char* path = extract_json_string(json, "Path"); |
| 423 | char* method_escaped = json_escape(method == NULL ? "GET" : method); | 423 | char* method_escaped = json_escape(method == NULL ? "GET" : method); |
| 424 | char* target_escaped = json_escape(url != NULL ? url : (path == NULL ? "/plugins/example-c/status" : path)); | 424 | char* target_escaped = json_escape(url != NULL ? url : (path == NULL ? "/v0/resource/plugins/example-c/status" : path)); |
| 425 | char* body_json = format_string( | 425 | char* body_json = format_string( |
| 426 | "{\"plugin\":\"example-c\",\"method\":\"%s\",\"target\":\"%s\"}", | 426 | "{\"plugin\":\"example-c\",\"method\":\"%s\",\"target\":\"%s\"}", |
| 427 | method_escaped == NULL ? "" : method_escaped, | 427 | method_escaped == NULL ? "" : method_escaped, |
| @@ -100,7 +100,8 @@ type streamResponse struct { · 99 unmodified lines | |||
| 100 | } | 100 | } |
| 101 | 101 | ||
| 102 | type managementRegistrationResponse struct { | 102 | type managementRegistrationResponse struct { |
| 103 | Routes []pluginapi.ManagementRoute `json:"routes,omitempty"` | 103 | Routes []pluginapi.ManagementRoute `json:"routes,omitempty"` |
| 104 | Resources []pluginapi.ResourceRoute `json:"resources,omitempty"` | ||
| 104 | } | 105 | } |
| 105 | 106 | ||
| 106 | func main() {} | 107 | func main() {} |
| @@ -207,14 +208,18 @@ func handleMethod(method string, request []byte) ([]byte, error) { · 100 unmodified lines | |||
| 207 | case pluginabi.MethodCommandLineExecute: | 208 | case pluginabi.MethodCommandLineExecute: |
| 208 | return okEnvelope(pluginapi.CommandLineExecutionResponse{Stdout: []byte("plugin example command\n")}) | 209 | return okEnvelope(pluginapi.CommandLineExecutionResponse{Stdout: []byte("plugin example command\n")}) |
| 209 | case pluginabi.MethodManagementRegister: | 210 | case pluginabi.MethodManagementRegister: |
| 210 | return okEnvelope(managementRegistrationResponse{Routes: []pluginapi.ManagementRoute{{ | 211 | // CPA exposes menu resources under /v0/resource/plugins/<plugin-id>/. |
| 211 | Method: http.MethodGet, | 212 | return okEnvelope(managementRegistrationResponse{Resources: []pluginapi.ResourceRoute{{ |
| 212 | Path: "/plugins/example/status", | 213 | Path: "/status", |
| 213 | Menu: "Example Plugin", | 214 | Menu: "Example Plugin", |
| 214 | Description: "Shows example plugin status.", | 215 | Description: "Shows example plugin status as a browser-navigable resource.", |
| 215 | }}}) | 216 | }}}) |
| 216 | case pluginabi.MethodManagementHandle: | 217 | case pluginabi.MethodManagementHandle: |
| 217 | return okEnvelope(pluginapi.ManagementResponse{StatusCode: http.StatusOK, Body: []byte(`{"plugin":"example"}`)}) | 218 | return okEnvelope(pluginapi.ManagementResponse{ |
| 219 | StatusCode: http.StatusOK, | ||
| 220 | Headers: http.Header{"Content-Type": []string{"text/html; charset=utf-8"}}, | ||
| 221 | Body: []byte(`<!doctype html><title>Example Plugin</title><main>Example Plugin</main>`), | ||
| 222 | }) | ||
| 218 | default: | 223 | default: |
| 219 | return errorEnvelope("unknown_method", "unknown method: "+method), nil | 224 | return errorEnvelope("unknown_method", "unknown method: "+method), nil |
| 220 | } | 225 | } |
| @@ -18,7 +18,7 @@ const FRONTEND_AUTH_RESPONSE: &str = r#"{"ok":true,"result":{"Authenticated":tru · 17 unmodified lines | |||
| 18 | const STREAM_RESPONSE: &str = r#"{"ok":true,"result":{"headers":{"content-type":["text/event-stream"]},"chunks":[{"Payload":"cGx1Z2luLWV4YW1wbGUtcnVzdAo="}]}}"#; | 18 | const STREAM_RESPONSE: &str = r#"{"ok":true,"result":{"headers":{"content-type":["text/event-stream"]},"chunks":[{"Payload":"cGx1Z2luLWV4YW1wbGUtcnVzdAo="}]}}"#; |
| 19 | 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"}]}}"#; | 19 | 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"}]}}"#; |
| 20 | const CLI_EXECUTE_RESPONSE: &str = r#"{"ok":true,"result":{"Stdout":"cGx1Z2luIGV4YW1wbGUgcnVzdCBjb21tYW5kCg==","ExitCode":0}}"#; | 20 | const CLI_EXECUTE_RESPONSE: &str = r#"{"ok":true,"result":{"Stdout":"cGx1Z2luIGV4YW1wbGUgcnVzdCBjb21tYW5kCg==","ExitCode":0}}"#; |
| 21 | 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."}]}}"#; | 21 | 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."}]}}"#; |
| 22 | const UNKNOWN_METHOD_RESPONSE: &str = r#"{"ok":false,"error":{"code":"unknown_method","message":"unknown method"}}"#; | 22 | const UNKNOWN_METHOD_RESPONSE: &str = r#"{"ok":false,"error":{"code":"unknown_method","message":"unknown method"}}"#; |
| 23 | const INVALID_METHOD_RESPONSE: &str = r#"{"ok":false,"error":{"code":"invalid_method","message":"method is required"}}"#; | 23 | const INVALID_METHOD_RESPONSE: &str = r#"{"ok":false,"error":{"code":"invalid_method","message":"method is required"}}"#; |
| 24 | 24 | ||
| @@ -170,7 +170,7 @@ fn make_http_response(request: &[u8]) -> String { · 145 unmodified lines | |||
| 170 | let method = extract_json_string(&json, "Method").unwrap_or_else(|| "GET".to_string()); | 170 | let method = extract_json_string(&json, "Method").unwrap_or_else(|| "GET".to_string()); |
| 171 | let target = extract_json_string(&json, "URL") | 171 | let target = extract_json_string(&json, "URL") |
| 172 | .or_else(|| extract_json_string(&json, "Path")) | 172 | .or_else(|| extract_json_string(&json, "Path")) |
| 173 | .unwrap_or_else(|| "/plugins/example-rust/status".to_string()); | 173 | .unwrap_or_else(|| "/v0/resource/plugins/example-rust/status".to_string()); |
| 174 | let body = format!( | 174 | let body = format!( |
| 175 | r#"{{"plugin":"example-rust","method":"{}","target":"{}"}}"#, | 175 | r#"{{"plugin":"example-rust","method":"{}","target":"{}"}}"#, |
| 176 | json_escape(&method), | 176 | json_escape(&method), |
| @@ -382,7 +382,7 @@ func (s *Server) homeHeartbeatMiddleware() gin.HandlerFunc { · 381 unmodified lines | |||
| 382 | } | 382 | } |
| 383 | if c != nil && c.Request != nil { | 383 | if c != nil && c.Request != nil { |
| 384 | path := c.Request.URL.Path | 384 | path := c.Request.URL.Path |
| 385 | if strings.HasPrefix(path, "/v0/management/") || path == "/v0/management" || path == "/management.html" { | 385 | if strings.HasPrefix(path, "/v0/management/") || path == "/v0/management" || strings.HasPrefix(path, "/v0/resource/plugins/") || path == "/management.html" { |
| 386 | c.Next() | 386 | c.Next() |
| 387 | return | 387 | return |
| 388 | } | 388 | } |
| @@ -811,6 +811,10 @@ func (s *Server) pluginManagementNoRoute(c *gin.Context) { · 422 unmodified lines | |||
| 811 | return | 811 | return |
| 812 | } | 812 | } |
| 813 | path := c.Request.URL.Path | 813 | path := c.Request.URL.Path |
| 814 | if strings.HasPrefix(path, "/v0/resource/plugins/") { | ||
| 815 | s.pluginResourceNoRoute(c) | ||
| 816 | return | ||
| 817 | } | ||
| 814 | if path != "/v0/management" && !strings.HasPrefix(path, "/v0/management/") { | 818 | if path != "/v0/management" && !strings.HasPrefix(path, "/v0/management/") { |
| 815 | c.AbortWithStatus(http.StatusNotFound) | 819 | c.AbortWithStatus(http.StatusNotFound) |
| 816 | return | 820 | return |
| @@ -837,6 +841,24 @@ func (s *Server) pluginManagementNoRoute(c *gin.Context) { · 20 unmodified lines | |||
| 837 | c.AbortWithStatus(http.StatusNotFound) | 841 | c.AbortWithStatus(http.StatusNotFound) |
| 838 | } | 842 | } |
| 839 | 843 | ||
| 844 | func (s *Server) pluginResourceNoRoute(c *gin.Context) { | ||
| 845 | if s == nil || c == nil || c.Request == nil || c.Request.URL == nil { | ||
| 846 | if c != nil { | ||
| 847 | c.AbortWithStatus(http.StatusNotFound) | ||
| 848 | } | ||
| 849 | return | ||
| 850 | } | ||
| 851 | if s.cfg == nil || s.cfg.Home.Enabled || s.pluginHost == nil { | ||
| 852 | c.AbortWithStatus(http.StatusNotFound) | ||
| 853 | return | ||
| 854 | } | ||
| 855 | if s.pluginHost.ServeResourceHTTP(c.Writer, c.Request) { | ||
| 856 | c.Abort() | ||
| 857 | return | ||
| 858 | } | ||
| 859 | c.AbortWithStatus(http.StatusNotFound) | ||
| 860 | } | ||
| 861 | |||
| 840 | func (s *Server) serveManagementControlPanel(c *gin.Context) { | 862 | func (s *Server) serveManagementControlPanel(c *gin.Context) { |
| 841 | cfg := s.cfg | 863 | cfg := s.cfg |
| 842 | if cfg == nil || cfg.Home.Enabled || cfg.RemoteManagement.DisableControlPanel { | 864 | if cfg == nil || cfg.Home.Enabled || cfg.RemoteManagement.DisableControlPanel { |
| @@ -37,6 +37,7 @@ type Host struct { · 36 unmodified lines | |||
| 37 | commandLineFlags map[string]commandLineFlagRecord | 37 | commandLineFlags map[string]commandLineFlagRecord |
| 38 | commandLineHits map[string]struct{} | 38 | commandLineHits map[string]struct{} |
| 39 | managementRoutes map[string]managementRouteRecord | 39 | managementRoutes map[string]managementRouteRecord |
| 40 | resourceRoutes map[string]resourceRouteRecord | ||
| 40 | streams *streamBridge | 41 | streams *streamBridge |
| 41 | httpStreams *hostHTTPStreamBridge | 42 | httpStreams *hostHTTPStreamBridge |
| 42 | callbackContexts *callbackContextRegistry | 43 | callbackContexts *callbackContextRegistry |
| @@ -58,6 +59,7 @@ func New() *Host { · 15 unmodified lines | |||
| 58 | commandLineFlags: make(map[string]commandLineFlagRecord), | 59 | commandLineFlags: make(map[string]commandLineFlagRecord), |
| 59 | commandLineHits: make(map[string]struct{}), | 60 | commandLineHits: make(map[string]struct{}), |
| 60 | managementRoutes: make(map[string]managementRouteRecord), | 61 | managementRoutes: make(map[string]managementRouteRecord), |
| 62 | resourceRoutes: make(map[string]resourceRouteRecord), | ||
| 61 | streams: newStreamBridge(), | 63 | streams: newStreamBridge(), |
| 62 | httpStreams: newHostHTTPStreamBridge(), | 64 | httpStreams: newHostHTTPStreamBridge(), |
| 63 | callbackContexts: newCallbackContextRegistry(), | 65 | callbackContexts: newCallbackContextRegistry(), |
| @@ -93,6 +95,8 @@ func (h *Host) ApplyConfig(ctx context.Context, cfg *config.Config) { · 29 unmodified lines | |||
| 93 | h.runtimeConfig = cfg | 95 | h.runtimeConfig = cfg |
| 94 | 96 | ||
| 95 | if !rc.Enabled { | 97 | if !rc.Enabled { |
| 98 | h.managementRoutes = make(map[string]managementRouteRecord) | ||
| 99 | h.resourceRoutes = make(map[string]resourceRouteRecord) | ||
| 96 | h.snapshot.Store(emptySnapshot()) | 100 | h.snapshot.Store(emptySnapshot()) |
| 97 | h.mu.Unlock() | 101 | h.mu.Unlock() |
| 98 | h.refreshThinkingProviders(nil) | 102 | h.refreshThinkingProviders(nil) |
| @@ -102,6 +106,8 @@ func (h *Host) ApplyConfig(ctx context.Context, cfg *config.Config) { · 3 unmodified lines | |||
| 102 | files, errSelect := selectPluginFiles(rc.Dir) | 106 | files, errSelect := selectPluginFiles(rc.Dir) |
| 103 | if errSelect != nil { | 107 | if errSelect != nil { |
| 104 | log.Warnf("pluginhost: failed to select plugin files: %v", errSelect) | 108 | log.Warnf("pluginhost: failed to select plugin files: %v", errSelect) |
| 109 | h.managementRoutes = make(map[string]managementRouteRecord) | ||
| 110 | h.resourceRoutes = make(map[string]resourceRouteRecord) | ||
| 105 | h.snapshot.Store(emptySnapshot()) | 111 | h.snapshot.Store(emptySnapshot()) |
| 106 | h.mu.Unlock() | 112 | h.mu.Unlock() |
| 107 | h.refreshThinkingProviders(nil) | 113 | h.refreshThinkingProviders(nil) |
| @@ -187,6 +193,7 @@ func (h *Host) ShutdownAll() { · 79 unmodified lines | |||
| 187 | h.commandLineFlags = make(map[string]commandLineFlagRecord) | 193 | h.commandLineFlags = make(map[string]commandLineFlagRecord) |
| 188 | h.commandLineHits = make(map[string]struct{}) | 194 | h.commandLineHits = make(map[string]struct{}) |
| 189 | h.managementRoutes = make(map[string]managementRouteRecord) | 195 | h.managementRoutes = make(map[string]managementRouteRecord) |
| 196 | h.resourceRoutes = make(map[string]resourceRouteRecord) | ||
| 190 | h.snapshot.Store(emptySnapshot()) | 197 | h.snapshot.Store(emptySnapshot()) |
| 191 | h.mu.Unlock() | 198 | h.mu.Unlock() |
| 192 | 199 | ||
| @@ -12,20 +12,30 @@ import ( · 11 unmodified lines | |||
| 12 | log "github.com/sirupsen/logrus" | 12 | log "github.com/sirupsen/logrus" |
| 13 | ) | 13 | ) |
| 14 | 14 | ||
| 15 | const managementBasePath = "/v0/management" | 15 | const ( |
| 16 | managementBasePath = "/v0/management" | ||
| 17 | resourcePluginBasePath = "/v0/resource/plugins" | ||
| 18 | legacyPluginRoutePrefix = "/plugins" | ||
| 19 | ) | ||
| 16 | 20 | ||
| 17 | type managementRouteRecord struct { | 21 | type managementRouteRecord struct { |
| 18 | pluginID string | 22 | pluginID string |
| 19 | route pluginapi.ManagementRoute | 23 | route pluginapi.ManagementRoute |
| 20 | } | 24 | } |
| 21 | 25 | ||
| 22 | // RegisterManagementRoutes rebuilds the plugin-owned Management API route table. | 26 | type resourceRouteRecord struct { |
| 27 | pluginID string | ||
| 28 | route pluginapi.ResourceRoute | ||
| 29 | } | ||
| 30 | |||
| 31 | // RegisterManagementRoutes rebuilds the plugin-owned Management API and resource route tables. | ||
| 23 | func (h *Host) RegisterManagementRoutes(ctx context.Context, reserved map[string]struct{}) { | 32 | func (h *Host) RegisterManagementRoutes(ctx context.Context, reserved map[string]struct{}) { |
| 24 | if h == nil { | 33 | if h == nil { |
| 25 | return | 34 | return |
| 26 | } | 35 | } |
| 27 | 36 | ||
| 28 | nextRoutes := make(map[string]managementRouteRecord) | 37 | nextRoutes := make(map[string]managementRouteRecord) |
| 38 | nextResources := make(map[string]resourceRouteRecord) | ||
| 29 | for _, record := range h.Snapshot().records { | 39 | for _, record := range h.Snapshot().records { |
| 30 | plugin := record.plugin.Capabilities.ManagementAPI | 40 | plugin := record.plugin.Capabilities.ManagementAPI |
| 31 | if plugin == nil || h.isPluginFused(record.id) { | 41 | if plugin == nil || h.isPluginFused(record.id) { |
| @@ -36,12 +46,19 @@ func (h *Host) RegisterManagementRoutes(ctx context.Context, reserved map[string · 4 unmodified lines | |||
| 36 | log.Warnf("pluginhost: management registrar %s failed: %v", record.id, errRegister) | 46 | log.Warnf("pluginhost: management registrar %s failed: %v", record.id, errRegister) |
| 37 | continue | 47 | continue |
| 38 | } | 48 | } |
| 49 | |||
| 39 | for _, item := range resp.Routes { | 50 | for _, item := range resp.Routes { |
| 40 | method, path, okRoute := normalizeManagementRoute(item) | 51 | method, path, okRoute := normalizeManagementRoute(item) |
| 41 | if !okRoute { | 52 | if !okRoute { |
| 42 | log.Warnf("pluginhost: plugin %s declared invalid management route %s %s", record.id, item.Method, item.Path) | 53 | log.Warnf("pluginhost: plugin %s declared invalid management route %s %s", record.id, item.Method, item.Path) |
| 43 | continue | 54 | continue |
| 44 | } | 55 | } |
| 56 | if routeDeclaresLegacyMenuResource(method, item) { | ||
| 57 | if !registerResourceRoute(nextResources, record.id, resourceRouteFromManagementRoute(item)) { | ||
| 58 | log.Warnf("pluginhost: plugin %s declared invalid resource route %s", record.id, item.Path) | ||
| 59 | } | ||
| 60 | continue | ||
| 61 | } | ||
| 45 | key := managementRouteKey(method, path) | 62 | key := managementRouteKey(method, path) |
| 46 | if _, exists := reserved[key]; exists { | 63 | if _, exists := reserved[key]; exists { |
| 47 | log.Warnf("pluginhost: plugin %s management route %s conflicts with an existing route and was skipped", record.id, key) | 64 | 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 · 10 unmodified lines | |||
| 58 | route: item, | 75 | route: item, |
| 59 | } | 76 | } |
| 60 | } | 77 | } |
| 78 | |||
| 79 | for _, item := range resp.Resources { | ||
| 80 | if !registerResourceRoute(nextResources, record.id, item) { | ||
| 81 | log.Warnf("pluginhost: plugin %s declared invalid resource route %s", record.id, item.Path) | ||
| 82 | } | ||
| 83 | } | ||
| 61 | } | 84 | } |
| 62 | 85 | ||
| 63 | h.mu.Lock() | 86 | h.mu.Lock() |
| 64 | h.managementRoutes = nextRoutes | 87 | h.managementRoutes = nextRoutes |
| 88 | h.resourceRoutes = nextResources | ||
| 65 | h.mu.Unlock() | 89 | h.mu.Unlock() |
| 66 | } | 90 | } |
| 67 | 91 | ||
| @@ -77,8 +101,9 @@ func (h *Host) callManagementRegistrar(ctx context.Context, record capabilityRec · 9 unmodified lines | |||
| 77 | } | 101 | } |
| 78 | }() | 102 | }() |
| 79 | return plugin.RegisterManagement(ctx, pluginapi.ManagementRegistrationRequest{ | 103 | return plugin.RegisterManagement(ctx, pluginapi.ManagementRegistrationRequest{ |
| 80 | Plugin: record.meta, | 104 | Plugin: record.meta, |
| 81 | BasePath: managementBasePath, | 105 | BasePath: managementBasePath, |
| 106 | ResourceBasePath: resourcePluginBasePath + "/" + record.id, | ||
| 82 | }) | 107 | }) |
| 83 | } | 108 | } |
| 84 | 109 | ||
| @@ -118,6 +143,75 @@ func normalizeManagementRoute(item pluginapi.ManagementRoute) (string, string, b · 33 unmodified lines | |||
| 118 | return method, fullPath, true | 143 | return method, fullPath, true |
| 119 | } | 144 | } |
| 120 | 145 | ||
| 146 | func routeDeclaresLegacyMenuResource(method string, item pluginapi.ManagementRoute) bool { | ||
| 147 | return strings.EqualFold(strings.TrimSpace(method), http.MethodGet) && strings.TrimSpace(item.Menu) != "" | ||
| 148 | } | ||
| 149 | |||
| 150 | func resourceRouteFromManagementRoute(item pluginapi.ManagementRoute) pluginapi.ResourceRoute { | ||
| 151 | return pluginapi.ResourceRoute{ | ||
| 152 | Path: item.Path, | ||
| 153 | Menu: item.Menu, | ||
| 154 | Description: item.Description, | ||
| 155 | Handler: item.Handler, | ||
| 156 | } | ||
| 157 | } | ||
| 158 | |||
| 159 | func registerResourceRoute(routes map[string]resourceRouteRecord, pluginID string, item pluginapi.ResourceRoute) bool { | ||
| 160 | path, okRoute := normalizeResourceRoute(pluginID, item) | ||
| 161 | if !okRoute { | ||
| 162 | return false | ||
| 163 | } | ||
| 164 | key := managementRouteKey(http.MethodGet, path) | ||
| 165 | if _, exists := routes[key]; exists { | ||
| 166 | log.Warnf("pluginhost: plugin %s resource route %s conflicts with a higher-priority plugin and was skipped", pluginID, key) | ||
| 167 | return true | ||
| 168 | } | ||
| 169 | item.Path = path | ||
| 170 | routes[key] = resourceRouteRecord{ | ||
| 171 | pluginID: pluginID, | ||
| 172 | route: item, | ||
| 173 | } | ||
| 174 | return true | ||
| 175 | } | ||
| 176 | |||
| 177 | func normalizeResourceRoute(pluginID string, item pluginapi.ResourceRoute) (string, bool) { | ||
| 178 | if item.Handler == nil { | ||
| 179 | return "", false | ||
| 180 | } | ||
| 181 | pluginID = strings.TrimSpace(pluginID) | ||
| 182 | if pluginID == "" { | ||
| 183 | return "", false | ||
| 184 | } | ||
| 185 | |||
| 186 | path := strings.TrimSpace(item.Path) | ||
| 187 | if path == "" { | ||
| 188 | return "", false | ||
| 189 | } | ||
| 190 | if !strings.HasPrefix(path, "/") { | ||
| 191 | path = "/" + path | ||
| 192 | } | ||
| 193 | |||
| 194 | pluginBasePath := resourcePluginBasePath + "/" + pluginID | ||
| 195 | if strings.HasPrefix(path, pluginBasePath+"/") { | ||
| 196 | path = strings.TrimPrefix(path, pluginBasePath) | ||
| 197 | } else if strings.HasPrefix(path, legacyPluginRoutePrefix+"/"+pluginID+"/") { | ||
| 198 | path = strings.TrimPrefix(path, legacyPluginRoutePrefix+"/"+pluginID) | ||
| 199 | } | ||
| 200 | path = strings.TrimRight(path, "/") | ||
| 201 | if path == "" { | ||
| 202 | return "", false | ||
| 203 | } | ||
| 204 | |||
| 205 | fullPath := pluginBasePath + path | ||
| 206 | if !strings.HasPrefix(fullPath, pluginBasePath+"/") { | ||
| 207 | return "", false | ||
| 208 | } | ||
| 209 | if strings.ContainsAny(fullPath, " \t\r\n") || strings.Contains(fullPath, ":") || strings.Contains(fullPath, "*") || strings.Contains(fullPath, "..") { | ||
| 210 | return "", false | ||
| 211 | } | ||
| 212 | return fullPath, true | ||
| 213 | } | ||
| 214 | |||
| 121 | func managementRouteKey(method, path string) string { | 215 | func managementRouteKey(method, path string) string { |
| 122 | return strings.ToUpper(strings.TrimSpace(method)) + " " + strings.TrimSpace(path) | 216 | return strings.ToUpper(strings.TrimSpace(method)) + " " + strings.TrimSpace(path) |
| 123 | } | 217 | } |
| @@ -178,6 +272,50 @@ func (h *Host) ServeManagementHTTP(w http.ResponseWriter, r *http.Request) bool · 54 unmodified lines | |||
| 178 | return true | 272 | return true |
| 179 | } | 273 | } |
| 180 | 274 | ||
| 275 | // ServeResourceHTTP dispatches an unauthenticated browser-navigable resource request to a plugin route. | ||
| 276 | func (h *Host) ServeResourceHTTP(w http.ResponseWriter, r *http.Request) bool { | ||
| 277 | if h == nil || w == nil || r == nil || r.URL == nil { | ||
| 278 | return false | ||
| 279 | } | ||
| 280 | if !strings.EqualFold(r.Method, http.MethodGet) { | ||
| 281 | return false | ||
| 282 | } | ||
| 283 | key := managementRouteKey(http.MethodGet, r.URL.Path) | ||
| 284 | h.mu.Lock() | ||
| 285 | record, okRoute := h.resourceRoutes[key] | ||
| 286 | h.mu.Unlock() | ||
| 287 | if !okRoute || record.route.Handler == nil || h.isPluginFused(record.pluginID) { | ||
| 288 | return false | ||
| 289 | } | ||
| 290 | |||
| 291 | resp, errHandle := h.callResourceHandler(r.Context(), record, pluginapi.ManagementRequest{ | ||
| 292 | Method: http.MethodGet, | ||
| 293 | Path: r.URL.Path, | ||
| 294 | Headers: cloneHeader(r.Header), | ||
| 295 | Query: cloneValues(r.URL.Query()), | ||
| 296 | }) | ||
| 297 | if errHandle != nil { | ||
| 298 | log.Warnf("pluginhost: resource handler %s failed: %v", record.pluginID, errHandle) | ||
| 299 | http.Error(w, "plugin resource handler failed", http.StatusBadGateway) | ||
| 300 | return true | ||
| 301 | } | ||
| 302 | |||
| 303 | for keyHeader, values := range resp.Headers { | ||
| 304 | for _, value := range values { | ||
| 305 | w.Header().Add(keyHeader, value) | ||
| 306 | } | ||
| 307 | } | ||
| 308 | statusCode := resp.StatusCode | ||
| 309 | if statusCode == 0 { | ||
| 310 | statusCode = http.StatusOK | ||
| 311 | } | ||
| 312 | w.WriteHeader(statusCode) | ||
| 313 | if _, errWrite := w.Write(resp.Body); errWrite != nil { | ||
| 314 | log.Warnf("pluginhost: failed to write plugin resource response: %v", errWrite) | ||
| 315 | } | ||
| 316 | return true | ||
| 317 | } | ||
| 318 | |||
| 181 | func (h *Host) callManagementHandler(ctx context.Context, record managementRouteRecord, req pluginapi.ManagementRequest) (resp pluginapi.ManagementResponse, err error) { | 319 | func (h *Host) callManagementHandler(ctx context.Context, record managementRouteRecord, req pluginapi.ManagementRequest) (resp pluginapi.ManagementResponse, err error) { |
| 182 | if h == nil || record.route.Handler == nil || h.isPluginFused(record.pluginID) { | 320 | if h == nil || record.route.Handler == nil || h.isPluginFused(record.pluginID) { |
| 183 | return pluginapi.ManagementResponse{}, nil | 321 | return pluginapi.ManagementResponse{}, nil |
| @@ -191,3 +329,17 @@ func (h *Host) callManagementHandler(ctx context.Context, record managementRoute · 7 unmodified lines | |||
| 191 | }() | 329 | }() |
| 192 | return record.route.Handler.HandleManagement(ctx, req) | 330 | return record.route.Handler.HandleManagement(ctx, req) |
| 193 | } | 331 | } |
| 332 | |||
| 333 | func (h *Host) callResourceHandler(ctx context.Context, record resourceRouteRecord, req pluginapi.ManagementRequest) (resp pluginapi.ManagementResponse, err error) { | ||
| 334 | if h == nil || record.route.Handler == nil || h.isPluginFused(record.pluginID) { | ||
| 335 | return pluginapi.ManagementResponse{}, nil | ||
| 336 | } | ||
| 337 | defer func() { | ||
| 338 | if recovered := recover(); recovered != nil { | ||
| 339 | h.fusePlugin(record.pluginID, "ResourceHandler.HandleManagement", recovered) | ||
| 340 | resp = pluginapi.ManagementResponse{} | ||
| 341 | err = fmt.Errorf("resource handler panic: %v", recovered) | ||
| 342 | } | ||
| 343 | }() | ||
| 344 | return record.route.Handler.HandleManagement(ctx, req) | ||
| 345 | } | ||
| @@ -91,18 +91,77 @@ func TestManagementHandlerPanicFusesPlugin(t *testing.T) { · 90 unmodified lines | |||
| 91 | } | 91 | } |
| 92 | } | 92 | } |
| 93 | 93 | ||
| 94 | func TestRegisteredPluginsIncludesGETManagementMenus(t *testing.T) { | 94 | func TestServeResourceHTTPDispatchesPluginResource(t *testing.T) { |
| 95 | plugin := &managementPluginDouble{ | 95 | host := newHostWithRecords(capabilityRecord{ |
| 96 | routes: []pluginapi.ManagementRoute{ | 96 | id: "resource", |
| 97 | { | 97 | plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ |
| 98 | Method: http.MethodGet, | 98 | ManagementAPI: &managementPluginDouble{resources: []pluginapi.ResourceRoute{{ |
| 99 | Path: "/plugins/menu/status", | 99 | Path: "/status", |
| 100 | Menu: "Status", | 100 | Menu: "Status", |
| 101 | Description: "Shows plugin status.", | 101 | Description: "Shows plugin status.", |
| 102 | Handler: managementHandlerFunc(func(_ context.Context, req pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) { | ||
| 103 | if req.Path != "/v0/resource/plugins/resource/status" { | ||
| 104 | t.Fatalf("resource request path = %q, want normalized resource path", req.Path) | ||
| 105 | } | ||
| 106 | return pluginapi.ManagementResponse{ | ||
| 107 | Headers: http.Header{"Content-Type": []string{"text/html; charset=utf-8"}}, | ||
| 108 | Body: []byte("<!doctype html><title>resource</title>"), | ||
| 109 | }, nil | ||
| 110 | }), | ||
| 111 | }}}, | ||
| 112 | }}, | ||
| 113 | }) | ||
| 114 | host.RegisterManagementRoutes(context.Background(), nil) | ||
| 115 | |||
| 116 | req := httptest.NewRequest(http.MethodGet, "/v0/resource/plugins/resource/status", nil) | ||
| 117 | rec := httptest.NewRecorder() | ||
| 118 | if !host.ServeResourceHTTP(rec, req) { | ||
| 119 | t.Fatal("ServeResourceHTTP() = false, want true") | ||
| 120 | } | ||
| 121 | if rec.Code != http.StatusOK || rec.Body.String() != "<!doctype html><title>resource</title>" { | ||
| 122 | t.Fatalf("response = %d %q, want 200 html", rec.Code, rec.Body.String()) | ||
| 123 | } | ||
| 124 | if got := rec.Header().Get("Content-Type"); got != "text/html; charset=utf-8" { | ||
| 125 | t.Fatalf("Content-Type = %q, want text/html; charset=utf-8", got) | ||
| 126 | } | ||
| 127 | } | ||
| 128 | |||
| 129 | func TestLegacyGETManagementMenuRegistersAsResource(t *testing.T) { | ||
| 130 | host := newHostWithRecords(capabilityRecord{ | ||
| 131 | id: "legacy", | ||
| 132 | plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ | ||
| 133 | ManagementAPI: &managementPluginDouble{routes: []pluginapi.ManagementRoute{{ | ||
| 134 | Method: http.MethodGet, | ||
| 135 | Path: "/plugins/legacy/status", | ||
| 136 | Menu: "Legacy Status", | ||
| 137 | Description: "Shows legacy plugin status.", | ||
| 102 | Handler: managementHandlerFunc(func(context.Context, pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) { | 138 | Handler: managementHandlerFunc(func(context.Context, pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) { |
| 103 | return pluginapi.ManagementResponse{}, nil | 139 | return pluginapi.ManagementResponse{Body: []byte("legacy")}, nil |
| 104 | }), | 140 | }), |
| 105 | }, | 141 | }}}, |
| 142 | }}, | ||
| 143 | }) | ||
| 144 | host.RegisterManagementRoutes(context.Background(), nil) | ||
| 145 | |||
| 146 | managementReq := httptest.NewRequest(http.MethodGet, "/v0/management/plugins/legacy/status", nil) | ||
| 147 | managementRec := httptest.NewRecorder() | ||
| 148 | if host.ServeManagementHTTP(managementRec, managementReq) { | ||
| 149 | t.Fatal("legacy menu route was served as Management API route") | ||
| 150 | } | ||
| 151 | |||
| 152 | resourceReq := httptest.NewRequest(http.MethodGet, "/v0/resource/plugins/legacy/status", nil) | ||
| 153 | resourceRec := httptest.NewRecorder() | ||
| 154 | if !host.ServeResourceHTTP(resourceRec, resourceReq) { | ||
| 155 | t.Fatal("legacy menu route was not served as resource route") | ||
| 156 | } | ||
| 157 | if resourceRec.Body.String() != "legacy" { | ||
| 158 | t.Fatalf("resource body = %q, want legacy", resourceRec.Body.String()) | ||
| 159 | } | ||
| 160 | } | ||
| 161 | |||
| 162 | func TestRegisteredPluginsIncludesResourceMenus(t *testing.T) { | ||
| 163 | plugin := &managementPluginDouble{ | ||
| 164 | routes: []pluginapi.ManagementRoute{ | ||
| 106 | { | 165 | { |
| 107 | Method: http.MethodGet, | 166 | Method: http.MethodGet, |
| 108 | Path: "/plugins/menu/hidden", | 167 | Path: "/plugins/menu/hidden", |
| @@ -110,11 +169,12 @@ func TestRegisteredPluginsIncludesGETManagementMenus(t *testing.T) { · 1 unmodified line | |||
| 110 | return pluginapi.ManagementResponse{}, nil | 169 | return pluginapi.ManagementResponse{}, nil |
| 111 | }), | 170 | }), |
| 112 | }, | 171 | }, |
| 172 | }, | ||
| 173 | resources: []pluginapi.ResourceRoute{ | ||
| 113 | { | 174 | { |
| 114 | Method: http.MethodPost, | 175 | Path: "/status", |
| 115 | Path: "/plugins/menu/run", | 176 | Menu: "Status", |
| 116 | Menu: "Run", | 177 | Description: "Shows plugin status.", |
| 117 | Description: "Runs a plugin action.", | ||
| 118 | Handler: managementHandlerFunc(func(context.Context, pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) { | 178 | Handler: managementHandlerFunc(func(context.Context, pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) { |
| 119 | return pluginapi.ManagementResponse{}, nil | 179 | return pluginapi.ManagementResponse{}, nil |
| 120 | }), | 180 | }), |
| @@ -136,17 +196,18 @@ func TestRegisteredPluginsIncludesGETManagementMenus(t *testing.T) { · 15 unmodified lines | |||
| 136 | t.Fatalf("RegisteredPlugins()[0].Menus = %#v, want one visible GET menu", plugins[0].Menus) | 196 | t.Fatalf("RegisteredPlugins()[0].Menus = %#v, want one visible GET menu", plugins[0].Menus) |
| 137 | } | 197 | } |
| 138 | menu := plugins[0].Menus[0] | 198 | menu := plugins[0].Menus[0] |
| 139 | if menu.Path != "/v0/management/plugins/menu/status" || menu.Menu != "Status" || menu.Description != "Shows plugin status." { | 199 | if menu.Path != "/v0/resource/plugins/menu/status" || menu.Menu != "Status" || menu.Description != "Shows plugin status." { |
| 140 | t.Fatalf("menu = %#v, want normalized status menu", menu) | 200 | t.Fatalf("menu = %#v, want normalized status menu", menu) |
| 141 | } | 201 | } |
| 142 | } | 202 | } |
| 143 | 203 | ||
| 144 | type managementPluginDouble struct { | 204 | type managementPluginDouble struct { |
| 145 | routes []pluginapi.ManagementRoute | 205 | routes []pluginapi.ManagementRoute |
| 206 | resources []pluginapi.ResourceRoute | ||
| 146 | } | 207 | } |
| 147 | 208 | ||
| 148 | func (p *managementPluginDouble) RegisterManagement(context.Context, pluginapi.ManagementRegistrationRequest) (pluginapi.ManagementRegistrationResponse, error) { | 209 | func (p *managementPluginDouble) RegisterManagement(context.Context, pluginapi.ManagementRegistrationRequest) (pluginapi.ManagementRegistrationResponse, error) { |
| 149 | return pluginapi.ManagementRegistrationResponse{Routes: p.routes}, nil | 210 | return pluginapi.ManagementRegistrationResponse{Routes: p.routes, Resources: p.resources}, nil |
| 150 | } | 211 | } |
| 151 | 212 | ||
| 152 | type managementHandlerFunc func(context.Context, pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) | 213 | type managementHandlerFunc func(context.Context, pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) |
| @@ -498,7 +498,12 @@ func (a *rpcPluginAdapter) RegisterManagement(ctx context.Context, req pluginapi · 497 unmodified lines | |||
| 498 | route.Handler = a | 498 | route.Handler = a |
| 499 | routes = append(routes, route) | 499 | routes = append(routes, route) |
| 500 | } | 500 | } |
| 501 | return pluginapi.ManagementRegistrationResponse{Routes: routes}, nil | 501 | resources := make([]pluginapi.ResourceRoute, 0, len(resp.Resources)) |
| 502 | for _, route := range resp.Resources { | ||
| 503 | route.Handler = a | ||
| 504 | resources = append(resources, route) | ||
| 505 | } | ||
| 506 | return pluginapi.ManagementRegistrationResponse{Routes: routes, Resources: resources}, nil | ||
| 502 | } | 507 | } |
| 503 | 508 | ||
| 504 | func (a *rpcPluginAdapter) HandleManagement(ctx context.Context, req pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) { | 509 | func (a *rpcPluginAdapter) HandleManagement(ctx context.Context, req pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) { |
| @@ -103,7 +103,8 @@ type rpcThinkingApplyRequest struct { · 102 unmodified lines | |||
| 103 | } | 103 | } |
| 104 | 104 | ||
| 105 | type rpcManagementRegistrationResponse struct { | 105 | type rpcManagementRegistrationResponse struct { |
| 106 | Routes []pluginapi.ManagementRoute `json:"routes,omitempty"` | 106 | Routes []pluginapi.ManagementRoute `json:"routes,omitempty"` |
| 107 | Resources []pluginapi.ResourceRoute `json:"resources,omitempty"` | ||
| 107 | } | 108 | } |
| 108 | 109 | ||
| 109 | type rpcEmptyResponse struct{} | 110 | type rpcEmptyResponse struct{} |
| @@ -1,7 +1,6 @@ | |||
| 1 | package pluginhost | 1 | package pluginhost |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "net/http" | ||
| 5 | "sort" | 4 | "sort" |
| 6 | "strings" | 5 | "strings" |
| 7 | 6 | ||
| @@ -29,7 +28,7 @@ type RegisteredPluginInfo struct { · 21 unmodified lines | |||
| 29 | Menus []RegisteredPluginMenu | 28 | Menus []RegisteredPluginMenu |
| 30 | } | 29 | } |
| 31 | 30 | ||
| 32 | // RegisteredPluginMenu describes a plugin-owned GET Management API menu entry. | 31 | // RegisteredPluginMenu describes a plugin-owned resource menu entry. |
| 33 | type RegisteredPluginMenu struct { | 32 | type RegisteredPluginMenu struct { |
| 34 | Path string | 33 | Path string |
| 35 | Menu string | 34 | Menu string |
| @@ -67,10 +66,7 @@ func (h *Host) registeredPluginMenus() map[string][]RegisteredPluginMenu { · 31 unmodified lines | |||
| 67 | } | 66 | } |
| 68 | h.mu.Lock() | 67 | h.mu.Lock() |
| 69 | defer h.mu.Unlock() | 68 | defer h.mu.Unlock() |
| 70 | for _, record := range h.managementRoutes { | 69 | for _, record := range h.resourceRoutes { |
| 71 | if !strings.EqualFold(strings.TrimSpace(record.route.Method), http.MethodGet) { | ||
| 72 | continue | ||
| 73 | } | ||
| 74 | menu := strings.TrimSpace(record.route.Menu) | 70 | menu := strings.TrimSpace(record.route.Menu) |
| 75 | if menu == "" { | 71 | if menu == "" { |
| 76 | continue | 72 | continue |
| @@ -109,7 +109,7 @@ type Capabilities struct { · 108 unmodified lines | |||
| 109 | UsagePlugin UsagePlugin | 109 | UsagePlugin UsagePlugin |
| 110 | // CommandLinePlugin declares and handles plugin-owned command-line flags. | 110 | // CommandLinePlugin declares and handles plugin-owned command-line flags. |
| 111 | CommandLinePlugin CommandLinePlugin | 111 | CommandLinePlugin CommandLinePlugin |
| 112 | // ManagementAPI declares plugin-owned diagnostic Management API routes. | 112 | // ManagementAPI declares plugin-owned diagnostic Management API and resource routes. |
| 113 | ManagementAPI ManagementAPI | 113 | ManagementAPI ManagementAPI |
| 114 | } | 114 | } |
| 115 | 115 | ||
| @@ -921,7 +921,7 @@ type CommandLineExecutionResponse struct { · 805 unmodified lines | |||
| 921 | ExitCode int | 921 | ExitCode int |
| 922 | } | 922 | } |
| 923 | 923 | ||
| 924 | // ManagementAPI declares plugin-owned Management API routes. | 924 | // ManagementAPI declares plugin-owned Management API and resource routes. |
| 925 | type ManagementAPI interface { | 925 | type ManagementAPI interface { |
| 926 | RegisterManagement(context.Context, ManagementRegistrationRequest) (ManagementRegistrationResponse, error) | 926 | RegisterManagement(context.Context, ManagementRegistrationRequest) (ManagementRegistrationResponse, error) |
| 927 | } | 927 | } |
| @@ -932,12 +932,16 @@ type ManagementRegistrationRequest struct { · 4 unmodified lines | |||
| 932 | Plugin Metadata | 932 | Plugin Metadata |
| 933 | // BasePath is the only Management API prefix plugins may register under. | 933 | // BasePath is the only Management API prefix plugins may register under. |
| 934 | BasePath string | 934 | BasePath string |
| 935 | // ResourceBasePath is the plugin resource prefix for browser-navigable resources. | ||
| 936 | ResourceBasePath string | ||
| 935 | } | 937 | } |
| 936 | 938 | ||
| 937 | // ManagementRegistrationResponse lists plugin-owned Management API routes. | 939 | // ManagementRegistrationResponse lists plugin-owned Management API and resource routes. |
| 938 | type ManagementRegistrationResponse struct { | 940 | type ManagementRegistrationResponse struct { |
| 939 | // Routes contains the exact Management API routes to expose. | 941 | // Routes contains the exact Management API routes to expose. |
| 940 | Routes []ManagementRoute | 942 | Routes []ManagementRoute |
| 943 | // Resources contains browser-navigable plugin resources exposed under /v0/resource/plugins/<pluginID>/. | ||
| 944 | Resources []ResourceRoute | ||
| 941 | } | 945 | } |
| 942 | 946 | ||
| 943 | // ManagementRoute describes one plugin-owned Management API route. | 947 | // ManagementRoute describes one plugin-owned Management API route. |
| @@ -946,15 +950,27 @@ type ManagementRoute struct { · 2 unmodified lines | |||
| 946 | Method string | 950 | Method string |
| 947 | // Path is an exact path under /v0/management/. Relative paths are resolved under that prefix. | 951 | // Path is an exact path under /v0/management/. Relative paths are resolved under that prefix. |
| 948 | Path string | 952 | Path string |
| 949 | // Menu is the optional management UI menu label for GET routes. | 953 | // Menu is a legacy resource menu label. GET routes with Menu are registered under /v0/resource/plugins/<pluginID>/. |
| 950 | Menu string | 954 | Menu string |
| 951 | // Description explains the management route for UI display. | 955 | // Description explains the legacy resource menu entry for UI display. |
| 952 | Description string | 956 | Description string |
| 953 | // Handler processes matching Management API requests. | 957 | // Handler processes matching Management API requests. |
| 954 | Handler ManagementHandler | 958 | Handler ManagementHandler |
| 955 | } | 959 | } |
| 956 | 960 | ||
| 957 | // ManagementHandler handles one plugin-owned Management API route. | 961 | // ResourceRoute describes one plugin-owned browser-navigable resource route. |
| 962 | type ResourceRoute struct { | ||
| 963 | // Path is an exact path under /v0/resource/plugins/<pluginID>/. Relative paths are resolved under that prefix. | ||
| 964 | Path string | ||
| 965 | // Menu is the management UI menu label for this GET resource. | ||
| 966 | Menu string | ||
| 967 | // Description explains the resource route for UI display. | ||
| 968 | Description string | ||
| 969 | // Handler processes matching resource requests. Resource requests are not management-authenticated. | ||
| 970 | Handler ManagementHandler | ||
| 971 | } | ||
| 972 | |||
| 973 | // ManagementHandler handles one plugin-owned Management API or resource route. | ||
| 958 | type ManagementHandler interface { | 974 | type ManagementHandler interface { |
| 959 | HandleManagement(context.Context, ManagementRequest) (ManagementResponse, error) | 975 | HandleManagement(context.Context, ManagementRequest) (ManagementResponse, error) |
| 960 | } | 976 | } |
| @@ -48,16 +48,15 @@ func TestMetadataConfigFieldsExposePluginSchema(t *testing.T) { · 47 unmodified lines | |||
| 48 | } | 48 | } |
| 49 | } | 49 | } |
| 50 | 50 | ||
| 51 | func TestManagementRouteMenuFieldsExposeManagementUIHints(t *testing.T) { | 51 | func TestResourceRouteMenuFieldsExposeManagementUIHints(t *testing.T) { |
| 52 | route := ManagementRoute{ | 52 | route := ResourceRoute{ |
| 53 | Method: "GET", | 53 | Path: "/status", |
| 54 | Path: "/plugins/example/status", | ||
| 55 | Menu: "Example Status", | 54 | Menu: "Example Status", |
| 56 | Description: "Shows example plugin status.", | 55 | Description: "Shows example plugin status.", |
| 57 | Handler: compileTimePlugin{}, | 56 | Handler: compileTimePlugin{}, |
| 58 | } | 57 | } |
| 59 | if route.Menu == "" || route.Description == "" { | 58 | if route.Menu == "" || route.Description == "" { |
| 60 | t.Fatalf("management route missing menu fields: %#v", route) | 59 | t.Fatalf("resource route missing menu fields: %#v", route) |
| 61 | } | 60 | } |
| 62 | } | 61 | } |
| 63 | 62 | ||