wip
This commit is contained in:
1
http-api/.gitignore
vendored
1
http-api/.gitignore
vendored
@@ -2,3 +2,4 @@
|
||||
env.json
|
||||
dynamodb_volume/
|
||||
elastic_volume/
|
||||
meili_data/
|
||||
|
||||
@@ -4,6 +4,7 @@ from typing import Any
|
||||
from aws_lambda_powertools import Logger, Tracer
|
||||
from aws_lambda_powertools.event_handler.api_gateway import (
|
||||
APIGatewayHttpResolver,
|
||||
CORSConfig,
|
||||
Response,
|
||||
content_types,
|
||||
)
|
||||
@@ -12,20 +13,26 @@ from aws_lambda_powertools.logging import correlation_paths
|
||||
from aws_lambda_powertools.utilities.typing import LambdaContext
|
||||
|
||||
from middlewares import AuthorizerMiddleware, TenantMiddleware
|
||||
from routes import courses, enrollments, lookup, me, orders, users, webhooks
|
||||
from routes import courses, enrollments, lookup, orders, settings, users, webhooks
|
||||
|
||||
DEBUG = os.getenv('LOG_LEVEL') == 'DEBUG'
|
||||
DEBUG = 'AWS_SAM_LOCAL' in os.environ
|
||||
|
||||
tracer = Tracer()
|
||||
logger = Logger(__name__)
|
||||
app = APIGatewayHttpResolver(enable_validation=True, debug=DEBUG)
|
||||
cors = CORSConfig(
|
||||
allow_origin='*',
|
||||
allow_headers=['Content-Type', 'X-Requested-With', 'Authorization', 'X-Tenant'],
|
||||
max_age=600,
|
||||
allow_credentials=False,
|
||||
)
|
||||
app = APIGatewayHttpResolver(enable_validation=True, cors=cors, debug=DEBUG)
|
||||
app.use(middlewares=[AuthorizerMiddleware(), TenantMiddleware()])
|
||||
app.include_router(courses.router, prefix='/courses')
|
||||
app.include_router(enrollments.router, prefix='/enrollments')
|
||||
app.include_router(orders.router, prefix='/orders')
|
||||
app.include_router(users.router, prefix='/users')
|
||||
app.include_router(webhooks.router, prefix='/webhooks')
|
||||
app.include_router(me.router, prefix='/me')
|
||||
app.include_router(settings.router, prefix='/settings')
|
||||
app.include_router(lookup.router, prefix='/lookup')
|
||||
|
||||
|
||||
|
||||
@@ -48,19 +48,21 @@ tracer = Tracer()
|
||||
logger = Logger(__name__)
|
||||
idp_client = boto3.client('cognito-idp')
|
||||
user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client)
|
||||
collect = DynamoDBCollection(user_layer)
|
||||
user_collect = DynamoDBCollection(user_layer)
|
||||
|
||||
|
||||
@tracer.capture_lambda_handler
|
||||
@logger.inject_lambda_context
|
||||
@event_source(data_class=APIGatewayAuthorizerEventV2)
|
||||
def lambda_handler(event: APIGatewayAuthorizerEventV2, context: LambdaContext) -> dict:
|
||||
"""Authenticates a user using a bearer token (for user or API).
|
||||
Only handles authentication; any additional logic (e.g., tenant) is performed afterward."""
|
||||
bearer = _parse_bearer_token(event.headers.get('authorization', ''))
|
||||
|
||||
if not bearer:
|
||||
return APIGatewayAuthorizerResponseV2(authorize=False).asdict()
|
||||
|
||||
attrs = _authorizer(bearer).asdict()
|
||||
attrs = _authorizer(bearer, user_collect).asdict()
|
||||
return APIGatewayAuthorizerResponseV2(**attrs).asdict()
|
||||
|
||||
|
||||
@@ -76,7 +78,7 @@ class BearerToken:
|
||||
|
||||
|
||||
@dataclass
|
||||
class Authorizer:
|
||||
class AuthorizerResponseV2:
|
||||
authorize: bool = False
|
||||
context: dict[str, Any] | None = None
|
||||
auth_flow_type: AuthFlowType = AuthFlowType.USER_AUTH
|
||||
@@ -92,11 +94,15 @@ class Authorizer:
|
||||
return data
|
||||
|
||||
|
||||
def _get_apikey(token: str) -> dict[str, dict | str]:
|
||||
def _get_apikey(token: str, /, collect: DynamoDBCollection) -> dict[str, dict | str]:
|
||||
return collect.get_item(KeyPair('apikey', token))
|
||||
|
||||
|
||||
def _authorizer(bearer: BearerToken) -> Authorizer:
|
||||
def _authorizer(
|
||||
bearer: BearerToken,
|
||||
/,
|
||||
collect: DynamoDBCollection,
|
||||
) -> AuthorizerResponseV2:
|
||||
"""
|
||||
Build an Authorizer object based on the bearer token's auth type.
|
||||
|
||||
@@ -113,13 +119,13 @@ def _authorizer(bearer: BearerToken) -> Authorizer:
|
||||
try:
|
||||
if bearer.auth_flow_type == AuthFlowType.USER_AUTH:
|
||||
user = get_user(bearer.token, idp_client)
|
||||
return Authorizer(True, {'user': user})
|
||||
return AuthorizerResponseV2(True, {'user': user})
|
||||
|
||||
apikey = _get_apikey(bearer.token)
|
||||
apikey = _get_apikey(bearer.token, collect)
|
||||
context = pick(('tenant', 'user'), apikey)
|
||||
return Authorizer(True, context, AuthFlowType.API_AUTH)
|
||||
return AuthorizerResponseV2(True, context, AuthFlowType.API_AUTH)
|
||||
except Exception:
|
||||
return Authorizer()
|
||||
return AuthorizerResponseV2()
|
||||
|
||||
|
||||
def _parse_bearer_token(s: str) -> BearerToken | None:
|
||||
|
||||
@@ -9,6 +9,15 @@ services:
|
||||
working_dir: /home/dynamodblocal
|
||||
command: "-jar DynamoDBLocal.jar -sharedDb -dbPath ./data"
|
||||
|
||||
meilisearch:
|
||||
container_name: meilisearch
|
||||
image: getmeili/meilisearch:v1.13
|
||||
volumes:
|
||||
- ./meili_data:/meili_data
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 7700:7700
|
||||
|
||||
elastic:
|
||||
image: docker.elastic.co/elasticsearch/elasticsearch:8.11.3
|
||||
container_name: elastic
|
||||
|
||||
@@ -34,6 +34,7 @@ def token(username: str) -> KonvivaToken:
|
||||
r = requests.get(url.geturl(), headers=headers)
|
||||
r.raise_for_status()
|
||||
|
||||
# Because Konviva does not return the proper HTTP status code
|
||||
if err := glom(r.json(), 'errors.0', default=None):
|
||||
raise KonvivaError(err)
|
||||
|
||||
|
||||
@@ -63,7 +63,15 @@ class TenantMiddleware(BaseMiddlewareHandler):
|
||||
next_middleware: NextMiddleware,
|
||||
) -> Response:
|
||||
context = app.current_event.request_context.authorizer.get_lambda
|
||||
tenant = app.current_event.headers.get('x-tenant')
|
||||
auth_flow_type = context.get('auth_flow_type')
|
||||
|
||||
match auth_flow_type, tenant:
|
||||
case AuthFlowType.API_AUTH, None:
|
||||
app.append_context(tenant=context['tenant'])
|
||||
case AuthFlowType.USER_AUTH, str():
|
||||
print(tenant)
|
||||
|
||||
return next_middleware(app)
|
||||
|
||||
|
||||
@@ -108,7 +116,8 @@ class AuditLogMiddleware(BaseMiddlewareHandler):
|
||||
ip_addr = req_context.http.source_ip
|
||||
response = next_middleware(app)
|
||||
|
||||
# Successful request
|
||||
# Successful response
|
||||
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status#successful_responses
|
||||
if 200 <= response.status_code < 300 and user:
|
||||
now_ = now()
|
||||
data = (
|
||||
@@ -124,7 +133,8 @@ class AuditLogMiddleware(BaseMiddlewareHandler):
|
||||
|
||||
self.collect.put_item(
|
||||
key=KeyPair(
|
||||
pk=ComposeKey(user.id, prefix='logs'),
|
||||
# Post-migration: remove `delimiter` from ComposeKey.
|
||||
pk=ComposeKey(user.id, prefix='logs', delimiter=':'),
|
||||
sk=now_.isoformat(),
|
||||
),
|
||||
action=self.action,
|
||||
|
||||
@@ -13,27 +13,28 @@ from settings import USER_TABLE
|
||||
|
||||
router = Router()
|
||||
user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client)
|
||||
collect = DynamoDBCollection(user_layer)
|
||||
user_collect = DynamoDBCollection(user_layer)
|
||||
|
||||
|
||||
LIMIT = 25
|
||||
|
||||
|
||||
@router.get('/', include_in_schema=False)
|
||||
def me():
|
||||
def settings():
|
||||
user: User = router.context['user']
|
||||
acls = collect.get_items(
|
||||
acls = user_collect.get_items(
|
||||
KeyPair(user.id, PrefixKey('acls')),
|
||||
limit=LIMIT,
|
||||
)
|
||||
workspaces = collect.get_items(
|
||||
tenants = user_collect.get_items(
|
||||
KeyPair(user.id, PrefixKey('orgs')),
|
||||
limit=LIMIT,
|
||||
)
|
||||
|
||||
return {
|
||||
'acls': acls['items'],
|
||||
'workspaces': workspaces['items'],
|
||||
# Note: ensure compatibility with search on React's tenant menu
|
||||
'tenants': [x | {'id': x['sk']} for x in tenants['items']],
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ class BadRequestError(MissingError, PowertoolsBadRequestError): ...
|
||||
|
||||
router = Router()
|
||||
user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client)
|
||||
collect = DynamoDBCollection(user_layer, exception_cls=BadRequestError)
|
||||
user_collect = DynamoDBCollection(user_layer, exception_cls=BadRequestError)
|
||||
elastic_client = Elasticsearch(**ELASTIC_CONN)
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ def get_users():
|
||||
compress=True,
|
||||
tags=['User'],
|
||||
summary='Create user',
|
||||
middlewares=[AuditLogMiddleware('USER_ADD', collect)],
|
||||
middlewares=[AuditLogMiddleware('USER_ADD', user_collect)],
|
||||
)
|
||||
def post_user(payload: User):
|
||||
return Response(status_code=HTTPStatus.CREATED)
|
||||
@@ -84,7 +84,7 @@ def patch_reset(id: str, payload: NewPasswordPayload):
|
||||
summary='Get user',
|
||||
)
|
||||
def get_user(id: str):
|
||||
return collect.get_item(KeyPair(id, '0'))
|
||||
return user_collect.get_item(KeyPair(id, '0'))
|
||||
|
||||
|
||||
@router.get('/<id>/idp', compress=True, include_in_schema=False)
|
||||
@@ -99,7 +99,7 @@ def get_idp(id: str):
|
||||
summary='Get user emails',
|
||||
)
|
||||
def get_emails(id: str):
|
||||
return collect.get_items(
|
||||
return user_collect.get_items(
|
||||
KeyPair(id, PrefixKey('emails')),
|
||||
start_key=router.current_event.get_query_string_value('start_key', None),
|
||||
)
|
||||
@@ -112,7 +112,7 @@ def get_emails(id: str):
|
||||
summary='Get user logs',
|
||||
)
|
||||
def get_logs(id: str):
|
||||
return collect.get_items(
|
||||
return user_collect.get_items(
|
||||
# Post-migration: uncomment to enable PartitionKey with a composite key (id with `logs` prefix).
|
||||
# PartitionKey(ComposeKey(id, prefix='logs')),
|
||||
PartitionKey(ComposeKey(id, prefix='log', delimiter=':')),
|
||||
@@ -127,7 +127,7 @@ def get_logs(id: str):
|
||||
summary='Get user orgs',
|
||||
)
|
||||
def get_orgs(id: str):
|
||||
return collect.get_items(
|
||||
return user_collect.get_items(
|
||||
KeyPair(id, PrefixKey('orgs')),
|
||||
start_key=router.current_event.get_query_string_value('start_key', None),
|
||||
)
|
||||
|
||||
@@ -23,7 +23,7 @@ Globals:
|
||||
Architectures:
|
||||
- x86_64
|
||||
Layers:
|
||||
- !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:26
|
||||
- !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:28
|
||||
Environment:
|
||||
Variables:
|
||||
TZ: America/Sao_Paulo
|
||||
@@ -39,6 +39,8 @@ Globals:
|
||||
ELASTIC_AUTH_PASS: "{{resolve:ssm:/betaeducacao/elastic/auth_pass/str}}"
|
||||
KONVIVA_API_URL: https://saladeaula.digital
|
||||
KONVIVA_SECRET_KEY: "{{resolve:ssm:/betaeducacao/konviva/secret_key/str}}"
|
||||
MEILISEARCH_HOST: https://meili.vps.eduseg.com.br
|
||||
MEILISEARCH_API_KEY: "{{resolve:ssm:/saladeaula/meili_api_key}}"
|
||||
|
||||
Resources:
|
||||
HttpLog:
|
||||
@@ -53,6 +55,8 @@ Resources:
|
||||
AllowOrigins: ["*"]
|
||||
AllowMethods: [GET, POST, PUT, DELETE, PATCH, OPTIONS]
|
||||
AllowHeaders: [Content-Type, X-Requested-With, Authorization, X-Tenant]
|
||||
AllowCredentials: false
|
||||
MaxAge: 600
|
||||
Auth:
|
||||
DefaultAuthorizer: LambdaRequestAuthorizer
|
||||
Authorizers:
|
||||
@@ -63,6 +67,7 @@ Resources:
|
||||
EnableSimpleResponses: true
|
||||
Identity:
|
||||
Headers: [Authorization]
|
||||
ReauthorizeEvery: 300
|
||||
|
||||
HttpApiFunction:
|
||||
Type: AWS::Serverless::Function
|
||||
@@ -76,6 +81,14 @@ Resources:
|
||||
- DynamoDBCrudPolicy:
|
||||
TableName: !Ref CourseTable
|
||||
Events:
|
||||
Preflight:
|
||||
Type: HttpApi
|
||||
Properties:
|
||||
Path: /{proxy+}
|
||||
Method: OPTIONS
|
||||
ApiId: !Ref HttpApi
|
||||
Auth:
|
||||
Authorizer: NONE
|
||||
AnyRequest:
|
||||
Type: HttpApi
|
||||
Properties:
|
||||
|
||||
@@ -34,6 +34,7 @@ class HttpApiProxy:
|
||||
body: dict = {},
|
||||
*,
|
||||
headers: dict = {},
|
||||
auth_flow_type: str = 'USER_AUTH',
|
||||
**kwargs,
|
||||
) -> dict:
|
||||
return {
|
||||
@@ -59,7 +60,7 @@ class HttpApiProxy:
|
||||
'custom:user_id': '5OxmMjL-ujoR5IMGegQz',
|
||||
'sub': 'c4f30dbd-083e-4b84-aa50-c31afe9b9c01',
|
||||
},
|
||||
'auth_flow_type': 'USER_AUTH',
|
||||
'auth_flow_type': auth_flow_type,
|
||||
},
|
||||
'jwt': {
|
||||
'claims': {'claim1': 'value1', 'claim2': 'value2'},
|
||||
|
||||
@@ -4,7 +4,7 @@ from http import HTTPMethod, HTTPStatus
|
||||
from ..conftest import HttpApiProxy, LambdaContext
|
||||
|
||||
|
||||
def test_me(
|
||||
def test_settings(
|
||||
mock_app,
|
||||
dynamodb_seeds,
|
||||
http_api_proxy: HttpApiProxy,
|
||||
@@ -12,10 +12,7 @@ def test_me(
|
||||
):
|
||||
# This data was added from seeds
|
||||
r = mock_app.lambda_handler(
|
||||
http_api_proxy(
|
||||
raw_path='/me',
|
||||
method=HTTPMethod.GET,
|
||||
),
|
||||
http_api_proxy(raw_path='/settings', method=HTTPMethod.GET),
|
||||
lambda_context,
|
||||
)
|
||||
|
||||
@@ -37,7 +34,7 @@ def test_me(
|
||||
'roles': ['ADMIN'],
|
||||
},
|
||||
],
|
||||
'workspaces': [
|
||||
'tenants': [
|
||||
{
|
||||
'sk': 'cJtK9SsnJhKPyxESe7g3DG',
|
||||
'name': 'Beta Educação',
|
||||
@@ -5,8 +5,6 @@ from .conftest import LambdaContext
|
||||
|
||||
|
||||
def test_bearer_jwt(lambda_context: LambdaContext):
|
||||
import auth as app
|
||||
|
||||
# You should mock the Cognito user to pass the test
|
||||
app.get_user = lambda *args, **kwargs: {
|
||||
'sub': '58efed8d-d276-41a8-8502-4ab8b5a6415e',
|
||||
@@ -32,11 +30,7 @@ def test_bearer_jwt(lambda_context: LambdaContext):
|
||||
}
|
||||
|
||||
|
||||
def test_bearer_apikey(
|
||||
monkeypatch,
|
||||
dynamodb_seeds,
|
||||
lambda_context: LambdaContext,
|
||||
):
|
||||
def test_bearer_apikey(dynamodb_seeds, lambda_context: LambdaContext):
|
||||
event = {
|
||||
'headers': {
|
||||
'authorization': 'Bearer sk-MzI1MDQ0NTctZjEzMy00YzAwLTkzNmItNmFhNzEyY2E5ZjQw',
|
||||
|
||||
31
http-api/tests/test_middelwares.py
Normal file
31
http-api/tests/test_middelwares.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from http import HTTPMethod
|
||||
|
||||
from aws_lambda_powertools.event_handler.api_gateway import APIGatewayHttpResolver
|
||||
|
||||
from middlewares import TenantMiddleware
|
||||
|
||||
from .conftest import HttpApiProxy, LambdaContext
|
||||
|
||||
|
||||
def test_eval(
|
||||
dynamodb_seeds,
|
||||
http_api_proxy: HttpApiProxy,
|
||||
lambda_context: LambdaContext,
|
||||
):
|
||||
app = APIGatewayHttpResolver()
|
||||
app.use(middlewares=[TenantMiddleware()])
|
||||
|
||||
@app.get('/')
|
||||
def index():
|
||||
return {}
|
||||
|
||||
result = app(
|
||||
http_api_proxy(
|
||||
raw_path='/',
|
||||
method=HTTPMethod.GET,
|
||||
headers={'Tenant': 'cJtK9SsnJhKPyxESe7g3DG'},
|
||||
),
|
||||
lambda_context,
|
||||
)
|
||||
|
||||
assert result['statusCode'] == 200
|
||||
42
http-api/uv.lock
generated
42
http-api/uv.lock
generated
@@ -10,6 +10,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arnparse"
|
||||
version = "0.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bd/42/949284e998282b167e273872fa9c39b06d41a6055163c30aa2daaeee76a0/arnparse-0.0.2.tar.gz", hash = "sha256:cb87f17200d07121108a9085d4a09cc69a55582647776b9a917b0b1f279db8f8", size = 2677 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/57/6f/630bedeb32964e99661990811a66389201b62c047b35c17e332dad9be2a3/arnparse-0.0.2-py2.py3-none-any.whl", hash = "sha256:b0906734e4b8f19e39b1e32944c6cd6274b6da90c066a83882ac7a11d27553e0", size = 2904 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "attrs"
|
||||
version = "25.3.0"
|
||||
@@ -107,6 +116,20 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/5b/f96cf58c37704b907ac2f9cc94e45ba0a2aa3b2062421aa8b8614f1d78de/botocore-1.37.20-py3-none-any.whl", hash = "sha256:c34f4f25fda7c4f726adf5a948590bd6bd7892c05278d31e344b5908e7b43301", size = 13432464 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "camel-converter"
|
||||
version = "4.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ee/3d/dd783586dc0c4aee5b6b88489666fdb2c0c344ea0aa8a5c10746cc423707/camel_converter-4.0.1.tar.gz", hash = "sha256:401414549ae4ac4073e38cdc4aa6d464dc534fc40aa06ff787bf0960b0c86535", size = 38915 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/32/e5/806359514cc8305f047bd6d39d63890298c0596f7328b534059724bd1a9e/camel_converter-4.0.1-py3-none-any.whl", hash = "sha256:0cba7ca1354a29ca2191983deecc9dcf28889f606c28d6ed18ac7d4586b163ac", size = 6243 },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
pydantic = [
|
||||
{ name = "pydantic" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2025.1.31"
|
||||
@@ -444,15 +467,17 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "layercake"
|
||||
version = "0.1.11"
|
||||
version = "0.1.13"
|
||||
source = { directory = "../layercake" }
|
||||
dependencies = [
|
||||
{ name = "arnparse" },
|
||||
{ name = "aws-lambda-powertools", extra = ["all"] },
|
||||
{ name = "boto3" },
|
||||
{ name = "elasticsearch" },
|
||||
{ name = "elasticsearch-dsl" },
|
||||
{ name = "ftfy" },
|
||||
{ name = "glom" },
|
||||
{ name = "meilisearch" },
|
||||
{ name = "orjson" },
|
||||
{ name = "pycpfcnpj" },
|
||||
{ name = "pydantic", extra = ["email"] },
|
||||
@@ -464,12 +489,14 @@ dependencies = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "arnparse", specifier = ">=0.0.2" },
|
||||
{ name = "aws-lambda-powertools", extras = ["all"], specifier = ">=3.8.0" },
|
||||
{ name = "boto3", specifier = ">=1.37.16" },
|
||||
{ name = "elasticsearch", specifier = ">=8.17.2" },
|
||||
{ name = "elasticsearch-dsl", specifier = ">=8.17.1" },
|
||||
{ name = "ftfy", specifier = ">=6.3.1" },
|
||||
{ name = "glom", specifier = ">=24.11.0" },
|
||||
{ name = "meilisearch", specifier = ">=0.34.0" },
|
||||
{ name = "orjson", specifier = ">=3.10.15" },
|
||||
{ name = "pycpfcnpj", specifier = ">=1.8" },
|
||||
{ name = "pydantic", extras = ["email"], specifier = ">=2.10.6" },
|
||||
@@ -488,6 +515,19 @@ dev = [
|
||||
{ name = "ruff", specifier = ">=0.11.1" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "meilisearch"
|
||||
version = "0.34.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "camel-converter", extra = ["pydantic"] },
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/44/42/b6a62f355057521c0d9df44a402205e3037299fdcb9cee4dfa22eebd22f0/meilisearch-0.34.0.tar.gz", hash = "sha256:6244af23fa118f5a127ebf3f1297ea8d1d73324bf189b13d61cc201e18cd9e90", size = 23623 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/2f/264c07a3f488260ea36c78cbc201b76e6baf9ef92e0c7f78657a6a5e5f22/meilisearch-0.34.0-py3-none-any.whl", hash = "sha256:fae8ad2a15d12c27fa0a1fff2ae2e4e3e2e22b869950408d63c87e2c095a9f61", size = 24373 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "orjson"
|
||||
version = "3.10.16"
|
||||
|
||||
Reference in New Issue
Block a user