first commit
This commit is contained in:
127
local/adapters.py
Normal file
127
local/adapters.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user