diff --git a/.clippy.toml b/.clippy.toml index ece14b8d..62ca7423 100644 --- a/.clippy.toml +++ b/.clippy.toml @@ -1 +1 @@ -msrv = "1.54" +msrv = "1.56" diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 2c335f2c..11472058 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -2,6 +2,7 @@ name: CI env: CARGO_TERM_COLOR: always + MSRV: 1.56.0 on: push: @@ -88,8 +89,8 @@ jobs: command: test args: --all --all-features --all-targets - # some examples doesn't support 1.54 (such as async-graphql) - # so we only test axum itself on 1.54 + # some examples doesn't support our MSRV (such as async-graphql) + # so we only test axum itself on our MSRV test-msrv: needs: check runs-on: ubuntu-latest @@ -97,10 +98,21 @@ jobs: - uses: actions/checkout@master - uses: actions-rs/toolchain@v1 with: - toolchain: 1.54 + toolchain: ${{ env.MSRV }} override: true profile: minimal + - name: "install Rust nightly" + uses: actions-rs/toolchain@v1 + with: + toolchain: nightly + profile: minimal - uses: Swatinem/rust-cache@v1 + - name: Select minimal versions + uses: actions-rs/cargo@v1 + with: + command: update + args: -Z minimal-versions + toolchain: nightly - name: Run tests uses: actions-rs/cargo@v1 with: @@ -109,8 +121,12 @@ jobs: -p axum -p axum-extra -p axum-core - --all-features --all-targets - # the compiler errors are different on 1.54 which makes + -p internal-minimal-versions + --all-features + --all-targets + --locked + toolchain: ${{ env.MSRV }} + # the compiler errors are different on our MSRV which makes # the trybuild tests in axum-macros fail, so just run the doc # tests - name: Run axum-macros doc tests @@ -119,8 +135,11 @@ jobs: command: test args: > -p axum-macros + -p internal-minimal-versions --doc --all-features + --locked + toolchain: ${{ env.MSRV }} test-docs: needs: check diff --git a/Cargo.toml b/Cargo.toml index 51b1cc39..a221fbc5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,4 +4,9 @@ members = [ "axum-core", "axum-extra", "axum-macros", + + # internal crate used to bump the minimum versions we + # get for some dependencies which otherwise wouldn't build + # with `cargo +nightly update -Z minimal-versions` + "internal-minimal-versions", ] diff --git a/ECOSYSTEM.md b/ECOSYSTEM.md index b708d871..c0a65891 100644 --- a/ECOSYSTEM.md +++ b/ECOSYSTEM.md @@ -10,7 +10,7 @@ If your project isn't listed here and you would like it to be, please feel free - [axum-flash](https://crates.io/crates/axum-flash): One-time notifications (aka flash messages) for axum. - [axum-msgpack](https://crates.io/crates/axum-msgpack): MessagePack Extractors for axum. - [axum-sqlx-tx](https://crates.io/crates/axum-sqlx-tx): Request-bound [SQLx](https://github.com/launchbadge/sqlx#readme) transactions with automatic commit/rollback based on response. -- [aliri_tower](https://crates.io/crates/aliri_tower): JWT validation and OAuth2 scopes checking middleware. +- [aliri_axum](https://docs.rs/aliri_axum) and [aliri_tower](https://docs.rs/aliri_tower): JWT validation middleware and OAuth2 scopes enforcing extractors. - [ezsockets](https://github.com/gbaranski/ezsockets): Easy to use WebSocket library that integrates with Axum. - [axum_database_sessions](https://github.com/AscendingCreations/AxumSessions): Database persistent sessions like pythons flask_sessionstore for Axum. - [axum_sessions_auth](https://github.com/AscendingCreations/AxumSessionsAuth): Persistant session based user login with rights management for Axum. @@ -18,6 +18,7 @@ If your project isn't listed here and you would like it to be, please feel free - [shuttle](https://github.com/getsynth/shuttle): A serverless platform built for Rust. Now with axum support. - [axum-tungstenite](https://github.com/davidpdrsn/axum-tungstenite): WebSocket connections for axum directly using tungstenite - [axum-jrpc](https://github.com/0xdeafbeef/axum-jrpc): Json-rpc extractor for axum +- [axum-tracing-opentelemetry](https://crates.io/crates/axum-tracing-opentelemetry): Middlewares and tools to integrate axum + tracing + opentelemetry ## Project showcase @@ -35,6 +36,7 @@ If your project isn't listed here and you would like it to be, please feel free - [CLOMonitor](https://clomonitor.io) ([repository](https://github.com/cncf/clomonitor)): Checks open source projects repositories to verify they meet certain best practices. - [Pinging.net](https://www.pinging.net) ([repository](https://github.com/benhansenslc/pinging)): A new way to check and monitor your internet connection. - [wastebin](https://github.com/matze/wastebin): A minimalist pastebin service. +- [sandbox_axum_observability](https://github.com/davidB/sandbox_axum_observability) A Sandbox/showcase project to experiment axum and observability (tracing, opentelemetry, jaeger, grafana tempo,...) [Realworld]: https://github.com/gothinkster/realworld [SQLx]: https://github.com/launchbadge/sqlx diff --git a/axum-core/CHANGELOG.md b/axum-core/CHANGELOG.md index e63ee5d2..da272e5f 100644 --- a/axum-core/CHANGELOG.md +++ b/axum-core/CHANGELOG.md @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - None. +# 0.2.6 (18. June, 2022) + +- **change:** axum-core's MSRV is now 1.56 ([#1098]) + +[#1098]: https://github.com/tokio-rs/axum/pull/1098 + # 0.2.5 (08. June, 2022) - **added:** Automatically handle `http_body::LengthLimitError` in `FailedToBufferBody` and map diff --git a/axum-core/Cargo.toml b/axum-core/Cargo.toml index 0f7cb823..4d6e74e7 100644 --- a/axum-core/Cargo.toml +++ b/axum-core/Cargo.toml @@ -1,14 +1,14 @@ [package] categories = ["asynchronous", "network-programming", "web-programming"] description = "Core types and traits for axum" -edition = "2018" +edition = "2021" homepage = "https://github.com/tokio-rs/axum" keywords = ["http", "web", "framework"] license = "MIT" name = "axum-core" readme = "README.md" repository = "https://github.com/tokio-rs/axum" -version = "0.2.5" # remember to also bump the version that axum depends on +version = "0.2.6" # remember to also bump the version that axum depends on [dependencies] async-trait = "0.1" diff --git a/axum-core/README.md b/axum-core/README.md index 2506b283..bd5efbeb 100644 --- a/axum-core/README.md +++ b/axum-core/README.md @@ -14,7 +14,7 @@ This crate uses `#![forbid(unsafe_code)]` to ensure everything is implemented in ## Minimum supported Rust version -axum-core's MSRV is 1.54. +axum-core's MSRV is 1.56. ## Getting Help diff --git a/axum-core/src/response/append_headers.rs b/axum-core/src/response/append_headers.rs index 0eb7c14b..d777332f 100644 --- a/axum-core/src/response/append_headers.rs +++ b/axum-core/src/response/append_headers.rs @@ -1,6 +1,6 @@ use super::{IntoResponse, IntoResponseParts, Response, ResponseParts, TryIntoHeaderError}; use http::header::{HeaderName, HeaderValue}; -use std::{convert::TryInto, fmt}; +use std::fmt; /// Append headers to a response. /// diff --git a/axum-core/src/response/into_response.rs b/axum-core/src/response/into_response.rs index 336f2adb..1ad461f8 100644 --- a/axum-core/src/response/into_response.rs +++ b/axum-core/src/response/into_response.rs @@ -11,7 +11,7 @@ use http_body::{ }; use std::{ borrow::Cow, - convert::{Infallible, TryInto}, + convert::Infallible, fmt, pin::Pin, task::{Context, Poll}, diff --git a/axum-core/src/response/into_response_parts.rs b/axum-core/src/response/into_response_parts.rs index 00c2763d..831e35cb 100644 --- a/axum-core/src/response/into_response_parts.rs +++ b/axum-core/src/response/into_response_parts.rs @@ -3,10 +3,7 @@ use http::{ header::{HeaderMap, HeaderName, HeaderValue}, Extensions, StatusCode, }; -use std::{ - convert::{Infallible, TryInto}, - fmt, -}; +use std::{convert::Infallible, fmt}; /// Trait for adding headers and extensions to a response. /// diff --git a/axum-extra/CHANGELOG.md b/axum-extra/CHANGELOG.md index e2dd7ae8..4ce07962 100644 --- a/axum-extra/CHANGELOG.md +++ b/axum-extra/CHANGELOG.md @@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning]. - None. +# 0.3.5 (27. June, 2022) + +- **added:** Add `JsonLines` for streaming newline delimited JSON ([#1093]) +- **change:** axum's MSRV is now 1.56 ([#1098]) + +[#1093]: https://github.com/tokio-rs/axum/pull/1093 +[#1098]: https://github.com/tokio-rs/axum/pull/1098 + # 0.3.4 (08. June, 2022) - **fixed:** Use `impl IntoResponse` less in docs ([#1049]) diff --git a/axum-extra/Cargo.toml b/axum-extra/Cargo.toml index 9a919193..47b5ca64 100644 --- a/axum-extra/Cargo.toml +++ b/axum-extra/Cargo.toml @@ -1,14 +1,14 @@ [package] categories = ["asynchronous", "network-programming", "web-programming"] description = "Extra utilities for axum" -edition = "2018" +edition = "2021" homepage = "https://github.com/tokio-rs/axum" keywords = ["http", "web", "framework"] license = "MIT" name = "axum-extra" readme = "README.md" repository = "https://github.com/tokio-rs/axum" -version = "0.3.4" +version = "0.3.5" [features] default = [] @@ -19,6 +19,7 @@ cookie-private = ["cookie", "cookie-lib/private"] cookie-signed = ["cookie", "cookie-lib/signed"] erased-json = ["serde_json", "serde"] form = ["serde", "serde_html_form"] +json-lines = ["serde_json", "serde", "tokio-util/io", "tokio-stream/io-util"] query = ["serde", "serde_html_form"] spa = ["tower-http/fs"] typed-routing = ["axum-macros", "serde", "percent-encoding"] @@ -26,6 +27,7 @@ typed-routing = ["axum-macros", "serde", "percent-encoding"] [dependencies] axum = { path = "../axum", version = "0.5", default-features = false } bytes = "1.1.0" +futures-util = { version = "0.3", default-features = false, features = ["alloc"] } http = "0.2" mime = "0.3" pin-project-lite = "0.2" @@ -42,10 +44,12 @@ percent-encoding = { version = "2.1", optional = true } serde = { version = "1.0", optional = true } serde_html_form = { version = "0.1", optional = true } serde_json = { version = "1.0.71", optional = true } +tokio-stream = { version = "0.1.9", optional = true } tokio-util = { version = "0.7", optional = true } [dev-dependencies] axum = { path = "../axum", version = "0.5", features = ["headers"] } +futures = "0.3" hyper = "0.14" reqwest = { version = "0.11", default-features = false, features = ["json", "stream", "multipart"] } serde = { version = "1.0", features = ["derive"] } diff --git a/axum-extra/README.md b/axum-extra/README.md index 3efacb9c..07d2252e 100644 --- a/axum-extra/README.md +++ b/axum-extra/README.md @@ -14,7 +14,7 @@ This crate uses `#![forbid(unsafe_code)]` to ensure everything is implemented in ## Minimum supported Rust version -axum-extra's MSRV is 1.54. +axum-extra's MSRV is 1.56. ## Getting Help diff --git a/axum-extra/src/extract/mod.rs b/axum-extra/src/extract/mod.rs index 9abed09f..22e19559 100644 --- a/axum-extra/src/extract/mod.rs +++ b/axum-extra/src/extract/mod.rs @@ -27,3 +27,7 @@ pub use self::form::Form; #[cfg(feature = "query")] pub use self::query::Query; + +#[cfg(feature = "json-lines")] +#[doc(no_inline)] +pub use crate::json_lines::JsonLines; diff --git a/axum-extra/src/json_lines.rs b/axum-extra/src/json_lines.rs new file mode 100644 index 00000000..1bc8d1a0 --- /dev/null +++ b/axum-extra/src/json_lines.rs @@ -0,0 +1,286 @@ +//! Newline delimited JSON extractor and response. + +use axum::{ + async_trait, + body::{HttpBody, StreamBody}, + extract::{rejection::BodyAlreadyExtracted, FromRequest, RequestParts}, + response::{IntoResponse, Response}, + BoxError, +}; +use bytes::{BufMut, Bytes, BytesMut}; +use futures_util::stream::{BoxStream, Stream, TryStream, TryStreamExt}; +use pin_project_lite::pin_project; +use serde::{de::DeserializeOwned, Serialize}; +use std::{ + io::{self, Write}, + marker::PhantomData, + pin::Pin, + task::{Context, Poll}, +}; +use tokio::io::AsyncBufReadExt; +use tokio_stream::wrappers::LinesStream; +use tokio_util::io::StreamReader; + +pin_project! { + /// A stream of newline delimited JSON. + /// + /// This can be used both as an extractor and as a response. + /// + /// # As extractor + /// + /// ```rust + /// use axum_extra::json_lines::JsonLines; + /// use futures::stream::StreamExt; + /// + /// async fn handler(mut stream: JsonLines) { + /// while let Some(value) = stream.next().await { + /// // ... + /// } + /// } + /// ``` + /// + /// # As response + /// + /// ```rust + /// use axum::{BoxError, response::{IntoResponse, Response}}; + /// use axum_extra::json_lines::JsonLines; + /// use futures::stream::Stream; + /// + /// fn stream_of_values() -> impl Stream> { + /// # futures::stream::empty() + /// } + /// + /// async fn handler() -> Response { + /// JsonLines::new(stream_of_values()).into_response() + /// } + /// ``` + // we use `AsExtractor` as the default because you're more likely to name this type if its used + // as an extractor + pub struct JsonLines { + #[pin] + inner: Inner, + _marker: PhantomData, + } +} + +pin_project! { + #[project = InnerProj] + enum Inner { + Response { + #[pin] + stream: S, + }, + Extractor { + #[pin] + stream: BoxStream<'static, Result>, + }, + } +} + +/// Maker type used to prove that an `JsonLines` was constructed via `FromRequest`. +#[derive(Debug)] +#[non_exhaustive] +pub struct AsExtractor; + +/// Maker type used to prove that an `JsonLines` was constructed via `JsonLines::new`. +#[derive(Debug)] +#[non_exhaustive] +pub struct AsResponse; + +impl JsonLines { + /// Create a new `JsonLines` from a stream of items. + pub fn new(stream: S) -> Self { + Self { + inner: Inner::Response { stream }, + _marker: PhantomData, + } + } +} + +#[async_trait] +impl FromRequest for JsonLines +where + B: HttpBody + Send + 'static, + B::Data: Into, + B::Error: Into, + T: DeserializeOwned, +{ + type Rejection = BodyAlreadyExtracted; + + async fn from_request(req: &mut RequestParts) -> Result { + // `Stream::lines` isn't a thing so we have to convert it into an `AsyncRead` + // so we can call `AsyncRead::lines` and then convert it back to a `Stream` + + let body = req.take_body().ok_or_else(BodyAlreadyExtracted::default)?; + let body = BodyStream { body }; + + let stream = body + .map_ok(Into::into) + .map_err(|err| io::Error::new(io::ErrorKind::Other, err)); + let read = StreamReader::new(stream); + let lines_stream = LinesStream::new(read.lines()); + + let deserialized_stream = + lines_stream + .map_err(axum::Error::new) + .and_then(|value| async move { + serde_json::from_str::(&value).map_err(axum::Error::new) + }); + + Ok(Self { + inner: Inner::Extractor { + stream: Box::pin(deserialized_stream), + }, + _marker: PhantomData, + }) + } +} + +// like `axum::extract::BodyStream` except it doesn't box the inner body +// we don't need that since we box the final stream in `Inner::Extractor` +pin_project! { + struct BodyStream { + #[pin] + body: B, + } +} + +impl Stream for BodyStream +where + B: HttpBody + Send + 'static, +{ + type Item = Result; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.project().body.poll_data(cx) + } +} + +impl Stream for JsonLines { + type Item = Result; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.project().inner.project() { + InnerProj::Extractor { stream } => stream.poll_next(cx), + // `JsonLines<_, AsExtractor>` can only be constructed via `FromRequest` + // which doesn't use this variant + InnerProj::Response { .. } => unreachable!(), + } + } +} + +impl IntoResponse for JsonLines +where + S: TryStream + Send + 'static, + S::Ok: Serialize + Send, + S::Error: Into, +{ + fn into_response(self) -> Response { + let inner = match self.inner { + Inner::Response { stream } => stream, + // `JsonLines<_, AsResponse>` can only be constructed via `JsonLines::new` + // which doesn't use this variant + Inner::Extractor { .. } => unreachable!(), + }; + + let stream = inner.map_err(Into::into).and_then(|value| async move { + let mut buf = BytesMut::new().writer(); + serde_json::to_writer(&mut buf, &value)?; + buf.write_all(b"\n")?; + Ok::<_, BoxError>(buf.into_inner().freeze()) + }); + let stream = StreamBody::new(stream); + + // there is no consensus around mime type yet + // https://github.com/wardi/jsonlines/issues/36 + stream.into_response() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_helpers::*; + use axum::{ + routing::{get, post}, + Router, + }; + use futures_util::StreamExt; + use http::StatusCode; + use serde::Deserialize; + use std::{convert::Infallible, error::Error}; + + #[derive(Serialize, Deserialize, PartialEq, Eq, Debug)] + struct User { + id: i32, + } + + #[tokio::test] + async fn extractor() { + let app = Router::new().route( + "/", + post(|mut stream: JsonLines| async move { + assert_eq!(stream.next().await.unwrap().unwrap(), User { id: 1 }); + assert_eq!(stream.next().await.unwrap().unwrap(), User { id: 2 }); + assert_eq!(stream.next().await.unwrap().unwrap(), User { id: 3 }); + + // sources are downcastable to `serde_json::Error` + let err = stream.next().await.unwrap().unwrap_err(); + let _: &serde_json::Error = err + .source() + .unwrap() + .downcast_ref::() + .unwrap(); + }), + ); + + let client = TestClient::new(app); + + let res = client + .post("/") + .body( + vec![ + "{\"id\":1}", + "{\"id\":2}", + "{\"id\":3}", + // to trigger an error for source downcasting + "{\"id\":false}", + ] + .join("\n"), + ) + .send() + .await; + assert_eq!(res.status(), StatusCode::OK); + } + + #[tokio::test] + async fn response() { + let app = Router::new().route( + "/", + get(|| async { + let values = futures_util::stream::iter(vec![ + Ok::<_, Infallible>(User { id: 1 }), + Ok::<_, Infallible>(User { id: 2 }), + Ok::<_, Infallible>(User { id: 3 }), + ]); + JsonLines::new(values) + }), + ); + + let client = TestClient::new(app); + + let res = client.get("/").send().await; + + let values = res + .text() + .await + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .collect::>(); + + assert_eq!( + values, + vec![User { id: 1 }, User { id: 2 }, User { id: 3 },] + ); + } +} diff --git a/axum-extra/src/lib.rs b/axum-extra/src/lib.rs index 258ce4f6..a63080eb 100644 --- a/axum-extra/src/lib.rs +++ b/axum-extra/src/lib.rs @@ -15,6 +15,7 @@ //! `cookie-signed` | Enables the `SignedCookieJar` extractor | No //! `erased-json` | Enables the `ErasedJson` response | No //! `form` | Enables the `Form` extractor | No +//! `json-lines` | Enables the `json-lines` extractor and response | No //! `query` | Enables the `Query` extractor | No //! `spa` | Enables the `Spa` router | No //! `typed-routing` | Enables the `TypedPath` routing utilities | No @@ -67,6 +68,9 @@ pub mod extract; pub mod response; pub mod routing; +#[cfg(feature = "json-lines")] +pub mod json_lines; + #[cfg(feature = "typed-routing")] #[doc(hidden)] pub mod __private { diff --git a/axum-extra/src/response/mod.rs b/axum-extra/src/response/mod.rs index 9324c805..7926b8c8 100644 --- a/axum-extra/src/response/mod.rs +++ b/axum-extra/src/response/mod.rs @@ -5,3 +5,7 @@ mod erased_json; #[cfg(feature = "erased-json")] pub use erased_json::ErasedJson; + +#[cfg(feature = "json-lines")] +#[doc(no_inline)] +pub use crate::json_lines::JsonLines; diff --git a/axum-extra/src/routing/resource.rs b/axum-extra/src/routing/resource.rs index 86e83505..af870b2b 100644 --- a/axum-extra/src/routing/resource.rs +++ b/axum-extra/src/routing/resource.rs @@ -204,6 +204,7 @@ mod tests { .edit(|Path(id): Path| async move { format!("users#edit id={}", id) }) .update(|Path(id): Path| async move { format!("users#update id={}", id) }) .destroy(|Path(id): Path| async move { format!("users#destroy id={}", id) }) + // TODO(david): figure out if we need `nest_service` + `nest` methods on `Resource` .nest(Router::new().route( "/tweets", get(|Path(id): Path| async move { format!("users#tweets id={}", id) }), diff --git a/axum-macros/CHANGELOG.md b/axum-macros/CHANGELOG.md index 812c6c53..406fa472 100644 --- a/axum-macros/CHANGELOG.md +++ b/axum-macros/CHANGELOG.md @@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - None. +# 0.2.3 (27. June, 2022) + +- **change:** axum-macros's MSRV is now 1.56 ([#1098]) +- **fixed:** Silence "unnecessary use of `to_string`" lint for `#[derive(TypedPath)]` ([#1117]) + +[#1098]: https://github.com/tokio-rs/axum/pull/1098 +[#1117]: https://github.com/tokio-rs/axum/pull/1117 + # 0.2.2 (18. May, 2022) - **added:** In `debug_handler`, check if `Request` is used as non-final extractor ([#1035]) diff --git a/axum-macros/Cargo.toml b/axum-macros/Cargo.toml index e3229d35..af9e65ea 100644 --- a/axum-macros/Cargo.toml +++ b/axum-macros/Cargo.toml @@ -1,14 +1,14 @@ [package] categories = ["asynchronous", "network-programming", "web-programming"] description = "Macros for axum" -edition = "2018" +edition = "2021" homepage = "https://github.com/tokio-rs/axum" keywords = ["axum"] license = "MIT" name = "axum-macros" readme = "README.md" repository = "https://github.com/tokio-rs/axum" -version = "0.2.2" +version = "0.2.3" [lib] proc-macro = true @@ -26,4 +26,4 @@ rustversion = "1.0" serde = { version = "1.0", features = ["derive"] } syn = { version = "1.0", features = ["full", "extra-traits"] } tokio = { version = "1.0", features = ["full"] } -trybuild = "1.0" +trybuild = "1.0.63" diff --git a/axum-macros/README.md b/axum-macros/README.md index e5cb15ef..0018fc17 100644 --- a/axum-macros/README.md +++ b/axum-macros/README.md @@ -14,7 +14,7 @@ This crate uses `#![forbid(unsafe_code)]` to ensure everything is implemented in ## Minimum supported Rust version -axum-macros's MSRV is 1.54. +axum-macros's MSRV is 1.56. ## Getting Help diff --git a/axum-macros/src/typed_path.rs b/axum-macros/src/typed_path.rs index 3caeb0bd..a7654371 100644 --- a/axum-macros/src/typed_path.rs +++ b/axum-macros/src/typed_path.rs @@ -104,12 +104,18 @@ fn expand_named_fields( let display_impl = quote_spanned! {path.span()=> #[automatically_derived] impl ::std::fmt::Display for #ident { + #[allow(clippy::unnecessary_to_owned)] fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { let Self { #(#captures,)* } = self; write!( f, #format_str, - #(#captures = ::axum_extra::__private::utf8_percent_encode(&#captures.to_string(), ::axum_extra::__private::PATH_SEGMENT)),* + #( + #captures = ::axum_extra::__private::utf8_percent_encode( + &#captures.to_string(), + ::axum_extra::__private::PATH_SEGMENT, + ) + ),* ) } } @@ -200,12 +206,18 @@ fn expand_unnamed_fields( let display_impl = quote_spanned! {path.span()=> #[automatically_derived] impl ::std::fmt::Display for #ident { + #[allow(clippy::unnecessary_to_owned)] fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { let Self { #(#destructure_self)* } = self; write!( f, #format_str, - #(#captures = ::axum_extra::__private::utf8_percent_encode(&#captures.to_string(), ::axum_extra::__private::PATH_SEGMENT)),* + #( + #captures = ::axum_extra::__private::utf8_percent_encode( + &#captures.to_string(), + ::axum_extra::__private::PATH_SEGMENT, + ) + ),* ) } } diff --git a/axum/CHANGELOG.md b/axum/CHANGELOG.md index a3c74aed..051eeeec 100644 --- a/axum/CHANGELOG.md +++ b/axum/CHANGELOG.md @@ -7,13 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 # Unreleased -- **added:** Support resolving host name via `Forwarded` header in `Host` - extractor ([#1078]) +- **breaking:** Remove `extractor_middleware` which was previously deprecated. + Use `axum::middleware::from_extractor` instead ([#1077]) +- **breaking:** Allow `Error: Into` for `Route::{layer, route_layer}` ([#924]) +- **breaking:** `MethodRouter` now panics on overlapping routes ([#1102]) - **breaking:** `Router::nest` now only accepts `Router`s. Use `Router::nest_service` to nest opaque services - **added:** Add `Router::nest_service` for nesting opaque services. Use this to nest services like `tower::services::ServeDir` -- **breaking:** The route `/foo/` no longer matches `/foo/*rest`. If you want +- **breaking:** The request `/foo/` no longer matches `/foo/*rest`. If you want to match `/foo/` you have to add a route specifically for that - **breaking:** Path params for wildcard routes no longer include the prefix `/`. e.g. `/foo.js` will match `/*filepath` with a value of `foo.js`, _not_ @@ -21,7 +23,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **fixed:** Routes like `/foo` and `/*rest` are no longer considered overlapping. `/foo` will take priority +[#1077]: https://github.com/tokio-rs/axum/pull/1077 +[#1102]: https://github.com/tokio-rs/axum/pull/1102 +[#924]: https://github.com/tokio-rs/axum/pull/924 + +# 0.5.10 (28. June, 2022) + +- **fixed:** Make `Router` cheaper to clone ([#1123]) +- **fixed:** Fix possible panic when doing trailing slash redirect ([#1124]) + +[#1123]: https://github.com/tokio-rs/axum/pull/1123 +[#1124]: https://github.com/tokio-rs/axum/pull/1124 + +# 0.5.9 (20. June, 2022) + +- **fixed:** Fix compile error when the `headers` is enabled and the `form` + feature is disabled ([#1107]) + +[#1107]: https://github.com/tokio-rs/axum/pull/1107 + +# 0.5.8 (18. June, 2022) + +- **added:** Support resolving host name via `Forwarded` header in `Host` + extractor ([#1078]) +- **added:** Implement `IntoResponse` for `Form` ([#1095]) +- **change:** axum's MSRV is now 1.56 ([#1098]) + [#1078]: https://github.com/tokio-rs/axum/pull/1078 +[#1095]: https://github.com/tokio-rs/axum/pull/1095 +[#1098]: https://github.com/tokio-rs/axum/pull/1098 # 0.5.7 (08. June, 2022) @@ -403,7 +433,7 @@ Yanked, as it contained an accidental breaking change. `Router`. - **added:** Add `Handler::into_make_service_with_connect_info` for serving a handler without a `Router`, and storing info about the incoming connection. - - **breaking:** axum's minimum supported rust version is now 1.54 + - **breaking:** axum's minimum supported rust version is now 1.56 - Routing: - Big internal refactoring of routing leading to several improvements ([#363]) - **added:** Wildcard routes like `.route("/api/users/*rest", service)` are now supported. diff --git a/axum/Cargo.toml b/axum/Cargo.toml index bb66b2f4..b7f661df 100644 --- a/axum/Cargo.toml +++ b/axum/Cargo.toml @@ -1,9 +1,9 @@ [package] name = "axum" -version = "0.5.7" +version = "0.5.10" categories = ["asynchronous", "network-programming", "web-programming"] description = "Web framework that focuses on ergonomics and modularity" -edition = "2018" +edition = "2021" homepage = "https://github.com/tokio-rs/axum" keywords = ["http", "web", "framework"] license = "MIT" @@ -25,7 +25,7 @@ ws = ["tokio-tungstenite", "sha-1", "base64"] [dependencies] async-trait = "0.1.43" -axum-core = { path = "../axum-core", version = "0.2.5" } +axum-core = { path = "../axum-core", version = "0.2.6" } bitflags = "1.0" bytes = "1.0" futures-util = { version = "0.3", default-features = false, features = ["alloc"] } @@ -33,9 +33,7 @@ http = "0.2.5" http-body = "0.4.4" hyper = { version = "0.14.14", features = ["server", "tcp", "stream"] } itoa = "1.0.1" -# TODO(david): cannot ship until matchit has released a new version but we can -# start making changes -matchit = { git = "https://github.com/ibraheemdev/matchit", branch = "catchall-revamp" } +matchit = "0.6" memchr = "2.4.1" mime = "0.3.16" percent-encoding = "2.1" @@ -50,7 +48,7 @@ tower-service = "0.3" # optional dependencies base64 = { version = "0.13", optional = true } -headers = { version = "0.3", optional = true } +headers = { version = "0.3.7", optional = true } multer = { version = "2.0.0", optional = true } serde_json = { version = "1.0", features = ["raw_value"], optional = true } serde_urlencoded = { version = "0.7", optional = true } @@ -62,7 +60,7 @@ anyhow = "1.0" futures = "0.3" quickcheck = "1.0" quickcheck_macros = "1.0" -reqwest = { version = "0.11", default-features = false, features = ["json", "stream", "multipart"] } +reqwest = { version = "0.11.11", default-features = false, features = ["json", "stream", "multipart"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" tokio = { version = "1.6.1", features = ["macros", "rt", "rt-multi-thread", "net", "test-util"] } diff --git a/axum/README.md b/axum/README.md index 8768e459..8fcb9cc1 100644 --- a/axum/README.md +++ b/axum/README.md @@ -111,7 +111,7 @@ This crate uses `#![forbid(unsafe_code)]` to ensure everything is implemented in ## Minimum supported Rust version -axum's MSRV is 1.54. +axum's MSRV is 1.56. ## Examples diff --git a/axum/src/docs/error_handling.md b/axum/src/docs/error_handling.md index 3b342121..01a0afaa 100644 --- a/axum/src/docs/error_handling.md +++ b/axum/src/docs/error_handling.md @@ -1,5 +1,12 @@ Error handling model and utilities +# Table of contents + +- [axum's error handling model](#axums-error-handling-model) +- [Routing to fallible services](#routing-to-fallible-services) +- [Applying fallible middleware](#applying-fallible-middleware) +- [Running extractors for error handling](#running-extractors-for-error-handling) + # axum's error handling model axum is based on [`tower::Service`] which bundles errors through its associated diff --git a/axum/src/docs/extract.md b/axum/src/docs/extract.md index 37920923..eedf5ec5 100644 --- a/axum/src/docs/extract.md +++ b/axum/src/docs/extract.md @@ -1,5 +1,20 @@ Types and traits for extracting data from requests. +# Table of contents + +- [Intro](#intro) +- [Common extractors](#common-extractors) +- [Applying multiple extractors](#applying-multiple-extractors) +- [Be careful when extracting `Request`](#be-careful-when-extracting-request) +- [Optional extractors](#optional-extractors) +- [Customizing extractor responses](#customizing-extractor-responses) +- [Accessing inner errors](#accessing-inner-errors) +- [Defining custom extractors](#defining-custom-extractors) +- [Accessing other extractors in `FromRequest` implementations](#accessing-other-extractors-in-fromrequest-implementations) +- [Request body extractors](#request-body-extractors) + +# Intro + A handler function is an async function that takes any number of "extractors" as arguments. An extractor is a type that implements [`FromRequest`](crate::extract::FromRequest). diff --git a/axum/src/docs/middleware.md b/axum/src/docs/middleware.md index 17a3d72f..958c504b 100644 --- a/axum/src/docs/middleware.md +++ b/axum/src/docs/middleware.md @@ -1,3 +1,14 @@ +# Table of contents + +- [Intro](#intro) +- [Applying middleware](#applying-middleware) +- [Commonly used middleware](#commonly-used-middleware) +- [Writing middleware](#writing-middleware) +- [Routing to services/middleware and backpressure](#routing-to-servicesmiddleware-and-backpressure) +- [Sharing state between handlers and middleware](#sharing-state-between-handlers-and-middleware) + +# Intro + axum is unique in that it doesn't have its own bespoke middleware system and instead integrates with [`tower`]. This means the ecosystem of [`tower`] and [`tower-http`] middleware all work with axum. diff --git a/axum/src/docs/response.md b/axum/src/docs/response.md index 09839de5..aebaa9df 100644 --- a/axum/src/docs/response.md +++ b/axum/src/docs/response.md @@ -1,5 +1,10 @@ Types and traits for generating responses. +# Table of contents + +- [Building responses](#building-responses) +- [Returning different response types](#returning-different-response-types) + # Building responses Anything that implements [`IntoResponse`] can be returned from a handler. axum diff --git a/axum/src/extract/extractor_middleware.rs b/axum/src/extract/extractor_middleware.rs deleted file mode 100644 index 9e3c1942..00000000 --- a/axum/src/extract/extractor_middleware.rs +++ /dev/null @@ -1,16 +0,0 @@ -//! Convert an extractor into a middleware. -//! -//! See [`extractor_middleware`] for more details. - -use crate::middleware::from_extractor; - -pub use crate::middleware::{ - future::FromExtractorResponseFuture as ResponseFuture, FromExtractor as ExtractorMiddleware, - FromExtractorLayer as ExtractorMiddlewareLayer, -}; - -/// Convert an extractor into a middleware. -#[deprecated(note = "Please use `axum::middleware::from_extractor` instead")] -pub fn extractor_middleware() -> ExtractorMiddlewareLayer { - from_extractor() -} diff --git a/axum/src/extract/mod.rs b/axum/src/extract/mod.rs index 6e17d973..6f0435f6 100644 --- a/axum/src/extract/mod.rs +++ b/axum/src/extract/mod.rs @@ -4,7 +4,6 @@ use http::header; use rejection::*; pub mod connect_info; -pub mod extractor_middleware; pub mod path; pub mod rejection; @@ -24,7 +23,6 @@ pub use axum_core::extract::{FromRequest, RequestParts}; pub use self::{ connect_info::ConnectInfo, content_length_limit::ContentLengthLimit, - extractor_middleware::extractor_middleware, host::Host, path::Path, raw_query::RawQuery, @@ -39,11 +37,8 @@ pub use crate::Json; pub use crate::Extension; #[cfg(feature = "form")] -mod form; - -#[cfg(feature = "form")] -#[doc(inline)] -pub use self::form::Form; +#[doc(no_inline)] +pub use crate::form::Form; #[cfg(feature = "matched-path")] mod matched_path; diff --git a/axum/src/extract/form.rs b/axum/src/form.rs similarity index 78% rename from axum/src/extract/form.rs rename to axum/src/form.rs index 4f0295af..9974a460 100644 --- a/axum/src/extract/form.rs +++ b/axum/src/form.rs @@ -1,24 +1,23 @@ -use super::{has_content_type, rejection::*, FromRequest, RequestParts}; use crate::body::{Bytes, HttpBody}; +use crate::extract::{has_content_type, rejection::*, FromRequest, RequestParts}; use crate::BoxError; use async_trait::async_trait; -use http::Method; +use axum_core::response::{IntoResponse, Response}; +use http::header::CONTENT_TYPE; +use http::{Method, StatusCode}; use serde::de::DeserializeOwned; +use serde::Serialize; use std::ops::Deref; -/// Extractor that deserializes `application/x-www-form-urlencoded` requests -/// into some type. +/// URL encoded extractor and response. /// -/// `T` is expected to implement [`serde::Deserialize`]. +/// # As extractor /// -/// # Example +/// If used as an extractor `Form` will deserialize `application/x-www-form-urlencoded` request +/// bodies into some target type via [`serde::Deserialize`]. /// -/// ```rust,no_run -/// use axum::{ -/// extract::Form, -/// routing::post, -/// Router, -/// }; +/// ```rust +/// use axum::Form; /// use serde::Deserialize; /// /// #[derive(Deserialize)] @@ -27,19 +26,31 @@ use std::ops::Deref; /// password: String, /// } /// -/// async fn accept_form(form: Form) { -/// let sign_up: SignUp = form.0; -/// +/// async fn accept_form(Form(sign_up): Form) { /// // ... /// } -/// -/// let app = Router::new().route("/sign_up", post(accept_form)); -/// # async { -/// # axum::Server::bind(&"".parse().unwrap()).serve(app.into_make_service()).await.unwrap(); -/// # }; /// ``` /// -/// Note that `Content-Type: multipart/form-data` requests are not supported. +/// Note that `Content-Type: multipart/form-data` requests are not supported. Use [`Multipart`] +/// instead. +/// +/// # As response +/// +/// ```rust +/// use axum::Form; +/// use serde::Serialize; +/// +/// #[derive(Serialize)] +/// struct Payload { +/// value: String, +/// } +/// +/// async fn handler() -> Form { +/// Form(Payload { value: "foo".to_owned() }) +/// } +/// ``` +/// +/// [`Multipart`]: crate::extract::Multipart #[cfg_attr(docsrs, doc(cfg(feature = "form")))] #[derive(Debug, Clone, Copy, Default)] pub struct Form(pub T); @@ -74,6 +85,22 @@ where } } +impl IntoResponse for Form +where + T: Serialize, +{ + fn into_response(self) -> Response { + match serde_urlencoded::to_string(&self.0) { + Ok(body) => ( + [(CONTENT_TYPE, mime::APPLICATION_WWW_FORM_URLENCODED.as_ref())], + body, + ) + .into_response(), + Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(), + } + } +} + impl Deref for Form { type Target = T; diff --git a/axum/src/lib.rs b/axum/src/lib.rs index ac977976..c5f19505 100644 --- a/axum/src/lib.rs +++ b/axum/src/lib.rs @@ -395,6 +395,8 @@ pub(crate) mod macros; mod extension; +#[cfg(feature = "form")] +mod form; #[cfg(feature = "json")] mod json; #[cfg(feature = "headers")] @@ -434,5 +436,9 @@ pub use self::routing::Router; #[cfg(feature = "headers")] pub use self::typed_header::TypedHeader; +#[doc(inline)] +#[cfg(feature = "form")] +pub use self::form::Form; + #[doc(inline)] pub use axum_core::{BoxError, Error}; diff --git a/axum/src/response/mod.rs b/axum/src/response/mod.rs index 50c2c458..695b931c 100644 --- a/axum/src/response/mod.rs +++ b/axum/src/response/mod.rs @@ -15,6 +15,10 @@ pub use crate::Json; #[cfg(feature = "headers")] pub use crate::TypedHeader; +#[cfg(feature = "form")] +#[doc(no_inline)] +pub use crate::form::Form; + #[doc(no_inline)] pub use crate::Extension; diff --git a/axum/src/response/redirect.rs b/axum/src/response/redirect.rs index a994db7b..e11389e8 100644 --- a/axum/src/response/redirect.rs +++ b/axum/src/response/redirect.rs @@ -1,6 +1,5 @@ use axum_core::response::{IntoResponse, Response}; use http::{header::LOCATION, HeaderValue, StatusCode}; -use std::convert::TryFrom; /// Response that redirects the request to another location. /// diff --git a/axum/src/routing/method_routing.rs b/axum/src/routing/method_routing.rs index 264dedaa..4edb8bdb 100644 --- a/axum/src/routing/method_routing.rs +++ b/axum/src/routing/method_routing.rs @@ -912,6 +912,32 @@ impl MethodRouter { S: Service, Response = Response, Error = E> + Clone + Send + 'static, S::Future: Send + 'static, { + macro_rules! set_service { + ( + $filter:ident, + $svc:ident, + $allow_header:ident, + [ + $( + ($out:ident, $variant:ident, [$($method:literal),+]) + ),+ + $(,)? + ] + ) => { + $( + if $filter.contains(MethodFilter::$variant) { + if $out.is_some() { + panic!("Overlapping method route. Cannot add two method routes that both handle `{}`", stringify!($variant)) + } + $out = $svc.clone(); + $( + append_allow_header(&mut $allow_header, $method); + )+ + } + )+ + } + } + // written with a pattern match like this to ensure we update all fields let Self { mut get, @@ -927,39 +953,21 @@ impl MethodRouter { _request_body: _, } = self; let svc = Some(Route::new(svc)); - if filter.contains(MethodFilter::GET) { - get = svc.clone(); - append_allow_header(&mut allow_header, "GET"); - append_allow_header(&mut allow_header, "HEAD"); - } - if filter.contains(MethodFilter::HEAD) { - append_allow_header(&mut allow_header, "HEAD"); - head = svc.clone(); - } - if filter.contains(MethodFilter::DELETE) { - append_allow_header(&mut allow_header, "DELETE"); - delete = svc.clone(); - } - if filter.contains(MethodFilter::OPTIONS) { - append_allow_header(&mut allow_header, "OPTIONS"); - options = svc.clone(); - } - if filter.contains(MethodFilter::PATCH) { - append_allow_header(&mut allow_header, "PATCH"); - patch = svc.clone(); - } - if filter.contains(MethodFilter::POST) { - append_allow_header(&mut allow_header, "POST"); - post = svc.clone(); - } - if filter.contains(MethodFilter::PUT) { - append_allow_header(&mut allow_header, "PUT"); - put = svc.clone(); - } - if filter.contains(MethodFilter::TRACE) { - append_allow_header(&mut allow_header, "TRACE"); - trace = svc; - } + set_service!( + filter, + svc, + allow_header, + [ + (get, GET, ["GET", "HEAD"]), + (head, HEAD, ["HEAD"]), + (delete, DELETE, ["DELETE"]), + (options, OPTIONS, ["OPTIONS"]), + (patch, PATCH, ["PATCH"]), + (post, POST, ["POST"]), + (put, PUT, ["PUT"]), + (trace, TRACE, ["TRACE"]), + ] + ); Self { get, head, @@ -1294,6 +1302,32 @@ mod tests { assert_eq!(headers[ALLOW], "GET,POST"); } + #[tokio::test] + #[should_panic( + expected = "Overlapping method route. Cannot add two method routes that both handle `GET`" + )] + async fn handler_overlaps() { + let _: MethodRouter = get(ok).get(ok); + } + + #[tokio::test] + #[should_panic( + expected = "Overlapping method route. Cannot add two method routes that both handle `POST`" + )] + async fn service_overlaps() { + let _: MethodRouter = post_service(ok.into_service()).post_service(ok.into_service()); + } + + #[tokio::test] + async fn get_head_does_not_overlap() { + let _: MethodRouter = get(ok).head(ok); + } + + #[tokio::test] + async fn head_get_does_not_overlap() { + let _: MethodRouter = head(ok).get(ok); + } + async fn call(method: Method, svc: &mut S) -> (StatusCode, HeaderMap, String) where S: Service, Response = Response, Error = Infallible>, diff --git a/axum/src/routing/mod.rs b/axum/src/routing/mod.rs index b7955e15..acb6a93f 100644 --- a/axum/src/routing/mod.rs +++ b/axum/src/routing/mod.rs @@ -64,7 +64,7 @@ impl RouteId { /// The router type for composing handlers and services. pub struct Router { routes: HashMap>, - node: Node, + node: Arc, fallback: Fallback, } @@ -72,7 +72,7 @@ impl Clone for Router { fn clone(&self) -> Self { Self { routes: self.routes.clone(), - node: self.node.clone(), + node: Arc::clone(&self.node), fallback: self.fallback.clone(), } } @@ -157,9 +157,12 @@ where Err(service) => Endpoint::Route(Route::new(service)), }; - if let Err(err) = self.node.insert(path, id) { + let mut node = + Arc::try_unwrap(Arc::clone(&self.node)).unwrap_or_else(|node| (*node).clone()); + if let Err(err) = node.insert(path, id) { panic!("Invalid route: {}", err); } + self.node = Arc::new(node); self.routes.insert(id, service); @@ -190,12 +193,12 @@ where panic!("Cannot nest `Router`s that has a fallback"); } - for (id, nested_path) in node.route_id_to_path { - let route = routes.remove(&id).unwrap(); - let full_path: Cow = if &*nested_path == "/" { + for (id, nested_path) in &node.route_id_to_path { + let route = routes.remove(id).unwrap(); + let full_path: Cow = if &**nested_path == "/" { path.into() } else if path == "/" { - (&*nested_path).into() + (&**nested_path).into() } else if let Some(path) = path.strip_suffix('/') { format!("{}{}", path, nested_path).into() } else { @@ -289,15 +292,15 @@ where pub fn layer(self, layer: L) -> Router where L: Layer>, - L::Service: Service, Response = Response, Error = Infallible> - + Clone - + Send - + 'static, + L::Service: + Service, Response = Response> + Clone + Send + 'static, + >>::Error: Into + 'static, >>::Future: Send + 'static, NewResBody: HttpBody + Send + 'static, NewResBody::Error: Into, { let layer = ServiceBuilder::new() + .map_err(Into::into) .layer(MapResponseBodyLayer::new(boxed)) .layer(layer) .into_inner(); @@ -329,15 +332,14 @@ where pub fn route_layer(self, layer: L) -> Self where L: Layer>, - L::Service: Service, Response = Response, Error = Infallible> - + Clone - + Send - + 'static, + L::Service: Service, Response = Response> + Clone + Send + 'static, + >>::Error: Into + 'static, >>::Future: Send + 'static, NewResBody: HttpBody + Send + 'static, NewResBody::Error: Into, { let layer = ServiceBuilder::new() + .map_err(Into::into) .layer(MapResponseBodyLayer::new(boxed)) .layer(layer) .into_inner(); @@ -466,29 +468,35 @@ where match self.node.at(&path) { Ok(match_) => self.call_route(match_, req), - Err(MatchError::MissingTrailingSlash) => { - let new_uri = replace_trailing_slash(req.uri(), &format!("{}/", &path)); + Err(err) => { + let mut fallback = match &self.fallback { + Fallback::Default(inner) => inner.clone(), + Fallback::Custom(inner) => inner.clone(), + }; - RouteFuture::from_response( - Redirect::permanent(&new_uri.to_string()).into_response(), - ) - } - Err(MatchError::ExtraTrailingSlash) => { - let new_uri = replace_trailing_slash(req.uri(), path.strip_suffix('/').unwrap()); + let new_uri = match err { + MatchError::MissingTrailingSlash => { + replace_path(req.uri(), &format!("{}/", &path)) + } + MatchError::ExtraTrailingSlash => { + replace_path(req.uri(), path.strip_suffix('/').unwrap()) + } + MatchError::NotFound => None, + }; - RouteFuture::from_response( - Redirect::permanent(&new_uri.to_string()).into_response(), - ) + if let Some(new_uri) = new_uri { + RouteFuture::from_response( + Redirect::permanent(&new_uri.to_string()).into_response(), + ) + } else { + fallback.call(req) + } } - Err(MatchError::NotFound) => match &self.fallback { - Fallback::Default(inner) => inner.clone().call(req), - Fallback::Custom(inner) => inner.clone().call(req), - }, } } } -fn replace_trailing_slash(uri: &Uri, new_path: &str) -> Uri { +fn replace_path(uri: &Uri, new_path: &str) -> Option { let mut new_path_and_query = new_path.to_owned(); if let Some(query) = uri.query() { new_path_and_query.push('?'); @@ -498,7 +506,7 @@ fn replace_trailing_slash(uri: &Uri, new_path: &str) -> Uri { let mut parts = uri.clone().into_parts(); parts.path_and_query = Some(new_path_and_query.parse().unwrap()); - Uri::from_parts(parts).unwrap() + Uri::from_parts(parts).ok() } /// Wrapper around `matchit::Router` that supports merging two `Router`s. @@ -601,7 +609,19 @@ impl fmt::Debug for Endpoint { } #[test] +#[allow(warnings)] fn traits() { use crate::test_helpers::*; assert_send::>(); } + +// https://github.com/tokio-rs/axum/issues/1122 +#[test] +fn test_replace_trailing_slash() { + let uri = "api.ipify.org:80".parse::().unwrap(); + assert!(uri.scheme().is_none()); + assert_eq!(uri.authority(), Some(&"api.ipify.org:80".parse().unwrap())); + assert!(uri.path_and_query().is_none()); + + replace_path(&uri, "/foo"); +} diff --git a/axum/src/routing/strip_prefix.rs b/axum/src/routing/strip_prefix.rs index 25744843..ec0e2325 100644 --- a/axum/src/routing/strip_prefix.rs +++ b/axum/src/routing/strip_prefix.rs @@ -138,7 +138,7 @@ where let a = a.map(Some).chain(std::iter::repeat_with(|| None)); let b = b.map(Some).chain(std::iter::repeat_with(|| None)); a.zip(b) - // use `map_while` when its stable in our MSRV + // use `map_while` when its stable in our MSRV (1.57) .take_while(|(a, b)| a.is_some() || b.is_some()) .filter_map(|(a, b)| match (a, b) { (Some(a), Some(b)) => Some(Item::Both(a, b)), diff --git a/axum/src/test_helpers/test_client.rs b/axum/src/test_helpers/test_client.rs index 34791b45..d52c61a6 100644 --- a/axum/src/test_helpers/test_client.rs +++ b/axum/src/test_helpers/test_client.rs @@ -5,10 +5,7 @@ use http::{ Request, StatusCode, }; use hyper::{Body, Server}; -use std::{ - convert::TryFrom, - net::{SocketAddr, TcpListener}, -}; +use std::net::{SocketAddr, TcpListener}; use tower::make::Shared; use tower_service::Service; diff --git a/examples/async-graphql/Cargo.toml b/examples/async-graphql/Cargo.toml index 2501791f..c114269d 100644 --- a/examples/async-graphql/Cargo.toml +++ b/examples/async-graphql/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "example-async-graphql" version = "0.1.0" -edition = "2018" +edition = "2021" publish = false [dependencies] diff --git a/examples/chat/Cargo.toml b/examples/chat/Cargo.toml index d5139c73..90d88246 100644 --- a/examples/chat/Cargo.toml +++ b/examples/chat/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "example-chat" version = "0.1.0" -edition = "2018" +edition = "2021" publish = false [dependencies] diff --git a/examples/consume-body-in-extractor-or-middleware/Cargo.toml b/examples/consume-body-in-extractor-or-middleware/Cargo.toml index a9694e1c..92616ab9 100644 --- a/examples/consume-body-in-extractor-or-middleware/Cargo.toml +++ b/examples/consume-body-in-extractor-or-middleware/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "example-consume-body-in-extractor-or-middleware" version = "0.1.0" -edition = "2018" +edition = "2021" publish = false [dependencies] diff --git a/examples/cors/Cargo.toml b/examples/cors/Cargo.toml index 44fbd5ca..8ed7d80d 100644 --- a/examples/cors/Cargo.toml +++ b/examples/cors/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "example-cors" version = "0.1.0" -edition = "2018" +edition = "2021" publish = false [dependencies] diff --git a/examples/customize-extractor-error/Cargo.toml b/examples/customize-extractor-error/Cargo.toml index 2c7dd1a1..95f99080 100644 --- a/examples/customize-extractor-error/Cargo.toml +++ b/examples/customize-extractor-error/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "example-customize-extractor-error" version = "0.1.0" -edition = "2018" +edition = "2021" publish = false [dependencies] diff --git a/examples/customize-path-rejection/Cargo.toml b/examples/customize-path-rejection/Cargo.toml index 3373dc65..8f5b1e44 100644 --- a/examples/customize-path-rejection/Cargo.toml +++ b/examples/customize-path-rejection/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "example-customize-path-rejection" version = "0.1.0" -edition = "2018" +edition = "2021" publish = false [dependencies] diff --git a/examples/error-handling-and-dependency-injection/Cargo.toml b/examples/error-handling-and-dependency-injection/Cargo.toml index 5d7b1ca9..583ab15a 100644 --- a/examples/error-handling-and-dependency-injection/Cargo.toml +++ b/examples/error-handling-and-dependency-injection/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "example-error-handling-and-dependency-injection" version = "0.1.0" -edition = "2018" +edition = "2021" publish = false [dependencies] diff --git a/examples/form/Cargo.toml b/examples/form/Cargo.toml index fc65f6df..a1b8f0ab 100644 --- a/examples/form/Cargo.toml +++ b/examples/form/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "example-form" version = "0.1.0" -edition = "2018" +edition = "2021" publish = false [dependencies] diff --git a/examples/global-404-handler/Cargo.toml b/examples/global-404-handler/Cargo.toml index 95a3003c..9848d9e8 100644 --- a/examples/global-404-handler/Cargo.toml +++ b/examples/global-404-handler/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "example-global-404-handler" version = "0.1.0" -edition = "2018" +edition = "2021" publish = false [dependencies] diff --git a/examples/graceful-shutdown/Cargo.toml b/examples/graceful-shutdown/Cargo.toml index 87163dc0..19af3113 100644 --- a/examples/graceful-shutdown/Cargo.toml +++ b/examples/graceful-shutdown/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "example-graceful-shutdown" version = "0.1.0" -edition = "2018" +edition = "2021" publish = false [dependencies] diff --git a/examples/hello-world/Cargo.toml b/examples/hello-world/Cargo.toml index 36b5dfc6..6fbee18a 100644 --- a/examples/hello-world/Cargo.toml +++ b/examples/hello-world/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "example-hello-world" version = "0.1.0" -edition = "2018" +edition = "2021" publish = false [dependencies] diff --git a/examples/http-proxy/Cargo.toml b/examples/http-proxy/Cargo.toml index 69f89bba..90ea85c5 100644 --- a/examples/http-proxy/Cargo.toml +++ b/examples/http-proxy/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "example-http-proxy" version = "0.1.0" -edition = "2018" +edition = "2021" publish = false [dependencies] diff --git a/examples/jwt/Cargo.toml b/examples/jwt/Cargo.toml index 7078a651..a18eb6ec 100644 --- a/examples/jwt/Cargo.toml +++ b/examples/jwt/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "example-jwt" version = "0.1.0" -edition = "2018" +edition = "2021" publish = false [dependencies] diff --git a/examples/key-value-store/Cargo.toml b/examples/key-value-store/Cargo.toml index 954862cf..e4c9063c 100644 --- a/examples/key-value-store/Cargo.toml +++ b/examples/key-value-store/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "example-key-value-store" version = "0.1.0" -edition = "2018" +edition = "2021" publish = false [dependencies] diff --git a/examples/low-level-rustls/Cargo.toml b/examples/low-level-rustls/Cargo.toml index ab753c71..b5e9c152 100644 --- a/examples/low-level-rustls/Cargo.toml +++ b/examples/low-level-rustls/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "example-low-level-rustls" version = "0.1.0" -edition = "2018" +edition = "2021" publish = false [dependencies] diff --git a/examples/low-level-rustls/src/main.rs b/examples/low-level-rustls/src/main.rs index 1a077874..6f4a2259 100644 --- a/examples/low-level-rustls/src/main.rs +++ b/examples/low-level-rustls/src/main.rs @@ -11,7 +11,14 @@ use hyper::server::{ conn::{AddrIncoming, Http}, }; use rustls_pemfile::{certs, pkcs8_private_keys}; -use std::{fs::File, io::BufReader, net::SocketAddr, pin::Pin, sync::Arc}; +use std::{ + fs::File, + io::BufReader, + net::SocketAddr, + path::{Path, PathBuf}, + pin::Pin, + sync::Arc, +}; use tokio::net::TcpListener; use tokio_rustls::{ rustls::{Certificate, PrivateKey, ServerConfig}, @@ -30,8 +37,12 @@ async fn main() { .init(); let rustls_config = rustls_server_config( - "examples/tls-rustls/self_signed_certs/key.pem", - "examples/tls-rustls/self_signed_certs/cert.pem", + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("self_signed_certs") + .join("key.pem"), + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("self_signed_certs") + .join("cert.pem"), ); let acceptor = TlsAcceptor::from(rustls_config); @@ -65,7 +76,7 @@ async fn handler(ConnectInfo(addr): ConnectInfo) -> String { addr.to_string() } -fn rustls_server_config(key: &str, cert: &str) -> Arc { +fn rustls_server_config(key: impl AsRef, cert: impl AsRef) -> Arc { let mut key_reader = BufReader::new(File::open(key).unwrap()); let mut cert_reader = BufReader::new(File::open(cert).unwrap()); diff --git a/examples/multipart-form/Cargo.toml b/examples/multipart-form/Cargo.toml index 7860c4dd..fc8f971a 100644 --- a/examples/multipart-form/Cargo.toml +++ b/examples/multipart-form/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "example-multipart-form" version = "0.1.0" -edition = "2018" +edition = "2021" publish = false [dependencies] diff --git a/examples/oauth/Cargo.toml b/examples/oauth/Cargo.toml index c6921de7..66133673 100644 --- a/examples/oauth/Cargo.toml +++ b/examples/oauth/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "example-oauth" version = "0.1.0" -edition = "2018" +edition = "2021" publish = false [dependencies] diff --git a/examples/print-request-response/Cargo.toml b/examples/print-request-response/Cargo.toml index 1786e223..e59f7eae 100644 --- a/examples/print-request-response/Cargo.toml +++ b/examples/print-request-response/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "example-print-request-response" version = "0.1.0" -edition = "2018" +edition = "2021" publish = false [dependencies] diff --git a/examples/prometheus-metrics/Cargo.toml b/examples/prometheus-metrics/Cargo.toml index 03d40f8c..c29993de 100644 --- a/examples/prometheus-metrics/Cargo.toml +++ b/examples/prometheus-metrics/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "example-prometheus-metrics" version = "0.1.0" -edition = "2018" +edition = "2021" publish = false [dependencies] diff --git a/examples/query-params-with-empty-strings/Cargo.toml b/examples/query-params-with-empty-strings/Cargo.toml index 36a25938..b64c1244 100644 --- a/examples/query-params-with-empty-strings/Cargo.toml +++ b/examples/query-params-with-empty-strings/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "example-query-params-with-empty-strings" version = "0.1.0" -edition = "2018" +edition = "2021" publish = false [dependencies] diff --git a/examples/readme/Cargo.toml b/examples/readme/Cargo.toml index c898d7cc..4a79c9bb 100644 --- a/examples/readme/Cargo.toml +++ b/examples/readme/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "example-readme" version = "0.1.0" -edition = "2018" +edition = "2021" publish = false [dependencies] diff --git a/examples/rest-grpc-multiplex/Cargo.toml b/examples/rest-grpc-multiplex/Cargo.toml index 4b4bbfe0..4c143cdc 100644 --- a/examples/rest-grpc-multiplex/Cargo.toml +++ b/examples/rest-grpc-multiplex/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "example-rest-grpc-multiplex" version = "0.1.0" -edition = "2018" +edition = "2021" publish = false [dependencies] diff --git a/examples/reverse-proxy/Cargo.toml b/examples/reverse-proxy/Cargo.toml index bc77aa5a..81ddb519 100644 --- a/examples/reverse-proxy/Cargo.toml +++ b/examples/reverse-proxy/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "example-reverse-proxy" version = "0.1.0" -edition = "2018" +edition = "2021" [dependencies] axum = { path = "../../axum" } diff --git a/examples/reverse-proxy/src/main.rs b/examples/reverse-proxy/src/main.rs index 849af0b3..a9d2a5c7 100644 --- a/examples/reverse-proxy/src/main.rs +++ b/examples/reverse-proxy/src/main.rs @@ -14,7 +14,7 @@ use axum::{ Router, }; use hyper::{client::HttpConnector, Body}; -use std::{convert::TryFrom, net::SocketAddr}; +use std::net::SocketAddr; type Client = hyper::client::Client; diff --git a/examples/routes-and-handlers-close-together/Cargo.toml b/examples/routes-and-handlers-close-together/Cargo.toml index fa8c14ce..8cacbd47 100644 --- a/examples/routes-and-handlers-close-together/Cargo.toml +++ b/examples/routes-and-handlers-close-together/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "example-routes-and-handlers-close-together" version = "0.1.0" -edition = "2018" +edition = "2021" publish = false [dependencies] diff --git a/examples/sessions/Cargo.toml b/examples/sessions/Cargo.toml index 0129ce41..247df4d3 100644 --- a/examples/sessions/Cargo.toml +++ b/examples/sessions/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "example-sessions" version = "0.1.0" -edition = "2018" +edition = "2021" publish = false [dependencies] diff --git a/examples/sqlx-postgres/Cargo.toml b/examples/sqlx-postgres/Cargo.toml index 5a1a19f4..0587dc7f 100644 --- a/examples/sqlx-postgres/Cargo.toml +++ b/examples/sqlx-postgres/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "example-sqlx-postgres" version = "0.1.0" -edition = "2018" +edition = "2021" publish = false [dependencies] diff --git a/examples/sse/Cargo.toml b/examples/sse/Cargo.toml index 8e52f362..3e9b5b58 100644 --- a/examples/sse/Cargo.toml +++ b/examples/sse/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "example-sse" version = "0.1.0" -edition = "2018" +edition = "2021" publish = false [dependencies] diff --git a/examples/sse/src/main.rs b/examples/sse/src/main.rs index d80f6685..4dbfcb46 100644 --- a/examples/sse/src/main.rs +++ b/examples/sse/src/main.rs @@ -12,7 +12,7 @@ use axum::{ Router, }; use futures::stream::{self, Stream}; -use std::{convert::Infallible, net::SocketAddr, time::Duration}; +use std::{convert::Infallible, net::SocketAddr, path::PathBuf, time::Duration}; use tokio_stream::StreamExt as _; use tower_http::{services::ServeDir, trace::TraceLayer}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; @@ -27,14 +27,17 @@ async fn main() { .with(tracing_subscriber::fmt::layer()) .init(); - let static_files_service = - get_service(ServeDir::new("examples/sse/assets").append_index_html_on_directories(true)) - .handle_error(|error: std::io::Error| async move { - ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Unhandled internal error: {}", error), - ) - }); + let assets_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("assets"); + + let static_files_service = get_service( + ServeDir::new(assets_dir).append_index_html_on_directories(true), + ) + .handle_error(|error: std::io::Error| async move { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Unhandled internal error: {}", error), + ) + }); // build our application with a route let app = Router::new() @@ -59,7 +62,7 @@ async fn sse_handler( // A `Stream` that repeats an event every second let stream = stream::repeat_with(|| Event::default().data("hi!")) .map(Ok) - .throttle(Duration::from_secs(10)); + .throttle(Duration::from_secs(1)); Sse::new(stream).keep_alive( axum::response::sse::KeepAlive::new() diff --git a/examples/static-file-server/Cargo.toml b/examples/static-file-server/Cargo.toml index af4a6830..7f17eca6 100644 --- a/examples/static-file-server/Cargo.toml +++ b/examples/static-file-server/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "example-static-file-server" version = "0.1.0" -edition = "2018" +edition = "2021" publish = false [dependencies] diff --git a/examples/stream-to-file/Cargo.toml b/examples/stream-to-file/Cargo.toml index b203f836..b159c871 100644 --- a/examples/stream-to-file/Cargo.toml +++ b/examples/stream-to-file/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "example-stream-to-file" version = "0.1.0" -edition = "2018" +edition = "2021" publish = false [dependencies] diff --git a/examples/templates/Cargo.toml b/examples/templates/Cargo.toml index 99bc73a3..2f5aba27 100644 --- a/examples/templates/Cargo.toml +++ b/examples/templates/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "example-templates" version = "0.1.0" -edition = "2018" +edition = "2021" publish = false [dependencies] diff --git a/examples/testing/Cargo.toml b/examples/testing/Cargo.toml index 8fddfef8..6ce3e80a 100644 --- a/examples/testing/Cargo.toml +++ b/examples/testing/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "example-testing" version = "0.1.0" -edition = "2018" +edition = "2021" publish = false [dependencies] diff --git a/examples/tls-rustls/Cargo.toml b/examples/tls-rustls/Cargo.toml index 0521f0a1..0edf74d6 100644 --- a/examples/tls-rustls/Cargo.toml +++ b/examples/tls-rustls/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "example-tls-rustls" version = "0.1.0" -edition = "2018" +edition = "2021" publish = false [dependencies] diff --git a/examples/tls-rustls/src/main.rs b/examples/tls-rustls/src/main.rs index 18f74cb3..b3110675 100644 --- a/examples/tls-rustls/src/main.rs +++ b/examples/tls-rustls/src/main.rs @@ -6,7 +6,7 @@ use axum::{routing::get, Router}; use axum_server::tls_rustls::RustlsConfig; -use std::net::SocketAddr; +use std::{net::SocketAddr, path::PathBuf}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; #[tokio::main] @@ -19,8 +19,12 @@ async fn main() { .init(); let config = RustlsConfig::from_pem_file( - "examples/tls-rustls/self_signed_certs/cert.pem", - "examples/tls-rustls/self_signed_certs/key.pem", + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("self_signed_certs") + .join("cert.pem"), + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("self_signed_certs") + .join("key.pem"), ) .await .unwrap(); diff --git a/examples/todos/Cargo.toml b/examples/todos/Cargo.toml index 0bfcb8c4..6e3b0a69 100644 --- a/examples/todos/Cargo.toml +++ b/examples/todos/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "example-todos" version = "0.1.0" -edition = "2018" +edition = "2021" publish = false [dependencies] diff --git a/examples/tokio-postgres/Cargo.toml b/examples/tokio-postgres/Cargo.toml index 20a4cbee..74806044 100644 --- a/examples/tokio-postgres/Cargo.toml +++ b/examples/tokio-postgres/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "example-tokio-postgres" version = "0.1.0" -edition = "2018" +edition = "2021" publish = false [dependencies] diff --git a/examples/tracing-aka-logging/Cargo.toml b/examples/tracing-aka-logging/Cargo.toml index 92ce0857..39bcb79d 100644 --- a/examples/tracing-aka-logging/Cargo.toml +++ b/examples/tracing-aka-logging/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "example-tracing-aka-logging" version = "0.1.0" -edition = "2018" +edition = "2021" publish = false [dependencies] diff --git a/examples/unix-domain-socket/Cargo.toml b/examples/unix-domain-socket/Cargo.toml index d283adc7..e3e01846 100644 --- a/examples/unix-domain-socket/Cargo.toml +++ b/examples/unix-domain-socket/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "example-unix-domain-socket" version = "0.1.0" -edition = "2018" +edition = "2021" publish = false [dependencies] diff --git a/examples/validator/Cargo.toml b/examples/validator/Cargo.toml index e4d148cc..8c35019f 100644 --- a/examples/validator/Cargo.toml +++ b/examples/validator/Cargo.toml @@ -1,5 +1,5 @@ [package] -edition = "2018" +edition = "2021" name = "example-validator" publish = false version = "0.1.0" diff --git a/examples/versioning/Cargo.toml b/examples/versioning/Cargo.toml index 5a645237..ac8628c3 100644 --- a/examples/versioning/Cargo.toml +++ b/examples/versioning/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "example-versioning" version = "0.1.0" -edition = "2018" +edition = "2021" publish = false [dependencies] diff --git a/examples/websockets/Cargo.toml b/examples/websockets/Cargo.toml index 6a435ac2..cb307989 100644 --- a/examples/websockets/Cargo.toml +++ b/examples/websockets/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "example-websockets" version = "0.1.0" -edition = "2018" +edition = "2021" publish = false [dependencies] diff --git a/examples/websockets/src/main.rs b/examples/websockets/src/main.rs index 9dab67a0..bbdcaa08 100644 --- a/examples/websockets/src/main.rs +++ b/examples/websockets/src/main.rs @@ -16,7 +16,7 @@ use axum::{ routing::{get, get_service}, Router, }; -use std::net::SocketAddr; +use std::{net::SocketAddr, path::PathBuf}; use tower_http::{ services::ServeDir, trace::{DefaultMakeSpan, TraceLayer}, @@ -33,18 +33,18 @@ async fn main() { .with(tracing_subscriber::fmt::layer()) .init(); + let assets_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("assets"); + // build our application with some routes let app = Router::new() .fallback( - get_service( - ServeDir::new("examples/websockets/assets").append_index_html_on_directories(true), - ) - .handle_error(|error: std::io::Error| async move { - ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Unhandled internal error: {}", error), - ) - }), + get_service(ServeDir::new(assets_dir).append_index_html_on_directories(true)) + .handle_error(|error: std::io::Error| async move { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Unhandled internal error: {}", error), + ) + }), ) // routes are matched from bottom to top, so we have to put `nest` at the // top since it matches all routes diff --git a/internal-minimal-versions/Cargo.toml b/internal-minimal-versions/Cargo.toml new file mode 100644 index 00000000..9e2a69c7 --- /dev/null +++ b/internal-minimal-versions/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "internal-minimal-versions" +version = "0.1.0" +edition = "2021" +publish = false + +# these dependencies don't build if installed with `cargo +nightly update -Z +# minimal-versions` so we add them here to make sure we get a version that +# does build +# +# this only matters for axum's CI +[dependencies] +crc32fast = "1.3.2" +gcc = "0.3.55" +time = "0.3.9" +tungstenite = "0.17.2" diff --git a/internal-minimal-versions/src/lib.rs b/internal-minimal-versions/src/lib.rs new file mode 100644 index 00000000..36db4933 --- /dev/null +++ b/internal-minimal-versions/src/lib.rs @@ -0,0 +1 @@ +// intentionally left empty