---
title: 编写 Endpoint Plugin
description: 创建包含强类型 JSON Route 的 Linked Rust Plugin，并在打开 Socket 前证明其行为。
---

本步骤生成 `company.greetings-http`，它提供两条
`lenso.http.endpoint@1` Route。完成本步骤后再连接 Ingress。

## 1. 生成 Plugin

从 CLI 的 Web Authoring Path 开始：

```sh
lenso plugin new company.greetings-http --web
cd company.greetings-http
```

该命令会创建一个独立的 Linked Rust Plugin：

```text
company.greetings-http/
├── Cargo.toml
├── README.md
└── src/
    └── lib.rs
```

Manifest 已经声明 Web Root Slot，并固定相互兼容的 Framework Revision：

```toml title="Cargo.toml"
[package.metadata.lenso]
plugin-id = "company.greetings-http"
root-slot = "web"
```

默认情况下，生成流程还会创建 `Cargo.lock` 并运行无 Socket 测试。仅当依赖必须
稍后解析时才使用 `--no-install`。把 Crate 移入 Host Workspace 时，改用该
Workspace 的 Dependency Entry，并删除脚手架中用于隔离独立项目的
`[workspace]` Table。

## 2. 实现 Route

创建 Endpoint Provider：

```rust title="src/lib.rs"
use std::{cell::{Cell, RefCell}, collections::BTreeMap, rc::Rc};

use lenso_capability_http_endpoint::{
    prelude::*,
    response::{Problem, StatusCode},
};
use serde::{Deserialize, Serialize};

#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct CreateGreeting {
    name: String,
}

#[derive(Debug, Deserialize, Serialize)]
struct SearchGreetings {
    term: String,
}

#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
struct Greeting {
    id: String,
    message: String,
}

#[lenso::plugin]
#[derive(Clone, Debug, Default)]
pub struct GreetingsHttp {
    next_id: Rc<Cell<u64>>,
    greetings: Rc<RefCell<BTreeMap<String, Greeting>>>,
}

#[endpoint]
impl GreetingsHttp {
    #[post("greetings.create", "/greetings")]
    async fn create(
        &self,
        Json(input): Json<CreateGreeting>,
    ) -> Result<(StatusCode, Json<Greeting>), Problem> {
        // 真实 Plugin 在这里调用并等待业务 Capability。
        std::future::ready(()).await;
        let name = input.name.trim();
        if name.is_empty() {
            return Err(Problem::new(
                StatusCode::BAD_REQUEST,
                "invalid_name",
                "name must not be empty",
            ));
        }

        let sequence = self.next_id.get() + 1;
        self.next_id.set(sequence);
        let greeting = Greeting {
            id: format!("greeting-{sequence}"),
            message: format!("Hello, {name}!"),
        };
        self.greetings
            .borrow_mut()
            .insert(greeting.id.clone(), greeting.clone());
        Ok((StatusCode::CREATED, Json(greeting)))
    }

    #[query("greetings.search", "/greetings/search")]
    async fn search(
        &self,
        Json(input): Json<SearchGreetings>,
    ) -> Result<Json<Vec<Greeting>>, Problem> {
        std::future::ready(()).await;
        let greetings = self.greetings
            .borrow()
            .values()
            .filter(|greeting| greeting.message.contains(&input.term))
            .cloned()
            .collect();
        Ok(Json(greetings))
    }
}
```

`#[lenso::plugin]` 声明产品行为边界；`#[endpoint]` 从同一组声明生成 Route
Description、Dispatch Path、`lenso.http.endpoint@1` Capability 与 Linked Plugin
Factory。业务作者不需要实现 `NativePluginFactory`，更不应再编写
`NativeModuleFactory`。Handler 直接返回面向业务的值：`Json<T>` 表示 `200`
JSON Response，`(StatusCode, T)` 覆盖状态码，`Problem` 表示有意返回给 Client
的失败；Macro 会把它们降为 Portable Capability Contract。

`Json<T>` 也会在 Handler 运行前拒绝无效 JSON 与不支持的 Content Type，
`Path<T>` 解码命名 Path Parameter，`QueryParams<T>` 解码 URL Query String。
HTTP QUERY Method 使用 `#[query("route.id", "/path")]`：它适合安全、幂等、
但需要结构化 Body 的请求，和 URL Query Parameter 不是一回事。

示例中的 Map 是临时 Instance State。其他 Interface 也需要 Greeting 行为时，
把共享业务 Fact 移到业务 Capability Provider。持久状态属于 Store Plugin，
不属于 Ingress。

## 3. 直接证明 Provider

在同一个 Crate 中使用无 Socket Test Harness：

```rust title="src/lib.rs"
#[cfg(test)]
mod tests {
    use futures::executor::block_on;
    use lenso_capability_http_endpoint::testing::EndpointTest;

    use super::*;

    #[test]
    fn creates_and_queries_a_greeting() {
        block_on(async {
            let endpoint = EndpointTest::new(GreetingsHttp::default());
            let created = endpoint
                .request("greetings.create")
                .json(&CreateGreeting { name: "Lenso".to_owned() })
                .unwrap()
                .send().await.unwrap();
            assert_eq!(created.status(), StatusCode::CREATED);
            let found = endpoint
                .request("greetings.search")
                .json(&SearchGreetings { term: "Lenso".to_owned() })
                .unwrap()
                .send().await.unwrap();
            assert_eq!(found.json::<Vec<Greeting>>().unwrap().len(), 1);
        });
    }
}
```

`EndpointTest` 从生成的 Route Table 读取 Method 与 Path，直接调用 Plugin，
因此无需绑定端口即可测试 Extractor、Middleware、Typed Response 与 Dispatch。
Request Builder 还支持 `.query()`、`.path_parameter()` 与 `.header()`。

针对生成的 `EndpointProvider` 增加聚焦测试：

- `describe` 只返回 `greetings.create` 和 `greetings.search`；
- 有效 JSON 返回 `201`；
- 空 `name` 返回有意设计的 `400` Problem Details；
- 无效 JSON 与不支持的 Content Type 在 `create` 前失败；
- `greetings.search` 使用 HTTP QUERY Method 并接收 JSON Body。

运行 Crate 检查：

```sh
cargo fmt --all -- --check
cargo test --locked
cargo clippy --locked --all-targets -- -D warnings
```

Owner Repository 在 `examples/greetings-http-plugin` 提供了完整、可复制的
Scaffold，同时维护了 Extractor 与 Response 示例：

```sh
cargo test --locked -p lenso-capability-http-endpoint \
  --test endpoint_attributes
```

Route Description 稳定，并且所有直接成功与失败断言通过时，本步骤完成。继续
[连接 Host 与 Ingress](/docs/zh/web/web-host-integration)。
