@nxgt/graphql-codegen-msw 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +293 -2
- package/THIRD-PARTY-NOTICES.md +71 -0
- package/dist/config.d.ts +66 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/imports.d.ts +17 -0
- package/dist/imports.d.ts.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +365 -0
- package/dist/index.js.map +15 -0
- package/dist/input-types.d.ts +11 -0
- package/dist/input-types.d.ts.map +1 -0
- package/dist/naming.d.ts +23 -0
- package/dist/naming.d.ts.map +1 -0
- package/dist/operations.d.ts +17 -0
- package/dist/operations.d.ts.map +1 -0
- package/dist/options.d.ts +31 -0
- package/dist/options.d.ts.map +1 -0
- package/dist/runtime/coercion/cross-inspect.d.ts +19 -0
- package/dist/runtime/coercion/cross-inspect.d.ts.map +1 -0
- package/dist/runtime/coercion/document.d.ts +51 -0
- package/dist/runtime/coercion/document.d.ts.map +1 -0
- package/dist/runtime/coercion/index.d.ts +30 -0
- package/dist/runtime/coercion/index.d.ts.map +1 -0
- package/dist/runtime/coercion/inspect.d.ts +9 -0
- package/dist/runtime/coercion/inspect.d.ts.map +1 -0
- package/dist/runtime/coercion/issues.d.ts +19 -0
- package/dist/runtime/coercion/issues.d.ts.map +1 -0
- package/dist/runtime/coercion/messages.d.ts +37 -0
- package/dist/runtime/coercion/messages.d.ts.map +1 -0
- package/dist/runtime/coercion/suggestions.d.ts +15 -0
- package/dist/runtime/coercion/suggestions.d.ts.map +1 -0
- package/dist/runtime/coercion/validate.d.ts +39 -0
- package/dist/runtime/coercion/validate.d.ts.map +1 -0
- package/dist/runtime/index.d.ts +6 -0
- package/dist/runtime/index.d.ts.map +1 -0
- package/dist/runtime/index.js +595 -0
- package/dist/runtime/index.js.map +21 -0
- package/dist/runtime/mock-operation.d.ts +65 -0
- package/dist/runtime/mock-operation.d.ts.map +1 -0
- package/dist/runtime/refusals.d.ts +36 -0
- package/dist/runtime/refusals.d.ts.map +1 -0
- package/dist/runtime/response.d.ts +34 -0
- package/dist/runtime/response.d.ts.map +1 -0
- package/docs/README.md +14 -0
- package/docs/guide/handlers.md +654 -0
- package/docs/roadmap.md +40 -0
- package/docs/troubleshooting.md +304 -0
- package/package.json +80 -5
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
# Handlers
|
|
2
|
+
|
|
3
|
+
What the plugin writes from your operations, how each handler checks its
|
|
4
|
+
variables and answers an invalid input, and the runtime the generated file
|
|
5
|
+
calls.
|
|
6
|
+
|
|
7
|
+
## The smallest example
|
|
8
|
+
|
|
9
|
+
```graphql
|
|
10
|
+
# schema
|
|
11
|
+
input PersonInput {
|
|
12
|
+
firstName: String! @constraint(minLength: 2)
|
|
13
|
+
email: String! @constraint(format: "email")
|
|
14
|
+
}
|
|
15
|
+
input SignUpInput { person: PersonInput!, tags: [String!] @constraint(maxItems: 2) }
|
|
16
|
+
type User { id: ID!, name: String! }
|
|
17
|
+
type Query { user(id: ID! @constraint(minLength: 3)): User }
|
|
18
|
+
type Mutation { signUp(input: SignUpInput!): User }
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
```graphql
|
|
22
|
+
# documents
|
|
23
|
+
query User($id: ID!) { user(id: $id) { id name } }
|
|
24
|
+
mutation SignUp($input: SignUpInput!) { signUp(input: $input) { id name } }
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
// codegen.ts, beside the client preset and @nxgt/graphql-codegen-zod
|
|
29
|
+
'src/generated/msw.ts': {
|
|
30
|
+
plugins: ['@nxgt/graphql-codegen-msw'],
|
|
31
|
+
config: {
|
|
32
|
+
endpoint: 'https://api.example.com/graphql',
|
|
33
|
+
schemasImport: './zod',
|
|
34
|
+
typesImport: './graphql',
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The plugin writes:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import type { GraphQLHandler } from "msw/graphql";
|
|
43
|
+
import { type GraphQLMockResolver, type MockOptions, type InvalidInput, type InputTypes, mockOperation, invalidInputRefusal } from "@nxgt/graphql-codegen-msw/runtime";
|
|
44
|
+
import type { UserQuery, UserQueryVariables, SignUpMutation, SignUpMutationVariables } from "./graphql";
|
|
45
|
+
import { zUserQueryVariables, zSignUpMutationVariables } from "./zod";
|
|
46
|
+
|
|
47
|
+
/** Where the client sends its operations; a handler's `options.endpoint` overrides it. */
|
|
48
|
+
export const endpoint = "https://api.example.com/graphql";
|
|
49
|
+
|
|
50
|
+
/** How an invalid input is answered, without calling the resolver. */
|
|
51
|
+
export const invalidInput: InvalidInput = invalidInputRefusal();
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The input types the variables reach, from the schema: how a handler words
|
|
55
|
+
* a value graphql refuses. Internal: its shape may change in any version.
|
|
56
|
+
*/
|
|
57
|
+
const inputTypes: InputTypes = {
|
|
58
|
+
DateTime: { kind: "scalar" },
|
|
59
|
+
ID: { kind: "builtin" },
|
|
60
|
+
PersonInput: {
|
|
61
|
+
kind: "input",
|
|
62
|
+
fields: {
|
|
63
|
+
firstName: "String!",
|
|
64
|
+
email: "String!",
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
SignUpInput: {
|
|
68
|
+
kind: "input",
|
|
69
|
+
fields: {
|
|
70
|
+
person: "PersonInput!",
|
|
71
|
+
tags: "[String!]",
|
|
72
|
+
birth: "DateTime",
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
String: { kind: "builtin" },
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** Mocks the User query: `resolver` answers once the variables pass `zUserQueryVariables`. */
|
|
79
|
+
export function mockUserQuery(
|
|
80
|
+
resolver: GraphQLMockResolver<UserQuery, UserQueryVariables>,
|
|
81
|
+
options?: MockOptions,
|
|
82
|
+
): GraphQLHandler {
|
|
83
|
+
return mockOperation({ type: "query", name: "User", variables: zUserQueryVariables, variableTypes: { id: "ID!" }, inputTypes, endpoint, invalidInput }, resolver, options);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Mocks the SignUp mutation: … */
|
|
87
|
+
export function mockSignUpMutation(…): GraphQLHandler { … }
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
One handler factory per **named query and mutation**, in document order. The
|
|
91
|
+
schema gives `inputTypes`, every input type the variables reach (sorted by
|
|
92
|
+
name, an input object's fields and an enum's values one per line, so a schema
|
|
93
|
+
change shows as the lines it changes), and each handler's `variableTypes`: the
|
|
94
|
+
answer to a value graphql
|
|
95
|
+
refuses is worded from them (see
|
|
96
|
+
[What the API refuses before your resolvers](#what-the-api-refuses-before-your-resolvers)).
|
|
97
|
+
Their shape is internal to the generated file and the runtime, and may change
|
|
98
|
+
in any version; regenerate after an upgrade.
|
|
99
|
+
|
|
100
|
+
## What each import is
|
|
101
|
+
|
|
102
|
+
### `typesImport`: the operations' types
|
|
103
|
+
|
|
104
|
+
The module exporting each operation's result type and variables type, named
|
|
105
|
+
as `typescript-operations` names them (`UserQuery`, `UserQueryVariables`):
|
|
106
|
+
|
|
107
|
+
- the client preset's `graphql.ts`: `typesImport: './graphql'`, with the
|
|
108
|
+
preset generating into the same folder;
|
|
109
|
+
- or the file `typescript-operations` writes: `typesImport: './operations'`.
|
|
110
|
+
|
|
111
|
+
Both write the wire types: what the response holds as JSON, what the client
|
|
112
|
+
sends. Do not point `typesImport` at codegen-zod's file: its result types are
|
|
113
|
+
`z.output`, decoded (`joined` is a `Date` there), and its variables types
|
|
114
|
+
`z.input`; the handler would compile, but its `data` would not be what the
|
|
115
|
+
wire holds.
|
|
116
|
+
|
|
117
|
+
The plugin imports the types only (`import type`), never a document: msw
|
|
118
|
+
matches an operation by its name, so the mock bundle carries no document.
|
|
119
|
+
|
|
120
|
+
### `schemasImport`: the variables' schemas
|
|
121
|
+
|
|
122
|
+
The file `@nxgt/graphql-codegen-zod` writes. Each handler imports that
|
|
123
|
+
operation's variables schema, `z<Operation>Variables` (`zUserQueryVariables`),
|
|
124
|
+
which carries every `@constraint` of every argument or input field the
|
|
125
|
+
variable reaches, the input types (`z.strictObject`) and your custom scalars.
|
|
126
|
+
That is the schema a client checks before sending: the mock refuses what the
|
|
127
|
+
server refuses.
|
|
128
|
+
|
|
129
|
+
### Naming: one config for the three files
|
|
130
|
+
|
|
131
|
+
The plugin names the types as `typescript-operations` does and the schemas as
|
|
132
|
+
codegen-zod does (`schemaPrefix`, default `z`, before the type's name). Put
|
|
133
|
+
the naming options at the **root** of the codegen config, so every output
|
|
134
|
+
reads them:
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
const config: CodegenConfig = {
|
|
138
|
+
config: { omitOperationSuffix: false, typesPrefix: '' },
|
|
139
|
+
generates: { … },
|
|
140
|
+
};
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
| Option | Effect on the names |
|
|
144
|
+
| --- | --- |
|
|
145
|
+
| `namingConvention` | `'keep'`, `'change-case-all#<case>'`, a function, or `{ typeNames, transformUnderscore }`, as the typescript plugins read it |
|
|
146
|
+
| `typesPrefix`, `typesSuffix` | around each type's name, the schema's included (`zIUserQueryVariables`) |
|
|
147
|
+
| `dedupeOperationSuffix` | `query UserQuery` is `UserQuery`, not `UserQueryQuery` |
|
|
148
|
+
| `omitOperationSuffix` | no `Query` or `Mutation` suffix |
|
|
149
|
+
| `schemaPrefix` | before each schema's name, as codegen-zod's |
|
|
150
|
+
|
|
151
|
+
The handler is `mock` and the operation's converted name with its suffix,
|
|
152
|
+
without `typesPrefix` or `typesSuffix`: `mockUserQuery`, `mockSignUpMutation`.
|
|
153
|
+
Two operations converting to one name (`getUser` and `GetUser`) fail
|
|
154
|
+
generation.
|
|
155
|
+
|
|
156
|
+
### The paths
|
|
157
|
+
|
|
158
|
+
`schemasImport`, `typesImport`, and the modules of `invalidInput` and
|
|
159
|
+
`translate` are written into the generated imports as you give them: a path
|
|
160
|
+
relative to the generated file, or a package. The plugin loads none of them;
|
|
161
|
+
a wrong path or export fails the generated file's typecheck, which names it.
|
|
162
|
+
Under `moduleResolution: nodenext` (or `node16`), give a relative path its
|
|
163
|
+
`.js` extension: `'./zod.js'`.
|
|
164
|
+
|
|
165
|
+
## The endpoint
|
|
166
|
+
|
|
167
|
+
`endpoint` is what msw's `graphql.link(endpoint)` takes: the URL your client
|
|
168
|
+
posts to, `'https://api.example.com/graphql'`, or a path msw matches on any
|
|
169
|
+
origin, `'*/graphql'` (a relative `'/graphql'` resolves against the page in a
|
|
170
|
+
browser). It is exported, so a test can read it:
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
import { endpoint } from './generated/msw';
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
One handler can answer elsewhere: `mockUserQuery(resolver, { endpoint:
|
|
177
|
+
'https://staging.example.com/graphql' })`, any msw path, a `RegExp` included.
|
|
178
|
+
|
|
179
|
+
## The resolver
|
|
180
|
+
|
|
181
|
+
`GraphQLMockResolver<Result, Variables>` is msw's GraphQL resolver, its
|
|
182
|
+
`variables` typed with the operation's variables type and its answer's `data`
|
|
183
|
+
with the result type. It receives msw's resolver info: `variables`, `query`,
|
|
184
|
+
`operationName`, `request`, `cookies`, `requestId`, `finalize`.
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
mockUserQuery(async ({ variables, request }) => {
|
|
188
|
+
if (!request.headers.get('authorization')) return errorResponse({ message: 'Sign in', extensions: { http: { status: 401 } } });
|
|
189
|
+
return HttpResponse.json({ data: { user: { id: String(variables.id), name: 'Ada' } } });
|
|
190
|
+
});
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
It answers as an msw resolver does:
|
|
194
|
+
|
|
195
|
+
- `HttpResponse.json({ data })`: the response;
|
|
196
|
+
- `undefined`: the next handler answers (msw's own fall-through);
|
|
197
|
+
- `errorResponse(error, { status })`: `{ errors: [error] }` at `status`, else
|
|
198
|
+
the error's `extensions.http.status` (the highest, for several errors),
|
|
199
|
+
else 200, a status no response can carry (outside 200–599, or 204, 205,
|
|
200
|
+
304) ignored; the body leaves out `extensions.http` and
|
|
201
|
+
`extensions.unexpected`, as graphql-yoga does;
|
|
202
|
+
- a thrown `GraphQLError` (from any copy of `graphql`): the same as
|
|
203
|
+
`errorResponse(error)`, so the resolver can throw what your server's throws
|
|
204
|
+
(`invalidInputError`, `fieldsError`, `badUserInput`);
|
|
205
|
+
- anything else thrown: msw answers 500, as it does for any resolver.
|
|
206
|
+
|
|
207
|
+
`variables` are **the variables as sent**: checked, not parsed. A `DateTime`
|
|
208
|
+
is still its string, an absent variable with a default is still absent. When
|
|
209
|
+
you need the decoded values, parse them yourself:
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
mockSignUpMutation(({ variables }) => {
|
|
213
|
+
const { input } = zSignUpMutationVariables.parse(variables); // input.birth is a Date
|
|
214
|
+
…
|
|
215
|
+
});
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
A **generator** resolver (`function*`) is refused, by its type and at run
|
|
219
|
+
time: msw keeps the iterator it returns and answers the next requests from it
|
|
220
|
+
without calling the handler again, so their variables would go unchecked.
|
|
221
|
+
Register one handler per answer, each `{ once: true }`, in the order they
|
|
222
|
+
should answer:
|
|
223
|
+
|
|
224
|
+
```ts
|
|
225
|
+
server.use(
|
|
226
|
+
mockUserQuery(() => HttpResponse.json({ data: { user: first } }), { once: true }),
|
|
227
|
+
mockUserQuery(() => HttpResponse.json({ data: { user: second } })),
|
|
228
|
+
);
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
## An invalid input
|
|
232
|
+
|
|
233
|
+
Before the resolver runs, the handler parses the variables with the
|
|
234
|
+
operation's schema (`safeParseAsync`, so an asynchronous format of yours
|
|
235
|
+
works). When they fail, the resolver is not called. The handler answers in
|
|
236
|
+
two steps, always in this order:
|
|
237
|
+
|
|
238
|
+
1. a value the API refuses while coercing the variables is answered as
|
|
239
|
+
graphql-yoga answers it, whatever `invalidInput` is (see
|
|
240
|
+
[What the API refuses before your resolvers](#what-the-api-refuses-before-your-resolvers));
|
|
241
|
+
2. any other invalid input, a constraint, with the generated
|
|
242
|
+
`invalidInput`'s error and status, as `{ errors: [error] }`.
|
|
243
|
+
|
|
244
|
+
### What the API refuses before your resolvers
|
|
245
|
+
|
|
246
|
+
graphql-yoga checks the variables against their GraphQL types before it
|
|
247
|
+
executes anything: a missing value for a non-null type, a `null` for one, a
|
|
248
|
+
value of the wrong type (a number for a `String`, an `Int` beyond 32 bits), an
|
|
249
|
+
unknown enum value, an input field the type does not declare, a value a
|
|
250
|
+
custom scalar refuses. Your server's validation never sees these, so its
|
|
251
|
+
invalid-input answer never names them. The mock answers them as graphql-yoga
|
|
252
|
+
5.24 on graphql 17 does:
|
|
253
|
+
|
|
254
|
+
- status **400**, under `Accept: application/json` and
|
|
255
|
+
`application/graphql-response+json` alike;
|
|
256
|
+
- one plain GraphQL error per problem, `{ message, locations }`, with no
|
|
257
|
+
`extensions`: no `code`, no `fields`;
|
|
258
|
+
- the message in graphql 17's wording, every type named as the schema names
|
|
259
|
+
it and graphql's "Did you mean …?" included, located at the variable's
|
|
260
|
+
definition in the query sent;
|
|
261
|
+
- each value printed as the API prints it: two levels deep and ten items
|
|
262
|
+
(`{ a: { b: [Object] } }`, `[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, ... 2 more items]`) in graphql's
|
|
263
|
+
own messages (a scalar's, an enum's), three levels and every item in the
|
|
264
|
+
executor's (an input object's, the `got invalid value …` prefix);
|
|
265
|
+
- the errors in graphql's order: variables as declared, an input object's
|
|
266
|
+
fields as the schema declares them, its unknown fields last.
|
|
267
|
+
|
|
268
|
+
```json
|
|
269
|
+
{
|
|
270
|
+
"errors": [
|
|
271
|
+
{ "message": "Variable \"$id\" of required type \"ID!\" was not provided.", "locations": [{ "line": 1, "column": 12 }] },
|
|
272
|
+
{ "message": "Variable \"$role\" got invalid value \"ADMN\"; Value \"ADMN\" does not exist in \"Role\" enum. Did you mean the enum value \"ADMIN\"?", "locations": [{ "line": 1, "column": 26 }] }
|
|
273
|
+
]
|
|
274
|
+
}
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
When graphql refuses any variable, the whole request gets this answer and no
|
|
278
|
+
other: the constraint issues beside it are not reported, as the API never
|
|
279
|
+
reaches its validation. It applies whatever `invalidInput` is, a function of
|
|
280
|
+
yours included, which therefore only receives the issues graphql would let
|
|
281
|
+
through.
|
|
282
|
+
|
|
283
|
+
How it is decided: the generated file describes, from the schema, every input
|
|
284
|
+
type the variables reach (`inputTypes`) and each variable's type
|
|
285
|
+
(`variableTypes`). The handler walks the variables sent as graphql walks
|
|
286
|
+
them. A built-in scalar, an enum, an input object, a list and a non-null type
|
|
287
|
+
are judged as graphql judges them, from the declared type and the value,
|
|
288
|
+
never from a Zod issue: an `Int` past 32 bits is refused as the API refuses
|
|
289
|
+
it, an `@constraint(max: 50)` exceeded is not. A custom scalar refuses where
|
|
290
|
+
the operation's Zod schema has an issue at its position.
|
|
291
|
+
|
|
292
|
+
| What is sent | The API's refusal, and the mock's |
|
|
293
|
+
| --- | --- |
|
|
294
|
+
| a non-null variable left out | `Variable "$id" of required type "ID!" was not provided.` |
|
|
295
|
+
| `null` for a non-null variable | `Variable "$id" of non-null type "ID!" must not be null.` |
|
|
296
|
+
| a value of the wrong type | `Variable "$id" got invalid value true; ID cannot represent value: true`, as graphql words `String`, `Int`, `Float`, `Boolean` and `ID` |
|
|
297
|
+
| an `Int` beyond 32 bits | `… got invalid value 3000000000; Int cannot represent non 32-bit signed integer value: 3000000000` |
|
|
298
|
+
| an unknown enum value | `… Value "ADMN" does not exist in "Role" enum. Did you mean the enum value "ADMIN"?`, or `Enum "Role" cannot represent non-string value: 5.` |
|
|
299
|
+
| an input object missing a required field | `… got invalid value { email: "a@b.co" } at "input.person"; Expected value of type "PersonInput" to include required field "firstName", found: { email: "a@b.co" }.` |
|
|
300
|
+
| `null` for a non-null field or list item | `… got invalid value null at "input.person.firstName"; Expected value of non-null type "String!" not to be null.` |
|
|
301
|
+
| an unknown input field | `… Expected value of type "SignUpInput" not to include unknown field "tag". Did you mean "tags"? Found: { … }.` |
|
|
302
|
+
| something else than an object | `… Expected value of type "SignUpInput" to be an object, found: "x".` |
|
|
303
|
+
| a value a custom scalar refuses | `… got invalid value "x" at "input.birth"; DateTime cannot represent this input: Invalid ISO datetime` |
|
|
304
|
+
| a `@oneOf` input object without exactly one non-null field | `… Within OneOf Input Object type "Pick", exactly one field must be specified, and the value for that field must be non-null.` |
|
|
305
|
+
|
|
306
|
+
A list also takes a single value, as graphql coerces it: `"MEMBR"` for
|
|
307
|
+
`[Role!]` is refused at `roles`, an item of `["ADMIN", "MEMBR"]` at
|
|
308
|
+
`roles[1]`.
|
|
309
|
+
|
|
310
|
+
### Known differences
|
|
311
|
+
|
|
312
|
+
Checked against graphql-yoga 5.24.2, `@graphql-tools/executor` 2.0.3 and
|
|
313
|
+
graphql 17.0.2 over 75 requests (missing, null and wrongly typed values at the
|
|
314
|
+
top level and nested, unknown enum values and fields, Int overflow, custom
|
|
315
|
+
scalars, lists and lists of lists, single values for lists, `@oneOf`, values
|
|
316
|
+
printed deep or long: `[Object]`, `[Array]`, `... 2 more items`): 74 answers
|
|
317
|
+
are identical, status and body. Where the mock differs:
|
|
318
|
+
|
|
319
|
+
- **An `ID` given an integer**, the one difference of the 75. graphql takes
|
|
320
|
+
`5` for an `ID` (as `"5"`); codegen-zod's schema for `ID` is a string, so
|
|
321
|
+
the mock refuses it with `invalidInput`'s answer
|
|
322
|
+
(`validation.errors.invalid` by default).
|
|
323
|
+
- **A custom scalar of another make.** The mock words a custom scalar's
|
|
324
|
+
refusal as a scalar built with `zodScalar` (`@nxgt/graphql-scalars`) does,
|
|
325
|
+
`<Name> cannot represent this input: <the schema's first issue>`, which
|
|
326
|
+
holds when the client's schema is the scalar's own. A scalar of another make
|
|
327
|
+
words its refusal its own way (graphql's `Expected value of type "X", found:
|
|
328
|
+
…` when it returns nothing, `…, but encountered error "…"; found: …` when it
|
|
329
|
+
throws a plain `Error`).
|
|
330
|
+
- **An application format's code.** A format of the application's own
|
|
331
|
+
(`withValidation`'s `formats`, a Zod string schema) is answered with the
|
|
332
|
+
code its Zod issue gives: `validation.errors.invalid-format` only for a
|
|
333
|
+
Zod format or a pattern, `validation.errors.invalid` for a refinement, a
|
|
334
|
+
prefix, a suffix or a substring. The API may answer `invalid-format` for
|
|
335
|
+
every format of its own.
|
|
336
|
+
- **An API on graphql 16.** graphql 16 words some of these messages
|
|
337
|
+
differently; the mock always words them as graphql 17 does.
|
|
338
|
+
|
|
339
|
+
### `'invalid-input'`, the default
|
|
340
|
+
|
|
341
|
+
For an API that answers invalid input as one 400 error with a list of fields:
|
|
342
|
+
`invalidInputError` from `@nxgt/graphql-validation`, one field per Zod issue,
|
|
343
|
+
in the order Zod reports them.
|
|
344
|
+
|
|
345
|
+
| Field | From the Zod issue |
|
|
346
|
+
| --- | --- |
|
|
347
|
+
| `path` | `inputPath(issue.path)`: dotted, the leading `input` dropped (`person.firstName`, `tags.1`); another variable keeps its name (`first`) |
|
|
348
|
+
| `code` | the constraint, as in the table below |
|
|
349
|
+
| `message` | `translate(code)`, or the code without `translate` |
|
|
350
|
+
|
|
351
|
+
| Zod issue | `code` |
|
|
352
|
+
| --- | --- |
|
|
353
|
+
| `too_small` / `too_big` on a string | `validation.errors.too-short` / `validation.errors.too-long` |
|
|
354
|
+
| `too_small` / `too_big` on a number or a bigint, `not_multiple_of` | `validation.errors.out-of-range` |
|
|
355
|
+
| `too_small` / `too_big` on a list or a set | `validation.errors.too-few` / `validation.errors.too-many` |
|
|
356
|
+
| `invalid_format` (a format such as `email`, or a `pattern`) | `validation.errors.invalid-format` |
|
|
357
|
+
| `invalid_format` for `startsWith`, `endsWith` or `includes` | `validation.errors.invalid` |
|
|
358
|
+
| any other constraint (a date's bound, a refinement, …) | `validation.errors.invalid` |
|
|
359
|
+
|
|
360
|
+
A field has no `params`: the bound or the format is not written. These codes
|
|
361
|
+
are the ones the API the mocks stand in for answers; a new param would be an
|
|
362
|
+
addition, a changed code a breaking change. The mapping is
|
|
363
|
+
`@nxgt/graphql-validation`'s `fieldIssueOf`, so a server that answers its own
|
|
364
|
+
Zod parses with it answers the same fields.
|
|
365
|
+
|
|
366
|
+
Each field's path starts at the variable's name, since the handler parses
|
|
367
|
+
the variables: `inputPath` drops a leading `input` only. It matches the
|
|
368
|
+
server's `inputPath(['input', …])` when the variable is named `input`, after
|
|
369
|
+
the argument (`mutation SignUp($input: SignUpInput!) { signUp(input:
|
|
370
|
+
$input) }`); a variable named otherwise (`$person`) keeps its name
|
|
371
|
+
(`person.firstName`), where the server's path would not.
|
|
372
|
+
|
|
373
|
+
```json
|
|
374
|
+
{
|
|
375
|
+
"errors": [{
|
|
376
|
+
"message": "validation.errors.invalid-input",
|
|
377
|
+
"extensions": {
|
|
378
|
+
"code": "validation.errors.invalid-input",
|
|
379
|
+
"fields": [
|
|
380
|
+
{ "path": "person.firstName", "code": "validation.errors.too-short", "message": "validation.errors.too-short" },
|
|
381
|
+
{ "path": "person.email", "code": "validation.errors.invalid-format", "message": "validation.errors.invalid-format" },
|
|
382
|
+
{ "path": "tags", "code": "validation.errors.too-many", "message": "validation.errors.too-many" }
|
|
383
|
+
]
|
|
384
|
+
}
|
|
385
|
+
}]
|
|
386
|
+
}
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
The status is 400. `fieldIssueOf(issue)` is the mapping above, re-exported
|
|
390
|
+
from `./runtime` for a function of your own that keeps it.
|
|
391
|
+
|
|
392
|
+
#### `translate`
|
|
393
|
+
|
|
394
|
+
```ts
|
|
395
|
+
config: { …, translate: '../i18n#translate' }
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
```ts
|
|
399
|
+
// src/i18n.ts
|
|
400
|
+
import type { Translate } from '@nxgt/graphql-codegen-msw/runtime';
|
|
401
|
+
|
|
402
|
+
export const translate: Translate = (code) => messages[code] ?? code;
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
The generated file writes `invalidInputRefusal({ translate })`: the error's
|
|
406
|
+
message is `translate('validation.errors.invalid-input')`, each field's
|
|
407
|
+
`translate(code)`. `translate` goes with the default answer only; with
|
|
408
|
+
another `invalidInput`, generation fails.
|
|
409
|
+
|
|
410
|
+
### `'bad-user-input'`
|
|
411
|
+
|
|
412
|
+
The error `withValidation` raises on a server: `extensions.code`
|
|
413
|
+
`BAD_USER_INPUT`, `extensions.issues` each with its `path`, Zod's `message`
|
|
414
|
+
and `code`, the message `Invalid arguments for <operation>. <path>: <first
|
|
415
|
+
message>`. Status 200, as a GraphQL server answers a resolver's error.
|
|
416
|
+
|
|
417
|
+
```json
|
|
418
|
+
{
|
|
419
|
+
"errors": [{
|
|
420
|
+
"message": "Invalid arguments for SignUp. input.person.firstName: Too small: expected string to have >=2 characters",
|
|
421
|
+
"extensions": {
|
|
422
|
+
"code": "BAD_USER_INPUT",
|
|
423
|
+
"issues": [{ "path": ["input", "person", "firstName"], "message": "Too small: expected string to have >=2 characters", "code": "too_small" }]
|
|
424
|
+
}
|
|
425
|
+
}]
|
|
426
|
+
}
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
Three differences with the server's: `<operation>` is the operation's name
|
|
430
|
+
(`SignUp`), where the server names the field (`Mutation.signUp`); each path
|
|
431
|
+
starts with the variable's name, where the server's starts with the
|
|
432
|
+
argument's (the first two are the same when the variable is named after the
|
|
433
|
+
argument); and the mock answers `{ errors }` alone, with no `data`, and no
|
|
434
|
+
`path` or `locations` on the error, which the server's execution adds. There
|
|
435
|
+
is no `constraint` on an issue, as on a client.
|
|
436
|
+
|
|
437
|
+
### `'<module>#<export>'`: your own
|
|
438
|
+
|
|
439
|
+
A function typed `InvalidInput`:
|
|
440
|
+
|
|
441
|
+
```ts
|
|
442
|
+
type InvalidInput = (
|
|
443
|
+
issues: readonly z.core.$ZodIssue[],
|
|
444
|
+
operation: { readonly type: 'query' | 'mutation'; readonly name: string },
|
|
445
|
+
) => InvalidInputAnswer | Promise<InvalidInputAnswer>;
|
|
446
|
+
|
|
447
|
+
interface InvalidInputAnswer {
|
|
448
|
+
readonly error: GraphQLError | GraphQLFormattedError;
|
|
449
|
+
readonly status: number;
|
|
450
|
+
}
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
`issues` are Zod's, each `path` starting with the variable's name, and only
|
|
454
|
+
the issues graphql lets through: a value the API refuses while coercing the
|
|
455
|
+
variables is answered before your function is called. The handler answers
|
|
456
|
+
`{ errors: [error] }` at `status`, as `errorResponse` does: a `GraphQLError`
|
|
457
|
+
is written through its `toJSON()`, `extensions.http` left out, and a status no
|
|
458
|
+
response can carry (outside 200–599, or 204, 205, 304) is ignored for the
|
|
459
|
+
error's own `extensions.http.status`, else 200.
|
|
460
|
+
|
|
461
|
+
```ts
|
|
462
|
+
// src/mocks/invalid-input.ts
|
|
463
|
+
import {
|
|
464
|
+
fieldIssueOf,
|
|
465
|
+
type InvalidInput,
|
|
466
|
+
invalidInputError,
|
|
467
|
+
} from '@nxgt/graphql-codegen-msw/runtime';
|
|
468
|
+
import { translate } from '../i18n';
|
|
469
|
+
|
|
470
|
+
/** The default shape, with the bound as a param on a string's length. */
|
|
471
|
+
export const invalidInput: InvalidInput = (issues) => ({
|
|
472
|
+
error: invalidInputError(
|
|
473
|
+
issues.map((issue) => {
|
|
474
|
+
const field = fieldIssueOf(issue);
|
|
475
|
+
return issue.code === 'too_small' && issue.origin === 'string'
|
|
476
|
+
? { ...field, params: { min: Number(issue.minimum) } }
|
|
477
|
+
: field;
|
|
478
|
+
}),
|
|
479
|
+
{ translate },
|
|
480
|
+
),
|
|
481
|
+
status: 400,
|
|
482
|
+
});
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
```ts
|
|
486
|
+
config: { …, invalidInput: '../mocks/invalid-input#invalidInput' }
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
The generated file writes `export const invalidInput: InvalidInput =
|
|
490
|
+
<your export>;`, so a function that does not fit the type fails the generated
|
|
491
|
+
file's typecheck.
|
|
492
|
+
|
|
493
|
+
## In tests
|
|
494
|
+
|
|
495
|
+
`setupServer` from `msw/node`, under Node:
|
|
496
|
+
|
|
497
|
+
```ts
|
|
498
|
+
import { HttpResponse } from 'msw';
|
|
499
|
+
import { setupServer } from 'msw/node';
|
|
500
|
+
import { mockUserQuery } from './generated/msw';
|
|
501
|
+
|
|
502
|
+
const server = setupServer();
|
|
503
|
+
beforeAll(() => server.listen({ onUnhandledFrame: 'error' }));
|
|
504
|
+
afterEach(() => server.resetHandlers());
|
|
505
|
+
afterAll(() => server.close());
|
|
506
|
+
|
|
507
|
+
test('a missing user', async () => {
|
|
508
|
+
server.use(mockUserQuery(() => HttpResponse.json({ data: { user: null } })));
|
|
509
|
+
// render, fetch, assert
|
|
510
|
+
});
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
`server.use(a, b)` puts `a` and `b` before the handlers already there, `a`
|
|
514
|
+
first.
|
|
515
|
+
|
|
516
|
+
### Under Bun
|
|
517
|
+
|
|
518
|
+
msw/node's `setupServer` intercepts no request under Bun (measured with msw
|
|
519
|
+
3.0.3 on Bun 1.4.2): its `fetch` interceptor works through Node's sockets,
|
|
520
|
+
which Bun's `fetch` does not use, so the request goes to the network. Two ways
|
|
521
|
+
around it:
|
|
522
|
+
|
|
523
|
+
- run the tests that need `setupServer` under Node;
|
|
524
|
+
- or build msw's network yourself from msw's experimental API and the
|
|
525
|
+
interceptors' web `fetch` interceptor, which patches the global `fetch`
|
|
526
|
+
only. This package's own specs do it (add `@mswjs/interceptors` at msw's
|
|
527
|
+
version as a dev dependency):
|
|
528
|
+
|
|
529
|
+
```ts
|
|
530
|
+
import { FetchInterceptor } from '@mswjs/interceptors/fetch/web';
|
|
531
|
+
import { defineNetwork, InterceptorSource } from 'msw/experimental';
|
|
532
|
+
|
|
533
|
+
const network = defineNetwork({
|
|
534
|
+
// The cast: the interceptors package declares its base class once per build.
|
|
535
|
+
sources: [new InterceptorSource({ interceptors: [new FetchInterceptor() as never] })],
|
|
536
|
+
handlers: [],
|
|
537
|
+
onUnhandledFrame: 'error',
|
|
538
|
+
context: { quiet: true },
|
|
539
|
+
});
|
|
540
|
+
beforeAll(() => network.enable());
|
|
541
|
+
afterEach(() => network.resetHandlers());
|
|
542
|
+
afterAll(() => network.disable());
|
|
543
|
+
|
|
544
|
+
test('a missing user', async () => {
|
|
545
|
+
network.use(mockUserQuery(() => HttpResponse.json({ data: { user: null } })));
|
|
546
|
+
});
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
`msw/experimental` may change in a minor msw release.
|
|
550
|
+
|
|
551
|
+
## What gets no handler
|
|
552
|
+
|
|
553
|
+
- **An anonymous operation**: msw matches by name, and has none to match.
|
|
554
|
+
- **A subscription**: msw 3 mocks subscriptions over WebSocket
|
|
555
|
+
(`graphql-transport-ws`) only, through another resolver shape. See the
|
|
556
|
+
[roadmap](../roadmap.md).
|
|
557
|
+
- **A fragment**: it is not an operation.
|
|
558
|
+
|
|
559
|
+
Persisted queries (a request with a hash and no query text) and batched
|
|
560
|
+
requests (an array of operations in one POST) are not matched either.
|
|
561
|
+
|
|
562
|
+
## The runtime
|
|
563
|
+
|
|
564
|
+
`@nxgt/graphql-codegen-msw/runtime` is what the generated file calls. It
|
|
565
|
+
imports `msw`, `msw/graphql`, `graphql` (to locate the coercion answer's
|
|
566
|
+
errors in the query sent), `zod` and `@nxgt/graphql-validation` at run time, never
|
|
567
|
+
the plugin.
|
|
568
|
+
|
|
569
|
+
```ts
|
|
570
|
+
declare function mockOperation<Result extends GraphQLQuery, Variables extends GraphQLVariables>(
|
|
571
|
+
operation: MockedOperation,
|
|
572
|
+
resolver: GraphQLMockResolver<Result, Variables>,
|
|
573
|
+
options?: MockOptions,
|
|
574
|
+
): GraphQLHandler;
|
|
575
|
+
|
|
576
|
+
interface MockedOperation {
|
|
577
|
+
readonly type: 'query' | 'mutation';
|
|
578
|
+
readonly name: string;
|
|
579
|
+
readonly variables: z.ZodType;
|
|
580
|
+
readonly variableTypes?: Readonly<Record<string, string>>; // internal: { first: "Int = 10" }; default {}
|
|
581
|
+
readonly inputTypes?: InputTypes; // internal: the input types the variables reach; default {}
|
|
582
|
+
readonly endpoint: Path;
|
|
583
|
+
readonly invalidInput: InvalidInput;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
interface MockOptions extends RequestHandlerOptions { // msw's: `once`
|
|
587
|
+
readonly endpoint?: Path;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
declare function invalidInputRefusal(options?: { readonly translate?: Translate }): InvalidInput;
|
|
591
|
+
declare const badUserInputRefusal: InvalidInput;
|
|
592
|
+
declare function errorResponse(
|
|
593
|
+
error: AnyGraphQLError | readonly AnyGraphQLError[],
|
|
594
|
+
init?: { readonly status?: number },
|
|
595
|
+
): HttpResponse<{ errors: GraphQLFormattedError[] }>;
|
|
596
|
+
declare function isGraphQLError(value: unknown): value is GraphQLError;
|
|
597
|
+
|
|
598
|
+
// Re-exported from @nxgt/graphql-validation, as they are there:
|
|
599
|
+
// invalidInputError, fieldsError, fieldIssueOf, inputPath,
|
|
600
|
+
// and the types Translate, FieldIssue, FieldError.
|
|
601
|
+
declare function fieldIssueOf(issue: z.core.$ZodIssue): FieldIssue;
|
|
602
|
+
declare function inputPath(path: readonly PropertyKey[]): string;
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
Types: `GraphQLMockResolver`, `GraphQLMockInfo`, `MockedOperation`,
|
|
606
|
+
`MockOptions`, `InvalidInput`, `InvalidInputAnswer`, `RefusedOperation`,
|
|
607
|
+
`AnyGraphQLError`, `Translate`, `FieldIssue`, `FieldError`, and `InputTypes` / `InputType`, internal: exported for the
|
|
608
|
+
generated file's annotation, their shape may change in any version.
|
|
609
|
+
|
|
610
|
+
### A handler written by hand
|
|
611
|
+
|
|
612
|
+
`mockOperation` is the generated handlers' only call: you can write a handler
|
|
613
|
+
the plugin does not, for an operation outside your documents, with it.
|
|
614
|
+
`variableTypes` and `inputTypes` are optional and default to `{}`: without
|
|
615
|
+
them no variable is walked, so every refusal, a value graphql would refuse
|
|
616
|
+
while coercing it included, goes to `invalidInput`.
|
|
617
|
+
|
|
618
|
+
```ts
|
|
619
|
+
import { HttpResponse } from 'msw';
|
|
620
|
+
import { invalidInputRefusal, mockOperation } from '@nxgt/graphql-codegen-msw/runtime';
|
|
621
|
+
import { z } from 'zod';
|
|
622
|
+
|
|
623
|
+
export const mockPing = mockOperation<{ ping: string }, { times?: number | null }>(
|
|
624
|
+
{
|
|
625
|
+
type: 'query',
|
|
626
|
+
name: 'Ping',
|
|
627
|
+
variables: z.object({ times: z.int().nullish() }),
|
|
628
|
+
endpoint: '*/graphql',
|
|
629
|
+
invalidInput: invalidInputRefusal(),
|
|
630
|
+
},
|
|
631
|
+
() => HttpResponse.json({ data: { ping: 'pong' } }),
|
|
632
|
+
);
|
|
633
|
+
```
|
|
634
|
+
|
|
635
|
+
To have the coercion answer as well, copy both from a generated file and
|
|
636
|
+
regenerate that file after each upgrade: their shape is internal, and a
|
|
637
|
+
`kind` the installed runtime does not know throws
|
|
638
|
+
[`the generated file was written by another version than the runtime; regenerate it`](../troubleshooting.md#at-run-time).
|
|
639
|
+
|
|
640
|
+
## The plugin function
|
|
641
|
+
|
|
642
|
+
```ts
|
|
643
|
+
import { type CodegenMswConfig, plugin } from '@nxgt/graphql-codegen-msw';
|
|
644
|
+
|
|
645
|
+
declare function plugin(
|
|
646
|
+
schema: GraphQLSchema,
|
|
647
|
+
documents: readonly DocumentFile[],
|
|
648
|
+
config?: CodegenMswConfig,
|
|
649
|
+
): string;
|
|
650
|
+
```
|
|
651
|
+
|
|
652
|
+
graphql-codegen finds `plugin` by the package name. It throws, before writing
|
|
653
|
+
anything, on a missing option or one it cannot read; each message is in
|
|
654
|
+
[Troubleshooting](../troubleshooting.md).
|
package/docs/roadmap.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Roadmap
|
|
2
|
+
|
|
3
|
+
What `@nxgt/graphql-codegen-msw` is heading for, phrased as what you get.
|
|
4
|
+
|
|
5
|
+
## Now
|
|
6
|
+
|
|
7
|
+
Nothing in progress.
|
|
8
|
+
|
|
9
|
+
## Next
|
|
10
|
+
|
|
11
|
+
Candidates, not commitments.
|
|
12
|
+
|
|
13
|
+
- **A `setupServer` that works under Bun**, once msw's own does, or a helper
|
|
14
|
+
for the web `fetch` interceptor network the specs build today.
|
|
15
|
+
|
|
16
|
+
## Later
|
|
17
|
+
|
|
18
|
+
- **Subscriptions.** msw 3 mocks them over WebSocket (`graphql-transport-ws`)
|
|
19
|
+
only, with a resolver that publishes rather than answers, and an invalid
|
|
20
|
+
input has no HTTP status there. `@nxgt/graphql-client` subscribes over SSE,
|
|
21
|
+
which msw's GraphQL subscription handler does not intercept.
|
|
22
|
+
- **Persisted queries**: matching a request that carries a hash and no query
|
|
23
|
+
text (`documentId`, APQ).
|
|
24
|
+
- **Batched requests**: answering an array of operations posted at once.
|
|
25
|
+
|
|
26
|
+
## Not planned
|
|
27
|
+
|
|
28
|
+
- **Generator resolvers.** msw would answer from the iterator without calling
|
|
29
|
+
the handler, so the variables of every request after the first would go
|
|
30
|
+
unchecked. One handler per answer with `{ once: true }` does the same.
|
|
31
|
+
|
|
32
|
+
## Shipped
|
|
33
|
+
|
|
34
|
+
- **The first release** — 0.1.0. One typed msw 3 handler per named query
|
|
35
|
+
and mutation, its variables checked with `@nxgt/graphql-codegen-zod`'s
|
|
36
|
+
schemas, a value the API cannot coerce answered as graphql-yoga answers it
|
|
37
|
+
(every type named from the schema, graphql's suggestions included), and a
|
|
38
|
+
constraint answered as one 400 error with a list of fields (the mapping of
|
|
39
|
+
`@nxgt/graphql-validation`'s `fieldIssueOf`), as `BAD_USER_INPUT`, or by a
|
|
40
|
+
function of yours.
|