Tutorial
This is a more extensive run-through of the package functionality. In this guide we'll set up an reverse-proxy with the following requirements:
- Proxy requests to two target servers
- Different authentication to different servers
- The application should also be behind its own authentication
- Support compressing response for the user
Already bored?
Don't feel like listening to me yap? You can jump to the prepared example found here
Scenario
Let's take the scenario of an ERP platform. It has multiple partners which manage their business through it. An ERP system is complex enough for it to need multiple different services, rather than a large monolithic service. So the platform likely needs a reverse-proxy in front of its services to handle the partner authentication and serve all of its content from a single point of entry.
For our scenario, we'll look at two services an ERP would need to provide:
- Content storage
- Transactions
These will be the target services we will proxy with our reverse-proxy.
Target servers
The prerequisite to our proxy is obviously something to proxy the requests to. Not to lose time on writing these, since it's not the point of the exercise, you can find the codes for the two example servers here.
Take some time to inspect them, see what endpoints they expose, and how they work. TL;DR: they have some CRUD endpoints expecting
Environment
Let's initialize the environment first and install the package.
In this guide we'll use uv for managing
our dependencies. The following commands will create an environment and install
the package inside it.
Tooling
You'll see me using http commands in the shell. I'm using
httpie for testing but you can use
curl or whatever tool you feel comfortable with
Folder structure
Let's prepare our folder structure
proxy/
├── src/
│ ├── middlewares/ # The shared proxy middlewares
│ ├── routers/ # The routers for our target servers
│ ├── utils/ # Utility functionality we might need
│ └── app.py # Main application entry point
├── pyproject.toml # Project dependencies
└── uv.lock # Locked dependencies
Having prepared our structure we're ready to start writing our app.
The AIOHTTP app
Let's start by creating our AIOHTTP application.
Create a new file src/app.py with the following content:
from aiohttp import web
def create_app() -> web.Application:
"""Create and configure the application"""
app = web.Application()
return app
if __name__ == "__main__":
app = create_app()
web.run_app(app, host="localhost", port=8080)
We did no special magic we just configured our application. You can try running it with:
Authentication
Let's add some authentication to it. In the src/utils folder create a file
called auth.py.
from datetime import datetime, timedelta, timezone
import jwt
from aiohttp import web
JWT_SECRET = "your-super-secret-jwt-key" # (1)
JWT_ALGORITHM = "HS256"
JWT_EXP_DELTA_SECONDS = 3600 # 1hr
USERS = { # (2)!
"WAL001": {
"password": "wal001",
"role": "user",
},
}
def create_token(user_id: str) -> str: # (3)!
"""Create a new JWT token for the user"""
payload = {
"user_id": user_id,
"exp": datetime.now(tz=timezone.utc) + timedelta(seconds=JWT_EXP_DELTA_SECONDS),
"iat": datetime.now(tz=timezone.utc),
}
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
def verify_token(token: str) -> dict: # (4)!
"""Verify the JWT token and return the payload"""
try:
payload = jwt.decode(
token, JWT_SECRET, algorithms=[JWT_ALGORITHM], verify_exp=True
)
return payload
except jwt.ExpiredSignatureError:
raise web.HTTPUnauthorized(reason="Token has expired")
except jwt.InvalidTokenError:
raise web.HTTPUnauthorized(reason="Invalid token")
openssl rand -hex 32- In the real world please don't use a dictionary
- Simple function which takes the user_id and creates a token with the user_id.
- Function that tries to decode the token and verify that it isn't expired
Our file has some simple functionality to generate and verify a generated token.
Let's put some of it to use in our app.py file.
from aiohttp import web
from src.utils.auth import USERS, create_token
async def login(request):
"""Handle user login"""
data = await request.json()
username = data.get("username")
password = data.get("password")
if not username or not password:
raise web.HTTPBadRequest(reason="Username and password are required")
user = USERS.get(username)
if not user or user["password"] != password:
raise web.HTTPUnauthorized(reason="Invalid username or password")
token = create_token(username)
return web.json_response(
{"token": token, "user": {"username": username, "role": user["role"]}}
)
def create_app() -> web.Application:
app = web.Application()
app.router.add_post("/login", login)
return app
#...
Great! Our app now has authentication. You can run the server and test it:
You can store the token you get as we'll need it later.
Transactions Handler
Great now that authentication is out of the way, we can start adding our proxy handlers. Let's start with the transactions handler.
from typing import Any, AsyncGenerator
from aiorp import HTTPProxyHandler, ProxyContext
from yarl import URL
TRANSACTIONS_API_KEY = "transactions-secret-key-123" # (1)!
TRANSACTIONS_URL = URL("http://localhost:8001")
transactions_ctx = ProxyContext(url=TRANSACTIONS_URL) # (2)!
transactions_handler = HTTPProxyHandler(context=transactions_ctx) # (3)!
@transactions_handler.proxy # (4)!
async def transactions_auth(ctx: ProxyContext) -> AsyncGenerator[None, Any]:
"""Add transactions API key to requests"""
ctx.request.headers["X-API-Key"] = TRANSACTIONS_API_KEY
yield # (5)!
- This is our example API key for our transactions service
ProxyContextwill take care of setting up a session to the target service- The proxy handler is the brains, it will forward all of the requests to the target service. It also supports attaching middleware functions to execute before and after the proxy request.
- This decorator is used to register a proxy middleware function on our handler. The middleware function will do pre-request actions and post-request(response) actions.
- The code up to the yield will execute before the request,
everything afterwards will happen after the request is executed.
Within the function, one can use the
ProxyContextthat offers access to theProxyRequestandProxyResponseobjects.
With this setup now, we configured a handler to forward authenticated requests to the transactions service. We obviously still need to connect it to our app so let's do that now.
Import the transactions_handler, and then attach it to
the router below the last defined login route. Note that we need to leave the
path open to proxy all requests our service can accept.
Also we need to import the transactions_ctx and the configure_contexts,
and call the function with the app and context. This will assure proper session
handling.
# ...
configure_contexts(app, [transactions_ctx]) # (1)!
app.router.add_route(
"*", "/shops/{shop_id:[A-Za-z0-9]+}/transactions{tail:.*}", transactions_handler
)
# ...
- We need to configure the context to start sessions when we start the app and close them when the app is turned off
We are now ready to test the communication with the target service. Start both the proxy server and the target transactions server.
If you get a response with test transactions inside, it means we did everything correctly.
The inventory handler
The setup for the second service is the same, you can try doing it yourself, or just copy it from the example in the Github repository. You don't need it for the example, it's there for your practice and to demonstrate how to set up a proxy with multiple target servers.
Loading the user
More often than not, it might be useful to know which user is interacting with
our service . We have this information in the token already but we just
need to load it. Let's create a handler that will do just that.
In the src/middlewares directory create an auth.py file.
from typing import Any, AsyncGenerator
from aiohttp import web
from aiorp import ProxyContext
from src.utils.auth import verify_token
async def auth_middleware(ctx: ProxyContext) -> AsyncGenerator[None, Any]:
"""Middleware to handle authentication for proxy requests"""
auth_header = ctx.request.headers.get("Authorization") # (1)!
if not auth_header or not auth_header.startswith("Bearer "):
raise web.HTTPUnauthorized(reason="Missing or invalid Authorization header")
token = auth_header.split(" ")[1]
try:
payload = verify_token(token) # (2)!
if ctx.state is None:
ctx.state = {}
ctx.state["user"] = payload # (3)!
yield # (4)!
except web.HTTPUnauthorized as e:
raise e
except Exception as e:
raise web.HTTPUnauthorized(reason=str(e))
- Load the auth header from the incoming request (
in_req) - Attempt to verify the token using our utility function
- Add the user to the current proxy context state
- Give control back to the http handler
There we have it, an authentication proxy middleware that will store our user in the proxy context.
# ...
from src.middlewares.auth import auth_middleware
# ...
transactions_handler = HTTPProxyHandler(context=transactions_ctx)
transactions_handler.add_middleware(
ProxyMiddlewareDef(MiddlewarePhase.CLIENT_EDGE, auth_middleware)
) # (1)!
#...
- Add the middleware
CLIENT_EDGEso it executes as soon as possible in the request lifetime
And what's nice, is that it is reusable, so if you've
prepared the inventory service also, you can just plug it in there also
Rewriting the path
Sometimes we might want to have different endpoint paths at our proxy service,
compared to the endpoints on the target services. For example some of our
target services might serve the same common endpoints (e.g. /api/login).
For these cases we need to differ the services. With some service identifier
in the proxy endpoint.
In other cases, like the one we can see with our services here, we have
a common prefix that we can actually fill ourselves: /shops/{shop_id}/.
We can find the shop_id in the API key, and use it to build the path.
Securing even more access to resources of different users. Let's take
a look at how we can easily do this.
Add another file to the middlewares module called rewrite.py. Let's
define the functionality of rewriting:
from posixpath import join
from typing import Any, AsyncGenerator
from aiorp import ProxyContext
async def rewrite_shop_path(ctx: ProxyContext) -> AsyncGenerator[None, Any]:
user = ctx.state["user"]
new_path = join(f"/shops/{user["user_id"]}", ctx.request.url.path.lstrip("/")) # (1)!
ctx.request.url = ctx.request.url.with_path(new_path)
yield
- Prefix the path with the correct shop identifier. Don't forget to strip the
prefix slash from the path (second argument), or
joinwill consider it as absolute path and disregard all else
Having this prepared now, we can include it in our handlers the same way we did with the authorization middleware, for both services.
# ...
from src.middlewares.rewrite import rewrite_shop_path
# ...
transactions_handler = HTTPProxyHandler(context=transactions_ctx)
transactions_handler.add_middleware(
ProxyMiddlewareDef(MiddlewarePhase.PROXY, rewrite_shop_path)
) # (1)!
- You want to add it as
PROXYphase middleware, since we want it executed after the user is loaded (inCLIENT_EDGEphase)
Just keep in mind to set the middleware phase to PROXY.
Let's test it also!
Compressing the response
A common requirement is the possibility to add compression to responses to save
some on network traffic. Let' see how to do that with the proxy
middlewares.
import gzip
from typing import Any, AsyncGenerator
from aiohttp import web
from aiorp.context import ProxyContext
async def compression_middleware(ctx: ProxyContext) -> AsyncGenerator[None, Any]:
"""Middleware to compress responses before sending to client"""
yield
accept_encoding = ctx.request.in_req.headers.get("Accept-Encoding", "")
if "gzip" not in accept_encoding.lower():
return
if not ctx.response.web_response_set:
await ctx.response.set_response()
if ctx.response.web is web.StreamResponse:
return
content = ctx.response.web.body
compressed = gzip.compress(content)
new_response = web.Response(
body=compressed,
status=ctx.response.web.status,
headers=ctx.response.web.headers,
)
new_response.headers["Content-Encoding"] = "gzip"
new_response.headers["Content-Length"] = str(len(compressed))
ctx.response._web = new_response
Having written this we can add it in the same way we did previously to our handlers
# ...
transactions_handler.add_middleware(
ProxyMiddlewareDef(MiddlewarePhase.CLIENT_EDGE, compression_middleware) # (1)!
)
# ...
- This needs to happen right before we return the response to the client.
Let's test the compression now. The http tool sends an Accept-Encoding
header by default with gzip and deflate. If you are using curl,
just add the header Accept-Encoding: gzip.
Th-th-th-that's all folks!
That should give you a nice overview of the functionality of this package. If you are missing some more functionality, I recommend checking out the Advanced section as you might find some information there perhaps. Otherwise, prepare an Issue on Github with a request. (format for issue all still TBD)