diff --git a/ast/builtins.go b/ast/builtins.go index e736cfc041..3701b9775f 100644 --- a/ast/builtins.go +++ b/ast/builtins.go @@ -2516,11 +2516,11 @@ var HTTPSend = &Builtin{ // GraphQLParse returns a pair of AST objects from parsing/validation. var GraphQLParse = &Builtin{ Name: "graphql.parse", - Description: "Returns AST objects for a given GraphQL query and schema after validating the query against the schema. Returns undefined if errors were encountered during parsing or validation.", + Description: "Returns AST objects for a given GraphQL query and schema after validating the query against the schema. Returns undefined if errors were encountered during parsing or validation. The query and/or schema can be either GraphQL strings or AST objects from the other GraphQL builtin functions.", Decl: types.NewFunction( types.Args( - types.Named("query", types.S), - types.Named("schema", types.S), + types.Named("query", types.NewAny(types.S, types.NewObject(nil, types.NewDynamicProperty(types.A, types.A)))), + types.Named("schema", types.NewAny(types.S, types.NewObject(nil, types.NewDynamicProperty(types.A, types.A)))), ), types.Named("output", types.NewArray([]types.Type{ types.NewObject(nil, types.NewDynamicProperty(types.A, types.A)), @@ -2532,11 +2532,11 @@ var GraphQLParse = &Builtin{ // GraphQLParseAndVerify returns a boolean and a pair of AST object from parsing/validation. var GraphQLParseAndVerify = &Builtin{ Name: "graphql.parse_and_verify", - Description: "Returns a boolean indicating success or failure alongside the parsed ASTs for a given GraphQL query and schema after validating the query against the schema.", + Description: "Returns a boolean indicating success or failure alongside the parsed ASTs for a given GraphQL query and schema after validating the query against the schema. The query and/or schema can be either GraphQL strings or AST objects from the other GraphQL builtin functions.", Decl: types.NewFunction( types.Args( - types.Named("query", types.S), - types.Named("schema", types.S), + types.Named("query", types.NewAny(types.S, types.NewObject(nil, types.NewDynamicProperty(types.A, types.A)))), + types.Named("schema", types.NewAny(types.S, types.NewObject(nil, types.NewDynamicProperty(types.A, types.A)))), ), types.Named("output", types.NewArray([]types.Type{ types.B, @@ -2576,11 +2576,11 @@ var GraphQLParseSchema = &Builtin{ // schema, and returns false for all other inputs. var GraphQLIsValid = &Builtin{ Name: "graphql.is_valid", - Description: "Checks that a GraphQL query is valid against a given schema.", + Description: "Checks that a GraphQL query is valid against a given schema. The query and/or schema can be either GraphQL strings or AST objects from the other GraphQL builtin functions.", Decl: types.NewFunction( types.Args( - types.Named("query", types.S), - types.Named("schema", types.S), + types.Named("query", types.NewAny(types.S, types.NewObject(nil, types.NewDynamicProperty(types.A, types.A)))), + types.Named("schema", types.NewAny(types.S, types.NewObject(nil, types.NewDynamicProperty(types.A, types.A)))), ), types.Named("output", types.B).Description("`true` if the query is valid under the given schema. `false` otherwise."), ), diff --git a/builtin_metadata.json b/builtin_metadata.json index d72ff00b5a..7c799b5470 100644 --- a/builtin_metadata.json +++ b/builtin_metadata.json @@ -4125,11 +4125,11 @@ "args": [ { "name": "query", - "type": "string" + "type": "any\u003cstring, object[any: any]\u003e" }, { "name": "schema", - "type": "string" + "type": "any\u003cstring, object[any: any]\u003e" } ], "available": [ @@ -4142,7 +4142,7 @@ "v0.44.0", "edge" ], - "description": "Checks that a GraphQL query is valid against a given schema.", + "description": "Checks that a GraphQL query is valid against a given schema. The query and/or schema can be either GraphQL strings or AST objects from the other GraphQL builtin functions.", "introduced": "v0.41.0", "result": { "description": "`true` if the query is valid under the given schema. `false` otherwise.", @@ -4155,11 +4155,11 @@ "args": [ { "name": "query", - "type": "string" + "type": "any\u003cstring, object[any: any]\u003e" }, { "name": "schema", - "type": "string" + "type": "any\u003cstring, object[any: any]\u003e" } ], "available": [ @@ -4172,7 +4172,7 @@ "v0.44.0", "edge" ], - "description": "Returns AST objects for a given GraphQL query and schema after validating the query against the schema. Returns undefined if errors were encountered during parsing or validation.", + "description": "Returns AST objects for a given GraphQL query and schema after validating the query against the schema. Returns undefined if errors were encountered during parsing or validation. The query and/or schema can be either GraphQL strings or AST objects from the other GraphQL builtin functions.", "introduced": "v0.41.0", "result": { "description": "`output` is of the form `[query_ast, schema_ast]`. If the GraphQL query is valid given the provided schema, then `query_ast` and `schema_ast` are objects describing the ASTs for the query and schema.", @@ -4185,11 +4185,11 @@ "args": [ { "name": "query", - "type": "string" + "type": "any\u003cstring, object[any: any]\u003e" }, { "name": "schema", - "type": "string" + "type": "any\u003cstring, object[any: any]\u003e" } ], "available": [ @@ -4202,7 +4202,7 @@ "v0.44.0", "edge" ], - "description": "Returns a boolean indicating success or failure alongside the parsed ASTs for a given GraphQL query and schema after validating the query against the schema.", + "description": "Returns a boolean indicating success or failure alongside the parsed ASTs for a given GraphQL query and schema after validating the query against the schema. The query and/or schema can be either GraphQL strings or AST objects from the other GraphQL builtin functions.", "introduced": "v0.41.0", "result": { "description": " `output` is of the form `[valid, query_ast, schema_ast]`. If the query is valid given the provided schema, then `valid` is `true`, and `query_ast` and `schema_ast` are objects describing the ASTs for the GraphQL query and schema. Otherwise, `valid` is `false` and `query_ast` and `schema_ast` are `{}`.", diff --git a/capabilities.json b/capabilities.json index a44b951193..4ab2cd8550 100644 --- a/capabilities.json +++ b/capabilities.json @@ -1051,10 +1051,42 @@ "decl": { "args": [ { - "type": "string" + "of": [ + { + "type": "string" + }, + { + "dynamic": { + "key": { + "type": "any" + }, + "value": { + "type": "any" + } + }, + "type": "object" + } + ], + "type": "any" }, { - "type": "string" + "of": [ + { + "type": "string" + }, + { + "dynamic": { + "key": { + "type": "any" + }, + "value": { + "type": "any" + } + }, + "type": "object" + } + ], + "type": "any" } ], "result": { @@ -1068,10 +1100,42 @@ "decl": { "args": [ { - "type": "string" + "of": [ + { + "type": "string" + }, + { + "dynamic": { + "key": { + "type": "any" + }, + "value": { + "type": "any" + } + }, + "type": "object" + } + ], + "type": "any" }, { - "type": "string" + "of": [ + { + "type": "string" + }, + { + "dynamic": { + "key": { + "type": "any" + }, + "value": { + "type": "any" + } + }, + "type": "object" + } + ], + "type": "any" } ], "result": { @@ -1109,10 +1173,42 @@ "decl": { "args": [ { - "type": "string" + "of": [ + { + "type": "string" + }, + { + "dynamic": { + "key": { + "type": "any" + }, + "value": { + "type": "any" + } + }, + "type": "object" + } + ], + "type": "any" }, { - "type": "string" + "of": [ + { + "type": "string" + }, + { + "dynamic": { + "key": { + "type": "any" + }, + "value": { + "type": "any" + } + }, + "type": "object" + } + ], + "type": "any" } ], "result": { diff --git a/docs/content/graphql-api-authorization.md b/docs/content/graphql-api-authorization.md index 64bc1bf6ee..e270b6612d 100644 --- a/docs/content/graphql-api-authorization.md +++ b/docs/content/graphql-api-authorization.md @@ -41,7 +41,7 @@ schema { } type Query { - userByID(id: String): Employee + employeeByID(id: String): Employee } ``` diff --git a/test/cases/testdata/graphql/test-graphql-is-valid.yaml b/test/cases/testdata/graphql/test-graphql-is-valid.yaml index e40ed86e51..7ec2f7d1ef 100644 --- a/test/cases/testdata/graphql/test-graphql-is-valid.yaml +++ b/test/cases/testdata/graphql/test-graphql-is-valid.yaml @@ -145,3 +145,31 @@ cases: query: data.test.p = x want_result: - x: true + - data: + modules: + - | + package test + schema_ast := graphql.parse_schema(` + type Employee { + id: String! + salary: Int! + } + + schema { + query: Query + } + + type Query { + employeeByID(id: String): Employee + } + `) + query_ast := graphql.parse_query(` + query { employeeByID(id: "alice") { salary }} + `) + p { + graphql.is_valid(query_ast, schema_ast) + } + note: graphql_is_valid/success - AST objects - Employee example + query: data.test.p = x + want_result: + - x: true diff --git a/test/cases/testdata/graphql/test-graphql-parse-and-verify.yaml b/test/cases/testdata/graphql/test-graphql-parse-and-verify.yaml index 911a1175b1..b1fcba4e21 100644 --- a/test/cases/testdata/graphql/test-graphql-parse-and-verify.yaml +++ b/test/cases/testdata/graphql/test-graphql-parse-and-verify.yaml @@ -28,7 +28,7 @@ cases: } } ` - q_ast := {"Operations": [{"Name": "", "Operation": "query", "SelectionSet": [{"Alias": "entity", "Definition": {"Description": "", "Name": "entity", "Type": {"NamedType": "_Entity", "NonNull": false}}, "Name": "entity", "ObjectDefinition": {"BuiltIn": false, "Description": "", "Fields": [{"Description": "", "Name": "entity", "Type": {"NamedType": "_Entity", "NonNull": false}}, {"Description": "", "Name": "__schema", "Type": {"NamedType": "__Schema", "NonNull": true}}, {"Arguments": [{"Description": "", "Name": "name", "Type": {"NamedType": "String", "NonNull": true}}], "Description": "", "Name": "__type", "Type": {"NamedType": "__Type", "NonNull": false}}], "Kind": "OBJECT", "Name": "Query"}, "SelectionSet": [{"ObjectDefinition": {"BuiltIn": false, "Description": "", "Kind": "UNION", "Name": "_Entity", "Types": ["Product", "User"]}, "SelectionSet": [{"Alias": "id", "Definition": {"Description": "", "Name": "id", "Type": {"NamedType": "ID", "NonNull": true}}, "Name": "id", "ObjectDefinition": {"BuiltIn": false, "Description": "", "Fields": [{"Description": "", "Name": "id", "Type": {"NamedType": "ID", "NonNull": true}}], "Kind": "OBJECT", "Name": "User"}}], "TypeCondition": "User"}]}]}]} + q_ast := {"Operations": [{"Name": "", "Operation": "query", "SelectionSet": [{"Alias": "entity", "Name": "entity", "SelectionSet": [{"SelectionSet": [{"Alias": "id", "Name": "id"}], "TypeCondition": "User"}]}]}]} p { [valid, q_ast, _] = graphql.parse_and_verify(query, schema) valid @@ -95,7 +95,7 @@ cases: } } ` - q_ast := {"Operations": [{"Name": "SomeOperation", "Operation": "query", "SelectionSet": [{"Alias": "myAction", "Arguments": [{"Name": "myEnum", "Value": {"Definition": {"BuiltIn": false, "Description": "", "EnumValues": [{"Description": "", "Name": "EN"}, {"Description": "", "Name": "LT"}, {"Description": "", "Name": "DE"}], "Kind": "ENUM", "Name": "Locale"}, "ExpectedType": {"NamedType": "Locale", "NonNull": true}, "Kind": 0, "Raw": "locale", "VariableDefinition": {"DefaultValue": {"Definition": {"BuiltIn": false, "Description": "", "EnumValues": [{"Description": "", "Name": "EN"}, {"Description": "", "Name": "LT"}, {"Description": "", "Name": "DE"}], "Kind": "ENUM", "Name": "Locale"}, "ExpectedType": {"NamedType": "Locale", "NonNull": true}, "Kind": 7, "Raw": "DE"}, "Definition": {"BuiltIn": false, "Description": "", "EnumValues": [{"Description": "", "Name": "EN"}, {"Description": "", "Name": "LT"}, {"Description": "", "Name": "DE"}], "Kind": "ENUM", "Name": "Locale"}, "Type": {"NamedType": "Locale", "NonNull": true}, "Used": true, "Variable": "locale"}}}], "Definition": {"Arguments": [{"Description": "", "Name": "myEnum", "Type": {"NamedType": "Locale", "NonNull": true}}], "Description": "", "Name": "myAction", "Type": {"NamedType": "SomeResult", "NonNull": true}}, "Name": "myAction", "ObjectDefinition": {"BuiltIn": false, "Description": "", "Fields": [{"Arguments": [{"Description": "", "Name": "myEnum", "Type": {"NamedType": "Locale", "NonNull": true}}], "Description": "", "Name": "myAction", "Type": {"NamedType": "SomeResult", "NonNull": true}}, {"Description": "", "Name": "__schema", "Type": {"NamedType": "__Schema", "NonNull": true}}, {"Arguments": [{"Description": "", "Name": "name", "Type": {"NamedType": "String", "NonNull": true}}], "Description": "", "Name": "__type", "Type": {"NamedType": "__Type", "NonNull": false}}], "Kind": "OBJECT", "Name": "Query"}, "SelectionSet": [{"Alias": "id", "Definition": {"Description": "", "Name": "id", "Type": {"NamedType": "String", "NonNull": false}}, "Name": "id", "ObjectDefinition": {"BuiltIn": false, "Description": "", "Fields": [{"Description": "", "Name": "id", "Type": {"NamedType": "String", "NonNull": false}}], "Kind": "OBJECT", "Name": "SomeResult"}}]}], "VariableDefinitions": [{"DefaultValue": {"Definition": {"BuiltIn": false, "Description": "", "EnumValues": [{"Description": "", "Name": "EN"}, {"Description": "", "Name": "LT"}, {"Description": "", "Name": "DE"}], "Kind": "ENUM", "Name": "Locale"}, "ExpectedType": {"NamedType": "Locale", "NonNull": true}, "Kind": 7, "Raw": "DE"}, "Definition": {"BuiltIn": false, "Description": "", "EnumValues": [{"Description": "", "Name": "EN"}, {"Description": "", "Name": "LT"}, {"Description": "", "Name": "DE"}], "Kind": "ENUM", "Name": "Locale"}, "Type": {"NamedType": "Locale", "NonNull": true}, "Used": true, "Variable": "locale"}]}]} + q_ast := {"Operations": [{"Name": "SomeOperation", "Operation": "query", "SelectionSet": [{"Alias": "myAction", "Arguments": [{"Name": "myEnum", "Value": {"Kind": 0, "Raw": "locale"}}], "Name": "myAction", "SelectionSet": [{"Alias": "id", "Name": "id"}]}], "VariableDefinitions": [{"DefaultValue": {"Kind": 7, "Raw": "DE"}, "Type": {"NamedType": "Locale", "NonNull": true}, "Used": false, "Variable": "locale"}]}]} p { [valid, q_ast, _] = graphql.parse_and_verify(query, schema) valid @@ -143,7 +143,7 @@ cases: bar @include(if: $flag) } ` - q_ast := {"Fragments": [{"Definition": {"BuiltIn": false, "Description": "", "Fields": [{"Description": "", "Name": "bar", "Type": {"NamedType": "String", "NonNull": true}}, {"Description": "", "Name": "__schema", "Type": {"NamedType": "__Schema", "NonNull": true}}, {"Arguments": [{"Description": "", "Name": "name", "Type": {"NamedType": "String", "NonNull": true}}], "Description": "", "Name": "__type", "Type": {"NamedType": "__Type", "NonNull": false}}], "Kind": "OBJECT", "Name": "Query"}, "Name": "Bar", "SelectionSet": [{"Alias": "bar", "Definition": {"Description": "", "Name": "bar", "Type": {"NamedType": "String", "NonNull": true}}, "Directives": [{"Arguments": [{"Name": "if", "Value": {"Definition": {"BuiltIn": true, "Description": "The `Boolean` scalar type represents `true` or `false`.", "Kind": "SCALAR", "Name": "Boolean"}, "ExpectedType": {"NamedType": "Boolean", "NonNull": true}, "Kind": 0, "Raw": "flag", "VariableDefinition": {"Definition": {"BuiltIn": true, "Description": "The `Boolean` scalar type represents `true` or `false`.", "Kind": "SCALAR", "Name": "Boolean"}, "Type": {"NamedType": "Boolean", "NonNull": true}, "Used": true, "Variable": "flag"}}}], "Definition": {"Arguments": [{"Description": "", "Name": "if", "Type": {"NamedType": "Boolean", "NonNull": true}}], "Description": "The @include directive may be provided for fields, fragment spreads, and inline fragments, and allows for conditional inclusion during execution as described by the if argument.", "IsRepeatable": false, "Locations": ["FIELD", "FRAGMENT_SPREAD", "INLINE_FRAGMENT"], "Name": "include"}, "Location": "FIELD", "Name": "include", "ParentDefinition": {"BuiltIn": true, "Description": "The `String`scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text.", "Kind": "SCALAR", "Name": "String"}}], "Name": "bar", "ObjectDefinition": {"BuiltIn": false, "Description": "", "Fields": [{"Description": "", "Name": "bar", "Type": {"NamedType": "String", "NonNull": true}}, {"Description": "", "Name": "__schema", "Type": {"NamedType": "__Schema", "NonNull": true}}, {"Arguments": [{"Description": "", "Name": "name", "Type": {"NamedType": "String", "NonNull": true}}], "Description": "", "Name": "__type", "Type": {"NamedType": "__Type", "NonNull": false}}], "Kind": "OBJECT", "Name": "Query"}}], "TypeCondition": "Query"}], "Operations": [{"Name": "Foo", "Operation": "query", "SelectionSet": [{"Definition": {"Definition": {"BuiltIn": false, "Description": "", "Fields": [{"Description": "", "Name": "bar", "Type": {"NamedType": "String", "NonNull": true}}, {"Description": "", "Name": "__schema", "Type": {"NamedType": "__Schema", "NonNull": true}}, {"Arguments": [{"Description": "", "Name": "name", "Type": {"NamedType": "String", "NonNull": true}}], "Description": "", "Name": "__type", "Type": {"NamedType": "__Type", "NonNull": false}}], "Kind": "OBJECT", "Name": "Query"}, "Name": "Bar", "SelectionSet": [{"Alias": "bar", "Definition": {"Description": "", "Name": "bar", "Type": {"NamedType": "String", "NonNull": true}}, "Directives": [{"Arguments": [{"Name": "if", "Value": {"Definition": {"BuiltIn": true, "Description": "The `Boolean` scalar type represents `true` or `false`.", "Kind": "SCALAR", "Name": "Boolean"}, "ExpectedType": {"NamedType": "Boolean", "NonNull": true}, "Kind": 0, "Raw": "flag", "VariableDefinition": {"Definition": {"BuiltIn": true, "Description": "The `Boolean` scalar type represents `true` or `false`.", "Kind": "SCALAR", "Name": "Boolean"}, "Type": {"NamedType": "Boolean", "NonNull": true}, "Used": true, "Variable": "flag"}}}], "Definition": {"Arguments": [{"Description": "", "Name": "if", "Type": {"NamedType": "Boolean", "NonNull": true}}], "Description": "The @include directive may be provided for fields, fragment spreads, and inline fragments, and allows for conditional inclusion during execution as described by the if argument.", "IsRepeatable": false, "Locations": ["FIELD", "FRAGMENT_SPREAD", "INLINE_FRAGMENT"], "Name": "include"}, "Location": "FIELD", "Name": "include", "ParentDefinition": {"BuiltIn": true, "Description": "The `String`scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text.", "Kind": "SCALAR", "Name": "String"}}], "Name": "bar", "ObjectDefinition": {"BuiltIn": false, "Description": "", "Fields": [{"Description": "", "Name": "bar", "Type": {"NamedType": "String", "NonNull": true}}, {"Description": "", "Name": "__schema", "Type": {"NamedType": "__Schema", "NonNull": true}}, {"Arguments": [{"Description": "", "Name": "name", "Type": {"NamedType": "String", "NonNull": true}}], "Description": "", "Name": "__type", "Type": {"NamedType": "__Type", "NonNull": false}}], "Kind": "OBJECT", "Name": "Query"}}], "TypeCondition": "Query"}, "Name": "Bar", "ObjectDefinition": {"BuiltIn": false, "Description": "", "Fields": [{"Description": "", "Name": "bar", "Type": {"NamedType": "String", "NonNull": true}}, {"Description": "", "Name": "__schema", "Type": {"NamedType": "__Schema", "NonNull": true}}, {"Arguments": [{"Description": "", "Name": "name", "Type": {"NamedType": "String", "NonNull": true}}], "Description": "", "Name": "__type", "Type": {"NamedType": "__Type", "NonNull": false}}], "Kind": "OBJECT", "Name": "Query"}}], "VariableDefinitions": [{"Definition": {"BuiltIn": true, "Description": "The `Boolean` scalar type represents `true` or `false`.", "Kind": "SCALAR", "Name": "Boolean"}, "Type": {"NamedType": "Boolean", "NonNull": true}, "Used": true, "Variable": "flag"}]}]} + q_ast := {"Fragments": [{"Name": "Bar", "SelectionSet": [{"Alias": "bar", "Directives": [{"Arguments": [{"Name": "if", "Value": {"Kind": 0, "Raw": "flag"}}], "Location": "", "Name": "include"}], "Name": "bar"}], "TypeCondition": "Query"}], "Operations": [{"Name": "Foo", "Operation": "query", "SelectionSet": [{"Name": "Bar"}], "VariableDefinitions": [{"Type": {"NamedType": "Boolean", "NonNull": true}, "Used": false, "Variable": "flag"}]}]} p { [valid, q_ast, _] = graphql.parse_and_verify(query, schema) valid @@ -152,3 +152,32 @@ cases: query: data.test.p = x want_result: - x: true + - data: + modules: + - | + package test + schema_ast := graphql.parse_schema(` + type Employee { + id: String! + salary: Int! + } + + schema { + query: Query + } + + type Query { + employeeByID(id: String): Employee + } + `) + query_ast := graphql.parse_query(` + query { employeeByID(id: "alice") { salary }} + `) + p { + [valid, query_ast, schema_ast] = graphql.parse_and_verify(query_ast, schema_ast) + valid + } + note: graphql_parse_and_verify/success - AST objects - Employee example + query: data.test.p = x + want_result: + - x: true diff --git a/test/cases/testdata/graphql/test-graphql-parse.yaml b/test/cases/testdata/graphql/test-graphql-parse.yaml index a63ea1d229..8d66782a5a 100644 --- a/test/cases/testdata/graphql/test-graphql-parse.yaml +++ b/test/cases/testdata/graphql/test-graphql-parse.yaml @@ -28,7 +28,7 @@ cases: } } ` - q_ast := {"Operations": [{"Name": "", "Operation": "query", "SelectionSet": [{"Alias": "entity", "Definition": {"Description": "", "Name": "entity", "Type": {"NamedType": "_Entity", "NonNull": false}}, "Name": "entity", "ObjectDefinition": {"BuiltIn": false, "Description": "", "Fields": [{"Description": "", "Name": "entity", "Type": {"NamedType": "_Entity", "NonNull": false}}, {"Description": "", "Name": "__schema", "Type": {"NamedType": "__Schema", "NonNull": true}}, {"Arguments": [{"Description": "", "Name": "name", "Type": {"NamedType": "String", "NonNull": true}}], "Description": "", "Name": "__type", "Type": {"NamedType": "__Type", "NonNull": false}}], "Kind": "OBJECT", "Name": "Query"}, "SelectionSet": [{"ObjectDefinition": {"BuiltIn": false, "Description": "", "Kind": "UNION", "Name": "_Entity", "Types": ["Product", "User"]}, "SelectionSet": [{"Alias": "id", "Definition": {"Description": "", "Name": "id", "Type": {"NamedType": "ID", "NonNull": true}}, "Name": "id", "ObjectDefinition": {"BuiltIn": false, "Description": "", "Fields": [{"Description": "", "Name": "id", "Type": {"NamedType": "ID", "NonNull": true}}], "Kind": "OBJECT", "Name": "User"}}], "TypeCondition": "User"}]}]}]} + q_ast := {"Operations": [{"Name": "", "Operation": "query", "SelectionSet": [{"Alias": "entity", "Name": "entity", "SelectionSet": [{"SelectionSet": [{"Alias": "id", "Name": "id"}], "TypeCondition": "User"}]}]}]} p { [q_ast, _] = graphql.parse(query, schema) } @@ -94,7 +94,7 @@ cases: } } ` - q_ast := {"Operations": [{"Name": "SomeOperation", "Operation": "query", "SelectionSet": [{"Alias": "myAction", "Arguments": [{"Name": "myEnum", "Value": {"Definition": {"BuiltIn": false, "Description": "", "EnumValues": [{"Description": "", "Name": "EN"}, {"Description": "", "Name": "LT"}, {"Description": "", "Name": "DE"}], "Kind": "ENUM", "Name": "Locale"}, "ExpectedType": {"NamedType": "Locale", "NonNull": true}, "Kind": 0, "Raw": "locale", "VariableDefinition": {"DefaultValue": {"Definition": {"BuiltIn": false, "Description": "", "EnumValues": [{"Description": "", "Name": "EN"}, {"Description": "", "Name": "LT"}, {"Description": "", "Name": "DE"}], "Kind": "ENUM", "Name": "Locale"}, "ExpectedType": {"NamedType": "Locale", "NonNull": true}, "Kind": 7, "Raw": "DE"}, "Definition": {"BuiltIn": false, "Description": "", "EnumValues": [{"Description": "", "Name": "EN"}, {"Description": "", "Name": "LT"}, {"Description": "", "Name": "DE"}], "Kind": "ENUM", "Name": "Locale"}, "Type": {"NamedType": "Locale", "NonNull": true}, "Used": true, "Variable": "locale"}}}], "Definition": {"Arguments": [{"Description": "", "Name": "myEnum", "Type": {"NamedType": "Locale", "NonNull": true}}], "Description": "", "Name": "myAction", "Type": {"NamedType": "SomeResult", "NonNull": true}}, "Name": "myAction", "ObjectDefinition": {"BuiltIn": false, "Description": "", "Fields": [{"Arguments": [{"Description": "", "Name": "myEnum", "Type": {"NamedType": "Locale", "NonNull": true}}], "Description": "", "Name": "myAction", "Type": {"NamedType": "SomeResult", "NonNull": true}}, {"Description": "", "Name": "__schema", "Type": {"NamedType": "__Schema", "NonNull": true}}, {"Arguments": [{"Description": "", "Name": "name", "Type": {"NamedType": "String", "NonNull": true}}], "Description": "", "Name": "__type", "Type": {"NamedType": "__Type", "NonNull": false}}], "Kind": "OBJECT", "Name": "Query"}, "SelectionSet": [{"Alias": "id", "Definition": {"Description": "", "Name": "id", "Type": {"NamedType": "String", "NonNull": false}}, "Name": "id", "ObjectDefinition": {"BuiltIn": false, "Description": "", "Fields": [{"Description": "", "Name": "id", "Type": {"NamedType": "String", "NonNull": false}}], "Kind": "OBJECT", "Name": "SomeResult"}}]}], "VariableDefinitions": [{"DefaultValue": {"Definition": {"BuiltIn": false, "Description": "", "EnumValues": [{"Description": "", "Name": "EN"}, {"Description": "", "Name": "LT"}, {"Description": "", "Name": "DE"}], "Kind": "ENUM", "Name": "Locale"}, "ExpectedType": {"NamedType": "Locale", "NonNull": true}, "Kind": 7, "Raw": "DE"}, "Definition": {"BuiltIn": false, "Description": "", "EnumValues": [{"Description": "", "Name": "EN"}, {"Description": "", "Name": "LT"}, {"Description": "", "Name": "DE"}], "Kind": "ENUM", "Name": "Locale"}, "Type": {"NamedType": "Locale", "NonNull": true}, "Used": true, "Variable": "locale"}]}]} + q_ast := {"Operations": [{"Name": "SomeOperation", "Operation": "query", "SelectionSet": [{"Alias": "myAction", "Arguments": [{"Name": "myEnum", "Value": {"Kind": 0, "Raw": "locale"}}], "Name": "myAction", "SelectionSet": [{"Alias": "id", "Name": "id"}]}], "VariableDefinitions": [{"DefaultValue": {"Kind": 7, "Raw": "DE"}, "Type": {"NamedType": "Locale", "NonNull": true}, "Used": false, "Variable": "locale"}]}]} p { [q_ast, _] = graphql.parse(query, schema) } @@ -140,7 +140,7 @@ cases: bar @include(if: $flag) } ` - q_ast := {"Fragments": [{"Definition": {"BuiltIn": false, "Description": "", "Fields": [{"Description": "", "Name": "bar", "Type": {"NamedType": "String", "NonNull": true}}, {"Description": "", "Name": "__schema", "Type": {"NamedType": "__Schema", "NonNull": true}}, {"Arguments": [{"Description": "", "Name": "name", "Type": {"NamedType": "String", "NonNull": true}}], "Description": "", "Name": "__type", "Type": {"NamedType": "__Type", "NonNull": false}}], "Kind": "OBJECT", "Name": "Query"}, "Name": "Bar", "SelectionSet": [{"Alias": "bar", "Definition": {"Description": "", "Name": "bar", "Type": {"NamedType": "String", "NonNull": true}}, "Directives": [{"Arguments": [{"Name": "if", "Value": {"Definition": {"BuiltIn": true, "Description": "The `Boolean` scalar type represents `true` or `false`.", "Kind": "SCALAR", "Name": "Boolean"}, "ExpectedType": {"NamedType": "Boolean", "NonNull": true}, "Kind": 0, "Raw": "flag", "VariableDefinition": {"Definition": {"BuiltIn": true, "Description": "The `Boolean` scalar type represents `true` or `false`.", "Kind": "SCALAR", "Name": "Boolean"}, "Type": {"NamedType": "Boolean", "NonNull": true}, "Used": true, "Variable": "flag"}}}], "Definition": {"Arguments": [{"Description": "", "Name": "if", "Type": {"NamedType": "Boolean", "NonNull": true}}], "Description": "The @include directive may be provided for fields, fragment spreads, and inline fragments, and allows for conditional inclusion during execution as described by the if argument.", "IsRepeatable": false, "Locations": ["FIELD", "FRAGMENT_SPREAD", "INLINE_FRAGMENT"], "Name": "include"}, "Location": "FIELD", "Name": "include", "ParentDefinition": {"BuiltIn": true, "Description": "The `String`scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text.", "Kind": "SCALAR", "Name": "String"}}], "Name": "bar", "ObjectDefinition": {"BuiltIn": false, "Description": "", "Fields": [{"Description": "", "Name": "bar", "Type": {"NamedType": "String", "NonNull": true}}, {"Description": "", "Name": "__schema", "Type": {"NamedType": "__Schema", "NonNull": true}}, {"Arguments": [{"Description": "", "Name": "name", "Type": {"NamedType": "String", "NonNull": true}}], "Description": "", "Name": "__type", "Type": {"NamedType": "__Type", "NonNull": false}}], "Kind": "OBJECT", "Name": "Query"}}], "TypeCondition": "Query"}], "Operations": [{"Name": "Foo", "Operation": "query", "SelectionSet": [{"Definition": {"Definition": {"BuiltIn": false, "Description": "", "Fields": [{"Description": "", "Name": "bar", "Type": {"NamedType": "String", "NonNull": true}}, {"Description": "", "Name": "__schema", "Type": {"NamedType": "__Schema", "NonNull": true}}, {"Arguments": [{"Description": "", "Name": "name", "Type": {"NamedType": "String", "NonNull": true}}], "Description": "", "Name": "__type", "Type": {"NamedType": "__Type", "NonNull": false}}], "Kind": "OBJECT", "Name": "Query"}, "Name": "Bar", "SelectionSet": [{"Alias": "bar", "Definition": {"Description": "", "Name": "bar", "Type": {"NamedType": "String", "NonNull": true}}, "Directives": [{"Arguments": [{"Name": "if", "Value": {"Definition": {"BuiltIn": true, "Description": "The `Boolean` scalar type represents `true` or `false`.", "Kind": "SCALAR", "Name": "Boolean"}, "ExpectedType": {"NamedType": "Boolean", "NonNull": true}, "Kind": 0, "Raw": "flag", "VariableDefinition": {"Definition": {"BuiltIn": true, "Description": "The `Boolean` scalar type represents `true` or `false`.", "Kind": "SCALAR", "Name": "Boolean"}, "Type": {"NamedType": "Boolean", "NonNull": true}, "Used": true, "Variable": "flag"}}}], "Definition": {"Arguments": [{"Description": "", "Name": "if", "Type": {"NamedType": "Boolean", "NonNull": true}}], "Description": "The @include directive may be provided for fields, fragment spreads, and inline fragments, and allows for conditional inclusion during execution as described by the if argument.", "IsRepeatable": false, "Locations": ["FIELD", "FRAGMENT_SPREAD", "INLINE_FRAGMENT"], "Name": "include"}, "Location": "FIELD", "Name": "include", "ParentDefinition": {"BuiltIn": true, "Description": "The `String`scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text.", "Kind": "SCALAR", "Name": "String"}}], "Name": "bar", "ObjectDefinition": {"BuiltIn": false, "Description": "", "Fields": [{"Description": "", "Name": "bar", "Type": {"NamedType": "String", "NonNull": true}}, {"Description": "", "Name": "__schema", "Type": {"NamedType": "__Schema", "NonNull": true}}, {"Arguments": [{"Description": "", "Name": "name", "Type": {"NamedType": "String", "NonNull": true}}], "Description": "", "Name": "__type", "Type": {"NamedType": "__Type", "NonNull": false}}], "Kind": "OBJECT", "Name": "Query"}}], "TypeCondition": "Query"}, "Name": "Bar", "ObjectDefinition": {"BuiltIn": false, "Description": "", "Fields": [{"Description": "", "Name": "bar", "Type": {"NamedType": "String", "NonNull": true}}, {"Description": "", "Name": "__schema", "Type": {"NamedType": "__Schema", "NonNull": true}}, {"Arguments": [{"Description": "", "Name": "name", "Type": {"NamedType": "String", "NonNull": true}}], "Description": "", "Name": "__type", "Type": {"NamedType": "__Type", "NonNull": false}}], "Kind": "OBJECT", "Name": "Query"}}], "VariableDefinitions": [{"Definition": {"BuiltIn": true, "Description": "The `Boolean` scalar type represents `true` or `false`.", "Kind": "SCALAR", "Name": "Boolean"}, "Type": {"NamedType": "Boolean", "NonNull": true}, "Used": true, "Variable": "flag"}]}]} + q_ast := {"Fragments": [{"Name": "Bar", "SelectionSet": [{"Alias": "bar", "Directives": [{"Arguments": [{"Name": "if", "Value": {"Kind": 0, "Raw": "flag"}}], "Location": "", "Name": "include"}], "Name": "bar"}], "TypeCondition": "Query"}], "Operations": [{"Name": "Foo", "Operation": "query", "SelectionSet": [{"Name": "Bar"}], "VariableDefinitions": [{"Type": {"NamedType": "Boolean", "NonNull": true}, "Used": false, "Variable": "flag"}]}]} p { [q_ast, _] = graphql.parse(query, schema) } @@ -148,3 +148,31 @@ cases: query: data.test.p = x want_result: - x: true + - data: + modules: + - | + package test + schema_ast := graphql.parse_schema(` + type Employee { + id: String! + salary: Int! + } + + schema { + query: Query + } + + type Query { + employeeByID(id: String): Employee + } + `) + query_ast := graphql.parse_query(` + query { employeeByID(id: "alice") { salary }} + `) + p { + [query_ast, schema_ast] = graphql.parse(query_ast, schema_ast) + } + note: graphql_parse/success - AST objects - Employee example + query: data.test.p = x + want_result: + - x: true diff --git a/topdown/graphql.go b/topdown/graphql.go index dc5ebc24d3..e43af7000c 100644 --- a/topdown/graphql.go +++ b/topdown/graphql.go @@ -5,40 +5,22 @@ package topdown import ( + "encoding/json" "fmt" "strings" - gqltop "github.com/open-policy-agent/opa/internal/gqlparser" - gqlast "github.com/open-policy-agent/opa/internal/gqlparser/ast" gqlparser "github.com/open-policy-agent/opa/internal/gqlparser/parser" gqlvalidator "github.com/open-policy-agent/opa/internal/gqlparser/validator" + // Side-effecting import. Triggers GraphQL library's validation rule init() functions. + _ "github.com/open-policy-agent/opa/internal/gqlparser/validator/rules" + "github.com/open-policy-agent/opa/ast" "github.com/open-policy-agent/opa/topdown/builtins" ) -// Parses a GraphQL schema, and returns only the Schema object. -// Used in validation of queries. -// NOTE(philipc): The error type here is a gqlerror.Error struct, -// which requires us to treat it specially, since the returned value -// will *always* be non-null. -// See: https://staticcheck.io/docs/checks#SA4023 for details. -func loadSchema(schema string) (*gqlast.Schema, error) { - loadedSchema, err := gqltop.LoadSchema(&gqlast.Source{Input: schema}) - if err != nil { - errorParts := strings.SplitN(err.Error(), ":", 4) - msg := strings.TrimLeft(errorParts[3], " ") - return nil, fmt.Errorf("%s in GraphQL schema string at location %s:%s", msg, errorParts[1], errorParts[2]) - } - return loadedSchema, nil -} - // Parses a GraphQL schema, and returns the GraphQL AST for the schema. -// NOTE(philipc): The error type here is a gqlerror.Error struct, -// which requires us to treat it specially, since the returned value -// will *always* be non-null. -// See: https://staticcheck.io/docs/checks#SA4023 for details. func parseSchema(schema string) (*gqlast.SchemaDocument, error) { // NOTE(philipc): We don't include the "built-in schema defs" from the // underlying graphql parsing library here, because those definitions @@ -56,10 +38,6 @@ func parseSchema(schema string) (*gqlast.SchemaDocument, error) { } // Parses a GraphQL query, and returns the GraphQL AST for the query. -// NOTE(philipc): The error type here is a gqlerror.Error struct, -// which requires us to treat it specially, since the returned value -// will *always* be non-null. -// See: https://staticcheck.io/docs/checks#SA4023 for details. func parseQuery(query string) (*gqlast.QueryDocument, error) { queryAST, err := gqlparser.ParseQuery(&gqlast.Source{Input: query}) if err != nil { @@ -73,10 +51,6 @@ func parseQuery(query string) (*gqlast.QueryDocument, error) { // Validates a GraphQL query against a schema, and returns an error. // In this case, we get a wrappered error list type, and pluck out // just the first error message in the list. -// NOTE(philipc): The error type from Validate() is a gqlerror.List -// struct, which requires us to treat it specially, since the -// returned value will *always* be non-null. -// See: https://staticcheck.io/docs/checks#SA4023 for details. func validateQuery(schema *gqlast.Schema, query *gqlast.QueryDocument) error { // Validate the query against the schema, erroring if there's an issue. err := gqlvalidator.Validate(schema, query) @@ -94,6 +68,78 @@ func validateQuery(schema *gqlast.Schema, query *gqlast.QueryDocument) error { return nil } +func getBuiltinSchema() *gqlast.SchemaDocument { + schema, err := gqlparser.ParseSchema(gqlvalidator.Prelude) + if err != nil { + panic(fmt.Errorf("Error in gqlparser Prelude (should be impossible): %w", err)) + } + return schema +} + +// NOTE(philipc): This function expects *validated* schema documents, and will break +// if it is fed arbitrary structures. +func mergeSchemaDocuments(docA *gqlast.SchemaDocument, docB *gqlast.SchemaDocument) *gqlast.SchemaDocument { + ast := &gqlast.SchemaDocument{} + ast.Merge(docA) + ast.Merge(docB) + return ast +} + +// Converts a SchemaDocument into a gqlast.Schema object that can be used for validation. +// It merges in the builtin schema typedefs exactly as gqltop.LoadSchema did internally. +func convertSchema(schemaDoc *gqlast.SchemaDocument) (*gqlast.Schema, error) { + // Merge builtin schema + schema we were provided. + builtinsSchemaDoc := getBuiltinSchema() + mergedSchemaDoc := mergeSchemaDocuments(builtinsSchemaDoc, schemaDoc) + schema, err := gqlvalidator.ValidateSchemaDocument(mergedSchemaDoc) + if err != nil { + return nil, fmt.Errorf("Error in gqlparser SchemaDocument to Schema conversion: %w", err) + } + return schema, nil +} + +// Converts an ast.Object into a gqlast.QueryDocument object. +func objectToQueryDocument(value ast.Object) (*gqlast.QueryDocument, error) { + // Convert ast.Term to interface{} for JSON encoding below. + asJSON, err := ast.JSON(value) + if err != nil { + return nil, err + } + // Marshal to JSON. + bs, err := json.Marshal(asJSON) + if err != nil { + return nil, err + } + // Unmarshal from JSON -> gqlast.QueryDocument. + var result gqlast.QueryDocument + err = json.Unmarshal(bs, &result) + if err != nil { + return nil, err + } + return &result, nil +} + +// Converts an ast.Object into a gqlast.SchemaDocument object. +func objectToSchemaDocument(value ast.Object) (*gqlast.SchemaDocument, error) { + // Convert ast.Term to interface{} for JSON encoding below. + asJSON, err := ast.JSON(value) + if err != nil { + return nil, err + } + // Marshal to JSON. + bs, err := json.Marshal(asJSON) + if err != nil { + return nil, err + } + // Unmarshal from JSON -> gqlast.SchemaDocument. + var result gqlast.SchemaDocument + err = json.Unmarshal(bs, &result) + if err != nil { + return nil, err + } + return &result, nil +} + // Recursively traverses an AST that has been run through InterfaceToValue, // and prunes away the fields with null or empty values, and all `Position` // structs. @@ -178,35 +224,34 @@ func pruneIrrelevantGraphQLASTNodes(value ast.Value) ast.Value { // Reports errors from parsing/validation. func builtinGraphQLParse(_ BuiltinContext, operands []*ast.Term, iter func(*ast.Term) error) error { - // Get the raw strings from each operand so that we can - // feed them to the GraphQL parser functions. - rawQuery, err := builtins.StringOperand(operands[0].Value, 1) - if err != nil { - return err + var queryDoc *gqlast.QueryDocument + var schemaDoc *gqlast.SchemaDocument + var err error + + // Parse/translate query if it's a string/object. + switch x := operands[0].Value.(type) { + case ast.String: + queryDoc, err = parseQuery(string(x)) + case ast.Object: + queryDoc, err = objectToQueryDocument(x) + default: + // Error if wrong type. + return builtins.NewOperandTypeErr(0, x, "string", "object") } - rawSchema, err := builtins.StringOperand(operands[1].Value, 1) if err != nil { return err } - // Generate ASTs/errors for the GraphQL query. - queryDoc, err := parseQuery(string(rawQuery)) - if err != nil { - return err + // Parse/translate schema if it's a string/object. + switch x := operands[1].Value.(type) { + case ast.String: + schemaDoc, err = parseSchema(string(x)) + case ast.Object: + schemaDoc, err = objectToSchemaDocument(x) + default: + // Error if wrong type. + return builtins.NewOperandTypeErr(1, x, "string", "object") } - - // Validate the query against the schema, erroring if there's an issue. - schema, err := loadSchema(string(rawSchema)) - if err != nil { - return err - } - if err := validateQuery(schema, queryDoc); err != nil { - return err - } - - // Generate AST/errors for the GraphQL schema, since the query - // passed validation. - schemaDoc, err := parseSchema(string(rawSchema)) if err != nil { return err } @@ -221,6 +266,15 @@ func builtinGraphQLParse(_ BuiltinContext, operands []*ast.Term, iter func(*ast. return err } + // Validate the query against the schema, erroring if there's an issue. + schema, err := convertSchema(schemaDoc) + if err != nil { + return err + } + if err := validateQuery(schema, queryDoc); err != nil { + return err + } + // Recursively remove irrelevant AST structures. queryResult := pruneIrrelevantGraphQLASTNodes(queryASTValue.(ast.Object)) querySchema := pruneIrrelevantGraphQLASTNodes(schemaASTValue.(ast.Object)) @@ -236,16 +290,9 @@ func builtinGraphQLParse(_ BuiltinContext, operands []*ast.Term, iter func(*ast. // Returns default value when errors occur. func builtinGraphQLParseAndVerify(_ BuiltinContext, operands []*ast.Term, iter func(*ast.Term) error) error { - // Get the raw strings from each operand so that we can - // feed them to the GraphQL parser functions. - rawQuery, err := builtins.StringOperand(operands[0].Value, 1) - if err != nil { - return err - } - rawSchema, err := builtins.StringOperand(operands[1].Value, 1) - if err != nil { - return err - } + var queryDoc *gqlast.QueryDocument + var schemaDoc *gqlast.SchemaDocument + var err error unverified := ast.ArrayTerm( ast.BooleanTerm(false), @@ -253,24 +300,30 @@ func builtinGraphQLParseAndVerify(_ BuiltinContext, operands []*ast.Term, iter f ast.NewTerm(ast.NewObject()), ) - // Generate ASTs/errors for the GraphQL query. - queryDoc, err := parseQuery(string(rawQuery)) + // Parse/translate query if it's a string/object. + switch x := operands[0].Value.(type) { + case ast.String: + queryDoc, err = parseQuery(string(x)) + case ast.Object: + queryDoc, err = objectToQueryDocument(x) + default: + // Error if wrong type. + return iter(unverified) + } if err != nil { return iter(unverified) } - // Validate the query against the schema, erroring if there's an issue. - schema, err := loadSchema(string(rawSchema)) - if err != nil { + // Parse/translate schema if it's a string/object. + switch x := operands[1].Value.(type) { + case ast.String: + schemaDoc, err = parseSchema(string(x)) + case ast.Object: + schemaDoc, err = objectToSchemaDocument(x) + default: + // Error if wrong type. return iter(unverified) } - if err := validateQuery(schema, queryDoc); err != nil { - return iter(unverified) - } - - // Generate AST/errors for the GraphQL schema, since the query - // passed validation. - schemaDoc, err := parseSchema(string(rawSchema)) if err != nil { return iter(unverified) } @@ -285,6 +338,15 @@ func builtinGraphQLParseAndVerify(_ BuiltinContext, operands []*ast.Term, iter f return iter(unverified) } + // Validate the query against the schema, erroring if there's an issue. + schema, err := convertSchema(schemaDoc) + if err != nil { + return iter(unverified) + } + if err := validateQuery(schema, queryDoc); err != nil { + return iter(unverified) + } + // Recursively remove irrelevant AST structures. queryResult := pruneIrrelevantGraphQLASTNodes(queryASTValue.(ast.Object)) querySchema := pruneIrrelevantGraphQLASTNodes(schemaASTValue.(ast.Object)) @@ -348,29 +410,42 @@ func builtinGraphQLParseSchema(_ BuiltinContext, operands []*ast.Term, iter func } func builtinGraphQLIsValid(_ BuiltinContext, operands []*ast.Term, iter func(*ast.Term) error) error { - // Get the raw strings from each operand so that we can - // feed them to the GraphQL parser functions. - rawQuery, err := builtins.StringOperand(operands[0].Value, 1) - if err != nil { + var queryDoc *gqlast.QueryDocument + var schemaDoc *gqlast.SchemaDocument + var err error + + switch x := operands[0].Value.(type) { + case ast.String: + queryDoc, err = parseQuery(string(x)) + case ast.Object: + queryDoc, err = objectToQueryDocument(x) + default: + // Error if wrong type. return iter(ast.BooleanTerm(false)) } - rawSchema, err := builtins.StringOperand(operands[1].Value, 1) if err != nil { return iter(ast.BooleanTerm(false)) } - // Generate ASTs/errors for the GraphQL schema and query. - schema, err := loadSchema(string(rawSchema)) - if err != nil { + switch x := operands[1].Value.(type) { + case ast.String: + schemaDoc, err = parseSchema(string(x)) + case ast.Object: + schemaDoc, err = objectToSchemaDocument(x) + default: + // Error if wrong type. return iter(ast.BooleanTerm(false)) } - query, err := parseQuery(string(rawQuery)) if err != nil { return iter(ast.BooleanTerm(false)) } // Validate the query against the schema, erroring if there's an issue. - if err := validateQuery(schema, query); err != nil { + schema, err := convertSchema(schemaDoc) + if err != nil { + return iter(ast.BooleanTerm(false)) + } + if err := validateQuery(schema, queryDoc); err != nil { return iter(ast.BooleanTerm(false)) }