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