add rate limit to user
This commit is contained in:
@@ -1,13 +1,22 @@
|
||||
from http import HTTPStatus
|
||||
from typing import Annotated
|
||||
|
||||
from aws_lambda_powertools.event_handler.api_gateway import Router
|
||||
from aws_lambda_powertools.event_handler.exceptions import (
|
||||
NotFoundError,
|
||||
ServiceError,
|
||||
)
|
||||
from aws_lambda_powertools.event_handler.openapi.params import Body
|
||||
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
|
||||
from layercake.dateutils import now, ttl
|
||||
from layercake.dynamodb import (
|
||||
DynamoDBPersistenceLayer,
|
||||
KeyPair,
|
||||
SortKey,
|
||||
TransactKey,
|
||||
)
|
||||
from layercake.extra_types import CpfStr, NameStr
|
||||
|
||||
from api_gateway import JSONResponse
|
||||
from boto3clients import dynamodb_client
|
||||
from config import USER_TABLE
|
||||
|
||||
@@ -21,17 +30,96 @@ router = Router()
|
||||
dyn = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client)
|
||||
|
||||
|
||||
class UserNotFoundError(NotFoundError): ...
|
||||
|
||||
|
||||
class CPFConflictError(ServiceError):
|
||||
def __init__(self, msg: str | dict):
|
||||
super().__init__(HTTPStatus.CONFLICT, msg)
|
||||
|
||||
|
||||
class RateLimitExceededError(ServiceError):
|
||||
def __init__(self, msg: str | dict):
|
||||
super().__init__(HTTPStatus.TOO_MANY_REQUESTS, msg)
|
||||
|
||||
|
||||
@router.get('/<user_id>')
|
||||
def get_user(user_id: str):
|
||||
return dyn.collection.get_item(
|
||||
KeyPair(user_id, '0'),
|
||||
exc_cls=NotFoundError,
|
||||
user = dyn.collection.get_items(
|
||||
TransactKey(user_id)
|
||||
+ SortKey('0')
|
||||
+ SortKey(
|
||||
sk='RATE_LIMIT_EXCEEDED',
|
||||
rename_key='rate_limit_exceeded',
|
||||
)
|
||||
+ SortKey(
|
||||
sk='CREATED_BY',
|
||||
rename_key='created_by',
|
||||
),
|
||||
)
|
||||
|
||||
if not user:
|
||||
return UserNotFoundError('User not found')
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@router.patch('/<user_id>')
|
||||
def update(
|
||||
user_id: str,
|
||||
name: Annotated[NameStr, Body(embed=True)],
|
||||
cpf: Annotated[CpfStr, Body(embed=True)],
|
||||
): ...
|
||||
new_cpf: Annotated[CpfStr, Body(embed=True, alias='cpf')],
|
||||
):
|
||||
now_ = now()
|
||||
old_cpf = dyn.collection.get_item(
|
||||
KeyPair(
|
||||
pk=user_id,
|
||||
sk=SortKey(
|
||||
'0',
|
||||
path_spec='cpf',
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
with dyn.transact_writer() as transact:
|
||||
transact.put(
|
||||
item={
|
||||
'id': user_id,
|
||||
'sk': 'RATE_LIMIT_EXCEEDED',
|
||||
'ttl': ttl(start_dt=now_, hours=24),
|
||||
'created_at': now_,
|
||||
},
|
||||
cond_expr='attribute_not_exists(sk)',
|
||||
exc_cls=RateLimitExceededError,
|
||||
)
|
||||
|
||||
transact.update(
|
||||
key=KeyPair(user_id, '0'),
|
||||
update_expr='SET #name = :name, cpf = :cpf, updated_at = :now',
|
||||
expr_attr_names={
|
||||
'#name': 'name',
|
||||
},
|
||||
expr_attr_values={
|
||||
':name': name,
|
||||
':cpf': new_cpf,
|
||||
':now': now_,
|
||||
},
|
||||
cond_expr='attribute_exists(sk)',
|
||||
)
|
||||
|
||||
if old_cpf != new_cpf:
|
||||
transact.put(
|
||||
item={
|
||||
'id': 'cpf',
|
||||
'cpf': new_cpf,
|
||||
'user_id': user_id,
|
||||
'created_at': now_,
|
||||
},
|
||||
cond_expr='attribute_not_exists(sk)',
|
||||
exc_cls=CPFConflictError,
|
||||
)
|
||||
|
||||
if old_cpf:
|
||||
transact.delete(key=KeyPair('cpf', old_cpf))
|
||||
|
||||
return JSONResponse(status_code=HTTPStatus.OK)
|
||||
|
||||
31
api.saladeaula.digital/tests/routes/users/test_user.py
Normal file
31
api.saladeaula.digital/tests/routes/users/test_user.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from http import HTTPMethod, HTTPStatus
|
||||
|
||||
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
|
||||
|
||||
from ...conftest import HttpApiProxy, LambdaContext
|
||||
|
||||
|
||||
def test_update(
|
||||
app,
|
||||
seeds,
|
||||
http_api_proxy: HttpApiProxy,
|
||||
lambda_context: LambdaContext,
|
||||
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
|
||||
):
|
||||
user_id = '15bacf02-1535-4bee-9022-19d106fd7518'
|
||||
r = app.lambda_handler(
|
||||
http_api_proxy(
|
||||
raw_path=f'/users/{user_id}',
|
||||
method=HTTPMethod.PATCH,
|
||||
body={
|
||||
'name': 'Sérgio Rafael Siqueira',
|
||||
'cpf': '07879819908',
|
||||
},
|
||||
),
|
||||
lambda_context,
|
||||
)
|
||||
|
||||
assert r['statusCode'] == HTTPStatus.OK
|
||||
|
||||
r = dynamodb_persistence_layer.collection.get_item(KeyPair(user_id, '0'))
|
||||
assert r['name'] == 'Sérgio Rafael Siqueira'
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Route } from './+types/profile'
|
||||
|
||||
import { AlertCircleIcon } from 'lucide-react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Link, useOutletContext } from 'react-router'
|
||||
import { PatternFormat } from 'react-number-format'
|
||||
@@ -24,16 +25,36 @@ import {
|
||||
import { Input } from '@repo/ui/components/ui/input'
|
||||
import { Spinner } from '@repo/ui/components/ui/spinner'
|
||||
import { type User } from '@repo/ui/routes/users/data'
|
||||
import { request as req, HttpMethod } from '@repo/util/request'
|
||||
|
||||
import { formSchema, type Schema } from './emails/data'
|
||||
import { useFetcher } from 'react-router'
|
||||
import { userContext } from '@repo/auth/context'
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle
|
||||
} from '@repo/ui/components/ui/alert'
|
||||
|
||||
export async function action({ request }: Route.ActionArgs) {
|
||||
export async function action({ request, context }: Route.ActionArgs) {
|
||||
const body = await request.json()
|
||||
console.log(body)
|
||||
const user = context.get(userContext)
|
||||
const r = await req({
|
||||
url: `/users/${user.sub}`,
|
||||
method: HttpMethod.PATCH,
|
||||
headers: new Headers({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify(body),
|
||||
request,
|
||||
context
|
||||
})
|
||||
|
||||
if (r.ok) {
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
return { ok: false, error: await r.json() }
|
||||
}
|
||||
|
||||
export default function Route({}: Route.ComponentProps) {
|
||||
const { user } = useOutletContext() as { user: User }
|
||||
const fetcher = useFetcher()
|
||||
@@ -52,7 +73,19 @@ export default function Route({}: Route.ComponentProps) {
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<fieldset disabled={!!user?.rate_limit_exceeded} className="space-y-4">
|
||||
{user?.rate_limit_exceeded && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircleIcon />
|
||||
<AlertTitle>Limite de atualizações excedido</AlertTitle>
|
||||
<AlertDescription>
|
||||
Nova tentativa disponível a partir de{' '}
|
||||
{remainingTime(user.rate_limit_exceeded.ttl)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">Meu perfil</CardTitle>
|
||||
@@ -89,8 +122,8 @@ export default function Route({}: Route.ComponentProps) {
|
||||
</FormControl>
|
||||
<FormLabel className="text-sm font-normal text-muted-foreground">
|
||||
<span>
|
||||
Para gerenciar os emails ou trocar o email principal, use
|
||||
as{' '}
|
||||
Para gerenciar os emails ou trocar o email principal,
|
||||
use as{' '}
|
||||
<Link
|
||||
to="emails"
|
||||
className="text-blue-400 underline hover:no-underline"
|
||||
@@ -140,7 +173,24 @@ export default function Route({}: Route.ComponentProps) {
|
||||
Atualizar perfil
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
function remainingTime(ttl: number) {
|
||||
const date = new Date(ttl * 1000)
|
||||
|
||||
const day = date.toLocaleDateString('pt-BR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit'
|
||||
})
|
||||
|
||||
const time = date.toLocaleTimeString('pt-BR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
|
||||
return `${day} às ${time}`
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ private_jwk = JsonWebKey.import_key(private_key)
|
||||
|
||||
# https://docs.authlib.org/en/v0.12/specs/rfc6750.html#authlib.oauth2.rfc6750.BearerToken.GRANT_TYPES_EXPIRES_IN
|
||||
GRANT_TYPES_EXPIRES_IN = {
|
||||
'authorization_code': 60 * 3, # 3 minutes
|
||||
'authorization_code': 3600, # 1 hour
|
||||
'refresh_token': 3600, # 1 hour
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ export type User = {
|
||||
name: string
|
||||
email: string
|
||||
cpf: string
|
||||
rate_limit_exceeded?: any
|
||||
}
|
||||
|
||||
export const headers = {
|
||||
|
||||
Reference in New Issue
Block a user