mount public data from the atmosphere to a virtual filesystem (macos only) pdfs.at
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(atproto): add IdentityResolver actor with TTL cache

+117
+70
Packages/ATProto/Sources/ATProto/Identity/IdentityResolver.swift
··· 1 + import Foundation 2 + 3 + public struct ResolvedIdentity: Sendable, Equatable { 4 + public let did: DID 5 + public let handle: Handle? 6 + public let doc: DIDDocument 7 + public var pds: URL? { doc.pdsEndpoint } 8 + } 9 + 10 + public actor IdentityResolver { 11 + let handleResolver: HandleResolver 12 + let didResolver: DIDResolver 13 + let ttl: TimeInterval 14 + 15 + struct CacheEntry<V> { 16 + let value: V 17 + let expiresAt: Date 18 + } 19 + 20 + var didDocs: [DID: CacheEntry<DIDDocument>] = [:] 21 + var handleToDID: [Handle: CacheEntry<DID>] = [:] 22 + 23 + public init( 24 + session: URLSession = .shared, 25 + ttl: TimeInterval = 60 * 60 26 + ) { 27 + self.handleResolver = HandleResolver(session: session) 28 + self.didResolver = DIDResolver(session: session) 29 + self.ttl = ttl 30 + } 31 + 32 + public func resolve(did: DID) async throws -> DIDDocument { 33 + if let entry = didDocs[did], entry.expiresAt > Date() { 34 + return entry.value 35 + } 36 + let doc = try await didResolver.resolve(did: did) 37 + didDocs[did] = CacheEntry(value: doc, expiresAt: Date().addingTimeInterval(ttl)) 38 + return doc 39 + } 40 + 41 + public func resolve(handle: Handle) async throws -> DID { 42 + if let entry = handleToDID[handle], entry.expiresAt > Date() { 43 + return entry.value 44 + } 45 + let did = try await handleResolver.resolve(handle: handle) 46 + handleToDID[handle] = CacheEntry(value: did, expiresAt: Date().addingTimeInterval(ttl)) 47 + return did 48 + } 49 + 50 + public func resolve(handleOrDID: String) async throws -> ResolvedIdentity { 51 + let did: DID 52 + let handle: Handle? 53 + if let parsedDID = DID(handleOrDID) { 54 + did = parsedDID 55 + handle = nil 56 + } else if let parsedHandle = Handle(handleOrDID) { 57 + handle = parsedHandle 58 + did = try await resolve(handle: parsedHandle) 59 + } else { 60 + throw ATProtoError.invalidIdentity(handleOrDID) 61 + } 62 + let doc = try await resolve(did: did) 63 + return ResolvedIdentity(did: did, handle: handle, doc: doc) 64 + } 65 + 66 + public func invalidate() { 67 + didDocs.removeAll() 68 + handleToDID.removeAll() 69 + } 70 + }
+47
Packages/ATProto/Tests/ATProtoTests/IdentityResolverTests.swift
··· 1 + import Foundation 2 + import Testing 3 + @testable import ATProto 4 + 5 + @Suite("IdentityResolver", .serialized) 6 + struct IdentityResolverTests { 7 + @Test("resolves handle end-to-end: handle → DID → PDS") 8 + func endToEnd() async throws { 9 + let session = URLProtocolStub.install { request in 10 + switch request.url { 11 + case let u? where u.host == "cloudflare-dns.com": 12 + return .json(#""" 13 + {"Status":0,"Answer":[{"data":"\"did=did:plc:abc\""}]} 14 + """#) 15 + case let u? where u.absoluteString == "https://plc.directory/did:plc:abc": 16 + return .json(#""" 17 + {"id":"did:plc:abc","alsoKnownAs":["at://natemoo.re"], 18 + "service":[{"id":"#atproto_pds","type":"AtprotoPersonalDataServer","serviceEndpoint":"https://bsky.social"}]} 19 + """#) 20 + default: 21 + return URLProtocolStub.Response(statusCode: 404, headers: [:], body: Data()) 22 + } 23 + } 24 + defer { URLProtocolStub.reset(session: session) } 25 + 26 + let resolver = IdentityResolver(session: session) 27 + let resolved = try await resolver.resolve(handleOrDID: "natemoo.re") 28 + #expect(resolved.did.rawValue == "did:plc:abc") 29 + #expect(resolved.pds?.absoluteString == "https://bsky.social") 30 + } 31 + 32 + @Test("caches DID→doc within TTL") 33 + func cacheHits() async throws { 34 + let session = URLProtocolStub.install { _ in 35 + .json(#""" 36 + {"id":"did:plc:abc", 37 + "service":[{"id":"#atproto_pds","type":"AtprotoPersonalDataServer","serviceEndpoint":"https://bsky.social"}]} 38 + """#) 39 + } 40 + defer { URLProtocolStub.reset(session: session) } 41 + 42 + let resolver = IdentityResolver(session: session) 43 + _ = try await resolver.resolve(did: DID("did:plc:abc")!) 44 + _ = try await resolver.resolve(did: DID("did:plc:abc")!) 45 + #expect(URLProtocolStub.recordedRequests(for: session).count == 1) 46 + } 47 + }