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
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277 | (ns ird.transport
"HTTP transport for the ird API over babashka.http-client.
Requests use optional Bearer auth (API key or account JWT), JSON bodies,
and optional query maps. List helpers can follow cursor pagination via
`paginate` and offset pagination via `paginate-offset`; `upload-bytes`
sends raw bytes to an IPFS upload-grant URL. Rebind `*http-fn*` in tests."
(:require
[babashka.http-client :as http]
[cheshire.core :as json]
[clojure.string :as str]
[ird.config :as config]
[ird.errors :as err])
(:import
[java.io File InputStream]
[java.net URLEncoder]))
(defn- url-encode
"URL-encode S (keywords by name, anything else via str), using %20 (not +)
for spaces."
[s]
(let [s (if (keyword? s) (name s) (str s))]
(str/replace (URLEncoder/encode s "UTF-8") "+" "%20")))
(defn query-string
"Build a URL query string (with leading ?) from PARAMS, skipping nil values.
Returns nil when there is nothing to encode."
[params]
(let [pairs (for [[k v] params
:when (and k (some? v))]
(str (url-encode (name k)) "=" (url-encode v)))]
(when (seq pairs)
(str "?" (str/join "&" pairs)))))
(defn- parse-json-body
"Parse BODY as JSON with keyword keys (snake_case kept as-is)."
[body]
(json/parse-string (str/trim body) true))
(defn- error-message
"Extract a human-readable message from an error RESPONSE. The ird API
envelope is {:error {:code :message}}; the node-hosted mailbox uses a bare
{:error \"code\"} instead, in which case the code doubles as the message."
[response body]
(or (get-in response [:error :message])
(let [e (:error response)] (when (string? e) e))
body
""))
(defn- error-code
"Extract the machine-readable error code from an error RESPONSE, from
either envelope (see `error-message`)."
[response]
(let [e (:error response)]
(if (string? e) e (:code e))))
(def ^:dynamic *http-fn*
"Function executing an HTTP request map {:method :uri :headers :body} and
returning {:status <int> :body <string>} (rebindable in tests); defaults to
babashka.http-client/request."
(fn [req]
(http/request (assoc req :throw false))))
(def ^:dynamic *sleep-fn*
"Function used by polling helpers to wait between attempts (rebindable in
tests); defaults to Thread/sleep."
(fn [ms] (Thread/sleep (long ms))))
(defn- merge-http-opts
"Merge the client's :http-opts into REQ. :headers are merged into the
computed headers (not replacing them) so options like a custom User-Agent
cannot silently drop Authorization or Content-Type."
[req opts]
(-> (merge req (dissoc opts :headers))
(update :headers merge (:headers opts))))
(defn build-request
"Build the request map for an ird API call: optional Bearer auth, optional
JSON body, optional query string. The Authorization header is only added
when the client has a non-blank :api-key — several endpoints allow
anonymous access."
[client method path {:keys [query body]}]
(let [{:keys [api-key api-url]} client
base (str/replace (or api-url config/api-base-url) #"/*$" "")
url-path (if (str/starts-with? path "/") path (str "/" path))
url (str base url-path (query-string query))]
(cond-> {:method method
:uri url
:headers (cond-> {"Accept" "application/json"}
(not (str/blank? (str api-key)))
(assoc "Authorization" (str "Bearer " api-key)))}
(some? body) (-> (assoc :body (json/generate-string body))
(assoc-in [:headers "Content-Type"] "application/json"))
(:http-opts client) (merge-http-opts (:http-opts client)))))
(defn- decode-response
"Turn a raw {:status :body} HTTP response into a parsed map, throwing
api-errors on non-2xx statuses. Successful maps carry {:ird/status <n>}
metadata so callers can distinguish 202 (accepted/queued) from 200/201."
[{:keys [status body]}]
(let [ok? (<= 200 status 299)
text (str body)]
(if (str/blank? text)
(if ok?
(with-meta {} {:ird/status status})
(throw (err/api-error "Empty error response" nil status nil)))
(let [response (try
(parse-json-body text)
(catch Exception e
(if ok?
(throw (err/transport-error
"Failed to parse JSON response"
(or text (ex-message e))))
::unparsed)))]
(when-not ok?
(let [response (when (map? response) response)]
(throw (err/api-error (error-message response text)
(error-code response)
status
response))))
(if (instance? clojure.lang.IObj response)
(with-meta response {:ird/status status})
response)))))
(defn- execute
"Run REQ through *http-fn*, mapping connection-level failures to
transport-errors."
[client req]
(when (:verbose client)
(println "ird" (name (:method req)) (:uri req)))
(let [raw (try
(*http-fn* req)
(catch clojure.lang.ExceptionInfo e (throw e))
(catch Exception e
(throw (err/transport-error
(str "Request failed: " (ex-message e))
{:uri (:uri req)}))))]
(decode-response raw)))
(defn request
"Execute an ird API request.
METHOD is :get/:post/:put/:patch/:delete. PATH is the endpoint path
relative to the API base (e.g. \"/domains/check\"). OPTS may contain:
:query - map of query-string parameters (nil values skipped)
:body - map serialised to a JSON request body (some DELETEs carry one)
Returns the parsed response map (snake_case keyword keys) with
{:ird/status <http-status>} metadata. Throws an api-error on non-2xx
responses and a transport-error on connection/parse failures."
([client method path]
(request client method path nil))
([client method path opts]
(execute client (build-request client method path opts))))
(defn paginate
"Follow cursor pagination for a list endpoint, collecting all items under
COLLECTION-KEY (e.g. :registrations). Returns a vector of items.
QUERY may include filters; :cursor is advanced automatically from each
response's :next_cursor until it is absent."
[client method path collection-key query]
(loop [cursor nil
acc []]
(let [q (if cursor (assoc query :cursor cursor) query)
resp (request client method path {:query q})
acc' (into acc (get resp collection-key))
next-cursor (:next_cursor resp)]
(if next-cursor
(recur next-cursor acc')
acc'))))
(defn paginate-offset
"Follow offset pagination for a list endpoint, collecting all items under
COLLECTION-KEY (e.g. :pins). Returns a vector of items.
QUERY may include filters and a page :limit (default 100); :offset is
advanced by the number of rows each page actually returns and paging stops
on the first short or empty page. The response's :total is deliberately
ignored — it can move while the pages are being walked.
Note that rows shifting between requests (an insert or delete under a page
boundary) can duplicate or skip an item, as with any offset paging."
[client method path collection-key query]
(let [limit (or (:limit query) 100)]
(loop [offset (or (:offset query) 0)
acc []]
(let [q (assoc query :limit limit :offset offset)
items (get (request client method path {:query q}) collection-key)
n (count items)
acc' (into acc items)]
(if (< n limit)
acc'
(recur (+ offset n) acc'))))))
(defn request-url
"Execute a request against an absolute URL rather than the ird API base —
for the node-hosted endpoints the API hands out: an IPFS upload grant
(`upload-bytes`) or a p2p app's mailbox (ird.mailbox).
METHOD is :get/:post/... URL is absolute. OPTS may contain:
:token bearer token for the endpoint (from the grant or the mint)
:query map of query-string parameters (nil values skipped)
:headers extra headers, merged over the computed ones
:body raw body, already coerced (bytes, File, InputStream, string)
Returns the parsed response map. Throws like `request`."
([method url] (request-url method url nil))
([method url {:keys [token query headers body]}]
(execute nil (cond-> {:method method
:uri (str url (query-string query))
:headers (cond-> {"Accept" "application/json"}
(not (str/blank? (str token)))
(assoc "Authorization" (str "Bearer " token)))}
(some? body) (assoc :body body)
headers (update :headers merge headers)))))
(defn poll-until
"Poll STEP (a no-arg fn returning the latest response) until DONE? is
truthy for its result, sleeping between attempts via *sleep-fn* with
exponential backoff (the interval doubles up to :max-interval-ms).
OPTS:
:interval-ms initial interval between polls (default 1000)
:max-interval-ms backoff cap (default 8000)
:timeout-ms overall deadline (default 120000)
:message message for the timeout error
Returns the first result for which (done? result) is truthy. Throws
ex-info {:type :ird/timeout :last <result>} when the deadline passes
first."
[step done? {:keys [interval-ms max-interval-ms timeout-ms message]}]
(let [interval-ms (or interval-ms 1000)
max-interval-ms (or max-interval-ms 8000)
timeout-ms (or timeout-ms 120000)
deadline (+ (System/currentTimeMillis) timeout-ms)]
(loop [interval interval-ms]
(let [result (step)]
(cond
(done? result) result
(>= (System/currentTimeMillis) deadline)
(throw (ex-info (or message "Timed out polling")
{:type :ird/timeout :last result}))
:else (do (*sleep-fn* interval)
(recur (min max-interval-ms (* 2 interval)))))))))
(defn- coerce-upload-source
"Coerce SOURCE (file path string, File, InputStream, or byte array) into a
request body babashka.http-client can send."
[source]
(cond
(string? source) (File. ^String source)
(instance? File source) source
(instance? InputStream source) source
(bytes? source) source
:else (throw (err/transport-error
"Unsupported upload source; expected a file path, File, InputStream, or byte array"
{:source-class (some-> source class str)}))))
(defn upload-bytes
"POST raw bytes to an IPFS upload-grant URL (from ird.ipfs/start-upload).
URL and TOKEN come from the grant; the token is single-use and may be nil
once consumed. SOURCE is a file path, File, InputStream, or byte array.
OPTS: :format — \"car\" appends ?format=car for a client-packed CAR archive.
Returns the parsed response map. Throws like `request`."
([url token source]
(upload-bytes url token source nil))
([url token source {:keys [format]}]
(request-url :post url
{:token token
:query (when (= "car" format) {:format "car"})
:headers {"Content-Type" "application/octet-stream"}
:body (coerce-upload-source source)})))
|