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 +40 -0
- package/.github/workflows/coveralls.yaml +28 -0
- package/.husky/post-commit +4 -0
- package/CHANGELOG.md +12 -0
- package/README.md +55 -84
- package/demo/routes/count.js +3 -3
- package/demo/routes/hello/[name].js +4 -4
- package/demo-ts/README.md +19 -0
- package/demo-ts/context/README.md +3 -0
- package/demo-ts/context/Store.ts +12 -0
- package/demo-ts/context/context.ts +12 -0
- package/demo-ts/index.ts +25 -0
- package/demo-ts/routes/#types.ts +7 -0
- package/demo-ts/routes/README.md +3 -0
- package/demo-ts/routes/count.ts +18 -0
- package/demo-ts/routes/hello/#types.ts +16 -0
- package/demo-ts/routes/hello/[name].ts +11 -0
- package/demo-ts/routes/hello/kitty.ts +5 -0
- package/demo-ts/tsconfig.json +9 -0
- package/demo-ts/types/Greeting.ts +1 -0
- package/demo-ts/types/HtmlImgTag.ts +1 -0
- package/demo-ts/types/Http.ts +2 -0
- package/demo-ts/types/README.md +5 -0
- package/package.json +12 -8
- package/src/counterfact.js +2 -2
- package/src/dispatcher.js +2 -2
- package/src/module-loader.js +17 -6
- package/src/registry.js +15 -8
- package/test/counterfact.test.js +27 -0
- package/test/dispatcher.test.js +38 -11
- package/test/lib/with-temporary-files.js +9 -0
- package/test/module-loader.test.js +29 -1
- package/test/registry.test.js +2 -2
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 }}
|
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
|
[](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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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 `
|
|
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
|
|
122
|
+
There are no rules around how you manipulate the context. Yes, you read that right.
|
|
151
123
|
|
|
152
124
|
```js
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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,
|
|
141
|
+
export function GET({ path, context }) {
|
|
171
142
|
|
|
172
|
-
|
|
173
|
-
|
|
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 ${
|
|
149
|
+
body: "Hello, ${path.name}. You look ${context.friends[name].appearance} today!"
|
|
179
150
|
}
|
|
180
151
|
}
|
|
181
152
|
|
|
182
|
-
export function POST(path,
|
|
153
|
+
export function POST(path, context, body) {
|
|
183
154
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
172
|
+
export function PUT({ body }) {
|
|
173
|
+
return doSomeStuffWith(body).then(() => {
|
|
174
|
+
body: "Successfully did an async PUT";
|
|
175
|
+
});
|
|
176
|
+
}
|
|
206
177
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
package/demo/routes/count.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
export function GET({
|
|
2
|
-
if (!
|
|
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(
|
|
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,
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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();
|
package/demo-ts/index.ts
ADDED
|
@@ -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,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 @@
|
|
|
1
|
+
export type Greeting = `${string}, ${string}!`;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type HtmlImgTag = `<img src="${string}">`;
|
|
@@ -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.
|
|
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.
|
|
29
|
-
"@stryker-mutator/core": "6.
|
|
30
|
-
"@stryker-mutator/jest-runner": "6.
|
|
31
|
-
"
|
|
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.
|
|
37
|
+
"jest": "28.1.2",
|
|
35
38
|
"koa": "2.13.4",
|
|
36
|
-
"
|
|
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
|
},
|
package/src/counterfact.js
CHANGED
|
@@ -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.
|
|
16
|
+
this.registry.context = reducer(this.registry.context);
|
|
17
17
|
},
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
context: this.registry.context,
|
|
20
20
|
body,
|
|
21
21
|
query,
|
|
22
22
|
});
|
package/src/module-loader.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
8
|
+
context;
|
|
9
9
|
|
|
10
|
-
constructor(
|
|
11
|
-
this.
|
|
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("
|
|
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
|
-
|
|
77
|
-
|
|
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 });
|
package/test/counterfact.test.js
CHANGED
|
@@ -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
|
});
|
package/test/dispatcher.test.js
CHANGED
|
@@ -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/
|
|
99
|
+
registry.add("/increment/{value}", {
|
|
100
100
|
GET({ reduce, path }) {
|
|
101
101
|
const amountToIncrement = Number.parseInt(path.value, 10);
|
|
102
102
|
|
|
103
|
-
reduce((
|
|
104
|
-
value:
|
|
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.
|
|
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.
|
|
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/
|
|
134
|
-
GET({
|
|
133
|
+
registry.add("/increment/{value}", {
|
|
134
|
+
GET({ context, path }) {
|
|
135
135
|
const amountToIncrement = Number.parseInt(path.value, 10);
|
|
136
136
|
|
|
137
|
-
|
|
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.
|
|
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.
|
|
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,
|
|
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);
|
package/test/registry.test.js
CHANGED
|
@@ -56,7 +56,7 @@ describe("a scripted server", () => {
|
|
|
56
56
|
return foo;
|
|
57
57
|
},
|
|
58
58
|
|
|
59
|
-
|
|
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("/
|
|
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}`,
|