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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Preserve LF line endings in test fixtures so tests pass on Windows.
test-files/** eol=lf
6 changes: 6 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
85 changes: 83 additions & 2 deletions .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ on:
- main
pull_request: {}

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
check:
runs-on: ubuntu-latest
Expand All @@ -22,6 +29,9 @@ jobs:
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@nightly
- uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: cargo doc
env:
RUSTDOCFLAGS: "-D rustdoc::broken_intra_doc_links --cfg docsrs"
Expand All @@ -35,7 +45,9 @@ jobs:
- uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
- uses: taiki-e/install-action@cargo-hack
- uses: taiki-e/install-action@v2
with:
tool: cargo-hack
- name: cargo hack check
env:
RUSTFLAGS: "-D unused_imports -D dead_code -D unused_variables"
Expand All @@ -59,6 +71,22 @@ jobs:
save-if: ${{ github.ref == 'refs/heads/main' }}
- run: cargo test --workspace --all-features

test-os:
# Test on macOS and Windows to catch platform-specific issues.
needs: check
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [macos-latest, windows-latest]
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
- run: cargo test --workspace --all-features

test-msrv:
needs: check
runs-on: ubuntu-latest
Expand All @@ -80,6 +108,59 @@ jobs:
save-if: ${{ github.ref == 'refs/heads/main' }}
- run: cargo check -p tower-http --all-features

minimal-versions:
needs: check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@nightly
- uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Install cargo-hack
uses: taiki-e/install-action@v2
with:
tool: cargo-hack
- name: Check with minimal versions
run: |
cargo hack --remove-dev-deps
cargo update -Z minimal-versions
cargo update -p crc32fast --precise 1.2.0
cargo check -p tower-http --all-features

semver-checks:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Install cargo-semver-checks
uses: taiki-e/install-action@v2
with:
tool: cargo-semver-checks
- name: Check semver compatibility
id: checks
run: |
set +e
OUTPUT=$(cargo semver-checks check-release -p tower-http --all-features \
--baseline-rev origin/${{ github.base_ref }} --color never 2>&1)
STATUS=$?
echo "$OUTPUT"
if [ $STATUS -ne 0 ]; then
echo "$OUTPUT" > semver-output.txt
fi
- name: Upload results
if: always() && hashFiles('semver-output.txt') != ''
uses: actions/upload-artifact@v7
with:
name: semver-checks-output
path: semver-output.txt

style:
needs: check
runs-on: ubuntu-latest
Expand Down Expand Up @@ -124,7 +205,7 @@ jobs:
with:
toolchain: nightly-2025-10-18
- name: Install cargo-check-external-types
uses: taiki-e/cache-cargo-install-action@v2
uses: taiki-e/cache-cargo-install-action@v3
with:
tool: cargo-check-external-types@0.4.0
- uses: Swatinem/rust-cache@v2
Expand Down
58 changes: 58 additions & 0 deletions .github/workflows/semver-comment.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
name: Semver Checks Comment

on:
workflow_run:
workflows: ["CI"]
types:
- completed

permissions:
actions: read
pull-requests: write

jobs:
comment:
runs-on: ubuntu-latest
if: github.event.workflow_run.event == 'pull_request'
steps:
- name: Download results
id: download
uses: actions/download-artifact@v8
with:
name: semver-checks-output
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}
continue-on-error: true

- name: Build comment
id: build
if: steps.download.outcome == 'success'
run: |
echo "comment<<EOF" >> "$GITHUB_OUTPUT"
echo "### ⚠️ Breaking API changes detected" >> "$GITHUB_OUTPUT"
echo "" >> "$GITHUB_OUTPUT"
echo "Please make sure these are intentional and noted in the changelog." >> "$GITHUB_OUTPUT"
echo "" >> "$GITHUB_OUTPUT"
echo "<details><summary>cargo semver-checks output</summary>" >> "$GITHUB_OUTPUT"
echo "" >> "$GITHUB_OUTPUT"
echo '```' >> "$GITHUB_OUTPUT"
cat semver-output.txt >> "$GITHUB_OUTPUT"
echo '```' >> "$GITHUB_OUTPUT"
echo "</details>" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"

- name: Post comment
if: steps.build.outputs.comment
uses: marocchino/sticky-pull-request-comment@v3
with:
number: ${{ github.event.workflow_run.pull_requests[0].number }}
header: semver-checks
message: ${{ steps.build.outputs.comment }}

- name: Hide comment
if: steps.download.outcome == 'failure'
uses: marocchino/sticky-pull-request-comment@v3
with:
number: ${{ github.event.workflow_run.pull_requests[0].number }}
header: semver-checks
hide: true
2 changes: 1 addition & 1 deletion test-files/missing_precompressed.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
Test file!
Test file
Binary file modified test-files/only_gzipped.txt.gz
Binary file not shown.
2 changes: 1 addition & 1 deletion test-files/precompressed.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
"This is a test file!"
Test file
2 changes: 1 addition & 1 deletion test-files/precompressed.txt.br
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
€"This is a test file!"
‹€Test file

Binary file modified test-files/precompressed.txt.gz
Binary file not shown.
Binary file modified test-files/precompressed.txt.zst
Binary file not shown.
Binary file modified test-files/precompressed.txt.zz
Binary file not shown.
18 changes: 18 additions & 0 deletions tower-http/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

# Unreleased

## Changed

- `trace`: `DefaultOnRequest`, `DefaultOnResponse`, `DefaultOnFailure`, and
`DefaultOnEos` now explicitly parent their tracing events to the request span
rather than relying on the ambient span context. This fixes intermittent cases
where events could appear without their request span attached ([#655])
- The implicit `tokio` and `async-compression` features are removed (BREAKING).
These were kept as no-op features in 0.6.x for backwards compatibility after
the switch to `dep:` syntax in [#642]. Downstream crates that activate
`tower-http/tokio` or `tower-http/async-compression` should remove those
feature entries; the underlying dependencies are still pulled in transitively
by the features that need them (e.g. `compression-gzip`, `fs`, `timeout`).
([#628])

[#628]: https://github.com/tower-rs/tower-http/pull/628
[#642]: https://github.com/tower-rs/tower-http/pull/642

# 0.6.11

## Added
Expand Down Expand Up @@ -51,6 +68,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
[#408]: https://github.com/tower-rs/tower-http/pull/408
[#506]: https://github.com/tower-rs/tower-http/pull/506
[#587]: https://github.com/tower-rs/tower-http/pull/587
[#655]: https://github.com/tower-rs/tower-http/issues/655
[#672]: https://github.com/tower-rs/tower-http/pull/672
[#675]: https://github.com/tower-rs/tower-http/pull/675
[#677]: https://github.com/tower-rs/tower-http/pull/677
Expand Down
5 changes: 0 additions & 5 deletions tower-http/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -120,11 +120,6 @@ decompression-full = ["decompression-br", "decompression-deflate", "decompressio
decompression-gzip = ["dep:async-compression", "async-compression?/gzip", "futures-core", "dep:http-body", "dep:http-body-util", "tokio-util", "dep:tokio"]
decompression-zstd = ["dep:async-compression", "async-compression?/zstd", "futures-core", "dep:http-body", "dep:http-body-util", "tokio-util", "dep:tokio"]

# FIXME: rip this out come 0.7.0.
# ref: https://github.com/tower-rs/tower-http/pull/666#issuecomment-4382555061
tokio = []
async-compression = []

[package.metadata.docs.rs]
all-features = true
rustdoc-args = ["--cfg", "docsrs"]
Expand Down
85 changes: 84 additions & 1 deletion tower-http/src/compression/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ mod tests {
use super::*;
use crate::test_helpers::{Body, WithTrailers};
use async_compression::tokio::write::{BrotliDecoder, BrotliEncoder};
use bytes::Bytes;
use flate2::read::GzDecoder;
use http::header::{
ACCEPT_ENCODING, ACCEPT_RANGES, CONTENT_ENCODING, CONTENT_RANGE, CONTENT_TYPE, RANGE,
Expand All @@ -106,7 +107,7 @@ mod tests {
use std::sync::{Arc, RwLock};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio_util::io::StreamReader;
use tower::{service_fn, Service, ServiceExt};
use tower::{service_fn, BoxError, Service, ServiceExt};

// Compression filter allows every other request to be compressed
#[derive(Clone)]
Expand Down Expand Up @@ -522,6 +523,88 @@ mod tests {
assert_eq!(decompressed, "Hello, World!");
}

#[tokio::test]
async fn trailers_with_empty_body() {
let svc = service_fn(|_req: Request<Body>| async {
let mut trailers = HeaderMap::new();
trailers.insert("grpc-status", "0".parse().unwrap());
trailers.insert("grpc-message", "OK".parse().unwrap());
let body = Body::empty().with_trailers(trailers);
Ok::<_, Infallible>(Response::builder().body(body).unwrap())
});
let mut svc = Compression::new(svc).compress_when(Always);

let req = Request::builder()
.header("accept-encoding", "gzip")
.body(Body::empty())
.unwrap();
let res = svc.ready().await.unwrap().call(req).await.unwrap();

let collected = res.into_body().collect().await.unwrap();
let trailers = collected.trailers().cloned().unwrap();
assert_eq!(trailers["grpc-status"], "0");
assert_eq!(trailers["grpc-message"], "OK");
}

#[tokio::test]
async fn trailers_with_streamed_body() {
// Simulate a gRPC-like streamed response: multiple data frames followed by trailers
let svc = service_fn(|_req: Request<Body>| async {
let stream = futures_util::stream::iter(vec![
Ok::<_, BoxError>(Bytes::from("chunk1")),
Ok(Bytes::from("chunk2")),
Ok(Bytes::from("chunk3")),
]);
let mut trailers = HeaderMap::new();
trailers.insert("grpc-status", "0".parse().unwrap());
let body = Body::from_stream(stream).with_trailers(trailers);
Ok::<_, Infallible>(Response::builder().body(body).unwrap())
});
let mut svc = Compression::new(svc).compress_when(Always);

let req = Request::builder()
.header("accept-encoding", "gzip")
.body(Body::empty())
.unwrap();
let res = svc.ready().await.unwrap().call(req).await.unwrap();

let collected = res.into_body().collect().await.unwrap();
let trailers = collected.trailers().cloned().unwrap();
let compressed_data = collected.to_bytes();

let mut decoder = GzDecoder::new(&compressed_data[..]);
let mut decompressed = String::new();
decoder.read_to_string(&mut decompressed).unwrap();

assert_eq!(decompressed, "chunk1chunk2chunk3");
assert_eq!(trailers["grpc-status"], "0");
}

#[tokio::test]
async fn trailers_with_grpc_web_content_type() {
let svc = service_fn(|_req: Request<Body>| async {
let mut trailers = HeaderMap::new();
trailers.insert("grpc-status", "0".parse().unwrap());
let body = Body::from("a".repeat((SizeAbove::DEFAULT_MIN_SIZE * 2) as usize))
.with_trailers(trailers);
let mut res = Response::new(body);
res.headers_mut()
.insert(CONTENT_TYPE, "application/grpc-web+proto".parse().unwrap());
Ok::<_, Infallible>(res)
});
let mut svc = Compression::new(svc).compress_when(Always);

let req = Request::builder()
.header("accept-encoding", "gzip")
.body(Body::empty())
.unwrap();
let res = svc.ready().await.unwrap().call(req).await.unwrap();

let collected = res.into_body().collect().await.unwrap();
let trailers = collected.trailers().cloned().unwrap();
assert_eq!(trailers["grpc-status"], "0");
}

#[tokio::test]
async fn size_hint_identity() {
let msg = "Hello, world!";
Expand Down
12 changes: 1 addition & 11 deletions tower-http/src/compression_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -238,19 +238,9 @@ where
// poll any remaining frames, such as trailers
let body = M::get_pin_mut(this.read).get_pin_mut().get_pin_mut();
match ready!(body.poll_frame(cx)) {
Some(Ok(frame)) if frame.is_trailers() => Poll::Ready(Some(Ok(
Some(Ok(frame)) => Poll::Ready(Some(Ok(
frame.map_data(|mut data| data.copy_to_bytes(data.remaining()))
))),
Some(Ok(frame)) => {
if let Ok(bytes) = frame.into_data() {
if bytes.has_remaining() {
return Poll::Ready(Some(Err(
"there are extra bytes after body has been decompressed".into(),
)));
}
}
Poll::Ready(None)
}
Some(Err(err)) => Poll::Ready(Some(Err(err.into()))),
None => Poll::Ready(None),
}
Expand Down
2 changes: 1 addition & 1 deletion tower-http/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@
clippy::match_like_matches_macro,
clippy::type_complexity
)]
#![forbid(unsafe_code)]
#![deny(unsafe_code)]
#![cfg_attr(docsrs, feature(doc_cfg))]
#![cfg_attr(test, allow(clippy::float_cmp))]

Expand Down
Loading
Loading