diff --git a/src/StevanFreeborn.Atproto.IdentityResolver/AtprotoId.cs b/src/StevanFreeborn.Atproto.IdentityResolver/Identity/AtprotoId.cs
similarity index 77%
rename from src/StevanFreeborn.Atproto.IdentityResolver/AtprotoId.cs
rename to src/StevanFreeborn.Atproto.IdentityResolver/Identity/AtprotoId.cs
index a7dd472..d7e64ce 100644
--- a/src/StevanFreeborn.Atproto.IdentityResolver/AtprotoId.cs
+++ b/src/StevanFreeborn.Atproto.IdentityResolver/Identity/AtprotoId.cs
@@ -1,17 +1,17 @@
namespace StevanFreeborn.Atproto.IdentityResolver;
///
-///
+/// Represents an identifier for the atproto network.
///
public abstract class AtprotoId
{
///
- ///
+ /// Gets the string representation of the underlying id.
///
public string Value { get; protected set; } = string.Empty;
///
- ///
+ /// Constructs a strongly typed representation of the given id.
///
public static AtprotoId From(string atprotoId)
{
@@ -37,14 +37,14 @@ public abstract class AtprotoId
}
///
-///
+/// Represents a did on the atproto network.
///
public class Did : AtprotoId
{
private static readonly string[] ValidPrefixes = ["did:web:", "did:plc:"];
///
- ///
+ /// Gets the method associated with the did.
///
public string Method { get; private set; }
@@ -72,7 +72,7 @@ public class Did : AtprotoId
}
///
- ///
+ /// Constructs a strongly-typed representation of the did.
///
public static new Did From(string did)
{
@@ -103,7 +103,7 @@ public class Did : AtprotoId
}
///
-///
+/// Represents a handle for an id on the atproto network.
///
public class Handle : AtprotoId
{
@@ -123,8 +123,14 @@ public class Handle : AtprotoId
}
///
- ///
+ /// Constructs a strongly-typed representation of the handle.
///
+ /// The handle for the id you'd like to represent
+ /// An instance of a
+ ///
+ /// Thrown when is null or empty.
+ /// Thrown when handle starts with '@'.
+ ///
public static new Handle From(string handle)
{
return new(handle);
diff --git a/src/StevanFreeborn.Atproto.IdentityResolver/Identity/DidDocument.cs b/src/StevanFreeborn.Atproto.IdentityResolver/Identity/DidDocument.cs
new file mode 100644
index 0000000..eaa1402
--- /dev/null
+++ b/src/StevanFreeborn.Atproto.IdentityResolver/Identity/DidDocument.cs
@@ -0,0 +1,97 @@
+using System.Collections.ObjectModel;
+using System.Text.Json.Serialization;
+
+using StevanFreeborn.Atproto.IdentityResolver.Json;
+
+namespace StevanFreeborn.Atproto.IdentityResolver.Identity;
+
+///
+///
+///
+public sealed class DidDocument
+{
+ ///
+ ///
+ ///
+ [JsonPropertyName("@context")]
+ [JsonConverter(typeof(StringToCollectionConverter))]
+ public Collection Context { get; private set; } = [];
+
+ ///
+ ///
+ ///
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = string.Empty;
+
+ ///
+ ///
+ ///
+ [JsonPropertyName("alsoKnownAs")]
+ public Collection AlsoKnownAs { get; private set; } = [];
+
+ ///
+ ///
+ ///
+ [JsonPropertyName("verificationMethod")]
+ public Collection VerificatonMethod { get; private set; } = [];
+
+ ///
+ ///
+ ///
+ [JsonPropertyName("service")]
+ public Collection Service { get; private set; } = [];
+}
+
+///
+///
+///
+public class VerificationMethod
+{
+ ///
+ ///
+ ///
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = string.Empty;
+
+ ///
+ ///
+ ///
+ [JsonPropertyName("type")]
+ public string Type { get; set; } = string.Empty;
+
+ ///
+ ///
+ ///
+ [JsonPropertyName("controller")]
+ public string Controller { get; set; } = string.Empty;
+
+ ///
+ ///
+ ///
+ [JsonPropertyName("publicKeyMultiBase")]
+ public string PublicKeyMultiBase { get; set; } = string.Empty;
+}
+
+///
+///
+///
+public class Service
+{
+ ///
+ ///
+ ///
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = string.Empty;
+
+ ///
+ ///
+ ///
+ [JsonPropertyName("type")]
+ public string Type { get; set; } = string.Empty;
+
+ ///
+ ///
+ ///
+ [JsonPropertyName("serviceEndpoint")]
+ public string ServiceEndpoint { get; set; } = string.Empty;
+}
\ No newline at end of file
diff --git a/src/StevanFreeborn.Atproto.IdentityResolver/Retrieval/DefaultDidDocumentRetriever.cs b/src/StevanFreeborn.Atproto.IdentityResolver/Retrieval/DefaultDidDocumentRetriever.cs
new file mode 100644
index 0000000..7ff3d18
--- /dev/null
+++ b/src/StevanFreeborn.Atproto.IdentityResolver/Retrieval/DefaultDidDocumentRetriever.cs
@@ -0,0 +1,66 @@
+using System.Text.Json;
+
+using StevanFreeborn.Atproto.IdentityResolver.Exceptions;
+using StevanFreeborn.Atproto.IdentityResolver.Identity;
+using StevanFreeborn.Atproto.IdentityResolver.Json;
+
+namespace StevanFreeborn.Atproto.IdentityResolver.Retrieval;
+
+///
+///
+///
+public class DefaultDidDocumentRetriever(HttpClient httpClient) : IDidDocumentRetriever
+{
+ ///
+ ///
+ ///
+ public Task GetByPlcOrThrowAsync(Did did, CancellationToken ct)
+ {
+ if (did is null)
+ {
+ throw new ArgumentNullException(nameof(did));
+ }
+
+ var requestUri = new Uri($"https://plc.directory/{did.Value}", UriKind.Absolute);
+ return GetOrThrowAsync(requestUri, ct);
+ }
+
+ ///
+ ///
+ ///
+ public Task GetByWellKnownOrThrowAsync(Handle handle, CancellationToken ct)
+ {
+ if (handle is null)
+ {
+ throw new ArgumentNullException(nameof(handle));
+ }
+
+ var requestUri = new Uri($"https://{handle.Value}/.well-known/did.json", UriKind.Absolute);
+ return GetOrThrowAsync(requestUri, ct);
+ }
+
+ private async Task GetOrThrowAsync(Uri requestUri, CancellationToken ct)
+ {
+ try
+ {
+ using var response = await httpClient.GetAsync(requestUri, ct).ConfigureAwait(false);
+ response.EnsureSuccessStatusCode();
+
+ using var body = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
+
+ var didDocument = await JsonSerializer
+ .DeserializeAsync(body, JsonContext.Default.DidDocument, ct)
+ .ConfigureAwait(false);
+
+ return didDocument ?? throw new InvalidOperationException("Deserializing response body resulted in null value.");
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ throw new DidDocumentNotFoundException("Unable to retrieve did document. See inner exception", ex);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/StevanFreeborn.Atproto.IdentityResolver/Retrieval/DefaultDidRetriever.cs b/src/StevanFreeborn.Atproto.IdentityResolver/Retrieval/DefaultDidRetriever.cs
new file mode 100644
index 0000000..dc5338a
--- /dev/null
+++ b/src/StevanFreeborn.Atproto.IdentityResolver/Retrieval/DefaultDidRetriever.cs
@@ -0,0 +1,133 @@
+using System.Net.Http.Headers;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+using StevanFreeborn.Atproto.IdentityResolver.Exceptions;
+using StevanFreeborn.Atproto.IdentityResolver.Json;
+
+namespace StevanFreeborn.Atproto.IdentityResolver.Retrieval;
+
+///
+///
+///
+public class DefaultDidRetriever(HttpClient httpClient) : IDidRetriever
+{
+ private static readonly MediaTypeWithQualityHeaderValue DnsJsonHeader = new("application/dns-json");
+
+ ///
+ ///
+ ///
+ public async Task GetByDnsOrThrowAsync(Handle handle, CancellationToken ct)
+ {
+ if (handle is null)
+ {
+ throw new ArgumentNullException(nameof(handle));
+ }
+
+ try
+ {
+ var requestUri = new Uri($"https://cloudflare-dns.com/dns-query?name=_atproto.{handle.Value}&type=TXT");
+
+ using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
+ request.Headers.Accept.Add(DnsJsonHeader);
+
+ using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
+ response.EnsureSuccessStatusCode();
+
+ using var body = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
+ var dnsResponse = await JsonSerializer.DeserializeAsync(body, JsonContext.Default.DnsQueryResponse, ct).ConfigureAwait(false);
+
+ var didString = ExtractDidFromDnsResponse(dnsResponse);
+
+ if (string.IsNullOrWhiteSpace(didString))
+ {
+ throw new InvalidOperationException($"No valid atproto DID TXT record found for handle '{handle.Value}'.");
+ }
+
+ return Did.From(didString);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ throw new DidNotFoundException("Unable to get did. See inner exception", ex);
+ }
+ }
+
+ ///
+ ///
+ ///
+ public async Task GetByWellKnownOrThrowAsync(Handle handle, CancellationToken ct)
+ {
+ if (handle is null)
+ {
+ throw new ArgumentNullException(nameof(handle));
+ }
+
+ try
+ {
+ var requestUri = new Uri($"https://{handle.Value}/.well-known/atproto-did");
+
+ using var response = await httpClient.GetAsync(requestUri, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
+ response.EnsureSuccessStatusCode();
+
+ var rawDid = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
+ var trimmedDid = rawDid?.Trim();
+
+ if (string.IsNullOrWhiteSpace(trimmedDid))
+ {
+ throw new InvalidOperationException($"Endpoint '{requestUri}' returned an empty DID response.");
+ }
+
+ return Did.From(trimmedDid);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ throw new DidNotFoundException("Unable to retrieve did. See inner exception", ex);
+ }
+ }
+
+ private static string? ExtractDidFromDnsResponse(DnsQueryResponse? response)
+ {
+ if (response?.Answer is null)
+ {
+ return null;
+ }
+
+ foreach (var answer in response.Answer)
+ {
+ if (string.IsNullOrWhiteSpace(answer.Data))
+ {
+ continue;
+ }
+
+ // Cloudflare wraps TXT records in quotes (e.g., "\"did=did:plc:...\"")
+ var text = answer.Data.Trim('\"');
+
+ if (text.StartsWith("did=", StringComparison.OrdinalIgnoreCase))
+ {
+ return text.Substring(4).Trim();
+ }
+ }
+
+ return null;
+ }
+}
+
+internal class DnsQueryResponse
+{
+ [JsonPropertyName("Answer")]
+ public List Answer { get; set; } = [];
+}
+
+internal class Answer
+{
+ [JsonPropertyName("data")]
+ public string Data { get; set; } = string.Empty;
+}
\ No newline at end of file
diff --git a/src/StevanFreeborn.Atproto.IdentityResolver/Retrieval/IDidDocumentRetriever.cs b/src/StevanFreeborn.Atproto.IdentityResolver/Retrieval/IDidDocumentRetriever.cs
new file mode 100644
index 0000000..031ae11
--- /dev/null
+++ b/src/StevanFreeborn.Atproto.IdentityResolver/Retrieval/IDidDocumentRetriever.cs
@@ -0,0 +1,19 @@
+using StevanFreeborn.Atproto.IdentityResolver.Identity;
+
+namespace StevanFreeborn.Atproto.IdentityResolver.Retrieval;
+
+///
+///
+///
+public interface IDidDocumentRetriever
+{
+ ///
+ ///
+ ///
+ Task GetByWellKnownOrThrowAsync(Handle handle, CancellationToken ct);
+
+ ///
+ ///
+ ///
+ Task GetByPlcOrThrowAsync(Did did, CancellationToken ct);
+}
\ No newline at end of file
diff --git a/src/StevanFreeborn.Atproto.IdentityResolver/Retrieval/IDidRetriever.cs b/src/StevanFreeborn.Atproto.IdentityResolver/Retrieval/IDidRetriever.cs
new file mode 100644
index 0000000..5de46c6
--- /dev/null
+++ b/src/StevanFreeborn.Atproto.IdentityResolver/Retrieval/IDidRetriever.cs
@@ -0,0 +1,17 @@
+namespace StevanFreeborn.Atproto.IdentityResolver.Retrieval;
+
+///
+///
+///
+public interface IDidRetriever
+{
+ ///
+ ///
+ ///
+ Task GetByWellKnownOrThrowAsync(Handle handle, CancellationToken ct);
+
+ ///
+ ///
+ ///
+ Task GetByDnsOrThrowAsync(Handle handle, CancellationToken ct);
+}
\ No newline at end of file
diff --git a/tests/StevanFreeborn.Atproto.IdentityResolver.Tests/Unit/DefaultDidDocumentRetriever.cs b/tests/StevanFreeborn.Atproto.IdentityResolver.Tests/Unit/DefaultDidDocumentRetriever.cs
new file mode 100644
index 0000000..7a0042e
--- /dev/null
+++ b/tests/StevanFreeborn.Atproto.IdentityResolver.Tests/Unit/DefaultDidDocumentRetriever.cs
@@ -0,0 +1,26 @@
+using RichardSzalay.MockHttp;
+
+using StevanFreeborn.Atproto.IdentityResolver.Retrieval;
+
+namespace StevanFreeborn.Atproto.IdentityResolver.Tests.Unit;
+
+public sealed class DefaultDidDocumentRetrieverTests : IDisposable
+{
+ private readonly MockHttpMessageHandler _mockHttpMessageHandler = new();
+ private readonly DefaultDidDocumentRetriever _sut;
+
+ public DefaultDidDocumentRetrieverTests()
+ {
+ _sut = new(_mockHttpMessageHandler.ToHttpClient());
+ }
+
+ public void Dispose()
+ {
+ _mockHttpMessageHandler.Dispose();
+ }
+
+ [Fact]
+ public async Task GetByWellKnownOrThrowAsync_WhenResponse()
+ {
+ }
+}
\ No newline at end of file
diff --git a/tests/StevanFreeborn.Atproto.IdentityResolver.Tests/Unit/DefaultDidRetrieverTests.cs b/tests/StevanFreeborn.Atproto.IdentityResolver.Tests/Unit/DefaultDidRetrieverTests.cs
new file mode 100644
index 0000000..de22212
--- /dev/null
+++ b/tests/StevanFreeborn.Atproto.IdentityResolver.Tests/Unit/DefaultDidRetrieverTests.cs
@@ -0,0 +1,172 @@
+using System.Net;
+using System.Text.Json;
+
+using StevanFreeborn.Atproto.IdentityResolver.Exceptions;
+
+using RichardSzalay.MockHttp;
+using StevanFreeborn.Atproto.IdentityResolver.Retrieval;
+
+namespace StevanFreeborn.Atproto.IdentityResolver.Tests.Unit;
+
+public sealed class DefaultDidRetrieverTests : IDisposable
+{
+ private readonly MockHttpMessageHandler _mockHttpMessageHandler = new();
+ private readonly DefaultDidRetriever _sut;
+
+ public DefaultDidRetrieverTests()
+ {
+ _sut = new(_mockHttpMessageHandler.ToHttpClient());
+ }
+
+ public void Dispose()
+ {
+ _mockHttpMessageHandler.Dispose();
+ }
+
+ [Fact]
+ public async Task GetByDnsOrThrowAsync_WhenHandleIsNull_ItShouldThrowArgumentNullException()
+ {
+ var act = async () => await _sut.GetByDnsOrThrowAsync(null!, TestContext.Current.CancellationToken);
+
+ var e = await act.Should().ThrowAsync();
+ }
+
+ [Fact]
+ public async Task GetByDnsOrThrowAsync_WhenResponseIsNotSuccessful_ItShouldThrowDidNotFoundException()
+ {
+ var handle = "stevan.cc";
+
+ _mockHttpMessageHandler
+ .When(HttpMethod.Get, $"https://cloudflare-dns.com/dns-query?name=_atproto.{handle}&type=TXT")
+ .Respond(HttpStatusCode.InternalServerError);
+
+ var act = async () => await _sut.GetByDnsOrThrowAsync(Handle.From(handle), TestContext.Current.CancellationToken);
+
+ await act.Should().ThrowAsync();
+ }
+
+ [Fact]
+ public async Task GetByDnsOrThrowAsync_WhenResponseIsSuccessfulButDeserialziationFails_ItShouldThrowDidNotFoundException()
+ {
+ var handle = "stevan.cc";
+
+ _mockHttpMessageHandler
+ .When(HttpMethod.Get, $"https://cloudflare-dns.com/dns-query?name=_atproto.{handle}&type=TXT")
+ .Respond("application/json", JsonSerializer.Serialize("null"));
+
+ var act = async () => await _sut.GetByDnsOrThrowAsync(Handle.From(handle), TestContext.Current.CancellationToken);
+
+ var e = await act.Should().ThrowAsync();
+ e.WithInnerException();
+ }
+
+ [Fact]
+ public async Task GetByDnsOrThrowAsync_WhenResponseIsSuccessfulButBodyIsNull_ItShouldThrowDidNotFoundException()
+ {
+ var handle = "stevan.cc";
+
+ _mockHttpMessageHandler
+ .When(HttpMethod.Get, $"https://cloudflare-dns.com/dns-query?name=_atproto.{handle}&type=TXT")
+ .Respond("application/json", "null");
+
+ var act = async () => await _sut.GetByDnsOrThrowAsync(Handle.From(handle), TestContext.Current.CancellationToken);
+
+ await act.Should().ThrowAsync();
+ }
+
+ [Fact]
+ public async Task GetByDnsOrThrowAsync_WhenResponseIsSuccessfulButNoAnswers_ItShouldThrowDidNotFoundException()
+ {
+ var handle = "stevan.cc";
+ var response = new DnsQueryResponse();
+
+ _mockHttpMessageHandler
+ .When(HttpMethod.Get, $"https://cloudflare-dns.com/dns-query?name=_atproto.{handle}&type=TXT")
+ .Respond("application/json", JsonSerializer.Serialize(response));
+
+ var act = async () => await _sut.GetByDnsOrThrowAsync(Handle.From(handle), TestContext.Current.CancellationToken);
+
+ await act.Should().ThrowAsync();
+ }
+
+ [Fact]
+ public async Task GetByDnsOrThrowAsync_WhenResponseIsSuccessfulButTextRecordValueFormattedIncorrectly_ItShouldThrowDidNotFoundException()
+ {
+ var handle = "stevan.cc";
+ var response = new DnsQueryResponse()
+ {
+ Answer = [new() { Data = $"did:web:{handle}" }]
+ };
+
+ _mockHttpMessageHandler
+ .When(HttpMethod.Get, $"https://cloudflare-dns.com/dns-query?name=_atproto.{handle}&type=TXT")
+ .Respond("application/json", JsonSerializer.Serialize(response));
+
+ var act = async () => await _sut.GetByDnsOrThrowAsync(Handle.From(handle), TestContext.Current.CancellationToken);
+
+ await act.Should().ThrowAsync();
+ }
+
+ [Fact]
+ public async Task GetByDnsOrThrowAsync_WhenResponseIsSuccessfulAndTextRecordValueFormattedCorrectly_ItShouldReturnDid()
+ {
+ var handle = "stevan.cc";
+ var response = new DnsQueryResponse()
+ {
+ Answer = [new() { Data = $@"did=did:web:{handle}" }]
+ };
+
+ _mockHttpMessageHandler
+ .When(HttpMethod.Get, $"https://cloudflare-dns.com/dns-query?name=_atproto.{handle}&type=TXT")
+ .Respond("application/json", JsonSerializer.Serialize(response));
+
+ var result = await _sut.GetByDnsOrThrowAsync(Handle.From(handle), TestContext.Current.CancellationToken);
+
+ result.Should().BeEquivalentTo(Did.From($"did:web:{handle}"));
+ }
+
+ [Fact]
+ public async Task GetByWellKnownOrThrowAsync_WhenResponseIsUnsuccessful_ItShouldThrowDidNotFoundException()
+ {
+ var handle = "stevan.cc";
+ var did = $"did:web:{handle}";
+
+ _mockHttpMessageHandler
+ .When(HttpMethod.Get, $"https://{handle}/.well-known/atproto-did")
+ .Respond(HttpStatusCode.InternalServerError);
+
+ var act = async () => await _sut.GetByWellKnownOrThrowAsync(Handle.From(handle), TestContext.Current.CancellationToken);
+
+ await act.Should().ThrowAsync();
+ }
+
+ [Fact]
+ public async Task GetByWellKnownOrThrowAsync_WhenResponseIsSuccessfulAndButDoesNotContainDid_ItShouldThrowDidNotFoundException()
+ {
+ var handle = "stevan.cc";
+ var did = $"did:web:{handle}";
+
+ _mockHttpMessageHandler
+ .When(HttpMethod.Get, $"https://{handle}/.well-known/atproto-did")
+ .Respond("application/json", JsonSerializer.Serialize(new { did }));
+
+ var act = async () => await _sut.GetByWellKnownOrThrowAsync(Handle.From(handle), TestContext.Current.CancellationToken);
+
+ await act.Should().ThrowAsync();
+ }
+
+ [Fact]
+ public async Task GetByWellKnownOrThrowAsync_WhenResponseIsSuccessfulAndContainsDid_ItShouldReturnDid()
+ {
+ var handle = "stevan.cc";
+ var did = $"did:web:{handle}";
+
+ _mockHttpMessageHandler
+ .When(HttpMethod.Get, $"https://{handle}/.well-known/atproto-did")
+ .Respond("text/plain", did);
+
+ var result = await _sut.GetByWellKnownOrThrowAsync(Handle.From(handle), TestContext.Current.CancellationToken);
+
+ result.Should().BeEquivalentTo(Did.From(did));
+ }
+}
\ No newline at end of file