1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
|
## Resolve Bluesky / ATProto handles to DIDs via the public AppView.
## Avatar traits are still a pure function of the DID after resolution.
import std/[httpclient, json, strutils]
const defaultEndpoint* =
"https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle"
type
HttpResponse* = object
status*: int ## 0 means the request never reached the server
body*: JsonNode
error*: string
HttpGet* = proc (url: string): HttpResponse {.gcsafe.}
Identity* = object
did*: string
handle*: string
ResolveOpts* = object
endpoint*: string
httpGet*: HttpGet ## test hook; nil uses the real transport
func normalizeHandle*(handle: string): string =
## Strip @ and whitespace.
handle.strip().strip(trailing = false, chars = {'@'}).toLowerAscii
func isDid*(s: string): bool =
## True if the string is already a DID: `did:<method>:…`, method case-insensitive.
let t = s.strip()
if not t.toLowerAscii.startsWith("did:"):
return false
var i = 4
while i < t.len and t[i] in {'a'..'z', 'A'..'Z', '0'..'9'}:
inc i
i > 4 and i < t.len and t[i] == ':'
func urlEncode(s: string): string =
for c in s:
if c in {'A'..'Z', 'a'..'z', '0'..'9', '_', '.', '~', '-'}:
result.add c
else:
result.add '%' & toHex(int(uint8(c)), 2)
proc httpGetJson(url: string): HttpResponse {.gcsafe.} =
## GET `url` and parse the body as JSON. `status: 0` means the request never
## reached the server — the transport itself failed — and `error` says why.
{.cast(gcsafe).}:
try:
let client = newHttpClient(timeout = 15_000)
defer: client.close()
let resp = client.get(url)
# `status` is "200 OK". Body parsing gets its own try: an error page from
# the AppView must stay an HTTP failure, not look like a dead transport.
let status = parseInt(resp.status.split(' ')[0])
var body: JsonNode
try: body = parseJson(resp.body) except CatchableError: discard
HttpResponse(status: status, body: body)
except CatchableError as e:
HttpResponse(status: 0, error: e.msg)
proc resolveHandle*(handle: string, opts = ResolveOpts()): string =
## Resolve a handle to its DID. Raises if the handle cannot be resolved.
let clean = normalizeHandle(handle)
if clean.len == 0:
raise newException(ValueError, "empty handle")
if isDid(clean):
return clean.strip()
let
endpoint = if opts.endpoint.len > 0: opts.endpoint else: defaultEndpoint
url = endpoint & "?handle=" & urlEncode(clean)
get = if opts.httpGet != nil: opts.httpGet else: HttpGet(httpGetJson)
resp = get(url)
if resp.status == 0:
# No HTTP status at all: the request never left the machine. Saying "is
# that a real handle?" here blames the user for our own missing transport.
# Name it, and point at the offline path.
raise newException(IOError,
"couldn't reach the Bluesky API to resolve @" & clean &
(if resp.error.len > 0: " — " & resp.error else: "") &
"\nPass the DID directly (freeqsay did:plc:… …) to skip resolution.")
if resp.status != 200:
raise newException(IOError,
"couldn't resolve @" & clean & " — is that a real Bluesky handle? (" &
$resp.status & ")")
let did = if resp.body != nil and resp.body.kind == JObject and
resp.body.hasKey("did"): resp.body["did"].getStr else: ""
if did.len == 0 or not isDid(did):
raise newException(IOError, "resolveHandle returned no DID for @" & clean)
did
proc resolveIdentity*(handleOrDid: string, opts = ResolveOpts()): Identity =
## Accept either a handle or a DID; resolve handles, pass DIDs through.
let raw = handleOrDid.strip()
if isDid(raw):
Identity(did: raw)
else:
Identity(did: resolveHandle(raw, opts), handle: normalizeHandle(raw))
|