128 lines
6.0 KiB
Python
128 lines
6.0 KiB
Python
"""Local-only composition root. No production provider implementations/imports."""
|
|
import os
|
|
from typing import Protocol
|
|
from urllib.parse import urlparse
|
|
import boto3
|
|
from botocore.config import Config
|
|
from botocore.exceptions import ClientError
|
|
|
|
def require_local():
|
|
if os.environ.get('APP_ENV') != 'local':
|
|
raise RuntimeError('This runtime only supports APP_ENV=local')
|
|
for name in ('PAYMENT', 'FREIGHT', 'TINY', 'WHATSAPP'):
|
|
if os.environ.get(f'{name}_ADAPTER') != 'fake':
|
|
raise RuntimeError(f'{name} must use the fake adapter')
|
|
if os.environ.get('STORAGE_ADAPTER') != 's3-local':
|
|
raise RuntimeError('Only local S3 storage is supported')
|
|
for name in ('S3_ENDPOINT', 'S3_PUBLIC_ENDPOINT'):
|
|
endpoint = urlparse(os.environ[name])
|
|
if endpoint.scheme != 'http' or endpoint.hostname not in ('storage', 'localhost', '127.0.0.1'):
|
|
raise RuntimeError(f'{name} must point to local MinIO')
|
|
|
|
class PaymentAdapter(Protocol):
|
|
def pay(self, quote_id: str, total_cents: int) -> dict: ...
|
|
|
|
class FakePayment:
|
|
def pay(self, quote_id: str, total_cents: int) -> dict:
|
|
return {'provider': 'fake', 'id': f'local-{quote_id}',
|
|
'status': 'paid', 'total_cents': total_cents}
|
|
|
|
class FreightAdapter(Protocol):
|
|
def quote(self, service: str, postal_code: str) -> dict: ...
|
|
|
|
class FakeFreight:
|
|
def quote(self, service: str, postal_code: str) -> dict:
|
|
if service == 'pickup':
|
|
return {'provider': 'fake', 'service': 'pickup', 'total_cents': 0, 'postal_code': ''}
|
|
if service != 'mock-standard' or len(postal_code) != 8 or not postal_code.isascii() or not postal_code.isdigit():
|
|
raise ValueError('Select pickup or a mock quote with an eight-digit CEP')
|
|
cents = int(os.environ.get('MOCK_FREIGHT_CENTS', '1500'))
|
|
if cents < 0:
|
|
raise ValueError('Invalid mock freight configuration')
|
|
return {'provider': 'fake', 'service': service, 'postal_code': postal_code,
|
|
'total_cents': cents, 'description': 'Local simulated freight'}
|
|
|
|
class EventAdapter(Protocol):
|
|
def deliver(self, event_key: str, payload: dict) -> dict: ...
|
|
|
|
class FakeTiny:
|
|
def deliver(self, event_key: str, payload: dict) -> dict:
|
|
return {'provider': 'fake-tiny', 'reference': f"LOCAL-{payload['number']}",
|
|
'event_key': event_key, 'status': 'recorded-locally'}
|
|
|
|
class FakeWhatsApp:
|
|
def deliver(self, event_key: str, payload: dict) -> dict:
|
|
return {'provider': 'fake-whatsapp', 'event_key': event_key,
|
|
'event': payload['event'], 'status': 'recorded-locally'}
|
|
|
|
class ObjectStorage(Protocol):
|
|
def begin(self, key: str) -> str: ...
|
|
def parts(self, key: str, upload_id: str) -> list: ...
|
|
def part_url(self, key: str, upload_id: str, part: int, size: int) -> str: ...
|
|
def complete(self, key: str, upload_id: str, parts: list): ...
|
|
def size(self, key: str) -> int: ...
|
|
def download(self, key: str, name: str) -> str: ...
|
|
def health(self): ...
|
|
def discard(self, key: str, upload_id: str, complete: bool): ...
|
|
|
|
class LocalS3Storage:
|
|
def __init__(self):
|
|
config = Config(signature_version='s3v4', s3={'addressing_style': 'path'},
|
|
connect_timeout=3, read_timeout=10, retries={'max_attempts': 2})
|
|
self.client = boto3.client('s3', endpoint_url=os.environ['S3_ENDPOINT'], config=config)
|
|
self.public = boto3.client('s3', endpoint_url=os.environ['S3_PUBLIC_ENDPOINT'], config=config)
|
|
self.bucket = os.environ['S3_BUCKET']
|
|
|
|
def initialize(self):
|
|
try:
|
|
self.client.head_bucket(Bucket=self.bucket)
|
|
except ClientError as exc:
|
|
if exc.response['ResponseMetadata']['HTTPStatusCode'] != 404:
|
|
raise
|
|
self.client.create_bucket(Bucket=self.bucket)
|
|
self.client.put_bucket_lifecycle_configuration(Bucket=self.bucket, LifecycleConfiguration={
|
|
'Rules': [{'ID': 'local-artwork-retention', 'Status': 'Enabled', 'Filter': {'Prefix': ''},
|
|
'Expiration': {'Days': 30}, 'AbortIncompleteMultipartUpload': {'DaysAfterInitiation': 1}}]})
|
|
|
|
def health(self):
|
|
self.client.head_bucket(Bucket=self.bucket)
|
|
|
|
def begin(self, key):
|
|
return self.client.create_multipart_upload(Bucket=self.bucket, Key=key,
|
|
ContentType='application/octet-stream')['UploadId']
|
|
|
|
def parts(self, key, upload_id):
|
|
result = []
|
|
for page in self.client.get_paginator('list_parts').paginate(
|
|
Bucket=self.bucket, Key=key, UploadId=upload_id):
|
|
result.extend(page.get('Parts', []))
|
|
return result
|
|
|
|
def part_url(self, key, upload_id, part, size):
|
|
return self.public.generate_presigned_url('upload_part', Params={
|
|
'Bucket': self.bucket, 'Key': key, 'UploadId': upload_id, 'PartNumber': part,
|
|
'ContentLength': size}, ExpiresIn=900)
|
|
|
|
def complete(self, key, upload_id, parts):
|
|
self.client.complete_multipart_upload(Bucket=self.bucket, Key=key, UploadId=upload_id,
|
|
MultipartUpload={'Parts': [{'PartNumber': p['PartNumber'], 'ETag': p['ETag']} for p in parts]})
|
|
|
|
def size(self, key):
|
|
return self.client.head_object(Bucket=self.bucket, Key=key)['ContentLength']
|
|
|
|
def download(self, key, name):
|
|
from urllib.parse import quote
|
|
return self.public.generate_presigned_url('get_object', Params={
|
|
'Bucket': self.bucket, 'Key': key,
|
|
'ResponseContentDisposition': "attachment; filename*=UTF-8''" + quote(name, safe=''),
|
|
'ResponseContentType': 'application/octet-stream'}, ExpiresIn=300)
|
|
|
|
def discard(self, key, upload_id, complete):
|
|
if not complete:
|
|
try:
|
|
self.client.abort_multipart_upload(Bucket=self.bucket, Key=key, UploadId=upload_id)
|
|
except ClientError as exc:
|
|
if exc.response['ResponseMetadata']['HTTPStatusCode'] != 404:
|
|
raise
|
|
self.client.delete_object(Bucket=self.bucket, Key=key)
|