add rate limit to user

This commit is contained in:
2025-11-30 22:31:46 -03:00
parent 6e726601d2
commit 8d312893fa
5 changed files with 264 additions and 94 deletions

View File

@@ -1,13 +1,22 @@
from http import HTTPStatus
from typing import Annotated from typing import Annotated
from aws_lambda_powertools.event_handler.api_gateway import Router from aws_lambda_powertools.event_handler.api_gateway import Router
from aws_lambda_powertools.event_handler.exceptions import ( from aws_lambda_powertools.event_handler.exceptions import (
NotFoundError, NotFoundError,
ServiceError,
) )
from aws_lambda_powertools.event_handler.openapi.params import Body 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 layercake.extra_types import CpfStr, NameStr
from api_gateway import JSONResponse
from boto3clients import dynamodb_client from boto3clients import dynamodb_client
from config import USER_TABLE from config import USER_TABLE
@@ -21,17 +30,96 @@ router = Router()
dyn = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client) 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>') @router.get('/<user_id>')
def get_user(user_id: str): def get_user(user_id: str):
return dyn.collection.get_item( user = dyn.collection.get_items(
KeyPair(user_id, '0'), TransactKey(user_id)
exc_cls=NotFoundError, + 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>') @router.patch('/<user_id>')
def update( def update(
user_id: str, user_id: str,
name: Annotated[NameStr, Body(embed=True)], 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)

View 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'

View File

@@ -1,5 +1,6 @@
import type { Route } from './+types/profile' import type { Route } from './+types/profile'
import { AlertCircleIcon } from 'lucide-react'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { Link, useOutletContext } from 'react-router' import { Link, useOutletContext } from 'react-router'
import { PatternFormat } from 'react-number-format' import { PatternFormat } from 'react-number-format'
@@ -24,14 +25,34 @@ import {
import { Input } from '@repo/ui/components/ui/input' import { Input } from '@repo/ui/components/ui/input'
import { Spinner } from '@repo/ui/components/ui/spinner' import { Spinner } from '@repo/ui/components/ui/spinner'
import { type User } from '@repo/ui/routes/users/data' 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 { formSchema, type Schema } from './emails/data'
import { useFetcher } from 'react-router' 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() 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: true }
}
return { ok: false, error: await r.json() }
} }
export default function Route({}: Route.ComponentProps) { export default function Route({}: Route.ComponentProps) {
@@ -52,7 +73,19 @@ export default function Route({}: Route.ComponentProps) {
return ( return (
<Form {...form}> <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> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-2xl">Meu perfil</CardTitle> <CardTitle className="text-2xl">Meu perfil</CardTitle>
@@ -89,8 +122,8 @@ export default function Route({}: Route.ComponentProps) {
</FormControl> </FormControl>
<FormLabel className="text-sm font-normal text-muted-foreground"> <FormLabel className="text-sm font-normal text-muted-foreground">
<span> <span>
Para gerenciar os emails ou trocar o email principal, use Para gerenciar os emails ou trocar o email principal,
as{' '} use as{' '}
<Link <Link
to="emails" to="emails"
className="text-blue-400 underline hover:no-underline" className="text-blue-400 underline hover:no-underline"
@@ -140,7 +173,24 @@ export default function Route({}: Route.ComponentProps) {
Atualizar perfil Atualizar perfil
</Button> </Button>
</div> </div>
</fieldset>
</form> </form>
</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}`
}

View File

@@ -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 # https://docs.authlib.org/en/v0.12/specs/rfc6750.html#authlib.oauth2.rfc6750.BearerToken.GRANT_TYPES_EXPIRES_IN
GRANT_TYPES_EXPIRES_IN = { GRANT_TYPES_EXPIRES_IN = {
'authorization_code': 60 * 3, # 3 minutes 'authorization_code': 3600, # 1 hour
'refresh_token': 3600, # 1 hour 'refresh_token': 3600, # 1 hour
} }

View File

@@ -5,6 +5,7 @@ export type User = {
name: string name: string
email: string email: string
cpf: string cpf: string
rate_limit_exceeded?: any
} }
export const headers = { export const headers = {