Files
warmbly/internal/formserver/client.go
T

221 lines
6.3 KiB
Go

package formserver
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"sync"
"time"
"github.com/warmbly/warmbly/internal/formwire"
)
var errFormNotFound = errors.New("formserver: form not found")
const (
// Short positive TTL: an unpublish takes effect within it, and one busy
// form costs the backend at most a few requests a minute.
formCacheTTL = 15 * time.Second
formNegCacheTTL = 30 * time.Second
// formCacheMax bounds the map against public-id spray; real instances
// hold a few hundred forms at most.
formCacheMax = 4096
)
// backendClient is the forms service's only dependency: the backend's
// internal API, authenticated with the same bearer token the workers and the
// tracking service use.
type backendClient struct {
base string
token string
http *http.Client
mu sync.Mutex
cache map[string]cacheEntry
}
// cacheEntry with a nil form is a negative entry: the id is known missing.
type cacheEntry struct {
form *formwire.PublicForm
expires time.Time
}
func newBackendClient(base, token string) *backendClient {
return &backendClient{
base: base,
token: token,
http: &http.Client{Timeout: 10 * time.Second},
cache: map[string]cacheEntry{},
}
}
func (b *backendClient) formURL(publicID, suffix string) string {
return b.base + "/api/v1/internal/forms/" + url.PathEscape(publicID) + suffix
}
func (b *backendClient) do(req *http.Request) (*http.Response, error) {
req.Header.Set("Authorization", "Bearer "+b.token)
return b.http.Do(req)
}
func (b *backendClient) cached(publicID string) (cacheEntry, bool) {
b.mu.Lock()
defer b.mu.Unlock()
e, ok := b.cache[publicID]
if !ok || time.Now().After(e.expires) {
return cacheEntry{}, false
}
return e, true
}
func (b *backendClient) store(publicID string, f *formwire.PublicForm, ttl time.Duration) {
b.mu.Lock()
defer b.mu.Unlock()
if len(b.cache) >= formCacheMax {
now := time.Now()
for k, e := range b.cache {
if now.After(e.expires) {
delete(b.cache, k)
}
}
// Still full after sweeping means an id spray; dropping the window
// costs a few extra backend hits, holding it costs unbounded memory.
if len(b.cache) >= formCacheMax {
b.cache = map[string]cacheEntry{}
}
}
b.cache[publicID] = cacheEntry{form: f, expires: time.Now().Add(ttl)}
}
// Form resolves a published form, TTL-cached. errFormNotFound for unknown or
// unpublished ids; any other error means the backend was unreachable.
func (b *backendClient) Form(ctx context.Context, publicID string) (*formwire.PublicForm, error) {
if e, ok := b.cached(publicID); ok {
if e.form == nil {
return nil, errFormNotFound
}
return e.form, nil
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, b.formURL(publicID, ""), nil)
if err != nil {
return nil, err
}
resp, err := b.do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
var f formwire.PublicForm
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&f); err != nil {
return nil, err
}
b.store(publicID, &f, formCacheTTL)
return &f, nil
case http.StatusNotFound:
b.store(publicID, nil, formNegCacheTTL)
return nil, errFormNotFound
default:
return nil, fmt.Errorf("formserver: backend returned %d for form fetch", resp.StatusCode)
}
}
// submitOutcome is the backend's verdict on one submission. Exactly one of
// Result, Rejected or NotFound is meaningful.
type submitOutcome struct {
Result *formwire.SubmitResult
Rejected string
NotFound bool
}
// Submit forwards a visitor's answers. An error return means the backend was
// unreachable, never that the submission was invalid.
func (b *backendClient) Submit(ctx context.Context, publicID string, in *formwire.SubmitRequest) (*submitOutcome, error) {
body, err := json.Marshal(in)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, b.formURL(publicID, "/submissions"), bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := b.do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
var res formwire.SubmitResult
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&res); err != nil {
return nil, err
}
return &submitOutcome{Result: &res}, nil
case http.StatusNotFound:
return &submitOutcome{NotFound: true}, nil
case http.StatusBadRequest:
var rej formwire.SubmitError
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&rej); err != nil || rej.Message == "" {
rej.Message = "The submission could not be processed."
}
return &submitOutcome{Rejected: rej.Message}, nil
default:
return nil, fmt.Errorf("formserver: backend returned %d for submit", resp.StatusCode)
}
}
// FormWithToken resolves a form together with its ?t= prefill. Deliberately
// uncached: per-contact prefill must never be served to another visitor, and
// the render-token gate plus the event budget bound the extra backend hits.
func (b *backendClient) FormWithToken(ctx context.Context, publicID, linkToken string) (*formwire.PublicForm, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, b.formURL(publicID, "?t="+url.QueryEscape(linkToken)), nil)
if err != nil {
return nil, err
}
resp, err := b.do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
var f formwire.PublicForm
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&f); err != nil {
return nil, err
}
return &f, nil
case http.StatusNotFound:
b.store(publicID, nil, formNegCacheTTL)
return nil, errFormNotFound
default:
return nil, fmt.Errorf("formserver: backend returned %d for form fetch", resp.StatusCode)
}
}
// RecordEvent forwards one funnel event; best-effort, the page never waits.
func (b *backendClient) RecordEvent(publicID string, ev *formwire.EventRequest) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
body, err := json.Marshal(ev)
if err != nil {
return
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, b.formURL(publicID, "/events"), bytes.NewReader(body))
if err != nil {
return
}
req.Header.Set("Content-Type", "application/json")
resp, err := b.do(req)
if err != nil {
return
}
_ = resp.Body.Close()
}