Repository: planety/prologue
Pull request #259: Content-Type header parser
Author: login: nnsee
Created: 2025-02-28T09:20:40Z | Merged: 2025-03-10T11:52:01Z

This fixes #257.

Added a spec-compliant (as far as I can tell) header parser for the Content-Type header, specifically in regards to the `boundary` param. Also added some tests for the new parser and `form.nim`.

Linked issue #257: multipart/form-data broken on new firefox version
Multipart forms seem to be broken in new firefox versions, no idea why. It works in chromium, qtwebkit and firefox 128 ESR but it seems like something changed in a new version. I looked at the difference between the form format for between firefox versions but they seem the same. Here's a minimal example:
```nim
import prologue

const page = """<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
    <form action="/upload" method="post" enctype="multipart/form-data">
        <input type="file" name="image">
        <button type="submit">Upload</button>
    </form>
</body>
</html>"""

proc form(ctx: Context) {.async.} =
    resp htmlResponse(page)
    
proc upload(ctx: Context) {.async.} =
    try:
        let f = ctx.request.formParams["image"]
        if f.body.len == 0:
            resp "<h1>File upload is empty.</h1>"
        else:
            resp "<h1>Success, filename: " & f.params["filename"] & "</h1>"
    except KeyError:
        resp "<h1>Error, param 'image' not found.</h1>"

var app = newApp()
app.addRoute("/", form)
app.addRoute("/upload", upload, HttpPost)
app.run()
```
Will always respond with the error on firefox 135
Linked issue comments: user: login: nnsee
created_at: 2025-02-27T13:53:18Z
body: Firefox changed their form boundary format (for whatever reason). Prologue doesn't like this new format.

Old format: `---------------------------323898160039929939653173633681`

New format: `----geckoformboundary7e078691432d9e8fc24d10b0f2bc9151`

Interestingly, it seems that Prologue specifically doesn't like the string `boundary` in the boundary. If you remove or modify that, the request starts working again. I'll need to do some more code review to figure out why.
author_association: CONTRIBUTOR
user: login: nnsee
created_at: 2025-02-27T13:54:45Z
body: Pretty sure the issue is this:

https://github.com/planety/prologue/blob/c3da4452732077652a6ebabb8b7365a65f300fbf/src/prologue/core/form.nim#L27
author_association: CONTRIBUTOR
user: login: bobbbob98
created_at: 2025-02-27T23:38:57Z
body: Seems like the only change needs to be to use find instead of rfind so it doesn't search from the end of the string, the example works with that.
author_association: NONE
user: login: nnsee
created_at: 2025-02-28T06:40:25Z
body: Either that, or scan for `boundary=`, or a mixture of both. Pretty sure
it's still out of spec though, because as far as I know, the boundary
string in the header can either be quoted or left unquoted. Need to look at
the spec.

author_association: CONTRIBUTOR
user: login: nnsee
created_at: 2025-02-28T08:08:15Z
body: From [RFC 2046 section 5.1.1](https://datatracker.ietf.org/doc/html/rfc2046#section-5.1.1):
```
   WARNING TO IMPLEMENTORS:  The grammar for parameters on the Content-
   type field is such that it is often necessary to enclose the boundary
   parameter values in quotes on the Content-type line.  This is not
   always necessary, but never hurts. Implementors should be sure to
   study the grammar carefully in order to avoid producing invalid
   Content-type fields.  Thus, a typical "multipart" Content-Type header
   field might look like this:

     Content-Type: multipart/mixed; boundary=gc0p4Jq0M2Yt08j34c0p

   But the following is not valid:

     Content-Type: multipart/mixed; boundary=gc0pJq0M:08jU534c0p

   (because of the colon) and must instead be represented as

     Content-Type: multipart/mixed; boundary="gc0pJq0M:08jU534c0p"
```

I'm pretty sure the current implementation always assumes that the boundary parameter is _not_ quoted. This works:
```sh
curl 'http://localhost:8080/upload' \
  -H 'Content-Type: multipart/form-data; boundary=abcdef' \
  --data-binary $'--abcdef\r\nContent-Disposition: form-data; name="image"; filename="test.nim"\r\nContent-Type: text/x-nim\r\n\r\naaaa\r\n--abcdef--\r\n'
```
```html
<h1>Success, filename: test.nim</h1>
```

... but this doesn't:
```sh
curl 'http://localhost:8080/upload' \
  -H 'Content-Type: multipart/form-data; boundary="abcdef"' \
  --data-binary $'--abcdef\r\nContent-Disposition: form-data; name="image"; filename="test.nim"\r\nContent-Type: text/x-nim\r\n\r\naaaa\r\n--abcdef--\r\n'
```
```html
<h1>Error, param 'image' not found.</h1>
```
author_association: CONTRIBUTOR
user: login: nnsee
created_at: 2025-02-28T08:13:19Z
body: Form parsing seems a bit fragile in general. This crashes the server (malformed boundary ending):
```sh
curl 'http://localhost:8080/upload' \
  -H 'Content-Type: multipart/form-data; boundary=abcdef' \
  --data-binary $'--abcdef\r\nContent-Disposition: form-data; name="image"; filename="test.nim"\r\nContent-Type: text/x-nim\r\n\r\naaaa\r\n-\r\n'
```

```
Traceback (most recent call last)
/home/xx/.choosenim/toolchains/nim-2.0.2/lib/system.nim(568) eventLoop
/home/xx/.nimble/pkgs2/httpx-0.3.7-6e0fc3133fbd20530d53ab2792e93d3151387b10/httpx.nim(478) processEvents
/home/xx/.nimble/pkgs2/prologue-0.6.6-433777a19c0bf630753006f0646c5a6dbbfd4b74/prologue/core/application.nim(536) handler
/home/xx/.nimble/pkgs2/prologue-0.6.6-433777a19c0bf630753006f0646c5a6dbbfd4b74/prologue/core/application.nim(496) handleRequest
/home/xx/.choosenim/toolchains/nim-2.0.2/lib/pure/httpcore.nim(449) handleNativeRequest
/home/xx/.nimble/pkgs2/prologue-0.6.6-433777a19c0bf630753006f0646c5a6dbbfd4b74/prologue/core/form.nim(100) parseFormParams
/home/xx/.choosenim/toolchains/nim-2.0.2/lib/system.nim(370) parseFormPart
SIGSEGV: Illegal storage access. (Attempt to read from nil?)
```
author_association: CONTRIBUTOR

Diff:
diff --git a/src/prologue/core/contenttype.nim b/src/prologue/core/contenttype.nim
new file mode 100644
index 00000000..422c6c63
--- /dev/null
+++ b/src/prologue/core/contenttype.nim
@@ -0,0 +1,118 @@
+import std/[strutils, tables, strformat, sequtils]
+
+type
+  MediaType* = object
+    mainType*: string
+    subType*: string
+    parameters*: Table[string, string]
+
+const
+  # Token characters as per RFC 7230 section 3.2.6
+  # https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.6
+  VALID_TOKEN_CHARACTERS = {'a'..'z', 'A'..'Z', '0'..'9',
+    '!', '#', '$', '%', '&', '\'', '*', '+', '-', '.', '^', '_', '`', '|', '~'}
+
+proc skipWhitespace(headerValue: string, i: var int) =
+  ## Skips the next whitespace characters beginning from index ``i``.
+  ## This updates the param ``i``
+  while i < headerValue.len and headerValue[i] in Whitespace:
+    inc i
+
+proc parseContentType*(headerValue: string): MediaType =
+  ## Parses a Content-Type header according to RFC 7230, RFC 2045, and RFC 2046.
+  ## Returns a MediaType object containing the main type, sub type, and parameters.
+  runnableExamples:
+    let mediaType = parseContentType("text/plain; charset=\"utf-8\"")
+    doAssert mediaType.mainType == "text"
+    doAssert mediaType.subType == "plain"
+    doAssert mediaType.parameters.len == 1
+    doAssert mediaType.parameters["charset"] == "utf-8"
+
+  result = MediaType(parameters: initTable[string, string]())
+  var
+    i = 0
+    headerLen = headerValue.len
+
+  headerValue.skipWhitespace(i)
+
+  # media type
+  let mediaTypeStart = i
+  while i < headerLen and headerValue[i] notin {';', ' ', '\t'}:
+    inc i
+
+  let mediaType = headerValue[mediaTypeStart..<i].strip()
+  let typeParts = mediaType.split('/')
+
+  if typeParts.len != 2:
+    raise newException(ValueError, &"Invalid media type: {mediaType}")
+
+  result.mainType = typeParts[0].toLowerAscii
+  result.subType = typeParts[1].toLowerAscii
+
+  headerValue.skipWhitespace(i)
+
+  # params
+  while i < headerLen and headerValue[i] == ';':
+    inc i
+
+    headerValue.skipWhitespace(i)
+
+    # param name
+    let paramNameStart = i
+    while i < headerLen and headerValue[i] notin {'=', ';', ' ', '\t'}:
+      inc i
+
+    if i >= headerLen or headerValue[i] != '=':
+      # this is a malformed parameter - skip it
+      while i < headerLen and headerValue[i] != ';':
+        inc i
+      continue
+
+    let paramName = headerValue[paramNameStart..<i].strip().toLowerAscii()
+    inc i
+
+    headerValue.skipWhitespace(i)
+
+    # param value
+    var
+      paramValue: string
+      foundClosingQuote = false
+
+    if i < headerLen and headerValue[i] == '"':
+      # quoted value
+      inc i
+      let valueStart = i
+
+      while i < headerLen:
+        if headerValue[i] == '\\' and i + 1 < headerLen and headerValue[i + 1] == '"':
+          inc i, 2
+        elif headerValue[i] == '"':
+          paramValue = headerValue[valueStart..<i]
+          inc i
+          foundClosingQuote = true
+          break
+        else:
+          inc i
+
+      if not foundClosingQuote:
+        paramValue = headerValue[valueStart..<headerLen]
+    else:
+      # unquoted value
+      let valueStart = i
+      while i < headerLen and headerValue[i] notin {';', ' ', '\t'}:
+        inc i
+
+      paramValue = headerValue[valueStart..<i]
+
+    result.parameters[paramName] = paramValue
+    headerValue.skipWhitespace(i)
+
+proc `$`*(mediaType: MediaType): string =
+  ## Convert MediaType to string representation
+  result = mediaType.mainType & "/" & mediaType.subType
+
+  for name, value in mediaType.parameters:
+    if value.anyIt(it notin VALID_TOKEN_CHARACTERS):
+      result.add(&"; {name}=\"{value}\"")
+    else:
+      result.add(&"; {name}={value}")
diff --git a/src/prologue/core/form.nim b/src/prologue/core/form.nim
index f5770adb..792b0942 100644
--- a/src/prologue/core/form.nim
+++ b/src/prologue/core/form.nim
@@ -12,23 +12,30 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-
 import std/[strtabs, strutils, strformat, parseutils, tables]
 from std/uri import decodeQuery
 
 import ./httpcore/httplogue
 from ./types import FormPart, initFormPart, `[]=`
 import ./request
+import ./contenttype
 
 
 func parseFormPart*(body, contentType: string): FormPart =
   ## Parses form part of the body of the request.
   let
-    sep = contentType[contentType.rfind("boundary") + 9 .. ^1]
+    mediaType = parseContentType(contentType)
+    sep = if "boundary" in mediaType.parameters: mediaType.parameters["boundary"] else: ""
     startSep = fmt"--{sep}"
     endSep = fmt"--{sep}--"
     startPos = find(body, startSep)
     endPos = rfind(body, endSep)
+
+  # make sure we found valid boundaries
+  if startPos < 0 or endPos < 0 or startPos >= endPos:
+    return initFormPart()
+
+  let
     formData = body[startPos ..< endPos]
     formDataSeq = formData.split(startSep & "\c\L")
 
@@ -89,18 +96,19 @@ func parseFormPart*(body, contentType: string): FormPart =
 
 func parseFormParams*(request: var Request, contentType: string) =
   ## Parses get or post or query parameters.
-  if "form-urlencoded" in contentType:
+  let mediaType = parseContentType(contentType)
+
+  if mediaType.mainType == "application" and mediaType.subType == "x-www-form-urlencoded":
     request.formParams = initFormPart()
     if request.reqMethod == HttpPost:
       for (key, value) in decodeQuery(request.body):
         # formPrams and postParams for secret event
         request.formParams[key] = value
         request.postParams[key] = value
-  elif "multipart/form-data" in contentType and "boundary" in contentType:
+  elif mediaType.mainType == "multipart" and mediaType.subType == "form-data" and "boundary" in mediaType.parameters:
     request.formParams = parseFormPart(request.body, contentType)
 
   # /student?name=simon&age=sixteen
   # query -> name=simon&age=sixteen
-
   for (key, value) in decodeQuery(request.query):
     request.queryParams[key] = value
diff --git a/tests/unit/tunit_core/tunit_contenttype.nim b/tests/unit/tunit_core/tunit_contenttype.nim
new file mode 100644
index 00000000..74fa7d28
--- /dev/null
+++ b/tests/unit/tunit_core/tunit_contenttype.nim
@@ -0,0 +1,164 @@
+import ../../../src/prologue/core/contenttype
+import std/[tables, strutils, strformat]
+
+block:
+  let mediaType = parseContentType("text/plain")
+  doAssert mediaType.mainType == "text"
+  doAssert mediaType.subType == "plain"
+  doAssert mediaType.parameters.len == 0
+
+block:
+  let mediaType = parseContentType("text/plain; charset=utf-8")
+  doAssert mediaType.mainType == "text"
+  doAssert mediaType.subType == "plain"
+  doAssert mediaType.parameters.len == 1
+  doAssert "charset" in mediaType.parameters
+  doAssert mediaType.parameters["charset"] == "utf-8"
+
+block:
+  let mediaType = parseContentType("text/plain; charset=utf-8; format=flowed")
+  doAssert mediaType.mainType == "text"
+  doAssert mediaType.subType == "plain"
+  doAssert mediaType.parameters.len == 2
+  doAssert "charset" in mediaType.parameters
+  doAssert "format" in mediaType.parameters
+  doAssert mediaType.parameters["charset"] == "utf-8"
+  doAssert mediaType.parameters["format"] == "flowed"
+
+block:
+  let mediaType = parseContentType("text/plain; charset=\"utf-8\"")
+  doAssert mediaType.mainType == "text"
+  doAssert mediaType.subType == "plain"
+  doAssert mediaType.parameters.len == 1
+  doAssert "charset" in mediaType.parameters
+  doAssert mediaType.parameters["charset"] == "utf-8"
+
+block:
+  let mediaType = parseContentType("application/json; charset=\"utf 8\"")
+  doAssert mediaType.mainType == "application"
+  doAssert mediaType.subType == "json"
+  doAssert mediaType.parameters.len == 1
+  doAssert "charset" in mediaType.parameters
+  doAssert mediaType.parameters["charset"] == "utf 8"
+
+block:
+  let mediaType = parseContentType("multipart/form-data; boundary=---------------------------263701891623491983764541468")
+  doAssert mediaType.mainType == "multipart"
+  doAssert mediaType.subType == "form-data"
+  doAssert mediaType.parameters.len == 1
+  doAssert "boundary" in mediaType.parameters
+  doAssert mediaType.parameters["boundary"] == "---------------------------263701891623491983764541468"
+
+block:
+  let mediaType = parseContentType("multipart/form-data; boundary=\"simple-boundary\"")
+  doAssert mediaType.mainType == "multipart"
+  doAssert mediaType.subType == "form-data"
+  doAssert mediaType.parameters.len == 1
+  doAssert "boundary" in mediaType.parameters
+  doAssert mediaType.parameters["boundary"] == "simple-boundary"
+
+block:
+  let mediaType = parseContentType("multipart/form-data; boundary=\"boundary with spaces and-dashes\"")
+  doAssert mediaType.mainType == "multipart"
+  doAssert mediaType.subType == "form-data"
+  doAssert mediaType.parameters.len == 1
+  doAssert "boundary" in mediaType.parameters
+  doAssert mediaType.parameters["boundary"] == "boundary with spaces and-dashes"
+
+block:
+  let mediaType = parseContentType("text/plain; description=\"This is a \\\"quoted\\\" description\"")
+  doAssert mediaType.mainType == "text"
+  doAssert mediaType.subType == "plain"
+  doAssert mediaType.parameters.len == 1
+  doAssert "description" in mediaType.parameters
+  doAssert mediaType.parameters["description"] == "This is a \\\"quoted\\\" description"
+
+block:
+  let mediaType = parseContentType("TeXt/PlAiN; ChArSeT=UTF-8")
+  doAssert mediaType.mainType == "text"
+  doAssert mediaType.subType == "plain"
+  doAssert mediaType.parameters.len == 1
+  doAssert "charset" in mediaType.parameters
+  doAssert mediaType.parameters["charset"] == "UTF-8"
+
+block:
+  let mediaType = parseContentType("  text/plain  ;  charset=utf-8  ;  format=flowed  ")
+  doAssert mediaType.mainType == "text"
+  doAssert mediaType.subType == "plain"
+  doAssert mediaType.parameters.len == 2
+  doAssert "charset" in mediaType.parameters
+  doAssert "format" in mediaType.parameters
+  doAssert mediaType.parameters["charset"] == "utf-8"
+  doAssert mediaType.parameters["format"] == "flowed"
+
+block:
+  let mediaType = parseContentType("text/plain; charset")
+  doAssert mediaType.mainType == "text"
+  doAssert mediaType.subType == "plain"
+  doAssert mediaType.parameters.len == 0
+
+block:
+  try:
+    discard parseContentType("text")
+    doAssert false, "Should have raised an exception"
+  except ValueError:
+    doAssert true
+
+block:
+  let mediaType = parseContentType("text/plain; charset=utf-8; format=flowed")
+  let str = $mediaType
+  doAssert str.startsWith("text/plain")
+  doAssert "; charset=utf-8" in str
+  doAssert "; format=flowed" in str
+
+block:
+  var mediaType = MediaType(
+    mainType: "multipart",
+    subType: "form-data",
+    parameters: {"boundary": "simple boundary with spaces"}.toTable
+  )
+  let str = $mediaType
+  doAssert str == "multipart/form-data; boundary=\"simple boundary with spaces\""
+
+block:
+  var mediaType = MediaType(
+    mainType: "text",
+    subType: "plain",
+    parameters: {
+      "charset": "utf-8",
+      "description": "This is a description with spaces"
+    }.toTable
+  )
+  let str = $mediaType
+  doAssert str.startsWith("text/plain")
+  doAssert "; charset=utf-8" in str
+  doAssert "; description=\"This is a description with spaces\"" in str
+
+# some real-world examples
+block:
+  let examples = [
+    "text/html; charset=UTF-8",
+    "application/json",
+    "application/x-www-form-urlencoded",
+    "multipart/form-data; boundary=something",
+    "image/jpeg",
+    "application/octet-stream",
+    "text/css; charset=utf-8",
+    "application/javascript",
+    "multipart/mixed; boundary=\"frontier\"",
+    "application/pdf",
+    "text/plain; charset=us-ascii"
+  ]
+
+  for example in examples:
+    let mediaType = parseContentType(example)
+    let roundTrip = $mediaType
+
+    let roundTripMediaType = parseContentType(roundTrip)
+    doAssert roundTripMediaType.mainType == mediaType.mainType
+    doAssert roundTripMediaType.subType == mediaType.subType
+    doAssert roundTripMediaType.parameters.len == mediaType.parameters.len
+
+    for key, value in mediaType.parameters:
+      doAssert key in roundTripMediaType.parameters
+      doAssert roundTripMediaType.parameters[key] == value
diff --git a/tests/unit/tunit_core/tunit_form.nim b/tests/unit/tunit_core/tunit_form.nim
index 3696b8c0..718eb0fd 100644
--- a/tests/unit/tunit_core/tunit_form.nim
+++ b/tests/unit/tunit_core/tunit_form.nim
@@ -1,24 +1,42 @@
 import ../../../src/prologue/core/form
 import tables, strutils
-const testmime =
-  "-----------------------------263701891623491983764541468\13\10" &
-  "Content-Disposition: form-data; name=\"howLongValid\"\13\10" &
-  "\13\10" &
-  "3600\13\10" &
-  "-----------------------------263701891623491983764541468\13\10" &
-  "Content-Disposition: form-data; name=\"upload\"; filename=\"testfile.txt\"\13\10" &
-  "Content-Type: text/plain\13\10" &
-  "\13\10" &
-  "1234\13\10" &
-  "5678\13\10" &
-  "abcd\13\10" &
-  "-----------------------------263701891623491983764541468--\13\10"
-const testfile =
-  "1234\13\10" &
-  "5678\13\10" &
-  "abcd"
-const contenttype = "multipart/form-data; boundary=---------------------------263701891623491983764541468"
-let formPart = parseFormPart(testmime, contenttype)
-doAssert formPart.data["upload"].body.len == testfile.len
-doAssert formPart.data["upload"].body == testfile
-doAssert parseInt(formPart.data["howLongValid"].body) == 3600
\ No newline at end of file
+
+block:
+  const testmime =
+    "-----------------------------263701891623491983764541468\13\10" &
+    "Content-Disposition: form-data; name=\"howLongValid\"\13\10" &
+    "\13\10" &
+    "3600\13\10" &
+    "-----------------------------263701891623491983764541468\13\10" &
+    "Content-Disposition: form-data; name=\"upload\"; filename=\"testfile.txt\"\13\10" &
+    "Content-Type: text/plain\13\10" &
+    "\13\10" &
+    "1234\13\10" &
+    "5678\13\10" &
+    "abcd\13\10" &
+    "-----------------------------263701891623491983764541468--\13\10"
+  const testfile =
+    "1234\13\10" &
+    "5678\13\10" &
+    "abcd"
+  const contenttype = "multipart/form-data; boundary=---------------------------263701891623491983764541468"
+  let formPart = parseFormPart(testmime, contenttype)
+  doAssert formPart.data["upload"].body.len == testfile.len
+  doAssert formPart.data["upload"].body == testfile
+  doAssert parseInt(formPart.data["howLongValid"].body) == 3600
+
+block:
+  # check that quoted boundary values work
+  const testfile =
+    "data"
+  const testmime =
+    "--boundary\13\10" &
+    "Content-Disposition: form-data; name=\"upload\"\13\10" &
+    "\13\10" &
+    testfile &
+    "\13\10" &
+    "--boundary--\13\10"
+  const contenttype = "multipart/form-data; boundary=\"boundary\""
+  let formPart = parseFormPart(testmime, contenttype)
+  doAssert formPart.data["upload"].body.len == testfile.len
+  doAssert formPart.data["upload"].body == testfile


Reviews:
user: login: PhilippMDoerner
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-07T16:40:34Z
user: login: PhilippMDoerner
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-07T16:50:12Z
user: login: ringabout
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-08T14:21:58Z
user: login: ringabout
body: Nice work!
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-08T14:22:32Z
user: login: PhilippMDoerner
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-09T12:48:59Z
user: login: PhilippMDoerner
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-09T12:49:37Z
user: login: PhilippMDoerner
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-09T12:52:04Z
user: login: PhilippMDoerner
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-09T12:56:34Z
user: login: PhilippMDoerner
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-09T13:03:13Z
user: login: PhilippMDoerner
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-09T13:05:27Z
user: login: nnsee
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-10T08:41:38Z
user: login: PhilippMDoerner
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-10T08:43:04Z

Review comments:
diff_hunk: @@ -0,0 +1,113 @@
+import std/[strutils, tables, strformat]
+
+type
+  MediaType* = object
+    mainType*: string
+    subType*: string
+    parameters*: Table[string, string]
+
+proc parseContentType*(headerValue: string): MediaType =
+  ## Parses a Content-Type header according to RFC 7230, RFC 2045, and RFC 2046.
+  ## Returns a MediaType object containing the main type, sub type, and parameters.
+  result = MediaType(parameters: initTable[string, string]())
+  var
+    i = 0
+    headerLen = headerValue.len
+
+  while i < headerLen and headerValue[i] in Whitespace:
+    inc i
+
+  # media type
+  let mediaTypeStart = i
+  while i < headerLen and headerValue[i] notin {';', ' ', '\t'}:
+    inc i
+
+  let mediaType = headerValue[mediaTypeStart..<i].strip()
+  let typeParts = mediaType.split('/')
+
+  if typeParts.len != 2:
+    raise newException(ValueError, &"Invalid media type: {mediaType}")
+
+  result.mainType = typeParts[0].toLowerAscii
+  result.subType = typeParts[1].toLowerAscii
+
+  while i < headerLen and headerValue[i] in Whitespace:
+    inc i
+
+  # params
+  while i < headerLen and headerValue[i] == ';':
+    inc i
+
+    while i < headerLen and headerValue[i] in Whitespace:
+      inc i
+
+    # param name
+    let paramNameStart = i
+    while i < headerLen and headerValue[i] notin {'=', ';', ' ', '\t'}:
+      inc i
+
+    if i >= headerLen or headerValue[i] != '=':
+      # this is a malformed parameter - skip it
+      while i < headerLen and headerValue[i] != ';':
+        inc i
+      continue
+
+    let paramName = headerValue[paramNameStart..<i].strip().toLowerAscii()
+    inc i
+
+    while i < headerLen and headerValue[i] in Whitespace:
+      inc i
+
+    # param value
+    var
+      paramValue: string
+      foundClosingQuote = false
+
+    if i < headerLen and headerValue[i] == '"':
+      # quoted value
+      inc i
+      let valueStart = i
+
+      while i < headerLen:
+        if headerValue[i] == '\\' and i + 1 < headerLen and headerValue[i + 1] == '"':
+          inc i, 2
+        elif headerValue[i] == '"':
+          paramValue = headerValue[valueStart..<i]
+          inc i
+          foundClosingQuote = true
+          break
+        else:
+          inc i
+
+      if not foundClosingQuote:
+        paramValue = headerValue[valueStart..<headerLen]
+    else:
+      # unquoted value
+      let valueStart = i
+      while i < headerLen and headerValue[i] notin {';', ' ', '\t'}:
+        inc i
+
+      paramValue = headerValue[valueStart..<i]
+
+    result.parameters[paramName] = paramValue
+
+    while i < headerLen and headerValue[i] in Whitespace:
+      inc i
+
+proc `$`*(mediaType: MediaType): string =
+  ## Convert MediaType to string representation
+  result = mediaType.mainType & "/" & mediaType.subType
+
+  for name, value in mediaType.parameters:
+    var needsQuotes = false
+    for c in value:
+      # valid token characters as per RFC 7230 section 3.2.6
+      if not (c in {'a'..'z', 'A'..'Z', '0'..'9',
path: src/prologue/core/contenttype.nim
user: login: PhilippMDoerner
body: Generally I'd recommend having a const with that set (could be in global scope in this file as it does not necessarily need to be anywhere else) so if doesn't need to be generated all the time and can have a speaking name.
For example:
```nim
 # Token Characters as per RFC 7230 section 3.2.6 (link maybe?)
const VALID_TOKEN_CHARACTERS = ... the set ...
```
created_at: 2025-03-07T16:40:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,113 @@
+import std/[strutils, tables, strformat]
+
+type
+  MediaType* = object
+    mainType*: string
+    subType*: string
+    parameters*: Table[string, string]
+
+proc parseContentType*(headerValue: string): MediaType =
+  ## Parses a Content-Type header according to RFC 7230, RFC 2045, and RFC 2046.
+  ## Returns a MediaType object containing the main type, sub type, and parameters.
+  result = MediaType(parameters: initTable[string, string]())
+  var
+    i = 0
+    headerLen = headerValue.len
+
+  while i < headerLen and headerValue[i] in Whitespace:
+    inc i
+
+  # media type
+  let mediaTypeStart = i
+  while i < headerLen and headerValue[i] notin {';', ' ', '\t'}:
path: src/prologue/core/contenttype.nim
user: login: PhilippMDoerner
body: Would it be correct to put this in a variable with a speaking name, i.e.:
`const MEDIA_TYPE_TERMINATORS = {';', ' ', '\t'}`
?


created_at: 2025-03-07T16:50:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,113 @@
+import std/[strutils, tables, strformat]
+
+type
+  MediaType* = object
+    mainType*: string
+    subType*: string
+    parameters*: Table[string, string]
+
+proc parseContentType*(headerValue: string): MediaType =
+  ## Parses a Content-Type header according to RFC 7230, RFC 2045, and RFC 2046.
+  ## Returns a MediaType object containing the main type, sub type, and parameters.
+  result = MediaType(parameters: initTable[string, string]())
+  var
+    i = 0
+    headerLen = headerValue.len
+
+  while i < headerLen and headerValue[i] in Whitespace:
+    inc i
+
+  # media type
+  let mediaTypeStart = i
+  while i < headerLen and headerValue[i] notin {';', ' ', '\t'}:
+    inc i
+
+  let mediaType = headerValue[mediaTypeStart..<i].strip()
+  let typeParts = mediaType.split('/')
+
+  if typeParts.len != 2:
+    raise newException(ValueError, &"Invalid media type: {mediaType}")
+
+  result.mainType = typeParts[0].toLowerAscii
+  result.subType = typeParts[1].toLowerAscii
+
+  while i < headerLen and headerValue[i] in Whitespace:
+    inc i
+
+  # params
+  while i < headerLen and headerValue[i] == ';':
+    inc i
+
+    while i < headerLen and headerValue[i] in Whitespace:
+      inc i
+
+    # param name
+    let paramNameStart = i
+    while i < headerLen and headerValue[i] notin {'=', ';', ' ', '\t'}:
+      inc i
+
+    if i >= headerLen or headerValue[i] != '=':
+      # this is a malformed parameter - skip it
+      while i < headerLen and headerValue[i] != ';':
+        inc i
+      continue
+
+    let paramName = headerValue[paramNameStart..<i].strip().toLowerAscii()
+    inc i
+
+    while i < headerLen and headerValue[i] in Whitespace:
+      inc i
+
+    # param value
+    var
+      paramValue: string
+      foundClosingQuote = false
+
+    if i < headerLen and headerValue[i] == '"':
+      # quoted value
+      inc i
+      let valueStart = i
+
+      while i < headerLen:
+        if headerValue[i] == '\\' and i + 1 < headerLen and headerValue[i + 1] == '"':
+          inc i, 2
+        elif headerValue[i] == '"':
+          paramValue = headerValue[valueStart..<i]
+          inc i
+          foundClosingQuote = true
+          break
+        else:
+          inc i
+
+      if not foundClosingQuote:
+        paramValue = headerValue[valueStart..<headerLen]
+    else:
+      # unquoted value
+      let valueStart = i
+      while i < headerLen and headerValue[i] notin {';', ' ', '\t'}:
+        inc i
+
+      paramValue = headerValue[valueStart..<i]
+
+    result.parameters[paramName] = paramValue
+
+    while i < headerLen and headerValue[i] in Whitespace:
+      inc i
+
+proc `$`*(mediaType: MediaType): string =
+  ## Convert MediaType to string representation
+  result = mediaType.mainType & "/" & mediaType.subType
+
+  for name, value in mediaType.parameters:
+    var needsQuotes = false
+    for c in value:
+      # valid token characters as per RFC 7230 section 3.2.6
+      if not (c in {'a'..'z', 'A'..'Z', '0'..'9',
+                    '!', '#', '$', '%', '&', '\'', '*', '+', '-', '.', '^', '_', '`', '|', '~'}):
path: src/prologue/core/contenttype.nim
user: login: ringabout
body: ```suggestion
      if c notin {'a'..'z', 'A'..'Z', '0'..'9',
                    '!', '#', '$', '%', '&', '\'', '*', '+', '-', '.', '^', '_', '`', '|', '~'}:
```
created_at: 2025-03-08T14:21:58Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,113 @@
+import std/[strutils, tables, strformat]
+
+type
+  MediaType* = object
+    mainType*: string
+    subType*: string
+    parameters*: Table[string, string]
+
+proc parseContentType*(headerValue: string): MediaType =
+  ## Parses a Content-Type header according to RFC 7230, RFC 2045, and RFC 2046.
path: src/prologue/core/contenttype.nim
user: login: PhilippMDoerner
body: Could be nice to add an example string with parameters just to give the reader an idea of what kind of string is being parsed without having to read the RFCs
created_at: 2025-03-09T12:48:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,113 @@
+import std/[strutils, tables, strformat]
+
+type
+  MediaType* = object
+    mainType*: string
+    subType*: string
+    parameters*: Table[string, string]
+
+proc parseContentType*(headerValue: string): MediaType =
+  ## Parses a Content-Type header according to RFC 7230, RFC 2045, and RFC 2046.
+  ## Returns a MediaType object containing the main type, sub type, and parameters.
+  result = MediaType(parameters: initTable[string, string]())
+  var
+    i = 0
+    headerLen = headerValue.len
+
+  while i < headerLen and headerValue[i] in Whitespace:
+    inc i
+
+  # media type
+  let mediaTypeStart = i
+  while i < headerLen and headerValue[i] notin {';', ' ', '\t'}:
path: src/prologue/core/contenttype.nim
user: login: PhilippMDoerner
body: That wouldn't necessarily improve that much in terms of readability so imo... meh
created_at: 2025-03-09T12:49:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,113 @@
+import std/[strutils, tables, strformat]
+
+type
+  MediaType* = object
+    mainType*: string
+    subType*: string
+    parameters*: Table[string, string]
+
+proc parseContentType*(headerValue: string): MediaType =
+  ## Parses a Content-Type header according to RFC 7230, RFC 2045, and RFC 2046.
+  ## Returns a MediaType object containing the main type, sub type, and parameters.
+  result = MediaType(parameters: initTable[string, string]())
+  var
+    i = 0
+    headerLen = headerValue.len
+
+  while i < headerLen and headerValue[i] in Whitespace:
+    inc i
+
+  # media type
+  let mediaTypeStart = i
+  while i < headerLen and headerValue[i] notin {';', ' ', '\t'}:
+    inc i
+
+  let mediaType = headerValue[mediaTypeStart..<i].strip()
+  let typeParts = mediaType.split('/')
+
+  if typeParts.len != 2:
+    raise newException(ValueError, &"Invalid media type: {mediaType}")
+
+  result.mainType = typeParts[0].toLowerAscii
+  result.subType = typeParts[1].toLowerAscii
+
+  while i < headerLen and headerValue[i] in Whitespace:
+    inc i
+
+  # params
+  while i < headerLen and headerValue[i] == ';':
+    inc i
+
+    while i < headerLen and headerValue[i] in Whitespace:
+      inc i
+
+    # param name
+    let paramNameStart = i
+    while i < headerLen and headerValue[i] notin {'=', ';', ' ', '\t'}:
+      inc i
+
+    if i >= headerLen or headerValue[i] != '=':
+      # this is a malformed parameter - skip it
+      while i < headerLen and headerValue[i] != ';':
+        inc i
+      continue
+
+    let paramName = headerValue[paramNameStart..<i].strip().toLowerAscii()
+    inc i
+
+    while i < headerLen and headerValue[i] in Whitespace:
path: src/prologue/core/contenttype.nim
user: login: PhilippMDoerner
body: Given how often this occurs you *could* outsource this into a proc:
```nim
proc skipNextWhitespacesIn(i: var int, headerValue: string) =
  while i < headerValue.len and headerValue[i] in Whitespace:
    inc i

#example usage:
i.skipNextWhitespacesIn(headerValue)
 ```

I'm not sure myself how much that improves readability so I'll leave it to you to decide if that actually improves readability.
created_at: 2025-03-09T12:52:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,113 @@
+import std/[strutils, tables, strformat]
+
+type
+  MediaType* = object
+    mainType*: string
+    subType*: string
+    parameters*: Table[string, string]
+
+proc parseContentType*(headerValue: string): MediaType =
+  ## Parses a Content-Type header according to RFC 7230, RFC 2045, and RFC 2046.
+  ## Returns a MediaType object containing the main type, sub type, and parameters.
+  result = MediaType(parameters: initTable[string, string]())
+  var
+    i = 0
+    headerLen = headerValue.len
+
+  while i < headerLen and headerValue[i] in Whitespace:
+    inc i
+
+  # media type
+  let mediaTypeStart = i
+  while i < headerLen and headerValue[i] notin {';', ' ', '\t'}:
+    inc i
+
+  let mediaType = headerValue[mediaTypeStart..<i].strip()
+  let typeParts = mediaType.split('/')
+
+  if typeParts.len != 2:
+    raise newException(ValueError, &"Invalid media type: {mediaType}")
+
+  result.mainType = typeParts[0].toLowerAscii
+  result.subType = typeParts[1].toLowerAscii
+
+  while i < headerLen and headerValue[i] in Whitespace:
+    inc i
+
+  # params
+  while i < headerLen and headerValue[i] == ';':
+    inc i
+
+    while i < headerLen and headerValue[i] in Whitespace:
+      inc i
+
+    # param name
+    let paramNameStart = i
+    while i < headerLen and headerValue[i] notin {'=', ';', ' ', '\t'}:
+      inc i
+
+    if i >= headerLen or headerValue[i] != '=':
+      # this is a malformed parameter - skip it
+      while i < headerLen and headerValue[i] != ';':
+        inc i
+      continue
+
+    let paramName = headerValue[paramNameStart..<i].strip().toLowerAscii()
+    inc i
+
+    while i < headerLen and headerValue[i] in Whitespace:
+      inc i
+
+    # param value
+    var
+      paramValue: string
+      foundClosingQuote = false
+
+    if i < headerLen and headerValue[i] == '"':
+      # quoted value
+      inc i
+      let valueStart = i
+
+      while i < headerLen:
+        if headerValue[i] == '\\' and i + 1 < headerLen and headerValue[i + 1] == '"':
+          inc i, 2
+        elif headerValue[i] == '"':
+          paramValue = headerValue[valueStart..<i]
+          inc i
+          foundClosingQuote = true
+          break
+        else:
+          inc i
+
+      if not foundClosingQuote:
+        paramValue = headerValue[valueStart..<headerLen]
+    else:
+      # unquoted value
+      let valueStart = i
+      while i < headerLen and headerValue[i] notin {';', ' ', '\t'}:
+        inc i
+
+      paramValue = headerValue[valueStart..<i]
+
+    result.parameters[paramName] = paramValue
+
+    while i < headerLen and headerValue[i] in Whitespace:
+      inc i
+
+proc `$`*(mediaType: MediaType): string =
+  ## Convert MediaType to string representation
+  result = mediaType.mainType & "/" & mediaType.subType
+
+  for name, value in mediaType.parameters:
+    var needsQuotes = false
path: src/prologue/core/contenttype.nim
user: login: PhilippMDoerner
body: If you want a more convenient shorthand here:

```nim
let needsQuotes = value.anyIt(it notin VALID_TOKEN_CHARACTERS)
```
created_at: 2025-03-09T12:56:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -89,18 +96,19 @@ func parseFormPart*(body, contentType: string): FormPart =
 
 func parseFormParams*(request: var Request, contentType: string) =
   ## Parses get or post or query parameters.
-  if "form-urlencoded" in contentType:
+  let mediaType = parseContentType(contentType)
+
+  if mediaType.mainType == "application" and mediaType.subType == "x-www-form-urlencoded":
     request.formParams = initFormPart()
     if request.reqMethod == HttpPost:
       for (key, value) in decodeQuery(request.body):
         # formPrams and postParams for secret event
         request.formParams[key] = value
         request.postParams[key] = value
-  elif "multipart/form-data" in contentType and "boundary" in contentType:
+  elif mediaType.mainType == "multipart" and mediaType.subType == "form-data" and "boundary" in mediaType.parameters:
path: src/prologue/core/form.nim
user: login: PhilippMDoerner
body: Could put this in its own proc:

```nim
# contenttype.nim
proc isApplicationUrlEndedForm*(x: MediaType): bool =
  x.mainType == "application" and x.subType == "x-www-form-urlencoded"

proc isMultipartFormdata*(x: MediaType): bool =
  x.mainType == "multipart" and x.subType == "form-data"

proc hasParam*(x: MediaType, paramName: string): bool =
  paramName in x.parameters
....

#form.nim
...
if mediaType.isApplicationUrlEncodedForm():

elif mediaType.isMultipartFormdata() and mediaType.hasParam("boundary)
```

Again, just an optional suggestion from my own code style, doesn't need to be adhered to here, I think it's already alright to read as is.
created_at: 2025-03-09T13:03:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,113 @@
+import std/[strutils, tables, strformat]
+
+type
+  MediaType* = object
+    mainType*: string
+    subType*: string
+    parameters*: Table[string, string]
+
+proc parseContentType*(headerValue: string): MediaType =
+  ## Parses a Content-Type header according to RFC 7230, RFC 2045, and RFC 2046.
path: src/prologue/core/contenttype.nim
user: login: nnsee
body: Would a `runnableExamples` be fine here?
created_at: 2025-03-10T08:41:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,113 @@
+import std/[strutils, tables, strformat]
+
+type
+  MediaType* = object
+    mainType*: string
+    subType*: string
+    parameters*: Table[string, string]
+
+proc parseContentType*(headerValue: string): MediaType =
+  ## Parses a Content-Type header according to RFC 7230, RFC 2045, and RFC 2046.
path: src/prologue/core/contenttype.nim
user: login: PhilippMDoerner
body: That would actually be even better than what I thought, entirely forgot nim had those. Perfect idea!
created_at: 2025-03-10T08:43:04Z
author_association: CONTRIBUTOR

PR comments:
user: login: nnsee
created_at: 2025-02-28T09:32:51Z
body: I don't know why the tests fail. It also fails on devel even without my changes. And the process seems to hang.
author_association: CONTRIBUTOR
user: login: ringabout
created_at: 2025-02-28T11:55:33Z
body: The devel actually passes the tests because circle CI is unused though it still causes failures. I guess there is a loop which causes the hang 
author_association: MEMBER
user: login: PhilippMDoerner
created_at: 2025-03-06T17:52:49Z
body: Out of curiosity, what blocks this? A fix for the test-suite?
author_association: CONTRIBUTOR
user: login: ringabout
created_at: 2025-03-07T11:41:57Z
body: I think so. Besides, I could merge a workaround PR to replace `rfind` with `find` => https://github.com/planety/prologue/pull/260
author_association: MEMBER
user: login: nnsee
created_at: 2025-03-07T11:47:40Z
body: I don't think the test suite issue is from my side, try to run the test suite on just the master branch of this repo and see if it exhibits the same behavior, I'm pretty sure it does.
author_association: CONTRIBUTOR
user: login: ringabout
created_at: 2025-03-07T12:42:37Z
body: It seems that https://github.com/planety/prologue/pull/263 the cookie dep causes the hang
author_association: MEMBER
user: login: PhilippMDoerner
created_at: 2025-03-07T16:54:22Z
body: I think before I go any deeper into this: I'm happy to do a codereview here, if my style of code that I tend to strive for and find most readable works for y'all.

As in, I am very thankful you took the time to implement this Ras, I'd just love to split a few things out for example to have a few smaller procs that do individual tasks like a "parseMediaType" proc that returns a parsed media-type and media-type start- and end-indexes etc.
My goal there would be that ideally you don't need to know the RFCs to understand the code, the variable names and proc names tell you everything you need while isolating smaller individual tasks.

However, if that looks like it does more work than it's worth I'm happy to drop that.
author_association: CONTRIBUTOR
user: login: PhilippMDoerner
created_at: 2025-03-09T13:04:59Z
body: Overall, LGTM. A couple suggestions that you can heed if you want to, I'll leave that up to you.
author_association: CONTRIBUTOR
user: login: nnsee
created_at: 2025-03-10T09:07:07Z
body: Thanks for the code review!
author_association: CONTRIBUTOR