Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ This project adheres to [Semantic Versioning](https://semver.org/).
## [2.6.0.dev0](https://github.com/httpie/httpie/compare/2.5.0...master) (unreleased)

- Added support for formatting & coloring of JSON bodies preceded by non-JSON data (e.g., an XXSI prefix). ([#1130](https://github.com/httpie/httpie/issues/1130))
- Added `--format-options=response.as:CONTENT_TYPE` to allow overriding the response `Content-Type`. ([#1134](https://github.com/httpie/httpie/issues/1134))
- Added `--response-as` shortcut for setting the response `Content-Type`-related `--format-options`. ([#1134](https://github.com/httpie/httpie/issues/1134))
- Installed plugins are now listed in `--debug` output. ([#1165](https://github.com/httpie/httpie/issues/1165))
- Fixed duplicate keys preservation of JSON data. ([#1163](https://github.com/httpie/httpie/issues/1163))

Expand Down
29 changes: 21 additions & 8 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1214,14 +1214,15 @@ You can further control the applied formatting via the more granular [format opt
The `--format-options=opt1:value,opt2:value` option allows you to control how the output should be formatted
when formatting is applied. The following options are available:

| Option | Default value | Shortcuts |
| ---------------: | :-----------: | ------------------------ |
| `headers.sort` | `true` | `--sorted`, `--unsorted` |
| `json.format` | `true` | N/A |
| `json.indent` | `4` | N/A |
| `json.sort_keys` | `true` | `--sorted`, `--unsorted` |
| `xml.format` | `true` | N/A |
| `xml.indent` | `2` | N/A |
| Option | Default value | Shortcuts |
| ---------------: | :-----------: | ----------------------------------------- |
| `headers.sort` | `true` | `--sorted`, `--unsorted` |
| `json.format` | `true` | N/A |
| `json.indent` | `4` | N/A |
| `json.sort_keys` | `true` | `--sorted`, `--unsorted` |
| `response.as` | `''` | [`--response-as`](#response-content-type) |
| `xml.format` | `true` | N/A |
| `xml.indent` | `2` | N/A |

For example, this is how you would disable the default header and JSON key
sorting, and specify a custom JSON indent size:
Expand All @@ -1236,6 +1237,18 @@ sorting-related format options (currently it means JSON keys and headers):

This is something you will typically store as one of the default options in your [config](#config) file.

#### Response `Content-Type`

The `--response-as=value` option is a shortcut for `--format-options response.as:value`,
and it allows you to override the response `Content-Type` sent by the server.
That makes it possible for HTTPie to pretty-print the response even when the server specifies the type incorrectly.

For example, the following request will force the response to be treated as XML:

```bash
$ http --response-as=application/xml pie.dev/get
```

### Binary data

Binary data is suppressed for terminal output, which makes it safe to perform requests to URLs that send back binary data.
Expand Down
5 changes: 4 additions & 1 deletion httpie/cli/argparser.py
Original file line number Diff line number Diff line change
Expand Up @@ -457,7 +457,10 @@ def _process_download_options(self):
self.error('--continue requires --output to be specified')

def _process_format_options(self):
format_options = self.args.format_options or []
if self.args.response_as is not None:
format_options.append('response.as:' + self.args.response_as)
parsed_options = PARSED_DEFAULT_FORMAT_OPTIONS
for options_group in self.args.format_options or []:
for options_group in format_options:
parsed_options = parse_format_options(options_group, defaults=parsed_options)
self.args.format_options = parsed_options
2 changes: 2 additions & 0 deletions httpie/cli/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,13 @@
PRETTY_STDOUT_TTY_ONLY = object()


EMPTY_FORMAT_OPTION = "''"
DEFAULT_FORMAT_OPTIONS = [
'headers.sort:true',
'json.format:true',
'json.indent:4',
'json.sort_keys:true',
'response.as:' + EMPTY_FORMAT_OPTION,
'xml.format:true',
'xml.indent:2',
]
Expand Down
14 changes: 14 additions & 0 deletions httpie/cli/definition.py
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,20 @@
'''
)

output_processing.add_argument(
'--response-as',
metavar='CONTENT_TYPE',
help='''
Override the response Content-Type for formatting purposes, e.g.:

--response-as=application/xml

It is a shortcut for:

--format-options=response.as:CONTENT_TYPE
'''
)


output_processing.add_argument(
'--format-options',
Expand Down
1 change: 1 addition & 0 deletions httpie/output/processing.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ def __init__(self, groups: List[str], env=Environment(), **kwargs):
:param kwargs: additional keyword arguments for processors

"""
self.options = kwargs['format_options']
available_plugins = plugin_manager.get_formatters_grouped()
self.enabled_plugins = []
for group in groups:
Expand Down
15 changes: 12 additions & 3 deletions httpie/output/streams.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@
from itertools import chain
from typing import Callable, Iterable, Union

from ..cli.constants import EMPTY_FORMAT_OPTION
from ..context import Environment
from ..constants import UTF8
from ..models import HTTPMessage
from ..models import HTTPMessage, HTTPResponse
from .processing import Conversion, Formatting

from .utils import parse_header_content_type

BINARY_SUPPRESSED_NOTICE = (
b'\n'
Expand Down Expand Up @@ -136,7 +137,15 @@ def __init__(
super().__init__(**kwargs)
self.formatting = formatting
self.conversion = conversion
self.mime = self.msg.content_type.split(';')[0]
self.mime = self.get_mime()

def get_mime(self) -> str:
mime = parse_header_content_type(self.msg.content_type)[0]
if isinstance(self.msg, HTTPResponse):
forced_content_type = self.formatting.options['response']['as']
if forced_content_type != EMPTY_FORMAT_OPTION:
mime = parse_header_content_type(forced_content_type)[0] or mime
return mime

def get_headers(self) -> bytes:
return self.formatting.format_headers(
Expand Down
54 changes: 54 additions & 0 deletions httpie/output/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,57 @@ def parse_prefixed_json(data: str) -> Tuple[str, str]:
data_prefix = matches[0] if matches else ''
body = data[len(data_prefix):]
return data_prefix, body


def parse_header_content_type(line):
"""Parse a Content-Type like header.
Return the main Content-Type and a dictionary of options.
>>> parse_header_content_type('application/xml; charset=utf-8')
('application/xml', {'charset': 'utf-8'})
>>> parse_header_content_type('application/xml; charset = utf-8')
('application/xml', {'charset': 'utf-8'})
>>> parse_header_content_type('application/html+xml;ChArSeT="UTF-8"')
('application/html+xml', {'charset': 'UTF-8'})
>>> parse_header_content_type('application/xml')
('application/xml', {})
>>> parse_header_content_type(';charset=utf-8')
('', {'charset': 'utf-8'})
>>> parse_header_content_type('charset=utf-8')
('', {'charset': 'utf-8'})
>>> parse_header_content_type('multipart/mixed; boundary="gc0pJq0M:08jU534c0p"')
('multipart/mixed', {'boundary': 'gc0pJq0M:08jU534c0p'})
>>> parse_header_content_type('Message/Partial; number=3; total=3; id="oc=jpbe0M2Yt4s@foo.com"')
('Message/Partial', {'number': '3', 'total': '3', 'id': 'oc=jpbe0M2Yt4s@foo.com'})
"""
# Source: https://github.com/python/cpython/blob/bb3e0c2/Lib/cgi.py#L230

def _parseparam(s: str):
# Source: https://github.com/python/cpython/blob/bb3e0c2/Lib/cgi.py#L218
while s[:1] == ';':
s = s[1:]
end = s.find(';')
while end > 0 and (s.count('"', 0, end) - s.count('\\"', 0, end)) % 2:
end = s.find(';', end + 1)
if end < 0:
end = len(s)
f = s[:end]
yield f.strip()
s = s[end:]

# Special case: 'key=value' only (without starting with ';').
if ';' not in line and '=' in line:
line = ';' + line

parts = _parseparam(';' + line)
key = parts.__next__()
pdict = {}
for p in parts:
i = p.find('=')
if i >= 0:
name = p[:i].strip().lower()
value = p[i + 1:].strip()
if len(value) >= 2 and value[0] == value[-1] == '"':
value = value[1:-1]
value = value.replace('\\\\', '\\').replace('\\"', '"')
pdict[name] = value
return key, pdict
19 changes: 19 additions & 0 deletions tests/test_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,9 @@ def test_parse_format_options_errors(self, options_string, expected_error):
'indent': 10,
'format': True
},
'response': {
'as': "''",
},
'xml': {
'format': True,
'indent': 2,
Expand All @@ -396,6 +399,9 @@ def test_parse_format_options_errors(self, options_string, expected_error):
'indent': 4,
'format': True
},
'response': {
'as': "''",
},
'xml': {
'format': True,
'indent': 2,
Expand All @@ -417,6 +423,9 @@ def test_parse_format_options_errors(self, options_string, expected_error):
'indent': 4,
'format': True
},
'response': {
'as': "''",
},
'xml': {
'format': True,
'indent': 2,
Expand All @@ -435,6 +444,7 @@ def test_parse_format_options_errors(self, options_string, expected_error):
(
[
'--format-options=json.indent:2',
'--format-options=response.as:application/xml; charset=utf-8',
'--format-options=xml.format:false',
'--format-options=xml.indent:4',
'--unsorted',
Expand All @@ -449,6 +459,9 @@ def test_parse_format_options_errors(self, options_string, expected_error):
'indent': 2,
'format': True
},
'response': {
'as': 'application/xml; charset=utf-8',
},
'xml': {
'format': False,
'indent': 4,
Expand All @@ -470,6 +483,9 @@ def test_parse_format_options_errors(self, options_string, expected_error):
'indent': 2,
'format': True
},
'response': {
'as': "''",
},
'xml': {
'format': True,
'indent': 2,
Expand All @@ -492,6 +508,9 @@ def test_parse_format_options_errors(self, options_string, expected_error):
'indent': 2,
'format': True
},
'response': {
'as': "''",
},
'xml': {
'format': True,
'indent': 2,
Expand Down
63 changes: 58 additions & 5 deletions tests/test_xml.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,21 @@
from .fixtures import XML_FILES_PATH, XML_FILES_VALID, XML_FILES_INVALID
from .utils import http, URL_EXAMPLE

SAMPLE_XML_DATA = '<?xml version="1.0" encoding="utf-8"?><root><e>text</e></root>'
XML_DATA_RAW = '<?xml version="1.0" encoding="utf-8"?><root><e>text</e></root>'
XML_DATA_FORMATTED = pretty_xml(parse_xml(XML_DATA_RAW))


@pytest.mark.parametrize(
'options, expected_xml',
[
('xml.format:false', SAMPLE_XML_DATA),
('xml.indent:2', pretty_xml(parse_xml(SAMPLE_XML_DATA))),
('xml.indent:4', pretty_xml(parse_xml(SAMPLE_XML_DATA), indent=4)),
('xml.format:false', XML_DATA_RAW),
('xml.indent:2', XML_DATA_FORMATTED),
('xml.indent:4', pretty_xml(parse_xml(XML_DATA_RAW), indent=4)),
]
)
@responses.activate
def test_xml_format_options(options, expected_xml):
responses.add(responses.GET, URL_EXAMPLE, body=SAMPLE_XML_DATA,
responses.add(responses.GET, URL_EXAMPLE, body=XML_DATA_RAW,
content_type='application/xml')

r = http('--format-options', options, URL_EXAMPLE)
Expand Down Expand Up @@ -83,3 +84,55 @@ def test_invalid_xml(file):
# No formatting done, data is simply printed as-is
r = http(URL_EXAMPLE)
assert xml_data in r


@responses.activate
def test_content_type_from_format_options_argument():
"""Test XML response with a incorrect Content-Type header.
Using the --format-options to force the good one.
"""
responses.add(responses.GET, URL_EXAMPLE, body=XML_DATA_RAW,
content_type='plain/text')
args = ('--format-options', 'response.as:application/xml',
URL_EXAMPLE)

# Ensure the option is taken into account only for responses.
# Request
r = http('--offline', '--raw', XML_DATA_RAW, *args)
assert XML_DATA_RAW in r

# Response
r = http(*args)
assert XML_DATA_FORMATTED in r


@responses.activate
def test_content_type_from_shortcut_argument():
"""Test XML response with a incorrect Content-Type header.
Using the --format-options shortcut to force the good one.
"""
responses.add(responses.GET, URL_EXAMPLE, body=XML_DATA_RAW,
content_type='text/plain')
args = ('--response-as', 'application/xml', URL_EXAMPLE)

# Ensure the option is taken into account only for responses.
# Request
r = http('--offline', '--raw', XML_DATA_RAW, *args)
assert XML_DATA_RAW in r

# Response
r = http(*args)
assert XML_DATA_FORMATTED in r


@responses.activate
def test_content_type_from_incomplete_format_options_argument():
"""Test XML response with a incorrect Content-Type header.
Using the --format-options to use a partial Content-Type without mime type.
"""
responses.add(responses.GET, URL_EXAMPLE, body=XML_DATA_RAW,
content_type='text/plain')

# The provided Content-Type is simply ignored, and so no formatting is done.
r = http('--response-as', 'charset=utf-8', URL_EXAMPLE)
assert XML_DATA_RAW in r