diff --git a/codegen/src/v1/access.rs b/codegen/src/v1/access.rs index a4dbb6171..80efca037 100644 --- a/codegen/src/v1/access.rs +++ b/codegen/src/v1/access.rs @@ -4,6 +4,7 @@ use super::ops::Operations; use crate::declare_codegen; +use crate::v2::post_object; use heck::ToSnakeCase; use scoped_writer::g; @@ -52,12 +53,9 @@ pub fn codegen(ops: &Operations) { g!("/// Checks whether the {} request has accesses to the resources.", op.name); g!("/// "); g!("/// This method returns `Ok(())` by default."); - if op.name == "PostObject" { - g!("/// "); - g!("/// POST Object is not a synonym for `PutObject`: the request carries a form (its fields,"); - g!("/// the file name and the policy) and the response carries the `success_action_*`"); - g!("/// semantics. The default path runs the `put_object` check as well, so this hook is an"); - g!("/// addition to it rather than a replacement."); + // The synthetic operation adds POST-specific notes to its hook documentation. + if post_object::is_synthetic(&op.name) { + post_object::codegen_access_hook(); } if op.is_minio { g!("#[cfg(feature = \"minio\")]"); diff --git a/codegen/src/v1/aws_conv.rs b/codegen/src/v1/aws_conv.rs index 1c848f4b0..69bc29240 100644 --- a/codegen/src/v1/aws_conv.rs +++ b/codegen/src/v1/aws_conv.rs @@ -5,6 +5,7 @@ use super::dto::RustTypes; use super::ops::Operations; use super::ops::is_op_input; use super::rust; +use crate::v2::post_object; use crate::declare_codegen; @@ -23,10 +24,12 @@ pub fn codegen(ops: &Operations, rust_types: &RustTypes) { g!(); for (name, rust_type) in rust_types { + // PostObject is a synthetic API in s3s; aws-sdk-s3 has no corresponding types. + if post_object::is_synthetic_type(name) { + continue; + } + match name.as_str() { - // PostObject is a synthetic API in s3s; aws-sdk-s3 has no corresponding types. - "PostObjectInput" => continue, - "PostObjectOutput" => continue, // ListenBucketNotification is a MinIO extension; aws-sdk-s3 has no corresponding types. "ListenBucketNotificationInput" => continue, "ListenBucketNotificationOutput" => continue, diff --git a/codegen/src/v1/aws_proxy.rs b/codegen/src/v1/aws_proxy.rs index a1533beb5..86a52a151 100644 --- a/codegen/src/v1/aws_proxy.rs +++ b/codegen/src/v1/aws_proxy.rs @@ -6,6 +6,7 @@ use super::ops::Operations; use super::rust; use crate::declare_codegen; +use crate::v2::post_object; use std::format as f; @@ -35,7 +36,7 @@ pub fn codegen(ops: &Operations, rust_types: &RustTypes) { for op in ops.values() { // PostObject is a synthetic API in s3s; aws-sdk-s3 has no corresponding operation. - if op.name == "PostObject" { + if post_object::is_synthetic(&op.name) { continue; } let method_name = op.name.to_snake_case(); diff --git a/codegen/src/v1/dto.rs b/codegen/src/v1/dto.rs index 203c9da0c..b54a23768 100644 --- a/codegen/src/v1/dto.rs +++ b/codegen/src/v1/dto.rs @@ -10,6 +10,7 @@ use super::{rust, smithy}; use crate::declare_codegen; use crate::v1::Patch; +use crate::v2::post_object; use std::borrow::Cow; use std::collections::{BTreeMap, BTreeSet}; @@ -300,55 +301,10 @@ pub fn collect_rust_types(model: &smithy::Model, ops: &Operations) -> RustTypes patch_types(&mut space); unify_operation_types(ops, &mut space); - // POST Object is not a Smithy-modeled operation in the upstream S3 model. - // We still want to distinguish it from PutObject at the trait layer. - // Fork the unified DTO types so behavior can stay identical, - // while leaving room to extend PostObject* with POST-only fields later. - for (src, dst) in [("PutObjectInput", "PostObjectInput"), ("PutObjectOutput", "PostObjectOutput")] { - if let Some(src_ty) = space.get(src).cloned() { - let mut dst_ty = src_ty; - match &mut dst_ty { - rust::Type::Struct(s) => { - dst.clone_into(&mut s.name); - } - _ => { - // PutObject{Input,Output} are expected to be structs. - unimplemented!("{src} is not a struct"); - } - } - assert!(space.insert(dst.to_owned(), dst_ty).is_none()); - } - } + // The synthetic operation is not in the upstream model; the v2 module owns it. + post_object::codegen_dto_clone(&mut space); - // Add POST Object specific fields to PostObjectInput - if let Some(rust::Type::Struct(post_in)) = space.get_mut("PostObjectInput") { - post_in.fields.push(rust::StructField { - name: o("success_action_redirect"), - type_: o("String"), - option_type: true, - position: o("s3s"), - doc: Some(o("The URL to which the client is redirected upon successful upload.")), - ..rust::StructField::default() - }); - post_in.fields.push(rust::StructField { - name: o("success_action_status"), - type_: o("i32"), - option_type: true, - position: o("s3s"), - doc: Some(o( - "The status code returned to the client upon successful upload. Valid values are 200, 201, and 204.", - )), - ..rust::StructField::default() - }); - post_in.fields.push(rust::StructField { - name: o("policy"), - type_: o("PostPolicy"), - option_type: true, - position: o("s3s"), - doc: Some(o("The POST policy document that was included in the request.")), - ..rust::StructField::default() - }); - } + post_object::codegen_post_only_fields(&mut space); space } @@ -457,7 +413,7 @@ fn unify_operation_types(ops: &Operations, space: &mut RustTypes) { // unify operation input type for op in ops.values() { - if op.name == "PostObject" { + if post_object::is_synthetic(&op.name) { continue; } if op.name == "SelectObjectContent" { @@ -484,7 +440,7 @@ fn unify_operation_types(ops: &Operations, space: &mut RustTypes) { // unify operation output type for op in ops.values() { - if op.name == "PostObject" { + if post_object::is_synthetic(&op.name) { continue; } let output_ty = if op.smithy_output == "Unit" { @@ -699,88 +655,13 @@ pub fn codegen(rust_types: &RustTypes, ops: &Operations, patch: Option) { codegen_builders(rust_types, ops); codegen_dto_ext(rust_types); - codegen_post_object_mapping_helpers(rust_types); + post_object::codegen_mapping_helpers(rust_types); if matches!(patch, Some(Patch::Minio)) { super::minio::codegen_in_dto(); } } -fn codegen_post_object_mapping_helpers(rust_types: &RustTypes) { - let Some(rust::Type::Struct(put_in)) = rust_types.get("PutObjectInput") else { return }; - let Some(rust::Type::Struct(put_out)) = rust_types.get("PutObjectOutput") else { return }; - let Some(rust::Type::Struct(post_in)) = rust_types.get("PostObjectInput") else { return }; - let Some(rust::Type::Struct(post_out)) = rust_types.get("PostObjectOutput") else { return }; - - // PostObjectInput has extra fields (success_action_redirect, success_action_status). - // We verify that the common fields (those from PutObjectInput) match. - assert!(post_in.fields.len() >= put_in.fields.len()); - for (a, b) in put_in.fields.iter().zip(post_in.fields.iter()) { - assert_eq!(a.name, b.name); - assert_eq!(a.type_, b.type_); - assert_eq!(a.option_type, b.option_type); - } - assert_eq!(put_out.fields.len(), post_out.fields.len()); - for (a, b) in put_out.fields.iter().zip(post_out.fields.iter()) { - assert_eq!(a.name, b.name); - assert_eq!(a.type_, b.type_); - assert_eq!(a.option_type, b.option_type); - } - - // Collect POST-only field names (those not in PutObjectInput) - let put_in_field_names: std::collections::BTreeSet<_> = put_in.fields.iter().map(|f| f.name.as_str()).collect(); - let post_only_fields: Vec<_> = post_in - .fields - .iter() - .filter(|f| !put_in_field_names.contains(f.name.as_str())) - .collect(); - - g!(); - g([ - "// NOTE: PostObject is a synthetic API in s3s.", - "// PostObjectInput has extra fields for POST-specific behavior (success_action_redirect, success_action_status).", - ]); - - g!("pub(crate) fn put_object_input_into_post_object_input(x: PutObjectInput) -> PostObjectInput {{"); - g!(" PostObjectInput {{"); - for field in &put_in.fields { - g!(" {}: x.{},", field.name, field.name); - } - // POST-only fields get default values - for field in &post_only_fields { - g!(" {}: None,", field.name); - } - g!(" }}"); - g!("}}"); - - g!("pub(crate) fn post_object_input_into_put_object_input(x: PostObjectInput) -> PutObjectInput {{"); - g!(" PutObjectInput {{"); - // Only copy fields that exist in PutObjectInput - for field in &put_in.fields { - g!(" {}: x.{},", field.name, field.name); - } - g!(" }}"); - g!("}}"); - - g!("pub(crate) fn put_object_output_into_post_object_output(x: PutObjectOutput) -> PostObjectOutput {{"); - g!(" PostObjectOutput {{"); - for field in &put_out.fields { - g!(" {}: x.{},", field.name, field.name); - } - g!(" }}"); - g!("}}"); - - // This function is currently unused but kept for symmetry and potential future use - g!("#[allow(dead_code)]"); - g!("pub(crate) fn post_object_output_into_put_object_output(x: PostObjectOutput) -> PutObjectOutput {{"); - g!(" PutObjectOutput {{"); - for field in &post_out.fields { - g!(" {}: x.{},", field.name, field.name); - } - g!(" }}"); - g!("}}"); -} - fn codegen_struct(ty: &rust::Struct, rust_types: &RustTypes, ops: &Operations, needs_serde: bool, needs_custom_default: bool) { codegen_doc(ty.doc.as_deref()); diff --git a/codegen/src/v1/gen_tests/ops.rs b/codegen/src/v1/gen_tests/ops.rs index 4e4744ae0..183926dc9 100644 --- a/codegen/src/v1/gen_tests/ops.rs +++ b/codegen/src/v1/gen_tests/ops.rs @@ -25,6 +25,7 @@ use crate::v1::dto::RustTypes; use crate::v1::ops::Operation; use crate::v1::ops::Operations; use crate::v1::rust; +use crate::v2::post_object; use super::debug::Mode; use super::debug::spec; @@ -39,11 +40,6 @@ use heck::ToSnakeCase; use s3s_model::error_codes; use scoped_writer::g; -/// The synthetic multipart operation. Its input is parsed from a verified -/// multipart form, which needs a signed request, so the family covers it with a -/// direct call instead of a request-table entry. -const POST_OBJECT: &str = "PostObject"; - /// Operations whose optional XML payload still needs a non-empty body: the /// generated deserializer maps an empty body to `MalformedXML` for them. const REQUIRES_BODY: &[&str] = &["CompleteMultipartUpload", "PutObjectLegalHold", "PutObjectRetention"]; @@ -77,7 +73,8 @@ pub(super) fn codegen(ops: &Operations, rust_types_base: &RustTypes, rust_types_ assert_eq!( cases.len() + 1, ops.len(), - "every operation except {POST_OBJECT} must have a request case" + "every operation except {} must have a request case", + post_object::name() ); write_test_file("ops.rs", || { @@ -311,22 +308,8 @@ fn emit_harness() { " String::from_utf8_lossy(&bytes).into_owned()", "}", "", - "/// Builds a request for the direct call of the synthetic multipart operation.", - "fn post_object_request() -> S3Request {", - " S3Request {", - " input: PostObjectInput::default(),", - " method: Method::POST,", - " uri: Uri::from_static(\"/bucket/key\"),", - " headers: HeaderMap::new(),", - " extensions: Extensions::new(),", - " credentials: None,", - " region: None,", - " service: None,", - " trailing_headers: None,", - " }", - "}", - "", ]); + post_object::codegen_test_fixture(); g([ "/// Every operation is reached through the real service pipeline, and the", @@ -432,41 +415,14 @@ fn emit_harness() { "", ]); - g([ - "/// The synthetic POST Object operation is covered by a direct call: its", - "/// input comes from a verified multipart form, which the request table", - "/// cannot produce. The default body delegates to put_object.", - "#[tokio::test]", - "async fn post_object_delegates_to_put_object() {", - " let err = DefaultS3", - " .post_object(post_object_request())", - " .await", - " .expect_err(\"the default body delegates to put_object, which reports NotImplemented\");", - " assert_eq!(err.code().as_str(), \"NotImplemented\");", - "", - " let recorder = RecordingS3::default();", - " let err = recorder", - " .post_object(post_object_request())", - " .await", - " .expect_err(\"the recording double reports NotImplemented\");", - " assert_eq!(err.code().as_str(), \"NotImplemented\");", - " assert_eq!(recorder.take().as_slice(), [\"PostObject\"].as_slice());", - "", - " let mut req = post_object_request();", - " PassThroughAccess", - " .post_object(&mut req)", - " .await", - " .expect(\"the default access body allows the request\");", - "}", - "", - ]); + post_object::codegen_generated_test(); } fn collect_cases(ops: &Operations, types: &RustTypes) -> Vec { let mut cases = Vec::new(); for op in ops.values() { - if op.name == POST_OBJECT { + if post_object::is_synthetic(&op.name) { continue; } diff --git a/codegen/src/v1/mod.rs b/codegen/src/v1/mod.rs index 26b53074f..5f109acf5 100644 --- a/codegen/src/v1/mod.rs +++ b/codegen/src/v1/mod.rs @@ -1,7 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: 2023-2026 The s3s Authors -mod rust; +// `v2::post_object` reads the rust type model, so the module is visible crate-wide; +// this is a visibility change only and cannot change the generated output. +pub(crate) mod rust; mod smithy; mod utils; @@ -28,12 +30,18 @@ use std::io::BufWriter; pub use self::utils::o; +// `v2::post_object` owns the synthetic operation and reads these v1 items; +// re-exporting them is a visibility change only and cannot change what is generated. +pub(crate) use self::dto::RustTypes; +pub(crate) use self::ops::{OPS_GENERATED_DIR, Operation, Operations, codegen_file_header}; + fn write_file(path: &str, f: impl FnOnce()) { let mut writer = BufWriter::new(File::create(path).unwrap()); scoped_writer::scoped(&mut writer, f); } -pub(super) fn write_dir_file(dir: &str, name: &str, f: impl FnOnce()) { +/// Visible to `v2::post_object`, which writes the operation module through it. +pub(crate) fn write_dir_file(dir: &str, name: &str, f: impl FnOnce()) { std::fs::create_dir_all(dir).unwrap(); write_file(&format!("{dir}/{name}"), f); } diff --git a/codegen/src/v1/oir.rs b/codegen/src/v1/oir.rs index 378fa0952..7d0bd0d41 100644 --- a/codegen/src/v1/oir.rs +++ b/codegen/src/v1/oir.rs @@ -7,6 +7,7 @@ use super::ops::{OPS_GENERATED_DIR, Operations, PathPattern, codegen_file_header}; use super::write_dir_file; +use crate::v2::post_object; use scoped_writer::g; use std::collections::BTreeMap; @@ -100,7 +101,7 @@ struct OirBucket { fn collect_oir_buckets(ops: &Operations) -> Vec { let mut groups: BTreeMap<(String, PathPattern), Vec<(String, bool)>> = BTreeMap::new(); for op in ops.values() { - if op.name == "PostObject" { + if post_object::is_synthetic(&op.name) { continue; } // The official `x-id` query value must equal the operation name so diff --git a/codegen/src/v1/ops.rs b/codegen/src/v1/ops.rs index 24ce1f4be..7b2e9bbe6 100644 --- a/codegen/src/v1/ops.rs +++ b/codegen/src/v1/ops.rs @@ -8,6 +8,8 @@ use super::xml::{is_xml_output, is_xml_payload}; use super::{dto, rust, smithy}; use super::{headers, o, write_dir_file}; +use crate::v2::post_object; + use std::cmp::Reverse; use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::format as f; @@ -127,32 +129,8 @@ pub fn collect_operations(model: &smithy::Model) -> Operations { insert(&mut operations, op_name, op); } - // PostObject is a synthetic operation (multipart form upload) which is not present in the - // upstream Smithy model. We add it here so downstream generators (trait/access/etc.) can treat - // it like a normal operation. - if operations.contains_key("PostObject").not() { - let op_name = o("PostObject"); - let op = Operation { - name: op_name.clone(), - - input: o("PostObjectInput"), - output: o("PostObjectOutput"), - - // Placeholders (there is no Smithy-modeled input/output for PostObject). - smithy_input: o("Unit"), - smithy_output: o("Unit"), - - s3_unwrapped_xml_output: false, - doc: None, - - http_method: o("POST"), - http_uri: o("/{Bucket}"), - http_code: 200, - - is_minio: false, - }; - insert(&mut operations, op_name, op); - } + // The synthetic multipart operation is not in the upstream model; the v2 module owns it. + post_object::inject_operation(&mut operations); operations } @@ -172,9 +150,10 @@ pub fn is_op_output(name: &str, ops: &Operations) -> bool { /// variants; per-operation differences between them are emitted inline with /// mutually exclusive `#[cfg(feature = "minio")]` / `#[cfg(not(feature = "minio"))]` /// gates instead of generating two files and merging them textually. -pub(super) const OPS_GENERATED_DIR: &str = "crates/s3s/src/ops/generated"; +// Visible to `v2::post_object`, which emits the operation module with them. +pub(crate) const OPS_GENERATED_DIR: &str = "crates/s3s/src/ops/generated"; -pub(super) fn codegen_file_header() { +pub(crate) fn codegen_file_header() { g!("// SPDX-License-Identifier: Apache-2.0"); g!("// SPDX-FileCopyrightText: 2023-2026 The s3s Authors"); g!(); @@ -189,7 +168,7 @@ pub fn codegen(ops: &Operations, rust_types_base: &RustTypes, rust_types_minio: codegen_header_value(ops, rust_types_minio); }); - codegen_post_object_fork_op(rust_types_minio); + post_object::codegen_fork_op(rust_types_minio); codegen_http(ops, rust_types_base, rust_types_minio); codegen_router(ops, rust_types_minio); crate::v1::oir::codegen_oir(ops); @@ -221,7 +200,7 @@ pub fn codegen(ops: &Operations, rust_types_base: &RustTypes, rust_types_minio: ]); for op in ops.values() { - if op.name == "PostObject" { + if post_object::is_synthetic(&op.name) { continue; } g!("mod {};", op.name.to_snake_case()); @@ -233,7 +212,7 @@ pub fn codegen(ops: &Operations, rust_types_base: &RustTypes, rust_types_minio: g!(); for op in ops.values() { - if op.name == "PostObject" { + if post_object::is_synthetic(&op.name) { continue; } if op.is_minio { @@ -287,7 +266,7 @@ fn op_has_header_fields(op: &Operation, rust_types: &RustTypes) -> bool { fn codegen_http(ops: &Operations, rust_types_base: &RustTypes, rust_types_minio: &RustTypes) { for op in ops.values() { - if op.name == "PostObject" { + if post_object::is_synthetic(&op.name) { continue; } codegen_op_unit(op, rust_types_base, rust_types_minio); @@ -362,188 +341,6 @@ fn status_code_name(code: u16) -> &'static str { } } -#[allow(clippy::too_many_lines)] -fn codegen_post_object_fork_op(rust_types: &RustTypes) { - // PostObject is a synthetic operation: same behavior as PutObject (multipart form upload), - // but separated at the trait layer so implementations can distinguish PUT vs POST later. - // PostObjectInput has extra fields for POST-specific behavior (success_action_redirect, success_action_status). - let Some(rust::Type::Struct(put_in)) = rust_types.get("PutObjectInput") else { return }; - let Some(rust::Type::Struct(_put_out)) = rust_types.get("PutObjectOutput") else { return }; - let Some(rust::Type::Struct(post_in)) = rust_types.get("PostObjectInput") else { return }; - let Some(rust::Type::Struct(_post_out)) = rust_types.get("PostObjectOutput") else { return }; - - // PostObjectInput has extra fields, so we only verify that common fields match. - assert!(post_in.fields.len() >= put_in.fields.len()); - for (a, b) in put_in.fields.iter().zip(post_in.fields.iter()) { - assert_eq!(a.name, b.name); - assert_eq!(a.type_, b.type_); - assert_eq!(a.option_type, b.option_type); - } - - write_dir_file(OPS_GENERATED_DIR, "post_object.rs", || { - codegen_file_header(); - g([ - "use crate::dto::*;", - "use crate::error::*;", - "use crate::http;", - "use crate::ops::{CallContext, PutObject};", - "", - ]); - g(["pub struct PostObject;", "", "impl PostObject {"]); - g([ - " pub fn deserialize_http(req: &mut http::Request) -> S3Result {", - " let Some(m) = req.s3ext.multipart.take() else {", - " return Err(invalid_request!(\"missing multipart form\"));", - " };", - "", - " // Parse POST-specific fields before consuming the multipart form", - " let success_action_redirect: Option = match http::parse_field_value(&m, \"success_action_redirect\")? {", - " Some(v) => Some(v),", - " None => http::parse_field_value(&m, \"redirect\")?,", - " };", - " let success_action_status: Option = http::parse_field_value(&m, \"success_action_status\")?;", - "", - " // Get the validated POST policy from request extensions", - " let policy = req.s3ext.post_policy.take();", - "", - " let put_input = PutObject::deserialize_http_multipart(req, m)?;", - " let mut post_input = put_object_input_into_post_object_input(put_input);", - " post_input.success_action_redirect = success_action_redirect;", - " post_input.success_action_status = success_action_status;", - " post_input.policy = policy;", - " Ok(post_input)", - " }", - "", - " pub fn serialize_http(", - " bucket: &str,", - " key: &str,", - " success_action_redirect: Option<&str>,", - " success_action_status: Option,", - " output: &PostObjectOutput,", - " ) -> S3Result {", - " let etag_str = output.e_tag.as_ref().map(ETag::value).unwrap_or_default();", - "", - " // Handle success_action_redirect: return 303 See Other with Location header", - " if let Some(redirect_url) = success_action_redirect {", - " // Defense-in-depth: Reject URLs with control characters that could enable header injection", - " if redirect_url.chars().any(char::is_control) {", - " return Err(s3_error!(InvalidArgument, \"success_action_redirect contains invalid control characters\"));", - " }", - "", - " // Parse the URL to validate and manipulate it properly", - " let mut url = url::Url::parse(redirect_url).map_err(|e| s3_error!(e, InvalidArgument, \"Invalid redirect URL\"))?;", - "", - " // Add query parameters (bucket, key, etag) to the URL", - " url.query_pairs_mut()", - " .append_pair(\"bucket\", bucket)", - " .append_pair(\"key\", key)", - " .append_pair(\"etag\", etag_str);", - "", - " let mut res = http::Response::with_status(http::StatusCode::SEE_OTHER);", - " res.headers.insert(", - " hyper::header::LOCATION,", - " url.as_str().parse().map_err(|e| s3_error!(e, InternalError))?", - " );", - " return Ok(res);", - " }", - "", - " // Handle success_action_status", - " match success_action_status {", - " Some(200) => {", - " // 200 OK with empty body", - " Ok(http::Response::with_status(http::StatusCode::OK))", - " }", - " Some(201) => {", - " // 201 Created with XML body using PostResponse DTO", - " let location = format!(\"/{bucket}/{key}\");", - " let post_response = crate::dto::PostResponse {", - " location: &location,", - " bucket,", - " key,", - " etag: etag_str,", - " };", - " let mut res = http::Response::with_status(http::StatusCode::CREATED);", - " http::set_xml_body(&mut res, &post_response)?;", - " Ok(res)", - " }", - " _ => {", - " // 204 No Content (default, also for unrecognized values)", - " Ok(http::Response::with_status(http::StatusCode::NO_CONTENT))", - " }", - " }", - " }", - "}", - "", - ]); - - g(["#[async_trait::async_trait]", "impl crate::ops::Operation for PostObject {"]); - g([" fn name(&self) -> &'static str {", " \"PostObject\"", " }", ""]); - g([" fn needs_full_body(&self) -> bool {", " false", " }", ""]); - g([" fn has_request_payload(&self) -> bool {", " true", " }", ""]); - g([ - " fn has_streaming_body(&self) -> bool {", - " // POST Object's body is the multipart file stream governed by", - " // `post_object_max_file_size`, not the request body wrapped by", - " // `put_object_max_size`.", - " false", - " }", - "", - ]); - - g([ - " async fn call(&self, ccx: &CallContext<'_>, req: &mut http::Request) -> S3Result {", - " let post_input = Self::deserialize_http(req)?;", - " // Save POST-specific fields before conversion", - " let success_action_redirect = post_input.success_action_redirect.clone();", - " let success_action_status = post_input.success_action_status;", - " let bucket = post_input.bucket.clone();", - " let key = post_input.key.clone();", - "", - " let put_input = post_object_input_into_put_object_input(post_input);", - " let mut put_req = crate::ops::build_s3_request(put_input, req);", - " let s3 = ccx.s3;", - " if let Some(access) = ccx.access {", - " // Keep backward-compatible behavior: POST object used to be gated by put_object access check.", - " access.put_object(&mut put_req).await?;", - " }", - " let mut post_req = put_req.map_input(put_object_input_into_post_object_input);", - " // Restore POST-specific fields that were lost during conversion", - " post_req.input.success_action_redirect.clone_from(&success_action_redirect);", - " post_req.input.success_action_status = success_action_status;", - " if let Some(access) = ccx.access {", - " // New hook for POST object (optional).", - " access.post_object(&mut post_req).await?;", - " }", - " let result = s3.post_object(post_req).await;", - " let s3_resp = match result {", - " Ok(val) => val,", - " Err(err) => return crate::ops::serialize_error_for_method(&req.method, err, false),", - " };", - " // Serialize with POST-specific response behavior", - " let mut resp = Self::serialize_http(", - " &bucket,", - " &key,", - " success_action_redirect.as_deref(),", - " success_action_status,", - " &s3_resp.output,", - " )?;", - " if let Some(status) = s3_resp.status {", - " resp.status = status;", - " }", - " resp.headers.extend(s3_resp.headers);", - " resp.extensions.extend(s3_resp.extensions);", - " if http::is_bodyless_status(resp.status) {", - " http::strip_bodyless(&mut resp);", - " }", - " Ok(resp)", - " }", - "}", - ]); - - g!(); - }); -} - fn codegen_header_value(ops: &Operations, rust_types: &RustTypes) { let mut str_enum_names: BTreeSet<&str> = default(); @@ -1286,7 +1083,7 @@ fn collect_routes<'a>(ops: &'a Operations, rust_types: &'a RustTypes) -> HashMap for op in ops.values() { // PostObject is resolved in ops::prepare() for multipart requests. // Do not put it into the generated router to avoid overlaps. - if op.name == "PostObject" { + if post_object::is_synthetic(&op.name) { continue; } let pat = PathPattern::parse(&op.http_uri); @@ -1384,7 +1181,7 @@ fn has_request_payload(op: &Operation, rust_types: &RustTypes) -> bool { /// implementation without buffering: its input carries a `StreamingBlob` /// payload (`PutObject`, `UploadPart`, `WriteGetObjectResponse`). fn has_streaming_body(op: &Operation, rust_types: &RustTypes) -> bool { - if op.name == "PostObject" { + if post_object::is_synthetic(&op.name) { // Synthetic operation: the body is the multipart file stream governed // by `post_object_max_file_size`, not the request body wrapped by // `put_object_max_size`. diff --git a/codegen/src/v1/s3_trait.rs b/codegen/src/v1/s3_trait.rs index 9b8285abd..a3b3e07cf 100644 --- a/codegen/src/v1/s3_trait.rs +++ b/codegen/src/v1/s3_trait.rs @@ -5,6 +5,7 @@ use super::ops::Operations; use super::rust::codegen_doc; use crate::declare_codegen; +use crate::v2::post_object; use heck::ToSnakeCase; use scoped_writer::g; @@ -29,29 +30,9 @@ pub fn codegen(ops: &Operations) { let input = &op.input; let output = &op.output; - if op.name == "PostObject" { - g([ - "/// POST Object (multipart form upload)", - "///", - "/// This is a synthetic method separated from `PutObject` so implementations can distinguish", - "/// POST vs PUT.", - "///", - "/// The default implementation is a migration scaffold, not a correct POST implementation: it", - "/// converts the request into a [`S3::put_object`] call, and the shape it forwards has no place", - "/// for the form (its fields, the file name and the policy), the framing facts (boundary, tail", - "/// length) or the `success_action_*` response semantics.", - "///", - "/// It is kept so that an implementation which only implements [`S3::put_object`] keeps serving", - "/// POST requests while it migrates; the scaffold may be removed in a future minor release.", - "///", - "/// An implementation that cares about POST semantics should implement this method explicitly", - "/// instead of relying on the default.", - ]); - g!("async fn post_object(&self, req: S3Request) -> S3Result> {{"); - g!("let resp = self.put_object(req.map_input(crate::dto::post_object_input_into_put_object_input)).await?;"); - g!("Ok(resp.map_output(crate::dto::put_object_output_into_post_object_output))"); - g!("}}"); - g!(); + // The synthetic operation's default bridge is owned by the v2 module. + if post_object::is_synthetic(&op.name) { + post_object::codegen_trait_default(); continue; } diff --git a/codegen/src/v2/mod.rs b/codegen/src/v2/mod.rs index c3c6a6f6c..be0ec8271 100644 --- a/codegen/src/v2/mod.rs +++ b/codegen/src/v2/mod.rs @@ -6,4 +6,7 @@ #[allow(dead_code)] pub mod smithy; +/// The synthetic `PostObject` operation: its descriptor and its emitters. +pub(crate) mod post_object; + pub fn run() {} diff --git a/codegen/src/v2/post_object.rs b/codegen/src/v2/post_object.rs new file mode 100644 index 000000000..aa70e414a --- /dev/null +++ b/codegen/src/v2/post_object.rs @@ -0,0 +1,529 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2023-2026 The s3s Authors + +//! Ownership of the synthetic `PostObject` operation. +//! +//! `PostObject` has no shape in the upstream Smithy model, so the generation +//! pipeline used to special-case it in several modules. This module is the one +//! place that describes the operation; the v1 modules only assemble its output +//! in their own passes. +//! +//! It provides a descriptor and pure functions rather than running a pass of +//! its own: the operation has to be injected into the operation set and its +//! DTO types have to be forked while v1 generates those, so taking over the +//! execution order would add coupling without owning anything. + +use crate::v1::o; +use crate::v1::rust; +use crate::v1::write_dir_file; +use crate::v1::{OPS_GENERATED_DIR, Operation, Operations, RustTypes, codegen_file_header}; + +use scoped_writer::g; + +/// The facts of the synthetic `PostObject` operation. +/// +/// The descriptor is the single source of truth for the name, the DTO type +/// names and the placeholder Smithy shapes; the emitters read it instead of +/// repeating the literals. +pub(crate) struct SyntheticOp { + /// Operation name: the S3 trait method name and the generated file name. + pub(crate) name: &'static str, + /// Rust input DTO type name. + pub(crate) input: &'static str, + /// Rust output DTO type name. + pub(crate) output: &'static str, + /// Placeholder Smithy input shape: the request is not modeled as a shape. + pub(crate) smithy_input: &'static str, + /// Placeholder Smithy output shape: the response is not modeled as a shape. + pub(crate) smithy_output: &'static str, + /// The operation whose input DTO the synthetic operation forks. + pub(crate) fork_input: &'static str, + /// The operation whose output DTO the synthetic operation forks. + pub(crate) fork_output: &'static str, + /// HTTP method of the placeholder route. + pub(crate) http_method: &'static str, + /// HTTP URI of the placeholder route. + pub(crate) http_uri: &'static str, + /// HTTP status of the placeholder route. + pub(crate) http_code: u16, +} + +/// Returns the descriptor of the synthetic `PostObject` operation. +pub(crate) fn descriptor() -> SyntheticOp { + SyntheticOp { + name: "PostObject", + input: "PostObjectInput", + output: "PostObjectOutput", + smithy_input: "Unit", + smithy_output: "Unit", + fork_input: "PutObjectInput", + fork_output: "PutObjectOutput", + http_method: "POST", + http_uri: "/{Bucket}", + http_code: 200, + } +} + +/// The name of the synthetic operation. +/// +/// Emitters that compare operation names read it here instead of repeating the literal. +pub(crate) fn name() -> &'static str { + descriptor().name +} + +/// Whether `name` is the synthetic operation. +/// +/// The passes that enumerate the Smithy-modeled operations skip it. +pub(crate) fn is_synthetic(name: &str) -> bool { + name == descriptor().name +} + +/// Whether `name` is one of the synthetic operation's DTO type names. +/// +/// The aws-sdk conversion has no corresponding types to map them to. +pub(crate) fn is_synthetic_type(name: &str) -> bool { + let op = descriptor(); + name == op.input || name == op.output +} + +/// Emits the request fixture of the synthetic operation into the ops test file. +/// +/// The caller keeps the emission position, so the generated file stays byte for byte the same. +pub(crate) fn codegen_test_fixture() { + g([ + "/// Builds a request for the direct call of the synthetic multipart operation.", + "fn post_object_request() -> S3Request {", + " S3Request {", + " input: PostObjectInput::default(),", + " method: Method::POST,", + " uri: Uri::from_static(\"/bucket/key\"),", + " headers: HeaderMap::new(),", + " extensions: Extensions::new(),", + " credentials: None,", + " region: None,", + " service: None,", + " trailing_headers: None,", + " }", + "}", + "", + ]); +} + +/// Emits the direct-call test of the synthetic operation into the ops test file. +/// +/// Its input comes from a verified multipart form, which the request table cannot produce; +/// the family covers it with a direct call; the test also pins the default bridge to `put_object`. +pub(crate) fn codegen_generated_test() { + g([ + "/// The synthetic POST Object operation is covered by a direct call: its", + "/// input comes from a verified multipart form, which the request table", + "/// cannot produce. The default body delegates to put_object.", + "#[tokio::test]", + "async fn post_object_delegates_to_put_object() {", + " let err = DefaultS3", + " .post_object(post_object_request())", + " .await", + " .expect_err(\"the default body delegates to put_object, which reports NotImplemented\");", + " assert_eq!(err.code().as_str(), \"NotImplemented\");", + "", + " let recorder = RecordingS3::default();", + " let err = recorder", + " .post_object(post_object_request())", + " .await", + " .expect_err(\"the recording double reports NotImplemented\");", + " assert_eq!(err.code().as_str(), \"NotImplemented\");", + " assert_eq!(recorder.take().as_slice(), [\"PostObject\"].as_slice());", + "", + " let mut req = post_object_request();", + " PassThroughAccess", + " .post_object(&mut req)", + " .await", + " .expect(\"the default access body allows the request\");", + "}", + "", + ]); +} + +/// Injects the synthetic operation into the operation set. +/// +/// The upstream model has no shape for it; this is where the placeholder the other passes read comes from. +pub(crate) fn inject_operation(operations: &mut Operations) { + if operations.contains_key(descriptor().name) { + return; + } + let op = descriptor(); + let operation = Operation { + name: o(op.name), + + input: o(op.input), + output: o(op.output), + + // Placeholders: there is no Smithy-modeled input/output for the synthetic operation. + smithy_input: o(op.smithy_input), + smithy_output: o(op.smithy_output), + + s3_unwrapped_xml_output: false, + doc: None, + + http_method: o(op.http_method), + http_uri: o(op.http_uri), + http_code: op.http_code, + + is_minio: false, + }; + assert!(operations.insert(operation.name.clone(), operation).is_none()); +} + +/// Emits the generated operation module of the synthetic operation. +/// +/// It behaves like the operation it forks, but stays a separate unit at the trait layer. +#[allow(clippy::too_many_lines)] +pub(crate) fn codegen_fork_op(rust_types: &RustTypes) { + // PostObject is a synthetic operation: same behavior as PutObject (multipart form upload), + // but separated at the trait layer so implementations can distinguish PUT vs POST later. + // PostObjectInput has extra fields for POST-specific behavior (success_action_redirect, success_action_status). + let Some(rust::Type::Struct(put_in)) = rust_types.get("PutObjectInput") else { return }; + let Some(rust::Type::Struct(_put_out)) = rust_types.get("PutObjectOutput") else { return }; + let Some(rust::Type::Struct(post_in)) = rust_types.get("PostObjectInput") else { return }; + let Some(rust::Type::Struct(_post_out)) = rust_types.get("PostObjectOutput") else { return }; + + // PostObjectInput has extra fields, so we only verify that common fields match. + assert!(post_in.fields.len() >= put_in.fields.len()); + for (a, b) in put_in.fields.iter().zip(post_in.fields.iter()) { + assert_eq!(a.name, b.name); + assert_eq!(a.type_, b.type_); + assert_eq!(a.option_type, b.option_type); + } + + write_dir_file(OPS_GENERATED_DIR, "post_object.rs", || { + codegen_file_header(); + g([ + "use crate::dto::*;", + "use crate::error::*;", + "use crate::http;", + "use crate::ops::{CallContext, PutObject};", + "", + ]); + g(["pub struct PostObject;", "", "impl PostObject {"]); + g([ + " pub fn deserialize_http(req: &mut http::Request) -> S3Result {", + " let Some(m) = req.s3ext.multipart.take() else {", + " return Err(invalid_request!(\"missing multipart form\"));", + " };", + "", + " // Parse POST-specific fields before consuming the multipart form", + " let success_action_redirect: Option = match http::parse_field_value(&m, \"success_action_redirect\")? {", + " Some(v) => Some(v),", + " None => http::parse_field_value(&m, \"redirect\")?,", + " };", + " let success_action_status: Option = http::parse_field_value(&m, \"success_action_status\")?;", + "", + " // Get the validated POST policy from request extensions", + " let policy = req.s3ext.post_policy.take();", + "", + " let put_input = PutObject::deserialize_http_multipart(req, m)?;", + " let mut post_input = put_object_input_into_post_object_input(put_input);", + " post_input.success_action_redirect = success_action_redirect;", + " post_input.success_action_status = success_action_status;", + " post_input.policy = policy;", + " Ok(post_input)", + " }", + "", + " pub fn serialize_http(", + " bucket: &str,", + " key: &str,", + " success_action_redirect: Option<&str>,", + " success_action_status: Option,", + " output: &PostObjectOutput,", + " ) -> S3Result {", + " let etag_str = output.e_tag.as_ref().map(ETag::value).unwrap_or_default();", + "", + " // Handle success_action_redirect: return 303 See Other with Location header", + " if let Some(redirect_url) = success_action_redirect {", + " // Defense-in-depth: Reject URLs with control characters that could enable header injection", + " if redirect_url.chars().any(char::is_control) {", + " return Err(s3_error!(InvalidArgument, \"success_action_redirect contains invalid control characters\"));", + " }", + "", + " // Parse the URL to validate and manipulate it properly", + " let mut url = url::Url::parse(redirect_url).map_err(|e| s3_error!(e, InvalidArgument, \"Invalid redirect URL\"))?;", + "", + " // Add query parameters (bucket, key, etag) to the URL", + " url.query_pairs_mut()", + " .append_pair(\"bucket\", bucket)", + " .append_pair(\"key\", key)", + " .append_pair(\"etag\", etag_str);", + "", + " let mut res = http::Response::with_status(http::StatusCode::SEE_OTHER);", + " res.headers.insert(", + " hyper::header::LOCATION,", + " url.as_str().parse().map_err(|e| s3_error!(e, InternalError))?", + " );", + " return Ok(res);", + " }", + "", + " // Handle success_action_status", + " match success_action_status {", + " Some(200) => {", + " // 200 OK with empty body", + " Ok(http::Response::with_status(http::StatusCode::OK))", + " }", + " Some(201) => {", + " // 201 Created with XML body using PostResponse DTO", + " let location = format!(\"/{bucket}/{key}\");", + " let post_response = crate::dto::PostResponse {", + " location: &location,", + " bucket,", + " key,", + " etag: etag_str,", + " };", + " let mut res = http::Response::with_status(http::StatusCode::CREATED);", + " http::set_xml_body(&mut res, &post_response)?;", + " Ok(res)", + " }", + " _ => {", + " // 204 No Content (default, also for unrecognized values)", + " Ok(http::Response::with_status(http::StatusCode::NO_CONTENT))", + " }", + " }", + " }", + "}", + "", + ]); + + g(["#[async_trait::async_trait]", "impl crate::ops::Operation for PostObject {"]); + g([" fn name(&self) -> &'static str {", " \"PostObject\"", " }", ""]); + g([" fn needs_full_body(&self) -> bool {", " false", " }", ""]); + g([" fn has_request_payload(&self) -> bool {", " true", " }", ""]); + g([ + " fn has_streaming_body(&self) -> bool {", + " // POST Object's body is the multipart file stream governed by", + " // `post_object_max_file_size`, not the request body wrapped by", + " // `put_object_max_size`.", + " false", + " }", + "", + ]); + + g([ + " async fn call(&self, ccx: &CallContext<'_>, req: &mut http::Request) -> S3Result {", + " let post_input = Self::deserialize_http(req)?;", + " // Save POST-specific fields before conversion", + " let success_action_redirect = post_input.success_action_redirect.clone();", + " let success_action_status = post_input.success_action_status;", + " let bucket = post_input.bucket.clone();", + " let key = post_input.key.clone();", + "", + " let put_input = post_object_input_into_put_object_input(post_input);", + " let mut put_req = crate::ops::build_s3_request(put_input, req);", + " let s3 = ccx.s3;", + " if let Some(access) = ccx.access {", + " // Keep backward-compatible behavior: POST object used to be gated by put_object access check.", + " access.put_object(&mut put_req).await?;", + " }", + " let mut post_req = put_req.map_input(put_object_input_into_post_object_input);", + " // Restore POST-specific fields that were lost during conversion", + " post_req.input.success_action_redirect.clone_from(&success_action_redirect);", + " post_req.input.success_action_status = success_action_status;", + " if let Some(access) = ccx.access {", + " // New hook for POST object (optional).", + " access.post_object(&mut post_req).await?;", + " }", + " let result = s3.post_object(post_req).await;", + " let s3_resp = match result {", + " Ok(val) => val,", + " Err(err) => return crate::ops::serialize_error_for_method(&req.method, err, false),", + " };", + " // Serialize with POST-specific response behavior", + " let mut resp = Self::serialize_http(", + " &bucket,", + " &key,", + " success_action_redirect.as_deref(),", + " success_action_status,", + " &s3_resp.output,", + " )?;", + " if let Some(status) = s3_resp.status {", + " resp.status = status;", + " }", + " resp.headers.extend(s3_resp.headers);", + " resp.extensions.extend(s3_resp.extensions);", + " if http::is_bodyless_status(resp.status) {", + " http::strip_bodyless(&mut resp);", + " }", + " Ok(resp)", + " }", + "}", + ]); + + g!(); + }); +} + +/// Forks the unified DTO types of the operation the synthetic one derives from. +/// +/// The synthetic operation is not modeled, so its DTO types are copies of the forked ones, renamed. +pub(crate) fn codegen_dto_clone(space: &mut RustTypes) { + let op = descriptor(); + for (src, dst) in [(op.fork_input, op.input), (op.fork_output, op.output)] { + if let Some(src_ty) = space.get(src).cloned() { + let mut dst_ty = src_ty; + match &mut dst_ty { + rust::Type::Struct(s) => { + dst.clone_into(&mut s.name); + } + _ => { + // PutObject{Input,Output} are expected to be structs. + unimplemented!("{src} is not a struct"); + } + } + assert!(space.insert(dst.to_owned(), dst_ty).is_none()); + } + } +} + +/// Adds the POST-only fields to the input DTO of the synthetic operation. +/// +/// The fields are not in the forked input; their position is s3s, so serializers and routers ignore them. +pub(crate) fn codegen_post_only_fields(space: &mut RustTypes) { + let Some(rust::Type::Struct(post_in)) = space.get_mut(descriptor().input) else { return }; + post_in.fields.push(rust::StructField { + name: o("success_action_redirect"), + type_: o("String"), + option_type: true, + position: o("s3s"), + doc: Some(o("The URL to which the client is redirected upon successful upload.")), + ..rust::StructField::default() + }); + post_in.fields.push(rust::StructField { + name: o("success_action_status"), + type_: o("i32"), + option_type: true, + position: o("s3s"), + doc: Some(o( + "The status code returned to the client upon successful upload. Valid values are 200, 201, and 204.", + )), + ..rust::StructField::default() + }); + post_in.fields.push(rust::StructField { + name: o("policy"), + type_: o("PostPolicy"), + option_type: true, + position: o("s3s"), + doc: Some(o("The POST policy document that was included in the request.")), + ..rust::StructField::default() + }); +} + +/// Emits the default bridge method of the synthetic operation into the `S3` trait. +/// +/// The scaffold delegates to the operation it forks, so an implementation that only implements that one keeps serving the synthetic operation while it migrates. +pub(crate) fn codegen_trait_default() { + g([ + "/// POST Object (multipart form upload)", + "///", + "/// This is a synthetic method separated from `PutObject` so implementations can distinguish", + "/// POST vs PUT.", + "///", + "/// The default implementation is a migration scaffold, not a correct POST implementation: it", + "/// converts the request into a [`S3::put_object`] call, and the shape it forwards has no place", + "/// for the form (its fields, the file name and the policy), the framing facts (boundary, tail", + "/// length) or the `success_action_*` response semantics.", + "///", + "/// It is kept so that an implementation which only implements [`S3::put_object`] keeps serving", + "/// POST requests while it migrates; the scaffold may be removed in a future minor release.", + "///", + "/// An implementation that cares about POST semantics should implement this method explicitly", + "/// instead of relying on the default.", + ]); + g!("async fn post_object(&self, req: S3Request) -> S3Result> {{"); + g!("let resp = self.put_object(req.map_input(crate::dto::post_object_input_into_put_object_input)).await?;"); + g!("Ok(resp.map_output(crate::dto::put_object_output_into_post_object_output))"); + g!("}}"); + g!(); +} + +/// Emits the POST-specific notes of the access hook documentation. +/// +/// The hook is an addition to the check of the operation it forks, not a replacement. +pub(crate) fn codegen_access_hook() { + g!("/// "); + g!("/// POST Object is not a synonym for `PutObject`: the request carries a form (its fields,"); + g!("/// the file name and the policy) and the response carries the `success_action_*`"); + g!("/// semantics. The default path runs the `put_object` check as well, so this hook is an"); + g!("/// addition to it rather than a replacement."); +} +/// Emits the conversion helpers between the forked DTO types and the synthetic ones. +pub(crate) fn codegen_mapping_helpers(rust_types: &RustTypes) { + let Some(rust::Type::Struct(put_in)) = rust_types.get("PutObjectInput") else { return }; + let Some(rust::Type::Struct(put_out)) = rust_types.get("PutObjectOutput") else { return }; + let Some(rust::Type::Struct(post_in)) = rust_types.get("PostObjectInput") else { return }; + let Some(rust::Type::Struct(post_out)) = rust_types.get("PostObjectOutput") else { return }; + + // PostObjectInput has extra fields (success_action_redirect, success_action_status). + // We verify that the common fields (those from PutObjectInput) match. + assert!(post_in.fields.len() >= put_in.fields.len()); + for (a, b) in put_in.fields.iter().zip(post_in.fields.iter()) { + assert_eq!(a.name, b.name); + assert_eq!(a.type_, b.type_); + assert_eq!(a.option_type, b.option_type); + } + assert_eq!(put_out.fields.len(), post_out.fields.len()); + for (a, b) in put_out.fields.iter().zip(post_out.fields.iter()) { + assert_eq!(a.name, b.name); + assert_eq!(a.type_, b.type_); + assert_eq!(a.option_type, b.option_type); + } + + // Collect POST-only field names (those not in PutObjectInput) + let put_in_field_names: std::collections::BTreeSet<_> = put_in.fields.iter().map(|f| f.name.as_str()).collect(); + let post_only_fields: Vec<_> = post_in + .fields + .iter() + .filter(|f| !put_in_field_names.contains(f.name.as_str())) + .collect(); + + g!(); + g([ + "// NOTE: PostObject is a synthetic API in s3s.", + "// PostObjectInput has extra fields for POST-specific behavior (success_action_redirect, success_action_status).", + ]); + + g!("pub(crate) fn put_object_input_into_post_object_input(x: PutObjectInput) -> PostObjectInput {{"); + g!(" PostObjectInput {{"); + for field in &put_in.fields { + g!(" {}: x.{},", field.name, field.name); + } + // POST-only fields get default values + for field in &post_only_fields { + g!(" {}: None,", field.name); + } + g!(" }}"); + g!("}}"); + + g!("pub(crate) fn post_object_input_into_put_object_input(x: PostObjectInput) -> PutObjectInput {{"); + g!(" PutObjectInput {{"); + // Only copy fields that exist in PutObjectInput + for field in &put_in.fields { + g!(" {}: x.{},", field.name, field.name); + } + g!(" }}"); + g!("}}"); + + g!("pub(crate) fn put_object_output_into_post_object_output(x: PutObjectOutput) -> PostObjectOutput {{"); + g!(" PostObjectOutput {{"); + for field in &put_out.fields { + g!(" {}: x.{},", field.name, field.name); + } + g!(" }}"); + g!("}}"); + + // This function is currently unused but kept for symmetry and potential future use + g!("#[allow(dead_code)]"); + g!("pub(crate) fn post_object_output_into_put_object_output(x: PostObjectOutput) -> PutObjectOutput {{"); + g!(" PutObjectOutput {{"); + for field in &post_out.fields { + g!(" {}: x.{},", field.name, field.name); + } + g!(" }}"); + g!("}}"); +}