Upload & download
This page covers writing and reading object bytes. It walks PutObject (buffered and streaming), GetObject (whole-object and streaming, ranges, versions, response overrides), and HeadObject.
The examples lead with the native client and show the S3 client right after, behind the surface toggle. Two clients write to the same encrypted, per-tenant store:
- the native client uses an auto-managed JSON bearer token (
NativeClient/lockwellnative/LockwellNativeClient); - the S3 client signs every request with SigV4 (
Client/lockwellsdk/LockwellClient).
For the operation matrix at a glance, see the native data-plane reference and the S3 operations reference. Copy, list, delete, and conditional writes have their own pages, linked at the bottom.
Constructing a client
The native client takes an endpoint plus an access-key id and secret, then mints and refreshes its own bearer token (single-flight, thread-safe). The S3 client takes the same credentials and signs each request directly.
import { NativeClient, Client } from "@kelphect/sdk";
// Native JSON data plane (token managed for you):
const native = new NativeClient({
endpoint: "https://objects.example.com", // public listener; /api/v1 is added for you
accessKeyId: process.env.LOCKWELL_ACCESS_KEY_ID,
secretKey: process.env.LOCKWELL_SECRET_KEY,
});
// S3 data plane (SigV4):
const s3 = new Client({
endpoint: "https://objects.example.com",
accessKeyId: process.env.LOCKWELL_ACCESS_KEY_ID,
secretKey: process.env.LOCKWELL_SECRET_KEY,
});import (
"github.com/lockwell/lockwell/pkg/lockwellnative"
"github.com/lockwell/lockwell/pkg/lockwellsdk"
)
native, err := lockwellnative.New(
"https://objects.example.com",
os.Getenv("LOCKWELL_ACCESS_KEY_ID"),
os.Getenv("LOCKWELL_SECRET_KEY"),
)
s3, err := lockwellsdk.New(
"https://objects.example.com",
lockwellsdk.Credentials{
AccessKeyID: os.Getenv("LOCKWELL_ACCESS_KEY_ID"),
SecretKey: os.Getenv("LOCKWELL_SECRET_KEY"),
},
)import com.lockwell.sdk.nativeapi.LockwellNativeClient;
import com.lockwell.sdk.LockwellClient;
import com.lockwell.sdk.Credentials;
var native = LockwellNativeClient.builder()
.endpoint("https://objects.example.com")
.accessKeyId(System.getenv("LOCKWELL_ACCESS_KEY_ID"))
.secretKey(System.getenv("LOCKWELL_SECRET_KEY"))
.build();
var s3 = LockwellClient.builder()
.endpoint("https://objects.example.com")
.credentials(new Credentials(System.getenv("LOCKWELL_ACCESS_KEY_ID"),
System.getenv("LOCKWELL_SECRET_KEY")))
.build();The rest of this page uses native for the native client and s3 for the S3 client.
Put an object (buffered)
Hand the SDK a byte buffer and it does one request. The native client returns the stored object's ETag and, on a versioned bucket, a version id.
const put = await native.putObject("reports", "q1/summary.txt", "hello world", {
contentType: "text/plain",
metadata: { author: "finance", quarter: "Q1" },
});
console.log(put.etag, put.versionId);put, err := native.PutObject(ctx, lockwellnative.PutObjectInput{
Bucket: "reports",
Key: "q1/summary.txt",
Body: strings.NewReader("hello world"),
ContentType: "text/plain",
Metadata: map[string]string{"author": "finance", "quarter": "Q1"},
})
fmt.Println(put.ETag, put.VersionID)import com.lockwell.sdk.nativeapi.NativeTypes.PutOptions;
var put = native.putObject("reports", "q1/summary.txt", "hello world".getBytes(),
new PutOptions()
.contentType("text/plain")
.metadata("author", "finance")
.metadata("quarter", "Q1"));
System.out.println(put.etag() + " " + put.versionId());The same with the S3 client:
const put = await s3.putObject("reports", "q1/summary.txt", Buffer.from("hello world"), {
contentType: "text/plain",
metadata: { author: "finance", quarter: "Q1" },
});
console.log(put.etag, put.versionId);put, err := s3.PutObject(ctx, "reports", "q1/summary.txt", []byte("hello world"),
lockwellsdk.WithContentType("text/plain"),
lockwellsdk.WithMetadata(map[string]string{"author": "finance", "quarter": "Q1"}),
)
fmt.Println(put.ETag, put.VersionID)var put = s3.putObject("reports", "q1/summary.txt", "hello world".getBytes(),
new LockwellClient.PutOptions()
.contentType("text/plain")
.metadata("author", "finance")
.metadata("quarter", "Q1"));
System.out.println(put.etag() + " " + put.versionId());User metadata is stored alongside the object and returned on every read. On the native path it travels as X-Lockwell-Meta-* headers; on the S3 path as x-amz-meta-*. Both SDKs surface it as a plain key-value map (the prefix is stripped for you).
PutObject options
| Option | Native (Go / Node / Java) | S3 (Go / Node / Java) | Effect |
|---|---|---|---|
| Content type | ContentType / contentType / .contentType | WithContentType / contentType / .contentType | Sets the stored media type. |
| User metadata | Metadata / metadata / .metadata | WithMetadata / metadata / .metadata | Arbitrary key-value pairs. |
| Idempotency key | IdempotencyKey / idempotencyKey / .idempotencyKey | WithIdempotencyKey / idempotencyKey / .idempotencyKey | Makes a retried write replay the stored result. See conditional writes. |
| Checksum | Checksums / checksums / .checksum | WithChecksumAlgorithm / checksumAlgorithm / .checksum | Server verifies and persists an end-to-end digest. See checksums. |
| Create-only | IfNoneMatch:"*" / ifNoneMatch:'*' / .ifAbsent() | (use native or copy) | Write only when the key is absent. See conditional writes. |
| Overwrite-only | IfMatch / ifMatch / .ifMatch | (use native or copy) | Write only when the current ETag matches. See conditional writes. |
| SSE-S3 | (always on at rest) | WithServerSideEncryption / serverSideEncryption / .serverSideEncryption | Requests server-managed encryption at rest. |
| Object Lock at write | set with setObjectRetention after write | WithObjectLock* headers via PutOptions | Apply retention or a legal hold. See object lock. |
Conditional create and overwrite are native-client features. The native putObject takes If-None-Match / If-Match directly. The S3 PutObject does not, so for an existing object reach for a conditional copy. Lockwell never issues presigned writes on the S3 client; for a browser-direct upload use a native signed URL.
The S3 PutObject has no conditional headers by design. Use the native putObject for create-only or
overwrite-only writes, or a conditional copy for an existing key. :::
SSE-S3 at rest (S3 client)
The native store is always encrypted at rest, so the native client has no SSE option. On the S3 client WithServerSideEncryption / serverSideEncryption: true / .serverSideEncryption() asks for SSE-S3: a server-managed, per-tenant key encrypts the object at rest. The response echoes serverSideEncryption: "AES256". SSE-KMS and SSE-C are non-goals and are not exposed by any SDK.
const put = await s3.putObject("vault", "ledger.json", Buffer.from(data), {
serverSideEncryption: true,
});
console.log(put.serverSideEncryption); // "AES256"put, err := s3.PutObject(ctx, "vault", "ledger.json", data,
lockwellsdk.WithServerSideEncryption())
fmt.Println(put.ServerSideEncryption) // "AES256"var put = s3.putObject("vault", "ledger.json", data,
new LockwellClient.PutOptions().serverSideEncryption());
System.out.println(put.serverSideEncryption()); // "AES256"::::
Put an object (streaming)
Hand the SDK a reader and the body goes to the server without ever sitting whole in memory. Reach for this when a file is larger than you want to buffer.
The native client streams the raw bytes. A streaming body cannot be replayed, so the client mints a fresh token before it starts streaming and a token-expiry 401 retry is never needed. A 401 from a genuinely revoked key still surfaces.
import { createReadStream } from "node:fs";
import { Readable } from "node:stream";
// A ReadableStream / async-iterable body streams without buffering:
const file = Readable.toWeb(createReadStream("./big.bin"));
await native.putObject("reports", "big.bin", file, {
contentType: "application/octet-stream",
contentLength: 1_048_576, // set so the server enforces the size cap + quota up front
});f, _ := os.Open("./big.bin")
defer f.Close()
info, _ := f.Stat()
_, err := native.PutObject(ctx, lockwellnative.PutObjectInput{
Bucket: "reports",
Key: "big.bin",
Body: f,
ContentType: "application/octet-stream",
ContentLength: info.Size(), // lets the server enforce the size cap + quota up front
})import java.io.FileInputStream;
// An InputStream supplier streams the body:
native.putObject("reports", "big.bin",
() -> {
try { return new FileInputStream("./big.bin"); }
catch (Exception e) { throw new RuntimeException(e); }
},
new PutOptions().contentType("application/octet-stream"));Set contentLength (native) where you know the size up front: it lets the server enforce the per-object size cap and the tenant quota before it accepts a single byte, and it avoids chunked transfer encoding. For very large or resumable uploads, use multipart upload instead.
The S3 client wraps the stream as an aws-chunked body with an end-to-end checksum trailer, so a checksum algorithm is required on putObjectStream. An idempotency key is not supported for the S3 stream (the trailer is not known at reservation time); use the buffered PutObject when you need idempotency.
import { createReadStream } from "node:fs";
// Checksum required; sent in an aws-chunked trailer:
await s3.putObjectStream("reports", "big.bin", createReadStream("./big.bin"), "CRC64NVME", {
contentType: "application/octet-stream",
});g, _ := os.Open("./big.bin")
defer g.Close()
info, _ := g.Stat()
_, err := s3.PutObjectStream(ctx, "reports", "big.bin", g, info.Size(),
lockwellsdk.ChecksumCRC64NVME,
lockwellsdk.WithContentType("application/octet-stream"),
)import java.nio.file.Files;
import java.nio.file.Path;
try (var in = Files.newInputStream(Path.of("big.bin"))) {
s3.putObjectStream("reports", "big.bin", in, "CRC64NVME",
new LockwellClient.PutOptions().contentType("application/octet-stream"));
}Get an object
GetObject streams the body. On both clients you own the body and must close it, and the result carries the object's metadata: content type, length, ETag, version id, and any checksums.
// Streaming download (no whole-object buffering):
const obj = await native.getObjectStream("reports", "big.bin");
console.log(obj.contentType, obj.contentLength, obj.etag);
for await (const chunk of obj.body) {
/* ...process chunk... */
}
// Buffered convenience (small objects):
const small = await native.getObject("reports", "q1/summary.txt");
console.log(small.body.toString("utf8"), small.metadata);// Streaming; close the reader:
obj, err := native.GetObject(ctx, lockwellnative.GetObjectInput{Bucket: "reports", Key: "big.bin"})
if err != nil {
return err
}
defer obj.Close()
fmt.Println(obj.ContentType, obj.ContentLength, obj.ETag)
io.Copy(dst, obj)import com.lockwell.sdk.nativeapi.NativeTypes.GetResult;
// Streaming InputStream; try-with-resources closes it:
try (GetResult obj = native.getObject("reports", "big.bin")) {
System.out.println(obj.contentType() + " " + obj.contentLength());
obj.body().transferTo(out);
}The same with the S3 client:
const s3obj = await s3.getObjectStream("reports", "big.bin");
const all = await s3obj.readAll(); // or iterate s3obj.bodyout, err := s3.GetObject(ctx, "reports", "big.bin")
if err != nil {
if lockwellsdk.IsNotFound(err) { /* ... */ }
return err
}
defer out.Body.Close()
io.Copy(dst, out.Body)
fmt.Println(out.ContentType, out.ContentLength, out.Metadata)import java.util.Map;
// Streaming:
try (var got = s3.getObjectStream("reports", "big.bin", Map.of())) {
got.body().transferTo(out);
}
// Buffered (small objects):
var small = s3.getObject("reports", "q1/summary.txt", Map.of());
System.out.println(new String(small.body()) + " " + small.metadata());Byte ranges
Pass a range to fetch part of an object. The server replies 206 Partial Content with a Content-Range. The native client takes the raw HTTP Range value; the S3 client takes a typed range (start, end inclusive, end < 0 means "to end").
const part = await native.getObjectStream("reports", "big.bin", { range: "bytes=0-1023" });
console.log(part.contentRange);part, _ := native.GetObject(ctx, lockwellnative.GetObjectInput{
Bucket: "reports", Key: "big.bin", Range: "bytes=0-1023",
})
defer part.Close()
fmt.Println(part.ContentRange)// range + optional version:
try (var part = native.getObject("reports", "big.bin", "bytes=0-1023", null)) {
System.out.println(part.contentRange());
}const s3part = await s3.getObjectStream("reports", "big.bin", { range: "bytes=0-1023" });// Typed range, inclusive:
out, _ := s3.GetObject(ctx, "reports", "big.bin", lockwellsdk.WithRange(0, 1023))
defer out.Body.Close()
fmt.Println(out.ContentRange)// Range passed in the query/header map:
try (var part = s3.getObjectStream("reports", "big.bin", Map.of("Range", "bytes=0-1023"))) { /* ... */ }Reading a specific version
On a versioned bucket, pass a versionId to read a past version rather than the current one.
const old = await native.getObject("reports", "q1/summary.txt", { versionId });old, _ := native.GetObject(ctx, lockwellnative.GetObjectInput{
Bucket: "reports", Key: "q1/summary.txt", VersionID: versionID,
})// range + versionId:
try (var old = native.getObject("reports", "q1/summary.txt", null, versionId)) { /* ... */ }const s3old = await s3.getObject("reports", "q1/summary.txt", { versionId });out, _ := s3.GetObject(ctx, "reports", "q1/summary.txt", lockwellsdk.WithVersionID(versionID))// versionId in the query map:
var s3old = s3.getObject("reports", "q1/summary.txt", Map.of("versionId", versionId));Response-header overrides (S3 client)
The S3 GetObject can override the headers the server returns for this one read, so you can force a download filename or a content type without rewriting the object. These are the standard S3 response-* query overrides.
const dl = await s3.getObjectStream("reports", "q1.csv", {
responseContentType: "text/csv",
});out, _ := s3.GetObject(ctx, "reports", "q1.csv",
lockwellsdk.WithResponseContentType("text/csv"),
lockwellsdk.WithResponseContentDisposition(`attachment; filename="q1.csv"`),
)var dl = s3.getObject("reports", "q1.csv", Map.of(
"response-content-type", "text/csv",
"response-content-disposition", "attachment; filename=\"q1.csv\""));Reading one multipart part (S3 client)
WithPartNumber(n) on the S3 GetObject returns the byte range of a single multipart part (1-based) plus the total part count in PartsCount. This lets a downloader fetch the object part by part with the same boundaries it was uploaded with.
out, _ := s3.GetObject(ctx, "reports", "big.bin", lockwellsdk.WithPartNumber(1))
fmt.Println(out.PartsCount) // total partsHead an object
HeadObject returns the same metadata as a read with no body: content type, length, ETag, version id, checksums, and encryption status. A missing object is a not-found error on both clients.
import { isNativeNotFound } from "@kelphect/sdk";
try {
const head = await native.headObject("reports", "q1/summary.txt");
console.log(head.contentLength, head.etag, head.metadata);
} catch (err) {
if (isNativeNotFound(err)) {
/* not there */
} else throw err;
}info, err := native.HeadObject(ctx, lockwellnative.GetObjectInput{Bucket: "reports", Key: "q1/summary.txt"})
if err != nil {
if lockwellnative.IsNotFound(err) { /* not there */ }
return err
}
fmt.Println(info.ContentLength, info.ETag)var head = native.headObject("reports", "q1/summary.txt");
System.out.println(head.contentLength() + " " + head.etag());import { isNotFound } from "@kelphect/sdk";
try {
const head = await s3.headObject("reports", "q1/summary.txt");
console.log(head.contentLength, head.etag, head.metadata);
} catch (err) {
if (isNotFound(err)) {
/* not there */
} else throw err;
}info, err := s3.HeadObject(ctx, "reports", "q1/summary.txt")
if err != nil {
if lockwellsdk.IsNotFound(err) { /* not there */ }
return err
}
fmt.Println(info.ContentLength, info.ETag, info.Metadata)// Returns a GetResult with an empty body:
var s3head = s3.headObject("reports", "q1/summary.txt");
System.out.println(s3head.contentLength() + " " + s3head.etag());Errors
Both clients raise a structured error carrying a stable code, the HTTP status, and a request id you can correlate with an audit row.
| Status | Meaning | Native guard | S3 guard |
|---|---|---|---|
| 404 | no such bucket/key | IsNotFound / isNativeNotFound | IsNotFound / isNotFound |
| 401 | bad/expired token or revoked key | IsUnauthorized / isNativeUnauthorized | (re-sign) |
| 403 | scope or policy denial | IsForbidden / isNativeForbidden | APIError code |
| 409 | already exists | IsAlreadyExists / isNativeConflict | APIError code |
| 412 | precondition not met | IsPreconditionFailed / isNativePreconditionFailed | APIError code |
| 507 | tenant storage quota exceeded | IsQuotaExceeded / isNativeQuotaExceeded | APIError code |
In Java, catch NativeException (native) or ApiException (S3) and branch on statusCode() / code(). Full handling, including the retry policy, is on Errors & retries.
Next steps
- Listing & pagination: find the objects you stored.
- Copying objects: server-side copy, conditionals, large-object part copy.
- Deleting objects: single, batch, and versioned deletes.
- Conditional writes: create-only, overwrite-only, idempotency.
- Multipart uploads: resumable uploads for very large objects.
- Signed URLs: hand a browser a direct upload or download URL.