diff --git a/build.gradle b/build.gradle index 37dcab18a7..753c0e28a6 100644 --- a/build.gradle +++ b/build.gradle @@ -52,6 +52,7 @@ def reactiveStreamsVersion = '1.0.3' def slf4jVersion = '1.7.35' def releaseVersion = System.env.RELEASE_VERSION def antlrVersion = '4.9.3' // https://mvnrepository.com/artifact/org.antlr/antlr4-runtime +def guavaVersion = '32.1.1-jre' version = releaseVersion ? releaseVersion : getDevelopmentVersion() group = 'com.graphql-java' @@ -91,7 +92,7 @@ dependencies { api 'com.graphql-java:java-dataloader:3.2.0' api 'org.reactivestreams:reactive-streams:' + reactiveStreamsVersion antlr 'org.antlr:antlr4:' + antlrVersion - implementation 'com.google.guava:guava:31.0.1-jre' + implementation 'com.google.guava:guava:' + guavaVersion testImplementation group: 'junit', name: 'junit', version: '4.13.2' testImplementation 'org.spockframework:spock-core:2.0-groovy-3.0' testImplementation 'org.codehaus.groovy:groovy:3.0.9' @@ -127,7 +128,7 @@ shadowJar { } relocate('org.antlr.v4.runtime', 'graphql.org.antlr.v4.runtime') dependencies { - include(dependency('com.google.guava:guava:31.0.1-jre')) + include(dependency('com.google.guava:guava:' + guavaVersion)) include(dependency('org.antlr:antlr4-runtime:' + antlrVersion)) } from "LICENSE.md" @@ -155,7 +156,7 @@ shadowJar { bnd(''' -exportcontents: graphql.* -removeheaders: Private-Package -Import-Package: !com.google.*,!org.checkerframework.*,!javax.annotation.*,!graphql.com.google.*,!org.antlr.*,!graphql.org.antlr.*,* +Import-Package: !com.google.*,!org.checkerframework.*,!javax.annotation.*,!graphql.com.google.*,!org.antlr.*,!graphql.org.antlr.*,!sun.misc.*,* ''') } @@ -172,8 +173,27 @@ task removeNotNeededGuava(type: Zip) { } } +task extractWithoutGuava(type: Copy) { + from({ zipTree({ "build/libs/graphql-java-${project.version}.jar" }) }) { + exclude('/com/**') + } + into layout.buildDirectory.dir("extract") +} + +task buildNewJar(type: Jar) { + from layout.buildDirectory.dir("extract") + archiveFileName = "graphql-java-tmp.jar" + destinationDirectory = file("${project.buildDir}/libs") + manifest { + from file("build/extract/META-INF/MANIFEST.MF") + } + doLast { + delete("build/libs/graphql-java-${project.version}.jar") + file("build/libs/graphql-java-tmp.jar").renameTo(file("build/libs/graphql-java-${project.version}.jar")) + } +} -shadowJar.finalizedBy removeNotNeededGuava +shadowJar.finalizedBy extractWithoutGuava, buildNewJar task testng(type: Test) { diff --git a/src/main/java/graphql/ErrorClassification.java b/src/main/java/graphql/ErrorClassification.java index e23f172810..db9764f2ce 100644 --- a/src/main/java/graphql/ErrorClassification.java +++ b/src/main/java/graphql/ErrorClassification.java @@ -23,4 +23,21 @@ public interface ErrorClassification { default Object toSpecification(GraphQLError error) { return String.valueOf(this); } + + /** + * This produces a simple ErrorClassification that represents the provided String. You can + * use this factory method to give out simple but custom error classifications. + * + * @param errorClassification the string that represents the error classification + * + * @return a ErrorClassification that is that provided string + */ + static ErrorClassification errorClassification(String errorClassification) { + return new ErrorClassification() { + @Override + public String toString() { + return errorClassification; + } + }; + } } diff --git a/src/main/java/graphql/execution/AsyncExecutionStrategy.java b/src/main/java/graphql/execution/AsyncExecutionStrategy.java index bdac02f5a9..9cad7903ed 100644 --- a/src/main/java/graphql/execution/AsyncExecutionStrategy.java +++ b/src/main/java/graphql/execution/AsyncExecutionStrategy.java @@ -5,10 +5,12 @@ import graphql.execution.instrumentation.ExecutionStrategyInstrumentationContext; import graphql.execution.instrumentation.Instrumentation; import graphql.execution.instrumentation.parameters.InstrumentationExecutionStrategyParameters; +import graphql.introspection.Introspection; import java.util.ArrayList; import java.util.List; import java.util.Set; +import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.function.BiConsumer; @@ -46,6 +48,12 @@ public CompletableFuture execute(ExecutionContext executionCont MergedSelectionSet fields = parameters.getFields(); Set fieldNames = fields.keySet(); + + Optional isNotSensible = Introspection.isIntrospectionSensible(executionContext.getGraphQLContext(),fields); + if (isNotSensible.isPresent()) { + return CompletableFuture.completedFuture(isNotSensible.get()); + } + Async.CombinedBuilder futures = Async.ofExpectedSize(fields.size()); List resolvedFields = new ArrayList<>(fieldNames.size()); for (String fieldName : fieldNames) { diff --git a/src/main/java/graphql/execution/AsyncSerialExecutionStrategy.java b/src/main/java/graphql/execution/AsyncSerialExecutionStrategy.java index bf094ac41b..7242578e89 100644 --- a/src/main/java/graphql/execution/AsyncSerialExecutionStrategy.java +++ b/src/main/java/graphql/execution/AsyncSerialExecutionStrategy.java @@ -6,8 +6,10 @@ import graphql.execution.instrumentation.Instrumentation; import graphql.execution.instrumentation.InstrumentationContext; import graphql.execution.instrumentation.parameters.InstrumentationExecutionStrategyParameters; +import graphql.introspection.Introspection; import java.util.List; +import java.util.Optional; import java.util.concurrent.CompletableFuture; import static graphql.execution.instrumentation.SimpleInstrumentationContext.nonNullCtx; @@ -39,6 +41,13 @@ public CompletableFuture execute(ExecutionContext executionCont MergedSelectionSet fields = parameters.getFields(); ImmutableList fieldNames = ImmutableList.copyOf(fields.keySet()); + // this is highly unlikely since Mutations cant do introspection BUT in theory someone could make the query strategy this code + // so belts and braces + Optional isNotSensible = Introspection.isIntrospectionSensible(executionContext.getGraphQLContext(), fields); + if (isNotSensible.isPresent()) { + return CompletableFuture.completedFuture(isNotSensible.get()); + } + CompletableFuture> resultsFuture = Async.eachSequentially(fieldNames, (fieldName, index, prevResults) -> { MergedField currentField = fields.getSubField(fieldName); ResultPath fieldPath = parameters.getPath().segment(mkNameForPath(currentField)); diff --git a/src/main/java/graphql/execution/ValuesResolver.java b/src/main/java/graphql/execution/ValuesResolver.java index fb93b51ae4..ffcd245d0e 100644 --- a/src/main/java/graphql/execution/ValuesResolver.java +++ b/src/main/java/graphql/execution/ValuesResolver.java @@ -220,7 +220,15 @@ private static Object valueToLiteral(GraphqlFieldVisibility fieldVisibility, Inp if (valueMode == NORMALIZED) { return assertShouldNeverHappen("can't infer normalized structure"); } - return valueToLiteralLegacy(inputValueWithState.getValue(), type); + Value value = valueToLiteralLegacy( + inputValueWithState.getValue(), + type); + // + // the valueToLiteralLegacy() nominally cant know if null means never set or is set to a null value + // but this code can know - its is SET to a value so, it MUST be a Null Literal + // this method would assert at the end of it if inputValueWithState.isNotSet() were true + // + return value == null ? NullValue.of() : value; } if (inputValueWithState.isLiteral()) { return inputValueWithState.getValue(); diff --git a/src/main/java/graphql/i18n/I18n.java b/src/main/java/graphql/i18n/I18n.java index dd9efae54e..9ae105faf8 100644 --- a/src/main/java/graphql/i18n/I18n.java +++ b/src/main/java/graphql/i18n/I18n.java @@ -35,7 +35,9 @@ public enum BundleType { protected I18n(BundleType bundleType, Locale locale) { assertNotNull(bundleType); assertNotNull(locale); - this.resourceBundle = ResourceBundle.getBundle(bundleType.baseName, locale); + // load the resource bundle with this classes class loader - to help avoid confusion in complicated worlds + // like OSGI + this.resourceBundle = ResourceBundle.getBundle(bundleType.baseName, locale, I18n.class.getClassLoader()); } public ResourceBundle getResourceBundle() { diff --git a/src/main/java/graphql/introspection/Introspection.java b/src/main/java/graphql/introspection/Introspection.java index 3b8b5f40f0..3c088d5eaf 100644 --- a/src/main/java/graphql/introspection/Introspection.java +++ b/src/main/java/graphql/introspection/Introspection.java @@ -3,8 +3,13 @@ import com.google.common.collect.ImmutableSet; import graphql.Assert; +import graphql.ExecutionResult; +import graphql.ExecutionResultImpl; +import graphql.GraphQLContext; import graphql.Internal; import graphql.PublicApi; +import graphql.execution.MergedField; +import graphql.execution.MergedSelectionSet; import graphql.execution.ValuesResolver; import graphql.language.AstPrinter; import graphql.schema.FieldCoordinates; @@ -31,13 +36,16 @@ import graphql.schema.GraphQLSchema; import graphql.schema.GraphQLUnionType; import graphql.schema.InputValueWithState; +import org.jetbrains.annotations.NotNull; import java.util.ArrayList; 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.concurrent.atomic.AtomicBoolean; import java.util.stream.Collectors; import static graphql.Assert.assertTrue; @@ -56,8 +64,83 @@ import static graphql.schema.GraphQLTypeUtil.unwrapOne; import static graphql.schema.visibility.DefaultGraphqlFieldVisibility.DEFAULT_FIELD_VISIBILITY; +/** + * GraphQl has a unique capability called Introspection that allow + * consumers to inspect the system and discover the fields and types available and makes the system self documented. + *

+ * Some security recommendations such as OWASP + * recommend that introspection be disabled in production. The {@link Introspection#enabledJvmWide(boolean)} method can be used to disable + * introspection for the whole JVM or you can place {@link Introspection#INTROSPECTION_DISABLED} into the {@link GraphQLContext} of a request + * to disable introspection for that request. + */ @PublicApi public class Introspection { + + + /** + * Placing a boolean value under this key in the per request {@link GraphQLContext} will enable + * or disable Introspection on that request. + */ + public static final String INTROSPECTION_DISABLED = "INTROSPECTION_DISABLED"; + private static final AtomicBoolean INTROSPECTION_ENABLED_STATE = new AtomicBoolean(true); + + /** + * This static method will enable / disable Introspection at a JVM wide level. + * + * @param enabled the flag indicating the desired enabled state + * + * @return the previous state of enablement + */ + public static boolean enabledJvmWide(boolean enabled) { + return INTROSPECTION_ENABLED_STATE.getAndSet(enabled); + } + + /** + * @return true if Introspection is enabled at a JVM wide level or false otherwise + */ + public static boolean isEnabledJvmWide() { + return INTROSPECTION_ENABLED_STATE.get(); + } + + /** + * This will look in to the field selection set and see if there are introspection fields, + * and if there is,it checks if introspection should run, and if not it will return an errored {@link ExecutionResult} + * that can be returned to the user. + * + * @param mergedSelectionSet the fields to be executed + * + * @return an optional error result + */ + public static Optional isIntrospectionSensible(GraphQLContext graphQLContext, MergedSelectionSet mergedSelectionSet) { + MergedField schemaField = mergedSelectionSet.getSubField(SchemaMetaFieldDef.getName()); + if (schemaField != null) { + if (!isIntrospectionEnabled(graphQLContext)) { + return mkDisabledError(schemaField); + } + } + MergedField typeField = mergedSelectionSet.getSubField(TypeMetaFieldDef.getName()); + if (typeField != null) { + if (!isIntrospectionEnabled(graphQLContext)) { + return mkDisabledError(typeField); + } + } + // later we can put a good faith check code here to check the fields make sense + return Optional.empty(); + } + + @NotNull + private static Optional mkDisabledError(MergedField schemaField) { + IntrospectionDisabledError error = new IntrospectionDisabledError(schemaField.getSingleField().getSourceLocation()); + return Optional.of(ExecutionResultImpl.newExecutionResult().addError(error).build()); + } + + private static boolean isIntrospectionEnabled(GraphQLContext graphQlContext) { + if (!isEnabledJvmWide()) { + return false; + } + return !graphQlContext.getOrDefault(INTROSPECTION_DISABLED, false); + } + private static final Map> introspectionDataFetchers = new LinkedHashMap<>(); private static void register(GraphQLFieldsContainer parentType, String fieldName, IntrospectionDataFetcher introspectionDataFetcher) { @@ -592,6 +675,7 @@ public enum DirectiveLocation { return environment.getGraphQLSchema().getType(name); }; + // __typename is always available public static final IntrospectionDataFetcher TypeNameMetaFieldDefDataFetcher = environment -> simplePrint(environment.getParentType()); @Internal diff --git a/src/main/java/graphql/introspection/IntrospectionDisabledError.java b/src/main/java/graphql/introspection/IntrospectionDisabledError.java new file mode 100644 index 0000000000..cbd7e077a3 --- /dev/null +++ b/src/main/java/graphql/introspection/IntrospectionDisabledError.java @@ -0,0 +1,35 @@ +package graphql.introspection; + +import graphql.ErrorClassification; +import graphql.ErrorType; +import graphql.GraphQLError; +import graphql.Internal; +import graphql.language.SourceLocation; + +import java.util.Collections; +import java.util.List; + +@Internal +public class IntrospectionDisabledError implements GraphQLError { + + private final List locations; + + public IntrospectionDisabledError(SourceLocation sourceLocation) { + locations = sourceLocation == null ? Collections.emptyList() : Collections.singletonList(sourceLocation); + } + + @Override + public String getMessage() { + return "Introspection has been disabled for this request"; + } + + @Override + public List getLocations() { + return locations; + } + + @Override + public ErrorClassification getErrorType() { + return ErrorClassification.errorClassification("IntrospectionDisabled"); + } +} diff --git a/src/main/java/graphql/parser/ParseCancelledTooDeepException.java b/src/main/java/graphql/parser/ParseCancelledTooDeepException.java new file mode 100644 index 0000000000..f872fd1ac1 --- /dev/null +++ b/src/main/java/graphql/parser/ParseCancelledTooDeepException.java @@ -0,0 +1,15 @@ +package graphql.parser; + +import graphql.Internal; +import graphql.language.SourceLocation; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@Internal +public class ParseCancelledTooDeepException extends InvalidSyntaxException { + + @Internal + public ParseCancelledTooDeepException(String msg, @Nullable SourceLocation sourceLocation, @Nullable String offendingToken, int maxTokens, @NotNull String tokenType) { + super(sourceLocation, msg, null, offendingToken, null); + } +} diff --git a/src/main/java/graphql/parser/ParseCancelledTooManyCharsException.java b/src/main/java/graphql/parser/ParseCancelledTooManyCharsException.java new file mode 100644 index 0000000000..cf3d08e591 --- /dev/null +++ b/src/main/java/graphql/parser/ParseCancelledTooManyCharsException.java @@ -0,0 +1,12 @@ +package graphql.parser; + +import graphql.Internal; + +@Internal +public class ParseCancelledTooManyCharsException extends InvalidSyntaxException { + + @Internal + public ParseCancelledTooManyCharsException(String msg, int maxCharacters) { + super(null, msg, null, null, null); + } +} diff --git a/src/main/java/graphql/parser/Parser.java b/src/main/java/graphql/parser/Parser.java index d2726dda68..3f99eb4f20 100644 --- a/src/main/java/graphql/parser/Parser.java +++ b/src/main/java/graphql/parser/Parser.java @@ -21,6 +21,7 @@ import org.antlr.v4.runtime.atn.PredictionMode; import org.antlr.v4.runtime.tree.ParseTreeListener; import org.antlr.v4.runtime.tree.TerminalNode; +import org.jetbrains.annotations.NotNull; import java.io.IOException; import java.io.Reader; @@ -29,6 +30,7 @@ import java.util.Optional; import java.util.function.BiConsumer; import java.util.function.BiFunction; +import java.util.function.Consumer; /** * This can parse graphql syntax, both Query syntax and Schema Definition Language (SDL) syntax, into an @@ -36,10 +38,10 @@ *

* You should not generally need to call this class as the {@link graphql.GraphQL} code sets this up for you * but if you are doing specific graphql utilities this class is essential. - * + *

* Graphql syntax has a series of characters, such as spaces, new lines and commas that are not considered relevant * to the syntax. However they can be captured and associated with the AST elements they belong to. - * + *

* This costs more memory but for certain use cases (like editors) this maybe be useful. We have chosen to no capture * ignored characters by default but you can turn this on, either per parse or statically for the whole JVM * via {@link ParserOptions#setDefaultParserOptions(ParserOptions)} ()}} @@ -205,39 +207,18 @@ private Type parseTypeImpl(String input) throws InvalidSyntaxException { } private Node parseImpl(Reader reader, BiFunction nodeFunction, ParserOptions parserOptions) throws InvalidSyntaxException { - MultiSourceReader multiSourceReader; - if (reader instanceof MultiSourceReader) { - multiSourceReader = (MultiSourceReader) reader; - } else { - multiSourceReader = MultiSourceReader.newMultiSourceReader() - .reader(reader, null).build(); - } - CodePointCharStream charStream; - try { - charStream = CharStreams.fromReader(multiSourceReader); - } catch (IOException e) { - throw new UncheckedIOException(e); - } + parserOptions = Optional.ofNullable(parserOptions).orElse(ParserOptions.getDefaultParserOptions()); - GraphqlLexer lexer = new GraphqlLexer(charStream); - lexer.removeErrorListeners(); - lexer.addErrorListener(new BaseErrorListener() { - @Override - public void syntaxError(Recognizer recognizer, Object offendingSymbol, int line, int charPositionInLine, String msg, RecognitionException e) { - SourceLocation sourceLocation = AntlrHelper.createSourceLocation(multiSourceReader, line, charPositionInLine); - String preview = AntlrHelper.createPreview(multiSourceReader, line); - throw new InvalidSyntaxException(sourceLocation, msg, preview, null, null); - } - }); + MultiSourceReader multiSourceReader = setupMultiSourceReader(reader, parserOptions); - // default in the parser options if they are not set - parserOptions = Optional.ofNullable(parserOptions).orElse(ParserOptions.getDefaultParserOptions()); + SafeTokenReader safeTokenReader = setupSafeTokenReader(parserOptions, multiSourceReader); + + CodePointCharStream charStream = setupCharStream(safeTokenReader); + + GraphqlLexer lexer = setupGraphqlLexer(multiSourceReader, charStream); // this lexer wrapper allows us to stop lexing when too many tokens are in place. This prevents DOS attacks. - int maxTokens = parserOptions.getMaxTokens(); - int maxWhitespaceTokens = parserOptions.getMaxWhitespaceTokens(); - BiConsumer onTooManyTokens = (maxTokenCount, token) -> throwCancelParseIfTooManyTokens(token, maxTokenCount, multiSourceReader); - SafeTokenSource safeTokenSource = new SafeTokenSource(lexer, maxTokens, maxWhitespaceTokens, onTooManyTokens); + SafeTokenSource safeTokenSource = getSafeTokenSource(parserOptions, multiSourceReader, lexer); CommonTokenStream tokens = new CommonTokenStream(safeTokenSource); @@ -281,13 +262,93 @@ public void syntaxError(Recognizer recognizer, Object offendingSymbol, int return node; } + private static MultiSourceReader setupMultiSourceReader(Reader reader, ParserOptions parserOptions) { + MultiSourceReader multiSourceReader; + if (reader instanceof MultiSourceReader) { + multiSourceReader = (MultiSourceReader) reader; + } else { + multiSourceReader = MultiSourceReader.newMultiSourceReader() + .reader(reader, null).build(); + } + return multiSourceReader; + } + + @NotNull + private static SafeTokenReader setupSafeTokenReader(ParserOptions parserOptions, MultiSourceReader multiSourceReader) { + int maxCharacters = parserOptions.getMaxCharacters(); + Consumer onTooManyCharacters = it -> { + String msg = String.format("More than %d characters have been presented. To prevent Denial Of Service attacks, parsing has been cancelled.", maxCharacters); + throw new ParseCancelledTooManyCharsException(msg, maxCharacters); + }; + return new SafeTokenReader(multiSourceReader, maxCharacters, onTooManyCharacters); + } + + @NotNull + private static CodePointCharStream setupCharStream(SafeTokenReader safeTokenReader) { + CodePointCharStream charStream; + try { + charStream = CharStreams.fromReader(safeTokenReader); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + return charStream; + } + + @NotNull + private static GraphqlLexer setupGraphqlLexer(MultiSourceReader multiSourceReader, CodePointCharStream charStream) { + GraphqlLexer lexer = new GraphqlLexer(charStream); + lexer.removeErrorListeners(); + lexer.addErrorListener(new BaseErrorListener() { + @Override + public void syntaxError(Recognizer recognizer, Object offendingSymbol, int line, int charPositionInLine, String msg, RecognitionException e) { + SourceLocation sourceLocation = AntlrHelper.createSourceLocation(multiSourceReader, line, charPositionInLine); + String preview = AntlrHelper.createPreview(multiSourceReader, line); + throw new InvalidSyntaxException(sourceLocation, msg, preview, null, null); + } + }); + return lexer; + } + + @NotNull + private SafeTokenSource getSafeTokenSource(ParserOptions parserOptions, MultiSourceReader multiSourceReader, GraphqlLexer lexer) { + int maxTokens = parserOptions.getMaxTokens(); + int maxWhitespaceTokens = parserOptions.getMaxWhitespaceTokens(); + BiConsumer onTooManyTokens = (maxTokenCount, token) -> throwIfTokenProblems( + token, + maxTokenCount, + multiSourceReader, + ParseCancelledException.class); + return new SafeTokenSource(lexer, maxTokens, maxWhitespaceTokens, onTooManyTokens); + } + private void setupParserListener(MultiSourceReader multiSourceReader, GraphqlParser parser, GraphqlAntlrToLanguage toLanguage) { ParserOptions parserOptions = toLanguage.getParserOptions(); ParsingListener parsingListener = parserOptions.getParsingListener(); int maxTokens = parserOptions.getMaxTokens(); + int maxRuleDepth = parserOptions.getMaxRuleDepth(); // prevent a billion laugh attacks by restricting how many tokens we allow ParseTreeListener listener = new GraphqlBaseListener() { int count = 0; + int depth = 0; + + + @Override + public void enterEveryRule(ParserRuleContext ctx) { + depth++; + if (depth > maxRuleDepth) { + throwIfTokenProblems( + ctx.getStart(), + maxRuleDepth, + multiSourceReader, + ParseCancelledTooDeepException.class + ); + } + } + + @Override + public void exitEveryRule(ParserRuleContext ctx) { + depth--; + } @Override public void visitTerminal(TerminalNode node) { @@ -312,15 +373,20 @@ public int getCharPositionInLine() { count++; if (count > maxTokens) { - throwCancelParseIfTooManyTokens(token, maxTokens, multiSourceReader); + throwIfTokenProblems( + token, + maxTokens, + multiSourceReader, + ParseCancelledException.class + ); } } }; parser.addParseListener(listener); } - private void throwCancelParseIfTooManyTokens(Token token, int maxTokens, MultiSourceReader multiSourceReader) throws ParseCancelledException { - String tokenType = "grammar"; + private void throwIfTokenProblems(Token token, int maxLimit, MultiSourceReader multiSourceReader, Class targetException) throws ParseCancelledException { + String tokenType = "grammar"; SourceLocation sourceLocation = null; String offendingToken = null; if (token != null) { @@ -330,7 +396,11 @@ private void throwCancelParseIfTooManyTokens(Token token, int maxTokens, MultiSo offendingToken = token.getText(); sourceLocation = AntlrHelper.createSourceLocation(multiSourceReader, token.getLine(), token.getCharPositionInLine()); } - String msg = String.format("More than %d %s tokens have been presented. To prevent Denial Of Service attacks, parsing has been cancelled.", maxTokens, tokenType); + if (targetException.equals(ParseCancelledTooDeepException.class)) { + String msg = String.format("More than %d deep %s rules have been entered. To prevent Denial Of Service attacks, parsing has been cancelled.", maxLimit, tokenType); + throw new ParseCancelledTooDeepException(msg, sourceLocation, offendingToken, maxLimit, tokenType); + } + String msg = String.format("More than %d %s tokens have been presented. To prevent Denial Of Service attacks, parsing has been cancelled.", maxLimit, tokenType); throw new ParseCancelledException(msg, sourceLocation, offendingToken); } diff --git a/src/main/java/graphql/parser/ParserOptions.java b/src/main/java/graphql/parser/ParserOptions.java index 6fe708323a..2d5da2b4dd 100644 --- a/src/main/java/graphql/parser/ParserOptions.java +++ b/src/main/java/graphql/parser/ParserOptions.java @@ -11,13 +11,24 @@ */ @PublicApi public class ParserOptions { + /** + * A graphql hacking vector is to send nonsensical queries with large tokens that contain a repeated characters + * that burn lots of parsing CPU time and burn memory representing a document that won't ever execute. + * To prevent this for most users, graphql-java sets this value to 1MB. + * ANTLR parsing time is linear to the number of characters presented. The more you + * allow the longer it takes. + *

+ * If you want to allow more, then {@link #setDefaultParserOptions(ParserOptions)} allows you to change this + * JVM wide. + */ + public static final int MAX_QUERY_CHARACTERS = 1024 * 1024; // 1 MB /** - * A graphql hacking vector is to send nonsensical queries that burn lots of parsing CPU time and burn + * A graphql hacking vector is to send nonsensical queries with lots of tokens that burn lots of parsing CPU time and burn * memory representing a document that won't ever execute. To prevent this for most users, graphql-java - * set this value to 15000. ANTLR parsing time is linear to the number of tokens presented. The more you + * sets this value to 15000. ANTLR parsing time is linear to the number of tokens presented. The more you * allow the longer it takes. - * + *

* If you want to allow more, then {@link #setDefaultParserOptions(ParserOptions)} allows you to change this * JVM wide. */ @@ -26,34 +37,50 @@ public class ParserOptions { * Another graphql hacking vector is to send large amounts of whitespace in operations that burn lots of parsing CPU time and burn * memory representing a document. Whitespace token processing in ANTLR is 2 orders of magnitude faster than grammar token processing * however it still takes some time to happen. - * + *

* If you want to allow more, then {@link #setDefaultParserOptions(ParserOptions)} allows you to change this * JVM wide. */ public static final int MAX_WHITESPACE_TOKENS = 200_000; + /** + * A graphql hacking vector is to send nonsensical queries that have lots of grammar rule depth to them which + * can cause stack overflow exceptions during the query parsing. To prevent this for most users, graphql-java + * sets this value to 500 grammar rules deep. + *

+ * If you want to allow more, then {@link #setDefaultParserOptions(ParserOptions)} allows you to change this + * JVM wide. + */ + public static final int MAX_RULE_DEPTH = 500; + private static ParserOptions defaultJvmParserOptions = newParserOptions() .captureIgnoredChars(false) .captureSourceLocation(true) .captureLineComments(true) + .maxCharacters(MAX_QUERY_CHARACTERS) .maxTokens(MAX_QUERY_TOKENS) // to prevent a billion laughs style attacks, we set a default for graphql-java .maxWhitespaceTokens(MAX_WHITESPACE_TOKENS) + .maxRuleDepth(MAX_RULE_DEPTH) .build(); private static ParserOptions defaultJvmOperationParserOptions = newParserOptions() .captureIgnoredChars(false) .captureSourceLocation(true) .captureLineComments(false) // #comments are not useful in query parsing + .maxCharacters(MAX_QUERY_CHARACTERS) .maxTokens(MAX_QUERY_TOKENS) // to prevent a billion laughs style attacks, we set a default for graphql-java .maxWhitespaceTokens(MAX_WHITESPACE_TOKENS) + .maxRuleDepth(MAX_RULE_DEPTH) .build(); private static ParserOptions defaultJvmSdlParserOptions = newParserOptions() .captureIgnoredChars(false) .captureSourceLocation(true) .captureLineComments(true) // #comments are useful in SDL parsing + .maxCharacters(Integer.MAX_VALUE) .maxTokens(Integer.MAX_VALUE) // we are less worried about a billion laughs with SDL parsing since the call path is not facing attackers .maxWhitespaceTokens(Integer.MAX_VALUE) + .maxRuleDepth(Integer.MAX_VALUE) .build(); /** @@ -154,16 +181,20 @@ public static void setDefaultSdlParserOptions(ParserOptions options) { private final boolean captureIgnoredChars; private final boolean captureSourceLocation; private final boolean captureLineComments; + private final int maxCharacters; private final int maxTokens; private final int maxWhitespaceTokens; + private final int maxRuleDepth; private final ParsingListener parsingListener; private ParserOptions(Builder builder) { this.captureIgnoredChars = builder.captureIgnoredChars; this.captureSourceLocation = builder.captureSourceLocation; this.captureLineComments = builder.captureLineComments; + this.maxCharacters = builder.maxCharacters; this.maxTokens = builder.maxTokens; this.maxWhitespaceTokens = builder.maxWhitespaceTokens; + this.maxRuleDepth = builder.maxRuleDepth; this.parsingListener = builder.parsingListener; } @@ -204,6 +235,18 @@ public boolean isCaptureLineComments() { return captureLineComments; } + /** + * A graphql hacking vector is to send nonsensical queries that contain a repeated characters that burn lots of parsing CPU time and burn + * memory representing a document that won't ever execute. To prevent this for most users, graphql-java + * sets this value to 1MB. + * + * @return the maximum number of characters the parser will accept, after which an exception will be thrown. + */ + public int getMaxCharacters() { + return maxCharacters; + } + + /** * A graphql hacking vector is to send nonsensical queries that burn lots of parsing CPU time and burns * memory representing a document that won't ever execute. To prevent this you can set a maximum number of parse @@ -226,6 +269,17 @@ public int getMaxWhitespaceTokens() { return maxWhitespaceTokens; } + /** + * A graphql hacking vector is to send nonsensical queries that have lots of rule depth to them which + * can cause stack overflow exceptions during the query parsing. To prevent this you can set a value + * that is the maximum depth allowed before an exception is thrown and the parsing is stopped. + * + * @return the maximum token depth the parser will accept, after which an exception will be thrown. + */ + public int getMaxRuleDepth() { + return maxRuleDepth; + } + public ParsingListener getParsingListener() { return parsingListener; } @@ -245,9 +299,11 @@ public static class Builder { private boolean captureIgnoredChars = false; private boolean captureSourceLocation = true; private boolean captureLineComments = true; - private int maxTokens = MAX_QUERY_TOKENS; private ParsingListener parsingListener = ParsingListener.NOOP; + private int maxCharacters = MAX_QUERY_CHARACTERS; + private int maxTokens = MAX_QUERY_TOKENS; private int maxWhitespaceTokens = MAX_WHITESPACE_TOKENS; + private int maxRuleDepth = MAX_RULE_DEPTH; Builder() { } @@ -256,8 +312,10 @@ public static class Builder { this.captureIgnoredChars = parserOptions.captureIgnoredChars; this.captureSourceLocation = parserOptions.captureSourceLocation; this.captureLineComments = parserOptions.captureLineComments; + this.maxCharacters = parserOptions.maxCharacters; this.maxTokens = parserOptions.maxTokens; this.maxWhitespaceTokens = parserOptions.maxWhitespaceTokens; + this.maxRuleDepth = parserOptions.maxRuleDepth; this.parsingListener = parserOptions.parsingListener; } @@ -276,6 +334,11 @@ public Builder captureLineComments(boolean captureLineComments) { return this; } + public Builder maxCharacters(int maxCharacters) { + this.maxCharacters = maxCharacters; + return this; + } + public Builder maxTokens(int maxTokens) { this.maxTokens = maxTokens; return this; @@ -286,6 +349,11 @@ public Builder maxWhitespaceTokens(int maxWhitespaceTokens) { return this; } + public Builder maxRuleDepth(int maxRuleDepth) { + this.maxRuleDepth = maxRuleDepth; + return this; + } + public Builder parsingListener(ParsingListener parsingListener) { this.parsingListener = assertNotNull(parsingListener); return this; diff --git a/src/main/java/graphql/parser/SafeTokenReader.java b/src/main/java/graphql/parser/SafeTokenReader.java new file mode 100644 index 0000000000..be102be0d2 --- /dev/null +++ b/src/main/java/graphql/parser/SafeTokenReader.java @@ -0,0 +1,95 @@ +package graphql.parser; + +import graphql.Internal; +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; +import java.io.Reader; +import java.nio.CharBuffer; +import java.util.function.Consumer; + +/** + * This reader will only emit a maximum number of characters from it. This is used to protect us from evil input. + *

+ * If a graphql system does not have some max HTTP input limit, then this will help protect the system. This is a limit + * of last resort. Ideally the http input should be limited, but if its not, we have this. + */ +@Internal +public class SafeTokenReader extends Reader { + + private final Reader delegate; + private final int maxCharacters; + private final Consumer whenMaxCharactersExceeded; + private int count; + + public SafeTokenReader(Reader delegate, int maxCharacters, Consumer whenMaxCharactersExceeded) { + this.delegate = delegate; + this.maxCharacters = maxCharacters; + this.whenMaxCharactersExceeded = whenMaxCharactersExceeded; + count = 0; + } + + private int checkHowMany(int read, int howMany) { + if (read != -1) { + count += howMany; + if (count > maxCharacters) { + whenMaxCharactersExceeded.accept(maxCharacters); + } + } + return read; + } + + @Override + public int read(char @NotNull [] buff, int off, int len) throws IOException { + int howMany = delegate.read(buff, off, len); + return checkHowMany(howMany, howMany); + } + + @Override + public int read() throws IOException { + int ch = delegate.read(); + return checkHowMany(ch, 1); + } + + @Override + public int read(@NotNull CharBuffer target) throws IOException { + int howMany = delegate.read(target); + return checkHowMany(howMany, howMany); + } + + @Override + public int read( char @NotNull [] buff) throws IOException { + int howMany = delegate.read(buff); + return checkHowMany(howMany, howMany); + } + + @Override + public void close() throws IOException { + delegate.close(); + } + + @Override + public long skip(long n) throws IOException { + return delegate.skip(n); + } + + @Override + public boolean ready() throws IOException { + return delegate.ready(); + } + + @Override + public boolean markSupported() { + return delegate.markSupported(); + } + + @Override + public void mark(int readAheadLimit) throws IOException { + delegate.mark(readAheadLimit); + } + + @Override + public void reset() throws IOException { + delegate.reset(); + } +} diff --git a/src/main/java/graphql/schema/GraphQLAppliedDirectiveArgument.java b/src/main/java/graphql/schema/GraphQLAppliedDirectiveArgument.java index dd595a5392..5942425204 100644 --- a/src/main/java/graphql/schema/GraphQLAppliedDirectiveArgument.java +++ b/src/main/java/graphql/schema/GraphQLAppliedDirectiveArgument.java @@ -11,6 +11,7 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import java.util.ArrayList; import java.util.List; import java.util.function.Consumer; @@ -34,6 +35,8 @@ public class GraphQLAppliedDirectiveArgument implements GraphQLNamedSchemaElemen private final Argument definition; + public static final String CHILD_TYPE = "type"; + private GraphQLAppliedDirectiveArgument(String name, InputValueWithState value, GraphQLInputType type, @@ -104,19 +107,23 @@ public Argument getDefinition() { @Override public List getChildren() { - return ImmutableKit.emptyList(); + List children = new ArrayList<>(); + children.add(getType()); + return children; } - @Override public SchemaElementChildrenContainer getChildrenWithTypeReferences() { return SchemaElementChildrenContainer.newSchemaElementChildrenContainer() + .child(CHILD_TYPE, originalType) .build(); } @Override public GraphQLAppliedDirectiveArgument withNewChildren(SchemaElementChildrenContainer newChildren) { - return this; + return transform(builder -> + builder.type(newChildren.getChildOrNull(CHILD_TYPE)) + ); } @Override diff --git a/src/main/java/graphql/schema/diff/SchemaDiff.java b/src/main/java/graphql/schema/diff/SchemaDiff.java index 52c2fa0747..b89616bc40 100644 --- a/src/main/java/graphql/schema/diff/SchemaDiff.java +++ b/src/main/java/graphql/schema/diff/SchemaDiff.java @@ -70,7 +70,7 @@ public static Options defaultOptions() { private static class CountingReporter implements DifferenceReporter { final DifferenceReporter delegate; - int breakingCount = 1; + int breakingCount = 0; private CountingReporter(DifferenceReporter delegate) { this.delegate = delegate; diff --git a/src/main/java/graphql/schema/idl/SchemaGeneratorHelper.java b/src/main/java/graphql/schema/idl/SchemaGeneratorHelper.java index 2fcb1bf072..6ebc071501 100644 --- a/src/main/java/graphql/schema/idl/SchemaGeneratorHelper.java +++ b/src/main/java/graphql/schema/idl/SchemaGeneratorHelper.java @@ -80,8 +80,10 @@ import static graphql.Assert.assertNotNull; import static graphql.Directives.DEPRECATED_DIRECTIVE_DEFINITION; +import static graphql.Directives.IncludeDirective; import static graphql.Directives.NO_LONGER_SUPPORTED; import static graphql.Directives.SPECIFIED_BY_DIRECTIVE_DEFINITION; +import static graphql.Directives.SkipDirective; import static graphql.Directives.SpecifiedByDirective; import static graphql.collect.ImmutableKit.emptyList; import static graphql.introspection.Introspection.DirectiveLocation.ARGUMENT_DEFINITION; @@ -1055,6 +1057,11 @@ Set buildAdditionalDirectiveDefinitions(BuildContext buildCtx) TypeDefinitionRegistry typeRegistry = buildCtx.getTypeRegistry(); for (DirectiveDefinition directiveDefinition : typeRegistry.getDirectiveDefinitions().values()) { + if (IncludeDirective.getName().equals(directiveDefinition.getName()) + || SkipDirective.getName().equals(directiveDefinition.getName())) { + // skip and include directives are added by default to the GraphQLSchema via the GraphQLSchema builder. + continue; + } GraphQLDirective directive = buildDirectiveDefinitionFromAst(buildCtx, directiveDefinition, inputTypeFactory(buildCtx)); buildCtx.addDirectiveDefinition(directive); additionalDirectives.add(directive); diff --git a/src/main/java/graphql/schema/idl/SchemaPrinter.java b/src/main/java/graphql/schema/idl/SchemaPrinter.java index ea0df6b2e4..3683a0ddab 100644 --- a/src/main/java/graphql/schema/idl/SchemaPrinter.java +++ b/src/main/java/graphql/schema/idl/SchemaPrinter.java @@ -672,7 +672,7 @@ private SchemaElementPrinter inputObjectPrinter() { String astValue = printAst(defaultValue, fd.getType()); out.format(" = %s", astValue); } - out.format(directivesString(GraphQLInputObjectField.class, fd.isDeprecated(), fd)); + out.print(directivesString(GraphQLInputObjectField.class, fd.isDeprecated(), fd)); out.format("\n"); }); out.format("}"); diff --git a/src/main/java/graphql/schema/transform/FieldVisibilitySchemaTransformation.java b/src/main/java/graphql/schema/transform/FieldVisibilitySchemaTransformation.java index a290258838..303b81b9c8 100644 --- a/src/main/java/graphql/schema/transform/FieldVisibilitySchemaTransformation.java +++ b/src/main/java/graphql/schema/transform/FieldVisibilitySchemaTransformation.java @@ -1,10 +1,12 @@ package graphql.schema.transform; +import com.google.common.collect.ImmutableList; import graphql.PublicApi; import graphql.schema.GraphQLEnumType; import graphql.schema.GraphQLFieldDefinition; import graphql.schema.GraphQLImplementingType; import graphql.schema.GraphQLInputObjectField; +import graphql.schema.GraphQLInputObjectType; import graphql.schema.GraphQLInterfaceType; import graphql.schema.GraphQLNamedSchemaElement; import graphql.schema.GraphQLNamedType; @@ -60,7 +62,7 @@ public final GraphQLSchema apply(GraphQLSchema schema) { Set markedForRemovalTypes = new HashSet<>(); // query, mutation, and subscription types should not be removed - final Set protectedTypeNames = getRootTypes(schema).stream() + final Set protectedTypeNames = getOperationTypes(schema).stream() .map(GraphQLObjectType::getName) .collect(Collectors.toSet()); @@ -216,6 +218,7 @@ public TraversalControl visitGraphQLType(GraphQLSchemaElement node, !observedAfterTransform.contains(node) && (node instanceof GraphQLObjectType || node instanceof GraphQLEnumType || + node instanceof GraphQLInputObjectType || node instanceof GraphQLInterfaceType || node instanceof GraphQLUnionType)) { @@ -250,12 +253,21 @@ public TraversalControl visitGraphQLType(GraphQLSchemaElement node, } } - private List getRootTypes(GraphQLSchema schema) { + private List getRootTypes(GraphQLSchema schema) { + return ImmutableList.builder() + .addAll(getOperationTypes(schema)) + // Include directive definitions as roots, since they won't be removed in the filtering process. + // Some types (enums, input types, etc.) might be reachable only by directive definitions (and + // not by other types or fields). + .addAll(schema.getDirectives()) + .build(); + } + + private List getOperationTypes(GraphQLSchema schema) { return Stream.of( schema.getQueryType(), schema.getSubscriptionType(), schema.getMutationType() ).filter(Objects::nonNull).collect(Collectors.toList()); } - } diff --git a/src/main/java/graphql/schema/visibility/NoIntrospectionGraphqlFieldVisibility.java b/src/main/java/graphql/schema/visibility/NoIntrospectionGraphqlFieldVisibility.java index 2fc2fe6f00..475ecb014c 100644 --- a/src/main/java/graphql/schema/visibility/NoIntrospectionGraphqlFieldVisibility.java +++ b/src/main/java/graphql/schema/visibility/NoIntrospectionGraphqlFieldVisibility.java @@ -10,12 +10,17 @@ /** * This field visibility will prevent Introspection queries from being performed. Technically this puts your - * system in contravention of the specification - http://facebook.github.io/graphql/#sec-Introspection but some - * production systems want this lock down in place. + * system in contravention of the specification + * but some production systems want this lock down in place. + * + * @deprecated This is no longer the best way to prevent Introspection - {@link graphql.introspection.Introspection#enabledJvmWide(boolean)} + * can be used instead */ @PublicApi +@Deprecated // (since = "2024-03-16") public class NoIntrospectionGraphqlFieldVisibility implements GraphqlFieldVisibility { + @Deprecated // (since = "2024-03-16") public static NoIntrospectionGraphqlFieldVisibility NO_INTROSPECTION_FIELD_VISIBILITY = new NoIntrospectionGraphqlFieldVisibility(); diff --git a/src/main/java/graphql/util/Breadcrumb.java b/src/main/java/graphql/util/Breadcrumb.java index cae637efdc..ad1415a00a 100644 --- a/src/main/java/graphql/util/Breadcrumb.java +++ b/src/main/java/graphql/util/Breadcrumb.java @@ -3,6 +3,7 @@ import graphql.PublicApi; import java.util.Objects; +import java.util.StringJoiner; /** * A specific {@link NodeLocation} inside a node. This means {@link #getNode()} returns a Node which has a child @@ -47,9 +48,16 @@ public boolean equals(Object o) { @Override public int hashCode() { int result = 1; - result = 31 * result + Objects.hashCode(node); + result = 31 * result + Objects.hashCode(node); result = 31 * result + Objects.hashCode(location); return result; } + @Override + public String toString() { + return new StringJoiner(", ", "[", "]") + .add("" + location) + .add("" + node) + .toString(); + } } diff --git a/src/main/java/graphql/util/FpKit.java b/src/main/java/graphql/util/FpKit.java index ce164b6595..32b0182bf0 100644 --- a/src/main/java/graphql/util/FpKit.java +++ b/src/main/java/graphql/util/FpKit.java @@ -335,6 +335,7 @@ public static Supplier interThreadMemoize(Supplier delegate) { * * @param set1 first set * @param set2 second set + * @param for two * @return intersection set */ public static Set intersection(Set set1, Set set2) { diff --git a/src/main/java/graphql/util/NodeLocation.java b/src/main/java/graphql/util/NodeLocation.java index 61851b0b6f..8a1f9f10bc 100644 --- a/src/main/java/graphql/util/NodeLocation.java +++ b/src/main/java/graphql/util/NodeLocation.java @@ -33,7 +33,7 @@ public int getIndex() { @Override public String toString() { - return "NodeLocation{" + + return "{" + "name='" + name + '\'' + ", index=" + index + '}'; diff --git a/src/main/java/graphql/validation/TraversalContext.java b/src/main/java/graphql/validation/TraversalContext.java index f9a157941e..f0195f80c4 100644 --- a/src/main/java/graphql/validation/TraversalContext.java +++ b/src/main/java/graphql/validation/TraversalContext.java @@ -33,6 +33,7 @@ import graphql.schema.GraphQLType; import graphql.schema.GraphQLUnionType; import graphql.schema.GraphQLUnmodifiedType; +import graphql.schema.InputValueWithState; import java.util.ArrayList; import java.util.List; @@ -48,6 +49,7 @@ public class TraversalContext implements DocumentVisitor { private final List outputTypeStack = new ArrayList<>(); private final List parentTypeStack = new ArrayList<>(); private final List inputTypeStack = new ArrayList<>(); + private final List defaultValueStack = new ArrayList<>(); private final List fieldDefStack = new ArrayList<>(); private final List nameStack = new ArrayList<>(); private GraphQLDirective directive; @@ -156,6 +158,7 @@ private void enterImpl(Argument argument) { } addInputType(argumentType != null ? argumentType.getType() : null); + addDefaultValue(argumentType != null ? argumentType.getArgumentDefaultValue() : null); this.argument = argumentType; } @@ -166,23 +169,30 @@ private void enterImpl(ArrayValue arrayValue) { inputType = (GraphQLInputType) unwrapOne(nullableType); } addInputType(inputType); + // List positions never have a default value. See graphql-js impl for inspiration + addDefaultValue(null); } private void enterImpl(ObjectField objectField) { GraphQLUnmodifiedType objectType = unwrapAll(getInputType()); GraphQLInputType inputType = null; + GraphQLInputObjectField inputField = null; if (objectType instanceof GraphQLInputObjectType) { GraphQLInputObjectType inputObjectType = (GraphQLInputObjectType) objectType; - GraphQLInputObjectField inputField = schema.getFieldVisibility().getFieldDefinition(inputObjectType, objectField.getName()); - if (inputField != null) + inputField = schema.getFieldVisibility().getFieldDefinition(inputObjectType, objectField.getName()); + if (inputField != null) { inputType = inputField.getType(); + } } addInputType(inputType); + addDefaultValue(inputField != null ? inputField.getInputFieldDefaultValue() : null); } private GraphQLArgument find(List arguments, String name) { for (GraphQLArgument argument : arguments) { - if (argument.getName().equals(name)) return argument; + if (argument.getName().equals(name)) { + return argument; + } } return null; } @@ -191,29 +201,32 @@ private GraphQLArgument find(List arguments, String name) { @Override public void leave(Node node, List ancestors) { if (node instanceof OperationDefinition) { - outputTypeStack.remove(outputTypeStack.size() - 1); + pop(outputTypeStack); } else if (node instanceof SelectionSet) { - parentTypeStack.remove(parentTypeStack.size() - 1); + pop(parentTypeStack); } else if (node instanceof Field) { leaveName(((Field) node).getName()); - fieldDefStack.remove(fieldDefStack.size() - 1); - outputTypeStack.remove(outputTypeStack.size() - 1); + pop(fieldDefStack); + pop(outputTypeStack); } else if (node instanceof Directive) { directive = null; } else if (node instanceof InlineFragment) { - outputTypeStack.remove(outputTypeStack.size() - 1); + pop(outputTypeStack); } else if (node instanceof FragmentDefinition) { leaveName(((FragmentDefinition) node).getName()); - outputTypeStack.remove(outputTypeStack.size() - 1); + pop(outputTypeStack); } else if (node instanceof VariableDefinition) { inputTypeStack.remove(inputTypeStack.size() - 1); } else if (node instanceof Argument) { argument = null; - inputTypeStack.remove(inputTypeStack.size() - 1); + pop(inputTypeStack); + pop(defaultValueStack); } else if (node instanceof ArrayValue) { - inputTypeStack.remove(inputTypeStack.size() - 1); + pop(inputTypeStack); + pop(defaultValueStack); } else if (node instanceof ObjectField) { - inputTypeStack.remove(inputTypeStack.size() - 1); + pop(inputTypeStack); + pop(defaultValueStack); } } @@ -250,10 +263,16 @@ private void addOutputType(GraphQLOutputType type) { } private T lastElement(List list) { - if (list.size() == 0) return null; + if (list.isEmpty()) { + return null; + } return list.get(list.size() - 1); } + private T pop(List list) { + return list.remove(list.size() - 1); + } + /** * @return can be null if the parent is not a CompositeType */ @@ -268,11 +287,18 @@ private void addParentType(GraphQLCompositeType compositeType) { public GraphQLInputType getInputType() { return lastElement(inputTypeStack); } + public InputValueWithState getDefaultValue() { + return lastElement(defaultValueStack); + } private void addInputType(GraphQLInputType graphQLInputType) { inputTypeStack.add(graphQLInputType); } + private void addDefaultValue(InputValueWithState defaultValue) { + defaultValueStack.add(defaultValue); + } + public GraphQLFieldDefinition getFieldDef() { return lastElement(fieldDefStack); } diff --git a/src/main/java/graphql/validation/ValidationContext.java b/src/main/java/graphql/validation/ValidationContext.java index ad94f603ee..9333f2a6fe 100644 --- a/src/main/java/graphql/validation/ValidationContext.java +++ b/src/main/java/graphql/validation/ValidationContext.java @@ -13,6 +13,7 @@ import graphql.schema.GraphQLInputType; import graphql.schema.GraphQLOutputType; import graphql.schema.GraphQLSchema; +import graphql.schema.InputValueWithState; import java.util.LinkedHashMap; import java.util.List; @@ -37,8 +38,10 @@ public ValidationContext(GraphQLSchema schema, Document document, I18n i18n) { } private void buildFragmentMap() { - for (Definition definition : document.getDefinitions()) { - if (!(definition instanceof FragmentDefinition)) continue; + for (Definition definition : document.getDefinitions()) { + if (!(definition instanceof FragmentDefinition)) { + continue; + } FragmentDefinition fragmentDefinition = (FragmentDefinition) definition; fragmentDefinitionMap.put(fragmentDefinition.getName(), fragmentDefinition); } @@ -68,6 +71,10 @@ public GraphQLInputType getInputType() { return traversalContext.getInputType(); } + public InputValueWithState getDefaultValue() { + return traversalContext.getDefaultValue(); + } + public GraphQLFieldDefinition getFieldDef() { return traversalContext.getFieldDef(); } diff --git a/src/main/java/graphql/validation/rules/OverlappingFieldsCanBeMerged.java b/src/main/java/graphql/validation/rules/OverlappingFieldsCanBeMerged.java index 526d11e514..a674e46929 100644 --- a/src/main/java/graphql/validation/rules/OverlappingFieldsCanBeMerged.java +++ b/src/main/java/graphql/validation/rules/OverlappingFieldsCanBeMerged.java @@ -135,7 +135,7 @@ private void collectFieldsForField(Map> fieldMap, Grap GraphQLFieldDefinition fieldDefinition = getVisibleFieldDefinition(fieldsContainer, field); fieldType = fieldDefinition != null ? fieldDefinition.getType() : null; } - fieldMap.get(responseName).add(new FieldAndType(field, fieldType, parentType)); + fieldMap.get(responseName).add(new FieldAndType(field, fieldType, unwrappedParent)); } private GraphQLFieldDefinition getVisibleFieldDefinition(GraphQLFieldsContainer fieldsContainer, Field field) { diff --git a/src/main/java/graphql/validation/rules/VariableTypesMatch.java b/src/main/java/graphql/validation/rules/VariableTypesMatch.java index 72ca603df2..bec873d1e3 100644 --- a/src/main/java/graphql/validation/rules/VariableTypesMatch.java +++ b/src/main/java/graphql/validation/rules/VariableTypesMatch.java @@ -59,24 +59,25 @@ public void checkVariable(VariableReference variableReference) { if (variableType == null) { return; } - GraphQLInputType expectedType = getValidationContext().getInputType(); - Optional schemaDefault = Optional.ofNullable(getValidationContext().getArgument()).map(v -> v.getArgumentDefaultValue()); - Value schemaDefaultValue = null; - if (schemaDefault.isPresent() && schemaDefault.get().isLiteral()) { - schemaDefaultValue = (Value) schemaDefault.get().getValue(); - } else if (schemaDefault.isPresent() && schemaDefault.get().isSet()) { - schemaDefaultValue = ValuesResolver.valueToLiteral(schemaDefault.get(), expectedType); - } - if (expectedType == null) { - // we must have a unknown variable say to not have a known type + GraphQLInputType locationType = getValidationContext().getInputType(); + Optional locationDefault = Optional.ofNullable(getValidationContext().getDefaultValue()); + if (locationType == null) { + // we must have an unknown variable say to not have a known type return; } - if (!variablesTypesMatcher.doesVariableTypesMatch(variableType, variableDefinition.getDefaultValue(), expectedType) && - !variablesTypesMatcher.doesVariableTypesMatch(variableType, schemaDefaultValue, expectedType)) { + Value locationDefaultValue = null; + if (locationDefault.isPresent() && locationDefault.get().isLiteral()) { + locationDefaultValue = (Value) locationDefault.get().getValue(); + } else if (locationDefault.isPresent() && locationDefault.get().isSet()) { + locationDefaultValue = ValuesResolver.valueToLiteral(locationDefault.get(), locationType); + } + boolean variableDefMatches = variablesTypesMatcher.doesVariableTypesMatch(variableType, variableDefinition.getDefaultValue(), locationType, locationDefaultValue); + if (!variableDefMatches) { GraphQLType effectiveType = variablesTypesMatcher.effectiveType(variableType, variableDefinition.getDefaultValue()); String message = i18n(VariableTypeMismatch, "VariableTypesMatchRule.unexpectedType", + variableDefinition.getName(), GraphQLTypeUtil.simplePrint(effectiveType), - GraphQLTypeUtil.simplePrint(expectedType)); + GraphQLTypeUtil.simplePrint(locationType)); addError(VariableTypeMismatch, variableReference.getSourceLocation(), message); } } diff --git a/src/main/java/graphql/validation/rules/VariablesTypesMatcher.java b/src/main/java/graphql/validation/rules/VariablesTypesMatcher.java index 8208eba393..0b47fcec78 100644 --- a/src/main/java/graphql/validation/rules/VariablesTypesMatcher.java +++ b/src/main/java/graphql/validation/rules/VariablesTypesMatcher.java @@ -9,16 +9,38 @@ import static graphql.schema.GraphQLNonNull.nonNull; import static graphql.schema.GraphQLTypeUtil.isList; import static graphql.schema.GraphQLTypeUtil.isNonNull; +import static graphql.schema.GraphQLTypeUtil.unwrapNonNull; import static graphql.schema.GraphQLTypeUtil.unwrapOne; @Internal public class VariablesTypesMatcher { - public boolean doesVariableTypesMatch(GraphQLType variableType, Value variableDefaultValue, GraphQLType expectedType) { - return checkType(effectiveType(variableType, variableDefaultValue), expectedType); + /** + * This method and variable naming was inspired from the reference graphql-js implementation + * + * @param varType the variable type + * @param varDefaultValue the default value for the variable + * @param locationType the location type where the variable was encountered + * @param locationDefaultValue the default value for that location + * + * @return true if the variable matches ok + */ + public boolean doesVariableTypesMatch(GraphQLType varType, Value varDefaultValue, GraphQLType locationType, Value locationDefaultValue) { + if (isNonNull(locationType) && !isNonNull(varType)) { + boolean hasNonNullVariableDefaultValue = + varDefaultValue != null && !(varDefaultValue instanceof NullValue); + boolean hasLocationDefaultValue = locationDefaultValue != null; + if (!hasNonNullVariableDefaultValue && !hasLocationDefaultValue) { + return false; + } + GraphQLType nullableLocationType = unwrapNonNull(locationType); + return checkType(varType, nullableLocationType); + } + return checkType(varType, locationType); } - public GraphQLType effectiveType(GraphQLType variableType, Value defaultValue) { + + public GraphQLType effectiveType(GraphQLType variableType, Value defaultValue) { if (defaultValue == null || defaultValue instanceof NullValue) { return variableType; } diff --git a/src/main/resources/i18n/Validation.properties b/src/main/resources/i18n/Validation.properties index c6eb1ae49b..7cac6b1fa8 100644 --- a/src/main/resources/i18n/Validation.properties +++ b/src/main/resources/i18n/Validation.properties @@ -76,7 +76,7 @@ VariableDefaultValuesOfCorrectType.badDefault=Validation error ({0}) : Bad defau # VariablesAreInputTypes.wrongType=Validation error ({0}) : Input variable ''{1}'' type ''{2}'' is not an input type # -VariableTypesMatchRule.unexpectedType=Validation error ({0}) : Variable type ''{1}'' does not match expected type ''{2}'' +VariableTypesMatchRule.unexpectedType=Validation error ({0}) : Variable ''{1}'' of type ''{2}'' used in position expecting type ''{3}'' # # These are used but IDEA cant find them easily as being called # diff --git a/src/test/groovy/graphql/execution/ValuesResolverE2ETest.groovy b/src/test/groovy/graphql/execution/ValuesResolverE2ETest.groovy new file mode 100644 index 0000000000..590d84e277 --- /dev/null +++ b/src/test/groovy/graphql/execution/ValuesResolverE2ETest.groovy @@ -0,0 +1,136 @@ +package graphql.execution + +import graphql.ExecutionInput +import graphql.ExecutionResult +import graphql.GraphQL +import graphql.Scalars +import graphql.TestUtil +import graphql.schema.DataFetcher +import graphql.schema.DataFetchingEnvironment +import graphql.schema.FieldCoordinates +import graphql.schema.GraphQLCodeRegistry +import graphql.schema.GraphQLInputObjectType +import graphql.schema.GraphQLList +import graphql.schema.GraphQLObjectType +import graphql.schema.GraphQLSchema +import spock.lang.Specification + +class ValuesResolverE2ETest extends Specification { + + def "issue 3276 - reported bug on validation problems as SDL"() { + def sdl = ''' + type Query { + items(pagination: Pagination = {limit: 10, offset: 0}): [String] + } + + input Pagination { + limit: Int + offset: Int + } + ''' + DataFetcher df = { DataFetchingEnvironment env -> + def pagination = env.getArgument("pagination") as Map + def strings = pagination.entrySet().collect { entry -> entry.key + "=" + entry.value } + return strings + } + def schema = TestUtil.schema(sdl, [Query: [items: df]]) + def graphQL = GraphQL.newGraphQL(schema).build() + + when: + def ei = ExecutionInput.newExecutionInput(''' + query Items($limit: Int, $offset: Int) { + items(pagination: {limit: $limit, offset: $offset}) + } + ''').variables([limit: 5, offset: 0]).build() + def er = graphQL.execute(ei) + then: + er.errors.isEmpty() + er.data == [items : ["limit=5", "offset=0"]] + } + + def "issue 3276 - reported bug on validation problems as reported code"() { + DataFetcher dataFetcher = { env -> + def pagination = env.getArgument("pagination") as Map + def strings = pagination.entrySet().collect { entry -> entry.key + "=" + entry.value } + return strings + } + GraphQLSchema schema = GraphQLSchema.newSchema() + .query(GraphQLObjectType.newObject() + .name("Query") + .field(items -> items + .name("items") + .type(GraphQLList.list(Scalars.GraphQLString)) + .argument(pagination -> pagination + .name("pagination") + //skipped adding the default limit/offset values as it doesn't change anything + .defaultValueProgrammatic(new HashMap<>()) + .type(GraphQLInputObjectType.newInputObject() + .name("Pagination") + .field(limit -> limit + .name("limit") + .type(Scalars.GraphQLInt)) + .field(offset -> offset + .name("offset") + .type(Scalars.GraphQLInt)) + .build()))) + .build()) + .codeRegistry(GraphQLCodeRegistry.newCodeRegistry() + .dataFetcher(FieldCoordinates.coordinates("Query", "items"), dataFetcher) + .build()) + .build() + + GraphQL gql = GraphQL.newGraphQL(schema).build() + + Map vars = new HashMap<>() + vars.put("limit", 5) + vars.put("offset", 0) + + ExecutionInput ei = ExecutionInput.newExecutionInput() + .query("query Items( \$limit: Int, \$offset: Int) {\n" + + " items(\n" + + " pagination: {limit: \$limit, offset: \$offset} \n" + + " )\n" + + "}") + .variables(vars) + .build() + + when: + ExecutionResult result = gql.execute( ei) + then: + result.errors.isEmpty() + result.data == [items : ["limit=5", "offset=0"]] + } + + def "issue 3276 - should end up in validation errors because location defaults are not present"() { + def sdl = ''' + type Query { + items(pagination: Pagination = {limit: 1, offset: 1}): [String] + } + input Pagination { + limit: Int! #non-null this time, no default value + offset: Int! #non-null this time, no default value + } + ''' + DataFetcher df = { DataFetchingEnvironment env -> + def pagination = env.getArgument("pagination") as Map + def strings = pagination.entrySet().collect { entry -> entry.key + "=" + entry.value } + return strings + } + def schema = TestUtil.schema(sdl, [Query: [items: df]]) + def graphQL = GraphQL.newGraphQL(schema).build() + + when: + def ei = ExecutionInput.newExecutionInput(''' + query Items( $limit: Int, $offset: Int) { + items( + pagination: {limit: $limit, offset: $offset} + ) + } + ''').variables([limit: 5, offset: null]).build() + def er = graphQL.execute(ei) + then: + er.errors.size() == 2 + er.errors[0].message == "Validation error (VariableTypeMismatch@[items]) : Variable 'limit' of type 'Int' used in position expecting type 'Int!'" + er.errors[1].message == "Validation error (VariableTypeMismatch@[items]) : Variable 'offset' of type 'Int' used in position expecting type 'Int!'" + } +} diff --git a/src/test/groovy/graphql/i18n/I18nTest.groovy b/src/test/groovy/graphql/i18n/I18nTest.groovy index 7abe1346a6..d8c26b2218 100644 --- a/src/test/groovy/graphql/i18n/I18nTest.groovy +++ b/src/test/groovy/graphql/i18n/I18nTest.groovy @@ -1,6 +1,8 @@ package graphql.i18n import graphql.AssertException +import graphql.ExecutionInput +import graphql.TestUtil import graphql.i18n.I18n.BundleType import spock.lang.Specification @@ -26,6 +28,88 @@ class I18nTest extends Specification { } } + + def "missing resource bundles default to a base version"() { + // see https://saimana.com/list-of-country-locale-code/ + + def expected = "Validation error ({0}) : Type '{1}' definition is not executable" + + when: + def i18n = I18n.i18n(BundleType.Validation, Locale.ENGLISH) + def msg = i18n.msg("ExecutableDefinitions.notExecutableType") + + then: + msg == expected + + when: + i18n = I18n.i18n(BundleType.Validation, Locale.CHINESE) + msg = i18n.msg("ExecutableDefinitions.notExecutableType") + then: + msg == expected + + when: + i18n = I18n.i18n(BundleType.Validation, new Locale("en", "IN")) // India + msg = i18n.msg("ExecutableDefinitions.notExecutableType") + then: + msg == expected + + when: + i18n = I18n.i18n(BundleType.Validation, new Locale("en", "FJ")) // Fiji + msg = i18n.msg("ExecutableDefinitions.notExecutableType") + then: + msg == expected + + when: + i18n = I18n.i18n(BundleType.Validation, new Locale("")) // Nothing + msg = i18n.msg("ExecutableDefinitions.notExecutableType") + then: + msg == expected + } + + def "integration test of valid messages"() { + def sdl = """ + type Query { + field(arg : Int) : Subselection + } + + type Subselection { + name : String + } + """ + def graphQL = TestUtil.graphQL(sdl).build() + + + when: + def locale = new Locale("en", "IN") + def ei = ExecutionInput.newExecutionInput().query("query missingSubselectionQ { field(arg : 1) }") + .locale(locale) + .build() + def er = graphQL.execute(ei) + then: + !er.errors.isEmpty() + er.errors[0].message == "Validation error (SubselectionRequired@[field]) : Subselection required for type 'Subselection' of field 'field'" + + + when: + locale = Locale.getDefault() + ei = ExecutionInput.newExecutionInput().query("query missingSubselectionQ { field(arg : 1) }") + .locale(locale) + .build() + er = graphQL.execute(ei) + then: + !er.errors.isEmpty() + er.errors[0].message == "Validation error (SubselectionRequired@[field]) : Subselection required for type 'Subselection' of field 'field'" + + when: + // no locale - it should default + ei = ExecutionInput.newExecutionInput().query("query missingSubselectionQ { field(arg : 1) }") + .build() + er = graphQL.execute(ei) + then: + !er.errors.isEmpty() + er.errors[0].message == "Validation error (SubselectionRequired@[field]) : Subselection required for type 'Subselection' of field 'field'" + } + static def assertBundleStaticShape(ResourceBundle bundle) { def enumeration = bundle.getKeys() while (enumeration.hasMoreElements()) { diff --git a/src/test/groovy/graphql/introspection/IntrospectionTest.groovy b/src/test/groovy/graphql/introspection/IntrospectionTest.groovy index 5dd073e629..77ac0298d5 100644 --- a/src/test/groovy/graphql/introspection/IntrospectionTest.groovy +++ b/src/test/groovy/graphql/introspection/IntrospectionTest.groovy @@ -1,6 +1,8 @@ package graphql.introspection +import graphql.ExecutionInput import graphql.TestUtil +import graphql.execution.AsyncSerialExecutionStrategy import graphql.schema.DataFetcher import graphql.schema.FieldCoordinates import graphql.schema.GraphQLCodeRegistry @@ -11,9 +13,22 @@ import spock.lang.See import spock.lang.Specification import static graphql.GraphQL.newGraphQL +import static graphql.Scalars.GraphQLString +import static graphql.schema.GraphQLArgument.newArgument +import static graphql.schema.GraphQLFieldDefinition.newFieldDefinition +import static graphql.schema.GraphQLObjectType.newObject +import static graphql.schema.GraphQLSchema.newSchema class IntrospectionTest extends Specification { + def setup() { + Introspection.enabledJvmWide(true) + } + + def cleanup() { + Introspection.enabledJvmWide(true) + } + def "bug 1186 - introspection depth check"() { def spec = ''' type Query { @@ -353,4 +368,134 @@ class IntrospectionTest extends Specification { then: queryTypeFields == [[name: "inA"], [name: "inB"], [name: "inC"]] } + + def "issue 3285 - deprecated defaultValue on programmatic args prints AST literal as expected"() { + def queryObjType = newObject().name("Query") + .field(newFieldDefinition().name("f").type(GraphQLString) + .argument(newArgument().name("arg").type(GraphQLString).defaultValue(null))) + .build() + def schema = newSchema().query(queryObjType).build() + def graphQL = newGraphQL(schema).build() + + + when: + def executionResult = graphQL.execute(IntrospectionQuery.INTROSPECTION_QUERY) + then: + executionResult.errors.isEmpty() + + def types = executionResult.data['__schema']['types'] as List + def queryType = types.find { it['name'] == 'Query' } + def fField = (queryType['fields'] as List)[0] + def arg = (fField['args'] as List)[0] + arg['name'] == "arg" + arg['defaultValue'] == "null" // printed AST + } + + def "jvm wide enablement"() { + def graphQL = TestUtil.graphQL("type Query { f : String } ").build() + + when: + def er = graphQL.execute(IntrospectionQuery.INTROSPECTION_QUERY) + + then: + er.errors.isEmpty() + + when: + Introspection.enabledJvmWide(false) + er = graphQL.execute(IntrospectionQuery.INTROSPECTION_QUERY) + + then: + er.errors[0] instanceof IntrospectionDisabledError + er.errors[0].getErrorType().toString() == "IntrospectionDisabled" + + when: + Introspection.enabledJvmWide(true) + er = graphQL.execute(IntrospectionQuery.INTROSPECTION_QUERY) + + then: + er.errors.isEmpty() + } + + def "per request enablement"() { + def graphQL = TestUtil.graphQL("type Query { f : String } ").build() + + when: + // null context + def ei = ExecutionInput.newExecutionInput(IntrospectionQuery.INTROSPECTION_QUERY) + .build() + def er = graphQL.execute(ei) + + then: + er.errors.isEmpty() + + when: + ei = ExecutionInput.newExecutionInput(IntrospectionQuery.INTROSPECTION_QUERY) + .graphQLContext(["INTROSPECTION_DISABLED": false]).build() + er = graphQL.execute(ei) + + then: + er.errors.isEmpty() + + when: + ei = ExecutionInput.newExecutionInput(IntrospectionQuery.INTROSPECTION_QUERY) + .graphQLContext(["INTROSPECTION_DISABLED": true]).build() + er = graphQL.execute(ei) + + then: + er.errors[0] instanceof IntrospectionDisabledError + er.errors[0].getErrorType().toString() == "IntrospectionDisabled" + } + + def "mixed schema and other fields stop early"() { + def graphQL = TestUtil.graphQL("type Query { normalField : String } ").build() + + def query = """ + query goodAndBad { + normalField + __schema{ types{ fields { name }}} + } + """ + + when: + def er = graphQL.execute(query) + + then: + er.errors.isEmpty() + + when: + Introspection.enabledJvmWide(false) + er = graphQL.execute(query) + + then: + er.errors[0] instanceof IntrospectionDisabledError + er.errors[0].getErrorType().toString() == "IntrospectionDisabled" + er.data == null // stops hard + } + + def "AsyncSerialExecutionStrategy with jvm wide enablement"() { + def graphQL = TestUtil.graphQL("type Query { f : String } ") + .queryExecutionStrategy(new AsyncSerialExecutionStrategy()).build() + + when: + def er = graphQL.execute(IntrospectionQuery.INTROSPECTION_QUERY) + + then: + er.errors.isEmpty() + + when: + Introspection.enabledJvmWide(false) + er = graphQL.execute(IntrospectionQuery.INTROSPECTION_QUERY) + + then: + er.errors[0] instanceof IntrospectionDisabledError + er.errors[0].getErrorType().toString() == "IntrospectionDisabled" + + when: + Introspection.enabledJvmWide(true) + er = graphQL.execute(IntrospectionQuery.INTROSPECTION_QUERY) + + then: + er.errors.isEmpty() + } + } diff --git a/src/test/groovy/graphql/parser/ParserOptionsTest.groovy b/src/test/groovy/graphql/parser/ParserOptionsTest.groovy index 5867b181fc..6a0937ff5c 100644 --- a/src/test/groovy/graphql/parser/ParserOptionsTest.groovy +++ b/src/test/groovy/graphql/parser/ParserOptionsTest.groovy @@ -7,6 +7,8 @@ class ParserOptionsTest extends Specification { static defaultOperationOptions = ParserOptions.getDefaultOperationParserOptions() static defaultSdlOptions = ParserOptions.getDefaultSdlParserOptions() + static final int ONE_MB = 1024 * 1024 + void setup() { ParserOptions.setDefaultParserOptions(defaultOptions) ParserOptions.setDefaultOperationParserOptions(defaultOperationOptions) @@ -21,6 +23,7 @@ class ParserOptionsTest extends Specification { def "lock in default settings"() { expect: + defaultOptions.getMaxCharacters() == ONE_MB defaultOptions.getMaxTokens() == 15_000 defaultOptions.getMaxWhitespaceTokens() == 200_000 defaultOptions.isCaptureSourceLocation() @@ -33,6 +36,7 @@ class ParserOptionsTest extends Specification { !defaultOperationOptions.isCaptureLineComments() !defaultOperationOptions.isCaptureIgnoredChars() + defaultSdlOptions.getMaxCharacters() == Integer.MAX_VALUE defaultSdlOptions.getMaxTokens() == Integer.MAX_VALUE defaultSdlOptions.getMaxWhitespaceTokens() == Integer.MAX_VALUE defaultSdlOptions.isCaptureSourceLocation() @@ -41,11 +45,22 @@ class ParserOptionsTest extends Specification { } def "can set in new option JVM wide"() { - def newDefaultOptions = defaultOptions.transform({ it.captureIgnoredChars(true) }) + def newDefaultOptions = defaultOptions.transform({ + it.captureIgnoredChars(true) + }) def newDefaultOperationOptions = defaultOperationOptions.transform( - { it.captureIgnoredChars(true).captureLineComments(true).maxWhitespaceTokens(300_000) }) + { + it.captureIgnoredChars(true) + .captureLineComments(true) + .maxCharacters(1_000_000) + .maxWhitespaceTokens(300_000) + }) def newDefaultSDlOptions = defaultSdlOptions.transform( - { it.captureIgnoredChars(true).captureLineComments(true).maxWhitespaceTokens(300_000) }) + { + it.captureIgnoredChars(true) + .captureLineComments(true) + .maxWhitespaceTokens(300_000) + }) when: ParserOptions.setDefaultParserOptions(newDefaultOptions) @@ -58,18 +73,21 @@ class ParserOptionsTest extends Specification { then: + currentDefaultOptions.getMaxCharacters() == ONE_MB currentDefaultOptions.getMaxTokens() == 15_000 currentDefaultOptions.getMaxWhitespaceTokens() == 200_000 currentDefaultOptions.isCaptureSourceLocation() currentDefaultOptions.isCaptureLineComments() currentDefaultOptions.isCaptureIgnoredChars() + currentDefaultOperationOptions.getMaxCharacters() == 1_000_000 currentDefaultOperationOptions.getMaxTokens() == 15_000 currentDefaultOperationOptions.getMaxWhitespaceTokens() == 300_000 currentDefaultOperationOptions.isCaptureSourceLocation() currentDefaultOperationOptions.isCaptureLineComments() currentDefaultOperationOptions.isCaptureIgnoredChars() + currentDefaultSdlOptions.getMaxCharacters() == Integer.MAX_VALUE currentDefaultSdlOptions.getMaxTokens() == Integer.MAX_VALUE currentDefaultSdlOptions.getMaxWhitespaceTokens() == 300_000 currentDefaultSdlOptions.isCaptureSourceLocation() diff --git a/src/test/groovy/graphql/parser/ParserStressTest.groovy b/src/test/groovy/graphql/parser/ParserStressTest.groovy new file mode 100644 index 0000000000..1c1f252d2d --- /dev/null +++ b/src/test/groovy/graphql/parser/ParserStressTest.groovy @@ -0,0 +1,198 @@ +package graphql.parser + +import graphql.ExecutionInput +import graphql.TestUtil +import spock.lang.Specification + +/** + * Tests related to how the Parser can be stress tested + */ +class ParserStressTest extends Specification { + static defaultOptions = ParserOptions.getDefaultParserOptions() + static defaultOperationOptions = ParserOptions.getDefaultOperationParserOptions() + static defaultSdlOptions = ParserOptions.getDefaultSdlParserOptions() + + void setup() { + ParserOptions.setDefaultParserOptions(defaultOptions) + ParserOptions.setDefaultOperationParserOptions(defaultOperationOptions) + ParserOptions.setDefaultSdlParserOptions(defaultSdlOptions) + } + + void cleanup() { + ParserOptions.setDefaultParserOptions(defaultOptions) + ParserOptions.setDefaultOperationParserOptions(defaultOperationOptions) + ParserOptions.setDefaultSdlParserOptions(defaultSdlOptions) + } + + + def "a billion laughs attack will be prevented by default"() { + def lol = "@lol" * 10000 // two tokens = 20000+ tokens + def text = "query { f $lol }" + when: + Parser.parse(text) + + then: + def e = thrown(ParseCancelledException) + e.getMessage().contains("parsing has been cancelled") + + when: "integration test to prove it cancels by default" + + def sdl = """type Query { f : ID} """ + def graphQL = TestUtil.graphQL(sdl).build() + def er = graphQL.execute(text) + then: + er.errors.size() == 1 + er.errors[0].message.contains("parsing has been cancelled") + } + + def "a large whitespace laughs attack will be prevented by default"() { + def spaces = " " * 300_000 + def text = "query { f $spaces }" + when: + Parser.parse(text) + + then: + def e = thrown(ParseCancelledException) + e.getMessage().contains("parsing has been cancelled") + + when: "integration test to prove it cancels by default" + + def sdl = """type Query { f : ID} """ + def graphQL = TestUtil.graphQL(sdl).build() + def er = graphQL.execute(text) + then: + er.errors.size() == 1 + er.errors[0].message.contains("parsing has been cancelled") + } + + def "they can shoot themselves if they want to with large documents"() { + def lol = "@lol" * 10000 // two tokens = 20000+ tokens + def text = "query { f $lol }" + + def options = ParserOptions.newParserOptions().maxTokens(30000).build() + + when: + def doc = new Parser().parseDocument(text, options) + + then: + doc != null + } + + def "they can shoot themselves if they want to with large documents with lots of whitespace"() { + def spaces = " " * 300_000 + def text = "query { f $spaces }" + + def options = ParserOptions.newParserOptions().maxWhitespaceTokens(Integer.MAX_VALUE).build() + when: + def doc = new Parser().parseDocument(text, options) + + then: + doc != null + } + + def "they can set their own listener into action"() { + def queryText = "query { f(arg : 1) }" + + def count = 0 + def tokens = [] + ParsingListener listener = { count++; tokens.add(it.getText()) } + def parserOptions = ParserOptions.newParserOptions().parsingListener(listener).build() + + when: + def doc = new Parser().parseDocument(queryText, parserOptions) + + then: + doc != null + count == 9 + tokens == ["query", "{", "f", "(", "arg", ":", "1", ")", "}"] + + when: "integration test to prove it be supplied via EI" + + def sdl = """type Query { f(arg : Int) : ID} """ + def graphQL = TestUtil.graphQL(sdl).build() + + + def context = [:] + context.put(ParserOptions.class, parserOptions) + def executionInput = ExecutionInput.newExecutionInput() + .query(queryText) + .graphQLContext(context).build() + + count = 0 + tokens = [] + def er = graphQL.execute(executionInput) + then: + er.errors.size() == 0 + count == 9 + tokens == ["query", "{", "f", "(", "arg", ":", "1", ")", "}"] + + } + + def "deep query stack overflows are prevented by limiting the depth of rules"() { + String text = mkDeepQuery(10000) + + when: + new Parser().parseDocument(text, defaultOperationOptions) + + then: + thrown(ParseCancelledTooDeepException) + } + + def "wide queries are prevented by max token counts"() { + String text = mkWideQuery(10000) + + when: + + new Parser().parseDocument(text, defaultOperationOptions) + + then: + thrown(ParseCancelledException) // too many tokens will catch this wide queries + } + + def "large single token attack parse can be prevented"() { + String text = "q" * 10_000_000 + text = "query " + text + " {f}" + + when: + new Parser().parseDocument(text, defaultOperationOptions) + + then: + thrown(ParseCancelledTooManyCharsException) + } + + def "inside limits single token attack parse will be accepted"() { + String text = "q" * 900_000 + text = "query " + text + " {f}" + + when: + def document = new Parser().parseDocument(text, defaultOperationOptions) + + then: + document != null // its parsed - its invalid of course but parsed + } + + String mkDeepQuery(int howMany) { + def field = 'f(a:"")' + StringBuilder sb = new StringBuilder("query q{") + for (int i = 0; i < howMany; i++) { + sb.append(field) + if (i < howMany - 1) { + sb.append("{") + } + } + for (int i = 0; i < howMany - 1; i++) { + sb.append("}") + } + sb.append("}") + return sb.toString() + } + + String mkWideQuery(int howMany) { + StringBuilder sb = new StringBuilder("query q{f(") + for (int i = 0; i < howMany; i++) { + sb.append('a:1,') + } + sb.append(")}") + return sb.toString() + } +} diff --git a/src/test/groovy/graphql/parser/ParserTest.groovy b/src/test/groovy/graphql/parser/ParserTest.groovy index 6a76ace1b4..c80bf8a988 100644 --- a/src/test/groovy/graphql/parser/ParserTest.groovy +++ b/src/test/groovy/graphql/parser/ParserTest.groovy @@ -1147,104 +1147,5 @@ triple3 : """edge cases \\""" "" " \\"" \\" edge cases""" document.getDefinitions()[0].getSourceLocation() == SourceLocation.EMPTY } - def "a billion laughs attack will be prevented by default"() { - def lol = "@lol" * 10000 // two tokens = 20000+ tokens - def text = "query { f $lol }" - when: - Parser.parse(text) - - then: - def e = thrown(ParseCancelledException) - e.getMessage().contains("parsing has been cancelled") - - when: "integration test to prove it cancels by default" - - def sdl = """type Query { f : ID} """ - def graphQL = TestUtil.graphQL(sdl).build() - def er = graphQL.execute(text) - then: - er.errors.size() == 1 - er.errors[0].message.contains("parsing has been cancelled") - } - - def "a large whitespace laughs attack will be prevented by default"() { - def spaces = " " * 300_000 - def text = "query { f $spaces }" - when: - Parser.parse(text) - - then: - def e = thrown(ParseCancelledException) - e.getMessage().contains("parsing has been cancelled") - - when: "integration test to prove it cancels by default" - - def sdl = """type Query { f : ID} """ - def graphQL = TestUtil.graphQL(sdl).build() - def er = graphQL.execute(text) - then: - er.errors.size() == 1 - er.errors[0].message.contains("parsing has been cancelled") - } - def "they can shoot themselves if they want to with large documents"() { - def lol = "@lol" * 10000 // two tokens = 20000+ tokens - def text = "query { f $lol }" - - def options = ParserOptions.newParserOptions().maxTokens(30000).build() - when: - def doc = new Parser().parseDocument(text, options) - - then: - doc != null - } - - def "they can shoot themselves if they want to with large documents with lots of whitespace"() { - def spaces = " " * 300_000 - def text = "query { f $spaces }" - - def options = ParserOptions.newParserOptions().maxWhitespaceTokens(Integer.MAX_VALUE).build() - when: - def doc = new Parser().parseDocument(text, options) - - then: - doc != null - } - - def "they can set their own listener into action"() { - def queryText = "query { f(arg : 1) }" - - def count = 0 - def tokens = [] - ParsingListener listener = { count++; tokens.add(it.getText()) } - def parserOptions = ParserOptions.newParserOptions().parsingListener(listener).build() - when: - def doc = new Parser().parseDocument(queryText, parserOptions) - - then: - doc != null - count == 9 - tokens == ["query", "{", "f", "(", "arg", ":", "1", ")", "}"] - - when: "integration test to prove it be supplied via EI" - - def sdl = """type Query { f(arg : Int) : ID} """ - def graphQL = TestUtil.graphQL(sdl).build() - - - def context = [:] - context.put(ParserOptions.class, parserOptions) - def executionInput = ExecutionInput.newExecutionInput() - .query(queryText) - .graphQLContext(context).build() - - count = 0 - tokens = [] - def er = graphQL.execute(executionInput) - then: - er.errors.size() == 0 - count == 9 - tokens == ["query", "{", "f", "(", "arg", ":", "1", ")", "}"] - - } } diff --git a/src/test/groovy/graphql/parser/SafeTokenReaderTest.groovy b/src/test/groovy/graphql/parser/SafeTokenReaderTest.groovy new file mode 100644 index 0000000000..e96fe93b9c --- /dev/null +++ b/src/test/groovy/graphql/parser/SafeTokenReaderTest.groovy @@ -0,0 +1,18 @@ +package graphql.parser + +import spock.lang.Specification + +class SafeTokenReaderTest extends Specification { + + def "will count how many its read and stop after max"() { + when: + StringReader sr = new StringReader("0123456789") + SafeTokenReader safeReader = new SafeTokenReader(sr, 5, + { Integer maxChars -> throw new RuntimeException("max " + maxChars) }) + safeReader.readLine() + + then: + def rte = thrown(RuntimeException) + rte.message == "max 5" + } +} diff --git a/src/test/groovy/graphql/schema/SchemaTransformerTest.groovy b/src/test/groovy/graphql/schema/SchemaTransformerTest.groovy index 4d41f672b2..c315553f1b 100644 --- a/src/test/groovy/graphql/schema/SchemaTransformerTest.groovy +++ b/src/test/groovy/graphql/schema/SchemaTransformerTest.groovy @@ -841,4 +841,79 @@ type Query { } ''' } + + def "can rename scalars"() { + + def schema = TestUtil.schema(""" + scalar Foo + type Query { + field : Foo + } +""") + + def visitor = new GraphQLTypeVisitorStub() { + + @Override + TraversalControl visitGraphQLScalarType(GraphQLScalarType node, TraverserContext context) { + if (node.getName().equals("Foo")) { + GraphQLScalarType newNode = node.transform({sc -> sc.name("Bar")}) + return changeNode(context, newNode) + } + return super.visitGraphQLScalarType(node, context) + } + } + + when: + def newSchema = SchemaTransformer.transformSchema(schema, visitor) + then: + newSchema.getType("Bar") instanceof GraphQLScalarType + newSchema.getType("Foo") == null + (newSchema.getObjectType("Query").getFieldDefinition("field").getType() as GraphQLScalarType).getName() == "Bar" + } + + def "rename scalars are changed in applied arguments"() { + + def schema = TestUtil.schema(""" + scalar Foo + directive @myDirective(fooArgOnDirective: Foo) on FIELD_DEFINITION + type Query { + foo(fooArgOnField: Foo) : Foo @myDirective + } +""") + + def visitor = new GraphQLTypeVisitorStub() { + + @Override + TraversalControl visitGraphQLScalarType(GraphQLScalarType node, TraverserContext context) { + if (node.getName().equals("Foo")) { + GraphQLScalarType newNode = node.transform({sc -> sc.name("Bar")}) + return changeNode(context, newNode) + } + return super.visitGraphQLScalarType(node, context) + } + } + + when: + def newSchema = SchemaTransformer.transformSchema(schema, visitor) + then: + + def fieldDef = newSchema.getObjectType("Query").getFieldDefinition("foo") + def appliedDirective = fieldDef.getAppliedDirective("myDirective") + def oldSkoolDirective = fieldDef.getDirective("myDirective") + def argument = fieldDef.getArgument("fooArgOnField") + def directiveDecl = newSchema.getDirective("myDirective") + def directiveArgument = directiveDecl.getArgument("fooArgOnDirective") + + (fieldDef.getType() as GraphQLScalarType).getName() == "Bar" + (argument.getType() as GraphQLScalarType).getName() == "Bar" + (directiveArgument.getType() as GraphQLScalarType).getName() == "Bar" + + (oldSkoolDirective.getArgument("fooArgOnDirective").getType() as GraphQLScalarType).getName() == "Bar" + + newSchema.getType("Bar") instanceof GraphQLScalarType + + // not working at this stage + (appliedDirective.getArgument("fooArgOnDirective").getType() as GraphQLScalarType).getName() == "Bar" + newSchema.getType("Foo") == null + } } diff --git a/src/test/groovy/graphql/schema/diff/SchemaDiffTest.groovy b/src/test/groovy/graphql/schema/diff/SchemaDiffTest.groovy index ca957db197..07d1350258 100644 --- a/src/test/groovy/graphql/schema/diff/SchemaDiffTest.groovy +++ b/src/test/groovy/graphql/schema/diff/SchemaDiffTest.groovy @@ -570,4 +570,25 @@ class SchemaDiffTest extends Specification { } + def "SchemaDiff and CapturingReporter have the same diff counts"() { + def schema1 = TestUtil.schema("type Query { f : String }") + def schema2 = TestUtil.schema("type Query { f : Int }") + + when: + def capturingReporter = new CapturingReporter() + def schemaDiff = new SchemaDiff() + def breakingCount = schemaDiff.diffSchema(DiffSet.diffSet(schema1, schema1), capturingReporter) + then: + breakingCount == capturingReporter.getBreakageCount() + breakingCount == 0 + + when: + capturingReporter = new CapturingReporter() + schemaDiff = new SchemaDiff() + breakingCount = schemaDiff.diffSchema(DiffSet.diffSet(schema1, schema2), capturingReporter) + + then: + breakingCount == capturingReporter.getBreakageCount() + breakingCount == 1 + } } diff --git a/src/test/groovy/graphql/schema/idl/SchemaGeneratorTest.groovy b/src/test/groovy/graphql/schema/idl/SchemaGeneratorTest.groovy index 4d9f5f7029..931be4467d 100644 --- a/src/test/groovy/graphql/schema/idl/SchemaGeneratorTest.groovy +++ b/src/test/groovy/graphql/schema/idl/SchemaGeneratorTest.groovy @@ -2533,4 +2533,35 @@ class SchemaGeneratorTest extends Specification { then: noExceptionThrown() } + + def "skip and include should be added to the schema only if not already defined"() { + def sdl = ''' + "Directs the executor to skip this field or fragment when the `if`'argument is true." + directive @skip( + "Skipped when true." + if: Boolean! + ) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT + + "Directs the executor to include this field or fragment only when the `if` argument is true" + directive @include( + "Included when true." + if: Boolean! + ) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT + + type Query { + hello: String + } + ''' + when: + def schema = TestUtil.schema(sdl) + then: + schema.getDirectives().findAll { it.name == "skip" }.size() == 1 + schema.getDirectives().findAll { it.name == "include" }.size() == 1 + + and: + def newSchema = GraphQLSchema.newSchema(schema).build() + then: + newSchema.getDirectives().findAll { it.name == "skip" }.size() == 1 + newSchema.getDirectives().findAll { it.name == "include" }.size() == 1 + } } diff --git a/src/test/groovy/graphql/schema/idl/SchemaPrinterTest.groovy b/src/test/groovy/graphql/schema/idl/SchemaPrinterTest.groovy index 5f7f6ce409..cc180c38a4 100644 --- a/src/test/groovy/graphql/schema/idl/SchemaPrinterTest.groovy +++ b/src/test/groovy/graphql/schema/idl/SchemaPrinterTest.groovy @@ -2012,6 +2012,28 @@ type Query { ''' } + def "directive containing formatting specifiers"() { + def constraintDirective = GraphQLDirective.newDirective().name("constraint") + .argument({ + it.name("regex").type(GraphQLString).valueProgrammatic("%") + }) + .build() + GraphQLInputObjectType type = GraphQLInputObjectType.newInputObject().name("Person") + .field({ it.name("thisMustBeAPercentageSign").type(GraphQLString).withDirective(constraintDirective) }) + .build() + + when: + + def result = new SchemaPrinter().print(type) + + + then: + result == '''input Person { + thisMustBeAPercentageSign: String @constraint(regex : "%") +} +''' + } + def "can specify a new ordering for the schema printer"() { def sdl = """ @@ -2171,6 +2193,24 @@ type Query { type TestObjectB { field: String } +''' + } + + def "issue 3285 - deprecated defaultValue on programmatic args prints as expected"() { + def queryObjType = newObject().name("Query") + .field(newFieldDefinition().name("f").type(GraphQLString) + .argument(newArgument().name("arg").type(GraphQLString).defaultValue(null))) + .build() + def schema = GraphQLSchema.newSchema().query(queryObjType).build() + + + when: + def options = defaultOptions().includeDirectiveDefinitions(false) + def sdl = new SchemaPrinter(options).print(schema) + then: + sdl == '''type Query { + f(arg: String = null): String +} ''' } } \ No newline at end of file diff --git a/src/test/groovy/graphql/schema/transform/FieldVisibilitySchemaTransformationTest.groovy b/src/test/groovy/graphql/schema/transform/FieldVisibilitySchemaTransformationTest.groovy index c6f25fd367..a6f82018a2 100644 --- a/src/test/groovy/graphql/schema/transform/FieldVisibilitySchemaTransformationTest.groovy +++ b/src/test/groovy/graphql/schema/transform/FieldVisibilitySchemaTransformationTest.groovy @@ -1178,4 +1178,67 @@ class FieldVisibilitySchemaTransformationTest extends Specification { (restrictedSchema.getType("Account") as GraphQLObjectType).getFieldDefinition("billingStatus") == null restrictedSchema.getType("BillingStatus") != null } + + def "can remove a field with a directive containing enum argument"() { + given: + GraphQLSchema schema = TestUtil.schema(""" + + directive @private(privateType: SecretType) on FIELD_DEFINITION + enum SecretType { + SUPER_SECRET + NOT_SO_SECRET + } + + type Query { + account: Account + } + + type Account { + name: String + billingStatus: BillingStatus @private(privateType: NOT_SO_SECRET) + } + + type BillingStatus { + accountNumber: String + } + """) + + when: + GraphQLSchema restrictedSchema = visibilitySchemaTransformation.apply(schema) + + then: + (restrictedSchema.getType("Account") as GraphQLObjectType).getFieldDefinition("billingStatus") == null + restrictedSchema.getType("BillingStatus") == null + } + + def "can remove a field with a directive containing type argument"() { + given: + GraphQLSchema schema = TestUtil.schema(""" + + directive @private(privateType: SecretType) on FIELD_DEFINITION + input SecretType { + description: String + } + + type Query { + account: Account + } + + type Account { + name: String + billingStatus: BillingStatus @private(privateType: { description: "secret" }) + } + + type BillingStatus { + accountNumber: String + } + """) + + when: + GraphQLSchema restrictedSchema = visibilitySchemaTransformation.apply(schema) + + then: + (restrictedSchema.getType("Account") as GraphQLObjectType).getFieldDefinition("billingStatus") == null + restrictedSchema.getType("BillingStatus") == null + } } diff --git a/src/test/groovy/graphql/validation/rules/OverlappingFieldsCanBeMergedTest.groovy b/src/test/groovy/graphql/validation/rules/OverlappingFieldsCanBeMergedTest.groovy index deedfbed77..bcb5884260 100644 --- a/src/test/groovy/graphql/validation/rules/OverlappingFieldsCanBeMergedTest.groovy +++ b/src/test/groovy/graphql/validation/rules/OverlappingFieldsCanBeMergedTest.groovy @@ -339,6 +339,7 @@ class OverlappingFieldsCanBeMergedTest extends Specification { def schema = schema(''' type Dog { nickname: String + name : String } type Query { dog: Dog } ''') @@ -351,6 +352,58 @@ class OverlappingFieldsCanBeMergedTest extends Specification { errorCollector.getErrors()[0].locations == [new SourceLocation(3, 13), new SourceLocation(4, 13)] } + def 'issue 3332 - Alias masking direct field access non fragment'() { + given: + def query = """ + { dog { + name: nickname + name + }} + """ + def schema = schema(''' + type Dog { + name : String + nickname: String + } + type Query { dog: Dog } + ''') + when: + traverse(query, schema) + + then: + errorCollector.getErrors().size() == 1 + errorCollector.getErrors()[0].message == "Validation error (FieldsConflict@[dog]) : 'name' : 'nickname' and 'name' are different fields" + errorCollector.getErrors()[0].locations == [new SourceLocation(3, 13), new SourceLocation(4, 13)] + } + + def 'issue 3332 -Alias masking direct field access non fragment with non null parent type'() { + given: + def query = """ + query GetCat { + cat { + foo1 + foo1: foo2 + } + } + """ + def schema = schema(''' + type Query { + cat: Cat! # non null parent type + } + type Cat { + foo1: String! + foo2: String! + } + ''') + when: + traverse(query, schema) + + then: + errorCollector.getErrors().size() == 1 + errorCollector.getErrors()[0].message == "Validation error (FieldsConflict@[cat]) : 'foo1' : 'foo1' and 'foo2' are different fields" + errorCollector.getErrors()[0].locations == [new SourceLocation(4, 17), new SourceLocation(5, 17)] + } + def 'conflicting args'() { given: def query = """ diff --git a/src/test/groovy/graphql/validation/rules/VariableTypesMatchTest.groovy b/src/test/groovy/graphql/validation/rules/VariableTypesMatchTest.groovy index dab3510daa..5ccaff13dd 100644 --- a/src/test/groovy/graphql/validation/rules/VariableTypesMatchTest.groovy +++ b/src/test/groovy/graphql/validation/rules/VariableTypesMatchTest.groovy @@ -2,8 +2,10 @@ package graphql.validation.rules import graphql.StarWarsSchema +import graphql.TestUtil import graphql.i18n.I18n import graphql.parser.Parser +import graphql.schema.GraphQLSchema import graphql.validation.LanguageTraversal import graphql.validation.RulesVisitor import graphql.validation.ValidationContext @@ -14,10 +16,10 @@ import spock.lang.Specification class VariableTypesMatchTest extends Specification { ValidationErrorCollector errorCollector = new ValidationErrorCollector() - def traverse(String query) { + def traverse(String query, GraphQLSchema schema = StarWarsSchema.starWarsSchema) { def document = Parser.parse(query) I18n i18n = I18n.i18n(I18n.BundleType.Validation, Locale.ENGLISH) - def validationContext = new ValidationContext(StarWarsSchema.starWarsSchema, document, i18n) + def validationContext = new ValidationContext(schema, document, i18n) def variableTypesMatchRule = new VariableTypesMatch(validationContext, errorCollector) def languageTraversal = new LanguageTraversal() languageTraversal.traverse(document, new RulesVisitor(validationContext, [variableTypesMatchRule])) @@ -56,7 +58,7 @@ class VariableTypesMatchTest extends Specification { then: errorCollector.containsValidationError(ValidationErrorType.VariableTypeMismatch) // #991: describe which types were mismatched in error message - errorCollector.errors[0].message == "Validation error (VariableTypeMismatch@[human]) : Variable type 'String' does not match expected type 'String!'" + errorCollector.errors[0].message == "Validation error (VariableTypeMismatch@[human]) : Variable 'id' of type 'String' used in position expecting type 'String!'" } def "invalid variables in fragment spread"() { @@ -78,7 +80,7 @@ class VariableTypesMatchTest extends Specification { then: errorCollector.containsValidationError(ValidationErrorType.VariableTypeMismatch) - errorCollector.errors[0].message == "Validation error (VariableTypeMismatch@[QueryType/human]) : Variable type 'String' does not match expected type 'String!'" + errorCollector.errors[0].message == "Validation error (VariableTypeMismatch@[QueryType/human]) : Variable 'xid' of type 'String' used in position expecting type 'String!'" } def "mixed validity operations, valid first"() { @@ -104,7 +106,7 @@ class VariableTypesMatchTest extends Specification { then: errorCollector.containsValidationError(ValidationErrorType.VariableTypeMismatch) - errorCollector.errors[0].message == "Validation error (VariableTypeMismatch@[QueryType/human]) : Variable type 'String' does not match expected type 'String!'" + errorCollector.errors[0].message == "Validation error (VariableTypeMismatch@[QueryType/human]) : Variable 'id' of type 'String' used in position expecting type 'String!'" } def "mixed validity operations, invalid first"() { @@ -130,7 +132,7 @@ class VariableTypesMatchTest extends Specification { then: errorCollector.containsValidationError(ValidationErrorType.VariableTypeMismatch) - errorCollector.errors[0].message == "Validation error (VariableTypeMismatch@[QueryType/human]) : Variable type 'String' does not match expected type 'String!'" + errorCollector.errors[0].message == "Validation error (VariableTypeMismatch@[QueryType/human]) : Variable 'id' of type 'String' used in position expecting type 'String!'" } def "multiple invalid operations"() { @@ -158,11 +160,97 @@ class VariableTypesMatchTest extends Specification { errorCollector.getErrors().size() == 2 errorCollector.errors.any { it.validationErrorType == ValidationErrorType.VariableTypeMismatch && - it.message == "Validation error (VariableTypeMismatch@[QueryType/human]) : Variable type 'String' does not match expected type 'String!'" + it.message == "Validation error (VariableTypeMismatch@[QueryType/human]) : Variable 'id' of type 'String' used in position expecting type 'String!'" } errorCollector.errors.any { it.validationErrorType == ValidationErrorType.VariableTypeMismatch && - it.message == "Validation error (VariableTypeMismatch@[QueryType/human]) : Variable type 'Boolean' does not match expected type 'String!'" + it.message == "Validation error (VariableTypeMismatch@[QueryType/human]) : Variable 'id' of type 'Boolean' used in position expecting type 'String!'" } } + + + def "issue 3276 - invalid variables in object field values with no defaults in location"() { + + def sdl = ''' + type Query { + items(pagination: Pagination = {limit: 1, offset: 1}): [String] + } + input Pagination { + limit: Int! + offset: Int! + } + ''' + def schema = TestUtil.schema(sdl) + given: + def query = ''' + query Items( $limit: Int, $offset: Int) { + items( + pagination: {limit: $limit, offset: $offset} + ) + } + ''' + + when: + traverse(query, schema) + + then: + errorCollector.containsValidationError(ValidationErrorType.VariableTypeMismatch) + errorCollector.errors[0].message == "Validation error (VariableTypeMismatch@[items]) : Variable 'limit' of type 'Int' used in position expecting type 'Int!'" + } + + def "issue 3276 - valid variables because of schema defaults with nullable variable"() { + + def sdl = ''' + type Query { + items(pagination: Pagination! = {limit: 1, offset: 1}): [String] + } + input Pagination { + limit: Int! + offset: Int! + } + ''' + def schema = TestUtil.schema(sdl) + given: + def query = ''' + query Items( $var : Pagination) { + items( + pagination: $var + ) + } + ''' + + when: + traverse(query, schema) + + then: + errorCollector.errors.isEmpty() + } + + def "issue 3276 - valid variables because of variable defaults"() { + + def sdl = ''' + type Query { + items(pagination: Pagination!): [String] + } + input Pagination { + limit: Int! + offset: Int! + } + ''' + def schema = TestUtil.schema(sdl) + given: + def query = ''' + query Items( $var : Pagination = {limit: 1, offset: 1}) { + items( + pagination: $var + ) + } + ''' + + when: + traverse(query, schema) + + then: + errorCollector.errors.isEmpty() + } } diff --git a/src/test/groovy/graphql/validation/rules/VariablesTypesMatcherTest.groovy b/src/test/groovy/graphql/validation/rules/VariablesTypesMatcherTest.groovy index c6bead4ef6..b05e425090 100644 --- a/src/test/groovy/graphql/validation/rules/VariablesTypesMatcherTest.groovy +++ b/src/test/groovy/graphql/validation/rules/VariablesTypesMatcherTest.groovy @@ -1,6 +1,7 @@ package graphql.validation.rules import graphql.language.BooleanValue +import graphql.language.StringValue import spock.lang.Specification import spock.lang.Unroll @@ -18,7 +19,7 @@ class VariablesTypesMatcherTest extends Specification { def "#variableType with default value #defaultValue and expected #expectedType should result: #result "() { expect: - typesMatcher.doesVariableTypesMatch(variableType, defaultValue, expectedType) == result + typesMatcher.doesVariableTypesMatch(variableType, defaultValue, expectedType, null) == result where: variableType | defaultValue | expectedType || result @@ -27,4 +28,18 @@ class VariablesTypesMatcherTest extends Specification { nonNull(GraphQLBoolean) | new BooleanValue(true) | GraphQLBoolean || true nonNull(GraphQLString) | null | list(GraphQLString) || false } + + @Unroll + def "issue 3276 - #variableType with default value #defaultValue and expected #expectedType with #locationDefaultValue should result: #result "() { + + expect: + typesMatcher.doesVariableTypesMatch(variableType, defaultValue, expectedType, locationDefaultValue) == result + + where: + variableType | defaultValue | expectedType | locationDefaultValue || result + GraphQLString | null | nonNull(GraphQLString) | null || false + GraphQLString | null | nonNull(GraphQLString) | StringValue.of("x") || true + GraphQLString | StringValue.of("x") | nonNull(GraphQLString) | StringValue.of("x") || true + GraphQLString | StringValue.of("x") | nonNull(GraphQLString) | null || true + } }