counterfact 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.eslintrc.cjs CHANGED
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
 
3
3
  module.exports = {
4
+ ignorePatterns: ["/node_modules/", "/coverage/", "/reports/"],
5
+
4
6
  extends: ["hardcore", "hardcore/ts", "hardcore/node"],
5
7
 
6
8
  parserOptions: {
@@ -77,5 +79,43 @@ module.exports = {
77
79
  "node/no-unpublished-import": "off",
78
80
  },
79
81
  },
82
+
83
+ {
84
+ files: ["*.cjs"],
85
+ extends: ["hardcore", "hardcore/node"],
86
+
87
+ rules: {
88
+ "import/no-commonjs": "off",
89
+ },
90
+
91
+ parserOptions: {
92
+ sourceType: "script",
93
+ },
94
+ },
95
+
96
+ {
97
+ files: ["demo-ts/**/*.ts"],
98
+ extends: ["hardcore", "hardcore/node", "hardcore/ts"],
99
+
100
+ parserOptions: {
101
+ sourceType: "module",
102
+ project: "./demo-ts/tsconfig.json",
103
+ },
104
+
105
+ rules: {
106
+ "import/prefer-default-export": "off",
107
+ "import/no-unused-modules": "off",
108
+ "func-style": "off",
109
+ camelcase: "off",
110
+ "@typescript-eslint/naming-convention": "off",
111
+ "no-magic-numbers": "off",
112
+ "no-param-reassign": "off",
113
+ "import/group-exports": "off",
114
+ "max-len": "off",
115
+ "etc/prefer-interface": "off",
116
+ "@typescript-eslint/prefer-readonly-parameter-types": "off",
117
+ "eslint-comments/no-unused-disable": "off",
118
+ },
119
+ },
80
120
  ],
81
121
  };
@@ -0,0 +1,28 @@
1
+ on: ["pull_request"]
2
+
3
+ name: Coveralls
4
+
5
+ jobs:
6
+
7
+ build:
8
+ name: Coveralls
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+
12
+ - uses: actions/checkout@v3
13
+
14
+ - name: Setup Node.js 16.x
15
+ uses: actions/setup-node@v3
16
+ with:
17
+ node-version: 16.x
18
+ cache: yarn
19
+
20
+ - name: install, run tests
21
+ run: |
22
+ yarn
23
+ yarn test
24
+
25
+ - name: Coveralls
26
+ uses: coverallsapp/github-action@1.1.3
27
+ with:
28
+ github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env sh
2
+ . "$(dirname -- "$0")/_/husky.sh"
3
+
4
+ npm run lint
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # counterfact
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 741e4fe: change path parameters from [this] to {this} for consistency with OpenAPI
8
+ - f237c38: proof of concept specifying routes with TypeScript and ts-node
9
+
10
+ ### Patch Changes
11
+
12
+ - 420cd52: return a 404 with a helpful error message when a handler for a route does not exist
13
+ - c11a475: allow the intial context (nee "store") to be passed as the second argument to `counterfact()`
14
+
3
15
  ## 0.1.2
4
16
 
5
17
  ### Patch Changes
package/README.md CHANGED
@@ -1,26 +1,7 @@
1
1
  # Counterfact
2
2
 
3
- <!--
4
- To make this work we had to trick Stryker into thinking it was running on Travis CI
5
- As of this writing it's not getting updated automatically.
6
-
7
- TRAVIS=true STRYKER_DASHBOARD_API_KEY=XXXXXXX yarn stryker run
8
-
9
- https://github.com/stryker-mutator/stryker-js/issues/744
10
- -->
11
-
12
3
  [![Mutation testing badge](https://img.shields.io/endpoint?style=flat&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Fpmcelhaney%2Fcounterfact%2Fmain)](https://dashboard.stryker-mutator.io/reports/github.com/pmcelhaney/counterfact/main)
13
4
 
14
- ## This is a work in progress.
15
-
16
- If you're nosy and don't mind a whole lot of incompleteness:
17
-
18
- ```sh
19
- yarn
20
- yarn test
21
- node demo/index.js
22
- ```
23
-
24
5
  ## Why?
25
6
 
26
7
  I was building an UI against back end code that was so cumbersome I spent almost as much time trying to get the latest version of the back end code running as I did writing the front end code. Also I needed to build UI code against features and bug fixes that would not be available for a long time. Eventually I determined in the long run it would be quicker to hack together a fake implementation of the API so that I could keep going. Over time that fake implementation evolved to the point that it was pretty nice to work with. And I loved that I could easily recreate the exact scenarios I needed to test against.
@@ -43,19 +24,7 @@ yarn add -D counterfact
43
24
 
44
25
  ## Usage
45
26
 
46
- ### TL;DR
47
-
48
- ```js
49
- // ./path/to/some-route.js
50
- export function REQUEST_METHOD({parts, of, the, request}) {
51
- return {
52
- parts,
53
- of,
54
- the,
55
- response
56
- };
57
- }
58
- ```
27
+ See the [demo](./demo/README.md) directory for an example.
59
28
 
60
29
  ### Using the Koa plugin
61
30
 
@@ -71,8 +40,11 @@ const PORT = 3100;
71
40
 
72
41
  const app = new Koa();
73
42
 
43
+ const initialContext = {};
44
+
74
45
  const { koaMiddleware } = await counterfact(
75
- fileURLToPath(new URL("routes/", import.meta.url))
46
+ fileURLToPath(new URL("routes/", import.meta.url)),
47
+ initialContext
76
48
  );
77
49
 
78
50
  app.use(koaMiddleware);
@@ -93,12 +65,12 @@ To mock an API at `/hello/world`, create a file called `./routes/hello/world.js`
93
65
  Inside that file, output a function that handles a GET request.
94
66
 
95
67
  ```js
96
- export function GET() {
97
- return {
98
- status: 200, // optional HTTP status code (200 is the default)
99
- body: "hello world" // HTTP response body
100
- }
101
- }
68
+ export function GET() {
69
+ return {
70
+ status: 200, // optional HTTP status code (200 is the default)
71
+ body: "hello world", // HTTP response body
72
+ };
73
+ }
102
74
  ```
103
75
 
104
76
  Now when you run your server and call "GET /hello/world" you should get a 200 response with "hello world" in the body.
@@ -108,21 +80,21 @@ Now when you run your server and call "GET /hello/world" you should get a 200 re
108
80
  The get function has one parameter, a context object that contains metadata about the request. We can use that to read a the query string from `/hello/world?greeting=Hi`
109
81
 
110
82
  ```js
111
- export function GET(context) {
112
- return {
113
- body: "${context.query.greeting} world"
114
- }
115
- }
83
+ export function GET(context) {
84
+ return {
85
+ body: "${context.query.greeting} world",
86
+ };
87
+ }
116
88
  ```
117
89
 
118
90
  In practice, tend to use destructuring syntax, which is the closest thing we have in JavaScript to named arguments.
119
91
 
120
92
  ```js
121
- export function GET({ query }) {
122
- return {
123
- body: "${query.greeting} world"
124
- }
125
- }
93
+ export function GET({ query }) {
94
+ return {
95
+ body: "${query.greeting} world",
96
+ };
97
+ }
126
98
  ```
127
99
 
128
100
  ### Dynamic routes
@@ -130,11 +102,11 @@ In practice, tend to use destructuring syntax, which is the closest thing we hav
130
102
  Create another file called `./routes/hello/[name].js`. This file will match `/hello/universe`, `/hello/friends`, and `/hello/whatever-you-want-here`.
131
103
 
132
104
  ```js
133
- export function GET({ greeting, path }) {
134
- return {
135
- body: "${query.greeting}, ${path.name}!"
136
- }
137
- }
105
+ export function GET({ greeting, path }) {
106
+ return {
107
+ body: "${query.greeting}, ${path.name}!",
108
+ };
109
+ }
138
110
  ```
139
111
 
140
112
  The `path` object is analogous to the `query` object, holding values in the dynamic parts of the path.
@@ -145,21 +117,20 @@ This feature was [inspired by Next.js](https://nextjs.org/docs/routing/dynamic-r
145
117
 
146
118
  ### State
147
119
 
148
- State management is handled through a plain old JavaScript object called `store`. The store is initialized as an empty object (`{}`). You can read and write its keys however you like. Changes will persist from one request to another as long as the server is running.
120
+ State management is handled through a plain old JavaScript object called `context`, which is passed as the second argument to the `counterfact()` function (default value = `{}`). You can modify the context object however you like. Changes will persist from one request to another as long as the server is running.
149
121
 
150
- There are no rules around how you manipulate the store. Yes, you read that right.
122
+ There are no rules around how you manipulate the context. Yes, you read that right.
151
123
 
152
124
  ```js
153
- export function GET({ greeting, path, store }) {
154
-
155
- store.visits ??= {};
156
- store.visits[path.name] ??= 0;
157
- store.visits[path.name] += 1;
125
+ export function GET({ greeting, path, context }) {
126
+ context.visits ??= {};
127
+ context.visits[path.name] ??= 0;
128
+ context.visits[path.name] += 1;
158
129
 
159
- return {
160
- body: "${query.greeting}, ${path.name}!"
161
- }
162
- }
130
+ return {
131
+ body: "${query.greeting}, ${path.name}!",
132
+ };
133
+ }
163
134
  ```
164
135
 
165
136
  ### Request Methods
@@ -167,23 +138,23 @@ There are no rules around how you manipulate the store. Yes, you read that right
167
138
  So far we've only covered `GET` requests. What about `POST`, `PUT`, `PATCH` and `DELETE`? All HTTP request methods are supported. It's a matter exporting functions with the corresponding names.
168
139
 
169
140
  ```js
170
- export function GET({ path, store }) {
141
+ export function GET({ path, context }) {
171
142
 
172
- store.friends ??= {};
173
- store.friends[path.name] ??= {
143
+ context.friends ??= {};
144
+ context.friends[path.name] ??= {
174
145
  appearance: "lovely"
175
146
  };
176
147
 
177
148
  return {
178
- body: "Hello, ${path.name}. You look ${store.friends[name].appearance} today!"
149
+ body: "Hello, ${path.name}. You look ${context.friends[name].appearance} today!"
179
150
  }
180
151
  }
181
152
 
182
- export function POST(path, store, body) {
153
+ export function POST(path, context, body) {
183
154
 
184
- store.friends ??= {};
185
- store.friends[path.name] ??= {};
186
- store.friends[appearance] = body.appearance;
155
+ context.friends ??= {};
156
+ context.friends[path.name] ??= {};
157
+ context.friends[appearance] = body.appearance;
187
158
 
188
159
  return {
189
160
  body: {
@@ -198,18 +169,18 @@ So far we've only covered `GET` requests. What about `POST`, `PUT`, `PATCH` and
198
169
  If you need to do work asynchronously, return a promise or use async / await.
199
170
 
200
171
  ```js
201
- export function PUT({body}) {
202
- return doSomeStuffWith(body).then(() => {
203
- body: "Successfully did an async PUT"
204
- });
205
- }
172
+ export function PUT({ body }) {
173
+ return doSomeStuffWith(body).then(() => {
174
+ body: "Successfully did an async PUT";
175
+ });
176
+ }
206
177
 
207
- export async function DELETE() {
208
- await deleteMe();
209
- return {
210
- body: "Took a while, but now it's deleted."
211
- };
212
- }
178
+ export async function DELETE() {
179
+ await deleteMe();
180
+ return {
181
+ body: "Took a while, but now it's deleted.",
182
+ };
183
+ }
213
184
  ```
214
185
 
215
186
  ### Coming soon
@@ -1,12 +1,12 @@
1
- export function GET({ store }) {
2
- if (!store.visits) {
1
+ export function GET({ context }) {
2
+ if (!context.visits) {
3
3
  return {
4
4
  body: "You have not visited anyone yet.",
5
5
  };
6
6
  }
7
7
 
8
8
  return {
9
- body: Object.entries(store.visits)
9
+ body: Object.entries(context.visits)
10
10
  .map(([page, count]) => `You visited ${page} ${count} times.`)
11
11
  .join("\n"),
12
12
  };
@@ -1,7 +1,7 @@
1
- export function GET({ path, store, query }) {
2
- store.visits ??= {};
3
- store.visits[path.name] ??= 0;
4
- store.visits[path.name] += 1;
1
+ export function GET({ path, context, query }) {
2
+ context.visits ??= {};
3
+ context.visits[path.name] ??= 0;
4
+ context.visits[path.name] += 1;
5
5
 
6
6
  if (!path) {
7
7
  return { body: "Hello, stranger!" };
@@ -0,0 +1,19 @@
1
+ # Counterfact Demo - TypeScript
2
+
3
+ This demo illustrates some of the basic features of Counterfact, with TypeScript.
4
+
5
+ It requires ts-node (`npm i -g ts-node`).
6
+
7
+ Start it by running `ts-node-esm index.ts`.
8
+
9
+ `index.ts` starts a Koa server and loads Counterfact's middleware pointed to route definitions at `./routes`.
10
+
11
+ Under `./routes` you will find a few endpoints definitions:
12
+
13
+ `hello.ts` defines `/hello/:name` and says hello to your friends.
14
+
15
+ `hello/kitty.ts` defines `/hello/kitty`, overriding the behavior defined in `hello.ts`.
16
+
17
+ `count.ts` defines `/count` and reports how many times you've visited the other URLs.
18
+
19
+ Try adding more routes. You should be able to see the updates without restarting the server.
@@ -0,0 +1,3 @@
1
+ # Context
2
+
3
+ This folder will contain the context object (currently named "store"), which will define the server state. The `Store.ts` file exports a class which defines the context's type and an instance of that class which holds the state in memory. (It also defines the initial state.)
@@ -0,0 +1,12 @@
1
+ // This file will be maintained by hand.
2
+
3
+ interface Visits {
4
+ [name: string]: number;
5
+ }
6
+
7
+ export class Context {
8
+ public visits: Visits = {};
9
+ }
10
+
11
+ // Counterfact will load this object and use it as the initial state.
12
+ export const store = new Context();
@@ -0,0 +1,12 @@
1
+ // This file will be maintained by hand.
2
+
3
+ interface Visits {
4
+ [name: string]: number;
5
+ }
6
+
7
+ export class Context {
8
+ public visits?: Visits = {};
9
+ }
10
+
11
+ // Counterfact will load this object and use it as the initial state.
12
+ export const context = new Context();
@@ -0,0 +1,25 @@
1
+ /* eslint-disable no-console, node/no-unpublished-import */
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ // eslint-disable-next-line import/no-extraneous-dependencies
5
+ import Koa from "koa";
6
+
7
+ import { counterfact } from "../src/counterfact";
8
+
9
+ const PORT = 3100;
10
+
11
+ const app = new Koa();
12
+
13
+ const { koaMiddleware } = await counterfact(
14
+ fileURLToPath(new URL("routes/", import.meta.url))
15
+ );
16
+
17
+ app.use(koaMiddleware);
18
+
19
+ app.listen(PORT);
20
+ console.log("Try these URLs:");
21
+ console.log(`http://localhost:${PORT}/hello/world`);
22
+ console.log(`http://localhost:${PORT}/hello/friends`);
23
+ console.log(`http://localhost:${PORT}/hello/kitty`);
24
+ console.log(`http://localhost:${PORT}/hello/world?greeting=Hi`);
25
+ console.log(`http://localhost:${PORT}/count`);
@@ -0,0 +1,7 @@
1
+ import type { HttpResponseStatusCode } from "../types/Http";
2
+ import type { Context } from "../context/context";
3
+
4
+ export type Get_count = (request: { context: Context }) => {
5
+ body: string;
6
+ status?: HttpResponseStatusCode;
7
+ };
@@ -0,0 +1,3 @@
1
+ # Routes
2
+
3
+ This directory defines the routes. These will be maintained by hand with the exception of files named `#types.ts`, which will be generated from the OpenAPI spec. We may generate files for routes as well, but only if they don't already exist. The `#types.ts` files will be overwritten. The others won't.
@@ -0,0 +1,18 @@
1
+ import type { Get_count } from "./#types";
2
+
3
+ export const GET: Get_count = ({ context }) => {
4
+ if (context.visits === undefined) {
5
+ return {
6
+ body: "You have not visited anyone yet.",
7
+ };
8
+ }
9
+
10
+ return {
11
+ body: Object.entries(context.visits)
12
+ .map(
13
+ ([page, count]: [string, number]) =>
14
+ `You visited ${page} ${count} times.`
15
+ )
16
+ .join("\n"),
17
+ };
18
+ };
@@ -0,0 +1,16 @@
1
+ import type { Context } from "../../context/context";
2
+ import type { HttpResponseStatusCode } from "../../types/Http";
3
+ import type { Greeting } from "../../types/Greeting";
4
+ import type { HtmlImgTag } from "../../types/HtmlImgTag";
5
+
6
+ export type Get_name = (request: {
7
+ context: Context;
8
+ query: { greeting?: string };
9
+ path: { name: string };
10
+ }) => { body: Greeting; status?: HttpResponseStatusCode };
11
+
12
+ export type Get_kitty = (request: {
13
+ context: Context;
14
+ query: { greeting?: string };
15
+ path: { name: string };
16
+ }) => { body: HtmlImgTag; status?: HttpResponseStatusCode };
@@ -0,0 +1,11 @@
1
+ import type { Get_name } from "./#types";
2
+
3
+ export const GET: Get_name = ({ path, context, query }) => {
4
+ context.visits ??= {};
5
+ context.visits[path.name] ??= 0;
6
+ context.visits[path.name] += 1;
7
+
8
+ return {
9
+ body: `${query.greeting ?? "Hello"}, ${path.name}!`,
10
+ };
11
+ };
@@ -0,0 +1,5 @@
1
+ import type { Get_kitty } from "./#types";
2
+
3
+ export const GET: Get_kitty = () => ({
4
+ body: '<img src="https://upload.wikimedia.org/wikipedia/en/0/05/Hello_kitty_character_portrait.png">',
5
+ });
@@ -0,0 +1,9 @@
1
+ {
2
+ "compilerOptions": {
3
+ "moduleResolution": "node",
4
+ "module": "es2022",
5
+ "target": "es2022",
6
+ "strictNullChecks": true,
7
+ "allowSyntheticDefaultImports": true
8
+ }
9
+ }
@@ -0,0 +1 @@
1
+ export type Greeting = `${string}, ${string}!`;
@@ -0,0 +1 @@
1
+ export type HtmlImgTag = `<img src="${string}">`;
@@ -0,0 +1,2 @@
1
+ // eslint-disable-next-line @typescript-eslint/no-magic-numbers
2
+ export type HttpResponseStatusCode = 200 | 404 | 500;
@@ -0,0 +1,5 @@
1
+ # Types
2
+
3
+ This directory is currently maintained by hand. The goal is to generate it from an OpenAPI spec.
4
+
5
+ Some of the example types currently use template literals so the examples can be something more interesting than plain old strings. We're not expecting to generate template literal types from OpenAPI.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "counterfact",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "a library for building a fake REST API for testing",
5
5
  "type": "module",
6
6
  "main": "./src/counterfact.js",
@@ -22,18 +22,22 @@
22
22
  "scripts": {
23
23
  "test": "yarn node --experimental-vm-modules $(yarn bin jest)",
24
24
  "test:mutants": "stryker run stryker.config.json",
25
- "release": "npx changeset publish"
25
+ "release": "npx changeset publish",
26
+ "prepare": "husky install",
27
+ "lint": "eslint ."
26
28
  },
27
29
  "devDependencies": {
28
- "@changesets/cli": "2.22.0",
29
- "@stryker-mutator/core": "6.0.2",
30
- "@stryker-mutator/jest-runner": "6.0.2",
31
- "eslint": "8.17.0",
30
+ "@changesets/cli": "2.23.0",
31
+ "@stryker-mutator/core": "6.1.2",
32
+ "@stryker-mutator/jest-runner": "6.1.2",
33
+ "@types/koa": "2.13.4",
34
+ "eslint": "8.18.0",
32
35
  "eslint-config-hardcore": "24.5.0",
33
36
  "eslint-formatter-github-annotations": "0.1.0",
34
- "jest": "28.1.1",
37
+ "jest": "28.1.2",
35
38
  "koa": "2.13.4",
36
- "nodemon": "2.0.16",
39
+ "husky": "8.0.1",
40
+ "nodemon": "2.0.18",
37
41
  "stryker-cli": "1.0.2",
38
42
  "supertest": "6.2.3"
39
43
  },
@@ -3,8 +3,8 @@ import { Dispatcher } from "./dispatcher.js";
3
3
  import { koaMiddleware } from "./koa-middleware.js";
4
4
  import { ModuleLoader } from "./module-loader.js";
5
5
 
6
- export async function counterfact(basePath) {
7
- const registry = new Registry();
6
+ export async function counterfact(basePath, context = {}) {
7
+ const registry = new Registry(context);
8
8
  const dispatcher = new Dispatcher(registry);
9
9
  const moduleLoader = new ModuleLoader(basePath, registry);
10
10
 
package/src/dispatcher.js CHANGED
@@ -13,10 +13,10 @@ export class Dispatcher {
13
13
  // path: parts.slice(remainingParts).join("/"),
14
14
 
15
15
  reduce: (reducer) => {
16
- this.registry.store = reducer(this.registry.store);
16
+ this.registry.context = reducer(this.registry.context);
17
17
  },
18
18
 
19
- store: this.registry.store,
19
+ context: this.registry.context,
20
20
  body,
21
21
  query,
22
22
  });
@@ -20,12 +20,8 @@ export class ModuleLoader extends EventEmitter {
20
20
  }
21
21
 
22
22
  async watch() {
23
- if (this.watcher) {
24
- throw new Error("already watching");
25
- }
26
-
27
23
  this.watcher = chokidar
28
- .watch(`${this.basePath}/**/*`)
24
+ .watch(`${this.basePath}/**/*.{js,mjs,ts,mts}`)
29
25
  .on("all", (event, pathName) => {
30
26
  if (!["add", "change", "unlink"].includes(event)) {
31
27
  return;
@@ -34,6 +30,10 @@ export class ModuleLoader extends EventEmitter {
34
30
  const parts = path.parse(pathName.replace(this.basePath, ""));
35
31
  const url = `/${path.join(parts.dir, parts.name)}`;
36
32
 
33
+ if (parts.name.includes("#")) {
34
+ return;
35
+ }
36
+
37
37
  if (event === "unlink") {
38
38
  this.registry.remove(url);
39
39
  this.emit("remove", pathName);
@@ -49,7 +49,7 @@ export class ModuleLoader extends EventEmitter {
49
49
  })
50
50
  // eslint-disable-next-line promise/prefer-await-to-then
51
51
  .catch((error) => {
52
- throw new Error(String(error));
52
+ throw error;
53
53
  });
54
54
  });
55
55
  await once(this.watcher, "ready");
@@ -64,12 +64,23 @@ export class ModuleLoader extends EventEmitter {
64
64
  withFileTypes: true,
65
65
  });
66
66
  const imports = files.flatMap(async (file) => {
67
+ if (file.name.includes("#")) {
68
+ return;
69
+ }
70
+
71
+ // eslint-disable-next-line no-magic-numbers
72
+ const extension = file.name.split(".").at(-1);
73
+
67
74
  if (file.isDirectory()) {
68
75
  await this.load(path.join(directory, file.name));
69
76
 
70
77
  return;
71
78
  }
72
79
 
80
+ if (!["js", "mjs", "ts", "mts"].includes(extension)) {
81
+ return;
82
+ }
83
+
73
84
  const endpoint = await import(
74
85
  path.join(this.basePath, directory, file.name)
75
86
  );
package/src/registry.js CHANGED
@@ -5,10 +5,10 @@ export class Registry {
5
5
  children: {},
6
6
  };
7
7
 
8
- store;
8
+ context;
9
9
 
10
- constructor(store = {}) {
11
- this.store = store;
10
+ constructor(context = {}) {
11
+ this.context = context;
12
12
  }
13
13
 
14
14
  add(url, module) {
@@ -43,17 +43,21 @@ export class Registry {
43
43
  return Boolean(this.handler(url)?.module?.[method]);
44
44
  }
45
45
 
46
+ // eslint-disable-next-line max-statements
46
47
  handler(url) {
47
48
  let node = this.moduleTree;
48
49
 
49
50
  const path = {};
50
51
 
52
+ const matchedParts = [""];
53
+
51
54
  for (const segment of url.split("/").slice(1)) {
52
55
  if (node.children[segment]) {
53
56
  node = node.children[segment];
57
+ matchedParts.push(segment);
54
58
  } else {
55
59
  const dynamicSegment = Object.keys(node.children).find(
56
- (ds) => ds.startsWith("[") && ds.endsWith("]")
60
+ (ds) => ds.startsWith("{") && ds.endsWith("}")
57
61
  );
58
62
 
59
63
  if (dynamicSegment) {
@@ -61,11 +65,13 @@ export class Registry {
61
65
  path[dynamicSegment.slice(1, -1)] = segment;
62
66
 
63
67
  node = node.children[dynamicSegment];
68
+
69
+ matchedParts.push(dynamicSegment);
64
70
  }
65
71
  }
66
72
  }
67
73
 
68
- return { module: node.module, path };
74
+ return { module: node.module, path, matchedPath: matchedParts.join("/") };
69
75
  }
70
76
 
71
77
  endpoint(httpRequestMethod, url) {
@@ -73,9 +79,10 @@ export class Registry {
73
79
  const lambda = handler?.module?.[httpRequestMethod];
74
80
 
75
81
  if (!lambda) {
76
- throw new Error(
77
- `${httpRequestMethod} method for endpoint at "${url}" does not exist`
78
- );
82
+ return () => ({
83
+ status: 404,
84
+ body: `Could not find a ${httpRequestMethod} method at ${url}\nGot as far as ${handler.matchedPath}`,
85
+ });
79
86
  }
80
87
 
81
88
  return ({ ...context }) => lambda({ ...context, path: handler.path });
@@ -43,4 +43,31 @@ describe("integration test", () => {
43
43
  await moduleLoader.stopWatching();
44
44
  });
45
45
  });
46
+
47
+ it("loads the initial context", async () => {
48
+ const app = new Koa();
49
+ const request = supertest(app.callback());
50
+ const files = {
51
+ "paths/hello.mjs": `
52
+ export async function GET({context}) {
53
+ return await Promise.resolve({ body: "Hello " + context.name });
54
+ }
55
+ `,
56
+ };
57
+
58
+ await withTemporaryFiles(files, async (basePath) => {
59
+ const { koaMiddleware, moduleLoader } = await counterfact(
60
+ `${basePath}/paths`,
61
+ { name: "World" }
62
+ );
63
+
64
+ app.use(koaMiddleware);
65
+
66
+ const getResponse = await request.get("/hello");
67
+
68
+ expect(getResponse.text).toBe("Hello World");
69
+
70
+ await moduleLoader.stopWatching();
71
+ });
72
+ });
46
73
  });
@@ -96,12 +96,12 @@ describe("a dispatcher", () => {
96
96
  it("passes a reducer function that can be used to read / update the store", async () => {
97
97
  const registry = new Registry({ value: 0 });
98
98
 
99
- registry.add("/increment/[value]", {
99
+ registry.add("/increment/{value}", {
100
100
  GET({ reduce, path }) {
101
101
  const amountToIncrement = Number.parseInt(path.value, 10);
102
102
 
103
- reduce((store) => ({
104
- value: store.value + amountToIncrement,
103
+ reduce((context) => ({
104
+ value: context.value + amountToIncrement,
105
105
  }));
106
106
 
107
107
  return { body: "incremented" };
@@ -116,7 +116,7 @@ describe("a dispatcher", () => {
116
116
  body: "",
117
117
  });
118
118
 
119
- expect(registry.store.value).toBe(1);
119
+ expect(registry.context.value).toBe(1);
120
120
 
121
121
  await dispatcher.request({
122
122
  method: "GET",
@@ -124,18 +124,17 @@ describe("a dispatcher", () => {
124
124
  body: "",
125
125
  });
126
126
 
127
- expect(registry.store.value).toBe(3);
127
+ expect(registry.context.value).toBe(3);
128
128
  });
129
129
 
130
130
  it("allows the store to be mutated directly", async () => {
131
131
  const registry = new Registry({ value: 0 });
132
132
 
133
- registry.add("/increment/[value]", {
134
- GET({ store, path }) {
133
+ registry.add("/increment/{value}", {
134
+ GET({ context, path }) {
135
135
  const amountToIncrement = Number.parseInt(path.value, 10);
136
136
 
137
- // eslint-disable-next-line no-param-reassign
138
- store.value += amountToIncrement;
137
+ context.value += amountToIncrement;
139
138
 
140
139
  return { body: "incremented" };
141
140
  },
@@ -151,7 +150,7 @@ describe("a dispatcher", () => {
151
150
 
152
151
  expect(result.body).toBe("incremented");
153
152
 
154
- expect(registry.store.value).toBe(1);
153
+ expect(registry.context.value).toBe(1);
155
154
 
156
155
  await dispatcher.request({
157
156
  method: "GET",
@@ -159,6 +158,34 @@ describe("a dispatcher", () => {
159
158
  body: "",
160
159
  });
161
160
 
162
- expect(registry.store.value).toBe(3);
161
+ expect(registry.context.value).toBe(3);
162
+ });
163
+ });
164
+
165
+ describe("given a in invalid path", () => {
166
+ it("returns a 404 when the route is not found", () => {
167
+ const registry = new Registry();
168
+
169
+ registry.add("/your/{side}/{bodyPart}/in/and/your/left/foot/out", {
170
+ PUT() {
171
+ return {
172
+ status: 201,
173
+ body: "ok",
174
+ };
175
+ },
176
+ });
177
+
178
+ const response = new Dispatcher(registry).request({
179
+ method: "PUT",
180
+ path: "/your/left/foot/in/and/your/right/foot/out",
181
+ });
182
+
183
+ expect(response.status).toBe(404);
184
+
185
+ expect(response.body).toBe(
186
+ "Could not find a PUT method at " +
187
+ "/your/left/foot/in/and/your/right/foot/out\n" +
188
+ "Got as far as /your/{side}/{bodyPart}/in/and/your"
189
+ );
163
190
  });
164
191
  });
@@ -22,6 +22,14 @@ function createAddFunction(basePath) {
22
22
  };
23
23
  }
24
24
 
25
+ function createAddDirectoryFunction(basePath) {
26
+ return async function addDirectory(filePath) {
27
+ const fullPath = path.join(basePath, filePath);
28
+
29
+ await fs.mkdir(fullPath, { recursive: true });
30
+ };
31
+ }
32
+
25
33
  function createRemoveFunction(basePath) {
26
34
  return async function remove(filePath) {
27
35
  const fullPath = path.join(basePath, filePath);
@@ -52,6 +60,7 @@ export async function withTemporaryFiles(files, ...callbacks) {
52
60
  await callback(temporaryDirectory, {
53
61
  add: createAddFunction(temporaryDirectory),
54
62
  remove: createRemoveFunction(temporaryDirectory),
63
+ addDirectory: createAddDirectoryFunction(temporaryDirectory),
55
64
  });
56
65
  }
57
66
  } finally {
@@ -84,6 +84,34 @@ describe("a module loader", () => {
84
84
  );
85
85
  });
86
86
 
87
+ it("ignores files with the wrong file extension", async () => {
88
+ const contents = 'export function GET() { return { body: "hello" }; }';
89
+
90
+ const files = {
91
+ "module.mjs": contents,
92
+ "README.md": contents,
93
+ "#types.mjs": contents,
94
+ };
95
+
96
+ await withTemporaryFiles(files, async (basePath, { add }) => {
97
+ const registry = new Registry();
98
+ const loader = new ModuleLoader(basePath, registry);
99
+
100
+ await loader.load();
101
+ await loader.watch();
102
+
103
+ await add("other.txt", "should not be loaded");
104
+ await add("#other.mjs", "should not be loaded");
105
+
106
+ expect(registry.exists("GET", "/module")).toBe(true);
107
+ expect(registry.exists("GET", "/README")).toBe(false);
108
+ expect(registry.exists("GET", "/other")).toBe(false);
109
+ expect(registry.exists("GET", "/types")).toBe(false);
110
+
111
+ await loader.stopWatching();
112
+ });
113
+ });
114
+
87
115
  // This should work but I can't figure out how to break the
88
116
  // module cache when running through Jest (which uses the
89
117
  // experimental module API).
@@ -109,7 +137,7 @@ describe("a module loader", () => {
109
137
  const response = await registry.endpoint(
110
138
  "GET",
111
139
  "/change"
112
- )({ path: "", reduce: () => undefined, store: {} });
140
+ )({ path: "", reduce: () => undefined, context: {} });
113
141
 
114
142
  expect(response.body).toBe("after change");
115
143
  expect(registry.exists("GET", "/late/addition")).toBe(true);
@@ -56,7 +56,7 @@ describe("a scripted server", () => {
56
56
  return foo;
57
57
  },
58
58
 
59
- store: {},
59
+ context: {},
60
60
  };
61
61
  const getA = await registry.endpoint("GET", "/a")(props);
62
62
  const getB = await registry.endpoint("GET", "/b")(props);
@@ -88,7 +88,7 @@ describe("a scripted server", () => {
88
88
  it("handles a dynamic path", () => {
89
89
  const registry = new Registry();
90
90
 
91
- registry.add("/[organization]/users/[username]/friends/[page]", {
91
+ registry.add("/{organization}/users/{username}/friends/{page}", {
92
92
  GET({ path }) {
93
93
  return {
94
94
  body: `page ${path.page} of ${path.username}'s friends in ${path.organization}`,