Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions components/ads-client/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
18 changes: 14 additions & 4 deletions components/ads-client/src/mars/environment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,11 @@

use once_cell::sync::Lazy;
use url::Url;
use url_macro::url;

static MARS_API_ENDPOINT_PROD: Lazy<Url> =
Lazy::new(|| Url::parse("https://ads.mozilla.org/v1/").expect("hardcoded URL must be valid"));
static MARS_API_ENDPOINT_PROD: Lazy<Url> = Lazy::new(|| url!("https://ads.mozilla.org/v1/"));

static MARS_API_ENDPOINT_STAGING: Lazy<Url> =
Lazy::new(|| Url::parse("https://ads.allizom.org/v1/").expect("hardcoded URL must be valid"));
static MARS_API_ENDPOINT_STAGING: Lazy<Url> = Lazy::new(|| url!("https://ads.allizom.org/v1/"));

#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum Environment {
Expand Down Expand Up @@ -59,4 +58,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");
}
}
16 changes: 16 additions & 0 deletions components/support/url-macro/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
111 changes: 111 additions & 0 deletions components/support/url-macro/README.md
Original file line number Diff line number Diff line change
@@ -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<Url>` +
`.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<Url> = Lazy::new(|| {
Url::parse("https://ads.mozilla.org/v1/").expect("hardcoded URL must be valid")
});

// After
static MARS_API_ENDPOINT_PROD: Lazy<Url> = 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`.
58 changes: 58 additions & 0 deletions components/support/url-macro/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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()
}
15 changes: 15 additions & 0 deletions components/support/url-macro/tests/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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 = "../" }
9 changes: 9 additions & 0 deletions components/support/url-macro/tests/empty_args.rs
Original file line number Diff line number Diff line change
@@ -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!();
}
7 changes: 7 additions & 0 deletions components/support/url-macro/tests/empty_args.stderr
Original file line number Diff line number Diff line change
@@ -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)
9 changes: 9 additions & 0 deletions components/support/url-macro/tests/invalid_url.rs
Original file line number Diff line number Diff line change
@@ -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");
}
5 changes: 5 additions & 0 deletions components/support/url-macro/tests/invalid_url.stderr
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
error: relative URL without a base
--> invalid_url.rs:8:18
|
8 | let _ = url!("not a url");
| ^^^^^^^^^^^
9 changes: 9 additions & 0 deletions components/support/url-macro/tests/non_string_literal.rs
Original file line number Diff line number Diff line change
@@ -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);
}
5 changes: 5 additions & 0 deletions components/support/url-macro/tests/non_string_literal.stderr
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
error: expected string literal
--> non_string_literal.rs:8:18
|
8 | let _ = url!(42);
| ^^
21 changes: 21 additions & 0 deletions components/support/url-macro/tests/pass.rs
Original file line number Diff line number Diff line change
@@ -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"));
}
17 changes: 17 additions & 0 deletions components/support/url-macro/tests/tests.rs
Original file line number Diff line number Diff line change
@@ -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");
}
Loading