summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--benchmarks/parsers.scm60
-rw-r--r--benchmarks/run.scm7
-rw-r--r--benchmarks/unparsers.scm54
-rw-r--r--header-parsers.scm994
-rw-r--r--intarweb.egg11
-rw-r--r--intarweb.scm1055
-rw-r--r--tests/run.scm1243
7 files changed, 3424 insertions, 0 deletions
diff --git a/benchmarks/parsers.scm b/benchmarks/parsers.scm
new file mode 100644
index 0000000..e4612e2
--- /dev/null
+++ b/benchmarks/parsers.scm
@@ -0,0 +1,60 @@
+(import intarweb chicken.string chicken.time chicken.time.posix
+ chicken.port srfi-13)
+
+(define (mk-headers . strs)
+ (string-append (string-join strs "\r\n") "\r\n\r\n"))
+
+(begin (newline)
+ (print "--- Request parsing ---")
+ (begin (print "Parsing a minimal HTTP/1.0 request many times")
+ (let* ((str (mk-headers "GET / HTTP/1.0"
+ "Host: 127.0.0.1:8080"
+ "User-Agent: ApacheBench/2.3"))
+ (p (open-input-string str)))
+ (time (do ((i 0 (add1 i)))
+ ((= i 100000))
+ (##sys#setslot p 10 0) ; rewind
+ (read-request p)))))
+
+ (begin (print "Parsing a realistic HTTP/1.1 request many times")
+ (let* ((str (mk-headers "GET /foo HTTP/1.1"
+ "Host: localhost:8080"
+ "User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:24.0) Gecko/20140722 Firefox/24.0 Iceweasel/24.7.0"
+ "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
+ "Accept-Language: en-US,en;q=0.5"
+ "Accept-Encoding: gzip, deflate"
+ "Connection: keep-alive"))
+ (p (open-input-string str)))
+ (time (do ((i 0 (add1 i)))
+ ((= i 100000))
+ (##sys#setslot p 10 0) ; rewind
+ (read-request p))))))
+
+(begin (newline)
+ (print "--- Response parsing ---")
+ (begin (print "Parsing a minimal HTTP/1.0 response many times")
+ (let* ((str (mk-headers "HTTP/1.0 200 OK"
+ "Content-Length: 10"))
+ (p (open-input-string str)))
+ (time (do ((i 0 (add1 i)))
+ ((= i 100000))
+ (##sys#setslot p 10 0) ; rewind
+ (read-response p)))))
+
+ (begin (newline)
+ (print "Parsing a realistic HTTP/1.1 response many times")
+ (let* ((str (mk-headers
+ "HTTP/1.1 404 Not Found"
+ "Date: Tue, 19 Aug 2014 19:14:24 GMT"
+ "Server: Apache"
+ "Vary: Accept-Encoding"
+ "Content-Encoding: gzip"
+ "Content-Length: 176"
+ "Keep-Alive: timeout=15, max=100"
+ "Connection: Keep-Alive"
+ "Content-Type: text/html; charset=iso-8859-1"))
+ (p (open-input-string str)))
+ (time (do ((i 0 (add1 i)))
+ ((= i 100000))
+ (##sys#setslot p 10 0) ; rewind
+ (read-response p))))))
diff --git a/benchmarks/run.scm b/benchmarks/run.scm
new file mode 100644
index 0000000..c7d45c8
--- /dev/null
+++ b/benchmarks/run.scm
@@ -0,0 +1,7 @@
+(print "Unparsers:")
+(print "==========\n")
+(load "unparsers")
+
+(print "\nParsers:")
+(print "==========\n")
+(load "parsers")
diff --git a/benchmarks/unparsers.scm b/benchmarks/unparsers.scm
new file mode 100644
index 0000000..b8db0c8
--- /dev/null
+++ b/benchmarks/unparsers.scm
@@ -0,0 +1,54 @@
+(import chicken.port chicken.time chicken.time.posix intarweb uri-common)
+
+(define null-output-port
+ (make-output-port void void))
+
+(begin (newline)
+ (print "--- Response unparsing ---")
+ (begin (print "Unparsing a minimal HTTP/1.1 response many times")
+ (let ((response (make-response port: null-output-port)))
+ (time (do ((i 0 (add1 i)))
+ ((= i 100000))
+ (write-response response)))))
+
+ (begin (print "Unparsing a realistic HTTP/1.1 response many times")
+ (let ((response
+ (make-response
+ port: null-output-port
+ headers: (headers
+ `((content-type text/css)
+ (etag (strong . "1234-0123456789"))
+ (content-length 1234)
+ (last-modified #(,(seconds->utc-time (current-seconds)) ()))
+ (date #(,(seconds->utc-time (current-seconds)) ())))))))
+ (time (do ((i 0 (add1 i)))
+ ((= i 100000))
+ (write-response response))))))
+
+(begin (newline)
+ (print "--- Request unparsing ---")
+ (begin (print "Unparsing a minimal HTTP/1.1 request many times")
+ (let ((request
+ (make-request port: null-output-port)))
+ (time (do ((i 0 (add1 i)))
+ ((= i 100000))
+ (write-request request)))))
+
+ (begin (print "Unparsing a realistic HTTP/1.1 request many times")
+ (let ((request
+ (make-request
+ port: null-output-port
+ uri: (uri-reference "http://www.call-cc.org/test.example")
+ headers: (headers
+ `((user-agent (("Mozilla" "5.0"
+ "X11; U; NetBSD amd64; en-US; rv:1.9.0.3")
+ ("Gecko" "2008110501" #f)
+ ("Minefield" "3.0.3" #f)))
+ (host ("example.com" . 8080))
+ (accept text/html application/xhtml+xml
+ #(application/xml ((q . 0.9))) #(*/* ((q . 0.8))))
+ (accept-language en-US #(en ((q . 0.5))))
+ (accept-encoding gzip deflate))))))
+ (time (do ((i 0 (add1 i)))
+ ((= i 100000))
+ (write-request request))))))
diff --git a/header-parsers.scm b/header-parsers.scm
new file mode 100644
index 0000000..89edeae
--- /dev/null
+++ b/header-parsers.scm
@@ -0,0 +1,994 @@
+;;;; Header value accessor procedures
+
+;; Get the raw contents of a header
+(define (header-contents name headers #!optional default)
+ (cond ((assq name (headers-v headers)) => cdr)
+ (else default)))
+
+;; Get all values of a header
+(define (header-values header-name headers)
+ (map (cut vector-ref <> 0) (header-contents header-name headers '())))
+
+;; Get the value of a header, assuming it has only one value
+(define (header-value header-name headers #!optional default)
+ (let ((contents (header-contents header-name headers '())))
+ (if (null? contents)
+ default
+ (get-value (car contents)))))
+
+;; Get the parameters of a header, assuming it has only one value
+(define (header-params header-name headers)
+ (let ((contents (header-contents header-name headers '())))
+ (if (null? contents)
+ '()
+ (get-params (car contents)))))
+
+;; Get a specific parameter of a header, assuming it has only one value
+(define (header-param param-name header-name headers #!optional default)
+ (let ((params (header-params header-name headers)))
+ (cond ((not (pair? params)) default)
+ ((assq param-name params) => cdr)
+ (else default))))
+
+;; Get the value from one header entry
+(define get-value (cut vector-ref <> 0))
+;; Fast local version
+(define-compiler-syntax get-value
+ (syntax-rules ()
+ ((_ header-entry)
+ (vector-ref header-entry 0))))
+
+;; Get all params from one header entry
+(define get-params (cut vector-ref <> 1))
+;; Fast local version
+(define-compiler-syntax get-params
+ (syntax-rules ()
+ ((_ header-entry)
+ (vector-ref header-entry 1))))
+
+;; Get one specific parameter from one header entry
+(define (get-param param contents #!optional (default #f))
+ (let ((params (vector-ref contents 1)))
+ (cond ((not (pair? params)) default)
+ ((assq param params) => cdr)
+ (else default))))
+
+(define (get-no-newline-value header)
+ (let ((v (get-value header)))
+ (when (string-index v #\newline)
+ ;; There's duplication with quote-string error here...
+ (signal-http-condition
+ 'raw-value
+ (conc "Unencoded newline in header contents! "
+ "Please encode the newline in a way "
+ "appropriate for this header")
+ (list string) 'unencoded-header 'value string))
+ v))
+
+;;;; Header parsers
+
+(define (single subparser #!optional (parameter-subparsers '()))
+ (lambda (contents)
+ (list ((with-params subparser parameter-subparsers) contents))))
+
+(define (multiple subparser #!optional (parameter-subparsers '()))
+ (lambda (contents)
+ (map (with-params subparser parameter-subparsers)
+ (split-multi-header contents))))
+
+;; RFC 2616 4.2 says multi-headers are a comma-separated list of tokens
+(define (split-multi-header value)
+ (let ((len (string-length value)))
+ (let loop ((result '())
+ (start-pos 0) ; Where the current header value starts
+ (search-pos 0)) ; Where the searching starts
+ (or (and-let* (((< search-pos len))
+ (pos (string-index value (char-set #\, #\") search-pos)))
+ (if (char=? #\, (string-ref value pos))
+ (loop (cons (string-copy value start-pos pos) result) (add1 pos) (add1 pos))
+ (let ((end-pos (escaped-string-end value (add1 pos) (char-set #\"))))
+ (loop result start-pos (add1 end-pos)))))
+ (reverse (cons (string-drop value start-pos) result))))))
+
+;; Remove all escape characters from the input, recognising "escaped escapes"
+(define (unescape str)
+ (let ((last-char (sub1 (string-length str))))
+ (let loop ((result "")
+ (start-pos 0))
+ (or (and-let* ((pos (string-index str #\\ start-pos)))
+ (if (= pos last-char)
+ (string-append result (string-copy str start-pos))
+ (loop (string-append result (string-copy str start-pos pos)
+ (string-copy str (add1 pos) (+ pos 2)))
+ (+ pos 2))))
+ (string-append result (string-copy str start-pos))))))
+
+;; Find a matching endpoint for a token, ignoring escaped copies of the token
+(define (escaped-string-end str start stop-char-set)
+ (let ((len (string-length str)))
+ (let loop ((start start))
+ (let ((pos (string-index str (char-set-adjoin stop-char-set #\\) start)))
+ (if pos
+ (if (char=? #\\ (string-ref str pos))
+ ;; Escaped matching closing symbol
+ (if (= len (add1 pos))
+ pos
+ (loop (+ pos 2)))
+ ;; Reached the matching closing symbol
+ pos)
+ len))))) ; No matching closing symbol? "Insert" it at the end
+
+;; Try to parse a token, starting at the provided offset, up until the
+;; char-set where we should stop. Returns two values: the token or #f if
+;; there is no token left, and the position on which the token ends.
+(define (parse-token value start-pos
+ #!optional
+ (stop-char-set (char-set-adjoin char-set:whitespace #\,)))
+ (if (>= start-pos (string-length value))
+ (values #f start-pos)
+ (let ((stop (char-set-adjoin stop-char-set #\")))
+ (let ((pos (string-index value stop start-pos)))
+ (if pos
+ (if (not (char=? #\" (string-ref value pos)))
+ (values (string-trim-both
+ value char-set:whitespace start-pos pos)
+ pos) ; Stop-char found, but no quoting
+ (let ((end-pos (escaped-string-end value (add1 pos)
+ (char-set #\"))))
+ ;; Found the double quote? Recurse on the remainder
+ (receive (rest final-pos)
+ (parse-token value (add1 end-pos) stop-char-set)
+ (values (string-append
+ (string-trim-both
+ value char-set:whitespace start-pos pos)
+ (if (= pos end-pos)
+ (unescape (string-copy value (add1 pos)))
+ (unescape (string-copy value (add1 pos) end-pos)))
+ (or rest ""))
+ final-pos))))
+ ;; Nothing found? Then the remainder of the string is the token
+ (values (string-trim-both
+ value char-set:whitespace start-pos)
+ (string-length value)))))))
+
+;; Comments are a bit like tokens, except they can be nested
+(define (parse-comment value start-pos)
+ (let* ((len (string-length value))
+ (nospace-pos (and (< start-pos len)
+ (string-skip value char-set:whitespace start-pos))))
+ (if (and nospace-pos (char=? (string-ref value nospace-pos) #\())
+ (let loop ((result "")
+ (start-pos (add1 nospace-pos)))
+ (if (>= start-pos len)
+ (values result len)
+ (let ((pos (string-index value (char-set #\" #\( #\)) start-pos)))
+ (if pos
+ (cond ((char=? #\( (string-ref value pos)) ; Nested comment
+ (receive (nested end-pos)
+ (parse-comment value pos)
+ (loop (sprintf "~A~A(~A)"
+ result
+ (string-copy value start-pos pos)
+ nested)
+ (add1 end-pos))))
+ ((char=? #\" (string-ref value pos))
+ (let lp ((end (add1 pos))
+ (c (string-ref value (add1 pos)))
+ (res '()))
+ (cond ((char=? #\" c)
+ (loop (string-append
+ result
+ (reverse-list->string res))
+ (add1 end)))
+ ((char=? #\\ c)
+ (lp (+ end 2)
+ (string-ref value (+ end 2))
+ (cons (string-ref value (add1 end))
+ res)))
+ (else
+ (lp (add1 end)
+ (string-ref value (add1 end))
+ (cons c res))))))
+ ;; Else it's a )
+ (else (values (conc result (string-copy value start-pos pos)) (add1 pos))))
+ ;; Nothing found? Then the remainder of the string is the token
+ (values (conc result (string-copy value start-pos))
+ (string-length value))))))
+ ;; No (? Then fail to match the 'comment'
+ (values #f start-pos))))
+
+(define (parse-params string start-pos param-subparsers #!optional (stop-set (char-set #\;)) (separator-or-stop-set (char-set #\; #\=)))
+ (let loop ((start-pos start-pos)
+ (params '()))
+ (unless separator-or-stop-set
+ (error "The parse-params API has changed a bit for performance reasons: if you pass the optional stop char set, you need to pass one more argument which is the same char-set extended with an equals-sign"))
+ (receive (attribute-name pos)
+ (parse-token string start-pos separator-or-stop-set)
+ (if attribute-name
+ (let ((attribute (http-name->symbol attribute-name)))
+ (if (and (< pos (string-length string))
+ (char=? (string-ref string pos) #\=))
+ (receive (value pos)
+ (parse-token string (add1 pos) stop-set)
+ ;; In case of no value ("foo="), use the empty string as value
+ (let ((value ((alist-ref attribute param-subparsers
+ eq? identity)
+ (or value ""))))
+ (loop (add1 pos) (cons (cons attribute value) params))))
+ ;; Missing value is interpreted as "present",
+ ;; so #t. If not present, it's #f when looking it up
+ (loop (add1 pos) (cons (cons attribute #t) params))))
+ (values (reverse params) pos)))))
+
+(define (parse-value+params string start-pos value-subparser param-subparsers)
+ (receive (value pos)
+ (parse-token string start-pos (char-set #\;))
+ (if (not value)
+ (values #f pos) ;; XXX this is wrong and not expected by the caller!
+ (receive (params pos)
+ (parse-params string (add1 pos) param-subparsers)
+ (values (vector (value-subparser value) params) pos)))))
+
+(define (with-params value-subparser parameter-subparsers)
+ (lambda (entry)
+ (receive (type+params pos)
+ (parse-value+params entry 0 value-subparser parameter-subparsers)
+ type+params)))
+
+(define (make-key/value-subparser key/value-subparsers)
+ (lambda (k/v)
+ ;; We're abusing parse-params here to read value
+ ;; instead of params. This is weird, but it works :)
+ (receive (key+value pos)
+ (parse-params k/v 0 key/value-subparsers)
+ (vector (car key+value) '())))) ;; There's only one key/value pair
+
+(foreign-declare "#include <locale.h>")
+
+(define-foreign-variable LC_TIME int)
+
+(define setlocale (foreign-lambda c-string setlocale int c-string))
+
+(define-syntax let-locale
+ (syntax-rules ()
+ ((let-locale ((cat val) ...) body ...)
+ (let ((backup '()))
+ (dynamic-wind
+ (lambda () (set! backup `((cat . ,(setlocale cat val)) ...)))
+ (lambda () body ...)
+ (lambda () (setlocale cat (alist-ref 'cat backup)) ...))))))
+
+(define (make-date->string-parser spec)
+ (let ((date-regex
+ (irregex
+ (string-translate*
+ spec
+ '((" " . " +") ; Any number of spaces is very permissive
+ ("%a" . "(Sun|Mon|Tue|Wed|Thu|Fri|Sat)")
+ ("%A" . "(Sunday|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday)")
+ ("%d" . "[0-9]{1,2}")
+ ("%b" . "(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)")
+ ("%y" . "[0-9]{1,2}")
+ ("%Y" . "[0-9]{4}")
+ ("%X" . "[0-9]{2}:[0-9]{2}:[0-9]{2}"))))))
+ (lambda (str)
+ (and (irregex-match date-regex str) ; Or irregex-search?
+ (let-locale ((LC_TIME "POSIX"))
+ (string->time str spec))))))
+
+(define rfc1123-string->time (make-date->string-parser "%a, %d %b %Y %X GMT"))
+
+;; This is a little more relaxed than strict rfc850 (it allows abbreviated
+;; weekdays) - for example Google Web Server outputs cookies in this format.
+(define rfc850-string->time
+ (disjoin (make-date->string-parser "%A, %d-%b-%y %X GMT")
+ (make-date->string-parser "%a, %d-%b-%Y %X GMT")))
+
+(define asctime-string->time (make-date->string-parser "%a %b %d %X %Y"))
+
+(define http-date-string->time
+ (disjoin rfc1123-string->time rfc850-string->time asctime-string->time))
+
+;; RFC 1123 updates RFC 822's datetime spec
+(define (rfc1123-subparser str)
+ (or (rfc1123-string->time str)
+ (signal-http-condition
+ 'rfc1123-subparser
+ "Error parsing RFC 1123 date/time" (list str)
+ 'rfc1123-subparser 'value str)))
+
+(define (rfc850-subparser str)
+ (or (rfc850-string->time str)
+ (signal-http-condition
+ 'rfc850-subparser
+ "Error parsing RFC850 date/time" (list str)
+ 'rfc850-subparser 'value str)))
+
+(define (asctime-subparser str)
+ (or (asctime-string->time str)
+ (signal-http-condition
+ 'asctime-subparser
+ "Error parsing asctime() date/time" (list str)
+ 'asctime-subparser 'value str)))
+
+;; rfc1123-date | rfc850-date | asctime-date
+(define (http-date-subparser str)
+ (or (http-date-string->time str)
+ (signal-http-condition
+ 'http-date-subparser
+ "Error parsing date/time" (list str)
+ 'http-date-subparser 'value str)))
+
+;; Change the accuracy of a number to 'digits' number of digits to the
+;; right of the decimal point.
+(define (chop-number num digits)
+ (let ((factor (expt 10 digits)))
+ (/ (round (* num factor)) factor)))
+
+(define (quality-subparser str)
+ ;; Anything that's not a number is seen as if the value is missing, hence 1.0
+ (let* ((num (or (string->number str) 1.0))
+ (imprecise (chop-number num 3)))
+ (max 0.0 (min 1.0 imprecise))))
+
+;; Just put all header strings in a list, so we can pass it on.
+;; Make no assumptions about the contents (only value, don't try to parse params)
+;; This is different from (multiple (without-params generic-header-parser))
+;; because this does not assume it can split up comma-separated values.
+;; It also will ensure that the value is raw,
+(define (unknown-header-parser contents)
+ (list (vector contents 'raw)))
+
+(define symbol-subparser
+ (compose string->symbol string-trim-both))
+
+(define symbol-subparser-ci
+ (compose string->symbol string-trim-both string-downcase))
+
+(define (natnum-subparser contents)
+ (let ((num (string->number contents)))
+ (if num (inexact->exact (max 0 (round num))) 0)))
+
+(define (host/port-subparser contents)
+ (let* ((idx (string-index-right contents #\:))
+ (portnum (and idx (string->number
+ (substring/shared contents (add1 idx))))))
+ (if (and idx portnum)
+ (cons (substring/shared contents 0 idx)
+ (inexact->exact (round portnum)))
+ (cons contents #f))))
+
+; base64 of 128 bit hex digest as per RFC1864 (eg, Content-md5)
+(define base64-subparser base64-decode)
+
+;; This is retarded. The websocket spec (RFC6455) explicitly says the
+;; product token must be compared case-insensitively. RFC2817,
+;; "upgrading to TLS within HTTP/1.1" doesn't mention anything about
+;; case sensitivity, but defines an all-uppercase token. This means
+;; that there're going to be servers which accept only "TLS/1.0"
+;; as-is. This all just means we can't add the convenience of
+;; downcasing and switching to a symbol, pushing the case-(in)sensitive
+;; comparison down the the user level, causing more bugs :(
+(define (product-subparser contents)
+ (let* ((idx (string-index contents #\/))
+ (version (and idx (substring/shared contents (add1 idx)))))
+ (if (and idx version)
+ (cons (substring/shared contents 0 idx) version)
+ (cons contents #f))))
+
+;; bytes <start>-<end>/<total>
+(define range-subparser
+ (let ((range-regex
+ (irregex '(seq "bytes" (+ space)
+ (=> start (+ digit)) "-" (=> end (+ digit))
+ "/" (=> total (+ digit))))))
+ (lambda (s)
+ (and-let* ((m (irregex-match range-regex s))
+ (start (string->number (irregex-match-substring m 'start)))
+ (end (string->number (irregex-match-substring m 'end)))
+ (total (string->number (irregex-match-substring m 'total))))
+ (list start end total)))))
+
+;; Accept *just* a filename, not a full path (simply strips directories)
+;; This matches the content-disposition recommendation in RFC2616, 19.5.1:
+;; "The receiving user agent SHOULD NOT respect any directory path
+;; information present in the filename-parm parameter, which is the only
+;; parameter believed to apply to HTTP implementations at this time. The
+;; filename SHOULD be treated as a terminal component only."
+;; This echoes RFC2183 (and RFC1806 which it supersedes), section 2.3:
+;; "The receiving MUA SHOULD NOT respect any directory path information
+;; that may seem to be present in the filename parameter. The filename
+;; should be treated as a terminal component only."
+(define (filename-subparser fn)
+ (let ((base-fn (pathname-strip-directory (string-trim-both fn))))
+ (and (not (member base-fn '("" "." "..")))
+ (not (string-index base-fn (char-set #\/ #\nul)))
+ base-fn)))
+
+;; [W/]<string>
+;; This is a full parser, because it needs to be able to distinguish
+;; between W/"foo" and "W/foo". If it's preprocessed by the tokenizer
+;; both get "normalised" to the same thing: W/foo
+;;
+;; XXX It could be a good idea if the single/multiple token parsers
+;; did not do anything to their contents. If the consuming parsers
+;; want tokens, they know how to have it. OTOH, it would mean much
+;; more code for all the parsers as they need to tokenize more...
+(define (etag-parser contents)
+ (let ((contents (string-trim-both contents)))
+ (list (vector
+ (if (string-prefix? "W/" contents)
+ `(weak . ,(parse-token contents 2 char-set:whitespace))
+ `(strong . ,(parse-token contents 0 char-set:whitespace)))
+ '()))))
+
+;; Used for both if-match and if-none-match
+;; This is either a wilcard ('*') or several entities
+(define (if-match-parser contents)
+ (let ((contents (string-trim-both contents)))
+ (if (string=? "*" contents)
+ (list (vector '* '()))
+ (let loop ((pos 0)
+ (etags '()))
+ (let ((weak (string-prefix? "W/" contents 0 2 pos)))
+ (receive (etag newpos)
+ (parse-token contents (+ pos (if weak 2 0)) char-set:whitespace)
+ (let ((newpos (string-skip contents char-set:whitespace newpos))
+ (value (and etag (vector (cons (if weak 'weak 'strong)
+ etag) '()))))
+ (if value
+ (if newpos
+ (loop newpos (cons value etags))
+ (reverse! (cons value etags)))
+ (reverse! etags)))))))))
+
+;; ( <product>[/<version>] [<comment>] )+
+;; This parser is a full parser because parse-token cannot handle
+;; comments yet... (if a ; is in a comment, it breaks down)
+(define software-parser
+ (let ((char-set:space-or-paren (char-set-union (char-set #\()
+ char-set:whitespace))
+ (char-set:slash-or-paren (char-set #\/ #\()))
+ (lambda (contents)
+ (let loop ((start-pos 0)
+ (products '()))
+ (let*-values (((product pos)
+ (parse-token contents start-pos
+ char-set:slash-or-paren))
+ ((version pos2)
+ (parse-token contents pos ; (add1 pos)
+ char-set:space-or-paren))
+ ((comment pos3) (parse-comment contents pos2))
+ ;; Ugh
+ ((real-version) (and version (not (string-null? version)) (string-trim version #\/))))
+ (if product
+ (loop pos3 (cons (list product real-version comment) products))
+ (list (vector (reverse products) '()))))))))
+
+;;;; MAJOR TODOs
+;; RFC1123 mailbox parser - just strings for now
+(define mailbox-subparser identity)
+
+;; Either an entity-tag or a http-date
+(define (if-range-parser contents)
+ (let ((http-date ((with-params http-date-string->time '()) contents)))
+ (if (get-value http-date)
+ (list http-date)
+ (etag-parser contents))))
+
+;; Either delta-seconds or http-date
+(define retry-after-subparser (disjoin http-date-subparser natnum-subparser))
+
+;; Tricky - see 2616 14.45
+;; We probably shouldn't try to do too much parsing here
+(define via-parser (multiple identity))
+
+;; Tricky - see 2616 14.46
+(define warning-parser split-multi-header)
+;;;; END MAJOR TODOs
+
+(define (key/value-subparser str)
+ (let ((idx (string-index str #\=)))
+ (cons (string->symbol (string-take str idx)) (string-drop str (add1 idx)))))
+
+;; The 'expires' header defined by the Netscape cookie spec contains
+;; an embedded comma. RFC 2109 cookies use Max-Age instead.
+(define old-style-cookie?
+ (let ((old-cookie-regex
+ (irregex '(seq bos (+ (~ #\= #\")) "=" (* (~ #\;)) ";" ;
+ (* any) (w/nocase "expires") (* space) "="))))
+ (lambda (cookie)
+ (irregex-search old-cookie-regex cookie))))
+
+(define (string->number-list str)
+ (map string->number (string-split str ",")))
+
+(define (relative-ref/path-only s)
+ (and-let* ((ref (uri-reference s))
+ ((not (uri-host ref)))
+ ((null? (uri-query ref)))
+ ((not (uri-fragment ref))))
+ ref))
+
+;; We're using http-date-subparser for 'expires' instead of rfc1123-subparser
+;; (which would be the correct thing to do) because several well-known web
+;; server software packages (tested: PHP and Rails) get it wrong. So we
+;; will go by the robustness principle and allow any kind of HTTP date.
+(define set-cookie-parser
+ (let ((param-subparsers `((expires . ,http-date-subparser)
+ (max-age . ,string->number)
+ (version . ,string->number)
+ (port . ,string->number-list)
+ (path . ,relative-ref/path-only)))
+ (name/value-parser (lambda (str)
+ (let ((idx (string-index str #\=)))
+ (cons (string-take str idx)
+ (string-drop str (add1 idx)))))))
+ (lambda (contents)
+ (if (old-style-cookie? contents)
+ (list ((with-params name/value-parser param-subparsers) contents))
+ (map (with-params name/value-parser param-subparsers)
+ (split-multi-header contents))))))
+
+(define cache-control-parser
+ (let ((splitter (lambda (str) ;; Is this correct?
+ (map (compose string->symbol string-trim-both)
+ (string-split str ",")))))
+ (lambda (contents)
+ (map
+ (make-key/value-subparser `((max-age . ,natnum-subparser)
+ (s-maxage . ,natnum-subparser)
+ (max-stale . ,natnum-subparser)
+ (min-fresh . ,natnum-subparser)
+ (private . ,splitter)
+ (no-cache . ,splitter)))
+ (split-multi-header contents)))))
+
+(define (strict-transport-security-parser contents)
+ ;; This is ridiculous; there are no parameters because everything
+ ;; is a parameter (or everything is a value, depending on your point
+ ;; of view). The header has no main value. For convenience and sanity
+ ;; we just return an alist as a single value.
+ (list (vector (parse-params contents 0 `((max-age . ,natnum-subparser))) '())))
+
+(define (basic-auth-param-subparser contents pos)
+ (receive (secret pos)
+ (parse-token contents pos (char-set #\,))
+ (let* ((decoded (base64-decode secret))
+ (colon-idx (string-index decoded #\:))
+ (user (string-take decoded colon-idx))
+ (pass (string-drop decoded (add1 colon-idx))))
+ (values `((username . ,user) (password . ,pass)) pos))))
+
+(define (digest-auth-param-subparser contents pos)
+ (parse-params contents pos
+ `((nc . ,(lambda (n) (string->number n 16)))
+ (uri . ,uri-reference)
+ (qop . ,symbol-subparser)
+ (algorithm . ,symbol-subparser-ci))
+ (char-set #\,) (char-set #\, #\=)))
+
+(define authorization-param-subparsers
+ (make-parameter `((basic . ,basic-auth-param-subparser)
+ (digest . ,digest-auth-param-subparser))))
+
+(define (authorization-parser contents)
+ (let loop ((pos 0)
+ (result '()))
+ (receive (authtype pos)
+ (parse-token contents pos char-set:whitespace)
+ (if (not authtype)
+ (reverse result)
+ (let ((authtype (http-name->symbol authtype))
+ (default-subparser (lambda (contents pos)
+ (parse-params contents pos '()))))
+ (receive (params pos)
+ ((alist-ref authtype (authorization-param-subparsers)
+ eq? default-subparser) contents (add1 pos))
+ (loop (add1 pos)
+ (cons (vector authtype params) result))))))))
+
+(define (authenticate-parser contents)
+ (let loop ((pos 0)
+ (result '()))
+ (receive (authtype pos)
+ (parse-token contents pos char-set:whitespace)
+ (if (not authtype)
+ (reverse result)
+ (let ((authtype (http-name->symbol authtype)))
+ (receive (params pos)
+ (parse-params contents pos
+ `((domain . ,(lambda (s)
+ (map uri-reference
+ (string-split s))))
+ (qop . ,(lambda (s)
+ (map (compose symbol-subparser
+ string-trim)
+ (string-split s ","))))
+ (algorithm . ,symbol-subparser-ci)
+ (stale . ,(lambda (s)
+ (string-ci=? (string-trim s)
+ "TRUE"))))
+ (char-set #\,) (char-set #\, #\=))
+ (loop (add1 pos) (cons (vector authtype params) result))))))))
+
+(define (pragma-parser contents)
+ (map (make-key/value-subparser `()) (split-multi-header contents)))
+
+(define (te-parser contents)
+ (map (make-key/value-subparser `((q . ,quality-subparser)))
+ (split-multi-header contents)))
+
+;; Cookie headers are also braindead: there can be several cookies in one header,
+;; separated by either commas or semicolons. The only way to distinguish a
+;; new cookie from a parameter of the current cookie is the dollar in front
+;; of all parameter names.
+;; Also, there's a $Version attribute that prepends all cookies, which is
+;; considered to apply to all cookies that follow.
+(define (cookie-parser contents)
+ ;; Local version of parse-params that stops when param without $ is seen
+ (define (read-params start-pos)
+ (let next-param ((start-pos start-pos)
+ (params '()))
+ (receive (attribute-name pos)
+ (parse-token contents start-pos (char-set #\; #\=))
+ (if (or (not attribute-name) ;; Still reading params?
+ (not (char=? (string-ref attribute-name 0) #\$)))
+ (values (reverse! params) start-pos)
+ (let ((attrib (http-name->symbol (string-drop attribute-name 1))))
+ (if (and (< pos (string-length contents))
+ (char=? (string-ref contents pos) #\=))
+ (receive (value pos)
+ (parse-token contents (add1 pos) (char-set #\;))
+ (let ((value (case attrib
+ ((version port) (string->number (or value "")))
+ ((path) (relative-ref/path-only (or value "")))
+ (else value))))
+ (next-param (add1 pos) (cons (cons attrib value) params))))
+ ;; Missing value is interpreted as "present", so #t
+ (next-param (add1 pos) (cons (cons attrib #t) params))))))))
+ (receive (global-params pos)
+ (read-params 0)
+ (let loop ((cookies '())
+ (pos pos))
+ (let*-values (((name pos) (parse-token contents pos (char-set #\= #\;)))
+ ((val pos) (parse-token contents (add1 pos) (char-set #\;))))
+ (if (or (not name) (not val))
+ (reverse! cookies)
+ (receive (local-params pos)
+ (read-params (add1 pos))
+ (loop (cons (vector (cons name val)
+ (append! local-params global-params))
+ cookies) (add1 pos))))))))
+
+;;; Unparsers ;;;
+(define (unparse-params params unparsers #!key
+ (separator "; ") (grammar 'prefix)
+ (keyword-unparser ->string)
+ (value-unparser unparse-token))
+ (let loop ((params params)
+ (results '()))
+ (if (null? params)
+ (string-join (reverse results) separator grammar)
+ (let* ((name (caar params))
+ (val (cdar params))
+ (str (case val
+ ;; #t means param is present (no value)
+ ((#t) (keyword-unparser name))
+ ;; #f means param is missing
+ ((#f) #f)
+ (else (let* ((unparser (assq name unparsers))
+ (unparsed-val (if unparser
+ ((cdr unparser) val)
+ val)))
+ (string-append (keyword-unparser name) "="
+ (value-unparser unparsed-val)))))))
+ (loop (cdr params) (if str (cons str results) results))))))
+
+(define must-be-quoted-chars (char-set-adjoin char-set:iso-control #\"))
+
+(define (quote-string string)
+ (reverse-list->string
+ (cons #\"
+ (string-fold (lambda (c result)
+ (cond ((char=? c #\newline)
+ (signal-http-condition
+ 'quote-string
+ (conc "Unencoded newline in header contents! "
+ "Please encode the newline in a way "
+ "appropriate for this header")
+ (list string) 'unencoded-header 'value string))
+ ((char-set-contains? must-be-quoted-chars c)
+ (cons c (cons #\\ result)))
+ (else (cons c result))))
+ '(#\")
+ string))))
+
+;; Unparse a value as token, converting it to a string first
+(define unparse-token
+ (let ((default-trigger-chars (char-set-union must-be-quoted-chars
+ (char-set #\= #\; #\,)
+ char-set:blank)))
+ (lambda (token #!optional separator-chars)
+ (let ((trigger-quoting-chars
+ (if separator-chars
+ (char-set-union must-be-quoted-chars separator-chars char-set:blank)
+ default-trigger-chars))
+ (token-string (->string token)))
+ (if (string-any trigger-quoting-chars token-string)
+ (quote-string token-string)
+ token-string)))))
+
+(define (unparse-etag etag)
+ (string-append
+ (if (eq? 'weak (car etag)) "W/" "")
+ (quote-string (cdr etag)))) ;; Etags are _always_ quoted
+
+;; There's no need to make a specific header unparser for every header type.
+;; Usually, the Scheme value representing a header can unambiguously be
+;; unparsed into a header just by checking its type.
+(define (default-header-unparser header-contents)
+ (let loop ((headers (reverse header-contents))
+ (result '()))
+ (if (null? headers)
+ (list (string-join result ", "))
+ (let* ((header (car headers))
+ (contents (get-value header))
+ (value (cond
+ ((pair? contents) ; alist?
+ (let ((attribute (symbol->http-name (car contents))))
+ (if (eq? (cdr contents) #t)
+ (unparse-token attribute)
+ (string-append attribute "="
+ (unparse-token (cdr contents))))))
+ ((uri-reference? contents)
+ (unparse-token (uri->string contents) (char-set)))
+ (else (unparse-token contents)))))
+ (loop (cdr headers)
+ (cons
+ (string-append
+ value (unparse-params (get-params header) '()))
+ result))))))
+
+;; RFC2616 19.5.1 says that the "filename" attribute _must_ be quoted.
+;; It's a bit annoying that our API currently can't specify for particular
+;; attributes that only those must be unparsed specially, so we quote _all_
+;; attributes (which, strictly speaking, is always allowed for tokens) unless
+;; otherwise specified by a hack (when the value is prefixed by RAW).
+;; This may be dangerous or wrong, if a server doesn't accept quoted "name"
+;; attributes, for example. Not too likely since names can contain spaces etc.
+(define (content-disposition-unparser header-contents)
+ (let* ((type (get-value (car header-contents)))
+ (RAW (list 'raw))
+ (unparser (lambda (x) (if (and (pair? x) (eq? RAW (car x)))
+ (cdr x)
+ (quote-string (->string x))))))
+ (list (conc (unparse-token type)
+ (unparse-params (get-params (car header-contents))
+ `((filename . ,pathname-strip-directory)
+ (size . ,(lambda (x) (cons RAW (number->string x))))
+ (creation-date . ,rfc1123-time->string)
+ (modification-date . ,rfc1123-time->string)
+ (read-date . ,rfc1123-time->string))
+ value-unparser: unparser)))))
+
+(define (etag-unparser header-contents)
+ (list (unparse-etag (get-value (car header-contents)))))
+
+(define (if-match-unparser header-contents)
+ (let loop ((headers (reverse header-contents))
+ (result '()))
+ (cond
+ ((null? headers) (list (string-join result ", ")))
+ ((eq? '* (get-value (car headers))) '("*")) ;; We're done. * means anything
+ (else (loop (cdr headers)
+ (cons (unparse-etag (get-value (car headers))) result))))))
+
+(define (host/port-unparser header-contents)
+ (let ((contents (get-value (car header-contents))))
+ ;; XXX: urlencode?
+ (if (cdr contents)
+ (list (conc (car contents) ":" (cdr contents)))
+ (list (car contents)))))
+
+;; Handled specially because cookie value is not an alist but a cons of strings
+(define (set-cookie-unparser header-contents)
+ (map (lambda (header)
+ (let* ((unparsed-params
+ (map (lambda (p)
+ (if (eq? (cdr p) #t)
+ (unparse-token (symbol->http-name (car p)))
+ (string-append
+ (unparse-token (symbol->http-name (car p)))
+ "="
+ (cond
+ ((and (eq? (car p) 'port) (pair? (cdr p)))
+ (string-join
+ (map number->string (cdr p)) ","))
+ ((and (eq? (car p) 'path) (cdr p))
+ (uri->string (cdr p)))
+ ((eq? (car p) 'expires)
+ (let-locale ((LC_TIME "POSIX"))
+ (time->string (cdr p) "%A, %d-%b-%y %X GMT")))
+ (else (unparse-token (cdr p)))))))
+ ;; Remove #f values
+ (filter (lambda (p) (cdr p)) (get-params header))))
+ (cookie (get-value header))
+ (unparsed-cookie (string-append (car cookie) "="
+ (unparse-token (cdr cookie)))))
+ (string-join (cons unparsed-cookie unparsed-params) "; ")))
+ header-contents))
+
+(define (cookie-unparser header-contents)
+ (let loop ((prefix "")
+ (headers (reverse header-contents))
+ (result '()))
+ (if (null? headers)
+ (list (conc prefix (string-join result "; ")))
+ (let* ((version (get-param 'version (car headers) #f))
+ (params (alist-delete 'version (get-params (car headers))))
+ (unparsed-params
+ (map (lambda (p)
+ (if (eq? (cdr p) #t)
+ (unparse-token (conc "$" (symbol->http-name (car p))))
+ (string-append
+ (unparse-token
+ (conc "$" (symbol->http-name (car p))))
+ "="
+ (cond
+ ((and (eq? (car p) 'port) (pair? (cdr p)))
+ (string-join
+ (map number->string (cdr p)) ","))
+ ((and (eq? (car p) 'path) (cdr p))
+ (uri->string (cdr p)))
+ (else (unparse-token (cdr p)))))))
+ ;; Remove #f values
+ (filter (lambda (p) (cdr p)) params)))
+ (cookie (get-value (car headers)))
+ (unparsed-cookie (string-append (car cookie) "="
+ (unparse-token (cdr cookie)))))
+ ;; Doing it like this means we can't unparse cookies of
+ ;; mixed versions...
+ (loop (if version (sprintf "$Version: ~A; " version) prefix)
+ (cdr headers)
+ (cons (string-join (cons unparsed-cookie unparsed-params) "; ")
+ result))))))
+
+(define (software-unparser header-contents)
+ (list
+ (string-join
+ (map (lambda (content)
+ (conc (unparse-token (first content))
+ (if (second content)
+ (conc "/" (unparse-token (second content)))
+ "")
+ (if (third content)
+ (conc " (" (third content) ")")
+ "")))
+ (get-value (car header-contents))))))
+
+(define (product-unparser header-contents)
+ (list
+ (string-join
+ (map (lambda (header)
+ (let* ((default-unparser ; Not great, but better than nothing
+ (lambda (params) (unparse-params params '())))
+ (product+version (get-value header)))
+ (conc (unparse-token (car product+version))
+ (if (cdr product+version)
+ (conc "/" (unparse-token (cdr product+version)))
+ ""))))
+ header-contents)
+ ", ")))
+
+(define (via-unparser header-contents)
+ (list (string-join (map get-value header-contents) ", ")))
+
+(define (rfc1123-unparser header-contents)
+ (list (rfc1123-time->string (get-value (car header-contents)))))
+
+(define-constant short-weekdays `#("Sun" "Mon" "Tue" "Wed" "Thu" "Fri" "Sat"))
+(define-constant short-months `#("Jan" "Feb" "Mar" "Apr" "May" "Jun" "Jul" "Aug" "Sep" "Oct" "Nov" "Dec"))
+
+(define (rfc1123-time->string time)
+ (let ((padded (lambda (n)
+ (if (fx< n 10)
+ (string-append "0" (number->string n))
+ (number->string n))))
+ (secs (vector-ref time 0))
+ (mins (vector-ref time 1))
+ (hours (vector-ref time 2))
+ (mday (vector-ref time 3))
+ (month (vector-ref time 4))
+ (year (vector-ref time 5))
+ (wday (vector-ref time 6)))
+ (string-append (vector-ref short-weekdays wday) ", "
+ (padded mday) " " (vector-ref short-months month) " "
+ (number->string (+ 1900 year)) " " (padded hours) ":"
+ (padded mins) ":" (padded secs) " GMT")))
+
+(define (basic-auth-param-subunparser params)
+ (let ((user (alist-ref 'username params))
+ (pass (alist-ref 'password params)))
+ (if (string-index user #\:)
+ (signal-http-condition
+ 'basic-auth-param-subunparser
+ "Colon detected in username. This is not supported by basic auth!"
+ (list user) 'username-with-colon 'value user)
+ (base64-encode (string-append user ":" pass)))))
+
+(define (digest-auth-param-subunparser params)
+ (unparse-params params
+ `((nc . ,identity) ;; see below
+ (uri . ,uri->string)
+ (qop . ,symbol->string)
+ (algorithm . ,symbol->string))
+ separator: ", "
+ grammar: 'infix
+ keyword-unparser: symbol->string
+ value-unparser:
+ ;; Nasty exception for "nc", which is an unquoted
+ ;; padded integer...
+ (lambda (x)
+ (if (number? x)
+ (string-pad (number->string x 16) 8 #\0)
+ (quote-string (->string x))))))
+
+(define authorization-param-subunparsers
+ (make-parameter `((basic . ,basic-auth-param-subunparser)
+ (digest . ,digest-auth-param-subunparser))))
+
+(define (authorization-unparser header-contents)
+ (map (lambda (header)
+ (let* ((default-unparser ; Not great, but better than nothing
+ (lambda (params) (unparse-params params '())))
+ (auth-scheme (get-value header))
+ (unparser (alist-ref auth-scheme
+ (authorization-param-subunparsers)
+ eq? default-unparser)))
+ (string-append
+ (symbol->http-name auth-scheme) " "
+ (->string (unparser (get-params header))))))
+ header-contents))
+
+(define (authenticate-unparser header-contents)
+ (map (lambda (header)
+ (string-append
+ (symbol->http-name (get-value header))
+ " "
+ (let* ((old (get-params header))
+ ;; A quick hack to get presence of "stale"
+ ;; coded as TRUE instead of value-less param
+ ;; false value is coded by its absense
+ (params (if (alist-ref 'stale old)
+ (alist-update! 'stale 'TRUE old)
+ (alist-delete 'stale old))))
+ (unparse-params params
+ `((domain . ,(lambda (u)
+ (string-join
+ (map uri->string u))))
+ (qop . ,(lambda (q)
+ (string-join
+ (map symbol->string q)
+ ",")))
+ (algorithm . ,symbol->string))
+ separator: ", "
+ grammar: 'infix
+ keyword-unparser: symbol->string
+ value-unparser:
+ (lambda (x)
+ (if (eq? x 'TRUE)
+ "TRUE"
+ (quote-string (->string x))))))))
+ header-contents))
+
+(define (strict-transport-security-unparser header-contents)
+ (map (lambda (header) ; Should only be one header; the rest must be ignored!
+ (unparse-params (get-value header) '()
+ grammar: 'infix
+ keyword-unparser: ; Ugly but neccessary
+ (lambda (k)
+ (if (eq? k 'includesubdomains)
+ "includeSubDomains"
+ (->string k)))))
+ header-contents))
diff --git a/intarweb.egg b/intarweb.egg
new file mode 100644
index 0000000..43eb73d
--- /dev/null
+++ b/intarweb.egg
@@ -0,0 +1,11 @@
+;; -*- Scheme -*-
+((synopsis "A more convenient HTTP library")
+ (author "Peter Bex")
+ (category web)
+ (license "BSD")
+ (dependencies srfi-1 srfi-13 srfi-14 defstruct uri-common base64)
+ (test-dependencies test srfi-18)
+ (components (extension intarweb
+ (csc-options "-O3")
+ (source-dependencies "intarweb.scm"
+ "header-parsers.scm"))))
diff --git a/intarweb.scm b/intarweb.scm
new file mode 100644
index 0000000..3df4690
--- /dev/null
+++ b/intarweb.scm
@@ -0,0 +1,1055 @@
+;;;
+;;; Intarweb is an improved HTTP library for Chicken
+;;;
+;; Copyright (c) 2008-2018, Peter Bex
+;; All rights reserved.
+;;
+;; Redistribution and use in source and binary forms, with or without
+;; modification, are permitted provided that the following conditions
+;; are met:
+;;
+;; 1. Redistributions of source code must retain the above copyright
+;; notice, this list of conditions and the following disclaimer.
+;; 2. Redistributions in binary form must reproduce the above copyright
+;; notice, this list of conditions and the following disclaimer in the
+;; documentation and/or other materials provided with the distribution.
+;; 3. Neither the name of the author nor the names of its
+;; contributors may be used to endorse or promote products derived
+;; from this software without specific prior written permission.
+;;
+;; THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+;; "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+;; LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+;; FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+;; COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
+;; INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+;; (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+;; SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+;; HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
+;; STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+;; ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
+;; OF THE POSSIBILITY OF SUCH DAMAGE.
+
+;; TODO: Support RFC5987? Seems awfully messy though (need to pull in iconv?)
+;; We could use http://www.greenbytes.de/tech/tc2231/ in the testsuite.
+;; Look at that URI's toplevel directory for more HTTP/URI-related testcases!
+
+(module intarweb
+ (http-line-limit http-header-limit http-urlencoded-request-data-limit
+ replace-header-contents replace-header-contents! remove-header remove-header!
+ update-header-contents update-header-contents! headers single-headers
+ headers? headers->list http-name->symbol symbol->http-name
+ header-parsers header-unparsers unparse-header unparse-headers read-headers
+ safe-methods safe? idempotent-methods idempotent? keep-alive? response-class
+ etag=? etag=-weakly? etag-matches? etag-matches-weakly?
+
+ make-request request? request-major request-major-set!
+ request-minor request-minor-set!
+ request-method request-method-set! request-uri request-uri-set!
+ request-headers request-headers-set! request-port request-port-set!
+ update-request set-request! request-has-message-body?
+
+ request-parsers read-request request-unparsers write-request
+ finish-request-body http-0.9-request-parser http-1.x-request-parser
+ http-0.9-request-unparser http-1.0-request-unparser http-1.x-request-unparser
+ header-parse-error-handler
+ read-urlencoded-request-data
+
+ make-response response? response-major response-major-set!
+ response-minor response-minor-set!
+ response-code response-code-set! response-reason response-reason-set!
+ response-status response-status-set! response-headers response-headers-set!
+ response-port response-port-set! update-response set-response!
+ response-has-message-body-for-request?
+
+ write-response response-parsers response-unparsers read-response
+ finish-response-body http-0.9-response-parser http-0.9-response-unparser
+ http-1.0-response-parser http-1.0-response-unparser
+ http-1.x-response-parser http-1.x-response-unparser
+ http-status-codes http-status->code&reason
+
+ ;; http-header-parsers
+ header-contents header-values header-value header-params header-param
+ get-value get-params get-param
+
+ split-multi-header parse-token parse-comment
+ parse-params parse-value+params unparse-params
+ multiple single make-key/value-subparser
+
+ rfc1123-string->time rfc850-string->time asctime-string->time
+ http-date-string->time
+ rfc1123-subparser rfc850-subparser asctime-subparser http-date-subparser
+ product-subparser quality-subparser unknown-header-parser
+ filename-subparser symbol-subparser symbol-subparser-ci natnum-subparser
+ host/port-subparser base64-subparser range-subparser filename-subparser
+ etag-parser software-parser mailbox-subparser
+ if-range-parser retry-after-subparser via-parser warning-parser
+ key/value-subparser set-cookie-parser cache-control-parser pragma-parser
+ te-parser cookie-parser strict-transport-security-parser
+
+ must-be-quoted-chars quote-string unparse-token
+ default-header-unparser etag-unparser host/port-unparser
+ product-unparser software-unparser rfc1123-unparser cookie-unparser
+ strict-transport-security-unparser
+
+ ;; Subparsers/subunparsers
+ authorization-param-subparsers
+ basic-auth-param-subparser digest-auth-param-subparser
+
+ authorization-param-subunparsers
+ basic-auth-param-subunparser digest-auth-param-subunparser
+ )
+
+(import scheme (chicken base) (chicken foreign) (chicken irregex)
+ (chicken format) (chicken io) (chicken string)
+ (chicken time posix) (chicken pathname) (chicken fixnum)
+ (chicken condition) (chicken port) (chicken syntax)
+ srfi-1 srfi-13 srfi-14 base64 uri-common defstruct)
+
+;; The below can all be #f if you want no limit (not recommended!)
+(define http-line-limit (make-parameter 4096))
+(define http-header-limit (make-parameter 64))
+(define http-urlencoded-request-data-limit (make-parameter (* 4 1024 1024)))
+
+(define (read-urlencoded-request-data
+ request #!optional (max-length (http-urlencoded-request-data-limit)))
+ (let* ((p (request-port request))
+ (len (header-value 'content-length (request-headers request)))
+ ;; For simplicity's sake, we don't allow exactly the max request limit
+ (limit (if (and len max-length)
+ (min len max-length)
+ (or max-length len)))
+ (data (read-string limit (request-port request))))
+ (if (and (not (eof-object? data)) max-length (= max-length (string-length data)))
+ (signal-http-condition
+ 'read-urlencoded-request-data
+ "Max allowed URLencoded request size exceeded"
+ (list request max-length)
+ 'urlencoded-request-data-limit-exceeded
+ 'contents data 'limit limit)
+ (form-urldecode data))))
+
+(define (raise-line-limit-exceeded-error line limit port)
+ (let ((safe-line-prefix
+ (if (< limit 128)
+ (sprintf "~A[..and more (was limited to ~A)..]" line limit)
+ (sprintf "~A[..~A+ more chars (was limited to ~A)..]"
+ (substring line 0 128) (- limit 128) limit))))
+ (signal-http-condition
+ 'safe-read-line
+ "Max allowed line length exceeded"
+ (list port safe-line-prefix)
+ 'line-limit-exceeded 'contents line 'limit limit)))
+
+(define (safe-read-line p)
+ (let* ((limit (http-line-limit))
+ (line (read-line p (http-line-limit))))
+ (if (and (not (eof-object? line)) limit (= limit (string-length line)))
+ (raise-line-limit-exceeded-error line limit p)
+ line)))
+
+;; Make headers a new type, to force the use of the HEADERS procedure
+;; and ensure only proper header values are passed to all procedures
+;; that deal with headers.
+(define-record headers v)
+
+(define-record-printer (headers h out)
+ (fprintf out "#(headers: ~S)" (headers-v h)))
+
+(define headers->list headers-v)
+
+(define (remove-header! name headers)
+ (let loop ((h (headers-v headers)))
+ (cond
+ ((null? h) headers)
+ ((eq? name (caar h))
+ (set-cdr! h (cdr h))
+ headers)
+ (else (loop (cdr h))))))
+
+(define (remove-header name headers)
+ (make-headers
+ (let loop ((h (headers-v headers)))
+ (cond
+ ((null? h) h)
+ ((eq? name (caar h)) (loop (cdr h)))
+ (else (cons (car h) (loop (cdr h))))))))
+
+;; Check that the header values are valid vectors, and that if there
+;; is a raw value, there is only one value at all.
+(define (check-header-values loc name contents)
+ (let lp ((mode 'unknown) (todo contents))
+ (let ((head (car todo)))
+ (if (not (and (vector? head) (= 2 (vector-length head))))
+ (signal-http-condition
+ loc "header values must be vectors of length 2"
+ (list name contents) 'header-value)
+ (let ((type (if (eq? (get-params head) 'raw) 'raw 'cooked)))
+ (unless (or (eq? mode 'unknown) (eq? mode type))
+ (signal-http-condition
+ loc "When using raw headers, all values must be raw"
+ (list name contents) 'header-value)
+ (lp type (cdr todo))))))))
+
+;; XXX: Do we need these replace procedures in the exports list? It
+;; looks like we can use update everywhere.
+(define (replace-header-contents! name contents headers)
+ (check-header-values 'replace-header-contents! name contents)
+ (let loop ((h (headers-v headers)))
+ (cond
+ ((null? h)
+ (headers-v-set!
+ headers (cons (cons name contents) (headers-v headers)))
+ headers)
+ ((eq? name (caar h))
+ (set-cdr! (car h) contents)
+ headers)
+ (else (loop (cdr h))))))
+
+(define (replace-header-contents name contents headers)
+ (check-header-values 'replace-header-contents! name contents)
+ (make-headers
+ (let loop ((h (headers-v headers)))
+ (cond
+ ((null? h) (cons (cons name contents) h))
+ ((eq? name (caar h))
+ (cons (cons (caar h) contents) (cdr h)))
+ (else (cons (car h) (loop (cdr h))))))))
+
+(define (make-updater replacer)
+ (lambda (name contents headers)
+ (let ((old (header-contents name headers '())))
+ (replacer name
+ (if (member name (single-headers))
+ (list (last contents))
+ (append old contents))
+ headers))))
+
+(define update-header-contents (make-updater replace-header-contents))
+(define update-header-contents! (make-updater replace-header-contents!))
+
+(define http-name->symbol (compose string->symbol string-downcase!))
+(define symbol->http-name (compose string-titlecase symbol->string))
+
+;; Make a header set from a literal expression by folding in the headers
+;; with any previous ones
+(define (headers headers-to-be #!optional (old-headers (make-headers '())))
+ (fold (lambda (h new-headers)
+ (update-header-contents
+ (car h)
+ (map (lambda (v)
+ (if (vector? v) v (vector v '()))) ; normalize to vector
+ (cdr h))
+ new-headers))
+ old-headers
+ headers-to-be))
+
+(define (normalized-uri str)
+ (and-let* ((uri (uri-reference str)))
+ (uri-normalize-path-segments uri)))
+
+(include "header-parsers") ; Also includes header unparsers
+
+;; Any unknown headers are considered to be multi-headers, always
+(define single-headers
+ (make-parameter '(accept-ranges age authorization content-disposition
+ content-length content-location content-md5 content-type
+ date etag expect expires host if-modified-since
+ if-unmodified-since last-modified location max-forwards
+ proxy-authorization range referer retry-after server
+ transfer-encoding user-agent www-authenticate)))
+
+(define string->http-method string->symbol)
+(define http-method->string symbol->string)
+
+;; Make an output port automatically "chunked"
+(define (chunked-output-port port)
+ (let ((chunked-port
+ (make-output-port (lambda (s) ; write
+ (let ((len (string-length s)))
+ (unless (zero? len)
+ (fprintf port "~X\r\n~A\r\n" len s))))
+ (lambda () ; close
+ (close-output-port port))
+ (lambda () ; flush
+ (flush-output port)))))
+ ;; first "reserved" slot
+ ;; Slot 7 should probably stay 'custom
+ (##sys#setslot chunked-port 10 'chunked-output-port)
+ ;; second "reserved" slot
+ (##sys#setslot chunked-port 11 port)
+ chunked-port))
+
+;; Make an input port automatically "chunked"
+(define (chunked-input-port port)
+ (let* ((chunk-length 0)
+ (position 0)
+ (check-position (lambda ()
+ (when (and position (>= position chunk-length))
+ (unless (eq? chunk-length 0)
+ (safe-read-line port)) ; Read \r\n data trailer
+ (let ((line (safe-read-line port)))
+ (if (eof-object? line)
+ (set! position #f)
+ (begin
+ (set! chunk-length (string->number line 16))
+ (cond
+ ((not chunk-length) (set! position #f))
+ ((zero? chunk-length) ; Read final data trailer
+ (safe-read-line port)
+ (set! position #f))
+ (else (set! position 0))))))))))
+ (make-input-port (lambda () ; read-char
+ (check-position)
+ (if position
+ (let ((char (read-char port)))
+ (unless (eof-object? char)
+ (set! position (add1 position)))
+ char)
+ #!eof))
+ (lambda () ; ready?
+ (check-position)
+ (or (not position) (char-ready? port)))
+ (lambda () ; close
+ (close-input-port port))
+ (lambda () ; peek-char
+ (check-position)
+ (if position
+ (peek-char port)
+ #!eof))
+ (lambda (p bytes buf off) ; read-string!
+ (let lp ((todo bytes)
+ (total-bytes-read 0)
+ (off off))
+ (check-position)
+ (if (or (not position) (= todo 0))
+ total-bytes-read
+ (let* ((n (min todo (- chunk-length position)))
+ (bytes-read (read-string! n buf port off)))
+ (set! position (+ position bytes-read))
+ (lp (- todo bytes-read)
+ (+ total-bytes-read bytes-read)
+ (+ off bytes-read)))))))))
+;; TODO: Note that in the above, read-line is not currently
+;; implemented. It is *extremely* tricky to correctly maintain the
+;; port position when all \r *AND/OR* \n characters get chopped off
+;; the line-string. It can be done by maintaining our own extra
+;; buffer, but that complicates all the procedures here enormously,
+;; including read-line itself.
+
+;; RFC2616, Section 4.3: "The presence of a message-body in a request
+;; is signaled by the inclusion of a Content-Length or Transfer-Encoding
+;; header field in the request's message-headers."
+;; We don't check the method since "a server SHOULD read and forward the
+;; a message-body on any request", even it shouldn't be sent for that method.
+;;
+;; Because HTTP/1.0 has no official definition of when a message body
+;; is present, we'll assume it's always present, unless there is no
+;; content-length and we have a keep-alive connection.
+(define request-has-message-body?
+ (make-parameter
+ (lambda (req)
+ (let ((headers (request-headers req)))
+ (if (and (= 1 (request-major req)) (= 0 (request-minor req)))
+ (not (eq? 'keep-alive (header-contents 'connection headers)))
+ (or (header-contents 'content-length headers)
+ (header-contents 'transfer-encoding headers)))))))
+
+;; RFC2616, Section 4.3: "For response messages, whether or not a
+;; message-body is included with a message is dependent on both the
+;; request method and the response status code (section 6.1.1)."
+(define response-has-message-body-for-request?
+ (make-parameter
+ (lambda (resp req)
+ (not (or (= (response-class resp) 100)
+ (memv (response-code resp) '(204 304))
+ (eq? 'HEAD (request-method req)))))))
+
+;; OPTIONS and TRACE are not explicitly mentioned in section 9.1.1,
+;; but section 9.1.2 says they SHOULD NOT have side-effects by
+;; definition, which means they are safe, as well.
+(define safe-methods
+ (make-parameter '(GET HEAD OPTIONS TRACE)))
+
+;; RFC2616, Section 9.1.1
+(define (safe? obj)
+ (let ((method (if (request? obj) (request-method obj) obj)))
+ (not (not (member method (safe-methods))))))
+
+(define idempotent-methods
+ (make-parameter '(GET HEAD PUT DELETE OPTIONS TRACE)))
+
+;; RFC2616, Section 9.1.2
+(define (idempotent? obj)
+ (let ((method (if (request? obj) (request-method obj) obj)))
+ (not (not (member method (idempotent-methods))))))
+
+(define (keep-alive? obj)
+ (let ((major (if (request? obj) (request-major obj) (response-major obj)))
+ (minor (if (request? obj) (request-minor obj) (response-minor obj)))
+ (con (header-value 'connection (if (request? obj)
+ (request-headers obj)
+ (response-headers obj)))))
+ (if (and (= major 1) (> minor 0))
+ (not (eq? con 'close))
+ ;; RFC 2068, section 19.7.1 (see also RFC 2616, section 19.6.2)
+ (eq? con 'keep-alive))))
+
+(define (etag=? a b)
+ (and (not (eq? 'weak (car a)))
+ (eq? (car a) (car b))
+ (string=? (cdr a) (cdr b))))
+
+(define (etag=-weakly? a b)
+ (and (eq? (car a) (car b))
+ (string=? (cdr a) (cdr b))))
+
+(define (etag-matches? etag matchlist)
+ (any (lambda (m) (or (eq? m '*) (etag=? etag m))) matchlist))
+
+(define (etag-matches-weakly? etag matchlist)
+ (any (lambda (m) (or (eq? m '*) (etag=-weakly? etag m))) matchlist))
+
+;;;;;;;;;;;;;;;;;;;;;;;;;
+;;;; Request parsing ;;;;
+;;;;;;;;;;;;;;;;;;;;;;;;;
+
+;; This includes parsers for all RFC-defined headers
+(define header-parsers
+ (make-parameter
+ `((accept . ,(multiple symbol-subparser-ci
+ `((q . ,quality-subparser))))
+ (accept-charset . ,(multiple symbol-subparser-ci
+ `((q . ,quality-subparser))))
+ (accept-encoding . ,(multiple symbol-subparser-ci
+ `((q . ,quality-subparser))))
+ (accept-language . ,(multiple symbol-subparser-ci
+ `((q . ,quality-subparser))))
+ (accept-ranges . ,(single symbol-subparser-ci))
+ (age . ,(single natnum-subparser))
+ (allow . ,(multiple symbol-subparser))
+ (authorization . ,authorization-parser)
+ (cache-control . ,cache-control-parser)
+ (connection . ,(multiple symbol-subparser-ci))
+ (content-encoding . ,(multiple symbol-subparser-ci))
+ (content-language . ,(multiple symbol-subparser-ci))
+ (content-length . ,(single natnum-subparser))
+ (content-location . ,(single normalized-uri))
+ (content-md5 . ,(single base64-subparser))
+ (content-range . ,(single range-subparser))
+ (content-type . ,(single symbol-subparser-ci
+ `((charset . ,symbol-subparser-ci))))
+ (date . ,(single http-date-subparser))
+ (etag . ,etag-parser)
+ (expect . ,(single (make-key/value-subparser '())))
+ (expires . ,(single http-date-subparser))
+ (from . ,(multiple mailbox-subparser))
+ (host . ,(single host/port-subparser))
+ (if-match . ,if-match-parser)
+ (if-modified-since . ,(single http-date-subparser))
+ (if-none-match . ,if-match-parser)
+ (if-range . ,if-range-parser)
+ (if-unmodified-since . ,(single http-date-subparser))
+ (last-modified . ,(single http-date-subparser))
+ (location . ,(single normalized-uri))
+ (max-forwards . ,(single natnum-subparser))
+ (pragma . ,pragma-parser)
+ (proxy-authenticate . ,authenticate-parser)
+ (proxy-authorization . ,authorization-parser)
+ (range . ,(multiple range-subparser))
+ (referer . ,(single normalized-uri))
+ (retry-after . ,(single retry-after-subparser))
+ (server . ,software-parser)
+ (te . ,te-parser)
+ (trailer . ,(multiple symbol-subparser-ci))
+ (transfer-encoding . ,(single symbol-subparser-ci))
+ (upgrade . ,(multiple product-subparser))
+ (user-agent . ,software-parser)
+ (vary . ,(multiple symbol-subparser-ci))
+ (via . ,via-parser)
+ (warning . ,warning-parser)
+ (www-authenticate . ,authenticate-parser)
+ ;; RFC 2183
+ (content-disposition . ,(single symbol-subparser-ci
+ `((filename . ,filename-subparser)
+ (creation-date . ,rfc1123-subparser)
+ (modification-date . ,rfc1123-subparser)
+ (read-date . ,rfc1123-subparser)
+ (size . ,natnum-subparser))))
+ ;; RFC 2109
+ (set-cookie . ,set-cookie-parser)
+ (cookie . ,cookie-parser)
+ ;;
+ ;; TODO: RFC 2965?
+ ;;
+ ;; RFC 6797
+ (strict-transport-security . ,strict-transport-security-parser)
+ ;; Nonstandard but common headers
+ (x-forwarded-for . ,(multiple identity))
+ )))
+
+(define header-parse-error-handler ;; ignore errors
+ (make-parameter (lambda (header-name contents headers exn) headers)))
+
+;; The parser is supposed to return a list of header values for its header
+(define (parse-header name contents)
+ (let* ((default unknown-header-parser)
+ (parser (alist-ref name (header-parsers) eq? default)))
+ (parser contents)))
+
+(define (parse-header-line line headers)
+ (or
+ (and-let* ((colon-idx (string-index line #\:))
+ (header-name (http-name->symbol (string-take line colon-idx)))
+ (contents (string-trim-both (string-drop line (add1 colon-idx)))))
+ (handle-exceptions
+ exn
+ ((header-parse-error-handler) header-name contents headers exn)
+ (update-header-contents!
+ header-name (parse-header header-name contents) headers)))
+ (signal-http-condition
+ 'parse-header-line "Bad header line" (list line)
+ 'header-error 'contents line)))
+
+;; XXXX: Bottleneck?
+(define (read-headers port)
+ (if (eof-object? (peek-char port)) ; Yeah, so sue me
+ (make-headers '())
+ (let ((header-limit (http-header-limit))
+ (line-limit (http-line-limit)))
+ (let lp ((c (read-char port))
+ (ln '())
+ (headers (make-headers '()))
+ (hc 0)
+ (len 0))
+ (cond ((eqv? len line-limit)
+ (raise-line-limit-exceeded-error
+ (reverse-list->string ln) line-limit port))
+ ((eof-object? c)
+ (if (null? ln)
+ headers
+ (parse-header-line (reverse-list->string ln) headers)))
+ ;; Only accept CRLF (we're not this strict everywhere...)
+ ((and (eqv? c #\return) (eqv? (peek-char port) #\newline))
+ (read-char port) ; Consume and discard NL
+ (if (null? ln) ; Nothing came before: end of headers
+ headers
+ (let ((pc (peek-char port)))
+ (if (and (not (eof-object? pc))
+ (or (eqv? pc #\space) (eqv? pc #\tab)))
+ ;; If the next line starts with whitespace,
+ ;; it's a continuation line of the same
+ ;; header. See section 2.2 of RFC 2616.
+ (let skip ((pc (read-char port)) (len len) (ln ln))
+ (if (and (not (eqv? len line-limit))
+ (or (eqv? pc #\space) (eqv? pc #\tab)))
+ (skip (read-char port) (add1 len) (cons pc ln))
+ (lp pc ln headers hc len)))
+ (let* ((ln (reverse-list->string ln))
+ (headers (parse-header-line ln headers))
+ (hc (add1 hc)))
+ (when (eqv? hc header-limit)
+ (signal-http-condition
+ 'read-headers
+ "Max allowed header count exceeded"
+ (list port)
+ 'header-limit-exceeded
+ 'contents ln
+ 'headers headers
+ 'limit header-limit))
+ (lp (read-char port) '() headers hc 0))))))
+ ((eqv? c #\")
+ (let lp2 ((c2 (read-char port))
+ (ln (cons c ln))
+ (len len))
+ (cond ((or (eqv? 0 len) (eof-object? c2))
+ (lp c2 ln headers hc len))
+ ((eqv? c2 #\")
+ (lp (read-char port) (cons c2 ln)
+ headers hc (add1 len)))
+ ((eqv? c2 #\\)
+ (let ((c3 (read-char port))
+ (len len))
+ (if (or (eof-object? c3) (eqv? 0 len))
+ (lp c3 (cons c2 ln) headers hc len)
+ (lp2 (read-char port)
+ (cons c3 (cons c2 ln))
+ (add1 len)))))
+ (else
+ (lp2 (read-char port) (cons c2 ln) (add1 len))))))
+ (else
+ (lp (read-char port) (cons c ln) headers hc (add1 len))))))))
+
+(define (signal-http-condition loc msg args type . more-info)
+ (signal (make-composite-condition
+ (make-property-condition 'http)
+ (apply make-property-condition type more-info)
+ (make-property-condition
+ 'exn 'location loc 'message msg 'arguments args))))
+
+(defstruct request
+ (method 'GET) uri (major 1) (minor 1) (headers (make-headers '())) port)
+
+;; Perhaps we should have header parsers indexed by version or
+;; something like that, so you can define the maximum version. Useful
+;; for when expecting a response. Then we group request/response parsers
+;; together, as with request/response unparsers.
+(define http-0.9-request-parser
+ (let ((req (irregex '(seq (w/nocase "GET") (+ space) (=> uri (* any))))))
+ (lambda (line in)
+ (and-let* ((m (irregex-match req line))
+ (uri (normalized-uri (irregex-match-substring m 'uri))))
+ (make-request method: 'GET uri: uri
+ major: 0 minor: 9 port: in)))))
+
+;; Might want to reuse this elsewhere
+(define token-sre '(+ (~ "()<>@,;:\\\"/[]?={}\t ")))
+
+;; XXX This actually parses anything >= HTTP/1.0
+(define http-1.x-request-parser
+ (let ((req (irregex `(seq (=> method ,token-sre) (+ space)
+ (=> uri (+ (~ blank))) ; uri-common handles details
+ (+ space) (w/nocase "HTTP/")
+ (=> major (+ digit)) "." (=> minor (+ digit))))))
+ (lambda (line in)
+ (and-let* ((m (irregex-match req line))
+ (uri-string (irregex-match-substring m 'uri))
+ (major (string->number (irregex-match-substring m 'major)))
+ (minor (string->number (irregex-match-substring m 'minor)))
+ (method (string->http-method (irregex-match-substring m 'method)))
+ (headers (read-headers in)))
+ (let* ((wildcard (string=? uri-string "*"))
+ (uri (and (not wildcard) (normalized-uri uri-string)))
+ ;; HTTP/1.0 has no chunking
+ (port (if (and (or (> major 1) (>= minor 1))
+ (memq 'chunked
+ (header-values
+ 'transfer-encoding headers)))
+ (chunked-input-port in)
+ in)))
+ ;; HTTP/1.1 allows several "things" as "URI" (RFC2616, 5.1.2):
+ ;; Request-URI = "*" | absoluteURI | abs_path | authority
+ ;;
+ ;; HTTP/1.0, URIs are more limited (RFC1945, 5.1.2):
+ ;; Request-URI = absoluteURI | abs_path
+ ;;
+ ;; Currently, a plain authority is not accepted. This would
+ ;; require deep changes in the representation of request
+ ;; objects. It is only used in CONNECT requests, so
+ ;; currently not much of a problem. If we want to support
+ ;; this, we'd need a separate object type and expose a
+ ;; parser from uri-generic/uri-common for just authority.
+ (and (or (and wildcard (or (> major 1) (>= minor 1)))
+ (and uri (or (absolute-uri? uri)
+ (and (uri-path-absolute? uri)
+ (not (uri-host uri))))))
+ (make-request method: method uri: uri
+ major: major minor: minor
+ headers: headers
+ port: port)))))))
+
+(define request-parsers ; order matters here
+ (make-parameter (list http-1.x-request-parser)))
+
+(define (read-request inport)
+ (let ((line (safe-read-line inport)))
+ (and (not (eof-object? line))
+ ;; Try each parser in turn to process the request-line.
+ ;; A parser returns either #f or a request object
+ (let loop ((parsers (request-parsers)))
+ (if (null? parsers)
+ (signal-http-condition
+ 'read-request "Unknown protocol line" (list line)
+ 'unknown-protocol-line 'line line)
+ (or ((car parsers) line inport) (loop (cdr parsers))))))))
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;;;; Request unparsing ;;;;
+;;;;;;;;;;;;;;;;;;;;;;;;;;;
+
+(define header-unparsers
+ (make-parameter
+ `((content-disposition . ,content-disposition-unparser)
+ (date . ,rfc1123-unparser)
+ (etag . ,etag-unparser)
+ (expires . ,rfc1123-unparser)
+ (host . ,host/port-unparser)
+ (if-match . ,if-match-unparser)
+ (if-modified-since . ,rfc1123-unparser)
+ (if-none-match . ,if-match-unparser)
+ (if-unmodified-since . ,rfc1123-unparser)
+ (last-modified . ,rfc1123-unparser)
+ (user-agent . ,software-unparser)
+ (server . ,software-unparser)
+ (upgrade . ,product-unparser)
+ (cookie . ,cookie-unparser)
+ (set-cookie . ,set-cookie-unparser)
+ (authorization . ,authorization-unparser)
+ (www-authenticate . ,authenticate-unparser)
+ (proxy-authorization . ,authorization-unparser)
+ (proxy-authenticate . ,authenticate-unparser)
+ (via . ,via-unparser)
+ ;; RFC 6797
+ (strict-transport-security . ,strict-transport-security-unparser))))
+
+(define (unparse-header header-name header-value)
+ (cond ((and (not (null? header-value))
+ (eq? 'raw (get-params (car header-value))))
+ (map get-no-newline-value header-value))
+ ((assq header-name (header-unparsers))
+ => (lambda (unparser) ((cdr unparser) header-value)))
+ (else (default-header-unparser header-value))))
+
+(define (unparse-headers headers out)
+ (let ((unparsers (header-unparsers))) ; Don't access parameter for each header
+ (for-each
+ (lambda (h)
+ (let* ((name (car h))
+ (name-s (symbol->http-name name))
+ (contents (cdr h))
+ (unparse (cond ((assq name unparsers) => cdr) ; inlined for perf
+ (else default-header-unparser))))
+ (handle-exceptions exn
+ (if ((condition-predicate 'http) exn)
+ (signal exn) ;; Do not tamper with our own custom errors
+ (let* ((none "(no error message provided in original exn)")
+ (msg ((condition-property-accessor
+ 'exn 'message none) exn))
+ (loc ((condition-property-accessor
+ 'exn 'location #f) exn))
+ (args ((condition-property-accessor
+ 'exn 'arguments '()) exn)))
+ (signal-http-condition
+ 'unparse-headers
+ (sprintf "could not unparse ~S header ~S: ~A~A"
+ name-s contents (if loc (sprintf "(~A) " loc) "") msg)
+ args
+ 'unparse-error
+ 'header-name name
+ 'header-value contents
+ 'unparser unparse
+ 'original-exn exn)))
+ (let ((lines (if (and (not (null? contents))
+ (eq? 'raw (get-params (car contents))))
+ (map get-no-newline-value contents)
+ (unparse contents))))
+ (for-each (lambda (value)
+ ;; Verify there's no \r\n or \r or \n in value?
+ (display (string-append name-s ": " value "\r\n") out))
+ lines)))))
+ (headers-v headers))))
+
+;; Use string-append and display rather than fprintf so the line gets
+;; written in one burst. This supposedly avoids a strange race
+;; condition, see #800. We use string-append instead of sprintf for
+;; performance reasons. This is not exported, and our callers compare
+;; request-major and request-minor so we can assume they're numbers.
+(define (write-request-line request)
+ (let ((uri (request-uri request)))
+ (display (string-append
+ (http-method->string (request-method request))
+ " " (if uri (uri->string uri) "*") " HTTP/"
+ (number->string (request-major request)) "."
+ (number->string (request-minor request)) "\r\n")
+ (request-port request))))
+
+(define (http-0.9-request-unparser request)
+ (display (string-append "GET " (uri->string (request-uri request)) "\r\n")
+ (request-port request))
+ request)
+
+(define (http-1.0-request-unparser request)
+ (and-let* (((= (request-major request) 1))
+ ((= (request-minor request) 0))
+ (o (request-port request)))
+ (write-request-line request)
+ (unparse-headers (request-headers request) o)
+ (display "\r\n" o)
+ request))
+
+;; XXX This actually unparses anything >= HTTP/1.1
+(define (http-1.x-request-unparser request)
+ (and-let* (((or (> (request-major request) 1)
+ (and (= (request-major request) 1)
+ (> (request-minor request) 0))))
+ (o (request-port request)))
+ (write-request-line request)
+ (unparse-headers (request-headers request) o)
+ (display "\r\n" o)
+ (if (memq 'chunked (header-values 'transfer-encoding
+ (request-headers request)))
+ (update-request request
+ port: (chunked-output-port (request-port request)))
+ request)))
+
+(define request-unparsers ; order matters here
+ (make-parameter (list http-1.x-request-unparser http-1.0-request-unparser)))
+
+(define (write-request request)
+ ;; Try each unparser in turn to write the request-line.
+ ;; An unparser returns either #f or a new request object.
+ (let loop ((unparsers (request-unparsers)))
+ (if (null? unparsers)
+ (let ((major (request-major request))
+ (minor (request-minor request)))
+ (signal-http-condition
+ 'write-request
+ "Unknown protocol" (list (conc major "." minor))
+ 'unknown-protocol 'major major 'minor minor))
+ (or ((car unparsers) request) (loop (cdr unparsers))))))
+
+;; Required for chunked requests. This is a bit of a hack!
+(define (finish-request-body request)
+ (when (and (memq 'chunked (header-values 'transfer-encoding
+ (request-headers request)))
+ (eq? (##sys#slot (request-port request) 10) 'chunked-output-port))
+ (display "0\r\n\r\n" (##sys#slot (request-port request) 11)))
+ request)
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;;;; Response unparsing ;;;;
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+
+(defstruct response
+ (code 200) (reason "OK") (major 1) (minor 1) (headers (make-headers '())) port)
+
+(define make-response
+ (let ((old-make-response make-response))
+ (lambda (#!rest args #!key status code reason)
+ (let ((resp (apply old-make-response args)))
+ (when (and status (not code) (not reason))
+ (response-status-set! resp status))
+ resp))))
+
+(define update-response
+ (let ((old-update-response update-response))
+ (lambda (resp #!rest args #!key status code reason)
+ (let ((resp (apply old-update-response resp args)))
+ (when (and status (not code) (not reason))
+ (response-status-set! resp status))
+ resp))))
+
+(define (response-status-set! resp status)
+ (receive (code reason) (http-status->code&reason status)
+ (response-code-set! resp code)
+ (response-reason-set! resp reason)
+ resp))
+
+(define (response-class obj)
+ (let ((code (if (response? obj) (response-code obj) obj)))
+ (- code (modulo code 100))))
+
+(define (response-status obj)
+ (let* ((c (if (response? obj) (response-code obj) obj))
+ (s (find (lambda (x) (= (cadr x) c)) (http-status-codes))))
+ (if s
+ (car s)
+ (signal-http-condition
+ 'response-status "Unknown status code" (list c)
+ 'unknown-code 'code c))))
+
+(define (http-status->code&reason status)
+ (let ((s (alist-ref status (http-status-codes))))
+ (unless s
+ (signal-http-condition
+ 'http-status->code&reason
+ ;; haha, status symbol ;)
+ "Unknown response status symbol"
+ (list status) 'unknown-status 'status status))
+ (values (car s) (cdr s))))
+
+;; List of HTTP status codes based on:
+;; http://www.iana.org/assignments/http-status-codes/http-status-codes.xml
+(define http-status-codes
+ (make-parameter
+ `((continue . (100 . "Continue"))
+ (switching-protocols . (101 . "Switching Protocols"))
+ (processing . (102 . "Processing"))
+ (ok . (200 . "OK"))
+ (created . (201 . "Created"))
+ (accepted . (202 . "Accepted"))
+ (non-authoritative-information . (203 . "Non-Authoritative Information"))
+ (no-content . (204 . "No Content"))
+ (reset-content . (205 . "Reset Content"))
+ (partial-content . (206 . "Partial Content"))
+ (multi-status . (207 . "Multi-Status"))
+ (already-reported . (208 . "Already Reported"))
+ (im-used . (226 . "IM Used"))
+ (multiple-choices . (300 . "Multiple Choices"))
+ (moved-permanently . (301 . "Moved Permanently"))
+ (found . (302 . "Found"))
+ (see-other . (303 . "See Other"))
+ (not-modified . (304 . "Not Modified"))
+ (use-proxy . (305 . "Use Proxy"))
+ (temporary-redirect . (307 . "Temporary Redirect"))
+ (bad-request . (400 . "Bad Request"))
+ (unauthorized . (401 . "Unauthorized"))
+ (payment-required . (402 . "Payment Required"))
+ (forbidden . (403 . "Forbidden"))
+ (not-found . (404 . "Not Found"))
+ (method-not-allowed . (405 . "Method Not Allowed"))
+ (not-acceptable . (406 . "Not Acceptable"))
+ (proxy-authentication-required . (407 . "Proxy Authentication Required"))
+ (request-time-out . (408 . "Request Time-out"))
+ (conflict . (409 . "Conflict"))
+ (gone . (410 . "Gone"))
+ (length-required . (411 . "Length Required"))
+ (precondition-failed . (412 . "Precondition Failed"))
+ (request-entity-too-large . (413 . "Request Entity Too Large"))
+ (request-uri-too-large . (414 . "Request-URI Too Large"))
+ (unsupported-media-type . (415 . "Unsupported Media Type"))
+ (requested-range-not-satisfiable . (416 . "Requested Range Not Satisfiable"))
+ (expectation-failed . (417 . "Expectation Failed"))
+ (unprocessable-entity . (422 . "Unprocessable Entity"))
+ (locked . (423 . "Locked"))
+ (failed-dependency . (424 . "Failed Dependency"))
+ (upgrade-required . (426 . "Upgrade Required"))
+ (precondition-required . (428 . "Precondition Required"))
+ (too-many-requests . (429 . "Too Many Requests"))
+ (request-header-fields-too-large . (431 . "Request Header Fields Too Large"))
+ (internal-server-error . (500 . "Internal Server Error"))
+ (not-implemented . (501 . "Not Implemented"))
+ (bad-gateway . (502 . "Bad Gateway"))
+ (service-unavailable . (503 . "Service Unavailable"))
+ (gateway-time-out . (504 . "Gateway Time-out"))
+ (http-version-not-supported . (505 . "HTTP Version Not Supported"))
+ (insufficient-storage . (507 . "Insufficient Storage"))
+ (loop-detected . (508 . "Loop Detected"))
+ (not-extended . (510 . "Not Extended"))
+ (network-authentication-required . (511 . "Network Authentication Required")))))
+
+(define (http-0.9-response-unparser response)
+ response) ;; The response-body will just follow
+
+;; See notes at write-request-line
+(define (write-response-line response)
+ (display (string-append
+ "HTTP/"
+ (number->string (response-major response)) "."
+ (number->string (response-minor response)) " "
+ (->string (response-code response)) " "
+ (->string (response-reason response)) "\r\n")
+ (response-port response)))
+
+(define (http-1.0-response-unparser response)
+ (and-let* (((= (response-major response) 1))
+ ((= (response-minor response) 0))
+ (o (response-port response)))
+ (write-response-line response)
+ (unparse-headers (response-headers response) o)
+ (display "\r\n" o)
+ response))
+
+;; XXX This actually unparses anything >= HTTP/1.1
+(define (http-1.x-response-unparser response)
+ (and-let* (((or (> (response-major response) 1)
+ (and (= (response-major response) 1)
+ (> (response-minor response) 0))))
+ (o (response-port response)))
+ (write-response-line response)
+ (unparse-headers (response-headers response) o)
+ (display "\r\n" o)
+ (if (memq 'chunked (header-values 'transfer-encoding
+ (response-headers response)))
+ (update-response response
+ port: (chunked-output-port (response-port response)))
+ response)))
+
+(define response-unparsers
+ (make-parameter (list http-1.x-response-unparser http-1.0-response-unparser)))
+
+(define (write-response response)
+ ;; Try each unparser in turn to write the response-line.
+ ;; An unparser returns either #f or a new response object.
+ (let loop ((unparsers (response-unparsers)))
+ (if (null? unparsers)
+ (let ((major (response-major response))
+ (minor (response-minor response)))
+ (signal-http-condition
+ 'write-response
+ "Unknown protocol" (list (conc major "." minor))
+ 'unknown-protocol 'major major 'minor minor))
+ (or ((car unparsers) response) (loop (cdr unparsers))))))
+
+;; Required for chunked requests. This is a bit of a hack!
+(define (finish-response-body response)
+ (when (and (memq 'chunked (header-values 'transfer-encoding
+ (response-headers response)))
+ (eq? (##sys#slot (response-port response) 10) 'chunked-output-port))
+ (display "0\r\n\r\n" (##sys#slot (response-port response) 11)))
+ response)
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;
+;;;; Response parsing ;;;;
+;;;;;;;;;;;;;;;;;;;;;;;;;;
+
+(define http-1.x-response-parser
+ (let ((resp (irregex '(seq (w/nocase "HTTP/")
+ (=> major (+ digit)) "." (=> minor (+ digit))
+ ;; Could use '(= 3 digit) for status-code, but
+ ;; that's currently not compilable
+ (+ space) (=> status-code digit digit digit)
+ (+ space) (=> reason-phrase (* nonl))))))
+ (lambda (line in)
+ (and-let* ((m (irregex-match resp line))
+ (code (string->number (irregex-match-substring m 'status-code)))
+ (major (string->number (irregex-match-substring m 'major)))
+ (minor (string->number (irregex-match-substring m 'minor)))
+ ((or (> major 1) (and (= major 1) (> minor 0))))
+ (reason (irregex-match-substring m 'reason-phrase))
+ (h (read-headers in))
+ (port (if (memq 'chunked (header-values 'transfer-encoding h))
+ (chunked-input-port in)
+ in)))
+ (make-response code: code reason: reason
+ major: major minor: minor
+ headers: h
+ port: port)))))
+
+(define http-1.0-response-parser
+ (let ((resp (irregex '(seq (w/nocase "HTTP/1.0")
+ ;; Could use '(= 3 digit) for status-code, but
+ ;; that's currently not compilable
+ (+ space) (=> status-code digit digit digit)
+ (+ space) (=> reason-phrase (* nonl))))))
+ (lambda (line in)
+ (and-let* ((m (irregex-match resp line))
+ (code (string->number (irregex-match-substring m 'status-code)))
+ (reason (irregex-match-substring m 'reason-phrase))
+ (h (read-headers in)))
+ ;; HTTP/1.0 has no chunking
+ (make-response code: code reason: reason
+ major: 1 minor: 0
+ headers: h
+ port: in)))))
+
+;; You can't "detect" a 0.9 response, because there is no response line.
+;; It will simply output the body directly, so we will just assume that
+;; if we can't recognise the output string, we just got a 0.9 response.
+(define (http-0.9-response-parser line in)
+ (make-response code: 200 reason: "OK"
+ major: 0 minor: 9
+ ;; XXX This is wrong, it re-inserts \r\n, while it may have
+ ;; been a \n only. To work around this, we'd have to write
+ ;; a custom (safe-)read-line procedure.
+ ;; However, it does not matter much because HTTP 0.9 is only
+ ;; defined to ever return text/html, no binary or any other
+ ;; content type.
+ port: (call-with-input-string (string-append line "\r\n")
+ (lambda (str)
+ (make-concatenated-port str in)))))
+
+(define response-parsers ;; order matters here
+ (make-parameter (list http-1.x-response-parser http-1.0-response-parser)))
+
+(define (read-response inport)
+ (let ((line (safe-read-line inport)))
+ (and (not (eof-object? line))
+ (let loop ((parsers (response-parsers)))
+ (if (null? parsers)
+ (signal-http-condition
+ 'read-response "Unknown protocol line" (list line)
+ 'unknown-protocol-line 'line line)
+ (or ((car parsers) line inport) (loop (cdr parsers))))))))
+
+)
diff --git a/tests/run.scm b/tests/run.scm
new file mode 100644
index 0000000..e2d0b70
--- /dev/null
+++ b/tests/run.scm
@@ -0,0 +1,1243 @@
+(import scheme chicken.base chicken.port
+ chicken.condition chicken.time.posix srfi-1 srfi-18
+ test uri-common intarweb chicken.io chicken.format)
+
+;; Below, there are specific tests for when these do have a value
+(http-header-limit #f)
+(http-line-limit #f)
+(http-urlencoded-request-data-limit #f)
+
+(define-syntax test-error*
+ (syntax-rules ()
+ ((_ ?msg (?error-type ...) ?expr)
+ (let-syntax ((expression:
+ (syntax-rules ()
+ ((_ ?expr)
+ (condition-case (begin ?expr "<no error thrown>")
+ ((?error-type ...) '(?error-type ...))
+ (exn () (##sys#slot exn 1)))))))
+ (test ?msg '(?error-type ...) (expression: ?expr))))
+ ((_ ?msg ?error-type ?expr)
+ (test-error* ?msg (?error-type) ?expr))
+ ((_ ?error-type ?expr)
+ (test-error* (sprintf "~S" '?expr) ?error-type ?expr))))
+
+(header-parse-error-handler (lambda (header-name contents header exn)
+ (raise exn)))
+
+(define (test-read-headers str)
+ (call-with-input-string str read-headers))
+
+(test-begin "intarweb")
+(test-group "headers"
+ (test-group "single headers"
+ (parameterize ((single-headers '(foo qux))
+ (header-parsers `((foo . ,(single identity))
+ (qux . ,(single identity)))))
+ (let ((headers (test-read-headers "foo: bar\r\nqux:\t \tmooh\t \r\n\r\n")))
+ (test "Basic test"
+ '("bar") (header-values 'foo headers))
+ ;; RFC 2616 4.2
+ (test "Extra spaces are ignored"
+ '("mooh") (header-values 'qux headers)))
+ (let ((headers (test-read-headers "foo: bar\r\n qux: mooh\r\nquux: mumble\r\n\r\n")))
+ ;; RFC 2616 2.2
+ (test "Continuation chars"
+ '("bar qux: mooh") (header-values 'foo headers)))
+ ;; Not in RFC but common behaviour - also, robustness principle
+ (let ((headers (test-read-headers "foo: bar\r\nfoo: qux\r\n")))
+ (test "Multiple headers for singular header types discarded"
+ '("qux") (header-values 'foo headers)))))
+ ;; All this RFC 2616 4.2
+ (test-group "multi-headers"
+ (parameterize ((header-parsers `((foo . ,(multiple identity)))))
+ (let ((headers (test-read-headers "foo: bar\r\nfoo: qux\r\nquux: mumble\r\n\r\n")))
+ (test "Multiple headers"
+ '("bar" "qux") (header-values 'foo headers)))
+ (let ((headers (test-read-headers "Foo: bar\r\nFoO: qux\r\nquux: mumble\r\n\r\n")))
+ (test "Multiple headers: case insensitivity"
+ '("bar" "qux") (header-values 'foo headers)))
+ (let ((headers (test-read-headers "foo: bar, qux\r\nquux: mumble\r\n\r\n")))
+ (test "Comma-separated headers"
+ '("bar" "qux") (header-values 'foo headers)))
+ (let ((headers (test-read-headers "foo: \"ba\\\"r, qux\"\r\nfoo: mooh\r\n\r\n")))
+ (test "Quoted headers"
+ '("ba\"r, qux" "mooh") (header-values 'foo headers))))
+ ;; RFC 2616 4.5
+ ;; "Unrecognized header fields are treated as entity-header fields."
+ ;;
+ ;; RFC 2616 7.1
+ ;; "Unrecognized header fields SHOULD be ignored by the recipient and MUST be
+ ;; forwarded by transparent proxies."
+ (let ((headers (test-read-headers "unknown: foo, bar\r\nunknown: blah\r\n\r\n")))
+ (test "Unknown headers are not parsed and put into lists"
+ '("foo, bar" "blah") (header-values 'unknown headers))
+ (test "Unknown headers get raw instead of a parameter list"
+ 'raw (header-params 'unknown headers))))
+ (test-group "miscellaneous header stuff"
+ (parameterize ((header-parsers `((foo . ,(multiple identity))
+ (bar . ,(lambda x (error "bad header")))))
+ (http-header-limit 2))
+ (test-error "Missing header contents" (test-read-headers "foo\r\n\r\n"))
+ (test-error "Bad header w/ handler" (test-read-headers "bar: x\r\n\r\n"))
+ (parameterize ((header-parse-error-handler (lambda (n c h exn) h)))
+ (test "Bad header w/o handler" #t (headers? (test-read-headers "bar: x\r\n\r\n"))))
+ ;; RFC 2616 2.2
+ ;; "The backslash character ("\") MAY be used as a single-character
+ ;; quoting mechanism only within quoted-string and comment constructs."
+ ;; quoted-pair = "\" CHAR
+ ;; CHAR implies any char, *including* CR/LF. This is clarified by RFC 822,
+ ;; on which RFC 2616 is based.
+ ;; Apparently, even \CRLF is allowed (as opposed to \CR\LF)
+ (test "Embedded newlines"
+ '("bar\r\nqux")
+ ;; It's unclear whether we should interpret the "\r\n" as EOL
+ ;; in "\\\r\n", or whether it should be seen as an embedded \r
+ ;; followed by a \n (which is then interpreted as a literal \n?)
+ (header-values 'foo (test-read-headers "Foo: \"bar\\\r\\\nqux\"")))
+ (test-error "Too many headers is an error"
+ (test-read-headers "foo: bar\r\nfoo: qux\r\nfoo: hoohoo\r\n")))))
+
+(test-group "specialized header parsers"
+ (test-group "host/port"
+ (test "Hostname and port"
+ '(("foo.example.com" . 8080))
+ (header-values 'host (test-read-headers "Host: foo.example.com:8080")))
+ (test "Hostname, no port"
+ '(("foo.example.com" . #f))
+ (header-values 'host (test-read-headers "Host: foo.example.com"))))
+ (test-group "quality parameter"
+ (let* ((headers (test-read-headers "Accept: text/plain; Q=0.5, text/html, text/plain; q=0.123456, application/pdf; q=1.2345, text/xml; q=-0.234, text/whatever; q="))
+ (accept (header-contents 'accept headers)))
+ ;; RFC 2616 3.6: "All transfer-coding values are case insensitive".
+ ;; This includes the parameter name (attribute) and value.
+ (test "quality value (case-insensitive)"
+ 0.5 (get-param 'q (first accept) 1.0))
+ (test "quality encoding value"
+ 'text/plain (get-value (first accept)))
+ (test "quality values have only three digits"
+ 0.123 (get-param 'q (third accept) 1.0))
+ (test "quality values maximum is 1.0"
+ 1.0 (get-param 'q (fourth accept) 1.0))
+ (test "quality values minimum is 0.0"
+ 0.0 (get-param 'q (fifth accept) 1.0))
+ (test "missing quality value ok"
+ 1.0 (get-param 'q (sixth accept) 1.0))))
+ (test-group "charset parameter"
+ (let* ((headers (test-read-headers "Content-Type: text/PLAIN; charset=ISO-8859-1"))
+ (content-type (header-contents 'content-type headers)))
+ (test "content-type value is lowercase symbol"
+ 'text/plain (get-value (car content-type)))
+ ;; RFC 2616 3.4: "HTTP character sets are identified by
+ ;; case-insensitive tokens. The complete set of tokens is defined
+ ;; by the IANA Character Set registry."
+ (test "content-type charset is lowercase symbol"
+ 'iso-8859-1 (get-param 'charset (car content-type)))))
+
+ (test-group "symbol-parser-ci"
+ (let* ((headers (test-read-headers "Accept-Ranges: FoO")))
+ (test "Case-insensitive"
+ '(foo) (header-values 'accept-ranges headers))))
+
+ (test-group "symbol-parser"
+ (let* ((headers (test-read-headers "Allow: FoO, foo")))
+ (test "Case-sensitive"
+ '(FoO foo) (header-values 'allow headers))))
+
+ (test-group "natnum-subparser"
+ (parameterize ((single-headers '(foo bar qux mooh))
+ (header-parsers `((foo . ,(single natnum-subparser))
+ (bar . ,(single natnum-subparser))
+ (qux . ,(single natnum-subparser))
+ (mooh . ,(single natnum-subparser)))))
+ (let ((headers (test-read-headers "Foo: 10\r\nBar: abc\r\nQux: -10\r\nMooh: 1.6")))
+ (test "Simple test"
+ 10 (header-value 'foo headers))
+ (test "No number defaults to 0"
+ 0 (header-value 'bar headers))
+ (test "No negative numbers"
+ 0 (header-value 'qux headers))
+ ;; This is a "feature" in the interest of the robustness principle
+ (test "Rounding of real numbers"
+ 2 (header-value 'mooh headers)))))
+
+ (test-group "cache-control-parser"
+ (let ((headers (test-read-headers "Cache-control: max-age=10, private")))
+ (test "max-age is a number"
+ '(max-age . 10) (assq 'max-age (header-values 'cache-control headers)))
+ (test "private without value"
+ '(private . #t) (assq 'private (header-values 'cache-control headers))))
+ (let ((headers (test-read-headers "Cache-control: private=\"accept-encoding, accept-ranges\"\r\nCache-control: must-revalidate")))
+ (test "private with values"
+ '(private . (accept-encoding accept-ranges))
+ (assq 'private (header-values 'cache-control headers)))
+ (test "Acts like a multi-header"
+ '(must-revalidate . #t) (assq 'must-revalidate (header-values 'cache-control headers)))))
+
+ (test-group "authorization-parser"
+ (test-group "basic auth"
+ (let ((headers (test-read-headers "Authorization: Basic QWxpIEJhYmE6b3BlbiBzZXNhbWU=\r\n")))
+ (test "basic"
+ 'basic
+ (header-value 'authorization headers))
+ (test "username"
+ "Ali Baba"
+ (header-param 'username 'authorization headers))
+ (test "password"
+ "open sesame"
+ (header-param 'password 'authorization headers))))
+ (test-group "digest auth"
+ (let ((headers (test-read-headers "Authorization: Digest username=\"Mufasa\", realm=\"testrealm@host.com\", nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\", uri=\"/dir/index.html\", qop=auth, nc=00000001, cnonce=\"0a4f113b\", response=\"6629fae49393a05397450978507c4ef1\", opaque=\"5ccc069c403ebaf9f0171e9517f40e41\", algorithm=MD5")))
+ (test "digest"
+ 'digest
+ (header-value 'authorization headers))
+ (test "realm"
+ "testrealm@host.com"
+ (header-param 'realm 'authorization headers))
+ (test "nonce"
+ "dcd98b7102dd2f0e8b11d0f600bfb0c093"
+ (header-param 'nonce 'authorization headers))
+ (test "username"
+ "Mufasa"
+ (header-param 'username 'authorization headers))
+ (test "qop"
+ 'auth
+ (header-param 'qop 'authorization headers))
+ (test "digest uri"
+ "/dir/index.html"
+ (uri->string (header-param 'uri 'authorization headers)))
+ (test "nonce count"
+ 1
+ (header-param 'nc 'authorization headers))
+ (test "cnonce"
+ "0a4f113b"
+ (header-param 'cnonce 'authorization headers))
+ (test "response"
+ "6629fae49393a05397450978507c4ef1"
+ (header-param 'response 'authorization headers))
+ (test "opaque"
+ "5ccc069c403ebaf9f0171e9517f40e41"
+ (header-param 'opaque 'authorization headers))
+ (test "algorithm"
+ 'md5
+ (header-param 'algorithm 'authorization headers))))
+ (test-group "custom authorization scheme"
+ (parameterize ((authorization-param-subparsers
+ `((custom . ,(lambda (contents pos)
+ (receive (c p)
+ (parse-token contents pos)
+ (values `((contents . ,(http-name->symbol c))) p))))
+ . ,(authorization-param-subparsers))))
+ (let ((headers (test-read-headers "Authorization: Custom Security-through-obscurity")))
+ (test "Custom"
+ 'custom
+ (header-value 'authorization headers))
+ (test "Custom contents"
+ 'security-through-obscurity
+ (header-param 'contents 'authorization headers))))))
+
+ (test-group "authenticate parser"
+ (test-group "basic auth"
+ (let ((headers (test-read-headers "WWW-Authenticate: Basic realm=\"WallyWorld\"")))
+ (test "basic"
+ 'basic
+ (header-value 'www-authenticate headers))
+ (test "realm"
+ "WallyWorld"
+ (header-param 'realm 'www-authenticate headers))))
+ (test-group "digest auth"
+ (let ((headers (test-read-headers "WWW-Authenticate: Digest realm=\"testrealm@host.com\", qop=\"auth, auth-int\", nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\", opaque=\"5ccc069c403ebaf9f0171e9517f40e41\"")))
+ (test "digest"
+ 'digest
+ (header-value 'www-authenticate headers))
+ (test "realm"
+ "testrealm@host.com"
+ (header-param 'realm 'www-authenticate headers))
+ (test "qop"
+ '(auth auth-int)
+ (header-param 'qop 'www-authenticate headers))
+ (test "nonce"
+ "dcd98b7102dd2f0e8b11d0f600bfb0c093"
+ (header-param 'nonce 'www-authenticate headers))
+ (test "opaque"
+ "5ccc069c403ebaf9f0171e9517f40e41"
+ (header-param 'opaque 'www-authenticate headers))
+ (test "missing stale value"
+ #f
+ (header-param 'stale 'www-authenticate headers)))
+ (let ((headers (test-read-headers "WWW-Authenticate: Digest domain=\"/example http://foo.com/bar\", stale=TRUE")))
+ (test "domains"
+ '("/example" "http://foo.com/bar")
+ (map uri->string
+ (header-param 'domain 'www-authenticate headers)))
+ (test "stale"
+ #t
+ (header-param 'stale 'www-authenticate headers)))
+ (let ((headers (test-read-headers "WWW-Authenticate: Digest stale=whatever")))
+ (test "non-true stale value"
+ #f
+ (header-param 'stale 'www-authenticate headers)))))
+
+ (test-group "pragma-parser"
+ (let ((headers (test-read-headers "Pragma: custom-value=10, no-cache")))
+ (test "value"
+ '(custom-value . "10")
+ (assq 'custom-value (header-values 'pragma headers)))
+ (test "no value"
+ '(no-cache . #t) (assq 'no-cache (header-values 'pragma headers))))
+ (let ((headers (test-read-headers "Cache-control: private=\"accept-encoding, accept-ranges\"\r\nCache-control: must-revalidate")))
+ (test "private with values"
+ '(private . (accept-encoding accept-ranges))
+ (assq 'private (header-values 'cache-control headers)))
+ (test "Acts like a multi-header"
+ '(must-revalidate . #t) (assq 'must-revalidate (header-values 'cache-control headers)))))
+
+ ;; RFC 2616, 14.15 & RFC 1864 (Base64)
+ (test-group "base64-parser"
+ (let ((headers (test-read-headers "Content-md5: Q2hlY2sgSW50ZWdyaXR5IQ==")))
+ (test "md5 is base64-decoded"
+ "Check Integrity!"
+ (header-value 'content-md5 headers))))
+
+ (test-group "range-parser"
+ (let ((headers (test-read-headers "content-range: bytes 500-999/1234")))
+ (test "Simple range"
+ '(500 999 1234)
+ (header-value 'content-range headers))))
+
+ (test-group "content-disposition"
+ (let ((headers (test-read-headers "Content-Disposition: attachment; filename=dir/foo.jpg")))
+ (test "Attachment with filename parameter containing directory"
+ `(attachment (filename . "foo.jpg"))
+ (cons (header-value 'content-disposition headers)
+ (header-params 'content-disposition headers))))
+ (let ((headers (test-read-headers "Content-Disposition: inline; filename=foo.jpg; creation-date=Sun, 06 Nov 1994 08:49:37 GMT")))
+ (test "Inline with filename and (not quoted) creation-date parameter"
+ `(inline
+ (filename . "foo.jpg")
+ (creation-date . ,(utc-time->seconds '#(37 49 08 06 10 94 0 310 #f 0))))
+ (cons (header-value 'content-disposition headers)
+ (map (lambda (x)
+ (if (vector? (cdr x))
+ (cons (car x) (utc-time->seconds (cdr x)))
+ x))
+ (header-params 'content-disposition headers)))))
+ (let ((headers (test-read-headers "Content-Disposition: inline; read-date=\"Sun, 06 Nov 1994 08:49:37 GMT\"; size=100")))
+ (test "Inline with size and (quoted) read-date parameter"
+ `(inline
+ (read-date . ,(utc-time->seconds '#(37 49 08 06 10 94 0 310 #f 0)))
+ (size . 100))
+ (cons (header-value 'content-disposition headers)
+ (map (lambda (x)
+ (if (vector? (cdr x))
+ (cons (car x) (utc-time->seconds (cdr x)))
+ x))
+ (header-params 'content-disposition headers))))))
+
+ (test-group "normalized-uri"
+ (let ((headers (test-read-headers "Location: http://example.com/foo")))
+ (test "Uri"
+ (uri-reference "http://example.com/foo")
+ (header-value 'location headers)))
+ (let ((headers (test-read-headers "Location: http://example.com/foo/../bar")))
+ (test "Auto-normalization"
+ (uri-reference "http://example.com/bar")
+ (header-value 'location headers))))
+
+ (test-group "etag-parser"
+ (let ((headers (test-read-headers "Etag: \"foo\"")))
+ (test "Strong tag"
+ '(strong . "foo")
+ (header-value 'etag headers)))
+ (let ((headers (test-read-headers "Etag: W/\"bar\"")))
+ (test "Weak tag"
+ '(weak . "bar")
+ (header-value 'etag headers)))
+ (let ((headers (test-read-headers "Etag: \"\"")))
+ (test "Empty tag"
+ '(strong . "")
+ (header-value 'etag headers)))
+ (let ((headers (test-read-headers "Etag: \"W/bar\"")))
+ (test "Strong tag, containing W/ prefix"
+ '(strong . "W/bar")
+ (header-value 'etag headers))))
+
+ (test-group "if-match parser"
+ (let ((headers (test-read-headers "If-match: foo")))
+ (test "Strong etag"
+ '(strong . "foo")
+ (header-value 'if-match headers)))
+ (let ((headers (test-read-headers "If-match: W/foo")))
+ (test "Weak etag"
+ '(weak . "foo")
+ (header-value 'if-match headers)))
+ (let ((headers (test-read-headers "If-match: W/foo bar")))
+ (test "Multiple etags"
+ '((weak . "foo") (strong . "bar"))
+ (header-values 'if-match headers)))
+ (let ((headers (test-read-headers "If-match: *")))
+ (test "Wildcard"
+ '*
+ (header-value 'if-match headers))))
+
+ (test-group "http-date-parser"
+ (let ((headers (test-read-headers "Date: Sun, 06 Nov 1994 08:49:37 GMT")))
+ (test "RFC1123 time"
+ (utc-time->seconds '#(37 49 08 06 10 94 0 310 #f 0))
+ (utc-time->seconds (header-value 'date headers))))
+ (let ((headers (test-read-headers "Date: Sunday, 06-Nov-94 08:49:37 GMT")))
+ (test "RFC850 time"
+ (utc-time->seconds '#(37 49 08 06 10 94 0 310 #f 0))
+ (utc-time->seconds (header-value 'date headers))))
+ (let ((headers (test-read-headers "Date: Sun Nov 6 08:49:37 1994")))
+ (test "asctime time"
+ (utc-time->seconds '#(37 49 08 06 10 94 0 310 #f 0))
+ (utc-time->seconds (header-value 'date headers)))))
+
+ ;; This seems a little excessive.. Maybe find a way to reduce the number
+ ;; of cases and still have a good representative test?
+ (test-group "If-Range parser"
+ (let ((headers (test-read-headers "If-Range: Sun, 06 Nov 1994 08:49:37 GMT")))
+ (test "RFC1123 time"
+ (utc-time->seconds '#(37 49 08 06 10 94 0 310 #f 0))
+ (utc-time->seconds (header-value 'if-range headers))))
+ (let ((headers (test-read-headers "If-Range: Sunday, 06-Nov-94 08:49:37 GMT")))
+ (test "RFC850 time"
+ (utc-time->seconds '#(37 49 08 06 10 94 0 310 #f 0))
+ (utc-time->seconds (header-value 'if-range headers))))
+ (let ((headers (test-read-headers "If-Range: Sun Nov 6 08:49:37 1994")))
+ (test "asctime time"
+ (utc-time->seconds '#(37 49 08 06 10 94 0 310 #f 0))
+ (utc-time->seconds (header-value 'if-range headers))))
+ (let ((headers (test-read-headers "If-Range: \"foo\"")))
+ (test "Strong Etag"
+ '(strong . "foo")
+ (header-value 'if-range headers)))
+ (let ((headers (test-read-headers "If-Range: W/\"bar\"")))
+ (test "Weak Etag"
+ '(weak . "bar")
+ (header-value 'if-range headers)))
+ (let ((headers (test-read-headers "If-Range: \"\"")))
+ (test "Empty Etag"
+ '(strong . "")
+ (header-value 'if-range headers)))
+ (let ((headers (test-read-headers "If-Range: \"W/bar\"")))
+ (test "Strong Etag, containing W/ prefix"
+ '(strong . "W/bar")
+ (header-value 'if-range headers))) )
+
+ (test-group "via parser"
+ (let ((headers (test-read-headers "Via: 1.1")))
+ (test "simple"
+ '("1.1")
+ (header-values 'via headers)))
+ (let ((headers (test-read-headers "Via: 1.1 foo:80 (comment)")))
+ (test "complex"
+ '("1.1 foo:80 (comment)")
+ (header-values 'via headers)))
+ (let ((headers (test-read-headers "Via: 1.1 foo")))
+ (test "one hop"
+ '("1.1 foo")
+ (header-values 'via headers)))
+ (let ((headers (test-read-headers "Via: 1.1 foo, 1.0 bar")))
+ (test "two hops"
+ '("1.1 foo" "1.0 bar")
+ (header-values 'via headers))))
+
+ (test-group "product parser"
+ (test "Simple product"
+ '("websocket" . #f)
+ (header-value 'upgrade (test-read-headers "Upgrade: websocket\r\n")))
+ (test "Product with version"
+ '("TLS" . "1.0")
+ (header-value 'upgrade (test-read-headers "Upgrade: TLS/1.0\r\n"))))
+
+ (test-group "software parser"
+ (test "Simple product"
+ '(("Mozilla" "5.0" #f))
+ (header-value 'user-agent (test-read-headers "User-Agent: Mozilla/5.0\r\n")))
+ (test "Product with comment"
+ '(("Mozilla" #f "foo"))
+ (header-value 'user-agent (test-read-headers "User-Agent: Mozilla (foo)\r\n")))
+ (test "Realistic product (comments, semicolons)"
+ '(("Mozilla" "5.0" "X11; U; NetBSD amd64; en-US; rv:1.9.0.3") ("Gecko" "2008110501" #f) ("Minefield" "3.0.3" #f))
+ (header-value 'user-agent (test-read-headers "User-Agent: Mozilla/5.0 (X11; U; NetBSD amd64; en-US; rv:1.9.0.3) Gecko/2008110501 Minefield/3.0.3\r\n")))
+ ;; Reported by Peter Danenberg; Google Drive returns this header
+ (test "Realistic product (quoted comment)"
+ '(("UploadServer" #f "Built on May 4 2015 17:31:43 (1430785903)"))
+ (header-value 'server (test-read-headers "Server: UploadServer (\"Built on May 4 2015 17:31:43 (1430785903)\")\r\n"))))
+
+ (test-group "Set-Cookie parser"
+ (let* ((headers (test-read-headers "Set-Cookie: foo=\"bar\"")))
+ (test "Simple name/value pair"
+ '("foo" . "bar")
+ (get-value (first (header-contents 'set-cookie headers)))))
+ (let* ((headers (test-read-headers "Set-Cookie: foo=qux\r\nSet-Cookie: Foo=\"bar\"")))
+ ;; XXX: Should intarweb remove these, or should the user code handle this?
+ ;; What if interacting with actual broken code on the other side?
+ (test "Multiple cookies with same name (CI) are all kept"
+ '(("foo" . "qux") ("Foo" . "bar"))
+ (map get-value (header-contents 'set-cookie headers))))
+ (let* ((headers (test-read-headers "Set-Cookie: Foo=bar")))
+ (test "Cookie names preserve case"
+ '("Foo" . "bar")
+ (get-value (first (header-contents 'set-cookie headers)))))
+ (let ((headers (test-read-headers "Set-Cookie: foo=bar=qux; max-age=10")))
+ (test "Cookie with = signs"
+ '("foo" . "bar=qux")
+ (get-value (first (header-contents 'set-cookie headers)))))
+ (let* ((headers (test-read-headers "Set-Cookie: foo=bar; Comment=\"Hi, there!\", qux=mooh\r\nSet-Cookie: mumble=mutter\r\n")))
+ (test "Comment"
+ "Hi, there!"
+ (get-param 'comment
+ (first (header-contents 'set-cookie headers))))
+ (test "Multiple cookies in one header"
+ '("qux" . "mooh")
+ (get-value (second (header-contents 'set-cookie headers))))
+ (test "Multiple cookies in multiple headers"
+ '("mumble" . "mutter")
+ (get-value (third (header-contents 'set-cookie headers))))
+ (test "Missing \"secure\" value"
+ #f
+ (get-param 'secure
+ (third (header-contents 'set-cookie headers)))))
+ (let* ((headers (test-read-headers "Set-Cookie: foo=; expires=Sunday, 20-Jul-08 15:23:42 GMT; secure; path = / ; Port=80,8080")))
+ (test "Missing value"
+ '("foo" . "")
+ (get-value (first (header-contents 'set-cookie headers))))
+ (test "Old-style cookie expires value"
+ (utc-time->seconds '#(42 23 15 20 6 108 0 309 #f 0))
+ (utc-time->seconds
+ (get-param 'expires
+ (first (header-contents 'set-cookie headers)))))
+ (test "Secure value"
+ #t
+ (get-param 'secure
+ (first (header-contents 'set-cookie headers))))
+ (test "Path"
+ (uri-reference "/")
+ (get-param 'path
+ (first (header-contents 'set-cookie headers))))
+ (test "Port numbers"
+ '(80 8080)
+ (get-param 'port
+ (first (header-contents 'set-cookie headers)))))
+ (let* ((headers (test-read-headers "Set-Cookie: foo=; expires=Sun, 20 Jul 2008 15:23:42 GMT; secure; path = / ")))
+ (test "Noncompliant syntax cookie expiry value (rfc1123)"
+ (utc-time->seconds '#(42 23 15 20 6 108 0 309 #f 0))
+ (utc-time->seconds
+ (get-param 'expires
+ (first (header-contents 'set-cookie headers))))))
+ (let* ((headers (test-read-headers "Set-Cookie: foo=; expires=Sun, 20-Jul-2008 15:23:42 GMT; secure; path = / ")))
+ (test "Noncompliant syntax cookie expiry value (rfc850-like, abbrev day)"
+ (utc-time->seconds '#(42 23 15 20 6 108 0 309 #f 0))
+ (utc-time->seconds
+ (get-param 'expires
+ (first (header-contents 'set-cookie headers)))))))
+
+ (test-group "cookie-parser"
+ (let* ((headers (test-read-headers "Cookie: Foo=bar; $Path=/; qux=mooh; $unknown=something")))
+ (test "Multiple cookies in the same header"
+ '(("Foo" . "bar") . ("qux" . "mooh"))
+ (cons
+ (get-value (first (header-contents 'cookie headers)))
+ (get-value (second (header-contents 'cookie headers)))))
+ (test "Parameters of cookies (spaces stripped)"
+ (uri-reference "/")
+ (get-param 'path (first (header-contents 'cookie headers))))
+ (test "Parameters of cookies"
+ "something"
+ (get-param 'unknown (second (header-contents 'cookie headers)))))
+ (let* ((headers (test-read-headers "Cookie: $Version=\"1\"; Foo=bar; $Path=/; qux=mooh; $unknown=something")))
+ (test "Version string is used for all cookies"
+ (cons 1 1)
+ (cons
+ (get-param 'version (first (header-contents 'cookie headers)))
+ (get-param 'version (second (header-contents 'cookie headers)))))))
+
+ (test-group "strict-transport-security-parser"
+ (let ((headers (test-read-headers "Strict-Transport-Security: max-age=10; includeSubDomains")))
+ (test "max-age is a number"
+ '(max-age . 10)
+ (assq 'max-age (header-value 'strict-transport-security headers)))
+ (test "includeSubDomains without value"
+ '(includesubdomains . #t)
+ (assq 'includesubdomains (header-value 'strict-transport-security headers)))))
+
+ (test-group "headers"
+ (test "Simple test"
+ `(bar qux)
+ (header-values 'foo (headers `((foo bar qux)))))
+ (test "Multi headers are folded"
+ `(bar qux)
+ (header-values 'foo (headers `((foo bar)
+ (foo qux)))))
+ (test "Single headers are unique"
+ `(qux)
+ (header-values 'foo (parameterize ((single-headers '(foo)))
+ (headers `((foo bar)
+ (foo qux))))))
+ (test "Extra single headers are ignored"
+ `(qux)
+ (header-values 'foo (parameterize ((single-headers '(foo)))
+ (headers `((foo bar qux))))))
+ (test "Parameters"
+ `((bar . qux))
+ (get-params
+ (car (header-contents 'foo (headers `((foo #(mooh ((bar . qux))))))))))
+ (test "Multi headers are folded into old headers"
+ `(bar qux)
+ (header-values 'foo (headers `((foo qux))
+ (headers `((foo bar))))))))
+
+(define (test-unparse-headers h)
+ (call-with-output-string
+ (lambda (o)
+ (unparse-headers (headers h) o))))
+
+(test-group "unparsers"
+ (test-group "default unparser"
+ (test "String"
+ "Foo: bar\r\n"
+ (test-unparse-headers `((foo "bar"))))
+ (test "Multiple strings"
+ "Foo: bar, qux\r\n"
+ (test-unparse-headers `((foo "bar" "qux"))))
+ (test "Auto-quoting on commas and whitespace"
+ "Foo: \"bar, qux\", \"mooh blah\"\r\n"
+ (test-unparse-headers `((foo "bar, qux" "mooh blah"))))
+ ;; RFC 2616 2.2
+ (test "Escaping quotes"
+ "Foo: \"bar \\\" qux\", mooh\r\n"
+ (test-unparse-headers `((foo "bar \" qux" "mooh"))))
+ (test "Escaping control characters"
+ "Foo: \"bar\\\r\\\x01qux\"\r\n"
+ (test-unparse-headers `((foo "bar\r\x01qux"))))
+ ;; Unfortunately, there are no or very few HTTP implementations
+ ;; which understand that newlines can be escaped with a backslash
+ ;; in a quoted string. That's why we don't allow it.
+ ;; The user is expected to escape the newlines according to the type
+ ;; of header (URLencoding, removing the newlines from cookies, etc)
+ (test-error* "Embedded newlines throw an error"
+ (exn http unencoded-header)
+ (test-unparse-headers `((foo "bar\n\x01qux"))))
+ (test "Alist"
+ "Foo: Bar=qux, Mooh=mumble\r\n"
+ (test-unparse-headers `((foo (bar . qux) (mooh . mumble)))))
+ (test "Alist with escapes"
+ "Foo: Bar=qux, Mooh=\"mum, ble\"\r\n"
+ (test-unparse-headers `((foo (bar . "qux") (mooh . "mum, ble")))))
+ (test "URI"
+ "Foo: http://foo.com/bar;xyz?a=b\r\n"
+ (test-unparse-headers `((foo ,(uri-reference "http://foo.com/bar;xyz?a=b")))))
+ (test "Parameters"
+ "Foo: bar; qux=mooh; mumble=mutter; blah\r\n"
+ (test-unparse-headers `((foo #(bar ((qux . mooh)
+ (mumble . mutter)
+ (blah . #t)
+ (feh . #f)))))))
+ (test "Raw headers are unparsed as-is"
+ "Foo: bla bla; whatever \"ohai\"\r\n"
+ (test-unparse-headers `((foo #("bla bla; whatever \"ohai\"" raw)))))
+ (test "Raw headers are unparsed as-is for known headers, too"
+ "Etag: \"hi there\r\n" ;; unclosed quote is intentional here
+ (test-unparse-headers `((etag #("\"hi there" raw)))))
+ (test-error* "Embedded newlines in raw headers also throw an error"
+ (exn http unencoded-header)
+ (test-unparse-headers `((foo #("bar\n\x01qux" raw))))))
+ (test-group "etag unparser"
+ (test "Weak tag"
+ "Etag: W/\"blah\"\r\n"
+ (test-unparse-headers `((etag (weak . "blah")))))
+ (test "Strong tag"
+ "Etag: \"blah\"\r\n"
+ (test-unparse-headers `((etag (strong . "blah")))))
+ (test "Strong tag starting with W/"
+ "Etag: \"W/blah\"\r\n"
+ (test-unparse-headers `((etag (strong . "W/blah"))))))
+ (test-group "if-match unparser"
+ (test "List of etags"
+ "If-Match: \"foo\", \"bar\", W/\"qux\"\r\n"
+ (test-unparse-headers
+ `((if-match (strong . "foo") (strong . "bar") (weak . "qux")))))
+ (test "Wildcard"
+ "If-Match: *\r\n"
+ (test-unparse-headers
+ `((if-match (strong . "foo") * (weak . "qux"))))))
+ ;; http-dates are all deserialized as rfc1123
+ (test-group "date/time unparser"
+ (test "RFC1123 time"
+ "If-Modified-Since: Sun, 06 Nov 1994 08:49:37 GMT\r\n"
+ ;; Having to specify a vector here twice is sucky and counter-intuitive
+ (test-unparse-headers
+ `((if-modified-since #(#(37 49 08 06 10 94 0 310 #f 0) ()))))))
+ (test-group "host/port unparser"
+ (test "No port specified"
+ "Host: foo.example.com\r\n"
+ (test-unparse-headers `((host ("foo.example.com" . #f)))))
+ (test "Different port"
+ "Host: foo.example.com:8080\r\n"
+ (test-unparse-headers `((host ("foo.example.com" . 8080))))))
+ (test-group "product unparser"
+ (test "Products without version"
+ "Upgrade: websocket, foo\r\n"
+ (test-unparse-headers `((upgrade ("websocket" . #f) ("foo" . #f)))))
+ (test "Products with version"
+ "Upgrade: TLS/1.0, bar/2\r\n"
+ (test-unparse-headers `((upgrade ("TLS" . "1.0") ("bar" . "2"))))))
+ (test-group "software unparser"
+ (test "Product with comments"
+ "User-Agent: Mozilla (X11) Gecko/2008110501\r\n"
+ (test-unparse-headers `((user-agent (("Mozilla" #f "X11") ("Gecko" "2008110501" #f))))))
+ (test "Realistic product"
+ "User-Agent: Mozilla/5.0 (X11; U; NetBSD amd64; en-US; rv:1.9.0.3) Gecko/2008110501 Minefield/3.0.3\r\n"
+ (test-unparse-headers `((user-agent (("Mozilla" "5.0" "X11; U; NetBSD amd64; en-US; rv:1.9.0.3") ("Gecko" "2008110501" #f) ("Minefield" "3.0.3" #f)))))))
+ (test-group "cookie unparser"
+ (test "Basic cookie"
+ "Cookie: foo=bar; $Path=/; Qux=mooh; $Unknown=something\r\n"
+ (test-unparse-headers `((cookie #(("foo" . "bar")
+ ((path . ,(uri-reference "/"))))
+ #(("Qux" . "mooh")
+ ((unknown . "something")))))))
+ (test "Port list"
+ "Cookie: Foo=bar; $Port=80,8080\r\n"
+ (test-unparse-headers `((cookie #(("Foo" . "bar")
+ ((port . (80 8080))))))))
+ (test "#t or #f values"
+ "Cookie: Foo=bar; $Port\r\n"
+ (test-unparse-headers `((cookie #(("Foo" . "bar")
+ ((port . #t) (domain . #f))))))))
+ (test-group "Set-Cookie unparser"
+ (test "Simple name/value pair"
+ "Set-Cookie: foo=\"bar with space\"\r\n"
+ (test-unparse-headers `((set-cookie ("foo" . "bar with space")))))
+ ;; XXX: Should intarweb remove these, or should the user code handle this?
+ ;; What if interacting with actual broken code on the other side?
+ (test "Multiple cookies with same name (CI) are all written"
+ "Set-Cookie: foo=qux\r\nSet-Cookie: Foo=bar\r\n"
+ (test-unparse-headers `((set-cookie ("foo" . "qux") ("Foo" . "bar")))))
+ (test "Cookie names preserve case"
+ "Set-Cookie: Foo=bar\r\n"
+ (test-unparse-headers `((set-cookie ("Foo" . "bar")))))
+ (test "Cookie with = signs"
+ "Set-Cookie: foo=\"bar=qux\"; Max-Age=10\r\n"
+ (test-unparse-headers `((set-cookie #(("foo" . "bar=qux") ((max-age . 10)))))))
+ (test "Comment"
+ "Set-Cookie: foo=bar; Comment=\"Hi, there!\"\r\n"
+ (test-unparse-headers `((set-cookie #(("foo" . "bar")
+ ((comment . "Hi, there!")))))))
+ (test "Old-style cookie expires value"
+ "Set-Cookie: foo=; Expires=Sunday, 20-Jul-08 15:23:42 GMT\r\n"
+ (test-unparse-headers `((set-cookie #(("foo" . "")
+ ((expires . #(42 23 15 20 6 108 0 309 #f 0))))))))
+ (test "Secure (true)"
+ "Set-Cookie: foo=bar; Secure\r\n"
+ (test-unparse-headers `((set-cookie #(("foo" . "bar")
+ ((secure . #t)))))))
+ (test "Secure (false)"
+ "Set-Cookie: foo=bar\r\n"
+ (test-unparse-headers `((set-cookie #(("foo" . "bar")
+ ((secure . #f)))))))
+
+ (test "Path"
+ "Set-Cookie: foo=bar; Path=/blah\r\n"
+ (test-unparse-headers `((set-cookie #(("foo" . "bar")
+ ((path . ,(uri-reference "/blah"))
+ (secure . #f))))))))
+ (test-group "authorization unparser"
+ (test "Basic auth"
+ "Authorization: Basic QWxpIEJhYmE6b3BlbiBzZXNhbWU=\r\n"
+ (test-unparse-headers
+ `((authorization #(basic
+ ((username . "Ali Baba")
+ (password . "open sesame")))))))
+ (test-error* "Basic auth with colon in username"
+ (exn http username-with-colon)
+ (test-unparse-headers
+ `((authorization #(basic
+ ((username . "foo:bar")
+ (password . "qux")))))))
+ (test "Digest auth"
+ "Authorization: Digest username=\"Mufasa\", realm=\"testrealm@host.com\", nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\", uri=\"/dir/index.html\", qop=\"auth\", cnonce=\"0a4f113b\", response=\"6629fae49393a05397450978507c4ef1\", opaque=\"5ccc069c403ebaf9f0171e9517f40e41\", nc=00000001, algorithm=\"md5\"\r\n"
+ (test-unparse-headers
+ `((authorization #(digest
+ ((username . "Mufasa")
+ (realm . "testrealm@host.com")
+ (nonce . "dcd98b7102dd2f0e8b11d0f600bfb0c093")
+ (uri . ,(uri-reference "/dir/index.html"))
+ (qop . auth)
+ (cnonce . "0a4f113b")
+ (response . "6629fae49393a05397450978507c4ef1")
+ (opaque . "5ccc069c403ebaf9f0171e9517f40e41")
+ (nc . 1)
+ (algorithm . md5)))))))
+ (test "Custom auth"
+ "Authorization: Custom some-random-contents\r\n"
+ (parameterize ((authorization-param-subunparsers
+ `((custom . ,(lambda (params)
+ (alist-ref 'contents params)))
+ . ,(authorization-param-subparsers))))
+ (test-unparse-headers
+ `((authorization #(custom ((contents . some-random-contents)))))))))
+
+ (test-group "authenticate unparser"
+ (test-group "basic auth"
+ (test "basic"
+ "Www-Authenticate: Basic realm=\"WallyWorld\"\r\n"
+ (test-unparse-headers
+ `((www-authenticate #(basic
+ ((realm . "WallyWorld"))))))))
+ (test-group "digest auth"
+ (test "digest"
+ "Www-Authenticate: Digest realm=\"testrealm@host.com\", qop=\"auth,auth-int\", nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\", opaque=\"5ccc069c403ebaf9f0171e9517f40e41\"\r\n"
+ (test-unparse-headers
+ `((www-authenticate #(digest
+ ((realm . "testrealm@host.com")
+ (qop . (auth auth-int))
+ (nonce . "dcd98b7102dd2f0e8b11d0f600bfb0c093")
+ (opaque . "5ccc069c403ebaf9f0171e9517f40e41")))))))
+ (test "domains"
+ "Www-Authenticate: Digest domain=\"/example http://foo.com/bar\"\r\n"
+ (test-unparse-headers
+ `((www-authenticate #(digest
+ ((domain . (,(uri-reference "/example")
+ ,(uri-reference "http://foo.com/bar")))))))))
+ (test "stale"
+ "Www-Authenticate: Digest realm=\"foo\", stale=TRUE\r\n"
+ (test-unparse-headers
+ `((www-authenticate #(digest
+ ((realm . "foo")
+ (stale . #t)))))))
+ (test "stale present but false"
+ "Www-Authenticate: Digest realm=\"foo\"\r\n"
+ (test-unparse-headers
+ `((www-authenticate #(digest
+ ((realm . "foo")
+ (stale . #f)))))))))
+ (test-group "content-disposition unparser"
+ (test "Attributes are always fully quoted and filenames stripped"
+ "Content-Disposition: form-data; name=\"foo\"; filename=\"a b c\"\r\n"
+ (test-unparse-headers `((content-disposition
+ #(form-data ((name . foo)
+ (filename . "blabla/a b c")))))))
+ (test "Size and dates are recognised correctly"
+ "Content-Disposition: inline; size=20; creation-date=\"Sun, 06 Nov 1994 08:49:37 GMT\"\r\n"
+ (test-unparse-headers `((content-disposition
+ #(inline ((size . 20)
+ (creation-date . #(37 49 08 06 10 94 0 310 #f 0)))))))))
+
+ (test-group "strict-transport-security unparser"
+ (test "Silly capitalization is honored, even if unneccessary"
+ "Strict-Transport-Security: max-age=10; includeSubDomains\r\n"
+ (test-unparse-headers `((strict-transport-security
+ ((max-age . 10)
+ (includesubdomains . #t))))))))
+
+(define (test-read-request str)
+ (call-with-input-string str read-request))
+
+(test-group "reading of requests"
+ (parameterize ((request-parsers `(,(lambda (line in)
+ (and (string=? line "foo") 'foo))
+ ,(lambda (line in)
+ (and (string=? line "bar") 'bar)))))
+ (test-error* (exn http unknown-protocol-line) (test-read-request "qux"))
+ (test #f (test-read-request ""))
+ (test 'foo (test-read-request "foo"))
+ (test 'bar (test-read-request "bar")))
+ ;; Even though we officially "should" support HTTP/0.9, we disable it
+ ;; by default because there are security implications of just outputting
+ ;; responses for random resources that might be under attacker control.
+ (test-group "HTTP/0.9"
+ (test-error* "By default, HTTP/0.9 is disabled"
+ (exn http unknown-protocol-line)
+ (test-read-request "GET /path/../to/stuff?arg1=val1&arg2=val2\r\n"))
+ (parameterize ((request-parsers (list http-1.x-request-parser http-0.9-request-parser)))
+ (let ((req (test-read-request "GET /path/../to/stuff?arg1=val1&arg2=val2\r\n")))
+ (test 0 (request-major req))
+ (test 9 (request-minor req))
+ (test 'GET (request-method req))
+ ;; Path-normalized URI (dots removed)
+ (test (uri-reference "/to/stuff?arg1=val1&arg2=val2") (request-uri req))
+ (test (headers '()) (request-headers req)))
+ ;; RFC 1945 5.0 does not mention case-sensitivity for the method in HTTP/0.9.
+ ;; It only mentions it in the context of HTTP/1.x (section 5.1.1).
+ ;; We obey the BNF syntax rule in 2.1:
+ ;; "literal" - Quotation marks surround literal text.
+ ;; Unless stated otherwise, the text is case-insensitive.
+ ;; Section 4.1 defines:
+ ;; Simple-Request = "GET" SP Request-URI CRLF
+ (test "Method is case-insensitive" 'GET (request-method (test-read-request "geT /path\r\n")))
+ (test-error "0.9 only knows GET" (test-read-request "PUT /path"))))
+ (test-group "HTTP/1.0"
+ (test-error "Asterisk is not allowed for HTTP/1.0"
+ (request-uri (test-read-request "OPTIONS * HTTP/1.0\r\n")))
+ (let ((req (test-read-request "GET /path/to/stuff?arg1=val1&arg2=val2 HTTP/1.0\r\n\r\n")))
+ (test 1 (request-major req))
+ (test 0 (request-minor req))
+ (test 'GET (request-method req))
+ (test (uri-reference "/path/to/stuff?arg1=val1&arg2=val2") (request-uri req))
+ (test (headers '()) (request-headers req)))
+ (test 'PUT (request-method (test-read-request "PUT /path HTTP/1.0\r\n")))
+ (let ((req (test-read-request "POST / HTTP/1.0\r\nTransfer-Encoding: chunked\r\n\r\n3\r\nfoo\r\na\r\n1234567890\r\n")))
+ (test "Chunking ignored"
+ "3\r\nfoo\r\na\r\n1234567890\r\n"
+ (read-string #f (request-port req)))))
+ (test-group "HTTP/1.1" ; No need to test all things we test for 1.0
+ (test "Asterisk is treated specially and returns #f uri"
+ #f (request-uri (test-read-request "OPTIONS * HTTP/1.1\r\n")))
+ (let ((req (test-read-request "GET /path/to/stuff?arg1=val1&arg2=val2 HTTP/1.1\r\n\r\n")))
+ (test 1 (request-major req))
+ (test 1 (request-minor req)))
+ (test 'PUT (request-method (test-read-request "PUT /path HTTP/1.1\r\n\r\n")))
+ ; RFC 2616 5.1.1
+ (test "Method is case-sensitive" 'geT (request-method (test-read-request "geT /path HTTP/1.1\r\n\r\n")))
+ ; RFC 2616 3.1 + case-insensitivity BNF rule
+ (test "Protocol is case-insensitive" '1 (request-minor (test-read-request "GET /path htTP/1.1\r\n\r\n")))
+ ;; TODO: Test chunking
+ (test-error "Request line limit exceeded gives error"
+ (parameterize ((http-line-limit 5))
+ (test-read-request "GET /path HTTP/1.1\r\n\r\n")))
+ (test "Reading request body"
+ '((abc . "def") (ghi . "jkl"))
+ (let ((req (test-read-request
+ "GET / HTTP/1.1\r\nContent-Length: 15\r\n\r\nabc=def;ghi=jkl")))
+ (read-urlencoded-request-data req)))
+ (test "Reading request body with bigger limit"
+ '((abc . "def"))
+ (let ((req (test-read-request
+ "GET / HTTP/1.1\r\nContent-Length: 7\r\n\r\nabc=def")))
+ ;; Test for 8, since 7 would error
+ (parameterize ((http-urlencoded-request-data-limit 8))
+ (read-urlencoded-request-data req))))
+ (test-error "Request body limit exceeded gives error"
+ (let ((req (test-read-request
+ "GET / HTTP/1.1\r\nContent-Length: 7\r\n\r\nabc=def")))
+ ;; This errors when the limit is hit, not when it is exceeded
+ (parameterize ((http-urlencoded-request-data-limit 7))
+ (read-urlencoded-request-data req))))
+ (let ((req (test-read-request "POST / HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n3\r\nfoo\r\na\r\n1234567890\r\n0\r\n\r\n")))
+ (test "Chunking"
+ "foo1234567890"
+ (read-string #f (request-port req)))))
+ (test-group "Invalid protocols"
+ (test-error "Total cruft is unrecognised"
+ (test-read-request "whatever\r\n"))
+ (test-error "Invalid URI also causes protocol not to be recognised"
+ (test-read-request "GET //path HTTP/1.0\r\n"))))
+
+(define (test-write-request req . outputs)
+ (call-with-output-string
+ (lambda (out)
+ (request-port-set! req out)
+ (let ((r (write-request req)))
+ (for-each (lambda (output)
+ (display output (request-port r)))
+ outputs)
+ (finish-request-body r)))))
+
+(test-group "writing of requests"
+ ;; This can also be called Simple-Request as per RFC 1945 4.1
+ ;; RFC 2616 19.6 also states we should recognise 0.9 requests, but if
+ ;; we understand those we should also be able to generate them because
+ ;; a 0.9 server does not understand 1.x requests.
+ (test-group "HTTP/0.9"
+ (let ((req (make-request major: 0 minor: 9 method: 'GET
+ uri: (uri-reference "/foo/bar.html"))))
+ (test-error* "By default, HTTP/0.9 is disabled"
+ (exn http unknown-protocol)
+ (test-write-request req))
+ (parameterize ((request-unparsers (list http-1.x-request-unparser
+ http-1.0-request-unparser
+ http-0.9-request-unparser)))
+ (test "Always empty headers"
+ "GET /foo/bar.html\r\n"
+ (test-write-request (update-request req
+ headers:
+ (headers `((foo bar))))))
+ (test "Always GET"
+ "GET /foo/bar.html\r\n"
+ (test-write-request (update-request req method: 'POST))))))
+ (test-group "HTTP/1.0"
+ (let ((req (make-request major: 1 minor: 0
+ method: 'GET
+ uri: (uri-reference "/foo/bar.html"))))
+ (test "Headers"
+ "GET /foo/bar.html HTTP/1.0\r\nFoo: bar\r\n\r\ntest"
+ (test-write-request
+ (update-request req
+ headers: (headers `((foo bar))))
+ "test"))
+ (test "Chunking ignored"
+ "GET /foo/bar.html HTTP/1.0\r\nTransfer-Encoding: chunked\r\n\r\nfoobar"
+ (test-write-request
+ (update-request req
+ headers: (headers `((transfer-encoding chunked))))
+ "foo" "" "bar"))))
+ (test-group "HTTP/1.1"
+ (let ((req (make-request major: 1 minor: 1
+ method: 'GET
+ uri: (uri-reference "/foo/bar.html"))))
+ (test "Headers"
+ "GET /foo/bar.html HTTP/1.1\r\nFoo: bar\r\n\r\ntest"
+ (test-write-request
+ (update-request req
+ headers: (headers `((foo bar))))
+ "test"))
+ (test "Chunking"
+ "GET /foo/bar.html HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n3\r\nfoo\r\na\r\n1234567890\r\n0\r\n\r\n"
+ (test-write-request
+ (update-request req
+ headers: (headers `((transfer-encoding chunked))))
+ "foo" "" "1234567890"))
+ (test "OPTIONS-type asterisk if no URI"
+ "OPTIONS * HTTP/1.1\r\n\r\n"
+ (test-write-request
+ (update-request req method: 'OPTIONS uri: #f))))))
+
+(define (test-read-response input-string)
+ (call-with-input-string input-string read-response))
+
+(test-group "reading of responses"
+ (test-group "HTTP/1.1"
+ (let ((res (test-read-response "HTTP/1.1 303 See other\r\nFoo: bar\r\n\r\nContents")))
+ (test "Version detection"
+ '(1 . 1)
+ (cons (response-major res) (response-minor res)))
+ (test "Status"
+ '(see-other 303 "See other")
+ (list (response-status res) (response-code res) (response-reason res)))
+ (test "Headers"
+ '("bar")
+ (header-values 'foo (response-headers res)))
+ (test "Contents"
+ "Contents"
+ (read-string #f (response-port res))))
+ (test-error* (exn http unknown-protocol-line) (test-read-response "qux"))
+ (test #f (test-read-request ""))
+ (test-error "Response line limit exceeded gives error"
+ (parameterize ((http-line-limit 5))
+ (test-read-response "HTTP/1.1 200 OK\r\n\r\n")))
+ (let ((res (test-read-response "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n3\r\nfoo\r\na\r\n1234567890\r\n0\r\n\r\n")))
+ (test "Chunking"
+ "foo1234567890"
+ (read-string #f (response-port res))))
+ ;; Reported by "sz0ka" via IRC
+ (let ((res (test-read-response "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nfoo\r\n\r\n0\r\n\r\n")))
+ (test "First read of chunked port returns first line"
+ "foo"
+ (read-line (response-port res)))
+ (test "Peek-char returns EOF"
+ #!eof
+ (peek-char (response-port res)))
+ (test "Read-char also returns EOF"
+ #!eof
+ (read-char (response-port res)))))
+ (test-group "HTTP/1.0"
+ (let ((res (test-read-response "HTTP/1.0 303 See other\r\nFoo: bar\r\n\r\nContents")))
+ (test "Version detection"
+ '(1 . 0)
+ (cons (response-major res) (response-minor res)))
+ (test "Status"
+ '(303 . "See other")
+ (cons (response-code res) (response-reason res)))
+ (test "Headers"
+ '("bar")
+ (header-values 'foo (response-headers res)))
+ (test "Contents"
+ "Contents"
+ (read-string #f (response-port res))))
+ (let ((res (test-read-response "HTTP/1.0 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n3\r\nfoo\r\na\r\n1234567890\r\n")))
+ (test "Chunking ignored"
+ "3\r\nfoo\r\na\r\n1234567890\r\n"
+ (read-string #f (response-port res)))))
+ (test-group "HTTP/0.9"
+ (test-error* "By default, HTTP/0.9 is disabled"
+ (exn http unknown-protocol-line)
+ (test-read-response "Doesn't matter what's here\r\nLine 2"))
+ (parameterize ((response-parsers (list http-1.x-response-parser
+ http-1.0-response-parser
+ http-0.9-response-parser)))
+ (let ((res (test-read-response "Doesn't matter what's here\r\nLine 2")))
+ (test "Always OK status"
+ '(200 . "OK")
+ (cons (response-code res) (response-reason res)))
+ (test "Version detection; fallback to 0.9"
+ '(0 . 9)
+ (cons (response-major res) (response-minor res)))
+ (test "No headers"
+ (headers '()) (response-headers res))
+ (test "Contents"
+ "Doesn't matter what's here\r\nLine 2"
+ (read-string #f (response-port res)))))))
+
+(define (test-write-response res . outputs)
+ (call-with-output-string
+ (lambda (out)
+ (response-port-set! res out)
+ (let ((r (write-response res)))
+ (for-each (lambda (output)
+ (display output (response-port r)))
+ outputs)
+ (finish-response-body r)))))
+
+(test-group "writing of responses"
+ (test-group "HTTP/0.9"
+ (let ((res (make-response major: 0 minor: 9
+ code: 200 reason: "OK")))
+ (test-error* "By default, HTTP/0.9 is disabled"
+ (exn http unknown-protocol)
+ (test-write-response res "These are the contents\r\n"))
+ (parameterize ((response-unparsers (list http-1.x-response-unparser
+ http-1.0-response-unparser
+ http-0.9-response-unparser)))
+ (test "Headers ignored"
+ "These are the contents\r\n"
+ (test-write-response
+ (update-response res headers: (headers `((foo bar))))
+ "These are the contents\r\n")))))
+ (test-group "HTTP/1.0"
+ (let ((res (make-response major: 1 minor: 0
+ code: 200 reason: "OK")))
+ (test "Headers used"
+ "HTTP/1.0 200 OK\r\nFoo: bar\r\n\r\nThese are the contents\r\n"
+ (test-write-response
+ (update-response res headers: (headers `((foo bar))))
+ "These are the contents\r\n"))
+ (test "Status code"
+ "HTTP/1.0 303 See other\r\n\r\nThese are the contents\r\n"
+ (test-write-response
+ (update-response res code: 303 reason: "See other")
+ "These are the contents\r\n"))
+ (test "Chunking ignored"
+ "HTTP/1.0 200 OK\r\nTransfer-Encoding: chunked\r\n\r\nfoo1234567890"
+ (test-write-response
+ (update-response
+ res
+ headers: (headers `((transfer-encoding chunked))))
+ "foo" "1234567890"))))
+ (test-group "HTTP/1.1"
+ (let ((res (make-response major: 1 minor: 1
+ code: 200 reason: "OK")))
+ (test "Headers used"
+ "HTTP/1.1 200 OK\r\nFoo: bar\r\n\r\nThese are the contents\r\n"
+ (test-write-response
+ (update-response res headers: (headers `((foo bar))))
+ "These are the contents\r\n"))
+ (test "Status code"
+ "HTTP/1.1 303 See other\r\n\r\nThese are the contents\r\n"
+ (test-write-response
+ (update-response res code: 303 reason: "See other")
+ "These are the contents\r\n"))
+ (test "Chunking"
+ "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n3\r\nfoo\r\na\r\n1234567890\r\n0\r\n\r\n"
+ (test-write-response
+ (update-response
+ res
+ headers: (headers `((transfer-encoding chunked))))
+ "foo" "1234567890"))))
+ (test-group "status"
+ (let ((res (make-response major: 1 minor: 1)))
+ (test "reason and code are looked up by symbol properly"
+ "HTTP/1.1 409 Conflict\r\n\r\ntest"
+ (test-write-response (update-response res status: 'conflict)
+ "test"))
+ (test-error "an error is raised for unknown status codes"
+ (update-response res status: 'unknown))
+ (test "any status can be used when code and reason are given directly"
+ "HTTP/1.1 999 No Way\r\n\r\ntest"
+ (test-write-response
+ (update-response res code: 999 reason: "No Way")
+ "test"))
+ (test "defaults can be parameterized"
+ "HTTP/1.1 999 Say What\r\n\r\ntest"
+ (parameterize ((http-status-codes
+ (alist-cons 'say-what (cons 999 "Say What")
+ (http-status-codes))))
+ (test-write-response (update-response res status: 'say-what)
+ "test"))))))
+
+(test-group "etag comparison procedures"
+ (test-group "weak comparison"
+ (test-assert "Strong etag does not match list not containing it"
+ (not (etag-matches-weakly?
+ '(strong . "xyz") `((strong . "blabla")))))
+ (test-assert "Weak etag does not match list not containing it"
+ (not (etag-matches-weakly?
+ '(weak . "xyz") `((weak . "blabla")))))
+ (test-assert "Weak etag matches list containing it"
+ (etag-matches-weakly?
+ '(weak . "xyz") `((strong . "blabla") (weak . "xyz"))))
+ (test-assert "Strong etag matches list containing it"
+ (etag-matches-weakly?
+ '(strong . "xyz") `((strong . "blabla") (strong . "xyz"))))
+ (test-assert "Weak etag does not match list containing same tag but strong"
+ (not (etag-matches-weakly?
+ '(weak . "xyz") `((strong . "blabla") (strong . "xyz")))))
+ (test-assert "Strong etag does not match list containing same tag but weak"
+ (not (etag-matches-weakly?
+ '(strong . "xyz") `((strong . "blabla") (weak . "xyz")))))
+ (test-assert "Weak etag matches list containing wildcard"
+ (etag-matches-weakly?
+ '(weak . "xyz") `((strong . "blabla") *)))
+ (test-assert "Strong etag matches list containing wildcard"
+ (etag-matches-weakly?
+ '(strong . "xyz") `((strong . "blabla") *))))
+ (test-group "strong comparison"
+ (test-assert "Strong etag does not match list not containing it"
+ (not (etag-matches?
+ '(strong . "xyz") `((strong . "blabla")))))
+ (test-assert "Weak etag does not match list not containing it"
+ (not (etag-matches?
+ '(weak . "xyz") `((weak . "blabla")))))
+ (test-assert "Weak etag does *not* match list containing it"
+ (not (etag-matches?
+ '(weak . "xyz") `((strong . "blabla") (weak . "xyz")))))
+ (test-assert "Strong etag matches list containing it"
+ (etag-matches?
+ '(strong . "xyz") `((strong . "blabla") (strong . "xyz"))))
+ (test-assert "Weak etag does not match list containing same tag but strong"
+ (not (etag-matches?
+ '(weak . "xyz") `((strong . "blabla") (strong . "xyz")))))
+ (test-assert "Strong etag does not match list containing same tag but weak"
+ (not (etag-matches?
+ '(strong . "xyz") `((strong . "blabla") (weak . "xyz")))))
+ (test-assert "Weak etag matches list containing wildcard"
+ (etag-matches?
+ '(weak . "xyz") `((strong . "blabla") *)))
+ (test-assert "Strong etag matches list containing wildcard"
+ (etag-matches?
+ '(strong . "xyz") `((strong . "blabla") *)))))
+
+
+;; We don't expose chunked-output-port/chunked-input-port. Maybe we should?
+;; To work around this, prepend some stuff and parse some headers
+(define (chunked-inport string)
+ (let ((res (test-read-response
+ (string-append
+ "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n"
+ string))))
+ (response-port res)))
+
+(test-group "Chunked ports"
+ (let ((s "5\r\nab\ncd\r\n2\r\n\nx\r\n0\r\nDO NOT WANT"))
+ (test "read-lines" '("ab" "cd" "x") (read-lines (chunked-inport s)))
+ (let ((p (chunked-inport s)))
+ (test "read-char" #\a (read-char p))
+ (test "peek-char" #\b (peek-char p))
+ (test "partial read" "b\n" (read-string 2 p))
+ (test "short read" "c" (read-string 1 p))
+ (test "read across chunk boundaries" "d\nx" (read-string 3 p))
+ (test "read at eof" #!eof (read-string 1 p)))
+ (test "read beyond chunked port size"
+ "ab\ncd\nx" (read-string 10 (chunked-inport s)))))
+
+(test-end)
+
+(unless (zero? (test-failure-count)) (exit 1))
+
+;; TODO:
+;; - Fix the parsing system so it's not so broken (more comfortable combinators)
+;; - Test malformed headers
+;; - Add parsing capability for quoted-pairs inside tokens and comments
+;; - Rethink the auto-chunking stuff. Maybe this should be done at a higher level