Netflix / dgs-codegen

Apache License 2.0
183 stars 99 forks source link

Parsing of PageInfo fails (using generated types) #347

Open hmolinari-attentive opened 2 years ago

hmolinari-attentive commented 2 years ago

Using the schema found here (also attached as txt) schema.txt I've generated a query API.

I make the following query (generated by the query API):

query {
  site {
    settings {
      url {
        vanityUrl
      }
    }
    products(first: 10) {
      pageInfo {
        hasNextPage
        endCursor
      }
      edges {
        node {
          entityId
          name
          plainTextDescription(characterLimit: 1024)
          brand {
            name
          }
          availabilityV2 {
            status
          }
          inventory {
            hasVariantInventory
            aggregated {
              availableToSell
            }
          }
          path
          categories(first: 10) {
            pageInfo {
              hasNextPage
              endCursor
            }
            edges {
              node {
                name
              }
            }
          }
          images(first: 50) {
            pageInfo {
              hasNextPage
              endCursor
            }
            edges {
              node {
                urlOriginal
                altText
              }
            }
          }
          prices {
            price {
              value
              currencyCode
            }
            basePrice {
              value
              currencyCode
            }
            salePrice {
              value
              currencyCode
            }
          }
          productOptions(first: 3) {
            pageInfo {
              hasNextPage
              endCursor
            }
            edges {
              node {
                displayName
                ... on MultipleChoiceOption {
                  __typename
                  values {
                    pageInfo {
                      hasNextPage
                      endCursor
                    }
                    edges {
                      node {
                        label
                      }
                    }
                  }
                }
              }
            }
          }
          variants(first: 200) {
            pageInfo {
              hasNextPage
              endCursor
            }
            edges {
              node {
                inventory {
                  aggregated {
                    availableToSell
                  }
                }
                entityId
                productOptions(first: 3) {
                  pageInfo {
                    hasNextPage
                    endCursor
                  }
                  edges {
                    node {
                      entityId
                      displayName
                      ... on MultipleChoiceOption {
                        __typename
                        values {
                          pageInfo {
                            hasNextPage
                            endCursor
                          }
                          edges {
                            node {
                              label
                            }
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}

I get this response back:

{
  "data" : {
    "site" : {
      "settings" : {
        "url" : {
          "vanityUrl" : "https://shop.soapboxus.com"
        }
      },
      "products" : {
        "pageInfo" : {
          "hasNextPage" : false,
          "endCursor" : "YXJyYXljb25uZWN0aW9uOjA="
        },
        "edges" : [ {
          "node" : {
            "entityId" : 175,
            "name" : "Tide ReLoad Compatible Detergent",
            "plainTextDescription" : "\n\nThis Box Contains\nOne Tide ReLoad™ tray and Detergent\nTide ReLoad™ tray works with multiple types of detergent, shipped directly from us with free shipping. Just choose the type of Tide you like best – Tide PODS Original, Tide PODS Spring Meadow, or Tide Eco-Box Original. Please note that Tide ReLoad™ tray will not work with Tide products purchased elsewhere.\n",
            "brand" : null,
            "availabilityV2" : {
              "status" : "Available"
            },
            "inventory" : {
              "hasVariantInventory" : false,
              "aggregated" : null
            },
            "path" : "/tide-smart-tray-detergent/",
            "categories" : {
              "pageInfo" : {
                "hasNextPage" : false,
                "endCursor" : null
              },
              "edges" : [ ]
            },
            "images" : {
              "pageInfo" : {
                "hasNextPage" : false,
                "endCursor" : "YXJyYXljb25uZWN0aW9uOjY="
              },
              "edges" : [ {
                "node" : {
                  "urlOriginal" : "https://cdn11.bigcommerce.com/s-v1a6036467/images/stencil/original/products/175/800/Image_1_-_Delivered__03950.1633532989.jpg",
                  "altText" : ""
                }
              }, {
                "node" : {
                  "urlOriginal" : "https://cdn11.bigcommerce.com/s-v1a6036467/images/stencil/original/products/175/802/Image_2_-_Free_Shipping__53500.1633532989.jpg",
                  "altText" : ""
                }
              }, {
                "node" : {
                  "urlOriginal" : "https://cdn11.bigcommerce.com/s-v1a6036467/images/stencil/original/products/175/815/Image_3_-_Never_run_out_copy__63238.1633532989.jpg",
                  "altText" : ""
                }
              }, {
                "node" : {
                  "urlOriginal" : "https://cdn11.bigcommerce.com/s-v1a6036467/images/stencil/original/products/175/799/Image_4_-_Cancel_Anytime__26398.1633532989.jpg",
                  "altText" : ""
                }
              }, {
                "node" : {
                  "urlOriginal" : "https://cdn11.bigcommerce.com/s-v1a6036467/images/stencil/original/products/175/798/Image_5_-_Set_up__06965.1633532989.jpg",
                  "altText" : ""
                }
              }, {
                "node" : {
                  "urlOriginal" : "https://cdn11.bigcommerce.com/s-v1a6036467/images/stencil/original/products/175/803/Image_6_-_Skip_the_trip_to_the_store__76617.1633532989.jpg",
                  "altText" : ""
                }
              }, {
                "node" : {
                  "urlOriginal" : "https://cdn11.bigcommerce.com/s-v1a6036467/images/stencil/original/products/175/801/Image_7_-_Comes_with__54036.1633532989.jpg",
                  "altText" : ""
                }
              } ]
            },
            "prices" : {
              "price" : {
                "value" : 19.99,
                "currencyCode" : "USD"
              },
              "basePrice" : {
                "value" : 19.99,
                "currencyCode" : "USD"
              },
              "salePrice" : null
            },
            "productOptions" : {
              "pageInfo" : {
                "hasNextPage" : false,
                "endCursor" : "YXJyYXljb25uZWN0aW9uOjA="
              },
              "edges" : [ {
                "node" : {
                  "displayName" : "Your detergent selection",
                  "__typename" : "MultipleChoiceOption",
                  "values" : {
                    "pageInfo" : {
                      "hasNextPage" : false,
                      "endCursor" : "YXJyYXljb25uZWN0aW9uOjI="
                    },
                    "edges" : [ {
                      "node" : {
                        "label" : "Original"
                      }
                    }, {
                      "node" : {
                        "label" : "Spring Meadow"
                      }
                    }, {
                      "node" : {
                        "label" : "Tide Eco Box"
                      }
                    } ]
                  }
                }
              } ]
            },
            "variants" : {
              "pageInfo" : {
                "hasNextPage" : false,
                "endCursor" : "YXJyYXljb25uZWN0aW9uOjI="
              },
              "edges" : [ {
                "node" : {
                  "inventory" : {
                    "aggregated" : null
                  },
                  "entityId" : 170,
                  "productOptions" : {
                    "pageInfo" : {
                      "hasNextPage" : false,
                      "endCursor" : "YXJyYXljb25uZWN0aW9uOjA="
                    },
                    "edges" : [ {
                      "node" : {
                        "entityId" : 21,
                        "displayName" : "Your detergent selection",
                        "__typename" : "MultipleChoiceOption",
                        "values" : {
                          "pageInfo" : {
                            "hasNextPage" : false,
                            "endCursor" : "YXJyYXljb25uZWN0aW9uOjA="
                          },
                          "edges" : [ {
                            "node" : {
                              "label" : "Original"
                            }
                          } ]
                        }
                      }
                    } ]
                  }
                }
              }, {
                "node" : {
                  "inventory" : {
                    "aggregated" : null
                  },
                  "entityId" : 171,
                  "productOptions" : {
                    "pageInfo" : {
                      "hasNextPage" : false,
                      "endCursor" : "YXJyYXljb25uZWN0aW9uOjA="
                    },
                    "edges" : [ {
                      "node" : {
                        "entityId" : 21,
                        "displayName" : "Your detergent selection",
                        "__typename" : "MultipleChoiceOption",
                        "values" : {
                          "pageInfo" : {
                            "hasNextPage" : false,
                            "endCursor" : "YXJyYXljb25uZWN0aW9uOjA="
                          },
                          "edges" : [ {
                            "node" : {
                              "label" : "Spring Meadow"
                            }
                          } ]
                        }
                      }
                    } ]
                  }
                }
              }, {
                "node" : {
                  "inventory" : {
                    "aggregated" : null
                  },
                  "entityId" : 172,
                  "productOptions" : {
                    "pageInfo" : {
                      "hasNextPage" : false,
                      "endCursor" : "YXJyYXljb25uZWN0aW9uOjA="
                    },
                    "edges" : [ {
                      "node" : {
                        "entityId" : 21,
                        "displayName" : "Your detergent selection",
                        "__typename" : "MultipleChoiceOption",
                        "values" : {
                          "pageInfo" : {
                            "hasNextPage" : false,
                            "endCursor" : "YXJyYXljb25uZWN0aW9uOjA="
                          },
                          "edges" : [ {
                            "node" : {
                              "label" : "Tide Eco Box"
                            }
                          } ]
                        }
                      }
                    } ]
                  }
                }
              } ]
            }
          }
        } ]
      }
    }
  }
}

Then when I attempt to parse the site object into a type generated by the codegen plugin...

Optional<JsonNode> dataOptional =
                Optional.of(objectMapper.readTree(graphQLResponse.getJson()));

        System.out.println(dataOptional.get().toPrettyString());

        Optional<JsonNode> siteOptional =
                dataOptional.map(json -> json.get("data")).map(data -> data.get("site"));

        if (siteOptional.isEmpty()) {
            throw new NullPointerException();
        }

        com.attentivemobile.syncprocessor.model.bigcommerce.types.Site site =
                objectMapper.treeToValue(siteOptional.get(), Site.class);

I get this

com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of `graphql.relay.PageInfo` (no Creators, like default constructor, exist): abstract types either need to be mapped to concrete types, have custom deserializer, or contain additional type information
 at [Source: UNKNOWN; byte offset: #UNKNOWN] (through reference chain: com.attentivemobile.syncprocessor.model.bigcommerce.types.Site["products"]->com.attentivemobile.syncprocessor.model.bigcommerce.types.ProductConnection["pageInfo"])

    at com.fasterxml.jackson.databind.exc.InvalidDefinitionException.from(InvalidDefinitionException.java:67)
    at com.fasterxml.jackson.databind.DeserializationContext.reportBadDefinition(DeserializationContext.java:1904)
    at com.fasterxml.jackson.databind.DatabindContext.reportBadDefinition(DatabindContext.java:400)
    at com.fasterxml.jackson.databind.DeserializationContext.handleMissingInstantiator(DeserializationContext.java:1349)
    at com.fasterxml.jackson.databind.deser.AbstractDeserializer.deserialize(AbstractDeserializer.java:274)
    at com.fasterxml.jackson.databind.deser.impl.MethodProperty.deserializeAndSet(MethodProperty.java:129)
    at com.fasterxml.jackson.databind.deser.BeanDeserializer.vanillaDeserialize(BeanDeserializer.java:313)
    at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserialize(BeanDeserializer.java:176)
    at com.fasterxml.jackson.databind.deser.impl.MethodProperty.deserializeAndSet(MethodProperty.java:129)
    at com.fasterxml.jackson.databind.deser.BeanDeserializer.vanillaDeserialize(BeanDeserializer.java:313)
    at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserialize(BeanDeserializer.java:176)
    at com.fasterxml.jackson.databind.deser.DefaultDeserializationContext.readRootValue(DefaultDeserializationContext.java:322)
    at com.fasterxml.jackson.databind.ObjectMapper._readValue(ObjectMapper.java:4650)
    at com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:2831)
    at com.fasterxml.jackson.databind.ObjectMapper.treeToValue(ObjectMapper.java:3295)
    at com.attentivemobile.syncprocessor.service.vendor.bigcommerce.Test.test(Test.java:78)
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
    at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at java.base/java.lang.reflect.Method.invoke(Method.java:566)
    at org.junit.platform.commons.util.ReflectionUtils.invokeMethod(ReflectionUtils.java:688)
    at org.junit.jupiter.engine.execution.MethodInvocation.proceed(MethodInvocation.java:60)
    at ...

This is my codegen configuration, via the community maven port

<plugin>
                <groupId>io.github.deweyjose</groupId>
                <artifactId>graphqlcodegen-maven-plugin</artifactId>
                <version>1.16</version>
                <executions>
                    <execution>
                        <goals>
                            <goal>generate</goal>
                        </goals>
                        <configuration>
                            <schemaPaths>
                                <param>
                                    src/main/resources/api/bigcommerce/schema.graphql
                                </param>
                            </schemaPaths>
                            <packageName>com.attentivemobile.syncprocessor.model.bigcommerce</packageName>

                            <!--                            Required because scalars chosen by BigCommerce impose graphql-java scalar mappings-->
                            <typeMapping>
                                <BigDecimal>java.math.BigDecimal</BigDecimal>
                                <Long>java.lang.Long</Long>
                            </typeMapping>

                            <generateClient>true</generateClient>

                            <includeQueries>site</includeQueries>
                            <includeSubscriptions>[]</includeSubscriptions>
                            <includeMutations>[]</includeMutations>

                            <omitNullInputFields>true</omitNullInputFields>

                            <maxProjectionDepth>20</maxProjectionDepth>

                            <outputDir>${project.build.directory}/generated-sources/graphqlcodegen</outputDir>

                            <kotlinAllFieldsOptional>true</kotlinAllFieldsOptional>
                            <language>java</language>
                        </configuration>
                    </execution>
                </executions>
            </plugin>

and these are the versions of the dgs libraries I'm using

<!-- https://mvnrepository.com/artifact/com.netflix.graphql.dgs.codegen/graphql-dgs-codegen-client-core -->
        <dependency>
            <groupId>com.netflix.graphql.dgs.codegen</groupId>
            <artifactId>graphql-dgs-codegen-client-core</artifactId>
            <version>5.1.16</version>
        </dependency>

        <dependency>
            <groupId>com.netflix.graphql.dgs</groupId>
            <artifactId>graphql-dgs-client</artifactId>
            <version>4.9.20</version>
        </dependency>
kilink commented 2 years ago

Jackson needs to be told explicitly what concrete type to deserialize to for interfaces and abstract types such as PageInfo and ConnectionCursor. This can be done by registering a custom module and calling addAbstractTypeMapping. Jackson would still have trouble deserializing to DefaultPageInfo, so you would need a ValueInstantiator or Deserializer to handle that.


import com.fasterxml.jackson.databind.DeserializationConfig
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JavaType
import com.fasterxml.jackson.databind.PropertyMetadata
import com.fasterxml.jackson.databind.PropertyName
import com.fasterxml.jackson.databind.deser.CreatorProperty
import com.fasterxml.jackson.databind.deser.SettableBeanProperty
import com.fasterxml.jackson.databind.deser.ValueInstantiator
import graphql.relay.ConnectionCursor
import graphql.relay.DefaultPageInfo

class DefaultPageInfoValueInstantiator : ValueInstantiator.Base(DefaultPageInfo::class.java) {
    override fun canCreateFromObjectWith(): Boolean {
        return true
    }

    override fun getFromObjectArguments(config: DeserializationConfig): Array<SettableBeanProperty> {
        val connectionCursorType = config.constructType(ConnectionCursor::class.java)
        val booleanType = config.constructType(Boolean::class.java)
        return arrayOf(
                creatorProp("startCursor", connectionCursorType, 0),
                creatorProp("endCursor", connectionCursorType, 1),
                creatorProp("hasPrevious", booleanType, 2),
                creatorProp("hasNext", booleanType, 3)
        )
    }

    override fun createFromObjectWith(ctxt: DeserializationContext, args: Array<out Any>): Any {
        return DefaultPageInfo(args[0] as ConnectionCursor, args[1] as ConnectionCursor, args[2] as Boolean, args[3] as Boolean)
    }

    private fun creatorProp(name: String, type: JavaType, index: Int): CreatorProperty {
        return CreatorProperty.construct(PropertyName.construct(name), type, null, null, null, null, index, null, PropertyMetadata.STD_OPTIONAL)
    }
}

And the Jackson module would look like this:


import com.fasterxml.jackson.databind.module.SimpleModule
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer
import graphql.relay.ConnectionCursor
import graphql.relay.DefaultConnectionCursor
import graphql.relay.DefaultPageInfo
import graphql.relay.PageInfo

class GraphQLModule : SimpleModule() {
    init {
        addAbstractTypeMapping(PageInfo::class.java, DefaultPageInfo::class.java)
        addAbstractTypeMapping(ConnectionCursor::class.java, DefaultConnectionCursor::class.java)

        addSerializer(DefaultConnectionCursor::class.java, ToStringSerializer.instance)
        addValueInstantiator(DefaultPageInfo::class.java, DefaultPageInfoValueInstantiator())
    }
}

Example usage:


import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import graphql.relay.PageInfo

data class Response(val pageInfo: PageInfo)

val objectMapper = jacksonObjectMapper()
    .registerModule(GraphQLModule())

val response = objectMapper.readValue<Response>("""{"pageInfo": {"endCursor": "bar", "hasPrevious": true, "hasNext": true}}""")
println(response)
// prints Response(pageInfo=DefaultPageInfo{ startCursor=null, endCursor=bar, hasPreviousPage=true, hasNextPage=true})
println("json = " + objectMapper.writeValueAsString(response))
// print json = {"pageInfo":{"startCursor":null,"endCursor":"bar","hasPreviousPage":true,"hasNextPage":true}}

I have actually run into the same issue, particularly when writing tests for server-side responses. Now whether this is something that belongs in dgs-codegen (or elsewhere?), I am not sure. @berngp thoughts?

srinivasankavitha commented 2 years ago

Thanks @kilink. I think it makes sense to handle this in dgs-codegen since it is a well known and supported type.

On Apr 14, 2022, at 11:12 AM, Patrick Strawderman @.***> wrote:

 Jackson needs to be told explicitly what concrete type to deserialize to for interfaces and abstract types such as PageInfo and ConnectionCursor. This can be done by registering a custom module and calling addAbstractTypeMapping. Jackson would still have trouble deserializing to DefaultPageInfo, so you would need a ValueInstantiator or Deserializer to handle that.

import com.fasterxml.jackson.databind.DeserializationConfig import com.fasterxml.jackson.databind.DeserializationContext import com.fasterxml.jackson.databind.JavaType import com.fasterxml.jackson.databind.PropertyMetadata import com.fasterxml.jackson.databind.PropertyName import com.fasterxml.jackson.databind.deser.CreatorProperty import com.fasterxml.jackson.databind.deser.SettableBeanProperty import com.fasterxml.jackson.databind.deser.ValueInstantiator import graphql.relay.ConnectionCursor import graphql.relay.DefaultPageInfo

class DefaultPageInfoValueInstantiator : ValueInstantiator.Base(DefaultPageInfo::class.java) { override fun canCreateFromObjectWith(): Boolean { return true }

override fun getFromObjectArguments(config: DeserializationConfig): Array<SettableBeanProperty> {
    val connectionCursorType = config.constructType(ConnectionCursor::class.java)
    val booleanType = config.constructType(Boolean::class.java)
    return arrayOf(
            creatorProp("startCursor", connectionCursorType, 0),
            creatorProp("endCursor", connectionCursorType, 1),
            creatorProp("hasPrevious", booleanType, 2),
            creatorProp("hasNext", booleanType, 3)
    )
}

override fun createFromObjectWith(ctxt: DeserializationContext, args: Array<out Any>): Any {
    return DefaultPageInfo(args[0] as ConnectionCursor, args[1] as ConnectionCursor, args[2] as Boolean, args[3] as Boolean)
}

private fun creatorProp(name: String, type: JavaType, index: Int): CreatorProperty {
    return CreatorProperty.construct(PropertyName.construct(name), type, null, null, null, null, index, null, PropertyMetadata.STD_OPTIONAL)
}

} And the Jackson module would look like this:

import com.fasterxml.jackson.databind.module.SimpleModule import com.fasterxml.jackson.databind.ser.std.ToStringSerializer import graphql.relay.ConnectionCursor import graphql.relay.DefaultConnectionCursor import graphql.relay.DefaultPageInfo import graphql.relay.PageInfo

class GraphQLModule : SimpleModule() { init { addAbstractTypeMapping(PageInfo::class.java, DefaultPageInfo::class.java) addAbstractTypeMapping(ConnectionCursor::class.java, DefaultConnectionCursor::class.java)

    addSerializer(DefaultConnectionCursor::class.java, ToStringSerializer.instance)
    addValueInstantiator(DefaultPageInfo::class.java, DefaultPageInfoValueInstantiator())
}

} Example usage:

import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.readValue import graphql.relay.PageInfo

data class Response(val pageInfo: PageInfo)

val objectMapper = jacksonObjectMapper() .registerModule(GraphQLModule())

val response = objectMapper.readValue("""{"pageInfo": {"endCursor": "bar", "hasPrevious": true, "hasNext": true}}""") println(response) // prints Response(pageInfo=DefaultPageInfo{ startCursor=null, endCursor=bar, hasPreviousPage=true, hasNextPage=true}) println("json = " + objectMapper.writeValueAsString(response)) // print json = {"pageInfo":{"startCursor":null,"endCursor":"bar","hasPreviousPage":true,"hasNextPage":true}} I have actually run into the same issue, particularly when writing tests for server-side responses. Now whether this is something that belongs in dgs-codegen (or elsewhere?), I am not sure. @berngp thoughts?

— Reply to this email directly, view it on GitHub, or unsubscribe. You are receiving this because you are subscribed to this thread.