From 716d79739ca9d5f3155e5ac560537a3ceed7fb68 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn Date: Fri, 28 Aug 2026 09:14:52 -0500 Subject: [PATCH] feat: introduce models to represent atproto id --- .../AtprotoId.cs | 132 ++++++++++++++++++ .../AtprotoIdTests.cs | 19 +++ 2 files changed, 151 insertions(+) create mode 100644 src/StevanFreeborn.Atproto.IdentityResolver/AtprotoId.cs create mode 100644 tests/StevanFreeborn.Atproto.IdentityResolver.Tests/AtprotoIdTests.cs diff --git a/src/StevanFreeborn.Atproto.IdentityResolver/AtprotoId.cs b/src/StevanFreeborn.Atproto.IdentityResolver/AtprotoId.cs new file mode 100644 index 0000000..a7dd472 --- /dev/null +++ b/src/StevanFreeborn.Atproto.IdentityResolver/AtprotoId.cs @@ -0,0 +1,132 @@ +namespace StevanFreeborn.Atproto.IdentityResolver; + +/// +/// +/// +public abstract class AtprotoId +{ + /// + /// + /// + public string Value { get; protected set; } = string.Empty; + + /// + /// + /// + public static AtprotoId From(string atprotoId) + { + if (string.IsNullOrEmpty(atprotoId)) + { + throw new ArgumentException("Cannot be null or empty", nameof(atprotoId)); + } + + if (IsDomain(atprotoId)) + { + return Handle.From(atprotoId); + } + + return Did.From(atprotoId); + } + + private static bool IsDomain(string did) + { + var hostType = Uri.CheckHostName(did); + return hostType == UriHostNameType.Dns && did.Contains('.', StringComparison.OrdinalIgnoreCase); + } + +} + +/// +/// +/// +public class Did : AtprotoId +{ + private static readonly string[] ValidPrefixes = ["did:web:", "did:plc:"]; + + /// + /// + /// + public string Method { get; private set; } + + private Did(string did) + { + if (string.IsNullOrEmpty(did)) + { + throw new ArgumentException("Cannot be null or empty", nameof(did)); + } + + if (TryParseWithoutPrefix(did, out var prefix, out var didWithoutPrefix) is false) + { + throw new ArgumentException($"Must be did with one of these methods: {string.Join(',', ValidPrefixes)}", nameof(did)); + } + + if (string.IsNullOrEmpty(didWithoutPrefix)) + { + throw new ArgumentException("Must not be null or empty without did method prefix", nameof(did)); + } + + // Method should probably be a strongly + // typed object + Method = prefix.Contains("web", StringComparison.OrdinalIgnoreCase) ? "web" : "plc"; + Value = did; + } + + /// + /// + /// + public static new Did From(string did) + { + return new(did); + } + + private static bool TryParseWithoutPrefix( + string did, + out string prefix, + out string didWithoutPrefix + ) + { + prefix = string.Empty; + didWithoutPrefix = string.Empty; + + foreach (var validPrefix in ValidPrefixes) + { + if (did.StartsWith(validPrefix, StringComparison.OrdinalIgnoreCase)) + { + prefix = did[..validPrefix.Length]; + didWithoutPrefix = did[validPrefix.Length..]; + return true; + } + } + + return false; + } +} + +/// +/// +/// +public class Handle : AtprotoId +{ + private Handle(string handle) + { + if (string.IsNullOrEmpty(handle)) + { + throw new ArgumentException("Cannot be null or empty", nameof(handle)); + } + + if (handle.StartsWith('@')) + { + throw new ArgumentException("Cannot start with @", nameof(handle)); + } + + Value = handle; + } + + /// + /// + /// + public static new Handle From(string handle) + { + return new(handle); + } +} \ No newline at end of file diff --git a/tests/StevanFreeborn.Atproto.IdentityResolver.Tests/AtprotoIdTests.cs b/tests/StevanFreeborn.Atproto.IdentityResolver.Tests/AtprotoIdTests.cs new file mode 100644 index 0000000..3cf3cf6 --- /dev/null +++ b/tests/StevanFreeborn.Atproto.IdentityResolver.Tests/AtprotoIdTests.cs @@ -0,0 +1,19 @@ +namespace StevanFreeborn.Atproto.IdentityResolver.Tests; + +public class AtprotoIdTests +{ + [Theory] + [InlineData("")] + [InlineData("localhost")] + [InlineData("did:web:")] + [InlineData("did:plc:")] + [InlineData("did:mu:dxbmien5xsrkov5yhywgc4zu")] + [InlineData("@stevanfreeborn.com")] + [InlineData(null)] + public void From_WhenCalledWithInvalidDid_ItShouldThrowInvalidArgumentException(string? atprotoId) + { + var act = () => AtprotoId.From(atprotoId!); + + act.Should().Throw(); + } +} \ No newline at end of file