diff --git a/CLAUDE.md b/CLAUDE.md index f6ca724..bc513cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,6 +97,7 @@ go-sdk/ ├── serde/ # Marshaler/Unmarshaler seam + JSON default + Tristate ├── sse/ # Server-Sent Events (WHATWG) parser ├── webhook/ # inbound signature verification (HMAC + timestamp) +├── formdata/ # multipart/form-data body builder ├── .golangci.yml Makefile .github/workflows/ci.yml └── CONTRIBUTING.md CLAUDE.md README.md LICENSE ``` diff --git a/README.md b/README.md index 324a3d5..be3a3a9 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ standard library. | [`serde`](./serde) | Serialization seam (Marshaler/Unmarshaler) with a JSON default, plus Tristate for PATCH payloads. | | [`sse`](./sse) | Server-Sent Events (text/event-stream) WHATWG parser. | | [`webhook`](./webhook) | Inbound webhook signature verification (constant-time HMAC + timestamp tolerance). | +| [`formdata`](./formdata) | Multipart/form-data request body builder (replayable; file uploads). | | root [`dexpace`](.) | Umbrella `Client` wiring the default policy stack. | ### Pipeline order diff --git a/doc.go b/doc.go index 8dafe29..d4b9c7b 100644 --- a/doc.go +++ b/doc.go @@ -62,5 +62,8 @@ // The webhook package verifies inbound webhook signatures (constant-time HMAC // with a timestamp-tolerance window). // +// The formdata package builds replayable multipart/form-data request bodies for +// file uploads. +// // All of core depends only on the Go standard library. package dexpace diff --git a/docs/superpowers/plans/2026-06-16-formdata.md b/docs/superpowers/plans/2026-06-16-formdata.md new file mode 100644 index 0000000..678a740 --- /dev/null +++ b/docs/superpowers/plans/2026-06-16-formdata.md @@ -0,0 +1,400 @@ +# Multipart Form-Data Builder Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a `formdata` package — a chainable `Form` builder over `mime/multipart` that produces a replayable `multipart/form-data` body and the matching `Content-Type`, plus a `NewRequest` convenience. + +**Architecture:** `Form` wraps a `multipart.Writer` over an internal buffer; `Field`/`File` accumulate parts (first-error-wins, no panics); `Build` closes the writer and returns a `*bytes.Reader` (replayable via `http.NewRequest`'s `GetBody`). + +**Tech Stack:** Go 1.26+, standard library (`bytes`, `context`, `errors`, `io`, `mime/multipart`, `net/http`) plus the `header` package. Zero third-party dependencies. + +**Conventions every task must follow:** +- MIT license header on every `.go` file before the `package` clause: + ```go + // Copyright (c) 2026 dexpace and Omar Aljarrah. + // Licensed under the MIT License. See LICENSE in the repository root for details. + ``` +- Import groups: stdlib, blank line, then `github.com/dexpace/go-sdk/...`. +- Tests use `t.Parallel()`; stdlib-only test deps. +- Tools: Go 1.26.3; `gofumpt`/`golangci-lint` NOT installed — use `gofmt`, `go vet`, `go test -race`. +- Run commands from the repo root `/Users/omar/dexpace/go-sdk`. + +--- + +## File Structure + +| Path | Responsibility | +|---|---| +| `formdata/doc.go` (new) | package comment | +| `formdata/form.go` (new) | `Form` + methods | +| `formdata/form_test.go` (new) | build/parse-back + error tests | +| `doc.go`, `README.md`, `CLAUDE.md` (modify) | document; add the package | + +--- + +## Task 1: the `formdata` package + +**Files:** +- Create: `formdata/doc.go`, `formdata/form.go`, `formdata/form_test.go` + +- [ ] **Step 1: Write the failing tests** + +```go +// formdata/form_test.go +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +package formdata_test + +import ( + "context" + "errors" + "io" + "mime" + "mime/multipart" + "net/http" + "strings" + "testing" + + "github.com/dexpace/go-sdk/formdata" +) + +// partsOf parses body using the boundary in contentType and returns field values +// keyed by form name, plus file parts keyed by form name -> (filename, content). +func partsOf(t *testing.T, contentType string, body io.Reader) (fields map[string]string, files map[string][2]string) { + t.Helper() + _, params, err := mime.ParseMediaType(contentType) + if err != nil { + t.Fatalf("ParseMediaType(%q): %v", contentType, err) + } + mr := multipart.NewReader(body, params["boundary"]) + fields = map[string]string{} + files = map[string][2]string{} + for { + p, err := mr.NextPart() + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("NextPart: %v", err) + } + data, _ := io.ReadAll(p) + if p.FileName() != "" { + files[p.FormName()] = [2]string{p.FileName(), string(data)} + } else { + fields[p.FormName()] = string(data) + } + } + return fields, files +} + +func TestFormBuildRoundTrip(t *testing.T) { + t.Parallel() + + form := formdata.New(). + Field("name", "alice"). + FileBytes("file", "a.txt", []byte("hello")) + + body, err := form.Build() + if err != nil { + t.Fatalf("Build: %v", err) + } + + fields, files := partsOf(t, form.ContentType(), body) + if fields["name"] != "alice" { + t.Fatalf("field name = %q, want alice", fields["name"]) + } + if files["file"] != [2]string{"a.txt", "hello"} { + t.Fatalf("file = %v, want {a.txt hello}", files["file"]) + } +} + +func TestFormNewRequest(t *testing.T) { + t.Parallel() + + form := formdata.New().Field("k", "v") + req, err := form.NewRequest(context.Background(), http.MethodPost, "https://api.example.test/upload") + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + + ct := req.Header.Get("Content-Type") + if !strings.HasPrefix(ct, "multipart/form-data; boundary=") { + t.Fatalf("Content-Type = %q, want multipart/form-data; boundary=...", ct) + } + + // The body must be replayable (GetBody set by http.NewRequest for *bytes.Reader). + if req.GetBody == nil { + t.Fatal("GetBody is nil; body is not replayable") + } + b1, _ := io.ReadAll(req.Body) + rc, err := req.GetBody() + if err != nil { + t.Fatalf("GetBody: %v", err) + } + b2, _ := io.ReadAll(rc) + if string(b1) != string(b2) || len(b1) == 0 { + t.Fatal("replayed body does not match the original") + } +} + +type errReader struct{ err error } + +func (r errReader) Read([]byte) (int, error) { return 0, r.err } + +func TestFormFileReaderError(t *testing.T) { + t.Parallel() + + boom := errors.New("read failed") + form := formdata.New().File("file", "x", errReader{err: boom}) + if _, err := form.Build(); !errors.Is(err, boom) { + t.Fatalf("Build err = %v, want boom", err) + } +} + +func TestFormBuildTwiceErrors(t *testing.T) { + t.Parallel() + + form := formdata.New().Field("k", "v") + if _, err := form.Build(); err != nil { + t.Fatalf("first Build: %v", err) + } + if _, err := form.Build(); err == nil { + t.Fatal("second Build should error (already built)") + } +} + +func TestFormFieldAfterBuildErrors(t *testing.T) { + t.Parallel() + + form := formdata.New().Field("k", "v") + if _, err := form.Build(); err != nil { + t.Fatalf("Build: %v", err) + } + form.Field("late", "x") + if _, err := form.Build(); err == nil { + t.Fatal("Build after a post-build Field should error") + } +} + +func TestFormEmpty(t *testing.T) { + t.Parallel() + + form := formdata.New() + body, err := form.Build() + if err != nil { + t.Fatalf("empty Build: %v", err) + } + fields, files := partsOf(t, form.ContentType(), body) + if len(fields) != 0 || len(files) != 0 { + t.Fatalf("empty form has parts: fields=%v files=%v", fields, files) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./formdata/ -v` +Expected: FAIL — `formdata.New`/`Form` undefined. + +- [ ] **Step 3: Create `formdata/doc.go`** + +```go +// formdata/doc.go +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +// Package formdata builds multipart/form-data request bodies. A [Form] collects +// text fields and file parts and produces a replayable body together with the +// matching Content-Type (including the boundary), so the body survives retries. +// Use [Form.NewRequest] for a ready-to-send *http.Request. +package formdata +``` + +- [ ] **Step 4: Create `formdata/form.go`** + +```go +// formdata/form.go +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +package formdata + +import ( + "bytes" + "context" + "errors" + "io" + "mime/multipart" + "net/http" + + "github.com/dexpace/go-sdk/header" +) + +// Form builds a multipart/form-data request body. The zero value is not usable; +// create one with [New]. A Form is not safe for concurrent use. +type Form struct { + buf bytes.Buffer + w *multipart.Writer + err error + built bool +} + +// New returns an empty Form. +func New() *Form { + f := &Form{} + f.w = multipart.NewWriter(&f.buf) + return f +} + +// Field adds a text field. It returns f for chaining; the first error +// encountered is reported by [Form.Build]. +func (f *Form) Field(name, value string) *Form { + if f.err != nil { + return f + } + if f.built { + f.err = errors.New("formdata: Field called after Build") + return f + } + f.err = f.w.WriteField(name, value) + return f +} + +// File adds a file part named field, with the given filename, read from r. It +// returns f for chaining. +func (f *Form) File(field, filename string, r io.Reader) *Form { + if f.err != nil { + return f + } + if f.built { + f.err = errors.New("formdata: File called after Build") + return f + } + pw, err := f.w.CreateFormFile(field, filename) + if err != nil { + f.err = err + return f + } + if _, err := io.Copy(pw, r); err != nil { + f.err = err + } + return f +} + +// FileBytes adds a file part from an in-memory byte slice. +func (f *Form) FileBytes(field, filename string, data []byte) *Form { + return f.File(field, filename, bytes.NewReader(data)) +} + +// ContentType returns the multipart Content-Type, including the boundary. It is +// stable for the lifetime of the Form (the boundary is fixed at New). +func (f *Form) ContentType() string { + return f.w.FormDataContentType() +} + +// Build finalizes the form and returns a replayable body. It returns the first +// error encountered while adding parts. After a successful Build no more parts +// may be added, and a second Build returns an error. +func (f *Form) Build() (*bytes.Reader, error) { + if f.err != nil { + return nil, f.err + } + if f.built { + return nil, errors.New("formdata: already built") + } + if err := f.w.Close(); err != nil { + return nil, err + } + f.built = true + return bytes.NewReader(f.buf.Bytes()), nil +} + +// NewRequest builds the body and returns an *http.Request with the multipart +// Content-Type header set. The request body is replayable, so the retry policy +// can re-send it. +func (f *Form) NewRequest(ctx context.Context, method, url string) (*http.Request, error) { + body, err := f.Build() + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, method, url, body) + if err != nil { + return nil, err + } + req.Header.Set(header.ContentType, f.ContentType()) + return req, nil +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `go test ./formdata/ -v` +Expected: PASS — round-trip, NewRequest replay, file-error, build-twice, field-after-build, empty. + +- [ ] **Step 6: Commit** + +```bash +git add formdata/ +git commit -m "feat(formdata): add multipart form-data body builder" +``` + +--- + +## Task 2: docs and full gate + +**Files:** +- Modify: `doc.go`, `README.md`, `CLAUDE.md` + +- [ ] **Step 1: Mention formdata in `doc.go`** + +Read `doc.go`. Within the `package dexpace` doc comment (single contiguous `//` +block; no second package clause / no duplicate header), add: + +```go +// The formdata package builds replayable multipart/form-data request bodies for +// file uploads. +``` + +- [ ] **Step 2: Add `formdata` to `README.md`** + +Read `README.md`. Add a `formdata` row to the architecture/package table (matching +the column/link style): "Multipart/form-data request body builder (replayable; +file uploads)." + +- [ ] **Step 3: Add `formdata/` to `CLAUDE.md` Repository Layout** + +Read `CLAUDE.md`. Add a `formdata/` line near the other request-shaping packages: +`formdata/ # multipart/form-data body builder`. + +- [ ] **Step 4: Run the full gate** + +Run: +```bash +gofmt -l . +go vet ./... +go test -race ./... +``` +Expected: `gofmt -l .` prints nothing; `go vet` clean; every package passes under +the race detector (`formdata` now has tests). + +- [ ] **Step 5: Commit** + +```bash +git add doc.go README.md CLAUDE.md +git commit -m "docs: document the formdata package" +``` + +--- + +## Self-Review notes (for the implementer) + +- **Spec coverage:** `Form` builder, `Field`/`File`/`FileBytes`, `ContentType`, + `Build` (replayable), `NewRequest` (Task 1); docs (Task 2). +- **Type consistency:** `formdata.New`, `(*Form).Field/File/FileBytes/ContentType/Build/NewRequest` + used identically across tasks/tests. +- **Replayable body:** `Build` returns `*bytes.Reader`, so `http.NewRequest` + populates `GetBody` — verified in `TestFormNewRequest`. +- **First-error-wins, no panics:** `File`/`Field` no-op after an error or after + `Build`; `Build` surfaces the captured error. +- **`make check`** green before opening the PR. diff --git a/docs/superpowers/specs/2026-06-16-formdata-design.md b/docs/superpowers/specs/2026-06-16-formdata-design.md new file mode 100644 index 0000000..8113057 --- /dev/null +++ b/docs/superpowers/specs/2026-06-16-formdata-design.md @@ -0,0 +1,121 @@ +# Multipart form-data body builder — design + +**Date:** 2026-06-16 +**Status:** Approved (standing delegation); ready for implementation planning +**Subsystem:** deferred-feature #1 (multipart bodies, from the HTTP-value-types roadmap item) + +## Context + +File uploads need a `multipart/form-data` body. Go's `mime/multipart` builds one +but the ergonomics (writer, boundary, content-type, retry-replayable body) are +fiddly to assemble correctly each time. This package wraps it into a small, +chainable builder that produces a replayable body and the matching `Content-Type`. + +## Decisions + +1. **Wrap `mime/multipart`.** Don't reinvent encoding; provide an ergonomic + builder over `multipart.Writer`. +2. **Replayable body.** The body is buffered in memory and returned as a + `*bytes.Reader`, so `http.NewRequest` sets `GetBody` automatically and the retry + policy can replay it. (Consistent with the SDK's "buffer bodies that need to be + retried" guidance.) +3. **Chainable, deferred-error builder.** `Field`/`File` return the `*Form` and + accumulate the first error; `Build`/`NewRequest` surface it. No panics. +4. **A `NewRequest` convenience** that returns a ready `*http.Request` with the + `Content-Type` (including boundary) already set. + +## Architecture + +### `formdata` package (stdlib + `header`) + +```go +// Form builds a multipart/form-data request body. The zero value is not usable; +// create one with New. A Form is not safe for concurrent use. +type Form struct { + buf bytes.Buffer + w *multipart.Writer + err error +} + +// New returns an empty Form. +func New() *Form + +// Field adds a text field. It returns f for chaining. +func (f *Form) Field(name, value string) *Form + +// File adds a file part read from r (filename sets the part's filename). It +// returns f for chaining. +func (f *Form) File(field, filename string, r io.Reader) *Form + +// FileBytes adds a file part from an in-memory byte slice. +func (f *Form) FileBytes(field, filename string, data []byte) *Form + +// ContentType returns the multipart Content-Type, including the boundary. It is +// valid immediately after New (the boundary is fixed at construction). +func (f *Form) ContentType() string + +// Build finalizes the form and returns a replayable body. After Build no more +// parts may be added. It returns the first error encountered while building. +func (f *Form) Build() (*bytes.Reader, error) + +// NewRequest builds the body and returns an *http.Request with the multipart +// Content-Type header set. It is the ergonomic entry point. +func (f *Form) NewRequest(ctx context.Context, method, url string) (*http.Request, error) +``` + +### Behaviour + +- `New` creates a `multipart.Writer` over the internal buffer; the boundary is + generated once. +- `Field` calls `w.WriteField`; `File` calls `w.CreateFormFile` then `io.Copy` + from `r`; both no-op once `f.err` is set (first-error-wins). +- `Build` closes the writer (writes the trailing boundary) and returns + `bytes.NewReader(f.buf.Bytes())`. Calling a builder method after `Build` is a + programming error: `Build` sets a "closed" state and further `Field`/`File` + record an error. (Implementation: a `built bool`; methods check it.) +- `NewRequest` = `Build` + `http.NewRequestWithContext` + set + `header.ContentType` to `ContentType()`. Returns any build or request error. +- The returned body is a `*bytes.Reader`, so `http.NewRequest`/`NewRequestWithContext` + populates `GetBody` and `ContentLength` — the retry policy can replay it. + +## Edge cases + +- An I/O error from `File`'s `io.Copy` (e.g. a failing reader) is captured and + surfaced by `Build`/`NewRequest`. +- `Build` called twice → the second returns an error (already built); methods + after `Build` record an error. +- An empty form (no parts) builds a valid (empty) multipart body. +- A `File` with an empty filename is allowed (mime/multipart handles it). +- `ContentType` is stable across the lifetime of the `Form` (boundary fixed at + `New`), so calling it before or after `Build` returns the same value. + +## Package layout + +| Path | Change | +|---|---| +| `formdata/doc.go` (new) | package comment | +| `formdata/form.go` (new) | `Form` + methods | +| `formdata/form_test.go` (new) | build + parse-back + error tests | +| `doc.go`, `README.md`, `CLAUDE.md` | document; add the package | + +## Testing + +- Build a form with a field and a file; parse it back with `mime/multipart.Reader` + (using the boundary from `ContentType`) and assert the field value and file + contents/filename round-trip. +- `NewRequest` sets the `Content-Type` header (starts with `multipart/form-data; + boundary=`) and produces a request whose body replays (read it twice via + `GetBody`). +- A `File` reader that errors → `Build` returns that error. +- `Build` twice → second call errors; a `Field` after `Build` → `Build`/error + state reflects it. +- Empty form builds without error. +- Table-driven where natural, parallel; stdlib + `header` only; `gofmt`/`go vet`/ + `go test -race` clean. + +## Out of scope (deferred) + +- Streaming (non-buffered) bodies for very large uploads (would not be + retry-replayable; buffering is the documented default). +- Custom per-part headers/content types beyond what `CreateFormFile` sets (add a + `FilePart(header textproto.MIMEHeader, ...)` later if needed). diff --git a/formdata/doc.go b/formdata/doc.go new file mode 100644 index 0000000..59fb9c1 --- /dev/null +++ b/formdata/doc.go @@ -0,0 +1,8 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +// Package formdata builds multipart/form-data request bodies. A [Form] collects +// text fields and file parts and produces a replayable body together with the +// matching Content-Type (including the boundary), so the body survives retries. +// Use [Form.NewRequest] for a ready-to-send *http.Request. +package formdata diff --git a/formdata/form.go b/formdata/form.go new file mode 100644 index 0000000..489c77f --- /dev/null +++ b/formdata/form.go @@ -0,0 +1,110 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +package formdata + +import ( + "bytes" + "context" + "errors" + "io" + "mime/multipart" + "net/http" + + "github.com/dexpace/go-sdk/header" +) + +// Form builds a multipart/form-data request body. The zero value is not usable; +// create one with [New]. A Form is not safe for concurrent use. +type Form struct { + buf bytes.Buffer + w *multipart.Writer + err error + built bool +} + +// New returns an empty Form. +func New() *Form { + f := &Form{} + f.w = multipart.NewWriter(&f.buf) + return f +} + +// Field adds a text field. It returns f for chaining; the first error +// encountered is reported by [Form.Build]. +func (f *Form) Field(name, value string) *Form { + if f.err != nil { + return f + } + if f.built { + f.err = errors.New("formdata: Field called after Build") + return f + } + f.err = f.w.WriteField(name, value) + return f +} + +// File adds a file part named field, with the given filename, read from r. It +// returns f for chaining. +func (f *Form) File(field, filename string, r io.Reader) *Form { + if f.err != nil { + return f + } + if f.built { + f.err = errors.New("formdata: File called after Build") + return f + } + pw, err := f.w.CreateFormFile(field, filename) + if err != nil { + f.err = err + return f + } + if _, err := io.Copy(pw, r); err != nil { + f.err = err + } + return f +} + +// FileBytes adds a file part from an in-memory byte slice. +func (f *Form) FileBytes(field, filename string, data []byte) *Form { + return f.File(field, filename, bytes.NewReader(data)) +} + +// ContentType returns the multipart Content-Type, including the boundary. It is +// stable for the lifetime of the Form (the boundary is fixed at New). +func (f *Form) ContentType() string { + return f.w.FormDataContentType() +} + +// Build finalizes the form and returns a replayable body. It returns the first +// error encountered while adding parts. After a successful Build no more parts +// may be added, and a second Build returns an error. +func (f *Form) Build() (*bytes.Reader, error) { + if f.err != nil { + return nil, f.err + } + if f.built { + return nil, errors.New("formdata: already built") + } + if err := f.w.Close(); err != nil { + return nil, err + } + f.built = true + return bytes.NewReader(f.buf.Bytes()), nil +} + +// NewRequest builds the body and returns an *http.Request with the multipart +// Content-Type header set. The request body is replayable, so the retry policy +// can re-send it. +func (f *Form) NewRequest(ctx context.Context, method, url string) (*http.Request, error) { + body, err := f.Build() + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, method, url, body) + if err != nil { + return nil, err + } + req.Header.Set(header.ContentType, f.ContentType()) + return req, nil +} diff --git a/formdata/form_test.go b/formdata/form_test.go new file mode 100644 index 0000000..2223f79 --- /dev/null +++ b/formdata/form_test.go @@ -0,0 +1,146 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +package formdata_test + +import ( + "context" + "errors" + "io" + "mime" + "mime/multipart" + "net/http" + "strings" + "testing" + + "github.com/dexpace/go-sdk/formdata" +) + +func partsOf(t *testing.T, contentType string, body io.Reader) (fields map[string]string, files map[string][2]string) { + t.Helper() + _, params, err := mime.ParseMediaType(contentType) + if err != nil { + t.Fatalf("ParseMediaType(%q): %v", contentType, err) + } + mr := multipart.NewReader(body, params["boundary"]) + fields = map[string]string{} + files = map[string][2]string{} + for { + p, err := mr.NextPart() + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("NextPart: %v", err) + } + data, _ := io.ReadAll(p) + if p.FileName() != "" { + files[p.FormName()] = [2]string{p.FileName(), string(data)} + } else { + fields[p.FormName()] = string(data) + } + } + return fields, files +} + +func TestFormBuildRoundTrip(t *testing.T) { + t.Parallel() + + form := formdata.New(). + Field("name", "alice"). + FileBytes("file", "a.txt", []byte("hello")) + + body, err := form.Build() + if err != nil { + t.Fatalf("Build: %v", err) + } + + fields, files := partsOf(t, form.ContentType(), body) + if fields["name"] != "alice" { + t.Fatalf("field name = %q, want alice", fields["name"]) + } + if files["file"] != [2]string{"a.txt", "hello"} { + t.Fatalf("file = %v, want {a.txt hello}", files["file"]) + } +} + +func TestFormNewRequest(t *testing.T) { + t.Parallel() + + form := formdata.New().Field("k", "v") + req, err := form.NewRequest(context.Background(), http.MethodPost, "https://api.example.test/upload") + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + + ct := req.Header.Get("Content-Type") + if !strings.HasPrefix(ct, "multipart/form-data; boundary=") { + t.Fatalf("Content-Type = %q, want multipart/form-data; boundary=...", ct) + } + + if req.GetBody == nil { + t.Fatal("GetBody is nil; body is not replayable") + } + b1, _ := io.ReadAll(req.Body) + rc, err := req.GetBody() + if err != nil { + t.Fatalf("GetBody: %v", err) + } + b2, _ := io.ReadAll(rc) + if string(b1) != string(b2) || len(b1) == 0 { + t.Fatal("replayed body does not match the original") + } +} + +type errReader struct{ err error } + +func (r errReader) Read([]byte) (int, error) { return 0, r.err } + +func TestFormFileReaderError(t *testing.T) { + t.Parallel() + + boom := errors.New("read failed") + form := formdata.New().File("file", "x", errReader{err: boom}) + if _, err := form.Build(); !errors.Is(err, boom) { + t.Fatalf("Build err = %v, want boom", err) + } +} + +func TestFormBuildTwiceErrors(t *testing.T) { + t.Parallel() + + form := formdata.New().Field("k", "v") + if _, err := form.Build(); err != nil { + t.Fatalf("first Build: %v", err) + } + if _, err := form.Build(); err == nil { + t.Fatal("second Build should error (already built)") + } +} + +func TestFormFieldAfterBuildErrors(t *testing.T) { + t.Parallel() + + form := formdata.New().Field("k", "v") + if _, err := form.Build(); err != nil { + t.Fatalf("Build: %v", err) + } + form.Field("late", "x") + if _, err := form.Build(); err == nil { + t.Fatal("Build after a post-build Field should error") + } +} + +func TestFormEmpty(t *testing.T) { + t.Parallel() + + form := formdata.New() + body, err := form.Build() + if err != nil { + t.Fatalf("empty Build: %v", err) + } + fields, files := partsOf(t, form.ContentType(), body) + if len(fields) != 0 || len(files) != 0 { + t.Fatalf("empty form has parts: fields=%v files=%v", fields, files) + } +}