From 544806d8c6f44896d4b4e6fe356cec26128047f3 Mon Sep 17 00:00:00 2001 From: Martin Tomka Date: Fri, 12 Jun 2026 10:48:58 +0200 Subject: [PATCH 01/22] feat(fetch_azure): adapt fetch HttpClient to Azure's HttpClient Add a new fetch_azure crate providing FetchHttpClient, an adapter that implements typespec_client_core::http::HttpClient on top of a fetch::HttpClient. This lets the Azure SDK for Rust use fetch as its HTTP transport. The adapter converts a typespec Request into a fetch request (method, uri, headers, and bytes or seekable-stream body), executes it through the fetch client, and maps the fetch response back into an AsyncRawResponse with a streamed body. A new_http_client helper returns an Arc for convenience. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .spelling | 4 + Cargo.lock | 77 ++++++++++++++ Cargo.toml | 3 + crates/fetch_azure/CHANGELOG.md | 8 ++ crates/fetch_azure/Cargo.toml | 51 +++++++++ crates/fetch_azure/README.md | 49 +++++++++ crates/fetch_azure/favicon.ico | 3 + crates/fetch_azure/logo.png | 3 + crates/fetch_azure/src/lib.rs | 153 +++++++++++++++++++++++++++ crates/fetch_azure/tests/adapter.rs | 154 ++++++++++++++++++++++++++++ 10 files changed, 505 insertions(+) create mode 100644 crates/fetch_azure/CHANGELOG.md create mode 100644 crates/fetch_azure/Cargo.toml create mode 100644 crates/fetch_azure/README.md create mode 100644 crates/fetch_azure/favicon.ico create mode 100644 crates/fetch_azure/logo.png create mode 100644 crates/fetch_azure/src/lib.rs create mode 100644 crates/fetch_azure/tests/adapter.rs diff --git a/.spelling b/.spelling index 3527af725..c75e11884 100644 --- a/.spelling +++ b/.spelling @@ -593,3 +593,7 @@ deterministically dereferences honour MPSC +Azure +SDK +TypeSpec +typespec diff --git a/Cargo.lock b/Cargo.lock index 74407936b..acfacfd47 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -254,6 +254,17 @@ version = "4.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -956,6 +967,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", + "serde_core", ] [[package]] @@ -1016,6 +1028,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "dynosaur" version = "0.3.0" @@ -1158,6 +1176,20 @@ dependencies = [ "wiremock", ] +[[package]] +name = "fetch_azure" +version = "0.1.0" +dependencies = [ + "async-trait", + "bytesbuf", + "fetch", + "futures", + "http", + "http-body-util", + "tokio", + "typespec_client_core", +] + [[package]] name = "fetch_hyper" version = "0.4.0" @@ -3788,6 +3820,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] @@ -3804,6 +3837,17 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -3902,6 +3946,39 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" +[[package]] +name = "typespec" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21666a31293beab8f41d38c2849ddbc342cd9c7cb4d71a9818868287a8934e53" +dependencies = [ + "base64", + "bytes", + "futures", + "url", +] + +[[package]] +name = "typespec_client_core" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924f0c734e0ac3b881ab99d032bd28fcc969d2bb73ef1b8dd4772fd8e518a382" +dependencies = [ + "async-trait", + "base64", + "bytes", + "dyn-clone", + "futures", + "pin-project", + "rand 0.10.1", + "serde", + "time", + "tracing", + "typespec", + "url", + "uuid", +] + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/Cargo.toml b/Cargo.toml index 21c47484f..f518561c1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,7 @@ data_privacy_core = { path = "crates/data_privacy_core", default-features = fals data_privacy_macros = { path = "crates/data_privacy_macros", default-features = false, version = "0.10.1" } data_privacy_macros_impl = { path = "crates/data_privacy_macros_impl", default-features = false, version = "0.10.1" } fetch = { path = "crates/fetch", default-features = false, version = "0.11.0" } +fetch_azure = { path = "crates/fetch_azure", default-features = false, version = "0.1.0" } fetch_hyper = { path = "crates/fetch_hyper", default-features = false, version = "0.4.0" } fetch_options = { path = "crates/fetch_options", default-features = false, version = "0.2.1" } fetch_tls = { path = "crates/fetch_tls", default-features = false, version = "0.2.2" } @@ -66,6 +67,7 @@ allocator-api2 = { version = "0.4.0", default-features = false } anyhow = { version = "1.0.100", default-features = false } argh = { version = "0.1.13", default-features = false } async-once-cell = { version = "0.5.0", default-features = false } +async-trait = { version = "0.1.89", default-features = false } base64 = { version = "0.22.0", default-features = false, features = ["alloc"] } bolero = { version = "0.13.4", default-features = false } bumpalo = { version = "3.20.2", default-features = false } @@ -151,6 +153,7 @@ tracing-test = { version = "0.2.6", default-features = false } trait-variant = { version = "0.1.2", default-features = false } trybuild = { version = "1.0.114", default-features = false } typeid = { version = "1.0.3", default-features = false } +typespec_client_core = { version = "1.0.0", default-features = false } uuid = { version = "1.21.0", default-features = false } widestring = { version = "1.2.1", default-features = false } windows-sys = { version = "0.61.2", default-features = false } diff --git a/crates/fetch_azure/CHANGELOG.md b/crates/fetch_azure/CHANGELOG.md new file mode 100644 index 000000000..11720cb28 --- /dev/null +++ b/crates/fetch_azure/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +## [0.1.0] + +- ✨ Features + + - introduce `fetch_azure`: adapts `fetch::HttpClient` as a + `typespec_client_core::http::HttpClient` transport for the Azure SDK for Rust. diff --git a/crates/fetch_azure/Cargo.toml b/crates/fetch_azure/Cargo.toml new file mode 100644 index 000000000..ecfcd187f --- /dev/null +++ b/crates/fetch_azure/Cargo.toml @@ -0,0 +1,51 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[package] +name = "fetch_azure" +description = "Adapts the fetch HTTP client as a transport for the Azure SDK for Rust." +version = "0.1.0" +readme = "README.md" +keywords = ["oxidizer", "azure", "fetch", "http", "typespec"] +categories = ["network-programming"] + +edition = { workspace = true } +rust-version = { workspace = true } +authors = { workspace = true } +license = { workspace = true } +homepage = { workspace = true } +repository = "https://github.com/microsoft/oxidizer/tree/main/crates/fetch_azure" + +[package.metadata.cargo_check_external_types] +allowed_external_types = [ + # Workspace sibling crates + "fetch::*", + # External dependencies + "typespec_client_core::*", +] + +[package.metadata.docs.rs] +all-features = true + +[dependencies] +# internal +bytesbuf = { workspace = true, features = ["bytes-compat"] } +fetch = { workspace = true } + +# external +async-trait = { workspace = true } +futures = { workspace = true, features = ["std"] } +http = { workspace = true } +http-body-util = { workspace = true } +typespec_client_core = { workspace = true, features = ["http"] } + +[dev-dependencies] +# internal +fetch = { path = "../fetch", features = ["test-util"] } + +# external +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +typespec_client_core = { workspace = true, features = ["http"] } + +[lints] +workspace = true diff --git a/crates/fetch_azure/README.md b/crates/fetch_azure/README.md new file mode 100644 index 000000000..1affe6411 --- /dev/null +++ b/crates/fetch_azure/README.md @@ -0,0 +1,49 @@ +
+ Fetch Azure Logo + +# Fetch Azure + +[![crate.io](https://img.shields.io/crates/v/fetch_azure.svg)](https://crates.io/crates/fetch_azure) +[![docs.rs](https://docs.rs/fetch_azure/badge.svg)](https://docs.rs/fetch_azure) +[![MSRV](https://img.shields.io/crates/msrv/fetch_azure)](https://crates.io/crates/fetch_azure) +[![CI](https://github.com/microsoft/oxidizer/actions/workflows/main.yml/badge.svg?event=push)](https://github.com/microsoft/oxidizer/actions/workflows/main.yml) +[![Coverage](https://codecov.io/gh/microsoft/oxidizer/graph/badge.svg?token=FCUG0EL5TI)](https://codecov.io/gh/microsoft/oxidizer) +[![License](https://img.shields.io/badge/license-MIT-blue.svg)](../../LICENSE) +This crate was developed as part of the Oxidizer project + +
+ +Use [`fetch`][__link0] as the HTTP transport for the Azure SDK for Rust. + +The Azure SDK abstracts its HTTP transport behind the +[`typespec_client_core::http::HttpClient`][__link1] trait. This crate provides +[`FetchHttpClient`][__link2], an adapter that implements that trait on top of a +[`fetch::HttpClient`][__link3], so Azure SDK pipelines can run over `fetch` and +benefit from its resilience, observability, and runtime features. + +## Example + +```rust +use std::sync::Arc; + +use fetch::HttpClient; +use fetch_azure::FetchHttpClient; +use typespec_client_core::http::HttpClient as AzureHttpClient; + +// Wrap an existing `fetch` client so it can be handed to the Azure SDK. +fn as_azure_transport(client: HttpClient) -> Arc { + Arc::new(FetchHttpClient::new(client)) +} +``` + + +
+ +This crate was developed as part of The Oxidizer Project. Browse this crate's source code. + + + [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbLiTyV0MU86EbZU15e0PmecoboQ9jo59bnAEbyDXw04U13GlhYvRhcoQbRv-wpNqCGrcbMZzL91BgMNYbjDONT0OwnFsbr-HEvaBX0LFhZIOCZWZldGNoZjAuMTEuMIJrZmV0Y2hfYXp1cmVlMC4xLjCCdHR5cGVzcGVjX2NsaWVudF9jb3JlZTEuMC4w + [__link0]: https://crates.io/crates/fetch/0.11.0 + [__link1]: https://docs.rs/typespec_client_core/1.0.0/typespec_client_core/?search=http::HttpClient + [__link2]: https://docs.rs/fetch_azure/0.1.0/fetch_azure/struct.FetchHttpClient.html + [__link3]: https://docs.rs/fetch/0.11.0/fetch/?search=HttpClient diff --git a/crates/fetch_azure/favicon.ico b/crates/fetch_azure/favicon.ico new file mode 100644 index 000000000..82fe3de36 --- /dev/null +++ b/crates/fetch_azure/favicon.ico @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:140215583c71ac6fd9cf69ab5b44e11cbb053916426104e9dd40eeba6b0ae865 +size 23418 diff --git a/crates/fetch_azure/logo.png b/crates/fetch_azure/logo.png new file mode 100644 index 000000000..cf77a7a66 --- /dev/null +++ b/crates/fetch_azure/logo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8ac6d1cfa25763c51eb14541fa4defa965e8f16462e9d61e7de7fee6387a2553 +size 67062 diff --git a/crates/fetch_azure/src/lib.rs b/crates/fetch_azure/src/lib.rs new file mode 100644 index 000000000..e27c02069 --- /dev/null +++ b/crates/fetch_azure/src/lib.rs @@ -0,0 +1,153 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#![cfg_attr(all(coverage_nightly, test), feature(coverage_attribute))] +#![cfg_attr(docsrs, feature(doc_cfg))] +#![doc(html_logo_url = "https://media.githubusercontent.com/media/microsoft/oxidizer/refs/heads/main/crates/fetch_azure/logo.png")] +#![doc(html_favicon_url = "https://media.githubusercontent.com/media/microsoft/oxidizer/refs/heads/main/crates/fetch_azure/favicon.ico")] + +//! Use [`fetch`] as the HTTP transport for the Azure SDK for Rust. +//! +//! The Azure SDK abstracts its HTTP transport behind the +//! [`typespec_client_core::http::HttpClient`] trait. This crate provides +//! [`FetchHttpClient`], an adapter that implements that trait on top of a +//! [`fetch::HttpClient`], so Azure SDK pipelines can run over `fetch` and +//! benefit from its resilience, observability, and runtime features. +//! +//! # Example +//! +//! ``` +//! use std::sync::Arc; +//! +//! use fetch::HttpClient; +//! use fetch_azure::FetchHttpClient; +//! use typespec_client_core::http::HttpClient as AzureHttpClient; +//! +//! // Wrap an existing `fetch` client so it can be handed to the Azure SDK. +//! fn as_azure_transport(client: HttpClient) -> Arc { +//! Arc::new(FetchHttpClient::new(client)) +//! } +//! # let _ = as_azure_transport; +//! ``` + +use std::collections::HashMap; +use std::sync::Arc; + +use async_trait::async_trait; +use bytesbuf::BytesView; +use futures::StreamExt as _; +use http_body_util::BodyExt as _; +use typespec_client_core::error::{Error, ErrorKind}; +use typespec_client_core::http::headers::{HeaderName, HeaderValue, Headers}; +use typespec_client_core::http::request::{Body, Request}; +use typespec_client_core::http::response::PinnedStream; +use typespec_client_core::http::{AsyncRawResponse, HttpClient}; + +/// An [`HttpClient`] that uses a [`fetch::HttpClient`] as its transport. +/// +/// Construct one from an existing `fetch` client with [`FetchHttpClient::new`] +/// (or via [`From`]) and pass it to the Azure SDK wherever a +/// `dyn HttpClient` is expected. See [`new_http_client`] for a convenience that +/// returns an `Arc` directly. +#[derive(Debug, Clone)] +pub struct FetchHttpClient { + client: fetch::HttpClient, +} + +impl FetchHttpClient { + /// Creates a new adapter that forwards requests to the given `fetch` client. + #[must_use] + pub const fn new(client: fetch::HttpClient) -> Self { + Self { client } + } + + /// Returns a reference to the wrapped [`fetch::HttpClient`]. + #[must_use] + pub const fn inner(&self) -> &fetch::HttpClient { + &self.client + } + + /// Consumes the adapter and returns the wrapped [`fetch::HttpClient`]. + #[must_use] + pub fn into_inner(self) -> fetch::HttpClient { + self.client + } +} + +impl From for FetchHttpClient { + fn from(client: fetch::HttpClient) -> Self { + Self::new(client) + } +} + +/// Wraps a [`fetch::HttpClient`] as an `Arc`. +/// +/// This is a convenience for the common case of handing a `fetch`-backed +/// transport to the Azure SDK. +#[must_use] +pub fn new_http_client(client: fetch::HttpClient) -> Arc { + Arc::new(FetchHttpClient::new(client)) +} + +#[async_trait] +impl HttpClient for FetchHttpClient { + async fn execute_request(&self, request: &Request) -> typespec_client_core::Result { + // `Method::as_str` yields a canonical token (e.g. "GET") that `fetch`'s + // builder parses into an `http::Method`; this avoids matching on the + // `#[non_exhaustive]` typespec `Method` enum. + let mut builder = self.client.request(request.method().as_str(), request.url().as_str()); + + for (name, value) in request.headers().iter() { + builder = builder.header(name.as_str(), value.as_str()); + } + + builder = match request.body().clone() { + Body::Bytes(bytes) => builder.bytes(bytes), + Body::SeekableStream(stream) => builder.stream(stream.map(|chunk| { + chunk + .map(BytesView::from) + .map_err(|error| fetch::HttpError::from(std::io::Error::other(error))) + })), + }; + + let response = builder + .fetch() + .await + .map_err(|error| Error::with_error(ErrorKind::Io, error, "the fetch HTTP client failed to execute the request"))?; + + Ok(to_async_raw_response(response)) + } +} + +/// Converts a `fetch` [`HttpResponse`](fetch::HttpResponse) into an [`AsyncRawResponse`]. +fn to_async_raw_response(response: fetch::HttpResponse) -> AsyncRawResponse { + let (parts, body) = response.into_parts(); + let status = parts.status.as_u16().into(); + let headers = to_headers(&parts.headers); + + let stream: PinnedStream = Box::pin(body.into_data_stream().map(|chunk| { + chunk + .map(|view| view.to_bytes()) + .map_err(|error| Error::with_error(ErrorKind::Io, error, "failed to read the response body")) + })); + + AsyncRawResponse::new(status, headers, stream) +} + +/// Converts an [`http::HeaderMap`] into [`Headers`]. +/// +/// Header values that are not valid UTF-8 are skipped, mirroring the behavior +/// of the Azure SDK's built-in `reqwest` transport. +fn to_headers(map: &http::HeaderMap) -> Headers { + let headers = map + .iter() + .filter_map(|(name, value)| { + value + .to_str() + .ok() + .map(|value| (HeaderName::from(name.as_str().to_owned()), HeaderValue::from(value.to_owned()))) + }) + .collect::>(); + + Headers::from(headers) +} diff --git a/crates/fetch_azure/tests/adapter.rs b/crates/fetch_azure/tests/adapter.rs new file mode 100644 index 000000000..9c315875c --- /dev/null +++ b/crates/fetch_azure/tests/adapter.rs @@ -0,0 +1,154 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Integration tests for [`fetch_azure::FetchHttpClient`]. +//! +//! These exercise the adapter end-to-end using `fetch`'s `FakeHandler`, so no +//! real network access is required. + +use fetch::fake::FakeHandler; +use fetch::{HttpClient as FetchClient, HttpResponseBuilder}; +use fetch_azure::{FetchHttpClient, new_http_client}; +use typespec_client_core::Bytes; +use typespec_client_core::http::headers::HeaderName; +use typespec_client_core::http::request::Request; +use typespec_client_core::http::{HttpClient, Method, Url}; +use typespec_client_core::stream::BytesStream; + +fn request(method: Method) -> Request { + Request::new(Url::parse("https://example.com/path").expect("valid url"), method) +} + +/// A handler that always responds with the given status code and an empty body. +fn status_handler(status: u16) -> FakeHandler { + FakeHandler::from_fn(move |_request| HttpResponseBuilder::new_fake().status(status).build()) +} + +#[tokio::test] +async fn execute_request_maps_status_headers_and_body() { + let handler = FakeHandler::from_fn(|_request| { + HttpResponseBuilder::new_fake() + .status(201u16) + .header("x-test", "hello") + .text("world") + .build() + }); + let client = FetchHttpClient::new(FetchClient::new_fake(handler)); + + let response = client.execute_request(&request(Method::Get)).await.unwrap(); + + assert_eq!(response.status(), 201u16); + assert_eq!(response.headers().get_optional_str(&HeaderName::from("x-test")), Some("hello")); + + let body = response.into_body().collect().await.unwrap(); + assert_eq!(&*body, b"world"); +} + +#[tokio::test] +async fn execute_request_forwards_method_and_bytes_body() { + // The handler echoes the request body back, but only for POST requests. + let handler = FakeHandler::from_async_fn(|request| async move { + if request.method().as_str() != "POST" { + return HttpResponseBuilder::new_fake().status(400u16).build(); + } + + let body = request.into_body().into_bytes().await?; + HttpResponseBuilder::new_fake().status(200u16).bytes(body).build() + }); + let client = FetchHttpClient::new(FetchClient::new_fake(handler)); + + let mut request = request(Method::Post); + request.set_body(Bytes::from_static(b"payload")); + + let response = client.execute_request(&request).await.unwrap(); + + assert_eq!(response.status(), 200u16); + let body = response.into_body().collect().await.unwrap(); + assert_eq!(&*body, b"payload"); +} + +#[tokio::test] +async fn execute_request_forwards_seekable_stream_body() { + let handler = FakeHandler::from_async_fn(|request| async move { + let body = request.into_body().into_bytes().await?; + HttpResponseBuilder::new_fake().status(200u16).bytes(body).build() + }); + let client = FetchHttpClient::new(FetchClient::new_fake(handler)); + + let mut request = request(Method::Put); + request.set_body(BytesStream::new(Bytes::from_static(b"streamed"))); + + let response = client.execute_request(&request).await.unwrap(); + + assert_eq!(response.status(), 200u16); + let body = response.into_body().collect().await.unwrap(); + assert_eq!(&*body, b"streamed"); +} + +#[tokio::test] +async fn execute_request_forwards_request_headers() { + let handler = FakeHandler::from_fn(|request| { + let forwarded = request.headers().get("x-correlation").and_then(|value| value.to_str().ok()) == Some("abc123"); + let status = if forwarded { 200u16 } else { 400u16 }; + HttpResponseBuilder::new_fake().status(status).build() + }); + let client = FetchHttpClient::new(FetchClient::new_fake(handler)); + + let mut request = request(Method::Get); + request.insert_header("x-correlation", "abc123"); + + let response = client.execute_request(&request).await.unwrap(); + + assert_eq!(response.status(), 200u16); +} + +#[tokio::test] +async fn execute_request_maps_all_methods() { + for method in [Method::Delete, Method::Get, Method::Head, Method::Patch, Method::Post, Method::Put] { + let expected = method.as_str(); + let handler = FakeHandler::from_fn(move |request| { + let status = if request.method().as_str() == expected { 200u16 } else { 400u16 }; + HttpResponseBuilder::new_fake().status(status).build() + }); + let client = FetchHttpClient::new(FetchClient::new_fake(handler)); + + let response = client.execute_request(&request(method)).await.unwrap(); + + assert_eq!(response.status(), 200u16, "method {method:?} was not forwarded"); + } +} + +#[tokio::test] +async fn execute_request_maps_transport_error() { + let handler = FakeHandler::from_error_fn(|_request| fetch::HttpError::unavailable("simulated transport failure")); + let client = FetchHttpClient::new(FetchClient::new_fake(handler)); + + let error = client.execute_request(&request(Method::Get)).await.unwrap_err(); + + assert!( + error.to_string().contains("the fetch HTTP client failed to execute the request"), + "unexpected error: {error}" + ); +} + +#[tokio::test] +async fn new_http_client_returns_dyn_client() { + let client = new_http_client(FetchClient::new_fake(status_handler(202))); + + let response = client.execute_request(&request(Method::Get)).await.unwrap(); + + assert_eq!(response.status(), 202u16); +} + +#[tokio::test] +async fn from_fetch_client_and_inner_round_trip() { + let adapter = FetchHttpClient::from(FetchClient::new_fake(status_handler(200))); + + // `inner` exposes the wrapped client and `into_inner` returns it unchanged. + let _ = adapter.inner(); + let recovered = adapter.into_inner(); + let adapter = FetchHttpClient::new(recovered); + + let response = adapter.execute_request(&request(Method::Get)).await.unwrap(); + assert_eq!(response.status(), 200u16); +} From 7ed5355276cd22d74e97898e478d3310a8e6bdc3 Mon Sep 17 00:00:00 2001 From: Martin Tomka Date: Fri, 12 Jun 2026 10:56:58 +0200 Subject: [PATCH 02/22] docs(fetch_azure): reword header doc comment to satisfy spellcheck Avoid the possessive form of the SDK acronym, which Hunspell flags, in the to_headers doc comment. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/fetch_azure/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/fetch_azure/src/lib.rs b/crates/fetch_azure/src/lib.rs index e27c02069..f78245e80 100644 --- a/crates/fetch_azure/src/lib.rs +++ b/crates/fetch_azure/src/lib.rs @@ -137,7 +137,7 @@ fn to_async_raw_response(response: fetch::HttpResponse) -> AsyncRawResponse { /// Converts an [`http::HeaderMap`] into [`Headers`]. /// /// Header values that are not valid UTF-8 are skipped, mirroring the behavior -/// of the Azure SDK's built-in `reqwest` transport. +/// of the built-in `reqwest` transport in the Azure SDK. fn to_headers(map: &http::HeaderMap) -> Headers { let headers = map .iter() From 4c9ba179c4fc438a661f45e4df63760b51f2f08d Mon Sep 17 00:00:00 2001 From: Martin Tomka Date: Fri, 12 Jun 2026 11:08:37 +0200 Subject: [PATCH 03/22] refactor(fetch_azure): build then execute, add empty-body fast path Incorporate the best ideas from the internal azure_core adapter to reduce allocations and improve error classification: - split request building (mapped to DataConversion errors) from execution (mapped to Io errors) via layered::Service, instead of the combined builder .fetch() path - add an empty-body fast path that reuses fetch's shared empty body instead of allocating - stream the response body via HttpBody::into_stream, dropping the http-body-util dependency - forward seekable request-stream read errors with a descriptive message Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 2 +- crates/fetch_azure/Cargo.toml | 2 +- crates/fetch_azure/src/lib.rs | 80 +++++++++++++++++++++++------------ 3 files changed, 55 insertions(+), 29 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index acfacfd47..f10184c8b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1185,7 +1185,7 @@ dependencies = [ "fetch", "futures", "http", - "http-body-util", + "layered", "tokio", "typespec_client_core", ] diff --git a/crates/fetch_azure/Cargo.toml b/crates/fetch_azure/Cargo.toml index ecfcd187f..2ec43f88a 100644 --- a/crates/fetch_azure/Cargo.toml +++ b/crates/fetch_azure/Cargo.toml @@ -31,12 +31,12 @@ all-features = true # internal bytesbuf = { workspace = true, features = ["bytes-compat"] } fetch = { workspace = true } +layered = { workspace = true } # external async-trait = { workspace = true } futures = { workspace = true, features = ["std"] } http = { workspace = true } -http-body-util = { workspace = true } typespec_client_core = { workspace = true, features = ["http"] } [dev-dependencies] diff --git a/crates/fetch_azure/src/lib.rs b/crates/fetch_azure/src/lib.rs index f78245e80..ec691d497 100644 --- a/crates/fetch_azure/src/lib.rs +++ b/crates/fetch_azure/src/lib.rs @@ -35,8 +35,8 @@ use std::sync::Arc; use async_trait::async_trait; use bytesbuf::BytesView; -use futures::StreamExt as _; -use http_body_util::BodyExt as _; +use futures::{StreamExt as _, TryStreamExt as _}; +use layered::Service as _; use typespec_client_core::error::{Error, ErrorKind}; use typespec_client_core::http::headers::{HeaderName, HeaderValue, Headers}; use typespec_client_core::http::request::{Body, Request}; @@ -72,6 +72,47 @@ impl FetchHttpClient { pub fn into_inner(self) -> fetch::HttpClient { self.client } + + /// Converts a typespec [`Request`] into a `fetch` request. + fn to_fetch_request(&self, request: &Request) -> typespec_client_core::Result { + // `Method::as_str` yields a canonical token (e.g. "GET") that `fetch`'s + // builder parses into an `http::Method`; this avoids matching on the + // `#[non_exhaustive]` typespec `Method` enum. + let mut builder = self.client.request(request.method().as_str(), request.url().as_str()); + + for (name, value) in request.headers().iter() { + builder = builder.header(name.as_str(), value.as_str()); + } + + builder.body(self.to_fetch_body(request.body())).build().map_err(|error| { + Error::with_error( + ErrorKind::DataConversion, + error, + "failed to convert the Azure request into a fetch request", + ) + }) + } + + /// Converts a typespec request [`Body`] into a `fetch` [`HttpBody`](fetch::HttpBody). + /// + /// Empty byte bodies reuse a shared empty body, and non-empty byte bodies are + /// wrapped without copying. Seekable streams are forwarded as a chunk stream. + fn to_fetch_body(&self, body: &Body) -> fetch::HttpBody { + let builder: &fetch::HttpBodyBuilder = self.client.as_ref(); + + match body { + Body::Bytes(bytes) if bytes.is_empty() => builder.empty(), + Body::Bytes(bytes) => builder.bytes(BytesView::from(bytes.clone())), + Body::SeekableStream(stream) => { + let stream = stream.clone().map(|chunk| { + chunk + .map(BytesView::from) + .map_err(|error| fetch::HttpError::unavailable(format!("failed to read the Azure request body: {error}"))) + }); + builder.stream(stream, &fetch::options::HttpBodyOptions::default()) + } + } + } } impl From for FetchHttpClient { @@ -92,26 +133,11 @@ pub fn new_http_client(client: fetch::HttpClient) -> Arc { #[async_trait] impl HttpClient for FetchHttpClient { async fn execute_request(&self, request: &Request) -> typespec_client_core::Result { - // `Method::as_str` yields a canonical token (e.g. "GET") that `fetch`'s - // builder parses into an `http::Method`; this avoids matching on the - // `#[non_exhaustive]` typespec `Method` enum. - let mut builder = self.client.request(request.method().as_str(), request.url().as_str()); - - for (name, value) in request.headers().iter() { - builder = builder.header(name.as_str(), value.as_str()); - } + let request = self.to_fetch_request(request)?; - builder = match request.body().clone() { - Body::Bytes(bytes) => builder.bytes(bytes), - Body::SeekableStream(stream) => builder.stream(stream.map(|chunk| { - chunk - .map(BytesView::from) - .map_err(|error| fetch::HttpError::from(std::io::Error::other(error))) - })), - }; - - let response = builder - .fetch() + let response = self + .client + .execute(request) .await .map_err(|error| Error::with_error(ErrorKind::Io, error, "the fetch HTTP client failed to execute the request"))?; @@ -125,13 +151,13 @@ fn to_async_raw_response(response: fetch::HttpResponse) -> AsyncRawResponse { let status = parts.status.as_u16().into(); let headers = to_headers(&parts.headers); - let stream: PinnedStream = Box::pin(body.into_data_stream().map(|chunk| { - chunk - .map(|view| view.to_bytes()) - .map_err(|error| Error::with_error(ErrorKind::Io, error, "failed to read the response body")) - })); + let body = body + .into_stream() + .map_ok(|view| view.to_bytes()) + .map_err(|error| Error::with_error(ErrorKind::Io, error, "failed to read the response body")); + let body: PinnedStream = Box::pin(body); - AsyncRawResponse::new(status, headers, stream) + AsyncRawResponse::new(status, headers, body) } /// Converts an [`http::HeaderMap`] into [`Headers`]. From 3cbd1f6c30281d4bfbd2731b8793b73611bb5733 Mon Sep 17 00:00:00 2001 From: Martin Tomka Date: Fri, 12 Jun 2026 11:19:13 +0200 Subject: [PATCH 04/22] docs(fetch_azure): add azure_transport usage example Add examples/azure_transport.rs showing how to adapt a Tokio-based fetch client into an Arc Azure SDK transport and issue a request through the typespec HttpClient trait. Enable the tokio and rustls features on the fetch dev-dependency so the example can build a real client. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/fetch_azure/Cargo.toml | 2 +- .../fetch_azure/examples/azure_transport.rs | 29 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 crates/fetch_azure/examples/azure_transport.rs diff --git a/crates/fetch_azure/Cargo.toml b/crates/fetch_azure/Cargo.toml index 2ec43f88a..32a2d194f 100644 --- a/crates/fetch_azure/Cargo.toml +++ b/crates/fetch_azure/Cargo.toml @@ -41,7 +41,7 @@ typespec_client_core = { workspace = true, features = ["http"] } [dev-dependencies] # internal -fetch = { path = "../fetch", features = ["test-util"] } +fetch = { path = "../fetch", features = ["test-util", "tokio", "rustls"] } # external tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/crates/fetch_azure/examples/azure_transport.rs b/crates/fetch_azure/examples/azure_transport.rs new file mode 100644 index 000000000..dc22f2f7f --- /dev/null +++ b/crates/fetch_azure/examples/azure_transport.rs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Adapts a Tokio-based [`fetch::HttpClient`] into an Azure SDK transport and +//! issues a request through the [`typespec_client_core::http::HttpClient`] trait. +//! +//! Run with: `cargo run --example azure_transport` + +use std::sync::Arc; + +use fetch::HttpClient as FetchClient; +use fetch_azure::new_http_client; +use typespec_client_core::http::{HttpClient, Method, Request, Url}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Build a `fetch` client (Tokio runtime + rustls TLS) and adapt it so it can + // be used wherever the Azure SDK expects an `Arc` transport. + let transport: Arc = new_http_client(FetchClient::new_tokio()); + + // In a real application you would hand `transport` to an Azure SDK client's + // options. Here we drive it directly to show the round-trip. + let request = Request::new(Url::parse("https://example.com")?, Method::Get); + let response = transport.execute_request(&request).await?; + + println!("request completed with status: {}", u16::from(response.status())); + + Ok(()) +} From 122c195e261653d80208b7308434b270bf93c78e Mon Sep 17 00:00:00 2001 From: Martin Tomka Date: Fri, 12 Jun 2026 11:28:48 +0200 Subject: [PATCH 05/22] test(fetch_azure): cover request/response error and edge paths Add integration tests for the previously-uncovered branches so the crate reaches full line coverage: - request build failure maps to a DataConversion error - non-UTF8 response header values are skipped - a failing seekable request-body stream surfaces through the body error map - a failing response body surfaces through the response error map Adds async-trait and futures dev-dependencies for the erroring SeekableStream helper. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/fetch_azure/Cargo.toml | 2 + crates/fetch_azure/tests/adapter.rs | 119 +++++++++++++++++++++++++++- 2 files changed, 119 insertions(+), 2 deletions(-) diff --git a/crates/fetch_azure/Cargo.toml b/crates/fetch_azure/Cargo.toml index 32a2d194f..89da203ea 100644 --- a/crates/fetch_azure/Cargo.toml +++ b/crates/fetch_azure/Cargo.toml @@ -44,6 +44,8 @@ typespec_client_core = { workspace = true, features = ["http"] } fetch = { path = "../fetch", features = ["test-util", "tokio", "rustls"] } # external +async-trait = { workspace = true } +futures = { workspace = true, features = ["std"] } tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } typespec_client_core = { workspace = true, features = ["http"] } diff --git a/crates/fetch_azure/tests/adapter.rs b/crates/fetch_azure/tests/adapter.rs index 9c315875c..2bd6d802b 100644 --- a/crates/fetch_azure/tests/adapter.rs +++ b/crates/fetch_azure/tests/adapter.rs @@ -6,14 +6,19 @@ //! These exercise the adapter end-to-end using `fetch`'s `FakeHandler`, so no //! real network access is required. +use std::pin::Pin; +use std::task::{Context, Poll}; + +use async_trait::async_trait; use fetch::fake::FakeHandler; use fetch::{HttpClient as FetchClient, HttpResponseBuilder}; use fetch_azure::{FetchHttpClient, new_http_client}; +use futures::io::AsyncRead; use typespec_client_core::Bytes; use typespec_client_core::http::headers::HeaderName; -use typespec_client_core::http::request::Request; +use typespec_client_core::http::request::{Body, Request}; use typespec_client_core::http::{HttpClient, Method, Url}; -use typespec_client_core::stream::BytesStream; +use typespec_client_core::stream::{BytesStream, SeekableStream}; fn request(method: Method) -> Request { Request::new(Url::parse("https://example.com/path").expect("valid url"), method) @@ -152,3 +157,113 @@ async fn from_fetch_client_and_inner_round_trip() { let response = adapter.execute_request(&request(Method::Get)).await.unwrap(); assert_eq!(response.status(), 200u16); } + +#[tokio::test] +async fn execute_request_maps_request_build_failure() { + let client = FetchHttpClient::new(FetchClient::new_fake(status_handler(200))); + + // A header value containing a control character is rejected by the `http` + // crate when the fetch request is built, exercising the DataConversion path. + let mut request = request(Method::Get); + request.insert_header("x-invalid", "bad\nvalue"); + + let error = client.execute_request(&request).await.unwrap_err(); + + assert!( + error + .to_string() + .contains("failed to convert the Azure request into a fetch request"), + "unexpected error: {error}" + ); +} + +#[tokio::test] +async fn execute_request_skips_non_utf8_response_headers() { + let handler = FakeHandler::from_fn(|_request| { + let binary = fetch::HeaderValue::from_bytes(&[0xff, 0xfe]).expect("valid header value bytes"); + HttpResponseBuilder::new_fake() + .status(200u16) + .header("x-valid", "ok") + .header("x-binary", binary) + .build() + }); + let client = FetchHttpClient::new(FetchClient::new_fake(handler)); + + let response = client.execute_request(&request(Method::Get)).await.unwrap(); + + assert_eq!(response.headers().get_optional_str(&HeaderName::from("x-valid")), Some("ok")); + assert_eq!(response.headers().get_optional_str(&HeaderName::from("x-binary")), None); +} + +#[tokio::test] +async fn execute_request_maps_seekable_stream_read_error() { + let handler = FakeHandler::from_async_fn(|request| async move { + // Reading the body drives the erroring stream, surfacing the failure. + request.into_body().into_bytes().await?; + HttpResponseBuilder::new_fake().status(200u16).build() + }); + let client = FetchHttpClient::new(FetchClient::new_fake(handler)); + + let mut request = request(Method::Post); + request.set_body(Body::SeekableStream(Box::new(ErroringStream))); + + let error = client.execute_request(&request).await.unwrap_err(); + + assert!( + error_chain(&error).contains("failed to read the Azure request body"), + "unexpected error: {error}" + ); +} + +#[tokio::test] +async fn execute_request_maps_response_body_read_error() { + let handler = FakeHandler::from_fn(|_request| { + let body = fetch::HttpBodyBuilder::new_fake().stream( + futures::stream::iter([Err(fetch::HttpError::unavailable("boom"))]), + &fetch::options::HttpBodyOptions::default(), + ); + HttpResponseBuilder::new_fake().status(200u16).body(body).build() + }); + let client = FetchHttpClient::new(FetchClient::new_fake(handler)); + + let response = client.execute_request(&request(Method::Get)).await.unwrap(); + let error = response.into_body().collect().await.unwrap_err(); + + assert!( + error.to_string().contains("failed to read the response body"), + "unexpected error: {error}" + ); +} + +/// A [`SeekableStream`] whose reads always fail, used to cover the request-body error path. +#[derive(Debug, Clone)] +struct ErroringStream; + +impl AsyncRead for ErroringStream { + fn poll_read(self: Pin<&mut Self>, _cx: &mut Context<'_>, _buf: &mut [u8]) -> Poll> { + Poll::Ready(Err(std::io::Error::other("boom"))) + } +} + +#[async_trait] +impl SeekableStream for ErroringStream { + async fn reset(&mut self) -> typespec_client_core::Result<()> { + Ok(()) + } + + fn len(&self) -> Option { + None + } +} + +/// Joins an error and its `source` chain into a single string for assertions. +fn error_chain(error: &dyn std::error::Error) -> String { + let mut chain = error.to_string(); + let mut source = error.source(); + while let Some(cause) = source { + chain.push_str(" | "); + chain.push_str(&cause.to_string()); + source = cause.source(); + } + chain +} From 2cbec10712d6a49d597f5a5d6f2fa6231e7f9f50 Mon Sep 17 00:00:00 2001 From: Martin Tomka Date: Fri, 12 Jun 2026 11:55:02 +0200 Subject: [PATCH 06/22] feat(fetch_azure): add SpawnerRuntime and switch to azure_core Bundle a runtime abstraction alongside the transport: SpawnerRuntime implements azure_core::async_runtime::AsyncRuntime on top of an anyspawn::Spawner (spawn via the spawner, sleep on its blocking pool, and yield), with a new_async_runtime helper returning Arc. Switch the crate from typespec_client_core to azure_core (which re-exports the same typespec http and async_runtime traits) so both abstractions come from one Azure SDK crate. Add anyspawn and azure_core dependencies and drop the direct typespec_client_core dependency. Add integration tests for spawn, abort, sleep, yield, the dyn-runtime helper, and the From/inner round trip; lib.rs remains at 100% coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .spelling | 3 + Cargo.lock | 51 +++++- Cargo.toml | 2 +- crates/fetch_azure/CHANGELOG.md | 8 +- crates/fetch_azure/Cargo.toml | 13 +- crates/fetch_azure/README.md | 50 ++++-- .../fetch_azure/examples/azure_transport.rs | 2 +- crates/fetch_azure/src/lib.rs | 160 +++++++++++++++--- crates/fetch_azure/tests/adapter.rs | 76 ++++++++- 9 files changed, 314 insertions(+), 51 deletions(-) diff --git a/.spelling b/.spelling index c75e11884..035ee6526 100644 --- a/.spelling +++ b/.spelling @@ -597,3 +597,6 @@ Azure SDK TypeSpec typespec +Seekable +Spawner +awaitable diff --git a/Cargo.lock b/Cargo.lock index f10184c8b..1a07b0eec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -309,6 +309,38 @@ dependencies = [ "fs_extra", ] +[[package]] +name = "azure_core" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe6a26a7d374b440015cbbcbf2d9d8be5a133aa940599f5e5dc569504baa262e" +dependencies = [ + "async-lock", + "async-trait", + "azure_core_macros", + "bytes", + "futures", + "pin-project", + "rustc_version", + "serde", + "serde_json", + "tracing", + "typespec", + "typespec_client_core", +] + +[[package]] +name = "azure_core_macros" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9b52dba6a345f3ad2d42ff8d0d63df9d0994cfa29657bf18ffdbf149f78a4f5" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "tracing", +] + [[package]] name = "base64" version = "0.22.1" @@ -1180,14 +1212,15 @@ dependencies = [ name = "fetch_azure" version = "0.1.0" dependencies = [ + "anyspawn", "async-trait", + "azure_core", "bytesbuf", "fetch", "futures", "http", "layered", "tokio", - "typespec_client_core", ] [[package]] @@ -3955,6 +3988,8 @@ dependencies = [ "base64", "bytes", "futures", + "serde", + "serde_json", "url", ] @@ -3972,13 +4007,27 @@ dependencies = [ "pin-project", "rand 0.10.1", "serde", + "serde_json", "time", "tracing", "typespec", + "typespec_macros", "url", "uuid", ] +[[package]] +name = "typespec_macros" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c608f4427943f8adb211abc95c87672b1b98847152783507d54e3246e502f60" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn", +] + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/Cargo.toml b/Cargo.toml index f518561c1..fc77f9d21 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,6 +68,7 @@ anyhow = { version = "1.0.100", default-features = false } argh = { version = "0.1.13", default-features = false } async-once-cell = { version = "0.5.0", default-features = false } async-trait = { version = "0.1.89", default-features = false } +azure_core = { version = "1.0.0", default-features = false } base64 = { version = "0.22.0", default-features = false, features = ["alloc"] } bolero = { version = "0.13.4", default-features = false } bumpalo = { version = "3.20.2", default-features = false } @@ -153,7 +154,6 @@ tracing-test = { version = "0.2.6", default-features = false } trait-variant = { version = "0.1.2", default-features = false } trybuild = { version = "1.0.114", default-features = false } typeid = { version = "1.0.3", default-features = false } -typespec_client_core = { version = "1.0.0", default-features = false } uuid = { version = "1.21.0", default-features = false } widestring = { version = "1.2.1", default-features = false } windows-sys = { version = "0.61.2", default-features = false } diff --git a/crates/fetch_azure/CHANGELOG.md b/crates/fetch_azure/CHANGELOG.md index 11720cb28..c3c0f125d 100644 --- a/crates/fetch_azure/CHANGELOG.md +++ b/crates/fetch_azure/CHANGELOG.md @@ -4,5 +4,9 @@ - ✨ Features - - introduce `fetch_azure`: adapts `fetch::HttpClient` as a - `typespec_client_core::http::HttpClient` transport for the Azure SDK for Rust. + - introduce `fetch_azure`, bundling two Azure SDK abstractions backed by the + Oxidizer stack: + - `FetchHttpClient` implements `azure_core::http::HttpClient` on top of a + `fetch::HttpClient` transport. + - `SpawnerRuntime` implements `azure_core::async_runtime::AsyncRuntime` on top + of an `anyspawn::Spawner`. diff --git a/crates/fetch_azure/Cargo.toml b/crates/fetch_azure/Cargo.toml index 89da203ea..6bb19d7d7 100644 --- a/crates/fetch_azure/Cargo.toml +++ b/crates/fetch_azure/Cargo.toml @@ -3,10 +3,10 @@ [package] name = "fetch_azure" -description = "Adapts the fetch HTTP client as a transport for the Azure SDK for Rust." +description = "Azure SDK transport and runtime backed by the fetch HTTP client and an anyspawn spawner." version = "0.1.0" readme = "README.md" -keywords = ["oxidizer", "azure", "fetch", "http", "typespec"] +keywords = ["oxidizer", "azure", "fetch", "http", "runtime"] categories = ["network-programming"] edition = { workspace = true } @@ -19,8 +19,9 @@ repository = "https://github.com/microsoft/oxidizer/tree/main/crates/fetch_azure [package.metadata.cargo_check_external_types] allowed_external_types = [ # Workspace sibling crates + "anyspawn::*", "fetch::*", - # External dependencies + # External dependencies (azure_core re-exports these typespec types) "typespec_client_core::*", ] @@ -29,25 +30,27 @@ all-features = true [dependencies] # internal +anyspawn = { workspace = true } bytesbuf = { workspace = true, features = ["bytes-compat"] } fetch = { workspace = true } layered = { workspace = true } # external async-trait = { workspace = true } +azure_core = { workspace = true } futures = { workspace = true, features = ["std"] } http = { workspace = true } -typespec_client_core = { workspace = true, features = ["http"] } [dev-dependencies] # internal +anyspawn = { path = "../anyspawn", features = ["tokio"] } fetch = { path = "../fetch", features = ["test-util", "tokio", "rustls"] } # external async-trait = { workspace = true } +azure_core = { workspace = true } futures = { workspace = true, features = ["std"] } tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } -typespec_client_core = { workspace = true, features = ["http"] } [lints] workspace = true diff --git a/crates/fetch_azure/README.md b/crates/fetch_azure/README.md index 1affe6411..3928b3080 100644 --- a/crates/fetch_azure/README.md +++ b/crates/fetch_azure/README.md @@ -13,26 +13,40 @@ -Use [`fetch`][__link0] as the HTTP transport for the Azure SDK for Rust. +Bundle [`fetch`][__link0] and [`anyspawn`][__link1] as Azure SDK abstractions. The Azure SDK abstracts its HTTP transport behind the -[`typespec_client_core::http::HttpClient`][__link1] trait. This crate provides -[`FetchHttpClient`][__link2], an adapter that implements that trait on top of a -[`fetch::HttpClient`][__link3], so Azure SDK pipelines can run over `fetch` and -benefit from its resilience, observability, and runtime features. +[`azure_core::http::HttpClient`][__link2] trait and its task spawning, sleeping, and +yielding behind the [`azure_core::async_runtime::AsyncRuntime`][__link3] trait. This +crate provides adapters for both: + +* [`FetchHttpClient`][__link4] implements [`HttpClient`][__link5] on top of a + [`fetch::HttpClient`][__link6], so Azure SDK pipelines run over `fetch` and benefit + from its resilience and observability. +* [`SpawnerRuntime`][__link7] implements [`AsyncRuntime`][__link8] on top of an + [`anyspawn::Spawner`][__link9], so the Azure SDK spawns and sleeps on the runtime of + your choice. ## Example ```rust use std::sync::Arc; -use fetch::HttpClient; -use fetch_azure::FetchHttpClient; -use typespec_client_core::http::HttpClient as AzureHttpClient; +use anyspawn::Spawner; +use azure_core::async_runtime::{AsyncRuntime, set_async_runtime}; +use azure_core::http::HttpClient; +use fetch::HttpClient as FetchClient; +use fetch_azure::{new_async_runtime, new_http_client}; + +// Adapt a `fetch` client into an Azure SDK transport. +fn transport(client: FetchClient) -> Arc { + new_http_client(client) +} -// Wrap an existing `fetch` client so it can be handed to the Azure SDK. -fn as_azure_transport(client: HttpClient) -> Arc { - Arc::new(FetchHttpClient::new(client)) +// Install an `anyspawn`-backed async runtime for the Azure SDK. +fn install_runtime(spawner: Spawner) { + let runtime: Arc = new_async_runtime(spawner); + let _ = set_async_runtime(runtime); } ``` @@ -42,8 +56,14 @@ fn as_azure_transport(client: HttpClient) -> Arc { This crate was developed as part of The Oxidizer Project. Browse this crate's source code. - [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbLiTyV0MU86EbZU15e0PmecoboQ9jo59bnAEbyDXw04U13GlhYvRhcoQbRv-wpNqCGrcbMZzL91BgMNYbjDONT0OwnFsbr-HEvaBX0LFhZIOCZWZldGNoZjAuMTEuMIJrZmV0Y2hfYXp1cmVlMC4xLjCCdHR5cGVzcGVjX2NsaWVudF9jb3JlZTEuMC4w + [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbLiTyV0MU86EbZU15e0PmecoboQ9jo59bnAEbyDXw04U13GlhYvRhcoQbMickWN39c48b_g6cgT8kQlwb0qtqsQb8mDIb5jxRmQ-RUBphZISCaGFueXNwYXduZTAuNS4zgmphenVyZV9jb3JlZTEuMC4wgmVmZXRjaGYwLjExLjCCa2ZldGNoX2F6dXJlZTAuMS4w [__link0]: https://crates.io/crates/fetch/0.11.0 - [__link1]: https://docs.rs/typespec_client_core/1.0.0/typespec_client_core/?search=http::HttpClient - [__link2]: https://docs.rs/fetch_azure/0.1.0/fetch_azure/struct.FetchHttpClient.html - [__link3]: https://docs.rs/fetch/0.11.0/fetch/?search=HttpClient + [__link1]: https://crates.io/crates/anyspawn/0.5.3 + [__link2]: https://docs.rs/azure_core/1.0.0/azure_core/?search=http::HttpClient + [__link3]: https://docs.rs/azure_core/1.0.0/azure_core/?search=async_runtime::AsyncRuntime + [__link4]: https://docs.rs/fetch_azure/0.1.0/fetch_azure/struct.FetchHttpClient.html + [__link5]: https://docs.rs/azure_core/1.0.0/azure_core/?search=http::HttpClient + [__link6]: https://docs.rs/fetch/0.11.0/fetch/?search=HttpClient + [__link7]: https://docs.rs/fetch_azure/0.1.0/fetch_azure/struct.SpawnerRuntime.html + [__link8]: https://docs.rs/azure_core/1.0.0/azure_core/?search=async_runtime::AsyncRuntime + [__link9]: https://docs.rs/anyspawn/0.5.3/anyspawn/?search=Spawner diff --git a/crates/fetch_azure/examples/azure_transport.rs b/crates/fetch_azure/examples/azure_transport.rs index dc22f2f7f..636b14c4d 100644 --- a/crates/fetch_azure/examples/azure_transport.rs +++ b/crates/fetch_azure/examples/azure_transport.rs @@ -8,9 +8,9 @@ use std::sync::Arc; +use azure_core::http::{HttpClient, Method, Request, Url}; use fetch::HttpClient as FetchClient; use fetch_azure::new_http_client; -use typespec_client_core::http::{HttpClient, Method, Request, Url}; #[tokio::main] async fn main() -> Result<(), Box> { diff --git a/crates/fetch_azure/src/lib.rs b/crates/fetch_azure/src/lib.rs index ec691d497..58526997b 100644 --- a/crates/fetch_azure/src/lib.rs +++ b/crates/fetch_azure/src/lib.rs @@ -6,42 +6,63 @@ #![doc(html_logo_url = "https://media.githubusercontent.com/media/microsoft/oxidizer/refs/heads/main/crates/fetch_azure/logo.png")] #![doc(html_favicon_url = "https://media.githubusercontent.com/media/microsoft/oxidizer/refs/heads/main/crates/fetch_azure/favicon.ico")] -//! Use [`fetch`] as the HTTP transport for the Azure SDK for Rust. +//! Bundle [`fetch`] and [`anyspawn`] as Azure SDK abstractions. //! //! The Azure SDK abstracts its HTTP transport behind the -//! [`typespec_client_core::http::HttpClient`] trait. This crate provides -//! [`FetchHttpClient`], an adapter that implements that trait on top of a -//! [`fetch::HttpClient`], so Azure SDK pipelines can run over `fetch` and -//! benefit from its resilience, observability, and runtime features. +//! [`azure_core::http::HttpClient`] trait and its task spawning, sleeping, and +//! yielding behind the [`azure_core::async_runtime::AsyncRuntime`] trait. This +//! crate provides adapters for both: +//! +//! - [`FetchHttpClient`] implements [`HttpClient`] on top of a +//! [`fetch::HttpClient`], so Azure SDK pipelines run over `fetch` and benefit +//! from its resilience and observability. +//! - [`SpawnerRuntime`] implements [`AsyncRuntime`] on top of an +//! [`anyspawn::Spawner`], so the Azure SDK spawns and sleeps on the runtime of +//! your choice. //! //! # Example //! //! ``` //! use std::sync::Arc; //! -//! use fetch::HttpClient; -//! use fetch_azure::FetchHttpClient; -//! use typespec_client_core::http::HttpClient as AzureHttpClient; +//! use anyspawn::Spawner; +//! use azure_core::async_runtime::{AsyncRuntime, set_async_runtime}; +//! use azure_core::http::HttpClient; +//! use fetch::HttpClient as FetchClient; +//! use fetch_azure::{new_async_runtime, new_http_client}; +//! +//! // Adapt a `fetch` client into an Azure SDK transport. +//! fn transport(client: FetchClient) -> Arc { +//! new_http_client(client) +//! } //! -//! // Wrap an existing `fetch` client so it can be handed to the Azure SDK. -//! fn as_azure_transport(client: HttpClient) -> Arc { -//! Arc::new(FetchHttpClient::new(client)) +//! // Install an `anyspawn`-backed async runtime for the Azure SDK. +//! fn install_runtime(spawner: Spawner) { +//! let runtime: Arc = new_async_runtime(spawner); +//! let _ = set_async_runtime(runtime); //! } -//! # let _ = as_azure_transport; +//! # let _ = (transport, install_runtime); //! ``` use std::collections::HashMap; +use std::future::ready; +use std::pin::Pin; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::task::{Context, Poll}; +use anyspawn::{JoinHandle, Spawner}; use async_trait::async_trait; +use azure_core::async_runtime::{AbortableTask, AsyncRuntime, SpawnedTask, TaskFuture}; +use azure_core::error::{Error, ErrorKind}; +use azure_core::http::headers::{HeaderName, HeaderValue, Headers}; +use azure_core::http::request::{Body, Request}; +use azure_core::http::response::PinnedStream; +use azure_core::http::{AsyncRawResponse, HttpClient}; +use azure_core::time::Duration; use bytesbuf::BytesView; use futures::{StreamExt as _, TryStreamExt as _}; use layered::Service as _; -use typespec_client_core::error::{Error, ErrorKind}; -use typespec_client_core::http::headers::{HeaderName, HeaderValue, Headers}; -use typespec_client_core::http::request::{Body, Request}; -use typespec_client_core::http::response::PinnedStream; -use typespec_client_core::http::{AsyncRawResponse, HttpClient}; /// An [`HttpClient`] that uses a [`fetch::HttpClient`] as its transport. /// @@ -74,7 +95,7 @@ impl FetchHttpClient { } /// Converts a typespec [`Request`] into a `fetch` request. - fn to_fetch_request(&self, request: &Request) -> typespec_client_core::Result { + fn to_fetch_request(&self, request: &Request) -> azure_core::Result { // `Method::as_str` yields a canonical token (e.g. "GET") that `fetch`'s // builder parses into an `http::Method`; this avoids matching on the // `#[non_exhaustive]` typespec `Method` enum. @@ -132,7 +153,7 @@ pub fn new_http_client(client: fetch::HttpClient) -> Arc { #[async_trait] impl HttpClient for FetchHttpClient { - async fn execute_request(&self, request: &Request) -> typespec_client_core::Result { + async fn execute_request(&self, request: &Request) -> azure_core::Result { let request = self.to_fetch_request(request)?; let response = self @@ -177,3 +198,104 @@ fn to_headers(map: &http::HeaderMap) -> Headers { Headers::from(headers) } + +/// An [`AsyncRuntime`] that spawns work on an [`anyspawn::Spawner`]. +/// +/// Construct one from an existing [`Spawner`] with [`SpawnerRuntime::new`] (or +/// via [`From`]) and install it as the Azure SDK runtime with +/// [`azure_core::async_runtime::set_async_runtime`]. See [`new_async_runtime`] +/// for a convenience that returns an `Arc` directly. +#[derive(Debug, Clone)] +pub struct SpawnerRuntime { + spawner: Spawner, +} + +impl SpawnerRuntime { + /// Creates a new runtime that spawns work on the given [`Spawner`]. + #[must_use] + pub const fn new(spawner: Spawner) -> Self { + Self { spawner } + } + + /// Returns a reference to the wrapped [`Spawner`]. + pub const fn inner(&self) -> &Spawner { + &self.spawner + } + + /// Consumes the runtime and returns the wrapped [`Spawner`]. + pub fn into_inner(self) -> Spawner { + self.spawner + } +} + +impl From for SpawnerRuntime { + fn from(spawner: Spawner) -> Self { + Self::new(spawner) + } +} + +/// Wraps an [`anyspawn::Spawner`] as an `Arc`. +/// +/// This is a convenience for installing a `fetch`-friendly runtime with +/// [`azure_core::async_runtime::set_async_runtime`]. +#[must_use] +pub fn new_async_runtime(spawner: Spawner) -> Arc { + Arc::new(SpawnerRuntime::new(spawner)) +} + +impl AsyncRuntime for SpawnerRuntime { + fn spawn(&self, f: TaskFuture) -> SpawnedTask { + Box::pin(SpawnerTask::new(self.spawner.spawn(f))) + } + + fn sleep(&self, duration: Duration) -> TaskFuture { + let spawner = self.spawner.clone(); + Box::pin(async move { + // `time::Duration` can be negative; clamp such values to zero. The + // wait runs on the spawner's blocking pool so any runtime works. + let duration = std::time::Duration::try_from(duration).unwrap_or_default(); + let () = spawner.spawn_blocking(move || std::thread::sleep(duration)).await; + }) + } + + fn yield_now(&self) -> TaskFuture { + std::thread::yield_now(); + Box::pin(ready(())) + } +} + +/// Adapts an [`anyspawn::JoinHandle`] into an [`AbortableTask`]. +struct SpawnerTask { + handle: JoinHandle<()>, + aborted: AtomicBool, +} + +impl SpawnerTask { + fn new(handle: JoinHandle<()>) -> Self { + Self { + handle, + aborted: AtomicBool::new(false), + } + } +} + +impl Future for SpawnerTask { + type Output = Result<(), Box>; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let this = self.get_mut(); + if this.aborted.load(Ordering::Acquire) { + return Poll::Ready(Ok(())); + } + Pin::new(&mut this.handle).poll(cx).map(Ok) + } +} + +impl AbortableTask for SpawnerTask { + fn abort(&self) { + // `anyspawn` join handles cannot cancel the underlying task, so mark the + // task aborted and resolve on the next poll. The spawned work may keep + // running, but the caller is no longer blocked on it. + self.aborted.store(true, Ordering::Release); + } +} diff --git a/crates/fetch_azure/tests/adapter.rs b/crates/fetch_azure/tests/adapter.rs index 2bd6d802b..ef6437378 100644 --- a/crates/fetch_azure/tests/adapter.rs +++ b/crates/fetch_azure/tests/adapter.rs @@ -7,18 +7,23 @@ //! real network access is required. use std::pin::Pin; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use std::task::{Context, Poll}; +use anyspawn::Spawner; use async_trait::async_trait; +use azure_core::Bytes; +use azure_core::async_runtime::AsyncRuntime; +use azure_core::http::headers::HeaderName; +use azure_core::http::request::{Body, Request}; +use azure_core::http::{HttpClient, Method, Url}; +use azure_core::stream::{BytesStream, SeekableStream}; +use azure_core::time::Duration; use fetch::fake::FakeHandler; use fetch::{HttpClient as FetchClient, HttpResponseBuilder}; -use fetch_azure::{FetchHttpClient, new_http_client}; +use fetch_azure::{FetchHttpClient, SpawnerRuntime, new_async_runtime, new_http_client}; use futures::io::AsyncRead; -use typespec_client_core::Bytes; -use typespec_client_core::http::headers::HeaderName; -use typespec_client_core::http::request::{Body, Request}; -use typespec_client_core::http::{HttpClient, Method, Url}; -use typespec_client_core::stream::{BytesStream, SeekableStream}; fn request(method: Method) -> Request { Request::new(Url::parse("https://example.com/path").expect("valid url"), method) @@ -247,7 +252,7 @@ impl AsyncRead for ErroringStream { #[async_trait] impl SeekableStream for ErroringStream { - async fn reset(&mut self) -> typespec_client_core::Result<()> { + async fn reset(&mut self) -> azure_core::Result<()> { Ok(()) } @@ -267,3 +272,60 @@ fn error_chain(error: &dyn std::error::Error) -> String { } chain } + +#[tokio::test] +async fn runtime_spawn_runs_task_to_completion() { + let runtime = SpawnerRuntime::new(Spawner::new_tokio()); + let ran = Arc::new(AtomicBool::new(false)); + let ran_in_task = Arc::clone(&ran); + + let task = runtime.spawn(Box::pin(async move { + ran_in_task.store(true, Ordering::SeqCst); + })); + task.await.unwrap(); + + assert!(ran.load(Ordering::SeqCst)); +} + +#[tokio::test] +async fn runtime_abort_resolves_without_waiting() { + let runtime = SpawnerRuntime::new(Spawner::new_tokio()); + + // The task never completes on its own; aborting must let the await resolve. + let task = runtime.spawn(Box::pin(std::future::pending::<()>())); + task.abort(); + task.await.unwrap(); +} + +#[tokio::test] +async fn runtime_sleep_completes() { + let runtime = SpawnerRuntime::new(Spawner::new_tokio()); + + runtime.sleep(Duration::milliseconds(1)).await; +} + +#[tokio::test] +async fn runtime_yield_now_completes() { + let runtime = SpawnerRuntime::new(Spawner::new_tokio()); + + runtime.yield_now().await; +} + +#[tokio::test] +async fn new_async_runtime_returns_dyn_runtime() { + let runtime: Arc = new_async_runtime(Spawner::new_tokio()); + + runtime.spawn(Box::pin(async {})).await.unwrap(); +} + +#[tokio::test] +async fn runtime_from_spawner_and_inner_round_trip() { + let runtime = SpawnerRuntime::from(Spawner::new_tokio()); + + // `inner` exposes the wrapped spawner and `into_inner` returns it unchanged. + let _ = runtime.inner(); + let spawner = runtime.into_inner(); + let runtime = SpawnerRuntime::new(spawner); + + runtime.yield_now().await; +} From 1d49289a6265203a14e6c87ee280e04ce05c1685 Mon Sep 17 00:00:00 2001 From: Martin Tomka Date: Fri, 12 Jun 2026 12:01:37 +0200 Subject: [PATCH 07/22] refactor(fetch_azure): sleep on a tick::Clock SpawnerRuntime now holds a tick::Clock alongside the spawner and implements AsyncRuntime::sleep via Clock::delay, instead of blocking a spawner thread with std::thread::sleep. Constructors and new_async_runtime take the clock; spawner() and clock() accessors replace inner()/into_inner(), and From now accepts a (Spawner, Clock) tuple. lib.rs remains at 100% coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 1 + crates/fetch_azure/CHANGELOG.md | 2 +- crates/fetch_azure/Cargo.toml | 3 ++ crates/fetch_azure/README.md | 9 ++--- crates/fetch_azure/src/lib.rs | 55 ++++++++++++++++------------- crates/fetch_azure/tests/adapter.rs | 21 ++++++----- 6 files changed, 50 insertions(+), 41 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1a07b0eec..78c6502b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1220,6 +1220,7 @@ dependencies = [ "futures", "http", "layered", + "tick", "tokio", ] diff --git a/crates/fetch_azure/CHANGELOG.md b/crates/fetch_azure/CHANGELOG.md index c3c0f125d..084e56d46 100644 --- a/crates/fetch_azure/CHANGELOG.md +++ b/crates/fetch_azure/CHANGELOG.md @@ -9,4 +9,4 @@ - `FetchHttpClient` implements `azure_core::http::HttpClient` on top of a `fetch::HttpClient` transport. - `SpawnerRuntime` implements `azure_core::async_runtime::AsyncRuntime` on top - of an `anyspawn::Spawner`. + of an `anyspawn::Spawner` (spawning) and a `tick::Clock` (sleeping). diff --git a/crates/fetch_azure/Cargo.toml b/crates/fetch_azure/Cargo.toml index 6bb19d7d7..82745dc27 100644 --- a/crates/fetch_azure/Cargo.toml +++ b/crates/fetch_azure/Cargo.toml @@ -21,6 +21,7 @@ allowed_external_types = [ # Workspace sibling crates "anyspawn::*", "fetch::*", + "tick::*", # External dependencies (azure_core re-exports these typespec types) "typespec_client_core::*", ] @@ -34,6 +35,7 @@ anyspawn = { workspace = true } bytesbuf = { workspace = true, features = ["bytes-compat"] } fetch = { workspace = true } layered = { workspace = true } +tick = { workspace = true } # external async-trait = { workspace = true } @@ -45,6 +47,7 @@ http = { workspace = true } # internal anyspawn = { path = "../anyspawn", features = ["tokio"] } fetch = { path = "../fetch", features = ["test-util", "tokio", "rustls"] } +tick = { path = "../tick", features = ["tokio"] } # external async-trait = { workspace = true } diff --git a/crates/fetch_azure/README.md b/crates/fetch_azure/README.md index 3928b3080..9873ab38a 100644 --- a/crates/fetch_azure/README.md +++ b/crates/fetch_azure/README.md @@ -37,15 +37,16 @@ use azure_core::async_runtime::{AsyncRuntime, set_async_runtime}; use azure_core::http::HttpClient; use fetch::HttpClient as FetchClient; use fetch_azure::{new_async_runtime, new_http_client}; +use tick::Clock; // Adapt a `fetch` client into an Azure SDK transport. fn transport(client: FetchClient) -> Arc { new_http_client(client) } -// Install an `anyspawn`-backed async runtime for the Azure SDK. -fn install_runtime(spawner: Spawner) { - let runtime: Arc = new_async_runtime(spawner); +// Install an `anyspawn`-backed async runtime (sleeping on a `tick::Clock`). +fn install_runtime(spawner: Spawner, clock: Clock) { + let runtime: Arc = new_async_runtime(spawner, clock); let _ = set_async_runtime(runtime); } ``` @@ -56,7 +57,7 @@ fn install_runtime(spawner: Spawner) { This crate was developed as part of The Oxidizer Project. Browse this crate's source code. - [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbLiTyV0MU86EbZU15e0PmecoboQ9jo59bnAEbyDXw04U13GlhYvRhcoQbMickWN39c48b_g6cgT8kQlwb0qtqsQb8mDIb5jxRmQ-RUBphZISCaGFueXNwYXduZTAuNS4zgmphenVyZV9jb3JlZTEuMC4wgmVmZXRjaGYwLjExLjCCa2ZldGNoX2F6dXJlZTAuMS4w + [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbLiTyV0MU86EbZU15e0PmecoboQ9jo59bnAEbyDXw04U13GlhYvRhcoQbKO4oAJVSTNsbaMx08xFfCTQbKvuvqbtNTBYb1G5JrfJCnSBhZISCaGFueXNwYXduZTAuNS4zgmphenVyZV9jb3JlZTEuMC4wgmVmZXRjaGYwLjExLjCCa2ZldGNoX2F6dXJlZTAuMS4w [__link0]: https://crates.io/crates/fetch/0.11.0 [__link1]: https://crates.io/crates/anyspawn/0.5.3 [__link2]: https://docs.rs/azure_core/1.0.0/azure_core/?search=http::HttpClient diff --git a/crates/fetch_azure/src/lib.rs b/crates/fetch_azure/src/lib.rs index 58526997b..cf1533d8e 100644 --- a/crates/fetch_azure/src/lib.rs +++ b/crates/fetch_azure/src/lib.rs @@ -30,15 +30,16 @@ //! use azure_core::http::HttpClient; //! use fetch::HttpClient as FetchClient; //! use fetch_azure::{new_async_runtime, new_http_client}; +//! use tick::Clock; //! //! // Adapt a `fetch` client into an Azure SDK transport. //! fn transport(client: FetchClient) -> Arc { //! new_http_client(client) //! } //! -//! // Install an `anyspawn`-backed async runtime for the Azure SDK. -//! fn install_runtime(spawner: Spawner) { -//! let runtime: Arc = new_async_runtime(spawner); +//! // Install an `anyspawn`-backed async runtime (sleeping on a `tick::Clock`). +//! fn install_runtime(spawner: Spawner, clock: Clock) { +//! let runtime: Arc = new_async_runtime(spawner, clock); //! let _ = set_async_runtime(runtime); //! } //! # let _ = (transport, install_runtime); @@ -63,6 +64,7 @@ use azure_core::time::Duration; use bytesbuf::BytesView; use futures::{StreamExt as _, TryStreamExt as _}; use layered::Service as _; +use tick::Clock; /// An [`HttpClient`] that uses a [`fetch::HttpClient`] as its transport. /// @@ -199,48 +201,52 @@ fn to_headers(map: &http::HeaderMap) -> Headers { Headers::from(headers) } -/// An [`AsyncRuntime`] that spawns work on an [`anyspawn::Spawner`]. +/// An [`AsyncRuntime`] that spawns work on an [`anyspawn::Spawner`] and sleeps +/// on a [`tick::Clock`]. /// -/// Construct one from an existing [`Spawner`] with [`SpawnerRuntime::new`] (or -/// via [`From`]) and install it as the Azure SDK runtime with -/// [`azure_core::async_runtime::set_async_runtime`]. See [`new_async_runtime`] -/// for a convenience that returns an `Arc` directly. +/// Construct one from an existing [`Spawner`] and [`Clock`] with +/// [`SpawnerRuntime::new`] (or via [`From`]) and install it as the Azure SDK +/// runtime with [`azure_core::async_runtime::set_async_runtime`]. See +/// [`new_async_runtime`] for a convenience that returns an +/// `Arc` directly. #[derive(Debug, Clone)] pub struct SpawnerRuntime { spawner: Spawner, + clock: Clock, } impl SpawnerRuntime { - /// Creates a new runtime that spawns work on the given [`Spawner`]. + /// Creates a new runtime that spawns work on `spawner` and sleeps on `clock`. #[must_use] - pub const fn new(spawner: Spawner) -> Self { - Self { spawner } + pub const fn new(spawner: Spawner, clock: Clock) -> Self { + Self { spawner, clock } } /// Returns a reference to the wrapped [`Spawner`]. - pub const fn inner(&self) -> &Spawner { + pub const fn spawner(&self) -> &Spawner { &self.spawner } - /// Consumes the runtime and returns the wrapped [`Spawner`]. - pub fn into_inner(self) -> Spawner { - self.spawner + /// Returns a reference to the wrapped [`Clock`]. + #[must_use] + pub const fn clock(&self) -> &Clock { + &self.clock } } -impl From for SpawnerRuntime { - fn from(spawner: Spawner) -> Self { - Self::new(spawner) +impl From<(Spawner, Clock)> for SpawnerRuntime { + fn from((spawner, clock): (Spawner, Clock)) -> Self { + Self::new(spawner, clock) } } -/// Wraps an [`anyspawn::Spawner`] as an `Arc`. +/// Wraps an [`anyspawn::Spawner`] and [`tick::Clock`] as an `Arc`. /// /// This is a convenience for installing a `fetch`-friendly runtime with /// [`azure_core::async_runtime::set_async_runtime`]. #[must_use] -pub fn new_async_runtime(spawner: Spawner) -> Arc { - Arc::new(SpawnerRuntime::new(spawner)) +pub fn new_async_runtime(spawner: Spawner, clock: Clock) -> Arc { + Arc::new(SpawnerRuntime::new(spawner, clock)) } impl AsyncRuntime for SpawnerRuntime { @@ -249,12 +255,11 @@ impl AsyncRuntime for SpawnerRuntime { } fn sleep(&self, duration: Duration) -> TaskFuture { - let spawner = self.spawner.clone(); + let clock = self.clock.clone(); Box::pin(async move { - // `time::Duration` can be negative; clamp such values to zero. The - // wait runs on the spawner's blocking pool so any runtime works. + // `time::Duration` can be negative; clamp such values to zero. let duration = std::time::Duration::try_from(duration).unwrap_or_default(); - let () = spawner.spawn_blocking(move || std::thread::sleep(duration)).await; + clock.delay(duration).await; }) } diff --git a/crates/fetch_azure/tests/adapter.rs b/crates/fetch_azure/tests/adapter.rs index ef6437378..df38f3d9a 100644 --- a/crates/fetch_azure/tests/adapter.rs +++ b/crates/fetch_azure/tests/adapter.rs @@ -24,6 +24,7 @@ use fetch::fake::FakeHandler; use fetch::{HttpClient as FetchClient, HttpResponseBuilder}; use fetch_azure::{FetchHttpClient, SpawnerRuntime, new_async_runtime, new_http_client}; use futures::io::AsyncRead; +use tick::Clock; fn request(method: Method) -> Request { Request::new(Url::parse("https://example.com/path").expect("valid url"), method) @@ -275,7 +276,7 @@ fn error_chain(error: &dyn std::error::Error) -> String { #[tokio::test] async fn runtime_spawn_runs_task_to_completion() { - let runtime = SpawnerRuntime::new(Spawner::new_tokio()); + let runtime = SpawnerRuntime::new(Spawner::new_tokio(), Clock::new_tokio()); let ran = Arc::new(AtomicBool::new(false)); let ran_in_task = Arc::clone(&ran); @@ -289,7 +290,7 @@ async fn runtime_spawn_runs_task_to_completion() { #[tokio::test] async fn runtime_abort_resolves_without_waiting() { - let runtime = SpawnerRuntime::new(Spawner::new_tokio()); + let runtime = SpawnerRuntime::new(Spawner::new_tokio(), Clock::new_tokio()); // The task never completes on its own; aborting must let the await resolve. let task = runtime.spawn(Box::pin(std::future::pending::<()>())); @@ -299,33 +300,31 @@ async fn runtime_abort_resolves_without_waiting() { #[tokio::test] async fn runtime_sleep_completes() { - let runtime = SpawnerRuntime::new(Spawner::new_tokio()); + let runtime = SpawnerRuntime::new(Spawner::new_tokio(), Clock::new_tokio()); runtime.sleep(Duration::milliseconds(1)).await; } #[tokio::test] async fn runtime_yield_now_completes() { - let runtime = SpawnerRuntime::new(Spawner::new_tokio()); + let runtime = SpawnerRuntime::new(Spawner::new_tokio(), Clock::new_tokio()); runtime.yield_now().await; } #[tokio::test] async fn new_async_runtime_returns_dyn_runtime() { - let runtime: Arc = new_async_runtime(Spawner::new_tokio()); + let runtime: Arc = new_async_runtime(Spawner::new_tokio(), Clock::new_tokio()); runtime.spawn(Box::pin(async {})).await.unwrap(); } #[tokio::test] -async fn runtime_from_spawner_and_inner_round_trip() { - let runtime = SpawnerRuntime::from(Spawner::new_tokio()); +async fn runtime_from_spawner_clock_and_accessors_round_trip() { + let runtime = SpawnerRuntime::from((Spawner::new_tokio(), Clock::new_tokio())); - // `inner` exposes the wrapped spawner and `into_inner` returns it unchanged. - let _ = runtime.inner(); - let spawner = runtime.into_inner(); - let runtime = SpawnerRuntime::new(spawner); + // `spawner` and `clock` expose the wrapped components; rebuild from them. + let runtime = SpawnerRuntime::new(runtime.spawner().clone(), runtime.clock().clone()); runtime.yield_now().await; } From 52da8d769e68aa7d924dcab9b5e546054a9c4015 Mon Sep 17 00:00:00 2001 From: Martin Tomka Date: Fri, 12 Jun 2026 12:03:08 +0200 Subject: [PATCH 08/22] refactor(fetch_azure): rename FetchHttpClient to AzureHttpClient The adapter implements azure_core::http::HttpClient, so AzureHttpClient reads more naturally than FetchHttpClient. Pure rename; no behavior change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/fetch_azure/README.md | 6 +++--- crates/fetch_azure/src/lib.rs | 14 +++++++------- crates/fetch_azure/tests/adapter.rs | 28 ++++++++++++++-------------- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/crates/fetch_azure/README.md b/crates/fetch_azure/README.md index 9873ab38a..8a2248603 100644 --- a/crates/fetch_azure/README.md +++ b/crates/fetch_azure/README.md @@ -20,7 +20,7 @@ The Azure SDK abstracts its HTTP transport behind the yielding behind the [`azure_core::async_runtime::AsyncRuntime`][__link3] trait. This crate provides adapters for both: -* [`FetchHttpClient`][__link4] implements [`HttpClient`][__link5] on top of a +* [`AzureHttpClient`][__link4] implements [`HttpClient`][__link5] on top of a [`fetch::HttpClient`][__link6], so Azure SDK pipelines run over `fetch` and benefit from its resilience and observability. * [`SpawnerRuntime`][__link7] implements [`AsyncRuntime`][__link8] on top of an @@ -57,12 +57,12 @@ fn install_runtime(spawner: Spawner, clock: Clock) { This crate was developed as part of The Oxidizer Project. Browse this crate's source code. - [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbLiTyV0MU86EbZU15e0PmecoboQ9jo59bnAEbyDXw04U13GlhYvRhcoQbKO4oAJVSTNsbaMx08xFfCTQbKvuvqbtNTBYb1G5JrfJCnSBhZISCaGFueXNwYXduZTAuNS4zgmphenVyZV9jb3JlZTEuMC4wgmVmZXRjaGYwLjExLjCCa2ZldGNoX2F6dXJlZTAuMS4w + [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbLiTyV0MU86EbZU15e0PmecoboQ9jo59bnAEbyDXw04U13GlhYvRhcoQb4rPUECgOtb8b7c0DCTHFI70bcDYCpsFpNREbGe-lL-mrdYphZISCaGFueXNwYXduZTAuNS4zgmphenVyZV9jb3JlZTEuMC4wgmVmZXRjaGYwLjExLjCCa2ZldGNoX2F6dXJlZTAuMS4w [__link0]: https://crates.io/crates/fetch/0.11.0 [__link1]: https://crates.io/crates/anyspawn/0.5.3 [__link2]: https://docs.rs/azure_core/1.0.0/azure_core/?search=http::HttpClient [__link3]: https://docs.rs/azure_core/1.0.0/azure_core/?search=async_runtime::AsyncRuntime - [__link4]: https://docs.rs/fetch_azure/0.1.0/fetch_azure/struct.FetchHttpClient.html + [__link4]: https://docs.rs/fetch_azure/0.1.0/fetch_azure/struct.AzureHttpClient.html [__link5]: https://docs.rs/azure_core/1.0.0/azure_core/?search=http::HttpClient [__link6]: https://docs.rs/fetch/0.11.0/fetch/?search=HttpClient [__link7]: https://docs.rs/fetch_azure/0.1.0/fetch_azure/struct.SpawnerRuntime.html diff --git a/crates/fetch_azure/src/lib.rs b/crates/fetch_azure/src/lib.rs index cf1533d8e..ce2d6fd12 100644 --- a/crates/fetch_azure/src/lib.rs +++ b/crates/fetch_azure/src/lib.rs @@ -13,7 +13,7 @@ //! yielding behind the [`azure_core::async_runtime::AsyncRuntime`] trait. This //! crate provides adapters for both: //! -//! - [`FetchHttpClient`] implements [`HttpClient`] on top of a +//! - [`AzureHttpClient`] implements [`HttpClient`] on top of a //! [`fetch::HttpClient`], so Azure SDK pipelines run over `fetch` and benefit //! from its resilience and observability. //! - [`SpawnerRuntime`] implements [`AsyncRuntime`] on top of an @@ -68,16 +68,16 @@ use tick::Clock; /// An [`HttpClient`] that uses a [`fetch::HttpClient`] as its transport. /// -/// Construct one from an existing `fetch` client with [`FetchHttpClient::new`] +/// Construct one from an existing `fetch` client with [`AzureHttpClient::new`] /// (or via [`From`]) and pass it to the Azure SDK wherever a /// `dyn HttpClient` is expected. See [`new_http_client`] for a convenience that /// returns an `Arc` directly. #[derive(Debug, Clone)] -pub struct FetchHttpClient { +pub struct AzureHttpClient { client: fetch::HttpClient, } -impl FetchHttpClient { +impl AzureHttpClient { /// Creates a new adapter that forwards requests to the given `fetch` client. #[must_use] pub const fn new(client: fetch::HttpClient) -> Self { @@ -138,7 +138,7 @@ impl FetchHttpClient { } } -impl From for FetchHttpClient { +impl From for AzureHttpClient { fn from(client: fetch::HttpClient) -> Self { Self::new(client) } @@ -150,11 +150,11 @@ impl From for FetchHttpClient { /// transport to the Azure SDK. #[must_use] pub fn new_http_client(client: fetch::HttpClient) -> Arc { - Arc::new(FetchHttpClient::new(client)) + Arc::new(AzureHttpClient::new(client)) } #[async_trait] -impl HttpClient for FetchHttpClient { +impl HttpClient for AzureHttpClient { async fn execute_request(&self, request: &Request) -> azure_core::Result { let request = self.to_fetch_request(request)?; diff --git a/crates/fetch_azure/tests/adapter.rs b/crates/fetch_azure/tests/adapter.rs index df38f3d9a..2e2f5ba65 100644 --- a/crates/fetch_azure/tests/adapter.rs +++ b/crates/fetch_azure/tests/adapter.rs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Integration tests for [`fetch_azure::FetchHttpClient`]. +//! Integration tests for [`fetch_azure::AzureHttpClient`]. //! //! These exercise the adapter end-to-end using `fetch`'s `FakeHandler`, so no //! real network access is required. @@ -22,7 +22,7 @@ use azure_core::stream::{BytesStream, SeekableStream}; use azure_core::time::Duration; use fetch::fake::FakeHandler; use fetch::{HttpClient as FetchClient, HttpResponseBuilder}; -use fetch_azure::{FetchHttpClient, SpawnerRuntime, new_async_runtime, new_http_client}; +use fetch_azure::{AzureHttpClient, SpawnerRuntime, new_async_runtime, new_http_client}; use futures::io::AsyncRead; use tick::Clock; @@ -44,7 +44,7 @@ async fn execute_request_maps_status_headers_and_body() { .text("world") .build() }); - let client = FetchHttpClient::new(FetchClient::new_fake(handler)); + let client = AzureHttpClient::new(FetchClient::new_fake(handler)); let response = client.execute_request(&request(Method::Get)).await.unwrap(); @@ -66,7 +66,7 @@ async fn execute_request_forwards_method_and_bytes_body() { let body = request.into_body().into_bytes().await?; HttpResponseBuilder::new_fake().status(200u16).bytes(body).build() }); - let client = FetchHttpClient::new(FetchClient::new_fake(handler)); + let client = AzureHttpClient::new(FetchClient::new_fake(handler)); let mut request = request(Method::Post); request.set_body(Bytes::from_static(b"payload")); @@ -84,7 +84,7 @@ async fn execute_request_forwards_seekable_stream_body() { let body = request.into_body().into_bytes().await?; HttpResponseBuilder::new_fake().status(200u16).bytes(body).build() }); - let client = FetchHttpClient::new(FetchClient::new_fake(handler)); + let client = AzureHttpClient::new(FetchClient::new_fake(handler)); let mut request = request(Method::Put); request.set_body(BytesStream::new(Bytes::from_static(b"streamed"))); @@ -103,7 +103,7 @@ async fn execute_request_forwards_request_headers() { let status = if forwarded { 200u16 } else { 400u16 }; HttpResponseBuilder::new_fake().status(status).build() }); - let client = FetchHttpClient::new(FetchClient::new_fake(handler)); + let client = AzureHttpClient::new(FetchClient::new_fake(handler)); let mut request = request(Method::Get); request.insert_header("x-correlation", "abc123"); @@ -121,7 +121,7 @@ async fn execute_request_maps_all_methods() { let status = if request.method().as_str() == expected { 200u16 } else { 400u16 }; HttpResponseBuilder::new_fake().status(status).build() }); - let client = FetchHttpClient::new(FetchClient::new_fake(handler)); + let client = AzureHttpClient::new(FetchClient::new_fake(handler)); let response = client.execute_request(&request(method)).await.unwrap(); @@ -132,7 +132,7 @@ async fn execute_request_maps_all_methods() { #[tokio::test] async fn execute_request_maps_transport_error() { let handler = FakeHandler::from_error_fn(|_request| fetch::HttpError::unavailable("simulated transport failure")); - let client = FetchHttpClient::new(FetchClient::new_fake(handler)); + let client = AzureHttpClient::new(FetchClient::new_fake(handler)); let error = client.execute_request(&request(Method::Get)).await.unwrap_err(); @@ -153,12 +153,12 @@ async fn new_http_client_returns_dyn_client() { #[tokio::test] async fn from_fetch_client_and_inner_round_trip() { - let adapter = FetchHttpClient::from(FetchClient::new_fake(status_handler(200))); + let adapter = AzureHttpClient::from(FetchClient::new_fake(status_handler(200))); // `inner` exposes the wrapped client and `into_inner` returns it unchanged. let _ = adapter.inner(); let recovered = adapter.into_inner(); - let adapter = FetchHttpClient::new(recovered); + let adapter = AzureHttpClient::new(recovered); let response = adapter.execute_request(&request(Method::Get)).await.unwrap(); assert_eq!(response.status(), 200u16); @@ -166,7 +166,7 @@ async fn from_fetch_client_and_inner_round_trip() { #[tokio::test] async fn execute_request_maps_request_build_failure() { - let client = FetchHttpClient::new(FetchClient::new_fake(status_handler(200))); + let client = AzureHttpClient::new(FetchClient::new_fake(status_handler(200))); // A header value containing a control character is rejected by the `http` // crate when the fetch request is built, exercising the DataConversion path. @@ -193,7 +193,7 @@ async fn execute_request_skips_non_utf8_response_headers() { .header("x-binary", binary) .build() }); - let client = FetchHttpClient::new(FetchClient::new_fake(handler)); + let client = AzureHttpClient::new(FetchClient::new_fake(handler)); let response = client.execute_request(&request(Method::Get)).await.unwrap(); @@ -208,7 +208,7 @@ async fn execute_request_maps_seekable_stream_read_error() { request.into_body().into_bytes().await?; HttpResponseBuilder::new_fake().status(200u16).build() }); - let client = FetchHttpClient::new(FetchClient::new_fake(handler)); + let client = AzureHttpClient::new(FetchClient::new_fake(handler)); let mut request = request(Method::Post); request.set_body(Body::SeekableStream(Box::new(ErroringStream))); @@ -230,7 +230,7 @@ async fn execute_request_maps_response_body_read_error() { ); HttpResponseBuilder::new_fake().status(200u16).body(body).build() }); - let client = FetchHttpClient::new(FetchClient::new_fake(handler)); + let client = AzureHttpClient::new(FetchClient::new_fake(handler)); let response = client.execute_request(&request(Method::Get)).await.unwrap(); let error = response.into_body().collect().await.unwrap_err(); From 18525b2c356717f6d4e8dee7e68dde4463dace00 Mon Sep 17 00:00:00 2001 From: Martin Tomka Date: Fri, 12 Jun 2026 12:07:51 +0200 Subject: [PATCH 09/22] refactor(fetch_azure): replace new_http_client with From conversions Drop the new_http_client free function in favor of From impls: From for AzureHttpClient (unchanged) and a new From for Arc, so callers write AzureHttpClient::from(client).into(). A direct From for Arc is not possible under the orphan rules, so the Arc conversion goes through the local type. lib.rs remains at 100% coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/fetch_azure/README.md | 6 ++-- .../fetch_azure/examples/azure_transport.rs | 6 ++-- crates/fetch_azure/src/lib.rs | 29 +++++++++++-------- crates/fetch_azure/tests/adapter.rs | 6 ++-- 4 files changed, 26 insertions(+), 21 deletions(-) diff --git a/crates/fetch_azure/README.md b/crates/fetch_azure/README.md index 8a2248603..453a03887 100644 --- a/crates/fetch_azure/README.md +++ b/crates/fetch_azure/README.md @@ -36,12 +36,12 @@ use anyspawn::Spawner; use azure_core::async_runtime::{AsyncRuntime, set_async_runtime}; use azure_core::http::HttpClient; use fetch::HttpClient as FetchClient; -use fetch_azure::{new_async_runtime, new_http_client}; +use fetch_azure::{AzureHttpClient, new_async_runtime}; use tick::Clock; // Adapt a `fetch` client into an Azure SDK transport. fn transport(client: FetchClient) -> Arc { - new_http_client(client) + AzureHttpClient::from(client).into() } // Install an `anyspawn`-backed async runtime (sleeping on a `tick::Clock`). @@ -57,7 +57,7 @@ fn install_runtime(spawner: Spawner, clock: Clock) { This crate was developed as part of The Oxidizer Project. Browse this crate's source code. - [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbLiTyV0MU86EbZU15e0PmecoboQ9jo59bnAEbyDXw04U13GlhYvRhcoQb4rPUECgOtb8b7c0DCTHFI70bcDYCpsFpNREbGe-lL-mrdYphZISCaGFueXNwYXduZTAuNS4zgmphenVyZV9jb3JlZTEuMC4wgmVmZXRjaGYwLjExLjCCa2ZldGNoX2F6dXJlZTAuMS4w + [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbLiTyV0MU86EbZU15e0PmecoboQ9jo59bnAEbyDXw04U13GlhYvRhcoQb43XIV8wWsU0bsTMRDzwuHYQbSZRHrVnQxcobSLO2bBXAE3phZISCaGFueXNwYXduZTAuNS4zgmphenVyZV9jb3JlZTEuMC4wgmVmZXRjaGYwLjExLjCCa2ZldGNoX2F6dXJlZTAuMS4w [__link0]: https://crates.io/crates/fetch/0.11.0 [__link1]: https://crates.io/crates/anyspawn/0.5.3 [__link2]: https://docs.rs/azure_core/1.0.0/azure_core/?search=http::HttpClient diff --git a/crates/fetch_azure/examples/azure_transport.rs b/crates/fetch_azure/examples/azure_transport.rs index 636b14c4d..61a8eee98 100644 --- a/crates/fetch_azure/examples/azure_transport.rs +++ b/crates/fetch_azure/examples/azure_transport.rs @@ -2,7 +2,7 @@ // Licensed under the MIT License. //! Adapts a Tokio-based [`fetch::HttpClient`] into an Azure SDK transport and -//! issues a request through the [`typespec_client_core::http::HttpClient`] trait. +//! issues a request through the [`azure_core::http::HttpClient`] trait. //! //! Run with: `cargo run --example azure_transport` @@ -10,13 +10,13 @@ use std::sync::Arc; use azure_core::http::{HttpClient, Method, Request, Url}; use fetch::HttpClient as FetchClient; -use fetch_azure::new_http_client; +use fetch_azure::AzureHttpClient; #[tokio::main] async fn main() -> Result<(), Box> { // Build a `fetch` client (Tokio runtime + rustls TLS) and adapt it so it can // be used wherever the Azure SDK expects an `Arc` transport. - let transport: Arc = new_http_client(FetchClient::new_tokio()); + let transport: Arc = AzureHttpClient::from(FetchClient::new_tokio()).into(); // In a real application you would hand `transport` to an Azure SDK client's // options. Here we drive it directly to show the round-trip. diff --git a/crates/fetch_azure/src/lib.rs b/crates/fetch_azure/src/lib.rs index ce2d6fd12..bfdf9a336 100644 --- a/crates/fetch_azure/src/lib.rs +++ b/crates/fetch_azure/src/lib.rs @@ -29,12 +29,12 @@ //! use azure_core::async_runtime::{AsyncRuntime, set_async_runtime}; //! use azure_core::http::HttpClient; //! use fetch::HttpClient as FetchClient; -//! use fetch_azure::{new_async_runtime, new_http_client}; +//! use fetch_azure::{AzureHttpClient, new_async_runtime}; //! use tick::Clock; //! //! // Adapt a `fetch` client into an Azure SDK transport. //! fn transport(client: FetchClient) -> Arc { -//! new_http_client(client) +//! AzureHttpClient::from(client).into() //! } //! //! // Install an `anyspawn`-backed async runtime (sleeping on a `tick::Clock`). @@ -69,9 +69,17 @@ use tick::Clock; /// An [`HttpClient`] that uses a [`fetch::HttpClient`] as its transport. /// /// Construct one from an existing `fetch` client with [`AzureHttpClient::new`] -/// (or via [`From`]) and pass it to the Azure SDK wherever a -/// `dyn HttpClient` is expected. See [`new_http_client`] for a convenience that -/// returns an `Arc` directly. +/// (or via [`From`]), then convert it into an `Arc` via [`From`] +/// / [`Into`] to hand to the Azure SDK: +/// +/// ``` +/// # use std::sync::Arc; +/// # use azure_core::http::HttpClient; +/// # use fetch_azure::AzureHttpClient; +/// # fn wrap(client: fetch::HttpClient) -> Arc { +/// AzureHttpClient::from(client).into() +/// # } +/// ``` #[derive(Debug, Clone)] pub struct AzureHttpClient { client: fetch::HttpClient, @@ -144,13 +152,10 @@ impl From for AzureHttpClient { } } -/// Wraps a [`fetch::HttpClient`] as an `Arc`. -/// -/// This is a convenience for the common case of handing a `fetch`-backed -/// transport to the Azure SDK. -#[must_use] -pub fn new_http_client(client: fetch::HttpClient) -> Arc { - Arc::new(AzureHttpClient::new(client)) +impl From for Arc { + fn from(client: AzureHttpClient) -> Self { + Arc::new(client) + } } #[async_trait] diff --git a/crates/fetch_azure/tests/adapter.rs b/crates/fetch_azure/tests/adapter.rs index 2e2f5ba65..af0b0675c 100644 --- a/crates/fetch_azure/tests/adapter.rs +++ b/crates/fetch_azure/tests/adapter.rs @@ -22,7 +22,7 @@ use azure_core::stream::{BytesStream, SeekableStream}; use azure_core::time::Duration; use fetch::fake::FakeHandler; use fetch::{HttpClient as FetchClient, HttpResponseBuilder}; -use fetch_azure::{AzureHttpClient, SpawnerRuntime, new_async_runtime, new_http_client}; +use fetch_azure::{AzureHttpClient, SpawnerRuntime, new_async_runtime}; use futures::io::AsyncRead; use tick::Clock; @@ -143,8 +143,8 @@ async fn execute_request_maps_transport_error() { } #[tokio::test] -async fn new_http_client_returns_dyn_client() { - let client = new_http_client(FetchClient::new_fake(status_handler(202))); +async fn azure_http_client_converts_into_dyn_client() { + let client: Arc = AzureHttpClient::from(FetchClient::new_fake(status_handler(202))).into(); let response = client.execute_request(&request(Method::Get)).await.unwrap(); From b46b4020d156c2c6835cddd9e1c6cdbeb000a279 Mon Sep 17 00:00:00 2001 From: Martin Tomka Date: Fri, 12 Jun 2026 12:18:53 +0200 Subject: [PATCH 10/22] refactor(fetch_azure): split modules and fix runtime cancellation Address PR review feedback: - move AzureHttpClient into a client module - move the runtime into a runtime module and rename SpawnerRuntime to Runtime - fix cancellation: spawn tasks wrapped in futures::future::Abortable and abort via AbortHandle so aborting wakes pending waiters, instead of the flag-based approach that left parked awaiters hanging lib.rs is now a thin module root; client.rs and runtime.rs are each at 100% coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/fetch_azure/CHANGELOG.md | 2 +- crates/fetch_azure/README.md | 14 +- crates/fetch_azure/src/client.rs | 157 ++++++++++++++++ crates/fetch_azure/src/lib.rs | 274 +--------------------------- crates/fetch_azure/src/runtime.rs | 119 ++++++++++++ crates/fetch_azure/tests/adapter.rs | 14 +- 6 files changed, 298 insertions(+), 282 deletions(-) create mode 100644 crates/fetch_azure/src/client.rs create mode 100644 crates/fetch_azure/src/runtime.rs diff --git a/crates/fetch_azure/CHANGELOG.md b/crates/fetch_azure/CHANGELOG.md index 084e56d46..af0d305ca 100644 --- a/crates/fetch_azure/CHANGELOG.md +++ b/crates/fetch_azure/CHANGELOG.md @@ -8,5 +8,5 @@ Oxidizer stack: - `FetchHttpClient` implements `azure_core::http::HttpClient` on top of a `fetch::HttpClient` transport. - - `SpawnerRuntime` implements `azure_core::async_runtime::AsyncRuntime` on top + - `Runtime` implements `azure_core::async_runtime::AsyncRuntime` on top of an `anyspawn::Spawner` (spawning) and a `tick::Clock` (sleeping). diff --git a/crates/fetch_azure/README.md b/crates/fetch_azure/README.md index 453a03887..ffaa0d8a7 100644 --- a/crates/fetch_azure/README.md +++ b/crates/fetch_azure/README.md @@ -20,12 +20,11 @@ The Azure SDK abstracts its HTTP transport behind the yielding behind the [`azure_core::async_runtime::AsyncRuntime`][__link3] trait. This crate provides adapters for both: -* [`AzureHttpClient`][__link4] implements [`HttpClient`][__link5] on top of a +* [`AzureHttpClient`][__link4] implements [`azure_core::http::HttpClient`][__link5] on top of a [`fetch::HttpClient`][__link6], so Azure SDK pipelines run over `fetch` and benefit from its resilience and observability. -* [`SpawnerRuntime`][__link7] implements [`AsyncRuntime`][__link8] on top of an - [`anyspawn::Spawner`][__link9], so the Azure SDK spawns and sleeps on the runtime of - your choice. +* [`Runtime`][__link7] implements [`azure_core::async_runtime::AsyncRuntime`][__link8] on top of + an [`anyspawn::Spawner`][__link9] (spawning) and a [`tick::Clock`][__link10] (sleeping). ## Example @@ -57,14 +56,15 @@ fn install_runtime(spawner: Spawner, clock: Clock) { This crate was developed as part of The Oxidizer Project. Browse this crate's source code. - [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbLiTyV0MU86EbZU15e0PmecoboQ9jo59bnAEbyDXw04U13GlhYvRhcoQb43XIV8wWsU0bsTMRDzwuHYQbSZRHrVnQxcobSLO2bBXAE3phZISCaGFueXNwYXduZTAuNS4zgmphenVyZV9jb3JlZTEuMC4wgmVmZXRjaGYwLjExLjCCa2ZldGNoX2F6dXJlZTAuMS4w + [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbLiTyV0MU86EbZU15e0PmecoboQ9jo59bnAEbyDXw04U13GlhYvRhcoQbrqIoJ8N-P7Mbeo4KsHTJWykbZMzMdpJ5oFcbCmGbFqcAa-lhZIWCaGFueXNwYXduZTAuNS4zgmphenVyZV9jb3JlZTEuMC4wgmVmZXRjaGYwLjExLjCCa2ZldGNoX2F6dXJlZTAuMS4wgmR0aWNrZTAuMy4z [__link0]: https://crates.io/crates/fetch/0.11.0 [__link1]: https://crates.io/crates/anyspawn/0.5.3 + [__link10]: https://docs.rs/tick/0.3.3/tick/?search=Clock [__link2]: https://docs.rs/azure_core/1.0.0/azure_core/?search=http::HttpClient [__link3]: https://docs.rs/azure_core/1.0.0/azure_core/?search=async_runtime::AsyncRuntime - [__link4]: https://docs.rs/fetch_azure/0.1.0/fetch_azure/struct.AzureHttpClient.html + [__link4]: https://docs.rs/fetch_azure/0.1.0/fetch_azure/?search=AzureHttpClient [__link5]: https://docs.rs/azure_core/1.0.0/azure_core/?search=http::HttpClient [__link6]: https://docs.rs/fetch/0.11.0/fetch/?search=HttpClient - [__link7]: https://docs.rs/fetch_azure/0.1.0/fetch_azure/struct.SpawnerRuntime.html + [__link7]: https://docs.rs/fetch_azure/0.1.0/fetch_azure/?search=Runtime [__link8]: https://docs.rs/azure_core/1.0.0/azure_core/?search=async_runtime::AsyncRuntime [__link9]: https://docs.rs/anyspawn/0.5.3/anyspawn/?search=Spawner diff --git a/crates/fetch_azure/src/client.rs b/crates/fetch_azure/src/client.rs new file mode 100644 index 000000000..1fbf5c1fb --- /dev/null +++ b/crates/fetch_azure/src/client.rs @@ -0,0 +1,157 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The [`AzureHttpClient`] transport adapter. + +use std::collections::HashMap; +use std::sync::Arc; + +use async_trait::async_trait; +use azure_core::error::{Error, ErrorKind}; +use azure_core::http::headers::{HeaderName, HeaderValue, Headers}; +use azure_core::http::request::{Body, Request}; +use azure_core::http::response::PinnedStream; +use azure_core::http::{AsyncRawResponse, HttpClient}; +use bytesbuf::BytesView; +use futures::{StreamExt as _, TryStreamExt as _}; +use layered::Service as _; + +/// An [`HttpClient`] that uses a [`fetch::HttpClient`] as its transport. +/// +/// Construct one from an existing `fetch` client with [`AzureHttpClient::new`] +/// (or via [`From`]), then convert it into an `Arc` via [`From`] +/// / [`Into`] to hand to the Azure SDK: +/// +/// ``` +/// # use std::sync::Arc; +/// # use azure_core::http::HttpClient; +/// # use fetch_azure::AzureHttpClient; +/// # fn wrap(client: fetch::HttpClient) -> Arc { +/// AzureHttpClient::from(client).into() +/// # } +/// ``` +#[derive(Debug, Clone)] +pub struct AzureHttpClient { + client: fetch::HttpClient, +} + +impl AzureHttpClient { + /// Creates a new adapter that forwards requests to the given `fetch` client. + #[must_use] + pub const fn new(client: fetch::HttpClient) -> Self { + Self { client } + } + + /// Returns a reference to the wrapped [`fetch::HttpClient`]. + #[must_use] + pub const fn inner(&self) -> &fetch::HttpClient { + &self.client + } + + /// Consumes the adapter and returns the wrapped [`fetch::HttpClient`]. + #[must_use] + pub fn into_inner(self) -> fetch::HttpClient { + self.client + } + + /// Converts a typespec [`Request`] into a `fetch` request. + fn to_fetch_request(&self, request: &Request) -> azure_core::Result { + // `Method::as_str` yields a canonical token (e.g. "GET") that `fetch`'s + // builder parses into an `http::Method`; this avoids matching on the + // `#[non_exhaustive]` typespec `Method` enum. + let mut builder = self.client.request(request.method().as_str(), request.url().as_str()); + + for (name, value) in request.headers().iter() { + builder = builder.header(name.as_str(), value.as_str()); + } + + builder.body(self.to_fetch_body(request.body())).build().map_err(|error| { + Error::with_error( + ErrorKind::DataConversion, + error, + "failed to convert the Azure request into a fetch request", + ) + }) + } + + /// Converts a typespec request [`Body`] into a `fetch` [`HttpBody`](fetch::HttpBody). + /// + /// Empty byte bodies reuse a shared empty body, and non-empty byte bodies are + /// wrapped without copying. Seekable streams are forwarded as a chunk stream. + fn to_fetch_body(&self, body: &Body) -> fetch::HttpBody { + let builder: &fetch::HttpBodyBuilder = self.client.as_ref(); + + match body { + Body::Bytes(bytes) if bytes.is_empty() => builder.empty(), + Body::Bytes(bytes) => builder.bytes(BytesView::from(bytes.clone())), + Body::SeekableStream(stream) => { + let stream = stream.clone().map(|chunk| { + chunk + .map(BytesView::from) + .map_err(|error| fetch::HttpError::unavailable(format!("failed to read the Azure request body: {error}"))) + }); + builder.stream(stream, &fetch::options::HttpBodyOptions::default()) + } + } + } +} + +impl From for AzureHttpClient { + fn from(client: fetch::HttpClient) -> Self { + Self::new(client) + } +} + +impl From for Arc { + fn from(client: AzureHttpClient) -> Self { + Arc::new(client) + } +} + +#[async_trait] +impl HttpClient for AzureHttpClient { + async fn execute_request(&self, request: &Request) -> azure_core::Result { + let request = self.to_fetch_request(request)?; + + let response = self + .client + .execute(request) + .await + .map_err(|error| Error::with_error(ErrorKind::Io, error, "the fetch HTTP client failed to execute the request"))?; + + Ok(to_async_raw_response(response)) + } +} + +/// Converts a `fetch` [`HttpResponse`](fetch::HttpResponse) into an [`AsyncRawResponse`]. +fn to_async_raw_response(response: fetch::HttpResponse) -> AsyncRawResponse { + let (parts, body) = response.into_parts(); + let status = parts.status.as_u16().into(); + let headers = to_headers(&parts.headers); + + let body = body + .into_stream() + .map_ok(|view| view.to_bytes()) + .map_err(|error| Error::with_error(ErrorKind::Io, error, "failed to read the response body")); + let body: PinnedStream = Box::pin(body); + + AsyncRawResponse::new(status, headers, body) +} + +/// Converts an [`http::HeaderMap`] into [`Headers`]. +/// +/// Header values that are not valid UTF-8 are skipped, mirroring the behavior +/// of the built-in `reqwest` transport in the Azure SDK. +fn to_headers(map: &http::HeaderMap) -> Headers { + let headers = map + .iter() + .filter_map(|(name, value)| { + value + .to_str() + .ok() + .map(|value| (HeaderName::from(name.as_str().to_owned()), HeaderValue::from(value.to_owned()))) + }) + .collect::>(); + + Headers::from(headers) +} diff --git a/crates/fetch_azure/src/lib.rs b/crates/fetch_azure/src/lib.rs index bfdf9a336..0f36006b9 100644 --- a/crates/fetch_azure/src/lib.rs +++ b/crates/fetch_azure/src/lib.rs @@ -13,12 +13,11 @@ //! yielding behind the [`azure_core::async_runtime::AsyncRuntime`] trait. This //! crate provides adapters for both: //! -//! - [`AzureHttpClient`] implements [`HttpClient`] on top of a +//! - [`AzureHttpClient`] implements [`azure_core::http::HttpClient`] on top of a //! [`fetch::HttpClient`], so Azure SDK pipelines run over `fetch` and benefit //! from its resilience and observability. -//! - [`SpawnerRuntime`] implements [`AsyncRuntime`] on top of an -//! [`anyspawn::Spawner`], so the Azure SDK spawns and sleeps on the runtime of -//! your choice. +//! - [`Runtime`] implements [`azure_core::async_runtime::AsyncRuntime`] on top of +//! an [`anyspawn::Spawner`] (spawning) and a [`tick::Clock`] (sleeping). //! //! # Example //! @@ -45,267 +44,8 @@ //! # let _ = (transport, install_runtime); //! ``` -use std::collections::HashMap; -use std::future::ready; -use std::pin::Pin; -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::task::{Context, Poll}; +mod client; +mod runtime; -use anyspawn::{JoinHandle, Spawner}; -use async_trait::async_trait; -use azure_core::async_runtime::{AbortableTask, AsyncRuntime, SpawnedTask, TaskFuture}; -use azure_core::error::{Error, ErrorKind}; -use azure_core::http::headers::{HeaderName, HeaderValue, Headers}; -use azure_core::http::request::{Body, Request}; -use azure_core::http::response::PinnedStream; -use azure_core::http::{AsyncRawResponse, HttpClient}; -use azure_core::time::Duration; -use bytesbuf::BytesView; -use futures::{StreamExt as _, TryStreamExt as _}; -use layered::Service as _; -use tick::Clock; - -/// An [`HttpClient`] that uses a [`fetch::HttpClient`] as its transport. -/// -/// Construct one from an existing `fetch` client with [`AzureHttpClient::new`] -/// (or via [`From`]), then convert it into an `Arc` via [`From`] -/// / [`Into`] to hand to the Azure SDK: -/// -/// ``` -/// # use std::sync::Arc; -/// # use azure_core::http::HttpClient; -/// # use fetch_azure::AzureHttpClient; -/// # fn wrap(client: fetch::HttpClient) -> Arc { -/// AzureHttpClient::from(client).into() -/// # } -/// ``` -#[derive(Debug, Clone)] -pub struct AzureHttpClient { - client: fetch::HttpClient, -} - -impl AzureHttpClient { - /// Creates a new adapter that forwards requests to the given `fetch` client. - #[must_use] - pub const fn new(client: fetch::HttpClient) -> Self { - Self { client } - } - - /// Returns a reference to the wrapped [`fetch::HttpClient`]. - #[must_use] - pub const fn inner(&self) -> &fetch::HttpClient { - &self.client - } - - /// Consumes the adapter and returns the wrapped [`fetch::HttpClient`]. - #[must_use] - pub fn into_inner(self) -> fetch::HttpClient { - self.client - } - - /// Converts a typespec [`Request`] into a `fetch` request. - fn to_fetch_request(&self, request: &Request) -> azure_core::Result { - // `Method::as_str` yields a canonical token (e.g. "GET") that `fetch`'s - // builder parses into an `http::Method`; this avoids matching on the - // `#[non_exhaustive]` typespec `Method` enum. - let mut builder = self.client.request(request.method().as_str(), request.url().as_str()); - - for (name, value) in request.headers().iter() { - builder = builder.header(name.as_str(), value.as_str()); - } - - builder.body(self.to_fetch_body(request.body())).build().map_err(|error| { - Error::with_error( - ErrorKind::DataConversion, - error, - "failed to convert the Azure request into a fetch request", - ) - }) - } - - /// Converts a typespec request [`Body`] into a `fetch` [`HttpBody`](fetch::HttpBody). - /// - /// Empty byte bodies reuse a shared empty body, and non-empty byte bodies are - /// wrapped without copying. Seekable streams are forwarded as a chunk stream. - fn to_fetch_body(&self, body: &Body) -> fetch::HttpBody { - let builder: &fetch::HttpBodyBuilder = self.client.as_ref(); - - match body { - Body::Bytes(bytes) if bytes.is_empty() => builder.empty(), - Body::Bytes(bytes) => builder.bytes(BytesView::from(bytes.clone())), - Body::SeekableStream(stream) => { - let stream = stream.clone().map(|chunk| { - chunk - .map(BytesView::from) - .map_err(|error| fetch::HttpError::unavailable(format!("failed to read the Azure request body: {error}"))) - }); - builder.stream(stream, &fetch::options::HttpBodyOptions::default()) - } - } - } -} - -impl From for AzureHttpClient { - fn from(client: fetch::HttpClient) -> Self { - Self::new(client) - } -} - -impl From for Arc { - fn from(client: AzureHttpClient) -> Self { - Arc::new(client) - } -} - -#[async_trait] -impl HttpClient for AzureHttpClient { - async fn execute_request(&self, request: &Request) -> azure_core::Result { - let request = self.to_fetch_request(request)?; - - let response = self - .client - .execute(request) - .await - .map_err(|error| Error::with_error(ErrorKind::Io, error, "the fetch HTTP client failed to execute the request"))?; - - Ok(to_async_raw_response(response)) - } -} - -/// Converts a `fetch` [`HttpResponse`](fetch::HttpResponse) into an [`AsyncRawResponse`]. -fn to_async_raw_response(response: fetch::HttpResponse) -> AsyncRawResponse { - let (parts, body) = response.into_parts(); - let status = parts.status.as_u16().into(); - let headers = to_headers(&parts.headers); - - let body = body - .into_stream() - .map_ok(|view| view.to_bytes()) - .map_err(|error| Error::with_error(ErrorKind::Io, error, "failed to read the response body")); - let body: PinnedStream = Box::pin(body); - - AsyncRawResponse::new(status, headers, body) -} - -/// Converts an [`http::HeaderMap`] into [`Headers`]. -/// -/// Header values that are not valid UTF-8 are skipped, mirroring the behavior -/// of the built-in `reqwest` transport in the Azure SDK. -fn to_headers(map: &http::HeaderMap) -> Headers { - let headers = map - .iter() - .filter_map(|(name, value)| { - value - .to_str() - .ok() - .map(|value| (HeaderName::from(name.as_str().to_owned()), HeaderValue::from(value.to_owned()))) - }) - .collect::>(); - - Headers::from(headers) -} - -/// An [`AsyncRuntime`] that spawns work on an [`anyspawn::Spawner`] and sleeps -/// on a [`tick::Clock`]. -/// -/// Construct one from an existing [`Spawner`] and [`Clock`] with -/// [`SpawnerRuntime::new`] (or via [`From`]) and install it as the Azure SDK -/// runtime with [`azure_core::async_runtime::set_async_runtime`]. See -/// [`new_async_runtime`] for a convenience that returns an -/// `Arc` directly. -#[derive(Debug, Clone)] -pub struct SpawnerRuntime { - spawner: Spawner, - clock: Clock, -} - -impl SpawnerRuntime { - /// Creates a new runtime that spawns work on `spawner` and sleeps on `clock`. - #[must_use] - pub const fn new(spawner: Spawner, clock: Clock) -> Self { - Self { spawner, clock } - } - - /// Returns a reference to the wrapped [`Spawner`]. - pub const fn spawner(&self) -> &Spawner { - &self.spawner - } - - /// Returns a reference to the wrapped [`Clock`]. - #[must_use] - pub const fn clock(&self) -> &Clock { - &self.clock - } -} - -impl From<(Spawner, Clock)> for SpawnerRuntime { - fn from((spawner, clock): (Spawner, Clock)) -> Self { - Self::new(spawner, clock) - } -} - -/// Wraps an [`anyspawn::Spawner`] and [`tick::Clock`] as an `Arc`. -/// -/// This is a convenience for installing a `fetch`-friendly runtime with -/// [`azure_core::async_runtime::set_async_runtime`]. -#[must_use] -pub fn new_async_runtime(spawner: Spawner, clock: Clock) -> Arc { - Arc::new(SpawnerRuntime::new(spawner, clock)) -} - -impl AsyncRuntime for SpawnerRuntime { - fn spawn(&self, f: TaskFuture) -> SpawnedTask { - Box::pin(SpawnerTask::new(self.spawner.spawn(f))) - } - - fn sleep(&self, duration: Duration) -> TaskFuture { - let clock = self.clock.clone(); - Box::pin(async move { - // `time::Duration` can be negative; clamp such values to zero. - let duration = std::time::Duration::try_from(duration).unwrap_or_default(); - clock.delay(duration).await; - }) - } - - fn yield_now(&self) -> TaskFuture { - std::thread::yield_now(); - Box::pin(ready(())) - } -} - -/// Adapts an [`anyspawn::JoinHandle`] into an [`AbortableTask`]. -struct SpawnerTask { - handle: JoinHandle<()>, - aborted: AtomicBool, -} - -impl SpawnerTask { - fn new(handle: JoinHandle<()>) -> Self { - Self { - handle, - aborted: AtomicBool::new(false), - } - } -} - -impl Future for SpawnerTask { - type Output = Result<(), Box>; - - fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - let this = self.get_mut(); - if this.aborted.load(Ordering::Acquire) { - return Poll::Ready(Ok(())); - } - Pin::new(&mut this.handle).poll(cx).map(Ok) - } -} - -impl AbortableTask for SpawnerTask { - fn abort(&self) { - // `anyspawn` join handles cannot cancel the underlying task, so mark the - // task aborted and resolve on the next poll. The spawned work may keep - // running, but the caller is no longer blocked on it. - self.aborted.store(true, Ordering::Release); - } -} +pub use client::AzureHttpClient; +pub use runtime::{Runtime, new_async_runtime}; diff --git a/crates/fetch_azure/src/runtime.rs b/crates/fetch_azure/src/runtime.rs new file mode 100644 index 000000000..92445e041 --- /dev/null +++ b/crates/fetch_azure/src/runtime.rs @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The [`Runtime`] async-runtime adapter. + +use std::future::ready; +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context, Poll}; + +use anyspawn::{JoinHandle, Spawner}; +use azure_core::async_runtime::{AbortableTask, AsyncRuntime, SpawnedTask, TaskFuture}; +use azure_core::time::Duration; +use futures::future::{AbortHandle, Abortable}; +use tick::Clock; + +/// An [`AsyncRuntime`] that spawns work on an [`anyspawn::Spawner`] and sleeps +/// on a [`tick::Clock`]. +/// +/// Construct one from an existing [`Spawner`] and [`Clock`] with +/// [`Runtime::new`] (or via [`From`]) and install it as the Azure SDK runtime +/// with [`azure_core::async_runtime::set_async_runtime`]. See +/// [`new_async_runtime`] for a convenience that returns an +/// `Arc` directly. +#[derive(Debug, Clone)] +pub struct Runtime { + spawner: Spawner, + clock: Clock, +} + +impl Runtime { + /// Creates a new runtime that spawns work on `spawner` and sleeps on `clock`. + #[must_use] + pub const fn new(spawner: Spawner, clock: Clock) -> Self { + Self { spawner, clock } + } + + /// Returns a reference to the wrapped [`Spawner`]. + pub const fn spawner(&self) -> &Spawner { + &self.spawner + } + + /// Returns a reference to the wrapped [`Clock`]. + #[must_use] + pub const fn clock(&self) -> &Clock { + &self.clock + } +} + +impl From<(Spawner, Clock)> for Runtime { + fn from((spawner, clock): (Spawner, Clock)) -> Self { + Self::new(spawner, clock) + } +} + +/// Wraps an [`anyspawn::Spawner`] and [`tick::Clock`] as an `Arc`. +/// +/// This is a convenience for installing a `fetch`-friendly runtime with +/// [`azure_core::async_runtime::set_async_runtime`]. +#[must_use] +pub fn new_async_runtime(spawner: Spawner, clock: Clock) -> Arc { + Arc::new(Runtime::new(spawner, clock)) +} + +impl AsyncRuntime for Runtime { + fn spawn(&self, f: TaskFuture) -> SpawnedTask { + // Wrap the task so that `abort` cancels it through `futures`: aborting + // wakes the spawned task, which resolves, which in turn wakes anyone + // awaiting the returned `SpawnedTask`. + let (abort_handle, abort_registration) = AbortHandle::new_pair(); + let task = Abortable::new(f, abort_registration); + let handle = self.spawner.spawn(async move { + // The `Aborted` result is expected when cancelled and carries no value. + let _ = task.await; + }); + Box::pin(RuntimeTask { handle, abort_handle }) + } + + fn sleep(&self, duration: Duration) -> TaskFuture { + let clock = self.clock.clone(); + Box::pin(async move { + // `time::Duration` can be negative; clamp such values to zero. + let duration = std::time::Duration::try_from(duration).unwrap_or_default(); + clock.delay(duration).await; + }) + } + + fn yield_now(&self) -> TaskFuture { + std::thread::yield_now(); + Box::pin(ready(())) + } +} + +/// Adapts an [`anyspawn::JoinHandle`] into an [`AbortableTask`]. +/// +/// Holds the [`AbortHandle`] of the spawned [`Abortable`] task so [`abort`] +/// can cancel work that is still pending and wake anyone awaiting it. +/// +/// [`abort`]: AbortableTask::abort +struct RuntimeTask { + handle: JoinHandle<()>, + abort_handle: AbortHandle, +} + +impl Future for RuntimeTask { + type Output = Result<(), Box>; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + Pin::new(&mut self.get_mut().handle).poll(cx).map(Ok) + } +} + +impl AbortableTask for RuntimeTask { + fn abort(&self) { + // Cancels the `Abortable` task, which wakes its executor, completes the + // join handle, and unblocks any pending waiter on this task. + self.abort_handle.abort(); + } +} diff --git a/crates/fetch_azure/tests/adapter.rs b/crates/fetch_azure/tests/adapter.rs index af0b0675c..94db1b2b7 100644 --- a/crates/fetch_azure/tests/adapter.rs +++ b/crates/fetch_azure/tests/adapter.rs @@ -22,7 +22,7 @@ use azure_core::stream::{BytesStream, SeekableStream}; use azure_core::time::Duration; use fetch::fake::FakeHandler; use fetch::{HttpClient as FetchClient, HttpResponseBuilder}; -use fetch_azure::{AzureHttpClient, SpawnerRuntime, new_async_runtime}; +use fetch_azure::{AzureHttpClient, Runtime, new_async_runtime}; use futures::io::AsyncRead; use tick::Clock; @@ -276,7 +276,7 @@ fn error_chain(error: &dyn std::error::Error) -> String { #[tokio::test] async fn runtime_spawn_runs_task_to_completion() { - let runtime = SpawnerRuntime::new(Spawner::new_tokio(), Clock::new_tokio()); + let runtime = Runtime::new(Spawner::new_tokio(), Clock::new_tokio()); let ran = Arc::new(AtomicBool::new(false)); let ran_in_task = Arc::clone(&ran); @@ -290,7 +290,7 @@ async fn runtime_spawn_runs_task_to_completion() { #[tokio::test] async fn runtime_abort_resolves_without_waiting() { - let runtime = SpawnerRuntime::new(Spawner::new_tokio(), Clock::new_tokio()); + let runtime = Runtime::new(Spawner::new_tokio(), Clock::new_tokio()); // The task never completes on its own; aborting must let the await resolve. let task = runtime.spawn(Box::pin(std::future::pending::<()>())); @@ -300,14 +300,14 @@ async fn runtime_abort_resolves_without_waiting() { #[tokio::test] async fn runtime_sleep_completes() { - let runtime = SpawnerRuntime::new(Spawner::new_tokio(), Clock::new_tokio()); + let runtime = Runtime::new(Spawner::new_tokio(), Clock::new_tokio()); runtime.sleep(Duration::milliseconds(1)).await; } #[tokio::test] async fn runtime_yield_now_completes() { - let runtime = SpawnerRuntime::new(Spawner::new_tokio(), Clock::new_tokio()); + let runtime = Runtime::new(Spawner::new_tokio(), Clock::new_tokio()); runtime.yield_now().await; } @@ -321,10 +321,10 @@ async fn new_async_runtime_returns_dyn_runtime() { #[tokio::test] async fn runtime_from_spawner_clock_and_accessors_round_trip() { - let runtime = SpawnerRuntime::from((Spawner::new_tokio(), Clock::new_tokio())); + let runtime = Runtime::from((Spawner::new_tokio(), Clock::new_tokio())); // `spawner` and `clock` expose the wrapped components; rebuild from them. - let runtime = SpawnerRuntime::new(runtime.spawner().clone(), runtime.clock().clone()); + let runtime = Runtime::new(runtime.spawner().clone(), runtime.clock().clone()); runtime.yield_now().await; } From 66a10724218a7edda913263d8a4c8e013d000b7d Mon Sep 17 00:00:00 2001 From: Martin Tomka Date: Fri, 12 Jun 2026 12:45:51 +0200 Subject: [PATCH 11/22] refactor(fetch_azure): trim public API and split tests Address PR review feedback: - drop AzureHttpClient::inner and into_inner (not needed publicly) - drop the new_async_runtime free function; add From for Arc to mirror the client's From-based Arc conversion - split integration tests into tests/client.rs and tests/runtime.rs client.rs and runtime.rs remain at 100% coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/fetch_azure/README.md | 6 +- crates/fetch_azure/src/client.rs | 12 --- crates/fetch_azure/src/lib.rs | 6 +- crates/fetch_azure/src/runtime.rs | 18 ++--- .../tests/{adapter.rs => client.rs} | 79 +------------------ crates/fetch_azure/tests/runtime.rs | 71 +++++++++++++++++ 6 files changed, 87 insertions(+), 105 deletions(-) rename crates/fetch_azure/tests/{adapter.rs => client.rs} (78%) create mode 100644 crates/fetch_azure/tests/runtime.rs diff --git a/crates/fetch_azure/README.md b/crates/fetch_azure/README.md index ffaa0d8a7..d9c438ea5 100644 --- a/crates/fetch_azure/README.md +++ b/crates/fetch_azure/README.md @@ -35,7 +35,7 @@ use anyspawn::Spawner; use azure_core::async_runtime::{AsyncRuntime, set_async_runtime}; use azure_core::http::HttpClient; use fetch::HttpClient as FetchClient; -use fetch_azure::{AzureHttpClient, new_async_runtime}; +use fetch_azure::{AzureHttpClient, Runtime}; use tick::Clock; // Adapt a `fetch` client into an Azure SDK transport. @@ -45,7 +45,7 @@ fn transport(client: FetchClient) -> Arc { // Install an `anyspawn`-backed async runtime (sleeping on a `tick::Clock`). fn install_runtime(spawner: Spawner, clock: Clock) { - let runtime: Arc = new_async_runtime(spawner, clock); + let runtime: Arc = Runtime::new(spawner, clock).into(); let _ = set_async_runtime(runtime); } ``` @@ -56,7 +56,7 @@ fn install_runtime(spawner: Spawner, clock: Clock) { This crate was developed as part of The Oxidizer Project. Browse this crate's source code. - [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbLiTyV0MU86EbZU15e0PmecoboQ9jo59bnAEbyDXw04U13GlhYvRhcoQbrqIoJ8N-P7Mbeo4KsHTJWykbZMzMdpJ5oFcbCmGbFqcAa-lhZIWCaGFueXNwYXduZTAuNS4zgmphenVyZV9jb3JlZTEuMC4wgmVmZXRjaGYwLjExLjCCa2ZldGNoX2F6dXJlZTAuMS4wgmR0aWNrZTAuMy4z + [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbLiTyV0MU86EbZU15e0PmecoboQ9jo59bnAEbyDXw04U13GlhYvRhcoQbKKsn8lFrPt8b0KAAJiBwTQ8bStgknxZYUFMb8x5TGN_lWzJhZIWCaGFueXNwYXduZTAuNS4zgmphenVyZV9jb3JlZTEuMC4wgmVmZXRjaGYwLjExLjCCa2ZldGNoX2F6dXJlZTAuMS4wgmR0aWNrZTAuMy4z [__link0]: https://crates.io/crates/fetch/0.11.0 [__link1]: https://crates.io/crates/anyspawn/0.5.3 [__link10]: https://docs.rs/tick/0.3.3/tick/?search=Clock diff --git a/crates/fetch_azure/src/client.rs b/crates/fetch_azure/src/client.rs index 1fbf5c1fb..1faca7f7a 100644 --- a/crates/fetch_azure/src/client.rs +++ b/crates/fetch_azure/src/client.rs @@ -42,18 +42,6 @@ impl AzureHttpClient { Self { client } } - /// Returns a reference to the wrapped [`fetch::HttpClient`]. - #[must_use] - pub const fn inner(&self) -> &fetch::HttpClient { - &self.client - } - - /// Consumes the adapter and returns the wrapped [`fetch::HttpClient`]. - #[must_use] - pub fn into_inner(self) -> fetch::HttpClient { - self.client - } - /// Converts a typespec [`Request`] into a `fetch` request. fn to_fetch_request(&self, request: &Request) -> azure_core::Result { // `Method::as_str` yields a canonical token (e.g. "GET") that `fetch`'s diff --git a/crates/fetch_azure/src/lib.rs b/crates/fetch_azure/src/lib.rs index 0f36006b9..0c27fe168 100644 --- a/crates/fetch_azure/src/lib.rs +++ b/crates/fetch_azure/src/lib.rs @@ -28,7 +28,7 @@ //! use azure_core::async_runtime::{AsyncRuntime, set_async_runtime}; //! use azure_core::http::HttpClient; //! use fetch::HttpClient as FetchClient; -//! use fetch_azure::{AzureHttpClient, new_async_runtime}; +//! use fetch_azure::{AzureHttpClient, Runtime}; //! use tick::Clock; //! //! // Adapt a `fetch` client into an Azure SDK transport. @@ -38,7 +38,7 @@ //! //! // Install an `anyspawn`-backed async runtime (sleeping on a `tick::Clock`). //! fn install_runtime(spawner: Spawner, clock: Clock) { -//! let runtime: Arc = new_async_runtime(spawner, clock); +//! let runtime: Arc = Runtime::new(spawner, clock).into(); //! let _ = set_async_runtime(runtime); //! } //! # let _ = (transport, install_runtime); @@ -48,4 +48,4 @@ mod client; mod runtime; pub use client::AzureHttpClient; -pub use runtime::{Runtime, new_async_runtime}; +pub use runtime::Runtime; diff --git a/crates/fetch_azure/src/runtime.rs b/crates/fetch_azure/src/runtime.rs index 92445e041..152084016 100644 --- a/crates/fetch_azure/src/runtime.rs +++ b/crates/fetch_azure/src/runtime.rs @@ -18,10 +18,9 @@ use tick::Clock; /// on a [`tick::Clock`]. /// /// Construct one from an existing [`Spawner`] and [`Clock`] with -/// [`Runtime::new`] (or via [`From`]) and install it as the Azure SDK runtime -/// with [`azure_core::async_runtime::set_async_runtime`]. See -/// [`new_async_runtime`] for a convenience that returns an -/// `Arc` directly. +/// [`Runtime::new`] (or via [`From`]), then convert it into an +/// `Arc` via [`From`] / [`Into`] and install it with +/// [`azure_core::async_runtime::set_async_runtime`]. #[derive(Debug, Clone)] pub struct Runtime { spawner: Spawner, @@ -53,13 +52,10 @@ impl From<(Spawner, Clock)> for Runtime { } } -/// Wraps an [`anyspawn::Spawner`] and [`tick::Clock`] as an `Arc`. -/// -/// This is a convenience for installing a `fetch`-friendly runtime with -/// [`azure_core::async_runtime::set_async_runtime`]. -#[must_use] -pub fn new_async_runtime(spawner: Spawner, clock: Clock) -> Arc { - Arc::new(Runtime::new(spawner, clock)) +impl From for Arc { + fn from(runtime: Runtime) -> Self { + Arc::new(runtime) + } } impl AsyncRuntime for Runtime { diff --git a/crates/fetch_azure/tests/adapter.rs b/crates/fetch_azure/tests/client.rs similarity index 78% rename from crates/fetch_azure/tests/adapter.rs rename to crates/fetch_azure/tests/client.rs index 94db1b2b7..c19cdb10a 100644 --- a/crates/fetch_azure/tests/adapter.rs +++ b/crates/fetch_azure/tests/client.rs @@ -3,28 +3,23 @@ //! Integration tests for [`fetch_azure::AzureHttpClient`]. //! -//! These exercise the adapter end-to-end using `fetch`'s `FakeHandler`, so no -//! real network access is required. +//! These exercise the transport adapter end-to-end using `fetch`'s +//! `FakeHandler`, so no real network access is required. use std::pin::Pin; use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; use std::task::{Context, Poll}; -use anyspawn::Spawner; use async_trait::async_trait; use azure_core::Bytes; -use azure_core::async_runtime::AsyncRuntime; use azure_core::http::headers::HeaderName; use azure_core::http::request::{Body, Request}; use azure_core::http::{HttpClient, Method, Url}; use azure_core::stream::{BytesStream, SeekableStream}; -use azure_core::time::Duration; use fetch::fake::FakeHandler; use fetch::{HttpClient as FetchClient, HttpResponseBuilder}; -use fetch_azure::{AzureHttpClient, Runtime, new_async_runtime}; +use fetch_azure::AzureHttpClient; use futures::io::AsyncRead; -use tick::Clock; fn request(method: Method) -> Request { Request::new(Url::parse("https://example.com/path").expect("valid url"), method) @@ -151,19 +146,6 @@ async fn azure_http_client_converts_into_dyn_client() { assert_eq!(response.status(), 202u16); } -#[tokio::test] -async fn from_fetch_client_and_inner_round_trip() { - let adapter = AzureHttpClient::from(FetchClient::new_fake(status_handler(200))); - - // `inner` exposes the wrapped client and `into_inner` returns it unchanged. - let _ = adapter.inner(); - let recovered = adapter.into_inner(); - let adapter = AzureHttpClient::new(recovered); - - let response = adapter.execute_request(&request(Method::Get)).await.unwrap(); - assert_eq!(response.status(), 200u16); -} - #[tokio::test] async fn execute_request_maps_request_build_failure() { let client = AzureHttpClient::new(FetchClient::new_fake(status_handler(200))); @@ -273,58 +255,3 @@ fn error_chain(error: &dyn std::error::Error) -> String { } chain } - -#[tokio::test] -async fn runtime_spawn_runs_task_to_completion() { - let runtime = Runtime::new(Spawner::new_tokio(), Clock::new_tokio()); - let ran = Arc::new(AtomicBool::new(false)); - let ran_in_task = Arc::clone(&ran); - - let task = runtime.spawn(Box::pin(async move { - ran_in_task.store(true, Ordering::SeqCst); - })); - task.await.unwrap(); - - assert!(ran.load(Ordering::SeqCst)); -} - -#[tokio::test] -async fn runtime_abort_resolves_without_waiting() { - let runtime = Runtime::new(Spawner::new_tokio(), Clock::new_tokio()); - - // The task never completes on its own; aborting must let the await resolve. - let task = runtime.spawn(Box::pin(std::future::pending::<()>())); - task.abort(); - task.await.unwrap(); -} - -#[tokio::test] -async fn runtime_sleep_completes() { - let runtime = Runtime::new(Spawner::new_tokio(), Clock::new_tokio()); - - runtime.sleep(Duration::milliseconds(1)).await; -} - -#[tokio::test] -async fn runtime_yield_now_completes() { - let runtime = Runtime::new(Spawner::new_tokio(), Clock::new_tokio()); - - runtime.yield_now().await; -} - -#[tokio::test] -async fn new_async_runtime_returns_dyn_runtime() { - let runtime: Arc = new_async_runtime(Spawner::new_tokio(), Clock::new_tokio()); - - runtime.spawn(Box::pin(async {})).await.unwrap(); -} - -#[tokio::test] -async fn runtime_from_spawner_clock_and_accessors_round_trip() { - let runtime = Runtime::from((Spawner::new_tokio(), Clock::new_tokio())); - - // `spawner` and `clock` expose the wrapped components; rebuild from them. - let runtime = Runtime::new(runtime.spawner().clone(), runtime.clock().clone()); - - runtime.yield_now().await; -} diff --git a/crates/fetch_azure/tests/runtime.rs b/crates/fetch_azure/tests/runtime.rs new file mode 100644 index 000000000..dd82056ea --- /dev/null +++ b/crates/fetch_azure/tests/runtime.rs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Integration tests for [`fetch_azure::Runtime`]. +//! +//! These drive the runtime adapter on a real Tokio spawner and clock. + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use anyspawn::Spawner; +use azure_core::async_runtime::AsyncRuntime; +use azure_core::time::Duration; +use fetch_azure::Runtime; +use tick::Clock; + +#[tokio::test] +async fn runtime_spawn_runs_task_to_completion() { + let runtime = Runtime::new(Spawner::new_tokio(), Clock::new_tokio()); + let ran = Arc::new(AtomicBool::new(false)); + let ran_in_task = Arc::clone(&ran); + + let task = runtime.spawn(Box::pin(async move { + ran_in_task.store(true, Ordering::SeqCst); + })); + task.await.unwrap(); + + assert!(ran.load(Ordering::SeqCst)); +} + +#[tokio::test] +async fn runtime_abort_resolves_without_waiting() { + let runtime = Runtime::new(Spawner::new_tokio(), Clock::new_tokio()); + + // The task never completes on its own; aborting must wake the waiter so the + // await resolves rather than hanging forever. + let task = runtime.spawn(Box::pin(std::future::pending::<()>())); + task.abort(); + task.await.unwrap(); +} + +#[tokio::test] +async fn runtime_sleep_completes() { + let runtime = Runtime::new(Spawner::new_tokio(), Clock::new_tokio()); + + runtime.sleep(Duration::milliseconds(1)).await; +} + +#[tokio::test] +async fn runtime_yield_now_completes() { + let runtime = Runtime::new(Spawner::new_tokio(), Clock::new_tokio()); + + runtime.yield_now().await; +} + +#[tokio::test] +async fn runtime_converts_into_dyn_runtime() { + let runtime: Arc = Runtime::new(Spawner::new_tokio(), Clock::new_tokio()).into(); + + runtime.spawn(Box::pin(async {})).await.unwrap(); +} + +#[tokio::test] +async fn runtime_from_spawner_clock_and_accessors_round_trip() { + let runtime = Runtime::from((Spawner::new_tokio(), Clock::new_tokio())); + + // `spawner` and `clock` expose the wrapped components; rebuild from them. + let runtime = Runtime::new(runtime.spawner().clone(), runtime.clock().clone()); + + runtime.yield_now().await; +} From f29f42fc618fa61cac591ead3168a762b8c63f52 Mon Sep 17 00:00:00 2001 From: Martin Tomka Date: Fri, 12 Jun 2026 13:11:22 +0200 Subject: [PATCH 12/22] feat(fetch_azure): implement azure_identity::Executor for Runtime Add an optional zure-identity feature under which Runtime implements zure_identity::Executor, running developer-credential commands on the spawner's blocking pool. Add a tokio-based blob-listing example that wires the transport, executor, and DeveloperToolsCredential together. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 71 ++++++++++++++++++++++++ Cargo.toml | 2 + crates/fetch_azure/CHANGELOG.md | 5 +- crates/fetch_azure/Cargo.toml | 12 ++++ crates/fetch_azure/examples/blob_list.rs | 55 ++++++++++++++++++ crates/fetch_azure/src/runtime.rs | 18 ++++++ crates/fetch_azure/tests/runtime.rs | 24 ++++++++ 7 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 crates/fetch_azure/examples/blob_list.rs diff --git a/Cargo.lock b/Cargo.lock index 78c6502b8..cd50877c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -248,6 +248,28 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "async-task" version = "4.7.1" @@ -341,6 +363,42 @@ dependencies = [ "tracing", ] +[[package]] +name = "azure_identity" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32edf96b356ca7c51d7590c4925cc36efc3947a5da4468e8e0b25c56ecbb3de5" +dependencies = [ + "async-lock", + "async-trait", + "azure_core", + "futures", + "pin-project", + "serde", + "serde_json", + "time", + "tracing", + "url", +] + +[[package]] +name = "azure_storage_blob" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1756febbcca86c862ef718b983b505d08bd65a9bc984a915b0a16af4a4c3fe5b" +dependencies = [ + "async-stream", + "async-trait", + "azure_core", + "bytes", + "futures", + "percent-encoding", + "pin-project", + "serde", + "serde_json", + "time", +] + [[package]] name = "base64" version = "0.22.1" @@ -1215,6 +1273,8 @@ dependencies = [ "anyspawn", "async-trait", "azure_core", + "azure_identity", + "azure_storage_blob", "bytesbuf", "fetch", "futures", @@ -2862,6 +2922,16 @@ dependencies = [ "syn", ] +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quote" version = "1.0.45" @@ -3989,6 +4059,7 @@ dependencies = [ "base64", "bytes", "futures", + "quick-xml", "serde", "serde_json", "url", diff --git a/Cargo.toml b/Cargo.toml index fc77f9d21..e9045cc43 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -69,6 +69,8 @@ argh = { version = "0.1.13", default-features = false } async-once-cell = { version = "0.5.0", default-features = false } async-trait = { version = "0.1.89", default-features = false } azure_core = { version = "1.0.0", default-features = false } +azure_identity = { version = "1.0.0", default-features = false } +azure_storage_blob = { version = "1.0.0", default-features = false } base64 = { version = "0.22.0", default-features = false, features = ["alloc"] } bolero = { version = "0.13.4", default-features = false } bumpalo = { version = "3.20.2", default-features = false } diff --git a/crates/fetch_azure/CHANGELOG.md b/crates/fetch_azure/CHANGELOG.md index af0d305ca..520e6f828 100644 --- a/crates/fetch_azure/CHANGELOG.md +++ b/crates/fetch_azure/CHANGELOG.md @@ -6,7 +6,10 @@ - introduce `fetch_azure`, bundling two Azure SDK abstractions backed by the Oxidizer stack: - - `FetchHttpClient` implements `azure_core::http::HttpClient` on top of a + - `AzureHttpClient` implements `azure_core::http::HttpClient` on top of a `fetch::HttpClient` transport. - `Runtime` implements `azure_core::async_runtime::AsyncRuntime` on top of an `anyspawn::Spawner` (spawning) and a `tick::Clock` (sleeping). + - with the optional `azure-identity` feature, `Runtime` also implements + `azure_identity::Executor`, running credential subprocesses on the + `anyspawn::Spawner`. diff --git a/crates/fetch_azure/Cargo.toml b/crates/fetch_azure/Cargo.toml index 82745dc27..1d8d6d939 100644 --- a/crates/fetch_azure/Cargo.toml +++ b/crates/fetch_azure/Cargo.toml @@ -23,12 +23,18 @@ allowed_external_types = [ "fetch::*", "tick::*", # External dependencies (azure_core re-exports these typespec types) + "azure_identity::*", "typespec_client_core::*", ] [package.metadata.docs.rs] all-features = true +[features] +## Implement [`azure_identity::Executor`] for [`Runtime`], allowing it to run the +## subprocesses that developer credentials (e.g. the Azure CLI) rely on. +azure-identity = ["dep:azure_identity"] + [dependencies] # internal anyspawn = { workspace = true } @@ -40,6 +46,7 @@ tick = { workspace = true } # external async-trait = { workspace = true } azure_core = { workspace = true } +azure_identity = { workspace = true, optional = true } futures = { workspace = true, features = ["std"] } http = { workspace = true } @@ -52,8 +59,13 @@ tick = { path = "../tick", features = ["tokio"] } # external async-trait = { workspace = true } azure_core = { workspace = true } +azure_storage_blob = { workspace = true } futures = { workspace = true, features = ["std"] } tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +[[example]] +name = "blob_list" +required-features = ["azure-identity"] + [lints] workspace = true diff --git a/crates/fetch_azure/examples/blob_list.rs b/crates/fetch_azure/examples/blob_list.rs new file mode 100644 index 000000000..e5181909d --- /dev/null +++ b/crates/fetch_azure/examples/blob_list.rs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Lists blobs in an Azure Storage container using `fetch` as the transport and +//! an `anyspawn`/`tick`-backed [`Runtime`] as the credential executor. +//! +//! Set `AZURE_STORAGE_SERVICE_ENDPOINT` (and sign in with `az`/`azd`), then run: +//! `cargo run --example blob_list --features azure-identity` + +use std::env; +use std::sync::Arc; + +use anyspawn::Spawner; +use azure_core::credentials::TokenCredential; +use azure_core::http::{ClientOptions, Transport, Url}; +use azure_identity::{DeveloperToolsCredential, DeveloperToolsCredentialOptions, Executor}; +use azure_storage_blob::{BlobServiceClient, BlobServiceClientOptions}; +use fetch::HttpClient as FetchClient; +use fetch_azure::{AzureHttpClient, Runtime}; +use futures::TryStreamExt as _; +use tick::Clock; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let service_url: Url = env::var("AZURE_STORAGE_SERVICE_ENDPOINT")?.parse()?; + + // Run developer-credential subprocesses (e.g. the Azure CLI) on a + // tokio-backed `Runtime` used as the credential's `Executor`. + let executor: Arc = Arc::new(Runtime::new(Spawner::new_tokio(), Clock::new_tokio())); + let credential: Arc = + DeveloperToolsCredential::new(Some(DeveloperToolsCredentialOptions { executor: Some(executor) }))?; + + // Use a tokio `fetch` client as the Azure SDK transport. + let transport = Transport::new(AzureHttpClient::from(FetchClient::new_tokio()).into()); + let options = BlobServiceClientOptions { + client_options: ClientOptions { + transport: Some(transport), + ..Default::default() + }, + ..Default::default() + }; + + let client = BlobServiceClient::new(service_url, Some(credential), Some(options))?.blob_container_client("examples"); + + // Enumerate blobs in the "examples" container. + let mut pager = client.list_blobs(None)?; + while let Some(blob) = pager.try_next().await? { + let name = blob.name.as_deref().unwrap_or("(unknown)"); + let content_type = blob.properties.and_then(|properties| properties.content_type); + let content_type = content_type.as_deref().unwrap_or("(unknown)"); + println!("{name} ({content_type})"); + } + + Ok(()) +} diff --git a/crates/fetch_azure/src/runtime.rs b/crates/fetch_azure/src/runtime.rs index 152084016..9d911f012 100644 --- a/crates/fetch_azure/src/runtime.rs +++ b/crates/fetch_azure/src/runtime.rs @@ -113,3 +113,21 @@ impl AbortableTask for RuntimeTask { self.abort_handle.abort(); } } + +/// Runs developer-credential commands (e.g. the Azure CLI) on the blocking +/// pool of the [`Spawner`], so credentials like `DeveloperToolsCredential` +/// work on the same runtime as the rest of the SDK. +#[cfg(feature = "azure-identity")] +#[async_trait::async_trait] +impl azure_identity::Executor for Runtime { + async fn run(&self, program: &std::ffi::OsStr, args: &[&std::ffi::OsStr]) -> std::io::Result { + // The program and arguments are borrowed, so own them before moving the + // blocking work onto the spawner's pool. + let program = program.to_os_string(); + let args: Vec = args.iter().map(|arg| (*arg).to_os_string()).collect(); + + self.spawner + .spawn_blocking(move || std::process::Command::new(&program).args(&args).output()) + .await + } +} diff --git a/crates/fetch_azure/tests/runtime.rs b/crates/fetch_azure/tests/runtime.rs index dd82056ea..f8c77a5f3 100644 --- a/crates/fetch_azure/tests/runtime.rs +++ b/crates/fetch_azure/tests/runtime.rs @@ -69,3 +69,27 @@ async fn runtime_from_spawner_clock_and_accessors_round_trip() { runtime.yield_now().await; } + +#[cfg(feature = "azure-identity")] +#[tokio::test] +async fn runtime_executor_runs_command() { + use std::ffi::OsStr; + + use azure_identity::Executor; + + let runtime = Runtime::new(Spawner::new_tokio(), Clock::new_tokio()); + + #[cfg(windows)] + let output = runtime + .run(OsStr::new("cmd"), &[OsStr::new("/C"), OsStr::new("echo hello")]) + .await + .unwrap(); + #[cfg(not(windows))] + let output = runtime + .run(OsStr::new("/bin/sh"), &[OsStr::new("-c"), OsStr::new("echo hello")]) + .await + .unwrap(); + + assert!(output.status.success()); + assert!(String::from_utf8_lossy(&output.stdout).contains("hello")); +} From 1796294a6321a8e085594b04f180516fd6a83e56 Mon Sep 17 00:00:00 2001 From: Martin Tomka Date: Fri, 12 Jun 2026 13:31:30 +0200 Subject: [PATCH 13/22] fix(fetch_azure): make blob_list example no-op without endpoint The CI examples runner executes every example and requires exit 0. The blob_list example needs a live Storage account and developer sign-in, so it now skips gracefully (printing a message and returning Ok) when \AZURE_STORAGE_SERVICE_ENDPOINT\ is unset, while still running the full flow when configured. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/fetch_azure/examples/blob_list.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/fetch_azure/examples/blob_list.rs b/crates/fetch_azure/examples/blob_list.rs index e5181909d..5c689cccc 100644 --- a/crates/fetch_azure/examples/blob_list.rs +++ b/crates/fetch_azure/examples/blob_list.rs @@ -22,7 +22,13 @@ use tick::Clock; #[tokio::main] async fn main() -> Result<(), Box> { - let service_url: Url = env::var("AZURE_STORAGE_SERVICE_ENDPOINT")?.parse()?; + // This example needs a live Storage account and developer sign-in, so it + // no-ops when the endpoint is not configured (e.g. in CI). + let Ok(endpoint) = env::var("AZURE_STORAGE_SERVICE_ENDPOINT") else { + println!("AZURE_STORAGE_SERVICE_ENDPOINT is not set; skipping blob listing."); + return Ok(()); + }; + let service_url: Url = endpoint.parse()?; // Run developer-credential subprocesses (e.g. the Azure CLI) on a // tokio-backed `Runtime` used as the credential's `Executor`. From cb9f80497cfd4a9f1a5d949360d0e581493e1ec1 Mon Sep 17 00:00:00 2001 From: Martin Tomka Date: Fri, 12 Jun 2026 14:22:15 +0200 Subject: [PATCH 14/22] test(fetch_azure): fix mutation-testing gaps Mark to_fetch_body with mutants::skip: the empty-body fast path is observationally equivalent to the general bytes path (both yield a zero-length body), so the is_empty() guard is an equivalent mutant. Bound the abort cancellation test with a timeout so a no-op abort fails fast (caught) instead of hanging until the mutation-test timeout. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 1 + crates/fetch_azure/Cargo.toml | 3 ++- crates/fetch_azure/src/client.rs | 4 ++++ crates/fetch_azure/tests/runtime.rs | 9 +++++++-- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cd50877c3..6e4ffb14a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1280,6 +1280,7 @@ dependencies = [ "futures", "http", "layered", + "mutants", "tick", "tokio", ] diff --git a/crates/fetch_azure/Cargo.toml b/crates/fetch_azure/Cargo.toml index 1d8d6d939..35c294ef0 100644 --- a/crates/fetch_azure/Cargo.toml +++ b/crates/fetch_azure/Cargo.toml @@ -61,7 +61,8 @@ async-trait = { workspace = true } azure_core = { workspace = true } azure_storage_blob = { workspace = true } futures = { workspace = true, features = ["std"] } -tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +mutants = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] } [[example]] name = "blob_list" diff --git a/crates/fetch_azure/src/client.rs b/crates/fetch_azure/src/client.rs index 1faca7f7a..5d00504d7 100644 --- a/crates/fetch_azure/src/client.rs +++ b/crates/fetch_azure/src/client.rs @@ -66,6 +66,10 @@ impl AzureHttpClient { /// /// Empty byte bodies reuse a shared empty body, and non-empty byte bodies are /// wrapped without copying. Seekable streams are forwarded as a chunk stream. + // The empty-body fast path yields a body that is observationally identical to + // the general bytes path (both report a zero-length body), so the + // `is_empty()` guard is an equivalent mutant that no test can distinguish. + #[cfg_attr(test, mutants::skip)] fn to_fetch_body(&self, body: &Body) -> fetch::HttpBody { let builder: &fetch::HttpBodyBuilder = self.client.as_ref(); diff --git a/crates/fetch_azure/tests/runtime.rs b/crates/fetch_azure/tests/runtime.rs index f8c77a5f3..2de01e67c 100644 --- a/crates/fetch_azure/tests/runtime.rs +++ b/crates/fetch_azure/tests/runtime.rs @@ -33,10 +33,15 @@ async fn runtime_abort_resolves_without_waiting() { let runtime = Runtime::new(Spawner::new_tokio(), Clock::new_tokio()); // The task never completes on its own; aborting must wake the waiter so the - // await resolves rather than hanging forever. + // await resolves rather than hanging forever. The timeout bounds the wait so + // a broken `abort` fails the test promptly instead of hanging. let task = runtime.spawn(Box::pin(std::future::pending::<()>())); task.abort(); - task.await.unwrap(); + + tokio::time::timeout(std::time::Duration::from_secs(10), task) + .await + .expect("abort should wake the waiter so the task resolves promptly") + .unwrap(); } #[tokio::test] From d1aaa8f156661af3c07bf22012b7ed692d652a79 Mon Sep 17 00:00:00 2001 From: Martin Tomka Date: Fri, 12 Jun 2026 15:22:46 +0200 Subject: [PATCH 15/22] refactor(fetch_azure): move runtime and executor to anyspawn_azure crate Extract the AsyncRuntime adapter (Runtime) and the azure_identity::Executor impl out of fetch_azure into a dedicated anyspawn_azure crate. fetch_azure now provides only the AzureHttpClient transport; anyspawn_azure owns the spawner/clock-backed runtime and the optional azure-identity executor. Also drop the From<(Spawner, Clock)> for Runtime conversion (callers use Runtime::new) and keep the blob_list example in fetch_azure, now dev-depending on anyspawn_azure for the executor. Register both crates in the root README and CHANGELOG. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 + Cargo.lock | 14 +++++ Cargo.toml | 1 + README.md | 2 + crates/anyspawn_azure/CHANGELOG.md | 13 ++++ crates/anyspawn_azure/Cargo.toml | 58 ++++++++++++++++++ crates/anyspawn_azure/README.md | 61 +++++++++++++++++++ crates/anyspawn_azure/favicon.ico | 3 + crates/anyspawn_azure/logo.png | 3 + crates/anyspawn_azure/src/lib.rs | 42 +++++++++++++ .../src/runtime.rs | 10 +-- .../tests/runtime.rs | 6 +- crates/fetch_azure/CHANGELOG.md | 12 +--- crates/fetch_azure/Cargo.toml | 23 ++----- crates/fetch_azure/README.md | 44 ++++--------- crates/fetch_azure/examples/blob_list.rs | 3 +- crates/fetch_azure/src/lib.rs | 30 +++------ 17 files changed, 235 insertions(+), 92 deletions(-) create mode 100644 crates/anyspawn_azure/CHANGELOG.md create mode 100644 crates/anyspawn_azure/Cargo.toml create mode 100644 crates/anyspawn_azure/README.md create mode 100644 crates/anyspawn_azure/favicon.ico create mode 100644 crates/anyspawn_azure/logo.png create mode 100644 crates/anyspawn_azure/src/lib.rs rename crates/{fetch_azure => anyspawn_azure}/src/runtime.rs (93%) rename crates/{fetch_azure => anyspawn_azure}/tests/runtime.rs (94%) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6631cdff..6f0976792 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ Please see each crate's change log below: - [`anyspawn`](./crates/anyspawn/CHANGELOG.md) +- [`anyspawn_azure`](./crates/anyspawn_azure/CHANGELOG.md) - [`bytesbuf`](./crates/bytesbuf/CHANGELOG.md) - [`bytesbuf_io`](./crates/bytesbuf_io/CHANGELOG.md) - [`cachet`](./crates/cachet/CHANGELOG.md) @@ -13,6 +14,7 @@ Please see each crate's change log below: - [`data_privacy_macros`](./crates/data_privacy_macros/CHANGELOG.md) - [`data_privacy_macros_impl`](./crates/data_privacy_macros_impl/CHANGELOG.md) - [`fetch`](./crates/fetch/CHANGELOG.md) +- [`fetch_azure`](./crates/fetch_azure/CHANGELOG.md) - [`fetch_hyper`](./crates/fetch_hyper/CHANGELOG.md) - [`fetch_options`](./crates/fetch_options/CHANGELOG.md) - [`fetch_tls`](./crates/fetch_tls/CHANGELOG.md) diff --git a/Cargo.lock b/Cargo.lock index 6e4ffb14a..7ca4ef679 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -91,6 +91,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "anyspawn_azure" +version = "0.1.0" +dependencies = [ + "anyspawn", + "async-trait", + "azure_core", + "azure_identity", + "futures", + "tick", + "tokio", +] + [[package]] name = "argh" version = "0.1.19" @@ -1271,6 +1284,7 @@ name = "fetch_azure" version = "0.1.0" dependencies = [ "anyspawn", + "anyspawn_azure", "async-trait", "azure_core", "azure_identity", diff --git a/Cargo.toml b/Cargo.toml index e9045cc43..6c21bc13e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ homepage = "https://github.com/microsoft/oxidizer" # local dependencies anyspawn = { path = "crates/anyspawn", default-features = false, version = "0.5.3" } +anyspawn_azure = { path = "crates/anyspawn_azure", default-features = false, version = "0.1.0" } bytesbuf = { path = "crates/bytesbuf", default-features = false, version = "0.5.3" } bytesbuf_io = { path = "crates/bytesbuf_io", default-features = false, version = "0.5.4" } cachet = { path = "crates/cachet", default-features = false, version = "0.6.6" } diff --git a/README.md b/README.md index 0f4f9d6c3..c5de8d200 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ This repository contains a set of crates that help you build robust highly scala These are the primary crates built out of this repo: - [`anyspawn`](./crates/anyspawn/README.md) - A generic task spawner compatible with any async runtime. +- [`anyspawn_azure`](./crates/anyspawn_azure/README.md) - Azure SDK async runtime and process executor backed by an anyspawn spawner and a tick clock. - [`bytesbuf`](./crates/bytesbuf/README.md) - Types for creating and manipulating byte sequences. - [`bytesbuf_io`](./crates/bytesbuf_io/README.md) - Asynchronous I/O abstractions expressed via `bytesbuf` types. - [`cachet`](./crates/cachet/README.md) - A composable, customizable multi-tier caching library with rich feature support. @@ -35,6 +36,7 @@ These are the primary crates built out of this repo: - [`cachet_tier`](./crates/cachet_tier/README.md) - Core cache tier trait and abstractions for building cache backends. - [`data_privacy`](./crates/data_privacy/README.md) - Mechanisms to classify, manipulate, and redact sensitive data. - [`fetch`](./crates/fetch/README.md) - "Universal, composable and resilient HTTP client." +- [`fetch_azure`](./crates/fetch_azure/README.md) - Azure SDK HTTP transport backed by the fetch HTTP client. - [`fetch_hyper`](./crates/fetch_hyper/README.md) - Hyper-based HTTP transport utilities for fetch. - [`fetch_options`](./crates/fetch_options/README.md) - Options types for 'fetch' crate. - [`fundle`](./crates/fundle/README.md) - Compile-time safe dependency injection for Rust. diff --git a/crates/anyspawn_azure/CHANGELOG.md b/crates/anyspawn_azure/CHANGELOG.md new file mode 100644 index 000000000..7298e7a85 --- /dev/null +++ b/crates/anyspawn_azure/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +## [0.1.0] + +- ✨ Features + + - introduce `anyspawn_azure`, adapting Oxidizer primitives to Azure SDK + runtime abstractions: + - `Runtime` implements `azure_core::async_runtime::AsyncRuntime` on top + of an `anyspawn::Spawner` (spawning) and a `tick::Clock` (sleeping). + - with the optional `azure-identity` feature, `Runtime` also implements + `azure_identity::Executor`, running credential commands on the + `anyspawn::Spawner`. diff --git a/crates/anyspawn_azure/Cargo.toml b/crates/anyspawn_azure/Cargo.toml new file mode 100644 index 000000000..1a1904c3a --- /dev/null +++ b/crates/anyspawn_azure/Cargo.toml @@ -0,0 +1,58 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[package] +name = "anyspawn_azure" +description = "Azure SDK async runtime and process executor backed by an anyspawn spawner and a tick clock." +version = "0.1.0" +readme = "README.md" +keywords = ["oxidizer", "azure", "async", "runtime", "spawner"] +categories = ["asynchronous"] + +edition = { workspace = true } +rust-version = { workspace = true } +authors = { workspace = true } +license = { workspace = true } +homepage = { workspace = true } +repository = "https://github.com/microsoft/oxidizer/tree/main/crates/anyspawn_azure" + +[package.metadata.cargo_check_external_types] +allowed_external_types = [ + # Workspace sibling crates + "anyspawn::*", + "tick::*", + # External dependencies (azure_core re-exports these typespec types) + "azure_identity::*", + "typespec_client_core::*", +] + +[package.metadata.docs.rs] +all-features = true + +[features] +## Implement [`azure_identity::Executor`] for [`Runtime`], allowing it to run the +## subprocesses that developer credentials (e.g. the Azure CLI) rely on. +azure-identity = ["dep:azure_identity", "dep:async-trait"] + +[dependencies] +# internal +anyspawn = { workspace = true } +tick = { workspace = true } + +# external +async-trait = { workspace = true, optional = true } +azure_core = { workspace = true } +azure_identity = { workspace = true, optional = true } +futures = { workspace = true, features = ["std"] } + +[dev-dependencies] +# internal +anyspawn = { path = "../anyspawn", features = ["tokio"] } +tick = { path = "../tick", features = ["tokio"] } + +# external +azure_core = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] } + +[lints] +workspace = true diff --git a/crates/anyspawn_azure/README.md b/crates/anyspawn_azure/README.md new file mode 100644 index 000000000..a9011fede --- /dev/null +++ b/crates/anyspawn_azure/README.md @@ -0,0 +1,61 @@ +
+ Anyspawn Azure Logo + +# Anyspawn Azure + +[![crate.io](https://img.shields.io/crates/v/anyspawn_azure.svg)](https://crates.io/crates/anyspawn_azure) +[![docs.rs](https://docs.rs/anyspawn_azure/badge.svg)](https://docs.rs/anyspawn_azure) +[![MSRV](https://img.shields.io/crates/msrv/anyspawn_azure)](https://crates.io/crates/anyspawn_azure) +[![CI](https://github.com/microsoft/oxidizer/actions/workflows/main.yml/badge.svg?event=push)](https://github.com/microsoft/oxidizer/actions/workflows/main.yml) +[![Coverage](https://codecov.io/gh/microsoft/oxidizer/graph/badge.svg?token=FCUG0EL5TI)](https://codecov.io/gh/microsoft/oxidizer) +[![License](https://img.shields.io/badge/license-MIT-blue.svg)](../../LICENSE) +This crate was developed as part of the Oxidizer project + +
+ +Bundle [`anyspawn`][__link0] and [`tick`][__link1] as Azure SDK runtime abstractions. + +The Azure SDK abstracts its task spawning, sleeping, and yielding behind the +[`azure_core::async_runtime::AsyncRuntime`][__link2] trait, and the process +execution that developer credentials rely on behind the `azure_identity::Executor` +trait. This crate adapts those primitives to both: + +* [`Runtime`][__link3] implements [`azure_core::async_runtime::AsyncRuntime`][__link4] on top of + an [`anyspawn::Spawner`][__link5] (spawning) and a [`tick::Clock`][__link6] (sleeping). +* With the `azure-identity` feature, [`Runtime`][__link7] also implements + `azure_identity::Executor`, running credential commands on the + [`anyspawn::Spawner`][__link8]. + +## Example + +```rust +use std::sync::Arc; + +use anyspawn::Spawner; +use anyspawn_azure::Runtime; +use azure_core::async_runtime::{AsyncRuntime, set_async_runtime}; +use tick::Clock; + +// Install an `anyspawn`-backed async runtime (sleeping on a `tick::Clock`). +fn install_runtime(spawner: Spawner, clock: Clock) { + let runtime: Arc = Runtime::new(spawner, clock).into(); + let _ = set_async_runtime(runtime); +} +``` + + +
+ +This crate was developed as part of The Oxidizer Project. Browse this crate's source code. + + + [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbLiTyV0MU86EbZU15e0PmecoboQ9jo59bnAEbyDXw04U13GlhYvRhcoQbOThbrkbuaaYbO0Bp3pN43M4bn_FaHoENT04bW5I_iyf0UGlhZISCaGFueXNwYXduZTAuNS4zgm5hbnlzcGF3bl9henVyZWUwLjEuMIJqYXp1cmVfY29yZWUxLjAuMIJkdGlja2UwLjMuMw + [__link0]: https://crates.io/crates/anyspawn/0.5.3 + [__link1]: https://crates.io/crates/tick/0.3.3 + [__link2]: https://docs.rs/azure_core/1.0.0/azure_core/?search=async_runtime::AsyncRuntime + [__link3]: https://docs.rs/anyspawn_azure/0.1.0/anyspawn_azure/?search=Runtime + [__link4]: https://docs.rs/azure_core/1.0.0/azure_core/?search=async_runtime::AsyncRuntime + [__link5]: https://docs.rs/anyspawn/0.5.3/anyspawn/?search=Spawner + [__link6]: https://docs.rs/tick/0.3.3/tick/?search=Clock + [__link7]: https://docs.rs/anyspawn_azure/0.1.0/anyspawn_azure/?search=Runtime + [__link8]: https://docs.rs/anyspawn/0.5.3/anyspawn/?search=Spawner diff --git a/crates/anyspawn_azure/favicon.ico b/crates/anyspawn_azure/favicon.ico new file mode 100644 index 000000000..e8aa25ff1 --- /dev/null +++ b/crates/anyspawn_azure/favicon.ico @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9eb58f6eca7bfa5554daab00b06c30bfd0d9d2eabc82d1ed4c9d1cede030f2f3 +size 167984 diff --git a/crates/anyspawn_azure/logo.png b/crates/anyspawn_azure/logo.png new file mode 100644 index 000000000..8fc9e4be7 --- /dev/null +++ b/crates/anyspawn_azure/logo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2500d13a5e0017dbfd0c7312411cce10cefdafabcea25692e24b8c0ca283f5f6 +size 10094 diff --git a/crates/anyspawn_azure/src/lib.rs b/crates/anyspawn_azure/src/lib.rs new file mode 100644 index 000000000..b5e29b56e --- /dev/null +++ b/crates/anyspawn_azure/src/lib.rs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#![cfg_attr(all(coverage_nightly, test), feature(coverage_attribute))] +#![cfg_attr(docsrs, feature(doc_cfg))] +#![doc(html_logo_url = "https://media.githubusercontent.com/media/microsoft/oxidizer/refs/heads/main/crates/anyspawn_azure/logo.png")] +#![doc(html_favicon_url = "https://media.githubusercontent.com/media/microsoft/oxidizer/refs/heads/main/crates/anyspawn_azure/favicon.ico")] + +//! Bundle [`anyspawn`] and [`tick`] as Azure SDK runtime abstractions. +//! +//! The Azure SDK abstracts its task spawning, sleeping, and yielding behind the +//! [`azure_core::async_runtime::AsyncRuntime`] trait, and the process +//! execution that developer credentials rely on behind the `azure_identity::Executor` +//! trait. This crate adapts those primitives to both: +//! +//! - [`Runtime`] implements [`azure_core::async_runtime::AsyncRuntime`] on top of +//! an [`anyspawn::Spawner`] (spawning) and a [`tick::Clock`] (sleeping). +//! - With the `azure-identity` feature, [`Runtime`] also implements +//! `azure_identity::Executor`, running credential commands on the +//! [`anyspawn::Spawner`]. +//! +//! # Example +//! +//! ``` +//! use std::sync::Arc; +//! +//! use anyspawn::Spawner; +//! use anyspawn_azure::Runtime; +//! use azure_core::async_runtime::{AsyncRuntime, set_async_runtime}; +//! use tick::Clock; +//! +//! // Install an `anyspawn`-backed async runtime (sleeping on a `tick::Clock`). +//! fn install_runtime(spawner: Spawner, clock: Clock) { +//! let runtime: Arc = Runtime::new(spawner, clock).into(); +//! let _ = set_async_runtime(runtime); +//! } +//! # let _ = install_runtime; +//! ``` + +mod runtime; + +pub use runtime::Runtime; diff --git a/crates/fetch_azure/src/runtime.rs b/crates/anyspawn_azure/src/runtime.rs similarity index 93% rename from crates/fetch_azure/src/runtime.rs rename to crates/anyspawn_azure/src/runtime.rs index 9d911f012..2f53b805c 100644 --- a/crates/fetch_azure/src/runtime.rs +++ b/crates/anyspawn_azure/src/runtime.rs @@ -18,8 +18,8 @@ use tick::Clock; /// on a [`tick::Clock`]. /// /// Construct one from an existing [`Spawner`] and [`Clock`] with -/// [`Runtime::new`] (or via [`From`]), then convert it into an -/// `Arc` via [`From`] / [`Into`] and install it with +/// [`Runtime::new`], then convert it into an `Arc` via +/// [`From`] / [`Into`] and install it with /// [`azure_core::async_runtime::set_async_runtime`]. #[derive(Debug, Clone)] pub struct Runtime { @@ -46,12 +46,6 @@ impl Runtime { } } -impl From<(Spawner, Clock)> for Runtime { - fn from((spawner, clock): (Spawner, Clock)) -> Self { - Self::new(spawner, clock) - } -} - impl From for Arc { fn from(runtime: Runtime) -> Self { Arc::new(runtime) diff --git a/crates/fetch_azure/tests/runtime.rs b/crates/anyspawn_azure/tests/runtime.rs similarity index 94% rename from crates/fetch_azure/tests/runtime.rs rename to crates/anyspawn_azure/tests/runtime.rs index 2de01e67c..03d0017c9 100644 --- a/crates/fetch_azure/tests/runtime.rs +++ b/crates/anyspawn_azure/tests/runtime.rs @@ -9,9 +9,9 @@ use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use anyspawn::Spawner; +use anyspawn_azure::Runtime; use azure_core::async_runtime::AsyncRuntime; use azure_core::time::Duration; -use fetch_azure::Runtime; use tick::Clock; #[tokio::test] @@ -66,8 +66,8 @@ async fn runtime_converts_into_dyn_runtime() { } #[tokio::test] -async fn runtime_from_spawner_clock_and_accessors_round_trip() { - let runtime = Runtime::from((Spawner::new_tokio(), Clock::new_tokio())); +async fn runtime_accessors_round_trip() { + let runtime = Runtime::new(Spawner::new_tokio(), Clock::new_tokio()); // `spawner` and `clock` expose the wrapped components; rebuild from them. let runtime = Runtime::new(runtime.spawner().clone(), runtime.clock().clone()); diff --git a/crates/fetch_azure/CHANGELOG.md b/crates/fetch_azure/CHANGELOG.md index 520e6f828..f0531ccc7 100644 --- a/crates/fetch_azure/CHANGELOG.md +++ b/crates/fetch_azure/CHANGELOG.md @@ -4,12 +4,6 @@ - ✨ Features - - introduce `fetch_azure`, bundling two Azure SDK abstractions backed by the - Oxidizer stack: - - `AzureHttpClient` implements `azure_core::http::HttpClient` on top of a - `fetch::HttpClient` transport. - - `Runtime` implements `azure_core::async_runtime::AsyncRuntime` on top - of an `anyspawn::Spawner` (spawning) and a `tick::Clock` (sleeping). - - with the optional `azure-identity` feature, `Runtime` also implements - `azure_identity::Executor`, running credential subprocesses on the - `anyspawn::Spawner`. + - introduce `fetch_azure`, adapting a `fetch::HttpClient` into an Azure SDK + HTTP transport: `AzureHttpClient` implements `azure_core::http::HttpClient` + on top of a `fetch::HttpClient`. diff --git a/crates/fetch_azure/Cargo.toml b/crates/fetch_azure/Cargo.toml index 35c294ef0..fd30041f2 100644 --- a/crates/fetch_azure/Cargo.toml +++ b/crates/fetch_azure/Cargo.toml @@ -3,10 +3,10 @@ [package] name = "fetch_azure" -description = "Azure SDK transport and runtime backed by the fetch HTTP client and an anyspawn spawner." +description = "Azure SDK HTTP transport backed by the fetch HTTP client." version = "0.1.0" readme = "README.md" -keywords = ["oxidizer", "azure", "fetch", "http", "runtime"] +keywords = ["oxidizer", "azure", "fetch", "http", "transport"] categories = ["network-programming"] edition = { workspace = true } @@ -19,54 +19,41 @@ repository = "https://github.com/microsoft/oxidizer/tree/main/crates/fetch_azure [package.metadata.cargo_check_external_types] allowed_external_types = [ # Workspace sibling crates - "anyspawn::*", "fetch::*", - "tick::*", # External dependencies (azure_core re-exports these typespec types) - "azure_identity::*", "typespec_client_core::*", ] [package.metadata.docs.rs] all-features = true -[features] -## Implement [`azure_identity::Executor`] for [`Runtime`], allowing it to run the -## subprocesses that developer credentials (e.g. the Azure CLI) rely on. -azure-identity = ["dep:azure_identity"] - [dependencies] # internal -anyspawn = { workspace = true } bytesbuf = { workspace = true, features = ["bytes-compat"] } fetch = { workspace = true } layered = { workspace = true } -tick = { workspace = true } # external async-trait = { workspace = true } azure_core = { workspace = true } -azure_identity = { workspace = true, optional = true } futures = { workspace = true, features = ["std"] } http = { workspace = true } [dev-dependencies] # internal anyspawn = { path = "../anyspawn", features = ["tokio"] } +anyspawn_azure = { path = "../anyspawn_azure", features = ["azure-identity"] } fetch = { path = "../fetch", features = ["test-util", "tokio", "rustls"] } tick = { path = "../tick", features = ["tokio"] } # external async-trait = { workspace = true } azure_core = { workspace = true } +azure_identity = { workspace = true } azure_storage_blob = { workspace = true } futures = { workspace = true, features = ["std"] } mutants = { workspace = true } -tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] } - -[[example]] -name = "blob_list" -required-features = ["azure-identity"] +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } [lints] workspace = true diff --git a/crates/fetch_azure/README.md b/crates/fetch_azure/README.md index d9c438ea5..350b591b3 100644 --- a/crates/fetch_azure/README.md +++ b/crates/fetch_azure/README.md @@ -13,41 +13,29 @@ -Bundle [`fetch`][__link0] and [`anyspawn`][__link1] as Azure SDK abstractions. +Adapt a [`fetch::HttpClient`][__link0] into an Azure SDK HTTP transport. The Azure SDK abstracts its HTTP transport behind the -[`azure_core::http::HttpClient`][__link2] trait and its task spawning, sleeping, and -yielding behind the [`azure_core::async_runtime::AsyncRuntime`][__link3] trait. This -crate provides adapters for both: +[`azure_core::http::HttpClient`][__link1] trait. [`AzureHttpClient`][__link2] implements that +trait on top of a [`fetch::HttpClient`][__link3], so Azure SDK pipelines run over +`fetch` and benefit from its resilience and observability. -* [`AzureHttpClient`][__link4] implements [`azure_core::http::HttpClient`][__link5] on top of a - [`fetch::HttpClient`][__link6], so Azure SDK pipelines run over `fetch` and benefit - from its resilience and observability. -* [`Runtime`][__link7] implements [`azure_core::async_runtime::AsyncRuntime`][__link8] on top of - an [`anyspawn::Spawner`][__link9] (spawning) and a [`tick::Clock`][__link10] (sleeping). +To run the Azure SDK on an [`anyspawn`][__link4]-backed async runtime, see the +`anyspawn_azure` crate. ## Example ```rust use std::sync::Arc; -use anyspawn::Spawner; -use azure_core::async_runtime::{AsyncRuntime, set_async_runtime}; use azure_core::http::HttpClient; use fetch::HttpClient as FetchClient; -use fetch_azure::{AzureHttpClient, Runtime}; -use tick::Clock; +use fetch_azure::AzureHttpClient; // Adapt a `fetch` client into an Azure SDK transport. fn transport(client: FetchClient) -> Arc { AzureHttpClient::from(client).into() } - -// Install an `anyspawn`-backed async runtime (sleeping on a `tick::Clock`). -fn install_runtime(spawner: Spawner, clock: Clock) { - let runtime: Arc = Runtime::new(spawner, clock).into(); - let _ = set_async_runtime(runtime); -} ``` @@ -56,15 +44,9 @@ fn install_runtime(spawner: Spawner, clock: Clock) { This crate was developed as part of The Oxidizer Project. Browse this crate's source code. - [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbLiTyV0MU86EbZU15e0PmecoboQ9jo59bnAEbyDXw04U13GlhYvRhcoQbKKsn8lFrPt8b0KAAJiBwTQ8bStgknxZYUFMb8x5TGN_lWzJhZIWCaGFueXNwYXduZTAuNS4zgmphenVyZV9jb3JlZTEuMC4wgmVmZXRjaGYwLjExLjCCa2ZldGNoX2F6dXJlZTAuMS4wgmR0aWNrZTAuMy4z - [__link0]: https://crates.io/crates/fetch/0.11.0 - [__link1]: https://crates.io/crates/anyspawn/0.5.3 - [__link10]: https://docs.rs/tick/0.3.3/tick/?search=Clock - [__link2]: https://docs.rs/azure_core/1.0.0/azure_core/?search=http::HttpClient - [__link3]: https://docs.rs/azure_core/1.0.0/azure_core/?search=async_runtime::AsyncRuntime - [__link4]: https://docs.rs/fetch_azure/0.1.0/fetch_azure/?search=AzureHttpClient - [__link5]: https://docs.rs/azure_core/1.0.0/azure_core/?search=http::HttpClient - [__link6]: https://docs.rs/fetch/0.11.0/fetch/?search=HttpClient - [__link7]: https://docs.rs/fetch_azure/0.1.0/fetch_azure/?search=Runtime - [__link8]: https://docs.rs/azure_core/1.0.0/azure_core/?search=async_runtime::AsyncRuntime - [__link9]: https://docs.rs/anyspawn/0.5.3/anyspawn/?search=Spawner + [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbLiTyV0MU86EbZU15e0PmecoboQ9jo59bnAEbyDXw04U13GlhYvRhcoQbIDHZEzF7TqQbEgXgpwz2qz4bFYd2Uq2wVpQbG0lvoB-LAzVhZISCaGFueXNwYXduZTAuNS4zgmphenVyZV9jb3JlZTEuMC4wgmVmZXRjaGYwLjExLjCCa2ZldGNoX2F6dXJlZTAuMS4w + [__link0]: https://docs.rs/fetch/0.11.0/fetch/?search=HttpClient + [__link1]: https://docs.rs/azure_core/1.0.0/azure_core/?search=http::HttpClient + [__link2]: https://docs.rs/fetch_azure/0.1.0/fetch_azure/?search=AzureHttpClient + [__link3]: https://docs.rs/fetch/0.11.0/fetch/?search=HttpClient + [__link4]: https://crates.io/crates/anyspawn/0.5.3 diff --git a/crates/fetch_azure/examples/blob_list.rs b/crates/fetch_azure/examples/blob_list.rs index 5c689cccc..e6838addf 100644 --- a/crates/fetch_azure/examples/blob_list.rs +++ b/crates/fetch_azure/examples/blob_list.rs @@ -11,12 +11,13 @@ use std::env; use std::sync::Arc; use anyspawn::Spawner; +use anyspawn_azure::Runtime; use azure_core::credentials::TokenCredential; use azure_core::http::{ClientOptions, Transport, Url}; use azure_identity::{DeveloperToolsCredential, DeveloperToolsCredentialOptions, Executor}; use azure_storage_blob::{BlobServiceClient, BlobServiceClientOptions}; use fetch::HttpClient as FetchClient; -use fetch_azure::{AzureHttpClient, Runtime}; +use fetch_azure::AzureHttpClient; use futures::TryStreamExt as _; use tick::Clock; diff --git a/crates/fetch_azure/src/lib.rs b/crates/fetch_azure/src/lib.rs index 0c27fe168..6103ec5fc 100644 --- a/crates/fetch_azure/src/lib.rs +++ b/crates/fetch_azure/src/lib.rs @@ -6,46 +6,32 @@ #![doc(html_logo_url = "https://media.githubusercontent.com/media/microsoft/oxidizer/refs/heads/main/crates/fetch_azure/logo.png")] #![doc(html_favicon_url = "https://media.githubusercontent.com/media/microsoft/oxidizer/refs/heads/main/crates/fetch_azure/favicon.ico")] -//! Bundle [`fetch`] and [`anyspawn`] as Azure SDK abstractions. +//! Adapt a [`fetch::HttpClient`] into an Azure SDK HTTP transport. //! //! The Azure SDK abstracts its HTTP transport behind the -//! [`azure_core::http::HttpClient`] trait and its task spawning, sleeping, and -//! yielding behind the [`azure_core::async_runtime::AsyncRuntime`] trait. This -//! crate provides adapters for both: +//! [`azure_core::http::HttpClient`] trait. [`AzureHttpClient`] implements that +//! trait on top of a [`fetch::HttpClient`], so Azure SDK pipelines run over +//! `fetch` and benefit from its resilience and observability. //! -//! - [`AzureHttpClient`] implements [`azure_core::http::HttpClient`] on top of a -//! [`fetch::HttpClient`], so Azure SDK pipelines run over `fetch` and benefit -//! from its resilience and observability. -//! - [`Runtime`] implements [`azure_core::async_runtime::AsyncRuntime`] on top of -//! an [`anyspawn::Spawner`] (spawning) and a [`tick::Clock`] (sleeping). +//! To run the Azure SDK on an [`anyspawn`]-backed async runtime, see the +//! `anyspawn_azure` crate. //! //! # Example //! //! ``` //! use std::sync::Arc; //! -//! use anyspawn::Spawner; -//! use azure_core::async_runtime::{AsyncRuntime, set_async_runtime}; //! use azure_core::http::HttpClient; //! use fetch::HttpClient as FetchClient; -//! use fetch_azure::{AzureHttpClient, Runtime}; -//! use tick::Clock; +//! use fetch_azure::AzureHttpClient; //! //! // Adapt a `fetch` client into an Azure SDK transport. //! fn transport(client: FetchClient) -> Arc { //! AzureHttpClient::from(client).into() //! } -//! -//! // Install an `anyspawn`-backed async runtime (sleeping on a `tick::Clock`). -//! fn install_runtime(spawner: Spawner, clock: Clock) { -//! let runtime: Arc = Runtime::new(spawner, clock).into(); -//! let _ = set_async_runtime(runtime); -//! } -//! # let _ = (transport, install_runtime); +//! # let _ = transport; //! ``` mod client; -mod runtime; pub use client::AzureHttpClient; -pub use runtime::Runtime; From e20387be0e74be52608f96248164064b62444f37 Mon Sep 17 00:00:00 2001 From: Martin Tomka Date: Fri, 12 Jun 2026 15:41:21 +0200 Subject: [PATCH 16/22] fix(fetch_azure): drop broken intra-doc link to anyspawn fetch_azure no longer depends on anyspawn after the runtime extraction, so the crate-doc reference to it must be a plain code span rather than an intra-doc link (which fails the docs build under -D warnings). Regenerate the README accordingly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/fetch_azure/README.md | 5 ++--- crates/fetch_azure/src/lib.rs | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/fetch_azure/README.md b/crates/fetch_azure/README.md index 350b591b3..8e40ccb0c 100644 --- a/crates/fetch_azure/README.md +++ b/crates/fetch_azure/README.md @@ -20,7 +20,7 @@ The Azure SDK abstracts its HTTP transport behind the trait on top of a [`fetch::HttpClient`][__link3], so Azure SDK pipelines run over `fetch` and benefit from its resilience and observability. -To run the Azure SDK on an [`anyspawn`][__link4]-backed async runtime, see the +To run the Azure SDK on an `anyspawn`-backed async runtime, see the `anyspawn_azure` crate. ## Example @@ -44,9 +44,8 @@ fn transport(client: FetchClient) -> Arc { This crate was developed as part of The Oxidizer Project. Browse this crate's source code. - [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbLiTyV0MU86EbZU15e0PmecoboQ9jo59bnAEbyDXw04U13GlhYvRhcoQbIDHZEzF7TqQbEgXgpwz2qz4bFYd2Uq2wVpQbG0lvoB-LAzVhZISCaGFueXNwYXduZTAuNS4zgmphenVyZV9jb3JlZTEuMC4wgmVmZXRjaGYwLjExLjCCa2ZldGNoX2F6dXJlZTAuMS4w + [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbLiTyV0MU86EbZU15e0PmecoboQ9jo59bnAEbyDXw04U13GlhYvRhcoQb2aDhkkwbzxob5vBRjb_giOMbjsT0P_P88kgbcQx5M6vmOXJhZIOCamF6dXJlX2NvcmVlMS4wLjCCZWZldGNoZjAuMTEuMIJrZmV0Y2hfYXp1cmVlMC4xLjA [__link0]: https://docs.rs/fetch/0.11.0/fetch/?search=HttpClient [__link1]: https://docs.rs/azure_core/1.0.0/azure_core/?search=http::HttpClient [__link2]: https://docs.rs/fetch_azure/0.1.0/fetch_azure/?search=AzureHttpClient [__link3]: https://docs.rs/fetch/0.11.0/fetch/?search=HttpClient - [__link4]: https://crates.io/crates/anyspawn/0.5.3 diff --git a/crates/fetch_azure/src/lib.rs b/crates/fetch_azure/src/lib.rs index 6103ec5fc..a207e0b48 100644 --- a/crates/fetch_azure/src/lib.rs +++ b/crates/fetch_azure/src/lib.rs @@ -13,7 +13,7 @@ //! trait on top of a [`fetch::HttpClient`], so Azure SDK pipelines run over //! `fetch` and benefit from its resilience and observability. //! -//! To run the Azure SDK on an [`anyspawn`]-backed async runtime, see the +//! To run the Azure SDK on an `anyspawn`-backed async runtime, see the //! `anyspawn_azure` crate. //! //! # Example From 54223e04d0f3b353840c7761157abdcf0ddce13a Mon Sep 17 00:00:00 2001 From: Martin Tomka Date: Fri, 12 Jun 2026 16:07:56 +0200 Subject: [PATCH 17/22] refactor: depend on typespec_client_core directly instead of azure_core Both crate libraries used azure_core only to reach the HttpClient and AsyncRuntime traits, which azure_core re-exports from typespec_client_core (the allowed_external_types lists already targeted typespec_client_core). Depend on typespec_client_core directly in both libraries (fetch_azure enables its \http\ feature). azure_core is retained only as a fetch_azure dev-dependency for the blob_list example, which uses Azure-specific ClientOptions/Transport/credentials. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 3 ++- Cargo.toml | 1 + crates/anyspawn_azure/Cargo.toml | 5 ++--- crates/anyspawn_azure/README.md | 12 ++++++------ crates/anyspawn_azure/src/lib.rs | 6 +++--- crates/anyspawn_azure/src/runtime.rs | 6 +++--- crates/anyspawn_azure/tests/runtime.rs | 4 ++-- crates/fetch_azure/Cargo.toml | 4 ++-- crates/fetch_azure/README.md | 8 ++++---- crates/fetch_azure/src/client.rs | 14 +++++++------- crates/fetch_azure/src/lib.rs | 4 ++-- crates/fetch_azure/tests/client.rs | 12 ++++++------ 12 files changed, 40 insertions(+), 39 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7ca4ef679..720b7d966 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -97,11 +97,11 @@ version = "0.1.0" dependencies = [ "anyspawn", "async-trait", - "azure_core", "azure_identity", "futures", "tick", "tokio", + "typespec_client_core", ] [[package]] @@ -1297,6 +1297,7 @@ dependencies = [ "mutants", "tick", "tokio", + "typespec_client_core", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 6c21bc13e..ff6fbf8b4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -157,6 +157,7 @@ tracing-test = { version = "0.2.6", default-features = false } trait-variant = { version = "0.1.2", default-features = false } trybuild = { version = "1.0.114", default-features = false } typeid = { version = "1.0.3", default-features = false } +typespec_client_core = { version = "1.0.0", default-features = false } uuid = { version = "1.21.0", default-features = false } widestring = { version = "1.2.1", default-features = false } windows-sys = { version = "0.61.2", default-features = false } diff --git a/crates/anyspawn_azure/Cargo.toml b/crates/anyspawn_azure/Cargo.toml index 1a1904c3a..79d9f9773 100644 --- a/crates/anyspawn_azure/Cargo.toml +++ b/crates/anyspawn_azure/Cargo.toml @@ -21,7 +21,7 @@ allowed_external_types = [ # Workspace sibling crates "anyspawn::*", "tick::*", - # External dependencies (azure_core re-exports these typespec types) + # External dependencies that define the AsyncRuntime and Executor traits "azure_identity::*", "typespec_client_core::*", ] @@ -41,9 +41,9 @@ tick = { workspace = true } # external async-trait = { workspace = true, optional = true } -azure_core = { workspace = true } azure_identity = { workspace = true, optional = true } futures = { workspace = true, features = ["std"] } +typespec_client_core = { workspace = true } [dev-dependencies] # internal @@ -51,7 +51,6 @@ anyspawn = { path = "../anyspawn", features = ["tokio"] } tick = { path = "../tick", features = ["tokio"] } # external -azure_core = { workspace = true } tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] } [lints] diff --git a/crates/anyspawn_azure/README.md b/crates/anyspawn_azure/README.md index a9011fede..669059563 100644 --- a/crates/anyspawn_azure/README.md +++ b/crates/anyspawn_azure/README.md @@ -16,11 +16,11 @@ Bundle [`anyspawn`][__link0] and [`tick`][__link1] as Azure SDK runtime abstractions. The Azure SDK abstracts its task spawning, sleeping, and yielding behind the -[`azure_core::async_runtime::AsyncRuntime`][__link2] trait, and the process +[`typespec_client_core::async_runtime::AsyncRuntime`][__link2] trait, and the process execution that developer credentials rely on behind the `azure_identity::Executor` trait. This crate adapts those primitives to both: -* [`Runtime`][__link3] implements [`azure_core::async_runtime::AsyncRuntime`][__link4] on top of +* [`Runtime`][__link3] implements [`typespec_client_core::async_runtime::AsyncRuntime`][__link4] on top of an [`anyspawn::Spawner`][__link5] (spawning) and a [`tick::Clock`][__link6] (sleeping). * With the `azure-identity` feature, [`Runtime`][__link7] also implements `azure_identity::Executor`, running credential commands on the @@ -33,8 +33,8 @@ use std::sync::Arc; use anyspawn::Spawner; use anyspawn_azure::Runtime; -use azure_core::async_runtime::{AsyncRuntime, set_async_runtime}; use tick::Clock; +use typespec_client_core::async_runtime::{AsyncRuntime, set_async_runtime}; // Install an `anyspawn`-backed async runtime (sleeping on a `tick::Clock`). fn install_runtime(spawner: Spawner, clock: Clock) { @@ -49,12 +49,12 @@ fn install_runtime(spawner: Spawner, clock: Clock) { This crate was developed as part of The Oxidizer Project. Browse this crate's source code. - [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbLiTyV0MU86EbZU15e0PmecoboQ9jo59bnAEbyDXw04U13GlhYvRhcoQbOThbrkbuaaYbO0Bp3pN43M4bn_FaHoENT04bW5I_iyf0UGlhZISCaGFueXNwYXduZTAuNS4zgm5hbnlzcGF3bl9henVyZWUwLjEuMIJqYXp1cmVfY29yZWUxLjAuMIJkdGlja2UwLjMuMw + [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbLiTyV0MU86EbZU15e0PmecoboQ9jo59bnAEbyDXw04U13GlhYvRhcoQbBIQZjhLbN24b4SQLJr0OB3sbAJBFhMk9gnYbZcuWw6vetXBhZISCaGFueXNwYXduZTAuNS4zgm5hbnlzcGF3bl9henVyZWUwLjEuMIJkdGlja2UwLjMuM4J0dHlwZXNwZWNfY2xpZW50X2NvcmVlMS4wLjA [__link0]: https://crates.io/crates/anyspawn/0.5.3 [__link1]: https://crates.io/crates/tick/0.3.3 - [__link2]: https://docs.rs/azure_core/1.0.0/azure_core/?search=async_runtime::AsyncRuntime + [__link2]: https://docs.rs/typespec_client_core/1.0.0/typespec_client_core/?search=async_runtime::AsyncRuntime [__link3]: https://docs.rs/anyspawn_azure/0.1.0/anyspawn_azure/?search=Runtime - [__link4]: https://docs.rs/azure_core/1.0.0/azure_core/?search=async_runtime::AsyncRuntime + [__link4]: https://docs.rs/typespec_client_core/1.0.0/typespec_client_core/?search=async_runtime::AsyncRuntime [__link5]: https://docs.rs/anyspawn/0.5.3/anyspawn/?search=Spawner [__link6]: https://docs.rs/tick/0.3.3/tick/?search=Clock [__link7]: https://docs.rs/anyspawn_azure/0.1.0/anyspawn_azure/?search=Runtime diff --git a/crates/anyspawn_azure/src/lib.rs b/crates/anyspawn_azure/src/lib.rs index b5e29b56e..2aaab6df9 100644 --- a/crates/anyspawn_azure/src/lib.rs +++ b/crates/anyspawn_azure/src/lib.rs @@ -9,11 +9,11 @@ //! Bundle [`anyspawn`] and [`tick`] as Azure SDK runtime abstractions. //! //! The Azure SDK abstracts its task spawning, sleeping, and yielding behind the -//! [`azure_core::async_runtime::AsyncRuntime`] trait, and the process +//! [`typespec_client_core::async_runtime::AsyncRuntime`] trait, and the process //! execution that developer credentials rely on behind the `azure_identity::Executor` //! trait. This crate adapts those primitives to both: //! -//! - [`Runtime`] implements [`azure_core::async_runtime::AsyncRuntime`] on top of +//! - [`Runtime`] implements [`typespec_client_core::async_runtime::AsyncRuntime`] on top of //! an [`anyspawn::Spawner`] (spawning) and a [`tick::Clock`] (sleeping). //! - With the `azure-identity` feature, [`Runtime`] also implements //! `azure_identity::Executor`, running credential commands on the @@ -26,8 +26,8 @@ //! //! use anyspawn::Spawner; //! use anyspawn_azure::Runtime; -//! use azure_core::async_runtime::{AsyncRuntime, set_async_runtime}; //! use tick::Clock; +//! use typespec_client_core::async_runtime::{AsyncRuntime, set_async_runtime}; //! //! // Install an `anyspawn`-backed async runtime (sleeping on a `tick::Clock`). //! fn install_runtime(spawner: Spawner, clock: Clock) { diff --git a/crates/anyspawn_azure/src/runtime.rs b/crates/anyspawn_azure/src/runtime.rs index 2f53b805c..795f31a00 100644 --- a/crates/anyspawn_azure/src/runtime.rs +++ b/crates/anyspawn_azure/src/runtime.rs @@ -9,10 +9,10 @@ use std::sync::Arc; use std::task::{Context, Poll}; use anyspawn::{JoinHandle, Spawner}; -use azure_core::async_runtime::{AbortableTask, AsyncRuntime, SpawnedTask, TaskFuture}; -use azure_core::time::Duration; use futures::future::{AbortHandle, Abortable}; use tick::Clock; +use typespec_client_core::async_runtime::{AbortableTask, AsyncRuntime, SpawnedTask, TaskFuture}; +use typespec_client_core::time::Duration; /// An [`AsyncRuntime`] that spawns work on an [`anyspawn::Spawner`] and sleeps /// on a [`tick::Clock`]. @@ -20,7 +20,7 @@ use tick::Clock; /// Construct one from an existing [`Spawner`] and [`Clock`] with /// [`Runtime::new`], then convert it into an `Arc` via /// [`From`] / [`Into`] and install it with -/// [`azure_core::async_runtime::set_async_runtime`]. +/// [`typespec_client_core::async_runtime::set_async_runtime`]. #[derive(Debug, Clone)] pub struct Runtime { spawner: Spawner, diff --git a/crates/anyspawn_azure/tests/runtime.rs b/crates/anyspawn_azure/tests/runtime.rs index 03d0017c9..409159b39 100644 --- a/crates/anyspawn_azure/tests/runtime.rs +++ b/crates/anyspawn_azure/tests/runtime.rs @@ -10,9 +10,9 @@ use std::sync::atomic::{AtomicBool, Ordering}; use anyspawn::Spawner; use anyspawn_azure::Runtime; -use azure_core::async_runtime::AsyncRuntime; -use azure_core::time::Duration; use tick::Clock; +use typespec_client_core::async_runtime::AsyncRuntime; +use typespec_client_core::time::Duration; #[tokio::test] async fn runtime_spawn_runs_task_to_completion() { diff --git a/crates/fetch_azure/Cargo.toml b/crates/fetch_azure/Cargo.toml index fd30041f2..b426b46ca 100644 --- a/crates/fetch_azure/Cargo.toml +++ b/crates/fetch_azure/Cargo.toml @@ -20,7 +20,7 @@ repository = "https://github.com/microsoft/oxidizer/tree/main/crates/fetch_azure allowed_external_types = [ # Workspace sibling crates "fetch::*", - # External dependencies (azure_core re-exports these typespec types) + # External dependency that defines the HttpClient trait "typespec_client_core::*", ] @@ -35,9 +35,9 @@ layered = { workspace = true } # external async-trait = { workspace = true } -azure_core = { workspace = true } futures = { workspace = true, features = ["std"] } http = { workspace = true } +typespec_client_core = { workspace = true, features = ["http"] } [dev-dependencies] # internal diff --git a/crates/fetch_azure/README.md b/crates/fetch_azure/README.md index 8e40ccb0c..693b3c96a 100644 --- a/crates/fetch_azure/README.md +++ b/crates/fetch_azure/README.md @@ -16,7 +16,7 @@ Adapt a [`fetch::HttpClient`][__link0] into an Azure SDK HTTP transport. The Azure SDK abstracts its HTTP transport behind the -[`azure_core::http::HttpClient`][__link1] trait. [`AzureHttpClient`][__link2] implements that +[`typespec_client_core::http::HttpClient`][__link1] trait. [`AzureHttpClient`][__link2] implements that trait on top of a [`fetch::HttpClient`][__link3], so Azure SDK pipelines run over `fetch` and benefit from its resilience and observability. @@ -28,9 +28,9 @@ To run the Azure SDK on an `anyspawn`-backed async runtime, see the ```rust use std::sync::Arc; -use azure_core::http::HttpClient; use fetch::HttpClient as FetchClient; use fetch_azure::AzureHttpClient; +use typespec_client_core::http::HttpClient; // Adapt a `fetch` client into an Azure SDK transport. fn transport(client: FetchClient) -> Arc { @@ -44,8 +44,8 @@ fn transport(client: FetchClient) -> Arc { This crate was developed as part of The Oxidizer Project. Browse this crate's source code. - [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbLiTyV0MU86EbZU15e0PmecoboQ9jo59bnAEbyDXw04U13GlhYvRhcoQb2aDhkkwbzxob5vBRjb_giOMbjsT0P_P88kgbcQx5M6vmOXJhZIOCamF6dXJlX2NvcmVlMS4wLjCCZWZldGNoZjAuMTEuMIJrZmV0Y2hfYXp1cmVlMC4xLjA + [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbLiTyV0MU86EbZU15e0PmecoboQ9jo59bnAEbyDXw04U13GlhYvRhcoQbzCTaIS0go2Ub8ViwNofWrMobqqDe_YG9-OUbqRZ4eEJ24fxhZIOCZWZldGNoZjAuMTEuMIJrZmV0Y2hfYXp1cmVlMC4xLjCCdHR5cGVzcGVjX2NsaWVudF9jb3JlZTEuMC4w [__link0]: https://docs.rs/fetch/0.11.0/fetch/?search=HttpClient - [__link1]: https://docs.rs/azure_core/1.0.0/azure_core/?search=http::HttpClient + [__link1]: https://docs.rs/typespec_client_core/1.0.0/typespec_client_core/?search=http::HttpClient [__link2]: https://docs.rs/fetch_azure/0.1.0/fetch_azure/?search=AzureHttpClient [__link3]: https://docs.rs/fetch/0.11.0/fetch/?search=HttpClient diff --git a/crates/fetch_azure/src/client.rs b/crates/fetch_azure/src/client.rs index 5d00504d7..f7ce460ee 100644 --- a/crates/fetch_azure/src/client.rs +++ b/crates/fetch_azure/src/client.rs @@ -7,14 +7,14 @@ use std::collections::HashMap; use std::sync::Arc; use async_trait::async_trait; -use azure_core::error::{Error, ErrorKind}; -use azure_core::http::headers::{HeaderName, HeaderValue, Headers}; -use azure_core::http::request::{Body, Request}; -use azure_core::http::response::PinnedStream; -use azure_core::http::{AsyncRawResponse, HttpClient}; use bytesbuf::BytesView; use futures::{StreamExt as _, TryStreamExt as _}; use layered::Service as _; +use typespec_client_core::error::{Error, ErrorKind}; +use typespec_client_core::http::headers::{HeaderName, HeaderValue, Headers}; +use typespec_client_core::http::request::{Body, Request}; +use typespec_client_core::http::response::PinnedStream; +use typespec_client_core::http::{AsyncRawResponse, HttpClient}; /// An [`HttpClient`] that uses a [`fetch::HttpClient`] as its transport. /// @@ -43,7 +43,7 @@ impl AzureHttpClient { } /// Converts a typespec [`Request`] into a `fetch` request. - fn to_fetch_request(&self, request: &Request) -> azure_core::Result { + fn to_fetch_request(&self, request: &Request) -> typespec_client_core::Result { // `Method::as_str` yields a canonical token (e.g. "GET") that `fetch`'s // builder parses into an `http::Method`; this avoids matching on the // `#[non_exhaustive]` typespec `Method` enum. @@ -102,7 +102,7 @@ impl From for Arc { #[async_trait] impl HttpClient for AzureHttpClient { - async fn execute_request(&self, request: &Request) -> azure_core::Result { + async fn execute_request(&self, request: &Request) -> typespec_client_core::Result { let request = self.to_fetch_request(request)?; let response = self diff --git a/crates/fetch_azure/src/lib.rs b/crates/fetch_azure/src/lib.rs index a207e0b48..1e2b261fc 100644 --- a/crates/fetch_azure/src/lib.rs +++ b/crates/fetch_azure/src/lib.rs @@ -9,7 +9,7 @@ //! Adapt a [`fetch::HttpClient`] into an Azure SDK HTTP transport. //! //! The Azure SDK abstracts its HTTP transport behind the -//! [`azure_core::http::HttpClient`] trait. [`AzureHttpClient`] implements that +//! [`typespec_client_core::http::HttpClient`] trait. [`AzureHttpClient`] implements that //! trait on top of a [`fetch::HttpClient`], so Azure SDK pipelines run over //! `fetch` and benefit from its resilience and observability. //! @@ -21,9 +21,9 @@ //! ``` //! use std::sync::Arc; //! -//! use azure_core::http::HttpClient; //! use fetch::HttpClient as FetchClient; //! use fetch_azure::AzureHttpClient; +//! use typespec_client_core::http::HttpClient; //! //! // Adapt a `fetch` client into an Azure SDK transport. //! fn transport(client: FetchClient) -> Arc { diff --git a/crates/fetch_azure/tests/client.rs b/crates/fetch_azure/tests/client.rs index c19cdb10a..612886f75 100644 --- a/crates/fetch_azure/tests/client.rs +++ b/crates/fetch_azure/tests/client.rs @@ -11,15 +11,15 @@ use std::sync::Arc; use std::task::{Context, Poll}; use async_trait::async_trait; -use azure_core::Bytes; -use azure_core::http::headers::HeaderName; -use azure_core::http::request::{Body, Request}; -use azure_core::http::{HttpClient, Method, Url}; -use azure_core::stream::{BytesStream, SeekableStream}; use fetch::fake::FakeHandler; use fetch::{HttpClient as FetchClient, HttpResponseBuilder}; use fetch_azure::AzureHttpClient; use futures::io::AsyncRead; +use typespec_client_core::Bytes; +use typespec_client_core::http::headers::HeaderName; +use typespec_client_core::http::request::{Body, Request}; +use typespec_client_core::http::{HttpClient, Method, Url}; +use typespec_client_core::stream::{BytesStream, SeekableStream}; fn request(method: Method) -> Request { Request::new(Url::parse("https://example.com/path").expect("valid url"), method) @@ -235,7 +235,7 @@ impl AsyncRead for ErroringStream { #[async_trait] impl SeekableStream for ErroringStream { - async fn reset(&mut self) -> azure_core::Result<()> { + async fn reset(&mut self) -> typespec_client_core::Result<()> { Ok(()) } From d3ff6f6e1c2532d0aae353a2b941b759b3fa0381 Mon Sep 17 00:00:00 2001 From: Martin Tomka Date: Fri, 12 Jun 2026 16:19:28 +0200 Subject: [PATCH 18/22] refactor(fetch_azure): rename AzureHttpClient to HttpClient Per review, rename the transport adapter to \ etch_azure::HttpClient\ so it reads naturally alongside \nyspawn_azure::Runtime\. Since the struct now shares its name with the \ ypespec_client_core::http::HttpClient\ trait it implements, the trait is imported under the \HttpClientTrait\ alias where it must be named (impl site, \Arc\). Updates the lib/struct docs, examples, and tests accordingly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/fetch_azure/CHANGELOG.md | 2 +- crates/fetch_azure/README.md | 13 ++++----- .../fetch_azure/examples/azure_transport.rs | 6 ++-- crates/fetch_azure/examples/blob_list.rs | 4 +-- crates/fetch_azure/src/client.rs | 27 +++++++++--------- crates/fetch_azure/src/lib.rs | 11 ++++---- crates/fetch_azure/tests/client.rs | 28 +++++++++---------- 7 files changed, 44 insertions(+), 47 deletions(-) diff --git a/crates/fetch_azure/CHANGELOG.md b/crates/fetch_azure/CHANGELOG.md index f0531ccc7..ae595c4bb 100644 --- a/crates/fetch_azure/CHANGELOG.md +++ b/crates/fetch_azure/CHANGELOG.md @@ -5,5 +5,5 @@ - ✨ Features - introduce `fetch_azure`, adapting a `fetch::HttpClient` into an Azure SDK - HTTP transport: `AzureHttpClient` implements `azure_core::http::HttpClient` + HTTP transport: `HttpClient` implements `typespec_client_core::http::HttpClient` on top of a `fetch::HttpClient`. diff --git a/crates/fetch_azure/README.md b/crates/fetch_azure/README.md index 693b3c96a..416340d26 100644 --- a/crates/fetch_azure/README.md +++ b/crates/fetch_azure/README.md @@ -16,7 +16,7 @@ Adapt a [`fetch::HttpClient`][__link0] into an Azure SDK HTTP transport. The Azure SDK abstracts its HTTP transport behind the -[`typespec_client_core::http::HttpClient`][__link1] trait. [`AzureHttpClient`][__link2] implements that +[`typespec_client_core::http::HttpClient`][__link1] trait. [`HttpClient`][__link2] implements that trait on top of a [`fetch::HttpClient`][__link3], so Azure SDK pipelines run over `fetch` and benefit from its resilience and observability. @@ -29,12 +29,11 @@ To run the Azure SDK on an `anyspawn`-backed async runtime, see the use std::sync::Arc; use fetch::HttpClient as FetchClient; -use fetch_azure::AzureHttpClient; -use typespec_client_core::http::HttpClient; +use fetch_azure::HttpClient; // Adapt a `fetch` client into an Azure SDK transport. -fn transport(client: FetchClient) -> Arc { - AzureHttpClient::from(client).into() +fn transport(client: FetchClient) -> Arc { + HttpClient::from(client).into() } ``` @@ -44,8 +43,8 @@ fn transport(client: FetchClient) -> Arc { This crate was developed as part of The Oxidizer Project. Browse this crate's source code. - [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbLiTyV0MU86EbZU15e0PmecoboQ9jo59bnAEbyDXw04U13GlhYvRhcoQbzCTaIS0go2Ub8ViwNofWrMobqqDe_YG9-OUbqRZ4eEJ24fxhZIOCZWZldGNoZjAuMTEuMIJrZmV0Y2hfYXp1cmVlMC4xLjCCdHR5cGVzcGVjX2NsaWVudF9jb3JlZTEuMC4w + [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbLiTyV0MU86EbZU15e0PmecoboQ9jo59bnAEbyDXw04U13GlhYvRhcoQbUKR9me1iXZ8bPNIxVoL75w4bTc-gWeJmBuMbzmMs_QiBJylhZIOCZWZldGNoZjAuMTEuMIJrZmV0Y2hfYXp1cmVlMC4xLjCCdHR5cGVzcGVjX2NsaWVudF9jb3JlZTEuMC4w [__link0]: https://docs.rs/fetch/0.11.0/fetch/?search=HttpClient [__link1]: https://docs.rs/typespec_client_core/1.0.0/typespec_client_core/?search=http::HttpClient - [__link2]: https://docs.rs/fetch_azure/0.1.0/fetch_azure/?search=AzureHttpClient + [__link2]: https://docs.rs/fetch_azure/0.1.0/fetch_azure/?search=HttpClient [__link3]: https://docs.rs/fetch/0.11.0/fetch/?search=HttpClient diff --git a/crates/fetch_azure/examples/azure_transport.rs b/crates/fetch_azure/examples/azure_transport.rs index 61a8eee98..ae6fcdb8e 100644 --- a/crates/fetch_azure/examples/azure_transport.rs +++ b/crates/fetch_azure/examples/azure_transport.rs @@ -8,15 +8,15 @@ use std::sync::Arc; -use azure_core::http::{HttpClient, Method, Request, Url}; +use azure_core::http::{HttpClient as HttpClientTrait, Method, Request, Url}; use fetch::HttpClient as FetchClient; -use fetch_azure::AzureHttpClient; +use fetch_azure::HttpClient; #[tokio::main] async fn main() -> Result<(), Box> { // Build a `fetch` client (Tokio runtime + rustls TLS) and adapt it so it can // be used wherever the Azure SDK expects an `Arc` transport. - let transport: Arc = AzureHttpClient::from(FetchClient::new_tokio()).into(); + let transport: Arc = HttpClient::from(FetchClient::new_tokio()).into(); // In a real application you would hand `transport` to an Azure SDK client's // options. Here we drive it directly to show the round-trip. diff --git a/crates/fetch_azure/examples/blob_list.rs b/crates/fetch_azure/examples/blob_list.rs index e6838addf..f28ee5257 100644 --- a/crates/fetch_azure/examples/blob_list.rs +++ b/crates/fetch_azure/examples/blob_list.rs @@ -17,7 +17,7 @@ use azure_core::http::{ClientOptions, Transport, Url}; use azure_identity::{DeveloperToolsCredential, DeveloperToolsCredentialOptions, Executor}; use azure_storage_blob::{BlobServiceClient, BlobServiceClientOptions}; use fetch::HttpClient as FetchClient; -use fetch_azure::AzureHttpClient; +use fetch_azure::HttpClient; use futures::TryStreamExt as _; use tick::Clock; @@ -38,7 +38,7 @@ async fn main() -> Result<(), Box> { DeveloperToolsCredential::new(Some(DeveloperToolsCredentialOptions { executor: Some(executor) }))?; // Use a tokio `fetch` client as the Azure SDK transport. - let transport = Transport::new(AzureHttpClient::from(FetchClient::new_tokio()).into()); + let transport = Transport::new(HttpClient::from(FetchClient::new_tokio()).into()); let options = BlobServiceClientOptions { client_options: ClientOptions { transport: Some(transport), diff --git a/crates/fetch_azure/src/client.rs b/crates/fetch_azure/src/client.rs index f7ce460ee..0ddf40fb1 100644 --- a/crates/fetch_azure/src/client.rs +++ b/crates/fetch_azure/src/client.rs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! The [`AzureHttpClient`] transport adapter. +//! The [`HttpClient`] transport adapter. use std::collections::HashMap; use std::sync::Arc; @@ -14,28 +14,27 @@ use typespec_client_core::error::{Error, ErrorKind}; use typespec_client_core::http::headers::{HeaderName, HeaderValue, Headers}; use typespec_client_core::http::request::{Body, Request}; use typespec_client_core::http::response::PinnedStream; -use typespec_client_core::http::{AsyncRawResponse, HttpClient}; +use typespec_client_core::http::{AsyncRawResponse, HttpClient as HttpClientTrait}; -/// An [`HttpClient`] that uses a [`fetch::HttpClient`] as its transport. +/// A [`typespec_client_core::http::HttpClient`] backed by a [`fetch::HttpClient`] transport. /// -/// Construct one from an existing `fetch` client with [`AzureHttpClient::new`] +/// Construct one from an existing `fetch` client with [`HttpClient::new`] /// (or via [`From`]), then convert it into an `Arc` via [`From`] /// / [`Into`] to hand to the Azure SDK: /// /// ``` /// # use std::sync::Arc; -/// # use azure_core::http::HttpClient; -/// # use fetch_azure::AzureHttpClient; -/// # fn wrap(client: fetch::HttpClient) -> Arc { -/// AzureHttpClient::from(client).into() +/// # use fetch_azure::HttpClient; +/// # fn wrap(client: fetch::HttpClient) -> Arc { +/// HttpClient::from(client).into() /// # } /// ``` #[derive(Debug, Clone)] -pub struct AzureHttpClient { +pub struct HttpClient { client: fetch::HttpClient, } -impl AzureHttpClient { +impl HttpClient { /// Creates a new adapter that forwards requests to the given `fetch` client. #[must_use] pub const fn new(client: fetch::HttpClient) -> Self { @@ -88,20 +87,20 @@ impl AzureHttpClient { } } -impl From for AzureHttpClient { +impl From for HttpClient { fn from(client: fetch::HttpClient) -> Self { Self::new(client) } } -impl From for Arc { - fn from(client: AzureHttpClient) -> Self { +impl From for Arc { + fn from(client: HttpClient) -> Self { Arc::new(client) } } #[async_trait] -impl HttpClient for AzureHttpClient { +impl HttpClientTrait for HttpClient { async fn execute_request(&self, request: &Request) -> typespec_client_core::Result { let request = self.to_fetch_request(request)?; diff --git a/crates/fetch_azure/src/lib.rs b/crates/fetch_azure/src/lib.rs index 1e2b261fc..5bff3458a 100644 --- a/crates/fetch_azure/src/lib.rs +++ b/crates/fetch_azure/src/lib.rs @@ -9,7 +9,7 @@ //! Adapt a [`fetch::HttpClient`] into an Azure SDK HTTP transport. //! //! The Azure SDK abstracts its HTTP transport behind the -//! [`typespec_client_core::http::HttpClient`] trait. [`AzureHttpClient`] implements that +//! [`typespec_client_core::http::HttpClient`] trait. [`HttpClient`] implements that //! trait on top of a [`fetch::HttpClient`], so Azure SDK pipelines run over //! `fetch` and benefit from its resilience and observability. //! @@ -22,16 +22,15 @@ //! use std::sync::Arc; //! //! use fetch::HttpClient as FetchClient; -//! use fetch_azure::AzureHttpClient; -//! use typespec_client_core::http::HttpClient; +//! use fetch_azure::HttpClient; //! //! // Adapt a `fetch` client into an Azure SDK transport. -//! fn transport(client: FetchClient) -> Arc { -//! AzureHttpClient::from(client).into() +//! fn transport(client: FetchClient) -> Arc { +//! HttpClient::from(client).into() //! } //! # let _ = transport; //! ``` mod client; -pub use client::AzureHttpClient; +pub use client::HttpClient; diff --git a/crates/fetch_azure/tests/client.rs b/crates/fetch_azure/tests/client.rs index 612886f75..c197ec16a 100644 --- a/crates/fetch_azure/tests/client.rs +++ b/crates/fetch_azure/tests/client.rs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Integration tests for [`fetch_azure::AzureHttpClient`]. +//! Integration tests for [`fetch_azure::HttpClient`]. //! //! These exercise the transport adapter end-to-end using `fetch`'s //! `FakeHandler`, so no real network access is required. @@ -13,12 +13,12 @@ use std::task::{Context, Poll}; use async_trait::async_trait; use fetch::fake::FakeHandler; use fetch::{HttpClient as FetchClient, HttpResponseBuilder}; -use fetch_azure::AzureHttpClient; +use fetch_azure::HttpClient; use futures::io::AsyncRead; use typespec_client_core::Bytes; use typespec_client_core::http::headers::HeaderName; use typespec_client_core::http::request::{Body, Request}; -use typespec_client_core::http::{HttpClient, Method, Url}; +use typespec_client_core::http::{HttpClient as HttpClientTrait, Method, Url}; use typespec_client_core::stream::{BytesStream, SeekableStream}; fn request(method: Method) -> Request { @@ -39,7 +39,7 @@ async fn execute_request_maps_status_headers_and_body() { .text("world") .build() }); - let client = AzureHttpClient::new(FetchClient::new_fake(handler)); + let client = HttpClient::new(FetchClient::new_fake(handler)); let response = client.execute_request(&request(Method::Get)).await.unwrap(); @@ -61,7 +61,7 @@ async fn execute_request_forwards_method_and_bytes_body() { let body = request.into_body().into_bytes().await?; HttpResponseBuilder::new_fake().status(200u16).bytes(body).build() }); - let client = AzureHttpClient::new(FetchClient::new_fake(handler)); + let client = HttpClient::new(FetchClient::new_fake(handler)); let mut request = request(Method::Post); request.set_body(Bytes::from_static(b"payload")); @@ -79,7 +79,7 @@ async fn execute_request_forwards_seekable_stream_body() { let body = request.into_body().into_bytes().await?; HttpResponseBuilder::new_fake().status(200u16).bytes(body).build() }); - let client = AzureHttpClient::new(FetchClient::new_fake(handler)); + let client = HttpClient::new(FetchClient::new_fake(handler)); let mut request = request(Method::Put); request.set_body(BytesStream::new(Bytes::from_static(b"streamed"))); @@ -98,7 +98,7 @@ async fn execute_request_forwards_request_headers() { let status = if forwarded { 200u16 } else { 400u16 }; HttpResponseBuilder::new_fake().status(status).build() }); - let client = AzureHttpClient::new(FetchClient::new_fake(handler)); + let client = HttpClient::new(FetchClient::new_fake(handler)); let mut request = request(Method::Get); request.insert_header("x-correlation", "abc123"); @@ -116,7 +116,7 @@ async fn execute_request_maps_all_methods() { let status = if request.method().as_str() == expected { 200u16 } else { 400u16 }; HttpResponseBuilder::new_fake().status(status).build() }); - let client = AzureHttpClient::new(FetchClient::new_fake(handler)); + let client = HttpClient::new(FetchClient::new_fake(handler)); let response = client.execute_request(&request(method)).await.unwrap(); @@ -127,7 +127,7 @@ async fn execute_request_maps_all_methods() { #[tokio::test] async fn execute_request_maps_transport_error() { let handler = FakeHandler::from_error_fn(|_request| fetch::HttpError::unavailable("simulated transport failure")); - let client = AzureHttpClient::new(FetchClient::new_fake(handler)); + let client = HttpClient::new(FetchClient::new_fake(handler)); let error = client.execute_request(&request(Method::Get)).await.unwrap_err(); @@ -139,7 +139,7 @@ async fn execute_request_maps_transport_error() { #[tokio::test] async fn azure_http_client_converts_into_dyn_client() { - let client: Arc = AzureHttpClient::from(FetchClient::new_fake(status_handler(202))).into(); + let client: Arc = HttpClient::from(FetchClient::new_fake(status_handler(202))).into(); let response = client.execute_request(&request(Method::Get)).await.unwrap(); @@ -148,7 +148,7 @@ async fn azure_http_client_converts_into_dyn_client() { #[tokio::test] async fn execute_request_maps_request_build_failure() { - let client = AzureHttpClient::new(FetchClient::new_fake(status_handler(200))); + let client = HttpClient::new(FetchClient::new_fake(status_handler(200))); // A header value containing a control character is rejected by the `http` // crate when the fetch request is built, exercising the DataConversion path. @@ -175,7 +175,7 @@ async fn execute_request_skips_non_utf8_response_headers() { .header("x-binary", binary) .build() }); - let client = AzureHttpClient::new(FetchClient::new_fake(handler)); + let client = HttpClient::new(FetchClient::new_fake(handler)); let response = client.execute_request(&request(Method::Get)).await.unwrap(); @@ -190,7 +190,7 @@ async fn execute_request_maps_seekable_stream_read_error() { request.into_body().into_bytes().await?; HttpResponseBuilder::new_fake().status(200u16).build() }); - let client = AzureHttpClient::new(FetchClient::new_fake(handler)); + let client = HttpClient::new(FetchClient::new_fake(handler)); let mut request = request(Method::Post); request.set_body(Body::SeekableStream(Box::new(ErroringStream))); @@ -212,7 +212,7 @@ async fn execute_request_maps_response_body_read_error() { ); HttpResponseBuilder::new_fake().status(200u16).body(body).build() }); - let client = AzureHttpClient::new(FetchClient::new_fake(handler)); + let client = HttpClient::new(FetchClient::new_fake(handler)); let response = client.execute_request(&request(Method::Get)).await.unwrap(); let error = response.into_body().collect().await.unwrap_err(); From 4412e9ef34041507488cd063c049e908e99789c5 Mon Sep 17 00:00:00 2001 From: Martin Tomka Date: Fri, 12 Jun 2026 16:24:43 +0200 Subject: [PATCH 19/22] feat(anyspawn_azure): add From for Arc Mirror the existing \From for Arc\ with a feature-gated \From for Arc\ so a Runtime can be handed to credentials as a boxed executor, with a test covering the conversion. Also enable the \http\ feature on anyspawn_azure's typespec_client_core dependency: typespec_client_core's always-compiled \stream\ module imports the http-gated \crate::http\, so the crate does not build without it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/anyspawn_azure/Cargo.toml | 2 +- crates/anyspawn_azure/src/runtime.rs | 7 +++++++ crates/anyspawn_azure/tests/runtime.rs | 23 +++++++++++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/crates/anyspawn_azure/Cargo.toml b/crates/anyspawn_azure/Cargo.toml index 79d9f9773..2c77e6224 100644 --- a/crates/anyspawn_azure/Cargo.toml +++ b/crates/anyspawn_azure/Cargo.toml @@ -43,7 +43,7 @@ tick = { workspace = true } async-trait = { workspace = true, optional = true } azure_identity = { workspace = true, optional = true } futures = { workspace = true, features = ["std"] } -typespec_client_core = { workspace = true } +typespec_client_core = { workspace = true, features = ["http"] } [dev-dependencies] # internal diff --git a/crates/anyspawn_azure/src/runtime.rs b/crates/anyspawn_azure/src/runtime.rs index 795f31a00..ae49b8e07 100644 --- a/crates/anyspawn_azure/src/runtime.rs +++ b/crates/anyspawn_azure/src/runtime.rs @@ -52,6 +52,13 @@ impl From for Arc { } } +#[cfg(feature = "azure-identity")] +impl From for Arc { + fn from(runtime: Runtime) -> Self { + Arc::new(runtime) + } +} + impl AsyncRuntime for Runtime { fn spawn(&self, f: TaskFuture) -> SpawnedTask { // Wrap the task so that `abort` cancels it through `futures`: aborting diff --git a/crates/anyspawn_azure/tests/runtime.rs b/crates/anyspawn_azure/tests/runtime.rs index 409159b39..d2a00e070 100644 --- a/crates/anyspawn_azure/tests/runtime.rs +++ b/crates/anyspawn_azure/tests/runtime.rs @@ -98,3 +98,26 @@ async fn runtime_executor_runs_command() { assert!(output.status.success()); assert!(String::from_utf8_lossy(&output.stdout).contains("hello")); } + +#[cfg(feature = "azure-identity")] +#[tokio::test] +async fn runtime_converts_into_dyn_executor() { + use std::ffi::OsStr; + + use azure_identity::Executor; + + let executor: Arc = Runtime::new(Spawner::new_tokio(), Clock::new_tokio()).into(); + + #[cfg(windows)] + let output = executor + .run(OsStr::new("cmd"), &[OsStr::new("/C"), OsStr::new("echo hello")]) + .await + .unwrap(); + #[cfg(not(windows))] + let output = executor + .run(OsStr::new("/bin/sh"), &[OsStr::new("-c"), OsStr::new("echo hello")]) + .await + .unwrap(); + + assert!(output.status.success()); +} From 60af619d65322d8b1c15655ba5f51c72630933cf Mon Sep 17 00:00:00 2001 From: Martin Tomka Date: Fri, 12 Jun 2026 16:56:04 +0200 Subject: [PATCH 20/22] docs(fetch): enable doc_cfg feature on docs.rs builds Add \#![cfg_attr(docsrs, feature(doc_cfg))]\ to fetch's crate root so feature-gated items render with their feature requirements on docs.rs, matching fetch_azure and anyspawn_azure. The attribute is gated on the \docsrs\ cfg, so it is inert on stable builds. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/fetch/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/fetch/src/lib.rs b/crates/fetch/src/lib.rs index 98798ca1e..217461429 100644 --- a/crates/fetch/src/lib.rs +++ b/crates/fetch/src/lib.rs @@ -2,6 +2,7 @@ // Licensed under the MIT License. #![cfg_attr(all(coverage_nightly, test), feature(coverage_attribute))] +#![cfg_attr(docsrs, feature(doc_cfg))] #![cfg_attr( not(feature = "json"), allow( From d65f613c505a0a2f4167181908d2102dd698b97f Mon Sep 17 00:00:00 2001 From: Martin Tomka Date: Fri, 12 Jun 2026 17:23:46 +0200 Subject: [PATCH 21/22] test: exclude tokio-backed integration tests from Miri Miri cannot run tokio's runtime or spawn OS processes, so the runtime and transport integration tests fail under \cargo miri test\. Gate both test files with \#![cfg(not(miri))]\ (matching anyspawn's tokio test files); neither crate has unsafe code, so Miri loses no UB coverage. Also fix a stale doc reference to fetch_azure::Runtime in the moved runtime tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/anyspawn_azure/tests/runtime.rs | 4 +++- crates/fetch_azure/tests/client.rs | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/anyspawn_azure/tests/runtime.rs b/crates/anyspawn_azure/tests/runtime.rs index d2a00e070..b890366e8 100644 --- a/crates/anyspawn_azure/tests/runtime.rs +++ b/crates/anyspawn_azure/tests/runtime.rs @@ -1,7 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Integration tests for [`fetch_azure::Runtime`]. +#![cfg(not(miri))] // Miri cannot run tokio's runtime or spawn OS processes. + +//! Integration tests for [`anyspawn_azure::Runtime`]. //! //! These drive the runtime adapter on a real Tokio spawner and clock. diff --git a/crates/fetch_azure/tests/client.rs b/crates/fetch_azure/tests/client.rs index c197ec16a..dd80047f9 100644 --- a/crates/fetch_azure/tests/client.rs +++ b/crates/fetch_azure/tests/client.rs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +#![cfg(not(miri))] // Miri cannot run the tokio-backed fetch transport. + //! Integration tests for [`fetch_azure::HttpClient`]. //! //! These exercise the transport adapter end-to-end using `fetch`'s From 3438550811b566c7edcd8540bc2f1cf5e1e4ff9d Mon Sep 17 00:00:00 2001 From: Martin Tomka Date: Fri, 12 Jun 2026 18:25:03 +0200 Subject: [PATCH 22/22] test: allow missing_docs in Miri-gated test files Under Miri the cfg(not(miri)) guard strips each test crate's contents, including its module doc, leaving an empty crate that trips the denied missing_docs lint. Add an allow(missing_docs) attribute before the cfg guard (matching anyspawn's tokio test files) so the empty Miri build compiles cleanly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/anyspawn_azure/tests/runtime.rs | 1 + crates/fetch_azure/tests/client.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/crates/anyspawn_azure/tests/runtime.rs b/crates/anyspawn_azure/tests/runtime.rs index b890366e8..e395815e0 100644 --- a/crates/anyspawn_azure/tests/runtime.rs +++ b/crates/anyspawn_azure/tests/runtime.rs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +#![allow(missing_docs, reason = "test module")] #![cfg(not(miri))] // Miri cannot run tokio's runtime or spawn OS processes. //! Integration tests for [`anyspawn_azure::Runtime`]. diff --git a/crates/fetch_azure/tests/client.rs b/crates/fetch_azure/tests/client.rs index dd80047f9..90dbf8073 100644 --- a/crates/fetch_azure/tests/client.rs +++ b/crates/fetch_azure/tests/client.rs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +#![allow(missing_docs, reason = "test module")] #![cfg(not(miri))] // Miri cannot run the tokio-backed fetch transport. //! Integration tests for [`fetch_azure::HttpClient`].