@nxgt/graphql-validation 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 +209 -2
- package/dist/bad-user-input.d.ts +35 -0
- package/dist/bad-user-input.d.ts.map +1 -0
- package/dist/builder/args-schema.d.ts +17 -0
- package/dist/builder/args-schema.d.ts.map +1 -0
- package/dist/builder/constraints.d.ts +16 -0
- package/dist/builder/constraints.d.ts.map +1 -0
- package/dist/builder/input-schema.d.ts +31 -0
- package/dist/builder/input-schema.d.ts.map +1 -0
- package/dist/builder/leaf.d.ts +14 -0
- package/dist/builder/leaf.d.ts.map +1 -0
- package/dist/chunks/index-n3hypgpf.js +315 -0
- package/dist/chunks/index-n3hypgpf.js.map +37 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +69 -0
- package/dist/cli.js.map +11 -0
- package/dist/constraint-directive.d.ts +21 -0
- package/dist/constraint-directive.d.ts.map +1 -0
- package/dist/formats/all.d.ts +9 -0
- package/dist/formats/all.d.ts.map +1 -0
- package/dist/formats/byte.d.ts +2 -0
- package/dist/formats/byte.d.ts.map +1 -0
- package/dist/formats/date-time.d.ts +2 -0
- package/dist/formats/date-time.d.ts.map +1 -0
- package/dist/formats/date.d.ts +2 -0
- package/dist/formats/date.d.ts.map +1 -0
- package/dist/formats/email.d.ts +2 -0
- package/dist/formats/email.d.ts.map +1 -0
- package/dist/formats/format.d.ts +15 -0
- package/dist/formats/format.d.ts.map +1 -0
- package/dist/formats/index.d.ts +13 -0
- package/dist/formats/index.d.ts.map +1 -0
- package/dist/formats/ipv4.d.ts +2 -0
- package/dist/formats/ipv4.d.ts.map +1 -0
- package/dist/formats/ipv6.d.ts +2 -0
- package/dist/formats/ipv6.d.ts.map +1 -0
- package/dist/formats/uri.d.ts +2 -0
- package/dist/formats/uri.d.ts.map +1 -0
- package/dist/formats/uuid.d.ts +2 -0
- package/dist/formats/uuid.d.ts.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +315 -0
- package/dist/index.js.map +16 -0
- package/dist/rules/all.d.ts +16 -0
- package/dist/rules/all.d.ts.map +1 -0
- package/dist/rules/contains.d.ts +2 -0
- package/dist/rules/contains.d.ts.map +1 -0
- package/dist/rules/ends-with.d.ts +2 -0
- package/dist/rules/ends-with.d.ts.map +1 -0
- package/dist/rules/exclusive-max.d.ts +2 -0
- package/dist/rules/exclusive-max.d.ts.map +1 -0
- package/dist/rules/exclusive-min.d.ts +2 -0
- package/dist/rules/exclusive-min.d.ts.map +1 -0
- package/dist/rules/format.d.ts +2 -0
- package/dist/rules/format.d.ts.map +1 -0
- package/dist/rules/index.d.ts +26 -0
- package/dist/rules/index.d.ts.map +1 -0
- package/dist/rules/max-items.d.ts +2 -0
- package/dist/rules/max-items.d.ts.map +1 -0
- package/dist/rules/max-length.d.ts +2 -0
- package/dist/rules/max-length.d.ts.map +1 -0
- package/dist/rules/max.d.ts +2 -0
- package/dist/rules/max.d.ts.map +1 -0
- package/dist/rules/min-items.d.ts +2 -0
- package/dist/rules/min-items.d.ts.map +1 -0
- package/dist/rules/min-length.d.ts +2 -0
- package/dist/rules/min-length.d.ts.map +1 -0
- package/dist/rules/min.d.ts +2 -0
- package/dist/rules/min.d.ts.map +1 -0
- package/dist/rules/multiple-of.d.ts +2 -0
- package/dist/rules/multiple-of.d.ts.map +1 -0
- package/dist/rules/not-contains.d.ts +2 -0
- package/dist/rules/not-contains.d.ts.map +1 -0
- package/dist/rules/pattern.d.ts +2 -0
- package/dist/rules/pattern.d.ts.map +1 -0
- package/dist/rules/rule.d.ts +55 -0
- package/dist/rules/rule.d.ts.map +1 -0
- package/dist/rules/starts-with.d.ts +2 -0
- package/dist/rules/starts-with.d.ts.map +1 -0
- package/dist/typedefs-command.d.ts +10 -0
- package/dist/typedefs-command.d.ts.map +1 -0
- package/dist/validated.d.ts +22 -0
- package/dist/validated.d.ts.map +1 -0
- package/dist/with-validation.d.ts +26 -0
- package/dist/with-validation.d.ts.map +1 -0
- package/docs/README.md +15 -0
- package/docs/guide/constraints.md +291 -0
- package/docs/guide/errors.md +194 -0
- package/docs/roadmap.md +30 -0
- package/docs/troubleshooting.md +260 -0
- package/graphql/constraint.graphqls +20 -0
- package/package.json +67 -5
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"starts-with.d.ts","sourceRoot":"","sources":["../../src/rules/starts-with.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,cAAc,yDAOzB,CAAC"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Where `--out` writes when it is given no file. */
|
|
2
|
+
export declare const DEFAULT_OUT = "generated/graphql/constraint.graphqls";
|
|
3
|
+
/** The `.graphqls` file's content: a header, then the SDL. */
|
|
4
|
+
export declare function graphqls(): string;
|
|
5
|
+
/**
|
|
6
|
+
* Runs the bin on `argv`: 0 done, 1 the file could not be written, 2 a usage
|
|
7
|
+
* error (nxgt-mongo-backup's convention). `--help` anywhere wins.
|
|
8
|
+
*/
|
|
9
|
+
export declare function main(argv: readonly string[]): Promise<number>;
|
|
10
|
+
//# sourceMappingURL=typedefs-command.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"typedefs-command.d.ts","sourceRoot":"","sources":["../src/typedefs-command.ts"],"names":[],"mappings":"AAOA,qDAAqD;AACrD,eAAO,MAAM,WAAW,0CAA0C,CAAC;AAUnE,8DAA8D;AAC9D,wBAAgB,QAAQ,IAAI,MAAM,CAEjC;AAOD;;;GAGG;AACH,wBAAsB,IAAI,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CA+BnE"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { GraphQLFieldResolver, GraphQLResolveInfo } from 'graphql';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
/** An object schema of the arguments, or its shape. */
|
|
4
|
+
export type ArgsSchema = z.ZodType<Record<string, unknown>> | z.ZodRawShape;
|
|
5
|
+
/** The schema `ArgsSchema` stands for. */
|
|
6
|
+
export type SchemaOf<S extends ArgsSchema> = S extends z.ZodType ? S : z.ZodObject<S & z.ZodRawShape>;
|
|
7
|
+
/**
|
|
8
|
+
* A resolver whose arguments are checked by `schema` before it runs, for what
|
|
9
|
+
* a `@constraint` cannot say: a rule across arguments, a business refinement,
|
|
10
|
+
* an async check. It receives the parsed arguments (`z.output`), defaults and
|
|
11
|
+
* transforms applied; an invalid input is the same `BAD_USER_INPUT` error as
|
|
12
|
+
* the directives'.
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* signUp: validated(
|
|
16
|
+
* z.object({ input: signUpSchema }).refine(({ input }) => input.password !== input.email),
|
|
17
|
+
* (_, { input }) => users.create(input),
|
|
18
|
+
* )
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export declare function validated<S extends ArgsSchema, TSource = unknown, TContext = unknown, TResult = unknown>(schema: S, resolve: (source: TSource, args: z.output<SchemaOf<S>>, context: TContext, info: GraphQLResolveInfo) => TResult): GraphQLFieldResolver<TSource, TContext, z.input<SchemaOf<S>>, Promise<Awaited<TResult>>>;
|
|
22
|
+
//# sourceMappingURL=validated.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"validated.d.ts","sourceRoot":"","sources":["../src/validated.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,oBAAoB,EAAE,kBAAkB,EAAE,MAAM,SAAS,CAAC;AACxE,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB,uDAAuD;AACvD,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,GAAG,CAAC,CAAC,WAAW,CAAC;AAE5E,0CAA0C;AAC1C,MAAM,MAAM,QAAQ,CAAC,CAAC,SAAS,UAAU,IAAI,CAAC,SAAS,CAAC,CAAC,OAAO,GAC7D,CAAC,GACD,CAAC,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,CAAC,WAAW,CAAC,CAAC;AAElC;;;;;;;;;;;;;GAaG;AACH,wBAAgB,SAAS,CACxB,CAAC,SAAS,UAAU,EACpB,OAAO,GAAG,OAAO,EACjB,QAAQ,GAAG,OAAO,EAClB,OAAO,GAAG,OAAO,EAEjB,MAAM,EAAE,CAAC,EACT,OAAO,EAAE,CACR,MAAM,EAAE,OAAO,EACf,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,EAC3B,OAAO,EAAE,QAAQ,EACjB,IAAI,EAAE,kBAAkB,KACpB,OAAO,GACV,oBAAoB,CACtB,OAAO,EACP,QAAQ,EACR,CAAC,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,EACpB,OAAO,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,CACzB,CAYA"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type GraphQLSchema } from 'graphql';
|
|
2
|
+
/**
|
|
3
|
+
* Checks every `@constraint` of `schema` before the resolvers run. Each
|
|
4
|
+
* field with a constrained argument — directly, or anywhere inside an input
|
|
5
|
+
* type it takes — gets its resolver (and its `subscribe`) wrapped: the
|
|
6
|
+
* arguments are parsed by the Zod schema the directives describe, the
|
|
7
|
+
* resolver receives the parsed value, and an invalid input becomes one
|
|
8
|
+
* `BAD_USER_INPUT` error. Fields with nothing to check are left untouched.
|
|
9
|
+
*
|
|
10
|
+
* The schema is built here, once: a constraint that cannot apply (a
|
|
11
|
+
* `minLength` on an `Int`, an unknown format, a bad pattern) throws now, not
|
|
12
|
+
* on the first request. The resolvers are wrapped in place and the same
|
|
13
|
+
* schema is returned, so it goes wherever it went before; calling it twice
|
|
14
|
+
* wraps nothing twice. Call it last, once every resolver is attached: a
|
|
15
|
+
* resolver set on a field afterwards replaces the check.
|
|
16
|
+
*
|
|
17
|
+
* A `@constraint` on an interface's argument must be repeated on each
|
|
18
|
+
* object's field, which is the one that resolves; a field that drops it
|
|
19
|
+
* fails here.
|
|
20
|
+
*
|
|
21
|
+
* The directives are read from the SDL (`astNode`), so a schema built from
|
|
22
|
+
* type definitions — `buildSchema`, `makeExecutableSchema`, Yoga, Apollo —
|
|
23
|
+
* is checked; a code-first schema with no SDL has no `@constraint` to read.
|
|
24
|
+
*/
|
|
25
|
+
export declare function withValidation<S extends GraphQLSchema>(schema: S): S;
|
|
26
|
+
//# sourceMappingURL=with-validation.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"with-validation.d.ts","sourceRoot":"","sources":["../src/with-validation.ts"],"names":[],"mappings":"AAAA,OAAO,EAIN,KAAK,aAAa,EAGlB,MAAM,SAAS,CAAC;AA2EjB;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,cAAc,CAAC,CAAC,SAAS,aAAa,EAAE,MAAM,EAAE,CAAC,GAAG,CAAC,CAgCpE"}
|
package/docs/README.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# @nxgt/graphql-validation documentation
|
|
2
|
+
|
|
3
|
+
## Guides
|
|
4
|
+
|
|
5
|
+
| Page | Read it when |
|
|
6
|
+
| --- | --- |
|
|
7
|
+
| [Constraints](guide/constraints.md) | you write `@constraint` in your schema: setup with `withValidation`, every argument and its Zod equivalent, the formats, lists and nested inputs |
|
|
8
|
+
| [Errors](guide/errors.md) | you handle the `BAD_USER_INPUT` error in a client, build it yourself with `badUserInput`, or add rules a directive cannot say with `validated` |
|
|
9
|
+
|
|
10
|
+
## Reference
|
|
11
|
+
|
|
12
|
+
| Page | Read it when |
|
|
13
|
+
| --- | --- |
|
|
14
|
+
| [Troubleshooting](troubleshooting.md) | you have an error message and want its cause |
|
|
15
|
+
| [Roadmap](roadmap.md) | you want to know what is coming, and what is not |
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
# Constraints
|
|
2
|
+
|
|
3
|
+
How to declare checks with `@constraint` and turn them on with
|
|
4
|
+
`withValidation`. For the error an invalid input becomes, see
|
|
5
|
+
[Errors](errors.md).
|
|
6
|
+
|
|
7
|
+
## Smallest complete example
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { makeExecutableSchema } from '@graphql-tools/schema';
|
|
11
|
+
import { graphql } from 'graphql';
|
|
12
|
+
import { constraintTypeDefs, withValidation } from '@nxgt/graphql-validation';
|
|
13
|
+
|
|
14
|
+
const schema = withValidation(
|
|
15
|
+
makeExecutableSchema({
|
|
16
|
+
typeDefs: [
|
|
17
|
+
constraintTypeDefs,
|
|
18
|
+
/* GraphQL */ `
|
|
19
|
+
type Query {
|
|
20
|
+
greet(name: String! @constraint(minLength: 2)): String
|
|
21
|
+
}
|
|
22
|
+
`,
|
|
23
|
+
],
|
|
24
|
+
resolvers: { Query: { greet: (_, { name }) => `Hello ${name}` } },
|
|
25
|
+
}),
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const result = await graphql({ schema, source: '{ greet(name: "A") }' });
|
|
29
|
+
// result.errors[0].message:
|
|
30
|
+
// "Invalid arguments for Query.greet. name: Too small: expected string to have >=2 characters"
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## The flow
|
|
34
|
+
|
|
35
|
+
1. Put `constraintTypeDefs` in your type definitions. It is a string: the SDL
|
|
36
|
+
of the `@constraint` directive.
|
|
37
|
+
2. Write `@constraint(...)` on arguments and input fields.
|
|
38
|
+
3. Build the schema: `buildSchema`, `makeExecutableSchema`, GraphQL Yoga,
|
|
39
|
+
Apollo Server, anything that builds from type definitions.
|
|
40
|
+
4. Attach every resolver, then call `withValidation(schema)` **last**: a
|
|
41
|
+
resolver set on a field afterwards replaces the check.
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
import { buildSchema } from 'graphql';
|
|
45
|
+
import { constraintTypeDefs, withValidation } from '@nxgt/graphql-validation';
|
|
46
|
+
|
|
47
|
+
const schema = buildSchema(`${constraintTypeDefs}
|
|
48
|
+
type Query { code(value: String @constraint(pattern: "^[A-Z]{3}$")): String }
|
|
49
|
+
`);
|
|
50
|
+
// ... set field resolvers on `schema` here ...
|
|
51
|
+
withValidation(schema);
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
What `withValidation` does:
|
|
55
|
+
|
|
56
|
+
- It wraps the resolver of every field that has a constrained argument, directly
|
|
57
|
+
or anywhere inside an input type it takes: `subscribe` for a subscription
|
|
58
|
+
field, `resolve` otherwise.
|
|
59
|
+
- It wraps in place and returns the same schema, typed as you passed it.
|
|
60
|
+
Calling it twice wraps nothing twice.
|
|
61
|
+
- The resolver receives the parsed arguments (`z.output` of the schema the
|
|
62
|
+
directives describe). An argument or input field the client left out stays
|
|
63
|
+
**absent** from them, not `undefined`: `{ input: { email } }`, with
|
|
64
|
+
`Object.keys(input)` equal to `['email']`. A `@oneOf` input keeps its one key.
|
|
65
|
+
- Each issue it raises carries `constraint`, the `@constraint` argument that
|
|
66
|
+
refused (see [Errors](errors.md#the-error)).
|
|
67
|
+
- A default value that breaks its own constraint throws at the call, naming
|
|
68
|
+
the place: `The default value of Query.a(name:) breaks its @constraint: Too
|
|
69
|
+
small: expected string to have >=2 characters`. It works for arguments and
|
|
70
|
+
input fields (`The default value of Page.size breaks ...`).
|
|
71
|
+
- It builds every schema once, at the call, so a constraint that cannot apply
|
|
72
|
+
throws then, with the place named, not on the first request. This includes
|
|
73
|
+
constraints in input types no argument reaches.
|
|
74
|
+
- A subscription field is checked once, in `subscribe`, before it starts, not
|
|
75
|
+
on every event.
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
import type { GraphQLSchema } from 'graphql';
|
|
79
|
+
|
|
80
|
+
declare function withValidation<S extends GraphQLSchema>(schema: S): S;
|
|
81
|
+
declare const constraintTypeDefs: string;
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The directives are read from the SDL. A **code-first** schema does not declare
|
|
85
|
+
`@constraint`, so `withValidation` throws `this schema declares no @constraint
|
|
86
|
+
directive`. A schema that declares it but whose constrained types and fields
|
|
87
|
+
have no SDL (built with `new GraphQLObjectType`, or from merged pieces) is not
|
|
88
|
+
checked, and nothing says so.
|
|
89
|
+
|
|
90
|
+
## The `@constraint` arguments
|
|
91
|
+
|
|
92
|
+
They are those of [graphql-constraint-directive](https://github.com/confuser/graphql-constraint-directive),
|
|
93
|
+
minus `uniqueTypeName`. A rule narrows the type it names; on any other type the
|
|
94
|
+
schema fails to build (see [Troubleshooting](../troubleshooting.md)).
|
|
95
|
+
|
|
96
|
+
| Argument | GraphQL type | Applies to | Zod equivalent |
|
|
97
|
+
| --- | --- | --- | --- |
|
|
98
|
+
| `format` | `String` | `String`, `ID` | the schema of the [format](#formats), applied first |
|
|
99
|
+
| `minLength` | `Int` | `String`, `ID` | `.min(n)` |
|
|
100
|
+
| `maxLength` | `Int` | `String`, `ID` | `.max(n)` |
|
|
101
|
+
| `startsWith` | `String` | `String`, `ID` | `.startsWith(s)` |
|
|
102
|
+
| `endsWith` | `String` | `String`, `ID` | `.endsWith(s)` |
|
|
103
|
+
| `contains` | `String` | `String`, `ID` | `.includes(s)` |
|
|
104
|
+
| `notContains` | `String` | `String`, `ID` | `.refine(...)`, message `Must not contain "s"` |
|
|
105
|
+
| `pattern` | `String` | `String`, `ID` | `.regex(new RegExp(p))` |
|
|
106
|
+
| `min` | `Float` | `Int`, `Float` | `.gte(n)` |
|
|
107
|
+
| `max` | `Float` | `Int`, `Float` | `.lte(n)` |
|
|
108
|
+
| `exclusiveMin` | `Float` | `Int`, `Float` | `.gt(n)` |
|
|
109
|
+
| `exclusiveMax` | `Float` | `Int`, `Float` | `.lt(n)` |
|
|
110
|
+
| `multipleOf` | `Float` | `Int`, `Float` | `.multipleOf(n)` |
|
|
111
|
+
| `minItems` | `Int` | a list | `.min(n)` on the array |
|
|
112
|
+
| `maxItems` | `Int` | a list | `.max(n)` on the array |
|
|
113
|
+
|
|
114
|
+
An `Int` is also checked to be an integer. Several arguments combine:
|
|
115
|
+
|
|
116
|
+
```graphql
|
|
117
|
+
input Product {
|
|
118
|
+
sku: String! @constraint(startsWith: "SKU-", maxLength: 12, pattern: "^SKU-[0-9]+$")
|
|
119
|
+
price: Float! @constraint(exclusiveMin: 0, multipleOf: 0.01)
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The directive must be the one `constraintTypeDefs` declares. A `@constraint`
|
|
124
|
+
declared otherwise, typically graphql-constraint-directive's SDL kept after a
|
|
125
|
+
migration, makes `withValidation` throw at startup: `This schema's @constraint
|
|
126
|
+
is not constraintTypeDefs': it is allowed on FIELD_DEFINITION; declares
|
|
127
|
+
uniqueTypeName, which no rule reads. Declare it with constraintTypeDefs.` An
|
|
128
|
+
argument of another type is named too (`declares minLength: String, not Int`).
|
|
129
|
+
See [Troubleshooting](../troubleshooting.md).
|
|
130
|
+
|
|
131
|
+
`@constraint` is allowed on `ARGUMENT_DEFINITION` and `INPUT_FIELD_DEFINITION`
|
|
132
|
+
only. On an output field `buildSchema` fails with
|
|
133
|
+
`Directive "@constraint" may not be used on FIELD_DEFINITION.`, where
|
|
134
|
+
graphql-constraint-directive allows it: a resolver's result is the job of
|
|
135
|
+
output scalars, and a directive that checked nothing there would be a promise
|
|
136
|
+
broken in silence. Use [`@nxgt/graphql-scalars`](https://www.npmjs.com/package/@nxgt/graphql-scalars)
|
|
137
|
+
for that.
|
|
138
|
+
|
|
139
|
+
### Lists
|
|
140
|
+
|
|
141
|
+
`minItems` and `maxItems` apply to the list; every other rule applies to its
|
|
142
|
+
items. A nullable argument also accepts `null` and absence.
|
|
143
|
+
|
|
144
|
+
```graphql
|
|
145
|
+
type Query {
|
|
146
|
+
# at least one tag, at most 5; each tag 1 to 20 characters
|
|
147
|
+
search(tags: [String!]! @constraint(minItems: 1, maxItems: 5, minLength: 1, maxLength: 20)): [String!]
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Nested and recursive inputs
|
|
152
|
+
|
|
153
|
+
Constraints on input fields are found through any depth of input types, lists
|
|
154
|
+
of them, and recursive ones. The issue path says where:
|
|
155
|
+
|
|
156
|
+
```graphql
|
|
157
|
+
input Address { zip: String! @constraint(pattern: "^[0-9]{5}$") }
|
|
158
|
+
input SignUp { name: String, address: Address! }
|
|
159
|
+
type Mutation { signUp(input: SignUp!): Boolean }
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
A bad `zip` is reported at `['input', 'address', 'zip']`.
|
|
163
|
+
|
|
164
|
+
### Interfaces
|
|
165
|
+
|
|
166
|
+
An interface field is resolved by the objects that implement it, so a
|
|
167
|
+
`@constraint` on an interface field's argument must be written again, the same,
|
|
168
|
+
on each implementing object's field. If an object drops it or writes a
|
|
169
|
+
different one, `withValidation` throws at startup rather than let the argument
|
|
170
|
+
go unchecked, printing both.
|
|
171
|
+
|
|
172
|
+
```graphql
|
|
173
|
+
interface Named { name(style: String @constraint(maxLength: 5)): String }
|
|
174
|
+
type Person implements Named {
|
|
175
|
+
name(style: String @constraint(maxLength: 5)): String
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### Format first
|
|
180
|
+
|
|
181
|
+
`format` replaces the base string schema, and every other rule narrows it, so
|
|
182
|
+
the order you write them in does not matter:
|
|
183
|
+
|
|
184
|
+
```graphql
|
|
185
|
+
email: String! @constraint(maxLength: 12, format: "email")
|
|
186
|
+
# the email format, then at most 12 characters
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Formats
|
|
190
|
+
|
|
191
|
+
`@constraint(format: "...")` takes one of these names. Anything else fails at
|
|
192
|
+
startup with `Unknown @constraint format "siret". Known formats: byte, date,
|
|
193
|
+
date-time, email, ipv4, ipv6, uri, uuid.`
|
|
194
|
+
|
|
195
|
+
| Format | Zod schema | Accepts |
|
|
196
|
+
| --- | --- | --- |
|
|
197
|
+
| `byte` | `z.base64()` | base64 text |
|
|
198
|
+
| `date-time` | `z.iso.datetime({ offset: true })` | RFC 3339 with an offset: `2024-03-10T12:00:00Z`, `2024-03-10T12:00:00+02:00` |
|
|
199
|
+
| `date` | `z.iso.date()` | `YYYY-MM-DD`, a real day |
|
|
200
|
+
| `email` | `z.email()` | an email address |
|
|
201
|
+
| `ipv4` | `z.ipv4()` | a dotted IPv4 address |
|
|
202
|
+
| `ipv6` | `z.ipv6()` | an IPv6 address |
|
|
203
|
+
| `uri` | `z.url({ protocol: /^(https?\|ftp)$/ })` | an absolute `http`, `https` or `ftp` URL; the scheme is required |
|
|
204
|
+
| `uuid` | `z.uuid()` | an 8-4-4-4-12 UUID |
|
|
205
|
+
|
|
206
|
+
Where they differ from graphql-constraint-directive's (validator.js):
|
|
207
|
+
|
|
208
|
+
- `date-time` is the canonical form only: an uppercase `T` and `Z`, seconds
|
|
209
|
+
present. A lowercase `t` or `z`, or a space for `T`, is refused.
|
|
210
|
+
- `uri` requires the scheme (`example.com` is refused) and nothing but `http`,
|
|
211
|
+
`https` and `ftp` gets in: no `javascript:`, no `mailto:`.
|
|
212
|
+
|
|
213
|
+
## In your IDE
|
|
214
|
+
|
|
215
|
+
An IDE's GraphQL plugin does not run your server, so it does not know
|
|
216
|
+
`@constraint` and reports `Unknown directive "@constraint"`. Point
|
|
217
|
+
`graphql.config.yml` at the file the package ships (by path; it is not an
|
|
218
|
+
`exports` subpath, so do not `import` it):
|
|
219
|
+
|
|
220
|
+
```yaml
|
|
221
|
+
schema:
|
|
222
|
+
- src/**/*.graphql
|
|
223
|
+
- node_modules/@nxgt/graphql-validation/graphql/constraint.graphqls
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
To keep a copy in the project, for an IDE that does not index `node_modules` or
|
|
227
|
+
to commit it with your schema:
|
|
228
|
+
|
|
229
|
+
```sh
|
|
230
|
+
bunx nxgt-graphql-validation typedefs --out
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
and list `generated/graphql/constraint.graphqls` in `graphql.config.yml` in place of the
|
|
234
|
+
`node_modules/...` line.
|
|
235
|
+
|
|
236
|
+
`npx` works the same. `--out` alone writes `generated/graphql/constraint.graphqls`,
|
|
237
|
+
`--out <file>` writes `<file>`, creating the folder, and without it the SDL goes
|
|
238
|
+
to stdout (`--help` or `-h`, anywhere on the line, lists the flags). It exits 0 when done, 1 when the file
|
|
239
|
+
cannot be written (`typedefs failed: <message>` on stderr), and 2 on a usage
|
|
240
|
+
error (`Unknown command "<x>".`, `Unexpected arguments: …`, or no command). Regenerate the file after upgrading the
|
|
241
|
+
package.
|
|
242
|
+
|
|
243
|
+
The schema must declare `@constraint` once. Pick one source for the server:
|
|
244
|
+
|
|
245
|
+
- **The generated file is part of your schema.** If the server loads its
|
|
246
|
+
type definitions by scanning `*.graphql(s)` files and the copy sits among
|
|
247
|
+
them, it already declares the directive: do not add `constraintTypeDefs`.
|
|
248
|
+
- **`constraintTypeDefs` declares it.** Then the copy is for the IDE only:
|
|
249
|
+
keep it out of the folders the server scans (or exclude it from the glob).
|
|
250
|
+
|
|
251
|
+
Both give the same schema, and `withValidation` accepts either: it checks that
|
|
252
|
+
the directive declared is this package's. Both at once throw
|
|
253
|
+
`There can be only one directive named "@constraint".`
|
|
254
|
+
|
|
255
|
+
## A realistic schema
|
|
256
|
+
|
|
257
|
+
```ts
|
|
258
|
+
import { createYoga, createSchema } from 'graphql-yoga';
|
|
259
|
+
import { constraintTypeDefs, withValidation } from '@nxgt/graphql-validation';
|
|
260
|
+
|
|
261
|
+
const schema = withValidation(
|
|
262
|
+
createSchema({
|
|
263
|
+
typeDefs: [
|
|
264
|
+
constraintTypeDefs,
|
|
265
|
+
/* GraphQL */ `
|
|
266
|
+
input Signup {
|
|
267
|
+
email: String! @constraint(format: "email", maxLength: 254)
|
|
268
|
+
password: String! @constraint(minLength: 12)
|
|
269
|
+
website: String @constraint(format: "uri")
|
|
270
|
+
}
|
|
271
|
+
type Mutation {
|
|
272
|
+
signup(input: Signup!): ID!
|
|
273
|
+
}
|
|
274
|
+
type Query { ok: Boolean }
|
|
275
|
+
`,
|
|
276
|
+
],
|
|
277
|
+
resolvers: {
|
|
278
|
+
Mutation: {
|
|
279
|
+
// runs only for a valid `input`
|
|
280
|
+
signup: (_, { input }) => createUser(input),
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
}),
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
declare function createUser(input: { email: string }): string;
|
|
287
|
+
|
|
288
|
+
export const yoga = createYoga({ schema });
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
For rules the directive cannot say, see [`validated`](errors.md#validated).
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# Errors
|
|
2
|
+
|
|
3
|
+
The error an invalid input becomes, how a client reads it, and `validated` for
|
|
4
|
+
rules a `@constraint` cannot say. For declaring constraints, see
|
|
5
|
+
[Constraints](constraints.md).
|
|
6
|
+
|
|
7
|
+
## The error
|
|
8
|
+
|
|
9
|
+
An invalid input is **one** `GraphQLError`, thrown before your resolver runs:
|
|
10
|
+
|
|
11
|
+
- `message`: `Invalid arguments for <Type>.<field>. <path>: <first issue message>`,
|
|
12
|
+
for a log line. The path is dotted; `arguments` stands for an empty path.
|
|
13
|
+
- `extensions.code`: `BAD_USER_INPUT`, the code Apollo and most clients read.
|
|
14
|
+
- `extensions.issues`: every reason, not only the first.
|
|
15
|
+
|
|
16
|
+
```json
|
|
17
|
+
{
|
|
18
|
+
"errors": [
|
|
19
|
+
{
|
|
20
|
+
"message": "Invalid arguments for Mutation.signUp. input.email: Invalid email address",
|
|
21
|
+
"path": ["signUp"],
|
|
22
|
+
"extensions": {
|
|
23
|
+
"code": "BAD_USER_INPUT",
|
|
24
|
+
"issues": [
|
|
25
|
+
{ "path": ["input", "email"], "message": "Invalid email address", "code": "invalid_format", "constraint": "format" },
|
|
26
|
+
{ "path": ["input", "name"], "message": "Too small: expected string to have >=2 characters", "code": "too_small", "constraint": "minLength" },
|
|
27
|
+
{ "path": ["input", "tags"], "message": "Too big: expected array to have <=2 items", "code": "too_big", "constraint": "maxItems" }
|
|
28
|
+
]
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
],
|
|
32
|
+
"data": { "signUp": null }
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The error's own `path` is the field; each issue's `path` is **relative to the
|
|
37
|
+
arguments**: the argument name, then input fields, with a number for a list
|
|
38
|
+
index. `code` is Zod's issue code (`too_small`, `too_big`, `invalid_format`,
|
|
39
|
+
`custom`, ...). `constraint` is the `@constraint` argument that refused
|
|
40
|
+
(`minLength`, `format`, `maxItems`, ...); it is absent for a refusal of a
|
|
41
|
+
[`validated`](#validated) schema. `message` is Zod's, in English, and is not
|
|
42
|
+
meant for display in every language: map on `constraint` and `path` if you
|
|
43
|
+
translate. Prefer `constraint` to `code`: it is the name in your schema, while
|
|
44
|
+
`code` is Zod's and can change when Zod does across a major version.
|
|
45
|
+
|
|
46
|
+
### Types
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
interface ValidationIssue {
|
|
50
|
+
readonly path: readonly (string | number)[];
|
|
51
|
+
readonly message: string;
|
|
52
|
+
readonly code: string;
|
|
53
|
+
/** The `@constraint` argument that refused; absent for `validated`'s own schema. */
|
|
54
|
+
readonly constraint?: ConstraintArgument;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type ConstraintArgument =
|
|
58
|
+
| 'format' | 'minLength' | 'maxLength' | 'startsWith' | 'endsWith'
|
|
59
|
+
| 'contains' | 'notContains' | 'pattern' | 'min' | 'max'
|
|
60
|
+
| 'exclusiveMin' | 'exclusiveMax' | 'multipleOf' | 'minItems' | 'maxItems';
|
|
61
|
+
|
|
62
|
+
type BadUserInputExtensions = {
|
|
63
|
+
readonly code: 'BAD_USER_INPUT';
|
|
64
|
+
readonly issues: readonly ValidationIssue[];
|
|
65
|
+
};
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## In a client
|
|
69
|
+
|
|
70
|
+
Map the issues onto form fields by path. A path of `['input', 'address', 'zip']`
|
|
71
|
+
is the field `address.zip` of the `input` argument.
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
import type { BadUserInputExtensions, ValidationIssue } from '@nxgt/graphql-validation';
|
|
75
|
+
|
|
76
|
+
interface GraphQLErrorJson {
|
|
77
|
+
message: string;
|
|
78
|
+
extensions?: Partial<BadUserInputExtensions>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Field errors keyed by `address.zip`, relative to the `input` argument. */
|
|
82
|
+
function fieldErrors(errors: readonly GraphQLErrorJson[]): Record<string, string[]> {
|
|
83
|
+
const byField: Record<string, string[]> = {};
|
|
84
|
+
for (const error of errors) {
|
|
85
|
+
if (error.extensions?.code !== 'BAD_USER_INPUT') continue;
|
|
86
|
+
for (const issue of (error.extensions.issues ?? []) as ValidationIssue[]) {
|
|
87
|
+
const [, ...field] = issue.path; // drop the argument name, `input`
|
|
88
|
+
const key = field.join('.');
|
|
89
|
+
(byField[key] ??= []).push(issue.message);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return byField;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// { email: ['Invalid email address'], name: ['Too small: ...'] }
|
|
96
|
+
|
|
97
|
+
/** Translated texts, keyed by the stable `constraint`, not by Zod's `code`. */
|
|
98
|
+
const texts: Record<string, string> = {
|
|
99
|
+
format: 'Not a valid value',
|
|
100
|
+
minLength: 'Too short',
|
|
101
|
+
maxItems: 'Too many items',
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const textOf = (issue: ValidationIssue) =>
|
|
105
|
+
(issue.constraint && texts[issue.constraint]) ?? issue.message;
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
An issue whose path is just the argument (`['code']` for `check(code: ...)`)
|
|
109
|
+
gives the empty key: show it as a form-level error.
|
|
110
|
+
|
|
111
|
+
## `validated`
|
|
112
|
+
|
|
113
|
+
`validated(schema, resolver)` checks the arguments with a Zod schema you write,
|
|
114
|
+
for what a directive cannot say: a rule across arguments or fields, a business
|
|
115
|
+
refinement, an async check. It raises the same error, so a client has one shape
|
|
116
|
+
to handle.
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
import { validated } from '@nxgt/graphql-validation';
|
|
120
|
+
import { z } from 'zod';
|
|
121
|
+
|
|
122
|
+
const range = z
|
|
123
|
+
.object({ from: z.number(), to: z.number() })
|
|
124
|
+
.refine(({ from, to }) => from <= to, {
|
|
125
|
+
message: 'from must not exceed to',
|
|
126
|
+
path: ['to'],
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const resolvers = {
|
|
130
|
+
Query: {
|
|
131
|
+
// (source, args, context, info): args is z.output of the schema
|
|
132
|
+
range: validated(range, (_, { from, to }) => to - from),
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
A bad `{ from: 4, to: 1 }` gives `Invalid arguments for Query.range. to: from must not exceed to`
|
|
138
|
+
with one issue `{ path: ['to'], message: 'from must not exceed to', code: 'custom' }`.
|
|
139
|
+
|
|
140
|
+
- The schema is a Zod object schema of the arguments, or its **shape**
|
|
141
|
+
(`{ name: z.string() }`, wrapped in `z.object` for you).
|
|
142
|
+
- The resolver receives `z.output`: defaults and transforms applied.
|
|
143
|
+
- Async refinements work; the check awaits them.
|
|
144
|
+
- When a field has both `@constraint` and `validated`, the directives run
|
|
145
|
+
first, then your schema.
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
import { validated } from '@nxgt/graphql-validation';
|
|
149
|
+
import { z } from 'zod';
|
|
150
|
+
|
|
151
|
+
declare const users: { exists(name: string): Promise<boolean> };
|
|
152
|
+
|
|
153
|
+
// type Query { claim(name: String! @constraint(minLength: 3)): String }
|
|
154
|
+
const claim = validated(
|
|
155
|
+
{ name: z.string().refine(async (name) => !(await users.exists(name)), 'already taken') },
|
|
156
|
+
(_, { name }) => name,
|
|
157
|
+
);
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
import type { GraphQLFieldResolver, GraphQLResolveInfo } from 'graphql';
|
|
162
|
+
import type { z } from 'zod';
|
|
163
|
+
|
|
164
|
+
type ArgsSchema = z.ZodType<Record<string, unknown>> | z.ZodRawShape;
|
|
165
|
+
type SchemaOf<S extends ArgsSchema> = S extends z.ZodType ? S : z.ZodObject<S & z.ZodRawShape>;
|
|
166
|
+
|
|
167
|
+
declare function validated<S extends ArgsSchema, TSource = unknown, TContext = unknown, TResult = unknown>(
|
|
168
|
+
schema: S,
|
|
169
|
+
resolve: (source: TSource, args: z.output<SchemaOf<S>>, context: TContext, info: GraphQLResolveInfo) => TResult,
|
|
170
|
+
): GraphQLFieldResolver<TSource, TContext, z.input<SchemaOf<S>>, Promise<Awaited<TResult>>>;
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
`validated` validates whatever the field's arguments are; it does not need
|
|
174
|
+
`withValidation` or `constraintTypeDefs` to work.
|
|
175
|
+
|
|
176
|
+
## `badUserInput`
|
|
177
|
+
|
|
178
|
+
For code that validates by hand and wants the same error:
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
import { badUserInput } from '@nxgt/graphql-validation';
|
|
182
|
+
import { z } from 'zod';
|
|
183
|
+
|
|
184
|
+
const result = z.object({ code: z.string().length(4) }).safeParse({ code: 'abc' });
|
|
185
|
+
if (!result.success) throw badUserInput('Query.redeem', result.error);
|
|
186
|
+
// message: "Invalid arguments for Query.redeem. code: Too small: expected string to have exactly 4 characters"
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
import type { GraphQLError } from 'graphql';
|
|
191
|
+
import type { z } from 'zod';
|
|
192
|
+
|
|
193
|
+
declare function badUserInput(where: string, error: z.ZodError): GraphQLError;
|
|
194
|
+
```
|
package/docs/roadmap.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Roadmap
|
|
2
|
+
|
|
3
|
+
What `@nxgt/graphql-validation` is heading for, phrased as what you get.
|
|
4
|
+
|
|
5
|
+
## Now
|
|
6
|
+
|
|
7
|
+
- **`@constraint` on arguments and input fields.** `constraintTypeDefs` for
|
|
8
|
+
your SDL, then `withValidation(schema)`: every constrained argument is
|
|
9
|
+
checked by a Zod schema built from its directives, through nested input
|
|
10
|
+
types and lists, before your resolver runs. The resolver receives the
|
|
11
|
+
parsed value.
|
|
12
|
+
- **One error a form can use.** An invalid input is one `GraphQLError` with
|
|
13
|
+
`extensions.code` `BAD_USER_INPUT` and an `issues` list, each with the
|
|
14
|
+
path of the offending field inside the arguments.
|
|
15
|
+
- **`validated(schema, resolver)`** for what a directive cannot say: rules
|
|
16
|
+
across fields, business refinements, with the same error.
|
|
17
|
+
- **Formats** `byte`, `date-time`, `date`, `email`, `ipv4`, `ipv6`, `uri`,
|
|
18
|
+
`uuid`.
|
|
19
|
+
|
|
20
|
+
## Next
|
|
21
|
+
|
|
22
|
+
- **A Zod codegen plugin** that writes the same schemas as source, from the
|
|
23
|
+
same rules, so the types your resolvers receive come from your SDL.
|
|
24
|
+
- **Your own formats**, named in `@constraint(format: "...")`.
|
|
25
|
+
- **Schemas that read the context**, `(args, context) => schema`, for checks
|
|
26
|
+
that need the request (uniqueness in a database, permissions).
|
|
27
|
+
|
|
28
|
+
## Shipped
|
|
29
|
+
|
|
30
|
+
Nothing yet.
|