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