Main coves client
1import 'package:dio/dio.dart';
2
3import 'did_helpers.dart';
4import 'identity_resolver_error.dart';
5
6/// Options for handle resolution.
7class ResolveHandleOptions {
8 /// Whether to bypass cache
9 final bool noCache;
10
11 /// Cancellation token for the request
12 final CancelToken? cancelToken;
13
14 const ResolveHandleOptions({this.noCache = false, this.cancelToken});
15}
16
17/// Interface for resolving atProto handles to DIDs.
18abstract class HandleResolver {
19 /// Resolves an atProto handle to a DID.
20 ///
21 /// Returns null if the handle doesn't resolve to a DID (but no error occurred).
22 /// Throws [HandleResolverError] if an unexpected error occurs during resolution.
23 Future<String?> resolve(String handle, [ResolveHandleOptions? options]);
24}
25
26/// XRPC-based handle resolver that uses com.atproto.identity.resolveHandle.
27///
28/// This resolver makes HTTP requests to an atProto XRPC service (typically
29/// a PDS or entryway service) to resolve handles.
30class XrpcHandleResolver implements HandleResolver {
31 /// The base URL of the XRPC service
32 final Uri serviceUrl;
33
34 /// HTTP client for making requests
35 final Dio dio;
36
37 XrpcHandleResolver(String serviceUrl, {Dio? dio})
38 : serviceUrl = Uri.parse(serviceUrl),
39 dio = dio ?? Dio();
40
41 @override
42 Future<String?> resolve(
43 String handle, [
44 ResolveHandleOptions? options,
45 ]) async {
46 final url = serviceUrl.resolve('/xrpc/com.atproto.identity.resolveHandle');
47 final uri = url.replace(queryParameters: {'handle': handle});
48
49 try {
50 final response = await dio.getUri(
51 uri,
52 options: Options(
53 headers: {if (options?.noCache ?? false) 'Cache-Control': 'no-cache'},
54 validateStatus: (status) {
55 // Allow 400 and 200 status codes
56 return status == 200 || status == 400;
57 },
58 ),
59 cancelToken: options?.cancelToken,
60 );
61
62 final data = response.data;
63
64 // Handle 400 Bad Request (expected for invalid/unresolvable handles)
65 if (response.statusCode == 400) {
66 if (data is Map<String, dynamic>) {
67 final error = data['error'] as String?;
68 final message = data['message'] as String?;
69
70 // Expected response for handle that doesn't exist
71 if (error == 'InvalidRequest' &&
72 message == 'Unable to resolve handle') {
73 return null;
74 }
75 }
76
77 throw HandleResolverError(
78 'Invalid response from resolveHandle method: ${response.data}',
79 );
80 }
81
82 // Handle successful response
83 if (response.statusCode == 200) {
84 if (data is! Map<String, dynamic>) {
85 throw HandleResolverError(
86 'Invalid response format from resolveHandle method',
87 );
88 }
89
90 final did = data['did'];
91 if (did is! String) {
92 throw HandleResolverError(
93 'Missing or invalid DID in resolveHandle response',
94 );
95 }
96
97 // Validate that it's a proper atProto DID
98 if (!isAtprotoDid(did)) {
99 throw HandleResolverError(
100 'Invalid DID returned from resolveHandle method: $did',
101 );
102 }
103
104 return did;
105 }
106
107 throw HandleResolverError(
108 'Unexpected status code from resolveHandle method: ${response.statusCode}',
109 );
110 } on DioException catch (e) {
111 if (e.type == DioExceptionType.cancel) {
112 throw HandleResolverError('Handle resolution was cancelled');
113 }
114
115 throw HandleResolverError('Failed to resolve handle: ${e.message}', e);
116 } catch (e) {
117 if (e is HandleResolverError) rethrow;
118
119 throw HandleResolverError('Unexpected error resolving handle: $e', e);
120 }
121 }
122}
123
124/// Cached handle resolver that wraps another resolver with caching.
125class CachedHandleResolver implements HandleResolver {
126 final HandleResolver _resolver;
127 final HandleCache _cache;
128
129 CachedHandleResolver(this._resolver, [HandleCache? cache])
130 : _cache = cache ?? InMemoryHandleCache();
131
132 @override
133 Future<String?> resolve(
134 String handle, [
135 ResolveHandleOptions? options,
136 ]) async {
137 // Check cache first unless noCache is set
138 if (!(options?.noCache ?? false)) {
139 final cached = await _cache.get(handle);
140 if (cached != null) {
141 return cached;
142 }
143 }
144
145 // Resolve and cache
146 final did = await _resolver.resolve(handle, options);
147 if (did != null) {
148 await _cache.set(handle, did);
149 }
150
151 return did;
152 }
153
154 /// Clears the cache
155 Future<void> clearCache() => _cache.clear();
156}
157
158/// Interface for caching handle resolution results.
159abstract class HandleCache {
160 Future<String?> get(String handle);
161 Future<void> set(String handle, String did);
162 Future<void> clear();
163}
164
165/// Simple in-memory handle cache with expiration.
166class InMemoryHandleCache implements HandleCache {
167 final Map<String, _CacheEntry> _cache = {};
168 final Duration _ttl;
169
170 InMemoryHandleCache({Duration? ttl}) : _ttl = ttl ?? const Duration(hours: 1);
171
172 @override
173 Future<String?> get(String handle) async {
174 final entry = _cache[handle];
175 if (entry == null) return null;
176
177 // Check if expired
178 if (DateTime.now().isAfter(entry.expiresAt)) {
179 _cache.remove(handle);
180 return null;
181 }
182
183 return entry.did;
184 }
185
186 @override
187 Future<void> set(String handle, String did) async {
188 _cache[handle] = _CacheEntry(did: did, expiresAt: DateTime.now().add(_ttl));
189 }
190
191 @override
192 Future<void> clear() async {
193 _cache.clear();
194 }
195}
196
197class _CacheEntry {
198 final String did;
199 final DateTime expiresAt;
200
201 _CacheEntry({required this.did, required this.expiresAt});
202}