diff --git a/docs-website/src/pages/docs/directives-reference/index.md b/docs-website/src/pages/docs/directives-reference/index.md index 9a102a8a5..743a56425 100644 --- a/docs-website/src/pages/docs/directives-reference/index.md +++ b/docs-website/src/pages/docs/directives-reference/index.md @@ -19,4 +19,5 @@ isIndexFile: true {% quick-link title="@export directive" icon="core" href="/docs/directives-reference/export-directive" description="Export a field value into Variables" /%} {% quick-link title="@internal directive" icon="core" href="/docs/directives-reference/internal-directive" description="Hide a variable from the external API" /%} {% quick-link title="@transform directive" icon="core" href="/docs/directives-reference/transform-directive" description="Lodash style response transformations" /%} +{% quick-link title="@removeNullVariables directive" icon="core" href="/docs/directives-reference/remove-null-variables-directive" description="Removes null variables query" /%} {% /quick-links %} diff --git a/docs-website/src/pages/docs/directives-reference/remove-null-variables-directive.md b/docs-website/src/pages/docs/directives-reference/remove-null-variables-directive.md new file mode 100644 index 000000000..b3c68d956 --- /dev/null +++ b/docs-website/src/pages/docs/directives-reference/remove-null-variables-directive.md @@ -0,0 +1,28 @@ +--- +title: '@removeNullVariables Directive' +pageTitle: WunderGraph - Directives - @removeNullVariables +description: +--- + +The `@removeNullVariables` directive allows you to remove variables with null value from your GraphQL Query or Mutation Operations. + +A potential use-case could be that you have a graphql upstream which is not accepting null values for variables. +By enabling this directive all variables with null values will be removed from upstream query. + +```graphql +query ($say: String, $name: String) @removeNullVariables { + hello(say: $say, name: $name) +} +``` + +The directive `@removeNullVariables` will transform variables json and remove top level null values. + +```json +{ "say": null, "name": "world" } +``` + +So upstream will receive the following variables: + +```json +{ "name": "world" } +``` diff --git a/go.mod b/go.mod index 11f5259c9..a0b8fc617 100644 --- a/go.mod +++ b/go.mod @@ -36,7 +36,7 @@ require ( github.com/prisma/prisma-client-go v0.16.2 github.com/qri-io/jsonschema v0.2.1 github.com/rs/cors v1.7.0 - github.com/sebdah/goldie v0.0.0-20180424091453-8784dd1ab561 + github.com/sebdah/goldie/v2 v2.5.3 github.com/segmentio/ksuid v1.0.4 github.com/spf13/cobra v1.1.3 github.com/spf13/viper v1.10.1 @@ -44,7 +44,7 @@ require ( github.com/tidwall/gjson v1.11.0 github.com/tidwall/sjson v1.1.5 github.com/valyala/fasthttp v1.26.0 - github.com/wundergraph/graphql-go-tools v1.60.1 + github.com/wundergraph/graphql-go-tools v1.60.3 go.uber.org/zap v1.18.1 golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 diff --git a/go.sum b/go.sum index 01f5f276d..594cc1f19 100644 --- a/go.sum +++ b/go.sum @@ -445,9 +445,8 @@ github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb github.com/savsgio/gotils v0.0.0-20200608150037-a5f6f5aef16c h1:2nF5+FZ4/qp7pZVL7fR6DEaSTzuDmNaFTyqp92/hwF8= github.com/savsgio/gotils v0.0.0-20200608150037-a5f6f5aef16c/go.mod h1:TWNAOTaVzGOXq8RbEvHnhzA/A2sLZzgn0m6URjnukY8= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= -github.com/sebdah/goldie v0.0.0-20180424091453-8784dd1ab561 h1:IY+sDBJR/wRtsxq+626xJnt4Tw7/ROA9cDIR8MMhWyg= -github.com/sebdah/goldie v0.0.0-20180424091453-8784dd1ab561/go.mod h1:lvjGftC8oe7XPtyrOidaMi0rp5B9+XY/ZRUynGnuaxQ= github.com/sebdah/goldie/v2 v2.5.3 h1:9ES/mNN+HNUbNWpVAlrzuZ7jE+Nrczbj8uFRjM7624Y= +github.com/sebdah/goldie/v2 v2.5.3/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI= github.com/segmentio/ksuid v1.0.4 h1:sBo2BdShXjmcugAMwjugoGUdUV0pcxY5mW4xKRn3v4c= github.com/segmentio/ksuid v1.0.4/go.mod h1:/XUiZBD3kVx5SmUOl55voK5yeAbBNNIed+2O73XgrPE= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= @@ -528,8 +527,8 @@ github.com/vmihailenco/msgpack/v5 v5.1.0 h1:+od5YbEXxW95SPlW6beocmt8nOtlh83zqat5 github.com/vmihailenco/msgpack/v5 v5.1.0/go.mod h1:C5gboKD0TJPqWDTVTtrQNfRbiBwHZGo8UTqP/9/XvLI= github.com/vmihailenco/tagparser v0.1.2 h1:gnjoVuB/kljJ5wICEEOpx98oXMWPLj22G67Vbd1qPqc= github.com/vmihailenco/tagparser v0.1.2/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI= -github.com/wundergraph/graphql-go-tools v1.60.1 h1:n/ZyidgnU1wfjeruQ3SUjOaoYzo+kmYKmbcwpHKNjEg= -github.com/wundergraph/graphql-go-tools v1.60.1/go.mod h1:kHPC4B6vQFMohnEofRgcy37eYhoDsjzxJ4P8ZhQrV4M= +github.com/wundergraph/graphql-go-tools v1.60.3 h1:HNKjKJ80MhEHx6ODeTogU/sS2ukMzkZPA2b4gF9PlVg= +github.com/wundergraph/graphql-go-tools v1.60.3/go.mod h1:kHPC4B6vQFMohnEofRgcy37eYhoDsjzxJ4P8ZhQrV4M= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= diff --git a/packages/sdk/src/definition/__snapshots__/graphql-introspection.test.ts.snap b/packages/sdk/src/definition/__snapshots__/graphql-introspection.test.ts.snap index 3b9f446ee..070c0c238 100644 --- a/packages/sdk/src/definition/__snapshots__/graphql-introspection.test.ts.snap +++ b/packages/sdk/src/definition/__snapshots__/graphql-introspection.test.ts.snap @@ -919,6 +919,25 @@ type Query", ], "Schema": "directive @fromClaim(name: Claim) on VARIABLE_DEFINITION +""" +The @removeNullVariables directive allows you to remove variables with null value from your GraphQL Query or Mutation Operations. + +A potential use-case could be that you have a graphql upstream which is not accepting null values for variables. +By enabling this directive all variables with null values will be removed from upstream query. + +query ($say: String, $name: String) @removeNullVariables { + hello(say: $say, name: $name) +} + +Directive will transform variables json and remove top level null values. +{ "say": null, "name": "world" } + +So upstream will receive the following variables: + +{ "name": "world" } +""" +directive @removeNullVariables on QUERY | MUTATION + directive @hooksVariable on VARIABLE_DEFINITION directive @jsonSchema( @@ -2155,6 +2174,25 @@ type Query", ], "Schema": "directive @fromClaim(name: Claim) on VARIABLE_DEFINITION +""" +The @removeNullVariables directive allows you to remove variables with null value from your GraphQL Query or Mutation Operations. + +A potential use-case could be that you have a graphql upstream which is not accepting null values for variables. +By enabling this directive all variables with null values will be removed from upstream query. + +query ($say: String, $name: String) @removeNullVariables { + hello(say: $say, name: $name) +} + +Directive will transform variables json and remove top level null values. +{ "say": null, "name": "world" } + +So upstream will receive the following variables: + +{ "name": "world" } +""" +directive @removeNullVariables on QUERY | MUTATION + directive @hooksVariable on VARIABLE_DEFINITION directive @jsonSchema( diff --git a/packages/sdk/src/definition/__snapshots__/merge.test.ts.snap b/packages/sdk/src/definition/__snapshots__/merge.test.ts.snap index 5001a43eb..02aec3b54 100644 --- a/packages/sdk/src/definition/__snapshots__/merge.test.ts.snap +++ b/packages/sdk/src/definition/__snapshots__/merge.test.ts.snap @@ -3,7 +3,7 @@ exports[`Should be merged: merged_apis 1`] = ` "{ "DefaultFlushInterval": 500, - "Schema": "directive @fromClaim(name: Claim) on VARIABLE_DEFINITION\\n\\ndirective @hooksVariable on VARIABLE_DEFINITION\\n\\ndirective @jsonSchema(\\n \\"\\"\\"\\n The value of both of these keywords MUST be a string.\\n \\n Both of these keywords can be used to decorate a user interface with\\n information about the data produced by this user interface. A title\\n will preferably be short, whereas a description will provide\\n explanation about the purpose of the instance described by this\\n schema.\\n \\"\\"\\"\\n title: String\\n \\"\\"\\"\\n The value of both of these keywords MUST be a string.\\n \\n Both of these keywords can be used to decorate a user interface with\\n information about the data produced by this user interface. A title\\n will preferably be short, whereas a description will provide\\n explanation about the purpose of the instance described by this\\n schema.\\n \\"\\"\\"\\n description: String\\n \\"\\"\\"\\n The value of \\"multipleOf\\" MUST be a number, strictly greater than 0.\\n \\n A numeric instance is valid only if division by this keyword's value\\n results in an integer.\\n \\"\\"\\"\\n multipleOf: Int\\n \\"\\"\\"\\n The value of \\"maximum\\" MUST be a number, representing an inclusive\\n upper limit for a numeric instance.\\n \\n If the instance is a number, then this keyword validates only if the\\n instance is less than or exactly equal to \\"maximum\\".\\n \\"\\"\\"\\n maximum: Int\\n \\"\\"\\"\\n The value of \\"exclusiveMaximum\\" MUST be number, representing an\\n exclusive upper limit for a numeric instance.\\n \\n If the instance is a number, then the instance is valid only if it\\n has a value strictly less than (not equal to) \\"exclusiveMaximum\\".\\n \\"\\"\\"\\n exclusiveMaximum: Int\\n \\"\\"\\"\\n The value of \\"minimum\\" MUST be a number, representing an inclusive\\n lower limit for a numeric instance.\\n \\n If the instance is a number, then this keyword validates only if the\\n instance is greater than or exactly equal to \\"minimum\\".\\n \\"\\"\\"\\n minimum: Int\\n \\"\\"\\"\\n The value of \\"exclusiveMinimum\\" MUST be number, representing an\\n exclusive lower limit for a numeric instance.\\n \\n If the instance is a number, then the instance is valid only if it\\n has a value strictly greater than (not equal to) \\"exclusiveMinimum\\".\\n \\"\\"\\"\\n exclusiveMinimum: Int\\n \\"\\"\\"\\n The value of this keyword MUST be a non-negative integer.\\n \\n A string instance is valid against this keyword if its length is less\\n than, or equal to, the value of this keyword.\\n \\n The length of a string instance is defined as the number of its\\n characters as defined by RFC 7159 [RFC7159].\\n \\"\\"\\"\\n maxLength: Int\\n \\"\\"\\"\\n The value of this keyword MUST be a non-negative integer.\\n \\n A string instance is valid against this keyword if its length is\\n greater than, or equal to, the value of this keyword.\\n \\n The length of a string instance is defined as the number of its\\n characters as defined by RFC 7159 [RFC7159].\\n \\n Omitting this keyword has the same behavior as a value of 0.\\n \\"\\"\\"\\n minLength: Int\\n \\"\\"\\"\\n The value of this keyword MUST be a string. This string SHOULD be a\\n valid regular expression, according to the ECMA 262 regular\\n expression dialect.\\n \\n A string instance is considered valid if the regular expression\\n matches the instance successfully. Recall: regular expressions are\\n not implicitly anchored.\\n \\"\\"\\"\\n pattern: String\\n \\"\\"\\"\\n The value of this keyword MUST be a non-negative integer.\\n \\n An array instance is valid against \\"maxItems\\" if its size is less\\n than, or equal to, the value of this keyword.\\n \\"\\"\\"\\n maxItems: Int\\n \\"\\"\\"\\n The value of this keyword MUST be a non-negative integer.\\n \\n An array instance is valid against \\"minItems\\" if its size is greater\\n than, or equal to, the value of this keyword.\\n \\n Omitting this keyword has the same behavior as a value of 0.\\n \\"\\"\\"\\n minItems: Int\\n \\"\\"\\"\\n The value of this keyword MUST be a boolean.\\n \\n If this keyword has boolean value false, the instance validates\\n successfully. If it has boolean value true, the instance validates\\n successfully if all of its elements are unique.\\n \\n Omitting this keyword has the same behavior as a value of false.\\n \\"\\"\\"\\n uniqueItems: Boolean\\n commonPattern: COMMON_REGEX_PATTERN\\n) on VARIABLE_DEFINITION\\n\\n\\"\\"\\"\\nThe directive @injectCurrentDateTime injects a DateTime string of the current date and time into the variable.\\nThis variable MUST be a string compatible scalar. \\n\\nThe default format, is: ISO 8601\\nIf no format is chosen, the default format is used.\\nCustom formats are allowed by specifying a format conforming to the Golang specification for specifying a date time format.\\n\\"\\"\\"\\ndirective @injectCurrentDateTime(\\n format: WunderGraphDateTimeFormat = ISO8601\\n \\"\\"\\"\\n customFormat must conform to the Golang specification for specifying a date time format\\n \\"\\"\\"\\n customFormat: String\\n) on VARIABLE_DEFINITION\\n\\n\\"\\"\\"\\nThe directive @injectGeneratedUUID injects a generated UUID into the variable.\\nThis variable MUST be a string.\\nAt the same time, it removes the variable from the input definition,\\ndisallowing the user to supply it.\\n\\nThis means, the UUID is 100% generated server-side and can be considered untempered.\\n\\"\\"\\"\\ndirective @injectGeneratedUUID on VARIABLE_DEFINITION\\n\\n\\"\\"\\"\\nThe @internalOperation Directive marks an Operation as internal.\\nBy doing so, the Operation is no longer accessible from the public API.\\nIt can only be accessed by internal services, like hooks.\\n\\"\\"\\"\\ndirective @internalOperation on QUERY | MUTATION | SUBSCRIPTION\\n\\n\\"\\"\\"\\nThe directive @injectEnvironmentVariable allows you to inject an environment variable into the variable definition.\\n\\"\\"\\"\\ndirective @injectEnvironmentVariable(name: String!) on VARIABLE_DEFINITION\\n\\n\\"\\"\\"\\nThe @export directive instructs the Execution Planner to export the field during the execution into the variable of the 'as' argument.\\nAs the execution is depth first, a field can only be used after it has been exported.\\nAdditionally, a field can only be used after using the '_join' field or on a different data source.\\nIt's not possible to export a field and use it in for the same data source.\\n\\nNote that the @export directive only works on fields that return a single value.\\nIt's not possible to export a list or object field.\\n\\"\\"\\"\\ndirective @export(\\n \\"\\"\\"The argument 'as' is the name of the variable to export the field to.\\"\\"\\"\\n as: String!\\n) on FIELD\\n\\n\\"\\"\\"\\nThe directive @internal marks a variable definition as internal so that clients can't access it.\\nThe field is also not visible in the public API.\\nIt's only being used as an internal variable to export fields into.\\n\\"\\"\\"\\ndirective @internal on VARIABLE_DEFINITION\\n\\n\\"\\"\\"\\nThe @transform directive allows to apply transformations to the response.\\nBy applying the directive, the shape of the response can be altered,\\nwhich will also modify the JSON-Schema of the response.\\nThat is, you will keep full type safety and code-generation for transformed fields.\\n\\"\\"\\"\\ndirective @transform(\\n \\"\\"\\"\\n Using the 'get' transformation allows you to extract a nested field using a JSON path.\\n This is useful to unnest data, e.g. when using the '_join' field, which adds an extra layer of nesting.\\n \\n Example:\\n \\n query GetName {\\n name: me @transform(get: \\"info.name\\") {\\n info {\\n name\\n }\\n }\\n }\\n \\n Before the transformation, the resolve looks like this:\\n \\n {\\n \\"name\\": {\\n \\"info\\": {\\n \\"name\\": \\"John Doe\\"\\n }\\n }\\n }\\n \\n With the transformation applied, the response will be reshaped like this:\\n \\n {\\n \\"name\\": \\"John Doe\\"\\n }\\n \\"\\"\\"\\n get: String\\n) on FIELD\\n\\ntype Query {\\n me: User\\n topProducts(first: Int = 5): [Product]\\n}\\n\\ntype User {\\n id: ID!\\n name: String\\n username: String\\n reviews: [Review]\\n _join: Query!\\n}\\n\\ntype Product {\\n upc: String!\\n name: String\\n price: Int\\n weight: Int\\n reviews: [Review]\\n inStock: Boolean\\n shippingEstimate: Int\\n _join: Query!\\n}\\n\\ntype Review {\\n id: ID!\\n body: String\\n author: User\\n product: Product\\n _join: Query!\\n}\\n\\nenum Claim {\\n USERID\\n EMAIL\\n EMAIL_VERIFIED\\n NAME\\n NICKNAME\\n LOCATION\\n PROVIDER\\n}\\n\\nenum COMMON_REGEX_PATTERN {\\n EMAIL\\n DOMAIN\\n}\\n\\nenum WunderGraphDateTimeFormat {\\n \\"\\"\\"2006-01-02T15:04:05-0700\\"\\"\\"\\n ISO8601\\n \\"\\"\\"Mon Jan _2 15:04:05 2006\\"\\"\\"\\n ANSIC\\n \\"\\"\\"Mon Jan _2 15:04:05 MST 2006\\"\\"\\"\\n UnixDate\\n \\"\\"\\"Mon Jan 02 15:04:05 -0700 2006\\"\\"\\"\\n RubyDate\\n \\"\\"\\"02 Jan 06 15:04 MST\\"\\"\\"\\n RFC822\\n \\"\\"\\"02 Jan 06 15:04 -0700\\"\\"\\"\\n RFC822Z\\n \\"\\"\\"Monday, 02-Jan-06 15:04:05 MST\\"\\"\\"\\n RFC850\\n \\"\\"\\"Mon, 02 Jan 2006 15:04:05 MST\\"\\"\\"\\n RFC1123\\n \\"\\"\\"Mon, 02 Jan 2006 15:04:05 -0700\\"\\"\\"\\n RFC1123Z\\n \\"\\"\\"2006-01-02T15:04:05Z07:00\\"\\"\\"\\n RFC3339\\n \\"\\"\\"2006-01-02T15:04:05.999999999Z07:00\\"\\"\\"\\n RFC3339Nano\\n \\"\\"\\"3:04PM\\"\\"\\"\\n Kitchen\\n \\"\\"\\"Jan _2 15:04:05\\"\\"\\"\\n Stamp\\n \\"\\"\\"Jan _2 15:04:05.000\\"\\"\\"\\n StampMilli\\n \\"\\"\\"Jan _2 15:04:05.000000\\"\\"\\"\\n StampMicro\\n \\"\\"\\"Jan _2 15:04:05.000000000\\"\\"\\"\\n StampNano\\n}", + "Schema": "directive @fromClaim(name: Claim) on VARIABLE_DEFINITION\\n\\n\\"\\"\\"\\nThe @removeNullVariables directive allows you to remove variables with null value from your GraphQL Query or Mutation Operations.\\n\\nA potential use-case could be that you have a graphql upstream which is not accepting null values for variables.\\nBy enabling this directive all variables with null values will be removed from upstream query.\\n\\nquery ($say: String, $name: String) @removeNullVariables {\\n\\thello(say: $say, name: $name)\\n}\\n\\nDirective will transform variables json and remove top level null values.\\n{ \\"say\\": null, \\"name\\": \\"world\\" }\\n\\nSo upstream will receive the following variables:\\n\\n{ \\"name\\": \\"world\\" }\\n\\"\\"\\"\\ndirective @removeNullVariables on QUERY | MUTATION\\n\\ndirective @hooksVariable on VARIABLE_DEFINITION\\n\\ndirective @jsonSchema(\\n \\"\\"\\"\\n The value of both of these keywords MUST be a string.\\n \\n Both of these keywords can be used to decorate a user interface with\\n information about the data produced by this user interface. A title\\n will preferably be short, whereas a description will provide\\n explanation about the purpose of the instance described by this\\n schema.\\n \\"\\"\\"\\n title: String\\n \\"\\"\\"\\n The value of both of these keywords MUST be a string.\\n \\n Both of these keywords can be used to decorate a user interface with\\n information about the data produced by this user interface. A title\\n will preferably be short, whereas a description will provide\\n explanation about the purpose of the instance described by this\\n schema.\\n \\"\\"\\"\\n description: String\\n \\"\\"\\"\\n The value of \\"multipleOf\\" MUST be a number, strictly greater than 0.\\n \\n A numeric instance is valid only if division by this keyword's value\\n results in an integer.\\n \\"\\"\\"\\n multipleOf: Int\\n \\"\\"\\"\\n The value of \\"maximum\\" MUST be a number, representing an inclusive\\n upper limit for a numeric instance.\\n \\n If the instance is a number, then this keyword validates only if the\\n instance is less than or exactly equal to \\"maximum\\".\\n \\"\\"\\"\\n maximum: Int\\n \\"\\"\\"\\n The value of \\"exclusiveMaximum\\" MUST be number, representing an\\n exclusive upper limit for a numeric instance.\\n \\n If the instance is a number, then the instance is valid only if it\\n has a value strictly less than (not equal to) \\"exclusiveMaximum\\".\\n \\"\\"\\"\\n exclusiveMaximum: Int\\n \\"\\"\\"\\n The value of \\"minimum\\" MUST be a number, representing an inclusive\\n lower limit for a numeric instance.\\n \\n If the instance is a number, then this keyword validates only if the\\n instance is greater than or exactly equal to \\"minimum\\".\\n \\"\\"\\"\\n minimum: Int\\n \\"\\"\\"\\n The value of \\"exclusiveMinimum\\" MUST be number, representing an\\n exclusive lower limit for a numeric instance.\\n \\n If the instance is a number, then the instance is valid only if it\\n has a value strictly greater than (not equal to) \\"exclusiveMinimum\\".\\n \\"\\"\\"\\n exclusiveMinimum: Int\\n \\"\\"\\"\\n The value of this keyword MUST be a non-negative integer.\\n \\n A string instance is valid against this keyword if its length is less\\n than, or equal to, the value of this keyword.\\n \\n The length of a string instance is defined as the number of its\\n characters as defined by RFC 7159 [RFC7159].\\n \\"\\"\\"\\n maxLength: Int\\n \\"\\"\\"\\n The value of this keyword MUST be a non-negative integer.\\n \\n A string instance is valid against this keyword if its length is\\n greater than, or equal to, the value of this keyword.\\n \\n The length of a string instance is defined as the number of its\\n characters as defined by RFC 7159 [RFC7159].\\n \\n Omitting this keyword has the same behavior as a value of 0.\\n \\"\\"\\"\\n minLength: Int\\n \\"\\"\\"\\n The value of this keyword MUST be a string. This string SHOULD be a\\n valid regular expression, according to the ECMA 262 regular\\n expression dialect.\\n \\n A string instance is considered valid if the regular expression\\n matches the instance successfully. Recall: regular expressions are\\n not implicitly anchored.\\n \\"\\"\\"\\n pattern: String\\n \\"\\"\\"\\n The value of this keyword MUST be a non-negative integer.\\n \\n An array instance is valid against \\"maxItems\\" if its size is less\\n than, or equal to, the value of this keyword.\\n \\"\\"\\"\\n maxItems: Int\\n \\"\\"\\"\\n The value of this keyword MUST be a non-negative integer.\\n \\n An array instance is valid against \\"minItems\\" if its size is greater\\n than, or equal to, the value of this keyword.\\n \\n Omitting this keyword has the same behavior as a value of 0.\\n \\"\\"\\"\\n minItems: Int\\n \\"\\"\\"\\n The value of this keyword MUST be a boolean.\\n \\n If this keyword has boolean value false, the instance validates\\n successfully. If it has boolean value true, the instance validates\\n successfully if all of its elements are unique.\\n \\n Omitting this keyword has the same behavior as a value of false.\\n \\"\\"\\"\\n uniqueItems: Boolean\\n commonPattern: COMMON_REGEX_PATTERN\\n) on VARIABLE_DEFINITION\\n\\n\\"\\"\\"\\nThe directive @injectCurrentDateTime injects a DateTime string of the current date and time into the variable.\\nThis variable MUST be a string compatible scalar. \\n\\nThe default format, is: ISO 8601\\nIf no format is chosen, the default format is used.\\nCustom formats are allowed by specifying a format conforming to the Golang specification for specifying a date time format.\\n\\"\\"\\"\\ndirective @injectCurrentDateTime(\\n format: WunderGraphDateTimeFormat = ISO8601\\n \\"\\"\\"\\n customFormat must conform to the Golang specification for specifying a date time format\\n \\"\\"\\"\\n customFormat: String\\n) on VARIABLE_DEFINITION\\n\\n\\"\\"\\"\\nThe directive @injectGeneratedUUID injects a generated UUID into the variable.\\nThis variable MUST be a string.\\nAt the same time, it removes the variable from the input definition,\\ndisallowing the user to supply it.\\n\\nThis means, the UUID is 100% generated server-side and can be considered untempered.\\n\\"\\"\\"\\ndirective @injectGeneratedUUID on VARIABLE_DEFINITION\\n\\n\\"\\"\\"\\nThe @internalOperation Directive marks an Operation as internal.\\nBy doing so, the Operation is no longer accessible from the public API.\\nIt can only be accessed by internal services, like hooks.\\n\\"\\"\\"\\ndirective @internalOperation on QUERY | MUTATION | SUBSCRIPTION\\n\\n\\"\\"\\"\\nThe directive @injectEnvironmentVariable allows you to inject an environment variable into the variable definition.\\n\\"\\"\\"\\ndirective @injectEnvironmentVariable(name: String!) on VARIABLE_DEFINITION\\n\\n\\"\\"\\"\\nThe @export directive instructs the Execution Planner to export the field during the execution into the variable of the 'as' argument.\\nAs the execution is depth first, a field can only be used after it has been exported.\\nAdditionally, a field can only be used after using the '_join' field or on a different data source.\\nIt's not possible to export a field and use it in for the same data source.\\n\\nNote that the @export directive only works on fields that return a single value.\\nIt's not possible to export a list or object field.\\n\\"\\"\\"\\ndirective @export(\\n \\"\\"\\"The argument 'as' is the name of the variable to export the field to.\\"\\"\\"\\n as: String!\\n) on FIELD\\n\\n\\"\\"\\"\\nThe directive @internal marks a variable definition as internal so that clients can't access it.\\nThe field is also not visible in the public API.\\nIt's only being used as an internal variable to export fields into.\\n\\"\\"\\"\\ndirective @internal on VARIABLE_DEFINITION\\n\\n\\"\\"\\"\\nThe @transform directive allows to apply transformations to the response.\\nBy applying the directive, the shape of the response can be altered,\\nwhich will also modify the JSON-Schema of the response.\\nThat is, you will keep full type safety and code-generation for transformed fields.\\n\\"\\"\\"\\ndirective @transform(\\n \\"\\"\\"\\n Using the 'get' transformation allows you to extract a nested field using a JSON path.\\n This is useful to unnest data, e.g. when using the '_join' field, which adds an extra layer of nesting.\\n \\n Example:\\n \\n query GetName {\\n name: me @transform(get: \\"info.name\\") {\\n info {\\n name\\n }\\n }\\n }\\n \\n Before the transformation, the resolve looks like this:\\n \\n {\\n \\"name\\": {\\n \\"info\\": {\\n \\"name\\": \\"John Doe\\"\\n }\\n }\\n }\\n \\n With the transformation applied, the response will be reshaped like this:\\n \\n {\\n \\"name\\": \\"John Doe\\"\\n }\\n \\"\\"\\"\\n get: String\\n) on FIELD\\n\\ntype Query {\\n me: User\\n topProducts(first: Int = 5): [Product]\\n}\\n\\ntype User {\\n id: ID!\\n name: String\\n username: String\\n reviews: [Review]\\n _join: Query!\\n}\\n\\ntype Product {\\n upc: String!\\n name: String\\n price: Int\\n weight: Int\\n reviews: [Review]\\n inStock: Boolean\\n shippingEstimate: Int\\n _join: Query!\\n}\\n\\ntype Review {\\n id: ID!\\n body: String\\n author: User\\n product: Product\\n _join: Query!\\n}\\n\\nenum Claim {\\n USERID\\n EMAIL\\n EMAIL_VERIFIED\\n NAME\\n NICKNAME\\n LOCATION\\n PROVIDER\\n}\\n\\nenum COMMON_REGEX_PATTERN {\\n EMAIL\\n DOMAIN\\n}\\n\\nenum WunderGraphDateTimeFormat {\\n \\"\\"\\"2006-01-02T15:04:05-0700\\"\\"\\"\\n ISO8601\\n \\"\\"\\"Mon Jan _2 15:04:05 2006\\"\\"\\"\\n ANSIC\\n \\"\\"\\"Mon Jan _2 15:04:05 MST 2006\\"\\"\\"\\n UnixDate\\n \\"\\"\\"Mon Jan 02 15:04:05 -0700 2006\\"\\"\\"\\n RubyDate\\n \\"\\"\\"02 Jan 06 15:04 MST\\"\\"\\"\\n RFC822\\n \\"\\"\\"02 Jan 06 15:04 -0700\\"\\"\\"\\n RFC822Z\\n \\"\\"\\"Monday, 02-Jan-06 15:04:05 MST\\"\\"\\"\\n RFC850\\n \\"\\"\\"Mon, 02 Jan 2006 15:04:05 MST\\"\\"\\"\\n RFC1123\\n \\"\\"\\"Mon, 02 Jan 2006 15:04:05 -0700\\"\\"\\"\\n RFC1123Z\\n \\"\\"\\"2006-01-02T15:04:05Z07:00\\"\\"\\"\\n RFC3339\\n \\"\\"\\"2006-01-02T15:04:05.999999999Z07:00\\"\\"\\"\\n RFC3339Nano\\n \\"\\"\\"3:04PM\\"\\"\\"\\n Kitchen\\n \\"\\"\\"Jan _2 15:04:05\\"\\"\\"\\n Stamp\\n \\"\\"\\"Jan _2 15:04:05.000\\"\\"\\"\\n StampMilli\\n \\"\\"\\"Jan _2 15:04:05.000000\\"\\"\\"\\n StampMicro\\n \\"\\"\\"Jan _2 15:04:05.000000000\\"\\"\\"\\n StampNano\\n}", "DataSources": [ { "Kind": 2, @@ -513,6 +513,25 @@ exports[`Should be merged: merged_apis 1`] = ` exports[`Should be merged: merged_schema 1`] = ` "directive @fromClaim(name: Claim) on VARIABLE_DEFINITION +""" +The @removeNullVariables directive allows you to remove variables with null value from your GraphQL Query or Mutation Operations. + +A potential use-case could be that you have a graphql upstream which is not accepting null values for variables. +By enabling this directive all variables with null values will be removed from upstream query. + +query ($say: String, $name: String) @removeNullVariables { + hello(say: $say, name: $name) +} + +Directive will transform variables json and remove top level null values. +{ "say": null, "name": "world" } + +So upstream will receive the following variables: + +{ "name": "world" } +""" +directive @removeNullVariables on QUERY | MUTATION + directive @hooksVariable on VARIABLE_DEFINITION directive @jsonSchema( diff --git a/packages/sdk/src/definition/merge.ts b/packages/sdk/src/definition/merge.ts index 854d81804..38847825b 100644 --- a/packages/sdk/src/definition/merge.ts +++ b/packages/sdk/src/definition/merge.ts @@ -80,6 +80,25 @@ directive @fromClaim( name: Claim ) on VARIABLE_DEFINITION +""" +The @removeNullVariables directive allows you to remove variables with null value from your GraphQL Query or Mutation Operations. + +A potential use-case could be that you have a graphql upstream which is not accepting null values for variables. +By enabling this directive all variables with null values will be removed from upstream query. + +query ($say: String, $name: String) @removeNullVariables { + hello(say: $say, name: $name) +} + +Directive will transform variables json and remove top level null values. +{ "say": null, "name": "world" } + +So upstream will receive the following variables: + +{ "name": "world" } +""" +directive @removeNullVariables on QUERY | MUTATION + enum Claim { USERID EMAIL diff --git a/pkg/apihandler/apihandler.go b/pkg/apihandler/apihandler.go index 83666b80f..82259ab1f 100644 --- a/pkg/apihandler/apihandler.go +++ b/pkg/apihandler/apihandler.go @@ -402,7 +402,7 @@ func (r *Builder) operationApiPath(name string) string { } func (r *Builder) registerInvalidOperation(name string) { - apiPath := r.operationApiPath((name)) + apiPath := r.operationApiPath(name) route := r.router.Methods(http.MethodGet, http.MethodPost, http.MethodOptions).Path(apiPath) route.Handler(&EndpointUnavailableHandler{ OperationName: name, @@ -439,14 +439,17 @@ func (r *Builder) registerOperation(operation *wgpb.Operation) error { shared.Parser.Parse(shared.Doc, shared.Report) if shared.Report.HasErrors() { - return shared.Report + return fmt.Errorf(ErrMsgOperationParseFailed, shared.Report) } shared.Normalizer.NormalizeNamedOperation(shared.Doc, r.definition, []byte(operation.Name), shared.Report) + if shared.Report.HasErrors() { + return fmt.Errorf(ErrMsgOperationNormalizationFailed, shared.Report) + } state := shared.Validation.Validate(shared.Doc, r.definition, shared.Report) if state != astvalidation.Valid { - return shared.Report + return fmt.Errorf(ErrMsgOperationValidationFailed, shared.Report) } preparedPlan := shared.Planner.Plan(shared.Doc, r.definition, operation.Name, shared.Report) @@ -845,6 +848,9 @@ func (h *GraphQLHandler) preparePlan(operationHash uint64, requestOperationName } else { shared.Normalizer.NormalizeNamedOperation(shared.Doc, h.definition, requestOperationName, shared.Report) } + if shared.Report.HasErrors() { + return nil, fmt.Errorf(ErrMsgOperationNormalizationFailed, shared.Report) + } state := shared.Validation.Validate(shared.Doc, h.definition, shared.Report) if state != astvalidation.Valid { diff --git a/pkg/apihandler/errors.go b/pkg/apihandler/errors.go new file mode 100644 index 000000000..94d89558f --- /dev/null +++ b/pkg/apihandler/errors.go @@ -0,0 +1,7 @@ +package apihandler + +const ( + ErrMsgOperationParseFailed = "failed to parse operation: %w" + ErrMsgOperationNormalizationFailed = "failed to normalize operation: %w" + ErrMsgOperationValidationFailed = "operation validation failed: %w" +) diff --git a/pkg/apihandler/internalapihandler.go b/pkg/apihandler/internalapihandler.go index a2ab75d73..7ff2a14f2 100644 --- a/pkg/apihandler/internalapihandler.go +++ b/pkg/apihandler/internalapihandler.go @@ -115,14 +115,17 @@ func (i *InternalBuilder) registerOperation(operation *wgpb.Operation) error { shared.Parser.Parse(shared.Doc, shared.Report) if shared.Report.HasErrors() { - return shared.Report + return fmt.Errorf(ErrMsgOperationParseFailed, shared.Report) } shared.Normalizer.NormalizeNamedOperation(shared.Doc, i.definition, []byte(operation.Name), shared.Report) + if shared.Report.HasErrors() { + return fmt.Errorf(ErrMsgOperationNormalizationFailed, shared.Report) + } state := shared.Validation.Validate(shared.Doc, i.definition, shared.Report) if state != astvalidation.Valid { - return shared.Report + return fmt.Errorf(ErrMsgOperationValidationFailed, shared.Report) } preparedPlan := shared.Planner.Plan(shared.Doc, i.definition, operation.Name, shared.Report) diff --git a/pkg/node/node_test.go b/pkg/node/node_test.go index 4a64e9fbc..a3468fd3d 100644 --- a/pkg/node/node_test.go +++ b/pkg/node/node_test.go @@ -18,7 +18,7 @@ import ( "github.com/gavv/httpexpect/v2" "github.com/phayes/freeport" - "github.com/sebdah/goldie" + "github.com/sebdah/goldie/v2" "github.com/stretchr/testify/assert" "go.uber.org/zap" "go.uber.org/zap/zapcore" @@ -29,6 +29,8 @@ import ( ) func TestNode(t *testing.T) { + g := goldie.New(t, goldie.WithFixtureDir("fixtures")) + logger := logging.New(true, false, zapcore.DebugLevel) userService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -51,7 +53,7 @@ func TestNode(t *testing.T) { assert.Equal(t, "67b77eab-d1a5-4cd8-b908-8443f24502b6", r.Header.Get("X-Request-Id")) req, _ := httputil.DumpRequest(r, true) _ = req - if bytes.Contains(req, []byte(`{"variables":{},"query":"query($first: Int){topProducts(first: $first){upc name price}}"}`)) { + if bytes.Contains(req, []byte(`{"variables":{"first":null},"query":"query($first: Int){topProducts(first: $first){upc name price}}"}`)) { _, _ = w.Write([]byte(`{"data":{"topProducts":[{"upc":"1","name":"A","price":1},{"upc":"2","name":"B","price":2}]}}`)) return } @@ -161,32 +163,32 @@ func TestNode(t *testing.T) { myReviews := withHeaders.GET("/operations/MyReviews"). WithQuery("unknown", 123). Expect().Status(http.StatusOK).Body().Raw() - goldie.Assert(t, "get my reviews json rpc", prettyJSON(myReviews)) + g.Assert(t, "get my reviews json rpc", prettyJSON(myReviews)) topProductsWithoutQuery := withHeaders.GET("/operations/TopProducts"). Expect().Status(http.StatusOK).Body().Raw() - goldie.Assert(t, "top products without query", prettyJSON(topProductsWithoutQuery)) + g.Assert(t, "top products without query", prettyJSON(topProductsWithoutQuery)) topProductsWithQuery := withHeaders.GET("/operations/TopProducts"). WithQuery("first", 1). WithQuery("unknown", 123). Expect().Status(http.StatusOK).Body().Raw() - goldie.Assert(t, "top products with query", prettyJSON(topProductsWithQuery)) + g.Assert(t, "top products with query", prettyJSON(topProductsWithQuery)) topProductsWithInvalidQuery := withHeaders.GET("/operations/TopProducts"). WithQuery("first", true). Expect().Status(http.StatusBadRequest).Body().Raw() - goldie.Assert(t, "top products with invalid query", prettyJSON(topProductsWithInvalidQuery)) + g.Assert(t, "top products with invalid query", prettyJSON(topProductsWithInvalidQuery)) topProductsWithQueryAsWgVariables := withHeaders.GET("/operations/TopProducts"). WithQuery("wg_variables", `{"first":1}`). Expect().Status(http.StatusOK).Body().Raw() - goldie.Assert(t, "top products with query as wg variables", prettyJSON(topProductsWithQueryAsWgVariables)) + g.Assert(t, "top products with query as wg variables", prettyJSON(topProductsWithQueryAsWgVariables)) topProductsWithInvalidQueryAsWgVariables := withHeaders.GET("/operations/TopProducts"). WithQuery("wg_variables", `{"first":true}`). Expect().Status(http.StatusBadRequest).Body().Raw() - goldie.Assert(t, "top products with invalid query as wg variables", prettyJSON(topProductsWithInvalidQueryAsWgVariables)) + g.Assert(t, "top products with invalid query as wg variables", prettyJSON(topProductsWithInvalidQueryAsWgVariables)) request := GraphQLRequest{ OperationName: "MyReviews", @@ -194,13 +196,15 @@ func TestNode(t *testing.T) { } actual := withHeaders.POST("/graphql").WithJSON(request).Expect().Status(http.StatusOK).Body().Raw() - goldie.Assert(t, "post my reviews graphql", prettyJSON(actual)) + g.Assert(t, "post my reviews graphql", prettyJSON(actual)) withHeaders.GET("/graphql").Expect().Status(http.StatusOK).Text( httpexpect.ContentOpts{MediaType: "text/html"}) } func TestInMemoryCache(t *testing.T) { + g := goldie.New(t, goldie.WithFixtureDir("fixtures")) + logger := logging.New(true, false, zapcore.DebugLevel) productService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -208,7 +212,7 @@ func TestInMemoryCache(t *testing.T) { if err != nil { t.Fatal(err) } - if bytes.Contains(data, []byte(`{"variables":{},"query":"query($first: Int){topProducts(first: $first){upc name price}}"}`)) { + if bytes.Contains(data, []byte(`{"variables":{"first":null},"query":"query($first: Int){topProducts(first: $first){upc name price}}"}`)) { if _, err := io.WriteString(w, `{"data":{"topProducts":[{"upc":"1","name":"A","price":1},{"upc":"2","name":"B","price":2}]}}`); err != nil { t.Fatal(err) } @@ -312,13 +316,13 @@ func TestInMemoryCache(t *testing.T) { // Send a request to populate the cache cold := withHeaders.GET("/operations/TopProducts").Expect() cold.Status(http.StatusOK).Header(cacheHeaderName).Equal("MISS") - goldie.Assert(t, expectedDataName, prettyJSON(cold.Body().Raw())) + g.Assert(t, expectedDataName, prettyJSON(cold.Body().Raw())) // Close the origin, so request can only be answered from the in-memory cache productService.Close() hot := withHeaders.GET("/operations/TopProducts").Expect() hot.Status(http.StatusOK).Header(cacheHeaderName).Equal("HIT") - goldie.Assert(t, expectedDataName, prettyJSON(hot.Body().Raw())) + g.Assert(t, expectedDataName, prettyJSON(hot.Body().Raw())) } func TestWebHooks(t *testing.T) {