Skip to content

Commit

Permalink
Parse form-urlencoded, form-data body parameters
Browse files Browse the repository at this point in the history
Specifically, parseApiRequest now parses the bodies of POST requests
with a Content-Type of either application/x-www-form-urlencoded or
multipart/form-data. This is to support to primary use cases:

1. The typical /subscribe submission from a web form, which defaults to
   form-urlencoded, but can easily use form-data. It would require
   JavaScript to construct path parameters instead, or even a JSON
   payload.

2. The List-Unsubscribe=One-Click use case. The email address and UID
   are path parameters, but the List-Unsubscribe parameter must encoded
   in the body. See: https://www.rfc-editor.org/rfc/rfc8058

Performed slight refactoring and cleanup of parser.go and parser_test.go
along the way. Of particular note is that parseApiRequest wraps all
errors in ParseError, instead of having ParseErrors bubble up.
  • Loading branch information
mbland committed Mar 31, 2023
1 parent b45aa51 commit 046e233
Show file tree
Hide file tree
Showing 2 changed files with 319 additions and 44 deletions.
109 changes: 83 additions & 26 deletions handler/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ package handler

import (
"fmt"
"io"
"mime"
"mime/multipart"
"net/http"
"net/mail"
"net/url"
"strings"

"github.com/google/uuid"
Expand Down Expand Up @@ -71,27 +76,22 @@ type apiRequest struct {
}

func parseApiRequest(req *apiRequest) (*eventOperation, error) {
if pi, err := newOpInfo(req.RawPath, req.Params); err != nil {
return nil, err
} else if email, err := pi.parseEmail(); err != nil {
return nil, err
} else if uid, err := pi.parseUid(); err != nil {
return nil, err
var info *opInfo = nil

if optype, err := parseOperationType(req.RawPath); err != nil {
return nil, &ParseError{optype, err.Error()}
} else if err := parseParams(req); err != nil {
return nil, &ParseError{optype, err.Error()}
} else {
return &eventOperation{pi.Type, email, uid}, nil
info = &opInfo{optype, req.Params}
}
}

type opInfo struct {
Type eventOperationType
Params map[string]string
}

func newOpInfo(endpoint string, params map[string]string) (*opInfo, error) {
if optype, err := parseOperationType(endpoint); err != nil {
return nil, err
if email, err := info.parseEmail(); err != nil {
return nil, &ParseError{info.Type, err.Error()}
} else if uid, err := info.parseUid(); err != nil {
return nil, &ParseError{info.Type, err.Error()}
} else {
return &opInfo{optype, params}, nil
return &eventOperation{info.Type, email, uid}, nil
}
}

Expand All @@ -103,9 +103,70 @@ func parseOperationType(endpoint string) (eventOperationType, error) {
} else if strings.HasPrefix(endpoint, UnsubscribePrefix) {
return UnsubscribeOp, nil
}
return UndefinedOp, &ParseError{
Type: UndefinedOp, Message: "unknown endpoint: " + endpoint,
return UndefinedOp, fmt.Errorf("unknown endpoint: %s", endpoint)
}

func parseParams(req *apiRequest) error {
if req.Method != http.MethodPost {
return nil
}

values, err := parseBody(req.ContentType, req.Body)

if err != nil {
const errFmt = `failed to parse body params with Content-Type %q: %s`
return fmt.Errorf(errFmt, req.ContentType, err)
}

for k, v := range values {
if len(v) != 1 {
values := strings.Join(v, ", ")
return fmt.Errorf("multiple values for %q: %s", k, values)
} else if _, exists := req.Params[k]; !exists {
req.Params[k] = v[0]
}
}
return nil
}

func parseBody(contentType, body string) (url.Values, error) {
mediaType, params, err := mime.ParseMediaType(contentType)

if err != nil {
const errFormat = "failed to parse %q: %s"
return url.Values{}, fmt.Errorf(errFormat, contentType, err)
}

switch mediaType {
case "application/x-www-form-urlencoded":
return url.ParseQuery(body)
case "multipart/form-data":
return parseFormData(body, params)
}
return url.Values{}, fmt.Errorf("unknown media type: %s", mediaType)
}

func parseFormData(body string, params map[string]string) (url.Values, error) {
reader := multipart.NewReader(strings.NewReader(body), params["boundary"])
values := url.Values{}

for {
if part, err := reader.NextPart(); err == io.EOF {
break
} else if err != nil {
return url.Values{}, err
} else if data, err := io.ReadAll(part); err != nil {
return url.Values{}, err
} else {
values.Add(part.FormName(), string(data))
}
}
return values, nil
}

type opInfo struct {
Type eventOperationType
Params map[string]string
}

func (oi *opInfo) parseEmail() (string, error) {
Expand All @@ -131,19 +192,15 @@ func parseParam[T string | uuid.UUID](
oi *opInfo, name string, nilValue T, parse func(string) (T, error),
) (T, error) {
if value, ok := oi.Params[name]; !ok {
return nilValue, oi.parseError("missing " + name + " parameter")
return nilValue, fmt.Errorf("missing %s parameter", name)
} else if v, err := parse(value); err != nil {
msg := fmt.Sprintf("invalid %s parameter: %s: %s", name, value, err)
return nilValue, oi.parseError(msg)
e := fmt.Errorf("invalid %s parameter: %s: %s", name, value, err)
return nilValue, e
} else {
return v, nil
}
}

func (oi *opInfo) parseError(message string) error {
return &ParseError{Type: oi.Type, Message: message}
}

type parsedSubject struct {
Email string
Uid uuid.UUID
Expand Down

0 comments on commit 046e233

Please sign in to comment.