Netflix / dgs-codegen

Apache License 2.0
183 stars 100 forks source link

Interface code generation breaks serialization for nested objects #322

Open manymotes opened 2 years ago

manymotes commented 2 years ago

generateInterfaces = true

with a schema type like:

extend type Query {
    """
    Returns a `ShipmentRating` resource by ID.
    """
    shipmentRating(input: ID!): ShipmentRating
    """
    Returns a list  of `ShipmentRating` resources with the given filters.
    """
    shipmentRatings(input: ShipmentRatingsInput): [ShipmentRating]
}

extend type Mutation {
    """
    Allows an API consumer to create a `shipmentRating`.
    """
    createShipmentRating(input: CreateShipmentRatingInput!): ShipmentRating
    """
    Allows an API consumer to calculate possible shipment ratings based on the organization's configured settings.
    """
    calculateShipmentRatings(input: CalculateShipmentRatingsInput!): [ShipmentRating]
}

enum ShipmentAmountType {
    BUFFER
    DISCOUNT
    FUEL_SURCHARGE
    INSURANCE
    PUBLISHED_RATE
    SURCHARGE
}

"""
A Shipment rating quote that can be displayed to
"""
type ShipmentRating implements Node {
    "ShipmentRating ID, prefixed with `shipmentRating_`"
    id: ID!
    "The quoted amount for the `ShipmentRating`"
    amount: Decimal!
    "Subtotal amounts of how the `ShipmentRating` amount was calculated"
    amountSubtotals: ShipmentRatingSubtotals!
    "The currencyCode of the amount fields."
    currencyCode: CurrencyCode!
    "When this `ShipmentRating` was created"
    createdAt: DateTime!
    "The user who created the `ShipmentRating`"
    createdBy: ID!
    "Breakdown of the details returned from the carrier."
    details: [ShipmentRatingDetail!]
    "The customer facing display name of the `ShipmentRating`."
    displayName: String!
    "The ISO-8601 timestamp of when the delivery could first be delivered"
    minTransitAt: DateTime
    "The ISO-8601 timestamp of when the delivery will be delivered"
    maxTransitAt: DateTime
    """
    For shipments that contain multiple fulfillment warehouses the multipleShipFromRatings will contain the individual `ShipmentRating` calculations.
    The aggregated totals will be reflected on the parent object. In these cases the shipFrom location will be null.
    """
    multipleShipFromRatings: [ShipmentRating]
    "The `organization` associated with the `ShipmentRating`"
    organization: ID!
    "A carrier service level code to identify how to fulfill the shipment"
    serviceLevelCode: String!
    "The `location` associated with the origin of the shipment."
    shipFrom: Location
    "The destination `location` used to generate the shipment Quote"
    shipTo: Location!
    "The Carton data included in the `ShipmentRating`"
    shipmentRatingCartons: [ShipmentRatingCarton!]!
    "The `ShippingProfile` associated with the `ShipmentRating`"
    shippingProfile: ShippingProfile!
    "When this `ShipmentRating` was most recently updated."
    updatedAt: DateTime!
    "The user who most recently updated the `ShipmentRating`."
    updatedBy: ID!
}

"""
The Carton details containing the package dimensions and items included in the `ShipmentRating`
"""
type ShipmentRatingCarton {
    "The total amount that the package is insured based in the currencyCode from the `ShipmentRating`."
    amountInsured: Decimal!
    "The Carton associated with the `ShipmentRatingCarton`."
    carton: Carton!
    "The weight the carrier is charging for this carton. This could be actual or dimensional weight of the carton."
    chargeableWeight: Decimal
    "The Dimensional weight factor used to determine the dimensionalWeight."
    dimensionalFactor: Int
    "The calculated dimensionalWeight of the carton."
    dimensionalWeight: Decimal
}

"""
A surcharge or discount breakdown provided by the carrier.
"""
type ShipmentRatingDetail {
    "Amount for each charge as defined by the `carrier`."
    amount: Decimal!
    "Unique identifier that will be tied to each fee that is charged by the carrier for the `shipmentRating`."
    carrierCode: String!
    "The type of fee that is being broken down as part of the `shipmentRating`."
    type: ShipmentAmountType!
}

"""
Subtotal amounts of how the `ShipmentRating` amount was calculated
"""
type ShipmentRatingSubtotals {
    "Amount charged for the fuel surcharge by the `carrier`."
    fuelSurcharge: Decimal
    "Cost to insure items that is charged by the `carrier`."
    insuranceCost: Decimal
    "The sum of any other surcharges that are not individually broken down by the `carrier` (residential falls into this bucked)."
    otherSurcharge: Decimal
    "Cost of shipping as defined by the `carrier`."
    shipping: Decimal!
}

"""
Input to create a non-calculated shipmentRating.
"""
input CreateShipmentRatingInput {
    amount: Decimal
    cartons: [ID!]!
    serviceLevelCode: String!
}

"""
Input to calculate a `shipmentRating` for the given Order.
"""
input CalculateShipmentRatingsInput {
    order: ID!
}

input ShipmentRatingsInput {
    countryCode: CountryCode
    organization: ID
    serviceLevel: ID
}

type Root @key(fields: "id") @extends {
    id: ID! @external
    shipmentRatings: [ShipmentRating]
}

type Location @key(fields: "id") @extends {
    id: ID! @external
}

type Carton @key(fields: "id")
@extends
{
    id: ID! @external
}

and a hibernate entity like this:

package com.zonos.shipmentrating.shipmentRating.model;

import com.zonos.shipmentratingclient.types.IShipmentRatingCarton;
import lombok.*;

import javax.persistence.Entity;
import java.math.BigDecimal;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder(toBuilder = true)
public class ShipmentRatingCartonEntity {

    private BigDecimal amountInsured;

    private CartonEntity carton;

    private BigDecimal chargeableWeight;

    private Integer dimensionalFactor;

    private BigDecimal dimensionalWeight;

}
fails to serialize with a DgsQueryExecutor call like this:

   @Test
    void getShipmentRatingTest() throws InvocationTargetException, IllegalAccessException {
        // test retrieving a shipment rating by ID
        ShipmentRating shipmentRating = createShipmentRating();

        var projection = new ShipmentRatingProjectionRoot();
        projection.id()
                .amount()
                .serviceLevelCode()
                .shipmentRatingCartons()
                .amountInsured();

        GraphQLQueryRequest request = new GraphQLQueryRequest(
                new ShipmentRatingGraphQLQuery.Builder()
                        .input(shipmentRating.getId())
                        .build(),
                projection,
                getZonosScalars()
        );
        ShipmentRating retrievedShipmentRating = dgsQueryExecutor.executeAndExtractJsonPathAsObject(request.serialize(),
                "data." + new ShipmentRatingGraphQLQuery().getOperationName(),
new TypeRef<List<ItemEntity>>() {
              });
        assertNotNull(retrievedShipmentRating);
        assertTrue(retrievedShipmentRating.getId().equals(shipmentRating.getId()));
        assertTrue(retrievedShipmentRating.getShipmentRatingCartons().size() == 1);
    }
KumarDevvrat commented 2 years ago

I noticed as well

manymotes commented 2 years ago

I think it boils down to serializing to interfaces. Interfaces dont know what constructor to use. I wished we could tell the the interfaces what object/constructor to use.

manymotes commented 2 years ago

We chose to stop using the interfaces. But to keep the schema, and java objects in sync, I just created an annotation. It uses reflection to check that our pojo's have all the fields required to support the autogenerated objects fields. something like this:

@Retention(RetentionPolicy.RUNTIME)
@Target(TYPE)
public @interface ZonosFieldParity {
    Class<?> parent();
}

import java.lang.annotation.Annotation;
import java.lang.reflect.Field;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.hibernate.AnnotationException;
import org.reflections.Reflections;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.stereotype.Component;

@Component
public class ZonosFieldParityAnnotationComponent implements InitializingBean {
   //fields we have automated in our db stuff
    private Set<String> ignoreNames = Stream.of("createdAt", "createdBy", "updatedAt", "updatedBy", "mode").collect(Collectors.toSet());

    @Override
    public void afterPropertiesSet() throws Exception {
        var clazzes = new Reflections("com.zonos").getTypesAnnotatedWith(ZonosFieldParity.class);

        for (Class<?> clazz : clazzes) {
            Annotation[] annotations = clazz.getAnnotations();

            Set<String> childClassMethods = new HashSet<>();

            var classType = Class.forName(clazz.getName());
            for (Field field : classType.getDeclaredFields()) {
                childClassMethods.add(field.getName());
            }

            ZonosFieldParity inheritance = (ZonosFieldParity) annotations[0];

            Class c = inheritance.parent();
            Arrays.stream(c.getDeclaredFields()).forEach(e -> {
                if (!ignoreNames.contains(e.getName())) {
                    if (!childClassMethods.contains(e.getName())) {
                        throw new AnnotationException("Zonos Field Parity error: " + clazz.getName() + " does not have " + e.getName() + " field");
                    }
                }
            });
        }
    }
}