diff --git a/docs/guide/mutations.md b/docs/guide/mutations.md index adaaf325..e86965e3 100644 --- a/docs/guide/mutations.md +++ b/docs/guide/mutations.md @@ -61,6 +61,9 @@ type OperationMessage { The field that caused the error, or `null` if it isn't associated with any particular field. """ field: String + + """The error code, or `null` if no error code was set.""" + code: String } type Fruit { diff --git a/strawberry_django/fields/types.py b/strawberry_django/fields/types.py index 7456c3f6..c58720e2 100644 --- a/strawberry_django/fields/types.py +++ b/strawberry_django/fields/types.py @@ -178,6 +178,10 @@ class Kind(enum.Enum): ), default=None, ) + code: Optional[str] = strawberry.field( + description="The error code, or `null` if no error code was set.", + default=None, + ) def __eq__(self, other: Self): if not isinstance(other, OperationMessage): @@ -187,10 +191,11 @@ def __eq__(self, other: Self): self.kind == other.kind and self.message == other.message and self.field == other.field + and self.code == other.code ) def __hash__(self): - return hash((self.__class__, self.kind, self.message, self.field)) + return hash((self.__class__, self.kind, self.message, self.field, self.code)) @strawberry.type diff --git a/strawberry_django/mutations/fields.py b/strawberry_django/mutations/fields.py index be204aa3..18f1d8b0 100644 --- a/strawberry_django/mutations/fields.py +++ b/strawberry_django/mutations/fields.py @@ -52,12 +52,13 @@ def _get_validation_errors(error: Exception): if isinstance(error, ValidationError) and hasattr(error, "error_dict"): # convert field errors - for field, field_errors in error.message_dict.items(): + for field, field_errors in error.error_dict.items(): for e in field_errors: yield OperationMessage( kind=kind, field=to_camel_case(field) if field != NON_FIELD_ERRORS else None, - message=e, + message=e.message % e.params if e.params else e.message, + code=getattr(e, "code", None), ) elif isinstance(error, ValidationError) and hasattr(error, "error_list"): # convert non-field errors @@ -65,6 +66,7 @@ def _get_validation_errors(error: Exception): yield OperationMessage( kind=kind, message=e.message % e.params if e.params else e.message, + code=getattr(error, "code", None), ) else: msg = getattr(error, "msg", None) @@ -74,6 +76,7 @@ def _get_validation_errors(error: Exception): yield OperationMessage( kind=kind, message=msg, + code=getattr(error, "code", None), ) diff --git a/tests/projects/schema.py b/tests/projects/schema.py index ac88473c..ce1797be 100644 --- a/tests/projects/schema.py +++ b/tests/projects/schema.py @@ -402,15 +402,29 @@ def create_project( ) -> ProjectType: """Create project documentation.""" if cost > 500: + # Field error without error code: raise ValidationError({"cost": "Cost cannot be higher than 500"}) + if cost < 0: + # Field error with error code: + raise ValidationError( + { + "cost": ValidationError( + "Cost cannot be lower than zero", + code="min_cost", + ), + }, + ) + project = Project( + name=name, + cost=cost, + due_date=due_date, + ) + project.full_clean() + project.save() return cast( ProjectType, - Project.objects.create( - name=name, - cost=cost, - due_date=due_date, - ), + project, ) @mutations.input_mutation(handle_django_errors=True) diff --git a/tests/projects/snapshots/schema.gql b/tests/projects/snapshots/schema.gql index 6e64b5b4..9fd36e08 100644 --- a/tests/projects/snapshots/schema.gql +++ b/tests/projects/snapshots/schema.gql @@ -380,6 +380,9 @@ type OperationMessage { The field that caused the error, or `null` if it isn't associated with any particular field. """ field: String + + """The error code, or `null` if no error code was set.""" + code: String } enum OperationMessageKind { diff --git a/tests/test_input_mutations.py b/tests/test_input_mutations.py index 72b3a802..97e22f8d 100644 --- a/tests/test_input_mutations.py +++ b/tests/test_input_mutations.py @@ -43,7 +43,88 @@ def test_input_mutation(db, gql_client: GraphQLTestClient): # The cost is properly set, but this user doesn't have # permission to see it "cost": None, - "dueDate": "2030-01-01T00:00:00", + "dueDate": "2030-01-01", + }, + } + + +@pytest.mark.django_db(transaction=True) +def test_input_mutation_with_internal_error_code(db, gql_client: GraphQLTestClient): + query = """ + mutation CreateProject ($input: CreateProjectInput!) { + createProject (input: $input) { + ... on ProjectType { + name + cost + } + ... on OperationInfo { + messages { + field + message + kind + code + } + } + } + } + """ + with assert_num_queries(0): + res = gql_client.query( + query, + {"input": {"name": 100 * "way to long", "cost": "10.40"}}, + ) + assert res.data == { + "createProject": { + "messages": [ + { + "field": "name", + "kind": "VALIDATION", + "message": ( + "Ensure this value has at most 255 characters (it has" + " 1100)." + ), + "code": "max_length", + }, + ], + }, + } + + +@pytest.mark.django_db(transaction=True) +def test_input_mutation_with_explicit_error_code(db, gql_client: GraphQLTestClient): + query = """ + mutation CreateProject ($input: CreateProjectInput!) { + createProject (input: $input) { + ... on ProjectType { + name + cost + } + ... on OperationInfo { + messages { + field + message + kind + code + } + } + } + } + """ + with assert_num_queries(0): + res = gql_client.query( + query, + {"input": {"name": "Some Project", "cost": "-1"}}, + ) + assert res.data == { + "createProject": { + "messages": [ + { + "field": "cost", + "kind": "VALIDATION", + "message": "Cost cannot be lower than zero", + "code": "min_cost", + }, + ], }, } @@ -62,6 +143,7 @@ def test_input_mutation_with_errors(db, gql_client: GraphQLTestClient): field message kind + code } } } @@ -79,6 +161,7 @@ def test_input_mutation_with_errors(db, gql_client: GraphQLTestClient): "field": "cost", "kind": "VALIDATION", "message": "Cost cannot be higher than 500", + "code": None, }, ], },