GcpGcs

A Google Cloud Storage client for Elixir, built on the JSON API over Finch. Bucket and object management, simple and resumable uploads, and streamed downloads — with structured errors, token caching, and telemetry.

CIHex.pm

Features

Installation

def deps do
[
{:gcp_gcs, "~> 0.2.0"}
]
end

Quick start

Authenticate

# Development: use your own credentials
gcloud auth application-default login
# Production: a service account file (consumed via Goth)
export GOOGLE_APPLICATION_CREDENTIALS="/path/to/service-account.json"

Use it

project = "my-project"
# Create a bucket
{:ok, _bucket} = GcpGcs.create_bucket(project, "my-bucket", location: "US")
# Upload + download a small object
{:ok, _object} = GcpGcs.put_object("my-bucket", "hello.txt", "Hello!", content_type: "text/plain")
{:ok, "Hello!"} = GcpGcs.download("my-bucket", "hello.txt")
# Object metadata
{:ok, object} = GcpGcs.get_object("my-bucket", "hello.txt")
object["size"] #=> "6"
object["contentType"] #=> "text/plain"
# List objects (with pagination info)
{:ok, %{items: items, next_page_token: token}} = GcpGcs.list_objects("my-bucket")

Every call returns {:ok, result} / :ok, or {:error, %GcpGcs.Error{}}. Responses are decoded JSON resources — plain maps with string keys.

Uploads

# Simple (in-memory) upload
{:ok, _} = GcpGcs.put_object("my-bucket", "notes.txt", "some text")
# Resumable streamed upload of a local file (constant memory)
{:ok, _} = GcpGcs.upload_file("my-bucket", "video.mp4", "/path/to/video.mp4")
# Resumable upload from any enumerable of binaries
chunks = Stream.repeatedly(fn -> :crypto.strong_rand_bytes(1_000_000) end) |> Stream.take(10)
{:ok, _} = GcpGcs.upload_stream("my-bucket", "random.bin", chunks)
# Let the library pick the strategy
{:ok, _} = GcpGcs.upload("my-bucket", "a.txt", "inline data")
{:ok, _} = GcpGcs.upload("my-bucket", "b.bin", {:file, "/path/to/b.bin"})

Content type is guessed from the object's extension for upload_file/4; pass content_type: to override. Resumable chunk size defaults to 8 MiB and can be tuned with chunk_size: (rounded to a 256 KiB multiple).

Low-level resumable control

{:ok, session} = GcpGcs.start_resumable_upload("my-bucket", "big.bin", content_type: "application/octet-stream")
{:resume, 262144} = GcpGcs.upload_chunk(session, chunk1, offset: 0, total: nil)
{:ok, _object} = GcpGcs.upload_chunk(session, last_chunk, offset: 262144, total: 300000)

Downloads

# Whole object into memory
{:ok, bytes} = GcpGcs.download("my-bucket", "report.pdf")
# Stream straight to disk (constant memory)
:ok = GcpGcs.download_to_file("my-bucket", "report.pdf", "/tmp/report.pdf")
# Fold the body through your own reducer
{:ok, byte_count} =
GcpGcs.download_stream("my-bucket", "big.bin", [], 0, fn
{:data, chunk}, n -> n + byte_size(chunk)
_other, n -> n
end)

Listing a "directory"

{:ok, %{items: files, prefixes: subdirs}} =
GcpGcs.list_objects("my-bucket", prefix: "logs/2026/", delimiter: "/")

Copy, compose, rewrite, move

# Copy (same-location)
{:ok, _} = GcpGcs.copy_object("src-bucket", "a.txt", "dst-bucket", "a-copy.txt")
# Compose (concatenate up to 32 objects in one bucket)
{:ok, _} = GcpGcs.compose_object("my-bucket", "combined.log", ["part-1", "part-2", "part-3"])
# Rewrite (large / cross-location / storage-class change — follows rewrite tokens)
{:ok, _} = GcpGcs.rewrite_object("src", "big.bin", "dst", "big.bin")
# Move/rename (buckets with hierarchical namespace)
{:ok, _} = GcpGcs.move_object("my-bucket", "old/name.txt", "new/name.txt")

Error handling

case GcpGcs.get_object("my-bucket", "missing.txt") do
{:ok, object} ->
object
{:error, %GcpGcs.Error{code: :not_found}} ->
:missing
{:error, %GcpGcs.Error{code: :unauthenticated}} ->
{:error, :auth}
{:error, %GcpGcs.Error{} = err} ->
raise "GCS error: #{err}"
end

code is a stable atom mapped from the HTTP status: :not_found, :already_exists, :permission_denied, :unauthenticated, :invalid_argument, :failed_precondition, :resource_exhausted, :unavailable, :validation_error, :connection_error, … The original decoded body / exception is preserved in details.

Configuration

Production

No configuration required — requests go to storage.googleapis.com.

# Optional: authenticate through a Goth instance instead of the gcloud CLI
config :gcp_gcs, :goth, MyApp.Goth
# Optional: tune the Finch pool
config :gcp_gcs, :finch, pool_size: 50, pool_count: 1
# Optional: default request timeout (ms)
config :gcp_gcs, :default_timeout, 30_000

Development / test (emulator)

config :gcp_gcs, :emulator,
scheme: "http",
host: "localhost",
port: 4443

STORAGE_EMULATOR_HOST (used by Google's own client libraries) is also honored. When an emulator is configured, authentication is skipped.

Authentication

GcpGcs.Auth obtains OAuth2 access tokens, caching them in ETS:

  1. Goth — when config :gcp_gcs, :goth, MyApp.Goth is set
  2. gcloud CLIgcloud auth application-default print-access-token

Using Goth:

credentials = "GOOGLE_APPLICATION_CREDENTIALS_JSON" |> System.fetch_env!() |> JSON.decode!()
children = [
{Goth,
name: MyApp.Goth,
source:
{:service_account, credentials,
scopes: ["https://www.googleapis.com/auth/devstorage.read_write"]}}
]

Call GcpGcs.clear_auth_cache/0 to force a token refresh after rotating credentials.

Telemetry

:telemetry.attach(
"gcs-logger",
[:gcp_gcs, :request, :stop],
fn _event, %{duration: duration}, %{operation: op, result: result}, _config ->
ms = System.convert_time_unit(duration, :native, :millisecond)
require Logger
Logger.info("gcs #{op} -> #{inspect(result)} in #{ms}ms")
end,
nil
)

Testing

Unit tests run with no external services:

mix test

Integration tests run against a local emulator:

docker-compose up -d
mix test --include integration
docker-compose down

Why the JSON API (and not gRPC)?

Cloud Storage is a blob store: the dominant work is transferring object bytes, which maps naturally onto HTTP. Downloads stream as a response body and uploads use the documented resumable protocol — both with constant memory. The JSON API is the canonical, fully-featured Storage surface, and building on Finch keeps the dependency footprint small.

License

MIT — see LICENSE.