simplecloud

package module
v0.0.14 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 15, 2026 License: MIT Imports: 25 Imported by: 2

README

simplecloud

A tiny Go package for reading and writing objects across different storage backends with a unified interface.

Installation

go get github.com/mtgban/simplecloud

Supported Backends

Backend Read Write Constructor
Local filesystem &FileBucket{}
HTTP/HTTPS NewHTTPBucket(client, baseURL)
Backblaze B2 NewB2Client(ctx, accessKey, secretKey, bucket)
Google Cloud Storage NewGCSClient(ctx, serviceAccountFile, bucket)
Amazon S3 NewS3Client(ctx, accessKey, secretKey, bucket, endpoint, region)

Usage

All backends implement the same interface:

type Reader interface {
    NewReader(context.Context, string) (io.ReadCloser, error)
}

type Writer interface {
    NewWriter(context.Context, string) (io.WriteCloser, error)
}
Reading from GCS
bucket, err := simplecloud.NewGCSClient(ctx, "service-account.json", "my-bucket")
if err != nil {
    log.Fatal(err)
}

reader, err := bucket.NewReader(ctx, "path/to/file.txt")
if err != nil {
    log.Fatal(err)
}
defer reader.Close()

data, err := io.ReadAll(reader)
Writing to B2
bucket, err := simplecloud.NewB2Client(ctx, accessKey, secretKey, "my-bucket")
if err != nil {
    log.Fatal(err)
}

writer, err := bucket.NewWriter(ctx, "path/to/file.txt")
if err != nil {
    log.Fatal(err)
}

_, err = writer.Write([]byte("hello world"))
if err != nil {
    writer.Close()
    log.Fatal(err)
}

if err := writer.Close(); err != nil {
    log.Fatal(err)  // important: Close() flushes to cloud storage
}
HTTP base paths

The base URL passed to NewHTTPBucket may include a path prefix, which is preserved on every request; the per-call path is joined onto it. For example, a base of https://host/v1 reading /data.json requests https://host/v1/data.json. Any credentials in the base URL are reused and redacted from error messages.

Transparent Compression

Use InitReader and InitWriter to automatically handle compressed files based on extension:

Extension Compression
.gz gzip
.bz2 bzip2
.xz xz/lzma
// Automatically decompresses .gz file
reader, err := simplecloud.InitReader(ctx, bucket, "data.json.gz")
if err != nil {
    log.Fatal(err)
}
defer reader.Close()
// reader yields decompressed data

// Automatically compresses to .xz
writer, err := simplecloud.InitWriter(ctx, bucket, "output.json.xz")
if err != nil {
    log.Fatal(err)
}
// writes are compressed before storage

Copying Between Backends

Copy files between any backends, with automatic compression/decompression:

src, _ := simplecloud.NewGCSClient(ctx, "sa.json", "source-bucket")
dst, _ := simplecloud.NewB2Client(ctx, key, secret, "dest-bucket")

// Copy and transcode: decompress gzip, recompress as xz
n, err := simplecloud.Copy(ctx, src, dst, "input.json.gz", "output.json.xz")
if err != nil {
    log.Fatal(err)
}
fmt.Printf("copied %d bytes\n", n)

If the transfer fails partway, Copy aborts the destination instead of closing it. This matters because Close() is what commits an object on every cloud backend — closing a failed transfer would publish a truncated object that later readers cannot distinguish from a good one. The FileBucket, B2Bucket, GCSBucket, and S3Bucket writers all support this via the Aborter interface; a custom destination that doesn't implement it is closed instead, and retains whatever was written.

Opening by URL

Open picks a backend from the path's scheme so callers don't have to construct one, applying transparent decompression via InitReader:

Scheme Backend
(none) Local filesystem
http, https HTTP(S)
b2 Backblaze B2 (host is the bucket name)
s3 Amazon S3 / S3-compatible (host is the bucket name)
gs Google Cloud Storage (host is the bucket name)

Backend-specific configuration — credentials, the HTTP client, endpoints, concurrency — is passed with functional options, so the package never sources credentials on its own:

r, err := simplecloud.Open(ctx, "b2://my-bucket/data/report.json.xz",
    simplecloud.WithB2Credentials(keyID, appKey),
    simplecloud.WithConcurrentDownloads(8),
)
if err != nil {
    log.Fatal(err)
}
defer r.Close()
// r yields decompressed data

// S3-compatible (e.g. Cloudflare R2):
r, err = simplecloud.Open(ctx, "s3://my-bucket/data/report.json.gz",
    simplecloud.WithS3Credentials(accessKey, secretKey),
    simplecloud.WithS3Endpoint("https://<account>.r2.cloudflarestorage.com"),
)

// A local path needs no options and no scheme:
r, err = simplecloud.Open(ctx, "/data/report.json.gz")

For any other scheme — or to control client lifecycle instead of the per-call client the s3 and gs schemes create — supply a resolver. It is consulted before the built-in schemes (so it can also override them); returning (nil, nil) falls through:

gcs, _ := simplecloud.NewGCSClient(ctx, "sa.json", "my-bucket") // closed by you
r, err := simplecloud.Open(ctx, "gs://my-bucket/data/report.json.gz",
    simplecloud.WithResolver(func(_ context.Context, scheme, host string) (simplecloud.Reader, error) {
        if scheme == "gs" {
            return gcs, nil
        }
        return nil, nil
    }),
)

Limitations

This is a lightweight helper, and some operations are not covered:

  • No Delete API
  • No retry logic or exponential backoff
  • No ACL or permission management
  • No multipart upload configuration
  • Context cancellation doesn't interrupt local file operations
  • Cloud clients aren't exposed for cleanup (create short-lived or manage externally)

For advanced use cases, use the underlying SDKs directly:

Listing

The three cloud backends implement the optional Lister interface. Listing is flat (no delimiter) and pages internally, so breaking out of the loop stops the requests:

bucket, err := simplecloud.NewB2Client(ctx, keyID, appKey, "my-bucket")
if err != nil {
    log.Fatal(err)
}

for obj, err := range bucket.List(ctx, "magic/") {
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(obj.Key, obj.Size, obj.LastModified)
}

FileBucket and HTTPBucket do not implement it — HTTP has no listing operation. Size is the stored (compressed) size, and LastModified means slightly different things per backend; see SPECIFICATIONS.md §10.

Further documentation

  • SPECIFICATIONS.md — the behaviour contract: path and key handling, compression, abort semantics, error wrapping, and what is explicitly not guaranteed.
  • AGENTS.md — for anyone changing this repo: the required checks, and the invariants that must not be broken (several exist because breaking them corrupted production data).
  • todo/ — known improvements that are deliberately not done yet, each with its reasoning and blockers.

License

MIT

Documentation

Overview

Package simplecloud provides a unified interface for reading and writing objects across different storage backends, including the local filesystem, HTTP, Backblaze B2, Google Cloud Storage, and Amazon S3.

All backends implement the Reader and/or Writer interfaces, which wrap the underlying SDK into a simple NewReader/NewWriter model. Transparent compression and decompression based on file extension is available via InitReader and InitWriter.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Copy added in v0.0.5

func Copy(ctx context.Context, src Reader, dst Writer, srcPath, dstPath string) (int64, error)

Copy reads from srcPath on src and writes to dstPath on dst, using InitReader and InitWriter so that compression and decompression are applied automatically based on the path extensions. This means formats can be transcoded in a single call — e.g. copying a .gz source to a .xz destination will decompress and recompress on the fly.

The returned count is the number of uncompressed bytes transferred between the reader and writer, not the number of bytes read from or written to storage.

If the transfer fails partway, the destination is aborted rather than closed, so no truncated object is published — Close is what commits on the cloud backends. Destinations that do not implement Aborter are closed instead, and so will retain whatever was written.

func InitReader

func InitReader(ctx context.Context, bucket Reader, path string) (io.ReadCloser, error)

InitReader opens path from bucket for reading, wrapping the stream in a decompressor when the path extension is recognised:

  • .gz — gzip
  • .bz2 — bzip2
  • .xz — xz/lzma

The path may be a full URL; only the path component is passed to the bucket. The caller must close the returned ReadCloser when done.

func InitWriter

func InitWriter(ctx context.Context, bucket Writer, path string) (io.WriteCloser, error)

InitWriter opens path on bucket for writing, wrapping the stream in a compressor when the path extension is recognised:

  • .gz — gzip
  • .bz2 — bzip2
  • .xz — xz/lzma

The path may be a full URL; only the path component is passed to the bucket. The caller must call Close on the returned WriteCloser when done; for cloud backends this is what commits the upload.

func Open added in v0.0.12

func Open(ctx context.Context, path string, opts ...OpenOption) (io.ReadCloser, error)

Open opens path for reading, selecting a backend from the URL scheme:

  • (no scheme) — local filesystem
  • http, https — HTTP(S); the scheme and host of path form the base URL
  • b2 — Backblaze B2; requires WithB2Credentials
  • s3 — Amazon S3 or S3-compatible; see WithS3Credentials/Endpoint/Region
  • gs — Google Cloud Storage; see WithGCSServiceAccount

For b2, s3, and gs the host is the bucket name. Any other scheme must be handled by a WithResolver resolver, which is also consulted first and so can override the built-ins. Compression is applied transparently from the path extension and any query string is dropped, exactly as in InitReader.

The scheme and host come from url.Parse. A path with no host, or one that url.Parse rejects (for example a local path containing a bare '%'), is treated as a local filesystem path. Because the original path is passed to InitReader, a '#' in an object key is preserved; a remote key containing a bare '%' is not addressable through Open, so percent-encode it or construct the backend directly.

The s3 and gs schemes construct a client per call and never close it — fine for short-lived use, but long-running callers should supply a WithResolver that returns a backend whose client they manage. The caller must close the returned ReadCloser.

Types

type Aborter added in v0.0.12

type Aborter interface {
	// Abort discards the write. It is called instead of, not in addition to,
	// Close.
	Abort() error
}

Aborter is implemented by writers that can discard an in-progress write instead of committing it.

On the cloud backends Close is what publishes an object, so closing a stream whose transfer failed would commit a truncated object. Copy calls Abort in that case. Writers that cannot abort are simply closed, which does commit whatever was written.

type B2Bucket

type B2Bucket struct {
	// Bucket is the underlying blazer bucket handle that reads and writes go
	// through.
	Bucket *b2.Bucket

	// ConcurrentDownloads controls how many parallel range requests are used
	// when downloading large objects. Zero uses the blazer library default.
	ConcurrentDownloads int
}

B2Bucket implements Reader and Writer for a Backblaze B2 bucket.

func NewB2Client

func NewB2Client(ctx context.Context, accessKey, secretKey, bucketName string) (*B2Bucket, error)

NewB2Client authenticates with Backblaze B2 using accessKey and secretKey, then opens the named bucket.

func (*B2Bucket) List added in v0.0.14

func (b *B2Bucket) List(ctx context.Context, prefix string) iter.Seq2[ObjectInfo, error]

List iterates over objects in the bucket whose key begins with prefix. The blazer iterator pages lazily, so abandoning it stops the requests.

Attrs is free here: the objects come back from the listing with their file info already populated, so reading them costs no extra request.

func (*B2Bucket) NewReader

func (b *B2Bucket) NewReader(ctx context.Context, path string) (io.ReadCloser, error)

NewReader opens the object at path in the bucket for reading. A leading slash is stripped: blazer interpolates the name straight into the object URL path, so "/foo" would create an object literally named "/foo".

func (*B2Bucket) NewWriter

func (b *B2Bucket) NewWriter(ctx context.Context, path string) (io.WriteCloser, error)

NewWriter opens the object at path in the bucket for writing. A leading slash is stripped (see NewReader). The caller must call Close when done; Close finalises the upload to B2. The returned writer implements Aborter: aborting cancels the write's context, and for uploads large enough to have switched to the large-file API it also issues b2_cancel_large_file so the uploaded parts are discarded rather than left billable.

type BucketResolver added in v0.0.12

type BucketResolver func(ctx context.Context, scheme, host string) (Reader, error)

BucketResolver constructs the backend for a URL's scheme and host (the host is the URL authority, typically the bucket name). Returning a non-nil Reader selects it; returning (nil, nil) falls through to Open's built-in schemes.

A resolver is the extension point for schemes Open does not handle natively, and for taking control of client lifecycle — e.g. returning a shared GCSBucket whose *storage.Client you close yourself, rather than the per-call client the built-in gs scheme creates and never closes.

type FileBucket

type FileBucket struct{}

FileBucket implements Reader and Writer against the local filesystem.

func (*FileBucket) NewReader

func (f *FileBucket) NewReader(ctx context.Context, path string) (io.ReadCloser, error)

NewReader opens the file at path for reading. The context is accepted to satisfy the Reader interface but is unused: local file reads are not cancellable.

func (*FileBucket) NewWriter added in v0.0.2

func (f *FileBucket) NewWriter(ctx context.Context, path string) (io.WriteCloser, error)

NewWriter creates or truncates the file at path for writing. Any missing parent directories are created automatically. The context is accepted to satisfy the Writer interface but is unused: local file writes are not cancellable. The returned writer implements Aborter.

type GCSBucket

type GCSBucket struct {
	// Bucket is the underlying GCS bucket handle that reads and writes go
	// through.
	Bucket *storage.BucketHandle
}

GCSBucket implements Reader and Writer for a Google Cloud Storage bucket.

func NewGCSClient

func NewGCSClient(ctx context.Context, serviceAccountFile, bucketName string) (*GCSBucket, error)

NewGCSClient creates a GCS client and opens the named bucket. If serviceAccountFile is non-empty it is used for authentication; otherwise Application Default Credentials are used, which works automatically in GKE, Cloud Run, and locally via `gcloud auth application-default login`. The underlying storage.Client is not exposed; callers that need to close it should construct one directly.

The file is declared to hold a service account rather than passed as an untyped credentials file, which is the non-deprecated form of the option: the untyped variant accepts any credential type, including externally sourced ones that name an executable to run. Note that the storage client resolves credentials through a path that does not currently act on the declared type, so this states the expectation rather than enforcing it.

func (*GCSBucket) List added in v0.0.14

func (g *GCSBucket) List(ctx context.Context, prefix string) iter.Seq2[ObjectInfo, error]

List iterates over objects in the bucket whose key begins with prefix. The underlying iterator pages lazily, so abandoning it stops the requests.

func (*GCSBucket) NewReader

func (g *GCSBucket) NewReader(ctx context.Context, path string) (io.ReadCloser, error)

NewReader opens the object at path in the bucket for reading. A leading slash is stripped so keys match the S3 and B2 backends; the GCS client would otherwise treat "/foo" as an object literally named "/foo".

func (*GCSBucket) NewWriter

func (g *GCSBucket) NewWriter(ctx context.Context, path string) (io.WriteCloser, error)

NewWriter opens the object at path in the bucket for writing. A leading slash is stripped (see NewReader). The caller must call Close when done; Close is what commits the object to GCS. The returned writer implements Aborter: aborting cancels the write's context, which is how the GCS client is told to discard a partial object instead of committing it.

Unlike S3 and B2, an abandoned GCS upload needs no server-side cleanup: only a completed resumable upload appears in the bucket, so partial data is never stored or billed, and the session expires on its own after a week.

type HTTPBucket

type HTTPBucket struct {
	// Client issues the GET requests; if nil at construction, NewHTTPBucket
	// substitutes http.DefaultClient.
	Client *http.Client
	// URL is the base URL whose scheme, host, credentials, and path are reused
	// for every request.
	URL *url.URL
}

HTTPBucket implements Reader for HTTP and HTTPS sources. It does not support writes; use a different backend for upload destinations.

func NewHTTPBucket

func NewHTTPBucket(client *http.Client, path string) (*HTTPBucket, error)

NewHTTPBucket constructs an HTTPBucket with the given base URL. The scheme, host, any credentials, and any base path are reused for every request; the per-call path is joined onto the base path in NewReader. If client is nil, http.DefaultClient is used.

func (*HTTPBucket) NewReader

func (h *HTTPBucket) NewReader(ctx context.Context, path string) (io.ReadCloser, error)

NewReader issues a GET request for path joined onto the bucket's base URL and returns the response body. A base path, if any, is preserved as a prefix (so a base of https://host/v1 and a path of /obj.gz requests /v1/obj.gz). Non-2xx responses are returned as an error with the URL redacted. The caller must close the returned ReadCloser when done.

type Lister added in v0.0.14

type Lister interface {
	// List iterates over every object whose key begins with prefix, in
	// whatever order the backend returns them. A leading slash on prefix is
	// stripped, and an empty prefix lists the whole bucket.
	//
	// Pagination is handled internally; the iterator fetches further pages as
	// it is consumed, so stopping early stops the requests. On failure the
	// iterator yields one final pair with a non-nil error and then ends, so a
	// caller must check the error on every iteration:
	//
	//	for obj, err := range bucket.List(ctx, "magic/") {
	//		if err != nil {
	//			return err
	//		}
	//		...
	//	}
	//
	// Listing is flat: there is no delimiter, so keys containing "/" are
	// returned in full rather than collapsed into common prefixes.
	List(ctx context.Context, prefix string) iter.Seq2[ObjectInfo, error]
}

Lister is implemented by backends that can enumerate objects.

It is optional, in the same way as Aborter: the local filesystem and HTTP backends do not implement it. Type-assert to reach it.

type ObjectInfo added in v0.0.14

type ObjectInfo struct {
	// Key is the object's full key, not relative to the listed prefix, and
	// carries no leading slash.
	Key string

	// Size is the stored size in bytes. For a compressed object this is the
	// compressed size, not the size InitReader will yield.
	Size int64

	// LastModified is the object's modification time. The backends do not all
	// mean the same thing by it: S3 and GCS always report a server-side
	// timestamp, while B2 only records one when the uploader supplied it and
	// otherwise falls back to the upload timestamp. Treat it as "roughly when
	// this object appeared", not as a value to compare across backends.
	LastModified time.Time
}

ObjectInfo describes a single object returned by a List.

type OpenOption added in v0.0.12

type OpenOption func(*openOptions)

OpenOption configures Open. Options are applied in order; a later option of the same kind overrides an earlier one.

func WithB2Credentials added in v0.0.12

func WithB2Credentials(keyID, appKey string) OpenOption

WithB2Credentials sets the Backblaze B2 application key id and key used to authenticate b2 paths. They are required for the b2 scheme; the library does not source credentials from the environment on its own.

func WithConcurrentDownloads added in v0.0.12

func WithConcurrentDownloads(n int) OpenOption

WithConcurrentDownloads sets the number of parallel range requests used for b2 downloads. Zero (the default) uses the blazer library default.

func WithGCSServiceAccount added in v0.0.12

func WithGCSServiceAccount(path string) OpenOption

WithGCSServiceAccount sets the service-account JSON file used for gs paths. Empty uses Application Default Credentials.

func WithHTTPClient added in v0.0.12

func WithHTTPClient(client *http.Client) OpenOption

WithHTTPClient sets the client used for http and https paths. When unset (or set to nil) http.DefaultClient is used.

func WithResolver added in v0.0.12

func WithResolver(r BucketResolver) OpenOption

WithResolver registers a resolver consulted before the built-in schemes, so it can add new schemes or override built-in ones. Returning (nil, nil) from the resolver falls through to the built-ins.

func WithS3Credentials added in v0.0.12

func WithS3Credentials(accessKey, secretKey string) OpenOption

WithS3Credentials sets the access key and secret key used for s3 paths. If left empty, the default AWS credential chain (environment, shared config, instance role, …) is used.

func WithS3Endpoint added in v0.0.12

func WithS3Endpoint(endpoint string) OpenOption

WithS3Endpoint targets an S3-compatible endpoint (e.g. Cloudflare R2, MinIO) for s3 paths; path-style addressing is enabled automatically when set.

func WithS3Region added in v0.0.12

func WithS3Region(region string) OpenOption

WithS3Region sets the region for s3 paths. Empty defaults to "auto".

type ReadWriter

type ReadWriter interface {
	Reader
	Writer
}

ReadWriter is implemented by backends that support both reads and writes.

type Reader

type Reader interface {
	// NewReader opens the object at path for reading. The caller must close
	// the returned ReadCloser when done.
	NewReader(context.Context, string) (io.ReadCloser, error)
}

Reader is implemented by any storage backend that supports object reads.

type S3Bucket added in v0.0.6

type S3Bucket struct {

	// Bucket is the name of the target S3 bucket.
	Bucket string
	// contains filtered or unexported fields
}

S3Bucket implements Reader and Writer for an Amazon S3 bucket (or any S3-compatible object store).

func NewS3Client added in v0.0.6

func NewS3Client(ctx context.Context, accessKey, secretKey, bucketName, endpoint, region string) (*S3Bucket, error)

NewS3Client creates an S3 client for the named bucket. accessKey and secretKey are optional; if both are empty, the default AWS credential chain is used. endpoint may be set to target S3-compatible stores (e.g. Cloudflare R2, MinIO); path-style addressing is enabled automatically when an endpoint is provided. region defaults to "auto" if empty.

func (*S3Bucket) List added in v0.0.14

func (s *S3Bucket) List(ctx context.Context, prefix string) iter.Seq2[ObjectInfo, error]

List iterates over objects in the bucket whose key begins with prefix. The paginator is advanced lazily, so abandoning the iterator stops the requests.

func (*S3Bucket) NewReader added in v0.0.6

func (s *S3Bucket) NewReader(ctx context.Context, path string) (io.ReadCloser, error)

NewReader opens the object at path in the bucket for reading. A leading slash is stripped: aws-sdk-go-v2 does not canonicalise keys, and reads with leading/double slashes 404 (aws/aws-sdk-go#2559).

func (*S3Bucket) NewWriter added in v0.0.6

func (s *S3Bucket) NewWriter(ctx context.Context, path string) (io.WriteCloser, error)

NewWriter opens the object at path in the bucket for writing using a background goroutine and an io.Pipe so that data is streamed to S3 without buffering the entire payload in memory. A leading slash is stripped: aws-sdk-go-v2 Upload/PutObject silently succeeds without storing anything when the key begins with a slash (aws/aws-sdk-go-v2#1701), so the strip prevents silent data loss. The caller must call Close when done; Close blocks until the upload completes and returns any upload error.

type Writer

type Writer interface {
	// NewWriter opens the object at path for writing. The caller must call
	// Close when done; for cloud backends, Close is what commits the upload.
	NewWriter(context.Context, string) (io.WriteCloser, error)
}

Writer is implemented by any storage backend that supports object writes.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL