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    variants: Option<std::sync::Arc<[&'static str]>>,
246    _marker: std::marker::PhantomData<T>,
247}
248
249#[cfg(all(feature = "cli", feature = "jsonschema"))]
250impl<T> Default for SchemaValueParser<T>
251where
252    T: schemars::JsonSchema + std::str::FromStr + Clone + Send + Sync + 'static,
253{
254    fn default() -> Self {
255        Self::new()
256    }
257}
258
259#[cfg(all(feature = "cli", feature = "jsonschema"))]
260impl<T> SchemaValueParser<T>
261where
262    T: schemars::JsonSchema + std::str::FromStr + Clone + Send + Sync + 'static,
263{
264    pub fn new() -> Self {
265        let variants = extract_enum_variants::<T>().map(|strings| {
266            let leaked: Vec<&'static str> = strings
267                .into_iter()
268                .map(|s| Box::leak(s.into_boxed_str()) as &'static str)
269                .collect();
270            leaked.into()
271        });
272        Self {
273            variants,
274            _marker: std::marker::PhantomData,
275        }
276    }
277}
278
279#[cfg(all(feature = "cli", feature = "jsonschema"))]
280fn extract_enum_variants<T: schemars::JsonSchema>() -> Option<Vec<String>> {
281    let schema_value = serde_json::to_value(schemars::schema_for!(T)).ok()?;
282    let enum_values = schema_value.get("enum")?.as_array()?;
283    let variants: Vec<String> = enum_values
284        .iter()
285        .filter_map(|v| v.as_str().map(String::from))
286        .collect();
287    if variants.is_empty() {
288        None
289    } else {
290        Some(variants)
291    }
292}
293
294#[cfg(all(feature = "cli", feature = "jsonschema"))]
295impl<T> ::clap::builder::TypedValueParser for SchemaValueParser<T>
296where
297    T: schemars::JsonSchema + std::str::FromStr + Clone + Send + Sync + 'static,
298{
299    type Value = T;
300
301    fn parse_ref(
302        &self,
303        _cmd: &::clap::Command,
304        _arg: Option<&::clap::Arg>,
305        value: &std::ffi::OsStr,
306    ) -> Result<T, ::clap::Error> {
307        let s = value
308            .to_str()
309            .ok_or_else(|| ::clap::Error::new(::clap::error::ErrorKind::InvalidUtf8))?;
310        s.parse::<T>()
311            .map_err(|_| ::clap::Error::new(::clap::error::ErrorKind::InvalidValue))
312    }
313
314    fn possible_values(
315        &self,
316    ) -> Option<Box<dyn Iterator<Item = ::clap::builder::PossibleValue> + '_>> {
317        let variants = self.variants.as_ref()?;
318        Some(Box::new(
319            variants
320                .iter()
321                .copied()
322                .map(::clap::builder::PossibleValue::new),
323        ))
324    }
325}
326
327/// Runtime method metadata with string-based types.
328///
329/// This is a simplified, serialization-friendly representation of method
330/// information intended for runtime introspection and tooling. Types are
331/// stored as strings rather than `syn` AST nodes.
332///
333/// **Not to be confused with [`server_less_parse::MethodInfo`]**, which is
334/// the richer, `syn`-based representation used internally by proc macros
335/// during code generation. The parse version retains full type information
336/// (`syn::Type`, `syn::Ident`) and supports `#[param(...)]` attributes.
337#[derive(Debug, Clone)]
338pub struct MethodInfo {
339    /// Method name (e.g., "create_user")
340    pub name: String,
341    /// Documentation string from /// comments
342    pub docs: Option<String>,
343    /// Parameter names and their type strings
344    pub params: Vec<ParamInfo>,
345    /// Return type string
346    pub return_type: String,
347    /// Whether the method is async
348    pub is_async: bool,
349    /// Whether the return type is a Stream
350    pub is_streaming: bool,
351    /// Whether the return type is `Option<T>`
352    pub is_optional: bool,
353    /// Whether the return type is `Result<T, E>`
354    pub is_result: bool,
355    /// Group display name for categorization
356    pub group: Option<String>,
357}
358
359/// Runtime parameter metadata with string-based types.
360///
361/// See [`MethodInfo`] for the relationship between this type and
362/// `server_less_parse::ParamInfo`.
363#[derive(Debug, Clone)]
364pub struct ParamInfo {
365    /// Parameter name
366    pub name: String,
367    /// Type as string
368    pub ty: String,
369    /// Whether this is an `Option<T>`
370    pub is_optional: bool,
371    /// Whether this looks like an ID parameter (ends with _id or is named id)
372    pub is_id: bool,
373}
374
375/// HTTP method inferred from function name
376#[derive(Debug, Clone, Copy, PartialEq, Eq)]
377pub enum HttpMethod {
378    Get,
379    Post,
380    Put,
381    Patch,
382    Delete,
383}
384
385impl HttpMethod {
386    /// Infer HTTP method from function name prefix
387    pub fn infer_from_name(name: &str) -> Self {
388        if name.starts_with("get_")
389            || name.starts_with("fetch_")
390            || name.starts_with("read_")
391            || name.starts_with("list_")
392            || name.starts_with("find_")
393            || name.starts_with("search_")
394        {
395            HttpMethod::Get
396        } else if name.starts_with("create_")
397            || name.starts_with("add_")
398            || name.starts_with("new_")
399        {
400            HttpMethod::Post
401        } else if name.starts_with("update_") || name.starts_with("set_") {
402            HttpMethod::Put
403        } else if name.starts_with("patch_") || name.starts_with("modify_") {
404            HttpMethod::Patch
405        } else if name.starts_with("delete_") || name.starts_with("remove_") {
406            HttpMethod::Delete
407        } else {
408            // Default to POST for RPC-style methods
409            HttpMethod::Post
410        }
411    }
412
413    pub fn as_str(&self) -> &'static str {
414        match self {
415            HttpMethod::Get => "GET",
416            HttpMethod::Post => "POST",
417            HttpMethod::Put => "PUT",
418            HttpMethod::Patch => "PATCH",
419            HttpMethod::Delete => "DELETE",
420        }
421    }
422}
423
424/// Pluralize an English word using common rules.
425///
426/// Rules applied in order:
427/// - Already ends in `s`, `x`, `z`, `ch`, or `sh` → append `es`
428/// - Ends in a consonant followed by `y` → replace `y` with `ies`
429/// - Everything else → append `s`
430fn pluralize(word: &str) -> String {
431    if word.ends_with('s')
432        || word.ends_with('x')
433        || word.ends_with('z')
434        || word.ends_with("ch")
435        || word.ends_with("sh")
436    {
437        format!("{word}es")
438    } else if word.ends_with('y')
439        && word.len() >= 2
440        && !matches!(
441            word.as_bytes()[word.len() - 2],
442            b'a' | b'e' | b'i' | b'o' | b'u'
443        )
444    {
445        // consonant + y → drop y, add ies
446        format!("{}ies", &word[..word.len() - 1])
447    } else {
448        format!("{word}s")
449    }
450}
451
452/// Infer URL path from method name
453pub fn infer_path(method_name: &str, http_method: HttpMethod) -> String {
454    // Strip common prefixes to get the resource name
455    let resource = method_name
456        .strip_prefix("get_")
457        .or_else(|| method_name.strip_prefix("fetch_"))
458        .or_else(|| method_name.strip_prefix("read_"))
459        .or_else(|| method_name.strip_prefix("list_"))
460        .or_else(|| method_name.strip_prefix("find_"))
461        .or_else(|| method_name.strip_prefix("search_"))
462        .or_else(|| method_name.strip_prefix("create_"))
463        .or_else(|| method_name.strip_prefix("add_"))
464        .or_else(|| method_name.strip_prefix("new_"))
465        .or_else(|| method_name.strip_prefix("update_"))
466        .or_else(|| method_name.strip_prefix("set_"))
467        .or_else(|| method_name.strip_prefix("patch_"))
468        .or_else(|| method_name.strip_prefix("modify_"))
469        .or_else(|| method_name.strip_prefix("delete_"))
470        .or_else(|| method_name.strip_prefix("remove_"))
471        .unwrap_or(method_name);
472
473    // Pluralize for collection endpoints.
474    // If the resource already ends in 's' it is likely already plural (e.g. the
475    // caller wrote `list_users` and we stripped the prefix to get "users").
476    // For singular forms we apply English pluralization rules.
477    let path_resource = if resource.ends_with('s') {
478        resource.to_string()
479    } else {
480        pluralize(resource)
481    };
482
483    match http_method {
484        // Collection operations
485        HttpMethod::Post => format!("/{path_resource}"),
486        HttpMethod::Get
487            if method_name.starts_with("list_")
488                || method_name.starts_with("search_")
489                || method_name.starts_with("find_") =>
490        {
491            format!("/{path_resource}")
492        }
493        // Single resource operations
494        HttpMethod::Get | HttpMethod::Put | HttpMethod::Patch | HttpMethod::Delete => {
495            format!("/{path_resource}/{{id}}")
496        }
497    }
498}
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503
504    #[test]
505    fn test_pluralize() {
506        // ends in s/x/z/ch/sh → add es
507        assert_eq!(pluralize("index"), "indexes");
508        assert_eq!(pluralize("status"), "statuses");
509        assert_eq!(pluralize("match"), "matches");
510        assert_eq!(pluralize("box"), "boxes");
511        assert_eq!(pluralize("buzz"), "buzzes");
512        assert_eq!(pluralize("brush"), "brushes");
513        // consonant + y → ies
514        assert_eq!(pluralize("query"), "queries");
515        // vowel + y → plain s (key, day, …)
516        assert_eq!(pluralize("key"), "keys");
517        // default → add s
518        assert_eq!(pluralize("item"), "items");
519    }
520
521    #[test]
522    fn test_http_method_inference() {
523        assert_eq!(HttpMethod::infer_from_name("get_user"), HttpMethod::Get);
524        assert_eq!(HttpMethod::infer_from_name("list_users"), HttpMethod::Get);
525        assert_eq!(HttpMethod::infer_from_name("create_user"), HttpMethod::Post);
526        assert_eq!(HttpMethod::infer_from_name("update_user"), HttpMethod::Put);
527        assert_eq!(
528            HttpMethod::infer_from_name("delete_user"),
529            HttpMethod::Delete
530        );
531        assert_eq!(
532            HttpMethod::infer_from_name("do_something"),
533            HttpMethod::Post
534        ); // RPC fallback
535    }
536
537    #[test]
538    fn test_path_inference() {
539        assert_eq!(infer_path("create_user", HttpMethod::Post), "/users");
540        assert_eq!(infer_path("get_user", HttpMethod::Get), "/users/{id}");
541        assert_eq!(infer_path("list_users", HttpMethod::Get), "/users");
542        assert_eq!(infer_path("delete_user", HttpMethod::Delete), "/users/{id}");
543    }
544}