GQLDefinition.java

package net.morimekta.providence.graphql;

import net.morimekta.providence.PEnumValue;
import net.morimekta.providence.PMessageVariant;
import net.morimekta.providence.PType;
import net.morimekta.providence.descriptor.PContainer;
import net.morimekta.providence.descriptor.PDeclaredDescriptor;
import net.morimekta.providence.descriptor.PDescriptor;
import net.morimekta.providence.descriptor.PEnumDescriptor;
import net.morimekta.providence.descriptor.PField;
import net.morimekta.providence.descriptor.PInterfaceDescriptor;
import net.morimekta.providence.descriptor.PMessageDescriptor;
import net.morimekta.providence.descriptor.PRequirement;
import net.morimekta.providence.descriptor.PService;
import net.morimekta.providence.descriptor.PServiceMethod;
import net.morimekta.providence.descriptor.PStructDescriptor;
import net.morimekta.providence.descriptor.PUnionDescriptor;
import net.morimekta.providence.graphql.gql.GQLScalar;
import net.morimekta.providence.graphql.gql.GQLUtil;
import net.morimekta.providence.graphql.introspection.EnumValue;
import net.morimekta.providence.graphql.introspection.Field;
import net.morimekta.providence.graphql.introspection.InputValue;
import net.morimekta.providence.graphql.introspection.Schema;
import net.morimekta.providence.graphql.introspection.Type;
import net.morimekta.providence.graphql.introspection.TypeKind;
import net.morimekta.util.collect.UnmodifiableList;
import net.morimekta.util.collect.UnmodifiableSet;
import net.morimekta.util.collect.UnmodifiableSortedMap;
import net.morimekta.util.io.IndentedPrintWriter;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.annotation.concurrent.Immutable;
import java.io.StringWriter;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.TreeMap;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Collectors;

import static java.util.Objects.requireNonNull;
import static net.morimekta.providence.graphql.gql.GQLUtil.toArgumentString;
import static net.morimekta.util.collect.UnmodifiableSet.setOf;

/**
 * A GQL service is a collection of zero or more 'queries' and zero
 * or more 'mutations'. The query and mutation distinction is meant
 * to represent <i>reading</i> and <i>writing</i> operations, but in
 * reality they distinguish <i>parallel</i> and <i>serial</i> execution,
 * in case the query contains more than one base entry.
 */
@Immutable
public class GQLDefinition {
    private final PService                 query;
    private final PService                 mutation;
    private final Map<String, PDescriptor> allTypes;
    private final Map<String, PDescriptor> inputTypes;
    private final Map<String, PDescriptor> outputTypes;
    private final Map<String, Type>        introspectionMap;
    private final Set<PField<?>>           idFields;
    private final boolean                  ignoreMaps;
    private final Set<PField<?>>           ignoreFields;
    private final Set<PDescriptor>         ignoreTypes;
    private final Set<PUnionDescriptor<?>> asInterface;
    private final AtomicReference<String>  schema;
    private final Schema                   introspectionSchema;

    /**
     * Builder for setting up a GraphQL definition.
     */
    public static class Builder {
        private       PService                 query;
        private       PService                 mutation;
        private       boolean                  ignoreMaps;
        private final Set<PField<?>>           idFields;
        private final Set<PField<?>>           ignoreFields;
        private final Set<PDescriptor>         ignoreTypes;
        private final Set<PUnionDescriptor<?>> asInterface;

        private Builder() {
            ignoreMaps = false;
            idFields = new HashSet<>();
            asInterface = new HashSet<>();
            ignoreFields = new HashSet<>();
            ignoreTypes = new HashSet<>();
        }

        /**
         * Service used for handling queries.
         *
         * @param query The query service.
         * @return The builder.
         */
        public Builder query(PService query) {
            this.query = query;
            return this;
        }

        /**
         * Service used for handling queries.
         *
         * @param mutation The mutation service.
         * @return The builder.
         */
        public Builder mutation(PService mutation) {
            this.mutation = mutation;
            return this;
        }

        /**
         * Transform field from string, binary or container of string or binary
         * to ID or ID list.
         *
         * @param fields The fields to be made into ID fields.
         * @return The builder.
         */
        public Builder idField(PField<?>... fields) {
            for (PField<?> field : fields) {
                if (!isValidIdField(field.getDescriptor())) {
                    throw new IllegalArgumentException(field.getDescriptor().getQualifiedName() + " cannot be handled as ID field.");
                }
                idFields.add(field);
            }
            return this;
        }

        /**
         * Handle these unions as interfaces. The union will be ignored in the schema (except for
         * input types), and it's interface will be used instead.
         *
         * @param descriptors Union descriptors to handle as interfaces.
         * @return The builder.
         */
        public Builder asInterface(PUnionDescriptor<?>... descriptors) {
            for (PUnionDescriptor<?> descriptor : descriptors) {
                if (descriptor.getImplementing() == null) {
                    throw new IllegalArgumentException(
                            "Union " + descriptor.getQualifiedName() + " does not have implementing interface, " +
                            "so is not allowed as 'union as interface' in graphql.");
                }
                asInterface.add(descriptor);
            }
            return this;
        }

        /**
         * Just ignore all maps, instead of failing.
         *
         * @return The builder.
         */
        public Builder ignoreMaps() {
            ignoreMaps = true;
            return this;
        }

        /**
         * Skip these fields. It will be possible to select / reference the field, but it
         * will be skipped from the schema.
         *
         * @param fields The fields to be ignore / skipped.
         * @return The builder.
         */
        public Builder ignoreFields(PField<?>... fields) {
            Collections.addAll(ignoreFields, fields);
            return this;
        }

        /**
         * Setting this will ignore all fields of the given type, or containing the given type
         * (list, set, map). Only declared types can be ignored.
         *
         * @param types Types to be ignored.
         * @return The builder.
         */
        public Builder ignoreTypes(PDeclaredDescriptor<?>... types) {
            Collections.addAll(ignoreTypes, types);
            return this;
        }

        /**
         * Build the GraphQL definition.
         *
         * @return The GQL definition.
         */
        public GQLDefinition build() {
            if (query == null) {
                throw new IllegalStateException("No query defined.");
            }
            return new GQLDefinition(
                    query,
                    mutation,
                    ignoreMaps,
                    idFields,
                    ignoreFields,
                    ignoreTypes,
                    asInterface);
        }
    }

    public static Builder builder() {
        return new Builder();
    }

    /**
     * Create a graphql definition instance.
     *
     * @param query    The query service. Mandatory.
     * @param mutation The mutation service.
     * @param idFields Collection if ID fields.
     * @deprecated Use Builder.
     */
    @Deprecated
    public GQLDefinition(@Nonnull PService query,
                         @Nullable PService mutation,
                         @Nonnull Collection<PField<?>> idFields) {
        this(query, mutation, idFields, setOf());
    }

    /**
     * Create a graphql definition instance.
     *
     * @param query The query service. Mandatory.
     * @param mutation The mutation service.
     * @param idFields Collection if ID fields.
     * @param asInterface Collection of unions to be
     * @deprecated Use Builder.
     */
    @Deprecated
    public GQLDefinition(@Nonnull PService query,
                         @Nullable PService mutation,
                         @Nonnull Collection<PField<?>> idFields,
                         @Nonnull Collection<PUnionDescriptor<?>> asInterface) {
        this(query, mutation, false, idFields, setOf(), setOf(), asInterface);
    }

    private GQLDefinition(@Nonnull PService query,
                          @Nullable PService mutation,
                          boolean ignoreMaps,
                          @Nonnull Collection<PField<?>> idFields,
                          @Nonnull Collection<PField<?>> ignoreFields,
                          @Nonnull Collection<PDescriptor> ignoreTypes,
                          @Nonnull Collection<PUnionDescriptor<?>> asInterface) {
        this.idFields = UnmodifiableSet.copyOf(idFields);
        this.asInterface = UnmodifiableSet.copyOf(asInterface);
        this.ignoreFields = UnmodifiableSet.copyOf(ignoreFields);
        this.ignoreTypes = UnmodifiableSet.copyOf(ignoreTypes);
        this.ignoreMaps = ignoreMaps;

        for (PUnionDescriptor<?> ui : asInterface) {
            if (ui.getImplementing() == null) {
                throw new IllegalArgumentException("Union " + ui.getName() + " does not have implementing interface, " +
                                                   "so is not allowed as 'union as interface' in graphql.");
            }
        }

        // This should try to preserve order.
        LinkedHashMap<String, PDescriptor> typeMap      = new LinkedHashMap<>();
        LinkedHashMap<String, PDescriptor> inputTypeMap = new LinkedHashMap<>();
        for (PServiceMethod method : query.getMethods()) {
            registerInputTypes(method.getRequestType(),
                               typeMap,
                               inputTypeMap,
                               this.ignoreMaps,
                               this.ignoreTypes,
                               this.ignoreFields,
                               false);
            registerTypes(method.getResponseType(),
                          typeMap,
                          inputTypeMap,
                          this.asInterface,
                          this.ignoreMaps,
                          this.ignoreTypes,
                          this.ignoreFields,
                          false);
        }
        if (mutation != null) {
            for (PServiceMethod method : mutation.getMethods()) {
                registerInputTypes(method.getRequestType(),
                                   typeMap,
                                   inputTypeMap,
                                   this.ignoreMaps,
                                   this.ignoreTypes,
                                   this.ignoreFields,
                                   false);
                registerTypes(method.getResponseType(),
                              typeMap,
                              inputTypeMap,
                              this.asInterface,
                              this.ignoreMaps,
                              this.ignoreTypes,
                              this.ignoreFields,
                              false);
            }
        }
        this.inputTypes = UnmodifiableSortedMap.copyOf(inputTypeMap);
        this.outputTypes = UnmodifiableSortedMap.copyOf(typeMap);

        registerTypes(Schema.kDescriptor,
                      typeMap,
                      inputTypeMap,
                      setOf(),
                      false,
                      setOf(),
                      setOf(),
                      true);

        Map<String, PDescriptor> allTypes      = new TreeMap<>(typeMap);
        Map<String, Type>        introspection = new TreeMap<>();
        ArrayList<PDescriptor>   types         = new ArrayList<>(typeMap.values());
        for (GQLScalar scalar : GQLScalar.values()) {
            introspection.put(scalar.introspection.getName(), scalar.introspection);
        }

        Collections.reverse(types);
        types.forEach(descriptor -> buildTypeDefinition(introspection, descriptor, false, false));

        types = new ArrayList<>(inputTypeMap.values());
        Collections.reverse(types);
        types.forEach(descriptor -> {
            Type tmp = buildTypeDefinition(introspection, descriptor, true, false);
            allTypes.put(tmp.getName(), descriptor);
        });
        Type queryType = buildServiceDefinition(introspection, query);
        Type mutationType = buildServiceDefinition(introspection, mutation);

        this.allTypes = UnmodifiableSortedMap.copyOf(allTypes);
        this.introspectionMap = UnmodifiableSortedMap.copyOf(introspection);
        this.introspectionSchema = Schema
                .builder()
                .setTypes(introspection
                                  .values()
                                  .stream()
                                  .filter(type -> !type.getName().startsWith("__"))
                                  .collect(Collectors.toList()))
                .setQueryType(queryType)
                .setMutationType(mutationType)
                .build();
        this.query = query;
        this.mutation = mutation;
        this.schema = new AtomicReference<>();
    }

    /**
     * Get query, e.g. for gql queries like this:
     *
     * <pre>
     * {
     *     hero(id:1001) {
     *         name
     *     }
     * }
     * </pre>
     *
     * @return The query service.
     */
    @Nonnull
    public PService getQuery() {
        return query;
    }

    /**
     * Get mutation by name, e.g. for gql queries like this:
     *
     * <pre>
     * mutation HeroStore {
     *     deleteHero(id:1001) {
     *         name
     *     }
     * }
     * </pre>
     *
     * @return The mutation service.
     */
    @Nullable
    public PService getMutation() {
        return mutation;
    }

    /**
     * If the field should be ignored. This means not allowed in fragments or
     * selection sets.
     *
     * @param field The field to check.
     * @return True if the field should be ignored.
     */
    public boolean isIgnoredField(PField<?> field) {
        return ignoreFields.contains(field) ||
               isIgnoredType(field.getDescriptor());
    }

    /**
     * If the type should be ignored.
     *
     * @param descriptor The type
     * @return If the type should be ignored.
     */
    public boolean isIgnoredType(PDescriptor descriptor) {
        return isIgnoredType(descriptor, ignoreMaps, ignoreTypes);
    }

    /**
     * Get a type used in the GQL service.
     *
     * @param name The name of the type.
     * @return The type description, enum or message.
     */
    public PDescriptor getType(@Nonnull String name) {
        return allTypes.get(name);
    }

    /**
     * Get the introspection type for a defined type.
     * @param name The type name.
     * @return Introspection type, or null if not defined in service.
     */
    @Nullable
    public Type getIntrospectionType(@Nonnull String name) {
        return introspectionMap.get(name);
    }

    /**
     * Get introspection type for a given descriptor.
     *
     * @param descriptor The descriptor to get introspection type for.
     * @param isInput If the type should be an input type.
     * @return The introspection type.
     */
    @Nonnull
    public Type getIntrospectionType(@Nonnull PDescriptor descriptor, boolean isInput) {
        String name = descriptor.getName();
        if (descriptor instanceof PMessageDescriptor &&
            isInput) {
            name = name + INPUT_TYPE;
        }
        return Optional.ofNullable(getIntrospectionType(name))
                       .orElseGet(() -> buildTypeDefinition(
                               new HashMap<>(introspectionMap), descriptor, isInput, false));
    }

    /**
     * Get a defined schema from the GQL service.
     * @return The GQL schema.
     */
    public String getSchema() {
        return schema.updateAndGet(schema -> {
            if (schema == null) {
                return buildSchema();
            }
            return schema;
        });
    }

    /**
     * Return the introspection schema for this definition.
     *
     * @return The schema.
     */
    @Nonnull
    public Schema getIntrospectionSchema() {
        return introspectionSchema;
    }

    private boolean isIdField(PField<?> field) {
        return idFields.contains(field);
    }

    private boolean isHiddenMethod(PServiceMethod method) {
        return method.getName().startsWith("__") ||
               (method.getResponseType() != null &&
                isHiddenField(method.getResponseType().fieldForId(0)));
    }

    private boolean isHiddenType(PDescriptor descriptor) {
        return descriptor.getName().startsWith("__") ||
               isIgnoredType(descriptor);
    }

    private boolean isHiddenField(PField<?> field) {
        return field.getName().startsWith("__") ||
               isHiddenType(field.getDescriptor()) ||
               isIgnoredField(field);
    }

    private Type buildServiceDefinition(Map<String, Type> introspection,
                                        PService service) {
        if (service == null) return null;

        Type._Builder builder = Type.builder();
        builder.setName(service.getName());
        builder.setKind(TypeKind.OBJECT);
        builder.setInterfaces(UnmodifiableList.listOf());

        for (PServiceMethod method : service.getMethods()) {
            if (isHiddenMethod(method)) {
                continue;
            }

            Field._Builder field = Field.builder();
            field.setName(method.getName());

            PUnionDescriptor<?> response = method.getResponseType();
            PStructDescriptor<?> request = method.getRequestType();

            if (response != null) {
                PDescriptor desc = response.fieldForId(0).getDescriptor();
                field.setType(Type.builder()
                                  .setKind(TypeKind.NON_NULL)
                                  .setOfType(makeTypeReference(buildTypeDefinition(
                                          introspection,
                                          desc,
                                          false,
                                          false))));
            } else {
                field.setType(GQLScalar.Boolean.introspection);
            }
            field.setArgs(buildInputValues(introspection, request));
            builder.addToFields(field.build());
        }

        Type type = builder.build();
        introspection.put(type.getName(), type);
        return type;
    }

    private String defaultValueString(Object value) {
        if (value == null) return null;
        return GQLUtil.toArgumentString(value);
    }

    private List<InputValue> buildInputValues(Map<String, Type> introspection,
                                              PMessageDescriptor<?> arguments) {
        List<InputValue> out = new ArrayList<>();
        for (PField<?> field : arguments.getFields()) {
            if (isHiddenField(field)) {
                continue;
            }
            out.add(InputValue.builder()
                              .setName(field.getName())
                              .setType(makeTypeReference(buildTypeDefinition(
                                      introspection,
                                      field.getDescriptor(),
                                      true,
                                      isIdField(field))))
                              .setDefaultValue(defaultValueString(field.getDefaultValue()))
                              .build());
        }
        return UnmodifiableList.copyOf(out);
    }

    private Field buildFieldSpec(Map<String, Type> introspection,
                                 PField<?> field) {
        Field._Builder builder = Field.builder();
        builder.setName(field.getName());
        if (field.getArgumentsType() != null) {
            builder.setArgs(buildInputValues(introspection, field.getArgumentsType()));
        }
        Type type = makeTypeReference(buildTypeDefinition(
                introspection,
                field.getDescriptor(),
                false,
                isIdField(field)));
        if (field.getRequirement() == PRequirement.REQUIRED) {
            type = Type.builder()
                       .setKind(TypeKind.NON_NULL)
                       .setOfType(type)
                       .build();
        }
        return builder.setType(type)
                      .build();
    }

    private Type makeTypeReference(Type type) {
        switch (type.getKind()) {
            case ENUM:
            case UNION:
            case INTERFACE:
            case OBJECT:
            case INPUT_OBJECT: {
                return type.mutate()
                           .clearInterfaces()
                           .clearInputFields()
                           .clearPossibleTypes()
                           .clearFields()
                           .clearEnumValues()
                           .clearDescription()
                           .build();
            }
            case LIST:
            case NON_NULL: {
                return type.mutate()
                           .setOfType(makeTypeReference(type.getOfType()))
                           .build();
            }
        }
        return type;
    }

    private boolean isUnionAsInterface(PDescriptor descriptor) {
        return descriptor instanceof PUnionDescriptor &&
               asInterface.contains(descriptor) &&
               ((PUnionDescriptor<?>) descriptor).getImplementing() != null;
    }

    private Type buildTypeDefinition(Map<String, Type> introspection,
                                     PDescriptor descriptor,
                                     boolean isInput,
                                     boolean isIdField) {
        switch (descriptor.getType()) {
            case MESSAGE: {
                PMessageDescriptor<?> md = (PMessageDescriptor<?>) descriptor;
                String name = descriptor.getName();
                if (isInput) {
                    name += INPUT_TYPE;
                } else if (isUnionAsInterface(md)) {
                    return buildTypeDefinition(introspection, requireNonNull(md.getImplementing()), false, isIdField);
                }
                if (introspection.containsKey(name)) {
                    return introspection.get(name);
                }

                Type._Builder builder = Type.builder();
                builder.setName(name);
                boolean isUnion = false;
                if (md.getVariant() == PMessageVariant.INTERFACE) {
                    builder.setKind(TypeKind.INTERFACE);
                    builder.setPossibleTypes(UnmodifiableList.listOf());
                    builder.setFields(UnmodifiableList.listOf());
                } else if (isInput) {
                    builder.setKind(TypeKind.INPUT_OBJECT);
                } else if (md.getVariant() == PMessageVariant.UNION &&
                           md.getImplementing() != null) {
                    builder.setKind(TypeKind.UNION);
                    builder.setPossibleTypes(UnmodifiableList.listOf());
                    isUnion = true;
                } else {
                    builder.setKind(TypeKind.OBJECT);
                    builder.setFields(UnmodifiableList.listOf());
                    builder.setInterfaces(UnmodifiableList.listOf());
                }

                if (md.getImplementing() != null && !isInput) {
                    builder.addToInterfaces(makeTypeReference(buildTypeDefinition(
                            introspection,
                            md.getImplementing(),
                            false,
                            false)));
                    if (introspection.containsKey(name)) {
                        return introspection.get(name);
                    }
                }
                introspection.put(name, builder.build());

                if (isUnion) {
                    for (PField<?> field : md.getFields()) {
                        if (isHiddenField(field)) {
                            continue;
                        }

                        builder.addToPossibleTypes(makeTypeReference(buildTypeDefinition(
                                introspection,
                                field.getDescriptor(),
                                false,
                                false)));
                    }
                } else if (isInput) {
                    builder.setInputFields(buildInputValues(introspection, md));
                } else {
                    for (PField<?> field : md.getFields()) {
                        if (isHiddenField(field)) {
                            continue;
                        }
                        builder.addToFields(buildFieldSpec(introspection, field));
                    }
                }

                introspection.put(name, builder.build());

                if (md instanceof PInterfaceDescriptor) {
                    PInterfaceDescriptor<?> id = (PInterfaceDescriptor<?>) descriptor;
                    for (PMessageDescriptor<?> pt : id.getPossibleTypes()) {
                        if (pt.getVariant() != PMessageVariant.UNION) {
                            builder.addToPossibleTypes(makeTypeReference(buildTypeDefinition(introspection, pt, false, false)));
                        }
                    }
                }

                introspection.put(name, builder.build());
                return builder.build();
            }
            case ENUM: {
                Type._Builder builder = Type.builder();
                if (introspection.containsKey(descriptor.getName())) {
                    return introspection.get(descriptor.getName());
                }

                builder.setName(descriptor.getName());
                builder.setKind(TypeKind.ENUM);
                builder.mutableEnumValues();

                for (PEnumValue<?> value : ((PEnumDescriptor<?>) descriptor).getValues()) {
                    builder.addToEnumValues(EnumValue.builder()
                                                     .setName(value.asString())
                                                     .build());
                }

                introspection.put(descriptor.getName(), builder.build());
                return builder.build();
            }
            case SET:
            case LIST: {
                Type._Builder builder = Type.builder();
                PContainer<?> pc = (PContainer<?>) descriptor;
                builder.setKind(TypeKind.LIST);
                builder.setOfType(makeTypeReference(buildTypeDefinition(introspection, pc.itemDescriptor(), isInput, isIdField)));
                return builder.build();
            }
            case STRING:
            case BINARY:
                if (isIdField) {
                    return GQLScalar.ID.introspection;
                }
                return GQLScalar.String.introspection;
            case VOID:
            case BOOL:
                return GQLScalar.Boolean.introspection;
            case I64:
            case I32:
            case I16:
            case BYTE:
                return GQLScalar.Int.introspection;
            case DOUBLE:
                return GQLScalar.Float.introspection;
        }
        throw new IllegalStateException("Unsupported type: " + descriptor.getType());
    }

    private String buildSchema() {
        StringWriter        out    = new StringWriter();
        IndentedPrintWriter writer = new IndentedPrintWriter(out, "  ", "\n");
        writer.append("# Generated for providence graphql")
              .newline();

        List<PDescriptor> types = new ArrayList<>(outputTypes.values());
        for (PDescriptor descriptor : types) {
            if (isHiddenType(descriptor)) {
                continue;
            }

            if (descriptor.getType() == PType.ENUM) {
                PEnumDescriptor<?> ed = (PEnumDescriptor<?>) descriptor;

                writer.formatln("enum %s {", descriptor.getName())
                      .begin();
                for (PEnumValue<?> val : ed.getValues()) {
                    writer.appendln(val.asString());
                }
                writer.end()
                      .appendln("}")
                      .newline();
            } else if (descriptor.getType() == PType.MESSAGE) {
                PMessageDescriptor<?> md = (PMessageDescriptor<?>) descriptor;
                if (md.getVariant() == PMessageVariant.UNION && md.getImplementing() != null) {
                    // union Name = Type1 | Type2
                    writer.formatln("union %s = ", md.getName());

                    boolean first = true;
                    for (PField<?> field : md.getFields()) {
                        if (isHiddenField(field)) {
                            continue;
                        }

                        if (first) {
                            first = false;
                        } else {
                            writer.append(" | ");
                        }
                        writer.append(field.getDescriptor().getName());
                    }
                    writer.newline();
                } else if (md.getVariant() == PMessageVariant.INTERFACE) {
                    // interface Name { ... }
                    writer.formatln("interface %s {", md.getName())
                          .begin();
                    for (PField<?> field : md.getFields()) {
                        if (isHiddenField(field)) {
                            continue;
                        }

                        writer.formatln("%s: %s", field.getName(), typeName(field));
                    }
                    writer.end()
                          .appendln("}")
                          .newline();
                } else {
                    // type
                    writer.formatln("type %s%s {", md.getName(),
                                    md.getImplementing() != null ? " implements " + typeName(md.getImplementing(), false) : "")
                          .begin();
                    for (PField<?> field : md.getFields()) {
                        if (isHiddenField(field)) {
                            continue;
                        }

                        writer.formatln("%s", field.getName());
                        if (field.getArgumentsType() != null) {
                            writer.append("(");
                            boolean first = true;
                            for (PField<?> arg : field.getArgumentsType().getFields()) {
                                if (isHiddenField(field)) {
                                    continue;
                                }

                                if (first) {
                                    first = false;
                                } else {
                                    writer.append(", ");
                                }
                                writer.format("%s: %s", arg.getName(), inputTypeName(arg.getDescriptor(), isIdField(arg)));
                                if (arg.hasDefaultValue()) {
                                    writer.format(" = %s", toArgumentString(arg.getDefaultValue()));
                                }
                            }
                            writer.append(")");
                        }
                        writer.format(": %s", typeName(field));
                    }
                    writer.end()
                          .appendln("}")
                          .newline();
                }
            }
        }

        types = new ArrayList<>(inputTypes.values());
        for (PDescriptor descriptor : types) {
            PMessageDescriptor<?> md = (PMessageDescriptor<?>) descriptor;
            // input
            writer.formatln("input %s%s {",
                            md.getName(),
                            INPUT_TYPE)
                  .begin();
            for (PField<?> field : md.getFields()) {
                if (isHiddenField(field)) {
                    continue;
                }

                writer.formatln("%s: %s", field.getName(), inputTypeName(field));
            }
            writer.end()
                  .appendln("}")
                  .newline();
        }

        appendService(writer, query);
        if (mutation != null) {
            appendService(writer, mutation);
        }

        writer.appendln("schema {")
              .begin()
              .formatln("query: %s", query.getName());
        if (mutation != null) {
            writer.formatln("mutation: %s", mutation.getName());
        }

        writer.end()
              .appendln("}")
              .newline();

        writer.flush();
        return out.toString();
    }

    private void appendService(IndentedPrintWriter writer, PService service) {
        writer.formatln("type %s {", service.getName())
              .begin();
        boolean firstMethod = true;
        for (PServiceMethod method : service.getMethods()) {
            if (isHiddenMethod(method)) {
                continue;
            }

            if (firstMethod) {
                firstMethod = false;
            } else {
                writer.newline();
            }

            writer.appendln(method.getName());
            PMessageDescriptor<?> args = method.getRequestType();
            writer.append("(");
            boolean first = true;
            for (PField<?> arg : args.getFields()) {
                if (isHiddenField(arg)) {
                    continue;
                }

                if (first) {
                    first = false;
                } else {
                    writer.append(", ");
                }
                writer.format("%s: %s", arg.getName(), inputTypeName(arg.getDescriptor(), isIdField(arg)));
                if (arg.hasDefaultValue()) {
                    writer.format(" = %s", toArgumentString(arg.getDefaultValue()));
                }
            }
            writer.append("): ");

            PUnionDescriptor<?> response = method.getResponseType();
            if (response != null) {
                PField<?> success = response.fieldForId(0);
                if (success.getType() == PType.VOID) {
                    writer.format("Boolean");
                } else {
                    writer.format("%s!", typeName(success));
                }
            }
        }
        writer.end()
              .appendln("}")
              .newline();
    }

    private String typeName(PField<?> field) {
        return typeName(field.getDescriptor(), isIdField(field)) +
               (field.getRequirement() == PRequirement.REQUIRED ? "!" : "");
    }

    private String typeName(PDescriptor descriptor, boolean idType) {
        switch (descriptor.getType()) {
            case MESSAGE:
                if (isUnionAsInterface(descriptor)) {
                    return typeName(requireNonNull(((PMessageDescriptor<?>) descriptor).getImplementing()), idType);
                }
                return descriptor.getName();
            case ENUM:
                return descriptor.getName();
            case LIST:
            case SET:
                PContainer<?> cd = (PContainer<?>) descriptor;
                return "[" + typeName(cd.itemDescriptor(), idType) + "!]";
            case VOID:
            case BOOL:
                return GQLScalar.Boolean.name();
            case BYTE:
            case I16:
            case I32:
            case I64:
                return GQLScalar.Int.name();
            case DOUBLE:
                return GQLScalar.Float.name();
            case STRING:
            case BINARY:
                if (idType) {
                    return GQLScalar.ID.name();
                }
                return GQLScalar.String.name();
        }
        throw new UnsupportedOperationException("Not supported type " + descriptor.getQualifiedName());
    }

    private String inputTypeName(PField<?> field) {
        return inputTypeName(field.getDescriptor(), isIdField(field)) +
               (field.getRequirement() == PRequirement.REQUIRED ? "!" : "");
    }

    private String inputTypeName(PDescriptor descriptor, boolean idType) {
        if (descriptor.getType() == PType.MESSAGE) {
            return descriptor.getName() + INPUT_TYPE;
        } else if (descriptor.getType() == PType.SET ||
                   descriptor.getType() == PType.LIST) {
            PContainer<?> cd = (PContainer<?>) descriptor;
            return "[" + inputTypeName(cd.itemDescriptor(), idType) + "!]";
        }

        return typeName(descriptor, idType);
    }

    private static final String INPUT_TYPE = "InputType";

    private static boolean isValidIdField(PDescriptor descriptor) {
        if (descriptor.getType() == PType.LIST ||
            descriptor.getType() == PType.SET) {
            return isValidIdField(((PContainer<?>) descriptor).itemDescriptor());
        }
        return descriptor.getType() == PType.STRING ||
               descriptor.getType() == PType.BINARY;
    }

    private static boolean isIgnoredType(@Nonnull PDescriptor descriptor,
                                         boolean ignoreMaps,
                                         @Nonnull Collection<PDescriptor> ignoredTypes) {
        if (ignoredTypes.contains(descriptor) ||
            (ignoreMaps && descriptor.getType() == PType.MAP)) return true;

        if (descriptor.getType() == PType.LIST ||
            descriptor.getType() == PType.SET) {
            return isIgnoredType(((PContainer<?>) descriptor).itemDescriptor(), ignoreMaps, ignoredTypes);
        }
        return false;
    }

    private static void registerInputTypes(PMessageDescriptor<?> descriptor,
                                           Map<String, PDescriptor> types,
                                           Map<String, PDescriptor> inputTypes,
                                           boolean ignoreMaps,
                                           Set<PDescriptor> ignoredTypes,
                                           Set<PField<?>> ignoreFields,
                                           boolean registerSelf) {
        if (descriptor != null) {
            if (descriptor.getVariant() == PMessageVariant.EXCEPTION ||
                inputTypes.containsKey(descriptor.getName())) {
                return;
            }

            if (registerSelf) {
                inputTypes.put(descriptor.getName(), descriptor);
            }

            for (PField<?> field : descriptor.getFields()) {
                if (field.getName().startsWith("__") ||
                    ignoreFields.contains(field) ||
                    isIgnoredType(field.getDescriptor(), ignoreMaps, ignoredTypes)) {
                    continue;
                }

                if (field.getType() == PType.ENUM) {
                    types.put(field.getDescriptor().getName(), field.getDescriptor());
                } else if (field.getType() == PType.MESSAGE) {
                    registerInputTypes((PMessageDescriptor<?>) field.getDescriptor(),
                                       types, inputTypes, ignoreMaps, ignoredTypes, ignoreFields, true);
                } else if ((field.getType() == PType.SET ||
                            field.getType() == PType.LIST)) {
                    PDescriptor itemType = ((PContainer<?>) field.getDescriptor()).itemDescriptor();
                    if (itemType.getType() == PType.MESSAGE) {
                        registerInputTypes((PMessageDescriptor<?>) itemType, types, inputTypes, ignoreMaps, ignoredTypes, ignoreFields, true);
                    } else if (itemType.getType() == PType.ENUM) {
                        types.put(itemType.getName(), itemType);
                    }
                }
            }
        }
    }

    private static void registerTypes(PMessageDescriptor<?> descriptor,
                                      Map<String, PDescriptor> types,
                                      Map<String, PDescriptor> inputTypes,
                                      Set<PUnionDescriptor<?>> asInterface,
                                      boolean ignoreMaps,
                                      Set<PDescriptor> ignoreTypes,
                                      Set<PField<?>> ignoreFields,
                                      boolean registerSelf) {
        if (descriptor != null) {
            if (descriptor.getVariant() == PMessageVariant.EXCEPTION ||
                types.containsKey(descriptor.getName())) {
                return;
            }
            if (descriptor instanceof PUnionDescriptor &&
                asInterface.contains(descriptor)) {
                registerSelf = false;
            }

            if (registerSelf) {
                types.put(descriptor.getName(), descriptor);
            }

            registerTypes(descriptor.getImplementing(),
                          types,
                          inputTypes,
                          asInterface,
                          ignoreMaps,
                          ignoreTypes,
                          ignoreFields,
                          true);

            for (PField<?> field : descriptor.getFields()) {
                if (field.getName().startsWith("__") ||
                    ignoreFields.contains(field) ||
                    isIgnoredType(field.getDescriptor(), ignoreMaps, ignoreTypes)) {
                    continue;
                }

                registerInputTypes(field.getArgumentsType(),
                                   types,
                                   inputTypes,
                                   ignoreMaps,
                                   ignoreTypes,
                                   ignoreFields,
                                   false);

                if (field.getType() == PType.ENUM) {
                    types.put(field.getDescriptor().getName(), field.getDescriptor());
                } else if (field.getType() == PType.MESSAGE) {
                    registerTypes((PMessageDescriptor<?>) field.getDescriptor(),
                                  types,
                                  inputTypes,
                                  asInterface,
                                  ignoreMaps,
                                  ignoreTypes,
                                  ignoreFields,
                                  true);
                } else if ((field.getType() == PType.SET ||
                            field.getType() == PType.LIST)) {
                    PDescriptor itemType = ((PContainer<?>) field.getDescriptor()).itemDescriptor();
                    if (itemType.getType() == PType.MESSAGE) {
                        registerTypes((PMessageDescriptor<?>) itemType,
                                      types,
                                      inputTypes,
                                      asInterface,
                                      ignoreMaps,
                                      ignoreTypes,
                                      ignoreFields,
                                      true);
                    } else if (itemType.getType() == PType.ENUM) {
                        types.put(itemType.getName(), itemType);
                    }
                }
            }
        }
    }
}