feat: add action v2 execution on requests and responses (#7637)

* feat: add execution of targets to grpc calls

* feat: add execution of targets to grpc calls

* feat: add execution of targets to grpc calls

* feat: add execution of targets to grpc calls

* feat: add execution of targets to grpc calls

* feat: add execution of targets to grpc calls

* feat: add execution of targets to grpc calls

* feat: split request and response logic to handle the different context information

* feat: split request and response logic to handle the different context information

* fix: integration test

* fix: import alias

* fix: refactor execution package

* fix: refactor execution interceptor integration and unit tests

* fix: refactor execution interceptor integration and unit tests

* fix: refactor execution interceptor integration and unit tests

* fix: refactor execution interceptor integration and unit tests

* fix: refactor execution interceptor integration and unit tests

* docs: basic documentation for executions and targets

* fix: change order for interceptors

* fix: merge back origin/main

* fix: change target definition command and query side (#7735)

* fix: change target definition command and query side

* fix: correct refactoring name changes

* fix: correct refactoring name changes

* fix: changing execution defintion with target list and type

* fix: changing execution definition with target list and type

* fix: add back search queries for target and include

* fix: projections change for execution with targets suffix table

* fix: projections change for execution with targets suffix table

* fix: projections change for execution with targets suffix table

* fix: projections change for execution with targets suffix table

* fix: projections change for execution with targets suffix table

* fix: projections change for execution with targets suffix table

* fix: projections change for execution with targets suffix table

* docs: add example to actions v2

* docs: add example to actions v2

* fix: correct integration tests on query for executions

* fix: add separate event for execution v2 as content changed

* fix: add separate event for execution v2 as content changed

* fix: added review comment changes

* fix: added review comment changes

* fix: added review comment changes

---------

Co-authored-by: adlerhurst <silvan.reusser@gmail.com>

* fix: added review comment changes

* fix: added review comment changes

* Update internal/api/grpc/server/middleware/execution_interceptor.go

Co-authored-by: Silvan <silvan.reusser@gmail.com>

* fix: added review comment changes

* fix: added review comment changes

* fix: added review comment changes

* fix: added review comment changes

* fix: added review comment changes

* fix: added review comment changes

---------

Co-authored-by: adlerhurst <silvan.reusser@gmail.com>
Co-authored-by: Elio Bischof <elio@zitadel.com>
This commit is contained in:
Stefan Benz
2024-05-04 11:55:57 +02:00
committed by GitHub
parent 7e345444bf
commit 1c5ecba42a
67 changed files with 4397 additions and 1556 deletions

View File

@@ -0,0 +1,122 @@
package execution
import (
"bytes"
"context"
"io"
"net/http"
"time"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/telemetry/tracing"
"github.com/zitadel/zitadel/internal/zerrors"
)
type ContextInfo interface {
GetHTTPRequestBody() []byte
GetContent() interface{}
SetHTTPResponseBody([]byte) error
}
type Target interface {
GetTargetID() string
IsInterruptOnError() bool
GetEndpoint() string
GetTargetType() domain.TargetType
GetTimeout() time.Duration
}
// CallTargets call a list of targets in order with handling of error and responses
func CallTargets(
ctx context.Context,
targets []Target,
info ContextInfo,
) (_ interface{}, err error) {
ctx, span := tracing.NewSpan(ctx)
defer span.EndWithError(err)
for _, target := range targets {
// call the type of target
resp, err := CallTarget(ctx, target, info)
// handle error if interrupt is set
if err != nil && target.IsInterruptOnError() {
return nil, err
}
if len(resp) > 0 {
// error in unmarshalling
if err := info.SetHTTPResponseBody(resp); err != nil {
return nil, err
}
}
}
return info.GetContent(), nil
}
type ContextInfoRequest interface {
GetHTTPRequestBody() []byte
}
// CallTarget call the desired type of target with handling of responses
func CallTarget(
ctx context.Context,
target Target,
info ContextInfoRequest,
) (res []byte, err error) {
ctx, span := tracing.NewSpan(ctx)
defer span.EndWithError(err)
switch target.GetTargetType() {
// get request, ignore response and return request and error for handling in list of targets
case domain.TargetTypeWebhook:
return nil, webhook(ctx, target.GetEndpoint(), target.GetTimeout(), info.GetHTTPRequestBody())
// get request, return response and error
case domain.TargetTypeCall:
return call(ctx, target.GetEndpoint(), target.GetTimeout(), info.GetHTTPRequestBody())
case domain.TargetTypeAsync:
go func(target Target, info ContextInfoRequest) {
if _, err := call(ctx, target.GetEndpoint(), target.GetTimeout(), info.GetHTTPRequestBody()); err != nil {
logging.WithFields("target", target.GetTargetID()).OnError(err).Info(err)
}
}(target, info)
return nil, nil
default:
return nil, zerrors.ThrowInternal(nil, "EXEC-auqnansr2m", "Errors.Execution.Unknown")
}
}
// webhook call a webhook, ignore the response but return the errror
func webhook(ctx context.Context, url string, timeout time.Duration, body []byte) error {
_, err := call(ctx, url, timeout, body)
return err
}
// call function to do a post HTTP request to a desired url with timeout
func call(ctx context.Context, url string, timeout time.Duration, body []byte) (_ []byte, err error) {
ctx, cancel := context.WithTimeout(ctx, timeout)
ctx, span := tracing.NewSpan(ctx)
defer func() {
cancel()
span.EndWithError(err)
}()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
client := http.DefaultClient
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
// Check for success between 200 and 299, redirect 300 to 399 is handled by the client, return error with statusCode >= 400
if resp.StatusCode >= 200 && resp.StatusCode <= 299 {
return io.ReadAll(resp.Body)
}
return nil, zerrors.ThrowUnknown(nil, "EXEC-dra6yamk98", "Errors.Execution.Failed")
}

View File

@@ -0,0 +1,347 @@
package execution
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/zitadel/internal/domain"
)
var _ Target = &mockTarget{}
type mockTarget struct {
InstanceID string
ExecutionID string
TargetID string
TargetType domain.TargetType
Endpoint string
Timeout time.Duration
InterruptOnError bool
}
func (e *mockTarget) GetTargetID() string {
return e.TargetID
}
func (e *mockTarget) IsInterruptOnError() bool {
return e.InterruptOnError
}
func (e *mockTarget) GetEndpoint() string {
return e.Endpoint
}
func (e *mockTarget) GetTargetType() domain.TargetType {
return e.TargetType
}
func (e *mockTarget) GetTimeout() time.Duration {
return e.Timeout
}
func Test_Call(t *testing.T) {
type args struct {
ctx context.Context
timeout time.Duration
sleep time.Duration
method string
body []byte
respBody []byte
statusCode int
}
type res struct {
body []byte
wantErr bool
}
tests := []struct {
name string
args args
res res
}{
{
"not ok status",
args{
ctx: context.Background(),
timeout: time.Minute,
sleep: time.Second,
method: http.MethodPost,
body: []byte("{\"request\": \"values\"}"),
respBody: []byte("{\"response\": \"values\"}"),
statusCode: http.StatusBadRequest,
},
res{
wantErr: true,
},
},
{
"timeout",
args{
ctx: context.Background(),
timeout: time.Second,
sleep: time.Second,
method: http.MethodPost,
body: []byte("{\"request\": \"values\"}"),
respBody: []byte("{\"response\": \"values\"}"),
statusCode: http.StatusOK,
},
res{
wantErr: true,
},
},
{
"ok",
args{
ctx: context.Background(),
timeout: time.Minute,
sleep: time.Second,
method: http.MethodPost,
body: []byte("{\"request\": \"values\"}"),
respBody: []byte("{\"response\": \"values\"}"),
statusCode: http.StatusOK,
},
res{
body: []byte("{\"response\": \"values\"}"),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
respBody, err := testServerCall(t,
tt.args.method,
tt.args.body,
tt.args.sleep,
tt.args.statusCode,
tt.args.respBody,
testCall(tt.args.ctx, tt.args.timeout, tt.args.body),
)
if tt.res.wantErr {
assert.Error(t, err)
assert.Nil(t, respBody)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.res.body, respBody)
}
})
}
}
func testCall(ctx context.Context, timeout time.Duration, body []byte) func(string) ([]byte, error) {
return func(url string) ([]byte, error) {
return call(ctx, url, timeout, body)
}
}
func testCallTarget(ctx context.Context,
target *mockTarget,
info ContextInfoRequest,
) func(string) ([]byte, error) {
return func(url string) (r []byte, err error) {
target.Endpoint = url
return CallTarget(ctx, target, info)
}
}
func testServerCall(
t *testing.T,
method string,
body []byte,
timeout time.Duration,
statusCode int,
respBody []byte,
call func(string) ([]byte, error),
) ([]byte, error) {
handler := func(w http.ResponseWriter, r *http.Request) {
checkRequest(t, r, method, body)
if statusCode != http.StatusOK {
http.Error(w, "error", statusCode)
return
}
time.Sleep(timeout)
w.Header().Set("Content-Type", "application/json")
if _, err := io.WriteString(w, string(respBody)); err != nil {
http.Error(w, "error", http.StatusInternalServerError)
return
}
}
server := httptest.NewServer(http.HandlerFunc(handler))
defer server.Close()
return call(server.URL)
}
func checkRequest(t *testing.T, sent *http.Request, method string, expectedBody []byte) {
sentBody, err := io.ReadAll(sent.Body)
require.NoError(t, err)
require.Equal(t, expectedBody, sentBody)
require.Equal(t, method, sent.Method)
}
var _ ContextInfoRequest = &mockContextInfoRequest{}
type request struct {
Request string `json:"request"`
}
type mockContextInfoRequest struct {
Request *request `json:"request"`
}
func newMockContextInfoRequest(s string) *mockContextInfoRequest {
return &mockContextInfoRequest{&request{s}}
}
func (c *mockContextInfoRequest) GetHTTPRequestBody() []byte {
data, _ := json.Marshal(c)
return data
}
func (c *mockContextInfoRequest) GetContent() []byte {
data, _ := json.Marshal(c.Request)
return data
}
func Test_CallTarget(t *testing.T) {
type args struct {
ctx context.Context
target *mockTarget
sleep time.Duration
info ContextInfoRequest
method string
body []byte
respBody []byte
statusCode int
}
type res struct {
body []byte
wantErr bool
}
tests := []struct {
name string
args args
res res
}{
{
"unknown targettype, error",
args{
ctx: context.Background(),
sleep: time.Second,
method: http.MethodPost,
info: newMockContextInfoRequest("content1"),
target: &mockTarget{
TargetType: 4,
},
body: []byte("{\"request\":{\"request\":\"content1\"}}"),
respBody: []byte("{\"request\":\"content2\"}"),
statusCode: http.StatusInternalServerError,
},
res{
wantErr: true,
},
},
{
"webhook, error",
args{
ctx: context.Background(),
sleep: time.Second,
method: http.MethodPost,
info: newMockContextInfoRequest("content1"),
target: &mockTarget{
TargetType: domain.TargetTypeWebhook,
Timeout: time.Minute,
},
body: []byte("{\"request\":{\"request\":\"content1\"}}"),
respBody: []byte("{\"request\":\"content2\"}"),
statusCode: http.StatusInternalServerError,
},
res{
wantErr: true,
},
},
{
"webhook, ok",
args{
ctx: context.Background(),
sleep: time.Second,
method: http.MethodPost,
info: newMockContextInfoRequest("content1"),
target: &mockTarget{
TargetType: domain.TargetTypeWebhook,
Timeout: time.Minute,
},
body: []byte("{\"request\":{\"request\":\"content1\"}}"),
respBody: []byte("{\"request\":\"content2\"}"),
statusCode: http.StatusOK,
},
res{
body: nil,
},
},
{
"request response, error",
args{
ctx: context.Background(),
sleep: time.Second,
method: http.MethodPost,
info: newMockContextInfoRequest("content1"),
target: &mockTarget{
TargetType: domain.TargetTypeCall,
Timeout: time.Minute,
},
body: []byte("{\"request\":{\"request\":\"content1\"}}"),
respBody: []byte("{\"request\":\"content2\"}"),
statusCode: http.StatusInternalServerError,
},
res{
wantErr: true,
},
},
{
"request response, ok",
args{
ctx: context.Background(),
sleep: time.Second,
method: http.MethodPost,
info: newMockContextInfoRequest("content1"),
target: &mockTarget{
TargetType: domain.TargetTypeCall,
Timeout: time.Minute,
},
body: []byte("{\"request\":{\"request\":\"content1\"}}"),
respBody: []byte("{\"request\":\"content2\"}"),
statusCode: http.StatusOK,
},
res{
body: []byte("{\"request\":\"content2\"}"),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
respBody, err := testServerCall(t,
tt.args.method,
tt.args.body,
tt.args.sleep,
tt.args.statusCode,
tt.args.respBody,
testCallTarget(tt.args.ctx, tt.args.target, tt.args.info),
)
if tt.res.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
assert.Equal(t, tt.res.body, respBody)
})
}
}