Skip to main content

server_less_core/
lib.rs

1//! Core traits and types for server-less.
2//!
3//! This crate provides the foundational types that server-less macros generate code against.
4
5pub mod error;
6pub mod extract;
7
8pub use error::{ErrorCode, ErrorResponse, IntoErrorCode, SchemaValidationError};
9pub use extract::Context;
10
11#[cfg(feature = "ws")]
12pub use extract::WsSender;
13
14/// Trait for types that can be mounted as CLI subcommand groups.
15///
16/// Implemented automatically by `#[cli]` on an impl block. Allows nested
17/// composition: a parent CLI can mount a child's commands as a subcommand group.
18#[cfg(feature = "cli")]
19pub trait CliSubcommand {
20    /// Build the clap Command tree for this type's subcommands.
21    fn cli_command() -> ::clap::Command;
22
23    /// Dispatch a matched subcommand to the appropriate method.
24    fn cli_dispatch(&self, matches: &::clap::ArgMatches) -> Result<(), Box<dyn std::error::Error>>;
25}
26
27/// Trait for types that can be mounted as MCP tool namespaces.
28///
29/// Implemented automatically by `#[mcp]` on an impl block. Allows nested
30/// composition: a parent MCP server can mount a child's tools with a name prefix.
31#[cfg(feature = "mcp")]
32pub trait McpNamespace {
33    /// Get tool definitions for this namespace.
34    fn mcp_namespace_tools() -> Vec<serde_json::Value>;
35
36    /// Get tool names for this namespace (without prefix).
37    fn mcp_namespace_tool_names() -> Vec<String>;
38
39    /// Call a tool by name (sync). Returns error for async-only methods.
40    fn mcp_namespace_call(
41        &self,
42        name: &str,
43        args: serde_json::Value,
44    ) -> Result<serde_json::Value, String>;
45
46    /// Call a tool by name (async).
47    fn mcp_namespace_call_async(
48        &self,
49        name: &str,
50        args: serde_json::Value,
51    ) -> impl std::future::Future<Output = Result<serde_json::Value, String>> + Send;
52}
53
54/// Trait for types that can be mounted as JSON-RPC method namespaces.
55///
56/// Implemented automatically by `#[jsonrpc]` on an impl block. Allows nested
57/// composition: a parent JSON-RPC server can mount a child's methods with a dot-separated prefix.
58#[cfg(feature = "jsonrpc")]
59pub trait JsonRpcMount {
60    /// Get method names for this mount (without prefix).
61    fn jsonrpc_mount_methods() -> Vec<String>;
62
63    /// Dispatch a method call (async).
64    fn jsonrpc_mount_dispatch(
65        &self,
66        method: &str,
67        params: serde_json::Value,
68    ) -> impl std::future::Future<Output = Result<serde_json::Value, String>> + Send;
69}
70
71/// Trait for types that can be mounted as WebSocket method namespaces.
72///
73/// Implemented automatically by `#[ws]` on an impl block. Allows nested
74/// composition: a parent WebSocket server can mount a child's methods with a dot-separated prefix.
75#[cfg(feature = "ws")]
76pub trait WsMount {
77    /// Get method names for this mount (without prefix).
78    fn ws_mount_methods() -> Vec<String>;
79
80    /// Dispatch a method call (sync). Returns error for async-only methods.
81    fn ws_mount_dispatch(
82        &self,
83        method: &str,
84        params: serde_json::Value,
85    ) -> Result<serde_json::Value, String>;
86
87    /// Dispatch a method call (async).
88    fn ws_mount_dispatch_async(
89        &self,
90        method: &str,
91        params: serde_json::Value,
92    ) -> impl std::future::Future<Output = Result<serde_json::Value, String>> + Send;
93}
94
95/// Trait for types that can be mounted as HTTP route groups.
96///
97/// Implemented automatically by `#[http]` on an impl block. Allows nested
98/// composition: a parent HTTP server can mount a child's routes under a path prefix.
99#[cfg(feature = "http")]
100pub trait HttpMount: Send + Sync + 'static {
101    /// Build an axum Router for this mount's routes.
102    fn http_mount_router(self: ::std::sync::Arc<Self>) -> ::axum::Router;
103
104    /// Get OpenAPI path definitions for this mount.
105    fn http_mount_openapi_paths() -> Vec<crate::HttpMountPathInfo>
106    where
107        Self: Sized;
108}
109
110/// Simplified path info for HttpMount composition.
111#[cfg(feature = "http")]
112#[derive(Debug, Clone)]
113pub struct HttpMountPathInfo {
114    /// The path (relative to the mount point).
115    pub path: String,
116    /// The HTTP method (get, post, etc.).
117    pub method: String,
118    /// Summary text.
119    pub summary: Option<String>,
120}
121
122/// Format CLI output according to output flags.
123///
124/// - `jsonl`: one JSON object per line for array values
125/// - `json`: machine-readable JSON (no whitespace)
126/// - `jq`: filter using jaq (jq implemented in Rust, no external binary needed)
127/// - Default: pretty-printed JSON
128#[cfg(feature = "cli")]
129pub fn cli_format_output(
130    value: serde_json::Value,
131    jsonl: bool,
132    json: bool,
133    jq: Option<&str>,
134) -> Result<String, Box<dyn std::error::Error>> {
135    if let Some(filter) = jq {
136        use jaq_core::load::{Arena, File as JaqFile, Loader};
137        use jaq_core::{Compiler, Ctx, Vars, data, unwrap_valr};
138        use jaq_json::Val;
139
140        let loader = Loader::new(jaq_std::defs().chain(jaq_json::defs()));
141        let arena = Arena::default();
142
143        let program = JaqFile {
144            code: filter,
145            path: (),
146        };
147
148        let modules = loader
149            .load(&arena, program)
150            .map_err(|errs| format!("jq parse error: {:?}", errs))?;
151
152        let filter_compiled = Compiler::default()
153            .with_funs(jaq_std::funs().chain(jaq_json::funs()))
154            .compile(modules)
155            .map_err(|errs| format!("jq compile error: {:?}", errs))?;
156
157        let val: Val = serde_json::from_value(value)?;
158        let ctx = Ctx::<data::JustLut<Val>>::new(&filter_compiled.lut, Vars::new([]));
159        let out = filter_compiled.id.run((ctx, val)).map(unwrap_valr);
160
161        let mut results = Vec::new();
162        for result in out {
163            match result {
164                Ok(v) => results.push(v.to_string()),
165                Err(e) => return Err(format!("jq runtime error: {:?}", e).into()),
166            }
167        }
168
169        Ok(results.join("\n"))
170    } else if jsonl {
171        match value {
172            serde_json::Value::Array(items) => {
173                let lines: Vec<String> = items
174                    .iter()
175                    .map(serde_json::to_string)
176                    .collect::<Result<_, _>>()?;
177                Ok(lines.join("\n"))
178            }
179            other => Ok(serde_json::to_string(&other)?),
180        }
181    } else if json {
182        Ok(serde_json::to_string(&value)?)
183    } else {
184        Ok(serde_json::to_string_pretty(&value)?)
185    }
186}
187
188/// Generate a JSON Schema for a type at runtime using schemars.
189///
190/// Called by `--output-schema` in `#[cli]`-generated code when the `jsonschema`
191/// feature is enabled. Users must `#[derive(schemars::JsonSchema)]` on their
192/// return types to use this.
193#[cfg(feature = "jsonschema")]
194pub fn cli_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
195    serde_json::to_value(schemars::schema_for!(T))
196        .unwrap_or_else(|_| serde_json::json!({"type": "object"}))
197}
198
199/// A clap [`TypedValueParser`] that uses [`schemars::JsonSchema`] to surface
200/// enum variants as possible values, and [`std::str::FromStr`] for actual parsing.
201///
202/// When `T` is an enum deriving `JsonSchema`, its variants appear in `--help`
203/// output and clap's error messages with no extra derives on the user type.
204/// For non-enum types (e.g. `String`, `u32`), this is a transparent pass-through
205/// to `FromStr`.
206///
207/// Used automatically by `#[cli]`-generated code when both the `cli` and
208/// `jsonschema` features are enabled.
209#[cfg(all(feature = "cli", feature = "jsonschema"))]
210#[derive(Clone)]
211pub struct SchemaValueParser<T: Clone + Send + Sync + 'static> {
212    /// Enum variant names as `'static` str. We leak each string once at
213    /// parser-construction time (command build, not per-parse), which is
214    /// acceptable for a CLI binary: the leak is bounded (a few bytes per
215    /// variant) and the memory is reclaimed when the process exits.
216    variants: Option<std::sync::Arc<[&'static str]>>,
217    _marker: std::marker::PhantomData<T>,
218}
219
220#[cfg(all(feature = "cli", feature = "jsonschema"))]
221impl<T> Default for SchemaValueParser<T>
222where
223    T: schemars::JsonSchema + std::str::FromStr + Clone + Send + Sync + 'static,
224{
225    fn default() -> Self {
226        Self::new()
227    }
228}
229
230#[cfg(all(feature = "cli", feature = "jsonschema"))]
231impl<T> SchemaValueParser<T>
232where
233    T: schemars::JsonSchema + std::str::FromStr + Clone + Send + Sync + 'static,
234{
235    pub fn new() -> Self {
236        let variants = extract_enum_variants::<T>().map(|strings| {
237            let leaked: Vec<&'static str> = strings
238                .into_iter()
239                .map(|s| Box::leak(s.into_boxed_str()) as &'static str)
240                .collect();
241            leaked.into()
242        });
243        Self {
244            variants,
245            _marker: std::marker::PhantomData,
246        }
247    }
248}
249
250#[cfg(all(feature = "cli", feature = "jsonschema"))]
251fn extract_enum_variants<T: schemars::JsonSchema>() -> Option<Vec<String>> {
252    let schema_value = serde_json::to_value(schemars::schema_for!(T)).ok()?;
253    let enum_values = schema_value.get("enum")?.as_array()?;
254    let variants: Vec<String> = enum_values
255        .iter()
256        .filter_map(|v| v.as_str().map(String::from))
257        .collect();
258    if variants.is_empty() {
259        None
260    } else {
261        Some(variants)
262    }
263}
264
265#[cfg(all(feature = "cli", feature = "jsonschema"))]
266impl<T> ::clap::builder::TypedValueParser for SchemaValueParser<T>
267where
268    T: schemars::JsonSchema + std::str::FromStr + Clone + Send + Sync + 'static,
269{
270    type Value = T;
271
272    fn parse_ref(
273        &self,
274        _cmd: &::clap::Command,
275        _arg: Option<&::clap::Arg>,
276        value: &std::ffi::OsStr,
277    ) -> Result<T, ::clap::Error> {
278        let s = value
279            .to_str()
280            .ok_or_else(|| ::clap::Error::new(::clap::error::ErrorKind::InvalidUtf8))?;
281        s.parse::<T>()
282            .map_err(|_| ::clap::Error::new(::clap::error::ErrorKind::InvalidValue))
283    }
284
285    fn possible_values(
286        &self,
287    ) -> Option<Box<dyn Iterator<Item = ::clap::builder::PossibleValue> + '_>> {
288        let variants = self.variants.as_ref()?;
289        Some(Box::new(
290            variants
291                .iter()
292                .copied()
293                .map(::clap::builder::PossibleValue::new),
294        ))
295    }
296}
297
298/// Runtime method metadata with string-based types.
299///
300/// This is a simplified, serialization-friendly representation of method
301/// information intended for runtime introspection and tooling. Types are
302/// stored as strings rather than `syn` AST nodes.
303///
304/// **Not to be confused with [`server_less_parse::MethodInfo`]**, which is
305/// the richer, `syn`-based representation used internally by proc macros
306/// during code generation. The parse version retains full type information
307/// (`syn::Type`, `syn::Ident`) and supports `#[param(...)]` attributes.
308#[derive(Debug, Clone)]
309pub struct MethodInfo {
310    /// Method name (e.g., "create_user")
311    pub name: String,
312    /// Documentation string from /// comments
313    pub docs: Option<String>,
314    /// Parameter names and their type strings
315    pub params: Vec<ParamInfo>,
316    /// Return type string
317    pub return_type: String,
318    /// Whether the method is async
319    pub is_async: bool,
320    /// Whether the return type is a Stream
321    pub is_streaming: bool,
322    /// Whether the return type is `Option<T>`
323    pub is_optional: bool,
324    /// Whether the return type is `Result<T, E>`
325    pub is_result: bool,
326    /// Group display name for categorization
327    pub group: Option<String>,
328}
329
330/// Runtime parameter metadata with string-based types.
331///
332/// See [`MethodInfo`] for the relationship between this type and
333/// `server_less_parse::ParamInfo`.
334#[derive(Debug, Clone)]
335pub struct ParamInfo {
336    /// Parameter name
337    pub name: String,
338    /// Type as string
339    pub ty: String,
340    /// Whether this is an `Option<T>`
341    pub is_optional: bool,
342    /// Whether this looks like an ID parameter (ends with _id or is named id)
343    pub is_id: bool,
344}
345
346/// HTTP method inferred from function name
347#[derive(Debug, Clone, Copy, PartialEq, Eq)]
348pub enum HttpMethod {
349    Get,
350    Post,
351    Put,
352    Patch,
353    Delete,
354}
355
356impl HttpMethod {
357    /// Infer HTTP method from function name prefix
358    pub fn infer_from_name(name: &str) -> Self {
359        if name.starts_with("get_")
360            || name.starts_with("fetch_")
361            || name.starts_with("read_")
362            || name.starts_with("list_")
363            || name.starts_with("find_")
364            || name.starts_with("search_")
365        {
366            HttpMethod::Get
367        } else if name.starts_with("create_")
368            || name.starts_with("add_")
369            || name.starts_with("new_")
370        {
371            HttpMethod::Post
372        } else if name.starts_with("update_") || name.starts_with("set_") {
373            HttpMethod::Put
374        } else if name.starts_with("patch_") || name.starts_with("modify_") {
375            HttpMethod::Patch
376        } else if name.starts_with("delete_") || name.starts_with("remove_") {
377            HttpMethod::Delete
378        } else {
379            // Default to POST for RPC-style methods
380            HttpMethod::Post
381        }
382    }
383
384    pub fn as_str(&self) -> &'static str {
385        match self {
386            HttpMethod::Get => "GET",
387            HttpMethod::Post => "POST",
388            HttpMethod::Put => "PUT",
389            HttpMethod::Patch => "PATCH",
390            HttpMethod::Delete => "DELETE",
391        }
392    }
393}
394
395/// Infer URL path from method name
396pub fn infer_path(method_name: &str, http_method: HttpMethod) -> String {
397    // Strip common prefixes to get the resource name
398    let resource = method_name
399        .strip_prefix("get_")
400        .or_else(|| method_name.strip_prefix("fetch_"))
401        .or_else(|| method_name.strip_prefix("read_"))
402        .or_else(|| method_name.strip_prefix("list_"))
403        .or_else(|| method_name.strip_prefix("find_"))
404        .or_else(|| method_name.strip_prefix("search_"))
405        .or_else(|| method_name.strip_prefix("create_"))
406        .or_else(|| method_name.strip_prefix("add_"))
407        .or_else(|| method_name.strip_prefix("new_"))
408        .or_else(|| method_name.strip_prefix("update_"))
409        .or_else(|| method_name.strip_prefix("set_"))
410        .or_else(|| method_name.strip_prefix("patch_"))
411        .or_else(|| method_name.strip_prefix("modify_"))
412        .or_else(|| method_name.strip_prefix("delete_"))
413        .or_else(|| method_name.strip_prefix("remove_"))
414        .unwrap_or(method_name);
415
416    // Pluralize for collection endpoints
417    let path_resource = if resource.ends_with('s') {
418        resource.to_string()
419    } else {
420        format!("{resource}s")
421    };
422
423    match http_method {
424        // Collection operations
425        HttpMethod::Post => format!("/{path_resource}"),
426        HttpMethod::Get
427            if method_name.starts_with("list_")
428                || method_name.starts_with("search_")
429                || method_name.starts_with("find_") =>
430        {
431            format!("/{path_resource}")
432        }
433        // Single resource operations
434        HttpMethod::Get | HttpMethod::Put | HttpMethod::Patch | HttpMethod::Delete => {
435            format!("/{path_resource}/{{id}}")
436        }
437    }
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443
444    #[test]
445    fn test_http_method_inference() {
446        assert_eq!(HttpMethod::infer_from_name("get_user"), HttpMethod::Get);
447        assert_eq!(HttpMethod::infer_from_name("list_users"), HttpMethod::Get);
448        assert_eq!(HttpMethod::infer_from_name("create_user"), HttpMethod::Post);
449        assert_eq!(HttpMethod::infer_from_name("update_user"), HttpMethod::Put);
450        assert_eq!(
451            HttpMethod::infer_from_name("delete_user"),
452            HttpMethod::Delete
453        );
454        assert_eq!(
455            HttpMethod::infer_from_name("do_something"),
456            HttpMethod::Post
457        ); // RPC fallback
458    }
459
460    #[test]
461    fn test_path_inference() {
462        assert_eq!(infer_path("create_user", HttpMethod::Post), "/users");
463        assert_eq!(infer_path("get_user", HttpMethod::Get), "/users/{id}");
464        assert_eq!(infer_path("list_users", HttpMethod::Get), "/users");
465        assert_eq!(infer_path("delete_user", HttpMethod::Delete), "/users/{id}");
466    }
467}