clj / src / ird / transport.clj
1,349 words
  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)})))

static mirror of HEAD · about · clone: git clone https://ird.cod-sursa.ro/clj.git