Skip to content

Commit

Permalink
#33 Add HTTP and Websocket endpoints params validation (#34)
Browse files Browse the repository at this point in the history
  • Loading branch information
pantunes authored Jan 18, 2024
1 parent 3eb75aa commit cc106c3
Show file tree
Hide file tree
Showing 12 changed files with 198 additions and 90 deletions.
50 changes: 30 additions & 20 deletions exchange_radar/web/src/endpoints/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@
from exchange_radar.web.src.models import Feed
from exchange_radar.web.src.models import History as HistoryModel
from exchange_radar.web.src.models import Stats as StatsModel
from exchange_radar.web.src.serializers.decorators import validate
from exchange_radar.web.src.serializers.http import (
IndexParamsInputSerializer,
ParamsInputSerializer,
)
from exchange_radar.web.src.settings import base as settings
from exchange_radar.web.src.utils import get_exchanges

Expand All @@ -29,15 +34,15 @@ class IndexBase(HTTPEndpoint):
websocket_url = settings.TRADES_SOCKET_URL
template_name = "index.j2"

def get(self, request):
coin = request.path_params.get("coin", "BTC")
@validate(serializer=IndexParamsInputSerializer)
async def get(self, request, data: ParamsInputSerializer):
context = {
"request": request,
"coin": coin,
"http_trades_url": self.http_trades_url.format(coin=coin),
"http_stats_url": self.http_stats_url.format(coin=coin),
"websocket_url": self.websocket_url.format(coin=coin),
"exchanges": get_exchanges(coin=coin),
"coin": data.coin,
"http_trades_url": self.http_trades_url.format(coin=data.coin),
"http_stats_url": self.http_stats_url.format(coin=data.coin),
"websocket_url": self.websocket_url.format(coin=data.coin),
"exchanges": get_exchanges(coin=data.coin),
"max_rows": settings.REDIS_MAX_ROWS,
}
return templates.TemplateResponse(self.template_name, context=context)
Expand Down Expand Up @@ -67,16 +72,16 @@ class FeedBase(HTTPEndpoint):
def __str__(self):
return type(self).__name__

async def post(self, request):
coin = request.path_params["coin"]
@validate(serializer=ParamsInputSerializer)
async def post(self, request, data: ParamsInputSerializer):
message = await request.json()
await self.manager.broadcast(message, coin)
is_saved = Feed.save_or_not(coin=coin, category=str(self), message=message)
await self.manager.broadcast(message, data.coin)
is_saved = Feed.save_or_not(coin=data.coin, category=str(self), message=message)
return JSONResponse({"r": is_saved}, status_code=201 if is_saved else 200)

async def get(self, request):
coin = request.path_params["coin"]
rows = Feed.select_rows(coin=coin, category=str(self))
@validate(serializer=ParamsInputSerializer)
async def get(self, _, data: ParamsInputSerializer):
rows = Feed.select_rows(coin=data.coin, category=str(self))
return JSONResponse({"r": rows}, status_code=200)


Expand All @@ -94,20 +99,25 @@ class FeedOctopuses(FeedBase):

class Stats(HTTPEndpoint):
@staticmethod
async def get(request):
coin = request.path_params["coin"]
data = StatsModel(trade_symbol=coin)
@validate(serializer=ParamsInputSerializer)
async def get(_, data: ParamsInputSerializer):
data = StatsModel(trade_symbol=data.coin)
return JSONResponse(data.model_dump(), status_code=200)


class History(HTTPEndpoint):
@staticmethod
async def get(request):
coin = request.path_params["coin"]
data = HistoryModel(trade_symbol=coin)
@validate(serializer=ParamsInputSerializer)
async def get(request, data: ParamsInputSerializer):
data = HistoryModel(trade_symbol=data.coin)
context = {
"request": request,
"rows": data.model_dump()["rows"],
"num_months": int(settings.REDIS_EXPIRATION / 30),
}
return templates.TemplateResponse("history.j2", context=context)


async def exc_handler(request, exc):
context = {"request": request, "error_message": exc.detail}
return templates.TemplateResponse("error.j2", context=context, status_code=exc.status_code)
34 changes: 18 additions & 16 deletions exchange_radar/web/src/endpoints/websockets.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,48 +6,50 @@
ConnectionTradesOctopusesManager,
ConnectionTradesWhalesManager,
)
from exchange_radar.web.src.serializers.decorators import validate
from exchange_radar.web.src.serializers.http import ParamsInputSerializer

manager_trades = ConnectionTradesManager.get_instance()
manager_trades_dolphins = ConnectionTradesDolphinsManager.get_instance()
manager_trades_octopuses = ConnectionTradesOctopusesManager.get_instance()
manager_trades_whales = ConnectionTradesWhalesManager.get_instance()


async def trades(websocket: WebSocket):
coin = websocket.path_params["coin"]
await manager_trades.connect(websocket, coin)
@validate(serializer=ParamsInputSerializer)
async def trades(websocket: WebSocket, data: ParamsInputSerializer):
await manager_trades.connect(websocket, data.coin)
try:
while True:
await websocket.receive_json()
except WebSocketDisconnect:
manager_trades.disconnect(websocket, coin)
manager_trades.disconnect(websocket, data.coin)


async def trades_whales(websocket: WebSocket):
coin = websocket.path_params["coin"]
await manager_trades_whales.connect(websocket, coin)
@validate(serializer=ParamsInputSerializer)
async def trades_whales(websocket: WebSocket, data: ParamsInputSerializer):
await manager_trades_whales.connect(websocket, data.coin)
try:
while True:
await websocket.receive_json()
except WebSocketDisconnect:
manager_trades_whales.disconnect(websocket, coin)
manager_trades_whales.disconnect(websocket, data.coin)


async def trades_dolphins(websocket: WebSocket):
coin = websocket.path_params["coin"]
await manager_trades_dolphins.connect(websocket, coin)
@validate(serializer=ParamsInputSerializer)
async def trades_dolphins(websocket: WebSocket, data: ParamsInputSerializer):
await manager_trades_dolphins.connect(websocket, data.coin)
try:
while True:
await websocket.receive_json()
except WebSocketDisconnect:
manager_trades_dolphins.disconnect(websocket, coin)
manager_trades_dolphins.disconnect(websocket, data.coin)


async def trades_octopuses(websocket: WebSocket):
coin = websocket.path_params["coin"]
await manager_trades_octopuses.connect(websocket, coin)
@validate(serializer=ParamsInputSerializer)
async def trades_octopuses(websocket: WebSocket, data: ParamsInputSerializer):
await manager_trades_octopuses.connect(websocket, data.coin)
try:
while True:
await websocket.receive_json()
except WebSocketDisconnect:
manager_trades_octopuses.disconnect(websocket, coin)
manager_trades_octopuses.disconnect(websocket, data.coin)
8 changes: 7 additions & 1 deletion exchange_radar/web/src/main.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
from starlette.applications import Starlette

from exchange_radar.web.src.endpoints.http import exc_handler
from exchange_radar.web.src.settings import base as settings
from exchange_radar.web.src.tasks.sync_cache import sync_cache
from exchange_radar.web.src.urls import routes

app = Starlette(debug=settings.DEBUG, routes=routes, on_startup=(sync_cache,))
exception_handlers = {
400: exc_handler,
}


app = Starlette(debug=settings.DEBUG, routes=routes, exception_handlers=exception_handlers, on_startup=(sync_cache,))
Empty file.
36 changes: 36 additions & 0 deletions exchange_radar/web/src/serializers/decorators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from collections.abc import Callable
from functools import wraps

from starlette.exceptions import HTTPException, WebSocketException
from starlette.requests import Request


class RaiseValidationException:
def __init__(self, request: Request, message: str):
if request.scope["type"] == "http":
raise HTTPException(400, detail=message)
elif request.scope["type"] == "websocket":
raise WebSocketException(code=1008, reason=None)
raise HTTPException(400, detail="Invalid request type")


def validate(serializer) -> Callable:
def decorator(f: Callable):
@wraps(f)
async def wrapper(*args, **kwargs):
data = None
request: Request = args[-1]
try:
data = serializer(**request.path_params)
except AttributeError:
# object has no attribute 'path_params'
pass
except TypeError:
RaiseValidationException(request=request, message="Mandatory fields are missing")
except ValueError as error:
RaiseValidationException(request=request, message=str(error))
return await f(data=data, *args, **kwargs)

return wrapper

return decorator
19 changes: 19 additions & 0 deletions exchange_radar/web/src/serializers/http.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from dataclasses import dataclass

from exchange_radar.web.src.serializers.mixins import Validations
from exchange_radar.web.src.settings import base as settings


@dataclass
class ParamsInputSerializer(Validations):
coin: str

def validate_coin(self, value, **_) -> str: # noqa
if value not in settings.COINS:
raise ValueError(f"Invalid coin: {value}")
return value


@dataclass
class IndexParamsInputSerializer(ParamsInputSerializer):
coin: str = "BTC"
5 changes: 5 additions & 0 deletions exchange_radar/web/src/serializers/mixins.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class Validations:
def __post_init__(self):
for name, field in self.__dataclass_fields__.items(): # noqa
if method := getattr(self, f"validate_{name}", None):
setattr(self, name, method(getattr(self, name), field=field))
6 changes: 1 addition & 5 deletions exchange_radar/web/src/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,7 @@

routes = [
# Preferably should be served by a load-balancer and not this web-app
Mount(
"/static",
StaticFiles(directory="./exchange_radar/web/static"),
name="static",
),
Mount("/static", app=StaticFiles(directory="./exchange_radar/web/static"), name="static"),
# main
Route("/", endpoint=IndexBase),
Route("/{coin:str}", endpoint=IndexBase),
Expand Down
47 changes: 47 additions & 0 deletions exchange_radar/web/static/js/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
function createElement(obj) {
const span = document.createElement("span");

if (obj.is_seller === false) {
span.className = "order_buy";
} else {
span.className = "order_sell";
}

span.innerHTML = ` ${obj.message} \n`;
return span
}

function setVolume(obj) {
const volume = obj.volume.toLocaleString('en-US', {
minimumFractionDigits: 4,
maximumFractionDigits: 8
});
$(`#${obj.trade_symbol}_volume`).text(volume);

if (obj.hasOwnProperty('price')) {
const volume_in_currency = (obj.volume * obj.price).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
$(`#${obj.trade_symbol}_volume_in_currency`).text(`${volume_in_currency} ${obj.currency}`);
}
}

function setVolumeTrades(obj) {
const volume_trades_buys = obj.volume_trades[0].toLocaleString('en-US', {
minimumFractionDigits: 4,
maximumFractionDigits: 8
});
$(`#${obj.trade_symbol}_volume_trades_buy_orders`).text(volume_trades_buys);

const volume_trades_sells = obj.volume_trades[1].toLocaleString('en-US', {
minimumFractionDigits: 4,
maximumFractionDigits: 8
});
$(`#${obj.trade_symbol}_volume_trades_sell_orders`).text(volume_trades_sells);
}

function setNumberTrades(obj) {
$(`#${obj.trade_symbol}_number_trades_buy_orders`).text(obj.number_trades[0].toLocaleString('en-US'));
$(`#${obj.trade_symbol}_number_trades_sell_orders`).text(obj.number_trades[1].toLocaleString('en-US'));
}
49 changes: 1 addition & 48 deletions exchange_radar/web/templates/base.j2
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,6 @@
{% include 'snippets/switch.j2' %}

<script>
function createElement(obj) {
const span = document.createElement("span");
if (obj.is_seller === false) {
span.className = "order_buy";
} else {
span.className = "order_sell";
}
span.innerHTML = ` ${obj.message} \n`;
return span
}
function addRow(obj) {
const elem = createElement(obj)
Expand All @@ -32,41 +19,6 @@
}
}
function setVolume(obj) {
const volume = obj.volume.toLocaleString('en-US', {
minimumFractionDigits: 4,
maximumFractionDigits: 8
});
$(`#${obj.trade_symbol}_volume`).text(volume);
if (obj.hasOwnProperty('price')) {
const volume_in_currency = (obj.volume * obj.price).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
$(`#${obj.trade_symbol}_volume_in_currency`).text(`${volume_in_currency} ${obj.currency}`);
}
}
function setVolumeTrades(obj) {
const volume_trades_buys = obj.volume_trades[0].toLocaleString('en-US', {
minimumFractionDigits: 4,
maximumFractionDigits: 8
});
$(`#${obj.trade_symbol}_volume_trades_buy_orders`).text(volume_trades_buys);
const volume_trades_sells = obj.volume_trades[1].toLocaleString('en-US', {
minimumFractionDigits: 4,
maximumFractionDigits: 8
});
$(`#${obj.trade_symbol}_volume_trades_sell_orders`).text(volume_trades_sells);
}
function setNumberTrades(obj) {
$(`#${obj.trade_symbol}_number_trades_buy_orders`).text(obj.number_trades[0].toLocaleString('en-US'));
$(`#${obj.trade_symbol}_number_trades_sell_orders`).text(obj.number_trades[1].toLocaleString('en-US'));
}
function retrieveData() {
$.get('{{ http_stats_url }}').done(function (response) {
setVolume(response);
Expand Down Expand Up @@ -112,6 +64,7 @@
ws.close();
};
}
function formatPage() {
const subURL = new URL("{{ http_trades_url }}").pathname.replace(/^\/feed/, "");
$("a").each(function () {
Expand Down
32 changes: 32 additions & 0 deletions exchange_radar/web/templates/error.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<!doctype html>
<html lang="en">

<head>
{% include 'snippets/headers.j2' %}
{% include 'snippets/switch.j2' %}
</head>

<body>
<div class="padded-boxes">
<section>
<div class="padded">
<!-- box 1 content -->
<pre>
{{ error_message }}





<label class="switch"><span class="sun"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="#ffd43b"><circle r="5" cy="12" cx="12"></circle><path d="m21 13h-1a1 1 0 0 1 0-2h1a1 1 0 0 1 0 2zm-17 0h-1a1 1 0 0 1 0-2h1a1 1 0 0 1 0 2zm13.66-5.66a1 1 0 0 1 -.66-.29 1 1 0 0 1 0-1.41l.71-.71a1 1 0 1 1 1.41 1.41l-.71.71a1 1 0 0 1 -.75.29zm-12.02 12.02a1 1 0 0 1 -.71-.29 1 1 0 0 1 0-1.41l.71-.66a1 1 0 0 1 1.41 1.41l-.71.71a1 1 0 0 1 -.7.24zm6.36-14.36a1 1 0 0 1 -1-1v-1a1 1 0 0 1 2 0v1a1 1 0 0 1 -1 1zm0 17a1 1 0 0 1 -1-1v-1a1 1 0 0 1 2 0v1a1 1 0 0 1 -1 1zm-5.66-14.66a1 1 0 0 1 -.7-.29l-.71-.71a1 1 0 0 1 1.41-1.41l.71.71a1 1 0 0 1 0 1.41 1 1 0 0 1 -.71.29zm12.02 12.02a1 1 0 0 1 -.7-.29l-.66-.71a1 1 0 0 1 1.36-1.36l.71.71a1 1 0 0 1 0 1.41 1 1 0 0 1 -.71.24z"></path></g></svg></span><span class="moon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path d="m223.5 32c-123.5 0-223.5 100.3-223.5 224s100 224 223.5 224c60.6 0 115.5-24.2 155.8-63.4 5-4.9 6.3-12.5 3.1-18.7s-10.1-9.7-17-8.5c-9.8 1.7-19.8 2.6-30.1 2.6-96.9 0-175.5-78.8-175.5-176 0-65.8 36-123.1 89.3-153.3 6.1-3.5 9.2-10.5 7.7-17.3s-7.3-11.9-14.3-12.5c-6.3-.5-12.6-.8-19-.8z"></path></svg></span><input type="checkbox" class="input"><span class="slider"></span></label>


<a href="https://pauloantunes.com">Contact</a> | <a href="https://github.com/pantunes/exchange-radar">Github</a>

</pre>
</div>
</section>
</div>
</body>

</html>
Loading

0 comments on commit cc106c3

Please sign in to comment.