This commit is contained in:
2025-03-20 21:26:04 -03:00
parent 85cbc9269c
commit 1f19380f5c
20 changed files with 293 additions and 54 deletions

View File

@@ -93,7 +93,7 @@ def _keys(
}
def _ttl_kwargs(ttl: datetime | int | None = None, tz=None):
def _ttl_kwargs(ttl: datetime | int | None = None, tz=None) -> dict:
if not ttl:
return {}
@@ -109,7 +109,7 @@ def _ttl_kwargs(ttl: datetime | int | None = None, tz=None):
}
class MissingError(ValueError):
class MissingRecordError(ValueError):
pass
@@ -120,7 +120,7 @@ def get_record(
glom_spec: str | None = None,
raise_on_missing: bool = True,
default_on_missing: Any = None,
missing_cls: Type[Exception] = MissingError,
missing_cls: Type[Exception] = MissingRecordError,
delimiter: str = DELIMITER,
persistence_layer: DynamoDBPersistenceLayer,
) -> Any:

View File

@@ -28,13 +28,13 @@ def search(
r = s.execute()
except Exception:
return {
'total_hits': 0,
'total_items': 0,
'total_pages': 0,
'hits': [],
'items': [],
}
else:
return {
'total_hits': r.hits.total.value, # type: ignore
'total_items': r.hits.total.value, # type: ignore
'total_pages': math.ceil(r.hits.total.value / page_size), # type: ignore
'hits': [hit.to_dict() for hit in r],
}

View File

@@ -2,9 +2,9 @@ from pydantic import BaseModel
class SearchResponse(BaseModel):
total_hits: int
total_items: int
total_pages: int
hits: list[dict]
items: list[dict]
class RecordResponse(BaseModel):

View File

@@ -1,23 +1,40 @@
from typing import Annotated
from uuid import uuid4
import shortuuid
from layercake.extra_types import CnpjStr
from pydantic import BaseModel, Field, StringConstraints
from layercake.extra_types import CnpjStr, CpfStr, NameStr
from pydantic import (
UUID4,
BaseModel,
ConfigDict,
EmailStr,
Field,
StringConstraints,
)
class Org(BaseModel):
id: str
id: UUID4 | str = Field(default_factory=uuid4)
name: Annotated[str, StringConstraints(strip_whitespace=True)]
cnpj: CnpjStr | None = None
class User(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
id: UUID4 | str = Field(default_factory=uuid4)
name: NameStr
email: EmailStr
email_verified: bool = False
cpf: CpfStr | None = None
class Cert(BaseModel):
id: str
id: UUID4 | str = Field(default_factory=uuid4)
exp_interval: int
class Course(BaseModel):
id: str = Field(default_factory=shortuuid.uuid)
id: UUID4 | str = Field(default_factory=uuid4)
name: str
cert: Cert | None = None
access_period: int | None = None

View File

@@ -14,7 +14,7 @@ router = Router()
elastic_client = Elasticsearch(**ELASTIC_CONN)
@router.get('/', compress=True)
@router.get('/', compress=True, tags=['Course'])
def get_courses() -> SearchResponse:
event = router.current_event
query = event.get_query_string_value('query', '{}')
@@ -28,11 +28,11 @@ def get_courses() -> SearchResponse:
)
@router.post('/', compress=True)
@router.post('/', compress=True, tags=['Course'])
def post_course(payload: Course):
return Response(status_code=HTTPStatus.CREATED)
@router.get('/<id>')
@router.get('/<id>', compress=True, tags=['Course'])
def get_course(id: str):
return {}

View File

@@ -13,7 +13,7 @@ router = Router()
elastic_client = Elasticsearch(**ELASTIC_CONN)
@router.get('/')
@router.get('/', compress=True, tags=['Enrollment'])
def get_enrollments() -> SearchResponse:
event = router.current_event
query = event.get_query_string_value('query', '{}')
@@ -27,7 +27,7 @@ def get_enrollments() -> SearchResponse:
)
@router.get('/<id>')
@router.get('/<id>', compress=True, tags=['Enrollment'])
def get_enrollment(id: str):
return {}
@@ -36,11 +36,11 @@ class CancelPayload(BaseModel):
status: Literal['CANCELED'] = 'CANCELED'
@router.patch('/<id>')
@router.patch('/<id>', compress=True, tags=['Enrollment'])
def cancel(id: str, payload: CancelPayload):
return {}
@router.post('/')
@router.post('/', compress=True, tags=['Enrollment'])
def enroll():
return {}

View File

@@ -11,7 +11,7 @@ router = Router()
elastic_client = Elasticsearch(**ELASTIC_CONN)
@router.get('/')
@router.get('/', compress=True, tags=['Order'])
def get_orders() -> SearchResponse:
event = router.current_event
query = event.get_query_string_value('query', '{}')

View File

@@ -8,12 +8,13 @@ from aws_lambda_powertools.event_handler.api_gateway import (
Router,
)
from elasticsearch import Elasticsearch
from pydantic import UUID4, BaseModel, StringConstraints
from layercake.dynamodb import DynamoDBPersistenceLayer
from dynamodb import KeyLoc, get_records
from pydantic import UUID4, BaseModel, StringConstraints
import elastic
from dynamodb import KeyLoc, get_records
from http_models import RecordResponse, SearchResponse
from models import User
from settings import ELASTIC_CONN, USER_TABLE
router = Router()
@@ -36,6 +37,11 @@ def get_users() -> SearchResponse:
)
@router.post('/', compress=True, tags=['User'], summary='Create user')
def post_user(payload: User):
return Response(status_code=HTTPStatus.CREATED)
class ResetPasswordPayload(BaseModel):
cognito_sub: UUID4
new_password: Annotated[str, StringConstraints(min_length=6)]

View File

@@ -23,7 +23,7 @@ Globals:
Architectures:
- x86_64
Layers:
- !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:10
- !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:13
Environment:
Variables:
TZ: America/Sao_Paulo

View File

@@ -2,8 +2,14 @@ import base64
import json
from dataclasses import dataclass
from http import HTTPMethod
from typing import Generator
import boto3
import pytest
from layercake.dynamodb import DynamoDBPersistenceLayer
table_name = 'pytest'
dynamodb_endpoint_url = 'http://127.0.0.1:8000'
@dataclass
@@ -86,6 +92,36 @@ def http_api_proxy():
return HttpApiProxy()
@pytest.fixture
def dynamodb_client():
return boto3.client('dynamodb', endpoint_url=dynamodb_endpoint_url)
@pytest.fixture()
def dynamodb_persistence_layer(
dynamodb_client,
) -> Generator[DynamoDBPersistenceLayer, None, None]:
dynamodb_client.create_table(
AttributeDefinitions=[
{'AttributeName': 'id', 'AttributeType': 'S'},
{'AttributeName': 'sk', 'AttributeType': 'S'},
],
TableName=table_name,
KeySchema=[
{'AttributeName': 'id', 'KeyType': 'HASH'},
{'AttributeName': 'sk', 'KeyType': 'RANGE'},
],
ProvisionedThroughput={
'ReadCapacityUnits': 123,
'WriteCapacityUnits': 123,
},
)
yield DynamoDBPersistenceLayer(table_name, dynamodb_client)
dynamodb_client.delete_table(TableName=table_name)
@pytest.fixture
def mock_app(monkeypatch):
monkeypatch.setattr('settings.ELASTIC_CONN', {'hosts': 'http://127.0.0.1:9200'})

View File

@@ -3,7 +3,11 @@ from http import HTTPMethod, HTTPStatus
from ..conftest import HttpApiProxy, LambdaContext
def test_courses(mock_app, http_api_proxy: HttpApiProxy, lambda_context: LambdaContext):
def test_post_course(
mock_app,
http_api_proxy: HttpApiProxy,
lambda_context: LambdaContext,
):
r = mock_app.lambda_handler(
http_api_proxy(
raw_path='/courses',

View File

@@ -0,0 +1,24 @@
from http import HTTPMethod, HTTPStatus
from ..conftest import HttpApiProxy, LambdaContext
def test_post_user(
mock_app,
http_api_proxy: HttpApiProxy,
lambda_context: LambdaContext,
):
r = mock_app.lambda_handler(
http_api_proxy(
raw_path='/users',
method=HTTPMethod.POST,
body={
'name': 'Sérgio R Siqueira',
'email': 'sergio@somosbeta.com.br',
'cpf': '07879819908',
},
),
lambda_context,
)
assert r['statusCode'] == HTTPStatus.CREATED

40
http-api/uv.lock generated
View File

@@ -232,6 +232,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/33/cf/1f7649b8b9a3543e042d3f348e398a061923ac05b507f3f4d95f11938aa9/cryptography-44.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:5f6f90b72d8ccadb9c6e311c775c8305381db88374c65fa1a68250aa8a9cb3a6", size = 3210957 },
]
[[package]]
name = "dnspython"
version = "2.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b5/4a/263763cb2ba3816dd94b08ad3a33d5fdae34ecb856678773cc40a3605829/dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1", size = 345197 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632 },
]
[[package]]
name = "elastic-transport"
version = "8.17.1"
@@ -272,6 +281,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ad/b4/5e707bca39062ba0b5227696a767db09767e5f09e869c6cb14aeb36e4b9d/elasticsearch_dsl-8.17.1-py3-none-any.whl", hash = "sha256:49ee12a6a8d43fcfc0af42b49649531a6ef228c9e4795325de27f6b309b62b6d", size = 158294 },
]
[[package]]
name = "email-validator"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "dnspython" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/48/ce/13508a1ec3f8bb981ae4ca79ea40384becc868bfae97fd1c942bb3a001b1/email_validator-2.2.0.tar.gz", hash = "sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7", size = 48967 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/ee/bf0adb559ad3c786f12bcbc9296b3f5675f529199bef03e2df281fa1fadb/email_validator-2.2.0-py3-none-any.whl", hash = "sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631", size = 33521 },
]
[[package]]
name = "face"
version = "24.0.0"
@@ -344,6 +366,15 @@ dev = [
{ name = "ruff", specifier = ">=0.9.1" },
]
[[package]]
name = "idna"
version = "3.10"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 },
]
[[package]]
name = "iniconfig"
version = "2.0.0"
@@ -387,7 +418,7 @@ dependencies = [
{ name = "glom" },
{ name = "orjson" },
{ name = "pycpfcnpj" },
{ name = "pydantic" },
{ name = "pydantic", extra = ["email"] },
{ name = "pydantic-extra-types" },
{ name = "pytz" },
{ name = "shortuuid" },
@@ -403,7 +434,7 @@ requires-dist = [
{ name = "glom", specifier = ">=24.11.0" },
{ name = "orjson", specifier = ">=3.10.15" },
{ name = "pycpfcnpj", specifier = ">=1.8" },
{ name = "pydantic", specifier = ">=2.10.6" },
{ name = "pydantic", extras = ["email"], specifier = ">=2.10.6" },
{ name = "pydantic-extra-types", specifier = ">=2.10.3" },
{ name = "pytz", specifier = ">=2025.1" },
{ name = "shortuuid", specifier = ">=1.0.13" },
@@ -509,6 +540,11 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/3c/8cc1cc84deffa6e25d2d0c688ebb80635dfdbf1dbea3e30c541c8cf4d860/pydantic-2.10.6-py3-none-any.whl", hash = "sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584", size = 431696 },
]
[package.optional-dependencies]
email = [
{ name = "email-validator" },
]
[[package]]
name = "pydantic-core"
version = "2.27.2"

View File

@@ -9,31 +9,49 @@ from botocore.exceptions import ClientError
logger = Logger(__name__)
def _serialize(v):
if isinstance(v, datetime):
return v.isoformat()
if isinstance(v, IPv4Address):
return str(v)
if isinstance(v, (list, tuple)):
return [_serialize(x) for x in v]
if isinstance(v, dict):
return {k: _serialize(dv) for k, dv in v.items()}
return v
def _serialize_python_types(obj: Any) -> str | dict | list:
match obj:
case datetime():
return obj.isoformat()
case IPv4Address():
return str(obj)
case list() | tuple():
return [_serialize_python_types(v) for v in obj]
case dict():
return {k: _serialize_python_types(v) for k, v in obj.items()}
case _:
return obj
def serialize(obj: dict) -> dict:
return {k: TypeSerializer().serialize(_serialize(v)) for k, v in obj.items()}
serializer = TypeSerializer()
return {k: serializer.serialize(_serialize_python_types(v)) for k, v in obj.items()}
def deserialize(obj: dict) -> dict:
return {k: TypeDeserializer().deserialize(v) for k, v in obj.items()}
deserializer = TypeDeserializer()
return {k: deserializer.deserialize(v) for k, v in obj.items()}
def Key(pk: str, sk: str) -> dict[str, str]:
def Key(
val: str | tuple[str, ...],
*,
prefix: str | None = None,
delimiter: str = '#',
) -> str:
if not prefix and not isinstance(val, tuple):
return val
if isinstance(val, str):
val = (val,)
if prefix:
val = (prefix,) + val
return delimiter.join(val)
def KeyPair(pk: str, sk: str) -> dict[str, str]:
return {
'id': pk,
'sk': sk,

View File

@@ -4,7 +4,8 @@ from typing import TYPE_CHECKING, Annotated, Any
import ftfy
from pycpfcnpj import cpfcnpj
from pydantic import BaseModel, Field, GetCoreSchemaHandler
from pydantic import BaseModel, Field, GetCoreSchemaHandler, GetJsonSchemaHandler
from pydantic.json_schema import JsonSchemaValue
from pydantic_core import CoreSchema, core_schema
from pydantic_extra_types.payment import PaymentCardNumber
@@ -47,6 +48,14 @@ else:
return name
@classmethod
def __get_pydantic_json_schema__(
cls, core_schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler
) -> JsonSchemaValue:
field_schema = handler(core_schema)
field_schema.update(type='string', format='name')
return field_schema
class PaymentCardValidation:
"""
@@ -145,11 +154,9 @@ if TYPE_CHECKING:
CnpjStr = Annotated[str, ...]
else:
class CpfStr(CpfCnpj):
...
class CpfStr(CpfCnpj): ...
class CnpjStr(CpfCnpj):
...
class CnpjStr(CpfCnpj): ...
if __name__ == '__main__':

View File

View File

@@ -16,7 +16,7 @@ dependencies = [
"glom>=24.11.0",
"orjson>=3.10.15",
"pycpfcnpj>=1.8",
"pydantic>=2.10.6",
"pydantic[email]>=2.10.6",
"pydantic-extra-types>=2.10.3",
"pytz>=2025.1",
"shortuuid>=1.0.13",

View File

@@ -1,7 +1,40 @@
from datetime import datetime
from ipaddress import IPv4Address
import pytest
from botocore.exceptions import ClientError
from layercake.dynamodb import DynamoDBPersistenceLayer, TransactItems
from layercake.dynamodb import (
DynamoDBPersistenceLayer,
Key,
KeyPair,
TransactItems,
serialize,
)
def test_serialize():
assert serialize(
{
'id': '123',
'sk': 'abc',
'date': datetime.fromisoformat('2025-03-20T18:29:10.713994'),
'ip': IPv4Address('127.0.0.1'),
}
) == {
'id': {'S': '123'},
'sk': {'S': 'abc'},
'date': {'S': '2025-03-20T18:29:10.713994'},
'ip': {'S': '127.0.0.1'},
}
def test_key():
assert Key(('122', 'abc'), prefix='schedules') == 'schedules#122#abc'
def test_keypair():
assert KeyPair('123', 'abc') == {'id': '123', 'sk': 'abc'}
def test_transact_write_items(dynamodb_client):
@@ -33,5 +66,6 @@ def test_transact_write_items(dynamodb_client):
},
cond_expr='attribute_not_exists(sk)',
)
with pytest.raises(ClientError):
user_layer.transact_write_items(transact)

View File

@@ -0,0 +1,21 @@
from layercake.funcs import omit, pick
def test_omit():
values = {'indigo': '#4b0082', 'navy': '#000080'}
assert omit(['indigo'], values) == {'navy': '#000080'}
assert omit(['test'], values) == values
def test_pick():
values = {'indigo': '#4b0082', 'navy': '#000080'}
assert pick(['navy'], values) == {'navy': '#000080'}
assert pick(['test'], values) == {}
def test_pick_default_val():
values = {'name': 'test'}
assert pick(['name', 'surname'], values, exclude_none=False, default=False) == {
'name': 'test',
'surname': False,
}

40
layercake/uv.lock generated
View File

@@ -232,6 +232,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/33/cf/1f7649b8b9a3543e042d3f348e398a061923ac05b507f3f4d95f11938aa9/cryptography-44.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:5f6f90b72d8ccadb9c6e311c775c8305381db88374c65fa1a68250aa8a9cb3a6", size = 3210957 },
]
[[package]]
name = "dnspython"
version = "2.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b5/4a/263763cb2ba3816dd94b08ad3a33d5fdae34ecb856678773cc40a3605829/dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1", size = 345197 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632 },
]
[[package]]
name = "elastic-transport"
version = "8.17.1"
@@ -272,6 +281,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ad/b4/5e707bca39062ba0b5227696a767db09767e5f09e869c6cb14aeb36e4b9d/elasticsearch_dsl-8.17.1-py3-none-any.whl", hash = "sha256:49ee12a6a8d43fcfc0af42b49649531a6ef228c9e4795325de27f6b309b62b6d", size = 158294 },
]
[[package]]
name = "email-validator"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "dnspython" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/48/ce/13508a1ec3f8bb981ae4ca79ea40384becc868bfae97fd1c942bb3a001b1/email_validator-2.2.0.tar.gz", hash = "sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7", size = 48967 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/ee/bf0adb559ad3c786f12bcbc9296b3f5675f529199bef03e2df281fa1fadb/email_validator-2.2.0-py3-none-any.whl", hash = "sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631", size = 33521 },
]
[[package]]
name = "face"
version = "24.0.0"
@@ -319,6 +341,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/a2/75fd80784ec33da8d39cf885e8811a4fbc045a90db5e336b8e345e66dbb2/glom-24.11.0-py3-none-any.whl", hash = "sha256:991db7fcb4bfa9687010aa519b7b541bbe21111e70e58fdd2d7e34bbaa2c1fbd", size = 102690 },
]
[[package]]
name = "idna"
version = "3.10"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 },
]
[[package]]
name = "iniconfig"
version = "2.1.0"
@@ -362,7 +393,7 @@ dependencies = [
{ name = "glom" },
{ name = "orjson" },
{ name = "pycpfcnpj" },
{ name = "pydantic" },
{ name = "pydantic", extra = ["email"] },
{ name = "pydantic-extra-types" },
{ name = "pytz" },
{ name = "shortuuid" },
@@ -385,7 +416,7 @@ requires-dist = [
{ name = "glom", specifier = ">=24.11.0" },
{ name = "orjson", specifier = ">=3.10.15" },
{ name = "pycpfcnpj", specifier = ">=1.8" },
{ name = "pydantic", specifier = ">=2.10.6" },
{ name = "pydantic", extras = ["email"], specifier = ">=2.10.6" },
{ name = "pydantic-extra-types", specifier = ">=2.10.3" },
{ name = "pytz", specifier = ">=2025.1" },
{ name = "shortuuid", specifier = ">=1.0.13" },
@@ -491,6 +522,11 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/3c/8cc1cc84deffa6e25d2d0c688ebb80635dfdbf1dbea3e30c541c8cf4d860/pydantic-2.10.6-py3-none-any.whl", hash = "sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584", size = 431696 },
]
[package.optional-dependencies]
email = [
{ name = "email-validator" },
]
[[package]]
name = "pydantic-core"
version = "2.27.2"