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