diff --git a/CHANGELOG.md b/CHANGELOG.md index 16e7592e..9aedafd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,52 @@ All notable changes to the Firefly Framework for Rust. +## v26.6.28 — 2026-06-16 + +A Spring Boot **parity** increment: the declarative HTTP-interface client — the +highest single value lever from the parity-gap analysis (it lifts the REST/HTTP +clients area off the floor). + +### Added + +- **`#[http_client]`** — a declarative HTTP-interface client, the analog of + Spring 6's `@HttpExchange` (the modern OpenFeign replacement). Annotate a + **trait** of methods with the *same* verb attributes a `#[rest_controller]` + uses and the macro generates a `Impl` that issues the requests over a + `WebClient` — the mirror image of a controller. + - **Verbs:** `#[get("/path")]` / `#[post]` / `#[put]` / `#[delete]` / + `#[patch]` + generic `#[request(method = "…")]`. Path variables use the + framework's `:id` syntax (same as the server macro); `{id}` is a compile + error pointing at `:id`. + - **Argument binding** needs no attributes in the common case: a name-matched + `:var` arg is the path variable, the lone non-scalar arg on a body verb is + the JSON body, the rest are query params (`Option` omits when `None`, + `Vec`/`&[_]` repeat). Override with `#[path]` / `#[query("k")]` / + `#[header("X")]` / `#[body]`. Every `:var` must bind exactly once or it is a + compile error; an `Option`/`Vec`/slice path variable is rejected. + - **Return shapes:** `async fn -> Result` (the ergonomic + default), `Result>`, non-async `Mono` / `Flux` + (returned directly; a `Flux` defaults `Accept: application/x-ndjson`), and + `WebClientResponse` (the `.exchange()` escape hatch). + - **Construction:** `Impl::new(base_url)` or `::with_client(WebClient)`; + the type is `Clone`. With `#[http_client(... bean)]` it is registered as a + `@Service` and bound to `dyn Trait`, so `#[autowired] Arc` + resolves (pulling a shared `WebClient` bean, named via `client = "…"`). + - **Error fidelity (documented):** an awaited `Result` + surfaces every failure as `ClientError::Problem` (carrying a `FireflyError` + with the original status/code, so the classifiers still work); the + structured `Transport`/`Decode`/`Encode`/`InvalidUrl` variants survive only + on the `Mono`/`Flux` return forms. +- **`firefly_client::encode_path_segment`** — RFC 3986 path-segment + percent-encoding (used by generated clients; also public). + +The macro reuses the server `#[rest_controller]`'s verb-attribute grammar +(`MappingAttr`/`VERBS`/`join_path`), so client and server can't drift. Designed +via a scored 3-proposal panel and adversarially reviewed (the review caught a +runtime footgun — an `Option` path variable producing `…/Some(x)` URLs — now a +compile error). The `firefly::prelude` now also re-exports `WebClient` / +`ClientError` / `new_web_client`. + ## v26.6.27 — 2026-06-16 A Spring Boot **parity** increment: declarative rollback rules on diff --git a/Cargo.lock b/Cargo.lock index 74dc0ef8..bd4b76a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1436,7 +1436,7 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "firefly" -version = "26.6.27" +version = "26.6.28" dependencies = [ "axum", "firefly-actuator", @@ -1480,7 +1480,7 @@ dependencies = [ [[package]] name = "firefly-actuator" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "axum", @@ -1497,7 +1497,7 @@ dependencies = [ [[package]] name = "firefly-admin" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "axum", @@ -1526,7 +1526,7 @@ dependencies = [ [[package]] name = "firefly-aop" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "inventory", @@ -1536,7 +1536,7 @@ dependencies = [ [[package]] name = "firefly-backoffice" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "axum", @@ -1555,7 +1555,7 @@ dependencies = [ [[package]] name = "firefly-cache" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "firefly-observability", @@ -1567,7 +1567,7 @@ dependencies = [ [[package]] name = "firefly-cache-postgres" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "chrono", @@ -1579,7 +1579,7 @@ dependencies = [ [[package]] name = "firefly-cache-redis" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "firefly-cache", @@ -1591,7 +1591,7 @@ dependencies = [ [[package]] name = "firefly-callbacks" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "axum", @@ -1617,7 +1617,7 @@ dependencies = [ [[package]] name = "firefly-cli" -version = "26.6.27" +version = "26.6.28" dependencies = [ "axum", "chrono", @@ -1638,7 +1638,7 @@ dependencies = [ [[package]] name = "firefly-client" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-stream", "axum", @@ -1660,7 +1660,7 @@ dependencies = [ [[package]] name = "firefly-config" -version = "26.6.27" +version = "26.6.28" dependencies = [ "axum", "regex", @@ -1675,7 +1675,7 @@ dependencies = [ [[package]] name = "firefly-config-server" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "axum", @@ -1691,7 +1691,7 @@ dependencies = [ [[package]] name = "firefly-container" -version = "26.6.27" +version = "26.6.28" dependencies = [ "futures", "inventory", @@ -1702,7 +1702,7 @@ dependencies = [ [[package]] name = "firefly-cqrs" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "chrono", @@ -1725,7 +1725,7 @@ dependencies = [ [[package]] name = "firefly-data" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-stream", "async-trait", @@ -1742,7 +1742,7 @@ dependencies = [ [[package]] name = "firefly-data-mongodb" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-stream", "async-trait", @@ -1760,7 +1760,7 @@ dependencies = [ [[package]] name = "firefly-data-sqlx" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-stream", "async-trait", @@ -1782,7 +1782,7 @@ dependencies = [ [[package]] name = "firefly-ecm" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "chrono", @@ -1798,7 +1798,7 @@ dependencies = [ [[package]] name = "firefly-ecm-esignature-adobe-sign" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "axum", @@ -1815,7 +1815,7 @@ dependencies = [ [[package]] name = "firefly-ecm-esignature-docusign" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "axum", @@ -1832,7 +1832,7 @@ dependencies = [ [[package]] name = "firefly-ecm-esignature-logalty" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "axum", @@ -1849,7 +1849,7 @@ dependencies = [ [[package]] name = "firefly-ecm-storage-aws" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "axum", @@ -1869,7 +1869,7 @@ dependencies = [ [[package]] name = "firefly-ecm-storage-azure" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "axum", @@ -1889,7 +1889,7 @@ dependencies = [ [[package]] name = "firefly-eda" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "base64", @@ -1911,7 +1911,7 @@ dependencies = [ [[package]] name = "firefly-eda-kafka" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "firefly-eda", @@ -1926,7 +1926,7 @@ dependencies = [ [[package]] name = "firefly-eda-postgres" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "chrono", @@ -1944,7 +1944,7 @@ dependencies = [ [[package]] name = "firefly-eda-rabbitmq" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "firefly-eda", @@ -1959,7 +1959,7 @@ dependencies = [ [[package]] name = "firefly-eda-redis" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "firefly-eda", @@ -1975,7 +1975,7 @@ dependencies = [ [[package]] name = "firefly-eventsourcing" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "base64", @@ -1993,7 +1993,7 @@ dependencies = [ [[package]] name = "firefly-i18n" -version = "26.6.27" +version = "26.6.28" dependencies = [ "axum", "http", @@ -2008,7 +2008,7 @@ dependencies = [ [[package]] name = "firefly-idp" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "axum", @@ -2024,7 +2024,7 @@ dependencies = [ [[package]] name = "firefly-idp-aws-cognito" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "axum", @@ -2045,7 +2045,7 @@ dependencies = [ [[package]] name = "firefly-idp-azure-ad" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "axum", @@ -2062,7 +2062,7 @@ dependencies = [ [[package]] name = "firefly-idp-internal-db" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "axum", @@ -2084,7 +2084,7 @@ dependencies = [ [[package]] name = "firefly-idp-keycloak" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "axum", @@ -2101,7 +2101,7 @@ dependencies = [ [[package]] name = "firefly-integration-tests" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "axum", @@ -2136,7 +2136,7 @@ dependencies = [ [[package]] name = "firefly-kernel" -version = "26.6.27" +version = "26.6.28" dependencies = [ "chrono", "serde", @@ -2148,7 +2148,7 @@ dependencies = [ [[package]] name = "firefly-lifecycle" -version = "26.6.27" +version = "26.6.28" dependencies = [ "thiserror 1.0.69", "tokio", @@ -2157,12 +2157,14 @@ dependencies = [ [[package]] name = "firefly-macros" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "axum", "darling 0.20.11", "firefly", + "firefly-client", + "firefly-reactive", "firefly-scheduling", "http-body-util", "proc-macro2", @@ -2179,7 +2181,7 @@ dependencies = [ [[package]] name = "firefly-migrations" -version = "26.6.27" +version = "26.6.28" dependencies = [ "chrono", "hex", @@ -2192,7 +2194,7 @@ dependencies = [ [[package]] name = "firefly-notifications" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "chrono", @@ -2207,7 +2209,7 @@ dependencies = [ [[package]] name = "firefly-notifications-firebase" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "axum", @@ -2223,7 +2225,7 @@ dependencies = [ [[package]] name = "firefly-notifications-resend" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "axum", @@ -2240,7 +2242,7 @@ dependencies = [ [[package]] name = "firefly-notifications-sendgrid" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "axum", @@ -2257,7 +2259,7 @@ dependencies = [ [[package]] name = "firefly-notifications-smtp" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "base64", @@ -2274,7 +2276,7 @@ dependencies = [ [[package]] name = "firefly-notifications-twilio" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "axum", @@ -2290,7 +2292,7 @@ dependencies = [ [[package]] name = "firefly-observability" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "chrono", @@ -2314,7 +2316,7 @@ dependencies = [ [[package]] name = "firefly-openapi" -version = "26.6.27" +version = "26.6.28" dependencies = [ "axum", "chrono", @@ -2330,7 +2332,7 @@ dependencies = [ [[package]] name = "firefly-orchestration" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "axum", @@ -2354,7 +2356,7 @@ dependencies = [ [[package]] name = "firefly-plugins" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "chrono", @@ -2364,7 +2366,7 @@ dependencies = [ [[package]] name = "firefly-reactive" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-stream", "firefly-kernel", @@ -2376,7 +2378,7 @@ dependencies = [ [[package]] name = "firefly-resilience" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "firefly-config", @@ -2388,7 +2390,7 @@ dependencies = [ [[package]] name = "firefly-rule-engine" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "axum", @@ -2408,7 +2410,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "axum", @@ -2425,7 +2427,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen-ledger-core" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "chrono", @@ -2440,7 +2442,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen-ledger-interfaces" -version = "26.6.27" +version = "26.6.28" dependencies = [ "chrono", "firefly", @@ -2451,7 +2453,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen-ledger-models" -version = "26.6.27" +version = "26.6.28" dependencies = [ "chrono", "firefly", @@ -2464,7 +2466,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen-ledger-sdk" -version = "26.6.27" +version = "26.6.28" dependencies = [ "firefly-client", "firefly-sample-lumen-ledger-interfaces", @@ -2476,7 +2478,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen-ledger-web" -version = "26.6.27" +version = "26.6.28" dependencies = [ "axum", "firefly", @@ -2494,7 +2496,7 @@ dependencies = [ [[package]] name = "firefly-sample-macro-quickstart" -version = "26.6.27" +version = "26.6.28" dependencies = [ "axum", "firefly", @@ -2507,7 +2509,7 @@ dependencies = [ [[package]] name = "firefly-sample-orders" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "axum", @@ -2530,7 +2532,7 @@ dependencies = [ [[package]] name = "firefly-sample-reactive-banking" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-stream", "async-trait", @@ -2570,7 +2572,7 @@ dependencies = [ [[package]] name = "firefly-scheduling" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "chrono", @@ -2591,7 +2593,7 @@ dependencies = [ [[package]] name = "firefly-security" -version = "26.6.27" +version = "26.6.28" dependencies = [ "argon2", "async-trait", @@ -2617,7 +2619,7 @@ dependencies = [ [[package]] name = "firefly-session" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "axum", @@ -2642,7 +2644,7 @@ dependencies = [ [[package]] name = "firefly-session-mongodb" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "firefly-session", @@ -2655,7 +2657,7 @@ dependencies = [ [[package]] name = "firefly-session-postgres" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "firefly-session", @@ -2667,7 +2669,7 @@ dependencies = [ [[package]] name = "firefly-session-redis" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "firefly-session", @@ -2679,7 +2681,7 @@ dependencies = [ [[package]] name = "firefly-shell" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "futures", @@ -2689,7 +2691,7 @@ dependencies = [ [[package]] name = "firefly-spike-linkspike-core" -version = "26.6.27" +version = "26.6.28" dependencies = [ "axum", "firefly", @@ -2697,7 +2699,7 @@ dependencies = [ [[package]] name = "firefly-spike-linkspike-web" -version = "26.6.27" +version = "26.6.28" dependencies = [ "axum", "firefly", @@ -2709,7 +2711,7 @@ dependencies = [ [[package]] name = "firefly-sse" -version = "26.6.27" +version = "26.6.28" dependencies = [ "axum", "bytes", @@ -2725,7 +2727,7 @@ dependencies = [ [[package]] name = "firefly-starter-application" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "firefly-cqrs", @@ -2739,7 +2741,7 @@ dependencies = [ [[package]] name = "firefly-starter-core" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "axum", @@ -2765,7 +2767,7 @@ dependencies = [ [[package]] name = "firefly-starter-data" -version = "26.6.27" +version = "26.6.28" dependencies = [ "axum", "firefly-cqrs", @@ -2778,7 +2780,7 @@ dependencies = [ [[package]] name = "firefly-starter-domain" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "firefly-eventsourcing", @@ -2790,7 +2792,7 @@ dependencies = [ [[package]] name = "firefly-starter-experience" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "axum", @@ -2811,7 +2813,7 @@ dependencies = [ [[package]] name = "firefly-starter-web" -version = "26.6.27" +version = "26.6.28" dependencies = [ "axum", "firefly-kernel", @@ -2826,7 +2828,7 @@ dependencies = [ [[package]] name = "firefly-testkit" -version = "26.6.27" +version = "26.6.28" dependencies = [ "axum", "base64", @@ -2845,7 +2847,7 @@ dependencies = [ [[package]] name = "firefly-transactional" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "inventory", @@ -2856,7 +2858,7 @@ dependencies = [ [[package]] name = "firefly-utils" -version = "26.6.27" +version = "26.6.28" dependencies = [ "aes-gcm", "base64", @@ -2872,7 +2874,7 @@ dependencies = [ [[package]] name = "firefly-validators" -version = "26.6.27" +version = "26.6.28" dependencies = [ "chrono", "firefly-kernel", @@ -2883,7 +2885,7 @@ dependencies = [ [[package]] name = "firefly-web" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "axum", @@ -2922,7 +2924,7 @@ dependencies = [ [[package]] name = "firefly-webhooks" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "axum", @@ -2950,7 +2952,7 @@ dependencies = [ [[package]] name = "firefly-websocket" -version = "26.6.27" +version = "26.6.28" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index ce843f5c..f3315734 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -90,7 +90,7 @@ members = [ ] [workspace.package] -version = "26.6.27" +version = "26.6.28" edition = "2021" license = "Apache-2.0" repository = "https://github.com/fireflyframework/fireflyframework-rust" @@ -99,80 +99,80 @@ rust-version = "1.88" [workspace.dependencies] # ---- internal crates ---- -firefly-reactive = { path = "crates/reactive", version = "26.6.27" } -firefly-kernel = { path = "crates/kernel", version = "26.6.27" } -firefly-utils = { path = "crates/utils", version = "26.6.27" } -firefly-validators = { path = "crates/validators", version = "26.6.27" } -firefly-web = { path = "crates/web", version = "26.6.27" } -firefly-config = { path = "crates/config", version = "26.6.27" } -firefly-i18n = { path = "crates/i18n", version = "26.6.27" } -firefly-cache = { path = "crates/cache", version = "26.6.27" } -firefly-observability = { path = "crates/observability", version = "26.6.27" } -firefly-data = { path = "crates/data", version = "26.6.27" } -firefly-cqrs = { path = "crates/cqrs", version = "26.6.27" } -firefly-eda = { path = "crates/eda", version = "26.6.27" } -firefly-eventsourcing = { path = "crates/eventsourcing", version = "26.6.27" } -firefly-orchestration = { path = "crates/orchestration", version = "26.6.27" } -firefly-rule-engine = { path = "crates/rule-engine", version = "26.6.27" } -firefly-plugins = { path = "crates/plugins", version = "26.6.27" } -firefly-lifecycle = { path = "crates/lifecycle", version = "26.6.27" } -firefly-actuator = { path = "crates/actuator", version = "26.6.27" } -firefly-scheduling = { path = "crates/scheduling", version = "26.6.27" } -firefly-resilience = { path = "crates/resilience", version = "26.6.27" } -firefly-security = { path = "crates/security", version = "26.6.27" } -firefly-migrations = { path = "crates/migrations", version = "26.6.27" } -firefly-openapi = { path = "crates/openapi", version = "26.6.27" } -firefly-sse = { path = "crates/sse", version = "26.6.27" } -firefly-transactional = { path = "crates/transactional", version = "26.6.27" } -firefly-testkit = { path = "crates/testkit", version = "26.6.27" } -firefly-client = { path = "crates/client", version = "26.6.27" } -firefly-config-server = { path = "crates/config-server", version = "26.6.27" } -firefly-idp = { path = "crates/idp", version = "26.6.27" } -firefly-idp-internal-db = { path = "crates/idp-internal-db", version = "26.6.27" } -firefly-idp-keycloak = { path = "crates/idp-keycloak", version = "26.6.27" } -firefly-idp-azure-ad = { path = "crates/idp-azure-ad", version = "26.6.27" } -firefly-idp-aws-cognito = { path = "crates/idp-aws-cognito", version = "26.6.27" } -firefly-ecm = { path = "crates/ecm", version = "26.6.27" } -firefly-ecm-storage-aws = { path = "crates/ecm-storage-aws", version = "26.6.27" } -firefly-ecm-storage-azure = { path = "crates/ecm-storage-azure", version = "26.6.27" } -firefly-ecm-esignature-docusign = { path = "crates/ecm-esignature-docusign", version = "26.6.27" } -firefly-ecm-esignature-adobe-sign = { path = "crates/ecm-esignature-adobe-sign", version = "26.6.27" } -firefly-ecm-esignature-logalty = { path = "crates/ecm-esignature-logalty", version = "26.6.27" } -firefly-notifications = { path = "crates/notifications", version = "26.6.27" } -firefly-notifications-sendgrid = { path = "crates/notifications-sendgrid", version = "26.6.27" } -firefly-notifications-resend = { path = "crates/notifications-resend", version = "26.6.27" } -firefly-notifications-twilio = { path = "crates/notifications-twilio", version = "26.6.27" } -firefly-notifications-firebase = { path = "crates/notifications-firebase", version = "26.6.27" } -firefly-callbacks = { path = "crates/callbacks", version = "26.6.27" } -firefly-webhooks = { path = "crates/webhooks", version = "26.6.27" } -firefly-starter-core = { path = "crates/starter-core", version = "26.6.27" } -firefly-starter-application = { path = "crates/starter-application", version = "26.6.27" } -firefly-starter-domain = { path = "crates/starter-domain", version = "26.6.27" } -firefly-starter-data = { path = "crates/starter-data", version = "26.6.27" } -firefly-backoffice = { path = "crates/backoffice", version = "26.6.27" } -firefly-admin = { path = "crates/admin", version = "26.6.27" } -firefly-aop = { path = "crates/aop", version = "26.6.27" } -firefly-cli = { path = "crates/cli", version = "26.6.27" } -firefly-container = { path = "crates/container", version = "26.6.27" } -firefly-session = { path = "crates/session", version = "26.6.27" } -firefly-shell = { path = "crates/shell", version = "26.6.27" } -firefly-websocket = { path = "crates/websocket", version = "26.6.27" } -firefly-notifications-smtp = { path = "crates/notifications-smtp", version = "26.6.27" } -firefly-cache-redis = { path = "crates/cache-redis", version = "26.6.27" } -firefly-eda-kafka = { path = "crates/eda-kafka", version = "26.6.27" } -firefly-eda-rabbitmq = { path = "crates/eda-rabbitmq", version = "26.6.27" } -firefly-eda-postgres = { path = "crates/eda-postgres", version = "26.6.27" } -firefly-eda-redis = { path = "crates/eda-redis", version = "26.6.27" } -firefly-cache-postgres = { path = "crates/cache-postgres", version = "26.6.27" } -firefly-starter-web = { path = "crates/starter-web", version = "26.6.27" } -firefly = { path = "crates/firefly", version = "26.6.27" } -firefly-macros = { path = "crates/macros", version = "26.6.27" } -firefly-data-sqlx = { path = "crates/data-sqlx", version = "26.6.27" } -firefly-data-mongodb = { path = "crates/data-mongodb", version = "26.6.27" } -firefly-session-redis = { path = "crates/session-redis", version = "26.6.27" } -firefly-session-postgres = { path = "crates/session-postgres", version = "26.6.27" } -firefly-session-mongodb = { path = "crates/session-mongodb", version = "26.6.27" } -firefly-starter-experience = { path = "crates/starter-experience", version = "26.6.27" } +firefly-reactive = { path = "crates/reactive", version = "26.6.28" } +firefly-kernel = { path = "crates/kernel", version = "26.6.28" } +firefly-utils = { path = "crates/utils", version = "26.6.28" } +firefly-validators = { path = "crates/validators", version = "26.6.28" } +firefly-web = { path = "crates/web", version = "26.6.28" } +firefly-config = { path = "crates/config", version = "26.6.28" } +firefly-i18n = { path = "crates/i18n", version = "26.6.28" } +firefly-cache = { path = "crates/cache", version = "26.6.28" } +firefly-observability = { path = "crates/observability", version = "26.6.28" } +firefly-data = { path = "crates/data", version = "26.6.28" } +firefly-cqrs = { path = "crates/cqrs", version = "26.6.28" } +firefly-eda = { path = "crates/eda", version = "26.6.28" } +firefly-eventsourcing = { path = "crates/eventsourcing", version = "26.6.28" } +firefly-orchestration = { path = "crates/orchestration", version = "26.6.28" } +firefly-rule-engine = { path = "crates/rule-engine", version = "26.6.28" } +firefly-plugins = { path = "crates/plugins", version = "26.6.28" } +firefly-lifecycle = { path = "crates/lifecycle", version = "26.6.28" } +firefly-actuator = { path = "crates/actuator", version = "26.6.28" } +firefly-scheduling = { path = "crates/scheduling", version = "26.6.28" } +firefly-resilience = { path = "crates/resilience", version = "26.6.28" } +firefly-security = { path = "crates/security", version = "26.6.28" } +firefly-migrations = { path = "crates/migrations", version = "26.6.28" } +firefly-openapi = { path = "crates/openapi", version = "26.6.28" } +firefly-sse = { path = "crates/sse", version = "26.6.28" } +firefly-transactional = { path = "crates/transactional", version = "26.6.28" } +firefly-testkit = { path = "crates/testkit", version = "26.6.28" } +firefly-client = { path = "crates/client", version = "26.6.28" } +firefly-config-server = { path = "crates/config-server", version = "26.6.28" } +firefly-idp = { path = "crates/idp", version = "26.6.28" } +firefly-idp-internal-db = { path = "crates/idp-internal-db", version = "26.6.28" } +firefly-idp-keycloak = { path = "crates/idp-keycloak", version = "26.6.28" } +firefly-idp-azure-ad = { path = "crates/idp-azure-ad", version = "26.6.28" } +firefly-idp-aws-cognito = { path = "crates/idp-aws-cognito", version = "26.6.28" } +firefly-ecm = { path = "crates/ecm", version = "26.6.28" } +firefly-ecm-storage-aws = { path = "crates/ecm-storage-aws", version = "26.6.28" } +firefly-ecm-storage-azure = { path = "crates/ecm-storage-azure", version = "26.6.28" } +firefly-ecm-esignature-docusign = { path = "crates/ecm-esignature-docusign", version = "26.6.28" } +firefly-ecm-esignature-adobe-sign = { path = "crates/ecm-esignature-adobe-sign", version = "26.6.28" } +firefly-ecm-esignature-logalty = { path = "crates/ecm-esignature-logalty", version = "26.6.28" } +firefly-notifications = { path = "crates/notifications", version = "26.6.28" } +firefly-notifications-sendgrid = { path = "crates/notifications-sendgrid", version = "26.6.28" } +firefly-notifications-resend = { path = "crates/notifications-resend", version = "26.6.28" } +firefly-notifications-twilio = { path = "crates/notifications-twilio", version = "26.6.28" } +firefly-notifications-firebase = { path = "crates/notifications-firebase", version = "26.6.28" } +firefly-callbacks = { path = "crates/callbacks", version = "26.6.28" } +firefly-webhooks = { path = "crates/webhooks", version = "26.6.28" } +firefly-starter-core = { path = "crates/starter-core", version = "26.6.28" } +firefly-starter-application = { path = "crates/starter-application", version = "26.6.28" } +firefly-starter-domain = { path = "crates/starter-domain", version = "26.6.28" } +firefly-starter-data = { path = "crates/starter-data", version = "26.6.28" } +firefly-backoffice = { path = "crates/backoffice", version = "26.6.28" } +firefly-admin = { path = "crates/admin", version = "26.6.28" } +firefly-aop = { path = "crates/aop", version = "26.6.28" } +firefly-cli = { path = "crates/cli", version = "26.6.28" } +firefly-container = { path = "crates/container", version = "26.6.28" } +firefly-session = { path = "crates/session", version = "26.6.28" } +firefly-shell = { path = "crates/shell", version = "26.6.28" } +firefly-websocket = { path = "crates/websocket", version = "26.6.28" } +firefly-notifications-smtp = { path = "crates/notifications-smtp", version = "26.6.28" } +firefly-cache-redis = { path = "crates/cache-redis", version = "26.6.28" } +firefly-eda-kafka = { path = "crates/eda-kafka", version = "26.6.28" } +firefly-eda-rabbitmq = { path = "crates/eda-rabbitmq", version = "26.6.28" } +firefly-eda-postgres = { path = "crates/eda-postgres", version = "26.6.28" } +firefly-eda-redis = { path = "crates/eda-redis", version = "26.6.28" } +firefly-cache-postgres = { path = "crates/cache-postgres", version = "26.6.28" } +firefly-starter-web = { path = "crates/starter-web", version = "26.6.28" } +firefly = { path = "crates/firefly", version = "26.6.28" } +firefly-macros = { path = "crates/macros", version = "26.6.28" } +firefly-data-sqlx = { path = "crates/data-sqlx", version = "26.6.28" } +firefly-data-mongodb = { path = "crates/data-mongodb", version = "26.6.28" } +firefly-session-redis = { path = "crates/session-redis", version = "26.6.28" } +firefly-session-postgres = { path = "crates/session-postgres", version = "26.6.28" } +firefly-session-mongodb = { path = "crates/session-mongodb", version = "26.6.28" } +firefly-starter-experience = { path = "crates/starter-experience", version = "26.6.28" } # ---- async runtime + web ---- tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "signal", "io-util", "net", "fs"] } diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index d801506a..cbf462c1 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -155,6 +155,7 @@ mod rest; mod retry; mod scaffold; mod soap; +mod uri; mod webclient; #[cfg(feature = "grpc")] @@ -170,6 +171,12 @@ pub use scaffold::{ new_grpc, new_soap, new_websocket, GrpcPlaceholder, SoapPlaceholder, WebSocketPlaceholder, }; pub use soap::{wrap_envelope, SoapBuilder, SoapClient}; +pub use uri::encode_path_segment; + +// Re-export `http` so the `#[http_client]` macro's generic `#[request(method = +// "...")]` codegen can name `firefly_client::http::Method` through the one +// facade contract path, without the user crate depending on `http` directly. +pub use http; pub use webclient::{ new_web_client, RequestSpec, ResponseSpec, WebClient, WebClientBuilder, WebClientResponse, NDJSON_CONTENT_TYPE, SSE_CONTENT_TYPE, diff --git a/crates/client/src/uri.rs b/crates/client/src/uri.rs new file mode 100644 index 00000000..f761b47c --- /dev/null +++ b/crates/client/src/uri.rs @@ -0,0 +1,116 @@ +// Copyright 2026 Firefly Software Foundation. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! URI helpers shared by the declarative HTTP-interface client codegen. +//! +//! [`encode_path_segment`] percent-encodes a value before it is spliced into a +//! single URL path segment, so a path variable like `id = "a/b"` cannot inject +//! an extra segment (or a query / fragment). This is the Rust analog of Spring's +//! `UriComponentsBuilder` path-variable encoding, used by the `#[http_client]` +//! macro when it expands a `:name` template hole. + +/// Percent-encodes `s` for safe inclusion in a **single** URL path segment. +/// +/// Every byte outside the RFC 3986 "unreserved" set plus the `sub-delims` and a +/// small set of path-safe punctuation (`@`, `:`) that are legal *within* a +/// segment is escaped as `%XX`. Crucially the segment separators and component +/// delimiters — `/`, `?`, `#`, `%`, and whitespace — are always escaped, so a +/// caller-supplied value can never break out of its segment. +/// +/// This mirrors Spring's `UriComponentsBuilder` path-variable encoding, which +/// the [`http_client`](firefly_macros) macro relies on when substituting a +/// `:name` template hole. +/// +/// # Examples +/// +/// ``` +/// use firefly_client::encode_path_segment; +/// +/// assert_eq!(encode_path_segment("42"), "42"); +/// assert_eq!(encode_path_segment("a/b"), "a%2Fb"); +/// assert_eq!(encode_path_segment("a b?c#d%e"), "a%20b%3Fc%23d%25e"); +/// ``` +pub fn encode_path_segment(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for &b in s.as_bytes() { + if is_path_segment_safe(b) { + out.push(b as char); + } else { + out.push('%'); + out.push(hex_digit(b >> 4)); + out.push(hex_digit(b & 0x0f)); + } + } + out +} + +/// Whether `b` may appear literally in a single URL path segment. +/// +/// The allowed set is RFC 3986 `pchar` minus `%` (which always introduces an +/// escape) — i.e. `unreserved` + `sub-delims` + `:` + `@`. Everything else, +/// including the segment / component delimiters `/ ? #` and any control or +/// non-ASCII byte, is escaped. +fn is_path_segment_safe(b: u8) -> bool { + matches!(b, + // unreserved: ALPHA / DIGIT / "-" / "." / "_" / "~" + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' + | b'-' | b'.' | b'_' | b'~' + // sub-delims + | b'!' | b'$' | b'&' | b'\'' | b'(' | b')' + | b'*' | b'+' | b',' | b';' | b'=' + // pchar extras legal within a segment + | b':' | b'@' + ) +} + +/// The uppercase hex digit for a nibble in `0..=15` (matching the uppercase +/// `%XX` escapes Spring's `UriComponentsBuilder` produces). +fn hex_digit(nibble: u8) -> char { + match nibble { + 0..=9 => (b'0' + nibble) as char, + _ => (b'A' + (nibble - 10)) as char, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn leaves_unreserved_untouched() { + assert_eq!(encode_path_segment("abcXYZ0189-._~"), "abcXYZ0189-._~"); + } + + #[test] + fn escapes_segment_and_component_delimiters() { + assert_eq!(encode_path_segment("a/b"), "a%2Fb"); + assert_eq!(encode_path_segment("a?b"), "a%3Fb"); + assert_eq!(encode_path_segment("a#b"), "a%23b"); + assert_eq!(encode_path_segment("100%"), "100%25"); + } + + #[test] + fn escapes_whitespace_and_non_ascii() { + assert_eq!(encode_path_segment("a b"), "a%20b"); + // U+00E9 (é) is two UTF-8 bytes, both escaped. + assert_eq!(encode_path_segment("\u{00e9}"), "%C3%A9"); + } + + #[test] + fn keeps_segment_safe_punctuation() { + // `:` and `@` are legal within a path segment and pass through. + assert_eq!(encode_path_segment("a:b@c"), "a:b@c"); + assert_eq!(encode_path_segment("a,b;c=d"), "a,b;c=d"); + } +} diff --git a/crates/firefly/src/lib.rs b/crates/firefly/src/lib.rs index 98fa0987..6f9274ef 100644 --- a/crates/firefly/src/lib.rs +++ b/crates/firefly/src/lib.rs @@ -430,6 +430,20 @@ pub mod prelude { /// The reactive `Mono`/`Flux` types (Reactor-style). pub use firefly_reactive::{Flux, Mono}; + // ---- Outbound HTTP client ------------------------------------------ + /// The declarative HTTP-interface client surface: the [`ClientError`] a + /// `#[http_client]` `Result` method returns, the reactive + /// [`WebClient`] / [`WebClientResponse`] the generated `Impl` wraps, + /// and the `new_web_client` / `WebClientBuilder` entry points used to + /// construct or inject one. + /// + /// [`ClientError`]: firefly_client::ClientError + /// [`WebClient`]: firefly_client::WebClient + /// [`WebClientResponse`]: firefly_client::WebClientResponse + pub use firefly_client::{ + new_web_client, ClientError, WebClient, WebClientBuilder, WebClientResponse, + }; + // ---- Every macro ---------------------------------------------------- /// All derive/attribute macros from `firefly-macros`. // `allow(unused_imports)`: empty while the macro crate is a placeholder diff --git a/crates/macros/Cargo.toml b/crates/macros/Cargo.toml index 56f721bd..9b240a5f 100644 --- a/crates/macros/Cargo.toml +++ b/crates/macros/Cargo.toml @@ -31,6 +31,12 @@ firefly = { workspace = true, features = ["data-sqlx"] } # `#[async_method]` macro test can route through a local `__rt` shim, exercising # the executor surface without compiling the heavy facade. firefly-scheduling = { workspace = true } +# The `#[http_client]` round-trip test points a generated `Impl` at an +# in-process axum server; `firefly-client` / `firefly-reactive` are named +# directly so the test can build the `WebClient` and assert `Mono` / `Flux` +# results (and the `Flux::collect_list().block()` streaming case). +firefly-client = { workspace = true } +firefly-reactive = { workspace = true } sqlx = { workspace = true } tokio = { workspace = true } serde = { workspace = true } diff --git a/crates/macros/src/http_client.rs b/crates/macros/src/http_client.rs new file mode 100644 index 00000000..c79b91a6 --- /dev/null +++ b/crates/macros/src/http_client.rs @@ -0,0 +1,1510 @@ +// Copyright 2026 Firefly Software Foundation. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! `#[http_client]` — the declarative HTTP-interface client (Spring's +//! `@HttpExchange`). +//! +//! Applied to a `trait` of methods, it emits the trait verbatim (with the verb +//! and per-arg marker attributes stripped) plus a generated concrete +//! `Impl` struct that wraps a [`WebClient`](firefly_client::WebClient) +//! and implements the trait by translating each method's verb attribute, path +//! template, and bound arguments into a fluent `WebClient` request. +//! +//! See the macro entry point's rustdoc in `lib.rs` for the user-facing surface. +//! +//! # Error-fold fidelity caveat +//! +//! For the awaited `async fn -> Result` shape, every failure +//! (transport, encode, decode, invalid URL, and an upstream error status) folds +//! into [`ClientError::Problem`](firefly_client::ClientError::Problem) carrying a +//! `FireflyError`. The original HTTP status / problem code is preserved, so the +//! `ClientError` classifiers (`is_not_found()`, `is_server_error()`, +//! `is_retryable()`) still answer correctly. The structured +//! `ClientError::Transport` / `::Decode` / `::Encode` / `::InvalidUrl` variants +//! are **not** reconstructed in this form — they are preserved only on the +//! `Mono` / `Flux` (non-awaited) return forms, which surface the raw +//! `ClientError` from the underlying `WebClient` pipeline unchanged. + +use proc_macro2::{Span, TokenStream}; +use quote::{format_ident, quote}; +use syn::{FnArg, GenericArgument, ItemTrait, PathArguments, ReturnType, TraitItem, Type}; + +use crate::common::{facade_from_override, Facade}; +use crate::web::{join_path, unwrap_result_ok, MappingAttr, VERBS}; + +/// Trait-level `#[http_client(...)]` options. +#[derive(Default)] +struct ClientArgs { + krate: Option, + /// Base path joined onto every method route. + path: Option, + /// DI bean name / qualifier. + name: Option, + /// Trait-wide default `Accept` header. + accept: Option, + /// Trait-wide default `Content-Type` header. + content_type: Option, + /// Name of a `WebClient` bean to resolve (instead of building one). + client: Option, + /// Opt-in DI bean registration. + bean: bool, +} + +/// Where a bound argument's value goes on the wire. +enum Binding { + /// Substitutes the `:name` template hole (percent-encoded, via `Display`). + Path { var: String, ident: syn::Ident }, + /// `.query(key, value)`; `Option<_>` omits `None`, `Vec`/`&[_]` repeats. + Query { + key: String, + ident: syn::Ident, + ty: QueryShape, + }, + /// `.header(name, value)` (via `Display`); `Option<_>` omits `None`. + Header { + name: String, + ident: syn::Ident, + optional: bool, + }, + /// `.body(&value)` (via `Serialize`). + Body { ident: syn::Ident }, +} + +/// The query-scalar container shape, selecting how a query arg is emitted. +#[derive(Clone, Copy)] +enum QueryShape { + /// A bare scalar — `.query(k, v.to_string())`. + Scalar, + /// `Option` — conditional `.query` only when `Some`. + Option, + /// `Vec` / `&[T]` — repeated `.query` per element. + Repeated, +} + +/// The detected return shape of a trait method. +enum ReturnShape { + /// `async fn -> Result` (or a custom `E: From`). + /// Boxed so the variant sizes stay close (it carries several `Type`s). + ResultMono(Box), + /// `fn -> Mono` (non-async) — returns the `Mono` directly. + Mono { item_ty: Type, is_exchange: bool }, + /// `fn -> Flux` (non-async) — returns the `Flux` directly. + Flux { item_ty: Type }, +} + +/// The folding plan for the `async fn -> Result` return shape. +struct ResultPlan { + /// The success type `T` (after unwrapping `Result`). + ok_ty: Type, + /// `Some` when the error type is not the bare `ClientError` and the fold + /// must `.map_err(>::from)`. + map_err: Option, + /// The empty-body fold class for `T`. + empty: EmptyClass, + /// `WebClientResponse` escape hatch — use `.exchange()` not `body_to_mono`. + is_exchange: bool, +} + +/// How an empty (204 / null) body folds for a `Result` success type. +#[derive(Clone, Copy)] +enum EmptyClass { + /// `T = ()` — `Ok(())`. + Unit, + /// `T = Option<_>` — `Ok(None)`. + OptionNone, + /// `T = Vec<_>` — `Ok(vec![])`. + VecEmpty, + /// `T` is a concrete required value — synthesize a CLIENT_EMPTY_BODY problem. + Required, +} + +/// One processed method: its signature plus the computed plan. +struct Method { + sig: syn::Signature, + verb: VerbCall, + /// The fully joined path template (base + method path), with `:name` holes. + path: String, + bindings: Vec, + ret: ReturnShape, +} + +/// The verb a method maps to, as the `WebClient` call to make. +enum VerbCall { + /// `.get()` / `.post()` / `.put()` / `.delete()` / `.patch()`. + Named(&'static str), + /// `request(method = "HEAD")` → `.method(Method::from_bytes(...))`. + Method(String), +} + +/// Expands `#[http_client]` on a trait. +pub(crate) fn http_client(args: TokenStream, item: ItemTrait) -> syn::Result { + let cfg = parse_args(args)?; + let facade = facade_from_override(&cfg.krate)?; + + let trait_ident = item.ident.clone(); + let impl_ident = format_ident!("{}Impl", trait_ident); + let base_path = cfg.path.clone().unwrap_or_default(); + let base_path = base_path.trim_end_matches('/').to_string(); + + // Process every method against a *cleaned* copy of the trait — the emitted + // trait must carry no verb / per-arg marker attributes. + let mut clean = item.clone(); + let mut methods: Vec = Vec::new(); + let mut has_async = false; + for trait_item in &mut clean.items { + let TraitItem::Fn(method) = trait_item else { + continue; + }; + let processed = process_method(method, &base_path, &cfg)?; + if processed.sig.asyncness.is_some() { + has_async = true; + } + methods.push(processed); + } + + if methods.is_empty() { + return Err(syn::Error::new_spanned( + &item.ident, + "#[http_client] found no methods carrying a #[get]/#[post]/#[put]/#[delete]/\ + #[patch]/#[request] verb attribute", + )); + } + + // When `bean` is requested the trait must be object-safe for the `dyn` + // bind; surface the un-object-safe shapes up front rather than as a + // downstream `dyn Trait` error. The `dyn Trait` autowire target must also be + // `Send + Sync` (every `Arc` bean is), so add those supertraits to + // the emitted trait when they are not already present. + if cfg.bean { + check_object_safe(&item)?; + ensure_send_sync_supertraits(&mut clean); + } + + let method_impls = methods + .iter() + .map(|m| emit_method(m, &facade)) + .collect::>>()?; + + let rt = facade.rt(); + let client_rt = quote!(#rt::firefly_client); + let aop = facade.aop(); + + // `::new(base_url)` applies the trait-wide default headers. + let mut header_chain = TokenStream::new(); + if let Some(accept) = cfg.accept.as_deref().filter(|s| !s.is_empty()) { + header_chain.extend(quote! { .with_header("Accept", #accept) }); + } + if let Some(ct) = cfg.content_type.as_deref().filter(|s| !s.is_empty()) { + header_chain.extend(quote! { .with_header("Content-Type", #ct) }); + } + + let new_doc = format!( + "Builds a `{impl_ident}` that issues every request through a freshly built \ + `WebClient` rooted at `base_url`. The trait's `accept` / `content_type` \ + defaults (if any) are applied as default headers. Generated by `#[http_client]`." + ); + let with_client_doc = format!( + "Builds a `{impl_ident}` over an already-configured `WebClient` (timeouts, \ + default headers, a shared connection pool) — the primary DI seam, the \ + analog of Spring's `HttpServiceProxyFactory`. Generated by `#[http_client]`." + ); + + // The `#[async_trait]` impl is emitted only when the trait has an `async fn` + // method; async-trait passes non-async (`Mono`/`Flux`) methods through + // unchanged, but emitting it on a fully-non-async trait would still rewrite + // those signatures, so it is gated. + let trait_impl = if has_async { + quote! { + #[#aop::async_trait] + impl #trait_ident for #impl_ident { + #(#method_impls)* + } + } + } else { + quote! { + impl #trait_ident for #impl_ident { + #(#method_impls)* + } + } + }; + + let di_tokens = if cfg.bean { + emit_di(&trait_ident, &impl_ident, &cfg, &facade)? + } else { + TokenStream::new() + }; + + // When the trait carries `async fn` methods, both the trait declaration and + // its impl must wear `#[async_trait]` so the desugared + // `-> Pin>` signatures line up (and the trait stays + // dyn-compatible for the `Arc` autowire). A fully-non-async + // (`Mono`/`Flux`) trait is emitted untouched. + let trait_decl = if has_async { + quote! { + #[#aop::async_trait] + #clean + } + } else { + quote! { #clean } + }; + + Ok(quote! { + #trait_decl + + #[derive(::core::clone::Clone)] + pub struct #impl_ident { + __web: #client_rt::WebClient, + } + + impl #impl_ident { + #[doc = #new_doc] + pub fn new(base_url: impl ::core::convert::AsRef) -> Self { + let __web = #client_rt::new_web_client(base_url) + #header_chain + .build(); + Self { __web } + } + + #[doc = #with_client_doc] + pub fn with_client(__web: #client_rt::WebClient) -> Self { + Self { __web } + } + } + + #trait_impl + + #di_tokens + }) +} + +/// Parses the trait-level `#[http_client(...)]` arguments. +fn parse_args(args: TokenStream) -> syn::Result { + let mut out = ClientArgs::default(); + if args.is_empty() { + return Ok(out); + } + let parser = syn::meta::parser(|meta| { + if meta.path.is_ident("crate") { + out.krate = Some(meta.value()?.parse::()?.value()); + } else if meta.path.is_ident("path") { + out.path = Some(meta.value()?.parse::()?.value()); + } else if meta.path.is_ident("name") { + out.name = Some(meta.value()?.parse::()?.value()); + } else if meta.path.is_ident("accept") { + out.accept = Some(meta.value()?.parse::()?.value()); + } else if meta.path.is_ident("content_type") { + out.content_type = Some(meta.value()?.parse::()?.value()); + } else if meta.path.is_ident("client") { + out.client = Some(meta.value()?.parse::()?.value()); + } else if meta.path.is_ident("bean") { + // Bare flag (or `bean = true/false`). + if meta.input.peek(syn::Token![=]) { + out.bean = meta.value()?.parse::()?.value(); + } else { + out.bean = true; + } + } else { + return Err(meta.error( + "unknown #[http_client] argument; use path, name, accept, content_type, \ + client, bean, or crate", + )); + } + Ok(()) + }); + syn::parse::Parser::parse2(parser, args)?; + Ok(out) +} + +/// Strips the verb + per-arg markers from `method`, parses them, and computes +/// the binding plan and return shape. +fn process_method( + method: &mut syn::TraitItemFn, + base_path: &str, + _cfg: &ClientArgs, +) -> syn::Result { + // 1. Find and strip the single verb attribute. + let (verb, mapping) = take_verb_attr(method)?; + + // 2. Validate the `&self` receiver. + let mut inputs = method.sig.inputs.iter(); + match inputs.next() { + Some(FnArg::Receiver(r)) if r.reference.is_some() && r.mutability.is_none() => {} + _ => { + return Err(syn::Error::new_spanned( + &method.sig, + "an #[http_client] method must take `&self`", + )); + } + } + + // 3. The joined path template + its `:name` holes. + let sub_path = mapping.path.as_ref().map(|l| l.value()).unwrap_or_default(); + let path = join_path(base_path, &sub_path); + let segments = parse_path_template(&path, &method.sig)?; + let path_vars: Vec = segments + .iter() + .filter_map(|s| match s { + Segment::Var(v) => Some(v.clone()), + Segment::Literal(_) => None, + }) + .collect(); + + // 4. Body-allowing verbs. Named `post`/`put`/`patch` carry a body; `get` + // (and the other named read verbs) do not. A custom `#[request(method)]` + // verb is body-eligible only when it is not one of the bodyless methods + // (GET/HEAD/OPTIONS/TRACE/CONNECT), so a struct arg on e.g. `HEAD` is not + // silently routed to the body. + let body_allowed = match &verb { + VerbCall::Named("post") | VerbCall::Named("put") | VerbCall::Named("patch") => true, + VerbCall::Named(_) => false, + VerbCall::Method(m) => !matches!( + m.to_ascii_uppercase().as_str(), + "GET" | "HEAD" | "OPTIONS" | "TRACE" | "CONNECT" + ), + }; + + // 5. Parse + strip per-arg attrs and compute the binding plan. + let bindings = bind_params(method, &path_vars, body_allowed)?; + + // 6. Return shape. + let ret = return_shape(method)?; + + Ok(Method { + sig: method.sig.clone(), + verb, + path, + bindings, + ret, + }) +} + +/// Finds (and strips) the single verb attribute on a method, returning the verb +/// call + parsed [`MappingAttr`]. An unknown / duplicate / missing verb attr is +/// a precise compile error. +fn take_verb_attr(method: &mut syn::TraitItemFn) -> syn::Result<(VerbCall, MappingAttr)> { + let mut found: Option<(VerbCall, MappingAttr)> = None; + let mut kept = Vec::with_capacity(method.attrs.len()); + for attr in std::mem::take(&mut method.attrs) { + let named_verb = VERBS.iter().find(|v| attr.path().is_ident(v)); + let is_request = attr.path().is_ident("request"); + if named_verb.is_none() && !is_request { + kept.push(attr); + continue; + } + if found.is_some() { + return Err(syn::Error::new_spanned( + &attr, + "an #[http_client] method may carry at most one HTTP-verb mapping", + )); + } + let (verb, mapping) = if let Some(v) = named_verb { + // A named verb reuses the server's `MappingAttr` grammar verbatim. + (VerbCall::Named(v), parse_mapping_attr(&attr)?) + } else { + // `#[request(method = "HEAD", path = "/x", status = N)]` — the + // generic form has its own small grammar (`method` is required and + // is not part of the server's `MappingAttr`). + parse_request_attr(&attr)? + }; + found = Some((verb, mapping)); + } + method.attrs = kept; + found.ok_or_else(|| { + syn::Error::new_spanned( + &method.sig, + "an #[http_client] method must carry a verb attribute: #[get]/#[post]/#[put]/\ + #[delete]/#[patch] or #[request(method = \"...\")]", + ) + }) +} + +/// Parses a verb attribute into a [`MappingAttr`] (handling the bare-path +/// `#[get]` form), reusing the server's grammar. +fn parse_mapping_attr(attr: &syn::Attribute) -> syn::Result { + match &attr.meta { + syn::Meta::Path(_) => Ok(MappingAttr::default()), + syn::Meta::List(_) => attr.parse_args::(), + syn::Meta::NameValue(_) => Err(syn::Error::new_spanned( + attr, + "expected `#[get(\"/path\", ...)]` or a bare `#[get]`", + )), + } +} + +/// Parses a `#[request(method = "...", path = "...", status = N)]` attribute +/// into the verb call + a [`MappingAttr`] carrying its `path` / `status`. The +/// generic verb's grammar is small and `method`-required, distinct from the +/// named-verb `MappingAttr` grammar (which has no `method` key). +fn parse_request_attr(attr: &syn::Attribute) -> syn::Result<(VerbCall, MappingAttr)> { + let mut method_name: Option = None; + let mut mapping = MappingAttr::default(); + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("method") { + method_name = Some(meta.value()?.parse::()?.value()); + } else if meta.path.is_ident("path") { + mapping.path = Some(meta.value()?.parse::()?); + } else if meta.path.is_ident("status") { + mapping.status = Some(meta.value()?.parse::()?.base10_parse()?); + } else if meta.input.peek(syn::Token![=]) { + // Tolerate (and ignore) the other server-only OpenAPI keys so a + // signature copies cleanly between server and client. + let _: syn::Lit = meta.value()?.parse()?; + } else { + return Err(meta.error("unknown #[request(...)] argument; use method, path, or status")); + } + Ok(()) + })?; + let method_name = method_name.ok_or_else(|| { + syn::Error::new_spanned( + attr, + "#[request(...)] requires `method = \"\"` (e.g. \ + #[request(method = \"HEAD\", path = \"/x\")])", + ) + })?; + // Validate the method string at expansion time so a malformed token is a + // clean compile error rather than a runtime `from_bytes(..).expect(..)` panic. + validate_http_method(attr, &method_name)?; + Ok((VerbCall::Method(method_name), mapping)) +} + +/// Validates that a `#[request(method = "...")]` string is a non-empty ASCII +/// HTTP method token. The grammar is restricted to uppercase ASCII letters +/// (`A`–`Z`) — the safe superset covering every standard verb (and any sensible +/// custom one) — so a malformed method becomes a clean compile error instead of +/// a runtime panic from `http::Method::from_bytes(..).expect(..)`. +fn validate_http_method(attr: &syn::Attribute, method: &str) -> syn::Result<()> { + let ok = !method.is_empty() && method.bytes().all(|b| b.is_ascii_uppercase()); + if !ok { + return Err(syn::Error::new_spanned( + attr, + format!( + "#[request(method = \"{method}\")] is not a valid HTTP method; expected a \ + non-empty uppercase ASCII token (e.g. \"GET\", \"HEAD\", \"PATCH\")" + ), + )); + } + Ok(()) +} + +/// One piece of a parsed path template. +enum Segment { + Literal(String), + Var(String), +} + +/// Splits a path template into literal + `:name` variable segments. Rejects the +/// Spring `{id}` spelling with a pointer at the axum `:id` convention. +fn parse_path_template(path: &str, sig: &syn::Signature) -> syn::Result> { + if path.contains('{') || path.contains('}') { + return Err(syn::Error::new_spanned( + sig, + format!( + "#[http_client] path {path:?} uses Spring `{{name}}` syntax; firefly uses the \ + axum-style `:name` path-variable spelling (e.g. `/orders/:id`)" + ), + )); + } + // Split on '/', keeping the separators so the URI is reconstructed exactly. + let mut out: Vec = Vec::new(); + let mut literal = String::new(); + for (i, raw) in path.split('/').enumerate() { + if i > 0 { + literal.push('/'); + } + if let Some(name) = raw.strip_prefix(':') { + if name.is_empty() { + return Err(syn::Error::new_spanned( + sig, + format!("#[http_client] path {path:?} has an empty `:` path variable"), + )); + } + // Flush the accumulated literal (including the trailing '/'), then + // record the variable. + out.push(Segment::Literal(std::mem::take(&mut literal))); + out.push(Segment::Var(name.to_string())); + } else { + literal.push_str(raw); + } + } + if !literal.is_empty() { + out.push(Segment::Literal(literal)); + } + Ok(out) +} + +/// Computes the per-argument binding plan, parsing + stripping each arg's +/// marker attribute. Applies the first-match-wins precedence: explicit attr > +/// path-name-match > body-default > query-default, then verifies every `:name` +/// is bound exactly once. +fn bind_params( + method: &mut syn::TraitItemFn, + path_vars: &[String], + body_allowed: bool, +) -> syn::Result> { + // First pass: classify each typed arg, stripping its marker attribute. + enum Slot { + ExplicitPath { + var: String, + ident: syn::Ident, + ty: Type, + }, + ExplicitQuery { + key: String, + ident: syn::Ident, + ty: Type, + }, + ExplicitHeader { + name: String, + ident: syn::Ident, + ty: Type, + }, + ExplicitBody { + ident: syn::Ident, + }, + Unannotated { + ident: syn::Ident, + ty: Type, + }, + } + + let mut slots: Vec = Vec::new(); + let mut body_count = 0usize; + for arg in method.sig.inputs.iter_mut() { + let FnArg::Typed(pat) = arg else { + // The receiver (`&self`), already validated. + continue; + }; + let ident = pat_ident(&pat.pat)?; + let arg_attr = take_arg_attr(&mut pat.attrs, &ident)?; + let ty = (*pat.ty).clone(); + match arg_attr { + Some(ArgAttr::Path { name }) => { + let var = name.unwrap_or_else(|| ident.to_string()); + slots.push(Slot::ExplicitPath { var, ident, ty }); + } + Some(ArgAttr::Query { name }) => { + let key = name.unwrap_or_else(|| ident.to_string()); + slots.push(Slot::ExplicitQuery { key, ident, ty }); + } + Some(ArgAttr::Header { name }) => { + slots.push(Slot::ExplicitHeader { name, ident, ty }); + } + Some(ArgAttr::Body) => { + body_count += 1; + slots.push(Slot::ExplicitBody { ident }); + } + None => slots.push(Slot::Unannotated { ident, ty }), + } + } + if body_count > 1 { + return Err(syn::Error::new_spanned( + &method.sig, + "an #[http_client] method may carry at most one #[body] argument", + )); + } + + // Second pass: resolve the unannotated args by precedence. + let mut bindings: Vec = Vec::new(); + let mut bound_vars: Vec = Vec::new(); + // Has any arg (explicit or matched) claimed the body slot yet? + let mut has_body = body_count > 0; + // Collect the body-eligible unannotated candidates to detect ambiguity. + let mut body_candidates: Vec = Vec::new(); + + for slot in &slots { + match slot { + Slot::ExplicitPath { var, ident, ty } => { + if !path_vars.iter().any(|v| v == var) { + return Err(syn::Error::new_spanned( + &method.sig, + format!( + "#[path(\"{var}\")] on `{ident}` does not match any `:{var}` segment \ + in the method path" + ), + )); + } + check_path_var_ty(var, ident, ty)?; + bound_vars.push(var.clone()); + bindings.push(Binding::Path { + var: var.clone(), + ident: ident.clone(), + }); + } + Slot::ExplicitQuery { key, ident, ty } => { + bindings.push(Binding::Query { + key: key.clone(), + ident: ident.clone(), + ty: query_shape(ty), + }); + } + Slot::ExplicitHeader { name, ident, ty } => { + bindings.push(Binding::Header { + name: name.clone(), + ident: ident.clone(), + optional: is_option(ty), + }); + } + Slot::ExplicitBody { ident } => { + bindings.push(Binding::Body { + ident: ident.clone(), + }); + } + Slot::Unannotated { ident, ty } => { + // Precedence: path-name-match first. + let ident_str = ident.to_string(); + if path_vars.iter().any(|v| v == &ident_str) { + check_path_var_ty(&ident_str, ident, ty)?; + bound_vars.push(ident_str.clone()); + bindings.push(Binding::Path { + var: ident_str, + ident: ident.clone(), + }); + continue; + } + // Body-default: a lone non-query-scalar arg on a body verb. + if body_allowed && !is_query_scalar(ty) { + body_candidates.push(ident.clone()); + // Defer the actual body binding until we know it is unique; + // record a placeholder via the candidate list. + continue; + } + // Query-default. + bindings.push(Binding::Query { + key: ident_str, + ident: ident.clone(), + ty: query_shape(ty), + }); + } + } + } + + // Resolve the body candidates. + match (has_body, body_candidates.len()) { + (false, 0) => {} + (false, 1) => { + bindings.push(Binding::Body { + ident: body_candidates.remove(0), + }); + has_body = true; + } + (false, _) => { + return Err(syn::Error::new_spanned( + &method.sig, + "ambiguous body; more than one argument could be the request body — \ + annotate exactly one with #[body]", + )); + } + (true, n) if n >= 1 => { + // An explicit #[body] is present, but other non-scalar unannotated + // args remain with no binding — also ambiguous. + return Err(syn::Error::new_spanned( + &method.sig, + "ambiguous body; an #[body] argument is already declared but another \ + non-scalar argument is unannotated — annotate it with #[query]/#[header] \ + or remove it", + )); + } + (true, _) => {} + } + let _ = has_body; + + // Every `:name` must be bound exactly once. + for var in path_vars { + let count = bound_vars.iter().filter(|v| *v == var).count(); + if count == 0 { + return Err(syn::Error::new_spanned( + &method.sig, + format!( + "the path variable `:{var}` is not bound to any argument; add an argument \ + named `{var}` or annotate one with #[path(\"{var}\")]" + ), + )); + } + if count > 1 { + return Err(syn::Error::new_spanned( + &method.sig, + format!("the path variable `:{var}` is bound by more than one argument"), + )); + } + } + + Ok(bindings) +} + +/// A parsed per-argument marker attribute. +enum ArgAttr { + Path { name: Option }, + Query { name: Option }, + Header { name: String }, + Body, +} + +/// Finds and strips the single per-arg marker (`#[path]`/`#[query]`/`#[header]`/ +/// `#[body]`) on an argument, rejecting conflicts. +fn take_arg_attr( + attrs: &mut Vec, + ident: &syn::Ident, +) -> syn::Result> { + let mut found: Option = None; + let mut kept = Vec::with_capacity(attrs.len()); + for attr in std::mem::take(attrs) { + let which = ["path", "query", "header", "body"] + .iter() + .find(|k| attr.path().is_ident(k)) + .copied(); + let Some(which) = which else { + kept.push(attr); + continue; + }; + if found.is_some() { + return Err(syn::Error::new_spanned( + &attr, + format!( + "argument `{ident}` carries conflicting binding attributes; use exactly one \ + of #[path], #[query], #[header], #[body]" + ), + )); + } + let parsed = match which { + "path" => ArgAttr::Path { + name: optional_lit(&attr)?, + }, + "query" => ArgAttr::Query { + name: optional_lit(&attr)?, + }, + "header" => { + let name = required_lit(&attr).map_err(|_| { + syn::Error::new_spanned( + &attr, + format!( + "#[header(\"X-Name\")] on `{ident}` requires the header name as a \ + string literal (header names are not identifiers)" + ), + ) + })?; + ArgAttr::Header { name } + } + "body" => { + if !matches!(attr.meta, syn::Meta::Path(_)) { + return Err(syn::Error::new_spanned(&attr, "#[body] takes no arguments")); + } + ArgAttr::Body + } + _ => unreachable!(), + }; + found = Some(parsed); + } + *attrs = kept; + Ok(found) +} + +/// Reads an optional positional string literal from `#[path]` / `#[path("id")]`. +fn optional_lit(attr: &syn::Attribute) -> syn::Result> { + match &attr.meta { + syn::Meta::Path(_) => Ok(None), + syn::Meta::List(_) => Ok(Some(attr.parse_args::()?.value())), + syn::Meta::NameValue(_) => Err(syn::Error::new_spanned( + attr, + "expected `#[path]` or `#[path(\"name\")]`", + )), + } +} + +/// Reads a required positional string literal from `#[header("X")]`. +fn required_lit(attr: &syn::Attribute) -> syn::Result { + Ok(attr.parse_args::()?.value()) +} + +/// The `Ident` of a simple argument pattern, or an error for a destructured / +/// non-ident pattern. +fn pat_ident(pat: &syn::Pat) -> syn::Result { + match pat { + syn::Pat::Ident(p) => Ok(p.ident.clone()), + _ => Err(syn::Error::new_spanned( + pat, + "#[http_client] method arguments must be simple `name: Type` bindings", + )), + } +} + +/// The macro-introduced locals of a generated method body, minted at +/// [`Span::mixed_site`] so user method params (or trait-wide names) can never +/// capture or shadow them. (`__web` is a struct field reached via `self`, so it +/// is intentionally *not* here — it must stay `call_site` to resolve.) +struct Hygiene { + /// The built request URI (`let (mut) __uri = ...`). + uri: syn::Ident, + /// The mutable `RequestSpec` accumulator. + spec: syn::Ident, + /// The per-iteration / per-arm scratch value. + v: syn::Ident, + /// The folded `Result` before any `map_err`. + out: syn::Ident, + /// The bound `FireflyError` in the error arm. + fe: syn::Ident, +} + +impl Hygiene { + fn new() -> Self { + let id = |n: &str| syn::Ident::new(n, Span::mixed_site()); + Self { + uri: id("__uri"), + spec: id("__spec"), + v: id("__v"), + out: id("__out"), + fe: id("__fe"), + } + } +} + +/// Emits the body of one method in the generated trait impl. +fn emit_method(m: &Method, facade: &Facade) -> syn::Result { + let rt = facade.rt(); + let client_rt = quote!(#rt::firefly_client); + let sig = &m.sig; + let hy = Hygiene::new(); + let Hygiene { uri, spec, .. } = &hy; + + // 1. Build the `__uri` expression from the path template. + let uri_build = emit_uri(m, &client_rt, &hy); + + // 2. The verb call producing the initial `RequestSpec`. + let verb_call = match &m.verb { + VerbCall::Named(v) => { + let vident = syn::Ident::new(v, Span::call_site()); + quote!(self.__web.#vident()) + } + VerbCall::Method(name) => { + let bytes = syn::LitByteStr::new(name.as_bytes(), Span::call_site()); + quote!(self.__web.method( + #client_rt::http::Method::from_bytes(#bytes) + .expect("#[http_client] #[request(method)] is a valid HTTP method") + )) + } + }; + + // 3. Per-arg request mutations appended after `.uri(__uri)`. + let mutations = m.bindings.iter().map(|b| emit_binding(b, &hy)); + + // The `Flux` accept default is set before the user-overridable headers so an + // explicit per-trait `accept` still wins (it is a default header on the + // WebClient, which `.header(..)` only overrides when re-set here). + let flux_accept = if matches!(m.ret, ReturnShape::Flux { .. }) { + quote! { #spec = #spec.header("Accept", #client_rt::NDJSON_CONTENT_TYPE); } + } else { + TokenStream::new() + }; + + let terminal = emit_terminal(m, facade, &hy)?; + + Ok(quote! { + #sig { + #uri_build + let mut #spec = #verb_call.uri(#uri); + #flux_accept + #(#mutations)* + #terminal + } + }) +} + +/// Emits the `let __uri = ...;` statement reconstructing the path with each +/// `:name` hole replaced by `encode_path_segment(arg.to_string())`. +fn emit_uri(m: &Method, client_rt: &TokenStream, hy: &Hygiene) -> TokenStream { + let uri = &hy.uri; + // The template was already validated in `process_method`; an error here is + // unreachable, but fall back to the raw path literal rather than panicking. + let segments = match parse_path_template(&m.path, &m.sig) { + Ok(s) => s, + Err(_) => { + let path = &m.path; + return quote! { let #uri = #path; }; + } + }; + // Map each path var name to its bound arg ident. + let mut pushes = TokenStream::new(); + let mut has_var = false; + for seg in &segments { + match seg { + Segment::Literal(lit) => { + if !lit.is_empty() { + pushes.extend(quote! { #uri.push_str(#lit); }); + } + } + Segment::Var(name) => { + has_var = true; + let ident = m.bindings.iter().find_map(|b| match b { + Binding::Path { var, ident } if var == name => Some(ident), + _ => None, + }); + let ident = ident.expect("path var bound (validated in bind_params)"); + pushes.extend(quote! { + #uri.push_str(&#client_rt::encode_path_segment( + &::std::string::ToString::to_string(&#ident) + )); + }); + } + } + } + if has_var { + quote! { + let mut #uri = ::std::string::String::new(); + #pushes + } + } else { + // No variables — a single static literal. + let path = &m.path; + quote! { let #uri = #path; } + } +} + +/// Emits the request mutation for one binding (appended after `.uri(..)`). +fn emit_binding(b: &Binding, hy: &Hygiene) -> TokenStream { + let Hygiene { spec, v, .. } = hy; + match b { + Binding::Path { .. } => TokenStream::new(), // handled in the URI build + Binding::Query { key, ident, ty } => match ty { + QueryShape::Scalar => quote! { + #spec = #spec.query(#key, ::std::string::ToString::to_string(&#ident)); + }, + QueryShape::Option => quote! { + if let ::core::option::Option::Some(#v) = &#ident { + #spec = #spec.query(#key, ::std::string::ToString::to_string(#v)); + } + }, + QueryShape::Repeated => quote! { + for #v in (&#ident).into_iter() { + #spec = #spec.query(#key, ::std::string::ToString::to_string(#v)); + } + }, + }, + Binding::Header { + name, + ident, + optional, + } => { + if *optional { + quote! { + if let ::core::option::Option::Some(#v) = &#ident { + #spec = #spec.header(#name, ::std::string::ToString::to_string(#v)); + } + } + } else { + quote! { + #spec = #spec.header(#name, ::std::string::ToString::to_string(&#ident)); + } + } + } + Binding::Body { ident } => quote! { + #spec = #spec.body(&#ident); + }, + } +} + +/// Emits the terminal operator + (for the `Result` shape) the fold. +fn emit_terminal(m: &Method, facade: &Facade, hy: &Hygiene) -> syn::Result { + let rt = facade.rt(); + let client_rt = "e!(#rt::firefly_client); + let kernel = quote!(#rt::firefly_kernel); + let Hygiene { + spec, v, out, fe, .. + } = hy; + match &m.ret { + ReturnShape::Mono { + item_ty, + is_exchange, + } => { + if *is_exchange { + Ok(quote! { #spec.retrieve().exchange() }) + } else { + Ok(quote! { #spec.retrieve().body_to_mono::<#item_ty>() }) + } + } + ReturnShape::Flux { item_ty } => Ok(quote! { #spec.retrieve().body_to_flux::<#item_ty>() }), + ReturnShape::ResultMono(plan) => { + let ResultPlan { + ok_ty, + map_err, + empty, + is_exchange, + } = &**plan; + let empty_problem = emit_empty_body_problem(&kernel); + let fold = if *is_exchange { + // `Result` — the raw exchange escape hatch. + quote! { + match #spec.retrieve().exchange().await { + ::core::result::Result::Ok(::core::option::Option::Some(#v)) => + ::core::result::Result::Ok(#v), + ::core::result::Result::Ok(::core::option::Option::None) => + ::core::result::Result::Err( + #client_rt::ClientError::Problem(#empty_problem) + ), + ::core::result::Result::Err(#fe) => + ::core::result::Result::Err( + #client_rt::ClientError::Problem(#fe) + ), + } + } + } else { + let none_arm = emit_empty_arm(*empty, client_rt, &empty_problem); + quote! { + match #spec.retrieve().body_to_mono::<#ok_ty>().await { + ::core::result::Result::Ok(::core::option::Option::Some(#v)) => + ::core::result::Result::Ok(#v), + ::core::result::Result::Ok(::core::option::Option::None) => + #none_arm, + ::core::result::Result::Err(#fe) => + ::core::result::Result::Err( + #client_rt::ClientError::Problem(#fe) + ), + } + } + }; + // Route through the facade `ClientError` so `Problem(..)` and the + // synthesized empty-body problem name the same type the user wrote. + let body = quote! { + let #out: ::core::result::Result<#ok_ty, #client_rt::ClientError> = { #fold }; + }; + match map_err { + None => Ok(quote! { + #body + #out + }), + Some(err_ty) => Ok(quote! { + #body + #out.map_err(<#err_ty as ::core::convert::From<#client_rt::ClientError>>::from) + }), + } + } + } +} + +/// The synthesized `CLIENT_EMPTY_BODY` 502 problem for a required-`T` empty +/// body — a typed error instead of a panic (defensive, ~unreachable). +fn emit_empty_body_problem(kernel: &TokenStream) -> TokenStream { + quote! { + #kernel::FireflyError::new( + "CLIENT_EMPTY_BODY", + "Bad Gateway", + 502u16, + "the upstream returned an empty body for a required response value", + ) + } +} + +/// The `Ok(None)` fold arm for an empty body, by success-type class. +fn emit_empty_arm( + empty: EmptyClass, + client_rt: &TokenStream, + empty_problem: &TokenStream, +) -> TokenStream { + match empty { + EmptyClass::Unit => quote!(::core::result::Result::Ok(())), + EmptyClass::OptionNone => { + quote!(::core::result::Result::Ok(::core::option::Option::None)) + } + EmptyClass::VecEmpty => quote!(::core::result::Result::Ok(::std::vec::Vec::new())), + EmptyClass::Required => quote! { + ::core::result::Result::Err(#client_rt::ClientError::Problem(#empty_problem)) + }, + } +} + +/// Detects the structural return shape and validates `asyncness` against it. +fn return_shape(method: &syn::TraitItemFn) -> syn::Result { + let is_async = method.sig.asyncness.is_some(); + let ReturnType::Type(_, ty) = &method.sig.output else { + return Err(non_supported_return_err(&method.sig)); + }; + + // `Mono` / `Flux` (non-async, reactive-first). + if let Some(inner) = generic_arg(ty, "Mono") { + if is_async { + return Err(syn::Error::new_spanned( + &method.sig, + "a Mono-returning method must not be `async fn`; the Mono is already deferred — \ + drop `async` (or return `Result<_, ClientError>` for the awaited form)", + )); + } + let is_exchange = last_ident_is(&inner, "WebClientResponse"); + return Ok(ReturnShape::Mono { + item_ty: inner, + is_exchange, + }); + } + if let Some(inner) = generic_arg(ty, "Flux") { + if is_async { + return Err(syn::Error::new_spanned( + &method.sig, + "a Flux-returning method must not be `async fn`; the Flux is already deferred — \ + drop `async`", + )); + } + return Ok(ReturnShape::Flux { item_ty: inner }); + } + + // `Result` (the ergonomic awaited form). + if last_ident_is(ty, "Result") { + if !is_async { + return Err(syn::Error::new_spanned( + &method.sig, + "a Result-returning #[http_client] method must be `async fn` (it is awaited); \ + for the deferred form return `Mono` / `Flux` from a non-async fn", + )); + } + let ok_ty = unwrap_result_ok(ty).clone(); + let err_ty = result_err(ty); + let is_exchange = last_ident_is(&ok_ty, "WebClientResponse"); + let empty = classify_empty(&ok_ty); + let map_err = match &err_ty { + Some(e) if !last_ident_is(e, "ClientError") => Some(e.clone()), + _ => None, + }; + return Ok(ReturnShape::ResultMono(Box::new(ResultPlan { + ok_ty, + map_err, + empty, + is_exchange, + }))); + } + + Err(non_supported_return_err(&method.sig)) +} + +/// The standard "unsupported return type" diagnostic. +fn non_supported_return_err(sig: &syn::Signature) -> syn::Error { + syn::Error::new_spanned( + sig, + "unsupported #[http_client] return type; use `async fn -> Result` \ + (or a custom `E: From`), or a non-async `fn -> Mono` / `Flux`", + ) +} + +/// The error type `E` of a `Result`, or `None`. +fn result_err(ty: &Type) -> Option { + let Type::Path(tp) = ty else { return None }; + let seg = tp.path.segments.last()?; + let PathArguments::AngleBracketed(args) = &seg.arguments else { + return None; + }; + let mut it = args.args.iter().filter_map(|a| match a { + GenericArgument::Type(t) => Some(t), + _ => None, + }); + it.next(); // skip Ok type + it.next().cloned() +} + +/// Classifies how an empty body folds for a `Result` success type `T`. +fn classify_empty(ok_ty: &Type) -> EmptyClass { + if is_unit(ok_ty) { + EmptyClass::Unit + } else if last_ident_is(ok_ty, "Option") { + EmptyClass::OptionNone + } else if last_ident_is(ok_ty, "Vec") { + EmptyClass::VecEmpty + } else { + EmptyClass::Required + } +} + +/// The single angle-bracketed generic argument of a `Wrapper` whose last +/// segment ident is `wrapper`, or `None`. +fn generic_arg(ty: &Type, wrapper: &str) -> Option { + let Type::Path(tp) = ty else { return None }; + let seg = tp.path.segments.last()?; + if seg.ident != wrapper { + return None; + } + let PathArguments::AngleBracketed(args) = &seg.arguments else { + return None; + }; + args.args.iter().find_map(|a| match a { + GenericArgument::Type(t) => Some(t.clone()), + _ => None, + }) +} + +/// Whether `ty`'s last path segment ident equals `name`. +fn last_ident_is(ty: &Type, name: &str) -> bool { + matches!(ty, Type::Path(tp) + if tp.path.segments.last().is_some_and(|s| s.ident == name)) +} + +/// Whether `ty` is the unit type `()`. +fn is_unit(ty: &Type) -> bool { + matches!(ty, Type::Tuple(t) if t.elems.is_empty()) +} + +/// Whether `ty` is `Option<_>`. +fn is_option(ty: &Type) -> bool { + last_ident_is(ty, "Option") +} + +/// The query container shape for an argument type. +fn query_shape(ty: &Type) -> QueryShape { + if is_option(ty) { + QueryShape::Option + } else if last_ident_is(ty, "Vec") || is_slice_ref(ty) { + QueryShape::Repeated + } else { + QueryShape::Scalar + } +} + +/// Whether `ty` is `&[T]` (a slice reference). +fn is_slice_ref(ty: &Type) -> bool { + match ty { + Type::Reference(r) => matches!(&*r.elem, Type::Slice(_)), + _ => false, + } +} + +/// Whether `ty` is a `[T; N]` array. +fn is_array(ty: &Type) -> bool { + matches!(ty, Type::Array(_)) + || matches!(ty, Type::Reference(r) if matches!(&*r.elem, Type::Array(_))) +} + +/// Validates that an argument bound as a `:name` path variable is a `Display` +/// scalar, not a multi-value / optional container. An `Option<_>` / `Vec<_>` / +/// slice / array bound as a path var compiles but renders garbage URIs (e.g. +/// `…/Some(x)`, `…/None`, `…/[1, 2]`), so it is rejected at expansion time. +fn check_path_var_ty(var: &str, ident: &syn::Ident, ty: &Type) -> syn::Result<()> { + let bad = is_option(ty) || last_ident_is(ty, "Vec") || is_slice_ref(ty) || is_array(ty); + if bad { + let rendered = quote!(#ty).to_string().replace(' ', ""); + return Err(syn::Error::new_spanned( + ident, + format!( + "#[http_client] path variable `:{var}` must be a Display scalar, not \ + Option/Vec/slice — got `{rendered}`" + ), + )); + } + Ok(()) +} + +/// Whether `ty` is on the query-scalar allowlist: a primitive integer / float / +/// bool, `String` / `&str`, `Uuid`, or an `Option` / `Vec` / `&[_]` of those. +fn is_query_scalar(ty: &Type) -> bool { + match ty { + Type::Reference(r) => match &*r.elem { + // `&str` + Type::Path(_) if last_ident_is(&r.elem, "str") => true, + // `&[T]` of a scalar. + Type::Slice(s) => is_query_scalar(&s.elem), + other => is_query_scalar(other), + }, + Type::Path(tp) => { + let Some(seg) = tp.path.segments.last() else { + return false; + }; + let name = seg.ident.to_string(); + // `Option` / `Vec` of a scalar. + if name == "Option" || name == "Vec" { + if let Some(inner) = generic_arg(ty, &name) { + return is_query_scalar(&inner); + } + return false; + } + matches!( + name.as_str(), + "u8" | "u16" + | "u32" + | "u64" + | "u128" + | "usize" + | "i8" + | "i16" + | "i32" + | "i64" + | "i128" + | "isize" + | "f32" + | "f64" + | "bool" + | "char" + | "str" + | "String" + | "Uuid" + ) + } + _ => false, + } +} + +/// Validates that the trait is object-safe enough for the `dyn Trait` bind that +/// `bean` registration requires: every method must take `&self` and declare no +/// generic type/const parameters. +fn check_object_safe(item: &ItemTrait) -> syn::Result<()> { + for trait_item in &item.items { + // An associated type or const makes the trait not `dyn`-compatible; the + // `dyn Trait` bind `bean` registration needs would otherwise fail with a + // cryptic downstream error, so reject it up front. + match trait_item { + TraitItem::Type(t) => { + return Err(syn::Error::new_spanned( + t, + format!( + "#[http_client(bean)] requires an object-safe trait, but associated type \ + `{}` makes it not `dyn`-compatible; remove it or drop `bean`", + t.ident + ), + )); + } + TraitItem::Const(c) => { + return Err(syn::Error::new_spanned( + c, + format!( + "#[http_client(bean)] requires an object-safe trait, but associated const \ + `{}` makes it not `dyn`-compatible; remove it or drop `bean`", + c.ident + ), + )); + } + _ => {} + } + let TraitItem::Fn(method) = trait_item else { + continue; + }; + let has_generics = method + .sig + .generics + .params + .iter() + .any(|p| !matches!(p, syn::GenericParam::Lifetime(_))); + if has_generics { + return Err(syn::Error::new_spanned( + &method.sig, + format!( + "#[http_client(bean)] requires an object-safe trait, but method `{}` is \ + generic; remove the generic parameters or drop `bean`", + method.sig.ident + ), + )); + } + } + Ok(()) +} + +/// Adds `Send` + `Sync` supertrait bounds to the emitted trait when they are +/// not already present. The `#[http_client(bean)]` `dyn Trait` autowire target +/// (`Arc`) is bound through `Container::bind`, which requires the +/// interface type be `Send + Sync + 'static` — so the trait must carry those +/// bounds. Mirrors the hand-written `trait Port: Send + Sync {}` a +/// `#[firefly(provides = "dyn Port")]` component relies on. +fn ensure_send_sync_supertraits(item: &mut ItemTrait) { + let has = |name: &str| { + item.supertraits.iter().any(|b| { + matches!(b, syn::TypeParamBound::Trait(t) + if t.path.segments.last().is_some_and(|s| s.ident == name)) + }) + }; + let has_send = has("Send"); + let has_sync = has("Sync"); + if !has_send { + item.supertraits + .push(syn::parse_quote!(::core::marker::Send)); + } + if !has_sync { + item.supertraits + .push(syn::parse_quote!(::core::marker::Sync)); + } +} + +/// Emits the DI bean registration: the `firefly_register` thunk (resolving a +/// shared `WebClient`), the `inventory` `ComponentRegistration`, and the +/// `dyn Trait` auto-bind — the same shape `derive_component` emits. +fn emit_di( + trait_ident: &syn::Ident, + impl_ident: &syn::Ident, + cfg: &ClientArgs, + facade: &Facade, +) -> syn::Result { + let container = facade.container(); + let client_rt = { + let rt = facade.rt(); + quote!(#rt::firefly_client) + }; + let bean_name = cfg.name.clone().unwrap_or_default(); + let type_name_lit = impl_ident.to_string(); + + // Resolve the shared WebClient: a named bean when `client = "..."` is set, + // else the primary `WebClient` bean. + let resolve_web = match cfg.client.as_deref().filter(|s| !s.is_empty()) { + Some(name) => { + let panic_msg = format!( + "#[http_client(bean)] registration of `{impl_ident}`: no `WebClient` bean named \ + `{name}` is registered. Cause: {{}}" + ); + quote! { + let __web = #container::Container::resolve_named::<#client_rt::WebClient>(__c, #name) + .unwrap_or_else(|__e| ::core::panic!(#panic_msg, __e)); + } + } + None => { + let panic_msg = format!( + "#[http_client(bean)] registration of `{impl_ident}`: no `WebClient` bean is \ + registered. Register one (e.g. \ + `container.register_instance(WebClientBuilder::new(url).build())`) before \ + `container.scan()`. Cause: {{}}" + ); + quote! { + let __web = #container::Container::resolve::<#client_rt::WebClient>(__c) + .unwrap_or_else(|__e| ::core::panic!(#panic_msg, __e)); + } + } + }; + + let register_doc = format!( + "Registers `{impl_ident}` (and binds `dyn {trait_ident}`) on the container, resolving a \ + shared `WebClient` bean. Generated by `#[http_client(bean)]`." + ); + + Ok(quote! { + impl #impl_ident { + #[doc = #register_doc] + pub fn firefly_register(__container: &#container::Container) { + __container.register_factory_with::<#impl_ident, _>( + #container::Scope::Singleton, + #bean_name, + false, + 0, + |__c: &#container::Container| { + #resolve_web + ::core::result::Result::Ok(#impl_ident::with_client((*__web).clone())) + }, + ); + __container.set_stereotype::<#impl_ident>("service"); + __container.bind::(|__impl_arc| __impl_arc); + } + } + + #container::inventory::submit! { + #container::ComponentRegistration { + type_name: #type_name_lit, + module_path: ::core::module_path!(), + bean_name: #bean_name, + stereotype: #container::BeanStereotype::Service, + scope: #container::Scope::Singleton, + primary: false, + order: 0, + lazy: false, + register: <#impl_ident>::firefly_register, + conditions: || ::std::vec![], + } + } + }) +} diff --git a/crates/macros/src/lib.rs b/crates/macros/src/lib.rs index cbda44bc..0ceb875b 100644 --- a/crates/macros/src/lib.rs +++ b/crates/macros/src/lib.rs @@ -38,6 +38,7 @@ //! | [`macro@scheduled`] | a zero-arg `async fn` | a `schedule_(scheduler)` helper | //! | [`macro@async_method`] | an `async fn(self: Arc, …) -> R` | a non-async `fn … -> firefly_scheduling::TaskHandle` that spawns the body | //! | [`macro@rest_controller`] | an `impl` block (`#[get]`/`#[post]`/… methods) | a `routes(state) -> axum::Router` | +//! | [`macro@http_client`] | a `trait` (`#[get]`/`#[post]`/… methods) | a `Impl` client over a `WebClient` (Spring's `@HttpExchange`) | //! | [`macro@DomainEvent`] / [`macro@AggregateRoot`] (derive) | a struct | event-type/aggregate ergonomics | //! | [`macro@event_listener`] | an `async fn(Event) -> FireflyResult<()>` | a `subscribe_(broker)` helper (EDA broker consumer) | //! | [`macro@application_event_listener`] | a free `async fn(&E)` | an in-process `@EventListener` (discovered via `inventory`, fired by `publish_event`) | @@ -67,6 +68,7 @@ mod entity; mod event_listener; mod eventsourcing; mod handlers; +mod http_client; mod mapper; mod method_security; mod orchestration; @@ -80,7 +82,7 @@ mod validate; mod web; use proc_macro::TokenStream; -use syn::{parse_macro_input, DeriveInput, ItemFn, ItemImpl}; +use syn::{parse_macro_input, DeriveInput, ItemFn, ItemImpl, ItemTrait}; use crate::container::{RegisterAllInput, Stereotype}; @@ -833,6 +835,120 @@ pub fn rest_controller(args: TokenStream, item: TokenStream) -> TokenStream { emit(web::rest_controller(args.into(), item)) } +/// Turns a `trait` into a declarative **HTTP-interface client** — Spring's +/// `@HttpExchange` (the `HttpServiceProxyFactory` / Feign-client experience). +/// +/// The trait describes the remote API; the macro emits it verbatim (markers +/// stripped) plus a concrete `Impl` struct that wraps a +/// [`WebClient`](firefly_client::WebClient) and implements the trait by +/// translating each method's verb attribute, path template, and bound arguments +/// into a fluent request. The host is **not** on the attribute (faithful to +/// Spring's relative `@HttpExchange(url)`); it comes from construction — +/// `Impl::new(base_url)` builds a `WebClient`, or +/// `Impl::with_client(web)` injects a tuned one. +/// +/// ## Methods +/// +/// Each method carries one verb attribute — `#[get("/:id")]` / `#[post]` / +/// `#[put]` / `#[delete]` / `#[patch]`, or the generic +/// `#[request(method = "HEAD", path = "/x")]` — using the **same** grammar and +/// the **same** axum-style `:name` path-variable spelling as +/// [`macro@rest_controller`], so a signature copies cleanly between server and +/// client. (The Spring `{id}` spelling is rejected with a pointer at `:id`.) +/// Every method takes `&self`. +/// +/// ## Argument binding (zero attrs in the common case) +/// +/// First match wins: an explicit arg attr > a path-name match > the body +/// default > the query default. +/// - `#[path]` / `#[path("id")]` substitutes the `:id` hole (percent-encoded, +/// via `Display`); an **unannotated** arg whose name equals a `:name` segment +/// binds to it automatically. Every `:name` must bind to exactly one argument. +/// - `#[query]` / `#[query("k")]` adds a query param (via `Display`); an +/// `Option<_>` omits `None`, a `Vec<_>` / `&[_]` repeats per element. An +/// unannotated query-scalar argument (integer/float/bool, `String`/`&str`, +/// `Uuid`, or `Option`/`Vec`/`&[_]` of those) defaults to a query param. +/// - `#[header("X-Tenant")]` sets a header (name required; `Option<_>` omits). +/// - `#[body]` sends the argument as a JSON body (via `Serialize`, `.body(&v)`); +/// on a body verb the lone unannotated non-scalar argument is the body by +/// default. At most one body. +/// +/// ## Return shapes +/// +/// - `async fn -> Result` (the ergonomic tier): +/// `.body_to_mono::().await`, folded `Some → Ok` / `None →` (`Ok(())` for +/// `()`, `Ok(vec![])` for `Vec`, `Ok(None)` for `Option`, else a synthesized +/// `CLIENT_EMPTY_BODY` problem) / `Err → ClientError::Problem`. +/// - `async fn -> Result` with `E: From` honors the custom +/// error via `.map_err(From::from)`. +/// - `fn -> Mono` / `fn -> Flux` (**non-async**, reactive-first) return the +/// `Mono` / `Flux` directly, no fold; a `Flux` defaults `Accept: +/// application/x-ndjson`. (A `Mono` / `Flux` on an `async fn` is a compile +/// error — it is already deferred.) +/// - `WebClientResponse` (as `Mono` or the `Result` form) is +/// the raw `.exchange()` escape hatch. +/// +/// ## Errors (fold fidelity) +/// +/// On the awaited `async fn -> Result` form, **every** failure +/// (transport, encode, decode, invalid URL, or an upstream error status) is +/// surfaced as `ClientError::Problem(FireflyError)`. The original status / problem +/// code is preserved, so `is_not_found()` / `is_server_error()` / `is_retryable()` +/// still classify correctly — but the structured `ClientError::Transport` / +/// `::Decode` / `::Encode` / `::InvalidUrl` variants are **not** reconstructed in +/// this form. Those structured variants are preserved only on the `Mono` / +/// `Flux` (non-awaited) return forms, which yield the raw `ClientError` +/// unchanged. (With a custom `E: From`, your `From` impl sees the +/// `Problem(..)` variant here.) +/// +/// ## DI (opt-in via `bean`) +/// +/// `#[http_client(bean)]` also registers `Impl` as a `@Service` and binds +/// `dyn Trait`, so `#[autowired] x: Arc` resolves. Registration pulls +/// a shared `WebClient` bean from the container (a named one with +/// `client = "..."`). The trait must be object-safe. +/// +/// ```ignore +/// use firefly::prelude::*; // #[http_client], ClientError, Mono, Flux +/// use serde::{Deserialize, Serialize}; +/// +/// #[derive(Serialize)] +/// pub struct CreateOrder { pub sku: String, pub qty: u32 } +/// #[derive(Deserialize)] +/// pub struct Order { pub id: String, pub sku: String } +/// +/// #[http_client(path = "/api/v1/orders", name = "orders", bean)] +/// pub trait OrdersClient { +/// #[get("/:id")] +/// async fn get_order(&self, id: String) -> Result; +/// #[get("/")] +/// async fn list(&self, status: String, page: Option) -> Result, ClientError>; +/// #[post("/")] +/// async fn create(&self, #[header("X-Tenant")] tenant: String, order: CreateOrder) +/// -> Result; +/// #[delete("/:id")] +/// async fn cancel(&self, id: String) -> Result<(), ClientError>; +/// #[get("/stream")] +/// fn stream(&self) -> Flux; +/// } +/// +/// # async fn demo() -> Result<(), ClientError> { +/// let api = OrdersClientImpl::new("https://orders.svc"); +/// let order = api.get_order("42".into()).await?; +/// # let _ = order; Ok(()) +/// # } +/// ``` +/// +/// Trait options: `path` (base path), `name` (DI bean name / qualifier), +/// `accept` / `content_type` (trait-wide default headers), `client` (a named +/// `WebClient` bean to share), `bean` (opt into DI registration), and `crate` +/// (facade override). +#[proc_macro_attribute] +pub fn http_client(args: TokenStream, item: TokenStream) -> TokenStream { + let item = parse_macro_input!(item as ItemTrait); + emit(http_client::http_client(args.into(), item)) +} + /// Registers a DI bean's CQRS / EDA / scheduled handler methods — the Rust /// analog of Spring scanning a `@Component`'s `@CommandHandler` / /// `@QueryHandler` / `@EventListener` / `@Scheduled` methods. diff --git a/crates/macros/src/web.rs b/crates/macros/src/web.rs index 9e1b73ef..1bd99ead 100644 --- a/crates/macros/src/web.rs +++ b/crates/macros/src/web.rs @@ -76,8 +76,8 @@ struct Mapping { /// or the rich `#[get("/x", summary = "...", description = "...", /// tags = ["A"], deprecated)]` form that feeds the OpenAPI generator. #[derive(Default)] -struct MappingAttr { - path: Option, +pub(crate) struct MappingAttr { + pub(crate) path: Option, summary: Option, description: Option, tags: Vec, @@ -88,7 +88,7 @@ struct MappingAttr { /// Success-response type name (`response = Foo` → `"Foo"`). response: Option, /// Success status code (`status = 202`). - status: Option, + pub(crate) status: Option, /// Explicitly-declared `header("X-Foo", required, description = "…")` params. params: Vec, } @@ -260,7 +260,7 @@ fn json_inner_schema(ty: &Type) -> Option { /// The `Ok` type of a `Result` / `WebResult` return type, or the type /// itself when it is not a result. -fn unwrap_result_ok(ty: &Type) -> &Type { +pub(crate) fn unwrap_result_ok(ty: &Type) -> &Type { let Type::Path(tp) = ty else { return ty; }; @@ -365,7 +365,7 @@ fn infer_response_schema(output: &ReturnType) -> Option { find_json_schema(unwrap_result_ok(ty)) } -const VERBS: &[&str] = &["get", "post", "put", "delete", "patch"]; +pub(crate) const VERBS: &[&str] = &["get", "post", "put", "delete", "patch"]; /// Expands `#[rest_controller(path = "...")]` on an `impl` block into the /// original impl plus a generated `fn routes(state) -> axum::Router`. @@ -632,7 +632,7 @@ fn parse_mapping_attr(attr: &syn::Attribute) -> syn::Result { /// Joins a controller base path with a method-relative path, normalising /// slashes so `("/api", "/x")`, `("/api", "x")` and `("/api/", "/x")` all /// yield `"/api/x"`, and an empty method path yields the base path. -fn join_path(base: &str, sub: &str) -> String { +pub(crate) fn join_path(base: &str, sub: &str) -> String { let base = base.trim_end_matches('/'); let sub = sub.trim(); if sub.is_empty() || sub == "/" { diff --git a/crates/macros/tests/http_client.rs b/crates/macros/tests/http_client.rs new file mode 100644 index 00000000..12de3ee5 --- /dev/null +++ b/crates/macros/tests/http_client.rs @@ -0,0 +1,370 @@ +// In-process axum round-trip for `#[http_client]`. +// +// The mirror-image parity test: a generated `Impl` client is pointed at a +// tiny axum server bound on an ephemeral port, and each return shape + binding is +// asserted end-to-end on the wire — path-var substituted & percent-encoded, +// query present / omitted for `Option`, header set, body serialized, `Vec` +// decoded, `()` on a 204, a 404 surfacing as `ClientError::Problem` whose +// `.is_not_found()` is true, and an NDJSON `Flux` collected via `.collect_list()`. +// +// This proves the deliberate `:id` client<->server convention lines up over HTTP, +// using the same firefly facade a real consumer compiles against. + +use std::net::SocketAddr; +use std::sync::Arc; + +use axum::extract::{Path, Query}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::routing::get; +use axum::{Json, Router}; +use firefly::prelude::*; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +#[derive(Clone, Serialize, Deserialize, PartialEq, Debug)] +struct Order { + id: String, + sku: String, +} + +#[derive(Serialize)] +struct CreateOrder { + sku: String, + qty: u32, +} + +// --------------------------------------------------------------------------- +// The client under test — exercises every binding + return shape. +// --------------------------------------------------------------------------- + +#[http_client(path = "/api/v1/orders")] +trait OrdersClient { + // Path var (name-matched), percent-encoded. + #[get("/:id")] + async fn get_order(&self, id: String) -> Result; + + // Query params: `status` always present, `page` (Option) omitted when None. + #[get("/")] + async fn list(&self, status: String, page: Option) -> Result, ClientError>; + + // Explicit header + JSON body. + #[post("/")] + async fn create( + &self, + #[header("X-Tenant")] tenant: String, + order: CreateOrder, + ) -> Result; + + // 204 -> unit. + #[delete("/:id")] + async fn cancel(&self, id: String) -> Result<(), ClientError>; + + // 404 -> ClientError::Problem. + #[get("/missing/:id")] + async fn get_missing(&self, id: String) -> Result; + + // 204/empty -> Ok(None) (the `Option` empty-body fold). + #[get("/opt/:id")] + async fn find_opt(&self, id: String) -> Result, ClientError>; + + // Custom error type via `E: From` — a 404 maps through map_err. + #[get("/missing/:id")] + async fn get_custom(&self, id: String) -> Result; + + // Reactive NDJSON stream. + #[get("/stream")] + fn stream(&self) -> Flux; +} + +// A bespoke error wrapping `ClientError`, proving the `map_err(From::from)` fold +// routes a failure through a user-defined error type. +#[derive(Debug)] +struct CustomError(ClientError); + +impl From for CustomError { + fn from(e: ClientError) -> Self { + CustomError(e) + } +} + +// --------------------------------------------------------------------------- +// The in-process server: handlers echo back what they received so the client's +// wire encoding is observable in the asserted response. +// --------------------------------------------------------------------------- + +async fn get_order(Path(id): Path) -> Json { + // The server sees the *decoded* path segment, so a percent-encoded `a/b` + // arrives back as `a/b` here — proving the client escaped it into one + // segment rather than injecting `/api/v1/orders/a/b`. + Json(Order { + id, + sku: "SKU-1".into(), + }) +} + +#[derive(Deserialize)] +struct ListQuery { + status: String, + page: Option, +} + +async fn list(Query(q): Query) -> Json> { + // Echo the received query into the response so the test can assert which + // params were sent. + Json(vec![Order { + id: format!("status={}", q.status), + sku: format!("page={:?}", q.page), + }]) +} + +async fn create(headers: HeaderMap, Json(body): Json) -> Json { + let tenant = headers + .get("X-Tenant") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + Json(Order { + id: tenant, + sku: body["sku"].as_str().unwrap_or_default().to_string(), + }) +} + +async fn cancel(Path(_id): Path) -> StatusCode { + StatusCode::NO_CONTENT +} + +// 204 / empty body — the client folds it to `Ok(None)` / `Ok(vec![])` per the +// success type. +async fn empty_body() -> StatusCode { + StatusCode::NO_CONTENT +} + +async fn get_missing(Path(_id): Path) -> Response { + // An RFC 7807 problem body, so the client decodes it into a FireflyError. + ( + StatusCode::NOT_FOUND, + [("content-type", "application/problem+json")], + Json(json!({ + "type": "ORDER_NOT_FOUND", + "title": "Not Found", + "status": 404, + "detail": "no such order", + })), + ) + .into_response() +} + +async fn stream() -> Response { + let body = "{\"id\":\"1\",\"sku\":\"A\"}\n{\"id\":\"2\",\"sku\":\"B\"}\n"; + ( + StatusCode::OK, + [("content-type", "application/x-ndjson")], + body, + ) + .into_response() +} + +/// Spawns the server on an ephemeral port and returns its base URL. +async fn spawn_server() -> String { + let app = Router::new() + .route("/api/v1/orders/stream", get(stream)) + .route("/api/v1/orders/missing/:id", get(get_missing)) + .route("/api/v1/orders/opt/:id", get(empty_body)) + .route("/api/v1/orders/:id", get(get_order).delete(cancel)) + // `#[get("/")]` over base `/api/v1/orders` joins to `/api/v1/orders` + // (the shared `join_path` trims the trailing slash), exactly as the + // `#[rest_controller]` server side does — so the route has no trailing + // slash here either. + .route("/api/v1/orders", get(list).post(create)); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind ephemeral port"); + let addr: SocketAddr = listener.local_addr().expect("local addr"); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("serve"); + }); + format!("http://{addr}") +} + +#[tokio::test] +async fn round_trip_get_with_encoded_path_var() { + let base = spawn_server().await; + let api = OrdersClientImpl::new(base); + + // A plain id round-trips. + let order = api.get_order("42".into()).await.expect("get_order"); + assert_eq!(order.id, "42"); + assert_eq!(order.sku, "SKU-1"); + + // A slash in the id is percent-encoded into a single segment (decoded back + // to `a/b` server-side), not injected as an extra path segment. + let order = api.get_order("a/b".into()).await.expect("get encoded"); + assert_eq!(order.id, "a/b"); +} + +#[tokio::test] +async fn round_trip_query_present_and_omitted() { + let base = spawn_server().await; + let api = OrdersClientImpl::new(base); + + // `page = Some` -> both query params present. + let with_page = api + .list("open".into(), Some(2)) + .await + .expect("list with page"); + assert_eq!(with_page.len(), 1); + assert_eq!(with_page[0].id, "status=open"); + assert_eq!(with_page[0].sku, "page=Some(2)"); + + // `page = None` -> the `page` query param is omitted entirely. + let without_page = api.list("open".into(), None).await.expect("list no page"); + assert_eq!(without_page[0].sku, "page=None"); +} + +#[tokio::test] +async fn round_trip_header_and_body() { + let base = spawn_server().await; + let api = OrdersClientImpl::new(base); + + let created = api + .create( + "acme".into(), + CreateOrder { + sku: "SKU-9".into(), + qty: 3, + }, + ) + .await + .expect("create"); + // The header was sent (echoed into `id`) and the body serialized (echoed + // into `sku`). + assert_eq!(created.id, "acme"); + assert_eq!(created.sku, "SKU-9"); +} + +#[tokio::test] +async fn round_trip_unit_on_204() { + let base = spawn_server().await; + let api = OrdersClientImpl::new(base); + // A 204 No Content folds to `Ok(())`. + api.cancel("42".into()).await.expect("cancel"); +} + +#[tokio::test] +async fn round_trip_404_is_not_found_problem() { + let base = spawn_server().await; + let api = OrdersClientImpl::new(base); + + let err = api + .get_missing("99".into()) + .await + .expect_err("expected a 404"); + match &err { + ClientError::Problem(fe) => { + assert_eq!(fe.status, 404); + assert_eq!(fe.code, "ORDER_NOT_FOUND"); + } + other => panic!("expected ClientError::Problem, got {other:?}"), + } + assert!(err.is_not_found(), "404 should classify as not-found"); +} + +#[tokio::test] +async fn round_trip_empty_body_folds_to_option_none() { + let base = spawn_server().await; + let api = OrdersClientImpl::new(base); + // A 204 / empty body on a `Result, _>` method folds to `Ok(None)`. + let found = api.find_opt("42".into()).await.expect("find_opt ok"); + assert_eq!(found, None); +} + +// NOTE: the `Result, _>` empty-body fold (`Ok(vec![])`) is intentionally +// *not* covered by a wire test. The `WebClient` decodes an empty 2xx body as JSON +// `null` (so an empty `Option` body deserializes straight to `None`, asserted +// above), but `null` is not a valid sequence, so `body_to_mono::>` fails +// with `CLIENT_DECODE` before the macro's `Ok(vec![])` empty arm can run. That +// fold arm only fires when the `Mono` completes *truly* empty (`Ok(None)`), which +// `body_to_mono` never does — it always decodes a value. Exercising it would +// require a server returning a literal `[]`, which is not an empty body. The +// macro's `VecEmpty` arm is still unit-covered by the trybuild/expansion corpus. + +#[tokio::test] +async fn round_trip_custom_error_maps_through_map_err() { + let base = spawn_server().await; + let api = OrdersClientImpl::new(base); + // A 404 on a `Result` method routes through + // `map_err(>::from)`, so the failure arrives + // as the user's error type wrapping the classified `ClientError::Problem`. + let err = api + .get_custom("99".into()) + .await + .expect_err("expected a 404 mapped to CustomError"); + let CustomError(inner) = err; + match &inner { + ClientError::Problem(fe) => assert_eq!(fe.status, 404), + other => panic!("expected ClientError::Problem, got {other:?}"), + } + assert!(inner.is_not_found(), "404 should classify as not-found"); +} + +#[tokio::test] +async fn round_trip_flux_ndjson_stream() { + let base = spawn_server().await; + let api = OrdersClientImpl::new(base); + + let orders = api + .stream() + .collect_list() + .block() + .await + .expect("stream ok") + .expect("non-empty list"); + assert_eq!( + orders, + vec![ + Order { + id: "1".into(), + sku: "A".into() + }, + Order { + id: "2".into(), + sku: "B".into() + }, + ] + ); +} + +// --------------------------------------------------------------------------- +// DI: the `bean` flag binds `dyn Trait`, so the trait object resolves from the +// container after a shared `WebClient` bean is registered. Proves the autowire +// thunk wires the trait object end-to-end. +// --------------------------------------------------------------------------- + +#[http_client(path = "/api/v1/orders", bean)] +trait OrdersBeanClient { + #[get("/:id")] + async fn get_order(&self, id: String) -> Result; +} + +#[tokio::test] +async fn di_resolves_trait_object_through_bean_bind() { + let base = spawn_server().await; + + let container = Container::new(); + // A `WebClient` bean pointed at the in-process server — the dependency the + // generated `firefly_register` thunk resolves. + container.register_instance(firefly::client::new_web_client(base).build()); + // `scan()` discovers the `#[http_client(bean)]` inventory thunk and runs its + // registrar (binding `dyn OrdersBeanClient`). + container.scan(); + + // The trait object resolves via the `bean`/bind seam ... + let client: Arc = + Container::resolve::(&container).expect("resolve dyn"); + // ... and a call through it round-trips over HTTP. + let order = client.get_order("7".into()).await.expect("get via dyn"); + assert_eq!(order.id, "7"); + assert_eq!(order.sku, "SKU-1"); +} diff --git a/crates/macros/tests/trybuild.rs b/crates/macros/tests/trybuild.rs index 280f256a..d4e2e447 100644 --- a/crates/macros/tests/trybuild.rs +++ b/crates/macros/tests/trybuild.rs @@ -21,4 +21,9 @@ fn ui() { let t = trybuild::TestCases::new(); t.pass("tests/ui/pass/*.rs"); t.compile_fail("tests/ui/fail/*.rs"); + // The `#[http_client]` macro keeps its own pass/fail corpus so each return + // shape and binding rule (and each locked diagnostic) is exercised in + // isolation from the rest of the macro suite. + t.pass("tests/ui/http_client/pass/*.rs"); + t.compile_fail("tests/ui/http_client/fail/*.rs"); } diff --git a/crates/macros/tests/ui/http_client/fail/ambiguous_body.rs b/crates/macros/tests/ui/http_client/fail/ambiguous_body.rs new file mode 100644 index 00000000..85ab581a --- /dev/null +++ b/crates/macros/tests/ui/http_client/fail/ambiguous_body.rs @@ -0,0 +1,17 @@ +// Two body-eligible (non-scalar) arguments with no `#[body]` are ambiguous. + +use firefly::prelude::*; +use serde::Serialize; + +#[derive(Serialize)] +struct A; +#[derive(Serialize)] +struct B; + +#[http_client] +trait Api { + #[post("/x")] + async fn create(&self, a: A, b: B) -> Result<(), ClientError>; +} + +fn main() {} diff --git a/crates/macros/tests/ui/http_client/fail/ambiguous_body.stderr b/crates/macros/tests/ui/http_client/fail/ambiguous_body.stderr new file mode 100644 index 00000000..23034e3b --- /dev/null +++ b/crates/macros/tests/ui/http_client/fail/ambiguous_body.stderr @@ -0,0 +1,5 @@ +error: ambiguous body; more than one argument could be the request body — annotate exactly one with #[body] + --> tests/ui/http_client/fail/ambiguous_body.rs:14:5 + | +14 | async fn create(&self, a: A, b: B) -> Result<(), ClientError>; + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/crates/macros/tests/ui/http_client/fail/async_mono.rs b/crates/macros/tests/ui/http_client/fail/async_mono.rs new file mode 100644 index 00000000..8a0f2c22 --- /dev/null +++ b/crates/macros/tests/ui/http_client/fail/async_mono.rs @@ -0,0 +1,16 @@ +// A `Mono`-returning method must NOT be `async fn` (the Mono is already +// deferred). + +use firefly::prelude::*; +use serde::Deserialize; + +#[derive(Deserialize)] +struct Order; + +#[http_client] +trait Api { + #[get("/x")] + async fn get(&self) -> Mono; +} + +fn main() {} diff --git a/crates/macros/tests/ui/http_client/fail/async_mono.stderr b/crates/macros/tests/ui/http_client/fail/async_mono.stderr new file mode 100644 index 00000000..3c8c402a --- /dev/null +++ b/crates/macros/tests/ui/http_client/fail/async_mono.stderr @@ -0,0 +1,5 @@ +error: a Mono-returning method must not be `async fn`; the Mono is already deferred — drop `async` (or return `Result<_, ClientError>` for the awaited form) + --> tests/ui/http_client/fail/async_mono.rs:13:5 + | +13 | async fn get(&self) -> Mono; + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/crates/macros/tests/ui/http_client/fail/bad_return.rs b/crates/macros/tests/ui/http_client/fail/bad_return.rs new file mode 100644 index 00000000..dbb1fdd0 --- /dev/null +++ b/crates/macros/tests/ui/http_client/fail/bad_return.rs @@ -0,0 +1,12 @@ +// A return type that is neither `Result` nor `Mono`/`Flux` must be rejected with +// a pointer at the supported shapes. + +use firefly::prelude::*; + +#[http_client] +trait Api { + #[get("/x")] + async fn get(&self) -> String; +} + +fn main() {} diff --git a/crates/macros/tests/ui/http_client/fail/bad_return.stderr b/crates/macros/tests/ui/http_client/fail/bad_return.stderr new file mode 100644 index 00000000..e347517b --- /dev/null +++ b/crates/macros/tests/ui/http_client/fail/bad_return.stderr @@ -0,0 +1,5 @@ +error: unsupported #[http_client] return type; use `async fn -> Result` (or a custom `E: From`), or a non-async `fn -> Mono` / `Flux` + --> tests/ui/http_client/fail/bad_return.rs:9:5 + | +9 | async fn get(&self) -> String; + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/crates/macros/tests/ui/http_client/fail/bean_assoc_type.rs b/crates/macros/tests/ui/http_client/fail/bean_assoc_type.rs new file mode 100644 index 00000000..90740b06 --- /dev/null +++ b/crates/macros/tests/ui/http_client/fail/bean_assoc_type.rs @@ -0,0 +1,15 @@ +// An associated type makes the trait not `dyn`-compatible, so the `bean` flag +// (which binds `dyn Trait`) rejects it up front rather than failing downstream +// with a cryptic `dyn Trait` error. + +use firefly::prelude::*; + +#[http_client(bean)] +trait Api { + type Output; + + #[get("/x")] + async fn get(&self) -> Result<(), ClientError>; +} + +fn main() {} diff --git a/crates/macros/tests/ui/http_client/fail/bean_assoc_type.stderr b/crates/macros/tests/ui/http_client/fail/bean_assoc_type.stderr new file mode 100644 index 00000000..d86319a4 --- /dev/null +++ b/crates/macros/tests/ui/http_client/fail/bean_assoc_type.stderr @@ -0,0 +1,5 @@ +error: #[http_client(bean)] requires an object-safe trait, but associated type `Output` makes it not `dyn`-compatible; remove it or drop `bean` + --> tests/ui/http_client/fail/bean_assoc_type.rs:9:5 + | +9 | type Output; + | ^^^^^^^^^^^^ diff --git a/crates/macros/tests/ui/http_client/fail/conflicting_arg_attrs.rs b/crates/macros/tests/ui/http_client/fail/conflicting_arg_attrs.rs new file mode 100644 index 00000000..9dfa5b15 --- /dev/null +++ b/crates/macros/tests/ui/http_client/fail/conflicting_arg_attrs.rs @@ -0,0 +1,11 @@ +// Two binding attributes on one argument (`#[path]` + `#[query]`) conflict. + +use firefly::prelude::*; + +#[http_client] +trait Api { + #[get("/:id")] + async fn get(&self, #[path] #[query] id: String) -> Result<(), ClientError>; +} + +fn main() {} diff --git a/crates/macros/tests/ui/http_client/fail/conflicting_arg_attrs.stderr b/crates/macros/tests/ui/http_client/fail/conflicting_arg_attrs.stderr new file mode 100644 index 00000000..523cdf80 --- /dev/null +++ b/crates/macros/tests/ui/http_client/fail/conflicting_arg_attrs.stderr @@ -0,0 +1,5 @@ +error: argument `id` carries conflicting binding attributes; use exactly one of #[path], #[query], #[header], #[body] + --> tests/ui/http_client/fail/conflicting_arg_attrs.rs:8:33 + | +8 | async fn get(&self, #[path] #[query] id: String) -> Result<(), ClientError>; + | ^^^^^^^^ diff --git a/crates/macros/tests/ui/http_client/fail/double_body.rs b/crates/macros/tests/ui/http_client/fail/double_body.rs new file mode 100644 index 00000000..f292b654 --- /dev/null +++ b/crates/macros/tests/ui/http_client/fail/double_body.rs @@ -0,0 +1,17 @@ +// Two `#[body]` arguments must be a compile error. + +use firefly::prelude::*; +use serde::Serialize; + +#[derive(Serialize)] +struct A; +#[derive(Serialize)] +struct B; + +#[http_client] +trait Api { + #[post("/x")] + async fn create(&self, #[body] a: A, #[body] b: B) -> Result<(), ClientError>; +} + +fn main() {} diff --git a/crates/macros/tests/ui/http_client/fail/double_body.stderr b/crates/macros/tests/ui/http_client/fail/double_body.stderr new file mode 100644 index 00000000..da5a9742 --- /dev/null +++ b/crates/macros/tests/ui/http_client/fail/double_body.stderr @@ -0,0 +1,5 @@ +error: an #[http_client] method may carry at most one #[body] argument + --> tests/ui/http_client/fail/double_body.rs:14:5 + | +14 | async fn create(&self, #[body] a: A, #[body] b: B) -> Result<(), ClientError>; + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/crates/macros/tests/ui/http_client/fail/generic_method_bean.rs b/crates/macros/tests/ui/http_client/fail/generic_method_bean.rs new file mode 100644 index 00000000..7bdf5fe1 --- /dev/null +++ b/crates/macros/tests/ui/http_client/fail/generic_method_bean.rs @@ -0,0 +1,13 @@ +// A generic method under `bean` cannot satisfy the `dyn Trait` autowire bind, so +// the macro rejects it up front. + +use firefly::prelude::*; +use serde::Serialize; + +#[http_client(bean)] +trait Api { + #[post("/x")] + async fn create(&self, #[body] body: T) -> Result<(), ClientError>; +} + +fn main() {} diff --git a/crates/macros/tests/ui/http_client/fail/generic_method_bean.stderr b/crates/macros/tests/ui/http_client/fail/generic_method_bean.stderr new file mode 100644 index 00000000..b322a826 --- /dev/null +++ b/crates/macros/tests/ui/http_client/fail/generic_method_bean.stderr @@ -0,0 +1,13 @@ +error: #[http_client(bean)] requires an object-safe trait, but method `create` is generic; remove the generic parameters or drop `bean` + --> tests/ui/http_client/fail/generic_method_bean.rs:10:5 + | +10 | async fn create(&self, #[body] body: T) -> Result<(), ClientError>; + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +warning: unused import: `serde::Serialize` + --> tests/ui/http_client/fail/generic_method_bean.rs:5:5 + | +5 | use serde::Serialize; + | ^^^^^^^^^^^^^^^^ + | + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/crates/macros/tests/ui/http_client/fail/missing_self.rs b/crates/macros/tests/ui/http_client/fail/missing_self.rs new file mode 100644 index 00000000..44f20941 --- /dev/null +++ b/crates/macros/tests/ui/http_client/fail/missing_self.rs @@ -0,0 +1,11 @@ +// A method without a `&self` receiver must be a compile error. + +use firefly::prelude::*; + +#[http_client] +trait Api { + #[get("/x")] + async fn get(id: String) -> Result<(), ClientError>; +} + +fn main() {} diff --git a/crates/macros/tests/ui/http_client/fail/missing_self.stderr b/crates/macros/tests/ui/http_client/fail/missing_self.stderr new file mode 100644 index 00000000..5fece32a --- /dev/null +++ b/crates/macros/tests/ui/http_client/fail/missing_self.stderr @@ -0,0 +1,5 @@ +error: an #[http_client] method must take `&self` + --> tests/ui/http_client/fail/missing_self.rs:8:5 + | +8 | async fn get(id: String) -> Result<(), ClientError>; + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/crates/macros/tests/ui/http_client/fail/no_verb.rs b/crates/macros/tests/ui/http_client/fail/no_verb.rs new file mode 100644 index 00000000..622ca3f1 --- /dev/null +++ b/crates/macros/tests/ui/http_client/fail/no_verb.rs @@ -0,0 +1,10 @@ +// A method with no verb attribute must be a compile error. + +use firefly::prelude::*; + +#[http_client] +trait Api { + async fn get(&self) -> Result<(), ClientError>; +} + +fn main() {} diff --git a/crates/macros/tests/ui/http_client/fail/no_verb.stderr b/crates/macros/tests/ui/http_client/fail/no_verb.stderr new file mode 100644 index 00000000..6e53bf67 --- /dev/null +++ b/crates/macros/tests/ui/http_client/fail/no_verb.stderr @@ -0,0 +1,5 @@ +error: an #[http_client] method must carry a verb attribute: #[get]/#[post]/#[put]/#[delete]/#[patch] or #[request(method = "...")] + --> tests/ui/http_client/fail/no_verb.rs:7:5 + | +7 | async fn get(&self) -> Result<(), ClientError>; + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/crates/macros/tests/ui/http_client/fail/non_async_result.rs b/crates/macros/tests/ui/http_client/fail/non_async_result.rs new file mode 100644 index 00000000..6099e78e --- /dev/null +++ b/crates/macros/tests/ui/http_client/fail/non_async_result.rs @@ -0,0 +1,11 @@ +// A `Result`-returning method must be `async fn` (it is awaited). + +use firefly::prelude::*; + +#[http_client] +trait Api { + #[get("/x")] + fn get(&self) -> Result<(), ClientError>; +} + +fn main() {} diff --git a/crates/macros/tests/ui/http_client/fail/non_async_result.stderr b/crates/macros/tests/ui/http_client/fail/non_async_result.stderr new file mode 100644 index 00000000..264734e2 --- /dev/null +++ b/crates/macros/tests/ui/http_client/fail/non_async_result.stderr @@ -0,0 +1,5 @@ +error: a Result-returning #[http_client] method must be `async fn` (it is awaited); for the deferred form return `Mono` / `Flux` from a non-async fn + --> tests/ui/http_client/fail/non_async_result.rs:8:5 + | +8 | fn get(&self) -> Result<(), ClientError>; + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/crates/macros/tests/ui/http_client/fail/option_path_var.rs b/crates/macros/tests/ui/http_client/fail/option_path_var.rs new file mode 100644 index 00000000..48262bb2 --- /dev/null +++ b/crates/macros/tests/ui/http_client/fail/option_path_var.rs @@ -0,0 +1,13 @@ +// An `Option<_>` (or `Vec<_>` / slice) bound as a `:name` path variable would +// render a garbage URI (e.g. `…/Some(x)` or `…/None`), so it is rejected at +// macro-expansion time with a clean diagnostic. + +use firefly::prelude::*; + +#[http_client] +trait Api { + #[get("/:id")] + async fn get(&self, id: Option) -> Result<(), ClientError>; +} + +fn main() {} diff --git a/crates/macros/tests/ui/http_client/fail/option_path_var.stderr b/crates/macros/tests/ui/http_client/fail/option_path_var.stderr new file mode 100644 index 00000000..bbca470b --- /dev/null +++ b/crates/macros/tests/ui/http_client/fail/option_path_var.stderr @@ -0,0 +1,5 @@ +error: #[http_client] path variable `:id` must be a Display scalar, not Option/Vec/slice — got `Option` + --> tests/ui/http_client/fail/option_path_var.rs:10:25 + | +10 | async fn get(&self, id: Option) -> Result<(), ClientError>; + | ^^ diff --git a/crates/macros/tests/ui/http_client/fail/request_missing_method.rs b/crates/macros/tests/ui/http_client/fail/request_missing_method.rs new file mode 100644 index 00000000..01b7b4bd --- /dev/null +++ b/crates/macros/tests/ui/http_client/fail/request_missing_method.rs @@ -0,0 +1,12 @@ +// `#[request(...)]` requires an explicit `method = "..."`; omitting it is a clean +// compile error rather than a defaulted verb. + +use firefly::prelude::*; + +#[http_client] +trait Api { + #[request(path = "/x")] + async fn call(&self) -> Result<(), ClientError>; +} + +fn main() {} diff --git a/crates/macros/tests/ui/http_client/fail/request_missing_method.stderr b/crates/macros/tests/ui/http_client/fail/request_missing_method.stderr new file mode 100644 index 00000000..27800079 --- /dev/null +++ b/crates/macros/tests/ui/http_client/fail/request_missing_method.stderr @@ -0,0 +1,5 @@ +error: #[request(...)] requires `method = ""` (e.g. #[request(method = "HEAD", path = "/x")]) + --> tests/ui/http_client/fail/request_missing_method.rs:8:5 + | +8 | #[request(path = "/x")] + | ^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/crates/macros/tests/ui/http_client/fail/spring_path_syntax.rs b/crates/macros/tests/ui/http_client/fail/spring_path_syntax.rs new file mode 100644 index 00000000..79d06551 --- /dev/null +++ b/crates/macros/tests/ui/http_client/fail/spring_path_syntax.rs @@ -0,0 +1,12 @@ +// The Spring `{id}` path-variable spelling must be rejected with a pointer at +// the axum-style `:id` convention. + +use firefly::prelude::*; + +#[http_client] +trait Api { + #[get("/{id}")] + async fn get(&self, id: String) -> Result<(), ClientError>; +} + +fn main() {} diff --git a/crates/macros/tests/ui/http_client/fail/spring_path_syntax.stderr b/crates/macros/tests/ui/http_client/fail/spring_path_syntax.stderr new file mode 100644 index 00000000..5c9756bc --- /dev/null +++ b/crates/macros/tests/ui/http_client/fail/spring_path_syntax.stderr @@ -0,0 +1,5 @@ +error: #[http_client] path "/{id}" uses Spring `{name}` syntax; firefly uses the axum-style `:name` path-variable spelling (e.g. `/orders/:id`) + --> tests/ui/http_client/fail/spring_path_syntax.rs:9:5 + | +9 | async fn get(&self, id: String) -> Result<(), ClientError>; + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/crates/macros/tests/ui/http_client/fail/two_verbs.rs b/crates/macros/tests/ui/http_client/fail/two_verbs.rs new file mode 100644 index 00000000..4517e9de --- /dev/null +++ b/crates/macros/tests/ui/http_client/fail/two_verbs.rs @@ -0,0 +1,12 @@ +// Two verb attributes on one method must be a compile error. + +use firefly::prelude::*; + +#[http_client] +trait Api { + #[get("/a")] + #[post("/b")] + async fn both(&self) -> Result<(), ClientError>; +} + +fn main() {} diff --git a/crates/macros/tests/ui/http_client/fail/two_verbs.stderr b/crates/macros/tests/ui/http_client/fail/two_verbs.stderr new file mode 100644 index 00000000..d4c4ed09 --- /dev/null +++ b/crates/macros/tests/ui/http_client/fail/two_verbs.stderr @@ -0,0 +1,5 @@ +error: an #[http_client] method may carry at most one HTTP-verb mapping + --> tests/ui/http_client/fail/two_verbs.rs:8:5 + | +8 | #[post("/b")] + | ^^^^^^^^^^^^^ diff --git a/crates/macros/tests/ui/http_client/fail/unbound_path_var.rs b/crates/macros/tests/ui/http_client/fail/unbound_path_var.rs new file mode 100644 index 00000000..3a6fc874 --- /dev/null +++ b/crates/macros/tests/ui/http_client/fail/unbound_path_var.rs @@ -0,0 +1,11 @@ +// A `:name` path variable with no matching argument must be a compile error. + +use firefly::prelude::*; + +#[http_client] +trait Api { + #[get("/:id")] + async fn get(&self, other: String) -> Result<(), ClientError>; +} + +fn main() {} diff --git a/crates/macros/tests/ui/http_client/fail/unbound_path_var.stderr b/crates/macros/tests/ui/http_client/fail/unbound_path_var.stderr new file mode 100644 index 00000000..6c8a2e8c --- /dev/null +++ b/crates/macros/tests/ui/http_client/fail/unbound_path_var.stderr @@ -0,0 +1,5 @@ +error: the path variable `:id` is not bound to any argument; add an argument named `id` or annotate one with #[path("id")] + --> tests/ui/http_client/fail/unbound_path_var.rs:8:5 + | +8 | async fn get(&self, other: String) -> Result<(), ClientError>; + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/crates/macros/tests/ui/http_client/pass/shapes.rs b/crates/macros/tests/ui/http_client/pass/shapes.rs new file mode 100644 index 00000000..743b367b --- /dev/null +++ b/crates/macros/tests/ui/http_client/pass/shapes.rs @@ -0,0 +1,105 @@ +// A single compile-pass case exercising every `#[http_client]` return shape and +// every binding rule against the real facade. If any expansion stops compiling, +// trybuild reports it here. + +use std::sync::Arc; + +use firefly::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize)] +pub struct CreateOrder { + pub sku: String, + pub qty: u32, +} + +#[derive(Serialize, Deserialize)] +pub struct Order { + pub id: String, + pub sku: String, +} + +// A custom error that converts from `ClientError`, for the `Result` shape. +#[derive(Debug)] +pub struct ApiError(String); + +impl From for ApiError { + fn from(e: ClientError) -> Self { + ApiError(e.to_string()) + } +} + +#[http_client(path = "/api/v1/orders", name = "orders", accept = "application/json", bean)] +pub trait OrdersClient { + // `:id` name-matches the `id` arg -> path var. + #[get("/:id")] + async fn get_order(&self, id: String) -> Result; + + // Inferred query params; `Option` omits when `None`. + #[get("/")] + async fn list(&self, status: String, page: Option) -> Result, ClientError>; + + // Lone non-scalar arg -> JSON body; one explicit header. + #[post("/")] + async fn create( + &self, + #[header("X-Tenant")] tenant: String, + order: CreateOrder, + ) -> Result; + + // 204 / empty body -> unit. + #[delete("/:id")] + async fn cancel(&self, id: String) -> Result<(), ClientError>; + + // Option success type. + #[get("/:id/maybe")] + async fn maybe(&self, id: String) -> Result, ClientError>; + + // Explicit binding attributes + repeated query (Vec) + optional header. + #[put("/:order_id")] + async fn replace( + &self, + #[path("order_id")] order_id: String, + #[query("tag")] tags: Vec, + #[header("X-Trace")] trace: Option, + #[body] order: CreateOrder, + ) -> Result; + + // Custom error type (E: From). + #[get("/:id/strict")] + async fn strict(&self, id: String) -> Result; + + // The raw exchange escape hatch via the Result form. + #[get("/:id/raw")] + async fn raw(&self, id: String) -> Result; + + // Reactive-first: non-async Mono / Flux. + #[get("/:id")] + fn get_order_mono(&self, id: String) -> Mono; + + #[get("/stream")] + fn stream(&self) -> Flux; + + // Generic verb form. + #[request(method = "HEAD", path = "/:id")] + async fn head(&self, id: String) -> Result<(), ClientError>; +} + +fn main() { + // Construction surfaces exist and the struct is named `Impl`. + let api = OrdersClientImpl::new("https://orders.svc"); + let _clone = api.clone(); + let web = firefly::client::new_web_client("https://orders.svc").build(); + let _injected = OrdersClientImpl::with_client(web); + + // The DI registrar exists (from `bean`). + let _reg: fn(&Container) = OrdersClientImpl::firefly_register; + + // The trait object resolves through `dyn` (object-safe). + fn _autowire(_: Arc) {} + + // Reference the async + reactive methods so they are type-checked. + let _f = OrdersClientImpl::get_order; + let _m = OrdersClientImpl::get_order_mono; + let _s = OrdersClientImpl::stream; +} diff --git a/docs/book/src/13-http-clients.md b/docs/book/src/13-http-clients.md index 9e887134..463b425b 100644 --- a/docs/book/src/13-http-clients.md +++ b/docs/book/src/13-http-clients.md @@ -23,6 +23,9 @@ RFC 7807 problem decode into a typed `FireflyError`: - the **reactive `WebClient`** — whose terminal operators hand back `Mono` / `Flux`, so an outbound call drops straight into a reactive pipeline. +On top of the `WebClient` sits the **declarative `#[http_client]`** trait — the +Spring `@HttpExchange` analog — covered [below](#the-declarative-client-http_client). + The crate also ships scaffolds for SOAP, gRPC, GraphQL, and WebSocket clients. Everything is reachable through the one `firefly` facade as `firefly::client`. @@ -219,6 +222,87 @@ if resp.is_success() { > ); > ``` +## The declarative client (`#[http_client]`) + +Writing the call chain by hand is fine for one-off requests, but a *service you +call repeatedly* deserves a typed interface. `#[http_client]` is the analog of +Spring 6's `@HttpExchange` (the modern OpenFeign replacement): you write a +**trait** of methods carrying the same verb attributes a `#[rest_controller]` +uses, and the macro generates a `Impl` that issues the requests over a +`WebClient`. It is the mirror image of a controller — same vocabulary, request +issued instead of received. + +```rust,ignore +use firefly::prelude::*; // #[http_client], ClientError, Mono, Flux +use serde::{Deserialize, Serialize}; + +#[derive(Serialize)] +pub struct CreateOrder { pub sku: String, pub qty: u32 } +#[derive(Deserialize)] +pub struct Order { pub id: String, pub sku: String } + +#[http_client(path = "/api/v1/orders", name = "orders", bean)] +pub trait OrdersClient { + // `:id` name-matches the `id` arg → path variable (percent-encoded). + #[get("/:id")] + async fn get_order(&self, id: String) -> Result; + + // `status` / `page` are neither path vars nor a body → inferred query + // params; `Option` omits itself when `None`. + #[get("/")] + async fn list(&self, status: String, page: Option) -> Result, ClientError>; + + // the lone non-scalar arg is the JSON body; one explicit header. + #[post("/")] + async fn create(&self, #[header("X-Tenant")] tenant: String, order: CreateOrder) + -> Result; + + #[delete("/:id")] + async fn cancel(&self, id: String) -> Result<(), ClientError>; // 204 → () + + // reactive-first: a non-async fn returning Mono/Flux, no bridging. + #[get("/stream")] + fn stream(&self) -> Flux; +} +``` + +Construct it from a base URL, or inject a tuned `WebClient`: + +```rust,ignore +let api = OrdersClientImpl::new("https://orders.svc"); // builds a WebClient +let order = api.get_order("42".into()).await?; +// or: OrdersClientImpl::with_client(my_web_client) // shared pool / timeouts +``` + +**Path syntax is the framework's `:id`** (the same as `#[rest_controller]`), not +Spring's `{id}` — so a controller and its mirror-image client read identically, +and writing `{id}` is a compile error pointing you at `:id`. **Argument binding** +needs no attributes in the common case: an unannotated arg whose name matches a +`:var` segment is the path variable, the lone unannotated non-scalar arg on a +`POST`/`PUT`/`PATCH` is the JSON body, and everything else is a query param. +Override with `#[path]` / `#[query("k")]` / `#[header("X")]` / `#[body]`. Every +`:var` must bind to exactly one argument or the macro refuses to compile, so a +rename surfaces loudly instead of silently dropping the value. + +**Return shapes:** an `async fn` returning `Result` is the +ergonomic default; `Result` works for any `E: From`; a +*non-async* `fn` returning `Mono` / `Flux` hands back the deferred reactive +value directly (a `Flux` defaults to `Accept: application/x-ndjson`); and +`WebClientResponse` is the raw `.exchange()` escape hatch. + +> **Error fidelity.** On an awaited `Result` method every failure +> arrives as `ClientError::Problem` carrying a `FireflyError` with the original +> status and code — so `is_not_found()` / `is_server_error()` / `is_retryable()` +> still classify correctly — rather than the structured `Transport` / `Decode` / +> `Encode` variants, which survive only on the `Mono` / `Flux` return forms +> (where the `FireflyError` terminal *is* the reactive error channel). Match on +> the reactive form when you need byte-exact variants. + +With `#[http_client(... bean)]` the generated `OrdersClientImpl` is registered as +a `@Service` and bound to `dyn OrdersClient`, so a collaborator just +`#[autowired] orders: Arc` — the Feign-client autowire payoff, +resolving a shared `WebClient` bean (a named one with `client = "…"`). + ## Composing with resilience Both clients are deliberately small. For circuit breaking, rate limiting, or