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/// One node in a CLI "manual": the reference entry for a single command path.
28///
29/// The manual is the whole-subtree aggregate emitted by the `--manual` flag.
30/// Each leaf command in the tree contributes one `CliManualNode`; mount points
31/// contribute the nodes of their subtree (recursively), with the path prefixed.
32///
33/// This is a serializable data structure (not a closure or scraped runtime
34/// state), so the manual caches, transports, and diffs cleanly. The `--manual`
35/// rendering layer turns a `Vec<CliManualNode>` into either human-readable text
36/// or a path-keyed JSON object.
37#[cfg(feature = "cli")]
38#[derive(Clone, Debug, serde::Serialize)]
39pub struct CliManualNode {
40    /// Space-separated command path from the root of the *invoked* subtree,
41    /// e.g. `"edit history list"`. The root subtree's own node (if it has a
42    /// default action) uses the empty string.
43    pub path: String,
44    /// The command's description (first paragraph of its doc comment), if any.
45    pub description: Option<String>,
46    /// JSON Schema of the command's input parameters.
47    pub input_schema: serde_json::Value,
48    /// JSON Schema of the command's return type.
49    pub output_schema: serde_json::Value,
50}
51
52/// Render a flat list of manual nodes as a path-keyed JSON object.
53///
54/// This is the structured (`--manual --json` / `--jq`) shape: one key per
55/// command path, each value carrying `description`, `input_schema`, and
56/// `output_schema`. Used by `#[cli]`-generated `--manual` handling.
57#[cfg(feature = "cli")]
58pub fn cli_manual_to_json(nodes: &[CliManualNode]) -> serde_json::Value {
59    let mut map = serde_json::Map::new();
60    for node in nodes {
61        map.insert(
62            node.path.clone(),
63            serde_json::json!({
64                "description": node.description,
65                "input_schema": node.input_schema,
66                "output_schema": node.output_schema,
67            }),
68        );
69    }
70    serde_json::Value::Object(map)
71}
72
73/// Render a flat list of manual nodes as human-readable reference text.
74///
75/// This is the default (`--manual` with no format flag) shape: a markdown-ish
76/// reference page, one section per command path. Used by `#[cli]`-generated
77/// `--manual` handling.
78#[cfg(feature = "cli")]
79pub fn cli_manual_to_text(nodes: &[CliManualNode]) -> String {
80    let mut out = String::new();
81    for (i, node) in nodes.iter().enumerate() {
82        if i > 0 {
83            out.push('\n');
84        }
85        let heading = if node.path.is_empty() {
86            "(default)"
87        } else {
88            node.path.as_str()
89        };
90        out.push_str(&format!("# {heading}\n"));
91        if let Some(desc) = &node.description
92            && !desc.is_empty()
93        {
94            out.push_str(&format!("\n{desc}\n"));
95        }
96        if let Some(props) = node
97            .input_schema
98            .get("properties")
99            .and_then(|p| p.as_object())
100            && !props.is_empty()
101        {
102            out.push_str("\nParameters:\n");
103            let required: std::collections::HashSet<&str> = node
104                .input_schema
105                .get("required")
106                .and_then(|r| r.as_array())
107                .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
108                .unwrap_or_default();
109            for (name, schema) in props {
110                let ty = schema
111                    .get("type")
112                    .and_then(|t| t.as_str())
113                    .unwrap_or("value");
114                let req = if required.contains(name.as_str()) {
115                    " (required)"
116                } else {
117                    ""
118                };
119                out.push_str(&format!("  {name}: {ty}{req}\n"));
120            }
121        }
122    }
123    out
124}
125
126/// Trait for types that can be mounted as CLI subcommand groups.
127///
128/// Implemented automatically by `#[cli]` on an impl block. Allows nested
129/// composition: a parent CLI can mount a child's commands as a subcommand group.
130#[cfg(feature = "cli")]
131pub trait CliSubcommand {
132    /// Build the clap Command tree for this type's subcommands.
133    fn cli_command() -> ::clap::Command;
134
135    /// Collect the manual (whole-subtree reference) nodes for this type.
136    ///
137    /// `prefix` is the command path of *this* node from the root of the invoked
138    /// subtree (empty at the invocation root). Each leaf appends one node; each
139    /// mount point recurses into its child type with an extended prefix.
140    ///
141    /// The default returns an empty vec so hand-written `CliSubcommand` impls
142    /// keep compiling; `#[cli]` always overrides it.
143    fn cli_manual_nodes(&self, _prefix: &str) -> Vec<CliManualNode> {
144        Vec::new()
145    }
146
147    /// Dispatch a matched subcommand to the appropriate method.
148    fn cli_dispatch(&self, matches: &::clap::ArgMatches) -> Result<(), Box<dyn std::error::Error>>;
149
150    /// Dispatch a matched subcommand asynchronously.
151    ///
152    /// Awaits the dispatched method directly without creating an internal runtime.
153    /// Used by `cli_run_async` to support user-provided async runtimes.
154    fn cli_dispatch_async<'a>(
155        &'a self,
156        matches: &'a ::clap::ArgMatches,
157    ) -> impl std::future::Future<Output = Result<(), Box<dyn std::error::Error>>> + 'a {
158        async move { self.cli_dispatch(matches) }
159    }
160}
161
162/// Trait for types that can be mounted as MCP tool namespaces.
163///
164/// Implemented automatically by `#[mcp]` on an impl block. Allows nested
165/// composition: a parent MCP server can mount a child's tools with a name prefix.
166#[cfg(feature = "mcp")]
167pub trait McpNamespace {
168    /// Get tool definitions for this namespace.
169    fn mcp_namespace_tools() -> Vec<serde_json::Value>;
170
171    /// Get tool names for this namespace (without prefix).
172    fn mcp_namespace_tool_names() -> Vec<String>;
173
174    /// Call a tool by name (sync). Returns error for async-only methods.
175    fn mcp_namespace_call(
176        &self,
177        name: &str,
178        args: serde_json::Value,
179    ) -> Result<serde_json::Value, String>;
180
181    /// Call a tool by name (async).
182    fn mcp_namespace_call_async(
183        &self,
184        name: &str,
185        args: serde_json::Value,
186    ) -> impl std::future::Future<Output = Result<serde_json::Value, String>> + Send;
187}
188
189/// Trait for types that can be mounted as JSON-RPC method namespaces.
190///
191/// Implemented automatically by `#[jsonrpc]` on an impl block. Allows nested
192/// composition: a parent JSON-RPC server can mount a child's methods with a dot-separated prefix.
193#[cfg(feature = "jsonrpc")]
194pub trait JsonRpcMount {
195    /// Get method names for this mount (without prefix).
196    fn jsonrpc_mount_methods() -> Vec<String>;
197
198    /// Dispatch a method call (sync). Returns error for async-only methods.
199    fn jsonrpc_mount_dispatch(
200        &self,
201        method: &str,
202        params: serde_json::Value,
203    ) -> Result<serde_json::Value, String>;
204
205    /// Dispatch a method call (async).
206    fn jsonrpc_mount_dispatch_async(
207        &self,
208        method: &str,
209        params: serde_json::Value,
210    ) -> impl std::future::Future<Output = Result<serde_json::Value, String>> + Send;
211}
212
213/// Trait for types that can be mounted as WebSocket method namespaces.
214///
215/// Implemented automatically by `#[ws]` on an impl block. Allows nested
216/// composition: a parent WebSocket server can mount a child's methods with a dot-separated prefix.
217#[cfg(feature = "ws")]
218pub trait WsMount {
219    /// Get method names for this mount (without prefix).
220    fn ws_mount_methods() -> Vec<String>;
221
222    /// Dispatch a method call (sync). Returns error for async-only methods.
223    fn ws_mount_dispatch(
224        &self,
225        method: &str,
226        params: serde_json::Value,
227    ) -> Result<serde_json::Value, String>;
228
229    /// Dispatch a method call (async).
230    fn ws_mount_dispatch_async(
231        &self,
232        method: &str,
233        params: serde_json::Value,
234    ) -> impl std::future::Future<Output = Result<serde_json::Value, String>> + Send;
235}
236
237/// Trait for types that can be mounted as HTTP route groups.
238///
239/// Implemented automatically by `#[http]` on an impl block. Allows nested
240/// composition: a parent HTTP server can mount a child's routes under a path prefix.
241#[cfg(feature = "http")]
242pub trait HttpMount: Send + Sync + 'static {
243    /// Build an axum Router for this mount's routes.
244    fn http_mount_router(self: ::std::sync::Arc<Self>) -> ::axum::Router;
245
246    /// Get full OpenAPI path definitions for this mount (including any nested mounts).
247    ///
248    /// Paths are relative to the mount point. The parent prefixes them when composing.
249    fn http_mount_openapi_paths() -> Vec<server_less_openapi::OpenApiPath>
250    where
251        Self: Sized;
252}
253
254/// Format a `serde_json::Value` according to the active JSON output flag.
255///
256/// This function is only called when at least one JSON flag is active
257/// (`--json`, `--jsonl`, or `--jq`). The overall CLI default output path
258/// (human-readable `Display` text) is generated directly by the `#[cli]`
259/// macro and does not go through this function.
260///
261/// Flag precedence (first match wins):
262///
263/// - `jq`: filter the value using jaq (jq implemented in Rust, no external binary needed)
264/// - `jsonl`: one compact JSON object per line (arrays are unwrapped; non-arrays emit a single line)
265/// - `json` (`true`): compact JSON — `serde_json::to_string`, no whitespace
266/// - `json` (`false`) with no other flag active: pretty-printed JSON — only reachable when called
267///   directly, not from `#[cli]`-generated code (which always sets at least one flag)
268#[cfg(feature = "cli")]
269pub fn cli_format_output(
270    value: serde_json::Value,
271    jsonl: bool,
272    json: bool,
273    jq: Option<&str>,
274) -> Result<String, Box<dyn std::error::Error>> {
275    if let Some(filter) = jq {
276        use jaq_core::load::{Arena, File as JaqFile, Loader};
277        use jaq_core::{Compiler, Ctx, Vars, data, unwrap_valr};
278        use jaq_json::Val;
279
280        let loader =
281            Loader::new(jaq_core::defs().chain(jaq_std::defs()).chain(jaq_json::defs()));
282        let arena = Arena::default();
283
284        let program = JaqFile {
285            code: filter,
286            path: (),
287        };
288
289        let modules = loader
290            .load(&arena, program)
291            .map_err(|errs| format!("jq parse error: {:?}", errs))?;
292
293        let filter_compiled = Compiler::default()
294            .with_funs(jaq_core::funs().chain(jaq_std::funs()).chain(jaq_json::funs()))
295            .compile(modules)
296            .map_err(|errs| format!("jq compile error: {:?}", errs))?;
297
298        let val: Val = serde_json::from_value(value)?;
299        let ctx = Ctx::<data::JustLut<Val>>::new(&filter_compiled.lut, Vars::new([]));
300        let out = filter_compiled.id.run((ctx, val)).map(unwrap_valr);
301
302        let mut results = Vec::new();
303        for result in out {
304            match result {
305                Ok(v) => results.push(v.to_string()),
306                Err(e) => return Err(format!("jq runtime error: {:?}", e).into()),
307            }
308        }
309
310        Ok(results.join("\n"))
311    } else if jsonl {
312        match value {
313            serde_json::Value::Array(items) => {
314                let lines: Vec<String> = items
315                    .iter()
316                    .map(serde_json::to_string)
317                    .collect::<Result<_, _>>()?;
318                Ok(lines.join("\n"))
319            }
320            other => Ok(serde_json::to_string(&other)?),
321        }
322    } else if json {
323        Ok(serde_json::to_string(&value)?)
324    } else {
325        Ok(serde_json::to_string_pretty(&value)?)
326    }
327}
328
329/// Generate a JSON Schema for a type at runtime using schemars.
330///
331/// Called by `--output-schema` in `#[cli]`-generated code when the `jsonschema`
332/// feature is enabled. Users must `#[derive(schemars::JsonSchema)]` on their
333/// return types to use this.
334#[cfg(feature = "jsonschema")]
335pub fn cli_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
336    serde_json::to_value(schemars::schema_for!(T))
337        .unwrap_or_else(|_| serde_json::json!({"type": "object"}))
338}
339
340/// A clap [`TypedValueParser`] that uses [`schemars::JsonSchema`] to surface
341/// enum variants as possible values, and [`std::str::FromStr`] for actual parsing.
342///
343/// When `T` is an enum deriving `JsonSchema`, its variants appear in `--help`
344/// output and clap's error messages with no extra derives on the user type.
345/// For non-enum types (e.g. `String`, `u32`), this is a transparent pass-through
346/// to `FromStr`.
347///
348/// Used automatically by `#[cli]`-generated code when both the `cli` and
349/// `jsonschema` features are enabled.
350#[cfg(all(feature = "cli", feature = "jsonschema"))]
351#[derive(Clone)]
352pub struct SchemaValueParser<T: Clone + Send + Sync + 'static> {
353    /// Enum variant names as `'static` str. We leak each string once at
354    /// parser-construction time (command build, not per-parse), which is
355    /// acceptable for a CLI binary: the leak is bounded (a few bytes per
356    /// variant) and the memory is reclaimed when the process exits.
357    variants: Option<std::sync::Arc<[&'static str]>>,
358    _marker: std::marker::PhantomData<T>,
359}
360
361#[cfg(all(feature = "cli", feature = "jsonschema"))]
362impl<T> Default for SchemaValueParser<T>
363where
364    T: schemars::JsonSchema + std::str::FromStr + Clone + Send + Sync + 'static,
365{
366    fn default() -> Self {
367        Self::new()
368    }
369}
370
371#[cfg(all(feature = "cli", feature = "jsonschema"))]
372impl<T> SchemaValueParser<T>
373where
374    T: schemars::JsonSchema + std::str::FromStr + Clone + Send + Sync + 'static,
375{
376    /// Creates a new `SchemaValueParser`, extracting allowed enum variants if available.
377    pub fn new() -> Self {
378        let variants = extract_enum_variants::<T>().map(|strings| {
379            let leaked: Vec<&'static str> = strings
380                .into_iter()
381                .map(|s| Box::leak(s.into_boxed_str()) as &'static str)
382                .collect();
383            leaked.into()
384        });
385        Self {
386            variants,
387            _marker: std::marker::PhantomData,
388        }
389    }
390}
391
392#[cfg(all(feature = "cli", feature = "jsonschema"))]
393fn extract_enum_variants<T: schemars::JsonSchema>() -> Option<Vec<String>> {
394    let schema_value = serde_json::to_value(schemars::schema_for!(T)).ok()?;
395    let enum_values = schema_value.get("enum")?.as_array()?;
396    let variants: Vec<String> = enum_values
397        .iter()
398        .filter_map(|v| v.as_str().map(String::from))
399        .collect();
400    if variants.is_empty() {
401        None
402    } else {
403        Some(variants)
404    }
405}
406
407#[cfg(all(feature = "cli", feature = "jsonschema"))]
408impl<T> ::clap::builder::TypedValueParser for SchemaValueParser<T>
409where
410    T: schemars::JsonSchema + std::str::FromStr + Clone + Send + Sync + 'static,
411    <T as std::str::FromStr>::Err: std::fmt::Display,
412{
413    type Value = T;
414
415    fn parse_ref(
416        &self,
417        _cmd: &::clap::Command,
418        _arg: Option<&::clap::Arg>,
419        value: &std::ffi::OsStr,
420    ) -> Result<T, ::clap::Error> {
421        let s = value
422            .to_str()
423            .ok_or_else(|| ::clap::Error::new(::clap::error::ErrorKind::InvalidUtf8))?;
424        s.parse::<T>()
425            .map_err(|e| ::clap::Error::raw(::clap::error::ErrorKind::ValueValidation, e))
426    }
427
428    fn possible_values(&self) -> Option<Box<dyn Iterator<Item = clap::builder::PossibleValue> + '_>> {
429        self.variants.as_ref().map(|variants| {
430            let v: Vec<_> = variants
431                .iter()
432                .map(|s| clap::builder::PossibleValue::new(*s))
433                .collect();
434            Box::new(v.into_iter()) as Box<dyn Iterator<Item = clap::builder::PossibleValue>>
435        })
436    }
437}
438
439/// Runtime method metadata with string-based types.
440///
441/// This is a simplified, serialization-friendly representation of method
442/// information intended for runtime introspection and tooling. Types are
443/// stored as strings rather than `syn` AST nodes.
444///
445/// **Not to be confused with [`server_less_parse::MethodInfo`]**, which is
446/// the richer, `syn`-based representation used internally by proc macros
447/// during code generation. The parse version retains full type information
448/// (`syn::Type`, `syn::Ident`) and supports `#[param(...)]` attributes.
449#[derive(Debug, Clone)]
450pub struct MethodInfo {
451    /// Method name (e.g., "create_user")
452    pub name: String,
453    /// Documentation string from /// comments
454    pub docs: Option<String>,
455    /// Parameter names and their type strings
456    pub params: Vec<ParamInfo>,
457    /// Return type string
458    pub return_type: String,
459    /// Whether the method is async
460    pub is_async: bool,
461    /// Whether the return type is a Stream
462    pub is_streaming: bool,
463    /// Whether the return type is `Option<T>`
464    pub is_optional: bool,
465    /// Whether the return type is `Result<T, E>`
466    pub is_result: bool,
467    /// Group display name for categorization
468    pub group: Option<String>,
469}
470
471/// Runtime parameter metadata with string-based types.
472///
473/// See [`MethodInfo`] for the relationship between this type and
474/// `server_less_parse::ParamInfo`.
475#[derive(Debug, Clone)]
476pub struct ParamInfo {
477    /// Parameter name
478    pub name: String,
479    /// Type as string
480    pub ty: String,
481    /// Whether this is an `Option<T>`
482    pub is_optional: bool,
483    /// Whether this looks like an ID parameter (ends with _id or is named id)
484    pub is_id: bool,
485}
486
487/// Runtime HTTP method enum used in generated introspection code.
488///
489/// See also [`server_less_parse::HttpMethod`] for the compile-time equivalent used
490/// by proc macros during code generation.
491#[derive(Debug, Clone, Copy, PartialEq, Eq)]
492pub enum HttpMethod {
493    Get,
494    Post,
495    Put,
496    Patch,
497    Delete,
498}
499
500impl HttpMethod {
501    /// Infer HTTP method from function name prefix
502    pub fn infer_from_name(name: &str) -> Self {
503        if name.starts_with("get_")
504            || name.starts_with("fetch_")
505            || name.starts_with("read_")
506            || name.starts_with("list_")
507            || name.starts_with("find_")
508            || name.starts_with("search_")
509        {
510            HttpMethod::Get
511        } else if name.starts_with("create_")
512            || name.starts_with("add_")
513            || name.starts_with("new_")
514        {
515            HttpMethod::Post
516        } else if name.starts_with("update_") || name.starts_with("set_") {
517            HttpMethod::Put
518        } else if name.starts_with("patch_") || name.starts_with("modify_") {
519            HttpMethod::Patch
520        } else if name.starts_with("delete_") || name.starts_with("remove_") {
521            HttpMethod::Delete
522        } else {
523            // Default to POST for RPC-style methods
524            HttpMethod::Post
525        }
526    }
527
528    /// Returns the HTTP method as an uppercase string slice (e.g. `"GET"`, `"POST"`).
529    pub fn as_str(&self) -> &'static str {
530        match self {
531            HttpMethod::Get => "GET",
532            HttpMethod::Post => "POST",
533            HttpMethod::Put => "PUT",
534            HttpMethod::Patch => "PATCH",
535            HttpMethod::Delete => "DELETE",
536        }
537    }
538}
539
540/// Pluralize an English word using common rules.
541///
542/// Rules applied in order:
543/// - Already ends in `s`, `x`, `z`, `ch`, or `sh` → append `es`
544/// - Ends in a consonant followed by `y` → replace `y` with `ies`
545/// - Everything else → append `s`
546fn pluralize(word: &str) -> String {
547    if word.ends_with('s')
548        || word.ends_with('x')
549        || word.ends_with('z')
550        || word.ends_with("ch")
551        || word.ends_with("sh")
552    {
553        format!("{word}es")
554    } else if word.ends_with('y')
555        && word.len() >= 2
556        && !matches!(
557            word.as_bytes()[word.len() - 2],
558            b'a' | b'e' | b'i' | b'o' | b'u'
559        )
560    {
561        // consonant + y → drop y, add ies
562        format!("{}ies", &word[..word.len() - 1])
563    } else {
564        format!("{word}s")
565    }
566}
567
568/// Infer a REST path from a method name and HTTP method.
569///
570/// This is the runtime version used in generated introspection code. It does not
571/// have access to parameter information, so it cannot infer `/{id}` paths contextually.
572/// See `server_less_parse` (or `server_less_macros::openapi_gen`) for the richer
573/// compile-time version that uses parameter names to infer `/{id}` paths.
574pub fn infer_path(method_name: &str, http_method: HttpMethod) -> String {
575    // Strip common prefixes to get the resource name
576    let resource = method_name
577        .strip_prefix("get_")
578        .or_else(|| method_name.strip_prefix("fetch_"))
579        .or_else(|| method_name.strip_prefix("read_"))
580        .or_else(|| method_name.strip_prefix("list_"))
581        .or_else(|| method_name.strip_prefix("find_"))
582        .or_else(|| method_name.strip_prefix("search_"))
583        .or_else(|| method_name.strip_prefix("create_"))
584        .or_else(|| method_name.strip_prefix("add_"))
585        .or_else(|| method_name.strip_prefix("new_"))
586        .or_else(|| method_name.strip_prefix("update_"))
587        .or_else(|| method_name.strip_prefix("set_"))
588        .or_else(|| method_name.strip_prefix("patch_"))
589        .or_else(|| method_name.strip_prefix("modify_"))
590        .or_else(|| method_name.strip_prefix("delete_"))
591        .or_else(|| method_name.strip_prefix("remove_"))
592        .unwrap_or(method_name);
593
594    // Pluralize for collection endpoints.
595    // If the resource already ends in 's' it is likely already plural (e.g. the
596    // caller wrote `list_users` and we stripped the prefix to get "users").
597    // For singular forms we apply English pluralization rules.
598    let path_resource = if resource.ends_with('s') {
599        resource.to_string()
600    } else {
601        pluralize(resource)
602    };
603
604    match http_method {
605        // Collection operations
606        HttpMethod::Post => format!("/{path_resource}"),
607        HttpMethod::Get
608            if method_name.starts_with("list_")
609                || method_name.starts_with("search_")
610                || method_name.starts_with("find_") =>
611        {
612            format!("/{path_resource}")
613        }
614        // Single resource operations
615        HttpMethod::Get | HttpMethod::Put | HttpMethod::Patch | HttpMethod::Delete => {
616            format!("/{path_resource}/{{id}}")
617        }
618    }
619}
620
621#[cfg(test)]
622mod tests {
623    use super::*;
624
625    #[test]
626    fn test_pluralize() {
627        // ends in s/x/z/ch/sh → add es
628        assert_eq!(pluralize("index"), "indexes");
629        assert_eq!(pluralize("status"), "statuses");
630        assert_eq!(pluralize("match"), "matches");
631        assert_eq!(pluralize("box"), "boxes");
632        assert_eq!(pluralize("buzz"), "buzzes");
633        assert_eq!(pluralize("brush"), "brushes");
634        // consonant + y → ies
635        assert_eq!(pluralize("query"), "queries");
636        // vowel + y → plain s (key, day, …)
637        assert_eq!(pluralize("key"), "keys");
638        // default → add s
639        assert_eq!(pluralize("item"), "items");
640    }
641
642    #[test]
643    fn test_http_method_inference() {
644        assert_eq!(HttpMethod::infer_from_name("get_user"), HttpMethod::Get);
645        assert_eq!(HttpMethod::infer_from_name("list_users"), HttpMethod::Get);
646        assert_eq!(HttpMethod::infer_from_name("create_user"), HttpMethod::Post);
647        assert_eq!(HttpMethod::infer_from_name("update_user"), HttpMethod::Put);
648        assert_eq!(
649            HttpMethod::infer_from_name("delete_user"),
650            HttpMethod::Delete
651        );
652        assert_eq!(
653            HttpMethod::infer_from_name("do_something"),
654            HttpMethod::Post
655        ); // RPC fallback
656    }
657
658    #[test]
659    fn test_path_inference() {
660        assert_eq!(infer_path("create_user", HttpMethod::Post), "/users");
661        assert_eq!(infer_path("get_user", HttpMethod::Get), "/users/{id}");
662        assert_eq!(infer_path("list_users", HttpMethod::Get), "/users");
663        assert_eq!(infer_path("delete_user", HttpMethod::Delete), "/users/{id}");
664    }
665}