Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using Xunit;

namespace PhoneNumbers.Extensions.Test
{
public class TestPhoneNumberExtensions
{
private static readonly PhoneNumberUtil Util = PhoneNumberUtil.GetInstance();

[Fact]
public void ToE164_FormatsAsE164()
{
var number = Util.Parse("6194002404", "US");

Assert.Equal("+16194002404", number.ToE164());
}

[Fact]
public void ToNationalFormat_FormatsNationally()
{
var number = Util.Parse("+16194002404", null);

Assert.Equal("(619) 400-2404", number.ToNationalFormat());
}

[Fact]
public void ToInternationalFormat_FormatsInternationally()
{
var number = Util.Parse("+16194002404", null);

Assert.Equal("+1 619-400-2404", number.ToInternationalFormat());
}

[Fact]
public void IsValid_ValidNumber_ReturnsTrue()
{
var number = Util.Parse("+16194002404", null);

Assert.True(number.IsValid());
}

[Fact]
public void IsValid_InvalidNumber_ReturnsFalse()
{
var number = Util.Parse("1235557704", "US");

Assert.False(number.IsValid());
}
}
}
101 changes: 101 additions & 0 deletions csharp/PhoneNumbers.Extensions.Test/TestPhoneNumberJsonContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
using Xunit;

namespace PhoneNumbers.Extensions.Test
{
public class TestPhoneNumberJsonContext
{
private static readonly PhoneNumberUtil Util = PhoneNumberUtil.GetInstance();

[Theory]
[InlineData("+16194002404", "+16194002404", null)]
[InlineData("+16194002404", "6194002404", "US")]
[InlineData("+448443351801", "+448443351801", null)]
[InlineData("+448443351801", "0844 335 1801", "GB")]
[InlineData("+380445004973", "0445004973", "UA")]
public void DefaultOptions_RoundTripsThroughSourceGenContext(string expected, string input, string? region)
{
var number = Util.Parse(input, region);

var json = JsonSerializer.Serialize(number, PhoneNumberJsonOptions.Default);
// Compare via the plain-string reading, not the raw JSON text: System.Text.Json's
// default encoder escapes '+' (as "+") for HTML/JS safety, so the literal text
// isn't `"+E164..."` even though it decodes to that string.
Assert.Equal(expected, JsonSerializer.Deserialize<string>(json));

var roundTripped = JsonSerializer.Deserialize<PhoneNumbers.PhoneNumber>(json, PhoneNumberJsonOptions.Default);
Assert.Equal(expected, Util.Format(roundTripped!, PhoneNumberFormat.E164));
}

[Fact]
public void Default_ResolvesTypeInfoThroughContext()
{
// The TypeInfoResolver must actually be able to produce a JsonTypeInfo for PhoneNumber
// (i.e. PhoneNumberJsonContext really does cover it) rather than silently falling back
// to reflection, which would defeat the point under trimming/AOT.
var typeInfo = PhoneNumberJsonOptions.Default.GetTypeInfo(typeof(PhoneNumbers.PhoneNumber));

Assert.NotNull(typeInfo);
Assert.Same(PhoneNumberJsonContext.Default, PhoneNumberJsonOptions.Default.TypeInfoResolver);
}

[Fact]
public void Create_CopiesBaseOptionsAndStillWiresConverter()
{
var baseOptions = new JsonSerializerOptions { WriteIndented = true };

var options = PhoneNumberJsonOptions.Create(baseOptions);

Assert.True(options.WriteIndented);
var number = Util.Parse("+16194002404", null);
var json = JsonSerializer.Serialize(number, options);
Assert.Equal("+16194002404", JsonSerializer.Deserialize<string>(json));
}

[Fact]
public void RawContext_BypassesConverterAndRecursesForever()
{
// Documents the gotcha in PhoneNumberJsonContext's remarks. PhoneNumber.DefaultInstanceForType
// is a public get-only property that returns the type's own static default instance (itself a
// PhoneNumber exposing the same property); the source-generated member-based serializer for
// PhoneNumber (used when you go through the raw JsonTypeInfo<T> instead of through options wired
// with PhoneNumberConverter) walks it and recurses without end, rather than ever producing the
// E.164 string PhoneNumberConverter would.
var number = Util.Parse("+16194002404", null);

Assert.Throws<InvalidOperationException>(
() => JsonSerializer.Serialize(number, PhoneNumberJsonContext.Default.PhoneNumber));
}

[Fact]
public void Create_CombinesAnExistingResolverInsteadOfReplacingIt()
{
// Regression test: Create() must combine baseOptions' own TypeInfoResolver with
// PhoneNumberJsonContext.Default rather than overwrite it, so a DTO type resolved by the
// caller's own JsonSerializerContext can still contain a PhoneNumber property. Overwriting
// (the original bug) made JsonSerializer throw NotSupportedException for DtoWithPhoneNumber
// because DtoContext alone has no metadata for it.
var baseOptions = new JsonSerializerOptions { TypeInfoResolver = DtoContext.Default };

var options = PhoneNumberJsonOptions.Create(baseOptions);
var dto = new DtoWithPhoneNumber { Number = Util.Parse("+16194002404", null) };

var json = JsonSerializer.Serialize(dto, options);
var roundTripped = JsonSerializer.Deserialize<DtoWithPhoneNumber>(json, options);

Assert.Equal("+16194002404", Util.Format(roundTripped!.Number, PhoneNumberFormat.E164));
}
}

internal class DtoWithPhoneNumber
{
public PhoneNumbers.PhoneNumber Number { get; set; } = null!;
}

[JsonSerializable(typeof(DtoWithPhoneNumber))]
internal partial class DtoContext : JsonSerializerContext
{
}
}
32 changes: 32 additions & 0 deletions csharp/PhoneNumbers.Extensions/PhoneNumberExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
namespace PhoneNumbers.Extensions
{
/// <summary>
/// C#-idiomatic extension methods over <see cref="PhoneNumbers.PhoneNumber"/> for the formatting
/// and validity checks callers reach for most often, so they read as
/// <c>number.ToE164()</c> / <c>number.IsValid()</c> instead of
/// <c>PhoneNumberUtil.GetInstance().Format(number, PhoneNumberFormat.E164)</c>.
/// </summary>
public static class PhoneNumberExtensions
{
private static readonly PhoneNumberUtil PhoneNumberUtil = PhoneNumberUtil.GetInstance();

/// <summary>Formats <paramref name="number"/> as E.164, e.g. "+16194002404".</summary>
public static string ToE164(this PhoneNumbers.PhoneNumber number)
=> PhoneNumberUtil.Format(number, PhoneNumberFormat.E164);

/// <summary>Formats <paramref name="number"/> in national format, e.g. "(619) 400-2404".</summary>
public static string ToNationalFormat(this PhoneNumbers.PhoneNumber number)
=> PhoneNumberUtil.Format(number, PhoneNumberFormat.NATIONAL);

/// <summary>Formats <paramref name="number"/> in international format, e.g. "+1 619-400-2404".</summary>
public static string ToInternationalFormat(this PhoneNumbers.PhoneNumber number)
=> PhoneNumberUtil.Format(number, PhoneNumberFormat.INTERNATIONAL);

/// <summary>
/// Equivalent to <see cref="PhoneNumberUtil.IsValidNumber"/>, as an extension method on an
/// already-parsed <see cref="PhoneNumbers.PhoneNumber"/>.
/// </summary>
public static bool IsValid(this PhoneNumbers.PhoneNumber number)
=> PhoneNumberUtil.IsValidNumber(number);
}
}
50 changes: 50 additions & 0 deletions csharp/PhoneNumbers.Extensions/PhoneNumberJsonContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;

namespace PhoneNumbers.Extensions
{
/// <summary>
/// Source-generated <see cref="JsonSerializerContext"/> for <see cref="PhoneNumbers.PhoneNumber"/>,
/// so consumers building trimmed or Native AOT apps do not need to hand-write one.
/// </summary>
/// <remarks>
/// <para>
/// The metadata this context generates for <see cref="PhoneNumbers.PhoneNumber"/> is a plain,
/// member-based (reflection-free) serializer — it knows nothing about
/// <see cref="PhoneNumberConverter"/>. Do not use it to actually serialize a
/// <see cref="PhoneNumbers.PhoneNumber"/>: <see cref="PhoneNumbers.PhoneNumber"/>.DefaultInstanceForType is a
/// public get-only property that returns the type's static default instance, which is itself a
/// <see cref="PhoneNumbers.PhoneNumber"/> exposing the same DefaultInstanceForType property, so
/// member-based serialization walks straight into infinite recursion (a
/// <see cref="System.InvalidOperationException"/> for exceeding the writer's max depth, or a real
/// stack overflow at larger depth limits) instead of ever producing JSON. This context exists only
/// so <see cref="PhoneNumbers.PhoneNumber"/> can be *resolved*
/// (given a <see cref="System.Text.Json.Serialization.Metadata.JsonTypeInfo"/> to satisfy the type
/// graph) when it appears as a member of another type covered by source generation — e.g. a
/// consumer's own <see cref="JsonSerializerContext"/> combined with this one via
/// <see cref="JsonTypeInfoResolver"/>.Combine — provided a converter is also registered so that
/// resolved-but-broken metadata is never actually invoked.
/// </para>
/// <para>
/// <b>Actual serialization always goes through <see cref="PhoneNumberConverter"/>, never through
/// the metadata generated here.</b> Prefer <see cref="PhoneNumberJsonOptions.Default"/> (or
/// <see cref="PhoneNumberJsonOptions.Create"/>), which wires both together correctly. If you build
/// your own <see cref="JsonSerializerOptions"/> instead, you must set both:
/// <code>
/// options.TypeInfoResolver = PhoneNumberJsonContext.Default;
/// options.Converters.Add(new PhoneNumberConverter());
/// </code>
/// and always serialize via <c>JsonSerializer.Serialize(value, options)</c> /
/// <c>JsonSerializer.Deserialize(json, typeof(T), options)</c> — never via the raw
/// <c>PhoneNumberJsonContext.Default.PhoneNumber</c> <see cref="JsonTypeInfo{T}"/> directly. That
/// overload uses the <see cref="JsonTypeInfo{T}"/>'s own baked-in (member-based) converter and
/// bypasses <see cref="JsonSerializerOptions.Converters"/> entirely, hitting the recursion above.
/// </para>
/// </remarks>
[JsonSourceGenerationOptions]
[JsonSerializable(typeof(PhoneNumbers.PhoneNumber))]
public partial class PhoneNumberJsonContext : JsonSerializerContext
{
}
}
91 changes: 91 additions & 0 deletions csharp/PhoneNumbers.Extensions/PhoneNumberJsonOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;

namespace PhoneNumbers.Extensions
{
/// <summary>
/// Ready-made <see cref="JsonSerializerOptions"/> for serializing <see cref="PhoneNumbers.PhoneNumber"/>
/// under Native AOT / trimming, where the reflection-based default resolver is unavailable.
/// Combines <see cref="PhoneNumberJsonContext"/> (source-generated type metadata) with
/// <see cref="PhoneNumberConverter"/> (the actual read/write logic) so callers cannot wire the two
/// together incorrectly — see the remarks on <see cref="PhoneNumberJsonContext"/> for why doing it
/// by hand is easy to get wrong.
/// </summary>
public static class PhoneNumberJsonOptions
{
/// <summary>
/// A shared, ready-to-use <see cref="JsonSerializerOptions"/> instance for a bare
/// <see cref="PhoneNumbers.PhoneNumber"/>, e.g.
/// <c>JsonSerializer.Serialize(number, PhoneNumberJsonOptions.Default)</c>. Instances of
/// <see cref="JsonSerializerOptions"/> are safe to reuse concurrently once first used, so this
/// is safe to share across a whole application.
/// </summary>
public static JsonSerializerOptions Default { get; } = Create();

/// <summary>
/// Builds a new <see cref="JsonSerializerOptions"/> with <see cref="PhoneNumberJsonContext"/>
/// and <see cref="PhoneNumberConverter"/> wired together correctly. Use this instead of
/// <see cref="Default"/> when you need to layer in your own settings (e.g. combining
/// <see cref="PhoneNumberJsonContext.Default"/> with your own <see cref="JsonSerializerContext"/>
/// via <see cref="System.Text.Json.Serialization.Metadata.JsonTypeInfoResolver"/>.Combine for a
/// DTO that has a <see cref="PhoneNumbers.PhoneNumber"/> property), or when you don't want to
/// mutate the shared <see cref="Default"/> instance.
/// </summary>
/// <param name="baseOptions">
/// Optional options to copy other settings from (e.g. <see cref="JsonSerializerOptions.WriteIndented"/>).
/// If <paramref name="baseOptions"/> already has a <see cref="JsonSerializerOptions.TypeInfoResolver"/>
/// set (e.g. your own <see cref="JsonSerializerContext"/>, or a resolver already combined via
/// <c>JsonTypeInfoResolver.Combine</c>), it is combined with
/// <see cref="PhoneNumberJsonContext.Default"/> rather than replaced, so a DTO type resolved by your
/// own context can still contain a <see cref="PhoneNumbers.PhoneNumber"/> property. A
/// <see cref="PhoneNumberConverter"/> is always appended to <see cref="JsonSerializerOptions.Converters"/>.
/// </param>
public static JsonSerializerOptions Create(JsonSerializerOptions baseOptions = null)
{
var options = baseOptions is null ? new JsonSerializerOptions() : new JsonSerializerOptions(baseOptions);
options.TypeInfoResolver = options.TypeInfoResolver is { } existingResolver
? JsonTypeInfoResolver.Combine(existingResolver, PhoneNumberJsonContext.Default)
: PhoneNumberJsonContext.Default;
options.Converters.Add(new PhoneNumberConverter());
return options;
}

/// <summary>
/// Serializes a <see cref="PhoneNumbers.PhoneNumber"/> to its E.164 JSON string using
/// <see cref="Default"/>. Prefer this over calling
/// <c>JsonSerializer.Serialize(number, PhoneNumberJsonOptions.Default)</c> yourself: that call
/// goes through the <see cref="JsonSerializerOptions"/>-based overload, which the trimming/AOT
/// analyzers always flag (IL2026/IL3050) because they cannot see that <see cref="Default"/>
/// never actually falls back to reflection. This method carries that guarantee instead, so a
/// consumer project with trim/AOT analysis on gets no warning for using it.
/// </summary>
#if NET5_0_OR_GREATER
[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming",
"IL2026:RequiresUnreferencedCode",
Justification = "Default always resolves PhoneNumber via PhoneNumberJsonContext + PhoneNumberConverter, never reflection.")]
[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("AOT",
"IL3050:RequiresDynamicCode",
Justification = "Default always resolves PhoneNumber via PhoneNumberJsonContext + PhoneNumberConverter, never reflection.")]
#endif
public static string Serialize(PhoneNumbers.PhoneNumber number)
=> JsonSerializer.Serialize(number, Default);

/// <summary>
/// Deserializes a <see cref="PhoneNumbers.PhoneNumber"/> from its E.164 JSON string using
/// <see cref="Default"/>. See <see cref="Serialize"/> for why this is preferable to calling
/// <c>JsonSerializer.Deserialize&lt;PhoneNumber&gt;(json, PhoneNumberJsonOptions.Default)</c>
/// directly under trim/AOT analysis.
/// </summary>
#if NET5_0_OR_GREATER
[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming",
"IL2026:RequiresUnreferencedCode",
Justification = "Default always resolves PhoneNumber via PhoneNumberJsonContext + PhoneNumberConverter, never reflection.")]
[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("AOT",
"IL3050:RequiresDynamicCode",
Justification = "Default always resolves PhoneNumber via PhoneNumberJsonContext + PhoneNumberConverter, never reflection.")]
#endif
public static PhoneNumbers.PhoneNumber Deserialize(string json)
=> JsonSerializer.Deserialize<PhoneNumbers.PhoneNumber>(json, Default);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,10 @@
<PackageReference Include="System.ComponentModel.Annotations" />
</ItemGroup>

<PropertyGroup Condition="'$(TargetFramework)' != 'netstandard2.0'">
<!-- Mirrors PhoneNumbers.csproj: opt in to trim/AOT analysis on modern .NET TFMs now that
PhoneNumberJsonContext exists specifically to support Native AOT consumers. -->
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>

</Project>