@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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +293 -2
  3. package/THIRD-PARTY-NOTICES.md +71 -0
  4. package/dist/config.d.ts +66 -0
  5. package/dist/config.d.ts.map +1 -0
  6. package/dist/imports.d.ts +17 -0
  7. package/dist/imports.d.ts.map +1 -0
  8. package/dist/index.d.ts +13 -0
  9. package/dist/index.d.ts.map +1 -0
  10. package/dist/index.js +365 -0
  11. package/dist/index.js.map +15 -0
  12. package/dist/input-types.d.ts +11 -0
  13. package/dist/input-types.d.ts.map +1 -0
  14. package/dist/naming.d.ts +23 -0
  15. package/dist/naming.d.ts.map +1 -0
  16. package/dist/operations.d.ts +17 -0
  17. package/dist/operations.d.ts.map +1 -0
  18. package/dist/options.d.ts +31 -0
  19. package/dist/options.d.ts.map +1 -0
  20. package/dist/runtime/coercion/cross-inspect.d.ts +19 -0
  21. package/dist/runtime/coercion/cross-inspect.d.ts.map +1 -0
  22. package/dist/runtime/coercion/document.d.ts +51 -0
  23. package/dist/runtime/coercion/document.d.ts.map +1 -0
  24. package/dist/runtime/coercion/index.d.ts +30 -0
  25. package/dist/runtime/coercion/index.d.ts.map +1 -0
  26. package/dist/runtime/coercion/inspect.d.ts +9 -0
  27. package/dist/runtime/coercion/inspect.d.ts.map +1 -0
  28. package/dist/runtime/coercion/issues.d.ts +19 -0
  29. package/dist/runtime/coercion/issues.d.ts.map +1 -0
  30. package/dist/runtime/coercion/messages.d.ts +37 -0
  31. package/dist/runtime/coercion/messages.d.ts.map +1 -0
  32. package/dist/runtime/coercion/suggestions.d.ts +15 -0
  33. package/dist/runtime/coercion/suggestions.d.ts.map +1 -0
  34. package/dist/runtime/coercion/validate.d.ts +39 -0
  35. package/dist/runtime/coercion/validate.d.ts.map +1 -0
  36. package/dist/runtime/index.d.ts +6 -0
  37. package/dist/runtime/index.d.ts.map +1 -0
  38. package/dist/runtime/index.js +595 -0
  39. package/dist/runtime/index.js.map +21 -0
  40. package/dist/runtime/mock-operation.d.ts +65 -0
  41. package/dist/runtime/mock-operation.d.ts.map +1 -0
  42. package/dist/runtime/refusals.d.ts +36 -0
  43. package/dist/runtime/refusals.d.ts.map +1 -0
  44. package/dist/runtime/response.d.ts +34 -0
  45. package/dist/runtime/response.d.ts.map +1 -0
  46. package/docs/README.md +14 -0
  47. package/docs/guide/handlers.md +654 -0
  48. package/docs/roadmap.md +40 -0
  49. package/docs/troubleshooting.md +304 -0
  50. 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).
@@ -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.