counterfact 0.0.1
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/.changeset/README.md +8 -0
- package/.changeset/config.json +11 -0
- package/.eslintrc.cjs +81 -0
- package/.github/workflows/ci.yaml +46 -0
- package/.putout.json +5 -0
- package/.vscode/settings.json +5 -0
- package/README.md +42 -0
- package/demo/README.md +15 -0
- package/demo/index.js +18 -0
- package/demo/routes/count.js +13 -0
- package/demo/routes/hello/[id]/show-id.js +5 -0
- package/demo/routes/hello/kitty.js +5 -0
- package/demo/routes/hello.js +13 -0
- package/index.js +4 -0
- package/jest.config.js +6 -0
- package/package.json +29 -0
- package/renovate.json +5 -0
- package/src/counterfact.js +15 -0
- package/src/dispatcher.js +24 -0
- package/src/koa-middleware.js +17 -0
- package/src/module-loader.js +85 -0
- package/src/registry.js +87 -0
- package/test/counterfact.test.js +39 -0
- package/test/dispatcher.test.js +143 -0
- package/test/koa-middleware.test.js +28 -0
- package/test/lib/with-temporary-files.js +62 -0
- package/test/module-loader.test.js +121 -0
- package/test/registry.test.js +105 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Changesets
|
|
2
|
+
|
|
3
|
+
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
|
|
4
|
+
with multi-package repos, or single-package repos to help you version and publish your code. You can
|
|
5
|
+
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
|
|
6
|
+
|
|
7
|
+
We have a quick list of common questions to get you started engaging with this project in
|
|
8
|
+
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://unpkg.com/@changesets/config@2.0.0/schema.json",
|
|
3
|
+
"changelog": "@changesets/cli/changelog",
|
|
4
|
+
"commit": false,
|
|
5
|
+
"fixed": [],
|
|
6
|
+
"linked": [],
|
|
7
|
+
"access": "restricted",
|
|
8
|
+
"baseBranch": "main",
|
|
9
|
+
"updateInternalDependencies": "patch",
|
|
10
|
+
"ignore": []
|
|
11
|
+
}
|
package/.eslintrc.cjs
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
module.exports = {
|
|
4
|
+
extends: ["hardcore", "hardcore/ts", "hardcore/node"],
|
|
5
|
+
|
|
6
|
+
parserOptions: {
|
|
7
|
+
sourceType: "module",
|
|
8
|
+
},
|
|
9
|
+
|
|
10
|
+
rules: {
|
|
11
|
+
"putout/putout": "off",
|
|
12
|
+
"import/prefer-default-export": "off",
|
|
13
|
+
"@typescript-eslint/naming-convention": "off",
|
|
14
|
+
|
|
15
|
+
"max-len": [
|
|
16
|
+
"warn",
|
|
17
|
+
{
|
|
18
|
+
ignorePattern: "eslint|it\\(|describe\\(",
|
|
19
|
+
ignoreTemplateLiterals: true,
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
overrides: [
|
|
25
|
+
{
|
|
26
|
+
files: ["*.cjs"],
|
|
27
|
+
extends: ["hardcore", "hardcore/node"],
|
|
28
|
+
|
|
29
|
+
rules: {
|
|
30
|
+
"import/no-commonjs": "off",
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
parserOptions: {
|
|
34
|
+
sourceType: "script",
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
{
|
|
39
|
+
files: ["*.test.js"],
|
|
40
|
+
|
|
41
|
+
extends: ["hardcore", "hardcore/ts", "hardcore/node", "hardcore/jest"],
|
|
42
|
+
|
|
43
|
+
rules: {
|
|
44
|
+
"putout/putout": "off",
|
|
45
|
+
"import/unambiguous": "off",
|
|
46
|
+
"jest/prefer-expect-assertions": "off",
|
|
47
|
+
|
|
48
|
+
"new-cap": [
|
|
49
|
+
"error",
|
|
50
|
+
{ capIsNewExceptionPattern: "GET|PUT|POST|DELETE" },
|
|
51
|
+
],
|
|
52
|
+
|
|
53
|
+
"@typescript-eslint/naming-convention": "off",
|
|
54
|
+
|
|
55
|
+
"max-len": [
|
|
56
|
+
"warn",
|
|
57
|
+
{
|
|
58
|
+
ignorePattern: "eslint|it\\(|describe\\(",
|
|
59
|
+
ignoreTemplateLiterals: true,
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
|
|
63
|
+
"node/no-unpublished-import": "off",
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
{
|
|
68
|
+
files: ["demo/**/*.js"],
|
|
69
|
+
extends: ["hardcore", "hardcore/node"],
|
|
70
|
+
|
|
71
|
+
rules: {
|
|
72
|
+
"import/no-extraneous-dependencies": "off",
|
|
73
|
+
"import/no-unused-modules": "off",
|
|
74
|
+
"import/prefer-default-export": "off",
|
|
75
|
+
"no-param-reassign": "off",
|
|
76
|
+
"no-console": "off",
|
|
77
|
+
"node/no-unpublished-import": "off",
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
name: CI Checks
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
branches:
|
|
6
|
+
- main
|
|
7
|
+
workflow_dispatch:
|
|
8
|
+
|
|
9
|
+
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
ci-checks:
|
|
13
|
+
name: CI Checks
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
steps:
|
|
16
|
+
- name: Check out repo
|
|
17
|
+
uses: actions/checkout@v3
|
|
18
|
+
with:
|
|
19
|
+
fetch-depth: 0
|
|
20
|
+
- uses: actions/setup-node@v3
|
|
21
|
+
with:
|
|
22
|
+
node-version: 16.x
|
|
23
|
+
cache: yarn
|
|
24
|
+
id: setup-node
|
|
25
|
+
- name: Get Node Version
|
|
26
|
+
run: echo "::set-output name=version::$(node -v)"
|
|
27
|
+
id: node-version
|
|
28
|
+
- name: Cache node_modules
|
|
29
|
+
uses: actions/cache@v3
|
|
30
|
+
with:
|
|
31
|
+
path: "**/node_modules"
|
|
32
|
+
key: ${{ runner.os }}-${{ steps.node-version.outputs.version }}-${{ hashFiles('**/yarn.lock') }}
|
|
33
|
+
- name: Install Packages
|
|
34
|
+
run: yarn install --frozen-lockfile
|
|
35
|
+
- name: ESlint
|
|
36
|
+
run: yarn eslint -f github-annotations .
|
|
37
|
+
- name: Unit tests
|
|
38
|
+
run: yarn test --reporters=github-actions
|
|
39
|
+
- name: Create Release Pull Request or Publish to npm
|
|
40
|
+
id: changesets
|
|
41
|
+
uses: changesets/action@v1
|
|
42
|
+
with:
|
|
43
|
+
publish: yarn release
|
|
44
|
+
env:
|
|
45
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
46
|
+
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
package/.putout.json
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Counterfact
|
|
2
|
+
|
|
3
|
+
This is a work in progress.
|
|
4
|
+
|
|
5
|
+
If you're nosy and don't mind a whole lot of incompleteness:
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
yarn
|
|
9
|
+
yarn test
|
|
10
|
+
node demo/index.js
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Then open http://localhost:3100/hello/world and prepare to be underwhelmed. (See [./demo/README.md](./demo/README.md) for more.)
|
|
14
|
+
|
|
15
|
+
## Features
|
|
16
|
+
|
|
17
|
+
- [x] Convention-over-configuration style API
|
|
18
|
+
- [x] un-opinionated with respect how you set up fake data and APIs
|
|
19
|
+
- [x] e.g. implement `GET /hello/world` by exporting a `GET()` function from `./hello/world.mjs`
|
|
20
|
+
- [ ] easily read the request (method, headers, body, path, query string) and send a response (status, headers, body, content type, etc.)
|
|
21
|
+
- [x] fundamental, most commonly used parts
|
|
22
|
+
- [ ] details
|
|
23
|
+
- [x] middleware to plug in to Koa
|
|
24
|
+
- [ ] can also be run as a stand-alone HTTP server via CLI
|
|
25
|
+
- [x] hot reload: add / remove / change a file and see results without restarting the server
|
|
26
|
+
- [x] convenient access to a store which has local state
|
|
27
|
+
- [x] pure API (using reducers)
|
|
28
|
+
- [x] side effect API
|
|
29
|
+
- [ ] set up test scenarios by populating a store using "migration scripts"
|
|
30
|
+
- [ ] configure the store through an API or a GUI to select migration scripts
|
|
31
|
+
- [ ] "record mode" to automatically generate migration scripts
|
|
32
|
+
|
|
33
|
+
## Non-functional requirements
|
|
34
|
+
|
|
35
|
+
- [x] Written in Typescript
|
|
36
|
+
- [x] Solid unit test coverage
|
|
37
|
+
- [ ] CI/CD pipeline
|
|
38
|
+
- [ ] Documentation
|
|
39
|
+
- [x] Demo
|
|
40
|
+
- [ ] Publish to npm
|
|
41
|
+
|
|
42
|
+
As of Friday, April 15, 2022, all of the "hard" stuff is done (bleeding edge Node + Jest + TypeScript and most of hot reloading) and what remains is mostly drudgery. I'm aiming to finish hot reloading and put together a demo by EOD Monday (and hopefully more check some more boxes).
|
package/demo/README.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Counterfact Demo
|
|
2
|
+
|
|
3
|
+
This demo illustrates some of the basic features of Counterfact.
|
|
4
|
+
|
|
5
|
+
`index.js` starts an Apollo server and loads Counterfact's middleware pointed to route definitions at `./routes`.
|
|
6
|
+
|
|
7
|
+
Under `./routes` you will find a few endpoints definitions:
|
|
8
|
+
|
|
9
|
+
`hello.js` defines `/hello/:friend` and says hello to your friends.
|
|
10
|
+
|
|
11
|
+
`hello/kitty.js` defines `/hello/kitty`, overriding the behavior defined in `hello.js`.
|
|
12
|
+
|
|
13
|
+
`count.js` defines `/count` and reports how many times you've visited the other URLs.
|
|
14
|
+
|
|
15
|
+
Try adding more routes. You should be able to see the updates without restarting the server.
|
package/demo/index.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
|
|
3
|
+
import Koa from "koa";
|
|
4
|
+
|
|
5
|
+
import { counterfact } from "../src/counterfact.js";
|
|
6
|
+
|
|
7
|
+
const PORT = 3100;
|
|
8
|
+
|
|
9
|
+
const app = new Koa();
|
|
10
|
+
|
|
11
|
+
const { koaMiddleware } = await counterfact(
|
|
12
|
+
fileURLToPath(new URL("routes/", import.meta.url))
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
app.use(koaMiddleware);
|
|
16
|
+
|
|
17
|
+
app.listen(PORT);
|
|
18
|
+
console.log(`Open http://localhost:${PORT}/hello/world`);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function GET({ store }) {
|
|
2
|
+
if (!store.visits) {
|
|
3
|
+
return {
|
|
4
|
+
body: "You have not visited anyone yet.",
|
|
5
|
+
};
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
return {
|
|
9
|
+
body: Object.entries(store.visits)
|
|
10
|
+
.map(([page, count]) => `You visited ${page} ${count} times.`)
|
|
11
|
+
.join("\n"),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function GET({ path, store, query }) {
|
|
2
|
+
store.visits ??= {};
|
|
3
|
+
store.visits[path] ??= 0;
|
|
4
|
+
store.visits[path] += 1;
|
|
5
|
+
|
|
6
|
+
if (!path) {
|
|
7
|
+
return { body: "Hello, stranger!" };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
return {
|
|
11
|
+
body: `${query.greeting ?? "Hello"}, ${path}!`,
|
|
12
|
+
};
|
|
13
|
+
}
|
package/index.js
ADDED
package/jest.config.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "counterfact",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "a library for building a fake REST API for testing",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"author": "Patrick McElhaney <pmcelhaney@gmail.com>",
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=16.9.0"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"test": "yarn node --experimental-vm-modules $(yarn bin jest)"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"eslint": "8.17.0",
|
|
17
|
+
"eslint-config-hardcore": "24.5.0",
|
|
18
|
+
"eslint-formatter-github-annotations": "0.1.0",
|
|
19
|
+
"jest": "28.1.0",
|
|
20
|
+
"koa": "2.13.4",
|
|
21
|
+
"nodemon": "2.0.16",
|
|
22
|
+
"supertest": "6.2.3",
|
|
23
|
+
"typescript": "4.7.3"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@changesets/cli": "^2.22.0",
|
|
27
|
+
"chokidar": "^3.5.3"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Registry } from "./registry.js";
|
|
2
|
+
import { Dispatcher } from "./dispatcher.js";
|
|
3
|
+
import { koaMiddleware } from "./koa-middleware.js";
|
|
4
|
+
import { ModuleLoader } from "./module-loader.js";
|
|
5
|
+
|
|
6
|
+
export async function counterfact(basePath) {
|
|
7
|
+
const registry = new Registry();
|
|
8
|
+
const dispatcher = new Dispatcher(registry);
|
|
9
|
+
const moduleLoader = new ModuleLoader(basePath, registry);
|
|
10
|
+
|
|
11
|
+
await moduleLoader.load();
|
|
12
|
+
await moduleLoader.watch();
|
|
13
|
+
|
|
14
|
+
return { koaMiddleware: koaMiddleware(dispatcher), registry, moduleLoader };
|
|
15
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export class Dispatcher {
|
|
2
|
+
registry;
|
|
3
|
+
|
|
4
|
+
constructor(registry) {
|
|
5
|
+
this.registry = registry;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
request({ method, path, body, query }) {
|
|
9
|
+
return this.registry.endpoint(
|
|
10
|
+
method,
|
|
11
|
+
path
|
|
12
|
+
)({
|
|
13
|
+
// path: parts.slice(remainingParts).join("/"),
|
|
14
|
+
|
|
15
|
+
reduce: (reducer) => {
|
|
16
|
+
this.registry.store = reducer(this.registry.store);
|
|
17
|
+
},
|
|
18
|
+
|
|
19
|
+
store: this.registry.store,
|
|
20
|
+
body,
|
|
21
|
+
query,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export function koaMiddleware(dispatcher) {
|
|
2
|
+
return async function middleware(ctx) {
|
|
3
|
+
const { method, path, body, query } = ctx.request;
|
|
4
|
+
|
|
5
|
+
const response = await dispatcher.request({
|
|
6
|
+
method,
|
|
7
|
+
path,
|
|
8
|
+
body,
|
|
9
|
+
query,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
/* eslint-disable require-atomic-updates */
|
|
13
|
+
ctx.body = response.body;
|
|
14
|
+
ctx.status = 200;
|
|
15
|
+
/* eslint-enable require-atomic-updates */
|
|
16
|
+
};
|
|
17
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/* eslint-disable node/no-unsupported-features/es-syntax */
|
|
2
|
+
/* eslint-disable import/no-dynamic-require */
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import EventEmitter, { once } from "node:events";
|
|
6
|
+
|
|
7
|
+
import chokidar from "chokidar";
|
|
8
|
+
|
|
9
|
+
export class ModuleLoader extends EventEmitter {
|
|
10
|
+
basePath;
|
|
11
|
+
|
|
12
|
+
registry;
|
|
13
|
+
|
|
14
|
+
watcher;
|
|
15
|
+
|
|
16
|
+
constructor(basePath, registry) {
|
|
17
|
+
super();
|
|
18
|
+
this.basePath = basePath;
|
|
19
|
+
this.registry = registry;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async watch() {
|
|
23
|
+
if (this.watcher) {
|
|
24
|
+
throw new Error("already watching");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
this.watcher = chokidar
|
|
28
|
+
.watch(`${this.basePath}/**/*`)
|
|
29
|
+
.on("all", (event, pathName) => {
|
|
30
|
+
if (!["add", "change", "unlink"].includes(event)) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const parts = path.parse(pathName.replace(this.basePath, ""));
|
|
35
|
+
const url = `/${path.join(parts.dir, parts.name)}`;
|
|
36
|
+
|
|
37
|
+
if (event === "unlink") {
|
|
38
|
+
this.registry.remove(url);
|
|
39
|
+
this.emit("remove", pathName);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
import(`${pathName}?cacheBust=${Date.now()}`)
|
|
43
|
+
// eslint-disable-next-line promise/prefer-await-to-then
|
|
44
|
+
.then((endpoint) => {
|
|
45
|
+
this.registry.add(url, endpoint);
|
|
46
|
+
this.emit(event, pathName);
|
|
47
|
+
|
|
48
|
+
return "ok";
|
|
49
|
+
})
|
|
50
|
+
// eslint-disable-next-line promise/prefer-await-to-then
|
|
51
|
+
.catch((error) => {
|
|
52
|
+
throw new Error(String(error));
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
await once(this.watcher, "ready");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async stopWatching() {
|
|
59
|
+
await this.watcher?.close();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async load(directory = "") {
|
|
63
|
+
const files = await fs.readdir(path.join(this.basePath, directory), {
|
|
64
|
+
withFileTypes: true,
|
|
65
|
+
});
|
|
66
|
+
const imports = files.flatMap(async (file) => {
|
|
67
|
+
if (file.isDirectory()) {
|
|
68
|
+
await this.load(path.join(directory, file.name));
|
|
69
|
+
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const endpoint = await import(
|
|
74
|
+
path.join(this.basePath, directory, file.name)
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
this.registry.add(
|
|
78
|
+
`/${path.join(directory, path.parse(file.name).name)}`,
|
|
79
|
+
endpoint
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
await Promise.all(imports);
|
|
84
|
+
}
|
|
85
|
+
}
|
package/src/registry.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
export class Registry {
|
|
2
|
+
modules = {};
|
|
3
|
+
|
|
4
|
+
moduleTree = {
|
|
5
|
+
children: {},
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
store;
|
|
9
|
+
|
|
10
|
+
constructor(store = {}) {
|
|
11
|
+
this.store = store;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
get modulesList() {
|
|
15
|
+
return Object.keys(this.modules);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
add(url, module) {
|
|
19
|
+
let node = this.moduleTree;
|
|
20
|
+
|
|
21
|
+
for (const segment of url.split("/").slice(1)) {
|
|
22
|
+
node.children ??= {};
|
|
23
|
+
node.children[segment] ??= {};
|
|
24
|
+
node = node.children[segment];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
node.module = module;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
remove(url) {
|
|
31
|
+
let node = this.moduleTree;
|
|
32
|
+
|
|
33
|
+
for (const segment of url.split("/").slice(1)) {
|
|
34
|
+
node = node?.children?.[segment];
|
|
35
|
+
|
|
36
|
+
if (!node) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
delete node.module;
|
|
42
|
+
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
exists(method, url) {
|
|
47
|
+
return Boolean(this.handler(url)?.module?.[method]);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
handler(url) {
|
|
51
|
+
let node = this.moduleTree;
|
|
52
|
+
|
|
53
|
+
const path = {};
|
|
54
|
+
|
|
55
|
+
for (const segment of url.split("/").slice(1)) {
|
|
56
|
+
if (node.children[segment]) {
|
|
57
|
+
node = node.children[segment];
|
|
58
|
+
} else {
|
|
59
|
+
const dynamicSegment = Object.keys(node.children).find(
|
|
60
|
+
(ds) => ds.startsWith("[") && ds.endsWith("]")
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
if (dynamicSegment) {
|
|
64
|
+
// eslint-disable-next-line no-magic-numbers
|
|
65
|
+
path[dynamicSegment.slice(1, -1)] = segment;
|
|
66
|
+
|
|
67
|
+
node = node.children[dynamicSegment];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return { module: node.module, path };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
endpoint(httpRequestMethod, url) {
|
|
76
|
+
const handler = this.handler(url);
|
|
77
|
+
const lambda = handler?.module?.[httpRequestMethod];
|
|
78
|
+
|
|
79
|
+
if (!lambda) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`${httpRequestMethod} method for endpoint at "${url}" does not exist`
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return ({ ...context }) => lambda({ ...context, path: handler.path });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import supertest from "supertest";
|
|
2
|
+
import Koa from "koa";
|
|
3
|
+
|
|
4
|
+
import { counterfact } from "../src/counterfact.js";
|
|
5
|
+
|
|
6
|
+
import { withTemporaryFiles } from "./lib/with-temporary-files.js";
|
|
7
|
+
|
|
8
|
+
describe("integration test", () => {
|
|
9
|
+
it("finds a path", async () => {
|
|
10
|
+
const app = new Koa();
|
|
11
|
+
const request = supertest(app.callback());
|
|
12
|
+
const files = {
|
|
13
|
+
"hello.mjs": `
|
|
14
|
+
export async function GET() {
|
|
15
|
+
return await Promise.resolve({ body: "GET /hello" });
|
|
16
|
+
}
|
|
17
|
+
`,
|
|
18
|
+
"hello/world.mjs": `
|
|
19
|
+
export async function POST() {
|
|
20
|
+
return await Promise.resolve({ body: "POST /hello/world" });
|
|
21
|
+
}
|
|
22
|
+
`,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
await withTemporaryFiles(files, async (basePath) => {
|
|
26
|
+
const { koaMiddleware, moduleLoader } = await counterfact(basePath);
|
|
27
|
+
|
|
28
|
+
app.use(koaMiddleware);
|
|
29
|
+
|
|
30
|
+
const getResponse = await request.get("/hello");
|
|
31
|
+
const postResponse = await request.post("/hello/world");
|
|
32
|
+
|
|
33
|
+
expect(getResponse.text).toBe("GET /hello");
|
|
34
|
+
expect(postResponse.text).toBe("POST /hello/world");
|
|
35
|
+
|
|
36
|
+
await moduleLoader.stopWatching();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { Dispatcher } from "../src/dispatcher.js";
|
|
2
|
+
import { Registry } from "../src/registry.js";
|
|
3
|
+
|
|
4
|
+
describe("a dispatcher", () => {
|
|
5
|
+
it("dispatches a get request to a server and returns the response", async () => {
|
|
6
|
+
const registry = new Registry();
|
|
7
|
+
|
|
8
|
+
registry.add("/hello", {
|
|
9
|
+
GET() {
|
|
10
|
+
return {
|
|
11
|
+
body: "hello",
|
|
12
|
+
};
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const dispatcher = new Dispatcher(registry);
|
|
17
|
+
const response = await dispatcher.request({
|
|
18
|
+
method: "GET",
|
|
19
|
+
path: "/hello",
|
|
20
|
+
body: "",
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
expect(response.body).toBe("hello");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("passes the request body", async () => {
|
|
27
|
+
const registry = new Registry();
|
|
28
|
+
|
|
29
|
+
registry.add("/a", {
|
|
30
|
+
GET({ body }) {
|
|
31
|
+
return {
|
|
32
|
+
body: `Hello ${body.name} of ${body.place}!`,
|
|
33
|
+
};
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const dispatcher = new Dispatcher(registry);
|
|
38
|
+
const response = await dispatcher.request({
|
|
39
|
+
method: "GET",
|
|
40
|
+
path: "/a",
|
|
41
|
+
|
|
42
|
+
body: {
|
|
43
|
+
name: "Catherine",
|
|
44
|
+
place: "Aragon",
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
expect(response.body).toBe("Hello Catherine of Aragon!");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("passes the query params", async () => {
|
|
52
|
+
const registry = new Registry();
|
|
53
|
+
|
|
54
|
+
registry.add("/a", {
|
|
55
|
+
GET({ query }) {
|
|
56
|
+
return {
|
|
57
|
+
body: `Searching for stores near ${query.zip}!`,
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const dispatcher = new Dispatcher(registry);
|
|
63
|
+
const response = await dispatcher.request({
|
|
64
|
+
method: "GET",
|
|
65
|
+
path: "/a",
|
|
66
|
+
|
|
67
|
+
query: {
|
|
68
|
+
zip: "90210",
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
expect(response.body).toBe("Searching for stores near 90210!");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("passes a reducer function that can be used to read / update the store", async () => {
|
|
76
|
+
const registry = new Registry({ value: 0 });
|
|
77
|
+
|
|
78
|
+
registry.add("/increment/[value]", {
|
|
79
|
+
GET({ reduce, path }) {
|
|
80
|
+
const amountToIncrement = Number.parseInt(path.value, 10);
|
|
81
|
+
|
|
82
|
+
reduce((store) => ({
|
|
83
|
+
value: store.value + amountToIncrement,
|
|
84
|
+
}));
|
|
85
|
+
|
|
86
|
+
return { body: "incremented" };
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const dispatcher = new Dispatcher(registry);
|
|
91
|
+
|
|
92
|
+
await dispatcher.request({
|
|
93
|
+
method: "GET",
|
|
94
|
+
path: "/increment/1",
|
|
95
|
+
body: "",
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
expect(registry.store.value).toBe(1);
|
|
99
|
+
|
|
100
|
+
await dispatcher.request({
|
|
101
|
+
method: "GET",
|
|
102
|
+
path: "/increment/2",
|
|
103
|
+
body: "",
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
expect(registry.store.value).toBe(3);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("allows the store to be mutated directly", async () => {
|
|
110
|
+
const registry = new Registry({ value: 0 });
|
|
111
|
+
|
|
112
|
+
registry.add("/increment/[value]", {
|
|
113
|
+
GET({ store, path }) {
|
|
114
|
+
const amountToIncrement = Number.parseInt(path.value, 10);
|
|
115
|
+
|
|
116
|
+
// eslint-disable-next-line no-param-reassign
|
|
117
|
+
store.value += amountToIncrement;
|
|
118
|
+
|
|
119
|
+
return { body: "incremented" };
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const dispatcher = new Dispatcher(registry);
|
|
124
|
+
|
|
125
|
+
const result = await dispatcher.request({
|
|
126
|
+
method: "GET",
|
|
127
|
+
path: "/increment/1",
|
|
128
|
+
body: "",
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
expect(result.body).toBe("incremented");
|
|
132
|
+
|
|
133
|
+
expect(registry.store.value).toBe(1);
|
|
134
|
+
|
|
135
|
+
await dispatcher.request({
|
|
136
|
+
method: "GET",
|
|
137
|
+
path: "/increment/2",
|
|
138
|
+
body: "",
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
expect(registry.store.value).toBe(3);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Registry } from "../src/registry.js";
|
|
2
|
+
import { Dispatcher } from "../src/dispatcher.js";
|
|
3
|
+
import { koaMiddleware } from "../src/koa-middleware.js";
|
|
4
|
+
|
|
5
|
+
describe("koa middleware", () => {
|
|
6
|
+
it("passes the request to the dispatcher and returns the response", async () => {
|
|
7
|
+
const registry = new Registry();
|
|
8
|
+
|
|
9
|
+
registry.add("/hello", {
|
|
10
|
+
GET({ body }) {
|
|
11
|
+
return {
|
|
12
|
+
body: `Hello, ${body.name}!`,
|
|
13
|
+
};
|
|
14
|
+
},
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const dispatcher = new Dispatcher(registry);
|
|
18
|
+
const middleware = koaMiddleware(dispatcher);
|
|
19
|
+
const ctx = {
|
|
20
|
+
request: { path: "/hello", method: "GET", body: { name: "Homer" } },
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
await middleware(ctx);
|
|
24
|
+
|
|
25
|
+
expect(ctx.status).toBe(200);
|
|
26
|
+
expect(ctx.body).toBe("Hello, Homer!");
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import { constants as fsConstants } from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
async function ensureDirectoryExists(filePath) {
|
|
7
|
+
const directory = path.dirname(filePath);
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
await fs.access(directory, fsConstants.W_OK);
|
|
11
|
+
} catch {
|
|
12
|
+
await fs.mkdir(directory, { recursive: true });
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function createAddFunction(basePath) {
|
|
17
|
+
return async function add(filePath, content) {
|
|
18
|
+
const fullPath = path.join(basePath, filePath);
|
|
19
|
+
|
|
20
|
+
await ensureDirectoryExists(fullPath);
|
|
21
|
+
await fs.writeFile(fullPath, content);
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function createRemoveFunction(basePath) {
|
|
26
|
+
return async function remove(filePath) {
|
|
27
|
+
const fullPath = path.join(basePath, filePath);
|
|
28
|
+
|
|
29
|
+
await ensureDirectoryExists(fullPath);
|
|
30
|
+
await fs.rm(fullPath);
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function withTemporaryFiles(files, ...callbacks) {
|
|
35
|
+
const temporaryDirectory = `${await fs.mkdtemp(
|
|
36
|
+
path.join(os.tmpdir(), "wtf-")
|
|
37
|
+
)}/`;
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const writes = Object.entries(files).map(async (entry) => {
|
|
41
|
+
const [filename, contents] = entry;
|
|
42
|
+
const filePath = path.join(temporaryDirectory, filename);
|
|
43
|
+
|
|
44
|
+
await ensureDirectoryExists(filePath);
|
|
45
|
+
await fs.writeFile(filePath, contents);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
await Promise.all(writes);
|
|
49
|
+
|
|
50
|
+
for (const callback of callbacks) {
|
|
51
|
+
// eslint-disable-next-line no-await-in-loop, node/callback-return
|
|
52
|
+
await callback(temporaryDirectory, {
|
|
53
|
+
add: createAddFunction(temporaryDirectory),
|
|
54
|
+
remove: createRemoveFunction(temporaryDirectory),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
} finally {
|
|
58
|
+
await fs.rm(temporaryDirectory, {
|
|
59
|
+
recursive: true,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { once } from "node:events";
|
|
2
|
+
|
|
3
|
+
import { ModuleLoader } from "../src/module-loader.js";
|
|
4
|
+
import { Registry } from "../src/registry.js";
|
|
5
|
+
|
|
6
|
+
import { withTemporaryFiles } from "./lib/with-temporary-files.js";
|
|
7
|
+
|
|
8
|
+
describe("a module loader", () => {
|
|
9
|
+
it("finds a file and adds it to the registry", async () => {
|
|
10
|
+
const files = {
|
|
11
|
+
"hello.mjs": `
|
|
12
|
+
export function GET() {
|
|
13
|
+
return {
|
|
14
|
+
body: "hello"
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
`,
|
|
18
|
+
"a/b/c.mjs": `
|
|
19
|
+
export function GET() {
|
|
20
|
+
return {
|
|
21
|
+
body: "GET from a/b/c"
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
`,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
await withTemporaryFiles(files, async (basePath) => {
|
|
28
|
+
const registry = new Registry();
|
|
29
|
+
const loader = new ModuleLoader(basePath, registry);
|
|
30
|
+
|
|
31
|
+
await loader.load();
|
|
32
|
+
|
|
33
|
+
expect(registry.exists("GET", "/hello")).toBe(true);
|
|
34
|
+
expect(registry.exists("POST", "/hello")).toBe(false);
|
|
35
|
+
expect(registry.exists("GET", "/goodbye")).toBe(false);
|
|
36
|
+
expect(registry.exists("GET", "/a/b/c")).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("updates the registry when a file is added", async () => {
|
|
41
|
+
await withTemporaryFiles({}, async (basePath, { add }) => {
|
|
42
|
+
const registry = new Registry();
|
|
43
|
+
const loader = new ModuleLoader(basePath, registry);
|
|
44
|
+
|
|
45
|
+
await loader.load();
|
|
46
|
+
await loader.watch();
|
|
47
|
+
|
|
48
|
+
expect(registry.exists("GET", "/late/addition")).toBe(false);
|
|
49
|
+
|
|
50
|
+
add(
|
|
51
|
+
"late/addition.mjs",
|
|
52
|
+
'export function GET() { return { body: "I\'m here now!" }; }'
|
|
53
|
+
);
|
|
54
|
+
await once(loader, "add");
|
|
55
|
+
|
|
56
|
+
expect(registry.exists("GET", "/late/addition")).toBe(true);
|
|
57
|
+
|
|
58
|
+
await loader.stopWatching();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("updates the registry when a file is deleted", async () => {
|
|
63
|
+
await withTemporaryFiles(
|
|
64
|
+
{
|
|
65
|
+
"delete-me.mjs":
|
|
66
|
+
'export function GET() { return { body: "Goodbye" }; }',
|
|
67
|
+
},
|
|
68
|
+
async (basePath, { remove }) => {
|
|
69
|
+
const registry = new Registry();
|
|
70
|
+
const loader = new ModuleLoader(basePath, registry);
|
|
71
|
+
|
|
72
|
+
await loader.load();
|
|
73
|
+
await loader.watch();
|
|
74
|
+
|
|
75
|
+
expect(registry.exists("GET", "/delete-me")).toBe(true);
|
|
76
|
+
|
|
77
|
+
remove("delete-me.mjs");
|
|
78
|
+
await once(loader, "remove");
|
|
79
|
+
|
|
80
|
+
expect(registry.exists("GET", "/delete-me")).toBe(false);
|
|
81
|
+
|
|
82
|
+
await loader.stopWatching();
|
|
83
|
+
}
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// This should work but I can't figure out how to break the
|
|
88
|
+
// module cache when running through Jest (which uses the
|
|
89
|
+
// experimental module API).
|
|
90
|
+
|
|
91
|
+
// eslint-disable-next-line jest/no-disabled-tests
|
|
92
|
+
it.skip("updates the registry when a file is changed", async () => {
|
|
93
|
+
await withTemporaryFiles(
|
|
94
|
+
{
|
|
95
|
+
"change.mjs":
|
|
96
|
+
'export function GET() { return { body: "before change" }; }',
|
|
97
|
+
},
|
|
98
|
+
async (basePath, { add }) => {
|
|
99
|
+
const registry = new Registry();
|
|
100
|
+
const loader = new ModuleLoader(basePath, registry);
|
|
101
|
+
|
|
102
|
+
await loader.watch();
|
|
103
|
+
add(
|
|
104
|
+
"change.mjs",
|
|
105
|
+
'export function GET() { return { body: "after change" }; }'
|
|
106
|
+
);
|
|
107
|
+
await once(loader, "change");
|
|
108
|
+
|
|
109
|
+
const response = await registry.endpoint(
|
|
110
|
+
"GET",
|
|
111
|
+
"/change"
|
|
112
|
+
)({ path: "", reduce: () => undefined, store: {} });
|
|
113
|
+
|
|
114
|
+
expect(response.body).toBe("after change");
|
|
115
|
+
expect(registry.exists("GET", "/late/addition")).toBe(true);
|
|
116
|
+
|
|
117
|
+
await loader.stopWatching();
|
|
118
|
+
}
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { Registry } from "../src/registry.js";
|
|
2
|
+
|
|
3
|
+
describe("a scripted server", () => {
|
|
4
|
+
it("knows if a handler exists for a request method at a path", () => {
|
|
5
|
+
const registry = new Registry();
|
|
6
|
+
|
|
7
|
+
registry.add("/hello", {
|
|
8
|
+
async GET() {
|
|
9
|
+
await Promise.resolve("noop");
|
|
10
|
+
|
|
11
|
+
return { body: "hello" };
|
|
12
|
+
},
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
expect(registry.exists("GET", "/hello")).toBe(true);
|
|
16
|
+
expect(registry.exists("POST", "/hello")).toBe(false);
|
|
17
|
+
expect(registry.exists("GET", "/goodbye")).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it.todo("returns debug information if path does not exist");
|
|
21
|
+
|
|
22
|
+
it("returns a function matching the URL and request method", async () => {
|
|
23
|
+
const registry = new Registry();
|
|
24
|
+
|
|
25
|
+
registry.add("/a", {
|
|
26
|
+
async GET() {
|
|
27
|
+
await Promise.resolve("noop");
|
|
28
|
+
|
|
29
|
+
return { body: "GET a" };
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
async POST() {
|
|
33
|
+
await Promise.resolve("noop");
|
|
34
|
+
|
|
35
|
+
return { body: "POST a" };
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
registry.add("/b", {
|
|
39
|
+
async GET() {
|
|
40
|
+
await Promise.resolve("noop");
|
|
41
|
+
|
|
42
|
+
return { body: "GET b" };
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
async POST() {
|
|
46
|
+
await Promise.resolve("noop");
|
|
47
|
+
|
|
48
|
+
return { body: "POST b" };
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const props = {
|
|
53
|
+
path: "",
|
|
54
|
+
|
|
55
|
+
reduce(foo) {
|
|
56
|
+
return foo;
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
store: {},
|
|
60
|
+
};
|
|
61
|
+
const getA = await registry.endpoint("GET", "/a")(props);
|
|
62
|
+
const getB = await registry.endpoint("GET", "/b")(props);
|
|
63
|
+
const postA = await registry.endpoint("POST", "/a")(props);
|
|
64
|
+
const postB = await registry.endpoint("POST", "/b")(props);
|
|
65
|
+
|
|
66
|
+
expect(getA.body).toBe("GET a");
|
|
67
|
+
expect(getB.body).toBe("GET b");
|
|
68
|
+
expect(postA.body).toBe("POST a");
|
|
69
|
+
expect(postB.body).toBe("POST b");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("constructs a tree of the registered modules", () => {
|
|
73
|
+
const registry = new Registry();
|
|
74
|
+
|
|
75
|
+
registry.add("/nc", "North Carolina");
|
|
76
|
+
registry.add("/nc/charlotte/south-park", "South Park");
|
|
77
|
+
registry.add("/nc/charlotte", "Charlotte, NC");
|
|
78
|
+
|
|
79
|
+
const { nc } = registry.moduleTree.children;
|
|
80
|
+
const { charlotte } = nc.children;
|
|
81
|
+
const southPark = charlotte.children["south-park"];
|
|
82
|
+
|
|
83
|
+
expect(nc.module).toBe("North Carolina");
|
|
84
|
+
expect(charlotte.module).toBe("Charlotte, NC");
|
|
85
|
+
expect(southPark.module).toBe("South Park");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("handles a dynamic path", () => {
|
|
89
|
+
const registry = new Registry();
|
|
90
|
+
|
|
91
|
+
registry.add("/[organization]/users/[username]/friends/[page]", {
|
|
92
|
+
GET({ path }) {
|
|
93
|
+
return {
|
|
94
|
+
body: `page ${path.page} of ${path.username}'s friends in ${path.organization}`,
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
expect(
|
|
100
|
+
registry.endpoint("GET", "/acme/users/alice/friends/2")({})
|
|
101
|
+
).toStrictEqual({
|
|
102
|
+
body: "page 2 of alice's friends in acme",
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
});
|