Skip to content

Commit 952222d

Browse files
author
Sergio García Prado
authored
Merge pull request #58 from Clariteia/0.0.5
0.0.5
2 parents b008e99 + e2ca0c1 commit 952222d

24 files changed

+797
-287
lines changed

HISTORY.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,8 @@
1717

1818
## 0.0.4 (2021-08-25)
1919
* Add support to `CORS`.
20+
21+
## 0.1.0 (2021-11-03)
22+
23+
* Add support for external Authentication systems based on tokens.
24+
* Remove `minos-apigateway-common` dependency.

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ coverage: ## check code coverage quickly with the default Python
6767

6868
reformat: ## check code coverage quickly with the default Python
6969
poetry run black --line-length 120 minos tests
70-
poetry run isort --recursive minos tests
70+
poetry run isort minos tests
7171

7272
docs: ## generate Sphinx HTML documentation, including API docs
7373
rm -rf docs/api

minos/api_gateway/rest/__init__.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
1-
"""
2-
Copyright (C) 2021 Clariteia SL
3-
4-
This file is part of minos framework.
5-
6-
Minos framework can not be copied and/or distributed without the express permission of Clariteia SL.
7-
"""
8-
__version__ = "0.0.4"
1+
__version__ = "0.1.0"
92

3+
from .config import (
4+
ApiGatewayConfig,
5+
)
6+
from .exceptions import (
7+
ApiGatewayConfigException,
8+
ApiGatewayException,
9+
InvalidAuthenticationException,
10+
NoTokenException,
11+
)
1012
from .launchers import (
1113
EntrypointLauncher,
1214
)

minos/api_gateway/rest/__main__.py

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,3 @@
1-
"""
2-
Copyright (C) 2021 Clariteia SL
3-
4-
This file is part of minos framework.
5-
6-
Minos framework can not be copied and/or distributed without the express permission of Clariteia SL.
7-
"""
81
from .cli import (
92
main,
103
)

minos/api_gateway/rest/cli.py

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,3 @@
1-
"""
2-
Copyright (C) 2021 Clariteia SL
3-
4-
This file is part of minos framework.
5-
6-
Minos framework can not be copied and/or distributed without the express permission of Clariteia SL.
7-
"""
81
from pathlib import (
92
Path,
103
)
@@ -14,13 +7,13 @@
147

158
import typer
169

17-
from minos.api_gateway.common import (
18-
MinosConfig,
10+
from .config import (
11+
ApiGatewayConfig,
1912
)
20-
from minos.api_gateway.rest.launchers import (
13+
from .launchers import (
2114
EntrypointLauncher,
2215
)
23-
from minos.api_gateway.rest.service import (
16+
from .service import (
2417
ApiGatewayRestService,
2518
)
2619

@@ -36,14 +29,12 @@ def start(
3629
"""Start Api Gateway services."""
3730

3831
try:
39-
config = MinosConfig(file_path)
32+
config = ApiGatewayConfig(file_path)
4033
except Exception as exc:
4134
typer.echo(f"Error loading config: {exc!r}")
4235
raise typer.Exit(code=1)
4336

44-
services = (
45-
ApiGatewayRestService(address=config.rest.connection.host, port=config.rest.connection.port, config=config),
46-
)
37+
services = (ApiGatewayRestService(address=config.rest.host, port=config.rest.port, config=config),)
4738
try:
4839
EntrypointLauncher(config=config, services=services).launch()
4940
except Exception as exc:

minos/api_gateway/rest/config.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
from __future__ import (
2+
annotations,
3+
)
4+
5+
import abc
6+
import collections
7+
import os
8+
import typing as t
9+
from distutils import (
10+
util,
11+
)
12+
from pathlib import (
13+
Path,
14+
)
15+
16+
import yaml
17+
18+
from .exceptions import (
19+
ApiGatewayConfigException,
20+
)
21+
22+
REST = collections.namedtuple("Rest", "host port cors auth")
23+
DISCOVERY = collections.namedtuple("Discovery", "host port")
24+
CORS = collections.namedtuple("Cors", "enabled")
25+
AUTH = collections.namedtuple("Auth", "enabled host port method path")
26+
27+
_ENVIRONMENT_MAPPER = {
28+
"rest.host": "API_GATEWAY_REST_HOST",
29+
"rest.port": "API_GATEWAY_REST_PORT",
30+
"rest.cors.enabled": "API_GATEWAY_REST_CORS_ENABLED",
31+
"rest.auth.enabled": "API_GATEWAY_REST_AUTH_ENABLED",
32+
"rest.auth.host": "API_GATEWAY_REST_AUTH_HOST",
33+
"rest.auth.port": "API_GATEWAY_REST_AUTH_PORT",
34+
"rest.auth.method": "API_GATEWAY_REST_AUTH_METHOD",
35+
"rest.auth.path": "API_GATEWAY_REST_AUTH_PATH",
36+
"discovery.host": "API_GATEWAY_DISCOVERY_HOST",
37+
"discovery.port": "API_GATEWAY_DISCOVERY_PORT",
38+
}
39+
40+
_PARAMETERIZED_MAPPER = {
41+
"rest.host": "api_gateway_rest_host",
42+
"rest.port": "api_gateway_rest_port",
43+
"rest.cors.enabled": "api_gateway_rest_cors_enabled",
44+
"rest.auth.enabled": "api_gateway_rest_auth_enabled",
45+
"rest.auth.host": "api_gateway_rest_auth_host",
46+
"rest.auth.port": "api_gateway_rest_auth_port",
47+
"rest.auth.method": "api_gateway_rest_auth_method",
48+
"rest.auth.path": "api_gateway_rest_auth_path",
49+
"discovery.host": "api_gateway_discovery_host",
50+
"discovery.port": "api_gateway_discovery_port",
51+
}
52+
53+
54+
class ApiGatewayConfig(abc.ABC):
55+
"""Api Gateway config class."""
56+
57+
__slots__ = ("_services", "_path", "_data", "_with_environment", "_parameterized")
58+
59+
def __init__(self, path: t.Union[Path, str], with_environment: bool = True, **kwargs):
60+
if isinstance(path, Path):
61+
path = str(path)
62+
self._services = {}
63+
self._path = path
64+
self._load(path)
65+
self._with_environment = with_environment
66+
self._parameterized = kwargs
67+
68+
@staticmethod
69+
def _file_exit(path: str) -> bool:
70+
if os.path.isfile(path):
71+
return True
72+
return False
73+
74+
def _load(self, path):
75+
if self._file_exit(path):
76+
with open(path) as f:
77+
self._data = yaml.load(f, Loader=yaml.FullLoader)
78+
else:
79+
raise ApiGatewayConfigException(f"Check if this path: {path} is correct")
80+
81+
def _get(self, key: str, **kwargs: t.Any) -> t.Any:
82+
if key in _PARAMETERIZED_MAPPER and _PARAMETERIZED_MAPPER[key] in self._parameterized:
83+
return self._parameterized[_PARAMETERIZED_MAPPER[key]]
84+
85+
if self._with_environment and key in _ENVIRONMENT_MAPPER and _ENVIRONMENT_MAPPER[key] in os.environ:
86+
if os.environ[_ENVIRONMENT_MAPPER[key]] in ["true", "True", "false", "False"]:
87+
return bool(util.strtobool(os.environ[_ENVIRONMENT_MAPPER[key]]))
88+
return os.environ[_ENVIRONMENT_MAPPER[key]]
89+
90+
def _fn(k: str, data: dict[str, t.Any]) -> t.Any:
91+
current, _, following = k.partition(".")
92+
93+
part = data[current]
94+
if not following:
95+
return part
96+
97+
return _fn(following, part)
98+
99+
return _fn(key, self._data)
100+
101+
@property
102+
def rest(self) -> REST:
103+
"""Get the rest config.
104+
105+
:return: A ``REST`` NamedTuple instance.
106+
"""
107+
return REST(host=self._get("rest.host"), port=int(self._get("rest.port")), cors=self._cors, auth=self._auth)
108+
109+
@property
110+
def _cors(self) -> CORS:
111+
"""Get the cors config.
112+
113+
:return: A ``CORS`` NamedTuple instance.
114+
"""
115+
return CORS(enabled=self._get("rest.cors.enabled"))
116+
117+
@property
118+
def _auth(self) -> t.Optional[AUTH]:
119+
try:
120+
return AUTH(
121+
enabled=self._get("rest.auth.enabled"),
122+
host=self._get("rest.auth.host"),
123+
port=int(self._get("rest.auth.port")),
124+
method=self._get("rest.auth.method"),
125+
path=self._get("rest.auth.path"),
126+
)
127+
except KeyError:
128+
return None
129+
130+
@property
131+
def discovery(self) -> DISCOVERY:
132+
"""Get the rest config.
133+
134+
:return: A ``REST`` NamedTuple instance.
135+
"""
136+
return DISCOVERY(host=self._get("discovery.host"), port=int(self._get("discovery.port")))
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
class ApiGatewayException(Exception):
2+
"""Base Api Gateway Exception."""
3+
4+
5+
class InvalidAuthenticationException(ApiGatewayException):
6+
"""Exception to be raised when authentication is not valid."""
7+
8+
9+
class NoTokenException(ApiGatewayException):
10+
"""Exception to be raised when token is not available."""
11+
12+
13+
class ApiGatewayConfigException(ApiGatewayException):
14+
"""Base config exception."""

minos/api_gateway/rest/handler.py

Lines changed: 71 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
1-
"""minos.api_gateway.rest.handler module."""
2-
31
import logging
42
from typing import (
53
Any,
4+
Optional,
65
)
76

87
from aiohttp import (
@@ -15,23 +14,52 @@
1514
URL,
1615
)
1716

17+
from .exceptions import (
18+
InvalidAuthenticationException,
19+
NoTokenException,
20+
)
21+
1822
logger = logging.getLogger(__name__)
1923

2024

2125
async def orchestrate(request: web.Request) -> web.Response:
2226
""" Orchestrate discovery and microservice call """
23-
discovery_host = request.app["config"].discovery.connection.host
24-
discovery_port = request.app["config"].discovery.connection.port
27+
discovery_host = request.app["config"].discovery.host
28+
discovery_port = request.app["config"].discovery.port
2529

2630
verb = request.method
2731
url = f"/{request.match_info['endpoint']}"
2832

2933
discovery_data = await discover(discovery_host, int(discovery_port), "/microservices", verb, url)
3034

31-
microservice_response = await call(**discovery_data, original_req=request)
35+
user = await get_user(request)
36+
37+
microservice_response = await call(**discovery_data, original_req=request, user=user)
3238
return microservice_response
3339

3440

41+
async def get_user(request: web.Request) -> Optional[str]:
42+
"""Get The user identifier if it is available.
43+
44+
:param request: The external request.
45+
:return: An string value containing the user identifier or ``None`` if no user information is available.
46+
"""
47+
auth = request.app["config"].rest.auth
48+
if auth is None or not auth.enabled:
49+
return None
50+
51+
try:
52+
await get_token(request)
53+
except NoTokenException:
54+
return None
55+
56+
try:
57+
original_headers = dict(request.headers.copy())
58+
return await authenticate(auth.host, auth.port, auth.method, auth.path, original_headers)
59+
except InvalidAuthenticationException:
60+
return None
61+
62+
3563
async def discover(host: str, port: int, path: str, verb: str, endpoint: str) -> dict[str, Any]:
3664
"""Call discovery service and get microservice connection data.
3765
@@ -59,26 +87,33 @@ async def discover(host: str, port: int, path: str, verb: str, endpoint: str) ->
5987

6088

6189
# noinspection PyUnusedLocal
62-
async def call(address: str, port: int, original_req: web.Request, **kwargs) -> web.Response:
90+
async def call(address: str, port: int, original_req: web.Request, user: Optional[str], **kwargs) -> web.Response:
6391
"""Call microservice (redirect the original call)
6492
6593
:param address: The ip of the microservices.
6694
:param port: The port of the microservice.
6795
:param original_req: The original request.
6896
:param kwargs: Additional named arguments.
97+
:param user: User that makes the request
6998
:return: The web response to be retrieved to the client.
7099
"""
71100

72-
headers = original_req.headers
101+
headers = original_req.headers.copy()
102+
if user is not None:
103+
headers["User"] = user
104+
else: # Enforce that the 'User' entry is only generated by the auth system.
105+
# noinspection PyTypeChecker
106+
headers.pop("User", None)
107+
73108
url = original_req.url.with_scheme("http").with_host(address).with_port(port)
74109
method = original_req.method
75110
content = await original_req.text()
76111

77112
logger.info(f"Redirecting {method!r} request to {url!r}...")
78113

79114
try:
80-
async with ClientSession(headers=headers) as session:
81-
async with session.request(method=method, url=url, data=content) as response:
115+
async with ClientSession() as session:
116+
async with session.request(headers=headers, method=method, url=url, data=content) as response:
82117
return await _clone_response(response)
83118
except ClientConnectorError:
84119
raise web.HTTPServiceUnavailable(text="The requested endpoint is not available.")
@@ -89,3 +124,30 @@ async def _clone_response(response: ClientResponse) -> web.Response:
89124
return web.Response(
90125
body=await response.read(), status=response.status, reason=response.reason, headers=response.headers,
91126
)
127+
128+
129+
async def authenticate(host: str, port: str, method: str, path: str, authorization_headers: dict[str, str]) -> str:
130+
authentication_url = URL(f"http://{host}:{port}{path}")
131+
authentication_method = method
132+
logger.info("Authenticating request...")
133+
134+
try:
135+
async with ClientSession(headers=authorization_headers) as session:
136+
async with session.request(method=authentication_method, url=authentication_url) as response:
137+
if response.ok:
138+
jwt_payload = await response.json()
139+
return jwt_payload["sub"]
140+
else:
141+
raise InvalidAuthenticationException
142+
except (ClientConnectorError, web.HTTPError):
143+
raise InvalidAuthenticationException
144+
145+
146+
async def get_token(request: web.Request) -> str:
147+
headers = request.headers
148+
if "Authorization" in headers and "Bearer" in headers["Authorization"]:
149+
parts = headers["Authorization"].split()
150+
if len(parts) == 2:
151+
return parts[1]
152+
153+
raise NoTokenException

0 commit comments

Comments
 (0)