diff --git a/openapi/Swarm.yaml b/openapi/Swarm.yaml index 7f48e32d329..309d9fc11d9 100644 --- a/openapi/Swarm.yaml +++ b/openapi/Swarm.yaml @@ -163,37 +163,12 @@ paths: tags: - Bytes parameters: - - in: header - schema: - $ref: "SwarmCommon.yaml#/components/parameters/SwarmPostageBatchId" - name: swarm-postage-batch-id - required: true - - in: header - schema: - $ref: "SwarmCommon.yaml#/components/parameters/SwarmTagParameter" - name: swarm-tag - required: false - - in: header - schema: - $ref: "SwarmCommon.yaml#/components/parameters/SwarmPinParameter" - name: swarm-pin - required: false - - in: header - schema: - $ref: "SwarmCommon.yaml#/components/parameters/SwarmDeferredUpload" - name: swarm-deferred-upload - required: false - - in: header - schema: - $ref: "SwarmCommon.yaml#/components/parameters/SwarmEncryptParameter" - name: swarm-encrypt - required: false - - in: header - schema: - $ref: "SwarmCommon.yaml#/components/parameters/SwarmRedundancyLevelParameter" - name: swarm-redundancy-level - required: false - + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmPostageBatchId" + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmTagParameter" + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmPinParameter" + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmDeferredUpload" + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmEncryptParameter" + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmRedundancyLevelParameter" requestBody: content: application/octet-stream: diff --git a/pkg/api/api.go b/pkg/api/api.go index 5f747237bc4..e81cab0cf62 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -214,8 +214,9 @@ type Service struct { whitelistedWithdrawalAddress []common.Address - preMapHooks map[string]func(v string) (string, error) - validate *validator.Validate + preMapHooks map[string]func(v string) (string, error) + customValidationMessages map[string]func(err validator.FieldError) error + validate *validator.Validate redistributionAgent *storageincentives.Agent @@ -321,6 +322,7 @@ func New( } return name }) + s.setupValidation() s.stamperStore = stamperStore for _, v := range whitelistedWithdrawalAddress { @@ -709,11 +711,17 @@ func (s *Service) mapStructure(input, output any) func(string, log.Logger, http. case []byte: val = string(v) } + var cause error + if msgFn, ok := s.customValidationMessages[err.Tag()]; ok { + cause = msgFn(err) + } else { + cause = fmt.Errorf("want %s:%s", err.Tag(), err.Param()) + } vErrs = multierror.Append(vErrs, &validationError{ Entry: strings.ToLower(err.Field()), Value: val, - Cause: fmt.Errorf("want %s:%s", err.Tag(), err.Param()), + Cause: cause, }) } return response(vErrs.ErrorOrNil()) diff --git a/pkg/api/bytes.go b/pkg/api/bytes.go index 4c1cd891df0..945e9c3beae 100644 --- a/pkg/api/bytes.go +++ b/pkg/api/bytes.go @@ -40,7 +40,7 @@ func (s *Service) bytesUploadHandler(w http.ResponseWriter, r *http.Request) { Pin bool `map:"Swarm-Pin"` Deferred *bool `map:"Swarm-Deferred-Upload"` Encrypt bool `map:"Swarm-Encrypt"` - RLevel redundancy.Level `map:"Swarm-Redundancy-Level"` + RLevel redundancy.Level `map:"Swarm-Redundancy-Level" validate:"rLevel"` Act bool `map:"Swarm-Act"` HistoryAddress swarm.Address `map:"Swarm-Act-History-Address"` }{} diff --git a/pkg/api/bytes_test.go b/pkg/api/bytes_test.go index 40280e44992..56ec6574565 100644 --- a/pkg/api/bytes_test.go +++ b/pkg/api/bytes_test.go @@ -8,11 +8,13 @@ import ( "bytes" "context" "errors" + "fmt" "net/http" "strconv" "testing" "github.com/ethersphere/bee/v2/pkg/api" + "github.com/ethersphere/bee/v2/pkg/file/redundancy" "github.com/ethersphere/bee/v2/pkg/jsonhttp" "github.com/ethersphere/bee/v2/pkg/jsonhttp/jsonhttptest" "github.com/ethersphere/bee/v2/pkg/log" @@ -408,3 +410,68 @@ func TestBytesDirectUpload(t *testing.T) { }), ) } + +func TestBytesRedundancyLevel(t *testing.T) { + t.Parallel() + + client, _, _, _ := newTestServer(t, testServerOptions{ + Storer: mockstorer.New(), + Post: mockpost.New(mockpost.WithAcceptAll()), + }) + + const maxValidLevel = redundancy.PARANOID + + tests := []struct { + name string + level int + want *jsonhttp.StatusResponse + }{ + {"minimum level (NONE) is valid", int(redundancy.NONE), nil}, + {"maximum valid level (PARANOID) is valid", int(maxValidLevel), nil}, + { + "level below minimum is invalid", int(-1), + &jsonhttp.StatusResponse{ + Code: http.StatusBadRequest, + Message: "invalid header params", + Reasons: []jsonhttp.Reason{ + { + Field: "Swarm-Redundancy-Level", + Error: "invalid syntax", + }, + }, + }, + }, + { + "level above maximum is invalid", int(maxValidLevel + 1), + &jsonhttp.StatusResponse{ + Code: http.StatusBadRequest, + Message: "invalid header params", + Reasons: []jsonhttp.Reason{ + { + Field: "swarm-redundancy-level", + Error: fmt.Sprintf("want redundancy level to be between %d and %d", int(redundancy.NONE), int(redundancy.PARANOID)), + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := []jsonhttptest.Option{ + jsonhttptest.WithRequestHeader(api.SwarmDeferredUploadHeader, "true"), + jsonhttptest.WithRequestHeader(api.SwarmPostageBatchIdHeader, batchOkStr), + jsonhttptest.WithRequestHeader(api.SwarmRedundancyLevelHeader, strconv.Itoa(tt.level)), + jsonhttptest.WithRequestBody(bytes.NewReader([]byte("test"))), + } + var statusCode int + if tt.want == nil { + statusCode = http.StatusCreated + } else { + statusCode = tt.want.Code + opts = append(opts, jsonhttptest.WithExpectedJSONResponse(*tt.want)) + } + jsonhttptest.Request(t, client, http.MethodPost, "/bytes", statusCode, opts...) + }) + } +} diff --git a/pkg/api/bzz.go b/pkg/api/bzz.go index a27e3f813c5..92472efddcb 100644 --- a/pkg/api/bzz.go +++ b/pkg/api/bzz.go @@ -72,7 +72,7 @@ func (s *Service) bzzUploadHandler(w http.ResponseWriter, r *http.Request) { Deferred *bool `map:"Swarm-Deferred-Upload"` Encrypt bool `map:"Swarm-Encrypt"` IsDir bool `map:"Swarm-Collection"` - RLevel redundancy.Level `map:"Swarm-Redundancy-Level"` + RLevel redundancy.Level `map:"Swarm-Redundancy-Level" validate:"rLevel"` Act bool `map:"Swarm-Act"` HistoryAddress swarm.Address `map:"Swarm-Act-History-Address"` }{} @@ -387,7 +387,7 @@ func (s *Service) serveReference(logger log.Logger, address swarm.Address, pathV Cache *bool `map:"Swarm-Cache"` Strategy *getter.Strategy `map:"Swarm-Redundancy-Strategy"` FallbackMode *bool `map:"Swarm-Redundancy-Fallback-Mode"` - RLevel *redundancy.Level `map:"Swarm-Redundancy-Level"` + RLevel *redundancy.Level `map:"Swarm-Redundancy-Level" validate:"omitempty,rLevel"` ChunkRetrievalTimeout *string `map:"Swarm-Chunk-Retrieval-Timeout"` }{} @@ -599,7 +599,7 @@ func (s *Service) serveManifestEntry( func (s *Service) downloadHandler(logger log.Logger, w http.ResponseWriter, r *http.Request, reference swarm.Address, additionalHeaders http.Header, etag, headersOnly bool, rootCh swarm.Chunk) { headers := struct { Strategy *getter.Strategy `map:"Swarm-Redundancy-Strategy"` - RLevel *redundancy.Level `map:"Swarm-Redundancy-Level"` + RLevel *redundancy.Level `map:"Swarm-Redundancy-Level" validate:"omitempty,rLevel"` FallbackMode *bool `map:"Swarm-Redundancy-Fallback-Mode"` ChunkRetrievalTimeout *string `map:"Swarm-Chunk-Retrieval-Timeout"` LookaheadBufferSize *int `map:"Swarm-Lookahead-Buffer-Size"` diff --git a/pkg/api/bzz_test.go b/pkg/api/bzz_test.go index 7f636476d1f..418e58a5ff6 100644 --- a/pkg/api/bzz_test.go +++ b/pkg/api/bzz_test.go @@ -1185,3 +1185,144 @@ func TestBzzDownloadHeaders(t *testing.T) { jsonhttptest.WithExpectedResponseHeader(api.ContentTypeHeader, "text/html; charset=utf-8"), ) } + +func TestBzzUploadRedundancyLevel(t *testing.T) { + t.Parallel() + + client, _, _, _ := newTestServer(t, testServerOptions{ + Storer: mockstorer.New(), + Post: mockpost.New(mockpost.WithAcceptAll()), + }) + + const maxValidLevel = redundancy.PARANOID + + tests := []struct { + name string + level int + want *jsonhttp.StatusResponse + }{ + {"minimum level (NONE) is valid", int(redundancy.NONE), nil}, + {"maximum valid level (PARANOID) is valid", int(maxValidLevel), nil}, + { + "level below minimum is invalid", int(-1), + &jsonhttp.StatusResponse{ + Code: http.StatusBadRequest, + Message: "invalid header params", + Reasons: []jsonhttp.Reason{ + { + Field: "Swarm-Redundancy-Level", + Error: "invalid syntax", + }, + }, + }, + }, + { + "level above maximum is invalid", int(maxValidLevel + 1), + &jsonhttp.StatusResponse{ + Code: http.StatusBadRequest, + Message: "invalid header params", + Reasons: []jsonhttp.Reason{ + { + Field: "swarm-redundancy-level", + Error: fmt.Sprintf("want redundancy level to be between %d and %d", int(redundancy.NONE), int(redundancy.PARANOID)), + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := []jsonhttptest.Option{ + jsonhttptest.WithRequestHeader(api.ContentTypeHeader, "text/plain"), + jsonhttptest.WithRequestHeader(api.SwarmDeferredUploadHeader, "true"), + jsonhttptest.WithRequestHeader(api.SwarmPostageBatchIdHeader, batchOkStr), + jsonhttptest.WithRequestHeader(api.SwarmRedundancyLevelHeader, strconv.Itoa(tt.level)), + jsonhttptest.WithRequestBody(bytes.NewReader([]byte("test"))), + } + var statusCode int + if tt.want == nil { + statusCode = http.StatusCreated + } else { + statusCode = tt.want.Code + opts = append(opts, jsonhttptest.WithExpectedJSONResponse(*tt.want)) + } + jsonhttptest.Request(t, client, http.MethodPost, "/bzz", statusCode, opts...) + }) + } +} + +func TestBzzDownloadRedundancyLevel(t *testing.T) { + t.Parallel() + + client, _, _, _ := newTestServer(t, testServerOptions{ + Storer: mockstorer.New(), + Post: mockpost.New(mockpost.WithAcceptAll()), + }) + + testData := []byte("test download redundancy level") + var resp api.BzzUploadResponse + jsonhttptest.Request(t, client, http.MethodPost, "/bzz", http.StatusCreated, + jsonhttptest.WithRequestHeader(api.ContentTypeHeader, "text/plain"), + jsonhttptest.WithRequestHeader(api.SwarmDeferredUploadHeader, "true"), + jsonhttptest.WithRequestHeader(api.SwarmPostageBatchIdHeader, batchOkStr), + jsonhttptest.WithRequestBody(bytes.NewReader(testData)), + jsonhttptest.WithUnmarshalJSONResponse(&resp), + ) + + const maxValidLevel = redundancy.PARANOID + + tests := []struct { + name string + level int + want *jsonhttp.StatusResponse + }{ + {"minimum level (NONE) is valid", int(redundancy.NONE), nil}, + {"maximum valid level (PARANOID) is valid", int(maxValidLevel), nil}, + { + "level below minimum is invalid", int(-1), + &jsonhttp.StatusResponse{ + Code: http.StatusBadRequest, + Message: "invalid header params", + Reasons: []jsonhttp.Reason{ + { + Field: "Swarm-Redundancy-Level", + Error: "invalid syntax", + }, + }, + }, + }, + { + "level above maximum is invalid", int(maxValidLevel + 1), + &jsonhttp.StatusResponse{ + Code: http.StatusBadRequest, + Message: "invalid header params", + Reasons: []jsonhttp.Reason{ + { + Field: "swarm-redundancy-level", + Error: fmt.Sprintf("want redundancy level to be between %d and %d", int(redundancy.NONE), int(redundancy.PARANOID)), + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := []jsonhttptest.Option{ + jsonhttptest.WithRequestHeader(api.SwarmRedundancyLevelHeader, strconv.Itoa(tt.level)), + } + var statusCode int + if tt.want == nil { + statusCode = http.StatusOK + opts = append(opts, + jsonhttptest.WithExpectedResponse(testData), + ) + } else { + statusCode = tt.want.Code + opts = append(opts, jsonhttptest.WithExpectedJSONResponse(*tt.want)) + } + jsonhttptest.Request(t, client, http.MethodGet, "/bzz/"+resp.Reference.String(), statusCode, opts...) + }) + } +} diff --git a/pkg/api/validation.go b/pkg/api/validation.go new file mode 100644 index 00000000000..2e1422ff414 --- /dev/null +++ b/pkg/api/validation.go @@ -0,0 +1,34 @@ +// Copyright 2026 The Swarm Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package api + +import ( + "fmt" + + "github.com/ethersphere/bee/v2/pkg/file/redundancy" + "github.com/go-playground/validator/v10" +) + +const ( + RedundancyLevelTag = "rLevel" +) + +// setupValidation configures custom validation rules and their custom error messages. +func (s *Service) setupValidation() { + err := s.validate.RegisterValidation(RedundancyLevelTag, func(fl validator.FieldLevel) bool { + level := redundancy.Level(fl.Field().Uint()) + return level.Validate() + }) + if err != nil { + s.logger.Error(err, "failed to register validation") + panic(err) + } + + s.customValidationMessages = map[string]func(err validator.FieldError) error{ + RedundancyLevelTag: func(err validator.FieldError) error { + return fmt.Errorf("want redundancy level to be between %d and %d", int(redundancy.NONE), int(redundancy.PARANOID)) + }, + } +} diff --git a/pkg/file/redundancy/level.go b/pkg/file/redundancy/level.go index 411da15ec98..c4eeacddc9f 100644 --- a/pkg/file/redundancy/level.go +++ b/pkg/file/redundancy/level.go @@ -29,6 +29,11 @@ const ( PARANOID ) +// Validate validates the redundancy level +func (l Level) Validate() bool { + return l >= NONE && l <= PARANOID +} + // GetParities returns number of parities based on appendix F table 5 func (l Level) GetParities(shards int) int { et, err := l.getErasureTable()