From 65462a642c466e6812bdd5aabadad02364060dae Mon Sep 17 00:00:00 2001 From: "carpentry-heartbeat[bot]" Date: Sat, 20 Jun 2026 21:01:09 +0200 Subject: [PATCH] Add Form.encode, form-escape, and Form.parse-pairs Form module only had parse (decode) but no encode. This adds: - form-escape: private helper that is the inverse of form-unescape, encoding spaces as + and percent-encoding other special characters - Form.encode: encodes a (Map String String) as a URL-encoded form body, the inverse of Form.parse - Form.parse-pairs: parses into (Array (Pair String String)) preserving order and duplicate keys, unlike the Map-based parse --- http.carp | 42 ++++++++++++++++- test/http.carp | 126 ++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 165 insertions(+), 3 deletions(-) diff --git a/http.carp b/http.carp index 026c0c2..5e94002 100644 --- a/http.carp +++ b/http.carp @@ -552,7 +552,7 @@ Returns `(Maybe String)`.") @"Service Unavailable" @"Unknown"))) -(doc Form "provides URL-encoded form body parsing. +(doc Form "provides URL-encoded form body parsing and serialization. ``` (match (Form.parse \"name=carp&version=1\") @@ -580,7 +580,45 @@ per the `application/x-www-form-urlencoded` specification.") v (form-unescape &(String.byte-slice pair (Int.inc eq) (String.length pair)))] (Map.put! &m &k &v))))) - (Result.Success m)))) + (Result.Success m))) + + (private form-escape) + (hidden form-escape) + (defn form-escape [s] + (let-do [parts &(String.split-by s &[\space]) + escaped []] + (for [i 0 (Array.length parts)] + (Array.push-back! &escaped (URI.escape (Array.unsafe-nth parts i)))) + (String.join "+" &escaped))) + + (doc encode "encodes a `(Map String String)` as a URL-encoded form body. +Spaces become `+` and other special characters are percent-encoded. +This is the inverse of `parse`.") + (defn encode [m] + (Map.kv-reduce + &(fn [acc k v] + (let [pair (String.concat &[(form-escape k) @"=" (form-escape v)])] + (if (String.empty? &acc) pair (String.concat &[acc @"&" pair])))) + @"" + m)) + + (doc parse-pairs "parses a URL-encoded form body into an +`(Array (Pair String String))`. Unlike `parse`, this preserves the order +of pairs and allows duplicate keys. Keys and values are percent-decoded. +The `+` character is decoded as space.") + (defn parse-pairs [s] + (let-do [result (the (Array (Pair String String)) []) + parts &(String.split-by s &[\&])] + (for [i 0 (Array.length parts)] + (let [part (Array.unsafe-nth parts i) + eq (String.index-of part \=)] + (if (= eq -1) + (Array.push-back! &result (Pair.init (form-unescape part) @"")) + (let [k (form-unescape &(String.byte-slice part 0 eq)) + v (form-unescape + &(String.byte-slice part (Int.inc eq) (String.length part)))] + (Array.push-back! &result (Pair.init k v)))))) + (Result.Success result)))) (defmodule Request (doc form-data "parses the request body as URL-encoded form data. diff --git a/test/http.carp b/test/http.carp index 2c1784e..58b8bd9 100644 --- a/test/http.carp +++ b/test/http.carp @@ -356,4 +356,128 @@ (Request.form-data &(Request.get (URI.zero) [] {} @""))) (Result.Success m) (= 0 (Map.length &m)) _ false) - "form-data on empty body returns empty map")) + "form-data on empty body returns empty map") + + ; --------------------------------------------------------------------------- + ; Form.encode + ; --------------------------------------------------------------------------- + + (assert-equal test + "name=carp" + &(let-do [m (the (Map String String) {})] + (Map.put! &m "name" "carp") + (Form.encode &m)) + "encode single pair") + + (assert-equal test + "msg=hello+world" + &(let-do [m (the (Map String String) {})] + (Map.put! &m "msg" "hello world") + (Form.encode &m)) + "encode space as +") + + (assert-equal test + "x=%26" + &(let-do [m (the (Map String String) {})] + (Map.put! &m "x" "&") + (Form.encode &m)) + "encode & as %26") + + (assert-equal test + "k=a%2bb" + &(let-do [m (the (Map String String) {})] + (Map.put! &m "k" "a+b") + (Form.encode &m)) + "encode literal + as %2b") + + (assert-equal test + "key+name=val" + &(let-do [m (the (Map String String) {})] + (Map.put! &m "key name" "val") + (Form.encode &m)) + "encode space in key as +") + + ; roundtrip: parse(encode(m)) == m + (assert-true test + (let-do [m (the (Map String String) {})] + (Map.put! &m "a" "1") + (match (the (Result (Map String String) String) + (Form.parse &(Form.encode &m))) + (Result.Success parsed) (= &parsed &m) + _ false)) + "encode/parse roundtrip single pair") + + ; --------------------------------------------------------------------------- + ; Form.parse-pairs + ; --------------------------------------------------------------------------- + + (assert-equal test + 2 + (match (the + (Result (Array (Pair String String)) String) + (Form.parse-pairs "name=carp&version=1")) + (Result.Success ps) (Array.length &ps) + _ 0) + "parse-pairs returns correct count") + + (assert-true test + (match (the + (Result (Array (Pair String String)) String) + (Form.parse-pairs "name=carp&version=1")) + (Result.Success ps) + (and + (= (Pair.a (Array.unsafe-nth &ps 0)) "name") + (= (Pair.b (Array.unsafe-nth &ps 0)) "carp")) + _ false) + "parse-pairs first pair is correct") + + (assert-true test + (match (the + (Result (Array (Pair String String)) String) + (Form.parse-pairs "name=carp&version=1")) + (Result.Success ps) + (and + (= (Pair.a (Array.unsafe-nth &ps 1)) "version") + (= (Pair.b (Array.unsafe-nth &ps 1)) "1")) + _ false) + "parse-pairs second pair is correct") + + ; preserves duplicate keys + (assert-equal test + 3 + (match (the + (Result (Array (Pair String String)) String) + (Form.parse-pairs "x=1&x=2&x=3")) + (Result.Success ps) (Array.length &ps) + _ 0) + "parse-pairs preserves duplicate keys") + + ; decodes + as space + (assert-true test + (match (the + (Result (Array (Pair String String)) String) + (Form.parse-pairs "msg=hello+world")) + (Result.Success ps) (= (Pair.b (Array.unsafe-nth &ps 0)) "hello world") + _ false) + "parse-pairs decodes + as space") + + ; handles key without value + (assert-true test + (match (the + (Result (Array (Pair String String)) String) + (Form.parse-pairs "flag")) + (Result.Success ps) + (and + (= (Pair.a (Array.unsafe-nth &ps 0)) "flag") + (= (Pair.b (Array.unsafe-nth &ps 0)) "")) + _ false) + "parse-pairs handles key without value") + + ; decodes percent-encoded values + (assert-true test + (match (the + (Result (Array (Pair String String)) String) + (Form.parse-pairs "x=%26%3D")) + (Result.Success ps) (= (Pair.b (Array.unsafe-nth &ps 0)) "&=") + _ false) + "parse-pairs decodes percent-encoded values"))