Write the Endpoint Plugin
Create one linked Rust Plugin with typed JSON routes and prove its behavior before opening a socket.
This step produces company.greetings-http, a Plugin that provides two
lenso.http.endpoint@1 routes. Complete it before touching Ingress.
1. Generate the Plugin
Start from the CLI’s Web authoring path:
lenso plugin new company.greetings-http --web
cd company.greetings-http
The command creates a standalone linked Rust Plugin:
company.greetings-http/
├── Cargo.toml
├── README.md
└── src/
└── lib.rs
Its manifest already declares the Web root slot and pins compatible framework revisions:
[package.metadata.lenso]
plugin-id = "company.greetings-http"
root-slot = "web"
By default, generation also creates Cargo.lock and runs the socket-free test.
Use --no-install only when dependency resolution must happen later. When you
move the crate into a Host workspace, replace the pinned dependencies with that
workspace’s dependency entries and remove the scaffold’s standalone
[workspace] table.
2. Implement the routes
Create the Endpoint provider:
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> {
// Replace this with the business Capability call in a real Plugin.
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] declares the product behavior boundary. #[endpoint]
generates the route description, dispatch path, lenso.http.endpoint@1
Capability, and linked Plugin factory from the same declarations. Product code
does not implement NativePluginFactory, and must not use the retired
NativeModuleFactory API. A handler returns business-facing values:
Json<T> means a 200 JSON response, (StatusCode, T) overrides its status,
and Problem is an intentional client-visible failure. The macro lowers these
values into the portable Capability contract.
Json<T> also rejects malformed JSON and unsupported content types before the
handler runs. Path<T> decodes named path parameters, while QueryParams<T>
decodes URL query strings. Use #[query("route.id", "/path")] for the HTTP
QUERY method—a safe, idempotent request that may carry a structured body. It is
not the same thing as URL query parameters.
The map is temporary Instance state. Move shared business facts into a business Capability provider when another interface also needs Greeting behavior. Put durable state in a selected Store Plugin, not in Ingress.
3. Prove the provider directly
Use the socket-free harness in the same crate:
#[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 reads method and path from the generated route table. It invokes
the Plugin directly, so it tests extractors, middleware, typed responses, and
dispatch without binding a port. Its request builder also supports .query(),
.path_parameter(), and .header().
Add focused tests against the generated EndpointProvider:
describereturns exactlygreetings.createandgreetings.search;- valid JSON returns
201; - empty
namereturns an intentional400Problem Details response; - malformed JSON and unsupported content types fail before
createruns; and greetings.searchuses the HTTP QUERY method and accepts a JSON body.
Run the crate’s checks:
cargo fmt --all -- --check
cargo test --locked
cargo clippy --locked --all-targets -- -D warnings
The owner repository includes the complete copyable scaffold at
examples/greetings-http-plugin. It also maintains extractor and response
examples:
cargo test --locked -p lenso-capability-http-endpoint \
--test endpoint_attributes
This step is complete when route descriptions are stable and every direct success and failure assertion passes. Continue with Connect the Host and Ingress.