diff --git a/README.rst b/README.rst index fdc7f4ef..1041b9e1 100644 --- a/README.rst +++ b/README.rst @@ -530,7 +530,6 @@ Simple example: $ http PUT httpbin.org/put name=John email=john@example.org .. code-block:: http - PUT / HTTP/1.1 Accept: application/json, */*;q=0.5 Accept-Encoding: gzip, deflate @@ -1745,6 +1744,60 @@ exchange after it has been created, specify the session name via # But it is not updated: $ http --session-read-only=./ro-session.json httpbin.org/headers Custom-Header:new-value +Cookie Storage Behaviour +------------------------ + +**TL;DR:** Cookie storage priority: Server response > Command line request > Session file + +To set a cookie within a Session there are three options: + +1. Get a `Set-Cookie` header in a response from a server + +.. code-block:: bash + + $ http --session=./session.json httpbin.org/cookie/set?foo=bar + +2. Set the cookie name and value through the command line as seen in `cookies`_ + +.. code-block:: bash + + $ http --session=./session.json httpbin.org/headers Cookie:foo=bar + +3. Manually set cookie parameters in the json file of the session + +.. code-block:: json + + { + "__meta__": { + "about": "HTTPie session file", + "help": "https://httpie.org/doc#sessions", + "httpie": "2.2.0-dev" + }, + "auth": { + "password": null, + "type": null, + "username": null + }, + "cookies": { + "foo": { + "expires": null, + "path": "/", + "secure": false, + "value": "bar" + } + } + } + +Cookies will be set in the session file with the priority specified above. For example, a cookie +set through the command line will overwrite a cookie of the same name stored +in the session file. If the server returns a `Set-Cookie` header with a +cookie of the same name, the returned cookie will overwrite the preexisting cookie. + +Expired cookies are never stored. If a cookie in a session file expires, it will be removed before +sending a new request. If the server expires an existing cookie, it will also be removed from the +session file. + + Config ====== diff --git a/httpie/sessions.py b/httpie/sessions.py index c8581fa6..5a4acef8 100644 --- a/httpie/sessions.py +++ b/httpie/sessions.py @@ -4,6 +4,8 @@ Persistent, JSON-serialized sessions. """ import os import re + +from http.cookies import SimpleCookie from pathlib import Path from typing import Iterable, Optional, Union from urllib.parse import urlsplit @@ -76,7 +78,13 @@ class Session(BaseConfigDict): continue # Ignore explicitly unset headers value = value.decode('utf8') - if name == 'User-Agent' and value.startswith('HTTPie/'): + if name.lower() == 'user-agent' and value.startswith('HTTPie/'): + continue + + if name.lower() == 'cookie': + for cookie_name, morsel in SimpleCookie(value).items(): + self['cookies'][cookie_name] = {'value': morsel.value} + del request_headers[name] continue for prefix in SESSION_IGNORED_HEADER_PREFIXES: diff --git a/tests/test_sessions.py b/tests/test_sessions.py index 6cb5ce0e..724cc4e8 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -35,6 +35,27 @@ class SessionTestBase: return MockEnvironment(config_dir=self.config_dir) +class CookieTestBase: + def setup_method(self, method): + self.config_dir = mk_config_dir() + + orig_session = { + 'cookies': { + 'cookie1': { + 'value': 'foo', + }, + 'cookie2': { + 'value': 'foo', + } + } + } + self.session_path = self.config_dir / 'test-session.json' + self.session_path.write_text(json.dumps(orig_session)) + + def teardown_method(self, method): + shutil.rmtree(self.config_dir) + + class TestSessionFlow(SessionTestBase): """ These tests start with an existing session created in `setup_method()`. @@ -191,13 +212,7 @@ class TestSession(SessionTestBase): os.chdir(cwd) -class TestExpiredCookies: - - def setup_method(self, method): - self.config_dir = mk_config_dir() - - def teardown_method(self, method): - shutil.rmtree(self.config_dir) +class TestExpiredCookies(CookieTestBase): @pytest.mark.parametrize( argnames=['initial_cookie', 'expired_cookie'], @@ -213,29 +228,16 @@ class TestExpiredCookies: assert expired_cookie not in session.cookies def test_expired_cookies(self, httpbin): - orig_session = { - 'cookies': { - 'to_expire': { - 'value': 'foo' - }, - 'to_stay': { - 'value': 'foo' - }, - } - } - session_path = self.config_dir / 'test-session.json' - session_path.write_text(json.dumps(orig_session)) - r = http( - '--session', str(session_path), + '--session', str(self.session_path), '--print=H', - httpbin.url + '/cookies/delete?to_expire', + httpbin.url + '/cookies/delete?cookie2', ) - assert 'Cookie: to_expire=foo; to_stay=foo' in r + assert 'Cookie: cookie1=foo; cookie2=foo' in r - updated_session = json.loads(session_path.read_text()) - assert 'to_stay' in updated_session['cookies'] - assert 'to_expire' not in updated_session['cookies'] + updated_session = json.loads(self.session_path.read_text()) + assert 'cookie1' in updated_session['cookies'] + assert 'cookie2' not in updated_session['cookies'] @pytest.mark.parametrize( argnames=['headers', 'now', 'expected_expired'], @@ -277,3 +279,90 @@ class TestExpiredCookies: ) def test_get_expired_cookies_manages_multiple_cookie_headers(self, headers, now, expected_expired): assert get_expired_cookies(headers, now=now) == expected_expired + + +class TestCookieStorage(CookieTestBase): + + @pytest.mark.parametrize( + argnames=['new_cookies', 'new_cookies_dict', 'expected'], + argvalues=[( + 'new=bar', + {'new': 'bar'}, + 'cookie1=foo; cookie2=foo; new=bar' + ), + ( + 'new=bar;chocolate=milk', + {'new': 'bar', 'chocolate': 'milk'}, + 'chocolate=milk; cookie1=foo; cookie2=foo; new=bar' + ), + ( + 'new=bar; chocolate=milk', + {'new': 'bar', 'chocolate': 'milk'}, + 'chocolate=milk; cookie1=foo; cookie2=foo; new=bar' + ), + ( + 'new=bar;; chocolate=milk;;;', + {'new': 'bar', 'chocolate': 'milk'}, + 'cookie1=foo; cookie2=foo; new=bar' + ), + ( + 'new=bar; chocolate=milk;;;', + {'new': 'bar', 'chocolate': 'milk'}, + 'chocolate=milk; cookie1=foo; cookie2=foo; new=bar' + ) + ] + ) + def test_existing_and_new_cookies_sent_in_request(self, new_cookies, new_cookies_dict, expected, httpbin): + r = http( + '--session', str(self.session_path), + '--print=H', + httpbin.url, + 'Cookie:' + new_cookies, + ) + # Note: cookies in response are in alphabetical order + assert 'Cookie: ' + expected in r + + updated_session = json.loads(self.session_path.read_text()) + for name, value in new_cookies_dict.items(): + assert name, value in updated_session['cookies'] + assert 'Cookie' not in updated_session['headers'] + + @pytest.mark.parametrize( + argnames=['cli_cookie', 'set_cookie', 'expected'], + argvalues=[( + '', + '/cookies/set/cookie1/bar', + 'bar' + ), + ( + 'cookie1=not_foo', + '/cookies/set/cookie1/bar', + 'bar' + ), + ( + 'cookie1=not_foo', + '', + 'not_foo' + ), + ( + '', + '', + 'foo' + ) + ] + ) + def test_cookie_storage_priority(self, cli_cookie, set_cookie, expected, httpbin): + """ + Expected order of priority for cookie storage in session file: + 1. set-cookie (from server) + 2. command line arg + 3. cookie already stored in session file + """ + r = http( + '--session', str(self.session_path), + httpbin.url + set_cookie, + 'Cookie:' + cli_cookie, + ) + updated_session = json.loads(self.session_path.read_text()) + + assert updated_session['cookies']['cookie1']['value'] == expected