Repository: pmcelhaney/counterfact
Pull request #363 (2023-01-19 17:04:53)

proxy url
- --proxyURL CLI option to proxy to another server
- move the repl code into its own file
- ability to turn the proxy on and off in the REPL



Patch:
diff --git a/.changeset/late-plums-reply.md b/.changeset/late-plums-reply.md
new file mode 100644
index 0000000..e51f3c2
--- /dev/null
+++ b/.changeset/late-plums-reply.md
@@ -0,0 +1,5 @@
+---
+"counterfact": minor
+---
+
+new `--proxyURL <url>` CLI option and `.proxy [on|off]` command in the REPL
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 16806db..64e4ef4 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -18,11 +18,11 @@
   "cSpell.words": [
     "bodyparser",
     "counterfact",
+    "counterfactuals",
     "Deno",
     "openapi",
     "Petstore",
-    "counterfactuals",
-    "openapi",
+    "proxied",
     "transpiles"
   ]
 }
diff --git a/bin/counterfact.js b/bin/counterfact.js
index 9881f8f..72ab0d5 100755
--- a/bin/counterfact.js
+++ b/bin/counterfact.js
@@ -2,13 +2,13 @@
 
 // eslint-disable-next-line node/shebang
 import nodePath from "node:path";
-import repl from "node:repl";
 
 import { program } from "commander";
 import open from "open";
 
 import { generate } from "../src/typescript-generator/generate.js";
 import { start } from "../src/server/start.js";
+import { startRepl } from "../src/server/repl.js";
 
 const DEFAULT_PORT = 3100;
 
@@ -26,12 +26,15 @@ async function main(source, destination) {
 
   const guiUrl = `${url}/counterfact/`;
 
-  const { contextRegistry } = await start({
+  const config = {
     basePath,
     port: options.port,
     openApiPath: source,
     includeSwaggerUi: true,
-  });
+    proxyUrl: options.proxyUrl,
+  };
+
+  const { contextRegistry } = await start(config);
 
   const waysToInteract = [
     `Call the REST APIs at ${url} (with your front end app, curl, Postman, etc.)`,
@@ -62,35 +65,7 @@ async function main(source, destination) {
 
   process.stdout.write("Starting REPL...\n");
 
-  const replServer = repl.start("> ");
-
-  replServer.defineCommand("counterfact", {
-    help: "Get help with Counterfact",
-
-    action() {
-      process.stdout.write(
-        "This is a read-eval-print loop (REPL), the same as the one you get when you run node with no arguments.\n"
-      );
-      process.stdout.write(
-        "Except that it's connected to the running server, which you can access with the following globals:\n\n"
-      );
-      process.stdout.write(
-        "- loadContext('/some/path'): to access the context object for a given path\n"
-      );
-      process.stdout.write(
-        "- context: the root context ( same as loadContext('/') )\n"
-      );
-      process.stdout.write(
-        "\nFor more information, see https://counterfact.dev/docs/usage.html\n\n"
-      );
-
-      this.clearBufferedCommand();
-      this.displayPrompt();
-    },
-  });
-
-  replServer.context.loadContext = (path) => contextRegistry.find(path);
-  replServer.context.context = replServer.context.loadContext("/");
+  startRepl(contextRegistry, config);
 
   if (openBrowser) {
     await open(guiUrl);
@@ -107,5 +82,6 @@ program
   .option("--port <number>", "server port number", DEFAULT_PORT)
   .option("--swagger", "include swagger-ui")
   .option("--open", "open a browser")
+  .option("--proxyUrl <string>", "proxy URL")
   .action(main)
   .parse(process.argv);
diff --git a/docs/usage.md b/docs/usage.md
index 7522a59..778c749 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -215,6 +215,25 @@ context.pets.find((pet) => pet.name.startsWith("F"));
 
 Using the REPL is a lot faster (and more fun) than wrangling config files and SQL and whatever else it takes to a real back end into the states you need to test your UI flows.
 
+## Proxy Peek-a-boo 🫣
+
+At some point you're going to want to test your code against a real server. Or maybe you want to use Counterfact for newer endpoints that don't exist in the real server yet and a real server for everything else. Counterfact has a couple of facilities that allow you to _proxy_ to the real server on a case-by-case basis.
+
+To proxy an individual endpoint, you can use the `$.proxy()` function.
+
+```ts copy
+// pet/{id}.ts
+ export const GET: HTTP_GET ($) => {
+    return $.proxy("http://uat.petstore.example.com/pet")
+ };
+```
+
+To toggle globally between Counterfact and a proxy server, pass `--proxy-url <url>` in he CLI.
+
+Then type `.proxy on` / `.proxy off` in the REPL to turn it on and off. When the global proxy is on, all requests will be sent to the proxy URL instead of the mock implementations in Counterfact.
+
+This feature is hot off the presses and somewhat experimental. We have plans to introduce more granular controls over what gets proxied when, but we want to see how this works first. Please send feedback!
+
 ## No Cap Recap 🧢
 
 Using convention over configuration and automatically generated types, Counterfact allows front-end developers to quickly build fake REST APIs for prototype and testing purposes.
diff --git a/src/server/counterfact.js b/src/server/counterfact.js
index 37d742b..aab671b 100644
--- a/src/server/counterfact.js
+++ b/src/server/counterfact.js
@@ -21,7 +21,8 @@ async function loadOpenApiDocument(source) {
 
 export async function counterfact(
   basePath,
-  openApiPath = nodePath.join(basePath, "../openapi.yaml")
+  openApiPath = nodePath.join(basePath, "../openapi.yaml"),
+  options = {}
 ) {
   const openApiDocument = await loadOpenApiDocument(openApiPath);
 
@@ -43,7 +44,7 @@ export async function counterfact(
   await moduleLoader.watch();
 
   return {
-    koaMiddleware: koaMiddleware(dispatcher),
+    koaMiddleware: koaMiddleware(dispatcher, options),
     registry,
     moduleLoader,
     contextRegistry,
diff --git a/src/server/koa-middleware.js b/src/server/koa-middleware.js
index 859b906..40626ab 100644
--- a/src/server/koa-middleware.js
+++ b/src/server/koa-middleware.js
@@ -6,7 +6,7 @@ export function koaMiddleware(dispatcher, options = {}, proxy = koaProxy) {
   return async function middleware(ctx, next) {
     const { method, path, headers, body, query } = ctx.request;
 
-    if (options.proxyUrl) {
+    if (options.proxyEnabled && options.proxyUrl) {
       return proxy({ host: options.proxyUrl })(ctx, next);
     }
 
diff --git a/src/server/repl.js b/src/server/repl.js
new file mode 100644
index 0000000..1a2df5b
--- /dev/null
+++ b/src/server/repl.js
@@ -0,0 +1,56 @@
+import repl from "node:repl";
+
+export function startRepl(contextRegistry, config) {
+  const replServer = repl.start("> ");
+
+  replServer.defineCommand("counterfact", {
+    help: "Get help with Counterfact",
+
+    action() {
+      process.stdout.write(
+        "This is a read-eval-print loop (REPL), the same as the one you get when you run node with no arguments.\n"
+      );
+      process.stdout.write(
+        "Except that it's connected to the running server, which you can access with the following globals:\n\n"
+      );
+      process.stdout.write(
+        "- loadContext('/some/path'): to access the context object for a given path\n"
+      );
+      process.stdout.write(
+        "- context: the root context ( same as loadContext('/') )\n"
+      );
+      process.stdout.write(
+        "\nFor more information, see https://counterfact.dev/docs/usage.html\n\n"
+      );
+
+      this.clearBufferedCommand();
+      this.displayPrompt();
+    },
+  });
+
+  replServer.defineCommand("proxy", {
+    help: "proxy [on|off] - turn the proxy on or off; proxy - print proxy info",
+
+    action(state) {
+      if (state === "on") {
+        // eslint-disable-next-line no-param-reassign
+        config.enableProxy = true;
+      }
+
+      if (state === "off") {
+        // eslint-disable-next-line no-param-reassign
+        config.enableProxy = false;
+      }
+
+      process.stdout.write(
+        `Proxy is ${config.enableProxy ? "on" : "off"}: ${config.proxyUrl}\n`
+      );
+
+      this.clearBufferedCommand();
+      this.displayPrompt();
+    },
+  });
+
+  replServer.context.loadContext = (path) => contextRegistry.find(path);
+  replServer.context.context = replServer.context.loadContext("/");
+}
diff --git a/src/server/start.js b/src/server/start.js
index 9cb2177..ad2e4dc 100644
--- a/src/server/start.js
+++ b/src/server/start.js
@@ -70,12 +70,14 @@ export async function start({
   basePath = process.cwd(),
   port = DEFAULT_PORT,
   openApiPath = nodePath.join(basePath, "../openapi.yaml"),
+  proxyUrl = undefined,
 }) {
   const app = new Koa();
 
   const { koaMiddleware, contextRegistry, registry } = await counterfact(
     basePath,
-    openApiPath
+    openApiPath,
+    { proxyUrl }
   );
 
   app.use(openapi(openApiPath, `//localhost:${port}`));


Test patch:
diff --git a/test/server/counterfact.test.js b/test/server/counterfact.test.js
index c6c6bdb..9d11d70 100644
--- a/test/server/counterfact.test.js
+++ b/test/server/counterfact.test.js
@@ -1,3 +1,5 @@
+import http from "node:http";
+
 import supertest from "supertest";
 import Koa from "koa";
 
@@ -91,4 +93,45 @@ describe("integration test", () => {
       await moduleLoader.stopWatching();
     });
   });
+
+  it("proxies when a proxyUrl is present and the proxy is enabled", async () => {
+    const proxyTarget = http.createServer((req, res) => {
+      res.writeHead(200, {
+        "Content-Type": "text/plain",
+      });
+
+      res.end("I am proxy!\n");
+    });
+
+    proxyTarget.listen(8121);
+
+    const app = new Koa();
+    const request = supertest(app.callback());
+    const files = {
+      "paths/hello.mjs": `
+        export async function GET() {
+          return await Promise.resolve({ body: "GET /hello" });
+        }
+      `,
+    };
+
+    await withTemporaryFiles(files, async (basePath) => {
+      const { koaMiddleware, moduleLoader } = await counterfact(
+        basePath,
+        `${basePath}/openapi.yaml`,
+        {
+          proxyUrl: `http://localhost:${proxyTarget.address().port}`,
+          proxyEnabled: true,
+        }
+      );
+
+      app.use(koaMiddleware);
+
+      const getResponse = await request.get("/hello");
+
+      expect(getResponse.text).toBe("I am proxy!\n");
+
+      await moduleLoader.stopWatching();
+    });
+  });
 });
diff --git a/test/server/koa-middleware.test.js b/test/server/koa-middleware.test.js
index ab5b20e..9b95644 100644
--- a/test/server/koa-middleware.test.js
+++ b/test/server/koa-middleware.test.js
@@ -55,7 +55,7 @@ describe("koa middleware", () => {
     expect(ctx.status).toBe(304);
   });
 
-  it("proxies when the response says to use a proxy", async () => {
+  it("proxies when a proxyURL is passed in the options", async () => {
     const registry = new Registry();
 
     registry.add("/proxy", {
@@ -67,7 +67,7 @@ describe("koa middleware", () => {
     const dispatcher = new Dispatcher(registry, new ContextRegistry());
     const middleware = koaMiddleware(
       dispatcher,
-      { proxyUrl: "https://example.com" },
+      { proxyUrl: "https://example.com", proxyEnabled: true },
       mockKoaProxy
     );
     const ctx = {