add rate limit to user
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
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 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}`
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
Reference in New Issue
Block a user