From ccfa24be0cf9803533bcdde2dce3e854f3de0ea5 Mon Sep 17 00:00:00 2001 From: Alexandre Lacheze Date: Mon, 30 Sep 2019 13:07:34 +0000 Subject: [PATCH] feat: complete implementation (fragments, introspection...) BREAKING CHANGE: - Removed support for Observable rootValue - Removed the support for Observable variables: If necessary, it can be done by the user with a simple: ```js variables$.pipe( switchMap(variables => graphql({ schema, source: query, variables })) ) ``` - Removed support for query as Document in graphql(): ```js // previously ok: graphql(schema, gql`query {}`) // now use: execute(schema, gql`query {}`) // or graphql(schema, `query {}`) ``` - Error handling: every expected errors (validation, syntax error, throwing resolver...) are pushed in the error field of the ExecutionResult, the RxJS stream does not throw in these cases anymore --- README.md | 25 +- package-lock.json | 202 +++- package.json | 9 +- .../fieldNotFoundMessageForType-test.ts | 36 - src/__tests__/graphqlObservable-test.ts | 133 ++- src/__tests__/reference/starWarsQuery-test.ts | 97 +- src/__tests__/reference/starWarsSchema.ts | 2 +- .../__tests__/rx-subscriptions-test.ts | 136 +++ src/execution/execute.ts | 912 ++++++++++++++++++ src/execution/index.ts | 1 + src/graphql.ts | 105 ++ src/index.ts | 8 +- src/jstutils/inspect.ts | 6 + src/jstutils/invariant.ts | 5 + src/jstutils/isInvalid.ts | 6 + src/reactive-graphql.ts | 400 -------- src/rxutils/combinePropsLatest.ts | 30 + src/rxutils/mapPromiseToObservale.ts | 15 + src/rxutils/mapToFirstValue.ts | 19 + 19 files changed, 1574 insertions(+), 573 deletions(-) delete mode 100644 src/__tests__/fieldNotFoundMessageForType-test.ts create mode 100644 src/execution/__tests__/rx-subscriptions-test.ts create mode 100644 src/execution/execute.ts create mode 100644 src/execution/index.ts create mode 100644 src/graphql.ts create mode 100644 src/jstutils/inspect.ts create mode 100644 src/jstutils/invariant.ts create mode 100644 src/jstutils/isInvalid.ts delete mode 100644 src/reactive-graphql.ts create mode 100644 src/rxutils/combinePropsLatest.ts create mode 100644 src/rxutils/mapPromiseToObservale.ts create mode 100644 src/rxutils/mapToFirstValue.ts diff --git a/README.md b/README.md index ce92cb2..a2163ca 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,6 @@ Execute GraphQL queries against reactive resolvers (resolvers that return Observable) to get a reactive results. -_This project aims to become a complete GraphQL implementation based around [RxJS](https://github.com/ReactiveX/rxjs)._ - ## Install ``` @@ -19,13 +17,12 @@ $ npm i reactive-graphql --save The usage is very similar to `graphql-js`'s [`graphql`](https://graphql.org/graphql-js/graphql/#graphql) function, except that: - resolvers can return an Observable -- the returned value is an Observable +- the result of a query is an Observable ```js +import { graphql } from "reactive-graphql"; import { makeExecutableSchema } from "graphql-tools"; -import gql from "graphql-tag"; import { timer } from "rxjs"; -import graphql from "reactive-graphql"; const typeDefs = ` type Query { @@ -48,13 +45,13 @@ const schema = makeExecutableSchema({ resolvers }); -const query = gql` +const query = ` query { time } `; -const stream = graphql(query, schema); +const stream = graphql(schema, query); // stream is an Observable stream.subscribe(res => console.log(res)); ``` @@ -70,20 +67,8 @@ outputs ... ``` -## API - -The first argument you pass into `reactive-graphql` is a GraphQL query, either parsed or as string, the second one is an executable schema. You can pass in the root context as an object as a third parameter. The variables can be passed as 4th parameter. - -The implementation will always return an Observable. -If any of the resolvers returns an error the implementation will emit the error on the stream. -Otherwise the data will be wrapped in a `{ data }` object, like most implementations handle this. - ## Caveats - -Unsupported GraphQL features: - -- fragments of all kinds -- subscriptions (as everything is treated as a subscription) +GraphQL Subscriptions are not supported (see [issue #27](https://github.com/mesosphere/reactive-graphql/issues/27)) as everything is treated as subscriptions. ## See Also diff --git a/package-lock.json b/package-lock.json index 074c8cc..93f53db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -328,6 +328,12 @@ "integrity": "sha512-DC8xTuW/6TYgvEg3HEXS7cu9OijFqprVDXXiOcdOKZCU/5PJNLZU37VVvmZHdtMiGOa8wAA/We+JzbdxFzQTRQ==", "dev": true }, + "@types/memoizee": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@types/memoizee/-/memoizee-0.4.2.tgz", + "integrity": "sha512-bhdZXZWKfpkQuuiQjVjnPiNeBHpIAC6rfOFqlJXKD3VC35mCcolfVfXYTnk9Ppee5Mkmmz3Llgec7xCdJAbzWw==", + "dev": true + }, "JSONStream": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.5.tgz", @@ -1501,7 +1507,7 @@ }, "colors": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/colors/-/colors-1.0.3.tgz", + "resolved": "http://registry.npmjs.org/colors/-/colors-1.0.3.tgz", "integrity": "sha1-BDP0TYCWgP3rYO0mDxsMJi6CpAs=", "dev": true }, @@ -1682,6 +1688,15 @@ "array-find-index": "^1.0.1" } }, + "d": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.1.tgz", + "integrity": "sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==", + "requires": { + "es5-ext": "^0.10.50", + "type": "^1.0.1" + } + }, "dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", @@ -1791,7 +1806,6 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz", "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==", - "dev": true, "requires": { "object-keys": "^1.0.12" } @@ -1849,6 +1863,12 @@ } } }, + "delay": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/delay/-/delay-4.3.0.tgz", + "integrity": "sha512-Lwaf3zVFDMBop1yDuFZ19F9WyGcZcGacsbdlZtWjQmM50tOcMntm1njF/Nb/Vjij3KaSvCF+sEYGKrrjObu2NA==", + "dev": true + }, "delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -2016,7 +2036,6 @@ "version": "1.12.0", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.12.0.tgz", "integrity": "sha512-C8Fx/0jFmV5IPoMOFPA9P9G5NtqW+4cOPit3MIuvR2t7Ag2K15EJTpxnHAYTzL+aYQJIESYeXZmDBfOBE1HcpA==", - "dev": true, "requires": { "es-to-primitive": "^1.1.1", "function-bind": "^1.1.1", @@ -2029,13 +2048,32 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.0.tgz", "integrity": "sha512-qZryBOJjV//LaxLTV6UC//WewneB3LcXOL9NP++ozKVXsIIIpm/2c13UDiD9Jp2eThsecw9m3jPqDwTyobcdbg==", - "dev": true, "requires": { "is-callable": "^1.1.4", "is-date-object": "^1.0.1", "is-symbol": "^1.0.2" } }, + "es5-ext": { + "version": "0.10.50", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.50.tgz", + "integrity": "sha512-KMzZTPBkeQV/JcSQhI5/z6d9VWJ3EnQ194USTUwIYZ2ZbpN8+SGXQKt1h68EX44+qt+Fzr8DO17vnxrw7c3agw==", + "requires": { + "es6-iterator": "~2.0.3", + "es6-symbol": "~3.1.1", + "next-tick": "^1.0.0" + } + }, + "es6-iterator": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", + "integrity": "sha1-p96IkUGgWpSwhUQDstCg+/qY87c=", + "requires": { + "d": "1", + "es5-ext": "^0.10.35", + "es6-symbol": "^3.1.1" + } + }, "es6-promise": { "version": "4.2.5", "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.5.tgz", @@ -2044,13 +2082,33 @@ }, "es6-promisify": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz", + "resolved": "http://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz", "integrity": "sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM=", "dev": true, "requires": { "es6-promise": "^4.0.3" } }, + "es6-symbol": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.1.tgz", + "integrity": "sha1-vwDvT9q2uhtG7Le2KbTH7VcVzHc=", + "requires": { + "d": "1", + "es5-ext": "~0.10.14" + } + }, + "es6-weak-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.3.tgz", + "integrity": "sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA==", + "requires": { + "d": "1", + "es5-ext": "^0.10.46", + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.1" + } + }, "escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", @@ -2103,6 +2161,15 @@ "integrity": "sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs=", "dev": true }, + "event-emitter": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", + "integrity": "sha1-34xp7vFkeSPHFXuc6DhAYQsCzDk=", + "requires": { + "d": "1", + "es5-ext": "~0.10.14" + } + }, "exec-sh": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/exec-sh/-/exec-sh-0.2.2.tgz", @@ -3316,8 +3383,7 @@ "function-bind": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", - "dev": true + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" }, "get-caller-file": { "version": "1.0.3", @@ -3452,19 +3518,14 @@ "dev": true }, "graphql": { - "version": "14.0.2", - "resolved": "https://registry.npmjs.org/graphql/-/graphql-14.0.2.tgz", - "integrity": "sha512-gUC4YYsaiSJT1h40krG3J+USGlwhzNTXSb4IOZljn9ag5Tj+RkoXrWp+Kh7WyE3t1NCfab5kzCuxBIvOMERMXw==", + "version": "14.5.4", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-14.5.4.tgz", + "integrity": "sha512-dPLvHoxy5m9FrkqWczPPRnH0X80CyvRE6e7Fa5AWEqEAzg9LpxHvKh24po/482E6VWHigOkAmb4xCp6P9yT9gw==", + "dev": true, "requires": { "iterall": "^1.2.2" } }, - "graphql-tag": { - "version": "2.8.0", - "resolved": "http://registry.npmjs.org/graphql-tag/-/graphql-tag-2.8.0.tgz", - "integrity": "sha1-Us3qB6hCFU7BGi6EDBG5d/m4Nc4=", - "dev": true - }, "graphql-tools": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/graphql-tools/-/graphql-tools-4.0.3.tgz", @@ -3524,7 +3585,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dev": true, "requires": { "function-bind": "^1.1.1" } @@ -3547,8 +3607,7 @@ "has-symbols": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.0.tgz", - "integrity": "sha1-uhqPGvKg/DllD1yFA2dwQSIGO0Q=", - "dev": true + "integrity": "sha1-uhqPGvKg/DllD1yFA2dwQSIGO0Q=" }, "has-value": { "version": "1.0.0", @@ -3769,8 +3828,7 @@ "inherits": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", - "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", - "dev": true + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" }, "ini": { "version": "1.3.5", @@ -3812,6 +3870,11 @@ "kind-of": "^3.0.2" } }, + "is-arguments": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.0.4.tgz", + "integrity": "sha512-xPh0Rmt8NE65sNzvyUmWgI1tz3mKq74lGA0mL8LYZcoIzKOzDh6HmrYm3d18k60nHerC8A9Km8kYu87zfSFnLA==" + }, "is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -3836,8 +3899,7 @@ "is-callable": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.4.tgz", - "integrity": "sha512-r5p9sxJjYnArLjObpjA4xu5EKI3CuKHkJXMhT7kwbpUyIFD1n5PMAsoPvWnvtZiNz7LjkYDRZhd7FlI0eMijEA==", - "dev": true + "integrity": "sha512-r5p9sxJjYnArLjObpjA4xu5EKI3CuKHkJXMhT7kwbpUyIFD1n5PMAsoPvWnvtZiNz7LjkYDRZhd7FlI0eMijEA==" }, "is-ci": { "version": "1.2.1", @@ -3860,8 +3922,7 @@ "is-date-object": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.1.tgz", - "integrity": "sha1-mqIOtq7rv/d/vTPnTKAbM1gdOhY=", - "dev": true + "integrity": "sha1-mqIOtq7rv/d/vTPnTKAbM1gdOhY=" }, "is-descriptor": { "version": "0.1.6", @@ -3936,6 +3997,11 @@ "integrity": "sha1-lp1J4bszKfa7fwkIm+JleLLd1Go=", "dev": true }, + "is-generator-function": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.7.tgz", + "integrity": "sha512-YZc5EwyO4f2kWCax7oegfuSr9mFz1ZvieNYBEjmukLxgXfBUbxAWGVF7GZf0zidYtoBl3WvC07YK0wT76a+Rtw==" + }, "is-glob": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-2.0.1.tgz", @@ -3956,7 +4022,7 @@ }, "is-obj": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", + "resolved": "http://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", "integrity": "sha1-PkcprB9f3gJc19g6iW2rn09n2w8=", "dev": true }, @@ -3995,11 +4061,15 @@ "integrity": "sha1-IHurkWOEmcB7Kt8kCkGochADRXU=", "dev": true }, + "is-promise": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.1.0.tgz", + "integrity": "sha1-eaKp7OfwlugPNtKy87wWwf9L8/o=" + }, "is-regex": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.4.tgz", "integrity": "sha1-VRdIm1RwkbCTDglWVM7SXul+lJE=", - "dev": true, "requires": { "has": "^1.0.1" } @@ -4020,7 +4090,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.2.tgz", "integrity": "sha512-HS8bZ9ox60yCJLH9snBpIwv9pYUAkcuLhSA1oero1UB5y9aiQpRA8y2ex945AOtCZL1lJDeIk3G5LthswI46Lw==", - "dev": true, "requires": { "has-symbols": "^1.0.0" } @@ -4906,6 +4975,14 @@ "yallist": "^2.1.2" } }, + "lru-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/lru-queue/-/lru-queue-0.1.0.tgz", + "integrity": "sha1-Jzi9nw089PhEkMVzbEhpmsYyzaM=", + "requires": { + "es5-ext": "~0.10.2" + } + }, "macos-release": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/macos-release/-/macos-release-2.0.0.tgz", @@ -4992,6 +5069,21 @@ "mimic-fn": "^1.0.0" } }, + "memoizee": { + "version": "0.4.14", + "resolved": "https://registry.npmjs.org/memoizee/-/memoizee-0.4.14.tgz", + "integrity": "sha512-/SWFvWegAIYAO4NQMpcX+gcra0yEZu4OntmUdrBaWrJncxOqAziGFlHxc7yjKVK2uu3lpPW27P27wkR82wA8mg==", + "requires": { + "d": "1", + "es5-ext": "^0.10.45", + "es6-weak-map": "^2.0.2", + "event-emitter": "^0.3.5", + "is-promise": "^2.1", + "lru-queue": "0.1", + "next-tick": "1", + "timers-ext": "^0.1.5" + } + }, "meow": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/meow/-/meow-4.0.1.tgz", @@ -5023,7 +5115,7 @@ }, "minimist": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "resolved": "http://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", "dev": true }, @@ -5275,6 +5367,11 @@ "integrity": "sha1-5tq3/r9a2Bbqgc9cYpxaDr3nLBo=", "dev": true }, + "next-tick": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.0.0.tgz", + "integrity": "sha1-yobR/ogoFpsBICCOPchCS524NCw=" + }, "nice-try": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", @@ -8499,8 +8596,7 @@ "object-keys": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.0.12.tgz", - "integrity": "sha512-FTMyFUm2wBcGHnH2eXmz7tC6IwlqQZ6mVZ+6dm6vZ4IQIHjs6FdNsQBuKGPuUUUY6NfJw2PshC08Tn6LzLDOag==", - "dev": true + "integrity": "sha512-FTMyFUm2wBcGHnH2eXmz7tC6IwlqQZ6mVZ+6dm6vZ4IQIHjs6FdNsQBuKGPuUUUY6NfJw2PshC08Tn6LzLDOag==" }, "object-visit": { "version": "1.0.1", @@ -8519,6 +8615,17 @@ } } }, + "object.entries": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.0.tgz", + "integrity": "sha512-l+H6EQ8qzGRxbkHOd5I/aHRhHDKoQXQ8g0BYt4uSweQU1/J6dZUOyWh9a2Vky35YCKjzmgxOzta2hH6kf9HuXA==", + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.12.0", + "function-bind": "^1.1.1", + "has": "^1.0.3" + } + }, "object.getownpropertydescriptors": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.0.3.tgz", @@ -9019,7 +9126,7 @@ "dependencies": { "minimist": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "resolved": "http://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", "dev": true } @@ -9325,8 +9432,7 @@ "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 + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, "safe-regex": { "version": "1.1.0", @@ -10419,7 +10525,7 @@ }, "through": { "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "resolved": "http://registry.npmjs.org/through/-/through-2.3.8.tgz", "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", "dev": true }, @@ -10433,6 +10539,15 @@ "xtend": "~4.0.1" } }, + "timers-ext": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/timers-ext/-/timers-ext-0.1.7.tgz", + "integrity": "sha512-b85NUNzTSdodShTIbky6ZF02e8STtVVfD+fu4aXXShEELpozH+bCpJLYMPZbsABN2wDH7fJpqIoXxJpzbf0NqQ==", + "requires": { + "es5-ext": "~0.10.46", + "next-tick": "1" + } + }, "tmpl": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.4.tgz", @@ -10594,6 +10709,11 @@ "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=", "dev": true }, + "type": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/type/-/type-1.0.3.tgz", + "integrity": "sha512-51IMtNfVcee8+9GJvj0spSuFcZHe9vSib6Xtgsny1Km9ugyz2mbS08I3rsUIRYgJohFRFU1160sgRodYz378Hg==" + }, "type-check": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", @@ -10758,6 +10878,18 @@ "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==", "dev": true }, + "util": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.1.tgz", + "integrity": "sha512-MREAtYOp+GTt9/+kwf00IYoHZyjM8VU4aVrkzUlejyqaIjd2GztVl5V9hGXKlvBKE3gENn/FMfHE5v6hElXGcQ==", + "requires": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "object.entries": "^1.1.0", + "safe-buffer": "^5.1.2" + } + }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/package.json b/package.json index 3cff97e..539eccd 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,9 @@ "build": "tsc -p tsconfig.dist.json" }, "dependencies": { - "graphql": "^14.0.2" + "iterall": "^1.2.2", + "memoizee": "^0.4.14", + "util": "^0.12.1" }, "peerDependencies": { "rxjs": "^6.0.0" @@ -27,8 +29,9 @@ "devDependencies": { "@types/graphql": "14.0.2", "@types/jest": "23.3.10", - "graphql": "14.0.2", - "graphql-tag": "2.8.0", + "@types/memoizee": "^0.4.2", + "delay": "^4.3.0", + "graphql": "^14.5.4", "graphql-tools": "4.0.3", "jest": "23.6.0", "rimraf": "2.6.2", diff --git a/src/__tests__/fieldNotFoundMessageForType-test.ts b/src/__tests__/fieldNotFoundMessageForType-test.ts deleted file mode 100644 index fe9f065..0000000 --- a/src/__tests__/fieldNotFoundMessageForType-test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { GraphQLObjectType, GraphQLString, GraphQLScalarType } from "graphql"; -import { fieldNotFoundMessageForType } from "../reactive-graphql"; - -describe("fieldNotFoundMessageForType", () => { - it("returns a helpful message for null", () => { - expect(fieldNotFoundMessageForType(null)).toBe( - "The type should not be null." - ); - }); - - it("returns a helpful message for scalar types", () => { - expect( - fieldNotFoundMessageForType( - new GraphQLScalarType({ - name: "Odd", - serialize(value) { - return value % 2 === 1 ? value : null; - } - }) - ) - ).toBe("The field has a scalar type, which means it supports no nesting."); - }); - - it("returns all field names for object type", () => { - expect( - fieldNotFoundMessageForType( - new GraphQLObjectType({ - name: "Row", - fields: () => ({ - id: { type: GraphQLString } - }) - }) - ) - ).toBe("The only fields found in this Object are: id."); - }); -}); diff --git a/src/__tests__/graphqlObservable-test.ts b/src/__tests__/graphqlObservable-test.ts index 39bf704..22ae2a8 100644 --- a/src/__tests__/graphqlObservable-test.ts +++ b/src/__tests__/graphqlObservable-test.ts @@ -1,10 +1,10 @@ +import delay from 'delay'; import { of } from "rxjs"; import { take, map, combineLatest } from "rxjs/operators"; import { marbles } from "rxjs-marbles/jest"; import { makeExecutableSchema } from "graphql-tools"; -import gql from "graphql-tag"; import { graphql } from "../"; @@ -205,7 +205,7 @@ itMarbles.only = (title, test) => { describe("graphqlObservable", function() { describe("Query", function() { itMarbles("solves listing all fields", function(m) { - const query = gql` + const query = ` query { launched { name @@ -245,8 +245,8 @@ describe("graphqlObservable", function() { }); itMarbles("filters by variable argument", function(m) { - const query = gql` - query { + const query = ` + query($nameFilter: String) { launched(name: $nameFilter) { name firstFlight @@ -260,7 +260,7 @@ describe("graphqlObservable", function() { a: { data: { launched: [expectedData[0]] } } }); - const nameFilter = of("apollo11"); + const nameFilter = "apollo11"; const result = graphql( schema, query, @@ -277,7 +277,7 @@ describe("graphqlObservable", function() { }); itMarbles("filters by static argument", function(m) { - const query = gql` + const query = ` query { launched(name: "apollo13") { name @@ -300,7 +300,7 @@ describe("graphqlObservable", function() { }); itMarbles("filters out fields", function(m) { - const query = gql` + const query = ` query { launched { name @@ -322,7 +322,7 @@ describe("graphqlObservable", function() { }); itMarbles("resolve with name alias", function(m) { - const query = gql` + const query = ` query { launched { title: name @@ -344,7 +344,7 @@ describe("graphqlObservable", function() { }); itMarbles("resolves using root value", function(m) { - const query = gql` + const query = ` query { launched { name @@ -375,7 +375,7 @@ describe("graphqlObservable", function() { describe("Field Resolvers", function() { describe("Leafs", function() { itMarbles("defaults to return the property on the object", function(m) { - const query = gql` + const query = ` query { plain { noFieldResolver @@ -390,7 +390,7 @@ describe("graphqlObservable", function() { }); itMarbles("if defined it executes the field resolver", function(m) { - const query = gql` + const query = ` query { plain { fieldResolver @@ -405,7 +405,7 @@ describe("graphqlObservable", function() { }); itMarbles("if defined but returns undefined, field is null", function (m) { - const query = gql` + const query = ` query { plain { fieldResolvesUndefined @@ -420,7 +420,7 @@ describe("graphqlObservable", function() { }); itMarbles("the field resolvers 1st argument is parent", function(m) { - const query = gql` + const query = ` query { plain { giveMeTheParentFieldResolver @@ -443,7 +443,7 @@ describe("graphqlObservable", function() { }); itMarbles("the field resolvers 2nd argument is arguments", function(m) { - const query = gql` + const query = ` query { plain { giveMeTheArgsFieldResolver(arg: "My passed arg") @@ -466,7 +466,7 @@ describe("graphqlObservable", function() { }); itMarbles("the field resolvers 3rd argument is context", function(m) { - const query = gql` + const query = ` query { plain { giveMeTheContextFieldResolver @@ -489,7 +489,7 @@ describe("graphqlObservable", function() { describe("Nodes", function() { itMarbles("if defined it executes the field resolver", function(m) { - const query = gql` + const query = ` query { item { nodeFieldResolver { @@ -512,7 +512,7 @@ describe("graphqlObservable", function() { }); itMarbles("if nullable field resolver returns null, it resolves null", function(m) { - const query = gql` + const query = ` query { item { nullableNodeFieldResolver { @@ -535,7 +535,7 @@ describe("graphqlObservable", function() { }); itMarbles("the field resolvers 1st argument is parent", function(m) { - const query = gql` + const query = ` query { item { giveMeTheParentFieldResolver { @@ -562,7 +562,7 @@ describe("graphqlObservable", function() { }); itMarbles("the field resolvers 2nd argument is arguments", function(m) { - const query = gql` + const query = ` query { item { giveMeTheArgsFieldResolver(arg: "My passed arg") { @@ -589,7 +589,7 @@ describe("graphqlObservable", function() { }); itMarbles("the field resolvers 3rd argument is context", function(m) { - const query = gql` + const query = ` query { item { giveMeTheContextFieldResolver { @@ -615,7 +615,7 @@ describe("graphqlObservable", function() { itMarbles("nested resolvers pass down the context and parent", function( m ) { - const query = gql` + const query = ` query { nested { firstFieldResolver { @@ -642,47 +642,38 @@ describe("graphqlObservable", function() { }); }); - itMarbles("throwing an error results in an error observable", function(m) { - const query = gql` + it("throwing an error results in an error in execution result", async function() { + const query = ` query { throwingResolver } `; - const expected = m.cold( - "#", - {}, - new Error( - "reactive-graphql: resolver 'throwingResolver' throws this error: 'Error: my personal error'" - ) - ); - const result = graphql(fieldResolverSchema, query, null, {}); - m.expect(result.pipe(take(1))).toBeObservable(expected); + + const result = await graphql(fieldResolverSchema, query, null, {}).pipe(take(1)).toPromise(); + expect(result).toHaveProperty('errors'); + expect(result.errors![0].message).toBe('my personal error'); + expect(result.errors![0].path).toEqual(['throwingResolver']); + }); - itMarbles( - "accessing an unknown query field results in an error observable", - function(m) { - const query = gql` + it( + "accessing an unknown query field results in an error in execution result", + async function() { + const query = ` query { youDontKnowMe } `; - const expected = m.cold( - "#", - {}, - new Error( - "reactive-graphql: field 'youDontKnowMe' was not found on type 'Query'. The only fields found in this Object are: plain,item,nested,throwingResolver." - ) - ); - const result = graphql(fieldResolverSchema, query, null, {}); - m.expect(result.pipe(take(1))).toBeObservable(expected); + const result = await graphql(fieldResolverSchema, query, null, {}).pipe(take(1)).toPromise(); + expect(result).toHaveProperty('errors'); + expect(result.errors![0].message).toBe('Cannot query field \"youDontKnowMe\" on type \"Query\".') } ); }); describe("Mutation", function() { itMarbles("createShuttle adds a shuttle and return its name", function(m) { - const mutation = gql` + const mutation = ` mutation { createShuttle(name: "RocketShip") { name @@ -707,7 +698,7 @@ describe("graphqlObservable", function() { itMarbles( "createShuttleList adds a shuttle and return all shuttles", function(m) { - const mutation = gql` + const mutation = ` mutation { createShuttleList(name: "RocketShip") { name @@ -738,8 +729,8 @@ describe("graphqlObservable", function() { ); itMarbles("accept alias name", function(m) { - const mutation = gql` - mutation { + const mutation = ` + mutation ($name: String){ shut: createShuttle(name: $name) { name } @@ -766,5 +757,49 @@ describe("graphqlObservable", function() { m.expect(result).toBeObservable(expected); }); + + it('respects serial execution of resolvers', async () => { + let theNumber = 0; + const schema = makeExecutableSchema({ + typeDefs: ` + type Mutation { + increment: Int! + } + type Query { + theNumber: Int! + } + `, + resolvers: { + Mutation: { + // atomic resolver + increment: async () => { + const _theNumber = theNumber; + await delay(100); + theNumber = _theNumber + 1; + return theNumber; + } + }, + } + }); + + const result$ = graphql({ + schema, + source: ` + mutation { + first: increment, + second: increment, + third: increment, + } + `, + }) + const result = await result$.pipe(take(1)).toPromise(); + expect(result).toEqual({ + data: { + first: 1, + second: 2, // 1 if not serial + third: 3, // 1 if not serial + } + }) + }) }); }); diff --git a/src/__tests__/reference/starWarsQuery-test.ts b/src/__tests__/reference/starWarsQuery-test.ts index 597b99a..69b05a2 100644 --- a/src/__tests__/reference/starWarsQuery-test.ts +++ b/src/__tests__/reference/starWarsQuery-test.ts @@ -1,4 +1,4 @@ -import gql from "graphql-tag"; +import { ExecutionResult, SourceLocation } from "graphql"; import { take } from "rxjs/operators"; import StarWarsSchema from "./starWarsSchema"; @@ -6,12 +6,9 @@ import { graphql as graphqlObservable } from "../../"; const graphql = (schema, query, rootValue?, contextValue?, variableValues?) => { return new Promise(resolve => { - const taggedQuery = gql` - ${query} - `; graphqlObservable( schema, - taggedQuery, + query, rootValue, contextValue, variableValues @@ -21,6 +18,50 @@ const graphql = (schema, query, rootValue?, contextValue?, variableValues?) => { }); }; +type SerializedExecutionResult = { + data?: TData | null, + errors?: ReadonlyArray<{ + message: string, + locations?: ReadonlyArray, + path?: ReadonlyArray + }> +} +declare global { + namespace jest { + interface Matchers { + /** + * Will test the equality of GraphQL's `ExecutionResult`. + * + * In opposite to the simple `toEqual` it will test the `errors` field + * with `GraphqQLErrors`. Specifically it will test the equlity of the + * properties `message`, `locations` and `path`. + * @param expected + */ + toEqualExecutionResult(expected: SerializedExecutionResult): R; + } + } +} + +expect.extend({ + toEqualExecutionResult(actual: ExecutionResult, expected: SerializedExecutionResult) { + let actualSerialized: SerializedExecutionResult = { + data: actual.data, + }; + if(actual.errors) { + actualSerialized.errors = actual.errors.map(e => ({ + message: e.message, + locations: e.locations, + path: e.path, + })) + } + expect(actualSerialized).toEqual(expected); + return { + message: 'ok', + pass: true, + } + } +}) + describe("Star Wars Query Tests", () => { describe("Basic Queries", () => { it("Correctly identifies R2-D2 as the hero of the Star Wars Saga", async () => { @@ -32,7 +73,7 @@ describe("Star Wars Query Tests", () => { } `; const result = await graphql(StarWarsSchema, query); - expect(result).toEqual({ + expect(result).toEqualExecutionResult({ data: { hero: { name: "R2-D2" @@ -50,7 +91,7 @@ describe("Star Wars Query Tests", () => { } `; const result = await graphql(StarWarsSchema, query); - expect(result).toEqual({ + expect(result).toEqualExecutionResult({ data: { myrobot: { name: "R2-D2" @@ -72,7 +113,7 @@ describe("Star Wars Query Tests", () => { } `; const result = await graphql(StarWarsSchema, query); - expect(result).toEqual({ + expect(result).toEqualExecutionResult({ data: { hero: { id: "2001", @@ -95,7 +136,7 @@ describe("Star Wars Query Tests", () => { }); // Requires support to nested queries https://jira.mesosphere.com/browse/DCOS-22358 - describe.skip("Nested Queries", () => { + describe("Nested Queries", () => { it("Allows us to query for the friends of friends of R2-D2", async () => { const query = ` query NestedQuery { @@ -112,7 +153,7 @@ describe("Star Wars Query Tests", () => { } `; const result = await graphql(StarWarsSchema, query); - expect(result).toEqual({ + expect(result).toEqualExecutionResult({ data: { hero: { name: "R2-D2", @@ -185,7 +226,7 @@ describe("Star Wars Query Tests", () => { } `; const result = await graphql(StarWarsSchema, query); - expect(result).toEqual({ + expect(result).toEqualExecutionResult({ data: { human: { name: "Luke Skywalker" @@ -204,7 +245,7 @@ describe("Star Wars Query Tests", () => { `; const params = { someId: "1000" }; const result = await graphql(StarWarsSchema, query, null, null, params); - expect(result).toEqual({ + expect(result).toEqualExecutionResult({ data: { human: { name: "Luke Skywalker" @@ -223,7 +264,7 @@ describe("Star Wars Query Tests", () => { `; const params = { someId: "1002" }; const result = await graphql(StarWarsSchema, query, null, null, params); - expect(result).toEqual({ + expect(result).toEqualExecutionResult({ data: { human: { name: "Han Solo" @@ -233,7 +274,7 @@ describe("Star Wars Query Tests", () => { }); // Requires support to errors https://jira.mesosphere.com/browse/DCOS-22062 - it.skip("Allows us to create a generic query, then pass an invalid ID to get null back", async () => { + it("Allows us to create a generic query, then pass an invalid ID to get null back", async () => { const query = ` query humanQuery($id: String!) { human(id: $id) { @@ -243,7 +284,7 @@ describe("Star Wars Query Tests", () => { `; const params = { id: "not a valid id" }; const result = await graphql(StarWarsSchema, query, null, null, params); - expect(result).toEqual({ + expect(result).toEqualExecutionResult({ data: { human: null } @@ -261,7 +302,7 @@ describe("Star Wars Query Tests", () => { } `; const result = await graphql(StarWarsSchema, query); - expect(result).toEqual({ + expect(result).toEqualExecutionResult({ data: { luke: { name: "Luke Skywalker" @@ -282,7 +323,7 @@ describe("Star Wars Query Tests", () => { } `; const result = await graphql(StarWarsSchema, query); - expect(result).toEqual({ + expect(result).toEqualExecutionResult({ data: { luke: { name: "Luke Skywalker" @@ -310,7 +351,7 @@ describe("Star Wars Query Tests", () => { } `; const result = await graphql(StarWarsSchema, query); - expect(result).toEqual({ + expect(result).toEqualExecutionResult({ data: { luke: { name: "Luke Skywalker", @@ -325,7 +366,7 @@ describe("Star Wars Query Tests", () => { }); // Require support to fragments https://jira.mesosphere.com/browse/DCOS-22356 - it.skip("Allows us to use a fragment to avoid duplicating content", async () => { + it("Allows us to use a fragment to avoid duplicating content", async () => { const query = ` query UseFragment { luke: human(id: "1000") { @@ -342,7 +383,7 @@ describe("Star Wars Query Tests", () => { } `; const result = await graphql(StarWarsSchema, query); - expect(result).toEqual({ + expect(result).toEqualExecutionResult({ data: { luke: { name: "Luke Skywalker", @@ -359,7 +400,7 @@ describe("Star Wars Query Tests", () => { // Not supporting introspection describe("Using __typename to find the type of an object", () => { - it.skip("Allows us to verify that R2-D2 is a droid", async () => { + it("Allows us to verify that R2-D2 is a droid", async () => { const query = ` query CheckTypeOfR2 { hero { @@ -369,7 +410,7 @@ describe("Star Wars Query Tests", () => { } `; const result = await graphql(StarWarsSchema, query); - expect(result).toEqual({ + expect(result).toEqualExecutionResult({ data: { hero: { __typename: "Droid", @@ -380,7 +421,7 @@ describe("Star Wars Query Tests", () => { }); // Requires support to introspection https://jira.mesosphere.com/browse/DCOS-22357 - it.skip("Allows us to verify that Luke is a human", async () => { + it("Allows us to verify that Luke is a human", async () => { const query = ` query CheckTypeOfLuke { hero(episode: EMPIRE) { @@ -390,7 +431,7 @@ describe("Star Wars Query Tests", () => { } `; const result = await graphql(StarWarsSchema, query); - expect(result).toEqual({ + expect(result).toEqualExecutionResult({ data: { hero: { __typename: "Human", @@ -402,7 +443,7 @@ describe("Star Wars Query Tests", () => { }); // Requires support to errors https://jira.mesosphere.com/browse/DCOS-22062 - describe.skip("Reporting errors raised in resolvers", () => { + describe("Reporting errors raised in resolvers", () => { it("Correctly reports error on accessing secretBackstory", async () => { const query = ` query HeroNameQuery { @@ -413,7 +454,7 @@ describe("Star Wars Query Tests", () => { } `; const result = await graphql(StarWarsSchema, query); - expect(result).toEqual({ + expect(result).toEqualExecutionResult({ data: { hero: { name: "R2-D2", @@ -443,7 +484,7 @@ describe("Star Wars Query Tests", () => { } `; const result = await graphql(StarWarsSchema, query); - expect(result).toEqual({ + expect(result).toEqualExecutionResult({ data: { hero: { name: "R2-D2", @@ -493,7 +534,7 @@ describe("Star Wars Query Tests", () => { } `; const result = await graphql(StarWarsSchema, query); - expect(result).toEqual({ + expect(result).toEqualExecutionResult({ data: { mainHero: { name: "R2-D2", diff --git a/src/__tests__/reference/starWarsSchema.ts b/src/__tests__/reference/starWarsSchema.ts index 1efb1c8..44ad8ac 100644 --- a/src/__tests__/reference/starWarsSchema.ts +++ b/src/__tests__/reference/starWarsSchema.ts @@ -185,7 +185,7 @@ const humanType = new GraphQLObjectType({ type: GraphQLString, description: "Where are they from and how they came to be who they are.", resolve() { - throwError(new Error("secretBackstory is secret.")); + return throwError(new Error("secretBackstory is secret.")); } } }), diff --git a/src/execution/__tests__/rx-subscriptions-test.ts b/src/execution/__tests__/rx-subscriptions-test.ts new file mode 100644 index 0000000..4fd5556 --- /dev/null +++ b/src/execution/__tests__/rx-subscriptions-test.ts @@ -0,0 +1,136 @@ +import { makeExecutableSchema } from "graphql-tools"; +import { marbles } from "rxjs-marbles/jest"; +import { TestObservableLike } from "rxjs-marbles/types"; +import { parse } from "graphql"; +import { execute } from "../.."; + +describe('Execution: Rx subscriptions management', () => { + describe('subscription/unsubscription sycnhronization of resolved observable with result of query', () => { + const executeScenario = ( + revolvedValue$: TestObservableLike, + ) => { + const schema = makeExecutableSchema({ + typeDefs: ` + type Query { + value: String! + }`, + resolvers: { + Query: { + value: () => revolvedValue$, + } + } + }); + + return execute({ + schema, + document: parse(` + query { + value + } + `) + }) + } + + it('should wait for result subscription to subscribe to Observable returned by resolver', marbles(m => { + const value$ = m.hot( + '-a--b--c---' + ) + m.expect( + executeScenario(value$), + '--^--------' + ).toBeObservable( + '----B--C---', { + B: { data: { value: 'b' } }, + C: { data: { value: 'c' } }, + } + ) + m.expect(value$).toHaveSubscriptions( + '--^--------' + ) + })); + + it('should unsubsribe from Observable returned by resolver when unsubscribe from result', marbles(m => { + const value$ = m.hot( + '-a--b--c---' + ) + m.expect( + executeScenario(value$), + '^----!----' + ).toBeObservable( + '-A--B------', { + A: { data: { value: 'a' } }, + B: { data: { value: 'b' } }, + } + ) + m.expect(value$).toHaveSubscriptions( + '^----!----' + ) + })); + }); + + describe('giving up a resolved Observable', () => { + const executeScenario = ( + currentEmitter$: TestObservableLike, + emitter$s: { + [key: string]: TestObservableLike, + }, + ) => { + const schema = makeExecutableSchema({ + typeDefs: ` + type Emitter { + value: String! + } + type Query { + currentEmitter: Emitter! + }`, + resolvers: { + Query: { + currentEmitter: () => currentEmitter$, + }, + Emitter: { + value: (p: string) => emitter$s[p] + } + } + }); + + return execute({ + schema, + document: parse(` + query { + currentEmitter { + value + } + } + `) + }) + } + + it('should unsubscribe from it (switchMap)', marbles(m => { + const currentEmitter$ = m.hot( + '-A-----B---' + ); + const emitter$s = { + A: m.hot('aaaaaaaaaaa'), + B: m.hot('bbbbbbbbbbb'), + } + m.expect( + executeScenario(currentEmitter$, emitter$s), + '^---------!' + ).toBeObservable( + // -A-----B--- + '-aaaaaabbb-', { + a: { data: { currentEmitter: { value: 'a' }}}, + b: { data: { currentEmitter: { value: 'b' }}}, + } + ) + m.expect(emitter$s.A).toHaveSubscriptions( + // -A-----B--- + '-^-----!--' + ) + m.expect(emitter$s.B).toHaveSubscriptions( + // -A-----B--- + '-------^--!' + ) + })); + }) +}); diff --git a/src/execution/execute.ts b/src/execution/execute.ts new file mode 100644 index 0000000..afbe281 --- /dev/null +++ b/src/execution/execute.ts @@ -0,0 +1,912 @@ +/** + * `execute.ts` is the equivalent of `graphql-js`'s `src/execution/execute.js`: + * it follows the same structure and same function names. + * + * The implementation of each function is very close to its sibling in `graphql-js` + * and, for the most part, is just adapted for reactive execution (dealing with `Observable`). + * Some functions are just copy-pasted because they did not require any change + * but could not be imported from `graphql-js`. + * Some functions are not present because they could be imported from `graphql-js`. + */ +import { Observable, of, from, isObservable, combineLatest, throwError } from "rxjs"; +import { map, catchError, switchMap } from "rxjs/operators"; +import { forEach, isIterable } from "iterall"; +import memoize from "memoizee"; +import { + ExecutionResult, + DocumentNode, + GraphQLObjectType, + GraphQLSchema, + FieldNode, + GraphQLField, + GraphQLOutputType, + GraphQLFieldResolver, + isObjectType, + isNonNullType, + responsePathAsArray, + ExecutionArgs, + OperationDefinitionNode, + ResponsePath, + GraphQLResolveInfo, + GraphQLError, + getOperationRootType, + GraphQLList, + GraphQLLeafType, + isListType, + isLeafType, + isAbstractType, + GraphQLAbstractType, +} from "graphql"; + +import { + ExecutionResultDataDefault, + assertValidExecutionArguments, + buildExecutionContext, + ExecutionContext, + collectFields, + buildResolveInfo, + getFieldDef, +} from 'graphql/execution/execute'; +import Maybe from 'graphql/tsutils/Maybe'; +import { addPath } from "graphql/jsutils/Path"; +import combinePropsLatest from "../rxutils/combinePropsLatest"; +import { getArgumentValues } from "graphql/execution/values"; +import { locatedError } from "graphql/error"; +import invariant from "../jstutils/invariant"; +import isInvalid from "../jstutils/isInvalid"; +import inspect from "../jstutils/inspect"; +import isNullish from "../jstutils/isNullish"; +import mapPromiseToObservale from "../rxutils/mapPromiseToObservale"; +import mapToFirstValue from "../rxutils/mapToFirstValue"; + +/** + * Implements the "Evaluating requests" section of the GraphQL specification. + * + * Returns a RxJS's `Observable` of `ExecutionResult`. + * + * Note: reactive equivalent of `graphql-js`'s `execute`. + */ +export function execute(args: ExecutionArgs) + : Observable>; +export function execute( + schema: GraphQLSchema, + document: DocumentNode, + rootValue?: unknown, + contextValue?: unknown, + variableValues?: Maybe<{ [key: string]: unknown }>, + operationName?: Maybe, + fieldResolver?: Maybe> +): Observable>; +export function execute( + argsOrSchema, + document?, + rootValue?, + contextValue?, + variableValues?, + operationName?, + fieldResolver?, +) { + return isExecutionArgs(argsOrSchema, arguments) + ? executeImpl( + argsOrSchema.schema, + argsOrSchema.document, + argsOrSchema.rootValue, + argsOrSchema.contextValue, + argsOrSchema.variableValues, + argsOrSchema.operationName, + argsOrSchema.fieldResolver, + ) + : executeImpl( + argsOrSchema, + document, + rootValue, + contextValue, + variableValues, + operationName, + fieldResolver, + ); +} + +function isExecutionArgs( + _argsOrSchema: GraphQLSchema | ExecutionArgs, + args: IArguments +): _argsOrSchema is ExecutionArgs { + return args.length === 1; +} + +function executeImpl( + schema: GraphQLSchema, + document: DocumentNode, + rootValue?: unknown, + contextValue?: unknown, + variableValues?: Maybe<{ [key: string]: unknown }>, + operationName?: Maybe, + fieldResolver?: Maybe> +): Observable> { + // If arguments are missing or incorrect, throw an error. + assertValidExecutionArguments(schema, document, variableValues); + + // If a valid execution context cannot be created due to incorrect arguments, + // a "Response" with only errors is returned. + const exeContext = buildExecutionContext( + schema, + document, + rootValue, + contextValue, + variableValues, + operationName, + fieldResolver, + ); + + // Return early errors if execution context failed. + if (!isValidExecutionContext(exeContext)) { + return of({ errors: exeContext }); + } + + const data = executeOperation(exeContext, exeContext.operation, rootValue); + return buildResponse(exeContext, data); +} + +/** + * Returns true if subject is a valid `ExecutionContext` and not array of `GraphQLError`. + * + * Note: reference implementation does a `Array.isArray` in `executeImpl` function body. In comparison, + * `isValidExecutionContext` ensures typing correctness with type assertion. + * @param subject value to be tested + */ +function isValidExecutionContext(subject: ReadonlyArray | ExecutionContext): subject is ExecutionContext { + return !Array.isArray(subject); +} + +/** + * Given a completed execution context and data as Observable, build the `{ errors, data }` + * response defined by the "Response" section of the GraphQL specification. + * + * Note: reactive equivalent of `graphql-js`'s `buildResponse`. + */ +function buildResponse( + exeContext: ExecutionContext, + data: Observable<{ [key: string]: unknown} | null> +): Observable> { + // @ts-ignore `'{ [key: string]: unknown; }' is assignable to the constraint of type 'TData', but 'TData' could be instantiated with a different subtype of constraint '{}'` + return data.pipe(map(d => { + if (exeContext.errors.length === 0 && d !== null) { + return { + data: d, + } + } else { + return { + errors: exeContext.errors, + data: d, + } + } + })) +} + +/** + * Implements the "Evaluating operations" section of the spec. + * + * Note: reactive equivalent of `graphql-js`'s `executeOperation`. The difference lies + * in the fact that, here, a RxJS's `Observable` is returned instead of a `Promise` (or plain value). + */ +function executeOperation( + exeContext: ExecutionContext, + operation: OperationDefinitionNode, + rootValue: unknown +): Observable<({ [key: string]: unknown }) | null> { + const type = getOperationRootType(exeContext.schema, operation); + const fields = collectFields( + exeContext, + type, + operation.selectionSet, + Object.create(null), + Object.create(null), + ); + + const path = undefined; + + // Errors from sub-fields of a NonNull type may propagate to the top level, + // at which point we still log the error and null the parent field, which + // in this case is the entire response. + // + // Similar to completeValueCatchingError. + try { + const result = + operation.operation === 'mutation' + ? executeFieldsSerially(exeContext, type, rootValue, path, fields) + : executeFields(exeContext, type, rootValue, path, fields); + return result; + } catch (error) { + exeContext.errors.push(error); + return of(null); + } +} + +/** + * Implements the "Evaluating selection sets" section of the spec for "write" mode, + * ie with serial execution. + * + * Note: reactive equivalent of `graphql-js`'s `executeFieldsSerially`. The difference + * lies in the fact that: + * - here, a RxJS's `Observable` is returned instead of a `Promise` (or plain value) + * - in `graphql-js`, serial execution is implemented by waiting, one by one, for the + * resolution of the `Promise` returned by the resolution of each `field`. Here we wait + * for the first value of the resolved `Observable` to be emited before passing to + * the next field resolution. + * + * Thus, in case of resolvers resolving `Promises`, we match + * reference implementation's behavior. + */ +function executeFieldsSerially( + exeContext: ExecutionContext, + parentType: GraphQLObjectType, + sourceValue: unknown, + path: ResponsePath | undefined, + fields: { [key: string]: FieldNode[]} +): Observable<{ [key: string]: unknown }> { + const results: { [key: string]: Observable } = {}; + + // during iteration, we keep track of the result of the previously + // resolved field so that we can queue the resolution of the next field + // after the emition of the first value of the previous result. + let previousResolvedResult: (Observable | undefined); + + for (let i = 0, keys = Object.keys(fields); i < keys.length; ++i) { + const responseName = keys[i]; + const fieldNodes = fields[responseName]; + const fieldPath = addPath(path, responseName); + + const resolve = () => resolveField( + exeContext, + parentType, + sourceValue, + fieldNodes, + fieldPath, + ); + + const result = previousResolvedResult ? + // queuing `resolve` after first emition of `previousResolvedResult` + // using `mapToFirstValue` to get an Observable that represents this process + mapToFirstValue(previousResolvedResult, resolve) + : + // first iteration: no previous result need to queue after + resolve(); + + previousResolvedResult = result; + + if (result !== undefined) { + results[responseName] = result; + } + } + + return combinePropsLatest(results); +} + +/** + * Implements the "Evaluating selection sets" section of the spec + * for "read" mode. + * + * Note: reactive equivalent of `graphql-js`'s `executeFields`. The difference lies + * in the fact that, here, a RxJS's `Observable` is returned instead of a `Promise` (or plain value). + */ +function executeFields( + exeContext: ExecutionContext, + parentType: GraphQLObjectType, + sourceValue: unknown, + path: ResponsePath | undefined, + fields: { [key: string]: FieldNode[] } +): Observable<{ [key: string]: unknown }> { + const results: { [key: string]: Observable } = {}; + + for (let i = 0, keys = Object.keys(fields); i < keys.length; ++i) { + const responseName = keys[i]; + const fieldNodes = fields[responseName]; + const fieldPath = addPath(path, responseName); + const result = resolveField( + exeContext, + parentType, + sourceValue, + fieldNodes, + fieldPath, + ); + + if (result !== undefined) { + results[responseName] = result; + } + } + + return combinePropsLatest(results); +} + +/** + * Resolves the field on the given source object. + * + * Note: reactive equivalent of `graphql-js`'s `resolveField`. The difference lies + * in the fact that, here, a RxJS's `Observable` is returned instead of a `Promise` (or plain value). + */ +function resolveField( + exeContext: ExecutionContext, + parentType: GraphQLObjectType, + source: unknown, + fieldNodes: FieldNode[], + path: ResponsePath, +): Observable { + const fieldNode = fieldNodes[0]; + const fieldName = fieldNode.name.value; + + const fieldDef = getFieldDef(exeContext.schema, parentType, fieldName); + if (!fieldDef) { + return of(undefined); + } + + const resolveFn = fieldDef.resolve || exeContext.fieldResolver; + + const info = buildResolveInfo( + exeContext, + fieldDef, + fieldNodes, + parentType, + path, + ); + + const result = resolveFieldValueOrError( + exeContext, + fieldDef, + fieldNodes, + resolveFn, + source, + info, + ); + + return completeValueCatchingError( + exeContext, + fieldDef.type, + fieldNodes, + info, + path, + result, + ); +} + +/** + * Note: reactive equivalent of `graphql-js`'s `resolveFieldValueOrError`. The difference lies + * in the fact that, here, a RxJS's `Observable` is returned. + */ +function resolveFieldValueOrError( + exeContext: ExecutionContext, + fieldDef: GraphQLField, + fieldNodes: ReadonlyArray, + resolveFn: GraphQLFieldResolver, + source: TSource, + info: GraphQLResolveInfo +): (Error | Observable) { + try { + const args = getArgumentValues( + fieldDef, + fieldNodes[0], + exeContext.variableValues, + ); + + const contextValue = exeContext.contextValue; + + const result = resolveFn(source, args, contextValue, info); + + if (isObservable(result)) { + return result + .pipe(catchError(err => throwError(asErrorInstance(err)))); + } + + if (result instanceof Promise) { + return from(result) + .pipe(catchError(err => throwError(asErrorInstance(err)))); + } + + // it lloks like plain value + return of(result) + } catch (err) { + return asErrorInstance(err); + } +} + +/** + * Sometimes a non-error is thrown, wrap it as an Error instance to ensure a + * consistent Error interface. + * + * Note: copy-paste of `graphql-js`'s `asErrorInstance` in `execute.js`. + */ +function asErrorInstance(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error) || undefined); +} + +/** + * This is a small wrapper around completeValue which detects and logs errors + * in the execution context. + * + * Note: reactive equivalent of `graphql-js`'s `completeValueCatchingError`. The difference lies + * in the fact that, here, a RxJS's `Observable` is returned. + */ +function completeValueCatchingError( + exeContext: ExecutionContext, + returnType: GraphQLOutputType, + fieldNodes: ReadonlyArray, + info: GraphQLResolveInfo, + path: ResponsePath, + result: Error | Observable, +): Observable { + if (result instanceof Error) { + return of(handleFieldError( + result, + fieldNodes, + path, + returnType, + exeContext, + )); + } + try { + return result + .pipe( + switchMap(res => completeValue( + exeContext, + returnType, + fieldNodes, + info, + path, + res, + ) + ) + ) + .pipe(catchError(err => of(handleFieldError( + asErrorInstance(err), + fieldNodes, + path, + returnType, + exeContext, + )))) + } catch (error) { + return of(handleFieldError( + asErrorInstance(error), + fieldNodes, + path, + returnType, + exeContext, + )) + } +} + +/** + * Note: copy-paste of `graphql-js`'s `handleFieldError`. + */ +function handleFieldError( + rawError: Error, + fieldNodes: ReadonlyArray, + path: ResponsePath, + returnType: GraphQLOutputType, + exeContext: ExecutionContext, +): null { + const error = locatedError( + asErrorInstance(rawError), + fieldNodes, + responsePathAsArray(path), + ); + + // If the field type is non-nullable, then it is resolved without any + // protection from errors, however it still properly locates the error. + if (isNonNullType(returnType)) { + throw error; + } + + // Otherwise, error protection is applied, logging the error and resolving + // a null value for this field if one is encountered. + exeContext.errors.push(error); + return null; +} + +/** + * Implements the instructions for completeValue as defined in the + * "Field entries" section of the spec. + * + * Note: reactive equivalent of `graphql-js`'s `completeValue`. The difference lies + * in the fact that, here, we deal with RxJS's `Observable` and an `Observable` is returned. + */ +function completeValue( + exeContext: ExecutionContext, + returnType: GraphQLOutputType, + fieldNodes: ReadonlyArray, + info: GraphQLResolveInfo, + path: ResponsePath, + result: unknown, +): Observable { + // If result is an Error, throw a located error. + if (result instanceof Error) { + throw result; + } + + // If field type is NonNull, complete for inner type, and throw field error + // if result is null. + if (isNonNullType(returnType)) { + const completed = completeValue( + exeContext, + returnType.ofType, + fieldNodes, + info, + path, + result, + ); + if (completed === null) { + throw new Error( + `Cannot return null for non-nullable field ${info.parentType.name}.${ + info.fieldName + }.`, + ); + } + return completed; + } + + // If result value is null-ish (null, undefined, or NaN) then return null. + if (isNullish(result)) { + return of(null); + } + + // If field type is List, complete each item in the list with the inner type + if (isListType(returnType)) { + return completeListValue( + exeContext, + returnType, + fieldNodes, + info, + path, + result, + ); + } + + // If field type is a leaf type, Scalar or Enum, serialize to a valid value, + // returning null if serialization is not possible. + if (isLeafType(returnType)) { + return completeLeafValue(returnType, result); + } + + // If field type is an abstract type, Interface or Union, determine the + // runtime Object type and complete for that type. + if (isAbstractType(returnType)) { + return completeAbstractValue( + exeContext, + returnType, + fieldNodes, + info, + path, + result, + ); + } + + // If field type is Object, execute and complete all sub-selections. + if (isObjectType(returnType)) { + return completeObjectValue( + exeContext, + returnType, + fieldNodes, + info, + path, + result, + ); + } + + // Not reachable. All possible output types have been considered. + /* istanbul ignore next */ + throw new Error( + `Cannot complete value of unexpected type "${inspect( + (returnType), + )}".`, + ); +}; + +/** + * Complete a list value by completing each item in the list with the + * inner type + * + * Note: reactive equivalent of `graphql-js`'s `completeListValue`. The difference lies + * in the fact that, here, we deal with RxJS's `Observable` and an `Observable` is returned. + */ +function completeListValue( + exeContext: ExecutionContext, + returnType: GraphQLList, + fieldNodes: ReadonlyArray, + info: GraphQLResolveInfo, + path: ResponsePath, + result: unknown, +): Observable> { + invariant( + isIterable(result), + `Expected Iterable, but did not find one for field ${ + info.parentType.name + }.${info.fieldName}.`, + ); + + // for typescript only: asserts `result` type + if (!isIterable(result)) throw new Error('Expected Iterable'); + + const itemType = returnType.ofType; + const completedResults: Observable[] = []; + + forEach(result, (item, index) => { + const fieldPath = addPath(path, index); + const completedItem = completeValueCatchingError( + exeContext, + itemType, + fieldNodes, + info, + fieldPath, + of(item), + ); + completedResults.push(completedItem); + }); + + return combineLatest(completedResults); +} +/** + * Complete a Scalar or Enum by serializing to a valid value, returning + * null if serialization is not possible. + * + * Note: reactive equivalent of `graphql-js`'s `completeLeafValue`. The difference lies + * in the fact that, here, an `Observable` is returned. + */ +function completeLeafValue(returnType: GraphQLLeafType, result: unknown): Observable { + invariant(returnType.serialize, 'Missing serialize method on type'); + const serializedResult = returnType.serialize(result); + if (isInvalid(serializedResult)) { + throw new Error( + `Expected a value of type "${inspect(returnType)}" but ` + + `received: ${inspect(result)}`, + ); + } + return of(serializedResult); +} +/** + * Complete a value of an abstract type by determining the runtime object type + * of that value, then complete the value for that type. + * + * Note: reactive equivalent of `graphql-js`'s `completeAbstractValue`. The difference lies + * in the fact that, here, we deal with asychronisity in a Observable fashion and that + * an `Observable` is returned. + */ +function completeAbstractValue( + exeContext: ExecutionContext, + returnType: GraphQLAbstractType, + fieldNodes: ReadonlyArray, + info: GraphQLResolveInfo, + path: ResponsePath, + result: unknown, +): Observable<{ [key: string]: unknown }> { + const runtimeType = returnType.resolveType + ? returnType.resolveType(result, exeContext.contextValue, info) + : defaultResolveTypeFn(result, exeContext.contextValue, info, returnType); + + return mapPromiseToObservale( + Promise.resolve(runtimeType), + resolvedRuntimeType => completeObjectValue( + exeContext, + ensureValidRuntimeType( + resolvedRuntimeType, + exeContext, + returnType, + fieldNodes, + info, + result, + ), + fieldNodes, + info, + path, + result, + ) + ) +} + +/** + * Note: copy-pasted from `graphql-js`'s `ensureValidRuntimeType`. + */ +function ensureValidRuntimeType( + runtimeTypeOrName: Maybe | string, + exeContext: ExecutionContext, + returnType: GraphQLAbstractType, + fieldNodes: ReadonlyArray, + info: GraphQLResolveInfo, + result: unknown, +): GraphQLObjectType { + const runtimeType = + typeof runtimeTypeOrName === 'string' + ? exeContext.schema.getType(runtimeTypeOrName) + : runtimeTypeOrName; + + if (!isObjectType(runtimeType)) { + throw new GraphQLError( + `Abstract type ${returnType.name} must resolve to an Object type at ` + + `runtime for field ${info.parentType.name}.${info.fieldName} with ` + + `value ${inspect(result)}, received "${inspect(runtimeType)}". ` + + `Either the ${returnType.name} type should provide a "resolveType" ` + + 'function or each possible type should provide an "isTypeOf" function.', + fieldNodes, + ); + } + + if (!exeContext.schema.isPossibleType(returnType, runtimeType)) { + throw new GraphQLError( + `Runtime Object type "${runtimeType.name}" is not a possible type ` + + `for "${returnType.name}".`, + fieldNodes, + ); + } + + return runtimeType; +} + +/** + * Complete an Object value by executing all sub-selections. + * + * Note: reactive equivalent of `graphql-js`'s `completeObjectValue`. The difference lies + * in the fact that, here, we deal with asychronisity in a Observable fashion and that + * an `Observable` is returned. + */ +function completeObjectValue( + exeContext: ExecutionContext, + returnType: GraphQLObjectType, + fieldNodes: ReadonlyArray, + info: GraphQLResolveInfo, + path: ResponsePath, + result: unknown, +): Observable<{ [key: string]: unknown }> { + // If there is an isTypeOf predicate function, call it with the + // current result. If isTypeOf returns false, then raise an error rather + // than continuing execution. + if (returnType.isTypeOf) { + const isTypeOf = returnType.isTypeOf(result, exeContext.contextValue, info); + + if (isTypeOf instanceof Promise) { + return mapPromiseToObservale( + isTypeOf, + resolvedIsTypeOf => { + if (!resolvedIsTypeOf) { + throw invalidReturnTypeError(returnType, result, fieldNodes); + } + + return collectAndExecuteSubfields( + exeContext, + returnType, + fieldNodes, + path, + result, + ); + }) + } + + if (!isTypeOf) { + throw invalidReturnTypeError(returnType, result, fieldNodes); + } + } + + return collectAndExecuteSubfields( + exeContext, + returnType, + fieldNodes, + path, + result, + ); +} + +/** + * + * Note: copy-pasted from `graphql-js`. + */ +function invalidReturnTypeError( + returnType: GraphQLObjectType, + result: unknown, + fieldNodes: ReadonlyArray, +): GraphQLError { + return new GraphQLError( + `Expected value of type "${returnType.name}" but got: ${inspect(result)}.`, + fieldNodes, + ); +} + +/** + * + * Note: reactive equivalent of `graphql-js`'s `collectAndExecuteSubfields`. The difference lies + * in the fact that, here, an `Observable` is returned. + */ +function collectAndExecuteSubfields( + exeContext: ExecutionContext, + returnType: GraphQLObjectType, + fieldNodes: ReadonlyArray, + path: ResponsePath, + result: unknown, +): Observable<{ [key: string]: unknown }> { + // Collect sub-fields to execute to complete this value. + const subFieldNodes = collectSubfields(exeContext, returnType, fieldNodes); + return executeFields(exeContext, returnType, result, path, subFieldNodes); +} + +/** + * A memoized collection of relevant subfields with regard to the return + * type. Memoizing ensures the subfields are not repeatedly calculated, which + * saves overhead when resolving lists of values. + * + * Note: copy-pasted from `graphql-js`. The difference lies in the fact that + * `graphql-js` implements its own memoization, while we use `memoizee` package. + */ +const collectSubfields = memoize(_collectSubfields); +function _collectSubfields( + exeContext: ExecutionContext, + returnType: GraphQLObjectType, + fieldNodes: ReadonlyArray, +): { [key: string]: FieldNode[] } { + let subFieldNodes = Object.create(null); + const visitedFragmentNames = Object.create(null); + for (let i = 0; i < fieldNodes.length; i++) { + const selectionSet = fieldNodes[i].selectionSet; + if (selectionSet) { + subFieldNodes = collectFields( + exeContext, + returnType, + selectionSet, + subFieldNodes, + visitedFragmentNames, + ); + } + } + return subFieldNodes; +} + +type MaybePromise = T | Promise; + +/** + * If a resolveType function is not given, then a default resolve behavior is + * used which attempts two strategies: + * + * First, See if the provided value has a `__typename` field defined, if so, use + * that value as name of the resolved type. + * + * Otherwise, test each possible type for the abstract type by calling + * isTypeOf for the object being coerced, returning the first type that matches. + * Note: copy-pasted from `graphql-js` + */ +function defaultResolveTypeFn( + value: unknown, + contextValue: unknown, + info: GraphQLResolveInfo, + abstractType: GraphQLAbstractType, +): MaybePromise | string> { + // First, look for `__typename`. + if ( + typeof value === 'object' && + value !== null && + typeof value['__typename'] === 'string' + ) { + return value['__typename']; + } + + // Otherwise, test each possible type. + const possibleTypes = info.schema.getPossibleTypes(abstractType); + const promisedIsTypeOfResults: Promise[] = []; + + for (let i = 0; i < possibleTypes.length; i++) { + const type = possibleTypes[i]; + + if (type.isTypeOf) { + const isTypeOfResult = type.isTypeOf(value, contextValue, info); + + if (isTypeOfResult instanceof Promise) { + promisedIsTypeOfResults[i] = isTypeOfResult; + } else if (isTypeOfResult) { + return type; + } + } + } + + if (promisedIsTypeOfResults.length) { + return Promise.all(promisedIsTypeOfResults).then(isTypeOfResults => { + for (let i = 0; i < isTypeOfResults.length; i++) { + if (isTypeOfResults[i]) { + return possibleTypes[i]; + } + } + }); + } +} diff --git a/src/execution/index.ts b/src/execution/index.ts new file mode 100644 index 0000000..bc800bd --- /dev/null +++ b/src/execution/index.ts @@ -0,0 +1 @@ +export { execute } from './execute'; \ No newline at end of file diff --git a/src/graphql.ts b/src/graphql.ts new file mode 100644 index 0000000..c2c25ff --- /dev/null +++ b/src/graphql.ts @@ -0,0 +1,105 @@ +import { Observable, of } from "rxjs"; +import { execute } from "./execution/execute"; +import { ExecutionResult, validateSchema, parse, validate, GraphQLSchema, Source, GraphQLFieldResolver, DocumentNode } from "graphql"; +import Maybe from "graphql/tsutils/Maybe"; + +export type GraphQLArgs = { + schema: GraphQLSchema; + source: string | Source; + rootValue?: unknown; + contextValue?: unknown; + variableValues?: Maybe<{ [key: string]: unknown }>, + operationName?: string | null; + fieldResolver?: GraphQLFieldResolver | null; +}; + +function isGraphQLArgs( + _argsOrSchema: GraphQLSchema | GraphQLArgs, + args: IArguments +): _argsOrSchema is GraphQLArgs { + return args.length === 1; +} + +export function graphql(arg0: GraphQLArgs): Observable>; + +export function graphql( + schema: GraphQLSchema, + source: Source | string, + rootValue?: unknown, + contextValue?: unknown, + variableValues?: Maybe<{ [key: string]: unknown }>, + operationName?: string | null, + fieldResolver?: GraphQLFieldResolver | null, +): Observable>; + +export function graphql( + argsOrSchema, + source?, + rootValue?, + contextValue?, + variableValues?, + operationName?, + fieldResolver?, + +) { + return isGraphQLArgs(argsOrSchema, arguments) + ? graphqlImpl( + argsOrSchema.schema, + argsOrSchema.source, + argsOrSchema.rootValue, + argsOrSchema.contextValue, + argsOrSchema.variableValues, + argsOrSchema.operationName, + argsOrSchema.fieldResolver, + ) + : graphqlImpl( + argsOrSchema, + source, + rootValue, + contextValue, + variableValues, + operationName, + fieldResolver, + ); +} + +function graphqlImpl( + schema: GraphQLSchema, + source: string | Source, + rootValue?: any, + contextValue?: any, + variableValues?: Maybe<{ [key: string]: unknown }>, + operationName?: string | null, + fieldResolver?: GraphQLFieldResolver | null, +): Observable> { + // Validate Schema + const schemaValidationErrors = validateSchema(schema); + if (schemaValidationErrors.length > 0) { + return of({ errors: schemaValidationErrors }); + } + + // Parse + let document: DocumentNode; + try { + document = parse(source); + } catch (syntaxError) { + return of({ errors: [syntaxError] }); + } + + // Validate + const validationErrors = validate(schema, document); + if (validationErrors.length > 0) { + return of({ errors: validationErrors }); + } + + // Execute + return execute( + schema, + document, + rootValue, + contextValue, + variableValues, + operationName, + fieldResolver, + ); +} diff --git a/src/index.ts b/src/index.ts index 9e0e9d4..64b6831 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,7 @@ -export { default as graphql } from "./reactive-graphql"; +export { + graphql +} from './graphql'; + +export { + execute, +} from './execution'; diff --git a/src/jstutils/inspect.ts b/src/jstutils/inspect.ts new file mode 100644 index 0000000..70c9208 --- /dev/null +++ b/src/jstutils/inspect.ts @@ -0,0 +1,6 @@ +// @ts-ignore nodeJS typescript not loaded +import * as util from 'util'; + +export default function inspect(smthing: unknown) { + return util.inspect(smthing); +} diff --git a/src/jstutils/invariant.ts b/src/jstutils/invariant.ts new file mode 100644 index 0000000..c103705 --- /dev/null +++ b/src/jstutils/invariant.ts @@ -0,0 +1,5 @@ +export default function invariant(condition: any, message: string) { + if (!condition) { + throw new Error(message); + } +} diff --git a/src/jstutils/isInvalid.ts b/src/jstutils/isInvalid.ts new file mode 100644 index 0000000..555bd7f --- /dev/null +++ b/src/jstutils/isInvalid.ts @@ -0,0 +1,6 @@ +/** + * Returns true if a value is undefined, or NaN. + */ +export default function isInvalid(value: any): boolean { + return value === undefined || value !== value; +} diff --git a/src/reactive-graphql.ts b/src/reactive-graphql.ts deleted file mode 100644 index 7747aad..0000000 --- a/src/reactive-graphql.ts +++ /dev/null @@ -1,400 +0,0 @@ -import { Observable, of, from, throwError, isObservable } from "rxjs"; -import { concatMap, map, combineLatest } from "rxjs/operators"; - -import { - DefinitionNode, - DocumentNode, - getNamedType, - GraphQLInterfaceType, - GraphQLObjectType, - GraphQLSchema, - GraphQLType, - SelectionNode, - FieldNode, - GraphQLField, - GraphQLFieldResolver, - isTypeSystemDefinitionNode, - isTypeSystemExtensionNode, - Kind, - ArgumentNode, - isScalarType, - isEnumType, - isObjectType, - isNonNullType, - parse, -} from "graphql"; - -import isNullish from './jstutils/isNullish'; - -// WARNING: This is NOT a spec complete graphql implementation -// https://facebook.github.io/graphql/October2016/ - -interface OperationNode { - operation: "query" | "mutation"; -} - -type SchemaNode = SelectionNode | DefinitionNode; - -function isOperationDefinition(node: any): node is OperationNode { - return node.kind === Kind.OPERATION_DEFINITION; -} -function isFieldNode(node: SchemaNode): node is FieldNode { - return node.kind === Kind.FIELD; -} - -// We don't treat OperationDefinitions as Definitions but as entry points for our execution -function isDefinitionNode(node: SchemaNode): node is DefinitionNode { - return ( - node.kind === Kind.FRAGMENT_DEFINITION || - isTypeSystemDefinitionNode(node) || - isTypeSystemExtensionNode(node) - ); -} - -interface FieldWithResolver extends GraphQLField { - resolve: GraphQLFieldResolver; -} - -function isFieldWithResolver( - field: GraphQLField -): field is FieldWithResolver { - return field.resolve instanceof Function; -} - -export default function graphql( - schema: GraphQLSchema, - query: string | DocumentNode, - rootValue?: any, - context: object = {}, - variables: object = {} -): Observable<{ data?: T; errors?: string[] }> { - // Parse - let doc; - if (typeof query === "string") { - try { - doc = parse(query); - } catch (syntaxError) { - return of({ errors: [syntaxError] }); - } - } else { - doc = query; - } - - if (doc.definitions.length !== 1) { - return throwObservable("query must have a single definition as root"); - } - - if (!(schema.getTypeMap instanceof Function)) { - return throwObservable("schema must have a getTypeMap method"); - } - - const types = schema.getTypeMap(); - - return resolve(doc.definitions[0], context, variables, rootValue, null).pipe( - map((data: T) => ({ - data - })) - ); - - function resolve( - definition: SchemaNode, - context: object, - variables: object, - parent: any, - type: GraphQLType | null - ) { - if (isOperationDefinition(definition)) { - const nextType = getResultType(type, definition, parent); - - return resolveResult(definition, context, variables, parent, nextType); - } - - // The definition gives us the field to resolve - if (isFieldNode(definition)) { - const field = getField(type, definition); - - // Something unexpcected was passed into getField - if (field === null) { - return throwObservable( - `field '${ - definition.name.value - }' was not found on type '${type}'. ${fieldNotFoundMessageForType( - type - )}` - ); - } - - const resolvedObservable = resolveField( - field, - definition, - context, - variables, - parent - ) - // If result value is null-ish (null, undefined, or NaN) then return null. - .pipe(map(value => isNullish(value) ? null : value)); - - // Directly return the leaf nodes - if (definition.selectionSet === undefined) { - return resolvedObservable; - } - - return resolvedObservable.pipe( - concatMap(emitted => { - if (!emitted) { - if (isNonNullType(type)) { - return throwObservable(`resolver for ${field.name} emitted empty value`); - } - return of(null); - } - - if (emitted instanceof Array) { - return resolveArrayResults( - definition, - context, - variables, - emitted, - type - ); - } - - const nextType = getResultType(type, definition, emitted); - return resolveResult( - definition, - context, - variables, - emitted, - nextType - ); - }) - ); - } - - // It is no operationDefinitionand no fieldNode, so it seems like an error - return throwObservable( - "Input does not look like OperationDefinition nor FieldNode" - ); - } - - // Goes one level deeper into the query nesting - function resolveResult( - definition: SchemaNode, - context: object, - variables: object, - parent: any, - type: GraphQLType | null - ): Observable { - if (isDefinitionNode(definition)) { - return throwObservable("Definition types should not be present here"); - } - - if (definition.kind === Kind.FRAGMENT_SPREAD) { - return throwObservable("Unsupported use of fragments"); - } - - if (!definition.selectionSet) { - return of(parent); - } - - return definition.selectionSet.selections.reduce((acc, sel) => { - if ( - sel.kind === Kind.FRAGMENT_SPREAD || - sel.kind === Kind.INLINE_FRAGMENT - ) { - return throwObservable("Unsupported use of fragments in selection set"); - } - - const result = resolve(sel, context, variables, parent, type); - const fieldName = (sel.alias || sel.name).value; - - return acc.pipe(combineLatest(result, objectAppendWithKey(fieldName))); - }, of({})); - } - - function resolveArrayResults( - definition: SchemaNode, - context: object, - variables: object, - parents: any[], - parentType: GraphQLType | null - ) { - return parents.reduce((acc, result) => { - const nextType = getResultType(parentType, definition, result); - const resultObserver = resolveResult( - definition, - context, - variables, - result, - nextType - ); - - return acc.pipe( - combineLatest( - // TODO: fix this type overwrite - (resultObserver as unknown) as Observable[], - function(destination: Observable[], source: Observable) { - return destination.concat(source); - } - ) - ); - }, of([])); - } - - function getField( - parentType: GraphQLType | null, - definition: SchemaNode - ): GraphQLField | null { - // Go one level deeper into the query - if (parentType instanceof GraphQLObjectType && isFieldNode(definition)) { - const parentFields = parentType.getFields(); - const fieldName = definition.name.value; - - if (parentFields[fieldName]) { - return parentFields[definition.name.value]; - } - } - - // These cases should ideally at some point be not existant, - // but due to our partial implementation this loop-hole is needed - return null; - } - - function getResultType( - parentType: GraphQLType | null, - definition: SchemaNode, - instance: any - ): GraphQLType | null { - const translateOperation = { - query: "Query", - mutation: "Mutation" - }; - - // Operation is given (query or mutation), returns a type - if (isOperationDefinition(definition)) { - return types[translateOperation[definition.operation]]; - } - - // Get one level deeper in the query nesting - const field = getField(parentType, definition); - if (field !== null) { - const fieldType = getNamedType(field.type); - - // Make this abstract type concrete if possible - if ( - fieldType instanceof GraphQLInterfaceType && - fieldType.resolveType instanceof Function - ) { - // We currenlty only allow resolveType to return a GraphQLObjectType - // and we pass in the wrong values as we don't need this feature currently - // @ts-ignore - return getNamedType(fieldType.resolveType(instance)); - } else { - return fieldType; - } - } - - return null; - } -} - -function throwObservable(error: string): Observable { - return throwError(new Error(`reactive-graphql: ${error}`)); -} - -function buildResolveArgs(definition: FieldNode, variables: object) { - return (definition.arguments || []).reduce( - (carry, arg) => ({ - ...carry, - ...(arg.value.kind === Kind.VARIABLE - ? // @ts-ignore - { [arg.name.value]: variables[arg.value.name.value] } - : { - [arg.name.value]: getArgValue(arg) - }) - }), - {} - ); -} - -function getArgValue(arg: ArgumentNode): any { - if (arg.value.kind === "NullValue" || arg.value.kind === "Variable") { - return null; - } - - if (arg.value.kind === "ListValue") { - return arg.value.values; - } - - if (arg.value.kind === "ObjectValue") { - return arg.value.fields; - } - - return arg.value.value; -} - -const objectAppendWithKey = (key: string) => { - return (destination: object, source: any) => ({ - ...destination, - [key]: source - }); -}; - -function resolveField( - field: GraphQLField, - definition: FieldNode, - context: object, - variables: object, - parent: any -): Observable { - if (!isFieldWithResolver(field)) { - return of(parent[field.name]); - } - - const args = buildResolveArgs(definition, variables); - try { - const resolvedValue = field.resolve( - parent, - args, - context, - // @ts-ignore - null // that would be the info - ); - - if (isObservable(resolvedValue)) { - return resolvedValue; - } - - if (resolvedValue instanceof Promise) { - return from(resolvedValue); - } - - // It seems like a plain value - return of(resolvedValue); - } catch (err) { - return throwObservable( - `resolver '${field.name}' throws this error: '${err}'` - ); - } -} - -export function fieldNotFoundMessageForType(type: GraphQLType | null): string { - if (type === null) { - return "The type should not be null."; - } - - if (isScalarType(type)) { - return "The field has a scalar type, which means it supports no nesting."; - } - - if (isEnumType(type)) { - return "The field has an enum type, which means it supports no nesting."; - } - - if (isObjectType(type)) { - return `The only fields found in this Object are: ${Object.keys( - type.getFields() - )}.`; - } - - return ""; -} diff --git a/src/rxutils/combinePropsLatest.ts b/src/rxutils/combinePropsLatest.ts new file mode 100644 index 0000000..7a64deb --- /dev/null +++ b/src/rxutils/combinePropsLatest.ts @@ -0,0 +1,30 @@ +import { Observable, combineLatest } from 'rxjs'; +import { map } from 'rxjs/operators'; + +/** + * Combine multiple Observables passed as an object to create + * an Observable that emits an object with the same keys and, + * as values, the latest values of each of the corresponding + * input Observables. + * + * Like `combineLatest` but for object properties instead of + * iterable of Observables. + * + * @param input + */ +function combinePropsLatest( + input: { [key: string]: Observable } + ): Observable<{[key: string]: any}> { + const keys = Object.keys(input); + return combineLatest( + keys.map(key => input[key]) + ).pipe(map(resultArray => { + const results: { [key: string]: any } = {}; + resultArray.forEach((result, i) => { + results[keys[i]] = result; + }) + return results; + })) +} + +export default combinePropsLatest; diff --git a/src/rxutils/mapPromiseToObservale.ts b/src/rxutils/mapPromiseToObservale.ts new file mode 100644 index 0000000..c7d436d --- /dev/null +++ b/src/rxutils/mapPromiseToObservale.ts @@ -0,0 +1,15 @@ +import { Observable, from } from "rxjs"; +import { switchMap } from "rxjs/operators"; + +/** + * Build an Observable given a Promise's resolved value. + * + * @param promise + * @param observableFn + */ +export default function mapPromiseToObservale( + promise: Promise, + observableFn: (resolved: TPromiseValue) => Observable +): Observable { + return from(promise).pipe(switchMap(observableFn)); +}; diff --git a/src/rxutils/mapToFirstValue.ts b/src/rxutils/mapToFirstValue.ts new file mode 100644 index 0000000..dc925bb --- /dev/null +++ b/src/rxutils/mapToFirstValue.ts @@ -0,0 +1,19 @@ +import { Observable } from "rxjs"; +import { switchMap, take } from "rxjs/operators"; + +/** + * Will wait for the first value of the given Observable + * for mapping and emits the value of the Observable + * returned by `mappingFn`. + * + * @param observable + * @param mappingFn + */ +export default function mapToFirstValue( + observable: Observable, + mappingFn: (firstValue: TObservableValue) => Observable +): Observable { + return observable + .pipe(take(1)) + .pipe(switchMap(mappingFn)) +};