diff --git a/README.rst b/README.rst index 24ed6c46..2a84b157 100644 --- a/README.rst +++ b/README.rst @@ -106,6 +106,10 @@ The above can be further simplified by omitting ``GET`` and ``POST`` because the http -b https://api.github.com/repos/jkbr/httpie | http httpbin.org/post +An alternative to ``stdin`` is to pass a file name whose content will be used as the request body. It has the advantage that the ``Content-Type`` header will automatically be set to the appropriate value based on the filename extension (using the ``mimetypes`` module). Therefore, the following will request will send the verbatim contents of the file with ``Content-Type: application/xml``:: + + http PUT httpbin.org/put @/data/file.xml + Flags ^^^^^ @@ -215,6 +219,7 @@ Changelog --------- * `0.2.3dev `_ + * Added support for request payloads from a file path with automatic ``Content-Type`` (``http URL @/path``). * `0.2.2 `_ (2012-06-24) * The ``METHOD`` positional argument can now be omitted (defaults to ``GET``, or to ``POST`` with data). * Fixed --verbose --form. diff --git a/httpie/__main__.py b/httpie/__main__.py index 3a25c14f..07429195 100644 --- a/httpie/__main__.py +++ b/httpie/__main__.py @@ -24,8 +24,8 @@ def _get_response(parser, args, stdin, stdin_isatty): 'Content-Type' not in args.headers and (args.data or args.json)): args.headers['Content-Type'] = TYPE_JSON - if stdin_isatty: - # Serialize the parsed data. + if isinstance(args.data, dict): + # Serialize the data dict parsed from arguments. args.data = json.dumps(args.data) if 'Accept' not in args.headers: # Default Accept to JSON as well. diff --git a/httpie/cli.py b/httpie/cli.py index 6e84bf04..13f19b62 100644 --- a/httpie/cli.py +++ b/httpie/cli.py @@ -25,16 +25,16 @@ group_type = parser.add_mutually_exclusive_group(required=False) group_type.add_argument( '--json', '-j', action='store_true', help=_(''' - (default) Data items are serialized as a JSON object. + (default) Data items from the command line are serialized as a JSON object. The Content-Type and Accept headers - are set to application/json (if not set via the command line). + are set to application/json (if not specified). ''') ) group_type.add_argument( '--form', '-f', action='store_true', help=_(''' - Data items are serialized as form fields. - The Content-Type is set to application/x-www-form-urlencoded (if not specifid). + Data items from the command line are serialized as form fields. + The Content-Type is set to application/x-www-form-urlencoded (if not specified). The presence of any file fields results into a multipart/form-data request. ''') ) diff --git a/httpie/cliparse.py b/httpie/cliparse.py index 5ccb790b..8f3b67d3 100644 --- a/httpie/cliparse.py +++ b/httpie/cliparse.py @@ -7,6 +7,7 @@ import sys import re import json import argparse +import mimetypes from collections import namedtuple @@ -58,14 +59,14 @@ class Parser(argparse.ArgumentParser): self._guess_method(args, stdin_isatty) self._parse_items(args) if not stdin_isatty: - self._process_stdin(args, stdin) + self._body_from_file(args, stdin) return args - def _process_stdin(self, args, stdin): + def _body_from_file(self, args, f): if args.data: - self.error('Request body (stdin) and request ' + self.error('Request body (from stdin or a file) and request ' 'data (key=value) cannot be mixed.') - args.data = stdin.read() + args.data = f.read() def _guess_method(self, args, stdin_isatty=sys.stdin.isatty()): """ @@ -125,12 +126,26 @@ class Parser(argparse.ArgumentParser): self.error(e.message) if args.files and not args.form: - # We could just switch to --form automatically here, - # but I think it's better to make it explicit. - self.error( - ' You need to set the --form / -f flag to' - ' to issue a multipart request. File fields: %s' - % ','.join(args.files.keys())) + # `http url @/path/to/file` + # It's not --form so the file contents will be used as the + # body of the requests. Also, we try to detect the appropriate + # Content-Type. + if len(args.files) > 1: + self.error( + 'Only one file can be specified unless' + ' --form is used. File fields: %s' + % ','.join(args.files.keys())) + f = list(args.files.values())[0] + self._body_from_file(args, f) + args.files = {} + if 'Content-Type' not in args.headers: + mime, encoding = mimetypes.guess_type(f.name, strict=False) + if mime: + content_type = mime + if encoding: + content_type = '%s; charset=%s' % (mime, encoding) + args.headers['Content-Type'] = content_type + def _validate_output_options(self, args): unknown_output_options = set(args.output_options) - set(OUTPUT_OPTIONS) diff --git a/tests/file2.txt b/tests/file2.txt new file mode 100644 index 00000000..fba7ccb9 --- /dev/null +++ b/tests/file2.txt @@ -0,0 +1 @@ +__test_file_content__ diff --git a/tests/tests.py b/tests/tests.py index 3915c211..cba8ac42 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -18,6 +18,7 @@ from httpie import __main__, cliparse TEST_FILE_PATH = os.path.join(TESTS_ROOT, 'file.txt') +TEST_FILE2_PATH = os.path.join(TESTS_ROOT, 'file2.txt') TEST_FILE_CONTENT = open(TEST_FILE_PATH).read().strip() TERMINAL_COLOR_PRESENCE_CHECK = '\x1b[' @@ -180,10 +181,42 @@ class MultipartFormDataFileUploadTest(BaseTestCase): r = http('--form', 'POST', 'http://httpbin.org/post', 'test-file@%s' % TEST_FILE_PATH, 'foo=bar') self.assertIn('HTTP/1.1 200', r) - self.assertIn('"test-file": "__test_file_content__', r) + self.assertIn('"test-file": "%s' % TEST_FILE_CONTENT, r) self.assertIn('"foo": "bar"', r) +class RequestBodyFromFilePathTest(BaseTestCase): + """ + `http URL @file' + + """ + def test_request_body_from_file_by_path(self): + r = http('POST', 'http://httpbin.org/post', '@' + TEST_FILE_PATH) + self.assertIn('HTTP/1.1 200', r) + self.assertIn(TEST_FILE_CONTENT, r) + self.assertIn('"Content-Type": "text/plain"', r) + + def test_request_body_from_file_by_path_with_explicit_content_type(self): + r = http('POST', 'http://httpbin.org/post', '@' + TEST_FILE_PATH, 'Content-Type:x-foo/bar') + self.assertIn('HTTP/1.1 200', r) + self.assertIn(TEST_FILE_CONTENT, r) + self.assertIn('"Content-Type": "x-foo/bar"', r) + + def test_request_body_from_file_by_path_only_one_file_allowed(self): + self.assertRaises(SystemExit, lambda: http( + 'POST', + 'http://httpbin.org/post', + '@' + TEST_FILE_PATH, + '@' + TEST_FILE2_PATH)) + + def test_request_body_from_file_by_path_only_no_data_items_allowed(self): + self.assertRaises(SystemExit, lambda: http( + 'POST', + 'http://httpbin.org/post', + '@' + TEST_FILE_PATH, + 'foo=bar')) + + class AuthTest(BaseTestCase): def test_basic_auth(self): @@ -273,7 +306,7 @@ class ItemParsingTest(BaseTestCase): self.assertIn('test-file', files) -class HTTPieArgumentParserTestCase(unittest.TestCase): +class ArgumentParserTestCase(unittest.TestCase): def setUp(self): self.parser = cliparse.Parser()