Skip to content

Commit afaf904

Browse files
michael.yakmichaelyaakoby
authored andcommitted
Allow to customize the web-framework
The customizer allows for web-framework specific customiztion. For example, it can be used to add authentication to pyctuator running with FastAPI. See #67
1 parent 5e8c950 commit afaf904

File tree

6 files changed

+114
-9
lines changed

6 files changed

+114
-9
lines changed

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,10 @@ Pyctuator(
331331
)
332332
```
333333

334+
### Protecting Pyctuator with authentication
335+
Since there are numerous standard approaches to protect an API, Pyctuator doesn't explicitly support any of them. Instead, Pyctuator allows to customize its integration with the web-framework.
336+
See the example in [fastapi_with_authentication_example_app.py](examples/FastAPI/fastapi_with_authentication_example_app.py).
337+
334338
## Full blown examples
335339
The `examples` folder contains full blown Python projects that are built using [Poetry](https://python-poetry.org/).
336340

examples/FastAPI/README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,8 @@ This example demonstrates the integration with the [FastAPI](https://fastapi.tia
1313
poetry run python -m fastapi_example_app
1414
```
1515

16-
![FastAPI Example](../images/FastAPI.png)
16+
![FastAPI Example](../images/FastAPI.png)
17+
18+
## Running an example where pyctuator requires authentication
19+
In order to protect the Pyctuator endpoint, a customizer is used to make the required configuration changes to the API router.
20+
In addition, the credentials need to be included in the registration request sent to SBA in order for it it could authenticate when querying the pyctuator API.
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import datetime
2+
import logging
3+
import random
4+
import secrets
5+
6+
from fastapi import FastAPI, Depends, APIRouter, HTTPException
7+
from fastapi.security import HTTPBasicCredentials, HTTPBasic
8+
from starlette import status
9+
from uvicorn import Server
10+
from uvicorn.config import Config
11+
12+
from pyctuator.pyctuator import Pyctuator
13+
14+
my_logger = logging.getLogger("example")
15+
16+
17+
class SimplisticBasicAuth:
18+
def __init__(self, username: str, password: str):
19+
"""
20+
Initializes a simplistic basic-auth FastAPI dependency with hardcoded username and password -
21+
don't do this at home!
22+
"""
23+
self.username = username
24+
self.password = password
25+
26+
def __call__(self, credentials: HTTPBasicCredentials = Depends(HTTPBasic(realm="pyctuator"))):
27+
correct_username = secrets.compare_digest(credentials.username, self.username)
28+
correct_password = secrets.compare_digest(credentials.password, self.password) if self.password else True
29+
30+
if not (correct_username and correct_password):
31+
raise HTTPException(
32+
status_code=status.HTTP_401_UNAUTHORIZED,
33+
detail="Incorrect username or password",
34+
headers={"WWW-Authenticate": "Basic"},
35+
)
36+
37+
38+
username = "u1"
39+
password = "p2"
40+
security = SimplisticBasicAuth(username, password)
41+
42+
43+
app = FastAPI(
44+
title="FastAPI Example Server",
45+
description="Demonstrate Spring Boot Admin Integration with FastAPI",
46+
docs_url="/api",
47+
)
48+
49+
50+
def add_authentication_to_pyctuator(router: APIRouter) -> None:
51+
router.dependencies = [Depends(security)]
52+
53+
54+
@app.get("/")
55+
def read_root(credentials: HTTPBasicCredentials = Depends(security)):
56+
my_logger.debug(f"{datetime.datetime.now()} - {str(random.randint(0, 100))}")
57+
return {"username": credentials.username, "password": credentials.password}
58+
59+
60+
example_app_address = "172.18.0.1"
61+
example_sba_address = "localhost"
62+
63+
pyctuator = Pyctuator(
64+
app,
65+
"Example FastAPI",
66+
app_url=f"http://{example_app_address}:8000",
67+
pyctuator_endpoint_url=f"http://{example_app_address}:8000/pyctuator",
68+
registration_url=f"http://{example_sba_address}:8080/instances",
69+
app_description=app.description,
70+
customizer=add_authentication_to_pyctuator, # Customize Pyctuator's API router to require authentication
71+
metadata={
72+
"user.name": username, # Include the credentials in the registration request sent to SBA
73+
"user.password": password,
74+
}
75+
)
76+
77+
# Keep the console clear - configure uvicorn (FastAPI's WSGI web app) not to log the detail of every incoming request
78+
uvicorn_logger = logging.getLogger("uvicorn")
79+
uvicorn_logger.setLevel(logging.WARNING)
80+
81+
server = Server(config=(Config(
82+
app=app,
83+
loop="asyncio",
84+
host="0.0.0.0",
85+
logger=uvicorn_logger,
86+
)))
87+
server.run()

examples/FastAPI/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ authors = [
99
[tool.poetry.dependencies]
1010
python = "^3.7"
1111
psutil = { version = "^5.6" }
12-
fastapi = { version = "^0.41.0" }
12+
fastapi = { version = "^0.68.0" }
1313
uvicorn = { version = "^0.9.0" }
1414
pyctuator = { version = "^0.16.0" }
1515

pyctuator/impl/fastapi_pyctuator.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,12 @@ def __init__(
3535
app: FastAPI,
3636
pyctuator_impl: PyctuatorImpl,
3737
include_in_openapi_schema: bool = False,
38+
customizer: Callable[[APIRouter], None] = None
3839
) -> None:
3940
super().__init__(app, pyctuator_impl)
4041
router = APIRouter()
42+
if customizer:
43+
customizer(router)
4144

4245
@router.get("/", include_in_schema=include_in_openapi_schema, tags=["pyctuator"])
4346
def get_endpoints() -> EndpointsData:

pyctuator/pyctuator.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ def __init__(
4242
metadata: Optional[dict] = None,
4343
additional_app_info: Optional[dict] = None,
4444
ssl_context: Optional[ssl.SSLContext] = None,
45+
customizer: Optional[Callable] = None
4546
) -> None:
4647
"""The entry point for integrating pyctuator with a web-frameworks such as FastAPI and Flask.
4748
@@ -79,6 +80,9 @@ def __init__(
7980
:param metadata: optional metadata key-value pairs that are displayed in SBA main page of an instance
8081
:param additional_app_info: additional arbitrary information to add to the application's "Info" section
8182
:param ssl_context: optional SSL context to be used when registering with SBA
83+
:param customizer: a function that can customize the integration with the web-framework which is therefore web-
84+
framework specific. For FastAPI, the function receives pyctuator's APIRouter allowing to add "dependencies" and
85+
anything else that's provided by the router. See fastapi_with_authentication_example_app.py
8286
"""
8387

8488
self.auto_deregister = auto_deregister
@@ -113,7 +117,7 @@ def __init__(
113117
root_logger.addHandler(self.pyctuator_impl.logfile.log_messages)
114118

115119
# Find and initialize an integration layer between the web-framework adn pyctuator
116-
framework_integrations = {
120+
framework_integrations: Dict[str, Callable[[Any, PyctuatorImpl, Optional[Callable]], bool]] = {
117121
"flask": self._integrate_flask,
118122
"fastapi": self._integrate_fastapi,
119123
"aiohttp": self._integrate_aiohttp,
@@ -122,7 +126,7 @@ def __init__(
122126
for framework_name, framework_integration_function in framework_integrations.items():
123127
if self._is_framework_installed(framework_name):
124128
logging.debug("Framework %s is installed, trying to integrate with it", framework_name)
125-
success = framework_integration_function(app, self.pyctuator_impl)
129+
success = framework_integration_function(app, self.pyctuator_impl, customizer)
126130
if success:
127131
logging.debug("Integrated with framework %s", framework_name)
128132
if registration_url is not None:
@@ -176,7 +180,7 @@ def set_build_info(
176180
def _is_framework_installed(self, framework_name: str) -> bool:
177181
return importlib.util.find_spec(framework_name) is not None
178182

179-
def _integrate_fastapi(self, app: Any, pyctuator_impl: PyctuatorImpl) -> bool:
183+
def _integrate_fastapi(self, app: Any, pyctuator_impl: PyctuatorImpl, customizer: Optional[Callable]) -> bool:
180184
"""
181185
This method should only be called if we detected that FastAPI is installed.
182186
It will then check whether the given app is a FastAPI app, and if so - it will add the Pyctuator
@@ -185,11 +189,12 @@ def _integrate_fastapi(self, app: Any, pyctuator_impl: PyctuatorImpl) -> bool:
185189
from fastapi import FastAPI
186190
if isinstance(app, FastAPI):
187191
from pyctuator.impl.fastapi_pyctuator import FastApiPyctuator
188-
FastApiPyctuator(app, pyctuator_impl, False)
192+
FastApiPyctuator(app, pyctuator_impl, False, customizer)
189193
return True
190194
return False
191195

192-
def _integrate_flask(self, app: Any, pyctuator_impl: PyctuatorImpl) -> bool:
196+
# pylint: disable=unused-argument
197+
def _integrate_flask(self, app: Any, pyctuator_impl: PyctuatorImpl, customizer: Optional[Callable]) -> bool:
193198
"""
194199
This method should only be called if we detected that Flask is installed.
195200
It will then check whether the given app is a Flask app, and if so - it will add the Pyctuator
@@ -202,7 +207,8 @@ def _integrate_flask(self, app: Any, pyctuator_impl: PyctuatorImpl) -> bool:
202207
return True
203208
return False
204209

205-
def _integrate_aiohttp(self, app: Any, pyctuator_impl: PyctuatorImpl) -> bool:
210+
# pylint: disable=unused-argument
211+
def _integrate_aiohttp(self, app: Any, pyctuator_impl: PyctuatorImpl, customizer: Optional[Callable]) -> bool:
206212
"""
207213
This method should only be called if we detected that aiohttp is installed.
208214
It will then check whether the given app is a aiohttp app, and if so - it will add the Pyctuator
@@ -215,7 +221,8 @@ def _integrate_aiohttp(self, app: Any, pyctuator_impl: PyctuatorImpl) -> bool:
215221
return True
216222
return False
217223

218-
def _integrate_tornado(self, app: Any, pyctuator_impl: PyctuatorImpl) -> bool:
224+
# pylint: disable=unused-argument
225+
def _integrate_tornado(self, app: Any, pyctuator_impl: PyctuatorImpl, customizer: Optional[Callable]) -> bool:
219226
"""
220227
This method should only be called if we detected that tornado is installed.
221228
It will then check whether the given app is a tornado app, and if so - it will add the Pyctuator

0 commit comments

Comments
 (0)