diff --git a/README.md b/README.md index 162166cd..edeedbaf 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Pour commencer : -## [Docker](docker/DOCKER.md) +## [Docker](ops/cours/OPS_COURS.md) Pour l'ensemble des parties du cours diff --git a/api/.env b/api/.env index 220539aa..6e5bfc7b 100644 --- a/api/.env +++ b/api/.env @@ -1,3 +1,7 @@ POSTGRES_USER=userpd POSTGRES_PASSWORD=postgrespassword POSTGRES_DB=dbesiee + +PGADMIN_DEFAULT_EMAIL=morgan.courivaud@esiee.fr +PGADMIN_DEFAULT_PASSWORD=pgadminpassword +PGADMIN_LISTEN_PORT=9001 diff --git a/api/Dockerfile b/api/Dockerfile index 38ae8716..7f95badc 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -3,5 +3,6 @@ FROM tiangolo/uvicorn-gunicorn-fastapi:python3.7 ADD requirements.txt . RUN pip install -r requirements.txt +WORKDIR /app COPY ./app /app/app diff --git a/api/app/main.py b/api/app/main.py index 25ab717d..1a2149b9 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -1,12 +1,15 @@ +import base64 from typing import Optional -from fastapi import FastAPI +from fastapi import FastAPI, Header from fastapi.middleware.cors import CORSMiddleware import json import asyncio import os + +from starlette.requests import Request from starlette_exporter import PrometheusMiddleware, handle_metrics -from .models import BaseSQL, engine -from . import routers +from models import BaseSQL, engine +import routers app = FastAPI( title="My title", @@ -28,7 +31,7 @@ ) app.include_router(routers.PostRouter) -app.include_router(routers.HealthRouter) +#app.include_router(routers.HealthRouter) app.add_middleware(PrometheusMiddleware) app.add_route("/metrics", handle_metrics) @@ -38,10 +41,16 @@ async def startup_event(): BaseSQL.metadata.create_all(bind=engine) + @app.get("/api/headers") -def read_hello(request: Request, x_userinfo: Optional[str] = Header(None, convert_underscores=True), ): +def read_hello( + request: Request, + x_userinfo: Optional[str] = Header(None, convert_underscores=True), +): print(request["headers"]) - return {"Headers": json.loads(base64.b64decode(x_userinfo))} + b64 = base64.b64decode(x_userinfo.encode("utf-8")) + return {"Headers": request["headers"]} + @app.get("/") def read_root(): diff --git a/api/app/models/__init__.py b/api/app/models/__init__.py index 1c268fd3..5e154a90 100644 --- a/api/app/models/__init__.py +++ b/api/app/models/__init__.py @@ -1,3 +1,3 @@ from .post import Post from .database import BaseSQL -from .db import get_db, engine \ No newline at end of file +from .db import get_db, engine diff --git a/api/app/models/database.py b/api/app/models/database.py index 7d39e2dd..690701c7 100644 --- a/api/app/models/database.py +++ b/api/app/models/database.py @@ -1,7 +1,7 @@ from sqlalchemy import create_engine from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker -import os +import os POSTGRES_USER = os.environ.get("POSTGRES_USER") @@ -9,12 +9,12 @@ POSTGRES_DB = os.environ.get("POSTGRES_DB") -SQLALCHEMY_DATABASE_URL = f"postgresql://{POSTGRES_USER}:{POSTGRES_PASSWORD}@postgres/{POSTGRES_DB}" +SQLALCHEMY_DATABASE_URL = ( + f"postgresql://{POSTGRES_USER}:{POSTGRES_PASSWORD}@db/{POSTGRES_DB}" +) print(SQLALCHEMY_DATABASE_URL) -engine = create_engine( - SQLALCHEMY_DATABASE_URL -) +engine = create_engine(SQLALCHEMY_DATABASE_URL) SessionLocal = sessionmaker(autocommit=False, autoflush=True, bind=engine) BaseSQL = declarative_base() diff --git a/api/app/models/db.py b/api/app/models/db.py index 7d907548..7b6d13d2 100644 --- a/api/app/models/db.py +++ b/api/app/models/db.py @@ -6,4 +6,4 @@ def get_db(): db = SessionLocal() yield db finally: - db.close() \ No newline at end of file + db.close() diff --git a/api/app/routers/__init__.py b/api/app/routers/__init__.py index 5dab8b48..93ea2d4d 100644 --- a/api/app/routers/__init__.py +++ b/api/app/routers/__init__.py @@ -1,2 +1,2 @@ from .posts import router as PostRouter -from .health import router as HealthRouter \ No newline at end of file +from .health import router as HealthRouter diff --git a/api/app/routers/health.py b/api/app/routers/health.py index 3b042fdf..6cfd7c96 100644 --- a/api/app/routers/health.py +++ b/api/app/routers/health.py @@ -22,6 +22,8 @@ def read_root(): @router.get("/api/headers") -def read_hello(request: Request, x_userinfo: Optional[str] = Header(None, convert_underscores=True)): +def read_hello( + request: Request, x_userinfo: Optional[str] = Header(None, convert_underscores=True) +): print(request["headers"]) return {"Headers": json.loads(base64.b64decode(x_userinfo))} diff --git a/api/app/routers/posts.py b/api/app/routers/posts.py index 668b2437..66d4210b 100644 --- a/api/app/routers/posts.py +++ b/api/app/routers/posts.py @@ -1,6 +1,6 @@ from fastapi import APIRouter, Depends -from ..services import posts as posts_service -from .. import schemas, models +from services import posts as posts_service +import schemas, models from sqlalchemy.orm import Session router = APIRouter(prefix="/posts") @@ -25,8 +25,9 @@ async def get_posts_by_title(title: str = None, db: Session = Depends(models.get @router.put("/{post_id}", tags=["posts"]) -async def update_post_by_id(post_id: str, post: schemas.Post, - db: Session = Depends(models.get_db)): +async def update_post_by_id( + post_id: str, post: schemas.Post, db: Session = Depends(models.get_db) +): return posts_service.update_post(post_id=post_id, db=db, post=post) diff --git a/api/app/services/posts.py b/api/app/services/posts.py index 76524f40..02965e88 100644 --- a/api/app/services/posts.py +++ b/api/app/services/posts.py @@ -3,7 +3,7 @@ from sqlalchemy.orm import Session from fastapi import HTTPException from datetime import datetime -from .. import models, schemas +import models, schemas def get_all_posts(db: Session, skip: int = 0, limit: int = 10) -> List[models.Post]: @@ -16,7 +16,7 @@ def get_all_posts(db: Session, skip: int = 0, limit: int = 10) -> List[models.Po def get_post_by_id(post_id: str, db: Session) -> models.Post: record = db.query(models.Post).filter(models.Post.id == post_id).first() if not record: - raise HTTPException(status_code=404, detail="Not Found") + raise HTTPException(status_code=404, detail="Not Found") record.id = str(record.id) return record diff --git a/api/tp/predict/app/main.py b/api/tp/predict/app/main.py index 99245b45..97e30910 100644 --- a/api/tp/predict/app/main.py +++ b/api/tp/predict/app/main.py @@ -1,6 +1,6 @@ from fastapi import FastAPI, BackgroundTasks import pathlib -import glob +import glob from joblib import load, dump from schema import IrisPredict, IrisTrain import numpy as np @@ -14,16 +14,17 @@ version="0.0.1", ) -global clf +global clf + +CLASSES = ["setosa", "versicolor", "virginica"] -CLASSES = ['setosa', 'versicolor', 'virginica'] @app.get("/") def read_root(): return {"Hello": "World"} -@app.on_event('startup') +@app.on_event("startup") async def load_model(): global clf all_model_paths = glob.glob("./data/iris_*") @@ -34,11 +35,12 @@ async def load_model(): @app.post("/iris/predict") async def predict_iris(iris: IrisPredict): return { - "predicted_classes" : clf.predict(np.asarray([iris.data])).tolist(), - "predicted_probas" : clf.predict_proba(np.asarray([iris.data])).tolist(), - "classes" : CLASSES + "predicted_classes": clf.predict(np.asarray([iris.data])).tolist(), + "predicted_probas": clf.predict_proba(np.asarray([iris.data])).tolist(), + "classes": CLASSES, } + def retrain_model(X, y): logreg = LogisticRegression() logreg.fit(X, y) @@ -52,7 +54,7 @@ async def train_iris_model(iris: IrisTrain, background_tasks: BackgroundTasks): background_tasks.add_task(retrain_model, X=X, y=y) return {"message": "Notification sent in the background"} + @app.get("/iris/classes") async def create_post(iris: IrisPredict): return CLASSES - diff --git a/api/tp/predict/app/schema.py b/api/tp/predict/app/schema.py index 26f82c09..3851c42c 100644 --- a/api/tp/predict/app/schema.py +++ b/api/tp/predict/app/schema.py @@ -5,6 +5,7 @@ class IrisPredict(BaseModel): data: List[float] + class IrisTrain(BaseModel): data: List[List[float]] - targets: List[float] \ No newline at end of file + targets: List[float] diff --git a/authentication/tp/api.py b/authentication/tp/api.py index 91bbd2d7..e8dee562 100644 --- a/authentication/tp/api.py +++ b/authentication/tp/api.py @@ -7,15 +7,14 @@ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") # Configure client -keycloak_openid = KeycloakOpenID(server_url="http://localhost:8080/auth/", - client_id="fastapi", - realm_name="master", - client_secret_key="97e13e2d-90e6-447f-9e3b-914b27653821") +keycloak_openid = KeycloakOpenID( + server_url="http://localhost:8080/auth/", + client_id="fastapi", + realm_name="master", + client_secret_key="97e13e2d-90e6-447f-9e3b-914b27653821", +) @app.get("/protected") def protected(token: str = Depends(oauth2_scheme)): - return { - "Hello": "World", - "user_infos": token - } + return {"Hello": "World", "user_infos": token} diff --git a/authentication/tp/front.py b/authentication/tp/front.py old mode 100755 new mode 100644 index 3cad317e..89660300 --- a/authentication/tp/front.py +++ b/authentication/tp/front.py @@ -7,27 +7,30 @@ from os.path import join, dirname, realpath import requests -UPLOADS_PATH = join(dirname(realpath(__file__)), 'client_secrets.json') +UPLOADS_PATH = join(dirname(realpath(__file__)), "client_secrets.json") logging.basicConfig(level=logging.DEBUG) app = Flask(__name__) print(UPLOADS_PATH) -app.config.update({ - 'SECRET_KEY': 'SomethingNotEntirelySecret', - 'TESTING': True, - 'DEBUG': True, - 'OIDC_CLIENT_SECRETS': UPLOADS_PATH, - 'OIDC_ID_TOKEN_COOKIE_SECURE': False, - 'OIDC_REQUIRE_VERIFIED_EMAIL': False, - 'OIDC_USER_INFO_ENABLED': True, - 'OIDC_OPENID_REALM': 'master', - 'OIDC_SCOPES': ['openid', 'email', 'profile'], - 'OIDC_INTROSPECTION_AUTH_METHOD': 'client_secret_post' -}) +app.config.update( + { + "SECRET_KEY": "SomethingNotEntirelySecret", + "TESTING": True, + "DEBUG": True, + "OIDC_CLIENT_SECRETS": UPLOADS_PATH, + "OIDC_ID_TOKEN_COOKIE_SECURE": False, + "OIDC_REQUIRE_VERIFIED_EMAIL": False, + "OIDC_USER_INFO_ENABLED": True, + "OIDC_OPENID_REALM": "master", + "OIDC_SCOPES": ["openid", "email", "profile"], + "OIDC_INTROSPECTION_AUTH_METHOD": "client_secret_post", + } +) oidc = OpenIDConnect(app) + @app.before_request def before_request(): if oidc.user_loggedin: @@ -36,58 +39,68 @@ def before_request(): g.user = None -@app.route('/') +@app.route("/") def hello_world(): if oidc.user_loggedin: - return ('Hello, %s, See private ' - 'Log out') % \ - oidc.user_getfield('preferred_username') + return ( + 'Hello, %s, See private ' + 'Log out' + ) % oidc.user_getfield("preferred_username") else: return 'Welcome anonymous, Log in' -@app.route('/private') +@app.route("/private") @oidc.require_login def hello_me(): """Example for protected endpoint that extracts private information from the OpenID Connect id_token. - Uses the accompanied access_token to access a backend service. + Uses the accompanied access_token to access a backend service. """ - info = oidc.user_getinfo(['preferred_username', 'email', 'sub']) + info = oidc.user_getinfo(["preferred_username", "email", "sub"]) - username = info.get('preferred_username') - email = info.get('email') - user_id = info.get('sub') + username = info.get("preferred_username") + email = info.get("email") + user_id = info.get("sub") if user_id in oidc.credentials_store: try: from oauth2client.client import OAuth2Credentials - access_token = OAuth2Credentials.from_json(oidc.credentials_store[user_id]).access_token - headers = {'Authorization': 'Bearer %s' % (access_token)} + + access_token = OAuth2Credentials.from_json( + oidc.credentials_store[user_id] + ).access_token + headers = {"Authorization": "Bearer %s" % (access_token)} # YOLO - greeting = requests.get('http://127.0.0.1:1235/protected', headers=headers).text + greeting = requests.get( + "http://127.0.0.1:1235/protected", headers=headers + ).text except: greeting = "Hello %s" % username - - return ("""%s your email is %s and your user_id is %s! + return """%s your email is %s and your user_id is %s! """ % - (greeting, email, user_id)) + """ % ( + greeting, + email, + user_id, + ) -@app.route('/logout') +@app.route("/logout") def logout(): """Performs local logout by removing the session cookie.""" res = make_response('Hi, you have been logged out! Return') session.clear() oidc.logout() - requests.get("http://localhost:8080/auth/realms/master/protocol/openid-connect/logout") + requests.get( + "http://localhost:8080/auth/realms/master/protocol/openid-connect/logout" + ) return res -if __name__ == '__main__': - app.run(host='0.0.0.0', port=5001, debug=True) \ No newline at end of file +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5001, debug=True) diff --git a/base_authentication/.env b/base_authentication/.env new file mode 100644 index 00000000..e7aa090c --- /dev/null +++ b/base_authentication/.env @@ -0,0 +1,6 @@ +POSTGRES_USER=userpd +POSTGRES_PASSWORD=postgrespassword +POSTGRES_DB=dbesiee + +JWT_SECRET_KEY=mysecretkey +JWT_SECRET_ALGORITHM=HS256 diff --git a/base_authentication/Dockerfile b/base_authentication/Dockerfile new file mode 100644 index 00000000..a046c181 --- /dev/null +++ b/base_authentication/Dockerfile @@ -0,0 +1,7 @@ +FROM tiangolo/uvicorn-gunicorn-fastapi:python3.7 + +COPY requirements.txt . + +RUN pip install -r requirements.txt + +COPY ./app /app/app diff --git a/base_authentication/README.md b/base_authentication/README.md new file mode 100644 index 00000000..2f4e8e17 --- /dev/null +++ b/base_authentication/README.md @@ -0,0 +1,232 @@ +# Notions abordées dans ce chapitre + +- Notion de clé étrangère (foreign key) dans une base de données ainsi qui sont implémentation en FastAPI +- Notion d'encodage et de décodage de token JWT (JSON WebToken) +- Systême d'authentification de base avec FastAPI + +# Foreign Key + +## Concept +Dans les bases de données relationnelles, la notion de clé étrangère est extrêmement importante. +Elle permet d'établir un lien entre deux tables et d'effectuer des opérations entre elles. +Le concept est très simple, dans la base de données, il faut simplement définir une colonne qui va indiquer une colonne dans une autre table. + +Ici, nous étudions le cas de la clé étrangère (ForeignKey) qui permet de représenter une relation de "un à plusieurs" entre deux tables. +Il va nous permettre de lier plusieurs objets d'une table à un seul objet d'une autre table. + +Parmi les autres types de relation que l'on peut retrouver dans les bases de données relationnelles, on peut citer : +- La relation "un à un" : un objet d'une table est lié à un seul objet d'une autre table. Ce n'est qu'un cas particulier de la relation "un à plusieurs". Il existe alors une contrainte d'unicité sur la clé étrangère. +- La relation "plusieurs à plusieurs" : un objet d'une table est lié à plusieurs objets d'une autre table et vice versa. Pour cela, on utilise une table de jointure appelée Many To Many. + +## Exemple + +Reprenons notre application avec le model Post créé précedemment. +Voici la représentation écrite en python pour la table Post: +``` +class Post(BaseSQL): + __tablename__ = "posts" + + id = Column(UUID(as_uuid=True), primary_key=True, index=True) + title = Column(String) + description = Column(String) + created_at = Column(DateTime()) + updated_at = Column(DateTime()) +``` + + +Imaginons maintenant que l'on veuille savoir quel utilisateur de notre application a créé un post. +Nous aurons donc besoin d'un nouvel objet (table) User qui va contenir les informations de l'utilisateur. + +``` +class User(BaseSQL): + __tablename__ = "users" + + id = Column(UUID(as_uuid=True), primary_key=True, index=True) + username = Column(String) + password = Column(String) + created_at = Column(DateTime()) + updated_at = Column(DateTime()) +``` + +Ainsi, nous pouvons sauvegarder les informations liés a nos utilisateurs. +Nous devons donc maintenant créer un lien entre ces deux tables. + +Pour cela, nous allons ajouter une colonne `user_id` dans la table Post qui va contenir l'id de l'utilisateur qui a créé le post. + +``` +class Post(BaseSQL): + __tablename__ = "posts" + + id = Column(UUID(as_uuid=True), primary_key=True, index=True) + title = Column(String) + description = Column(String) + created_at = Column(DateTime()) + updated_at = Column(DateTime()) + user_id = Column(UUID(as_uuid=True), ForeignKey('users.id')) +``` + +Ainsi, nous avons créé une relation entre les tables Post et User. + +Pour récupérer les informations de l'utilisateur qui a créé un post, il suffit de filter les posts par l'id de l'utilisateur. + +``` +user_posts = session.query(Post).filter(Post.user_id == user_id).first() +``` + +Il existe un moyen plus directe de récupérer les informations de l'utilisateur qui a créé un post en utilisant la méthode `relationship` de SQLAlchemy. + +``` +class Post(BaseSQL): + __tablename__ = "posts" + + id = Column(UUID(as_uuid=True), primary_key=True, index=True) + title = Column(String) + description = Column(String) + created_at = Column(DateTime()) + updated_at = Column(DateTime()) + user_id = Column(UUID(as_uuid=True), ForeignKey('users.id')) + user = relationship("User", back_populates="posts") +``` + +En premier argument, on retrouve le nom de la table User et en second argument, le nom de l'attribut `backpopulates` qui désigne le nom +de l'attribut de l'objet User pour y retrouver tous ses posts. +On peut donc maintenant récupérer l'utilisateur depuis un `Post` + +``` +post = session.query(Post).filter(Post.id == post_id).first() +user = post.user +all_user_posts = user.posts +``` + +Ou bien + +``` +user = session.query(User).filter(User.id == user_id).first() +user_posts = user.posts +``` + +# Authentication + +Dans cette section, nous allons voir comment mettre en place un système d'authentification basique avec FastAPI. +A partir d'un login classique avec username et mot de passe, nous allons générer un Token JWT (JSON Web Token) +qui sera ensuite utilisé pour s'authentifier sur nos endpoints qui seront configurés en conséquence. +Chaque endpoint qui attend une authentification acceptera un header `Authorization`. La valeur de ce header commencera par le mot `Bearer` suivi du token. + +``` +Authorization: Bearer +``` + + +## JWT Token + + +Un JWT (JSON Web Token) est un moyen sécurisé d'échanger des informations entre deux parties. + +Un tel token est constitué de trois parties : un header, un payload et une signature. + +Le header contient des informations sur le type de token et l'algorithme de cryptage utilisé pour le signer (HMAC, RSA...). +Le payload contient les données que l'on veut transmettre (comme l'ID d'utilisateur, les rôles, etc.). Ces données peuvent être publiques ou privées. +Signature : C'est une sorte de vérification qui permet de s'assurer que le token n'a pas été modifié. Elle est créée en combinant le header, le payload et une clé secrète. + +Voici un exemple de JWT Token + +``` +eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoibW9uX2lkIn0.qIBDak2Hk554hDP0hgHfMeQmt-D74kV21qQiHQ7AIow +``` + +Les trois parties distinctes sont séparées par un `.`. + +## Encodage et décodage de JWT Token + +Pour encoder un JWT Token, on utilise une librairie qui va nous permettre de générer un token à partir d'un payload et d'une clé secrète. + +``` python +import jwt +JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", "should-be-an-environment-variable") +JWT_SECRET_ALGORITHM = os.getenv("JWT_SECRET_ALGORITHM", "HS256") + +def _encode_jwt(user: User) -> str: + return jwt.encode( + { + "user_id": "mon_id", + }, + JWT_SECRET_KEY, + algorithm=JWT_SECRET_ALGORITHM, + ) +``` + +Il est fortement recommandé de stocker la clé secrète dans une variable d'environnement. + +Pour décoder un JWT Token, on utilise la même librairie qui va nous permettre de vérifier la signature du token et de récupérer les données du payload. + +``` python +def _decode_jwt(token: str) -> dict: + return jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_SECRET_ALGORITHM]) +``` + +Ainsi, un tier ne possédant pas la clé secrète ne pourra pas vérifier la signature d'un token, en créer un ou en décoder un. + +Par contre, un JWT Token en tant que tel est facilement décodable et lisible par n'importe qui. +En effet, il s'agit simplement d'une concaténation d'information en base64. + +Par exemple, on peut retrouver les informations encodées dans le token précédent en utilisant un décodeur en ligne (https://jwt.io/). + +Lors de la création d'un token, on y associe généralement un temps d'expiration pour des raisons de sécurité. + +## API endpoint avec authentification + +Nous devons maintenant configurer nos endpoints pour recevoir un JWT Token, vérifier son intégrité et autoriser l'accès aux ressources. + +Pour cela, nous allons utiliser une dépendance FastAPI qui va nous permettre de vérifier la validité du token. + +```python +from fastapi.security import HTTPBearer + +security=HTTPBearer() + +@router.get("/{post_id}", dependencies=[Depends(security)], tags=["posts"]) +async def get_post_by_id(post_id: str, request: Request, db: Session = Depends(models.get_db)): + auth_header = request.headers.get("Authorization") + + token = verify_autorization_header(auth_header) + + post = posts_service.get_post_by_id(post_id=post_id, db=db) + + if str(post.user_id) != token.get("user_id"): + raise HTTPException(status_code=403, detail=f"Forbidden {post.user_id} {token.get('user_id')}") + + return post +``` + +Pour une simple authentification, FastAPI nous fournit une dépendance `HTTPBearer` qui va nous permettre de vérifier la présence d'un token dans le header `Authorization`. +Vous verrez apparaitre un petit cadenas à côté de l'endpoint pour indiquer qu'il est sécurisé. + +![img.png](img.png) + +Pour s'authentifier, il suffit de générer un token et de l'envoyer dans le header `Authorization` de la requête HTTP. +La documentation de l'API vous offre un bouton pour tester l'endpoint avec un token. +En cliquant sur le cadenas, vous verrez une fenêtre s'ouvrir pour vous permettre de rentrer un token. + +![img_1.png](img_1.png) + +Il nous faut désormais décoder ce token pour vérifier l'identité de l'utilisateur. +Voici un exemple de fonction qui va nous permettre de vérifier le token. + +```python +def verify_autorization_header(access_token: str): + if not access_token or not access_token.startswith("Bearer "): + raise HTTPException(status_code=401, detail="No auth provided.") + + try: + token = access_token.split("Bearer ")[1] + auth = jwt.decode( + token, JWT_SECRET_KEY, JWT_SECRET_ALGORITHM + ) + except jwt.InvalidTokenError as err: + raise HTTPException(status_code=401, detail=f"Invalid token.") + + return auth +``` + +Ainsi, nous avons mis en place un système d'authentification basique avec FastAPI. +Toutes les requêtes vers les endpoints sécurisés devront être accompagnées d'un token valide. Sinon la requete sera rejetée diff --git a/projet/app/__init__.py b/base_authentication/app/__init__.py similarity index 100% rename from projet/app/__init__.py rename to base_authentication/app/__init__.py diff --git a/base_authentication/app/main.py b/base_authentication/app/main.py new file mode 100644 index 00000000..8a45060c --- /dev/null +++ b/base_authentication/app/main.py @@ -0,0 +1,59 @@ +import base64 +from typing import Optional +from fastapi import FastAPI, Header +from fastapi.middleware.cors import CORSMiddleware +import json + +from starlette.requests import Request +from starlette_exporter import PrometheusMiddleware, handle_metrics +from models import BaseSQL, engine +import routers +from routers.auth import auth_router +from routers.users import user_router + +app = FastAPI( + title="Auth app", + description="My description", + version="0.0.1", +) +origins = [ + "http://localhost:4200", + "http://localhost:3000", + "http://localhost:3001", +] + +app.add_middleware( + CORSMiddleware, + allow_origins=origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(routers.PostRouter) +app.include_router(routers.HealthRouter) +app.include_router(user_router) +app.include_router(auth_router) + +app.add_middleware(PrometheusMiddleware) + +app.add_route("/metrics", handle_metrics) + + +@app.on_event("startup") +async def startup_event(): + BaseSQL.metadata.create_all(bind=engine) + + +@app.get("/api/headers") +def read_hello( + request: Request, + x_userinfo: Optional[str] = Header(None, convert_underscores=True), +): + print(request["headers"]) + return {"Headers": json.loads(base64.b64decode(x_userinfo))} + + +@app.get("/") +def read_root(): + return {"Hello": "World"} diff --git a/base_authentication/app/models/__init__.py b/base_authentication/app/models/__init__.py new file mode 100644 index 00000000..74f11bd6 --- /dev/null +++ b/base_authentication/app/models/__init__.py @@ -0,0 +1,4 @@ +from .post import Post +from .user import User +from .database import BaseSQL +from .db import get_db, engine diff --git a/projet/app/models/database.py b/base_authentication/app/models/database.py similarity index 72% rename from projet/app/models/database.py rename to base_authentication/app/models/database.py index 2b15ed94..690701c7 100644 --- a/projet/app/models/database.py +++ b/base_authentication/app/models/database.py @@ -1,7 +1,7 @@ from sqlalchemy import create_engine from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker -import os +import os POSTGRES_USER = os.environ.get("POSTGRES_USER") @@ -9,12 +9,12 @@ POSTGRES_DB = os.environ.get("POSTGRES_DB") -SQLALCHEMY_DATABASE_URL = f"postgresql://{POSTGRES_USER}:{POSTGRES_PASSWORD}@db/{POSTGRES_DB}" +SQLALCHEMY_DATABASE_URL = ( + f"postgresql://{POSTGRES_USER}:{POSTGRES_PASSWORD}@db/{POSTGRES_DB}" +) print(SQLALCHEMY_DATABASE_URL) -engine = create_engine( - SQLALCHEMY_DATABASE_URL -) +engine = create_engine(SQLALCHEMY_DATABASE_URL) SessionLocal = sessionmaker(autocommit=False, autoflush=True, bind=engine) BaseSQL = declarative_base() diff --git a/base_authentication/app/models/db.py b/base_authentication/app/models/db.py new file mode 100644 index 00000000..a25d8386 --- /dev/null +++ b/base_authentication/app/models/db.py @@ -0,0 +1,10 @@ +from .database import SessionLocal, engine + + +# Dependency +def get_db(): + try: + db = SessionLocal() + yield db + finally: + db.close() diff --git a/base_authentication/app/models/post.py b/base_authentication/app/models/post.py new file mode 100644 index 00000000..14eb20d3 --- /dev/null +++ b/base_authentication/app/models/post.py @@ -0,0 +1,19 @@ +from sqlalchemy import Column, String, DateTime, Integer, ForeignKey +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship + +from models.database import BaseSQL + + +class Post(BaseSQL): + __tablename__ = "posts" + + id = Column(UUID(as_uuid=True), primary_key=True, index=True) + title = Column(String) + description = Column(String) + + user_id = Column(UUID, ForeignKey("users.id")) + user = relationship("User", back_populates="posts") + + created_at = Column(DateTime()) + updated_at = Column(DateTime()) diff --git a/base_authentication/app/models/user.py b/base_authentication/app/models/user.py new file mode 100644 index 00000000..bc5f4088 --- /dev/null +++ b/base_authentication/app/models/user.py @@ -0,0 +1,20 @@ +from datetime import datetime +from uuid import uuid4 + +from sqlalchemy import Column, String, DateTime +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship + +from .database import BaseSQL + + +class User(BaseSQL): + __tablename__ = "users" + + id = Column(UUID(as_uuid=True), primary_key=True, index=True, default=str(uuid4())) + username = Column(String, unique=True) + password = Column(String) + created_at = Column(DateTime(), default=datetime.now()) + updated_at = Column(DateTime(), default=datetime.now()) + + posts = relationship("Post", back_populates="user") diff --git a/base_authentication/app/routers/__init__.py b/base_authentication/app/routers/__init__.py new file mode 100644 index 00000000..93ea2d4d --- /dev/null +++ b/base_authentication/app/routers/__init__.py @@ -0,0 +1,2 @@ +from .posts import router as PostRouter +from .health import router as HealthRouter diff --git a/base_authentication/app/routers/auth.py b/base_authentication/app/routers/auth.py new file mode 100644 index 00000000..d79f3eb1 --- /dev/null +++ b/base_authentication/app/routers/auth.py @@ -0,0 +1,20 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +import models +from schemas.auth_token import AuthToken +from schemas.users import User +from services.auth import generate_access_token + +auth_router = APIRouter(prefix="/auth") + + +@auth_router.post("/token", tags=["auth"]) +async def get_access_token( + user_login: User, + db: Session = Depends(models.get_db), +) -> AuthToken: + access_token = generate_access_token(db=db, user_login=user_login) + return AuthToken( + access_token=access_token, + ) diff --git a/projet/app/routers/health.py b/base_authentication/app/routers/health.py similarity index 81% rename from projet/app/routers/health.py rename to base_authentication/app/routers/health.py index 3b042fdf..6cfd7c96 100644 --- a/projet/app/routers/health.py +++ b/base_authentication/app/routers/health.py @@ -22,6 +22,8 @@ def read_root(): @router.get("/api/headers") -def read_hello(request: Request, x_userinfo: Optional[str] = Header(None, convert_underscores=True)): +def read_hello( + request: Request, x_userinfo: Optional[str] = Header(None, convert_underscores=True) +): print(request["headers"]) return {"Headers": json.loads(base64.b64decode(x_userinfo))} diff --git a/base_authentication/app/routers/posts.py b/base_authentication/app/routers/posts.py new file mode 100644 index 00000000..b26309e8 --- /dev/null +++ b/base_authentication/app/routers/posts.py @@ -0,0 +1,73 @@ +from typing import List, Any, Dict + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.security import HTTPBearer +from sqlalchemy.orm import Session +from starlette.requests import Request +from starlette.routing import Router + +import models +import schemas + +from services.auth import verify_authorization_header, get_user_id +from services import posts as posts_service + +router = APIRouter(prefix="/posts") + +security = HTTPBearer() + + +@router.post("/", dependencies=[Depends(security)], tags=["posts"]) +async def create_post( + post: schemas.Post, + user_id: str = Depends(get_user_id), + db: Session = Depends(models.get_db), +): + return posts_service.create_post(user_id=user_id, post=post, db=db) + + +@router.get("/users", dependencies=[Depends(security)], tags=["posts_per_user"]) +async def get_user_posts( + token: int = Depends(verify_authorization_header), + db: Session = Depends(models.get_db), +) -> List[schemas.Post]: + + user_id = token.get("user_id") + + return posts_service.get_posts_for_user(db=db, user_id=user_id) + + +@router.get("/{post_id}", dependencies=[Depends(security)], tags=["posts"]) +async def get_post_by_id( + post_id: str, token: int = Depends(verify_authorization_header), db: Session = Depends(models.get_db) +): + post = posts_service.get_post_by_id(post_id=post_id, db=db) + + if str(post.user_id) != token.get("user_id"): + raise HTTPException( + status_code=403, detail=f"Forbidden {post.user_id} {token.get('user_id')}" + ) + + return post + + +@router.get("/", tags=["posts"]) +async def get_posts(db: Session = Depends(models.get_db)): + return posts_service.get_all_posts(db=db) + + +@router.put("/{post_id}", dependencies=[Depends(security)], tags=["posts"]) +async def update_post_by_id( + post_id: str, post: schemas.Post, db: Session = Depends(models.get_db) +): + return posts_service.update_post(post_id=post_id, db=db, post=post) + + +@router.delete("/{post_id}", tags=["posts"]) +async def delete_post_by_id(post_id: str, db: Session = Depends(models.get_db)): + return posts_service.delete_post(post_id=post_id, db=db) + + +@router.delete("/", tags=["posts"]) +async def delete_all_posts(db: Session = Depends(models.get_db)): + return posts_service.delete_all_posts(db=db) diff --git a/base_authentication/app/routers/users.py b/base_authentication/app/routers/users.py new file mode 100644 index 00000000..519e7ac9 --- /dev/null +++ b/base_authentication/app/routers/users.py @@ -0,0 +1,20 @@ +from typing import List + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +import models +from schemas.users import User +from services.users import create_user, get_all_users + +user_router = APIRouter(prefix="/users") + + +@user_router.post("/", tags=["users"]) +async def post_user(user: User, db: Session = Depends(models.get_db)): + return create_user(user=user, db=db) + + +@user_router.get("/", tags=["users"]) +async def retrieve_all_users(db: Session = Depends(models.get_db)) -> List[User]: + return get_all_users(db=db) diff --git a/base_authentication/app/routers/utils.py b/base_authentication/app/routers/utils.py new file mode 100644 index 00000000..5e5499d6 --- /dev/null +++ b/base_authentication/app/routers/utils.py @@ -0,0 +1,17 @@ +import jwt +from fastapi import HTTPException + +from services.auth import JWT_SECRET_KEY, JWT_SECRET_ALGORITHM + + +def verify_autorization_header(access_token: str): + if not access_token or not access_token.startswith("Bearer "): + raise HTTPException(status_code=401, detail="No auth provided.") + + try: + token = access_token.split("Bearer ")[1] + auth = jwt.decode(token, JWT_SECRET_KEY, JWT_SECRET_ALGORITHM) + except jwt.InvalidTokenError as err: + raise HTTPException(status_code=401, detail=f"Invalid token.") + + return auth diff --git a/projet/app/schemas/__init__.py b/base_authentication/app/schemas/__init__.py similarity index 100% rename from projet/app/schemas/__init__.py rename to base_authentication/app/schemas/__init__.py diff --git a/base_authentication/app/schemas/auth_token.py b/base_authentication/app/schemas/auth_token.py new file mode 100644 index 00000000..cafb3d5b --- /dev/null +++ b/base_authentication/app/schemas/auth_token.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel + + +class AuthToken(BaseModel): + access_token: str diff --git a/base_authentication/app/schemas/posts.py b/base_authentication/app/schemas/posts.py new file mode 100644 index 00000000..5c50f6f0 --- /dev/null +++ b/base_authentication/app/schemas/posts.py @@ -0,0 +1,17 @@ +from typing import List, Optional +from datetime import datetime +from pydantic import BaseModel, Field +from uuid import uuid4 +from typing_extensions import Annotated + + +class Post(BaseModel): + id: Annotated[str, Field(default_factory=lambda: uuid4().hex)] + title: str + description: Optional[str] + user_id: Optional[str] + created_at: Annotated[datetime, Field(default_factory=lambda: datetime.now())] + updated_at: Annotated[datetime, Field(default_factory=lambda: datetime.now())] + + class Config: + orm_mode = True diff --git a/base_authentication/app/schemas/users.py b/base_authentication/app/schemas/users.py new file mode 100644 index 00000000..81bc556c --- /dev/null +++ b/base_authentication/app/schemas/users.py @@ -0,0 +1,10 @@ +from typing import List, Optional +from datetime import datetime +from pydantic import BaseModel, Field +from uuid import uuid4 +from typing_extensions import Annotated + + +class User(BaseModel): + username: str + password: str diff --git a/projet/app/dependencies/__init__.py b/base_authentication/app/services/__init__.py similarity index 100% rename from projet/app/dependencies/__init__.py rename to base_authentication/app/services/__init__.py diff --git a/base_authentication/app/services/auth.py b/base_authentication/app/services/auth.py new file mode 100644 index 00000000..96b90128 --- /dev/null +++ b/base_authentication/app/services/auth.py @@ -0,0 +1,70 @@ +import os +from typing import Dict, Union, List + +import jwt +from fastapi import Depends, HTTPException, Header +from sqlalchemy.orm import Session + +import models +from schemas.users import User + +JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", "should-be-an-environment-variable") +JWT_SECRET_ALGORITHM = os.getenv("JWT_SECRET_ALGORITHM", "HS256") + + +def _encode_jwt(user: User) -> str: + return jwt.encode( + { + "user_id": str(user.id), + "role": user.role, # admin, customer, staff, manager... + }, + JWT_SECRET_KEY, + algorithm=JWT_SECRET_ALGORITHM, + ) + + +async def verify_authorization_header( + authorization: str = Header(...), +) -> Dict[str, Union[int, Dict[str, Union[List[str], int, str]]]]: + if not authorization or not authorization.startswith("Bearer "): + raise HTTPException(status_code=401, detail="No authorization header") + try: + auth = jwt.decode( + authorization[7:], + JWT_SECRET_KEY, + algorithms=[JWT_SECRET_ALGORITHM], + ) + except jwt.ExpiredSignatureError: + raise HTTPException(status_code=401, detail="Token expired") + except jwt.InvalidTokenError as err: + raise HTTPException(status_code=401, detail=f"Invalid token: '{err}'") + + return auth + + +async def get_user_id(authorization: str = Header(...)) -> str: + auth = await verify_authorization_header(authorization) + try: + user_id = str(auth["user_id"]) + except KeyError: + raise HTTPException(status_code=401, detail="Invalid token") + return user_id + + +def generate_access_token( + db: Session, + user_login: User, +): + user = ( + db.query(models.User) + .filter( + models.User.username == user_login.username, + models.User.password == user_login.password, + ) + .first() + ) + + if not user: + raise HTTPException(status_code=404, detail="Incorrect username or password") + + return _encode_jwt(user) diff --git a/base_authentication/app/services/posts.py b/base_authentication/app/services/posts.py new file mode 100644 index 00000000..f76fe8ce --- /dev/null +++ b/base_authentication/app/services/posts.py @@ -0,0 +1,68 @@ +from typing import List + +from sqlalchemy.orm import Session +from fastapi import HTTPException +from datetime import datetime +import models, schemas + + +def get_all_posts(db: Session, skip: int = 0, limit: int = 10) -> List[models.Post]: + records = db.query(models.Post).filter().all() + for record in records: + record.id = str(record.id) + return records + + +def get_post_by_id(post_id: str, db: Session) -> models.Post: + record = db.query(models.Post).filter(models.Post.id == post_id).first() + if not record: + raise HTTPException(status_code=404, detail="Not Found") + record.id = str(record.id) + return record + + +def get_posts_by_title(title: str, db: Session) -> List[models.Post]: + records = db.query(models.Post).filter(models.Post.title == title).all() + for record in records: + record.id = str(record.id) + return records + + +def get_posts_for_user(db: Session, user_id: str) -> List[models.Post]: + return db.query(models.Post).filter(models.Post.user_id == user_id).all() + + +def update_post(post_id: str, db: Session, post: schemas.Post) -> models.Post: + db_post = get_post_by_id(post_id=post_id, db=db) + for var, value in vars(post).items(): + setattr(db_post, var, value) if value else None + db_post.updated_at = datetime.now() + db.add(db_post) + db.commit() + db.refresh(db_post) + return db_post + + +def delete_post(post_id: str, db: Session) -> models.Post: + db_post = get_post_by_id(post_id=post_id, db=db) + db.delete(db_post) + db.commit() + return db_post + + +def delete_all_posts(db: Session) -> List[models.Post]: + records = db.query(models.Post).filter() + for record in records: + db.delete(record) + db.commit() + return records + + +def create_post(db: Session, user_id: str, post: schemas.Post) -> models.Post: + db_post = models.Post(**post.dict()) + db_post.user_id = user_id + db.add(db_post) + db.commit() + db.refresh(db_post) + db_post.id = str(db_post.id) + return db_post diff --git a/base_authentication/app/services/users.py b/base_authentication/app/services/users.py new file mode 100644 index 00000000..3bdf675d --- /dev/null +++ b/base_authentication/app/services/users.py @@ -0,0 +1,52 @@ +from typing import Optional, List +from uuid import uuid4 + +import jwt +from fastapi import HTTPException +from jwt import InvalidTokenError +from sqlalchemy.orm import Session +from starlette.status import HTTP_401_UNAUTHORIZED + +import models +from models import get_db +from schemas.users import User +from services.auth import JWT_SECRET_KEY, JWT_SECRET_ALGORITHM + + +def create_user(db: Session, user: User) -> models.User: + record = db.query(models.User).filter(models.User.username == user.username).first() + if record: + raise HTTPException(status_code=409, detail="Username already taken") + + db_user = models.User( + id=str(uuid4()), username=user.username, password=user.password + ) + db.add(db_user) + db.commit() + + return db_user + + +def get_all_users(db: Session) -> List[models.User]: + return db.query(models.User).filter().all() + + +def get_user_by_id(db: Session, user_id: int) -> Optional[models.User]: + return db.query(models.User).filter(models.User.id == user_id).first() + + +def get_current_user_id(token: str): + try: + payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_SECRET_ALGORITHM]) + user_id: str = payload.get("user_id") + if user_id is None: + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + ) + return user_id + except InvalidTokenError: + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + ) diff --git a/base_authentication/docker-compose.yml b/base_authentication/docker-compose.yml new file mode 100644 index 00000000..f926d156 --- /dev/null +++ b/base_authentication/docker-compose.yml @@ -0,0 +1,33 @@ +version: '3.4' + +networks: + default: + driver: bridge +services: + api: + build: . + networks: + - default + volumes: + - ./app/:/app + command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080", "--reload"] + ports: + - "8080:8080" + env_file: + - .env + depends_on: + - db + + db: + image: postgres + restart: always + env_file: + - .env + ports: + - "5431:5432" + + volumes: + - postgres_data:/var/lib/postgresql/data + +volumes: + postgres_data: \ No newline at end of file diff --git a/base_authentication/img.png b/base_authentication/img.png new file mode 100644 index 00000000..230d5e01 Binary files /dev/null and b/base_authentication/img.png differ diff --git a/base_authentication/img_1.png b/base_authentication/img_1.png new file mode 100644 index 00000000..7452d469 Binary files /dev/null and b/base_authentication/img_1.png differ diff --git a/projet/requirements.txt b/base_authentication/requirements.txt similarity index 62% rename from projet/requirements.txt rename to base_authentication/requirements.txt index d298b3c4..5aaa1af3 100644 --- a/projet/requirements.txt +++ b/base_authentication/requirements.txt @@ -2,4 +2,6 @@ starlette_exporter sqlalchemy pydantic psycopg2 -python-slugify \ No newline at end of file +python-slugify +pyjwt +fastapi diff --git a/base_authentication/tp.md b/base_authentication/tp.md new file mode 100644 index 00000000..d8eddddc --- /dev/null +++ b/base_authentication/tp.md @@ -0,0 +1,22 @@ +# Exercice + +## Première partie +A partir de l'app mise en place dans le dossier `app/`, ajouter un système d'authentification basique avec FastAPI sur les +endpoints que vous jugerez bon de sécuriser. + +## Deuxième partie + +Vous remarquerez certainement que pour chaque endpoint sécurisé, vous devrez vérifier le token JWT à chaque fois. +Ce qui implique une duplication de code. +A l'aide de la documentation de FastAPI, mettez en place un système permettant de gérer +l'authentification de manière automatique pour un endpoint. + +## Troisième partie + +Ecrivez une fonction qui récupère l'ID du user à partir du token JWT. +En entrant dans la fonction de l'endpoint, le programme devra avoir valider le token et avoir l'ID de l'utilisateur à disposition + +## Quatrième partie + +A l'aide de la librairie `requests`, écrivez un script python pour effectuer une requête vers un de vos endpoint sécurisé. + diff --git a/kong/POSTGRES_PASSWORD b/kong/POSTGRES_PASSWORD old mode 100755 new mode 100644 diff --git a/kong/docker/scripts/kong_cors_plugin.py b/kong/docker/scripts/kong_cors_plugin.py index eee1bdb9..cb821177 100644 --- a/kong/docker/scripts/kong_cors_plugin.py +++ b/kong/docker/scripts/kong_cors_plugin.py @@ -1,5 +1,6 @@ import requests import os + # from dotenv import load_dotenv # load_dotenv("../../.env") @@ -16,33 +17,38 @@ BACKEND_HOST_IP = os.environ.get("BACKEND_HOST_IP") data = [ - ('name', 'cors'), - ('config.origins', 'http://localhost:1337/*'), - ('config.methods', 'GET'), - ('config.methods', 'POST'), - ('config.methods', 'OPTIONS'), - ('config.methods', 'PUT'), - ('config.methods', 'DELETE'), - ('config.headers', 'Accept'), - ('config.headers', 'Accept-Version'), - ('config.headers', 'Content-Length'), - ('config.headers', 'Content-MD5'), - ('config.headers', 'Content-Type'), - ('config.headers', 'Authorization'), - ('config.headers', 'Date'), - ('config.headers', 'X-Auth-Token'), - ('config.exposed_headers', 'X-Auth-Token'), - ('config.credentials', 'true'), - ('config.max_age', '3600') + ("name", "cors"), + ("config.origins", "http://localhost:1337/*"), + ("config.methods", "GET"), + ("config.methods", "POST"), + ("config.methods", "OPTIONS"), + ("config.methods", "PUT"), + ("config.methods", "DELETE"), + ("config.headers", "Accept"), + ("config.headers", "Accept-Version"), + ("config.headers", "Content-Length"), + ("config.headers", "Content-MD5"), + ("config.headers", "Content-Type"), + ("config.headers", "Authorization"), + ("config.headers", "Date"), + ("config.headers", "X-Auth-Token"), + ("config.exposed_headers", "X-Auth-Token"), + ("config.credentials", "true"), + ("config.max_age", "3600"), ] def get_enki_service_id(): - response = requests.get(f'http://{KONG_HOST_IP}:{KONG_PORT}/services') - print(response.json()["data"]) - enki_api_id = [elt["id"] for elt in response.json()["data"] if elt["host"] == BACKEND_HOST_IP][0] - return enki_api_id + response = requests.get(f"http://{KONG_HOST_IP}:{KONG_PORT}/services") + print(response.json()["data"]) + enki_api_id = [ + elt["id"] for elt in response.json()["data"] if elt["host"] == BACKEND_HOST_IP + ][0] + return enki_api_id + enki_service_id = get_enki_service_id() -response = requests.post(f'http://{KONG_HOST_IP}:{KONG_PORT}/services/{enki_service_id}/plugins', data=data) +response = requests.post( + f"http://{KONG_HOST_IP}:{KONG_PORT}/services/{enki_service_id}/plugins", data=data +) diff --git a/kong/docker/scripts/kong_oidc_import.py b/kong/docker/scripts/kong_oidc_import.py index f75d0866..abf331a5 100644 --- a/kong/docker/scripts/kong_oidc_import.py +++ b/kong/docker/scripts/kong_oidc_import.py @@ -2,6 +2,7 @@ import os import uuid from keycloak.exceptions import KeycloakGetError + # from dotenv import load_dotenv # load_dotenv("../../.env") @@ -22,92 +23,95 @@ services = [ - { - 'name': "front_service", - 'url': f'http://nginx:7777/front', - 'path': "front" - - }, - { - 'name': "api_service", - 'url': f'http://api:5000/api', - 'path': "api" - - } + {"name": "front_service", "url": f"http://nginx:7777/front", "path": "front"}, + {"name": "api_service", "url": f"http://api:5000/api", "path": "api"}, ] -#def clean(): -response = requests.get(f'http://{KONG_HOST_IP}:{KONG_PORT}/routes') +# def clean(): +response = requests.get(f"http://{KONG_HOST_IP}:{KONG_PORT}/routes") for _id in [e["id"] for e in response.json()["data"]]: - requests.delete(f'http://{KONG_HOST_IP}:{KONG_PORT}/routes/{_id}') -response = requests.get(f'http://{KONG_HOST_IP}:{KONG_PORT}/services') + requests.delete(f"http://{KONG_HOST_IP}:{KONG_PORT}/routes/{_id}") +response = requests.get(f"http://{KONG_HOST_IP}:{KONG_PORT}/services") for _id in [e["id"] for e in response.json()["data"]]: - requests.delete(f'http://{KONG_HOST_IP}:{KONG_PORT}/services/{_id}') + requests.delete(f"http://{KONG_HOST_IP}:{KONG_PORT}/services/{_id}") from keycloak import KeycloakAdmin # Create kong client on Keycloak -keycloak_admin = KeycloakAdmin(server_url=KEYCLOAK_URL, - username=KEYCLOAK_ADMIN_USER, - password=KEYCLOAK_ADMIN_PASSWORD, - verify=True) +keycloak_admin = KeycloakAdmin( + server_url=KEYCLOAK_URL, + username=KEYCLOAK_ADMIN_USER, + password=KEYCLOAK_ADMIN_PASSWORD, + verify=True, +) def create_client_and_get_client_secret(): # Create kong client on Keycloak - keycloak_admin = KeycloakAdmin(server_url=KEYCLOAK_URL, - username=KEYCLOAK_ADMIN_USER, - password=KEYCLOAK_ADMIN_PASSWORD, - verify=True) + keycloak_admin = KeycloakAdmin( + server_url=KEYCLOAK_URL, + username=KEYCLOAK_ADMIN_USER, + password=KEYCLOAK_ADMIN_PASSWORD, + verify=True, + ) try: - keycloak_admin.create_client({ - "clientId":CLIENT_NAME, - "name":CLIENT_NAME, - "enabled": True, - "redirectUris":[ "/front/*", "/api/*", "/*", "*" ], - }) - + keycloak_admin.create_client( + { + "clientId": CLIENT_NAME, + "name": CLIENT_NAME, + "enabled": True, + "redirectUris": ["/front/*", "/api/*", "/*", "*"], + } + ) + client_uuid = keycloak_admin.get_client_id(CLIENT_NAME) keycloak_admin.generate_client_secrets(client_uuid) except KeycloakGetError as e: - if e.response_code == 409: + if e.response_code == 409: print("Keycloak Kong client already exists") - + client_uuid = keycloak_admin.get_client_id(CLIENT_NAME) - return keycloak_admin.get_client_secrets(client_uuid)['value'] + return keycloak_admin.get_client_secrets(client_uuid)["value"] + CLIENT_SECRET = create_client_and_get_client_secret() -introspection_url = f'http://{KEYCLOAK_HOST_IP}:{KEYCLOAK_PORT}/auth/realms/{REALM_NAME}/protocol/openid-connect/token/introspect' -discovery_url = f'http://{KEYCLOAK_HOST_IP}:{KEYCLOAK_PORT}/auth/realms/{REALM_NAME}/.well-known/openid-configuration' +introspection_url = f"http://{KEYCLOAK_HOST_IP}:{KEYCLOAK_PORT}/auth/realms/{REALM_NAME}/protocol/openid-connect/token/introspect" +discovery_url = f"http://{KEYCLOAK_HOST_IP}:{KEYCLOAK_PORT}/auth/realms/{REALM_NAME}/.well-known/openid-configuration" for service in services: data = service # Create Service - response = requests.post(f'http://{KONG_HOST_IP}:{KONG_PORT}/services', data=data) + response = requests.post(f"http://{KONG_HOST_IP}:{KONG_PORT}/services", data=data) created_service_id = response.json()["id"] # Create route data = { - 'service.id': f'{created_service_id}', - 'paths[]': f'/{service["path"]}', + "service.id": f"{created_service_id}", + "paths[]": f'/{service["path"]}', } - response = requests.post(f'http://{KONG_HOST_IP}:{KONG_PORT}/services/{service["name"]}/routes', data=data) + response = requests.post( + f'http://{KONG_HOST_IP}:{KONG_PORT}/services/{service["name"]}/routes', + data=data, + ) # Configure OIDC data = { - 'name': 'oidc', - 'config.client_id': f'{CLIENT_ID}', - 'config.client_secret': f'{CLIENT_SECRET}', - 'config.realm': f'{REALM_NAME}', - 'config.bearer_only': 'true', - 'config.introspection_endpoint': introspection_url, - 'config.discovery': discovery_url + "name": "oidc", + "config.client_id": f"{CLIENT_ID}", + "config.client_secret": f"{CLIENT_SECRET}", + "config.realm": f"{REALM_NAME}", + "config.bearer_only": "true", + "config.introspection_endpoint": introspection_url, + "config.discovery": discovery_url, } - response = requests.post(f'http://{KONG_HOST_IP}:{KONG_PORT}/services/{created_service_id}/plugins', data=data) \ No newline at end of file + response = requests.post( + f"http://{KONG_HOST_IP}:{KONG_PORT}/services/{created_service_id}/plugins", + data=data, + ) diff --git a/kong/docker/scripts/kong_oidc_import_test.py b/kong/docker/scripts/kong_oidc_import_test.py index e7ffe8b1..ed83ea71 100644 --- a/kong/docker/scripts/kong_oidc_import_test.py +++ b/kong/docker/scripts/kong_oidc_import_test.py @@ -1,6 +1,7 @@ import requests import os import uuid + # from dotenv import load_dotenv # load_dotenv("../../.env") @@ -15,74 +16,74 @@ services = [ - { - 'name': "front_service", - 'url': f'http://nginx:7777/front', - 'path': "front" - - }, - { - 'name': "api_service", - 'url': f'http://nginx:7777/api', - 'path': "api" - - } + {"name": "front_service", "url": f"http://nginx:7777/front", "path": "front"}, + {"name": "api_service", "url": f"http://nginx:7777/api", "path": "api"}, ] -#def clean(): -response = requests.get(f'http://{KONG_HOST_IP}:{KONG_PORT}/routes') +# def clean(): +response = requests.get(f"http://{KONG_HOST_IP}:{KONG_PORT}/routes") for _id in [e["id"] for e in response.json()["data"]]: - requests.delete(f'http://{KONG_HOST_IP}:{KONG_PORT}/routes/{_id}') -response = requests.get(f'http://{KONG_HOST_IP}:{KONG_PORT}/services') + requests.delete(f"http://{KONG_HOST_IP}:{KONG_PORT}/routes/{_id}") +response = requests.get(f"http://{KONG_HOST_IP}:{KONG_PORT}/services") for _id in [e["id"] for e in response.json()["data"]]: - requests.delete(f'http://{KONG_HOST_IP}:{KONG_PORT}/services/{_id}') + requests.delete(f"http://{KONG_HOST_IP}:{KONG_PORT}/services/{_id}") from keycloak import KeycloakAdmin # Create kong client on Keycloak -keycloak_admin = KeycloakAdmin(server_url=KEYCLOAK_URL, - username=KEYCLOAK_ADMIN_USER, - password=KEYCLOAK_ADMIN_PASSWORD, - verify=True) +keycloak_admin = KeycloakAdmin( + server_url=KEYCLOAK_URL, + username=KEYCLOAK_ADMIN_USER, + password=KEYCLOAK_ADMIN_PASSWORD, + verify=True, +) CLIENT_KONG_KEYCLOAK_ID = str(uuid.uuid4()) -keycloak_admin.create_client({ - "id":CLIENT_KONG_KEYCLOAK_ID, - "clientId":CLIENT_ID, - "name":CLIENT_ID, - "enabled": True, - "redirectUris":[ "/front/*", "/api/*", "/*", "*" ], -}) +keycloak_admin.create_client( + { + "id": CLIENT_KONG_KEYCLOAK_ID, + "clientId": CLIENT_ID, + "name": CLIENT_ID, + "enabled": True, + "redirectUris": ["/front/*", "/api/*", "/*", "*"], + } +) CLIENT_SECRET = keycloak_admin.get_client_secrets(CLIENT_KONG_KEYCLOAK_ID)["value"] -introspection_url = f'http://{KEYCLOAK_HOST_IP}:{KEYCLOAK_PORT}/auth/realms/{REALM_NAME}/protocol/openid-connect/token/introspect' -discovery_url = f'http://{KEYCLOAK_HOST_IP}:{KEYCLOAK_PORT}/auth/realms/{REALM_NAME}/.well-known/openid-configuration' +introspection_url = f"http://{KEYCLOAK_HOST_IP}:{KEYCLOAK_PORT}/auth/realms/{REALM_NAME}/protocol/openid-connect/token/introspect" +discovery_url = f"http://{KEYCLOAK_HOST_IP}:{KEYCLOAK_PORT}/auth/realms/{REALM_NAME}/.well-known/openid-configuration" for service in services: data = service # Create Service - response = requests.post(f'http://{KONG_HOST_IP}:{KONG_PORT}/services', data=data) + response = requests.post(f"http://{KONG_HOST_IP}:{KONG_PORT}/services", data=data) created_service_id = response.json()["id"] # Create route data = { - 'service.id': f'{created_service_id}', - 'paths[]': f'/{service["path"]}', + "service.id": f"{created_service_id}", + "paths[]": f'/{service["path"]}', } - response = requests.post(f'http://{KONG_HOST_IP}:{KONG_PORT}/services/{service["name"]}/routes', data=data) + response = requests.post( + f'http://{KONG_HOST_IP}:{KONG_PORT}/services/{service["name"]}/routes', + data=data, + ) # Configure OIDC data = { - 'name': 'oidc', - 'config.client_id': f'{CLIENT_ID}', - 'config.client_secret': f'{CLIENT_SECRET}', - 'config.realm': f'{REALM_NAME}', - 'config.bearer_only': 'true', - 'config.introspection_endpoint': introspection_url, - 'config.discovery': discovery_url + "name": "oidc", + "config.client_id": f"{CLIENT_ID}", + "config.client_secret": f"{CLIENT_SECRET}", + "config.realm": f"{REALM_NAME}", + "config.bearer_only": "true", + "config.introspection_endpoint": introspection_url, + "config.discovery": discovery_url, } - response = requests.post(f'http://{KONG_HOST_IP}:{KONG_PORT}/services/{created_service_id}/plugins', data=data) \ No newline at end of file + response = requests.post( + f"http://{KONG_HOST_IP}:{KONG_PORT}/services/{created_service_id}/plugins", + data=data, + ) diff --git a/ops/cours/Dockerfile b/ops/cours/Dockerfile index 0b46a19e..3e69e679 100644 --- a/ops/cours/Dockerfile +++ b/ops/cours/Dockerfile @@ -2,9 +2,10 @@ FROM python:3.10-slim-buster WORKDIR /app +COPY . . + COPY requirements.txt requirements.txt RUN pip3 install -r requirements.txt -COPY . . CMD [ "python3", "-m" , "flask", "--debug", "run", "--host=0.0.0.0" ] diff --git a/ops/cours/README.md b/ops/cours/README.md index 4af82c52..47eb5824 100644 --- a/ops/cours/README.md +++ b/ops/cours/README.md @@ -1,6 +1,7 @@ # Docker - [Docker](#docker) + - [Terminology](#terminology) - [Install Docker Desktop](#install-docker-desktop) - [Build an image](#build-an-image) - [Launch a container](#launch-a-container) @@ -14,6 +15,35 @@ - [Docker registry](#docker-registry) - [Développer depuis un conteneur](#développer-depuis-un-conteneur) +## Terminology + +### Image + +An image is a read-only template with instructions for creating a Docker container. +Often, an image is based on another image, with some additional customization. +It contains all the libraries, dependencies, and files that the container needs to run. +An image is shareable and portable, so you can deploy the same image in multiple locations at once—much like a software binary file. + +### Container + +A container is a runnable instance of an image. +You can create, start, stop, move, or delete a container using the Docker API or CLI. + +### Registry + +A registry is a collection of repositories, and a repository is a collection of images—sort of like a GitHub repository, but for Docker images. + +### Tag + +A tag is a label applied to a Docker image in a repository. +Tags are used to specify different versions of the same image, for example, `latest`, `v1`, `v2`. + +### Volume + +A volume is a persistent data storage mechanism that allows data to exist beyond the lifetime of the container. +Volumes are used to share data between the host and the container, and between containers. + + ## Install Docker Desktop Windows: https://docs.docker.com/desktop/install/windows-install/ @@ -38,11 +68,22 @@ COPY . . CMD [ "python3", "-m" , "flask", "--debug", "run", "--host=0.0.0.0" ] ``` -Build image +Build image: + +By default, Docker will look for a file named `Dockerfile` in the directory designated by the `context` argument (the last one specified). + ``` docker build -t image_name . ``` +This command will build an image named `image_name` from the file named `Dockerfile` in the current directory (`context` arg is .) . + +To build an image from a specific Dockerfile, use the `-f` flag. + +``` +docker build -t image_name -f dockerfiles/other_Dockerfile whatever_folder/ +``` + ## Launch a container @@ -101,6 +142,27 @@ Exit with `CTRL-D` ## Docker Compose +Docker compose is a tool for defining and running multi-container Docker applications. You can see it as a shortcut of the `docker run` command. +You can also give instructions to build a container. +With a single command you can create and start all the services from your configuration. + +Docker compose is based on a yaml file named by default docker-compose.yml. + + +```docker-compose.yaml +version: '3.8' +services: + web: + build: . + ports: + - "5000:5000" + redis: + image: "redis:alpine" +``` + + +### Commands + Launch containers ``` docker-compose up @@ -109,7 +171,7 @@ Launch containers and force rebuild ``` docker-compose up --build ``` -Launch containers in detached mode +Launch containers in detached mode (background) ``` docker-compose up -d ``` diff --git a/ops/cours/app.py b/ops/cours/app.py index fbce0a3b..9660184e 100644 --- a/ops/cours/app.py +++ b/ops/cours/app.py @@ -1,7 +1,8 @@ from flask import Flask + app = Flask(__name__) -@app.route('/') -def hello_world(): - return 'Hello, Docker!' +@app.route("/") +def hello_world(): + return "Hello, Docker!" diff --git a/ops/cours/docker-compose.yml b/ops/cours/docker-compose.yml index 5ebda332..3d862939 100644 --- a/ops/cours/docker-compose.yml +++ b/ops/cours/docker-compose.yml @@ -6,9 +6,11 @@ services: context: . dockerfile: Dockerfile ports: - - 5000:5000 + - "5000:5000" + volumes: + - .:/app front: image: nginx:latest ports: - - 80:80 \ No newline at end of file + - "80:80" diff --git a/ops/cours/requirements.txt b/ops/cours/requirements.txt index 2077213c..2ae9d047 100644 --- a/ops/cours/requirements.txt +++ b/ops/cours/requirements.txt @@ -1 +1,2 @@ -Flask \ No newline at end of file +Flask +pytest diff --git a/ops/cours/test.py b/ops/cours/test.py new file mode 100644 index 00000000..e69de29b diff --git a/ops/tp/api/Dockerfile b/ops/tp/api/Dockerfile index 85c1eddb..15c1ab32 100644 --- a/ops/tp/api/Dockerfile +++ b/ops/tp/api/Dockerfile @@ -5,4 +5,4 @@ WORKDIR /app COPY . . RUN pip3 install -r requirements.txt -CMD [ "python3", "-m" , "flask", "--debug", "run", "--host=0.0.0.0" ] +CMD [ "python3", "-m" , "flask", "--debug", "run", "--host=0.0.0.0", "--port=5001" ] diff --git a/ops/tp/api/app.py b/ops/tp/api/app.py index 93b6a7bf..67dc567e 100644 --- a/ops/tp/api/app.py +++ b/ops/tp/api/app.py @@ -1,7 +1,8 @@ from flask import Flask + app = Flask(__name__) -@app.route('/') -def hello_world(): - return 'Hello from api!' +@app.route("/") +def hello_world(): + return "Hello from api!" diff --git a/ops/tp/docker-compose.yml b/ops/tp/docker-compose.yml index 268cd3b4..1838aa7c 100644 --- a/ops/tp/docker-compose.yml +++ b/ops/tp/docker-compose.yml @@ -6,7 +6,7 @@ services: context: api dockerfile: Dockerfile ports: - - 5000:5000 + - "5001:5001" volumes: - ./api:/app @@ -14,3 +14,7 @@ services: build: context: front dockerfile: Dockerfile + ports: + - "8080:8080" + volumes: + - ./front:/app diff --git a/ops/tp/front/app.py b/ops/tp/front/app.py index 9e0a3e8e..7e5d45b8 100644 --- a/ops/tp/front/app.py +++ b/ops/tp/front/app.py @@ -3,13 +3,16 @@ app = Flask(__name__) -# @app.context_processor -# def utility_processor(): -# def request_api(url=API_URL): -# response = requests.get(f"http://{url}").text -# return response -# return dict(request_api=request_api) - -@app.route('/') +API_URL = "api:5001" + +@app.context_processor +def utility_processor(): + def request_api(url=API_URL): + response = requests.get(f"http://{url}").text + return response + return dict(request_api=request_api) + + +@app.route("/") def hello(): - return render_template('./index.html') \ No newline at end of file + return render_template("./index.html") diff --git a/ops/tp/front/templates/index.html b/ops/tp/front/templates/index.html index a956dc04..d765df15 100644 --- a/ops/tp/front/templates/index.html +++ b/ops/tp/front/templates/index.html @@ -7,5 +7,6 @@

Hello from front!

+

API response: {{ request_api() }}

diff --git a/projet/Dockerfile b/projet/Dockerfile deleted file mode 100644 index ee012d62..00000000 --- a/projet/Dockerfile +++ /dev/null @@ -1,7 +0,0 @@ -FROM tiangolo/uvicorn-gunicorn-fastapi:python3.10 - -ADD requirements.txt . - -RUN pip install -r requirements.txt - -COPY ./app /app/app diff --git a/projet/app/routers/__init__.py b/projet/app/routers/__init__.py deleted file mode 100644 index 5dab8b48..00000000 --- a/projet/app/routers/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from .posts import router as PostRouter -from .health import router as HealthRouter \ No newline at end of file diff --git a/projet/docker-compose.yml b/projet/docker-compose.yml deleted file mode 100644 index 214d3eeb..00000000 --- a/projet/docker-compose.yml +++ /dev/null @@ -1,25 +0,0 @@ -version: '3.4' - -networks: - default: - driver: bridge -services: - api: - build: . - networks: - - default - volumes: - - ./app/:/app/app - command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "5000", "--reload"] - ports: - - "5000:5000" - env_file: - - .env - - db: - image: postgres - restart: always - env_file: - - .env - ports: - - "5432:5432" \ No newline at end of file diff --git a/test/locust/locustfile.py b/test/locust/locustfile.py index f0e34b7f..3249beb0 100644 --- a/test/locust/locustfile.py +++ b/test/locust/locustfile.py @@ -1,15 +1,15 @@ from locust import HttpUser, TaskSet, task, between + class TaskPredict(TaskSet): @task def predict(self): request_body = {"data": "test"} - self.client.post('/predict', json=request_body) - + self.client.post("/predict", json=request_body) class TaskLoadTest(HttpUser): tasks = [TaskPredict] - host = 'http://127.0.0.1' + host = "http://127.0.0.1" stop_timeout = 20 - wait_time = between(1, 5) \ No newline at end of file + wait_time = between(1, 5) diff --git a/projet/.env b/unit_tests/cours/.env similarity index 100% rename from projet/.env rename to unit_tests/cours/.env diff --git a/unit_tests/cours/Dockerfile b/unit_tests/cours/Dockerfile new file mode 100644 index 00000000..4be2d58f --- /dev/null +++ b/unit_tests/cours/Dockerfile @@ -0,0 +1,7 @@ +FROM tiangolo/uvicorn-gunicorn-fastapi:python3.7 + +ADD requirements.txt . + +RUN pip install -r requirements.txt + +COPY ./app /app/app \ No newline at end of file diff --git a/unit_tests/cours/README.md b/unit_tests/cours/README.md new file mode 100644 index 00000000..044114b9 --- /dev/null +++ b/unit_tests/cours/README.md @@ -0,0 +1,77 @@ +# Tests unitaires + +## Pourquoi écrire des tests unitaires ? + +Quand on commence à écrire du code, généralement, il fonctionne très bien. On l'a testé nous-mêmes assez facilement et on ne rencontre aucun bug. +Mais, au fur et à mesure que le code évolue, il devient de plus en plus difficile de tout tester "à la main". +Lorsque notre application s'aggrandit, certains bout de code vont etre partagés entre des certaines, classes, fonctions, point d'API, etc. +Si on effectue une modification dans un de ces bout de code, on ne peut pas être sûr que cela n'aura pas d'impact sur le reste de l'application. +Les tests unitaires permettent de vérifier que le code fonctionne toujours correctement, même après des modifications. + +## Qu'est-ce qu'un test unitaire ? + +Un test unitaire est un morceau de code qui teste une autre partie de code. +Il est écrit pour vérifier que le code fonctionne correctement. En général, il s'agit de tester une fonction ou une méthode individuelle pour s'assurer qu'elle produit le résultat attendu dans des conditions spécifiques. +L'objectif est de : +- s'assurer que chaque unité de code fonctionne comme prévu. +- détecter les erreurs et les bugs dans le code avant qu'ils n'apparaissent en situation réelle. +- faciliter la maintenance du code en permettant de vérifier que les modifications apportées n'ont pas d'impact sur le reste de l'application (non-regression). +- documenter le code en fournissant des exemples d'utilisation. + +## Comment écrire un test unitaire ? + +Pour écrire un test unitaire, il faut : +- définir un cas de test : c'est une situation particulière dans laquelle on va tester le code. +- exécuter le code à tester avec les paramètres du cas de test. +- vérifier que le résultat obtenu est celui attendu. +- si le résultat obtenu est différent de celui attendu, le test échoue. + +Un test unitaire doit pouvoir être exécuté de manière automatique, sans intervention humaine. +Il doit être reproductible, c'est-à-dire qu'il doit donner le même résultat à chaque exécution (déterministe). +Il doit être indépendant des autres tests, c'est-à-dire qu'il ne doit pas dépendre du résultat d'un autre test pour fonctionner. +Enfin, un test unitaire doit être rapide à exécuter. Lorsque notre application grandit, le nombre de tests unitaires va augmenter. +Il est donc important que ces tests s'exécutent rapidement pour ne pas ralentir le développement. + +## Exemple de test unitaire + +Voici un exemple de test unitaire: +Imaginons que j'ai une fonction `addition` qui prend deux paramètres `a` et `b` et qui retourne la somme de ces deux paramètres. + +```python + +def addition(a: int, b: int) -> int: + return a + b + +``` + +Un test unitaire pour cette fonction pourrait ressembler à ceci: + +```python +def test_addition(): + assert addition(1, 2) == 3 +``` + +Ici, mon cas de test est l'addition de deux entiers positifs. +Je veux pouvoir aussi m'assurer que l'addition de deux entiers négatifs fonctionne correctement. +Dans ce cas j'ajouterai un deuxième test: + +```python +def test_addition_negative_number(): + assert addition(-1, -2) == -3 +``` + +## Frameworks de tests unitaires + +Il existe de nombreux frameworks de tests unitaires pour de nombreux langages de programmation. +En Python, le framework de test unitaire le plus utilisé est `unittest`. +`unittest` est un module de la bibliothèque standard de Python qui permet d'écrire des tests unitaires. +Il fournit des classes et des méthodes pour créer des cas de test, exécuter les tests et vérifier les résultats. + +Il existe également d'autres frameworks de tests unitaires pour Python, tels que `pytest`, `nose`, `doctest`, etc. + +### Pytest + +`pytest` est un framework de test unitaire pour Python qui est plus simple et plus flexible que `unittest`. +Il permet d'écrire des tests de manière plus concise et plus lisible. +Il fournit également des fonctionnalités supplémentaires telles que la découverte automatique des tests, la paramétrisation des tests, les fixtures, etc. + diff --git a/unit_tests/cours/app/__init__.py b/unit_tests/cours/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/unit_tests/cours/app/dependencies/__init__.py b/unit_tests/cours/app/dependencies/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/projet/app/main.py b/unit_tests/cours/app/main.py similarity index 70% rename from projet/app/main.py rename to unit_tests/cours/app/main.py index bde73790..b099aa77 100644 --- a/projet/app/main.py +++ b/unit_tests/cours/app/main.py @@ -1,12 +1,14 @@ from typing import Optional -from fastapi import FastAPI +from fastapi import FastAPI, Header from fastapi.middleware.cors import CORSMiddleware import json import asyncio import os + +from starlette.requests import Request from starlette_exporter import PrometheusMiddleware, handle_metrics -from app.models import BaseSQL, engine -from app import routers +from models import BaseSQL, engine +import routers app = FastAPI( title="My title", @@ -39,6 +41,15 @@ async def startup_event(): BaseSQL.metadata.create_all(bind=engine) +@app.get("/api/headers") +def read_hello( + request: Request, + x_userinfo: Optional[str] = Header(None, convert_underscores=True), +): + print(request["headers"]) + return {"Headers": json.loads(base64.b64decode(x_userinfo))} + + @app.get("/") def read_root(): return {"Hello": "World"} diff --git a/projet/app/models/__init__.py b/unit_tests/cours/app/models/__init__.py similarity index 63% rename from projet/app/models/__init__.py rename to unit_tests/cours/app/models/__init__.py index 1c268fd3..5e154a90 100644 --- a/projet/app/models/__init__.py +++ b/unit_tests/cours/app/models/__init__.py @@ -1,3 +1,3 @@ from .post import Post from .database import BaseSQL -from .db import get_db, engine \ No newline at end of file +from .db import get_db, engine diff --git a/unit_tests/cours/app/models/database.py b/unit_tests/cours/app/models/database.py new file mode 100644 index 00000000..e1cb9dc3 --- /dev/null +++ b/unit_tests/cours/app/models/database.py @@ -0,0 +1,20 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +import os + + +POSTGRES_USER = os.environ.get("POSTGRES_USER") +POSTGRES_PASSWORD = os.environ.get("POSTGRES_PASSWORD") +POSTGRES_DB = os.environ.get("POSTGRES_DB") + + +SQLALCHEMY_DATABASE_URL = ( + f"postgresql://{POSTGRES_USER}:{POSTGRES_PASSWORD}@postgres/{POSTGRES_DB}" +) +print(SQLALCHEMY_DATABASE_URL) + +engine = create_engine(SQLALCHEMY_DATABASE_URL) +SessionLocal = sessionmaker(autocommit=False, autoflush=True, bind=engine) + +BaseSQL = declarative_base() diff --git a/projet/app/models/db.py b/unit_tests/cours/app/models/db.py similarity index 87% rename from projet/app/models/db.py rename to unit_tests/cours/app/models/db.py index 7d907548..7b6d13d2 100644 --- a/projet/app/models/db.py +++ b/unit_tests/cours/app/models/db.py @@ -6,4 +6,4 @@ def get_db(): db = SessionLocal() yield db finally: - db.close() \ No newline at end of file + db.close() diff --git a/projet/app/models/post.py b/unit_tests/cours/app/models/post.py similarity index 89% rename from projet/app/models/post.py rename to unit_tests/cours/app/models/post.py index 9eb16398..f098451b 100644 --- a/projet/app/models/post.py +++ b/unit_tests/cours/app/models/post.py @@ -1,6 +1,6 @@ from sqlalchemy import Column, String, DateTime from sqlalchemy.dialects.postgresql import UUID -from app.models.database import BaseSQL +from .database import BaseSQL class Post(BaseSQL): diff --git a/unit_tests/cours/app/routers/__init__.py b/unit_tests/cours/app/routers/__init__.py new file mode 100644 index 00000000..93ea2d4d --- /dev/null +++ b/unit_tests/cours/app/routers/__init__.py @@ -0,0 +1,2 @@ +from .posts import router as PostRouter +from .health import router as HealthRouter diff --git a/unit_tests/cours/app/routers/health.py b/unit_tests/cours/app/routers/health.py new file mode 100644 index 00000000..6cfd7c96 --- /dev/null +++ b/unit_tests/cours/app/routers/health.py @@ -0,0 +1,29 @@ +from fastapi import FastAPI, Header, Request, APIRouter +from typing import Optional +import base64 +import json + +router = APIRouter() + + +@router.get("/") +def read_root(): + return {"Hello": "World"} + + +@router.get("/api") +def read_hello(): + return {"Hello": "Api"} + + +@router.get("/health") +def read_root(): + return {"message": "Api is running fine!"} + + +@router.get("/api/headers") +def read_hello( + request: Request, x_userinfo: Optional[str] = Header(None, convert_underscores=True) +): + print(request["headers"]) + return {"Headers": json.loads(base64.b64decode(x_userinfo))} diff --git a/projet/app/routers/posts.py b/unit_tests/cours/app/routers/posts.py similarity index 85% rename from projet/app/routers/posts.py rename to unit_tests/cours/app/routers/posts.py index 510edc1f..81dfd13c 100644 --- a/projet/app/routers/posts.py +++ b/unit_tests/cours/app/routers/posts.py @@ -1,8 +1,9 @@ from fastapi import APIRouter, Depends -from app.services import posts as posts_service -from app import schemas, models from sqlalchemy.orm import Session +import schemas +import models + router = APIRouter(prefix="/posts") @@ -25,8 +26,9 @@ async def get_posts_by_title(title: str = None, db: Session = Depends(models.get @router.put("/{post_id}", tags=["posts"]) -async def update_post_by_id(post_id: str, post: schemas.Post, - db: Session = Depends(models.get_db)): +async def update_post_by_id( + post_id: str, post: schemas.Post, db: Session = Depends(models.get_db) +): return posts_service.update_post(post_id=post_id, db=db, post=post) diff --git a/unit_tests/cours/app/schemas/__init__.py b/unit_tests/cours/app/schemas/__init__.py new file mode 100644 index 00000000..a406556b --- /dev/null +++ b/unit_tests/cours/app/schemas/__init__.py @@ -0,0 +1 @@ +from .posts import Post diff --git a/projet/app/schemas/posts.py b/unit_tests/cours/app/schemas/posts.py similarity index 100% rename from projet/app/schemas/posts.py rename to unit_tests/cours/app/schemas/posts.py diff --git a/unit_tests/cours/app/services/__init__.py b/unit_tests/cours/app/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/projet/app/services/posts.py b/unit_tests/cours/app/services/posts.py similarity index 98% rename from projet/app/services/posts.py rename to unit_tests/cours/app/services/posts.py index 2326d513..984aa61b 100644 --- a/projet/app/services/posts.py +++ b/unit_tests/cours/app/services/posts.py @@ -3,7 +3,7 @@ from sqlalchemy.orm import Session from fastapi import HTTPException from datetime import datetime -from app import models, schemas +from .. import models, schemas def get_all_posts(db: Session, skip: int = 0, limit: int = 10) -> List[models.Post]: @@ -16,7 +16,7 @@ def get_all_posts(db: Session, skip: int = 0, limit: int = 10) -> List[models.Po def get_post_by_id(post_id: str, db: Session) -> models.Post: record = db.query(models.Post).filter(models.Post.id == post_id).first() if not record: - raise HTTPException(status_code=404, detail="Not Found") + raise HTTPException(status_code=404, detail="Not Found") record.id = str(record.id) return record diff --git a/unit_tests/cours/app/tests/__init__.py b/unit_tests/cours/app/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/unit_tests/cours/app/tests/conftest.py b/unit_tests/cours/app/tests/conftest.py new file mode 100644 index 00000000..67477caf --- /dev/null +++ b/unit_tests/cours/app/tests/conftest.py @@ -0,0 +1,15 @@ +# test_main.py +from fastapi.testclient import TestClient +from main import app + +client = TestClient(app) + +def test_read_root(): + response = client.get("/") + assert response.status_code == 200 + assert response.json() == {"Hello": "World"} + +def test_read_item(): + response = client.get("/items/42", params={"q": "example"}) + assert response.status_code == 200 + assert response.json() == {"item_id": 42, "q": "example"} diff --git a/unit_tests/cours/app/tests/test_api.py b/unit_tests/cours/app/tests/test_api.py new file mode 100644 index 00000000..e69de29b diff --git a/unit_tests/cours/app/tests/test_posts_api.py b/unit_tests/cours/app/tests/test_posts_api.py new file mode 100644 index 00000000..dce929c7 --- /dev/null +++ b/unit_tests/cours/app/tests/test_posts_api.py @@ -0,0 +1,63 @@ +from typing import Callable + +from starlette.testclient import TestClient + +import schemas +from models import Post + + +def test_get_posts(client: TestClient, db, post_factory: Callable[..., Post]): + first_post = post_factory() + response = client.get("/posts/") + + actual = db.query(Post).all() + + assert response.status_code == 200 + assert len(actual) == 1 + + actual_post = actual[0] + + assert first_post.id == actual_post.id + assert first_post.title == actual_post.title + assert first_post.description == actual_post.description + +def test_create_posts(client: TestClient, db): + first_post = schemas.Post( + title="title", + description="description" + ) + response = client.post("/posts/", json=first_post.dict()) + + actual = db.query(Post).all() + + assert response.status_code == 200 + assert len(actual) == 1 + + actual_post = actual[0] + + assert actual_post.id + assert first_post.title == actual_post.title + assert first_post.description == actual_post.description + + db.delete(actual_post) + +def test_update_posts(client: TestClient, db, post_factory: Callable[..., Post]): + first_post = post_factory() + change_title_payload = schemas.Post( + title="new title" + ) + + response = client.patch("/posts/", json=change_title_payload.dict()) + + actual = db.query(Post).all() + + assert response.status_code == 200 + assert len(actual) == 1 + + actual_post = actual[0] + + assert actual_post.id + assert first_post.title == actual_post.title + assert first_post.description == actual_post.description + + db.delete(actual_post) diff --git a/unit_tests/cours/docker-compose.yml b/unit_tests/cours/docker-compose.yml new file mode 100644 index 00000000..5508056e --- /dev/null +++ b/unit_tests/cours/docker-compose.yml @@ -0,0 +1,35 @@ +version: '3.4' + +networks: + default: + driver: bridge +services: + api: + build: . + networks: + - default + volumes: + - ./app/:/app + command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "5000", "--reload"] + ports: + - "5000:5000" + env_file: + - .env + + db: + image: postgres + restart: always + env_file: + - .env + ports: + - "5432:5432" + + test: + build: . + networks: + - default + volumes: + - ./app/:/app + command: [ "pytest", "tests"] + env_file: + - .env diff --git a/unit_tests/cours/requirements.txt b/unit_tests/cours/requirements.txt new file mode 100644 index 00000000..2ed39146 --- /dev/null +++ b/unit_tests/cours/requirements.txt @@ -0,0 +1,7 @@ +starlette_exporter +sqlalchemy +pydantic +psycopg2 +python-slugify +pytest +fastapi \ No newline at end of file