mirror of
https://github.com/Nezumi-2711/google-drive-s3.git
synced 2026-09-22 20:01:19 +00:00
feat: add the openapi docs
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
# I.R.I.S. frontend integration docs
|
||||
|
||||
I.R.I.S. exposes a focused S3-compatible API on top of Google Drive. The live API reference is available from every enabled Worker deployment at [`/docs`](/docs), with the source OpenAPI document at [`/openapi.yaml`](/openapi.yaml).
|
||||
|
||||
## Start here
|
||||
|
||||
1. Configure the Worker with `CORS_ALLOWED_ORIGINS` for every browser origin that will upload or download objects.
|
||||
2. Keep `SECRET_KEY` in a backend-for-frontend (BFF), not in browser code.
|
||||
3. Let the BFF issue short-lived presigned URLs for a constrained bucket/key/method.
|
||||
4. Upload or download through the presigned URL from the browser.
|
||||
|
||||
| Document | Purpose |
|
||||
| --- | --- |
|
||||
| [Integration guide](./integration-guide.md) | Browser upload, multipart upload, listing, CORS, and recommended architecture. |
|
||||
| [Authentication](./authentication.md) | The precise AWS Signature V4 behavior enforced by I.R.I.S. |
|
||||
| [Limitations](./limitations.md) | Unsupported S3 features and Google Drive operational limits. |
|
||||
| [`examples/bff-presign.ts`](./examples/bff-presign.ts) | A Workers BFF endpoint that creates short-lived PUT URLs. |
|
||||
| [`examples/browser-upload.ts`](./examples/browser-upload.ts) | Single object upload from a browser. |
|
||||
| [`examples/browser-multipart.ts`](./examples/browser-multipart.ts) | Strictly sequential browser multipart upload. |
|
||||
|
||||
## Browser security model
|
||||
|
||||
```text
|
||||
Browser ── POST /presign ──> BFF (holds SECRET_KEY)
|
||||
│ │
|
||||
└──── presigned S3 request ────┴──> I.R.I.S. Worker ──> Google Drive
|
||||
```
|
||||
|
||||
The browser never receives `SECRET_KEY`. Presigned URLs must be short lived and scoped to the single object operation the browser needs.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Authentication and Signature V4
|
||||
|
||||
I.R.I.S. accepts AWS Signature Version 4 in either form:
|
||||
|
||||
- an `Authorization: AWS4-HMAC-SHA256 …` header with `x-amz-date`; or
|
||||
- a presigned request with `X-Amz-Algorithm`, `X-Amz-Credential`, `X-Amz-Date`, `X-Amz-SignedHeaders`, and `X-Amz-Signature` query parameters.
|
||||
|
||||
The `Credential` access-key ID must equal the Worker's `ACCESS_KEY`; the signing key is derived from `SECRET_KEY`, `REGION`, service `s3`, and the `YYYYMMDD` request date.
|
||||
|
||||
## Presigned URL expiry
|
||||
|
||||
Query-authenticated requests require `X-Amz-Expires` from 1 through 604800 seconds (seven days). The Worker returns `403 AccessDenied` with `Request has expired` when it is missing, invalid, out of range, or its signed timestamp plus expiry is in the past.
|
||||
|
||||
BFFs should normally use a much shorter period such as five minutes.
|
||||
|
||||
## Header-authenticated requests
|
||||
|
||||
The Worker requires a valid `x-amz-date` and rejects a request when the server time differs from it by more than 15 minutes. This returns `403 RequestTimeTooSkewed`.
|
||||
|
||||
## Canonical request behavior
|
||||
|
||||
The canonical request is built from:
|
||||
|
||||
1. method;
|
||||
2. URL path;
|
||||
3. sorted query string (excluding `X-Amz-Signature` for presigned URLs);
|
||||
4. the signed-header list and normalized values;
|
||||
5. the signed-header list; and
|
||||
6. `x-amz-content-sha256`, defaulting to `UNSIGNED-PAYLOAD`.
|
||||
|
||||
I.R.I.S. deliberately canonicalizes a signed `accept-encoding` header to `identity`. Cloudflare can rewrite the received value at the edge; S3 SDKs that sign this header use `identity` for this reason. Browser code must not sign `accept-encoding` because browser networking controls it.
|
||||
|
||||
Payload hashes are **not** verified. Browser and BFF clients should use `x-amz-content-sha256: UNSIGNED-PAYLOAD`; this is a deliberate streaming limitation, not an integrity guarantee.
|
||||
|
||||
## Public-read buckets
|
||||
|
||||
Buckets listed in `PUBLIC_READ_BUCKETS` permit unsigned `GET` and `HEAD` requests. All write operations still require valid Signature V4 authentication. `PUBLIC_READ_BUCKETS` must be a subset of `ALLOWED_BUCKETS`.
|
||||
|
||||
For a tested signing reference, see the `signed()` helper in [`test/s3.test.ts`](../test/s3.test.ts).
|
||||
@@ -0,0 +1,95 @@
|
||||
import { AbortMultipartUploadCommand, CompleteMultipartUploadCommand, CreateMultipartUploadCommand, PutObjectCommand, S3Client, UploadPartCommand } from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
|
||||
interface Env {
|
||||
IRIS_ENDPOINT: string;
|
||||
IRIS_ACCESS_KEY: string;
|
||||
IRIS_SECRET_KEY: string;
|
||||
IRIS_REGION: string;
|
||||
IRIS_BUCKET: string;
|
||||
}
|
||||
|
||||
interface PresignRequest {
|
||||
key: string;
|
||||
contentType?: string;
|
||||
operation?: "put" | "createMultipart" | "uploadPart" | "completeMultipart" | "abortMultipart";
|
||||
uploadId?: string;
|
||||
partNumber?: number;
|
||||
parts?: Array<{ ETag: string; PartNumber: number }>;
|
||||
}
|
||||
|
||||
function escapeXml(value: string): string {
|
||||
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function completionXml(parts: Array<{ ETag: string; PartNumber: number }> | undefined): string | null {
|
||||
if (!parts?.length || parts.some((part, index) => !Number.isInteger(part.PartNumber) || part.PartNumber !== index + 1 || typeof part.ETag !== "string" || part.ETag.length === 0)) return null;
|
||||
return `<CompleteMultipartUpload>${parts.map((part) => `<Part><PartNumber>${part.PartNumber}</PartNumber><ETag>${escapeXml(part.ETag)}</ETag></Part>`).join("")}</CompleteMultipartUpload>`;
|
||||
}
|
||||
|
||||
function json(data: unknown, status = 200): Response {
|
||||
return Response.json(data, { status, headers: { "Cache-Control": "no-store" } });
|
||||
}
|
||||
|
||||
function s3(env: Env): S3Client {
|
||||
return new S3Client({
|
||||
endpoint: env.IRIS_ENDPOINT,
|
||||
region: env.IRIS_REGION,
|
||||
forcePathStyle: true,
|
||||
credentials: { accessKeyId: env.IRIS_ACCESS_KEY, secretAccessKey: env.IRIS_SECRET_KEY },
|
||||
});
|
||||
}
|
||||
|
||||
function validKey(key: unknown): key is string {
|
||||
return typeof key === "string" && key.length > 0 && key.length <= 1024 && !key.startsWith("/") && !key.split("/").includes("..");
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
if (request.method !== "POST" || new URL(request.url).pathname !== "/presign") return new Response("Not found", { status: 404 });
|
||||
|
||||
let input: PresignRequest;
|
||||
try {
|
||||
input = await request.json<PresignRequest>();
|
||||
} catch {
|
||||
return json({ error: "Expected JSON body" }, 400);
|
||||
}
|
||||
if (!validKey(input.key)) return json({ error: "Invalid object key" }, 400);
|
||||
|
||||
const client = s3(env);
|
||||
const operation = input.operation ?? "put";
|
||||
const expiresIn = 5 * 60;
|
||||
|
||||
if (operation === "put") {
|
||||
const contentType = input.contentType || "application/octet-stream";
|
||||
const url = await getSignedUrl(client, new PutObjectCommand({ Bucket: env.IRIS_BUCKET, Key: input.key, ContentType: contentType }), { expiresIn });
|
||||
return json({ url, method: "PUT", headers: { "Content-Type": contentType }, expiresIn });
|
||||
}
|
||||
|
||||
if (operation === "createMultipart") {
|
||||
const contentType = input.contentType || "application/octet-stream";
|
||||
const url = await getSignedUrl(client, new CreateMultipartUploadCommand({ Bucket: env.IRIS_BUCKET, Key: input.key, ContentType: contentType }), { expiresIn });
|
||||
return json({ url, method: "POST", headers: { "Content-Type": contentType }, expiresIn });
|
||||
}
|
||||
|
||||
if (!input.uploadId) return json({ error: "uploadId is required" }, 400);
|
||||
if (operation === "uploadPart") {
|
||||
const partNumber = input.partNumber;
|
||||
if (!Number.isInteger(partNumber) || partNumber === undefined || partNumber < 1 || partNumber > 10_000) return json({ error: "partNumber must be 1 through 10000" }, 400);
|
||||
const url = await getSignedUrl(client, new UploadPartCommand({ Bucket: env.IRIS_BUCKET, Key: input.key, UploadId: input.uploadId, PartNumber: partNumber }), { expiresIn });
|
||||
return json({ url, method: "PUT", expiresIn });
|
||||
}
|
||||
if (operation === "completeMultipart") {
|
||||
const body = completionXml(input.parts);
|
||||
if (!body) return json({ error: "parts must have sequential PartNumber values beginning at 1 and non-empty ETag values" }, 400);
|
||||
const url = await getSignedUrl(client, new CompleteMultipartUploadCommand({ Bucket: env.IRIS_BUCKET, Key: input.key, UploadId: input.uploadId, MultipartUpload: { Parts: input.parts } }), { expiresIn });
|
||||
return json({ url, method: "POST", headers: { "Content-Type": "application/xml" }, body, expiresIn });
|
||||
}
|
||||
if (operation === "abortMultipart") {
|
||||
const url = await getSignedUrl(client, new AbortMultipartUploadCommand({ Bucket: env.IRIS_BUCKET, Key: input.key, UploadId: input.uploadId }), { expiresIn });
|
||||
return json({ url, method: "DELETE", expiresIn });
|
||||
}
|
||||
|
||||
return json({ error: "Unsupported operation" }, 400);
|
||||
},
|
||||
} satisfies ExportedHandler<Env>;
|
||||
@@ -0,0 +1,48 @@
|
||||
interface PresignResponse {
|
||||
url: string;
|
||||
method: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
}
|
||||
|
||||
async function presign(body: Record<string, unknown>): Promise<PresignResponse> {
|
||||
const response = await fetch("/presign", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
return response.json<PresignResponse>();
|
||||
}
|
||||
|
||||
function uploadIdFromXml(xml: string): string {
|
||||
const value = /<UploadId>([^<]+)<\/UploadId>/.exec(xml)?.[1];
|
||||
if (!value) throw new Error("CreateMultipartUpload did not return UploadId");
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Uploads parts in order. I.R.I.S. does not allow parallel or out-of-order part uploads. */
|
||||
export async function uploadLargeFile(file: File, key: string, partSize = 16 * 1024 * 1024): Promise<void> {
|
||||
const contentType = file.type || "application/octet-stream";
|
||||
const create = await presign({ key, contentType, operation: "createMultipart" });
|
||||
const createResponse = await fetch(create.url, { method: create.method, headers: create.headers });
|
||||
if (!createResponse.ok) throw new Error(await createResponse.text());
|
||||
const uploadId = uploadIdFromXml(await createResponse.text());
|
||||
|
||||
const parts: Array<{ ETag: string; PartNumber: number }> = [];
|
||||
for (let index = 0, offset = 0; offset < file.size; index++, offset += partSize) {
|
||||
const partNumber = index + 1;
|
||||
const body = file.slice(offset, Math.min(offset + partSize, file.size));
|
||||
const signed = await presign({ key, uploadId, partNumber, operation: "uploadPart" });
|
||||
const response = await fetch(signed.url, { method: signed.method, body });
|
||||
if (!response.ok) throw new Error(`Part ${partNumber} failed: ${await response.text()}`);
|
||||
const etag = response.headers.get("ETag");
|
||||
if (!etag) throw new Error(`Part ${partNumber} did not return ETag; check CORS_ALLOWED_ORIGINS`);
|
||||
parts.push({ ETag: etag, PartNumber: partNumber });
|
||||
}
|
||||
|
||||
const complete = await presign({ key, uploadId, parts, operation: "completeMultipart" });
|
||||
if (!complete.body) throw new Error("BFF did not return the completion XML body");
|
||||
const completeResponse = await fetch(complete.url, { method: complete.method, headers: complete.headers, body: complete.body });
|
||||
if (!completeResponse.ok) throw new Error(await completeResponse.text());
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
interface PresignedPut {
|
||||
url: string;
|
||||
method: "PUT";
|
||||
headers: { "Content-Type": string };
|
||||
expiresIn: number;
|
||||
}
|
||||
|
||||
async function getPresignedPut(key: string, contentType: string): Promise<PresignedPut> {
|
||||
const response = await fetch("/presign", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ key, contentType, operation: "put" }),
|
||||
});
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
return response.json<PresignedPut>();
|
||||
}
|
||||
|
||||
export async function uploadFile(file: File, key: string): Promise<string | null> {
|
||||
const signed = await getPresignedPut(key, file.type || "application/octet-stream");
|
||||
const response = await fetch(signed.url, {
|
||||
method: signed.method,
|
||||
headers: signed.headers,
|
||||
body: file,
|
||||
});
|
||||
if (!response.ok) throw new Error(`Upload failed: ${await response.text()}`);
|
||||
|
||||
// Requires CORS_ALLOWED_ORIGINS to include this page's exact origin.
|
||||
return response.headers.get("ETag");
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
# Frontend integration guide
|
||||
|
||||
## Architecture
|
||||
|
||||
Use a backend-for-frontend (BFF) as the trust boundary:
|
||||
|
||||
```text
|
||||
Browser → BFF (holds ACCESS_KEY + SECRET_KEY, issues presigned URLs) → I.R.I.S. Worker → Google Drive
|
||||
```
|
||||
|
||||
Never put `SECRET_KEY` in browser code, a static site, or a public environment variable. The browser receives only a short-lived presigned URL for one method and object key.
|
||||
|
||||
## Enable browser access
|
||||
|
||||
Configure the Worker with an explicit allow-list of web origins:
|
||||
|
||||
```ini
|
||||
CORS_ALLOWED_ORIGINS=https://app.example.com,http://localhost:5173
|
||||
```
|
||||
|
||||
Set it to `*` only for a genuinely public, credential-free integration. Leaving it unset emits no CORS headers. I.R.I.S. exposes `ETag`, `Content-Range`, `Content-Length`, `Last-Modified`, `Accept-Ranges`, and `x-amz-request-id`, so browser code can inspect the headers that form the effective S3 response payload.
|
||||
|
||||
### Server-to-server compatibility
|
||||
|
||||
CORS does not restrict non-browser clients. `Access-Control-*` headers are emitted only when a request has an allowed `Origin`; a CLI, Dokploy, rclone, aws-cli, or other service request with no `Origin` keeps its normal S3 status and body. With an exact origin allow-list, responses also include `Vary: Origin`, including requests without an `Origin`, so shared caches cannot replay a no-origin object response to a browser. S3 clients ignore this cache-control metadata.
|
||||
|
||||
An `OPTIONS` request with no `Origin`, or a preflight from a disallowed origin, receives the same bare `204` as a CORS-disabled Worker. A disallowed `Origin` never changes the underlying S3 result—it simply receives no `Access-Control-*` headers.
|
||||
|
||||
## Presign in the BFF
|
||||
|
||||
Use `@aws-sdk/s3-request-presigner` and send a limited expiry. I.R.I.S. validates `X-Amz-Expires` and rejects URLs after their signing time plus that value.
|
||||
|
||||
```ts
|
||||
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
|
||||
const client = new S3Client({
|
||||
endpoint: env.IRIS_ENDPOINT,
|
||||
region: env.IRIS_REGION,
|
||||
credentials: {
|
||||
accessKeyId: env.IRIS_ACCESS_KEY,
|
||||
secretAccessKey: env.IRIS_SECRET_KEY,
|
||||
},
|
||||
forcePathStyle: true,
|
||||
});
|
||||
|
||||
const url = await getSignedUrl(
|
||||
client,
|
||||
new PutObjectCommand({
|
||||
Bucket: "assets",
|
||||
Key: "uploads/avatar.png",
|
||||
ContentType: "image/png",
|
||||
}),
|
||||
{ expiresIn: 300 },
|
||||
);
|
||||
```
|
||||
|
||||
Keep the `ContentType` in the command and send precisely that same `Content-Type` from the browser. It becomes the Drive `mimeType`. A non-simple content type causes a preflight request, which is why the Worker CORS configuration is necessary.
|
||||
|
||||
## Upload one object
|
||||
|
||||
Use `Blob`, `File`, `ArrayBuffer`, or a typed array as the body. This lets the browser provide a known body length. Do **not** use a `ReadableStream` unless the BFF signs `x-amz-decoded-content-length` and the browser sends it: multipart part uploads require `Content-Length` or `x-amz-decoded-content-length`.
|
||||
|
||||
```ts
|
||||
const response = await fetch(presignedUrl, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": file.type || "application/octet-stream" },
|
||||
body: file,
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
const etag = response.headers.get("ETag");
|
||||
```
|
||||
|
||||
`ETag` is readable only when the request `Origin` is configured in `CORS_ALLOWED_ORIGINS`.
|
||||
|
||||
## Multipart upload
|
||||
|
||||
Cloudflare request limits mean a single PUT is best kept below roughly 100 MB. For larger files, have the BFF initiate the upload, issue a presigned URL for each part, and complete the upload after the browser returns all ETags.
|
||||
|
||||
Use 16 MB parts as a practical baseline. Upload parts **strictly sequentially starting at 1**. I.R.I.S. waits up to 20 seconds for an earlier part; out-of-order work then receives `503 SlowDown`. Requests more than 64 part numbers ahead are rejected immediately.
|
||||
|
||||
1. BFF signs `POST /bucket/key?uploads` and starts the multipart upload.
|
||||
2. BFF issues a signed `PUT` URL for part 1; browser uploads it and records the `ETag`.
|
||||
3. Repeat one part at a time for parts 2 through $n$.
|
||||
4. BFF signs `POST /bucket/key?uploadId=…`; browser sends the `CompleteMultipartUpload` XML body containing the sequential part numbers and ETags.
|
||||
|
||||
See [`examples/browser-multipart.ts`](./examples/browser-multipart.ts) for a complete browser-side loop.
|
||||
|
||||
## Build a file browser
|
||||
|
||||
List with `prefix` and `delimiter=/`:
|
||||
|
||||
```text
|
||||
GET /assets?prefix=photos/&delimiter=/
|
||||
```
|
||||
|
||||
Files appear in `Contents`; immediate child folders appear as `CommonPrefixes`. Google Drive directories are physical folders, not zero-byte marker objects. Read [limitations](./limitations.md) before designing pagination or rename/move features.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Limitations
|
||||
|
||||
I.R.I.S. is a narrow S3 compatibility layer. Design frontend features around the behavior below.
|
||||
|
||||
## Listing is not pageable
|
||||
|
||||
`max-keys`, `continuation-token`, and `marker` are ignored. I.R.I.S. scans at most 5000 Google Drive nodes and then sets `IsTruncated=true`; it does not provide a continuation token, so additional results cannot be requested. The XML value `MaxKeys=1000` is a fixed compatibility value, not the real scan limit.
|
||||
|
||||
Use narrow prefixes and `delimiter=/` for a file browser. Do not build an infinite-scrolling full-drive browser.
|
||||
|
||||
## No server-side rename or move
|
||||
|
||||
`CopyObject` and `UploadPartCopy` return `501 NotImplemented`. There is no server-side rename or move because both require copying. Implement rename/move as download, re-upload under the target key, then delete the original—subject to bandwidth, time, and error handling.
|
||||
|
||||
## Unsupported S3 features
|
||||
|
||||
- `ListBuckets`, `CreateBucket`, and `DeleteBucket`
|
||||
- batch `DeleteObjects`
|
||||
- object versioning, ACLs, object tags, lifecycle rules, and `x-amz-meta-*` user metadata
|
||||
- CopyObject and UploadPartCopy
|
||||
- discovering active multipart uploads: `ListMultipartUploads` always reports an empty list
|
||||
|
||||
Bucket `?acl`, `?versioning`, and `?location` requests are currently treated as regular bucket listing requests rather than errors. Do not rely on this accidental compatibility.
|
||||
|
||||
## Directories and objects
|
||||
|
||||
Directories are physical Google Drive folders created from object-key path segments. I.R.I.S. does not create zero-byte directory marker objects. An empty prefix can therefore exist as a Drive folder without an S3 marker object.
|
||||
|
||||
## Multipart behavior
|
||||
|
||||
Parts must be uploaded strictly in consecutive order beginning at part 1. Parts are immutable after they have committed. An incomplete upload expires after 24 hours. `ETAG_STYLE=md5` returns Google Drive's file MD5 for completed multipart objects; `ETAG_STYLE=multipart` is available only for clients that require an S3-style composite ETag.
|
||||
|
||||
## Quotas and plan limits
|
||||
|
||||
Google Drive's free tier has 15 GB total storage and a 750 GB/day upload limit. Cloudflare applies request-size limits before a request reaches the Worker; use multipart uploads for large browser uploads.
|
||||
@@ -0,0 +1,391 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: I.R.I.S. Google Drive S3 API
|
||||
version: 1.0.0
|
||||
description: |
|
||||
An intentionally small S3-compatible object-storage API backed by Google Drive.
|
||||
Authenticate every non-public request with AWS Signature Version 4. Browser clients
|
||||
should receive short-lived presigned URLs from a backend-for-frontend, never credentials.
|
||||
servers:
|
||||
- url: https://{worker-host}
|
||||
variables:
|
||||
worker-host:
|
||||
default: your-worker.example.workers.dev
|
||||
security:
|
||||
- sigv4: []
|
||||
tags:
|
||||
- name: Objects
|
||||
- name: Buckets
|
||||
- name: Multipart uploads
|
||||
paths:
|
||||
/{bucket}:
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Bucket'
|
||||
get:
|
||||
tags: [Buckets]
|
||||
operationId: listObjects
|
||||
summary: List objects in a bucket
|
||||
description: List objects recursively, or use `delimiter=/` to return immediate folders as `CommonPrefixes`.
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Prefix'
|
||||
- $ref: '#/components/parameters/Delimiter'
|
||||
responses:
|
||||
'200':
|
||||
description: Object listing.
|
||||
content:
|
||||
application/xml:
|
||||
schema: { $ref: '#/components/schemas/ListBucketResult' }
|
||||
'403': { $ref: '#/components/responses/AccessDenied' }
|
||||
'405': { $ref: '#/components/responses/MethodNotAllowed' }
|
||||
head:
|
||||
tags: [Buckets]
|
||||
operationId: headBucket
|
||||
summary: Check bucket access
|
||||
responses:
|
||||
'200': { description: The bucket is configured and accessible. }
|
||||
'403': { $ref: '#/components/responses/AccessDenied' }
|
||||
'405': { $ref: '#/components/responses/MethodNotAllowed' }
|
||||
/{bucket}?uploads:
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Bucket'
|
||||
get:
|
||||
tags: [Multipart uploads]
|
||||
operationId: listMultipartUploads
|
||||
summary: List multipart uploads
|
||||
description: Always returns an empty list; active multipart uploads cannot be enumerated.
|
||||
responses:
|
||||
'200':
|
||||
description: Empty multipart upload listing.
|
||||
content:
|
||||
application/xml:
|
||||
schema: { $ref: '#/components/schemas/ListMultipartUploadsResult' }
|
||||
'403': { $ref: '#/components/responses/AccessDenied' }
|
||||
/{bucket}/{key}:
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Bucket'
|
||||
- $ref: '#/components/parameters/Key'
|
||||
put:
|
||||
tags: [Objects]
|
||||
operationId: putObject
|
||||
summary: Create or replace an object
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/ContentType'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/octet-stream:
|
||||
schema: { type: string, format: binary }
|
||||
'*/*':
|
||||
schema: { type: string, format: binary }
|
||||
responses:
|
||||
'200':
|
||||
description: Object uploaded.
|
||||
headers:
|
||||
ETag: { $ref: '#/components/headers/ETag' }
|
||||
'403': { $ref: '#/components/responses/AccessDenied' }
|
||||
'501': { $ref: '#/components/responses/NotImplemented' }
|
||||
get:
|
||||
tags: [Objects]
|
||||
operationId: getObject
|
||||
summary: Download an object
|
||||
parameters:
|
||||
- name: Range
|
||||
in: header
|
||||
schema: { type: string, example: bytes=0-1023 }
|
||||
responses:
|
||||
'200':
|
||||
description: Full object data.
|
||||
headers:
|
||||
ETag: { $ref: '#/components/headers/ETag' }
|
||||
Content-Length: { $ref: '#/components/headers/ContentLength' }
|
||||
Last-Modified: { $ref: '#/components/headers/LastModified' }
|
||||
Accept-Ranges: { schema: { type: string, example: bytes } }
|
||||
content:
|
||||
application/octet-stream:
|
||||
schema: { type: string, format: binary }
|
||||
'206':
|
||||
description: Requested byte range.
|
||||
headers:
|
||||
Content-Range: { schema: { type: string, example: bytes 0-1023/4096 } }
|
||||
Content-Length: { $ref: '#/components/headers/ContentLength' }
|
||||
content:
|
||||
application/octet-stream:
|
||||
schema: { type: string, format: binary }
|
||||
'403': { $ref: '#/components/responses/AccessDenied' }
|
||||
'404': { $ref: '#/components/responses/NoSuchKey' }
|
||||
head:
|
||||
tags: [Objects]
|
||||
operationId: headObject
|
||||
summary: Get object metadata
|
||||
responses:
|
||||
'200':
|
||||
description: Object metadata in headers.
|
||||
headers:
|
||||
ETag: { $ref: '#/components/headers/ETag' }
|
||||
Content-Length: { $ref: '#/components/headers/ContentLength' }
|
||||
Last-Modified: { $ref: '#/components/headers/LastModified' }
|
||||
Accept-Ranges: { schema: { type: string, example: bytes } }
|
||||
'403': { $ref: '#/components/responses/AccessDenied' }
|
||||
'404': { $ref: '#/components/responses/NoSuchKey' }
|
||||
delete:
|
||||
tags: [Objects]
|
||||
operationId: deleteObject
|
||||
summary: Delete an object
|
||||
responses:
|
||||
'204': { description: Object deleted. }
|
||||
'403': { $ref: '#/components/responses/AccessDenied' }
|
||||
'404': { $ref: '#/components/responses/NoSuchKey' }
|
||||
/{bucket}/{key}?uploads:
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Bucket'
|
||||
- $ref: '#/components/parameters/Key'
|
||||
post:
|
||||
tags: [Multipart uploads]
|
||||
operationId: createMultipartUpload
|
||||
summary: Initiate a multipart upload
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/ContentType'
|
||||
responses:
|
||||
'200':
|
||||
description: Upload initiated.
|
||||
content:
|
||||
application/xml:
|
||||
schema: { $ref: '#/components/schemas/InitiateMultipartUploadResult' }
|
||||
'403': { $ref: '#/components/responses/AccessDenied' }
|
||||
'501': { $ref: '#/components/responses/NotImplemented' }
|
||||
/{bucket}/{key}?uploadId={uploadId}&partNumber={partNumber}:
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Bucket'
|
||||
- $ref: '#/components/parameters/Key'
|
||||
- $ref: '#/components/parameters/UploadId'
|
||||
- $ref: '#/components/parameters/PartNumber'
|
||||
put:
|
||||
tags: [Multipart uploads]
|
||||
operationId: uploadPart
|
||||
summary: Upload one multipart part
|
||||
description: Parts must arrive strictly sequentially beginning with part number 1.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/octet-stream:
|
||||
schema: { type: string, format: binary }
|
||||
responses:
|
||||
'200':
|
||||
description: Part accepted.
|
||||
headers:
|
||||
ETag: { $ref: '#/components/headers/ETag' }
|
||||
'400': { $ref: '#/components/responses/InvalidArgument' }
|
||||
'403': { $ref: '#/components/responses/AccessDenied' }
|
||||
'404': { $ref: '#/components/responses/NoSuchUpload' }
|
||||
'503': { $ref: '#/components/responses/SlowDown' }
|
||||
/{bucket}/{key}?uploadId={uploadId}:
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Bucket'
|
||||
- $ref: '#/components/parameters/Key'
|
||||
- $ref: '#/components/parameters/UploadId'
|
||||
post:
|
||||
tags: [Multipart uploads]
|
||||
operationId: completeMultipartUpload
|
||||
summary: Complete a multipart upload
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/xml:
|
||||
schema: { $ref: '#/components/schemas/CompleteMultipartUpload' }
|
||||
responses:
|
||||
'200':
|
||||
description: Upload completed.
|
||||
content:
|
||||
application/xml:
|
||||
schema: { $ref: '#/components/schemas/CompleteMultipartUploadResult' }
|
||||
'400': { $ref: '#/components/responses/InvalidArgument' }
|
||||
'403': { $ref: '#/components/responses/AccessDenied' }
|
||||
'404': { $ref: '#/components/responses/NoSuchUpload' }
|
||||
get:
|
||||
tags: [Multipart uploads]
|
||||
operationId: listParts
|
||||
summary: List uploaded parts
|
||||
parameters:
|
||||
- name: part-number-marker
|
||||
in: query
|
||||
schema: { type: integer, minimum: 0, default: 0 }
|
||||
- name: max-parts
|
||||
in: query
|
||||
schema: { type: integer, minimum: 1, maximum: 1000, default: 1000 }
|
||||
responses:
|
||||
'200':
|
||||
description: Current part list.
|
||||
content:
|
||||
application/xml:
|
||||
schema: { $ref: '#/components/schemas/ListPartsResult' }
|
||||
'400': { $ref: '#/components/responses/InvalidArgument' }
|
||||
'403': { $ref: '#/components/responses/AccessDenied' }
|
||||
'404': { $ref: '#/components/responses/NoSuchUpload' }
|
||||
delete:
|
||||
tags: [Multipart uploads]
|
||||
operationId: abortMultipartUpload
|
||||
summary: Abort a multipart upload
|
||||
responses:
|
||||
'204': { description: Upload aborted. }
|
||||
'403': { $ref: '#/components/responses/AccessDenied' }
|
||||
'404': { $ref: '#/components/responses/NoSuchUpload' }
|
||||
components:
|
||||
securitySchemes:
|
||||
sigv4:
|
||||
type: apiKey
|
||||
in: header
|
||||
name: Authorization
|
||||
description: AWS Signature Version 4 header authentication or equivalent `X-Amz-*` presigned query parameters.
|
||||
parameters:
|
||||
Bucket:
|
||||
name: bucket
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
Key:
|
||||
name: key
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
Prefix:
|
||||
name: prefix
|
||||
in: query
|
||||
schema: { type: string, default: '' }
|
||||
Delimiter:
|
||||
name: delimiter
|
||||
in: query
|
||||
schema: { type: string, example: / }
|
||||
UploadId:
|
||||
name: uploadId
|
||||
in: query
|
||||
required: true
|
||||
schema: { type: string }
|
||||
PartNumber:
|
||||
name: partNumber
|
||||
in: query
|
||||
required: true
|
||||
schema: { type: integer, minimum: 1, maximum: 10000 }
|
||||
ContentType:
|
||||
name: Content-Type
|
||||
in: header
|
||||
schema: { type: string, example: application/octet-stream }
|
||||
headers:
|
||||
ETag:
|
||||
schema: { type: string, example: '"d41d8cd98f00b204e9800998ecf8427e"' }
|
||||
ContentLength:
|
||||
schema: { type: integer, minimum: 0 }
|
||||
LastModified:
|
||||
schema: { type: string, format: date-time }
|
||||
responses:
|
||||
AccessDenied:
|
||||
description: Authentication failed, the access key is not recognized, the presigned URL expired, or bucket access is denied.
|
||||
content: { application/xml: { schema: { $ref: '#/components/schemas/S3Error' } } }
|
||||
NoSuchKey:
|
||||
description: The object does not exist.
|
||||
content: { application/xml: { schema: { $ref: '#/components/schemas/S3Error' } } }
|
||||
NoSuchUpload:
|
||||
description: The multipart upload does not exist.
|
||||
content: { application/xml: { schema: { $ref: '#/components/schemas/S3Error' } } }
|
||||
InvalidArgument:
|
||||
description: A required parameter or body is invalid.
|
||||
content: { application/xml: { schema: { $ref: '#/components/schemas/S3Error' } } }
|
||||
NotImplemented:
|
||||
description: This S3 operation is not supported.
|
||||
content: { application/xml: { schema: { $ref: '#/components/schemas/S3Error' } } }
|
||||
SlowDown:
|
||||
description: Parts are out of sequence or the upload is busy; retry later.
|
||||
content: { application/xml: { schema: { $ref: '#/components/schemas/S3Error' } } }
|
||||
MethodNotAllowed:
|
||||
description: The method/path combination is unsupported.
|
||||
content: { application/xml: { schema: { $ref: '#/components/schemas/S3Error' } } }
|
||||
schemas:
|
||||
S3Error:
|
||||
type: object
|
||||
xml: { name: Error }
|
||||
required: [Code, Message, RequestId]
|
||||
properties:
|
||||
Code: { type: string, xml: { name: Code } }
|
||||
Message: { type: string, xml: { name: Message } }
|
||||
Resource: { type: string, xml: { name: Resource } }
|
||||
RequestId: { type: string, xml: { name: RequestId } }
|
||||
Object:
|
||||
type: object
|
||||
xml: { name: Contents }
|
||||
properties:
|
||||
Key: { type: string }
|
||||
LastModified: { type: string, format: date-time }
|
||||
ETag: { type: string }
|
||||
Size: { type: integer }
|
||||
StorageClass: { type: string, example: STANDARD }
|
||||
ListBucketResult:
|
||||
type: object
|
||||
xml: { name: ListBucketResult, namespace: 'http://s3.amazonaws.com/doc/2006-03-01/' }
|
||||
properties:
|
||||
Name: { type: string }
|
||||
Prefix: { type: string }
|
||||
Delimiter: { type: string }
|
||||
MaxKeys: { type: integer, example: 1000 }
|
||||
IsTruncated: { type: boolean }
|
||||
Contents: { type: array, items: { $ref: '#/components/schemas/Object' }, xml: { wrapped: false } }
|
||||
InitiateMultipartUploadResult:
|
||||
type: object
|
||||
xml: { name: InitiateMultipartUploadResult, namespace: 'http://s3.amazonaws.com/doc/2006-03-01/' }
|
||||
required: [Bucket, Key, UploadId]
|
||||
properties:
|
||||
Bucket: { type: string }
|
||||
Key: { type: string }
|
||||
UploadId: { type: string }
|
||||
CompleteMultipartUpload:
|
||||
type: object
|
||||
xml: { name: CompleteMultipartUpload }
|
||||
required: [Part]
|
||||
properties:
|
||||
Part:
|
||||
type: array
|
||||
minItems: 1
|
||||
xml: { wrapped: false }
|
||||
items:
|
||||
type: object
|
||||
xml: { name: Part }
|
||||
required: [PartNumber, ETag]
|
||||
properties:
|
||||
PartNumber: { type: integer }
|
||||
ETag: { type: string }
|
||||
CompleteMultipartUploadResult:
|
||||
type: object
|
||||
xml: { name: CompleteMultipartUploadResult, namespace: 'http://s3.amazonaws.com/doc/2006-03-01/' }
|
||||
properties:
|
||||
Location: { type: string }
|
||||
Bucket: { type: string }
|
||||
Key: { type: string }
|
||||
ETag: { type: string }
|
||||
ListPartsResult:
|
||||
type: object
|
||||
xml: { name: ListPartsResult, namespace: 'http://s3.amazonaws.com/doc/2006-03-01/' }
|
||||
properties:
|
||||
Bucket: { type: string }
|
||||
Key: { type: string }
|
||||
UploadId: { type: string }
|
||||
NextPartNumberMarker: { type: integer }
|
||||
IsTruncated: { type: boolean }
|
||||
Part:
|
||||
type: array
|
||||
xml: { wrapped: false }
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
PartNumber: { type: integer }
|
||||
LastModified: { type: string, format: date-time }
|
||||
ETag: { type: string }
|
||||
Size: { type: integer }
|
||||
ListMultipartUploadsResult:
|
||||
type: object
|
||||
xml: { name: ListMultipartUploadsResult, namespace: 'http://s3.amazonaws.com/doc/2006-03-01/' }
|
||||
properties:
|
||||
Bucket: { type: string }
|
||||
KeyMarker: { type: string }
|
||||
UploadIdMarker: { type: string }
|
||||
NextKeyMarker: { type: string }
|
||||
NextUploadIdMarker: { type: string }
|
||||
MaxUploads: { type: integer, example: 1000 }
|
||||
IsTruncated: { type: boolean, example: false }
|
||||
Reference in New Issue
Block a user