From 705af8d812d77fe2eb74f547ebbef0eb42e339fc Mon Sep 17 00:00:00 2001 From: Hansheng Zhao Date: Thu, 30 Apr 2026 16:36:29 -0500 Subject: [PATCH 1/2] refactor(ads-client): relocated mars staging url unit test. --- components/ads-client/src/mars/environment.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/components/ads-client/src/mars/environment.rs b/components/ads-client/src/mars/environment.rs index 53833e36b8..5075c223d5 100644 --- a/components/ads-client/src/mars/environment.rs +++ b/components/ads-client/src/mars/environment.rs @@ -59,4 +59,15 @@ mod tests { assert_eq!(url.host(), Some(Host::Domain("ads.mozilla.org"))); assert_eq!(url.path(), "/v1/ads"); } + + #[test] + fn staging_endpoint_parses_and_is_expected() { + let url = Environment::Staging.into_url("ads"); + + assert_eq!(url.as_str(), "https://ads.allizom.org/v1/ads"); + + assert_eq!(url.scheme(), "https"); + assert_eq!(url.host(), Some(Host::Domain("ads.allizom.org"))); + assert_eq!(url.path(), "/v1/ads"); + } } From ab08c6b816beab7c2eb062690089ee01eefb471c Mon Sep 17 00:00:00 2001 From: Hansheng Zhao Date: Fri, 1 May 2026 12:29:20 -0500 Subject: [PATCH 2/2] feat(ads-client): added url-macro component to verify URL validity at build time. --- Cargo.lock | 20 ++++ Cargo.toml | 4 + components/ads-client/Cargo.toml | 1 + components/ads-client/src/mars/environment.rs | 7 +- components/support/url-macro/Cargo.toml | 16 +++ components/support/url-macro/README.md | 111 ++++++++++++++++++ components/support/url-macro/src/lib.rs | 58 +++++++++ components/support/url-macro/tests/Cargo.toml | 15 +++ .../support/url-macro/tests/empty_args.rs | 9 ++ .../support/url-macro/tests/empty_args.stderr | 7 ++ .../support/url-macro/tests/invalid_url.rs | 9 ++ .../url-macro/tests/invalid_url.stderr | 5 + .../url-macro/tests/non_string_literal.rs | 9 ++ .../url-macro/tests/non_string_literal.stderr | 5 + components/support/url-macro/tests/pass.rs | 21 ++++ components/support/url-macro/tests/tests.rs | 17 +++ 16 files changed, 310 insertions(+), 4 deletions(-) create mode 100644 components/support/url-macro/Cargo.toml create mode 100644 components/support/url-macro/README.md create mode 100644 components/support/url-macro/src/lib.rs create mode 100644 components/support/url-macro/tests/Cargo.toml create mode 100644 components/support/url-macro/tests/empty_args.rs create mode 100644 components/support/url-macro/tests/empty_args.stderr create mode 100644 components/support/url-macro/tests/invalid_url.rs create mode 100644 components/support/url-macro/tests/invalid_url.stderr create mode 100644 components/support/url-macro/tests/non_string_literal.rs create mode 100644 components/support/url-macro/tests/non_string_literal.stderr create mode 100644 components/support/url-macro/tests/pass.rs create mode 100644 components/support/url-macro/tests/tests.rs diff --git a/Cargo.lock b/Cargo.lock index b6a7373fb8..78e9416fdb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -35,6 +35,7 @@ dependencies = [ "thiserror 2.0.3", "uniffi", "url", + "url-macro", "uuid", "viaduct", "viaduct-dev", @@ -5101,6 +5102,25 @@ dependencies = [ "serde", ] +[[package]] +name = "url-macro" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", + "url", +] + +[[package]] +name = "url-macro-tests" +version = "0.1.0" +dependencies = [ + "trybuild", + "url", + "url-macro", +] + [[package]] name = "utf8_iter" version = "1.0.4" diff --git a/Cargo.toml b/Cargo.toml index b4b1212b69..3c5b9ff0a3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,6 +49,8 @@ members = [ "components/support/text-table", "components/support/tracing", "components/support/types", + "components/support/url-macro", + "components/support/url-macro/tests", "components/support/viaduct-dev", "components/support/viaduct-hyper", "components/support/viaduct-reqwest", @@ -138,6 +140,8 @@ default-members = [ "components/support/rc_crypto/nss/nss_sys", "components/support/sql", "components/support/types", + "components/support/url-macro", + "components/support/url-macro/tests", "components/support/viaduct-reqwest", "components/sync_manager", "components/sync15", diff --git a/components/ads-client/Cargo.toml b/components/ads-client/Cargo.toml index 87390f52e6..15eac5443a 100644 --- a/components/ads-client/Cargo.toml +++ b/components/ads-client/Cargo.toml @@ -26,6 +26,7 @@ thiserror = "2" once_cell = "1.5" uniffi = { version = "0.31" } url = { version = "2", features = ["serde"] } +url-macro = { path = "../support/url-macro" } uuid = { version = "1.3", features = ["v4"] } viaduct = { path = "../viaduct", features = ["ohttp"] } sql-support = { path = "../support/sql" } diff --git a/components/ads-client/src/mars/environment.rs b/components/ads-client/src/mars/environment.rs index 5075c223d5..37ed91fdd1 100644 --- a/components/ads-client/src/mars/environment.rs +++ b/components/ads-client/src/mars/environment.rs @@ -5,12 +5,11 @@ use once_cell::sync::Lazy; use url::Url; +use url_macro::url; -static MARS_API_ENDPOINT_PROD: Lazy = - Lazy::new(|| Url::parse("https://ads.mozilla.org/v1/").expect("hardcoded URL must be valid")); +static MARS_API_ENDPOINT_PROD: Lazy = Lazy::new(|| url!("https://ads.mozilla.org/v1/")); -static MARS_API_ENDPOINT_STAGING: Lazy = - Lazy::new(|| Url::parse("https://ads.allizom.org/v1/").expect("hardcoded URL must be valid")); +static MARS_API_ENDPOINT_STAGING: Lazy = Lazy::new(|| url!("https://ads.allizom.org/v1/")); #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub enum Environment { diff --git a/components/support/url-macro/Cargo.toml b/components/support/url-macro/Cargo.toml new file mode 100644 index 0000000000..01bc42b2ba --- /dev/null +++ b/components/support/url-macro/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "url-macro" +version = "0.1.0" +edition = "2021" +license = "MPL-2.0" +publish = false +autotests = false + +[lib] +proc-macro = true + +[dependencies] +syn = { version = "2.0", features = ["derive", "parsing", "full"] } +quote = "1.0" +proc-macro2 = "1.0" +url = "2" diff --git a/components/support/url-macro/README.md b/components/support/url-macro/README.md new file mode 100644 index 0000000000..beb4740a78 --- /dev/null +++ b/components/support/url-macro/README.md @@ -0,0 +1,111 @@ +# url-macro + +Compile-time URL string validation for application-services. + +## What it is + +A proc macro that validates a URL string literal at compile time. Invalid +input becomes a compile error pointing at the offending literal; valid +input expands to a runtime `url::Url::parse` call that is guaranteed to +succeed. + +```rust +use url::Url; +use url_macro::url; + +let endpoint: Url = url!("https://ads.mozilla.org/v1/"); +// url!("not a url"); // → compile error: relative URL without a base +// url!(); // → compile error: expected string literal +``` + +## Why we built it + +Today the monorepo carries **205 occurrences** of `Url::parse(...).expect/unwrap` +across **30 files**, in **three incompatible patterns** (`Lazy` + +`.expect()`, `const &str`, inline `Url::parse()` in `match` arms). At +least one literal — `https://ads.mozilla.org/v1/` — is duplicated across +crates with different patterns. + +The macro provides: + +1. **Deduplication leverage** — a single canonical way to declare a hard-coded URL. +2. **Parseability proof at build time** — typos in URL literals fail `cargo build`, not production. +3. **Zero runtime cost beyond the existing `Url::parse`** — the expansion is what your code already does. + +## What it is *not* + +- **Not a `const` URL** — `url::Url::parse` is not `const fn`. The macro + cannot be used in `static FOO: Url = url!(...)`. Wrap with + `once_cell::Lazy` or `std::sync::LazyLock` for static contexts. +- **Not a substitute for semantic tests** — the macro proves a string + parses as a URL. It does not prove the URL points to the correct + server, has the right scheme, or matches your intent. Existing tests + that assert host/path/scheme should stay. + +## API + +| Form | Behavior | +|---|---| +| `url!("https://example.com/")` | Returns `url::Url`. | +| `url!("malformed")` | Compile error with `url::ParseError`'s message. | +| `url!()` / `url!(123)` | Compile error: expected string literal. | + +The expanded code references `::url::Url::parse`, so the calling crate +must declare `url` as a dependency. + +## Adoption + +**Opt-in, per-component, no deadline.** The crate is being introduced +in `ads-client` first as a working example. Other components are welcome +to migrate at their own pace. + +### Before / after (from ads-client) + +```rust +// Before +static MARS_API_ENDPOINT_PROD: Lazy = Lazy::new(|| { + Url::parse("https://ads.mozilla.org/v1/").expect("hardcoded URL must be valid") +}); + +// After +static MARS_API_ENDPOINT_PROD: Lazy = Lazy::new(|| url!("https://ads.mozilla.org/v1/")); +``` + +### Migration recipe + +1. Add `url-macro = { path = "../support/url-macro" }` to your crate's + `Cargo.toml` `[dependencies]`. +2. `use url_macro::url;` in any module with hard-coded URL literals. +3. Replace `Url::parse("...").expect(...)` (or `.unwrap()`) with + `url!("...")`. Drop the `.expect`/`.unwrap` — the macro guarantees + the parse cannot fail. +4. **Do not migrate**: + - Test fixture URLs where panic on bad input is the desired behavior. + - Dynamic URLs built via `format!` or runtime concatenation — the + macro only accepts string literals. + +## Status + +Initial release — `url!` macro only. Future extensions (e.g., +`base_url!` returning a newtype, scheme-restricted variants) will be +proposed via separate ADRs if usage warrants. + +## Layout + +``` +components/support/url-macro/ +├── Cargo.toml # proc-macro = true +├── src/lib.rs # url! implementation (~30 LOC, syn + quote) +├── README.md # this file +└── tests/ # separate test crate (url-macro-tests) + ├── Cargo.toml + ├── tests.rs # trybuild driver + ├── pass.rs + ├── *.rs # compile-fail cases + └── *.stderr # trybuild golden files +``` + +## Questions / migrating your component? + +Reach out in the application-services repo. The reference migration is +`components/ads-client/src/mars/environment.rs`. diff --git a/components/support/url-macro/src/lib.rs b/components/support/url-macro/src/lib.rs new file mode 100644 index 0000000000..ff18f6da1c --- /dev/null +++ b/components/support/url-macro/src/lib.rs @@ -0,0 +1,58 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +//! Compile-time URL validation macro for application-services. +//! +//! Provides a [`url!`] proc macro that validates a string literal as a +//! parseable URL at compile time. Invalid input produces a compile +//! error pointing at the offending literal; valid input expands to a +//! runtime `url::Url::parse` call that is guaranteed to succeed. +//! +//! ## Consumer requirements +//! +//! The expanded code references `::url::Url::parse`, so the calling +//! crate must declare `url` as a dependency. +//! +//! ## Limitations +//! +//! `url::Url::parse` is not `const fn`, so the macro cannot be used +//! directly in `static`/`const` initializers. Wrap with +//! `once_cell::Lazy` or `std::sync::LazyLock` for static contexts. + +use proc_macro::TokenStream; +use quote::quote; +use syn::{parse_macro_input, LitStr}; + +/// Validates a URL string literal at compile time. +/// +/// On success, expands to a `url::Url::parse(...)` call that cannot +/// fail at runtime. On failure, the macro emits a compile error +/// carrying `url::ParseError`'s message, anchored at the offending +/// literal. +/// +/// # Examples +/// +/// ```ignore +/// use url_macro::url; +/// use url::Url; +/// +/// let endpoint: Url = url!("https://ads.mozilla.org/v1/"); +/// // url!("not a url"); // -> compile error: relative URL without a base +/// ``` +#[proc_macro] +pub fn url(input: TokenStream) -> TokenStream { + let lit = parse_macro_input!(input as LitStr); + let value = lit.value(); + + if let Err(err) = ::url::Url::parse(&value) { + return syn::Error::new(lit.span(), err.to_string()) + .to_compile_error() + .into(); + } + + quote! { + ::url::Url::parse(#lit).expect("URL validated at compile time") + } + .into() +} diff --git a/components/support/url-macro/tests/Cargo.toml b/components/support/url-macro/tests/Cargo.toml new file mode 100644 index 0000000000..3cde138fb6 --- /dev/null +++ b/components/support/url-macro/tests/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "url-macro-tests" +version = "0.1.0" +edition = "2021" +license = "MPL-2.0" +publish = false + +[[test]] +name = "tests" +path = "tests.rs" + +[dev-dependencies] +trybuild = { version = "1.0.49", features = ["diff"] } +url = "2" +url-macro = { path = "../" } diff --git a/components/support/url-macro/tests/empty_args.rs b/components/support/url-macro/tests/empty_args.rs new file mode 100644 index 0000000000..30c39120a6 --- /dev/null +++ b/components/support/url-macro/tests/empty_args.rs @@ -0,0 +1,9 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use url_macro::url; + +fn main() { + let _ = url!(); +} diff --git a/components/support/url-macro/tests/empty_args.stderr b/components/support/url-macro/tests/empty_args.stderr new file mode 100644 index 0000000000..6312063168 --- /dev/null +++ b/components/support/url-macro/tests/empty_args.stderr @@ -0,0 +1,7 @@ +error: unexpected end of input, expected string literal + --> empty_args.rs:8:13 + | +8 | let _ = url!(); + | ^^^^^^ + | + = note: this error originates in the macro `url` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/components/support/url-macro/tests/invalid_url.rs b/components/support/url-macro/tests/invalid_url.rs new file mode 100644 index 0000000000..0454e52dbe --- /dev/null +++ b/components/support/url-macro/tests/invalid_url.rs @@ -0,0 +1,9 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use url_macro::url; + +fn main() { + let _ = url!("not a url"); +} diff --git a/components/support/url-macro/tests/invalid_url.stderr b/components/support/url-macro/tests/invalid_url.stderr new file mode 100644 index 0000000000..d2c2d75209 --- /dev/null +++ b/components/support/url-macro/tests/invalid_url.stderr @@ -0,0 +1,5 @@ +error: relative URL without a base + --> invalid_url.rs:8:18 + | +8 | let _ = url!("not a url"); + | ^^^^^^^^^^^ diff --git a/components/support/url-macro/tests/non_string_literal.rs b/components/support/url-macro/tests/non_string_literal.rs new file mode 100644 index 0000000000..b67e885f6d --- /dev/null +++ b/components/support/url-macro/tests/non_string_literal.rs @@ -0,0 +1,9 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use url_macro::url; + +fn main() { + let _ = url!(42); +} diff --git a/components/support/url-macro/tests/non_string_literal.stderr b/components/support/url-macro/tests/non_string_literal.stderr new file mode 100644 index 0000000000..7385f40984 --- /dev/null +++ b/components/support/url-macro/tests/non_string_literal.stderr @@ -0,0 +1,5 @@ +error: expected string literal + --> non_string_literal.rs:8:18 + | +8 | let _ = url!(42); + | ^^ diff --git a/components/support/url-macro/tests/pass.rs b/components/support/url-macro/tests/pass.rs new file mode 100644 index 0000000000..bfe828b84b --- /dev/null +++ b/components/support/url-macro/tests/pass.rs @@ -0,0 +1,21 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use url::Url; +use url_macro::url; + +fn main() { + let prod: Url = url!("https://ads.mozilla.org/v1/"); + assert_eq!(prod.scheme(), "https"); + assert_eq!(prod.host_str(), Some("ads.mozilla.org")); + assert_eq!(prod.path(), "/v1/"); + + let staging: Url = url!("https://ads.allizom.org/v1/"); + assert_eq!(staging.scheme(), "https"); + assert_eq!(staging.host_str(), Some("ads.allizom.org")); + + let with_query: Url = url!("https://example.com/path?key=value#frag"); + assert_eq!(with_query.query(), Some("key=value")); + assert_eq!(with_query.fragment(), Some("frag")); +} diff --git a/components/support/url-macro/tests/tests.rs b/components/support/url-macro/tests/tests.rs new file mode 100644 index 0000000000..018416d598 --- /dev/null +++ b/components/support/url-macro/tests/tests.rs @@ -0,0 +1,17 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Note: the .stderr golden files in this directory are produced by +// trybuild and are sensitive to the rustc version. If they go stale +// after a toolchain bump, regenerate with `TRYBUILD=overwrite cargo +// test -p url-macro-tests` and review the diff before committing. + +#[test] +fn tests() { + let t = trybuild::TestCases::new(); + t.pass("pass.rs"); + t.compile_fail("empty_args.rs"); + t.compile_fail("non_string_literal.rs"); + t.compile_fail("invalid_url.rs"); +}