AtlatestRepositorysigil-http
sigil-http / tree / src / sigil / httpclient.sgl
1
;;; (sigil http client) - HTTP Client Implementation2
;;;3
;;; Provides HTTP/1.1 client functionality for making requests to HTTP4
;;; and HTTPS servers.5
;;;6
;;; Example:7
;;; (import (sigil http client))8
;;; (let ((response (http-get "https://example.com/")))9
;;; (display (http-response-body response)))11
(define-library (sigil http client)12
(import (sigil core)13
(sigil string)14
(sigil struct)15
(sigil io)16
(sigil time)17
(only (sigil math) exact round)18
(sigil socket)19
(sigil http request)20
(sigil http response))22
;; TLS is loaded lazily to allow HTTP-only usage without TLS dependency24
(export25
;; URL parsing26
parse-url27
url-scheme28
url-host29
url-port30
url-path31
url-query33
;; High-level client API34
http-get35
http-post36
http-put37
http-delete38
http-head39
http-options40
http-patch41
http-request43
;; JSON conveniences (requires sigil-json)44
http-response-json45
http-get/json46
http-post/json48
;; Send-status classification (for timeout: callers)49
http-ack-unconfirmed?51
;; Streaming download52
http-download54
;; Byte-faithful in-memory fetch (status + headers + raw body bytes)55
http-fetch-bytes57
;; API client helpers58
build-api-url59
make-response-checker60
make-json-api62
;; Re-export response accessors for convenience63
http-response?64
http-response-status65
http-response-headers66
http-response-body68
;; Internal — exported for testing69
parse-http-response70
decode-chunked-body71
find-header-end-bytes72
no-body-expected?73
detect-framing74
chunked-body-complete?75
framing-complete?76
;; http-fetch-bytes internals — exported for testing77
fetch-parse-headers78
fetch-content-length79
fetch-assemble80
fetch-dechunk)82
(begin84
;; ============================================================85
;; Lazy TLS Loading86
;; ============================================================87
;;88
;; TLS is loaded on first HTTPS request to avoid requiring the89
;; TLS library at compile time. This allows sigil-http to be90
;; compiled without sigil-tls being present.92
;; Promise that loads TLS module on first use93
(define tls-module94
(delay95
(guard (exn (else #f))96
(load-module '(sigil tls)))))98
;; Helper to get a TLS function, with error on missing TLS99
(define (tls-ref sym)100
(let ((m (force tls-module)))101
(if m102
(module-ref m sym)103
(error "HTTPS requires TLS support. Install sigil-tls package."))))105
;; Cached TLS function promises106
(define %tls-connect (delay (tls-ref 'tls-connect)))107
(define %tls-connection? (delay (tls-ref 'tls-connection?)))108
(define %tls-read (delay (tls-ref 'tls-read)))109
(define %tls-read-bytevector (delay (tls-ref 'tls-read-bytevector)))110
(define %tls-write (delay (tls-ref 'tls-write)))111
(define %tls-close (delay (tls-ref 'tls-close)))112
(define %tls-set-non-blocking! (delay (tls-ref 'tls-set-non-blocking!)))114
;; TLS function wrappers115
(define (tls-connect* host port . connect-timeout-ms)116
(apply (force %tls-connect) host port connect-timeout-ms))118
(define (tls-connection?* conn)119
(and (force tls-module)120
((force %tls-connection?) conn)))122
(define (tls-read* conn . args)123
(apply (force %tls-read) conn args))125
(define (tls-read-bytevector* conn . args)126
(apply (force %tls-read-bytevector) conn args))128
(define (tls-write* conn data)129
((force %tls-write) conn data))131
(define (tls-close* conn)132
((force %tls-close) conn))134
(define (tls-set-non-blocking!* conn enable)135
((force %tls-set-non-blocking!) conn enable))137
;; ============================================================138
;; Lazy JSON Loading139
;; ============================================================140
;;141
;; JSON is loaded on first use of JSON conveniences to avoid142
;; requiring sigil-json when not needed.144
(define json-module145
(delay146
(guard (exn (else #f))147
(load-module '(sigil json)))))149
(define (json-ref sym)150
(let ((m (force json-module)))151
(if m152
(module-ref m sym)153
(error "JSON functions require sigil-json package."))))155
(define %json-decode (delay (json-ref 'json-decode)))156
(define %json-encode (delay (json-ref 'json-encode)))158
(define (json-decode* str)159
((force %json-decode) str))161
(define (json-encode* value)162
((force %json-encode) value))164
;; ============================================================165
;; URL Record and Parsing166
;; ============================================================168
(define-struct url169
(scheme) ; "http" or "https"170
(host) ; "example.com"171
(port) ; 80, 443, or custom172
(path) ; "/path/to/resource"173
(query)) ; "foo=bar" or #f175
;;; Parse a URL string into a url record.176
;;;177
;;; Supports `http://host:port/path?query` and `https://...`.178
;;; Use `url-scheme`, `url-host`, `url-port`, `url-path`, `url-query`179
;;; to access the components.180
;;;181
;;; ```scheme182
;;; (let ((u (parse-url "https://example.com:8080/api?key=val")))183
;;; (url-host u)) ; => "example.com"184
;;; ```185
(define (parse-url url-string)186
(: string? -> any?)187
(let* ((scheme-end (string-index url-string (lambda (c) (char=? c #\:))))188
(scheme (if scheme-end189
(substring url-string 0 scheme-end)190
"http"))191
;; Skip "://" after scheme192
(rest-start (if scheme-end193
(+ scheme-end 3) ; Skip "://"194
0))195
(rest (substring url-string rest-start (string-length url-string)))196
;; Find end of host:port (first / or end of string)197
(path-start (string-index rest (lambda (c) (char=? c #\/))))198
(authority (if path-start199
(substring rest 0 path-start)200
rest))201
(path-and-query (if path-start202
(substring rest path-start (string-length rest))203
"/"))204
;; Parse host:port from authority205
(port-sep (string-index authority (lambda (c) (char=? c #\:))))206
(host (if port-sep207
(substring authority 0 port-sep)208
authority))209
(port (cond210
(port-sep211
(string->number (substring authority (+ port-sep 1)212
(string-length authority))))213
((string=? scheme "https") 443)214
(else 80)))215
;; Parse path?query216
(query-start (string-index path-and-query (lambda (c) (char=? c #\?))))217
(path (if query-start218
(substring path-and-query 0 query-start)219
path-and-query))220
(query (if query-start221
(substring path-and-query (+ query-start 1)222
(string-length path-and-query))223
#f)))224
(url scheme: scheme225
host: host226
port: port227
path: path228
query: query)))230
;; ============================================================231
;; Low-level Connection Helpers232
;; ============================================================234
;;; Connect to a server, using TLS if scheme is https.235
;;; Returns connection object or #f on failure.236
;;;237
;;; `connect-timeout` (seconds, or #f) bounds the TLS connect phase238
;;; so a blackholed address can't hang on the OS SYN timeout. It is239
;;; only honored for HTTPS (the TLS connect path); plain HTTP uses240
;;; the socket layer's blocking connect (no timeout knob there yet).241
(define (connect-to-server parsed-url connect-timeout)242
(let ((host (url-host parsed-url))243
(port (url-port parsed-url))244
(scheme (url-scheme parsed-url)))245
(if (string=? scheme "https")246
(if (and connect-timeout (> connect-timeout 0))247
(tls-connect* host port248
(exact (round (* connect-timeout 1000))))249
(tls-connect* host port))250
(tcp-connect host port))))252
;;; Write data to connection (socket or TLS)253
(define (conn-write conn data)254
(if (tls-connection?* conn)255
(tls-write* conn data)256
(socket-write conn data)))258
;;; Read data from connection (socket or TLS)259
(define (conn-read conn . max-bytes)260
(if (tls-connection?* conn)261
(if (null? max-bytes)262
(tls-read* conn)263
(tls-read* conn (car max-bytes)))264
(if (null? max-bytes)265
(socket-read conn)266
(socket-read conn (car max-bytes)))))268
;;; Read binary data from connection as bytevector (socket or TLS)269
(define (conn-read-bytes conn . max-bytes)270
(if (tls-connection?* conn)271
(if (null? max-bytes)272
(tls-read-bytevector* conn)273
(tls-read-bytevector* conn (car max-bytes)))274
(if (null? max-bytes)275
(socket-read-bytevector conn)276
(socket-read-bytevector conn (car max-bytes)))))278
;;; Close connection279
(define (conn-close conn)280
(if (tls-connection?* conn)281
(tls-close* conn)282
(socket-close conn)))284
;;; Put a connection (socket or TLS) into non-blocking mode.285
;;; Used by the timeout path so reads return immediately when no286
;;; data is available, letting the read loop enforce a deadline.287
(define (conn-set-non-blocking! conn)288
(if (tls-connection?* conn)289
(tls-set-non-blocking!* conn #t)290
(socket-set-non-blocking! conn #t)))292
;; ============================================================293
;; Read Timeouts (opt-in)294
;; ============================================================295
;;296
;; By default (timeout: omitted/#f) all reads are blocking and297
;; behave exactly as before. When a positive timeout is supplied,298
;; the connection is switched to non-blocking after the request is299
;; written and the read loop polls until data arrives, the peer300
;; closes, or a wall-clock deadline passes — at which point a clean301
;; timeout error is raised so callers can reconnect instead of302
;; blocking forever on a half-open/blackholed connection.304
;; Seconds to sleep between empty (no-data-yet) non-blocking reads.305
(define *read-poll-interval* 0.02)307
;;; Compute an absolute deadline in jiffies from a timeout in308
;;; seconds, or #f when no (positive) timeout was requested.309
(define (timeout->deadline timeout)310
(if (and timeout (number? timeout) (> timeout 0))311
;; Keep the deadline an exact integer: `current-jiffy` is a312
;; large exact value, and adding an inexact offset to it would313
;; lose nanosecond precision at that magnitude.314
(+ (current-jiffy)315
(exact (round (* timeout (jiffies-per-second)))))316
#f))318
;;; Has the (jiffy) deadline passed?319
(define (deadline-expired? deadline)320
(and deadline (>= (current-jiffy) deadline)))322
;; Irritant marking an exception as "the request was written to the323
;; server (so it was likely delivered/processed) but reading the324
;; response failed" — as opposed to a connect/write failure where the325
;; request never left. Callers can use `http-ack-unconfirmed?` to326
;; treat the send as best-effort success (no retry) rather than a327
;; failed send (retry would re-deliver an already-delivered request).328
(define ack-unconfirmed-irritant 'http-ack-unconfirmed)330
;;; Is `exn` an "ack unconfirmed" error (request sent, response read331
;;; failed)? Distinguishes a delivered-but-unconfirmed send from a332
;;; genuine never-sent failure (the latter does not carry this mark).333
(define (http-ack-unconfirmed? exn)334
(and (error-object? exn)335
(memq ack-unconfirmed-irritant (error-object-irritants exn))336
#t))338
;;; Raise an "ack unconfirmed" error: the request was sent but the339
;;; response could not be read (read deadline, empty/closed read, or340
;;; unparseable response). Carries `ack-unconfirmed-irritant`.341
(define (raise-http-ack-unconfirmed reason)342
(error (string-append "HTTP request sent but response read failed: "343
reason)344
ack-unconfirmed-irritant))346
;;; Raise a clean timeout error (read deadline). Marked ack-unconfirmed347
;;; because the deadline only fires after the request was written.348
(define (raise-http-timeout)349
(raise-http-ack-unconfirmed "read deadline exceeded"))351
;; ============================================================352
;; HTTP Request Building353
;; ============================================================355
;;; Build HTTP request string356
(define (build-request-string method parsed-url headers body)357
(let* ((path (url-path parsed-url))358
(query (url-query parsed-url))359
(uri (if query360
(string-append path "?" query)361
path))362
(host (url-host parsed-url))363
(port (url-port parsed-url))364
(host-header (if (or (and (string=? (url-scheme parsed-url) "http")365
(= port 80))366
(and (string=? (url-scheme parsed-url) "https")367
(= port 443)))368
host369
(string-append host ":" (number->string port)))))370
(string-append371
;; Request line372
(symbol->string method) " " uri " HTTP/1.1\r\n"373
;; Host header (required for HTTP/1.1)374
"Host: " host-header "\r\n"375
;; User-Agent376
"User-Agent: Sigil/1.0\r\n"377
;; Connection378
"Connection: close\r\n"379
;; Additional headers380
(build-header-lines headers)381
;; Content-Length if body present (use byte length for UTF-8)382
(if body383
(string-append "Content-Length: "384
(number->string385
(bytevector-length (string->utf8 body)))386
"\r\n")387
"")388
;; End of headers389
"\r\n"390
;; Body391
(or body ""))))393
;;; Build header lines from headers (dict or alist)394
(define (build-header-lines headers)395
(cond396
;; Empty397
((null? headers) "")398
;; Dict - convert entries to header lines399
((dict? headers)400
(let loop ((entries (dict-entries headers)) (result ""))401
(if (null? entries)402
result403
(let ((entry (car entries)))404
(loop (cdr entries)405
(string-append result406
(keyword->string (car entry))407
": "408
(cdr entry)409
"\r\n"))))))410
;; Alist - legacy format411
(else412
(let loop ((headers headers) (result ""))413
(if (null? headers)414
result415
(let ((h (car headers)))416
(loop (cdr headers)417
(string-append result418
(car h) ": " (cdr h) "\r\n"))))))))420
;; ============================================================421
;; HTTP Response Parsing422
;; ============================================================424
;;; Parse HTTP status line425
;;; Returns (version status-code reason) or #f426
(define (parse-status-line line)427
(let ((space1 (string-index line (lambda (c) (char=? c #\space)))))428
(if (not space1)429
#f430
(let* ((version (substring line 0 space1))431
(rest (substring line (+ space1 1) (string-length line)))432
(space2 (string-index rest (lambda (c) (char=? c #\space)))))433
(if (not space2)434
#f435
(let ((status-str (substring rest 0 space2))436
(reason (substring rest (+ space2 1) (string-length rest))))437
(list version (string->number status-str) reason)))))))439
;;; Parse response headers from data440
;;; Returns dict with keyword keys441
(define (parse-response-headers lines)442
(let loop ((lines lines) (headers #{}))443
(if (null? lines)444
headers445
(let* ((line (car lines))446
(colon-pos (string-index line (lambda (c) (char=? c #\:)))))447
(if colon-pos448
(let ((name (string->keyword449
(string-downcase (substring line 0 colon-pos))))450
(value (string-trim451
(substring line (+ colon-pos 1)452
(string-length line)))))453
(loop (cdr lines)454
(dict-set headers name value)))455
(loop (cdr lines) headers))))))457
;;; Find the end of the header block (\r\n\r\n) in a bytevector.458
;;; Returns the index of the first \r, or #f if not yet present.459
(define (find-header-end-bytes bv)460
(let ((len (bytevector-length bv)))461
(let loop ((i 0))462
(if (> (+ i 3) (- len 1))463
#f464
(if (and (= (bytevector-u8-ref bv i) 13)465
(= (bytevector-u8-ref bv (+ i 1)) 10)466
(= (bytevector-u8-ref bv (+ i 2)) 13)467
(= (bytevector-u8-ref bv (+ i 3)) 10))468
i469
(loop (+ i 1)))))))471
;;; Does this method/status combination forbid a response body?472
;;; HEAD never has a body; 1xx/204/304 never have a body (RFC 9112).473
(define (no-body-expected? method status)474
(or (eq? method 'HEAD)475
(and status476
(or (= status 204)477
(= status 304)478
(and (>= status 100) (< status 200))))))480
;;; Inspect the accumulated response bytes and determine how the body481
;;; is framed. Returns #f while the header block is still incomplete,482
;;; otherwise a descriptor:483
;;; (no-body) — no body permitted; complete at headers484
;;; (length <body-start> <n>) — fixed Content-Length body485
;;; (chunked <body-start>) — chunked transfer-encoding486
;;; (until-close) — unframed; read until the peer closes487
;;; This lets the reader stop as soon as the full body has arrived488
;;; instead of waiting for the connection to close (EOF), which a489
;;; keep-alive/half-open peer may never do.490
(define (detect-framing method bv)491
(let ((he (find-header-end-bytes bv)))492
(if (not he)493
#f494
(let* ((body-start (+ he 4))495
(header-str (utf8->string (bytevector-copy bv 0 he)))496
(lines (string-split header-str "\r\n"))497
(status-info (and (pair? lines) (parse-status-line (car lines))))498
(status (and status-info (cadr status-info)))499
(headers (if (pair? lines)500
(parse-response-headers (cdr lines))501
#{}))502
(te (dict-ref headers transfer-encoding: #f))503
(cl (dict-ref headers content-length: #f)))504
(cond505
((no-body-expected? method status) (list 'no-body))506
((and te (string-contains? (string-downcase te) "chunked"))507
(list 'chunked body-start))508
(cl (let ((n (string->number (string-trim cl))))509
(if n (list 'length body-start n) (list 'until-close))))510
(else (list 'until-close)))))))512
;;; Is the chunked body starting at `start` fully present in `bv`?513
;;; Walks chunk-size lines until the terminating 0-size chunk and its514
;;; closing CRLF (no trailers). Returns #f if more data is needed.515
(define (chunked-body-complete? bv start)516
(let ((len (bytevector-length bv)))517
(let loop ((pos start))518
(let ((line-end (find-crlf-bytes bv pos)))519
(if (not line-end)520
#f521
(let* ((size-str (utf8->string (bytevector-copy bv pos line-end)))522
(sz (hex-string->number size-str)))523
(cond524
((not sz) #f)525
((= sz 0)526
;; Final chunk: need the closing CRLF after "0\r\n".527
(let ((after (+ line-end 2)))528
(and (<= (+ after 2) len)529
(= (bytevector-u8-ref bv after) 13)530
(= (bytevector-u8-ref bv (+ after 1)) 10))))531
(else532
;; size-line CRLF + data + trailing CRLF533
(let ((next (+ line-end 2 sz 2)))534
(if (> next len) #f (loop next)))))))))))536
;;; Is the response complete per its framing descriptor?537
;;; `combined` is the accumulated bytevector (only needed for chunked).538
(define (framing-complete? framing total combined)539
(let ((mode (car framing)))540
(cond541
((eq? mode 'no-body) #t)542
((eq? mode 'length) (>= (- total (cadr framing)) (caddr framing)))543
((eq? mode 'chunked) (chunked-body-complete? combined (cadr framing)))544
(else #f)))) ; until-close — rely on EOF546
;;; Read HTTP response from connection547
;;; Returns http-response or #f on error.548
;;; `method` is the request method (HEAD responses carry no body).549
;;; `deadline` is a jiffy deadline (or #f). When set, the read loop550
;;; enforces a timeout instead of blocking on a stalled read.551
(define (read-http-response method conn deadline)552
;; Read all available data553
(let ((data (read-all-data method conn deadline)))554
(if (or (not data) (string=? data ""))555
#f556
(parse-http-response data))))558
;;; Read a full HTTP response from the connection.559
;;; Reads as bytevectors and converts to string once at the end to560
;;; avoid splitting multi-byte UTF-8 characters across chunks.561
;;;562
;;; Termination is HTTP-framing-aware: once the header block has563
;;; arrived, the response is considered complete as soon as the body564
;;; is fully received per its framing (Content-Length, the chunked565
;;; 0-terminator, or a body-less status/method). The reader does NOT566
;;; wait for the connection to close — a keep-alive or half-open peer567
;;; may hold it open indefinitely even after sending a complete568
;;; response, which previously hung the read (or, with a deadline,569
;;; tripped a spurious timeout on an already-delivered request).570
;;; Only an unframed response (no Content-Length, not chunked) falls571
;;; back to reading until EOF.572
;;;573
;;; When `deadline` is #f, reads block. When set, the connection is574
;;; non-blocking: an empty read means "no data yet", so we sleep575
;;; briefly and retry until the response completes, the peer closes,576
;;; or the deadline passes (which raises a timeout error).577
(define (read-all-data method conn deadline)578
(let loop ((chunks '()) (total 0) (framing #f))579
(let ((chunk (conn-read-bytes conn 8192)))580
(cond581
((not chunk)582
;; Error583
(if (null? chunks)584
#f585
(utf8->string (apply bytevector-append (reverse chunks)))))586
((eof-object? chunk)587
;; Peer closed — done (the only terminator for until-close).588
(utf8->string (apply bytevector-append (reverse chunks))))589
((= (bytevector-length chunk) 0)590
(if deadline591
;; Non-blocking: no data yet — enforce the deadline.592
(if (deadline-expired? deadline)593
(begin (conn-close conn) (raise-http-timeout))594
(begin (sleep *read-poll-interval*) (loop chunks total framing)))595
;; Blocking (no timeout): no data available, done.596
(utf8->string (apply bytevector-append (reverse chunks)))))597
(else598
(let* ((chunks* (cons chunk chunks))599
(total* (+ total (bytevector-length chunk)))600
;; Combine only while detecting headers or scanning a601
;; chunked body; Content-Length completion is a cheap602
;; byte-count check needing no recombination.603
(need-bytes (or (not framing)604
(eq? (car framing) 'chunked)))605
(combined (and need-bytes606
(apply bytevector-append (reverse chunks*))))607
(framing* (or framing608
(and combined (detect-framing method combined)))))609
(if (and framing* (framing-complete? framing* total* combined))610
(utf8->string (or combined611
(apply bytevector-append (reverse chunks*))))612
(loop chunks* total* framing*))))))))614
;;; Parse HTTP response from string615
(define (parse-http-response data)616
;; Find end of headers (blank line)617
(let ((header-end (find-header-end data)))618
(if (not header-end)619
#f620
(let* ((header-section (substring data 0 header-end))621
(body-start (skip-crlf data header-end))622
(raw-body (if (< body-start (string-length data))623
(substring data body-start (string-length data))624
""))625
(lines (string-split header-section "\r\n")))626
(if (null? lines)627
#f628
(let ((status-info (parse-status-line (car lines))))629
(if (not status-info)630
#f631
(let* ((status-code (cadr status-info))632
(headers (parse-response-headers (cdr lines)))633
(transfer-encoding (dict-ref headers transfer-encoding: #f))634
(body (if (and transfer-encoding635
(string-contains? (string-downcase transfer-encoding) "chunked"))636
(decode-chunked-body raw-body)637
raw-body)))638
(http-response639
status: status-code640
headers: headers641
body: body)))))))))643
;;; Decode chunked transfer encoding using byte-level operations.644
;;; Chunk sizes in HTTP are byte counts, so we must work with bytes645
;;; to correctly handle multi-byte UTF-8 content.646
;;; Format: <hex-size>\r\n<data>\r\n ... 0\r\n\r\n647
(define (decode-chunked-body data)648
(let* ((bv (string->utf8 data))649
(len (bytevector-length bv)))650
(let loop ((pos 0) (chunks '()))651
(if (>= pos len)652
(utf8->string (apply bytevector-append (reverse chunks)))653
;; Find end of chunk size line (\r\n)654
(let ((line-end (find-crlf-bytes bv pos)))655
(if (not line-end)656
(utf8->string (apply bytevector-append (reverse chunks)))657
;; Extract size string (ASCII, safe to convert)658
(let* ((size-bv (bytevector-copy bv pos line-end))659
(size-str (utf8->string size-bv))660
(chunk-size (hex-string->number size-str)))661
(if (or (not chunk-size) (= chunk-size 0))662
(utf8->string (apply bytevector-append (reverse chunks)))663
;; Read chunk data (byte-level offsets)664
(let ((chunk-start (+ line-end 2))665
(chunk-end (+ line-end 2 chunk-size)))666
(if (> chunk-end len)667
(utf8->string (apply bytevector-append (reverse chunks)))668
(let ((chunk (bytevector-copy bv chunk-start chunk-end)))669
(loop (+ chunk-end 2)670
(cons chunk chunks)))))))))))))672
;;; Find position of \r\n in a bytevector starting at pos673
(define (find-crlf-bytes bv pos)674
(let ((len (bytevector-length bv)))675
(let loop ((i pos))676
(if (>= i (- len 1))677
#f678
(if (and (= (bytevector-u8-ref bv i) 13) ; \r679
(= (bytevector-u8-ref bv (+ i 1)) 10)) ; \n680
i681
(loop (+ i 1)))))))683
;;; Find position of \r\n in a string starting at pos684
(define (find-crlf data pos)685
(let ((len (string-length data)))686
(let loop ((i pos))687
(if (>= i (- len 1))688
#f689
(if (and (char=? (string-ref data i) #\return)690
(char=? (string-ref data (+ i 1)) #\newline))691
i692
(loop (+ i 1)))))))694
;;; Convert hex string to number695
(define (hex-string->number str)696
(let ((s (string-trim str)))697
(if (string=? s "")698
#f699
(let loop ((i 0) (result 0))700
(if (>= i (string-length s))701
result702
(let* ((c (char-downcase (string-ref s i)))703
(digit (cond704
((and (char>=? c #\0) (char<=? c #\9))705
(- (char->integer c) (char->integer #\0)))706
((and (char>=? c #\a) (char<=? c #\f))707
(+ 10 (- (char->integer c) (char->integer #\a))))708
(else #f))))709
(if (not digit)710
result ; Stop at non-hex character711
(loop (+ i 1) (+ (* result 16) digit)))))))))713
;;; Find the end of HTTP headers (position of \r\n\r\n)714
(define (find-header-end data)715
(let ((len (string-length data)))716
(let loop ((i 0))717
(if (>= i (- len 3))718
#f719
(if (and (char=? (string-ref data i) #\return)720
(char=? (string-ref data (+ i 1)) #\newline)721
(char=? (string-ref data (+ i 2)) #\return)722
(char=? (string-ref data (+ i 3)) #\newline))723
i724
(loop (+ i 1)))))))726
;;; Skip CRLF sequence(s) at position727
(define (skip-crlf data pos)728
(let ((len (string-length data)))729
(let loop ((i pos))730
(if (>= i len)731
i732
(if (or (char=? (string-ref data i) #\return)733
(char=? (string-ref data i) #\newline))734
(loop (+ i 1))735
i)))))737
;; ============================================================738
;; High-Level Client API739
;; ============================================================741
;;; Make an HTTP request.742
;;;743
;;; Low-level function for making HTTP requests. Prefer the convenience744
;;; functions (http-get, http-post, etc.) for common cases.745
;;;746
;;; The optional `timeout:` keyword bounds the response read. When747
;;; given a positive number of seconds, the read is made748
;;; non-blocking and a timeout error is raised if no complete749
;;; response arrives before the deadline (so a half-open/blackholed750
;;; connection fails cleanly instead of hanging). When omitted (the751
;;; default), reads block exactly as before.752
;;;753
;;; The optional `connect-timeout:` keyword (seconds) bounds the754
;;; HTTPS connect phase, so a blackholed address can't hang on the755
;;; OS SYN timeout. Omitted/#f keeps the blocking connect.756
;;;757
;;; ```scheme758
;;; (http-request 'GET "https://api.example.com/users"759
;;; headers: #{ authorization: "Bearer token" })760
;;;761
;;; (http-request 'POST "https://api.example.com/users"762
;;; headers: #{ content-type: "application/json" }763
;;; body: "{\"name\": \"Alice\"}"764
;;; timeout: 25)765
;;; ```766
(define (http-request method url (keys: (headers #{}) (body #f) (timeout #f)767
(connect-timeout #f)))768
(: symbol? string? (headers: dict?) (body: (maybe string?))769
(timeout: (maybe number?)) (connect-timeout: (maybe number?)) -> any?)770
(let* ((parsed-url (parse-url url))771
(conn (connect-to-server parsed-url connect-timeout)))772
(if (not conn)773
#f ; connect failed — request never sent774
(let ((deadline (timeout->deadline timeout))775
(request-str (build-request-string method parsed-url headers body)))776
;; Write the request while still blocking (requests are777
;; small and fit the socket buffer), then switch to778
;; non-blocking so the read loop can enforce the deadline.779
(let ((wrote (conn-write conn request-str)))780
;; Phase distinction is only surfaced when a timeout is in781
;; use (opt-in); without it, behavior is byte-identical to782
;; before (the write result is ignored and a failed read783
;; just yields #f).784
(if (and deadline (not wrote))785
(begin (conn-close conn) #f) ; write failed — never (fully) sent786
(begin787
(when deadline (conn-set-non-blocking! conn))788
(let ((response (read-http-response method conn deadline)))789
(conn-close conn)790
(if (and deadline (not response))791
;; Request was written but no usable response792
;; came back → delivered, ack unconfirmed.793
(raise-http-ack-unconfirmed "no response read")794
response)))))))))796
;;; HTTP GET request.797
;;;798
;;; ```scheme799
;;; (http-get "https://example.com/")800
;;;801
;;; (http-get "https://api.example.com/users"802
;;; headers: #{ authorization: "Bearer token" })803
;;; ```804
(define (http-get url (keys: (headers #{}) (timeout #f) (connect-timeout #f)))805
(: string? (headers: dict?) (timeout: (maybe number?))806
(connect-timeout: (maybe number?)) -> any?)807
(http-request 'GET url headers: headers timeout: timeout808
connect-timeout: connect-timeout))810
;;; HTTP POST request.811
;;;812
;;; If no Content-Type header is provided, defaults to813
;;; application/x-www-form-urlencoded.814
;;;815
;;; ```scheme816
;;; (http-post "https://api.example.com/data" "key=value")817
;;;818
;;; (http-post "https://api.example.com/data"819
;;; "{\"key\": \"value\"}"820
;;; headers: #{ content-type: "application/json" })821
;;; ```822
(define (http-post url body (keys: (headers #{}) (timeout #f) (connect-timeout #f)))823
(: string? any? (headers: dict?) (timeout: (maybe number?))824
(connect-timeout: (maybe number?)) -> any?)825
(let ((hdrs (if (and (dict? headers) (not (dict-contains? headers content-type:)))826
(dict-set headers content-type: "application/x-www-form-urlencoded")827
headers)))828
(http-request 'POST url headers: hdrs body: body timeout: timeout829
connect-timeout: connect-timeout)))831
;;; HTTP PUT request.832
;;;833
;;; ```scheme834
;;; (http-put "https://api.example.com/users/123"835
;;; "{\"name\": \"Alice\"}"836
;;; headers: #{ content-type: "application/json" })837
;;; ```838
(define (http-put url body (keys: (headers #{}) (timeout #f) (connect-timeout #f)))839
(: string? any? (headers: dict?) (timeout: (maybe number?))840
(connect-timeout: (maybe number?)) -> any?)841
(http-request 'PUT url headers: headers body: body timeout: timeout842
connect-timeout: connect-timeout))844
;;; HTTP DELETE request.845
;;;846
;;; ```scheme847
;;; (http-delete "https://api.example.com/users/123")848
;;;849
;;; (http-delete "https://api.example.com/users/123"850
;;; headers: #{ authorization: "Bearer token" })851
;;; ```852
(define (http-delete url (keys: (headers #{}) (timeout #f) (connect-timeout #f)))853
(: string? (headers: dict?) (timeout: (maybe number?))854
(connect-timeout: (maybe number?)) -> any?)855
(http-request 'DELETE url headers: headers timeout: timeout856
connect-timeout: connect-timeout))858
;;; HTTP HEAD request.859
;;;860
;;; Like GET but only retrieves headers, not body.861
;;; Useful for checking if a resource exists or getting metadata.862
;;;863
;;; ```scheme864
;;; (let ((res (http-head "https://example.com/file.pdf")))865
;;; (http-response-header res "Content-Length"))866
;;; ```867
(define (http-head url (keys: (headers #{}) (timeout #f) (connect-timeout #f)))868
(: string? (headers: dict?) (timeout: (maybe number?))869
(connect-timeout: (maybe number?)) -> any?)870
(http-request 'HEAD url headers: headers timeout: timeout871
connect-timeout: connect-timeout))873
;;; HTTP OPTIONS request.874
;;;875
;;; Query server for allowed methods on a resource.876
;;;877
;;; ```scheme878
;;; (let ((res (http-options "https://api.example.com/users")))879
;;; (http-response-header res "Allow"))880
;;; ; => "GET, POST, OPTIONS"881
;;; ```882
(define (http-options url (keys: (headers #{}) (timeout #f) (connect-timeout #f)))883
(: string? (headers: dict?) (timeout: (maybe number?))884
(connect-timeout: (maybe number?)) -> any?)885
(http-request 'OPTIONS url headers: headers timeout: timeout886
connect-timeout: connect-timeout))888
;;; HTTP PATCH request.889
;;;890
;;; Partially update a resource. Unlike PUT which replaces the entire891
;;; resource, PATCH applies partial modifications.892
;;;893
;;; ```scheme894
;;; (http-patch "https://api.example.com/users/123"895
;;; "{\"email\": \"[email protected]\"}"896
;;; headers: #{ content-type: "application/json" })897
;;; ```898
(define (http-patch url body (keys: (headers #{}) (timeout #f) (connect-timeout #f)))899
(: string? any? (headers: dict?) (timeout: (maybe number?))900
(connect-timeout: (maybe number?)) -> any?)901
(http-request 'PATCH url headers: headers body: body timeout: timeout902
connect-timeout: connect-timeout))904
;; ============================================================905
;; JSON Conveniences906
;; ============================================================908
;;; Parse HTTP response body as JSON.909
;;;910
;;; Returns the parsed JSON value, or #f if the response is #f911
;;; or parsing fails. Requires sigil-json package.912
;;;913
;;; ```scheme914
;;; (let ((res (http-get "https://api.example.com/data")))915
;;; (http-response-json res))916
;;; ; => #{ users: #[...] count: 42 }917
;;; ```918
(define (http-response-json response)919
(: any? -> any?)920
(if (and response (http-response-body response))921
(guard (exn (else #f))922
(json-decode* (http-response-body response)))923
#f))925
;;; HTTP GET request expecting JSON response.926
;;;927
;;; Makes a GET request and parses the response body as JSON.928
;;; Returns the parsed JSON value, or #f if request fails or929
;;; status is not 2xx.930
;;;931
;;; ```scheme932
;;; (http-get/json "https://api.example.com/users")933
;;; ; => #{ users: #[...] }934
;;;935
;;; (http-get/json "https://api.example.com/users"936
;;; headers: #{ authorization: "Bearer token" })937
;;; ```938
(define (http-get/json url (keys: (headers #{}) (timeout #f) (connect-timeout #f)))939
(: string? (headers: dict?) (timeout: (maybe number?))940
(connect-timeout: (maybe number?)) -> any?)941
(let ((res (http-get url headers: headers timeout: timeout942
connect-timeout: connect-timeout)))943
(and res (http-response-json res))))945
;;; HTTP POST request with JSON body, expecting JSON response.946
;;;947
;;; Encodes the body as JSON, sets Content-Type to application/json,948
;;; and parses the response as JSON. Returns parsed JSON on any status949
;;; code, or #f if the request failed entirely.950
;;;951
;;; ```scheme952
;;; (http-post/json "https://api.example.com/users"953
;;; #{ name: "Alice" email: "[email protected]" })954
;;; ; => #{ id: 123 name: "Alice" }955
;;;956
;;; (http-post/json "https://api.example.com/users"957
;;; #{ name: "Alice" }958
;;; headers: #{ authorization: "Bearer token" })959
;;; ```960
(define (http-post/json url body (keys: (headers #{}) (timeout #f) (connect-timeout #f)))961
(: string? any? (headers: dict?) (timeout: (maybe number?))962
(connect-timeout: (maybe number?)) -> any?)963
(let* ((json-body (json-encode* body))964
(hdrs (dict-set headers content-type: "application/json"))965
(res (http-request 'POST url headers: hdrs body: json-body timeout: timeout966
connect-timeout: connect-timeout)))967
(and res (http-response-json res))))969
;; ============================================================970
;; Streaming Download971
;; ============================================================973
;;; Read HTTP response headers only, without consuming the body.974
;;;975
;;; Returns a list: (status-code headers leftover-string)976
;;; where leftover-string is any body data already read past the977
;;; header boundary. Returns #f on failure.978
(define (read-response-headers conn)979
(let loop ((accumulated ""))980
(let ((chunk (conn-read conn 8192)))981
(cond982
((or (not chunk) (eof-object? chunk))983
;; Connection closed or error before headers complete984
(let ((data (if (and chunk (not (eof-object? chunk)))985
(string-append accumulated chunk)986
accumulated)))987
(let ((header-end (find-header-end data)))988
(if header-end989
(parse-header-result data header-end)990
#f))))991
((string=? chunk "")992
;; Non-blocking, no data yet - check what we have993
(let ((header-end (find-header-end accumulated)))994
(if header-end995
(parse-header-result accumulated header-end)996
(loop accumulated))))997
(else998
(let* ((data (string-append accumulated chunk))999
(header-end (find-header-end data)))1000
(if header-end1001
(parse-header-result data header-end)1002
(loop data))))))))1004
;;; Parse headers from data at the given header-end position.1005
;;; Returns (status-code headers leftover-string).1006
(define (parse-header-result data header-end)1007
(let* ((header-section (substring data 0 header-end))1008
(body-start (skip-crlf data header-end))1009
(leftover (if (< body-start (string-length data))1010
(substring data body-start (string-length data))1011
""))1012
(lines (string-split header-section "\r\n")))1013
(if (null? lines)1014
#f1015
(let ((status-info (parse-status-line (car lines))))1016
(if (not status-info)1017
#f1018
(list (cadr status-info)1019
(parse-response-headers (cdr lines))1020
leftover))))))1022
;;; Stream response body from connection to an output port.1023
;;;1024
;;; Writes leftover bytes (from header read) first, then reads1025
;;; remaining data as bytevectors and writes them to the port.1026
(define (stream-body-to-port conn port content-length leftover-string on-progress)1027
(let ((written 0))1028
;; Write any leftover data from header reading1029
(when (and leftover-string (not (string=? leftover-string "")))1030
(let ((bv (string->utf8 leftover-string)))1031
(write-bytevector bv port)1032
(set! written (+ written (bytevector-length bv)))1033
(when on-progress1034
(on-progress written content-length))))1035
;; Stream remaining body1036
(let loop ()1037
(when (or (not content-length) (< written content-length))1038
(let ((chunk (conn-read-bytes conn 65536)))1039
(cond1040
((or (not chunk) (eof-object? chunk))1041
;; Done or error1042
#t)1043
((= (bytevector-length chunk) 0)1044
;; Non-blocking, no data yet1045
(loop))1046
(else1047
(write-bytevector chunk port)1048
(set! written (+ written (bytevector-length chunk)))1049
(when on-progress1050
(on-progress written content-length))1051
(loop))))))1052
written))1054
;;; Download a URL to a file, streaming data directly to disk.1055
;;;1056
;;; Unlike `http-get` which loads the entire response into memory,1057
;;; `http-download` streams the response body to a file, making it1058
;;; suitable for large downloads.1059
;;;1060
;;; The `on-progress` callback receives `(bytes-received total-bytes)`1061
;;; where `total-bytes` may be `#f` if the server didn't send1062
;;; Content-Length.1063
;;;1064
;;; Returns a dict with download info on success, or `#f` on failure.1065
;;;1066
;;; ```scheme1067
;;; (http-download "https://example.com/large-file.bin"1068
;;; "/tmp/file.bin")1069
;;; ; => #{ status: 200 size: 12345 path: "/tmp/file.bin" }1070
;;;1071
;;; (http-download "https://example.com/file.bin"1072
;;; "/tmp/file.bin"1073
;;; on-progress: (lambda (received total)1074
;;; (display (str received "/" total "\r"))))1075
;;; ```1076
(define (http-download url dest-path1077
(keys: (headers #{})1078
(on-progress #f)1079
(max-redirects 5)))1080
(let* ((parsed-url (parse-url url))1081
(conn (connect-to-server parsed-url #f)))1082
(if (not conn)1083
#f1084
(let ((request-str (build-request-string 'GET parsed-url headers #f)))1085
(conn-write conn request-str)1086
(let ((result (read-response-headers conn)))1087
(if (not result)1088
(begin (conn-close conn) #f)1089
(let ((status (car result))1090
(resp-headers (cadr result))1091
(leftover (caddr result)))1092
;; Handle redirects1093
(if (and (member status '(301 302 303 307 308))1094
(> max-redirects 0))1095
(let ((location (dict-ref resp-headers location: #f)))1096
(conn-close conn)1097
(if location1098
(http-download location dest-path1099
headers: headers1100
on-progress: on-progress1101
max-redirects: (- max-redirects 1))1102
#f))1103
;; Download body1104
(let* ((content-length-str1105
(dict-ref resp-headers content-length: #f))1106
(content-length1107
(if content-length-str1108
(string->number content-length-str)1109
#f))1110
(port (open-binary-output-file dest-path))1111
(bytes-written1112
(stream-body-to-port conn port content-length1113
leftover on-progress)))1114
(close-output-port port)1115
(conn-close conn)1116
(dict status: status1117
size: bytes-written1118
path: dest-path))))))))))1120
;; ============================================================1121
;; Byte-faithful fetch (raw response bytes)1122
;; ============================================================1123
;;1124
;; `http-request` utf8->strings the whole body (corrupting any non-UTF-81125
;; payload — wasm, images, archives), and `http-download` streams to a1126
;; file. `http-fetch-bytes` returns the response IN MEMORY as raw bytes so1127
;; a caller such as a reverse proxy can relay it byte-for-byte.1129
(define fetch-default-timeout 30) ; seconds1130
(define fetch-read-chunk 65536)1131
(define fetch-max-idle-polls 6000)1133
;;; Fetch `url` (a `method` symbol, optional request `headers` dict and1134
;;; string `body`) and return the response as raw bytes:1135
;;;1136
;;; #{ status: <integer>1137
;;; headers: <ordered alist of (lowercased-name . value)>1138
;;; body: <bytevector> }1139
;;;1140
;;; or #f if the upstream could not be reached / the response was1141
;;; unparseable. Distinct from `http-request` in three ways a byte-exact1142
;;; relay needs:1143
;;;1144
;;; * the body is a BYTEVECTOR, never decoded to a string;1145
;;; * `headers` is an ORDERED alist that preserves order AND duplicates1146
;;; (e.g. multiple Set-Cookie), which a dict would silently collapse;1147
;;; * REDIRECTS ARE NOT FOLLOWED — a 3xx is returned untouched (status +1148
;;; Location intact) so the caller decides whether to chase it. A1149
;;; reverse proxy must relay redirects, not follow them; a1150
;;; redirect-following wrapper can layer on top.1151
;;;1152
;;; `timeout` (seconds, or #f -> 30) bounds the TLS connect and is the1153
;;; IDLE read deadline (it resets whenever bytes arrive, so a large but1154
;;; steadily-flowing body never times out). Sends `Connection: close`, so1155
;;; a body with no Content-Length is read to EOF.1156
;;;1157
;;; ```1158
;;; (let ((r (http-fetch-bytes 'GET "https://example.com/app.wasm")))1159
;;; (and r (bytevector-length (dict-ref r body: #f))))1160
;;; ```1161
(define (http-fetch-bytes method url (keys: (headers #{}) (body #f) (timeout #f)))1162
(let ((secs (or timeout fetch-default-timeout)))1163
(guard (e (#t #f))1164
(let ((parsed (parse-url url)))1165
(and parsed1166
(let ((conn (connect-to-server parsed secs)))1167
(and conn1168
(guard (e (#t (begin (fetch-safe-close conn) #f)))1169
(conn-write conn (build-request-string method parsed headers body))1170
(let ((result (fetch-read-response conn method secs)))1171
(fetch-safe-close conn)1172
result)))))))))1174
(define (fetch-safe-close conn)1175
(guard (e (#t #f)) (conn-close conn)))1177
;; Phase 1: accumulate bytes until the CRLFCRLF header terminator (headers1178
;; are small, so the bounded append here is cheap), then hand off to the1179
;; body reader. Returns the result dict, or #f if the connection closed1180
;; before a complete header block arrived.1181
(define (fetch-read-response conn method timeout)1182
(let loop ((buf (make-bytevector 0)) (idle 0) (deadline (+ (current-second) timeout)))1183
(cond1184
((or (>= (current-second) deadline) (> idle fetch-max-idle-polls)) #f)1185
(else1186
(let ((hidx (find-header-end-bytes buf)))1187
(if hidx1188
(fetch-parse conn method timeout buf hidx)1189
(let ((chunk (guard (e (#t 'err)) (conn-read-bytes conn fetch-read-chunk))))1190
(cond1191
((or (eq? chunk 'err) (not chunk) (eof-object? chunk)) #f)1192
((zero? (bytevector-length chunk)) (loop buf (+ idle 1) deadline))1193
(else (loop (bytevector-append buf chunk) 0 (+ (current-second) timeout)))))))))))1195
;; Parse the head, then read the body per its framing. `body0` is whatever1196
;; body bytes already arrived with the header block.1197
(define (fetch-parse conn method timeout buf hidx)1198
(let* ((head-bytes (bytevector-copy buf 0 hidx))1199
(body0 (bytevector-copy buf (+ hidx 4) (bytevector-length buf)))1200
(lines (string-split (utf8->string head-bytes) "\r\n"))1201
(status (and (pair? lines)1202
(let ((si (parse-status-line (car lines)))) (and si (cadr si)))))1203
(hdrs (fetch-parse-headers (if (pair? lines) (cdr lines) '())))1204
(clen (fetch-content-length hdrs))1205
(te (fetch-header hdrs "transfer-encoding"))1206
(chunked? (and te (string-contains? (string-downcase te) "chunked"))))1207
(and status1208
(let ((bodyv1209
(cond1210
((no-body-expected? method status) (make-bytevector 0))1211
((and clen (not chunked?)) (fetch-body-clen conn timeout body0 clen))1212
(chunked? (fetch-dechunk (fetch-body-eof conn timeout body0)))1213
(else (fetch-body-eof conn timeout body0)))))1214
#{ status: status headers: hdrs body: bodyv }))))1216
;; Header lines -> ordered alist of (lowercased-name . value), preserving1217
;; ORDER and DUPLICATES (a relay must keep multiple Set-Cookie etc.). A1218
;; line with no colon is skipped.1219
(define (fetch-parse-headers lines)1220
(let loop ((ls lines) (acc '()))1221
(cond1222
((null? ls) (reverse acc))1223
(else1224
(let* ((line (car ls))1225
(cpos (string-index line (lambda (c) (char=? c #\:)))))1226
(if cpos1227
(let ((name (string-downcase (string-trim (substring line 0 cpos))))1228
(value (string-trim (substring line (+ cpos 1) (string-length line)))))1229
(loop (cdr ls) (cons (cons name value) acc)))1230
(loop (cdr ls) acc)))))))1232
;; First value for a (lowercased) header name, or #f.1233
(define (fetch-header hdrs name)1234
(let loop ((hs hdrs))1235
(cond ((null? hs) #f)1236
((string=? (car (car hs)) name) (cdr (car hs)))1237
(else (loop (cdr hs))))))1239
(define (fetch-content-length hdrs)1240
(let ((v (fetch-header hdrs "content-length")))1241
(and v (let ((n (string->number (string-trim v)))) (and (integer? n) (>= n 0) n)))))1243
;; Identity body of known Content-Length: read until `clen` bytes (or a1244
;; stall / EOF). Chunks accumulate in a list; assembled once. IDLE deadline1245
;; resets on data.1246
(define (fetch-body-clen conn timeout body0 clen)1247
(let loop ((chunks (list body0)) (have (bytevector-length body0))1248
(idle 0) (deadline (+ (current-second) timeout)))1249
(cond1250
((>= have clen) (fetch-assemble (reverse chunks)))1251
((or (>= (current-second) deadline) (> idle fetch-max-idle-polls))1252
(fetch-assemble (reverse chunks)))1253
(else1254
(let ((chunk (guard (e (#t 'err)) (conn-read-bytes conn fetch-read-chunk))))1255
(cond1256
((or (eq? chunk 'err) (not chunk) (eof-object? chunk)) (fetch-assemble (reverse chunks)))1257
((zero? (bytevector-length chunk)) (loop chunks have (+ idle 1) deadline))1258
(else (loop (cons chunk chunks) (+ have (bytevector-length chunk))1259
0 (+ (current-second) timeout)))))))))1261
;; Chunked or no Content-Length: read to EOF (we send Connection: close).1262
(define (fetch-body-eof conn timeout body0)1263
(let loop ((chunks (list body0)) (idle 0) (deadline (+ (current-second) timeout)))1264
(cond1265
((or (>= (current-second) deadline) (> idle fetch-max-idle-polls))1266
(fetch-assemble (reverse chunks)))1267
(else1268
(let ((chunk (guard (e (#t 'err)) (conn-read-bytes conn fetch-read-chunk))))1269
(cond1270
((or (eq? chunk 'err) (not chunk) (eof-object? chunk)) (fetch-assemble (reverse chunks)))1271
((zero? (bytevector-length chunk)) (loop chunks (+ idle 1) deadline))1272
(else (loop (cons chunk chunks) 0 (+ (current-second) timeout)))))))))1274
;; Concatenate a list of bytevectors with a SINGLE allocation. NOT1275
;; `(apply bytevector-append …)`: a multi-MB body arrives as hundreds of1276
;; chunks, and splatting that many args silently produced an EMPTY result.1277
(define (fetch-assemble chunks)1278
(let ((total (let sum ((cs chunks) (n 0))1279
(if (null? cs) n (sum (cdr cs) (+ n (bytevector-length (car cs))))))))1280
(let ((out (make-bytevector total 0)))1281
(let copy ((cs chunks) (pos 0))1282
(if (null? cs)1283
out1284
(let ((c (car cs)))1285
(bytevector-copy! out pos c 0 (bytevector-length c))1286
(copy (cdr cs) (+ pos (bytevector-length c)))))))))1288
;; Byte-exact chunked-transfer decode: strip the hex size lines + CRLFs.1289
;; Stops at the 0-size terminator or a truncated tail (best-effort).1290
(define (fetch-dechunk bv)1291
(let ((len (bytevector-length bv)))1292
(let loop ((pos 0) (out '()))1293
(if (>= pos len)1294
(fetch-assemble (reverse out))1295
(let ((line-end (fetch-find-crlf bv pos)))1296
(if (not line-end)1297
(fetch-assemble (reverse out))1298
(let* ((size (fetch-hex (utf8->string (bytevector-copy bv pos line-end))))1299
(data (+ line-end 2)))1300
(cond1301
((or (not size) (<= size 0)) (fetch-assemble (reverse out)))1302
((> (+ data size) len) (fetch-assemble (reverse out)))1303
(else (loop (+ data size 2)1304
(cons (bytevector-copy bv data (+ data size)) out)))))))))))1306
(define (fetch-find-crlf bv pos)1307
(let ((len (bytevector-length bv)))1308
(let loop ((i pos))1309
(cond1310
((> (+ i 2) len) #f)1311
((and (= (bytevector-u8-ref bv i) 13) (= (bytevector-u8-ref bv (+ i 1)) 10)) i)1312
(else (loop (+ i 1)))))))1314
(define (fetch-hex s)1315
(let* ((t (string-trim s))1316
(semi (string-index t (lambda (c) (char=? c #\;))))1317
(hx (if semi (substring t 0 semi) t))1318
(len (string-length hx)))1319
(if (= len 0)1320
#f1321
(let loop ((i 0) (acc 0))1322
(if (>= i len)1323
acc1324
(let ((d (fetch-hex-digit (string-ref hx i))))1325
(if d (loop (+ i 1) (+ (* acc 16) d)) #f)))))))1327
(define (fetch-hex-digit ch)1328
(cond1329
((and (char>=? ch #\0) (char<=? ch #\9)) (- (char->integer ch) 48))1330
((and (char>=? ch #\a) (char<=? ch #\f)) (+ 10 (- (char->integer ch) 97)))1331
((and (char>=? ch #\A) (char<=? ch #\F)) (+ 10 (- (char->integer ch) 65)))1332
(else #f)))1334
;; ============================================================1335
;; API Client Helpers1336
;; ============================================================1338
;;; Build an API URL from a base URL and path segments.1339
;;;1340
;;; Common pattern across API clients: concatenate a base URL with1341
;;; slash-separated path parts.1342
;;;1343
;;; ```scheme1344
;;; (build-api-url "https://api.example.com" "v1" "users" "123")1345
;;; ; => "https://api.example.com/v1/users/123"1346
;;; ```1347
(define (build-api-url base-url . parts)1348
(apply string-append base-url1349
(map (lambda (p) (string-append "/" p)) parts)))1351
;;; Create a response checker function for an API client.1352
;;;1353
;;; Takes a name for error messages and an optional list of1354
;;; (status-code . message) pairs for specific error handling.1355
;;; Returns a function that checks an HTTP response and either1356
;;; returns parsed JSON on success or raises an error.1357
;;;1358
;;; The optional `parse-error` keyword accepts a function1359
;;; `(lambda (status body) ...)` for custom error body parsing1360
;;; (e.g., JSON:API error extraction). When provided, it is called1361
;;; instead of the default handler for status >= 400 that don't1362
;;; match a specific handler entry.1363
;;;1364
;;; ```scheme1365
;;; (define check-response1366
;;; (make-response-checker1367
;;; name: "YouTube API"1368
;;; handlers: (list1369
;;; (cons 401 "Access token may be expired.")1370
;;; (cons 403 "Possible quota exceeded."))))1371
;;;1372
;;; (check-response (http-get url headers: auth))1373
;;; ```1374
(define (make-response-checker (keys: (name "API")1375
(handlers '())1376
(parse-error #f)))1377
(lambda (response)1378
(if (not (http-response? response))1379
(error (string-append name " request failed: no response")))1380
(let ((status (http-response-status response))1381
(body (http-response-body response)))1382
(cond1383
;; Check specific status handlers1384
((and (>= status 400)1385
(assv status handlers))1386
=> (lambda (entry)1387
(error (string-append1388
name " " (number->string status) ". "1389
(cdr entry)1390
" Response: " (or body "")))))1391
;; Generic error for 400+1392
((>= status 400)1393
(if parse-error1394
(parse-error status body)1395
(error (string-append1396
name " error " (number->string status) ": "1397
(or body "")))))1398
;; Success — return parsed JSON or #t1399
(else1400
(if (and body (not (string=? body "")))1401
(json-decode* body)1402
#t))))))1404
;;; Create authenticated JSON API method wrappers.1405
;;;1406
;;; Takes a function that returns auth headers and a response checker,1407
;;; and returns a dict with `get`, `post`, `put`, `patch`, and `delete`1408
;;; functions that handle JSON encoding/decoding and authentication.1409
;;;1410
;;; ```scheme1411
;;; (define api (make-json-api1412
;;; auth-headers: (lambda () #{ authorization: "Bearer tok" })1413
;;; check-response: my-checker))1414
;;;1415
;;; ((dict-ref api get:) "https://api.example.com/users")1416
;;; ((dict-ref api post:) "https://api.example.com/users" #{ name: "Alice" })1417
;;; ```1418
(define (make-json-api (keys: auth-headers check-response))1419
(let ((json-headers1420
(lambda ()1421
(dict-merge (auth-headers)1422
#{ content-type: "application/json" }))))1423
(dict1424
get: (lambda (url)1425
(check-response1426
(http-get url headers: (auth-headers))))1427
post: (lambda (url body)1428
(check-response1429
(http-post url (if (string? body) body (json-encode* body))1430
headers: (json-headers))))1431
put: (lambda (url body)1432
(check-response1433
(http-put url (if (string? body) body (json-encode* body))1434
headers: (json-headers))))1435
patch: (lambda (url body)1436
(check-response1437
(http-patch url (if (string? body) body (json-encode* body))1438
headers: (json-headers))))1439
delete: (lambda (url)1440
(check-response1441
(http-delete url headers: (auth-headers)))))))1443
))