diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4374a05..68b4fac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,8 @@ jobs: run: cargo build --all --locked --verbose - name: Run tests run: cargo test --all --locked --verbose + - name: Run tests with all features + run: cargo test --all --all-features --locked --verbose # The minimum supported Rust version, as declared by rust-version in Cargo.toml msrv: @@ -52,7 +54,7 @@ jobs: sed -i 's/^version = 4$/version = 3/' Cargo.lock grep -A1 'name = "encoding_rs"' Cargo.lock - name: Build and run tests with Rust 1.74 - run: cargo +1.74 test --locked --verbose + run: cargo +1.74 test --all-features --locked --verbose lint: runs-on: ubuntu-latest @@ -68,8 +70,8 @@ jobs: cargo fmt --all -- --check rustfmt --edition 2021 --check fuzz/fuzz_targets/*.rs - name: Run clippy - run: cargo clippy --all-targets --locked -- -D warnings + run: cargo clippy --all-targets --all-features --locked -- -D warnings - name: Check documentation - run: cargo doc --no-deps --locked + run: cargo doc --no-deps --all-features --locked env: RUSTDOCFLAGS: -D warnings diff --git a/CHANGELOG.md b/CHANGELOG.md index c215689..7e3fc3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +### Added + + - `serde` feature (off by default), which implements `Serialize` and `Deserialize` for `DataUrl` and `MimeType`, + as strings; invalid data URLs and MIME types make deserializing fail + ## 0.3.0 ### Added diff --git a/Cargo.lock b/Cargo.lock index 5e271eb..5fe83f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20,6 +20,8 @@ version = "0.3.0" dependencies = [ "encoding_rs", "infer", + "serde", + "serde_test", ] [[package]] @@ -48,6 +50,24 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -60,8 +80,63 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_test" +version = "1.0.177" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f901ee573cab6b3060453d2d5f0bae4e6d628c23c0a962ff9b5f1d7c8d4f1ed" +dependencies = [ + "serde", +] + [[package]] name = "simdutf8" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" diff --git a/Cargo.toml b/Cargo.toml index 73c38b3..4cc3a09 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,10 +26,18 @@ license = "CC0-1.0" [dependencies] encoding_rs = "0.8.35" # Used for charsets (label validation, decoding and encoding text) infer = { version = "0.22.0", default-features = false } # Used for detecting media types by file signature +serde = { version = "1.0", default-features = false, optional = true } # Used for the optional serde support + +[dev-dependencies] +serde_test = "1.0.177" # Used for testing the serde support [features] default = ["cli"] cli = [] # Build the dataurl executable (no extra dependencies, kept for compatibility) +serde = ["dep:serde"] # Implement Serialize and Deserialize for DataUrl and MimeType + +[package.metadata.docs.rs] +all-features = true [[bin]] name = "dataurl" diff --git a/Makefile b/Makefile index 5f1caae..fec6d52 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ build: .PHONY: build test: build - @cargo test --locked + @cargo test --locked --all-features @cargo fmt --all -- --check .PHONY: test diff --git a/README.md b/README.md index cd772fa..6251a14 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,29 @@ assert_eq!(extension_for_media_type("application/javascript"), Some("js")); The full API is documented on [docs.rs](https://docs.rs/dataurl). +### Serde + +With the `serde` feature, `DataUrl` and `MimeType` implement `Serialize` and `Deserialize`, as strings, +so they can be part of configuration files, API responses and so on: + +```toml +[dependencies] +dataurl = { version = "0.3", default-features = false, features = ["serde"] } +``` + +```rust +use dataurl::DataUrl; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize)] +struct Theme { + name: String, + logo: DataUrl, // "data:image/svg+xml,..." in JSON, TOML, YAML… +} +``` + +Invalid data URLs and MIME types make deserializing fail. + ### Comparison with data-url [data-url](https://crates.io/crates/data-url), from the Servo project (it lives in the [rust-url](https://github.com/servo/rust-url) repository), @@ -199,7 +222,7 @@ If you need to create or modify data URLs, work with text in other charsets, or make test ``` -Besides unit tests, this runs the [Web Platform Tests](https://github.com/web-platform-tests/wpt) suites for data URLs, +Besides unit tests (including the ones for the `serde` feature), this runs the [Web Platform Tests](https://github.com/web-platform-tests/wpt) suites for data URLs, base64 and MIME types (in `tests/wpt/`), and randomized round-trip tests (`tests/roundtrip.rs`). Those run 5000 iterations each by default; set `DATAURL_ROUNDTRIP_ITERATIONS` for more. The same checks are available as [cargo-fuzz](https://github.com/rust-fuzz/cargo-fuzz) targets, which need a nightly toolchain: diff --git a/src/lib.rs b/src/lib.rs index fa66727..1120412 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -39,6 +39,9 @@ //! decoding text in other charsets, and detecting media types //! (see the [comparison](https://github.com/Y2Z/dataurl#comparison-with-data-url)). //! +//! With the `serde` feature, [`DataUrl`] and [`MimeType`] implement `Serialize` and `Deserialize`, +//! as strings. +//! //! [data: URL processor]: https://fetch.spec.whatwg.org/#data-url-processor //! [data-url]: https://crates.io/crates/data-url @@ -46,6 +49,8 @@ mod base64; mod detect; pub mod mime; mod percent; +#[cfg(feature = "serde")] +mod serde_impl; pub use detect::{detect_media_type, extension_for_media_type}; pub use mime::MimeType; diff --git a/src/serde_impl.rs b/src/serde_impl.rs new file mode 100644 index 0000000..8aa7568 --- /dev/null +++ b/src/serde_impl.rs @@ -0,0 +1,69 @@ +//! Serde support (the `serde` feature): data URLs and MIME types are serialized as strings, +//! and deserialized by parsing those strings. + +use std::fmt; +use std::marker::PhantomData; +use std::str::FromStr; + +use serde::de::{self, Deserialize, Deserializer, Visitor}; +use serde::ser::{Serialize, Serializer}; + +use crate::{DataUrl, Error, MimeType}; + +/// Serialized as its string form (the same as `to_string()`, written without building a string first +/// by serializers that support that, such as serde_json's). +impl Serialize for DataUrl { + fn serialize(&self, serializer: S) -> Result { + serializer.collect_str(self) + } +} + +/// Deserialized from a string with [`DataUrl::parse()`]; invalid data URLs are an error. +impl<'de> Deserialize<'de> for DataUrl { + fn deserialize>(deserializer: D) -> Result { + deserializer.deserialize_str(ParsingVisitor::new("a data URL")) + } +} + +/// Serialized as its string form (the same as `to_string()`). +impl Serialize for MimeType { + fn serialize(&self, serializer: S) -> Result { + serializer.collect_str(self) + } +} + +/// Deserialized from a string with [`MimeType::parse()`]; invalid MIME types are an error. +impl<'de> Deserialize<'de> for MimeType { + fn deserialize>(deserializer: D) -> Result { + deserializer.deserialize_str(ParsingVisitor::new("a MIME type")) + } +} + +struct ParsingVisitor { + expecting: &'static str, + marker: PhantomData, +} + +impl ParsingVisitor { + fn new(expecting: &'static str) -> Self { + ParsingVisitor { + expecting, + marker: PhantomData, + } + } +} + +impl> Visitor<'_> for ParsingVisitor { + type Value = T; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str(self.expecting) + } + + fn visit_str(self, value: &str) -> Result { + // The input isn't included in the error, since data URLs can be huge + value + .parse() + .map_err(|error| E::custom(format_args!("expected {}: {error}", self.expecting))) + } +} diff --git a/tests/serde.rs b/tests/serde.rs new file mode 100644 index 0000000..6e79e82 --- /dev/null +++ b/tests/serde.rs @@ -0,0 +1,77 @@ +//! The serde feature (run with `cargo test --features serde`) +#![cfg(feature = "serde")] + +use dataurl::{DataEncoding, DataUrl, MimeType}; +use serde_test::{assert_de_tokens, assert_de_tokens_error, assert_tokens, Token}; + +#[test] +fn data_url_as_string() { + let data_url = DataUrl::parse("data:text/plain;charset=utf-8,Hello,%20World!").unwrap(); + assert_tokens( + &data_url, + &[Token::Str("data:text/plain;charset=utf-8,Hello,%20World!")], + ); +} + +#[test] +fn data_url_keeps_base64() { + let data_url: DataUrl = "data:image/png;base64,iVBORw==".parse().unwrap(); + assert_eq!(data_url.data_encoding(), DataEncoding::Base64); + assert_tokens(&data_url, &[Token::Str("data:image/png;base64,iVBORw==")]); +} + +#[test] +fn data_url_from_borrowed_and_owned_strings() { + let data_url = DataUrl::parse("data:,x").unwrap(); + assert_de_tokens(&data_url, &[Token::BorrowedStr("data:,x")]); + assert_de_tokens(&data_url, &[Token::String("data:,x")]); +} + +#[test] +fn data_url_gets_normalized() { + // Deserializing parses the string, so what gets serialized again is the normalized form + let data_url: DataUrl = "DATA:Text/Plain;Charset=\"UTF-8\",x".parse().unwrap(); + assert_de_tokens( + &data_url, + &[Token::Str("DATA:Text/Plain;Charset=\"UTF-8\",x")], + ); + assert_tokens(&data_url, &[Token::Str("data:text/plain;charset=UTF-8,x")]); +} + +#[test] +fn invalid_data_url() { + assert_de_tokens_error::( + &[Token::Str("https://example.com/")], + "expected a data URL: not a data URL", + ); + assert_de_tokens_error::( + &[Token::Str("data:;base64,a")], + "expected a data URL: data URL contains invalid base64", + ); +} + +#[test] +fn data_url_must_be_a_string() { + assert_de_tokens_error::( + &[Token::U32(1)], + "invalid type: integer `1`, expected a data URL", + ); +} + +#[test] +fn mime_type_as_string() { + let mime_type = MimeType::parse("Text/HTML; charset=\"Shift_JIS\"").unwrap(); + assert_tokens(&mime_type, &[Token::Str("text/html;charset=Shift_JIS")]); + assert_de_tokens( + &mime_type, + &[Token::Str("Text/HTML; charset=\"Shift_JIS\"")], + ); +} + +#[test] +fn invalid_mime_type() { + assert_de_tokens_error::( + &[Token::Str("text")], + "expected a MIME type: invalid media type", + ); +}