Metadata-Version: 2.5
Name: af-downloads-client
Version: 0.1.0
Summary: Thin client for registering and revoking allfly-downloads links
Author-email: Allfly <engineering@allfly.io>
License-Expression: MIT
Requires-Python: <4.0,>=3.13
Requires-Dist: boto3<2.0,>=1.40
Requires-Dist: pydantic<3.0,>=2.9
Description-Content-Type: text/markdown

# af-downloads-client

Thin client for **allfly-downloads**, the service that gives out stable download links (`https://downloads.quest.allfly.io/{token}`) for files in our S3 buckets instead of raw presigned URLs. The service creates a short-lived presigned URL only when someone opens the link.

This package holds only the contract (request/response models and errors) and the client. All link logic (tokens, allowlist, expiry, presigning, landing pages) lives in the `allfly-downloads` repo. For now the client invokes the links Lambda directly; moving to HTTP later is a version bump, not an API change.

## Installation

```bash
uv add af-downloads-client
# or
pip install af-downloads-client
```

## Quickstart

```python
from allfly.downloads.client import AllflyDownloads, LinkMode, RegisterDownloadRequest

downloads = AllflyDownloads()

# Export email: landing page link that expires
link = downloads.register_download(
    RegisterDownloadRequest(
        bucket="staging-jobs-system-assets",
        key=f"exports/{job_id}.xlsx",
        mode=LinkMode.landing,
        source=f"jobs:{job_id}",
        file_name="Report.xlsx",
    )
)
link.url         # https://downloads.quest.allfly.io/{token}
link.expires_at  # datetime, or None for links that never expire

# Intercom screenshot: inline link usable as <img src>, no expiry
image = downloads.register_download(
    RegisterDownloadRequest(
        bucket=bucket,
        key=key,
        mode=LinkMode.inline,
        source=f"request:{request_id}",
    )
)

# Revoke one link, or every link for a source
downloads.revoke(token=link.token)
downloads.revoke(source=f"request:{request_id}")  # returns how many were revoked
```

`register_download` is idempotent per `source` + `key`: a retry returns the existing active link.

## Link modes

| Mode | Use | Opening the link |
|---|---|---|
| `landing` | Export emails | Landing page first (safe for mail scanners), then the download as an attachment |
| `inline` | Images embedded elsewhere (Intercom) | Redirects straight to the file, served inline |

The mode decides the lifetime: `landing` links expire after the service's configured default, `inline` links never expire. There's no per-link override.

## Request fields

| Field | Required | Description |
|---|---|---|
| `bucket`, `key` | yes | The S3 object. Must be on the service allowlist. |
| `mode` | yes | `landing` or `inline` |
| `source` | yes | Producer reference, e.g. `jobs:{job_id}` or `request:{request_id}`. Used to revoke and to dedupe. |
| `file_name` | no | Download name and landing page title for `landing` links. Defaults to the key's last segment. Not needed for `inline`. |

There's no content type field: S3 serves the `Content-Type` stored on the object, so set it when you upload.

## Configuration

| Setting | Default | Description |
|---|---|---|
| `function_name` argument | `allfly-downloads-links` | Links Lambda name (the same in every account) |
| `lambda_client` argument | `boto3.client("lambda")` | Any boto3-compatible Lambda client; created lazily |

Credentials come from the default boto3 chain (the ECS task role in our services). The caller's role must be allowed to invoke the links Lambda; that permission is managed in the `allfly-downloads` repo, not by consumers.

## Errors

All errors derive from `DownloadsError`.

| Exception | When |
|---|---|
| `LinkNotAllowedError` | The bucket/key is outside the service allowlist (`not_allowed`) |
| `InvalidDownloadRequestError` | The service rejected the request (`invalid_request`) |
| `DownloadsError` | The Lambda crashed, was throttled or unreachable, or replied with something unexpected |

`revoke()` raises `ValueError` unless exactly one of `token` or `source` is given.

## Wire format

Requests to the links Lambda:

```json
{"action": "register", "bucket": "...", "key": "...", "mode": "landing", "source": "jobs:123", "file_name": "Report.xlsx"}
{"action": "revoke", "token": "..."}
{"action": "revoke", "source": "request:9"}
```

Replies:

```json
{"ok": true, "data": {"token": "...", "url": "...", "expires_at": "2026-11-02T10:00:00Z"}}
{"ok": true, "data": {"revoked": 1}}
{"ok": false, "error": {"code": "not_allowed", "message": "..."}}
```

Response models ignore unknown fields, so the service can add fields without breaking older clients.

## Testing consumers

Mock the client in your tests:

```python
from unittest.mock import MagicMock

from allfly.downloads.client import AllflyDownloads, RegisterDownloadResponse

downloads = MagicMock(spec=AllflyDownloads)
downloads.register_download.return_value = RegisterDownloadResponse(token="t", url="https://downloads.example/t")
```

## Development

```bash
cd af-downloads-client
uv sync
uv run pytest
```
