diff --git a/examples/go.mod b/examples/go.mod index d1e35ca..0e959a6 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -26,9 +26,11 @@ require ( github.com/golang/protobuf v1.5.3 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0 // indirect github.com/nyaruka/phonenumbers v1.0.55 // indirect + github.com/philhofer/fwd v1.1.2 // indirect github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect + github.com/tinylib/msgp v1.1.9 // indirect go.opentelemetry.io/otel/metric v1.23.1 // indirect go.opentelemetry.io/otel/trace v1.23.1 // indirect go.opentelemetry.io/proto/otlp v1.1.0 // indirect diff --git a/examples/go.sum b/examples/go.sum index a7074f2..5bd1c60 100644 --- a/examples/go.sum +++ b/examples/go.sum @@ -34,6 +34,8 @@ github.com/oarkflow/log v1.0.74 h1:ZF+G7ZMO2bHRcNMVovqa3LwkzxsaQqhaFCe92Gwwhtg= github.com/oarkflow/log v1.0.74/go.mod h1:GjB0Np5m9DXTwlS2fpkH5jDsiTMYhD60aG/9UegLNvw= github.com/oarkflow/pkg v0.1.22 h1:jlpBm4b1fuQkgDJEoozk28HkLZpx6qeTPCcU1AApRaQ= github.com/oarkflow/pkg v0.1.22/go.mod h1:nVXPmSHyXzizWnA+auR7yOrg+Sw3z4Qbx9GQTtzEbww= +github.com/philhofer/fwd v1.1.2 h1:bnDivRJ1EWPjUIRXV5KfORO897HTbpFAQddBdE8t7Gw= +github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2tUTP0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee h1:8Iv5m6xEo1NR1AvpV+7XmhI4r39LGNzwUL4YpMuL5vk= @@ -50,6 +52,8 @@ github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JT github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tinylib/msgp v1.1.9 h1:SHf3yoO2sGA0veCJeCBYLHuttAVFHGm2RHgNodW7wQU= +github.com/tinylib/msgp v1.1.9/go.mod h1:BCXGB54lDD8qUEPmiG0cQQUANC4IUQyB2ItS2UDlO/k= go.opentelemetry.io/otel v1.23.1 h1:Za4UzOqJYS+MUczKI320AtqZHZb7EqxO00jAHE0jmQY= go.opentelemetry.io/otel v1.23.1/go.mod h1:Td0134eafDLcTS4y+zQ26GE8u3dEuRBiBCTUIRHaikA= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.23.1 h1:o8iWeVFa1BcLtVEV0LzrCxV2/55tB3xLxADr6Kyoey4= diff --git a/examples/middlewares.go b/examples/middlewares.go new file mode 100644 index 0000000..5dc11b5 --- /dev/null +++ b/examples/middlewares.go @@ -0,0 +1,20 @@ +package main + +import ( + "context" + + "github.com/oarkflow/frame" + "github.com/oarkflow/frame/middlewares/server/idempotency" + "github.com/oarkflow/frame/middlewares/server/requestid" + "github.com/oarkflow/frame/server" +) + +func main() { + srv := server.Default(server.WithHostPorts(":8081")) + srv.Use(requestid.New()) + srv.Use(idempotency.New()) + srv.GET("/", idempotency.New(), func(c context.Context, ctx *frame.Context) { + ctx.JSON(200, "Hello world") + }) + srv.Spin() +} diff --git a/fs.go b/fs.go index 2b57c17..0c91631 100644 --- a/fs.go +++ b/fs.go @@ -1267,3 +1267,17 @@ func NewPathSlashesStripper(slashesCount int) PathRewriteFunc { return stripLeadingSlashes(ctx.Path(), slashesCount) } } + +// IsMethodSafe reports whether the HTTP method is considered safe. +// See https://datatracker.ietf.org/doc/html/rfc9110#section-9.2.1 +func IsMethodSafe(m string) bool { + switch m { + case consts.MethodGet, + consts.MethodHead, + consts.MethodOptions, + consts.MethodTrace: + return true + default: + return false + } +} diff --git a/go.mod b/go.mod index 4998574..d56db27 100644 --- a/go.mod +++ b/go.mod @@ -2,20 +2,20 @@ module github.com/oarkflow/frame go 1.22.0 -replace github.com/bytedance/go-tagexpr/v2 => github.com/sujit-baniya/go-tagexpr/v2 v2.9.13 +replace github.com/bytedance/go-tagexpr/v2 => github.com/sujit-baniya/go-tagexpr/v2 v2.9.14 require ( - github.com/bytedance/go-tagexpr/v2 v2.9.11 + github.com/bytedance/go-tagexpr/v2 v2.0.0-00010101000000-000000000000 github.com/bytedance/gopkg v0.0.0-20240202110943-5e26950c5e57 github.com/c9s/goprocinfo v0.0.0-20210130143923-c95fcf8c64a8 github.com/cloudwego/netpoll v0.5.2-0.20240206071512-faa52638971c github.com/golang-jwt/jwt/v4 v4.5.0 github.com/oarkflow/log v1.0.74 github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee - github.com/shirou/gopsutil/v3 v3.23.12 + github.com/shirou/gopsutil/v3 v3.24.1 github.com/tinylib/msgp v1.1.9 golang.org/x/crypto v0.19.0 - golang.org/x/sync v0.0.0-20210220032951-036812b2e83c + golang.org/x/sync v0.6.0 google.golang.org/protobuf v1.32.0 ) @@ -23,11 +23,9 @@ require ( github.com/andeya/ameda v1.5.3 // indirect github.com/andeya/goutil v1.0.1 // indirect github.com/go-ole/go-ole v1.2.6 // indirect - github.com/klauspost/compress v1.17.6 // indirect + github.com/golang/protobuf v1.5.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect - github.com/oarkflow/errors v0.0.6 // indirect - github.com/oarkflow/phonenumbers v1.2.7 // indirect - github.com/oarkflow/pkg v0.1.22 // indirect + github.com/nyaruka/phonenumbers v1.0.55 // indirect github.com/philhofer/fwd v1.1.2 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/shoenig/go-m1cpu v0.1.6 // indirect @@ -36,7 +34,7 @@ require ( github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect github.com/yusufpapurcu/wmi v1.2.3 // indirect - golang.org/x/net v0.21.0 // indirect + golang.org/x/net v0.10.0 // indirect golang.org/x/sys v0.17.0 // indirect golang.org/x/text v0.14.0 // indirect ) diff --git a/go.sum b/go.sum index 0fb43b6..d26f292 100644 --- a/go.sum +++ b/go.sum @@ -16,22 +16,20 @@ github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.0 h1:LUVKkCeviFUMKqHa4tXIIij/lbhnMbP7Fn5wKdKkRh4= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI= -github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= -github.com/oarkflow/errors v0.0.6 h1:qTBzVblrX6bFbqYLfatsrZHMBPchOZiIE3pfVzh1+k8= -github.com/oarkflow/errors v0.0.6/go.mod h1:UETn0Q55PJ+YUbpR4QImIoBavd6QvJtyW/oeTT7ghZM= +github.com/nyaruka/phonenumbers v1.0.55 h1:bj0nTO88Y68KeUQ/n3Lo2KgK7lM1hF7L9NFuwcCl3yg= +github.com/nyaruka/phonenumbers v1.0.55/go.mod h1:sDaTZ/KPX5f8qyV9qN+hIm+4ZBARJrupC6LuhshJq1U= github.com/oarkflow/log v1.0.74 h1:ZF+G7ZMO2bHRcNMVovqa3LwkzxsaQqhaFCe92Gwwhtg= github.com/oarkflow/log v1.0.74/go.mod h1:GjB0Np5m9DXTwlS2fpkH5jDsiTMYhD60aG/9UegLNvw= -github.com/oarkflow/phonenumbers v1.2.7 h1:r96sroWDGEOpqZYdjDdYIc8tMR4tkopqc+QWC8E83g0= -github.com/oarkflow/phonenumbers v1.2.7/go.mod h1:NCtO2B2L6ZUGb0B7qvEoX8acI6BR0O7hVFluKNrenxo= -github.com/oarkflow/pkg v0.1.22 h1:jlpBm4b1fuQkgDJEoozk28HkLZpx6qeTPCcU1AApRaQ= -github.com/oarkflow/pkg v0.1.22/go.mod h1:nVXPmSHyXzizWnA+auR7yOrg+Sw3z4Qbx9GQTtzEbww= github.com/philhofer/fwd v1.1.2 h1:bnDivRJ1EWPjUIRXV5KfORO897HTbpFAQddBdE8t7Gw= github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2tUTP0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -40,8 +38,8 @@ github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee h1:8Iv5m6xEo1NR1AvpV+7XmhI4r39LGNzwUL4YpMuL5vk= github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee/go.mod h1:qwtSXrKuJh/zsFQ12yEE89xfCrGKK63Rr7ctU/uCo4g= -github.com/shirou/gopsutil/v3 v3.23.12 h1:z90NtUkp3bMtmICZKpC4+WaknU1eXtp5vtbQ11DgpE4= -github.com/shirou/gopsutil/v3 v3.23.12/go.mod h1:1FrWgea594Jp7qmjHUUPlJDTPgcsb9mGnXDxavtikzM= +github.com/shirou/gopsutil/v3 v3.24.1 h1:R3t6ondCEvmARp3wxODhXMTLC/klMa87h2PHUw5m7QI= +github.com/shirou/gopsutil/v3 v3.24.1/go.mod h1:UU7a2MSBQa+kW1uuDq8DeEBS8kmrnQwsv2b5O513rwU= github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= @@ -55,8 +53,8 @@ github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/sujit-baniya/go-tagexpr/v2 v2.9.13 h1://5FC6741WijnRY7Sft362q4veaILNZdiwlH9hWfzqE= -github.com/sujit-baniya/go-tagexpr/v2 v2.9.13/go.mod h1:BdPGP/Ajg9RYc7orlkZ7+bQhpTufc+kw7F/PgVuQKxA= +github.com/sujit-baniya/go-tagexpr/v2 v2.9.14 h1:ZZxMK8s4hFaePHKY167FfQEfyDBVQuiwOpRNchNFNZg= +github.com/sujit-baniya/go-tagexpr/v2 v2.9.14/go.mod h1:6/ql7IOzKXMgUYHPjS/mtCOD9OFRZIwm2GXu/lla5R8= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= @@ -72,10 +70,11 @@ github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQ golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/net v0.0.0-20221014081412-f15817d10f9b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= -golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= -golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= +golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +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-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -83,7 +82,7 @@ golang.org/x/sys v0.0.0-20220110181412-a018aaa089fe/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -92,6 +91,7 @@ golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/middlewares/server/idempotency/config.go b/middlewares/server/idempotency/config.go new file mode 100644 index 0000000..30ea656 --- /dev/null +++ b/middlewares/server/idempotency/config.go @@ -0,0 +1,127 @@ +package idempotency + +import ( + "errors" + "fmt" + "time" + + "github.com/oarkflow/frame" + "github.com/oarkflow/frame/internal/bytesconv" + "github.com/oarkflow/frame/pkg/common/storage" + "github.com/oarkflow/frame/pkg/common/storage/memory" +) + +var ErrInvalidIdempotencyKey = errors.New("invalid idempotency key") + +// Config defines the config for middleware. +type Config struct { + // Next defines a function to skip this middleware when returned true. + // + // Optional. Default: a function which skips the middleware on safe HTTP request method. + Next func(c *frame.Context) bool + + // Lifetime is the maximum lifetime of an idempotency key. + // + // Optional. Default: 30 * time.Minute + Lifetime time.Duration + + // KeyHeader is the name of the header that contains the idempotency key. + // + // Optional. Default: X-Idempotency-Key + KeyHeader string + // KeyHeaderValidate defines a function to validate the syntax of the idempotency header. + // + // Optional. Default: a function which ensures the header is 36 characters long (the size of an UUID). + KeyHeaderValidate func(string) error + + // KeepResponseHeaders is a list of headers that should be kept from the original response. + // + // Optional. Default: nil (to keep all headers) + KeepResponseHeaders []string + + // Lock locks an idempotency key. + // + // Optional. Default: an in-memory locker for this process only. + Lock Locker + + // Storage stores response data by idempotency key. + // + // Optional. Default: an in-memory storage for this process only. + Storage storage.Storage +} + +// ConfigDefault is the default config +var ConfigDefault = Config{ + Next: func(c *frame.Context) bool { + // Skip middleware if the request was done using a safe HTTP method + return frame.IsMethodSafe(bytesconv.B2s(c.Method())) + }, + + Lifetime: 30 * time.Minute, + + KeyHeader: "X-Idempotency-Key", + KeyHeaderValidate: func(k string) error { + if l, wl := len(k), 36; l != wl { // UUID length is 36 chars + return fmt.Errorf("%w: invalid length: %d != %d", ErrInvalidIdempotencyKey, l, wl) + } + + return nil + }, + + KeepResponseHeaders: nil, + + Lock: nil, // Set in configDefault so we don't allocate data here. + + Storage: nil, // Set in configDefault so we don't allocate data here. +} + +// Helper function to set default values +func configDefault(config ...Config) Config { + // Return default config if nothing provided + if len(config) < 1 { + cfg := ConfigDefault + + cfg.Lock = NewMemoryLock() + cfg.Storage = memory.New(memory.Config{ + GCInterval: cfg.Lifetime / 2, // Half the lifetime interval + }) + + return cfg + } + + // Override default config + cfg := config[0] + + // Set default values + + if cfg.Next == nil { + cfg.Next = ConfigDefault.Next + } + + if cfg.Lifetime.Nanoseconds() == 0 { + cfg.Lifetime = ConfigDefault.Lifetime + } + + if cfg.KeyHeader == "" { + cfg.KeyHeader = ConfigDefault.KeyHeader + } + if cfg.KeyHeaderValidate == nil { + cfg.KeyHeaderValidate = ConfigDefault.KeyHeaderValidate + } + + if cfg.KeepResponseHeaders != nil && len(cfg.KeepResponseHeaders) == 0 { + cfg.KeepResponseHeaders = ConfigDefault.KeepResponseHeaders + } + + if cfg.Lock == nil { + cfg.Lock = NewMemoryLock() + } + + if cfg.Storage == nil { + cfg.Storage = memory.New(memory.Config{ + GCInterval: cfg.Lifetime / 2, // Half the lifetime interval + }) + } + + return cfg +} diff --git a/middlewares/server/idempotency/idempotency.go b/middlewares/server/idempotency/idempotency.go new file mode 100644 index 0000000..ed4e55e --- /dev/null +++ b/middlewares/server/idempotency/idempotency.go @@ -0,0 +1,169 @@ +package idempotency + +import ( + "context" + "fmt" + "strings" + + "github.com/oarkflow/log" + + "github.com/oarkflow/frame" + "github.com/oarkflow/frame/internal/bytesconv" + "github.com/oarkflow/frame/pkg/common/utils" +) + +// Inspired by https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-idempotency-key-header-02 +// and https://github.com/penguin-statistics/backend-next/blob/f2f7d5ba54fc8a58f168d153baa17b2ad4a14e45/internal/pkg/middlewares/idempotency.go + +const ( + localsKeyIsFromCache = "idempotency_isfromcache" + localsKeyWasPutToCache = "idempotency_wasputtocache" +) + +func IsFromCache(c *frame.Context) bool { + val, exists := c.Get(localsKeyIsFromCache) + if exists { + return true + } + return val != nil +} + +func WasPutToCache(c *frame.Context) bool { + val, exists := c.Get(localsKeyWasPutToCache) + if exists { + return true + } + return val != nil +} + +func New(config ...Config) frame.HandlerFunc { + // Set default config + cfg := configDefault(config...) + + keepResponseHeadersMap := make(map[string]struct{}, len(cfg.KeepResponseHeaders)) + for _, h := range cfg.KeepResponseHeaders { + keepResponseHeadersMap[strings.ToLower(h)] = struct{}{} + } + + maybeWriteCachedResponse := func(c *frame.Context, key string) (bool, error) { + if val, err := cfg.Storage.Get(key); err != nil { + return false, fmt.Errorf("failed to read response: %w", err) + } else if val != nil { + var res response + if _, err := res.UnmarshalMsg(val); err != nil { + return false, fmt.Errorf("failed to unmarshal response: %w", err) + } + c.SetStatusCode(res.StatusCode) + + for header, vals := range res.Headers { + for _, val := range vals { + c.Header(header, val) + } + } + + if len(res.Body) != 0 { + if _, err := c.Write(res.Body); err != nil { + return true, err + } + } + + c.Set(localsKeyIsFromCache, true) + + return true, nil + } + + return false, nil + } + + return func(ctx context.Context, c *frame.Context) { + // Don't execute middleware if Next returns true + if cfg.Next != nil && cfg.Next(c) { + fmt.Println("Coming here") + c.Next(ctx) + } + fmt.Println(string(c.Path())) + // Don't execute middleware if the idempotency key is empty + val, _ := c.Get(cfg.KeyHeader) + key := utils.CopyString(fmt.Sprintf("%v", val)) + if key == "" { + c.Next(ctx) + } + fmt.Println("Hello") + // Validate key + if err := cfg.KeyHeaderValidate(key); err != nil { + c.AbortWithError(500, err) + return + } + + // First-pass: if the idempotency key is in the storage, get and return the response + if ok, err := maybeWriteCachedResponse(c, key); err != nil { + c.AbortWithError(500, fmt.Errorf("failed to write cached response at fastpath: %w", err)) + return + } else if ok { + return + } + + if err := cfg.Lock.Lock(key); err != nil { + c.AbortWithError(500, fmt.Errorf("failed to lock: %w", err)) + return + } + defer func() { + if err := cfg.Lock.Unlock(key); err != nil { + log.Error().Msgf("[IDEMPOTENCY] failed to unlock key %q: %v", key, err) + } + }() + + // Lock acquired. If the idempotency key now is in the storage, get and return the response + if ok, err := maybeWriteCachedResponse(c, key); err != nil { + c.AbortWithError(500, fmt.Errorf("failed to write cached response while locked: %w", err)) + return + } else if ok { + return + } + + c.Next(ctx) + + // Construct response + res := &response{ + StatusCode: c.Response.StatusCode(), + + Body: utils.CopyBytes(c.Response.Body()), + } + { + headers := make(map[string][]string) + c.VisitAllHeaders(func(key, value []byte) { + k := bytesconv.B2s(key) + headers[k] = c.Response.Header.GetAll(k) + }) + if cfg.KeepResponseHeaders == nil { + // Keep all + res.Headers = headers + } else { + // Filter + res.Headers = make(map[string][]string) + for h := range headers { + if _, ok := keepResponseHeadersMap[utils.ToLower(h)]; ok { + res.Headers[h] = headers[h] + } + } + } + } + + // Marshal response + bs, err := res.MarshalMsg(nil) + if err != nil { + c.AbortWithError(500, fmt.Errorf("failed to marshal response: %w", err)) + return + } + + // Store response + if err := cfg.Storage.Set(key, bs, cfg.Lifetime); err != nil { + c.AbortWithError(500, fmt.Errorf("failed to save response: %w", err)) + return + } + + c.Set(localsKeyWasPutToCache, true) + + return + } +} diff --git a/middlewares/server/idempotency/locker.go b/middlewares/server/idempotency/locker.go new file mode 100644 index 0000000..bf8bf0e --- /dev/null +++ b/middlewares/server/idempotency/locker.go @@ -0,0 +1,53 @@ +package idempotency + +import ( + "sync" +) + +// Locker implements a spinlock for a string key. +type Locker interface { + Lock(key string) error + Unlock(key string) error +} + +type MemoryLock struct { + mu sync.Mutex + + keys map[string]*sync.Mutex +} + +func (l *MemoryLock) Lock(key string) error { + l.mu.Lock() + mu, ok := l.keys[key] + if !ok { + mu = new(sync.Mutex) + l.keys[key] = mu + } + l.mu.Unlock() + + mu.Lock() + + return nil +} + +func (l *MemoryLock) Unlock(key string) error { + l.mu.Lock() + mu, ok := l.keys[key] + l.mu.Unlock() + if !ok { + // This happens if we try to unlock an unknown key + return nil + } + + mu.Unlock() + + return nil +} + +func NewMemoryLock() *MemoryLock { + return &MemoryLock{ + keys: make(map[string]*sync.Mutex), + } +} + +var _ Locker = (*MemoryLock)(nil) diff --git a/middlewares/server/idempotency/response.go b/middlewares/server/idempotency/response.go new file mode 100644 index 0000000..f42d1a3 --- /dev/null +++ b/middlewares/server/idempotency/response.go @@ -0,0 +1,10 @@ +package idempotency + +//go:generate msgp -o=response_msgp.go -io=false -unexported +type response struct { + StatusCode int `msg:"sc"` + + Headers map[string][]string `msg:"hs"` + + Body []byte `msg:"b"` +} diff --git a/middlewares/server/idempotency/response_msgp.go b/middlewares/server/idempotency/response_msgp.go new file mode 100644 index 0000000..410d118 --- /dev/null +++ b/middlewares/server/idempotency/response_msgp.go @@ -0,0 +1,131 @@ +package idempotency + +// Code generated by github.com/tinylib/msgp DO NOT EDIT. + +import ( + "github.com/tinylib/msgp/msgp" +) + +// MarshalMsg implements msgp.Marshaler +func (z *response) MarshalMsg(b []byte) (o []byte, err error) { + o = msgp.Require(b, z.Msgsize()) + // map header, size 3 + // string "sc" + o = append(o, 0x83, 0xa2, 0x73, 0x63) + o = msgp.AppendInt(o, z.StatusCode) + // string "hs" + o = append(o, 0xa2, 0x68, 0x73) + o = msgp.AppendMapHeader(o, uint32(len(z.Headers))) + for za0001, za0002 := range z.Headers { + o = msgp.AppendString(o, za0001) + o = msgp.AppendArrayHeader(o, uint32(len(za0002))) + for za0003 := range za0002 { + o = msgp.AppendString(o, za0002[za0003]) + } + } + // string "b" + o = append(o, 0xa1, 0x62) + o = msgp.AppendBytes(o, z.Body) + return +} + +// UnmarshalMsg implements msgp.Unmarshaler +func (z *response) UnmarshalMsg(bts []byte) (o []byte, err error) { + var field []byte + _ = field + var zb0001 uint32 + zb0001, bts, err = msgp.ReadMapHeaderBytes(bts) + if err != nil { + err = msgp.WrapError(err) + return + } + for zb0001 > 0 { + zb0001-- + field, bts, err = msgp.ReadMapKeyZC(bts) + if err != nil { + err = msgp.WrapError(err) + return + } + switch msgp.UnsafeString(field) { + case "sc": + z.StatusCode, bts, err = msgp.ReadIntBytes(bts) + if err != nil { + err = msgp.WrapError(err, "StatusCode") + return + } + case "hs": + var zb0002 uint32 + zb0002, bts, err = msgp.ReadMapHeaderBytes(bts) + if err != nil { + err = msgp.WrapError(err, "Headers") + return + } + if z.Headers == nil { + z.Headers = make(map[string][]string, zb0002) + } else if len(z.Headers) > 0 { + for key := range z.Headers { + delete(z.Headers, key) + } + } + for zb0002 > 0 { + var za0001 string + var za0002 []string + zb0002-- + za0001, bts, err = msgp.ReadStringBytes(bts) + if err != nil { + err = msgp.WrapError(err, "Headers") + return + } + var zb0003 uint32 + zb0003, bts, err = msgp.ReadArrayHeaderBytes(bts) + if err != nil { + err = msgp.WrapError(err, "Headers", za0001) + return + } + if cap(za0002) >= int(zb0003) { + za0002 = (za0002)[:zb0003] + } else { + za0002 = make([]string, zb0003) + } + for za0003 := range za0002 { + za0002[za0003], bts, err = msgp.ReadStringBytes(bts) + if err != nil { + err = msgp.WrapError(err, "Headers", za0001, za0003) + return + } + } + z.Headers[za0001] = za0002 + } + case "b": + z.Body, bts, err = msgp.ReadBytesBytes(bts, z.Body) + if err != nil { + err = msgp.WrapError(err, "Body") + return + } + default: + bts, err = msgp.Skip(bts) + if err != nil { + err = msgp.WrapError(err) + return + } + } + } + o = bts + return +} + +// Msgsize returns an upper bound estimate of the number of bytes occupied by the serialized message +func (z *response) Msgsize() (s int) { + s = 1 + 3 + msgp.IntSize + 3 + msgp.MapHeaderSize + if z.Headers != nil { + for za0001, za0002 := range z.Headers { + _ = za0002 + s += msgp.StringPrefixSize + len(za0001) + msgp.ArrayHeaderSize + for za0003 := range za0002 { + s += msgp.StringPrefixSize + len(za0002[za0003]) + } + } + } + s += 2 + msgp.BytesPrefixSize + len(z.Body) + return +}