Skip to content

Commit 0447b7d

Browse files
veliasconfuser
andauthored
Schema documentation transformation - #4 (#160)
fixes #4 --------- Co-authored-by: James Mortemore <[email protected]>
1 parent 0c12f9f commit 0447b7d

File tree

10 files changed

+546
-3
lines changed

10 files changed

+546
-3
lines changed

README.md

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,81 @@ app.use(
306306
app.listen(4000);
307307

308308
```
309+
### Schema documentation
310+
311+
You can use the provided schema transformation to automatically add `@constraint` documentation into fields and arguments descriptions. By default directives are not typically present in the exposed introspected schema
312+
313+
```js
314+
const { constraintDirectiveTypeDefs, constraintDirectiveDocumentation } = require('graphql-constraint-directive')
315+
const { makeExecutableSchema } = require('@graphql-tools/schema')
316+
317+
const typeDefs = ...
318+
319+
let schema = makeExecutableSchema({
320+
typeDefs: [constraintDirectiveTypeDefs, typeDefs]
321+
})
322+
323+
schema = constraintDirectiveDocumentation()(schema);
324+
325+
// any constraint directive handler implementation
326+
```
327+
328+
This transformation appends `constraint documentation header`, and then a list of `constraint conditions descriptions` to the description of each field and argument where the `@constraint` directive is used.
329+
330+
Original schema:
331+
```graphql
332+
"""
333+
Existing field or argument description.
334+
"""
335+
fieldOrArgument: String @constraint(minLength: 10, maxLength: 50)
336+
```
337+
338+
Transformed schema:
339+
```graphql
340+
"""
341+
Existing field or argument description.
342+
343+
*Constraints:*
344+
* Minimum length: `10`
345+
* Maximum length: `50`
346+
"""
347+
fieldOrArgument: String @constraint(minLength: 10, maxLength: 50)
348+
```
349+
350+
[CommonMark](https://spec.commonmark.org) is used in the desccription for better readability.
351+
352+
If `constraint documentation header` already exists in the field or argument description, then
353+
constraint documentation is not appended. This allows you to override constraint description
354+
when necessary, or use this in a chain of subgraph/supergraph schemes.
355+
356+
Both `constraint documentation header` and `constraint conditions descriptions` can be customized
357+
during the transformation creation, eg. to localize them.
358+
359+
```js
360+
schema = constraintDirectiveDocumentation(
361+
{
362+
header: '*Changed header:*',
363+
descriptionsMap: {
364+
minLength: 'Changed Minimum length',
365+
maxLength: 'Changed Maximum length',
366+
startsWith: 'Changed Starts with',
367+
endsWith: 'Changed Ends with',
368+
contains: 'Changed Contains',
369+
notContains: 'Changed Doesn\'t contain',
370+
pattern: 'Changed Must match RegEx pattern',
371+
format: 'Changed Must match format',
372+
min: 'Changed Minimum value',
373+
max: 'Changed Maximum value',
374+
exclusiveMin: 'Changed Grater than',
375+
exclusiveMax: 'Changed Less than',
376+
multipleOf: 'Changed Must be a multiple of',
377+
minItems: 'Changed Minimum number of items',
378+
maxItems: 'Changed Maximum number of items'
379+
}
380+
}
381+
)(schema);
382+
```
383+
309384

310385
## API
311386
### String

index.d.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,68 @@ import {GraphQLSchema, GraphQLError, DocumentNode, ValidationContext} from "grap
22
import {PluginDefinition} from "apollo-server-core";
33
import QueryValidationVisitor from "./lib/query-validation-visitor";
44

5+
/**
6+
* Schema transformer which adds custom types performing validations based on the @constraint directives.
7+
*/
58
export function constraintDirective () : (schema: GraphQLSchema) => GraphQLSchema;
69

10+
interface DocumentationOptions {
11+
/** Header for the constraints documentation block in the field or argument description */
12+
header?: string;
13+
/** Names for distinct constraint types */
14+
descriptionsMap?: {
15+
minLength: string,
16+
maxLength: string,
17+
startsWith: string,
18+
endsWith: string,
19+
contains: string,
20+
notContains: string,
21+
pattern: string,
22+
format: string,
23+
min: string,
24+
max: string,
25+
exclusiveMin: string,
26+
exclusiveMax: string,
27+
multipleOf: string,
28+
minItems: string,
29+
maxItems: string
30+
};
31+
}
32+
33+
/**
34+
* Schema transformer which adds @constraint directives documentation to the fields and arguments descriptions.
35+
* Documentation not added if it already exists (`header` is present in the field or argument description)
36+
*
37+
* @param options options to customize the documentation process
38+
*/
39+
export function constraintDirectiveDocumentation (options: DocumentationOptions) : (schema: GraphQLSchema) => GraphQLSchema;
40+
41+
/**
42+
* Type definition for @constraint directive.
43+
*/
744
export const constraintDirectiveTypeDefs: string
845

46+
/**
47+
* Method for query validation based on the @constraint directives defined in the schema.
48+
*
49+
* @param schema GraphQL schema to look for directives
50+
* @param query GraphQL query to validate
51+
* @param variables used in the query to validate
52+
* @param operationName optional name of the GraphQL operation to validate
53+
*/
954
export function validateQuery () : (schema: GraphQLSchema, query: DocumentNode, variables: Record<string, any>, operationName?: string) => Array<GraphQLError>;
1055

56+
/**
57+
* Create Apollo 3 plugin performing query validation.
58+
*/
1159
export function createApolloQueryValidationPlugin ( options: { schema: GraphQLSchema } ) : PluginDefinition;
1260

61+
/**
62+
* Create JS GraphQL Validation Rule performing query validation.
63+
*/
1364
export function createQueryValidationRule( options: { [key: string]: any }) : (context: ValidationContext) => QueryValidationVisitor;
1465

66+
/**
67+
* Create Envelop plugin performing query validation.
68+
*/
1569
export function createEnvelopQueryValidationPlugin() : object;

index.js

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@ const {
66
visit,
77
visitWithTypeInfo,
88
separateOperations,
9-
GraphQLError
9+
GraphQLError,
10+
getDirectiveValues
1011
} = require('graphql')
1112
const QueryValidationVisitor = require('./lib/query-validation-visitor.js')
1213
const { getDirective, mapSchema, MapperKind } = require('@graphql-tools/utils')
1314
const { getConstraintTypeObject, getScalarType } = require('./lib/type-utils')
14-
const { constraintDirectiveTypeDefs } = require('./lib/type-defs')
15+
const { constraintDirectiveTypeDefs, constraintDirectiveTypeDefsObj } = require('./lib/type-defs')
1516

1617
function constraintDirective () {
1718
const constraintTypes = {}
@@ -95,6 +96,77 @@ function constraintDirective () {
9596
})
9697
}
9798

99+
function constraintDirectiveDocumentation (options) {
100+
// Default descriptions, can be changed through options
101+
let DESCRIPTINS_MAP = {
102+
minLength: 'Minimal length',
103+
maxLength: 'Maximal length',
104+
startsWith: 'Starts with',
105+
endsWith: 'Ends with',
106+
contains: 'Contains',
107+
notContains: 'Doesn\'t contain',
108+
pattern: 'Must match RegEx pattern',
109+
format: 'Must match format',
110+
min: 'Minimal value',
111+
max: 'Maximal value',
112+
exclusiveMin: 'Grater than',
113+
exclusiveMax: 'Less than',
114+
multipleOf: 'Must be a multiple of',
115+
minItems: 'Minimal number of items',
116+
maxItems: 'Maximal number of items'
117+
}
118+
119+
if (options?.descriptionsMap) {
120+
DESCRIPTINS_MAP = options.descriptionsMap
121+
}
122+
123+
let HEADER = '*Constraints:*'
124+
if (options?.header) {
125+
HEADER = options.header
126+
}
127+
128+
function documentConstraintDirective (fieldConfig, directiveArgumentMap) {
129+
if (fieldConfig.description) {
130+
// skip documentation if it is already here
131+
if (fieldConfig.description.includes(HEADER)) return
132+
133+
// add two new lines to separate from previous description by paragraph
134+
fieldConfig.description += '\n\n'
135+
} else {
136+
fieldConfig.description = ''
137+
}
138+
139+
fieldConfig.description += HEADER + '\n'
140+
141+
Object.entries(directiveArgumentMap).forEach(([key, value]) => {
142+
if (key === 'uniqueTypeName') return
143+
fieldConfig.description += `* ${DESCRIPTINS_MAP[key] ? DESCRIPTINS_MAP[key] : key}: \`${value}\`\n`
144+
})
145+
}
146+
147+
return (schema) =>
148+
mapSchema(schema, {
149+
[MapperKind.FIELD]: (fieldConfig) => {
150+
const directiveArgumentMap = getDirectiveValues(constraintDirectiveTypeDefsObj, fieldConfig.astNode)
151+
152+
if (directiveArgumentMap) {
153+
documentConstraintDirective(fieldConfig, directiveArgumentMap)
154+
155+
return fieldConfig
156+
}
157+
},
158+
[MapperKind.ARGUMENT]: (fieldConfig) => {
159+
const directiveArgumentMap = getDirectiveValues(constraintDirectiveTypeDefsObj, fieldConfig.astNode)
160+
161+
if (directiveArgumentMap) {
162+
documentConstraintDirective(fieldConfig, directiveArgumentMap)
163+
164+
return fieldConfig
165+
}
166+
}
167+
})
168+
}
169+
98170
function validateQuery (schema, query, variables, operationName) {
99171
const typeInfo = new TypeInfo(schema)
100172

@@ -159,4 +231,4 @@ function createQueryValidationRule (options) {
159231
}
160232
}
161233

162-
module.exports = { constraintDirective, constraintDirectiveTypeDefs, validateQuery, createApolloQueryValidationPlugin, createEnvelopQueryValidationPlugin, createQueryValidationRule }
234+
module.exports = { constraintDirective, constraintDirectiveDocumentation, constraintDirectiveTypeDefs, validateQuery, createApolloQueryValidationPlugin, createEnvelopQueryValidationPlugin, createQueryValidationRule }

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"types": "index.d.ts",
77
"scripts": {
88
"test": "standard && nyc --reporter=html --reporter=text --reporter=lcov mocha test/**/testsuite-full.js",
9+
"test-documentation": "standard && nyc --reporter=html --reporter=text --reporter=lcov mocha test/**/testsuite-documentation.js",
910
"test-schema-wrapper": "standard && nyc --reporter=html --reporter=text --reporter=lcov mocha test/**/testsuite-schema-wrapper.js",
1011
"test-apollo-plugin": "standard && nyc --reporter=html --reporter=text --reporter=lcov mocha test/**/testsuite-apollo-plugin.js",
1112
"test-apollo4-plugin": "standard && nyc --reporter=html --reporter=text --reporter=lcov mocha test/**/testsuite-apollo4-plugin.js",

test/snapshots/ws-1.txt

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
directive @constraint(minLength: Int, maxLength: Int, startsWith: String, endsWith: String, contains: String, notContains: String, pattern: String, format: String, min: Float, max: Float, exclusiveMin: Float, exclusiveMax: Float, multipleOf: Float, minItems: Int, maxItems: Int, uniqueTypeName: String) on INPUT_FIELD_DEFINITION | FIELD_DEFINITION | ARGUMENT_DEFINITION
2+
3+
type Query {
4+
"""Query books field documented"""
5+
books(
6+
"*Constraints:*\n* Minimal value: `1`\n* Maximal value: `3`\n"
7+
size: Int
8+
9+
" Query argument documented \n\n*Constraints:*\n* Minimal length: `1`\n"
10+
first: String
11+
): [Book]
12+
13+
""" Query book field documented """
14+
book: Book
15+
}
16+
17+
type Book {
18+
"*Constraints:*\n* Maximal length: `10`\n"
19+
title: String
20+
21+
"Book description already documented\n\n*Constraints:*\n* Minimal length: `10`\n* Maximal length: `50`\n"
22+
description: String
23+
authors(
24+
"Book authors argument documented\n\n*Constraints:*\n* Maximal value: `4`\n"
25+
size: Int
26+
27+
"""
28+
Already documented
29+
30+
*Constraints:*
31+
* Minimal length as documented: 1
32+
"""
33+
first: String
34+
): [String]
35+
}
36+
37+
type Mutation {
38+
createBook(input: BookInput): Book
39+
}
40+
41+
input BookInput {
42+
"*Constraints:*\n* Minimal value: `3`\n"
43+
title: Int!
44+
author: AuthorInput
45+
}
46+
47+
input AuthorInput {
48+
"*Constraints:*\n* Minimal length: `2`\n"
49+
name: String!
50+
}

test/snapshots/ws-2-2.txt

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
directive @constraint(minLength: Int, maxLength: Int, startsWith: String, endsWith: String, contains: String, notContains: String, pattern: String, format: String, min: Float, max: Float, exclusiveMin: Float, exclusiveMax: Float, multipleOf: Float, minItems: Int, maxItems: Int, uniqueTypeName: String) on INPUT_FIELD_DEFINITION | FIELD_DEFINITION | ARGUMENT_DEFINITION
2+
3+
type Query {
4+
"""Query books field documented"""
5+
books(
6+
"My header:\n* Minimal value: `1`\n* Maximal value: `3`\n"
7+
size: Int
8+
9+
" Query argument documented \n\nMy header:\n* Minimal length: `1`\n"
10+
first: String
11+
): [Book]
12+
13+
""" Query book field documented """
14+
book: Book
15+
}
16+
17+
type Book {
18+
"My header:\n* Maximal length: `10`\n"
19+
title: String
20+
21+
"Book description already documented\n\nMy header:\n* Minimal length: `10`\n* Maximal length: `50`\n"
22+
description: String
23+
authors(
24+
"Book authors argument documented\n\nMy header:\n* Maximal value: `4`\n"
25+
size: Int
26+
27+
"Already documented\n\n*Constraints:*\n* Minimal length as documented: 1\n\nMy header:\n* Minimal length: `1`\n"
28+
first: String
29+
): [String]
30+
}
31+
32+
type Mutation {
33+
createBook(input: BookInput): Book
34+
}
35+
36+
input BookInput {
37+
"My header:\n* Minimal value: `3`\n"
38+
title: Int!
39+
author: AuthorInput
40+
}
41+
42+
input AuthorInput {
43+
"My header:\n* Minimal length: `2`\n"
44+
name: String!
45+
}

0 commit comments

Comments
 (0)