Packages

s3

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

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:

main.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()
Click the code to edit it

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.

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:

main.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")
Click the code to edit it

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:

main.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
)
Click the code to edit it

contentType is stored with the object, so browsers treat it right. mime 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:

main.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()
Click the code to edit it

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:

main.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")
Click the code to edit it

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:

main.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
Click the code to edit it
Quick check

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

Your turn

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.

main.osl
Ctrl Enter runs
Expected output
true

API reference#

15 members, generated from the std:s3 source of OSL 9.3.0.

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.