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