From c1426073db9cd161e59d2b3685ee8878134a7785 Mon Sep 17 00:00:00 2001 From: franklin Date: Tue, 21 Apr 2026 00:28:35 +0200 Subject: [PATCH 01/29] refactor: replace Codec[T] interface with Codec[S,T] function-type struct Replace single-param interfaces with two-param function types: - Encoder[S,T] func type, Decoder[S,T] func type - Codec[S,T] struct bundling Encode/Decode - StreamEncoder[S], StreamDecoder[S] func types - StreamCodec[S] struct bundling stream Encode/Decode - Remove old Encoder[T], Decoder[T] interfaces and Func wrappers --- codec.go | 16 +- decoder.go | 5 - decoderfunc.go | 7 - .../2026-04-21-generic-codec-interface.md | 1087 +++++++++++++++++ ...26-04-21-generic-codec-interface-design.md | 226 ++++ encoder.go | 5 - encoderfunc.go | 7 - skills-lock.json | 10 + streamcodec.go | 20 +- streamdecoder.go | 9 - streamdecoderfunc.go | 11 - streamencoder.go | 9 - streamencoderfunc.go | 11 - 13 files changed, 1345 insertions(+), 78 deletions(-) delete mode 100644 decoder.go delete mode 100644 decoderfunc.go create mode 100644 docs/superpowers/plans/2026-04-21-generic-codec-interface.md create mode 100644 docs/superpowers/specs/2026-04-21-generic-codec-interface-design.md delete mode 100644 encoder.go delete mode 100644 encoderfunc.go create mode 100644 skills-lock.json delete mode 100644 streamdecoder.go delete mode 100644 streamdecoderfunc.go delete mode 100644 streamencoder.go delete mode 100644 streamencoderfunc.go diff --git a/codec.go b/codec.go index 1ebf401..19f47f8 100644 --- a/codec.go +++ b/codec.go @@ -1,9 +1,13 @@ package goencode -// Codec encodes T to []byte and decodes []byte back to T. -type Codec[T any] interface { - // Encode encodes v into bytes. - Encode(v T) ([]byte, error) - // Decode decodes b into v. - Decode(b []byte, v *T) error +// Encoder encodes source S to target T. +type Encoder[S, T any] func(s S) (T, error) + +// Decoder decodes target T back into source S. +type Decoder[S, T any] func(t T, s *S) error + +// Codec bundles an Encoder and Decoder for S ↔ T round-trips. +type Codec[S, T any] struct { + Encode Encoder[S, T] + Decode Decoder[S, T] } diff --git a/decoder.go b/decoder.go deleted file mode 100644 index 33258d5..0000000 --- a/decoder.go +++ /dev/null @@ -1,5 +0,0 @@ -package goencode - -type Decoder[T any] interface { - Decode(v any) error -} diff --git a/decoderfunc.go b/decoderfunc.go deleted file mode 100644 index f5277dc..0000000 --- a/decoderfunc.go +++ /dev/null @@ -1,7 +0,0 @@ -package goencode - -type DecoderFunc[T any] func(v any) error - -func (f DecoderFunc[T]) Decode(v any) error { - return f(v) -} diff --git a/docs/superpowers/plans/2026-04-21-generic-codec-interface.md b/docs/superpowers/plans/2026-04-21-generic-codec-interface.md new file mode 100644 index 0000000..fab5626 --- /dev/null +++ b/docs/superpowers/plans/2026-04-21-generic-codec-interface.md @@ -0,0 +1,1087 @@ +# Generic Codec Interface Redesign — 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:** Replace single-param `Codec[T]`/`StreamCodec[T]` interfaces with two-param `Codec[S, T]`/`StreamCodec[S]` function-type structs, enabling type-safe codec composition via `Pipe`. + +**Architecture:** Core types become function types (`Encoder[S, T]`, `Decoder[S, T]`) bundled into structs (`Codec[S, T]`, `StreamCodec[S]`). Composition via free `Pipe*` functions. Compression codecs become standalone `Codec[[]byte, []byte]` instead of decorators. File codec stays as decorator with own signature. + +**Tech Stack:** Go 1.24+, generics, no new dependencies. + +**Spec:** `docs/superpowers/specs/2026-04-21-generic-codec-interface-design.md` + +--- + +## File Structure + +### Root package (`github.com/foomo/goencode`) + +| Action | File | Responsibility | +|--------|------|---------------| +| Rewrite | `codec.go` | `Codec[S, T]` struct, `Encoder[S, T]` func type, `Decoder[S, T]` func type | +| Rewrite | `streamcodec.go` | `StreamCodec[S]` struct, `StreamEncoder[S]` func type, `StreamDecoder[S]` func type | +| Create | `pipe.go` | `PipeEncoder`, `PipeDecoder`, `PipeCodec` functions | +| Delete | `encoder.go` | Old `Encoder[T]` interface — replaced by `Encoder[S, T]` func type in codec.go | +| Delete | `decoder.go` | Old `Decoder[T]` interface — replaced by `Decoder[S, T]` func type in codec.go | +| Delete | `encoderfunc.go` | Old `EncoderFunc[T]` — redundant | +| Delete | `decoderfunc.go` | Old `DecoderFunc[T]` — redundant | +| Delete | `streamencoder.go` | Old `StreamEncoder[T]` interface — replaced | +| Delete | `streamdecoder.go` | Old `StreamDecoder[T]` interface — replaced | +| Delete | `streamencoderfunc.go` | Old `StreamEncoderFunc[T]` — redundant | +| Delete | `streamdecoderfunc.go` | Old `StreamDecoderFunc[T]` — redundant | + +### Subpackages (each follows same pattern) + +| Category | Packages | Codec change | StreamCodec change | +|----------|----------|-------------|-------------------| +| Serialization | `json/v1`, `json/v2`, `xml`, `gob`, `asn1` | Return `goencode.Codec[T, []byte]` | Return `goencode.StreamCodec[T]` | +| Encoding | `base64`, `base32`, `hex`, `ascii85`, `pem` | Return `goencode.Codec[[]byte, []byte]` | Return `goencode.StreamCodec[[]byte]` | +| CSV | `csv` | Return `goencode.Codec[[][]string, []byte]` | Return `goencode.StreamCodec[[][]string]` | +| Compression | `gzip`, `flate`, `snappy`, `zstd`, `brotli` | Standalone `goencode.Codec[[]byte, []byte]` (no inner codec) | Standalone `goencode.StreamCodec[[]byte]` (no inner codec) | +| File | `file` | Keeps own `Codec[T]` struct wrapping `goencode.Codec[T, []byte]` | Keeps own `StreamCodec[T]` wrapping `goencode.StreamCodec[T]` | +| YAML | `yaml/v2`, `yaml/v3`, `yaml/v4` | Return `goencode.Codec[T, []byte]` | N/A (no stream codecs) | +| Msgpack | `msgpack/tinylib`, `msgpack/vmihailenco` | Return `goencode.Codec[T, []byte]` | Return `goencode.StreamCodec[T]` | + +--- + +### Task 1: Rewrite Root Package Core Types + +**Files:** +- Rewrite: `codec.go` +- Rewrite: `streamcodec.go` +- Delete: `encoder.go`, `decoder.go`, `encoderfunc.go`, `decoderfunc.go`, `streamencoder.go`, `streamdecoder.go`, `streamencoderfunc.go`, `streamdecoderfunc.go` + +- [ ] **Step 1: Delete obsolete files** + +```bash +cd /Users/franklin/Workingcopies/github.com/foomo/goencode +rm encoder.go decoder.go encoderfunc.go decoderfunc.go \ + streamencoder.go streamdecoder.go streamencoderfunc.go streamdecoderfunc.go +``` + +- [ ] **Step 2: Rewrite `codec.go`** + +```go +package goencode + +// Encoder encodes source S to target T. +type Encoder[S, T any] func(s S) (T, error) + +// Decoder decodes target T back into source S. +type Decoder[S, T any] func(t T, s *S) error + +// Codec bundles an Encoder and Decoder for S ↔ T round-trips. +type Codec[S, T any] struct { + Encode Encoder[S, T] + Decode Decoder[S, T] +} +``` + +- [ ] **Step 3: Rewrite `streamcodec.go`** + +```go +package goencode + +import "io" + +// StreamEncoder encodes S into an io.Writer. +type StreamEncoder[S any] func(w io.Writer, s S) error + +// StreamDecoder decodes S from an io.Reader. +type StreamDecoder[S any] func(r io.Reader, s *S) error + +// StreamCodec bundles streaming encode/decode for S. +type StreamCodec[S any] struct { + Encode StreamEncoder[S] + Decode StreamDecoder[S] +} +``` + +- [ ] **Step 4: Verify root package compiles** + +Run: `cd /Users/franklin/Workingcopies/github.com/foomo/goencode && go build ./...` + +Expected: Compilation errors in subpackages (they still reference old types). Root package itself should compile. + +Note: Use `go build .` (root only) to verify just the root package, since subpackages will fail until migrated. + +Run: `go build .` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add -A && git commit -m "refactor: replace Codec[T] interface with Codec[S,T] function-type struct + +Replace single-param interfaces with two-param function types: +- Encoder[S,T] func type, Decoder[S,T] func type +- Codec[S,T] struct bundling Encode/Decode +- StreamEncoder[S], StreamDecoder[S] func types +- StreamCodec[S] struct bundling stream Encode/Decode +- Remove old Encoder[T], Decoder[T] interfaces and Func wrappers" +``` + +--- + +### Task 2: Add Pipe Composition Functions + +**Files:** +- Create: `pipe.go` +- Create: `pipe_test.go` + +- [ ] **Step 1: Write `pipe_test.go`** + +```go +package goencode_test + +import ( + "fmt" + "strconv" + "testing" + + goencode "github.com/foomo/goencode" +) + +func TestPipeEncoder(t *testing.T) { + intToStr := goencode.Encoder[int, string](func(i int) (string, error) { + return strconv.Itoa(i), nil + }) + strToBytes := goencode.Encoder[string, []byte](func(s string) ([]byte, error) { + return []byte(s), nil + }) + + piped := goencode.PipeEncoder(intToStr, strToBytes) + + got, err := piped(42) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(got) != "42" { + t.Fatalf("got %q, want %q", string(got), "42") + } +} + +func TestPipeEncoder_FirstError(t *testing.T) { + failing := goencode.Encoder[int, string](func(i int) (string, error) { + return "", fmt.Errorf("encode failed") + }) + second := goencode.Encoder[string, []byte](func(s string) ([]byte, error) { + t.Fatal("second encoder should not be called") + return nil, nil + }) + + piped := goencode.PipeEncoder(failing, second) + + _, err := piped(42) + if err == nil { + t.Fatal("expected error") + } +} + +func TestPipeDecoder(t *testing.T) { + strToInt := goencode.Decoder[int, string](func(s string, i *int) error { + v, err := strconv.Atoi(s) + if err != nil { + return err + } + *i = v + return nil + }) + bytesToStr := goencode.Decoder[string, []byte](func(b []byte, s *string) error { + *s = string(b) + return nil + }) + + piped := goencode.PipeDecoder(strToInt, bytesToStr) + + var got int + if err := piped([]byte("42"), &got); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != 42 { + t.Fatalf("got %d, want 42", got) + } +} + +func TestPipeCodec(t *testing.T) { + intStr := goencode.Codec[int, string]{ + Encode: func(i int) (string, error) { + return strconv.Itoa(i), nil + }, + Decode: func(s string, i *int) error { + v, err := strconv.Atoi(s) + if err != nil { + return err + } + *i = v + return nil + }, + } + strBytes := goencode.Codec[string, []byte]{ + Encode: func(s string) ([]byte, error) { + return []byte(s), nil + }, + Decode: func(b []byte, s *string) error { + *s = string(b) + return nil + }, + } + + piped := goencode.PipeCodec(intStr, strBytes) + + encoded, err := piped.Encode(42) + if err != nil { + t.Fatalf("encode error: %v", err) + } + if string(encoded) != "42" { + t.Fatalf("encoded: got %q, want %q", string(encoded), "42") + } + + var decoded int + if err := piped.Decode(encoded, &decoded); err != nil { + t.Fatalf("decode error: %v", err) + } + if decoded != 42 { + t.Fatalf("decoded: got %d, want 42", decoded) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd /Users/franklin/Workingcopies/github.com/foomo/goencode && go test -tags=safe -run TestPipe -v .` +Expected: FAIL — `PipeEncoder`, `PipeDecoder`, `PipeCodec` not defined + +- [ ] **Step 3: Write `pipe.go`** + +```go +package goencode + +// PipeEncoder chains two encoders: A → B → C. +func PipeEncoder[A, B, C any](first Encoder[A, B], second Encoder[B, C]) Encoder[A, C] { + return func(a A) (C, error) { + b, err := first(a) + if err != nil { + var zero C + return zero, err + } + return second(b) + } +} + +// PipeDecoder chains two decoders in reverse: decodes C → B via second, then B → A via first. +func PipeDecoder[A, B, C any](first Decoder[A, B], second Decoder[B, C]) Decoder[A, C] { + return func(c C, a *A) error { + var b B + if err := second(c, &b); err != nil { + return err + } + return first(b, a) + } +} + +// PipeCodec chains two codecs: Codec[A,B] + Codec[B,C] → Codec[A,C]. +func PipeCodec[A, B, C any](first Codec[A, B], second Codec[B, C]) Codec[A, C] { + return Codec[A, C]{ + Encode: PipeEncoder(first.Encode, second.Encode), + Decode: PipeDecoder(first.Decode, second.Decode), + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd /Users/franklin/Workingcopies/github.com/foomo/goencode && go test -tags=safe -run TestPipe -v .` +Expected: PASS (all 4 tests) + +- [ ] **Step 5: Commit** + +```bash +git add pipe.go pipe_test.go && git commit -m "feat: add Pipe composition functions for Encoder, Decoder, Codec" +``` + +--- + +### Task 3: Migrate Serialization Codecs (json/v1, xml, gob, asn1) + +These all follow the same pattern: stateless generic codec returning `Codec[T, []byte]`. + +**Files:** +- Modify: `json/v1/codec.go` +- Modify: `json/v1/streamcodec.go` +- Modify: `json/v1/codec_test.go` +- Modify: `json/v1/streamcodec_test.go` +- Modify: `xml/codec.go` (same pattern) +- Modify: `xml/streamcodec.go` (same pattern, if exists) +- Modify: `gob/streamcodec.go` +- Modify: `asn1/codec.go` +- Modify: `asn1/streamcodec.go` +- Modify: all corresponding `*_test.go` and `benchmark_test.go` files + +- [ ] **Step 1: Rewrite `json/v1/codec.go`** + +```go +package json + +import ( + "encoding/json" + + encoding "github.com/foomo/goencode" +) + +// NewCodec returns a JSON codec for T. +// It is safe for concurrent use. +func NewCodec[T any]() encoding.Codec[T, []byte] { + return encoding.Codec[T, []byte]{ + Encode: func(v T) ([]byte, error) { + return json.Marshal(v) + }, + Decode: func(b []byte, v *T) error { + return json.Unmarshal(b, v) + }, + } +} +``` + +- [ ] **Step 2: Rewrite `json/v1/streamcodec.go`** + +```go +package json + +import ( + "encoding/json" + "io" + + encoding "github.com/foomo/goencode" +) + +// NewStreamCodec returns a JSON stream codec for T. +// It is safe for concurrent use. +func NewStreamCodec[T any]() encoding.StreamCodec[T] { + return encoding.StreamCodec[T]{ + Encode: func(w io.Writer, v T) error { + return json.NewEncoder(w).Encode(v) + }, + Decode: func(r io.Reader, v *T) error { + return json.NewDecoder(r).Decode(v) + }, + } +} +``` + +- [ ] **Step 3: Update `json/v1/codec_test.go`** + +```go +package json_test + +import ( + "fmt" + + "github.com/foomo/goencode/json/v1" +) + +func ExampleNewCodec() { + type Data struct { + Name string + } + + c := json.NewCodec[Data]() + + encoded, err := c.Encode(Data{Name: "example-123"}) + if err != nil { + fmt.Printf("Encode failed: %v\n", err) + return + } + + fmt.Printf("Encoded: %s\n", string(encoded)) + + var decoded Data + if err := c.Decode(encoded, &decoded); err != nil { + fmt.Printf("Decode failed: %v\n", err) + return + } + + fmt.Printf("Decoded Name: %s\n", decoded.Name) + // Output: + // Encoded: {"Name":"example-123"} + // Decoded Name: example-123 +} +``` + +Note: Example function name changes from `ExampleCodec` to `ExampleNewCodec` because there is no longer a `Codec` exported type — only `NewCodec` constructor. + +- [ ] **Step 4: Migrate xml, gob, asn1 codecs using same pattern** + +For each package, replace the struct type + methods with a constructor returning `encoding.Codec[T, []byte]` or `encoding.StreamCodec[T]`. + +**`xml/codec.go`** — same as json/v1 but uses `encoding/xml.Marshal`/`Unmarshal`. Note: current xml codec uses `bufpool.sync` for encoding — keep that optimization by using a closure that captures the pool usage. + +**`gob/streamcodec.go`** — returns `encoding.StreamCodec[T]` using `gob.NewEncoder`/`gob.NewDecoder`. + +**`asn1/codec.go`** — uses `encoding/asn1.Marshal`/`Unmarshal`. Note: asn1.Unmarshal returns `(rest []byte, err error)` — keep the existing discard-rest pattern. + +Delete the old struct types (`Codec[T]`, `StreamCodec[T]`) from each package — they are replaced by the constructor functions. + +- [ ] **Step 5: Run tests for all migrated packages** + +Run: `cd /Users/franklin/Workingcopies/github.com/foomo/goencode && go test -tags=safe ./json/v1/... ./xml/... ./gob/... ./asn1/...` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add json/v1/ xml/ gob/ asn1/ && git commit -m "refactor: migrate json/v1, xml, gob, asn1 to Codec[S,T] function types" +``` + +--- + +### Task 4: Migrate Encoding Codecs (base64, base32, hex, ascii85, pem) + +These are non-generic, operating on `[]byte` → `[]byte` (or `*pem.Block` for pem). + +**Files:** +- Modify: `base64/codec.go`, `base64/streamcodec.go` +- Modify: `base32/codec.go`, `base32/streamcodec.go` +- Modify: `hex/codec.go`, `hex/streamcodec.go` +- Modify: `ascii85/codec.go`, `ascii85/streamcodec.go` +- Modify: `pem/streamcodec.go` +- Modify: all corresponding test and benchmark files + +- [ ] **Step 1: Rewrite `base64/codec.go`** + +```go +package base64 + +import ( + stdbase64 "encoding/base64" + + encoding "github.com/foomo/goencode" +) + +// NewCodec returns a Base64 codec. +// It is safe for concurrent use. +func NewCodec() encoding.Codec[[]byte, []byte] { + return encoding.Codec[[]byte, []byte]{ + Encode: func(v []byte) ([]byte, error) { + dst := make([]byte, stdbase64.StdEncoding.EncodedLen(len(v))) + stdbase64.StdEncoding.Encode(dst, v) + return dst, nil + }, + Decode: func(b []byte, v *[]byte) error { + dst := make([]byte, stdbase64.StdEncoding.DecodedLen(len(b))) + n, err := stdbase64.StdEncoding.Decode(dst, b) + if err != nil { + return err + } + *v = dst[:n] + return nil + }, + } +} +``` + +- [ ] **Step 2: Rewrite `base64/streamcodec.go`** + +```go +package base64 + +import ( + stdbase64 "encoding/base64" + "io" + + encoding "github.com/foomo/goencode" +) + +// NewStreamCodec returns a Base64 stream codec. +// It is safe for concurrent use. +func NewStreamCodec() encoding.StreamCodec[[]byte] { + return encoding.StreamCodec[[]byte]{ + Encode: func(w io.Writer, v []byte) error { + enc := stdbase64.NewEncoder(stdbase64.StdEncoding, w) + if _, err := enc.Write(v); err != nil { + _ = enc.Close() + return err + } + return enc.Close() + }, + Decode: func(r io.Reader, v *[]byte) error { + data, err := io.ReadAll(stdbase64.NewDecoder(stdbase64.StdEncoding, r)) + if err != nil { + return err + } + *v = data + return nil + }, + } +} +``` + +- [ ] **Step 3: Migrate base32, hex, ascii85, pem using same pattern** + +Each follows the same structure — replace struct + methods with constructor returning `encoding.Codec[[]byte, []byte]` and `encoding.StreamCodec[[]byte]`. + +**pem** is special: operates on `*pem.Block` not `[]byte`. Returns `encoding.Codec[*pem.Block, []byte]` and `encoding.StreamCodec[*pem.Block]`. + +Delete old struct types from each package. + +- [ ] **Step 4: Update test files** + +Rename example functions from `ExampleCodec`/`ExampleStreamCodec` to `ExampleNewCodec`/`ExampleNewStreamCodec` since the exported type is now the constructor, not the struct. + +- [ ] **Step 5: Run tests** + +Run: `cd /Users/franklin/Workingcopies/github.com/foomo/goencode && go test -tags=safe ./base64/... ./base32/... ./hex/... ./ascii85/... ./pem/...` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add base64/ base32/ hex/ ascii85/ pem/ && git commit -m "refactor: migrate encoding codecs (base64, base32, hex, ascii85, pem) to Codec[S,T]" +``` + +--- + +### Task 5: Migrate CSV Codec + +CSV is special — operates on `[][]string`. + +**Files:** +- Modify: `csv/streamcodec.go` +- Modify: `csv/codec_test.go`, `csv/streamcodec_test.go`, `csv/benchmark_test.go` + +- [ ] **Step 1: Rewrite `csv/streamcodec.go`** + +CSV only has a StreamCodec. Return `encoding.StreamCodec[[][]string]`. + +```go +package csv + +import ( + "encoding/csv" + "io" + + encoding "github.com/foomo/goencode" +) + +// NewStreamCodec returns a CSV stream codec for [][]string. +// It is safe for concurrent use. +func NewStreamCodec() encoding.StreamCodec[[][]string] { + return encoding.StreamCodec[[][]string]{ + Encode: func(w io.Writer, v [][]string) error { + return csv.NewWriter(w).WriteAll(v) + }, + Decode: func(r io.Reader, v *[][]string) error { + records, err := csv.NewReader(r).ReadAll() + if err != nil { + return err + } + *v = records + return nil + }, + } +} +``` + +Note: Check if csv also has a `Codec` (non-stream). If so, migrate it similarly to return `encoding.Codec[[][]string, []byte]`. + +- [ ] **Step 2: Update test files, run tests** + +Run: `cd /Users/franklin/Workingcopies/github.com/foomo/goencode && go test -tags=safe ./csv/...` +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add csv/ && git commit -m "refactor: migrate csv codec to StreamCodec[S] function type" +``` + +--- + +### Task 6: Migrate Compression Codecs (gzip, flate, snappy, zstd, brotli) + +Biggest change: remove decorator pattern. Each becomes standalone `Codec[[]byte, []byte]`. + +**Files:** +- Rewrite: `gzip/codec.go`, `gzip/streamcodec.go` +- Rewrite: `flate/codec.go`, `flate/streamcodec.go` +- Rewrite: `snappy/codec.go`, `snappy/streamcodec.go` +- Rewrite: `zstd/codec.go`, `zstd/streamcodec.go` +- Rewrite: `brotli/codec.go`, `brotli/streamcodec.go` +- Keep: `gzip/option.go`, `flate/option.go`, `zstd/option.go`, `brotli/option.go` (unchanged) +- Modify: all corresponding test and benchmark files + +- [ ] **Step 1: Rewrite `gzip/codec.go`** + +```go +package gzip + +import ( + "bytes" + "compress/gzip" + "fmt" + "io" + + encoding "github.com/foomo/goencode" + "github.com/foomo/goencode/internal/sync" +) + +// NewCodec returns a gzip compression codec. +// It is safe for concurrent use. +func NewCodec(opts ...Option) encoding.Codec[[]byte, []byte] { + o := options{ + level: gzip.DefaultCompression, + } + for _, opt := range opts { + opt(&o) + } + + return encoding.Codec[[]byte, []byte]{ + Encode: func(data []byte) ([]byte, error) { + buf := sync.Get() + defer sync.Put(buf) + + w, err := gzip.NewWriterLevel(buf, o.level) + if err != nil { + return nil, err + } + + if _, err := w.Write(data); err != nil { + return nil, err + } + + if err := w.Close(); err != nil { + return nil, err + } + + return append([]byte(nil), buf.Bytes()...), nil + }, + Decode: func(data []byte, v *[]byte) error { + r, err := gzip.NewReader(bytes.NewReader(data)) + if err != nil { + return err + } + defer r.Close() + + var src io.Reader = r + if o.maxDecodedSize > 0 { + src = io.LimitReader(r, o.maxDecodedSize+1) + } + + decoded, err := io.ReadAll(src) + if err != nil { + return err + } + + if o.maxDecodedSize > 0 && int64(len(decoded)) > o.maxDecodedSize { + return fmt.Errorf("gzip: decompressed size exceeds limit of %d bytes", o.maxDecodedSize) + } + + *v = decoded + return nil + }, + } +} +``` + +- [ ] **Step 2: Rewrite `gzip/streamcodec.go`** + +```go +package gzip + +import ( + "compress/gzip" + "io" + + encoding "github.com/foomo/goencode" +) + +// NewStreamCodec returns a gzip compression stream codec. +// It is safe for concurrent use. +func NewStreamCodec(opts ...Option) encoding.StreamCodec[[]byte] { + o := options{ + level: gzip.DefaultCompression, + } + for _, opt := range opts { + opt(&o) + } + + return encoding.StreamCodec[[]byte]{ + Encode: func(w io.Writer, data []byte) error { + gw, err := gzip.NewWriterLevel(w, o.level) + if err != nil { + return err + } + + if _, err := gw.Write(data); err != nil { + gw.Close() + return err + } + + return gw.Close() + }, + Decode: func(r io.Reader, v *[]byte) error { + gr, err := gzip.NewReader(r) + if err != nil { + return err + } + defer gr.Close() + + var src io.Reader = gr + if o.maxDecodedSize > 0 { + src = io.LimitReader(gr, o.maxDecodedSize+1) + } + + data, err := io.ReadAll(src) + if err != nil { + return err + } + + if o.maxDecodedSize > 0 && int64(len(data)) > o.maxDecodedSize { + return fmt.Errorf("gzip: decompressed size exceeds limit of %d bytes", o.maxDecodedSize) + } + + *v = data + return nil + }, + } +} +``` + +- [ ] **Step 3: Migrate flate, snappy, zstd, brotli using same pattern** + +Each compression codec follows the same transformation: +- Remove generic type param `[T any]` +- Remove inner `codec` field +- Return `encoding.Codec[[]byte, []byte]` / `encoding.StreamCodec[[]byte]` +- Encode/Decode operate directly on `[]byte` +- Keep option.go files unchanged + +**snappy** is simplest — no options, just `NewCodec() encoding.Codec[[]byte, []byte]`. + +**zstd, brotli** — same pattern as gzip, use their respective compression libraries. + +- [ ] **Step 4: Update test files** + +Tests change from decorator pattern to standalone + Pipe: + +```go +// Old test pattern +c := gzip.NewCodec(json.NewCodec[Data]()) +encoded, _ := c.Encode(Data{Name: "test"}) + +// New test pattern — test gzip standalone +c := gzip.NewCodec() +encoded, _ := c.Encode([]byte(`{"Name":"test"}`)) +var decoded []byte +_ = c.Decode(encoded, &decoded) +// assert decoded == original bytes + +// New test pattern — test with Pipe +combined := goencode.PipeCodec(json.NewCodec[Data](), gzip.NewCodec()) +encoded, _ := combined.Encode(Data{Name: "test"}) +var decoded Data +_ = combined.Decode(encoded, &decoded) +``` + +Update all `*_test.go` and `benchmark_test.go` files accordingly. + +- [ ] **Step 5: Run tests** + +Run: `cd /Users/franklin/Workingcopies/github.com/foomo/goencode && go test -tags=safe ./gzip/... ./flate/... ./snappy/... ./zstd/... ./brotli/...` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add gzip/ flate/ snappy/ zstd/ brotli/ && git commit -m "refactor: make compression codecs standalone Codec[[]byte,[]byte] + +Remove decorator pattern. Each compression codec now operates on raw +bytes. Use goencode.PipeCodec() to compose with serialization codecs." +``` + +--- + +### Task 7: Migrate File Codec + +File codec stays as decorator — wraps `Codec[T, []byte]` with own signature. + +**Files:** +- Modify: `file/codec.go` +- Modify: `file/streamcodec.go` +- Modify: `file/option.go` (likely unchanged) +- Modify: `file/codec_test.go`, `file/streamcodec_test.go` + +- [ ] **Step 1: Update `file/codec.go`** + +Only change: the inner codec type from `encoding.Codec[T]` to `encoding.Codec[T, []byte]`. + +```go +package file + +import ( + "fmt" + "os" + "path/filepath" + + encoding "github.com/foomo/goencode" +) + +// Codec encodes T to a file and decodes T from a file using an underlying Codec[T, []byte]. +// Writes are atomic: data is written to a temporary file and renamed into place. +// It is safe for concurrent use. +type Codec[T any] struct { + codec encoding.Codec[T, []byte] + perm os.FileMode +} + +// NewCodec returns a file codec that delegates serialization to codec. +func NewCodec[T any](codec encoding.Codec[T, []byte], opts ...Option) *Codec[T] { + o := options{ + perm: 0o644, + } + for _, opt := range opts { + opt(&o) + } + + return &Codec[T]{ + codec: codec, + perm: o.perm, + } +} + +// Encode serializes v and atomically writes the result to path. +func (c *Codec[T]) Encode(path string, v T) error { + b, err := c.codec.Encode(v) + if err != nil { + return err + } + + dir := filepath.Dir(path) + + f, err := os.CreateTemp(dir, ".tmp-*") + if err != nil { + return fmt.Errorf("creating temp file: %w", err) + } + + tmp := f.Name() + + if _, err := f.Write(b); err != nil { + f.Close() + os.Remove(tmp) + return fmt.Errorf("writing temp file: %w", err) + } + + if err := f.Close(); err != nil { + os.Remove(tmp) + return fmt.Errorf("closing temp file: %w", err) + } + + if err := os.Chmod(tmp, c.perm); err != nil { + os.Remove(tmp) + return fmt.Errorf("setting file permissions: %w", err) + } + + if err := os.Rename(tmp, path); err != nil { + os.Remove(tmp) + return fmt.Errorf("renaming temp file: %w", err) + } + + return nil +} + +// Decode reads the file at path and deserializes its contents into v. +func (c *Codec[T]) Decode(path string, v *T) error { + b, err := os.ReadFile(path) + if err != nil { + return err + } + + return c.codec.Decode(b, v) +} +``` + +- [ ] **Step 2: Update `file/streamcodec.go`** + +Same change: inner codec type from `encoding.StreamCodec[T]` to `encoding.StreamCodec[T]` (StreamCodec signature is unchanged — still single param). + +```go +package file + +import ( + "fmt" + "os" + "path/filepath" + + encoding "github.com/foomo/goencode" +) + +// StreamCodec encodes T to a file and decodes T from a file using an underlying StreamCodec[T]. +// Writes are atomic: data is written to a temporary file and renamed into place. +// It is safe for concurrent use. +type StreamCodec[T any] struct { + codec encoding.StreamCodec[T] + perm os.FileMode +} + +// NewStreamCodec returns a file stream codec that delegates serialization to codec. +func NewStreamCodec[T any](codec encoding.StreamCodec[T], opts ...Option) *StreamCodec[T] { + o := options{ + perm: 0o644, + } + for _, opt := range opts { + opt(&o) + } + + return &StreamCodec[T]{ + codec: codec, + perm: o.perm, + } +} + +// Encode serializes v and atomically writes the result to path. +func (c *StreamCodec[T]) Encode(path string, v T) error { + dir := filepath.Dir(path) + + f, err := os.CreateTemp(dir, ".tmp-*") + if err != nil { + return fmt.Errorf("creating temp file: %w", err) + } + + tmp := f.Name() + + if err := c.codec.Encode(f, v); err != nil { + f.Close() + os.Remove(tmp) + return fmt.Errorf("encoding to temp file: %w", err) + } + + if err := f.Close(); err != nil { + os.Remove(tmp) + return fmt.Errorf("closing temp file: %w", err) + } + + if err := os.Chmod(tmp, c.perm); err != nil { + os.Remove(tmp) + return fmt.Errorf("setting file permissions: %w", err) + } + + if err := os.Rename(tmp, path); err != nil { + os.Remove(tmp) + return fmt.Errorf("renaming temp file: %w", err) + } + + return nil +} + +// Decode reads the file at path and deserializes its contents into v. +func (c *StreamCodec[T]) Decode(path string, v *T) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + + return c.codec.Decode(f, v) +} +``` + +- [ ] **Step 3: Update tests — constructor call stays same** + +The file codec test should work with minimal changes since the API is the same. The only difference is `json.NewCodec[Data]()` now returns a struct instead of interface — but Go handles this transparently. + +- [ ] **Step 4: Run tests** + +Run: `cd /Users/franklin/Workingcopies/github.com/foomo/goencode && go test -tags=safe ./file/...` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add file/ && git commit -m "refactor: update file codec to accept Codec[T, []byte]" +``` + +--- + +### Task 8: Migrate Submodule Codecs (json/v2, yaml/v2, yaml/v3, yaml/v4, msgpack/*) + +These are separate go.mod modules with external dependencies. + +**Files:** +- Modify: `json/v2/codec.go` +- Modify: `yaml/v2/codec.go`, `yaml/v3/codec.go`, `yaml/v4/codec.go` +- Modify: `msgpack/tinylib/codec.go`, `msgpack/tinylib/streamcodec.go` +- Modify: `msgpack/vmihailenco/codec.go`, `msgpack/vmihailenco/streamcodec.go` +- Modify: all corresponding test and benchmark files + +- [ ] **Step 1: Migrate `json/v2/codec.go`** + +Same pattern as json/v1 but uses `github.com/go-json-experiment/json`. Return `encoding.Codec[T, []byte]`. If it has additional methods (`EncodeTo`/`DecodeFrom`), those can be dropped since StreamCodec covers streaming. + +- [ ] **Step 2: Migrate yaml codecs** + +All three yaml versions follow identical pattern — return `encoding.Codec[T, []byte]`. + +- [ ] **Step 3: Migrate msgpack codecs** + +**msgpack/tinylib** — has type constraints (`msgp.Marshaler`/`msgp.Unmarshaler`). The constraint stays but return type changes to `encoding.Codec[T, []byte]`. Keep the type assertion checks in the constructor. + +**msgpack/vmihailenco** — standard pattern, return `encoding.Codec[T, []byte]`. + +- [ ] **Step 4: Update go.mod files** + +Each submodule's `go.mod` references the root module. Run `go mod tidy` in each: + +```bash +for dir in json/v2 yaml/v2 yaml/v3 yaml/v4 msgpack/tinylib msgpack/vmihailenco; do + (cd "$dir" && go mod tidy) +done +``` + +- [ ] **Step 5: Update tests, run all** + +Run: `cd /Users/franklin/Workingcopies/github.com/foomo/goencode && go test -tags=safe ./json/v2/... ./yaml/v2/... ./yaml/v3/... ./yaml/v4/... ./msgpack/tinylib/... ./msgpack/vmihailenco/...` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add json/v2/ yaml/ msgpack/ && git commit -m "refactor: migrate submodule codecs (json/v2, yaml, msgpack) to Codec[S,T]" +``` + +--- + +### Task 9: Full Test Suite & Lint + +**Files:** None new — validation only. + +- [ ] **Step 1: Run full test suite** + +Run: `cd /Users/franklin/Workingcopies/github.com/foomo/goencode && make test` +Expected: PASS + +- [ ] **Step 2: Run linter** + +Run: `cd /Users/franklin/Workingcopies/github.com/foomo/goencode && make lint` +Expected: PASS (or only pre-existing warnings) + +- [ ] **Step 3: Fix any lint issues** + +Address any new lint warnings introduced by the migration. + +- [ ] **Step 4: Run race detector** + +Run: `cd /Users/franklin/Workingcopies/github.com/foomo/goencode && make test.race` +Expected: PASS + +- [ ] **Step 5: Run full check** + +Run: `cd /Users/franklin/Workingcopies/github.com/foomo/goencode && make check` +Expected: PASS + +- [ ] **Step 6: Commit any fixes** + +```bash +git add -A && git commit -m "fix: address lint issues from codec interface migration" +``` + +(Skip if no fixes needed.) diff --git a/docs/superpowers/specs/2026-04-21-generic-codec-interface-design.md b/docs/superpowers/specs/2026-04-21-generic-codec-interface-design.md new file mode 100644 index 0000000..ff010d6 --- /dev/null +++ b/docs/superpowers/specs/2026-04-21-generic-codec-interface-design.md @@ -0,0 +1,226 @@ +# Generic Codec Interface Redesign + +**Date:** 2026-04-21 +**Status:** Draft + +## Summary + +Redesign goencode's core interfaces from single-param `Codec[T]` / `StreamCodec[T]` interfaces to two-param `Codec[S, T]` / `StreamCodec[S]` function-type structs. Enables type-safe codec composition via `Pipe`, unifies encoding/compression/file/conversion codecs under one model. + +## Motivation + +- Current `Codec[T]` hardcodes `[]byte` as target — file codec (`string` path) and base64 (`[]byte → []byte`) are special cases that don't fit the interface +- No way to compose codecs with type-safe piping (e.g., JSON → base64 → file) +- Type conversion codecs (e.g., `string ↔ int`) impossible under current interface +- Compression codecs unnecessarily coupled to inner codec via decorator pattern + +## Core Types + +### Primitives + +```go +package goencode + +import "io" + +// Encoder encodes source S to target T. +type Encoder[S, T any] func(s S) (T, error) + +// Decoder decodes target T back into source S. +type Decoder[S, T any] func(t T, s *S) error + +// StreamEncoder encodes S into an io.Writer. +type StreamEncoder[S any] func(w io.Writer, s S) error + +// StreamDecoder decodes S from an io.Reader. +type StreamDecoder[S any] func(r io.Reader, s *S) error +``` + +### Bundles + +```go +// Codec bundles an Encoder and Decoder for S ↔ T round-trips. +type Codec[S, T any] struct { + Encode Encoder[S, T] + Decode Decoder[S, T] +} + +// StreamCodec bundles streaming encode/decode for S. +type StreamCodec[S any] struct { + Encode StreamEncoder[S] + Decode StreamDecoder[S] +} +``` + +### Design Decisions + +- **Function types over interfaces**: most composable, least boilerplate. Closures capture config naturally. +- **Pointer baked into Decoder**: `Decoder[S, T]` signature is `func(t T, s *S) error` — pointer on S is implicit, avoids third type param. +- **StreamCodec stays single type param**: io.Writer/io.Reader are fixed, no need for `[S, T]`. +- **Structs not interfaces**: Codec/StreamCodec are struct bundles of function fields, not interface contracts. + +## Composition + +```go +// PipeEncoder chains two encoders: A → B → C. +func PipeEncoder[A, B, C any](first Encoder[A, B], second Encoder[B, C]) Encoder[A, C] { + return func(a A) (C, error) { + b, err := first(a) + if err != nil { + var zero C + return zero, err + } + return second(b) + } +} + +// PipeDecoder chains two decoders: C → B → A (reverse order). +func PipeDecoder[A, B, C any](first Decoder[A, B], second Decoder[B, C]) Decoder[A, C] { + return func(c C, a *A) error { + var b B + if err := second(c, &b); err != nil { + return err + } + return first(b, a) + } +} + +// PipeCodec chains two codecs: Codec[A,B] + Codec[B,C] → Codec[A,C]. +func PipeCodec[A, B, C any](first Codec[A, B], second Codec[B, C]) Codec[A, C] { + return Codec[A, C]{ + Encode: PipeEncoder(first.Encode, second.Encode), + Decode: PipeDecoder(first.Decode, second.Decode), + } +} +``` + +## Codec Migration + +### Serialization codecs (json, xml, gob, asn1, csv) + +```go +// Constructor signature unchanged, return type changes +func NewCodec[T any]() goencode.Codec[T, []byte] +func NewStreamCodec[T any]() goencode.StreamCodec[T] +``` + +### Encoding codecs (base64, base32, hex, ascii85, pem) + +```go +// Now fits naturally as []byte → []byte +func NewCodec() goencode.Codec[[]byte, []byte] +func NewStreamCodec() goencode.StreamCodec[[]byte] +``` + +### Compression codecs (gzip, flate, snappy, zstd) + +No longer decorators. Become standalone `Codec[[]byte, []byte]`, compose via Pipe. + +```go +// Before (decorator) +c := gzip.NewCodec(json.NewCodec[MyType]()) + +// After (Pipe composition) +c := goencode.PipeCodec(json.NewCodec[MyType](), gzip.NewCodec()) +``` + +```go +func NewCodec(opts ...Option) goencode.Codec[[]byte, []byte] +func NewStreamCodec(opts ...Option) goencode.StreamCodec[[]byte] +``` + +### File codec + +Becomes standalone `Codec[[]byte, string]` — compose via Pipe like compression codecs. + +```go +// []byte ↔ string (file path). Encode writes bytes to file, returns path. Decode reads file. +func NewCodec(opts ...Option) goencode.Codec[[]byte, string] +``` + +```go +// Usage: JSON → file via Pipe +full := goencode.PipeCodec(json.NewCodec[MyType](), file.NewCodec()) // Codec[MyType, string] +``` + +Note: Encode requires caller to provide the file path. Signature is `func(b []byte) (string, error)` — but file codec needs a path to write to. Options: pass path via `WithPath(p string)` option, or change to `Codec[[]byte, string]` where encode takes bytes and an option sets the target path. Alternative: keep decorator pattern for file codec since it needs write path context. **Decision: keep file codec as decorator** — it wraps an inner codec because it needs to control the full write-path lifecycle (temp file + rename). Unlike compression, file I/O is inherently stateful (needs path). + +```go +// File codec stays as wrapper — needs path context +func NewCodec[T any](codec goencode.Codec[T, []byte], opts ...Option) *Codec[T] + +type Codec[T any] struct { + codec goencode.Codec[T, []byte] + perm os.FileMode +} + +// Encode writes v to file at path atomically. Decode reads file at path into v. +func (c *Codec[T]) Encode(path string, v T) error +func (c *Codec[T]) Decode(path string, v *T) error +``` + +File codec does NOT implement `goencode.Codec[S, T]` — it has its own signature with path. This is acceptable: file I/O is fundamentally different from value transformations. + +### Type conversion codecs (new) + +New capability enabled by `Codec[S, T]`: + +```go +func NewStringIntCodec() goencode.Codec[string, int] +``` + +## Composition Examples + +```go +// JSON → base64 encoded +jsonCodec := json.NewCodec[MyType]() // Codec[MyType, []byte] +b64Codec := base64.NewCodec() // Codec[[]byte, []byte] +combined := goencode.PipeCodec(jsonCodec, b64Codec) // Codec[MyType, []byte] + +// JSON → gzip compressed +jsonCodec := json.NewCodec[MyType]() // Codec[MyType, []byte] +gzipCodec := gzip.NewCodec() // Codec[[]byte, []byte] +combined := goencode.PipeCodec(jsonCodec, gzipCodec) // Codec[MyType, []byte] + +// JSON → base64, then write to file +jsonB64 := goencode.PipeCodec(json.NewCodec[MyType](), base64.NewCodec()) // Codec[MyType, []byte] +fc := file.NewCodec(jsonB64) // file.Codec[MyType] +fc.Encode("/tmp/data.b64", myVal) // atomic write + +// Type conversion chaining +strToInt := conv.NewStringIntCodec() // Codec[string, int] +intToBytes := conv.NewIntBytesCodec() // Codec[int, []byte] +strToBytes := goencode.PipeCodec(strToInt, intToBytes) // Codec[string, []byte] +``` + +## Removed Types + +| Old | Replacement | +|-----|-------------| +| `Codec[T]` interface | `Codec[S, T]` struct | +| `StreamCodec[T]` interface | `StreamCodec[S]` struct | +| `Encoder[T]` interface | `Encoder[S, T]` func type | +| `Decoder[T]` interface | `Decoder[S, T]` func type | +| `EncoderFunc[T]` | Redundant — Encoder is already a func type | +| `DecoderFunc[T]` | Redundant — Decoder is already a func type | +| `StreamEncoder[T]` interface | `StreamEncoder[S]` func type | +| `StreamDecoder[T]` interface | `StreamDecoder[S]` func type | +| `StreamEncoderFunc[T]` | Redundant | +| `StreamDecoderFunc[T]` | Redundant | + +## Breaking Changes + +- All codec constructors return structs instead of interfaces +- Compression codecs no longer take inner codec — compose via `PipeCodec` +- File codec returns `Codec[T, string]` instead of custom interface +- All root package types replaced (see table above) +- Still alpha — no major version bump needed + +## Testing Strategy + +- **Round-trip tests** for every codec: encode then decode, assert equality +- **Pipe tests**: chain 2-3 codecs, verify round-trip through full pipeline +- **Error propagation**: first encoder fails → second never called +- **Decode reversal**: verify PipeDecoder applies decoders in reverse order +- **StreamCodec tests**: same pattern as today, unchanged +- **Benchmarks**: existing benchmark structure stays, update signatures diff --git a/encoder.go b/encoder.go deleted file mode 100644 index d4ee713..0000000 --- a/encoder.go +++ /dev/null @@ -1,5 +0,0 @@ -package goencode - -type Encoder[T any] interface { - Encode(v T) error -} diff --git a/encoderfunc.go b/encoderfunc.go deleted file mode 100644 index fc605dc..0000000 --- a/encoderfunc.go +++ /dev/null @@ -1,7 +0,0 @@ -package goencode - -type EncoderFunc[T any] func(v T) error - -func (f EncoderFunc[T]) Encode(v T) error { - return f(v) -} diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..b1c9c77 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "skills": { + "grill-me": { + "source": "mattpocock/skills", + "sourceType": "github", + "computedHash": "784f0dbb7403b0f00324bce9a112f715342777a0daee7bbb7385f9c6f0a170ea" + } + } +} diff --git a/streamcodec.go b/streamcodec.go index 672eb3b..6276cc1 100644 --- a/streamcodec.go +++ b/streamcodec.go @@ -1,11 +1,15 @@ package goencode -import ( - "io" -) - -// StreamCodec encodes T to an io.Writer and decodes T from an io.Reader. -type StreamCodec[T any] interface { - Encode(w io.Writer, v T) error - Decode(r io.Reader, v *T) error +import "io" + +// StreamEncoder encodes S into an io.Writer. +type StreamEncoder[S any] func(w io.Writer, s S) error + +// StreamDecoder decodes S from an io.Reader. +type StreamDecoder[S any] func(r io.Reader, s *S) error + +// StreamCodec bundles streaming encode/decode for S. +type StreamCodec[S any] struct { + Encode StreamEncoder[S] + Decode StreamDecoder[S] } diff --git a/streamdecoder.go b/streamdecoder.go deleted file mode 100644 index 0878629..0000000 --- a/streamdecoder.go +++ /dev/null @@ -1,9 +0,0 @@ -package goencode - -import ( - "io" -) - -type StreamDecoder[T any] interface { - Decode(r io.Reader, v *T) error -} diff --git a/streamdecoderfunc.go b/streamdecoderfunc.go deleted file mode 100644 index 7a48827..0000000 --- a/streamdecoderfunc.go +++ /dev/null @@ -1,11 +0,0 @@ -package goencode - -import ( - "io" -) - -type StreamDecoderFunc[T any] func(r io.Reader, v *T) error - -func (f StreamDecoderFunc[T]) Decode(r io.Reader, v *T) error { - return f(r, v) -} diff --git a/streamencoder.go b/streamencoder.go deleted file mode 100644 index f5b6831..0000000 --- a/streamencoder.go +++ /dev/null @@ -1,9 +0,0 @@ -package goencode - -import ( - "io" -) - -type StreamEncoder[T any] interface { - Encode(w io.Writer, v T) error -} diff --git a/streamencoderfunc.go b/streamencoderfunc.go deleted file mode 100644 index 0e06dad..0000000 --- a/streamencoderfunc.go +++ /dev/null @@ -1,11 +0,0 @@ -package goencode - -import ( - "io" -) - -type StreamEncoderFunc[T any] func(w io.Writer, v T) error - -func (f StreamEncoderFunc[T]) Encode(w io.Writer, v T) error { - return f(w, v) -} From 71da86cfc181bfc705ad4440ea9458ba77c09f01 Mon Sep 17 00:00:00 2001 From: franklin Date: Tue, 21 Apr 2026 00:30:00 +0200 Subject: [PATCH 02/29] feat: add Pipe composition functions for Encoder, Decoder, Codec --- pipe.go | 32 +++++++++++++++ pipe_test.go | 113 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 pipe.go create mode 100644 pipe_test.go diff --git a/pipe.go b/pipe.go new file mode 100644 index 0000000..5dda455 --- /dev/null +++ b/pipe.go @@ -0,0 +1,32 @@ +package goencode + +// PipeEncoder chains two encoders: A → B → C. +func PipeEncoder[A, B, C any](first Encoder[A, B], second Encoder[B, C]) Encoder[A, C] { + return func(a A) (C, error) { + b, err := first(a) + if err != nil { + var zero C + return zero, err + } + return second(b) + } +} + +// PipeDecoder chains two decoders in reverse: decodes C → B via second, then B → A via first. +func PipeDecoder[A, B, C any](first Decoder[A, B], second Decoder[B, C]) Decoder[A, C] { + return func(c C, a *A) error { + var b B + if err := second(c, &b); err != nil { + return err + } + return first(b, a) + } +} + +// PipeCodec chains two codecs: Codec[A,B] + Codec[B,C] → Codec[A,C]. +func PipeCodec[A, B, C any](first Codec[A, B], second Codec[B, C]) Codec[A, C] { + return Codec[A, C]{ + Encode: PipeEncoder(first.Encode, second.Encode), + Decode: PipeDecoder(first.Decode, second.Decode), + } +} diff --git a/pipe_test.go b/pipe_test.go new file mode 100644 index 0000000..bbd4328 --- /dev/null +++ b/pipe_test.go @@ -0,0 +1,113 @@ +package goencode_test + +import ( + "fmt" + "strconv" + "testing" + + goencode "github.com/foomo/goencode" +) + +func TestPipeEncoder(t *testing.T) { + intToStr := goencode.Encoder[int, string](func(i int) (string, error) { + return strconv.Itoa(i), nil + }) + strToBytes := goencode.Encoder[string, []byte](func(s string) ([]byte, error) { + return []byte(s), nil + }) + + piped := goencode.PipeEncoder(intToStr, strToBytes) + + got, err := piped(42) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(got) != "42" { + t.Fatalf("got %q, want %q", string(got), "42") + } +} + +func TestPipeEncoder_FirstError(t *testing.T) { + failing := goencode.Encoder[int, string](func(i int) (string, error) { + return "", fmt.Errorf("encode failed") + }) + second := goencode.Encoder[string, []byte](func(s string) ([]byte, error) { + t.Fatal("second encoder should not be called") + return nil, nil + }) + + piped := goencode.PipeEncoder(failing, second) + + _, err := piped(42) + if err == nil { + t.Fatal("expected error") + } +} + +func TestPipeDecoder(t *testing.T) { + strToInt := goencode.Decoder[int, string](func(s string, i *int) error { + v, err := strconv.Atoi(s) + if err != nil { + return err + } + *i = v + return nil + }) + bytesToStr := goencode.Decoder[string, []byte](func(b []byte, s *string) error { + *s = string(b) + return nil + }) + + piped := goencode.PipeDecoder(strToInt, bytesToStr) + + var got int + if err := piped([]byte("42"), &got); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != 42 { + t.Fatalf("got %d, want 42", got) + } +} + +func TestPipeCodec(t *testing.T) { + intStr := goencode.Codec[int, string]{ + Encode: func(i int) (string, error) { + return strconv.Itoa(i), nil + }, + Decode: func(s string, i *int) error { + v, err := strconv.Atoi(s) + if err != nil { + return err + } + *i = v + return nil + }, + } + strBytes := goencode.Codec[string, []byte]{ + Encode: func(s string) ([]byte, error) { + return []byte(s), nil + }, + Decode: func(b []byte, s *string) error { + *s = string(b) + return nil + }, + } + + piped := goencode.PipeCodec(intStr, strBytes) + + encoded, err := piped.Encode(42) + if err != nil { + t.Fatalf("encode error: %v", err) + } + if string(encoded) != "42" { + t.Fatalf("encoded: got %q, want %q", string(encoded), "42") + } + + var decoded int + if err := piped.Decode(encoded, &decoded); err != nil { + t.Fatalf("decode error: %v", err) + } + if decoded != 42 { + t.Fatalf("decoded: got %d, want 42", decoded) + } +} From 5d3e8db2bcd08f57b71921f3c35b0db7138266e8 Mon Sep 17 00:00:00 2001 From: franklin Date: Tue, 21 Apr 2026 00:38:36 +0200 Subject: [PATCH 03/29] refactor: migrate json/v1, xml, gob, asn1 to Codec[S,T] function types --- asn1/codec.go | 27 +++++++++++----------- asn1/codec_test.go | 2 +- asn1/streamcodec.go | 46 +++++++++++++++++-------------------- asn1/streamcodec_test.go | 2 +- gob/codec.go | 35 ++++++++++++++-------------- gob/codec_test.go | 2 +- gob/streamcodec.go | 24 +++++++++---------- gob/streamcodec_test.go | 2 +- json/v1/codec.go | 24 +++++++++---------- json/v1/codec_test.go | 2 +- json/v1/streamcodec.go | 24 +++++++++---------- json/v1/streamcodec_test.go | 2 +- xml/codec.go | 35 ++++++++++++++-------------- xml/codec_test.go | 2 +- xml/streamcodec.go | 24 +++++++++---------- xml/streamcodec_test.go | 2 +- 16 files changed, 124 insertions(+), 131 deletions(-) diff --git a/asn1/codec.go b/asn1/codec.go index 1a543ca..1a5cca8 100644 --- a/asn1/codec.go +++ b/asn1/codec.go @@ -2,21 +2,20 @@ package asn1 import ( stdasn1 "encoding/asn1" + + encoding "github.com/foomo/goencode" ) -// Codec is a Codec[T] backed by encoding/asn1. +// NewCodec returns an ASN1 codec for T. // It is safe for concurrent use. -type Codec[T any] struct{} - -// NewCodec returns an ASN1 serializer for T. -func NewCodec[T any]() *Codec[T] { return &Codec[T]{} } - -func (Codec[T]) Encode(v T) ([]byte, error) { - return stdasn1.Marshal(v) -} - -func (Codec[T]) Decode(b []byte, v *T) error { - _, err := stdasn1.Unmarshal(b, v) - - return err +func NewCodec[T any]() encoding.Codec[T, []byte] { + return encoding.Codec[T, []byte]{ + Encode: func(v T) ([]byte, error) { + return stdasn1.Marshal(v) + }, + Decode: func(b []byte, v *T) error { + _, err := stdasn1.Unmarshal(b, v) + return err + }, + } } diff --git a/asn1/codec_test.go b/asn1/codec_test.go index 912bff1..b720c50 100644 --- a/asn1/codec_test.go +++ b/asn1/codec_test.go @@ -6,7 +6,7 @@ import ( "github.com/foomo/goencode/asn1" ) -func ExampleCodec() { +func ExampleNewCodec() { c := asn1.NewCodec[int]() encoded, err := c.Encode(42) diff --git a/asn1/streamcodec.go b/asn1/streamcodec.go index 8c5573a..fda6108 100644 --- a/asn1/streamcodec.go +++ b/asn1/streamcodec.go @@ -3,33 +3,29 @@ package asn1 import ( stdasn1 "encoding/asn1" "io" + + encoding "github.com/foomo/goencode" ) -// StreamCodec is a StreamCodec[T] backed by encoding/asn1. +// NewStreamCodec returns an ASN1 stream codec for T. // It is safe for concurrent use. -type StreamCodec[T any] struct{} - -// NewStreamCodec returns an ASN1 stream serializer for T. -func NewStreamCodec[T any]() *StreamCodec[T] { return &StreamCodec[T]{} } - -func (StreamCodec[T]) Encode(w io.Writer, v T) error { - data, err := stdasn1.Marshal(v) - if err != nil { - return err - } - - _, err = w.Write(data) - - return err -} - -func (StreamCodec[T]) Decode(r io.Reader, v *T) error { - data, err := io.ReadAll(r) - if err != nil { - return err +func NewStreamCodec[T any]() encoding.StreamCodec[T] { + return encoding.StreamCodec[T]{ + Encode: func(w io.Writer, v T) error { + data, err := stdasn1.Marshal(v) + if err != nil { + return err + } + _, err = w.Write(data) + return err + }, + Decode: func(r io.Reader, v *T) error { + data, err := io.ReadAll(r) + if err != nil { + return err + } + _, err = stdasn1.Unmarshal(data, v) + return err + }, } - - _, err = stdasn1.Unmarshal(data, v) - - return err } diff --git a/asn1/streamcodec_test.go b/asn1/streamcodec_test.go index 9ae5760..32d0075 100644 --- a/asn1/streamcodec_test.go +++ b/asn1/streamcodec_test.go @@ -7,7 +7,7 @@ import ( "github.com/foomo/goencode/asn1" ) -func ExampleStreamCodec() { +func ExampleNewStreamCodec() { c := asn1.NewStreamCodec[int]() var buf bytes.Buffer diff --git a/gob/codec.go b/gob/codec.go index 3e28955..76599d1 100644 --- a/gob/codec.go +++ b/gob/codec.go @@ -4,27 +4,26 @@ import ( "bytes" stdgob "encoding/gob" + encoding "github.com/foomo/goencode" "github.com/foomo/goencode/internal/sync" ) -// Codec is a Codec[T] backed by encoding/gob. +// NewCodec returns a GOB codec for T. // It is safe for concurrent use. -type Codec[T any] struct{} - -// NewCodec returns a GOB serializer for T. -func NewCodec[T any]() *Codec[T] { return &Codec[T]{} } - -func (Codec[T]) Encode(v T) ([]byte, error) { - buf := sync.Get() - defer sync.Put(buf) - - if err := stdgob.NewEncoder(buf).Encode(v); err != nil { - return nil, err +func NewCodec[T any]() encoding.Codec[T, []byte] { + return encoding.Codec[T, []byte]{ + Encode: func(v T) ([]byte, error) { + buf := sync.Get() + defer sync.Put(buf) + + if err := stdgob.NewEncoder(buf).Encode(v); err != nil { + return nil, err + } + + return append([]byte(nil), buf.Bytes()...), nil + }, + Decode: func(b []byte, v *T) error { + return stdgob.NewDecoder(bytes.NewReader(b)).Decode(v) + }, } - - return append([]byte(nil), buf.Bytes()...), nil -} - -func (Codec[T]) Decode(b []byte, v *T) error { - return stdgob.NewDecoder(bytes.NewReader(b)).Decode(v) } diff --git a/gob/codec_test.go b/gob/codec_test.go index c613c2c..21c54d0 100644 --- a/gob/codec_test.go +++ b/gob/codec_test.go @@ -6,7 +6,7 @@ import ( "github.com/foomo/goencode/gob" ) -func ExampleCodec() { +func ExampleNewCodec() { type Data struct { Name string } diff --git a/gob/streamcodec.go b/gob/streamcodec.go index a113a17..f2ba630 100644 --- a/gob/streamcodec.go +++ b/gob/streamcodec.go @@ -3,19 +3,19 @@ package gob import ( "encoding/gob" "io" + + encoding "github.com/foomo/goencode" ) -// StreamCodec is a StreamCodec[T] backed by encoding/gob. +// NewStreamCodec returns a GOB stream codec for T. // It is safe for concurrent use. -type StreamCodec[T any] struct{} - -// NewStreamCodec returns a GOB stream serializer for T. -func NewStreamCodec[T any]() *StreamCodec[T] { return &StreamCodec[T]{} } - -func (StreamCodec[T]) Encode(w io.Writer, v T) error { - return gob.NewEncoder(w).Encode(v) -} - -func (StreamCodec[T]) Decode(r io.Reader, v *T) error { - return gob.NewDecoder(r).Decode(v) +func NewStreamCodec[T any]() encoding.StreamCodec[T] { + return encoding.StreamCodec[T]{ + Encode: func(w io.Writer, v T) error { + return gob.NewEncoder(w).Encode(v) + }, + Decode: func(r io.Reader, v *T) error { + return gob.NewDecoder(r).Decode(v) + }, + } } diff --git a/gob/streamcodec_test.go b/gob/streamcodec_test.go index c85e3e9..43092f4 100644 --- a/gob/streamcodec_test.go +++ b/gob/streamcodec_test.go @@ -7,7 +7,7 @@ import ( "github.com/foomo/goencode/gob" ) -func ExampleStreamCodec() { +func ExampleNewStreamCodec() { type Data struct { Name string } diff --git a/json/v1/codec.go b/json/v1/codec.go index 1189e74..31efb95 100644 --- a/json/v1/codec.go +++ b/json/v1/codec.go @@ -2,19 +2,19 @@ package json import ( "encoding/json" + + encoding "github.com/foomo/goencode" ) -// Codec is a Codec[T] backed by encoding/json. +// NewCodec returns a JSON codec for T. // It is safe for concurrent use. -type Codec[T any] struct{} - -// NewCodec returns a JSON serializer for T. -func NewCodec[T any]() Codec[T] { return Codec[T]{} } - -func (Codec[T]) Encode(v T) ([]byte, error) { - return json.Marshal(v) -} - -func (Codec[T]) Decode(b []byte, v *T) error { - return json.Unmarshal(b, v) +func NewCodec[T any]() encoding.Codec[T, []byte] { + return encoding.Codec[T, []byte]{ + Encode: func(v T) ([]byte, error) { + return json.Marshal(v) + }, + Decode: func(b []byte, v *T) error { + return json.Unmarshal(b, v) + }, + } } diff --git a/json/v1/codec_test.go b/json/v1/codec_test.go index b027512..a7aada7 100644 --- a/json/v1/codec_test.go +++ b/json/v1/codec_test.go @@ -6,7 +6,7 @@ import ( "github.com/foomo/goencode/json/v1" ) -func ExampleCodec() { +func ExampleNewCodec() { type Data struct { Name string } diff --git a/json/v1/streamcodec.go b/json/v1/streamcodec.go index facb112..e7d09b2 100644 --- a/json/v1/streamcodec.go +++ b/json/v1/streamcodec.go @@ -3,19 +3,19 @@ package json import ( "encoding/json" "io" + + encoding "github.com/foomo/goencode" ) -// StreamCodec is a StreamCodec[T] backed by encoding/json. +// NewStreamCodec returns a JSON stream codec for T. // It is safe for concurrent use. -type StreamCodec[T any] struct{} - -// NewStreamCodec returns a JSON stream serializer for T. -func NewStreamCodec[T any]() *StreamCodec[T] { return &StreamCodec[T]{} } - -func (StreamCodec[T]) Encode(w io.Writer, v T) error { - return json.NewEncoder(w).Encode(v) -} - -func (StreamCodec[T]) Decode(r io.Reader, v *T) error { - return json.NewDecoder(r).Decode(v) +func NewStreamCodec[T any]() encoding.StreamCodec[T] { + return encoding.StreamCodec[T]{ + Encode: func(w io.Writer, v T) error { + return json.NewEncoder(w).Encode(v) + }, + Decode: func(r io.Reader, v *T) error { + return json.NewDecoder(r).Decode(v) + }, + } } diff --git a/json/v1/streamcodec_test.go b/json/v1/streamcodec_test.go index a11bf5f..4e4c9f1 100644 --- a/json/v1/streamcodec_test.go +++ b/json/v1/streamcodec_test.go @@ -7,7 +7,7 @@ import ( "github.com/foomo/goencode/json/v1" ) -func ExampleStreamCodec() { +func ExampleNewStreamCodec() { type Data struct { Name string } diff --git a/xml/codec.go b/xml/codec.go index fd96525..de8d329 100644 --- a/xml/codec.go +++ b/xml/codec.go @@ -4,27 +4,26 @@ import ( "bytes" "encoding/xml" + encoding "github.com/foomo/goencode" "github.com/foomo/goencode/internal/sync" ) -// Codec is a Codec[T] backed by encoding/xml. +// NewCodec returns an XML codec for T. // It is safe for concurrent use. -type Codec[T any] struct{} - -// NewCodec returns an XML serializer for T. -func NewCodec[T any]() *Codec[T] { return &Codec[T]{} } - -func (Codec[T]) Encode(v T) ([]byte, error) { - buf := sync.Get() - defer sync.Put(buf) - - if err := xml.NewEncoder(buf).Encode(v); err != nil { - return nil, err +func NewCodec[T any]() encoding.Codec[T, []byte] { + return encoding.Codec[T, []byte]{ + Encode: func(v T) ([]byte, error) { + buf := sync.Get() + defer sync.Put(buf) + + if err := xml.NewEncoder(buf).Encode(v); err != nil { + return nil, err + } + + return append([]byte(nil), buf.Bytes()...), nil + }, + Decode: func(b []byte, v *T) error { + return xml.NewDecoder(bytes.NewReader(b)).Decode(v) + }, } - - return append([]byte(nil), buf.Bytes()...), nil -} - -func (Codec[T]) Decode(b []byte, v *T) error { - return xml.NewDecoder(bytes.NewReader(b)).Decode(v) } diff --git a/xml/codec_test.go b/xml/codec_test.go index c1304eb..fd3c117 100644 --- a/xml/codec_test.go +++ b/xml/codec_test.go @@ -6,7 +6,7 @@ import ( "github.com/foomo/goencode/xml" ) -func ExampleCodec() { +func ExampleNewCodec() { type Data struct { XMLName struct{} `xml:"data"` Name string `xml:"name"` diff --git a/xml/streamcodec.go b/xml/streamcodec.go index c9e1823..f079d23 100644 --- a/xml/streamcodec.go +++ b/xml/streamcodec.go @@ -3,19 +3,19 @@ package xml import ( "encoding/xml" "io" + + encoding "github.com/foomo/goencode" ) -// StreamCodec is a StreamCodec[T] backed by encoding/xml. +// NewStreamCodec returns an XML stream codec for T. // It is safe for concurrent use. -type StreamCodec[T any] struct{} - -// NewStreamCodec returns an XML stream serializer for T. -func NewStreamCodec[T any]() *StreamCodec[T] { return &StreamCodec[T]{} } - -func (StreamCodec[T]) Encode(w io.Writer, v T) error { - return xml.NewEncoder(w).Encode(v) -} - -func (StreamCodec[T]) Decode(r io.Reader, v *T) error { - return xml.NewDecoder(r).Decode(v) +func NewStreamCodec[T any]() encoding.StreamCodec[T] { + return encoding.StreamCodec[T]{ + Encode: func(w io.Writer, v T) error { + return xml.NewEncoder(w).Encode(v) + }, + Decode: func(r io.Reader, v *T) error { + return xml.NewDecoder(r).Decode(v) + }, + } } diff --git a/xml/streamcodec_test.go b/xml/streamcodec_test.go index a555ddb..2568e46 100644 --- a/xml/streamcodec_test.go +++ b/xml/streamcodec_test.go @@ -7,7 +7,7 @@ import ( "github.com/foomo/goencode/xml" ) -func ExampleStreamCodec() { +func ExampleNewStreamCodec() { type Data struct { XMLName struct{} `xml:"data"` Name string `xml:"name"` From 946fd81940085df398fabf39bc11dca516fb232b Mon Sep 17 00:00:00 2001 From: franklin Date: Tue, 21 Apr 2026 00:44:09 +0200 Subject: [PATCH 04/29] refactor: migrate encoding codecs (base64, base32, hex, ascii85, pem) to Codec[S,T] --- ascii85/codec.go | 42 ++++++++++++++++------------------- ascii85/streamcodec.go | 42 ++++++++++++++++------------------- base32/codec.go | 42 ++++++++++++++++------------------- base32/codec_test.go | 2 +- base32/streamcodec.go | 43 +++++++++++++++++------------------- base32/streamcodec_test.go | 2 +- base64/codec.go | 42 ++++++++++++++++------------------- base64/codec_test.go | 2 +- base64/streamcodec.go | 45 ++++++++++++++++++-------------------- base64/streamcodec_test.go | 2 +- hex/codec.go | 42 ++++++++++++++++------------------- hex/codec_test.go | 2 +- hex/streamcodec.go | 37 ++++++++++++++----------------- hex/streamcodec_test.go | 2 +- pem/codec.go | 34 ++++++++++++++-------------- pem/codec_test.go | 2 +- pem/streamcodec.go | 43 +++++++++++++++++------------------- pem/streamcodec_test.go | 2 +- 18 files changed, 197 insertions(+), 231 deletions(-) diff --git a/ascii85/codec.go b/ascii85/codec.go index 37543d5..aeb7137 100644 --- a/ascii85/codec.go +++ b/ascii85/codec.go @@ -3,31 +3,27 @@ package ascii85 import ( "bytes" stdascii85 "encoding/ascii85" + + encoding "github.com/foomo/goencode" ) -// Codec is a Codec[[]byte] backed by encoding/ascii85. +// NewCodec returns an ASCII85 codec. // It is safe for concurrent use. -type Codec struct{} - -// NewCodec returns an ASCII85 serializer. -func NewCodec() *Codec { return &Codec{} } - -func (Codec) Encode(v []byte) ([]byte, error) { - dst := make([]byte, stdascii85.MaxEncodedLen(len(v))) - n := stdascii85.Encode(dst, v) - - return dst[:n], nil -} - -func (Codec) Decode(b []byte, v *[]byte) error { - buf := bytes.NewBuffer(make([]byte, 0, len(b))) - r := stdascii85.NewDecoder(bytes.NewReader(b)) - - if _, err := buf.ReadFrom(r); err != nil { - return err +func NewCodec() encoding.Codec[[]byte, []byte] { + return encoding.Codec[[]byte, []byte]{ + Encode: func(v []byte) ([]byte, error) { + dst := make([]byte, stdascii85.MaxEncodedLen(len(v))) + n := stdascii85.Encode(dst, v) + return dst[:n], nil + }, + Decode: func(b []byte, v *[]byte) error { + buf := bytes.NewBuffer(make([]byte, 0, len(b))) + r := stdascii85.NewDecoder(bytes.NewReader(b)) + if _, err := buf.ReadFrom(r); err != nil { + return err + } + *v = buf.Bytes() + return nil + }, } - - *v = buf.Bytes() - - return nil } diff --git a/ascii85/streamcodec.go b/ascii85/streamcodec.go index 26f9aae..2989f0c 100644 --- a/ascii85/streamcodec.go +++ b/ascii85/streamcodec.go @@ -3,31 +3,27 @@ package ascii85 import ( stdascii85 "encoding/ascii85" "io" + + encoding "github.com/foomo/goencode" ) -// StreamCodec is a StreamCodec[[]byte] backed by encoding/ascii85. +// NewStreamCodec returns an ASCII85 stream codec. // It is safe for concurrent use. -type StreamCodec struct{} - -// NewStreamCodec returns an ASCII85 stream serializer. -func NewStreamCodec() StreamCodec { return StreamCodec{} } - -func (StreamCodec) Encode(w io.Writer, v []byte) error { - dst := make([]byte, stdascii85.MaxEncodedLen(len(v))) - n := stdascii85.Encode(dst, v) - - _, err := w.Write(dst[:n]) - - return err -} - -func (StreamCodec) Decode(r io.Reader, v *[]byte) error { - data, err := io.ReadAll(stdascii85.NewDecoder(r)) - if err != nil { - return err +func NewStreamCodec() encoding.StreamCodec[[]byte] { + return encoding.StreamCodec[[]byte]{ + Encode: func(w io.Writer, v []byte) error { + dst := make([]byte, stdascii85.MaxEncodedLen(len(v))) + n := stdascii85.Encode(dst, v) + _, err := w.Write(dst[:n]) + return err + }, + Decode: func(r io.Reader, v *[]byte) error { + data, err := io.ReadAll(stdascii85.NewDecoder(r)) + if err != nil { + return err + } + *v = data + return nil + }, } - - *v = data - - return nil } diff --git a/base32/codec.go b/base32/codec.go index 26d6796..2e8ee6c 100644 --- a/base32/codec.go +++ b/base32/codec.go @@ -2,31 +2,27 @@ package base32 import ( stdbase32 "encoding/base32" + + encoding "github.com/foomo/goencode" ) -// Codec is a Codec[[]byte] backed by encoding/base32. +// NewCodec returns a Base32 codec. // It is safe for concurrent use. -type Codec struct{} - -// NewCodec returns a Base32 serializer. -func NewCodec() *Codec { return &Codec{} } - -func (Codec) Encode(v []byte) ([]byte, error) { - dst := make([]byte, stdbase32.StdEncoding.EncodedLen(len(v))) - stdbase32.StdEncoding.Encode(dst, v) - - return dst, nil -} - -func (Codec) Decode(b []byte, v *[]byte) error { - dst := make([]byte, stdbase32.StdEncoding.DecodedLen(len(b))) - - n, err := stdbase32.StdEncoding.Decode(dst, b) - if err != nil { - return err +func NewCodec() encoding.Codec[[]byte, []byte] { + return encoding.Codec[[]byte, []byte]{ + Encode: func(v []byte) ([]byte, error) { + dst := make([]byte, stdbase32.StdEncoding.EncodedLen(len(v))) + stdbase32.StdEncoding.Encode(dst, v) + return dst, nil + }, + Decode: func(b []byte, v *[]byte) error { + dst := make([]byte, stdbase32.StdEncoding.DecodedLen(len(b))) + n, err := stdbase32.StdEncoding.Decode(dst, b) + if err != nil { + return err + } + *v = dst[:n] + return nil + }, } - - *v = dst[:n] - - return nil } diff --git a/base32/codec_test.go b/base32/codec_test.go index f0173a0..90a5720 100644 --- a/base32/codec_test.go +++ b/base32/codec_test.go @@ -6,7 +6,7 @@ import ( "github.com/foomo/goencode/base32" ) -func ExampleCodec() { +func ExampleNewCodec() { c := base32.NewCodec() encoded, err := c.Encode([]byte("hello")) diff --git a/base32/streamcodec.go b/base32/streamcodec.go index 9712b34..e18150d 100644 --- a/base32/streamcodec.go +++ b/base32/streamcodec.go @@ -3,31 +3,28 @@ package base32 import ( stdbase32 "encoding/base32" "io" + + encoding "github.com/foomo/goencode" ) -// StreamCodec is a StreamCodec[[]byte] backed by encoding/base32. +// NewStreamCodec returns a Base32 stream codec. // It is safe for concurrent use. -type StreamCodec struct{} - -// NewStreamCodec returns a Base32 stream serializer. -func NewStreamCodec() *StreamCodec { return &StreamCodec{} } - -func (StreamCodec) Encode(w io.Writer, v []byte) error { - enc := stdbase32.NewEncoder(stdbase32.StdEncoding, w) - if _, err := enc.Write(v); err != nil { - return err +func NewStreamCodec() encoding.StreamCodec[[]byte] { + return encoding.StreamCodec[[]byte]{ + Encode: func(w io.Writer, v []byte) error { + enc := stdbase32.NewEncoder(stdbase32.StdEncoding, w) + if _, err := enc.Write(v); err != nil { + return err + } + return enc.Close() + }, + Decode: func(r io.Reader, v *[]byte) error { + data, err := io.ReadAll(stdbase32.NewDecoder(stdbase32.StdEncoding, r)) + if err != nil { + return err + } + *v = data + return nil + }, } - - return enc.Close() -} - -func (StreamCodec) Decode(r io.Reader, v *[]byte) error { - data, err := io.ReadAll(stdbase32.NewDecoder(stdbase32.StdEncoding, r)) - if err != nil { - return err - } - - *v = data - - return nil } diff --git a/base32/streamcodec_test.go b/base32/streamcodec_test.go index 7327b31..0a7dc98 100644 --- a/base32/streamcodec_test.go +++ b/base32/streamcodec_test.go @@ -7,7 +7,7 @@ import ( "github.com/foomo/goencode/base32" ) -func ExampleStreamCodec() { +func ExampleNewStreamCodec() { c := base32.NewStreamCodec() var buf bytes.Buffer diff --git a/base64/codec.go b/base64/codec.go index aad0fca..1b3b644 100644 --- a/base64/codec.go +++ b/base64/codec.go @@ -2,31 +2,27 @@ package base64 import ( stdbase64 "encoding/base64" + + encoding "github.com/foomo/goencode" ) -// Codec is a Codec[[]byte] backed by encoding/base64. +// NewCodec returns a Base64 codec. // It is safe for concurrent use. -type Codec struct{} - -// NewCodec returns a Base64 serializer. -func NewCodec() *Codec { return &Codec{} } - -func (Codec) Encode(v []byte) ([]byte, error) { - dst := make([]byte, stdbase64.StdEncoding.EncodedLen(len(v))) - stdbase64.StdEncoding.Encode(dst, v) - - return dst, nil -} - -func (Codec) Decode(b []byte, v *[]byte) error { - dst := make([]byte, stdbase64.StdEncoding.DecodedLen(len(b))) - - n, err := stdbase64.StdEncoding.Decode(dst, b) - if err != nil { - return err +func NewCodec() encoding.Codec[[]byte, []byte] { + return encoding.Codec[[]byte, []byte]{ + Encode: func(v []byte) ([]byte, error) { + dst := make([]byte, stdbase64.StdEncoding.EncodedLen(len(v))) + stdbase64.StdEncoding.Encode(dst, v) + return dst, nil + }, + Decode: func(b []byte, v *[]byte) error { + dst := make([]byte, stdbase64.StdEncoding.DecodedLen(len(b))) + n, err := stdbase64.StdEncoding.Decode(dst, b) + if err != nil { + return err + } + *v = dst[:n] + return nil + }, } - - *v = dst[:n] - - return nil } diff --git a/base64/codec_test.go b/base64/codec_test.go index ed0efb1..88fde5f 100644 --- a/base64/codec_test.go +++ b/base64/codec_test.go @@ -6,7 +6,7 @@ import ( "github.com/foomo/goencode/base64" ) -func ExampleCodec() { +func ExampleNewCodec() { c := base64.NewCodec() encoded, err := c.Encode([]byte("hello")) diff --git a/base64/streamcodec.go b/base64/streamcodec.go index effb0d7..602d7f0 100644 --- a/base64/streamcodec.go +++ b/base64/streamcodec.go @@ -3,32 +3,29 @@ package base64 import ( stdbase64 "encoding/base64" "io" + + encoding "github.com/foomo/goencode" ) -// StreamCodec is a StreamCodec[[]byte] backed by encoding/base64. +// NewStreamCodec returns a Base64 stream codec. // It is safe for concurrent use. -type StreamCodec struct{} - -// NewStreamCodec returns a Base64 stream serializer. -func NewStreamCodec() StreamCodec { return StreamCodec{} } - -func (StreamCodec) Encode(w io.Writer, v []byte) error { - enc := stdbase64.NewEncoder(stdbase64.StdEncoding, w) - if _, err := enc.Write(v); err != nil { - _ = enc.Close() - return err +func NewStreamCodec() encoding.StreamCodec[[]byte] { + return encoding.StreamCodec[[]byte]{ + Encode: func(w io.Writer, v []byte) error { + enc := stdbase64.NewEncoder(stdbase64.StdEncoding, w) + if _, err := enc.Write(v); err != nil { + _ = enc.Close() + return err + } + return enc.Close() + }, + Decode: func(r io.Reader, v *[]byte) error { + data, err := io.ReadAll(stdbase64.NewDecoder(stdbase64.StdEncoding, r)) + if err != nil { + return err + } + *v = data + return nil + }, } - - return enc.Close() -} - -func (StreamCodec) Decode(r io.Reader, v *[]byte) error { - data, err := io.ReadAll(stdbase64.NewDecoder(stdbase64.StdEncoding, r)) - if err != nil { - return err - } - - *v = data - - return nil } diff --git a/base64/streamcodec_test.go b/base64/streamcodec_test.go index 4c00575..d56642b 100644 --- a/base64/streamcodec_test.go +++ b/base64/streamcodec_test.go @@ -7,7 +7,7 @@ import ( "github.com/foomo/goencode/base64" ) -func ExampleStreamCodec() { +func ExampleNewStreamCodec() { c := base64.NewStreamCodec() var buf bytes.Buffer diff --git a/hex/codec.go b/hex/codec.go index 0a60abf..b6fad04 100644 --- a/hex/codec.go +++ b/hex/codec.go @@ -2,31 +2,27 @@ package hex import ( stdhex "encoding/hex" + + encoding "github.com/foomo/goencode" ) -// Codec is a Codec[[]byte] backed by encoding/hex. +// NewCodec returns a Hex codec. // It is safe for concurrent use. -type Codec struct{} - -// NewCodec returns a Hex serializer. -func NewCodec() *Codec { return &Codec{} } - -func (Codec) Encode(v []byte) ([]byte, error) { - dst := make([]byte, stdhex.EncodedLen(len(v))) - stdhex.Encode(dst, v) - - return dst, nil -} - -func (Codec) Decode(b []byte, v *[]byte) error { - dst := make([]byte, stdhex.DecodedLen(len(b))) - - n, err := stdhex.Decode(dst, b) - if err != nil { - return err +func NewCodec() encoding.Codec[[]byte, []byte] { + return encoding.Codec[[]byte, []byte]{ + Encode: func(v []byte) ([]byte, error) { + dst := make([]byte, stdhex.EncodedLen(len(v))) + stdhex.Encode(dst, v) + return dst, nil + }, + Decode: func(b []byte, v *[]byte) error { + dst := make([]byte, stdhex.DecodedLen(len(b))) + n, err := stdhex.Decode(dst, b) + if err != nil { + return err + } + *v = dst[:n] + return nil + }, } - - *v = dst[:n] - - return nil } diff --git a/hex/codec_test.go b/hex/codec_test.go index f864ea2..b6b942e 100644 --- a/hex/codec_test.go +++ b/hex/codec_test.go @@ -6,7 +6,7 @@ import ( "github.com/foomo/goencode/hex" ) -func ExampleCodec() { +func ExampleNewCodec() { c := hex.NewCodec() encoded, err := c.Encode([]byte("hello")) diff --git a/hex/streamcodec.go b/hex/streamcodec.go index a8b41e6..d80e68c 100644 --- a/hex/streamcodec.go +++ b/hex/streamcodec.go @@ -3,28 +3,25 @@ package hex import ( stdhex "encoding/hex" "io" + + encoding "github.com/foomo/goencode" ) -// StreamCodec is a StreamCodec[[]byte] backed by encoding/hex. +// NewStreamCodec returns a Hex stream codec. // It is safe for concurrent use. -type StreamCodec struct{} - -// NewStreamCodec returns a Hex stream serializer. -func NewStreamCodec() *StreamCodec { return &StreamCodec{} } - -func (StreamCodec) Encode(w io.Writer, v []byte) error { - _, err := stdhex.NewEncoder(w).Write(v) - - return err -} - -func (StreamCodec) Decode(r io.Reader, v *[]byte) error { - data, err := io.ReadAll(stdhex.NewDecoder(r)) - if err != nil { - return err +func NewStreamCodec() encoding.StreamCodec[[]byte] { + return encoding.StreamCodec[[]byte]{ + Encode: func(w io.Writer, v []byte) error { + _, err := stdhex.NewEncoder(w).Write(v) + return err + }, + Decode: func(r io.Reader, v *[]byte) error { + data, err := io.ReadAll(stdhex.NewDecoder(r)) + if err != nil { + return err + } + *v = data + return nil + }, } - - *v = data - - return nil } diff --git a/hex/streamcodec_test.go b/hex/streamcodec_test.go index 8e57fed..0af9673 100644 --- a/hex/streamcodec_test.go +++ b/hex/streamcodec_test.go @@ -7,7 +7,7 @@ import ( "github.com/foomo/goencode/hex" ) -func ExampleStreamCodec() { +func ExampleNewStreamCodec() { c := hex.NewStreamCodec() var buf bytes.Buffer diff --git a/pem/codec.go b/pem/codec.go index a637cfb..9fecc63 100644 --- a/pem/codec.go +++ b/pem/codec.go @@ -3,26 +3,24 @@ package pem import ( stdpem "encoding/pem" "errors" + + encoding "github.com/foomo/goencode" ) -// Codec is a Codec[*pem.Block] backed by encoding/pem. +// NewCodec returns a PEM codec. // It is safe for concurrent use. -type Codec struct{} - -// NewCodec returns a PEM serializer. -func NewCodec() *Codec { return &Codec{} } - -func (Codec) Encode(v *stdpem.Block) ([]byte, error) { - return stdpem.EncodeToMemory(v), nil -} - -func (Codec) Decode(b []byte, v **stdpem.Block) error { - block, _ := stdpem.Decode(b) - if block == nil { - return errors.New("pem: no PEM block found") +func NewCodec() encoding.Codec[*stdpem.Block, []byte] { + return encoding.Codec[*stdpem.Block, []byte]{ + Encode: func(v *stdpem.Block) ([]byte, error) { + return stdpem.EncodeToMemory(v), nil + }, + Decode: func(b []byte, v **stdpem.Block) error { + block, _ := stdpem.Decode(b) + if block == nil { + return errors.New("pem: no PEM block found") + } + *v = block + return nil + }, } - - *v = block - - return nil } diff --git a/pem/codec_test.go b/pem/codec_test.go index f1d644c..ae6734c 100644 --- a/pem/codec_test.go +++ b/pem/codec_test.go @@ -7,7 +7,7 @@ import ( "github.com/foomo/goencode/pem" ) -func ExampleCodec() { +func ExampleNewCodec() { c := pem.NewCodec() block := &stdpem.Block{ diff --git a/pem/streamcodec.go b/pem/streamcodec.go index b820727..efec9c8 100644 --- a/pem/streamcodec.go +++ b/pem/streamcodec.go @@ -4,31 +4,28 @@ import ( stdpem "encoding/pem" "errors" "io" + + encoding "github.com/foomo/goencode" ) -// StreamCodec is a StreamCodec[*pem.Block] backed by encoding/pem. +// NewStreamCodec returns a PEM stream codec. // It is safe for concurrent use. -type StreamCodec struct{} - -// NewStreamCodec returns a PEM stream serializer. -func NewStreamCodec() *StreamCodec { return &StreamCodec{} } - -func (StreamCodec) Encode(w io.Writer, v *stdpem.Block) error { - return stdpem.Encode(w, v) -} - -func (StreamCodec) Decode(r io.Reader, v **stdpem.Block) error { - data, err := io.ReadAll(r) - if err != nil { - return err +func NewStreamCodec() encoding.StreamCodec[*stdpem.Block] { + return encoding.StreamCodec[*stdpem.Block]{ + Encode: func(w io.Writer, v *stdpem.Block) error { + return stdpem.Encode(w, v) + }, + Decode: func(r io.Reader, v **stdpem.Block) error { + data, err := io.ReadAll(r) + if err != nil { + return err + } + block, _ := stdpem.Decode(data) + if block == nil { + return errors.New("encoding: no PEM block found") + } + *v = block + return nil + }, } - - block, _ := stdpem.Decode(data) - if block == nil { - return errors.New("encoding: no PEM block found") - } - - *v = block - - return nil } diff --git a/pem/streamcodec_test.go b/pem/streamcodec_test.go index 3de8b33..f07d7a1 100644 --- a/pem/streamcodec_test.go +++ b/pem/streamcodec_test.go @@ -8,7 +8,7 @@ import ( "github.com/foomo/goencode/pem" ) -func ExampleStreamCodec() { +func ExampleNewStreamCodec() { c := pem.NewStreamCodec() block := &stdpem.Block{ From d5f0b96c48969a1a441ee67f54050c3d9407cbf7 Mon Sep 17 00:00:00 2001 From: franklin Date: Tue, 21 Apr 2026 00:46:01 +0200 Subject: [PATCH 05/29] refactor: migrate csv codec to Codec[S,T] function types --- csv/codec.go | 61 ++++++++++++++++++++--------------------- csv/codec_test.go | 2 +- csv/streamcodec.go | 46 ++++++++++++++----------------- csv/streamcodec_test.go | 2 +- 4 files changed, 52 insertions(+), 59 deletions(-) diff --git a/csv/codec.go b/csv/codec.go index ac1bbfd..0d28dc4 100644 --- a/csv/codec.go +++ b/csv/codec.go @@ -4,41 +4,38 @@ import ( "bytes" stdcsv "encoding/csv" + encoding "github.com/foomo/goencode" "github.com/foomo/goencode/internal/sync" ) -// Codec is a Codec[[][]string] backed by encoding/csv. +// NewCodec returns a CSV codec for [][]string. // It is safe for concurrent use. -type Codec struct{} - -// NewCodec returns a CSV serializer. -func NewCodec() *Codec { return &Codec{} } - -func (Codec) Encode(v [][]string) ([]byte, error) { - buf := sync.Get() - defer sync.Put(buf) - - cw := stdcsv.NewWriter(buf) - if err := cw.WriteAll(v); err != nil { - return nil, err - } - - cw.Flush() - - if err := cw.Error(); err != nil { - return nil, err +func NewCodec() encoding.Codec[[][]string, []byte] { + return encoding.Codec[[][]string, []byte]{ + Encode: func(v [][]string) ([]byte, error) { + buf := sync.Get() + defer sync.Put(buf) + + cw := stdcsv.NewWriter(buf) + if err := cw.WriteAll(v); err != nil { + return nil, err + } + + cw.Flush() + + if err := cw.Error(); err != nil { + return nil, err + } + + return append([]byte(nil), buf.Bytes()...), nil + }, + Decode: func(b []byte, v *[][]string) error { + records, err := stdcsv.NewReader(bytes.NewReader(b)).ReadAll() + if err != nil { + return err + } + *v = records + return nil + }, } - - return append([]byte(nil), buf.Bytes()...), nil -} - -func (Codec) Decode(b []byte, v *[][]string) error { - records, err := stdcsv.NewReader(bytes.NewReader(b)).ReadAll() - if err != nil { - return err - } - - *v = records - - return nil } diff --git a/csv/codec_test.go b/csv/codec_test.go index 8bdd541..103bf00 100644 --- a/csv/codec_test.go +++ b/csv/codec_test.go @@ -6,7 +6,7 @@ import ( "github.com/foomo/goencode/csv" ) -func ExampleCodec() { +func ExampleNewCodec() { c := csv.NewCodec() records := [][]string{ diff --git a/csv/streamcodec.go b/csv/streamcodec.go index 91c0255..d29f8f1 100644 --- a/csv/streamcodec.go +++ b/csv/streamcodec.go @@ -3,33 +3,29 @@ package csv import ( stdcsv "encoding/csv" "io" + + encoding "github.com/foomo/goencode" ) -// StreamCodec is a StreamCodec[[][]string] backed by encoding/csv. +// NewStreamCodec returns a CSV stream codec for [][]string. // It is safe for concurrent use. -type StreamCodec struct{} - -// NewStreamCodec returns a CSV stream serializer. -func NewStreamCodec() *StreamCodec { return &StreamCodec{} } - -func (StreamCodec) Encode(w io.Writer, v [][]string) error { - cw := stdcsv.NewWriter(w) - if err := cw.WriteAll(v); err != nil { - return err - } - - cw.Flush() - - return cw.Error() -} - -func (StreamCodec) Decode(r io.Reader, v *[][]string) error { - records, err := stdcsv.NewReader(r).ReadAll() - if err != nil { - return err +func NewStreamCodec() encoding.StreamCodec[[][]string] { + return encoding.StreamCodec[[][]string]{ + Encode: func(w io.Writer, v [][]string) error { + cw := stdcsv.NewWriter(w) + if err := cw.WriteAll(v); err != nil { + return err + } + cw.Flush() + return cw.Error() + }, + Decode: func(r io.Reader, v *[][]string) error { + records, err := stdcsv.NewReader(r).ReadAll() + if err != nil { + return err + } + *v = records + return nil + }, } - - *v = records - - return nil } diff --git a/csv/streamcodec_test.go b/csv/streamcodec_test.go index ea377fc..73dcc1e 100644 --- a/csv/streamcodec_test.go +++ b/csv/streamcodec_test.go @@ -7,7 +7,7 @@ import ( "github.com/foomo/goencode/csv" ) -func ExampleStreamCodec() { +func ExampleNewStreamCodec() { c := csv.NewStreamCodec() records := [][]string{ From 1124e02f4e6f7146754df5f2ea0b644a47f58e1b Mon Sep 17 00:00:00 2001 From: franklin Date: Tue, 21 Apr 2026 07:37:50 +0200 Subject: [PATCH 06/29] refactor: make compression codecs standalone Codec[[]byte,[]byte] Remove decorator pattern. Each compression codec now operates on raw bytes. Use goencode.PipeCodec() to compose with serialization codecs. --- brotli/benchmark_test.go | 5 +- brotli/codec.go | 78 +++++++++++---------------- brotli/codec_test.go | 7 +-- brotli/streamcodec.go | 69 ++++++++++++------------ brotli/streamcodec_test.go | 18 +++---- flate/benchmark_test.go | 3 +- flate/codec.go | 102 +++++++++++++++-------------------- flate/codec_test.go | 7 +-- flate/streamcodec.go | 77 +++++++++++++------------- flate/streamcodec_test.go | 18 +++---- gzip/benchmark_test.go | 3 +- gzip/codec.go | 108 ++++++++++++++++--------------------- gzip/codec_test.go | 5 +- gzip/streamcodec.go | 83 ++++++++++++++-------------- gzip/streamcodec_test.go | 18 +++---- snappy/benchmark_test.go | 3 +- snappy/codec.go | 41 +++++--------- snappy/codec_test.go | 5 +- snappy/streamcodec.go | 41 +++++++------- snappy/streamcodec_test.go | 18 +++---- zstd/benchmark_test.go | 5 +- zstd/codec.go | 80 +++++++++++---------------- zstd/codec_test.go | 7 +-- zstd/streamcodec.go | 78 +++++++++++++-------------- zstd/streamcodec_test.go | 18 +++---- 25 files changed, 398 insertions(+), 499 deletions(-) diff --git a/brotli/benchmark_test.go b/brotli/benchmark_test.go index 4573456..26f23d7 100644 --- a/brotli/benchmark_test.go +++ b/brotli/benchmark_test.go @@ -3,13 +3,14 @@ package brotli_test import ( "testing" + goencode "github.com/foomo/goencode" "github.com/foomo/goencode/brotli" "github.com/foomo/goencode/internal/testdata" - "github.com/foomo/goencode/json/v1" + json "github.com/foomo/goencode/json/v1" ) func BenchmarkCodec(b *testing.B) { - c := brotli.NewCodec(json.NewCodec[*testdata.User]()) + c := goencode.PipeCodec(json.NewCodec[*testdata.User](), brotli.NewCodec()) b.Run("encode", func(b *testing.B) { v := testdata.NewUser() diff --git a/brotli/codec.go b/brotli/codec.go index 97eb3b0..48c51e0 100644 --- a/brotli/codec.go +++ b/brotli/codec.go @@ -10,16 +10,9 @@ import ( "github.com/foomo/goencode/internal/sync" ) -// Codec is a Codec[T] that applies Brotli compression on top of another Codec[T]. +// NewCodec returns a Brotli compression codec. // It is safe for concurrent use. -type Codec[T any] struct { - codec encoding.Codec[T] - level int - maxDecodedSize int64 -} - -// NewCodec returns a Brotli compression codec that delegates serialization to codec. -func NewCodec[T any](codec encoding.Codec[T], opts ...Option) *Codec[T] { +func NewCodec(opts ...Option) encoding.Codec[[]byte, []byte] { o := options{ level: brotli.DefaultCompression, } @@ -27,51 +20,42 @@ func NewCodec[T any](codec encoding.Codec[T], opts ...Option) *Codec[T] { opt(&o) } - return &Codec[T]{ - codec: codec, - level: o.level, - maxDecodedSize: o.maxDecodedSize, - } -} + return encoding.Codec[[]byte, []byte]{ + Encode: func(data []byte) ([]byte, error) { + buf := sync.Get() + defer sync.Put(buf) -func (c *Codec[T]) Encode(v T) ([]byte, error) { - b, err := c.codec.Encode(v) - if err != nil { - return nil, err - } + w := brotli.NewWriterLevel(buf, o.level) - buf := sync.Get() - defer sync.Put(buf) + if _, err := w.Write(data); err != nil { + return nil, err + } - w := brotli.NewWriterLevel(buf, c.level) + if err := w.Close(); err != nil { + return nil, err + } - if _, err := w.Write(b); err != nil { - return nil, err - } + return append([]byte(nil), buf.Bytes()...), nil + }, + Decode: func(data []byte, v *[]byte) error { + r := brotli.NewReader(bytes.NewReader(data)) - if err := w.Close(); err != nil { - return nil, err - } + var src io.Reader = r + if o.maxDecodedSize > 0 { + src = io.LimitReader(r, o.maxDecodedSize+1) + } - return append([]byte(nil), buf.Bytes()...), nil -} - -func (c *Codec[T]) Decode(b []byte, v *T) error { - r := brotli.NewReader(bytes.NewReader(b)) - - var src io.Reader = r - if c.maxDecodedSize > 0 { - src = io.LimitReader(r, c.maxDecodedSize+1) - } + decoded, err := io.ReadAll(src) + if err != nil { + return err + } - data, err := io.ReadAll(src) - if err != nil { - return err - } + if o.maxDecodedSize > 0 && int64(len(decoded)) > o.maxDecodedSize { + return fmt.Errorf("brotli: decompressed size exceeds limit of %d bytes", o.maxDecodedSize) + } - if c.maxDecodedSize > 0 && int64(len(data)) > c.maxDecodedSize { - return fmt.Errorf("brotli: decompressed size exceeds limit of %d bytes", c.maxDecodedSize) + *v = decoded + return nil + }, } - - return c.codec.Decode(data, v) } diff --git a/brotli/codec_test.go b/brotli/codec_test.go index dc16bfa..494f801 100644 --- a/brotli/codec_test.go +++ b/brotli/codec_test.go @@ -3,16 +3,17 @@ package brotli_test import ( "fmt" + goencode "github.com/foomo/goencode" "github.com/foomo/goencode/brotli" - "github.com/foomo/goencode/json/v1" + json "github.com/foomo/goencode/json/v1" ) -func ExampleCodec() { +func ExampleNewCodec() { type Data struct { Name string } - c := brotli.NewCodec(json.NewCodec[Data]()) + c := goencode.PipeCodec(json.NewCodec[Data](), brotli.NewCodec()) encoded, err := c.Encode(Data{Name: "example-123"}) if err != nil { diff --git a/brotli/streamcodec.go b/brotli/streamcodec.go index 5836d2c..20c1140 100644 --- a/brotli/streamcodec.go +++ b/brotli/streamcodec.go @@ -1,22 +1,16 @@ package brotli import ( + "fmt" "io" "github.com/andybalholm/brotli" encoding "github.com/foomo/goencode" ) -// StreamCodec is a StreamCodec[T] that applies Brotli compression on top of another StreamCodec[T]. +// NewStreamCodec returns a Brotli compression stream codec. // It is safe for concurrent use. -type StreamCodec[T any] struct { - codec encoding.StreamCodec[T] - level int - maxDecodedSize int64 -} - -// NewStreamCodec returns a Brotli compression stream codec that delegates serialization to codec. -func NewStreamCodec[T any](codec encoding.StreamCodec[T], opts ...Option) *StreamCodec[T] { +func NewStreamCodec(opts ...Option) encoding.StreamCodec[[]byte] { o := options{ level: brotli.DefaultCompression, } @@ -24,31 +18,36 @@ func NewStreamCodec[T any](codec encoding.StreamCodec[T], opts ...Option) *Strea opt(&o) } - return &StreamCodec[T]{ - codec: codec, - level: o.level, - maxDecodedSize: o.maxDecodedSize, - } -} - -func (c *StreamCodec[T]) Encode(w io.Writer, v T) error { - bw := brotli.NewWriterLevel(w, c.level) - - if err := c.codec.Encode(bw, v); err != nil { - bw.Close() - return err - } - - return bw.Close() -} - -func (c *StreamCodec[T]) Decode(r io.Reader, v *T) error { - br := brotli.NewReader(r) - - var src io.Reader = br - if c.maxDecodedSize > 0 { - src = io.LimitReader(br, c.maxDecodedSize+1) + return encoding.StreamCodec[[]byte]{ + Encode: func(w io.Writer, data []byte) error { + bw := brotli.NewWriterLevel(w, o.level) + + if _, err := bw.Write(data); err != nil { + bw.Close() + return err + } + + return bw.Close() + }, + Decode: func(r io.Reader, v *[]byte) error { + br := brotli.NewReader(r) + + var src io.Reader = br + if o.maxDecodedSize > 0 { + src = io.LimitReader(br, o.maxDecodedSize+1) + } + + data, err := io.ReadAll(src) + if err != nil { + return err + } + + if o.maxDecodedSize > 0 && int64(len(data)) > o.maxDecodedSize { + return fmt.Errorf("brotli: decompressed size exceeds limit of %d bytes", o.maxDecodedSize) + } + + *v = data + return nil + }, } - - return c.codec.Decode(src, v) } diff --git a/brotli/streamcodec_test.go b/brotli/streamcodec_test.go index 5d7e5b9..2f642a7 100644 --- a/brotli/streamcodec_test.go +++ b/brotli/streamcodec_test.go @@ -5,29 +5,25 @@ import ( "fmt" "github.com/foomo/goencode/brotli" - "github.com/foomo/goencode/json/v1" ) -func ExampleStreamCodec() { - type Data struct { - Name string - } - - c := brotli.NewStreamCodec(json.NewStreamCodec[Data]()) +func ExampleNewStreamCodec() { + c := brotli.NewStreamCodec() + input := []byte("hello brotli stream") var buf bytes.Buffer - if err := c.Encode(&buf, Data{Name: "example-123"}); err != nil { + if err := c.Encode(&buf, input); err != nil { fmt.Printf("Encode failed: %v\n", err) return } - var decoded Data + var decoded []byte if err := c.Decode(&buf, &decoded); err != nil { fmt.Printf("Decode failed: %v\n", err) return } - fmt.Printf("Decoded Name: %s\n", decoded.Name) + fmt.Printf("Decoded: %s\n", string(decoded)) // Output: - // Decoded Name: example-123 + // Decoded: hello brotli stream } diff --git a/flate/benchmark_test.go b/flate/benchmark_test.go index 3f9cfbe..9b17413 100644 --- a/flate/benchmark_test.go +++ b/flate/benchmark_test.go @@ -3,13 +3,14 @@ package flate_test import ( "testing" + goencode "github.com/foomo/goencode" "github.com/foomo/goencode/flate" "github.com/foomo/goencode/internal/testdata" json "github.com/foomo/goencode/json/v1" ) func BenchmarkCodec(b *testing.B) { - c := flate.NewCodec(json.NewCodec[*testdata.User]()) + c := goencode.PipeCodec(json.NewCodec[*testdata.User](), flate.NewCodec()) b.Run("encode", func(b *testing.B) { v := testdata.NewUser() diff --git a/flate/codec.go b/flate/codec.go index 5c72bf6..b2a69c0 100644 --- a/flate/codec.go +++ b/flate/codec.go @@ -10,16 +10,9 @@ import ( "github.com/foomo/goencode/internal/sync" ) -// Codec is a Codec[T] that applies DEFLATE compression on top of another Codec[T]. +// NewCodec returns a DEFLATE compression codec. // It is safe for concurrent use. -type Codec[T any] struct { - codec encoding.Codec[T] - level int - maxDecodedSize int64 -} - -// NewCodec returns a flate compression codec that delegates serialization to codec. -func NewCodec[T any](codec encoding.Codec[T], opts ...Option) *Codec[T] { +func NewCodec(opts ...Option) encoding.Codec[[]byte, []byte] { o := options{ level: flate.DefaultCompression, } @@ -27,55 +20,46 @@ func NewCodec[T any](codec encoding.Codec[T], opts ...Option) *Codec[T] { opt(&o) } - return &Codec[T]{ - codec: codec, - level: o.level, - maxDecodedSize: o.maxDecodedSize, - } -} - -func (c *Codec[T]) Encode(v T) ([]byte, error) { - b, err := c.codec.Encode(v) - if err != nil { - return nil, err - } - - buf := sync.Get() - defer sync.Put(buf) - - w, err := flate.NewWriter(buf, c.level) - if err != nil { - return nil, err - } - - if _, err := w.Write(b); err != nil { - return nil, err - } - - if err := w.Close(); err != nil { - return nil, err - } - - return append([]byte(nil), buf.Bytes()...), nil -} - -func (c *Codec[T]) Decode(b []byte, v *T) error { - r := flate.NewReader(bytes.NewReader(b)) - defer r.Close() - - var src io.Reader = r - if c.maxDecodedSize > 0 { - src = io.LimitReader(r, c.maxDecodedSize+1) + return encoding.Codec[[]byte, []byte]{ + Encode: func(data []byte) ([]byte, error) { + buf := sync.Get() + defer sync.Put(buf) + + w, err := flate.NewWriter(buf, o.level) + if err != nil { + return nil, err + } + + if _, err := w.Write(data); err != nil { + return nil, err + } + + if err := w.Close(); err != nil { + return nil, err + } + + return append([]byte(nil), buf.Bytes()...), nil + }, + Decode: func(data []byte, v *[]byte) error { + r := flate.NewReader(bytes.NewReader(data)) + defer r.Close() + + var src io.Reader = r + if o.maxDecodedSize > 0 { + src = io.LimitReader(r, o.maxDecodedSize+1) + } + + decoded, err := io.ReadAll(src) + if err != nil { + return err + } + + if o.maxDecodedSize > 0 && int64(len(decoded)) > o.maxDecodedSize { + return fmt.Errorf("flate: decompressed size exceeds limit of %d bytes", o.maxDecodedSize) + } + + *v = decoded + return nil + }, } - - data, err := io.ReadAll(src) - if err != nil { - return err - } - - if c.maxDecodedSize > 0 && int64(len(data)) > c.maxDecodedSize { - return fmt.Errorf("flate: decompressed size exceeds limit of %d bytes", c.maxDecodedSize) - } - - return c.codec.Decode(data, v) } diff --git a/flate/codec_test.go b/flate/codec_test.go index 27ea10f..3203595 100644 --- a/flate/codec_test.go +++ b/flate/codec_test.go @@ -3,16 +3,17 @@ package flate_test import ( "fmt" + goencode "github.com/foomo/goencode" "github.com/foomo/goencode/flate" - "github.com/foomo/goencode/json/v1" + json "github.com/foomo/goencode/json/v1" ) -func ExampleCodec() { +func ExampleNewCodec() { type Data struct { Name string } - c := flate.NewCodec(json.NewCodec[Data]()) + c := goencode.PipeCodec(json.NewCodec[Data](), flate.NewCodec()) encoded, err := c.Encode(Data{Name: "example-123"}) if err != nil { diff --git a/flate/streamcodec.go b/flate/streamcodec.go index 4451132..eba595c 100644 --- a/flate/streamcodec.go +++ b/flate/streamcodec.go @@ -2,21 +2,15 @@ package flate import ( "compress/flate" + "fmt" "io" encoding "github.com/foomo/goencode" ) -// StreamCodec is a StreamCodec[T] that applies DEFLATE compression on top of another StreamCodec[T]. +// NewStreamCodec returns a DEFLATE compression stream codec. // It is safe for concurrent use. -type StreamCodec[T any] struct { - codec encoding.StreamCodec[T] - level int - maxDecodedSize int64 -} - -// NewStreamCodec returns a flate compression stream codec that delegates serialization to codec. -func NewStreamCodec[T any](codec encoding.StreamCodec[T], opts ...Option) *StreamCodec[T] { +func NewStreamCodec(opts ...Option) encoding.StreamCodec[[]byte] { o := options{ level: flate.DefaultCompression, } @@ -24,35 +18,40 @@ func NewStreamCodec[T any](codec encoding.StreamCodec[T], opts ...Option) *Strea opt(&o) } - return &StreamCodec[T]{ - codec: codec, - level: o.level, - maxDecodedSize: o.maxDecodedSize, - } -} - -func (c *StreamCodec[T]) Encode(w io.Writer, v T) error { - fw, err := flate.NewWriter(w, c.level) - if err != nil { - return err - } - - if err := c.codec.Encode(fw, v); err != nil { - fw.Close() - return err + return encoding.StreamCodec[[]byte]{ + Encode: func(w io.Writer, data []byte) error { + fw, err := flate.NewWriter(w, o.level) + if err != nil { + return err + } + + if _, err := fw.Write(data); err != nil { + fw.Close() + return err + } + + return fw.Close() + }, + Decode: func(r io.Reader, v *[]byte) error { + fr := flate.NewReader(r) + defer fr.Close() + + var src io.Reader = fr + if o.maxDecodedSize > 0 { + src = io.LimitReader(fr, o.maxDecodedSize+1) + } + + data, err := io.ReadAll(src) + if err != nil { + return err + } + + if o.maxDecodedSize > 0 && int64(len(data)) > o.maxDecodedSize { + return fmt.Errorf("flate: decompressed size exceeds limit of %d bytes", o.maxDecodedSize) + } + + *v = data + return nil + }, } - - return fw.Close() -} - -func (c *StreamCodec[T]) Decode(r io.Reader, v *T) error { - fr := flate.NewReader(r) - defer fr.Close() - - var src io.Reader = fr - if c.maxDecodedSize > 0 { - src = io.LimitReader(fr, c.maxDecodedSize+1) - } - - return c.codec.Decode(src, v) } diff --git a/flate/streamcodec_test.go b/flate/streamcodec_test.go index 38e544a..eb88d96 100644 --- a/flate/streamcodec_test.go +++ b/flate/streamcodec_test.go @@ -5,29 +5,25 @@ import ( "fmt" "github.com/foomo/goencode/flate" - json "github.com/foomo/goencode/json/v1" ) -func ExampleStreamCodec() { - type Data struct { - Name string - } - - c := flate.NewStreamCodec(json.NewStreamCodec[Data]()) +func ExampleNewStreamCodec() { + c := flate.NewStreamCodec() + input := []byte("hello flate stream") var buf bytes.Buffer - if err := c.Encode(&buf, Data{Name: "example-123"}); err != nil { + if err := c.Encode(&buf, input); err != nil { fmt.Printf("Encode failed: %v\n", err) return } - var decoded Data + var decoded []byte if err := c.Decode(&buf, &decoded); err != nil { fmt.Printf("Decode failed: %v\n", err) return } - fmt.Printf("Decoded Name: %s\n", decoded.Name) + fmt.Printf("Decoded: %s\n", string(decoded)) // Output: - // Decoded Name: example-123 + // Decoded: hello flate stream } diff --git a/gzip/benchmark_test.go b/gzip/benchmark_test.go index 060370e..dbd1e00 100644 --- a/gzip/benchmark_test.go +++ b/gzip/benchmark_test.go @@ -3,13 +3,14 @@ package gzip_test import ( "testing" + goencode "github.com/foomo/goencode" "github.com/foomo/goencode/gzip" "github.com/foomo/goencode/internal/testdata" json "github.com/foomo/goencode/json/v1" ) func BenchmarkCodec(b *testing.B) { - c := gzip.NewCodec(json.NewCodec[*testdata.User]()) + c := goencode.PipeCodec(json.NewCodec[*testdata.User](), gzip.NewCodec()) b.Run("encode", func(b *testing.B) { v := testdata.NewUser() diff --git a/gzip/codec.go b/gzip/codec.go index 26acc14..74ff9b6 100644 --- a/gzip/codec.go +++ b/gzip/codec.go @@ -10,16 +10,9 @@ import ( "github.com/foomo/goencode/internal/sync" ) -// Codec is a Codec[T] that applies gzip compression on top of another Codec[T]. +// NewCodec returns a gzip compression codec. // It is safe for concurrent use. -type Codec[T any] struct { - codec encoding.Codec[T] - level int - maxDecodedSize int64 -} - -// NewCodec returns a gzip compression codec that delegates serialization to codec. -func NewCodec[T any](codec encoding.Codec[T], opts ...Option) *Codec[T] { +func NewCodec(opts ...Option) encoding.Codec[[]byte, []byte] { o := options{ level: gzip.DefaultCompression, } @@ -27,58 +20,49 @@ func NewCodec[T any](codec encoding.Codec[T], opts ...Option) *Codec[T] { opt(&o) } - return &Codec[T]{ - codec: codec, - level: o.level, - maxDecodedSize: o.maxDecodedSize, - } -} - -func (c *Codec[T]) Encode(v T) ([]byte, error) { - b, err := c.codec.Encode(v) - if err != nil { - return nil, err - } - - buf := sync.Get() - defer sync.Put(buf) - - w, err := gzip.NewWriterLevel(buf, c.level) - if err != nil { - return nil, err - } - - if _, err := w.Write(b); err != nil { - return nil, err - } - - if err := w.Close(); err != nil { - return nil, err + return encoding.Codec[[]byte, []byte]{ + Encode: func(data []byte) ([]byte, error) { + buf := sync.Get() + defer sync.Put(buf) + + w, err := gzip.NewWriterLevel(buf, o.level) + if err != nil { + return nil, err + } + + if _, err := w.Write(data); err != nil { + return nil, err + } + + if err := w.Close(); err != nil { + return nil, err + } + + return append([]byte(nil), buf.Bytes()...), nil + }, + Decode: func(data []byte, v *[]byte) error { + r, err := gzip.NewReader(bytes.NewReader(data)) + if err != nil { + return err + } + defer r.Close() + + var src io.Reader = r + if o.maxDecodedSize > 0 { + src = io.LimitReader(r, o.maxDecodedSize+1) + } + + decoded, err := io.ReadAll(src) + if err != nil { + return err + } + + if o.maxDecodedSize > 0 && int64(len(decoded)) > o.maxDecodedSize { + return fmt.Errorf("gzip: decompressed size exceeds limit of %d bytes", o.maxDecodedSize) + } + + *v = decoded + return nil + }, } - - return append([]byte(nil), buf.Bytes()...), nil -} - -func (c *Codec[T]) Decode(b []byte, v *T) error { - r, err := gzip.NewReader(bytes.NewReader(b)) - if err != nil { - return err - } - defer r.Close() - - var src io.Reader = r - if c.maxDecodedSize > 0 { - src = io.LimitReader(r, c.maxDecodedSize+1) - } - - data, err := io.ReadAll(src) - if err != nil { - return err - } - - if c.maxDecodedSize > 0 && int64(len(data)) > c.maxDecodedSize { - return fmt.Errorf("gzip: decompressed size exceeds limit of %d bytes", c.maxDecodedSize) - } - - return c.codec.Decode(data, v) } diff --git a/gzip/codec_test.go b/gzip/codec_test.go index 54f433b..edaf637 100644 --- a/gzip/codec_test.go +++ b/gzip/codec_test.go @@ -3,16 +3,17 @@ package gzip_test import ( "fmt" + goencode "github.com/foomo/goencode" "github.com/foomo/goencode/gzip" json "github.com/foomo/goencode/json/v1" ) -func ExampleCodec() { +func ExampleNewCodec() { type Data struct { Name string } - c := gzip.NewCodec(json.NewCodec[Data]()) + c := goencode.PipeCodec(json.NewCodec[Data](), gzip.NewCodec()) encoded, err := c.Encode(Data{Name: "example-123"}) if err != nil { diff --git a/gzip/streamcodec.go b/gzip/streamcodec.go index 7b91b8d..bbdc97c 100644 --- a/gzip/streamcodec.go +++ b/gzip/streamcodec.go @@ -2,21 +2,15 @@ package gzip import ( "compress/gzip" + "fmt" "io" encoding "github.com/foomo/goencode" ) -// StreamCodec is a StreamCodec[T] that applies gzip compression on top of another StreamCodec[T]. +// NewStreamCodec returns a gzip compression stream codec. // It is safe for concurrent use. -type StreamCodec[T any] struct { - codec encoding.StreamCodec[T] - level int - maxDecodedSize int64 -} - -// NewStreamCodec returns a gzip compression stream codec that delegates serialization to codec. -func NewStreamCodec[T any](codec encoding.StreamCodec[T], opts ...Option) *StreamCodec[T] { +func NewStreamCodec(opts ...Option) encoding.StreamCodec[[]byte] { o := options{ level: gzip.DefaultCompression, } @@ -24,38 +18,43 @@ func NewStreamCodec[T any](codec encoding.StreamCodec[T], opts ...Option) *Strea opt(&o) } - return &StreamCodec[T]{ - codec: codec, - level: o.level, - maxDecodedSize: o.maxDecodedSize, - } -} - -func (c *StreamCodec[T]) Encode(w io.Writer, v T) error { - gw, err := gzip.NewWriterLevel(w, c.level) - if err != nil { - return err - } - - if err := c.codec.Encode(gw, v); err != nil { - gw.Close() - return err + return encoding.StreamCodec[[]byte]{ + Encode: func(w io.Writer, data []byte) error { + gw, err := gzip.NewWriterLevel(w, o.level) + if err != nil { + return err + } + + if _, err := gw.Write(data); err != nil { + gw.Close() + return err + } + + return gw.Close() + }, + Decode: func(r io.Reader, v *[]byte) error { + gr, err := gzip.NewReader(r) + if err != nil { + return err + } + defer gr.Close() + + var src io.Reader = gr + if o.maxDecodedSize > 0 { + src = io.LimitReader(gr, o.maxDecodedSize+1) + } + + data, err := io.ReadAll(src) + if err != nil { + return err + } + + if o.maxDecodedSize > 0 && int64(len(data)) > o.maxDecodedSize { + return fmt.Errorf("gzip: decompressed size exceeds limit of %d bytes", o.maxDecodedSize) + } + + *v = data + return nil + }, } - - return gw.Close() -} - -func (c *StreamCodec[T]) Decode(r io.Reader, v *T) error { - gr, err := gzip.NewReader(r) - if err != nil { - return err - } - defer gr.Close() - - var src io.Reader = gr - if c.maxDecodedSize > 0 { - src = io.LimitReader(gr, c.maxDecodedSize+1) - } - - return c.codec.Decode(src, v) } diff --git a/gzip/streamcodec_test.go b/gzip/streamcodec_test.go index ee02d2b..78d7474 100644 --- a/gzip/streamcodec_test.go +++ b/gzip/streamcodec_test.go @@ -5,29 +5,25 @@ import ( "fmt" "github.com/foomo/goencode/gzip" - json "github.com/foomo/goencode/json/v1" ) -func ExampleStreamCodec() { - type Data struct { - Name string - } - - c := gzip.NewStreamCodec(json.NewStreamCodec[Data]()) +func ExampleNewStreamCodec() { + c := gzip.NewStreamCodec() + input := []byte("hello gzip stream") var buf bytes.Buffer - if err := c.Encode(&buf, Data{Name: "example-123"}); err != nil { + if err := c.Encode(&buf, input); err != nil { fmt.Printf("Encode failed: %v\n", err) return } - var decoded Data + var decoded []byte if err := c.Decode(&buf, &decoded); err != nil { fmt.Printf("Decode failed: %v\n", err) return } - fmt.Printf("Decoded Name: %s\n", decoded.Name) + fmt.Printf("Decoded: %s\n", string(decoded)) // Output: - // Decoded Name: example-123 + // Decoded: hello gzip stream } diff --git a/snappy/benchmark_test.go b/snappy/benchmark_test.go index ead807c..dd78cbc 100644 --- a/snappy/benchmark_test.go +++ b/snappy/benchmark_test.go @@ -3,13 +3,14 @@ package snappy_test import ( "testing" + goencode "github.com/foomo/goencode" "github.com/foomo/goencode/internal/testdata" json "github.com/foomo/goencode/json/v1" "github.com/foomo/goencode/snappy" ) func BenchmarkCodec(b *testing.B) { - c := snappy.NewCodec(json.NewCodec[*testdata.User]()) + c := goencode.PipeCodec(json.NewCodec[*testdata.User](), snappy.NewCodec()) b.Run("encode", func(b *testing.B) { v := testdata.NewUser() diff --git a/snappy/codec.go b/snappy/codec.go index 18ca1e9..62da011 100644 --- a/snappy/codec.go +++ b/snappy/codec.go @@ -5,33 +5,20 @@ import ( "github.com/golang/snappy" ) -// Codec is a Codec[T] that applies Snappy compression on top of another Codec[T]. +// NewCodec returns a Snappy compression codec. // It is safe for concurrent use. -type Codec[T any] struct { - codec encoding.Codec[T] -} - -// NewCodec returns a Snappy compression codec that delegates serialization to codec. -func NewCodec[T any](codec encoding.Codec[T]) *Codec[T] { - return &Codec[T]{ - codec: codec, - } -} - -func (c *Codec[T]) Encode(v T) ([]byte, error) { - b, err := c.codec.Encode(v) - if err != nil { - return nil, err +func NewCodec() encoding.Codec[[]byte, []byte] { + return encoding.Codec[[]byte, []byte]{ + Encode: func(data []byte) ([]byte, error) { + return snappy.Encode(nil, data), nil + }, + Decode: func(data []byte, v *[]byte) error { + decoded, err := snappy.Decode(nil, data) + if err != nil { + return err + } + *v = decoded + return nil + }, } - - return snappy.Encode(nil, b), nil -} - -func (c *Codec[T]) Decode(b []byte, v *T) error { - data, err := snappy.Decode(nil, b) - if err != nil { - return err - } - - return c.codec.Decode(data, v) } diff --git a/snappy/codec_test.go b/snappy/codec_test.go index 921013f..6a3f31c 100644 --- a/snappy/codec_test.go +++ b/snappy/codec_test.go @@ -3,16 +3,17 @@ package snappy_test import ( "fmt" + goencode "github.com/foomo/goencode" json "github.com/foomo/goencode/json/v1" "github.com/foomo/goencode/snappy" ) -func ExampleCodec() { +func ExampleNewCodec() { type Data struct { Name string } - c := snappy.NewCodec(json.NewCodec[Data]()) + c := goencode.PipeCodec(json.NewCodec[Data](), snappy.NewCodec()) encoded, err := c.Encode(Data{Name: "example-123"}) if err != nil { diff --git a/snappy/streamcodec.go b/snappy/streamcodec.go index 3244be8..49cfbf7 100644 --- a/snappy/streamcodec.go +++ b/snappy/streamcodec.go @@ -7,29 +7,24 @@ import ( "github.com/golang/snappy" ) -// StreamCodec is a StreamCodec[T] that applies Snappy compression on top of another StreamCodec[T]. +// NewStreamCodec returns a Snappy compression stream codec. // It is safe for concurrent use. -type StreamCodec[T any] struct { - codec encoding.StreamCodec[T] -} - -// NewStreamCodec returns a Snappy compression stream codec that delegates serialization to codec. -func NewStreamCodec[T any](codec encoding.StreamCodec[T]) *StreamCodec[T] { - return &StreamCodec[T]{ - codec: codec, +func NewStreamCodec() encoding.StreamCodec[[]byte] { + return encoding.StreamCodec[[]byte]{ + Encode: func(w io.Writer, data []byte) error { + sw := snappy.NewBufferedWriter(w) + if _, err := sw.Write(data); err != nil { + return err + } + return sw.Close() + }, + Decode: func(r io.Reader, v *[]byte) error { + data, err := io.ReadAll(snappy.NewReader(r)) + if err != nil { + return err + } + *v = data + return nil + }, } } - -func (c *StreamCodec[T]) Encode(w io.Writer, v T) error { - sw := snappy.NewBufferedWriter(w) - - if err := c.codec.Encode(sw, v); err != nil { - return err - } - - return sw.Close() -} - -func (c *StreamCodec[T]) Decode(r io.Reader, v *T) error { - return c.codec.Decode(snappy.NewReader(r), v) -} diff --git a/snappy/streamcodec_test.go b/snappy/streamcodec_test.go index 8b821f7..42536ef 100644 --- a/snappy/streamcodec_test.go +++ b/snappy/streamcodec_test.go @@ -4,30 +4,26 @@ import ( "bytes" "fmt" - json "github.com/foomo/goencode/json/v1" "github.com/foomo/goencode/snappy" ) -func ExampleStreamCodec() { - type Data struct { - Name string - } - - c := snappy.NewStreamCodec(json.NewStreamCodec[Data]()) +func ExampleNewStreamCodec() { + c := snappy.NewStreamCodec() + input := []byte("hello snappy stream") var buf bytes.Buffer - if err := c.Encode(&buf, Data{Name: "example-123"}); err != nil { + if err := c.Encode(&buf, input); err != nil { fmt.Printf("Encode failed: %v\n", err) return } - var decoded Data + var decoded []byte if err := c.Decode(&buf, &decoded); err != nil { fmt.Printf("Decode failed: %v\n", err) return } - fmt.Printf("Decoded Name: %s\n", decoded.Name) + fmt.Printf("Decoded: %s\n", string(decoded)) // Output: - // Decoded Name: example-123 + // Decoded: hello snappy stream } diff --git a/zstd/benchmark_test.go b/zstd/benchmark_test.go index 61e160e..6c9a20d 100644 --- a/zstd/benchmark_test.go +++ b/zstd/benchmark_test.go @@ -3,13 +3,14 @@ package zstd_test import ( "testing" + goencode "github.com/foomo/goencode" "github.com/foomo/goencode/internal/testdata" - "github.com/foomo/goencode/json/v1" + json "github.com/foomo/goencode/json/v1" "github.com/foomo/goencode/zstd" ) func BenchmarkCodec(b *testing.B) { - c := zstd.NewCodec(json.NewCodec[*testdata.User]()) + c := goencode.PipeCodec(json.NewCodec[*testdata.User](), zstd.NewCodec()) b.Run("encode", func(b *testing.B) { v := testdata.NewUser() diff --git a/zstd/codec.go b/zstd/codec.go index c24c8da..329a2f0 100644 --- a/zstd/codec.go +++ b/zstd/codec.go @@ -5,16 +5,9 @@ import ( "github.com/klauspost/compress/zstd" ) -// Codec is a Codec[T] that applies Zstandard compression on top of another Codec[T]. +// NewCodec returns a Zstandard compression codec. // It is safe for concurrent use. -type Codec[T any] struct { - codec encoding.Codec[T] - level zstd.EncoderLevel - maxDecodedSize int64 -} - -// NewCodec returns a Zstandard compression codec that delegates serialization to codec. -func NewCodec[T any](codec encoding.Codec[T], opts ...Option) *Codec[T] { +func NewCodec(opts ...Option) encoding.Codec[[]byte, []byte] { o := options{ level: zstd.SpeedDefault, } @@ -22,44 +15,35 @@ func NewCodec[T any](codec encoding.Codec[T], opts ...Option) *Codec[T] { opt(&o) } - return &Codec[T]{ - codec: codec, - level: o.level, - maxDecodedSize: o.maxDecodedSize, - } -} - -func (c *Codec[T]) Encode(v T) ([]byte, error) { - b, err := c.codec.Encode(v) - if err != nil { - return nil, err - } - - enc, err := zstd.NewWriter(nil, zstd.WithEncoderLevel(c.level)) - if err != nil { - return nil, err - } - defer enc.Close() - - return enc.EncodeAll(b, nil), nil -} - -func (c *Codec[T]) Decode(b []byte, v *T) error { - opts := []zstd.DOption{} - if c.maxDecodedSize > 0 { - opts = append(opts, zstd.WithDecoderMaxMemory(uint64(c.maxDecodedSize))) - } - - dec, err := zstd.NewReader(nil, opts...) - if err != nil { - return err - } - defer dec.Close() - - data, err := dec.DecodeAll(b, nil) - if err != nil { - return err + return encoding.Codec[[]byte, []byte]{ + Encode: func(data []byte) ([]byte, error) { + enc, err := zstd.NewWriter(nil, zstd.WithEncoderLevel(o.level)) + if err != nil { + return nil, err + } + defer enc.Close() + + return enc.EncodeAll(data, nil), nil + }, + Decode: func(data []byte, v *[]byte) error { + dopts := []zstd.DOption{} + if o.maxDecodedSize > 0 { + dopts = append(dopts, zstd.WithDecoderMaxMemory(uint64(o.maxDecodedSize))) + } + + dec, err := zstd.NewReader(nil, dopts...) + if err != nil { + return err + } + defer dec.Close() + + decoded, err := dec.DecodeAll(data, nil) + if err != nil { + return err + } + + *v = decoded + return nil + }, } - - return c.codec.Decode(data, v) } diff --git a/zstd/codec_test.go b/zstd/codec_test.go index 9108c7d..bbcc835 100644 --- a/zstd/codec_test.go +++ b/zstd/codec_test.go @@ -3,16 +3,17 @@ package zstd_test import ( "fmt" - "github.com/foomo/goencode/json/v1" + goencode "github.com/foomo/goencode" + json "github.com/foomo/goencode/json/v1" "github.com/foomo/goencode/zstd" ) -func ExampleCodec() { +func ExampleNewCodec() { type Data struct { Name string } - c := zstd.NewCodec(json.NewCodec[Data]()) + c := goencode.PipeCodec(json.NewCodec[Data](), zstd.NewCodec()) encoded, err := c.Encode(Data{Name: "example-123"}) if err != nil { diff --git a/zstd/streamcodec.go b/zstd/streamcodec.go index 8368fba..a26c5cc 100644 --- a/zstd/streamcodec.go +++ b/zstd/streamcodec.go @@ -7,16 +7,9 @@ import ( "github.com/klauspost/compress/zstd" ) -// StreamCodec is a StreamCodec[T] that applies Zstandard compression on top of another StreamCodec[T]. +// NewStreamCodec returns a Zstandard compression stream codec. // It is safe for concurrent use. -type StreamCodec[T any] struct { - codec encoding.StreamCodec[T] - level zstd.EncoderLevel - maxDecodedSize int64 -} - -// NewStreamCodec returns a Zstandard compression stream codec that delegates serialization to codec. -func NewStreamCodec[T any](codec encoding.StreamCodec[T], opts ...Option) *StreamCodec[T] { +func NewStreamCodec(opts ...Option) encoding.StreamCodec[[]byte] { o := options{ level: zstd.SpeedDefault, } @@ -24,38 +17,39 @@ func NewStreamCodec[T any](codec encoding.StreamCodec[T], opts ...Option) *Strea opt(&o) } - return &StreamCodec[T]{ - codec: codec, - level: o.level, - maxDecodedSize: o.maxDecodedSize, - } -} - -func (c *StreamCodec[T]) Encode(w io.Writer, v T) error { - zw, err := zstd.NewWriter(w, zstd.WithEncoderLevel(c.level)) - if err != nil { - return err - } - - if err := c.codec.Encode(zw, v); err != nil { - zw.Close() - return err + return encoding.StreamCodec[[]byte]{ + Encode: func(w io.Writer, data []byte) error { + zw, err := zstd.NewWriter(w, zstd.WithEncoderLevel(o.level)) + if err != nil { + return err + } + + if _, err := zw.Write(data); err != nil { + zw.Close() + return err + } + + return zw.Close() + }, + Decode: func(r io.Reader, v *[]byte) error { + dopts := []zstd.DOption{} + if o.maxDecodedSize > 0 { + dopts = append(dopts, zstd.WithDecoderMaxMemory(uint64(o.maxDecodedSize))) + } + + zr, err := zstd.NewReader(r, dopts...) + if err != nil { + return err + } + defer zr.Close() + + data, err := io.ReadAll(zr) + if err != nil { + return err + } + + *v = data + return nil + }, } - - return zw.Close() -} - -func (c *StreamCodec[T]) Decode(r io.Reader, v *T) error { - opts := []zstd.DOption{} - if c.maxDecodedSize > 0 { - opts = append(opts, zstd.WithDecoderMaxMemory(uint64(c.maxDecodedSize))) - } - - zr, err := zstd.NewReader(r, opts...) - if err != nil { - return err - } - defer zr.Close() - - return c.codec.Decode(zr, v) } diff --git a/zstd/streamcodec_test.go b/zstd/streamcodec_test.go index 33298f4..f062ee4 100644 --- a/zstd/streamcodec_test.go +++ b/zstd/streamcodec_test.go @@ -4,30 +4,26 @@ import ( "bytes" "fmt" - "github.com/foomo/goencode/json/v1" "github.com/foomo/goencode/zstd" ) -func ExampleStreamCodec() { - type Data struct { - Name string - } - - c := zstd.NewStreamCodec(json.NewStreamCodec[Data]()) +func ExampleNewStreamCodec() { + c := zstd.NewStreamCodec() + input := []byte("hello zstd stream") var buf bytes.Buffer - if err := c.Encode(&buf, Data{Name: "example-123"}); err != nil { + if err := c.Encode(&buf, input); err != nil { fmt.Printf("Encode failed: %v\n", err) return } - var decoded Data + var decoded []byte if err := c.Decode(&buf, &decoded); err != nil { fmt.Printf("Decode failed: %v\n", err) return } - fmt.Printf("Decoded Name: %s\n", decoded.Name) + fmt.Printf("Decoded: %s\n", string(decoded)) // Output: - // Decoded Name: example-123 + // Decoded: hello zstd stream } From 5f7c95b6850799e825f9cb62dbeb087c6d2eb3e8 Mon Sep 17 00:00:00 2001 From: franklin Date: Tue, 21 Apr 2026 08:16:45 +0200 Subject: [PATCH 07/29] refactor: update file codec to accept Codec[T, []byte] --- file/codec.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/file/codec.go b/file/codec.go index 7eafe84..87a0c7e 100644 --- a/file/codec.go +++ b/file/codec.go @@ -8,16 +8,16 @@ import ( encoding "github.com/foomo/goencode" ) -// Codec encodes T to a file and decodes T from a file using an underlying Codec[T]. +// Codec encodes T to a file and decodes T from a file using an underlying Codec[T, []byte]. // Writes are atomic: data is written to a temporary file and renamed into place. // It is safe for concurrent use. type Codec[T any] struct { - codec encoding.Codec[T] + codec encoding.Codec[T, []byte] perm os.FileMode } // NewCodec returns a file codec that delegates serialization to codec. -func NewCodec[T any](codec encoding.Codec[T], opts ...Option) *Codec[T] { +func NewCodec[T any](codec encoding.Codec[T, []byte], opts ...Option) *Codec[T] { o := options{ perm: 0o644, } From fb072668a8f66d09395a319a0cee0aebaa79a190 Mon Sep 17 00:00:00 2001 From: franklin Date: Tue, 21 Apr 2026 08:38:32 +0200 Subject: [PATCH 08/29] refactor: migrate submodule codecs (json/v2, yaml, msgpack) to Codec[S,T] --- json/v2/codec.go | 43 +++++++++++++----------- json/v2/codec_test.go | 2 +- msgpack/tinylib/codec.go | 44 +++++++++++-------------- msgpack/tinylib/codec_test.go | 2 +- msgpack/tinylib/streamcodec.go | 42 +++++++++++------------ msgpack/tinylib/streamcodec_test.go | 2 +- msgpack/vmihailenco/codec.go | 23 +++++++------ msgpack/vmihailenco/codec_test.go | 2 +- msgpack/vmihailenco/streamcodec.go | 23 +++++++------ msgpack/vmihailenco/streamcodec_test.go | 2 +- yaml/v2/codec.go | 26 +++++++-------- yaml/v2/codec_test.go | 2 +- yaml/v3/codec.go | 26 +++++++-------- yaml/v3/codec_test.go | 2 +- yaml/v4/codec.go | 26 +++++++-------- yaml/v4/codec_test.go | 2 +- 16 files changed, 128 insertions(+), 141 deletions(-) diff --git a/json/v2/codec.go b/json/v2/codec.go index aad5041..d86bb10 100644 --- a/json/v2/codec.go +++ b/json/v2/codec.go @@ -3,29 +3,32 @@ package json import ( "io" + encoding "github.com/foomo/goencode" "github.com/go-json-experiment/json" ) -// Codec is a Codec[T] backed by encoding/json. -// It is zero-allocation on the encode path for small structs and safe for -// concurrent use. -type Codec[T any] struct{} - -// NewCodec returns a JSON serializer for T. -func NewCodec[T any]() *Codec[T] { return &Codec[T]{} } - -func (Codec[T]) Encode(v T) ([]byte, error) { - return json.Marshal(v) -} - -func (Codec[T]) Decode(b []byte, v *T) error { - return json.Unmarshal(b, v) -} - -func (Codec[T]) EncodeTo(w io.Writer, v T) error { - return json.MarshalWrite(w, v) +// NewCodec returns a JSON codec for T backed by go-json-experiment/json. +// It is safe for concurrent use. +func NewCodec[T any]() encoding.Codec[T, []byte] { + return encoding.Codec[T, []byte]{ + Encode: func(v T) ([]byte, error) { + return json.Marshal(v) + }, + Decode: func(b []byte, v *T) error { + return json.Unmarshal(b, v) + }, + } } -func (Codec[T]) DecodeFrom(r io.Reader, v *T) error { - return json.UnmarshalRead(r, v) +// NewStreamCodec returns a JSON stream codec for T backed by go-json-experiment/json. +// It is safe for concurrent use. +func NewStreamCodec[T any]() encoding.StreamCodec[T] { + return encoding.StreamCodec[T]{ + Encode: func(w io.Writer, v T) error { + return json.MarshalWrite(w, v) + }, + Decode: func(r io.Reader, v *T) error { + return json.UnmarshalRead(r, v) + }, + } } diff --git a/json/v2/codec_test.go b/json/v2/codec_test.go index 21aa8e2..02cea67 100644 --- a/json/v2/codec_test.go +++ b/json/v2/codec_test.go @@ -6,7 +6,7 @@ import ( json "github.com/foomo/goencode/json/v2" ) -func ExampleCodec() { +func ExampleNewCodec() { type Data struct { Name string } diff --git a/msgpack/tinylib/codec.go b/msgpack/tinylib/codec.go index 4787d2d..19a8622 100644 --- a/msgpack/tinylib/codec.go +++ b/msgpack/tinylib/codec.go @@ -3,35 +3,31 @@ package msgpack import ( "fmt" + encoding "github.com/foomo/goencode" "github.com/tinylib/msgp/msgp" ) -// Codec is a Codec[T] backed by tinylib/msgp. +// NewCodec returns a msgpack codec for T backed by tinylib/msgp. // T must have msgp code generation (go:generate msgp) so that // *T implements msgp.Marshaler and msgp.Unmarshaler. // It is safe for concurrent use. -type Codec[T any] struct{} - -// NewCodec returns a msgpack serializer for T. -func NewCodec[T any]() *Codec[T] { return &Codec[T]{} } - -func (Codec[T]) Encode(v T) ([]byte, error) { - if m, ok := any(v).(msgp.Marshaler); ok { - return m.MarshalMsg(nil) +func NewCodec[T any]() encoding.Codec[T, []byte] { + return encoding.Codec[T, []byte]{ + Encode: func(v T) ([]byte, error) { + if m, ok := any(v).(msgp.Marshaler); ok { + return m.MarshalMsg(nil) + } + if m, ok := any(&v).(msgp.Marshaler); ok { + return m.MarshalMsg(nil) + } + return nil, fmt.Errorf("msgpack: %T does not implement msgp.Marshaler", v) + }, + Decode: func(b []byte, v *T) error { + if u, ok := any(v).(msgp.Unmarshaler); ok { + _, err := u.UnmarshalMsg(b) + return err + } + return fmt.Errorf("msgpack: %T does not implement msgp.Unmarshaler", v) + }, } - - if m, ok := any(&v).(msgp.Marshaler); ok { - return m.MarshalMsg(nil) - } - - return nil, fmt.Errorf("msgpack: %T does not implement msgp.Marshaler", v) -} - -func (Codec[T]) Decode(b []byte, v *T) error { - if u, ok := any(v).(msgp.Unmarshaler); ok { - _, err := u.UnmarshalMsg(b) - return err - } - - return fmt.Errorf("msgpack: %T does not implement msgp.Unmarshaler", v) } diff --git a/msgpack/tinylib/codec_test.go b/msgpack/tinylib/codec_test.go index c5382e9..115a6d4 100644 --- a/msgpack/tinylib/codec_test.go +++ b/msgpack/tinylib/codec_test.go @@ -7,7 +7,7 @@ import ( "github.com/foomo/goencode/msgpack/tinylib/testdata" ) -func ExampleCodec() { +func ExampleNewCodec() { c := msgpack.NewCodec[testdata.User]() encoded, err := c.Encode(*testdata.NewUserTinyLib()) diff --git a/msgpack/tinylib/streamcodec.go b/msgpack/tinylib/streamcodec.go index 7332fff..4c75bad 100644 --- a/msgpack/tinylib/streamcodec.go +++ b/msgpack/tinylib/streamcodec.go @@ -4,34 +4,30 @@ import ( "fmt" "io" + encoding "github.com/foomo/goencode" "github.com/tinylib/msgp/msgp" ) -// StreamCodec is a StreamCodec[T] backed by tinylib/msgp. +// NewStreamCodec returns a msgpack stream codec for T backed by tinylib/msgp. // T must have msgp code generation (go:generate msgp) so that // *T implements msgp.Encodable and msgp.Decodable. // It is safe for concurrent use. -type StreamCodec[T any] struct{} - -// NewStreamCodec returns a msgpack stream serializer for T. -func NewStreamCodec[T any]() *StreamCodec[T] { return &StreamCodec[T]{} } - -func (StreamCodec[T]) Encode(w io.Writer, v T) error { - if e, ok := any(v).(msgp.Encodable); ok { - return msgp.Encode(w, e) +func NewStreamCodec[T any]() encoding.StreamCodec[T] { + return encoding.StreamCodec[T]{ + Encode: func(w io.Writer, v T) error { + if e, ok := any(v).(msgp.Encodable); ok { + return msgp.Encode(w, e) + } + if e, ok := any(&v).(msgp.Encodable); ok { + return msgp.Encode(w, e) + } + return fmt.Errorf("msgpack: %T does not implement msgp.Encodable", v) + }, + Decode: func(r io.Reader, v *T) error { + if d, ok := any(v).(msgp.Decodable); ok { + return msgp.Decode(r, d) + } + return fmt.Errorf("msgpack: %T does not implement msgp.Decodable", v) + }, } - - if e, ok := any(&v).(msgp.Encodable); ok { - return msgp.Encode(w, e) - } - - return fmt.Errorf("msgpack: %T does not implement msgp.Encodable", v) -} - -func (StreamCodec[T]) Decode(r io.Reader, v *T) error { - if d, ok := any(v).(msgp.Decodable); ok { - return msgp.Decode(r, d) - } - - return fmt.Errorf("msgpack: %T does not implement msgp.Decodable", v) } diff --git a/msgpack/tinylib/streamcodec_test.go b/msgpack/tinylib/streamcodec_test.go index 9cc9302..1ee71ca 100644 --- a/msgpack/tinylib/streamcodec_test.go +++ b/msgpack/tinylib/streamcodec_test.go @@ -8,7 +8,7 @@ import ( "github.com/foomo/goencode/msgpack/tinylib/testdata" ) -func ExampleStreamCodec() { +func ExampleNewStreamCodec() { c := msgpack.NewStreamCodec[testdata.User]() var buf bytes.Buffer diff --git a/msgpack/vmihailenco/codec.go b/msgpack/vmihailenco/codec.go index 99a3ecd..6699088 100644 --- a/msgpack/vmihailenco/codec.go +++ b/msgpack/vmihailenco/codec.go @@ -1,20 +1,19 @@ package msgpack import ( + encoding "github.com/foomo/goencode" "github.com/vmihailenco/msgpack/v5" ) -// Codec is a Codec[T] backed by vmihailenco/msgpack/v5. +// NewCodec returns a msgpack codec for T backed by vmihailenco/msgpack/v5. // It is safe for concurrent use. -type Codec[T any] struct{} - -// NewCodec returns a msgpack serializer for T. -func NewCodec[T any]() *Codec[T] { return &Codec[T]{} } - -func (Codec[T]) Encode(v T) ([]byte, error) { - return msgpack.Marshal(v) -} - -func (Codec[T]) Decode(b []byte, v *T) error { - return msgpack.Unmarshal(b, v) +func NewCodec[T any]() encoding.Codec[T, []byte] { + return encoding.Codec[T, []byte]{ + Encode: func(v T) ([]byte, error) { + return msgpack.Marshal(v) + }, + Decode: func(b []byte, v *T) error { + return msgpack.Unmarshal(b, v) + }, + } } diff --git a/msgpack/vmihailenco/codec_test.go b/msgpack/vmihailenco/codec_test.go index 3871c52..b1c453e 100644 --- a/msgpack/vmihailenco/codec_test.go +++ b/msgpack/vmihailenco/codec_test.go @@ -6,7 +6,7 @@ import ( msgpack "github.com/foomo/goencode/msgpack/vmihailenco" ) -func ExampleCodec() { +func ExampleNewCodec() { type Data struct { Name string `msgpack:"name"` } diff --git a/msgpack/vmihailenco/streamcodec.go b/msgpack/vmihailenco/streamcodec.go index 8bd13af..790b639 100644 --- a/msgpack/vmihailenco/streamcodec.go +++ b/msgpack/vmihailenco/streamcodec.go @@ -3,20 +3,19 @@ package msgpack import ( "io" + encoding "github.com/foomo/goencode" "github.com/vmihailenco/msgpack/v5" ) -// StreamCodec is a StreamCodec[T] backed by vmihailenco/msgpack/v5. +// NewStreamCodec returns a msgpack stream codec for T backed by vmihailenco/msgpack/v5. // It is safe for concurrent use. -type StreamCodec[T any] struct{} - -// NewStreamCodec returns a msgpack stream serializer for T. -func NewStreamCodec[T any]() *StreamCodec[T] { return &StreamCodec[T]{} } - -func (StreamCodec[T]) Encode(w io.Writer, v T) error { - return msgpack.NewEncoder(w).Encode(v) -} - -func (StreamCodec[T]) Decode(r io.Reader, v *T) error { - return msgpack.NewDecoder(r).Decode(v) +func NewStreamCodec[T any]() encoding.StreamCodec[T] { + return encoding.StreamCodec[T]{ + Encode: func(w io.Writer, v T) error { + return msgpack.NewEncoder(w).Encode(v) + }, + Decode: func(r io.Reader, v *T) error { + return msgpack.NewDecoder(r).Decode(v) + }, + } } diff --git a/msgpack/vmihailenco/streamcodec_test.go b/msgpack/vmihailenco/streamcodec_test.go index 218b23e..7d24bcb 100644 --- a/msgpack/vmihailenco/streamcodec_test.go +++ b/msgpack/vmihailenco/streamcodec_test.go @@ -7,7 +7,7 @@ import ( msgpack "github.com/foomo/goencode/msgpack/vmihailenco" ) -func ExampleStreamCodec() { +func ExampleNewStreamCodec() { type Data struct { Name string `msgpack:"name"` } diff --git a/yaml/v2/codec.go b/yaml/v2/codec.go index ca0b56c..40429fa 100644 --- a/yaml/v2/codec.go +++ b/yaml/v2/codec.go @@ -1,21 +1,19 @@ package yaml import ( + encoding "github.com/foomo/goencode" "go.yaml.in/yaml/v2" ) -// Codec is a Codec[T] backed by encoding/json. -// It is zero-allocation on the encode path for small structs and safe for -// concurrent use. -type Codec[T any] struct{} - -// NewCodec returns a Codec codec for T. -func NewCodec[T any]() Codec[T] { return Codec[T]{} } - -func (Codec[T]) Encode(v T) ([]byte, error) { - return yaml.Marshal(v) -} - -func (Codec[T]) Decode(b []byte, v *T) error { - return yaml.Unmarshal(b, v) +// NewCodec returns a YAML v2 codec for T. +// It is safe for concurrent use. +func NewCodec[T any]() encoding.Codec[T, []byte] { + return encoding.Codec[T, []byte]{ + Encode: func(v T) ([]byte, error) { + return yaml.Marshal(v) + }, + Decode: func(b []byte, v *T) error { + return yaml.Unmarshal(b, v) + }, + } } diff --git a/yaml/v2/codec_test.go b/yaml/v2/codec_test.go index 5d89798..8263316 100644 --- a/yaml/v2/codec_test.go +++ b/yaml/v2/codec_test.go @@ -6,7 +6,7 @@ import ( "github.com/foomo/goencode/yaml/v2" ) -func ExampleCodec() { +func ExampleNewCodec() { type Data struct { Name string } diff --git a/yaml/v3/codec.go b/yaml/v3/codec.go index 3594cb0..63b703b 100644 --- a/yaml/v3/codec.go +++ b/yaml/v3/codec.go @@ -1,21 +1,19 @@ package yaml import ( + encoding "github.com/foomo/goencode" "go.yaml.in/yaml/v3" ) -// Codec is a Codec[T] backed by encoding/json. -// It is zero-allocation on the encode path for small structs and safe for -// concurrent use. -type Codec[T any] struct{} - -// NewCodec returns a Codec codec for T. -func NewCodec[T any]() Codec[T] { return Codec[T]{} } - -func (Codec[T]) Encode(v T) ([]byte, error) { - return yaml.Marshal(v) -} - -func (Codec[T]) Decode(b []byte, v *T) error { - return yaml.Unmarshal(b, v) +// NewCodec returns a YAML v3 codec for T. +// It is safe for concurrent use. +func NewCodec[T any]() encoding.Codec[T, []byte] { + return encoding.Codec[T, []byte]{ + Encode: func(v T) ([]byte, error) { + return yaml.Marshal(v) + }, + Decode: func(b []byte, v *T) error { + return yaml.Unmarshal(b, v) + }, + } } diff --git a/yaml/v3/codec_test.go b/yaml/v3/codec_test.go index 69b4f8f..f733b00 100644 --- a/yaml/v3/codec_test.go +++ b/yaml/v3/codec_test.go @@ -6,7 +6,7 @@ import ( "github.com/foomo/goencode/yaml/v3" ) -func ExampleCodec() { +func ExampleNewCodec() { type Data struct { Name string } diff --git a/yaml/v4/codec.go b/yaml/v4/codec.go index 2cb651e..92510b9 100644 --- a/yaml/v4/codec.go +++ b/yaml/v4/codec.go @@ -1,21 +1,19 @@ package yaml import ( + encoding "github.com/foomo/goencode" "go.yaml.in/yaml/v4" ) -// Codec is a Codec[T] backed by encoding/json. -// It is zero-allocation on the encode path for small structs and safe for -// concurrent use. -type Codec[T any] struct{} - -// NewCodec returns a Codec codec for T. -func NewCodec[T any]() Codec[T] { return Codec[T]{} } - -func (Codec[T]) Encode(v T) ([]byte, error) { - return yaml.Marshal(v) -} - -func (Codec[T]) Decode(b []byte, v *T) error { - return yaml.Unmarshal(b, v) +// NewCodec returns a YAML v4 codec for T. +// It is safe for concurrent use. +func NewCodec[T any]() encoding.Codec[T, []byte] { + return encoding.Codec[T, []byte]{ + Encode: func(v T) ([]byte, error) { + return yaml.Marshal(v) + }, + Decode: func(b []byte, v *T) error { + return yaml.Unmarshal(b, v) + }, + } } diff --git a/yaml/v4/codec_test.go b/yaml/v4/codec_test.go index 93479ec..2a109bc 100644 --- a/yaml/v4/codec_test.go +++ b/yaml/v4/codec_test.go @@ -6,7 +6,7 @@ import ( "github.com/foomo/goencode/yaml/v4" ) -func ExampleCodec() { +func ExampleNewCodec() { type Data struct { Name string } From ab9c994f173d9ea37b35d6eb4cf33479a73495fd Mon Sep 17 00:00:00 2001 From: franklin Date: Tue, 21 Apr 2026 08:54:36 +0200 Subject: [PATCH 09/29] fix: address lint issues from codec interface migration --- ascii85/codec.go | 4 ++++ ascii85/streamcodec.go | 3 +++ asn1/streamcodec.go | 4 ++++ base32/codec.go | 4 ++++ base32/streamcodec.go | 3 +++ base64/codec.go | 4 ++++ base64/streamcodec.go | 3 +++ brotli/codec.go | 1 + brotli/streamcodec.go | 1 + brotli/streamcodec_test.go | 1 + csv/codec.go | 2 ++ csv/streamcodec.go | 4 ++++ flate/codec.go | 1 + flate/streamcodec.go | 1 + flate/streamcodec_test.go | 1 + gzip/codec.go | 1 + gzip/streamcodec.go | 1 + gzip/streamcodec_test.go | 1 + hex/codec.go | 4 ++++ hex/streamcodec.go | 2 ++ msgpack/tinylib/codec.go | 3 +++ msgpack/tinylib/streamcodec.go | 3 +++ pem/codec.go | 2 ++ pem/streamcodec.go | 7 ++++--- pipe.go | 2 ++ pipe_test.go | 8 ++++++++ snappy/codec.go | 2 ++ snappy/streamcodec.go | 3 +++ snappy/streamcodec_test.go | 1 + zstd/codec.go | 1 + zstd/streamcodec.go | 1 + zstd/streamcodec_test.go | 1 + 32 files changed, 77 insertions(+), 3 deletions(-) diff --git a/ascii85/codec.go b/ascii85/codec.go index aeb7137..6c34cb8 100644 --- a/ascii85/codec.go +++ b/ascii85/codec.go @@ -14,15 +14,19 @@ func NewCodec() encoding.Codec[[]byte, []byte] { Encode: func(v []byte) ([]byte, error) { dst := make([]byte, stdascii85.MaxEncodedLen(len(v))) n := stdascii85.Encode(dst, v) + return dst[:n], nil }, Decode: func(b []byte, v *[]byte) error { buf := bytes.NewBuffer(make([]byte, 0, len(b))) + r := stdascii85.NewDecoder(bytes.NewReader(b)) if _, err := buf.ReadFrom(r); err != nil { return err } + *v = buf.Bytes() + return nil }, } diff --git a/ascii85/streamcodec.go b/ascii85/streamcodec.go index 2989f0c..350da58 100644 --- a/ascii85/streamcodec.go +++ b/ascii85/streamcodec.go @@ -15,6 +15,7 @@ func NewStreamCodec() encoding.StreamCodec[[]byte] { dst := make([]byte, stdascii85.MaxEncodedLen(len(v))) n := stdascii85.Encode(dst, v) _, err := w.Write(dst[:n]) + return err }, Decode: func(r io.Reader, v *[]byte) error { @@ -22,7 +23,9 @@ func NewStreamCodec() encoding.StreamCodec[[]byte] { if err != nil { return err } + *v = data + return nil }, } diff --git a/asn1/streamcodec.go b/asn1/streamcodec.go index fda6108..4d950c2 100644 --- a/asn1/streamcodec.go +++ b/asn1/streamcodec.go @@ -16,7 +16,9 @@ func NewStreamCodec[T any]() encoding.StreamCodec[T] { if err != nil { return err } + _, err = w.Write(data) + return err }, Decode: func(r io.Reader, v *T) error { @@ -24,7 +26,9 @@ func NewStreamCodec[T any]() encoding.StreamCodec[T] { if err != nil { return err } + _, err = stdasn1.Unmarshal(data, v) + return err }, } diff --git a/base32/codec.go b/base32/codec.go index 2e8ee6c..7bb162d 100644 --- a/base32/codec.go +++ b/base32/codec.go @@ -13,15 +13,19 @@ func NewCodec() encoding.Codec[[]byte, []byte] { Encode: func(v []byte) ([]byte, error) { dst := make([]byte, stdbase32.StdEncoding.EncodedLen(len(v))) stdbase32.StdEncoding.Encode(dst, v) + return dst, nil }, Decode: func(b []byte, v *[]byte) error { dst := make([]byte, stdbase32.StdEncoding.DecodedLen(len(b))) + n, err := stdbase32.StdEncoding.Decode(dst, b) if err != nil { return err } + *v = dst[:n] + return nil }, } diff --git a/base32/streamcodec.go b/base32/streamcodec.go index e18150d..208f1ed 100644 --- a/base32/streamcodec.go +++ b/base32/streamcodec.go @@ -16,6 +16,7 @@ func NewStreamCodec() encoding.StreamCodec[[]byte] { if _, err := enc.Write(v); err != nil { return err } + return enc.Close() }, Decode: func(r io.Reader, v *[]byte) error { @@ -23,7 +24,9 @@ func NewStreamCodec() encoding.StreamCodec[[]byte] { if err != nil { return err } + *v = data + return nil }, } diff --git a/base64/codec.go b/base64/codec.go index 1b3b644..8191d82 100644 --- a/base64/codec.go +++ b/base64/codec.go @@ -13,15 +13,19 @@ func NewCodec() encoding.Codec[[]byte, []byte] { Encode: func(v []byte) ([]byte, error) { dst := make([]byte, stdbase64.StdEncoding.EncodedLen(len(v))) stdbase64.StdEncoding.Encode(dst, v) + return dst, nil }, Decode: func(b []byte, v *[]byte) error { dst := make([]byte, stdbase64.StdEncoding.DecodedLen(len(b))) + n, err := stdbase64.StdEncoding.Decode(dst, b) if err != nil { return err } + *v = dst[:n] + return nil }, } diff --git a/base64/streamcodec.go b/base64/streamcodec.go index 602d7f0..a1f9fb4 100644 --- a/base64/streamcodec.go +++ b/base64/streamcodec.go @@ -17,6 +17,7 @@ func NewStreamCodec() encoding.StreamCodec[[]byte] { _ = enc.Close() return err } + return enc.Close() }, Decode: func(r io.Reader, v *[]byte) error { @@ -24,7 +25,9 @@ func NewStreamCodec() encoding.StreamCodec[[]byte] { if err != nil { return err } + *v = data + return nil }, } diff --git a/brotli/codec.go b/brotli/codec.go index 48c51e0..c5097f9 100644 --- a/brotli/codec.go +++ b/brotli/codec.go @@ -55,6 +55,7 @@ func NewCodec(opts ...Option) encoding.Codec[[]byte, []byte] { } *v = decoded + return nil }, } diff --git a/brotli/streamcodec.go b/brotli/streamcodec.go index 20c1140..ed0ce65 100644 --- a/brotli/streamcodec.go +++ b/brotli/streamcodec.go @@ -47,6 +47,7 @@ func NewStreamCodec(opts ...Option) encoding.StreamCodec[[]byte] { } *v = data + return nil }, } diff --git a/brotli/streamcodec_test.go b/brotli/streamcodec_test.go index 2f642a7..16a736a 100644 --- a/brotli/streamcodec_test.go +++ b/brotli/streamcodec_test.go @@ -11,6 +11,7 @@ func ExampleNewStreamCodec() { c := brotli.NewStreamCodec() input := []byte("hello brotli stream") + var buf bytes.Buffer if err := c.Encode(&buf, input); err != nil { fmt.Printf("Encode failed: %v\n", err) diff --git a/csv/codec.go b/csv/codec.go index 0d28dc4..3cd02f2 100644 --- a/csv/codec.go +++ b/csv/codec.go @@ -34,7 +34,9 @@ func NewCodec() encoding.Codec[[][]string, []byte] { if err != nil { return err } + *v = records + return nil }, } diff --git a/csv/streamcodec.go b/csv/streamcodec.go index d29f8f1..ef0150a 100644 --- a/csv/streamcodec.go +++ b/csv/streamcodec.go @@ -16,7 +16,9 @@ func NewStreamCodec() encoding.StreamCodec[[][]string] { if err := cw.WriteAll(v); err != nil { return err } + cw.Flush() + return cw.Error() }, Decode: func(r io.Reader, v *[][]string) error { @@ -24,7 +26,9 @@ func NewStreamCodec() encoding.StreamCodec[[][]string] { if err != nil { return err } + *v = records + return nil }, } diff --git a/flate/codec.go b/flate/codec.go index b2a69c0..22e867d 100644 --- a/flate/codec.go +++ b/flate/codec.go @@ -59,6 +59,7 @@ func NewCodec(opts ...Option) encoding.Codec[[]byte, []byte] { } *v = decoded + return nil }, } diff --git a/flate/streamcodec.go b/flate/streamcodec.go index eba595c..78889f2 100644 --- a/flate/streamcodec.go +++ b/flate/streamcodec.go @@ -51,6 +51,7 @@ func NewStreamCodec(opts ...Option) encoding.StreamCodec[[]byte] { } *v = data + return nil }, } diff --git a/flate/streamcodec_test.go b/flate/streamcodec_test.go index eb88d96..a7f3c17 100644 --- a/flate/streamcodec_test.go +++ b/flate/streamcodec_test.go @@ -11,6 +11,7 @@ func ExampleNewStreamCodec() { c := flate.NewStreamCodec() input := []byte("hello flate stream") + var buf bytes.Buffer if err := c.Encode(&buf, input); err != nil { fmt.Printf("Encode failed: %v\n", err) diff --git a/gzip/codec.go b/gzip/codec.go index 74ff9b6..3ddfb8b 100644 --- a/gzip/codec.go +++ b/gzip/codec.go @@ -62,6 +62,7 @@ func NewCodec(opts ...Option) encoding.Codec[[]byte, []byte] { } *v = decoded + return nil }, } diff --git a/gzip/streamcodec.go b/gzip/streamcodec.go index bbdc97c..e9b29f0 100644 --- a/gzip/streamcodec.go +++ b/gzip/streamcodec.go @@ -54,6 +54,7 @@ func NewStreamCodec(opts ...Option) encoding.StreamCodec[[]byte] { } *v = data + return nil }, } diff --git a/gzip/streamcodec_test.go b/gzip/streamcodec_test.go index 78d7474..a2e2537 100644 --- a/gzip/streamcodec_test.go +++ b/gzip/streamcodec_test.go @@ -11,6 +11,7 @@ func ExampleNewStreamCodec() { c := gzip.NewStreamCodec() input := []byte("hello gzip stream") + var buf bytes.Buffer if err := c.Encode(&buf, input); err != nil { fmt.Printf("Encode failed: %v\n", err) diff --git a/hex/codec.go b/hex/codec.go index b6fad04..a4bd794 100644 --- a/hex/codec.go +++ b/hex/codec.go @@ -13,15 +13,19 @@ func NewCodec() encoding.Codec[[]byte, []byte] { Encode: func(v []byte) ([]byte, error) { dst := make([]byte, stdhex.EncodedLen(len(v))) stdhex.Encode(dst, v) + return dst, nil }, Decode: func(b []byte, v *[]byte) error { dst := make([]byte, stdhex.DecodedLen(len(b))) + n, err := stdhex.Decode(dst, b) if err != nil { return err } + *v = dst[:n] + return nil }, } diff --git a/hex/streamcodec.go b/hex/streamcodec.go index d80e68c..b55d8d5 100644 --- a/hex/streamcodec.go +++ b/hex/streamcodec.go @@ -20,7 +20,9 @@ func NewStreamCodec() encoding.StreamCodec[[]byte] { if err != nil { return err } + *v = data + return nil }, } diff --git a/msgpack/tinylib/codec.go b/msgpack/tinylib/codec.go index 19a8622..d0f78ef 100644 --- a/msgpack/tinylib/codec.go +++ b/msgpack/tinylib/codec.go @@ -17,9 +17,11 @@ func NewCodec[T any]() encoding.Codec[T, []byte] { if m, ok := any(v).(msgp.Marshaler); ok { return m.MarshalMsg(nil) } + if m, ok := any(&v).(msgp.Marshaler); ok { return m.MarshalMsg(nil) } + return nil, fmt.Errorf("msgpack: %T does not implement msgp.Marshaler", v) }, Decode: func(b []byte, v *T) error { @@ -27,6 +29,7 @@ func NewCodec[T any]() encoding.Codec[T, []byte] { _, err := u.UnmarshalMsg(b) return err } + return fmt.Errorf("msgpack: %T does not implement msgp.Unmarshaler", v) }, } diff --git a/msgpack/tinylib/streamcodec.go b/msgpack/tinylib/streamcodec.go index 4c75bad..cc9c64d 100644 --- a/msgpack/tinylib/streamcodec.go +++ b/msgpack/tinylib/streamcodec.go @@ -18,15 +18,18 @@ func NewStreamCodec[T any]() encoding.StreamCodec[T] { if e, ok := any(v).(msgp.Encodable); ok { return msgp.Encode(w, e) } + if e, ok := any(&v).(msgp.Encodable); ok { return msgp.Encode(w, e) } + return fmt.Errorf("msgpack: %T does not implement msgp.Encodable", v) }, Decode: func(r io.Reader, v *T) error { if d, ok := any(v).(msgp.Decodable); ok { return msgp.Decode(r, d) } + return fmt.Errorf("msgpack: %T does not implement msgp.Decodable", v) }, } diff --git a/pem/codec.go b/pem/codec.go index 9fecc63..5a4f7d9 100644 --- a/pem/codec.go +++ b/pem/codec.go @@ -19,7 +19,9 @@ func NewCodec() encoding.Codec[*stdpem.Block, []byte] { if block == nil { return errors.New("pem: no PEM block found") } + *v = block + return nil }, } diff --git a/pem/streamcodec.go b/pem/streamcodec.go index efec9c8..d1e3cb1 100644 --- a/pem/streamcodec.go +++ b/pem/streamcodec.go @@ -12,19 +12,20 @@ import ( // It is safe for concurrent use. func NewStreamCodec() encoding.StreamCodec[*stdpem.Block] { return encoding.StreamCodec[*stdpem.Block]{ - Encode: func(w io.Writer, v *stdpem.Block) error { - return stdpem.Encode(w, v) - }, + Encode: stdpem.Encode, Decode: func(r io.Reader, v **stdpem.Block) error { data, err := io.ReadAll(r) if err != nil { return err } + block, _ := stdpem.Decode(data) if block == nil { return errors.New("encoding: no PEM block found") } + *v = block + return nil }, } diff --git a/pipe.go b/pipe.go index 5dda455..9c24e22 100644 --- a/pipe.go +++ b/pipe.go @@ -8,6 +8,7 @@ func PipeEncoder[A, B, C any](first Encoder[A, B], second Encoder[B, C]) Encoder var zero C return zero, err } + return second(b) } } @@ -19,6 +20,7 @@ func PipeDecoder[A, B, C any](first Decoder[A, B], second Decoder[B, C]) Decoder if err := second(c, &b); err != nil { return err } + return first(b, a) } } diff --git a/pipe_test.go b/pipe_test.go index bbd4328..9c0fc7f 100644 --- a/pipe_test.go +++ b/pipe_test.go @@ -22,6 +22,7 @@ func TestPipeEncoder(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } + if string(got) != "42" { t.Fatalf("got %q, want %q", string(got), "42") } @@ -50,7 +51,9 @@ func TestPipeDecoder(t *testing.T) { if err != nil { return err } + *i = v + return nil }) bytesToStr := goencode.Decoder[string, []byte](func(b []byte, s *string) error { @@ -64,6 +67,7 @@ func TestPipeDecoder(t *testing.T) { if err := piped([]byte("42"), &got); err != nil { t.Fatalf("unexpected error: %v", err) } + if got != 42 { t.Fatalf("got %d, want 42", got) } @@ -79,7 +83,9 @@ func TestPipeCodec(t *testing.T) { if err != nil { return err } + *i = v + return nil }, } @@ -99,6 +105,7 @@ func TestPipeCodec(t *testing.T) { if err != nil { t.Fatalf("encode error: %v", err) } + if string(encoded) != "42" { t.Fatalf("encoded: got %q, want %q", string(encoded), "42") } @@ -107,6 +114,7 @@ func TestPipeCodec(t *testing.T) { if err := piped.Decode(encoded, &decoded); err != nil { t.Fatalf("decode error: %v", err) } + if decoded != 42 { t.Fatalf("decoded: got %d, want 42", decoded) } diff --git a/snappy/codec.go b/snappy/codec.go index 62da011..cc77e65 100644 --- a/snappy/codec.go +++ b/snappy/codec.go @@ -17,7 +17,9 @@ func NewCodec() encoding.Codec[[]byte, []byte] { if err != nil { return err } + *v = decoded + return nil }, } diff --git a/snappy/streamcodec.go b/snappy/streamcodec.go index 49cfbf7..1502b92 100644 --- a/snappy/streamcodec.go +++ b/snappy/streamcodec.go @@ -16,6 +16,7 @@ func NewStreamCodec() encoding.StreamCodec[[]byte] { if _, err := sw.Write(data); err != nil { return err } + return sw.Close() }, Decode: func(r io.Reader, v *[]byte) error { @@ -23,7 +24,9 @@ func NewStreamCodec() encoding.StreamCodec[[]byte] { if err != nil { return err } + *v = data + return nil }, } diff --git a/snappy/streamcodec_test.go b/snappy/streamcodec_test.go index 42536ef..f6fcbec 100644 --- a/snappy/streamcodec_test.go +++ b/snappy/streamcodec_test.go @@ -11,6 +11,7 @@ func ExampleNewStreamCodec() { c := snappy.NewStreamCodec() input := []byte("hello snappy stream") + var buf bytes.Buffer if err := c.Encode(&buf, input); err != nil { fmt.Printf("Encode failed: %v\n", err) diff --git a/zstd/codec.go b/zstd/codec.go index 329a2f0..6d38ed0 100644 --- a/zstd/codec.go +++ b/zstd/codec.go @@ -43,6 +43,7 @@ func NewCodec(opts ...Option) encoding.Codec[[]byte, []byte] { } *v = decoded + return nil }, } diff --git a/zstd/streamcodec.go b/zstd/streamcodec.go index a26c5cc..44164ea 100644 --- a/zstd/streamcodec.go +++ b/zstd/streamcodec.go @@ -49,6 +49,7 @@ func NewStreamCodec(opts ...Option) encoding.StreamCodec[[]byte] { } *v = data + return nil }, } diff --git a/zstd/streamcodec_test.go b/zstd/streamcodec_test.go index f062ee4..b638834 100644 --- a/zstd/streamcodec_test.go +++ b/zstd/streamcodec_test.go @@ -11,6 +11,7 @@ func ExampleNewStreamCodec() { c := zstd.NewStreamCodec() input := []byte("hello zstd stream") + var buf bytes.Buffer if err := c.Encode(&buf, input); err != nil { fmt.Printf("Encode failed: %v\n", err) From ea302381c1d7ab3851e45b8569ba880d1018d383 Mon Sep 17 00:00:00 2001 From: franklin Date: Tue, 21 Apr 2026 09:10:20 +0200 Subject: [PATCH 10/29] docs: update README.md for Codec[S,T] interface Also migrate toml codec to Codec[S,T] function type. --- README.md | 45 ++++++++++++++++++++++++++++++--------------- toml/codec.go | 24 ++++++++++++------------ toml/codec_test.go | 2 +- 3 files changed, 43 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index f69cf8a..c315b02 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,9 @@ ## Features -- Generic `Codec[T]` and `StreamCodec[T]` interfaces with compile-time type safety -- Composable compression wrappers (gzip, flate, snappy, zstd, brotli) using the decorator pattern +- Generic `Codec[S, T]` and `StreamCodec[S]` function-type structs with compile-time type safety +- Type-safe codec composition via `PipeCodec` (e.g., JSON → gzip, JSON → base64) +- Standalone compression codecs (gzip, flate, snappy, zstd, brotli) as `Codec[[]byte, []byte]` - Stream support via `io.Reader`/`io.Writer` for memory-efficient pipelines - Atomic file I/O with temp file + rename - Zero dependencies in the core module @@ -24,36 +25,50 @@ go get github.com/foomo/goencode ``` -## Core Interfaces +## Core Types ```go -// Byte-oriented -type Codec[T any] interface { -Encode(v T) ([]byte, error) -Decode(b []byte, v *T) error +// Function types +type Encoder[S, T any] func(s S) (T, error) +type Decoder[S, T any] func(t T, s *S) error + +// Byte-oriented codec bundle +type Codec[S, T any] struct { + Encode Encoder[S, T] + Decode Decoder[S, T] } -// Stream-oriented -type StreamCodec[T any] interface { -Encode(w io.Writer, v T) error -Decode(r io.Reader, v *T) error +// Stream function types +type StreamEncoder[S any] func(w io.Writer, s S) error +type StreamDecoder[S any] func(r io.Reader, s *S) error + +// Stream-oriented codec bundle +type StreamCodec[S any] struct { + Encode StreamEncoder[S] + Decode StreamDecoder[S] } + +// Composition +func PipeCodec[A, B, C any](first Codec[A, B], second Codec[B, C]) Codec[A, C] ``` ## Quick Start ```go // Basic JSON encode/decode -c := json.NewCodec[User]() +c := json.NewCodec[User]() // Codec[User, []byte] b, err := c.Encode(User{Name: "Alice", Age: 30}) var u User err = c.Decode(b, &u) -// Compose with compression -c := gzip.NewCodec[User](json.NewCodec[User]()) +// Compose with compression via PipeCodec +c := goencode.PipeCodec(json.NewCodec[User](), gzip.NewCodec()) + +// Chain multiple codecs: JSON → base64 +c := goencode.PipeCodec(json.NewCodec[User](), base64.NewCodec()) // Add atomic file persistence -fc := file.NewCodec[User](gzip.NewCodec[User](json.NewCodec[User]())) +fc := file.NewCodec(goencode.PipeCodec(json.NewCodec[User](), gzip.NewCodec())) err := fc.Encode("/tmp/user.json.gz", user) ``` diff --git a/toml/codec.go b/toml/codec.go index 4dfe70d..b0b9429 100644 --- a/toml/codec.go +++ b/toml/codec.go @@ -1,20 +1,20 @@ package toml import ( + encoding "github.com/foomo/goencode" + "github.com/BurntSushi/toml" ) -// Codec is a Codec[T] backed by github.com/BurntSushi/toml. -// It is safe for concurrent use. -type Codec[T any] struct{} - // NewCodec returns a TOML codec for T. -func NewCodec[T any]() Codec[T] { return Codec[T]{} } - -func (Codec[T]) Encode(v T) ([]byte, error) { - return toml.Marshal(v) -} - -func (Codec[T]) Decode(b []byte, v *T) error { - return toml.Unmarshal(b, v) +// It is safe for concurrent use. +func NewCodec[T any]() encoding.Codec[T, []byte] { + return encoding.Codec[T, []byte]{ + Encode: func(v T) ([]byte, error) { + return toml.Marshal(v) + }, + Decode: func(b []byte, v *T) error { + return toml.Unmarshal(b, v) + }, + } } diff --git a/toml/codec_test.go b/toml/codec_test.go index 785f11e..5ddfc49 100644 --- a/toml/codec_test.go +++ b/toml/codec_test.go @@ -6,7 +6,7 @@ import ( "github.com/foomo/goencode/toml" ) -func ExampleCodec() { +func ExampleNewCodec() { type Data struct { Name string } From 27fc2bd904132349ca516bc66844b532d7d689a4 Mon Sep 17 00:00:00 2001 From: franklin Date: Tue, 21 Apr 2026 09:25:28 +0200 Subject: [PATCH 11/29] refactor: improve test examples for public API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Convert Pipe tests to Examples for godoc visibility - Migrate toml streamcodec to Codec[S,T] function type - Add json/v2 ExampleNewStreamCodec - Fix toml ExampleStreamCodec → ExampleNewStreamCodec --- json/v2/streamcodec_test.go | 32 +++++++++ pipe_test.go | 140 ++++++++++++++++++------------------ toml/streamcodec.go | 25 +++---- toml/streamcodec_test.go | 2 +- 4 files changed, 117 insertions(+), 82 deletions(-) create mode 100644 json/v2/streamcodec_test.go diff --git a/json/v2/streamcodec_test.go b/json/v2/streamcodec_test.go new file mode 100644 index 0000000..4e85ee7 --- /dev/null +++ b/json/v2/streamcodec_test.go @@ -0,0 +1,32 @@ +package json_test + +import ( + "bytes" + "fmt" + + json "github.com/foomo/goencode/json/v2" +) + +func ExampleNewStreamCodec() { + type Data struct { + Name string + } + + c := json.NewStreamCodec[Data]() + + var buf bytes.Buffer + if err := c.Encode(&buf, Data{Name: "example-123"}); err != nil { + fmt.Printf("Encode failed: %v\n", err) + return + } + + var decoded Data + if err := c.Decode(&buf, &decoded); err != nil { + fmt.Printf("Decode failed: %v\n", err) + return + } + + fmt.Printf("Decoded Name: %s\n", decoded.Name) + // Output: + // Decoded Name: example-123 +} diff --git a/pipe_test.go b/pipe_test.go index 9c0fc7f..799d4a0 100644 --- a/pipe_test.go +++ b/pipe_test.go @@ -8,44 +8,75 @@ import ( goencode "github.com/foomo/goencode" ) -func TestPipeEncoder(t *testing.T) { - intToStr := goencode.Encoder[int, string](func(i int) (string, error) { - return strconv.Itoa(i), nil - }) - strToBytes := goencode.Encoder[string, []byte](func(s string) ([]byte, error) { - return []byte(s), nil - }) +func ExamplePipeCodec() { + intStr := goencode.Codec[int, string]{ + Encode: func(i int) (string, error) { + return strconv.Itoa(i), nil + }, + Decode: func(s string, i *int) error { + v, err := strconv.Atoi(s) + if err != nil { + return err + } - piped := goencode.PipeEncoder(intToStr, strToBytes) + *i = v - got, err := piped(42) + return nil + }, + } + strBytes := goencode.Codec[string, []byte]{ + Encode: func(s string) ([]byte, error) { + return []byte(s), nil + }, + Decode: func(b []byte, s *string) error { + *s = string(b) + return nil + }, + } + + piped := goencode.PipeCodec(intStr, strBytes) + + encoded, err := piped.Encode(42) if err != nil { - t.Fatalf("unexpected error: %v", err) + fmt.Printf("Encode failed: %v\n", err) + return } - if string(got) != "42" { - t.Fatalf("got %q, want %q", string(got), "42") + var decoded int + if err := piped.Decode(encoded, &decoded); err != nil { + fmt.Printf("Decode failed: %v\n", err) + return } + + fmt.Printf("Encoded: %s\n", string(encoded)) + fmt.Printf("Decoded: %d\n", decoded) + // Output: + // Encoded: 42 + // Decoded: 42 } -func TestPipeEncoder_FirstError(t *testing.T) { - failing := goencode.Encoder[int, string](func(i int) (string, error) { - return "", fmt.Errorf("encode failed") +func ExamplePipeEncoder() { + intToStr := goencode.Encoder[int, string](func(i int) (string, error) { + return strconv.Itoa(i), nil }) - second := goencode.Encoder[string, []byte](func(s string) ([]byte, error) { - t.Fatal("second encoder should not be called") - return nil, nil + strToBytes := goencode.Encoder[string, []byte](func(s string) ([]byte, error) { + return []byte(s), nil }) - piped := goencode.PipeEncoder(failing, second) + piped := goencode.PipeEncoder(intToStr, strToBytes) - _, err := piped(42) - if err == nil { - t.Fatal("expected error") + got, err := piped(42) + if err != nil { + fmt.Printf("Error: %v\n", err) + return } + + fmt.Printf("Result: %s\n", string(got)) + // Output: + // Result: 42 } -func TestPipeDecoder(t *testing.T) { +func ExamplePipeDecoder() { strToInt := goencode.Decoder[int, string](func(s string, i *int) error { v, err := strconv.Atoi(s) if err != nil { @@ -65,57 +96,28 @@ func TestPipeDecoder(t *testing.T) { var got int if err := piped([]byte("42"), &got); err != nil { - t.Fatalf("unexpected error: %v", err) + fmt.Printf("Error: %v\n", err) + return } - if got != 42 { - t.Fatalf("got %d, want 42", got) - } + fmt.Printf("Result: %d\n", got) + // Output: + // Result: 42 } -func TestPipeCodec(t *testing.T) { - intStr := goencode.Codec[int, string]{ - Encode: func(i int) (string, error) { - return strconv.Itoa(i), nil - }, - Decode: func(s string, i *int) error { - v, err := strconv.Atoi(s) - if err != nil { - return err - } - - *i = v - - return nil - }, - } - strBytes := goencode.Codec[string, []byte]{ - Encode: func(s string) ([]byte, error) { - return []byte(s), nil - }, - Decode: func(b []byte, s *string) error { - *s = string(b) - return nil - }, - } - - piped := goencode.PipeCodec(intStr, strBytes) - - encoded, err := piped.Encode(42) - if err != nil { - t.Fatalf("encode error: %v", err) - } - - if string(encoded) != "42" { - t.Fatalf("encoded: got %q, want %q", string(encoded), "42") - } +func TestPipeEncoder_FirstError(t *testing.T) { + failing := goencode.Encoder[int, string](func(i int) (string, error) { + return "", fmt.Errorf("encode failed") + }) + second := goencode.Encoder[string, []byte](func(s string) ([]byte, error) { + t.Fatal("second encoder should not be called") + return nil, nil + }) - var decoded int - if err := piped.Decode(encoded, &decoded); err != nil { - t.Fatalf("decode error: %v", err) - } + piped := goencode.PipeEncoder(failing, second) - if decoded != 42 { - t.Fatalf("decoded: got %d, want 42", decoded) + _, err := piped(42) + if err == nil { + t.Fatal("expected error") } } diff --git a/toml/streamcodec.go b/toml/streamcodec.go index 8f8885a..29b1fc5 100644 --- a/toml/streamcodec.go +++ b/toml/streamcodec.go @@ -3,21 +3,22 @@ package toml import ( "io" + encoding "github.com/foomo/goencode" + "github.com/BurntSushi/toml" ) -// StreamCodec is a StreamCodec[T] backed by github.com/BurntSushi/toml. -// It is safe for concurrent use. -type StreamCodec[T any] struct{} - // NewStreamCodec returns a TOML stream codec for T. -func NewStreamCodec[T any]() *StreamCodec[T] { return &StreamCodec[T]{} } - -func (StreamCodec[T]) Encode(w io.Writer, v T) error { - return toml.NewEncoder(w).Encode(v) -} +// It is safe for concurrent use. +func NewStreamCodec[T any]() encoding.StreamCodec[T] { + return encoding.StreamCodec[T]{ + Encode: func(w io.Writer, v T) error { + return toml.NewEncoder(w).Encode(v) + }, + Decode: func(r io.Reader, v *T) error { + _, err := toml.NewDecoder(r).Decode(v) -func (StreamCodec[T]) Decode(r io.Reader, v *T) error { - _, err := toml.NewDecoder(r).Decode(v) - return err + return err + }, + } } diff --git a/toml/streamcodec_test.go b/toml/streamcodec_test.go index c2c2981..0596cda 100644 --- a/toml/streamcodec_test.go +++ b/toml/streamcodec_test.go @@ -7,7 +7,7 @@ import ( "github.com/foomo/goencode/toml" ) -func ExampleStreamCodec() { +func ExampleNewStreamCodec() { type Data struct { Name string } From 9d81e0c275fa687a78e81dc87b8fdb96a1565749 Mon Sep 17 00:00:00 2001 From: franklin Date: Tue, 21 Apr 2026 09:54:48 +0200 Subject: [PATCH 12/29] docs: add design spec for standalone Encoder/Decoder exports Co-Authored-By: Claude Opus 4.6 (1M context) --- ...andalone-encoder-decoder-exports-design.md | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-21-standalone-encoder-decoder-exports-design.md diff --git a/docs/superpowers/specs/2026-04-21-standalone-encoder-decoder-exports-design.md b/docs/superpowers/specs/2026-04-21-standalone-encoder-decoder-exports-design.md new file mode 100644 index 0000000..7d8724e --- /dev/null +++ b/docs/superpowers/specs/2026-04-21-standalone-encoder-decoder-exports-design.md @@ -0,0 +1,88 @@ +# Standalone Encoder/Decoder Exports + +**Date:** 2026-04-21 +**Status:** Approved + +## Problem + +Consumer APIs (e.g. a messaging library) often need only one direction — decode incoming messages or encode outgoing ones. Currently most subpackages only export `NewCodec[T]()` returning a full `Codec[S, T]`, forcing consumers to depend on both directions even when they only need one. + +The root package already defines `Encoder[S, T]` and `Decoder[S, T]` as standalone function types, and `json/v1` already exports bare `Encoder[T]`/`Decoder[T]` funcs. This pattern should be extended to all subpackages. + +## Design + +### Rule + +- **No options** → export bare funcs `Encoder` and `Decoder` (like `json/v1` today) +- **Takes options** → export `NewEncoder(opts ...Option)` and `NewDecoder(opts ...Option)` constructors + +### Simple codecs — bare funcs + +Serialization codecs (generic `[T any]`): + +| Package | Exports | +|---------|---------| +| json/v1 | `Encoder[T]`, `Decoder[T]` *(already exists)* | +| json/v2 | `Encoder[T]`, `Decoder[T]` | +| xml | `Encoder[T]`, `Decoder[T]` | +| gob | `Encoder[T]`, `Decoder[T]` | +| asn1 | `Encoder[T]`, `Decoder[T]` | +| csv | `Encoder[T]`, `Decoder[T]` | +| toml | `Encoder[T]`, `Decoder[T]` | +| yaml/v2 | `Encoder[T]`, `Decoder[T]` | +| yaml/v3 | `Encoder[T]`, `Decoder[T]` | +| yaml/v4 | `Encoder[T]`, `Decoder[T]` | +| msgpack/tinylib | `Encoder[T]`, `Decoder[T]` | +| msgpack/vmihailenco | `Encoder[T]`, `Decoder[T]` | + +Encoding codecs (no type param, `[]byte` ↔ `[]byte` or `*pem.Block` ↔ `[]byte`): + +| Package | Exports | +|---------|---------| +| base64 | `Encoder`, `Decoder` | +| base32 | `Encoder`, `Decoder` | +| hex | `Encoder`, `Decoder` | +| ascii85 | `Encoder`, `Decoder` | +| pem | `Encoder`, `Decoder` | +| snappy | `Encoder`, `Decoder` | + +### Configurable codecs — constructor funcs + +Compression codecs that accept options: + +| Package | Exports | +|---------|---------| +| gzip | `NewEncoder(opts ...Option)`, `NewDecoder(opts ...Option)` | +| flate | `NewEncoder(opts ...Option)`, `NewDecoder(opts ...Option)` | +| zstd | `NewEncoder(opts ...Option)`, `NewDecoder(opts ...Option)` | +| brotli | `NewEncoder(opts ...Option)`, `NewDecoder(opts ...Option)` | + +### Skipped + +- `file` — wraps another codec + filesystem I/O. Different abstraction, leave as-is. + +## Call-site examples + +Consumer API requiring only decode: + +```go +func NewConsumer[T any](decode goencode.Decoder[T, []byte]) { ... } + +NewConsumer(json.Decoder[MyMsg]) +NewConsumer(gob.Decoder[MyMsg]) +NewConsumer(yaml.Decoder[MyMsg]) +``` + +Compression — only encode direction: + +```go +compress := gzip.NewEncoder(gzip.WithLevel(gzip.BestCompression)) +data, err := compress(raw) +``` + +## Scope + +- Purely additive — existing `NewCodec`/`NewStreamCodec` constructors unchanged. +- Each subpackage's `NewCodec` reuses the new bare funcs / constructors internally. +- No changes to root package types (`Encoder`, `Decoder`, `Codec`, `Pipe*`). +- StreamEncoder/StreamDecoder standalone exports are out of scope for this change. From 694e2137b425c890d5fe8333365f707a15b82ec5 Mon Sep 17 00:00:00 2001 From: franklin Date: Tue, 21 Apr 2026 10:08:41 +0200 Subject: [PATCH 13/29] docs: add implementation plan for standalone Encoder/Decoder exports Co-Authored-By: Claude Opus 4.6 (1M context) --- ...4-21-standalone-encoder-decoder-exports.md | 1137 +++++++++++++++++ 1 file changed, 1137 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-21-standalone-encoder-decoder-exports.md diff --git a/docs/superpowers/plans/2026-04-21-standalone-encoder-decoder-exports.md b/docs/superpowers/plans/2026-04-21-standalone-encoder-decoder-exports.md new file mode 100644 index 0000000..741c93b --- /dev/null +++ b/docs/superpowers/plans/2026-04-21-standalone-encoder-decoder-exports.md @@ -0,0 +1,1137 @@ +# Standalone Encoder/Decoder Exports 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:** Export standalone `Encoder` and `Decoder` functions from all subpackages so consumer APIs can depend on a single direction without requiring a full `Codec`. + +**Architecture:** Each subpackage gets bare `Encoder`/`Decoder` funcs (simple codecs) or `NewEncoder`/`NewDecoder` constructors (configurable codecs). Existing `NewCodec` constructors are refactored to delegate to these new exports. The reference implementation is `json/v1/codec.go` which already has this pattern. + +**Tech Stack:** Go generics, `go.work` multi-module workspace + +--- + +### Task 1: Simple serialization codecs — stdlib (xml, gob, asn1, csv) + +These packages use stdlib only, live in the root module, and follow the same pattern: extract inline encode/decode closures into named generic funcs. + +**Files:** +- Modify: `xml/codec.go` +- Modify: `gob/codec.go` +- Modify: `asn1/codec.go` +- Modify: `csv/codec.go` + +- [ ] **Step 1: Refactor `xml/codec.go`** + +```go +package xml + +import ( + stdxml "encoding/xml" + + encoding "github.com/foomo/goencode" +) + +// Encoder encodes T to XML bytes. +func Encoder[T any](v T) ([]byte, error) { + return stdxml.Marshal(v) +} + +// Decoder decodes XML bytes into T. +func Decoder[T any](b []byte, v *T) error { + return stdxml.Unmarshal(b, v) +} + +// NewCodec returns an XML codec for T. +// It is safe for concurrent use. +func NewCodec[T any]() encoding.Codec[T, []byte] { + return encoding.Codec[T, []byte]{ + Encode: Encoder[T], + Decode: Decoder[T], + } +} +``` + +- [ ] **Step 2: Refactor `gob/codec.go`** + +```go +package gob + +import ( + "bytes" + stdgob "encoding/gob" + + encoding "github.com/foomo/goencode" + "github.com/foomo/goencode/internal/sync" +) + +// Encoder encodes T to gob bytes. +func Encoder[T any](v T) ([]byte, error) { + buf := sync.Get() + defer sync.Put(buf) + + if err := stdgob.NewEncoder(buf).Encode(v); err != nil { + return nil, err + } + + return append([]byte(nil), buf.Bytes()...), nil +} + +// Decoder decodes gob bytes into T. +func Decoder[T any](b []byte, v *T) error { + return stdgob.NewDecoder(bytes.NewReader(b)).Decode(v) +} + +// NewCodec returns a gob codec for T. +// It is safe for concurrent use. +func NewCodec[T any]() encoding.Codec[T, []byte] { + return encoding.Codec[T, []byte]{ + Encode: Encoder[T], + Decode: Decoder[T], + } +} +``` + +- [ ] **Step 3: Refactor `asn1/codec.go`** + +```go +package asn1 + +import ( + stdasn1 "encoding/asn1" + + encoding "github.com/foomo/goencode" +) + +// Encoder encodes T to ASN.1 bytes. +func Encoder[T any](v T) ([]byte, error) { + return stdasn1.Marshal(v) +} + +// Decoder decodes ASN.1 bytes into T. +func Decoder[T any](b []byte, v *T) error { + _, err := stdasn1.Unmarshal(b, v) + return err +} + +// NewCodec returns an ASN1 codec for T. +// It is safe for concurrent use. +func NewCodec[T any]() encoding.Codec[T, []byte] { + return encoding.Codec[T, []byte]{ + Encode: Encoder[T], + Decode: Decoder[T], + } +} +``` + +- [ ] **Step 4: Refactor `csv/codec.go`** + +```go +package csv + +import ( + "bytes" + stdcsv "encoding/csv" + + encoding "github.com/foomo/goencode" + "github.com/foomo/goencode/internal/sync" +) + +// Encoder encodes [][]string to CSV bytes. +func Encoder(v [][]string) ([]byte, error) { + buf := sync.Get() + defer sync.Put(buf) + + cw := stdcsv.NewWriter(buf) + if err := cw.WriteAll(v); err != nil { + return nil, err + } + + cw.Flush() + + if err := cw.Error(); err != nil { + return nil, err + } + + return append([]byte(nil), buf.Bytes()...), nil +} + +// Decoder decodes CSV bytes into [][]string. +func Decoder(b []byte, v *[][]string) error { + records, err := stdcsv.NewReader(bytes.NewReader(b)).ReadAll() + if err != nil { + return err + } + + *v = records + + return nil +} + +// NewCodec returns a CSV codec for [][]string. +// It is safe for concurrent use. +func NewCodec() encoding.Codec[[][]string, []byte] { + return encoding.Codec[[][]string, []byte]{ + Encode: Encoder, + Decode: Decoder, + } +} +``` + +- [ ] **Step 5: Run tests for stdlib codecs** + +Run: `cd /Users/franklin/Workingcopies/github.com/foomo/goencode && go test -tags=safe ./xml/... ./gob/... ./asn1/... ./csv/...` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add xml/codec.go gob/codec.go asn1/codec.go csv/codec.go +git commit -m "refactor: export standalone Encoder/Decoder in xml, gob, asn1, csv" +``` + +--- + +### Task 2: Simple encoding codecs (base64, base32, hex, ascii85, pem) + +These are `[]byte ↔ []byte` (or `*pem.Block ↔ []byte`) — no type parameters. + +**Files:** +- Modify: `base64/codec.go` +- Modify: `base32/codec.go` +- Modify: `hex/codec.go` +- Modify: `ascii85/codec.go` +- Modify: `pem/codec.go` + +- [ ] **Step 1: Refactor `base64/codec.go`** + +```go +package base64 + +import ( + stdbase64 "encoding/base64" + + encoding "github.com/foomo/goencode" +) + +// Encoder encodes bytes to Base64. +func Encoder(v []byte) ([]byte, error) { + dst := make([]byte, stdbase64.StdEncoding.EncodedLen(len(v))) + stdbase64.StdEncoding.Encode(dst, v) + + return dst, nil +} + +// Decoder decodes Base64 bytes. +func Decoder(b []byte, v *[]byte) error { + dst := make([]byte, stdbase64.StdEncoding.DecodedLen(len(b))) + + n, err := stdbase64.StdEncoding.Decode(dst, b) + if err != nil { + return err + } + + *v = dst[:n] + + return nil +} + +// NewCodec returns a Base64 codec. +// It is safe for concurrent use. +func NewCodec() encoding.Codec[[]byte, []byte] { + return encoding.Codec[[]byte, []byte]{ + Encode: Encoder, + Decode: Decoder, + } +} +``` + +- [ ] **Step 2: Refactor `base32/codec.go`** + +```go +package base32 + +import ( + stdbase32 "encoding/base32" + + encoding "github.com/foomo/goencode" +) + +// Encoder encodes bytes to Base32. +func Encoder(v []byte) ([]byte, error) { + dst := make([]byte, stdbase32.StdEncoding.EncodedLen(len(v))) + stdbase32.StdEncoding.Encode(dst, v) + + return dst, nil +} + +// Decoder decodes Base32 bytes. +func Decoder(b []byte, v *[]byte) error { + dst := make([]byte, stdbase32.StdEncoding.DecodedLen(len(b))) + + n, err := stdbase32.StdEncoding.Decode(dst, b) + if err != nil { + return err + } + + *v = dst[:n] + + return nil +} + +// NewCodec returns a Base32 codec. +// It is safe for concurrent use. +func NewCodec() encoding.Codec[[]byte, []byte] { + return encoding.Codec[[]byte, []byte]{ + Encode: Encoder, + Decode: Decoder, + } +} +``` + +- [ ] **Step 3: Refactor `hex/codec.go`** + +```go +package hex + +import ( + stdhex "encoding/hex" + + encoding "github.com/foomo/goencode" +) + +// Encoder encodes bytes to hexadecimal. +func Encoder(v []byte) ([]byte, error) { + dst := make([]byte, stdhex.EncodedLen(len(v))) + stdhex.Encode(dst, v) + + return dst, nil +} + +// Decoder decodes hexadecimal bytes. +func Decoder(b []byte, v *[]byte) error { + dst := make([]byte, stdhex.DecodedLen(len(b))) + + n, err := stdhex.Decode(dst, b) + if err != nil { + return err + } + + *v = dst[:n] + + return nil +} + +// NewCodec returns a Hex codec. +// It is safe for concurrent use. +func NewCodec() encoding.Codec[[]byte, []byte] { + return encoding.Codec[[]byte, []byte]{ + Encode: Encoder, + Decode: Decoder, + } +} +``` + +- [ ] **Step 4: Refactor `ascii85/codec.go`** + +```go +package ascii85 + +import ( + "bytes" + stdascii85 "encoding/ascii85" + + encoding "github.com/foomo/goencode" +) + +// Encoder encodes bytes to ASCII85. +func Encoder(v []byte) ([]byte, error) { + dst := make([]byte, stdascii85.MaxEncodedLen(len(v))) + n := stdascii85.Encode(dst, v) + + return dst[:n], nil +} + +// Decoder decodes ASCII85 bytes. +func Decoder(b []byte, v *[]byte) error { + buf := bytes.NewBuffer(make([]byte, 0, len(b))) + + r := stdascii85.NewDecoder(bytes.NewReader(b)) + if _, err := buf.ReadFrom(r); err != nil { + return err + } + + *v = buf.Bytes() + + return nil +} + +// NewCodec returns an ASCII85 codec. +// It is safe for concurrent use. +func NewCodec() encoding.Codec[[]byte, []byte] { + return encoding.Codec[[]byte, []byte]{ + Encode: Encoder, + Decode: Decoder, + } +} +``` + +- [ ] **Step 5: Refactor `pem/codec.go`** + +```go +package pem + +import ( + stdpem "encoding/pem" + "errors" + + encoding "github.com/foomo/goencode" +) + +// Encoder encodes a PEM block to bytes. +func Encoder(v *stdpem.Block) ([]byte, error) { + return stdpem.EncodeToMemory(v), nil +} + +// Decoder decodes bytes into a PEM block. +func Decoder(b []byte, v **stdpem.Block) error { + block, _ := stdpem.Decode(b) + if block == nil { + return errors.New("pem: no PEM block found") + } + + *v = block + + return nil +} + +// NewCodec returns a PEM codec. +// It is safe for concurrent use. +func NewCodec() encoding.Codec[*stdpem.Block, []byte] { + return encoding.Codec[*stdpem.Block, []byte]{ + Encode: Encoder, + Decode: Decoder, + } +} +``` + +- [ ] **Step 6: Run tests for encoding codecs** + +Run: `cd /Users/franklin/Workingcopies/github.com/foomo/goencode && go test -tags=safe ./base64/... ./base32/... ./hex/... ./ascii85/... ./pem/...` +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add base64/codec.go base32/codec.go hex/codec.go ascii85/codec.go pem/codec.go +git commit -m "refactor: export standalone Encoder/Decoder in base64, base32, hex, ascii85, pem" +``` + +--- + +### Task 3: Simple compression codec (snappy) + +Snappy takes no options, so it gets bare funcs. + +**Files:** +- Modify: `snappy/codec.go` + +- [ ] **Step 1: Refactor `snappy/codec.go`** + +```go +package snappy + +import ( + encoding "github.com/foomo/goencode" + "github.com/golang/snappy" +) + +// Encoder compresses bytes using Snappy. +func Encoder(data []byte) ([]byte, error) { + return snappy.Encode(nil, data), nil +} + +// Decoder decompresses Snappy bytes. +func Decoder(data []byte, v *[]byte) error { + decoded, err := snappy.Decode(nil, data) + if err != nil { + return err + } + + *v = decoded + + return nil +} + +// NewCodec returns a Snappy compression codec. +// It is safe for concurrent use. +func NewCodec() encoding.Codec[[]byte, []byte] { + return encoding.Codec[[]byte, []byte]{ + Encode: Encoder, + Decode: Decoder, + } +} +``` + +- [ ] **Step 2: Run tests** + +Run: `cd /Users/franklin/Workingcopies/github.com/foomo/goencode/snappy && go test -tags=safe ./...` +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add snappy/codec.go +git commit -m "refactor: export standalone Encoder/Decoder in snappy" +``` + +--- + +### Task 4: Configurable compression codecs (gzip, flate, zstd, brotli) + +These accept `Option` variadic args. They get `NewEncoder`/`NewDecoder` constructors. The closures inside `NewCodec` are extracted into these constructors, and `NewCodec` delegates to them. + +**Files:** +- Modify: `gzip/codec.go` +- Modify: `flate/codec.go` +- Modify: `zstd/codec.go` +- Modify: `brotli/codec.go` + +- [ ] **Step 1: Refactor `gzip/codec.go`** + +```go +package gzip + +import ( + "bytes" + "compress/gzip" + "fmt" + "io" + + encoding "github.com/foomo/goencode" + "github.com/foomo/goencode/internal/sync" +) + +// NewEncoder returns a gzip compression encoder. +func NewEncoder(opts ...Option) encoding.Encoder[[]byte, []byte] { + o := options{ + level: gzip.DefaultCompression, + } + for _, opt := range opts { + opt(&o) + } + + return func(data []byte) ([]byte, error) { + buf := sync.Get() + defer sync.Put(buf) + + w, err := gzip.NewWriterLevel(buf, o.level) + if err != nil { + return nil, err + } + + if _, err := w.Write(data); err != nil { + return nil, err + } + + if err := w.Close(); err != nil { + return nil, err + } + + return append([]byte(nil), buf.Bytes()...), nil + } +} + +// NewDecoder returns a gzip decompression decoder. +func NewDecoder(opts ...Option) encoding.Decoder[[]byte, []byte] { + o := options{} + for _, opt := range opts { + opt(&o) + } + + return func(data []byte, v *[]byte) error { + r, err := gzip.NewReader(bytes.NewReader(data)) + if err != nil { + return err + } + defer r.Close() + + var src io.Reader = r + if o.maxDecodedSize > 0 { + src = io.LimitReader(r, o.maxDecodedSize+1) + } + + decoded, err := io.ReadAll(src) + if err != nil { + return err + } + + if o.maxDecodedSize > 0 && int64(len(decoded)) > o.maxDecodedSize { + return fmt.Errorf("gzip: decompressed size exceeds limit of %d bytes", o.maxDecodedSize) + } + + *v = decoded + + return nil + } +} + +// NewCodec returns a gzip compression codec. +// It is safe for concurrent use. +func NewCodec(opts ...Option) encoding.Codec[[]byte, []byte] { + return encoding.Codec[[]byte, []byte]{ + Encode: NewEncoder(opts...), + Decode: NewDecoder(opts...), + } +} +``` + +- [ ] **Step 2: Refactor `flate/codec.go`** + +```go +package flate + +import ( + "bytes" + "compress/flate" + "fmt" + "io" + + encoding "github.com/foomo/goencode" + "github.com/foomo/goencode/internal/sync" +) + +// NewEncoder returns a DEFLATE compression encoder. +func NewEncoder(opts ...Option) encoding.Encoder[[]byte, []byte] { + o := options{ + level: flate.DefaultCompression, + } + for _, opt := range opts { + opt(&o) + } + + return func(data []byte) ([]byte, error) { + buf := sync.Get() + defer sync.Put(buf) + + w, err := flate.NewWriter(buf, o.level) + if err != nil { + return nil, err + } + + if _, err := w.Write(data); err != nil { + return nil, err + } + + if err := w.Close(); err != nil { + return nil, err + } + + return append([]byte(nil), buf.Bytes()...), nil + } +} + +// NewDecoder returns a DEFLATE decompression decoder. +func NewDecoder(opts ...Option) encoding.Decoder[[]byte, []byte] { + o := options{} + for _, opt := range opts { + opt(&o) + } + + return func(data []byte, v *[]byte) error { + r := flate.NewReader(bytes.NewReader(data)) + defer r.Close() + + var src io.Reader = r + if o.maxDecodedSize > 0 { + src = io.LimitReader(r, o.maxDecodedSize+1) + } + + decoded, err := io.ReadAll(src) + if err != nil { + return err + } + + if o.maxDecodedSize > 0 && int64(len(decoded)) > o.maxDecodedSize { + return fmt.Errorf("flate: decompressed size exceeds limit of %d bytes", o.maxDecodedSize) + } + + *v = decoded + + return nil + } +} + +// NewCodec returns a DEFLATE compression codec. +// It is safe for concurrent use. +func NewCodec(opts ...Option) encoding.Codec[[]byte, []byte] { + return encoding.Codec[[]byte, []byte]{ + Encode: NewEncoder(opts...), + Decode: NewDecoder(opts...), + } +} +``` + +- [ ] **Step 3: Refactor `zstd/codec.go`** + +```go +package zstd + +import ( + encoding "github.com/foomo/goencode" + "github.com/klauspost/compress/zstd" +) + +// NewEncoder returns a Zstandard compression encoder. +func NewEncoder(opts ...Option) encoding.Encoder[[]byte, []byte] { + o := options{ + level: zstd.SpeedDefault, + } + for _, opt := range opts { + opt(&o) + } + + return func(data []byte) ([]byte, error) { + enc, err := zstd.NewWriter(nil, zstd.WithEncoderLevel(o.level)) + if err != nil { + return nil, err + } + defer enc.Close() + + return enc.EncodeAll(data, nil), nil + } +} + +// NewDecoder returns a Zstandard decompression decoder. +func NewDecoder(opts ...Option) encoding.Decoder[[]byte, []byte] { + o := options{} + for _, opt := range opts { + opt(&o) + } + + return func(data []byte, v *[]byte) error { + dopts := []zstd.DOption{} + if o.maxDecodedSize > 0 { + dopts = append(dopts, zstd.WithDecoderMaxMemory(uint64(o.maxDecodedSize))) + } + + dec, err := zstd.NewReader(nil, dopts...) + if err != nil { + return err + } + defer dec.Close() + + decoded, err := dec.DecodeAll(data, nil) + if err != nil { + return err + } + + *v = decoded + + return nil + } +} + +// NewCodec returns a Zstandard compression codec. +// It is safe for concurrent use. +func NewCodec(opts ...Option) encoding.Codec[[]byte, []byte] { + return encoding.Codec[[]byte, []byte]{ + Encode: NewEncoder(opts...), + Decode: NewDecoder(opts...), + } +} +``` + +- [ ] **Step 4: Refactor `brotli/codec.go`** + +```go +package brotli + +import ( + "bytes" + "fmt" + "io" + + "github.com/andybalholm/brotli" + encoding "github.com/foomo/goencode" + "github.com/foomo/goencode/internal/sync" +) + +// NewEncoder returns a Brotli compression encoder. +func NewEncoder(opts ...Option) encoding.Encoder[[]byte, []byte] { + o := options{ + level: brotli.DefaultCompression, + } + for _, opt := range opts { + opt(&o) + } + + return func(data []byte) ([]byte, error) { + buf := sync.Get() + defer sync.Put(buf) + + w := brotli.NewWriterLevel(buf, o.level) + + if _, err := w.Write(data); err != nil { + return nil, err + } + + if err := w.Close(); err != nil { + return nil, err + } + + return append([]byte(nil), buf.Bytes()...), nil + } +} + +// NewDecoder returns a Brotli decompression decoder. +func NewDecoder(opts ...Option) encoding.Decoder[[]byte, []byte] { + o := options{} + for _, opt := range opts { + opt(&o) + } + + return func(data []byte, v *[]byte) error { + r := brotli.NewReader(bytes.NewReader(data)) + + var src io.Reader = r + if o.maxDecodedSize > 0 { + src = io.LimitReader(r, o.maxDecodedSize+1) + } + + decoded, err := io.ReadAll(src) + if err != nil { + return err + } + + if o.maxDecodedSize > 0 && int64(len(decoded)) > o.maxDecodedSize { + return fmt.Errorf("brotli: decompressed size exceeds limit of %d bytes", o.maxDecodedSize) + } + + *v = decoded + + return nil + } +} + +// NewCodec returns a Brotli compression codec. +// It is safe for concurrent use. +func NewCodec(opts ...Option) encoding.Codec[[]byte, []byte] { + return encoding.Codec[[]byte, []byte]{ + Encode: NewEncoder(opts...), + Decode: NewDecoder(opts...), + } +} +``` + +- [ ] **Step 5: Run tests for compression codecs** + +Run: `cd /Users/franklin/Workingcopies/github.com/foomo/goencode && go test -tags=safe ./gzip/... ./flate/...` +Run: `cd /Users/franklin/Workingcopies/github.com/foomo/goencode/zstd && go test -tags=safe ./...` +Run: `cd /Users/franklin/Workingcopies/github.com/foomo/goencode/brotli && go test -tags=safe ./...` +Expected: PASS for all + +- [ ] **Step 6: Commit** + +```bash +git add gzip/codec.go flate/codec.go zstd/codec.go brotli/codec.go +git commit -m "refactor: export NewEncoder/NewDecoder in gzip, flate, zstd, brotli" +``` + +--- + +### Task 5: Submodule serialization codecs (json/v2, yaml/v2, yaml/v3, yaml/v4, msgpack/tinylib, msgpack/vmihailenco, toml) + +These live in separate go.mod submodules. Same bare-func pattern as Task 1. + +**Files:** +- Modify: `json/v2/codec.go` +- Modify: `yaml/v2/codec.go` +- Modify: `yaml/v3/codec.go` +- Modify: `yaml/v4/codec.go` +- Modify: `msgpack/tinylib/codec.go` +- Modify: `msgpack/vmihailenco/codec.go` +- Modify: `toml/codec.go` + +- [ ] **Step 1: Refactor `toml/codec.go`** + +```go +package toml + +import ( + encoding "github.com/foomo/goencode" + + "github.com/BurntSushi/toml" +) + +// Encoder encodes T to TOML bytes. +func Encoder[T any](v T) ([]byte, error) { + return toml.Marshal(v) +} + +// Decoder decodes TOML bytes into T. +func Decoder[T any](b []byte, v *T) error { + return toml.Unmarshal(b, v) +} + +// NewCodec returns a TOML codec for T. +// It is safe for concurrent use. +func NewCodec[T any]() encoding.Codec[T, []byte] { + return encoding.Codec[T, []byte]{ + Encode: Encoder[T], + Decode: Decoder[T], + } +} +``` + +- [ ] **Step 2: Refactor `json/v2/codec.go`** + +Note: `json/v2` uses `github.com/go-json-experiment/json`. Read the current file to get the exact encode/decode logic before extracting. The current `NewCodec` uses `json.Marshal`/`json.Unmarshal` from that package. + +```go +package json + +import ( + encoding "github.com/foomo/goencode" + "github.com/go-json-experiment/json" +) + +// Encoder encodes T to JSON bytes (v2). +func Encoder[T any](v T) ([]byte, error) { + return json.Marshal(v) +} + +// Decoder decodes JSON bytes into T (v2). +func Decoder[T any](b []byte, v *T) error { + return json.Unmarshal(b, v) +} + +// NewCodec returns a JSON v2 codec for T. +// It is safe for concurrent use. +func NewCodec[T any]() encoding.Codec[T, []byte] { + return encoding.Codec[T, []byte]{ + Encode: Encoder[T], + Decode: Decoder[T], + } +} +``` + +Preserve any existing `NewStreamCodec` function unchanged at the bottom of the file. + +- [ ] **Step 3: Refactor `yaml/v2/codec.go`** + +```go +package yaml + +import ( + encoding "github.com/foomo/goencode" + "go.yaml.in/yaml/v2" +) + +// Encoder encodes T to YAML v2 bytes. +func Encoder[T any](v T) ([]byte, error) { + return yaml.Marshal(v) +} + +// Decoder decodes YAML v2 bytes into T. +func Decoder[T any](b []byte, v *T) error { + return yaml.Unmarshal(b, v) +} + +// NewCodec returns a YAML v2 codec for T. +// It is safe for concurrent use. +func NewCodec[T any]() encoding.Codec[T, []byte] { + return encoding.Codec[T, []byte]{ + Encode: Encoder[T], + Decode: Decoder[T], + } +} +``` + +- [ ] **Step 4: Refactor `yaml/v3/codec.go`** + +```go +package yaml + +import ( + encoding "github.com/foomo/goencode" + "gopkg.in/yaml.v3" +) + +// Encoder encodes T to YAML v3 bytes. +func Encoder[T any](v T) ([]byte, error) { + return yaml.Marshal(v) +} + +// Decoder decodes YAML v3 bytes into T. +func Decoder[T any](b []byte, v *T) error { + return yaml.Unmarshal(b, v) +} + +// NewCodec returns a YAML v3 codec for T. +// It is safe for concurrent use. +func NewCodec[T any]() encoding.Codec[T, []byte] { + return encoding.Codec[T, []byte]{ + Encode: Encoder[T], + Decode: Decoder[T], + } +} +``` + +- [ ] **Step 5: Refactor `yaml/v4/codec.go`** + +```go +package yaml + +import ( + encoding "github.com/foomo/goencode" + "github.com/goccy/go-yaml" +) + +// Encoder encodes T to YAML bytes. +func Encoder[T any](v T) ([]byte, error) { + return yaml.Marshal(v) +} + +// Decoder decodes YAML bytes into T. +func Decoder[T any](b []byte, v *T) error { + return yaml.Unmarshal(b, v) +} + +// NewCodec returns a YAML v4 codec for T. +// It is safe for concurrent use. +func NewCodec[T any]() encoding.Codec[T, []byte] { + return encoding.Codec[T, []byte]{ + Encode: Encoder[T], + Decode: Decoder[T], + } +} +``` + +- [ ] **Step 6: Refactor `msgpack/vmihailenco/codec.go`** + +```go +package msgpack + +import ( + encoding "github.com/foomo/goencode" + "github.com/vmihailenco/msgpack/v5" +) + +// Encoder encodes T to msgpack bytes (vmihailenco). +func Encoder[T any](v T) ([]byte, error) { + return msgpack.Marshal(v) +} + +// Decoder decodes msgpack bytes into T (vmihailenco). +func Decoder[T any](b []byte, v *T) error { + return msgpack.Unmarshal(b, v) +} + +// NewCodec returns a msgpack codec for T backed by vmihailenco/msgpack. +// It is safe for concurrent use. +func NewCodec[T any]() encoding.Codec[T, []byte] { + return encoding.Codec[T, []byte]{ + Encode: Encoder[T], + Decode: Decoder[T], + } +} +``` + +- [ ] **Step 7: Refactor `msgpack/tinylib/codec.go`** + +This one is special — it checks for `msgp.Marshaler`/`msgp.Unmarshaler` interfaces. The `Encoder` and `Decoder` funcs must retain this runtime check. + +```go +package msgpack + +import ( + "fmt" + + encoding "github.com/foomo/goencode" + "github.com/tinylib/msgp/msgp" +) + +// Encoder encodes T to msgpack bytes (tinylib). +// T must have msgp code generation (go:generate msgp) so that +// *T implements msgp.Marshaler. +func Encoder[T any](v T) ([]byte, error) { + if m, ok := any(v).(msgp.Marshaler); ok { + return m.MarshalMsg(nil) + } + + if m, ok := any(&v).(msgp.Marshaler); ok { + return m.MarshalMsg(nil) + } + + return nil, fmt.Errorf("msgpack: %T does not implement msgp.Marshaler", v) +} + +// Decoder decodes msgpack bytes into T (tinylib). +// T must have msgp code generation (go:generate msgp) so that +// *T implements msgp.Unmarshaler. +func Decoder[T any](b []byte, v *T) error { + if u, ok := any(v).(msgp.Unmarshaler); ok { + _, err := u.UnmarshalMsg(b) + return err + } + + return fmt.Errorf("msgpack: %T does not implement msgp.Unmarshaler", v) +} + +// NewCodec returns a msgpack codec for T backed by tinylib/msgp. +// T must have msgp code generation (go:generate msgp) so that +// *T implements msgp.Marshaler and msgp.Unmarshaler. +// It is safe for concurrent use. +func NewCodec[T any]() encoding.Codec[T, []byte] { + return encoding.Codec[T, []byte]{ + Encode: Encoder[T], + Decode: Decoder[T], + } +} +``` + +- [ ] **Step 8: Run tests for all submodule codecs** + +Run each in its own module directory: +```bash +cd /Users/franklin/Workingcopies/github.com/foomo/goencode/toml && go test -tags=safe ./... +cd /Users/franklin/Workingcopies/github.com/foomo/goencode/json/v2 && go test -tags=safe ./... +cd /Users/franklin/Workingcopies/github.com/foomo/goencode/yaml/v2 && go test -tags=safe ./... +cd /Users/franklin/Workingcopies/github.com/foomo/goencode/yaml/v3 && go test -tags=safe ./... +cd /Users/franklin/Workingcopies/github.com/foomo/goencode/yaml/v4 && go test -tags=safe ./... +cd /Users/franklin/Workingcopies/github.com/foomo/goencode/msgpack/vmihailenco && go test -tags=safe ./... +cd /Users/franklin/Workingcopies/github.com/foomo/goencode/msgpack/tinylib && go test -tags=safe ./... +``` +Expected: PASS for all + +- [ ] **Step 9: Commit** + +```bash +git add toml/codec.go json/v2/codec.go yaml/v2/codec.go yaml/v3/codec.go yaml/v4/codec.go msgpack/tinylib/codec.go msgpack/vmihailenco/codec.go +git commit -m "refactor: export standalone Encoder/Decoder in submodule codecs" +``` + +--- + +### Task 6: Run full CI check + +- [ ] **Step 1: Run lint** + +Run: `cd /Users/franklin/Workingcopies/github.com/foomo/goencode && make lint` +Expected: PASS (no new lint issues) + +- [ ] **Step 2: Run full test suite** + +Run: `cd /Users/franklin/Workingcopies/github.com/foomo/goencode && make test` +Expected: PASS + +- [ ] **Step 3: Fix any issues found by lint or tests** + +If lint reports issues (e.g. unused imports after refactor), fix them and re-run. + +- [ ] **Step 4: Final commit if fixes were needed** + +```bash +git add -u +git commit -m "fix: address lint issues from Encoder/Decoder export refactor" +``` From a4311c390efa23e47cd0beac125abd8c0fc8c079 Mon Sep 17 00:00:00 2001 From: franklin Date: Tue, 21 Apr 2026 10:18:28 +0200 Subject: [PATCH 14/29] refactor: export standalone Encoder/Decoder in xml, gob, asn1, csv Co-Authored-By: Claude Opus 4.6 (1M context) --- asn1/codec.go | 20 +++++++++++------- csv/codec.go | 56 ++++++++++++++++++++++++++++----------------------- gob/codec.go | 32 +++++++++++++++++------------ xml/codec.go | 32 +++++++++++++++++------------ 4 files changed, 82 insertions(+), 58 deletions(-) diff --git a/asn1/codec.go b/asn1/codec.go index 1a5cca8..447bf8c 100644 --- a/asn1/codec.go +++ b/asn1/codec.go @@ -6,16 +6,22 @@ import ( encoding "github.com/foomo/goencode" ) +// Encoder encodes T to ASN.1 bytes. +func Encoder[T any](v T) ([]byte, error) { + return stdasn1.Marshal(v) +} + +// Decoder decodes ASN.1 bytes into T. +func Decoder[T any](b []byte, v *T) error { + _, err := stdasn1.Unmarshal(b, v) + return err +} + // NewCodec returns an ASN1 codec for T. // It is safe for concurrent use. func NewCodec[T any]() encoding.Codec[T, []byte] { return encoding.Codec[T, []byte]{ - Encode: func(v T) ([]byte, error) { - return stdasn1.Marshal(v) - }, - Decode: func(b []byte, v *T) error { - _, err := stdasn1.Unmarshal(b, v) - return err - }, + Encode: Encoder[T], + Decode: Decoder[T], } } diff --git a/csv/codec.go b/csv/codec.go index 3cd02f2..de3fd50 100644 --- a/csv/codec.go +++ b/csv/codec.go @@ -8,36 +8,42 @@ import ( "github.com/foomo/goencode/internal/sync" ) -// NewCodec returns a CSV codec for [][]string. -// It is safe for concurrent use. -func NewCodec() encoding.Codec[[][]string, []byte] { - return encoding.Codec[[][]string, []byte]{ - Encode: func(v [][]string) ([]byte, error) { - buf := sync.Get() - defer sync.Put(buf) +// Encoder encodes [][]string to CSV bytes. +func Encoder(v [][]string) ([]byte, error) { + buf := sync.Get() + defer sync.Put(buf) + + cw := stdcsv.NewWriter(buf) + if err := cw.WriteAll(v); err != nil { + return nil, err + } + + cw.Flush() - cw := stdcsv.NewWriter(buf) - if err := cw.WriteAll(v); err != nil { - return nil, err - } + if err := cw.Error(); err != nil { + return nil, err + } - cw.Flush() + return append([]byte(nil), buf.Bytes()...), nil +} - if err := cw.Error(); err != nil { - return nil, err - } +// Decoder decodes CSV bytes into [][]string. +func Decoder(b []byte, v *[][]string) error { + records, err := stdcsv.NewReader(bytes.NewReader(b)).ReadAll() + if err != nil { + return err + } - return append([]byte(nil), buf.Bytes()...), nil - }, - Decode: func(b []byte, v *[][]string) error { - records, err := stdcsv.NewReader(bytes.NewReader(b)).ReadAll() - if err != nil { - return err - } + *v = records - *v = records + return nil +} - return nil - }, +// NewCodec returns a CSV codec for [][]string. +// It is safe for concurrent use. +func NewCodec() encoding.Codec[[][]string, []byte] { + return encoding.Codec[[][]string, []byte]{ + Encode: Encoder, + Decode: Decoder, } } diff --git a/gob/codec.go b/gob/codec.go index 76599d1..a890722 100644 --- a/gob/codec.go +++ b/gob/codec.go @@ -8,22 +8,28 @@ import ( "github.com/foomo/goencode/internal/sync" ) +// Encoder encodes T to gob bytes. +func Encoder[T any](v T) ([]byte, error) { + buf := sync.Get() + defer sync.Put(buf) + + if err := stdgob.NewEncoder(buf).Encode(v); err != nil { + return nil, err + } + + return append([]byte(nil), buf.Bytes()...), nil +} + +// Decoder decodes gob bytes into T. +func Decoder[T any](b []byte, v *T) error { + return stdgob.NewDecoder(bytes.NewReader(b)).Decode(v) +} + // NewCodec returns a GOB codec for T. // It is safe for concurrent use. func NewCodec[T any]() encoding.Codec[T, []byte] { return encoding.Codec[T, []byte]{ - Encode: func(v T) ([]byte, error) { - buf := sync.Get() - defer sync.Put(buf) - - if err := stdgob.NewEncoder(buf).Encode(v); err != nil { - return nil, err - } - - return append([]byte(nil), buf.Bytes()...), nil - }, - Decode: func(b []byte, v *T) error { - return stdgob.NewDecoder(bytes.NewReader(b)).Decode(v) - }, + Encode: Encoder[T], + Decode: Decoder[T], } } diff --git a/xml/codec.go b/xml/codec.go index de8d329..28d121a 100644 --- a/xml/codec.go +++ b/xml/codec.go @@ -8,22 +8,28 @@ import ( "github.com/foomo/goencode/internal/sync" ) +// Encoder encodes T to XML bytes. +func Encoder[T any](v T) ([]byte, error) { + buf := sync.Get() + defer sync.Put(buf) + + if err := xml.NewEncoder(buf).Encode(v); err != nil { + return nil, err + } + + return append([]byte(nil), buf.Bytes()...), nil +} + +// Decoder decodes XML bytes into T. +func Decoder[T any](b []byte, v *T) error { + return xml.NewDecoder(bytes.NewReader(b)).Decode(v) +} + // NewCodec returns an XML codec for T. // It is safe for concurrent use. func NewCodec[T any]() encoding.Codec[T, []byte] { return encoding.Codec[T, []byte]{ - Encode: func(v T) ([]byte, error) { - buf := sync.Get() - defer sync.Put(buf) - - if err := xml.NewEncoder(buf).Encode(v); err != nil { - return nil, err - } - - return append([]byte(nil), buf.Bytes()...), nil - }, - Decode: func(b []byte, v *T) error { - return xml.NewDecoder(bytes.NewReader(b)).Decode(v) - }, + Encode: Encoder[T], + Decode: Decoder[T], } } From 53710907424ad1d8309a8b18280a51e257c4a3b4 Mon Sep 17 00:00:00 2001 From: franklin Date: Tue, 21 Apr 2026 10:23:00 +0200 Subject: [PATCH 15/29] refactor: export standalone Encoder/Decoder in base64, base32, hex, ascii85, pem Co-Authored-By: Claude Opus 4.6 (1M context) --- ascii85/codec.go | 42 ++++++++++++++++++++++++------------------ base32/codec.go | 42 ++++++++++++++++++++++++------------------ base64/codec.go | 42 ++++++++++++++++++++++++------------------ hex/codec.go | 42 ++++++++++++++++++++++++------------------ pem/codec.go | 32 +++++++++++++++++++------------- 5 files changed, 115 insertions(+), 85 deletions(-) diff --git a/ascii85/codec.go b/ascii85/codec.go index 6c34cb8..f834906 100644 --- a/ascii85/codec.go +++ b/ascii85/codec.go @@ -7,27 +7,33 @@ import ( encoding "github.com/foomo/goencode" ) -// NewCodec returns an ASCII85 codec. -// It is safe for concurrent use. -func NewCodec() encoding.Codec[[]byte, []byte] { - return encoding.Codec[[]byte, []byte]{ - Encode: func(v []byte) ([]byte, error) { - dst := make([]byte, stdascii85.MaxEncodedLen(len(v))) - n := stdascii85.Encode(dst, v) +// Encoder encodes bytes to ASCII85. +func Encoder(v []byte) ([]byte, error) { + dst := make([]byte, stdascii85.MaxEncodedLen(len(v))) + n := stdascii85.Encode(dst, v) - return dst[:n], nil - }, - Decode: func(b []byte, v *[]byte) error { - buf := bytes.NewBuffer(make([]byte, 0, len(b))) + return dst[:n], nil +} + +// Decoder decodes ASCII85 bytes. +func Decoder(b []byte, v *[]byte) error { + buf := bytes.NewBuffer(make([]byte, 0, len(b))) - r := stdascii85.NewDecoder(bytes.NewReader(b)) - if _, err := buf.ReadFrom(r); err != nil { - return err - } + r := stdascii85.NewDecoder(bytes.NewReader(b)) + if _, err := buf.ReadFrom(r); err != nil { + return err + } - *v = buf.Bytes() + *v = buf.Bytes() - return nil - }, + return nil +} + +// NewCodec returns an ASCII85 codec. +// It is safe for concurrent use. +func NewCodec() encoding.Codec[[]byte, []byte] { + return encoding.Codec[[]byte, []byte]{ + Encode: Encoder, + Decode: Decoder, } } diff --git a/base32/codec.go b/base32/codec.go index 7bb162d..d797ee3 100644 --- a/base32/codec.go +++ b/base32/codec.go @@ -6,27 +6,33 @@ import ( encoding "github.com/foomo/goencode" ) -// NewCodec returns a Base32 codec. -// It is safe for concurrent use. -func NewCodec() encoding.Codec[[]byte, []byte] { - return encoding.Codec[[]byte, []byte]{ - Encode: func(v []byte) ([]byte, error) { - dst := make([]byte, stdbase32.StdEncoding.EncodedLen(len(v))) - stdbase32.StdEncoding.Encode(dst, v) +// Encoder encodes bytes to Base32. +func Encoder(v []byte) ([]byte, error) { + dst := make([]byte, stdbase32.StdEncoding.EncodedLen(len(v))) + stdbase32.StdEncoding.Encode(dst, v) - return dst, nil - }, - Decode: func(b []byte, v *[]byte) error { - dst := make([]byte, stdbase32.StdEncoding.DecodedLen(len(b))) + return dst, nil +} + +// Decoder decodes Base32 bytes. +func Decoder(b []byte, v *[]byte) error { + dst := make([]byte, stdbase32.StdEncoding.DecodedLen(len(b))) - n, err := stdbase32.StdEncoding.Decode(dst, b) - if err != nil { - return err - } + n, err := stdbase32.StdEncoding.Decode(dst, b) + if err != nil { + return err + } - *v = dst[:n] + *v = dst[:n] - return nil - }, + return nil +} + +// NewCodec returns a Base32 codec. +// It is safe for concurrent use. +func NewCodec() encoding.Codec[[]byte, []byte] { + return encoding.Codec[[]byte, []byte]{ + Encode: Encoder, + Decode: Decoder, } } diff --git a/base64/codec.go b/base64/codec.go index 8191d82..289f230 100644 --- a/base64/codec.go +++ b/base64/codec.go @@ -6,27 +6,33 @@ import ( encoding "github.com/foomo/goencode" ) -// NewCodec returns a Base64 codec. -// It is safe for concurrent use. -func NewCodec() encoding.Codec[[]byte, []byte] { - return encoding.Codec[[]byte, []byte]{ - Encode: func(v []byte) ([]byte, error) { - dst := make([]byte, stdbase64.StdEncoding.EncodedLen(len(v))) - stdbase64.StdEncoding.Encode(dst, v) +// Encoder encodes bytes to Base64. +func Encoder(v []byte) ([]byte, error) { + dst := make([]byte, stdbase64.StdEncoding.EncodedLen(len(v))) + stdbase64.StdEncoding.Encode(dst, v) - return dst, nil - }, - Decode: func(b []byte, v *[]byte) error { - dst := make([]byte, stdbase64.StdEncoding.DecodedLen(len(b))) + return dst, nil +} + +// Decoder decodes Base64 bytes. +func Decoder(b []byte, v *[]byte) error { + dst := make([]byte, stdbase64.StdEncoding.DecodedLen(len(b))) - n, err := stdbase64.StdEncoding.Decode(dst, b) - if err != nil { - return err - } + n, err := stdbase64.StdEncoding.Decode(dst, b) + if err != nil { + return err + } - *v = dst[:n] + *v = dst[:n] - return nil - }, + return nil +} + +// NewCodec returns a Base64 codec. +// It is safe for concurrent use. +func NewCodec() encoding.Codec[[]byte, []byte] { + return encoding.Codec[[]byte, []byte]{ + Encode: Encoder, + Decode: Decoder, } } diff --git a/hex/codec.go b/hex/codec.go index a4bd794..36d41fb 100644 --- a/hex/codec.go +++ b/hex/codec.go @@ -6,27 +6,33 @@ import ( encoding "github.com/foomo/goencode" ) -// NewCodec returns a Hex codec. -// It is safe for concurrent use. -func NewCodec() encoding.Codec[[]byte, []byte] { - return encoding.Codec[[]byte, []byte]{ - Encode: func(v []byte) ([]byte, error) { - dst := make([]byte, stdhex.EncodedLen(len(v))) - stdhex.Encode(dst, v) +// Encoder encodes bytes to hexadecimal. +func Encoder(v []byte) ([]byte, error) { + dst := make([]byte, stdhex.EncodedLen(len(v))) + stdhex.Encode(dst, v) - return dst, nil - }, - Decode: func(b []byte, v *[]byte) error { - dst := make([]byte, stdhex.DecodedLen(len(b))) + return dst, nil +} + +// Decoder decodes hexadecimal bytes. +func Decoder(b []byte, v *[]byte) error { + dst := make([]byte, stdhex.DecodedLen(len(b))) - n, err := stdhex.Decode(dst, b) - if err != nil { - return err - } + n, err := stdhex.Decode(dst, b) + if err != nil { + return err + } - *v = dst[:n] + *v = dst[:n] - return nil - }, + return nil +} + +// NewCodec returns a Hex codec. +// It is safe for concurrent use. +func NewCodec() encoding.Codec[[]byte, []byte] { + return encoding.Codec[[]byte, []byte]{ + Encode: Encoder, + Decode: Decoder, } } diff --git a/pem/codec.go b/pem/codec.go index 5a4f7d9..fbe31de 100644 --- a/pem/codec.go +++ b/pem/codec.go @@ -7,22 +7,28 @@ import ( encoding "github.com/foomo/goencode" ) +// Encoder encodes a PEM block to bytes. +func Encoder(v *stdpem.Block) ([]byte, error) { + return stdpem.EncodeToMemory(v), nil +} + +// Decoder decodes bytes into a PEM block. +func Decoder(b []byte, v **stdpem.Block) error { + block, _ := stdpem.Decode(b) + if block == nil { + return errors.New("pem: no PEM block found") + } + + *v = block + + return nil +} + // NewCodec returns a PEM codec. // It is safe for concurrent use. func NewCodec() encoding.Codec[*stdpem.Block, []byte] { return encoding.Codec[*stdpem.Block, []byte]{ - Encode: func(v *stdpem.Block) ([]byte, error) { - return stdpem.EncodeToMemory(v), nil - }, - Decode: func(b []byte, v **stdpem.Block) error { - block, _ := stdpem.Decode(b) - if block == nil { - return errors.New("pem: no PEM block found") - } - - *v = block - - return nil - }, + Encode: Encoder, + Decode: Decoder, } } From fffa4f97e5c8b8eafd9cb0780080d7348be1635f Mon Sep 17 00:00:00 2001 From: franklin Date: Tue, 21 Apr 2026 10:24:24 +0200 Subject: [PATCH 16/29] refactor: export standalone Encoder/Decoder in snappy Co-Authored-By: Claude Opus 4.6 (1M context) --- snappy/codec.go | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/snappy/codec.go b/snappy/codec.go index cc77e65..bb73247 100644 --- a/snappy/codec.go +++ b/snappy/codec.go @@ -5,22 +5,28 @@ import ( "github.com/golang/snappy" ) +// Encoder compresses bytes using Snappy. +func Encoder(data []byte) ([]byte, error) { + return snappy.Encode(nil, data), nil +} + +// Decoder decompresses Snappy bytes. +func Decoder(data []byte, v *[]byte) error { + decoded, err := snappy.Decode(nil, data) + if err != nil { + return err + } + + *v = decoded + + return nil +} + // NewCodec returns a Snappy compression codec. // It is safe for concurrent use. func NewCodec() encoding.Codec[[]byte, []byte] { return encoding.Codec[[]byte, []byte]{ - Encode: func(data []byte) ([]byte, error) { - return snappy.Encode(nil, data), nil - }, - Decode: func(data []byte, v *[]byte) error { - decoded, err := snappy.Decode(nil, data) - if err != nil { - return err - } - - *v = decoded - - return nil - }, + Encode: Encoder, + Decode: Decoder, } } From 39162df56e3bf0ef27e3ecee4c04e5d64f354810 Mon Sep 17 00:00:00 2001 From: franklin Date: Tue, 21 Apr 2026 10:31:29 +0200 Subject: [PATCH 17/29] refactor: export NewEncoder/NewDecoder in gzip, flate, zstd, brotli Co-Authored-By: Claude Opus 4.6 (1M context) --- brotli/codec.go | 79 +++++++++++++++++++++-------------- flate/codec.go | 103 ++++++++++++++++++++++++++------------------- gzip/codec.go | 109 +++++++++++++++++++++++++++--------------------- zstd/codec.go | 81 ++++++++++++++++++++--------------- 4 files changed, 216 insertions(+), 156 deletions(-) diff --git a/brotli/codec.go b/brotli/codec.go index c5097f9..d332713 100644 --- a/brotli/codec.go +++ b/brotli/codec.go @@ -10,9 +10,8 @@ import ( "github.com/foomo/goencode/internal/sync" ) -// NewCodec returns a Brotli compression codec. -// It is safe for concurrent use. -func NewCodec(opts ...Option) encoding.Codec[[]byte, []byte] { +// NewEncoder returns a Brotli compression encoder. +func NewEncoder(opts ...Option) encoding.Encoder[[]byte, []byte] { o := options{ level: brotli.DefaultCompression, } @@ -20,43 +19,59 @@ func NewCodec(opts ...Option) encoding.Codec[[]byte, []byte] { opt(&o) } - return encoding.Codec[[]byte, []byte]{ - Encode: func(data []byte) ([]byte, error) { - buf := sync.Get() - defer sync.Put(buf) + return func(data []byte) ([]byte, error) { + buf := sync.Get() + defer sync.Put(buf) + + w := brotli.NewWriterLevel(buf, o.level) - w := brotli.NewWriterLevel(buf, o.level) + if _, err := w.Write(data); err != nil { + return nil, err + } + + if err := w.Close(); err != nil { + return nil, err + } + + return append([]byte(nil), buf.Bytes()...), nil + } +} - if _, err := w.Write(data); err != nil { - return nil, err - } +// NewDecoder returns a Brotli decompression decoder. +func NewDecoder(opts ...Option) encoding.Decoder[[]byte, []byte] { + o := options{} + for _, opt := range opts { + opt(&o) + } - if err := w.Close(); err != nil { - return nil, err - } + return func(data []byte, v *[]byte) error { + r := brotli.NewReader(bytes.NewReader(data)) - return append([]byte(nil), buf.Bytes()...), nil - }, - Decode: func(data []byte, v *[]byte) error { - r := brotli.NewReader(bytes.NewReader(data)) + var src io.Reader = r + if o.maxDecodedSize > 0 { + src = io.LimitReader(r, o.maxDecodedSize+1) + } - var src io.Reader = r - if o.maxDecodedSize > 0 { - src = io.LimitReader(r, o.maxDecodedSize+1) - } + decoded, err := io.ReadAll(src) + if err != nil { + return err + } - decoded, err := io.ReadAll(src) - if err != nil { - return err - } + if o.maxDecodedSize > 0 && int64(len(decoded)) > o.maxDecodedSize { + return fmt.Errorf("brotli: decompressed size exceeds limit of %d bytes", o.maxDecodedSize) + } - if o.maxDecodedSize > 0 && int64(len(decoded)) > o.maxDecodedSize { - return fmt.Errorf("brotli: decompressed size exceeds limit of %d bytes", o.maxDecodedSize) - } + *v = decoded - *v = decoded + return nil + } +} - return nil - }, +// NewCodec returns a Brotli compression codec. +// It is safe for concurrent use. +func NewCodec(opts ...Option) encoding.Codec[[]byte, []byte] { + return encoding.Codec[[]byte, []byte]{ + Encode: NewEncoder(opts...), + Decode: NewDecoder(opts...), } } diff --git a/flate/codec.go b/flate/codec.go index 22e867d..d695840 100644 --- a/flate/codec.go +++ b/flate/codec.go @@ -10,9 +10,8 @@ import ( "github.com/foomo/goencode/internal/sync" ) -// NewCodec returns a DEFLATE compression codec. -// It is safe for concurrent use. -func NewCodec(opts ...Option) encoding.Codec[[]byte, []byte] { +// NewEncoder returns a DEFLATE compression encoder. +func NewEncoder(opts ...Option) encoding.Encoder[[]byte, []byte] { o := options{ level: flate.DefaultCompression, } @@ -20,47 +19,63 @@ func NewCodec(opts ...Option) encoding.Codec[[]byte, []byte] { opt(&o) } + return func(data []byte) ([]byte, error) { + buf := sync.Get() + defer sync.Put(buf) + + w, err := flate.NewWriter(buf, o.level) + if err != nil { + return nil, err + } + + if _, err := w.Write(data); err != nil { + return nil, err + } + + if err := w.Close(); err != nil { + return nil, err + } + + return append([]byte(nil), buf.Bytes()...), nil + } +} + +// NewDecoder returns a DEFLATE decompression decoder. +func NewDecoder(opts ...Option) encoding.Decoder[[]byte, []byte] { + o := options{} + for _, opt := range opts { + opt(&o) + } + + return func(data []byte, v *[]byte) error { + r := flate.NewReader(bytes.NewReader(data)) + defer r.Close() + + var src io.Reader = r + if o.maxDecodedSize > 0 { + src = io.LimitReader(r, o.maxDecodedSize+1) + } + + decoded, err := io.ReadAll(src) + if err != nil { + return err + } + + if o.maxDecodedSize > 0 && int64(len(decoded)) > o.maxDecodedSize { + return fmt.Errorf("flate: decompressed size exceeds limit of %d bytes", o.maxDecodedSize) + } + + *v = decoded + + return nil + } +} + +// NewCodec returns a DEFLATE compression codec. +// It is safe for concurrent use. +func NewCodec(opts ...Option) encoding.Codec[[]byte, []byte] { return encoding.Codec[[]byte, []byte]{ - Encode: func(data []byte) ([]byte, error) { - buf := sync.Get() - defer sync.Put(buf) - - w, err := flate.NewWriter(buf, o.level) - if err != nil { - return nil, err - } - - if _, err := w.Write(data); err != nil { - return nil, err - } - - if err := w.Close(); err != nil { - return nil, err - } - - return append([]byte(nil), buf.Bytes()...), nil - }, - Decode: func(data []byte, v *[]byte) error { - r := flate.NewReader(bytes.NewReader(data)) - defer r.Close() - - var src io.Reader = r - if o.maxDecodedSize > 0 { - src = io.LimitReader(r, o.maxDecodedSize+1) - } - - decoded, err := io.ReadAll(src) - if err != nil { - return err - } - - if o.maxDecodedSize > 0 && int64(len(decoded)) > o.maxDecodedSize { - return fmt.Errorf("flate: decompressed size exceeds limit of %d bytes", o.maxDecodedSize) - } - - *v = decoded - - return nil - }, + Encode: NewEncoder(opts...), + Decode: NewDecoder(opts...), } } diff --git a/gzip/codec.go b/gzip/codec.go index 3ddfb8b..fbba267 100644 --- a/gzip/codec.go +++ b/gzip/codec.go @@ -10,9 +10,8 @@ import ( "github.com/foomo/goencode/internal/sync" ) -// NewCodec returns a gzip compression codec. -// It is safe for concurrent use. -func NewCodec(opts ...Option) encoding.Codec[[]byte, []byte] { +// NewEncoder returns a gzip compression encoder. +func NewEncoder(opts ...Option) encoding.Encoder[[]byte, []byte] { o := options{ level: gzip.DefaultCompression, } @@ -20,50 +19,66 @@ func NewCodec(opts ...Option) encoding.Codec[[]byte, []byte] { opt(&o) } + return func(data []byte) ([]byte, error) { + buf := sync.Get() + defer sync.Put(buf) + + w, err := gzip.NewWriterLevel(buf, o.level) + if err != nil { + return nil, err + } + + if _, err := w.Write(data); err != nil { + return nil, err + } + + if err := w.Close(); err != nil { + return nil, err + } + + return append([]byte(nil), buf.Bytes()...), nil + } +} + +// NewDecoder returns a gzip decompression decoder. +func NewDecoder(opts ...Option) encoding.Decoder[[]byte, []byte] { + o := options{} + for _, opt := range opts { + opt(&o) + } + + return func(data []byte, v *[]byte) error { + r, err := gzip.NewReader(bytes.NewReader(data)) + if err != nil { + return err + } + defer r.Close() + + var src io.Reader = r + if o.maxDecodedSize > 0 { + src = io.LimitReader(r, o.maxDecodedSize+1) + } + + decoded, err := io.ReadAll(src) + if err != nil { + return err + } + + if o.maxDecodedSize > 0 && int64(len(decoded)) > o.maxDecodedSize { + return fmt.Errorf("gzip: decompressed size exceeds limit of %d bytes", o.maxDecodedSize) + } + + *v = decoded + + return nil + } +} + +// NewCodec returns a gzip compression codec. +// It is safe for concurrent use. +func NewCodec(opts ...Option) encoding.Codec[[]byte, []byte] { return encoding.Codec[[]byte, []byte]{ - Encode: func(data []byte) ([]byte, error) { - buf := sync.Get() - defer sync.Put(buf) - - w, err := gzip.NewWriterLevel(buf, o.level) - if err != nil { - return nil, err - } - - if _, err := w.Write(data); err != nil { - return nil, err - } - - if err := w.Close(); err != nil { - return nil, err - } - - return append([]byte(nil), buf.Bytes()...), nil - }, - Decode: func(data []byte, v *[]byte) error { - r, err := gzip.NewReader(bytes.NewReader(data)) - if err != nil { - return err - } - defer r.Close() - - var src io.Reader = r - if o.maxDecodedSize > 0 { - src = io.LimitReader(r, o.maxDecodedSize+1) - } - - decoded, err := io.ReadAll(src) - if err != nil { - return err - } - - if o.maxDecodedSize > 0 && int64(len(decoded)) > o.maxDecodedSize { - return fmt.Errorf("gzip: decompressed size exceeds limit of %d bytes", o.maxDecodedSize) - } - - *v = decoded - - return nil - }, + Encode: NewEncoder(opts...), + Decode: NewDecoder(opts...), } } diff --git a/zstd/codec.go b/zstd/codec.go index 6d38ed0..b01bbd7 100644 --- a/zstd/codec.go +++ b/zstd/codec.go @@ -5,9 +5,8 @@ import ( "github.com/klauspost/compress/zstd" ) -// NewCodec returns a Zstandard compression codec. -// It is safe for concurrent use. -func NewCodec(opts ...Option) encoding.Codec[[]byte, []byte] { +// NewEncoder returns a Zstandard compression encoder. +func NewEncoder(opts ...Option) encoding.Encoder[[]byte, []byte] { o := options{ level: zstd.SpeedDefault, } @@ -15,36 +14,52 @@ func NewCodec(opts ...Option) encoding.Codec[[]byte, []byte] { opt(&o) } + return func(data []byte) ([]byte, error) { + enc, err := zstd.NewWriter(nil, zstd.WithEncoderLevel(o.level)) + if err != nil { + return nil, err + } + defer enc.Close() + + return enc.EncodeAll(data, nil), nil + } +} + +// NewDecoder returns a Zstandard decompression decoder. +func NewDecoder(opts ...Option) encoding.Decoder[[]byte, []byte] { + o := options{} + for _, opt := range opts { + opt(&o) + } + + return func(data []byte, v *[]byte) error { + dopts := []zstd.DOption{} + if o.maxDecodedSize > 0 { + dopts = append(dopts, zstd.WithDecoderMaxMemory(uint64(o.maxDecodedSize))) + } + + dec, err := zstd.NewReader(nil, dopts...) + if err != nil { + return err + } + defer dec.Close() + + decoded, err := dec.DecodeAll(data, nil) + if err != nil { + return err + } + + *v = decoded + + return nil + } +} + +// NewCodec returns a Zstandard compression codec. +// It is safe for concurrent use. +func NewCodec(opts ...Option) encoding.Codec[[]byte, []byte] { return encoding.Codec[[]byte, []byte]{ - Encode: func(data []byte) ([]byte, error) { - enc, err := zstd.NewWriter(nil, zstd.WithEncoderLevel(o.level)) - if err != nil { - return nil, err - } - defer enc.Close() - - return enc.EncodeAll(data, nil), nil - }, - Decode: func(data []byte, v *[]byte) error { - dopts := []zstd.DOption{} - if o.maxDecodedSize > 0 { - dopts = append(dopts, zstd.WithDecoderMaxMemory(uint64(o.maxDecodedSize))) - } - - dec, err := zstd.NewReader(nil, dopts...) - if err != nil { - return err - } - defer dec.Close() - - decoded, err := dec.DecodeAll(data, nil) - if err != nil { - return err - } - - *v = decoded - - return nil - }, + Encode: NewEncoder(opts...), + Decode: NewDecoder(opts...), } } From 6a052f7e044ab2c1d9d6428d3340944e089b98cb Mon Sep 17 00:00:00 2001 From: franklin Date: Tue, 21 Apr 2026 11:56:35 +0200 Subject: [PATCH 18/29] refactor: export standalone Encoder/Decoder in submodule codecs Co-Authored-By: Claude Opus 4.6 (1M context) --- json/v2/codec.go | 18 +++++++++----- msgpack/tinylib/codec.go | 48 ++++++++++++++++++++++-------------- msgpack/vmihailenco/codec.go | 18 +++++++++----- toml/codec.go | 18 +++++++++----- yaml/v2/codec.go | 18 +++++++++----- yaml/v3/codec.go | 18 +++++++++----- yaml/v4/codec.go | 18 +++++++++----- 7 files changed, 101 insertions(+), 55 deletions(-) diff --git a/json/v2/codec.go b/json/v2/codec.go index d86bb10..94c82ad 100644 --- a/json/v2/codec.go +++ b/json/v2/codec.go @@ -7,16 +7,22 @@ import ( "github.com/go-json-experiment/json" ) +// Encoder encodes T to JSON bytes (v2). +func Encoder[T any](v T) ([]byte, error) { + return json.Marshal(v) +} + +// Decoder decodes JSON bytes into T (v2). +func Decoder[T any](b []byte, v *T) error { + return json.Unmarshal(b, v) +} + // NewCodec returns a JSON codec for T backed by go-json-experiment/json. // It is safe for concurrent use. func NewCodec[T any]() encoding.Codec[T, []byte] { return encoding.Codec[T, []byte]{ - Encode: func(v T) ([]byte, error) { - return json.Marshal(v) - }, - Decode: func(b []byte, v *T) error { - return json.Unmarshal(b, v) - }, + Encode: Encoder[T], + Decode: Decoder[T], } } diff --git a/msgpack/tinylib/codec.go b/msgpack/tinylib/codec.go index d0f78ef..fa1c09e 100644 --- a/msgpack/tinylib/codec.go +++ b/msgpack/tinylib/codec.go @@ -7,30 +7,40 @@ import ( "github.com/tinylib/msgp/msgp" ) +// Encoder encodes T to msgpack bytes (tinylib). +// T must have msgp code generation (go:generate msgp) so that +// *T implements msgp.Marshaler. +func Encoder[T any](v T) ([]byte, error) { + if m, ok := any(v).(msgp.Marshaler); ok { + return m.MarshalMsg(nil) + } + + if m, ok := any(&v).(msgp.Marshaler); ok { + return m.MarshalMsg(nil) + } + + return nil, fmt.Errorf("msgpack: %T does not implement msgp.Marshaler", v) +} + +// Decoder decodes msgpack bytes into T (tinylib). +// T must have msgp code generation (go:generate msgp) so that +// *T implements msgp.Unmarshaler. +func Decoder[T any](b []byte, v *T) error { + if u, ok := any(v).(msgp.Unmarshaler); ok { + _, err := u.UnmarshalMsg(b) + return err + } + + return fmt.Errorf("msgpack: %T does not implement msgp.Unmarshaler", v) +} + // NewCodec returns a msgpack codec for T backed by tinylib/msgp. // T must have msgp code generation (go:generate msgp) so that // *T implements msgp.Marshaler and msgp.Unmarshaler. // It is safe for concurrent use. func NewCodec[T any]() encoding.Codec[T, []byte] { return encoding.Codec[T, []byte]{ - Encode: func(v T) ([]byte, error) { - if m, ok := any(v).(msgp.Marshaler); ok { - return m.MarshalMsg(nil) - } - - if m, ok := any(&v).(msgp.Marshaler); ok { - return m.MarshalMsg(nil) - } - - return nil, fmt.Errorf("msgpack: %T does not implement msgp.Marshaler", v) - }, - Decode: func(b []byte, v *T) error { - if u, ok := any(v).(msgp.Unmarshaler); ok { - _, err := u.UnmarshalMsg(b) - return err - } - - return fmt.Errorf("msgpack: %T does not implement msgp.Unmarshaler", v) - }, + Encode: Encoder[T], + Decode: Decoder[T], } } diff --git a/msgpack/vmihailenco/codec.go b/msgpack/vmihailenco/codec.go index 6699088..142483f 100644 --- a/msgpack/vmihailenco/codec.go +++ b/msgpack/vmihailenco/codec.go @@ -5,15 +5,21 @@ import ( "github.com/vmihailenco/msgpack/v5" ) +// Encoder encodes T to msgpack bytes (vmihailenco). +func Encoder[T any](v T) ([]byte, error) { + return msgpack.Marshal(v) +} + +// Decoder decodes msgpack bytes into T (vmihailenco). +func Decoder[T any](b []byte, v *T) error { + return msgpack.Unmarshal(b, v) +} + // NewCodec returns a msgpack codec for T backed by vmihailenco/msgpack/v5. // It is safe for concurrent use. func NewCodec[T any]() encoding.Codec[T, []byte] { return encoding.Codec[T, []byte]{ - Encode: func(v T) ([]byte, error) { - return msgpack.Marshal(v) - }, - Decode: func(b []byte, v *T) error { - return msgpack.Unmarshal(b, v) - }, + Encode: Encoder[T], + Decode: Decoder[T], } } diff --git a/toml/codec.go b/toml/codec.go index b0b9429..3eae915 100644 --- a/toml/codec.go +++ b/toml/codec.go @@ -6,15 +6,21 @@ import ( "github.com/BurntSushi/toml" ) +// Encoder encodes T to TOML bytes. +func Encoder[T any](v T) ([]byte, error) { + return toml.Marshal(v) +} + +// Decoder decodes TOML bytes into T. +func Decoder[T any](b []byte, v *T) error { + return toml.Unmarshal(b, v) +} + // NewCodec returns a TOML codec for T. // It is safe for concurrent use. func NewCodec[T any]() encoding.Codec[T, []byte] { return encoding.Codec[T, []byte]{ - Encode: func(v T) ([]byte, error) { - return toml.Marshal(v) - }, - Decode: func(b []byte, v *T) error { - return toml.Unmarshal(b, v) - }, + Encode: Encoder[T], + Decode: Decoder[T], } } diff --git a/yaml/v2/codec.go b/yaml/v2/codec.go index 40429fa..9ea2243 100644 --- a/yaml/v2/codec.go +++ b/yaml/v2/codec.go @@ -5,15 +5,21 @@ import ( "go.yaml.in/yaml/v2" ) +// Encoder encodes T to YAML v2 bytes. +func Encoder[T any](v T) ([]byte, error) { + return yaml.Marshal(v) +} + +// Decoder decodes YAML v2 bytes into T. +func Decoder[T any](b []byte, v *T) error { + return yaml.Unmarshal(b, v) +} + // NewCodec returns a YAML v2 codec for T. // It is safe for concurrent use. func NewCodec[T any]() encoding.Codec[T, []byte] { return encoding.Codec[T, []byte]{ - Encode: func(v T) ([]byte, error) { - return yaml.Marshal(v) - }, - Decode: func(b []byte, v *T) error { - return yaml.Unmarshal(b, v) - }, + Encode: Encoder[T], + Decode: Decoder[T], } } diff --git a/yaml/v3/codec.go b/yaml/v3/codec.go index 63b703b..8a94048 100644 --- a/yaml/v3/codec.go +++ b/yaml/v3/codec.go @@ -5,15 +5,21 @@ import ( "go.yaml.in/yaml/v3" ) +// Encoder encodes T to YAML v3 bytes. +func Encoder[T any](v T) ([]byte, error) { + return yaml.Marshal(v) +} + +// Decoder decodes YAML v3 bytes into T. +func Decoder[T any](b []byte, v *T) error { + return yaml.Unmarshal(b, v) +} + // NewCodec returns a YAML v3 codec for T. // It is safe for concurrent use. func NewCodec[T any]() encoding.Codec[T, []byte] { return encoding.Codec[T, []byte]{ - Encode: func(v T) ([]byte, error) { - return yaml.Marshal(v) - }, - Decode: func(b []byte, v *T) error { - return yaml.Unmarshal(b, v) - }, + Encode: Encoder[T], + Decode: Decoder[T], } } diff --git a/yaml/v4/codec.go b/yaml/v4/codec.go index 92510b9..9d16452 100644 --- a/yaml/v4/codec.go +++ b/yaml/v4/codec.go @@ -5,15 +5,21 @@ import ( "go.yaml.in/yaml/v4" ) +// Encoder encodes T to YAML v4 bytes. +func Encoder[T any](v T) ([]byte, error) { + return yaml.Marshal(v) +} + +// Decoder decodes YAML v4 bytes into T. +func Decoder[T any](b []byte, v *T) error { + return yaml.Unmarshal(b, v) +} + // NewCodec returns a YAML v4 codec for T. // It is safe for concurrent use. func NewCodec[T any]() encoding.Codec[T, []byte] { return encoding.Codec[T, []byte]{ - Encode: func(v T) ([]byte, error) { - return yaml.Marshal(v) - }, - Decode: func(b []byte, v *T) error { - return yaml.Unmarshal(b, v) - }, + Encode: Encoder[T], + Decode: Decoder[T], } } From 9699c223971154e79bf470307e88302aca6edf8c Mon Sep 17 00:00:00 2001 From: franklin Date: Tue, 21 Apr 2026 13:03:57 +0200 Subject: [PATCH 19/29] fix: correct .PHONY syntax for lefthook target in Makefile Co-Authored-By: Claude Opus 4.6 (1M context) --- Makefile | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 514d8ac..be99ee6 100644 --- a/Makefile +++ b/Makefile @@ -24,6 +24,7 @@ ifeq (, $(shell command -v mise)) endif @mise install +.PHONY: .lefthook # Configure git hooks for lefthook .lefthook: @lefthook install --reset-hooks-path @@ -99,7 +100,7 @@ audit: @echo "〉security audit" #@trivy fs . --format table --severity HIGH,CRITICAL @go install golang.org/x/vuln/cmd/govulncheck@latest - @go govulncheck ./... + @govulncheck ./... .PHONY: outdated ## Show outdated direct dependencies @@ -152,7 +153,7 @@ godocs: .PHONY: help ## Show help text help: - @echo "goencode\n" + @echo "\ngoencode\n" @echo "Usage:\n make [task]" @awk '{ \ if($$0 ~ /^### /){ \ From c2b2dd672bb29f9713773a523b6e965b8e9847b1 Mon Sep 17 00:00:00 2001 From: franklin Date: Tue, 21 Apr 2026 13:25:57 +0200 Subject: [PATCH 20/29] chore: remove superpowers data from git tracking Moved to .claude/data/superpowers/ which is gitignored. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-04-21-generic-codec-interface.md | 1087 ---------------- ...4-21-standalone-encoder-decoder-exports.md | 1137 ----------------- ...26-04-21-generic-codec-interface-design.md | 226 ---- ...andalone-encoder-decoder-exports-design.md | 88 -- 4 files changed, 2538 deletions(-) delete mode 100644 docs/superpowers/plans/2026-04-21-generic-codec-interface.md delete mode 100644 docs/superpowers/plans/2026-04-21-standalone-encoder-decoder-exports.md delete mode 100644 docs/superpowers/specs/2026-04-21-generic-codec-interface-design.md delete mode 100644 docs/superpowers/specs/2026-04-21-standalone-encoder-decoder-exports-design.md diff --git a/docs/superpowers/plans/2026-04-21-generic-codec-interface.md b/docs/superpowers/plans/2026-04-21-generic-codec-interface.md deleted file mode 100644 index fab5626..0000000 --- a/docs/superpowers/plans/2026-04-21-generic-codec-interface.md +++ /dev/null @@ -1,1087 +0,0 @@ -# Generic Codec Interface Redesign — 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:** Replace single-param `Codec[T]`/`StreamCodec[T]` interfaces with two-param `Codec[S, T]`/`StreamCodec[S]` function-type structs, enabling type-safe codec composition via `Pipe`. - -**Architecture:** Core types become function types (`Encoder[S, T]`, `Decoder[S, T]`) bundled into structs (`Codec[S, T]`, `StreamCodec[S]`). Composition via free `Pipe*` functions. Compression codecs become standalone `Codec[[]byte, []byte]` instead of decorators. File codec stays as decorator with own signature. - -**Tech Stack:** Go 1.24+, generics, no new dependencies. - -**Spec:** `docs/superpowers/specs/2026-04-21-generic-codec-interface-design.md` - ---- - -## File Structure - -### Root package (`github.com/foomo/goencode`) - -| Action | File | Responsibility | -|--------|------|---------------| -| Rewrite | `codec.go` | `Codec[S, T]` struct, `Encoder[S, T]` func type, `Decoder[S, T]` func type | -| Rewrite | `streamcodec.go` | `StreamCodec[S]` struct, `StreamEncoder[S]` func type, `StreamDecoder[S]` func type | -| Create | `pipe.go` | `PipeEncoder`, `PipeDecoder`, `PipeCodec` functions | -| Delete | `encoder.go` | Old `Encoder[T]` interface — replaced by `Encoder[S, T]` func type in codec.go | -| Delete | `decoder.go` | Old `Decoder[T]` interface — replaced by `Decoder[S, T]` func type in codec.go | -| Delete | `encoderfunc.go` | Old `EncoderFunc[T]` — redundant | -| Delete | `decoderfunc.go` | Old `DecoderFunc[T]` — redundant | -| Delete | `streamencoder.go` | Old `StreamEncoder[T]` interface — replaced | -| Delete | `streamdecoder.go` | Old `StreamDecoder[T]` interface — replaced | -| Delete | `streamencoderfunc.go` | Old `StreamEncoderFunc[T]` — redundant | -| Delete | `streamdecoderfunc.go` | Old `StreamDecoderFunc[T]` — redundant | - -### Subpackages (each follows same pattern) - -| Category | Packages | Codec change | StreamCodec change | -|----------|----------|-------------|-------------------| -| Serialization | `json/v1`, `json/v2`, `xml`, `gob`, `asn1` | Return `goencode.Codec[T, []byte]` | Return `goencode.StreamCodec[T]` | -| Encoding | `base64`, `base32`, `hex`, `ascii85`, `pem` | Return `goencode.Codec[[]byte, []byte]` | Return `goencode.StreamCodec[[]byte]` | -| CSV | `csv` | Return `goencode.Codec[[][]string, []byte]` | Return `goencode.StreamCodec[[][]string]` | -| Compression | `gzip`, `flate`, `snappy`, `zstd`, `brotli` | Standalone `goencode.Codec[[]byte, []byte]` (no inner codec) | Standalone `goencode.StreamCodec[[]byte]` (no inner codec) | -| File | `file` | Keeps own `Codec[T]` struct wrapping `goencode.Codec[T, []byte]` | Keeps own `StreamCodec[T]` wrapping `goencode.StreamCodec[T]` | -| YAML | `yaml/v2`, `yaml/v3`, `yaml/v4` | Return `goencode.Codec[T, []byte]` | N/A (no stream codecs) | -| Msgpack | `msgpack/tinylib`, `msgpack/vmihailenco` | Return `goencode.Codec[T, []byte]` | Return `goencode.StreamCodec[T]` | - ---- - -### Task 1: Rewrite Root Package Core Types - -**Files:** -- Rewrite: `codec.go` -- Rewrite: `streamcodec.go` -- Delete: `encoder.go`, `decoder.go`, `encoderfunc.go`, `decoderfunc.go`, `streamencoder.go`, `streamdecoder.go`, `streamencoderfunc.go`, `streamdecoderfunc.go` - -- [ ] **Step 1: Delete obsolete files** - -```bash -cd /Users/franklin/Workingcopies/github.com/foomo/goencode -rm encoder.go decoder.go encoderfunc.go decoderfunc.go \ - streamencoder.go streamdecoder.go streamencoderfunc.go streamdecoderfunc.go -``` - -- [ ] **Step 2: Rewrite `codec.go`** - -```go -package goencode - -// Encoder encodes source S to target T. -type Encoder[S, T any] func(s S) (T, error) - -// Decoder decodes target T back into source S. -type Decoder[S, T any] func(t T, s *S) error - -// Codec bundles an Encoder and Decoder for S ↔ T round-trips. -type Codec[S, T any] struct { - Encode Encoder[S, T] - Decode Decoder[S, T] -} -``` - -- [ ] **Step 3: Rewrite `streamcodec.go`** - -```go -package goencode - -import "io" - -// StreamEncoder encodes S into an io.Writer. -type StreamEncoder[S any] func(w io.Writer, s S) error - -// StreamDecoder decodes S from an io.Reader. -type StreamDecoder[S any] func(r io.Reader, s *S) error - -// StreamCodec bundles streaming encode/decode for S. -type StreamCodec[S any] struct { - Encode StreamEncoder[S] - Decode StreamDecoder[S] -} -``` - -- [ ] **Step 4: Verify root package compiles** - -Run: `cd /Users/franklin/Workingcopies/github.com/foomo/goencode && go build ./...` - -Expected: Compilation errors in subpackages (they still reference old types). Root package itself should compile. - -Note: Use `go build .` (root only) to verify just the root package, since subpackages will fail until migrated. - -Run: `go build .` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add -A && git commit -m "refactor: replace Codec[T] interface with Codec[S,T] function-type struct - -Replace single-param interfaces with two-param function types: -- Encoder[S,T] func type, Decoder[S,T] func type -- Codec[S,T] struct bundling Encode/Decode -- StreamEncoder[S], StreamDecoder[S] func types -- StreamCodec[S] struct bundling stream Encode/Decode -- Remove old Encoder[T], Decoder[T] interfaces and Func wrappers" -``` - ---- - -### Task 2: Add Pipe Composition Functions - -**Files:** -- Create: `pipe.go` -- Create: `pipe_test.go` - -- [ ] **Step 1: Write `pipe_test.go`** - -```go -package goencode_test - -import ( - "fmt" - "strconv" - "testing" - - goencode "github.com/foomo/goencode" -) - -func TestPipeEncoder(t *testing.T) { - intToStr := goencode.Encoder[int, string](func(i int) (string, error) { - return strconv.Itoa(i), nil - }) - strToBytes := goencode.Encoder[string, []byte](func(s string) ([]byte, error) { - return []byte(s), nil - }) - - piped := goencode.PipeEncoder(intToStr, strToBytes) - - got, err := piped(42) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if string(got) != "42" { - t.Fatalf("got %q, want %q", string(got), "42") - } -} - -func TestPipeEncoder_FirstError(t *testing.T) { - failing := goencode.Encoder[int, string](func(i int) (string, error) { - return "", fmt.Errorf("encode failed") - }) - second := goencode.Encoder[string, []byte](func(s string) ([]byte, error) { - t.Fatal("second encoder should not be called") - return nil, nil - }) - - piped := goencode.PipeEncoder(failing, second) - - _, err := piped(42) - if err == nil { - t.Fatal("expected error") - } -} - -func TestPipeDecoder(t *testing.T) { - strToInt := goencode.Decoder[int, string](func(s string, i *int) error { - v, err := strconv.Atoi(s) - if err != nil { - return err - } - *i = v - return nil - }) - bytesToStr := goencode.Decoder[string, []byte](func(b []byte, s *string) error { - *s = string(b) - return nil - }) - - piped := goencode.PipeDecoder(strToInt, bytesToStr) - - var got int - if err := piped([]byte("42"), &got); err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got != 42 { - t.Fatalf("got %d, want 42", got) - } -} - -func TestPipeCodec(t *testing.T) { - intStr := goencode.Codec[int, string]{ - Encode: func(i int) (string, error) { - return strconv.Itoa(i), nil - }, - Decode: func(s string, i *int) error { - v, err := strconv.Atoi(s) - if err != nil { - return err - } - *i = v - return nil - }, - } - strBytes := goencode.Codec[string, []byte]{ - Encode: func(s string) ([]byte, error) { - return []byte(s), nil - }, - Decode: func(b []byte, s *string) error { - *s = string(b) - return nil - }, - } - - piped := goencode.PipeCodec(intStr, strBytes) - - encoded, err := piped.Encode(42) - if err != nil { - t.Fatalf("encode error: %v", err) - } - if string(encoded) != "42" { - t.Fatalf("encoded: got %q, want %q", string(encoded), "42") - } - - var decoded int - if err := piped.Decode(encoded, &decoded); err != nil { - t.Fatalf("decode error: %v", err) - } - if decoded != 42 { - t.Fatalf("decoded: got %d, want 42", decoded) - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd /Users/franklin/Workingcopies/github.com/foomo/goencode && go test -tags=safe -run TestPipe -v .` -Expected: FAIL — `PipeEncoder`, `PipeDecoder`, `PipeCodec` not defined - -- [ ] **Step 3: Write `pipe.go`** - -```go -package goencode - -// PipeEncoder chains two encoders: A → B → C. -func PipeEncoder[A, B, C any](first Encoder[A, B], second Encoder[B, C]) Encoder[A, C] { - return func(a A) (C, error) { - b, err := first(a) - if err != nil { - var zero C - return zero, err - } - return second(b) - } -} - -// PipeDecoder chains two decoders in reverse: decodes C → B via second, then B → A via first. -func PipeDecoder[A, B, C any](first Decoder[A, B], second Decoder[B, C]) Decoder[A, C] { - return func(c C, a *A) error { - var b B - if err := second(c, &b); err != nil { - return err - } - return first(b, a) - } -} - -// PipeCodec chains two codecs: Codec[A,B] + Codec[B,C] → Codec[A,C]. -func PipeCodec[A, B, C any](first Codec[A, B], second Codec[B, C]) Codec[A, C] { - return Codec[A, C]{ - Encode: PipeEncoder(first.Encode, second.Encode), - Decode: PipeDecoder(first.Decode, second.Decode), - } -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `cd /Users/franklin/Workingcopies/github.com/foomo/goencode && go test -tags=safe -run TestPipe -v .` -Expected: PASS (all 4 tests) - -- [ ] **Step 5: Commit** - -```bash -git add pipe.go pipe_test.go && git commit -m "feat: add Pipe composition functions for Encoder, Decoder, Codec" -``` - ---- - -### Task 3: Migrate Serialization Codecs (json/v1, xml, gob, asn1) - -These all follow the same pattern: stateless generic codec returning `Codec[T, []byte]`. - -**Files:** -- Modify: `json/v1/codec.go` -- Modify: `json/v1/streamcodec.go` -- Modify: `json/v1/codec_test.go` -- Modify: `json/v1/streamcodec_test.go` -- Modify: `xml/codec.go` (same pattern) -- Modify: `xml/streamcodec.go` (same pattern, if exists) -- Modify: `gob/streamcodec.go` -- Modify: `asn1/codec.go` -- Modify: `asn1/streamcodec.go` -- Modify: all corresponding `*_test.go` and `benchmark_test.go` files - -- [ ] **Step 1: Rewrite `json/v1/codec.go`** - -```go -package json - -import ( - "encoding/json" - - encoding "github.com/foomo/goencode" -) - -// NewCodec returns a JSON codec for T. -// It is safe for concurrent use. -func NewCodec[T any]() encoding.Codec[T, []byte] { - return encoding.Codec[T, []byte]{ - Encode: func(v T) ([]byte, error) { - return json.Marshal(v) - }, - Decode: func(b []byte, v *T) error { - return json.Unmarshal(b, v) - }, - } -} -``` - -- [ ] **Step 2: Rewrite `json/v1/streamcodec.go`** - -```go -package json - -import ( - "encoding/json" - "io" - - encoding "github.com/foomo/goencode" -) - -// NewStreamCodec returns a JSON stream codec for T. -// It is safe for concurrent use. -func NewStreamCodec[T any]() encoding.StreamCodec[T] { - return encoding.StreamCodec[T]{ - Encode: func(w io.Writer, v T) error { - return json.NewEncoder(w).Encode(v) - }, - Decode: func(r io.Reader, v *T) error { - return json.NewDecoder(r).Decode(v) - }, - } -} -``` - -- [ ] **Step 3: Update `json/v1/codec_test.go`** - -```go -package json_test - -import ( - "fmt" - - "github.com/foomo/goencode/json/v1" -) - -func ExampleNewCodec() { - type Data struct { - Name string - } - - c := json.NewCodec[Data]() - - encoded, err := c.Encode(Data{Name: "example-123"}) - if err != nil { - fmt.Printf("Encode failed: %v\n", err) - return - } - - fmt.Printf("Encoded: %s\n", string(encoded)) - - var decoded Data - if err := c.Decode(encoded, &decoded); err != nil { - fmt.Printf("Decode failed: %v\n", err) - return - } - - fmt.Printf("Decoded Name: %s\n", decoded.Name) - // Output: - // Encoded: {"Name":"example-123"} - // Decoded Name: example-123 -} -``` - -Note: Example function name changes from `ExampleCodec` to `ExampleNewCodec` because there is no longer a `Codec` exported type — only `NewCodec` constructor. - -- [ ] **Step 4: Migrate xml, gob, asn1 codecs using same pattern** - -For each package, replace the struct type + methods with a constructor returning `encoding.Codec[T, []byte]` or `encoding.StreamCodec[T]`. - -**`xml/codec.go`** — same as json/v1 but uses `encoding/xml.Marshal`/`Unmarshal`. Note: current xml codec uses `bufpool.sync` for encoding — keep that optimization by using a closure that captures the pool usage. - -**`gob/streamcodec.go`** — returns `encoding.StreamCodec[T]` using `gob.NewEncoder`/`gob.NewDecoder`. - -**`asn1/codec.go`** — uses `encoding/asn1.Marshal`/`Unmarshal`. Note: asn1.Unmarshal returns `(rest []byte, err error)` — keep the existing discard-rest pattern. - -Delete the old struct types (`Codec[T]`, `StreamCodec[T]`) from each package — they are replaced by the constructor functions. - -- [ ] **Step 5: Run tests for all migrated packages** - -Run: `cd /Users/franklin/Workingcopies/github.com/foomo/goencode && go test -tags=safe ./json/v1/... ./xml/... ./gob/... ./asn1/...` -Expected: PASS - -- [ ] **Step 6: Commit** - -```bash -git add json/v1/ xml/ gob/ asn1/ && git commit -m "refactor: migrate json/v1, xml, gob, asn1 to Codec[S,T] function types" -``` - ---- - -### Task 4: Migrate Encoding Codecs (base64, base32, hex, ascii85, pem) - -These are non-generic, operating on `[]byte` → `[]byte` (or `*pem.Block` for pem). - -**Files:** -- Modify: `base64/codec.go`, `base64/streamcodec.go` -- Modify: `base32/codec.go`, `base32/streamcodec.go` -- Modify: `hex/codec.go`, `hex/streamcodec.go` -- Modify: `ascii85/codec.go`, `ascii85/streamcodec.go` -- Modify: `pem/streamcodec.go` -- Modify: all corresponding test and benchmark files - -- [ ] **Step 1: Rewrite `base64/codec.go`** - -```go -package base64 - -import ( - stdbase64 "encoding/base64" - - encoding "github.com/foomo/goencode" -) - -// NewCodec returns a Base64 codec. -// It is safe for concurrent use. -func NewCodec() encoding.Codec[[]byte, []byte] { - return encoding.Codec[[]byte, []byte]{ - Encode: func(v []byte) ([]byte, error) { - dst := make([]byte, stdbase64.StdEncoding.EncodedLen(len(v))) - stdbase64.StdEncoding.Encode(dst, v) - return dst, nil - }, - Decode: func(b []byte, v *[]byte) error { - dst := make([]byte, stdbase64.StdEncoding.DecodedLen(len(b))) - n, err := stdbase64.StdEncoding.Decode(dst, b) - if err != nil { - return err - } - *v = dst[:n] - return nil - }, - } -} -``` - -- [ ] **Step 2: Rewrite `base64/streamcodec.go`** - -```go -package base64 - -import ( - stdbase64 "encoding/base64" - "io" - - encoding "github.com/foomo/goencode" -) - -// NewStreamCodec returns a Base64 stream codec. -// It is safe for concurrent use. -func NewStreamCodec() encoding.StreamCodec[[]byte] { - return encoding.StreamCodec[[]byte]{ - Encode: func(w io.Writer, v []byte) error { - enc := stdbase64.NewEncoder(stdbase64.StdEncoding, w) - if _, err := enc.Write(v); err != nil { - _ = enc.Close() - return err - } - return enc.Close() - }, - Decode: func(r io.Reader, v *[]byte) error { - data, err := io.ReadAll(stdbase64.NewDecoder(stdbase64.StdEncoding, r)) - if err != nil { - return err - } - *v = data - return nil - }, - } -} -``` - -- [ ] **Step 3: Migrate base32, hex, ascii85, pem using same pattern** - -Each follows the same structure — replace struct + methods with constructor returning `encoding.Codec[[]byte, []byte]` and `encoding.StreamCodec[[]byte]`. - -**pem** is special: operates on `*pem.Block` not `[]byte`. Returns `encoding.Codec[*pem.Block, []byte]` and `encoding.StreamCodec[*pem.Block]`. - -Delete old struct types from each package. - -- [ ] **Step 4: Update test files** - -Rename example functions from `ExampleCodec`/`ExampleStreamCodec` to `ExampleNewCodec`/`ExampleNewStreamCodec` since the exported type is now the constructor, not the struct. - -- [ ] **Step 5: Run tests** - -Run: `cd /Users/franklin/Workingcopies/github.com/foomo/goencode && go test -tags=safe ./base64/... ./base32/... ./hex/... ./ascii85/... ./pem/...` -Expected: PASS - -- [ ] **Step 6: Commit** - -```bash -git add base64/ base32/ hex/ ascii85/ pem/ && git commit -m "refactor: migrate encoding codecs (base64, base32, hex, ascii85, pem) to Codec[S,T]" -``` - ---- - -### Task 5: Migrate CSV Codec - -CSV is special — operates on `[][]string`. - -**Files:** -- Modify: `csv/streamcodec.go` -- Modify: `csv/codec_test.go`, `csv/streamcodec_test.go`, `csv/benchmark_test.go` - -- [ ] **Step 1: Rewrite `csv/streamcodec.go`** - -CSV only has a StreamCodec. Return `encoding.StreamCodec[[][]string]`. - -```go -package csv - -import ( - "encoding/csv" - "io" - - encoding "github.com/foomo/goencode" -) - -// NewStreamCodec returns a CSV stream codec for [][]string. -// It is safe for concurrent use. -func NewStreamCodec() encoding.StreamCodec[[][]string] { - return encoding.StreamCodec[[][]string]{ - Encode: func(w io.Writer, v [][]string) error { - return csv.NewWriter(w).WriteAll(v) - }, - Decode: func(r io.Reader, v *[][]string) error { - records, err := csv.NewReader(r).ReadAll() - if err != nil { - return err - } - *v = records - return nil - }, - } -} -``` - -Note: Check if csv also has a `Codec` (non-stream). If so, migrate it similarly to return `encoding.Codec[[][]string, []byte]`. - -- [ ] **Step 2: Update test files, run tests** - -Run: `cd /Users/franklin/Workingcopies/github.com/foomo/goencode && go test -tags=safe ./csv/...` -Expected: PASS - -- [ ] **Step 3: Commit** - -```bash -git add csv/ && git commit -m "refactor: migrate csv codec to StreamCodec[S] function type" -``` - ---- - -### Task 6: Migrate Compression Codecs (gzip, flate, snappy, zstd, brotli) - -Biggest change: remove decorator pattern. Each becomes standalone `Codec[[]byte, []byte]`. - -**Files:** -- Rewrite: `gzip/codec.go`, `gzip/streamcodec.go` -- Rewrite: `flate/codec.go`, `flate/streamcodec.go` -- Rewrite: `snappy/codec.go`, `snappy/streamcodec.go` -- Rewrite: `zstd/codec.go`, `zstd/streamcodec.go` -- Rewrite: `brotli/codec.go`, `brotli/streamcodec.go` -- Keep: `gzip/option.go`, `flate/option.go`, `zstd/option.go`, `brotli/option.go` (unchanged) -- Modify: all corresponding test and benchmark files - -- [ ] **Step 1: Rewrite `gzip/codec.go`** - -```go -package gzip - -import ( - "bytes" - "compress/gzip" - "fmt" - "io" - - encoding "github.com/foomo/goencode" - "github.com/foomo/goencode/internal/sync" -) - -// NewCodec returns a gzip compression codec. -// It is safe for concurrent use. -func NewCodec(opts ...Option) encoding.Codec[[]byte, []byte] { - o := options{ - level: gzip.DefaultCompression, - } - for _, opt := range opts { - opt(&o) - } - - return encoding.Codec[[]byte, []byte]{ - Encode: func(data []byte) ([]byte, error) { - buf := sync.Get() - defer sync.Put(buf) - - w, err := gzip.NewWriterLevel(buf, o.level) - if err != nil { - return nil, err - } - - if _, err := w.Write(data); err != nil { - return nil, err - } - - if err := w.Close(); err != nil { - return nil, err - } - - return append([]byte(nil), buf.Bytes()...), nil - }, - Decode: func(data []byte, v *[]byte) error { - r, err := gzip.NewReader(bytes.NewReader(data)) - if err != nil { - return err - } - defer r.Close() - - var src io.Reader = r - if o.maxDecodedSize > 0 { - src = io.LimitReader(r, o.maxDecodedSize+1) - } - - decoded, err := io.ReadAll(src) - if err != nil { - return err - } - - if o.maxDecodedSize > 0 && int64(len(decoded)) > o.maxDecodedSize { - return fmt.Errorf("gzip: decompressed size exceeds limit of %d bytes", o.maxDecodedSize) - } - - *v = decoded - return nil - }, - } -} -``` - -- [ ] **Step 2: Rewrite `gzip/streamcodec.go`** - -```go -package gzip - -import ( - "compress/gzip" - "io" - - encoding "github.com/foomo/goencode" -) - -// NewStreamCodec returns a gzip compression stream codec. -// It is safe for concurrent use. -func NewStreamCodec(opts ...Option) encoding.StreamCodec[[]byte] { - o := options{ - level: gzip.DefaultCompression, - } - for _, opt := range opts { - opt(&o) - } - - return encoding.StreamCodec[[]byte]{ - Encode: func(w io.Writer, data []byte) error { - gw, err := gzip.NewWriterLevel(w, o.level) - if err != nil { - return err - } - - if _, err := gw.Write(data); err != nil { - gw.Close() - return err - } - - return gw.Close() - }, - Decode: func(r io.Reader, v *[]byte) error { - gr, err := gzip.NewReader(r) - if err != nil { - return err - } - defer gr.Close() - - var src io.Reader = gr - if o.maxDecodedSize > 0 { - src = io.LimitReader(gr, o.maxDecodedSize+1) - } - - data, err := io.ReadAll(src) - if err != nil { - return err - } - - if o.maxDecodedSize > 0 && int64(len(data)) > o.maxDecodedSize { - return fmt.Errorf("gzip: decompressed size exceeds limit of %d bytes", o.maxDecodedSize) - } - - *v = data - return nil - }, - } -} -``` - -- [ ] **Step 3: Migrate flate, snappy, zstd, brotli using same pattern** - -Each compression codec follows the same transformation: -- Remove generic type param `[T any]` -- Remove inner `codec` field -- Return `encoding.Codec[[]byte, []byte]` / `encoding.StreamCodec[[]byte]` -- Encode/Decode operate directly on `[]byte` -- Keep option.go files unchanged - -**snappy** is simplest — no options, just `NewCodec() encoding.Codec[[]byte, []byte]`. - -**zstd, brotli** — same pattern as gzip, use their respective compression libraries. - -- [ ] **Step 4: Update test files** - -Tests change from decorator pattern to standalone + Pipe: - -```go -// Old test pattern -c := gzip.NewCodec(json.NewCodec[Data]()) -encoded, _ := c.Encode(Data{Name: "test"}) - -// New test pattern — test gzip standalone -c := gzip.NewCodec() -encoded, _ := c.Encode([]byte(`{"Name":"test"}`)) -var decoded []byte -_ = c.Decode(encoded, &decoded) -// assert decoded == original bytes - -// New test pattern — test with Pipe -combined := goencode.PipeCodec(json.NewCodec[Data](), gzip.NewCodec()) -encoded, _ := combined.Encode(Data{Name: "test"}) -var decoded Data -_ = combined.Decode(encoded, &decoded) -``` - -Update all `*_test.go` and `benchmark_test.go` files accordingly. - -- [ ] **Step 5: Run tests** - -Run: `cd /Users/franklin/Workingcopies/github.com/foomo/goencode && go test -tags=safe ./gzip/... ./flate/... ./snappy/... ./zstd/... ./brotli/...` -Expected: PASS - -- [ ] **Step 6: Commit** - -```bash -git add gzip/ flate/ snappy/ zstd/ brotli/ && git commit -m "refactor: make compression codecs standalone Codec[[]byte,[]byte] - -Remove decorator pattern. Each compression codec now operates on raw -bytes. Use goencode.PipeCodec() to compose with serialization codecs." -``` - ---- - -### Task 7: Migrate File Codec - -File codec stays as decorator — wraps `Codec[T, []byte]` with own signature. - -**Files:** -- Modify: `file/codec.go` -- Modify: `file/streamcodec.go` -- Modify: `file/option.go` (likely unchanged) -- Modify: `file/codec_test.go`, `file/streamcodec_test.go` - -- [ ] **Step 1: Update `file/codec.go`** - -Only change: the inner codec type from `encoding.Codec[T]` to `encoding.Codec[T, []byte]`. - -```go -package file - -import ( - "fmt" - "os" - "path/filepath" - - encoding "github.com/foomo/goencode" -) - -// Codec encodes T to a file and decodes T from a file using an underlying Codec[T, []byte]. -// Writes are atomic: data is written to a temporary file and renamed into place. -// It is safe for concurrent use. -type Codec[T any] struct { - codec encoding.Codec[T, []byte] - perm os.FileMode -} - -// NewCodec returns a file codec that delegates serialization to codec. -func NewCodec[T any](codec encoding.Codec[T, []byte], opts ...Option) *Codec[T] { - o := options{ - perm: 0o644, - } - for _, opt := range opts { - opt(&o) - } - - return &Codec[T]{ - codec: codec, - perm: o.perm, - } -} - -// Encode serializes v and atomically writes the result to path. -func (c *Codec[T]) Encode(path string, v T) error { - b, err := c.codec.Encode(v) - if err != nil { - return err - } - - dir := filepath.Dir(path) - - f, err := os.CreateTemp(dir, ".tmp-*") - if err != nil { - return fmt.Errorf("creating temp file: %w", err) - } - - tmp := f.Name() - - if _, err := f.Write(b); err != nil { - f.Close() - os.Remove(tmp) - return fmt.Errorf("writing temp file: %w", err) - } - - if err := f.Close(); err != nil { - os.Remove(tmp) - return fmt.Errorf("closing temp file: %w", err) - } - - if err := os.Chmod(tmp, c.perm); err != nil { - os.Remove(tmp) - return fmt.Errorf("setting file permissions: %w", err) - } - - if err := os.Rename(tmp, path); err != nil { - os.Remove(tmp) - return fmt.Errorf("renaming temp file: %w", err) - } - - return nil -} - -// Decode reads the file at path and deserializes its contents into v. -func (c *Codec[T]) Decode(path string, v *T) error { - b, err := os.ReadFile(path) - if err != nil { - return err - } - - return c.codec.Decode(b, v) -} -``` - -- [ ] **Step 2: Update `file/streamcodec.go`** - -Same change: inner codec type from `encoding.StreamCodec[T]` to `encoding.StreamCodec[T]` (StreamCodec signature is unchanged — still single param). - -```go -package file - -import ( - "fmt" - "os" - "path/filepath" - - encoding "github.com/foomo/goencode" -) - -// StreamCodec encodes T to a file and decodes T from a file using an underlying StreamCodec[T]. -// Writes are atomic: data is written to a temporary file and renamed into place. -// It is safe for concurrent use. -type StreamCodec[T any] struct { - codec encoding.StreamCodec[T] - perm os.FileMode -} - -// NewStreamCodec returns a file stream codec that delegates serialization to codec. -func NewStreamCodec[T any](codec encoding.StreamCodec[T], opts ...Option) *StreamCodec[T] { - o := options{ - perm: 0o644, - } - for _, opt := range opts { - opt(&o) - } - - return &StreamCodec[T]{ - codec: codec, - perm: o.perm, - } -} - -// Encode serializes v and atomically writes the result to path. -func (c *StreamCodec[T]) Encode(path string, v T) error { - dir := filepath.Dir(path) - - f, err := os.CreateTemp(dir, ".tmp-*") - if err != nil { - return fmt.Errorf("creating temp file: %w", err) - } - - tmp := f.Name() - - if err := c.codec.Encode(f, v); err != nil { - f.Close() - os.Remove(tmp) - return fmt.Errorf("encoding to temp file: %w", err) - } - - if err := f.Close(); err != nil { - os.Remove(tmp) - return fmt.Errorf("closing temp file: %w", err) - } - - if err := os.Chmod(tmp, c.perm); err != nil { - os.Remove(tmp) - return fmt.Errorf("setting file permissions: %w", err) - } - - if err := os.Rename(tmp, path); err != nil { - os.Remove(tmp) - return fmt.Errorf("renaming temp file: %w", err) - } - - return nil -} - -// Decode reads the file at path and deserializes its contents into v. -func (c *StreamCodec[T]) Decode(path string, v *T) error { - f, err := os.Open(path) - if err != nil { - return err - } - defer f.Close() - - return c.codec.Decode(f, v) -} -``` - -- [ ] **Step 3: Update tests — constructor call stays same** - -The file codec test should work with minimal changes since the API is the same. The only difference is `json.NewCodec[Data]()` now returns a struct instead of interface — but Go handles this transparently. - -- [ ] **Step 4: Run tests** - -Run: `cd /Users/franklin/Workingcopies/github.com/foomo/goencode && go test -tags=safe ./file/...` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add file/ && git commit -m "refactor: update file codec to accept Codec[T, []byte]" -``` - ---- - -### Task 8: Migrate Submodule Codecs (json/v2, yaml/v2, yaml/v3, yaml/v4, msgpack/*) - -These are separate go.mod modules with external dependencies. - -**Files:** -- Modify: `json/v2/codec.go` -- Modify: `yaml/v2/codec.go`, `yaml/v3/codec.go`, `yaml/v4/codec.go` -- Modify: `msgpack/tinylib/codec.go`, `msgpack/tinylib/streamcodec.go` -- Modify: `msgpack/vmihailenco/codec.go`, `msgpack/vmihailenco/streamcodec.go` -- Modify: all corresponding test and benchmark files - -- [ ] **Step 1: Migrate `json/v2/codec.go`** - -Same pattern as json/v1 but uses `github.com/go-json-experiment/json`. Return `encoding.Codec[T, []byte]`. If it has additional methods (`EncodeTo`/`DecodeFrom`), those can be dropped since StreamCodec covers streaming. - -- [ ] **Step 2: Migrate yaml codecs** - -All three yaml versions follow identical pattern — return `encoding.Codec[T, []byte]`. - -- [ ] **Step 3: Migrate msgpack codecs** - -**msgpack/tinylib** — has type constraints (`msgp.Marshaler`/`msgp.Unmarshaler`). The constraint stays but return type changes to `encoding.Codec[T, []byte]`. Keep the type assertion checks in the constructor. - -**msgpack/vmihailenco** — standard pattern, return `encoding.Codec[T, []byte]`. - -- [ ] **Step 4: Update go.mod files** - -Each submodule's `go.mod` references the root module. Run `go mod tidy` in each: - -```bash -for dir in json/v2 yaml/v2 yaml/v3 yaml/v4 msgpack/tinylib msgpack/vmihailenco; do - (cd "$dir" && go mod tidy) -done -``` - -- [ ] **Step 5: Update tests, run all** - -Run: `cd /Users/franklin/Workingcopies/github.com/foomo/goencode && go test -tags=safe ./json/v2/... ./yaml/v2/... ./yaml/v3/... ./yaml/v4/... ./msgpack/tinylib/... ./msgpack/vmihailenco/...` -Expected: PASS - -- [ ] **Step 6: Commit** - -```bash -git add json/v2/ yaml/ msgpack/ && git commit -m "refactor: migrate submodule codecs (json/v2, yaml, msgpack) to Codec[S,T]" -``` - ---- - -### Task 9: Full Test Suite & Lint - -**Files:** None new — validation only. - -- [ ] **Step 1: Run full test suite** - -Run: `cd /Users/franklin/Workingcopies/github.com/foomo/goencode && make test` -Expected: PASS - -- [ ] **Step 2: Run linter** - -Run: `cd /Users/franklin/Workingcopies/github.com/foomo/goencode && make lint` -Expected: PASS (or only pre-existing warnings) - -- [ ] **Step 3: Fix any lint issues** - -Address any new lint warnings introduced by the migration. - -- [ ] **Step 4: Run race detector** - -Run: `cd /Users/franklin/Workingcopies/github.com/foomo/goencode && make test.race` -Expected: PASS - -- [ ] **Step 5: Run full check** - -Run: `cd /Users/franklin/Workingcopies/github.com/foomo/goencode && make check` -Expected: PASS - -- [ ] **Step 6: Commit any fixes** - -```bash -git add -A && git commit -m "fix: address lint issues from codec interface migration" -``` - -(Skip if no fixes needed.) diff --git a/docs/superpowers/plans/2026-04-21-standalone-encoder-decoder-exports.md b/docs/superpowers/plans/2026-04-21-standalone-encoder-decoder-exports.md deleted file mode 100644 index 741c93b..0000000 --- a/docs/superpowers/plans/2026-04-21-standalone-encoder-decoder-exports.md +++ /dev/null @@ -1,1137 +0,0 @@ -# Standalone Encoder/Decoder Exports 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:** Export standalone `Encoder` and `Decoder` functions from all subpackages so consumer APIs can depend on a single direction without requiring a full `Codec`. - -**Architecture:** Each subpackage gets bare `Encoder`/`Decoder` funcs (simple codecs) or `NewEncoder`/`NewDecoder` constructors (configurable codecs). Existing `NewCodec` constructors are refactored to delegate to these new exports. The reference implementation is `json/v1/codec.go` which already has this pattern. - -**Tech Stack:** Go generics, `go.work` multi-module workspace - ---- - -### Task 1: Simple serialization codecs — stdlib (xml, gob, asn1, csv) - -These packages use stdlib only, live in the root module, and follow the same pattern: extract inline encode/decode closures into named generic funcs. - -**Files:** -- Modify: `xml/codec.go` -- Modify: `gob/codec.go` -- Modify: `asn1/codec.go` -- Modify: `csv/codec.go` - -- [ ] **Step 1: Refactor `xml/codec.go`** - -```go -package xml - -import ( - stdxml "encoding/xml" - - encoding "github.com/foomo/goencode" -) - -// Encoder encodes T to XML bytes. -func Encoder[T any](v T) ([]byte, error) { - return stdxml.Marshal(v) -} - -// Decoder decodes XML bytes into T. -func Decoder[T any](b []byte, v *T) error { - return stdxml.Unmarshal(b, v) -} - -// NewCodec returns an XML codec for T. -// It is safe for concurrent use. -func NewCodec[T any]() encoding.Codec[T, []byte] { - return encoding.Codec[T, []byte]{ - Encode: Encoder[T], - Decode: Decoder[T], - } -} -``` - -- [ ] **Step 2: Refactor `gob/codec.go`** - -```go -package gob - -import ( - "bytes" - stdgob "encoding/gob" - - encoding "github.com/foomo/goencode" - "github.com/foomo/goencode/internal/sync" -) - -// Encoder encodes T to gob bytes. -func Encoder[T any](v T) ([]byte, error) { - buf := sync.Get() - defer sync.Put(buf) - - if err := stdgob.NewEncoder(buf).Encode(v); err != nil { - return nil, err - } - - return append([]byte(nil), buf.Bytes()...), nil -} - -// Decoder decodes gob bytes into T. -func Decoder[T any](b []byte, v *T) error { - return stdgob.NewDecoder(bytes.NewReader(b)).Decode(v) -} - -// NewCodec returns a gob codec for T. -// It is safe for concurrent use. -func NewCodec[T any]() encoding.Codec[T, []byte] { - return encoding.Codec[T, []byte]{ - Encode: Encoder[T], - Decode: Decoder[T], - } -} -``` - -- [ ] **Step 3: Refactor `asn1/codec.go`** - -```go -package asn1 - -import ( - stdasn1 "encoding/asn1" - - encoding "github.com/foomo/goencode" -) - -// Encoder encodes T to ASN.1 bytes. -func Encoder[T any](v T) ([]byte, error) { - return stdasn1.Marshal(v) -} - -// Decoder decodes ASN.1 bytes into T. -func Decoder[T any](b []byte, v *T) error { - _, err := stdasn1.Unmarshal(b, v) - return err -} - -// NewCodec returns an ASN1 codec for T. -// It is safe for concurrent use. -func NewCodec[T any]() encoding.Codec[T, []byte] { - return encoding.Codec[T, []byte]{ - Encode: Encoder[T], - Decode: Decoder[T], - } -} -``` - -- [ ] **Step 4: Refactor `csv/codec.go`** - -```go -package csv - -import ( - "bytes" - stdcsv "encoding/csv" - - encoding "github.com/foomo/goencode" - "github.com/foomo/goencode/internal/sync" -) - -// Encoder encodes [][]string to CSV bytes. -func Encoder(v [][]string) ([]byte, error) { - buf := sync.Get() - defer sync.Put(buf) - - cw := stdcsv.NewWriter(buf) - if err := cw.WriteAll(v); err != nil { - return nil, err - } - - cw.Flush() - - if err := cw.Error(); err != nil { - return nil, err - } - - return append([]byte(nil), buf.Bytes()...), nil -} - -// Decoder decodes CSV bytes into [][]string. -func Decoder(b []byte, v *[][]string) error { - records, err := stdcsv.NewReader(bytes.NewReader(b)).ReadAll() - if err != nil { - return err - } - - *v = records - - return nil -} - -// NewCodec returns a CSV codec for [][]string. -// It is safe for concurrent use. -func NewCodec() encoding.Codec[[][]string, []byte] { - return encoding.Codec[[][]string, []byte]{ - Encode: Encoder, - Decode: Decoder, - } -} -``` - -- [ ] **Step 5: Run tests for stdlib codecs** - -Run: `cd /Users/franklin/Workingcopies/github.com/foomo/goencode && go test -tags=safe ./xml/... ./gob/... ./asn1/... ./csv/...` -Expected: PASS - -- [ ] **Step 6: Commit** - -```bash -git add xml/codec.go gob/codec.go asn1/codec.go csv/codec.go -git commit -m "refactor: export standalone Encoder/Decoder in xml, gob, asn1, csv" -``` - ---- - -### Task 2: Simple encoding codecs (base64, base32, hex, ascii85, pem) - -These are `[]byte ↔ []byte` (or `*pem.Block ↔ []byte`) — no type parameters. - -**Files:** -- Modify: `base64/codec.go` -- Modify: `base32/codec.go` -- Modify: `hex/codec.go` -- Modify: `ascii85/codec.go` -- Modify: `pem/codec.go` - -- [ ] **Step 1: Refactor `base64/codec.go`** - -```go -package base64 - -import ( - stdbase64 "encoding/base64" - - encoding "github.com/foomo/goencode" -) - -// Encoder encodes bytes to Base64. -func Encoder(v []byte) ([]byte, error) { - dst := make([]byte, stdbase64.StdEncoding.EncodedLen(len(v))) - stdbase64.StdEncoding.Encode(dst, v) - - return dst, nil -} - -// Decoder decodes Base64 bytes. -func Decoder(b []byte, v *[]byte) error { - dst := make([]byte, stdbase64.StdEncoding.DecodedLen(len(b))) - - n, err := stdbase64.StdEncoding.Decode(dst, b) - if err != nil { - return err - } - - *v = dst[:n] - - return nil -} - -// NewCodec returns a Base64 codec. -// It is safe for concurrent use. -func NewCodec() encoding.Codec[[]byte, []byte] { - return encoding.Codec[[]byte, []byte]{ - Encode: Encoder, - Decode: Decoder, - } -} -``` - -- [ ] **Step 2: Refactor `base32/codec.go`** - -```go -package base32 - -import ( - stdbase32 "encoding/base32" - - encoding "github.com/foomo/goencode" -) - -// Encoder encodes bytes to Base32. -func Encoder(v []byte) ([]byte, error) { - dst := make([]byte, stdbase32.StdEncoding.EncodedLen(len(v))) - stdbase32.StdEncoding.Encode(dst, v) - - return dst, nil -} - -// Decoder decodes Base32 bytes. -func Decoder(b []byte, v *[]byte) error { - dst := make([]byte, stdbase32.StdEncoding.DecodedLen(len(b))) - - n, err := stdbase32.StdEncoding.Decode(dst, b) - if err != nil { - return err - } - - *v = dst[:n] - - return nil -} - -// NewCodec returns a Base32 codec. -// It is safe for concurrent use. -func NewCodec() encoding.Codec[[]byte, []byte] { - return encoding.Codec[[]byte, []byte]{ - Encode: Encoder, - Decode: Decoder, - } -} -``` - -- [ ] **Step 3: Refactor `hex/codec.go`** - -```go -package hex - -import ( - stdhex "encoding/hex" - - encoding "github.com/foomo/goencode" -) - -// Encoder encodes bytes to hexadecimal. -func Encoder(v []byte) ([]byte, error) { - dst := make([]byte, stdhex.EncodedLen(len(v))) - stdhex.Encode(dst, v) - - return dst, nil -} - -// Decoder decodes hexadecimal bytes. -func Decoder(b []byte, v *[]byte) error { - dst := make([]byte, stdhex.DecodedLen(len(b))) - - n, err := stdhex.Decode(dst, b) - if err != nil { - return err - } - - *v = dst[:n] - - return nil -} - -// NewCodec returns a Hex codec. -// It is safe for concurrent use. -func NewCodec() encoding.Codec[[]byte, []byte] { - return encoding.Codec[[]byte, []byte]{ - Encode: Encoder, - Decode: Decoder, - } -} -``` - -- [ ] **Step 4: Refactor `ascii85/codec.go`** - -```go -package ascii85 - -import ( - "bytes" - stdascii85 "encoding/ascii85" - - encoding "github.com/foomo/goencode" -) - -// Encoder encodes bytes to ASCII85. -func Encoder(v []byte) ([]byte, error) { - dst := make([]byte, stdascii85.MaxEncodedLen(len(v))) - n := stdascii85.Encode(dst, v) - - return dst[:n], nil -} - -// Decoder decodes ASCII85 bytes. -func Decoder(b []byte, v *[]byte) error { - buf := bytes.NewBuffer(make([]byte, 0, len(b))) - - r := stdascii85.NewDecoder(bytes.NewReader(b)) - if _, err := buf.ReadFrom(r); err != nil { - return err - } - - *v = buf.Bytes() - - return nil -} - -// NewCodec returns an ASCII85 codec. -// It is safe for concurrent use. -func NewCodec() encoding.Codec[[]byte, []byte] { - return encoding.Codec[[]byte, []byte]{ - Encode: Encoder, - Decode: Decoder, - } -} -``` - -- [ ] **Step 5: Refactor `pem/codec.go`** - -```go -package pem - -import ( - stdpem "encoding/pem" - "errors" - - encoding "github.com/foomo/goencode" -) - -// Encoder encodes a PEM block to bytes. -func Encoder(v *stdpem.Block) ([]byte, error) { - return stdpem.EncodeToMemory(v), nil -} - -// Decoder decodes bytes into a PEM block. -func Decoder(b []byte, v **stdpem.Block) error { - block, _ := stdpem.Decode(b) - if block == nil { - return errors.New("pem: no PEM block found") - } - - *v = block - - return nil -} - -// NewCodec returns a PEM codec. -// It is safe for concurrent use. -func NewCodec() encoding.Codec[*stdpem.Block, []byte] { - return encoding.Codec[*stdpem.Block, []byte]{ - Encode: Encoder, - Decode: Decoder, - } -} -``` - -- [ ] **Step 6: Run tests for encoding codecs** - -Run: `cd /Users/franklin/Workingcopies/github.com/foomo/goencode && go test -tags=safe ./base64/... ./base32/... ./hex/... ./ascii85/... ./pem/...` -Expected: PASS - -- [ ] **Step 7: Commit** - -```bash -git add base64/codec.go base32/codec.go hex/codec.go ascii85/codec.go pem/codec.go -git commit -m "refactor: export standalone Encoder/Decoder in base64, base32, hex, ascii85, pem" -``` - ---- - -### Task 3: Simple compression codec (snappy) - -Snappy takes no options, so it gets bare funcs. - -**Files:** -- Modify: `snappy/codec.go` - -- [ ] **Step 1: Refactor `snappy/codec.go`** - -```go -package snappy - -import ( - encoding "github.com/foomo/goencode" - "github.com/golang/snappy" -) - -// Encoder compresses bytes using Snappy. -func Encoder(data []byte) ([]byte, error) { - return snappy.Encode(nil, data), nil -} - -// Decoder decompresses Snappy bytes. -func Decoder(data []byte, v *[]byte) error { - decoded, err := snappy.Decode(nil, data) - if err != nil { - return err - } - - *v = decoded - - return nil -} - -// NewCodec returns a Snappy compression codec. -// It is safe for concurrent use. -func NewCodec() encoding.Codec[[]byte, []byte] { - return encoding.Codec[[]byte, []byte]{ - Encode: Encoder, - Decode: Decoder, - } -} -``` - -- [ ] **Step 2: Run tests** - -Run: `cd /Users/franklin/Workingcopies/github.com/foomo/goencode/snappy && go test -tags=safe ./...` -Expected: PASS - -- [ ] **Step 3: Commit** - -```bash -git add snappy/codec.go -git commit -m "refactor: export standalone Encoder/Decoder in snappy" -``` - ---- - -### Task 4: Configurable compression codecs (gzip, flate, zstd, brotli) - -These accept `Option` variadic args. They get `NewEncoder`/`NewDecoder` constructors. The closures inside `NewCodec` are extracted into these constructors, and `NewCodec` delegates to them. - -**Files:** -- Modify: `gzip/codec.go` -- Modify: `flate/codec.go` -- Modify: `zstd/codec.go` -- Modify: `brotli/codec.go` - -- [ ] **Step 1: Refactor `gzip/codec.go`** - -```go -package gzip - -import ( - "bytes" - "compress/gzip" - "fmt" - "io" - - encoding "github.com/foomo/goencode" - "github.com/foomo/goencode/internal/sync" -) - -// NewEncoder returns a gzip compression encoder. -func NewEncoder(opts ...Option) encoding.Encoder[[]byte, []byte] { - o := options{ - level: gzip.DefaultCompression, - } - for _, opt := range opts { - opt(&o) - } - - return func(data []byte) ([]byte, error) { - buf := sync.Get() - defer sync.Put(buf) - - w, err := gzip.NewWriterLevel(buf, o.level) - if err != nil { - return nil, err - } - - if _, err := w.Write(data); err != nil { - return nil, err - } - - if err := w.Close(); err != nil { - return nil, err - } - - return append([]byte(nil), buf.Bytes()...), nil - } -} - -// NewDecoder returns a gzip decompression decoder. -func NewDecoder(opts ...Option) encoding.Decoder[[]byte, []byte] { - o := options{} - for _, opt := range opts { - opt(&o) - } - - return func(data []byte, v *[]byte) error { - r, err := gzip.NewReader(bytes.NewReader(data)) - if err != nil { - return err - } - defer r.Close() - - var src io.Reader = r - if o.maxDecodedSize > 0 { - src = io.LimitReader(r, o.maxDecodedSize+1) - } - - decoded, err := io.ReadAll(src) - if err != nil { - return err - } - - if o.maxDecodedSize > 0 && int64(len(decoded)) > o.maxDecodedSize { - return fmt.Errorf("gzip: decompressed size exceeds limit of %d bytes", o.maxDecodedSize) - } - - *v = decoded - - return nil - } -} - -// NewCodec returns a gzip compression codec. -// It is safe for concurrent use. -func NewCodec(opts ...Option) encoding.Codec[[]byte, []byte] { - return encoding.Codec[[]byte, []byte]{ - Encode: NewEncoder(opts...), - Decode: NewDecoder(opts...), - } -} -``` - -- [ ] **Step 2: Refactor `flate/codec.go`** - -```go -package flate - -import ( - "bytes" - "compress/flate" - "fmt" - "io" - - encoding "github.com/foomo/goencode" - "github.com/foomo/goencode/internal/sync" -) - -// NewEncoder returns a DEFLATE compression encoder. -func NewEncoder(opts ...Option) encoding.Encoder[[]byte, []byte] { - o := options{ - level: flate.DefaultCompression, - } - for _, opt := range opts { - opt(&o) - } - - return func(data []byte) ([]byte, error) { - buf := sync.Get() - defer sync.Put(buf) - - w, err := flate.NewWriter(buf, o.level) - if err != nil { - return nil, err - } - - if _, err := w.Write(data); err != nil { - return nil, err - } - - if err := w.Close(); err != nil { - return nil, err - } - - return append([]byte(nil), buf.Bytes()...), nil - } -} - -// NewDecoder returns a DEFLATE decompression decoder. -func NewDecoder(opts ...Option) encoding.Decoder[[]byte, []byte] { - o := options{} - for _, opt := range opts { - opt(&o) - } - - return func(data []byte, v *[]byte) error { - r := flate.NewReader(bytes.NewReader(data)) - defer r.Close() - - var src io.Reader = r - if o.maxDecodedSize > 0 { - src = io.LimitReader(r, o.maxDecodedSize+1) - } - - decoded, err := io.ReadAll(src) - if err != nil { - return err - } - - if o.maxDecodedSize > 0 && int64(len(decoded)) > o.maxDecodedSize { - return fmt.Errorf("flate: decompressed size exceeds limit of %d bytes", o.maxDecodedSize) - } - - *v = decoded - - return nil - } -} - -// NewCodec returns a DEFLATE compression codec. -// It is safe for concurrent use. -func NewCodec(opts ...Option) encoding.Codec[[]byte, []byte] { - return encoding.Codec[[]byte, []byte]{ - Encode: NewEncoder(opts...), - Decode: NewDecoder(opts...), - } -} -``` - -- [ ] **Step 3: Refactor `zstd/codec.go`** - -```go -package zstd - -import ( - encoding "github.com/foomo/goencode" - "github.com/klauspost/compress/zstd" -) - -// NewEncoder returns a Zstandard compression encoder. -func NewEncoder(opts ...Option) encoding.Encoder[[]byte, []byte] { - o := options{ - level: zstd.SpeedDefault, - } - for _, opt := range opts { - opt(&o) - } - - return func(data []byte) ([]byte, error) { - enc, err := zstd.NewWriter(nil, zstd.WithEncoderLevel(o.level)) - if err != nil { - return nil, err - } - defer enc.Close() - - return enc.EncodeAll(data, nil), nil - } -} - -// NewDecoder returns a Zstandard decompression decoder. -func NewDecoder(opts ...Option) encoding.Decoder[[]byte, []byte] { - o := options{} - for _, opt := range opts { - opt(&o) - } - - return func(data []byte, v *[]byte) error { - dopts := []zstd.DOption{} - if o.maxDecodedSize > 0 { - dopts = append(dopts, zstd.WithDecoderMaxMemory(uint64(o.maxDecodedSize))) - } - - dec, err := zstd.NewReader(nil, dopts...) - if err != nil { - return err - } - defer dec.Close() - - decoded, err := dec.DecodeAll(data, nil) - if err != nil { - return err - } - - *v = decoded - - return nil - } -} - -// NewCodec returns a Zstandard compression codec. -// It is safe for concurrent use. -func NewCodec(opts ...Option) encoding.Codec[[]byte, []byte] { - return encoding.Codec[[]byte, []byte]{ - Encode: NewEncoder(opts...), - Decode: NewDecoder(opts...), - } -} -``` - -- [ ] **Step 4: Refactor `brotli/codec.go`** - -```go -package brotli - -import ( - "bytes" - "fmt" - "io" - - "github.com/andybalholm/brotli" - encoding "github.com/foomo/goencode" - "github.com/foomo/goencode/internal/sync" -) - -// NewEncoder returns a Brotli compression encoder. -func NewEncoder(opts ...Option) encoding.Encoder[[]byte, []byte] { - o := options{ - level: brotli.DefaultCompression, - } - for _, opt := range opts { - opt(&o) - } - - return func(data []byte) ([]byte, error) { - buf := sync.Get() - defer sync.Put(buf) - - w := brotli.NewWriterLevel(buf, o.level) - - if _, err := w.Write(data); err != nil { - return nil, err - } - - if err := w.Close(); err != nil { - return nil, err - } - - return append([]byte(nil), buf.Bytes()...), nil - } -} - -// NewDecoder returns a Brotli decompression decoder. -func NewDecoder(opts ...Option) encoding.Decoder[[]byte, []byte] { - o := options{} - for _, opt := range opts { - opt(&o) - } - - return func(data []byte, v *[]byte) error { - r := brotli.NewReader(bytes.NewReader(data)) - - var src io.Reader = r - if o.maxDecodedSize > 0 { - src = io.LimitReader(r, o.maxDecodedSize+1) - } - - decoded, err := io.ReadAll(src) - if err != nil { - return err - } - - if o.maxDecodedSize > 0 && int64(len(decoded)) > o.maxDecodedSize { - return fmt.Errorf("brotli: decompressed size exceeds limit of %d bytes", o.maxDecodedSize) - } - - *v = decoded - - return nil - } -} - -// NewCodec returns a Brotli compression codec. -// It is safe for concurrent use. -func NewCodec(opts ...Option) encoding.Codec[[]byte, []byte] { - return encoding.Codec[[]byte, []byte]{ - Encode: NewEncoder(opts...), - Decode: NewDecoder(opts...), - } -} -``` - -- [ ] **Step 5: Run tests for compression codecs** - -Run: `cd /Users/franklin/Workingcopies/github.com/foomo/goencode && go test -tags=safe ./gzip/... ./flate/...` -Run: `cd /Users/franklin/Workingcopies/github.com/foomo/goencode/zstd && go test -tags=safe ./...` -Run: `cd /Users/franklin/Workingcopies/github.com/foomo/goencode/brotli && go test -tags=safe ./...` -Expected: PASS for all - -- [ ] **Step 6: Commit** - -```bash -git add gzip/codec.go flate/codec.go zstd/codec.go brotli/codec.go -git commit -m "refactor: export NewEncoder/NewDecoder in gzip, flate, zstd, brotli" -``` - ---- - -### Task 5: Submodule serialization codecs (json/v2, yaml/v2, yaml/v3, yaml/v4, msgpack/tinylib, msgpack/vmihailenco, toml) - -These live in separate go.mod submodules. Same bare-func pattern as Task 1. - -**Files:** -- Modify: `json/v2/codec.go` -- Modify: `yaml/v2/codec.go` -- Modify: `yaml/v3/codec.go` -- Modify: `yaml/v4/codec.go` -- Modify: `msgpack/tinylib/codec.go` -- Modify: `msgpack/vmihailenco/codec.go` -- Modify: `toml/codec.go` - -- [ ] **Step 1: Refactor `toml/codec.go`** - -```go -package toml - -import ( - encoding "github.com/foomo/goencode" - - "github.com/BurntSushi/toml" -) - -// Encoder encodes T to TOML bytes. -func Encoder[T any](v T) ([]byte, error) { - return toml.Marshal(v) -} - -// Decoder decodes TOML bytes into T. -func Decoder[T any](b []byte, v *T) error { - return toml.Unmarshal(b, v) -} - -// NewCodec returns a TOML codec for T. -// It is safe for concurrent use. -func NewCodec[T any]() encoding.Codec[T, []byte] { - return encoding.Codec[T, []byte]{ - Encode: Encoder[T], - Decode: Decoder[T], - } -} -``` - -- [ ] **Step 2: Refactor `json/v2/codec.go`** - -Note: `json/v2` uses `github.com/go-json-experiment/json`. Read the current file to get the exact encode/decode logic before extracting. The current `NewCodec` uses `json.Marshal`/`json.Unmarshal` from that package. - -```go -package json - -import ( - encoding "github.com/foomo/goencode" - "github.com/go-json-experiment/json" -) - -// Encoder encodes T to JSON bytes (v2). -func Encoder[T any](v T) ([]byte, error) { - return json.Marshal(v) -} - -// Decoder decodes JSON bytes into T (v2). -func Decoder[T any](b []byte, v *T) error { - return json.Unmarshal(b, v) -} - -// NewCodec returns a JSON v2 codec for T. -// It is safe for concurrent use. -func NewCodec[T any]() encoding.Codec[T, []byte] { - return encoding.Codec[T, []byte]{ - Encode: Encoder[T], - Decode: Decoder[T], - } -} -``` - -Preserve any existing `NewStreamCodec` function unchanged at the bottom of the file. - -- [ ] **Step 3: Refactor `yaml/v2/codec.go`** - -```go -package yaml - -import ( - encoding "github.com/foomo/goencode" - "go.yaml.in/yaml/v2" -) - -// Encoder encodes T to YAML v2 bytes. -func Encoder[T any](v T) ([]byte, error) { - return yaml.Marshal(v) -} - -// Decoder decodes YAML v2 bytes into T. -func Decoder[T any](b []byte, v *T) error { - return yaml.Unmarshal(b, v) -} - -// NewCodec returns a YAML v2 codec for T. -// It is safe for concurrent use. -func NewCodec[T any]() encoding.Codec[T, []byte] { - return encoding.Codec[T, []byte]{ - Encode: Encoder[T], - Decode: Decoder[T], - } -} -``` - -- [ ] **Step 4: Refactor `yaml/v3/codec.go`** - -```go -package yaml - -import ( - encoding "github.com/foomo/goencode" - "gopkg.in/yaml.v3" -) - -// Encoder encodes T to YAML v3 bytes. -func Encoder[T any](v T) ([]byte, error) { - return yaml.Marshal(v) -} - -// Decoder decodes YAML v3 bytes into T. -func Decoder[T any](b []byte, v *T) error { - return yaml.Unmarshal(b, v) -} - -// NewCodec returns a YAML v3 codec for T. -// It is safe for concurrent use. -func NewCodec[T any]() encoding.Codec[T, []byte] { - return encoding.Codec[T, []byte]{ - Encode: Encoder[T], - Decode: Decoder[T], - } -} -``` - -- [ ] **Step 5: Refactor `yaml/v4/codec.go`** - -```go -package yaml - -import ( - encoding "github.com/foomo/goencode" - "github.com/goccy/go-yaml" -) - -// Encoder encodes T to YAML bytes. -func Encoder[T any](v T) ([]byte, error) { - return yaml.Marshal(v) -} - -// Decoder decodes YAML bytes into T. -func Decoder[T any](b []byte, v *T) error { - return yaml.Unmarshal(b, v) -} - -// NewCodec returns a YAML v4 codec for T. -// It is safe for concurrent use. -func NewCodec[T any]() encoding.Codec[T, []byte] { - return encoding.Codec[T, []byte]{ - Encode: Encoder[T], - Decode: Decoder[T], - } -} -``` - -- [ ] **Step 6: Refactor `msgpack/vmihailenco/codec.go`** - -```go -package msgpack - -import ( - encoding "github.com/foomo/goencode" - "github.com/vmihailenco/msgpack/v5" -) - -// Encoder encodes T to msgpack bytes (vmihailenco). -func Encoder[T any](v T) ([]byte, error) { - return msgpack.Marshal(v) -} - -// Decoder decodes msgpack bytes into T (vmihailenco). -func Decoder[T any](b []byte, v *T) error { - return msgpack.Unmarshal(b, v) -} - -// NewCodec returns a msgpack codec for T backed by vmihailenco/msgpack. -// It is safe for concurrent use. -func NewCodec[T any]() encoding.Codec[T, []byte] { - return encoding.Codec[T, []byte]{ - Encode: Encoder[T], - Decode: Decoder[T], - } -} -``` - -- [ ] **Step 7: Refactor `msgpack/tinylib/codec.go`** - -This one is special — it checks for `msgp.Marshaler`/`msgp.Unmarshaler` interfaces. The `Encoder` and `Decoder` funcs must retain this runtime check. - -```go -package msgpack - -import ( - "fmt" - - encoding "github.com/foomo/goencode" - "github.com/tinylib/msgp/msgp" -) - -// Encoder encodes T to msgpack bytes (tinylib). -// T must have msgp code generation (go:generate msgp) so that -// *T implements msgp.Marshaler. -func Encoder[T any](v T) ([]byte, error) { - if m, ok := any(v).(msgp.Marshaler); ok { - return m.MarshalMsg(nil) - } - - if m, ok := any(&v).(msgp.Marshaler); ok { - return m.MarshalMsg(nil) - } - - return nil, fmt.Errorf("msgpack: %T does not implement msgp.Marshaler", v) -} - -// Decoder decodes msgpack bytes into T (tinylib). -// T must have msgp code generation (go:generate msgp) so that -// *T implements msgp.Unmarshaler. -func Decoder[T any](b []byte, v *T) error { - if u, ok := any(v).(msgp.Unmarshaler); ok { - _, err := u.UnmarshalMsg(b) - return err - } - - return fmt.Errorf("msgpack: %T does not implement msgp.Unmarshaler", v) -} - -// NewCodec returns a msgpack codec for T backed by tinylib/msgp. -// T must have msgp code generation (go:generate msgp) so that -// *T implements msgp.Marshaler and msgp.Unmarshaler. -// It is safe for concurrent use. -func NewCodec[T any]() encoding.Codec[T, []byte] { - return encoding.Codec[T, []byte]{ - Encode: Encoder[T], - Decode: Decoder[T], - } -} -``` - -- [ ] **Step 8: Run tests for all submodule codecs** - -Run each in its own module directory: -```bash -cd /Users/franklin/Workingcopies/github.com/foomo/goencode/toml && go test -tags=safe ./... -cd /Users/franklin/Workingcopies/github.com/foomo/goencode/json/v2 && go test -tags=safe ./... -cd /Users/franklin/Workingcopies/github.com/foomo/goencode/yaml/v2 && go test -tags=safe ./... -cd /Users/franklin/Workingcopies/github.com/foomo/goencode/yaml/v3 && go test -tags=safe ./... -cd /Users/franklin/Workingcopies/github.com/foomo/goencode/yaml/v4 && go test -tags=safe ./... -cd /Users/franklin/Workingcopies/github.com/foomo/goencode/msgpack/vmihailenco && go test -tags=safe ./... -cd /Users/franklin/Workingcopies/github.com/foomo/goencode/msgpack/tinylib && go test -tags=safe ./... -``` -Expected: PASS for all - -- [ ] **Step 9: Commit** - -```bash -git add toml/codec.go json/v2/codec.go yaml/v2/codec.go yaml/v3/codec.go yaml/v4/codec.go msgpack/tinylib/codec.go msgpack/vmihailenco/codec.go -git commit -m "refactor: export standalone Encoder/Decoder in submodule codecs" -``` - ---- - -### Task 6: Run full CI check - -- [ ] **Step 1: Run lint** - -Run: `cd /Users/franklin/Workingcopies/github.com/foomo/goencode && make lint` -Expected: PASS (no new lint issues) - -- [ ] **Step 2: Run full test suite** - -Run: `cd /Users/franklin/Workingcopies/github.com/foomo/goencode && make test` -Expected: PASS - -- [ ] **Step 3: Fix any issues found by lint or tests** - -If lint reports issues (e.g. unused imports after refactor), fix them and re-run. - -- [ ] **Step 4: Final commit if fixes were needed** - -```bash -git add -u -git commit -m "fix: address lint issues from Encoder/Decoder export refactor" -``` diff --git a/docs/superpowers/specs/2026-04-21-generic-codec-interface-design.md b/docs/superpowers/specs/2026-04-21-generic-codec-interface-design.md deleted file mode 100644 index ff010d6..0000000 --- a/docs/superpowers/specs/2026-04-21-generic-codec-interface-design.md +++ /dev/null @@ -1,226 +0,0 @@ -# Generic Codec Interface Redesign - -**Date:** 2026-04-21 -**Status:** Draft - -## Summary - -Redesign goencode's core interfaces from single-param `Codec[T]` / `StreamCodec[T]` interfaces to two-param `Codec[S, T]` / `StreamCodec[S]` function-type structs. Enables type-safe codec composition via `Pipe`, unifies encoding/compression/file/conversion codecs under one model. - -## Motivation - -- Current `Codec[T]` hardcodes `[]byte` as target — file codec (`string` path) and base64 (`[]byte → []byte`) are special cases that don't fit the interface -- No way to compose codecs with type-safe piping (e.g., JSON → base64 → file) -- Type conversion codecs (e.g., `string ↔ int`) impossible under current interface -- Compression codecs unnecessarily coupled to inner codec via decorator pattern - -## Core Types - -### Primitives - -```go -package goencode - -import "io" - -// Encoder encodes source S to target T. -type Encoder[S, T any] func(s S) (T, error) - -// Decoder decodes target T back into source S. -type Decoder[S, T any] func(t T, s *S) error - -// StreamEncoder encodes S into an io.Writer. -type StreamEncoder[S any] func(w io.Writer, s S) error - -// StreamDecoder decodes S from an io.Reader. -type StreamDecoder[S any] func(r io.Reader, s *S) error -``` - -### Bundles - -```go -// Codec bundles an Encoder and Decoder for S ↔ T round-trips. -type Codec[S, T any] struct { - Encode Encoder[S, T] - Decode Decoder[S, T] -} - -// StreamCodec bundles streaming encode/decode for S. -type StreamCodec[S any] struct { - Encode StreamEncoder[S] - Decode StreamDecoder[S] -} -``` - -### Design Decisions - -- **Function types over interfaces**: most composable, least boilerplate. Closures capture config naturally. -- **Pointer baked into Decoder**: `Decoder[S, T]` signature is `func(t T, s *S) error` — pointer on S is implicit, avoids third type param. -- **StreamCodec stays single type param**: io.Writer/io.Reader are fixed, no need for `[S, T]`. -- **Structs not interfaces**: Codec/StreamCodec are struct bundles of function fields, not interface contracts. - -## Composition - -```go -// PipeEncoder chains two encoders: A → B → C. -func PipeEncoder[A, B, C any](first Encoder[A, B], second Encoder[B, C]) Encoder[A, C] { - return func(a A) (C, error) { - b, err := first(a) - if err != nil { - var zero C - return zero, err - } - return second(b) - } -} - -// PipeDecoder chains two decoders: C → B → A (reverse order). -func PipeDecoder[A, B, C any](first Decoder[A, B], second Decoder[B, C]) Decoder[A, C] { - return func(c C, a *A) error { - var b B - if err := second(c, &b); err != nil { - return err - } - return first(b, a) - } -} - -// PipeCodec chains two codecs: Codec[A,B] + Codec[B,C] → Codec[A,C]. -func PipeCodec[A, B, C any](first Codec[A, B], second Codec[B, C]) Codec[A, C] { - return Codec[A, C]{ - Encode: PipeEncoder(first.Encode, second.Encode), - Decode: PipeDecoder(first.Decode, second.Decode), - } -} -``` - -## Codec Migration - -### Serialization codecs (json, xml, gob, asn1, csv) - -```go -// Constructor signature unchanged, return type changes -func NewCodec[T any]() goencode.Codec[T, []byte] -func NewStreamCodec[T any]() goencode.StreamCodec[T] -``` - -### Encoding codecs (base64, base32, hex, ascii85, pem) - -```go -// Now fits naturally as []byte → []byte -func NewCodec() goencode.Codec[[]byte, []byte] -func NewStreamCodec() goencode.StreamCodec[[]byte] -``` - -### Compression codecs (gzip, flate, snappy, zstd) - -No longer decorators. Become standalone `Codec[[]byte, []byte]`, compose via Pipe. - -```go -// Before (decorator) -c := gzip.NewCodec(json.NewCodec[MyType]()) - -// After (Pipe composition) -c := goencode.PipeCodec(json.NewCodec[MyType](), gzip.NewCodec()) -``` - -```go -func NewCodec(opts ...Option) goencode.Codec[[]byte, []byte] -func NewStreamCodec(opts ...Option) goencode.StreamCodec[[]byte] -``` - -### File codec - -Becomes standalone `Codec[[]byte, string]` — compose via Pipe like compression codecs. - -```go -// []byte ↔ string (file path). Encode writes bytes to file, returns path. Decode reads file. -func NewCodec(opts ...Option) goencode.Codec[[]byte, string] -``` - -```go -// Usage: JSON → file via Pipe -full := goencode.PipeCodec(json.NewCodec[MyType](), file.NewCodec()) // Codec[MyType, string] -``` - -Note: Encode requires caller to provide the file path. Signature is `func(b []byte) (string, error)` — but file codec needs a path to write to. Options: pass path via `WithPath(p string)` option, or change to `Codec[[]byte, string]` where encode takes bytes and an option sets the target path. Alternative: keep decorator pattern for file codec since it needs write path context. **Decision: keep file codec as decorator** — it wraps an inner codec because it needs to control the full write-path lifecycle (temp file + rename). Unlike compression, file I/O is inherently stateful (needs path). - -```go -// File codec stays as wrapper — needs path context -func NewCodec[T any](codec goencode.Codec[T, []byte], opts ...Option) *Codec[T] - -type Codec[T any] struct { - codec goencode.Codec[T, []byte] - perm os.FileMode -} - -// Encode writes v to file at path atomically. Decode reads file at path into v. -func (c *Codec[T]) Encode(path string, v T) error -func (c *Codec[T]) Decode(path string, v *T) error -``` - -File codec does NOT implement `goencode.Codec[S, T]` — it has its own signature with path. This is acceptable: file I/O is fundamentally different from value transformations. - -### Type conversion codecs (new) - -New capability enabled by `Codec[S, T]`: - -```go -func NewStringIntCodec() goencode.Codec[string, int] -``` - -## Composition Examples - -```go -// JSON → base64 encoded -jsonCodec := json.NewCodec[MyType]() // Codec[MyType, []byte] -b64Codec := base64.NewCodec() // Codec[[]byte, []byte] -combined := goencode.PipeCodec(jsonCodec, b64Codec) // Codec[MyType, []byte] - -// JSON → gzip compressed -jsonCodec := json.NewCodec[MyType]() // Codec[MyType, []byte] -gzipCodec := gzip.NewCodec() // Codec[[]byte, []byte] -combined := goencode.PipeCodec(jsonCodec, gzipCodec) // Codec[MyType, []byte] - -// JSON → base64, then write to file -jsonB64 := goencode.PipeCodec(json.NewCodec[MyType](), base64.NewCodec()) // Codec[MyType, []byte] -fc := file.NewCodec(jsonB64) // file.Codec[MyType] -fc.Encode("/tmp/data.b64", myVal) // atomic write - -// Type conversion chaining -strToInt := conv.NewStringIntCodec() // Codec[string, int] -intToBytes := conv.NewIntBytesCodec() // Codec[int, []byte] -strToBytes := goencode.PipeCodec(strToInt, intToBytes) // Codec[string, []byte] -``` - -## Removed Types - -| Old | Replacement | -|-----|-------------| -| `Codec[T]` interface | `Codec[S, T]` struct | -| `StreamCodec[T]` interface | `StreamCodec[S]` struct | -| `Encoder[T]` interface | `Encoder[S, T]` func type | -| `Decoder[T]` interface | `Decoder[S, T]` func type | -| `EncoderFunc[T]` | Redundant — Encoder is already a func type | -| `DecoderFunc[T]` | Redundant — Decoder is already a func type | -| `StreamEncoder[T]` interface | `StreamEncoder[S]` func type | -| `StreamDecoder[T]` interface | `StreamDecoder[S]` func type | -| `StreamEncoderFunc[T]` | Redundant | -| `StreamDecoderFunc[T]` | Redundant | - -## Breaking Changes - -- All codec constructors return structs instead of interfaces -- Compression codecs no longer take inner codec — compose via `PipeCodec` -- File codec returns `Codec[T, string]` instead of custom interface -- All root package types replaced (see table above) -- Still alpha — no major version bump needed - -## Testing Strategy - -- **Round-trip tests** for every codec: encode then decode, assert equality -- **Pipe tests**: chain 2-3 codecs, verify round-trip through full pipeline -- **Error propagation**: first encoder fails → second never called -- **Decode reversal**: verify PipeDecoder applies decoders in reverse order -- **StreamCodec tests**: same pattern as today, unchanged -- **Benchmarks**: existing benchmark structure stays, update signatures diff --git a/docs/superpowers/specs/2026-04-21-standalone-encoder-decoder-exports-design.md b/docs/superpowers/specs/2026-04-21-standalone-encoder-decoder-exports-design.md deleted file mode 100644 index 7d8724e..0000000 --- a/docs/superpowers/specs/2026-04-21-standalone-encoder-decoder-exports-design.md +++ /dev/null @@ -1,88 +0,0 @@ -# Standalone Encoder/Decoder Exports - -**Date:** 2026-04-21 -**Status:** Approved - -## Problem - -Consumer APIs (e.g. a messaging library) often need only one direction — decode incoming messages or encode outgoing ones. Currently most subpackages only export `NewCodec[T]()` returning a full `Codec[S, T]`, forcing consumers to depend on both directions even when they only need one. - -The root package already defines `Encoder[S, T]` and `Decoder[S, T]` as standalone function types, and `json/v1` already exports bare `Encoder[T]`/`Decoder[T]` funcs. This pattern should be extended to all subpackages. - -## Design - -### Rule - -- **No options** → export bare funcs `Encoder` and `Decoder` (like `json/v1` today) -- **Takes options** → export `NewEncoder(opts ...Option)` and `NewDecoder(opts ...Option)` constructors - -### Simple codecs — bare funcs - -Serialization codecs (generic `[T any]`): - -| Package | Exports | -|---------|---------| -| json/v1 | `Encoder[T]`, `Decoder[T]` *(already exists)* | -| json/v2 | `Encoder[T]`, `Decoder[T]` | -| xml | `Encoder[T]`, `Decoder[T]` | -| gob | `Encoder[T]`, `Decoder[T]` | -| asn1 | `Encoder[T]`, `Decoder[T]` | -| csv | `Encoder[T]`, `Decoder[T]` | -| toml | `Encoder[T]`, `Decoder[T]` | -| yaml/v2 | `Encoder[T]`, `Decoder[T]` | -| yaml/v3 | `Encoder[T]`, `Decoder[T]` | -| yaml/v4 | `Encoder[T]`, `Decoder[T]` | -| msgpack/tinylib | `Encoder[T]`, `Decoder[T]` | -| msgpack/vmihailenco | `Encoder[T]`, `Decoder[T]` | - -Encoding codecs (no type param, `[]byte` ↔ `[]byte` or `*pem.Block` ↔ `[]byte`): - -| Package | Exports | -|---------|---------| -| base64 | `Encoder`, `Decoder` | -| base32 | `Encoder`, `Decoder` | -| hex | `Encoder`, `Decoder` | -| ascii85 | `Encoder`, `Decoder` | -| pem | `Encoder`, `Decoder` | -| snappy | `Encoder`, `Decoder` | - -### Configurable codecs — constructor funcs - -Compression codecs that accept options: - -| Package | Exports | -|---------|---------| -| gzip | `NewEncoder(opts ...Option)`, `NewDecoder(opts ...Option)` | -| flate | `NewEncoder(opts ...Option)`, `NewDecoder(opts ...Option)` | -| zstd | `NewEncoder(opts ...Option)`, `NewDecoder(opts ...Option)` | -| brotli | `NewEncoder(opts ...Option)`, `NewDecoder(opts ...Option)` | - -### Skipped - -- `file` — wraps another codec + filesystem I/O. Different abstraction, leave as-is. - -## Call-site examples - -Consumer API requiring only decode: - -```go -func NewConsumer[T any](decode goencode.Decoder[T, []byte]) { ... } - -NewConsumer(json.Decoder[MyMsg]) -NewConsumer(gob.Decoder[MyMsg]) -NewConsumer(yaml.Decoder[MyMsg]) -``` - -Compression — only encode direction: - -```go -compress := gzip.NewEncoder(gzip.WithLevel(gzip.BestCompression)) -data, err := compress(raw) -``` - -## Scope - -- Purely additive — existing `NewCodec`/`NewStreamCodec` constructors unchanged. -- Each subpackage's `NewCodec` reuses the new bare funcs / constructors internally. -- No changes to root package types (`Encoder`, `Decoder`, `Codec`, `Pipe*`). -- StreamEncoder/StreamDecoder standalone exports are out of scope for this change. From c76fc4bfca7d83b22199771c216db0ed6db5b9da Mon Sep 17 00:00:00 2001 From: franklin Date: Tue, 21 Apr 2026 13:31:52 +0200 Subject: [PATCH 21/29] refactor: export standalone StreamEncoder/StreamDecoder in json/v1, xml, gob, asn1 Co-Authored-By: Claude Opus 4.6 (1M context) --- asn1/streamcodec.go | 48 ++++++++++++++++++++++++------------------ gob/streamcodec.go | 18 ++++++++++------ json/v1/streamcodec.go | 18 ++++++++++------ xml/streamcodec.go | 18 ++++++++++------ 4 files changed, 63 insertions(+), 39 deletions(-) diff --git a/asn1/streamcodec.go b/asn1/streamcodec.go index 4d950c2..0db0095 100644 --- a/asn1/streamcodec.go +++ b/asn1/streamcodec.go @@ -7,29 +7,35 @@ import ( encoding "github.com/foomo/goencode" ) -// NewStreamCodec returns an ASN1 stream codec for T. +// StreamEncoder encodes T to an ASN.1 stream. +func StreamEncoder[T any](w io.Writer, v T) error { + data, err := stdasn1.Marshal(v) + if err != nil { + return err + } + + _, err = w.Write(data) + + return err +} + +// StreamDecoder decodes T from an ASN.1 stream. +func StreamDecoder[T any](r io.Reader, v *T) error { + data, err := io.ReadAll(r) + if err != nil { + return err + } + + _, err = stdasn1.Unmarshal(data, v) + + return err +} + +// NewStreamCodec returns an ASN.1 stream codec for T. // It is safe for concurrent use. func NewStreamCodec[T any]() encoding.StreamCodec[T] { return encoding.StreamCodec[T]{ - Encode: func(w io.Writer, v T) error { - data, err := stdasn1.Marshal(v) - if err != nil { - return err - } - - _, err = w.Write(data) - - return err - }, - Decode: func(r io.Reader, v *T) error { - data, err := io.ReadAll(r) - if err != nil { - return err - } - - _, err = stdasn1.Unmarshal(data, v) - - return err - }, + Encode: StreamEncoder[T], + Decode: StreamDecoder[T], } } diff --git a/gob/streamcodec.go b/gob/streamcodec.go index f2ba630..2ba34d9 100644 --- a/gob/streamcodec.go +++ b/gob/streamcodec.go @@ -7,15 +7,21 @@ import ( encoding "github.com/foomo/goencode" ) +// StreamEncoder encodes T to a gob stream. +func StreamEncoder[T any](w io.Writer, v T) error { + return gob.NewEncoder(w).Encode(v) +} + +// StreamDecoder decodes T from a gob stream. +func StreamDecoder[T any](r io.Reader, v *T) error { + return gob.NewDecoder(r).Decode(v) +} + // NewStreamCodec returns a GOB stream codec for T. // It is safe for concurrent use. func NewStreamCodec[T any]() encoding.StreamCodec[T] { return encoding.StreamCodec[T]{ - Encode: func(w io.Writer, v T) error { - return gob.NewEncoder(w).Encode(v) - }, - Decode: func(r io.Reader, v *T) error { - return gob.NewDecoder(r).Decode(v) - }, + Encode: StreamEncoder[T], + Decode: StreamDecoder[T], } } diff --git a/json/v1/streamcodec.go b/json/v1/streamcodec.go index e7d09b2..426b772 100644 --- a/json/v1/streamcodec.go +++ b/json/v1/streamcodec.go @@ -7,15 +7,21 @@ import ( encoding "github.com/foomo/goencode" ) +// StreamEncoder encodes T to a JSON stream. +func StreamEncoder[T any](w io.Writer, v T) error { + return json.NewEncoder(w).Encode(v) +} + +// StreamDecoder decodes T from a JSON stream. +func StreamDecoder[T any](r io.Reader, v *T) error { + return json.NewDecoder(r).Decode(v) +} + // NewStreamCodec returns a JSON stream codec for T. // It is safe for concurrent use. func NewStreamCodec[T any]() encoding.StreamCodec[T] { return encoding.StreamCodec[T]{ - Encode: func(w io.Writer, v T) error { - return json.NewEncoder(w).Encode(v) - }, - Decode: func(r io.Reader, v *T) error { - return json.NewDecoder(r).Decode(v) - }, + Encode: StreamEncoder[T], + Decode: StreamDecoder[T], } } diff --git a/xml/streamcodec.go b/xml/streamcodec.go index f079d23..9c7c110 100644 --- a/xml/streamcodec.go +++ b/xml/streamcodec.go @@ -7,15 +7,21 @@ import ( encoding "github.com/foomo/goencode" ) +// StreamEncoder encodes T to an XML stream. +func StreamEncoder[T any](w io.Writer, v T) error { + return xml.NewEncoder(w).Encode(v) +} + +// StreamDecoder decodes T from an XML stream. +func StreamDecoder[T any](r io.Reader, v *T) error { + return xml.NewDecoder(r).Decode(v) +} + // NewStreamCodec returns an XML stream codec for T. // It is safe for concurrent use. func NewStreamCodec[T any]() encoding.StreamCodec[T] { return encoding.StreamCodec[T]{ - Encode: func(w io.Writer, v T) error { - return xml.NewEncoder(w).Encode(v) - }, - Decode: func(r io.Reader, v *T) error { - return xml.NewDecoder(r).Decode(v) - }, + Encode: StreamEncoder[T], + Decode: StreamDecoder[T], } } From 3af06430ab2f34f849842550e48fe1eed508f64a Mon Sep 17 00:00:00 2001 From: franklin Date: Tue, 21 Apr 2026 15:06:16 +0200 Subject: [PATCH 22/29] refactor: export standalone StreamEncoder/StreamDecoder in csv Co-Authored-By: Claude Opus 4.6 (1M context) --- csv/streamcodec.go | 46 ++++++++++++++++++++++++++-------------------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/csv/streamcodec.go b/csv/streamcodec.go index ef0150a..900551b 100644 --- a/csv/streamcodec.go +++ b/csv/streamcodec.go @@ -7,29 +7,35 @@ import ( encoding "github.com/foomo/goencode" ) +// StreamEncoder encodes [][]string to a CSV stream. +func StreamEncoder(w io.Writer, v [][]string) error { + cw := stdcsv.NewWriter(w) + if err := cw.WriteAll(v); err != nil { + return err + } + + cw.Flush() + + return cw.Error() +} + +// StreamDecoder decodes [][]string from a CSV stream. +func StreamDecoder(r io.Reader, v *[][]string) error { + records, err := stdcsv.NewReader(r).ReadAll() + if err != nil { + return err + } + + *v = records + + return nil +} + // NewStreamCodec returns a CSV stream codec for [][]string. // It is safe for concurrent use. func NewStreamCodec() encoding.StreamCodec[[][]string] { return encoding.StreamCodec[[][]string]{ - Encode: func(w io.Writer, v [][]string) error { - cw := stdcsv.NewWriter(w) - if err := cw.WriteAll(v); err != nil { - return err - } - - cw.Flush() - - return cw.Error() - }, - Decode: func(r io.Reader, v *[][]string) error { - records, err := stdcsv.NewReader(r).ReadAll() - if err != nil { - return err - } - - *v = records - - return nil - }, + Encode: StreamEncoder, + Decode: StreamDecoder, } } From 52b97bee549f26e55bf4f6252c516a628c403893 Mon Sep 17 00:00:00 2001 From: franklin Date: Tue, 21 Apr 2026 15:07:44 +0200 Subject: [PATCH 23/29] refactor: export standalone StreamEncoder/StreamDecoder in base64, base32, hex, ascii85, pem Co-Authored-By: Claude Opus 4.6 (1M context) --- ascii85/streamcodec.go | 40 ++++++++++++++++++++++---------------- base32/streamcodec.go | 42 +++++++++++++++++++++++----------------- base64/streamcodec.go | 44 ++++++++++++++++++++++++------------------ hex/streamcodec.go | 34 ++++++++++++++++++-------------- pem/streamcodec.go | 40 +++++++++++++++++++++++--------------- 5 files changed, 116 insertions(+), 84 deletions(-) diff --git a/ascii85/streamcodec.go b/ascii85/streamcodec.go index 350da58..9f57407 100644 --- a/ascii85/streamcodec.go +++ b/ascii85/streamcodec.go @@ -7,26 +7,32 @@ import ( encoding "github.com/foomo/goencode" ) +// StreamEncoder encodes bytes to an ASCII85 stream. +func StreamEncoder(w io.Writer, v []byte) error { + dst := make([]byte, stdascii85.MaxEncodedLen(len(v))) + n := stdascii85.Encode(dst, v) + _, err := w.Write(dst[:n]) + + return err +} + +// StreamDecoder decodes bytes from an ASCII85 stream. +func StreamDecoder(r io.Reader, v *[]byte) error { + data, err := io.ReadAll(stdascii85.NewDecoder(r)) + if err != nil { + return err + } + + *v = data + + return nil +} + // NewStreamCodec returns an ASCII85 stream codec. // It is safe for concurrent use. func NewStreamCodec() encoding.StreamCodec[[]byte] { return encoding.StreamCodec[[]byte]{ - Encode: func(w io.Writer, v []byte) error { - dst := make([]byte, stdascii85.MaxEncodedLen(len(v))) - n := stdascii85.Encode(dst, v) - _, err := w.Write(dst[:n]) - - return err - }, - Decode: func(r io.Reader, v *[]byte) error { - data, err := io.ReadAll(stdascii85.NewDecoder(r)) - if err != nil { - return err - } - - *v = data - - return nil - }, + Encode: StreamEncoder, + Decode: StreamDecoder, } } diff --git a/base32/streamcodec.go b/base32/streamcodec.go index 208f1ed..59f4cf3 100644 --- a/base32/streamcodec.go +++ b/base32/streamcodec.go @@ -7,27 +7,33 @@ import ( encoding "github.com/foomo/goencode" ) +// StreamEncoder encodes bytes to a Base32 stream. +func StreamEncoder(w io.Writer, v []byte) error { + enc := stdbase32.NewEncoder(stdbase32.StdEncoding, w) + if _, err := enc.Write(v); err != nil { + return err + } + + return enc.Close() +} + +// StreamDecoder decodes bytes from a Base32 stream. +func StreamDecoder(r io.Reader, v *[]byte) error { + data, err := io.ReadAll(stdbase32.NewDecoder(stdbase32.StdEncoding, r)) + if err != nil { + return err + } + + *v = data + + return nil +} + // NewStreamCodec returns a Base32 stream codec. // It is safe for concurrent use. func NewStreamCodec() encoding.StreamCodec[[]byte] { return encoding.StreamCodec[[]byte]{ - Encode: func(w io.Writer, v []byte) error { - enc := stdbase32.NewEncoder(stdbase32.StdEncoding, w) - if _, err := enc.Write(v); err != nil { - return err - } - - return enc.Close() - }, - Decode: func(r io.Reader, v *[]byte) error { - data, err := io.ReadAll(stdbase32.NewDecoder(stdbase32.StdEncoding, r)) - if err != nil { - return err - } - - *v = data - - return nil - }, + Encode: StreamEncoder, + Decode: StreamDecoder, } } diff --git a/base64/streamcodec.go b/base64/streamcodec.go index a1f9fb4..c804e8c 100644 --- a/base64/streamcodec.go +++ b/base64/streamcodec.go @@ -7,28 +7,34 @@ import ( encoding "github.com/foomo/goencode" ) +// StreamEncoder encodes bytes to a Base64 stream. +func StreamEncoder(w io.Writer, v []byte) error { + enc := stdbase64.NewEncoder(stdbase64.StdEncoding, w) + if _, err := enc.Write(v); err != nil { + _ = enc.Close() + return err + } + + return enc.Close() +} + +// StreamDecoder decodes bytes from a Base64 stream. +func StreamDecoder(r io.Reader, v *[]byte) error { + data, err := io.ReadAll(stdbase64.NewDecoder(stdbase64.StdEncoding, r)) + if err != nil { + return err + } + + *v = data + + return nil +} + // NewStreamCodec returns a Base64 stream codec. // It is safe for concurrent use. func NewStreamCodec() encoding.StreamCodec[[]byte] { return encoding.StreamCodec[[]byte]{ - Encode: func(w io.Writer, v []byte) error { - enc := stdbase64.NewEncoder(stdbase64.StdEncoding, w) - if _, err := enc.Write(v); err != nil { - _ = enc.Close() - return err - } - - return enc.Close() - }, - Decode: func(r io.Reader, v *[]byte) error { - data, err := io.ReadAll(stdbase64.NewDecoder(stdbase64.StdEncoding, r)) - if err != nil { - return err - } - - *v = data - - return nil - }, + Encode: StreamEncoder, + Decode: StreamDecoder, } } diff --git a/hex/streamcodec.go b/hex/streamcodec.go index b55d8d5..a6711d0 100644 --- a/hex/streamcodec.go +++ b/hex/streamcodec.go @@ -7,23 +7,29 @@ import ( encoding "github.com/foomo/goencode" ) +// StreamEncoder encodes bytes to a hexadecimal stream. +func StreamEncoder(w io.Writer, v []byte) error { + _, err := stdhex.NewEncoder(w).Write(v) + return err +} + +// StreamDecoder decodes bytes from a hexadecimal stream. +func StreamDecoder(r io.Reader, v *[]byte) error { + data, err := io.ReadAll(stdhex.NewDecoder(r)) + if err != nil { + return err + } + + *v = data + + return nil +} + // NewStreamCodec returns a Hex stream codec. // It is safe for concurrent use. func NewStreamCodec() encoding.StreamCodec[[]byte] { return encoding.StreamCodec[[]byte]{ - Encode: func(w io.Writer, v []byte) error { - _, err := stdhex.NewEncoder(w).Write(v) - return err - }, - Decode: func(r io.Reader, v *[]byte) error { - data, err := io.ReadAll(stdhex.NewDecoder(r)) - if err != nil { - return err - } - - *v = data - - return nil - }, + Encode: StreamEncoder, + Decode: StreamDecoder, } } diff --git a/pem/streamcodec.go b/pem/streamcodec.go index d1e3cb1..1979660 100644 --- a/pem/streamcodec.go +++ b/pem/streamcodec.go @@ -8,25 +8,33 @@ import ( encoding "github.com/foomo/goencode" ) +// StreamEncoder encodes a PEM block to a stream. +func StreamEncoder(w io.Writer, v *stdpem.Block) error { + return stdpem.Encode(w, v) +} + +// StreamDecoder decodes a PEM block from a stream. +func StreamDecoder(r io.Reader, v **stdpem.Block) error { + data, err := io.ReadAll(r) + if err != nil { + return err + } + + block, _ := stdpem.Decode(data) + if block == nil { + return errors.New("encoding: no PEM block found") + } + + *v = block + + return nil +} + // NewStreamCodec returns a PEM stream codec. // It is safe for concurrent use. func NewStreamCodec() encoding.StreamCodec[*stdpem.Block] { return encoding.StreamCodec[*stdpem.Block]{ - Encode: stdpem.Encode, - Decode: func(r io.Reader, v **stdpem.Block) error { - data, err := io.ReadAll(r) - if err != nil { - return err - } - - block, _ := stdpem.Decode(data) - if block == nil { - return errors.New("encoding: no PEM block found") - } - - *v = block - - return nil - }, + Encode: StreamEncoder, + Decode: StreamDecoder, } } From ab25341ccb4218e078a03218b73d6768708ad382 Mon Sep 17 00:00:00 2001 From: franklin Date: Tue, 21 Apr 2026 15:08:33 +0200 Subject: [PATCH 24/29] refactor: export standalone StreamEncoder/StreamDecoder in snappy Co-Authored-By: Claude Opus 4.6 (1M context) --- snappy/streamcodec.go | 42 ++++++++++++++++++++++++------------------ 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/snappy/streamcodec.go b/snappy/streamcodec.go index 1502b92..9ce7249 100644 --- a/snappy/streamcodec.go +++ b/snappy/streamcodec.go @@ -7,27 +7,33 @@ import ( "github.com/golang/snappy" ) +// StreamEncoder compresses bytes to a Snappy stream. +func StreamEncoder(w io.Writer, data []byte) error { + sw := snappy.NewBufferedWriter(w) + if _, err := sw.Write(data); err != nil { + return err + } + + return sw.Close() +} + +// StreamDecoder decompresses bytes from a Snappy stream. +func StreamDecoder(r io.Reader, v *[]byte) error { + data, err := io.ReadAll(snappy.NewReader(r)) + if err != nil { + return err + } + + *v = data + + return nil +} + // NewStreamCodec returns a Snappy compression stream codec. // It is safe for concurrent use. func NewStreamCodec() encoding.StreamCodec[[]byte] { return encoding.StreamCodec[[]byte]{ - Encode: func(w io.Writer, data []byte) error { - sw := snappy.NewBufferedWriter(w) - if _, err := sw.Write(data); err != nil { - return err - } - - return sw.Close() - }, - Decode: func(r io.Reader, v *[]byte) error { - data, err := io.ReadAll(snappy.NewReader(r)) - if err != nil { - return err - } - - *v = data - - return nil - }, + Encode: StreamEncoder, + Decode: StreamDecoder, } } From f5e5e1bf65d69b301690e61eb22b6b9917b99ed4 Mon Sep 17 00:00:00 2001 From: franklin Date: Tue, 21 Apr 2026 15:10:03 +0200 Subject: [PATCH 25/29] refactor: export NewStreamEncoder/NewStreamDecoder in gzip, flate, zstd, brotli Co-Authored-By: Claude Opus 4.6 (1M context) --- brotli/streamcodec.go | 71 ++++++++++++++++++------------- flate/streamcodec.go | 91 +++++++++++++++++++++++----------------- gzip/streamcodec.go | 97 +++++++++++++++++++++++++------------------ zstd/streamcodec.go | 89 ++++++++++++++++++++++----------------- 4 files changed, 204 insertions(+), 144 deletions(-) diff --git a/brotli/streamcodec.go b/brotli/streamcodec.go index ed0ce65..795a7f7 100644 --- a/brotli/streamcodec.go +++ b/brotli/streamcodec.go @@ -8,9 +8,8 @@ import ( encoding "github.com/foomo/goencode" ) -// NewStreamCodec returns a Brotli compression stream codec. -// It is safe for concurrent use. -func NewStreamCodec(opts ...Option) encoding.StreamCodec[[]byte] { +// NewStreamEncoder returns a Brotli compression stream encoder. +func NewStreamEncoder(opts ...Option) encoding.StreamEncoder[[]byte] { o := options{ level: brotli.DefaultCompression, } @@ -18,37 +17,53 @@ func NewStreamCodec(opts ...Option) encoding.StreamCodec[[]byte] { opt(&o) } - return encoding.StreamCodec[[]byte]{ - Encode: func(w io.Writer, data []byte) error { - bw := brotli.NewWriterLevel(w, o.level) + return func(w io.Writer, data []byte) error { + bw := brotli.NewWriterLevel(w, o.level) + + if _, err := bw.Write(data); err != nil { + bw.Close() + return err + } + + return bw.Close() + } +} + +// NewStreamDecoder returns a Brotli decompression stream decoder. +func NewStreamDecoder(opts ...Option) encoding.StreamDecoder[[]byte] { + o := options{} + for _, opt := range opts { + opt(&o) + } - if _, err := bw.Write(data); err != nil { - bw.Close() - return err - } + return func(r io.Reader, v *[]byte) error { + br := brotli.NewReader(r) - return bw.Close() - }, - Decode: func(r io.Reader, v *[]byte) error { - br := brotli.NewReader(r) + var src io.Reader = br + if o.maxDecodedSize > 0 { + src = io.LimitReader(br, o.maxDecodedSize+1) + } - var src io.Reader = br - if o.maxDecodedSize > 0 { - src = io.LimitReader(br, o.maxDecodedSize+1) - } + data, err := io.ReadAll(src) + if err != nil { + return err + } - data, err := io.ReadAll(src) - if err != nil { - return err - } + if o.maxDecodedSize > 0 && int64(len(data)) > o.maxDecodedSize { + return fmt.Errorf("brotli: decompressed size exceeds limit of %d bytes", o.maxDecodedSize) + } - if o.maxDecodedSize > 0 && int64(len(data)) > o.maxDecodedSize { - return fmt.Errorf("brotli: decompressed size exceeds limit of %d bytes", o.maxDecodedSize) - } + *v = data - *v = data + return nil + } +} - return nil - }, +// NewStreamCodec returns a Brotli compression stream codec. +// It is safe for concurrent use. +func NewStreamCodec(opts ...Option) encoding.StreamCodec[[]byte] { + return encoding.StreamCodec[[]byte]{ + Encode: NewStreamEncoder(opts...), + Decode: NewStreamDecoder(opts...), } } diff --git a/flate/streamcodec.go b/flate/streamcodec.go index 78889f2..a23d6dc 100644 --- a/flate/streamcodec.go +++ b/flate/streamcodec.go @@ -8,9 +8,8 @@ import ( encoding "github.com/foomo/goencode" ) -// NewStreamCodec returns a DEFLATE compression stream codec. -// It is safe for concurrent use. -func NewStreamCodec(opts ...Option) encoding.StreamCodec[[]byte] { +// NewStreamEncoder returns a DEFLATE compression stream encoder. +func NewStreamEncoder(opts ...Option) encoding.StreamEncoder[[]byte] { o := options{ level: flate.DefaultCompression, } @@ -18,41 +17,57 @@ func NewStreamCodec(opts ...Option) encoding.StreamCodec[[]byte] { opt(&o) } + return func(w io.Writer, data []byte) error { + fw, err := flate.NewWriter(w, o.level) + if err != nil { + return err + } + + if _, err := fw.Write(data); err != nil { + fw.Close() + return err + } + + return fw.Close() + } +} + +// NewStreamDecoder returns a DEFLATE decompression stream decoder. +func NewStreamDecoder(opts ...Option) encoding.StreamDecoder[[]byte] { + o := options{} + for _, opt := range opts { + opt(&o) + } + + return func(r io.Reader, v *[]byte) error { + fr := flate.NewReader(r) + defer fr.Close() + + var src io.Reader = fr + if o.maxDecodedSize > 0 { + src = io.LimitReader(fr, o.maxDecodedSize+1) + } + + data, err := io.ReadAll(src) + if err != nil { + return err + } + + if o.maxDecodedSize > 0 && int64(len(data)) > o.maxDecodedSize { + return fmt.Errorf("flate: decompressed size exceeds limit of %d bytes", o.maxDecodedSize) + } + + *v = data + + return nil + } +} + +// NewStreamCodec returns a DEFLATE compression stream codec. +// It is safe for concurrent use. +func NewStreamCodec(opts ...Option) encoding.StreamCodec[[]byte] { return encoding.StreamCodec[[]byte]{ - Encode: func(w io.Writer, data []byte) error { - fw, err := flate.NewWriter(w, o.level) - if err != nil { - return err - } - - if _, err := fw.Write(data); err != nil { - fw.Close() - return err - } - - return fw.Close() - }, - Decode: func(r io.Reader, v *[]byte) error { - fr := flate.NewReader(r) - defer fr.Close() - - var src io.Reader = fr - if o.maxDecodedSize > 0 { - src = io.LimitReader(fr, o.maxDecodedSize+1) - } - - data, err := io.ReadAll(src) - if err != nil { - return err - } - - if o.maxDecodedSize > 0 && int64(len(data)) > o.maxDecodedSize { - return fmt.Errorf("flate: decompressed size exceeds limit of %d bytes", o.maxDecodedSize) - } - - *v = data - - return nil - }, + Encode: NewStreamEncoder(opts...), + Decode: NewStreamDecoder(opts...), } } diff --git a/gzip/streamcodec.go b/gzip/streamcodec.go index e9b29f0..db357b3 100644 --- a/gzip/streamcodec.go +++ b/gzip/streamcodec.go @@ -8,9 +8,8 @@ import ( encoding "github.com/foomo/goencode" ) -// NewStreamCodec returns a gzip compression stream codec. -// It is safe for concurrent use. -func NewStreamCodec(opts ...Option) encoding.StreamCodec[[]byte] { +// NewStreamEncoder returns a gzip compression stream encoder. +func NewStreamEncoder(opts ...Option) encoding.StreamEncoder[[]byte] { o := options{ level: gzip.DefaultCompression, } @@ -18,44 +17,60 @@ func NewStreamCodec(opts ...Option) encoding.StreamCodec[[]byte] { opt(&o) } + return func(w io.Writer, data []byte) error { + gw, err := gzip.NewWriterLevel(w, o.level) + if err != nil { + return err + } + + if _, err := gw.Write(data); err != nil { + gw.Close() + return err + } + + return gw.Close() + } +} + +// NewStreamDecoder returns a gzip decompression stream decoder. +func NewStreamDecoder(opts ...Option) encoding.StreamDecoder[[]byte] { + o := options{} + for _, opt := range opts { + opt(&o) + } + + return func(r io.Reader, v *[]byte) error { + gr, err := gzip.NewReader(r) + if err != nil { + return err + } + defer gr.Close() + + var src io.Reader = gr + if o.maxDecodedSize > 0 { + src = io.LimitReader(gr, o.maxDecodedSize+1) + } + + data, err := io.ReadAll(src) + if err != nil { + return err + } + + if o.maxDecodedSize > 0 && int64(len(data)) > o.maxDecodedSize { + return fmt.Errorf("gzip: decompressed size exceeds limit of %d bytes", o.maxDecodedSize) + } + + *v = data + + return nil + } +} + +// NewStreamCodec returns a gzip compression stream codec. +// It is safe for concurrent use. +func NewStreamCodec(opts ...Option) encoding.StreamCodec[[]byte] { return encoding.StreamCodec[[]byte]{ - Encode: func(w io.Writer, data []byte) error { - gw, err := gzip.NewWriterLevel(w, o.level) - if err != nil { - return err - } - - if _, err := gw.Write(data); err != nil { - gw.Close() - return err - } - - return gw.Close() - }, - Decode: func(r io.Reader, v *[]byte) error { - gr, err := gzip.NewReader(r) - if err != nil { - return err - } - defer gr.Close() - - var src io.Reader = gr - if o.maxDecodedSize > 0 { - src = io.LimitReader(gr, o.maxDecodedSize+1) - } - - data, err := io.ReadAll(src) - if err != nil { - return err - } - - if o.maxDecodedSize > 0 && int64(len(data)) > o.maxDecodedSize { - return fmt.Errorf("gzip: decompressed size exceeds limit of %d bytes", o.maxDecodedSize) - } - - *v = data - - return nil - }, + Encode: NewStreamEncoder(opts...), + Decode: NewStreamDecoder(opts...), } } diff --git a/zstd/streamcodec.go b/zstd/streamcodec.go index 44164ea..8840d05 100644 --- a/zstd/streamcodec.go +++ b/zstd/streamcodec.go @@ -7,9 +7,8 @@ import ( "github.com/klauspost/compress/zstd" ) -// NewStreamCodec returns a Zstandard compression stream codec. -// It is safe for concurrent use. -func NewStreamCodec(opts ...Option) encoding.StreamCodec[[]byte] { +// NewStreamEncoder returns a Zstandard compression stream encoder. +func NewStreamEncoder(opts ...Option) encoding.StreamEncoder[[]byte] { o := options{ level: zstd.SpeedDefault, } @@ -17,40 +16,56 @@ func NewStreamCodec(opts ...Option) encoding.StreamCodec[[]byte] { opt(&o) } + return func(w io.Writer, data []byte) error { + zw, err := zstd.NewWriter(w, zstd.WithEncoderLevel(o.level)) + if err != nil { + return err + } + + if _, err := zw.Write(data); err != nil { + zw.Close() + return err + } + + return zw.Close() + } +} + +// NewStreamDecoder returns a Zstandard decompression stream decoder. +func NewStreamDecoder(opts ...Option) encoding.StreamDecoder[[]byte] { + o := options{} + for _, opt := range opts { + opt(&o) + } + + return func(r io.Reader, v *[]byte) error { + dopts := []zstd.DOption{} + if o.maxDecodedSize > 0 { + dopts = append(dopts, zstd.WithDecoderMaxMemory(uint64(o.maxDecodedSize))) + } + + zr, err := zstd.NewReader(r, dopts...) + if err != nil { + return err + } + defer zr.Close() + + data, err := io.ReadAll(zr) + if err != nil { + return err + } + + *v = data + + return nil + } +} + +// NewStreamCodec returns a Zstandard compression stream codec. +// It is safe for concurrent use. +func NewStreamCodec(opts ...Option) encoding.StreamCodec[[]byte] { return encoding.StreamCodec[[]byte]{ - Encode: func(w io.Writer, data []byte) error { - zw, err := zstd.NewWriter(w, zstd.WithEncoderLevel(o.level)) - if err != nil { - return err - } - - if _, err := zw.Write(data); err != nil { - zw.Close() - return err - } - - return zw.Close() - }, - Decode: func(r io.Reader, v *[]byte) error { - dopts := []zstd.DOption{} - if o.maxDecodedSize > 0 { - dopts = append(dopts, zstd.WithDecoderMaxMemory(uint64(o.maxDecodedSize))) - } - - zr, err := zstd.NewReader(r, dopts...) - if err != nil { - return err - } - defer zr.Close() - - data, err := io.ReadAll(zr) - if err != nil { - return err - } - - *v = data - - return nil - }, + Encode: NewStreamEncoder(opts...), + Decode: NewStreamDecoder(opts...), } } From 38956ee4503be70b71c60fbdfe9f6ecd3f1727f6 Mon Sep 17 00:00:00 2001 From: franklin Date: Tue, 21 Apr 2026 15:12:32 +0200 Subject: [PATCH 26/29] refactor: export standalone StreamEncoder/StreamDecoder in toml, json/v2, msgpack/* Move json/v2 NewStreamCodec from codec.go to streamcodec.go. Co-Authored-By: Claude Opus 4.6 (1M context) --- json/v2/codec.go | 15 ---------- json/v2/streamcodec.go | 27 ++++++++++++++++++ msgpack/tinylib/streamcodec.go | 46 ++++++++++++++++++------------ msgpack/vmihailenco/streamcodec.go | 18 ++++++++---- toml/streamcodec.go | 22 ++++++++------ 5 files changed, 81 insertions(+), 47 deletions(-) create mode 100644 json/v2/streamcodec.go diff --git a/json/v2/codec.go b/json/v2/codec.go index 94c82ad..3f9fcd5 100644 --- a/json/v2/codec.go +++ b/json/v2/codec.go @@ -1,8 +1,6 @@ package json import ( - "io" - encoding "github.com/foomo/goencode" "github.com/go-json-experiment/json" ) @@ -25,16 +23,3 @@ func NewCodec[T any]() encoding.Codec[T, []byte] { Decode: Decoder[T], } } - -// NewStreamCodec returns a JSON stream codec for T backed by go-json-experiment/json. -// It is safe for concurrent use. -func NewStreamCodec[T any]() encoding.StreamCodec[T] { - return encoding.StreamCodec[T]{ - Encode: func(w io.Writer, v T) error { - return json.MarshalWrite(w, v) - }, - Decode: func(r io.Reader, v *T) error { - return json.UnmarshalRead(r, v) - }, - } -} diff --git a/json/v2/streamcodec.go b/json/v2/streamcodec.go new file mode 100644 index 0000000..e199202 --- /dev/null +++ b/json/v2/streamcodec.go @@ -0,0 +1,27 @@ +package json + +import ( + "io" + + encoding "github.com/foomo/goencode" + "github.com/go-json-experiment/json" +) + +// StreamEncoder encodes T to a JSON stream (v2). +func StreamEncoder[T any](w io.Writer, v T) error { + return json.MarshalWrite(w, v) +} + +// StreamDecoder decodes T from a JSON stream (v2). +func StreamDecoder[T any](r io.Reader, v *T) error { + return json.UnmarshalRead(r, v) +} + +// NewStreamCodec returns a JSON stream codec for T backed by go-json-experiment/json. +// It is safe for concurrent use. +func NewStreamCodec[T any]() encoding.StreamCodec[T] { + return encoding.StreamCodec[T]{ + Encode: StreamEncoder[T], + Decode: StreamDecoder[T], + } +} diff --git a/msgpack/tinylib/streamcodec.go b/msgpack/tinylib/streamcodec.go index cc9c64d..58ab114 100644 --- a/msgpack/tinylib/streamcodec.go +++ b/msgpack/tinylib/streamcodec.go @@ -8,29 +8,39 @@ import ( "github.com/tinylib/msgp/msgp" ) +// StreamEncoder encodes T to a msgpack stream (tinylib). +// T must have msgp code generation (go:generate msgp) so that +// *T implements msgp.Encodable. +func StreamEncoder[T any](w io.Writer, v T) error { + if e, ok := any(v).(msgp.Encodable); ok { + return msgp.Encode(w, e) + } + + if e, ok := any(&v).(msgp.Encodable); ok { + return msgp.Encode(w, e) + } + + return fmt.Errorf("msgpack: %T does not implement msgp.Encodable", v) +} + +// StreamDecoder decodes T from a msgpack stream (tinylib). +// T must have msgp code generation (go:generate msgp) so that +// *T implements msgp.Decodable. +func StreamDecoder[T any](r io.Reader, v *T) error { + if d, ok := any(v).(msgp.Decodable); ok { + return msgp.Decode(r, d) + } + + return fmt.Errorf("msgpack: %T does not implement msgp.Decodable", v) +} + // NewStreamCodec returns a msgpack stream codec for T backed by tinylib/msgp. // T must have msgp code generation (go:generate msgp) so that // *T implements msgp.Encodable and msgp.Decodable. // It is safe for concurrent use. func NewStreamCodec[T any]() encoding.StreamCodec[T] { return encoding.StreamCodec[T]{ - Encode: func(w io.Writer, v T) error { - if e, ok := any(v).(msgp.Encodable); ok { - return msgp.Encode(w, e) - } - - if e, ok := any(&v).(msgp.Encodable); ok { - return msgp.Encode(w, e) - } - - return fmt.Errorf("msgpack: %T does not implement msgp.Encodable", v) - }, - Decode: func(r io.Reader, v *T) error { - if d, ok := any(v).(msgp.Decodable); ok { - return msgp.Decode(r, d) - } - - return fmt.Errorf("msgpack: %T does not implement msgp.Decodable", v) - }, + Encode: StreamEncoder[T], + Decode: StreamDecoder[T], } } diff --git a/msgpack/vmihailenco/streamcodec.go b/msgpack/vmihailenco/streamcodec.go index 790b639..ea3a300 100644 --- a/msgpack/vmihailenco/streamcodec.go +++ b/msgpack/vmihailenco/streamcodec.go @@ -7,15 +7,21 @@ import ( "github.com/vmihailenco/msgpack/v5" ) +// StreamEncoder encodes T to a msgpack stream (vmihailenco). +func StreamEncoder[T any](w io.Writer, v T) error { + return msgpack.NewEncoder(w).Encode(v) +} + +// StreamDecoder decodes T from a msgpack stream (vmihailenco). +func StreamDecoder[T any](r io.Reader, v *T) error { + return msgpack.NewDecoder(r).Decode(v) +} + // NewStreamCodec returns a msgpack stream codec for T backed by vmihailenco/msgpack/v5. // It is safe for concurrent use. func NewStreamCodec[T any]() encoding.StreamCodec[T] { return encoding.StreamCodec[T]{ - Encode: func(w io.Writer, v T) error { - return msgpack.NewEncoder(w).Encode(v) - }, - Decode: func(r io.Reader, v *T) error { - return msgpack.NewDecoder(r).Decode(v) - }, + Encode: StreamEncoder[T], + Decode: StreamDecoder[T], } } diff --git a/toml/streamcodec.go b/toml/streamcodec.go index 29b1fc5..1bc37f0 100644 --- a/toml/streamcodec.go +++ b/toml/streamcodec.go @@ -8,17 +8,23 @@ import ( "github.com/BurntSushi/toml" ) +// StreamEncoder encodes T to a TOML stream. +func StreamEncoder[T any](w io.Writer, v T) error { + return toml.NewEncoder(w).Encode(v) +} + +// StreamDecoder decodes T from a TOML stream. +func StreamDecoder[T any](r io.Reader, v *T) error { + _, err := toml.NewDecoder(r).Decode(v) + + return err +} + // NewStreamCodec returns a TOML stream codec for T. // It is safe for concurrent use. func NewStreamCodec[T any]() encoding.StreamCodec[T] { return encoding.StreamCodec[T]{ - Encode: func(w io.Writer, v T) error { - return toml.NewEncoder(w).Encode(v) - }, - Decode: func(r io.Reader, v *T) error { - _, err := toml.NewDecoder(r).Decode(v) - - return err - }, + Encode: StreamEncoder[T], + Decode: StreamDecoder[T], } } From faf456f272b6accb33a7420cb8a327ef638b1ffc Mon Sep 17 00:00:00 2001 From: franklin Date: Tue, 21 Apr 2026 15:13:46 +0200 Subject: [PATCH 27/29] feat: add StreamCodec with standalone StreamEncoder/StreamDecoder for yaml/v2, v3, v4 Co-Authored-By: Claude Opus 4.6 (1M context) --- yaml/v2/streamcodec.go | 27 +++++++++++++++++++++++++++ yaml/v3/streamcodec.go | 27 +++++++++++++++++++++++++++ yaml/v4/streamcodec.go | 27 +++++++++++++++++++++++++++ 3 files changed, 81 insertions(+) create mode 100644 yaml/v2/streamcodec.go create mode 100644 yaml/v3/streamcodec.go create mode 100644 yaml/v4/streamcodec.go diff --git a/yaml/v2/streamcodec.go b/yaml/v2/streamcodec.go new file mode 100644 index 0000000..c42f823 --- /dev/null +++ b/yaml/v2/streamcodec.go @@ -0,0 +1,27 @@ +package yaml + +import ( + "io" + + encoding "github.com/foomo/goencode" + "go.yaml.in/yaml/v2" +) + +// StreamEncoder encodes T to a YAML v2 stream. +func StreamEncoder[T any](w io.Writer, v T) error { + return yaml.NewEncoder(w).Encode(v) +} + +// StreamDecoder decodes T from a YAML v2 stream. +func StreamDecoder[T any](r io.Reader, v *T) error { + return yaml.NewDecoder(r).Decode(v) +} + +// NewStreamCodec returns a YAML v2 stream codec for T. +// It is safe for concurrent use. +func NewStreamCodec[T any]() encoding.StreamCodec[T] { + return encoding.StreamCodec[T]{ + Encode: StreamEncoder[T], + Decode: StreamDecoder[T], + } +} diff --git a/yaml/v3/streamcodec.go b/yaml/v3/streamcodec.go new file mode 100644 index 0000000..35b5618 --- /dev/null +++ b/yaml/v3/streamcodec.go @@ -0,0 +1,27 @@ +package yaml + +import ( + "io" + + encoding "github.com/foomo/goencode" + "go.yaml.in/yaml/v3" +) + +// StreamEncoder encodes T to a YAML v3 stream. +func StreamEncoder[T any](w io.Writer, v T) error { + return yaml.NewEncoder(w).Encode(v) +} + +// StreamDecoder decodes T from a YAML v3 stream. +func StreamDecoder[T any](r io.Reader, v *T) error { + return yaml.NewDecoder(r).Decode(v) +} + +// NewStreamCodec returns a YAML v3 stream codec for T. +// It is safe for concurrent use. +func NewStreamCodec[T any]() encoding.StreamCodec[T] { + return encoding.StreamCodec[T]{ + Encode: StreamEncoder[T], + Decode: StreamDecoder[T], + } +} diff --git a/yaml/v4/streamcodec.go b/yaml/v4/streamcodec.go new file mode 100644 index 0000000..ba52b17 --- /dev/null +++ b/yaml/v4/streamcodec.go @@ -0,0 +1,27 @@ +package yaml + +import ( + "io" + + encoding "github.com/foomo/goencode" + "go.yaml.in/yaml/v4" +) + +// StreamEncoder encodes T to a YAML v4 stream. +func StreamEncoder[T any](w io.Writer, v T) error { + return yaml.NewEncoder(w).Encode(v) +} + +// StreamDecoder decodes T from a YAML v4 stream. +func StreamDecoder[T any](r io.Reader, v *T) error { + return yaml.NewDecoder(r).Decode(v) +} + +// NewStreamCodec returns a YAML v4 stream codec for T. +// It is safe for concurrent use. +func NewStreamCodec[T any]() encoding.StreamCodec[T] { + return encoding.StreamCodec[T]{ + Encode: StreamEncoder[T], + Decode: StreamDecoder[T], + } +} From d7e21801d8fc6f531c0406d6dd91b8ae7708e98e Mon Sep 17 00:00:00 2001 From: franklin Date: Tue, 21 Apr 2026 15:25:55 +0200 Subject: [PATCH 28/29] refactor: split interfaces --- codec.go | 6 ------ decoder.go | 4 ++++ encoder.go | 4 ++++ json/v1/codec.go | 18 ++++++++++++------ 4 files changed, 20 insertions(+), 12 deletions(-) create mode 100644 decoder.go create mode 100644 encoder.go diff --git a/codec.go b/codec.go index 19f47f8..b34271b 100644 --- a/codec.go +++ b/codec.go @@ -1,11 +1,5 @@ package goencode -// Encoder encodes source S to target T. -type Encoder[S, T any] func(s S) (T, error) - -// Decoder decodes target T back into source S. -type Decoder[S, T any] func(t T, s *S) error - // Codec bundles an Encoder and Decoder for S ↔ T round-trips. type Codec[S, T any] struct { Encode Encoder[S, T] diff --git a/decoder.go b/decoder.go new file mode 100644 index 0000000..b8e38a3 --- /dev/null +++ b/decoder.go @@ -0,0 +1,4 @@ +package goencode + +// Decoder decodes target T back into source S. +type Decoder[S, T any] func(t T, s *S) error diff --git a/encoder.go b/encoder.go new file mode 100644 index 0000000..ebf43c4 --- /dev/null +++ b/encoder.go @@ -0,0 +1,4 @@ +package goencode + +// Encoder encodes source S to target T. +type Encoder[S, T any] func(s S) (T, error) diff --git a/json/v1/codec.go b/json/v1/codec.go index 31efb95..9330902 100644 --- a/json/v1/codec.go +++ b/json/v1/codec.go @@ -6,15 +6,21 @@ import ( encoding "github.com/foomo/goencode" ) +// Encoder encodes T to JSON bytes (v1). +func Encoder[T any](v T) ([]byte, error) { + return json.Marshal(v) +} + +// Decoder decodes JSON bytes into T (v1). +func Decoder[T any](b []byte, v *T) error { + return json.Unmarshal(b, v) +} + // NewCodec returns a JSON codec for T. // It is safe for concurrent use. func NewCodec[T any]() encoding.Codec[T, []byte] { return encoding.Codec[T, []byte]{ - Encode: func(v T) ([]byte, error) { - return json.Marshal(v) - }, - Decode: func(b []byte, v *T) error { - return json.Unmarshal(b, v) - }, + Encode: Encoder[T], + Decode: Decoder[T], } } From 2032af957f9f45d495f24e09df659cd6df4a2580 Mon Sep 17 00:00:00 2001 From: franklin Date: Tue, 21 Apr 2026 17:34:44 +0200 Subject: [PATCH 29/29] docs: update docs --- docs/guide/codecs.md | 47 ++++++++++++----------- docs/guide/composition.md | 70 +++++++++++++++++++---------------- docs/guide/file-codec.md | 14 +++---- docs/guide/getting-started.md | 44 ++++++++++++++-------- docs/guide/streaming.md | 52 ++++++++++++-------------- docs/index.md | 10 ++--- 6 files changed, 126 insertions(+), 111 deletions(-) diff --git a/docs/guide/codecs.md b/docs/guide/codecs.md index 6a99027..6c8c3f0 100644 --- a/docs/guide/codecs.md +++ b/docs/guide/codecs.md @@ -1,6 +1,6 @@ # Codec Reference -All codecs listed below implement `Codec[T]`, `StreamCodec[T]`, or both. The core module packages use only the Go standard library. +All codecs listed below return `Codec[S, T]` and/or `StreamCodec[S]` structs via constructor functions. Each package also exports standalone `Encoder` and `Decoder` functions. The core module packages use only the Go standard library. ## Serialization Codecs @@ -22,19 +22,22 @@ All codecs listed below implement `Codec[T]`, `StreamCodec[T]`, or both. The cor | `hex` | `hex.NewCodec()` | `[]byte` | `hex.NewStreamCodec()` | | `ascii85` | `ascii85.NewCodec()` | `[]byte` | `ascii85.NewStreamCodec()` | -## Compression Wrappers +## Compression Codecs -Compression codecs wrap an inner `Codec[T]` or `StreamCodec[T]` using the [decorator pattern](/guide/composition). +Compression codecs are standalone `Codec[[]byte, []byte]` — they compress and decompress raw bytes. Compose them with serialization codecs via [`PipeCodec`](/guide/composition). -| Package | Constructor | Options | -|---------|-------------|---------| -| `gzip` | `gzip.NewCodec[T](codec, opts...)` | `gzip.WithLevel(int)` | -| `flate` | `flate.NewCodec[T](codec, opts...)` | `flate.WithLevel(int)` | -| `snappy` | `snappy.NewCodec[T](codec)` | — | -| `zstd` | `zstd.NewCodec[T](codec, opts...)` | `zstd.WithLevel(zstd.EncoderLevel)` | -| `brotli` | `brotli.NewCodec[T](codec, opts...)` | `brotli.WithLevel(int)` | +| Package | Constructor | StreamCodec | Options | +|---------|-------------|-------------|---------| +| `gzip` | `gzip.NewCodec(opts...)` | `gzip.NewStreamCodec(opts...)` | `gzip.WithLevel(int)` | +| `flate` | `flate.NewCodec(opts...)` | `flate.NewStreamCodec(opts...)` | `flate.WithLevel(int)` | +| `snappy` | `snappy.NewCodec()` | `snappy.NewStreamCodec()` | — | +| `zstd` | `zstd.NewCodec(opts...)` | `zstd.NewStreamCodec(opts...)` | `zstd.WithLevel(zstd.EncoderLevel)` | +| `brotli` | `brotli.NewCodec(opts...)` | `brotli.NewStreamCodec(opts...)` | `brotli.WithLevel(int)` | -Each also has a stream variant: `gzip.NewStreamCodec[T](streamCodec, opts...)`, etc. +```go +// Example: JSON + gzip via PipeCodec +c := goencode.PipeCodec(json.NewCodec[User](), gzip.NewCodec()) +``` ::: tip `snappy`, `zstd`, and `brotli` are [submodule packages](#submodule-packages) that require a separate `go get`. @@ -48,14 +51,14 @@ Each also has a stream variant: `gzip.NewStreamCodec[T](streamCodec, opts...)`, The `file` package wraps any codec to read/write files atomically (temp file + rename). ```go -file.NewCodec[T](codec, opts...) // Encode(path string, v T) error -file.NewStreamCodec[T](codec, opts...) // Encode(path string, v T) error +file.NewCodec[T](codec, opts...) // accepts Codec[T, []byte], Encode(path string, v T) error +file.NewStreamCodec[T](codec, opts...) // accepts StreamCodec[T], Encode(path string, v T) error ``` Options: `file.WithPermissions(os.FileMode)` — default `0o644`. ::: warning -The file codec has a different method signature — it uses `path string` instead of `[]byte` or `io.Writer`. It does not satisfy the `Codec[T]` or `StreamCodec[T]` interfaces. See [File Codec](/guide/file-codec) for details. +The file codec has a different method signature — it uses `path string` instead of `[]byte` or `io.Writer`. See [File Codec](/guide/file-codec) for details. ::: ## Submodule Packages @@ -64,11 +67,13 @@ These packages have external dependencies and live in separate Go modules. Insta | Package | Import Path | Dependency | StreamCodec | |---------|-------------|------------|-------------| -| `json2` | `github.com/foomo/goencode/json2` | go-json-experiment | — (has `EncodeTo`/`DecodeFrom` methods) | -| `yaml/v2` | `github.com/foomo/goencode/yaml/v2` | go.yaml.in/yaml/v2 | — | -| `yaml/v3` | `github.com/foomo/goencode/yaml/v3` | go.yaml.in/yaml/v3 | — | -| `yaml/v4` | `github.com/foomo/goencode/yaml/v4` | go.yaml.in/yaml/v4 | — | -| `snappy` | `github.com/foomo/goencode/snappy` | github.com/golang/snappy | `snappy.NewStreamCodec[T](codec)` | -| `brotli` | `github.com/foomo/goencode/brotli` | github.com/andybalholm/brotli | `brotli.NewStreamCodec[T](codec, opts...)` | +| `json/v2` | `github.com/foomo/goencode/json/v2` | go-json-experiment | `json.NewStreamCodec[T]()` | +| `yaml/v2` | `github.com/foomo/goencode/yaml/v2` | go.yaml.in/yaml/v2 | `yaml.NewStreamCodec[T]()` | +| `yaml/v3` | `github.com/foomo/goencode/yaml/v3` | go.yaml.in/yaml/v3 | `yaml.NewStreamCodec[T]()` | +| `yaml/v4` | `github.com/foomo/goencode/yaml/v4` | go.yaml.in/yaml/v4 | `yaml.NewStreamCodec[T]()` | | `toml` | `github.com/foomo/goencode/toml` | github.com/BurntSushi/toml | `toml.NewStreamCodec[T]()` | -| `zstd` | `github.com/foomo/goencode/zstd` | github.com/klauspost/compress | `zstd.NewStreamCodec[T](codec, opts...)` | +| `msgpack/tinylib` | `github.com/foomo/goencode/msgpack/tinylib` | github.com/tinylib/msgp | `msgpack.NewStreamCodec[T]()` | +| `msgpack/vmihailenco` | `github.com/foomo/goencode/msgpack/vmihailenco` | github.com/vmihailenco/msgpack | `msgpack.NewStreamCodec[T]()` | +| `snappy` | `github.com/foomo/goencode/snappy` | github.com/golang/snappy | `snappy.NewStreamCodec()` | +| `brotli` | `github.com/foomo/goencode/brotli` | github.com/andybalholm/brotli | `brotli.NewStreamCodec(opts...)` | +| `zstd` | `github.com/foomo/goencode/zstd` | github.com/klauspost/compress | `zstd.NewStreamCodec(opts...)` | diff --git a/docs/guide/composition.md b/docs/guide/composition.md index 680f334..0b3de5c 100644 --- a/docs/guide/composition.md +++ b/docs/guide/composition.md @@ -1,13 +1,14 @@ # Composing Codecs -Compression codecs in goencode follow the decorator pattern — they wrap an inner `Codec[T]` to add a compression layer. This lets you compose serialization and compression in a single line. +Codecs in goencode compose via `PipeCodec` — a type-safe function that chains two codecs together. Compression codecs are standalone `Codec[[]byte, []byte]`, so you pipe a serialization codec into a compression codec to get a single composed codec. ## Basic Composition -A compression codec takes any `Codec[T]` as its first argument: +`PipeCodec` chains two codecs where the output type of the first matches the input type of the second: ```go import ( + "github.com/foomo/goencode" "github.com/foomo/goencode/gzip" "github.com/foomo/goencode/json/v1" ) @@ -18,7 +19,7 @@ type User struct { } // JSON serialization + gzip compression -c := gzip.NewCodec[User](json.NewCodec[User]()) // [!code highlight] +c := goencode.PipeCodec(json.NewCodec[User](), gzip.NewCodec()) // [!code highlight] b, err := c.Encode(User{Name: "Alice", Age: 30}) // b contains gzip-compressed JSON @@ -28,60 +29,73 @@ err = c.Decode(b, &u) // u == User{Name: "Alice", Age: 30} ``` -The flow is: `Encode` serializes with the inner codec, then compresses. `Decode` decompresses, then deserializes. +The flow is: `Encode` serializes with the first codec, then compresses with the second. `Decode` decompresses with the second, then deserializes with the first. ## Choosing a Format ::: code-group ```go [JSON + gzip] -c := gzip.NewCodec[User](json.NewCodec[User]()) +c := goencode.PipeCodec(json.NewCodec[User](), gzip.NewCodec()) ``` ```go [XML + flate] -c := flate.NewCodec[User](xml.NewCodec[User]()) +c := goencode.PipeCodec(xml.NewCodec[User](), flate.NewCodec()) ``` ```go [Gob + snappy] -c := snappy.NewCodec[User](gob.NewCodec[User]()) +c := goencode.PipeCodec(gob.NewCodec[User](), snappy.NewCodec()) ``` ```go [JSON + zstd] -c := zstd.NewCodec[User](json.NewCodec[User]()) +c := goencode.PipeCodec(json.NewCodec[User](), zstd.NewCodec()) ``` ::: ## Compression Options -gzip, flate, and zstd accept options to tune compression level: +gzip, flate, zstd, and brotli accept options to tune compression level: ```go // gzip with best compression -c := gzip.NewCodec[User]( +c := goencode.PipeCodec( json.NewCodec[User](), - gzip.WithLevel(gzip.BestCompression), // [!code highlight] + gzip.NewCodec(gzip.WithLevel(gzip.BestCompression)), // [!code highlight] ) // flate with best speed -c := flate.NewCodec[User]( +c := goencode.PipeCodec( json.NewCodec[User](), - flate.WithLevel(flate.BestSpeed), // [!code highlight] + flate.NewCodec(flate.WithLevel(flate.BestSpeed)), // [!code highlight] ) // zstd with best compression -c := zstd.NewCodec[User]( +c := goencode.PipeCodec( json.NewCodec[User](), - zstd.WithLevel(zstd.SpeedBestCompression), // [!code highlight] + zstd.NewCodec(zstd.WithLevel(zstd.SpeedBestCompression)), // [!code highlight] +) +``` + +## Chaining Multiple Codecs + +`PipeCodec` returns a `Codec`, so you can chain more than two: + +```go +// JSON → gzip → base64 +c := goencode.PipeCodec( + goencode.PipeCodec(json.NewCodec[User](), gzip.NewCodec()), + base64.NewCodec(), ) ``` ## Adding File Persistence -The `file` codec wraps any `Codec[T]` to read and write files atomically: +The `file` codec wraps any `Codec[T, []byte]` to read and write files atomically: ```go import ( + "github.com/foomo/goencode" "github.com/foomo/goencode/file" "github.com/foomo/goencode/gzip" "github.com/foomo/goencode/json/v1" @@ -94,7 +108,7 @@ type Config struct { // JSON + gzip + atomic file I/O fc := file.NewCodec[Config]( // [!code highlight] - gzip.NewCodec[Config](json.NewCodec[Config]()), // [!code highlight] + goencode.PipeCodec(json.NewCodec[Config](), gzip.NewCodec()), // [!code highlight] file.WithPermissions(0o600), ) // [!code highlight] @@ -106,24 +120,16 @@ var loaded Config err = fc.Decode("/etc/myapp/config.json.gz", &loaded) ``` -The composition layers from outside in: `file` → `gzip` → `json` → `T`. +The composition layers: `file` wraps the piped codec (`json` → `gzip`). -## Stream Composition +## Standalone Encoder/Decoder Composition -The same pattern works with `StreamCodec[T]`: +You can also compose individual encoder and decoder functions: ```go -import ( - "github.com/foomo/goencode/gzip" - "github.com/foomo/goencode/json/v1" -) +// Compose encoders: User → []byte → []byte +enc := goencode.PipeEncoder(json.Encoder[User], gzip.NewEncoder()) -sc := gzip.NewStreamCodec[User](json.NewStreamCodec[User]()) // [!code highlight] - -// Write compressed JSON to any io.Writer -err := sc.Encode(writer, user) - -// Read compressed JSON from any io.Reader -var u User -err = sc.Decode(reader, &u) +// Compose decoders: []byte → []byte → User +dec := goencode.PipeDecoder(json.Decoder[User], gzip.NewDecoder()) ``` diff --git a/docs/guide/file-codec.md b/docs/guide/file-codec.md index 3b04cff..430ca6b 100644 --- a/docs/guide/file-codec.md +++ b/docs/guide/file-codec.md @@ -1,9 +1,9 @@ # File Codec -The `file` package wraps any `Codec[T]` or `StreamCodec[T]` to add atomic file persistence. It writes to a temporary file first, then renames it into place — preventing partial writes if the process crashes mid-write. +The `file` package wraps any `Codec[T, []byte]` or `StreamCodec[T]` to add atomic file persistence. It writes to a temporary file first, then renames it into place — preventing partial writes if the process crashes mid-write. ::: warning -The file codec has a different method signature from `Codec[T]` — it uses `path string` instead of `[]byte`. It does not satisfy the `Codec[T]` or `StreamCodec[T]` interfaces. +The file codec has a different method signature — it uses `path string` instead of `[]byte` or `io.Writer`. ::: ## Basic Usage @@ -54,7 +54,7 @@ fc := file.NewCodec[Secrets]( ## Stream Variant -`file.NewStreamCodec[T]` wraps a `StreamCodec[T]` instead. This streams the encoded data directly to the temp file without buffering the full payload in memory. +`file.NewStreamCodec[T]` wraps a `StreamCodec[T]` struct instead. This streams the encoded data directly to the temp file without buffering the full payload in memory. ```go fsc := file.NewStreamCodec[Config](json.NewStreamCodec[Config]()) // [!code highlight] @@ -67,10 +67,11 @@ err = fsc.Decode("config.json", &loaded) ## Composed Example -Combine serialization, compression, and file persistence: +Combine serialization, compression, and file persistence via `PipeCodec`: ```go import ( + "github.com/foomo/goencode" "github.com/foomo/goencode/file" "github.com/foomo/goencode/gzip" "github.com/foomo/goencode/json/v1" @@ -82,10 +83,7 @@ type State struct { } fc := file.NewCodec[State]( - gzip.NewCodec[State]( - json.NewCodec[State](), - gzip.WithLevel(gzip.BestSpeed), - ), + goencode.PipeCodec(json.NewCodec[State](), gzip.NewCodec(gzip.WithLevel(gzip.BestSpeed))), file.WithPermissions(0o600), ) diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 902961f..6d8ddfc 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -13,41 +13,53 @@ These packages have external dependencies and require their own `go get`: | Package | Install | |---------|---------| -| `json2` | `go get github.com/foomo/goencode/json2` | +| `json/v2` | `go get github.com/foomo/goencode/json/v2` | | `yaml/v2` | `go get github.com/foomo/goencode/yaml/v2` | | `yaml/v3` | `go get github.com/foomo/goencode/yaml/v3` | | `yaml/v4` | `go get github.com/foomo/goencode/yaml/v4` | +| `toml` | `go get github.com/foomo/goencode/toml` | | `snappy` | `go get github.com/foomo/goencode/snappy` | | `zstd` | `go get github.com/foomo/goencode/zstd` | +| `brotli` | `go get github.com/foomo/goencode/brotli` | +| `msgpack/tinylib` | `go get github.com/foomo/goencode/msgpack/tinylib` | +| `msgpack/vmihailenco` | `go get github.com/foomo/goencode/msgpack/vmihailenco` | ::: -## Core Interfaces +## Core Types -goencode defines two generic interfaces at the root package. +goencode defines function types and struct bundles at the root package. -### Codec[T] — byte-oriented +### Codec[S, T] — byte-oriented ```go -// Codec encodes T to []byte and decodes []byte back to T. -type Codec[T any] interface { - Encode(v T) ([]byte, error) - Decode(b []byte, v *T) error +// Function types +type Encoder[S, T any] func(s S) (T, error) +type Decoder[S, T any] func(t T, s *S) error + +// Codec bundles an Encoder and Decoder pair. +type Codec[S, T any] struct { + Encode Encoder[S, T] + Decode Decoder[S, T] } ``` -Use `Codec[T]` when you need the encoded result as a byte slice — for example, storing in a database, sending over a message queue, or passing to another function. +Use `Codec[S, T]` when you need the encoded result as a value — for example, `Codec[User, []byte]` for serialization or `Codec[[]byte, []byte]` for compression. Codecs compose via `PipeCodec` for type-safe chaining. -### StreamCodec[T] — io.Reader/io.Writer-oriented +### StreamCodec[S] — io.Reader/io.Writer-oriented ```go -// StreamCodec encodes T to an io.Writer and decodes T from an io.Reader. -type StreamCodec[T any] interface { - Encode(w io.Writer, v T) error - Decode(r io.Reader, v *T) error +// Stream function types +type StreamEncoder[S any] func(w io.Writer, s S) error +type StreamDecoder[S any] func(r io.Reader, s *S) error + +// StreamCodec bundles a StreamEncoder and StreamDecoder pair. +type StreamCodec[S any] struct { + Encode StreamEncoder[S] + Decode StreamDecoder[S] } ``` -Use `StreamCodec[T]` when working with streams — HTTP request/response bodies, files, network connections, or any `io.Reader`/`io.Writer`. +Use `StreamCodec[S]` when working with streams — HTTP request/response bodies, files, network connections, or any `io.Reader`/`io.Writer`. ## Minimal Example @@ -87,4 +99,4 @@ func main() { ## Concurrency Safety -All codecs in this library are safe for concurrent use. Serialization codecs like `json.Codec[T]` are stateless zero-size structs; compression wrappers hold only immutable configuration. You can safely share a single codec instance across goroutines. +All codecs in this library are safe for concurrent use. Codec structs bundle pure function values with no shared mutable state. You can safely share a single codec instance across goroutines. diff --git a/docs/guide/streaming.md b/docs/guide/streaming.md index d169f0f..54beaaa 100644 --- a/docs/guide/streaming.md +++ b/docs/guide/streaming.md @@ -1,27 +1,22 @@ # Streaming -`StreamCodec[T]` encodes to an `io.Writer` and decodes from an `io.Reader`. Use it when working with streams — HTTP bodies, files, network connections, or pipelines — to avoid buffering entire payloads in memory. +`StreamCodec[S]` encodes to an `io.Writer` and decodes from an `io.Reader`. Use it when working with streams — HTTP bodies, files, network connections, or pipelines — to avoid buffering entire payloads in memory. -## Interface +## Types ```go -type StreamCodec[T any] interface { - Encode(w io.Writer, v T) error - Decode(r io.Reader, v *T) error +// Stream function types +type StreamEncoder[S any] func(w io.Writer, s S) error +type StreamDecoder[S any] func(r io.Reader, s *S) error + +// StreamCodec bundles a StreamEncoder and StreamDecoder pair. +type StreamCodec[S any] struct { + Encode StreamEncoder[S] + Decode StreamDecoder[S] } ``` -The root package also defines `Encoder[T]` and `Decoder[T]` for stateful encoder/decoder pairs: - -```go -type Encoder[T any] interface { - Encode(v T) error -} - -type Decoder[T any] interface { - Decode(v any) error -} -``` +Each serialization package exports standalone `StreamEncoder` and `StreamDecoder` functions alongside the `NewStreamCodec` constructor. ## JSON Streaming @@ -101,30 +96,29 @@ _ = c.Decode(&buf, &decoded) // [!code highlight] fmt.Println(decoded) // [[name age] [Alice 30]] ``` -## Compressed Streams +## Compression Streaming -Stream codecs compose the same way as byte codecs — compression wrappers accept an inner `StreamCodec[T]`: +Compression stream codecs are standalone `StreamCodec[[]byte]` — they compress and decompress raw bytes over streams: ```go import ( "github.com/foomo/goencode/gzip" - "github.com/foomo/goencode/json/v1" ) -type Payload struct { - Items []string `json:"items"` -} - -sc := gzip.NewStreamCodec[Payload](json.NewStreamCodec[Payload]()) // [!code highlight] +sc := gzip.NewStreamCodec() // StreamCodec[[]byte] // [!code highlight] -// Write gzip-compressed JSON to any io.Writer -err := sc.Encode(writer, payload) +// Write gzip-compressed bytes to any io.Writer +err := sc.Encode(writer, rawBytes) -// Read gzip-compressed JSON from any io.Reader -var p Payload -err = sc.Decode(reader, &p) +// Read gzip-decompressed bytes from any io.Reader +var decoded []byte +err = sc.Decode(reader, &decoded) ``` +::: tip +For combined serialization + compression (e.g., JSON → gzip), use the byte-oriented `PipeCodec` approach instead. See [Composing Codecs](/guide/composition) for details. +::: + ## HTTP Example StreamCodec is a natural fit for HTTP handlers: diff --git a/docs/index.md b/docs/index.md index b8a596d..2ce2897 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,7 +4,7 @@ layout: home hero: name: goencode text: Generic Encoding for Go - tagline: Composable, type-safe codec interfaces. Serialize, compress, and persist data with a single API. + tagline: Composable, type-safe codec function types. Serialize, compress, and persist data with a single API. image: src: /logo.png alt: goencode @@ -18,11 +18,11 @@ hero: features: - title: Type-Safe Generics - details: Codec[T] and StreamCodec[T] use Go generics so encode/decode operations are statically typed at compile time. - - title: Composable Wrappers - details: Layer gzip, flate, snappy, or zstd compression on any codec with a single function call using the decorator pattern. + details: Codec[S, T] and StreamCodec[S] use Go generics so encode/decode operations are statically typed at compile time. + - title: Composable Pipelines + details: Chain any two codecs with PipeCodec — e.g., JSON → gzip, JSON → base64 — with full type safety at compile time. - title: Streaming Support - details: StreamCodec[T] reads and writes directly to io.Reader/io.Writer for memory-efficient pipelines and network I/O. + details: StreamCodec[S] reads and writes directly to io.Reader/io.Writer for memory-efficient pipelines and network I/O. - title: Atomic File I/O details: The file codec writes to a temp file and renames into place, preventing partial writes and data corruption. - title: Zero Dependencies