diff --git a/Cargo.lock b/Cargo.lock index 122f2b4..742956b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -345,6 +345,12 @@ dependencies = [ "syn 2.0.87", ] +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "digest" version = "0.10.7" @@ -1197,6 +1203,16 @@ dependencies = [ "zerocopy-derive", ] +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "proc-macro-error" version = "1.0.4" @@ -1275,6 +1291,7 @@ dependencies = [ "opentelemetry-proto", "opentelemetry_sdk", "pin-project", + "pretty_assertions", "prost", "reqwest", "serde", @@ -2465,6 +2482,12 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yoke" version = "0.7.4" diff --git a/Cargo.toml b/Cargo.toml index 4341d98..efdf914 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -71,3 +71,4 @@ test-case = "3.3.1" tokio = { version = "1.43.0", features = ["test-util"]} bytes = "1.9.0" reqwest = { version = "0.12.12", default-features = false, features = ["stream"] } +pretty_assertions = "1.4.1" diff --git a/docs/design.md b/docs/design.md index 8b52e02..60fafe0 100644 --- a/docs/design.md +++ b/docs/design.md @@ -7,6 +7,10 @@ Packages published through PyOCI use the `application/pyoci.package.v1` [artifac The image index gets tagged with the package version. This allows multiple build artifacts to be published to the same package version. +`com.pyoci.sha256_digest` is added as an annotation in the `ImageIndex.manifests[]` +containing the digest of the package blob. +This is so we don't need to pull the manifest itself to get the digest of the package. + ```json { "schemaVersion": 2, @@ -22,7 +26,8 @@ This allows multiple build artifacts to be published to the same package version "os": "any" }, "annotations": { - "org.opencontainers.image.created":"2024-11-20T20:23:36Z" + "org.opencontainers.image.created":"2024-11-20T20:23:36Z", + "com.pyoci.sha256_digest": "b7513fb69106a855b69153582dec476677b3c79f4a13cfee6fb7a356cfa754c0" } } ], @@ -34,6 +39,11 @@ This allows multiple build artifacts to be published to the same package version ## Image Manifest +When a package is published with `PyOci :: Label :: :: ` classifiers, +the key/value pair will be added to the ImageManifest annotations. + +'org.opencontainers.image.created' will always be added. + ```json { "schemaVersion": 2, @@ -52,6 +62,8 @@ This allows multiple build artifacts to be published to the same package version } ], "annotations": { + "org.opencontainers.image.description": "Published using PyOCI as part of the examples.", + "org.opencontainers.image.url": "https://github.com/allexveldman/pyoci", "org.opencontainers.image.created":"2024-11-20T20:23:36Z" } } diff --git a/src/app.rs b/src/app.rs index 5fdaadd..a439423 100644 --- a/src/app.rs +++ b/src/app.rs @@ -13,7 +13,7 @@ use http::{header::CACHE_CONTROL, HeaderValue, StatusCode}; use serde::{ser::SerializeMap, Serialize, Serializer}; use tracing::{debug, info_span, Instrument}; -use crate::{package, pyoci::PyOciError, templates, PyOci}; +use crate::{package::Package, pyoci::PyOciError, templates, PyOci}; #[derive(Debug)] // Custom error type to translate between anyhow/axum @@ -155,7 +155,7 @@ async fn list_package( headers: HeaderMap, Path((registry, namespace, package_name)): Path<(String, String, String)>, ) -> Result, AppError> { - let package = package::new(®istry, &namespace, &package_name); + let package = Package::new(®istry, &namespace, &package_name); let mut client = PyOci::new(package.registry()?, get_auth(&headers))?; // Fetch at most 100 package versions @@ -207,7 +207,7 @@ async fn list_package_json( headers: HeaderMap, Path((registry, namespace, package_name)): Path<(String, String, String)>, ) -> Result, AppError> { - let package = package::new(®istry, &namespace, &package_name); + let package = Package::new(®istry, &namespace, &package_name); let mut client = PyOci::new(package.registry()?, get_auth(&headers))?; let versions = client.list_package_versions(&package).await?; @@ -228,7 +228,7 @@ async fn download_package( Path((registry, namespace, _distribution, filename)): Path<(String, String, String, String)>, headers: HeaderMap, ) -> Result { - let package = package::from_filename(®istry, &namespace, &filename)?; + let package = Package::from_filename(®istry, &namespace, &filename)?; let mut client = PyOci::new(package.registry()?, get_auth(&headers))?; let data = client @@ -256,7 +256,7 @@ async fn delete_package_version( Path((registry, namespace, name, version)): Path<(String, String, String, String)>, headers: HeaderMap, ) -> Result { - let package = package::new(®istry, &namespace, &name).with_oci_file(&version, ""); + let package = Package::new(®istry, &namespace, &name).with_oci_file(&version, ""); let mut client = PyOci::new(package.registry()?, get_auth(&headers))?; client.delete_package_version(&package).await?; @@ -275,11 +275,16 @@ async fn publish_package( ) -> Result { let form_data = UploadForm::from_multipart(multipart).await?; - let package = package::from_filename(®istry, &namespace, &form_data.filename)?; + let package = Package::from_filename(®istry, &namespace, &form_data.filename)?; let mut client = PyOci::new(package.registry()?, get_auth(&headers))?; client - .publish_package_file(&package, form_data.content, form_data.labels) + .publish_package_file( + &package, + form_data.content, + form_data.labels, + form_data.sha256, + ) .await?; Ok("Published".into()) } @@ -305,6 +310,7 @@ struct UploadForm { filename: String, content: Vec, labels: HashMap, + sha256: Option, } impl UploadForm { @@ -316,6 +322,7 @@ impl UploadForm { let mut protocol_version = None; let mut content = None; let mut filename = None; + let mut sha256 = None; let mut labels = HashMap::new(); while let Some(field) = multipart.next_field().await? { @@ -338,6 +345,7 @@ impl UploadForm { }; } } + "sha256_digest" => sha256 = Some(field.text().await?), name => debug!("Discarding field '{name}': {}", field.text().await?), } } @@ -414,6 +422,7 @@ impl UploadForm { filename, content: content.into(), labels, + sha256, }) } } @@ -427,12 +436,12 @@ mod tests { use axum::{ body::{to_bytes, Body}, + extract::FromRequest, http::Request, }; use bytes::Bytes; use http::HeaderValue; use indoc::formatdoc; - use mockito::Matcher; use oci_spec::{ distribution::{TagList, TagListBuilder}, image::{ @@ -440,7 +449,7 @@ mod tests { ImageManifestBuilder, Os, PlatformBuilder, }, }; - use serde_json::from_str; + use pretty_assertions::assert_eq; use tower::ServiceExt; #[test] @@ -460,145 +469,79 @@ mod tests { } #[tokio::test] - async fn cache_control_unmatched() { - let router = router(None, 50_000_000); - - let req = Request::builder() - .method("GET") - .uri("/foo") - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(req).await.unwrap(); - - assert_eq!(response.status(), StatusCode::NOT_FOUND); - assert_eq!( - response.headers().get("Cache-Control"), - Some(&HeaderValue::from_str("max-age=604800, public").unwrap()) - ); - } - - #[tokio::test] - async fn cache_control_root() { - let router = router(None, 50_000_000); - - let req = Request::builder() - .method("GET") - .uri("/") - .body(Body::empty()) - .unwrap(); - let response = router.oneshot(req).await.unwrap(); - - assert_eq!(response.status(), StatusCode::SEE_OTHER); - assert_eq!( - response.headers().get("Cache-Control"), - Some(&HeaderValue::from_str("max-age=604800, public").unwrap()) - ); - } - - #[tokio::test] - async fn publish_package_body_limit() { - let router = router(None, 10); - - let form = "Exceeds max body limit"; - let req = Request::builder() - .method("POST") - .uri("/pypi/pytest/") - .header("Content-Type", "multipart/form-data; boundary=foobar") - .body(form.to_string()) - .unwrap(); - let response = router.oneshot(req).await.unwrap(); - - assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); - } - - #[tokio::test] - async fn publish_package_missing_action() { - let router = router(None, 50_000_000); - + async fn upload_form_missing_action() { let form = "--foobar\r\n\ Content-Disposition: form-data; name=\"submit-name\"\r\n\ \r\n\ Larry\r\n\ --foobar--\r\n"; - let req = Request::builder() + let req: Request = Request::builder() .method("POST") .uri("/pypi/pytest/") .header("Content-Type", "multipart/form-data; boundary=foobar") - .body(form.to_string()) + .body(form.to_string().into()) .unwrap(); - let response = router.oneshot(req).await.unwrap(); - - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - let body = String::from_utf8( - to_bytes(response.into_body(), usize::MAX) - .await - .unwrap() - .into(), - ) - .unwrap(); - assert_eq!(&body, "Missing ':action' form-field"); + let multipart = Multipart::from_request(req, &()).await.unwrap(); + + let result = UploadForm::from_multipart(multipart) + .await + .expect_err("Expected Error") + .downcast::() + .expect("Expected PyOciError"); + assert_eq!(result.status, StatusCode::BAD_REQUEST); + assert_eq!(result.message, "Missing ':action' form-field"); } #[tokio::test] - async fn publish_package_invalid_action() { - let router = router(None, 50_000_000); - + async fn upload_form_invalid_action() { let form = "--foobar\r\n\ Content-Disposition: form-data; name=\":action\"\r\n\ \r\n\ not-file_download\r\n\ --foobar--\r\n"; - let req = Request::builder() + let req: Request = Request::builder() .method("POST") .uri("/pypi/pytest/") .header("Content-Type", "multipart/form-data; boundary=foobar") - .body(form.to_string()) + .body(form.to_string().into()) .unwrap(); - let response = router.oneshot(req).await.unwrap(); - - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - let body = String::from_utf8( - to_bytes(response.into_body(), usize::MAX) - .await - .unwrap() - .into(), - ) - .unwrap(); - assert_eq!(&body, "Invalid ':action' form-field"); + let multipart = Multipart::from_request(req, &()).await.unwrap(); + + let result = UploadForm::from_multipart(multipart) + .await + .expect_err("Expected Error") + .downcast::() + .expect("Expected PyOciError"); + assert_eq!(result.status, StatusCode::BAD_REQUEST); + assert_eq!(result.message, "Invalid ':action' form-field"); } #[tokio::test] - async fn publish_package_missing_protocol_version() { - let router = router(None, 50_000_000); - + async fn upload_form_missing_protocol_version() { let form = "--foobar\r\n\ Content-Disposition: form-data; name=\":action\"\r\n\ \r\n\ file_upload\r\n\ --foobar--\r\n"; - let req = Request::builder() + let req: Request = Request::builder() .method("POST") .uri("/pypi/pytest/") .header("Content-Type", "multipart/form-data; boundary=foobar") - .body(form.to_string()) + .body(form.to_string().into()) .unwrap(); - let response = router.oneshot(req).await.unwrap(); - - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - let body = String::from_utf8( - to_bytes(response.into_body(), usize::MAX) - .await - .unwrap() - .into(), - ) - .unwrap(); - assert_eq!(&body, "Missing 'protocol_version' form-field"); + let multipart = Multipart::from_request(req, &()).await.unwrap(); + + let result = UploadForm::from_multipart(multipart) + .await + .expect_err("Expected Error") + .downcast::() + .expect("Expected PyOciError"); + assert_eq!(result.status, StatusCode::BAD_REQUEST); + assert_eq!(result.message, "Missing 'protocol_version' form-field"); } #[tokio::test] - async fn publish_package_invalid_protocol_version() { - let router = router(None, 50_000_000); - + async fn upload_form_invalid_protocol_version() { let form = "--foobar\r\n\ Content-Disposition: form-data; name=\":action\"\r\n\ \r\n\ @@ -608,29 +551,25 @@ mod tests { \r\n\ 2\r\n\ --foobar--\r\n"; - let req = Request::builder() + let req: Request = Request::builder() .method("POST") .uri("/pypi/pytest/") .header("Content-Type", "multipart/form-data; boundary=foobar") - .body(form.to_string()) + .body(form.to_string().into()) .unwrap(); - let response = router.oneshot(req).await.unwrap(); - - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - let body = String::from_utf8( - to_bytes(response.into_body(), usize::MAX) - .await - .unwrap() - .into(), - ) - .unwrap(); - assert_eq!(&body, "Invalid 'protocol_version' form-field"); + let multipart = Multipart::from_request(req, &()).await.unwrap(); + + let result = UploadForm::from_multipart(multipart) + .await + .expect_err("Expected Error") + .downcast::() + .expect("Expected PyOciError"); + assert_eq!(result.status, StatusCode::BAD_REQUEST); + assert_eq!(result.message, "Invalid 'protocol_version' form-field"); } #[tokio::test] - async fn publish_package_missing_content() { - let router = router(None, 50_000_000); - + async fn upload_form_missing_content() { let form = "--foobar\r\n\ Content-Disposition: form-data; name=\":action\"\r\n\ \r\n\ @@ -640,29 +579,25 @@ mod tests { \r\n\ 1\r\n\ --foobar--\r\n"; - let req = Request::builder() + let req: Request = Request::builder() .method("POST") .uri("/pypi/pytest/") .header("Content-Type", "multipart/form-data; boundary=foobar") - .body(form.to_string()) + .body(form.to_string().into()) .unwrap(); - let response = router.oneshot(req).await.unwrap(); - - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - let body = String::from_utf8( - to_bytes(response.into_body(), usize::MAX) - .await - .unwrap() - .into(), - ) - .unwrap(); - assert_eq!(&body, "Missing 'content' form-field"); + let multipart = Multipart::from_request(req, &()).await.unwrap(); + + let result = UploadForm::from_multipart(multipart) + .await + .expect_err("Expected Error") + .downcast::() + .expect("Expected PyOciError"); + assert_eq!(result.status, StatusCode::BAD_REQUEST); + assert_eq!(result.message, "Missing 'content' form-field"); } #[tokio::test] - async fn publish_package_empty_content() { - let router = router(None, 50_000_000); - + async fn upload_form_empty_content() { let form = "--foobar\r\n\ Content-Disposition: form-data; name=\":action\"\r\n\ \r\n\ @@ -676,29 +611,25 @@ mod tests { \r\n\ \r\n\ --foobar--\r\n"; - let req = Request::builder() + let req: Request = Request::builder() .method("POST") .uri("/pypi/pytest/") .header("Content-Type", "multipart/form-data; boundary=foobar") - .body(form.to_string()) + .body(form.to_string().into()) .unwrap(); - let response = router.oneshot(req).await.unwrap(); - - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - let body = String::from_utf8( - to_bytes(response.into_body(), usize::MAX) - .await - .unwrap() - .into(), - ) - .unwrap(); - assert_eq!(&body, "No 'content' provided"); + let multipart = Multipart::from_request(req, &()).await.unwrap(); + + let result = UploadForm::from_multipart(multipart) + .await + .expect_err("Expected Error") + .downcast::() + .expect("Expected PyOciError"); + assert_eq!(result.status, StatusCode::BAD_REQUEST); + assert_eq!(result.message, "No 'content' provided"); } #[tokio::test] - async fn publish_package_content_missing_filename() { - let router = router(None, 50_000_000); - + async fn upload_form_content_missing_filename() { let form = "--foobar\r\n\ Content-Disposition: form-data; name=\":action\"\r\n\ \r\n\ @@ -712,29 +643,28 @@ mod tests { \r\n\ someawesomepackagedata\r\n\ --foobar--\r\n"; - let req = Request::builder() + let req: Request = Request::builder() .method("POST") .uri("/pypi/pytest/") .header("Content-Type", "multipart/form-data; boundary=foobar") - .body(form.to_string()) + .body(form.to_string().into()) .unwrap(); - let response = router.oneshot(req).await.unwrap(); - - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - let body = String::from_utf8( - to_bytes(response.into_body(), usize::MAX) - .await - .unwrap() - .into(), - ) - .unwrap(); - assert_eq!(&body, "'content' form-field is missing a 'filename'"); + let multipart = Multipart::from_request(req, &()).await.unwrap(); + + let result = UploadForm::from_multipart(multipart) + .await + .expect_err("Expected Error") + .downcast::() + .expect("Expected PyOciError"); + assert_eq!(result.status, StatusCode::BAD_REQUEST); + assert_eq!( + result.message, + "'content' form-field is missing a 'filename'" + ); } #[tokio::test] - async fn publish_package_content_filename_empty() { - let router = router(None, 50_000_000); - + async fn upload_form_content_filename_empty() { let form = "--foobar\r\n\ Content-Disposition: form-data; name=\":action\"\r\n\ \r\n\ @@ -748,29 +678,62 @@ mod tests { \r\n\ someawesomepackagedata\r\n\ --foobar--\r\n"; - let req = Request::builder() + let req: Request = Request::builder() .method("POST") .uri("/pypi/pytest/") .header("Content-Type", "multipart/form-data; boundary=foobar") - .body(form.to_string()) + .body(form.to_string().into()) .unwrap(); - let response = router.oneshot(req).await.unwrap(); - - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - let body = String::from_utf8( - to_bytes(response.into_body(), usize::MAX) - .await - .unwrap() - .into(), - ) - .unwrap(); - assert_eq!(&body, "No 'filename' provided"); + let multipart = Multipart::from_request(req, &()).await.unwrap(); + + let result = UploadForm::from_multipart(multipart) + .await + .expect_err("Expected Error") + .downcast::() + .expect("Expected PyOciError"); + assert_eq!(result.status, StatusCode::BAD_REQUEST); + assert_eq!(result.message, "No 'filename' provided"); } #[tokio::test] - async fn publish_package_content_filename_invalid() { - let router = router(None, 50_000_000); + /// Minimal valid form + async fn upload_form() { + let form = "--foobar\r\n\ + Content-Disposition: form-data; name=\":action\"\r\n\ + \r\n\ + file_upload\r\n\ + --foobar\r\n\ + Content-Disposition: form-data; name=\"protocol_version\"\r\n\ + \r\n\ + 1\r\n\ + --foobar\r\n\ + Content-Disposition: form-data; name=\"content\"; filename=\"foobar-1.0.0.tar.gz\"\r\n\ + \r\n\ + someawesomepackagedata\r\n\ + --foobar--\r\n"; + let req: Request = Request::builder() + .method("POST") + .uri("/pypi/pytest/") + .header("Content-Type", "multipart/form-data; boundary=foobar") + .body(form.to_string().into()) + .unwrap(); + let multipart = Multipart::from_request(req, &()).await.unwrap(); + + let result = UploadForm::from_multipart(multipart) + .await + .expect("Valid Form"); + assert_eq!(result.filename, "foobar-1.0.0.tar.gz"); + assert_eq!( + result.content, + String::from("someawesomepackagedata").into_bytes() + ); + assert_eq!(result.labels, HashMap::new()); + assert_eq!(result.sha256, None); + } + #[tokio::test] + /// Check if we can extract "PyOci :: Label :: " classifiers + async fn upload_form_labels() { let form = "--foobar\r\n\ Content-Disposition: form-data; name=\":action\"\r\n\ \r\n\ @@ -780,279 +743,100 @@ mod tests { \r\n\ 1\r\n\ --foobar\r\n\ - Content-Disposition: form-data; name=\"content\"; filename=\".env\"\r\n\ + Content-Disposition: form-data; name=\"classifiers\"\r\n\ + \r\n\ + Programming Language :: Python :: 3.13\r\n\ + --foobar\r\n\ + Content-Disposition: form-data; name=\"classifiers\"\r\n\ + \r\n\ + PyOCI :: Label :: org.opencontainers.image.url :: https://github.com/allexveldman/pyoci\r\n\ + --foobar\r\n\ + Content-Disposition: form-data; name=\"classifiers\"\r\n\ + \r\n\ + PyOCI :: Label :: other-label :: foobar\r\n\ + --foobar\r\n\ + Content-Disposition: form-data; name=\"content\"; filename=\"foobar-1.0.0.tar.gz\"\r\n\ \r\n\ someawesomepackagedata\r\n\ --foobar--\r\n"; - let req = Request::builder() + let req: Request = Request::builder() .method("POST") .uri("/pypi/pytest/") .header("Content-Type", "multipart/form-data; boundary=foobar") - .body(form.to_string()) + .body(form.to_string().into()) + .unwrap(); + let multipart = Multipart::from_request(req, &()).await.unwrap(); + + let result = UploadForm::from_multipart(multipart) + .await + .expect("Valid Form"); + assert_eq!( + result.labels, + HashMap::from([ + ( + "org.opencontainers.image.url".to_string(), + "https://github.com/allexveldman/pyoci".to_string() + ), + ("other-label".to_string(), "foobar".to_string()) + ]) + ); + } + + #[tokio::test] + async fn cache_control_unmatched() { + let router = router(None, 50_000_000); + + let req = Request::builder() + .method("GET") + .uri("/foo") + .body(Body::empty()) .unwrap(); let response = router.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - let body = String::from_utf8( - to_bytes(response.into_body(), usize::MAX) - .await - .unwrap() - .into(), - ) - .unwrap(); - assert_eq!(&body, "Unkown filetype '.env'"); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_eq!( + response.headers().get("Cache-Control"), + Some(&HeaderValue::from_str("max-age=604800, public").unwrap()) + ); } #[tokio::test] - async fn publish_package() { - let mut server = mockito::Server::new_async().await; - let url = server.url(); - let encoded_url = urlencoding::encode(&url).into_owned(); + async fn cache_control_root() { + let router = router(None, 50_000_000); - // Set timestamp to fixed time - crate::mocks::set_timestamp(1732134216); + let req = Request::builder() + .method("GET") + .uri("/") + .body(Body::empty()) + .unwrap(); + let response = router.oneshot(req).await.unwrap(); - let mocks = vec![ - // Mock the server, in order of expected requests - // IndexManifest does not yet exist - server - .mock("GET", "/v2/mockserver/foobar/manifests/1.0.0") - .with_status(404) - .create_async() - .await, - // HEAD request to check if blob exists for: - // - layer - // - config - server - .mock( - "HEAD", - mockito::Matcher::Regex(r"/v2/mockserver/foobar/blobs/.+".to_string()), - ) - .expect(2) - .with_status(404) - .create_async() - .await, - // POST request with blob for layer - server - .mock("POST", "/v2/mockserver/foobar/blobs/uploads/") - .with_status(202) // ACCEPTED - .with_header( - "Location", - &format!("{url}/v2/mockserver/foobar/blobs/uploads/1?_state=uploading"), - ) - .create_async() - .await, - server - .mock("PUT", "/v2/mockserver/foobar/blobs/uploads/1?_state=uploading&digest=sha256%3Ab7513fb69106a855b69153582dec476677b3c79f4a13cfee6fb7a356cfa754c0") - .with_status(201) // CREATED - .create_async() - .await, - // POST request with blob for config - server - .mock("POST", "/v2/mockserver/foobar/blobs/uploads/") - .with_status(202) // ACCEPTED - .with_header( - "Location", - &format!("{url}/v2/mockserver/foobar/blobs/uploads/2?_state=uploading"), - ) - .create_async() - .await, - server - .mock("PUT", "/v2/mockserver/foobar/blobs/uploads/2?_state=uploading&digest=sha256%3A44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a") - .with_status(201) // CREATED - .create_async() - .await, - // PUT request to create Manifest - server - .mock("PUT", "/v2/mockserver/foobar/manifests/sha256:e281659053054737342fd0c74a7605c4678c227db1e073260b44f845dfdf535a") - .match_header("Content-Type", "application/vnd.oci.image.manifest.v1+json") - .match_body(r#"{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","artifactType":"application/pyoci.package.v1","config":{"mediaType":"application/vnd.oci.empty.v1+json","digest":"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","size":2},"layers":[{"mediaType":"application/pyoci.package.v1","digest":"sha256:b7513fb69106a855b69153582dec476677b3c79f4a13cfee6fb7a356cfa754c0","size":22}],"annotations":{"org.opencontainers.image.created":"2024-11-20T20:23:36Z"}}"#) - .with_status(201) // CREATED - .create_async() - .await, - // PUT request to create Index - server - .mock("PUT", "/v2/mockserver/foobar/manifests/1.0.0") - .match_header("Content-Type", "application/vnd.oci.image.index.v1+json") - .match_body(r#"{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","artifactType":"application/pyoci.package.v1","manifests":[{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"sha256:e281659053054737342fd0c74a7605c4678c227db1e073260b44f845dfdf535a","size":496,"annotations":{"org.opencontainers.image.created":"2024-11-20T20:23:36Z"},"platform":{"architecture":".tar.gz","os":"any"}}],"annotations":{"org.opencontainers.image.created":"2024-11-20T20:23:36Z"}}"#) - .with_status(201) // CREATED - .create_async() - .await, - server - .mock("GET", mockito::Matcher::Any) - .expect(0) - .create_async() - .await, - ]; + assert_eq!(response.status(), StatusCode::SEE_OTHER); + assert_eq!( + response.headers().get("Cache-Control"), + Some(&HeaderValue::from_str("max-age=604800, public").unwrap()) + ); + } - let router = router(None, 50_000_000); + #[tokio::test] + async fn publish_package_body_limit() { + let router = router(None, 10); - let form = "--foobar\r\n\ - Content-Disposition: form-data; name=\":action\"\r\n\ - \r\n\ - file_upload\r\n\ - --foobar\r\n\ - Content-Disposition: form-data; name=\"protocol_version\"\r\n\ - \r\n\ - 1\r\n\ - --foobar\r\n\ - Content-Disposition: form-data; name=\"content\"; filename=\"foobar-1.0.0.tar.gz\"\r\n\ - \r\n\ - someawesomepackagedata\r\n\ - --foobar--\r\n"; + let form = "Exceeds max body limit"; let req = Request::builder() .method("POST") - .uri(format!("/{encoded_url}/mockserver/")) + .uri("/pypi/pytest/") .header("Content-Type", "multipart/form-data; boundary=foobar") .body(form.to_string()) .unwrap(); let response = router.oneshot(req).await.unwrap(); - let status = response.status(); - let body = String::from_utf8( - to_bytes(response.into_body(), usize::MAX) - .await - .unwrap() - .into(), - ) - .unwrap(); - - for mock in mocks { - mock.assert_async().await; - } - assert_eq!(&body, "Published"); - assert_eq!(status, StatusCode::OK); + assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); } #[tokio::test] - async fn publish_package_subpath() { - let mut server = mockito::Server::new_async().await; - let url = server.url(); - let encoded_url = urlencoding::encode(&url).into_owned(); - - // Set timestamp to fixed time - crate::mocks::set_timestamp(1732134216); - - let mocks = vec![ - // Mock the server, in order of expected requests - // IndexManifest does not yet exist - server - .mock("GET", "/v2/mockserver/foobar/manifests/1.0.0") - .with_status(404) - .create_async() - .await, - // HEAD request to check if blob exists for: - // - layer - // - config - server - .mock( - "HEAD", - mockito::Matcher::Regex(r"/v2/mockserver/foobar/blobs/.+".to_string()), - ) - .expect(2) - .with_status(404) - .create_async() - .await, - // POST request with blob for layer - server - .mock("POST", "/v2/mockserver/foobar/blobs/uploads/") - .with_status(202) // ACCEPTED - .with_header( - "Location", - &format!("{url}/v2/mockserver/foobar/blobs/uploads/1?_state=uploading"), - ) - .create_async() - .await, - server - .mock("PUT", "/v2/mockserver/foobar/blobs/uploads/1?_state=uploading&digest=sha256%3Ab7513fb69106a855b69153582dec476677b3c79f4a13cfee6fb7a356cfa754c0") - .with_status(201) // CREATED - .create_async() - .await, - // POST request with blob for config - server - .mock("POST", "/v2/mockserver/foobar/blobs/uploads/") - .with_status(202) // ACCEPTED - .with_header( - "Location", - &format!("{url}/v2/mockserver/foobar/blobs/uploads/2?_state=uploading"), - ) - .create_async() - .await, - server - .mock("PUT", "/v2/mockserver/foobar/blobs/uploads/2?_state=uploading&digest=sha256%3A44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a") - .with_status(201) // CREATED - .create_async() - .await, - // PUT request to create Manifest - server - .mock("PUT", "/v2/mockserver/foobar/manifests/sha256:e281659053054737342fd0c74a7605c4678c227db1e073260b44f845dfdf535a") - .match_header("Content-Type", "application/vnd.oci.image.manifest.v1+json") - .match_request(|request|{ - let request_json = from_str::(&request.utf8_lossy_body().expect("Body value")).expect("Valid manifest"); - request_json == from_str::(r#"{ - "schemaVersion": 2, - "mediaType": "application/vnd.oci.image.manifest.v1+json", - "artifactType": "application/pyoci.package.v1", - "config": { - "mediaType": "application/vnd.oci.empty.v1+json", - "digest": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", - "size": 2 - }, - "layers": [ - { - "mediaType": "application/pyoci.package.v1", - "digest": "sha256:b7513fb69106a855b69153582dec476677b3c79f4a13cfee6fb7a356cfa754c0", - "size": 22 - } - ], - "annotations": { - "org.opencontainers.image.created": "2024-11-20T20:23:36Z" - } - }"#).unwrap() - }) - .with_status(201) // CREATED - .create_async() - .await, - // PUT request to create Index - server - .mock("PUT", "/v2/mockserver/foobar/manifests/1.0.0") - .match_header("Content-Type", "application/vnd.oci.image.index.v1+json") - .match_request(|request|{ - let request_json = from_str::(&request.utf8_lossy_body().expect("Body value")).expect("Valid manifest"); - request_json == from_str::(r#"{ - "schemaVersion": 2, - "mediaType": "application/vnd.oci.image.index.v1+json", - "artifactType": "application/pyoci.package.v1", - "manifests": [ - { - "mediaType": "application/vnd.oci.image.manifest.v1+json", - "digest": "sha256:e281659053054737342fd0c74a7605c4678c227db1e073260b44f845dfdf535a", - "size": 496, - "annotations": { - "org.opencontainers.image.created": "2024-11-20T20:23:36Z" - }, - "platform": { - "architecture": ".tar.gz", - "os": "any" - } - } - ], - "annotations": { - "org.opencontainers.image.created": "2024-11-20T20:23:36Z" - } - }"#).unwrap() - }) - .with_status(201) // CREATED - .create_async() - .await, - server - .mock("GET", mockito::Matcher::Any) - .expect(0) - .create_async() - .await, - ]; - - let router = router(Some("/foo".to_string()), 50_000_000); + async fn publish_package_content_filename_invalid() { + let router = router(None, 50_000_000); let form = "--foobar\r\n\ Content-Disposition: form-data; name=\":action\"\r\n\ @@ -1063,19 +847,19 @@ mod tests { \r\n\ 1\r\n\ --foobar\r\n\ - Content-Disposition: form-data; name=\"content\"; filename=\"foobar-1.0.0.tar.gz\"\r\n\ + Content-Disposition: form-data; name=\"content\"; filename=\".env\"\r\n\ \r\n\ someawesomepackagedata\r\n\ --foobar--\r\n"; let req = Request::builder() .method("POST") - .uri(format!("/foo/{encoded_url}/mockserver/")) + .uri("/pypi/pytest/") .header("Content-Type", "multipart/form-data; boundary=foobar") .body(form.to_string()) .unwrap(); let response = router.oneshot(req).await.unwrap(); - let status = response.status(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body = String::from_utf8( to_bytes(response.into_body(), usize::MAX) .await @@ -1083,16 +867,11 @@ mod tests { .into(), ) .unwrap(); - - for mock in mocks { - mock.assert_async().await; - } - assert_eq!(&body, "Published"); - assert_eq!(status, StatusCode::OK); + assert_eq!(&body, "Unkown filetype '.env'"); } #[tokio::test] - async fn publish_package_with_classifiers() { + async fn publish_package() { let mut server = mockito::Server::new_async().await; let url = server.url(); let encoded_url = urlencoding::encode(&url).into_owned(); @@ -1152,21 +931,8 @@ mod tests { .await, // PUT request to create Manifest server - .mock("PUT", Matcher::Regex("/v2/mockserver/foobar/manifests/sha256:.*".to_string())) + .mock("PUT", "/v2/mockserver/foobar/manifests/sha256:e281659053054737342fd0c74a7605c4678c227db1e073260b44f845dfdf535a") .match_header("Content-Type", "application/vnd.oci.image.manifest.v1+json") - .match_request(|request|{ - // We only expect 1 call to this endpoint, deserialize and match the annotations - // Since the annotations serialize in a random order this can change the sha - // and makes matching the entire body very impractical on failures. - // TODO: refactor so most of this logic can be tested in isolation instead of - // needing a mock server - let request_json = from_str::(&request.utf8_lossy_body().expect("Body value")).expect("Valid manifest"); - request_json.annotations() == &Some(HashMap::from([ - ("org.opencontainers.image.url".to_string(),"https://github.com/allexveldman/pyoci".to_string()), - ("org.opencontainers.image.created".to_string(),"2024-11-20T20:23:36Z".to_string()), - ("other-label".to_string(), "foobar".to_string()), - ])) - }) .with_status(201) // CREATED .create_async() .await, @@ -1174,15 +940,6 @@ mod tests { server .mock("PUT", "/v2/mockserver/foobar/manifests/1.0.0") .match_header("Content-Type", "application/vnd.oci.image.index.v1+json") - .match_request(|request|{ - // We only expect 1 call to this endpoint, deserialize and match the annotations - let request_json = from_str::(&request.utf8_lossy_body().expect("Body value")).expect("Valid manifest"); - request_json.annotations() == &Some(HashMap::from([ - ("org.opencontainers.image.url".to_string(),"https://github.com/allexveldman/pyoci".to_string()), - ("org.opencontainers.image.created".to_string(),"2024-11-20T20:23:36Z".to_string()), - ("other-label".to_string(), "foobar".to_string()), - ])) - }) .with_status(201) // CREATED .create_async() .await, @@ -1204,18 +961,6 @@ mod tests { \r\n\ 1\r\n\ --foobar\r\n\ - Content-Disposition: form-data; name=\"classifiers\"\r\n\ - \r\n\ - Programming Language :: Python :: 3.13\r\n\ - --foobar\r\n\ - Content-Disposition: form-data; name=\"classifiers\"\r\n\ - \r\n\ - PyOCI :: Label :: org.opencontainers.image.url :: https://github.com/allexveldman/pyoci\r\n\ - --foobar\r\n\ - Content-Disposition: form-data; name=\"classifiers\"\r\n\ - \r\n\ - PyOCI :: Label :: other-label :: foobar\r\n\ - --foobar\r\n\ Content-Disposition: form-data; name=\"content\"; filename=\"foobar-1.0.0.tar.gz\"\r\n\ \r\n\ someawesomepackagedata\r\n\ @@ -1245,143 +990,20 @@ mod tests { } #[tokio::test] - async fn publish_package_already_exists() { - let mut server = mockito::Server::new_async().await; - let url = server.url(); - let encoded_url = urlencoding::encode(&url).into_owned(); - - let index = ImageIndexBuilder::default() - .schema_version(2_u32) - .media_type("application/vnd.oci.image.index.v1+json") - .artifact_type("application/pyoci.package.v1") - .manifests(vec![ - DescriptorBuilder::default() - .media_type("application/vnd.oci.image.manifest.v1+json") - .digest(digest("FooBar")) - .size(6_u64) - .platform( - PlatformBuilder::default() - .architecture(Arch::Other(".whl".to_string())) - .os(Os::Other("any".to_string())) - .build() - .unwrap(), - ) - .build() - .unwrap(), - DescriptorBuilder::default() - .media_type("application/vnd.oci.image.manifest.v1+json") - .digest(digest("manifest-digest")) // sha256:bc669544845542470042912a0f61b90499ffc2320b45ea66b0be50439c5aab19 - .size(6_u64) - .platform( - PlatformBuilder::default() - .architecture(Arch::Other(".tar.gz".to_string())) - .os(Os::Other("any".to_string())) - .build() - .unwrap(), - ) - .build() - .unwrap(), - ]) - .build() - .unwrap(); - - // Mock the server, in order of expected requests - let mocks = vec![ - // IndexManifest exists - server - .mock("GET", "/v2/mockserver/foobar/manifests/1.0.0") - .with_status(200) - .with_header("content-type", "application/vnd.oci.image.index.v1+json") - .with_body(serde_json::to_string::(&index).unwrap()) - .create_async() - .await, - server - .mock("GET", mockito::Matcher::Any) - .expect(0) - .create_async() - .await, - ]; - - let router = router(None, 50_000_000); - - let form = "--foobar\r\n\ - Content-Disposition: form-data; name=\":action\"\r\n\ - \r\n\ - file_upload\r\n\ - --foobar\r\n\ - Content-Disposition: form-data; name=\"protocol_version\"\r\n\ - \r\n\ - 1\r\n\ - --foobar\r\n\ - Content-Disposition: form-data; name=\"content\"; filename=\"foobar-1.0.0.tar.gz\"\r\n\ - \r\n\ - someawesomepackagedata\r\n\ - --foobar--\r\n"; - let req = Request::builder() - .method("POST") - .uri(format!("/{encoded_url}/mockserver/")) - .header("Content-Type", "multipart/form-data; boundary=foobar") - .body(form.to_string()) - .unwrap(); - let response = router.oneshot(req).await.unwrap(); - - let status = response.status(); - let body = String::from_utf8( - to_bytes(response.into_body(), usize::MAX) - .await - .unwrap() - .into(), - ) - .unwrap(); - - for mock in mocks { - mock.assert_async().await; - } - assert_eq!( - &body, - "Platform '.tar.gz' already exists for version '1.0.0'" - ); - assert_eq!(status, StatusCode::CONFLICT); - } - - #[tokio::test] - async fn publish_package_existing_index() { + async fn publish_package_subpath() { let mut server = mockito::Server::new_async().await; let url = server.url(); let encoded_url = urlencoding::encode(&url).into_owned(); - let index = ImageIndexBuilder::default() - .schema_version(2_u32) - .media_type("application/vnd.oci.image.index.v1+json") - .artifact_type("application/pyoci.package.v1") - .manifests(vec![DescriptorBuilder::default() - .media_type("application/vnd.oci.image.manifest.v1+json") - .digest(digest("FooBar")) - .size(6_u64) - .platform( - PlatformBuilder::default() - .architecture(Arch::Other(".whl".to_string())) - .os(Os::Other("any".to_string())) - .build() - .unwrap(), - ) - .build() - .unwrap()]) - .annotations(HashMap::from([( - "org.opencontainers.image.created".to_string(), - "1970-01-01T00:00:00Z".to_string(), - )])) - .build() - .unwrap(); + // Set timestamp to fixed time + crate::mocks::set_timestamp(1732134216); - // Mock the server, in order of expected requests let mocks = vec![ - // IndexManifest exists + // Mock the server, in order of expected requests + // IndexManifest does not yet exist server .mock("GET", "/v2/mockserver/foobar/manifests/1.0.0") - .with_status(200) - .with_header("content-type", "application/vnd.oci.image.index.v1+json") - .with_body(serde_json::to_string::(&index).unwrap()) + .with_status(404) .create_async() .await, // HEAD request to check if blob exists for: @@ -1428,17 +1050,15 @@ mod tests { .await, // PUT request to create Manifest server - .mock("PUT", "/v2/mockserver/foobar/manifests/sha256:89909daa9622152518936752dfcd377c8bc650ff21a02ea5556b47b95ac376fa") + .mock("PUT", "/v2/mockserver/foobar/manifests/sha256:e281659053054737342fd0c74a7605c4678c227db1e073260b44f845dfdf535a") .match_header("Content-Type", "application/vnd.oci.image.manifest.v1+json") - .match_body(r#"{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","artifactType":"application/pyoci.package.v1","config":{"mediaType":"application/vnd.oci.empty.v1+json","digest":"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","size":2},"layers":[{"mediaType":"application/pyoci.package.v1","digest":"sha256:b7513fb69106a855b69153582dec476677b3c79f4a13cfee6fb7a356cfa754c0","size":22}],"annotations":{"org.opencontainers.image.created":"1970-01-01T00:00:00Z"}}"#) .with_status(201) // CREATED .create_async() .await, - // PUT request to update Index + // PUT request to create Index server .mock("PUT", "/v2/mockserver/foobar/manifests/1.0.0") .match_header("Content-Type", "application/vnd.oci.image.index.v1+json") - .match_body(r#"{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","artifactType":"application/pyoci.package.v1","manifests":[{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"sha256:0d749abe1377573493e0df74df8d1282e46967754a1ebc7cc6323923a788ad5c","size":6,"platform":{"architecture":".whl","os":"any"}},{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"sha256:89909daa9622152518936752dfcd377c8bc650ff21a02ea5556b47b95ac376fa","size":496,"annotations":{"org.opencontainers.image.created":"1970-01-01T00:00:00Z"},"platform":{"architecture":".tar.gz","os":"any"}}],"annotations":{"org.opencontainers.image.created":"1970-01-01T00:00:00Z"}}"#) .with_status(201) // CREATED .create_async() .await, @@ -1449,7 +1069,7 @@ mod tests { .await, ]; - let router = router(None, 50_000_000); + let router = router(Some("/foo".to_string()), 50_000_000); let form = "--foobar\r\n\ Content-Disposition: form-data; name=\":action\"\r\n\ @@ -1466,7 +1086,7 @@ mod tests { --foobar--\r\n"; let req = Request::builder() .method("POST") - .uri(format!("/{encoded_url}/mockserver/")) + .uri(format!("/foo/{encoded_url}/mockserver/")) .header("Content-Type", "multipart/form-data; boundary=foobar") .body(form.to_string()) .unwrap(); diff --git a/src/main.rs b/src/main.rs index 8f0f2a0..e487921 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,7 +20,7 @@ mod service; #[cfg(test)] mod mocks; -pub use pyoci::PyOci; +use pyoci::PyOci; use tokio::task::JoinHandle; use std::collections::HashMap; diff --git a/src/package.rs b/src/package.rs index bbf37c3..000567c 100644 --- a/src/package.rs +++ b/src/package.rs @@ -7,12 +7,12 @@ use crate::pyoci::PyOciError; pub trait FileState {} -pub struct WithFile; +pub struct WithFileName; #[derive(Clone)] -pub struct WithoutFile; +pub struct WithoutFileName; -impl FileState for WithFile {} -impl FileState for WithoutFile {} +impl FileState for WithFileName {} +impl FileState for WithoutFileName {} #[derive(Debug, PartialEq, Eq, Clone)] pub struct Package<'a, T: FileState> { @@ -24,64 +24,6 @@ pub struct Package<'a, T: FileState> { _phantom: PhantomData, } -/// Create a Package without version or file information. -pub fn new<'a>(registry: &'a str, namespace: &'a str, name: &'a str) -> Package<'a, WithoutFile> { - let name = name.replace('-', "_"); - Package { - registry, - namespace, - name, - version: None, - arch: None, - _phantom: PhantomData, - } -} - -/// Create a Package parsing a filename into it's components -/// -/// The filename is expected to be normalized, specifically there should be no '-' in any of -/// it's components. -/// ref: https://packaging.python.org/en/latest/specifications/binary-distribution-format/#escaping-and-unicode -pub fn from_filename<'a>( - registry: &'a str, - namespace: &'a str, - filename: &str, -) -> Result> { - if filename.is_empty() { - bail!("Empty filename") - } - let (name, version, arch) = match filename.strip_suffix(".tar.gz") { - Some(rest) => match rest.splitn(2, '-').collect::>()[..] { - [name, version] => (name, version, ".tar.gz"), - _ => Err(PyOciError::from(( - StatusCode::BAD_REQUEST, - format!("Invalid source distribution filename '{}'", filename), - )))?, - }, - None => match filename.ends_with(".whl") { - true => match filename.splitn(3, '-').collect::>()[..] { - [name, version, arch] => (name, version, arch), - _ => Err(PyOciError::from(( - StatusCode::BAD_REQUEST, - format!("Invalid binary distribution filename '{}'", filename), - )))?, - }, - false => Err(PyOciError::from(( - StatusCode::BAD_REQUEST, - format!("Unkown filetype '{}'", filename), - )))?, - }, - }; - Ok(Package { - registry, - namespace, - name: name.to_string(), - version: Some(version.to_string()), - arch: Some(arch.to_string()), - _phantom: PhantomData, - }) -} - impl<'a, T: FileState> Package<'a, T> { /// Add/replace the version and architecture of the package for OCI provided values /// @@ -90,7 +32,7 @@ impl<'a, T: FileState> Package<'a, T> { /// as a tag MUST be at most 128 characters in length and MUST match the following regular expression: /// [a-zA-Z0-9_][a-zA-Z0-9._-]{0,127} /// - pub fn with_oci_file(&self, tag: &str, arch: &str) -> Package<'a, WithFile> { + pub fn with_oci_file(&self, tag: &str, arch: &str) -> Package<'a, WithFileName> { Package { registry: self.registry, namespace: self.namespace, @@ -136,7 +78,71 @@ fn registry_url(registry: &str) -> Result { Ok(url) } -impl Package<'_, WithFile> { +impl Package<'_, WithoutFileName> { + /// Create a Package without version or file information. + pub fn new<'a>( + registry: &'a str, + namespace: &'a str, + name: &'a str, + ) -> Package<'a, WithoutFileName> { + let name = name.replace('-', "_"); + Package { + registry, + namespace, + name, + version: None, + arch: None, + _phantom: PhantomData, + } + } +} + +impl Package<'_, WithFileName> { + /// Create a Package parsing a filename into it's components + /// + /// The filename is expected to be normalized, specifically there should be no '-' in any of + /// it's components. + /// ref: https://packaging.python.org/en/latest/specifications/binary-distribution-format/#escaping-and-unicode + pub fn from_filename<'a>( + registry: &'a str, + namespace: &'a str, + filename: &str, + ) -> Result> { + if filename.is_empty() { + bail!("Empty filename") + } + let (name, version, arch) = match filename.strip_suffix(".tar.gz") { + Some(rest) => match rest.splitn(2, '-').collect::>()[..] { + [name, version] => (name, version, ".tar.gz"), + _ => Err(PyOciError::from(( + StatusCode::BAD_REQUEST, + format!("Invalid source distribution filename '{}'", filename), + )))?, + }, + None => match filename.ends_with(".whl") { + true => match filename.splitn(3, '-').collect::>()[..] { + [name, version, arch] => (name, version, arch), + _ => Err(PyOciError::from(( + StatusCode::BAD_REQUEST, + format!("Invalid binary distribution filename '{}'", filename), + )))?, + }, + false => Err(PyOciError::from(( + StatusCode::BAD_REQUEST, + format!("Unkown filetype '{}'", filename), + )))?, + }, + }; + Ok(Package { + registry, + namespace, + name: name.to_string(), + version: Some(version.to_string()), + arch: Some(arch.to_string()), + _phantom: PhantomData, + }) + } + /// Tag of the package as used for the OCI registry pub fn oci_tag(&self) -> String { // OCI tags are not allowed to contain a "+" character @@ -217,7 +223,7 @@ mod tests { #[test] /// Test if we can get the package OCI name (namespace/name) fn test_info_oci_name() { - let info = new("https://foo.example", "bar", "baz"); + let info = Package::new("https://foo.example", "bar", "baz"); assert_eq!(info.oci_name(), "bar/baz".to_string()); } @@ -227,14 +233,15 @@ mod tests { #[test_case("bar-1.0.0.tar.gz", "1.0.0"; "simple version")] #[test_case("bar-1.0.0.dev4+g1664eb2.d20231017.tar.gz", "1.0.0.dev4-g1664eb2.d20231017"; "full version")] fn test_info_oci_tag(filename: &str, expected: &str) { - let info = from_filename("https://foo.example", "bar", filename).unwrap(); + let info = Package::from_filename("https://foo.example", "bar", filename).unwrap(); assert_eq!(info.oci_tag(), expected.to_string()); } #[test] /// Test if Info.py_uri() url-encodes the registry fn test_info_py_uri() { - let info = from_filename("https://foo.example:4000", "bar", "baz-1.tar.gz").unwrap(); + let info = + Package::from_filename("https://foo.example:4000", "bar", "baz-1.tar.gz").unwrap(); assert_eq!( info.py_uri(), "/foo.example%3A4000/bar/baz/baz-1.tar.gz".to_string() @@ -244,7 +251,7 @@ mod tests { #[test] /// Test Info.with_oci_file() return an Info object with the new version fn test_info_with_oci_file() { - let info = new("https://foo.example", "bar", "baz"); + let info = Package::new("https://foo.example", "bar", "baz"); let info = info.with_oci_file("0.1.pre3-1234.foobar", "tar.gz"); assert_eq!(info.version, Some("0.1.pre3+1234.foobar".to_string())); } @@ -255,7 +262,7 @@ mod tests { #[test_case("baz-2.5.1.dev4+g1664eb2.d20231017.tar.gz"; "sdist full version")] /// Test if we can convert from and to filenames fn test_info_filename(input: &str) { - let obj = from_filename("foo", "bar", input).unwrap(); + let obj = Package::from_filename("foo", "bar", input).unwrap(); assert_eq!(obj.filename(), input); } } diff --git a/src/pyoci.rs b/src/pyoci.rs index 478cb3a..0c60aa2 100644 --- a/src/pyoci.rs +++ b/src/pyoci.rs @@ -15,6 +15,7 @@ use oci_spec::{ }; use reqwest::Response; use serde::Deserialize; +use serde_json::to_string_pretty; use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::str::FromStr; @@ -26,8 +27,8 @@ use crate::mocks::OffsetDateTime; #[cfg(not(test))] use time::OffsetDateTime; -use crate::package::{Package, WithFile, WithoutFile}; -use crate::transport::HttpTransport; +use crate::package::{Package, WithFileName, WithoutFileName}; +use crate::transport::{HttpTransport, Transport}; use crate::ARTIFACT_TYPE; /// Build an URL from a format string while sanitizing the parameters @@ -74,7 +75,7 @@ struct PlatformManifest { } impl PlatformManifest { - fn new(manifest: ImageManifest, package: &Package) -> Self { + fn new(manifest: ImageManifest, package: &Package) -> Self { let platform = PlatformBuilder::default() .architecture(Arch::Other(package.oci_architecture().to_string())) .os(Os::Other("any".to_string())) @@ -168,24 +169,42 @@ pub struct AuthResponse { } /// Client to communicate with the OCI v2 registry -#[derive(Debug, Clone)] -pub struct PyOci { +#[derive(Debug)] +pub struct PyOci { registry: Url, - transport: HttpTransport, + transport: T, } -impl PyOci { +impl PyOci { /// Create a new Client - pub fn new(registry: Url, auth: Option) -> Result { + pub fn new(registry: Url, auth: Option) -> Result> { Ok(PyOci { registry, transport: HttpTransport::new(auth)?, }) } +} +impl Clone for PyOci +where + T: Clone, +{ + fn clone(&self) -> Self { + Self { + registry: self.registry.clone(), + transport: self.transport.clone(), + } + } +} + +/// Create/List/Download/Delete Packages +impl PyOci +where + T: Transport + Clone, +{ pub async fn list_package_versions<'a>( &mut self, - package: &'a Package<'a, WithoutFile>, + package: &'a Package<'a, WithoutFileName>, ) -> Result> { let name = package.oci_name(); let result = self.list_tags(&name).await?; @@ -198,14 +217,14 @@ impl PyOci { /// ref: https://github.com/opencontainers/distribution-spec/blob/main/spec.md#listing-tags pub async fn list_package_files<'a>( &mut self, - package: &'a Package<'a, WithoutFile>, + package: &'a Package<'a, WithoutFileName>, n: usize, - ) -> Result>> { + ) -> Result>> { let name = package.oci_name(); let result = self.list_tags(&name).await?; tracing::debug!("{:?}", result); let tags = result.tags(); - let mut files: Vec> = Vec::new(); + let mut files: Vec> = Vec::new(); let futures = FuturesUnordered::new(); if tags.len() > n { @@ -226,7 +245,7 @@ impl PyOci { futures.push(pyoci.package_info_for_ref(package, &name, tag)); } for result in futures - .collect::>, Error>>>() + .collect::>, Error>>>() .await { files.append(&mut result?); @@ -236,10 +255,10 @@ impl PyOci { async fn package_info_for_ref<'a>( mut self, - package: &'a Package<'a, WithoutFile>, + package: &'a Package<'a, WithoutFileName>, name: &str, reference: &str, - ) -> Result>> { + ) -> Result>> { let manifest = self.pull_manifest(name, reference).await?; let index = match manifest { Some(Manifest::Index(index)) => index, @@ -264,7 +283,7 @@ impl PyOci { // Artifact type is not set, err None => bail!("No artifact type set"), }; - let mut files: Vec> = Vec::new(); + let mut files: Vec> = Vec::new(); for manifest in index.manifests() { match manifest.platform().as_ref().unwrap().architecture() { oci_spec::image::Arch::Other(arch) => { @@ -279,7 +298,7 @@ impl PyOci { pub async fn download_package_file( &mut self, - package: &Package<'_, WithFile>, + package: &Package<'_, WithFileName>, ) -> Result { // Pull index let index = match self @@ -356,22 +375,70 @@ impl PyOci { .await } + /// Construct and publish the manifests and blob provided. + /// + /// The `sha256_digest`, if provided, will be verified against the sha256 of the actual content. + /// + /// The `annotations` will be added to the ImageManifest, mimicking the default docker CLI + /// behaviour. pub async fn publish_package_file( &mut self, - package: &Package<'_, WithFile>, + package: &Package<'_, WithFileName>, file: Vec, mut annotations: HashMap, + sha256_digest: Option, ) -> Result<()> { let name = package.oci_name(); let tag = package.oci_tag(); let layer = Blob::new(file, ARTIFACT_TYPE); - annotations.insert( + + let package_digest = verify_digest(&layer, sha256_digest)?; + + // Annotations added to the manifest descriptor in the ImageIndex + // We're adding the digest here so we don't need to pull the ImageManifest when listing + // packages to get the package (blob) digest + let mut index_manifest_annotations = HashMap::from([( + "com.pyoci.sha256_digest".to_string(), + package_digest.to_string(), + )]); + + let creation_annotation = HashMap::from([( "org.opencontainers.image.created".to_string(), OffsetDateTime::now_utc().format(&Rfc3339)?, - ); + )]); + + annotations.extend(creation_annotation.clone()); + index_manifest_annotations.extend(creation_annotation.clone()); // Build the Manifest + let manifest = self.image_manifest(package, &layer, annotations); + let index = self + .image_index( + package, + &manifest, + creation_annotation, + index_manifest_annotations, + ) + .await?; + tracing::debug!("{}", to_string_pretty(&index).unwrap()); + tracing::debug!("{}", to_string_pretty(&manifest.manifest).unwrap()); + + self.push_blob(&name, layer).await?; + self.push_blob(&name, empty_config()).await?; + self.push_manifest(&name, Manifest::Manifest(Box::new(manifest.manifest)), None) + .await?; + self.push_manifest(&name, Manifest::Index(Box::new(index)), Some(&tag)) + .await + } + + /// Get the definition of a new ImageManifest + fn image_manifest( + &self, + package: &Package<'_, WithFileName>, + layer: &Blob, + annotations: HashMap, + ) -> PlatformManifest { let config = empty_config(); let manifest = ImageManifestBuilder::default() .schema_version(SCHEMA_VERSION) @@ -379,10 +446,22 @@ impl PyOci { .artifact_type(ARTIFACT_TYPE) .config(config.descriptor.clone()) .layers(vec![layer.descriptor.clone()]) - .annotations(annotations.clone()) + .annotations(annotations) .build() .expect("valid ImageManifest"); - let manifest = PlatformManifest::new(manifest, package); + PlatformManifest::new(manifest, package) + } + + /// Create or Update the definition of a new ImageIndex + async fn image_index( + &mut self, + package: &Package<'_, WithFileName>, + manifest: &PlatformManifest, + index_annotations: HashMap, + index_manifest_annotations: HashMap, + ) -> Result { + let name = package.oci_name(); + let tag = package.oci_tag(); // Pull an existing index let index = match self.pull_manifest(&name, &tag).await? { Some(Manifest::Manifest(_)) => { @@ -398,8 +477,8 @@ impl PyOci { .schema_version(SCHEMA_VERSION) .media_type("application/vnd.oci.image.index.v1+json") .artifact_type(ARTIFACT_TYPE) - .manifests(vec![manifest.descriptor(annotations.clone())]) - .annotations(annotations.clone()) + .manifests(vec![manifest.descriptor(index_manifest_annotations)]) + .annotations(index_annotations) .build() .expect("valid ImageIndex"), // Existing index found, check artifact type @@ -427,23 +506,18 @@ impl PyOci { } } let mut manifests = index.manifests().to_vec(); - manifests.push(manifest.descriptor(annotations)); + manifests.push(manifest.descriptor(index_manifest_annotations)); index.set_manifests(manifests); *index } }; - tracing::debug!("Index: {:?}", index.to_string().unwrap()); - tracing::debug!("Manifest: {:?}", manifest.manifest.to_string().unwrap()); - - self.push_blob(&name, layer).await?; - self.push_blob(&name, config).await?; - self.push_manifest(&name, Manifest::Manifest(Box::new(manifest.manifest)), None) - .await?; - self.push_manifest(&name, Manifest::Index(Box::new(index)), Some(&tag)) - .await + Ok(index) } - pub async fn delete_package_version(&mut self, package: &Package<'_, WithFile>) -> Result<()> { + pub async fn delete_package_version( + &mut self, + package: &Package<'_, WithFileName>, + ) -> Result<()> { let name = package.oci_name(); let index = match self.pull_manifest(&name, &package.oci_tag()).await? { Some(Manifest::Index(index)) => index, @@ -474,7 +548,30 @@ impl PyOci { } } -impl PyOci { +/// Check if the provided digest matches the package digest +/// +/// Returns the digest if successful +fn verify_digest(layer: &Blob, expected_digest: Option) -> Result { + let package_digest = layer.descriptor.digest().digest(); + + if let Some(sha256_digest) = expected_digest { + // Verify if the sha256 as provided by the request matches the calculated sha of the + // uploaded content. + if package_digest != sha256_digest { + Err(PyOciError::from(( + StatusCode::BAD_REQUEST, + "Provided sha256_digest does not match the package content", + )))?; + } + } + Ok(package_digest.to_string()) +} + +/// Low-level functionality for interacting with the OCI registry +impl PyOci +where + T: Transport + Clone, +{ /// Push a blob to the registry using POST then PUT method /// /// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#post-then-put @@ -692,6 +789,9 @@ fn empty_config() -> Blob { #[cfg(test)] mod tests { + use pretty_assertions::assert_eq; + use serde_json::from_str; + use super::*; #[test] @@ -836,4 +936,329 @@ mod tests { mock.assert_async().await; } } + + #[test] + // Check if the digest is returned when no expected digest is provided + fn verify_digest_none() { + let layer = Blob::new(vec![b'a', b'b', b'c'], "test-artifact"); + let sha = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad".to_string(); + let result = verify_digest(&layer, None).expect("SHAs should match"); + assert_eq!(result, sha); + } + + #[test] + // Check if the digest is returned when the expected digest matches + fn verify_digest_match() { + let layer = Blob::new(vec![b'a', b'b', b'c'], "test-artifact"); + let sha = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad".to_string(); + let result = verify_digest(&layer, Some(sha.clone())).expect("SHAs should match"); + assert_eq!(result, sha); + } + + #[test] + // Check if an error is returned if the sha does not match + fn verify_digest_no_match() { + let layer = Blob::new(vec![b'a', b'b', b'c'], "test-artifact"); + let result = verify_digest(&layer, Some("no-match".to_string())) + .expect_err("Should return an error"); + let err = result + .downcast::() + .expect("Error should be PyOciError"); + assert_eq!(err.status, StatusCode::BAD_REQUEST); + } + + #[test] + fn image_manifest() { + let pyoci = PyOci { + registry: Url::parse("https://pyoci.com").expect("valid url"), + transport: HttpTransport::new(None).unwrap(), + }; + + let package = + Package::from_filename("ghcr.io", "mockserver", "bar-1.tar.gz").expect("Valid Package"); + let layer = Blob::new(vec![b'q', b'w', b'e'], "test-artifact"); + let annotations = HashMap::from([( + "test-annotation-key".to_string(), + "test-annotation-value".to_string(), + )]); + + let result = pyoci.image_manifest(&package, &layer, annotations.clone()); + assert_eq!( + result.manifest, + from_str::(r#"{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "artifactType": "application/pyoci.package.v1", + "config": { + "mediaType": "application/vnd.oci.empty.v1+json", + "digest": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", + "size": 2 + }, + "layers": [ + { + "mediaType": "test-artifact", + "digest": "sha256:489cd5dbc708c7e541de4d7cd91ce6d0f1613573b7fc5b40d3942ccb9555cf35", + "size": 3 + } + ], + "annotations": { + "test-annotation-key": "test-annotation-value" + } + }"#).unwrap() + ); + } + + #[tokio::test] + // Test if we can create a new ImageIndex + async fn image_index_new() { + // PyOci.image_index() will reach out to see if there is an existing index + // Reply with a NOT_FOUND + let mut server = mockito::Server::new_async().await; + let url = server.url(); + server + .mock("GET", "/v2/mockserver/bar/manifests/1") + .with_status(404) + .create_async() + .await; + + let mut pyoci = PyOci { + registry: Url::parse(&url).expect("valid url"), + transport: HttpTransport::new(None).unwrap(), + }; + + // Setup the objects we're publishing + let package = Package::from_filename("ghcr.io", "mockserver", "bar-1.tar.gz").unwrap(); + let layer = Blob::new(vec![b'q', b'w', b'e'], "test-artifact"); + let manifest = ImageManifestBuilder::default() + .schema_version(SCHEMA_VERSION) + .media_type("application/vnd.oci.image.manifest.v1+json") + .artifact_type(ARTIFACT_TYPE) + .config(empty_config().descriptor) + .layers(vec![layer.descriptor]) + .build() + .expect("valid ImageManifest"); + let manifest = PlatformManifest::new(manifest, &package); + + // Annotations for the ImageIndex + let index_annotations = HashMap::from([("idx-key".to_string(), "idx-val".to_string())]); + // Annotations for the ImageIndex.manifests[] + let index_manifest_annotations = + HashMap::from([("idx-mani-key".to_string(), "idx-mani-val".to_string())]); + + let result = pyoci + .image_index( + &package, + &manifest, + index_annotations, + index_manifest_annotations, + ) + .await + .expect("Valid ImageIndex"); + + assert_eq!( + result, + from_str::(r#"{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.index.v1+json", + "artifactType": "application/pyoci.package.v1", + "manifests": [ + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:6b95ce6324c6745397ccdb66864a73598b4df8989b1c0c8f0f386d85e2640d47", + "size": 406, + "annotations": { + "idx-mani-key": "idx-mani-val" + }, + "platform": { + "architecture": ".tar.gz", + "os": "any" + } + } + ], + "annotations": { + "idx-key": "idx-val" + } + }"#).unwrap() + ); + } + + #[tokio::test] + // Test if we can update an existing ImageIndex + async fn image_index_existing() { + // PyOci.image_index() will reach out to see if there is an existing index + // Reply with the existing index + let mut server = mockito::Server::new_async().await; + let url = server.url(); + + // Existing ImageIndex + let index = r#"{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.index.v1+json", + "artifactType": "application/pyoci.package.v1", + "manifests": [ + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:0d749abe1377573493e0df74df8d1282e46967754a1ebc7cc6323923a788ad5c", + "size": 6, + "platform": { + "architecture": ".whl", + "os": "any" + } + } + ], + "annotations": { + "created": "yesterday" + } + }"#; + + server + .mock("GET", "/v2/mockserver/bar/manifests/1") + .with_status(200) + .with_header("content-type", "application/vnd.oci.image.index.v1+json") + .with_body(index) + .create_async() + .await; + + let mut pyoci = PyOci { + registry: Url::parse(&url).expect("valid url"), + transport: HttpTransport::new(None).unwrap(), + }; + + // Setup the objects we're publishing + let package = Package::from_filename("ghcr.io", "mockserver", "bar-1.tar.gz").unwrap(); + let layer = Blob::new(vec![b'q', b'w', b'e'], "test-artifact"); + let manifest = ImageManifestBuilder::default() + .schema_version(SCHEMA_VERSION) + .media_type("application/vnd.oci.image.manifest.v1+json") + .artifact_type(ARTIFACT_TYPE) + .config(empty_config().descriptor) + .layers(vec![layer.descriptor]) + .build() + .expect("valid ImageManifest"); + let manifest = PlatformManifest::new(manifest, &package); + + // The ImageIndex annotations are only set when the index is newly created + // So these annotations should not show up in the updated index + let index_annotations = HashMap::from([("created".to_string(), "today".to_string())]); + // Annotations for the new ImageIndex.manifests[] + let index_manifest_annotations = + HashMap::from([("idx-mani-key".to_string(), "idx-mani-val".to_string())]); + + let result = pyoci + .image_index( + &package, + &manifest, + index_annotations, + index_manifest_annotations, + ) + .await + .expect("Valid ImageIndex"); + + assert_eq!( + result, + from_str::(r#"{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.index.v1+json", + "artifactType": "application/pyoci.package.v1", + "manifests": [ + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:0d749abe1377573493e0df74df8d1282e46967754a1ebc7cc6323923a788ad5c", + "size": 6, + "platform": { + "architecture": ".whl", + "os": "any" + } + }, + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:6b95ce6324c6745397ccdb66864a73598b4df8989b1c0c8f0f386d85e2640d47", + "size": 406, + "annotations": { + "idx-mani-key": "idx-mani-val" + }, + "platform": { + "architecture": ".tar.gz", + "os": "any" + } + } + ], + "annotations": { + "created": "yesterday" + } + }"#).unwrap() + ); + } + + #[tokio::test] + // Test if existing packages are rejected + async fn image_index_conflict() { + // PyOci.image_index() will reach out to see if there is an existing index + // Reply with the existing index + let mut server = mockito::Server::new_async().await; + let url = server.url(); + + // Existing ImageIndex + let index = r#"{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.index.v1+json", + "artifactType": "application/pyoci.package.v1", + "manifests": [ + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:6b95ce6324c6745397ccdb66864a73598b4df8989b1c0c8f0f386d85e2640d47", + "size": 406, + "annotations": { + "idx-mani-key": "idx-mani-val" + }, + "platform": { + "architecture": ".tar.gz", + "os": "any" + } + } + ], + "annotations": { + "created": "yesterday" + } + }"#; + + server + .mock("GET", "/v2/mockserver/bar/manifests/1") + .with_status(200) + .with_header("content-type", "application/vnd.oci.image.index.v1+json") + .with_body(index) + .create_async() + .await; + + let mut pyoci = PyOci { + registry: Url::parse(&url).expect("valid url"), + transport: HttpTransport::new(None).unwrap(), + }; + + // Setup the objects we're publishing + let package = Package::from_filename("ghcr.io", "mockserver", "bar-1.tar.gz").unwrap(); + let layer = Blob::new(vec![b'q', b'w', b'e'], "test-artifact"); + let manifest = ImageManifestBuilder::default() + .schema_version(SCHEMA_VERSION) + .media_type("application/vnd.oci.image.manifest.v1+json") + .artifact_type(ARTIFACT_TYPE) + .config(empty_config().descriptor) + .layers(vec![layer.descriptor]) + .build() + .expect("valid ImageManifest"); + let manifest = PlatformManifest::new(manifest, &package); + + let result = pyoci + .image_index(&package, &manifest, HashMap::new(), HashMap::new()) + .await + .expect_err("Expected an Err") + .downcast::() + .expect("Expected a PyOciError"); + + assert_eq!(result.status, StatusCode::CONFLICT); + assert_eq!( + result.message, + "Platform '.tar.gz' already exists for version '1'" + ); + } } diff --git a/src/templates.rs b/src/templates.rs index 0592fbd..7c7cdc7 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -1,9 +1,9 @@ -use crate::package::{Package, WithFile}; +use crate::package::{Package, WithFileName}; use askama::Template; #[derive(Template)] #[template(path = "list-package.html")] pub struct ListPackageTemplate<'a> { pub subpath: &'a str, - pub files: Vec>, + pub files: Vec>, } diff --git a/src/transport.rs b/src/transport.rs index dbad256..27d7394 100644 --- a/src/transport.rs +++ b/src/transport.rs @@ -11,6 +11,39 @@ use crate::service::AuthLayer; use crate::service::RequestLogLayer; use crate::USER_AGENT; +pub trait Transport { + /// Send a request + /// + /// When authentication is required, this method will automatically authenticate + /// using the provided Basic auth string and caches the Bearer token for future requests within + /// this session. + async fn send(&mut self, request: reqwest::RequestBuilder) -> Result; + + /// Return the underlying client + fn client(&self) -> &reqwest::Client; + + /// Create a new GET request + fn get(&self, url: url::Url) -> reqwest::RequestBuilder { + self.client().get(url) + } + /// Create a new POST request + fn post(&self, url: url::Url) -> reqwest::RequestBuilder { + self.client().post(url) + } + /// Create a new PUT request + fn put(&self, url: url::Url) -> reqwest::RequestBuilder { + self.client().put(url) + } + /// Create a new HEAD request + fn head(&self, url: url::Url) -> reqwest::RequestBuilder { + self.client().head(url) + } + /// Create a new DELETE request + fn delete(&self, url: url::Url) -> reqwest::RequestBuilder { + self.client().delete(url) + } +} + /// HTTP Transport /// /// This struct is responsible for sending HTTP requests to the upstream OCI registry. @@ -60,13 +93,15 @@ impl HttpTransport { auth_layer: AuthLayer::new(auth)?, }) } +} +impl Transport for HttpTransport { /// Send a request /// /// When authentication is required, this method will automatically authenticate /// using the provided Basic auth string and caches the Bearer token for future requests within /// this session. - pub async fn send(&mut self, request: reqwest::RequestBuilder) -> Result { + async fn send(&mut self, request: reqwest::RequestBuilder) -> Result { let request = request.build()?; let mut service = ServiceBuilder::new() @@ -79,25 +114,8 @@ impl HttpTransport { Ok(response) } - /// Create a new GET request - pub fn get(&self, url: url::Url) -> reqwest::RequestBuilder { - self.client.get(url) - } - /// Create a new POST request - pub fn post(&self, url: url::Url) -> reqwest::RequestBuilder { - self.client.post(url) - } - /// Create a new PUT request - pub fn put(&self, url: url::Url) -> reqwest::RequestBuilder { - self.client.put(url) - } - /// Create a new HEAD request - pub fn head(&self, url: url::Url) -> reqwest::RequestBuilder { - self.client.head(url) - } - /// Create a new DELETE request - pub fn delete(&self, url: url::Url) -> reqwest::RequestBuilder { - self.client.delete(url) + fn client(&self) -> &reqwest::Client { + &self.client } }