From 4c5641c8124c8ac4dda9d078867ab90c9bc6b79f Mon Sep 17 00:00:00 2001 From: Timothy Pansino <11214426+TimPansino@users.noreply.github.com> Date: Thu, 29 Jul 2021 14:35:39 -0700 Subject: [PATCH] Graphene Testing (#302) * Test basic working. * Fix Graphql tests misnamed field. * Finalize graphene test porting. * Updates after rebase. * Remove unused import. * Expand tox matrix for graphene. * Fix py2 tests for graphene. * Fix py2 tests for graphene. * Remove unused code. * Final testing update. Co-authored-by: Uma Annamalai * Minor testing tweaks. * Add testing for graphene schemas in fastapi. Co-authored-by: Uma Annamalai --- newrelic/hooks/framework_graphql.py | 2 - .../framework_fastapi/_target_application.py | 15 +- tests/framework_fastapi/test_application.py | 49 +- .../framework_graphene/_target_application.py | 170 ++++++ tests/framework_graphene/conftest.py | 51 ++ tests/framework_graphene/test_application.py | 515 ++++++++++++++++++ .../framework_graphql/_target_application.py | 2 +- tests/framework_graphql/test_application.py | 29 +- .../test_application_async.py | 3 +- tox.ini | 10 +- 10 files changed, 834 insertions(+), 12 deletions(-) create mode 100644 tests/framework_graphene/_target_application.py create mode 100644 tests/framework_graphene/conftest.py create mode 100644 tests/framework_graphene/test_application.py diff --git a/newrelic/hooks/framework_graphql.py b/newrelic/hooks/framework_graphql.py index 169f1a4abb..e5c3fec49e 100644 --- a/newrelic/hooks/framework_graphql.py +++ b/newrelic/hooks/framework_graphql.py @@ -21,7 +21,6 @@ from newrelic.common.object_wrapper import function_wrapper, wrap_function_wrapper from newrelic.core.graphql_utils import graphql_statement from collections import deque -from copy import copy GRAPHQL_IGNORED_FIELDS = frozenset(("id", "__typename")) @@ -426,7 +425,6 @@ def wrap_graphql_impl(wrapped, instance, args, kwargs): trace.statement = graphql_statement(query) with ErrorTrace(ignore=ignore_graphql_duplicate_exception): result = wrapped(*args, **kwargs) - # transaction.set_transaction_name(transaction_name, "GraphQL", priority=14) return result diff --git a/tests/framework_fastapi/_target_application.py b/tests/framework_fastapi/_target_application.py index 360eaf3a7b..ca45b056f6 100644 --- a/tests/framework_fastapi/_target_application.py +++ b/tests/framework_fastapi/_target_application.py @@ -13,8 +13,12 @@ # limitations under the License. from fastapi import FastAPI -from testing_support.asgi_testing import AsgiTest +from graphene import ObjectType, String, Schema +from graphql.execution.executors.asyncio import AsyncioExecutor +from starlette.graphql import GraphQLApp + from newrelic.api.transaction import current_transaction +from testing_support.asgi_testing import AsgiTest app = FastAPI() @@ -31,4 +35,13 @@ async def non_sync(): return {} +class Query(ObjectType): + hello = String() + + def resolve_hello(self, info): + return "Hello!" + + +app.add_route("/graphql", GraphQLApp(executor_class=AsyncioExecutor, schema=Schema(query=Query))) + target_application = AsgiTest(app) diff --git a/tests/framework_fastapi/test_application.py b/tests/framework_fastapi/test_application.py index 41860409ad..657ba127be 100644 --- a/tests/framework_fastapi/test_application.py +++ b/tests/framework_fastapi/test_application.py @@ -13,7 +13,8 @@ # limitations under the License. import pytest -from testing_support.fixtures import validate_transaction_metrics +from testing_support.fixtures import dt_enabled, validate_transaction_metrics +from testing_support.validators.validate_span_events import validate_span_events @pytest.mark.parametrize("endpoint,transaction_name", ( @@ -28,3 +29,49 @@ def _test(): assert response.status == 200 _test() + + +@dt_enabled +def test_graphql_endpoint(app): + from graphql import __version__ as version + + FRAMEWORK_METRICS = [ + ("Python/Framework/GraphQL/%s" % version, 1), + ] + _test_scoped_metrics = [ + ("GraphQL/resolve/GraphQL/hello", 1), + ("GraphQL/operation/GraphQL/query//hello", 1), + ] + _test_unscoped_metrics = [ + ("GraphQL/all", 1), + ("GraphQL/GraphQL/all", 1), + ("GraphQL/allWeb", 1), + ("GraphQL/GraphQL/allWeb", 1), + ] + _test_scoped_metrics + + _expected_query_operation_attributes = { + "graphql.operation.type": "query", + "graphql.operation.name": "", + "graphql.operation.query": "{ hello }", + } + _expected_query_resolver_attributes = { + "graphql.field.name": "hello", + "graphql.field.parentType": "Query", + "graphql.field.path": "hello", + "graphql.field.returnType": "String", + } + + @validate_span_events(exact_agents=_expected_query_operation_attributes) + @validate_span_events(exact_agents=_expected_query_resolver_attributes) + @validate_transaction_metrics( + "query//hello", + "GraphQL", + scoped_metrics=_test_scoped_metrics, + rollup_metrics=_test_unscoped_metrics + FRAMEWORK_METRICS, + ) + def _test(): + response = app.make_request("POST", "/graphql", params="query=%7B%20hello%20%7D") + assert response.status == 200 + assert "Hello!" in response.body.decode("utf-8") + + _test() diff --git a/tests/framework_graphene/_target_application.py b/tests/framework_graphene/_target_application.py new file mode 100644 index 0000000000..349326cce5 --- /dev/null +++ b/tests/framework_graphene/_target_application.py @@ -0,0 +1,170 @@ +# Copyright 2010 New Relic, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from graphene import ( + ObjectType, + Field, + String, + Schema, + Mutation as GrapheneMutation, + Int, + List, + NonNull, + Union, +) + + +class Author(ObjectType): + first_name = String() + last_name = String() + + +class Book(ObjectType): + id = Int() + name = String() + isbn = String() + author = Field(Author) + branch = String() + + +class Magazine(ObjectType): + id = Int() + name = String() + issue = Int() + branch = String() + + +class Item(Union): + class Meta: + types = (Book, Magazine) + + +class Library(ObjectType): + id = Int() + branch = String() + magazine = Field(List(Magazine)) + book = Field(List(Book)) + + +Storage = List(String) + + + +authors = [ + Author( + first_name="New", + last_name="Relic", + ), + Author( + first_name="Bob", + last_name="Smith", + ), + Author( + first_name="Leslie", + last_name="Jones", + ), +] + +books = [ + Book( + id=1, + name="Python Agent: The Book", + isbn="a-fake-isbn", + author=authors[0], + branch="riverside", + ), + Book( + id=2, + name="Ollies for O11y: A Sk8er's Guide to Observability", + isbn="a-second-fake-isbn", + author=authors[1], + branch="downtown", + ), + Book( + id=3, + name="[Redacted]", + isbn="a-third-fake-isbn", + author=authors[2], + branch="riverside", + ), +] + +magazines = [ + Magazine(id=1, name="Reli Updates Weekly", issue=1, branch="riverside"), + Magazine(id=2, name="Reli Updates Weekly", issue=2, branch="downtown"), + Magazine(id=3, name="Node Weekly", issue=1, branch="riverside"), +] + + +libraries = ["riverside", "downtown"] +libraries = [ + Library( + id=i + 1, + branch=branch, + magazine=[m for m in magazines if m.branch == branch], + book=[b for b in books if b.branch == branch], + ) + for i, branch in enumerate(libraries) +] + +storage = [] + + + +class StorageAdd(GrapheneMutation): + class Arguments: + string = String(required=True) + + string = String() + + def mutate(parent, info, string): + storage.append(string) + return String(string=string) + + +class Query(ObjectType): + library = Field(Library, index=Int(required=True)) + hello = String() + search = Field(List(Item), contains=String(required=True)) + echo = Field(String, echo=String(required=True)) + storage = Storage + error = String() + + def resolve_library(parent, info, index): + # returns an object that represents a Person + return libraries[index] + + def resolve_storage(parent, info): + return storage + + def resolve_search(parent, info, contains): + search_books = [b for b in books if contains in b.name] + search_magazines = [m for m in magazines if contains in m.name] + return search_books + search_magazines + + def resolve_hello(root, info): + return "Hello!" + + def resolve_echo(root, info, echo): + return echo + + def resolve_error(root, info): + raise RuntimeError("Runtime Error!") + + error_non_null = Field(NonNull(String), resolver=resolve_error) + + +class Mutation(ObjectType): + storage_add = StorageAdd.Field() + +_target_application = Schema(query=Query, mutation=Mutation, auto_camelcase=False) diff --git a/tests/framework_graphene/conftest.py b/tests/framework_graphene/conftest.py new file mode 100644 index 0000000000..2084a5bb46 --- /dev/null +++ b/tests/framework_graphene/conftest.py @@ -0,0 +1,51 @@ +# Copyright 2010 New Relic, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest +import six +from testing_support.fixtures import ( + code_coverage_fixture, + collector_agent_registration_fixture, + collector_available_fixture, +) + +_coverage_source = [ + "newrelic.hooks.framework_graphql", +] + +code_coverage = code_coverage_fixture(source=_coverage_source) + +_default_settings = { + "transaction_tracer.explain_threshold": 0.0, + "transaction_tracer.transaction_threshold": 0.0, + "transaction_tracer.stack_trace_threshold": 0.0, + "debug.log_data_collector_payloads": True, + "debug.record_transaction_failure": True, +} + +collector_agent_registration = collector_agent_registration_fixture( + app_name="Python Agent Test (framework_graphql)", + default_settings=_default_settings, +) + + +@pytest.fixture(scope="session") +def app(): + from _target_application import _target_application + + return _target_application + + +if six.PY2: + collect_ignore = ["test_application_async.py"] diff --git a/tests/framework_graphene/test_application.py b/tests/framework_graphene/test_application.py new file mode 100644 index 0000000000..9d2243e5c3 --- /dev/null +++ b/tests/framework_graphene/test_application.py @@ -0,0 +1,515 @@ +# Copyright 2010 New Relic, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import six +import pytest +from testing_support.fixtures import ( + dt_enabled, + validate_transaction_errors, + validate_transaction_metrics, +) +from testing_support.validators.validate_span_events import validate_span_events +from testing_support.validators.validate_transaction_count import ( + validate_transaction_count, +) + +from newrelic.api.background_task import background_task +from newrelic.common.object_names import callable_name + + +@pytest.fixture(scope="session") +def is_graphql_2(): + from graphql import __version__ as version + + major_version = int(version.split(".")[0]) + return major_version == 2 + + +@pytest.fixture(scope="session") +def graphql_run(): + """Wrapper function to simulate framework_graphql test behavior.""" + def execute(schema, *args, **kwargs): + return schema.execute(*args, **kwargs) + return execute + +def to_graphql_source(query): + def delay_import(): + try: + from graphql import Source + except ImportError: + # Fallback if Source is not implemented + return query + + from graphql import __version__ as version + + # For graphql2, Source objects aren't acceptable input + major_version = int(version.split(".")[0]) + if major_version == 2: + return query + + return Source(query) + + return delay_import + + +def example_middleware(next, root, info, **args): + return_value = next(root, info, **args) + return return_value + + +def error_middleware(next, root, info, **args): + raise RuntimeError("Runtime Error!") + + +_runtime_error_name = callable_name(RuntimeError) +_test_runtime_error = [(_runtime_error_name, "Runtime Error!")] +_graphql_base_rollup_metrics = [ + ("OtherTransaction/all", 1), + ("GraphQL/all", 1), + ("GraphQL/allOther", 1), + ("GraphQL/GraphQL/all", 1), + ("GraphQL/GraphQL/allOther", 1), +] + + +def test_basic(app, graphql_run): + from graphql import __version__ as version + + FRAMEWORK_METRICS = [ + ("Python/Framework/GraphQL/%s" % version, 1), + ] + + @validate_transaction_metrics( + "query//hello", + "GraphQL", + rollup_metrics=_graphql_base_rollup_metrics + FRAMEWORK_METRICS, + background_task=True, + ) + @background_task() + def _test(): + response = graphql_run(app, '{ hello }') + assert not response.errors + + _test() + + +@dt_enabled +def test_basic(app, graphql_run): + from graphql import __version__ as version + + FRAMEWORK_METRICS = [ + ("Python/Framework/GraphQL/%s" % version, 1), + ] + _test_mutation_scoped_metrics = [ + ("GraphQL/resolve/GraphQL/storage", 1), + ("GraphQL/resolve/GraphQL/storage_add", 1), + ("GraphQL/operation/GraphQL/query//storage", 1), + ("GraphQL/operation/GraphQL/mutation//storage_add.string", 1), + ] + _test_mutation_unscoped_metrics = [ + ("OtherTransaction/all", 1), + ("GraphQL/all", 2), + ("GraphQL/GraphQL/all", 2), + ("GraphQL/allOther", 2), + ("GraphQL/GraphQL/allOther", 2), + ] + _test_mutation_scoped_metrics + + _expected_mutation_operation_attributes = { + "graphql.operation.type": "mutation", + "graphql.operation.name": "", + } + _expected_mutation_resolver_attributes = { + "graphql.field.name": "storage_add", + "graphql.field.parentType": "Mutation", + "graphql.field.path": "storage_add", + "graphql.field.returnType": "StorageAdd", + } + _expected_query_operation_attributes = { + "graphql.operation.type": "query", + "graphql.operation.name": "", + } + _expected_query_resolver_attributes = { + "graphql.field.name": "storage", + "graphql.field.parentType": "Query", + "graphql.field.path": "storage", + "graphql.field.returnType": "[String]", + } + + @validate_transaction_metrics( + "query//storage", + "GraphQL", + scoped_metrics=_test_mutation_scoped_metrics, + rollup_metrics=_test_mutation_unscoped_metrics + FRAMEWORK_METRICS, + background_task=True, + ) + @validate_span_events(exact_agents=_expected_mutation_operation_attributes) + @validate_span_events(exact_agents=_expected_mutation_resolver_attributes) + @validate_span_events(exact_agents=_expected_query_operation_attributes) + @validate_span_events(exact_agents=_expected_query_resolver_attributes) + @background_task() + def _test(): + response = graphql_run(app, 'mutation { storage_add(string: "abc") { string } }') + assert not response.errors + response = graphql_run(app, "query { storage }") + assert not response.errors + + # These are separate assertions because pypy stores 'abc' as a unicode string while other Python versions do not + assert "storage" in str(response.data) + assert "abc" in str(response.data) + + _test() + + +@dt_enabled +def test_middleware(app, graphql_run, is_graphql_2): + _test_middleware_metrics = [ + ("GraphQL/operation/GraphQL/query//hello", 1), + ("GraphQL/resolve/GraphQL/hello", 1), + ("Function/test_application:example_middleware", 1), + ] + + @validate_transaction_metrics( + "query//hello", + "GraphQL", + scoped_metrics=_test_middleware_metrics, + rollup_metrics=_test_middleware_metrics + _graphql_base_rollup_metrics, + background_task=True, + ) + # Span count 4: Transaction, Operation, Middleware, and 1 Resolver + @validate_span_events(count=4) + @background_task() + def _test(): + response = graphql_run(app, "{ hello }", middleware=[example_middleware]) + assert not response.errors + assert "Hello!" in str(response.data) + + _test() + + +@dt_enabled +def test_exception_in_middleware(app, graphql_run): + query = "query MyQuery { hello }" + field = "hello" + + # Metrics + _test_exception_scoped_metrics = [ + ("GraphQL/operation/GraphQL/query/MyQuery/%s" % field, 1), + ("GraphQL/resolve/GraphQL/%s" % field, 1), + ] + _test_exception_rollup_metrics = [ + ("Errors/all", 1), + ("Errors/allOther", 1), + ("Errors/OtherTransaction/GraphQL/test_application:error_middleware", 1), + ] + _test_exception_scoped_metrics + + # Attributes + _expected_exception_resolver_attributes = { + "graphql.field.name": field, + "graphql.field.parentType": "Query", + "graphql.field.path": field, + "graphql.field.returnType": "String", + } + _expected_exception_operation_attributes = { + "graphql.operation.type": "query", + "graphql.operation.name": "MyQuery", + "graphql.operation.query": query, + } + + @validate_transaction_metrics( + "test_application:error_middleware", + "GraphQL", + scoped_metrics=_test_exception_scoped_metrics, + rollup_metrics=_test_exception_rollup_metrics + _graphql_base_rollup_metrics, + background_task=True, + ) + @validate_span_events(exact_agents=_expected_exception_operation_attributes) + @validate_span_events(exact_agents=_expected_exception_resolver_attributes) + @validate_transaction_errors(errors=_test_runtime_error) + @background_task() + def _test(): + response = graphql_run(app, query, middleware=[error_middleware]) + assert response.errors + + _test() + + +@pytest.mark.parametrize("field", ("error", "error_non_null")) +@dt_enabled +def test_exception_in_resolver(app, graphql_run, field): + query = "query MyQuery { %s }" % field + + if six.PY2: + txn_name = "_target_application:resolve_error" + else: + txn_name = "_target_application:Query.resolve_error" + + # Metrics + _test_exception_scoped_metrics = [ + ("GraphQL/operation/GraphQL/query/MyQuery/%s" % field, 1), + ("GraphQL/resolve/GraphQL/%s" % field, 1), + ] + _test_exception_rollup_metrics = [ + ("Errors/all", 1), + ("Errors/allOther", 1), + ("Errors/OtherTransaction/GraphQL/%s" % txn_name, 1), + ] + _test_exception_scoped_metrics + + # Attributes + _expected_exception_resolver_attributes = { + "graphql.field.name": field, + "graphql.field.parentType": "Query", + "graphql.field.path": field, + "graphql.field.returnType": "String!" if "non_null" in field else "String", + } + _expected_exception_operation_attributes = { + "graphql.operation.type": "query", + "graphql.operation.name": "MyQuery", + "graphql.operation.query": query, + } + + @validate_transaction_metrics( + txn_name, + "GraphQL", + scoped_metrics=_test_exception_scoped_metrics, + rollup_metrics=_test_exception_rollup_metrics + _graphql_base_rollup_metrics, + background_task=True, + ) + @validate_span_events(exact_agents=_expected_exception_operation_attributes) + @validate_span_events(exact_agents=_expected_exception_resolver_attributes) + @validate_transaction_errors(errors=_test_runtime_error) + @background_task() + def _test(): + response = graphql_run(app, query) + assert response.errors + + _test() + + +@dt_enabled +@pytest.mark.parametrize( + "query,exc_class", + [ + ("query MyQuery { missing_field }", "GraphQLError"), + ("{ syntax_error ", "graphql.error.syntax_error:GraphQLSyntaxError"), + ], +) +def test_exception_in_validation(app, graphql_run, is_graphql_2, query, exc_class): + if "syntax" in query: + txn_name = "graphql.language.parser:parse" + else: + if is_graphql_2: + txn_name = "graphql.validation.validation:validate" + else: + txn_name = "graphql.validation.validate:validate" + + # Import path differs between versions + if exc_class == "GraphQLError": + from graphql.error import GraphQLError + + exc_class = callable_name(GraphQLError) + + _test_exception_scoped_metrics = [ + # ('GraphQL/operation/GraphQL///', 1), + ] + _test_exception_rollup_metrics = [ + ("Errors/all", 1), + ("Errors/allOther", 1), + ("Errors/OtherTransaction/GraphQL/%s" % txn_name, 1), + ] + _test_exception_scoped_metrics + + # Attributes + _expected_exception_operation_attributes = { + "graphql.operation.type": "", + "graphql.operation.name": "", + "graphql.operation.query": query, + } + + @validate_transaction_metrics( + txn_name, + "GraphQL", + scoped_metrics=_test_exception_scoped_metrics, + rollup_metrics=_test_exception_rollup_metrics + _graphql_base_rollup_metrics, + background_task=True, + ) + @validate_span_events(exact_agents=_expected_exception_operation_attributes) + @validate_transaction_errors(errors=[exc_class]) + @background_task() + def _test(): + response = graphql_run(app, query) + assert response.errors + + _test() + + +@dt_enabled +def test_operation_metrics_and_attrs(app, graphql_run): + operation_metrics = [("GraphQL/operation/GraphQL/query/MyQuery/library", 1)] + operation_attrs = { + "graphql.operation.type": "query", + "graphql.operation.name": "MyQuery", + } + + @validate_transaction_metrics( + "query/MyQuery/library", + "GraphQL", + scoped_metrics=operation_metrics, + rollup_metrics=operation_metrics + _graphql_base_rollup_metrics, + background_task=True, + ) + # Span count 7: Transaction, Operation, and 7 Resolvers + # library, library.name, library.book + # library.book.name and library.book.id for each book resolved (in this case 2) + @validate_span_events(count=9) + @validate_span_events(exact_agents=operation_attrs) + @background_task() + def _test(): + response = graphql_run( + app, "query MyQuery { library(index: 0) { branch, book { id, name } } }" + ) + assert not response.errors + + _test() + + +@dt_enabled +def test_field_resolver_metrics_and_attrs(app, graphql_run): + field_resolver_metrics = [("GraphQL/resolve/GraphQL/hello", 1)] + graphql_attrs = { + "graphql.field.name": "hello", + "graphql.field.parentType": "Query", + "graphql.field.path": "hello", + "graphql.field.returnType": "String", + } + + @validate_transaction_metrics( + "query//hello", + "GraphQL", + scoped_metrics=field_resolver_metrics, + rollup_metrics=field_resolver_metrics + _graphql_base_rollup_metrics, + background_task=True, + ) + # Span count 3: Transaction, Operation, and 1 Resolver + @validate_span_events(count=3) + @validate_span_events(exact_agents=graphql_attrs) + @background_task() + def _test(): + response = graphql_run(app, "{ hello }") + assert not response.errors + assert "Hello!" in str(response.data) + + _test() + + +_test_queries = [ + ("{ hello }", "{ hello }"), # Basic query extraction + ("{ error }", "{ error }"), # Extract query on field error + (to_graphql_source("{ hello }"), "{ hello }"), # Extract query from Source objects + ("{ library(index: 0) { branch } }", "{ library(index: ?) { branch } }"), # Integers + ('{ echo(echo: "123") }', "{ echo(echo: ?) }"), # Strings with numerics + ('{ echo(echo: "test") }', "{ echo(echo: ?) }"), # Strings + ('{ TestEcho: echo(echo: "test") }', "{ TestEcho: echo(echo: ?) }"), # Aliases + ('{ TestEcho: echo(echo: "test") }', "{ TestEcho: echo(echo: ?) }"), # Variables + ( # Fragments + '{ ...MyFragment } fragment MyFragment on Query { echo(echo: "test") }', + "{ ...MyFragment } fragment MyFragment on Query { echo(echo: ?) }", + ), +] + + +@dt_enabled +@pytest.mark.parametrize("query,obfuscated", _test_queries) +def test_query_obfuscation(app, graphql_run, query, obfuscated): + graphql_attrs = {"graphql.operation.query": obfuscated} + + if callable(query): + query = query() + + @validate_span_events(exact_agents=graphql_attrs) + @background_task() + def _test(): + response = graphql_run(app, query) + if not isinstance(query, str) or "error" not in query: + assert not response.errors + + _test() + + +_test_queries = [ + ("{ hello }", "/hello"), # Basic query + ("{ error }", "/error"), # Extract deepest path on field error + ('{ echo(echo: "test") }', "/echo"), # Fields with arguments + ( + "{ library(index: 0) { branch, book { isbn branch } } }", + "/library", + ), # Complex Example, 1 level + ( + "{ library(index: 0) { book { author { first_name }} } }", + "/library.book.author.first_name", + ), # Complex Example, 2 levels + ("{ library(index: 0) { id, book { name } } }", "/library.book.name"), # Filtering + ('{ TestEcho: echo(echo: "test") }', "/echo"), # Aliases + ( + '{ search(contains: "A") { __typename ... on Book { name } } }', + "/search.name", + ), # InlineFragment + ( + '{ hello echo(echo: "test") }', + "", + ), # Multiple root selections. (need to decide on final behavior) + # FragmentSpread + ( + "{ library(index: 0) { book { ...MyFragment } } } fragment MyFragment on Book { name id }", # Fragment filtering + "/library.book.name", + ), + ( + "{ library(index: 0) { book { ...MyFragment } } } fragment MyFragment on Book { author { first_name } }", + "/library.book.author.first_name", + ), + ( + "{ library(index: 0) { book { ...MyFragment } magazine { ...MagFragment } } } fragment MyFragment on Book { author { first_name } } fragment MagFragment on Magazine { name }", + "/library", + ), +] + + +@dt_enabled +@pytest.mark.parametrize("query,expected_path", _test_queries) +def test_deepest_unique_path(app, graphql_run, query, expected_path): + if expected_path == "/error": + if six.PY2: + txn_name = "_target_application:resolve_error" + else: + txn_name = "_target_application:Query.resolve_error" + else: + txn_name = "query/%s" % expected_path + + @validate_transaction_metrics( + txn_name, + "GraphQL", + background_task=True, + ) + @background_task() + def _test(): + response = graphql_run(app, query) + if "error" not in query: + assert not response.errors + + _test() + + +@validate_transaction_count(0) +@background_task() +def test_ignored_introspection_transactions(app, graphql_run): + response = graphql_run(app, "{ __schema { types { name } } }") + assert not response.errors diff --git a/tests/framework_graphql/_target_application.py b/tests/framework_graphql/_target_application.py index 999ca226d7..69b669dec4 100644 --- a/tests/framework_graphql/_target_application.py +++ b/tests/framework_graphql/_target_application.py @@ -137,7 +137,7 @@ def resolve_search(parent, info, contains): "Library", { "id": GraphQLField(GraphQLInt), - "name": GraphQLField(GraphQLString), + "branch": GraphQLField(GraphQLString), "book": GraphQLField(GraphQLList(Book)), "magazine": GraphQLField(GraphQLList(Magazine)), }, diff --git a/tests/framework_graphql/test_application.py b/tests/framework_graphql/test_application.py index b5f7a13e33..ccec3231e5 100644 --- a/tests/framework_graphql/test_application.py +++ b/tests/framework_graphql/test_application.py @@ -85,8 +85,29 @@ def error_middleware(next, root, info, **args): ] +def test_basic(app, graphql_run): + from graphql import __version__ as version + + FRAMEWORK_METRICS = [ + ("Python/Framework/GraphQL/%s" % version, 1), + ] + + @validate_transaction_metrics( + "query//hello", + "GraphQL", + rollup_metrics=_graphql_base_rollup_metrics + FRAMEWORK_METRICS, + background_task=True, + ) + @background_task() + def _test(): + response = graphql_run(app, '{ hello }') + assert not response.errors + + _test() + + @dt_enabled -def test_basic(app, graphql_run, is_graphql_2): +def test_query_and_mutation(app, graphql_run, is_graphql_2): from graphql import __version__ as version FRAMEWORK_METRICS = [ @@ -353,7 +374,7 @@ def test_operation_metrics_and_attrs(app, graphql_run): @background_task() def _test(): response = graphql_run( - app, "query MyQuery { library(index: 0) { name, book { id, name } } }" + app, "query MyQuery { library(index: 0) { branch, book { id, name } } }" ) assert not response.errors @@ -393,7 +414,7 @@ def _test(): ("{ hello }", "{ hello }"), # Basic query extraction ("{ error }", "{ error }"), # Extract query on field error (to_graphql_source("{ hello }"), "{ hello }"), # Extract query from Source objects - ("{ library(index: 0) { name } }", "{ library(index: ?) { name } }"), # Integers + ("{ library(index: 0) { branch } }", "{ library(index: ?) { branch } }"), # Integers ('{ echo(echo: "123") }', "{ echo(echo: ?) }"), # Strings with numerics ('{ echo(echo: "test") }', "{ echo(echo: ?) }"), # Strings ('{ TestEcho: echo(echo: "test") }', "{ TestEcho: echo(echo: ?) }"), # Aliases @@ -428,7 +449,7 @@ def _test(): ("{ error }", "/error"), # Extract deepest path on field error ('{ echo(echo: "test") }', "/echo"), # Fields with arguments ( - "{ library(index: 0) { name, book { isbn branch } } }", + "{ library(index: 0) { branch, book { isbn branch } } }", "/library", ), # Complex Example, 1 level ( diff --git a/tests/framework_graphql/test_application_async.py b/tests/framework_graphql/test_application_async.py index c49082d296..7bbd2bbe80 100644 --- a/tests/framework_graphql/test_application_async.py +++ b/tests/framework_graphql/test_application_async.py @@ -2,7 +2,6 @@ import pytest from testing_support.fixtures import ( dt_enabled, - validate_transaction_errors, validate_transaction_metrics, ) from testing_support.validators.validate_span_events import validate_span_events @@ -27,7 +26,7 @@ def graphql_run(*args, **kwargs): @dt_enabled -def test_basic_async(app, graphql_run_async, is_graphql_2): +def test_query_and_mutation_async(app, graphql_run_async, is_graphql_2): from graphql import __version__ as version FRAMEWORK_METRICS = [ diff --git a/tox.ini b/tox.ini index ceb32526bf..3db3d6dd5d 100644 --- a/tox.ini +++ b/tox.ini @@ -113,8 +113,10 @@ envlist = python-framework_flask-{pypy,py27}-flask0012, python-framework_flask-{pypy,py27,py36,py37,py38,pypy3}-flask0101, python-framework_flask-{py37,py38,pypy3}-flask{latest,master}, + python-framework_graphene-{py27,py36,py37,py38,py39,pypy,pypy3}-graphenelatest, + python-framework_graphene-py37-graphene{0200,0201}, python-framework_graphql-{py27,py36,py37,py38,pypy,pypy3}-graphql02, - python-framework_graphql-{py36,py37,py38,py39,pypy3}-graphql{03,master}, + python-framework_graphql-{py36,py37,py38,py39,pypy3}-graphql03, python-framework_graphql-py37-graphql{0202,0203,0300,0301,master}, grpc-framework_grpc-{py27,py36}-grpc0125, grpc-framework_grpc-{py27,py36,py37,py38,py39}-grpclatest, @@ -234,12 +236,17 @@ deps = framework_falcon-falcon0200: falcon<2.1 framework_falcon-falconmaster: https://github.com/falconry/falcon/archive/master.zip framework_fastapi: fastapi + framework_fastapi: graphene + framework_fastapi: asyncio framework_flask: Flask-Compress framework_flask-flask0012: flask<0.13 framework_flask-flask0101: flask<1.2 framework_flask-flasklatest: flask[async] framework_flask-flaskmaster: https://github.com/pallets/werkzeug/archive/main.zip framework_flask-flaskmaster: https://github.com/pallets/flask/archive/main.zip#egg=flask[async] + framework_graphene-graphenelatest: graphene + framework_graphene-graphene0200: graphene<2.1 + framework_graphene-graphene0201: graphene<2.2 framework_graphql-graphql02: graphql-core<3 framework_graphql-graphql03: graphql-core<4 framework_graphql-graphql0202: graphql-core<2.3 @@ -355,6 +362,7 @@ changedir = framework_falcon: tests/framework_falcon framework_fastapi: tests/framework_fastapi framework_flask: tests/framework_flask + framework_graphene: tests/framework_graphene framework_graphql: tests/framework_graphql framework_grpc: tests/framework_grpc framework_pyramid: tests/framework_pyramid