Skip to content

Commit 0382ae6

Browse files
committed
feat(application): add application authentification
MP-685
1 parent f5a3ead commit 0382ae6

File tree

6 files changed

+202
-0
lines changed

6 files changed

+202
-0
lines changed

lumapps/latest/__init__.py

Whitespace-only changes.

lumapps/latest/client/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .application import Application, ApplicationClient # noqa
2+
from .base import IClient, Request, Response # noqa
3+
from .exceptions import InvalidLogin # noqa
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
from dataclasses import dataclass
2+
from typing import Any, Callable
3+
4+
from oauthlib.oauth2 import BackendApplicationClient, OAuth2Error, TokenExpiredError
5+
from requests_oauthlib import OAuth2Session
6+
7+
from .base import IClient, Request, Response
8+
from .exceptions import InvalidLogin
9+
10+
11+
@dataclass(frozen=True)
12+
class Application:
13+
client_id: str
14+
client_secret: str
15+
16+
17+
def retry_on_expired_token(func: Callable[..., Response]) -> Callable[..., Response]:
18+
def inner(client: "ApplicationClient", request: Request, **kwargs: Any) -> Response:
19+
try:
20+
return func(client, request, **kwargs)
21+
except TokenExpiredError:
22+
client.fetch_token()
23+
return func(client, request, **kwargs)
24+
25+
return inner
26+
27+
28+
class ApplicationClient(IClient):
29+
def __init__(
30+
self, base_url: str, organization_id: str, application: Application
31+
) -> None:
32+
"""
33+
Args:
34+
base_url: The API base url, i.e your Haussmann cell.
35+
e.g: https://XX-cell-YYY.api.lumapps.com
36+
organization_id: The ID of the given customer / organization.
37+
application: A LumApps application of the same customer.
38+
"""
39+
self.base_url = base_url.rstrip("/")
40+
self.organization_id = organization_id
41+
self.application = application
42+
self.session = OAuth2Session(
43+
client=BackendApplicationClient(
44+
client_id=application.client_id, scope=None,
45+
)
46+
)
47+
self.organization_url = (
48+
f"{self.base_url}/v2/organizations/{self.organization_id}"
49+
)
50+
51+
@retry_on_expired_token
52+
def request(self, request: Request, **_: Any) -> Response:
53+
if not self.session.token:
54+
# Ensure token in request
55+
self.fetch_token()
56+
response = self.session.request(
57+
request.method,
58+
f"{self.organization_url}/{request.url.lstrip('/')}",
59+
params=request.params,
60+
headers={**request.headers, "User-Agent": "lumapps-sdk"},
61+
json=request.json,
62+
)
63+
return Response(
64+
status_code=response.status_code,
65+
headers=dict(response.headers),
66+
json=response.json() if response.text else None,
67+
)
68+
69+
def fetch_token(self) -> None:
70+
try:
71+
self.session.fetch_token(
72+
f"{self.organization_url}/application-token",
73+
client_secret=self.application.client_secret,
74+
)
75+
except OAuth2Error as err:
76+
raise InvalidLogin("Could not fetch token from application") from err

lumapps/latest/client/base.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from abc import ABC, abstractmethod
2+
from dataclasses import dataclass, field
3+
from typing import Any, Mapping
4+
5+
6+
@dataclass(frozen=True)
7+
class Request:
8+
# The HTTP method, usually GET, POST, PUT or PATCH
9+
method: str
10+
# The requested URL
11+
url: str
12+
# The query parameters (?key=value)
13+
params: Mapping[str, str] = field(default_factory=dict)
14+
# The extra headers required to process the request
15+
headers: Mapping[str, str] = field(default_factory=dict)
16+
# The JSON content of the request
17+
json: Any = None
18+
19+
20+
@dataclass(frozen=True)
21+
class Response:
22+
status_code: int
23+
headers: Mapping[str, str]
24+
json: Any
25+
26+
27+
class IClient(ABC):
28+
"""
29+
The generic HTTP client for LumApps
30+
The implementation must handle authentification and specifics if necessary
31+
"""
32+
33+
@abstractmethod
34+
def request(self, request: Request, **kwargs: Any) -> Response: # pragma: no cover
35+
"""
36+
kwargs should be used for very niche behavior and not relied on extensively
37+
Most, if not all, implementations should NOT need to use it
38+
"""
39+
pass
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
class InvalidLogin(Exception):
2+
pass

tests/test_application_client.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
from lumapps.latest.client import ApplicationClient, Application, Request, InvalidLogin
2+
from oauthlib.oauth2 import TokenExpiredError
3+
from pytest import raises
4+
5+
6+
def test_request(requests_mock):
7+
# Given
8+
token_mock = requests_mock.post(
9+
"https://mock/v2/organizations/123/application-token",
10+
request_headers={"Authorization": "Basic Y2xpZW50OnNlY3JldA=="},
11+
json={"access_token": "123"},
12+
)
13+
call_mock = requests_mock.get(
14+
"https://mock/v2/organizations/123/test",
15+
request_headers={"Authorization": "Bearer 123"},
16+
json="response"
17+
)
18+
client = ApplicationClient(
19+
"https://mock", "123", Application(client_id="client", client_secret="secret")
20+
)
21+
22+
# When
23+
response = client.request(Request(method="GET", url="/test"))
24+
25+
# Then
26+
assert response.status_code == 200
27+
assert response.json == "response"
28+
assert token_mock.call_count == 1
29+
assert call_mock.call_count == 1
30+
31+
32+
def test_request_token_expired(requests_mock):
33+
# Given
34+
token_mock = requests_mock.post(
35+
"https://mock/v2/organizations/123/application-token",
36+
[
37+
{"json": {"access_token": "123"}},
38+
{"json": {"access_token": "456"}},
39+
]
40+
)
41+
call_token_expired_mock = requests_mock.get(
42+
"https://mock/v2/organizations/123/test",
43+
request_headers={"Authorization": "Bearer 123"},
44+
exc=TokenExpiredError
45+
)
46+
call_token_updated_mock = requests_mock.get(
47+
"https://mock/v2/organizations/123/test",
48+
request_headers={"Authorization": "Bearer 456"},
49+
json="response"
50+
)
51+
client = ApplicationClient(
52+
"https://mock", "123", Application(client_id="client", client_secret="secret")
53+
)
54+
55+
# When
56+
response = client.request(Request(method="GET", url="/test"))
57+
58+
# Then
59+
assert response.status_code == 200
60+
assert response.json == "response"
61+
assert token_mock.call_count == 2
62+
assert call_token_expired_mock.call_count == 1
63+
assert call_token_updated_mock.call_count == 1
64+
65+
66+
def test_request_no_token(requests_mock):
67+
# Given
68+
token_mock = requests_mock.post(
69+
"https://mock/v2/organizations/123/application-token",
70+
status_code=400,
71+
json={"error": "invalid_request"},
72+
)
73+
client = ApplicationClient(
74+
"https://mock", "123", Application(client_id="client", client_secret="secret")
75+
)
76+
77+
# When
78+
with raises(InvalidLogin):
79+
client.request(Request(method="GET", url="/test"))
80+
81+
# Then
82+
assert token_mock.call_count == 1

0 commit comments

Comments
 (0)