Hotchocolate: Uuid values with hyphens no longer supported

Created on 28 Nov 2020  ·  15Comments  ·  Source: ChilliCream/hotchocolate

Bug description

I have a project where Guids are used as identifiers. The input model looks like this:

public sealed class ProjectQueryInput
{
    public Guid ProjectId { get; set; }
}

This gets translated as a Uuid! type which is fine, however, when sending an id with a GraphQL client (Insomnia in my case) as "a3a4e988-a84f-45f6-993b-ac7401222975", we get back this error:

{
  "message": "The specified value type of field `projectId` does not match the field type.",
  "locations": [
    {
      "line": 21,
      "column": 18
    }
  ],
  "path": [
    "project",
    "project"
  ],
  "extensions": {
    "fieldName": "projectId",
    "fieldType": "Uuid!",
    "locationType": "Uuid!",
    "specifiedBy": "http://spec.graphql.org/June2018/#sec-Values-of-Correct-Type"
  }
}

Only after removing the hyphens, the query works.

Expected behavior
Guid formatted with hyphens should also be accepted as Uuid.

Desktop

  • OS: Windows 10
  • HotChocolate Version: 11.1.0-preview.2
❓ question 🌶 hot chocolate

Most helpful comment

Yes we have created a solution for the following case: Usually we get Uuids in format N (without hyphens) from the clients, but in some rare cases we get Uuids in format D (classic c# Guid with hyphens). To handle both I have registered the following class...

It first tries to parse the Uuids in the configured format and if the Uuid could not be parsed it tries the format D as alternate format. (You could hand over the alternate format also in the constructor if you want)

 public sealed class GuidType
        : ScalarType<Guid, StringValueNode>
    {
        private readonly string _format;
        private readonly string _alternateFormat = "D";

        /// <summary>
        /// Initializes a new instance of the <see cref="GuidType"/> class.
        /// </summary>
        public GuidType()
            : this('\0')
        {
        }

        /// <summary>
        /// Initializes a new instance of the <see cref="GuidType"/> class.
        /// </summary>
        public GuidType(char format)
            : this(ScalarNames.Uuid, format)
        {
        }

        /// <summary>
        /// Initializes a new instance of the <see cref="GuidType"/> class.
        /// </summary>
        public GuidType(NameString name, char format = '\0')
            : this(name, null, format)
        {
        }

        /// <summary>
        /// Initializes a new instance of the <see cref="GuidType"/> class.
        /// </summary>
        public GuidType(NameString name, string? description, char format = '\0')
            : base(name, BindingBehavior.Implicit)
        {
            Description = description;
            _format = CreateFormatString(format);
        }

        protected override bool IsInstanceOfType(StringValueNode valueSyntax)
        {
            if (Utf8Parser.TryParse(
                valueSyntax.AsSpan(), out Guid _, out int _, _format[0]))
            {
                return true;
            }

            return Utf8Parser.TryParse(
                valueSyntax.AsSpan(), out Guid _, out int _, _alternateFormat[0]);
        }

        protected override Guid ParseLiteral(StringValueNode valueSyntax)
        {
            if (Utf8Parser.TryParse(
                valueSyntax.AsSpan(), out Guid formatGuid, out int _, _format[0]))
            {
                return formatGuid;
            }

            if(Utf8Parser.TryParse(
                valueSyntax.AsSpan(), out Guid altFormatGuid, out int _, _alternateFormat[0]))
            {
                return altFormatGuid;
            }

            throw new SerializationException(
                ScalarCannotParseLiteral(Name, valueSyntax.GetType()),
                this);
        }

        protected override StringValueNode ParseValue(Guid runtimeValue)
        {
            return new StringValueNode(runtimeValue
                .ToString(_alternateFormat, CultureInfo.InvariantCulture));
        }

        public override IValueNode ParseResult(object? resultValue)
        {
            if (resultValue is null)
            {
                return NullValueNode.Default;
            }

            if (resultValue is string s)
            {
                return new StringValueNode(s);
            }

            if (resultValue is Guid g)
            {
                return ParseValue(g);
            }

            throw new SerializationException(
                ScalarCannotParseLiteral(Name, resultValue.GetType()),
                this);
        }

        public override bool TrySerialize(object? runtimeValue, out object? resultValue)
        {
            if (runtimeValue is null)
            {
                resultValue = null;
                return true;
            }

            if (runtimeValue is Guid uri)
            {
                resultValue = uri.ToString(_alternateFormat, CultureInfo.InvariantCulture);
                return true;
            }

            resultValue = null;
            return false;
        }

        public override bool TryDeserialize(object? resultValue, out object? runtimeValue)
        {
            if (resultValue is null)
            {
                runtimeValue = null;
                return true;
            }

            if (resultValue is string s && Guid.TryParse(s, out Guid guid))
            {
                runtimeValue = guid;
                return true;
            }

            if (resultValue is Guid)
            {
                runtimeValue = resultValue;
                return true;
            }

            runtimeValue = null;
            return false;
        }

        private static string CreateFormatString(char format)
        {
            if (format != '\0'
                && format != 'N'
                && format != 'D'
                && format != 'B'
                && format != 'P')
            {
                throw new ArgumentException(
                    "Unknown format. Guid supports the following format chars: " +
                    $"{{ `N`, `D`, `B`, `P` }}.{Environment.NewLine}" +
                    "https://docs.microsoft.com/en-us/dotnet/api/" +
                    "system.buffers.text.utf8parser.tryparse?" +
                    "view=netcore-3.1#System_Buffers_Text_Utf8Parser_" +
                    "TryParse_System_ReadOnlySpan_System_Byte__System_Guid__" +
                    "System_Int32__System_Char_",
                    nameof(format));
            }

            return format == '\0' ? "N" : format.ToString(CultureInfo.InvariantCulture);
        }

        private static string ScalarCannotParseLiteral(
            string typeName, Type literalType)
        {
            if (string.IsNullOrEmpty(typeName))
            {
                throw new ArgumentException(
                    "The typeName mustn't be null or empty.",
                    nameof(typeName));
            }

            if (literalType is null)
            {
                throw new ArgumentNullException(nameof(literalType));
            }

            return string.Format(
                CultureInfo.InvariantCulture,
                "{0} cannot parse the given literal of type `{1}",
                typeName,
                literalType.Name);
        }
    }

If you add it to the IRequestExecutorBuilder, then it overwrites the normal UuidType:


services
    .AddGraphQLServer()
    .BindRuntimeType<Guid, GuidType>()
    .
    .

All 15 comments

Hm....
Do you want to support both? With and without hyphens?

Well, version 10 supported both.

For me, it's a temporary problem as I had a lot of queries setup in my Insomnia client for testing purposes. The normal front-end doesn't have this problem as all Guids it gets back from the HotChocolate server are formatted as N (without hyphens).

However, this can be a breaking change for others that use hard-coded Guids in their front-end application or fetch Guids from another place (like REST API, SignalR, ...) formatted with hyphens.

I think, anything that Guid.Parse can parse should be allowed.

I also got hit by this issue today.

Another observation: Values are converted to lower-case, if round-tripped.

This is not a bug. We are not using Guid.Parse but Utf8Parser. You have to opt in to a specific format or reimplement the scalar. Our scalar implementation is aimed at low allocation. In most cases that we saw users use one format. If you do not fall into this category you need to reimplement.

I think @nscheibe has a working "forgiving" implementation of the UUid Scalar. Maybe he can share it here or ad dit to the migration guide

Yes we have created a solution for the following case: Usually we get Uuids in format N (without hyphens) from the clients, but in some rare cases we get Uuids in format D (classic c# Guid with hyphens). To handle both I have registered the following class...

It first tries to parse the Uuids in the configured format and if the Uuid could not be parsed it tries the format D as alternate format. (You could hand over the alternate format also in the constructor if you want)

 public sealed class GuidType
        : ScalarType<Guid, StringValueNode>
    {
        private readonly string _format;
        private readonly string _alternateFormat = "D";

        /// <summary>
        /// Initializes a new instance of the <see cref="GuidType"/> class.
        /// </summary>
        public GuidType()
            : this('\0')
        {
        }

        /// <summary>
        /// Initializes a new instance of the <see cref="GuidType"/> class.
        /// </summary>
        public GuidType(char format)
            : this(ScalarNames.Uuid, format)
        {
        }

        /// <summary>
        /// Initializes a new instance of the <see cref="GuidType"/> class.
        /// </summary>
        public GuidType(NameString name, char format = '\0')
            : this(name, null, format)
        {
        }

        /// <summary>
        /// Initializes a new instance of the <see cref="GuidType"/> class.
        /// </summary>
        public GuidType(NameString name, string? description, char format = '\0')
            : base(name, BindingBehavior.Implicit)
        {
            Description = description;
            _format = CreateFormatString(format);
        }

        protected override bool IsInstanceOfType(StringValueNode valueSyntax)
        {
            if (Utf8Parser.TryParse(
                valueSyntax.AsSpan(), out Guid _, out int _, _format[0]))
            {
                return true;
            }

            return Utf8Parser.TryParse(
                valueSyntax.AsSpan(), out Guid _, out int _, _alternateFormat[0]);
        }

        protected override Guid ParseLiteral(StringValueNode valueSyntax)
        {
            if (Utf8Parser.TryParse(
                valueSyntax.AsSpan(), out Guid formatGuid, out int _, _format[0]))
            {
                return formatGuid;
            }

            if(Utf8Parser.TryParse(
                valueSyntax.AsSpan(), out Guid altFormatGuid, out int _, _alternateFormat[0]))
            {
                return altFormatGuid;
            }

            throw new SerializationException(
                ScalarCannotParseLiteral(Name, valueSyntax.GetType()),
                this);
        }

        protected override StringValueNode ParseValue(Guid runtimeValue)
        {
            return new StringValueNode(runtimeValue
                .ToString(_alternateFormat, CultureInfo.InvariantCulture));
        }

        public override IValueNode ParseResult(object? resultValue)
        {
            if (resultValue is null)
            {
                return NullValueNode.Default;
            }

            if (resultValue is string s)
            {
                return new StringValueNode(s);
            }

            if (resultValue is Guid g)
            {
                return ParseValue(g);
            }

            throw new SerializationException(
                ScalarCannotParseLiteral(Name, resultValue.GetType()),
                this);
        }

        public override bool TrySerialize(object? runtimeValue, out object? resultValue)
        {
            if (runtimeValue is null)
            {
                resultValue = null;
                return true;
            }

            if (runtimeValue is Guid uri)
            {
                resultValue = uri.ToString(_alternateFormat, CultureInfo.InvariantCulture);
                return true;
            }

            resultValue = null;
            return false;
        }

        public override bool TryDeserialize(object? resultValue, out object? runtimeValue)
        {
            if (resultValue is null)
            {
                runtimeValue = null;
                return true;
            }

            if (resultValue is string s && Guid.TryParse(s, out Guid guid))
            {
                runtimeValue = guid;
                return true;
            }

            if (resultValue is Guid)
            {
                runtimeValue = resultValue;
                return true;
            }

            runtimeValue = null;
            return false;
        }

        private static string CreateFormatString(char format)
        {
            if (format != '\0'
                && format != 'N'
                && format != 'D'
                && format != 'B'
                && format != 'P')
            {
                throw new ArgumentException(
                    "Unknown format. Guid supports the following format chars: " +
                    $"{{ `N`, `D`, `B`, `P` }}.{Environment.NewLine}" +
                    "https://docs.microsoft.com/en-us/dotnet/api/" +
                    "system.buffers.text.utf8parser.tryparse?" +
                    "view=netcore-3.1#System_Buffers_Text_Utf8Parser_" +
                    "TryParse_System_ReadOnlySpan_System_Byte__System_Guid__" +
                    "System_Int32__System_Char_",
                    nameof(format));
            }

            return format == '\0' ? "N" : format.ToString(CultureInfo.InvariantCulture);
        }

        private static string ScalarCannotParseLiteral(
            string typeName, Type literalType)
        {
            if (string.IsNullOrEmpty(typeName))
            {
                throw new ArgumentException(
                    "The typeName mustn't be null or empty.",
                    nameof(typeName));
            }

            if (literalType is null)
            {
                throw new ArgumentNullException(nameof(literalType));
            }

            return string.Format(
                CultureInfo.InvariantCulture,
                "{0} cannot parse the given literal of type `{1}",
                typeName,
                literalType.Name);
        }
    }

If you add it to the IRequestExecutorBuilder, then it overwrites the normal UuidType:


services
    .AddGraphQLServer()
    .BindRuntimeType<Guid, GuidType>()
    .
    .

Hyphens worked perfectly (and were used by default) in HotChocolate v10. Won't anyone who upgrades to v11 have this same problem if their schema contains guids? Disallowing hyphens is a huge breaking change. It means that all consumers of any graphql api powered by hotchocolate need to be updated to remove hyphens in their queries. This is impractical for us and I imagine for many others.

Please allow hyphens in guids, even if its behind and option. It should be the default in my opinion. I can't think of another library I've ever used that doesn't allow hyphens in guids.

Also, the workaround posted above causes the following error:

The name `Uuid` was already registered by another type. (HotChocolate.Types.UuidType)

Looks like it's not overwriting the normal UuidType.

@ayoung-cforp Do you want to only use hyphens? or both?

Yes for the workaround posted above, you have to replace everywhere in your GraphQL the UuidType with the GuidType. Therefore Copy and Replace UuidType with GuidType.

@PascalSenn Best would be to permit hyphens when parsing input (but not require them) and to always print hyphens in output.

@nscheibe Not sure how I would accomplish that. We don't specify "UuidType" directly. Our input types are just defined as POCOs with Guid, Guid? and Optional properties.

@ayoung-cforp Guid is a struct. So i think you also need to also bind the nullable version

services
    .AddGraphQLServer()
    .BindRuntimeType<Guid, GuidType>()
    .BindRuntimeType<Guid?, GuidType>()
    .
    .

@PascalSenn Thanks

@nscheibe Looks like Guid properties in our C# class models are working correctly without any changes. We were using UuidType in a few ObjectTypeExtensions and after replacing those with GuidType, it's working correctly now. Thank you.

I will close this issue now.

This issue's conversation has been locked. Please file a new issue if you would like to continue the discussion.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

PascalSenn picture PascalSenn  ·  5Comments

sfmskywalker picture sfmskywalker  ·  3Comments

RohrerF picture RohrerF  ·  3Comments

sergeyshaykhullin picture sergeyshaykhullin  ·  3Comments

sgt picture sgt  ·  4Comments