Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
release type: minor
social_messages:
x: >-
{project_name} {version} is out! This release adds a
`mask_pre_execution_errors` option to `MaskErrors`, so clients can still see
why their query is invalid. 🍓 https://strawberry.rocks/release/{version}
linkedin: >-
{project_name} {version} is out. This release adds a
`mask_pre_execution_errors` option to the `MaskErrors` extension, so syntax
and validation errors can reach clients while the errors your resolvers
raise stay masked.
---

This release adds a `mask_pre_execution_errors` option to the `MaskErrors` extension.

`MaskErrors` masks every error. This includes the syntax errors of a document and the validation errors against the schema, so a client that sends an invalid query gets only the generic message. Set the new option to `False` to send these errors to the client. The errors that your resolvers raise stay masked:

```python
import strawberry
from strawberry.extensions import MaskErrors


@strawberry.type
class Query:
@strawberry.field
def hello(self) -> str:
return "world"

@strawberry.field
def hidden_error(self) -> str:
raise KeyError("This error will not be visible")


schema = strawberry.Schema(
Query,
extensions=[
lambda: MaskErrors(mask_pre_execution_errors=False),
],
)

# "Cannot query field 'helloo' on type 'Query'. Did you mean 'hello'?"
schema.execute_sync("{ helloo }")

# "Unexpected error."
schema.execute_sync("{ hiddenError }")
```

The default value is `True`, which keeps the current behaviour. Validation errors give the names of the fields, the arguments and the types of your schema. Thus disable the option only if the clients can know the shape of the schema.
60 changes: 59 additions & 1 deletion docs/extensions/mask-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ schema = strawberry.Schema(

```python
class MaskErrors(
should_mask_error=default_should_mask_error, error_message="Unexpected error."
should_mask_error=default_should_mask_error,
error_message="Unexpected error.",
mask_pre_execution_errors=True,
): ...
```

Expand All @@ -55,6 +57,13 @@ The `default_should_mask_error` function always returns `True`.

The error message to display to the client when there is an error.

#### `mask_pre_execution_errors: bool = True`

Mask the errors that occur before the execution step. These are the syntax
errors of a document and the validation errors against the schema. Set the
option to `False` to send these errors to the client. The extension continues to
mask the errors that resolvers raise.

## More examples:

<details>
Expand Down Expand Up @@ -119,3 +128,52 @@ schema = strawberry.Schema(
```

</details>

<details>
<summary>Keep syntax and validation errors visible</summary>

By default, the extension also masks the errors that occur before execution. A
client that sends a malformed document thus gets only the generic message. Set
`mask_pre_execution_errors` to `False` to send these errors to the client. The
errors that resolvers raise stay masked.

```python
import strawberry
from strawberry.extensions import MaskErrors


@strawberry.type
class Query:
@strawberry.field
def hello(self) -> str:
return "world"

@strawberry.field
def hidden_error(self) -> str:
raise KeyError("This error will not be visible")


schema = strawberry.Schema(
Query,
extensions=[
lambda: MaskErrors(mask_pre_execution_errors=False),
],
)

# "Cannot query field 'helloo' on type 'Query'. Did you mean 'hello'?"
schema.execute_sync("{ helloo }")

# "Unexpected error."
schema.execute_sync("{ hiddenError }")
```

<Note>

Validation errors give the names of the fields, the arguments and the types of
your schema. They can also suggest a name that is close to the name that the
client sent. Disable this option only if the clients can know the shape of the
schema.

</Note>

</details>
49 changes: 48 additions & 1 deletion strawberry/extensions/mask_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,31 @@ def default_should_mask_error(_: GraphQLError) -> bool:
class MaskErrors(SchemaExtension):
should_mask_error: Callable[[GraphQLError], bool]
error_message: str
mask_pre_execution_errors: bool

def __init__(
self,
should_mask_error: Callable[[GraphQLError], bool] = default_should_mask_error,
error_message: str = "Unexpected error.",
mask_pre_execution_errors: bool = True,
) -> None:
"""Initialize the MaskErrors extension.

Args:
should_mask_error: A function that tells if the extension must mask
an error. Use the `original_error` attribute to examine the
error that the resolver raised.
error_message: The message that the client gets for a masked error.
mask_pre_execution_errors: Mask the errors of the parse step and of
the validation step. Set it to `False` to send the syntax errors
and the validation errors of a document to the client. The
extension continues to mask the errors of the execution step.
"""
self.should_mask_error = should_mask_error
self.error_message = error_message
self.mask_pre_execution_errors = mask_pre_execution_errors
self._stream_result_processed = False
self._in_pre_execution_phase = False

def anonymise_error(self, error: GraphQLError) -> GraphQLError:
return GraphQLError(
Expand All @@ -55,6 +71,17 @@ def _process_errors(self, errors: list[GraphQLError]) -> list[GraphQLError]:

return processed_errors

@property
def _masking_enabled(self) -> bool:
"""Return `True` if the extension must mask the errors of this phase.

Parse errors and validation errors describe the document that the client
sent, and they can show the shape of the schema. This is a different
concern from the errors that resolvers raise. Thus the extension masks
them only when `mask_pre_execution_errors` is `True`.
"""
return self.mask_pre_execution_errors or not self._in_pre_execution_phase

def _process_result(self, result: object) -> None:
if isinstance(result, _ResultWithErrors) and result.errors:
result.errors = self._process_errors(result.errors)
Expand All @@ -68,15 +95,32 @@ def _process_stream_result(self, result: StreamExecutionResult) -> None:
for completed_result in getattr(result, "completed", None) or ():
self._process_result(completed_result)

def on_parse(self) -> Iterator[None]:
self._in_pre_execution_phase = True
yield

def on_execute(self) -> Iterator[None]:
# The parse step and the validation step always run before this hook.
# Thus an operation that comes here did not fail in those steps.
self._in_pre_execution_phase = False
Comment thread
alimony marked this conversation as resolved.
Outdated
yield

def on_operation(self) -> Iterator[None]:
self._stream_result_processed = False
# An error that occurs before the parse step, a missing query for
# example, does not come from the document. The extension always masks
# these errors.
self._in_pre_execution_phase = False
yield

# Streaming operations are handled result-by-result before each frame is
# yielded. Avoid processing the last result again when the stream closes.
if self._stream_result_processed:
return

if not self._masking_enabled:
return

result = self.execution_context.result

if isinstance(result, (GraphQLExecutionResult, StrawberryExecutionResult)):
Expand All @@ -87,5 +131,8 @@ def on_operation(self) -> Iterator[None]:
def on_stream_result(self, result: StreamExecutionResult) -> Iterator[None]:
"""Mask errors before a streamed execution result reaches the client."""
self._stream_result_processed = True
self._process_stream_result(result)

if self._masking_enabled:
self._process_stream_result(result)

yield None
75 changes: 75 additions & 0 deletions tests/schema/extensions/test_mask_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,30 @@
from strawberry.extensions import MaskErrors, ValidationCache


@strawberry.type
class Query:
"""Shared schema for the `mask_pre_execution_errors` tests."""

@strawberry.field
def test_field(self) -> str:
return "TestField"

@strawberry.field
def hidden_error(self) -> str:
raise KeyError("This error is not visible")


# What `MaskErrors(mask_pre_execution_errors=False)` does with a document that
# fails to parse, with one that fails to validate, and with one that makes a
# resolver raise: the message the client gets, and how many times the extension
# asks `should_mask_error` about it.
KEEP_PRE_EXECUTION_ERRORS = [
("query { testField( }", "Syntax Error: Expected Name, found '}'.", 0),
("query { missingField }", "Cannot query field 'missingField' on type 'Query'.", 0),
("query { hiddenError }", "Unexpected error.", 1),
]


@pytest.mark.parametrize(
"query",
[
Expand Down Expand Up @@ -314,3 +338,54 @@ def should_mask_error(error: GraphQLError) -> bool:
"path": ["visibleError"],
}
]


@pytest.mark.parametrize(
("query", "expected_message", "expected_calls"), KEEP_PRE_EXECUTION_ERRORS
)
def test_keep_pre_execution_errors_sync(
query: str, expected_message: str, expected_calls: int
):
should_mask_error = Mock(return_value=True)
schema = strawberry.Schema(
query=Query,
extensions=[
lambda: MaskErrors(
should_mask_error=should_mask_error,
mask_pre_execution_errors=False,
)
],
)

result = schema.execute_sync(query)

assert result.data is None
assert result.errors is not None
assert [error.message for error in result.errors] == [expected_message]
assert should_mask_error.call_count == expected_calls


@pytest.mark.asyncio
@pytest.mark.parametrize(
("query", "expected_message", "expected_calls"), KEEP_PRE_EXECUTION_ERRORS
)
async def test_keep_pre_execution_errors_async(
query: str, expected_message: str, expected_calls: int
):
should_mask_error = Mock(return_value=True)
schema = strawberry.Schema(
query=Query,
extensions=[
lambda: MaskErrors(
should_mask_error=should_mask_error,
mask_pre_execution_errors=False,
)
],
)

result = await schema.execute(query)

assert result.data is None
assert result.errors is not None
assert [error.message for error in result.errors] == [expected_message]
assert should_mask_error.call_count == expected_calls
44 changes: 44 additions & 0 deletions tests/schema/extensions/test_stream_result.py
Original file line number Diff line number Diff line change
Expand Up @@ -306,3 +306,47 @@ async def test_mask_errors_before_streaming_pre_execution_error(
assert [error.message for error in result.errors] == ["Unexpected error."]
finally:
await results.aclose()


async def first_stream_result(
schema: strawberry.Schema, operation: str | None
) -> ExecutionResult:
"""Return the first frame of a stream, then close the stream."""
results = await schema.stream(operation)
try:
result = await anext(results)
assert isinstance(result, ExecutionResult)
assert result.errors
return result
finally:
await results.aclose()


@pytest.mark.asyncio
async def test_keep_pre_execution_errors_before_streaming() -> None:
schema = strawberry.Schema(
query=Query,
extensions=[lambda: MaskErrors(mask_pre_execution_errors=False)],
)

kept = await first_stream_result(schema, "{ missingField }")
assert [error.message for error in kept.errors] == [
"Cannot query field 'missingField' on type 'Query'."
]

masked = await first_stream_result(schema, "{ dangerousQuery }")
assert [error.message for error in masked.errors] == ["Unexpected error."]


@pytest.mark.asyncio
async def test_mask_errors_forgets_the_phase_of_the_previous_operation() -> None:
"""An extension instance can be shared between operations."""
extension = MaskErrors(mask_pre_execution_errors=False)
schema = strawberry.Schema(query=Query, extensions=[lambda: extension])

# This operation stops in the validation step.
await first_stream_result(schema, "{ missingField }")

# This one stops before the parse step, so the extension must mask it.
result = await first_stream_result(schema, None)
assert [error.message for error in result.errors] == ["Unexpected error."]
Loading