// Copyright (c) 2019-2026 Alexander Medvednikov. All rights reserved. // Use of this source code is governed by an MIT license // that can be found in the LICENSE file. module s3 import net.http // FetchOptions overlays an S3 endpoint over the `fetch` call. All fields // are optional; bucket and key are taken from the URL. @[params] pub struct FetchOptions { pub: method http.Method = .get body []u8 credentials Credentials // content_type, acl, etc. are forwarded as-is when method is PUT/POST. content_type string content_disposition string content_encoding string cache_control string acl Acl storage_class StorageClass request_payer bool range string hash_payload bool } // FetchResponse is the simplified return type of `fetch`. It's intentionally // flat (no streaming yet) — easier to consume than V's `http.Response` and // surfaces the most useful fields. pub struct FetchResponse { pub: status_code int body []u8 headers map[string]string etag string content_type string content_length i64 } // fetch is a `fetch('s3://bucket/key', { ... })`-style helper. // // Examples: // // resp := s3.fetch('s3://my-bucket/path/to/file.txt')! // resp := s3.fetch('s3://my-bucket/key', method: .put, body: 'hello'.bytes())! // resp := s3.fetch('s3://key', method: .get, credentials: s3.Credentials{ bucket: 'b', ... })! // // The URL must use the `s3://` scheme. Anything else is rejected outright // (avoids accidentally calling a real HTTP endpoint with S3 credentials). pub fn fetch(url string, opts FetchOptions) !FetchResponse { if !url.starts_with('s3://') { return new_error('InvalidURL', 's3.fetch only accepts s3:// URLs (got: ${redact_url(url)})') } bucket, key := parse_s3_url(url)! mut creds := if opts.credentials.access_key_id == '' { Credentials.from_env() } else { opts.credentials } if bucket != '' { creds = Credentials{ ...creds bucket: bucket } } creds.validate()! c := Client{ credentials: creds } match opts.method { .get { data := c.get(key, bucket: bucket range: opts.range request_payer: opts.request_payer )! return FetchResponse{ status_code: 200 body: data content_length: i64(data.len) } } .head { st := c.stat(key, bucket: bucket request_payer: opts.request_payer )! return FetchResponse{ status_code: 200 etag: st.etag content_type: st.content_type content_length: st.size } } .put { c.put(key, opts.body, bucket: bucket content_type: opts.content_type content_disposition: opts.content_disposition content_encoding: opts.content_encoding cache_control: opts.cache_control acl: opts.acl storage_class: opts.storage_class request_payer: opts.request_payer hash_payload: opts.hash_payload )! return FetchResponse{ status_code: 200 } } .delete { c.delete(key, bucket: bucket request_payer: opts.request_payer )! return FetchResponse{ status_code: 204 } } else { return new_error('InvalidMethod', 's3.fetch: unsupported method ${opts.method}') } } return new_error('InvalidMethod', 's3.fetch: unreachable') } // parse_s3_url splits `s3://bucket/key/with/slashes` into (bucket, key). // // Special case: `s3://key` (no second path component) returns ('', 'key') — // the caller is then expected to provide the bucket via credentials. pub fn parse_s3_url(url string) !(string, string) { if !url.starts_with('s3://') { return new_error('InvalidURL', 'Not an s3:// URL') } rest := url[5..] // strip 's3://' if rest == '' { return new_error('InvalidURL', 'Empty s3:// URL') } if i := rest.index('/') { bucket := rest[..i] key := rest[i + 1..] if key == '' { return new_error('InvalidURL', 'Empty key in s3:// URL') } return bucket, key } // no '/' separator — treat the whole rest as the key return '', rest } // redact_url strips query strings before logging. Used in error messages so // presigned-URL-style query params (which can contain credentials) aren't // leaked into logs. pub fn redact_url(url string) string { if i := url.index('?') { return url[..i] + '?' } return url }