diff --git a/ezyhttp-server-graphql/src/main/java/com/tvd12/ezyhttp/server/graphql/controller/GraphQLController.java b/ezyhttp-server-graphql/src/main/java/com/tvd12/ezyhttp/server/graphql/controller/GraphQLController.java index 5c560ea1..b1ac3efb 100644 --- a/ezyhttp-server-graphql/src/main/java/com/tvd12/ezyhttp/server/graphql/controller/GraphQLController.java +++ b/ezyhttp-server-graphql/src/main/java/com/tvd12/ezyhttp/server/graphql/controller/GraphQLController.java @@ -62,9 +62,11 @@ public GraphQLController(Builder builder) { * curl --location -g --request GET 'http://localhost:8083/graphql?query={me{myVariable: $someValue}{id+name+friends{name}}}&variables={"id" : 1}' * * - * @param arguments the request arguments - * @param query GraphQL query - * @param variables a JSON-encoded string like { "myVariable": "someValue", ... } + * @param arguments the request arguments + * @param query GraphQL query + * @param operationName optional operation name for named operations + * @param variables a JSON-encoded string like + * { "myVariable": "someValue", ... } * @return the result */ @SuppressWarnings("unchecked") @@ -72,6 +74,7 @@ public GraphQLController(Builder builder) { public Object doGet( RequestArguments arguments, @RequestParam("query") String query, + @RequestParam("operationName") String operationName, @RequestParam("variables") String variables ) { Map variableMap = Collections.emptyMap(); @@ -92,7 +95,7 @@ public Object doGet( ); } } - return fetch(arguments, query, variableMap); + return fetch(arguments, query, operationName, variableMap); } /** @@ -119,6 +122,7 @@ public Object doPost( return fetch( arguments, request.getQuery(), + request.getOperationName(), request.getVariables() ); } @@ -127,9 +131,11 @@ public Object doPost( private Object fetch( RequestArguments arguments, String query, + String operationName, Map variables ) { - GraphQLSchema schema = schemaParser.parseQuery(query, variables); + GraphQLSchema schema = schemaParser + .parseQuery(query, operationName, variables); List queryDefinitions = schema .getQueryDefinitions(); Map answer = new HashMap<>(); diff --git a/ezyhttp-server-graphql/src/main/java/com/tvd12/ezyhttp/server/graphql/data/GraphQLRequest.java b/ezyhttp-server-graphql/src/main/java/com/tvd12/ezyhttp/server/graphql/data/GraphQLRequest.java index c8869fa6..c7a7c526 100644 --- a/ezyhttp-server-graphql/src/main/java/com/tvd12/ezyhttp/server/graphql/data/GraphQLRequest.java +++ b/ezyhttp-server-graphql/src/main/java/com/tvd12/ezyhttp/server/graphql/data/GraphQLRequest.java @@ -10,6 +10,7 @@ @Setter public class GraphQLRequest { private String query; + private String operationName; private Map variables; public Map getVariables() { diff --git a/ezyhttp-server-graphql/src/main/java/com/tvd12/ezyhttp/server/graphql/scheme/GraphQLSchemaParser.java b/ezyhttp-server-graphql/src/main/java/com/tvd12/ezyhttp/server/graphql/scheme/GraphQLSchemaParser.java index 6f3c62d1..7e83710d 100644 --- a/ezyhttp-server-graphql/src/main/java/com/tvd12/ezyhttp/server/graphql/scheme/GraphQLSchemaParser.java +++ b/ezyhttp-server-graphql/src/main/java/com/tvd12/ezyhttp/server/graphql/scheme/GraphQLSchemaParser.java @@ -14,6 +14,7 @@ import java.util.Map; import static com.tvd12.ezyfox.io.EzyStrings.EMPTY_STRING; +import static com.tvd12.ezyfox.io.EzyStrings.isNotBlank; @AllArgsConstructor public final class GraphQLSchemaParser { @@ -23,6 +24,28 @@ public final class GraphQLSchemaParser { private static final String VARIABLE_PLACEHOLDER_FIELD = "__ezyhttp_graphql_variable__"; + public GraphQLSchema parseQuery( + String queryToParse, + String operationName, + Map variables + ) { + if (!isNotBlank(operationName)) { + return parseQuery(queryToParse, variables); + } + String standardized = standardizeKeepOperationNames(queryToParse); + String selectionSet = extractNamedOperation(standardized, operationName); + if (selectionSet == null) { + throw new GraphQLObjectMapperException( + Collections.singletonList( + GraphQLError.builder() + .message("unknown operation named: " + operationName) + .build() + ) + ); + } + return parseQuery(selectionSet, variables); + } + @SuppressWarnings({"unchecked", "MethodLength"}) public GraphQLSchema parseQuery( String queryToParse, @@ -77,8 +100,6 @@ public GraphQLSchema parseQuery( ); replaceVariablePlaceholders(argumentMap, variables); childBuilder.arguments(argumentMap); - } catch (GraphQLObjectMapperException e) { - throw e; } catch (Exception e) { throw new GraphQLObjectMapperException( Collections.singletonList( @@ -176,6 +197,42 @@ public GraphQLSchema parseQuery( return schemaBuilder.build(); } + private String extractNamedOperation( + String standardizedQuery, + String operationName + ) { + int nameIdx = standardizedQuery.indexOf(operationName); + while (nameIdx >= 0) { + boolean validPrefix = nameIdx == 0 + || !isGraphQLNameChar(standardizedQuery.charAt(nameIdx - 1)); + int afterName = nameIdx + operationName.length(); + boolean validSuffix = afterName >= standardizedQuery.length() + || !isGraphQLNameChar(standardizedQuery.charAt(afterName)); + if (validPrefix && validSuffix) { + int braceStart = afterName; + while (braceStart < standardizedQuery.length() + && standardizedQuery.charAt(braceStart) != '{') { + braceStart++; + } + if (braceStart < standardizedQuery.length()) { + int depth = 0; + for (int i = braceStart; i < standardizedQuery.length(); i++) { + char c = standardizedQuery.charAt(i); + if (c == '{') { + depth++; + } else if (c == '}') { + if (--depth == 0) { + return standardizedQuery.substring(braceStart, i + 1); + } + } + } + } + } + nameIdx = standardizedQuery.indexOf(operationName, nameIdx + 1); + } + return null; + } + /** * Remove redundant '\t', '\n', '+', ',', ' ' in query. * @@ -186,14 +243,22 @@ private String standardize(String query) { if (query == null) { return EMPTY_STRING; } - String trimedQuery = query.trim(); - StringBuilder forwardStandard = forwardStandardize(trimedQuery); + String trimmedQuery = query.trim(); + StringBuilder forwardStandard = forwardStandardize(trimmedQuery); StringBuilder backwardStandard = backwardStandardize( forwardStandard.toString() ); return removeQueryPrefix(backwardStandard.toString()); } + private String standardizeKeepOperationNames(String query) { + if (query == null) { + return EMPTY_STRING; + } + StringBuilder forwardStandard = forwardStandardize(query.trim()); + return backwardStandardize(forwardStandard.toString()).toString(); + } + private StringBuilder forwardStandardize(String query) { int queryLength = query.length(); StringBuilder answer = new StringBuilder(); diff --git a/ezyhttp-server-graphql/src/test/java/com/tvd12/ezyhttp/server/graphql/test/controller/GraphQLControllerTest.java b/ezyhttp-server-graphql/src/test/java/com/tvd12/ezyhttp/server/graphql/test/controller/GraphQLControllerTest.java index 3451e9c1..628734ef 100644 --- a/ezyhttp-server-graphql/src/test/java/com/tvd12/ezyhttp/server/graphql/test/controller/GraphQLControllerTest.java +++ b/ezyhttp-server-graphql/src/test/java/com/tvd12/ezyhttp/server/graphql/test/controller/GraphQLControllerTest.java @@ -76,7 +76,7 @@ public void test() throws Exception { // when Object meResult = controller.doPost(arguments, meRequest); Throwable e = Asserts.assertThrows(() -> - controller.doGet(arguments, heroQuery, null) + controller.doGet(arguments, heroQuery, null, null) ); // then @@ -234,7 +234,7 @@ public void getAllFriendFields() { // when Object meResult = controller.doPost(arguments, meRequest); Throwable e = Asserts.assertThrows(() -> - controller.doGet(arguments, heroQuery, null) + controller.doGet(arguments, heroQuery, null, null) ); // then @@ -290,7 +290,7 @@ public void testFetcherNotFoundException() { String heroQuery = "{hero}"; // when - Throwable e = Asserts.assertThrows(() -> controller.doGet(arguments, heroQuery, null)); + Throwable e = Asserts.assertThrows(() -> controller.doGet(arguments, heroQuery, null, null)); // then Asserts.assertEqualsType(e, GraphQLFetcherException.class); @@ -338,7 +338,7 @@ public void testInterceptorFalse() { String heroQuery = "{hero}"; // when - Throwable e = Asserts.assertThrows(() -> controller.doGet(arguments, heroQuery, null)); + Throwable e = Asserts.assertThrows(() -> controller.doGet(arguments, heroQuery, null, null)); // then Asserts.assertEqualsType(e, GraphQLFetcherException.class); @@ -413,7 +413,7 @@ public void testQueryWithVariables() { // when Throwable e = Asserts.assertThrows(() -> - controller.doGet(arguments, welcomeQuery, variablesString) + controller.doGet(arguments, welcomeQuery, null, variablesString) ); // then @@ -478,8 +478,8 @@ public void testQueryWithNullVariableType() { String fooQuery = "{foo{value(value:$value){*}}}"; // when - Object fooResult1 = controller.doGet(arguments, fooQuery, "{\"value\": \"Bar\"}"); - Object fooResult2 = controller.doGet(arguments, fooQuery, null); + Object fooResult1 = controller.doGet(arguments, fooQuery, null, "{\"value\": \"Bar\"}"); + Object fooResult2 = controller.doGet(arguments, fooQuery, null, null); // then Asserts.assertEquals(fooResult1.toString(), "{data={foo={value={bar=Bar}}}}"); @@ -609,7 +609,7 @@ public void doGetTestException() { String heroQuery = "{hero}"; // when - Throwable e = Asserts.assertThrows(() -> controller.doGet(arguments, heroQuery, "abc")); + Throwable e = Asserts.assertThrows(() -> controller.doGet(arguments, heroQuery, null, "abc")); // then Asserts.assertEqualsType(e, GraphQLObjectMapperException.class); diff --git a/ezyhttp-server-graphql/src/test/java/com/tvd12/ezyhttp/server/graphql/test/scheme/GraphQLSchemaParserTest.java b/ezyhttp-server-graphql/src/test/java/com/tvd12/ezyhttp/server/graphql/test/scheme/GraphQLSchemaParserTest.java index 2c3bb854..46331386 100644 --- a/ezyhttp-server-graphql/src/test/java/com/tvd12/ezyhttp/server/graphql/test/scheme/GraphQLSchemaParserTest.java +++ b/ezyhttp-server-graphql/src/test/java/com/tvd12/ezyhttp/server/graphql/test/scheme/GraphQLSchemaParserTest.java @@ -7,6 +7,7 @@ import com.tvd12.ezyhttp.server.graphql.data.GraphQLField; import com.tvd12.ezyhttp.server.graphql.exception.GraphQLObjectMapperException; import com.tvd12.ezyhttp.server.graphql.json.GraphQLObjectMapperFactory; +import com.tvd12.ezyhttp.server.graphql.query.GraphQLQueryDefinition; import com.tvd12.ezyhttp.server.graphql.scheme.GraphQLSchema; import com.tvd12.ezyhttp.server.graphql.scheme.GraphQLSchemaParser; import com.tvd12.test.assertion.Asserts; @@ -1495,4 +1496,208 @@ public void removeQueryPrefixWithUnderscoreAfterPrefixTest() throws Exception { // then Asserts.assertEquals(result, "query_name{slug}"); } + + @Test + public void parseQueryWithOperationNameTest() { + // given + GraphQLSchemaParser instance = new GraphQLSchemaParser(new ObjectMapper()); + String query = "query GetMe{me{name}} query GetHero{hero{id}}"; + + // when + GraphQLSchema schema = instance.parseQuery( + query, + "GetMe", + Collections.emptyMap() + ); + + // then + List definitions = schema.getQueryDefinitions(); + Asserts.assertEquals(definitions.size(), 1); + Asserts.assertEquals(definitions.get(0).getName(), "me"); + } + + @Test + public void parseQueryWithOperationNameSelectSecondTest() { + // given + GraphQLSchemaParser instance = new GraphQLSchemaParser(new ObjectMapper()); + String query = "query GetMe{me{name}} query GetHero{hero{id}}"; + + // when + GraphQLSchema schema = instance.parseQuery( + query, + "GetHero", + Collections.emptyMap() + ); + + // then + List definitions = schema.getQueryDefinitions(); + Asserts.assertEquals(definitions.size(), 1); + Asserts.assertEquals(definitions.get(0).getName(), "hero"); + } + + @Test + public void parseQueryWithUnknownOperationNameTest() { + // given + GraphQLSchemaParser instance = new GraphQLSchemaParser(new ObjectMapper()); + String query = "query GetMe{me{name}}"; + + // when + Throwable e = Asserts.assertThrows(() -> + instance.parseQuery(query, "GetUnknown", Collections.emptyMap()) + ); + + // then + Asserts.assertEqualsType(e, GraphQLObjectMapperException.class); + List errors = ((GraphQLObjectMapperException) e).getErrors(); + Asserts.assertEquals(errors.size(), 1); + Asserts.assertEquals(errors.get(0).getMessage(), "unknown operation named: GetUnknown"); + } + + @Test + public void parseQueryWithNullOperationNameTest() { + // given + GraphQLSchemaParser instance = new GraphQLSchemaParser(new ObjectMapper()); + String query = "query GetMe{me{name}}"; + + // when — null operationName falls back to normal parsing + GraphQLSchema schema = instance.parseQuery( + query, + null, + Collections.emptyMap() + ); + + // then + List definitions = schema.getQueryDefinitions(); + Asserts.assertEquals(definitions.size(), 1); + Asserts.assertEquals(definitions.get(0).getName(), "me"); + } + + @Test + public void parseQueryOperationNameSuffixOfAnotherShouldNotMatchTest() { + // given — "GetMe" appears as suffix inside "SomeGetMe"; should skip it + // and match the standalone "GetMe" + GraphQLSchemaParser instance = new GraphQLSchemaParser(new ObjectMapper()); + String query = "query SomeGetMe{me{name}} query GetMe{user{id}}"; + + // when + GraphQLSchema schema = instance.parseQuery( + query, + "GetMe", + Collections.emptyMap() + ); + + // then — matched the second operation, not the one ending in "GetMe" + List definitions = schema.getQueryDefinitions(); + Asserts.assertEquals(definitions.size(), 1); + Asserts.assertEquals(definitions.get(0).getName(), "user"); + } + + @Test + public void parseQueryOperationNameAtStartOfStringTest() { + // given — no "query" keyword prefix; nameIdx == 0 + GraphQLSchemaParser instance = new GraphQLSchemaParser(new ObjectMapper()); + String query = "GetMe{me{name}}"; + + // when + GraphQLSchema schema = instance.parseQuery( + query, + "GetMe", + Collections.emptyMap() + ); + + // then + List definitions = schema.getQueryDefinitions(); + Asserts.assertEquals(definitions.size(), 1); + Asserts.assertEquals(definitions.get(0).getName(), "me"); + } + + @Test + public void parseQueryOperationNamePrefixOfAnotherShouldNotMatchTest() { + // given — "GetMe" is a prefix of "GetMeFull"; validSuffix check must reject it + // and match the standalone "GetMe" that comes after + GraphQLSchemaParser instance = new GraphQLSchemaParser(new ObjectMapper()); + String query = "query GetMeFull{me{name}} query GetMe{user{id}}"; + + // when + GraphQLSchema schema = instance.parseQuery( + query, + "GetMe", + Collections.emptyMap() + ); + + // then — matched the standalone "GetMe", not "GetMeFull" + List definitions = schema.getQueryDefinitions(); + Asserts.assertEquals(definitions.size(), 1); + Asserts.assertEquals(definitions.get(0).getName(), "user"); + } + + @Test + public void parseQueryOperationNameWithCharsBetweenNameAndBraceTest() { + // given — "(id: 1)" sits between operation name and '{'; braceStart++ runs + GraphQLSchemaParser instance = new GraphQLSchemaParser(new ObjectMapper()); + String query = "query GetMe(id: 1){me{name}}"; + + // when + GraphQLSchema schema = instance.parseQuery( + query, + "GetMe", + Collections.emptyMap() + ); + + // then + List definitions = schema.getQueryDefinitions(); + Asserts.assertEquals(definitions.size(), 1); + Asserts.assertEquals(definitions.get(0).getName(), "me"); + } + + @Test + public void parseQueryOperationNameWithUnclosedBracesTest() { + // given — unclosed braces: for loop exhausts without depth reaching 0 + GraphQLSchemaParser instance = new GraphQLSchemaParser(new ObjectMapper()); + String query = "query GetMe{me{name"; + + // when + Throwable e = Asserts.assertThrows(() -> + instance.parseQuery(query, "GetMe", Collections.emptyMap()) + ); + + // then + Asserts.assertEqualsType(e, GraphQLObjectMapperException.class); + List errors = ((GraphQLObjectMapperException) e).getErrors(); + Asserts.assertEquals(errors.get(0).getMessage(), "unknown operation named: GetMe"); + } + + @Test + public void parseQueryOperationNameAtEndWithoutSelectionSetTest() { + // given — "GetMe" at end of string: afterName >= length → validSuffix=true + // but braceStart loop finds no '{' → returns null → unknown operation + GraphQLSchemaParser instance = new GraphQLSchemaParser(new ObjectMapper()); + String query = "query GetOther{other{id}} query GetMe"; + + // when + Throwable e = Asserts.assertThrows(() -> + instance.parseQuery(query, "GetMe", Collections.emptyMap()) + ); + + // then + Asserts.assertEqualsType(e, GraphQLObjectMapperException.class); + List errors = ((GraphQLObjectMapperException) e).getErrors(); + Asserts.assertEquals(errors.get(0).getMessage(), "unknown operation named: GetMe"); + } + + @Test + public void parseQueryWithOperationNameAndNullQueryTest() { + // given — null query with operationName; standardizeKeepOperationNames returns "" + GraphQLSchemaParser instance = new GraphQLSchemaParser(new ObjectMapper()); + + // when + Throwable e = Asserts.assertThrows(() -> + instance.parseQuery(null, "GetMe", Collections.emptyMap()) + ); + + // then — "GetMe" not found in empty string + Asserts.assertEqualsType(e, GraphQLObjectMapperException.class); + List errors = ((GraphQLObjectMapperException) e).getErrors(); + Asserts.assertEquals(errors.get(0).getMessage(), "unknown operation named: GetMe"); + } }