atlassian-api / atlassian-python-api

Atlassian Python REST API wrapper
https://atlassian-python-api.readthedocs.io
Apache License 2.0
1.29k stars 642 forks source link

[Bug] HTTPError 415 when providing files using POST #1426

Open HackXIt opened 5 days ago

HackXIt commented 5 days ago

According to https://github.com/psf/requests/issues/1997 when using files in requests, one is not supposed to provide headers, so that the library will take care of the multipart/form-data with the appropriate content-type.

An example: (we wrote a custom extension just to do the XRAY API import)

        # ...
        files = {'file': (os.path.basename(result_file), open(result_file, 'rb'), 'application/xml')}
        return self.post(api_url, files=files, params=params)

Will result in: requests.exceptions.HTTPError: 415 Client Error: for url: https://[REDACTED]/rest/raven/1.0/api/import/execution/robot?projectKey=TCHCSIPDEV

This is due to the implementation in rest_client.py:

def request(
        self,
        method="GET",
        path="/",
        data=None,
        json=None,
        flags=None,
        params=None,
        headers=None,
        files=None,
        trailing=None,
        absolute=False,
        advanced_mode=False,
    ):
        """

        :param method:
        :param path:
        :param data:
        :param json:
        :param flags:
        :param params:
        :param headers:
        :param files:
        :param trailing: bool - OPTIONAL: Add trailing slash to url
        :param absolute: bool, OPTIONAL: Do not prefix url, url is absolute
        :param advanced_mode: bool, OPTIONAL: Return the raw response
        :return:
        """
        url = self.url_joiner(None if absolute else self.url, path, trailing)
        params_already_in_url = True if "?" in url else False
        if params or flags:
            if params_already_in_url:
                url += "&"
            else:
                url += "?"
        if params:
            url += urlencode(params or {})
        if flags:
            url += ("&" if params or params_already_in_url else "") + "&".join(flags or [])
        json_dump = None
        if files is None:
            data = None if not data else dumps(data)
            json_dump = None if not json else dumps(json)
        self.log_curl_debug(
            method=method,
            url=url,
            headers=headers,
            data=data if data else json_dump,
        )
        headers = headers or self.default_headers # <------- Always provides headers
        response = self._session.request(
            method=method,
            url=url,
            headers=headers, # <------- Should be None when providing files, so that the requests library handles file upload with appropriate headers
            data=data,
            json=json,
            timeout=self.timeout,
            verify=self.verify_ssl,
            files=files,
            proxies=self.proxies,
            cert=self.cert,
        )
        response.encoding = "utf-8"

        log.debug("HTTP: %s %s -> %s %s", method, path, response.status_code, response.reason)
        log.debug("HTTP: Response text -> %s", response.text)
        if self.advanced_mode or advanced_mode:
            return response

        self.raise_for_status(response)
        return response

If we change this to:

        headers = headers or self.default_headers if files is None else None

Then the request will work, as the multipart/form-data is appropriately handled by the requests library.

This is necessary in APIs like XRAY, where one can import result files, which are not JSON. But it should also be applicable for JSON files.

HackXIt commented 5 days ago

Provided files should also modify the behavior of:

def log_curl_debug(self, method, url, data=None, headers=None, level=logging.DEBUG):
        """

        :param method:
        :param url:
        :param data:
        :param headers:
        :param level:
        :return:
        """
        headers = headers or self.default_headers
        message = "curl --silent -X {method} -H {headers} {data} '{url}'".format(
            method=method,
            headers=" -H ".join(["'{0}: {1}'".format(key, value) for key, value in headers.items()]),
            data="" if not data else "--data '{0}'".format(dumps(data)),

Which currently does not consider files at all, but I understand that this a debugging functionality and is not applicable in many other cases.