diff options
-rw-r--r-- | http-client.release-info | 6 | ||||
-rw-r--r-- | http-client.scm | 54 | ||||
-rw-r--r-- | tests/run.scm | 236 | ||||
-rw-r--r-- | tests/testlib.scm | 2 |
4 files changed, 274 insertions, 24 deletions
diff --git a/http-client.release-info b/http-client.release-info index 69ce7c5..82afc87 100644 --- a/http-client.release-info +++ b/http-client.release-info @@ -1,7 +1,3 @@ (repo git "https://code.more-magic.net/{egg-name}") (uri targz "https://code.more-magic.net/{egg-name}/snapshot/{egg-name}-{egg-release}.tar.gz") -(release "1.0") -(release "1.1") -(release "1.1.1") -(release "1.2") -(release "1.2.1") +(release "2.0") diff --git a/http-client.scm b/http-client.scm index 1eef99d..a0cf59f 100644 --- a/http-client.scm +++ b/http-client.scm @@ -1,7 +1,7 @@ ;;; ;;; Convenient HTTP client library ;;; -;; Copyright (c) 2008-2022, Peter Bex +;; Copyright (c) 2008-2024, Peter Bex ;; Parts copyright (c) 2000-2004, Felix L. Winkelmann ;; All rights reserved. ;; @@ -46,7 +46,7 @@ server-connector default-server-connector prepare-request default-prepare-request) -(import scheme +(import scheme (scheme base) srfi-1 srfi-13 srfi-18 srfi-69 (chicken base) (chicken string) (chicken time) (chicken sort) (chicken io) (chicken file posix) (chicken format) @@ -295,16 +295,16 @@ (or (= pos len) (char-ready? port))) (lambda () ; close (close-input-port port)) - (lambda () ; peek-char + peek-char: (lambda () (if (= pos len) #!eof (peek-char port))) - (lambda (p bytes buf off) ; read-string! + read-bytevector: (lambda (p bytes buf off) (let* ((bytes (min bytes (- len pos))) - (bytes-read (read-string! bytes buf port off))) + (bytes-read (read-bytevector! buf port off (+ off bytes)))) (set! pos (+ pos bytes-read)) bytes-read)) - (lambda (p limit) ; read-line + read-line: (lambda (p limit) (if (= pos len) #!eof (let* ((bytes-left (- len pos)) @@ -716,9 +716,11 @@ '() (let* ((keys (cdr entry)) (file (kv-ref keys file:)) + (data (kv-ref keys data:)) (filename (or (kv-ref keys filename:) - (and (port? file) (port-name file)) - (and (string? file) file))) + (and file + (and (port? file) (port-name file)) + (and (string? file) file)))) (h (headers `((content-disposition #(form-data ((name . ,(car entry)) (filename . ,filename)))) @@ -733,6 +735,7 @@ (list "--" boundary "\r\n" hs "\r\n" (cond ((string? file) (cons 'file file)) ((port? file) (cons 'port file)) + ((string? data) data) ((eq? keys #t) "") (else (->string keys))) ;; The next boundary must always start on a new line @@ -748,14 +751,30 @@ (open-input-file (cdr chunk)) ;; Should be a port otherwise (cdr chunk)))) + (when (port-closed? p) + (raise (http-client-error 'call-with-input-request* + (conc "At least one port for the multipart body has been already " + "consumed and closed. This may be due to a retry or a redirect. " + "Hint: use 'data:' or limit max-retry-attempts and " + "max-redirect-depth to zero and catch the resulting exception") + (list p) + 'port-already-consumed 'port p))) (handle-exceptions exn - (begin (close-input-port p) (raise exn)) + (begin (close-input-port p) (raise exn)) (sendfile p output-port)) (close-input-port p)) (display chunk output-port))) entry)) entries)) +(define (maybe-string-port-length port) + (and (eq? (##sys#slot port 7) 'string) ; type + (let ((size (##sys#slot port 11)) + (string (##sys#slot port 12))) + (assert (integer? size)) ; Check our assumptions; this is pretty unsafe code + (assert (bytevector? string)) + size))) + (define (calculate-chunk-size entries) (call/cc (lambda (return) @@ -763,12 +782,16 @@ (fold (lambda (chunk total-size) (if (pair? chunk) (if (eq? 'port (car chunk)) + (let ((str-len (maybe-string-port-length (cdr chunk)))) + (if str-len + (+ total-size str-len) + ;; We can't calculate port lengths + ;; for non-string-ports. Let's just + ;; punt and hope the server won't + ;; return "411 Length Required"... + ;; (TODO: maybe try seeking it?) + (return #f))) ;; Should be a file otherwise. - ;; We can't calculate port lengths. - ;; Let's just punt and hope the server - ;; won't return "411 Length Required"... - ;; (TODO: maybe try seeking it?) - (return #f) (+ total-size (file-size (cdr chunk)))) (+ total-size (string-length chunk)))) total-size @@ -797,7 +820,8 @@ ((and (list? writer) (any (lambda (x) (and (pair? x) (pair? (cdr x)) - (eq? (cadr x) file:))) + (or (kv-ref (cdr x) file:) + (kv-ref (cdr x) data:)))) writer)) (let ((bd (conc "----------------Multipart-=_" (gensym 'boundary) "=_=" (current-process-id) diff --git a/tests/run.scm b/tests/run.scm index d8118ac..9ad7fd8 100644 --- a/tests/run.scm +++ b/tests/run.scm @@ -3,6 +3,8 @@ (include "../http-client.scm") (import http-client) +(import (chicken port)) + (include "testlib.scm") (test-begin "http-client") @@ -145,7 +147,7 @@ (test "Body was sent correctly" "lala=testing&another=data&more=stuff" (log-body log)))) - (test-group "alist form data body with file port" + (test-group "alist form data body with string port" (let* ((string-port (open-input-string "the file's contents")) (log (with-server-response (lambda () @@ -179,6 +181,165 @@ (test "Content type is multipart" 'multipart/form-data (header-value 'content-type h)) + (test "Content-length was set" + (string-length expected-data) (header-value 'content-length h)) + (test "Version is the default HTTP version of 1.1" + '(1 . 1) + (cons (request-major req) (request-minor req))) + (test "Transfer encoding is not set" + #f + (header-value 'transfer-encoding (request-headers req))) + (test "Body contains the file and other data, delimited by the boundary" + expected-data (log-body log)))) + + (test-group "alist form data body with string port using HTTP/1.0" + (let* ((string-port (open-input-string "the file's contents")) + (uri (uri-reference "http://example.com")) + (req (make-request uri: uri method: 'POST + major: 1 minor: 0)) + (log (with-server-response + (lambda () + (with-input-from-request + req + `((lala . "testing") + (the-file file: ,string-port + filename: "str") + ("more" . stuff)) + read-string)) + "HTTP/1.0 200 OK\r\n\r\n")) + (req (log-request log)) + (h (request-headers req)) + (boundary (header-param 'boundary 'content-type h)) + (expected-data + (conc + "--" boundary "\r\n" + "Content-Disposition: form-data; name=\"lala\"\r\n\r\n" + "testing\r\n" + "--" boundary "\r\n" + "Content-Disposition: form-data; name=\"the-file\"; " + "filename=\"str\"\r\n" + "Content-Type: application/octet-stream\r\n\r\n" + "the file's contents\r\n" + "--" boundary "\r\n" + "Content-Disposition: form-data; name=\"more\"\r\n\r\n" + "stuff\r\n" + "--" boundary "--\r\n"))) + + (test "Request method" 'POST (request-method req)) + (test "Content type is multipart" + 'multipart/form-data + (header-value 'content-type h)) + (test "Content-length was set" + (string-length expected-data) (header-value 'content-length h)) + (test "Version is correct" + '(1 . 0) + (cons (request-major req) (request-minor req))) + (test "Transfer encoding is not set" + #f + (header-value 'transfer-encoding (request-headers req))) + (test "Body contains the file and other data, delimited by the boundary" + expected-data (log-body log)))) + + (test-group "alist form data body with string port with redirect" + (test-error* "results in a port already consumed error" + (exn http port-already-consumed) + (let ((string-port (open-input-string "the file's contents"))) + (with-server-responses + (lambda () + (with-input-from-request + "http://example.com" + `((lala . "testing") + (the-file file: ,string-port + filename: "str") + ("more" . stuff)) + read-string)) + (conc "HTTP/1.0 301 Moved Permanently\r\n" + "Location: http://example.org/different/path\r\n" + "Content-Length: 8\r\n\r\nIgnored!") + "HTTP/1.0 200 OK\r\n\r\n")))) + + (test-group "alist form data body with string data" + (let* ((log (with-server-response + (lambda () + (with-input-from-request + "http://example.com" + `((lala . "testing") + (the-data data: "the data's contents" + filename: "str") + ("more" . stuff)) + read-string)) + "HTTP/1.0 200 OK\r\n\r\n")) + (req (log-request log)) + (h (request-headers req)) + (boundary (header-param 'boundary 'content-type h)) + (expected-data + (conc + "--" boundary "\r\n" + "Content-Disposition: form-data; name=\"lala\"\r\n\r\n" + "testing\r\n" + "--" boundary "\r\n" + "Content-Disposition: form-data; name=\"the-data\"; " + "filename=\"str\"\r\n" + "Content-Type: application/octet-stream\r\n\r\n" + "the data's contents\r\n" + "--" boundary "\r\n" + "Content-Disposition: form-data; name=\"more\"\r\n\r\n" + "stuff\r\n" + "--" boundary "--\r\n"))) + + (test "Request method" 'POST (request-method req)) + (test "Content type is multipart" + 'multipart/form-data + (header-value 'content-type h)) + (test "Content-length was set" + (string-length expected-data) (header-value 'content-length h)) + (test "Version is the default HTTP version of 1.1" + '(1 . 1) + (cons (request-major req) (request-minor req))) + (test "Transfer encoding is not set" + #f + (header-value 'transfer-encoding (request-headers req))) + (test "Body contains the file and other data, delimited by the boundary" + expected-data (log-body log)))) + + (test-group "alist form data body with custom port" + (let* ((string-port (open-input-string "the file's contents")) + (custom-port (make-input-port (lambda () (read-char string-port)) + (constantly #t) + (lambda () (close-input-port string-port)) + read-bytevector: (lambda (p bytes buf off) (read-bytevector! buf string-port off bytes)))) + (log (with-server-response + (lambda () + (with-input-from-request + "http://example.com" + `((lala . "testing") + (the-file file: ,custom-port + filename: "str") + ("more" . stuff)) + read-string)) + "HTTP/1.0 200 OK\r\n\r\n")) + (req (log-request log)) + (h (request-headers req)) + (boundary (header-param 'boundary 'content-type h)) + (expected-data + (conc + "--" boundary "\r\n" + "Content-Disposition: form-data; name=\"lala\"\r\n\r\n" + "testing\r\n" + "--" boundary "\r\n" + "Content-Disposition: form-data; name=\"the-file\"; " + "filename=\"str\"\r\n" + "Content-Type: application/octet-stream\r\n\r\n" + "the file's contents\r\n" + "--" boundary "\r\n" + "Content-Disposition: form-data; name=\"more\"\r\n\r\n" + "stuff\r\n" + "--" boundary "--\r\n"))) + + (test "Request method" 'POST (request-method req)) + (test "Content type is multipart" + 'multipart/form-data + (header-value 'content-type h)) (test "Content-length was not set" #f (header-value 'content-length h)) (test "Version is the default HTTP version of 1.1" @@ -190,8 +351,12 @@ (test "Body contains the file and other data, delimited by the boundary" expected-data (log-body log)))) - (test-group "alist form data body with file port using HTTP/1.0" + (test-group "alist form data body with custom port using HTTP/1.0" (let* ((string-port (open-input-string "the file's contents")) + (custom-port (make-input-port (lambda () (read-char string-port)) + (constantly #t) + (lambda () (close-input-port string-port)) + read-bytevector: (lambda (p bytes buf off) (read-bytevector! buf string-port off bytes)))) (uri (uri-reference "http://example.com")) (req (make-request uri: uri method: 'POST major: 1 minor: 0)) @@ -200,7 +365,7 @@ (with-input-from-request req `((lala . "testing") - (the-file file: ,string-port + (the-file file: ,custom-port filename: "str") ("more" . stuff)) read-string)) @@ -232,6 +397,8 @@ (test "Version is correct" '(1 . 0) (cons (request-major req) (request-minor req))) + ;; This is the important part - no chunking! And also, connection is closed, + ;; but we can't test for that here unless we expose the idle-connections pool. (test "Transfer encoding is not set" #f (header-value 'transfer-encoding (request-headers req))) @@ -520,6 +687,69 @@ '(1 1) (list (request-major req) (request-minor req))) (test "No body got sent (GET)" #f (log-body (last logs)))))) + (test-group "with POST and default value for 'retry-request?'" + (test-error* "results in a premature disconnection error due to retries not allowed for non-idempotent requests" + (exn http premature-disconnection) + (parameterize ((max-retry-attempts 3)) + (with-server-responses + (lambda () + (with-input-from-request + "http://example.com/blah" + `((lala . "testing") + (another . "thing")) + read-string)) + "" + (conc "HTTP/1.0 200 OK\r\n" + "Content-Length: 63\r\n\r\n" + "It would've worked on the second attempt, but we never get here"))))) + + (test-group "with POST and allowing retries for non-idempotent connections" + (parameterize ((retry-request? (constantly #t)) + (max-retry-attempts 3)) + (let* ((logs (with-server-responses + (lambda () + (with-input-from-request + "http://example.com/blah" + `((lala . "testing") + ("more" . stuff)) + read-string)) + "" + (conc "HTTP/1.0 200 OK\r\n" + "Content-Length: 63\r\n\r\n" + "It would've worked on the second attempt, but we never get here"))) + (req (log-request (last logs)))) + + ;; Just a few random checks + (test "URI is still OK" + "/blah" (uri->string (request-uri req))) + (test "host header is also OK" + '("example.com" . #f) + (header-value 'host (request-headers req))) + (test "HTTP request is version 1.1" + '(1 1) (list (request-major req) (request-minor req))) + (test "Body got sent again" + "lala=testing&more=stuff" (log-body (last logs)))))) + + (test-group "with port in data and allowing retries for non-idempotent connections" + (let ((string-port (open-input-string "the file's contents"))) + (test-error* "results in a port already consumed error" + (exn http port-already-consumed) + (parameterize ((retry-request? (constantly #t)) + (max-retry-attempts 3)) + (with-server-responses + (lambda () + (with-input-from-request + "http://example.com/blah" + `((lala . "testing") + (the-file file: ,string-port + filename: "str") + (another . "thing")) + read-string)) + "" + (conc "HTTP/1.0 200 OK\r\n" + "Content-Length: 63\r\n\r\n" + "It would've worked on the second attempt, but we never get here")))))) + (test-group "exceeding maximum retries" (parameterize ((max-retry-attempts 3)) (test-error* "results in a premature disconnection error" diff --git a/tests/testlib.scm b/tests/testlib.scm index 92e69da..e50e5da 100644 --- a/tests/testlib.scm +++ b/tests/testlib.scm @@ -3,7 +3,7 @@ ;; TODO: Test HTTPS somehow? -(import test uri-common intarweb srfi-1 srfi-18 (chicken tcp) +(import (scheme base) test uri-common intarweb srfi-1 srfi-18 (chicken tcp) (chicken string) (chicken io) (chicken file) (chicken format)) ;; From intarweb |