Skip to content

Commit

Permalink
Break {api,mailto}Handler out of Handler
Browse files Browse the repository at this point in the history
It was becoming apparent that Handler had two separate sets of
responsibilities. Breaking out the new objects makes those
responsibilities more explicit and easier to reason about and test.
Placing them in several smaller implementation and test files instead of
single, monolithic implementation and test files also helps lighten the
cognitive burden.

In the process, I also expanded "f.ta" to "f.agent" and "f.h" to
"f.handler".
  • Loading branch information
mbland committed Apr 4, 2023
1 parent 20d5779 commit c04a9f2
Show file tree
Hide file tree
Showing 6 changed files with 830 additions and 738 deletions.
269 changes: 269 additions & 0 deletions handler/api.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
package handler

import (
"encoding/base64"
"errors"
"fmt"
"log"
"net/http"
"strings"
"text/template"

"github.com/aws/aws-lambda-go/events"
"github.com/mbland/elistman/ops"
)

type RedirectMap map[ops.OperationResult]string

type apiHandler struct {
SiteTitle string
Agent ops.SubscriptionAgent
Redirects RedirectMap
responseTemplate *template.Template
}

func newApiHandler(
emailDomain string,
siteTitle string,
agent ops.SubscriptionAgent,
paths RedirectPaths,
responseTemplate string,
) (*apiHandler, error) {
fullUrl := func(path string) string {
return "https://" + emailDomain + "/" + path
}
responseTmpl, err := initResponseBodyTemplate(responseTemplate)

if err != nil {
return nil, err
}

return &apiHandler{
siteTitle,
agent,
RedirectMap{
ops.Invalid: fullUrl(paths.Invalid),
ops.AlreadySubscribed: fullUrl(paths.AlreadySubscribed),
ops.VerifyLinkSent: fullUrl(paths.VerifyLinkSent),
ops.Subscribed: fullUrl(paths.Subscribed),
ops.NotSubscribed: fullUrl(paths.NotSubscribed),
ops.Unsubscribed: fullUrl(paths.Unsubscribed),
},
responseTmpl,
}, nil
}

type responseTemplateParams struct {
Title string
SiteTitle string
Body string
}

type errorWithStatus struct {
HttpStatus int
Message string
}

func (err *errorWithStatus) Error() string {
return err.Message
}

func initResponseBodyTemplate(bodyTmpl string) (*template.Template, error) {
builder := &strings.Builder{}
params := &responseTemplateParams{}

if tmpl, err := template.New("responseBody").Parse(bodyTmpl); err != nil {
return nil, fmt.Errorf("parsing response body template failed: %s", err)
} else if err := tmpl.Execute(builder, params); err != nil {
return nil, fmt.Errorf(
"executing response body template failed: %s", err,
)
} else {
return tmpl, nil
}
}

func (h *apiHandler) HandleEvent(
origReq *events.APIGatewayV2HTTPRequest,
) *events.APIGatewayV2HTTPResponse {
var res *events.APIGatewayV2HTTPResponse = nil
req, err := newApiRequest(origReq)

if err == nil {
res, err = h.handleApiRequest(req)
}

if err != nil {
res = h.errorResponse(err)
}
logApiResponse(origReq, res, err)
return res
}

func (h *apiHandler) addResponseBody(
res *events.APIGatewayV2HTTPResponse, body string,
) {
httpStatus := res.StatusCode
title := fmt.Sprintf("%d %s", httpStatus, http.StatusText(httpStatus))
params := &responseTemplateParams{title, h.SiteTitle, body}
builder := &strings.Builder{}

if err := h.responseTemplate.Execute(builder, params); err != nil {
// This should never happen, but if it does, fall back to plain text.
log.Printf("ERROR adding HTML response body: %s: %+v", err, params)
res.Headers["content-type"] = "text/plain; charset=utf-8"
res.Body = fmt.Sprintf("%s - %s\n\n%s\n", title, h.SiteTitle, body)
} else {
res.Headers["content-type"] = "text/html; charset=utf-8"
res.Body = builder.String()
}
}

func (h *apiHandler) errorResponse(err error) *events.APIGatewayV2HTTPResponse {
res := &events.APIGatewayV2HTTPResponse{
StatusCode: http.StatusInternalServerError,
Headers: map[string]string{},
}
if apiErr, ok := err.(*errorWithStatus); ok {
res.StatusCode = apiErr.HttpStatus
}

body := "<p>There was a problem on our end; " +
"please try again in a few minutes.</p>\n"
h.addResponseBody(res, body)
return res
}

func logApiResponse(
req *events.APIGatewayV2HTTPRequest,
res *events.APIGatewayV2HTTPResponse,
err error,
) {
reqId := req.RequestContext.RequestID
desc := req.RequestContext.HTTP
errMsg := ""

if err != nil {
errMsg = ": " + err.Error()
}

log.Printf(`%s: %s "%s %s %s" %d%s`,
reqId,
desc.SourceIP, desc.Method, desc.Path, desc.Protocol, res.StatusCode,
errMsg,
)
}

func newApiRequest(req *events.APIGatewayV2HTTPRequest) (*apiRequest, error) {
contentType, foundContentType := req.Headers["content-type"]
body := req.Body

// This accounts for differences in HTTP Header casing between running
// bin/smoke-test.sh against a `sam local` server and the prod deployment.
//
// `curl` will send "Content-Type" to the local, unencrypted, HTTP/1.1
// server, which doesn't fully emulate a production API Gateway instance. It
// will send "content-type" to the encrypted HTTP/2 prod deployment.
//
// HTTP headers are supposed to be case insensitive. HTTP/2 headers MUST be
// lowercase:
//
// - Are HTTP headers case-sensitive?: https://stackoverflow.com/a/41169947
// - HTTP/1.1: https://www.rfc-editor.org/rfc/rfc7230#section-3.2
// - HTTP/2: https://www.rfc-editor.org/rfc/rfc7540#section-8.1.2
// - HTTP/2 Header Casing: https://blog.yaakov.online/http-2-header-casing/
//
// Also, the "Payload format version" of "Working with AWS Lambda proxy
// integrations for HTTP APIs" explictly states that "Header names are
// lowercased."
//
// - https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html
if !foundContentType {
contentType = req.Headers["Content-Type"]
}

// For some reason, the prod API Gateway will base64 encode POST body
// payloads. The `sam-local` server will not. Either way, it's good to do
// the right thing based on the value of this flag.
if req.IsBase64Encoded {
if decoded, err := base64.StdEncoding.DecodeString(body); err != nil {
return nil, fmt.Errorf("failed to base64 decode body: %s", err)
} else {
body = string(decoded)
}
}

return &apiRequest{
req.RequestContext.RequestID,
req.RawPath,
req.RequestContext.HTTP.Method,
contentType,
req.PathParameters,
body,
}, nil
}

func (h *apiHandler) handleApiRequest(
req *apiRequest,
) (*events.APIGatewayV2HTTPResponse, error) {
res := &events.APIGatewayV2HTTPResponse{Headers: map[string]string{}}
res.Headers["content-type"] = "text/plain; charset=utf-8"

if op, err := parseApiRequest(req); err != nil {
return h.respondToParseError(res, err)
} else if result, err := h.performOperation(op); err != nil {
return nil, err
} else if op.OneClick {
res.StatusCode = http.StatusOK
} else if redirect, ok := h.Redirects[result]; !ok {
return nil, fmt.Errorf("no redirect for op result: %s", result)
} else {
res.StatusCode = http.StatusSeeOther
res.Headers["location"] = redirect
}
return res, nil
}

func (h *apiHandler) respondToParseError(
response *events.APIGatewayV2HTTPResponse, err error,
) (*events.APIGatewayV2HTTPResponse, error) {
// Treat email parse errors differently for the Subscribe operation, since
// it may be due to a user typo. In all other cases, the assumption is that
// it's a bad machine generated request.
if !errors.Is(err, &ParseError{Type: SubscribeOp}) {
response.StatusCode = http.StatusBadRequest
body := "<p>Parsing the request failed:</p>\n" +
"<pre>\n" + template.HTMLEscapeString(err.Error()) + "\n</pre>\n" +
"<p>Please correct the request and try again.</p>"
h.addResponseBody(response, body)
} else if redirect, ok := h.Redirects[ops.Invalid]; !ok {
return nil, errors.New("no redirect for invalid operation")
} else {
response.StatusCode = http.StatusSeeOther
response.Headers["location"] = redirect
}
return response, nil
}

func (h *apiHandler) performOperation(
op *eventOperation,
) (ops.OperationResult, error) {
result := ops.Invalid
var err error = nil

switch op.Type {
case SubscribeOp:
result, err = h.Agent.Subscribe(op.Email)
case VerifyOp:
result, err = h.Agent.Verify(op.Email, op.Uid)
case UnsubscribeOp:
result, err = h.Agent.Unsubscribe(op.Email, op.Uid)
default:
err = fmt.Errorf("can't handle operation type: %s", op.Type)
}

if opErr, ok := err.(*ops.OperationErrorExternal); ok {
err = &errorWithStatus{http.StatusBadGateway, opErr.Error()}
}
return result, err
}
Loading

0 comments on commit c04a9f2

Please sign in to comment.