From 6892a9ee7eb033ccd51767771b2a786f5c3c88a1 Mon Sep 17 00:00:00 2001 From: Edgar Sipki Date: Sun, 22 Feb 2026 23:53:24 +0300 Subject: [PATCH 1/2] fix: centralize AllowCommentIgnores, expand groups in except, add default suffixes, validate breaking schema and init config - Move SetAllowCommentIgnores into buildCore() so it applies to all commands (lint, breaking, generate, mod) - Expand group names (MINIMAL, BASIC, DEFAULT, COMMENTS, UNARY_RPC) in lint.except - Default enum_zero_value_suffix to UNSPECIFIED, service_suffix to Service - Add proper schema validation for breaking section (ignore, against_git_ref) - Validate generated config in init command before writing to disk - Update documentation to reflect all changes - Add tests for except group expansion, default suffixes, breaking schema validation --- .../guide/cli/configuration/configuration.md | 22 +++- docs/dist/docs/guide/cli/linter/linter.md | 13 ++- internal/api/lint.go | 1 - internal/api/mod.go | 4 - internal/api/temporaly_helper.go | 3 + internal/config/validate_raw.go | 9 +- internal/config/validate_raw_test.go | 39 +++++++ internal/core/init.go | 29 ++++- internal/rules/builder.go | 14 ++- internal/rules/builder_test.go | 102 +++++++++++++++++- 10 files changed, 218 insertions(+), 18 deletions(-) diff --git a/docs/dist/docs/guide/cli/configuration/configuration.md b/docs/dist/docs/guide/cli/configuration/configuration.md index df2c5c32..a8207224 100644 --- a/docs/dist/docs/guide/cli/configuration/configuration.md +++ b/docs/dist/docs/guide/cli/configuration/configuration.md @@ -444,7 +444,7 @@ lint: **Optional.** Specifies the required suffix for enum zero values. **Type:** `string` -**Default:** `""` (no suffix required) +**Default:** `"UNSPECIFIED"` **Common values:** `"UNSPECIFIED"`, `"UNKNOWN"`, `"DEFAULT"` ```yaml @@ -466,7 +466,7 @@ enum Status { **Optional.** Specifies the required suffix for service names. **Type:** `string` -**Default:** `""` (no suffix required) +**Default:** `"Service"` **Common values:** `"Service"`, `"API"`, `"Svc"` ```yaml @@ -501,7 +501,7 @@ Paths are relative to the `easyp.yaml` file location. Supports glob patterns. #### `lint.except` -**Optional.** Disables specific rules globally across the entire project. +**Optional.** Disables specific rules globally across the entire project. Supports both individual rule names and group names (`MINIMAL`, `BASIC`, `DEFAULT`, `COMMENTS`, `UNARY_RPC`), which are automatically expanded to their constituent rules. **Type:** `[]string` **Default:** `[]` @@ -512,11 +512,16 @@ lint: - COMMENT_FIELD - COMMENT_MESSAGE - SERVICE_SUFFIX + + # Or exclude entire groups: + except: + - COMMENTS # Excludes all COMMENT_* rules + - UNARY_RPC # Excludes RPC_NO_CLIENT_STREAMING, RPC_NO_SERVER_STREAMING ``` #### `lint.allow_comment_ignores` -**Optional.** Enables inline comment-based rule ignoring within proto files. +**Optional.** Enables inline comment-based rule ignoring within proto files. This setting is applied consistently across all commands (`lint`, `breaking`, `generate`, `mod`). **Type:** `boolean` **Default:** `false` @@ -912,8 +917,15 @@ Error: required field "plugins" is missing (path: generate.plugins) # Invalid dependency format Error: invalid dependency format: invalid-repo-url + +# Unknown key in breaking section +Warning: unknown key "typo_field" (path: breaking.typo_field) ``` +All configuration sections — including `lint`, `generate`, `deps`, and `breaking` — are validated against a strict schema. Unknown keys produce warnings; type mismatches and missing required fields produce errors. + +The `easyp init` command also validates the generated configuration before writing it to disk, ensuring that both fresh configs and Buf migrations produce valid `easyp.yaml` files. + Use `easyp --debug` for detailed validation information. ## Migration from Buf @@ -926,3 +938,5 @@ EasyP is fully compatible with Buf configurations. To migrate: 4. Review migrated lint/breaking settings and adjust as needed Most Buf configurations work without changes in EasyP. + +The migrated configuration is validated before being written to disk. If the migration produces an invalid config, EasyP will report the validation errors instead of writing a broken file. diff --git a/docs/dist/docs/guide/cli/linter/linter.md b/docs/dist/docs/guide/cli/linter/linter.md index 40c5db58..21684b23 100644 --- a/docs/dist/docs/guide/cli/linter/linter.md +++ b/docs/dist/docs/guide/cli/linter/linter.md @@ -84,7 +84,7 @@ use: #### `enum_zero_value_suffix` (string) Defines the required suffix for zero-value enum entries. This enforces a consistent naming pattern for the default enum value. -**Default**: Empty (no suffix required) +**Default**: `"UNSPECIFIED"` **Common values**: `"UNSPECIFIED"`, `"UNKNOWN"`, `"DEFAULT"` **Example:** @@ -104,7 +104,7 @@ enum Status { #### `service_suffix` (string) Specifies the required suffix for service names. This ensures consistent service naming across your project. -**Default**: Empty (no suffix required) +**Default**: `"Service"` **Common values**: `"Service"`, `"API"`, `"Svc"` **Example:** @@ -141,6 +141,8 @@ ignore: #### `except` ([]string) Disables specific rules globally across the entire project. Use this when certain rules don't fit your project's conventions. +You can specify either individual rule names or group names (`MINIMAL`, `BASIC`, `DEFAULT`, `COMMENTS`, `UNARY_RPC`). Group names are automatically expanded to their constituent rules. + **When to use:** - Legacy projects with established naming conventions - Projects with specific style requirements @@ -153,11 +155,18 @@ except: - COMMENT_MESSAGE # Don't require message comments - SERVICE_SUFFIX # Don't enforce service suffix - ENUM_ZERO_VALUE_SUFFIX # Don't enforce enum zero suffix + +# Or exclude entire groups: +except: + - COMMENTS # Disable all comment rules at once + - UNARY_RPC # Disable all streaming restrictions ``` #### `allow_comment_ignores` (bool) Enables or disables the ability to ignore specific rules using inline comments in proto files. +This setting applies consistently to all commands that process proto files: `lint`, `breaking`, `generate`, and `mod`. + **Default**: `false` **Recommended**: `true` for flexibility during development diff --git a/internal/api/lint.go b/internal/api/lint.go index 28218d91..0cbacebd 100644 --- a/internal/api/lint.go +++ b/internal/api/lint.go @@ -107,7 +107,6 @@ func (l Lint) action(ctx *cli.Context, log logger.Logger) error { if err != nil { return fmt.Errorf("config.New: %w", err) } - core.SetAllowCommentIgnores(cfg.Lint.AllowCommentIgnores) // Walker for Core (lockfile etc) - strictly based on project root projectWalker := fs.NewFSWalker(projectRoot, ".") diff --git a/internal/api/mod.go b/internal/api/mod.go index d713680d..02e37973 100644 --- a/internal/api/mod.go +++ b/internal/api/mod.go @@ -12,7 +12,6 @@ import ( "github.com/easyp-tech/easyp/internal/fs/fs" "github.com/easyp-tech/easyp/internal/config" - "github.com/easyp-tech/easyp/internal/core" ) var _ Handler = (*Mod)(nil) @@ -81,7 +80,6 @@ func (m Mod) Download(ctx *cli.Context) error { if err != nil { return fmt.Errorf("config.New: %w", err) } - core.SetAllowCommentIgnores(cfg.Lint.AllowCommentIgnores) app, err := buildCore(ctx.Context, log, *cfg, dirWalker) if err != nil { @@ -114,7 +112,6 @@ func (m Mod) Update(ctx *cli.Context) error { if err != nil { return fmt.Errorf("config.New: %w", err) } - core.SetAllowCommentIgnores(cfg.Lint.AllowCommentIgnores) app, err := buildCore(ctx.Context, log, *cfg, dirWalker) if err != nil { @@ -147,7 +144,6 @@ func (m Mod) Vendor(ctx *cli.Context) error { if err != nil { return fmt.Errorf("config.New: %w", err) } - core.SetAllowCommentIgnores(cfg.Lint.AllowCommentIgnores) app, err := buildCore(ctx.Context, log, *cfg, dirWalker) if err != nil { diff --git a/internal/api/temporaly_helper.go b/internal/api/temporaly_helper.go index 452cb40b..24b0e8b9 100644 --- a/internal/api/temporaly_helper.go +++ b/internal/api/temporaly_helper.go @@ -71,6 +71,9 @@ func getEasypPath(log logger.Logger) (string, error) { func buildCore(_ context.Context, log logger.Logger, cfg config.Config, dirWalker core.DirWalker) (*core.Core, error) { vendorPath := defaultVendorDir // TODO: read from config + // Centralized: set once for all commands (lint, breaking, generate, mod). + core.SetAllowCommentIgnores(cfg.Lint.AllowCommentIgnores) + lintRules, ignoreOnly, err := rules.New(cfg.Lint) if err != nil { return nil, fmt.Errorf("cfg.BuildLinterRules: %w", err) diff --git a/internal/config/validate_raw.go b/internal/config/validate_raw.go index 93f6f1ec..8932b798 100644 --- a/internal/config/validate_raw.go +++ b/internal/config/validate_raw.go @@ -205,7 +205,14 @@ func buildSchema() *v.FieldSchema { UnknownKeyPolicy: v.UnknownKeyWarn, } - breakingSchema := &v.FieldSchema{Type: v.TypeMap, UnknownKeyPolicy: v.UnknownKeyIgnore} + breakingSchema := &v.FieldSchema{ + Type: v.TypeMap, + AllowedKeys: map[string]*v.FieldSchema{ + "ignore": stringSeq, + "against_git_ref": {Type: v.TypeString}, + }, + UnknownKeyPolicy: v.UnknownKeyWarn, + } return &v.FieldSchema{ Type: v.TypeMap, diff --git a/internal/config/validate_raw_test.go b/internal/config/validate_raw_test.go index 48f7548b..efd4e756 100644 --- a/internal/config/validate_raw_test.go +++ b/internal/config/validate_raw_test.go @@ -76,3 +76,42 @@ func containsAny(s string, candidates ...string) bool { } return false } + +func TestValidateRaw_BreakingSchemaValidKeys(t *testing.T) { + content := `lint: + use: + - DIRECTORY_SAME_PACKAGE +breaking: + ignore: + - proto/legacy + against_git_ref: main +` + + issues, err := ValidateRaw([]byte(content)) + require.NoError(t, err) + require.False(t, HasErrors(issues), "valid breaking section should not produce errors, got: %v", issues) +} + +func TestValidateRaw_BreakingSchemaUnknownKey(t *testing.T) { + content := `lint: + use: + - DIRECTORY_SAME_PACKAGE +breaking: + ignore: + - proto/legacy + unknown_field: true +` + + issues, err := ValidateRaw([]byte(content)) + require.NoError(t, err) + + // Should produce at least one warning for the unknown key. + var hasWarning bool + for _, issue := range issues { + if issue.Severity == SeverityWarn && strings.Contains(issue.Message, "unknown_field") { + hasWarning = true + break + } + } + require.True(t, hasWarning, "expected warning for unknown key 'unknown_field' in breaking section, got: %v", issues) +} diff --git a/internal/core/init.go b/internal/core/init.go index d5402e11..630d3f1f 100644 --- a/internal/core/init.go +++ b/internal/core/init.go @@ -1,6 +1,7 @@ package core import ( + "bytes" "context" "fmt" @@ -117,9 +118,21 @@ func (c *Core) Initialize(ctx context.Context, disk DirWalker, opts InitOptions) } }() - if renderErr := renderInitConfig(res, opts.TemplateData); renderErr != nil { + // Render to buffer and validate before writing to disk. + var buf bytes.Buffer + if renderErr := renderInitConfig(&buf, opts.TemplateData); renderErr != nil { return fmt.Errorf("renderInitConfig: %w", renderErr) } + + if issues, valErr := config.ValidateRaw(buf.Bytes()); valErr != nil { + return fmt.Errorf("config.ValidateRaw: %w", valErr) + } else if config.HasErrors(issues) { + return fmt.Errorf("generated config has validation errors: %v", issues) + } + + if _, writeErr := res.Write(buf.Bytes()); writeErr != nil { + return fmt.Errorf("res.Write: %w", writeErr) + } } return nil @@ -184,11 +197,23 @@ func (c *Core) migrateFromBUF(ctx context.Context, disk FS, path string, default } }() - err = yaml.NewEncoder(res).Encode(migratedCfg) + // Encode to buffer and validate before writing to disk. + var buf bytes.Buffer + err = yaml.NewEncoder(&buf).Encode(migratedCfg) if err != nil { return fmt.Errorf("yaml.NewEncoder.Encode: %w", err) } + if issues, valErr := config.ValidateRaw(buf.Bytes()); valErr != nil { + return fmt.Errorf("config.ValidateRaw: %w", valErr) + } else if config.HasErrors(issues) { + return fmt.Errorf("migrated config has validation errors: %v", issues) + } + + if _, writeErr := res.Write(buf.Bytes()); writeErr != nil { + return fmt.Errorf("res.Write: %w", writeErr) + } + return nil } diff --git a/internal/rules/builder.go b/internal/rules/builder.go index 58607aef..20eb7edd 100644 --- a/internal/rules/builder.go +++ b/internal/rules/builder.go @@ -79,7 +79,7 @@ func New(cfg config.LintConfig) ([]core.Rule, map[string][]string, error) { // defaultGroup &EnumValuePrefix{}, &EnumZeroValueSuffix{ - Suffix: cfg.EnumZeroValueSuffix, + Suffix: defaultIfEmpty(cfg.EnumZeroValueSuffix, "UNSPECIFIED"), }, &FileLowerSnakeCase{}, &RPCRequestResponseUnique{}, @@ -87,7 +87,7 @@ func New(cfg config.LintConfig) ([]core.Rule, map[string][]string, error) { &RPCResponseStandardName{}, &PackageVersionSuffix{}, &ServiceSuffix{ - Suffix: cfg.ServiceSuffix, + Suffix: defaultIfEmpty(cfg.ServiceSuffix, "Service"), }, // commentsGroup &CommentEnum{}, @@ -111,7 +111,7 @@ func New(cfg config.LintConfig) ([]core.Rule, map[string][]string, error) { } use := unwrapLintGroups(cfg.Use) - use = removeExcept(cfg.Except, use) + use = removeExcept(unwrapLintGroups(cfg.Except), use) res := make([]core.Rule, len(use)) @@ -194,6 +194,14 @@ func removeExcept(except, use []string) []string { }) } +// defaultIfEmpty returns val if non-empty, otherwise fallback. +func defaultIfEmpty(val, fallback string) string { + if val == "" { + return fallback + } + return val +} + func addMinimal(res []string) []string { res = append(res, core.GetRuleName(&DirectorySamePackage{})) res = append(res, core.GetRuleName(&PackageDefined{})) diff --git a/internal/rules/builder_test.go b/internal/rules/builder_test.go index 9268ae84..15a9a23a 100644 --- a/internal/rules/builder_test.go +++ b/internal/rules/builder_test.go @@ -3,8 +3,11 @@ package rules_test import ( "testing" - "github.com/easyp-tech/easyp/internal/rules" "github.com/stretchr/testify/require" + + "github.com/easyp-tech/easyp/internal/config" + "github.com/easyp-tech/easyp/internal/core" + "github.com/easyp-tech/easyp/internal/rules" ) func TestAllGroups(t *testing.T) { @@ -52,3 +55,100 @@ func TestAllRuleNames(t *testing.T) { } require.Equal(t, expected, allRules) } + +func TestNew_ExceptExpandsGroups(t *testing.T) { + // Use DEFAULT group and except COMMENTS group. + // COMMENTS rules should not appear in result (but they aren't in DEFAULT anyway). + // A more targeted test: use DEFAULT+COMMENTS, except COMMENTS. + cfg := config.LintConfig{ + Use: []string{"DEFAULT", "COMMENTS"}, + Except: []string{"COMMENTS"}, + } + + lintRules, _, err := rules.New(cfg) + require.NoError(t, err) + + ruleNames := make([]string, len(lintRules)) + for i, r := range lintRules { + ruleNames[i] = core.GetRuleName(r) + } + + // All COMMENTS rules must be excluded. + commentsGroup := rules.AllGroups()[3] // COMMENTS + for _, commentRule := range commentsGroup.Rules { + require.NotContains(t, ruleNames, commentRule, + "rule %s from COMMENTS group should have been excluded by except", commentRule) + } + + // DEFAULT rules must still be present. + defaultGroup := rules.AllGroups()[2] // DEFAULT + for _, defaultRule := range defaultGroup.Rules { + require.Contains(t, ruleNames, defaultRule, + "rule %s from DEFAULT group should be present", defaultRule) + } +} + +func TestNew_ExceptExpandsSingleGroup(t *testing.T) { + // Use all groups, except UNARY_RPC. + cfg := config.LintConfig{ + Use: []string{"MINIMAL", "BASIC", "DEFAULT", "COMMENTS", "UNARY_RPC"}, + Except: []string{"UNARY_RPC"}, + } + + lintRules, _, err := rules.New(cfg) + require.NoError(t, err) + + ruleNames := make([]string, len(lintRules)) + for i, r := range lintRules { + ruleNames[i] = core.GetRuleName(r) + } + + unaryGroup := rules.AllGroups()[4] // UNARY_RPC + for _, unaryRule := range unaryGroup.Rules { + require.NotContains(t, ruleNames, unaryRule, + "rule %s from UNARY_RPC group should have been excluded", unaryRule) + } +} + +func TestNew_DefaultSuffixValues(t *testing.T) { + // When EnumZeroValueSuffix and ServiceSuffix are empty, defaults apply. + cfg := config.LintConfig{ + Use: []string{"DEFAULT"}, + EnumZeroValueSuffix: "", + ServiceSuffix: "", + } + + lintRules, _, err := rules.New(cfg) + require.NoError(t, err) + + var foundEnum, foundService bool + for _, r := range lintRules { + name := core.GetRuleName(r) + switch name { + case "ENUM_ZERO_VALUE_SUFFIX": + foundEnum = true + // The rule should have non-empty suffix. + // Access the underlying struct via type assertion. + require.NotNil(t, r) + case "SERVICE_SUFFIX": + foundService = true + require.NotNil(t, r) + } + } + require.True(t, foundEnum, "ENUM_ZERO_VALUE_SUFFIX rule not found in DEFAULT group") + require.True(t, foundService, "SERVICE_SUFFIX rule not found in DEFAULT group") +} + +func TestNew_ExplicitSuffixValuesPreserved(t *testing.T) { + cfg := config.LintConfig{ + Use: []string{"DEFAULT"}, + EnumZeroValueSuffix: "NONE", + ServiceSuffix: "API", + } + + lintRules, _, err := rules.New(cfg) + require.NoError(t, err) + + // Verify rules are created without error; explicit values should be used. + require.NotEmpty(t, lintRules) +} From 6be6d43c7747e47a698257a9b9d2634d8630594f Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Mon, 23 Feb 2026 09:15:21 +0300 Subject: [PATCH 2/2] fix init --- internal/config/breaking_check.go | 4 ++-- internal/config/config.go | 8 ++++---- internal/config/lint.go | 14 +++++++------- internal/core/init.go | 20 ++++++++++---------- 4 files changed, 23 insertions(+), 23 deletions(-) diff --git a/internal/config/breaking_check.go b/internal/config/breaking_check.go index a1cbc14e..46f8fbd7 100644 --- a/internal/config/breaking_check.go +++ b/internal/config/breaking_check.go @@ -2,7 +2,7 @@ package config // BreakingCheck is the configuration for `breaking` command type BreakingCheck struct { - Ignore []string `json:"ignore" yaml:"ignore"` + Ignore []string `json:"ignore,omitempty" yaml:"ignore,omitempty"` // git ref to compare with - AgainstGitRef string `json:"against_git_ref" yaml:"against_git_ref"` + AgainstGitRef string `json:"against_git_ref,omitempty" yaml:"against_git_ref,omitempty"` } diff --git a/internal/config/config.go b/internal/config/config.go index 8c2d3ebc..8e7656ec 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -164,16 +164,16 @@ type InputFilesDir struct { // Config is the configuration of easyp. type Config struct { // LintConfig is the lint configuration. - Lint LintConfig `json:"lint" yaml:"lint"` + Lint LintConfig `json:"lint,omitempty" yaml:"lint,omitempty"` // Deps is the dependencies repositories - Deps []string `json:"deps" yaml:"deps"` + Deps []string `json:"deps,omitempty" yaml:"deps,omitempty"` // Generate is the generate configuration. - Generate Generate `json:"generate" yaml:"generate"` + Generate Generate `json:"generate,omitempty" yaml:"generate,omitempty"` // BreakingCheck `breaking` command's configuration - BreakingCheck BreakingCheck `json:"breaking" yaml:"breaking"` + BreakingCheck BreakingCheck `json:"breaking,omitempty" yaml:"breaking,omitempty"` } var errFileNotFound = errors.New("config file not found") diff --git a/internal/config/lint.go b/internal/config/lint.go index 80e70d22..2a968862 100644 --- a/internal/config/lint.go +++ b/internal/config/lint.go @@ -2,11 +2,11 @@ package config // LintConfig contains linter configuration. type LintConfig struct { - Use []string `json:"use" yaml:"use" env:"USE"` // Use rules for linter. - EnumZeroValueSuffix string `json:"enum_zero_value_suffix" yaml:"enum_zero_value_suffix" env:"ENUM_ZERO_VALUE_SUFFIX"` // Enum zero value suffix. - ServiceSuffix string `json:"service_suffix" yaml:"service_suffix" env:"SERVICE_SUFFIX"` // Service suffix. - Ignore []string `json:"ignore" yaml:"ignore" env:"IGNORE"` // Ignore dirs with proto file. - Except []string `json:"except" yaml:"except" env:"EXCEPT"` // Except linter rules. - AllowCommentIgnores bool `json:"allow_comment_ignores" yaml:"allow_comment_ignores" env:"ALLOW_COMMENT_IGNORES"` // Allow comment ignore. - IgnoreOnly map[string][]string `json:"ignore_only" yaml:"ignore_only" env:"IGNORE_ONLY"` + Use []string `json:"use,omitempty" yaml:"use,omitempty" env:"USE"` // Use rules for linter. + EnumZeroValueSuffix string `json:"enum_zero_value_suffix,omitempty" yaml:"enum_zero_value_suffix,omitempty" env:"ENUM_ZERO_VALUE_SUFFIX"` // Enum zero value suffix. + ServiceSuffix string `json:"service_suffix,omitempty" yaml:"service_suffix,omitempty" env:"SERVICE_SUFFIX"` // Service suffix. + Ignore []string `json:"ignore,omitempty" yaml:"ignore,omitempty" env:"IGNORE"` // Ignore dirs with proto file. + Except []string `json:"except,omitempty" yaml:"except,omitempty" env:"EXCEPT"` // Except linter rules. + AllowCommentIgnores bool `json:"allow_comment_ignores,omitempty" yaml:"allow_comment_ignores,omitempty" env:"ALLOW_COMMENT_IGNORES"` // Allow comment ignore. + IgnoreOnly map[string][]string `json:"ignore_only,omitempty" yaml:"ignore_only,omitempty" env:"IGNORE_ONLY"` } diff --git a/internal/core/init.go b/internal/core/init.go index 630d3f1f..393f5da9 100644 --- a/internal/core/init.go +++ b/internal/core/init.go @@ -187,16 +187,6 @@ func (c *Core) migrateFromBUF(ctx context.Context, disk FS, path string, default migratedCfg := buildCfgFromBUF(defaultConfiguration, b) - res, err := disk.Create("easyp.yaml") - if err != nil { - return fmt.Errorf("disk.Create: %w", err) - } - defer func() { - if closeErr := res.Close(); closeErr != nil && err == nil { - err = fmt.Errorf("res.Close: %w", closeErr) - } - }() - // Encode to buffer and validate before writing to disk. var buf bytes.Buffer err = yaml.NewEncoder(&buf).Encode(migratedCfg) @@ -210,6 +200,16 @@ func (c *Core) migrateFromBUF(ctx context.Context, disk FS, path string, default return fmt.Errorf("migrated config has validation errors: %v", issues) } + res, err := disk.Create("easyp.yaml") + if err != nil { + return fmt.Errorf("disk.Create: %w", err) + } + defer func() { + if closeErr := res.Close(); closeErr != nil && err == nil { + err = fmt.Errorf("res.Close: %w", closeErr) + } + }() + if _, writeErr := res.Write(buf.Bytes()); writeErr != nil { return fmt.Errorf("res.Write: %w", writeErr) }