From bd66d511a90dd7a635ec94e95f806be7de569212 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 29 Oct 2024 07:00:01 -0500 Subject: [PATCH] Refactor hyperdrive create and update commands to match each other's behavior and address bug with individual parameters on creation (#7024) * Refactor hyperdrive create and update commands to match each other's behavior and address bug with individual parameters on creation * fix snapshots --------- Co-authored-by: emily-shen <69125074+emily-shen@users.noreply.github.com> --- .../wrangler-hyperdrive-create-params-fix.md | 7 + ...ngler-hyperdrive-create-update-refactor.md | 7 + ...r-hyperdrive-update-support-conn-string.md | 7 + .../wrangler/src/__tests__/hyperdrive.test.ts | 690 ++++++++++++------ packages/wrangler/src/hyperdrive/client.ts | 66 +- packages/wrangler/src/hyperdrive/create.ts | 206 +----- packages/wrangler/src/hyperdrive/index.ts | 272 ++++++- packages/wrangler/src/hyperdrive/update.ts | 148 +--- 8 files changed, 836 insertions(+), 567 deletions(-) create mode 100644 .changeset/wrangler-hyperdrive-create-params-fix.md create mode 100644 .changeset/wrangler-hyperdrive-create-update-refactor.md create mode 100644 .changeset/wrangler-hyperdrive-update-support-conn-string.md diff --git a/.changeset/wrangler-hyperdrive-create-params-fix.md b/.changeset/wrangler-hyperdrive-create-params-fix.md new file mode 100644 index 000000000000..0c65903c4ff3 --- /dev/null +++ b/.changeset/wrangler-hyperdrive-create-params-fix.md @@ -0,0 +1,7 @@ +--- +"wrangler": patch +--- + +fix: make individual parameters work for `wrangler hyperdrive create` when not using HoA + +`wrangler hyperdrive create` individual parameters were not setting the database name correctly when calling the api. diff --git a/.changeset/wrangler-hyperdrive-create-update-refactor.md b/.changeset/wrangler-hyperdrive-create-update-refactor.md new file mode 100644 index 000000000000..7aa62c1d100f --- /dev/null +++ b/.changeset/wrangler-hyperdrive-create-update-refactor.md @@ -0,0 +1,7 @@ +--- +"wrangler": patch +--- + +refactor: use same param parsing code for `wrangler hyperdrive create` and `wrangler hyperdrive update` + +ensures that going forward, both commands support the same features and have the same names for config flags diff --git a/.changeset/wrangler-hyperdrive-update-support-conn-string.md b/.changeset/wrangler-hyperdrive-update-support-conn-string.md new file mode 100644 index 000000000000..1e72558b8205 --- /dev/null +++ b/.changeset/wrangler-hyperdrive-update-support-conn-string.md @@ -0,0 +1,7 @@ +--- +"wrangler": minor +--- + +feature: allow using a connection string when updating hyperdrive configs + +both `hyperdrive create` and `hyperdrive update` now support updating configs with connection strings. diff --git a/packages/wrangler/src/__tests__/hyperdrive.test.ts b/packages/wrangler/src/__tests__/hyperdrive.test.ts index a5a49d4649a6..8500e020c31f 100644 --- a/packages/wrangler/src/__tests__/hyperdrive.test.ts +++ b/packages/wrangler/src/__tests__/hyperdrive.test.ts @@ -8,7 +8,11 @@ import { useMockIsTTY } from "./helpers/mock-istty"; import { createFetchResult, msw } from "./helpers/msw"; import { runInTempDir } from "./helpers/run-in-tmp"; import { runWrangler } from "./helpers/run-wrangler"; -import type { HyperdriveConfig } from "../hyperdrive/client"; +import type { + CreateUpdateHyperdriveBody, + HyperdriveConfig, + PatchHyperdriveBody, +} from "../hyperdrive/client"; describe("hyperdrive help", () => { const std = mockConsoleMethods(); @@ -94,10 +98,25 @@ describe("hyperdrive commands", () => { }); it("should handle creating a hyperdrive config", async () => { - mockHyperdriveRequest(); + const reqProm = mockHyperdriveCreate(); await runWrangler( "hyperdrive create test123 --connection-string='postgresql://test:password@example.com:12345/neondb'" ); + + await expect(reqProm).resolves.toMatchInlineSnapshot(` + Object { + "name": "test123", + "origin": Object { + "database": "neondb", + "host": "example.com", + "password": "password", + "port": 12345, + "scheme": "postgresql", + "user": "test", + }, + } + `); + expect(std.out).toMatchInlineSnapshot(` "🚧 Creating 'test123' ✅ Created new Hyperdrive config @@ -108,20 +127,32 @@ describe("hyperdrive commands", () => { \\"host\\": \\"example.com\\", \\"port\\": 12345, \\"database\\": \\"neondb\\", + \\"scheme\\": \\"postgresql\\", \\"user\\": \\"test\\" - }, - \\"caching\\": { - \\"disabled\\": false } }" `); }); it("should handle creating a hyperdrive config for postgres without a port specified", async () => { - mockHyperdriveRequest(); + const reqProm = mockHyperdriveCreate(); await runWrangler( "hyperdrive create test123 --connection-string='postgresql://test:password@example.com/neondb'" ); + + await expect(reqProm).resolves.toMatchInlineSnapshot(` + Object { + "name": "test123", + "origin": Object { + "database": "neondb", + "host": "example.com", + "password": "password", + "port": 5432, + "scheme": "postgresql", + "user": "test", + }, + } + `); expect(std.out).toMatchInlineSnapshot(` "🚧 Creating 'test123' ✅ Created new Hyperdrive config @@ -132,20 +163,36 @@ describe("hyperdrive commands", () => { \\"host\\": \\"example.com\\", \\"port\\": 5432, \\"database\\": \\"neondb\\", + \\"scheme\\": \\"postgresql\\", \\"user\\": \\"test\\" - }, - \\"caching\\": { - \\"disabled\\": false } }" `); }); it("should handle creating a hyperdrive config with caching options", async () => { - mockHyperdriveRequest(); + const reqProm = mockHyperdriveCreate(); await runWrangler( "hyperdrive create test123 --connection-string='postgresql://test:password@example.com:12345/neondb' --max-age=30 --swr=15" ); + + await expect(reqProm).resolves.toMatchInlineSnapshot(` + Object { + "caching": Object { + "max_age": 30, + "stale_while_revalidate": 15, + }, + "name": "test123", + "origin": Object { + "database": "neondb", + "host": "example.com", + "password": "password", + "port": 12345, + "scheme": "postgresql", + "user": "test", + }, + } + `); expect(std.out).toMatchInlineSnapshot(` "🚧 Creating 'test123' ✅ Created new Hyperdrive config @@ -156,10 +203,10 @@ describe("hyperdrive commands", () => { \\"host\\": \\"example.com\\", \\"port\\": 12345, \\"database\\": \\"neondb\\", + \\"scheme\\": \\"postgresql\\", \\"user\\": \\"test\\" }, \\"caching\\": { - \\"disabled\\": false, \\"max_age\\": 30, \\"stale_while_revalidate\\": 15 } @@ -168,10 +215,24 @@ describe("hyperdrive commands", () => { }); it("should handle creating a hyperdrive config if the user is URL encoded", async () => { - mockHyperdriveRequest(); + const reqProm = mockHyperdriveCreate(); await runWrangler( "hyperdrive create test123 --connection-string='postgresql://user%3Aname:password@example.com/neondb'" ); + + await expect(reqProm).resolves.toMatchInlineSnapshot(` + Object { + "name": "test123", + "origin": Object { + "database": "neondb", + "host": "example.com", + "password": "password", + "port": 5432, + "scheme": "postgresql", + "user": "user:name", + }, + } + `); expect(std.out).toMatchInlineSnapshot(` "🚧 Creating 'test123' ✅ Created new Hyperdrive config @@ -182,20 +243,32 @@ describe("hyperdrive commands", () => { \\"host\\": \\"example.com\\", \\"port\\": 5432, \\"database\\": \\"neondb\\", + \\"scheme\\": \\"postgresql\\", \\"user\\": \\"user:name\\" - }, - \\"caching\\": { - \\"disabled\\": false } }" `); }); it("should handle creating a hyperdrive config if the password is URL encoded", async () => { - mockHyperdriveRequest(); + const reqProm = mockHyperdriveCreate(); await runWrangler( "hyperdrive create test123 --connection-string='postgresql://test:a%23%3F81n%287@example.com/neondb'" ); + + await expect(reqProm).resolves.toMatchInlineSnapshot(` + Object { + "name": "test123", + "origin": Object { + "database": "neondb", + "host": "example.com", + "password": "a#?81n(7", + "port": 5432, + "scheme": "postgresql", + "user": "test", + }, + } + `); expect(std.out).toMatchInlineSnapshot(` "🚧 Creating 'test123' ✅ Created new Hyperdrive config @@ -206,20 +279,32 @@ describe("hyperdrive commands", () => { \\"host\\": \\"example.com\\", \\"port\\": 5432, \\"database\\": \\"neondb\\", + \\"scheme\\": \\"postgresql\\", \\"user\\": \\"test\\" - }, - \\"caching\\": { - \\"disabled\\": false } }" `); }); it("should handle creating a hyperdrive config if the database name is URL encoded", async () => { - mockHyperdriveRequest(); + const reqProm = mockHyperdriveCreate(); await runWrangler( "hyperdrive create test123 --connection-string='postgresql://test:password@example.com/%22weird%22%20dbname'" ); + + await expect(reqProm).resolves.toMatchInlineSnapshot(` + Object { + "name": "test123", + "origin": Object { + "database": "\\"weird\\" dbname", + "host": "example.com", + "password": "password", + "port": 5432, + "scheme": "postgresql", + "user": "test", + }, + } + `); expect(std.out).toMatchInlineSnapshot(` "🚧 Creating 'test123' ✅ Created new Hyperdrive config @@ -230,34 +315,130 @@ describe("hyperdrive commands", () => { \\"host\\": \\"example.com\\", \\"port\\": 5432, \\"database\\": \\"/\\"weird/\\" dbname\\", + \\"scheme\\": \\"postgresql\\", \\"user\\": \\"test\\" + } + }" + `); + }); + + it("should create a hyperdrive config given individual params instead of a connection string without a scheme set", async () => { + const reqProm = mockHyperdriveCreate(); + await runWrangler( + "hyperdrive create test123 --host=example.com --database=neondb --user=test --password=password --port=5432" + ); + + await expect(reqProm).resolves.toMatchInlineSnapshot(` + Object { + "name": "test123", + "origin": Object { + "database": "neondb", + "host": "example.com", + "password": "password", + "port": 5432, + "scheme": "postgresql", + "user": "test", }, - \\"caching\\": { - \\"disabled\\": false + } + `); + expect(std.out).toMatchInlineSnapshot(` + "🚧 Creating 'test123' + ✅ Created new Hyperdrive config + { + \\"id\\": \\"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx\\", + \\"name\\": \\"test123\\", + \\"origin\\": { + \\"host\\": \\"example.com\\", + \\"port\\": 5432, + \\"database\\": \\"neondb\\", + \\"scheme\\": \\"postgresql\\", + \\"user\\": \\"test\\" + } + }" + `); + }); + + it("should create a hyperdrive config given individual params instead of a connection string", async () => { + const reqProm = mockHyperdriveCreate(); + await runWrangler( + "hyperdrive create test123 --host=example.com --database=neondb --user=test --password=password --port=1234" + ); + + await expect(reqProm).resolves.toMatchInlineSnapshot(` + Object { + "name": "test123", + "origin": Object { + "database": "neondb", + "host": "example.com", + "password": "password", + "port": 1234, + "scheme": "postgresql", + "user": "test", + }, + } + `); + expect(std.out).toMatchInlineSnapshot(` + "🚧 Creating 'test123' + ✅ Created new Hyperdrive config + { + \\"id\\": \\"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx\\", + \\"name\\": \\"test123\\", + \\"origin\\": { + \\"host\\": \\"example.com\\", + \\"port\\": 1234, + \\"database\\": \\"neondb\\", + \\"scheme\\": \\"postgresql\\", + \\"user\\": \\"test\\" } }" `); }); + it("should reject a create hyperdrive command if individual params are empty strings", async () => { + await expect(() => + runWrangler( + "hyperdrive create test123 --host='' --port=5432 --database=foo --user=test --password=foo" + ) + ).rejects.toThrow(); + expect(std.err).toMatchInlineSnapshot(` + "X [ERROR] You must provide an origin hostname for the database + + " + `); + }); + it("should reject a create hyperdrive command if both connection string and individual origin params are provided", async () => { - mockHyperdriveRequest(); await expect(() => runWrangler( - "hyperdrive create test123 --connection-string='postgresql://test:password@example.com/neondb' --host=example.com --port=5432 --database=neondb --user=test" + "hyperdrive create test123 --connection-string='postgresql://test:password@example.com/neondb' --host=example.com --port=5432 --database=neondb --user=test --password=foo" ) ).rejects.toThrow(); expect(std.err).toMatchInlineSnapshot(` - "X [ERROR] Arguments host and connection-string are mutually exclusive + "X [ERROR] Arguments origin-host and connection-string are mutually exclusive " `); }); it("should create a hyperdrive over access config given the right params", async () => { - mockHyperdriveRequest(); + const reqProm = mockHyperdriveCreate(); await runWrangler( "hyperdrive create test123 --host=example.com --database=neondb --user=test --password=password --access-client-id=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.access --access-client-secret=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" ); + await expect(reqProm).resolves.toMatchInlineSnapshot(` + Object { + "name": "test123", + "origin": Object { + "access_client_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.access", + "access_client_secret": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "database": "neondb", + "host": "example.com", + "password": "password", + "scheme": "postgresql", + "user": "test", + }, + } + `); expect(std.out).toMatchInlineSnapshot(` "🚧 Creating 'test123' ✅ Created new Hyperdrive config @@ -267,21 +448,33 @@ describe("hyperdrive commands", () => { \\"origin\\": { \\"host\\": \\"example.com\\", \\"database\\": \\"neondb\\", + \\"scheme\\": \\"postgresql\\", \\"user\\": \\"test\\", \\"access_client_id\\": \\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.access\\" - }, - \\"caching\\": { - \\"disabled\\": false } }" `); }); it("should create a hyperdrive over access config with a path in the host", async () => { - mockHyperdriveRequest(); + const reqProm = mockHyperdriveCreate(); await runWrangler( "hyperdrive create test123 --host=example.com/database --database=neondb --user=test --password=password --access-client-id=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.access --access-client-secret=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" ); + await expect(reqProm).resolves.toMatchInlineSnapshot(` + Object { + "name": "test123", + "origin": Object { + "access_client_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.access", + "access_client_secret": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "database": "neondb", + "host": "example.com/database", + "password": "password", + "scheme": "postgresql", + "user": "test", + }, + } + `); expect(std.out).toMatchInlineSnapshot(` "🚧 Creating 'test123' ✅ Created new Hyperdrive config @@ -291,18 +484,15 @@ describe("hyperdrive commands", () => { \\"origin\\": { \\"host\\": \\"example.com/database\\", \\"database\\": \\"neondb\\", + \\"scheme\\": \\"postgresql\\", \\"user\\": \\"test\\", \\"access_client_id\\": \\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.access\\" - }, - \\"caching\\": { - \\"disabled\\": false } }" `); }); it("should reject a create hyperdrive over access command if access client ID is set but not access client secret", async () => { - mockHyperdriveRequest(); await expect(() => runWrangler( "hyperdrive create test123 --host=example.com --database=neondb --user=test --password=password --access-client-id='xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.access'" @@ -318,58 +508,53 @@ describe("hyperdrive commands", () => { }); it("should reject a create hyperdrive over access command if access client secret is set but not access client ID", async () => { - mockHyperdriveRequest(); await expect(() => runWrangler( "hyperdrive create test123 --host=example.com --database=neondb --user=test --password=password --access-client-secret=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" ) ).rejects.toThrow(); expect(std.err).toMatchInlineSnapshot(` - "X [ERROR] Missing dependent arguments: - - access-client-secret -> access-client-id + "X [ERROR] You must provide both an Access Client ID and Access Client Secret when configuring Hyperdrive-over-Access " `); }); it("should handle listing configs", async () => { - mockHyperdriveRequest(); + mockHyperdriveGetListOrDelete(); await runWrangler("hyperdrive list"); expect(std.out).toMatchInlineSnapshot(` - "📋 Listing Hyperdrive configs - ┌──────────────────────────────────────┬─────────┬────────┬────────────────┬──────┬──────────┬────────────────────┐ - │ id │ name │ user │ host │ port │ database │ caching │ - ├──────────────────────────────────────┼─────────┼────────┼────────────────┼──────┼──────────┼────────────────────┤ - │ xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx │ test123 │ test │ example.com │ 5432 │ neondb │ {\\"disabled\\":false} │ - ├──────────────────────────────────────┼─────────┼────────┼────────────────┼──────┼──────────┼────────────────────┤ - │ yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy │ new-db │ dbuser │ www.google.com │ 3211 │ mydb │ {\\"disabled\\":false} │ - └──────────────────────────────────────┴─────────┴────────┴────────────────┴──────┴──────────┴────────────────────┘" - `); + "📋 Listing Hyperdrive configs + ┌──────────────────────────────────────┬─────────┬────────┬────────────────┬──────┬──────────┬───────────────────┐ + │ id │ name │ user │ host │ port │ database │ caching │ + ├──────────────────────────────────────┼─────────┼────────┼────────────────┼──────┼──────────┼───────────────────┤ + │ xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx │ test123 │ test │ example.com │ 5432 │ neondb │ │ + ├──────────────────────────────────────┼─────────┼────────┼────────────────┼──────┼──────────┼───────────────────┤ + │ yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy │ new-db │ dbuser │ www.google.com │ 3211 │ mydb │ {\\"disabled\\":true} │ + └──────────────────────────────────────┴─────────┴────────┴────────────────┴──────┴──────────┴───────────────────┘" + `); }); it("should handle displaying a config", async () => { - mockHyperdriveRequest(); + mockHyperdriveGetListOrDelete(); await runWrangler("hyperdrive get xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"); expect(std.out).toMatchInlineSnapshot(` - "{ - \\"id\\": \\"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx\\", - \\"name\\": \\"test123\\", - \\"origin\\": { - \\"host\\": \\"example.com\\", - \\"port\\": 5432, - \\"database\\": \\"neondb\\", - \\"user\\": \\"test\\" - }, - \\"caching\\": { - \\"disabled\\": false - } - }" - `); + "{ + \\"id\\": \\"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx\\", + \\"name\\": \\"test123\\", + \\"origin\\": { + \\"scheme\\": \\"postgresql\\", + \\"host\\": \\"example.com\\", + \\"port\\": 5432, + \\"database\\": \\"neondb\\", + \\"user\\": \\"test\\" + } + }" + `); }); it("should handle deleting a config", async () => { - mockHyperdriveRequest(); + mockHyperdriveGetListOrDelete(); await runWrangler("hyperdrive delete xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"); expect(std.out).toMatchInlineSnapshot(` "🗑️ Deleting Hyperdrive database config xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx @@ -378,81 +563,106 @@ describe("hyperdrive commands", () => { }); it("should handle updating a hyperdrive config's origin", async () => { - mockHyperdriveRequest(); + const reqProm = mockHyperdriveUpdate(); await runWrangler( - "hyperdrive update xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx --origin-host=example.com --origin-port=1234 --database=mydb --origin-user=newuser --origin-password='passw0rd!'" + "hyperdrive update xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx --origin-host=example.com --origin-port=1234" ); + await expect(reqProm).resolves.toMatchInlineSnapshot(` + Object { + "origin": Object { + "host": "example.com", + "port": 1234, + }, + } + `); expect(std.out).toMatchInlineSnapshot(` - "🚧 Updating 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' - ✅ Updated xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx Hyperdrive config - { - \\"id\\": \\"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx\\", - \\"name\\": \\"test123\\", - \\"origin\\": { - \\"host\\": \\"example.com\\", - \\"port\\": 1234, - \\"database\\": \\"mydb\\", - \\"user\\": \\"newuser\\" - }, - \\"caching\\": { - \\"disabled\\": false - } - }" - `); + "🚧 Updating 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' + ✅ Updated xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx Hyperdrive config + { + \\"id\\": \\"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx\\", + \\"name\\": \\"test123\\", + \\"origin\\": { + \\"scheme\\": \\"postgresql\\", + \\"host\\": \\"example.com\\", + \\"port\\": 1234, + \\"database\\": \\"neondb\\", + \\"user\\": \\"test\\" + } + }" + `); }); - it("should throw an exception when updating a hyperdrive config's origin but not all fields are set", async () => { - mockHyperdriveRequest(); + it("should handle updating a hyperdrive config's user", async () => { + const reqProm = mockHyperdriveUpdate(); + await runWrangler( + "hyperdrive update xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx --origin-user=newuser --origin-password='passw0rd!'" + ); + await expect(reqProm).resolves.toMatchInlineSnapshot(` + Object { + "origin": Object { + "password": "passw0rd!", + "user": "newuser", + }, + } + `); + expect(std.out).toMatchInlineSnapshot(` + "🚧 Updating 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' + ✅ Updated xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx Hyperdrive config + { + \\"id\\": \\"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx\\", + \\"name\\": \\"test123\\", + \\"origin\\": { + \\"scheme\\": \\"postgresql\\", + \\"host\\": \\"example.com\\", + \\"port\\": 5432, + \\"database\\": \\"neondb\\", + \\"user\\": \\"newuser\\" + } + }" + `); + }); + + it("should throw an exception when creating a hyperdrive config but not all fields are set", async () => { await expect(() => runWrangler( - "hyperdrive update xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx --origin-port=1234 --database=mydb --origin-user=newuser" + "hyperdrive create test123 --origin-port=1234 --database=mydb --origin-user=newuser" ) ).rejects.toThrow(); expect(std.err).toMatchInlineSnapshot(` - "X [ERROR] Missing dependent arguments: - - origin-port -> origin-host origin-port -> origin-password database -> origin-host database -> - origin-password origin-user -> origin-host origin-user -> origin-password + "X [ERROR] You must provide a password for the origin database " `); - expect(std.out).toMatchInlineSnapshot(` - " - wrangler hyperdrive update - - Update a Hyperdrive config - - POSITIONALS - id The ID of the Hyperdrive config [string] [required] + expect(std.out).toMatchInlineSnapshot(`""`); + }); - GLOBAL FLAGS - -j, --experimental-json-config Experimental: support wrangler.json [boolean] - -c, --config Path to .toml configuration file [string] - -e, --env Environment to use for operations and .env files [string] - -h, --help Show help [boolean] - -v, --version Show version number [boolean] + it("should throw an exception when updating a hyperdrive config's origin but not all fields are set", async () => { + const _ = mockHyperdriveUpdate(); + await expect(() => + runWrangler( + "hyperdrive update xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx --origin-port=1234 --database=mydb --origin-user=newuser" + ) + ).rejects.toThrow(); + expect(std.err).toMatchInlineSnapshot(` + "X [ERROR] You must provide an origin hostname for the database - OPTIONS - --name Give your config a new name [string] - --origin-host The host of the origin database [string] - --origin-port The port number of the origin database [number] - --origin-scheme The scheme used to connect to the origin database - e.g. postgresql or postgres [string] - --database The name of the database within the origin database [string] - --origin-user The username used to connect to the origin database [string] - --origin-password The password used to connect to the origin database [string] - --access-client-id The Client ID of the Access token to use when connecting to the origin database [string] - --access-client-secret The Client Secret of the Access token to use when connecting to the origin database [string] - --caching-disabled Disables the caching of SQL responses [boolean] [default: false] - --max-age Specifies max duration for which items should persist in the cache, cannot be set when caching is disabled [number] - --swr Indicates the number of seconds cache may serve the response after it becomes stale, cannot be set when caching is disabled [number]" + " `); }); it("should handle updating a hyperdrive config's caching settings", async () => { - mockHyperdriveRequest(); + const reqProm = mockHyperdriveUpdate(); await runWrangler( "hyperdrive update xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx --max-age=30 --swr=15" ); + await expect(reqProm).resolves.toMatchInlineSnapshot(` + Object { + "caching": Object { + "max_age": 30, + "stale_while_revalidate": 15, + }, + } + `); expect(std.out).toMatchInlineSnapshot(` "🚧 Updating 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' ✅ Updated xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx Hyperdrive config @@ -460,13 +670,13 @@ describe("hyperdrive commands", () => { \\"id\\": \\"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx\\", \\"name\\": \\"test123\\", \\"origin\\": { + \\"scheme\\": \\"postgresql\\", \\"host\\": \\"example.com\\", \\"port\\": 5432, \\"database\\": \\"neondb\\", \\"user\\": \\"test\\" }, \\"caching\\": { - \\"disabled\\": false, \\"max_age\\": 30, \\"stale_while_revalidate\\": 15 } @@ -475,10 +685,18 @@ describe("hyperdrive commands", () => { }); it("should handle disabling caching for a hyperdrive config", async () => { - mockHyperdriveRequest(); + const reqProm = mockHyperdriveUpdate(); await runWrangler( "hyperdrive update xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx --caching-disabled=true" ); + + await expect(reqProm).resolves.toMatchInlineSnapshot(` + Object { + "caching": Object { + "disabled": true, + }, + } + `); expect(std.out).toMatchInlineSnapshot(` "🚧 Updating 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' ✅ Updated xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx Hyperdrive config @@ -486,6 +704,7 @@ describe("hyperdrive commands", () => { \\"id\\": \\"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx\\", \\"name\\": \\"test123\\", \\"origin\\": { + \\"scheme\\": \\"postgresql\\", \\"host\\": \\"example.com\\", \\"port\\": 5432, \\"database\\": \\"neondb\\", @@ -499,10 +718,16 @@ describe("hyperdrive commands", () => { }); it("should handle updating a hyperdrive config's name", async () => { - mockHyperdriveRequest(); + const reqProm = mockHyperdriveUpdate(); await runWrangler( "hyperdrive update xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx --name='new-name'" ); + + await expect(reqProm).resolves.toMatchInlineSnapshot(` + Object { + "name": "new-name", + } + `); expect(std.out).toMatchInlineSnapshot(` "🚧 Updating 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' ✅ Updated xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx Hyperdrive config @@ -510,23 +735,34 @@ describe("hyperdrive commands", () => { \\"id\\": \\"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx\\", \\"name\\": \\"new-name\\", \\"origin\\": { + \\"scheme\\": \\"postgresql\\", \\"host\\": \\"example.com\\", \\"port\\": 5432, \\"database\\": \\"neondb\\", \\"user\\": \\"test\\" - }, - \\"caching\\": { - \\"disabled\\": false } }" `); }); it("should handle updating a hyperdrive to a hyperdrive over access config given the right parameters", async () => { - mockHyperdriveRequest(); + const reqProm = mockHyperdriveUpdate(); await runWrangler( "hyperdrive update xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx --origin-host=example.com --database=mydb --origin-user=newuser --origin-password='passw0rd!' --access-client-id='xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.access' --access-client-secret='xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'" ); + + await expect(reqProm).resolves.toMatchInlineSnapshot(` + Object { + "origin": Object { + "access_client_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.access", + "access_client_secret": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "database": "mydb", + "host": "example.com", + "password": "passw0rd!", + "user": "newuser", + }, + } + `); expect(std.out).toMatchInlineSnapshot(` "🚧 Updating 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' ✅ Updated xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx Hyperdrive config @@ -534,27 +770,24 @@ describe("hyperdrive commands", () => { \\"id\\": \\"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx\\", \\"name\\": \\"test123\\", \\"origin\\": { + \\"scheme\\": \\"postgresql\\", \\"host\\": \\"example.com\\", \\"database\\": \\"mydb\\", \\"user\\": \\"newuser\\", \\"access_client_id\\": \\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.access\\" - }, - \\"caching\\": { - \\"disabled\\": false } }" `); }); it("should throw an exception when updating a hyperdrive config's origin but neither port nor access credentials are provided", async () => { - mockHyperdriveRequest(); await expect(() => runWrangler( "hyperdrive update xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx --origin-host=example.com --database=mydb --origin-user=newuser --origin-password='passw0rd!'" ) ).rejects.toThrow(); expect(std.err).toMatchInlineSnapshot(` - "X [ERROR] When updating the origin, either the port or the Access Client ID and Secret must be set + "X [ERROR] You must provide a nonzero origin port for the database " `); @@ -562,55 +795,21 @@ describe("hyperdrive commands", () => { }); it("should throw an exception when updating a hyperdrive config's origin with access credentials but no other origin fields", async () => { - mockHyperdriveRequest(); + const _ = mockHyperdriveUpdate(); await expect(() => runWrangler( "hyperdrive update xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx --access-client-id='xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.access' --access-client-secret='xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'" ) ).rejects.toThrow(); expect(std.err).toMatchInlineSnapshot(` - "X [ERROR] Missing dependent arguments: - - access-client-id -> origin-host access-client-id -> database access-client-id -> origin-user - access-client-id -> origin-password access-client-secret -> origin-host access-client-secret -> - database access-client-secret -> origin-user access-client-secret -> origin-password + "X [ERROR] You must provide an origin hostname for the database " `); - expect(std.out).toMatchInlineSnapshot(` - " - wrangler hyperdrive update - - Update a Hyperdrive config - - POSITIONALS - id The ID of the Hyperdrive config [string] [required] - - GLOBAL FLAGS - -j, --experimental-json-config Experimental: support wrangler.json [boolean] - -c, --config Path to .toml configuration file [string] - -e, --env Environment to use for operations and .env files [string] - -h, --help Show help [boolean] - -v, --version Show version number [boolean] - - OPTIONS - --name Give your config a new name [string] - --origin-host The host of the origin database [string] - --origin-port The port number of the origin database [number] - --origin-scheme The scheme used to connect to the origin database - e.g. postgresql or postgres [string] - --database The name of the database within the origin database [string] - --origin-user The username used to connect to the origin database [string] - --origin-password The password used to connect to the origin database [string] - --access-client-id The Client ID of the Access token to use when connecting to the origin database [string] - --access-client-secret The Client Secret of the Access token to use when connecting to the origin database [string] - --caching-disabled Disables the caching of SQL responses [boolean] [default: false] - --max-age Specifies max duration for which items should persist in the cache, cannot be set when caching is disabled [number] - --swr Indicates the number of seconds cache may serve the response after it becomes stale, cannot be set when caching is disabled [number]" - `); + expect(std.out).toMatchInlineSnapshot(`""`); }); it("should reject an update command if the access client ID is provided but not the access client secret", async () => { - mockHyperdriveRequest(); await expect(() => runWrangler( "hyperdrive update xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx --origin-host=example.com --database=mydb --origin-user=newuser --origin-password='passw0rd!' --access-client-id='xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.access'" @@ -626,16 +825,13 @@ describe("hyperdrive commands", () => { }); it("should reject an update command if the access client secret is provided but not the access client ID", async () => { - mockHyperdriveRequest(); await expect(() => runWrangler( "hyperdrive update xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx --origin-host=example.com --database=mydb --origin-user=newuser --origin-password='passw0rd!' --access-client-secret='xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'" ) ).rejects.toThrow(); expect(std.err).toMatchInlineSnapshot(` - "X [ERROR] Missing dependent arguments: - - access-client-secret -> access-client-id + "X [ERROR] You must provide both an Access Client ID and Access Client Secret when configuring Hyperdrive-over-Access " `); @@ -646,18 +842,16 @@ const defaultConfig: HyperdriveConfig = { id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", name: "test123", origin: { + scheme: "postgresql", host: "example.com", port: 5432, database: "neondb", user: "test", }, - caching: { - disabled: false, - }, }; /** Create a mock handler for Hyperdrive API */ -function mockHyperdriveRequest() { +function mockHyperdriveGetListOrDelete() { msw.use( http.get( "*/accounts/:accountId/hyperdrive/configs/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", @@ -666,59 +860,6 @@ function mockHyperdriveRequest() { }, { once: true } ), - http.post( - "*/accounts/:accountId/hyperdrive/configs", - async ({ request }) => { - const reqBody = (await request.json()) as HyperdriveConfig; - return HttpResponse.json( - createFetchResult( - { - id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - name: reqBody.name, - origin: { - host: reqBody.origin.host, - port: reqBody.origin.port, - database: reqBody.origin.database, - // @ts-expect-error This is a string - scheme: reqBody.origin.protocol, - user: reqBody.origin.user, - access_client_id: reqBody.origin.access_client_id, - }, - caching: reqBody.caching, - }, - true - ) - ); - }, - { once: true } - ), - http.patch( - "*/accounts/:accountId/hyperdrive/configs/:configId", - async ({ request }) => { - const reqBody = (await request.json()) as HyperdriveConfig; - return HttpResponse.json( - createFetchResult( - { - id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - name: reqBody.name ?? defaultConfig.name, - origin: - reqBody.origin !== undefined - ? { - host: reqBody.origin.host, - port: reqBody.origin.port, - database: reqBody.origin.database, - user: reqBody.origin.user, - access_client_id: reqBody.origin.access_client_id, - } - : defaultConfig.origin, - caching: reqBody.caching ?? defaultConfig.caching, - }, - true - ) - ); - }, - { once: true } - ), http.delete( "*/accounts/:accountId/hyperdrive/configs/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", () => { @@ -741,9 +882,10 @@ function mockHyperdriveRequest() { port: 3211, database: "mydb", user: "dbuser", + scheme: "postgresql", }, caching: { - disabled: false, + disabled: true, }, }, ], @@ -755,3 +897,95 @@ function mockHyperdriveRequest() { ) ); } + +/** Create a mock handler for Hyperdrive API */ +function mockHyperdriveUpdate(): Promise { + return new Promise((resolve) => { + msw.use( + http.get( + "*/accounts/:accountId/hyperdrive/configs/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + () => { + return HttpResponse.json(createFetchResult(defaultConfig, true)); + }, + { once: true } + ), + http.patch( + "*/accounts/:accountId/hyperdrive/configs/:configId", + async ({ request }) => { + const reqBody = (await request.json()) as PatchHyperdriveBody; + + resolve(reqBody); + + let origin = defaultConfig.origin; + if (reqBody.origin) { + const { + password: _, + access_client_secret: _2, + ...reqOrigin + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } = reqBody.origin as any; + origin = { ...origin, ...reqOrigin }; + if (reqOrigin.port) { + delete origin.access_client_id; + delete origin.access_client_secret; + } else if ( + reqOrigin.access_client_id || + reqOrigin.access_client_secret + ) { + delete origin.port; + } + } + + return HttpResponse.json( + createFetchResult( + { + id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + name: reqBody.name ?? defaultConfig.name, + origin, + caching: reqBody.caching ?? defaultConfig.caching, + }, + true + ) + ); + }, + { once: true } + ) + ); + }); +} + +/** Create a mock handler for Hyperdrive API */ +function mockHyperdriveCreate(): Promise { + return new Promise((resolve) => { + msw.use( + http.post( + "*/accounts/:accountId/hyperdrive/configs", + async ({ request }) => { + const reqBody = (await request.json()) as CreateUpdateHyperdriveBody; + + resolve(reqBody); + + return HttpResponse.json( + createFetchResult( + { + id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + name: reqBody.name, + origin: { + host: reqBody.origin.host, + port: reqBody.origin.port, + database: reqBody.origin.database, + scheme: reqBody.origin.scheme, + user: reqBody.origin.user, + access_client_id: reqBody.origin.access_client_id, + }, + caching: reqBody.caching, + }, + true + ) + ); + }, + { once: true } + ) + ); + }); +} diff --git a/packages/wrangler/src/hyperdrive/client.ts b/packages/wrangler/src/hyperdrive/client.ts index 5ec3946d74d9..a2d204ec1623 100644 --- a/packages/wrangler/src/hyperdrive/client.ts +++ b/packages/wrangler/src/hyperdrive/client.ts @@ -6,23 +6,63 @@ export type HyperdriveConfig = { id: string; name: string; origin: PublicOrigin; - caching: CachingOptions; + caching?: CachingOptions; +}; + +export type OriginDatabase = { + scheme: string; + database: string; + user: string; + + // ensure password not set, must use OriginCommonWithSecrets + password?: never; +}; + +export type OriginDatabaseWithSecrets = Omit & { + password: string; +}; + +export type NetworkOriginHoA = { + host: string; + access_client_id: string; + + // Ensure post is not set, and secrets are not set + port?: never; + access_client_secret?: never; }; -export type PublicOrigin = { - host?: string; - port?: number; - scheme?: string; - database?: string; - user?: string; - access_client_id?: string; +export type NetworkOriginHoAWithSecrets = Omit< + NetworkOriginHoA, + "access_client_secret" +> & { + access_client_secret: string; }; -export type OriginWithSecrets = PublicOrigin & { - password?: string; - access_client_secret?: string; +export type NetworkOriginHostAndPort = { + host: string; + port: number; + + // Ensure HoA fields are not set + access_client_id?: never; + access_client_secret?: never; }; +// NetworkOrigin is never partial in the API, it must be submitted in it's entirety +export type NetworkOrigin = NetworkOriginHoA | NetworkOriginHostAndPort; +export type NetworkOriginWithSecrets = + | NetworkOriginHoAWithSecrets + | NetworkOriginHostAndPort; + +// Public responses of the full PublicOrigin type are never partial in the API +export type PublicOrigin = OriginDatabase & NetworkOrigin; + +// But the OriginWithSecrets has a partial variant for updates, that is only partial for fields in OriginDatabaseWithSecrets -- we always require a full NetworkOriginWithSecrets +export type OriginWithSecrets = OriginDatabaseWithSecrets & + NetworkOriginWithSecrets; +export type OriginWithSecretsPartial = + | (Partial & NetworkOriginWithSecrets) + | Partial; + export type CachingOptions = { disabled?: boolean; max_age?: number; @@ -32,12 +72,12 @@ export type CachingOptions = { export type CreateUpdateHyperdriveBody = { name: string; origin: OriginWithSecrets; - caching: CachingOptions; + caching?: CachingOptions; }; export type PatchHyperdriveBody = { name?: string; - origin?: OriginWithSecrets; + origin?: OriginWithSecretsPartial; caching?: CachingOptions; }; diff --git a/packages/wrangler/src/hyperdrive/create.ts b/packages/wrangler/src/hyperdrive/create.ts index f9a740cd918e..7d6b3997ce84 100644 --- a/packages/wrangler/src/hyperdrive/create.ts +++ b/packages/wrangler/src/hyperdrive/create.ts @@ -1,208 +1,40 @@ import { readConfig } from "../config"; -import { UserError } from "../errors"; import { logger } from "../logger"; import { createConfig } from "./client"; +import { getCacheOptionsFromArgs, getOriginFromArgs, upsertOptions } from "."; import type { CommonYargsArgv, StrictYargsOptionsToInterface, } from "../yargs-types"; -export function options(yargs: CommonYargsArgv) { - return yargs +export function options(commonYargs: CommonYargsArgv) { + const yargs = commonYargs .positional("name", { type: "string", demandOption: true, description: "The name of the Hyperdrive config", }) - .options({ - "connection-string": { - type: "string", - describe: - "The connection string for the database you want Hyperdrive to connect to - ex: protocol://user:password@host:port/database", - }, - host: { - type: "string", - describe: "The host of the origin database", - conflicts: "connection-string", - }, - port: { - type: "number", - describe: "The port number of the origin database", - conflicts: [ - "connection-string", - "access-client-id", - "access-client-secret", - ], - }, - scheme: { - type: "string", - describe: - "The scheme used to connect to the origin database - e.g. postgresql or postgres", - default: "postgresql", - }, - database: { - type: "string", - describe: "The name of the database within the origin database", - conflicts: "connection-string", - }, - user: { - type: "string", - describe: "The username used to connect to the origin database", - conflicts: "connection-string", - }, - password: { - type: "string", - describe: "The password used to connect to the origin database", - conflicts: "connection-string", - }, - "access-client-id": { - type: "string", - describe: - "The Client ID of the Access token to use when connecting to the origin database, must be set with a Client Access Secret", - conflicts: ["connection-string", "port"], - implies: ["access-client-secret"], - }, - "access-client-secret": { - type: "string", - describe: - "The Client Secret of the Access token to use when connecting to the origin database, must be set with a Client Access ID", - conflicts: ["connection-string", "port"], - implies: ["access-client-id"], - }, - "caching-disabled": { - type: "boolean", - describe: "Disables the caching of SQL responses", - default: false, - }, - "max-age": { - type: "number", - describe: - "Specifies max duration for which items should persist in the cache, cannot be set when caching is disabled", - }, - swr: { - type: "number", - describe: - "Indicates the number of seconds cache may serve the response after it becomes stale, cannot be set when caching is disabled", - }, + .default({ + "origin-scheme": "postgresql", }); + + return upsertOptions(yargs); } export async function handler( args: StrictYargsOptionsToInterface ) { const config = readConfig(args.config, args); - - const url = args.connectionString - ? new URL(args.connectionString) - : buildURLFromParts( - args.host, - args.port, - args.scheme, - args.user, - args.password - ); - - if ( - url.port === "" && - (url.protocol == "postgresql:" || url.protocol == "postgres:") - ) { - url.port = "5432"; - } - - if (url.protocol === "") { - throw new UserError( - "You must specify the database protocol - e.g. 'postgresql'." - ); - } else if (url.protocol !== "postgresql:" && url.protocol !== "postgres:") { - throw new UserError( - "Only PostgreSQL or PostgreSQL compatible databases are currently supported." - ); - } else if (url.host === "") { - throw new UserError( - "You must provide a hostname or IP address in your connection string - e.g. 'user:password@database-hostname.example.com:5432/databasename" - ); - } else if (url.port === "") { - throw new UserError( - "You must provide a port number - e.g. 'user:password@database.example.com:port/databasename" - ); - } else if ( - (args.connectionString && url.pathname === "") || - args.database === "" - ) { - throw new UserError( - "You must provide a database name as the path component - e.g. example.com:port/postgres" - ); - } else if (url.username === "") { - throw new UserError( - "You must provide a username - e.g. 'user:password@database.example.com:port/databasename'" - ); - } else if (url.password === "") { - throw new UserError( - "You must provide a password - e.g. 'user:password@database.example.com:port/databasename' " - ); - } else { - logger.log(`🚧 Creating '${args.name}'`); - - // if access client ID and client secret supplied in args, use them to construct origin without a port - const origin = - args.accessClientId && args.accessClientSecret - ? { - host: url.hostname + url.pathname, - scheme: url.protocol.replace(":", ""), - database: args.database, - user: decodeURIComponent(url.username), - password: decodeURIComponent(url.password), - access_client_id: args.accessClientId, - access_client_secret: args.accessClientSecret, - } - : { - host: url.hostname, - port: parseInt(url.port), - scheme: url.protocol.replace(":", ""), - // database will either be the value passed in the relevant yargs flag or is URL-decoded value from the url pathname - database: - args.connectionString !== "" - ? decodeURIComponent(url.pathname.replace("/", "")) - : args.database, - user: decodeURIComponent(url.username), - password: decodeURIComponent(url.password), - }; - const database = await createConfig(config, { - name: args.name, - origin, - caching: { - disabled: args.cachingDisabled, - max_age: args.maxAge, - stale_while_revalidate: args.swr, - }, - }); - logger.log( - `✅ Created new Hyperdrive config\n`, - JSON.stringify(database, null, 2) - ); - } -} - -function buildURLFromParts( - host: string | undefined, - port: number | undefined, - scheme: string, - user: string | undefined, - password: string | undefined -): URL { - const url = new URL(`${scheme}://${host}`); - - if (port) { - url.port = port.toString(); - } - - if (user) { - url.username = user; - } - - if (password) { - url.password = password; - } - - return url; + const origin = getOriginFromArgs(false, args); + + logger.log(`🚧 Creating '${args.name}'`); + const database = await createConfig(config, { + name: args.name, + origin, + caching: getCacheOptionsFromArgs(args), + }); + logger.log( + `✅ Created new Hyperdrive config\n`, + JSON.stringify(database, null, 2) + ); } diff --git a/packages/wrangler/src/hyperdrive/index.ts b/packages/wrangler/src/hyperdrive/index.ts index f471ff6090e9..ec65bb5c0fc6 100644 --- a/packages/wrangler/src/hyperdrive/index.ts +++ b/packages/wrangler/src/hyperdrive/index.ts @@ -1,9 +1,22 @@ +import chalk from "chalk"; +import { type Argv } from "yargs"; +import { UserError } from "../errors"; import { handler as createHandler, options as createOptions } from "./create"; import { handler as deleteHandler, options as deleteOptions } from "./delete"; import { handler as getHandler, options as getOptions } from "./get"; import { handler as listHandler, options as listOptions } from "./list"; import { handler as updateHandler, options as updateOptions } from "./update"; -import type { CommonYargsArgv } from "../yargs-types"; +import type { + CommonYargsArgv, + StrictYargsOptionsToInterface, +} from "../yargs-types"; +import type { + CachingOptions, + NetworkOriginWithSecrets, + OriginDatabaseWithSecrets, + OriginWithSecrets, + OriginWithSecretsPartial, +} from "./client"; export function hyperdrive(yargs: CommonYargsArgv) { return yargs @@ -28,3 +41,260 @@ export function hyperdrive(yargs: CommonYargsArgv) { updateHandler ); } + +export function upsertOptions(yargs: Argv) { + return yargs + .option({ + "connection-string": { + type: "string", + describe: + "The connection string for the database you want Hyperdrive to connect to - ex: protocol://user:password@host:port/database", + }, + "origin-host": { + alias: "host", + type: "string", + describe: "The host of the origin database", + conflicts: "connection-string", + }, + "origin-port": { + alias: "port", + type: "number", + describe: "The port number of the origin database", + conflicts: [ + "connection-string", + "access-client-id", + "access-client-secret", + ], + }, + "origin-scheme": { + alias: "scheme", + type: "string", + choices: ["postgres", "postgresql"], + describe: "The scheme used to connect to the origin database", + }, + database: { + type: "string", + describe: "The name of the database within the origin database", + conflicts: "connection-string", + }, + "origin-user": { + alias: "user", + type: "string", + describe: "The username used to connect to the origin database", + conflicts: "connection-string", + }, + "origin-password": { + alias: "password", + type: "string", + describe: "The password used to connect to the origin database", + conflicts: "connection-string", + }, + "access-client-id": { + type: "string", + describe: + "The Client ID of the Access token to use when connecting to the origin database", + conflicts: ["connection-string", "origin-port"], + implies: ["access-client-secret"], + }, + "access-client-secret": { + type: "string", + describe: + "The Client Secret of the Access token to use when connecting to the origin database", + conflicts: ["connection-string", "origin-port"], + }, + "caching-disabled": { + type: "boolean", + describe: "Disables the caching of SQL responses", + }, + "max-age": { + type: "number", + describe: + "Specifies max duration for which items should persist in the cache, cannot be set when caching is disabled", + }, + swr: { + type: "number", + describe: + "Indicates the number of seconds cache may serve the response after it becomes stale, cannot be set when caching is disabled", + }, + }) + .group( + ["connection-string"], + `${chalk.bold("Configure using a connection string")}` + ) + .group( + [ + "name", + "origin-host", + "origin-port", + "scheme", + "database", + "origin-user", + "origin-password", + ], + `${chalk.bold("Configure using individual parameters [conflicts with --connection-string]")}` + ) + .group( + ["access-client-id", "access-client-secret"], + `${chalk.bold("Hyperdrive over Access [conflicts with --connection-string, --origin-port]")}` + ) + .group( + ["caching-disabled", "max-age", "swr"], + `${chalk.bold("Caching Options")}` + ); +} + +export function getOriginFromArgs< + PartialUpdate extends boolean, + OriginConfig = PartialUpdate extends true + ? OriginWithSecretsPartial + : OriginWithSecrets, +>( + allowPartialOrigin: PartialUpdate, + args: StrictYargsOptionsToInterface +): PartialUpdate extends true ? OriginConfig | undefined : OriginConfig { + if (args.connectionString) { + const url = new URL(args.connectionString); + const errorMessages = []; + if ( + url.port === "" && + (url.protocol == "postgresql:" || url.protocol == "postgres:") + ) { + url.port = "5432"; + } + + if (url.protocol === "") { + errorMessages.push( + "You must specify the database protocol - e.g. 'postgresql'." + ); + } else if (url.protocol !== "postgresql:" && url.protocol !== "postgres:") { + throw new UserError( + "Only PostgreSQL or PostgreSQL compatible databases are currently supported." + ); + } else if (url.host === "") { + throw new UserError( + "You must provide a hostname or IP address in your connection string - e.g. 'user:password@database-hostname.example.com:5432/databasename" + ); + } else if (url.port === "") { + throw new UserError( + "You must provide a port number - e.g. 'user:password@database.example.com:port/databasename" + ); + } else if (url.pathname === "") { + throw new UserError( + "You must provide a database name as the path component - e.g. example.com:port/postgres" + ); + } else if (url.username === "") { + throw new UserError( + "You must provide a username - e.g. 'user:password@database.example.com:port/databasename'" + ); + } else if (url.password === "") { + throw new UserError( + "You must provide a password - e.g. 'user:password@database.example.com:port/databasename' " + ); + } + + return { + host: url.hostname, + port: parseInt(url.port), + scheme: url.protocol.replace(":", ""), + database: decodeURIComponent(url.pathname.replace("/", "")), + user: decodeURIComponent(url.username), + password: decodeURIComponent(url.password), + } as OriginConfig; + } + + if (!allowPartialOrigin) { + if (!args.originScheme) { + throw new UserError( + "You must specify the database protocol as --origin-scheme - e.g. 'postgresql'" + ); + } else if (!args.database) { + throw new UserError("You must provide a database name"); + } else if (!args.originUser) { + throw new UserError( + "You must provide a username for the origin database" + ); + } else if (!args.originPassword) { + throw new UserError( + "You must provide a password for the origin database" + ); + } + } + + const databaseConfig = { + scheme: args.originScheme, + database: args.database, + user: args.originUser, + password: args.originPassword, + } as PartialUpdate extends true + ? Partial + : OriginDatabaseWithSecrets; + + let networkOrigin: NetworkOriginWithSecrets | undefined; + if (args.accessClientId || args.accessClientSecret) { + if (!args.accessClientId || !args.accessClientSecret) { + throw new UserError( + "You must provide both an Access Client ID and Access Client Secret when configuring Hyperdrive-over-Access" + ); + } + + if (!args.originHost || args.originHost == "") { + throw new UserError( + "You must provide an origin hostname for the database" + ); + } + + networkOrigin = { + access_client_id: args.accessClientId, + access_client_secret: args.accessClientSecret, + host: args.originHost, + }; + } else if (args.originHost || args.originPort) { + if (!args.originHost) { + throw new UserError( + "You must provide an origin hostname for the database" + ); + } + + if (!args.originPort) { + throw new UserError( + "You must provide a nonzero origin port for the database" + ); + } + + networkOrigin = { + host: args.originHost, + port: args.originPort, + }; + } + + const origin = { + ...databaseConfig, + ...networkOrigin, + }; + + if (JSON.stringify(origin) === "{}") { + return undefined as PartialUpdate extends true + ? OriginConfig | undefined + : OriginConfig; + } else { + return origin as PartialUpdate extends true + ? OriginConfig | undefined + : OriginConfig; + } +} + +export function getCacheOptionsFromArgs( + args: StrictYargsOptionsToInterface +): CachingOptions | undefined { + const caching = { + disabled: args.cachingDisabled, + max_age: args.maxAge, + stale_while_revalidate: args.swr, + }; + + if (JSON.stringify(caching) === "{}") { + return undefined; + } else { + return caching; + } +} diff --git a/packages/wrangler/src/hyperdrive/update.ts b/packages/wrangler/src/hyperdrive/update.ts index 28e46d30fa96..4354d5a5c690 100644 --- a/packages/wrangler/src/hyperdrive/update.ts +++ b/packages/wrangler/src/hyperdrive/update.ts @@ -1,15 +1,14 @@ import { readConfig } from "../config"; -import { UserError } from "../errors"; import { logger } from "../logger"; import { patchConfig } from "./client"; +import { getCacheOptionsFromArgs, getOriginFromArgs, upsertOptions } from "."; import type { CommonYargsArgv, StrictYargsOptionsToInterface, } from "../yargs-types"; -import type { PatchHyperdriveBody } from "./client"; -export function options(yargs: CommonYargsArgv) { - return yargs +export function options(commonYargs: CommonYargsArgv) { + const yargs = commonYargs .positional("id", { type: "string", demandOption: true, @@ -17,150 +16,23 @@ export function options(yargs: CommonYargsArgv) { }) .options({ name: { type: "string", describe: "Give your config a new name" }, - "origin-host": { - type: "string", - describe: "The host of the origin database", - implies: ["database", "origin-user", "origin-password"], - }, - "origin-port": { - type: "number", - describe: "The port number of the origin database", - implies: ["origin-host", "database", "origin-user", "origin-password"], - }, - "origin-scheme": { - type: "string", - describe: - "The scheme used to connect to the origin database - e.g. postgresql or postgres", - }, - database: { - type: "string", - describe: "The name of the database within the origin database", - implies: ["origin-host", "origin-user", "origin-password"], - }, - "origin-user": { - type: "string", - describe: "The username used to connect to the origin database", - implies: ["origin-host", "database", "origin-password"], - }, - "origin-password": { - type: "string", - describe: "The password used to connect to the origin database", - implies: ["origin-host", "database", "origin-user"], - }, - "access-client-id": { - type: "string", - describe: - "The Client ID of the Access token to use when connecting to the origin database", - conflicts: ["origin-port"], - implies: [ - "access-client-secret", - "origin-host", - "database", - "origin-user", - "origin-password", - ], - }, - "access-client-secret": { - type: "string", - describe: - "The Client Secret of the Access token to use when connecting to the origin database", - conflicts: ["origin-port"], - implies: [ - "access-client-id", - "origin-host", - "database", - "origin-user", - "origin-password", - ], - }, - "caching-disabled": { - type: "boolean", - describe: "Disables the caching of SQL responses", - default: false, - }, - "max-age": { - type: "number", - describe: - "Specifies max duration for which items should persist in the cache, cannot be set when caching is disabled", - }, - swr: { - type: "number", - describe: - "Indicates the number of seconds cache may serve the response after it becomes stale, cannot be set when caching is disabled", - }, }); -} - -const coreOriginOptions = [ - "originHost", - "database", - "originUser", - "originPassword", -] as const; -function isOptionSet(args: T, key: keyof T): boolean { - return key in args && args[key] !== undefined; + return upsertOptions(yargs); } export async function handler( args: StrictYargsOptionsToInterface ) { - // check if all or none of the required origin fields are set, since we don't allow partial updates of the origin - const coreOriginFieldsSet = coreOriginOptions.every((field) => - isOptionSet(args, field) - ); - - if ( - coreOriginFieldsSet && - args.originPort === undefined && - args.accessClientId === undefined && - args.accessClientSecret === undefined - ) { - throw new UserError( - `When updating the origin, either the port or the Access Client ID and Secret must be set` - ); - } - const config = readConfig(args.config, args); + const origin = getOriginFromArgs(true, args); logger.log(`🚧 Updating '${args.id}'`); - - const database: PatchHyperdriveBody = {}; - - if (args.name !== undefined) { - database.name = args.name; - } - - if (coreOriginFieldsSet) { - if (args.accessClientId && args.accessClientSecret) { - database.origin = { - scheme: args.originScheme ?? "postgresql", - host: args.originHost, - database: args.database, - user: args.originUser, - password: args.originPassword, - access_client_id: args.accessClientId, - access_client_secret: args.accessClientSecret, - }; - } else { - database.origin = { - scheme: args.originScheme ?? "postgresql", - host: args.originHost, - port: args.originPort, - database: args.database, - user: args.originUser, - password: args.originPassword, - }; - } - } - - database.caching = { - disabled: args.cachingDisabled, - max_age: args.maxAge, - stale_while_revalidate: args.swr, - }; - - const updated = await patchConfig(config, args.id, database); + const updated = await patchConfig(config, args.id, { + name: args.name, + origin, + caching: getCacheOptionsFromArgs(args), + }); logger.log( `✅ Updated ${updated.id} Hyperdrive config\n`, JSON.stringify(updated, null, 2)