Skip to content

Commit

Permalink
feat: add @removeNullVariables directives (#477)
Browse files Browse the repository at this point in the history
* fix: upgrade graphql-go-tools with input coercion fix

* chore: add errors with explanation text to operation parse, normalize and validate stages

* chore: fmt

* chore: add removeNullVariables directive to base schema

* chore: add remove null variables directive description to docs

* chore: update graphql-go-tools to v1.60.3

* chore: update base grapphql schema docs for @removeNullVariable directive

* chore: update test snapshots

* chore: fix node tests, update goldie

* chore: fmt
  • Loading branch information
Sergiy Petrunin committed Dec 27, 2022
1 parent 2d211c9 commit 0f4398b
Show file tree
Hide file tree
Showing 11 changed files with 148 additions and 24 deletions.
1 change: 1 addition & 0 deletions docs-website/src/pages/docs/directives-reference/index.md
Expand Up @@ -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 %}
@@ -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" }
```
4 changes: 2 additions & 2 deletions go.mod
Expand Up @@ -36,15 +36,15 @@ 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
github.com/stretchr/testify v1.8.0
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
Expand Down
7 changes: 3 additions & 4 deletions go.sum
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
21 changes: 20 additions & 1 deletion packages/sdk/src/definition/__snapshots__/merge.test.ts.snap

Large diffs are not rendered by default.

19 changes: 19 additions & 0 deletions packages/sdk/src/definition/merge.ts
Expand Up @@ -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
Expand Down
12 changes: 9 additions & 3 deletions pkg/apihandler/apihandler.go
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
7 changes: 7 additions & 0 deletions 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"
)
7 changes: 5 additions & 2 deletions pkg/apihandler/internalapihandler.go
Expand Up @@ -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)
Expand Down
28 changes: 16 additions & 12 deletions pkg/node/node_test.go
Expand Up @@ -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"
Expand All @@ -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) {
Expand All @@ -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
}
Expand Down Expand Up @@ -161,54 +163,56 @@ 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",
Query: federationTestQuery,
}

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) {
data, err := io.ReadAll(r.Body)
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)
}
Expand Down Expand Up @@ -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) {
Expand Down

1 comment on commit 0f4398b

@vercel
Copy link

@vercel vercel bot commented on 0f4398b Dec 27, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.