Repository: ansible-collections/amazon.aws

Issue:
s3_object put with metadata specifying Content-Type results in ContentType being None (and boto3 raising exception)
### Summary

If I want to upload a file with a specific content-type, then I will set the metadata to have Content-Type:

```yaml
  - name: upload json
    amazon.aws.s3_object:
      bucket: "{{ discovery_bucket_name }}"
      content: "{{ discovery_tempfile.stdout }}"
      object: "discovery.json"
      mode: put
      metadata: 'ContentType=application/json'
```

however this causes the following kind of error:

<details>
  <summary>Traceback</summary>

```
Traceback (most recent call last):
  File "/tmp/ansible_amazon.aws.s3_object_payload_l1xlmt9s/ansible_amazon.aws.s3_object_payload.zip/ansible_collections/amazon/aws/plugins/modules/s3_object.py", line 756, in upload_s3file
  File "/tmp/ansible_amazon.aws.s3_object_payload_l1xlmt9s/ansible_amazon.aws.s3_object_payload.zip/ansible_collections/amazon/aws/plugins/module_utils/retries.py", line 105, in deciding_wrapper
    return retrying_wrapper(*args, **kwargs)
  File "/tmp/ansible_amazon.aws.s3_object_payload_l1xlmt9s/ansible_amazon.aws.s3_object_payload.zip/ansible_collections/amazon/aws/plugins/module_utils/cloud.py", line 119, in _retry_wrapper
    return _retry_func(
  File "/tmp/ansible_amazon.aws.s3_object_payload_l1xlmt9s/ansible_amazon.aws.s3_object_payload.zip/ansible_collections/amazon/aws/plugins/module_utils/cloud.py", line 68, in _retry_func
    return func()
  File "/usr/local/lib/python3.9/site-packages/boto3/s3/inject.py", line 636, in upload_fileobj
    return future.result()
  File "/usr/local/lib/python3.9/site-packages/s3transfer/futures.py", line 103, in result
    return self._coordinator.result()
  File "/usr/local/lib/python3.9/site-packages/s3transfer/futures.py", line 266, in result
    raise self._exception
  File "/usr/local/lib/python3.9/site-packages/s3transfer/tasks.py", line 139, in __call__
    return self._execute_main(kwargs)
  File "/usr/local/lib/python3.9/site-packages/s3transfer/tasks.py", line 162, in _execute_main
    return_value = self._main(**kwargs)
  File "/usr/local/lib/python3.9/site-packages/s3transfer/upload.py", line 764, in _main
    client.put_object(Bucket=bucket, Key=key, Body=body, **extra_args)
  File "/usr/local/lib/python3.9/site-packages/botocore/client.py", line 535, in _api_call
    return self._make_api_call(operation_name, kwargs)
  File "/usr/local/lib/python3.9/site-packages/botocore/client.py", line 936, in _make_api_call
    request_dict = self._convert_to_request_dict(
  File "/usr/local/lib/python3.9/site-packages/botocore/client.py", line 1010, in _convert_to_request_dict
    request_dict = self._serializer.serialize_to_request(
  File "/usr/local/lib/python3.9/site-packages/botocore/validate.py", line 381, in serialize_to_request
    raise ParamValidationError(report=report.generate_report())
botocore.exceptions.ParamValidationError: Parameter validation failed:
Invalid type for parameter ContentType, value: None, type: <class 'NoneType'>, valid types: <class 'str'>
```

</details>

This seems to be because this following code:

https://github.com/ansible-collections/amazon.aws/blob/main/plugins/modules/s3_object.py#L750

replaces `extra["ContentType"]` with the return value of `get_content_type` which, when `present = truthy` implicitly returns None:

https://github.com/ansible-collections/amazon.aws/blob/main/plugins/modules/s3_object.py#L657-L665

This was introduced in https://github.com/ansible-collections/amazon.aws/pull/1193 -- I have added the following comment: https://github.com/ansible-collections/amazon.aws/pull/1193/files#r1406429232


### Issue Type

Bug Report

### Component Name

s3_object

### Ansible Version

```console (paste below)
$ ansible --version

ansible [core 2.13.13]
  config file = /canupload/provision/ansible.cfg
  configured module search path = ['/root/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
  ansible python module location = /usr/local/lib/python3.9/site-packages/ansible
  ansible collection location = /root/.ansible/collections:/usr/share/ansible/collections
  executable location = /usr/local/bin/ansible
  python version = 3.9.18 (main, Nov 21 2023, 19:14:17) [GCC 10.2.1 20210110]
  jinja version = 3.1.2
  libyaml = True
```



### Collection Versions

```console (paste below)
$ ansible-galaxy collection list


# /usr/local/lib/python3.9/site-packages/ansible_collections
Collection                    Version
----------------------------- -------
amazon.aws                    3.5.0
ansible.netcommon             3.1.3
ansible.posix                 1.4.0
ansible.utils                 2.8.0
ansible.windows               1.12.0
arista.eos                    5.0.1
awx.awx                       21.10.0
azure.azcollection            1.14.0
check_point.mgmt              2.3.0
chocolatey.chocolatey         1.3.1
cisco.aci                     2.3.0
cisco.asa                     3.1.0
cisco.dnac                    6.6.1
cisco.intersight              1.0.22
cisco.ios                     3.3.2
cisco.iosxr                   3.3.1
cisco.ise                     2.5.9
cisco.meraki                  2.13.0
cisco.mso                     2.1.0
cisco.nso                     1.0.3
cisco.nxos                    3.2.0
cisco.ucs                     1.8.0
cloud.common                  2.1.2
cloudscale_ch.cloud           2.2.3
community.aws                 3.6.0
community.azure               1.1.0
community.ciscosmb            1.0.5
community.crypto              2.9.0
community.digitalocean        1.22.0
community.dns                 2.4.2
community.docker              2.7.3
community.fortios             1.0.0
community.general             5.8.3
community.google              1.0.0
community.grafana             1.5.3
community.hashi_vault         3.4.0
community.hrobot              1.6.0
community.libvirt             1.2.0
community.mongodb             1.4.2
community.mysql               3.5.1
community.network             4.0.2
community.okd                 2.2.0
community.postgresql          2.3.1
community.proxysql            1.4.0
community.rabbitmq            1.2.3
community.routeros            2.5.0
community.sap                 1.0.0
community.sap_libs            1.4.0
community.skydive             1.0.0
community.sops                1.5.0
community.vmware              2.10.2
community.windows             1.11.1
community.zabbix              1.9.0
containers.podman             1.10.1
cyberark.conjur               1.2.0
cyberark.pas                  1.0.14
dellemc.enterprise_sonic      1.1.2
dellemc.openmanage            5.5.0
dellemc.os10                  1.1.1
dellemc.os6                   1.0.7
dellemc.os9                   1.0.4
f5networks.f5_modules         1.21.0
fortinet.fortimanager         2.1.7
fortinet.fortios              2.2.1
frr.frr                       2.0.0
gluster.gluster               1.0.2
google.cloud                  1.0.2
hetzner.hcloud                1.9.0
hpe.nimble                    1.1.4
ibm.qradar                    2.1.0
ibm.spectrum_virtualize       1.10.0
infinidat.infinibox           1.3.12
infoblox.nios_modules         1.4.1
inspur.ispim                  1.2.0
inspur.sm                     2.3.0
junipernetworks.junos         3.1.0
kubernetes.core               2.3.2
lowlydba.sqlserver            1.2.0
mellanox.onyx                 1.0.0
netapp.aws                    21.7.0
netapp.azure                  21.10.0
netapp.cloudmanager           21.21.0
netapp.elementsw              21.7.0
netapp.ontap                  21.24.1
netapp.storagegrid            21.11.1
netapp.um_info                21.8.0
netapp_eseries.santricity     1.3.1
netbox.netbox                 3.9.0
ngine_io.cloudstack           2.3.0
ngine_io.exoscale             1.0.0
ngine_io.vultr                1.1.2
openstack.cloud               1.10.0
openvswitch.openvswitch       2.1.0
ovirt.ovirt                   2.4.1
purestorage.flasharray        1.15.0
purestorage.flashblade        1.10.0
purestorage.fusion            1.2.0
sensu.sensu_go                1.13.1
servicenow.servicenow         1.0.6
splunk.es                     2.1.0
t_systems_mms.icinga_director 1.31.4
theforeman.foreman            3.7.0
vmware.vmware_rest            2.2.0
vultr.cloud                   1.3.1
vyos.vyos                     3.0.1
wti.remote                    1.0.4

# /root/.ansible/collections/ansible_collections
Collection             Version
---------------------- -------
amazon.aws             7.0.0
community.aws          7.0.0
community.digitalocean 1.24.0
vultr.cloud            1.10.0
```


### AWS SDK versions

```console (paste below)
$ pip3 show boto boto3 botocore

pip3 show boto boto3 botocore
WARNING: Package(s) not found: boto
Name: boto3
Version: 1.29.6
Summary: The AWS SDK for Python
Home-page: https://github.com/boto/boto3
Author: Amazon Web Services
Author-email:
License: Apache License 2.0
Location: /usr/local/lib/python3.9/site-packages
Requires: botocore, jmespath, s3transfer
Required-by:
---
Name: botocore
Version: 1.32.6
Summary: Low-level, data-driven core of boto 3.
Home-page: https://github.com/boto/botocore
Author: Amazon Web Services
Author-email:
License: Apache License 2.0
Location: /usr/local/lib/python3.9/site-packages
Requires: jmespath, python-dateutil, urllib3
Required-by: boto3, s3transfer
```


### Configuration

```console (paste below)
$ ansible-config dump --only-changed

```


### OS / Environment

_No response_

### Steps to Reproduce

```yaml
  - name: upload json
    amazon.aws.s3_object:
      bucket: "{{ discovery_bucket_name }}"
      content: "{{ discovery_tempfile.stdout }}"
      object: "discovery.json"
      mode: put
      metadata: 'ContentType=application/json'
```



### Expected Results

I expected the file to be uploaded with a content type of application/json

### Actual Results

Error in Boto3 because ContentType is passed as None

```
Traceback (most recent call last):
  File "/tmp/ansible_amazon.aws.s3_object_payload_l1xlmt9s/ansible_amazon.aws.s3_object_payload.zip/ansible_collections/amazon/aws/plugins/modules/s3_object.py", line 756, in upload_s3file
  File "/tmp/ansible_amazon.aws.s3_object_payload_l1xlmt9s/ansible_amazon.aws.s3_object_payload.zip/ansible_collections/amazon/aws/plugins/module_utils/retries.py", line 105, in deciding_wrapper
    return retrying_wrapper(*args, **kwargs)
  File "/tmp/ansible_amazon.aws.s3_object_payload_l1xlmt9s/ansible_amazon.aws.s3_object_payload.zip/ansible_collections/amazon/aws/plugins/module_utils/cloud.py", line 119, in _retry_wrapper
    return _retry_func(
  File "/tmp/ansible_amazon.aws.s3_object_payload_l1xlmt9s/ansible_amazon.aws.s3_object_payload.zip/ansible_collections/amazon/aws/plugins/module_utils/cloud.py", line 68, in _retry_func
    return func()
  File "/usr/local/lib/python3.9/site-packages/boto3/s3/inject.py", line 636, in upload_fileobj
    return future.result()
  File "/usr/local/lib/python3.9/site-packages/s3transfer/futures.py", line 103, in result
    return self._coordinator.result()
  File "/usr/local/lib/python3.9/site-packages/s3transfer/futures.py", line 266, in result
    raise self._exception
  File "/usr/local/lib/python3.9/site-packages/s3transfer/tasks.py", line 139, in __call__
    return self._execute_main(kwargs)
  File "/usr/local/lib/python3.9/site-packages/s3transfer/tasks.py", line 162, in _execute_main
    return_value = self._main(**kwargs)
  File "/usr/local/lib/python3.9/site-packages/s3transfer/upload.py", line 764, in _main
    client.put_object(Bucket=bucket, Key=key, Body=body, **extra_args)
  File "/usr/local/lib/python3.9/site-packages/botocore/client.py", line 535, in _api_call
    return self._make_api_call(operation_name, kwargs)
  File "/usr/local/lib/python3.9/site-packages/botocore/client.py", line 936, in _make_api_call
    request_dict = self._convert_to_request_dict(
  File "/usr/local/lib/python3.9/site-packages/botocore/client.py", line 1010, in _convert_to_request_dict
    request_dict = self._serializer.serialize_to_request(
  File "/usr/local/lib/python3.9/site-packages/botocore/validate.py", line 381, in serialize_to_request
    raise ParamValidationError(report=report.generate_report())
botocore.exceptions.ParamValidationError: Parameter validation failed:
Invalid type for parameter ContentType, value: None, type: <class 'NoneType'>, valid types: <class 'str'>
```

### Code of Conduct

- [X] I agree to follow the Ansible Code of Conduct


Discussion:

Resolving patch:
diff --git a/plugins/modules/s3_object.py b/plugins/modules/s3_object.py
--- a/plugins/modules/s3_object.py
+++ b/plugins/modules/s3_object.py
@@ -654,15 +654,14 @@ def path_check(path):
         return False
 
 
-def get_content_type(src, present=True):
-    if not present:
-        content_type = None
-        if src:
-            content_type = mimetypes.guess_type(src)[0]
-        if content_type is None:
-            # s3 default content type
-            content_type = "binary/octet-stream"
-        return content_type
+def guess_content_type(src):
+    if src:
+        content_type = mimetypes.guess_type(src)[0]
+        if content_type:
+            return content_type
+
+    # S3 default content type
+    return "binary/octet-stream"
 
 
 def get_extra_params(
@@ -747,7 +746,8 @@ def upload_s3file(
             elif isinstance(permissions, list):
                 extra["ACL"] = permissions[0]
 
-        extra["ContentType"] = get_content_type(src, present=extra.get("ContentType"))
+        if "ContentType" not in extra:
+            extra["ContentType"] = guess_content_type(src)
 
         if src:
             s3.upload_file(aws_retry=True, Filename=src, Bucket=bucket, Key=obj, ExtraArgs=extra)


Test patch:
diff --git a/tests/integration/targets/s3_object/tasks/main.yml b/tests/integration/targets/s3_object/tasks/main.yml
--- a/tests/integration/targets/s3_object/tasks/main.yml
+++ b/tests/integration/targets/s3_object/tasks/main.yml
@@ -458,6 +458,34 @@
           - "'Object deleted from bucket' in result.msg"
           - result is changed
 
+    - name: test putting an object in the bucket with metadata set
+      s3_object:
+        bucket: "{{ bucket_name }}"
+        mode: put
+        src: "{{ remote_tmp_dir }}/upload.txt"
+        metadata: "Content-Type=text/plain"
+        object: delete_meta.txt
+        tags:
+            "lowercase spaced": "hello cruel world"
+            "Title Case": "Hello Cruel World"
+      retries: 3
+      delay: 3
+      register: result
+
+    - assert:
+        that:
+          - result is changed
+          - result.msg == "PUT operation complete"
+
+    - name: test delobj to just delete an object in the bucket
+      s3_object:
+        bucket: "{{ bucket_name }}"
+        mode: delobj
+        object: delete_meta.txt
+      retries: 3
+      delay: 3
+      register: result
+
     - name: test putting an encrypted object in the bucket
       s3_object:
         bucket: "{{ bucket_name }}"