Repository: pmcelhaney/counterfact
Pull request #152 (2022-08-05 21:40:00)

response.random() 
Counterfact is now able to read an OpenAPI document and use it to generate a random response.

```js
export function GET ({response}) {
   return response[200].random();
}
```




Patch:
diff --git a/.changeset/plenty-peaches-sneeze.md b/.changeset/plenty-peaches-sneeze.md
new file mode 100644
index 0000000..320399d
--- /dev/null
+++ b/.changeset/plenty-peaches-sneeze.md
@@ -0,0 +1,5 @@
+---
+"counterfact": minor
+---
+
+Counterfact is now able to read an OpenAPI document and use it to generate a random response.
diff --git a/demo/openapi.yaml b/demo/openapi.yaml
new file mode 100644
index 0000000..d3054dd
--- /dev/null
+++ b/demo/openapi.yaml
@@ -0,0 +1,57 @@
+openapi: 3.0.3
+info:
+  version: 1.0.0 
+  title: Sample API
+  description: A sample API to illustrate OpenAPI concepts
+paths:
+  /count:
+    get:
+      description: outputs the number of time each URL was visited
+      responses:
+        default:
+          description: Successful response
+          content:
+            application/json:
+              schema: 
+                type: 
+                  string
+              examples:
+                no visits:
+                  value: You have not visited anyone yet
+  /hello/kitty:
+    get:
+      description: HTML with a hello kitty image
+      responses:
+        default:
+          description: Successful response
+          content:
+            application/json:
+              schema: 
+                type: 
+                  string
+              examples:
+                hello kitty:
+                  value: >-
+                    <img
+                    src="https://upload.wikimedia.org/wikipedia/en/0/05/Hello_kitty_character_portrait.png">
+  /hello/{name}:
+    get:
+      parameters:
+        - in: path
+          name: name
+          required: true
+          schema:
+            type: string
+          description: says hello to the name
+      description: says hello to someone
+      responses:
+        default:
+          description: Successful response
+          content:
+            application/json:
+              schema: 
+                type: 
+                  string
+              examples:
+                hello-world:
+                  value: Hello, world
diff --git a/src/counterfact.js b/src/counterfact.js
index a3f46f7..622da70 100644
--- a/src/counterfact.js
+++ b/src/counterfact.js
@@ -1,11 +1,31 @@
+import fs from "node:fs/promises";
+import nodePath from "node:path";
+
+import yaml from "js-yaml";
+
 import { Registry } from "./registry.js";
 import { Dispatcher } from "./dispatcher.js";
 import { koaMiddleware } from "./koa-middleware.js";
 import { ModuleLoader } from "./module-loader.js";
 
-export async function counterfact(basePath, context = {}) {
+async function loadOpenApiDocument(source) {
+  try {
+    return yaml.load(await fs.readFile(source, "utf8"));
+  } catch {
+    return undefined;
+  }
+}
+
+export async function counterfact(
+  basePath,
+  context = {},
+  openApiPath = nodePath.join(basePath, "../openapi.yaml")
+) {
+  const openApiDocument = await loadOpenApiDocument(openApiPath);
+
   const registry = new Registry(context);
-  const dispatcher = new Dispatcher(registry);
+
+  const dispatcher = new Dispatcher(registry, openApiDocument);
   const moduleLoader = new ModuleLoader(basePath, registry);
 
   await moduleLoader.load();
diff --git a/src/dispatcher.js b/src/dispatcher.js
index 21e7663..a9cee6d 100644
--- a/src/dispatcher.js
+++ b/src/dispatcher.js
@@ -6,24 +6,37 @@ import { Tools } from "./tools.js";
 export class Dispatcher {
   registry;
 
-  constructor(registry) {
+  constructor(registry, openApiDocument) {
     this.registry = registry;
+    this.openApiDocument = openApiDocument;
   }
 
-  request({ method, path, headers, body, query }) {
+  operationForPathAndMethod(path, method) {
+    return this.openApiDocument?.paths?.[path]?.[method.toLowerCase()];
+  }
+
+  async request({ method, path, headers, body, query }) {
+    const response = await this.registry.endpoint(
+      method,
+      path
+    )({
+      tools: new Tools({ headers }),
+
+      context: this.registry.context,
+      body,
+      query,
+      headers,
+
+      response: createResponseBuilder(
+        this.operationForPathAndMethod(
+          this.registry.handler(path).matchedPath,
+          method
+        )
+      ),
+    });
+
     const normalizedResponse = this.normalizeResponse(
-      this.registry.endpoint(
-        method,
-        path
-      )({
-        tools: new Tools({ headers }),
-
-        context: this.registry.context,
-        body,
-        query,
-        headers,
-        response: createResponseBuilder(),
-      }),
+      response,
       headers?.accept ?? "*/*"
     );
 
@@ -61,7 +74,6 @@ export class Dispatcher {
         ...response,
         contentType: content.type,
         body: content.body,
-        content: undefined,
       };
 
       delete normalizedResponse.content;
diff --git a/src/registry.js b/src/registry.js
index 9695f28..1c1a254 100644
--- a/src/registry.js
+++ b/src/registry.js
@@ -86,6 +86,11 @@ export class Registry {
       });
     }
 
-    return ({ ...context }) => lambda({ ...context, path: handler.path });
+    return ({ ...context }) =>
+      lambda({
+        ...context,
+        path: handler.path,
+        matchedPath: handler.matchedPath ?? "none",
+      });
   }
 }
diff --git a/src/response-builder.js b/src/response-builder.js
index 229c3c8..58e8239 100644
--- a/src/response-builder.js
+++ b/src/response-builder.js
@@ -1,4 +1,8 @@
-export function createResponseBuilder() {
+import jsf from "json-schema-faker";
+
+jsf.option("useExamplesValue", true);
+
+export function createResponseBuilder(operation) {
   return new Proxy(
     {},
     {
@@ -41,6 +45,36 @@ export function createResponseBuilder() {
         json(body) {
           return this.match("application/json", body);
         },
+
+        random() {
+          const response =
+            operation.responses[this.status] ?? operation.responses.default;
+
+          if (response === undefined) {
+            return {
+              status: 500,
+
+              content: [
+                {
+                  type: "text/plain",
+                  body: `The Open API document does not specify a response for status code ${this.status}`,
+                },
+              ],
+            };
+          }
+
+          const { content } = response;
+
+          return {
+            ...this,
+
+            content: Object.keys(content).map((type) => ({
+              type,
+
+              body: jsf.generate(content[type].schema),
+            })),
+          };
+        },
       }),
     }
   );


Test patch:
diff --git a/test/counterfact.test.js b/test/counterfact.test.js
index 3c7d970..9305aa7 100644
--- a/test/counterfact.test.js
+++ b/test/counterfact.test.js
@@ -70,4 +70,50 @@ describe("integration test", () => {
       await moduleLoader.stopWatching();
     });
   });
+
+  it("uses an OpenAPI document to generate a random response", async () => {
+    const app = new Koa();
+    const request = supertest(app.callback());
+    const files = {
+      "openapi.yaml": `
+        openapi: 3.0.0
+        info:
+          title: Counterfact
+          version: 1.0.0
+        paths:
+          /hello:
+            get:
+              responses:
+                200:
+                  content:
+                    text/plain:
+                      schema:
+                        type: string
+                        examples: 
+                          - "hello"
+      `,
+
+      "paths/hello.mjs": `
+        export async function GET({response}) {
+          return response[200].random();
+        }
+      `,
+    };
+
+    await withTemporaryFiles(files, async (basePath) => {
+      const { koaMiddleware, moduleLoader } = await counterfact(
+        `${basePath}/paths`,
+        { name: "World" },
+        `${basePath}/openapi.yaml`
+      );
+
+      app.use(koaMiddleware);
+
+      const getResponse = await request.get("/hello");
+
+      expect(getResponse.text).toBe("hello");
+
+      await moduleLoader.stopWatching();
+    });
+  });
 });
diff --git a/test/dispatcher.test.js b/test/dispatcher.test.js
index 31f7f6c..c81dcd7 100644
--- a/test/dispatcher.test.js
+++ b/test/dispatcher.test.js
@@ -286,6 +286,49 @@ describe("a dispatcher", () => {
     expect(htmlResponse.body).toBe("<h1>hello</h1>");
   });
 
+  it("gives the response builder the OpenAPI object it needs to generate a random response", async () => {
+    const registry = new Registry();
+
+    registry.add("/a", {
+      GET({ response }) {
+        return response[200].random();
+      },
+    });
+
+    const openApiDocument = {
+      paths: {
+        "/a": {
+          get: {
+            responses: {
+              200: {
+                content: {
+                  "text/plain": {
+                    schema: {
+                      type: "string",
+                      examples: ["hello"],
+                    },
+                  },
+                },
+              },
+            },
+          },
+        },
+      },
+    };
+
+    const dispatcher = new Dispatcher(registry, openApiDocument);
+    const htmlResponse = await dispatcher.request({
+      method: "GET",
+      path: "/a",
+
+      headers: {
+        accept: "text/plain",
+      },
+    });
+
+    expect(htmlResponse.body).toBe("hello");
+  });
+
   it("passes status code in the response", async () => {
     const registry = new Registry();
 
@@ -343,7 +386,7 @@ describe("a dispatcher", () => {
 });
 
 describe("given an invalid path", () => {
-  it("returns a 404 when the route is not found", () => {
+  it("returns a 404 when the route is not found", async () => {
     const registry = new Registry();
 
     registry.add("/your/{side}/{bodyPart}/in/and/your/left/foot/out", {
@@ -355,7 +398,9 @@ describe("given an invalid path", () => {
       },
     });
 
-    const response = new Dispatcher(registry).request({
+    const dispatcher = new Dispatcher(registry);
+
+    const response = await dispatcher.request({
       method: "PUT",
       path: "/your/left/foot/in/and/your/right/foot/out",
     });
diff --git a/test/response-builder.test.js b/test/response-builder.test.js
index e2dca06..334f815 100644
--- a/test/response-builder.test.js
+++ b/test/response-builder.test.js
@@ -45,4 +45,85 @@ describe("a response builder", () => {
       "x-two": 2,
     });
   });
+
+  describe("builds a random response based on an Open API operation object", () => {
+    const operation = {
+      responses: {
+        200: {
+          content: {
+            "application/json": {
+              schema: {
+                type: "object",
+
+                properties: {
+                  value: {
+                    type: "string",
+                    required: true,
+                    examples: ["hello"],
+                  },
+                },
+
+                additionalProperties: false,
+              },
+            },
+
+            "text/plain": {
+              schema: {
+                type: "string",
+                examples: ["hello"],
+              },
+            },
+          },
+        },
+
+        default: {
+          content: {
+            "text/plain": {
+              schema: {
+                type: "string",
+                examples: ["an error occurred"],
+              },
+            },
+          },
+        },
+      },
+    };
+
+    it("using the status code", () => {
+      const response = createResponseBuilder(operation)[200].random();
+
+      expect(response.status).toBe(200);
+      expect(response.content).toStrictEqual([
+        { type: "application/json", body: { value: "hello" } },
+        { type: "text/plain", body: "hello" },
+      ]);
+    });
+
+    it("falls back to 'default' when status code is not listed explicitly", () => {
+      const response = createResponseBuilder(operation)[403].random();
+
+      expect(response.status).toBe(403);
+      expect(response.content).toStrictEqual([
+        { type: "text/plain", body: "an error occurred" },
+      ]);
+    });
+
+    it("returns 500 if it doesn't know what to do with the status code", () => {
+      const operationWithoutDefault = { ...operation };
+
+      delete operationWithoutDefault.responses.default;
+
+      const response = createResponseBuilder(
+        operationWithoutDefault
+      )[403].random();
+
+      expect(response.status).toBe(500);
+      expect(response.content).toStrictEqual([
+        {
+          type: "text/plain",
+          body: "The Open API document does not specify a response for status code 403",
+        },
+      ]);
+    });
+  });
 });