diff --git a/.github/workflows/benchmark.yaml b/.github/workflows/benchmark.yaml index 81b52fe..2712b45 100644 --- a/.github/workflows/benchmark.yaml +++ b/.github/workflows/benchmark.yaml @@ -20,7 +20,7 @@ jobs: mkdir -p ./profile { echo "stdout<> $GITHUB_OUTPUT id: bench diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f0c4633..14d1db5 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -27,7 +27,7 @@ jobs: - uses: actions/setup-go@v5 with: go-version-file: go.mod - - run: go test ./... -v -coverprofile=./coverage.txt -race -vet=off + - run: go test ./... -short -v -coverprofile=./coverage.txt -race -vet=off - name: Upload coverage data uses: codecov/codecov-action@v4.1.0 with: diff --git a/.golangci.yaml b/.golangci.yaml index 762b560..92e5988 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -8,8 +8,6 @@ linters: - staticcheck - unused - gosimple - - structcheck - - varcheck - ineffassign - typecheck - revive @@ -32,7 +30,6 @@ linters: - nilerr - nosprintfhostport - sqlclosecheck - - testpackage - unconvert - unparam - whitespace diff --git a/docs/images/memory.png b/docs/images/memory.png index 3ed0100..b94984f 100644 Binary files a/docs/images/memory.png and b/docs/images/memory.png differ diff --git a/docs/images/time.png b/docs/images/time.png index 967d89a..db465af 100644 Binary files a/docs/images/time.png and b/docs/images/time.png differ diff --git a/formstream.go b/formstream.go index 146b5fa..fe30475 100644 --- a/formstream.go +++ b/formstream.go @@ -41,7 +41,7 @@ type parserConfig struct { type ParserOption func(*parserConfig) -type DataSize uint64 +type DataSize int64 const ( _ DataSize = 1 << (iota * 10) diff --git a/formstream_test.go b/formstream_test.go index 961c7d5..ca6b40c 100644 --- a/formstream_test.go +++ b/formstream_test.go @@ -12,6 +12,7 @@ import ( "testing" "github.com/mazrean/formstream" + "github.com/mazrean/formstream/internal/myio" ) func ExampleNewParser() { @@ -69,44 +70,69 @@ large file contents const boundary = "boundary" -func sampleForm(fileSize formstream.DataSize, boundary string, reverse bool) (io.Reader, error) { - b := bytes.NewBuffer(nil) +func sampleForm(fileSize formstream.DataSize, boundary string, reverse bool) (io.ReadSeekCloser, error) { + if fileSize > 1*formstream.GB { + f, err := os.CreateTemp("", "formstream-test-form-") + if err != nil { + return nil, fmt.Errorf("failed to create temp file: %w", err) + } + + err = createSampleForm(f, fileSize, boundary, reverse) + if err != nil { + return nil, fmt.Errorf("failed to create sample form: %w", err) + } + + return f, nil + } + + buf := bytes.NewBuffer(nil) + + err := createSampleForm(buf, fileSize, boundary, reverse) + if err != nil { + return nil, fmt.Errorf("failed to create sample form: %w", err) + } - mw := multipart.NewWriter(b) + return myio.NopSeekCloser(bytes.NewReader(buf.Bytes())), nil +} + +func createSampleForm(w io.Writer, fileSize formstream.DataSize, boundary string, reverse bool) error { + mw := multipart.NewWriter(w) defer mw.Close() err := mw.SetBoundary(boundary) if err != nil { - return nil, fmt.Errorf("failed to set boundary: %w", err) + return fmt.Errorf("failed to set boundary: %w", err) } if !reverse { err := mw.WriteField("field", "value") if err != nil { - return nil, fmt.Errorf("failed to write field: %w", err) + return fmt.Errorf("failed to write field: %w", err) } } mh := make(textproto.MIMEHeader) mh.Set("Content-Disposition", `form-data; name="stream"; filename="file.txt"`) mh.Set("Content-Type", "text/plain") - w, err := mw.CreatePart(mh) + pw, err := mw.CreatePart(mh) if err != nil { - return nil, fmt.Errorf("failed to create part: %w", err) + return fmt.Errorf("failed to create part: %w", err) } - _, err = io.CopyN(w, strings.NewReader(strings.Repeat("a", int(fileSize))), int64(fileSize)) - if err != nil { - return nil, fmt.Errorf("failed to copy: %w", err) + for i := 0; i < int(fileSize/formstream.MB); i++ { + _, err := pw.Write([]byte(strings.Repeat("a", int(formstream.MB)))) + if err != nil { + return fmt.Errorf("failed to write: %w", err) + } } if reverse { err := mw.WriteField("field", "value") if err != nil { - return nil, fmt.Errorf("failed to write field: %w", err) + return fmt.Errorf("failed to write field: %w", err) } } - return b, nil + return nil } func BenchmarkFormStreamFastPath(b *testing.B) { @@ -122,6 +148,18 @@ func BenchmarkFormStreamFastPath(b *testing.B) { b.Run("1GB", func(b *testing.B) { benchmarkFormStream(b, 1*formstream.GB, false) }) + b.Run("5GB", func(b *testing.B) { + if testing.Short() { + b.Skip("skipping test in short mode.") + } + benchmarkFormStream(b, 5*formstream.GB, false) + }) + b.Run("10GB", func(b *testing.B) { + if testing.Short() { + b.Skip("skipping test in short mode.") + } + benchmarkFormStream(b, 10*formstream.GB, false) + }) } func BenchmarkFormStreamSlowPath(b *testing.B) { @@ -137,12 +175,31 @@ func BenchmarkFormStreamSlowPath(b *testing.B) { b.Run("1GB", func(b *testing.B) { benchmarkFormStream(b, 1*formstream.GB, true) }) + b.Run("5GB", func(b *testing.B) { + if testing.Short() { + b.Skip("skipping test in short mode.") + } + benchmarkFormStream(b, 5*formstream.GB, true) + }) + b.Run("10GB", func(b *testing.B) { + if testing.Short() { + b.Skip("skipping test in short mode.") + } + benchmarkFormStream(b, 10*formstream.GB, true) + }) } func benchmarkFormStream(b *testing.B, fileSize formstream.DataSize, reverse bool) { + r, err := sampleForm(fileSize, boundary, reverse) + if err != nil { + b.Fatal(err) + } + defer r.Close() + + b.ResetTimer() for i := 0; i < b.N; i++ { b.StopTimer() - r, err := sampleForm(fileSize, boundary, reverse) + _, err := r.Seek(0, io.SeekStart) if err != nil { b.Fatal(err) } @@ -169,32 +226,50 @@ func benchmarkFormStream(b *testing.B, fileSize formstream.DataSize, reverse boo if err != nil { b.Fatal(err) } - } } -func BenchmarkStdMultipart_ReadForm(b *testing.B) { - // default value in http package - const maxMemory = 32 * formstream.MB - +func BenchmarkStdMultipartReadForm(b *testing.B) { b.Run("1MB", func(b *testing.B) { - benchmarkStdMultipart_ReadForm(b, 1*formstream.MB, maxMemory) + benchmarkStdMultipartReadForm(b, 1*formstream.MB) }) b.Run("10MB", func(b *testing.B) { - benchmarkStdMultipart_ReadForm(b, 10*formstream.MB, maxMemory) + benchmarkStdMultipartReadForm(b, 10*formstream.MB) }) b.Run("100MB", func(b *testing.B) { - benchmarkStdMultipart_ReadForm(b, 100*formstream.MB, maxMemory) + benchmarkStdMultipartReadForm(b, 100*formstream.MB) }) b.Run("1GB", func(b *testing.B) { - benchmarkStdMultipart_ReadForm(b, 1*formstream.GB, maxMemory) + benchmarkStdMultipartReadForm(b, 1*formstream.GB) + }) + b.Run("5GB", func(b *testing.B) { + if testing.Short() { + b.Skip("skipping test in short mode.") + } + benchmarkStdMultipartReadForm(b, 5*formstream.GB) + }) + b.Run("10GB", func(b *testing.B) { + if testing.Short() { + b.Skip("skipping test in short mode.") + } + benchmarkStdMultipartReadForm(b, 10*formstream.GB) }) } -func benchmarkStdMultipart_ReadForm(b *testing.B, fileSize formstream.DataSize, maxMemory formstream.DataSize) { +func benchmarkStdMultipartReadForm(b *testing.B, fileSize formstream.DataSize) { + // default value in http package + const maxMemory = 32 * formstream.MB + + r, err := sampleForm(fileSize, boundary, false) + if err != nil { + b.Fatal(err) + } + defer r.Close() + + b.ResetTimer() for i := 0; i < b.N; i++ { b.StopTimer() - r, err := sampleForm(fileSize, boundary, false) + _, err := r.Seek(0, io.SeekStart) if err != nil { b.Fatal(err) } diff --git a/go.mod b/go.mod index bf06d98..4c92da0 100644 --- a/go.mod +++ b/go.mod @@ -38,6 +38,7 @@ require ( github.com/gin-gonic/gin v1.9.1 github.com/labstack/echo/v4 v4.11.4 golang.org/x/mod v0.11.0 // indirect + golang.org/x/sync v0.6.0 golang.org/x/sys v0.15.0 // indirect golang.org/x/tools v0.6.0 // indirect ) diff --git a/go.sum b/go.sum index 3ebaad5..3fb3240 100644 --- a/go.sum +++ b/go.sum @@ -76,6 +76,8 @@ golang.org/x/mod v0.11.0 h1:bUO06HqtnRcc/7l71XBe4WcqTZ+3AH1J59zWDDwLKgU= golang.org/x/mod v0.11.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= +golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= diff --git a/http/parser_test.go b/http/parser_test.go index 6e1476e..877ab79 100644 --- a/http/parser_test.go +++ b/http/parser_test.go @@ -1,17 +1,23 @@ package httpform_test import ( + "bytes" "context" "fmt" "io" "log" + "mime/multipart" "net/http" "net/http/httptest" + "net/textproto" + "os" "strings" "testing" "github.com/mazrean/formstream" httpform "github.com/mazrean/formstream/http" + "github.com/mazrean/formstream/internal/myio" + "golang.org/x/sync/errgroup" ) func TestExample(t *testing.T) { @@ -101,3 +107,354 @@ func saveUser(_ context.Context, name string, password string, iconReader io.Rea return nil } + +const boundary = "boundary" + +func sampleForm(fileSize formstream.DataSize, boundary string, reverse bool) (io.ReadSeekCloser, error) { + if fileSize > 1*formstream.GB { + f, err := os.CreateTemp("", "formstream-test-form-") + if err != nil { + return nil, fmt.Errorf("failed to create temp file: %w", err) + } + + err = createSampleForm(f, fileSize, boundary, reverse) + if err != nil { + return nil, fmt.Errorf("failed to create sample form: %w", err) + } + + return f, nil + } + + buf := bytes.NewBuffer(nil) + + err := createSampleForm(buf, fileSize, boundary, reverse) + if err != nil { + return nil, fmt.Errorf("failed to create sample form: %w", err) + } + + return myio.NopSeekCloser(bytes.NewReader(buf.Bytes())), nil +} + +func createSampleForm(w io.Writer, fileSize formstream.DataSize, boundary string, reverse bool) error { + mw := multipart.NewWriter(w) + defer mw.Close() + + err := mw.SetBoundary(boundary) + if err != nil { + return fmt.Errorf("failed to set boundary: %w", err) + } + + if !reverse { + err := mw.WriteField("field", "value") + if err != nil { + return fmt.Errorf("failed to write field: %w", err) + } + } + + mh := make(textproto.MIMEHeader) + mh.Set("Content-Disposition", `form-data; name="stream"; filename="file.txt"`) + mh.Set("Content-Type", "text/plain") + pw, err := mw.CreatePart(mh) + if err != nil { + return fmt.Errorf("failed to create part: %w", err) + } + for i := 0; i < int(fileSize/formstream.MB); i++ { + _, err := pw.Write([]byte(strings.Repeat("a", int(formstream.MB)))) + if err != nil { + return fmt.Errorf("failed to write: %w", err) + } + } + + if reverse { + err := mw.WriteField("field", "value") + if err != nil { + return fmt.Errorf("failed to write field: %w", err) + } + } + + return nil +} + +func TestSlowWriter(t *testing.T) { + if testing.Short() { + t.Skip("skipping test in short mode.") + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + parser, err := httpform.NewParser(r) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + err = parser.Register("stream", func(r io.Reader, header formstream.Header) error { + // get field value + _, _, _ = parser.Value("field") + + _, err := io.Copy(myio.SlowWriter(), r) + if err != nil { + return fmt.Errorf("failed to copy: %w", err) + } + + return nil + }, formstream.WithRequiredPart("field")) + if err != nil { + t.Fatal(err) + } + + err = parser.Parse() + if err != nil { + t.Fatal(err) + } + + w.WriteHeader(http.StatusCreated) + })) + defer srv.Close() + + f, err := os.CreateTemp("", "formstream-test-form-") + if err != nil { + t.Fatal(err) + } + defer f.Close() + + err = createSampleForm(f, 1*formstream.GB, boundary, false) + if err != nil { + t.Fatal(err) + } + + eg := &errgroup.Group{} + for i := 0; i < 100; i++ { + f, err := os.Open(f.Name()) + if err != nil { + t.Fatal(err) + } + + req, err := http.NewRequest(http.MethodPost, srv.URL, f) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", fmt.Sprintf("multipart/form-data; boundary=%s", boundary)) + + eg.Go(func() error { + res, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("failed to request: %w", err) + } + + if res.StatusCode != http.StatusCreated { + return fmt.Errorf("status code is wrong: expected: %d, actual: %d", http.StatusCreated, res.StatusCode) + } + + return nil + }) + } + + err = eg.Wait() + if err != nil { + t.Error(err) + } +} + +func BenchmarkFormStreamFastPath(b *testing.B) { + b.Run("1MB", func(b *testing.B) { + benchmarkFormStream(b, 1*formstream.MB, false) + }) + b.Run("10MB", func(b *testing.B) { + benchmarkFormStream(b, 10*formstream.MB, false) + }) + b.Run("100MB", func(b *testing.B) { + benchmarkFormStream(b, 100*formstream.MB, false) + }) + b.Run("1GB", func(b *testing.B) { + benchmarkFormStream(b, 1*formstream.GB, false) + }) + b.Run("5GB", func(b *testing.B) { + if testing.Short() { + b.Skip("skipping test in short mode.") + } + benchmarkFormStream(b, 5*formstream.GB, false) + }) + b.Run("10GB", func(b *testing.B) { + if testing.Short() { + b.Skip("skipping test in short mode.") + } + benchmarkFormStream(b, 10*formstream.GB, false) + }) +} + +func BenchmarkFormStreamSlowPath(b *testing.B) { + b.Run("1MB", func(b *testing.B) { + benchmarkFormStream(b, 1*formstream.MB, true) + }) + b.Run("10MB", func(b *testing.B) { + benchmarkFormStream(b, 10*formstream.MB, true) + }) + b.Run("100MB", func(b *testing.B) { + benchmarkFormStream(b, 100*formstream.MB, true) + }) + b.Run("1GB", func(b *testing.B) { + benchmarkFormStream(b, 1*formstream.GB, true) + }) + b.Run("5GB", func(b *testing.B) { + if testing.Short() { + b.Skip("skipping test in short mode.") + } + benchmarkFormStream(b, 5*formstream.GB, true) + }) + b.Run("10GB", func(b *testing.B) { + if testing.Short() { + b.Skip("skipping test in short mode.") + } + benchmarkFormStream(b, 10*formstream.GB, true) + }) +} + +func benchmarkFormStream(b *testing.B, fileSize formstream.DataSize, reverse bool) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + parser, err := httpform.NewParser(r) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + err = parser.Register("stream", func(r io.Reader, header formstream.Header) error { + // get field value + _, _, _ = parser.Value("field") + + _, err := io.Copy(io.Discard, r) + if err != nil { + return fmt.Errorf("failed to copy: %w", err) + } + + return nil + }, formstream.WithRequiredPart("field")) + if err != nil { + b.Fatal(err) + } + + err = parser.Parse() + if err != nil { + b.Fatal(err) + } + + w.WriteHeader(http.StatusCreated) + })) + defer srv.Close() + + r, err := sampleForm(fileSize, boundary, reverse) + if err != nil { + b.Fatal(err) + } + defer r.Close() + + req, err := http.NewRequest(http.MethodPost, srv.URL, r) + if err != nil { + b.Fatal(err) + } + req.Header.Set("Content-Type", fmt.Sprintf("multipart/form-data; boundary=%s", boundary)) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + b.StopTimer() + _, err := r.Seek(0, io.SeekStart) + if err != nil { + b.Fatal(err) + } + b.StartTimer() + + _, err = http.DefaultClient.Do(req) + if err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkStdMultipartReadForm(b *testing.B) { + b.Run("1MB", func(b *testing.B) { + benchmarkStdMultipartReadForm(b, 1*formstream.MB) + }) + b.Run("10MB", func(b *testing.B) { + benchmarkStdMultipartReadForm(b, 10*formstream.MB) + }) + b.Run("100MB", func(b *testing.B) { + benchmarkStdMultipartReadForm(b, 100*formstream.MB) + }) + b.Run("1GB", func(b *testing.B) { + benchmarkStdMultipartReadForm(b, 1*formstream.GB) + }) + b.Run("5GB", func(b *testing.B) { + if testing.Short() { + b.Skip("skipping test in short mode.") + } + benchmarkStdMultipartReadForm(b, 5*formstream.GB) + }) + b.Run("10GB", func(b *testing.B) { + if testing.Short() { + b.Skip("skipping test in short mode.") + } + benchmarkStdMultipartReadForm(b, 10*formstream.GB) + }) +} + +func benchmarkStdMultipartReadForm(b *testing.B, fileSize formstream.DataSize) { + // default value in http package + const maxMemory = 32 * formstream.MB + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mr := multipart.NewReader(r.Body, boundary) + form, err := mr.ReadForm(int64(maxMemory)) + if err != nil { + b.Fatal(err) + } + defer func() { + err := form.RemoveAll() + if err != nil { + b.Fatal(err) + } + }() + + f, err := form.File["stream"][0].Open() + if err != nil { + b.Fatal(err) + } + defer f.Close() + + _, err = io.Copy(io.Discard, f) + if err != nil { + b.Fatal(err) + } + + // get field value + _ = form.Value["field"][0] + + w.WriteHeader(http.StatusCreated) + })) + defer srv.Close() + + r, err := sampleForm(fileSize, boundary, false) + if err != nil { + b.Fatal(err) + } + defer r.Close() + + req, err := http.NewRequest(http.MethodPost, srv.URL, r) + if err != nil { + b.Fatal(err) + } + req.Header.Set("Content-Type", fmt.Sprintf("multipart/form-data; boundary=%s", boundary)) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + b.StopTimer() + _, err := r.Seek(0, io.SeekStart) + if err != nil { + b.Fatal(err) + } + b.StartTimer() + + _, err = http.DefaultClient.Do(req) + if err != nil { + b.Fatal(err) + } + } +} diff --git a/internal/condition_judge/condition_judger_test.go b/internal/condition_judge/condition_judger_test.go index 24b0e24..55df615 100644 --- a/internal/condition_judge/condition_judger_test.go +++ b/internal/condition_judge/condition_judger_test.go @@ -1,9 +1,11 @@ -package conditionjudge +package conditionjudge_test import ( "errors" "fmt" "testing" + + conditionjudge "github.com/mazrean/formstream/internal/condition_judge" ) var errTest = errors.New("test error") @@ -84,7 +86,7 @@ func TestConditionJudger(t *testing.T) { hooks: map[string]*mockHook{}, events: []event{ {"key", "field", "", nil, "", "", ""}, - {"hook", "stream", "one", ErrNoHooks, "", "", ""}, + {"hook", "stream", "one", conditionjudge.ErrNoHooks, "", "", ""}, }, }, "no call": { @@ -159,11 +161,11 @@ func TestConditionJudger(t *testing.T) { for name, tt := range tests { t.Run(name, func(t *testing.T) { - hookMap := make(map[string]Hook[string, string, string], len(tt.hooks)) + hookMap := make(map[string]conditionjudge.Hook[string, string, string], len(tt.hooks)) for key, hook := range tt.hooks { hookMap[key] = hook } - cj := NewConditionJudger(hookMap, preProcessFunc) + cj := conditionjudge.NewConditionJudger(hookMap, preProcessFunc) EVENT_LOOP: for _, event := range tt.events { diff --git a/internal/myio/nop.go b/internal/myio/nop.go new file mode 100644 index 0000000..d632439 --- /dev/null +++ b/internal/myio/nop.go @@ -0,0 +1,13 @@ +package myio + +import "io" + +type nopSeekCloser struct { + io.ReadSeeker +} + +func NopSeekCloser(r io.ReadSeeker) io.ReadSeekCloser { + return nopSeekCloser{r} +} + +func (nopSeekCloser) Close() error { return nil } diff --git a/internal/myio/slow_writer.go b/internal/myio/slow_writer.go new file mode 100644 index 0000000..883dc89 --- /dev/null +++ b/internal/myio/slow_writer.go @@ -0,0 +1,17 @@ +package myio + +import ( + "io" + "time" +) + +type slowWriter struct{} + +func SlowWriter() io.Writer { + return &slowWriter{} +} + +func (w *slowWriter) Write(p []byte) (n int, err error) { + time.Sleep(time.Duration(len(p)) * 50 * time.Nanosecond) + return len(p), nil +} diff --git a/parse.go b/parse.go index 540553f..5d4b478 100644 --- a/parse.go +++ b/parse.go @@ -155,16 +155,20 @@ var bufPool = sync.Pool{ } func (pp *preProcessor) run(normalParam *normalParam) (*abnormalParam, error) { - buf := bufPool.Get().(*bytes.Buffer) + buf, ok := bufPool.Get().(*bytes.Buffer) + if !ok { + buf = new(bytes.Buffer) + } buf.Reset() - n, err := io.CopyN(buf, normalParam.r, min(int64(pp.config.maxMemFileSize), int64(pp.config.maxMemSize))+1) + memLimit := min(pp.config.maxMemFileSize, pp.config.maxMemSize) + n, err := io.CopyN(buf, normalParam.r, int64(memLimit)+1) if err != nil && !errors.Is(err, io.EOF) { return nil, fmt.Errorf("failed to copy: %w", err) } var content io.ReadCloser - if n > int64(pp.config.maxMemFileSize) || n > int64(pp.config.maxMemSize) { + if DataSize(n) > memLimit { if pp.file == nil { f, err := os.CreateTemp("", "formstream-") if err != nil { diff --git a/register.go b/register.go index aca5fd3..ae2399b 100644 --- a/register.go +++ b/register.go @@ -6,7 +6,7 @@ import ( func (p *Parser) Register(name string, fn StreamHookFunc, options ...RegisterOption) error { if _, ok := p.hookMap[name]; ok { - return ErrDuplicateHookName{Name: name} + return DuplicateHookNameError{Name: name} } c := ®isterConfig{} @@ -22,11 +22,11 @@ func (p *Parser) Register(name string, fn StreamHookFunc, options ...RegisterOpt return nil } -type ErrDuplicateHookName struct { +type DuplicateHookNameError struct { Name string } -func (e ErrDuplicateHookName) Error() string { +func (e DuplicateHookNameError) Error() string { return fmt.Sprintf("duplicate hook name: %s", e.Name) } diff --git a/scripts/graph.py b/scripts/graph.py index 5c2646b..327e43e 100644 --- a/scripts/graph.py +++ b/scripts/graph.py @@ -10,8 +10,8 @@ def parse_group(group: str): if group.startswith("FormStream"): group = group.removeprefix("FormStream") return f"FormStream({group})" - elif group.startswith("StdMultipart_"): - group = group.split("_")[1] + elif group.startswith("StdMultipart"): + group = group.removeprefix("StdMultipart") return f"std(with {group})" return group