diff --git a/connectors/producer-polar/test/api.vcr.test.ts b/connectors/producer-polar/test/api.vcr.test.ts index ba198d4..1fc111c 100644 --- a/connectors/producer-polar/test/api.vcr.test.ts +++ b/connectors/producer-polar/test/api.vcr.test.ts @@ -35,7 +35,10 @@ describe("producer-polar api (vcr)", () => { const cassetteLayer = CassetteStoreLive.pipe( Layer.provide(NodeFileSystem.layer), ); - const vcrLayer = VcrHttpClientLayer().pipe( + const vcrLayer = VcrHttpClientLayer({ + connectorName: "producer-polar", + mode: "replay", + }).pipe( Layer.provide(Layer.mergeAll(NodeHttpClient.layerFetch, cassetteLayer)), ); diff --git a/packages/effect-http-client/README.md b/packages/effect-http-client/README.md index ac5ba6e..11c0583 100644 --- a/packages/effect-http-client/README.md +++ b/packages/effect-http-client/README.md @@ -32,6 +32,7 @@ const runnable = program.pipe( Effect.provide(FetchHttpClient.layer), Effect.provide( VcrHttpClientLayer({ + connectorName: "producer-polar", cassetteDir: "./cassettes", cassetteName: "example", mode: "auto", @@ -62,6 +63,7 @@ const runnable = program.pipe( Effect.provide(FetchHttpClient.layer), Effect.provide( VcrHttpClientLayer({ + connectorName: "producer-polar", cassetteDir: "./cassettes", cassetteName: "example", mode: "auto", @@ -99,6 +101,7 @@ export interface CassetteStoreService { type VcrMode = "record" | "replay" | "auto"; type VcrConfig = { + connectorName: string; cassetteDir: string; cassetteName: string; mode: VcrMode; @@ -137,3 +140,5 @@ Notes: - Request body streams are not consumed; they are represented as `"[stream]"`. - CI detection uses Effect Config (`Config.boolean("CI")`), so you can override it with a `ConfigProvider`. +- Connector-selective bypass is supported via `ACK_DISABLE_VCR` (comma-separated connector slugs). +- Set `connectorName` in `VcrConfig` to enable connector-specific bypass matching against `ACK_DISABLE_VCR`. diff --git a/packages/effect-http-client/src/types.ts b/packages/effect-http-client/src/types.ts index ff93d7b..8a355c7 100644 --- a/packages/effect-http-client/src/types.ts +++ b/packages/effect-http-client/src/types.ts @@ -55,6 +55,7 @@ export type VcrCassetteFile = { * VCR configuration. */ export type VcrConfig = { + readonly connectorName: string; readonly cassetteDir?: string; readonly cassetteName?: string; readonly mode?: VcrMode; diff --git a/packages/effect-http-client/src/vcr-http-client.ts b/packages/effect-http-client/src/vcr-http-client.ts index 06b0000..12429e3 100644 --- a/packages/effect-http-client/src/vcr-http-client.ts +++ b/packages/effect-http-client/src/vcr-http-client.ts @@ -1,4 +1,4 @@ -import { Config, Effect } from "effect"; +import { Config, Effect, Option } from "effect"; import { HttpClient, HttpClientError, @@ -78,6 +78,7 @@ const resolveCassetteLocation = (config: VcrConfig) => { * Apply defaults for common VCR behavior while preserving explicit overrides. */ const normalizeConfig = (config: VcrConfig) => ({ + connectorName: config.connectorName, cassetteDir: config.cassetteDir, cassetteName: config.cassetteName, mode: config.mode ?? "auto", @@ -92,6 +93,31 @@ const normalizeConfig = (config: VcrConfig) => ({ match: config.match, }); +const AckDisableVcrConfig = Config.option( + Config.string("ACK_DISABLE_VCR"), +).pipe( + Config.map((value) => + Option.match(value, { + onNone: () => new Set(), + onSome: (raw) => + new Set( + raw + .split(",") + .map((segment) => segment.trim().toLowerCase()) + .filter((segment) => segment.length > 0), + ), + }), + ), +); + +const shouldDisableVcr = (options: { + readonly connectorName: string; + readonly disabledConnectors: ReadonlySet; +}): boolean => { + const normalized = options.connectorName.trim().toLowerCase(); + return options.disabledConnectors.has(normalized); +}; + /** * Convert store or replay failures into HttpClient transport errors. */ @@ -360,28 +386,93 @@ export const makeVcrHttpClient = ( config: VcrConfig, ) => Effect.gen(function* () { + const normalized = normalizeConfig(config); + const isCi = yield* Config.boolean("CI").pipe(Config.withDefault(false)); + const disabledVcrConnectors = yield* AckDisableVcrConfig; + const vcrDisabledForConnector = shouldDisableVcr({ + connectorName: normalized.connectorName, + disabledConnectors: disabledVcrConnectors, + }); + + if (vcrDisabledForConnector) { + return live; + } + // CassetteStore is provided via Layer for platform-specific persistence. const store = yield* CassetteStore; - const normalized = normalizeConfig(config); const { path, exportKey } = resolveCassetteLocation(normalized); - const isCi = yield* Config.boolean("CI").pipe(Config.withDefault(false)); const client = live.pipe( HttpClient.transform((effect, request) => { - const vcrRequest = toVcrRequest(request); - if (normalized.mode === "replay") { - return replay( - request, - vcrRequest, - normalized, - store, + return Effect.gen(function* () { + const vcrRequest = toVcrRequest(request); + if (normalized.mode === "replay") { + return yield* replay( + request, + vcrRequest, + normalized, + store, + path, + exportKey, + ); + } + + if (normalized.mode === "record") { + return yield* record( + request, + vcrRequest, + effect, + normalized, + store, + path, + exportKey, + ); + } + + // Auto mode: replay if cassette exists, otherwise record (or fail in CI). + const available = yield* store + .exists(path) + .pipe(Effect.mapError((error) => toRequestError(request, error))); + + if (!available) { + if (isCi) { + return yield* Effect.fail( + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ + request, + description: "VCR cassette missing in CI for auto mode", + }), + }), + ); + } + return yield* record( + request, + vcrRequest, + effect, + normalized, + store, + path, + exportKey, + ); + } + + const cassette = yield* readCassetteExport( path, exportKey, + store, + request, ); - } + const entry = yield* findEntry(vcrRequest, cassette, normalized); - if (normalized.mode === "record") { - return record( + if (entry) { + const web = new Response(entry.response.body, { + status: entry.response.status, + headers: entry.response.headers, + }); + return HttpClientResponse.fromWeb(request, web); + } + + return yield* record( request, vcrRequest, effect, @@ -390,62 +481,7 @@ export const makeVcrHttpClient = ( path, exportKey, ); - } - - // Auto mode: replay if cassette exists, otherwise record (or fail in CI). - return store.exists(path).pipe( - Effect.mapError((error) => toRequestError(request, error)), - Effect.flatMap((available) => { - if (!available) { - if (isCi) { - return Effect.fail( - new HttpClientError.HttpClientError({ - reason: new HttpClientError.TransportError({ - request, - description: "VCR cassette missing in CI for auto mode", - }), - }), - ); - } - return record( - request, - vcrRequest, - effect, - normalized, - store, - path, - exportKey, - ); - } - - return readCassetteExport(path, exportKey, store, request).pipe( - Effect.flatMap((cassette) => - findEntry(vcrRequest, cassette, normalized).pipe( - Effect.flatMap((entry) => { - if (entry) { - const web = new Response(entry.response.body, { - status: entry.response.status, - headers: entry.response.headers, - }); - return Effect.succeed( - HttpClientResponse.fromWeb(request, web), - ); - } - return record( - request, - vcrRequest, - effect, - normalized, - store, - path, - exportKey, - ); - }), - ), - ), - ); - }), - ); + }); }), ); @@ -455,7 +491,7 @@ export const makeVcrHttpClient = ( /** * Layer that provides a VCR-wrapped HttpClient. */ -export const layer = (config: VcrConfig = {}) => +export const layer = (config: VcrConfig) => HttpClient.layerMergedServices( Effect.gen(function* () { const live = yield* HttpClient.HttpClient; diff --git a/packages/effect-http-client/test/vcr-http-client.test.ts b/packages/effect-http-client/test/vcr-http-client.test.ts index bc4fe97..4f8a66e 100644 --- a/packages/effect-http-client/test/vcr-http-client.test.ts +++ b/packages/effect-http-client/test/vcr-http-client.test.ts @@ -14,6 +14,7 @@ import { describe("record mode", () => { it.effect("stores a cassette entry", () => { const config: VcrConfig = { + connectorName: "test-connector", cassetteDir: "/tmp/vcr", cassetteName: "record-basic.cassette", mode: "record", @@ -45,6 +46,7 @@ describe("record mode", () => { describe("replay mode", () => { it.effect("returns stored response without live client", () => { const config: VcrConfig = { + connectorName: "test-connector", cassetteDir: "/tmp/vcr", cassetteName: "replay-basic.cassette", mode: "replay", @@ -98,6 +100,7 @@ describe("replay mode", () => { describe("auto mode", () => { it.effect("replays when cassette exists", () => { const config: VcrConfig = { + connectorName: "test-connector", cassetteDir: "/tmp/vcr", cassetteName: "auto-replay.cassette", mode: "auto", @@ -147,6 +150,7 @@ describe("auto mode", () => { describe("record with redaction", () => { it.effect("removes sensitive data from stored cassette", () => { const config: VcrConfig = { + connectorName: "test-connector", cassetteDir: "/tmp/vcr", cassetteName: "redact-ignore.cassette", mode: "record", @@ -192,6 +196,7 @@ describe("record with redaction", () => { describe("auto mode in CI", () => { it.effect("fails when cassette is missing", () => { const config: VcrConfig = { + connectorName: "test-connector", cassetteDir: "/tmp/vcr", cassetteName: "auto-ci-miss.cassette", mode: "auto", @@ -218,3 +223,70 @@ describe("auto mode in CI", () => { ); }); }); + +describe("ACK_DISABLE_VCR with connectorName", () => { + it.effect( + "bypasses VCR in replay mode when connectorName is disabled", + () => { + const config: VcrConfig = { + connectorName: "producer-polar", + cassetteDir: "/tmp/vcr", + cassetteName: "context-disable.cassette", + mode: "replay", + }; + const { layer: storeLayer } = makeStoreLayer(); + const live = makeLiveClient("live-response"); + + const liveLayer = Layer.succeed(HttpClient.HttpClient)(live); + const vcrLayer = VcrHttpClientLayer(config).pipe( + Layer.provide(Layer.mergeAll(storeLayer, liveLayer)), + ); + + return Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const response = yield* client.get("https://example.com/"); + const text = yield* response.text; + + expect(text).toBe("live-response"); + }).pipe( + Effect.provide(vcrLayer), + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.fromUnknown({ ACK_DISABLE_VCR: "producer-polar" }), + ), + ); + }, + ); + + it.effect( + "keeps VCR replay behavior when connectorName is not disabled", + () => { + const config: VcrConfig = { + connectorName: "producer-stripe", + cassetteDir: "/tmp/vcr", + cassetteName: "context-missing.cassette", + mode: "replay", + }; + const { layer: storeLayer } = makeStoreLayer(); + const live = makeLiveClient("live-response"); + + const liveLayer = Layer.succeed(HttpClient.HttpClient)(live); + const vcrLayer = VcrHttpClientLayer(config).pipe( + Layer.provide(Layer.mergeAll(storeLayer, liveLayer)), + ); + + return Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const result = yield* Effect.exit(client.get("https://example.com/")); + + expect(Exit.isFailure(result)).toBe(true); + }).pipe( + Effect.provide(vcrLayer), + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.fromUnknown({ ACK_DISABLE_VCR: "producer-polar" }), + ), + ); + }, + ); +});