Skip to content
Lenso
English
Esc
navigateopen⌘Jpreview
On this page

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:

  • describe returns exactly greetings.create and greetings.search;
  • valid JSON returns 201;
  • empty name returns an intentional 400 Problem Details response;
  • malformed JSON and unsupported content types fail before create runs; and
  • greetings.search uses 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.

Last updated on September 6, 2026

Was this page helpful?