Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions pkg/ffapi/apiserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ type APIServerOptions[T any] struct {
VersionedAPIs *VersionedAPIs
MonitoringRoutes []*Route
EnrichRequest func(r *APIRequest) (T, error)
MapError func(ctx context.Context, err error) error // optional: applied to every error before its status is derived
Description string
APIConfig config.Section
MonitoringConfig config.Section
Expand Down Expand Up @@ -304,6 +305,7 @@ func (as *apiServer[T]) handlerFactory(logLevel logrus.Level) *HandlerFactory {
SupportFieldRedaction: as.SupportFieldRedaction,
AlwaysPaginate: as.alwaysPaginate,
HandleYAML: as.handleYAML,
MapError: as.MapError,
}
hf.SetAPIEntryLoggingLevel(logLevel)
return hf
Expand Down
4 changes: 4 additions & 0 deletions pkg/ffapi/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ type HandlerFactory struct {
SupportFieldRedaction bool
BasePath string
BasePathParams []*PathParam
MapError func(ctx context.Context, err error) error // optional error mapping function, runs before status code extraction/setting

apiEntryLoggingLevel logrus.Level // the log level at which entry/exit logging is enabled at all (does not affect trace logging)
}
Expand Down Expand Up @@ -396,6 +397,9 @@ func (hs *HandlerFactory) APIWrapper(handler HandlerFunction) http.HandlerFunc {
status, err := handler(res, req)
durationMS := float64(time.Since(startTime)) / float64(time.Millisecond)
if err != nil {
if hs.MapError != nil {
err = hs.MapError(ctx, err)
}
if ffe, ok := (interface{}(err)).(i18n.FFError); ok {
if logrus.IsLevelEnabled(logrus.DebugLevel) {
l.Debugf("%s:\n%s", ffe.Error(), ffe.StackTrace())
Expand Down
81 changes: 81 additions & 0 deletions pkg/ffapi/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import (
"github.com/gorilla/mux"
"github.com/hyperledger-firefly/common/pkg/config"
"github.com/hyperledger-firefly/common/pkg/httpserver"
"github.com/hyperledger-firefly/common/pkg/i18n"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
)
Expand Down Expand Up @@ -749,3 +750,83 @@ func TestGetFormEmptyValue(t *testing.T) {
_, err := hs.getFormParams(req)
require.NoError(t, err)
}

func TestMapErrorAppliedToEveryError(t *testing.T) {
driverErr := fmt.Errorf("driver: check constraint violated")
hs := newTestHandlerFactory("", nil)
var mapped []error
hs.MapError = func(ctx context.Context, err error) error {
assert.NotNil(t, ctx)
mapped = append(mapped, err)
if err == driverErr {
return i18n.NewError(ctx, i18n.MsgUnknownFieldValue, "thing", "x") // 400
}
return err
}
jsonRoute := &Route{
Name: "json",
Path: "/json",
Method: http.MethodPost,
JSONInputValue: func() interface{} { return make(map[string]interface{}) },
JSONOutputValue: func() interface{} { return make(map[string]interface{}) },
JSONOutputCodes: []int{200},
JSONHandler: func(r *APIRequest) (output interface{}, err error) {
return nil, driverErr
},
}
streamRoute := &Route{
Name: "stream",
Path: "/stream",
Method: http.MethodGet,
JSONInputValue: nil,
JSONOutputCodes: []int{200},
StreamHandler: func(r *APIRequest) (output io.ReadCloser, err error) {
return nil, driverErr
},
}

for _, tc := range []struct {
name string
route *Route
req *http.Request
status int
match string
}{
{"json handler", jsonRoute, httptest.NewRequest(http.MethodPost, "/json", strings.NewReader(`{}`)), 400, "FF00111"},
{"stream handler", streamRoute, httptest.NewRequest(http.MethodGet, "/stream", nil), 400, "FF00111"},
{"input decode", jsonRoute, httptest.NewRequest(http.MethodPost, "/json", strings.NewReader(`{`)), 400, "unexpected EOF"},
} {
t.Run(tc.name, func(t *testing.T) {
mapped = nil
tc.req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
hs.RouteHandler(tc.route)(res, tc.req)
assert.Equal(t, tc.status, res.Code)
assert.Len(t, mapped, 1)
var resJSON map[string]interface{}
require.NoError(t, json.NewDecoder(res.Body).Decode(&resJSON))
assert.Regexp(t, tc.match, resJSON["error"])
})
}
}

func TestMapErrorNotCalledOnSuccess(t *testing.T) {
hs := newTestHandlerFactory("", nil)
hs.MapError = func(ctx context.Context, err error) error {
assert.Fail(t, "MapError called without an error")
return err
}
res := httptest.NewRecorder()
hs.RouteHandler(&Route{
Name: "json",
Path: "/json",
Method: http.MethodGet,
JSONInputValue: nil,
JSONOutputValue: func() interface{} { return make(map[string]interface{}) },
JSONOutputCodes: []int{200},
JSONHandler: func(r *APIRequest) (output interface{}, err error) {
return map[string]interface{}{}, nil
},
})(res, httptest.NewRequest(http.MethodGet, "/json", nil))
assert.Equal(t, 200, res.Code)
}
53 changes: 50 additions & 3 deletions pkg/ffapi/restfilter_json.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@
package ffapi

import (
"bytes"
"context"
"database/sql/driver"
"encoding/json"
"fmt"
"math/big"
"strconv"
"strings"

Expand Down Expand Up @@ -146,13 +148,24 @@ func SkipFieldValidation() *JSONBuildFilterOpt {

func (js *SimpleFilterValue) UnmarshalJSON(b []byte) error {
var v interface{}
err := json.Unmarshal(b, &v)
d := json.NewDecoder(bytes.NewReader(b))
d.UseNumber()
err := d.Decode(&v)
if err != nil {
return err
}
switch vi := v.(type) {
case float64:
*js = (SimpleFilterValue)(strconv.FormatFloat(vi, 'f', -1, 64))
case json.Number:
// Parsed exactly (rather than as a float64) so large values such as a uint256 are not
// rounded, and bounded to 256 bits so an exponent cannot expand into an enormous string
if !boundedLiteral(vi.String()) {
return i18n.NewError(context.Background(), i18n.MsgJSONQueryValueUnsupported, string(b))
}
r, ok := new(big.Rat).SetString(vi.String()) // #nosec G113 - length and exponent bounded by boundedLiteral above
if !ok || r.Num().BitLen() > maxFilterNumberBits || r.Denom().BitLen() > maxFilterNumberBits {
return i18n.NewError(context.Background(), i18n.MsgJSONQueryValueUnsupported, string(b))
}
*js = (SimpleFilterValue)(formatDecimal(r))
return nil
case string:
*js = (SimpleFilterValue)(vi)
Expand All @@ -165,6 +178,40 @@ func (js *SimpleFilterValue) UnmarshalJSON(b []byte) error {
}
}

const (
maxFilterNumberBits = 256
maxFilterNumberDigits = 78 // decimal digits in 2^256
maxFilterNumberLength = maxFilterNumberDigits + maxFilterNumberBits + 8 // integer digits, decimal places (2^-255 needs 255), sign, point and exponent
)

// boundedLiteral reports whether s, a valid JSON number, is short enough and has a small enough
// exponent that the value might fit in maxFilterNumberBits.
// Check before parsing to big.Rat (per gosec G113)
func boundedLiteral(s string) bool {
if len(s) > maxFilterNumberLength {
return false
}
i := strings.IndexAny(s, "eE")
if i < 0 {
return true
}
exp, err := strconv.Atoi(s[i+1:])
limit := maxFilterNumberLength + maxFilterNumberDigits
return err == nil && exp >= -limit && exp <= limit
}

// formatDecimal renders r, which was parsed from a decimal literal, as a plain decimal with
// no exponent and no trailing zeros (so "5.0" and "1e3" arrive as "5" and "1000")
func formatDecimal(r *big.Rat) string {
if r.IsInt() {
return r.Num().String()
}
// The denominator is 2^a*5^b, which terminates within max(a,b) places - and BitLen
// is always at least that
s := r.FloatString(r.Denom().BitLen())
return strings.TrimRight(s, "0")
}

func (js SimpleFilterValue) String() string {
return (string)(js)
}
Expand Down
43 changes: 43 additions & 0 deletions pkg/ffapi/restfilter_json_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"database/sql/driver"
"encoding/json"
"fmt"
"strings"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -777,3 +778,45 @@ func TestBuildQueryAndFail(t *testing.T) {
_, err = qf.BuildFilter(context.Background(), TestQueryFactory)
assert.Regexp(t, "FF00142.*color", err)
}

func TestSimpleFilterValueNumbers(t *testing.T) {
for in, expected := range map[string]string{
`9007199254740993`: "9007199254740993", // 2^53+1, not representable as a float64
`115792089237316195423570985008687907853269984665640564039457584007913129639935`: "115792089237316195423570985008687907853269984665640564039457584007913129639935", // 2^256-1
`-57896044618658097711785492504343953926634992332820282019728792003956564819968`: "-57896044618658097711785492504343953926634992332820282019728792003956564819968", // -2^255
`-12345678901234567890123`: "-12345678901234567890123",
`42`: "42",
`5.0`: "5",
`1e3`: "1000",
`1.5`: "1.5",
`-0.000123`: "-0.000123",
`1.25e-2`: "0.0125",
`0.0009765625`: "0.0009765625", // 1/1024 - more places than denominator digits
`9007199254740993e0`: "9007199254740993",
`123456789012345678901.5`: "123456789012345678901.5",
`1e30`: "1000000000000000000000000000000",
// 2^-255, the most decimal places a value within 256 bits can need
`0.000000000000000000000000000000000000000000000000000000000000000000000000000017272337110188889250772703725600799142232000728872562770047406940337183606324854115943015006944576453121094587892299327193990197893663893387306007554116149549372494220733642578125`: "0.000000000000000000000000000000000000000000000000000000000000000000000000000017272337110188889250772703725600799142232000728872562770047406940337183606324854115943015006944576453121094587892299327193990197893663893387306007554116149549372494220733642578125",
`true`: "true",
`"str"`: "str",
} {
var js SimpleFilterValue
err := json.Unmarshal([]byte(in), &js)
assert.NoError(t, err, in)
assert.Equal(t, expected, js.String(), in)
}

var js SimpleFilterValue
err := js.UnmarshalJSON([]byte(`115792089237316195423570985008687907853269984665640564039457584007913129639936`)) // 2^256
assert.Regexp(t, "FF00241", err)
err = js.UnmarshalJSON([]byte(`1e999999`))
assert.Regexp(t, "FF00241", err)
err = js.UnmarshalJSON([]byte(`1e99999999999999999999`)) // exponent overflows an int
assert.Regexp(t, "FF00241", err)
err = js.UnmarshalJSON([]byte(`1e80`)) // within the exponent bound, but over 256 bits
assert.Regexp(t, "FF00241", err)
err = js.UnmarshalJSON([]byte(strings.Repeat("9", 1_000_000))) // too long to parse
assert.Regexp(t, "FF00241", err)
err = js.UnmarshalJSON([]byte(`1e-400`))
assert.Regexp(t, "FF00241", err)
}
Loading