Skip to content

Commit 18faa77

Browse files
authored
Merge pull request MikeBrink#11 from corneyl/add-token-auth-support
Add support for authentication using auth-token
2 parents 37cbbb8 + 4b75d29 commit 18faa77

File tree

4 files changed

+120
-46
lines changed

4 files changed

+120
-46
lines changed

python_picnic_api/client.py

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
from .session import PicnicAPISession, PicnicAuthError
1+
from hashlib import md5
2+
23
from .helper import _tree_generator, _url_generator
4+
from .session import PicnicAPISession, PicnicAuthError
35

46
DEFAULT_URL = "https://storefront-prod.{}.picnicinternational.com/api/{}"
57
DEFAULT_COUNTRY_CODE = "NL"
@@ -8,17 +10,19 @@
810

911
class PicnicAPI:
1012
def __init__(
11-
self, username: str, password: str, country_code: str = DEFAULT_COUNTRY_CODE
13+
self, username: str = None, password: str = None,
14+
country_code: str = DEFAULT_COUNTRY_CODE, auth_token: str = None
1215
):
13-
self._username = username
14-
self._password = password
1516
self._country_code = country_code
1617
self._base_url = _url_generator(
1718
DEFAULT_URL, self._country_code, DEFAULT_API_VERSION
1819
)
1920

20-
self.session = PicnicAPISession()
21-
self.session.login(self._username, self._password, self._base_url)
21+
self.session = PicnicAPISession(auth_token=auth_token)
22+
23+
# Login if not authenticated
24+
if not self.session.authenticated and username and password:
25+
self.login(username, password)
2226

2327
def _get(self, path: str, add_picnic_headers=False):
2428
url = self._base_url + path
@@ -40,13 +44,27 @@ def _post(self, path: str, data=None):
4044
response = self.session.post(url, json=data).json()
4145

4246
if self._contains_auth_error(response):
43-
raise PicnicAuthError("Picnic authentication error")
47+
raise PicnicAuthError(f"Picnic authentication error: {response['error'].get('message')}")
4448

4549
return response
4650

4751
@staticmethod
4852
def _contains_auth_error(response):
49-
return isinstance(response, dict) and response.setdefault('error', {}).get('code') == 'AUTH_ERROR'
53+
if not isinstance(response, dict):
54+
return False
55+
56+
error_code = response.setdefault("error", {}).get("code")
57+
return error_code == "AUTH_ERROR" or error_code == "AUTH_INVALID_CRED"
58+
59+
def login(self, username: str, password: str):
60+
path = "/user/login"
61+
secret = md5(password.encode("utf-8")).hexdigest()
62+
data = {"key": username, "secret": secret, "client_id": 1}
63+
64+
return self._post(path, data)
65+
66+
def logged_in(self):
67+
return self.session.authenticated
5068

5169
def get_user(self):
5270
return self._get("/user")

python_picnic_api/session.py

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,54 @@
1-
from hashlib import md5
2-
from requests import Session
1+
from requests import Response, Session
32

43

54
class PicnicAuthError(Exception):
65
"""Indicates an error when authenticating to the Picnic API."""
76

87

98
class PicnicAPISession(Session):
10-
def __init__(self, *args, **kwargs):
11-
super().__init__(*args, **kwargs)
9+
AUTH_HEADER = "x-picnic-auth"
10+
11+
def __init__(self, auth_token: str = None):
12+
super().__init__()
13+
self._auth_token = auth_token
1214

1315
self.headers.update(
1416
{
1517
"User-Agent": "okhttp/3.9.0",
1618
"Content-Type": "application/json; charset=UTF-8",
19+
self.AUTH_HEADER: self._auth_token
1720
}
1821
)
1922

20-
def login(self, username: str, password: str, base_url: str):
21-
"""Login function for the Picnic API.
23+
@property
24+
def authenticated(self):
25+
"""Returns whether the user is authenticated by checking if the authentication token is set."""
26+
return bool(self._auth_token)
2227

23-
Args:
24-
username (str): username, usualy your email.
25-
password (str): password.
26-
"""
28+
@property
29+
def auth_token(self):
30+
"""Returns the auth token."""
31+
return self._auth_token
2732

28-
if "x-picnic-auth" in self.headers:
29-
self.headers.pop("x-picnic-auth", None)
33+
def _update_auth_token(self, auth_token):
34+
"""Update the auth token if not None and changed."""
35+
if auth_token and auth_token != self._auth_token:
36+
self._auth_token = auth_token
37+
self.headers.update({self.AUTH_HEADER: self._auth_token})
3038

31-
url = base_url + "/user/login"
39+
def get(self, url, **kwargs) -> Response:
40+
"""Do a GET request and update the auth token if set."""
41+
response = super(PicnicAPISession, self).get(url, **kwargs)
42+
self._update_auth_token(response.headers.get(self.AUTH_HEADER))
3243

33-
secret = md5(password.encode("utf-8")).hexdigest()
34-
data = {"key": username, "secret": secret, "client_id": 1}
44+
return response
3545

36-
response = self.post(url, json=data)
37-
if "x-picnic-auth" not in response.headers:
38-
raise PicnicAuthError("Could not authenticate against Picnic API")
46+
def post(self, url, data=None, json=None, **kwargs) -> Response:
47+
"""Do a POST request and update the auth token if set."""
48+
response = super(PicnicAPISession, self).post(url, data, json, **kwargs)
49+
self._update_auth_token(response.headers.get(self.AUTH_HEADER))
3950

40-
self.headers.update({"x-picnic-auth": response.headers["x-picnic-auth"]})
51+
return response
4152

4253

4354
__all__ = ["PicnicAuthError", "PicnicAPISession"]

tests/test_client.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,32 @@ def setUp(self) -> None:
2929
def tearDown(self) -> None:
3030
self.session_patcher.stop()
3131

32+
def test_login_credentials(self):
33+
self.session_mock().authenticated = False
34+
PicnicAPI(username='[email protected]', password='test')
35+
self.session_mock().post.assert_called_with(
36+
self.expected_base_url + '/user/login',
37+
json={'key': '[email protected]', 'secret': '098f6bcd4621d373cade4e832627b4f6', "client_id": 1}
38+
)
39+
40+
def test_login_auth_token(self):
41+
self.session_mock().authenticated = True
42+
PicnicAPI(username='[email protected]', password='test', auth_token='a3fwo7f3h78kf3was7h8f3ahf3ah78f3')
43+
self.session_mock().login.assert_not_called()
44+
45+
def test_login_failed(self):
46+
response = {
47+
"error": {
48+
"code": "AUTH_INVALID_CRED",
49+
"message": "Invalid credentials."
50+
}
51+
}
52+
self.session_mock().post.return_value = self.MockResponse(response, 200)
53+
54+
client = PicnicAPI()
55+
with self.assertRaises(PicnicAuthError):
56+
client.login('test-user', 'test-password')
57+
3258
def test_get_user(self):
3359
response = {
3460
"user_id": "594-241-3623",

tests/test_session.py

Lines changed: 38 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,26 @@
11
import unittest
2-
from hashlib import md5
32
from unittest.mock import patch
43

5-
from python_picnic_api.session import PicnicAPISession, PicnicAuthError
4+
from requests import Session
5+
6+
from python_picnic_api.session import PicnicAPISession
67

78

89
class TestSession(unittest.TestCase):
910
class MockResponse:
1011
def __init__(self, headers):
1112
self.headers = headers
1213

13-
def setUp(self) -> None:
14-
self.picnic_session = PicnicAPISession()
15-
16-
@patch.object(PicnicAPISession, 'post')
17-
def test_login(self, post_mock):
14+
@patch.object(Session, "post")
15+
def test_update_auth_token(self, post_mock):
16+
"""Test that the initial auth-token is saved."""
1817
post_mock.return_value = self.MockResponse({
1918
"x-picnic-auth": "3p9fqahw3uehfaw9fh8aw3ufaw389fpawhuo3fa"
2019
})
2120

22-
self.picnic_session.login("[email protected]", "test-password", "https://picnic.app")
23-
24-
post_mock.assert_called_with(
25-
'https://picnic.app/user/login',
26-
json={"key": "[email protected]", "secret": md5("test-password".encode("utf-8")).hexdigest(), "client_id": 1}
27-
)
28-
self.assertDictEqual(dict(self.picnic_session.headers), {
21+
picnic_session = PicnicAPISession()
22+
picnic_session.post("https://picnic.app/user/login", json={"test": "data"})
23+
self.assertDictEqual(dict(picnic_session.headers), {
2924
"Accept": "*/*",
3025
"Accept-Encoding": "gzip, deflate",
3126
"Connection": "keep-alive",
@@ -34,9 +29,33 @@ def test_login(self, post_mock):
3429
"x-picnic-auth": "3p9fqahw3uehfaw9fh8aw3ufaw389fpawhuo3fa"
3530
})
3631

37-
@patch.object(PicnicAPISession, 'post')
38-
def test_login_failed(self, post_mock):
39-
post_mock.return_value = self.MockResponse({})
32+
@patch.object(Session, "post")
33+
def test_update_auth_token_refresh(self, post_mock):
34+
"""Test that the auth-token is updated if a new one is given in the response headers."""
35+
post_mock.return_value = self.MockResponse({
36+
"x-picnic-auth": "renewed-auth-token"
37+
})
38+
39+
picnic_session = PicnicAPISession(auth_token="initial-auth-token")
40+
self.assertEqual(picnic_session.auth_token, "initial-auth-token")
41+
42+
picnic_session.post("https://picnic.app", json={"test": "data"})
43+
self.assertEqual(picnic_session.auth_token, "renewed-auth-token")
44+
45+
self.assertDictEqual(dict(picnic_session.headers), {
46+
"Accept": "*/*",
47+
"Accept-Encoding": "gzip, deflate",
48+
"Connection": "keep-alive",
49+
"User-Agent": "okhttp/3.9.0",
50+
"Content-Type": "application/json; charset=UTF-8",
51+
"x-picnic-auth": "renewed-auth-token"
52+
})
53+
54+
def test_authenticated_with_auth_token(self):
55+
picnic_session = PicnicAPISession(auth_token=None)
56+
self.assertFalse(picnic_session.authenticated)
57+
self.assertIsNone(picnic_session.headers[picnic_session.AUTH_HEADER])
4058

41-
with self.assertRaises(PicnicAuthError):
42-
self.picnic_session.login('[email protected]', 'test-password', 'https://picnic.app')
59+
picnic_session = PicnicAPISession(auth_token="3p9aw8fhzsefaw29f38h7p3fwuefah37f8kwg3i")
60+
self.assertTrue(picnic_session.authenticated)
61+
self.assertEqual(picnic_session.headers[picnic_session.AUTH_HEADER], "3p9aw8fhzsefaw29f38h7p3fwuefah37f8kwg3i")

0 commit comments

Comments
 (0)