Repository: saleor/saleor
Pull request: Add our implementation of UUID Scalar
I want to merge this change because...
This PR gives us control over UUID scalar and changes thrown errors to `GraphQLError` when provided a wrong value for the token.

<!-- Please mention all relevant issue numbers. -->
Fixes #5008 

# Impact

* [ ] New migrations
* [ ] New/Updated API fields or mutations
* [ ] Deprecated API fields or mutations
* [ ] Removed API types, fields, or mutations

# Pull Request Checklist

<!-- Please keep this section. It will make maintainer's life easier. -->

* [ ] Privileged queries and mutations are guarded by proper permission checks
* [ ] Database queries are optimized and the number of queries is constant
* [ ] Database migration files are up to date
* [ ] The changes are tested
* [ ] GraphQL schema and type definitions are up to date
* [ ] Changes are mentioned in the changelog


Problem statement:
Resolving a checkout by providing an invalid UUID should be handled
When running a query like the following:

```
{
  checkout(token: "Best-UUID") {
    created
  }
}
```

A `ValueError` will occur. We should whether return `None` or raise a validation error.

Note: we need to update the other queries and mutations that are using the `UUID` scalar.

Hints from issue discussion:

Commit message: Fix CHANGELOG

Patch to review:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 32711b97671..9e5e6302779 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,7 +4,10 @@ All notable, unreleased changes to this project will be documented in this file.
 
 ## [Unreleased]
 
+- Add our implementation of UUID scalar - #5646 by @koradon
+
 ## 2.10.0
+
 - OpenTracing support - #5188 by @tomaszszymanski129
 - Account confirmation email - #5126 by @tomaszszymanski129
 - Relocate `Checkout` and `CheckoutLine` methods into separate module and update checkout related plugins to use them - #4980 by @krzysztofwolski
diff --git a/saleor/graphql/checkout/schema.py b/saleor/graphql/checkout/schema.py
index afbebc062a3..4d49e063827 100644
--- a/saleor/graphql/checkout/schema.py
+++ b/saleor/graphql/checkout/schema.py
@@ -2,6 +2,7 @@
 
 from ...core.permissions import CheckoutPermissions
 from ..core.fields import BaseDjangoConnectionField, PrefetchingConnectionField
+from ..core.scalars import UUID
 from ..decorators import permission_required
 from ..payment.mutations import CheckoutPaymentCreate
 from .mutations import (
@@ -31,7 +32,7 @@ class CheckoutQueries(graphene.ObjectType):
     checkout = graphene.Field(
         Checkout,
         description="Look up a checkout by token.",
-        token=graphene.Argument(graphene.UUID, description="The checkout's token."),
+        token=graphene.Argument(UUID, description="The checkout's token."),
     )
     # FIXME we could optimize the below field
     checkouts = BaseDjangoConnectionField(Checkout, description="List of checkouts.")
diff --git a/saleor/graphql/checkout/types.py b/saleor/graphql/checkout/types.py
index 79a4e176a3e..30fb50b5270 100644
--- a/saleor/graphql/checkout/types.py
+++ b/saleor/graphql/checkout/types.py
@@ -8,6 +8,7 @@
 from ...core.taxes import display_gross_prices, zero_taxed_money
 from ...plugins.manager import get_plugins_manager
 from ..core.connection import CountableDjangoObjectType
+from ..core.scalars import UUID
 from ..core.types.money import TaxedMoney
 from ..decorators import permission_required
 from ..discount.dataloaders import DiscountsByDateTimeLoader
@@ -109,6 +110,7 @@ class Checkout(CountableDjangoObjectType):
         TaxedMoney,
         description="The price of the checkout before shipping, with taxes included.",
     )
+    token = graphene.Field(UUID, description=("UUID of checkout."), required=True)
     total_price = graphene.Field(
         TaxedMoney,
         description=(
@@ -129,7 +131,6 @@ class Meta:
             "quantity",
             "shipping_address",
             "shipping_method",
-            "token",
             "translated_discount_name",
             "user",
             "voucher_code",
diff --git a/saleor/graphql/core/scalars.py b/saleor/graphql/core/scalars.py
index 45f247ff772..fa0420c261a 100644
--- a/saleor/graphql/core/scalars.py
+++ b/saleor/graphql/core/scalars.py
@@ -88,3 +88,23 @@ def parse_literal_object(node):
             if field.name.value == "unit":
                 unit = field.value.value
         return Weight(**{unit: value})
+
+
+class UUID(graphene.UUID):
+    @staticmethod
+    def serialize(uuid):
+        return super(UUID, UUID).serialize(uuid)
+
+    @staticmethod
+    def parse_literal(node):
+        try:
+            return super(UUID, UUID).parse_literal(node)
+        except ValueError as e:
+            raise GraphQLError(str(e))
+
+    @staticmethod
+    def parse_value(value):
+        try:
+            return super(UUID, UUID).parse_value(value)
+        except ValueError as e:
+            raise GraphQLError(str(e))
diff --git a/saleor/graphql/order/mutations/orders.py b/saleor/graphql/order/mutations/orders.py
index 88dd2365443..bda4f3bf604 100644
--- a/saleor/graphql/order/mutations/orders.py
+++ b/saleor/graphql/order/mutations/orders.py
@@ -19,7 +19,7 @@
 from ....payment import CustomPaymentChoices, PaymentError, gateway
 from ...account.types import AddressInput
 from ...core.mutations import BaseMutation
-from ...core.scalars import Decimal
+from ...core.scalars import UUID, Decimal
 from ...core.types.common import OrderError
 from ...meta.deprecated.mutations import ClearMetaBaseMutation, UpdateMetaBaseMutation
 from ...meta.deprecated.types import MetaInput, MetaPath
@@ -465,9 +465,7 @@ class Meta:
         public = True
 
     class Arguments:
-        token = graphene.UUID(
-            description="Token of an object to update.", required=True
-        )
+        token = UUID(description="Token of an object to update.", required=True)
         input = MetaInput(
             description="Fields required to update new or stored metadata item.",
             required=True,
@@ -495,7 +493,7 @@ class Meta:
         public = True
 
     class Arguments:
-        token = graphene.UUID(description="Token of an object to clear.", required=True)
+        token = UUID(description="Token of an object to clear.", required=True)
         input = MetaPath(
             description="Fields required to update new or stored metadata item.",
             required=True,
diff --git a/saleor/graphql/order/schema.py b/saleor/graphql/order/schema.py
index 3abce46c9c1..7c0f79c1687 100644
--- a/saleor/graphql/order/schema.py
+++ b/saleor/graphql/order/schema.py
@@ -3,6 +3,7 @@
 from ...core.permissions import OrderPermissions
 from ..core.enums import ReportingPeriod
 from ..core.fields import FilterInputConnectionField, PrefetchingConnectionField
+from ..core.scalars import UUID
 from ..core.types import FilterInputObjectType, TaxedMoney
 from ..decorators import permission_required
 from .bulk_mutations.draft_orders import DraftOrderBulkDelete, DraftOrderLinesBulkDelete
@@ -117,9 +118,7 @@ class OrderQueries(graphene.ObjectType):
     order_by_token = graphene.Field(
         Order,
         description="Look up an order by token.",
-        token=graphene.Argument(
-            graphene.UUID, description="The order's token.", required=True
-        ),
+        token=graphene.Argument(UUID, description="The order's token.", required=True),
     )
 
     @permission_required(OrderPermissions.MANAGE_ORDERS)
diff --git a/saleor/graphql/product/types/digital_contents.py b/saleor/graphql/product/types/digital_contents.py
index 7107bbfb9c4..bdad5aa8916 100644
--- a/saleor/graphql/product/types/digital_contents.py
+++ b/saleor/graphql/product/types/digital_contents.py
@@ -4,6 +4,7 @@
 from ....core.permissions import ProductPermissions
 from ....product import models
 from ...core.connection import CountableDjangoObjectType
+from ...core.scalars import UUID
 from ...decorators import permission_required
 from ...meta.deprecated.resolvers import resolve_meta, resolve_private_meta
 from ...meta.types import ObjectWithMetadata
@@ -11,10 +12,13 @@
 
 class DigitalContentUrl(CountableDjangoObjectType):
     url = graphene.String(description="URL for digital content.")
+    token = graphene.Field(
+        UUID, description=("UUID of digital content."), required=True
+    )
 
     class Meta:
         model = models.DigitalContentUrl
-        only_fields = ["content", "created", "download_num", "token"]
+        only_fields = ["content", "created", "download_num"]
         interfaces = (relay.Node,)
 
     @staticmethod
diff --git a/saleor/graphql/schema.graphql b/saleor/graphql/schema.graphql
index d58accc5bba..7a2c14480da 100644
--- a/saleor/graphql/schema.graphql
+++ b/saleor/graphql/schema.graphql
@@ -757,7 +757,6 @@ type Checkout implements Node & ObjectWithMetadata {
   created: DateTime!
   lastChange: DateTime!
   user: User
-  token: UUID!
   quantity: Int!
   billingAddress: Address
   shippingAddress: Address
@@ -780,6 +779,7 @@ type Checkout implements Node & ObjectWithMetadata {
   lines: [CheckoutLine]
   shippingPrice: TaxedMoney
   subtotalPrice: TaxedMoney
+  token: UUID!
   totalPrice: TaxedMoney
 }
 
@@ -1609,12 +1609,12 @@ input DigitalContentUploadInput {
 }
 
 type DigitalContentUrl implements Node {
-  token: UUID!
   content: DigitalContent!
   created: DateTime!
   downloadNum: Int!
   id: ID!
   url: String
+  token: UUID!
 }
 
 type DigitalContentUrlCreate {
diff --git a/tests/api/test_product.py b/tests/api/test_product.py
index b585787c30b..9de22bb9c96 100644
--- a/tests/api/test_product.py
+++ b/tests/api/test_product.py
@@ -4446,7 +4446,7 @@ def test_create_product_with_weight_input(
     query = f"""
     mutation createProduct(
             $productType: ID!,
-            $category: ID!
+            $category: ID!,
             $name: String!,
             $sku: String,
             $basePrice: Decimal!)
diff --git a/tests/api/test_scalars.py b/tests/api/test_scalars.py
new file mode 100644
index 00000000000..c1a1cdbcfa0
--- /dev/null
+++ b/tests/api/test_scalars.py
@@ -0,0 +1,51 @@
+from tests.api.utils import get_graphql_content
+
+QUERY_CHECKOUT = """
+query getCheckout($token: UUID!) {
+    checkout(token: $token) {
+        token
+    }
+}
+"""
+
+
+def test_uuid_scalar_value_passed_as_variable(api_client, checkout):
+    variables = {"token": str(checkout.token)}
+    response = api_client.post_graphql(QUERY_CHECKOUT, variables)
+    content = get_graphql_content(response)
+    assert content["data"]["checkout"]["token"] == str(checkout.token)
+
+
+def test_uuid_scalar_wrong_value_passed_as_variable(api_client, checkout):
+    variables = {"token": "wrong-token"}
+    response = api_client.post_graphql(QUERY_CHECKOUT, variables)
+    content = get_graphql_content(response, ignore_errors=True)
+    assert len(content["errors"]) == 1
+
+
+def test_uuid_scalar_value_passed_in_input(api_client, checkout):
+    token = checkout.token
+    query = f"""
+        query{{
+            checkout(token: "{token}") {{
+                token
+            }}
+        }}
+    """
+    response = api_client.post_graphql(query)
+    content = get_graphql_content(response)
+    assert content["data"]["checkout"]["token"] == str(checkout.token)
+
+
+def test_uuid_scalar_wrong_value_passed_in_input(api_client, checkout):
+    token = "wrong-token"
+    query = f"""
+        query{{
+            checkout(token: "{token}") {{
+                token
+            }}
+        }}
+    """
+    response = api_client.post_graphql(query)
+    content = get_graphql_content(response, ignore_errors=True)
+    assert len(content["errors"]) == 1


Code review comments:
diff_hunk: @@ -109,6 +110,7 @@ class Checkout(CountableDjangoObjectType):
         TaxedMoney,
         description="The price of the checkout before shipping, with taxes included.",
     )
+    token = graphene.Field(UUID, description=("UUID of checkout."), required=True)
path: saleor/graphql/checkout/types.py
text: @user1:
I would change it to have consistency with other description of that field above:
```suggestion
    token = graphene.Field(UUID, description=("The checkout's token."), required=True)
```

@author:
Done