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