# s3

> Store and fetch objects in Amazon S3 and S3-compatible services such as R2 and MinIO, and hand out time-limited signed links.

This is the Markdown version of https://osl-lang.org/packages/s3. Every page of the OSL docs has one: add .md to its path. https://osl-lang.org/llms.txt lists them all.

```osl
import "std:s3"
```

S3 is object storage: a bucket holds objects, each found by a key. Amazon S3 started it, and Cloudflare R2, MinIO, Backblaze and others speak the same protocol. `std:s3` uploads, downloads and deletes objects, and makes presigned URLs that let someone upload or download one object without your credentials.

A client is made without touching the network, so creating one and signing a link runs here. Calls that move data are `norun`.

## Make a client

`s3.new(options)` returns a client for one bucket. `endpoint` and `bucket` are required. Credentials, region and a public URL are optional:

```osl
import "std:s3"

auto bucket = s3.new({
  endpoint: "https://s3.example.com",
  bucket: "photos",
  region: "eu-west-1",
  accessKeyId: "AKIAEXAMPLE",
  secretAccessKey: "secret"
})

log bucket.bucket()
log bucket.endpoint()
log bucket.region()
```

Output:

```text
photos
https://s3.example.com
eu-west-1
```

Leave out `endpoint` and run it: `s3.new` throws a `ValueError` that names the missing option, because a client that can't go anywhere is a mistake in your code. Read keys from [env](/packages/env.md).

## Public URLs

If the bucket is served from a CDN or a public domain, `publicURL` lets `client.public(key)` build the link people can open, escaping the key:

```osl
import "std:s3"

auto bucket = s3.new({
  endpoint: "https://s3.example.com",
  bucket: "photos",
  publicURL: "https://cdn.example.com"
})

log bucket.public("holiday/beach day.png")
```

Output:

```text
https://cdn.example.com/holiday/beach%20day.png
```

## Upload and download

`upload(key, data)` stores a string or bytes, `uploadFile(key, path)` stores a file, and `download(key)` returns bytes. `downloadFile(key, path)` saves one to disk. A missing object is a `NotFoundError`:

```osl
import "std:s3"
import "std:env"

auto bucket = s3.new({
  endpoint: "https://s3.example.com",
  bucket: "photos",
  accessKeyId: env.get("S3_KEY"),
  secretAccessKey: env.get("S3_SECRET")
})

bucket.upload("notes/hello.txt", "hello from OSL", {contentType: "text/plain"}).unwrap()
byte[] data = bucket.download("notes/hello.txt").unwrap()
log data.len

switch bucket.download("notes/missing.txt") (
  case ok(bytes)
    log "found"
  case err(e)
    log "no such object:" e.kind
)
```

`contentType` is stored with the object, so browsers treat it right. [mime](/packages/mime.md) can pick one from a file name.

## Delete

`delete(key)` removes an object. Deleting a key that doesn't exist succeeds, as it does in S3, so you don't need to check first:

```osl
import "std:s3"

auto bucket = s3.new({
  endpoint: "https://s3.example.com",
  bucket: "photos",
  accessKeyId: "...",
  secretAccessKey: "..."
})
bucket.delete("notes/hello.txt").unwrap()
bucket.delete("notes/hello.txt").unwrap()
```

## Presigned URLs

A presigned URL gives whoever holds it one kind of access to one object for a limited time, with no credentials shared. Hand a browser a URL to upload straight to the bucket, so a big file never passes through your server. `expires` is in milliseconds:

```osl
import "std:s3"

auto bucket = s3.new({
  endpoint: "https://s3.example.com",
  bucket: "photos",
  accessKeyId: "AKIAEXAMPLE",
  secretAccessKey: "secret"
})

string download = bucket.presignURL("a/b.png", {expires: 600000})
log download.startsWith("https://s3.example.com/photos/a/b.png?")
log download.contains("X-Amz-Signature=")
log download.contains("X-Amz-Expires=600")

string upload = bucket.presignURL("a/new.png", {method: "PUT", expires: 60000})
log upload.contains("X-Amz-Expires=60")
```

Output:

```text
true
true
true
true
```

The signature depends on the time, so the URL itself differs on every run. Try a different `expires` and see the number in the link change. The longest a link can live is seven days.

## Errors

Failures name their kind: `PermissionError` for bad credentials, `NetworkError` and `TimeoutError` for connection trouble, `NotFoundError` for a missing object. A service that is down shows up like this:

```osl
import "std:s3"

auto bucket = s3.new({
  endpoint: "http://127.0.0.1:1",
  bucket: "b",
  accessKeyId: "x",
  secretAccessKey: "y",
  timeout: 1000
})
log bucket.download("anything").unwrapErr().kind
```

Output:

```text
NetworkError
```

**Quiz.** Why use a presigned upload URL instead of uploading through your server?

- [ ] It is encrypted and a normal upload isn't.
- [x] The file goes straight to the bucket, and the browser never gets your credentials.
- [ ] It never expires.

Answer: The link grants one action on one key for a limited time.

### Exercise: A link that lasts a minute

Make a client for bucket `reports` at `https://s3.example.com` with credentials, and print whether a presigned download link for `q3.pdf` that expires in 60 seconds mentions `X-Amz-Expires=60`.

Starting code:

```osl
import "std:s3"

log false
```

It should print:

```text
true
```

Hint 1: `s3.new({endpoint: ..., bucket: "reports", accessKeyId: "a", secretAccessKey: "b"})`.

Hint 2: `presignURL("q3.pdf", {expires: 60000})` and `.contains(...)`.

A solution:

```osl
import "std:s3"

auto bucket = s3.new({
  endpoint: "https://s3.example.com",
  bucket: "reports",
  accessKeyId: "a",
  secretAccessKey: "b"
})
string link = bucket.presignURL("q3.pdf", {expires: 60000})
log link.contains("X-Amz-Expires=60")
```

## API reference

## API reference

Every member of `std:s3`, generated from the source of OSL.

### Functions

#### `s3.new(object? options) *s3.Client`

new returns a client for one bucket. Options: endpoint (required, the
service URL such as "https://s3.us-east-1.amazonaws.com" or
"https://<account>.r2.cloudflarestorage.com"), bucket (required), region
(default "us-east-1"; R2 uses "auto"), accessKeyId, secretAccessKey,
sessionToken (for temporary credentials), publicURL (base URL that
client.public builds on) and timeout (milliseconds per request, default
30000). Objects are addressed path-style, as endpoint/bucket/key. Throws
ValueError when endpoint or bucket is missing or endpoint isn't an http or
https URL.

### `*s3.Client`

#### `client.bucket() string`

bucket returns the bucket name.

#### `client.delete(string key) result<unit, error>`

delete removes the object key. Deleting a key that doesn't exist
succeeds, as it does in S3. Fails with PermissionError, NetworkError,
TimeoutError or RuntimeError like download.

#### `client.download(string key) result<byte[], error>`

download returns the contents of the object key. Fails with NotFoundError
when there is no such object, PermissionError when access is denied,
NetworkError or TimeoutError when the service can't be reached, and
RuntimeError for any other error response. Throws ValueError for an empty
key.

#### `client.downloadFile(string key, string path) result<unit, error>`

downloadFile saves the object key to path, replacing the file. Fails like
download, and with an IOError or PermissionError when path can't be
written; a partly written file is removed.

#### `client.endpoint() string`

endpoint returns the service URL the client was created with.

#### `client.get(object input) object`

**Deprecated.** Deprecated: use client.download(key) instead, which fails with NotFoundError for a missing object.

get fetches input.key and returns {ok, status, body, headers}, or
{ok: false, error} when the request couldn't be made.

#### `client.presign(object input) object`

**Deprecated.** Deprecated: use client.presignURL(key, options?) instead, which takes expires in milliseconds.

presign returns {ok: true, url} for input.key, with input.expires in
seconds, input.method and input.content_length, or {ok: false, error}.

#### `client.presignURL(string key, object? options) string`

presignURL returns a URL that grants whoever holds it one kind of access
to key without credentials. Options: method (default "GET"; "PUT" lets the
holder upload), expires (milliseconds the URL stays valid, default 3600000,
whole seconds from 1 second to 7 days) and contentLength (bytes; the
upload must then be exactly that size). Throws ValueError for an empty key
or an out-of-range option.

#### `client.public(string key) string`

public returns the public URL of key: publicURL/key when the client has a
publicURL option, otherwise endpoint/bucket/key. The URL is only useful
when the bucket allows public reads; use presignURL for private objects.

#### `client.put(object input) object`

**Deprecated.** Deprecated: use client.upload(key, data, options?) instead, which fails with a typed error.

put uploads input.body as input.key and returns {ok, status, body,
headers}, or {ok: false, error} when the request couldn't be made.

#### `client.region() string`

region returns the signing region, "us-east-1" unless set.

#### `client.remove(object input) object`

**Deprecated.** Deprecated: use client.delete(key) instead.

remove deletes input.key and returns {ok, status, body, headers}, or
{ok: false, error} when the request couldn't be made.

#### `client.upload(string key, any data, object? options) result<unit, error>`

upload stores data, a string or byte[], as the object key, replacing any
object already there. Options: contentType (default
"application/octet-stream"), contentEncoding (such as "gzip") and
cacheControl. Fails with PermissionError when the credentials are refused,
NetworkError or TimeoutError when the service can't be reached, and
RuntimeError for any other error response. Throws ValueError for an empty
key and TypeError for other data types.

#### `client.uploadFile(string key, string path, object? options) result<unit, error>`

uploadFile stores the file at path as the object key. It takes the same
options as upload, except that contentType defaults to the type its
extension suggests. Fails like upload, and with NotFoundError or
PermissionError when the local file can't be read.
