Skip to content

Latest commit

 

History

History
211 lines (163 loc) · 6.04 KB

File metadata and controls

211 lines (163 loc) · 6.04 KB

Impl-First Design

Server-less takes an impl-first approach: write your Rust methods, derive macros project them into protocols.

impl MyService {
    /// Create a new user
    fn create_user(&self, name: String, email: String) -> Result<User, UserError> { ... }

    /// Get user by ID
    fn get_user(&self, id: UserId) -> Option<User> { ... }

    /// List all users
    fn list_users(&self, limit: u32, offset: u32) -> Vec<User> { ... }

    /// Watch for new users
    fn watch_users(&self) -> impl Stream<Item=User> { ... }
}

Each derive macro projects this into a protocol:

Derive Projection
Http REST endpoints
Grpc Protobuf service
GraphQL Schema + resolvers
Cli Command-line interface
Mcp MCP tools for LLMs
Client (planned) Type-safe client SDK
OpenApi Spec from types + docs

Naming Conventions

HTTP Verb Inference

Method prefix HTTP verb Path pattern
create_*, add_* POST /resources
get_*, fetch_* GET /resources/{id}
list_*, find_*, search_* GET /resources
update_*, set_* PUT /resources/{id}
delete_*, remove_* DELETE /resources/{id}
anything else POST /rpc/{method_name}

Parameter Conventions

fn get_user(id: UserId) -> User
//          ^^^ path param: ends with "Id" or named "id"

fn list_users(limit: u32, offset: u32) -> Vec<User>
//            ^^^ query params: GET + primitive types

fn create_user(name: String, email: String) -> User
//             ^^^ body: POST/PUT + non-id params

CLI Conventions

fn create_user(name: String, email: String) -> User
// → myapp create-user --name "..." --email "..."

fn get_user(id: UserId) -> Option<User>
// → myapp get-user <ID>
//                  ^^^ positional: single id-like arg

MCP Conventions

/// Search for users by name
fn search_users(&self, query: String, limit: Option<u32>) -> Vec<User>

Becomes:

{
  "name": "search_users",
  "description": "Search for users by name",
  "inputSchema": {
    "type": "object",
    "properties": {
      "query": { "type": "string" },
      "limit": { "type": "integer" }
    },
    "required": ["query"]
  }
}
  • Method name → tool name
  • Doc comment → tool description
  • Parameters → input schema
  • Option<T> → optional parameter
  • Return type → tool result

Return Types

The return type is the API contract:

Type Meaning HTTP CLI
T Success with data 200 + body stdout
Option<T> Maybe not found 200 or 404 stdout or exit 1
Result<T, E> Success or typed error 200 or error from E stdout or stderr
() Success, no data 204 No Content silent
Vec<T> Collection (collected) 200 + JSON array JSON array
impl Iterator<Item=T> Lazy sequence SSE (via stream::iter) newline-delimited (streamed)
impl Stream<Item=T> Async streaming SSE/WebSocket newline-delimited

Streaming

impl Stream<Item=T> means streaming. For non-streaming protocols:

#[http(collect)]  // explicitly opt-in to collecting
fn watch_events(&self) -> impl Stream<Item=Event>

Without #[http(collect)], using Http derive on a streaming method is a compile error. This avoids hidden memory bombs.

Error Handling

Errors are typed via Result<T, E>:

enum UserError {
    NotFound,       // → 404 (convention: contains "NotFound")
    InvalidEmail,   // → 400 (convention: "Invalid*")
    Forbidden,      // → 403 (exact match)
    AlreadyExists,  // → 409 (convention: "AlreadyExists", "*Conflict")
}

Or explicit mapping:

#[derive(Error)]
enum UserError {
    #[error(http = 404, grpc = "NOT_FOUND")]
    NotFound,
}

Each protocol derive maps errors appropriately:

  • HTTP → status code + JSON body
  • gRPC → status code + details
  • CLI → stderr + exit code
  • GraphQL → errors array
  • MCP → error response

Overrides

Conventions work 80% of the time. Override when needed:

#[route(path = "/api/v1/users", method = "POST")]
fn register(&self, ...) -> User  // wouldn't infer POST from "register"

#[cli(name = "add")]
fn create_user(&self, ...) -> User  // override CLI subcommand name

#[mcp(name = "find_users", description = "Search the user database")]
fn search_users(&self, ...) -> Vec<User>  // override MCP tool metadata

Protocol-Specific Context

Some protocols need context (headers, metadata, etc.):

fn create_user(
    &self,
    ctx: Context,  // injected: HTTP headers, gRPC metadata, CLI env, etc.
    name: String,
    email: String,
) -> Result<User, UserError>

Context is protocol-agnostic. Each derive provides the relevant data:

  • HTTP: headers, cookies, query params
  • gRPC: metadata
  • CLI: env vars, config files
  • MCP: conversation context (if available)

CLI Async Runtime

cli_run() is the batteries-included sync entrypoint. When async methods are present, it internally creates a Tokio runtime to drive them. Tokio is the chosen battery — this is a deliberate default, not an accident.

cli_run_async() is the runtime-agnostic escape hatch: it awaits the dispatched method directly without importing any runtime. Users who prefer async-std, smol, or want to control runtime configuration use this:

#[tokio::main]         // or async_std::main, smol::block_on, etc.
async fn main() {
    app.cli_run_async().await.unwrap();
}

Why not make the runtime configurable? A #[cli(runtime = "async-std")] attribute would enumerate runtimes in the macro and generate different block_on call sites per choice — complexity with no real payoff. cli_run_async() already gives full control with zero macro complexity. Users who don't want Tokio use cli_run_async(); users who want to drop the entrypoints entirely use #[cli(no_sync)] / #[cli(no_async)].

Settled: tokio as default battery, cli_run_async() as the escape hatch. Do not revisit by adding a runtime attribute.