Skip to content

Commit b7494f2

Browse files
committed
Add r2 bucket cors command (list, set, delete)
1 parent 68b1758 commit b7494f2

File tree

5 files changed

+424
-0
lines changed

5 files changed

+424
-0
lines changed

.changeset/five-dryers-swim.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"wrangler": minor
3+
---
4+
5+
Added r2 bucket cors command to Wrangler including list, set, delete

packages/wrangler/src/__tests__/r2.test.ts

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ describe("r2", () => {
9595
wrangler r2 bucket domain Manage custom domains for an R2 bucket
9696
wrangler r2 bucket dev-url Manage public access via the r2.dev URL for an R2 bucket
9797
wrangler r2 bucket lifecycle Manage lifecycle rules for an R2 bucket
98+
wrangler r2 bucket cors Manage CORS configuration for an R2 bucket
9899
99100
GLOBAL FLAGS
100101
-c, --config Path to Wrangler configuration file [string]
@@ -132,6 +133,7 @@ describe("r2", () => {
132133
wrangler r2 bucket domain Manage custom domains for an R2 bucket
133134
wrangler r2 bucket dev-url Manage public access via the r2.dev URL for an R2 bucket
134135
wrangler r2 bucket lifecycle Manage lifecycle rules for an R2 bucket
136+
wrangler r2 bucket cors Manage CORS configuration for an R2 bucket
135137
136138
GLOBAL FLAGS
137139
-c, --config Path to Wrangler configuration file [string]
@@ -2055,6 +2057,155 @@ describe("r2", () => {
20552057
});
20562058
});
20572059
});
2060+
describe("cors", () => {
2061+
const { setIsTTY } = useMockIsTTY();
2062+
mockAccountId();
2063+
mockApiToken();
2064+
describe("list", () => {
2065+
it("should list CORS rules when they exist", async () => {
2066+
const bucketName = "my-bucket";
2067+
const corsRules = [
2068+
{
2069+
allowed: {
2070+
origins: ["https://www.example.com"],
2071+
methods: ["GET", "PUT"],
2072+
headers: ["Content-Type", "Authorization"],
2073+
},
2074+
exposeHeaders: ["ETag", "Content-Length"],
2075+
maxAgeSeconds: 8640,
2076+
},
2077+
];
2078+
2079+
msw.use(
2080+
http.get(
2081+
"*/accounts/:accountId/r2/buckets/:bucketName/cors",
2082+
async ({ params }) => {
2083+
const { accountId, bucketName: bucketParam } = params;
2084+
expect(accountId).toEqual("some-account-id");
2085+
expect(bucketParam).toEqual(bucketName);
2086+
return HttpResponse.json(
2087+
createFetchResult({
2088+
rules: corsRules,
2089+
})
2090+
);
2091+
},
2092+
{ once: true }
2093+
)
2094+
);
2095+
await runWrangler(`r2 bucket cors list ${bucketName}`);
2096+
expect(std.out).toMatchInlineSnapshot(`
2097+
"Listing CORS rules for bucket 'my-bucket'...
2098+
allowed_origins: https://www.example.com
2099+
allowed_methods: GET, PUT
2100+
allowed_headers: Content-Type, Authorization
2101+
exposed_headers: ETag, Content-Length
2102+
max_age_seconds: 8640"
2103+
`);
2104+
});
2105+
});
2106+
describe("set", () => {
2107+
it("should set CORS configuration from a JSON file", async () => {
2108+
const bucketName = "my-bucket";
2109+
const filePath = "cors-configuration.json";
2110+
const corsRules = {
2111+
rules: [
2112+
{
2113+
allowed: {
2114+
origins: ["https://www.example.com"],
2115+
methods: ["GET", "PUT"],
2116+
headers: ["Content-Type", "Authorization"],
2117+
},
2118+
exposeHeaders: ["ETag", "Content-Length"],
2119+
maxAgeSeconds: 8640,
2120+
},
2121+
],
2122+
};
2123+
2124+
writeFileSync(filePath, JSON.stringify(corsRules));
2125+
2126+
setIsTTY(true);
2127+
mockConfirm({
2128+
text: `Are you sure you want to overwrite the existing CORS configuration for bucket '${bucketName}'?`,
2129+
result: true,
2130+
});
2131+
2132+
msw.use(
2133+
http.put(
2134+
"*/accounts/:accountId/r2/buckets/:bucketName/cors",
2135+
async ({ request, params }) => {
2136+
const { accountId, bucketName: bucketParam } = params;
2137+
expect(accountId).toEqual("some-account-id");
2138+
expect(bucketName).toEqual(bucketParam);
2139+
const requestBody = await request.json();
2140+
expect(requestBody).toEqual({
2141+
...corsRules,
2142+
});
2143+
return HttpResponse.json(createFetchResult({}));
2144+
},
2145+
{ once: true }
2146+
)
2147+
);
2148+
2149+
await runWrangler(
2150+
`r2 bucket cors set ${bucketName} --file ${filePath}`
2151+
);
2152+
expect(std.out).toMatchInlineSnapshot(`
2153+
"Setting CORS configuration (1 rules) for bucket 'my-bucket'...
2154+
✨ Set CORS configuration for bucket 'my-bucket'."
2155+
`);
2156+
});
2157+
});
2158+
describe("delete", () => {
2159+
it("should delete CORS configuration as expected", async () => {
2160+
const bucketName = "my-bucket";
2161+
const corsRules = {
2162+
rules: [
2163+
{
2164+
allowed: {
2165+
origins: ["https://www.example.com"],
2166+
methods: ["GET", "PUT"],
2167+
headers: ["Content-Type", "Authorization"],
2168+
},
2169+
exposeHeaders: ["ETag", "Content-Length"],
2170+
maxAgeSeconds: 8640,
2171+
},
2172+
],
2173+
};
2174+
setIsTTY(true);
2175+
mockConfirm({
2176+
text: `Are you sure you want to clear the existing CORS configuration for bucket '${bucketName}'?`,
2177+
result: true,
2178+
});
2179+
msw.use(
2180+
http.get(
2181+
"*/accounts/:accountId/r2/buckets/:bucketName/cors",
2182+
async ({ params }) => {
2183+
const { accountId, bucketName: bucketParam } = params;
2184+
expect(accountId).toEqual("some-account-id");
2185+
expect(bucketParam).toEqual(bucketName);
2186+
return HttpResponse.json(createFetchResult(corsRules));
2187+
},
2188+
{ once: true }
2189+
),
2190+
http.delete(
2191+
"*/accounts/:accountId/r2/buckets/:bucketName/cors",
2192+
async ({ params }) => {
2193+
const { accountId, bucketName: bucketParam } = params;
2194+
expect(accountId).toEqual("some-account-id");
2195+
expect(bucketName).toEqual(bucketParam);
2196+
return HttpResponse.json(createFetchResult({}));
2197+
},
2198+
{ once: true }
2199+
)
2200+
);
2201+
await runWrangler(`r2 bucket cors delete ${bucketName}`);
2202+
expect(std.out).toMatchInlineSnapshot(`
2203+
"Deleting the CORS configuration for bucket 'my-bucket'...
2204+
CORS configuration deleted for bucket 'my-bucket'."
2205+
`);
2206+
});
2207+
});
2208+
});
20582209
});
20592210

20602211
describe("r2 object", () => {

packages/wrangler/src/r2/cors.ts

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import { defineCommand, defineNamespace } from "../core";
2+
import { confirm } from "../dialogs";
3+
import { UserError } from "../errors";
4+
import { logger } from "../logger";
5+
import { readFileSync } from "../parse";
6+
import { requireAuth } from "../user";
7+
import formatLabelledValues from "../utils/render-labelled-values";
8+
import {
9+
deleteCORSPolicy,
10+
getCORSPolicy,
11+
putCORSPolicy,
12+
tableFromCORSPolicyResponse,
13+
} from "./helpers";
14+
import type { CORSRule } from "./helpers";
15+
16+
defineNamespace({
17+
command: "wrangler r2 bucket cors",
18+
metadata: {
19+
description: "Manage CORS configuration for an R2 bucket",
20+
status: "stable",
21+
owner: "Product: R2",
22+
},
23+
});
24+
25+
defineCommand({
26+
command: "wrangler r2 bucket cors list",
27+
metadata: {
28+
description: "List the CORS rules for an R2 bucket",
29+
status: "stable",
30+
owner: "Product: R2",
31+
},
32+
positionalArgs: ["bucket"],
33+
args: {
34+
bucket: {
35+
describe: "The name of the R2 bucket to list the CORS rules for",
36+
type: "string",
37+
demandOption: true,
38+
},
39+
jurisdiction: {
40+
describe: "The jurisdiction where the bucket exists",
41+
alias: "J",
42+
requiresArg: true,
43+
type: "string",
44+
},
45+
},
46+
async handler({ bucket, jurisdiction }, { config }) {
47+
const accountId = await requireAuth(config);
48+
49+
logger.log(`Listing CORS rules for bucket '${bucket}'...`);
50+
const corsPolicy = await getCORSPolicy(accountId, bucket, jurisdiction);
51+
52+
if (corsPolicy.length === 0) {
53+
logger.log(
54+
`There is no CORS configuration defined for bucket '${bucket}'.`
55+
);
56+
} else {
57+
const tableOutput = tableFromCORSPolicyResponse(corsPolicy);
58+
logger.log(tableOutput.map((x) => formatLabelledValues(x)).join("\n\n"));
59+
}
60+
},
61+
});
62+
63+
defineCommand({
64+
command: "wrangler r2 bucket cors set",
65+
metadata: {
66+
description: "Set the CORS configuration for an R2 bucket from a JSON file",
67+
status: "stable",
68+
owner: "Product: R2",
69+
},
70+
positionalArgs: ["bucket"],
71+
args: {
72+
bucket: {
73+
describe: "The name of the R2 bucket to set the CORS configuration for",
74+
type: "string",
75+
demandOption: true,
76+
},
77+
file: {
78+
describe: "Path to the JSON file containing the CORS configuration",
79+
type: "string",
80+
demandOption: true,
81+
requiresArg: true,
82+
},
83+
jurisdiction: {
84+
describe: "The jurisdiction where the bucket exists",
85+
alias: "J",
86+
requiresArg: true,
87+
type: "string",
88+
},
89+
force: {
90+
describe: "Skip confirmation",
91+
type: "boolean",
92+
alias: "y",
93+
default: false,
94+
},
95+
},
96+
async handler({ bucket, file, jurisdiction, force }, { config }) {
97+
const accountId = await requireAuth(config);
98+
99+
let corsConfig: { rules: CORSRule[] };
100+
try {
101+
corsConfig = JSON.parse(readFileSync(file));
102+
} catch (e) {
103+
if (e instanceof Error) {
104+
throw new UserError(
105+
`Failed to read or parse the CORS configuration file: '${e.message}'`
106+
);
107+
} else {
108+
throw e;
109+
}
110+
}
111+
112+
if (!corsConfig.rules || !Array.isArray(corsConfig.rules)) {
113+
throw new UserError(
114+
"The CORS configuration file must contain a 'rules' array."
115+
);
116+
}
117+
118+
if (!force) {
119+
const confirmedRemoval = await confirm(
120+
`Are you sure you want to overwrite the existing CORS configuration for bucket '${bucket}'?`
121+
);
122+
if (!confirmedRemoval) {
123+
logger.log("Set cancelled.");
124+
return;
125+
}
126+
}
127+
128+
logger.log(
129+
`Setting CORS configuration (${corsConfig.rules.length} rules) for bucket '${bucket}'...`
130+
);
131+
await putCORSPolicy(accountId, bucket, corsConfig.rules, jurisdiction);
132+
logger.log(`✨ Set CORS configuration for bucket '${bucket}'.`);
133+
},
134+
});
135+
136+
defineCommand({
137+
command: "wrangler r2 bucket cors delete",
138+
metadata: {
139+
description: "Clear the CORS configuration for an R2 bucket",
140+
status: "stable",
141+
owner: "Product: R2",
142+
},
143+
positionalArgs: ["bucket"],
144+
args: {
145+
bucket: {
146+
describe:
147+
"The name of the R2 bucket to delete the CORS configuration for",
148+
type: "string",
149+
demandOption: true,
150+
},
151+
jurisdiction: {
152+
describe: "The jurisdiction where the bucket exists",
153+
alias: "J",
154+
requiresArg: true,
155+
type: "string",
156+
},
157+
force: {
158+
describe: "Skip confirmation",
159+
type: "boolean",
160+
alias: "y",
161+
default: false,
162+
},
163+
},
164+
async handler({ bucket, jurisdiction, force }, { config }) {
165+
const accountId = await requireAuth(config);
166+
167+
if (!force) {
168+
const confirmedRemoval = await confirm(
169+
`Are you sure you want to clear the existing CORS configuration for bucket '${bucket}'?`
170+
);
171+
if (!confirmedRemoval) {
172+
logger.log("Set cancelled.");
173+
return;
174+
}
175+
}
176+
177+
logger.log(`Deleting the CORS configuration for bucket '${bucket}'...`);
178+
await deleteCORSPolicy(accountId, bucket, jurisdiction);
179+
logger.log(`CORS configuration deleted for bucket '${bucket}'.`);
180+
},
181+
});

0 commit comments

Comments
 (0)