diff --git a/LICENSE b/LICENSE index 79821fe..34da5af 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2021 Moesif, Inc +Copyright (c) 2024 Moesif, Inc Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index 47cf449..0a718f1 100644 --- a/README.md +++ b/README.md @@ -13,12 +13,11 @@ and send them to [Moesif](https://www.moesif.com) for API analytics and monitori ## Overview -This middleware allows you to integrate Moesif's API analytics and -API monetization features with minimal configuration into APIs that are built on Python ASGI-based (Asynchronous Server Gateway Interface) frameworks. +This middleware allows you to easily integrate Moesif's API analytics and +API monetization service with APIs built on Python ASGI-based (Asynchronous Server Gateway Interface) frameworks including [FastAPI](https://fastapi.tiangolo.com/) and [Starlette](https://www.starlette.io/). [ASGI](https://asgi.readthedocs.io/en/latest/) -is a spiritual successor to WSGI (Web Server Gateway Interface). ASGI provides a standard interface between async-capable Python web servers, frameworks, and applications. Many Python Frameworks -are built on top of ASGI, such as [FastAPI](https://fastapi.tiangolo.com/). +is a spiritual successor to WSGI (Web Server Gateway Interface) enabling async-capable applications. ## Prerequisites Before using this middleware, make sure you have the following: @@ -725,110 +724,43 @@ See the example FastAPI app in the `examples/` folder of this repository that us Here's another sample FastAPI app: ```python -# Your custom code that returns a user id string -custom_user = "12345" - -async def custom_identify_user(): - return custom_user - -# identify user using async mode -async def identify_user(request, response): - user = await custom_identify_user() - return user - # identify user not using async mode def identify_user(request, response): - return custom_user - -# Your custom code that returns a company id string -custom_company = "67890" - -async def custom_identify_company(): - return custom_company - -# identify company using async mode -async def identify_company(request, response): - company = await custom_identify_company() - return company + return "12345" # identify company not using async mode def identify_company(request, response): - return custom_company - -custom_session_token = "XXXXXXXXXXXXXX" - -async def custom_get_token(): - # If you don't want to use the standard ASGI session token, - # add your custom code that returns a string for session/API token - return custom_session_token - -# get session token using async mode -async def get_token(request, response): - result = await custom_get_token() - return result - -# get session token not using async mode -def get_token(request, response): - return custom_session_token - -custom_metadata = { - 'datacenter': 'westus', - 'deployment_version': 'v1.2.3', -} - -async def custom_get_metadata(): - return custom_metadata - -# get metadata using async mode -async def get_metadata(request, response): - result = await custom_get_metadata() - return result + return "67890" # get metadata not using async mode def get_metadata(request, response): - return custom_metadata - -skip_route = "health/probe" - -async def custom_should_skip(request): - # Your custom code that returns `True` to skip logging - return skip_route in request.url._url - -# should skip check using async mode -async def should_skip(request, response): - result = await custom_should_skip(request) - return result + return { + 'datacenter': 'westus', + 'deployment_version': 'v1.2.3', + } # should skip check not using async mode def should_skip(request, response): - return skip_route in request.url._url + return "health/probe" in request.url._url -def custom_mask_event(eventmodel): +# mask event not using async mode +def mask_event(eventmodel): # Your custom code to change or remove any sensitive fields if 'password' in eventmodel.response.body: eventmodel.response.body['password'] = None return eventmodel -# mask event using async mode -async def mask_event(eventmodel): - return custom_mask_event(eventmodel) - -# mask event not using async mode -def mask_event(eventmodel): - return custom_mask_event(eventmodel) - moesif_settings = { 'APPLICATION_ID': 'YOUR_MOESIF_APPLICATION_ID', - 'DEBUG': False, 'LOG_BODY': True, + 'DEBUG': False, 'IDENTIFY_USER': identify_user, 'IDENTIFY_COMPANY': identify_company, - 'GET_SESSION_TOKEN': get_token, + 'GET_METADATA': get_metadata, 'SKIP': should_skip, 'MASK_EVENT_MODEL': mask_event, - 'GET_METADATA': get_metadata, - 'CAPTURE_OUTGOING_REQUESTS': False + 'CAPTURE_OUTGOING_REQUESTS': False, } app = FastAPI() @@ -842,10 +774,16 @@ You can use OAuth2 in your FastAPI app with this middleware. For more informatio Moesif has validated this middleware against the following frameworks and framework versions: -| | Framework Version | +| Framework | Version | |---------|-------------------| -| fastapi | > 0.51.0 - 0.78.0 | -| fastapi | 0.108.0 | +| fastapi | > 0.51.0 | +| fastapi | > 0.78.0 | +| fastapi | > 0.108.0 | +| fastapi | > 0.108.0 | + +## Examples +- [View example app with FastAPI](https://github.com/Moesif/moesifasgi/tree/master/examples/fastapi). + ## Explore Other Integrations diff --git a/examples/fastapi/main.py b/examples/fastapi/main.py index 99f12c2..31a5bca 100644 --- a/examples/fastapi/main.py +++ b/examples/fastapi/main.py @@ -1,227 +1,48 @@ from moesifasgi import MoesifMiddleware from typing import Optional -import uvicorn - from datetime import datetime, timedelta from typing import Union - -from fastapi import Depends, FastAPI, HTTPException, status -from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm -from jose import JWTError, jwt -from passlib.context import CryptContext +from fastapi import FastAPI from pydantic import BaseModel -# to get a string like this run: -# openssl rand -hex 32 -SECRET_KEY = "09d25e094faa6ca2556c818166b7a9563b93f7099f6f0f4caa6cf63b88e8d3e7" -ALGORITHM = "HS256" -ACCESS_TOKEN_EXPIRE_MINUTES = 30 - -class Token(BaseModel): - access_token: str - token_type: str - - -class TokenData(BaseModel): - username: Union[str, None] = None - - -class User(BaseModel): - username: str - email: Union[str, None] = None - full_name: Union[str, None] = None - disabled: Union[bool, None] = None - - -class UserInDB(User): - hashed_password: str - - -pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") - -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") - - -def verify_password(plain_password, hashed_password): - return pwd_context.verify(plain_password, hashed_password) - - -# hash password -def get_password_hash(password): - hashed = pwd_context.hash(password) - return hashed - - -def get_user(db, username: str): - if username in db: - user_dict = db[username] - return UserInDB(**user_dict) - - -def authenticate_user(fake_db, username: str, password: str): - user = get_user(fake_db, username) - if not user: - return False - if not verify_password(password, user.hashed_password): - return False - return user - - -# Create a random secret key that will be used to sign the JWT tokens -def create_access_token(data: dict, expires_delta: Union[timedelta, None] = None): - to_encode = data.copy() - if expires_delta: - expire = datetime.utcnow() + expires_delta - else: - expire = datetime.utcnow() + timedelta(minutes=15) - to_encode.update({"exp": expire}) - encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) - return encoded_jwt - - -fake_users_db = { - "johndoe": { - "username": "johndoe", - "full_name": "John Doe", - "email": "johndoe@example.com", - "hashed_password": get_password_hash("johndoe_password"), - "disabled": False, - } -} - - -# Decode the received token, verify it, and return the current user -# If the token is invalid, return an HTTP error right away. -async def get_current_user(token: str = Depends(oauth2_scheme)): - credentials_exception = HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials", - headers={"WWW-Authenticate": "Bearer"}, - ) - try: - payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) - username: str = payload.get("sub") - if username is None: - raise credentials_exception - token_data = TokenData(username=username) - except JWTError: - raise credentials_exception - user = get_user(fake_users_db, username=token_data.username) - if user is None: - raise credentials_exception - - return user - - -async def get_current_active_user(current_user: User = Depends(get_current_user)): - if current_user.disabled: - raise HTTPException(status_code=400, detail="Inactive user") - # current_user = 1 # TRY 2 - return current_user - - -custom_user_id = "12345" - - -async def custom_identify_user_id(): - return custom_user_id - - -# identify user using async mode -async def identify_user(request, response): - user_id = await custom_identify_user_id() - return user_id - # identify user not using async mode def identify_user(request, response): - return custom_user_id - - -# Your custom code that returns a company id string -custom_company_id = "67890" - -async def custom_identify_company_id(): - return custom_company_id - -# identify company using async mode -async def identify_company(request, response): - company = await custom_identify_company_id() - return company + # Implement your custom logic which reads user id from your request context + # For example, you can extract the claim from a JWT in the Authorization header + return "12345" # identify company not using async mode def identify_company(request, response): - return custom_company_id - -# If you don't want to use the standard ASGI session token, -# add your custom code that returns a string for session/API token -custom_session_token = "XXXXXXXXXXXXXX" - -async def custom_get_token(): - return custom_session_token - -# get session token using async mode -async def get_token(request, response): - result = await custom_get_token() - return result - -# get session token not using async mode -def get_token(request, response): - return custom_session_token - -custom_metadata = { - 'datacenter': 'westus', - 'deployment_version': 'v1.2.3', -} - -async def custom_get_metadata(): - return custom_metadata - -# get metadata using async mode -async def get_metadata(request, response): - result = await custom_get_metadata() - return result + # Implement your custom logic which reads company id from your request context + # For example, you can extract the claim from a JWT in the Authorization header + return "67890" # get metadata not using async mode def get_metadata(request, response): - return custom_metadata - -# Your custom code that returns true to skip logging -skip_route = "health/probe" - -async def custom_should_skip(request): - return skip_route in request.url._url - -# should skip check using async mode -async def should_skip(request, response): - result = await custom_should_skip(request) - return result + return { + 'datacenter': 'westus', + 'deployment_version': 'v1.2.3', + } # should skip check not using async mode def should_skip(request, response): - return skip_route in request.url._url + # Implement your custom logic to skip logging specific API calls to Moesif. + return "health/probe" in request.url._url -def custom_mask_event(eventmodel): +# mask event not using async mode +def mask_event(eventmodel): # Your custom code to change or remove any sensitive fields if 'password' in eventmodel.response.body: eventmodel.response.body['password'] = None return eventmodel -# mask event using async mode -async def mask_event(eventmodel): - return custom_mask_event(eventmodel) - -# mask event not using async mode -def mask_event(eventmodel): - return custom_mask_event(eventmodel) - moesif_settings = { 'APPLICATION_ID': 'Your Moesif Application Id', 'LOG_BODY': True, 'DEBUG': False, 'IDENTIFY_USER': identify_user, 'IDENTIFY_COMPANY': identify_company, - 'GET_SESSION_TOKEN': get_token, 'GET_METADATA': get_metadata, 'SKIP': should_skip, 'MASK_EVENT_MODEL': mask_event, @@ -243,33 +64,10 @@ class Item(BaseModel): async def create_item(item: Item): return item -@app.get("/v2") +@app.get("/hello") async def read_main(): return {"message": "Hello World"} -@app.post("/token", response_model=Token) -async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()): - user = authenticate_user(fake_users_db, form_data.username, form_data.password) - if not user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Incorrect username or password", - headers={"WWW-Authenticate": "Bearer"}, - ) - access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) - access_token = create_access_token( - data={"sub": user.username}, expires_delta=access_token_expires - ) - return {"access_token": access_token, "token_type": "bearer"} - -@app.get("/users/me/", response_model=User) -async def read_users_me(current_user: User = Depends(get_current_active_user)): - return current_user - -@app.get("/users/me/items/") -async def read_own_items(current_user: User = Depends(get_current_active_user)): - return [{"item_id": "Foo", "owner": current_user.username}] - # in case you need run with debugger, those lines are needed # if __name__ == "__main__": # uvicorn.run(app, host="127.0.0.1", port=8000) \ No newline at end of file diff --git a/moesifasgi/middleware.py b/moesifasgi/middleware.py index 2d44f5d..a8389d7 100644 --- a/moesifasgi/middleware.py +++ b/moesifasgi/middleware.py @@ -27,11 +27,21 @@ class MoesifMiddleware(BaseHTTPMiddleware): """ASGI Middleware for recording of request-response""" - def __init__(self, settings=None, *args, **kwargs): - super().__init__(*args, **kwargs) + def __init__(self, app=None, settings=None, *args, **kwargs): + super().__init__(app, *args, **kwargs) if settings is None: - raise Exception('Moesif Application ID is required in settings') + raise Exception('settings is not set. Ensure MoesifMiddleware is initialized with settings') + return + + if not isinstance(settings, dict): + raise Exception('settings is not a dictionary. Ensure MoesifMiddleware is initialized with a settings dictionary') + return + + if 'APPLICATION_ID' not in settings: + raise Exception('APPLICATION_ID was not defined in settings. APPLICATION_ID is a required field') + return + self.settings = settings self.DEBUG = self.settings.get('DEBUG', False) diff --git a/setup.py b/setup.py index 9611408..e97d056 100644 --- a/setup.py +++ b/setup.py @@ -28,7 +28,7 @@ # Versions should comply with PEP440. For a discussion on single-sourcing # the version across setup.py and the project code, see # https://packaging.python.org/en/latest/single_source_version.html - version='1.0.10', + version='1.1.0', description='Moesif Middleware for Python ASGI based platforms (FastAPI & Others)', long_description=long_description,