Import 'ipp' crate

Request Document: go/android-rust-importing-crates
For CL Reviewers: go/android3p#cl-review
Bug: 402933191
Test: m libipp

Change-Id: I04ab04adc4ad4a367691e4a96f0a81d7eb59ead0
diff --git a/crates/ipp/.android-checksum.json b/crates/ipp/.android-checksum.json
new file mode 100644
index 0000000..2635a5f
--- /dev/null
+++ b/crates/ipp/.android-checksum.json
@@ -0,0 +1 @@
+{"package":null,"files":{".cargo-checksum.json":"55db6339bc41d12f40c280cbbeb90d8234fcb2505b323c35f2a13d7605390acc","Android.bp":"c436d31352e460b05b372af27cabd911abe6c968739068275c34f77765c25665","Cargo.toml":"5a8d9dc131c4a5bf228ade9a943bb34e8e3957fa0988277c7bd9ae69e6c4c931","LICENSE":"08104e056668223e57f38f83c6c33ed0af9725350a1c5c2aea015c8e5447a5fd","METADATA":"f31dbaee517dd7732a2e1af7a5e9dba6361e192bd3e9f804058b40ef4f73d2ab","MODULE_LICENSE_APACHE2":"0d6f8afa3940b7f06bebee651376d43bc8b0d5b437337be2696d30377451e93a","README.md":"7a76e97a0bc738d82276b38f880cd112f1e21f00adc2a66c23dbc9d396de31b0","TEST_MAPPING":"9e8f49c038111726c8ee932cd96366e225b7b7010a1a454a4450a15bc1189f30","cargo_embargo.json":"fd790b4d2ef154f01185fb7cb018a6222e5e4d3fd008601c209e60d9f599bf63","patches/LICENSE.patch":"9a3abe19228cdff5436891287af162ffc05d7cb140f2a6e8130141791e52e75c","src/attribute.rs":"4eaf004d7b15585d6cdae8e7f6d9d52749f32d0bc637be201d287382b2de7804","src/client.rs":"3f07a9a9d539cefe0517bc38052248424f6caaf2046924e757ca7540cd678ffb","src/error.rs":"d4d9a1f6fbe18b7f940a2c2684fa6184bf335b254eab7cc3cba41b16e4c9232b","src/lib.rs":"581c9aaabf92c59cf206387b6efe16d951bc85fbef57b7dda8d1a987184ba488","src/model.rs":"56408fbbe21516736455100e4cb0fd52484dbe6826b72cdcb35e184734fe96f9","src/operation.rs":"2d7413fcb6096085bae0012352a381b48fb4379bdabe6884147fed8b57041f05","src/operation/builder.rs":"e12363159bc736c97534a2f927915c3d8289391849435cc4f44e3d4412882d63","src/operation/cups.rs":"2fad56b3d645371c4efd497a6e5ef2c4df8a137a5cd4bdf01cd434d2a8383768","src/parser.rs":"c57718495120142fcbd89101fb03263b3ad1ec3a6caa043f05da17e8599294f5","src/payload.rs":"3ac50acd69238e913c547be58010aa563d205e8955043e6b0d686864d6536513","src/reader.rs":"f58af769e96781c5d715f6471a6f92de82442fab0244d3611edb052b7134b346","src/request.rs":"5337ca3f0d54f1e7af44fb3716d9c7379ec992e3d538b581fb007a502ba06386","src/util.rs":"a23e3ef1fa1f9293dbe1ddf599062c680ad7adb86b4edb230fd049ac95840894","src/value.rs":"f06d06520069fbb1376118e97bfd4fc6d696ea8d2bb6861d5329fffadadcb879"}}
\ No newline at end of file
diff --git a/crates/ipp/.cargo-checksum.json b/crates/ipp/.cargo-checksum.json
new file mode 100644
index 0000000..d50e148
--- /dev/null
+++ b/crates/ipp/.cargo-checksum.json
@@ -0,0 +1 @@
+{"files":{"Cargo.toml":"83161f1835c51f1430a256d1de3fb4da13d78c9727a75f312c36b982775f1a73","README.md":"98d37d36dbb834b970f3cdfb71f9c81f68d900f99bda079de5bb4d021d4f8a9a","src/attribute.rs":"2c01ae2fa5a48b968b43622793a34974b8153544b1f7220743b14416085dab11","src/client.rs":"a3cebe2d7727b12054b91ea998b0c27612ced41e455010e59a084db4a2836142","src/error.rs":"aefe12457e30f4a7484c6d4bfa4b366a04c8525f30f6de9755ce9ebc220d8959","src/lib.rs":"d87167ee8c8d61091d98f6d25fc54873b8d3430f47a826cf0f50c854a21cf5ed","src/model.rs":"ac6fd2898c61811bc89e36412c2647677b88b65e44b73e7c4172dabc11033490","src/operation.rs":"f96e94228462cbf21dbe42e4e3790bdd28191a183b9b3361fce04259c22e8880","src/operation/builder.rs":"778de0432f1ef8bfaab368b371c2d2cde810e20a9c9b02049d2de1d26308100b","src/operation/cups.rs":"e6812e013e1a8819e428548125d5bbdbabe60bf2242f3b4b35598a4fc95aea88","src/parser.rs":"fc35f55ab4da85a6323c38e0b63c774a97e27784b028ece5ce5d99e27fbaae3a","src/payload.rs":"b9d2a0e02a0dd54ae2037c77f08b741798c32242a43b47bec17841bb4dfe9d45","src/reader.rs":"6cba49671f8bd13ac5f68b9adbec64260d5479ff9d10debeef13aa5fa8a24131","src/request.rs":"4ff4f28347e08725b3d465d07e2f8d92d1f1e59f2656fa71d52443c66ef485e3","src/util.rs":"cf0c5f9674b2120376dffda637b9aa0bbe33226e7eeb01e7cfd7fe5b1c93ccc1","src/value.rs":"2ac219754ff7541ca76805254345474d306dbf7a4c0f258ec99100008fc5bf4e"},"package":"0a74ba7383ea538b5c356323681ec5f4208eedf502e4e13886c82cf65af636ca"}
\ No newline at end of file
diff --git a/crates/ipp/Android.bp b/crates/ipp/Android.bp
new file mode 100644
index 0000000..a24c0cf
--- /dev/null
+++ b/crates/ipp/Android.bp
@@ -0,0 +1,83 @@
+// This file is generated by cargo_embargo.
+// Do not modify this file because the changes will be overridden on upgrade.
+
+package {
+    default_applicable_licenses: ["external_rust_crates_ipp_license"],
+    default_team: "trendy_team_android_rust",
+}
+
+license {
+    name: "external_rust_crates_ipp_license",
+    visibility: [":__subpackages__"],
+    license_kinds: ["SPDX-license-identifier-Apache-2.0"],
+    license_text: ["LICENSE"],
+}
+
+rust_test {
+    name: "ipp_test_src_lib",
+    host_supported: true,
+    crate_name: "ipp",
+    cargo_env_compat: true,
+    cargo_pkg_version: "5.2.0",
+    crate_root: "src/lib.rs",
+    test_suites: ["general-tests"],
+    auto_gen_config: true,
+    test_options: {
+        unit_test: true,
+    },
+    edition: "2021",
+    features: [
+        "async",
+        "futures-executor",
+        "futures-util",
+    ],
+    rustlibs: [
+        "libbytes",
+        "libfutures_executor",
+        "libfutures_util",
+        "libhttp",
+        "liblog_rust",
+        "libnum_traits",
+        "libthiserror",
+        "libtokio",
+    ],
+    proc_macros: [
+        "libenum_as_inner",
+        "libenum_primitive_derive",
+    ],
+}
+
+rust_library {
+    name: "libipp",
+    host_supported: true,
+    crate_name: "ipp",
+    cargo_env_compat: true,
+    cargo_pkg_version: "5.2.0",
+    crate_root: "src/lib.rs",
+    edition: "2021",
+    features: [
+        "async",
+        "futures-executor",
+        "futures-util",
+    ],
+    rustlibs: [
+        "libbytes",
+        "libfutures_executor",
+        "libfutures_util",
+        "libhttp",
+        "liblog_rust",
+        "libnum_traits",
+        "libthiserror",
+    ],
+    proc_macros: [
+        "libenum_as_inner",
+        "libenum_primitive_derive",
+    ],
+    apex_available: [
+        "//apex_available:platform",
+        "//apex_available:anyapex",
+    ],
+    product_available: true,
+    vendor_available: true,
+    min_sdk_version: "29",
+}
diff --git a/crates/ipp/Cargo.toml b/crates/ipp/Cargo.toml
new file mode 100644
index 0000000..ac3cb66
--- /dev/null
+++ b/crates/ipp/Cargo.toml
@@ -0,0 +1,170 @@
+# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
+#
+# When uploading crates to the registry Cargo will automatically
+# "normalize" Cargo.toml files for maximal compatibility
+# with all versions of Cargo and also rewrite `path` dependencies
+# to registry (e.g., crates.io) dependencies.
+#
+# If you are reading this file be aware that the original Cargo.toml
+# will likely look very different (and much more reasonable).
+# See Cargo.toml.orig for the original contents.
+
+[package]
+edition = "2021"
+name = "ipp"
+version = "5.2.0"
+authors = ["Dmitry Pankratov <dmitry@pankratov.net>"]
+build = false
+autolib = false
+autobins = false
+autoexamples = false
+autotests = false
+autobenches = false
+description = "Asynchronous IPP print protocol implementation"
+documentation = "https://docs.rs/ipp"
+readme = "README.md"
+keywords = [
+    "ipp",
+    "print",
+    "cups",
+    "printing",
+    "protocol",
+]
+license = "MIT/Apache-2.0"
+repository = "https://github.com/ancwrd1/ipp.rs"
+
+[lib]
+name = "ipp"
+path = "src/lib.rs"
+
+[dependencies.base64]
+version = "0.22"
+optional = true
+
+[dependencies.bytes]
+version = "1"
+
+[dependencies.enum-as-inner]
+version = "0.6"
+
+[dependencies.enum-primitive-derive]
+version = "0.3"
+
+[dependencies.futures-executor]
+version = "0.3"
+optional = true
+
+[dependencies.futures-util]
+version = "0.3"
+features = ["io"]
+optional = true
+default-features = false
+
+[dependencies.http]
+version = "1"
+
+[dependencies.log]
+version = "0.4"
+
+[dependencies.native-tls]
+version = "0.2"
+optional = true
+
+[dependencies.num-traits]
+version = "0.2"
+
+[dependencies.once_cell]
+version = "1"
+optional = true
+
+[dependencies.reqwest]
+version = "0.12"
+features = ["stream"]
+optional = true
+default-features = false
+
+[dependencies.rustls]
+version = "0.23"
+features = [
+    "ring",
+    "log",
+    "tls12",
+    "std",
+]
+optional = true
+default-features = false
+
+[dependencies.rustls-native-certs]
+version = "0.8"
+optional = true
+
+[dependencies.serde]
+version = "1"
+features = ["derive"]
+optional = true
+
+[dependencies.thiserror]
+version = "2"
+
+[dependencies.tokio-util]
+version = "0.7"
+features = [
+    "io",
+    "compat",
+]
+optional = true
+
+[dependencies.ureq]
+version = "2"
+optional = true
+default-features = false
+
+[dev-dependencies.tokio]
+version = "1"
+features = [
+    "macros",
+    "rt-multi-thread",
+]
+
+[features]
+async = [
+    "futures-util",
+    "futures-executor",
+]
+async-client = [
+    "async",
+    "reqwest",
+    "tokio-util",
+    "base64",
+]
+async-client-rustls = [
+    "async-client",
+    "rustls",
+    "reqwest/rustls-tls-native-roots",
+]
+async-client-tls = [
+    "async-client",
+    "native-tls",
+    "reqwest/native-tls",
+]
+client = [
+    "ureq",
+    "base64",
+]
+client-rustls = [
+    "client",
+    "rustls",
+    "rustls-native-certs",
+    "once_cell",
+    "ureq/tls",
+]
+client-tls = [
+    "client",
+    "native-tls",
+    "ureq/native-tls",
+]
+default = ["async-client-tls"]
+serde = [
+    "dep:serde",
+    "bytes/serde",
+]
diff --git a/crates/ipp/LICENSE b/crates/ipp/LICENSE
new file mode 100644
index 0000000..f49a4e1
--- /dev/null
+++ b/crates/ipp/LICENSE
@@ -0,0 +1,201 @@
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
\ No newline at end of file
diff --git a/crates/ipp/METADATA b/crates/ipp/METADATA
new file mode 100644
index 0000000..8b6f16e
--- /dev/null
+++ b/crates/ipp/METADATA
@@ -0,0 +1,17 @@
+name: "ipp"
+description: "Asynchronous IPP print protocol implementation"
+third_party {
+  version: "5.2.0"
+  license_type: NOTICE
+  last_upgrade_date {
+    year: 2025
+    month: 3
+    day: 21
+  }
+  homepage: "https://crates.io/crates/ipp"
+  identifier {
+    type: "Archive"
+    value: "https://static.crates.io/crates/ipp/ipp-5.2.0.crate"
+    version: "5.2.0"
+  }
+}
diff --git a/crates/ipp/MODULE_LICENSE_APACHE2 b/crates/ipp/MODULE_LICENSE_APACHE2
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/crates/ipp/MODULE_LICENSE_APACHE2
diff --git a/crates/ipp/README.md b/crates/ipp/README.md
new file mode 100644
index 0000000..c28a79a
--- /dev/null
+++ b/crates/ipp/README.md
@@ -0,0 +1,58 @@
+# ipp.rs
+
+[![github actions](https://github.com/ancwrd1/ipp.rs/workflows/CI/badge.svg)](https://github.com/ancwrd1/ipp.rs/actions)
+[![crates](https://img.shields.io/crates/v/ipp.svg)](https://crates.io/crates/ipp)
+[![license](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
+[![license](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
+[![docs.rs](https://docs.rs/ipp/badge.svg)](https://docs.rs/ipp)
+
+IPP protocol implementation for Rust.
+This crate implements IPP protocol as defined in [RFC 8010](https://tools.ietf.org/html/rfc8010), [RFC 8011](https://tools.ietf.org/html/rfc8011).
+
+It supports both synchronous and asynchronous operations (requests and responses) which is controlled by the `async` feature flag.
+
+The following build-time features are supported:
+
+* `async` - enables asynchronous APIs.
+* `async-client` - enables asynchronous IPP client based on `reqwest` crate, implies `async` feature.
+* `client` - enables blocking IPP client based on `ureq` crate.
+* `async-client-tls` - enables asynchronous IPP client with TLS, using native-tls backend. Implies `async-client` feature.
+* `client-tls` - enables blocking IPP client with TLS, using native-tls backend. Implies `client` feature.
+* `async-client-rustls` - enables asynchronous IPP client with TLS, using rustls backend. Implies `async-client` feature.
+* `client-rustls` - enables blocking IPP client with TLS, using rustls backend. Implies `client` feature.
+
+By default, the following features are enabled: `async-client-tls`.
+Use `default-features=false` dependency option to disable them.
+
+[Documentation](https://docs.rs/ipp/latest/ipp/)
+
+Usage example for async client:
+
+```rust
+use ipp::prelude::*;
+
+#[tokio::main]
+async fn main() -> Result<(), Box<dyn std::error::Error>> {
+    let uri: Uri = "http://localhost:631/printers/test-printer".parse()?;
+    let operation = IppOperationBuilder::get_printer_attributes(uri.clone()).build();
+    let client = AsyncIppClient::new(uri);
+    let resp = client.send(operation).await?;
+    if resp.header().status_code().is_success() {
+        let printer_attrs = resp
+            .attributes()
+            .groups_of(DelimiterTag::PrinterAttributes)
+            .next()
+            .unwrap();
+        for (_, v) in printer_attrs.attributes() {
+            println!("{}: {}", v.name(), v.value());
+        }
+    }
+    Ok(())
+}
+```
+
+For more usage examples please check the [examples folder](https://github.com/ancwrd1/ipp.rs/tree/master/examples).
+
+## License
+
+Licensed under MIT or Apache license ([LICENSE-MIT](https://opensource.org/licenses/MIT) or [LICENSE-APACHE](https://opensource.org/licenses/Apache-2.0))
diff --git a/crates/ipp/TEST_MAPPING b/crates/ipp/TEST_MAPPING
new file mode 100644
index 0000000..35f7874
--- /dev/null
+++ b/crates/ipp/TEST_MAPPING
@@ -0,0 +1,7 @@
+{
+  "postsubmit": [
+    {
+      "name": "ipp_test_src_lib"
+    }
+  ]
+}
diff --git a/crates/ipp/cargo_embargo.json b/crates/ipp/cargo_embargo.json
new file mode 100644
index 0000000..daf2ad7
--- /dev/null
+++ b/crates/ipp/cargo_embargo.json
@@ -0,0 +1,6 @@
+{
+  "run_cargo": false,
+  "min_sdk_version": "29",
+  "tests": true,
+  "features": ["async"]
+}
diff --git a/crates/ipp/patches/LICENSE.patch b/crates/ipp/patches/LICENSE.patch
new file mode 100644
index 0000000..bc7920a
--- /dev/null
+++ b/crates/ipp/patches/LICENSE.patch
@@ -0,0 +1,207 @@
+diff --git a/LICENSE b/LICENSE
+index e69de29b..f49a4e16 100644
+--- a/LICENSE
++++ b/LICENSE
+@@ -0,0 +1,201 @@
++                                 Apache License
++                           Version 2.0, January 2004
++                        http://www.apache.org/licenses/
++
++   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
++
++   1. Definitions.
++
++      "License" shall mean the terms and conditions for use, reproduction,
++      and distribution as defined by Sections 1 through 9 of this document.
++
++      "Licensor" shall mean the copyright owner or entity authorized by
++      the copyright owner that is granting the License.
++
++      "Legal Entity" shall mean the union of the acting entity and all
++      other entities that control, are controlled by, or are under common
++      control with that entity. For the purposes of this definition,
++      "control" means (i) the power, direct or indirect, to cause the
++      direction or management of such entity, whether by contract or
++      otherwise, or (ii) ownership of fifty percent (50%) or more of the
++      outstanding shares, or (iii) beneficial ownership of such entity.
++
++      "You" (or "Your") shall mean an individual or Legal Entity
++      exercising permissions granted by this License.
++
++      "Source" form shall mean the preferred form for making modifications,
++      including but not limited to software source code, documentation
++      source, and configuration files.
++
++      "Object" form shall mean any form resulting from mechanical
++      transformation or translation of a Source form, including but
++      not limited to compiled object code, generated documentation,
++      and conversions to other media types.
++
++      "Work" shall mean the work of authorship, whether in Source or
++      Object form, made available under the License, as indicated by a
++      copyright notice that is included in or attached to the work
++      (an example is provided in the Appendix below).
++
++      "Derivative Works" shall mean any work, whether in Source or Object
++      form, that is based on (or derived from) the Work and for which the
++      editorial revisions, annotations, elaborations, or other modifications
++      represent, as a whole, an original work of authorship. For the purposes
++      of this License, Derivative Works shall not include works that remain
++      separable from, or merely link (or bind by name) to the interfaces of,
++      the Work and Derivative Works thereof.
++
++      "Contribution" shall mean any work of authorship, including
++      the original version of the Work and any modifications or additions
++      to that Work or Derivative Works thereof, that is intentionally
++      submitted to Licensor for inclusion in the Work by the copyright owner
++      or by an individual or Legal Entity authorized to submit on behalf of
++      the copyright owner. For the purposes of this definition, "submitted"
++      means any form of electronic, verbal, or written communication sent
++      to the Licensor or its representatives, including but not limited to
++      communication on electronic mailing lists, source code control systems,
++      and issue tracking systems that are managed by, or on behalf of, the
++      Licensor for the purpose of discussing and improving the Work, but
++      excluding communication that is conspicuously marked or otherwise
++      designated in writing by the copyright owner as "Not a Contribution."
++
++      "Contributor" shall mean Licensor and any individual or Legal Entity
++      on behalf of whom a Contribution has been received by Licensor and
++      subsequently incorporated within the Work.
++
++   2. Grant of Copyright License. Subject to the terms and conditions of
++      this License, each Contributor hereby grants to You a perpetual,
++      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
++      copyright license to reproduce, prepare Derivative Works of,
++      publicly display, publicly perform, sublicense, and distribute the
++      Work and such Derivative Works in Source or Object form.
++
++   3. Grant of Patent License. Subject to the terms and conditions of
++      this License, each Contributor hereby grants to You a perpetual,
++      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
++      (except as stated in this section) patent license to make, have made,
++      use, offer to sell, sell, import, and otherwise transfer the Work,
++      where such license applies only to those patent claims licensable
++      by such Contributor that are necessarily infringed by their
++      Contribution(s) alone or by combination of their Contribution(s)
++      with the Work to which such Contribution(s) was submitted. If You
++      institute patent litigation against any entity (including a
++      cross-claim or counterclaim in a lawsuit) alleging that the Work
++      or a Contribution incorporated within the Work constitutes direct
++      or contributory patent infringement, then any patent licenses
++      granted to You under this License for that Work shall terminate
++      as of the date such litigation is filed.
++
++   4. Redistribution. You may reproduce and distribute copies of the
++      Work or Derivative Works thereof in any medium, with or without
++      modifications, and in Source or Object form, provided that You
++      meet the following conditions:
++
++      (a) You must give any other recipients of the Work or
++          Derivative Works a copy of this License; and
++
++      (b) You must cause any modified files to carry prominent notices
++          stating that You changed the files; and
++
++      (c) You must retain, in the Source form of any Derivative Works
++          that You distribute, all copyright, patent, trademark, and
++          attribution notices from the Source form of the Work,
++          excluding those notices that do not pertain to any part of
++          the Derivative Works; and
++
++      (d) If the Work includes a "NOTICE" text file as part of its
++          distribution, then any Derivative Works that You distribute must
++          include a readable copy of the attribution notices contained
++          within such NOTICE file, excluding those notices that do not
++          pertain to any part of the Derivative Works, in at least one
++          of the following places: within a NOTICE text file distributed
++          as part of the Derivative Works; within the Source form or
++          documentation, if provided along with the Derivative Works; or,
++          within a display generated by the Derivative Works, if and
++          wherever such third-party notices normally appear. The contents
++          of the NOTICE file are for informational purposes only and
++          do not modify the License. You may add Your own attribution
++          notices within Derivative Works that You distribute, alongside
++          or as an addendum to the NOTICE text from the Work, provided
++          that such additional attribution notices cannot be construed
++          as modifying the License.
++
++      You may add Your own copyright statement to Your modifications and
++      may provide additional or different license terms and conditions
++      for use, reproduction, or distribution of Your modifications, or
++      for any such Derivative Works as a whole, provided Your use,
++      reproduction, and distribution of the Work otherwise complies with
++      the conditions stated in this License.
++
++   5. Submission of Contributions. Unless You explicitly state otherwise,
++      any Contribution intentionally submitted for inclusion in the Work
++      by You to the Licensor shall be under the terms and conditions of
++      this License, without any additional terms or conditions.
++      Notwithstanding the above, nothing herein shall supersede or modify
++      the terms of any separate license agreement you may have executed
++      with Licensor regarding such Contributions.
++
++   6. Trademarks. This License does not grant permission to use the trade
++      names, trademarks, service marks, or product names of the Licensor,
++      except as required for reasonable and customary use in describing the
++      origin of the Work and reproducing the content of the NOTICE file.
++
++   7. Disclaimer of Warranty. Unless required by applicable law or
++      agreed to in writing, Licensor provides the Work (and each
++      Contributor provides its Contributions) on an "AS IS" BASIS,
++      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
++      implied, including, without limitation, any warranties or conditions
++      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
++      PARTICULAR PURPOSE. You are solely responsible for determining the
++      appropriateness of using or redistributing the Work and assume any
++      risks associated with Your exercise of permissions under this License.
++
++   8. Limitation of Liability. In no event and under no legal theory,
++      whether in tort (including negligence), contract, or otherwise,
++      unless required by applicable law (such as deliberate and grossly
++      negligent acts) or agreed to in writing, shall any Contributor be
++      liable to You for damages, including any direct, indirect, special,
++      incidental, or consequential damages of any character arising as a
++      result of this License or out of the use or inability to use the
++      Work (including but not limited to damages for loss of goodwill,
++      work stoppage, computer failure or malfunction, or any and all
++      other commercial damages or losses), even if such Contributor
++      has been advised of the possibility of such damages.
++
++   9. Accepting Warranty or Additional Liability. While redistributing
++      the Work or Derivative Works thereof, You may choose to offer,
++      and charge a fee for, acceptance of support, warranty, indemnity,
++      or other liability obligations and/or rights consistent with this
++      License. However, in accepting such obligations, You may act only
++      on Your own behalf and on Your sole responsibility, not on behalf
++      of any other Contributor, and only if You agree to indemnify,
++      defend, and hold each Contributor harmless for any liability
++      incurred by, or claims asserted against, such Contributor by reason
++      of your accepting any such warranty or additional liability.
++
++   END OF TERMS AND CONDITIONS
++
++   APPENDIX: How to apply the Apache License to your work.
++
++      To apply the Apache License to your work, attach the following
++      boilerplate notice, with the fields enclosed by brackets "[]"
++      replaced with your own identifying information. (Don't include
++      the brackets!)  The text should be enclosed in the appropriate
++      comment syntax for the file format. We also recommend that a
++      file or class name and description of purpose be included on the
++      same "printed page" as the copyright notice for easier
++      identification within third-party archives.
++
++   Copyright [yyyy] [name of copyright owner]
++
++   Licensed under the Apache License, Version 2.0 (the "License");
++   you may not use this file except in compliance with the License.
++   You may obtain a copy of the License at
++
++       http://www.apache.org/licenses/LICENSE-2.0
++
++   Unless required by applicable law or agreed to in writing, software
++   distributed under the License is distributed on an "AS IS" BASIS,
++   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
++   See the License for the specific language governing permissions and
++   limitations under the License.
+\ No newline at end of file
diff --git a/crates/ipp/src/attribute.rs b/crates/ipp/src/attribute.rs
new file mode 100644
index 0000000..4266f70
--- /dev/null
+++ b/crates/ipp/src/attribute.rs
@@ -0,0 +1,289 @@
+//!
+//! Attribute-related structs
+//!
+use std::collections::HashMap;
+
+use bytes::{BufMut, Bytes, BytesMut};
+#[cfg(feature = "serde")]
+use serde::{Deserialize, Serialize};
+
+use crate::{model::DelimiterTag, value::IppValue};
+
+fn is_header_attr(attr: &str) -> bool {
+    IppAttribute::HEADER_ATTRS.iter().any(|&at| at == attr)
+}
+
+/// `IppAttribute` represents an IPP attribute
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
+#[derive(Clone, Debug)]
+pub struct IppAttribute {
+    /// Attribute name
+    name: String,
+    /// Attribute value
+    value: IppValue,
+}
+
+impl IppAttribute {
+    pub const ATTRIBUTES_CHARSET: &'static str = "attributes-charset";
+    pub const ATTRIBUTES_NATURAL_LANGUAGE: &'static str = "attributes-natural-language";
+    pub const CHARSET_CONFIGURED: &'static str = "charset-configured";
+    pub const CHARSET_SUPPORTED: &'static str = "charset-supported";
+    pub const COMPRESSION_SUPPORTED: &'static str = "compression-supported";
+    pub const DOCUMENT_FORMAT_DEFAULT: &'static str = "document-format-default";
+    pub const DOCUMENT_FORMAT_SUPPORTED: &'static str = "document-format-supported";
+    pub const DOCUMENT_FORMAT_PREFERRED: &'static str = "document-format-preferred";
+    pub const GENERATED_NATURAL_LANGUAGE_SUPPORTED: &'static str = "generated-natural-language-supported";
+    pub const IPP_VERSIONS_SUPPORTED: &'static str = "ipp-versions-supported";
+    pub const NATURAL_LANGUAGE_CONFIGURED: &'static str = "natural-language-configured";
+    pub const OPERATIONS_SUPPORTED: &'static str = "operations-supported";
+    pub const PDL_OVERRIDE_SUPPORTED: &'static str = "pdl-override-supported";
+    pub const PRINTER_IS_ACCEPTING_JOBS: &'static str = "printer-is-accepting-jobs";
+    pub const PRINTER_MAKE_AND_MODEL: &'static str = "printer-make-and-model";
+    pub const PRINTER_NAME: &'static str = "printer-name";
+    pub const PRINTER_STATE: &'static str = "printer-state";
+    pub const PRINTER_STATE_MESSAGE: &'static str = "printer-state-message";
+    pub const PRINTER_STATE_REASONS: &'static str = "printer-state-reasons";
+    pub const PRINTER_UP_TIME: &'static str = "printer-up-time";
+    pub const PRINTER_URI: &'static str = "printer-uri";
+    pub const PRINTER_URI_SUPPORTED: &'static str = "printer-uri-supported";
+    pub const PRINTER_FIRMWARE_NAME: &'static str = "printer-firmware-name";
+    pub const PRINTER_FIRMWARE_STRING_VERSION: &'static str = "printer-firmware-string-version";
+    pub const PRINTER_DEVICE_ID: &'static str = "printer-device-id";
+    pub const PRINTER_UUID: &'static str = "printer-uuid";
+    pub const QUEUED_JOB_COUNT: &'static str = "queued-job-count";
+    pub const URI_AUTHENTICATION_SUPPORTED: &'static str = "uri-authentication-supported";
+    pub const URI_SECURITY_SUPPORTED: &'static str = "uri-security-supported";
+    pub const JOB_ID: &'static str = "job-id";
+    pub const JOB_NAME: &'static str = "job-name";
+    pub const JOB_STATE: &'static str = "job-state";
+    pub const JOB_STATE_REASONS: &'static str = "job-state-reasons";
+    pub const JOB_URI: &'static str = "job-uri";
+    pub const LAST_DOCUMENT: &'static str = "last-document";
+    pub const REQUESTING_USER_NAME: &'static str = "requesting-user-name";
+    pub const STATUS_MESSAGE: &'static str = "status-message";
+    pub const REQUESTED_ATTRIBUTES: &'static str = "requested-attributes";
+    pub const SIDES_SUPPORTED: &'static str = "sides-supported";
+    pub const SIDES: &'static str = "sides";
+    pub const OUTPUT_MODE_SUPPORTED: &'static str = "output-mode-supported";
+    pub const COLOR_SUPPORTED: &'static str = "color-supported";
+    pub const PRINTER_INFO: &'static str = "printer-info";
+    pub const PRINTER_LOCATION: &'static str = "printer-location";
+    pub const PRINTER_MORE_INFO: &'static str = "printer-more-info";
+    pub const PRINTER_RESOLUTION_DEFAULT: &'static str = "printer-resolution-default";
+    pub const PRINTER_RESOLUTION_SUPPORTED: &'static str = "printer-resolution-supported";
+    pub const COPIES_SUPPORTED: &'static str = "copies-supported";
+    pub const COPIES_DEFAULT: &'static str = "copies-default";
+    pub const COPIES: &'static str = "copies";
+    pub const SIDES_DEFAULT: &'static str = "sides-default";
+    pub const PRINT_QUALITY_DEFAULT: &'static str = "print-quality-default";
+    pub const PRINT_QUALITY_SUPPORTED: &'static str = "print-quality-supported";
+    pub const FINISHINGS_DEFAULT: &'static str = "finishings-default";
+    pub const FINISHINGS_SUPPORTED: &'static str = "finishings-supported";
+    pub const OUTPUT_BIN_DEFAULT: &'static str = "output-bin-default";
+    pub const OUTPUT_BIN_SUPPORTED: &'static str = "output-bin-supported";
+    pub const ORIENTATION_REQUESTED_DEFAULT: &'static str = "orientation-requested-default";
+    pub const ORIENTATION_REQUESTED_SUPPORTED: &'static str = "orientation-requested-supported";
+    pub const MEDIA_DEFAULT: &'static str = "media-default";
+    pub const MEDIA_SUPPORTED: &'static str = "media-supported";
+    pub const MEDIA_COL_SUPPORTED: &'static str = "media-col-supported";
+    pub const MEDIA_TYPE_SUPPORTED: &'static str = "media-type-supported";
+    pub const PAGES_PER_MINUTE: &'static str = "pages-per-minute";
+    pub const COLOR_MODE_SUPPORTED: &'static str = "color-mode-supported";
+    pub const PRINT_COLOR_MODE_SUPPORTED: &'static str = "print-color-mode-supported";
+    pub const PRINT_COLOR_MODE_DEFAULT: &'static str = "print-color-mode-default";
+    pub const PRINT_COLOR_MODE: &'static str = "print-color-mode";
+    pub const MULTIPLE_DOCUMENT_HANDLING_SUPPORTED: &'static str = "multiple-document-handling-supported";
+    pub const MULTIPLE_DOCUMENT_HANDLING_DEFAULT: &'static str = "multiple-document-handling-default";
+    pub const MULTIPLE_DOCUMENT_HANDLING: &'static str = "multiple-document-handling";
+    pub const MEDIA_SOURCE_SUPPORTED: &'static str = "media-source-supported";
+    pub const MOPRIA_CERTIFIED: &'static str = "mopria-certified";
+    pub const ORIENTATION_REQUESTED: &'static str = "orientation-requested";
+    pub const OUTPUT_BIN: &'static str = "output-bin";
+    pub const PRINT_QUALITY: &'static str = "print-quality";
+    pub const PRINTER_RESOLUTION: &'static str = "printer-resolution";
+    pub const MEDIA_COL: &'static str = "media-col";
+    pub const FINISHINGS: &'static str = "finishings";
+
+    // Per section 4.1.4. Character Set and Natural Language Operation Attributes
+    // The "attributes-charset" and "attributes-natural-language" attributes MUST be the first two attributes
+    // in every IPP request and response, as part of the initial Operation Attributes group of the IPP message
+    // Per section 4.1.5 Operation targets
+    // o  In the case where there is only one operation target attribute
+    //    (i.e., either only the "printer-uri" attribute or only the
+    //    "job-uri" attribute), that attribute MUST be the third attribute
+    //    in the Operation Attributes group.
+    // o  In the case where Job operations use two operation target
+    //    attributes (i.e., the "printer-uri" and "job-id" attributes), the
+    //    "printer-uri" attribute MUST be the third attribute and the
+    //    "job-id" attribute MUST be the fourth attribute.
+    const HEADER_ATTRS: [&'static str; 3] = [
+        IppAttribute::ATTRIBUTES_CHARSET,
+        IppAttribute::ATTRIBUTES_NATURAL_LANGUAGE,
+        IppAttribute::PRINTER_URI,
+    ];
+
+    /// Create new instance of the attribute
+    ///
+    /// * `name` - Attribute name<br/>
+    /// * `value` - Attribute value<br/>
+    pub fn new<S>(name: S, value: IppValue) -> IppAttribute
+    where
+        S: AsRef<str>,
+    {
+        IppAttribute {
+            name: name.as_ref().to_owned(),
+            value,
+        }
+    }
+
+    /// Return attribute name
+    pub fn name(&self) -> &str {
+        &self.name
+    }
+
+    /// Return attribute value
+    pub fn value(&self) -> &IppValue {
+        &self.value
+    }
+
+    /// Consume this attribute and return the value
+    pub fn into_value(self) -> IppValue {
+        self.value
+    }
+
+    /// Write attribute to byte array
+    pub fn to_bytes(&self) -> Bytes {
+        let mut buffer = BytesMut::new();
+
+        buffer.put_u8(self.value.to_tag());
+        buffer.put_u16(self.name.len() as u16);
+        buffer.put_slice(self.name.as_bytes());
+        buffer.put(self.value.to_bytes());
+        buffer.freeze()
+    }
+}
+
+/// Attribute group
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
+#[derive(Clone, Debug)]
+pub struct IppAttributeGroup {
+    tag: DelimiterTag,
+    attributes: HashMap<String, IppAttribute>,
+}
+
+impl IppAttributeGroup {
+    /// Create new attribute group of a given type
+    pub fn new(tag: DelimiterTag) -> IppAttributeGroup {
+        IppAttributeGroup {
+            tag,
+            attributes: HashMap::new(),
+        }
+    }
+
+    /// Return group type tag
+    pub fn tag(&self) -> DelimiterTag {
+        self.tag
+    }
+
+    /// Return read-only attributes
+    pub fn attributes(&self) -> &HashMap<String, IppAttribute> {
+        &self.attributes
+    }
+
+    /// Return mutable attributes
+    pub fn attributes_mut(&mut self) -> &mut HashMap<String, IppAttribute> {
+        &mut self.attributes
+    }
+
+    /// Consume this group and return mutable attributes
+    pub fn into_attributes(self) -> HashMap<String, IppAttribute> {
+        self.attributes
+    }
+}
+
+/// Attribute list
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
+#[derive(Clone, Debug, Default)]
+pub struct IppAttributes {
+    groups: Vec<IppAttributeGroup>,
+}
+
+impl IppAttributes {
+    /// Create attribute list
+    pub fn new() -> IppAttributes {
+        IppAttributes { ..Default::default() }
+    }
+
+    /// Get all groups
+    pub fn groups(&self) -> &[IppAttributeGroup] {
+        &self.groups
+    }
+
+    /// Get all mutable groups
+    pub fn groups_mut(&mut self) -> &mut Vec<IppAttributeGroup> {
+        &mut self.groups
+    }
+
+    /// Consume this attribute list and return all attribute groups
+    pub fn into_groups(self) -> Vec<IppAttributeGroup> {
+        self.groups
+    }
+
+    /// Get a list of attribute groups matching a given delimiter tag
+    pub fn groups_of(&self, tag: DelimiterTag) -> impl Iterator<Item = &IppAttributeGroup> {
+        self.groups.iter().filter(move |g| g.tag == tag)
+    }
+
+    /// Add attribute to a given group
+    pub fn add(&mut self, tag: DelimiterTag, attribute: IppAttribute) {
+        let group = self.groups_mut().iter_mut().find(|g| g.tag() == tag);
+        if let Some(group) = group {
+            group.attributes_mut().insert(attribute.name().to_owned(), attribute);
+        } else {
+            let mut new_group = IppAttributeGroup::new(tag);
+            new_group
+                .attributes_mut()
+                .insert(attribute.name().to_owned(), attribute);
+            self.groups_mut().push(new_group);
+        }
+    }
+
+    /// Write attribute list to byte array
+    pub fn to_bytes(&self) -> Bytes {
+        let mut buffer = BytesMut::new();
+
+        // put the required attributes first as described in section 4.1.4 of RFC8011
+        buffer.put_u8(DelimiterTag::OperationAttributes as u8);
+
+        if let Some(group) = self.groups_of(DelimiterTag::OperationAttributes).next() {
+            for hdr in &IppAttribute::HEADER_ATTRS {
+                if let Some(attr) = group.attributes().get(*hdr) {
+                    buffer.put(attr.to_bytes());
+                }
+            }
+
+            // now the other operation attributes
+            for attr in group.attributes().values() {
+                if !is_header_attr(attr.name()) {
+                    buffer.put(attr.to_bytes());
+                }
+            }
+        }
+
+        // now the rest
+        for group in self
+            .groups()
+            .iter()
+            .filter(|group| group.tag() != DelimiterTag::OperationAttributes)
+        {
+            buffer.put_u8(group.tag() as u8);
+
+            for attr in group.attributes().values() {
+                buffer.put(attr.to_bytes());
+            }
+        }
+        buffer.put_u8(DelimiterTag::EndOfAttributes as u8);
+
+        buffer.freeze()
+    }
+}
diff --git a/crates/ipp/src/client.rs b/crates/ipp/src/client.rs
new file mode 100644
index 0000000..b9fabc3
--- /dev/null
+++ b/crates/ipp/src/client.rs
@@ -0,0 +1,412 @@
+//!
+//! IPP client
+//!
+use std::{collections::BTreeMap, marker::PhantomData, time::Duration};
+
+use base64::Engine;
+use http::Uri;
+
+const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
+
+fn ipp_uri_to_string(uri: &Uri) -> String {
+    let (scheme, default_port) = match uri.scheme_str() {
+        Some("ipps") => ("https", 443),
+        Some("ipp") => ("http", 631),
+        _ => return uri.to_string(),
+    };
+
+    let authority = match uri.authority() {
+        Some(authority) => {
+            if authority.port_u16().is_some() {
+                authority.to_string()
+            } else {
+                format!("{}:{}", authority, default_port)
+            }
+        }
+        None => return uri.to_string(),
+    };
+
+    let path_and_query = uri.path_and_query().map(|p| p.as_str()).unwrap_or_default();
+
+    format!("{}://{}{}", scheme, authority, path_and_query)
+}
+
+/// Builder to create IPP client
+pub struct IppClientBuilder<T> {
+    uri: Uri,
+    ignore_tls_errors: bool,
+    request_timeout: Option<Duration>,
+    headers: BTreeMap<String, String>,
+    ca_certs: Vec<Vec<u8>>,
+    _phantom_data: PhantomData<T>,
+}
+
+impl<T> IppClientBuilder<T> {
+    fn new(uri: Uri) -> Self {
+        IppClientBuilder {
+            uri,
+            ignore_tls_errors: false,
+            request_timeout: None,
+            headers: BTreeMap::new(),
+            ca_certs: Vec::new(),
+            _phantom_data: PhantomData,
+        }
+    }
+
+    /// Enable or disable ignoring of TLS handshake errors. Default is false.
+    pub fn ignore_tls_errors(mut self, flag: bool) -> Self {
+        self.ignore_tls_errors = flag;
+        self
+    }
+
+    /// Add custom root certificate in PEM or DER format.
+    pub fn ca_cert<D: AsRef<[u8]>>(mut self, data: D) -> Self {
+        self.ca_certs.push(data.as_ref().to_owned());
+        self
+    }
+
+    /// Set network request timeout. Default is no timeout.
+    pub fn request_timeout(mut self, duration: Duration) -> Self {
+        self.request_timeout = Some(duration);
+        self
+    }
+
+    /// Add custom HTTP header
+    pub fn http_header<K, V>(mut self, key: K, value: V) -> Self
+    where
+        K: AsRef<str>,
+        V: AsRef<str>,
+    {
+        self.headers.insert(key.as_ref().to_owned(), value.as_ref().to_owned());
+        self
+    }
+
+    /// Add basic auth header (RFC 7617)
+    pub fn basic_auth<U, P>(mut self, username: U, password: P) -> Self
+    where
+        U: AsRef<str>,
+        P: AsRef<str>,
+    {
+        let authz =
+            base64::engine::general_purpose::STANDARD.encode(format!("{}:{}", username.as_ref(), password.as_ref()));
+        self.headers
+            .insert("authorization".to_owned(), format!("Basic {authz}"));
+        self
+    }
+}
+
+#[cfg(feature = "async-client")]
+impl IppClientBuilder<non_blocking::AsyncIppClient> {
+    /// Build the async client
+    pub fn build(self) -> non_blocking::AsyncIppClient {
+        non_blocking::AsyncIppClient(self)
+    }
+}
+
+#[cfg(feature = "client")]
+impl IppClientBuilder<blocking::IppClient> {
+    /// Build the blocking client
+    pub fn build(self) -> blocking::IppClient {
+        blocking::IppClient(self)
+    }
+}
+
+#[cfg(feature = "async-client")]
+pub mod non_blocking {
+    use std::io;
+
+    use futures_util::{io::BufReader, stream::TryStreamExt};
+    use http::Uri;
+    use reqwest::{Body, ClientBuilder};
+    use tokio_util::compat::FuturesAsyncReadCompatExt;
+
+    use crate::{error::IppError, parser::AsyncIppParser, request::IppRequestResponse};
+
+    use super::{ipp_uri_to_string, IppClientBuilder, CONNECT_TIMEOUT};
+
+    const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"), ";reqwest");
+
+    /// Asynchronous IPP client.
+    ///
+    /// IPP client is responsible for sending requests to IPP server.
+    pub struct AsyncIppClient(pub(super) IppClientBuilder<Self>);
+
+    impl AsyncIppClient {
+        /// Create IPP client with default options
+        pub fn new(uri: Uri) -> Self {
+            AsyncIppClient(AsyncIppClient::builder(uri))
+        }
+
+        /// Create IPP client builder for setting extra options
+        pub fn builder(uri: Uri) -> IppClientBuilder<Self> {
+            IppClientBuilder::new(uri)
+        }
+
+        /// Return client URI
+        pub fn uri(&self) -> &Uri {
+            &self.0.uri
+        }
+
+        /// Send IPP request to the server
+        pub async fn send<R>(&self, request: R) -> Result<IppRequestResponse, IppError>
+        where
+            R: Into<IppRequestResponse>,
+        {
+            let mut builder = ClientBuilder::new().connect_timeout(CONNECT_TIMEOUT);
+
+            if let Some(timeout) = self.0.request_timeout {
+                builder = builder.timeout(timeout);
+            }
+
+            #[cfg(any(feature = "async-client-tls", feature = "async-client-rustls"))]
+            {
+                if self.0.ignore_tls_errors {
+                    builder = builder
+                        .danger_accept_invalid_hostnames(true)
+                        .danger_accept_invalid_certs(true);
+                }
+                for data in &self.0.ca_certs {
+                    let cert =
+                        reqwest::Certificate::from_pem(data).or_else(|_| reqwest::Certificate::from_der(data))?;
+                    builder = builder.add_root_certificate(cert);
+                }
+            }
+
+            #[cfg(feature = "async-client-rustls")]
+            {
+                builder = builder.use_rustls_tls();
+            }
+
+            let mut req_builder = builder
+                .user_agent(USER_AGENT)
+                .build()?
+                .post(ipp_uri_to_string(&self.0.uri));
+
+            for (k, v) in &self.0.headers {
+                req_builder = req_builder.header(k, v);
+            }
+
+            let response = req_builder
+                .header("content-type", "application/ipp")
+                .body(Body::wrap_stream(tokio_util::io::ReaderStream::new(
+                    request.into().into_async_read().compat(),
+                )))
+                .send()
+                .await?;
+
+            if response.status().is_success() {
+                let parser = AsyncIppParser::new(BufReader::new(
+                    response
+                        .bytes_stream()
+                        .map_err(|e| io::Error::new(io::ErrorKind::Other, e))
+                        .into_async_read(),
+                ));
+                parser.parse().await.map_err(IppError::from)
+            } else {
+                Err(IppError::RequestError(response.status().as_u16()))
+            }
+        }
+    }
+}
+
+#[cfg(feature = "client")]
+pub mod blocking {
+    use http::Uri;
+    use ureq::AgentBuilder;
+
+    use crate::{error::IppError, parser::IppParser, reader::IppReader, request::IppRequestResponse};
+
+    use super::{ipp_uri_to_string, IppClientBuilder, CONNECT_TIMEOUT};
+
+    const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"), ";ureq");
+
+    /// Blocking IPP client.
+    ///
+    /// IPP client is responsible for sending requests to IPP server.
+    pub struct IppClient(pub(super) IppClientBuilder<Self>);
+
+    impl IppClient {
+        /// Create IPP client with default options
+        pub fn new(uri: Uri) -> Self {
+            IppClient(IppClient::builder(uri))
+        }
+
+        /// Create IPP client builder for setting extra options
+        pub fn builder(uri: Uri) -> IppClientBuilder<Self> {
+            IppClientBuilder::new(uri)
+        }
+
+        /// Return client URI
+        pub fn uri(&self) -> &Uri {
+            &self.0.uri
+        }
+
+        /// Send IPP request to the server
+        pub fn send<R>(&self, request: R) -> Result<IppRequestResponse, IppError>
+        where
+            R: Into<IppRequestResponse>,
+        {
+            let mut builder = AgentBuilder::new().timeout_connect(CONNECT_TIMEOUT);
+
+            if let Some(timeout) = self.0.request_timeout {
+                builder = builder.timeout(timeout);
+            }
+
+            #[cfg(feature = "client-tls")]
+            {
+                let mut tls_builder = native_tls::TlsConnector::builder();
+
+                tls_builder
+                    .danger_accept_invalid_hostnames(self.0.ignore_tls_errors)
+                    .danger_accept_invalid_certs(self.0.ignore_tls_errors);
+
+                for data in &self.0.ca_certs {
+                    let cert =
+                        native_tls::Certificate::from_pem(data).or_else(|_| native_tls::Certificate::from_der(data))?;
+                    tls_builder.add_root_certificate(cert);
+                }
+
+                let tls_connector = tls_builder.build()?;
+                builder = builder.tls_connector(std::sync::Arc::new(tls_connector));
+            }
+
+            #[cfg(feature = "client-rustls")]
+            {
+                use once_cell::sync::Lazy;
+                use rustls::pki_types::pem::PemObject;
+                use rustls_native_certs::{load_native_certs, CertificateResult};
+
+                static ROOTS: Lazy<CertificateResult> = Lazy::new(load_native_certs);
+
+                let mut root_store = rustls::RootCertStore::empty();
+                root_store.add_parsable_certificates(ROOTS.certs.clone());
+
+                for data in &self.0.ca_certs {
+                    let cert = rustls::pki_types::CertificateDer::<'static>::from_pem_slice(data)
+                        .unwrap_or_else(|_| rustls::pki_types::CertificateDer::from_slice(data));
+                    root_store.add(cert)?;
+                }
+
+                let secure_config = rustls::ClientConfig::builder()
+                    .with_root_certificates(root_store)
+                    .with_no_client_auth();
+
+                let config = if self.0.ignore_tls_errors {
+                    rustls::ClientConfig::builder()
+                        .dangerous()
+                        .with_custom_certificate_verifier(std::sync::Arc::new(verifiers::NoVerifier(
+                            secure_config.crypto_provider().clone(),
+                        )))
+                        .with_no_client_auth()
+                } else {
+                    secure_config
+                };
+
+                builder = builder.tls_config(std::sync::Arc::new(config));
+            }
+
+            let agent = builder.user_agent(USER_AGENT).build();
+
+            let mut req = agent
+                .post(&ipp_uri_to_string(&self.0.uri))
+                .set("content-type", "application/ipp");
+
+            for (k, v) in &self.0.headers {
+                req = req.set(k, v);
+            }
+
+            let response = req.send(request.into().into_read())?;
+            let reader = response.into_reader();
+            let parser = IppParser::new(IppReader::new(reader));
+
+            parser.parse().map_err(IppError::from)
+        }
+    }
+
+    #[cfg(feature = "client-rustls")]
+    mod verifiers {
+        use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
+        use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
+        use rustls::{crypto::CryptoProvider, DigitallySignedStruct, Error, SignatureScheme};
+        use std::sync::Arc;
+
+        #[derive(Debug)]
+        pub struct NoVerifier(pub Arc<CryptoProvider>);
+
+        impl ServerCertVerifier for NoVerifier {
+            fn verify_server_cert(
+                &self,
+                _end_entity: &CertificateDer,
+                _intermediates: &[CertificateDer],
+                _server_name: &ServerName,
+                _ocsp_response: &[u8],
+                _now: UnixTime,
+            ) -> Result<ServerCertVerified, Error> {
+                Ok(ServerCertVerified::assertion())
+            }
+
+            fn verify_tls12_signature(
+                &self,
+                _message: &[u8],
+                _cert: &CertificateDer,
+                _dss: &DigitallySignedStruct,
+            ) -> Result<HandshakeSignatureValid, Error> {
+                Ok(HandshakeSignatureValid::assertion())
+            }
+
+            fn verify_tls13_signature(
+                &self,
+                _message: &[u8],
+                _cert: &CertificateDer,
+                _dss: &DigitallySignedStruct,
+            ) -> Result<HandshakeSignatureValid, Error> {
+                Ok(HandshakeSignatureValid::assertion())
+            }
+
+            fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
+                self.0.signature_verification_algorithms.supported_schemes()
+            }
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use crate::client::ipp_uri_to_string;
+    use http::Uri;
+
+    #[test]
+    fn test_ipp_uri_no_port() {
+        let uri = "ipp://user:pass@host/path?query=1234".parse::<Uri>().unwrap();
+        let http_uri = ipp_uri_to_string(&uri);
+        assert_eq!(http_uri, "http://user:pass@host:631/path?query=1234");
+    }
+
+    #[test]
+    fn test_ipp_uri_with_port() {
+        let uri = "ipp://user:pass@host:1000".parse::<Uri>().unwrap();
+        let http_uri = ipp_uri_to_string(&uri);
+        assert_eq!(http_uri, "http://user:pass@host:1000/");
+    }
+
+    #[test]
+    fn test_ipps_uri_no_port() {
+        let uri = "ipps://host".parse::<Uri>().unwrap();
+        let http_uri = ipp_uri_to_string(&uri);
+        assert_eq!(http_uri, "https://host:443/");
+    }
+
+    #[test]
+    fn test_ipps_uri_with_port() {
+        let uri = "ipps://host:8443".parse::<Uri>().unwrap();
+        let http_uri = ipp_uri_to_string(&uri);
+        assert_eq!(http_uri, "https://host:8443/");
+    }
+
+    #[test]
+    fn test_http_uri_no_change() {
+        let uri = "http://somehost".parse::<Uri>().unwrap();
+        let http_uri = ipp_uri_to_string(&uri);
+        assert_eq!(http_uri, uri.to_string());
+    }
+}
diff --git a/crates/ipp/src/error.rs b/crates/ipp/src/error.rs
new file mode 100644
index 0000000..071d624
--- /dev/null
+++ b/crates/ipp/src/error.rs
@@ -0,0 +1,68 @@
+//!
+//! IPP error
+//!
+use std::io;
+
+use http::uri::InvalidUri;
+
+use crate::{model::StatusCode, parser::IppParseError};
+
+/// IPP error
+#[allow(clippy::large_enum_variant)]
+#[derive(Debug, thiserror::Error)]
+pub enum IppError {
+    #[error(transparent)]
+    /// HTTP protocol error
+    HttpError(#[from] http::Error),
+
+    #[error(transparent)]
+    #[cfg(feature = "async-client")]
+    /// Client error
+    AsyncClientError(#[from] reqwest::Error),
+
+    #[error("HTTP request error: {0}")]
+    /// HTTP request error
+    RequestError(u16),
+
+    #[error(transparent)]
+    /// Network or file I/O error
+    IoError(#[from] io::Error),
+
+    #[error("IPP status error: {0}")]
+    /// IPP status error
+    StatusError(StatusCode),
+
+    #[error("Printer not ready")]
+    PrinterNotReady,
+
+    #[error(transparent)]
+    /// Parsing error
+    ParseError(#[from] IppParseError),
+
+    #[error("Missing attribute in response")]
+    /// Missing attribute in response
+    MissingAttribute,
+
+    #[error("Invalid attribute type")]
+    /// Invalid attribute type
+    InvalidAttributeType,
+
+    #[error(transparent)]
+    /// Invalid URI
+    InvalidUri(#[from] InvalidUri),
+
+    #[error(transparent)]
+    #[cfg(feature = "client")]
+    /// Client error
+    ClientError(#[from] ureq::Error),
+
+    #[error(transparent)]
+    #[cfg(any(feature = "async-client-tls", feature = "client-tls"))]
+    /// TLS error
+    TlsError(#[from] native_tls::Error),
+
+    #[error(transparent)]
+    #[cfg(feature = "client-rustls")]
+    /// TLS error
+    RustlsError(#[from] rustls::Error),
+}
diff --git a/crates/ipp/src/lib.rs b/crates/ipp/src/lib.rs
new file mode 100644
index 0000000..911cecf
--- /dev/null
+++ b/crates/ipp/src/lib.rs
@@ -0,0 +1,159 @@
+//!
+//! IPP print protocol implementation for Rust. This crate can be used in several ways:
+//! * using the low-level request/response API and building the requests manually.
+//! * using the higher-level operations API with builders. Currently only a subset of all IPP operations is supported.
+//! * using the built-in IPP client.
+//! * using any third-party HTTP client and send the serialized request manually.
+//!
+//! This crate supports both synchronous and asynchronous operations. The following feature flags are supported:
+//! * `async` - enables asynchronous APIs.
+//! * `async-client` - enables asynchronous IPP client based on `reqwest` crate, implies `async` feature.
+//! * `client` - enables blocking IPP client based on `ureq` crate.
+//! * `async-client-tls` - enables asynchronous IPP client with TLS, using native-tls backend. Implies `async-client` feature.
+//! * `client-tls` - enables blocking IPP client with TLS, using native-tls backend. Implies `client` feature.
+//! * `async-client-rustls` - enables asynchronous IPP client with TLS, using rustls backend. Implies `async-client` feature.
+//! * `client-rustls` - enables blocking IPP client with TLS, using rustls backend. Implies `client` feature.
+//!
+//! By default, the following feature is enabled: `async-client-tls`.
+//!
+//! Implementation notes:
+//! * all RFC IPP values are supported including arrays and collections, for both de- and serialization.
+//! * this crate is also suitable for building IPP servers, however the example is not provided yet.
+//!
+//! Usage examples:
+//!
+//!```rust,no_run
+//! // using low-level async API
+//! use ipp::prelude::*;
+//!
+//! #[tokio::main]
+//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
+//!     let uri: Uri = "http://localhost:631/printers/test-printer".parse()?;
+//!     let req = IppRequestResponse::new(
+//!         IppVersion::v1_1(),
+//!         Operation::GetPrinterAttributes,
+//!         Some(uri.clone())
+//!     );
+//!     let client = AsyncIppClient::new(uri);
+//!     let resp = client.send(req).await?;
+//!     if resp.header().status_code().is_success() {
+//!         println!("{:?}", resp.attributes());
+//!     }
+//!     Ok(())
+//! }
+//!```
+//!```rust,no_run
+//! // using blocking operations API
+//! use ipp::prelude::*;
+//!
+//! fn main() -> Result<(), Box<dyn std::error::Error>> {
+//!     let uri: Uri = "http://localhost:631/printers/test-printer".parse()?;
+//!     let operation = IppOperationBuilder::get_printer_attributes(uri.clone()).build();
+//!     let client = IppClient::new(uri);
+//!     let resp = client.send(operation)?;
+//!     if resp.header().status_code().is_success() {
+//!         println!("{:?}", resp.attributes());
+//!     }
+//!     Ok(())
+//! }
+//!```
+
+#![allow(clippy::result_large_err)]
+
+use bytes::{BufMut, Bytes, BytesMut};
+use num_traits::FromPrimitive;
+
+#[cfg(feature = "serde")]
+use serde::{Deserialize, Serialize};
+
+use crate::model::{IppVersion, StatusCode};
+
+pub mod attribute;
+#[cfg(any(feature = "client", feature = "async-client"))]
+pub mod client;
+pub mod error;
+pub mod model;
+pub mod operation;
+pub mod parser;
+pub mod payload;
+pub mod reader;
+pub mod request;
+pub mod util;
+pub mod value;
+
+pub mod prelude {
+    //!
+    //! Common imports
+    //!
+    pub use http::Uri;
+    pub use num_traits::FromPrimitive as _;
+
+    pub use crate::{
+        attribute::{IppAttribute, IppAttributeGroup, IppAttributes},
+        model::*,
+        operation::builder::IppOperationBuilder,
+        payload::IppPayload,
+        request::IppRequestResponse,
+        value::IppValue,
+    };
+
+    pub use super::error::IppError;
+
+    #[cfg(feature = "async-client")]
+    pub use super::client::non_blocking::AsyncIppClient;
+
+    #[cfg(feature = "client")]
+    pub use super::client::blocking::IppClient;
+
+    pub use super::IppHeader;
+}
+
+/// IPP request and response header
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
+#[derive(Clone, Debug)]
+pub struct IppHeader {
+    /// IPP protocol version
+    pub version: IppVersion,
+    /// Operation tag for requests, status for responses
+    pub operation_or_status: u16,
+    /// ID of the request
+    pub request_id: u32,
+}
+
+impl IppHeader {
+    /// Create IPP header
+    pub fn new(version: IppVersion, operation_or_status: u16, request_id: u32) -> IppHeader {
+        IppHeader {
+            version,
+            operation_or_status,
+            request_id,
+        }
+    }
+
+    /// Write header to a given writer
+    pub fn to_bytes(&self) -> Bytes {
+        let mut buffer = BytesMut::new();
+        buffer.put_u16(self.version.0);
+        buffer.put_u16(self.operation_or_status);
+        buffer.put_u32(self.request_id);
+
+        buffer.freeze()
+    }
+
+    /// Decode and get IPP status code from the header
+    pub fn status_code(&self) -> StatusCode {
+        StatusCode::from_u16(self.operation_or_status).unwrap_or(StatusCode::UnknownStatusCode)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_header_to_bytes() {
+        let header = IppHeader::new(IppVersion::v2_1(), 0x1234, 0xaa55_aa55);
+        let buf = header.to_bytes();
+        assert_eq!(buf, vec![0x02, 0x01, 0x12, 0x34, 0xaa, 0x55, 0xaa, 0x55]);
+    }
+}
diff --git a/crates/ipp/src/model.rs b/crates/ipp/src/model.rs
new file mode 100644
index 0000000..ef590fe
--- /dev/null
+++ b/crates/ipp/src/model.rs
@@ -0,0 +1,285 @@
+//!
+//! Base IPP definitions and tags
+//!
+use std::fmt;
+
+#[cfg(feature = "serde")]
+use serde::{Deserialize, Serialize};
+
+use enum_primitive_derive::Primitive;
+
+/// IPP protocol version
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
+#[derive(Debug, Copy, Clone, Eq, PartialEq)]
+pub struct IppVersion(pub u16);
+
+impl IppVersion {
+    pub fn v1_0() -> Self {
+        IppVersion(0x0100)
+    }
+    pub fn v1_1() -> Self {
+        IppVersion(0x0101)
+    }
+    pub fn v2_0() -> Self {
+        IppVersion(0x0200)
+    }
+    pub fn v2_1() -> Self {
+        IppVersion(0x0201)
+    }
+    pub fn v2_2() -> Self {
+        IppVersion(0x0202)
+    }
+}
+
+/// IPP operation constants
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
+#[derive(Primitive, Debug, Copy, Clone, Eq, PartialEq)]
+#[allow(clippy::upper_case_acronyms)]
+pub enum Operation {
+    PrintJob = 0x0002,
+    PrintUri = 0x0003,
+    ValidateJob = 0x0004,
+    CreateJob = 0x0005,
+    SendDocument = 0x0006,
+    SendUri = 0x0007,
+    CancelJob = 0x0008,
+    GetJobAttributes = 0x0009,
+    GetJobs = 0x000A,
+    GetPrinterAttributes = 0x000B,
+    HoldJob = 0x000C,
+    ReleaseJob = 0x000D,
+    RestartJob = 0x000E,
+    PausePrinter = 0x0010,
+    ResumePrinter = 0x0011,
+    PurgeJobs = 0x0012,
+
+    CupsGetDefault = 0x4001,
+    CupsGetPrinters = 0x4002,
+    CupsAddModifyPrinter = 0x4003,
+    CupsDeletePrinter = 0x4004,
+    CupsGetClasses = 0x4005,
+    CupsAddModifyClass = 0x4006,
+    CupsDeleteClass = 0x4007,
+    CupsAcceptJobs = 0x4008,
+    CupsRejectJobs = 0x4009,
+    CupsSetDefault = 0x400A,
+    CupsGetDevices = 0x400B,
+    CupsGetPPDs = 0x400C,
+    CupsMoveJob = 0x400D,
+    CupsAuthenticateJob = 0x400E,
+    CupsGetPPD = 0x400F,
+    CupsGetDocument = 0x4027,
+    CupsCreateLocalPrinter = 0x4028,
+}
+
+/// printer-state constants
+#[derive(Primitive, Debug, Copy, Clone, Eq, PartialEq)]
+pub enum PrinterState {
+    Idle = 3,
+    Processing = 4,
+    Stopped = 5,
+}
+
+/// paper orientation constants
+#[derive(Primitive, Debug, Copy, Clone, Eq, PartialEq)]
+pub enum Orientation {
+    Portrait = 3,
+    Landscape = 4,
+    ReverseLandscape = 5,
+    ReversePortrait = 6,
+}
+
+/// print-quality constants
+#[derive(Primitive, Debug, Copy, Clone, Eq, PartialEq)]
+pub enum PrintQuality {
+    Draft = 3,
+    Normal = 4,
+    High = 5,
+}
+
+/// finishings constants
+#[derive(Primitive, Debug, Copy, Clone, Eq, PartialEq)]
+pub enum Finishings {
+    None = 3,
+    Staple = 4,
+    Punch = 5,
+    Cover = 6,
+    Bind = 7,
+    SaddleStitch = 8,
+    EdgeStitch = 9,
+    StapleTopLeft = 20,
+    StapleBottomLeft = 21,
+    StapleTopRight = 22,
+    StapleBottomRight = 23,
+    EdgeStitchLeft = 24,
+    EdgeStitchTop = 25,
+    EdgeStitchRight = 26,
+    EdgeStitchBottom = 27,
+    StapleDualLeft = 28,
+    StapleDualTop = 29,
+    StapleDualRight = 30,
+    StapleDualBottom = 31,
+    StapleTripleLeft = 32,
+    StapleTripleTop = 33,
+    StapleTripleRight = 34,
+    StapleTripleBottom = 35,
+    PunchTopLeft = 70,
+    PunchBottomLeft = 71,
+    PunchTopRight = 72,
+    PunchBottomRight = 73,
+    PunchDualLeft = 74,
+    PunchDualTop = 75,
+    PunchDualRight = 76,
+    PunchDualBottom = 77,
+    PunchTripleLeft = 78,
+    PunchTripleTop = 79,
+    PunchTripleRight = 80,
+    PunchTripleBottom = 81,
+    PunchQuadLeft = 82,
+    PunchQuadTop = 83,
+    PunchQuadRight = 84,
+    PunchQuadBottom = 85,
+}
+
+/// job-state constants
+#[derive(Primitive, Debug, Copy, Clone, Eq, PartialEq)]
+pub enum JobState {
+    Pending = 3,
+    PendingHeld = 4,
+    Processing = 5,
+    ProcessingStopped = 6,
+    Canceled = 7,
+    Aborted = 8,
+    Completed = 9,
+}
+
+/// group delimiter tags
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
+#[derive(Primitive, Debug, Copy, Clone, PartialEq, Hash, Eq)]
+pub enum DelimiterTag {
+    OperationAttributes = 0x01,
+    JobAttributes = 0x02,
+    EndOfAttributes = 0x03,
+    PrinterAttributes = 0x04,
+    UnsupportedAttributes = 0x05,
+}
+
+/// IPP value tags
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
+#[derive(Primitive, Debug, Copy, Clone, Eq, PartialEq)]
+pub enum ValueTag {
+    Unsupported = 0x10,
+    Unknown = 0x12,
+    NoValue = 0x13,
+    Integer = 0x21,
+    Boolean = 0x22,
+    Enum = 0x23,
+    OctetStringUnspecified = 0x30,
+    DateTime = 0x31,
+    Resolution = 0x32,
+    RangeOfInteger = 0x33,
+    BegCollection = 0x34,
+    TextWithLanguage = 0x35,
+    NameWithLanguage = 0x36,
+    EndCollection = 0x37,
+    TextWithoutLanguage = 0x41,
+    NameWithoutLanguage = 0x42,
+    Keyword = 0x44,
+    Uri = 0x45,
+    UriScheme = 0x46,
+    Charset = 0x47,
+    NaturalLanguage = 0x48,
+    MimeMediaType = 0x49,
+    MemberAttrName = 0x4a,
+}
+
+/// IPP status codes
+#[derive(Primitive, Debug, Copy, Clone, Eq, PartialEq)]
+pub enum StatusCode {
+    SuccessfulOk = 0x0000,
+    SuccessfulOkIgnoredOrSubstitutedAttributes = 0x0001,
+    SuccessfulOkConflictingAttributes = 0x0002,
+    ClientErrorBadRequest = 0x0400,
+    ClientErrorForbidden = 0x0401,
+    ClientErrorNotAuthenticated = 0x0402,
+    ClientErrorNotAuthorized = 0x0403,
+    ClientErrorNotPossible = 0x0404,
+    ClientErrorTimeout = 0x0405,
+    ClientErrorNotFound = 0x0406,
+    ClientErrorGone = 0x0407,
+    ClientErrorRequestEntityTooLong = 0x0408,
+    ClientErrorRequestValueTooLong = 0x0409,
+    ClientErrorDocumentFormatNotSupported = 0x040A,
+    ClientErrorAttributesOrValuesNotSupported = 0x040B,
+    ClientErrorUriSchemeNotSupported = 0x040C,
+    ClientErrorCharsetNotSupported = 0x040D,
+    ClientErrorConflictingAttributes = 0x040E,
+    ClientErrorCompressionNotSupported = 0x040F,
+    ClientErrorCompressionError = 0x0410,
+    ClientErrorDocumentFormatError = 0x0411,
+    ClientErrorDocumentAccessError = 0x0412,
+    ServerErrorInternalError = 0x0500,
+    ServerErrorOperationNotSupported = 0x0501,
+    ServerErrorServiceUnavailable = 0x0502,
+    ServerErrorVersionNotSupported = 0x0503,
+    ServerErrorDeviceError = 0x0504,
+    ServerErrorTemporaryError = 0x0505,
+    ServerErrorNotAcceptingJobs = 0x0506,
+    ServerErrorBusy = 0x0507,
+    ServerErrorJobCanceled = 0x0508,
+    ServerErrorMultipleDocumentJobsNotSupported = 0x0509,
+    UnknownStatusCode = 0xffff,
+}
+
+impl StatusCode {
+    pub fn is_success(&self) -> bool {
+        matches!(
+            self,
+            StatusCode::SuccessfulOk
+                | StatusCode::SuccessfulOkIgnoredOrSubstitutedAttributes
+                | StatusCode::SuccessfulOkConflictingAttributes
+        )
+    }
+}
+
+impl fmt::Display for StatusCode {
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        match self {
+            StatusCode::SuccessfulOk => write!(f, "No error"),
+            StatusCode::SuccessfulOkIgnoredOrSubstitutedAttributes => write!(f, "Ignored or substituted attributes"),
+            StatusCode::SuccessfulOkConflictingAttributes => write!(f, "Conflicting attributes"),
+            StatusCode::ClientErrorBadRequest => write!(f, "Bad request"),
+            StatusCode::ClientErrorForbidden => write!(f, "Forbidden"),
+            StatusCode::ClientErrorNotAuthenticated => write!(f, "Not authenticated"),
+            StatusCode::ClientErrorNotAuthorized => write!(f, "Not authorized"),
+            StatusCode::ClientErrorNotPossible => write!(f, "Not possible"),
+            StatusCode::ClientErrorTimeout => write!(f, "Timeout"),
+            StatusCode::ClientErrorNotFound => write!(f, "Not found"),
+            StatusCode::ClientErrorGone => write!(f, "Gone"),
+            StatusCode::ClientErrorRequestEntityTooLong => write!(f, "Entity too long"),
+            StatusCode::ClientErrorRequestValueTooLong => write!(f, "Request value too long"),
+            StatusCode::ClientErrorDocumentFormatNotSupported => write!(f, "Document format not supported"),
+            StatusCode::ClientErrorAttributesOrValuesNotSupported => write!(f, "Attributes or values not supported"),
+            StatusCode::ClientErrorUriSchemeNotSupported => write!(f, "Uri scheme not supported"),
+            StatusCode::ClientErrorCharsetNotSupported => write!(f, "Charset not supported"),
+            StatusCode::ClientErrorConflictingAttributes => write!(f, "Conflicting attributes"),
+            StatusCode::ClientErrorCompressionNotSupported => write!(f, "Compression not supported"),
+            StatusCode::ClientErrorCompressionError => write!(f, "Compression error"),
+            StatusCode::ClientErrorDocumentFormatError => write!(f, "Document format error"),
+            StatusCode::ClientErrorDocumentAccessError => write!(f, "Document access error"),
+            StatusCode::ServerErrorInternalError => write!(f, "Internal error"),
+            StatusCode::ServerErrorOperationNotSupported => write!(f, "Operation not supported"),
+            StatusCode::ServerErrorServiceUnavailable => write!(f, "Service unavailable"),
+            StatusCode::ServerErrorVersionNotSupported => write!(f, "Version not supported"),
+            StatusCode::ServerErrorDeviceError => write!(f, "Device error"),
+            StatusCode::ServerErrorTemporaryError => write!(f, "Temporary error"),
+            StatusCode::ServerErrorNotAcceptingJobs => write!(f, "Not accepting jobs"),
+            StatusCode::ServerErrorBusy => write!(f, "Busy"),
+            StatusCode::ServerErrorJobCanceled => write!(f, "Job canceled"),
+            StatusCode::ServerErrorMultipleDocumentJobsNotSupported => {
+                write!(f, "Multiple document jobs not supported")
+            }
+            StatusCode::UnknownStatusCode => write!(f, "Unknown status code"),
+        }
+    }
+}
diff --git a/crates/ipp/src/operation.rs b/crates/ipp/src/operation.rs
new file mode 100644
index 0000000..44a2f7e
--- /dev/null
+++ b/crates/ipp/src/operation.rs
@@ -0,0 +1,392 @@
+//!
+//! High-level IPP operation abstractions
+//!
+use http::Uri;
+
+use crate::{
+    attribute::IppAttribute,
+    model::{DelimiterTag, IppVersion, Operation},
+    payload::IppPayload,
+    request::IppRequestResponse,
+    value::IppValue,
+};
+
+pub mod builder;
+pub mod cups;
+
+fn with_user_name(user_name: Option<String>, req: &mut IppRequestResponse) {
+    if let Some(user_name) = user_name {
+        req.attributes_mut().add(
+            DelimiterTag::OperationAttributes,
+            IppAttribute::new(
+                IppAttribute::REQUESTING_USER_NAME,
+                IppValue::NameWithoutLanguage(user_name),
+            ),
+        );
+    }
+}
+
+/// Trait which represents a single IPP operation
+pub trait IppOperation {
+    /// Convert this operation to IPP request which is ready for sending
+    fn into_ipp_request(self) -> IppRequestResponse;
+
+    /// Return IPP version for this operation. Default is 1.1
+    fn version(&self) -> IppVersion {
+        IppVersion::v1_1()
+    }
+}
+
+impl<T: IppOperation> From<T> for IppRequestResponse {
+    fn from(op: T) -> Self {
+        op.into_ipp_request()
+    }
+}
+
+/// IPP operation Print-Job
+pub struct PrintJob {
+    printer_uri: Uri,
+    payload: IppPayload,
+    user_name: Option<String>,
+    job_name: Option<String>,
+    attributes: Vec<IppAttribute>,
+}
+
+impl PrintJob {
+    /// Create Print-Job operation
+    ///
+    /// * `printer_uri` - printer URI<br/>
+    /// * `payload` - job payload<br/>
+    /// * `user_name` - name of the user (requesting-user-name)<br/>
+    /// * `job_name` - job name (job-name)<br/>
+    pub fn new<S, U, N>(printer_uri: Uri, payload: S, user_name: Option<U>, job_name: Option<N>) -> PrintJob
+    where
+        S: Into<IppPayload>,
+        U: AsRef<str>,
+        N: AsRef<str>,
+    {
+        PrintJob {
+            printer_uri,
+            payload: payload.into(),
+            user_name: user_name.map(|v| v.as_ref().to_string()),
+            job_name: job_name.map(|v| v.as_ref().to_string()),
+            attributes: Vec::new(),
+        }
+    }
+
+    /// Set extra job attribute for this operation, for example `colormodel=grayscale`
+    pub fn add_attribute(&mut self, attribute: IppAttribute) {
+        self.attributes.push(attribute);
+    }
+}
+
+impl IppOperation for PrintJob {
+    fn into_ipp_request(self) -> IppRequestResponse {
+        let mut retval = IppRequestResponse::new(self.version(), Operation::PrintJob, Some(self.printer_uri));
+
+        with_user_name(self.user_name, &mut retval);
+
+        if let Some(job_name) = self.job_name {
+            retval.attributes_mut().add(
+                DelimiterTag::OperationAttributes,
+                IppAttribute::new(IppAttribute::JOB_NAME, IppValue::NameWithoutLanguage(job_name)),
+            )
+        }
+
+        for attr in self.attributes {
+            retval.attributes_mut().add(DelimiterTag::JobAttributes, attr);
+        }
+        *retval.payload_mut() = self.payload;
+
+        retval
+    }
+}
+
+/// IPP operation Get-Printer-Attributes
+pub struct GetPrinterAttributes {
+    printer_uri: Uri,
+    attributes: Vec<String>,
+}
+
+impl GetPrinterAttributes {
+    /// Create Get-Printer-Attributes operation to return all attributes
+    ///
+    /// * `printer_uri` - printer URI
+    pub fn new(printer_uri: Uri) -> GetPrinterAttributes {
+        GetPrinterAttributes {
+            printer_uri,
+            attributes: Vec::new(),
+        }
+    }
+
+    /// Create Get-Printer-Attributes operation to get a given list of attributes
+    ///
+    /// * `printer_uri` - printer URI
+    /// * `attributes` - list of attribute names to request from the printer
+    pub fn with_attributes<I, T>(printer_uri: Uri, attributes: I) -> GetPrinterAttributes
+    where
+        I: IntoIterator<Item = T>,
+        T: AsRef<str>,
+    {
+        GetPrinterAttributes {
+            printer_uri,
+            attributes: attributes.into_iter().map(|a| a.as_ref().to_string()).collect(),
+        }
+    }
+}
+
+impl IppOperation for GetPrinterAttributes {
+    fn into_ipp_request(self) -> IppRequestResponse {
+        let mut retval =
+            IppRequestResponse::new(self.version(), Operation::GetPrinterAttributes, Some(self.printer_uri));
+
+        if !self.attributes.is_empty() {
+            let vals: Vec<IppValue> = self.attributes.into_iter().map(IppValue::Keyword).collect();
+            retval.attributes_mut().add(
+                DelimiterTag::OperationAttributes,
+                IppAttribute::new(IppAttribute::REQUESTED_ATTRIBUTES, IppValue::Array(vals)),
+            );
+        }
+
+        retval
+    }
+}
+
+/// IPP operation Create-Job
+pub struct CreateJob {
+    printer_uri: Uri,
+    job_name: Option<String>,
+    attributes: Vec<IppAttribute>,
+}
+
+impl CreateJob {
+    /// Create Create-Job operation
+    ///
+    /// * `printer_uri` - printer URI
+    /// * `job_name` - optional job name (job-name)<br/>
+    pub fn new<T>(printer_uri: Uri, job_name: Option<T>) -> CreateJob
+    where
+        T: AsRef<str>,
+    {
+        CreateJob {
+            printer_uri,
+            job_name: job_name.map(|v| v.as_ref().to_string()),
+            attributes: Vec::new(),
+        }
+    }
+
+    /// Set extra job attribute for this operation, for example `colormodel=grayscale`
+    pub fn add_attribute(&mut self, attribute: IppAttribute) {
+        self.attributes.push(attribute);
+    }
+}
+
+impl IppOperation for CreateJob {
+    fn into_ipp_request(self) -> IppRequestResponse {
+        let mut retval = IppRequestResponse::new(self.version(), Operation::CreateJob, Some(self.printer_uri));
+
+        if let Some(job_name) = self.job_name {
+            retval.attributes_mut().add(
+                DelimiterTag::OperationAttributes,
+                IppAttribute::new(IppAttribute::JOB_NAME, IppValue::NameWithoutLanguage(job_name)),
+            )
+        }
+
+        for attr in self.attributes {
+            retval.attributes_mut().add(DelimiterTag::JobAttributes, attr);
+        }
+        retval
+    }
+}
+
+/// IPP operation Send-Document
+pub struct SendDocument {
+    printer_uri: Uri,
+    job_id: i32,
+    payload: IppPayload,
+    user_name: Option<String>,
+    last: bool,
+}
+
+impl SendDocument {
+    /// Create Send-Document operation
+    ///
+    /// * `printer_uri` - printer URI<br/>
+    /// * `job_id` - job ID returned by Create-Job operation<br/>
+    /// * `payload` - `IppPayload`<br/>
+    /// * `user_name` - name of the user (requesting-user-name)<br/>
+    /// * `last` - whether this document is a last one<br/>
+    pub fn new<S, U>(printer_uri: Uri, job_id: i32, payload: S, user_name: Option<U>, last: bool) -> SendDocument
+    where
+        S: Into<IppPayload>,
+        U: AsRef<str>,
+    {
+        SendDocument {
+            printer_uri,
+            job_id,
+            payload: payload.into(),
+            user_name: user_name.map(|v| v.as_ref().to_string()),
+            last,
+        }
+    }
+}
+
+impl IppOperation for SendDocument {
+    fn into_ipp_request(self) -> IppRequestResponse {
+        let mut retval = IppRequestResponse::new(self.version(), Operation::SendDocument, Some(self.printer_uri));
+
+        retval.attributes_mut().add(
+            DelimiterTag::OperationAttributes,
+            IppAttribute::new(IppAttribute::JOB_ID, IppValue::Integer(self.job_id)),
+        );
+
+        retval.attributes_mut().add(
+            DelimiterTag::OperationAttributes,
+            IppAttribute::new(IppAttribute::LAST_DOCUMENT, IppValue::Boolean(self.last)),
+        );
+
+        with_user_name(self.user_name, &mut retval);
+
+        *retval.payload_mut() = self.payload;
+
+        retval
+    }
+}
+
+/// IPP operation Purge-Jobs
+pub struct PurgeJobs {
+    printer_uri: Uri,
+    user_name: Option<String>,
+}
+
+impl PurgeJobs {
+    /// Create Purge-Jobs operation
+    ///
+    /// * `printer_uri` - printer URI<br/>
+    /// * `user_name` - name of the user (requesting-user-name)<br/>
+    pub fn new<U>(printer_uri: Uri, user_name: Option<U>) -> Self
+    where
+        U: AsRef<str>,
+    {
+        Self {
+            printer_uri,
+            user_name: user_name.map(|u| u.as_ref().to_owned()),
+        }
+    }
+}
+
+impl IppOperation for PurgeJobs {
+    fn into_ipp_request(self) -> IppRequestResponse {
+        let mut retval = IppRequestResponse::new(self.version(), Operation::PurgeJobs, Some(self.printer_uri));
+
+        with_user_name(self.user_name, &mut retval);
+
+        retval
+    }
+}
+
+/// IPP operation Cancel-Job
+pub struct CancelJob {
+    printer_uri: Uri,
+    job_id: i32,
+    user_name: Option<String>,
+}
+
+impl CancelJob {
+    /// Create Cancel-Job operation
+    ///
+    /// * `printer_uri` - printer URI<br/>
+    /// * `job_id` - job ID<br/>
+    /// * `user_name` - name of the user (requesting-user-name)<br/>
+    pub fn new<U>(printer_uri: Uri, job_id: i32, user_name: Option<U>) -> Self
+    where
+        U: AsRef<str>,
+    {
+        Self {
+            printer_uri,
+            job_id,
+            user_name: user_name.map(|u| u.as_ref().to_owned()),
+        }
+    }
+}
+
+impl IppOperation for CancelJob {
+    fn into_ipp_request(self) -> IppRequestResponse {
+        let mut retval = IppRequestResponse::new(self.version(), Operation::CancelJob, Some(self.printer_uri));
+        retval.attributes_mut().add(
+            DelimiterTag::OperationAttributes,
+            IppAttribute::new(IppAttribute::JOB_ID, IppValue::Integer(self.job_id)),
+        );
+        with_user_name(self.user_name, &mut retval);
+        retval
+    }
+}
+
+/// IPP operation Cancel-Job
+pub struct GetJobAttributes {
+    printer_uri: Uri,
+    job_id: i32,
+    user_name: Option<String>,
+}
+
+impl GetJobAttributes {
+    /// Create Get-Job-Attributes operation
+    ///
+    /// * `printer_uri` - printer URI<br/>
+    /// * `job_id` - job ID<br/>
+    /// * `user_name` - name of the user (requesting-user-name)<br/>
+    pub fn new<U>(printer_uri: Uri, job_id: i32, user_name: Option<U>) -> Self
+    where
+        U: AsRef<str>,
+    {
+        Self {
+            printer_uri,
+            job_id,
+            user_name: user_name.map(|u| u.as_ref().to_owned()),
+        }
+    }
+}
+
+impl IppOperation for GetJobAttributes {
+    fn into_ipp_request(self) -> IppRequestResponse {
+        let mut retval = IppRequestResponse::new(self.version(), Operation::GetJobAttributes, Some(self.printer_uri));
+        retval.attributes_mut().add(
+            DelimiterTag::OperationAttributes,
+            IppAttribute::new(IppAttribute::JOB_ID, IppValue::Integer(self.job_id)),
+        );
+        with_user_name(self.user_name, &mut retval);
+        retval
+    }
+}
+
+/// IPP operation Get-Jobs
+pub struct GetJobs {
+    printer_uri: Uri,
+    user_name: Option<String>,
+}
+
+impl GetJobs {
+    /// Create Get-Jobs operation
+    ///
+    /// * `printer_uri` - printer URI<br/>
+    /// * `user_name` - name of the user (requesting-user-name)<br/>
+    pub fn new<U>(printer_uri: Uri, user_name: Option<U>) -> Self
+    where
+        U: AsRef<str>,
+    {
+        Self {
+            printer_uri,
+            user_name: user_name.map(|u| u.as_ref().to_owned()),
+        }
+    }
+}
+
+impl IppOperation for GetJobs {
+    fn into_ipp_request(self) -> IppRequestResponse {
+        let mut retval = IppRequestResponse::new(self.version(), Operation::GetJobs, Some(self.printer_uri));
+
+        with_user_name(self.user_name, &mut retval);
+
+        retval
+    }
+}
diff --git a/crates/ipp/src/operation/builder.rs b/crates/ipp/src/operation/builder.rs
new file mode 100644
index 0000000..2bc2209
--- /dev/null
+++ b/crates/ipp/src/operation/builder.rs
@@ -0,0 +1,424 @@
+//!
+//! IPP operation builders
+//!
+use http::Uri;
+
+use crate::{
+    attribute::IppAttribute,
+    operation::{cups::*, *},
+    payload::IppPayload,
+};
+
+/// Builder to create IPP operations
+pub struct IppOperationBuilder;
+
+impl IppOperationBuilder {
+    /// Create Print-Job operation
+    ///
+    /// * `printer_uri` - printer URI<br/>
+    /// * `payload` - `IppPayload`
+    pub fn print_job(printer_uri: Uri, payload: IppPayload) -> PrintJobBuilder {
+        PrintJobBuilder::new(printer_uri, payload)
+    }
+
+    /// Create Get-Printer-Attributes operation builder
+    ///
+    /// * `printer_uri` - printer URI
+    pub fn get_printer_attributes(printer_uri: Uri) -> GetPrinterAttributesBuilder {
+        GetPrinterAttributesBuilder::new(printer_uri)
+    }
+
+    /// Create Create-Job operation builder
+    ///
+    /// * `printer_uri` - printer URI
+    pub fn create_job(printer_uri: Uri) -> CreateJobBuilder {
+        CreateJobBuilder::new(printer_uri)
+    }
+
+    /// Create CUPS-specific operations
+    pub fn cups() -> CupsBuilder {
+        CupsBuilder::new()
+    }
+
+    /// Create Send-Document operation builder
+    ///
+    /// * `printer_uri` - printer URI<br/>
+    /// * `job_id` - job id returned by Create-Job operation <br/>
+    /// * `payload` - `IppPayload`
+    pub fn send_document(printer_uri: Uri, job_id: i32, payload: IppPayload) -> SendDocumentBuilder {
+        SendDocumentBuilder::new(printer_uri, job_id, payload)
+    }
+
+    /// Create Purge-Jobs operation builder
+    ///
+    /// * `printer_uri` - printer URI
+    pub fn purge_jobs(printer_uri: Uri) -> PurgeJobsBuilder {
+        PurgeJobsBuilder::new(printer_uri)
+    }
+
+    /// Create Cancel-Job operation builder
+    ///
+    /// * `printer_uri` - printer URI
+    /// * `job_id` - job id to cancel <br/>
+    pub fn cancel_job(printer_uri: Uri, job_id: i32) -> CancelJobBuilder {
+        CancelJobBuilder::new(printer_uri, job_id)
+    }
+
+    /// Create Get-Job-Attributes operation builder
+    ///
+    /// * `printer_uri` - printer URI
+    /// * `job_id` - job id to cancel <br/>
+    pub fn get_job_attributes(printer_uri: Uri, job_id: i32) -> GetJobAttributesBuilder {
+        GetJobAttributesBuilder::new(printer_uri, job_id)
+    }
+
+    /// Create Get-Jobs operation builder
+    ///
+    /// * `printer_uri` - printer URI
+    pub fn get_jobs(printer_uri: Uri) -> GetJobsBuilder {
+        GetJobsBuilder::new(printer_uri)
+    }
+}
+
+/// Builder to create Print-Job operation
+pub struct PrintJobBuilder {
+    printer_uri: Uri,
+    payload: IppPayload,
+    user_name: Option<String>,
+    job_title: Option<String>,
+    attributes: Vec<IppAttribute>,
+}
+
+impl PrintJobBuilder {
+    fn new(printer_uri: Uri, payload: IppPayload) -> PrintJobBuilder {
+        PrintJobBuilder {
+            printer_uri,
+            payload,
+            user_name: None,
+            job_title: None,
+            attributes: Vec::new(),
+        }
+    }
+    /// Specify requesting-user-name attribute
+    pub fn user_name<S>(mut self, user_name: S) -> Self
+    where
+        S: AsRef<str>,
+    {
+        self.user_name = Some(user_name.as_ref().to_owned());
+        self
+    }
+
+    /// Specify job-name attribute
+    pub fn job_title<S>(mut self, job_title: S) -> Self
+    where
+        S: AsRef<str>,
+    {
+        self.job_title = Some(job_title.as_ref().to_owned());
+        self
+    }
+
+    /// Specify custom job attribute
+    pub fn attribute(mut self, attribute: IppAttribute) -> Self {
+        self.attributes.push(attribute);
+        self
+    }
+
+    /// Specify custom job attributes
+    pub fn attributes<I>(mut self, attributes: I) -> Self
+    where
+        I: IntoIterator<Item = IppAttribute>,
+    {
+        self.attributes.extend(attributes);
+        self
+    }
+
+    /// Build operation
+    pub fn build(self) -> impl IppOperation {
+        let op = PrintJob::new(
+            self.printer_uri,
+            self.payload,
+            self.user_name.as_ref(),
+            self.job_title.as_ref(),
+        );
+        self.attributes.into_iter().fold(op, |mut op, attr| {
+            op.add_attribute(attr);
+            op
+        })
+    }
+}
+
+/// Builder to create Get-Printer-Attributes operation
+pub struct GetPrinterAttributesBuilder {
+    printer_uri: Uri,
+    attributes: Vec<String>,
+}
+
+impl GetPrinterAttributesBuilder {
+    fn new(printer_uri: Uri) -> GetPrinterAttributesBuilder {
+        GetPrinterAttributesBuilder {
+            printer_uri,
+            attributes: Vec::new(),
+        }
+    }
+
+    /// Specify which attribute to retrieve from the printer. Can be repeated.
+    pub fn attribute<S>(mut self, attribute: S) -> Self
+    where
+        S: AsRef<str>,
+    {
+        self.attributes.push(attribute.as_ref().to_owned());
+        self
+    }
+
+    /// Specify which attributes to retrieve from the printer
+    pub fn attributes<S, I>(mut self, attributes: I) -> Self
+    where
+        S: AsRef<str>,
+        I: IntoIterator<Item = S>,
+    {
+        self.attributes
+            .extend(attributes.into_iter().map(|s| s.as_ref().to_string()));
+        self
+    }
+
+    /// Build operation
+    pub fn build(self) -> impl IppOperation {
+        GetPrinterAttributes::with_attributes(self.printer_uri, &self.attributes)
+    }
+}
+
+/// Builder to create Create-Job operation
+pub struct CreateJobBuilder {
+    printer_uri: Uri,
+    job_name: Option<String>,
+    attributes: Vec<IppAttribute>,
+}
+
+impl CreateJobBuilder {
+    fn new(printer_uri: Uri) -> CreateJobBuilder {
+        CreateJobBuilder {
+            printer_uri,
+            job_name: None,
+            attributes: Vec::new(),
+        }
+    }
+
+    /// Specify job-name attribute
+    pub fn job_name<S>(mut self, job_name: S) -> Self
+    where
+        S: AsRef<str>,
+    {
+        self.job_name = Some(job_name.as_ref().to_owned());
+        self
+    }
+
+    /// Specify custom job attribute
+    pub fn attribute(mut self, attribute: IppAttribute) -> Self {
+        self.attributes.push(attribute);
+        self
+    }
+
+    /// Specify custom job attributes
+    pub fn attributes<I>(mut self, attributes: I) -> Self
+    where
+        I: IntoIterator<Item = IppAttribute>,
+    {
+        self.attributes.extend(attributes);
+        self
+    }
+
+    /// Build operation
+    pub fn build(self) -> impl IppOperation {
+        let op = CreateJob::new(self.printer_uri, self.job_name.as_ref());
+        self.attributes.into_iter().fold(op, |mut op, attr| {
+            op.add_attribute(attr);
+            op
+        })
+    }
+}
+
+/// Builder to create Send-Document operation
+pub struct SendDocumentBuilder {
+    printer_uri: Uri,
+    job_id: i32,
+    payload: IppPayload,
+    user_name: Option<String>,
+    is_last: bool,
+}
+
+impl SendDocumentBuilder {
+    fn new(printer_uri: Uri, job_id: i32, payload: IppPayload) -> SendDocumentBuilder {
+        SendDocumentBuilder {
+            printer_uri,
+            job_id,
+            payload,
+            user_name: None,
+            is_last: true,
+        }
+    }
+
+    /// Specify originating-user-name attribute
+    pub fn user_name<S>(mut self, user_name: S) -> Self
+    where
+        S: AsRef<str>,
+    {
+        self.user_name = Some(user_name.as_ref().to_owned());
+        self
+    }
+
+    /// Parameter which indicates whether this document is a last one
+    pub fn last(mut self, last: bool) -> Self {
+        self.is_last = last;
+        self
+    }
+
+    /// Build operation
+    pub fn build(self) -> impl IppOperation {
+        SendDocument::new(
+            self.printer_uri,
+            self.job_id,
+            self.payload,
+            self.user_name.as_ref(),
+            self.is_last,
+        )
+    }
+}
+
+/// Builder to create Purge-Jobs operation
+pub struct PurgeJobsBuilder {
+    printer_uri: Uri,
+    user_name: Option<String>,
+}
+
+impl PurgeJobsBuilder {
+    fn new(printer_uri: Uri) -> PurgeJobsBuilder {
+        PurgeJobsBuilder {
+            printer_uri,
+            user_name: None,
+        }
+    }
+
+    /// Specify originating-user-name attribute
+    pub fn user_name<S>(mut self, user_name: S) -> Self
+    where
+        S: AsRef<str>,
+    {
+        self.user_name = Some(user_name.as_ref().to_owned());
+        self
+    }
+
+    /// Build operation
+    pub fn build(self) -> impl IppOperation {
+        PurgeJobs::new(self.printer_uri, self.user_name)
+    }
+}
+
+/// Builder to create Cancel-Job operation
+pub struct CancelJobBuilder {
+    printer_uri: Uri,
+    job_id: i32,
+    user_name: Option<String>,
+}
+
+impl CancelJobBuilder {
+    fn new(printer_uri: Uri, job_id: i32) -> CancelJobBuilder {
+        CancelJobBuilder {
+            printer_uri,
+            job_id,
+            user_name: None,
+        }
+    }
+
+    /// Specify originating-user-name attribute
+    pub fn user_name<S>(mut self, user_name: S) -> Self
+    where
+        S: AsRef<str>,
+    {
+        self.user_name = Some(user_name.as_ref().to_owned());
+        self
+    }
+
+    /// Build operation
+    pub fn build(self) -> impl IppOperation {
+        CancelJob::new(self.printer_uri, self.job_id, self.user_name)
+    }
+}
+
+/// Builder to create Get-Job-Attributes operation
+pub struct GetJobAttributesBuilder {
+    printer_uri: Uri,
+    job_id: i32,
+    user_name: Option<String>,
+}
+
+impl GetJobAttributesBuilder {
+    fn new(printer_uri: Uri, job_id: i32) -> GetJobAttributesBuilder {
+        GetJobAttributesBuilder {
+            printer_uri,
+            job_id,
+            user_name: None,
+        }
+    }
+
+    /// Specify originating-user-name attribute
+    pub fn user_name<S>(mut self, user_name: S) -> Self
+    where
+        S: AsRef<str>,
+    {
+        self.user_name = Some(user_name.as_ref().to_owned());
+        self
+    }
+
+    /// Build operation
+    pub fn build(self) -> impl IppOperation {
+        GetJobAttributes::new(self.printer_uri, self.job_id, self.user_name)
+    }
+}
+
+/// Builder to create Get-Jobs operation
+pub struct GetJobsBuilder {
+    printer_uri: Uri,
+    user_name: Option<String>,
+}
+
+impl GetJobsBuilder {
+    fn new(printer_uri: Uri) -> GetJobsBuilder {
+        GetJobsBuilder {
+            printer_uri,
+            user_name: None,
+        }
+    }
+
+    /// Specify originating-user-name attribute
+    pub fn user_name<S>(mut self, user_name: S) -> Self
+    where
+        S: AsRef<str>,
+    {
+        self.user_name = Some(user_name.as_ref().to_owned());
+        self
+    }
+
+    /// Build operation
+    pub fn build(self) -> impl IppOperation {
+        GetJobs::new(self.printer_uri, self.user_name)
+    }
+}
+
+/// CUPS operations builder
+pub struct CupsBuilder;
+
+impl CupsBuilder {
+    fn new() -> CupsBuilder {
+        CupsBuilder
+    }
+
+    /// CUPS-Get-Printers operation
+    pub fn get_printers(&self) -> impl IppOperation {
+        CupsGetPrinters::new()
+    }
+
+    /// CUPS-Delete-Printer operation
+    pub fn delete_printer(&self, printer_uri: Uri) -> impl IppOperation {
+        CupsDeletePrinter::new(printer_uri)
+    }
+}
diff --git a/crates/ipp/src/operation/cups.rs b/crates/ipp/src/operation/cups.rs
new file mode 100644
index 0000000..c1b06d7
--- /dev/null
+++ b/crates/ipp/src/operation/cups.rs
@@ -0,0 +1,40 @@
+//!
+//! CUPS-specific IPP operations. For operations which require user authentication the URI may include authority part.
+//!
+
+use http::Uri;
+
+use crate::{model::Operation, operation::IppOperation, request::IppRequestResponse};
+
+/// IPP operation CUPS-Get-Printers
+#[derive(Default)]
+pub struct CupsGetPrinters;
+
+impl CupsGetPrinters {
+    /// Create CUPS-Get-Printers operation
+    pub fn new() -> CupsGetPrinters {
+        CupsGetPrinters
+    }
+}
+
+impl IppOperation for CupsGetPrinters {
+    fn into_ipp_request(self) -> IppRequestResponse {
+        IppRequestResponse::new(self.version(), Operation::CupsGetPrinters, None)
+    }
+}
+
+/// IPP operation CUPS-Delete-Printer
+pub struct CupsDeletePrinter(Uri);
+
+impl CupsDeletePrinter {
+    /// Create CUPS-Get-Printers operation
+    pub fn new(printer_uri: Uri) -> CupsDeletePrinter {
+        CupsDeletePrinter(printer_uri)
+    }
+}
+
+impl IppOperation for CupsDeletePrinter {
+    fn into_ipp_request(self) -> IppRequestResponse {
+        IppRequestResponse::new(self.version(), Operation::CupsDeletePrinter, Some(self.0))
+    }
+}
diff --git a/crates/ipp/src/parser.rs b/crates/ipp/src/parser.rs
new file mode 100644
index 0000000..efdbb5a
--- /dev/null
+++ b/crates/ipp/src/parser.rs
@@ -0,0 +1,583 @@
+//!
+//! IPP stream parser
+//!
+use std::{
+    collections::BTreeMap,
+    io::{self, Read},
+};
+
+use bytes::Bytes;
+use log::{error, trace};
+
+#[cfg(feature = "async")]
+use {crate::reader::AsyncIppReader, futures_util::io::AsyncRead};
+
+use crate::{
+    attribute::{IppAttribute, IppAttributeGroup, IppAttributes},
+    model::{DelimiterTag, ValueTag},
+    reader::IppReader,
+    request::IppRequestResponse,
+    value::IppValue,
+    FromPrimitive as _, IppHeader,
+};
+
+/// Parse error enum
+#[derive(Debug, thiserror::Error)]
+pub enum IppParseError {
+    #[error("Invalid tag: {0}")]
+    InvalidTag(u8),
+
+    #[error("Invalid IPP collection")]
+    InvalidCollection,
+
+    #[error(transparent)]
+    IoError(#[from] io::Error),
+}
+
+// create a single value from one-element list, list otherwise
+fn list_or_value(mut list: Vec<IppValue>) -> IppValue {
+    if list.len() == 1 {
+        list.remove(0)
+    } else {
+        IppValue::Array(list)
+    }
+}
+
+struct ParserState {
+    current_group: Option<IppAttributeGroup>,
+    last_name: Option<String>,
+    context: Vec<Vec<IppValue>>,
+    attributes: IppAttributes,
+}
+
+impl ParserState {
+    fn new() -> Self {
+        ParserState {
+            current_group: None,
+            last_name: None,
+            context: vec![vec![]],
+            attributes: IppAttributes::new(),
+        }
+    }
+
+    fn add_last_attribute(&mut self) {
+        if let Some(last_name) = self.last_name.take() {
+            if let Some(val_list) = self.context.pop() {
+                if let Some(ref mut group) = self.current_group {
+                    let attr = IppAttribute::new(&last_name, list_or_value(val_list));
+                    group.attributes_mut().insert(last_name, attr);
+                }
+            }
+            self.context.push(vec![]);
+        }
+    }
+
+    fn parse_delimiter(&mut self, tag: u8) -> Result<DelimiterTag, IppParseError> {
+        trace!("Delimiter tag: {:0x}", tag);
+
+        let tag = DelimiterTag::from_u8(tag).ok_or(IppParseError::InvalidTag(tag))?;
+
+        self.add_last_attribute();
+
+        if let Some(group) = self.current_group.take() {
+            self.attributes.groups_mut().push(group);
+        }
+
+        self.current_group = Some(IppAttributeGroup::new(tag));
+
+        Ok(tag)
+    }
+
+    fn parse_value(&mut self, tag: u8, name: String, value: Bytes) -> Result<(), IppParseError> {
+        let ipp_value = IppValue::parse(tag, value)?;
+
+        trace!("Value tag: {:0x}: {}: {}", tag, name, ipp_value);
+
+        if !name.is_empty() {
+            // single attribute or begin of array
+            self.add_last_attribute();
+            // store it as a previous attribute
+            self.last_name = Some(name);
+        }
+        if tag == ValueTag::BegCollection as u8 {
+            // start new collection in the stack
+            trace!("Begin collection");
+            match ipp_value {
+                IppValue::Other { ref data, .. } if data.is_empty() => {}
+                _ => {
+                    error!("Invalid begin collection attribute");
+                    return Err(IppParseError::InvalidCollection);
+                }
+            }
+            self.context.push(vec![]);
+        } else if tag == ValueTag::EndCollection as u8 {
+            // get collection from the stack and add it to the previous element
+            trace!("End collection");
+            match ipp_value {
+                IppValue::Other { ref data, .. } if data.is_empty() => {}
+                _ => {
+                    error!("Invalid end collection attribute");
+                    return Err(IppParseError::InvalidCollection);
+                }
+            }
+            if let Some(arr) = self.context.pop() {
+                if let Some(val_list) = self.context.last_mut() {
+                    let mut map: BTreeMap<String, IppValue> = BTreeMap::new();
+                    for idx in (0..arr.len()).step_by(2) {
+                        if let (Some(IppValue::MemberAttrName(k)), Some(v)) = (arr.get(idx), arr.get(idx + 1)) {
+                            map.insert(k.to_string(), v.clone());
+                        }
+                    }
+                    val_list.push(IppValue::Collection(map));
+                }
+            }
+        } else if let Some(val_list) = self.context.last_mut() {
+            // add attribute to the current collection
+            val_list.push(ipp_value);
+        }
+        Ok(())
+    }
+}
+
+#[cfg(feature = "async")]
+/// Asynchronous IPP parser
+pub struct AsyncIppParser<R> {
+    reader: AsyncIppReader<R>,
+    state: ParserState,
+}
+
+#[cfg(feature = "async")]
+impl<R> AsyncIppParser<R>
+where
+    R: AsyncRead + Send + Sync + Unpin,
+{
+    /// Create IPP parser from AsyncIppReader
+    pub fn new<T>(reader: T) -> AsyncIppParser<R>
+    where
+        T: Into<AsyncIppReader<R>>,
+    {
+        AsyncIppParser {
+            reader: reader.into(),
+            state: ParserState::new(),
+        }
+    }
+
+    async fn parse_value(&mut self, tag: u8) -> Result<(), IppParseError> {
+        // value tag
+        let name = self.reader.read_name().await?;
+        let value = self.reader.read_value().await?;
+
+        self.state.parse_value(tag, name, value)
+    }
+
+    async fn parse_header_attributes(&mut self) -> Result<IppHeader, IppParseError> {
+        let header = self.reader.read_header().await?;
+        trace!("IPP header: {:?}", header);
+
+        loop {
+            match self.reader.read_tag().await? {
+                tag @ 0x01..=0x05 => {
+                    if self.state.parse_delimiter(tag)? == DelimiterTag::EndOfAttributes {
+                        break;
+                    }
+                }
+                tag @ 0x10..=0x4a => self.parse_value(tag).await?,
+                tag => {
+                    return Err(IppParseError::InvalidTag(tag));
+                }
+            }
+        }
+
+        Ok(header)
+    }
+
+    /// Parse IPP stream without reading beyond the end of the attributes. The payload stays untouched.
+    pub async fn parse_parts(mut self) -> Result<(IppHeader, IppAttributes, AsyncIppReader<R>), IppParseError> {
+        let header = self.parse_header_attributes().await?;
+        Ok((header, self.state.attributes, self.reader))
+    }
+
+    /// Parse IPP stream
+    pub async fn parse(mut self) -> Result<IppRequestResponse, IppParseError>
+    where
+        R: 'static,
+    {
+        let header = self.parse_header_attributes().await?;
+
+        Ok(IppRequestResponse {
+            header,
+            attributes: self.state.attributes,
+            payload: self.reader.into_payload(),
+        })
+    }
+}
+
+/// Synchronous IPP parser
+pub struct IppParser<R> {
+    reader: IppReader<R>,
+    state: ParserState,
+}
+
+impl<R> IppParser<R>
+where
+    R: 'static + Read + Send + Sync,
+{
+    /// Create IPP parser from IppReader
+    pub fn new<T>(reader: T) -> IppParser<R>
+    where
+        T: Into<IppReader<R>>,
+    {
+        IppParser {
+            reader: reader.into(),
+            state: ParserState::new(),
+        }
+    }
+
+    fn parse_value(&mut self, tag: u8) -> Result<(), IppParseError> {
+        // value tag
+        let name = self.reader.read_name()?;
+        let value = self.reader.read_value()?;
+
+        self.state.parse_value(tag, name, value)
+    }
+
+    fn parse_header_attributes(&mut self) -> Result<IppHeader, IppParseError> {
+        let header = self.reader.read_header()?;
+        trace!("IPP header: {:?}", header);
+
+        loop {
+            match self.reader.read_tag()? {
+                tag @ 0x01..=0x05 => {
+                    if self.state.parse_delimiter(tag)? == DelimiterTag::EndOfAttributes {
+                        break;
+                    }
+                }
+                tag @ 0x10..=0x4a => self.parse_value(tag)?,
+                tag => {
+                    return Err(IppParseError::InvalidTag(tag));
+                }
+            }
+        }
+
+        Ok(header)
+    }
+
+    /// Parse IPP stream without reading beyond the end of the attributes. The payload stays untouched.
+    pub fn parse_parts(mut self) -> Result<(IppHeader, IppAttributes, IppReader<R>), IppParseError> {
+        let header = self.parse_header_attributes()?;
+        Ok((header, self.state.attributes, self.reader))
+    }
+
+    /// Parse IPP stream
+    pub fn parse(mut self) -> Result<IppRequestResponse, IppParseError>
+    where
+        R: 'static,
+    {
+        let header = self.parse_header_attributes()?;
+
+        Ok(IppRequestResponse {
+            header,
+            attributes: self.state.attributes,
+            payload: self.reader.into_payload(),
+        })
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use crate::prelude::IppVersion;
+
+    use super::*;
+
+    #[cfg(feature = "async")]
+    #[tokio::test]
+    async fn test_async_parse_no_attributes() {
+        let data = &[1, 1, 0, 0, 0, 0, 0, 0, 3];
+        let result = AsyncIppParser::new(AsyncIppReader::new(futures_util::io::Cursor::new(data)))
+            .parse()
+            .await;
+        assert!(result.is_ok());
+
+        let res = result.ok().unwrap();
+        assert!(res.attributes.groups().is_empty());
+    }
+
+    #[cfg(feature = "async")]
+    #[tokio::test]
+    async fn test_async_parse_single_value() {
+        let data = &[
+            1, 1, 0, 0, 0, 0, 0, 0, 4, 0x21, 0x00, 0x04, b't', b'e', b's', b't', 0x00, 0x04, 0x12, 0x34, 0x56, 0x78, 3,
+        ];
+        let result = AsyncIppParser::new(AsyncIppReader::new(futures_util::io::Cursor::new(data)))
+            .parse()
+            .await;
+        assert!(result.is_ok());
+
+        let res = result.ok().unwrap();
+        let attrs = res
+            .attributes
+            .groups_of(DelimiterTag::PrinterAttributes)
+            .next()
+            .unwrap()
+            .attributes();
+        let attr = attrs.get("test").unwrap();
+        assert_eq!(attr.value().as_integer(), Some(&0x1234_5678));
+    }
+
+    #[cfg(feature = "async")]
+    #[tokio::test]
+    async fn test_async_parse_array() {
+        let data = &[
+            1, 1, 0, 0, 0, 0, 0, 0, 4, 0x21, 0x00, 0x04, b't', b'e', b's', b't', 0x00, 0x04, 0x12, 0x34, 0x56, 0x78,
+            0x21, 0x00, 0x00, 0x00, 0x04, 0x77, 0x65, 0x43, 0x21, 3,
+        ];
+        let result = AsyncIppParser::new(AsyncIppReader::new(futures_util::io::Cursor::new(data)))
+            .parse()
+            .await;
+        assert!(result.is_ok());
+
+        let res = result.ok().unwrap();
+        let attrs = res
+            .attributes
+            .groups_of(DelimiterTag::PrinterAttributes)
+            .next()
+            .unwrap()
+            .attributes();
+        let attr = attrs.get("test").unwrap();
+        assert_eq!(
+            attr.value().as_array(),
+            Some(&vec![IppValue::Integer(0x1234_5678), IppValue::Integer(0x7765_4321)])
+        );
+    }
+
+    #[cfg(feature = "async")]
+    #[tokio::test]
+    async fn test_async_parse_collection() {
+        let data = vec![
+            1, 1, 0, 0, 0, 0, 0, 0, 4, 0x34, 0, 4, b'c', b'o', b'l', b'l', 0, 0, 0x4a, 0, 0, 0, 4, b'a', b'b', b'c',
+            b'd', 0x44, 0, 0, 0, 3, b'k', b'e', b'y', 0x37, 0, 0, 0, 0, 3,
+        ];
+        let result = AsyncIppParser::new(AsyncIppReader::new(futures_util::io::Cursor::new(data)))
+            .parse()
+            .await;
+        assert!(result.is_ok());
+
+        let res = result.ok().unwrap();
+        let attrs = res
+            .attributes
+            .groups_of(DelimiterTag::PrinterAttributes)
+            .next()
+            .unwrap()
+            .attributes();
+        let attr = attrs.get("coll").unwrap();
+        assert_eq!(
+            attr.value(),
+            &IppValue::Collection(BTreeMap::from([(
+                "abcd".to_string(),
+                IppValue::Keyword("key".to_owned())
+            )]))
+        );
+    }
+
+    #[cfg(feature = "async")]
+    #[tokio::test]
+    async fn test_async_parse_with_payload() {
+        let data = vec![
+            1, 1, 0, 0, 0, 0, 0, 0, 4, 0x21, 0x00, 0x04, b't', b'e', b's', b't', 0x00, 0x04, 0x12, 0x34, 0x56, 0x78, 3,
+            b'f', b'o', b'o',
+        ];
+
+        let result = AsyncIppParser::new(AsyncIppReader::new(futures_util::io::Cursor::new(data)))
+            .parse()
+            .await;
+        assert!(result.is_ok());
+
+        let res = result.ok().unwrap();
+        assert_eq!(res.header.version, IppVersion::v1_1());
+        let attrs = res
+            .attributes
+            .groups_of(DelimiterTag::PrinterAttributes)
+            .next()
+            .unwrap()
+            .attributes();
+        let attr = attrs.get("test").unwrap();
+        assert_eq!(attr.value().as_integer(), Some(&0x1234_5678));
+
+        let mut cursor = futures_util::io::Cursor::new(Vec::new());
+        futures_executor::block_on(futures_util::io::copy(res.payload, &mut cursor)).unwrap();
+        assert_eq!(cursor.into_inner(), b"foo");
+    }
+
+    #[cfg(feature = "async")]
+    #[tokio::test]
+    async fn test_async_parse_parts() {
+        let data = vec![
+            1, 1, 0, 0, 0, 0, 0, 0, 4, 0x21, 0x00, 0x04, b't', b'e', b's', b't', 0x00, 0x04, 0x12, 0x34, 0x56, 0x78, 3,
+            b'f', b'o', b'o',
+        ];
+
+        let result = AsyncIppParser::new(AsyncIppReader::new(futures_util::io::Cursor::new(data)))
+            .parse_parts()
+            .await;
+        assert!(result.is_ok());
+
+        let (header, attributes, reader) = result.ok().unwrap();
+        assert_eq!(header.version, IppVersion::v1_1());
+        let attrs = attributes
+            .groups_of(DelimiterTag::PrinterAttributes)
+            .next()
+            .unwrap()
+            .attributes();
+        let attr = attrs.get("test").unwrap();
+        assert_eq!(attr.value().as_integer(), Some(&0x1234_5678));
+
+        let mut payload = reader.into_payload();
+        let mut cursor = io::Cursor::new(Vec::new());
+        io::copy(&mut payload, &mut cursor).unwrap();
+        assert_eq!(cursor.into_inner(), b"foo");
+    }
+
+    #[test]
+    fn test_parse_no_attributes() {
+        let data = &[1, 1, 0, 0, 0, 0, 0, 0, 3];
+        let result = IppParser::new(IppReader::new(io::Cursor::new(data))).parse();
+        assert!(result.is_ok());
+
+        let res = result.ok().unwrap();
+        assert!(res.attributes.groups().is_empty());
+    }
+
+    #[test]
+    fn test_parse_single_value() {
+        let data = &[
+            1, 1, 0, 0, 0, 0, 0, 0, 4, 0x21, 0x00, 0x04, b't', b'e', b's', b't', 0x00, 0x04, 0x12, 0x34, 0x56, 0x78, 3,
+        ];
+        let result = IppParser::new(IppReader::new(io::Cursor::new(data))).parse();
+        assert!(result.is_ok());
+
+        let res = result.ok().unwrap();
+        let attrs = res
+            .attributes
+            .groups_of(DelimiterTag::PrinterAttributes)
+            .next()
+            .unwrap()
+            .attributes();
+        let attr = attrs.get("test").unwrap();
+        assert_eq!(attr.value().as_integer(), Some(&0x1234_5678));
+    }
+
+    #[test]
+    fn test_parse_array() {
+        let data = &[
+            1, 1, 0, 0, 0, 0, 0, 0, 4, 0x21, 0x00, 0x04, b't', b'e', b's', b't', 0x00, 0x04, 0x12, 0x34, 0x56, 0x78,
+            0x21, 0x00, 0x00, 0x00, 0x04, 0x77, 0x65, 0x43, 0x21, 3,
+        ];
+        let result = IppParser::new(IppReader::new(io::Cursor::new(data))).parse();
+        assert!(result.is_ok());
+
+        let res = result.ok().unwrap();
+        let attrs = res
+            .attributes
+            .groups_of(DelimiterTag::PrinterAttributes)
+            .next()
+            .unwrap()
+            .attributes();
+        let attr = attrs.get("test").unwrap();
+        assert_eq!(
+            attr.value().as_array(),
+            Some(&vec![IppValue::Integer(0x1234_5678), IppValue::Integer(0x7765_4321)])
+        );
+    }
+
+    #[test]
+    fn test_parse_collection() {
+        let data = vec![
+            1, 1, 0, 0, 0, 0, 0, 0, 4, 0x34, 0, 4, b'c', b'o', b'l', b'l', 0, 0, 0x4a, 0, 0, 0, 4, b'a', b'b', b'c',
+            b'd', 0x44, 0, 0, 0, 3, b'k', b'e', b'y', 0x37, 0, 0, 0, 0, 3,
+        ];
+        let result = IppParser::new(IppReader::new(io::Cursor::new(data))).parse();
+        assert!(result.is_ok());
+
+        let res = result.ok().unwrap();
+        let attrs = res
+            .attributes
+            .groups_of(DelimiterTag::PrinterAttributes)
+            .next()
+            .unwrap()
+            .attributes();
+        let attr = attrs.get("coll").unwrap();
+        assert_eq!(
+            attr.value(),
+            &IppValue::Collection(BTreeMap::from([(
+                "abcd".to_string(),
+                IppValue::Keyword("key".to_owned())
+            )]))
+        );
+    }
+
+    #[test]
+    fn test_parser_with_payload() {
+        let data = vec![
+            1, 1, 0, 0, 0, 0, 0, 0, 4, 0x21, 0x00, 0x04, b't', b'e', b's', b't', 0x00, 0x04, 0x12, 0x34, 0x56, 0x78, 3,
+            b'f', b'o', b'o',
+        ];
+
+        let result = IppParser::new(IppReader::new(io::Cursor::new(data))).parse();
+        assert!(result.is_ok());
+
+        let mut res = result.ok().unwrap();
+        assert_eq!(res.header.version, IppVersion::v1_1());
+        let attrs = res
+            .attributes
+            .groups_of(DelimiterTag::PrinterAttributes)
+            .next()
+            .unwrap()
+            .attributes();
+        let attr = attrs.get("test").unwrap();
+        assert_eq!(attr.value().as_integer(), Some(&0x1234_5678));
+
+        let mut cursor = io::Cursor::new(Vec::new());
+        io::copy(&mut res.payload, &mut cursor).unwrap();
+        assert_eq!(cursor.into_inner(), b"foo");
+    }
+
+    #[test]
+    fn test_parse_parts() {
+        let data = vec![
+            1, 1, 0, 0, 0, 0, 0, 0, 4, 0x21, 0x00, 0x04, b't', b'e', b's', b't', 0x00, 0x04, 0x12, 0x34, 0x56, 0x78, 3,
+            b'f', b'o', b'o',
+        ];
+
+        let result = IppParser::new(IppReader::new(io::Cursor::new(data))).parse_parts();
+        assert!(result.is_ok());
+
+        let (header, attributes, reader) = result.ok().unwrap();
+        assert_eq!(header.version, IppVersion::v1_1());
+        let attrs = attributes
+            .groups_of(DelimiterTag::PrinterAttributes)
+            .next()
+            .unwrap()
+            .attributes();
+        let attr = attrs.get("test").unwrap();
+        assert_eq!(attr.value().as_integer(), Some(&0x1234_5678));
+
+        let mut payload = reader.into_payload();
+        let mut cursor = io::Cursor::new(Vec::new());
+        io::copy(&mut payload, &mut cursor).unwrap();
+        assert_eq!(cursor.into_inner(), b"foo");
+    }
+
+    #[test]
+    fn test_parse_groups() {
+        let data = vec![
+            0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x21, 0x00, 0x04, b't', b'e', b's', b't', 0x00, 0x04,
+            0x12, 0x34, 0x56, 0x78, 0x21, 0x00, 0x05, b't', b'e', b's', b't', b'2', 0x00, 0x04, 0x12, 0x34, 0x56, 0xFF,
+            0x04, 0x21, 0x00, 0x04, b't', b'e', b's', b't', 0x00, 0x04, 0x87, 0x65, 0x43, 0x21, 0x03,
+        ];
+
+        let res = IppParser::new(IppReader::new(io::Cursor::new(data))).parse().unwrap();
+
+        assert_eq!(2, res.attributes().groups()[0].attributes().len());
+        assert_eq!(1, res.attributes().groups()[1].attributes().len());
+    }
+}
diff --git a/crates/ipp/src/payload.rs b/crates/ipp/src/payload.rs
new file mode 100644
index 0000000..af68cf6
--- /dev/null
+++ b/crates/ipp/src/payload.rs
@@ -0,0 +1,85 @@
+//!
+//! IPP payload
+//!
+use std::io::{self, Read};
+
+#[cfg(feature = "async")]
+use {
+    futures_util::io::{AllowStdIo, AsyncRead, AsyncReadExt},
+    std::{
+        pin::Pin,
+        task::{Context, Poll},
+    },
+};
+
+enum PayloadKind {
+    #[cfg(feature = "async")]
+    Async(Box<dyn AsyncRead + Send + Sync + Unpin>),
+    Sync(Box<dyn Read + Send + Sync>),
+    Empty,
+}
+
+/// IPP payload
+pub struct IppPayload {
+    inner: PayloadKind,
+}
+
+impl IppPayload {
+    /// Create empty payload
+    pub fn empty() -> Self {
+        IppPayload {
+            inner: PayloadKind::Empty,
+        }
+    }
+
+    #[cfg(feature = "async")]
+    /// Create an async payload from the AsyncRead object
+    pub fn new_async<R>(r: R) -> Self
+    where
+        R: 'static + AsyncRead + Send + Sync + Unpin,
+    {
+        IppPayload {
+            inner: PayloadKind::Async(Box::new(r)),
+        }
+    }
+
+    /// Create a sync payload from the Read object
+    pub fn new<R>(r: R) -> Self
+    where
+        R: 'static + Read + Send + Sync,
+    {
+        IppPayload {
+            inner: PayloadKind::Sync(Box::new(r)),
+        }
+    }
+}
+
+impl Default for IppPayload {
+    fn default() -> Self {
+        Self {
+            inner: PayloadKind::Empty,
+        }
+    }
+}
+
+#[cfg(feature = "async")]
+impl AsyncRead for IppPayload {
+    fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context, buf: &mut [u8]) -> Poll<io::Result<usize>> {
+        match self.inner {
+            PayloadKind::Async(ref mut inner) => Pin::new(&mut *inner).poll_read(cx, buf),
+            PayloadKind::Sync(ref mut inner) => Pin::new(&mut AllowStdIo::new(inner)).poll_read(cx, buf),
+            PayloadKind::Empty => Poll::Ready(Ok(0)),
+        }
+    }
+}
+
+impl Read for IppPayload {
+    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
+        match self.inner {
+            #[cfg(feature = "async")]
+            PayloadKind::Async(ref mut inner) => futures_executor::block_on(inner.read(buf)),
+            PayloadKind::Sync(ref mut inner) => inner.read(buf),
+            PayloadKind::Empty => Ok(0),
+        }
+    }
+}
diff --git a/crates/ipp/src/reader.rs b/crates/ipp/src/reader.rs
new file mode 100644
index 0000000..d92d5a1
--- /dev/null
+++ b/crates/ipp/src/reader.rs
@@ -0,0 +1,270 @@
+//!
+//! IPP reader
+//!
+use std::io::{self, Read};
+
+use bytes::Bytes;
+
+#[cfg(feature = "async")]
+use futures_util::io::{AsyncRead, AsyncReadExt};
+
+use crate::{model::IppVersion, payload::IppPayload, IppHeader};
+
+#[cfg(feature = "async")]
+/// Asynchronous IPP reader contains a set of methods to read from IPP data stream
+pub struct AsyncIppReader<R> {
+    inner: R,
+}
+
+#[cfg(feature = "async")]
+impl<R> AsyncIppReader<R>
+where
+    R: AsyncRead + Send + Sync + Unpin,
+{
+    /// Create IppReader from AsyncRead instance
+    pub fn new(inner: R) -> Self {
+        AsyncIppReader { inner }
+    }
+
+    async fn read_bytes(&mut self, len: usize) -> io::Result<Bytes> {
+        let mut buf = vec![0; len];
+        self.inner.read_exact(&mut buf).await?;
+        Ok(buf.into())
+    }
+
+    async fn read_string(&mut self, len: usize) -> io::Result<String> {
+        self.read_bytes(len)
+            .await
+            .map(|b| String::from_utf8_lossy(&b).into_owned())
+    }
+
+    async fn read_u16(&mut self) -> io::Result<u16> {
+        let mut buf = [0u8; 2];
+        self.inner.read_exact(&mut buf).await?;
+        Ok(u16::from_be_bytes(buf))
+    }
+
+    async fn read_u8(&mut self) -> io::Result<u8> {
+        let mut buf = [0u8; 1];
+        self.inner.read_exact(&mut buf).await?;
+        Ok(buf[0])
+    }
+
+    async fn read_u32(&mut self) -> io::Result<u32> {
+        let mut buf = [0u8; 4];
+        self.inner.read_exact(&mut buf).await?;
+        Ok(u32::from_be_bytes(buf))
+    }
+
+    /// Read tag
+    pub async fn read_tag(&mut self) -> io::Result<u8> {
+        self.read_u8().await
+    }
+
+    /// Read IPP name from [len; name] element
+    pub async fn read_name(&mut self) -> io::Result<String> {
+        let name_len = self.read_u16().await?;
+        self.read_string(name_len as usize).await
+    }
+
+    /// Read IPP value from [len; value] element
+    pub async fn read_value(&mut self) -> io::Result<Bytes> {
+        let value_len = self.read_u16().await?;
+        self.read_bytes(value_len as usize).await
+    }
+
+    /// Read IPP header
+    pub async fn read_header(&mut self) -> io::Result<IppHeader> {
+        let version = IppVersion(self.read_u16().await?);
+        let operation_status = self.read_u16().await?;
+        let request_id = self.read_u32().await?;
+
+        Ok(IppHeader::new(version, operation_status, request_id))
+    }
+
+    /// Release the underlying reader
+    pub fn into_inner(self) -> R {
+        self.inner
+    }
+
+    /// Convert the remaining inner stream into IppPayload
+    pub fn into_payload(self) -> IppPayload
+    where
+        R: 'static,
+    {
+        IppPayload::new_async(self.inner)
+    }
+}
+
+#[cfg(feature = "async")]
+impl<R> From<R> for AsyncIppReader<R>
+where
+    R: AsyncRead + Send + Sync + Unpin,
+{
+    fn from(r: R) -> Self {
+        AsyncIppReader::new(r)
+    }
+}
+
+/// Synchronous IPP reader contains a set of methods to read from IPP data stream
+pub struct IppReader<R> {
+    inner: R,
+}
+
+impl<R> IppReader<R>
+where
+    R: Read + Send + Sync,
+{
+    /// Create IppReader from Read instance
+    pub fn new(inner: R) -> Self {
+        IppReader { inner }
+    }
+
+    fn read_bytes(&mut self, len: usize) -> io::Result<Bytes> {
+        let mut buf = vec![0; len];
+        self.inner.read_exact(&mut buf)?;
+        Ok(buf.into())
+    }
+
+    fn read_string(&mut self, len: usize) -> io::Result<String> {
+        self.read_bytes(len).map(|b| String::from_utf8_lossy(&b).into_owned())
+    }
+
+    fn read_u16(&mut self) -> io::Result<u16> {
+        let mut buf = [0u8; 2];
+        self.inner.read_exact(&mut buf)?;
+        Ok(u16::from_be_bytes(buf))
+    }
+
+    fn read_u8(&mut self) -> io::Result<u8> {
+        let mut buf = [0u8; 1];
+        self.inner.read_exact(&mut buf)?;
+        Ok(buf[0])
+    }
+
+    fn read_u32(&mut self) -> io::Result<u32> {
+        let mut buf = [0u8; 4];
+        self.inner.read_exact(&mut buf)?;
+        Ok(u32::from_be_bytes(buf))
+    }
+
+    /// Read tag
+    pub fn read_tag(&mut self) -> io::Result<u8> {
+        self.read_u8()
+    }
+
+    /// Read IPP name from [len; name] element
+    pub fn read_name(&mut self) -> io::Result<String> {
+        let name_len = self.read_u16()?;
+        self.read_string(name_len as usize)
+    }
+
+    /// Read IPP value from [len; value] element
+    pub fn read_value(&mut self) -> io::Result<Bytes> {
+        let value_len = self.read_u16()?;
+        self.read_bytes(value_len as usize)
+    }
+
+    /// Read IPP header
+    pub fn read_header(&mut self) -> io::Result<IppHeader> {
+        let version = IppVersion(self.read_u16()?);
+        let operation_status = self.read_u16()?;
+        let request_id = self.read_u32()?;
+
+        Ok(IppHeader::new(version, operation_status, request_id))
+    }
+
+    /// Release the underlying reader
+    pub fn into_inner(self) -> R {
+        self.inner
+    }
+
+    /// Convert the remaining inner stream into IppPayload
+    pub fn into_payload(self) -> IppPayload
+    where
+        R: 'static,
+    {
+        IppPayload::new(self.inner)
+    }
+}
+
+impl<R> From<R> for IppReader<R>
+where
+    R: Read + Send + Sync,
+{
+    fn from(r: R) -> Self {
+        IppReader::new(r)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::model::StatusCode;
+
+    #[test]
+    fn test_read_name() {
+        let data = io::Cursor::new(vec![0x00, 0x04, b't', b'e', b's', b't']);
+        let mut reader = IppReader::new(data);
+        let name = reader.read_name().unwrap();
+        assert_eq!(name, "test");
+    }
+
+    #[test]
+    fn test_read_value() {
+        let data = io::Cursor::new(vec![0x00, 0x04, b't', b'e', b's', b't']);
+        let mut reader = IppReader::new(data);
+        let value = reader.read_value().unwrap();
+        assert_eq!(value.as_ref(), b"test");
+    }
+
+    #[test]
+    fn test_read_borrowed_value() {
+        let data = vec![0x00, 0x04, b't', b'e', b's', b't'];
+        let data = io::Cursor::new(&data);
+        let mut reader = IppReader::new(data);
+        let value = reader.read_value().unwrap();
+        assert_eq!(value.as_ref(), b"test");
+    }
+
+    #[test]
+    fn test_read_header() {
+        let data = io::Cursor::new(vec![0x01, 0x01, 0x04, 0x01, 0x11, 0x22, 0x33, 0x44]);
+        let mut reader = IppReader::new(data);
+        let header = reader.read_header().unwrap();
+        assert_eq!(header.version, IppVersion::v1_1());
+        assert_eq!(header.operation_or_status, 0x401);
+        assert_eq!(header.request_id, 0x11223344);
+        assert_eq!(header.status_code(), StatusCode::ClientErrorForbidden);
+    }
+
+    #[cfg(feature = "async")]
+    #[tokio::test]
+    async fn test_async_read_name() {
+        let data = futures_util::io::Cursor::new(vec![0x00, 0x04, b't', b'e', b's', b't']);
+        let mut reader = AsyncIppReader::new(data);
+        let name = reader.read_name().await.unwrap();
+        assert_eq!(name, "test");
+    }
+
+    #[cfg(feature = "async")]
+    #[tokio::test]
+    async fn test_async_read_value() {
+        let data = futures_util::io::Cursor::new(vec![0x00, 0x04, b't', b'e', b's', b't']);
+        let mut reader = AsyncIppReader::new(data);
+        let value = reader.read_value().await.unwrap();
+        assert_eq!(value.as_ref(), b"test");
+    }
+
+    #[cfg(feature = "async")]
+    #[tokio::test]
+    async fn test_async_read_header() {
+        let data = futures_util::io::Cursor::new(vec![0x01, 0x01, 0x04, 0x01, 0x11, 0x22, 0x33, 0x44]);
+        let mut reader = AsyncIppReader::new(data);
+        let header = reader.read_header().await.unwrap();
+        assert_eq!(header.version, IppVersion::v1_1());
+        assert_eq!(header.operation_or_status, 0x401);
+        assert_eq!(header.request_id, 0x11223344);
+        assert_eq!(header.status_code(), StatusCode::ClientErrorForbidden);
+    }
+}
diff --git a/crates/ipp/src/request.rs b/crates/ipp/src/request.rs
new file mode 100644
index 0000000..0ac280c
--- /dev/null
+++ b/crates/ipp/src/request.rs
@@ -0,0 +1,150 @@
+//!
+//! IPP request
+//!
+use std::io::{self, Read};
+
+use bytes::{BufMut, Bytes, BytesMut};
+#[cfg(feature = "async")]
+use futures_util::io::{AsyncRead, AsyncReadExt};
+use http::Uri;
+use log::trace;
+#[cfg(feature = "serde")]
+use serde::{Deserialize, Serialize};
+
+use crate::{
+    attribute::{IppAttribute, IppAttributes},
+    model::{DelimiterTag, IppVersion, Operation, StatusCode},
+    payload::IppPayload,
+    value::*,
+    IppHeader,
+};
+
+/// IPP request/response struct
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
+pub struct IppRequestResponse {
+    pub(crate) header: IppHeader,
+    pub(crate) attributes: IppAttributes,
+    #[cfg_attr(feature = "serde", serde(skip))]
+    pub(crate) payload: IppPayload,
+}
+
+impl IppRequestResponse {
+    /// Create new IPP request for the operation and uri
+    pub fn new(version: IppVersion, operation: Operation, uri: Option<Uri>) -> IppRequestResponse {
+        let header = IppHeader::new(version, operation as u16, 1);
+        let mut attributes = IppAttributes::new();
+
+        attributes.add(
+            DelimiterTag::OperationAttributes,
+            IppAttribute::new(IppAttribute::ATTRIBUTES_CHARSET, IppValue::Charset("utf-8".to_string())),
+        );
+
+        attributes.add(
+            DelimiterTag::OperationAttributes,
+            IppAttribute::new(
+                IppAttribute::ATTRIBUTES_NATURAL_LANGUAGE,
+                IppValue::NaturalLanguage("en".to_string()),
+            ),
+        );
+
+        if let Some(uri) = uri {
+            attributes.add(
+                DelimiterTag::OperationAttributes,
+                IppAttribute::new(
+                    IppAttribute::PRINTER_URI,
+                    IppValue::Uri(crate::util::canonicalize_uri(&uri).to_string()),
+                ),
+            );
+        }
+
+        IppRequestResponse {
+            header,
+            attributes,
+            payload: IppPayload::empty(),
+        }
+    }
+
+    /// Create response from status and id
+    pub fn new_response(version: IppVersion, status: StatusCode, id: u32) -> IppRequestResponse {
+        let header = IppHeader::new(version, status as u16, id);
+        let mut response = IppRequestResponse {
+            header,
+            attributes: IppAttributes::new(),
+            payload: IppPayload::empty(),
+        };
+
+        response.attributes_mut().add(
+            DelimiterTag::OperationAttributes,
+            IppAttribute::new(IppAttribute::ATTRIBUTES_CHARSET, IppValue::Charset("utf-8".to_string())),
+        );
+        response.attributes_mut().add(
+            DelimiterTag::OperationAttributes,
+            IppAttribute::new(
+                IppAttribute::ATTRIBUTES_NATURAL_LANGUAGE,
+                IppValue::NaturalLanguage("en".to_string()),
+            ),
+        );
+
+        response
+    }
+
+    /// Get IPP header
+    pub fn header(&self) -> &IppHeader {
+        &self.header
+    }
+
+    /// Get mutable IPP header
+    pub fn header_mut(&mut self) -> &mut IppHeader {
+        &mut self.header
+    }
+
+    /// Get attributes
+    pub fn attributes(&self) -> &IppAttributes {
+        &self.attributes
+    }
+
+    /// Get attributes
+    pub fn attributes_mut(&mut self) -> &mut IppAttributes {
+        &mut self.attributes
+    }
+
+    /// Get payload
+    pub fn payload(&self) -> &IppPayload {
+        &self.payload
+    }
+
+    /// Get mutable payload
+    pub fn payload_mut(&mut self) -> &mut IppPayload {
+        &mut self.payload
+    }
+
+    /// Write request to byte array not including payload
+    pub fn to_bytes(&self) -> Bytes {
+        let mut buffer = BytesMut::new();
+        buffer.put(self.header.to_bytes());
+        buffer.put(self.attributes.to_bytes());
+        buffer.freeze()
+    }
+
+    #[cfg(feature = "async")]
+    /// Convert request/response into AsyncRead including payload
+    pub fn into_async_read(self) -> impl AsyncRead + Send + Sync + 'static {
+        let header = self.to_bytes();
+        trace!("IPP header size: {}", header.len(),);
+
+        futures_util::io::Cursor::new(header).chain(self.payload)
+    }
+
+    /// Convert request/response into Read including payload
+    pub fn into_read(self) -> impl Read + Send + Sync + 'static {
+        let header = self.to_bytes();
+        trace!("IPP header size: {}", header.len(),);
+
+        io::Cursor::new(header).chain(self.payload)
+    }
+
+    /// Consume request/response and return a payload
+    pub fn into_payload(self) -> IppPayload {
+        self.payload
+    }
+}
diff --git a/crates/ipp/src/util.rs b/crates/ipp/src/util.rs
new file mode 100644
index 0000000..ee389c0
--- /dev/null
+++ b/crates/ipp/src/util.rs
@@ -0,0 +1,96 @@
+//!
+//! IPP helper functions
+//!
+use http::Uri;
+use num_traits::FromPrimitive;
+
+use crate::{
+    attribute::IppAttribute,
+    error::IppError,
+    model::{DelimiterTag, PrinterState},
+    prelude::IppRequestResponse,
+};
+
+/// convert `http://username:pwd@host:port/path?query` into `ipp://host:port/path`
+pub fn canonicalize_uri(uri: &Uri) -> Uri {
+    let mut builder = Uri::builder().scheme("ipp").path_and_query(uri.path());
+    if let Some(authority) = uri.authority() {
+        if let Some(port) = authority.port_u16() {
+            builder = builder.authority(format!("{}:{}", authority.host(), port).as_str());
+        } else {
+            builder = builder.authority(authority.host());
+        }
+    }
+    builder.build().unwrap_or_else(|_| uri.to_owned())
+}
+
+const ERROR_STATES: &[&str] = &[
+    "media-jam",
+    "toner-empty",
+    "spool-area-full",
+    "cover-open",
+    "door-open",
+    "input-tray-missing",
+    "output-tray-missing",
+    "marker-supply-empty",
+    "paused",
+    "shutdown",
+];
+
+/// Check if the printer is ready for printing
+///
+/// * `response` - IPP response to check
+pub fn is_printer_ready(response: &IppRequestResponse) -> Result<bool, IppError> {
+    let status = response.header().status_code();
+    if !status.is_success() {
+        return Err(IppError::StatusError(status));
+    }
+
+    let state = response
+        .attributes()
+        .groups_of(DelimiterTag::PrinterAttributes)
+        .next()
+        .and_then(|g| g.attributes().get(IppAttribute::PRINTER_STATE))
+        .and_then(|attr| attr.value().as_enum())
+        .and_then(|v| PrinterState::from_i32(*v));
+
+    if let Some(PrinterState::Stopped) = state {
+        return Ok(false);
+    }
+
+    if let Some(reasons) = response
+        .attributes()
+        .groups_of(DelimiterTag::PrinterAttributes)
+        .next()
+        .and_then(|g| g.attributes().get(IppAttribute::PRINTER_STATE_REASONS))
+    {
+        let keywords = reasons
+            .value()
+            .into_iter()
+            .filter_map(|e| e.as_keyword())
+            .map(ToOwned::to_owned)
+            .collect::<Vec<_>>();
+
+        if keywords.iter().any(|k| ERROR_STATES.contains(&&k[..])) {
+            return Ok(false);
+        }
+    }
+    Ok(true)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_canonicalize_uri() {
+        assert_eq!(
+            canonicalize_uri(&"http://user:pass@example.com:631/path?query=val".parse().unwrap()),
+            "ipp://example.com:631/path"
+        );
+        assert_eq!(
+            canonicalize_uri(&"http://example.com/path?query=val".parse().unwrap()),
+            "ipp://example.com/path"
+        );
+    }
+}
diff --git a/crates/ipp/src/value.rs b/crates/ipp/src/value.rs
new file mode 100644
index 0000000..2841c63
--- /dev/null
+++ b/crates/ipp/src/value.rs
@@ -0,0 +1,565 @@
+//!
+//! IPP value
+//!
+use std::{collections::BTreeMap, convert::Infallible, fmt, io, str::FromStr};
+
+use bytes::{Buf, BufMut, Bytes, BytesMut};
+use enum_as_inner::EnumAsInner;
+#[cfg(feature = "serde")]
+use serde::{Deserialize, Serialize};
+
+use crate::{model::ValueTag, FromPrimitive as _};
+
+#[inline]
+fn get_len_string(data: &mut Bytes) -> String {
+    let len = data.get_u16() as usize;
+    let s = String::from_utf8_lossy(&data[0..len]).into_owned();
+    data.advance(len);
+    s
+}
+
+/// IPP attribute values as defined in [RFC 8010](https://tools.ietf.org/html/rfc8010)
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
+#[derive(Clone, Debug, PartialEq, Eq, Hash, EnumAsInner)]
+pub enum IppValue {
+    Integer(i32),
+    Enum(i32),
+    OctetString(String),
+    TextWithoutLanguage(String),
+    NameWithoutLanguage(String),
+    TextWithLanguage {
+        language: String,
+        text: String,
+    },
+    NameWithLanguage {
+        language: String,
+        name: String,
+    },
+    Charset(String),
+    NaturalLanguage(String),
+    Uri(String),
+    UriScheme(String),
+    RangeOfInteger {
+        min: i32,
+        max: i32,
+    },
+    Boolean(bool),
+    Keyword(String),
+    Array(Vec<IppValue>),
+    Collection(BTreeMap<String, IppValue>),
+    MimeMediaType(String),
+    DateTime {
+        year: u16,
+        month: u8,
+        day: u8,
+        hour: u8,
+        minutes: u8,
+        seconds: u8,
+        deci_seconds: u8,
+        utc_dir: char,
+        utc_hours: u8,
+        utc_mins: u8,
+    },
+    MemberAttrName(String),
+    Resolution {
+        cross_feed: i32,
+        feed: i32,
+        units: i8,
+    },
+    NoValue,
+    Other {
+        tag: u8,
+        data: Bytes,
+    },
+}
+
+impl IppValue {
+    /// Convert to binary tag
+    pub fn to_tag(&self) -> u8 {
+        match *self {
+            IppValue::Integer(_) => ValueTag::Integer as u8,
+            IppValue::Enum(_) => ValueTag::Enum as u8,
+            IppValue::RangeOfInteger { .. } => ValueTag::RangeOfInteger as u8,
+            IppValue::Boolean(_) => ValueTag::Boolean as u8,
+            IppValue::Keyword(_) => ValueTag::Keyword as u8,
+            IppValue::OctetString(_) => ValueTag::OctetStringUnspecified as u8,
+            IppValue::TextWithoutLanguage(_) => ValueTag::TextWithoutLanguage as u8,
+            IppValue::NameWithoutLanguage(_) => ValueTag::NameWithoutLanguage as u8,
+            IppValue::TextWithLanguage { .. } => ValueTag::TextWithLanguage as u8,
+            IppValue::NameWithLanguage { .. } => ValueTag::NameWithLanguage as u8,
+            IppValue::Charset(_) => ValueTag::Charset as u8,
+            IppValue::NaturalLanguage(_) => ValueTag::NaturalLanguage as u8,
+            IppValue::Uri(_) => ValueTag::Uri as u8,
+            IppValue::UriScheme(_) => ValueTag::UriScheme as u8,
+            IppValue::MimeMediaType(_) => ValueTag::MimeMediaType as u8,
+            IppValue::Array(ref array) => array.first().map(|v| v.to_tag()).unwrap_or(ValueTag::Unknown as u8),
+            IppValue::Collection(_) => ValueTag::BegCollection as u8,
+            IppValue::DateTime { .. } => ValueTag::DateTime as u8,
+            IppValue::MemberAttrName(_) => ValueTag::MemberAttrName as u8,
+            IppValue::Resolution { .. } => ValueTag::Resolution as u8,
+            IppValue::Other { tag, .. } => tag,
+            IppValue::NoValue => ValueTag::NoValue as u8,
+        }
+    }
+
+    /// Parse value from byte array which does not include the value length field
+    pub fn parse(value_tag: u8, mut data: Bytes) -> io::Result<IppValue> {
+        let ipp_tag = match ValueTag::from_u8(value_tag) {
+            Some(x) => x,
+            None => {
+                return Ok(IppValue::Other { tag: value_tag, data });
+            }
+        };
+
+        let value = match ipp_tag {
+            ValueTag::Integer => IppValue::Integer(data.get_i32()),
+            ValueTag::Enum => IppValue::Enum(data.get_i32()),
+            ValueTag::OctetStringUnspecified => IppValue::OctetString(String::from_utf8_lossy(&data).into_owned()),
+            ValueTag::TextWithoutLanguage => IppValue::TextWithoutLanguage(String::from_utf8_lossy(&data).into_owned()),
+            ValueTag::NameWithoutLanguage => IppValue::NameWithoutLanguage(String::from_utf8_lossy(&data).into_owned()),
+            ValueTag::TextWithLanguage => IppValue::TextWithLanguage {
+                language: get_len_string(&mut data),
+                text: get_len_string(&mut data),
+            },
+            ValueTag::NameWithLanguage => IppValue::NameWithLanguage {
+                language: get_len_string(&mut data),
+                name: get_len_string(&mut data),
+            },
+            ValueTag::Charset => IppValue::Charset(String::from_utf8_lossy(&data).into_owned()),
+            ValueTag::NaturalLanguage => IppValue::NaturalLanguage(String::from_utf8_lossy(&data).into_owned()),
+            ValueTag::Uri => IppValue::Uri(String::from_utf8_lossy(&data).into_owned()),
+            ValueTag::UriScheme => IppValue::UriScheme(String::from_utf8_lossy(&data).into_owned()),
+            ValueTag::RangeOfInteger => IppValue::RangeOfInteger {
+                min: data.get_i32(),
+                max: data.get_i32(),
+            },
+            ValueTag::Boolean => IppValue::Boolean(data.get_u8() != 0),
+            ValueTag::Keyword => IppValue::Keyword(String::from_utf8_lossy(&data).into_owned()),
+            ValueTag::MimeMediaType => IppValue::MimeMediaType(String::from_utf8_lossy(&data).into_owned()),
+            ValueTag::DateTime => IppValue::DateTime {
+                year: data.get_u16(),
+                month: data.get_u8(),
+                day: data.get_u8(),
+                hour: data.get_u8(),
+                minutes: data.get_u8(),
+                seconds: data.get_u8(),
+                deci_seconds: data.get_u8(),
+                utc_dir: data.get_u8() as char,
+                utc_hours: data.get_u8(),
+                utc_mins: data.get_u8(),
+            },
+            ValueTag::MemberAttrName => IppValue::MemberAttrName(String::from_utf8_lossy(&data).into_owned()),
+            ValueTag::Resolution => IppValue::Resolution {
+                cross_feed: data.get_i32(),
+                feed: data.get_i32(),
+                units: data.get_i8(),
+            },
+            ValueTag::NoValue => IppValue::NoValue,
+            _ => IppValue::Other { tag: value_tag, data },
+        };
+        Ok(value)
+    }
+
+    /// Write value to byte array, including leading value length field, excluding value tag
+    pub fn to_bytes(&self) -> Bytes {
+        let mut buffer = BytesMut::new();
+
+        match *self {
+            IppValue::Integer(i) | IppValue::Enum(i) => {
+                buffer.put_u16(4);
+                buffer.put_i32(i);
+            }
+            IppValue::RangeOfInteger { min, max } => {
+                buffer.put_u16(8);
+                buffer.put_i32(min);
+                buffer.put_i32(max);
+            }
+            IppValue::Boolean(b) => {
+                buffer.put_u16(1);
+                buffer.put_u8(b as u8);
+            }
+            IppValue::Keyword(ref s)
+            | IppValue::OctetString(ref s)
+            | IppValue::TextWithoutLanguage(ref s)
+            | IppValue::NameWithoutLanguage(ref s)
+            | IppValue::Charset(ref s)
+            | IppValue::NaturalLanguage(ref s)
+            | IppValue::Uri(ref s)
+            | IppValue::UriScheme(ref s)
+            | IppValue::MimeMediaType(ref s)
+            | IppValue::MemberAttrName(ref s) => {
+                buffer.put_u16(s.len() as u16);
+                buffer.put_slice(s.as_bytes());
+            }
+            IppValue::TextWithLanguage { ref language, ref text } => {
+                buffer.put_u16((language.len() + text.len() + 4) as u16);
+                buffer.put_u16(language.len() as u16);
+                buffer.put_slice(language.as_bytes());
+                buffer.put_u16(text.len() as u16);
+                buffer.put_slice(text.as_bytes());
+            }
+            IppValue::NameWithLanguage { ref language, ref name } => {
+                buffer.put_u16((language.len() + name.len() + 4) as u16);
+                buffer.put_u16(language.len() as u16);
+                buffer.put_slice(language.as_bytes());
+                buffer.put_u16(name.len() as u16);
+                buffer.put_slice(name.as_bytes());
+            }
+            IppValue::Array(ref list) => {
+                for (i, item) in list.iter().enumerate() {
+                    buffer.put(item.to_bytes());
+                    if i < list.len() - 1 {
+                        buffer.put_u8(self.to_tag());
+                        buffer.put_u16(0);
+                    }
+                }
+            }
+            IppValue::Collection(ref list) => {
+                // begin collection: value size is 0
+                buffer.put_u16(0);
+
+                for item in list.iter() {
+                    let atr_name = IppValue::MemberAttrName(item.0.to_string());
+                    // item tag
+                    buffer.put_u8(atr_name.to_tag());
+                    // name size is zero, this is a collection
+                    buffer.put_u16(0);
+
+                    buffer.put(atr_name.to_bytes());
+
+                    // item tag
+                    buffer.put_u8(item.1.to_tag());
+                    // name size is zero, this is a collection
+                    buffer.put_u16(0);
+
+                    buffer.put(item.1.to_bytes());
+                }
+                // write end collection attribute
+                buffer.put_u8(ValueTag::EndCollection as u8);
+                buffer.put_u32(0);
+            }
+            IppValue::DateTime {
+                year,
+                month,
+                day,
+                hour,
+                minutes,
+                seconds,
+                deci_seconds,
+                utc_dir,
+                utc_hours,
+                utc_mins,
+            } => {
+                buffer.put_u16(11);
+                buffer.put_u16(year);
+                buffer.put_u8(month);
+                buffer.put_u8(day);
+                buffer.put_u8(hour);
+                buffer.put_u8(minutes);
+                buffer.put_u8(seconds);
+                buffer.put_u8(deci_seconds);
+                buffer.put_u8(utc_dir as u8);
+                buffer.put_u8(utc_hours);
+                buffer.put_u8(utc_mins);
+            }
+            IppValue::Resolution {
+                cross_feed,
+                feed,
+                units,
+            } => {
+                buffer.put_u16(9);
+                buffer.put_i32(cross_feed);
+                buffer.put_i32(feed);
+                buffer.put_u8(units as u8);
+            }
+            IppValue::NoValue => buffer.put_u16(0),
+            IppValue::Other { ref data, .. } => {
+                buffer.put_u16(data.len() as u16);
+                buffer.put_slice(data);
+            }
+        }
+        buffer.freeze()
+    }
+}
+
+/// Implement Display trait to print the value
+impl fmt::Display for IppValue {
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        match *self {
+            IppValue::Integer(i) | IppValue::Enum(i) => write!(f, "{i}"),
+            IppValue::RangeOfInteger { min, max } => write!(f, "{min}..{max}"),
+            IppValue::Boolean(b) => write!(f, "{}", if b { "true" } else { "false" }),
+            IppValue::Keyword(ref s)
+            | IppValue::OctetString(ref s)
+            | IppValue::TextWithoutLanguage(ref s)
+            | IppValue::NameWithoutLanguage(ref s)
+            | IppValue::Charset(ref s)
+            | IppValue::NaturalLanguage(ref s)
+            | IppValue::Uri(ref s)
+            | IppValue::UriScheme(ref s)
+            | IppValue::MimeMediaType(ref s)
+            | IppValue::MemberAttrName(ref s) => write!(f, "{s}"),
+            IppValue::TextWithLanguage { ref language, ref text } => write!(f, "{language}:{text}"),
+            IppValue::NameWithLanguage { ref language, ref name } => write!(f, "{language}:{name}"),
+            IppValue::Array(ref array) => {
+                let s: Vec<String> = array.iter().map(|v| format!("{v}")).collect();
+                write!(f, "[{}]", s.join(", "))
+            }
+            IppValue::Collection(ref coll) => {
+                let s: Vec<String> = coll.iter().map(|(k, v)| format!("{k}={v}")).collect();
+                write!(f, "<{}>", s.join(", "))
+            }
+            IppValue::DateTime {
+                year,
+                month,
+                day,
+                hour,
+                minutes,
+                seconds,
+                deci_seconds,
+                utc_dir,
+                utc_hours,
+                ..
+            } => write!(
+                f,
+                "{year}-{month}-{day},{hour}:{minutes}:{seconds}.{deci_seconds},{utc_dir}{utc_hours}utc"
+            ),
+            IppValue::Resolution {
+                cross_feed,
+                feed,
+                units,
+            } => {
+                write!(f, "{cross_feed}x{feed}{}", if units == 3 { "in" } else { "cm" })
+            }
+
+            IppValue::NoValue => Ok(()),
+            IppValue::Other { tag, ref data } => write!(f, "{tag:0x}: {data:?}"),
+        }
+    }
+}
+
+impl FromStr for IppValue {
+    type Err = Infallible;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        let value = match s {
+            "true" => IppValue::Boolean(true),
+            "false" => IppValue::Boolean(false),
+            other => {
+                if let Ok(iv) = other.parse::<i32>() {
+                    IppValue::Integer(iv)
+                } else {
+                    IppValue::Keyword(other.to_owned())
+                }
+            }
+        };
+        Ok(value)
+    }
+}
+
+impl<'a> IntoIterator for &'a IppValue {
+    type Item = &'a IppValue;
+    type IntoIter = IppValueIterator<'a>;
+
+    fn into_iter(self) -> Self::IntoIter {
+        IppValueIterator { value: self, index: 0 }
+    }
+}
+
+pub struct IppValueIterator<'a> {
+    value: &'a IppValue,
+    index: usize,
+}
+
+impl<'a> Iterator for IppValueIterator<'a> {
+    type Item = &'a IppValue;
+
+    fn next(&mut self) -> Option<Self::Item> {
+        match self.value {
+            IppValue::Array(ref array) => {
+                if self.index < array.len() {
+                    self.index += 1;
+                    Some(&array[self.index - 1])
+                } else {
+                    None
+                }
+            }
+            IppValue::Collection(ref map) => {
+                if let Some(entry) = map.iter().nth(self.index) {
+                    self.index += 1;
+                    Some(entry.1)
+                } else {
+                    None
+                }
+            }
+            _ => {
+                if self.index == 0 {
+                    self.index += 1;
+                    Some(self.value)
+                } else {
+                    None
+                }
+            }
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use std::collections::BTreeMap;
+
+    use crate::attribute::IppAttribute;
+    use crate::model::DelimiterTag;
+    use crate::parser::IppParser;
+    use crate::reader::IppReader;
+
+    use super::*;
+
+    fn value_check(value: IppValue) {
+        let mut b = value.to_bytes();
+        b.advance(2); // skip value size
+        assert_eq!(IppValue::parse(value.to_tag() as u8, b).unwrap(), value);
+    }
+
+    #[test]
+    fn test_value_single() {
+        value_check(IppValue::Integer(1234));
+        value_check(IppValue::Enum(4321));
+        value_check(IppValue::OctetString("octet-string".to_owned()));
+        value_check(IppValue::TextWithoutLanguage("text-without".to_owned()));
+        value_check(IppValue::NameWithoutLanguage("name-without".to_owned()));
+        value_check(IppValue::TextWithLanguage {
+            language: "en".to_owned(),
+            text: "text-with".to_owned(),
+        });
+        value_check(IppValue::NameWithLanguage {
+            language: "en".to_owned(),
+            name: "name-with".to_owned(),
+        });
+        value_check(IppValue::Charset("charset".to_owned()));
+        value_check(IppValue::NaturalLanguage("natural".to_owned()));
+        value_check(IppValue::Uri("uri".to_owned()));
+        value_check(IppValue::UriScheme("urischeme".to_owned()));
+        value_check(IppValue::RangeOfInteger { min: -12, max: 45 });
+        value_check(IppValue::Boolean(true));
+        value_check(IppValue::Boolean(false));
+        value_check(IppValue::Keyword("keyword".to_owned()));
+        value_check(IppValue::MimeMediaType("mime".to_owned()));
+        value_check(IppValue::DateTime {
+            year: 2020,
+            month: 2,
+            day: 13,
+            hour: 12,
+            minutes: 34,
+            seconds: 22,
+            deci_seconds: 1,
+            utc_dir: 'c',
+            utc_hours: 1,
+            utc_mins: 30,
+        });
+        value_check(IppValue::MemberAttrName("member".to_owned()));
+        value_check(IppValue::Resolution {
+            cross_feed: 800,
+            feed: 600,
+            units: 2,
+        });
+        value_check(IppValue::NoValue);
+        value_check(IppValue::Other {
+            tag: 123,
+            data: "foo".into(),
+        });
+    }
+
+    #[test]
+    fn test_value_iterator_single() {
+        let val = IppValue::Integer(1234);
+
+        for v in &val {
+            assert_eq!(*v, val);
+        }
+    }
+
+    #[test]
+    fn test_value_iterator_multiple() {
+        let list = vec![IppValue::Integer(1234), IppValue::Integer(5678)];
+        let val = IppValue::Array(list.clone());
+
+        for v in val.into_iter().enumerate() {
+            assert_eq!(*v.1, list[v.0]);
+        }
+    }
+
+    #[test]
+    fn test_array() {
+        let attr = IppAttribute::new(
+            "list",
+            IppValue::Array(vec![IppValue::Integer(0x1111_1111), IppValue::Integer(0x2222_2222)]),
+        );
+        let buf = attr.to_bytes().to_vec();
+
+        assert_eq!(
+            buf,
+            vec![
+                0x21, 0, 4, b'l', b'i', b's', b't', 0, 4, 0x11, 0x11, 0x11, 0x11, 0x21, 0, 0, 0, 4, 0x22, 0x22, 0x22,
+                0x22
+            ],
+        );
+
+        let mut data = vec![1, 1, 0, 0, 0, 0, 0, 0, 4];
+        data.extend(buf);
+        data.push(3);
+
+        let result = IppParser::new(IppReader::new(io::Cursor::new(data))).parse();
+        assert!(result.is_ok());
+
+        let res = result.ok().unwrap();
+        let attrs = res
+            .attributes
+            .groups_of(DelimiterTag::PrinterAttributes)
+            .next()
+            .unwrap()
+            .attributes();
+        let attr = attrs.get("list").unwrap();
+        assert_eq!(
+            attr.value().as_array(),
+            Some(&vec![IppValue::Integer(0x1111_1111), IppValue::Integer(0x2222_2222)])
+        );
+    }
+
+    #[test]
+    fn test_collection() {
+        let attr = IppAttribute::new(
+            "coll",
+            IppValue::Collection(BTreeMap::from([("abcd".to_string(), IppValue::Integer(0x2222_2222))])),
+        );
+        let buf = attr.to_bytes();
+
+        assert_eq!(
+            vec![
+                0x34, 0, 4, b'c', b'o', b'l', b'l', 0, 0, 0x4a, 0, 0, 0, 4, b'a', b'b', b'c', b'd', 0x21, 0, 0, 0, 4,
+                0x22, 0x22, 0x22, 0x22, 0x37, 0, 0, 0, 0,
+            ],
+            buf
+        );
+
+        let mut data = vec![1, 1, 0, 0, 0, 0, 0, 0, 4];
+        data.extend(buf);
+        data.push(3);
+
+        let result = IppParser::new(IppReader::new(io::Cursor::new(data))).parse();
+        assert!(result.is_ok());
+
+        let res = result.ok().unwrap();
+        let attrs = res
+            .attributes
+            .groups_of(DelimiterTag::PrinterAttributes)
+            .next()
+            .unwrap()
+            .attributes();
+        let attr = attrs.get("coll").unwrap();
+        assert_eq!(
+            attr.value(),
+            &IppValue::Collection(BTreeMap::from([("abcd".to_string(), IppValue::Integer(0x2222_2222))]))
+        );
+    }
+}
diff --git a/pseudo_crate/Cargo.lock b/pseudo_crate/Cargo.lock
index 5724eda..7f90d27 100644
--- a/pseudo_crate/Cargo.lock
+++ b/pseudo_crate/Cargo.lock
@@ -249,10 +249,10 @@
  "hex",
  "hound",
  "http 0.2.12",
- "http-body",
+ "http-body 0.4.6",
  "httparse",
  "httpdate",
- "hyper",
+ "hyper 0.14.32",
  "hyper-timeout",
  "icu_capi",
  "icu_casemap",
@@ -273,6 +273,7 @@
  "inotify-sys",
  "intrusive-collections",
  "ipnet",
+ "ipp",
  "is-terminal",
  "itertools 0.14.0",
  "itoa",
@@ -476,7 +477,7 @@
  "toml_datetime",
  "toml_edit 0.22.20",
  "tonic",
- "tower",
+ "tower 0.4.13",
  "tower-layer",
  "tower-service",
  "tracing",
@@ -800,8 +801,8 @@
  "bytes",
  "futures-util",
  "http 0.2.12",
- "http-body",
- "hyper",
+ "http-body 0.4.6",
+ "hyper 0.14.32",
  "itoa",
  "matchit",
  "memchr",
@@ -815,7 +816,7 @@
  "serde_urlencoded",
  "sync_wrapper 0.1.2",
  "tokio",
- "tower",
+ "tower 0.4.13",
  "tower-layer",
  "tower-service",
 ]
@@ -830,7 +831,7 @@
  "bytes",
  "futures-util",
  "http 0.2.12",
- "http-body",
+ "http-body 0.4.6",
  "mime",
  "rustversion",
  "tower-layer",
@@ -2840,6 +2841,29 @@
 ]
 
 [[package]]
+name = "http-body"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
+dependencies = [
+ "bytes",
+ "http 1.1.0",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http 1.1.0",
+ "http-body 1.0.1",
+ "pin-project-lite",
+]
+
+[[package]]
 name = "httparse"
 version = "1.10.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2869,7 +2893,7 @@
  "futures-util",
  "h2 0.3.26",
  "http 0.2.12",
- "http-body",
+ "http-body 0.4.6",
  "httparse",
  "httpdate",
  "itoa",
@@ -2882,18 +2906,72 @@
 ]
 
 [[package]]
+name = "hyper"
+version = "1.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80"
+dependencies = [
+ "bytes",
+ "futures-channel",
+ "futures-util",
+ "http 1.1.0",
+ "http-body 1.0.1",
+ "httparse",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+ "want",
+]
+
+[[package]]
 name = "hyper-timeout"
 version = "0.4.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "bbb958482e8c7be4bc3cf272a766a2b0bf1a6755e7a6ae777f017a31d11b13b1"
 dependencies = [
- "hyper",
+ "hyper 0.14.32",
  "pin-project-lite",
  "tokio",
  "tokio-io-timeout",
 ]
 
 [[package]]
+name = "hyper-tls"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
+dependencies = [
+ "bytes",
+ "http-body-util",
+ "hyper 1.6.0",
+ "hyper-util",
+ "native-tls",
+ "tokio",
+ "tokio-native-tls",
+ "tower-service",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df2dcfbe0677734ab2f3ffa7fa7bfd4706bfdc1ef393f2ee30184aed67e631b4"
+dependencies = [
+ "bytes",
+ "futures-channel",
+ "futures-util",
+ "http 1.1.0",
+ "http-body 1.0.1",
+ "hyper 1.6.0",
+ "pin-project-lite",
+ "socket2",
+ "tokio",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
 name = "iana-time-zone"
 version = "0.1.61"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3510,6 +3588,27 @@
 checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
 
 [[package]]
+name = "ipp"
+version = "5.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0a74ba7383ea538b5c356323681ec5f4208eedf502e4e13886c82cf65af636ca"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "enum-as-inner",
+ "enum-primitive-derive",
+ "futures-executor",
+ "futures-util",
+ "http 1.1.0",
+ "log",
+ "native-tls",
+ "num-traits",
+ "reqwest",
+ "thiserror 2.0.11",
+ "tokio-util",
+]
+
+[[package]]
 name = "is-terminal"
 version = "0.4.16"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4263,6 +4362,23 @@
 ]
 
 [[package]]
+name = "native-tls"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8614eb2c83d59d1c8cc974dd3f920198647674a0a035e1af1fa58707e317466"
+dependencies = [
+ "libc 0.2.161",
+ "log",
+ "openssl",
+ "openssl-probe",
+ "openssl-sys",
+ "schannel",
+ "security-framework",
+ "security-framework-sys",
+ "tempfile",
+]
+
+[[package]]
 name = "nix"
 version = "0.26.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4532,6 +4648,12 @@
 ]
 
 [[package]]
+name = "openssl-probe"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
+
+[[package]]
 name = "openssl-sys"
 version = "0.9.104"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5340,6 +5462,48 @@
 ]
 
 [[package]]
+name = "reqwest"
+version = "0.12.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43e734407157c3c2034e0258f5e4473ddb361b1e85f95a66690d67264d7cd1da"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "futures-core",
+ "futures-util",
+ "http 1.1.0",
+ "http-body 1.0.1",
+ "http-body-util",
+ "hyper 1.6.0",
+ "hyper-tls",
+ "hyper-util",
+ "ipnet",
+ "js-sys",
+ "log",
+ "mime",
+ "native-tls",
+ "once_cell",
+ "percent-encoding",
+ "pin-project-lite",
+ "rustls-pemfile",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "sync_wrapper 1.0.2",
+ "tokio",
+ "tokio-native-tls",
+ "tokio-util",
+ "tower 0.5.2",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "wasm-streams",
+ "web-sys",
+ "windows-registry",
+]
+
+[[package]]
 name = "ring"
 version = "0.16.20"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5463,6 +5627,21 @@
 ]
 
 [[package]]
+name = "rustls-pemfile"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
+dependencies = [
+ "rustls-pki-types",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "917ce264624a4b4db1c364dcc35bfca9ded014d0a958cd47ad3e960e988ea51c"
+
+[[package]]
 name = "rustversion"
 version = "1.0.20"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5539,6 +5718,15 @@
 ]
 
 [[package]]
+name = "schannel"
+version = "0.1.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d"
+dependencies = [
+ "windows-sys 0.59.0",
+]
+
+[[package]]
 name = "scoped-tls"
 version = "1.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5563,6 +5751,29 @@
 ]
 
 [[package]]
+name = "security-framework"
+version = "2.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
+dependencies = [
+ "bitflags 2.9.0",
+ "core-foundation 0.9.4",
+ "core-foundation-sys",
+ "libc 0.2.161",
+ "security-framework-sys",
+]
+
+[[package]]
+name = "security-framework-sys"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32"
+dependencies = [
+ "core-foundation-sys",
+ "libc 0.2.161",
+]
+
+[[package]]
 name = "semver"
 version = "1.0.26"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6051,6 +6262,9 @@
 version = "1.0.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+dependencies = [
+ "futures-core",
+]
 
 [[package]]
 name = "synstructure"
@@ -6340,6 +6554,16 @@
 ]
 
 [[package]]
+name = "tokio-native-tls"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
+dependencies = [
+ "native-tls",
+ "tokio",
+]
+
+[[package]]
 name = "tokio-openssl"
 version = "0.6.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6382,6 +6606,7 @@
 dependencies = [
  "bytes",
  "futures-core",
+ "futures-io",
  "futures-sink",
  "pin-project-lite",
  "tokio",
@@ -6465,15 +6690,15 @@
  "bytes",
  "h2 0.3.26",
  "http 0.2.12",
- "http-body",
- "hyper",
+ "http-body 0.4.6",
+ "hyper 0.14.32",
  "hyper-timeout",
  "percent-encoding",
  "pin-project",
  "prost",
  "tokio",
  "tokio-stream",
- "tower",
+ "tower 0.4.13",
  "tower-layer",
  "tower-service",
  "tracing",
@@ -6500,6 +6725,21 @@
 ]
 
 [[package]]
+name = "tower"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project-lite",
+ "sync_wrapper 1.0.2",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
 name = "tower-layer"
 version = "0.3.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -7117,6 +7357,18 @@
 ]
 
 [[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.43"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61e9300f63a621e96ed275155c108eb6f843b6a26d053f122ab69724559dc8ed"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
 name = "wasm-bindgen-macro"
 version = "0.2.93"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -7146,6 +7398,19 @@
 checksum = "c62a0a307cb4a311d3a07867860911ca130c3494e8c2719593806c08bc5d0484"
 
 [[package]]
+name = "wasm-streams"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e072d4e72f700fb3443d8fe94a39315df013eef1104903cdb0a2abd322bbecd"
+dependencies = [
+ "futures-util",
+ "js-sys",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
+[[package]]
 name = "wayland-backend"
 version = "0.3.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -7287,6 +7552,36 @@
 ]
 
 [[package]]
+name = "windows-registry"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e400001bb720a623c1c69032f8e3e4cf09984deec740f007dd2b03ec864804b0"
+dependencies = [
+ "windows-result",
+ "windows-strings",
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-result"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
+dependencies = [
+ "windows-result",
+ "windows-targets 0.52.6",
+]
+
+[[package]]
 name = "windows-sys"
 version = "0.45.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/pseudo_crate/Cargo.toml b/pseudo_crate/Cargo.toml
index 0c43dcf..99224d5 100644
--- a/pseudo_crate/Cargo.toml
+++ b/pseudo_crate/Cargo.toml
@@ -183,6 +183,7 @@
 inotify-sys = "=0.1.5"
 intrusive-collections = "=0.9.7"
 ipnet = "=2.11.0"
+ipp = "=5.2.0"
 is-terminal = "=0.4.16"
 itertools = "=0.14.0"
 itoa = "=1.0.15"
diff --git a/pseudo_crate/crate-list.txt b/pseudo_crate/crate-list.txt
index 2224c03..7a0c031 100644
--- a/pseudo_crate/crate-list.txt
+++ b/pseudo_crate/crate-list.txt
@@ -176,6 +176,7 @@
 instant
 intrusive-collections
 ipnet
+ipp
 is-terminal
 itertools
 itoa