diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d41b3945..f84ce2cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,23 +4,31 @@ on: workflow_dispatch: inputs: run_formatting: - description: 'Run formatting checks' + description: "Run formatting checks" type: boolean default: false run_core: - description: 'Run core checks' + description: "Run core checks" type: boolean default: false run_web: - description: 'Run web checks' + description: "Run web checks" type: boolean default: false run_socketio: - description: 'Run socketio checks' + description: "Run socketio checks" type: boolean default: false run_grpc: - description: 'Run grpc checks' + description: "Run grpc checks" + type: boolean + default: false + run_jobs: + description: "Run jobs checks" + type: boolean + default: false + run_events: + description: "Run events checks" type: boolean default: false push: @@ -52,6 +60,8 @@ jobs: grpc: ${{ steps.filter.outputs.grpc }} web: ${{ steps.filter.outputs.web }} socketio: ${{ steps.filter.outputs.socketio }} + jobs: ${{ steps.filter.outputs.jobs }} + events: ${{ steps.filter.outputs.events }} formatting: ${{ steps.filter.outputs.formatting }} deps: ${{ steps.filter.outputs.deps }} steps: @@ -88,6 +98,22 @@ jobs: - 'crates/sword-macros/**' - 'rust-toolchain.toml' - '.github/workflows/**' + jobs: + - 'crates/sword-core/**' + - 'crates/sword/**' + - 'crates/sword-jobs/**' + - 'crates/sword-macros/**' + - 'tests/sword-jobs/**' + - 'rust-toolchain.toml' + - '.github/workflows/**' + events: + - 'crates/sword-core/**' + - 'crates/sword/**' + - 'crates/sword-events/**' + - 'crates/sword-macros/**' + - 'tests/sword-events/**' + - 'rust-toolchain.toml' + - '.github/workflows/**' formatting: - '**/*.rs' - 'rustfmt.toml' @@ -168,7 +194,7 @@ jobs: run: cargo clippy -p sword-grpc --all-targets --all-features -- -D warnings - name: Run clippy (sword-macros) - run: cargo clippy -p sword-macros --all-targets --features grpc-controllers -- -D warnings + run: cargo clippy -p sword-macros --all-targets --features grpc -- -D warnings - name: Run tests (sword-grpc-tests) run: cargo test -p sword-grpc-tests -- --test-threads=1 @@ -195,7 +221,7 @@ jobs: run: cargo clippy -p sword-web --all-targets --all-features -- -D warnings - name: Run clippy (sword-macros) - run: cargo clippy -p sword-macros --all-targets --features web-controllers -- -D warnings + run: cargo clippy -p sword-macros --all-targets --features web -- -D warnings - name: Run tests (sword-web-tests) run: cargo test -p sword-web-tests @@ -222,7 +248,7 @@ jobs: run: cargo clippy -p sword-socketio --all-targets --all-features -- -D warnings - name: Run clippy (sword-macros) - run: cargo clippy -p sword-macros --all-targets --features socketio-controllers -- -D warnings + run: cargo clippy -p sword-macros --all-targets --features socketio -- -D warnings - name: Run tests (sword-socketio-tests) run: cargo test -p sword-socketio-tests @@ -232,6 +258,60 @@ jobs: RUSTDOCFLAGS: "-D warnings" run: cargo doc -p sword-socketio --no-deps --all-features + jobs-checks: + needs: changes + if: needs.changes.outputs.jobs == 'true' || inputs.run_jobs == true + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + cache: true + + - name: Run clippy (sword-jobs) + run: cargo clippy -p sword-jobs --all-targets -- -D warnings + + - name: Run clippy (sword-macros) + run: cargo clippy -p sword-macros --all-targets --features jobs -- -D warnings + + - name: Run tests (sword-jobs-tests) + run: cargo test -p sword-jobs-tests + + - name: Build docs (sword-jobs) + env: + RUSTDOCFLAGS: "-D warnings" + run: cargo doc -p sword-jobs --no-deps + + events-checks: + needs: changes + if: needs.changes.outputs.events == 'true' || inputs.run_events == true + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + cache: true + + - name: Run clippy (sword-events) + run: cargo clippy -p sword-events --all-targets --all-features -- -D warnings + + - name: Run clippy (sword-macros) + run: cargo clippy -p sword-macros --all-targets --features events -- -D warnings + + - name: Run tests (sword-events-tests) + run: cargo test -p sword-events-tests + + - name: Build docs (sword-events) + env: + RUSTDOCFLAGS: "-D warnings" + run: cargo doc -p sword-events --no-deps --all-features + deps-checks: needs: changes if: needs.changes.outputs.deps == 'true' diff --git a/Cargo.lock b/Cargo.lock index d819a029..5e684706 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -528,6 +528,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf", +] + [[package]] name = "cipher" version = "0.4.4" @@ -666,6 +676,17 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "croner" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aa42bcd3d846ebf66e15bd528d1087f75d1c6c1c66ebff626178a106353c576" +dependencies = [ + "chrono", + "derive_builder", + "strum", +] + [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -719,14 +740,48 @@ dependencies = [ "cmov", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + [[package]] name = "darling" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core 0.24.1", + "darling_macro 0.24.1", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", ] [[package]] @@ -742,17 +797,52 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.5", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.119", +] + [[package]] name = "darling_macro" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core", + "darling_core 0.23.0", "quote", "syn 2.0.119", ] +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core 0.24.1", + "quote", + "syn 3.0.5", +] + [[package]] name = "data-encoding" version = "2.11.1" @@ -776,6 +866,37 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.119", +] + [[package]] name = "diff" version = "0.1.13" @@ -2028,6 +2149,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "num-integer" version = "0.1.46" @@ -2147,6 +2279,24 @@ dependencies = [ "getrandom 0.4.3", ] +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.1.13" @@ -2912,6 +3062,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + [[package]] name = "slab" version = "0.4.12" @@ -3220,6 +3376,27 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "subsecond" version = "0.7.10" @@ -3265,6 +3442,7 @@ dependencies = [ "sword-core", "sword-events", "sword-grpc", + "sword-jobs", "sword-layers", "sword-macros", "sword-socketio", @@ -3300,6 +3478,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "sword-events-tests" +version = "0.1.0" +dependencies = [ + "axum-test", + "sword", + "tokio", +] + [[package]] name = "sword-grpc" version = "0.4.1" @@ -3353,6 +3540,26 @@ dependencies = [ "tonic-prost-build", ] +[[package]] +name = "sword-jobs" +version = "0.4.1" +dependencies = [ + "inventory", + "sword-core", + "sword-macros", + "tokio-cron-scheduler", + "tracing", + "uuid", +] + +[[package]] +name = "sword-jobs-tests" +version = "0.1.0" +dependencies = [ + "sword", + "tokio", +] + [[package]] name = "sword-layers" version = "0.4.1" @@ -3379,7 +3586,7 @@ dependencies = [ name = "sword-macros" version = "0.4.1" dependencies = [ - "axum", + "darling 0.24.1", "heck", "proc-macro2", "quote", @@ -3699,6 +3906,22 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "tokio-cron-scheduler" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f50e41f200fd8ed426489bd356910ede4f053e30cebfbd59ef0f856f0d7432a" +dependencies = [ + "chrono", + "chrono-tz", + "croner", + "num-derive", + "num-traits", + "tokio", + "tracing", + "uuid", +] + [[package]] name = "tokio-macros" version = "2.7.2" @@ -4323,7 +4546,7 @@ version = "0.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240e4b81c20a1d6d50d1d7265c658dfbd204e8b9ac4d80f3c931f39462196335" dependencies = [ - "darling", + "darling 0.23.0", "proc-macro-error3", "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 78f4940e..08c25521 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,8 @@ members = [ "tests/sword-web", "tests/sword-socketio", "tests/sword-grpc", + "tests/sword-jobs", + "tests/sword-events", ] [workspace.metadata.cargo-machete] @@ -29,6 +31,7 @@ sword-web = { path = "crates/sword-web", version = "0.4.1" } sword-grpc = { path = "crates/sword-grpc", version = "0.4.1" } sword-socketio = { path = "crates/sword-socketio", version = "0.4.1" } sword-events = { path = "crates/sword-events", version = "0.4.1" } +sword-jobs = { path = "crates/sword-jobs", version = "0.4.1" } tokio = { version = "^1.0", features = ["full"] } tokio-stream = "0.1.18" diff --git a/crates/sword-core/src/controllers.rs b/crates/sword-core/src/controllers.rs index cf36cdd3..fab2cb4a 100644 --- a/crates/sword-core/src/controllers.rs +++ b/crates/sword-core/src/controllers.rs @@ -14,6 +14,7 @@ pub enum Controller { SocketIo, Grpc, EventHandler, + JobHandler, } /// Event source enum used by `#[controller(kind = Controller::EventHandler, ...)]` diff --git a/crates/sword-events/Cargo.toml b/crates/sword-events/Cargo.toml index fa0de46d..93fc38a1 100644 --- a/crates/sword-events/Cargo.toml +++ b/crates/sword-events/Cargo.toml @@ -14,7 +14,7 @@ in-memory = [] [dependencies] sword-core = { workspace = true } -sword-macros = { workspace = true, features = ["event-handlers"] } +sword-macros = { workspace = true, features = ["events"] } tokio = { workspace = true } inventory = { workspace = true } diff --git a/crates/sword-grpc/Cargo.toml b/crates/sword-grpc/Cargo.toml index e6e1a625..bb464524 100644 --- a/crates/sword-grpc/Cargo.toml +++ b/crates/sword-grpc/Cargo.toml @@ -31,7 +31,7 @@ tower = { workspace = true } sword-core = { workspace = true } sword-layers = { workspace = true, features = ["tracing", "body-limit", "request-id"] } -sword-macros = { workspace = true, features = ["grpc-controllers"] } +sword-macros = { workspace = true, features = ["grpc"] } tracing = { workspace = true } inventory = { workspace = true } diff --git a/crates/sword-jobs/Cargo.toml b/crates/sword-jobs/Cargo.toml new file mode 100644 index 00000000..69fe5da1 --- /dev/null +++ b/crates/sword-jobs/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "sword-jobs" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +rust-version.workspace = true + +[dependencies] +tokio-cron-scheduler = "0.15.1" + +sword-core = { workspace = true } +sword-macros = { workspace = true, features = ["jobs"] } +tracing = { workspace = true } +inventory = { workspace = true } +uuid = { workspace = true } diff --git a/crates/sword-jobs/src/lib.rs b/crates/sword-jobs/src/lib.rs new file mode 100644 index 00000000..8543d130 --- /dev/null +++ b/crates/sword-jobs/src/lib.rs @@ -0,0 +1,47 @@ +mod runtime; + +use std::any::TypeId; +use sword_core::State; +use tokio_cron_scheduler::Job; + +pub use runtime::JobSchedulerRuntime; + +/// The kind of schedule declared by a job handler. +pub enum JobKind { + Cron, + Repeated, + Once, +} + +/// Builds the `Job` for a single handler method. +pub type JobHandlerFn = fn(&State) -> Job; + +/// Builds a job controller into the `State`. +pub struct JobControllerRegistrar { + pub type_id: TypeId, + pub build_fn: fn(&State), +} + +/// Registers a single job belonging to a controller. +pub struct JobFunctionRegistrar { + pub handler: JobHandlerFn, + pub controller_type_id: TypeId, +} + +inventory::collect!(JobControllerRegistrar); +inventory::collect!(JobFunctionRegistrar); + +pub mod prelude { + pub use sword_macros::{cron, once, repeated}; + pub use tokio_cron_scheduler::{Job, JobScheduler}; + pub use uuid::Uuid; +} + +#[doc(hidden)] +pub mod internal { + pub use super::JobHandlerFn; + pub use super::JobKind; + pub use super::JobSchedulerRuntime; + pub use super::{JobControllerRegistrar, JobFunctionRegistrar}; + pub use tokio_cron_scheduler::Job; +} diff --git a/crates/sword-jobs/src/runtime.rs b/crates/sword-jobs/src/runtime.rs new file mode 100644 index 00000000..b319636c --- /dev/null +++ b/crates/sword-jobs/src/runtime.rs @@ -0,0 +1,88 @@ +use crate::{JobControllerRegistrar, JobFunctionRegistrar}; + +use std::{any::TypeId, collections::HashSet}; +use sword_core::{Controller, EngineBuildContext, State, sword_error}; +use tokio_cron_scheduler::JobScheduler; + +/// Runtime that builds job controllers, registers their jobs and keeps the +/// scheduler alive. +/// +/// The scheduler is created lazily in [`Self::start`] because its constructor +/// is async, while the application builder is synchronous. +#[derive(Clone)] +pub struct JobSchedulerRuntime { + state: State, + controller_ids: HashSet, +} + +impl JobSchedulerRuntime { + /// Creates the runtime from the state and the set of job controller + /// `TypeId`s the user registered in a module. + pub fn new(state: State, controller_ids: HashSet) -> Self { + Self { + state, + controller_ids, + } + } + + /// Builds the registered controllers, adds their jobs and starts the + /// scheduler. + /// + /// Returns the running scheduler so the caller can shut it down gracefully. + pub async fn start(&self) -> JobScheduler { + tracing::info!( + target: "sword.startup.job-scheduler", + controllers = self.controller_ids.len(), + "Starting sword job scheduler" + ); + + let scheduler = JobScheduler::new().await.unwrap_or_else(|err| { + sword_error! { + title: "Unexpected error while initializing sword's job scheduler", + reason: err, + } + }); + + // Build only the controllers the user actually registered in a module. + for registrar in inventory::iter::() { + if self.controller_ids.contains(®istrar.type_id) { + (registrar.build_fn)(&self.state); + } + } + + // Register every job that belongs to a registered controller. + for registrar in inventory::iter::() { + if !self.controller_ids.contains(®istrar.controller_type_id) { + continue; + } + + let job = (registrar.handler)(&self.state); + + if let Err(err) = scheduler.add(job).await { + tracing::error!( + target: "sword.jobs", + error = %err, + "Failed to add job to the scheduler" + ); + } + } + + scheduler.start().await.unwrap_or_else(|err| { + sword_error! { + title: "Failed to start sword's job scheduler", + reason: err, + } + }); + + scheduler + } +} + +impl From for JobSchedulerRuntime { + fn from(value: EngineBuildContext) -> Self { + Self { + state: value.state, + controller_ids: value.controllers.get_by_kind(Controller::JobHandler), + } + } +} diff --git a/crates/sword-macros/Cargo.toml b/crates/sword-macros/Cargo.toml index e3eca78e..5c722d65 100644 --- a/crates/sword-macros/Cargo.toml +++ b/crates/sword-macros/Cargo.toml @@ -20,17 +20,22 @@ rustdoc-args = ["--cfg", "docsrs"] proc-macro = true [dependencies] -axum = { workspace = true, optional = true } syn = { version = "3", features = ["full"] } quote = "1" proc-macro2 = "1" heck = { workspace = true } +darling = "0.24.1" [features] default = [] + +web = [] +socketio = [] + +grpc = [] +grpc-reflection = ["grpc"] + +jobs = [] +events = [] + hot-reload = [] -web-controllers = ["dep:axum"] -socketio-controllers = [] -grpc-controllers = [] -grpc-reflection = [] -event-handlers = [] diff --git a/crates/sword-macros/src/controllers/event_handler/mod.rs b/crates/sword-macros/src/controllers/event_handler/mod.rs deleted file mode 100644 index fd7f734a..00000000 --- a/crates/sword-macros/src/controllers/event_handler/mod.rs +++ /dev/null @@ -1,70 +0,0 @@ -mod handle; - -use super::shared::{CMetaStack, ControllerStruct, ParsedControllerKind}; -use proc_macro::TokenStream; -use proc_macro2::TokenStream as TokenStream2; -use quote::quote; -use syn::Error; - -pub use handle::expand_handle; - -pub fn expand_event_handler(input: &ControllerStruct) -> syn::Result { - let ParsedControllerKind::EventHandler { source } = &input.kind else { - return Err(Error::new_spanned( - &input.name, - "Expected an EventHandler controller struct", - )); - }; - - let self_name = &input.name; - let self_fields = &input.fields; - let controller_name_str = self_name.to_string(); - - let build_impl = crate::shared::gen_build(self_name, self_fields); - let clone_impl = crate::shared::gen_clone(self_name, self_fields); - - CMetaStack::push("event_handler", "controller_name", &controller_name_str); - - let source_tokens = source.as_tokens(); - - let expanded: TokenStream2 = quote! { - #build_impl - #clone_impl - - impl ::sword::internal::events::EventHandler for #self_name {} - - impl ::sword::internal::core::ControllerSpec for #self_name { - fn kind() -> ::sword::internal::core::Controller { - ::sword::internal::core::Controller::EventHandler - } - } - - const _: () = { - ::sword::internal::inventory::submit! { - ::sword::internal::events::EventControllerRegistrar { - handler_type_id: ::std::any::TypeId::of::<#self_name>(), - source: #source_tokens, - build: |state: &::sword::internal::core::State| { - let controller = <#self_name as ::sword::internal::core::Build>::build(state) - .unwrap_or_else(|err| { - ::sword::internal::core::sword_error! { - title: "Failed to build EventHandler controller", - reason: err, - context: { - "controller" => #controller_name_str, - }, - hints: [ - "Ensure all controller dependencies are registered as providers or components", - ], - } - }); - - state.insert::<#self_name>(controller); - }, - } - } - }; - }; - - Ok(TokenStream::from(expanded)) -} diff --git a/crates/sword-macros/src/controllers/mod.rs b/crates/sword-macros/src/controllers/mod.rs deleted file mode 100644 index d51f9609..00000000 --- a/crates/sword-macros/src/controllers/mod.rs +++ /dev/null @@ -1,75 +0,0 @@ -pub mod shared; - -#[cfg(feature = "event-handlers")] -pub mod event_handler; - -#[cfg(feature = "grpc-controllers")] -pub mod grpc; - -#[cfg(feature = "socketio-controllers")] -pub mod socketio; - -#[cfg(feature = "socketio-controllers")] -pub use socketio::expand_on_handler; - -#[cfg(feature = "web-controllers")] -pub mod web; - -use proc_macro::TokenStream; -use quote::quote; -use shared::ControllerStruct; -#[cfg(any( - feature = "web-controllers", - feature = "socketio-controllers", - feature = "grpc-controllers", - feature = "event-handlers", -))] -use shared::ParsedControllerKind; -use syn::ItemStruct; - -pub fn expand_controller(attr: TokenStream, item: TokenStream) -> syn::Result { - let input = syn::parse::(item.clone())?; - let parsed_input = ControllerStruct::parse(attr, item)?; - - let controller_kind = &parsed_input.kind; - - let builder: proc_macro::TokenStream = match controller_kind { - #[cfg(feature = "web-controllers")] - ParsedControllerKind::Web { .. } => web::expand_web_controller(&parsed_input)?, - - #[cfg(feature = "socketio-controllers")] - ParsedControllerKind::SocketIo { .. } => { - socketio::expand_socketio_controller(&parsed_input)? - } - - #[cfg(feature = "grpc-controllers")] - ParsedControllerKind::Grpc { .. } => grpc::expand_grpc_controller(&parsed_input)?, - - #[cfg(feature = "event-handlers")] - ParsedControllerKind::EventHandler { .. } => { - event_handler::expand_event_handler(&parsed_input)? - } - - #[cfg(not(any( - feature = "web-controllers", - feature = "socketio-controllers", - feature = "grpc-controllers", - feature = "event-handlers", - )))] - _ => { - return Err(syn::Error::new( - proc_macro2::Span::call_site(), - "no controller feature is enabled on `sword-macros`; enable `web-controllers`, `socketio-controllers`, `grpc-controllers`, or `event-handlers`", - )); - } - }; - - let builder = proc_macro2::TokenStream::from(builder); - - let expanded = quote! { - #input - #builder - }; - - Ok(TokenStream::from(expanded)) -} diff --git a/crates/sword-macros/src/controllers/shared/mod.rs b/crates/sword-macros/src/controllers/shared/mod.rs deleted file mode 100644 index da74bc9c..00000000 --- a/crates/sword-macros/src/controllers/shared/mod.rs +++ /dev/null @@ -1,340 +0,0 @@ -#[cfg(any( - feature = "web-controllers", - feature = "socketio-controllers", - feature = "event-handlers" -))] -mod cmeta; -mod parse; - -use proc_macro2::Span; -use syn::{ - Error, Ident, LitStr, Path, Token, - parse::{Parse, ParseStream}, - spanned::Spanned, -}; - -#[cfg(any( - feature = "web-controllers", - feature = "socketio-controllers", - feature = "event-handlers" -))] -pub(crate) use cmeta::CMetaStack; -pub(crate) use parse::ControllerStruct; - -pub enum ControllerKind { - Web, - SocketIo, - Grpc, - EventHandler, -} - -// Try to parse from a path like `EventSource::Memory`. -// Mirrors the runtime `sword_core::EventSource` enum. -#[cfg(feature = "event-handlers")] -pub enum EventSourceKind { - Memory, -} - -#[cfg(feature = "event-handlers")] -impl EventSourceKind { - pub fn as_tokens(&self) -> proc_macro2::TokenStream { - match self { - Self::Memory => quote::quote! { ::sword::internal::core::EventSource::Memory }, - } - } -} - -#[derive(Default)] -pub struct ControllerArgs { - pub kind: Option, - pub path: Option, - pub namespace: Option, - pub service: Option, - #[cfg(feature = "event-handlers")] - pub source: Option, -} - -pub enum ParsedControllerKind { - #[cfg(feature = "web-controllers")] - Web { path: String }, - - #[cfg(feature = "socketio-controllers")] - SocketIo { namespace: String }, - - #[cfg(feature = "grpc-controllers")] - Grpc { service: Path }, - - #[cfg(feature = "event-handlers")] - EventHandler { source: EventSourceKind }, -} - -// Try to parse from a path like `Controller::Web` or `Controller::SocketIo`. -// This is a necesary heuristic to determine the controller kind without having access -// to the real `Controller` enum, which is not available at the time of parsing. -impl Parse for ControllerKind { - fn parse(input: ParseStream<'_>) -> syn::Result { - let path: Path = input.parse()?; - - let last = path - .segments - .last() - .ok_or_else(|| Error::new(path.span(), "Expected a valid controller kind"))? - .ident - .to_string(); - - match last.as_str() { - "Web" => Ok(Self::Web), - "SocketIo" => Ok(Self::SocketIo), - "Grpc" => Ok(Self::Grpc), - "EventHandler" => Ok(Self::EventHandler), - _ => Err(Error::new( - path.span(), - "Invalid controller kind. Expected `Web`, `SocketIo`, `Grpc`, or `EventHandler`", - )), - } - } -} - -#[cfg(feature = "event-handlers")] -impl Parse for EventSourceKind { - fn parse(input: ParseStream<'_>) -> syn::Result { - let path: Path = input.parse()?; - - let last = path - .segments - .last() - .ok_or_else(|| Error::new(path.span(), "Expected a valid event source"))? - .ident - .to_string(); - - match last.as_str() { - "Memory" => Ok(Self::Memory), - _ => Err(Error::new( - path.span(), - "Invalid event source. Expected `EventSource::Memory`", - )), - } - } -} - -// Parse arguments like `kind = Controller::Web, path = "/api"` from the attribute input. -// This allows us to support a flexible syntax for specifying controller arguments, while also -// providing good error messages for missing or invalid arguments. -impl Parse for ControllerArgs { - fn parse(input: ParseStream<'_>) -> syn::Result { - let mut out = Self::default(); - - while !input.is_empty() { - let key: Ident = input.parse()?; - let key_span = key.span(); - let key_str = key.to_string(); - - input.parse::()?; - - match key_str.as_str() { - "kind" => { - if out.kind.is_some() { - return Err(Error::new(key_span, "Duplicate argument `kind`")); - } - out.kind = Some(input.parse()?); - } - "path" => { - if out.path.is_some() { - return Err(Error::new(key_span, "Duplicate argument `path`")); - } - out.path = Some(input.parse()?); - } - "namespace" => { - if out.namespace.is_some() { - return Err(Error::new(key_span, "Duplicate argument `namespace`")); - } - out.namespace = Some(input.parse()?); - } - "service" => { - if out.service.is_some() { - return Err(Error::new(key_span, "Duplicate argument `service`")); - } - out.service = Some(input.parse()?); - } - #[cfg(feature = "event-handlers")] - "source" => { - if out.source.is_some() { - return Err(Error::new(key_span, "Duplicate argument `source`")); - } - out.source = Some(input.parse()?); - } - _ => { - return Err(Error::new(key_span, "Unknown controller argument")); - } - } - - if input.is_empty() { - break; - } - - input.parse::()?; - } - - Ok(out) - } -} - -// Convert the parsed `ControllerArgs` into a more specific `ParsedControllerKind`, -// validating the presence and format of required arguments based on the controller kind. -// This step is crucial for providing clear error messages when required arguments are missing or invalid, -// and for ensuring that the controller kind is correctly determined based on the provided arguments. -impl TryFrom for ParsedControllerKind { - type Error = syn::Error; - - fn try_from(args: ControllerArgs) -> Result { - let kind = args - .kind - .ok_or_else(|| Error::new(Span::call_site(), "Missing required argument `kind`"))?; - - match kind { - ControllerKind::Web => { - if let Some(service) = args.service { - return Err(Error::new(service.span(), "`service` is not valid for Web")); - } - - let path = args - .path - .ok_or_else(|| Error::new(Span::call_site(), "Web requires `path`"))?; - - if let Some(namespace) = args.namespace { - return Err(Error::new( - namespace.span(), - "`namespace` is not valid for Web", - )); - } - - let path = path.value(); - - if !path.starts_with('/') { - return Err(syn::Error::new( - Span::call_site(), - "Path must start with '/'", - )); - } - - #[cfg(not(feature = "web-controllers"))] - { - Err(Error::new( - Span::call_site(), - "Web controllers require enabling the `web-controllers` feature", - )) - } - - #[cfg(feature = "web-controllers")] - Ok(ParsedControllerKind::Web { path }) - } - - ControllerKind::SocketIo => { - if let Some(service) = args.service { - return Err(Error::new( - service.span(), - "`service` is not valid for SocketIo", - )); - } - - let namespace = args.namespace.ok_or_else(|| { - Error::new(Span::call_site(), "SocketIo requires `namespace`") - })?; - - if let Some(path) = args.path { - return Err(Error::new(path.span(), "`path` is not valid for SocketIo")); - } - - let namespace = namespace.value(); - - if !namespace.starts_with('/') { - return Err(Error::new( - Span::call_site(), - "Namespace must start with '/'", - )); - } - - #[cfg(not(feature = "socketio-controllers"))] - { - Err(Error::new( - Span::call_site(), - "Socket.IO controllers require enabling the `socketio-controllers` feature", - )) - } - - #[cfg(feature = "socketio-controllers")] - Ok(ParsedControllerKind::SocketIo { namespace }) - } - - ControllerKind::Grpc => { - if let Some(path) = args.path { - return Err(Error::new(path.span(), "`path` is not valid for Grpc")); - } - - if let Some(namespace) = args.namespace { - return Err(Error::new( - namespace.span(), - "`namespace` is not valid for Grpc", - )); - } - - #[cfg(not(feature = "grpc-controllers"))] - { - let _ = args.service; - Err(Error::new( - Span::call_site(), - "gRPC controllers require enabling the `grpc-controllers` feature", - )) - } - - #[cfg(feature = "grpc-controllers")] - let service = args - .service - .ok_or_else(|| Error::new(Span::call_site(), "Grpc requires `service`"))?; - - #[cfg(feature = "grpc-controllers")] - Ok(ParsedControllerKind::Grpc { service }) - } - - ControllerKind::EventHandler => { - if let Some(path) = args.path { - return Err(Error::new( - path.span(), - "`path` is not valid for EventHandler", - )); - } - - if let Some(service) = args.service { - return Err(Error::new( - service.span(), - "`service` is not valid for EventHandler", - )); - } - - if let Some(namespace) = args.namespace { - return Err(Error::new( - namespace.span(), - "`namespace` is not valid for EventHandler; specify the full event key in `#[handle(\"...\")]` instead", - )); - } - - #[cfg(not(feature = "event-handlers"))] - { - Err(Error::new( - Span::call_site(), - "EventHandler controllers require enabling the `event-handlers` feature", - )) - } - - #[cfg(feature = "event-handlers")] - { - let source = args.source.ok_or_else(|| { - Error::new(Span::call_site(), "EventHandler requires `source`") - })?; - - Ok(ParsedControllerKind::EventHandler { source }) - } - } - } - } -} diff --git a/crates/sword-macros/src/controllers/shared/parse.rs b/crates/sword-macros/src/controllers/shared/parse.rs deleted file mode 100644 index ea99530f..00000000 --- a/crates/sword-macros/src/controllers/shared/parse.rs +++ /dev/null @@ -1,37 +0,0 @@ -use proc_macro::TokenStream; -use syn::{Ident, ItemStruct, Type}; - -use super::{ControllerArgs, ParsedControllerKind}; -use crate::{interceptor::InterceptorArgs, shared::StructFields}; - -pub struct ControllerStruct { - pub name: Ident, - pub kind: ParsedControllerKind, - pub fields: Vec<(Ident, Type)>, - pub interceptors: Vec, -} - -impl ControllerStruct { - pub fn parse(attr: TokenStream, item: TokenStream) -> syn::Result { - let input = syn::parse::(item)?; - let fields = StructFields::parse(&input)?; - - let controller_args = syn::parse::(attr)?; - let kind = ParsedControllerKind::try_from(controller_args)?; - - let mut interceptors = Vec::new(); - - for attr in &input.attrs { - if attr.path().is_ident("interceptor") { - interceptors.push(attr.parse_args::()?); - } - } - - Ok(Self { - name: input.ident, - kind, - fields, - interceptors, - }) - } -} diff --git a/crates/sword-macros/src/controllers/socketio/generation.rs b/crates/sword-macros/src/controllers/socketio/generation.rs deleted file mode 100644 index cdccae4d..00000000 --- a/crates/sword-macros/src/controllers/socketio/generation.rs +++ /dev/null @@ -1,176 +0,0 @@ -use crate::{ - controllers::shared::{ControllerStruct, ParsedControllerKind}, - shared::{gen_build, gen_clone}, -}; - -use proc_macro2::TokenStream; -use quote::quote; -use syn::{Error, Path}; - -pub fn generate_socketio_controller_builder( - input: &ControllerStruct, - interceptors: &[Path], -) -> syn::Result { - let ParsedControllerKind::SocketIo { namespace } = &input.kind else { - return Err(Error::new_spanned( - &input.name, - "Expected a Socket.IO controller struct", - )); - }; - - let self_name = &input.name; - let self_fields = &input.fields; - let controller_name_str = self_name.to_string(); - - let build_impl = gen_build(self_name, self_fields); - let clone_impl = gen_clone(self_name, self_fields); - - let interceptor_applications = interceptors.iter().rev().map(|interceptor_path| { - quote! { - let interceptor = state.borrow::<#interceptor_path>() - .unwrap_or_else(|err| { - ::sword::internal::core::sword_error!( - title: "Failed to retrieve Socket.IO interceptor from State", - reason: err, - context: { - "interceptor" => stringify!(#interceptor_path), - }, - hints: ["Ensure the interceptor is registered and built before controller setup"], - ) - }); - - let handler = handler.with(move |ctx: ::sword::socketio::SocketContext| { - let interceptor = ::std::sync::Arc::clone(&interceptor); - async move { - <#interceptor_path as ::sword::socketio::OnConnect>::on_connect(&*interceptor, ctx) - .await - .map_err(|e| ::std::boxed::Box::new(e) as ::std::boxed::Box) - } - }); - } - }); - - let setup_impl = quote! { - #[doc(hidden)] - pub fn __socketio_setup(state: &::sword::internal::core::State) { - use ::sword::internal::socketio::ConnectHandler; - - let controller = ::std::sync::Arc::new( - <#self_name as ::sword::internal::core::Build>::build(state).unwrap_or_else(|err| { - ::sword::internal::core::sword_error! { - title: "Failed to build Socket.IO controller", - reason: err, - context: { - "controller" => #controller_name_str, - }, - hints: ["Ensure all controller dependencies are registered as providers or components"], - } - }) - ); - - let io = state.get::<::sword::socketio::SocketIo>() - .unwrap_or_else(|err| { - ::sword::internal::core::sword_error! { - title: "Socket.IO component not found in application state", - reason: err, - context: { - "controller" => #controller_name_str, - }, - hints: [ - "Enable the `socketio-controllers` feature in Cargo.toml", - "Configure the socketio server section in your configuration file", - ], - } - }); - - let controller_type_id = ::std::any::TypeId::of::<#self_name>(); - let mut connection_handler: ::std::option::Option<::sword::internal::socketio::HandlerRegistrar> = None; - let mut message_handlers = ::std::vec::Vec::new(); - - for handler_meta in ::sword::internal::inventory::iter::<::sword::internal::socketio::HandlerRegistrar>() { - if handler_meta.controller_type_id != controller_type_id { - continue; - } - - match handler_meta.event_kind { - ::sword::internal::socketio::SocketEventKind::Connection => { - if connection_handler.is_some() { - ::sword::internal::core::sword_error!( - title: "Multiple connection handlers found in Socket.IO controller", - reason: "Only one #[on(\"connection\")] handler is allowed per controller", - context: { - "controller" => #controller_name_str, - }, - ); - } - connection_handler = Some(handler_meta.clone()); - }, - ::sword::internal::socketio::SocketEventKind::Message(_) - | ::sword::internal::socketio::SocketEventKind::Disconnection - | ::sword::internal::socketio::SocketEventKind::Fallback => { - message_handlers.push(handler_meta.clone()); - } - } - } - - let message_handlers: ::std::sync::Arc<[::sword::internal::socketio::HandlerRegistrar]> = - ::std::sync::Arc::from(message_handlers.into_boxed_slice()); - - let base_handler = move |ctx: ::sword::socketio::SocketContext| -> ::std::pin::Pin<::std::boxed::Box + ::std::marker::Send>> { - let socket = ctx.socket_ref().clone(); - - ::std::boxed::Box::pin(async move { - if let Some(handler) = connection_handler { - (handler.call_fn)(controller.clone(), ctx).await; - } - - for handler in message_handlers.iter() { - (handler.register_fn)(controller.clone(), socket.clone()); - } - }) - }; - - let handler = base_handler; - #(#interceptor_applications)* - - io.ns(#namespace, handler); - } - }; - - let setup_registration = quote! { - const _: () = { - ::sword::internal::inventory::submit! { - ::sword::internal::socketio::SocketIoHandlerRegistrar { - handler_type_id: ::std::any::TypeId::of::<#self_name>(), - handler_type_name: stringify!(#self_name), - setup_fn: #self_name::__socketio_setup, - } - } - }; - }; - - let expanded = quote! { - #build_impl - #clone_impl - - impl ::sword::internal::socketio::SocketIoController for #self_name { - fn namespace() -> &'static str { - #namespace - } - } - - impl ::sword::internal::core::ControllerSpec for #self_name { - fn kind() -> ::sword::internal::core::Controller { - ::sword::internal::core::Controller::SocketIo - } - } - - impl #self_name { - #setup_impl - } - - #setup_registration - }; - - Ok(expanded) -} diff --git a/crates/sword-macros/src/controllers/socketio/mod.rs b/crates/sword-macros/src/controllers/socketio/mod.rs deleted file mode 100644 index cadff9ff..00000000 --- a/crates/sword-macros/src/controllers/socketio/mod.rs +++ /dev/null @@ -1,34 +0,0 @@ -mod generation; -mod on_handler; - -use super::shared::{CMetaStack, ControllerStruct, ParsedControllerKind}; -use generation::generate_socketio_controller_builder; -use proc_macro::TokenStream; -use syn::{Error, Path}; - -pub use on_handler::expand_on_handler; - -pub fn expand_socketio_controller(input: &ControllerStruct) -> syn::Result { - let ParsedControllerKind::SocketIo { namespace } = &input.kind else { - return Err(Error::new( - input.name.span(), - "Invalid Socket.IO controller", - )); - }; - - let controller_name = input.name.to_string(); - - CMetaStack::push("socketio", "namespace", namespace); - CMetaStack::push("socketio", "controller_name", &controller_name); - - let interceptors: Vec = input - .interceptors - .iter() - .filter_map(|interceptor| interceptor.sword_path().cloned()) - .collect(); - - Ok(TokenStream::from(generate_socketio_controller_builder( - input, - &interceptors, - )?)) -} diff --git a/crates/sword-macros/src/controllers/socketio/on_handler.rs b/crates/sword-macros/src/controllers/socketio/on_handler.rs deleted file mode 100644 index f9a60a39..00000000 --- a/crates/sword-macros/src/controllers/socketio/on_handler.rs +++ /dev/null @@ -1,141 +0,0 @@ -use crate::controllers::shared::CMetaStack; -use heck::ToPascalCase; -use proc_macro::TokenStream; -use quote::{format_ident, quote}; -use syn::{ItemFn, LitStr}; - -pub fn expand_on_handler(attr: TokenStream, item: TokenStream) -> syn::Result { - if CMetaStack::get("socketio", "controller_name").is_none() { - return Ok(item); - } - - let event_lit = syn::parse::(attr)?; - let event_name = event_lit.value(); - let input_fn = syn::parse::(item)?; - - let controller_name = CMetaStack::get("socketio", "controller_name").unwrap(); - let namespace = CMetaStack::get("socketio", "namespace").ok_or_else(|| { - syn::Error::new( - proc_macro2::Span::call_site(), - "socketio namespace not found in CMetaStack", - ) - })?; - - let fn_name = &input_fn.sig.ident; - let controller_ident: syn::Ident = syn::parse_str(&controller_name)?; - - let event_kind = match event_name.as_str() { - "connection" => { - quote! { ::sword::internal::socketio::SocketEventKind::Connection } - } - "disconnection" => { - quote! { ::sword::internal::socketio::SocketEventKind::Disconnection } - } - "fallback" => { - quote! { ::sword::internal::socketio::SocketEventKind::Fallback } - } - custom => { - quote! { ::sword::internal::socketio::SocketEventKind::Message(#custom) } - } - }; - - let registration_name = format_ident!( - "__SWORD_SOCKETIO_HANDLER_{}_{}", - controller_name.replace("::", "_"), - fn_name - ); - - let fn_name_pascal = fn_name.to_string().to_pascal_case(); - - let handler_struct_name = format_ident!( - "SwordSocketIoHandler{}_{}", - controller_name.replace("::", ""), - fn_name_pascal - ); - - let handler_impl = quote! { - pub fn register_handler( - controller_any: ::std::sync::Arc, - socket: ::sword::socketio::SocketRef, - ) { - let controller = controller_any - .downcast::<#controller_ident>() - .unwrap_or_else(|_| { - ::sword::internal::core::sword_error!( - title: "Failed to downcast Socket.IO controller type", - reason: format!( - "Expected controller type {} during handler registration", - stringify!(#controller_ident) - ), - context: { - "controller" => stringify!(#controller_ident), - }, - hints: ["This indicates an internal macro invariant violation"], - ) - }); - - socket.on(#event_name, move |ctx: ::sword::socketio::SocketContext| { - let controller = ::std::sync::Arc::clone(&controller); - async move { - controller.#fn_name(ctx).await; - } - }); - } - - pub fn call_handler( - controller_any: ::std::sync::Arc, - ctx: ::sword::socketio::SocketContext, - ) -> ::std::pin::Pin<::std::boxed::Box + Send>> { - let controller = controller_any - .downcast::<#controller_ident>() - .unwrap_or_else(|_| { - ::sword::internal::core::sword_error!( - title: "Failed to downcast Socket.IO controller type", - reason: format!( - "Type mismatch while executing handler for {}", - stringify!(#controller_ident) - ), - context: { - "controller" => stringify!(#controller_ident), - }, - hints: ["This indicates an internal macro invariant violation"], - ) - }); - - ::std::boxed::Box::pin(async move { - controller.#fn_name(ctx).await; - }) - } - }; - - let inventory_registration = quote! { - #[allow(non_upper_case_globals)] - #[doc(hidden)] - const #registration_name: () = { - #[doc(hidden)] - pub struct #handler_struct_name; - - impl #handler_struct_name { - #handler_impl - } - - ::sword::internal::inventory::submit! { - ::sword::internal::socketio::HandlerRegistrar { - controller_type_id: ::std::any::TypeId::of::<#controller_ident>(), - namespace: #namespace, - event_kind: #event_kind, - method_name: stringify!(#fn_name), - register_fn: #handler_struct_name::register_handler, - call_fn: #handler_struct_name::call_handler, - } - } - }; - }; - - let expanded = quote! { - #input_fn - #inventory_registration - }; - - Ok(TokenStream::from(expanded)) -} diff --git a/crates/sword-macros/src/controllers/web/attributes/generation.rs b/crates/sword-macros/src/controllers/web/attributes/generation.rs deleted file mode 100644 index ebcfd983..00000000 --- a/crates/sword-macros/src/controllers/web/attributes/generation.rs +++ /dev/null @@ -1,296 +0,0 @@ -use super::parsing::{ParsedRouteAttribute, ReturnKind}; -use crate::controllers::web::expand_web_interceptor_args; - -use proc_macro::TokenStream as TokenStream1; -use proc_macro2::TokenStream; -use quote::{format_ident, quote}; - -pub struct WebRouteGenerator { - route: ParsedRouteAttribute, -} - -impl WebRouteGenerator { - pub fn new(route: ParsedRouteAttribute) -> Self { - Self { route } - } - - pub fn expand(self) -> TokenStream1 { - let route_fn_name = self.route_fn_name(); - let handler_with_interceptors = self.apply_route_interceptors(self.build_handler_router()); - let inventory_registration = self.build_route_registration(&route_fn_name); - let input_fn = &self.route.function; - - TokenStream1::from(quote! { - #input_fn - - pub fn #route_fn_name( - controller: std::sync::Arc, - state: ::sword::internal::core::State, - ) -> ::sword::internal::web::MethodRouter<::sword::internal::core::State> { - #handler_with_interceptors - } - - #inventory_registration - }) - } - - fn route_fn_name(&self) -> syn::Ident { - let fn_name = &self.route.function.sig.ident; - format_ident!("__sword_route_{}", fn_name) - } - - fn controller_ident(&self) -> syn::Ident { - format_ident!("{}", self.route.context.controller_name) - } - - fn build_handler_router(&self) -> TokenStream { - let routing_fn = self.route.method.routing_fn_tokens(); - let call_parts = HandlerCallParts::from_function(&self.route.function); - - let handler_body = if self.route.method.is_sse() { - self.build_sse_handler_body(&call_parts) - } else if self.route.is_result_return { - self.build_result_handler_body(&call_parts) - } else { - self.build_plain_handler_body(&call_parts) - }; - - if call_parts.has_params() { - let closure_params = &call_parts.closure_params; - - quote! { - ::sword::internal::web::routing::#routing_fn({ - let ctrl = std::sync::Arc::clone(&controller); - move |#(#closure_params),*| async move { - #handler_body - } - }) - } - } else { - quote! { - ::sword::internal::web::routing::#routing_fn({ - let ctrl = std::sync::Arc::clone(&controller); - move || async move { - #handler_body - } - }) - } - } - } - - fn build_sse_handler_body(&self, call_parts: &HandlerCallParts) -> TokenStream { - let fn_name = &call_parts.fn_name; - let call_args = &call_parts.call_args; - - let call = if call_parts.has_params() { - quote! { ctrl.#fn_name(#(#call_args),*).await } - } else { - quote! { ctrl.#fn_name().await } - }; - - if self.route.is_result_return { - quote! { - match #call { - Ok(__data) => { - use ::sword::internal::web::IntoResponse; - __data.into_response() - } - Err(__err) => { - use ::sword::internal::web::IntoResponse; - __err.into_response() - } - } - } - } else { - quote! { - use ::sword::internal::web::IntoResponse; - #call.into_response() - } - } - } - - fn build_plain_handler_body(&self, call_parts: &HandlerCallParts) -> TokenStream { - let fn_name = &call_parts.fn_name; - let call_args = &call_parts.call_args; - - if call_parts.has_params() { - quote! { - use ::sword::internal::web::IntoResponse; - ctrl.#fn_name(#(#call_args),*).await.into_response() - } - } else { - quote! { - use ::sword::internal::web::IntoResponse; - ctrl.#fn_name().await.into_response() - } - } - } - - fn build_result_handler_body(&self, call_parts: &HandlerCallParts) -> TokenStream { - let fn_name = &call_parts.fn_name; - let call_args = &call_parts.call_args; - let status = self.route.status_code; - let return_kind = self.route.return_kind; - - let call = if call_parts.has_params() { - quote! { ctrl.#fn_name(#(#call_args),*).await } - } else { - quote! { ctrl.#fn_name().await } - }; - - let ok_arm = match return_kind { - ReturnKind::Passthrough => { - quote! { - Ok(__data) => { - use ::sword::internal::web::IntoResponse; - __data.into_response() - } - } - } - ReturnKind::Serialize => { - quote! { - Ok(__data) => { - use ::sword::internal::web::IntoResponse; - ::sword::web::JsonResponse::status(#status).data(__data).into_response() - } - } - } - ReturnKind::Empty => { - quote! { - Ok(_) => { - use ::sword::internal::web::IntoResponse; - ::sword::web::JsonResponse::status(#status).into_response() - } - } - } - }; - - let err_arm = quote! { - Err(__err) => { - use ::sword::internal::web::IntoResponse; - __err.into_response() - } - }; - - quote! { - match #call { - #ok_arm, - #err_arm, - } - } - } - - fn apply_route_interceptors(&self, mut handler: TokenStream) -> TokenStream { - for interceptor in self.route.interceptors.iter().rev() { - let generated_interceptor = - expand_web_interceptor_args(interceptor, self.route.request_mode); - handler = quote! { - #handler.layer(#generated_interceptor) - }; - } - - for interceptor in self.route.context.controller_interceptors.iter().rev() { - let generated_interceptor = - expand_web_interceptor_args(interceptor, self.route.request_mode); - handler = quote! { - #handler.layer(#generated_interceptor) - }; - } - - handler - } - - fn build_route_registration(&self, route_fn_name: &syn::Ident) -> TokenStream { - let fn_name = &self.route.function.sig.ident; - let controller_ident = self.controller_ident(); - let registration_name = format_ident!( - "__SWORD_ROUTE_REGISTRAR_{}_{}", - self.route.context.controller_name.replace("::", "_"), - fn_name - ); - - let controller_name = &self.route.context.controller_name; - let route_path = &self.route.path; - let method = self.route.method.as_str(); - - quote! { - #[allow(non_upper_case_globals)] - #[doc(hidden)] - const #registration_name: () = { - ::sword::internal::inventory::submit! { - ::sword::internal::web::RouteRegistrar { - controller_id: ::std::any::TypeId::of::<#controller_ident>(), - path: #route_path, - handler: |state: ::sword::internal::core::State| -> ::sword::internal::web::MethodRouter<::sword::internal::core::State> { - let controller = - state.borrow::<#controller_ident>().unwrap_or_else(|err| { - ::sword::internal::core::sword_error!( - title: "Failed to build HTTP controller", - reason: err, - context: { - "controller" => #controller_name, - "route" => format!("{} {}", #method, #route_path), - }, - hints: ["Ensure all controller dependencies are registered in the DI container"], - ) - }); - - #controller_ident::#route_fn_name(controller, state) - }, - } - } - }; - } - } -} - -struct HandlerCallParts { - fn_name: syn::Ident, - closure_params: Vec, - call_args: Vec, -} - -impl HandlerCallParts { - fn from_function(function: &syn::ItemFn) -> Self { - let fn_name = function.sig.ident.clone(); - - let params: Vec<_> = function - .sig - .inputs - .iter() - .filter_map(|arg| { - if let syn::FnArg::Typed(pat_type) = arg { - Some(pat_type.ty.clone()) - } else { - None - } - }) - .collect(); - - let closure_params = params - .iter() - .enumerate() - .map(|(i, ty)| { - let param_name = format_ident!("p{}", i); - quote! { #param_name: #ty } - }) - .collect(); - - let call_args = (0..params.len()) - .map(|i| { - let param_name = format_ident!("p{}", i); - quote! { #param_name } - }) - .collect(); - - Self { - fn_name, - closure_params, - call_args, - } - } - - fn has_params(&self) -> bool { - !self.closure_params.is_empty() - } -} diff --git a/crates/sword-macros/src/controllers/web/attributes/mod.rs b/crates/sword-macros/src/controllers/web/attributes/mod.rs deleted file mode 100644 index c719c27a..00000000 --- a/crates/sword-macros/src/controllers/web/attributes/mod.rs +++ /dev/null @@ -1,21 +0,0 @@ -mod generation; -pub(crate) mod parsing; - -use proc_macro::TokenStream; - -use crate::controllers::shared::CMetaStack; -use generation::WebRouteGenerator; -pub use parsing::{ParsedRouteAttribute, RequestMode}; - -pub fn attribute(attribute_str: &str, attr: TokenStream, item: TokenStream) -> TokenStream { - if CMetaStack::get("web", "controller_name").is_none() { - return item; - } - - let parsed = match ParsedRouteAttribute::parse(attribute_str, attr, item) { - Ok(p) => p, - Err(e) => return e.to_compile_error().into(), - }; - - WebRouteGenerator::new(parsed).expand() -} diff --git a/crates/sword-macros/src/controllers/web/attributes/parsing.rs b/crates/sword-macros/src/controllers/web/attributes/parsing.rs deleted file mode 100644 index 33daabf5..00000000 --- a/crates/sword-macros/src/controllers/web/attributes/parsing.rs +++ /dev/null @@ -1,352 +0,0 @@ -use crate::{controllers::shared::CMetaStack, interceptor::InterceptorArgs}; -use proc_macro::TokenStream; -use proc_macro2::{Span, TokenStream as TokenStream2}; -use quote::quote; -use syn::spanned::Spanned; -use syn::{Attribute, GenericArgument, ItemFn, LitStr, PathArguments, ReturnType, Type}; - -#[derive(Clone, Copy, PartialEq, Eq)] -pub enum RequestMode { - None, - Buffered, - Streaming, -} - -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum ReturnKind { - Passthrough, - Serialize, - Empty, -} - -#[derive(Clone, Copy, PartialEq, Eq)] -pub enum HttpMethod { - Get, - Post, - Put, - Delete, - Patch, - Head, - Options, - Trace, - Connect, - Sse, -} - -impl HttpMethod { - pub fn from_attr_name(method: &str) -> syn::Result { - match method { - "GET" => Ok(Self::Get), - "POST" => Ok(Self::Post), - "PUT" => Ok(Self::Put), - "DELETE" => Ok(Self::Delete), - "PATCH" => Ok(Self::Patch), - "HEAD" => Ok(Self::Head), - "OPTIONS" => Ok(Self::Options), - "TRACE" => Ok(Self::Trace), - "CONNECT" => Ok(Self::Connect), - "SSE" => Ok(Self::Sse), - _ => Err(syn::Error::new( - Span::call_site(), - format!("Unsupported HTTP method `{method}`"), - )), - } - } - - pub fn as_str(self) -> &'static str { - match self { - Self::Get => "GET", - Self::Post => "POST", - Self::Put => "PUT", - Self::Delete => "DELETE", - Self::Patch => "PATCH", - Self::Head => "HEAD", - Self::Options => "OPTIONS", - Self::Trace => "TRACE", - Self::Connect => "CONNECT", - Self::Sse => "SSE", - } - } - - pub fn routing_fn_tokens(self) -> TokenStream2 { - match self { - Self::Get => quote! { get }, - Self::Post => quote! { post }, - Self::Put => quote! { put }, - Self::Delete => quote! { delete }, - Self::Patch => quote! { patch }, - Self::Head => quote! { head }, - Self::Options => quote! { options }, - Self::Trace => quote! { trace }, - Self::Connect => quote! { connect }, - Self::Sse => quote! { get }, - } - } - - pub fn default_status_code(self) -> u16 { - match self { - Self::Post => 201, - _ => 200, - } - } - - /// Whether the route is a Server-Sent Events stream. - /// - /// SSE routes are served over GET but must remain streaming responses, so the - /// generator treats them as passthrough regardless of the return type. - pub fn is_sse(self) -> bool { - matches!(self, Self::Sse) - } -} - -pub struct WebRouteContext { - pub controller_name: String, - pub controller_interceptors: Vec, -} - -impl WebRouteContext { - fn from_cmeta(method: &str) -> syn::Result { - let Some(controller_name) = CMetaStack::get("web", "controller_name") else { - let error = format!( - "\n[ERROR] The #[{}] attribute must be used inside a #[controller] impl block.\n\ - \n\ - Make sure:\n\ - 1. The struct has #[controller(kind = Controller::Web, path = \"/path\")] attribute\n\ - 2. The struct is defined BEFORE the impl block\n\ - 3. The impl block is for the same struct\n", - method - ); - - return Err(syn::Error::new(Span::call_site(), error)); - }; - - let controller_interceptors = CMetaStack::get_list("web", "controller_interceptors") - .unwrap_or_default() - .into_iter() - .map(|interceptor| syn::parse_str::(&interceptor)) - .collect::>>()?; - - Ok(Self { - controller_name, - controller_interceptors, - }) - } -} - -/// Parsed data from a route attribute -pub struct ParsedRouteAttribute { - /// The HTTP method (GET, POST, etc.) - pub method: HttpMethod, - - /// The route path (e.g., "/", "/:id") - pub path: String, - - /// The original function with interceptor attributes removed - pub function: ItemFn, - - /// Parsed interceptor arguments - pub interceptors: Vec, - - /// Request extraction mode inferred from function signature. - pub request_mode: RequestMode, - - /// Controller metadata from CMetaStack - pub context: WebRouteContext, - - /// Whether the handler returns Result or WebResult - pub is_result_return: bool, - - /// How to wrap T when the handler returns Result - pub return_kind: ReturnKind, - - /// Default status code for the response (depends on HTTP method) - pub status_code: u16, -} - -impl ParsedRouteAttribute { - pub fn parse(method: &str, attr: TokenStream, item: TokenStream) -> syn::Result { - let method = HttpMethod::from_attr_name(method)?; - let path = Self::parse_path(attr)?; - let mut input_fn = Self::parse_function(item)?; - let request_mode = Self::infer_request_mode(&input_fn)?; - - let (interceptors, retained_attrs) = Self::extract_interceptors(&input_fn)?; - input_fn.attrs = retained_attrs; - - let context = Self::resolve_context(method.as_str())?; - - let (is_result_return, return_kind, status_code) = - Self::extract_result_info(&input_fn.sig, &method); - - Ok(Self { - method, - path, - function: input_fn, - interceptors, - request_mode, - context, - is_result_return, - return_kind, - status_code, - }) - } - - fn parse_path(attr: TokenStream) -> syn::Result { - if attr.is_empty() { - return Err(syn::Error::new( - Span::call_site(), - "Route path is required, e.g., #[get(\"/path\")]. If you want the root path, use \"/\".", - )); - } - - Ok(syn::parse::(attr)?.value()) - } - - fn parse_function(item: TokenStream) -> syn::Result { - syn::parse::(item) - } - - fn extract_interceptors( - input_fn: &ItemFn, - ) -> syn::Result<(Vec, Vec)> { - let mut interceptors = Vec::new(); - let mut retained_attrs = Vec::new(); - - for attr in input_fn.attrs.iter() { - if attr.path().is_ident("interceptor") { - interceptors.push(attr.parse_args::()?); - } else { - retained_attrs.push(attr.clone()); - } - } - - Ok((interceptors, retained_attrs)) - } - - fn resolve_context(method: &str) -> syn::Result { - WebRouteContext::from_cmeta(method) - } - - fn infer_request_mode(input_fn: &ItemFn) -> syn::Result { - let mut mode = RequestMode::None; - - for arg in &input_fn.sig.inputs { - let syn::FnArg::Typed(pat_type) = arg else { - continue; - }; - - let arg_mode = Self::request_mode_from_type(&pat_type.ty); - - if arg_mode == RequestMode::None { - continue; - } - - if mode != RequestMode::None && mode != arg_mode { - return Err(syn::Error::new( - pat_type.ty.span(), - "A route handler cannot use both `Request` and `StreamRequest` in the same signature", - )); - } - - mode = arg_mode; - } - - Ok(mode) - } - - fn request_mode_from_type(ty: &Type) -> RequestMode { - let Type::Path(type_path) = ty else { - return RequestMode::None; - }; - - let Some(last_segment) = type_path.path.segments.last() else { - return RequestMode::None; - }; - - if last_segment.ident == "Request" { - return RequestMode::Buffered; - } - - if last_segment.ident == "StreamRequest" { - return RequestMode::Streaming; - } - - RequestMode::None - } - - fn extract_result_info(sig: &syn::Signature, method: &HttpMethod) -> (bool, ReturnKind, u16) { - let status_code = method.default_status_code(); - - let return_type = match &sig.output { - ReturnType::Type(_, ty) => ty.as_ref(), - _ => return (false, ReturnKind::Passthrough, status_code), - }; - - let type_path = match return_type { - Type::Path(type_path) => type_path, - _ => return (false, ReturnKind::Passthrough, status_code), - }; - - let last_segment = match type_path.path.segments.last() { - Some(seg) => seg, - None => return (false, ReturnKind::Passthrough, status_code), - }; - - let ident = last_segment.ident.to_string(); - - let ok_type = match ident.as_str() { - "Result" => { - let args = match &last_segment.arguments { - PathArguments::AngleBracketed(args) => &args.args, - _ => return (false, ReturnKind::Passthrough, status_code), - }; - if args.len() < 2 { - return (false, ReturnKind::Passthrough, status_code); - } - match &args[0] { - GenericArgument::Type(ty) => ty.clone(), - _ => return (false, ReturnKind::Passthrough, status_code), - } - } - "WebResult" => { - match &last_segment.arguments { - PathArguments::AngleBracketed(args) => { - if args.args.is_empty() { - // WebResult without generics = WebResult - syn::parse_quote!(JsonResponse) - } else { - match &args.args[0] { - GenericArgument::Type(ty) => ty.clone(), - _ => return (false, ReturnKind::Passthrough, status_code), - } - } - } - _ => { - // WebResult without generics = WebResult - syn::parse_quote!(JsonResponse) - } - } - } - _ => return (false, ReturnKind::Passthrough, status_code), - }; - - let return_kind = Self::classify_return_type(&ok_type); - (true, return_kind, status_code) - } - - fn classify_return_type(ty: &Type) -> ReturnKind { - match ty { - Type::Tuple(tuple) if tuple.elems.is_empty() => ReturnKind::Empty, - Type::Path(type_path) => { - let Some(last_segment) = type_path.path.segments.last() else { - return ReturnKind::Serialize; - }; - match last_segment.ident.to_string().as_str() { - "JsonResponse" | "File" | "Redirect" | "Sse" => ReturnKind::Passthrough, - _ => ReturnKind::Serialize, - } - } - _ => ReturnKind::Serialize, - } - } -} diff --git a/crates/sword-macros/src/controllers/web/interceptor.rs b/crates/sword-macros/src/controllers/web/interceptor.rs deleted file mode 100644 index 8bc65dde..00000000 --- a/crates/sword-macros/src/controllers/web/interceptor.rs +++ /dev/null @@ -1,140 +0,0 @@ -use proc_macro2::TokenStream; -use quote::quote; - -use crate::controllers::web::attributes::RequestMode; -use crate::interceptor::InterceptorArgs; - -/// Expands interceptor arguments into the appropriate runtime code. -/// -/// 1. **Simple interceptor** (`#[interceptor(MyInterceptor)]`): -/// - Requires `MyInterceptor` to implement `OnRequest` or `OnRequestStream` -/// -/// 2. **Configured interceptor** (`#[interceptor(MyInterceptor, config = ...)]`): -/// - Requires `MyInterceptor` to implement -/// `OnRequestWithConfig` or `OnRequestStreamWithConfig` -pub fn expand_web_interceptor_args( - args: &InterceptorArgs, - request_mode: RequestMode, -) -> TokenStream { - let mode = ModeTokens::from_request_mode(request_mode); - - match args { - InterceptorArgs::Traditional(path) => generate_sword_simple(path, &mode), - InterceptorArgs::WithConfig { - interceptor, - config, - } => generate_sword_configured(interceptor, config, &mode), - InterceptorArgs::Expression(expr) => { - quote! { #expr } - } - } -} - -struct ModeTokens { - req_ty: TokenStream, - simple_trait: TokenStream, - configured_trait: TokenStream, -} - -impl ModeTokens { - fn from_request_mode(request_mode: RequestMode) -> Self { - match request_mode { - RequestMode::Streaming => Self { - req_ty: quote! { ::sword::web::StreamRequest }, - simple_trait: quote! { ::sword::web::OnRequestStream }, - configured_trait: quote! { ::sword::web::OnRequestStreamWithConfig }, - }, - _ => Self { - req_ty: quote! { ::sword::web::Request }, - simple_trait: quote! { ::sword::web::OnRequest }, - configured_trait: quote! { ::sword::web::OnRequestWithConfig }, - }, - } - } -} - -fn generate_sword_simple(path: &syn::Path, mode: &ModeTokens) -> TokenStream { - let req_ty = &mode.req_ty; - let simple_trait = &mode.simple_trait; - - quote! { - { - fn __check_on_request(mw: &M) -> &M { mw } - - let middleware = state.borrow::<#path>() - .unwrap_or_else(|err| { - ::sword::internal::core::sword_error!( - title: "Failed to retrieve HTTP interceptor from State", - reason: err, - context: { - "interceptor" => stringify!(#path), - }, - hints: ["Ensure the interceptor is registered and built before controller initialization"], - ) - }); - - let _ = __check_on_request(&*middleware); - - ::sword::internal::web::mw_with_state( - state.clone(), - move |mut req: #req_ty, next: ::sword::web::Next| { - req.set_next(next); - - let mw = ::std::sync::Arc::clone(&middleware); - async move { - <#path as #simple_trait>::on_request(&*mw, req).await - } - } - ) - } - } -} - -fn generate_sword_configured( - middleware_ty: &syn::Path, - config: &syn::Expr, - mode: &ModeTokens, -) -> TokenStream { - let req_ty = &mode.req_ty; - let configured_trait = &mode.configured_trait; - - quote! { - { - fn __check_on_request_configured(mw: &M) -> &M - where - M: #configured_trait - { - mw - } - - let middleware = state.borrow::<#middleware_ty>() - .unwrap_or_else(|err| { - ::sword::internal::core::sword_error!( - title: "Failed to retrieve HTTP interceptor from State", - reason: err, - context: { - "interceptor" => stringify!(#middleware_ty), - }, - hints: ["Ensure the interceptor is registered and built before controller initialization"], - ) - }); - - let _ = __check_on_request_configured::<#middleware_ty, _>(&*middleware); - - ::sword::internal::web::mw_with_state( - state.clone(), - move |mut req: #req_ty, next: ::sword::web::Next| { - req.set_next(next); - let mw = ::std::sync::Arc::clone(&middleware); - async move { - <#middleware_ty as #configured_trait<_>>::on_request( - &*mw, - #config, - req, - ).await - } - } - ) - } - } -} diff --git a/crates/sword-macros/src/controllers/web/mod.rs b/crates/sword-macros/src/controllers/web/mod.rs deleted file mode 100644 index 63cd7ca2..00000000 --- a/crates/sword-macros/src/controllers/web/mod.rs +++ /dev/null @@ -1,85 +0,0 @@ -pub mod attributes; -mod interceptor; - -use super::shared::{CMetaStack, ControllerStruct}; -use crate::controllers::shared::ParsedControllerKind; -use crate::shared::{gen_build, gen_clone}; - -use proc_macro::TokenStream; -use quote::quote; -use syn::Error; - -pub use interceptor::*; - -pub fn expand_web_controller(input: &ControllerStruct) -> syn::Result { - let ParsedControllerKind::Web { path } = &input.kind else { - return Err(Error::new(input.name.span(), "Expected a web controller")); - }; - - let ControllerStruct { - name: self_name, - fields: self_fields, - interceptors: controller_interceptors, - .. - } = input; - - let serialized_controller_interceptors: Vec<_> = controller_interceptors - .iter() - .map(|interceptor| interceptor.to_token_stream().to_string()) - .collect(); - - CMetaStack::push("web", "controller_path", path); - CMetaStack::push("web", "controller_name", &self_name.to_string()); - CMetaStack::push_list( - "web", - "controller_interceptors", - serialized_controller_interceptors, - ); - - let build_impl = gen_build(self_name, self_fields); - let clone_impl = gen_clone(self_name, self_fields); - - let builder = quote! { - #build_impl - #clone_impl - - ::sword::internal::inventory::submit! { - ::sword::internal::web::WebControllerRegistrar { - controller_id: ::std::any::TypeId::of::<#self_name>(), - controller_path: #path, - build: |state: &::sword::internal::core::State| { - state.insert::<#self_name>(#self_name::build(state).unwrap_or_else(|e| { - ::sword::internal::core::sword_error! { - title: "Failed to build controller", - reason: format!("Failed to build web controller dependency graph: {e}"), - context: { - "controller_name" => stringify!(#self_name), - "error" => format!("{e:?}"), - "source" => "WebControllerRegistrar::build", - }, - hints: ["Check the error message for details on what went wrong during construction"], - } - })); - }, - } - } - - impl ::sword::internal::web::WebController for #self_name { - fn base_path() -> &'static str { - #path - } - } - - impl ::sword::internal::core::ControllerSpec for #self_name { - fn kind() -> ::sword::internal::core::Controller { - ::sword::internal::core::Controller::Web - } - } - }; - - let expanded = quote! { - #builder - }; - - Ok(TokenStream::from(expanded)) -} diff --git a/crates/sword-macros/src/core/config.rs b/crates/sword-macros/src/core/config.rs index b9862fd2..455af6f5 100644 --- a/crates/sword-macros/src/core/config.rs +++ b/crates/sword-macros/src/core/config.rs @@ -1,52 +1,42 @@ +use crate::shared::{Result, paths::*}; + +use darling::{FromMeta, ast::NestedMeta}; use proc_macro::TokenStream; use quote::quote; -use syn::{DeriveInput, Error, Expr, Lit, Meta}; - -pub fn expand_config_struct(args: TokenStream, input: &DeriveInput) -> syn::Result { - let struct_name = &input.ident; - let self_ty = quote! { #struct_name }; - let meta = syn::parse::(args)?; - - let nv = meta.require_name_value().map_err(|_| { - Error::new_spanned(&meta, r#"expected format: #[config(key = "section_name")]"#) - })?; +use syn::DeriveInput; - if !nv.path.is_ident("key") { - return Err(Error::new_spanned(&nv.path, "expected `key` attribute")); - } +// `#[config(key = "literal_str")]` macro Args +#[derive(FromMeta)] +struct Args { + key: String, +} - let Expr::Lit(expr_lit) = &nv.value else { - return Err(Error::new_spanned( - &nv.value, - "expected string literal for key", - )); - }; +pub fn expand_config_struct(args: TokenStream, item: TokenStream) -> Result { + let input = syn::parse::(item)?; + let args = Args::from_list(&NestedMeta::parse_meta_list(args.into())?)?; - let Lit::Str(lit_str) = &expr_lit.lit else { - return Err(Error::new_spanned( - &expr_lit.lit, - "expected string literal for key", - )); - }; + let config_key = args.key; + let struct_name = &input.ident; + let self_ty = quote! { #struct_name }; let expanded = quote! { #input - impl ::sword::internal::core::ConfigItem for #struct_name { + impl #ConfigItem for #struct_name { fn key() -> &'static str { - #lit_str + #config_key } } - impl TryFrom<&::sword::internal::core::State> for #struct_name { - type Error = ::sword::internal::core::DependencyInjectionError; + impl TryFrom<&#State> for #struct_name { + type Error = #DependencyInjectionError; - fn try_from(state: &::sword::internal::core::State) -> Result { - let config = state.get::<::sword::internal::core::Config>() - .map_err(|_| ::sword::internal::core::DependencyInjectionError::dependency_not_found("Config"))?; + fn try_from(state: &#State) -> Result { + let config = state.get::<#Config>() + .map_err(|_| #DependencyInjectionError::dependency_not_found("Config"))?; config.get::() - .ok_or_else(|| ::sword::internal::core::DependencyInjectionError::dependency_not_found(format!("Config item '{}'", Self::key()))) + .ok_or_else(|| #DependencyInjectionError::dependency_not_found(format!("Config item '{}'", Self::key()))) } } @@ -54,17 +44,17 @@ pub fn expand_config_struct(args: TokenStream, input: &DeriveInput) -> syn::Resu // This allows automatic registration of all configs during app initialization // We use the full path to inventory through sword's re-export const _: () = { - ::sword::internal::inventory::submit! { - ::sword::internal::core::ConfigRegistrar::new(|state, config| { + #inventory::submit! { + #ConfigRegistrar::new(|state, config| { let config_item = config.get::<#self_ty>().unwrap_or_else(|| { - ::sword::internal::core::sword_error!( + #sword_error! { title: "Failed to load registered config item", - reason: format!("Config item '{}' was not found", #lit_str), + reason: format!("Config item '{}' was not found", #config_key), context: { - "key" => #lit_str, + "key" => #config_key, }, hints: ["Ensure the configuration file contains this section"], - ) + } }); state.insert(config_item); diff --git a/crates/sword-macros/src/core/controller.rs b/crates/sword-macros/src/core/controller.rs new file mode 100644 index 00000000..39ee595e --- /dev/null +++ b/crates/sword-macros/src/core/controller.rs @@ -0,0 +1,349 @@ +use crate::{ + core::{InterceptorArgs, take_interceptors}, + shared::{Error, Result, paths::*, structs::Struct}, +}; + +use darling::{FromDeriveInput, FromMeta, ast::NestedMeta}; +use proc_macro::TokenStream; +use proc_macro2::{Span, TokenStream as TokenStream2}; +use quote::quote; +use syn::{DeriveInput, Ident, LitStr, Path, Type}; + +// `#[controller(kind = ..., ...)]` model ===================================================== + +pub enum ControllerKind { + Web { path: String }, + SocketIo { namespace: String }, + Grpc { service: Path }, + EventHandler { source: EventSourceKind }, + JobHandler, +} + +pub enum EventSourceKind { + Memory, +} + +impl EventSourceKind { + pub fn as_tokens(&self) -> TokenStream2 { + match self { + Self::Memory => quote! { #EventSource::Memory }, + } + } +} + +#[derive(FromMeta)] +struct ControllerArgs { + kind: Path, + + #[darling(default)] + path: Option, + + #[darling(default)] + namespace: Option, + + #[darling(default)] + service: Option, + + #[darling(default)] + source: Option, +} + +pub struct ControllerStruct { + base: Struct, + pub kind: ControllerKind, + pub interceptors: Vec, +} + +impl ControllerStruct { + pub fn fields(&self) -> Vec<(Ident, Type)> { + self.base.fields() + } + + pub const fn ty(&self) -> &Ident { + &self.base.ident + } + + pub fn name(&self) -> String { + self.ty().to_string() + } + + pub fn span(&self) -> Span { + self.ty().span() + } +} + +// args -> kind ============================================================================== + +impl ControllerArgs { + fn resolve_kind(self) -> Result { + let name = self + .kind + .segments + .last() + .map(|segment| segment.ident.to_string()) + .unwrap_or_default(); + + match name.as_str() { + "Web" => { + if let Some(service) = &self.service { + return Err(Error::custom("`service` is not valid for Web").with_span(service)); + } + + if let Some(namespace) = &self.namespace { + return Err( + Error::custom("`namespace` is not valid for Web").with_span(namespace) + ); + } + + if let Some(source) = &self.source { + return Err(Error::custom("`source` is not valid for Web").with_span(source)); + } + + let path = self + .path + .as_ref() + .ok_or_else(|| Error::custom("Web requires `path`").with_span(&self.kind))? + .value(); + + if !path.starts_with('/') { + return Err(Error::custom("path must start with '/'").with_span(&self.kind)); + } + + #[cfg(feature = "web")] + { + Ok(ControllerKind::Web { path }) + } + + #[cfg(not(feature = "web"))] + { + Err( + Error::custom("Web controllers require enabling the `web` feature") + .with_span(&self.kind), + ) + } + } + + "SocketIo" => { + if let Some(path) = &self.path { + return Err(Error::custom("`path` is not valid for SocketIo").with_span(path)); + } + + if let Some(service) = &self.service { + return Err( + Error::custom("`service` is not valid for SocketIo").with_span(service) + ); + } + + if let Some(source) = &self.source { + return Err( + Error::custom("`source` is not valid for SocketIo").with_span(source) + ); + } + + let namespace = self + .namespace + .as_ref() + .ok_or_else(|| { + Error::custom("SocketIo requires `namespace`").with_span(&self.kind) + })? + .value(); + + if !namespace.starts_with('/') { + return Err( + Error::custom("namespace must start with '/'").with_span(&self.kind) + ); + } + + #[cfg(feature = "socketio")] + { + Ok(ControllerKind::SocketIo { namespace }) + } + + #[cfg(not(feature = "socketio"))] + { + Err(Error::custom( + "SocketIo controllers require enabling the `socketio` feature", + ) + .with_span(&self.kind)) + } + } + + "Grpc" => { + if let Some(path) = &self.path { + return Err(Error::custom("`path` is not valid for Grpc").with_span(path)); + } + + if let Some(namespace) = &self.namespace { + return Err( + Error::custom("`namespace` is not valid for Grpc").with_span(namespace) + ); + } + + if let Some(source) = &self.source { + return Err(Error::custom("`source` is not valid for Grpc").with_span(source)); + } + + let service = self.service.as_ref().ok_or_else(|| { + Error::custom("Grpc requires `service`").with_span(&self.kind) + })?; + + #[cfg(feature = "grpc")] + { + Ok(ControllerKind::Grpc { + service: service.clone(), + }) + } + + #[cfg(not(feature = "grpc"))] + { + let _ = service; + + Err( + Error::custom("Grpc controllers require enabling the `grpc` feature") + .with_span(&self.kind), + ) + } + } + + "EventHandler" => { + if let Some(path) = &self.path { + return Err( + Error::custom("`path` is not valid for EventHandler").with_span(path) + ); + } + + if let Some(namespace) = &self.namespace { + return Err(Error::custom("`namespace` is not valid for EventHandler") + .with_span(namespace)); + } + + if let Some(service) = &self.service { + return Err( + Error::custom("`service` is not valid for EventHandler").with_span(service) + ); + } + + let source = self.source.as_ref().ok_or_else(|| { + Error::custom("EventHandler requires `source`").with_span(&self.kind) + })?; + + let source_name = source + .segments + .last() + .map(|segment| segment.ident.to_string()) + .unwrap_or_default(); + + match source_name.as_str() { + "Memory" => { + #[cfg(feature = "events")] + { + Ok(ControllerKind::EventHandler { + source: EventSourceKind::Memory, + }) + } + + #[cfg(not(feature = "events"))] + { + Err(Error::custom( + "EventHandler controllers require enabling the `events` feature", + ) + .with_span(&self.kind)) + } + } + + other => { + Err(Error::custom(format!("unknown event source `{other}`")) + .with_span(source)) + } + } + } + + "JobHandler" => { + if let Some(path) = &self.path { + return Err(Error::custom("`path` is not valid for JobHandler").with_span(path)); + } + + if let Some(namespace) = &self.namespace { + return Err(Error::custom("`namespace` is not valid for JobHandler") + .with_span(namespace)); + } + + if let Some(service) = &self.service { + return Err( + Error::custom("`service` is not valid for JobHandler").with_span(service) + ); + } + + if let Some(source) = &self.source { + return Err( + Error::custom("`source` is not valid for JobHandler").with_span(source) + ); + } + + #[cfg(feature = "jobs")] + { + Ok(ControllerKind::JobHandler) + } + + #[cfg(not(feature = "jobs"))] + { + Err( + Error::custom("JobHandler controllers require enabling the `jobs` feature") + .with_span(&self.kind), + ) + } + } + + other => { + Err(Error::custom(format!("unknown controller kind `{other}`")) + .with_span(&self.kind)) + } + } + } +} + +// expansion ================================================================================= + +pub fn expand_controller(attr: TokenStream, item: TokenStream) -> Result { + let derive_input = syn::parse::(item.clone())?; + let base = Struct::from_derive_input(&derive_input)?; + + let args = ControllerArgs::from_list(&NestedMeta::parse_meta_list(attr.into())?)?; + let kind = args.resolve_kind()?; + + let interceptors = take_interceptors(&derive_input.attrs)?; + + let parsed = ControllerStruct { + base, + kind, + interceptors, + }; + + let builder: TokenStream2 = match &parsed.kind { + #[cfg(feature = "web")] + ControllerKind::Web { .. } => crate::web::expand_controller(&parsed), + + #[cfg(feature = "socketio")] + ControllerKind::SocketIo { .. } => crate::socketio::expand_controller(&parsed), + + #[cfg(feature = "grpc")] + ControllerKind::Grpc { .. } => crate::grpc::expand_controller(&parsed), + + #[cfg(feature = "events")] + ControllerKind::EventHandler { .. } => crate::events::expand_controller(&parsed), + + #[cfg(feature = "jobs")] + ControllerKind::JobHandler => crate::jobs::expand_controller(&parsed), + + #[allow(unreachable_patterns)] + _ => Err(Error::custom("no controller kind feature is enabled")), + }?; + + let struct_tokens: TokenStream2 = item.into(); + + Ok(quote! { + #struct_tokens + #builder + } + .into()) +} diff --git a/crates/sword-macros/src/core/event.rs b/crates/sword-macros/src/core/event.rs deleted file mode 100644 index bd0d8ab9..00000000 --- a/crates/sword-macros/src/core/event.rs +++ /dev/null @@ -1,59 +0,0 @@ -use proc_macro::TokenStream; -use proc_macro2::TokenStream as TokenStream2; -use quote::quote; -use syn::{DeriveInput, Error, Expr, Lit, Meta}; - -pub fn expand_event_struct(args: TokenStream, item: TokenStream) -> syn::Result { - let input = syn::parse::(item)?; - - if !matches!(input.data, syn::Data::Struct(_)) { - return Err(syn::Error::new_spanned( - &input, - "#[event] can only be applied to structs", - )); - } - - let struct_name = &input.ident; - let meta = syn::parse::(args)?; - - let nv = meta.require_name_value().map_err(|_| { - Error::new_spanned(&meta, r#"expected format: #[event(key = "event.key"])"#) - })?; - - if !nv.path.is_ident("key") { - return Err(Error::new_spanned(&nv.path, "expected `key` attribute")); - } - - let Expr::Lit(expr_lit) = &nv.value else { - return Err(Error::new_spanned( - &nv.value, - "expected string literal for key", - )); - }; - - let Lit::Str(lit_str) = &expr_lit.lit else { - return Err(Error::new_spanned( - &expr_lit.lit, - "expected string literal for key", - )); - }; - - let key = lit_str.value(); - - let expanded: TokenStream2 = quote! { - #[derive(::std::clone::Clone, ::std::fmt::Debug)] - #input - - impl ::sword::internal::events::Event for #struct_name { - fn key(&self) -> &'static str { - #key - } - - fn clone_event(&self) -> ::std::boxed::Box { - ::std::boxed::Box::new(self.clone()) - } - } - }; - - Ok(TokenStream::from(expanded)) -} diff --git a/crates/sword-macros/src/core/injectable.rs b/crates/sword-macros/src/core/injectable.rs new file mode 100644 index 00000000..e7924c58 --- /dev/null +++ b/crates/sword-macros/src/core/injectable.rs @@ -0,0 +1,105 @@ +use crate::shared::{ + Result, + paths::*, + structs::Struct, + traits::{generate_build_impl, generate_clone_impl, generate_has_deps_impl}, +}; + +use darling::{Error, FromDeriveInput, FromMeta, ast::NestedMeta, util::Flag}; +use proc_macro::TokenStream; +use proc_macro2::TokenStream as TokenStream2; +use quote::quote; +use syn::DeriveInput; + +// `#[injectable] args section ================================================================ + +#[derive(Debug, FromMeta)] +#[darling(rename_all = "snake_case")] +pub enum InjectableKind { + Provider, + Component, +} + +#[derive(FromMeta)] +pub struct Args { + pub no_derive_clone: Flag, + pub component: Flag, + pub provider: Flag, +} + +impl Args { + pub fn kind(&self) -> InjectableKind { + if self.provider.is_present() { + InjectableKind::Provider + } else { + InjectableKind::Component + } + } + + pub fn validate(&self) -> Result<()> { + if self.provider.is_present() && self.component.is_present() { + return Err(Error::custom("cannot use both `provider` and `component`") + .with_span(&self.component.span())); + } + + Ok(()) + } + + pub fn derive_clone(&self) -> bool { + !self.no_derive_clone.is_present() + } +} + +// `#[injectable] expand section =============================================================== + +pub fn expand_injectable(attr: TokenStream, item: TokenStream) -> Result { + let derive_input = syn::parse::(item.clone())?; + let parsed_struct = Struct::from_derive_input(&derive_input)?; + + let args = Args::from_list(&NestedMeta::parse_meta_list(attr.into())?)?; + + args.validate()?; + + let injectable_impl = match args.kind() { + InjectableKind::Provider => generate_provider_trait(&parsed_struct), + InjectableKind::Component => generate_component_trait(&parsed_struct), + }; + + let clone_impl = if args.derive_clone() { + generate_clone_impl(&parsed_struct.ident, &parsed_struct.fields()) + } else { + quote! {} + }; + + let struct_tokens: TokenStream2 = item.into(); + + let expanded = quote! { + #struct_tokens + #injectable_impl + #clone_impl + }; + + Ok(expanded.into()) +} + +fn generate_component_trait(item: &Struct) -> TokenStream2 { + let struct_name = &item.ident; + + let deps_impl = generate_has_deps_impl(struct_name, &item.fields()); + let build_impl = generate_build_impl(struct_name, &item.fields()); + + quote! { + #build_impl + #deps_impl + + impl #Component for #struct_name {} + } +} + +fn generate_provider_trait(item: &Struct) -> TokenStream2 { + let struct_name = &item.ident; + + quote! { + impl #Provider for #struct_name {} + } +} diff --git a/crates/sword-macros/src/core/injectable/mod.rs b/crates/sword-macros/src/core/injectable/mod.rs deleted file mode 100644 index eaa2db10..00000000 --- a/crates/sword-macros/src/core/injectable/mod.rs +++ /dev/null @@ -1,55 +0,0 @@ -mod parse; - -use crate::shared::*; -use parse::*; - -use proc_macro::TokenStream; -use proc_macro2::TokenStream as TokenStream2; -use quote::quote; -use syn::ItemStruct; - -pub fn expand_injectable(attr: TokenStream, item: TokenStream) -> syn::Result { - let input = syn::parse::(item.clone())?; - let parsed = parse_injectable_input(attr, item)?; - - let injectable_impl = match parsed.kind { - InjectableKind::Provider => generate_provider_trait(&parsed), - InjectableKind::Component => generate_component_trait(&parsed), - }; - - let clone_impl = gen_clone(&parsed.struct_name, &parsed.fields); - - let mut expanded = quote! { - #input - #injectable_impl - }; - - if parsed.derive_clone { - expanded.extend(quote! { - #clone_impl - }); - } - - Ok(expanded.into()) -} - -pub fn generate_component_trait(input: &InjectableInput) -> TokenStream2 { - let struct_name = &input.struct_name; - - let deps_impl = gen_deps(struct_name, &input.fields); - let build_impl = gen_build(struct_name, &input.fields); - - quote! { - #build_impl - #deps_impl - impl ::sword::internal::core::Component for #struct_name {} - } -} - -pub fn generate_provider_trait(parsed: &InjectableInput) -> TokenStream2 { - let struct_name = &parsed.struct_name; - - quote! { - impl ::sword::internal::core::Provider for #struct_name {} - } -} diff --git a/crates/sword-macros/src/core/injectable/parse.rs b/crates/sword-macros/src/core/injectable/parse.rs deleted file mode 100644 index a9dad50d..00000000 --- a/crates/sword-macros/src/core/injectable/parse.rs +++ /dev/null @@ -1,69 +0,0 @@ -use crate::shared::StructFields; -use proc_macro::TokenStream; -use syn::parse::{ParseStream, Result as ParseResult}; -use syn::{Ident, ItemStruct, Token, Type, parse::Parse}; - -pub enum InjectableKind { - Provider, - Component, -} - -pub struct InjectableInput { - pub struct_name: Ident, - pub fields: Vec<(Ident, Type)>, - pub derive_clone: bool, - pub kind: InjectableKind, -} - -struct InjectableArgs { - kind: InjectableKind, - derive_clone: bool, -} - -impl Parse for InjectableArgs { - fn parse(input: ParseStream) -> ParseResult { - let mut kind = InjectableKind::Component; - let mut derive_clone = true; - - if input.is_empty() { - return Ok(Self { kind, derive_clone }); - } - - while !input.is_empty() { - let arg: Ident = input.parse()?; - - match arg.to_string().as_str() { - "provider" => kind = InjectableKind::Provider, - "component" => kind = InjectableKind::Component, - "no_derive_clone" => derive_clone = false, - _ => { - return Err(syn::Error::new_spanned( - arg, - "Unknown attribute. Use 'provider', 'component', or 'no_derive_clone'", - )); - } - } - - if !input.is_empty() { - input.parse::()?; - } - } - - Ok(Self { kind, derive_clone }) - } -} - -pub fn parse_injectable_input( - attr: TokenStream, - item: TokenStream, -) -> Result { - let input = syn::parse::(item)?; - let args = syn::parse::(attr)?; - - Ok(InjectableInput { - struct_name: input.clone().ident, - fields: StructFields::parse(&input)?, - derive_clone: args.derive_clone, - kind: args.kind, - }) -} diff --git a/crates/sword-macros/src/core/interceptor.rs b/crates/sword-macros/src/core/interceptor.rs new file mode 100644 index 00000000..3066f5f7 --- /dev/null +++ b/crates/sword-macros/src/core/interceptor.rs @@ -0,0 +1,167 @@ +use crate::shared::{ + Result, + paths::*, + structs::Struct, + traits::{generate_build_impl, generate_clone_impl, generate_has_deps_impl}, +}; + +use darling::FromDeriveInput; +use proc_macro::TokenStream; +use proc_macro2::TokenStream as TokenStream2; +use quote::quote; +use syn::{ + Attribute, DeriveInput, Expr, Ident, Path, Token, + parse::{Parse, ParseStream, discouraged::Speculative}, +}; + +// #[derive(Interceptor)] macro expansion =========================================== + +pub fn expand_interceptor_derive(item: TokenStream) -> Result { + let derive_input = syn::parse::(item)?; + let parsed_struct = Struct::from_derive_input(&derive_input)?; + + let self_name = &parsed_struct.ident; + let self_fields = &parsed_struct.fields(); + + let build_impl = generate_build_impl(self_name, self_fields); + let clone_impl = generate_clone_impl(self_name, self_fields); + let deps_impl = generate_has_deps_impl(self_name, self_fields); + + let expanded = quote! { + #build_impl + #clone_impl + #deps_impl + + impl #Interceptor for #self_name {} + + #inventory::submit! { + #InterceptorRegistrar { + register: #self_name::register + } + } + }; + + Ok(TokenStream::from(expanded)) +} + +// #[interceptor()] macro parsing =================================================== + +#[derive(Clone)] +pub enum InterceptorArgs { + /// This variant represents -> `#[interceptor(Auth)]` + Traditional(Path), + + /// This variant represents -> `#[interceptor(Auth, config = expr)]` + WithConfig { interceptor: Path, config: Expr }, + + /// This variant represents -> `#[interceptor()]` (escape hatch) + Expression(Expr), +} + +pub enum SwordInterceptor { + Traditional(Path), + WithConfig { interceptor: Path, config: Expr }, +} + +impl InterceptorArgs { + /// Returns `Some(&Path)` if the provided interceptor is a sword's built in trait impl + /// like `OnConnect`, `OnRequest`, `OnRequestWithConfig` and others. + pub const fn path(&self) -> Option<&Path> { + match self { + InterceptorArgs::Traditional(path) => Some(path), + InterceptorArgs::WithConfig { interceptor, .. } => Some(interceptor), + Self::Expression(_) => None, + } + } + + /// Returns `true` if the provided interceptor is a sword's built in trait impl + /// like `OnConnect`, `OnRequest`, `OnRequestWithConfig` and others. + pub const fn is_sword_interceptor(&self) -> bool { + matches!(self, Self::Traditional(_) | Self::WithConfig { .. }) + } +} + +impl From for TokenStream2 { + fn from(args: InterceptorArgs) -> Self { + match args { + InterceptorArgs::Traditional(interceptor) => quote! { #interceptor }, + InterceptorArgs::WithConfig { + interceptor, + config, + } => { + quote! { #interceptor, config = #config } + } + InterceptorArgs::Expression(expr) => quote! { #expr }, + } + } +} + +impl Parse for SwordInterceptor { + fn parse(input: ParseStream) -> syn::Result { + // a sword interceptor always start with a path + let interceptor = input.parse::()?; + + // if after the path there's nothing: + if input.is_empty() { + return Ok(Self::Traditional(interceptor)); + } + + // if there's something else must be `, config = ` + input.parse::()?; + + let key = input.parse::()?; + + if key != "config" { + return Err(syn::Error::new(key.span(), "expected `config`")); + } + + input.parse::()?; + + let config: Expr = input.parse()?; + + if !input.is_empty() { + return Err(input.error("unexpected tokens after interceptor config")); + } + + Ok(Self::WithConfig { + interceptor, + config, + }) + } +} + +impl Parse for InterceptorArgs { + fn parse(input: ParseStream) -> syn::Result { + // first of all try to parse the input as a sword interceptor + let fork = input.fork(); + + if let Ok(parsed) = fork.parse::() { + input.advance_to(&fork); + + return Ok(match parsed { + SwordInterceptor::Traditional(path) => Self::Traditional(path), + SwordInterceptor::WithConfig { + interceptor, + config, + } => Self::WithConfig { + interceptor, + config, + }, + }); + } + + Ok(Self::Expression(input.parse()?)) + } +} + +pub fn take_interceptors(attrs: &[Attribute]) -> Result> { + let mut interceptors = Vec::new(); + + for attr in attrs { + if attr.path().is_ident("interceptor") { + interceptors.push(attr.parse_args::()?); + } + } + + Ok(interceptors) +} diff --git a/crates/sword-macros/src/core/mod.rs b/crates/sword-macros/src/core/mod.rs index faacaa99..b089fed7 100644 --- a/crates/sword-macros/src/core/mod.rs +++ b/crates/sword-macros/src/core/mod.rs @@ -1,5 +1,9 @@ -pub mod config; +mod config; +mod controller; +mod injectable; +mod interceptor; -#[cfg(feature = "event-handlers")] -pub mod event; -pub mod injectable; +pub use config::*; +pub use controller::*; +pub use injectable::*; +pub use interceptor::*; diff --git a/crates/sword-macros/src/errors/grpc_error/codegen.rs b/crates/sword-macros/src/errors/grpc_error/codegen.rs deleted file mode 100644 index b67b0043..00000000 --- a/crates/sword-macros/src/errors/grpc_error/codegen.rs +++ /dev/null @@ -1,152 +0,0 @@ -use proc_macro2::{Span, TokenStream}; -use quote::quote; -use syn::{Error, Fields, Ident}; - -use super::parse::GrpcErrorConfig; -use crate::errors::{MessageValue, validate_message_references}; - -pub struct GrpcErrorCodegen; - -impl GrpcErrorCodegen { - pub fn validate_variant_config( - enum_name: &Ident, - variant_name: &Ident, - fields: &Fields, - config: &GrpcErrorConfig, - ) -> syn::Result<()> { - if config.transparent { - crate::errors::validate_transparent_single_unnamed(variant_name, fields)?; - return Ok(()); - } - - match fields { - Fields::Named(named) => { - validate_message_references(enum_name, variant_name, named, &config.message)?; - } - Fields::Unnamed(unnamed) => { - crate::errors::validate_single_unnamed(variant_name, unnamed)?; - - if matches!( - config.message, - Some(MessageValue::Field(_) | MessageValue::Interpolated(_)) - ) { - return Err(Error::new_spanned( - variant_name, - "tuple variants do not support `message = field` or interpolated messages; use a named-field variant or build the final client message before creating the error", - )); - } - } - Fields::Unit => { - if matches!( - config.message, - Some(MessageValue::Field(_) | MessageValue::Interpolated(_)) - ) { - return Err(Error::new_spanned( - variant_name, - "unit variants do not support field-based or interpolated `message`", - )); - } - } - } - - Ok(()) - } - - pub fn generate_from_arm( - enum_name: &Ident, - variant_name: &Ident, - fields: &Fields, - config: &GrpcErrorConfig, - ) -> syn::Result { - if config.transparent { - return Ok(quote! { - #enum_name::#variant_name(inner) => ::sword::grpc::Status::from(inner), - }); - } - - let code_variant = Self::parse_code_to_tonic_variant(config.default_code(), variant_name)?; - let pattern = crate::errors::generate_pattern(enum_name, variant_name, fields); - let message_expr = Self::generate_message_expr(config, fields); - let tracing_stmt = Self::generate_tracing_stmt(variant_name, config, fields, &code_variant); - - Ok(quote! { - #pattern => { - #tracing_stmt - ::sword::grpc::Status::new(::sword::grpc::Code::#code_variant, #message_expr) - }, - }) - } - - pub fn parse_code_to_tonic_variant(code: &str, variant_name: &Ident) -> syn::Result { - let variant = match code { - "ok" => "Ok", - "cancelled" => "Cancelled", - "unknown" => "Unknown", - "invalid_argument" => "InvalidArgument", - "deadline_exceeded" => "DeadlineExceeded", - "not_found" => "NotFound", - "already_exists" => "AlreadyExists", - "permission_denied" => "PermissionDenied", - "resource_exhausted" => "ResourceExhausted", - "failed_precondition" => "FailedPrecondition", - "aborted" => "Aborted", - "out_of_range" => "OutOfRange", - "unimplemented" => "Unimplemented", - "internal" => "Internal", - "unavailable" => "Unavailable", - "data_loss" => "DataLoss", - "unauthenticated" => "Unauthenticated", - _ => { - return Err(Error::new_spanned( - variant_name, - "invalid gRPC code; use one of: ok, cancelled, unknown, invalid_argument, deadline_exceeded, not_found, already_exists, permission_denied, resource_exhausted, failed_precondition, aborted, out_of_range, unimplemented, internal, unavailable, data_loss, unauthenticated", - )); - } - }; - - Ok(Ident::new(variant, Span::call_site())) - } - - pub fn generate_message_expr(config: &GrpcErrorConfig, fields: &Fields) -> TokenStream { - crate::errors::generate_message_expr(&config.message, || match fields { - Fields::Unnamed(_) => quote! { format!("{}", _inner) }, - _ => quote! { __sword_internal_error.clone() }, - }) - } - - pub fn generate_tracing_stmt( - variant_name: &Ident, - config: &GrpcErrorConfig, - fields: &Fields, - code_variant: &Ident, - ) -> TokenStream { - let code_str = code_variant.to_string(); - - let tracing_level = config - .tracing_level - .clone() - .or_else(|| Some(default_tracing_level(&code_str).to_string())); - - crate::errors::generate_tracing_stmt( - variant_name, - &tracing_level, - fields, - &Ident::new("grpc_code", Span::call_site()), - quote! { #code_str }, - "gRPC error response", - ) - } -} - -/// Default tracing level for a gRPC code, matching the access logger's `Auto` policy: -/// `ok` → info, client errors → warn, server errors → error. -fn default_tracing_level(code_str: &str) -> &'static str { - match code_str { - "Ok" => "info", - "InvalidArgument" | "NotFound" | "AlreadyExists" | "PermissionDenied" - | "FailedPrecondition" | "OutOfRange" | "Unauthenticated" | "Aborted" | "Cancelled" => { - "warn" - } - _ => "error", - } -} diff --git a/crates/sword-macros/src/errors/grpc_error/mod.rs b/crates/sword-macros/src/errors/grpc_error/mod.rs deleted file mode 100644 index 6762ac2d..00000000 --- a/crates/sword-macros/src/errors/grpc_error/mod.rs +++ /dev/null @@ -1,52 +0,0 @@ -mod codegen; -mod parse; - -pub use parse::{parse_enum_grpc_error_config, parse_variant_grpc_error_config}; -use proc_macro2::TokenStream; -use quote::quote; -use syn::{Data, DeriveInput, Error}; - -pub fn derive_grpc_error(input: DeriveInput) -> syn::Result { - let enum_name = &input.ident; - - let Data::Enum(data) = &input.data else { - return Err(Error::new_spanned( - input, - "GrpcError can only be derived for enums", - )); - }; - - let defaults = parse_enum_grpc_error_config(&input.attrs)?; - let mut from_arms = Vec::new(); - - for variant in &data.variants { - let variant_config = parse_variant_grpc_error_config(&variant.ident, &variant.attrs)?; - let merged = variant_config.merged(&defaults); - - codegen::GrpcErrorCodegen::validate_variant_config( - enum_name, - &variant.ident, - &variant.fields, - &merged, - )?; - - from_arms.push(codegen::GrpcErrorCodegen::generate_from_arm( - enum_name, - &variant.ident, - &variant.fields, - &merged, - )?); - } - - Ok(quote! { - impl From<#enum_name> for ::sword::grpc::Status { - fn from(err: #enum_name) -> Self { - let __sword_internal_error = err.to_string(); - - match err { - #(#from_arms)* - } - } - } - }) -} diff --git a/crates/sword-macros/src/errors/grpc_error/parse.rs b/crates/sword-macros/src/errors/grpc_error/parse.rs deleted file mode 100644 index edff7877..00000000 --- a/crates/sword-macros/src/errors/grpc_error/parse.rs +++ /dev/null @@ -1,140 +0,0 @@ -use crate::errors::MessageValue; -use syn::{Attribute, Error, Ident, LitStr, Token, meta::ParseNestedMeta, spanned::Spanned}; - -#[derive(Debug, Clone, Default)] -pub struct GrpcErrorConfig { - pub transparent: bool, - pub code: Option, - pub message: Option, - pub tracing_level: Option, -} - -pub fn parse_enum_grpc_error_config(attrs: &[Attribute]) -> syn::Result { - let mut config = GrpcErrorConfig::default(); - - for attr in attrs - .iter() - .filter(|attr| attr.path().is_ident("grpc_error")) - { - config.parse_grpc_attr(attr, "grpc_error")?; - } - - config.validate_container()?; - Ok(config) -} - -pub fn parse_variant_grpc_error_config( - ident: &Ident, - attrs: &[Attribute], -) -> syn::Result { - let mut config = GrpcErrorConfig::default(); - - for attr in attrs.iter().filter(|attr| attr.path().is_ident("grpc")) { - config.parse_grpc_attr(attr, "grpc")?; - } - - for attr in attrs.iter().filter(|attr| attr.path().is_ident("tracing")) { - config.parse_tracing_attr(attr)?; - } - - config.validate_variant(ident)?; - Ok(config) -} - -impl GrpcErrorConfig { - pub fn merged(self, defaults: &GrpcErrorConfig) -> Self { - Self { - transparent: self.transparent || defaults.transparent, - code: self.code.or_else(|| defaults.code.clone()), - message: self.message.or_else(|| defaults.message.clone()), - tracing_level: self - .tracing_level - .or_else(|| defaults.tracing_level.clone()), - } - } - - pub fn default_code(&self) -> &str { - self.code.as_deref().unwrap_or("unknown") - } - - fn validate_container(&self) -> syn::Result<()> { - crate::errors::validate_transparent_container(self.transparent, "grpc") - } - - fn validate_variant(&self, ident: &Ident) -> syn::Result<()> { - let has_conflict = - self.code.is_some() || self.message.is_some() || self.tracing_level.is_some(); - - crate::errors::validate_transparent_variant( - self.transparent, - has_conflict, - ident, - "`code`, `message`, or `tracing`", - ) - } - - fn parse_grpc_attr(&mut self, attr: &Attribute, attr_name: &str) -> syn::Result<()> { - attr.parse_nested_meta(|meta| { - let ident = meta - .path - .get_ident() - .ok_or_else(|| Error::new(meta.path.span(), "expected identifier"))?; - - match ident.to_string().as_str() { - "transparent" => { - if self.transparent { - return Err(Error::new( - ident.span(), - "duplicate `transparent` attribute", - )); - } - - self.transparent = true; - Ok(()) - } - "code" => self.set_code(ident, &meta), - "message" => self.set_message(ident, &meta), - "tracing" => self.set_tracing_level(ident, &meta), - other => Err(Error::new( - ident.span(), - format!("unknown attribute `{other}` in #[{attr_name}(...)]"), - )), - } - }) - } - - fn parse_tracing_attr(&mut self, attr: &Attribute) -> syn::Result<()> { - crate::errors::parse_tracing_attr(&mut self.tracing_level, attr) - } - - fn set_code(&mut self, ident: &Ident, meta: &ParseNestedMeta) -> syn::Result<()> { - if self.code.is_some() { - return Err(Error::new(ident.span(), "duplicate `code` attribute")); - } - - self.code = Some(parse_code_value(ident, meta)?); - Ok(()) - } - - fn set_message(&mut self, ident: &Ident, meta: &ParseNestedMeta) -> syn::Result<()> { - if self.message.is_some() { - return Err(Error::new(ident.span(), "duplicate `message` attribute")); - } - - self.message = Some(MessageValue::parse(ident, meta)?); - Ok(()) - } - - fn set_tracing_level(&mut self, ident: &Ident, meta: &ParseNestedMeta) -> syn::Result<()> { - crate::errors::set_tracing_level(&mut self.tracing_level, ident, meta) - } -} - -fn parse_code_value(ident: &Ident, meta: &ParseNestedMeta) -> syn::Result { - if !meta.input.peek(Token![=]) { - return Err(Error::new(ident.span(), "expected '=' after 'code'")); - } - - meta.input.parse::()?; - Ok(meta.input.parse::()?.value()) -} diff --git a/crates/sword-macros/src/errors/http_error/codegen.rs b/crates/sword-macros/src/errors/http_error/codegen.rs deleted file mode 100644 index b4489957..00000000 --- a/crates/sword-macros/src/errors/http_error/codegen.rs +++ /dev/null @@ -1,139 +0,0 @@ -use proc_macro2::{Span, TokenStream}; -use quote::quote; -use syn::{Fields, Ident, Type}; - -use super::parse::HttpErrorConfig; -use crate::errors; - -pub struct HttpErrorCodegen; - -impl HttpErrorCodegen { - pub fn generate_from_arm( - enum_name: &Ident, - variant_name: &Ident, - fields: &Fields, - config: &HttpErrorConfig, - ) -> TokenStream { - if config.transparent { - return quote! { - #enum_name::#variant_name(inner) => ::sword::web::JsonResponse::from(inner), - }; - } - - let pattern = errors::generate_pattern(enum_name, variant_name, fields); - let tracing_stmt = Self::generate_tracing_stmt(variant_name, config, fields); - let builder = Self::generate_json_builder(config); - - quote! { - #pattern => { - #tracing_stmt - #builder - }, - } - } - - pub fn generate_json_builder(config: &HttpErrorConfig) -> TokenStream { - let status_code = config.code.as_ref().unwrap().as_u16(); - - let message_expr = errors::generate_message_expr(&config.message, || { - let default_message = config.default_message(); - quote! { #default_message } - }); - - let mut builder = quote! { - ::sword::web::JsonResponse::status(#status_code).message(#message_expr) - }; - - if let Some(field_name) = &config.error_field { - let field_ident = Ident::new(field_name, proc_macro2::Span::call_site()); - builder = quote! { #builder.error(&#field_ident) }; - } - - if let Some(field_name) = &config.errors_field { - let field_ident = Ident::new(field_name, proc_macro2::Span::call_site()); - builder = quote! { #builder.errors(&#field_ident) }; - } - - builder - } - - pub fn generate_tracing_stmt( - variant_name: &Ident, - config: &HttpErrorConfig, - fields: &Fields, - ) -> TokenStream { - let status_code = config.code.as_ref().unwrap().as_u16(); - - let tracing_level = config - .tracing_level - .clone() - .or_else(|| Some(default_tracing_level(status_code).to_string())); - - errors::generate_tracing_stmt( - variant_name, - &tracing_level, - fields, - &Ident::new("status_code", Span::call_site()), - quote! { #status_code }, - "HTTP error response", - ) - } - - pub fn generate_variant_fn(variant_name: &Ident, fields: &Fields) -> TokenStream { - let Fields::Named(named) = fields else { - return quote! {}; - }; - - let fn_name = variant_name; - - let params = named.named.iter().map(|field| { - let name = &field.ident; - let ty = &field.ty; - - if Self::is_string_type(ty) { - quote! { #name: impl Into } - } else { - quote! { #name: #ty } - } - }); - - let field_assignments = named.named.iter().map(|field| { - let name = &field.ident; - let ty = &field.ty; - - if Self::is_string_type(ty) { - quote! { #name: #name.into() } - } else { - quote! { #name } - } - }); - - quote! { - #[allow(non_snake_case)] - pub fn #fn_name(#(#params),*) -> Self { - Self::#variant_name { - #(#field_assignments),* - } - } - } - } - - fn is_string_type(ty: &Type) -> bool { - if let Type::Path(type_path) = ty - && let Some(segment) = type_path.path.segments.last() - { - return segment.ident == "String"; - } - false - } -} - -/// Default tracing level for an HTTP status code, matching the access logger's `Auto` -/// policy: 2xx/3xx → info, 4xx → warn, 5xx → error. -fn default_tracing_level(status_code: u16) -> &'static str { - match status_code { - 200..=399 => "info", - 400..=499 => "warn", - _ => "error", - } -} diff --git a/crates/sword-macros/src/errors/http_error/mod.rs b/crates/sword-macros/src/errors/http_error/mod.rs deleted file mode 100644 index f9916b34..00000000 --- a/crates/sword-macros/src/errors/http_error/mod.rs +++ /dev/null @@ -1,71 +0,0 @@ -mod codegen; -mod parse; -mod validate; - -pub use parse::HttpErrorConfig; -use proc_macro2::TokenStream; -use quote::quote; -use syn::{Data, DeriveInput, Error}; - -pub fn derive_http_error(input: DeriveInput) -> syn::Result { - let enum_name = &input.ident; - - let Data::Enum(data) = &input.data else { - return Err(Error::new_spanned( - input, - "HttpError can only be derived for enums", - )); - }; - - let defaults = HttpErrorConfig::from_attrs(&input.attrs)?; - let mut from_arms = Vec::new(); - let mut variant_fns = Vec::new(); - - for variant in &data.variants { - let variant_config = - HttpErrorConfig::parse_enum_variant_config(&variant.ident, &variant.attrs)?; - - let merged = variant_config.merged(&defaults); - - validate::HttpErrorValidator::validate_variant_config( - enum_name, - &variant.ident, - &variant.fields, - &merged, - )?; - - variant_fns.push(codegen::HttpErrorCodegen::generate_variant_fn( - &variant.ident, - &variant.fields, - )); - from_arms.push(codegen::HttpErrorCodegen::generate_from_arm( - enum_name, - &variant.ident, - &variant.fields, - &merged, - )); - } - - Ok(quote! { - impl From<#enum_name> for ::sword::web::JsonResponse { - fn from(err: #enum_name) -> Self { - let __sword_internal_error = err.to_string(); - - match err { - #(#from_arms)* - } - } - } - - impl ::sword::internal::web::IntoResponse for #enum_name { - fn into_response(self) -> ::sword::internal::web::AxumResponse { - ::sword::web::JsonResponse::from(self).into_response() - } - } - - #[allow(non_snake_case)] - impl #enum_name { - #(#variant_fns)* - } - }) -} diff --git a/crates/sword-macros/src/errors/http_error/parse.rs b/crates/sword-macros/src/errors/http_error/parse.rs deleted file mode 100644 index 877f0b87..00000000 --- a/crates/sword-macros/src/errors/http_error/parse.rs +++ /dev/null @@ -1,187 +0,0 @@ -use axum::http::StatusCode; -use syn::{Attribute, Error, Ident, LitInt, Token, meta::ParseNestedMeta, spanned::Spanned}; - -use crate::errors::MessageValue; - -#[derive(Debug, Clone, Default)] -pub struct HttpErrorConfig { - pub transparent: bool, - pub code: Option, - pub message: Option, - pub error_field: Option, - pub errors_field: Option, - pub tracing_level: Option, -} - -impl HttpErrorConfig { - pub fn from_attrs(attrs: &[Attribute]) -> syn::Result { - let mut config = HttpErrorConfig::default(); - - for attr in attrs - .iter() - .filter(|attr| attr.path().is_ident("http_error")) - { - config.parse_http_attr(attr)?; - } - - config.validate_container()?; - - Ok(config) - } - - pub fn parse_enum_variant_config(ident: &Ident, attrs: &[Attribute]) -> syn::Result { - let mut config = HttpErrorConfig::default(); - - for attr in attrs.iter().filter(|attr| attr.path().is_ident("http")) { - config.parse_http_attr(attr)?; - } - - for attr in attrs.iter().filter(|attr| attr.path().is_ident("tracing")) { - config.parse_tracing_attr(attr)?; - } - - config.validate_variant(ident)?; - - Ok(config) - } - - pub fn merged(self, defaults: &HttpErrorConfig) -> Self { - Self { - transparent: self.transparent || defaults.transparent, - code: self.code.or(defaults.code), - message: self.message.or_else(|| defaults.message.clone()), - error_field: self.error_field.or_else(|| defaults.error_field.clone()), - errors_field: self.errors_field.or_else(|| defaults.errors_field.clone()), - tracing_level: self - .tracing_level - .or_else(|| defaults.tracing_level.clone()), - } - } - - pub fn default_message(&self) -> String { - self.code - .as_ref() - .map(|code| code.canonical_reason().unwrap_or("Unknown Error")) - .unwrap_or("Unknown Error") - .to_string() - } - - fn validate_container(&self) -> syn::Result<()> { - crate::errors::validate_transparent_container(self.transparent, "http") - } - - fn validate_variant(&self, ident: &Ident) -> syn::Result<()> { - let has_conflict = self.code.is_some() - || self.message.is_some() - || self.error_field.is_some() - || self.errors_field.is_some() - || self.tracing_level.is_some(); - - crate::errors::validate_transparent_variant( - self.transparent, - has_conflict, - ident, - "`code`, `message`, `error`, `errors`, or `tracing`", - ) - } - - fn parse_http_attr(&mut self, attr: &Attribute) -> syn::Result<()> { - attr.parse_nested_meta(|meta| { - let ident = meta - .path - .get_ident() - .ok_or_else(|| Error::new(meta.path.span(), "expected identifier"))?; - - match ident.to_string().as_str() { - "transparent" => self.set_transparent(ident), - "code" => self.set_code(ident, &meta), - "message" => self.set_message(ident, &meta), - "error" => self.set_error_field(ident, &meta), - "errors" => self.set_errors_field(ident, &meta), - "tracing" => self.set_tracing_level(ident, &meta), - other => Err(Error::new( - ident.span(), - format!("unknown attribute `{other}` for this context"), - )), - } - }) - } - - fn parse_tracing_attr(&mut self, attr: &Attribute) -> syn::Result<()> { - crate::errors::parse_tracing_attr(&mut self.tracing_level, attr) - } - - fn set_transparent(&mut self, ident: &Ident) -> syn::Result<()> { - if self.transparent { - return Err(Error::new( - ident.span(), - "duplicate `transparent` attribute", - )); - } - - self.transparent = true; - Ok(()) - } - - fn set_code(&mut self, ident: &Ident, meta: &ParseNestedMeta) -> syn::Result<()> { - if self.code.is_some() { - return Err(Error::new(ident.span(), "duplicate `code` attribute")); - } - - self.code = Some(parse_status_code_value(ident, meta)?); - Ok(()) - } - - fn set_message(&mut self, ident: &Ident, meta: &ParseNestedMeta) -> syn::Result<()> { - if self.message.is_some() { - return Err(Error::new(ident.span(), "duplicate `message` attribute")); - } - - self.message = Some(MessageValue::parse(ident, meta)?); - Ok(()) - } - - fn set_error_field(&mut self, ident: &Ident, meta: &ParseNestedMeta) -> syn::Result<()> { - if self.error_field.is_some() { - return Err(Error::new(ident.span(), "duplicate `error` attribute")); - } - - self.error_field = Some(parse_field_ident(meta)?.to_string()); - Ok(()) - } - - fn set_errors_field(&mut self, ident: &Ident, meta: &ParseNestedMeta) -> syn::Result<()> { - if self.errors_field.is_some() { - return Err(Error::new(ident.span(), "duplicate `errors` attribute")); - } - - self.errors_field = Some(parse_field_ident(meta)?.to_string()); - Ok(()) - } - - fn set_tracing_level(&mut self, ident: &Ident, meta: &ParseNestedMeta) -> syn::Result<()> { - crate::errors::set_tracing_level(&mut self.tracing_level, ident, meta) - } -} - -fn parse_field_ident(meta: &ParseNestedMeta) -> syn::Result { - if !meta.input.peek(Token![=]) { - return Err(Error::new(meta.path.span(), "expected '=' after attribute")); - } - - meta.input.parse::()?; - meta.input.parse::() -} - -fn parse_status_code_value(ident: &Ident, meta: &ParseNestedMeta) -> syn::Result { - if !meta.input.peek(Token![=]) { - return Err(Error::new(ident.span(), "expected '=' after 'code'")); - } - - meta.input.parse::()?; - - let lit = meta.input.parse::()?; - let code = lit.base10_parse::()?; - - StatusCode::from_u16(code).map_err(|_| Error::new(lit.span(), "invalid HTTP status code")) -} diff --git a/crates/sword-macros/src/errors/http_error/validate.rs b/crates/sword-macros/src/errors/http_error/validate.rs deleted file mode 100644 index 5d9178da..00000000 --- a/crates/sword-macros/src/errors/http_error/validate.rs +++ /dev/null @@ -1,83 +0,0 @@ -use syn::{Error, Fields, Ident}; - -use super::parse::HttpErrorConfig; -use crate::errors::{MessageValue, validate_message_references, validate_named_field_refs}; - -pub struct HttpErrorValidator; - -impl HttpErrorValidator { - pub fn validate_variant_config( - enum_name: &Ident, - variant_name: &Ident, - fields: &Fields, - config: &HttpErrorConfig, - ) -> syn::Result<()> { - if config.transparent { - crate::errors::validate_transparent_single_unnamed(variant_name, fields)?; - return Ok(()); - } - - if config.code.is_none() { - return Err(Error::new_spanned( - variant_name, - "missing `code` after merging #[http_error(...)] and #[http(...)]", - )); - } - - match fields { - Fields::Named(named) => { - validate_message_references(enum_name, variant_name, named, &config.message)?; - - let extra_refs: Vec<(&str, &str)> = config - .error_field - .as_deref() - .map(|field| ("error", field)) - .into_iter() - .chain( - config - .errors_field - .as_deref() - .map(|field| ("errors", field)), - ) - .collect(); - - validate_named_field_refs(enum_name, variant_name, named, &extra_refs)?; - } - Fields::Unnamed(unnamed) => { - crate::errors::validate_single_unnamed(variant_name, unnamed)?; - - if Self::message_field_name(config).is_some() - || matches!(config.message, Some(MessageValue::Interpolated(_))) - || config.error_field.is_some() - || config.errors_field.is_some() - { - return Err(Error::new_spanned( - variant_name, - "tuple variants do not support `message = field`, interpolated messages, `error`, or `errors`; use named fields or construct the client message before creating the error", - )); - } - } - Fields::Unit => { - if Self::message_field_name(config).is_some() - || matches!(config.message, Some(MessageValue::Interpolated(_))) - || config.error_field.is_some() - || config.errors_field.is_some() - { - return Err(Error::new_spanned( - variant_name, - "unit variants do not support field-based or interpolated `message`, `error`, or `errors`", - )); - } - } - } - - Ok(()) - } - - pub fn message_field_name(config: &HttpErrorConfig) -> Option<&str> { - match &config.message { - Some(MessageValue::Field(field)) => Some(field.as_str()), - _ => None, - } - } -} diff --git a/crates/sword-macros/src/errors/mod.rs b/crates/sword-macros/src/errors/mod.rs deleted file mode 100644 index 81e4484b..00000000 --- a/crates/sword-macros/src/errors/mod.rs +++ /dev/null @@ -1,111 +0,0 @@ -mod grpc_error; -#[cfg(feature = "web-controllers")] -mod http_error; -mod shared; - -pub use grpc_error::*; -#[cfg(feature = "web-controllers")] -pub use http_error::*; -pub use shared::*; - -use syn::{Ident, LitStr, Token, meta::ParseNestedMeta}; - -#[derive(Debug, Clone)] -pub enum MessageValue { - Static(String), - Field(String), - Interpolated(String), -} - -impl MessageValue { - pub fn parse(ident: &Ident, meta: &ParseNestedMeta) -> syn::Result { - if !meta.input.peek(Token![=]) { - return Err(syn::Error::new( - ident.span(), - "expected '=' after 'message'", - )); - } - - meta.input.parse::()?; - - if let Ok(lit) = meta.input.parse::() { - let value = lit.value(); - - if value.contains('{') || value.contains('}') { - return Ok(MessageValue::Interpolated(value)); - } - - return Ok(MessageValue::Static(value)); - } - - if let Ok(field) = meta.input.parse::() { - return Ok(MessageValue::Field(field.to_string())); - } - - Err(syn::Error::new( - ident.span(), - "expected string literal or field identifier", - )) - } -} - -/// Extracts field names from `{field_name}` patterns in an interpolated message template. -/// Recognizes `{{` and `}}` as format ! escaping and skips them. -pub fn extract_template_fields(template: &str) -> Vec { - let mut fields = Vec::new(); - let mut chars = template.chars().peekable(); - while let Some(ch) = chars.next() { - if ch == '{' { - if chars.peek() == Some(&'{') { - chars.next(); - continue; - } - let mut name = String::new(); - while let Some(&c) = chars.peek() { - if c == '}' { - break; - } - name.push(c); - chars.next(); - } - if !name.is_empty() { - fields.push(name); - } - chars.next(); - } - } - fields -} - -/// Converts an interpolated template into a `format!` format string and a list of field names. -/// `"Conflict on {field}: {value}"` → `("Conflict on {}: {}", ["field", "value"])` -pub fn format_template(template: &str) -> (String, Vec) { - let mut fields = Vec::new(); - let mut format_str = String::new(); - let mut chars = template.chars().peekable(); - while let Some(ch) = chars.next() { - if ch == '{' { - if chars.peek() == Some(&'{') { - chars.next(); - format_str.push('{'); - continue; - } - let mut name = String::new(); - while let Some(&c) = chars.peek() { - if c == '}' { - break; - } - name.push(c); - chars.next(); - } - if !name.is_empty() { - fields.push(name); - format_str.push_str("{}"); - } - chars.next(); - } else { - format_str.push(ch); - } - } - (format_str, fields) -} diff --git a/crates/sword-macros/src/errors/shared.rs b/crates/sword-macros/src/errors/shared.rs deleted file mode 100644 index 6b8e92ee..00000000 --- a/crates/sword-macros/src/errors/shared.rs +++ /dev/null @@ -1,288 +0,0 @@ -use proc_macro2::TokenStream; -use quote::quote; -use syn::{ - Attribute, Error, Fields, FieldsNamed, Ident, LitStr, Token, meta::ParseNestedMeta, - spanned::Spanned, -}; - -use super::{MessageValue, extract_template_fields, format_template}; - -// ===== Parse helpers ===== - -pub fn parse_tracing_attr(tracing_level: &mut Option, attr: &Attribute) -> syn::Result<()> { - attr.parse_nested_meta(|meta| { - let ident = meta - .path - .get_ident() - .ok_or_else(|| Error::new(meta.path.span(), "expected identifier"))?; - - set_tracing_level_value(tracing_level, ident, ident.to_string()) - }) -} - -pub fn set_tracing_level( - tracing_level: &mut Option, - ident: &Ident, - meta: &ParseNestedMeta, -) -> syn::Result<()> { - if !meta.input.peek(Token![=]) { - return Err(Error::new(ident.span(), "expected '=' after 'tracing'")); - } - - meta.input.parse::()?; - - if let Ok(level) = meta.input.parse::() { - return set_tracing_level_value(tracing_level, &level, level.to_string()); - } - - if let Ok(level) = meta.input.parse::() { - return set_tracing_level_value(tracing_level, ident, level.value()); - } - - Err(Error::new( - ident.span(), - "expected tracing level identifier or string literal", - )) -} - -pub fn set_tracing_level_value( - tracing_level: &mut Option, - ident: &Ident, - level: String, -) -> syn::Result<()> { - if tracing_level.is_some() { - return Err(Error::new(ident.span(), "duplicate `tracing` attribute")); - } - - match level.as_str() { - "trace" | "debug" | "info" | "warn" | "error" => { - *tracing_level = Some(level); - Ok(()) - } - _ => Err(Error::new( - ident.span(), - "invalid tracing level, expected one of: trace, debug, info, warn, error", - )), - } -} - -pub fn validate_transparent_container(transparent: bool, attr_name: &str) -> syn::Result<()> { - if transparent { - return Err(Error::new( - proc_macro2::Span::call_site(), - format!("`transparent` is only valid inside #[{attr_name}(...)] on enum variants"), - )); - } - - Ok(()) -} - -pub fn validate_transparent_variant( - transparent: bool, - has_conflict: bool, - ident: &Ident, - conflict_desc: &str, -) -> syn::Result<()> { - if transparent && has_conflict { - return Err(Error::new_spanned( - ident, - format!("`transparent` cannot be combined with {conflict_desc}"), - )); - } - - Ok(()) -} - -// ===== Codegen helpers ===== - -pub fn generate_pattern(enum_name: &Ident, variant_name: &Ident, fields: &Fields) -> TokenStream { - match fields { - Fields::Named(named) => { - let field_names: Vec<_> = named.named.iter().map(|f| &f.ident).collect(); - quote! { #enum_name::#variant_name { #(#field_names),* } } - } - Fields::Unnamed(_) => quote! { #enum_name::#variant_name(_inner) }, - Fields::Unit => quote! { #enum_name::#variant_name }, - } -} - -pub fn generate_message_expr( - message: &Option, - fallback: impl FnOnce() -> TokenStream, -) -> TokenStream { - match message { - Some(MessageValue::Static(message)) => quote! { #message }, - Some(MessageValue::Field(field_name)) => { - let field_ident = Ident::new(field_name, proc_macro2::Span::call_site()); - quote! { format!("{}", #field_ident) } - } - Some(MessageValue::Interpolated(template)) => { - let (fmt, fields) = format_template(template); - let field_idents: Vec<_> = fields - .iter() - .map(|f| Ident::new(f, proc_macro2::Span::call_site())) - .collect(); - quote! { format!(#fmt, #(#field_idents),*) } - } - None => fallback(), - } -} - -pub fn generate_tracing_stmt( - variant_name: &Ident, - tracing_level: &Option, - fields: &Fields, - code_key: &Ident, - code_value: TokenStream, - title: &str, -) -> TokenStream { - let Some(level) = tracing_level else { - return quote! {}; - }; - - let tracing_macro = match level.as_str() { - "trace" => quote! { ::sword::internal::tracing::trace }, - "debug" => quote! { ::sword::internal::tracing::debug }, - "info" => quote! { ::sword::internal::tracing::info }, - "warn" => quote! { ::sword::internal::tracing::warn }, - "error" => quote! { ::sword::internal::tracing::error }, - _ => return quote! {}, - }; - - let variant_str = variant_name.to_string(); - - match fields { - Fields::Named(named) => { - let field_logs = named.named.iter().map(|field| { - let field_name = field.ident.as_ref().unwrap(); - quote! { #field_name = ?#field_name, } - }); - - quote! { - #tracing_macro!( - error = %__sword_internal_error, - error_type = #variant_str, - #code_key = #code_value, - #(#field_logs)* - #title - ); - } - } - Fields::Unnamed(_) => { - quote! { - #tracing_macro!( - error = %__sword_internal_error, - inner = ?_inner, - error_type = #variant_str, - #code_key = #code_value, - #title - ); - } - } - Fields::Unit => { - quote! { - #tracing_macro!( - error = %__sword_internal_error, - error_type = #variant_str, - #code_key = #code_value, - #title - ); - } - } - } -} - -// ===== Validation helpers ===== - -/// Transparent variants must have exactly one unnamed field. -pub fn validate_transparent_single_unnamed( - variant_name: &Ident, - fields: &Fields, -) -> syn::Result<()> { - let is_single_unnamed = - matches!(fields, Fields::Unnamed(unnamed) if unnamed.unnamed.len() == 1); - - if !is_single_unnamed { - return Err(Error::new_spanned( - variant_name, - "transparent variants must have exactly one unnamed field", - )); - } - - Ok(()) -} - -/// Non-transparent tuple variants are only supported with exactly one field. -pub fn validate_single_unnamed( - variant_name: &Ident, - unnamed: &syn::FieldsUnnamed, -) -> syn::Result<()> { - if unnamed.unnamed.len() != 1 { - return Err(Error::new_spanned( - variant_name, - "non-transparent tuple variants are only supported with exactly one field", - )); - } - - Ok(()) -} - -/// Checks that `Field` and `Interpolated` message references point to real named fields. -pub fn validate_message_references( - enum_name: &Ident, - variant_name: &Ident, - fields: &FieldsNamed, - message: &Option, -) -> syn::Result<()> { - if let Some(MessageValue::Field(field_name)) = message { - ensure_named_field_exists(enum_name, variant_name, fields, field_name, "message")?; - } - - if let Some(MessageValue::Interpolated(template)) = message { - for field in extract_template_fields(template) { - ensure_named_field_exists(enum_name, variant_name, fields, &field, "message")?; - } - } - - Ok(()) -} - -/// Checks that a named variant has the fields referenced by `error`/`errors`-style attributes. -#[cfg(feature = "web-controllers")] -pub fn validate_named_field_refs( - enum_name: &Ident, - variant_name: &Ident, - fields: &FieldsNamed, - extra_refs: &[(&str, &str)], -) -> syn::Result<()> { - for (attr_name, field_name) in extra_refs { - ensure_named_field_exists(enum_name, variant_name, fields, field_name, attr_name)?; - } - - Ok(()) -} - -fn ensure_named_field_exists( - enum_name: &Ident, - variant_name: &Ident, - fields: &FieldsNamed, - field_name: &str, - attr_name: &str, -) -> syn::Result<()> { - let exists = fields - .named - .iter() - .filter_map(|field| field.ident.as_ref()) - .any(|ident| ident == field_name); - - if exists { - return Ok(()); - } - - Err(Error::new_spanned( - variant_name, - format!( - "`{attr_name} = {field_name}` references a missing field on {enum_name}::{variant_name}`" - ), - )) -} diff --git a/crates/sword-macros/src/events/event.rs b/crates/sword-macros/src/events/event.rs new file mode 100644 index 00000000..ac2bc752 --- /dev/null +++ b/crates/sword-macros/src/events/event.rs @@ -0,0 +1,42 @@ +use crate::shared::{Error, Result, paths::*}; +use darling::{FromMeta, ast::NestedMeta}; +use proc_macro::TokenStream; +use quote::quote; +use syn::DeriveInput; + +// `#[event(key = "literal_str")]` macro Args +#[derive(FromMeta)] +struct Args { + key: String, +} + +pub fn expand_event_struct(args: TokenStream, item: TokenStream) -> Result { + let input = syn::parse::(item)?; + let args = Args::from_list(&NestedMeta::parse_meta_list(args.into())?)?; + + if !matches!(input.data, syn::Data::Struct(_)) { + return Err( + Error::custom("#[event] can only be applied to structs").with_span(&input.ident) + ); + } + + let event_key = args.key; + let struct_name = &input.ident; + + let expanded = quote! { + #[derive(#Clone, #Debug)] + #input + + impl #Event for #struct_name { + fn key(&self) -> &'static str { + #event_key + } + + fn clone_event(&self) -> #Box { + #Box::new(#Clone::clone(self)) + } + } + }; + + Ok(TokenStream::from(expanded)) +} diff --git a/crates/sword-macros/src/controllers/event_handler/handle.rs b/crates/sword-macros/src/events/handle.rs similarity index 50% rename from crates/sword-macros/src/controllers/event_handler/handle.rs rename to crates/sword-macros/src/events/handle.rs index ca3ed920..349a68e1 100644 --- a/crates/sword-macros/src/controllers/event_handler/handle.rs +++ b/crates/sword-macros/src/events/handle.rs @@ -1,115 +1,101 @@ -use crate::controllers::shared::CMetaStack; +use crate::shared::{Result, cmeta::CMetaStack, paths::*}; + +use darling::Error; use heck::ToSnakeCase; use proc_macro::TokenStream; use quote::{format_ident, quote}; use syn::{FnArg, ItemFn, LitStr, Type}; -pub fn expand_handle(attr: TokenStream, item: TokenStream) -> syn::Result { - if CMetaStack::get("event_handler", "controller_name").is_none() { +pub fn expand_handle(attr: TokenStream, item: TokenStream) -> Result { + // Handshake: leave the method untouched outside an event handler controller. + let Some(controller_ty_str) = CMetaStack::get("event_controller", "controller_ty") else { return Ok(item); - } + }; let event_lit = syn::parse::(attr)?; let event_key = event_lit.value(); - let input_fn = syn::parse::(item)?; if event_key.is_empty() { - return Err(syn::Error::new_spanned( - &event_lit, + return Err(Error::custom( "event key must not be empty; specify the full key, e.g. #[handle(\"user.created\")]", - )); + ) + .with_span(&event_lit)); } - let controller_name = CMetaStack::get("event_handler", "controller_name").unwrap(); + let item_fn = syn::parse::(item)?; + let event_type = extract_event_type(&item_fn)?; - let fn_name = &input_fn.sig.ident; - let controller_ident: syn::Ident = syn::parse_str(&controller_name)?; - - let event_type = extract_event_type(&input_fn)?; + let controller_ty: syn::Ident = syn::parse_str(&controller_ty_str)?; + let fn_name = &item_fn.sig.ident; let registration_name = format_ident!( "__SWORD_EVENT_HANDLER_{}_{}", - controller_name.replace("::", "_"), + controller_ty_str.replace("::", "_"), fn_name ); - let build_fn_name = format_ident!( "__sword_event_build_{}_{}", - controller_ident.to_string().to_snake_case(), + controller_ty.to_string().to_snake_case(), fn_name ); - let expansion = quote! { - #input_fn + let expanded = quote! { + #item_fn #[allow(non_upper_case_globals)] #[doc(hidden)] const #registration_name: () = { - fn #build_fn_name( - state: &::sword::internal::core::State, - ) -> ::sword::internal::events::EventHandlerFn { - let controller: ::std::sync::Arc<#controller_ident> = - state.borrow::<#controller_ident>().unwrap_or_else(|_| { - ::sword::internal::core::sword_error!( + fn #build_fn_name(state: &#State) -> #EventHandlerFn { + let controller: #Arc<#controller_ty> = + state.borrow::<#controller_ty>().unwrap_or_else(|_| { + #sword_error! { title: "Failed to retrieve event handler controller from state", - reason: ::std::format!( + reason: #format!( "Controller {} was not built for event handler", - ::std::stringify!(#controller_ident) + #stringify!(#controller_ty) ), context: { - "controller" => ::std::stringify!(#controller_ident), + "controller" => #stringify!(#controller_ty), "event_key" => #event_key, }, hints: [ "Ensure the controller is registered via Module::register_controllers", ], - ) + } }); - ::std::sync::Arc::new( - move |event: ::std::sync::Arc| { - let controller = controller.clone(); + #Arc::new( + move |event: #Arc| -> #EventsBoxedFuture { + let controller = #Arc::clone(&controller); let cloned = event.clone_event(); - ::std::boxed::Box::pin(async move { + #Box::pin(async move { let typed = cloned.downcast_ref::<#event_type>().ok_or_else(|| { - ::std::format!( + #format!( "Failed to downcast event to {} for handler {} (key: {})", - ::std::any::type_name::<#event_type>(), - ::std::stringify!(#fn_name), + #type_name::<#event_type>(), + #stringify!(#fn_name), #event_key, ) })?; - (controller.#fn_name( - ::std::clone::Clone::clone(typed), - ) - .await) + controller.#fn_name(#Clone::clone(typed)).await }) - as ::std::pin::Pin< - ::std::boxed::Box< - dyn ::std::future::Future< - Output = ::sword::internal::events::EventHandlerResult< - (), - >, - > + ::std::marker::Send, - >, - > }, ) } - ::sword::internal::inventory::submit! { - ::sword::internal::events::EventRouteRegistrar { + #inventory::submit! { + #EventRouteRegistrar { event_key: #event_key, - handler_type_id: ::std::any::TypeId::of::<#controller_ident>(), + handler_type_id: #TypeId::of::<#controller_ty>(), build_and_handle: #build_fn_name, } } }; }; - Ok(TokenStream::from(expansion)) + Ok(expanded.into()) } fn extract_event_type(input_fn: &ItemFn) -> syn::Result { @@ -127,7 +113,7 @@ fn extract_event_type(input_fn: &ItemFn) -> syn::Result { )); } - let typed_params: Vec<&syn::FnArg> = input_fn + let typed_params: Vec<&FnArg> = input_fn .sig .inputs .iter() diff --git a/crates/sword-macros/src/events/mod.rs b/crates/sword-macros/src/events/mod.rs new file mode 100644 index 00000000..236f034f --- /dev/null +++ b/crates/sword-macros/src/events/mod.rs @@ -0,0 +1,73 @@ +mod event; +mod handle; + +pub use event::expand_event_struct; +pub use handle::expand_handle; + +use crate::core::{ControllerKind, ControllerStruct}; +use crate::shared::{ + Result, + cmeta::CMetaStack, + paths::*, + traits::{generate_build_impl, generate_clone_impl}, +}; + +use darling::Error; +use proc_macro2::TokenStream; +use quote::quote; + +pub fn expand_controller(controller: &ControllerStruct) -> Result { + let ControllerKind::EventHandler { source } = &controller.kind else { + return Err( + Error::custom("Expected an event handler controller").with_span(controller.ty()) + ); + }; + + let self_ty = controller.ty(); + let fields = controller.fields(); + + CMetaStack::push("event_controller", "controller_ty", &self_ty.to_string()); + + let build_impl = generate_build_impl(self_ty, &fields); + let clone_impl = generate_clone_impl(self_ty, &fields); + + let source_tokens = source.as_tokens(); + + let expanded = quote! { + #build_impl + #clone_impl + + impl #EventHandler for #self_ty {} + + impl #ControllerSpec for #self_ty { + fn kind() -> #Controller { + #Controller::EventHandler + } + } + + #inventory::submit! { + #EventControllerRegistrar { + handler_type_id: #TypeId::of::<#self_ty>(), + source: #source_tokens, + build: |state: &#State| { + let controller = #self_ty::build(state).unwrap_or_else(|e| { + #sword_error! { + title: "Failed to build controller", + reason: #format!("Failed to build event controller dependency graph: {e}"), + context: { + "controller_name" => #stringify!(#self_ty), + "error" => #format!("{e:?}"), + "source" => "EventControllerRegistrar::build", + }, + hints: ["Check the error message for details on what went wrong during construction"], + } + }); + + state.insert::<#self_ty>(controller); + }, + } + } + }; + + Ok(expanded) +} diff --git a/crates/sword-macros/src/grpc/errors.rs b/crates/sword-macros/src/grpc/errors.rs new file mode 100644 index 00000000..63512174 --- /dev/null +++ b/crates/sword-macros/src/grpc/errors.rs @@ -0,0 +1,54 @@ +mod expand; +mod parse; +mod validate; + +use crate::shared::Result; +use crate::shared::paths::*; + +use parse::GrpcErrorArgs; +use proc_macro::TokenStream; +use quote::quote; +use syn::{Data, DeriveInput}; + +pub fn derive_grpc_error(input: TokenStream) -> Result { + let input: DeriveInput = syn::parse(input)?; + let enum_ty = &input.ident; + + let Data::Enum(data) = &input.data else { + return Err( + darling::Error::custom("GrpcError can only be derived for enums").with_span(enum_ty), + ); + }; + + let defaults = GrpcErrorArgs::container(&input)?; + + let mut from_arms = Vec::new(); + + for variant in &data.variants { + let variant_args = GrpcErrorArgs::variant(variant)?; + let merged = variant_args.merged(&defaults); + + validate::validate_variant(enum_ty, &variant.ident, &variant.fields, &merged)?; + + from_arms.push(expand::generate_from_arm( + enum_ty, + &variant.ident, + &variant.fields, + &merged, + )?); + } + + let expanded = quote! { + impl From<#enum_ty> for #GrpcStatus { + fn from(err: #enum_ty) -> Self { + let __sword_internal_error = err.to_string(); + + match err { + #(#from_arms)* + } + } + } + }; + + Ok(expanded.into()) +} diff --git a/crates/sword-macros/src/grpc/errors/expand.rs b/crates/sword-macros/src/grpc/errors/expand.rs new file mode 100644 index 00000000..96585973 --- /dev/null +++ b/crates/sword-macros/src/grpc/errors/expand.rs @@ -0,0 +1,100 @@ +use super::parse::GrpcErrorArgs; +use crate::shared::errors::{ + TracingLevel, generate_message_expr, generate_pattern, generate_tracing_stmt, +}; + +use crate::shared::paths::*; + +use proc_macro2::{Span, TokenStream}; +use quote::quote; +use syn::{Error, Fields, Ident}; + +pub fn generate_from_arm( + enum_name: &Ident, + variant_name: &Ident, + fields: &Fields, + config: &GrpcErrorArgs, +) -> syn::Result { + if config.transparent.is_present() { + return Ok(quote! { + #enum_name::#variant_name(inner) => #GrpcStatus::from(inner), + }); + } + + let code_variant = parse_code_to_tonic_variant(config.default_code(), variant_name)?; + let pattern = generate_pattern(enum_name, variant_name, fields); + let message_expr = generate_message_expr(&config.message, || match fields { + Fields::Unnamed(_) => quote! { format!("{}", _inner) }, + _ => quote! { __sword_internal_error.clone() }, + }); + let tracing_stmt = generate_variant_tracing_stmt(variant_name, config, fields, &code_variant); + + Ok(quote! { + #pattern => { + #tracing_stmt + #GrpcStatus::new(#GrpcCode::#code_variant, #message_expr) + }, + }) +} + +fn parse_code_to_tonic_variant(code: &str, variant_name: &Ident) -> syn::Result { + let variant = match code { + "ok" => "Ok", + "cancelled" => "Cancelled", + "unknown" => "Unknown", + "invalid_argument" => "InvalidArgument", + "deadline_exceeded" => "DeadlineExceeded", + "not_found" => "NotFound", + "already_exists" => "AlreadyExists", + "permission_denied" => "PermissionDenied", + "resource_exhausted" => "ResourceExhausted", + "failed_precondition" => "FailedPrecondition", + "aborted" => "Aborted", + "out_of_range" => "OutOfRange", + "unimplemented" => "Unimplemented", + "internal" => "Internal", + "unavailable" => "Unavailable", + "data_loss" => "DataLoss", + "unauthenticated" => "Unauthenticated", + _ => { + return Err(Error::new_spanned( + variant_name, + "invalid gRPC code; use one of: ok, cancelled, unknown, invalid_argument, deadline_exceeded, not_found, already_exists, permission_denied, resource_exhausted, failed_precondition, aborted, out_of_range, unimplemented, internal, unavailable, data_loss, unauthenticated", + )); + } + }; + + Ok(Ident::new(variant, Span::call_site())) +} + +fn generate_variant_tracing_stmt( + variant_name: &Ident, + config: &GrpcErrorArgs, + fields: &Fields, + code_variant: &Ident, +) -> TokenStream { + let code_str = code_variant.to_string(); + let level = config.tracing.or(Some(default_tracing_level(&code_str))); + + generate_tracing_stmt( + variant_name, + level, + fields, + &Ident::new("grpc_code", Span::call_site()), + quote! { #code_str }, + "gRPC error response", + ) +} + +/// Default tracing level for a gRPC code, matching the access logger's `auto` +/// policy: `ok` → info, client errors → warn, server errors → error. +fn default_tracing_level(code_str: &str) -> TracingLevel { + match code_str { + "Ok" => TracingLevel::Info, + "InvalidArgument" | "NotFound" | "AlreadyExists" | "PermissionDenied" + | "FailedPrecondition" | "OutOfRange" | "Unauthenticated" | "Aborted" | "Cancelled" => { + TracingLevel::Warn + } + _ => TracingLevel::Error, + } +} diff --git a/crates/sword-macros/src/grpc/errors/parse.rs b/crates/sword-macros/src/grpc/errors/parse.rs new file mode 100644 index 00000000..c81cd333 --- /dev/null +++ b/crates/sword-macros/src/grpc/errors/parse.rs @@ -0,0 +1,58 @@ +use darling::{FromDeriveInput, FromVariant, util::Flag}; +use syn::{DeriveInput, Variant}; + +use crate::shared::Result; +use crate::shared::errors::{ + MessageValue, TracingLevel, read_tracing_attr, validate_transparent_container, + validate_transparent_variant, +}; + +/// Parsed `#[grpc_error(...)]` (container defaults) or `#[grpc(...)]` (variant). +#[derive(FromDeriveInput, FromVariant)] +#[darling(attributes(grpc_error, grpc))] +pub struct GrpcErrorArgs { + pub transparent: Flag, + pub code: Option, + pub message: Option, + pub tracing: Option, +} + +impl GrpcErrorArgs { + pub fn container(input: &DeriveInput) -> Result { + let args = Self::from_derive_input(input)?; + validate_transparent_container(args.transparent.is_present(), "grpc")?; + Ok(args) + } + + pub fn variant(variant: &Variant) -> Result { + let mut args = Self::from_variant(variant)?; + + if args.tracing.is_none() { + args.tracing = read_tracing_attr(&variant.attrs)?; + } + + let has_conflict = args.code.is_some() || args.message.is_some() || args.tracing.is_some(); + + validate_transparent_variant( + args.transparent.is_present(), + has_conflict, + &variant.ident, + "`code`, `message`, or `tracing`", + )?; + + Ok(args) + } + + pub fn merged(self, defaults: &Self) -> Self { + Self { + transparent: self.transparent, + code: self.code.or_else(|| defaults.code.clone()), + message: self.message.or_else(|| defaults.message.clone()), + tracing: self.tracing.or(defaults.tracing), + } + } + + pub fn default_code(&self) -> &str { + self.code.as_deref().unwrap_or("unknown") + } +} diff --git a/crates/sword-macros/src/grpc/errors/validate.rs b/crates/sword-macros/src/grpc/errors/validate.rs new file mode 100644 index 00000000..7f63ea7b --- /dev/null +++ b/crates/sword-macros/src/grpc/errors/validate.rs @@ -0,0 +1,52 @@ +use syn::{Error, Fields, Ident}; + +use super::parse::GrpcErrorArgs; +use crate::shared::errors::{ + MessageValue, validate_message_references, validate_single_unnamed, + validate_transparent_single_unnamed, +}; + +pub fn validate_variant( + enum_name: &Ident, + variant_name: &Ident, + fields: &Fields, + config: &GrpcErrorArgs, +) -> syn::Result<()> { + if config.transparent.is_present() { + validate_transparent_single_unnamed(variant_name, fields)?; + return Ok(()); + } + + match fields { + Fields::Named(named) => { + validate_message_references(enum_name, variant_name, named, &config.message)?; + } + Fields::Unnamed(unnamed) => { + validate_single_unnamed(variant_name, unnamed)?; + + if has_field_based_message(config) { + return Err(Error::new_spanned( + variant_name, + "tuple variants do not support `message = field` or interpolated messages; use a named-field variant or build the final client message before creating the error", + )); + } + } + Fields::Unit => { + if has_field_based_message(config) { + return Err(Error::new_spanned( + variant_name, + "unit variants do not support field-based or interpolated `message`", + )); + } + } + } + + Ok(()) +} + +const fn has_field_based_message(config: &GrpcErrorArgs) -> bool { + matches!( + config.message, + Some(MessageValue::Field(_)) | Some(MessageValue::Interpolated(_)) + ) +} diff --git a/crates/sword-macros/src/controllers/grpc/mod.rs b/crates/sword-macros/src/grpc/mod.rs similarity index 50% rename from crates/sword-macros/src/controllers/grpc/mod.rs rename to crates/sword-macros/src/grpc/mod.rs index c242f96f..3cbd6096 100644 --- a/crates/sword-macros/src/controllers/grpc/mod.rs +++ b/crates/sword-macros/src/grpc/mod.rs @@ -1,21 +1,31 @@ -use super::shared::{ControllerStruct, ParsedControllerKind}; -use crate::shared::{gen_build, gen_clone}; +mod errors; -use proc_macro::TokenStream; +pub use errors::derive_grpc_error; + +use crate::{ + core::{ControllerKind, ControllerStruct, InterceptorArgs}, + shared::{ + Result, + paths::*, + traits::{generate_build_impl, generate_clone_impl}, + }, +}; + +use darling::Error; +use proc_macro2::TokenStream; use quote::quote; -use syn::Error; -pub fn expand_grpc_controller(input: &ControllerStruct) -> syn::Result { - let ParsedControllerKind::Grpc { service } = &input.kind else { - return Err(Error::new(input.name.span(), "Expected a gRPC controller")); +pub fn expand_controller(controller: &ControllerStruct) -> Result { + let ControllerKind::Grpc { service } = &controller.kind else { + return Err(Error::custom("Expected a gRPC controller").with_span(&controller.span())); }; - let self_name = &input.name; - let self_fields = &input.fields; - let interceptors = &input.interceptors; + let self_ty = controller.ty(); + let fields = controller.fields(); + let interceptors = &controller.interceptors; - let build_impl = gen_build(self_name, self_fields); - let clone_impl = gen_clone(self_name, self_fields); + let build_impl = generate_build_impl(self_ty, &fields); + let clone_impl = generate_clone_impl(self_ty, &fields); #[cfg(feature = "grpc-reflection")] let reflection_descriptor_set = quote! { @@ -26,56 +36,55 @@ pub fn expand_grpc_controller(input: &ControllerStruct) -> syn::Result { + InterceptorArgs::Traditional(path) => { quote! { let interceptor = state.borrow::<#path>() .unwrap_or_else(|err| { - ::sword::internal::core::sword_error! { + #sword_error! { title: "Failed to retrieve gRPC interceptor from State", reason: err, context: { - "controller" => stringify!(#self_name), - "interceptor" => stringify!(#path), + "controller" => #stringify!(#self_ty), + "interceptor" => #stringify!(#path), }, hints: ["Ensure the interceptor is registered and built before gRPC controller setup"], } }); - let service = ::sword::internal::grpc::tonic_async_interceptor::AsyncInterceptedService::new( + let service = #tonic_async_interceptor::AsyncInterceptedService::new( service, - move |req: ::sword::internal::grpc::tonic::Request<()>| { - let interceptor = ::std::sync::Arc::clone(&interceptor); + move |req: #tonic::Request<()>| { + let interceptor = #Arc::clone(&interceptor); + async move { - <#path as ::sword::grpc::OnRequest>::on_request(&*interceptor, req).await + <#path as #GrpcOnRequest>::on_request(&*interceptor, req).await } }, ); } } - crate::interceptor::InterceptorArgs::WithConfig { - interceptor, - config, - } => { + InterceptorArgs::WithConfig { interceptor, config } => { quote! { let interceptor = state.borrow::<#interceptor>() .unwrap_or_else(|err| { - ::sword::internal::core::sword_error!( + #sword_error! { title: "Failed to retrieve gRPC interceptor from State", reason: err, context: { - "controller" => stringify!(#self_name), - "interceptor" => stringify!(#interceptor), + "controller" => #stringify!(#self_ty), + "interceptor" => #stringify!(#interceptor), }, hints: ["Ensure the interceptor is registered and built before gRPC controller setup"], - ) + } }); - let service = ::sword::internal::grpc::tonic_async_interceptor::AsyncInterceptedService::new( + let service = #tonic_async_interceptor::AsyncInterceptedService::new( service, - move |req: ::sword::internal::grpc::tonic::Request<()>| { - let interceptor = ::std::sync::Arc::clone(&interceptor); + move |req: #tonic::Request<()>| { + let interceptor = #Arc::clone(&interceptor); + async move { - <#interceptor as ::sword::grpc::OnRequestWithConfig<_>>::on_request( + <#interceptor as #GrpcOnRequestWithConfig<_>>::on_request( &*interceptor, #config, req, @@ -85,9 +94,9 @@ pub fn expand_grpc_controller(input: &ControllerStruct) -> syn::Result { + InterceptorArgs::Expression(expr) => { quote! { - let service = ::sword::internal::grpc::tonic_async_interceptor::AsyncInterceptedService::new( + let service = #tonic_async_interceptor::AsyncInterceptedService::new( service, #expr, ); @@ -99,74 +108,74 @@ pub fn expand_grpc_controller(input: &ControllerStruct) -> syn::Result(), + #inventory::submit! { + #GrpcControllerRegistrar { + controller_id: #TypeId::of::<#self_ty>(), reflection_descriptor_set: #reflection_descriptor_set, - build: |state: &::sword::internal::core::State| { - state.insert::<#self_name>(#self_name::build(state).unwrap_or_else(|e| { - ::sword::internal::core::sword_error! { + build: |state: &#State| { + state.insert::<#self_ty>(#self_ty::build(state).unwrap_or_else(|e| { + #sword_error! { title: "Failed to build gRPC controller", reason: "An error occurred while building the gRPC controller", context: { - "controller_name" => stringify!(#self_name), - "service" => stringify!(#service), - "error" => format!("{e:?}"), + "controller_name" => #stringify!(#self_ty), + "service" => #stringify!(#service), + "error" => #format!("{e:?}"), "source" => "GrpcControllerRegistrar::build", }, hints: ["Check the error message for details on what went wrong during construction"], } })); }, - register: |_state: &::sword::internal::core::State, _registry: &mut ::sword::internal::grpc::GrpcServiceRegistry| { + register: |_state: &#State, _registry: &mut #GrpcServiceRegistry| { let state = _state; let registry = _registry; - let controller = state.borrow::<#self_name>().unwrap_or_else(|err| { - ::sword::internal::core::sword_error! { + let controller = state.borrow::<#self_ty>().unwrap_or_else(|err| { + #sword_error! { title: "Failed to retrieve gRPC controller from State", reason: err, context: { - "controller_name" => stringify!(#self_name), - "service" => stringify!(#service), + "controller_name" => #stringify!(#self_ty), + "service" => #stringify!(#service), "source" => "GrpcControllerRegistrar::register", }, hints: ["Ensure the controller is built before registration"], } }); - let body_limit = state.borrow::<::sword::internal::grpc::GrpcBodyLimitValue>() + let body_limit = state.borrow::<#GrpcBodyLimitValue>() .unwrap_or_else(|err| { - ::sword::internal::core::sword_error!( + #sword_error! { title: "Failed to retrieve gRPC body-limit settings from State", reason: err, context: { - "controller_name" => stringify!(#self_name), - "service" => stringify!(#service), + "controller_name" => #stringify!(#self_ty), + "service" => #stringify!(#service), "source" => "GrpcControllerRegistrar::register", }, hints: ["Ensure gRPC body-limit settings are inserted into State before controller registration"], - ) + } }); - let service = <#service<#self_name>>::new((*controller).clone()) + let service = <#service<#self_ty>>::new((*controller).clone()) .max_decoding_message_size(body_limit.max_decoding_message_size) .max_encoding_message_size(body_limit.max_encoding_message_size); #(#interceptor_wrappers)* registry.routes_builder_mut().add_service(service); - registry.mark_service_registered_with_name(<#service<#self_name> as ::sword::internal::grpc::tonic::server::NamedService>::NAME); + registry.mark_service_registered_with_name(<#service<#self_ty> as #tonic::server::NamedService>::NAME); }, } } - impl ::sword::internal::grpc::GrpcController for #self_name {} + impl #GrpcController for #self_ty {} - impl ::sword::internal::core::ControllerSpec for #self_name { - fn kind() -> ::sword::internal::core::Controller { - ::sword::internal::core::Controller::Grpc + impl #ControllerSpec for #self_ty { + fn kind() -> #Controller { + #Controller::Grpc } } }; - Ok(TokenStream::from(expanded)) + Ok(expanded) } diff --git a/crates/sword-macros/src/interceptor/parse.rs b/crates/sword-macros/src/interceptor/parse.rs deleted file mode 100644 index d5dc5b5f..00000000 --- a/crates/sword-macros/src/interceptor/parse.rs +++ /dev/null @@ -1,120 +0,0 @@ -use proc_macro2::TokenStream; -use quote::quote; -use syn::parse::discouraged::Speculative; -use syn::{ - Expr, Path, Token, - parse::{Parse, ParseStream}, -}; - -pub enum InterceptorArgs { - Traditional(Path), - WithConfig { interceptor: Path, config: Expr }, - Expression(Expr), -} - -enum ParsedTraditionalInterceptor { - Traditional(Path), - WithConfig { interceptor: Path, config: Expr }, -} - -impl InterceptorArgs { - pub fn is_sword(&self) -> bool { - matches!(self, Self::Traditional(_) | Self::WithConfig { .. }) - } - - pub fn sword_path(&self) -> Option<&Path> { - match self { - Self::Traditional(path) => Some(path), - Self::WithConfig { interceptor, .. } => Some(interceptor), - Self::Expression(_) => None, - } - } - - pub fn to_token_stream(&self) -> TokenStream { - match self { - Self::Traditional(interceptor) => quote! { #interceptor }, - Self::WithConfig { - interceptor, - config, - } => { - quote! { #interceptor, config = #config } - } - Self::Expression(expr) => quote! { #expr }, - } - } -} - -impl From for TokenStream { - fn from(args: InterceptorArgs) -> Self { - match args { - InterceptorArgs::Traditional(interceptor) => quote! { #interceptor }, - InterceptorArgs::WithConfig { - interceptor, - config, - } => { - quote! { #interceptor, config = #config } - } - InterceptorArgs::Expression(expr) => quote! { #expr }, - } - } -} - -impl Parse for InterceptorArgs { - fn parse(input: ParseStream) -> syn::Result { - // Try the Sword-specific syntax first (`Auth` or `Auth, config = ...`). - // We parse it on a fork so we can fall back to a generic expression - // without consuming the real input when that specialized form doesn't match. - let fork = input.fork(); - - if let Ok(parsed) = fork.parse::() { - input.advance_to(&fork); - - return Ok(match parsed { - ParsedTraditionalInterceptor::Traditional(interceptor) => { - Self::Traditional(interceptor) - } - ParsedTraditionalInterceptor::WithConfig { - interceptor, - config, - } => Self::WithConfig { - interceptor, - config, - }, - }); - } - - Ok(Self::Expression(input.parse()?)) - } -} - -impl Parse for ParsedTraditionalInterceptor { - fn parse(input: ParseStream) -> syn::Result { - let interceptor: Path = input.parse()?; - - if input.is_empty() { - return Ok(Self::Traditional(interceptor)); - } - - input.parse::()?; - - // `Path, config = Expr` is the only keyed Sword form we accept here. - // Any other tail should fail so the outer parser can treat it as a - // generic expression instead. - let key: syn::Ident = input.parse()?; - if key != "config" { - return Err(syn::Error::new(key.span(), "expected `config`")); - } - - input.parse::()?; - let config: Expr = input.parse()?; - - if !input.is_empty() { - return Err(input.error("unexpected tokens after interceptor config")); - } - - Ok(Self::WithConfig { - interceptor, - config, - }) - } -} diff --git a/crates/sword-macros/src/interceptor_derive.rs b/crates/sword-macros/src/interceptor_derive.rs deleted file mode 100644 index 58b3300d..00000000 --- a/crates/sword-macros/src/interceptor_derive.rs +++ /dev/null @@ -1,54 +0,0 @@ -use crate::shared::{StructFields, gen_build, gen_clone, gen_deps}; -use proc_macro::TokenStream; -use proc_macro2::TokenStream as TokenStream2; -use quote::quote; -use syn::{Ident, ItemStruct, Type}; - -pub struct InterceptorInput { - pub struct_name: Ident, - pub fields: Vec<(Ident, Type)>, -} - -pub fn parse_interceptor_input(item: &ItemStruct) -> syn::Result { - let fields = StructFields::parse(item)?; - - Ok(InterceptorInput { - struct_name: item.ident.clone(), - fields, - }) -} - -pub fn derive_interceptor(input: TokenStream) -> syn::Result { - let input_struct = syn::parse::(input)?; - let parsed_input = parse_interceptor_input(&input_struct)?; - let builder = generate_interceptor_builder(&parsed_input); - - let expanded = quote! { - #builder - }; - - Ok(TokenStream::from(expanded)) -} - -pub fn generate_interceptor_builder(input: &InterceptorInput) -> TokenStream2 { - let self_name = &input.struct_name; - let self_fields = &input.fields; - - let build_impl = gen_build(self_name, self_fields); - let clone_impl = gen_clone(self_name, self_fields); - let deps_impl = gen_deps(self_name, self_fields); - - quote! { - #build_impl - #clone_impl - #deps_impl - - impl ::sword::internal::core::Interceptor for #self_name {} - - ::sword::internal::inventory::submit! { - ::sword::internal::core::InterceptorRegistrar { - register: #self_name::register, - } - } - } -} diff --git a/crates/sword-macros/src/jobs/mod.rs b/crates/sword-macros/src/jobs/mod.rs new file mode 100644 index 00000000..93017a80 --- /dev/null +++ b/crates/sword-macros/src/jobs/mod.rs @@ -0,0 +1,62 @@ +pub mod schedule; + +use crate::core::{ControllerKind, ControllerStruct}; +use crate::shared::{ + Result, + cmeta::CMetaStack, + paths::*, + traits::{generate_build_impl, generate_clone_impl}, +}; + +use darling::Error; +use proc_macro2::TokenStream; +use quote::quote; + +pub fn expand_controller(controller: &ControllerStruct) -> Result { + let ControllerKind::JobHandler = &controller.kind else { + return Err(Error::custom("Expected a job controller").with_span(&controller.span())); + }; + + let self_ty = &controller.ty(); + let fields = controller.fields(); + + CMetaStack::push("job_controller", "controller_ty", &controller.name()); + + let build_impl = generate_build_impl(self_ty, &fields); + let clone_impl = generate_clone_impl(self_ty, &fields); + + let expanded = quote! { + #build_impl + #clone_impl + + impl #ControllerSpec for #self_ty { + fn kind() -> #Controller { + #Controller::JobHandler + } + } + + #inventory::submit! { + #JobControllerRegistrar { + type_id: #TypeId::of::<#self_ty>(), + build_fn: |state: &#State| { + let controller = #self_ty::build(state).unwrap_or_else(|e| { + #sword_error! { + title: "Failed to build controller", + reason: format!("Failed to build job controller dependency graph: {e}"), + context: { + "controller_name" => stringify!(#self_ty), + "error" => format!("{e:?}"), + "source" => "JobControllerRegistrar::build", + }, + hints: ["Check the error message for details on what went wrong during construction"], + } + }); + + state.insert::<#self_ty>(controller); + } + } + } + }; + + Ok(expanded) +} diff --git a/crates/sword-macros/src/jobs/schedule.rs b/crates/sword-macros/src/jobs/schedule.rs new file mode 100644 index 00000000..c09ddd75 --- /dev/null +++ b/crates/sword-macros/src/jobs/schedule.rs @@ -0,0 +1,17 @@ +mod expand; +mod parsing; + +use crate::shared::Result; +use parsing::ParsedScheduleHandler; +pub use parsing::ScheduleKind; + +use proc_macro::TokenStream; + +pub fn expand_job_task( + kind: ScheduleKind, + attr: TokenStream, + item: TokenStream, +) -> Result { + let parsed = ParsedScheduleHandler::parse(&kind, attr, item)?; + Ok(expand::expand_schedule_handler(parsed)?.into()) +} diff --git a/crates/sword-macros/src/jobs/schedule/expand.rs b/crates/sword-macros/src/jobs/schedule/expand.rs new file mode 100644 index 00000000..f492a69b --- /dev/null +++ b/crates/sword-macros/src/jobs/schedule/expand.rs @@ -0,0 +1,78 @@ +use super::parsing::{ParsedScheduleHandler, Schedule}; +use crate::shared::{Result, paths::*}; + +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; + +pub fn expand_schedule_handler(schedule: ParsedScheduleHandler) -> Result { + let ParsedScheduleHandler { + item_fn, + fn_name, + controller_ty, + schedule, + } = schedule; + + let (job_constructor, schedule_arg) = match &schedule { + Schedule::Cron(lit) => (quote!(#Job::new_async), quote!(#lit)), + Schedule::Repeated(expr) => (quote!(#Job::new_repeated_async), quote!(#expr)), + Schedule::Once(expr) => (quote!(#Job::new_one_shot_async), quote!(#expr)), + }; + + let registration_name = format_ident!( + "__SWORD_JOB_{}_{}", + &controller_ty.to_string().replace("::", "_"), + &fn_name + ); + + let expanded = quote! { + #item_fn + + #[allow(non_upper_case_globals)] + #[doc(hidden)] + const #registration_name: () = { + #inventory::submit! { + #JobFunctionRegistrar { + controller_type_id: #TypeId::of::<#controller_ty>(), + handler: |state: &#State| -> #Job { + let controller = state.borrow::<#controller_ty>().unwrap_or_else(|err| { + #sword_error! { + title: "Failed to retrieve job controller from state", + reason: err, + context: { + "controller" => #stringify!(#controller_ty), + "job" => stringify!(#fn_name), + }, + hints: [ + "Ensure the controller is registered via Module::register_controllers", + ], + } + }); + + #job_constructor(#schedule_arg, move |uuid, scheduler| { + let controller = #Arc::clone(&controller); + + #Box::pin(async move { + controller.#fn_name(uuid, scheduler).await + }) + }) + .unwrap_or_else(|err| { + #sword_error! { + title: "Failed to register job", + reason: err, + context: { + "controller" => #stringify!(#controller_ty), + "job" => stringify!(#fn_name), + }, + hints: [ + "Check the cron expression or duration passed to the job attribute", + ], + } + }) + } + } + } + }; + }; + + Ok(expanded) +} diff --git a/crates/sword-macros/src/jobs/schedule/parsing.rs b/crates/sword-macros/src/jobs/schedule/parsing.rs new file mode 100644 index 00000000..8edd86b4 --- /dev/null +++ b/crates/sword-macros/src/jobs/schedule/parsing.rs @@ -0,0 +1,123 @@ +use crate::shared::{self, Result, cmeta::CMetaStack}; + +use darling::Error; +use proc_macro::TokenStream; +use proc_macro2::Span; +use quote::format_ident; +use syn::{Expr, FnArg, Ident, ItemFn, LitStr, spanned::Spanned}; + +pub enum ScheduleKind { + Cron, + Repeated, + Once, +} + +pub enum Schedule { + Cron(LitStr), + Repeated(Expr), + Once(Expr), +} + +pub struct ParsedScheduleHandler { + pub item_fn: ItemFn, + pub fn_name: Ident, + pub controller_ty: Ident, + pub schedule: Schedule, +} + +impl ParsedScheduleHandler { + pub fn parse(kind: &ScheduleKind, attr: TokenStream, item: TokenStream) -> Result { + let item_fn = syn::parse::(item).map(Self::validate_method)??; + let schedule = Schedule::parse(kind, attr)?; + + let Some(controller_ty) = CMetaStack::get("job_controller", "controller_ty") else { + let error = "\n[ERROR] This attribute macro must be used inside a Job Controller impl block.\n\ + \n\ + Make sure:\n\ + 1. The struct has #[controller(kind = Controller::JobHandler)] attribute\n\ + 2. The struct is defined BEFORE the impl block\n\ + 3. The impl block is for the same struct\n"; + + return Err(Error::custom(error).with_span(&Span::call_site())); + }; + + let controller_ty = format_ident!("{}", controller_ty); + let fn_name = item_fn.sig.ident.clone(); + + Ok(Self { + controller_ty, + fn_name, + item_fn, + schedule, + }) + } + + /// Requires `async fn name(&self, uuid: Uuid, scheduler: JobScheduler)`. + fn validate_method(item_fn: ItemFn) -> Result { + if item_fn.sig.asyncness.is_none() { + Err(Error::custom("a job handler must be `async`").with_span(&item_fn.span()))?; + } + + let mut inputs = item_fn.sig.inputs.iter(); + + let Some(FnArg::Receiver(_)) = inputs.next() else { + Err(Error::custom("a handler must have a `&self` param").with_span(&item_fn.sig))? + }; + + let Some(ty) = inputs.next() else { + Err(Error::custom("expected `uuid` as the second param").with_span(&item_fn.sig))? + }; + + shared::expect_type(ty, "Uuid")?; + + let Some(ty) = inputs.next() else { + Err(Error::custom("expected `scheduler` as the third param").with_span(&item_fn.sig))? + }; + + shared::expect_type(ty, "JobScheduler")?; + + Ok(item_fn) + } +} + +impl Schedule { + pub fn parse(kind: &ScheduleKind, attr: TokenStream) -> Result { + match kind { + ScheduleKind::Cron => { + let lit: LitStr = syn::parse(attr).map_err(|err| { + Error::custom( + "`#[cron]` expects a string literal, e.g. #[cron(\"*/5 * * * * *\")]", + ) + .with_span(&err.span()) + })?; + + if lit.value().trim().is_empty() { + return Err( + Error::custom("cron expression must not be empty").with_span(&lit.span()) + ); + } + + Ok(Self::Cron(lit)) + } + + ScheduleKind::Repeated => { + let expr: Expr = syn::parse(attr).map_err(|err| { + Error::custom("`#[repeated]` expects a duration, e.g. #[repeated(Duration::from_secs(5))]").with_span(&err.span()) + })?; + + Ok(Self::Repeated(expr)) + } + + ScheduleKind::Once => { + let expr: Expr = syn::parse(attr).map_err(|err| { + Error::custom( + "`#[once]` expects a duration, e.g. #[once(Duration::from_secs(30))]", + ) + .with_span(&err.span()) + })?; + + Ok(Self::Once(expr)) + } + } + } +} diff --git a/crates/sword-macros/src/lib.rs b/crates/sword-macros/src/lib.rs index 6fd3d207..27f10eec 100644 --- a/crates/sword-macros/src/lib.rs +++ b/crates/sword-macros/src/lib.rs @@ -1,171 +1,26 @@ -#![allow(irrefutable_let_patterns)] - -mod controllers; +#![allow(dead_code)] mod core; -mod errors; -mod interceptor_derive; mod shared; -mod interceptor { - mod parse; - pub use parse::InterceptorArgs; -} - -use proc_macro::TokenStream; -use quote::quote; -use syn::{DeriveInput, parse_macro_input}; -#[cfg(feature = "web-controllers")] -#[proc_macro_attribute] -pub fn get(attr: TokenStream, item: TokenStream) -> TokenStream { - controllers::web::attributes::attribute("GET", attr, item) -} +#[cfg(feature = "events")] +mod events; -#[cfg(feature = "web-controllers")] -#[proc_macro_attribute] -pub fn post(attr: TokenStream, item: TokenStream) -> TokenStream { - controllers::web::attributes::attribute("POST", attr, item) -} - -#[cfg(feature = "web-controllers")] -#[proc_macro_attribute] -pub fn put(attr: TokenStream, item: TokenStream) -> TokenStream { - controllers::web::attributes::attribute("PUT", attr, item) -} - -#[cfg(feature = "web-controllers")] -#[proc_macro_attribute] -pub fn delete(attr: TokenStream, item: TokenStream) -> TokenStream { - controllers::web::attributes::attribute("DELETE", attr, item) -} - -#[cfg(feature = "web-controllers")] -#[proc_macro_attribute] -pub fn patch(attr: TokenStream, item: TokenStream) -> TokenStream { - controllers::web::attributes::attribute("PATCH", attr, item) -} - -#[cfg(feature = "web-controllers")] -#[proc_macro_attribute] -pub fn head(attr: TokenStream, item: TokenStream) -> TokenStream { - controllers::web::attributes::attribute("HEAD", attr, item) -} - -#[cfg(feature = "web-controllers")] -#[proc_macro_attribute] -pub fn options(attr: TokenStream, item: TokenStream) -> TokenStream { - controllers::web::attributes::attribute("OPTIONS", attr, item) -} - -#[cfg(feature = "web-controllers")] -#[proc_macro_attribute] -pub fn trace(attr: TokenStream, item: TokenStream) -> TokenStream { - controllers::web::attributes::attribute("TRACE", attr, item) -} - -#[cfg(feature = "web-controllers")] -#[proc_macro_attribute] -pub fn connect(attr: TokenStream, item: TokenStream) -> TokenStream { - controllers::web::attributes::attribute("CONNECT", attr, item) -} +#[cfg(feature = "grpc")] +mod grpc; -#[cfg(feature = "web-controllers")] -/// Marks a handler as a Server-Sent Events (SSE) route. -/// -/// The handler must return an `Sse` response wrapping a stream, typically as -/// `Sse>`. The route is served over `GET` with -/// `text/event-stream` content type. -/// -/// ```rust,ignore -/// use sword::web::*; -/// use tokio_stream::{Stream, StreamExt}; -/// -/// #[controller(kind = Controller::Web, path = "/sse")] -/// struct SseController; -/// -/// impl SseController { -/// #[sse("/clock")] -/// async fn clock(&self) -> Sse> { -/// let stream = tokio_stream::iter(0..5).map(|i| Ok(Event::default().event("tick").data(i.to_string()))); -/// Sse::new(stream) -/// } -/// } -/// ``` -/// -/// > **Note:** SSE connections are long-lived. If `request-timeout` is enabled in the -/// > application config, the global timeout layer will terminate the stream, so leave it -/// > disabled or set a generous timeout for streaming routes. -#[proc_macro_attribute] -pub fn sse(attr: TokenStream, item: TokenStream) -> TokenStream { - controllers::web::attributes::attribute("SSE", attr, item) -} +#[cfg(feature = "jobs")] +mod jobs; -/// Defines a Sword controller. -/// Route handlers are declared directly inside the `impl` block using method attributes -/// such as `#[get]`, `#[post]`, `#[put]`, `#[patch]`, `#[delete]`, `#[head]`, `#[options]`, `#[trace]`, `#[connect]`, and `#[sse]`. -/// -/// ### Parameters -/// - `kind`: Controller kind. Use `Controller::Web`, `Controller::SocketIo`, `Controller::Grpc`, or `Controller::EventHandler`. -/// - `path`: Required when `kind = Controller::Web`. -/// - `namespace`: Required when `kind = Controller::SocketIo`. -/// - `service`: Required when `kind = Controller::Grpc`. -/// - `source`: Required when `kind = Controller::EventHandler`. Use `EventSource::Memory`. -/// -/// ### Usage -/// ```rust,ignore -/// #[controller(kind = Controller::Web, path = "/base_path")] -/// struct MyController {} -/// -/// impl MyController { -/// #[get("/sub_path")] -/// async fn my_handler(&self) -> WebResult { -/// Ok(JsonResponse::Ok().message("Hello from MyController")) -/// } -/// } -/// ``` -/// -/// ```rust,ignore -/// #[controller(kind = Controller::SocketIo, namespace = "/chat")] -/// struct ChatController; -/// -/// impl ChatController { -/// #[on("connection")] -/// async fn on_connect(&self, _ctx: SocketContext) {} -/// } -/// ``` -#[proc_macro_attribute] -pub fn controller(attr: TokenStream, item: TokenStream) -> TokenStream { - controllers::expand_controller(attr, item).unwrap_or_else(|err| err.to_compile_error().into()) -} +#[cfg(feature = "socketio")] +mod socketio; -/// Derive macro for creating interceptors. -/// -/// Generates implementations for the `Interceptor` trait. -/// -/// # Usage -/// ```rust,ignore -/// use sword::prelude::*; -/// -/// #[derive(Interceptor)] -/// struct MyInterceptor; -/// -/// // then implement some Interceptor trait variants -/// // depending on the controller kind (e.g. OnRequest, OnConnect.) -/// ``` -#[proc_macro_derive(Interceptor)] -pub fn derive_interceptor(input: TokenStream) -> TokenStream { - interceptor_derive::derive_interceptor(input) - .unwrap_or_else(|err| err.to_compile_error().into()) -} +#[cfg(feature = "web")] +mod web; -/// Marks a route or controller with one or more interceptors. -/// This macro can be used to apply an `Interceptor` to different controller kinds, -/// such as web controllers or Socket.IO controllers. -#[proc_macro_attribute] -pub fn interceptor(attr: TokenStream, item: TokenStream) -> TokenStream { - let _ = attr; - item -} +use proc_macro::TokenStream; +use quote::quote; +use syn::parse_macro_input; /// Defines a configuration struct for the application. /// This macro generates the necessary code to deserialize the struct from @@ -186,13 +41,8 @@ pub fn interceptor(attr: TokenStream, item: TokenStream) -> TokenStream { /// } /// ``` #[proc_macro_attribute] -pub fn config(args: TokenStream, item: TokenStream) -> TokenStream { - let input = parse_macro_input!(item as DeriveInput); - - match core::config::expand_config_struct(args, &input) { - Ok(tokens) => tokens, - Err(err) => err.to_compile_error().into(), - } +pub fn config(attr: TokenStream, item: TokenStream) -> TokenStream { + shared::emit(core::expand_config_struct(attr, item)) } /// Marks a struct as injectable. @@ -270,93 +120,101 @@ pub fn config(args: TokenStream, item: TokenStream) -> TokenStream { /// ``` #[proc_macro_attribute] pub fn injectable(attr: TokenStream, item: TokenStream) -> TokenStream { - core::injectable::expand_injectable(attr, item) - .unwrap_or_else(|err| err.to_compile_error().into()) + shared::emit(core::expand_injectable(attr, item)) } -/// Derive macro for HTTP error enums. -/// -/// Generates implementations for: -/// - `From for JsonResponse` - Converts error to JSON response -/// - `IntoResponse` - Allows returning error directly from handlers -/// -/// **Note**: Use with `thiserror::Error` for `Display`, `Error`, and `#[from]`. -/// -/// # Attributes -/// -/// Enum-level defaults can be declared with `#[http_error(...)]` and overridden per -/// variant with `#[http(...)]`. -/// -/// **For direct responses:** -/// - `code = `: HTTP status code (required) -/// - `message = ""`: Static client message (optional) -/// - `message = `: Uses a named field as the client message (optional) -/// - `error = `: Single error field to include (optional, named fields only) -/// - `errors = `: Multiple errors field to include (optional, named fields only) -/// -/// **For delegation:** -/// - `transparent`: Delegate to inner type's `From for Json` (for wrapping other `HttpError` types) -/// -/// **Tracing:** -/// - `tracing = ` inside `#[http_error(...)]` or `#[http(...)]` -/// - `#[tracing(level)]`: Backward-compatible shorthand at variant level -/// - `level`: One of `trace`, `debug`, `info`, `warn`, `error` -/// - Uses the internal `thiserror::Error` display for the `error` log field -/// - Logs variant fields as structured tracing fields when available -/// - Compatible with `RUST_LOG` for filtering -/// - Not allowed with `transparent` variants -/// - When `tracing` is not specified, the level defaults from the HTTP status code -/// (`2xx`/`3xx` → `info`, `4xx` → `warn`, `5xx` → `error`), matching the web access -/// logger's `auto` policy. -/// -/// ### Tracing Output -/// The generated logs include: -/// - `error`: The internal `thiserror` display string -/// - `error_type`: The variant name as string -/// - `status_code`: The HTTP status code -/// - For named variants: Each field as `field_name = ?field_value` -/// - For unnamed variants (single field): `inner = ?field` -/// - Unit variants: `error`, `error_type`, and `status_code` +/// Defines a Sword controller. /// -/// # Example +/// The controller kind and its specific arguments are declared in the +/// attribute, e.g. `#[controller(kind = Controller::Web, path = "/api")]`. +#[proc_macro_attribute] +pub fn controller(attr: TokenStream, item: TokenStream) -> TokenStream { + shared::emit(core::expand_controller(attr, item)) +} + +/// Derive macro for creating interceptors. +/// +/// Generates implementations for the `Interceptor` trait. /// +/// # Usage /// ```rust,ignore /// use sword::prelude::*; -/// use thiserror::Error; /// -/// #[derive(Debug, Error, HttpError)] -/// #[http_error(code = 500, tracing = error, message = "Internal server error")] -/// pub enum ApiError { -/// #[error("Not found")] -/// #[http(code = 404, message = "Not found", tracing = info)] -/// NotFound, +/// #[derive(Interceptor)] +/// struct MyInterceptor; /// -/// #[error("Conflict on field {field}: {value}")] -/// #[http(code = 409, message = client_message, error = detail)] -/// Conflict { -/// client_message: String, -/// field: String, -/// value: String, -/// detail: serde_json::Value, -/// }, +/// // then implement some Interceptor trait variants +/// // depending on the controller kind (e.g. OnRequest, OnConnect.) +/// ``` +#[proc_macro_derive(Interceptor)] +pub fn derive_interceptor(item: TokenStream) -> TokenStream { + shared::emit(core::expand_interceptor_derive(item)) +} + +/// Marks a route or controller with one or more interceptors. +/// This macro can be used to apply an `Interceptor` to different controller kinds, +/// such as web controllers or Socket.IO controllers. +#[proc_macro_attribute] +pub fn interceptor(attr: TokenStream, item: TokenStream) -> TokenStream { + let _ = attr; + item +} + +#[cfg(feature = "events")] +#[proc_macro_attribute] +pub fn event(attr: TokenStream, item: TokenStream) -> TokenStream { + shared::emit(events::expand_event_struct(attr, item)) +} + +#[cfg(feature = "events")] +#[proc_macro_attribute] +pub fn handle(attr: TokenStream, item: TokenStream) -> TokenStream { + shared::emit(events::expand_handle(attr, item)) +} + +/// Unified handler attribute for Socket.IO events. +/// +/// ### Event Types +/// - `#[on("connection")]` - Called when a client connects +/// - `#[on("disconnection")]` - Called when a client disconnects +/// - `#[on("fallback")]` - Called for unhandled events +/// - `#[on("custom_event")]` - Called for custom event names +/// +/// ### Parameters +/// All handlers receive `&self` and `ctx: SocketContext` which provides access to: +/// - Socket operations via `ctx` +/// - Message data via `ctx.try_data::()` +/// - Event name via `ctx.event()` +/// - Acknowledgments via `ctx.ack()` +/// +/// ### Usage +/// ```rust,ignore +/// #[controller(kind = Controller::SocketIo, namespace = "/chat")] +/// pub struct ChatController { ... } /// -/// #[error("IO Error: {0}")] -/// Io(#[from] std::io::Error), +/// impl ChatController { +/// #[on("connection")] +/// async fn on_connect(&self, ctx: SocketContext) { +/// println!("Client connected: {}", ctx.id()); +/// } /// -/// #[error("Auth Error: {0}")] -/// #[http(transparent)] // Delegates to other "HttpError" derivation -/// Auth(#[from] AuthError), +/// #[on("message")] +/// async fn handle_message(&self, ctx: SocketContext) { +/// let msg: String = ctx.try_data().unwrap(); +/// println!("Received: {}", msg); +/// } /// } /// ``` +#[cfg(feature = "socketio")] +#[proc_macro_attribute] +pub fn on(attr: TokenStream, item: TokenStream) -> TokenStream { + shared::emit(socketio::expand_on_handler(attr, item)) +} + +#[cfg(feature = "web")] #[proc_macro_derive(HttpError, attributes(http, http_error, tracing))] -#[cfg(feature = "web-controllers")] pub fn derive_http_error(input: TokenStream) -> TokenStream { - let input = parse_macro_input!(input as DeriveInput); - - match errors::derive_http_error(input) { - Ok(tokens) => tokens.into(), - Err(err) => err.to_compile_error().into(), - } + shared::emit(web::derive_http_error(input)) } /// Derive macro for gRPC error enums. @@ -423,14 +281,40 @@ pub fn derive_http_error(input: TokenStream) -> TokenStream { /// Database(#[from] anyhow::Error), /// } /// ``` +#[cfg(feature = "grpc")] #[proc_macro_derive(GrpcError, attributes(grpc, grpc_error, tracing))] pub fn derive_grpc_error(input: TokenStream) -> TokenStream { - let input = parse_macro_input!(input as DeriveInput); + shared::emit(grpc::derive_grpc_error(input)) +} - match errors::derive_grpc_error(input) { - Ok(tokens) => tokens.into(), - Err(err) => err.to_compile_error().into(), - } +#[cfg(feature = "jobs")] +#[proc_macro_attribute] +pub fn cron(attr: TokenStream, item: TokenStream) -> TokenStream { + shared::emit(jobs::schedule::expand_job_task( + jobs::schedule::ScheduleKind::Cron, + attr, + item, + )) +} + +#[cfg(feature = "jobs")] +#[proc_macro_attribute] +pub fn repeated(attr: TokenStream, item: TokenStream) -> TokenStream { + shared::emit(jobs::schedule::expand_job_task( + jobs::schedule::ScheduleKind::Repeated, + attr, + item, + )) +} + +#[cfg(feature = "jobs")] +#[proc_macro_attribute] +pub fn once(attr: TokenStream, item: TokenStream) -> TokenStream { + shared::emit(jobs::schedule::expand_job_task( + jobs::schedule::ScheduleKind::Once, + attr, + item, + )) } /// ### This is just a re-export of `tokio::main` to simplify the initial setup of @@ -725,94 +609,30 @@ pub fn main(_args: TokenStream, item: TokenStream) -> TokenStream { output.into() } -#[cfg(feature = "event-handlers")] -/// Defines an event struct for use with the event queue. -/// -/// Generates the `Event` trait implementation and derives `Clone`. -/// -/// # Example -/// -/// ```rust,ignore -/// #[event(key = "mail.send.failed")] -/// struct MailFailedEvent { -/// to: String, -/// error: String, -/// } -/// ``` -#[proc_macro_attribute] -pub fn event(args: TokenStream, item: TokenStream) -> TokenStream { - core::event::expand_event_struct(args, item).unwrap_or_else(|err| err.to_compile_error().into()) -} +// ---- web route attributes -------------------------------------------------- -#[cfg(feature = "event-handlers")] -/// Registers an event handler method for an `EventHandler` controller. -/// -/// The attribute takes the full event key as argument, which must match -/// the `key` of the event being published via `EventPublisher`. -/// -/// # Example -/// -/// ```rust,ignore -/// #[event(key = "mail.send.failed")] -/// struct MailFailedEvent { -/// to: String, -/// error: String, -/// } -/// -/// #[controller(kind = Controller::EventHandler, source = EventSource::Memory)] -/// struct MailHandler { -/// mailer: Arc, -/// } -/// -/// impl MailHandler { -/// #[handle("mail.send.failed")] -/// async fn on_failed(&self, event: MailFailedEvent) -> Result<()> { -/// self.mailer.resend(&event.to).await?; -/// Ok(()) -/// } -/// } -/// ``` -#[proc_macro_attribute] -pub fn handle(attr: TokenStream, item: TokenStream) -> TokenStream { - controllers::event_handler::expand_handle(attr, item) - .unwrap_or_else(|err| err.to_compile_error().into()) +#[cfg(feature = "web")] +macro_rules! define_routes { + ($($fn_name:ident => $method:literal),* $(,)?) => { + $( + #[proc_macro_attribute] + pub fn $fn_name(attr: TokenStream, item: TokenStream) -> TokenStream { + shared::emit(web::expand_route($method, attr, item)) + } + )* + }; } -#[cfg(feature = "socketio-controllers")] -/// Unified handler attribute for Socket.IO events. -/// -/// ### Event Types -/// - `#[on("connection")]` - Called when a client connects -/// - `#[on("disconnection")]` - Called when a client disconnects -/// - `#[on("fallback")]` - Called for unhandled events -/// - `#[on("custom_event")]` - Called for custom event names -/// -/// ### Parameters -/// All handlers receive `&self` and `ctx: SocketContext` which provides access to: -/// - Socket operations via `ctx` -/// - Message data via `ctx.try_data::()` -/// - Event name via `ctx.event()` -/// - Acknowledgments via `ctx.ack()` -/// -/// ### Usage -/// ```rust,ignore -/// #[controller(kind = Controller::SocketIo, namespace = "/chat")] -/// pub struct ChatController { ... } -/// -/// impl ChatController { -/// #[on("connection")] -/// async fn on_connect(&self, ctx: SocketContext) { -/// println!("Client connected: {}", ctx.id()); -/// } -/// -/// #[on("message")] -/// async fn handle_message(&self, ctx: SocketContext) { -/// let msg: String = ctx.try_data().unwrap(); -/// println!("Received: {}", msg); -/// } -/// } -/// ``` -#[proc_macro_attribute] -pub fn on(attr: TokenStream, item: TokenStream) -> TokenStream { - controllers::expand_on_handler(attr, item).unwrap_or_else(|err| err.to_compile_error().into()) -} +#[cfg(feature = "web")] +define_routes!( + get => "GET", + post => "POST", + put => "PUT", + patch => "PATCH", + delete => "DELETE", + head => "HEAD", + options => "OPTIONS", + trace => "TRACE", + connect => "CONNECT", + sse => "SSE", +); diff --git a/crates/sword-macros/src/controllers/shared/cmeta.rs b/crates/sword-macros/src/shared/cmeta.rs similarity index 100% rename from crates/sword-macros/src/controllers/shared/cmeta.rs rename to crates/sword-macros/src/shared/cmeta.rs diff --git a/crates/sword-macros/src/shared/errors.rs b/crates/sword-macros/src/shared/errors.rs new file mode 100644 index 00000000..781c7ed0 --- /dev/null +++ b/crates/sword-macros/src/shared/errors.rs @@ -0,0 +1,403 @@ +use darling::{Error, FromMeta}; +use proc_macro2::TokenStream; +use quote::quote; +use syn::{ + Attribute, Expr, ExprLit, Fields, FieldsNamed, Ident, Lit, LitStr, Path, spanned::Spanned, +}; + +// ===== Shared value types (used by HttpError and GrpcError) ===== + +/// A client-facing message: a fixed string, a field reference, or an +/// interpolated template with `{field}` placeholders. +#[derive(Debug, Clone)] +pub enum MessageValue { + Static(String), + Field(String), + Interpolated(String), +} + +impl FromMeta for MessageValue { + fn from_expr(expr: &Expr) -> darling::Result { + match expr { + Expr::Lit(ExprLit { + lit: Lit::Str(s), .. + }) => { + let value = s.value(); + + if value.contains('{') || value.contains('}') { + Ok(Self::Interpolated(value)) + } else { + Ok(Self::Static(value)) + } + } + Expr::Path(path) => Ok(Self::Field(path_ident(&path.path)?)), + _ => Err(Error::unexpected_expr_type(expr)), + } + } +} + +/// A reference to a named field of a variant (`error = detail`). +#[derive(Debug, Clone)] +pub struct FieldRef(pub String); + +impl FieldRef { + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl FromMeta for FieldRef { + fn from_expr(expr: &Expr) -> darling::Result { + match expr { + Expr::Path(path) => Ok(FieldRef(path_ident(&path.path)?)), + _ => Err(Error::custom("expected a field identifier").with_span(expr)), + } + } +} + +/// A tracing level, from either `tracing = error` or `#[tracing(error)]`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TracingLevel { + Trace, + Debug, + Info, + Warn, + Error, +} + +impl TracingLevel { + pub fn parse(name: &str) -> Option { + match name { + "trace" => Some(Self::Trace), + "debug" => Some(Self::Debug), + "info" => Some(Self::Info), + "warn" => Some(Self::Warn), + "error" => Some(Self::Error), + _ => None, + } + } +} + +impl FromMeta for TracingLevel { + fn from_expr(expr: &Expr) -> darling::Result { + match expr { + Expr::Path(path) => { + let name = path_ident(&path.path)?; + Self::parse(&name).ok_or_else(|| invalid_tracing_level(path)) + } + Expr::Lit(ExprLit { + lit: Lit::Str(s), .. + }) => Self::parse(&s.value()).ok_or_else(|| invalid_tracing_level(s)), + _ => Err(Error::unexpected_expr_type(expr)), + } + } +} + +/// Reads the standalone `#[tracing(level)]` shorthand (darling does not see it). +pub fn read_tracing_attr(attrs: &[Attribute]) -> crate::shared::Result> { + let mut level = None; + + for attr in attrs.iter().filter(|attr| attr.path().is_ident("tracing")) { + attr.parse_nested_meta(|meta| { + let ident = meta + .path + .get_ident() + .ok_or_else(|| syn::Error::new(meta.path.span(), "expected a tracing level"))?; + + let parsed = TracingLevel::parse(&ident.to_string()).ok_or_else(|| { + syn::Error::new( + ident.span(), + "invalid tracing level, expected one of: trace, debug, info, warn, error", + ) + })?; + + if level.is_some() { + return Err(syn::Error::new( + ident.span(), + "duplicate `tracing` attribute", + )); + } + + level = Some(parsed); + + Ok(()) + })?; + } + + Ok(level) +} + +fn path_ident(path: &Path) -> darling::Result { + path.get_ident() + .map(|ident| ident.to_string()) + .ok_or_else(|| Error::custom("expected a field identifier").with_span(path)) +} + +fn invalid_tracing_level(spanned: &impl Spanned) -> Error { + Error::custom("invalid tracing level, expected one of: trace, debug, info, warn, error") + .with_span(spanned) +} + +// ===== Template helpers ===== + +/// Extracts `{field_name}` references from an interpolated template, skipping +/// `{{`/`}}` escapes. +pub fn extract_template_fields(template: &str) -> Vec { + let mut fields = Vec::new(); + let mut chars = template.chars().peekable(); + + while let Some(ch) = chars.next() { + if ch != '{' { + continue; + } + + if chars.peek() == Some(&'{') { + chars.next(); + continue; + } + + let mut name = String::new(); + while let Some(&c) = chars.peek() { + if c == '}' { + break; + } + name.push(c); + chars.next(); + } + + if !name.is_empty() { + fields.push(name); + } + + chars.next(); + } + + fields +} + +// ===== Codegen helpers ===== + +pub fn generate_pattern(enum_name: &Ident, variant_name: &Ident, fields: &Fields) -> TokenStream { + match fields { + Fields::Named(named) => { + let field_names: Vec<_> = named.named.iter().map(|f| &f.ident).collect(); + quote! { #enum_name::#variant_name { #(#field_names),* } } + } + Fields::Unnamed(_) => quote! { #enum_name::#variant_name(_inner) }, + Fields::Unit => quote! { #enum_name::#variant_name }, + } +} + +pub fn generate_message_expr( + message: &Option, + fallback: impl FnOnce() -> TokenStream, +) -> TokenStream { + match message { + Some(MessageValue::Static(message)) => quote! { #message }, + Some(MessageValue::Field(field_name)) => { + let field_ident = Ident::new(field_name, proc_macro2::Span::call_site()); + quote! { format!("{}", #field_ident) } + } + Some(MessageValue::Interpolated(template)) => { + // Implicit capture: `format!("... {field} ...")` picks `field` from scope. + let literal = LitStr::new(template, proc_macro2::Span::call_site()); + quote! { format!(#literal) } + } + None => fallback(), + } +} + +pub fn generate_tracing_stmt( + variant_name: &Ident, + level: Option, + fields: &Fields, + code_key: &Ident, + code_value: TokenStream, + title: &str, +) -> TokenStream { + let Some(level) = level else { + return quote! {}; + }; + + let tracing = crate::shared::paths::tracing; + let tracing_macro = match level { + TracingLevel::Trace => quote! { #tracing::trace }, + TracingLevel::Debug => quote! { #tracing::debug }, + TracingLevel::Info => quote! { #tracing::info }, + TracingLevel::Warn => quote! { #tracing::warn }, + TracingLevel::Error => quote! { #tracing::error }, + }; + + let variant_str = variant_name.to_string(); + + match fields { + Fields::Named(named) => { + let field_logs = named.named.iter().map(|field| { + let field_name = field.ident.as_ref().unwrap(); + quote! { #field_name = ?#field_name, } + }); + + quote! { + #tracing_macro!( + error = %__sword_internal_error, + error_type = #variant_str, + #code_key = #code_value, + #(#field_logs)* + #title + ); + } + } + Fields::Unnamed(_) => { + quote! { + #tracing_macro!( + error = %__sword_internal_error, + inner = ?_inner, + error_type = #variant_str, + #code_key = #code_value, + #title + ); + } + } + Fields::Unit => { + quote! { + #tracing_macro!( + error = %__sword_internal_error, + error_type = #variant_str, + #code_key = #code_value, + #title + ); + } + } + } +} + +/// Default tracing level for a status/grpc code, matching the access logger: +/// 2xx/3xx → info, 4xx → warn, else → error. +pub fn default_tracing_level(code: u16) -> TracingLevel { + match code { + 200..=399 => TracingLevel::Info, + 400..=499 => TracingLevel::Warn, + _ => TracingLevel::Error, + } +} + +// ===== Validation helpers ===== + +pub fn validate_transparent_container(transparent: bool, attr_name: &str) -> syn::Result<()> { + if transparent { + return Err(syn::Error::new( + proc_macro2::Span::call_site(), + format!("`transparent` is only valid inside #[{attr_name}(...)] on enum variants"), + )); + } + + Ok(()) +} + +pub fn validate_transparent_variant( + transparent: bool, + has_conflict: bool, + ident: &Ident, + conflict_desc: &str, +) -> syn::Result<()> { + if transparent && has_conflict { + return Err(syn::Error::new_spanned( + ident, + format!("`transparent` cannot be combined with {conflict_desc}"), + )); + } + + Ok(()) +} + +/// Transparent variants must have exactly one unnamed field. +pub fn validate_transparent_single_unnamed( + variant_name: &Ident, + fields: &Fields, +) -> syn::Result<()> { + let is_single_unnamed = + matches!(fields, Fields::Unnamed(unnamed) if unnamed.unnamed.len() == 1); + + if !is_single_unnamed { + return Err(syn::Error::new_spanned( + variant_name, + "transparent variants must have exactly one unnamed field", + )); + } + + Ok(()) +} + +/// Non-transparent tuple variants are only supported with exactly one field. +pub fn validate_single_unnamed( + variant_name: &Ident, + unnamed: &syn::FieldsUnnamed, +) -> syn::Result<()> { + if unnamed.unnamed.len() != 1 { + return Err(syn::Error::new_spanned( + variant_name, + "non-transparent tuple variants are only supported with exactly one field", + )); + } + + Ok(()) +} + +/// Checks that `Field` and `Interpolated` message references point to real fields. +pub fn validate_message_references( + enum_name: &Ident, + variant_name: &Ident, + fields: &FieldsNamed, + message: &Option, +) -> syn::Result<()> { + if let Some(MessageValue::Field(field_name)) = &message { + ensure_named_field_exists(enum_name, variant_name, fields, field_name, "message")?; + } + + if let Some(MessageValue::Interpolated(template)) = &message { + for field in extract_template_fields(template) { + ensure_named_field_exists(enum_name, variant_name, fields, &field, "message")?; + } + } + + Ok(()) +} + +/// Checks that a named variant has the fields referenced by `error`/`errors`. +pub fn validate_named_field_refs( + enum_name: &Ident, + variant_name: &Ident, + fields: &FieldsNamed, + extra_refs: &[(&str, &str)], +) -> syn::Result<()> { + for (attr_name, field_name) in extra_refs { + ensure_named_field_exists(enum_name, variant_name, fields, field_name, attr_name)?; + } + + Ok(()) +} + +fn ensure_named_field_exists( + enum_name: &Ident, + variant_name: &Ident, + fields: &FieldsNamed, + field_name: &str, + attr_name: &str, +) -> syn::Result<()> { + let exists = fields + .named + .iter() + .filter_map(|field| field.ident.as_ref()) + .any(|ident| ident == field_name); + + if exists { + return Ok(()); + } + + Err(syn::Error::new_spanned( + variant_name, + format!( + "`{attr_name} = {field_name}` references a missing field on {enum_name}::{variant_name}`" + ), + )) +} diff --git a/crates/sword-macros/src/shared/mod.rs b/crates/sword-macros/src/shared/mod.rs index 6dbb1aa4..0b7b1e36 100644 --- a/crates/sword-macros/src/shared/mod.rs +++ b/crates/sword-macros/src/shared/mod.rs @@ -1,5 +1,39 @@ -mod generation; -mod parsing; +pub mod cmeta; +pub mod errors; +pub mod paths; +pub mod structs; +pub mod traits; -pub use generation::*; -pub use parsing::*; +pub use darling::Error; +use syn::{FnArg, Type}; + +pub type Result = std::result::Result; + +pub fn emit(result: Result) -> proc_macro::TokenStream { + match result { + Ok(ts) => ts, + Err(err) => err.write_errors().into(), + } +} + +pub fn expect_type(arg: &FnArg, expected: &str) -> Result<()> { + let FnArg::Typed(pat) = arg else { + return Ok(()); + }; + + let last = match pat.ty.as_ref() { + Type::Path(type_path) => type_path + .path + .segments + .last() + .map(|segment| segment.ident.to_string()), + _ => None, + }; + + if last.as_deref() != Some(expected) { + let message = format!("this parameter must be of type `{expected}`"); + Err(Error::custom(message).with_span(&pat.ty))? + } + + Ok(()) +} diff --git a/crates/sword-macros/src/shared/parsing.rs b/crates/sword-macros/src/shared/parsing.rs deleted file mode 100644 index cbbb97f7..00000000 --- a/crates/sword-macros/src/shared/parsing.rs +++ /dev/null @@ -1,26 +0,0 @@ -use syn::{Fields, Ident, ItemStruct, Type}; - -pub struct StructFields; - -impl StructFields { - pub fn parse(input: &ItemStruct) -> syn::Result> { - let mut fields_vec = Vec::new(); - - if let Fields::Unnamed(_) = input.fields { - return Err(syn::Error::new( - input.ident.span(), - "Tuple structs are not supported. Please use named fields.", - )); - } - - if let Fields::Named(named_fields) = &input.fields { - for field in &named_fields.named { - if let Some(ident) = &field.ident { - fields_vec.push((ident.clone(), field.ty.clone())); - } - } - } - - Ok(fields_vec) - } -} diff --git a/crates/sword-macros/src/shared/paths.rs b/crates/sword-macros/src/shared/paths.rs new file mode 100644 index 00000000..82ec13d9 --- /dev/null +++ b/crates/sword-macros/src/shared/paths.rs @@ -0,0 +1,102 @@ +macro_rules! path { + ($name:ident => $($path:tt)*) => { + #[allow(non_camel_case_types, dead_code)] + pub struct $name; + impl quote::ToTokens for $name { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { + tokens.extend(quote::quote!($($path)*)); + } + } + }; +} + +path!(TypeId => ::std::any::TypeId); +path!(type_name => ::std::any::type_name); +path!(Box => ::std::boxed::Box); +path!(Arc => ::std::sync::Arc); +path!(Clone => ::std::clone::Clone); +path!(Debug => ::std::fmt::Debug); +path!(Display => ::std::fmt::Display); +path!(Any => ::std::any::Any); +path!(format => ::std::format); +path!(stringify => ::std::stringify); + +path!(State => ::sword::internal::core::State); +path!(Component => ::sword::internal::core::Component); +path!(Provider => ::sword::internal::core::Provider); + +path!(Controller => ::sword::internal::core::Controller); +path!(ControllerSpec => ::sword::internal::core::ControllerSpec); + +path!(MethodRouter => ::sword::internal::web::MethodRouter); +path!(WebController => ::sword::internal::web::WebController); +path!(WebControllerRegistrar => ::sword::internal::web::WebControllerRegistrar); +path!(RouteRegistrar => ::sword::internal::web::RouteRegistrar); + +path!(Request => ::sword::web::Request); +path!(OnRequest => ::sword::web::OnRequest); +path!(OnRequestWithConfig => ::sword::web::OnRequestWithConfig); +path!(Next => ::sword::web::Next); + +path!(mw_with_state => ::sword::internal::web::mw_with_state); +path!(routing => ::sword::internal::web::routing); +path!(IntoResponse => ::sword::internal::web::IntoResponse); +path!(AxumResponse => ::sword::internal::web::AxumResponse); +path!(JsonResponse => ::sword::web::JsonResponse); + +path!(StreamRequest => ::sword::web::StreamRequest); + +path!(OnRequestStream => ::sword::web::OnRequestStream); +path!(OnRequestStreamWithConfig => ::sword::web::OnRequestStreamWithConfig); + +path!(Event => ::sword::internal::events::Event); +path!(EventHandler => ::sword::internal::events::EventHandler); +path!(EventControllerRegistrar => ::sword::internal::events::EventControllerRegistrar); +path!(EventRouteRegistrar => ::sword::internal::events::EventRouteRegistrar); +path!(EventHandlerFn => ::sword::internal::events::EventHandlerFn); +path!(EventHandlerResult => ::sword::internal::events::EventHandlerResult); +path!(EventSource => ::sword::internal::core::EventSource); +path!(EventsBoxedFuture => ::std::pin::Pin<::std::boxed::Box> + ::std::marker::Send>>); + +path!(Interceptor => ::sword::internal::core::Interceptor); +path!(InterceptorRegistrar => ::sword::internal::core::InterceptorRegistrar); + +path!(Build => ::sword::internal::core::Build); +path!(HasDeps => ::sword::internal::core::HasDeps); + +path!(Config => ::sword::internal::core::Config); +path!(ConfigItem => ::sword::internal::core::ConfigItem); +path!(ConfigRegistrar => ::sword::internal::core::ConfigRegistrar); + +path!(DependencyInjectionError => ::sword::internal::core::DependencyInjectionError); + +path!(inventory => ::sword::internal::inventory); +path!(sword_error => ::sword::internal::core::sword_error); +path!(tracing => ::sword::internal::tracing); + +path!(Job => ::sword::internal::jobs::Job); +path!(JobFunctionRegistrar => ::sword::internal::jobs::JobFunctionRegistrar); +path!(JobControllerRegistrar => ::sword::internal::jobs::JobControllerRegistrar); + +path!(SocketBoxedFuture => ::std::pin::Pin<::std::boxed::Box + ::std::marker::Send>>); + +path!(SocketIo => ::sword::socketio::SocketIo); +path!(SocketRef => ::sword::socketio::SocketRef); +path!(SocketContext => ::sword::socketio::SocketContext); +path!(OnConnect => ::sword::socketio::OnConnect); +path!(SocketEventKind => ::sword::internal::socketio::SocketEventKind); +path!(HandlerRegistrar => ::sword::internal::socketio::HandlerRegistrar); +path!(SocketIoHandlerRegistrar => ::sword::internal::socketio::SocketIoHandlerRegistrar); +path!(SocketIoController => ::sword::internal::socketio::SocketIoController); +path!(ConnectHandler => ::sword::internal::socketio::ConnectHandler); + +path!(GrpcController => ::sword::internal::grpc::GrpcController); +path!(GrpcControllerRegistrar => ::sword::internal::grpc::GrpcControllerRegistrar); +path!(GrpcServiceRegistry => ::sword::internal::grpc::GrpcServiceRegistry); +path!(GrpcBodyLimitValue => ::sword::internal::grpc::GrpcBodyLimitValue); +path!(tonic => ::sword::internal::grpc::tonic); +path!(tonic_async_interceptor => ::sword::internal::grpc::tonic_async_interceptor); +path!(GrpcStatus => ::sword::grpc::Status); +path!(GrpcCode => ::sword::grpc::Code); +path!(GrpcOnRequest => ::sword::grpc::OnRequest); +path!(GrpcOnRequestWithConfig => ::sword::grpc::OnRequestWithConfig); diff --git a/crates/sword-macros/src/shared/structs.rs b/crates/sword-macros/src/shared/structs.rs new file mode 100644 index 00000000..5f47fd24 --- /dev/null +++ b/crates/sword-macros/src/shared/structs.rs @@ -0,0 +1,24 @@ +use darling::{FromDeriveInput, ast, util}; +use syn::{Ident, Type}; + +#[derive(FromDeriveInput)] +#[darling(supports(struct_named, struct_unit))] +pub struct Struct { + pub ident: Ident, + pub data: ast::Data, +} + +impl Struct { + pub fn fields(&self) -> Vec<(Ident, Type)> { + self.data + .as_ref() + .take_struct() + .map(|s| { + s.fields + .into_iter() + .map(|f| (f.ident.clone().expect("named field"), f.ty.clone())) + .collect() + }) + .unwrap_or_default() + } +} diff --git a/crates/sword-macros/src/shared/generation.rs b/crates/sword-macros/src/shared/traits.rs similarity index 77% rename from crates/sword-macros/src/shared/generation.rs rename to crates/sword-macros/src/shared/traits.rs index 2e466f0b..d59b48b6 100644 --- a/crates/sword-macros/src/shared/generation.rs +++ b/crates/sword-macros/src/shared/traits.rs @@ -1,79 +1,9 @@ +use crate::shared::paths::*; + use proc_macro2::TokenStream; use quote::quote; use syn::{Ident, Type}; -pub fn extract_arc_inner_type(ty: &Type) -> Option<&Type> { - let Type::Path(type_path) = ty else { - return None; - }; - - let path = &type_path.path; - let last_segment = path.segments.last()?; - - if last_segment.ident != "Arc" { - return None; - } - - if !is_std_arc_path(path) { - return None; - } - - match &last_segment.arguments { - syn::PathArguments::AngleBracketed(args) => args.args.first().and_then(|arg| match arg { - syn::GenericArgument::Type(inner) => Some(inner), - _ => None, - }), - _ => None, - } -} - -fn is_std_arc_path(path: &syn::Path) -> bool { - let segments: Vec<_> = path.segments.iter().collect(); - - match segments.len() { - 1 => true, - 2 => segments[0].ident == "sync", - 3 => { - let root = &segments[0].ident; - let mid = &segments[1].ident; - (root == "std" || root == "alloc") && mid == "sync" - } - _ => false, - } -} - -pub fn generate_field_extractions(fields: &[(Ident, Type)]) -> TokenStream { - let extractions = - fields.iter().map( - |(field_name, field_type)| match extract_arc_inner_type(field_type) { - Some(inner_type) => { - quote! { - let #field_name = state.borrow::<#inner_type>()?; - } - } - None => { - quote! { - let #field_name = state.get::<#field_type>()?; - } - } - }, - ); - - quote! { - #(#extractions)* - } -} - -pub fn generate_field_assignments(fields: &[(Ident, Type)]) -> TokenStream { - let assignments = fields.iter().map(|(name, _)| { - quote! { #name } - }); - - quote! { - #(#assignments),* - } -} - /// Generates the implementation of the `Build` trait for a component. /// /// This generator is used by (#[interceptor], #[injectable], etc.) macros @@ -82,13 +12,13 @@ pub fn generate_field_assignments(fields: &[(Ident, Type)]) -> TokenStream { /// The generated code extracts each field dependency from the `State` using /// `State::get` (by-value fields) or `State::borrow` (`Arc` fields) and /// assembles them into a new component instance. -pub fn gen_build(name: &Ident, fields: &[(Ident, Type)]) -> TokenStream { +pub fn generate_build_impl(name: &Ident, fields: &[(Ident, Type)]) -> TokenStream { let extracts = generate_field_extractions(fields); let assigns = generate_field_assignments(fields); quote! { - impl ::sword::internal::core::Build for #name { - fn build(state: &::sword::internal::core::State) -> Result + impl #Build for #name { + fn build(state: &#State) -> Result where Self: Sized, { @@ -104,7 +34,7 @@ pub fn gen_build(name: &Ident, fields: &[(Ident, Type)]) -> TokenStream { /// /// This generator creates an explicit Clone implementation that clones each /// field individually, useful for components with Arc-wrapped dependencies. -pub fn gen_clone(name: &Ident, fields: &[(Ident, Type)]) -> TokenStream { +pub fn generate_clone_impl(name: &Ident, fields: &[(Ident, Type)]) -> TokenStream { let clones = fields.iter().map(|(field_name, _)| { quote! { #field_name: self.#field_name.clone() } }); @@ -125,20 +55,90 @@ pub fn gen_clone(name: &Ident, fields: &[(Ident, Type)]) -> TokenStream { /// This generator creates the `deps()` method that returns the `TypeId` of allS /// component dependencies, allowing the DI system to resolve the correct /// construction order using topological sorting. -pub fn gen_deps(name: &Ident, fields: &[(Ident, Type)]) -> TokenStream { +pub fn generate_has_deps_impl(name: &Ident, fields: &[(Ident, Type)]) -> TokenStream { let dep_types = fields.iter().map(|(_, field_type)| { if let Some(inner_type) = extract_arc_inner_type(field_type) { - quote! { ::std::any::TypeId::of::<#inner_type>() } + quote! { #TypeId::of::<#inner_type>() } } else { - quote! { ::std::any::TypeId::of::<#field_type>() } + quote! { #TypeId::of::<#field_type>() } } }); quote! { - impl ::sword::internal::core::HasDeps for #name { - fn deps() -> Vec<::std::any::TypeId> { + impl #HasDeps for #name { + fn deps() -> Vec<#TypeId> { vec![#(#dep_types),*] } } } } + +fn extract_arc_inner_type(ty: &Type) -> Option<&Type> { + let Type::Path(type_path) = ty else { + return None; + }; + + let path = &type_path.path; + let last_segment = path.segments.last()?; + + if last_segment.ident != "Arc" { + return None; + } + + let segments: Vec<_> = path.segments.iter().collect(); + + let is_std_arc_path = match segments.len() { + 1 => true, + 2 => segments[0].ident == "sync", + 3 => { + let root = &segments[0].ident; + let mid = &segments[1].ident; + (root == "std" || root == "alloc") && mid == "sync" + } + _ => false, + }; + + if !is_std_arc_path { + return None; + } + + match &last_segment.arguments { + syn::PathArguments::AngleBracketed(args) => args.args.first().and_then(|arg| match arg { + syn::GenericArgument::Type(inner) => Some(inner), + _ => None, + }), + _ => None, + } +} + +fn generate_field_extractions(fields: &[(Ident, Type)]) -> TokenStream { + let extractions = + fields.iter().map( + |(field_name, field_type)| match extract_arc_inner_type(field_type) { + Some(inner_type) => { + quote! { + let #field_name = state.borrow::<#inner_type>()?; + } + } + None => { + quote! { + let #field_name = state.get::<#field_type>()?; + } + } + }, + ); + + quote! { + #(#extractions)* + } +} + +fn generate_field_assignments(fields: &[(Ident, Type)]) -> TokenStream { + let assignments = fields.iter().map(|(name, _)| { + quote! { #name } + }); + + quote! { + #(#assignments),* + } +} diff --git a/crates/sword-macros/src/socketio/handlers.rs b/crates/sword-macros/src/socketio/handlers.rs new file mode 100644 index 00000000..a9ef4746 --- /dev/null +++ b/crates/sword-macros/src/socketio/handlers.rs @@ -0,0 +1,16 @@ +mod expand; +mod parsing; + +use crate::shared::{Result, cmeta::CMetaStack}; +use proc_macro::TokenStream; + +/// Entry point for the `#[on("...")]` handler attribute. +pub fn expand_on_handler(attr: TokenStream, item: TokenStream) -> Result { + // Handshake: leave the method untouched outside a Socket.IO controller. + if CMetaStack::get("socketio", "controller_ty").is_none() { + return Ok(item); + } + + let parsed = parsing::ParsedOnHandler::parse(attr, item)?; + Ok(expand::expand_on_attribute(parsed).into()) +} diff --git a/crates/sword-macros/src/socketio/handlers/expand.rs b/crates/sword-macros/src/socketio/handlers/expand.rs new file mode 100644 index 00000000..6d230c70 --- /dev/null +++ b/crates/sword-macros/src/socketio/handlers/expand.rs @@ -0,0 +1,96 @@ +use super::parsing::ParsedOnHandler; +use crate::shared::paths::*; + +use proc_macro2::TokenStream; +use quote::quote; + +pub fn expand_on_attribute(handler: ParsedOnHandler) -> TokenStream { + let ParsedOnHandler { + function, + fn_name, + controller_ty, + namespace, + event_name, + event_kind, + registration_name, + handler_struct_name, + } = handler; + + quote! { + #function + + #[allow(non_upper_case_globals)] + #[doc(hidden)] + const #registration_name: () = { + #[doc(hidden)] + pub struct #handler_struct_name; + + impl #handler_struct_name { + pub fn register_handler( + controller_any: #Arc, + socket: #SocketRef, + ) { + let controller = controller_any + .downcast::<#controller_ty>() + .unwrap_or_else(|_| { + #sword_error! { + title: "Failed to downcast Socket.IO controller type", + reason: #format!( + "Expected controller type {} during handler registration", + #stringify!(#controller_ty) + ), + context: { + "controller" => #stringify!(#controller_ty), + }, + hints: ["This indicates an internal macro invariant violation"], + } + }); + + socket.on(#event_name, move |ctx: #SocketContext| { + let controller = #Arc::clone(&controller); + + async move { + controller.#fn_name(ctx).await; + } + }); + } + + pub fn call_handler( + controller_any: #Arc, + ctx: #SocketContext, + ) -> #SocketBoxedFuture { + let controller = controller_any + .downcast::<#controller_ty>() + .unwrap_or_else(|_| { + #sword_error! { + title: "Failed to downcast Socket.IO controller type", + reason: #format!( + "Type mismatch while executing handler for {}", + #stringify!(#controller_ty) + ), + context: { + "controller" => #stringify!(#controller_ty), + }, + hints: ["This indicates an internal macro invariant violation"], + } + }); + + #Box::pin(async move { + controller.#fn_name(ctx).await; + }) + } + } + + #inventory::submit! { + #HandlerRegistrar { + controller_type_id: #TypeId::of::<#controller_ty>(), + namespace: #namespace, + event_kind: #event_kind, + method_name: #stringify!(#fn_name), + register_fn: #handler_struct_name::register_handler, + call_fn: #handler_struct_name::call_handler, + } + } + }; + } +} diff --git a/crates/sword-macros/src/socketio/handlers/parsing.rs b/crates/sword-macros/src/socketio/handlers/parsing.rs new file mode 100644 index 00000000..ac202d9a --- /dev/null +++ b/crates/sword-macros/src/socketio/handlers/parsing.rs @@ -0,0 +1,93 @@ +use crate::shared::{Error, Result, cmeta::CMetaStack, paths::*}; + +use heck::ToPascalCase; +use proc_macro::TokenStream; +use proc_macro2::TokenStream as TokenStream2; +use quote::{format_ident, quote}; +use syn::{Ident, ItemFn, LitStr}; + +/// Parsed data from an `#[on("...")]` handler attribute. +pub struct ParsedOnHandler { + /// The original function, kept as-is. + pub function: ItemFn, + + /// The handler method name. + pub fn_name: Ident, + + /// The enclosing Socket.IO controller type. + pub controller_ty: Ident, + + /// The controller namespace from `CMetaStack`. + pub namespace: String, + + /// The raw event name literal. + pub event_name: LitStr, + + /// The matching `SocketEventKind` tokens. + pub event_kind: TokenStream2, + + /// Name of the generated `const` registrar. + pub registration_name: Ident, + + /// Name of the generated helper struct holding the handler functions. + pub handler_struct_name: Ident, +} + +impl ParsedOnHandler { + pub fn parse(attr: TokenStream, item: TokenStream) -> Result { + let event_name = syn::parse::(attr)?; + + if event_name.value().is_empty() { + return Err(Error::custom( + "event name must not be empty; specify the event, e.g. #[on(\"message\")]", + ) + .with_span(&event_name)); + } + + let function = syn::parse::(item)?; + let fn_name = function.sig.ident.clone(); + + let controller_ty_str = CMetaStack::get("socketio", "controller_ty") + .ok_or_else(|| Error::custom("socketio controller type not found in CMetaStack"))?; + let namespace = CMetaStack::get("socketio", "namespace") + .ok_or_else(|| Error::custom("socketio namespace not found in CMetaStack"))?; + + let controller_ty: Ident = syn::parse_str(&controller_ty_str)?; + + let event_kind = event_kind_tokens(&event_name.value()); + let controller_ty_name = controller_ty.to_string(); + + let registration_name = format_ident!( + "__SWORD_SOCKETIO_HANDLER_{}_{}", + controller_ty_name.replace("::", "_"), + fn_name + ); + + let handler_struct_name = format_ident!( + "SwordSocketIoHandler{}_{}", + controller_ty_name.replace("::", ""), + fn_name.to_string().to_pascal_case() + ); + + Ok(Self { + function, + fn_name, + controller_ty, + namespace, + event_name, + event_kind, + registration_name, + handler_struct_name, + }) + } +} + +/// Maps a raw event name to its `SocketEventKind` tokens. +fn event_kind_tokens(event_name: &str) -> TokenStream2 { + match event_name { + "connection" => quote! { #SocketEventKind::Connection }, + "disconnection" => quote! { #SocketEventKind::Disconnection }, + "fallback" => quote! { #SocketEventKind::Fallback }, + custom => quote! { #SocketEventKind::Message(#custom) }, + } +} diff --git a/crates/sword-macros/src/socketio/mod.rs b/crates/sword-macros/src/socketio/mod.rs new file mode 100644 index 00000000..a576ac46 --- /dev/null +++ b/crates/sword-macros/src/socketio/mod.rs @@ -0,0 +1,188 @@ +mod handlers; + +pub use handlers::expand_on_handler; + +use crate::{ + core::{ControllerKind, ControllerStruct}, + shared::{ + Result, + cmeta::CMetaStack, + paths::*, + traits::{generate_build_impl, generate_clone_impl}, + }, +}; + +use darling::Error; +use proc_macro2::TokenStream; +use quote::quote; +use syn::Path; + +pub fn expand_controller(controller: &ControllerStruct) -> Result { + let ControllerKind::SocketIo { namespace } = &controller.kind else { + return Err(Error::custom("Expected a Socket.IO controller").with_span(&controller.span())); + }; + + let self_ty = controller.ty(); + let fields = controller.fields(); + + let interceptors: Vec = controller + .interceptors + .iter() + .filter_map(|interceptor| interceptor.path().cloned()) + .collect(); + + CMetaStack::push("socketio", "controller_ty", &controller.name()); + CMetaStack::push("socketio", "namespace", namespace); + + let build_impl = generate_build_impl(self_ty, &fields); + let clone_impl = generate_clone_impl(self_ty, &fields); + + let interceptor_applications = interceptors.iter().rev().map(|interceptor_path| { + quote! { + let interceptor = state.borrow::<#interceptor_path>() + .unwrap_or_else(|err| { + #sword_error! { + title: "Failed to retrieve Socket.IO interceptor from State", + reason: err, + context: { + "interceptor" => #stringify!(#interceptor_path), + }, + hints: ["Ensure the interceptor is registered and built before controller setup"], + } + }); + + let handler = handler.with(move |ctx: #SocketContext| { + let interceptor = #Arc::clone(&interceptor); + + async move { + <#interceptor_path as #OnConnect>::on_connect(&*interceptor, ctx) + .await + .map_err(|e| #Box::new(e) as #Box) + } + }); + } + }); + + let setup_impl = quote! { + #[doc(hidden)] + pub fn __socketio_setup(state: &#State) { + use #ConnectHandler; + + let controller = #Arc::new( + <#self_ty as #Build>::build(state).unwrap_or_else(|err| { + #sword_error! { + title: "Failed to build Socket.IO controller", + reason: err, + context: { + "controller" => #stringify!(#self_ty), + }, + hints: ["Ensure all controller dependencies are registered as providers or components"], + } + }) + ); + + let io = state.get::<#SocketIo>() + .unwrap_or_else(|err| { + #sword_error! { + title: "Socket.IO component not found in application state", + reason: err, + context: { + "controller" => #stringify!(#self_ty), + }, + hints: [ + "Enable the `socketio` feature in Cargo.toml", + "Configure the socketio server section in your configuration file", + ], + } + }); + + let controller_type_id = #TypeId::of::<#self_ty>(); + let mut connection_handler: Option<#HandlerRegistrar> = None; + let mut message_handlers = Vec::new(); + + for handler_meta in #inventory::iter::<#HandlerRegistrar>() { + if handler_meta.controller_type_id != controller_type_id { + continue; + } + + match handler_meta.event_kind { + #SocketEventKind::Connection => { + if connection_handler.is_some() { + #sword_error! { + title: "Multiple connection handlers found in Socket.IO controller", + reason: "Only one #[on(\"connection\")] handler is allowed per controller", + context: { + "controller" => #stringify!(#self_ty), + }, + } + } + + connection_handler = Some(handler_meta.clone()); + }, + #SocketEventKind::Message(_) + | #SocketEventKind::Disconnection + | #SocketEventKind::Fallback => { + message_handlers.push(handler_meta.clone()); + } + } + } + + let message_handlers: #Arc<[#HandlerRegistrar]> = + #Arc::from(message_handlers.into_boxed_slice()); + + let base_handler = move |ctx: #SocketContext| -> #SocketBoxedFuture { + let socket = ctx.socket_ref().clone(); + + #Box::pin(async move { + if let Some(handler) = connection_handler { + (handler.call_fn)(controller.clone(), ctx).await; + } + + for handler in message_handlers.iter() { + (handler.register_fn)(controller.clone(), socket.clone()); + } + }) + }; + + let handler = base_handler; + #(#interceptor_applications)* + + io.ns(#namespace, handler); + } + }; + + let setup_registration = quote! { + #inventory::submit! { + #SocketIoHandlerRegistrar { + handler_type_id: #TypeId::of::<#self_ty>(), + handler_type_name: #stringify!(#self_ty), + setup_fn: #self_ty::__socketio_setup, + } + } + }; + + let expanded = quote! { + #build_impl + #clone_impl + + impl #SocketIoController for #self_ty { + fn namespace() -> &'static str { + #namespace + } + } + + impl #ControllerSpec for #self_ty { + fn kind() -> #Controller { + #Controller::SocketIo + } + } + + impl #self_ty { + #setup_impl + } + + #setup_registration + }; + + Ok(expanded) +} diff --git a/crates/sword-macros/src/web/errors/expand.rs b/crates/sword-macros/src/web/errors/expand.rs new file mode 100644 index 00000000..abbdc534 --- /dev/null +++ b/crates/sword-macros/src/web/errors/expand.rs @@ -0,0 +1,126 @@ +use super::parse::HttpErrorArgs; +use crate::shared::errors::{ + default_tracing_level, generate_message_expr, generate_pattern, generate_tracing_stmt, +}; + +use crate::shared::paths::*; + +use proc_macro2::{Span, TokenStream}; +use quote::quote; +use syn::{Fields, Ident, Type}; + +pub fn generate_from_arm( + enum_name: &Ident, + variant_name: &Ident, + fields: &Fields, + config: &HttpErrorArgs, +) -> TokenStream { + if config.transparent.is_present() { + return quote! { + #enum_name::#variant_name(inner) => #JsonResponse::from(inner), + }; + } + + let pattern = generate_pattern(enum_name, variant_name, fields); + let tracing_stmt = generate_variant_tracing_stmt(variant_name, config, fields); + let builder = generate_json_builder(config); + + quote! { + #pattern => { + #tracing_stmt + #builder + }, + } +} + +fn generate_json_builder(config: &HttpErrorArgs) -> TokenStream { + let status_code = config.code.expect("code validated before codegen"); + + let message_expr = generate_message_expr(&config.message, || { + let default_message = config.default_message(); + quote! { #default_message } + }); + + let mut builder = quote! { + #JsonResponse::status(#status_code).message(#message_expr) + }; + + if let Some(field_name) = &config.error { + let field_ident = Ident::new(field_name.as_str(), Span::call_site()); + builder = quote! { #builder.error(&#field_ident) }; + } + + if let Some(field_name) = &config.errors { + let field_ident = Ident::new(field_name.as_str(), Span::call_site()); + builder = quote! { #builder.errors(&#field_ident) }; + } + + builder +} + +fn generate_variant_tracing_stmt( + variant_name: &Ident, + config: &HttpErrorArgs, + fields: &Fields, +) -> TokenStream { + let status_code = config.code.expect("code validated before codegen"); + let level = config.tracing.or(Some(default_tracing_level(status_code))); + + generate_tracing_stmt( + variant_name, + level, + fields, + &Ident::new("status_code", Span::call_site()), + quote! { #status_code }, + "HTTP error response", + ) +} + +pub fn generate_variant_fn(variant_name: &Ident, fields: &Fields) -> TokenStream { + let Fields::Named(named) = fields else { + return quote! {}; + }; + + let fn_name = variant_name; + + let params = named.named.iter().map(|field| { + let name = &field.ident; + let ty = &field.ty; + + if is_string_type(ty) { + quote! { #name: impl Into } + } else { + quote! { #name: #ty } + } + }); + + let field_assignments = named.named.iter().map(|field| { + let name = &field.ident; + let ty = &field.ty; + + if is_string_type(ty) { + quote! { #name: #name.into() } + } else { + quote! { #name } + } + }); + + quote! { + #[allow(non_snake_case)] + pub fn #fn_name(#(#params),*) -> Self { + Self::#variant_name { + #(#field_assignments),* + } + } + } +} + +fn is_string_type(ty: &Type) -> bool { + if let Type::Path(type_path) = ty + && let Some(segment) = type_path.path.segments.last() + { + return segment.ident == "String"; + } + + false +} diff --git a/crates/sword-macros/src/web/errors/mod.rs b/crates/sword-macros/src/web/errors/mod.rs new file mode 100644 index 00000000..87badb28 --- /dev/null +++ b/crates/sword-macros/src/web/errors/mod.rs @@ -0,0 +1,68 @@ +mod expand; +mod parse; +mod validate; + +use crate::shared::Result; +use crate::shared::paths::*; + +use parse::HttpErrorArgs; +use proc_macro::TokenStream; +use quote::quote; +use syn::{Data, DeriveInput}; + +pub fn derive_http_error(input: TokenStream) -> Result { + let input: DeriveInput = syn::parse(input)?; + let enum_ty = &input.ident; + + let Data::Enum(data) = &input.data else { + return Err( + darling::Error::custom("HttpError can only be derived for enums").with_span(enum_ty), + ); + }; + + let defaults = HttpErrorArgs::container(&input)?; + + let mut from_arms = Vec::new(); + let mut variant_fns = Vec::new(); + + for variant in &data.variants { + let variant_args = HttpErrorArgs::variant(variant)?; + let merged = variant_args.merged(&defaults); + + validate::validate_variant(enum_ty, &variant.ident, &variant.fields, &merged)?; + + from_arms.push(expand::generate_from_arm( + enum_ty, + &variant.ident, + &variant.fields, + &merged, + )); + + variant_fns.push(expand::generate_variant_fn(&variant.ident, &variant.fields)); + } + + let expanded = quote! { + impl From<#enum_ty> for #JsonResponse { + fn from(err: #enum_ty) -> Self { + let __sword_internal_error = err.to_string(); + + match err { + #(#from_arms)* + } + } + } + + impl #IntoResponse for #enum_ty { + fn into_response(self) -> #AxumResponse { + #JsonResponse::from(self).into_response() + } + } + + #[allow(non_snake_case)] + impl #enum_ty { + #(#variant_fns)* + } + }; + + Ok(expanded.into()) +} diff --git a/crates/sword-macros/src/web/errors/parse.rs b/crates/sword-macros/src/web/errors/parse.rs new file mode 100644 index 00000000..6ebbd126 --- /dev/null +++ b/crates/sword-macros/src/web/errors/parse.rs @@ -0,0 +1,88 @@ +use darling::{FromDeriveInput, FromVariant, util::Flag}; +use syn::{DeriveInput, Variant}; + +use crate::shared::Result; +use crate::shared::errors::{ + FieldRef, MessageValue, TracingLevel, read_tracing_attr, validate_transparent_container, + validate_transparent_variant, +}; + +/// Parsed `#[http_error(...)]` (container defaults) or `#[http(...)]` (variant). +#[derive(FromDeriveInput, FromVariant)] +#[darling(attributes(http_error, http))] +pub struct HttpErrorArgs { + pub transparent: Flag, + pub code: Option, + pub message: Option, + pub error: Option, + pub errors: Option, + pub tracing: Option, +} + +impl HttpErrorArgs { + pub fn container(input: &DeriveInput) -> Result { + let args = Self::from_derive_input(input)?; + validate_transparent_container(args.transparent.is_present(), "http")?; + Ok(args) + } + + pub fn variant(variant: &Variant) -> Result { + let mut args = Self::from_variant(variant)?; + + if args.tracing.is_none() { + args.tracing = read_tracing_attr(&variant.attrs)?; + } + + let has_conflict = args.code.is_some() + || args.message.is_some() + || args.error.is_some() + || args.errors.is_some() + || args.tracing.is_some(); + + validate_transparent_variant( + args.transparent.is_present(), + has_conflict, + &variant.ident, + "`code`, `message`, `error`, `errors`, or `tracing`", + )?; + + Ok(args) + } + + pub fn merged(self, defaults: &Self) -> Self { + Self { + transparent: self.transparent, + code: self.code.or(defaults.code), + message: self.message.or_else(|| defaults.message.clone()), + error: self.error.or_else(|| defaults.error.clone()), + errors: self.errors.or_else(|| defaults.errors.clone()), + tracing: self.tracing.or(defaults.tracing), + } + } + + pub fn default_message(&self) -> String { + self.code + .and_then(default_reason) + .unwrap_or("Unknown Error") + .to_string() + } +} + +const fn default_reason(code: u16) -> Option<&'static str> { + Some(match code { + 200 => "OK", + 201 => "Created", + 204 => "No Content", + 400 => "Bad Request", + 401 => "Unauthorized", + 403 => "Forbidden", + 404 => "Not Found", + 405 => "Method Not Allowed", + 409 => "Conflict", + 422 => "Unprocessable Entity", + 500 => "Internal Server Error", + 502 => "Bad Gateway", + 503 => "Service Unavailable", + _ => return None, + }) +} diff --git a/crates/sword-macros/src/web/errors/validate.rs b/crates/sword-macros/src/web/errors/validate.rs new file mode 100644 index 00000000..fb98c6a2 --- /dev/null +++ b/crates/sword-macros/src/web/errors/validate.rs @@ -0,0 +1,75 @@ +use syn::{Error, Fields, Ident}; + +use super::parse::HttpErrorArgs; +use crate::shared::errors::{ + MessageValue, validate_message_references, validate_named_field_refs, validate_single_unnamed, + validate_transparent_single_unnamed, +}; + +pub fn validate_variant( + enum_name: &Ident, + variant_name: &Ident, + fields: &Fields, + config: &HttpErrorArgs, +) -> syn::Result<()> { + if config.transparent.is_present() { + validate_transparent_single_unnamed(variant_name, fields)?; + return Ok(()); + } + + if config.code.is_none() { + return Err(Error::new_spanned( + variant_name, + "missing `code` after merging #[http_error(...)] and #[http(...)]", + )); + } + + match fields { + Fields::Named(named) => { + validate_message_references(enum_name, variant_name, named, &config.message)?; + + let extra_refs: Vec<(&str, &str)> = config + .error + .as_ref() + .map(|field| ("error", field.as_str())) + .into_iter() + .chain( + config + .errors + .as_ref() + .map(|field| ("errors", field.as_str())), + ) + .collect(); + + validate_named_field_refs(enum_name, variant_name, named, &extra_refs)?; + } + Fields::Unnamed(unnamed) => { + validate_single_unnamed(variant_name, unnamed)?; + + if has_field_based_refs(config) { + return Err(Error::new_spanned( + variant_name, + "tuple variants do not support `message = field`, interpolated messages, `error`, or `errors`; use named fields or construct the client message before creating the error", + )); + } + } + Fields::Unit => { + if has_field_based_refs(config) { + return Err(Error::new_spanned( + variant_name, + "unit variants do not support field-based or interpolated `message`, `error`, or `errors`", + )); + } + } + } + + Ok(()) +} + +const fn has_field_based_refs(config: &HttpErrorArgs) -> bool { + matches!( + config.message, + Some(MessageValue::Field(_)) | Some(MessageValue::Interpolated(_)) + ) || config.error.is_some() + || config.errors.is_some() +} diff --git a/crates/sword-macros/src/web/interceptors.rs b/crates/sword-macros/src/web/interceptors.rs new file mode 100644 index 00000000..2bb6992e --- /dev/null +++ b/crates/sword-macros/src/web/interceptors.rs @@ -0,0 +1,119 @@ +use crate::{core::InterceptorArgs, shared::paths::*, web::routes::RequestMode}; +use proc_macro2::TokenStream; +use quote::quote; +use syn::{Expr, Path}; + +pub struct RequestModeTokens { + request_ty: TokenStream, + traditional_trait: TokenStream, + with_config_trait: TokenStream, +} + +impl RequestModeTokens { + fn from_request_mode(request_mode: RequestMode) -> Self { + match request_mode { + RequestMode::Streaming => Self { + request_ty: quote! { #StreamRequest }, + traditional_trait: quote! { #OnRequestStream }, + with_config_trait: quote! { #OnRequestStreamWithConfig }, + }, + _ => Self { + request_ty: quote! { #Request }, + traditional_trait: quote! { #OnRequest }, + with_config_trait: quote! { #OnRequestWithConfig }, + }, + } + } +} + +pub fn expand_sword_interceptor(args: &InterceptorArgs, req_mode: RequestMode) -> TokenStream { + let mode = RequestModeTokens::from_request_mode(req_mode); + + match args { + InterceptorArgs::Expression(expr) => quote! { #expr }, + InterceptorArgs::Traditional(path) => generate_sword_traditional(path, &mode), + InterceptorArgs::WithConfig { + interceptor, + config, + } => generate_sword_with_config(interceptor, &mode, config), + } +} + +fn generate_sword_traditional(path: &Path, mode: &RequestModeTokens) -> TokenStream { + let request_type = mode.request_ty.clone(); + let simple_trait = mode.traditional_trait.clone(); + + quote! { + { + fn __check_on_request__(mw: &M) -> &M { mw } + + let middleware = state.borrow::<#path>() + .unwrap_or_else(|err| { + #sword_error!( + title: "Failed to retrieve HTTP interceptor from State", + reason: err, + context: { + "interceptor" => stringify!(#path), + }, + hints: ["Ensure the interceptor is registered and built before controller initialization"], + ) + }); + + let _ = __check_on_request__(&*middleware); + + #mw_with_state(state.clone(), move |mut req: #request_type, next: #Next| { + req.set_next(next); + + let mw = #Arc::clone(&middleware); + + async move { + <#path as #simple_trait>::on_request(&*mw, req).await + } + }) + } + } +} + +fn generate_sword_with_config( + interceptor_ty: &Path, + req_mode: &RequestModeTokens, + config: &Expr, +) -> TokenStream { + let request_ty = &req_mode.request_ty; + let with_config_trait = &req_mode.with_config_trait; + + quote! { + { + fn __check_on_request_configured__(mw: &M) -> &M + where + M: #with_config_trait + { + mw + } + + let interceptor = state.borrow::<#interceptor_ty>() + .unwrap_or_else(|err| { + #sword_error! { + title: "Failed to retrieve HTTP interceptor from State", + reason: err, + context: { + "interceptor" => stringify!(#interceptor_ty), + }, + hints: ["Ensure the interceptor is registered and built before controller initialization"], + } + }); + + let _ = __check_on_request_configured__::<#interceptor_ty, _>(&*interceptor); + + #mw_with_state(state.clone(), move |mut req: #request_ty, next: #Next| { + req.set_next(next); + + let mw = #Arc::clone(&interceptor); + + async move { + <#interceptor_ty as #with_config_trait<_>>::on_request(&*mw, #config, req).await + } + }) + } + } +} diff --git a/crates/sword-macros/src/web/mod.rs b/crates/sword-macros/src/web/mod.rs new file mode 100644 index 00000000..a4b376e0 --- /dev/null +++ b/crates/sword-macros/src/web/mod.rs @@ -0,0 +1,88 @@ +mod errors; +mod interceptors; +mod routes; + +pub use errors::derive_http_error; +pub use routes::expand_route; + +use crate::{ + core::{ControllerKind, ControllerStruct}, + shared::{ + Result, + cmeta::CMetaStack, + paths::*, + traits::{generate_build_impl, generate_clone_impl}, + }, +}; + +use darling::Error; +use proc_macro2::TokenStream; +use quote::quote; + +pub fn expand_controller(controller: &ControllerStruct) -> Result { + let ControllerKind::Web { path } = &controller.kind else { + return Err(Error::custom("Expected a web controller").with_span(&controller.span())); + }; + + let self_ty = controller.ty(); + let interceptors = controller.interceptors.clone(); + let fields = controller.fields(); + + let serialized_controller_interceptors: Vec<_> = interceptors + .into_iter() + .map(|interceptor| TokenStream::from(interceptor).to_string()) + .collect(); + + CMetaStack::push("web", "controller_path", path); + CMetaStack::push("web", "controller_name", &controller.name()); + CMetaStack::push_list( + "web", + "controller_interceptors", + serialized_controller_interceptors, + ); + + let build_impl = generate_build_impl(self_ty, &fields); + let clone_impl = generate_clone_impl(self_ty, &fields); + + let expanded = quote! { + #build_impl + #clone_impl + + impl #WebController for #self_ty { + fn base_path() -> &'static str { + #path + } + } + + impl #ControllerSpec for #self_ty { + fn kind() -> #Controller { + #Controller::Web + } + } + + #inventory::submit! { + #WebControllerRegistrar { + controller_id: #TypeId::of::<#self_ty>(), + controller_path: #path, + build: |state: &#State| { + let controller = #self_ty::build(state).unwrap_or_else(|e| { + #sword_error! { + title: "Failed to build controller", + reason: format!("Failed to build web controller dependency graph: {e}"), + context: { + "controller_name" => stringify!(#self_ty), + "error" => format!("{e:?}"), + "source" => "WebControllerRegistrar::build", + }, + hints: ["Check the error message for details on what went wrong during construction"], + } + }); + + state.insert::<#self_ty>(controller); + } + } + } + }; + + Ok(expanded) +} diff --git a/crates/sword-macros/src/web/routes.rs b/crates/sword-macros/src/web/routes.rs new file mode 100644 index 00000000..435bfd9d --- /dev/null +++ b/crates/sword-macros/src/web/routes.rs @@ -0,0 +1,15 @@ +mod context; +mod expand; +mod parsing; + +pub use context::WebRouteContext; +pub use parsing::{ParsedRouteAttribute, RequestMode}; + +use crate::shared::Result; +use proc_macro::TokenStream; + +/// Entry point for `#[get]`, `#[post]`, ... route attributes. +pub fn expand_route(method: &str, attr: TokenStream, item: TokenStream) -> Result { + let parsed = ParsedRouteAttribute::parse(method, attr, item)?; + Ok(expand::expand_web_attribute(&parsed).into()) +} diff --git a/crates/sword-macros/src/web/routes/context.rs b/crates/sword-macros/src/web/routes/context.rs new file mode 100644 index 00000000..10a19d5e --- /dev/null +++ b/crates/sword-macros/src/web/routes/context.rs @@ -0,0 +1,33 @@ +use crate::{core::InterceptorArgs, shared::cmeta::CMetaStack}; +use proc_macro2::Span; + +pub struct WebRouteContext { + pub controller_name: String, + pub controller_interceptors: Vec, +} + +impl WebRouteContext { + pub fn get() -> syn::Result { + let Some(controller_name) = CMetaStack::get("web", "controller_name") else { + let error = "\n[ERROR] This attribute macro must be used inside a Web Controller impl block.\n\ + \n\ + Make sure:\n\ + 1. The struct has #[controller(kind = Controller::Web, path = \"/path\")] attribute\n\ + 2. The struct is defined BEFORE the impl block\n\ + 3. The impl block is for the same struct\n"; + + return Err(syn::Error::new(Span::call_site(), error)); + }; + + let controller_interceptors = CMetaStack::get_list("web", "controller_interceptors") + .unwrap_or_default() + .into_iter() + .map(|interceptor| syn::parse_str::(&interceptor)) + .collect::>>()?; + + Ok(Self { + controller_name, + controller_interceptors, + }) + } +} diff --git a/crates/sword-macros/src/web/routes/expand.rs b/crates/sword-macros/src/web/routes/expand.rs new file mode 100644 index 00000000..f1fc514f --- /dev/null +++ b/crates/sword-macros/src/web/routes/expand.rs @@ -0,0 +1,183 @@ +use crate::shared::paths::*; +use crate::web::interceptors::expand_sword_interceptor; +use crate::web::routes::ParsedRouteAttribute; + +use super::parsing::ReturnKind; + +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; + +pub fn expand_web_attribute(route: &ParsedRouteAttribute) -> TokenStream { + let original_function = &route.function; + let route_fn_name = route.route_fn_name(); + let controller_ty = format_ident!("{}", route.context.controller_name); + + let path = &route.path; + let method = route.method.as_str(); + + let registration_name = format_ident!( + "__SWORD_ROUTE_REGISTRAR_{}_{}", + &route.context.controller_name.replace("::", "_"), + route_fn_name + ); + + let handler_router = build_handler_router(route); + let route_fn_body = apply_route_interceptors(route, handler_router); + + let expanded = quote! { + #original_function + + pub fn #route_fn_name(controller: #Arc, state: #State) -> #MethodRouter<#State> { + #route_fn_body + } + + #[allow(non_upper_case_globals)] + #[doc(hidden)] + const #registration_name: () = { + #inventory::submit! { + #RouteRegistrar { + controller_id: #TypeId::of::<#controller_ty>(), + path: #path, + handler: |state: #State| -> #MethodRouter<#State> { + let controller = state.borrow::<#controller_ty>().unwrap_or_else(|err| { + #sword_error! { + title: "Failed to build HTTP controller", + reason: err, + context: { + "controller" => #stringify!(#controller_ty), + "route" => format!("{} {}", #method, #path), + }, + hints: ["Ensure all controller dependencies are registered in the DI container"], + } + }); + + #controller_ty::#route_fn_name(controller, state) + } + } + } + }; + }; + + expanded +} + +// ---- handler router -------------------------------------------------------- + +fn build_handler_router(route: &ParsedRouteAttribute) -> TokenStream { + let routing_fn = route.method.routing_fn_tokens(); + let body = build_handler_body(route); + + match route.request_mode.to_tokens() { + Some(req_ty) => quote! { + #routing::#routing_fn({ + let controller = #Arc::clone(&controller); + move |req: #req_ty| async move { #body } + }) + }, + None => quote! { + #routing::#routing_fn({ + let controller = #Arc::clone(&controller); + move || async move { #body } + }) + }, + } +} + +// ---- interceptors ---------------------------------------------------------- + +fn apply_route_interceptors(route: &ParsedRouteAttribute, mut handler: TokenStream) -> TokenStream { + for interceptor in route.interceptors.iter().rev() { + let generated = expand_sword_interceptor(interceptor, route.request_mode); + handler = quote! { #handler.layer(#generated) }; + } + + for interceptor in route.context.controller_interceptors.iter().rev() { + let generated = expand_sword_interceptor(interceptor, route.request_mode); + handler = quote! { #handler.layer(#generated) }; + } + + handler +} + +// ---- handler bodies -------------------------------------------------------- + +fn build_handler_body(route: &ParsedRouteAttribute) -> TokenStream { + if route.method.is_sse() { + build_sse_handler_body(route) + } else if route.return_info.is_result { + build_result_handler_body(route) + } else { + build_plain_handler_body(route) + } +} + +fn method_call(route: &ParsedRouteAttribute) -> TokenStream { + let method = &route.function.sig.ident; + + if route.request_mode.to_tokens().is_some() { + quote! { controller.#method(req).await } + } else { + quote! { controller.#method().await } + } +} + +fn build_plain_handler_body(route: &ParsedRouteAttribute) -> TokenStream { + let call = method_call(route); + + quote! { + #IntoResponse::into_response(#call) + } +} + +fn build_result_handler_body(route: &ParsedRouteAttribute) -> TokenStream { + let call = method_call(route); + let status = route.return_info.status_code; + + let ok_arm = match route.return_info.kind { + ReturnKind::Passthrough => quote! { + Ok(__data) => { + #IntoResponse::into_response(__data) + } + }, + ReturnKind::Serialize => quote! { + Ok(__data) => { + #IntoResponse::into_response(#JsonResponse::status(#status).data(__data)) + } + }, + ReturnKind::Empty => quote! { + Ok(_) => { + #IntoResponse::into_response(#JsonResponse::status(#status)) + } + }, + }; + + quote! { + match #call { + #ok_arm, + Err(__err) => { + #IntoResponse::into_response(__err) + }, + } + } +} + +fn build_sse_handler_body(route: &ParsedRouteAttribute) -> TokenStream { + let call = method_call(route); + + if route.return_info.is_result { + quote! { + match #call { + Ok(__data) => { + #IntoResponse::into_response(__data) + } + Err(__err) => { + #IntoResponse::into_response(__err) + } + } + } + } else { + quote! { + #IntoResponse::into_response(#call) + } + } +} diff --git a/crates/sword-macros/src/web/routes/parsing.rs b/crates/sword-macros/src/web/routes/parsing.rs new file mode 100644 index 00000000..2f3bf98d --- /dev/null +++ b/crates/sword-macros/src/web/routes/parsing.rs @@ -0,0 +1,327 @@ +use crate::{ + core::{InterceptorArgs, take_interceptors}, + shared::{Result, paths::*}, +}; + +use super::WebRouteContext; + +use proc_macro::TokenStream; +use proc_macro2::{Span, TokenStream as TokenStream2}; +use quote::{format_ident, quote}; +use syn::{GenericArgument, ItemFn, LitStr, PathArguments, ReturnType, Type, spanned::Spanned}; + +/// Parsed data from a route attribute +pub struct ParsedRouteAttribute { + /// The HTTP method (GET, POST, etc.) + pub method: HttpMethod, + + /// The route path (e.g., "/", "/:id") + pub path: String, + + /// The original function, kept as-is. + pub function: ItemFn, + + /// Parsed interceptor arguments + pub interceptors: Vec, + + /// Request extraction mode inferred from function signature. + pub request_mode: RequestMode, + + /// Controller metadata from CMetaStack + pub context: WebRouteContext, + + /// Classified handler return metadata + pub return_info: RouteReturnInfo, +} + +impl ParsedRouteAttribute { + pub fn parse(method: &str, attr: TokenStream, item: TokenStream) -> Result { + let input_fn = syn::parse::(item)?; + let method = HttpMethod::from_attr_name(method)?; + + let path = syn::parse::(attr)?.path.value(); + let request_mode = RequestMode::try_from(&input_fn)?; + + let interceptors = take_interceptors(&input_fn.attrs)?; + let context = WebRouteContext::get()?; + let return_info = RouteReturnInfo::from_signature(&input_fn.sig, method); + + Ok(Self { + method, + path, + function: input_fn, + interceptors, + request_mode, + context, + return_info, + }) + } + + pub fn route_fn_name(&self) -> syn::Ident { + format_ident!("__sword_web_route_{}", self.function.sig.ident) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ReturnKind { + Passthrough, + Serialize, + Empty, +} + +/// How a route handler return its response +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RouteReturnInfo { + pub is_result: bool, + pub kind: ReturnKind, + pub status_code: u16, +} + +impl RouteReturnInfo { + pub fn from_signature(sig: &syn::Signature, method: HttpMethod) -> Self { + let status_code = method.default_status_code(); + let plain = Self { + status_code, + is_result: false, + kind: ReturnKind::Passthrough, + }; + + let Some(ok_type) = Self::result_ok_type(sig) else { + return plain; + }; + + Self { + is_result: true, + kind: Self::classify_return_kind(&ok_type), + status_code, + } + } + + /// Extracts `T` from `Result` / `WebResult` + /// or `WebResult` without generics, which means `JsonResponse`. + /// + /// Returns `None` for anything that is not a `Result`/`WebResult` return. + fn result_ok_type(sig: &syn::Signature) -> Option { + let return_type = match &sig.output { + ReturnType::Type(_, ty) => ty.as_ref(), + _ => return None, + }; + + let Type::Path(type_path) = return_type else { + return None; + }; + + // Gets the last segment for a type. e.g ::std::sync::Arc -> Arc + let last_segment = type_path.path.segments.last()?; + let ty_str = last_segment.ident.to_string(); + + match ty_str.as_str() { + "Result" => { + let args = match &last_segment.arguments { + PathArguments::AngleBracketed(args) => &args.args, + _ => return None, + }; + + if args.len() < 2 { + return None; + } + + match &args[0] { + GenericArgument::Type(ty) => Some(ty.clone()), + _ => None, + } + } + + "WebResult" => match &last_segment.arguments { + // WebResult -> T + PathArguments::AngleBracketed(args) if !args.args.is_empty() => { + match &args.args[0] { + GenericArgument::Type(ty) => Some(ty.clone()), + _ => None, + } + } + + // Empty WebResult == WebResult + _ => Some(syn::parse_quote!(JsonResponse)), + }, + + _ => None, + } + } + + fn classify_return_kind(ty: &Type) -> ReturnKind { + if let Type::Tuple(tuple) = ty + && tuple.elems.is_empty() + { + return ReturnKind::Empty; + } + + if let Type::Path(type_path) = ty { + // Gets the last segment for a type. e.g ::std::sync::Arc -> Arc + let Some(last_segment) = type_path.path.segments.last() else { + return ReturnKind::Serialize; + }; + + let ty_str = last_segment.ident.to_string(); + + match ty_str.as_str() { + "JsonResponse" | "File" | "Redirect" | "Sse" => return ReturnKind::Passthrough, + _ => return ReturnKind::Serialize, + } + } + + ReturnKind::Serialize + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum RequestMode { + None, + Buffered, + Streaming, +} + +impl RequestMode { + pub fn to_tokens(self) -> Option { + match self { + Self::Buffered => Some(quote! { #Request }), + Self::Streaming => Some(quote! { #StreamRequest }), + Self::None => None, + } + } +} + +impl TryFrom<&ItemFn> for RequestMode { + type Error = darling::Error; + + fn try_from(input_fn: &ItemFn) -> std::result::Result { + let mut mode = Self::None; + + for arg in &input_fn.sig.inputs { + let syn::FnArg::Typed(pat_type) = arg else { + continue; + }; + + let arg_mode = match &*pat_type.ty { + Type::Path(type_path) => match type_path.path.segments.last() { + Some(segment) if segment.ident == "Request" => Self::Buffered, + Some(segment) if segment.ident == "StreamRequest" => Self::Streaming, + _ => Self::None, + }, + _ => Self::None, + }; + + if arg_mode == Self::None { + continue; + } + + if mode != Self::None && mode != arg_mode { + return Err(darling::Error::custom( + "A route handler cannot use both `Request` and `StreamRequest` in the same signature", + ).with_span(&pat_type.span())); + } + + mode = arg_mode; + } + + Ok(mode) + } +} + +pub struct RouteArgs { + pub path: LitStr, +} + +impl syn::parse::Parse for RouteArgs { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + if input.is_empty() { + return Err(syn::Error::new( + Span::call_site(), + "Route path is required, e.g., #[get(\"/path\")]. If you want the root path, use \"/\".", + )); + } + + let path: LitStr = input.parse()?; + + Ok(Self { path }) + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum HttpMethod { + Get, + Post, + Put, + Delete, + Patch, + Head, + Options, + Trace, + Connect, + Sse, +} + +impl HttpMethod { + pub fn from_attr_name(method: &str) -> syn::Result { + match method { + "GET" => Ok(Self::Get), + "POST" => Ok(Self::Post), + "PUT" => Ok(Self::Put), + "DELETE" => Ok(Self::Delete), + "PATCH" => Ok(Self::Patch), + "HEAD" => Ok(Self::Head), + "OPTIONS" => Ok(Self::Options), + "TRACE" => Ok(Self::Trace), + "CONNECT" => Ok(Self::Connect), + "SSE" => Ok(Self::Sse), + _ => Err(syn::Error::new( + Span::call_site(), + format!("Unsupported HTTP method `{method}`"), + )), + } + } + + pub const fn as_str(self) -> &'static str { + match self { + Self::Get => "GET", + Self::Post => "POST", + Self::Put => "PUT", + Self::Delete => "DELETE", + Self::Patch => "PATCH", + Self::Head => "HEAD", + Self::Options => "OPTIONS", + Self::Trace => "TRACE", + Self::Connect => "CONNECT", + Self::Sse => "SSE", + } + } + + pub fn routing_fn_tokens(self) -> proc_macro2::TokenStream { + match self { + Self::Post => quote! { post }, + Self::Put => quote! { put }, + Self::Delete => quote! { delete }, + Self::Patch => quote! { patch }, + Self::Head => quote! { head }, + Self::Options => quote! { options }, + Self::Trace => quote! { trace }, + Self::Connect => quote! { connect }, + Self::Sse | Self::Get => quote! { get }, + } + } + + pub const fn default_status_code(self) -> u16 { + match self { + Self::Post => 201, + _ => 200, + } + } + + /// Whether the route is a Server-Sent Events stream. + /// + /// SSE routes are served over GET but must remain streaming responses, so the + /// generator treats them as passthrough regardless of the return type. + pub const fn is_sse(self) -> bool { + matches!(self, Self::Sse) + } +} diff --git a/crates/sword-socketio/Cargo.toml b/crates/sword-socketio/Cargo.toml index b1f43ab4..2940383d 100644 --- a/crates/sword-socketio/Cargo.toml +++ b/crates/sword-socketio/Cargo.toml @@ -28,7 +28,7 @@ inventory = { workspace = true } sword-core = { workspace = true } sword-web = { workspace = true } -sword-macros = { workspace = true, features = ["socketio-controllers"] } +sword-macros = { workspace = true, features = ["socketio"] } sword-layers = { workspace = true } socketioxide = { version = "0.18.0", features = ["extensions", "msgpack"] } diff --git a/crates/sword-socketio/src/config.rs b/crates/sword-socketio/src/config.rs index 208d9ffe..4bbbc7ad 100644 --- a/crates/sword-socketio/src/config.rs +++ b/crates/sword-socketio/src/config.rs @@ -10,7 +10,7 @@ use sword_core::{ByteConfig, ConfigItem, ConfigRegistrar, TimeConfig, inventory_ use sword_layers::DisplayConfig; #[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[serde(default)] +#[serde(default, rename_all = "kebab-case")] pub struct SocketIoServerConfig { /// The amount of time the server will wait for an acknowledgement /// from the client before closing the connection. diff --git a/crates/sword-web/Cargo.toml b/crates/sword-web/Cargo.toml index ed764f03..ef4a1c80 100644 --- a/crates/sword-web/Cargo.toml +++ b/crates/sword-web/Cargo.toml @@ -23,7 +23,7 @@ tokio-stream = { workspace = true } axum = { workspace = true } sword-core = { workspace = true } -sword-macros = { workspace = true, features = ["web-controllers"] } +sword-macros = { workspace = true, features = ["web"] } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/sword/Cargo.toml b/crates/sword/Cargo.toml index 78bcae8c..ec58c3e0 100644 --- a/crates/sword/Cargo.toml +++ b/crates/sword/Cargo.toml @@ -23,10 +23,11 @@ tokio = { workspace = true } sword-core = { workspace = true } sword-events = { workspace = true, optional = true } sword-layers = { workspace = true, features = ["tracing"] } -sword-macros = { workspace = true, features = ["web-controllers", "socketio-controllers", "grpc-controllers"] } +sword-macros = { workspace = true, features = ["web", "socketio", "grpc"] } sword-web = { workspace = true, optional = true } sword-grpc = { workspace = true, optional = true } sword-socketio = { workspace = true, optional = true } +sword-jobs = { workspace = true, optional = true } tracing = { workspace = true } inventory = { workspace = true } @@ -39,15 +40,19 @@ dioxus-devtools = { workspace = true, optional = true } [features] default = [] -events-in-memory = ["dep:sword-events", "sword-macros/event-handlers"] web = ["dep:sword-web"] +web-multipart = ["sword-web/multipart"] +web-swagger-ui = ["sword-web/swagger-ui"] + socketio = ["web", "dep:sword-socketio"] + grpc = ["dep:sword-grpc"] grpc-reflection = ["dep:sword-grpc", "sword-grpc/reflection"] grpc-error-details = ["grpc", "sword-grpc/error-details"] -web-multipart = ["sword-web/multipart"] -web-swagger-ui = ["sword-web/swagger-ui"] -validation-validator = ["sword-web/validation-validator", "sword-socketio?/validation-validator"] +events-in-memory = ["dep:sword-events"] +jobs = ["dep:sword-jobs"] + +validation-validator = ["sword-web/validation-validator", "sword-socketio?/validation-validator"] hot-reload = ["dep:subsecond", "dep:dioxus-devtools", "sword-macros/hot-reload"] diff --git a/crates/sword/src/application/builder.rs b/crates/sword/src/application/builder.rs index ef8a619b..fa5198eb 100644 --- a/crates/sword/src/application/builder.rs +++ b/crates/sword/src/application/builder.rs @@ -229,6 +229,12 @@ impl ApplicationBuilder { self.register_sword_built_in_layers(); // Initialization of built-in layers by the `sword_layers` crate. + #[cfg(feature = "jobs")] + let jobs = sword_jobs::JobSchedulerRuntime::new( + self.state.clone(), + self.controller_registry.get_by_kind(Controller::JobHandler), + ); + #[allow(unused_variables)] let ctx = EngineBuildContext { state: self.state, @@ -238,34 +244,33 @@ impl ApplicationBuilder { }; // NOTE: With --all-features, only the first matching #[cfg] branch is compiled in. - // The unreachable code and needless return warnings are suppressed here because - // each branch is reachable under normal single-feature usage. + // The `cfg_select!` arms assign `engine`, so a late init avoids duplicating the + // branch bodies across the two initializer forms. + #[allow(unused_assignments, clippy::needless_late_init)] + let engine: Option; + cfg_select! { feature = "grpc" => { let grpc_app = sword_grpc::application::GrpcApplication::from(ctx); - let engine = super::ApplicationEngine::Grpc(grpc_app); - - Application::new(engine, self.config) + engine = Some(super::ApplicationEngine::Grpc(grpc_app)); } any(feature = "web", feature = "socketio") => { let web_app = sword_web::application::WebApplication::from(ctx); - let engine = super::ApplicationEngine::Web(web_app); - - Application::new(engine, self.config) + engine = Some(super::ApplicationEngine::Web(web_app)); } _ => { - sword_error! { - title: "No application engine available", - reason: "No supported controller feature is enabled", - context: { - "source" => "ApplicationBuilder::build", - }, - hints: ["Enable one of: web, socketio, grpc"], - } + engine = None; } } + + let app = Application::new(engine, self.config); + + #[cfg(feature = "jobs")] + let app = app.with_jobs(jobs); + + app } } diff --git a/crates/sword/src/application/mod.rs b/crates/sword/src/application/mod.rs index 720d6193..c0926217 100644 --- a/crates/sword/src/application/mod.rs +++ b/crates/sword/src/application/mod.rs @@ -15,14 +15,30 @@ pub use env::Environment; /// the web server, routing, and application configuration. It provides a /// builder pattern for configuration and methods to run the application. pub struct Application { - engine: ApplicationEngine, + /// The transport engine. `None` for a jobs-only application. + engine: Option, pub config: Config, + + /// Job scheduler runtime, if the `jobs` feature is enabled and a job + /// controller was registered. + #[cfg(feature = "jobs")] + jobs: Option, } impl Application { - #[cfg(any(feature = "web", feature = "socketio", feature = "grpc"))] - pub(crate) fn new(engine: ApplicationEngine, config: Config) -> Self { - Self { engine, config } + pub(crate) fn new(engine: Option, config: Config) -> Self { + Self { + engine, + config, + #[cfg(feature = "jobs")] + jobs: None, + } + } + + #[cfg(feature = "jobs")] + pub(crate) fn with_jobs(mut self, jobs: sword_jobs::JobSchedulerRuntime) -> Self { + self.jobs = Some(jobs); + self } /// Creates a new application builder for configuring the application. @@ -94,17 +110,37 @@ impl Application { "Starting Sword application" ); + #[cfg(feature = "jobs")] + let mut job_scheduler = match &self.jobs { + Some(runtime) => Some(runtime.start().await), + None => None, + }; + match &self.engine { #[cfg(any(feature = "web", feature = "socketio"))] - ApplicationEngine::Web(app) => app.start().await, + Some(ApplicationEngine::Web(app)) => app.start().await, #[cfg(feature = "grpc")] - ApplicationEngine::Grpc(app) => app.start().await, + Some(ApplicationEngine::Grpc(app)) => app.start().await, + + _ => { + // Jobs-only application: keep running until shutdown. + #[cfg(feature = "jobs")] + if self.jobs.is_some() { + sword_core::shutdown_signal().await; + } + } + } - #[allow(unreachable_patterns)] - _ => unreachable!( - "Invalid application engine configuration. Enable the appropriate feature flag to use the desired engine." - ), + #[cfg(feature = "jobs")] + if let Some(scheduler) = job_scheduler.as_mut() + && let Err(err) = scheduler.shutdown().await + { + tracing::error!( + target: "sword.shutdown.job-scheduler", + error = %err, + "Failed to shut down job scheduler" + ); } } @@ -112,7 +148,7 @@ impl Application { #[cfg(any(feature = "web", feature = "socketio"))] pub fn router(&self) -> sword_web::internal::AxumRouter { #[cfg(any(feature = "web", feature = "socketio"))] - if let ApplicationEngine::Web(app) = &self.engine { + if let Some(ApplicationEngine::Web(app)) = &self.engine { return app.router(); } diff --git a/crates/sword/src/lib.rs b/crates/sword/src/lib.rs index 94cb0180..014d2f64 100644 --- a/crates/sword/src/lib.rs +++ b/crates/sword/src/lib.rs @@ -6,6 +6,9 @@ pub mod prelude; #[cfg(feature = "events-in-memory")] pub use sword_events::prelude as events; +#[cfg(feature = "jobs")] +pub use sword_jobs::prelude as jobs; + #[cfg(feature = "web")] pub use sword_web::prelude as web; @@ -37,6 +40,9 @@ pub mod internal { #[cfg(feature = "events-in-memory")] pub use sword_events::internal as events; + #[cfg(feature = "jobs")] + pub use sword_jobs::internal as jobs; + pub use inventory; pub use tokio::runtime as tokio_runtime; pub use toml; diff --git a/examples/grpc/README.md b/examples/grpc/README.md index 203ad0b7..80bcfa9c 100644 --- a/examples/grpc/README.md +++ b/examples/grpc/README.md @@ -1,6 +1,6 @@ # Sword gRPC Example -Minimal gRPC users CRUD example for Sword using `grpc-controllers` and an in-memory store. +Minimal gRPC users CRUD example for Sword using `grpc` and an in-memory store. ## Run diff --git a/examples/web/Cargo.toml b/examples/web/Cargo.toml index 0dc33a8c..bb33cb44 100644 --- a/examples/web/Cargo.toml +++ b/examples/web/Cargo.toml @@ -7,7 +7,7 @@ edition = "2024" ignored = ["sword-layers"] [dependencies] -sword = { workspace = true, features = ["web", "validation-validator", "web-swagger-ui", "events-in-memory"] } +sword = { workspace = true, features = ["web", "validation-validator", "web-swagger-ui", "events-in-memory", "jobs"] } sword-layers = { workspace = true, features = ["compression", "cors", "servedir"] } async-stream = { workspace = true } diff --git a/examples/web/src/jobs/mod.rs b/examples/web/src/jobs/mod.rs new file mode 100644 index 00000000..23b55a5c --- /dev/null +++ b/examples/web/src/jobs/mod.rs @@ -0,0 +1,36 @@ +use std::time::Duration; + +use sword::jobs::{JobScheduler, Uuid, cron, once, repeated}; +use sword::prelude::*; + +/// Example job controller exercising the three scheduling attributes. +#[controller(kind = Controller::JobHandler)] +pub struct CleanupJob {} + +impl CleanupJob { + /// Runs every hour (cron: second minute hour day month weekday). + #[cron("0 0 * * * *")] + async fn hourly(&self, uuid: Uuid, _: JobScheduler) { + tracing::info!(%uuid, "cleanup job: hourly tick"); + } + + /// Runs every 5 seconds. + #[repeated(Duration::from_secs(5))] + async fn heartbeat(&self, _: Uuid, _: JobScheduler) { + tracing::info!("cleanup job: heartbeat"); + } + + /// Runs once, 10 seconds after startup. + #[once(Duration::from_secs(10))] + async fn warmup(&self, _: Uuid, _: JobScheduler) { + tracing::info!("cleanup job: warmup"); + } +} + +pub struct JobsModule; + +impl Module for JobsModule { + fn register_controllers(controllers: &ControllerRegistry) { + controllers.register::(); + } +} diff --git a/examples/web/src/main.rs b/examples/web/src/main.rs index 9c8f5c03..c74cce81 100644 --- a/examples/web/src/main.rs +++ b/examples/web/src/main.rs @@ -1,3 +1,4 @@ +pub mod jobs; pub mod mailer; pub mod shared; pub mod sse; @@ -6,7 +7,10 @@ pub mod users; use dotenv::dotenv; use sword::prelude::*; -use crate::{mailer::MailerModule, shared::SharedModule, sse::SseModule, users::UsersModule}; +use crate::{ + jobs::JobsModule, mailer::MailerModule, shared::SharedModule, sse::SseModule, + users::UsersModule, +}; #[sword::main] async fn main() { @@ -19,6 +23,7 @@ async fn main() { .with_module::() .with_module::() .with_module::() + .with_module::() .build(); app.run().await; diff --git a/tests/sword-events/Cargo.toml b/tests/sword-events/Cargo.toml new file mode 100644 index 00000000..8131757a --- /dev/null +++ b/tests/sword-events/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "sword-events-tests" +version = "0.1.0" +edition.workspace = true +publish = false + +[dependencies] +sword = { workspace = true, features = ["web", "events-in-memory"] } +tokio = { workspace = true } +axum-test = { workspace = true } diff --git a/tests/sword-events/Config.toml b/tests/sword-events/Config.toml new file mode 100644 index 00000000..e0a81afc --- /dev/null +++ b/tests/sword-events/Config.toml @@ -0,0 +1,7 @@ +[application] +graceful-shutdown = false + +[web] +host = "0.0.0.0" +port = 8080 +body-limit = "1MB" diff --git a/tests/sword-events/src/lib.rs b/tests/sword-events/src/lib.rs new file mode 100644 index 00000000..2f530fac --- /dev/null +++ b/tests/sword-events/src/lib.rs @@ -0,0 +1,145 @@ +#![cfg(test)] + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; + +use sword::events::{EventHandlerResult, EventPublisher, event, handle}; +use sword::prelude::*; +use sword::web::*; + +pub struct EventStore { + hits: AtomicUsize, +} + +impl EventStore { + pub fn new() -> Self { + Self { + hits: AtomicUsize::new(0), + } + } + + pub fn increment(&self) { + self.hits.fetch_add(1, Ordering::SeqCst); + } + + pub fn count(&self) -> usize { + self.hits.load(Ordering::SeqCst) + } +} + +impl Default for EventStore { + fn default() -> Self { + Self::new() + } +} + +impl Provider for EventStore {} + +#[event(key = "user.created")] +pub struct UserCreated { + pub id: u32, +} + +#[controller(kind = Controller::EventHandler, source = EventSource::Memory)] +pub struct UserCreatedHandler { + store: Arc, +} + +impl UserCreatedHandler { + #[handle("user.created")] + async fn on_created(&self, event: UserCreated) -> EventHandlerResult<()> { + assert_eq!(event.id, 1, "handler received an unexpected event payload"); + self.store.increment(); + + Ok(()) + } +} + +#[controller(kind = Controller::Web, path = "/users")] +pub struct UsersController { + publisher: Arc, + store: Arc, +} + +impl UsersController { + #[post("/create")] + async fn create(&self) -> WebResult { + self.publisher.publish(UserCreated { id: 1 }).await; + + Ok(JsonResponse::Ok().message("published")) + } + + #[get("/count")] + async fn count(&self) -> WebResult { + Ok(self.store.count()) + } +} + +struct TestModule; + +impl Module for TestModule { + fn register_controllers(controllers: &ControllerRegistry) { + controllers.register::(); + controllers.register::(); + } +} + +fn test_server() -> axum_test::TestServer { + let app = Application::from_config_path("Config.toml") + .with_provider(EventStore::new()) + .with_module::() + .build(); + + axum_test::TestServer::new(app.router()) +} + +/// Polls `GET /users/count` until it reaches `expected` or times out. +async fn poll_count(server: &axum_test::TestServer, expected: usize) -> usize { + let mut count = 0; + + for _ in 0..200 { + let body = server.get("/users/count").await.json::(); + + count = body + .data + .as_ref() + .and_then(|value| value.as_u64()) + .unwrap_or(0) as usize; + + if count >= expected { + break; + } + + tokio::time::sleep(Duration::from_millis(10)).await; + } + + count +} + +#[tokio::test] +async fn web_route_publishes_event_that_increments_store() { + let server = test_server(); + + server.post("/users/create").await.assert_status_ok(); + + assert_eq!( + poll_count(&server, 1).await, + 1, + "event handler did not increment the store" + ); +} + +#[tokio::test] +async fn multiple_events_accumulate_in_store() { + let server = test_server(); + + server.post("/users/create").await.assert_status_ok(); + server.post("/users/create").await.assert_status_ok(); + + assert_eq!( + poll_count(&server, 2).await, + 2, + "store did not accumulate every published event" + ); +} diff --git a/tests/sword-jobs/Cargo.toml b/tests/sword-jobs/Cargo.toml new file mode 100644 index 00000000..eea974fb --- /dev/null +++ b/tests/sword-jobs/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "sword-jobs-tests" +version = "0.1.0" +edition.workspace = true +publish = false + +[dependencies] +sword = { workspace = true, features = ["jobs"] } +tokio = { workspace = true } diff --git a/tests/sword-jobs/src/lib.rs b/tests/sword-jobs/src/lib.rs new file mode 100644 index 00000000..5beb21d0 --- /dev/null +++ b/tests/sword-jobs/src/lib.rs @@ -0,0 +1,38 @@ +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; + +use sword::jobs::{JobScheduler, Uuid, repeated}; +use sword::prelude::*; + +static RUNS: AtomicUsize = AtomicUsize::new(0); + +#[controller(kind = Controller::JobHandler)] +pub struct TestJob {} + +impl TestJob { + #[repeated(Duration::from_millis(50))] + async fn tick(&self, _: Uuid, _: JobScheduler) { + RUNS.fetch_add(1, Ordering::SeqCst); + } +} + +#[tokio::test] +async fn repeated_job_runs() { + let state = sword::internal::core::State::new(); + let registry = ControllerRegistry::new(); + registry.register::(); + + let runtime = sword::internal::jobs::JobSchedulerRuntime::new( + state, + registry.get_by_kind(Controller::JobHandler), + ); + + let mut scheduler = runtime.start().await; + + // The scheduler ticks every 500ms, so allow some slack. + tokio::time::sleep(Duration::from_millis(1500)).await; + + assert!(RUNS.load(Ordering::SeqCst) >= 1, "job did not run"); + + scheduler.shutdown().await.unwrap(); +}