arcature 0.1.0

Arcature: an opinionated full-stack Rust web framework. One package, batteries included.
Documentation
//! Route metadata for the Arcature application DX layer.
//!
//! The `routes!` macro generates [`RouteDescriptor`] values as `const` items
//! -- plain data with `&'static` slices, no allocation, no runtime reflection.
//! These descriptors feed `arc routes` inspection, `arc build` validation, and
//! the `ApplicationGraph` route metadata.
//!
//! All types are const-constructible (unit enum variants, `&'static str`
//! fields) so the macro can emit them as `const` items.

/// An HTTP method supported by the `routes!` DSL.
///
/// The variants map 1:1 to the axum method-routing constructors
/// (`get`, `post`, `put`, `patch`, `delete`, `head`, `options`).
/// Const-constructible (unit variants).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize)]
#[serde(rename_all = "lowercase")]
pub enum RouteMethod {
    /// `GET` -- map to `axum::routing::get`.
    Get,
    /// `POST` -- map to `axum::routing::post`.
    Post,
    /// `PUT` -- map to `axum::routing::put`.
    Put,
    /// `PATCH` -- map to `axum::routing::patch`.
    Patch,
    /// `DELETE` -- map to `axum::routing::delete`.
    Delete,
    /// `HEAD` -- map to `axum::routing::head`.
    Head,
    /// `OPTIONS` -- map to `axum::routing::options`.
    Options,
}

impl RouteMethod {
    /// Returns the uppercase HTTP method string (e.g. `"GET"`, `"POST"`).
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Get => "GET",
            Self::Post => "POST",
            Self::Put => "PUT",
            Self::Patch => "PATCH",
            Self::Delete => "DELETE",
            Self::Head => "HEAD",
            Self::Options => "OPTIONS",
        }
    }

    /// Returns the lowercase axum routing function name (e.g. `"get"`,
    /// `"post"`).
    #[must_use]
    pub const fn as_routing_fn(self) -> &'static str {
        match self {
            Self::Get => "get",
            Self::Post => "post",
            Self::Put => "put",
            Self::Patch => "patch",
            Self::Delete => "delete",
            Self::Head => "head",
            Self::Options => "options",
        }
    }
}

impl std::fmt::Display for RouteMethod {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.as_str())
    }
}

/// Metadata for a single route, generated by the `routes!` macro.
///
/// Every field is `&'static str` or a unit enum -- no `Vec`, no `String`, no
/// allocation. A `const` array of `RouteDescriptor` is the inspection
/// artifact consumed by `arc routes` and `arc build`.
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize)]
pub struct RouteDescriptor {
    /// The HTTP method.
    pub method: RouteMethod,
    /// The full path pattern (e.g. `"/links/{link}"`). Group prefixes are
    /// already prepended by the macro.
    pub path: &'static str,
    /// The dotted route name (e.g. `"auth.login"`, `"links.index"`). Empty
    /// string `""` for unnamed routes.
    pub name: &'static str,
    /// The handler path as a string (e.g. `"SessionsController::create"`).
    pub handler: &'static str,
    /// The page identities this route renders, declared statically via the
    /// `page:`/`pages:` route option. Each entry is a canonical frontend
    /// page name matching a `#[page("Name")]` contract. Empty for routes
    /// that do not render an Inertia page (API endpoints, redirects,
    /// destroy actions).
    #[serde(default)]
    pub pages: &'static [&'static str],
    /// The typed input field shapes for an **Action** route, declared via
    /// the `action: RequestType` route option. The `routes!` macro resolves
    /// `<RequestType as RequestMetadata>::FIELDS` at compile time and bakes
    /// the slice here. Empty (`&[]`) for routes that are not actions.
    #[serde(default)]
    pub action_fields: &'static [super::field_metadata::FieldShape],
    /// The request input type name for an **Action** route (e.g.
    /// `"StoreLinkRequest"`), used to name the generated TypeScript input
    /// interface. Empty (`""`) for non-action routes.
    #[serde(default)]
    pub action_type: &'static str,
    /// The typed response element field shapes for a **Query** route,
    /// declared via the `query: ResourceType` (single) or
    /// `query: Vec<ResourceType>` (collection) route option. The `routes!`
    /// macro resolves `<ResourceType as ResourceMetadata>::FIELDS` at
    /// compile time and bakes the slice here. Empty (`&[]`) for routes that
    /// are not queries.
    #[serde(default)]
    pub query_fields: &'static [super::field_metadata::FieldShape],
    /// The response resource type name for a Query route (e.g.
    /// `"LinkResource"`), used to name the generated TypeScript response
    /// interface. Empty (`""`) for non-query routes. For `query: Vec<T>`,
    /// this is `T`'s name (the element type), and [`Self::query_array`] is
    /// `true`.
    #[serde(default)]
    pub query_type: &'static str,
    /// Whether a Query route's response is a collection (`query: Vec<T>` ->
    /// `true`) or a single value (`query: T` -> `false`). `false` for
    /// non-query routes.
    #[serde(default)]
    pub query_array: bool,
    /// The typed query-string field shapes for a **Query** route, declared
    /// via the `query_string: RequestType` route option. The `routes!`
    /// macro resolves `<RequestType as RequestMetadata>::FIELDS` at compile
    /// time and bakes the slice here. Empty (`&[]`) for queries without a
    /// typed query-string contract (the default).
    #[serde(default)]
    pub query_string_fields: &'static [super::field_metadata::FieldShape],
    /// The query-string request type name for a Query route (e.g.
    /// `"LinkSearchRequest"`), used to name the generated TypeScript
    /// query-string interface. Empty (`""`) for queries without a typed
    /// query-string contract.
    #[serde(default)]
    pub query_string_type: &'static str,
    /// The policies that guard this route, declared via the `policy:` /
    /// `policies:` route option and recorded by type name (the final path
    /// segment, so `crate::policies::LinkPolicy` and `LinkPolicy` record the
    /// same thing). A resource action inherits its resource's list. Empty
    /// (`&[]`) for a route no policy guards.
    ///
    /// This is a *declaration*, not enforcement: nothing in the router calls
    /// a policy on this route's behalf, and `Auth::authorize` in the handler
    /// remains the only thing that denies a request. What the declaration
    /// buys is the cross-check -- the Unified Application Graph can see that
    /// a route names `LinkPolicy` while no module exports it, or that a
    /// mutating route names no policy at all, neither of which is visible
    /// from the handler body.
    #[serde(default)]
    pub policies: &'static [&'static str],
}

/// A canonical resource action, used by `resource` route expansion and
/// `only`/`except` filtering.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ResourceAction {
    /// `GET /resource` -- list.
    Index,
    /// `GET /resource/new` -- new form.
    Create,
    /// `POST /resource` -- create.
    Store,
    /// `GET /resource/{id}` -- show.
    Show,
    /// `GET /resource/{id}/edit` -- edit form.
    Edit,
    /// `PUT /resource/{id}` -- update.
    Update,
    /// `DELETE /resource/{id}` -- destroy.
    Destroy,
}

#[allow(dead_code)] // Consumed by `arc routes` / `arc build` in later phases.
impl ResourceAction {
    /// Returns the action name as a lowercase string (e.g. `"index"`,
    /// `"store"`).
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Index => "index",
            Self::Create => "create",
            Self::Store => "store",
            Self::Show => "show",
            Self::Edit => "edit",
            Self::Update => "update",
            Self::Destroy => "destroy",
        }
    }

    /// Returns the HTTP method for this action.
    #[must_use]
    pub const fn method(self) -> RouteMethod {
        match self {
            Self::Index | Self::Create | Self::Show | Self::Edit => RouteMethod::Get,
            Self::Store => RouteMethod::Post,
            Self::Update => RouteMethod::Put,
            Self::Destroy => RouteMethod::Delete,
        }
    }

    /// Returns the path suffix for this action, relative to the resource
    /// base path. `{param}` is the route parameter placeholder. The
    /// returned string is allocated via `format!`.
    #[must_use]
    pub fn path_suffix(self, param: &str) -> String {
        match self {
            Self::Index | Self::Store => String::new(),
            Self::Create => "/new".to_string(),
            Self::Show | Self::Update | Self::Destroy => format!("/{{{param}}}"),
            Self::Edit => format!("/{{{param}}}/edit"),
        }
    }
}

impl std::fmt::Display for ResourceAction {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.as_str())
    }
}

/// Parses a resource action name string into a [`ResourceAction`].
///
/// Returns `None` for unknown action names. Used by `only`/`except`
/// validation in the `routes!` macro.
#[must_use]
#[allow(dead_code)] // Consumed by `arc routes` / `arc build` in later phases.
pub fn parse_resource_action(name: &str) -> Option<ResourceAction> {
    match name {
        "index" => Some(ResourceAction::Index),
        "create" => Some(ResourceAction::Create),
        "store" => Some(ResourceAction::Store),
        "show" => Some(ResourceAction::Show),
        "edit" => Some(ResourceAction::Edit),
        "update" => Some(ResourceAction::Update),
        "destroy" => Some(ResourceAction::Destroy),
        _ => None,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn route_method_display() {
        assert_eq!(RouteMethod::Get.to_string(), "GET");
        assert_eq!(RouteMethod::Post.to_string(), "POST");
        assert_eq!(RouteMethod::Delete.to_string(), "DELETE");
    }

    #[test]
    fn route_descriptor_serializes_to_json() {
        let descriptor = RouteDescriptor {
            method: RouteMethod::Get,
            path: "/links/{link}",
            name: "links.show",
            handler: "LinksController::show",
            pages: &["Show"],
            action_fields: &[],
            action_type: "",
            query_fields: &[],
            query_type: "",
            query_array: false,
            query_string_fields: &[],
            query_string_type: "",
            policies: &[],
        };
        let json = serde_json::to_string(&descriptor).unwrap();
        assert!(
            json.contains("\"method\":\"get\""),
            "method lowercase: {json}"
        );
        assert!(json.contains("\"path\":\"/links/{link}\""), "path: {json}");
        assert!(json.contains("\"name\":\"links.show\""), "name: {json}");
        assert!(
            json.contains("\"handler\":\"LinksController::show\""),
            "handler: {json}"
        );
        assert!(
            json.contains("\"pages\":[\"Show\"]"),
            "route->page edge serialized: {json}"
        );
    }

    #[test]
    fn route_method_routing_fn() {
        assert_eq!(RouteMethod::Get.as_routing_fn(), "get");
        assert_eq!(RouteMethod::Patch.as_routing_fn(), "patch");
    }

    #[test]
    fn resource_action_method_mapping() {
        assert_eq!(ResourceAction::Index.method(), RouteMethod::Get);
        assert_eq!(ResourceAction::Store.method(), RouteMethod::Post);
        assert_eq!(ResourceAction::Update.method(), RouteMethod::Put);
        assert_eq!(ResourceAction::Destroy.method(), RouteMethod::Delete);
    }

    #[test]
    fn parse_known_actions() {
        assert_eq!(parse_resource_action("index"), Some(ResourceAction::Index));
        assert_eq!(
            parse_resource_action("destroy"),
            Some(ResourceAction::Destroy)
        );
        assert_eq!(parse_resource_action("unknown"), None);
    }
}