From b74bbf35fcee81a00ce1a0fc6207f507bcb25497 Mon Sep 17 00:00:00 2001 From: Jordan Stout Date: Sat, 2 Nov 2019 07:08:50 -0700 Subject: [PATCH] Directives support with `@Directive` decorator (#369) Co-authors: @bbenoist * PR fixes and refactor @MichalLytek * proper docs about using directives * refactor directives generation --- CHANGELOG.md | 1 + docs/directives.md | 107 ++++ package-lock.json | 595 ++++++++++++++++++ src/decorators/Directive.ts | 33 + src/decorators/index.ts | 1 + src/errors/InvalidDirectiveError.ts | 6 + src/errors/index.ts | 1 + src/interfaces/ResolverInterface.ts | 2 +- src/metadata/definitions/class-metadata.ts | 2 + .../definitions/directive-metadata.ts | 15 + src/metadata/definitions/field-metadata.ts | 2 + src/metadata/definitions/index.ts | 1 + src/metadata/definitions/resolver-metadata.ts | 2 + src/metadata/metadata-storage.ts | 24 + src/schema/definition-node.ts | 155 +++++ src/schema/schema-generator.ts | 43 +- tests/functional/directives.ts | 477 ++++++++++++++ tests/helpers/directives/AppendDirective.ts | 30 + .../helpers/directives/UpperCaseDirective.ts | 65 ++ .../directives/assertValidDirective.ts | 41 ++ website/i18n/en.json | 3 + website/sidebars.json | 2 +- 22 files changed, 1599 insertions(+), 9 deletions(-) create mode 100644 docs/directives.md create mode 100644 src/decorators/Directive.ts create mode 100644 src/errors/InvalidDirectiveError.ts create mode 100644 src/metadata/definitions/directive-metadata.ts create mode 100644 src/schema/definition-node.ts create mode 100644 tests/functional/directives.ts create mode 100644 tests/helpers/directives/AppendDirective.ts create mode 100644 tests/helpers/directives/UpperCaseDirective.ts create mode 100644 tests/helpers/directives/assertValidDirective.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 51d543eec..3377f1f25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - **Breaking Change**: update `graphql-js` peer dependency to `^14.5.8` - **Breaking Change**: update `graphql-query-complexity` dependency to `^0.4.0` and drop support for `fieldConfigEstimator` (use `fieldExtensionsEstimator` instead) - update `TypeResolver` interface to match with `GraphQLTypeResolver` from `graphql-js` +- add basic support for directives with `@Directive()` decorator (#369) ### Fixes - refactor union types function syntax handling to prevent possible errors with circular refs diff --git a/docs/directives.md b/docs/directives.md new file mode 100644 index 000000000..3d6c4c5b0 --- /dev/null +++ b/docs/directives.md @@ -0,0 +1,107 @@ +--- +title: Directives +--- + +[GraphQL directives](https://www.apollographql.com/docs/graphql-tools/schema-directives/) though the syntax might remind the TS decorators: + +> A directive is an identifier preceded by a @ character, optionally followed by a list of named arguments, which can appear after almost any form of syntax in the GraphQL query or schema languages. + +But in fact, they are a purely SDL (Schema Definition Language) feature that allows you to put some metadata for selected type or its field: + +```graphql +type Foo @auth(requires: USER) { + field: String! +} + +type Bar { + field: String! @auth(requires: USER) +} +``` + +That metadata can be read in runtime to modify the structure and behavior of a GraphQL schema to support reusable code and tasks like authentication, permission, formatting, and plenty more. They are also really useful for some external services like [Apollo Cache Control](https://www.apollographql.com/docs/apollo-server/performance/caching/#adding-cache-hints-statically-in-your-schema) or [Apollo Federation](https://www.apollographql.com/docs/apollo-server/federation/introduction/#federated-schema-example). + +**TypeGraphQL** of course provides some basic support for using the schema directives via the `@Directive` decorator. + +## Usage + +### Declaring in schema + +Basically, there are two supported ways of declaring the usage of directives: + +- string based - just like in SDL, with the `@` syntax: + +```typescript +@Directive('@deprecated(reason: "Use newField")') +``` + +- object based - using a JS object to pass the named arguments + +```typescript +@Directive("deprecated", { reason: "Use newField" }) // syntax without `@` !!! +``` + +Currently, you can use the directives only on object types, input types and their fields, as well as queries and mutations. + +So the `@Directive` decorator can be placed over the class property/method or over the class itself, depending on the needs and the placements supported by the implementation: + +```typescript +@Directive("@auth(requires: USER)") +@ObjectType() +class Foo { + @Field() + field: string; +} + +@ObjectType() +class Bar { + @Directive("auth", { requires: "USER" }) + @Field() + field: string; +} + +@Resolver() +class FooBarResolver { + @Directive("@auth(requires: USER)") + @Query() + foobar(@Arg("baz") baz: string): string { + return "foobar"; + } +} +``` + +> Note that even as directives are a purely SDL thing, they won't appear in the generated schema definition file. Current implementation of directives in TypeGraphQL is using some crazy workarounds because [`graphql-js` doesn't support setting them by code](https://github.com/graphql/graphql-js/issues/1343) and the built-in `printSchema` utility omits the directives while printing. + +Also please note that `@Directive` can only contain a single GraphQL directive name or declaration. If you need to have multiple directives declared, just place multiple decorators: + +```typescript +@ObjectType() +class Foo { + @Directive("lowercase") + @Directive('@deprecated(reason: "Use `newField`")') + @Directive("hasRole", { role: Role.Manager }) + @Field() + bar: string; +} +``` + +### Providing the implementation + +Besides declaring the usage of directives, you also have to register the runtime part of the used directives. + +> Be aware that TypeGraphQL doesn't have any special way for implementing schema directives. You should use some [3rd party libraries](https://www.apollographql.com/docs/graphql-tools/schema-directives/#implementing-schema-directives) depending on the tool set you use in your project, e.g. `graphql-tools` or `ApolloServer`. + +Here is an example using the [`graphql-tools`](https://github.com/apollographql/graphql-tools): + +```typescript +import { SchemaDirectiveVisitor } from "graphql-tools"; + +// build the schema as always +const schema = buildSchemaSync({ + resolvers: [SampleResolver], +}); + +// register the used directives implementations +SchemaDirectiveVisitor.visitSchemaDirectives(schema, { + sample: SampleDirective, +}); +``` diff --git a/package-lock.json b/package-lock.json index a19c5af18..e7e46dc86 100644 --- a/package-lock.json +++ b/package-lock.json @@ -820,6 +820,601 @@ "upath": "^1.1.0" } }, + "fsevents": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.7.tgz", + "integrity": "sha512-Pxm6sI2MeBD7RdD12RYsqaP0nMiwx8eZBXCa6z2L+mRHm2DYrOYwihmhjpkdjUHwQhslWQjRpEgNq4XvBmaAuw==", + "dev": true, + "optional": true, + "requires": { + "nan": "^2.9.2", + "node-pre-gyp": "^0.10.0" + }, + "dependencies": { + "abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "dev": true, + "optional": true + }, + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", + "dev": true + }, + "aproba": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", + "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==", + "dev": true, + "optional": true + }, + "are-we-there-yet": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz", + "integrity": "sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w==", + "dev": true, + "optional": true, + "requires": { + "delegates": "^1.0.0", + "readable-stream": "^2.0.6" + } + }, + "balanced-match": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", + "dev": true + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "chownr": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.1.tgz", + "integrity": "sha512-j38EvO5+LHX84jlo6h4UzmOwi0UgW61WRyPtJz4qaadK5eY3BTS5TY/S1Stc3Uk2lIM6TPevAlULiEJwie860g==", + "dev": true, + "optional": true + }, + "code-point-at": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", + "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=", + "dev": true + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true + }, + "console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=", + "dev": true + }, + "core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", + "dev": true, + "optional": true + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "optional": true, + "requires": { + "ms": "2.0.0" + } + }, + "deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "optional": true + }, + "delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=", + "dev": true, + "optional": true + }, + "detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=", + "dev": true, + "optional": true + }, + "fs-minipass": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-1.2.5.tgz", + "integrity": "sha512-JhBl0skXjUPCFH7x6x61gQxrKyXsxB5gcgePLZCwfyCGGsTISMoIeObbrvVeP6Xmyaudw4TT43qV2Gz+iyd2oQ==", + "dev": true, + "optional": true, + "requires": { + "minipass": "^2.2.1" + } + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "dev": true, + "optional": true + }, + "gauge": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz", + "integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=", + "dev": true, + "optional": true, + "requires": { + "aproba": "^1.0.3", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.0", + "object-assign": "^4.1.0", + "signal-exit": "^3.0.0", + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1", + "wide-align": "^1.1.0" + } + }, + "glob": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.3.tgz", + "integrity": "sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==", + "dev": true, + "optional": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=", + "dev": true, + "optional": true + }, + "iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "optional": true, + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, + "ignore-walk": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-3.0.1.tgz", + "integrity": "sha512-DTVlMx3IYPe0/JJcYP7Gxg7ttZZu3IInhuEhbchuqneY9wWe5Ojy2mXLBaQFUQmo0AW2r3qG7m1mg86js+gnlQ==", + "dev": true, + "optional": true, + "requires": { + "minimatch": "^3.0.4" + } + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "dev": true, + "optional": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", + "dev": true + }, + "ini": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz", + "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==", + "dev": true, + "optional": true + }, + "is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", + "dev": true, + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true, + "optional": true + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", + "dev": true + }, + "minipass": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.3.5.tgz", + "integrity": "sha512-Gi1W4k059gyRbyVUZQ4mEqLm0YIUiGYfvxhF6SIlk3ui1WVxMTGfGdQ2SInh3PDrRTVvPKgULkpJtT4RH10+VA==", + "dev": true, + "requires": { + "safe-buffer": "^5.1.2", + "yallist": "^3.0.0" + } + }, + "minizlib": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-1.2.1.tgz", + "integrity": "sha512-7+4oTUOWKg7AuL3vloEWekXY2/D20cevzsrNT2kGWm+39J9hGTCBv8VI5Pm5lXZ/o3/mdR4f8rflAPhnQb8mPA==", + "dev": true, + "optional": true, + "requires": { + "minipass": "^2.2.1" + } + }, + "mkdirp": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "dev": true, + "requires": { + "minimist": "0.0.8" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true, + "optional": true + }, + "needle": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/needle/-/needle-2.2.4.tgz", + "integrity": "sha512-HyoqEb4wr/rsoaIDfTH2aVL9nWtQqba2/HvMv+++m8u0dz808MaagKILxtfeSN7QU7nvbQ79zk3vYOJp9zsNEA==", + "dev": true, + "optional": true, + "requires": { + "debug": "^2.1.2", + "iconv-lite": "^0.4.4", + "sax": "^1.2.4" + } + }, + "node-pre-gyp": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/node-pre-gyp/-/node-pre-gyp-0.10.3.tgz", + "integrity": "sha512-d1xFs+C/IPS8Id0qPTZ4bUT8wWryfR/OzzAFxweG+uLN85oPzyo2Iw6bVlLQ/JOdgNonXLCoRyqDzDWq4iw72A==", + "dev": true, + "optional": true, + "requires": { + "detect-libc": "^1.0.2", + "mkdirp": "^0.5.1", + "needle": "^2.2.1", + "nopt": "^4.0.1", + "npm-packlist": "^1.1.6", + "npmlog": "^4.0.2", + "rc": "^1.2.7", + "rimraf": "^2.6.1", + "semver": "^5.3.0", + "tar": "^4" + } + }, + "nopt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-4.0.1.tgz", + "integrity": "sha1-0NRoWv1UFRk8jHUFYC0NF81kR00=", + "dev": true, + "optional": true, + "requires": { + "abbrev": "1", + "osenv": "^0.1.4" + } + }, + "npm-bundled": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-1.0.5.tgz", + "integrity": "sha512-m/e6jgWu8/v5niCUKQi9qQl8QdeEduFA96xHDDzFGqly0OOjI7c+60KM/2sppfnUU9JJagf+zs+yGhqSOFj71g==", + "dev": true, + "optional": true + }, + "npm-packlist": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-1.2.0.tgz", + "integrity": "sha512-7Mni4Z8Xkx0/oegoqlcao/JpPCPEMtUvsmB0q7mgvlMinykJLSRTYuFqoQLYgGY8biuxIeiHO+QNJKbCfljewQ==", + "dev": true, + "optional": true, + "requires": { + "ignore-walk": "^3.0.1", + "npm-bundled": "^1.0.1" + } + }, + "npmlog": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz", + "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==", + "dev": true, + "optional": true, + "requires": { + "are-we-there-yet": "~1.1.2", + "console-control-strings": "~1.1.0", + "gauge": "~2.7.3", + "set-blocking": "~2.0.0" + } + }, + "number-is-nan": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", + "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=", + "dev": true + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", + "dev": true, + "optional": true + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dev": true, + "requires": { + "wrappy": "1" + } + }, + "os-homedir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", + "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=", + "dev": true, + "optional": true + }, + "os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", + "dev": true, + "optional": true + }, + "osenv": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/osenv/-/osenv-0.1.5.tgz", + "integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==", + "dev": true, + "optional": true, + "requires": { + "os-homedir": "^1.0.0", + "os-tmpdir": "^1.0.0" + } + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true, + "optional": true + }, + "process-nextick-args": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz", + "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==", + "dev": true, + "optional": true + }, + "rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "optional": true, + "requires": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "dependencies": { + "minimist": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", + "dev": true, + "optional": true + } + } + }, + "readable-stream": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", + "dev": true, + "optional": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "rimraf": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "dev": true, + "optional": true, + "requires": { + "glob": "^7.1.3" + } + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "optional": true + }, + "sax": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", + "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==", + "dev": true, + "optional": true + }, + "semver": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.6.0.tgz", + "integrity": "sha512-RS9R6R35NYgQn++fkDWaOmqGoj4Ek9gGs+DPxNUZKuwE183xjJroKvyo1IzVFeXvUrvmALy6FWD5xrdJT25gMg==", + "dev": true, + "optional": true + }, + "set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=", + "dev": true, + "optional": true + }, + "signal-exit": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", + "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=", + "dev": true, + "optional": true + }, + "string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", + "dev": true, + "requires": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "optional": true, + "requires": { + "safe-buffer": "~5.1.0" + } + }, + "strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", + "dev": true, + "optional": true + }, + "tar": { + "version": "4.4.8", + "resolved": "https://registry.npmjs.org/tar/-/tar-4.4.8.tgz", + "integrity": "sha512-LzHF64s5chPQQS0IYBn9IN5h3i98c12bo4NCO7e0sGM2llXQ3p2FGC5sdENN4cTW48O915Sh+x+EXx7XW96xYQ==", + "dev": true, + "optional": true, + "requires": { + "chownr": "^1.1.1", + "fs-minipass": "^1.2.5", + "minipass": "^2.3.4", + "minizlib": "^1.1.1", + "mkdirp": "^0.5.0", + "safe-buffer": "^5.1.2", + "yallist": "^3.0.2" + } + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", + "dev": true, + "optional": true + }, + "wide-align": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz", + "integrity": "sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==", + "dev": true, + "optional": true, + "requires": { + "string-width": "^1.0.2 || 2" + } + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "dev": true + }, + "yallist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.0.3.tgz", + "integrity": "sha512-S+Zk8DEWE6oKpV+vI3qWkaK+jSbIK86pCwe2IF/xwIpQ8jEuxpw9NyaGjmp9+BoJv5FV2piqCDcoCtStppiq2A==", + "dev": true + } + } + }, "is-glob": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.0.tgz", diff --git a/src/decorators/Directive.ts b/src/decorators/Directive.ts new file mode 100644 index 000000000..6dbeeb666 --- /dev/null +++ b/src/decorators/Directive.ts @@ -0,0 +1,33 @@ +import { MethodAndPropDecorator } from "./types"; +import { SymbolKeysNotSupportedError } from "../errors"; +import { getMetadataStorage } from "../metadata/getMetadataStorage"; + +export function Directive(sdl: string): MethodAndPropDecorator & ClassDecorator; +export function Directive( + name: string, + args?: Record, +): MethodAndPropDecorator & ClassDecorator; +export function Directive( + nameOrDefinition: string, + args: Record = {}, +): MethodDecorator | PropertyDecorator | ClassDecorator { + return (targetOrPrototype, propertyKey, descriptor) => { + const directive = { nameOrDefinition, args }; + + if (typeof propertyKey === "symbol") { + throw new SymbolKeysNotSupportedError(); + } + if (propertyKey) { + getMetadataStorage().collectDirectiveFieldMetadata({ + target: targetOrPrototype.constructor, + fieldName: propertyKey, + directive, + }); + } else { + getMetadataStorage().collectDirectiveClassMetadata({ + target: targetOrPrototype as Function, + directive, + }); + } + }; +} diff --git a/src/decorators/index.ts b/src/decorators/index.ts index 11dbbd9db..b6fc8caf6 100644 --- a/src/decorators/index.ts +++ b/src/decorators/index.ts @@ -5,6 +5,7 @@ export { Authorized } from "./Authorized"; export { createParamDecorator } from "./createParamDecorator"; export { createMethodDecorator } from "./createMethodDecorator"; export { Ctx } from "./Ctx"; +export { Directive } from "./Directive"; export { registerEnumType } from "./enums"; export { Field } from "./Field"; export { FieldResolver } from "./FieldResolver"; diff --git a/src/errors/InvalidDirectiveError.ts b/src/errors/InvalidDirectiveError.ts new file mode 100644 index 000000000..a13d179e8 --- /dev/null +++ b/src/errors/InvalidDirectiveError.ts @@ -0,0 +1,6 @@ +export class InvalidDirectiveError extends Error { + constructor(msg: string) { + super(msg); + Object.setPrototypeOf(this, new.target.prototype); + } +} diff --git a/src/errors/index.ts b/src/errors/index.ts index af0125e3c..b5d2a705f 100644 --- a/src/errors/index.ts +++ b/src/errors/index.ts @@ -5,6 +5,7 @@ export * from "./GeneratingSchemaError"; export * from "./ConflictingDefaultValuesError"; export * from "./ConflictingDefaultWithNullableError"; export * from "./InterfaceResolveTypeError"; +export * from "./InvalidDirectiveError"; export * from "./MissingSubscriptionTopicsError"; export * from "./NoExplicitTypeError"; export * from "./ReflectMetadataMissingError"; diff --git a/src/interfaces/ResolverInterface.ts b/src/interfaces/ResolverInterface.ts index 7fa2d80f2..966b159f2 100644 --- a/src/interfaces/ResolverInterface.ts +++ b/src/interfaces/ResolverInterface.ts @@ -3,5 +3,5 @@ * to provide a proper resolver method signatures for fields of T. */ export type ResolverInterface = { - [P in keyof T]?: (root: T, ...args: any[]) => T[P] | Promise + [P in keyof T]?: (root: T, ...args: any[]) => T[P] | Promise; }; diff --git a/src/metadata/definitions/class-metadata.ts b/src/metadata/definitions/class-metadata.ts index 067831218..be81b5a17 100644 --- a/src/metadata/definitions/class-metadata.ts +++ b/src/metadata/definitions/class-metadata.ts @@ -1,4 +1,5 @@ import { FieldMetadata } from "./field-metadata"; +import { DirectiveMetadata } from "./directive-metadata"; export interface ClassMetadata { name: string; @@ -6,4 +7,5 @@ export interface ClassMetadata { fields?: FieldMetadata[]; description?: string; isAbstract?: boolean; + directives?: DirectiveMetadata[]; } diff --git a/src/metadata/definitions/directive-metadata.ts b/src/metadata/definitions/directive-metadata.ts new file mode 100644 index 000000000..b5c46b169 --- /dev/null +++ b/src/metadata/definitions/directive-metadata.ts @@ -0,0 +1,15 @@ +export interface DirectiveMetadata { + nameOrDefinition: string; + args: Record; +} + +export interface DirectiveClassMetadata { + target: Function; + directive: DirectiveMetadata; +} + +export interface DirectiveFieldMetadata { + target: Function; + fieldName: string; + directive: DirectiveMetadata; +} diff --git a/src/metadata/definitions/field-metadata.ts b/src/metadata/definitions/field-metadata.ts index 7d3560f85..6b86838c9 100644 --- a/src/metadata/definitions/field-metadata.ts +++ b/src/metadata/definitions/field-metadata.ts @@ -2,6 +2,7 @@ import { ParamMetadata } from "./param-metadata"; import { TypeValueThunk, TypeOptions } from "../../decorators/types"; import { Middleware } from "../../interfaces/Middleware"; import { Complexity } from "../../interfaces"; +import { DirectiveMetadata } from "./directive-metadata"; export interface FieldMetadata { target: Function; @@ -15,4 +16,5 @@ export interface FieldMetadata { params?: ParamMetadata[]; roles?: any[]; middlewares?: Array>; + directives?: DirectiveMetadata[]; } diff --git a/src/metadata/definitions/index.ts b/src/metadata/definitions/index.ts index 15aa7d652..70a7d6ea9 100644 --- a/src/metadata/definitions/index.ts +++ b/src/metadata/definitions/index.ts @@ -1,5 +1,6 @@ export * from "./authorized-metadata"; export * from "./class-metadata"; +export * from "./directive-metadata"; export * from "./enum-metadata"; export * from "./field-metadata"; export * from "./middleware-metadata"; diff --git a/src/metadata/definitions/resolver-metadata.ts b/src/metadata/definitions/resolver-metadata.ts index 04a1bc669..be349ba34 100644 --- a/src/metadata/definitions/resolver-metadata.ts +++ b/src/metadata/definitions/resolver-metadata.ts @@ -10,6 +10,7 @@ import { import { ParamMetadata } from "./param-metadata"; import { Middleware } from "../../interfaces/Middleware"; import { Complexity } from "../../interfaces"; +import { DirectiveMetadata } from "./directive-metadata"; export interface BaseResolverMetadata { methodName: string; @@ -20,6 +21,7 @@ export interface BaseResolverMetadata { params?: ParamMetadata[]; roles?: any[]; middlewares?: Array>; + directives?: DirectiveMetadata[]; } export interface ResolverMetadata extends BaseResolverMetadata { diff --git a/src/metadata/metadata-storage.ts b/src/metadata/metadata-storage.ts index 8a5adc3fe..715fbdb74 100644 --- a/src/metadata/metadata-storage.ts +++ b/src/metadata/metadata-storage.ts @@ -23,6 +23,7 @@ import { } from "./utils"; import { ObjectClassMetadata } from "./definitions/object-class-metdata"; import { InterfaceClassMetadata } from "./definitions/interface-class-metadata"; +import { DirectiveClassMetadata, DirectiveFieldMetadata } from "./definitions/directive-metadata"; export class MetadataStorage { queries: ResolverMetadata[] = []; @@ -37,6 +38,8 @@ export class MetadataStorage { enums: EnumMetadata[] = []; unions: UnionMetadataWithSymbol[] = []; middlewares: MiddlewareMetadata[] = []; + classDirectives: DirectiveClassMetadata[] = []; + fieldDirectives: DirectiveFieldMetadata[] = []; private resolverClasses: ResolverClassMetadata[] = []; private fields: FieldMetadata[] = []; @@ -98,9 +101,19 @@ export class MetadataStorage { this.params.push(definition); } + collectDirectiveClassMetadata(definition: DirectiveClassMetadata) { + this.classDirectives.push(definition); + } + collectDirectiveFieldMetadata(definition: DirectiveFieldMetadata) { + this.fieldDirectives.push(definition); + } + build() { // TODO: disable next build attempts + this.classDirectives.reverse(); + this.fieldDirectives.reverse(); + this.buildClassMetadata(this.objectTypes); this.buildClassMetadata(this.inputTypes); this.buildClassMetadata(this.argumentTypes); @@ -128,6 +141,8 @@ export class MetadataStorage { this.enums = []; this.unions = []; this.middlewares = []; + this.classDirectives = []; + this.fieldDirectives = []; this.resolverClasses = []; this.fields = []; @@ -147,8 +162,14 @@ export class MetadataStorage { middleware => middleware.target === field.target && middleware.fieldName === field.name, ), ); + field.directives = this.fieldDirectives + .filter(it => it.target === field.target && it.fieldName === field.name) + .map(it => it.directive); }); def.fields = fields; + def.directives = this.classDirectives + .filter(it => it.target === def.target) + .map(it => it.directive); }); } @@ -167,6 +188,9 @@ export class MetadataStorage { middleware => middleware.target === def.target && def.methodName === middleware.fieldName, ), ); + def.directives = this.fieldDirectives + .filter(it => it.target === def.target && it.fieldName === def.methodName) + .map(it => it.directive); }); } diff --git a/src/schema/definition-node.ts b/src/schema/definition-node.ts new file mode 100644 index 000000000..c76a51744 --- /dev/null +++ b/src/schema/definition-node.ts @@ -0,0 +1,155 @@ +import { + ObjectTypeDefinitionNode, + InputObjectTypeDefinitionNode, + GraphQLOutputType, + FieldDefinitionNode, + GraphQLInputType, + InputValueDefinitionNode, + DirectiveNode, + parseValue, + DocumentNode, + parse, +} from "graphql"; + +import { InvalidDirectiveError } from "../errors"; +import { DirectiveMetadata } from "../metadata/definitions"; + +export function getObjectTypeDefinitionNode( + name: string, + directiveMetadata?: DirectiveMetadata[], +): ObjectTypeDefinitionNode | undefined { + if (!directiveMetadata || !directiveMetadata.length) { + return; + } + + return { + kind: "ObjectTypeDefinition", + name: { + kind: "Name", + // FIXME: use proper AST representation + value: name, + }, + directives: directiveMetadata.map(getDirectiveNode), + }; +} + +export function getInputObjectTypeDefinitionNode( + name: string, + directiveMetadata?: DirectiveMetadata[], +): InputObjectTypeDefinitionNode | undefined { + if (!directiveMetadata || !directiveMetadata.length) { + return; + } + + return { + kind: "InputObjectTypeDefinition", + name: { + kind: "Name", + // FIXME: use proper AST representation + value: name, + }, + directives: directiveMetadata.map(getDirectiveNode), + }; +} + +export function getFieldDefinitionNode( + name: string, + type: GraphQLOutputType, + directiveMetadata?: DirectiveMetadata[], +): FieldDefinitionNode | undefined { + if (!directiveMetadata || !directiveMetadata.length) { + return; + } + + return { + kind: "FieldDefinition", + type: { + kind: "NamedType", + name: { + kind: "Name", + value: type.toString(), + }, + }, + name: { + kind: "Name", + value: name, + }, + directives: directiveMetadata.map(getDirectiveNode), + }; +} + +export function getInputValueDefinitionNode( + name: string, + type: GraphQLInputType, + directiveMetadata?: DirectiveMetadata[], +): InputValueDefinitionNode | undefined { + if (!directiveMetadata || !directiveMetadata.length) { + return; + } + + return { + kind: "InputValueDefinition", + type: { + kind: "NamedType", + name: { + kind: "Name", + value: type.toString(), + }, + }, + name: { + kind: "Name", + value: name, + }, + directives: directiveMetadata.map(getDirectiveNode), + }; +} + +export function getDirectiveNode(directive: DirectiveMetadata): DirectiveNode { + const { nameOrDefinition, args } = directive; + + if (nameOrDefinition === "") { + throw new InvalidDirectiveError( + "Please pass at-least one directive name or definition to the @Directive decorator", + ); + } + + if (!nameOrDefinition.startsWith("@")) { + return { + kind: "Directive", + name: { + kind: "Name", + value: nameOrDefinition, + }, + arguments: Object.keys(args).map(argKey => ({ + kind: "Argument", + name: { + kind: "Name", + value: argKey, + }, + value: parseValue(args[argKey]), + })), + }; + } + + let parsed: DocumentNode; + try { + parsed = parse(`type String ${nameOrDefinition}`); + } catch (err) { + throw new InvalidDirectiveError( + `Error parsing directive definition "${directive.nameOrDefinition}"`, + ); + } + + const definitions = parsed.definitions as ObjectTypeDefinitionNode[]; + const directives = definitions + .filter(it => it.directives && it.directives.length > 0) + .map(it => it.directives!) + .reduce((acc, item) => [...acc, ...item]); // flatten the array + + if (directives.length !== 1) { + throw new InvalidDirectiveError( + `Please pass only one directive name or definition at a time to the @Directive decorator "${directive.nameOrDefinition}"`, + ); + } + return directives[0]; +} diff --git a/src/schema/schema-generator.ts b/src/schema/schema-generator.ts index 473bcbf85..6c77b29db 100644 --- a/src/schema/schema-generator.ts +++ b/src/schema/schema-generator.ts @@ -15,6 +15,7 @@ import { GraphQLEnumValueConfigMap, GraphQLUnionType, GraphQLTypeResolver, + GraphQLDirective, } from "graphql"; import { withFilter, ResolverFn } from "graphql-subscriptions"; @@ -43,6 +44,12 @@ import { import { ResolverFilterData, ResolverTopicData, TypeResolver } from "../interfaces"; import { getFieldMetadataFromInputType, getFieldMetadataFromObjectType } from "./utils"; import { ensureInstalledCorrectGraphQLPackage } from "../utils/graphql-version"; +import { + getFieldDefinitionNode, + getInputObjectTypeDefinitionNode, + getInputValueDefinitionNode, + getObjectTypeDefinitionNode, +} from "./definition-node"; interface AbstractInfo { isAbstract: boolean; @@ -81,6 +88,11 @@ export interface SchemaGeneratorOptions extends BuildContextOptions { * Disable checking on build the correctness of a schema */ skipCheck?: boolean; + + /** + * Array of graphql directives + */ + directives?: GraphQLDirective[]; } export abstract class SchemaGenerator { @@ -113,6 +125,7 @@ export abstract class SchemaGenerator { mutation: this.buildRootMutationType(options.resolvers), subscription: this.buildRootSubscriptionType(options.resolvers), types: this.buildOtherTypes(orphanedTypes), + directives: options.directives, }); BuildContext.reset(); @@ -268,6 +281,7 @@ export abstract class SchemaGenerator { type: new GraphQLObjectType({ name: objectType.name, description: objectType.description, + astNode: getObjectTypeDefinitionNode(objectType.name, objectType.directives), interfaces: () => { let interfaces = interfaceClasses.map( interfaceClass => @@ -293,14 +307,20 @@ export abstract class SchemaGenerator { (resolver.resolverClassMetadata === undefined || resolver.resolverClassMetadata.isAbstract === false), ); + const type = this.getGraphQLOutputType( + field.name, + field.getType(), + field.typeOptions, + ); fieldsMap[field.schemaName] = { - type: this.getGraphQLOutputType(field.name, field.getType(), field.typeOptions), + type, args: this.generateHandlerArgs(field.params!), resolve: fieldResolverMetadata ? createAdvancedFieldResolver(fieldResolverMetadata) : createSimpleFieldResolver(field), description: field.description, deprecationReason: field.deprecationReason, + astNode: getFieldDefinitionNode(field.name, type, field.directives), extensions: { complexity: field.complexity, }, @@ -361,10 +381,16 @@ export abstract class SchemaGenerator { inputType.name, ); + const type = this.getGraphQLInputType( + field.name, + field.getType(), + field.typeOptions, + ); fieldsMap[field.schemaName] = { description: field.description, - type: this.getGraphQLInputType(field.name, field.getType(), field.typeOptions), + type, defaultValue: field.typeOptions.defaultValue, + astNode: getInputValueDefinitionNode(field.name, type, field.directives), }; return fieldsMap; }, @@ -380,6 +406,7 @@ export abstract class SchemaGenerator { } return fields; }, + astNode: getInputObjectTypeDefinitionNode(inputType.name, inputType.directives), }), }; }); @@ -449,16 +476,18 @@ export abstract class SchemaGenerator { if (handler.resolverClassMetadata && handler.resolverClassMetadata.isAbstract) { return fields; } + const type = this.getGraphQLOutputType( + handler.methodName, + handler.getReturnType(), + handler.returnTypeOptions, + ); fields[handler.schemaName] = { - type: this.getGraphQLOutputType( - handler.methodName, - handler.getReturnType(), - handler.returnTypeOptions, - ), + type, args: this.generateHandlerArgs(handler.params!), resolve: createHandlerResolver(handler), description: handler.description, deprecationReason: handler.deprecationReason, + astNode: getFieldDefinitionNode(handler.schemaName, type, handler.directives), extensions: { complexity: handler.complexity, }, diff --git a/tests/functional/directives.ts b/tests/functional/directives.ts new file mode 100644 index 000000000..785832307 --- /dev/null +++ b/tests/functional/directives.ts @@ -0,0 +1,477 @@ +// tslint:disable:member-ordering +import "reflect-metadata"; + +import { GraphQLSchema, graphql, GraphQLInputObjectType } from "graphql"; +import { + Field, + InputType, + Resolver, + Query, + Arg, + Directive, + buildSchema, + ObjectType, + Mutation, +} from "../../src"; +import { getMetadataStorage } from "../../src/metadata/getMetadataStorage"; +import { SchemaDirectiveVisitor } from "graphql-tools"; +import { UpperCaseDirective } from "../helpers/directives/UpperCaseDirective"; +import { AppendDirective } from "../helpers/directives/AppendDirective"; +import { assertValidDirective } from "../helpers/directives/assertValidDirective"; +import { InvalidDirectiveError } from "../../src/errors/InvalidDirectiveError"; + +describe("Directives", () => { + let schema: GraphQLSchema; + + describe("Schema", () => { + beforeAll(async () => { + getMetadataStorage().clear(); + + @InputType() + class DirectiveOnFieldInput { + @Field() + @Directive("@upper") + append: string; + } + + @InputType() + @Directive("@upper") + class DirectiveOnClassInput { + @Field() + append: string; + } + + @ObjectType() + class SampleObjectType { + @Field() + @Directive("foo") + withDirective: string = "withDirective"; + + @Field() + @Directive("bar", { baz: "true" }) + withDirectiveWithArgs: string = "withDirectiveWithArgs"; + + @Field() + @Directive("upper") + withUpper: string = "withUpper"; + + @Field() + @Directive("@upper") + withUpperDefinition: string = "withUpperDefinition"; + + @Field() + @Directive("append") + withAppend: string = "hello"; + + @Field() + @Directive("@append") + withAppendDefinition: string = "hello"; + + @Field() + @Directive("append") + @Directive("upper") + withUpperAndAppend: string = "hello"; + + @Field() + withInput(@Arg("input") input: DirectiveOnFieldInput): string { + return `hello${input.append}`; + } + + @Field() + @Directive("upper") + withInputUpper(@Arg("input") input: DirectiveOnFieldInput): string { + return `hello${input.append}`; + } + + @Field() + withInputOnClass(@Arg("input") input: DirectiveOnClassInput): string { + return `hello${input.append}`; + } + + @Field() + @Directive("upper") + withInputUpperOnClass(@Arg("input") input: DirectiveOnClassInput): string { + return `hello${input.append}`; + } + } + + @Resolver() + class SampleResolver { + @Query(() => SampleObjectType) + objectType(): SampleObjectType { + return new SampleObjectType(); + } + + @Query() + @Directive("foo") + queryWithDirective(): string { + return "queryWithDirective"; + } + + @Query() + @Directive("bar", { baz: "true" }) + queryWithDirectiveWithArgs(): string { + return "queryWithDirectiveWithArgs"; + } + + @Query() + @Directive("upper") + queryWithUpper(): string { + return "queryWithUpper"; + } + + @Query() + @Directive("@upper") + queryWithUpperDefinition(): string { + return "queryWithUpper"; + } + + @Query() + @Directive("append") + queryWithAppend(): string { + return "hello"; + } + + @Query() + @Directive("@append") + queryWithAppendDefinition(): string { + return "hello"; + } + + @Query() + @Directive("append") + @Directive("upper") + queryWithUpperAndAppend(): string { + return "hello"; + } + + @Mutation() + @Directive("foo") + mutationWithDirective(): string { + return "mutationWithDirective"; + } + + @Mutation() + @Directive("bar", { baz: "true" }) + mutationWithDirectiveWithArgs(): string { + return "mutationWithDirectiveWithArgs"; + } + + @Mutation() + @Directive("upper") + mutationWithUpper(): string { + return "mutationWithUpper"; + } + + @Mutation() + @Directive("@upper") + mutationWithUpperDefinition(): string { + return "mutationWithUpper"; + } + + @Mutation() + @Directive("append") + mutationWithAppend(): string { + return "hello"; + } + + @Mutation() + @Directive("@append") + mutationWithAppendDefinition(): string { + return "hello"; + } + + @Mutation() + @Directive("append") + @Directive("upper") + mutationWithUpperAndAppend(): string { + return "hello"; + } + } + + schema = await buildSchema({ + resolvers: [SampleResolver], + }); + + SchemaDirectiveVisitor.visitSchemaDirectives(schema, { + upper: UpperCaseDirective, + append: AppendDirective, + }); + }); + + it("should generate schema without errors", async () => { + expect(schema).toBeDefined(); + }); + + describe("Query", () => { + it("should add directives to query types", async () => { + const queryWithDirective = schema.getQueryType()!.getFields().queryWithDirective; + + assertValidDirective(queryWithDirective.astNode, "foo"); + }); + + it("should add directives to query types with arguments", async () => { + const queryWithDirectiveWithArgs = schema.getQueryType()!.getFields() + .queryWithDirectiveWithArgs; + + assertValidDirective(queryWithDirectiveWithArgs.astNode, "bar", { baz: "true" }); + }); + + it("calls directive 'upper'", async () => { + const query = `query { + queryWithUpper + }`; + + const { data } = await graphql(schema, query); + + expect(data).toHaveProperty("queryWithUpper"); + expect(data.queryWithUpper).toBe("QUERYWITHUPPER"); + }); + + it("calls directive 'upper' using Definition", async () => { + const query = `query { + queryWithUpperDefinition + }`; + + const { data } = await graphql(schema, query); + + expect(data).toHaveProperty("queryWithUpperDefinition"); + expect(data.queryWithUpperDefinition).toBe("QUERYWITHUPPER"); + }); + + it("calls directive 'append'", async () => { + const query = `query { + queryWithAppend(append: ", world!") + }`; + + const { data } = await graphql(schema, query); + + expect(data).toHaveProperty("queryWithAppend"); + expect(data.queryWithAppend).toBe("hello, world!"); + }); + + it("calls directive 'append' using Definition", async () => { + const query = `query { + queryWithAppendDefinition(append: ", world!") + }`; + + const { data } = await graphql(schema, query); + + expect(data).toHaveProperty("queryWithAppendDefinition"); + expect(data.queryWithAppendDefinition).toBe("hello, world!"); + }); + + it("calls directive 'upper' and 'append'", async () => { + const query = `query { + queryWithUpperAndAppend(append: ", world!") + }`; + + const { data } = await graphql(schema, query); + + expect(data).toHaveProperty("queryWithUpperAndAppend"); + expect(data.queryWithUpperAndAppend).toBe("HELLO, WORLD!"); + }); + }); + + describe("Mutation", () => { + it("should add directives to mutation types", async () => { + const mutationWithDirective = schema.getMutationType()!.getFields().mutationWithDirective; + + assertValidDirective(mutationWithDirective.astNode, "foo"); + }); + + it("should add directives to mutation types with arguments", async () => { + const mutationWithDirectiveWithArgs = schema.getMutationType()!.getFields() + .mutationWithDirectiveWithArgs; + + assertValidDirective(mutationWithDirectiveWithArgs.astNode, "bar", { baz: "true" }); + }); + + it("calls directive 'upper'", async () => { + const mutation = `mutation { + mutationWithUpper + }`; + + const { data } = await graphql(schema, mutation); + + expect(data).toHaveProperty("mutationWithUpper"); + expect(data.mutationWithUpper).toBe("MUTATIONWITHUPPER"); + }); + + it("calls directive 'upper' using Definition", async () => { + const mutation = `mutation { + mutationWithUpperDefinition + }`; + + const { data } = await graphql(schema, mutation); + + expect(data).toHaveProperty("mutationWithUpperDefinition"); + expect(data.mutationWithUpperDefinition).toBe("MUTATIONWITHUPPER"); + }); + + it("calls directive 'append'", async () => { + const mutation = `mutation { + mutationWithAppend(append: ", world!") + }`; + + const { data } = await graphql(schema, mutation); + + expect(data).toHaveProperty("mutationWithAppend"); + expect(data.mutationWithAppend).toBe("hello, world!"); + }); + + it("calls directive 'append' using Definition", async () => { + const mutation = `mutation { + mutationWithAppendDefinition(append: ", world!") + }`; + + const { data } = await graphql(schema, mutation); + + expect(data).toHaveProperty("mutationWithAppendDefinition"); + expect(data.mutationWithAppendDefinition).toBe("hello, world!"); + }); + + it("calls directive 'upper' and 'append'", async () => { + const mutation = `mutation { + mutationWithUpperAndAppend(append: ", world!") + }`; + + const { data } = await graphql(schema, mutation); + + expect(data).toHaveProperty("mutationWithUpperAndAppend"); + expect(data.mutationWithUpperAndAppend).toBe("HELLO, WORLD!"); + }); + }); + + describe("InputType", () => { + it("adds field directive to input types", async () => { + const inputType = schema.getType("DirectiveOnClassInput") as GraphQLInputObjectType; + + expect(inputType).toHaveProperty("astNode"); + assertValidDirective(inputType.astNode, "upper"); + }); + + it("adds field directives to input type fields", async () => { + const fields = (schema.getType( + "DirectiveOnFieldInput", + ) as GraphQLInputObjectType).getFields(); + + expect(fields).toHaveProperty("append"); + expect(fields.append).toHaveProperty("astNode"); + assertValidDirective(fields.append.astNode, "upper"); + }); + }); + + describe("ObjectType", () => { + it("calls object type directives", async () => { + const query = `query { + objectType { + withDirective + withDirectiveWithArgs + withUpper + withUpperDefinition + withAppend(append: ", world!") + withAppendDefinition(append: ", world!") + withUpperAndAppend(append: ", world!") + withInput(input: { append: ", world!" }) + withInputUpper(input: { append: ", world!" }) + withInputOnClass(input: { append: ", world!" }) + withInputUpperOnClass(input: { append: ", world!" }) + } + }`; + + const { data } = await graphql(schema, query); + + expect(data).toHaveProperty("objectType"); + expect(data.objectType).toEqual({ + withDirective: "withDirective", + withDirectiveWithArgs: "withDirectiveWithArgs", + withUpper: "WITHUPPER", + withUpperDefinition: "WITHUPPERDEFINITION", + withAppend: "hello, world!", + withAppendDefinition: "hello, world!", + withUpperAndAppend: "HELLO, WORLD!", + withInput: "hello, WORLD!", + withInputUpper: "HELLO, WORLD!", + withInputOnClass: "hello, WORLD!", + withInputUpperOnClass: "HELLO, WORLD!", + }); + }); + }); + }); + + describe("errors", () => { + beforeEach(async () => { + getMetadataStorage().clear(); + }); + + it("throws error on multiple directive definitions", async () => { + expect.assertions(2); + + @Resolver() + class InvalidQuery { + @Query() + @Directive("@upper @append") + invalid(): string { + return "invalid"; + } + } + + try { + await buildSchema({ resolvers: [InvalidQuery] }); + } catch (err) { + expect(err).toBeInstanceOf(InvalidDirectiveError); + const error: InvalidDirectiveError = err; + expect(error.message).toContain( + 'Please pass only one directive name or definition at a time to the @Directive decorator "@upper @append"', + ); + } + }); + + it("throws error when parsing invalid directives", async () => { + expect.assertions(2); + + @Resolver() + class InvalidQuery { + @Query() + @Directive("@invalid(@directive)") + invalid(): string { + return "invalid"; + } + } + + try { + await buildSchema({ resolvers: [InvalidQuery] }); + } catch (err) { + expect(err).toBeInstanceOf(InvalidDirectiveError); + const error: InvalidDirectiveError = err; + expect(error.message).toContain( + 'Error parsing directive definition "@invalid(@directive)"', + ); + } + }); + + it("throws error when no directives are defined", async () => { + expect.assertions(2); + + @Resolver() + class InvalidQuery { + @Query() + @Directive("") + invalid(): string { + return "invalid"; + } + } + + try { + await buildSchema({ resolvers: [InvalidQuery] }); + } catch (err) { + expect(err).toBeInstanceOf(InvalidDirectiveError); + const error: InvalidDirectiveError = err; + expect(error.message).toContain( + "Please pass at-least one directive name or definition to the @Directive decorator", + ); + } + }); + }); +}); diff --git a/tests/helpers/directives/AppendDirective.ts b/tests/helpers/directives/AppendDirective.ts new file mode 100644 index 000000000..8f017794d --- /dev/null +++ b/tests/helpers/directives/AppendDirective.ts @@ -0,0 +1,30 @@ +import { SchemaDirectiveVisitor } from "graphql-tools"; +import { GraphQLField, GraphQLString, GraphQLDirective, DirectiveLocation } from "graphql"; + +export class AppendDirective extends SchemaDirectiveVisitor { + static getDirectiveDeclaration(directiveName: string): GraphQLDirective { + return new GraphQLDirective({ + name: directiveName, + locations: [DirectiveLocation.FIELD_DEFINITION], + }); + } + + visitFieldDefinition(field: GraphQLField) { + const resolve = field.resolve; + + field.args.push({ + name: "append", + description: "Appends a string to the end of a field", + type: GraphQLString, + defaultValue: "", + extensions: {}, + astNode: undefined, + }); + + field.resolve = async function(source, { append, ...otherArgs }, context, info) { + const result = await resolve!.call(this, source, otherArgs, context, info); + + return `${result}${append}`; + }; + } +} diff --git a/tests/helpers/directives/UpperCaseDirective.ts b/tests/helpers/directives/UpperCaseDirective.ts new file mode 100644 index 000000000..9c8979b92 --- /dev/null +++ b/tests/helpers/directives/UpperCaseDirective.ts @@ -0,0 +1,65 @@ +import { + GraphQLField, + GraphQLDirective, + DirectiveLocation, + GraphQLInputObjectType, + GraphQLInputField, + GraphQLScalarType, + GraphQLNonNull, +} from "graphql"; +import { SchemaDirectiveVisitor } from "graphql-tools"; + +class UpperCaseType extends GraphQLScalarType { + constructor(type: any) { + super({ + name: "UpperCase", + parseValue: value => this.upper(type.parseValue(value)), + serialize: value => this.upper(type.serialize(value)), + parseLiteral: ast => this.upper(type.parseLiteral(ast)), + }); + } + + upper(value: any) { + return typeof value === "string" ? value.toUpperCase() : value; + } +} + +export class UpperCaseDirective extends SchemaDirectiveVisitor { + static getDirectiveDeclaration(directiveName: string): GraphQLDirective { + return new GraphQLDirective({ + name: directiveName, + locations: [DirectiveLocation.FIELD_DEFINITION], + }); + } + + visitFieldDefinition(field: GraphQLField) { + this.wrapField(field); + } + + visitInputObject(object: GraphQLInputObjectType) { + const fields = object.getFields(); + + Object.keys(fields).forEach(field => { + this.wrapField(fields[field]); + }); + } + + visitInputFieldDefinition(field: GraphQLInputField) { + this.wrapField(field); + } + + wrapField(field: GraphQLField | GraphQLInputField): void { + if (field.type instanceof UpperCaseType) { + /* noop */ + } else if ( + field.type instanceof GraphQLNonNull && + field.type.ofType instanceof GraphQLScalarType + ) { + field.type = new GraphQLNonNull(new UpperCaseType(field.type.ofType)); + } else if (field.type instanceof GraphQLScalarType) { + field.type = new UpperCaseType(field.type); + } else { + throw new Error(`Not a scalar type: ${field.type}`); + } + } +} diff --git a/tests/helpers/directives/assertValidDirective.ts b/tests/helpers/directives/assertValidDirective.ts new file mode 100644 index 000000000..e50e79cf2 --- /dev/null +++ b/tests/helpers/directives/assertValidDirective.ts @@ -0,0 +1,41 @@ +import { + FieldDefinitionNode, + InputObjectTypeDefinitionNode, + InputValueDefinitionNode, + parseValue, +} from "graphql"; +import Maybe from "graphql/tsutils/Maybe"; + +export function assertValidDirective( + astNode: Maybe, + name: string, + args?: { [key: string]: string }, +): void { + if (!astNode) { + throw new Error(`Directive with name ${name} does not exist`); + } + + const directives = (astNode && astNode.directives) || []; + + const directive = directives.find(it => it.name.kind === "Name" && it.name.value === name); + + if (!directive) { + throw new Error(`Directive with name ${name} does not exist`); + } + + if (!args) { + if (Array.isArray(directive.arguments)) { + expect(directive.arguments).toHaveLength(0); + } else { + expect(directive.arguments).toBeFalsy(); + } + } else { + expect(directive.arguments).toEqual( + Object.keys(args).map(arg => ({ + kind: "Argument", + name: { kind: "Name", value: arg }, + value: parseValue(args[arg]), + })), + ); + } +} diff --git a/website/i18n/en.json b/website/i18n/en.json index 79ea443fa..94b2f79e3 100644 --- a/website/i18n/en.json +++ b/website/i18n/en.json @@ -23,6 +23,9 @@ "dependency-injection": { "title": "Dependency injection" }, + "directives": { + "title": "Directives" + }, "emit-schema": { "title": "Emitting the schema SDL" }, diff --git a/website/sidebars.json b/website/sidebars.json index a69baf93e..bab2e3dcc 100644 --- a/website/sidebars.json +++ b/website/sidebars.json @@ -8,7 +8,7 @@ "resolvers", "bootstrap" ], - "Advanced guides": ["scalars", "enums", "unions", "interfaces", "subscriptions"], + "Advanced guides": ["scalars", "enums", "unions", "interfaces", "subscriptions", "directives"], "Features": [ "dependency-injection", "authorization",