diff --git a/MAINTAINERS.md b/MAINTAINERS.md index f618c14..54af970 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -1,3 +1,5 @@ # Serverless Workflow Go SDK Maintainers -* [Ricardo Zanini](https://github.com/ricardozanini) \ No newline at end of file +* [Ricardo Zanini](https://github.com/ricardozanini) +* [Filippe Spolti](https://github.com/spolti) +* \ No newline at end of file diff --git a/OWNERS b/OWNERS index 61177ce..bc971b8 100644 --- a/OWNERS +++ b/OWNERS @@ -1,6 +1,7 @@ # List of usernames who may use /lgtm reviewers: - ricardozanini +- spolti # List of usernames who may use /approve approvers: diff --git a/README.md b/README.md index 0f3870b..64ead0c 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Current status of features implemented in the SDK is listed in the table below: | [v1.0.0](https://github.com/serverlessworkflow/sdk-go/releases/tag/v1.0.0) | [v0.5](https://github.com/serverlessworkflow/specification/tree/0.5.x) | | [v2.0.1](https://github.com/serverlessworkflow/sdk-go/releases/tag/v2.0.1) | [v0.6](https://github.com/serverlessworkflow/specification/tree/0.6.x) | | [v2.1.2](https://github.com/serverlessworkflow/sdk-go/releases/tag/v2.1.2) | [v0.7](https://github.com/serverlessworkflow/specification/tree/0.7.x) | -| [v2.2.2](https://github.com/serverlessworkflow/sdk-go/releases/tag/v2.2.2) | [v0.8](https://github.com/serverlessworkflow/specification/tree/0.8.x) | +| [v2.2.3](https://github.com/serverlessworkflow/sdk-go/releases/tag/v2.2.2) | [v0.8](https://github.com/serverlessworkflow/specification/tree/0.8.x) | ## How to use diff --git a/hack/deepcopy-gen.sh b/hack/deepcopy-gen.sh index 353a682..f8d30f3 100755 --- a/hack/deepcopy-gen.sh +++ b/hack/deepcopy-gen.sh @@ -43,6 +43,6 @@ if [ "${GENS}" = "all" ] || grep -qw "deepcopy" <<<"${GENS}"; then # for debug purposes, increase the log level by updating the -v flag to higher numbers, e.g. -v 4 "${GOPATH}/bin/deepcopy-gen" -v 1 \ --input-dirs ./model -O zz_generated.deepcopy \ - --go-header-file "${SCRIPT_ROOT}/hack/boilerplate.txt" \ + --go-header-file "${SCRIPT_ROOT}/hack/boilerplate.txt" "$@" fi diff --git a/model/action.go b/model/action.go index 5037ed1..a8d5705 100644 --- a/model/action.go +++ b/model/action.go @@ -14,6 +14,8 @@ package model +import "github.com/serverlessworkflow/sdk-go/v2/util" + // Action specify invocations of services or other workflows during workflow execution. type Action struct { // Defines Unique action identifier. @@ -61,7 +63,7 @@ type actionUnmarshal Action // UnmarshalJSON implements json.Unmarshaler func (a *Action) UnmarshalJSON(data []byte) error { a.ApplyDefault() - return unmarshalObject("action", data, (*actionUnmarshal)(a)) + return util.UnmarshalObject("action", data, (*actionUnmarshal)(a)) } // ApplyDefault set the default values for Action @@ -93,7 +95,7 @@ type functionRefUnmarshal FunctionRef // UnmarshalJSON implements json.Unmarshaler func (f *FunctionRef) UnmarshalJSON(data []byte) error { f.ApplyDefault() - return unmarshalPrimitiveOrObject("functionRef", data, &f.RefName, (*functionRefUnmarshal)(f)) + return util.UnmarshalPrimitiveOrObject("functionRef", data, &f.RefName, (*functionRefUnmarshal)(f)) } // ApplyDefault set the default values for Function Ref @@ -117,5 +119,5 @@ type sleepUnmarshal Sleep // UnmarshalJSON implements json.Unmarshaler func (s *Sleep) UnmarshalJSON(data []byte) error { - return unmarshalObject("sleep", data, (*sleepUnmarshal)(s)) + return util.UnmarshalObject("sleep", data, (*sleepUnmarshal)(s)) } diff --git a/model/action_data_filter.go b/model/action_data_filter.go index 16f1615..060f12f 100644 --- a/model/action_data_filter.go +++ b/model/action_data_filter.go @@ -14,6 +14,8 @@ package model +import "github.com/serverlessworkflow/sdk-go/v2/util" + // ActionDataFilter used to filter action data results. // +optional // +optional @@ -40,7 +42,7 @@ type actionDataFilterUnmarshal ActionDataFilter // UnmarshalJSON implements json.Unmarshaler func (a *ActionDataFilter) UnmarshalJSON(data []byte) error { a.ApplyDefault() - return unmarshalObject("actionDataFilter", data, (*actionDataFilterUnmarshal)(a)) + return util.UnmarshalObject("actionDataFilter", data, (*actionDataFilterUnmarshal)(a)) } // ApplyDefault set the default values for Action Data Filter diff --git a/model/action_data_filter_validator_test.go b/model/action_data_filter_validator_test.go new file mode 100644 index 0000000..df52da0 --- /dev/null +++ b/model/action_data_filter_validator_test.go @@ -0,0 +1,22 @@ +// Copyright 2022 The Serverless Workflow Specification Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package model + +import "testing" + +func TestActionDataFilterStructLevelValidation(t *testing.T) { + testCases := []ValidationCase{} + StructLevelValidationCtx(t, testCases) +} diff --git a/model/action_test.go b/model/action_test.go index 5d0c7fb..55c399d 100644 --- a/model/action_test.go +++ b/model/action_test.go @@ -19,81 +19,8 @@ import ( "testing" "github.com/stretchr/testify/assert" - - val "github.com/serverlessworkflow/sdk-go/v2/validator" ) -func TestSleepValidate(t *testing.T) { - type testCase struct { - desp string - sleep Sleep - err string - } - testCases := []testCase{ - { - desp: "all field empty", - sleep: Sleep{ - Before: "", - After: "", - }, - err: ``, - }, - { - desp: "only before field", - sleep: Sleep{ - Before: "PT5M", - After: "", - }, - err: ``, - }, - { - desp: "only after field", - sleep: Sleep{ - Before: "", - After: "PT5M", - }, - err: ``, - }, - { - desp: "all field", - sleep: Sleep{ - Before: "PT5M", - After: "PT5M", - }, - err: ``, - }, - { - desp: "invalid before value", - sleep: Sleep{ - Before: "T5M", - After: "PT5M", - }, - err: `Key: 'Sleep.Before' Error:Field validation for 'Before' failed on the 'iso8601duration' tag`, - }, - { - desp: "invalid after value", - sleep: Sleep{ - Before: "PT5M", - After: "T5M", - }, - err: `Key: 'Sleep.After' Error:Field validation for 'After' failed on the 'iso8601duration' tag`, - }, - } - for _, tc := range testCases { - t.Run(tc.desp, func(t *testing.T) { - err := val.GetValidator().Struct(tc.sleep) - - if tc.err != "" { - assert.Error(t, err) - assert.Regexp(t, tc.err, err) - return - } - - assert.NoError(t, err) - }) - } -} - func TestFunctionRefUnmarshalJSON(t *testing.T) { type testCase struct { desp string diff --git a/model/action_validator.go b/model/action_validator.go new file mode 100644 index 0000000..384469b --- /dev/null +++ b/model/action_validator.go @@ -0,0 +1,58 @@ +// Copyright 2022 The Serverless Workflow Specification Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package model + +import ( + validator "github.com/go-playground/validator/v10" + + val "github.com/serverlessworkflow/sdk-go/v2/validator" +) + +func init() { + val.GetValidator().RegisterStructValidationCtx(ValidationWrap(actionStructLevelValidationCtx), Action{}) + val.GetValidator().RegisterStructValidationCtx(ValidationWrap(functionRefStructLevelValidation), FunctionRef{}) +} + +func actionStructLevelValidationCtx(ctx ValidatorContext, structLevel validator.StructLevel) { + action := structLevel.Current().Interface().(Action) + + if action.FunctionRef == nil && action.EventRef == nil && action.SubFlowRef == nil { + structLevel.ReportError(action.FunctionRef, "FunctionRef", "FunctionRef", "required_without", "") + return + } + + values := []bool{ + action.FunctionRef != nil, + action.EventRef != nil, + action.SubFlowRef != nil, + } + + if validationNotExclusiveParamters(values) { + structLevel.ReportError(action.FunctionRef, "FunctionRef", "FunctionRef", val.TagExclusive, "") + structLevel.ReportError(action.EventRef, "EventRef", "EventRef", val.TagExclusive, "") + structLevel.ReportError(action.SubFlowRef, "SubFlowRef", "SubFlowRef", val.TagExclusive, "") + } + + if action.RetryRef != "" && !ctx.ExistRetry(action.RetryRef) { + structLevel.ReportError(action.RetryRef, "RetryRef", "RetryRef", val.TagExists, "") + } +} + +func functionRefStructLevelValidation(ctx ValidatorContext, structLevel validator.StructLevel) { + functionRef := structLevel.Current().Interface().(FunctionRef) + if !ctx.ExistFunction(functionRef.RefName) { + structLevel.ReportError(functionRef.RefName, "RefName", "RefName", val.TagExists, functionRef.RefName) + } +} diff --git a/model/action_validator_test.go b/model/action_validator_test.go new file mode 100644 index 0000000..5445f7b --- /dev/null +++ b/model/action_validator_test.go @@ -0,0 +1,200 @@ +// Copyright 2022 The Serverless Workflow Specification Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package model + +import ( + "testing" +) + +func buildActionByOperationState(state *State, name string) *Action { + action := Action{ + Name: name, + } + + state.OperationState.Actions = append(state.OperationState.Actions, action) + return &state.OperationState.Actions[len(state.OperationState.Actions)-1] +} + +func buildActionByForEachState(state *State, name string) *Action { + action := Action{ + Name: name, + } + + state.ForEachState.Actions = append(state.ForEachState.Actions, action) + return &state.ForEachState.Actions[len(state.ForEachState.Actions)-1] +} + +func buildActionByBranch(branch *Branch, name string) *Action { + action := Action{ + Name: name, + } + + branch.Actions = append(branch.Actions, action) + return &branch.Actions[len(branch.Actions)-1] +} + +func buildFunctionRef(workflow *Workflow, action *Action, name string) (*FunctionRef, *Function) { + function := Function{ + Name: name, + Operation: "http://function/function_name", + Type: FunctionTypeREST, + } + + functionRef := FunctionRef{ + RefName: name, + Invoke: InvokeKindSync, + } + action.FunctionRef = &functionRef + + workflow.Functions = append(workflow.Functions, function) + return &functionRef, &function +} + +func buildRetryRef(workflow *Workflow, action *Action, name string) { + retry := Retry{ + Name: name, + } + + workflow.Retries = append(workflow.Retries, retry) + action.RetryRef = name +} + +func buildSleep(action *Action) *Sleep { + action.Sleep = &Sleep{ + Before: "PT5S", + After: "PT5S", + } + return action.Sleep +} + +func TestActionStructLevelValidation(t *testing.T) { + baseWorkflow := buildWorkflow() + + operationState := buildOperationState(baseWorkflow, "start state") + buildEndByState(operationState, true, false) + action1 := buildActionByOperationState(operationState, "action 1") + buildFunctionRef(baseWorkflow, action1, "function 1") + + testCases := []ValidationCase{ + { + Desp: "success", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + return *model + }, + }, + { + Desp: "require_without", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].OperationState.Actions[0].FunctionRef = nil + return *model + }, + Err: `workflow.states[0].actions[0].functionRef required when "eventRef" or "subFlowRef" is not defined`, + }, + { + Desp: "exclude", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + buildEventRef(model, &model.States[0].OperationState.Actions[0], "event 1", "event2") + return *model + }, + Err: `workflow.states[0].actions[0].functionRef exclusive +workflow.states[0].actions[0].eventRef exclusive +workflow.states[0].actions[0].subFlowRef exclusive`, + }, + { + Desp: "oneofkind", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].OperationState.Actions[0].FunctionRef.Invoke = InvokeKindSync + "invalid" + return *model + }, + Err: `workflow.states[0].actions[0].functionRef.invoke need by one of [sync async]`, + }, + } + + StructLevelValidationCtx(t, testCases) +} + +func TestFunctionRefStructLevelValidation(t *testing.T) { + baseWorkflow := buildWorkflow() + + operationState := buildOperationState(baseWorkflow, "start state") + buildEndByState(operationState, true, false) + action1 := buildActionByOperationState(operationState, "action 1") + buildFunctionRef(baseWorkflow, action1, "function 1") + + testCases := []ValidationCase{ + { + Desp: "success", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + return *model + }, + }, + { + Desp: "exists", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].OperationState.Actions[0].FunctionRef.RefName = "invalid function" + return *model + }, + Err: `workflow.states[0].actions[0].functionRef.refName don't exist "invalid function"`, + }, + } + StructLevelValidationCtx(t, testCases) +} + +func TestSleepStructLevelValidation(t *testing.T) { + baseWorkflow := buildWorkflow() + + operationState := buildOperationState(baseWorkflow, "start state") + buildEndByState(operationState, true, false) + action1 := buildActionByOperationState(operationState, "action 1") + buildSleep(action1) + buildFunctionRef(baseWorkflow, action1, "function 1") + + testCases := []ValidationCase{ + { + Desp: "success", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + return *model + }, + }, + { + Desp: "omitempty", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].OperationState.Actions[0].Sleep.Before = "" + model.States[0].OperationState.Actions[0].Sleep.After = "" + return *model + }, + }, + { + Desp: "iso8601duration", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].OperationState.Actions[0].Sleep.Before = "P5S" + model.States[0].OperationState.Actions[0].Sleep.After = "P5S" + return *model + }, + Err: `workflow.states[0].actions[0].sleep.before invalid iso8601 duration "P5S" +workflow.states[0].actions[0].sleep.after invalid iso8601 duration "P5S"`, + }, + } + StructLevelValidationCtx(t, testCases) +} diff --git a/model/auth.go b/model/auth.go index 9646633..6632265 100644 --- a/model/auth.go +++ b/model/auth.go @@ -18,11 +18,25 @@ import ( "encoding/json" "fmt" "strings" + + "github.com/serverlessworkflow/sdk-go/v2/util" ) // AuthType can be "basic", "bearer", or "oauth2". Default is "basic" type AuthType string +func (i AuthType) KindValues() []string { + return []string{ + string(AuthTypeBasic), + string(AuthTypeBearer), + string(AuthTypeOAuth2), + } +} + +func (i AuthType) String() string { + return string(i) +} + const ( // AuthTypeBasic ... AuthTypeBasic AuthType = "basic" @@ -35,6 +49,18 @@ const ( // GrantType ... type GrantType string +func (i GrantType) KindValues() []string { + return []string{ + string(GrantTypePassword), + string(GrantTypeClientCredentials), + string(GrantTypeTokenExchange), + } +} + +func (i GrantType) String() string { + return string(i) +} + const ( // GrantTypePassword ... GrantTypePassword GrantType = "password" @@ -55,7 +81,7 @@ type Auth struct { // +kubebuilder:validation:Enum=basic;bearer;oauth2 // +kubebuilder:default=basic // +kubebuilder:validation:Required - Scheme AuthType `json:"scheme" validate:"min=1"` + Scheme AuthType `json:"scheme" validate:"required,oneofkind"` // Auth scheme properties. Can be one of "Basic properties definition", "Bearer properties definition", // or "OAuth2 properties definition" // +kubebuilder:validation:Required @@ -71,7 +97,7 @@ func (a *Auth) UnmarshalJSON(data []byte) error { PropertiesRaw json.RawMessage `json:"properties"` }{} - err := unmarshalObjectOrFile("auth", data, &authTmp) + err := util.UnmarshalObjectOrFile("auth", data, &authTmp) if err != nil { return err } @@ -84,13 +110,13 @@ func (a *Auth) UnmarshalJSON(data []byte) error { switch a.Scheme { case AuthTypeBasic: a.Properties.Basic = &BasicAuthProperties{} - return unmarshalObject("properties", authTmp.PropertiesRaw, a.Properties.Basic) + return util.UnmarshalObject("properties", authTmp.PropertiesRaw, a.Properties.Basic) case AuthTypeBearer: a.Properties.Bearer = &BearerAuthProperties{} - return unmarshalObject("properties", authTmp.PropertiesRaw, a.Properties.Bearer) + return util.UnmarshalObject("properties", authTmp.PropertiesRaw, a.Properties.Bearer) case AuthTypeOAuth2: a.Properties.OAuth2 = &OAuth2AuthProperties{} - return unmarshalObject("properties", authTmp.PropertiesRaw, a.Properties.OAuth2) + return util.UnmarshalObject("properties", authTmp.PropertiesRaw, a.Properties.OAuth2) default: return fmt.Errorf("failed to parse auth properties") } @@ -162,7 +188,7 @@ type OAuth2AuthProperties struct { // Defines the grant type. Can be "password", "clientCredentials", or "tokenExchange" // +kubebuilder:validation:Enum=password;clientCredentials;tokenExchange // +kubebuilder:validation:Required - GrantType GrantType `json:"grantType" validate:"required"` + GrantType GrantType `json:"grantType" validate:"required,oneofkind"` // String or a workflow expression. Contains the client identifier. // +kubebuilder:validation:Required ClientID string `json:"clientId" validate:"required"` diff --git a/model/auth_validator_test.go b/model/auth_validator_test.go new file mode 100644 index 0000000..e2ce55d --- /dev/null +++ b/model/auth_validator_test.go @@ -0,0 +1,210 @@ +// Copyright 2021 The Serverless Workflow Specification Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package model + +import "testing" + +func buildAuth(workflow *Workflow, name string) *Auth { + auth := Auth{ + Name: name, + Scheme: AuthTypeBasic, + } + workflow.Auth = append(workflow.Auth, auth) + return &workflow.Auth[len(workflow.Auth)-1] +} + +func buildBasicAuthProperties(auth *Auth) *BasicAuthProperties { + auth.Scheme = AuthTypeBasic + auth.Properties = AuthProperties{ + Basic: &BasicAuthProperties{ + Username: "username", + Password: "password", + }, + } + + return auth.Properties.Basic +} + +func buildOAuth2AuthProperties(auth *Auth) *OAuth2AuthProperties { + auth.Scheme = AuthTypeOAuth2 + auth.Properties = AuthProperties{ + OAuth2: &OAuth2AuthProperties{ + ClientID: "clientId", + GrantType: GrantTypePassword, + }, + } + + return auth.Properties.OAuth2 +} + +func buildBearerAuthProperties(auth *Auth) *BearerAuthProperties { + auth.Scheme = AuthTypeBearer + auth.Properties = AuthProperties{ + Bearer: &BearerAuthProperties{ + Token: "token", + }, + } + + return auth.Properties.Bearer +} + +func TestAuthStructLevelValidation(t *testing.T) { + baseWorkflow := buildWorkflow() + auth := buildAuth(baseWorkflow, "auth 1") + buildBasicAuthProperties(auth) + + operationState := buildOperationState(baseWorkflow, "start state") + buildEndByState(operationState, true, false) + action1 := buildActionByOperationState(operationState, "action 1") + buildFunctionRef(baseWorkflow, action1, "function 1") + + testCases := []ValidationCase{ + { + Desp: "success", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + return *model + }, + }, + { + Desp: "required", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.Auth[0].Name = "" + return *model + }, + Err: `workflow.auth[0].name is required`, + }, + { + Desp: "repeat", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.Auth = append(model.Auth, model.Auth[0]) + return *model + }, + Err: `workflow.auth has duplicate "name"`, + }, + } + StructLevelValidationCtx(t, testCases) +} + +func TestBasicAuthPropertiesStructLevelValidation(t *testing.T) { + baseWorkflow := buildWorkflow() + auth := buildAuth(baseWorkflow, "auth 1") + buildBasicAuthProperties(auth) + + operationState := buildOperationState(baseWorkflow, "start state") + buildEndByState(operationState, true, false) + action1 := buildActionByOperationState(operationState, "action 1") + buildFunctionRef(baseWorkflow, action1, "function 1") + + testCases := []ValidationCase{ + { + Desp: "success", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + return *model + }, + }, + { + Desp: "required", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.Auth[0].Properties.Basic.Username = "" + model.Auth[0].Properties.Basic.Password = "" + return *model + }, + Err: `workflow.auth[0].properties.basic.username is required +workflow.auth[0].properties.basic.password is required`, + }, + } + + StructLevelValidationCtx(t, testCases) +} + +func TestBearerAuthPropertiesStructLevelValidation(t *testing.T) { + baseWorkflow := buildWorkflow() + auth := buildAuth(baseWorkflow, "auth 1") + buildBearerAuthProperties(auth) + + operationState := buildOperationState(baseWorkflow, "start state") + buildEndByState(operationState, true, false) + action1 := buildActionByOperationState(operationState, "action 1") + buildFunctionRef(baseWorkflow, action1, "function 1") + + testCases := []ValidationCase{ + { + Desp: "success", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + return *model + }, + }, + { + Desp: "required", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.Auth[0].Properties.Bearer.Token = "" + return *model + }, + Err: `workflow.auth[0].properties.bearer.token is required`, + }, + } + + StructLevelValidationCtx(t, testCases) +} + +func TestOAuth2AuthPropertiesPropertiesStructLevelValidation(t *testing.T) { + baseWorkflow := buildWorkflow() + auth := buildAuth(baseWorkflow, "auth 1") + buildOAuth2AuthProperties(auth) + + operationState := buildOperationState(baseWorkflow, "start state") + buildEndByState(operationState, true, false) + action1 := buildActionByOperationState(operationState, "action 1") + buildFunctionRef(baseWorkflow, action1, "function 1") + + testCases := []ValidationCase{ + { + Desp: "success", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + return *model + }, + }, + { + Desp: "required", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.Auth[0].Properties.OAuth2.GrantType = "" + model.Auth[0].Properties.OAuth2.ClientID = "" + return *model + }, + Err: `workflow.auth[0].properties.oAuth2.grantType is required +workflow.auth[0].properties.oAuth2.clientID is required`, + }, + { + Desp: "oneofkind", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.Auth[0].Properties.OAuth2.GrantType = GrantTypePassword + "invalid" + return *model + }, + Err: `workflow.auth[0].properties.oAuth2.grantType need by one of [password clientCredentials tokenExchange]`, + }, + } + + StructLevelValidationCtx(t, testCases) +} diff --git a/model/callback_state.go b/model/callback_state.go index f35ec38..1dadcb6 100644 --- a/model/callback_state.go +++ b/model/callback_state.go @@ -22,7 +22,7 @@ import ( type CallbackState struct { // Defines the action to be executed. // +kubebuilder:validation:Required - Action Action `json:"action" validate:"required"` + Action Action `json:"action"` // References a unique callback event name in the defined workflow events. // +kubebuilder:validation:Required EventRef string `json:"eventRef" validate:"required"` diff --git a/model/callback_state_test.go b/model/callback_state_test.go deleted file mode 100644 index 9e3e856..0000000 --- a/model/callback_state_test.go +++ /dev/null @@ -1,96 +0,0 @@ -// Copyright 2022 The Serverless Workflow Specification Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package model - -import ( - "testing" - - "github.com/stretchr/testify/assert" - - val "github.com/serverlessworkflow/sdk-go/v2/validator" -) - -func TestCallbackStateStructLevelValidation(t *testing.T) { - type testCase struct { - desp string - callbackStateObj State - err string - } - testCases := []testCase{ - { - desp: "normal", - callbackStateObj: State{ - BaseState: BaseState{ - Name: "callbackTest", - Type: StateTypeCallback, - End: &End{ - Terminate: true, - }, - }, - CallbackState: &CallbackState{ - Action: Action{ - ID: "1", - Name: "action1", - }, - EventRef: "refExample", - }, - }, - err: ``, - }, - { - desp: "missing required EventRef", - callbackStateObj: State{ - BaseState: BaseState{ - Name: "callbackTest", - Type: StateTypeCallback, - }, - CallbackState: &CallbackState{ - Action: Action{ - ID: "1", - Name: "action1", - }, - }, - }, - err: `Key: 'State.CallbackState.EventRef' Error:Field validation for 'EventRef' failed on the 'required' tag`, - }, - // TODO need to register custom types - will be fixed by https://github.com/serverlessworkflow/sdk-go/issues/151 - //{ - // desp: "missing required Action", - // callbackStateObj: State{ - // BaseState: BaseState{ - // Name: "callbackTest", - // Type: StateTypeCallback, - // }, - // CallbackState: &CallbackState{ - // EventRef: "refExample", - // }, - // }, - // err: `Key: 'State.CallbackState.Action' Error:Field validation for 'Action' failed on the 'required' tag`, - //}, - } - for _, tc := range testCases { - t.Run(tc.desp, func(t *testing.T) { - err := val.GetValidator().Struct(&tc.callbackStateObj) - - if tc.err != "" { - assert.Error(t, err) - assert.Regexp(t, tc.err, err) - return - } - - assert.NoError(t, err) - }) - } -} diff --git a/model/callback_state_validator_test.go b/model/callback_state_validator_test.go new file mode 100644 index 0000000..a89cea9 --- /dev/null +++ b/model/callback_state_validator_test.go @@ -0,0 +1,116 @@ +// Copyright 2022 The Serverless Workflow Specification Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package model + +import ( + "testing" +) + +func buildCallbackState(workflow *Workflow, name, eventRef string) *State { + consumeEvent := Event{ + Name: eventRef, + Type: "event type", + Kind: EventKindProduced, + } + workflow.Events = append(workflow.Events, consumeEvent) + + state := State{ + BaseState: BaseState{ + Name: name, + Type: StateTypeCallback, + }, + CallbackState: &CallbackState{ + EventRef: eventRef, + }, + } + workflow.States = append(workflow.States, state) + + return &workflow.States[len(workflow.States)-1] +} + +func buildCallbackStateTimeout(callbackState *CallbackState) *CallbackStateTimeout { + callbackState.Timeouts = &CallbackStateTimeout{} + return callbackState.Timeouts +} + +func TestCallbackStateStructLevelValidation(t *testing.T) { + baseWorkflow := buildWorkflow() + + callbackState := buildCallbackState(baseWorkflow, "start state", "event 1") + buildEndByState(callbackState, true, false) + buildFunctionRef(baseWorkflow, &callbackState.Action, "function 1") + + testCases := []ValidationCase{ + { + Desp: "success", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + return *model + }, + }, + { + Desp: "required", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].CallbackState.EventRef = "" + return *model + }, + Err: `workflow.states[0].callbackState.eventRef is required`, + }, + } + + StructLevelValidationCtx(t, testCases) +} + +func TestCallbackStateTimeoutStructLevelValidation(t *testing.T) { + baseWorkflow := buildWorkflow() + + callbackState := buildCallbackState(baseWorkflow, "start state", "event 1") + buildEndByState(callbackState, true, false) + buildCallbackStateTimeout(callbackState.CallbackState) + buildFunctionRef(baseWorkflow, &callbackState.Action, "function 1") + + testCases := []ValidationCase{ + { + Desp: `success`, + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + return *model + }, + }, + { + Desp: `omitempty`, + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].CallbackState.Timeouts.ActionExecTimeout = "" + model.States[0].CallbackState.Timeouts.EventTimeout = "" + return *model + }, + }, + { + Desp: "iso8601duration", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].CallbackState.Timeouts.ActionExecTimeout = "P5S" + model.States[0].CallbackState.Timeouts.EventTimeout = "P5S" + return *model + }, + Err: `workflow.states[0].callbackState.timeouts.actionExecTimeout invalid iso8601 duration "P5S" +workflow.states[0].callbackState.timeouts.eventTimeout invalid iso8601 duration "P5S"`, + }, + } + + StructLevelValidationCtx(t, testCases) +} diff --git a/model/delay_state_test.go b/model/delay_state_test.go index 79f49e5..c960f3c 100644 --- a/model/delay_state_test.go +++ b/model/delay_state_test.go @@ -13,76 +13,3 @@ // limitations under the License. package model - -import ( - "testing" - - "github.com/stretchr/testify/assert" - - val "github.com/serverlessworkflow/sdk-go/v2/validator" -) - -func TestDelayStateStructLevelValidation(t *testing.T) { - type testCase struct { - desp string - delayStateObj State - err string - } - testCases := []testCase{ - { - desp: "normal", - delayStateObj: State{ - BaseState: BaseState{ - Name: "1", - Type: "delay", - End: &End{ - Terminate: true, - }, - }, - DelayState: &DelayState{ - TimeDelay: "PT5S", - }, - }, - err: ``, - }, - { - desp: "missing required timeDelay", - delayStateObj: State{ - BaseState: BaseState{ - Name: "1", - Type: "delay", - }, - DelayState: &DelayState{ - TimeDelay: "", - }, - }, - err: `Key: 'State.DelayState.TimeDelay' Error:Field validation for 'TimeDelay' failed on the 'required' tag`, - }, - { - desp: "invalid timeDelay duration", - delayStateObj: State{ - BaseState: BaseState{ - Name: "1", - Type: "delay", - }, - DelayState: &DelayState{ - TimeDelay: "P5S", - }, - }, - err: `Key: 'State.DelayState.TimeDelay' Error:Field validation for 'TimeDelay' failed on the 'iso8601duration' tag`, - }, - } - for _, tc := range testCases { - t.Run(tc.desp, func(t *testing.T) { - err := val.GetValidator().Struct(tc.delayStateObj) - - if tc.err != "" { - assert.Error(t, err) - assert.Regexp(t, tc.err, err) - return - } - - assert.NoError(t, err) - }) - } -} diff --git a/model/delay_state_validator_test.go b/model/delay_state_validator_test.go new file mode 100644 index 0000000..aed36c5 --- /dev/null +++ b/model/delay_state_validator_test.go @@ -0,0 +1,68 @@ +// Copyright 2022 The Serverless Workflow Specification Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package model + +import "testing" + +func buildDelayState(workflow *Workflow, name, timeDelay string) *State { + state := State{ + BaseState: BaseState{ + Name: name, + Type: StateTypeDelay, + }, + DelayState: &DelayState{ + TimeDelay: timeDelay, + }, + } + workflow.States = append(workflow.States, state) + + return &workflow.States[len(workflow.States)-1] +} + +func TestDelayStateStructLevelValidation(t *testing.T) { + baseWorkflow := buildWorkflow() + delayState := buildDelayState(baseWorkflow, "start state", "PT5S") + buildEndByState(delayState, true, false) + + testCases := []ValidationCase{ + { + Desp: "success", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + return *model + }, + }, + { + Desp: "required", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].DelayState.TimeDelay = "" + return *model + }, + Err: `workflow.states[0].delayState.timeDelay is required`, + }, + { + Desp: "iso8601duration", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].DelayState.TimeDelay = "P5S" + return *model + }, + Err: `workflow.states[0].delayState.timeDelay invalid iso8601 duration "P5S"`, + }, + } + + StructLevelValidationCtx(t, testCases) +} diff --git a/model/event.go b/model/event.go index 08545c5..a9c5a69 100644 --- a/model/event.go +++ b/model/event.go @@ -14,9 +14,22 @@ package model +import "github.com/serverlessworkflow/sdk-go/v2/util" + // EventKind defines this event as either `consumed` or `produced` type EventKind string +func (i EventKind) KindValues() []string { + return []string{ + string(EventKindConsumed), + string(EventKindProduced), + } +} + +func (i EventKind) String() string { + return string(i) +} + const ( // EventKindConsumed means the event continuation of workflow instance execution EventKindConsumed EventKind = "consumed" @@ -40,14 +53,14 @@ type Event struct { // Defines the CloudEvent as either 'consumed' or 'produced' by the workflow. Defaults to `consumed`. // +kubebuilder:validation:Enum=consumed;produced // +kubebuilder:default=consumed - Kind EventKind `json:"kind,omitempty"` + Kind EventKind `json:"kind,omitempty" validate:"required,oneofkind"` // If `true`, only the Event payload is accessible to consuming Workflow states. If `false`, both event payload // and context attributes should be accessible. Defaults to true. // +optional DataOnly bool `json:"dataOnly,omitempty"` // Define event correlation rules for this event. Only used for consumed events. // +optional - Correlation []Correlation `json:"correlation,omitempty" validate:"omitempty,dive"` + Correlation []Correlation `json:"correlation,omitempty" validate:"dive"` } type eventUnmarshal Event @@ -55,7 +68,7 @@ type eventUnmarshal Event // UnmarshalJSON unmarshal Event object from json bytes func (e *Event) UnmarshalJSON(data []byte) error { e.ApplyDefault() - return unmarshalObject("event", data, (*eventUnmarshal)(e)) + return util.UnmarshalObject("event", data, (*eventUnmarshal)(e)) } // ApplyDefault set the default values for Event @@ -105,7 +118,7 @@ type eventRefUnmarshal EventRef // UnmarshalJSON implements json.Unmarshaler func (e *EventRef) UnmarshalJSON(data []byte) error { e.ApplyDefault() - return unmarshalObject("eventRef", data, (*eventRefUnmarshal)(e)) + return util.UnmarshalObject("eventRef", data, (*eventRefUnmarshal)(e)) } // ApplyDefault set the default values for Event Ref diff --git a/model/event_data_filter.go b/model/event_data_filter.go index a69c7d3..a725a1b 100644 --- a/model/event_data_filter.go +++ b/model/event_data_filter.go @@ -14,6 +14,8 @@ package model +import "github.com/serverlessworkflow/sdk-go/v2/util" + // EventDataFilter used to filter consumed event payloads. type EventDataFilter struct { // If set to false, event payload is not added/merged to state data. In this case 'data' and 'toStateData' @@ -34,7 +36,7 @@ type eventDataFilterUnmarshal EventDataFilter // UnmarshalJSON implements json.Unmarshaler func (f *EventDataFilter) UnmarshalJSON(data []byte) error { f.ApplyDefault() - return unmarshalObject("eventDataFilter", data, (*eventDataFilterUnmarshal)(f)) + return util.UnmarshalObject("eventDataFilter", data, (*eventDataFilterUnmarshal)(f)) } // ApplyDefault set the default values for Event Data Filter diff --git a/model/event_data_filter_validator_test.go b/model/event_data_filter_validator_test.go new file mode 100644 index 0000000..1bbbac9 --- /dev/null +++ b/model/event_data_filter_validator_test.go @@ -0,0 +1,22 @@ +// Copyright 2022 The Serverless Workflow Specification Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package model + +import "testing" + +func TestEventDataFilterStateStructLevelValidation(t *testing.T) { + testCases := []ValidationCase{} + StructLevelValidationCtx(t, testCases) +} diff --git a/model/event_state.go b/model/event_state.go index 1d6235a..37d3840 100644 --- a/model/event_state.go +++ b/model/event_state.go @@ -16,6 +16,8 @@ package model import ( "encoding/json" + + "github.com/serverlessworkflow/sdk-go/v2/util" ) // EventState await one or more events and perform actions when they are received. If defined as the @@ -53,7 +55,7 @@ type eventStateUnmarshal EventState // UnmarshalJSON unmarshal EventState object from json bytes func (e *EventState) UnmarshalJSON(data []byte) error { e.ApplyDefault() - return unmarshalObject("eventState", data, (*eventStateUnmarshal)(e)) + return util.UnmarshalObject("eventState", data, (*eventStateUnmarshal)(e)) } // ApplyDefault set the default values for Event State @@ -69,10 +71,10 @@ type OnEvents struct { // Should actions be performed sequentially or in parallel. Default is sequential. // +kubebuilder:validation:Enum=sequential;parallel // +kubebuilder:default=sequential - ActionMode ActionMode `json:"actionMode,omitempty" validate:"required,oneof=sequential parallel"` + ActionMode ActionMode `json:"actionMode,omitempty" validate:"required,oneofkind"` // Actions to be performed if expression matches // +optional - Actions []Action `json:"actions,omitempty" validate:"omitempty,dive"` + Actions []Action `json:"actions,omitempty" validate:"dive"` // eventDataFilter defines the callback event data filter definition // +optional EventDataFilter EventDataFilter `json:"eventDataFilter,omitempty"` @@ -83,7 +85,7 @@ type onEventsUnmarshal OnEvents // UnmarshalJSON unmarshal OnEvents object from json bytes func (o *OnEvents) UnmarshalJSON(data []byte) error { o.ApplyDefault() - return unmarshalObject("onEvents", data, (*onEventsUnmarshal)(o)) + return util.UnmarshalObject("onEvents", data, (*onEventsUnmarshal)(o)) } // ApplyDefault set the default values for On Events diff --git a/model/event_state_validator.go b/model/event_state_validator.go new file mode 100644 index 0000000..d4f2f40 --- /dev/null +++ b/model/event_state_validator.go @@ -0,0 +1,39 @@ +// Copyright 2022 The Serverless Workflow Specification Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package model + +import ( + validator "github.com/go-playground/validator/v10" + + val "github.com/serverlessworkflow/sdk-go/v2/validator" +) + +func init() { + val.GetValidator().RegisterStructValidationCtx(ValidationWrap(eventStateStructLevelValidationCtx), EventState{}) + val.GetValidator().RegisterStructValidationCtx(ValidationWrap(onEventsStructLevelValidationCtx), OnEvents{}) +} + +func eventStateStructLevelValidationCtx(ctx ValidatorContext, structLevel validator.StructLevel) { + // EventRefs +} + +func onEventsStructLevelValidationCtx(ctx ValidatorContext, structLevel validator.StructLevel) { + onEvent := structLevel.Current().Interface().(OnEvents) + for _, eventRef := range onEvent.EventRefs { + if eventRef != "" && !ctx.ExistEvent(eventRef) { + structLevel.ReportError(eventRef, "eventRefs", "EventRefs", val.TagExists, "") + } + } +} diff --git a/model/event_state_validator_test.go b/model/event_state_validator_test.go new file mode 100644 index 0000000..ea7d319 --- /dev/null +++ b/model/event_state_validator_test.go @@ -0,0 +1,189 @@ +// Copyright 2022 The Serverless Workflow Specification Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package model + +import "testing" + +func buildEventState(workflow *Workflow, name string) *State { + state := State{ + BaseState: BaseState{ + Name: name, + Type: StateTypeEvent, + }, + EventState: &EventState{}, + } + + workflow.States = append(workflow.States, state) + return &workflow.States[len(workflow.States)-1] +} + +func buildOnEvents(workflow *Workflow, state *State, name string) *OnEvents { + event := Event{ + Name: name, + Type: "type", + Kind: EventKindProduced, + } + workflow.Events = append(workflow.Events, event) + + state.EventState.OnEvents = append(state.EventState.OnEvents, OnEvents{ + EventRefs: []string{event.Name}, + ActionMode: ActionModeParallel, + }) + + return &state.EventState.OnEvents[len(state.EventState.OnEvents)-1] +} + +func buildEventStateTimeout(state *State) *EventStateTimeout { + state.EventState.Timeouts = &EventStateTimeout{ + ActionExecTimeout: "PT5S", + EventTimeout: "PT5S", + } + return state.EventState.Timeouts +} + +func TestEventStateStructLevelValidation(t *testing.T) { + baseWorkflow := buildWorkflow() + + eventState := buildEventState(baseWorkflow, "start state") + buildOnEvents(baseWorkflow, eventState, "event 1") + buildEndByState(eventState, true, false) + + testCases := []ValidationCase{ + { + Desp: "success", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + return *model + }, + }, + { + Desp: "required", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].EventState.OnEvents = nil + return *model + }, + Err: `workflow.states[0].eventState.onEvents is required`, + }, + { + Desp: "min", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].EventState.OnEvents = []OnEvents{} + return *model + }, + Err: `workflow.states[0].eventState.onEvents must have the minimum 1`, + }, + } + StructLevelValidationCtx(t, testCases) +} + +func TestOnEventsStructLevelValidation(t *testing.T) { + baseWorkflow := buildWorkflow() + + eventState := buildEventState(baseWorkflow, "start state") + buildOnEvents(baseWorkflow, eventState, "event 1") + buildEndByState(eventState, true, false) + + testCases := []ValidationCase{ + { + Desp: "success", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + return *model + }, + }, + { + Desp: "exists", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].EventState.OnEvents[0].EventRefs = []string{"event not found"} + return *model + }, + Err: `workflow.states[0].eventState.onEvents[0].eventRefs don't exist "event not found"`, + }, + { + Desp: "required", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].EventState.OnEvents[0].EventRefs = nil + model.States[0].EventState.OnEvents[0].ActionMode = "" + return *model + }, + Err: `workflow.states[0].eventState.onEvents[0].eventRefs is required +workflow.states[0].eventState.onEvents[0].actionMode is required`, + }, + { + Desp: "min", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].EventState.OnEvents[0].EventRefs = []string{} + return *model + }, + Err: `workflow.states[0].eventState.onEvents[0].eventRefs must have the minimum 1`, + }, + { + Desp: "oneofkind", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].EventState.OnEvents[0].ActionMode = ActionModeParallel + "invalid" + return *model + }, + Err: `workflow.states[0].eventState.onEvents[0].actionMode need by one of [sequential parallel]`, + }, + } + StructLevelValidationCtx(t, testCases) +} + +func TestEventStateTimeoutStructLevelValidation(t *testing.T) { + baseWorkflow := buildWorkflow() + + eventState := buildEventState(baseWorkflow, "start state") + buildEventStateTimeout(eventState) + buildOnEvents(baseWorkflow, eventState, "event 1") + buildEndByState(eventState, true, false) + + testCases := []ValidationCase{ + { + Desp: "success", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + return *model + }, + }, + { + Desp: "omitempty", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].EventState.Timeouts.ActionExecTimeout = "" + model.States[0].EventState.Timeouts.EventTimeout = "" + return *model + }, + }, + { + Desp: "iso8601duration", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].EventState.Timeouts.ActionExecTimeout = "P5S" + model.States[0].EventState.Timeouts.EventTimeout = "P5S" + return *model + }, + Err: `workflow.states[0].eventState.timeouts.actionExecTimeout invalid iso8601 duration "P5S" +workflow.states[0].eventState.timeouts.eventTimeout invalid iso8601 duration "P5S"`, + }, + } + + StructLevelValidationCtx(t, testCases) +} diff --git a/model/event_validator.go b/model/event_validator.go index 8d134af..7b4daa9 100644 --- a/model/event_validator.go +++ b/model/event_validator.go @@ -15,20 +15,26 @@ package model import ( - "reflect" - validator "github.com/go-playground/validator/v10" + val "github.com/serverlessworkflow/sdk-go/v2/validator" ) func init() { - val.GetValidator().RegisterStructValidation(eventStructLevelValidation, Event{}) + val.GetValidator().RegisterStructValidationCtx(ValidationWrap(eventStructLevelValidation), Event{}) + val.GetValidator().RegisterStructValidationCtx(ValidationWrap(eventRefStructLevelValidation), EventRef{}) } // eventStructLevelValidation custom validator for event kind consumed -func eventStructLevelValidation(structLevel validator.StructLevel) { - event := structLevel.Current().Interface().(Event) - if event.Kind == EventKindConsumed && len(event.Type) == 0 { - structLevel.ReportError(reflect.ValueOf(event.Type), "Type", "type", "reqtypeconsumed", "") +func eventStructLevelValidation(ctx ValidatorContext, structLevel validator.StructLevel) { +} + +func eventRefStructLevelValidation(ctx ValidatorContext, structLevel validator.StructLevel) { + model := structLevel.Current().Interface().(EventRef) + if model.TriggerEventRef != "" && !ctx.ExistEvent(model.TriggerEventRef) { + structLevel.ReportError(model.TriggerEventRef, "triggerEventRef", "TriggerEventRef", val.TagExists, "") + } + if model.ResultEventRef != "" && !ctx.ExistEvent(model.ResultEventRef) { + structLevel.ReportError(model.ResultEventRef, "triggerEventRef", "TriggerEventRef", val.TagExists, "") } } diff --git a/model/event_validator_test.go b/model/event_validator_test.go index 90caa9c..80340b0 100644 --- a/model/event_validator_test.go +++ b/model/event_validator_test.go @@ -16,51 +16,201 @@ package model import ( "testing" - - val "github.com/serverlessworkflow/sdk-go/v2/validator" - "github.com/stretchr/testify/assert" ) -func TestEventRefStructLevelValidation(t *testing.T) { - type testCase struct { - name string - eventRef EventRef - err string +func buildEventRef(workflow *Workflow, action *Action, triggerEvent, resultEvent string) *EventRef { + produceEvent := Event{ + Name: triggerEvent, + Type: "event type", + Kind: EventKindProduced, + } + + consumeEvent := Event{ + Name: resultEvent, + Type: "event type", + Kind: EventKindProduced, + } + + workflow.Events = append(workflow.Events, produceEvent) + workflow.Events = append(workflow.Events, consumeEvent) + + eventRef := &EventRef{ + TriggerEventRef: triggerEvent, + ResultEventRef: resultEvent, + Invoke: InvokeKindSync, + } + + action.EventRef = eventRef + return action.EventRef +} + +func buildCorrelation(event *Event) *Correlation { + event.Correlation = append(event.Correlation, Correlation{ + ContextAttributeName: "attribute name", + }) + + return &event.Correlation[len(event.Correlation)-1] +} + +func TestEventStructLevelValidation(t *testing.T) { + baseWorkflow := buildWorkflow() + baseWorkflow.Events = Events{{ + Name: "event 1", + Type: "event type", + Kind: EventKindConsumed, + }} + + operationState := buildOperationState(baseWorkflow, "start state") + buildEndByState(operationState, true, false) + action1 := buildActionByOperationState(operationState, "action 1") + buildFunctionRef(baseWorkflow, action1, "function 1") + + testCases := []ValidationCase{ + { + Desp: "success", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + return *model + }, + }, + { + Desp: "repeat", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.Events = append(model.Events, model.Events[0]) + return *model + }, + Err: `workflow.events has duplicate "name"`, + }, + { + Desp: "required", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.Events[0].Name = "" + model.Events[0].Type = "" + model.Events[0].Kind = "" + return *model + }, + Err: `workflow.events[0].name is required +workflow.events[0].type is required +workflow.events[0].kind is required`, + }, + { + Desp: "oneofkind", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.Events[0].Kind = EventKindConsumed + "invalid" + return *model + }, + Err: `workflow.events[0].kind need by one of [consumed produced]`, + }, } + StructLevelValidationCtx(t, testCases) +} + +func TestCorrelationStructLevelValidation(t *testing.T) { + baseWorkflow := buildWorkflow() + baseWorkflow.Events = Events{{ + Name: "event 1", + Type: "event type", + Kind: EventKindConsumed, + }} + + buildCorrelation(&baseWorkflow.Events[0]) + + operationState := buildOperationState(baseWorkflow, "start state") + buildEndByState(operationState, true, false) + action1 := buildActionByOperationState(operationState, "action 1") + buildFunctionRef(baseWorkflow, action1, "function 1") - testCases := []testCase{ + testCases := []ValidationCase{ + { + Desp: "success", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + return *model + }, + }, { - name: "valid resultEventTimeout", - eventRef: EventRef{ - TriggerEventRef: "example valid", - ResultEventRef: "example valid", - ResultEventTimeout: "PT1H", - Invoke: InvokeKindSync, + Desp: "empty", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.Events[0].Correlation = nil + return *model }, - err: ``, }, { - name: "invalid resultEventTimeout", - eventRef: EventRef{ - TriggerEventRef: "example invalid", - ResultEventRef: "example invalid red", - ResultEventTimeout: "10hs", - Invoke: InvokeKindSync, + Desp: "required", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.Events[0].Correlation[0].ContextAttributeName = "" + return *model }, - err: `Key: 'EventRef.ResultEventTimeout' Error:Field validation for 'ResultEventTimeout' failed on the 'iso8601duration' tag`, + Err: `workflow.events[0].correlation[0].contextAttributeName is required`, }, + //TODO: Add test: correlation only used for `consumed` events } - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - err := val.GetValidator().Struct(tc.eventRef) - - if tc.err != "" { - assert.Error(t, err) - assert.Regexp(t, tc.err, err) - return - } - assert.NoError(t, err) - }) + StructLevelValidationCtx(t, testCases) +} + +func TestEventRefStructLevelValidation(t *testing.T) { + baseWorkflow := buildWorkflow() + + operationState := buildOperationState(baseWorkflow, "start state") + buildEndByState(operationState, true, false) + action1 := buildActionByOperationState(operationState, "action 1") + eventRef := buildEventRef(baseWorkflow, action1, "event 1", "event 2") + eventRef.ResultEventTimeout = "PT1H" + + testCases := []ValidationCase{ + { + Desp: "success", + Model: func() Workflow { + return *baseWorkflow.DeepCopy() + }, + }, + { + Desp: "required", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].OperationState.Actions[0].EventRef.TriggerEventRef = "" + model.States[0].OperationState.Actions[0].EventRef.ResultEventRef = "" + return *model + }, + Err: `workflow.states[0].actions[0].eventRef.triggerEventRef is required +workflow.states[0].actions[0].eventRef.resultEventRef is required`, + }, + { + Desp: "exists", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].OperationState.Actions[0].EventRef.TriggerEventRef = "invalid event" + model.States[0].OperationState.Actions[0].EventRef.ResultEventRef = "invalid event 2" + return *model + }, + Err: `workflow.states[0].actions[0].eventRef.triggerEventRef don't exist "invalid event" +workflow.states[0].actions[0].eventRef.triggerEventRef don't exist "invalid event 2"`, + }, + { + Desp: "iso8601duration", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].OperationState.Actions[0].EventRef.ResultEventTimeout = "10hs" + return *model + }, + Err: `workflow.states[0].actions[0].eventRef.resultEventTimeout invalid iso8601 duration "10hs"`, + }, + { + Desp: "oneofkind", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].OperationState.Actions[0].EventRef.Invoke = InvokeKindSync + "invalid" + return *model + }, + Err: `workflow.states[0].actions[0].eventRef.invoke need by one of [sync async]`, + }, } + + StructLevelValidationCtx(t, testCases) } diff --git a/model/foreach_state.go b/model/foreach_state.go index ad25b89..3edb891 100644 --- a/model/foreach_state.go +++ b/model/foreach_state.go @@ -18,6 +18,8 @@ import ( "encoding/json" "k8s.io/apimachinery/pkg/util/intstr" + + "github.com/serverlessworkflow/sdk-go/v2/util" ) // ForEachModeType Specifies how iterations are to be performed (sequentially or in parallel) @@ -58,8 +60,8 @@ type ForEachState struct { // +optional BatchSize *intstr.IntOrString `json:"batchSize,omitempty"` // Actions to be executed for each of the elements of inputCollection. - // +kubebuilder:validation:MinItems=1 - Actions []Action `json:"actions,omitempty" validate:"required,min=1,dive"` + // +kubebuilder:validation:MinItems=0 + Actions []Action `json:"actions,omitempty" validate:"required,min=0,dive"` // State specific timeout. // +optional Timeouts *ForEachStateTimeout `json:"timeouts,omitempty"` @@ -86,7 +88,7 @@ type forEachStateUnmarshal ForEachState // UnmarshalJSON implements json.Unmarshaler func (f *ForEachState) UnmarshalJSON(data []byte) error { f.ApplyDefault() - return unmarshalObject("forEachState", data, (*forEachStateUnmarshal)(f)) + return util.UnmarshalObject("forEachState", data, (*forEachStateUnmarshal)(f)) } // ApplyDefault set the default values for ForEach State diff --git a/model/foreach_state_validator.go b/model/foreach_state_validator.go index 6543ded..d1d9894 100644 --- a/model/foreach_state_validator.go +++ b/model/foreach_state_validator.go @@ -17,11 +17,10 @@ package model import ( "context" "reflect" - "strconv" validator "github.com/go-playground/validator/v10" + val "github.com/serverlessworkflow/sdk-go/v2/validator" - "k8s.io/apimachinery/pkg/util/intstr" ) func init() { @@ -40,20 +39,7 @@ func forEachStateStructLevelValidation(_ context.Context, structLevel validator. return } - switch stateObj.BatchSize.Type { - case intstr.Int: - if stateObj.BatchSize.IntVal <= 0 { - structLevel.ReportError(reflect.ValueOf(stateObj.BatchSize), "BatchSize", "batchSize", "gt0", "") - } - case intstr.String: - v, err := strconv.Atoi(stateObj.BatchSize.StrVal) - if err != nil { - structLevel.ReportError(reflect.ValueOf(stateObj.BatchSize), "BatchSize", "batchSize", "gt0", err.Error()) - return - } - - if v <= 0 { - structLevel.ReportError(reflect.ValueOf(stateObj.BatchSize), "BatchSize", "batchSize", "gt0", "") - } + if !val.ValidateGt0IntStr(stateObj.BatchSize) { + structLevel.ReportError(reflect.ValueOf(stateObj.BatchSize), "BatchSize", "batchSize", "gt0", "") } } diff --git a/model/foreach_state_validator_test.go b/model/foreach_state_validator_test.go index 1f6d5e7..8fb49d0 100644 --- a/model/foreach_state_validator_test.go +++ b/model/foreach_state_validator_test.go @@ -17,167 +17,105 @@ package model import ( "testing" - val "github.com/serverlessworkflow/sdk-go/v2/validator" - "github.com/stretchr/testify/assert" "k8s.io/apimachinery/pkg/util/intstr" ) -func TestForEachStateStructLevelValidation(t *testing.T) { - type testCase struct { - desp string - state State - err string +func buildForEachState(workflow *Workflow, name string) *State { + state := State{ + BaseState: BaseState{ + Name: name, + Type: StateTypeForEach, + }, + ForEachState: &ForEachState{ + InputCollection: "3", + Mode: ForEachModeTypeSequential, + }, } - testCases := []testCase{ + + workflow.States = append(workflow.States, state) + return &workflow.States[len(workflow.States)-1] +} + +func TestForEachStateStructLevelValidation(t *testing.T) { + baseWorkflow := buildWorkflow() + + forEachState := buildForEachState(baseWorkflow, "start state") + buildEndByState(forEachState, true, false) + action1 := buildActionByForEachState(forEachState, "action 1") + buildFunctionRef(baseWorkflow, action1, "function 1") + + testCases := []ValidationCase{ { - desp: "normal test & sequential", - state: State{ - BaseState: BaseState{ - Name: "1", - Type: StateTypeForEach, - End: &End{ - Terminate: true, - }, - }, - ForEachState: &ForEachState{ - InputCollection: "3", - Actions: []Action{ - {}, - }, - Mode: ForEachModeTypeSequential, - }, + Desp: "success", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].ForEachState.Mode = ForEachModeTypeParallel + model.States[0].ForEachState.BatchSize = &intstr.IntOrString{ + Type: intstr.Int, + IntVal: 1, + } + return *model }, - err: ``, }, { - desp: "normal test & parallel int", - state: State{ - BaseState: BaseState{ - Name: "1", - Type: StateTypeForEach, - End: &End{ - Terminate: true, - }, - }, - ForEachState: &ForEachState{ - InputCollection: "3", - Actions: []Action{ - {}, - }, - Mode: ForEachModeTypeParallel, - BatchSize: &intstr.IntOrString{ - Type: intstr.Int, - IntVal: 1, - }, - }, + Desp: "success without batch size", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].ForEachState.Mode = ForEachModeTypeParallel + model.States[0].ForEachState.BatchSize = nil + return *model }, - err: ``, }, { - desp: "normal test & parallel string", - state: State{ - BaseState: BaseState{ - Name: "1", - Type: StateTypeForEach, - End: &End{ - Terminate: true, - }, - }, - ForEachState: &ForEachState{ - InputCollection: "3", - Actions: []Action{ - {}, - }, - Mode: ForEachModeTypeParallel, - BatchSize: &intstr.IntOrString{ - Type: intstr.String, - StrVal: "1", - }, - }, + Desp: "gt0 int", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].ForEachState.Mode = ForEachModeTypeParallel + model.States[0].ForEachState.BatchSize = &intstr.IntOrString{ + Type: intstr.Int, + IntVal: 0, + } + return *model }, - err: ``, + Err: `workflow.states[0].forEachState.batchSize must be greater than 0`, }, { - desp: "invalid parallel int", - state: State{ - BaseState: BaseState{ - Name: "1", - Type: StateTypeForEach, - End: &End{ - Terminate: true, - }, - }, - ForEachState: &ForEachState{ - InputCollection: "3", - Actions: []Action{ - {}, - }, - Mode: ForEachModeTypeParallel, - BatchSize: &intstr.IntOrString{ - Type: intstr.Int, - IntVal: 0, - }, - }, + Desp: "oneofkind", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].ForEachState.Mode = ForEachModeTypeParallel + "invalid" + return *model }, - err: `Key: 'State.ForEachState.BatchSize' Error:Field validation for 'BatchSize' failed on the 'gt0' tag`, + Err: `workflow.states[0].forEachState.mode need by one of [sequential parallel]`, }, { - desp: "invalid parallel string", - state: State{ - BaseState: BaseState{ - Name: "1", - Type: "2", - End: &End{ - Terminate: true, - }, - }, - ForEachState: &ForEachState{ - InputCollection: "3", - Actions: []Action{ - {}, - }, - Mode: ForEachModeTypeParallel, - BatchSize: &intstr.IntOrString{ - Type: intstr.String, - StrVal: "0", - }, - }, + Desp: "required", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].ForEachState.InputCollection = "" + model.States[0].ForEachState.Mode = "" + model.States[0].ForEachState.Actions = nil + return *model }, - err: `Key: 'State.ForEachState.BatchSize' Error:Field validation for 'BatchSize' failed on the 'gt0' tag`, + Err: `workflow.states[0].forEachState.inputCollection is required +workflow.states[0].forEachState.actions is required +workflow.states[0].forEachState.mode is required`, }, { - desp: "invalid parallel string format", - state: State{ - BaseState: BaseState{ - Name: "1", - Type: "2", - }, - ForEachState: &ForEachState{ - InputCollection: "3", - Actions: []Action{ - {}, - }, - Mode: ForEachModeTypeParallel, - BatchSize: &intstr.IntOrString{ - Type: intstr.String, - StrVal: "a", - }, - }, + Desp: "min", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].ForEachState.Actions = []Action{} + return *model }, - err: `Key: 'State.ForEachState.BatchSize' Error:Field validation for 'BatchSize' failed on the 'gt0' tag`, + Err: ``, }, } - for _, tc := range testCases { - t.Run(tc.desp, func(t *testing.T) { - err := val.GetValidator().Struct(tc.state) - if tc.err != "" { - assert.Error(t, err) - assert.Regexp(t, tc.err, err) - return - } + StructLevelValidationCtx(t, testCases) +} - assert.NoError(t, err) - }) - } +func TestForEachStateTimeoutStructLevelValidation(t *testing.T) { + testCases := []ValidationCase{} + StructLevelValidationCtx(t, testCases) } diff --git a/model/function.go b/model/function.go index 49e23ab..07e6f77 100644 --- a/model/function.go +++ b/model/function.go @@ -14,6 +14,8 @@ package model +import "github.com/serverlessworkflow/sdk-go/v2/util" + const ( // FunctionTypeREST a combination of the function/service OpenAPI definition document URI and the particular service // operation that needs to be invoked, separated by a '#'. @@ -40,6 +42,22 @@ const ( // FunctionType ... type FunctionType string +func (i FunctionType) KindValues() []string { + return []string{ + string(FunctionTypeREST), + string(FunctionTypeRPC), + string(FunctionTypeExpression), + string(FunctionTypeGraphQL), + string(FunctionTypeAsyncAPI), + string(FunctionTypeOData), + string(FunctionTypeCustom), + } +} + +func (i FunctionType) String() string { + return string(i) +} + // Function ... type Function struct { Common `json:",inline"` @@ -51,13 +69,26 @@ type Function struct { // If type is `expression`, defines the workflow expression. If the type is `custom`, // #. // +kubebuilder:validation:Required - Operation string `json:"operation" validate:"required,oneof=rest rpc expression"` + Operation string `json:"operation" validate:"required"` // Defines the function type. Is either `custom`, `rest`, `rpc`, `expression`, `graphql`, `odata` or `asyncapi`. // Default is `rest`. // +kubebuilder:validation:Enum=rest;rpc;expression;graphql;odata;asyncapi;custom // +kubebuilder:default=rest - Type FunctionType `json:"type,omitempty"` + Type FunctionType `json:"type,omitempty" validate:"required,oneofkind"` // References an auth definition name to be used to access to resource defined in the operation parameter. // +optional - AuthRef string `json:"authRef,omitempty" validate:"omitempty,min=1"` + AuthRef string `json:"authRef,omitempty"` +} + +type functionUnmarshal Function + +// UnmarshalJSON implements json unmarshaler interface +func (f *Function) UnmarshalJSON(data []byte) error { + f.ApplyDefault() + return util.UnmarshalObject("function", data, (*functionUnmarshal)(f)) +} + +// ApplyDefault set the default values for Function +func (f *Function) ApplyDefault() { + f.Type = FunctionTypeREST } diff --git a/model/function_validator_test.go b/model/function_validator_test.go new file mode 100644 index 0000000..fcde6b9 --- /dev/null +++ b/model/function_validator_test.go @@ -0,0 +1,74 @@ +// Copyright 2021 The Serverless Workflow Specification Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package model + +import "testing" + +func TestFunctionStructLevelValidation(t *testing.T) { + baseWorkflow := buildWorkflow() + baseWorkflow.Functions = Functions{{ + Name: "function 1", + Operation: "http://function/action", + Type: FunctionTypeREST, + }} + + operationState := buildOperationState(baseWorkflow, "start state") + buildEndByState(operationState, true, false) + action1 := buildActionByOperationState(operationState, "action 1") + buildFunctionRef(baseWorkflow, action1, "function 2") + + testCases := []ValidationCase{ + { + Desp: "success", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + return *model + }, + }, + { + Desp: "required", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.Functions[0].Name = "" + model.Functions[0].Operation = "" + model.Functions[0].Type = "" + return *model + }, + Err: `workflow.functions[0].name is required +workflow.functions[0].operation is required +workflow.functions[0].type is required`, + }, + { + Desp: "repeat", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.Functions = append(model.Functions, model.Functions[0]) + return *model + }, + Err: `workflow.functions has duplicate "name"`, + }, + { + Desp: "oneofkind", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.Functions[0].Type = FunctionTypeREST + "invalid" + return *model + }, + Err: `workflow.functions[0].type need by one of [rest rpc expression graphql asyncapi odata custom]`, + }, + } + + StructLevelValidationCtx(t, testCases) +} diff --git a/model/inject_state_validator_test.go b/model/inject_state_validator_test.go new file mode 100644 index 0000000..a8f127c --- /dev/null +++ b/model/inject_state_validator_test.go @@ -0,0 +1,28 @@ +// Copyright 2022 The Serverless Workflow Specification Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package model + +import "testing" + +func TestInjectStateStructLevelValidation(t *testing.T) { + testCases := []ValidationCase{} + StructLevelValidationCtx(t, testCases) +} + +func TestInjectStateTimeoutStateStructLevelValidation(t *testing.T) { + testCases := []ValidationCase{} + + StructLevelValidationCtx(t, testCases) +} diff --git a/model/object.go b/model/object.go index a0e9fa0..10f4395 100644 --- a/model/object.go +++ b/model/object.go @@ -33,10 +33,11 @@ import ( // // +kubebuilder:validation:Type=object type Object struct { - Type Type `json:"type,inline"` - IntVal int32 `json:"intVal,inline"` - StrVal string `json:"strVal,inline"` - RawValue json.RawMessage `json:"rawValue,inline"` + Type Type `json:"type,inline"` + IntVal int32 `json:"intVal,inline"` + StrVal string `json:"strVal,inline"` + RawValue json.RawMessage `json:"rawValue,inline"` + BoolValue bool `json:"boolValue,inline"` } type Type int64 @@ -45,6 +46,7 @@ const ( Integer Type = iota String Raw + Boolean ) func FromInt(val int) Object { @@ -58,6 +60,10 @@ func FromString(val string) Object { return Object{Type: String, StrVal: val} } +func FromBool(val bool) Object { + return Object{Type: Boolean, BoolValue: val} +} + func FromRaw(val interface{}) Object { custom, err := json.Marshal(val) if err != nil { @@ -73,6 +79,9 @@ func (obj *Object) UnmarshalJSON(data []byte) error { if data[0] == '"' { obj.Type = String return json.Unmarshal(data, &obj.StrVal) + } else if data[0] == 't' || data[0] == 'f' { + obj.Type = Boolean + return json.Unmarshal(data, &obj.BoolValue) } else if data[0] == '{' { obj.Type = Raw return json.Unmarshal(data, &obj.RawValue) @@ -86,6 +95,8 @@ func (obj Object) MarshalJSON() ([]byte, error) { switch obj.Type { case String: return []byte(fmt.Sprintf(`%q`, obj.StrVal)), nil + case Boolean: + return []byte(fmt.Sprintf(`%t`, obj.BoolValue)), nil case Integer: return []byte(fmt.Sprintf(`%d`, obj.IntVal)), nil case Raw: diff --git a/model/operation_state.go b/model/operation_state.go index ebe97e0..8a88e3b 100644 --- a/model/operation_state.go +++ b/model/operation_state.go @@ -16,6 +16,8 @@ package model import ( "encoding/json" + + "github.com/serverlessworkflow/sdk-go/v2/util" ) // OperationState defines a set of actions to be performed in sequence or in parallel. @@ -23,10 +25,10 @@ type OperationState struct { // Specifies whether actions are performed in sequence or in parallel, defaults to sequential. // +kubebuilder:validation:Enum=sequential;parallel // +kubebuilder:default=sequential - ActionMode ActionMode `json:"actionMode,omitempty" validate:"required,oneof=sequential parallel"` + ActionMode ActionMode `json:"actionMode,omitempty" validate:"required,oneofkind"` // Actions to be performed - // +kubebuilder:validation:MinItems=1 - Actions []Action `json:"actions" validate:"required,min=1,dive"` + // +kubebuilder:validation:MinItems=0 + Actions []Action `json:"actions" validate:"min=0,dive"` // State specific timeouts // +optional Timeouts *OperationStateTimeout `json:"timeouts,omitempty"` @@ -49,7 +51,7 @@ type operationStateUnmarshal OperationState // UnmarshalJSON unmarshal OperationState object from json bytes func (o *OperationState) UnmarshalJSON(data []byte) error { o.ApplyDefault() - return unmarshalObject("operationState", data, (*operationStateUnmarshal)(o)) + return util.UnmarshalObject("operationState", data, (*operationStateUnmarshal)(o)) } // ApplyDefault set the default values for Operation State diff --git a/model/operation_state_validator_test.go b/model/operation_state_validator_test.go new file mode 100644 index 0000000..5da6dba --- /dev/null +++ b/model/operation_state_validator_test.go @@ -0,0 +1,121 @@ +// Copyright 2022 The Serverless Workflow Specification Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package model + +import ( + "testing" +) + +func buildOperationState(workflow *Workflow, name string) *State { + state := State{ + BaseState: BaseState{ + Name: name, + Type: StateTypeOperation, + }, + OperationState: &OperationState{ + ActionMode: ActionModeSequential, + }, + } + + workflow.States = append(workflow.States, state) + return &workflow.States[len(workflow.States)-1] +} + +func buildOperationStateTimeout(state *State) *OperationStateTimeout { + state.OperationState.Timeouts = &OperationStateTimeout{ + ActionExecTimeout: "PT5S", + } + return state.OperationState.Timeouts +} + +func TestOperationStateStructLevelValidation(t *testing.T) { + baseWorkflow := buildWorkflow() + + operationState := buildOperationState(baseWorkflow, "start state") + buildEndByState(operationState, true, false) + action1 := buildActionByOperationState(operationState, "action 1") + buildFunctionRef(baseWorkflow, action1, "function 1") + + testCases := []ValidationCase{ + { + Desp: "success", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + return *model + }, + }, + { + Desp: "min", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].OperationState.Actions = []Action{} + return *model + }, + Err: ``, + }, + { + Desp: "oneofkind", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].OperationState.ActionMode = ActionModeParallel + "invalid" + return *model + }, + Err: `workflow.states[0].actionMode need by one of [sequential parallel]`, + }, + } + + StructLevelValidationCtx(t, testCases) +} + +func TestOperationStateTimeoutStructLevelValidation(t *testing.T) { + baseWorkflow := buildWorkflow() + + operationState := buildOperationState(baseWorkflow, "start state") + buildEndByState(operationState, true, false) + operationStateTimeout := buildOperationStateTimeout(operationState) + buildStateExecTimeoutByOperationStateTimeout(operationStateTimeout) + + action1 := buildActionByOperationState(operationState, "action 1") + buildFunctionRef(baseWorkflow, action1, "function 1") + + testCases := []ValidationCase{ + { + Desp: "success", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + return *model + }, + }, + { + Desp: "omitempty", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].OperationState.Timeouts.ActionExecTimeout = "" + return *model + }, + }, + { + Desp: "iso8601duration", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].OperationState.Timeouts.ActionExecTimeout = "P5S" + return *model + }, + Err: `workflow.states[0].timeouts.actionExecTimeout invalid iso8601 duration "P5S"`, + }, + } + + StructLevelValidationCtx(t, testCases) +} diff --git a/model/parallel_state.go b/model/parallel_state.go index f46fa0a..96edd7a 100644 --- a/model/parallel_state.go +++ b/model/parallel_state.go @@ -18,11 +18,24 @@ import ( "encoding/json" "k8s.io/apimachinery/pkg/util/intstr" + + "github.com/serverlessworkflow/sdk-go/v2/util" ) // CompletionType define on how to complete branch execution. type CompletionType string +func (i CompletionType) KindValues() []string { + return []string{ + string(CompletionTypeAllOf), + string(CompletionTypeAtLeast), + } +} + +func (i CompletionType) String() string { + return string(i) +} + const ( // CompletionTypeAllOf defines all branches must complete execution before the state can transition/end. CompletionTypeAllOf CompletionType = "allOf" @@ -39,7 +52,7 @@ type ParallelState struct { // Option types on how to complete branch execution. Defaults to `allOf`. // +kubebuilder:validation:Enum=allOf;atLeast // +kubebuilder:default=allOf - CompletionType CompletionType `json:"completionType,omitempty" validate:"required,oneof=allOf atLeast"` + CompletionType CompletionType `json:"completionType,omitempty" validate:"required,oneofkind"` // Used when branchCompletionType is set to atLeast to specify the least number of branches that must complete // in order for the state to transition/end. // +optional @@ -67,7 +80,7 @@ type parallelStateUnmarshal ParallelState // UnmarshalJSON unmarshal ParallelState object from json bytes func (ps *ParallelState) UnmarshalJSON(data []byte) error { ps.ApplyDefault() - return unmarshalObject("parallelState", data, (*parallelStateUnmarshal)(ps)) + return util.UnmarshalObject("parallelState", data, (*parallelStateUnmarshal)(ps)) } // ApplyDefault set the default values for Parallel State diff --git a/model/parallel_state_validator.go b/model/parallel_state_validator.go index 5286988..5999071 100644 --- a/model/parallel_state_validator.go +++ b/model/parallel_state_validator.go @@ -17,11 +17,10 @@ package model import ( "context" "reflect" - "strconv" validator "github.com/go-playground/validator/v10" + val "github.com/serverlessworkflow/sdk-go/v2/validator" - "k8s.io/apimachinery/pkg/util/intstr" ) func init() { @@ -32,24 +31,9 @@ func init() { func parallelStateStructLevelValidation(_ context.Context, structLevel validator.StructLevel) { parallelStateObj := structLevel.Current().Interface().(ParallelState) - if parallelStateObj.CompletionType == CompletionTypeAllOf { - return - } - - switch parallelStateObj.NumCompleted.Type { - case intstr.Int: - if parallelStateObj.NumCompleted.IntVal <= 0 { - structLevel.ReportError(reflect.ValueOf(parallelStateObj.NumCompleted), "NumCompleted", "numCompleted", "gt0", "") - } - case intstr.String: - v, err := strconv.Atoi(parallelStateObj.NumCompleted.StrVal) - if err != nil { - structLevel.ReportError(reflect.ValueOf(parallelStateObj.NumCompleted), "NumCompleted", "numCompleted", "gt0", err.Error()) - return - } - - if v <= 0 { - structLevel.ReportError(reflect.ValueOf(parallelStateObj.NumCompleted), "NumCompleted", "numCompleted", "gt0", "") + if parallelStateObj.CompletionType == CompletionTypeAtLeast { + if !val.ValidateGt0IntStr(¶llelStateObj.NumCompleted) { + structLevel.ReportError(reflect.ValueOf(parallelStateObj.NumCompleted), "NumCompleted", "NumCompleted", "gt0", "") } } } diff --git a/model/parallel_state_validator_test.go b/model/parallel_state_validator_test.go index cc321ae..d1acea9 100644 --- a/model/parallel_state_validator_test.go +++ b/model/parallel_state_validator_test.go @@ -17,154 +17,236 @@ package model import ( "testing" - val "github.com/serverlessworkflow/sdk-go/v2/validator" - "github.com/stretchr/testify/assert" "k8s.io/apimachinery/pkg/util/intstr" ) +func buildParallelState(workflow *Workflow, name string) *State { + state := State{ + BaseState: BaseState{ + Name: name, + Type: StateTypeParallel, + }, + ParallelState: &ParallelState{ + CompletionType: CompletionTypeAllOf, + }, + } + + workflow.States = append(workflow.States, state) + return &workflow.States[len(workflow.States)-1] +} + +func buildBranch(state *State, name string) *Branch { + branch := Branch{ + Name: name, + } + + state.ParallelState.Branches = append(state.ParallelState.Branches, branch) + return &state.ParallelState.Branches[len(state.ParallelState.Branches)-1] +} + +func buildBranchTimeouts(branch *Branch) *BranchTimeouts { + branch.Timeouts = &BranchTimeouts{} + return branch.Timeouts +} + +func buildParallelStateTimeout(state *State) *ParallelStateTimeout { + state.ParallelState.Timeouts = &ParallelStateTimeout{ + BranchExecTimeout: "PT5S", + } + return state.ParallelState.Timeouts +} + func TestParallelStateStructLevelValidation(t *testing.T) { - type testCase struct { - desp string - state *State - err string + baseWorkflow := buildWorkflow() + + parallelState := buildParallelState(baseWorkflow, "start state") + buildEndByState(parallelState, true, false) + branch := buildBranch(parallelState, "brach 1") + action1 := buildActionByBranch(branch, "action 1") + buildFunctionRef(baseWorkflow, action1, "function 1") + + testCases := []ValidationCase{ + { + Desp: "success completionTypeAllOf", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + return *model + }, + }, + { + Desp: "success completionTypeAtLeast", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].ParallelState.CompletionType = CompletionTypeAtLeast + model.States[0].ParallelState.NumCompleted = intstr.FromInt(1) + return *model + }, + }, + { + Desp: "oneofkind", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].ParallelState.CompletionType = CompletionTypeAtLeast + " invalid" + return *model + }, + Err: `workflow.states[0].parallelState.completionType need by one of [allOf atLeast]`, + }, + { + Desp: "required", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].ParallelState.Branches = nil + model.States[0].ParallelState.CompletionType = "" + return *model + }, + Err: `workflow.states[0].parallelState.branches is required +workflow.states[0].parallelState.completionType is required`, + }, + { + Desp: "min", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].ParallelState.Branches = []Branch{} + return *model + }, + Err: `workflow.states[0].parallelState.branches must have the minimum 1`, + }, + { + Desp: "required numCompleted", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].ParallelState.CompletionType = CompletionTypeAtLeast + return *model + }, + Err: `workflow.states[0].parallelState.numCompleted must be greater than 0`, + }, } - testCases := []testCase{ + + StructLevelValidationCtx(t, testCases) +} + +func TestBranchStructLevelValidation(t *testing.T) { + baseWorkflow := buildWorkflow() + + parallelState := buildParallelState(baseWorkflow, "start state") + buildEndByState(parallelState, true, false) + branch := buildBranch(parallelState, "brach 1") + action1 := buildActionByBranch(branch, "action 1") + buildFunctionRef(baseWorkflow, action1, "function 1") + + testCases := []ValidationCase{ { - desp: "normal", - state: &State{ - BaseState: BaseState{ - Name: "1", - Type: "parallel", - End: &End{ - Terminate: true, - }, - }, - ParallelState: &ParallelState{ - Branches: []Branch{ - { - Name: "b1", - Actions: []Action{ - {}, - }, - }, - }, - CompletionType: CompletionTypeAllOf, - NumCompleted: intstr.FromInt(1), - }, + Desp: "success", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + return *model }, - err: ``, }, { - desp: "invalid completeType", - state: &State{ - BaseState: BaseState{ - Name: "1", - Type: "parallel", - End: &End{ - Terminate: true, - }, - }, - ParallelState: &ParallelState{ - Branches: []Branch{ - { - Name: "b1", - Actions: []Action{ - {}, - }, - }, - }, - CompletionType: CompletionTypeAllOf + "1", - }, + Desp: "required", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].ParallelState.Branches[0].Name = "" + model.States[0].ParallelState.Branches[0].Actions = nil + return *model }, - err: `Key: 'State.ParallelState.CompletionType' Error:Field validation for 'CompletionType' failed on the 'oneof' tag`, + Err: `workflow.states[0].parallelState.branches[0].name is required +workflow.states[0].parallelState.branches[0].actions is required`, }, { - desp: "invalid numCompleted `int`", - state: &State{ - BaseState: BaseState{ - Name: "1", - Type: "parallel", - End: &End{ - Terminate: true, - }, - }, - ParallelState: &ParallelState{ - Branches: []Branch{ - { - Name: "b1", - Actions: []Action{ - {}, - }, - }, - }, - CompletionType: CompletionTypeAtLeast, - NumCompleted: intstr.FromInt(0), - }, + Desp: "min", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].ParallelState.Branches[0].Actions = []Action{} + return *model }, - err: `Key: 'State.ParallelState.NumCompleted' Error:Field validation for 'NumCompleted' failed on the 'gt0' tag`, + Err: `workflow.states[0].parallelState.branches[0].actions must have the minimum 1`, }, + } + + StructLevelValidationCtx(t, testCases) +} + +func TestBranchTimeoutsStructLevelValidation(t *testing.T) { + baseWorkflow := buildWorkflow() + + parallelState := buildParallelState(baseWorkflow, "start state") + buildEndByState(parallelState, true, false) + branch := buildBranch(parallelState, "brach 1") + buildBranchTimeouts(branch) + action1 := buildActionByBranch(branch, "action 1") + buildFunctionRef(baseWorkflow, action1, "function 1") + + testCases := []ValidationCase{ { - desp: "invalid numCompleted string format", - state: &State{ - BaseState: BaseState{ - Name: "1", - Type: "parallel", - End: &End{ - Terminate: true, - }, - }, - ParallelState: &ParallelState{ - Branches: []Branch{ - { - Name: "b1", - Actions: []Action{ - {}, - }, - }, - }, - CompletionType: CompletionTypeAtLeast, - NumCompleted: intstr.FromString("a"), - }, + Desp: "success", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].ParallelState.Branches[0].Timeouts.ActionExecTimeout = "PT5S" + model.States[0].ParallelState.Branches[0].Timeouts.BranchExecTimeout = "PT5S" + return *model }, - err: `Key: 'State.ParallelState.NumCompleted' Error:Field validation for 'NumCompleted' failed on the 'gt0' tag`, }, { - desp: "normal", - state: &State{ - BaseState: BaseState{ - Name: "1", - Type: "parallel", - End: &End{ - Terminate: true, - }, - }, - ParallelState: &ParallelState{ - Branches: []Branch{ - { - Name: "b1", - Actions: []Action{ - {}, - }, - }, - }, - CompletionType: CompletionTypeAtLeast, - NumCompleted: intstr.FromString("0"), - }, + Desp: "omitempty", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].ParallelState.Branches[0].Timeouts.ActionExecTimeout = "" + model.States[0].ParallelState.Branches[0].Timeouts.BranchExecTimeout = "" + return *model }, - err: `Key: 'State.ParallelState.NumCompleted' Error:Field validation for 'NumCompleted' failed on the 'gt0' tag`, + }, + { + Desp: "iso8601duration", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].ParallelState.Branches[0].Timeouts.ActionExecTimeout = "P5S" + model.States[0].ParallelState.Branches[0].Timeouts.BranchExecTimeout = "P5S" + return *model + }, + Err: `workflow.states[0].parallelState.branches[0].timeouts.actionExecTimeout invalid iso8601 duration "P5S" +workflow.states[0].parallelState.branches[0].timeouts.branchExecTimeout invalid iso8601 duration "P5S"`, }, } - for _, tc := range testCases { - t.Run(tc.desp, func(t *testing.T) { - err := val.GetValidator().Struct(tc.state) - - if tc.err != "" { - assert.Error(t, err) - assert.Regexp(t, tc.err, err) - return - } - - assert.NoError(t, err) - }) + StructLevelValidationCtx(t, testCases) +} + +func TestParallelStateTimeoutStructLevelValidation(t *testing.T) { + baseWorkflow := buildWorkflow() + + parallelState := buildParallelState(baseWorkflow, "start state") + buildParallelStateTimeout(parallelState) + buildEndByState(parallelState, true, false) + branch := buildBranch(parallelState, "brach 1") + action1 := buildActionByBranch(branch, "action 1") + buildFunctionRef(baseWorkflow, action1, "function 1") + + testCases := []ValidationCase{ + { + Desp: "success", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + return *model + }, + }, + { + Desp: "omitempty", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].ParallelState.Timeouts.BranchExecTimeout = "" + return *model + }, + }, + { + Desp: "iso8601duration", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].ParallelState.Timeouts.BranchExecTimeout = "P5S" + return *model + }, + Err: `workflow.states[0].parallelState.timeouts.branchExecTimeout invalid iso8601 duration "P5S"`, + }, } + + StructLevelValidationCtx(t, testCases) } diff --git a/model/retry.go b/model/retry.go index 6ce8277..e3c7e10 100644 --- a/model/retry.go +++ b/model/retry.go @@ -17,6 +17,7 @@ package model import ( "k8s.io/apimachinery/pkg/util/intstr" + "github.com/serverlessworkflow/sdk-go/v2/util" "github.com/serverlessworkflow/sdk-go/v2/util/floatstr" ) @@ -41,3 +42,15 @@ type Retry struct { // TODO: make iso8601duration compatible this type Jitter floatstr.Float32OrString `json:"jitter,omitempty" validate:"omitempty,min=0,max=1"` } + +type retryUnmarshal Retry + +// UnmarshalJSON implements json.Unmarshaler +func (r *Retry) UnmarshalJSON(data []byte) error { + r.ApplyDefault() + return util.UnmarshalObject("retry", data, (*retryUnmarshal)(r)) +} + +func (r *Retry) ApplyDefault() { + r.MaxAttempts = intstr.FromInt(1) +} diff --git a/model/retry_validator.go b/model/retry_validator.go index 14886ce..b95e2f7 100644 --- a/model/retry_validator.go +++ b/model/retry_validator.go @@ -19,6 +19,7 @@ import ( validator "github.com/go-playground/validator/v10" "github.com/serverlessworkflow/sdk-go/v2/util/floatstr" + val "github.com/serverlessworkflow/sdk-go/v2/validator" ) diff --git a/model/retry_validator_test.go b/model/retry_validator_test.go index 78f1e70..5a3bca0 100644 --- a/model/retry_validator_test.go +++ b/model/retry_validator_test.go @@ -18,103 +18,74 @@ import ( "testing" "github.com/serverlessworkflow/sdk-go/v2/util/floatstr" - val "github.com/serverlessworkflow/sdk-go/v2/validator" - "github.com/stretchr/testify/assert" ) func TestRetryStructLevelValidation(t *testing.T) { - type testCase struct { - desp string - retryObj Retry - err string - } - testCases := []testCase{ - { - desp: "normal", - retryObj: Retry{ - Name: "1", - Delay: "PT5S", - MaxDelay: "PT5S", - Increment: "PT5S", - Jitter: floatstr.FromString("PT5S"), - }, - err: ``, - }, - { - desp: "normal with all optinal", - retryObj: Retry{ - Name: "1", - }, - err: ``, - }, + baseWorkflow := buildWorkflow() + + operationState := buildOperationState(baseWorkflow, "start state") + buildEndByState(operationState, true, false) + action1 := buildActionByOperationState(operationState, "action 1") + buildRetryRef(baseWorkflow, action1, "retry 1") + buildFunctionRef(baseWorkflow, action1, "function 1") + + testCases := []ValidationCase{ { - desp: "missing required name", - retryObj: Retry{ - Name: "", - Delay: "PT5S", - MaxDelay: "PT5S", - Increment: "PT5S", - Jitter: floatstr.FromString("PT5S"), + Desp: "success", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.Retries[0].Delay = "PT5S" + model.Retries[0].MaxDelay = "PT5S" + model.Retries[0].Increment = "PT5S" + model.Retries[0].Jitter = floatstr.FromString("PT5S") + return *model }, - err: `Key: 'Retry.Name' Error:Field validation for 'Name' failed on the 'required' tag`, }, { - desp: "invalid delay duration", - retryObj: Retry{ - Name: "1", - Delay: "P5S", - MaxDelay: "PT5S", - Increment: "PT5S", - Jitter: floatstr.FromString("PT5S"), + Desp: "required", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.Retries[0].Name = "" + model.States[0].OperationState.Actions[0].RetryRef = "" + return *model }, - err: `Key: 'Retry.Delay' Error:Field validation for 'Delay' failed on the 'iso8601duration' tag`, + Err: `workflow.retries[0].name is required`, }, { - desp: "invdalid max delay duration", - retryObj: Retry{ - Name: "1", - Delay: "PT5S", - MaxDelay: "P5S", - Increment: "PT5S", - Jitter: floatstr.FromString("PT5S"), + Desp: "repeat", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.Retries = append(model.Retries, model.Retries[0]) + return *model }, - err: `Key: 'Retry.MaxDelay' Error:Field validation for 'MaxDelay' failed on the 'iso8601duration' tag`, + Err: `workflow.retries has duplicate "name"`, }, { - desp: "invalid increment duration", - retryObj: Retry{ - Name: "1", - Delay: "PT5S", - MaxDelay: "PT5S", - Increment: "P5S", - Jitter: floatstr.FromString("PT5S"), + Desp: "exists", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].OperationState.Actions[0].RetryRef = "invalid retry" + return *model }, - err: `Key: 'Retry.Increment' Error:Field validation for 'Increment' failed on the 'iso8601duration' tag`, + Err: `workflow.states[0].actions[0].retryRef don't exist "invalid retry"`, }, { - desp: "invalid jitter duration", - retryObj: Retry{ - Name: "1", - Delay: "PT5S", - MaxDelay: "PT5S", - Increment: "PT5S", - Jitter: floatstr.FromString("P5S"), + Desp: "iso8601duration", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.Retries[0].Delay = "P5S" + model.Retries[0].MaxDelay = "P5S" + model.Retries[0].Increment = "P5S" + model.Retries[0].Jitter = floatstr.FromString("P5S") + + return *model }, - err: `Key: 'Retry.Jitter' Error:Field validation for 'Jitter' failed on the 'iso8601duration' tag`, + Err: `workflow.retries[0].delay invalid iso8601 duration "P5S" +workflow.retries[0].maxDelay invalid iso8601 duration "P5S" +workflow.retries[0].increment invalid iso8601 duration "P5S" +workflow.retries[0].jitter invalid iso8601 duration "P5S"`, }, } - for _, tc := range testCases { - t.Run(tc.desp, func(t *testing.T) { - err := val.GetValidator().Struct(tc.retryObj) - - if tc.err != "" { - assert.Error(t, err) - assert.Regexp(t, tc.err, err) - return - } - - assert.NoError(t, err) - }) - } + StructLevelValidationCtx(t, testCases) } diff --git a/model/sleep_state_test.go b/model/sleep_state_test.go index 47b6a1e..c960f3c 100644 --- a/model/sleep_state_test.go +++ b/model/sleep_state_test.go @@ -13,64 +13,3 @@ // limitations under the License. package model - -import ( - "testing" - - "github.com/stretchr/testify/assert" - - val "github.com/serverlessworkflow/sdk-go/v2/validator" -) - -func TestSleepStateStructLevelValidation(t *testing.T) { - type testCase struct { - desp string - state State - err string - } - testCases := []testCase{ - { - desp: "normal duration", - state: State{ - BaseState: BaseState{ - Name: "1", - Type: "sleep", - End: &End{ - Terminate: true, - }, - }, - SleepState: &SleepState{ - Duration: "PT10S", - }, - }, - err: ``, - }, - { - desp: "invalid duration", - state: State{ - BaseState: BaseState{ - Name: "1", - Type: "sleep", - }, - SleepState: &SleepState{ - Duration: "T10S", - }, - }, - err: `Key: 'State.SleepState.Duration' Error:Field validation for 'Duration' failed on the 'iso8601duration' tag`, - }, - } - - for _, tc := range testCases { - t.Run(tc.desp, func(t *testing.T) { - err := val.GetValidator().Struct(tc.state) - - if tc.err != "" { - assert.Error(t, err) - assert.Regexp(t, tc.err, err) - return - } - - assert.NoError(t, err) - }) - } -} diff --git a/model/sleep_state_validator_test.go b/model/sleep_state_validator_test.go new file mode 100644 index 0000000..057d6b3 --- /dev/null +++ b/model/sleep_state_validator_test.go @@ -0,0 +1,95 @@ +// Copyright 2022 The Serverless Workflow Specification Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package model + +import "testing" + +func buildSleepState(workflow *Workflow, name, duration string) *State { + state := State{ + BaseState: BaseState{ + Name: name, + Type: StateTypeSleep, + }, + SleepState: &SleepState{ + Duration: duration, + }, + } + + workflow.States = append(workflow.States, state) + return &workflow.States[len(workflow.States)-1] +} + +func buildSleepStateTimeout(state *State) *SleepStateTimeout { + state.SleepState.Timeouts = &SleepStateTimeout{} + return state.SleepState.Timeouts +} + +func TestSleepStateStructLevelValidation(t *testing.T) { + baseWorkflow := buildWorkflow() + + sleepState := buildSleepState(baseWorkflow, "start state", "PT5S") + buildEndByState(sleepState, true, false) + + testCases := []ValidationCase{ + { + Desp: "success", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + return *model + }, + }, + { + Desp: "required", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].SleepState.Duration = "" + return *model + }, + Err: `workflow.states[0].sleepState.duration is required`, + }, + { + Desp: "iso8601duration", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].SleepState.Duration = "P5S" + return *model + }, + Err: `workflow.states[0].sleepState.duration invalid iso8601 duration "P5S"`, + }, + } + + StructLevelValidationCtx(t, testCases) +} + +func TestSleepStateTimeoutStructLevelValidation(t *testing.T) { + baseWorkflow := buildWorkflow() + + sleepState := buildSleepState(baseWorkflow, "start state", "PT5S") + buildEndByState(sleepState, true, false) + sleepStateTimeout := buildSleepStateTimeout(sleepState) + buildStateExecTimeoutBySleepStateTimeout(sleepStateTimeout) + + testCases := []ValidationCase{ + { + Desp: "success", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + return *model + }, + }, + } + + StructLevelValidationCtx(t, testCases) +} diff --git a/model/state_exec_timeout.go b/model/state_exec_timeout.go index c487629..0a53fd8 100644 --- a/model/state_exec_timeout.go +++ b/model/state_exec_timeout.go @@ -14,6 +14,8 @@ package model +import "github.com/serverlessworkflow/sdk-go/v2/util" + // StateExecTimeout defines workflow state execution timeout type StateExecTimeout struct { // Single state execution timeout, not including retries (ISO 8601 duration format) @@ -28,5 +30,5 @@ type stateExecTimeoutUnmarshal StateExecTimeout // UnmarshalJSON unmarshal StateExecTimeout object from json bytes func (s *StateExecTimeout) UnmarshalJSON(data []byte) error { - return unmarshalPrimitiveOrObject("stateExecTimeout", data, &s.Total, (*stateExecTimeoutUnmarshal)(s)) + return util.UnmarshalPrimitiveOrObject("stateExecTimeout", data, &s.Total, (*stateExecTimeoutUnmarshal)(s)) } diff --git a/model/state_exec_timeout_test.go b/model/state_exec_timeout_test.go index 4f8ff08..6030395 100644 --- a/model/state_exec_timeout_test.go +++ b/model/state_exec_timeout_test.go @@ -18,8 +18,6 @@ import ( "testing" "github.com/stretchr/testify/assert" - - val "github.com/serverlessworkflow/sdk-go/v2/validator" ) func TestStateExecTimeoutUnmarshalJSON(t *testing.T) { @@ -113,65 +111,3 @@ func TestStateExecTimeoutUnmarshalJSON(t *testing.T) { }) } } - -func TestStateExecTimeoutStructLevelValidation(t *testing.T) { - type testCase struct { - desp string - timeout StateExecTimeout - err string - } - testCases := []testCase{ - { - desp: "normal total", - timeout: StateExecTimeout{ - Total: "PT10S", - }, - err: ``, - }, - { - desp: "normal total & single", - timeout: StateExecTimeout{ - Single: "PT10S", - Total: "PT10S", - }, - err: ``, - }, - { - desp: "missing total", - timeout: StateExecTimeout{ - Single: "PT10S", - Total: "", - }, - err: `Key: 'StateExecTimeout.Total' Error:Field validation for 'Total' failed on the 'required' tag`, - }, - { - desp: "invalid total duration", - timeout: StateExecTimeout{ - Single: "PT10S", - Total: "T10S", - }, - err: `Key: 'StateExecTimeout.Total' Error:Field validation for 'Total' failed on the 'iso8601duration' tag`, - }, - { - desp: "invalid single duration", - timeout: StateExecTimeout{ - Single: "T10S", - Total: "PT10S", - }, - err: `Key: 'StateExecTimeout.Single' Error:Field validation for 'Single' failed on the 'iso8601duration' tag`, - }, - } - for _, tc := range testCases { - t.Run(tc.desp, func(t *testing.T) { - err := val.GetValidator().Struct(tc.timeout) - - if tc.err != "" { - assert.Error(t, err) - assert.Regexp(t, tc.err, err) - return - } - - assert.NoError(t, err) - }) - } -} diff --git a/model/state_exec_timeout_validator_test.go b/model/state_exec_timeout_validator_test.go new file mode 100644 index 0000000..5a2f794 --- /dev/null +++ b/model/state_exec_timeout_validator_test.go @@ -0,0 +1,95 @@ +// Copyright 2022 The Serverless Workflow Specification Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package model + +import "testing" + +func buildStateExecTimeoutByTimeouts(timeouts *Timeouts) *StateExecTimeout { + stateExecTimeout := StateExecTimeout{ + Total: "PT5S", + Single: "PT5S", + } + timeouts.StateExecTimeout = &stateExecTimeout + return timeouts.StateExecTimeout +} + +func buildStateExecTimeoutBySleepStateTimeout(timeouts *SleepStateTimeout) *StateExecTimeout { + stateExecTimeout := StateExecTimeout{ + Total: "PT5S", + } + timeouts.StateExecTimeout = &stateExecTimeout + return timeouts.StateExecTimeout +} + +func buildStateExecTimeoutByOperationStateTimeout(timeouts *OperationStateTimeout) *StateExecTimeout { + stateExecTimeout := StateExecTimeout{ + Total: "PT5S", + Single: "PT5S", + } + timeouts.ActionExecTimeout = "PT5S" + timeouts.StateExecTimeout = &stateExecTimeout + return timeouts.StateExecTimeout +} + +func TestStateExecTimeoutStructLevelValidation(t *testing.T) { + baseWorkflow := buildWorkflow() + timeouts := buildTimeouts(baseWorkflow) + buildStateExecTimeoutByTimeouts(timeouts) + + callbackState := buildCallbackState(baseWorkflow, "start state", "event 1") + buildEndByState(callbackState, true, false) + buildCallbackStateTimeout(callbackState.CallbackState) + buildFunctionRef(baseWorkflow, &callbackState.Action, "function 1") + + testCases := []ValidationCase{ + { + Desp: "success", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + return *model + }, + }, + { + Desp: "omitempty", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.BaseWorkflow.Timeouts.StateExecTimeout.Single = "" + return *model + }, + }, + { + Desp: "required", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.BaseWorkflow.Timeouts.StateExecTimeout.Total = "" + return *model + }, + Err: `workflow.timeouts.stateExecTimeout.total is required`, + }, + { + Desp: "iso8601duration", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.BaseWorkflow.Timeouts.StateExecTimeout.Single = "P5S" + model.BaseWorkflow.Timeouts.StateExecTimeout.Total = "P5S" + return *model + }, + Err: `workflow.timeouts.stateExecTimeout.single invalid iso8601 duration "P5S" +workflow.timeouts.stateExecTimeout.total invalid iso8601 duration "P5S"`, + }, + } + + StructLevelValidationCtx(t, testCases) +} diff --git a/model/states.go b/model/states.go index 42c7b48..5842d9a 100644 --- a/model/states.go +++ b/model/states.go @@ -18,6 +18,8 @@ import ( "encoding/json" "fmt" "strings" + + "github.com/serverlessworkflow/sdk-go/v2/util" ) // StateType ... @@ -204,7 +206,7 @@ type unmarshalState State // UnmarshalJSON implements json.Unmarshaler func (s *State) UnmarshalJSON(data []byte) error { - if err := unmarshalObject("state", data, (*unmarshalState)(s)); err != nil { + if err := util.UnmarshalObject("state", data, (*unmarshalState)(s)); err != nil { return err } @@ -225,7 +227,7 @@ func (s *State) UnmarshalJSON(data []byte) error { case StateTypeOperation: state := &OperationState{} - if err := unmarshalObject("states", data, state); err != nil { + if err := util.UnmarshalObject("states", data, state); err != nil { return err } s.OperationState = state diff --git a/model/states_validator.go b/model/states_validator.go index ee55846..1bb58e5 100644 --- a/model/states_validator.go +++ b/model/states_validator.go @@ -15,19 +15,37 @@ package model import ( - "reflect" - validator "github.com/go-playground/validator/v10" + val "github.com/serverlessworkflow/sdk-go/v2/validator" ) func init() { - val.GetValidator().RegisterStructValidation(baseStateStructLevelValidation, BaseState{}) + val.GetValidator().RegisterStructValidationCtx(ValidationWrap(baseStateStructLevelValidationCtx), BaseState{}) } -func baseStateStructLevelValidation(structLevel validator.StructLevel) { +func baseStateStructLevelValidationCtx(ctx ValidatorContext, structLevel validator.StructLevel) { baseState := structLevel.Current().Interface().(BaseState) - if baseState.Type != StateTypeSwitch { - validTransitionAndEnd(structLevel, reflect.ValueOf(baseState), baseState.Transition, baseState.End) + if baseState.Type != StateTypeSwitch && !baseState.UsedForCompensation { + validTransitionAndEnd(structLevel, baseState, baseState.Transition, baseState.End) + } + + if baseState.CompensatedBy != "" { + if baseState.UsedForCompensation { + structLevel.ReportError(baseState.CompensatedBy, "CompensatedBy", "compensatedBy", val.TagRecursiveCompensation, "") + } + + if ctx.ExistState(baseState.CompensatedBy) { + value := ctx.States[baseState.CompensatedBy].BaseState + if value.UsedForCompensation && value.Type == StateTypeEvent { + structLevel.ReportError(baseState.CompensatedBy, "CompensatedBy", "compensatedBy", val.TagCompensatedbyEventState, "") + + } else if !value.UsedForCompensation { + structLevel.ReportError(baseState.CompensatedBy, "CompensatedBy", "compensatedBy", val.TagCompensatedby, "") + } + + } else { + structLevel.ReportError(baseState.CompensatedBy, "CompensatedBy", "compensatedBy", val.TagExists, "") + } } } diff --git a/model/states_validator_test.go b/model/states_validator_test.go index 296f726..8766d87 100644 --- a/model/states_validator_test.go +++ b/model/states_validator_test.go @@ -16,120 +16,136 @@ package model import ( "testing" - - val "github.com/serverlessworkflow/sdk-go/v2/validator" - "github.com/stretchr/testify/assert" ) -var stateTransitionDefault = State{ - BaseState: BaseState{ - Name: "name state", - Type: StateTypeOperation, - Transition: &Transition{ - NextState: "next name state", +func TestBaseStateStructLevelValidation(t *testing.T) { + baseWorkflow := buildWorkflow() + baseWorkflow.States = make(States, 0, 3) + + operationState := buildOperationState(baseWorkflow, "start state 1") + buildEndByState(operationState, true, false) + action1 := buildActionByOperationState(operationState, "action 1") + buildFunctionRef(baseWorkflow, action1, "function 1") + + operationState2 := buildOperationState(baseWorkflow, "state 2") + buildEndByState(operationState2, true, false) + action2 := buildActionByOperationState(operationState2, "action 2") + buildFunctionRef(baseWorkflow, action2, "function 2") + + eventState := buildEventState(baseWorkflow, "state 3") + buildOnEvents(baseWorkflow, eventState, "event 1") + buildEndByState(eventState, true, false) + + testCases := []ValidationCase{ + { + Desp: "success", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + return *model + }, }, - }, - OperationState: &OperationState{ - ActionMode: "sequential", - Actions: []Action{ - {}, + { + Desp: "repeat name", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States = []State{model.States[0], model.States[0]} + return *model + }, + Err: `workflow.states has duplicate "name"`, }, - }, -} - -var stateEndDefault = State{ - BaseState: BaseState{ - Name: "name state", - Type: StateTypeOperation, - End: &End{ - Terminate: true, + { + Desp: "exists", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].BaseState.CompensatedBy = "invalid state compensate by" + return *model + }, + Err: `workflow.states[0].compensatedBy don't exist "invalid state compensate by"`, }, - }, - OperationState: &OperationState{ - ActionMode: "sequential", - Actions: []Action{ - {}, + { + Desp: "tagcompensatedby", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].BaseState.CompensatedBy = model.States[1].BaseState.Name + return *model + }, + Err: `workflow.states[0].compensatedBy = "state 2" is not defined as usedForCompensation`, }, - }, -} - -var switchStateTransitionDefault = State{ - BaseState: BaseState{ - Name: "name state", - Type: StateTypeSwitch, - }, - SwitchState: &SwitchState{ - DataConditions: []DataCondition{ - { - Condition: "${ .applicant | .age >= 18 }", - Transition: &Transition{ - NextState: "nex state", - }, + { + Desp: "compensatedbyeventstate", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[2].BaseState.UsedForCompensation = true + model.States[0].BaseState.CompensatedBy = model.States[2].BaseState.Name + return *model }, + Err: `workflow.states[0].compensatedBy = "state 3" is defined as usedForCompensation and cannot be an event state`, }, - DefaultCondition: DefaultCondition{ - Transition: &Transition{ - NextState: "nex state", + { + Desp: "recursivecompensation", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].BaseState.UsedForCompensation = true + model.States[0].BaseState.CompensatedBy = model.States[0].BaseState.Name + return *model }, + Err: `workflow.states[0].compensatedBy = "start state 1" is defined as usedForCompensation (cannot themselves set their compensatedBy)`, }, - }, + } + + StructLevelValidationCtx(t, testCases) } func TestStateStructLevelValidation(t *testing.T) { - type testCase struct { - name string - instance State - err string - } + baseWorkflow := buildWorkflow() + baseWorkflow.States = make(States, 0, 2) - testCases := []testCase{ - { - name: "state transition success", - instance: stateTransitionDefault, - err: ``, - }, + operationState := buildOperationState(baseWorkflow, "start state") + buildEndByState(operationState, true, false) + action1 := buildActionByOperationState(operationState, "action 1") + buildFunctionRef(baseWorkflow, action1, "function 1") + + operationState2 := buildOperationState(baseWorkflow, "next state") + buildEndByState(operationState2, true, false) + action2 := buildActionByOperationState(operationState2, "action 2") + buildFunctionRef(baseWorkflow, action2, "function 2") + + testCases := []ValidationCase{ { - name: "state end success", - instance: stateEndDefault, - err: ``, + Desp: "success", + Model: func() Workflow { + return *baseWorkflow.DeepCopy() + }, }, { - name: "switch state success", - instance: switchStateTransitionDefault, - err: ``, + Desp: "required", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].BaseState.End = nil + return *model + }, + Err: `workflow.states[0].transition is required`, }, { - name: "state end and transition", - instance: func() State { - s := stateTransitionDefault - s.End = stateEndDefault.End - return s - }(), - err: `Key: 'State.BaseState.Transition' Error:Field validation for 'Transition' failed on the 'exclusive' tag`, + Desp: "exclusive", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + buildTransitionByState(&model.States[0], &model.States[1], false) + + return *model + }, + Err: `workflow.states[0].transition exclusive`, }, { - name: "basestate without end and transition", - instance: func() State { - s := stateTransitionDefault - s.Transition = nil - return s - }(), - err: `Key: 'State.BaseState.Transition' Error:Field validation for 'Transition' failed on the 'required' tag`, + Desp: "oneofkind", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].BaseState.Type = StateTypeOperation + "invalid" + return *model + }, + Err: `workflow.states[0].type need by one of [delay event operation parallel switch foreach inject callback sleep]`, }, } - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - err := val.GetValidator().Struct(tc.instance) - - if tc.err != "" { - assert.Error(t, err) - if err != nil { - assert.Equal(t, tc.err, err.Error()) - } - return - } - assert.NoError(t, err) - }) - } + StructLevelValidationCtx(t, testCases) } diff --git a/model/switch_state.go b/model/switch_state.go index 70f1b28..15d1a6d 100644 --- a/model/switch_state.go +++ b/model/switch_state.go @@ -17,21 +17,25 @@ package model import ( "encoding/json" "strings" + + "github.com/serverlessworkflow/sdk-go/v2/util" ) +type EventConditions []EventCondition + // SwitchState is workflow's gateways: direct transitions onf a workflow based on certain conditions. type SwitchState struct { // TODO: don't use BaseState for this, there are a few fields that SwitchState don't need. // Default transition of the workflow if there is no matching data conditions. Can include a transition or // end definition. - DefaultCondition DefaultCondition `json:"defaultCondition" validate:"required_without=EventConditions"` + DefaultCondition DefaultCondition `json:"defaultCondition"` // Defines conditions evaluated against events. // +optional - EventConditions []EventCondition `json:"eventConditions" validate:"required_without=DefaultCondition"` + EventConditions EventConditions `json:"eventConditions" validate:"dive"` // Defines conditions evaluated against data // +optional - DataConditions []DataCondition `json:"dataConditions" validate:"omitempty,min=1,dive"` + DataConditions []DataCondition `json:"dataConditions" validate:"dive"` // SwitchState specific timeouts // +optional Timeouts *SwitchStateTimeout `json:"timeouts,omitempty"` @@ -74,7 +78,7 @@ type defaultConditionUnmarshal DefaultCondition // UnmarshalJSON implements json.Unmarshaler func (e *DefaultCondition) UnmarshalJSON(data []byte) error { var nextState string - err := unmarshalPrimitiveOrObject("defaultCondition", data, &nextState, (*defaultConditionUnmarshal)(e)) + err := util.UnmarshalPrimitiveOrObject("defaultCondition", data, &nextState, (*defaultConditionUnmarshal)(e)) if err != nil { return err } diff --git a/model/switch_state_validator.go b/model/switch_state_validator.go index 83f1379..5738104 100644 --- a/model/switch_state_validator.go +++ b/model/switch_state_validator.go @@ -18,42 +18,47 @@ import ( "reflect" validator "github.com/go-playground/validator/v10" + val "github.com/serverlessworkflow/sdk-go/v2/validator" ) func init() { - val.GetValidator().RegisterStructValidation(switchStateStructLevelValidation, SwitchState{}) - val.GetValidator().RegisterStructValidation(defaultConditionStructLevelValidation, DefaultCondition{}) - val.GetValidator().RegisterStructValidation(eventConditionStructLevelValidation, EventCondition{}) - val.GetValidator().RegisterStructValidation(dataConditionStructLevelValidation, DataCondition{}) + val.GetValidator().RegisterStructValidationCtx(ValidationWrap(switchStateStructLevelValidation), SwitchState{}) + val.GetValidator().RegisterStructValidationCtx(ValidationWrap(defaultConditionStructLevelValidation), DefaultCondition{}) + val.GetValidator().RegisterStructValidationCtx(ValidationWrap(eventConditionStructLevelValidationCtx), EventCondition{}) + val.GetValidator().RegisterStructValidationCtx(ValidationWrap(dataConditionStructLevelValidation), DataCondition{}) } // SwitchStateStructLevelValidation custom validator for SwitchState -func switchStateStructLevelValidation(structLevel validator.StructLevel) { +func switchStateStructLevelValidation(ctx ValidatorContext, structLevel validator.StructLevel) { switchState := structLevel.Current().Interface().(SwitchState) switch { case len(switchState.DataConditions) == 0 && len(switchState.EventConditions) == 0: - structLevel.ReportError(reflect.ValueOf(switchState), "DataConditions", "dataConditions", "required", "must have one of dataConditions, eventConditions") + structLevel.ReportError(reflect.ValueOf(switchState), "DataConditions", "dataConditions", val.TagRequired, "") case len(switchState.DataConditions) > 0 && len(switchState.EventConditions) > 0: - structLevel.ReportError(reflect.ValueOf(switchState), "DataConditions", "dataConditions", "exclusive", "must have one of dataConditions, eventConditions") + structLevel.ReportError(reflect.ValueOf(switchState), "DataConditions", "dataConditions", val.TagExclusive, "") } } // DefaultConditionStructLevelValidation custom validator for DefaultCondition -func defaultConditionStructLevelValidation(structLevel validator.StructLevel) { +func defaultConditionStructLevelValidation(ctx ValidatorContext, structLevel validator.StructLevel) { defaultCondition := structLevel.Current().Interface().(DefaultCondition) - validTransitionAndEnd(structLevel, reflect.ValueOf(defaultCondition), defaultCondition.Transition, defaultCondition.End) + validTransitionAndEnd(structLevel, defaultCondition, defaultCondition.Transition, defaultCondition.End) } // EventConditionStructLevelValidation custom validator for EventCondition -func eventConditionStructLevelValidation(structLevel validator.StructLevel) { +func eventConditionStructLevelValidationCtx(ctx ValidatorContext, structLevel validator.StructLevel) { eventCondition := structLevel.Current().Interface().(EventCondition) - validTransitionAndEnd(structLevel, reflect.ValueOf(eventCondition), eventCondition.Transition, eventCondition.End) + validTransitionAndEnd(structLevel, eventCondition, eventCondition.Transition, eventCondition.End) + + if eventCondition.EventRef != "" && !ctx.ExistEvent(eventCondition.EventRef) { + structLevel.ReportError(eventCondition.EventRef, "eventRef", "EventRef", val.TagExists, "") + } } // DataConditionStructLevelValidation custom validator for DataCondition -func dataConditionStructLevelValidation(structLevel validator.StructLevel) { +func dataConditionStructLevelValidation(ctx ValidatorContext, structLevel validator.StructLevel) { dataCondition := structLevel.Current().Interface().(DataCondition) - validTransitionAndEnd(structLevel, reflect.ValueOf(dataCondition), dataCondition.Transition, dataCondition.End) + validTransitionAndEnd(structLevel, dataCondition, dataCondition.Transition, dataCondition.End) } diff --git a/model/switch_state_validator_test.go b/model/switch_state_validator_test.go index 7bddc46..9c40462 100644 --- a/model/switch_state_validator_test.go +++ b/model/switch_state_validator_test.go @@ -16,314 +16,259 @@ package model import ( "testing" - - val "github.com/serverlessworkflow/sdk-go/v2/validator" - "github.com/stretchr/testify/assert" ) -func TestSwitchStateStructLevelValidation(t *testing.T) { - type testCase struct { - desp string - obj State - err string - } - testCases := []testCase{ - { - desp: "normal & eventConditions", - obj: State{ - BaseState: BaseState{ - Name: "1", - Type: "switch", - }, - SwitchState: &SwitchState{ - DefaultCondition: DefaultCondition{ - Transition: &Transition{ - NextState: "1", - }, - }, - EventConditions: []EventCondition{ - { - EventRef: "1", - Transition: &Transition{ - NextState: "2", - }, - }, - }, - }, - }, - err: ``, +func buildSwitchState(workflow *Workflow, name string) *State { + state := State{ + BaseState: BaseState{ + Name: name, + Type: StateTypeSwitch, }, + SwitchState: &SwitchState{}, + } + + workflow.States = append(workflow.States, state) + return &workflow.States[len(workflow.States)-1] +} + +func buildDefaultCondition(state *State) *DefaultCondition { + state.SwitchState.DefaultCondition = DefaultCondition{} + return &state.SwitchState.DefaultCondition +} + +func buildDataCondition(state *State, name, condition string) *DataCondition { + if state.SwitchState.DataConditions == nil { + state.SwitchState.DataConditions = []DataCondition{} + } + + dataCondition := DataCondition{ + Name: name, + Condition: condition, + } + + state.SwitchState.DataConditions = append(state.SwitchState.DataConditions, dataCondition) + return &state.SwitchState.DataConditions[len(state.SwitchState.DataConditions)-1] +} + +func buildEventCondition(workflow *Workflow, state *State, name, eventRef string) (*Event, *EventCondition) { + workflow.Events = append(workflow.Events, Event{ + Name: eventRef, + Type: "event type", + Kind: EventKindConsumed, + }) + + eventCondition := EventCondition{ + Name: name, + EventRef: eventRef, + } + + state.SwitchState.EventConditions = append(state.SwitchState.EventConditions, eventCondition) + return &workflow.Events[len(workflow.Events)-1], &state.SwitchState.EventConditions[len(state.SwitchState.EventConditions)-1] +} + +func TestSwitchStateStructLevelValidation(t *testing.T) { + baseWorkflow := buildWorkflow() + + swithState := buildSwitchState(baseWorkflow, "start state") + defaultCondition := buildDefaultCondition(swithState) + buildEndByDefaultCondition(defaultCondition, true, false) + + dataCondition := buildDataCondition(swithState, "data condition 1", "1=1") + buildEndByDataCondition(dataCondition, true, false) + + testCases := []ValidationCase{ { - desp: "normal & dataConditions", - obj: State{ - BaseState: BaseState{ - Name: "1", - Type: "switch", - }, - SwitchState: &SwitchState{ - DefaultCondition: DefaultCondition{ - Transition: &Transition{ - NextState: "1", - }, - }, - DataConditions: []DataCondition{ - { - Condition: "1", - Transition: &Transition{ - NextState: "2", - }, - }, - }, - }, + Desp: "success", + Model: func() Workflow { + return *baseWorkflow.DeepCopy() }, - err: ``, }, { - desp: "missing eventConditions & dataConditions", - obj: State{ - BaseState: BaseState{ - Name: "1", - Type: "switch", - }, - SwitchState: &SwitchState{ - DefaultCondition: DefaultCondition{ - Transition: &Transition{ - NextState: "1", - }, - }, - }, + Desp: "required", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].SwitchState.DataConditions = nil + return *model }, - err: `Key: 'State.SwitchState.DataConditions' Error:Field validation for 'DataConditions' failed on the 'required' tag`, + Err: `workflow.states[0].switchState.dataConditions is required`, }, { - desp: "exclusive eventConditions & dataConditions", - obj: State{ - BaseState: BaseState{ - Name: "1", - Type: "switch", - }, - SwitchState: &SwitchState{ - DefaultCondition: DefaultCondition{ - Transition: &Transition{ - NextState: "1", - }, - }, - EventConditions: []EventCondition{ - { - EventRef: "1", - Transition: &Transition{ - NextState: "2", - }, - }, - }, - DataConditions: []DataCondition{ - { - Condition: "1", - Transition: &Transition{ - NextState: "2", - }, - }, - }, - }, + Desp: "exclusive", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + buildEventCondition(model, &model.States[0], "event condition", "event 1") + buildEndByEventCondition(&model.States[0].SwitchState.EventConditions[0], true, false) + return *model }, - err: `Key: 'State.SwitchState.DataConditions' Error:Field validation for 'DataConditions' failed on the 'exclusive' tag`, + Err: `workflow.states[0].switchState.dataConditions exclusive`, }, } - for _, tc := range testCases { - t.Run(tc.desp, func(t *testing.T) { - err := val.GetValidator().Struct(tc.obj) - - if tc.err != "" { - assert.Error(t, err) - assert.Equal(t, tc.err, err.Error()) - return - } - - assert.NoError(t, err) - }) - } + + StructLevelValidationCtx(t, testCases) } func TestDefaultConditionStructLevelValidation(t *testing.T) { - type testCase struct { - desp string - obj DefaultCondition - err string - } - testCases := []testCase{ + baseWorkflow := buildWorkflow() + buildSwitchState(baseWorkflow, "start state") + buildDefaultCondition(&baseWorkflow.States[0]) + + buildDataCondition(&baseWorkflow.States[0], "data condition 1", "1=1") + buildEndByDataCondition(&baseWorkflow.States[0].SwitchState.DataConditions[0], true, false) + buildDataCondition(&baseWorkflow.States[0], "data condition 2", "1=1") + + buildOperationState(baseWorkflow, "end state") + buildEndByState(&baseWorkflow.States[1], true, false) + buildActionByOperationState(&baseWorkflow.States[1], "action 1") + buildFunctionRef(baseWorkflow, &baseWorkflow.States[1].OperationState.Actions[0], "function 1") + + buildTransitionByDefaultCondition(&baseWorkflow.States[0].SwitchState.DefaultCondition, &baseWorkflow.States[1]) + buildTransitionByDataCondition(&baseWorkflow.States[0].SwitchState.DataConditions[1], &baseWorkflow.States[1], false) + + testCases := []ValidationCase{ { - desp: "normal & end", - obj: DefaultCondition{ - End: &End{ - Terminate: true, - }, + Desp: "success", + Model: func() Workflow { + return *baseWorkflow.DeepCopy() }, - err: ``, }, { - desp: "normal & transition", - obj: DefaultCondition{ - Transition: &Transition{ - NextState: "1", - }, + Desp: "required", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].SwitchState.DataConditions[0].End = nil + return *model }, - err: ``, - }, - { - desp: "missing end & transition", - obj: DefaultCondition{}, - err: `DefaultCondition.Transition' Error:Field validation for 'Transition' failed on the 'required' tag`, + Err: `workflow.states[0].switchState.dataConditions[0].transition is required`, }, { - desp: "exclusive end & transition", - obj: DefaultCondition{ - End: &End{ - Terminate: true, - }, - Transition: &Transition{ - NextState: "1", - }, + Desp: "exclusive", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + buildTransitionByDataCondition(&model.States[0].SwitchState.DataConditions[0], &model.States[1], false) + return *model }, - err: `Key: 'DefaultCondition.Transition' Error:Field validation for 'Transition' failed on the 'exclusive' tag`, + Err: `workflow.states[0].switchState.dataConditions[0].transition exclusive`, }, } - for _, tc := range testCases[2:] { - t.Run(tc.desp, func(t *testing.T) { - err := val.GetValidator().Struct(tc.obj) - - if tc.err != "" { - assert.Error(t, err) - assert.Regexp(t, tc.err, err) - return - } - - assert.NoError(t, err) - }) - } + + StructLevelValidationCtx(t, testCases) +} + +func TestSwitchStateTimeoutStructLevelValidation(t *testing.T) { } func TestEventConditionStructLevelValidation(t *testing.T) { - type testCase struct { - desp string - obj EventCondition - err string - } - testCases := []testCase{ + baseWorkflow := buildWorkflow() + baseWorkflow.States = make(States, 0, 2) + + // switch state + switchState := buildSwitchState(baseWorkflow, "start state") + + // default condition + defaultCondition := buildDefaultCondition(switchState) + buildEndByDefaultCondition(defaultCondition, true, false) + + // event condition 1 + _, eventCondition := buildEventCondition(baseWorkflow, switchState, "data condition 1", "event 1") + buildEndByEventCondition(eventCondition, true, false) + + // event condition 2 + _, eventCondition2 := buildEventCondition(baseWorkflow, switchState, "data condition 2", "event 2") + buildEndByEventCondition(eventCondition2, true, false) + + // operation state + operationState := buildOperationState(baseWorkflow, "end state") + buildEndByState(operationState, true, false) + action1 := buildActionByOperationState(operationState, "action 1") + buildFunctionRef(baseWorkflow, action1, "function 1") + + // trasition switch state to operation state + buildTransitionByEventCondition(eventCondition, operationState, false) + + testCases := []ValidationCase{ { - desp: "normal & end", - obj: EventCondition{ - EventRef: "1", - End: &End{ - Terminate: true, - }, + Desp: "success", + Model: func() Workflow { + return *baseWorkflow.DeepCopy() }, - err: ``, }, { - desp: "normal & transition", - obj: EventCondition{ - EventRef: "1", - Transition: &Transition{ - NextState: "1", - }, + Desp: "exists", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].SwitchState.EventConditions[0].EventRef = "event not found" + return *model }, - err: ``, + Err: `workflow.states[0].switchState.eventConditions[0].eventRef don't exist "event not found"`, }, { - desp: "missing end & transition", - obj: EventCondition{ - EventRef: "1", + Desp: "required", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].SwitchState.EventConditions[0].End = nil + return *model }, - err: `Key: 'EventCondition.Transition' Error:Field validation for 'Transition' failed on the 'required' tag`, + Err: `workflow.states[0].switchState.eventConditions[0].transition is required`, }, { - desp: "exclusive end & transition", - obj: EventCondition{ - EventRef: "1", - End: &End{ - Terminate: true, - }, - Transition: &Transition{ - NextState: "1", - }, + Desp: "exclusive", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + buildTransitionByEventCondition(&model.States[0].SwitchState.EventConditions[0], &model.States[1], false) + return *model }, - err: `Key: 'EventCondition.Transition' Error:Field validation for 'Transition' failed on the 'exclusive' tag`, + Err: `workflow.states[0].switchState.eventConditions[0].transition exclusive`, }, } - for _, tc := range testCases { - t.Run(tc.desp, func(t *testing.T) { - err := val.GetValidator().Struct(tc.obj) - - if tc.err != "" { - assert.Error(t, err) - assert.Regexp(t, tc.err, err) - return - } - - assert.NoError(t, err) - }) - } + + StructLevelValidationCtx(t, testCases) } func TestDataConditionStructLevelValidation(t *testing.T) { - type testCase struct { - desp string - obj DataCondition - err string - } - testCases := []testCase{ - { - desp: "normal & end", - obj: DataCondition{ - Condition: "1", - End: &End{ - Terminate: true, - }, - }, - err: ``, - }, + baseWorkflow := buildWorkflow() + // switch state + swithcState := buildSwitchState(baseWorkflow, "start state") + + // default condition + defaultCondition := buildDefaultCondition(swithcState) + buildEndByDefaultCondition(defaultCondition, true, false) + + // data condition + dataCondition := buildDataCondition(swithcState, "data condition 1", "1=1") + buildEndByDataCondition(dataCondition, true, false) + + // operation state + operationState := buildOperationState(baseWorkflow, "end state") + buildEndByState(operationState, true, false) + action1 := buildActionByOperationState(operationState, "action 1") + buildFunctionRef(baseWorkflow, action1, "function 1") + + testCases := []ValidationCase{ { - desp: "normal & transition", - obj: DataCondition{ - Condition: "1", - Transition: &Transition{ - NextState: "1", - }, + Desp: "success", + Model: func() Workflow { + return *baseWorkflow.DeepCopy() }, - err: ``, }, { - desp: "missing end & transition", - obj: DataCondition{ - Condition: "1", + Desp: "required", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].SwitchState.DataConditions[0].End = nil + return *model }, - err: `Key: 'DataCondition.Transition' Error:Field validation for 'Transition' failed on the 'required' tag`, + Err: `workflow.states[0].switchState.dataConditions[0].transition is required`, }, { - desp: "exclusive end & transition", - obj: DataCondition{ - Condition: "1", - End: &End{ - Terminate: true, - }, - Transition: &Transition{ - NextState: "1", - }, + Desp: "exclusive", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + buildTransitionByDataCondition(&model.States[0].SwitchState.DataConditions[0], &model.States[1], false) + return *model }, - err: `Key: 'DataCondition.Transition' Error:Field validation for 'Transition' failed on the 'exclusive' tag`, + Err: `workflow.states[0].switchState.dataConditions[0].transition exclusive`, }, } - for _, tc := range testCases { - t.Run(tc.desp, func(t *testing.T) { - err := val.GetValidator().Struct(tc.obj) - - if tc.err != "" { - assert.Error(t, err) - assert.Regexp(t, tc.err, err) - return - } - - assert.NoError(t, err) - }) - } + + StructLevelValidationCtx(t, testCases) } diff --git a/model/workflow.go b/model/workflow.go index c3b9694..58b382a 100644 --- a/model/workflow.go +++ b/model/workflow.go @@ -16,13 +16,18 @@ package model import ( "encoding/json" + + "github.com/serverlessworkflow/sdk-go/v2/util" ) // InvokeKind defines how the target is invoked. type InvokeKind string func (i InvokeKind) KindValues() []string { - return []string{string(InvokeKindSync), string(InvokeKindAsync)} + return []string{ + string(InvokeKindSync), + string(InvokeKindAsync), + } } func (i InvokeKind) String() string { @@ -40,6 +45,17 @@ const ( // ActionMode specifies how actions are to be performed. type ActionMode string +func (i ActionMode) KindValues() []string { + return []string{ + string(ActionModeSequential), + string(ActionModeParallel), + } +} + +func (i ActionMode) String() string { + return string(i) +} + const ( // ActionModeSequential specifies actions should be performed in sequence ActionModeSequential ActionMode = "sequential" @@ -55,6 +71,17 @@ const ( type ExpressionLangType string +func (i ExpressionLangType) KindValues() []string { + return []string{ + string(JqExpressionLang), + string(JsonPathExpressionLang), + } +} + +func (i ExpressionLangType) String() string { + return string(i) +} + const ( //JqExpressionLang ... JqExpressionLang ExpressionLangType = "jq" @@ -99,7 +126,7 @@ type BaseWorkflow struct { // Secrets allow you to access sensitive information, such as passwords, OAuth tokens, ssh keys, etc, // inside your Workflow Expressions. // +optional - Secrets Secrets `json:"secrets,omitempty"` + Secrets Secrets `json:"secrets,omitempty" validate:"unique"` // Constants Workflow constants are used to define static, and immutable, data which is available to // Workflow Expressions. // +optional @@ -108,13 +135,13 @@ type BaseWorkflow struct { // +kubebuilder:validation:Enum=jq;jsonpath // +kubebuilder:default=jq // +optional - ExpressionLang ExpressionLangType `json:"expressionLang,omitempty" validate:"omitempty,min=1,oneof=jq jsonpath"` + ExpressionLang ExpressionLangType `json:"expressionLang,omitempty" validate:"required,oneofkind"` // Defines the workflow default timeout settings. // +optional Timeouts *Timeouts `json:"timeouts,omitempty"` // Defines checked errors that can be explicitly handled during workflow execution. // +optional - Errors Errors `json:"errors,omitempty"` + Errors Errors `json:"errors,omitempty" validate:"unique=Name,dive"` // If "true", workflow instances is not terminated when there are no active execution paths. // Instance can be terminated with "terminate end definition" or reaching defined "workflowExecTimeout" // +optional @@ -133,7 +160,7 @@ type BaseWorkflow struct { // +kubebuilder:validation:Schemaless // +kubebuilder:pruning:PreserveUnknownFields // +optional - Auth Auths `json:"auth,omitempty" validate:"omitempty"` + Auth Auths `json:"auth,omitempty" validate:"unique=Name,dive"` } type Auths []Auth @@ -142,7 +169,7 @@ type authsUnmarshal Auths // UnmarshalJSON implements json.Unmarshaler func (r *Auths) UnmarshalJSON(data []byte) error { - return unmarshalObjectOrFile("auth", data, (*authsUnmarshal)(r)) + return util.UnmarshalObjectOrFile("auth", data, (*authsUnmarshal)(r)) } type Errors []Error @@ -151,21 +178,20 @@ type errorsUnmarshal Errors // UnmarshalJSON implements json.Unmarshaler func (e *Errors) UnmarshalJSON(data []byte) error { - return unmarshalObjectOrFile("errors", data, (*errorsUnmarshal)(e)) + return util.UnmarshalObjectOrFile("errors", data, (*errorsUnmarshal)(e)) } // Workflow base definition type Workflow struct { BaseWorkflow `json:",inline"` - // +kubebuilder:validation:MinItems=1 // +kubebuilder:pruning:PreserveUnknownFields - States []State `json:"states" validate:"required,min=1,dive"` + States States `json:"states" validate:"min=1,unique=Name,dive"` // +optional - Events Events `json:"events,omitempty"` + Events Events `json:"events,omitempty" validate:"unique=Name,dive"` // +optional - Functions Functions `json:"functions,omitempty"` + Functions Functions `json:"functions,omitempty" validate:"unique=Name,dive"` // +optional - Retries Retries `json:"retries,omitempty" validate:"dive"` + Retries Retries `json:"retries,omitempty" validate:"unique=Name,dive"` } type workflowUnmarshal Workflow @@ -173,7 +199,7 @@ type workflowUnmarshal Workflow // UnmarshalJSON implementation for json Unmarshal function for the Workflow type func (w *Workflow) UnmarshalJSON(data []byte) error { w.ApplyDefault() - err := unmarshalObject("workflow", data, (*workflowUnmarshal)(w)) + err := util.UnmarshalObject("workflow", data, (*workflowUnmarshal)(w)) if err != nil { return err } @@ -192,13 +218,14 @@ func (w *Workflow) ApplyDefault() { w.ExpressionLang = JqExpressionLang } +// +kubebuilder:validation:MinItems=1 type States []State type statesUnmarshal States // UnmarshalJSON implements json.Unmarshaler func (s *States) UnmarshalJSON(data []byte) error { - return unmarshalObject("states", data, (*statesUnmarshal)(s)) + return util.UnmarshalObject("states", data, (*statesUnmarshal)(s)) } type Events []Event @@ -207,7 +234,7 @@ type eventsUnmarshal Events // UnmarshalJSON implements json.Unmarshaler func (e *Events) UnmarshalJSON(data []byte) error { - return unmarshalObjectOrFile("events", data, (*eventsUnmarshal)(e)) + return util.UnmarshalObjectOrFile("events", data, (*eventsUnmarshal)(e)) } type Functions []Function @@ -216,7 +243,7 @@ type functionsUnmarshal Functions // UnmarshalJSON implements json.Unmarshaler func (f *Functions) UnmarshalJSON(data []byte) error { - return unmarshalObjectOrFile("functions", data, (*functionsUnmarshal)(f)) + return util.UnmarshalObjectOrFile("functions", data, (*functionsUnmarshal)(f)) } type Retries []Retry @@ -225,7 +252,7 @@ type retriesUnmarshal Retries // UnmarshalJSON implements json.Unmarshaler func (r *Retries) UnmarshalJSON(data []byte) error { - return unmarshalObjectOrFile("retries", data, (*retriesUnmarshal)(r)) + return util.UnmarshalObjectOrFile("retries", data, (*retriesUnmarshal)(r)) } // Timeouts ... @@ -252,7 +279,7 @@ type timeoutsUnmarshal Timeouts // UnmarshalJSON implements json.Unmarshaler func (t *Timeouts) UnmarshalJSON(data []byte) error { - return unmarshalObjectOrFile("timeouts", data, (*timeoutsUnmarshal)(t)) + return util.UnmarshalObjectOrFile("timeouts", data, (*timeoutsUnmarshal)(t)) } // WorkflowExecTimeout property defines the workflow execution timeout. It is defined using the ISO 8601 duration @@ -260,7 +287,7 @@ func (t *Timeouts) UnmarshalJSON(data []byte) error { type WorkflowExecTimeout struct { // Workflow execution timeout duration (ISO 8601 duration format). If not specified should be 'unlimited'. // +kubebuilder:default=unlimited - Duration string `json:"duration" validate:"required,min=1"` + Duration string `json:"duration" validate:"required,min=1,iso8601duration"` // If false, workflow instance is allowed to finish current execution. If true, current workflow execution // is stopped immediately. Default is false. // +optional @@ -275,7 +302,7 @@ type workflowExecTimeoutUnmarshal WorkflowExecTimeout // UnmarshalJSON implements json.Unmarshaler func (w *WorkflowExecTimeout) UnmarshalJSON(data []byte) error { w.ApplyDefault() - return unmarshalPrimitiveOrObject("workflowExecTimeout", data, &w.Duration, (*workflowExecTimeoutUnmarshal)(w)) + return util.UnmarshalPrimitiveOrObject("workflowExecTimeout", data, &w.Duration, (*workflowExecTimeoutUnmarshal)(w)) } // ApplyDefault set the default values for Workflow Exec Timeout @@ -312,7 +339,7 @@ type startUnmarshal Start // UnmarshalJSON implements json.Unmarshaler func (s *Start) UnmarshalJSON(data []byte) error { - return unmarshalPrimitiveOrObject("start", data, &s.StateName, (*startUnmarshal)(s)) + return util.UnmarshalPrimitiveOrObject("start", data, &s.StateName, (*startUnmarshal)(s)) } // Schedule ... @@ -335,7 +362,7 @@ type scheduleUnmarshal Schedule // UnmarshalJSON implements json.Unmarshaler func (s *Schedule) UnmarshalJSON(data []byte) error { - return unmarshalPrimitiveOrObject("schedule", data, &s.Interval, (*scheduleUnmarshal)(s)) + return util.UnmarshalPrimitiveOrObject("schedule", data, &s.Interval, (*scheduleUnmarshal)(s)) } // Cron ... @@ -352,12 +379,13 @@ type cronUnmarshal Cron // UnmarshalJSON custom unmarshal function for Cron func (c *Cron) UnmarshalJSON(data []byte) error { - return unmarshalPrimitiveOrObject("cron", data, &c.Expression, (*cronUnmarshal)(c)) + return util.UnmarshalPrimitiveOrObject("cron", data, &c.Expression, (*cronUnmarshal)(c)) } // Transition Serverless workflow states can have one or more incoming and outgoing transitions (from/to other states). // Each state can define a transition definition that is used to determine which state to transition to next. type Transition struct { + stateParent *State `json:"-"` // used in validation // Name of the state to transition to next. // +kubebuilder:validation:Required NextState string `json:"nextState" validate:"required,min=1"` @@ -374,7 +402,7 @@ type transitionUnmarshal Transition // UnmarshalJSON implements json.Unmarshaler func (t *Transition) UnmarshalJSON(data []byte) error { - return unmarshalPrimitiveOrObject("transition", data, &t.NextState, (*transitionUnmarshal)(t)) + return util.UnmarshalPrimitiveOrObject("transition", data, &t.NextState, (*transitionUnmarshal)(t)) } // OnError ... @@ -382,7 +410,7 @@ type OnError struct { // ErrorRef Reference to a unique workflow error definition. Used of errorRefs is not used ErrorRef string `json:"errorRef,omitempty"` // ErrorRefs References one or more workflow error definitions. Used if errorRef is not used - ErrorRefs []string `json:"errorRefs,omitempty"` + ErrorRefs []string `json:"errorRefs,omitempty" validate:"omitempty,unique"` // Transition to next state to handle the error. If retryRef is defined, this transition is taken only if // retries were unsuccessful. // +kubebuilder:validation:Schemaless @@ -418,7 +446,7 @@ type endUnmarshal End // UnmarshalJSON implements json.Unmarshaler func (e *End) UnmarshalJSON(data []byte) error { - return unmarshalPrimitiveOrObject("end", data, &e.Terminate, (*endUnmarshal)(e)) + return util.UnmarshalPrimitiveOrObject("end", data, &e.Terminate, (*endUnmarshal)(e)) } // ContinueAs can be used to stop the current workflow execution and start another one (of the same or a different type) @@ -443,7 +471,7 @@ type continueAsUnmarshal ContinueAs // UnmarshalJSON implements json.Unmarshaler func (c *ContinueAs) UnmarshalJSON(data []byte) error { - return unmarshalPrimitiveOrObject("continueAs", data, &c.WorkflowID, (*continueAsUnmarshal)(c)) + return util.UnmarshalPrimitiveOrObject("continueAs", data, &c.WorkflowID, (*continueAsUnmarshal)(c)) } // ProduceEvent Defines the event (CloudEvent format) to be produced when workflow execution completes or during a @@ -483,7 +511,7 @@ type dataInputSchemaUnmarshal DataInputSchema // UnmarshalJSON implements json.Unmarshaler func (d *DataInputSchema) UnmarshalJSON(data []byte) error { d.ApplyDefault() - return unmarshalPrimitiveOrObject("dataInputSchema", data, &d.Schema, (*dataInputSchemaUnmarshal)(d)) + return util.UnmarshalPrimitiveOrObject("dataInputSchema", data, &d.Schema, (*dataInputSchemaUnmarshal)(d)) } // ApplyDefault set the default values for Data Input Schema @@ -499,7 +527,7 @@ type secretsUnmarshal Secrets // UnmarshalJSON implements json.Unmarshaler func (s *Secrets) UnmarshalJSON(data []byte) error { - return unmarshalObjectOrFile("secrets", data, (*secretsUnmarshal)(s)) + return util.UnmarshalObjectOrFile("secrets", data, (*secretsUnmarshal)(s)) } // Constants Workflow constants are used to define static, and immutable, data which is available to Workflow Expressions. @@ -511,7 +539,7 @@ type Constants struct { // UnmarshalJSON implements json.Unmarshaler func (c *Constants) UnmarshalJSON(data []byte) error { - return unmarshalObjectOrFile("constants", data, &c.Data) + return util.UnmarshalObjectOrFile("constants", data, &c.Data) } type ConstantsData map[string]json.RawMessage diff --git a/model/workflow_ref.go b/model/workflow_ref.go index f0ec215..4c558cc 100644 --- a/model/workflow_ref.go +++ b/model/workflow_ref.go @@ -14,6 +14,27 @@ package model +import "github.com/serverlessworkflow/sdk-go/v2/util" + +// CompletionType define on how to complete branch execution. +type OnParentCompleteType string + +func (i OnParentCompleteType) KindValues() []string { + return []string{ + string(OnParentCompleteTypeTerminate), + string(OnParentCompleteTypeContinue), + } +} + +func (i OnParentCompleteType) String() string { + return string(i) +} + +const ( + OnParentCompleteTypeTerminate OnParentCompleteType = "terminate" + OnParentCompleteTypeContinue OnParentCompleteType = "continue" +) + // WorkflowRef holds a reference for a workflow definition type WorkflowRef struct { // Sub-workflow unique id @@ -32,7 +53,7 @@ type WorkflowRef struct { // is 'async'. Defaults to terminate. // +kubebuilder:validation:Enum=terminate;continue // +kubebuilder:default=terminate - OnParentComplete string `json:"onParentComplete,omitempty" validate:"required,oneof=terminate continue"` + OnParentComplete OnParentCompleteType `json:"onParentComplete,omitempty" validate:"required,oneofkind"` } type workflowRefUnmarshal WorkflowRef @@ -40,7 +61,7 @@ type workflowRefUnmarshal WorkflowRef // UnmarshalJSON implements json.Unmarshaler func (s *WorkflowRef) UnmarshalJSON(data []byte) error { s.ApplyDefault() - return unmarshalPrimitiveOrObject("subFlowRef", data, &s.WorkflowID, (*workflowRefUnmarshal)(s)) + return util.UnmarshalPrimitiveOrObject("subFlowRef", data, &s.WorkflowID, (*workflowRefUnmarshal)(s)) } // ApplyDefault set the default values for Workflow Ref diff --git a/model/workflow_ref_test.go b/model/workflow_ref_test.go index 4788a16..4a69fb5 100644 --- a/model/workflow_ref_test.go +++ b/model/workflow_ref_test.go @@ -19,8 +19,6 @@ import ( "testing" "github.com/stretchr/testify/assert" - - val "github.com/serverlessworkflow/sdk-go/v2/validator" ) func TestWorkflowRefUnmarshalJSON(t *testing.T) { @@ -105,76 +103,3 @@ func TestWorkflowRefUnmarshalJSON(t *testing.T) { }) } } - -func TestWorkflowRefValidate(t *testing.T) { - type testCase struct { - desp string - workflowRef WorkflowRef - err string - } - testCases := []testCase{ - { - desp: "all field & defaults", - workflowRef: WorkflowRef{ - WorkflowID: "1", - Version: "2", - Invoke: InvokeKindSync, - OnParentComplete: "terminate", - }, - err: ``, - }, - { - desp: "all field", - workflowRef: WorkflowRef{ - WorkflowID: "1", - Version: "2", - Invoke: InvokeKindAsync, - OnParentComplete: "continue", - }, - err: ``, - }, - { - desp: "missing workflowId", - workflowRef: WorkflowRef{ - WorkflowID: "", - Version: "2", - Invoke: InvokeKindSync, - OnParentComplete: "terminate", - }, - err: `Key: 'WorkflowRef.WorkflowID' Error:Field validation for 'WorkflowID' failed on the 'required' tag`, - }, - { - desp: "invalid invoke", - workflowRef: WorkflowRef{ - WorkflowID: "1", - Version: "2", - Invoke: "sync1", - OnParentComplete: "terminate", - }, - err: `Key: 'WorkflowRef.Invoke' Error:Field validation for 'Invoke' failed on the 'oneofkind' tag`, - }, - { - desp: "invalid onParentComplete", - workflowRef: WorkflowRef{ - WorkflowID: "1", - Version: "2", - Invoke: InvokeKindSync, - OnParentComplete: "terminate1", - }, - err: `Key: 'WorkflowRef.OnParentComplete' Error:Field validation for 'OnParentComplete' failed on the 'oneof' tag`, - }, - } - for _, tc := range testCases { - t.Run(tc.desp, func(t *testing.T) { - err := val.GetValidator().Struct(tc.workflowRef) - - if tc.err != "" { - assert.Error(t, err) - assert.Regexp(t, tc.err, err) - return - } - - assert.NoError(t, err) - }) - } -} diff --git a/model/workflow_ref_validator_test.go b/model/workflow_ref_validator_test.go new file mode 100644 index 0000000..96a7f9c --- /dev/null +++ b/model/workflow_ref_validator_test.go @@ -0,0 +1,68 @@ +// Copyright 2022 The Serverless Workflow Specification Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package model + +import "testing" + +func TestWorkflowRefStructLevelValidation(t *testing.T) { + baseWorkflow := buildWorkflow() + + operationState := buildOperationState(baseWorkflow, "start state") + buildEndByState(&baseWorkflow.States[0], true, false) + action1 := buildActionByOperationState(operationState, "action 1") + buildFunctionRef(baseWorkflow, action1, "function 1") + + baseWorkflow.States[0].OperationState.Actions[0].FunctionRef = nil + baseWorkflow.States[0].OperationState.Actions[0].SubFlowRef = &WorkflowRef{ + WorkflowID: "workflowID", + Invoke: InvokeKindSync, + OnParentComplete: OnParentCompleteTypeTerminate, + } + + testCases := []ValidationCase{ + { + Desp: "success", + Model: func() Workflow { + return *baseWorkflow.DeepCopy() + }, + }, + { + Desp: "required", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].OperationState.Actions[0].SubFlowRef.WorkflowID = "" + model.States[0].OperationState.Actions[0].SubFlowRef.Invoke = "" + model.States[0].OperationState.Actions[0].SubFlowRef.OnParentComplete = "" + return *model + }, + Err: `workflow.states[0].actions[0].subFlowRef.workflowID is required +workflow.states[0].actions[0].subFlowRef.invoke is required +workflow.states[0].actions[0].subFlowRef.onParentComplete is required`, + }, + { + Desp: "oneofkind", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].OperationState.Actions[0].SubFlowRef.Invoke = "invalid invoce" + model.States[0].OperationState.Actions[0].SubFlowRef.OnParentComplete = "invalid parent complete" + return *model + }, + Err: `workflow.states[0].actions[0].subFlowRef.invoke need by one of [sync async] +workflow.states[0].actions[0].subFlowRef.onParentComplete need by one of [terminate continue]`, + }, + } + + StructLevelValidationCtx(t, testCases) +} diff --git a/model/workflow_test.go b/model/workflow_test.go index 86a0ecc..29a3720 100644 --- a/model/workflow_test.go +++ b/model/workflow_test.go @@ -21,6 +21,7 @@ import ( "net/http/httptest" "testing" + "github.com/serverlessworkflow/sdk-go/v2/util" "github.com/stretchr/testify/assert" ) @@ -567,7 +568,7 @@ func TestConstantsUnmarshalJSON(t *testing.T) { } })) defer server.Close() - httpClient = *server.Client() + util.HttpClient = *server.Client() type testCase struct { desp string diff --git a/model/workflow_validator.go b/model/workflow_validator.go index 2ea7cf5..7d94d1f 100644 --- a/model/workflow_validator.go +++ b/model/workflow_validator.go @@ -15,83 +15,216 @@ package model import ( - "reflect" + "context" validator "github.com/go-playground/validator/v10" + val "github.com/serverlessworkflow/sdk-go/v2/validator" ) -func init() { - val.GetValidator().RegisterStructValidation(continueAsStructLevelValidation, ContinueAs{}) - val.GetValidator().RegisterStructValidation(workflowStructLevelValidation, Workflow{}) -} +type contextValueKey string + +const ValidatorContextValue contextValueKey = "value" + +type WorkflowValidator func(mapValues ValidatorContext, sl validator.StructLevel) -func continueAsStructLevelValidation(structLevel validator.StructLevel) { - continueAs := structLevel.Current().Interface().(ContinueAs) - if len(continueAs.WorkflowExecTimeout.Duration) > 0 { - if err := val.ValidateISO8601TimeDuration(continueAs.WorkflowExecTimeout.Duration); err != nil { - structLevel.ReportError(reflect.ValueOf(continueAs.WorkflowExecTimeout.Duration), - "workflowExecTimeout", "duration", "iso8601duration", "") +func ValidationWrap(fnCtx WorkflowValidator) validator.StructLevelFuncCtx { + return func(ctx context.Context, structLevel validator.StructLevel) { + if fnCtx != nil { + if mapValues, ok := ctx.Value(ValidatorContextValue).(ValidatorContext); ok { + fnCtx(mapValues, structLevel) + } } } } -// WorkflowStructLevelValidation custom validator -func workflowStructLevelValidation(structLevel validator.StructLevel) { - // unique name of the auth methods - // NOTE: we cannot add the custom validation of auth to Auth - // because `RegisterStructValidation` only works with struct type - wf := structLevel.Current().Interface().(Workflow) - dict := map[string]bool{} - - for _, a := range wf.BaseWorkflow.Auth { - if !dict[a.Name] { - dict[a.Name] = true - } else { - structLevel.ReportError(reflect.ValueOf(a.Name), "[]Auth.Name", "name", "reqnameunique", "") - } +type ValidatorContext struct { + States map[string]State + Functions map[string]Function + Events map[string]Event + Retries map[string]Retry + Errors map[string]Error +} + +func (c *ValidatorContext) init(workflow *Workflow) { + c.States = make(map[string]State, len(workflow.States)) + for _, state := range workflow.States { + c.States[state.BaseState.Name] = state } - startAndStatesTransitionValidator(structLevel, wf.BaseWorkflow.Start, wf.States) -} + c.Functions = make(map[string]Function, len(workflow.Functions)) + for _, function := range workflow.Functions { + c.Functions[function.Name] = function + } + + c.Events = make(map[string]Event, len(workflow.Events)) + for _, event := range workflow.Events { + c.Events[event.Name] = event + } -func startAndStatesTransitionValidator(structLevel validator.StructLevel, start *Start, states []State) { - statesMap := make(map[string]State, len(states)) - for _, state := range states { - statesMap[state.Name] = state + c.Retries = make(map[string]Retry, len(workflow.Retries)) + for _, retry := range workflow.Retries { + c.Retries[retry.Name] = retry } - if start != nil { - // if not exists the start transtion stop the states validations - if _, ok := statesMap[start.StateName]; !ok { - structLevel.ReportError(reflect.ValueOf(start), "Start", "start", "startnotexist", "") - return + c.Errors = make(map[string]Error, len(workflow.Errors)) + for _, error := range workflow.Errors { + c.Errors[error.Name] = error + } +} + +func (c *ValidatorContext) ExistState(name string) bool { + _, ok := c.States[name] + return ok +} + +func (c *ValidatorContext) ExistFunction(name string) bool { + _, ok := c.Functions[name] + return ok +} + +func (c *ValidatorContext) ExistEvent(name string) bool { + _, ok := c.Events[name] + return ok +} + +func (c *ValidatorContext) ExistRetry(name string) bool { + _, ok := c.Retries[name] + return ok +} + +func (c *ValidatorContext) ExistError(name string) bool { + _, ok := c.Errors[name] + return ok +} + +func NewValidatorContext(workflow *Workflow) context.Context { + for i := range workflow.States { + s := &workflow.States[i] + if s.BaseState.Transition != nil { + s.BaseState.Transition.stateParent = s + } + for _, onError := range s.BaseState.OnErrors { + if onError.Transition != nil { + onError.Transition.stateParent = s + } + } + if s.Type == StateTypeSwitch { + if s.SwitchState.DefaultCondition.Transition != nil { + s.SwitchState.DefaultCondition.Transition.stateParent = s + } + for _, e := range s.SwitchState.EventConditions { + if e.Transition != nil { + e.Transition.stateParent = s + } + } + for _, d := range s.SwitchState.DataConditions { + if d.Transition != nil { + d.Transition.stateParent = s + } + } } } - if len(states) == 1 { + contextValue := ValidatorContext{} + contextValue.init(workflow) + + return context.WithValue(context.Background(), ValidatorContextValue, contextValue) +} + +func init() { + // TODO: create states graph to complex check + + // val.GetValidator().RegisterStructValidationCtx(val.ValidationWrap(nil, workflowStructLevelValidation), Workflow{}) + val.GetValidator().RegisterStructValidationCtx(ValidationWrap(onErrorStructLevelValidationCtx), OnError{}) + val.GetValidator().RegisterStructValidationCtx(ValidationWrap(transitionStructLevelValidationCtx), Transition{}) + val.GetValidator().RegisterStructValidationCtx(ValidationWrap(startStructLevelValidationCtx), Start{}) +} + +func startStructLevelValidationCtx(ctx ValidatorContext, structLevel validator.StructLevel) { + start := structLevel.Current().Interface().(Start) + if start.StateName != "" && !ctx.ExistState(start.StateName) { + structLevel.ReportError(start.StateName, "StateName", "stateName", val.TagExists, "") + return + } +} + +func onErrorStructLevelValidationCtx(ctx ValidatorContext, structLevel validator.StructLevel) { + onError := structLevel.Current().Interface().(OnError) + hasErrorRef := onError.ErrorRef != "" + hasErrorRefs := len(onError.ErrorRefs) > 0 + + if !hasErrorRef && !hasErrorRefs { + structLevel.ReportError(onError.ErrorRef, "ErrorRef", "ErrorRef", val.TagRequired, "") + } else if hasErrorRef && hasErrorRefs { + structLevel.ReportError(onError.ErrorRef, "ErrorRef", "ErrorRef", val.TagExclusive, "") return } + if onError.ErrorRef != "" && !ctx.ExistError(onError.ErrorRef) { + structLevel.ReportError(onError.ErrorRef, "ErrorRef", "ErrorRef", val.TagExists, "") + } + + for _, errorRef := range onError.ErrorRefs { + if !ctx.ExistError(errorRef) { + structLevel.ReportError(onError.ErrorRefs, "ErrorRefs", "ErrorRefs", val.TagExists, "") + } + } +} + +func transitionStructLevelValidationCtx(ctx ValidatorContext, structLevel validator.StructLevel) { // Naive check if transitions exist - for _, state := range statesMap { - if state.Transition != nil { - if _, ok := statesMap[state.Transition.NextState]; !ok { - structLevel.ReportError(reflect.ValueOf(state), "Transition", "transition", "transitionnotexists", state.Transition.NextState) + transition := structLevel.Current().Interface().(Transition) + if ctx.ExistState(transition.NextState) { + if transition.stateParent != nil { + parentBaseState := transition.stateParent + + if parentBaseState.Name == transition.NextState { + // TODO: Improve recursive check + structLevel.ReportError(transition.NextState, "NextState", "NextState", val.TagRecursiveState, parentBaseState.Name) + } + + if parentBaseState.UsedForCompensation && !ctx.States[transition.NextState].BaseState.UsedForCompensation { + structLevel.ReportError(transition.NextState, "NextState", "NextState", val.TagTransitionUseForCompensation, "") + } + + if !parentBaseState.UsedForCompensation && ctx.States[transition.NextState].BaseState.UsedForCompensation { + structLevel.ReportError(transition.NextState, "NextState", "NextState", val.TagTransitionMainWorkflow, "") } } - } - // TODO: create states graph to complex check + } else { + structLevel.ReportError(transition.NextState, "NextState", "NextState", val.TagExists, "") + } } -func validTransitionAndEnd(structLevel validator.StructLevel, field interface{}, transition *Transition, end *End) { +func validTransitionAndEnd(structLevel validator.StructLevel, field any, transition *Transition, end *End) { hasTransition := transition != nil isEnd := end != nil && (end.Terminate || end.ContinueAs != nil || len(end.ProduceEvents) > 0) // TODO: check the spec continueAs/produceEvents to see how it influences the end if !hasTransition && !isEnd { - structLevel.ReportError(field, "Transition", "transition", "required", "must have one of transition, end") + structLevel.ReportError(field, "Transition", "transition", val.TagRequired, "") } else if hasTransition && isEnd { - structLevel.ReportError(field, "Transition", "transition", "exclusive", "must have one of transition, end") + structLevel.ReportError(field, "Transition", "transition", val.TagExclusive, "") + } +} + +func validationNotExclusiveParamters(values []bool) bool { + hasOne := false + hasTwo := false + + for i, val1 := range values { + if val1 { + hasOne = true + for j, val2 := range values { + if i != j && val2 { + hasTwo = true + break + } + } + break + } } + + return hasOne && hasTwo } diff --git a/model/workflow_validator_test.go b/model/workflow_validator_test.go index c305898..10e935a 100644 --- a/model/workflow_validator_test.go +++ b/model/workflow_validator_test.go @@ -22,216 +22,487 @@ import ( val "github.com/serverlessworkflow/sdk-go/v2/validator" ) -var workflowStructDefault = Workflow{ - BaseWorkflow: BaseWorkflow{ - ID: "id", - SpecVersion: "0.8", - Auth: Auths{ - { - Name: "auth name", +func buildWorkflow() *Workflow { + return &Workflow{ + BaseWorkflow: BaseWorkflow{ + ID: "id", + Key: "key", + Name: "name", + SpecVersion: "0.8", + Version: "0.1", + ExpressionLang: JqExpressionLang, + }, + } +} + +func buildEndByState(state *State, terminate, compensate bool) *End { + end := &End{ + Terminate: terminate, + Compensate: compensate, + } + state.BaseState.End = end + return end +} + +func buildEndByDefaultCondition(defaultCondition *DefaultCondition, terminate, compensate bool) *End { + end := &End{ + Terminate: terminate, + Compensate: compensate, + } + defaultCondition.End = end + return end +} + +func buildEndByDataCondition(dataCondition *DataCondition, terminate, compensate bool) *End { + end := &End{ + Terminate: terminate, + Compensate: compensate, + } + dataCondition.End = end + return end +} + +func buildEndByEventCondition(eventCondition *EventCondition, terminate, compensate bool) *End { + end := &End{ + Terminate: terminate, + Compensate: compensate, + } + eventCondition.End = end + return end +} + +func buildStart(workflow *Workflow, state *State) { + start := &Start{ + StateName: state.BaseState.Name, + } + workflow.BaseWorkflow.Start = start +} + +func buildTransitionByState(state, nextState *State, compensate bool) { + state.BaseState.Transition = &Transition{ + NextState: nextState.BaseState.Name, + Compensate: compensate, + } +} + +func buildTransitionByDataCondition(dataCondition *DataCondition, state *State, compensate bool) { + dataCondition.Transition = &Transition{ + NextState: state.BaseState.Name, + Compensate: compensate, + } +} + +func buildTransitionByEventCondition(eventCondition *EventCondition, state *State, compensate bool) { + eventCondition.Transition = &Transition{ + NextState: state.BaseState.Name, + Compensate: compensate, + } +} + +func buildTransitionByDefaultCondition(defaultCondition *DefaultCondition, state *State) { + defaultCondition.Transition = &Transition{ + NextState: state.BaseState.Name, + } +} + +func buildTimeouts(workflow *Workflow) *Timeouts { + timeouts := Timeouts{} + workflow.BaseWorkflow.Timeouts = &timeouts + return workflow.BaseWorkflow.Timeouts +} + +func TestBaseWorkflowStructLevelValidation(t *testing.T) { + baseWorkflow := buildWorkflow() + + operationState := buildOperationState(baseWorkflow, "start state") + buildEndByState(operationState, true, false) + action1 := buildActionByOperationState(operationState, "action 1") + buildFunctionRef(baseWorkflow, action1, "function 1") + + testCases := []ValidationCase{ + { + Desp: "success", + Model: func() Workflow { + return *baseWorkflow.DeepCopy() }, }, - Start: &Start{ - StateName: "name state", + { + Desp: "id exclude key", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.ID = "id" + model.Key = "" + return *model + }, }, - }, - States: []State{ { - BaseState: BaseState{ - Name: "name state", - Type: StateTypeOperation, - Transition: &Transition{ - NextState: "next name state", - }, + Desp: "key exclude id", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.ID = "" + model.Key = "key" + return *model }, - OperationState: &OperationState{ - ActionMode: "sequential", - Actions: []Action{ - {}, - }, + }, + { + Desp: "without id and key", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.ID = "" + model.Key = "" + return *model + }, + Err: `workflow.id required when "workflow.key" is not defined +workflow.key required when "workflow.id" is not defined`, + }, + { + Desp: "oneofkind", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.BaseWorkflow.ExpressionLang = JqExpressionLang + "invalid" + return *model }, + Err: `workflow.expressionLang need by one of [jq jsonpath]`, }, + } + + StructLevelValidationCtx(t, testCases) +} + +func TestContinueAsStructLevelValidation(t *testing.T) { + baseWorkflow := buildWorkflow() + + operationState := buildOperationState(baseWorkflow, "start state") + buildEndByState(operationState, true, false) + action1 := buildActionByOperationState(operationState, "action 1") + buildFunctionRef(baseWorkflow, action1, "function 1") + + baseWorkflow.States[0].BaseState.End.ContinueAs = &ContinueAs{ + WorkflowID: "sub workflow", + WorkflowExecTimeout: WorkflowExecTimeout{ + Duration: "P1M", + }, + } + + testCases := []ValidationCase{ { - BaseState: BaseState{ - Name: "next name state", - Type: StateTypeOperation, - End: &End{ - Terminate: true, - }, + Desp: "success", + Model: func() Workflow { + return *baseWorkflow.DeepCopy() }, - OperationState: &OperationState{ - ActionMode: "sequential", - Actions: []Action{ - {}, - }, + }, + { + Desp: "required", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].BaseState.End.ContinueAs.WorkflowID = "" + return *model }, + Err: `workflow.states[0].end.continueAs.workflowID is required`, }, - }, + } + + StructLevelValidationCtx(t, testCases) } -var listStateTransition1 = []State{ - { - BaseState: BaseState{ - Name: "name state", - Type: StateTypeOperation, - Transition: &Transition{ - NextState: "next name state", +func TestOnErrorStructLevelValidation(t *testing.T) { + baseWorkflow := buildWorkflow() + + operationState := buildOperationState(baseWorkflow, "start state") + buildEndByState(operationState, true, false) + action1 := buildActionByOperationState(operationState, "action 1") + buildFunctionRef(baseWorkflow, action1, "function 1") + + baseWorkflow.BaseWorkflow.Errors = Errors{{ + Name: "error 1", + }, { + Name: "error 2", + }} + baseWorkflow.States[0].BaseState.OnErrors = []OnError{{ + ErrorRef: "error 1", + }, { + ErrorRefs: []string{"error 1", "error 2"}, + }} + + testCases := []ValidationCase{ + { + Desp: "success", + Model: func() Workflow { + return *baseWorkflow.DeepCopy() }, }, - OperationState: &OperationState{ - ActionMode: "sequential", - Actions: []Action{{}}, - }, - }, - { - BaseState: BaseState{ - Name: "next name state", - Type: StateTypeOperation, - Transition: &Transition{ - NextState: "next name state 2", - }, - }, - OperationState: &OperationState{ - ActionMode: "sequential", - Actions: []Action{{}}, - }, - }, - { - BaseState: BaseState{ - Name: "next name state 2", - Type: StateTypeOperation, - End: &End{ - Terminate: true, - }, - }, - OperationState: &OperationState{ - ActionMode: "sequential", - Actions: []Action{{}}, - }, - }, -} - -func TestWorkflowStructLevelValidation(t *testing.T) { - type testCase[T any] struct { - name string - instance T - err string - } - testCases := []testCase[any]{ - { - name: "workflow success", - instance: workflowStructDefault, - }, - { - name: "workflow auth.name repeat", - instance: func() Workflow { - w := workflowStructDefault - w.Auth = append(w.Auth, w.Auth[0]) - return w - }(), - err: `Key: 'Workflow.[]Auth.Name' Error:Field validation for '[]Auth.Name' failed on the 'reqnameunique' tag`, - }, { - name: "workflow id exclude key", - instance: func() Workflow { - w := workflowStructDefault - w.ID = "id" - w.Key = "" - return w - }(), - err: ``, + Desp: "required", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].BaseState.OnErrors[0].ErrorRef = "" + return *model + }, + Err: `workflow.states[0].onErrors[0].errorRef is required`, }, { - name: "workflow key exclude id", - instance: func() Workflow { - w := workflowStructDefault - w.ID = "" - w.Key = "key" - return w - }(), - err: ``, + Desp: "exclusive", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].OnErrors[0].ErrorRef = "error 1" + model.States[0].OnErrors[0].ErrorRefs = []string{"error 2"} + return *model + }, + Err: `workflow.states[0].onErrors[0].errorRef or workflow.states[0].onErrors[0].errorRefs are exclusive`, }, { - name: "workflow id and key", - instance: func() Workflow { - w := workflowStructDefault - w.ID = "id" - w.Key = "key" - return w - }(), - err: ``, + Desp: "exists and exclusive", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].BaseState.OnErrors[0].ErrorRef = "invalid error name" + model.States[0].BaseState.OnErrors[0].ErrorRefs = []string{"invalid error name"} + return *model + }, + Err: `workflow.states[0].onErrors[0].errorRef or workflow.states[0].onErrors[0].errorRefs are exclusive`, }, { - name: "workflow without id and key", - instance: func() Workflow { - w := workflowStructDefault - w.ID = "" - w.Key = "" - return w - }(), - err: `Key: 'Workflow.BaseWorkflow.ID' Error:Field validation for 'ID' failed on the 'required_without' tag -Key: 'Workflow.BaseWorkflow.Key' Error:Field validation for 'Key' failed on the 'required_without' tag`, - }, - { - name: "workflow start", - instance: func() Workflow { - w := workflowStructDefault - w.Start = &Start{ - StateName: "start state not found", - } - return w - }(), - err: `Key: 'Workflow.Start' Error:Field validation for 'Start' failed on the 'startnotexist' tag`, + Desp: "exists errorRef", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].BaseState.OnErrors[0].ErrorRef = "invalid error name" + return *model + }, + Err: `workflow.states[0].onErrors[0].errorRef don't exist "invalid error name"`, + }, + { + Desp: "exists errorRefs", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].BaseState.OnErrors[0].ErrorRef = "" + model.States[0].BaseState.OnErrors[0].ErrorRefs = []string{"invalid error name"} + return *model + }, + Err: `workflow.states[0].onErrors[0].errorRefs don't exist ["invalid error name"]`, }, { - name: "workflow states transitions", - instance: func() Workflow { - w := workflowStructDefault - w.States = listStateTransition1 - return w - }(), - err: ``, + Desp: "duplicate", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].OnErrors[1].ErrorRefs = []string{"error 1", "error 1"} + return *model + }, + Err: `workflow.states[0].onErrors[1].errorRefs has duplicate value`, }, + } + + StructLevelValidationCtx(t, testCases) +} + +func TestStartStructLevelValidation(t *testing.T) { + baseWorkflow := buildWorkflow() + + operationState := buildOperationState(baseWorkflow, "start state") + buildStart(baseWorkflow, operationState) + buildEndByState(operationState, true, false) + action1 := buildActionByOperationState(operationState, "action 1") + buildFunctionRef(baseWorkflow, action1, "function 1") + + testCases := []ValidationCase{ { - name: "valid ContinueAs", - instance: ContinueAs{ - WorkflowID: "another-test", - Version: "2", - Data: FromString("${ del(.customerCount) }"), - WorkflowExecTimeout: WorkflowExecTimeout{ - Duration: "PT1H", - Interrupt: false, - RunBefore: "test", - }, + Desp: "success", + Model: func() Workflow { + return *baseWorkflow.DeepCopy() }, - err: ``, }, { - name: "invalid WorkflowExecTimeout", - instance: ContinueAs{ - WorkflowID: "test", - Version: "1", - Data: FromString("${ del(.customerCount) }"), - WorkflowExecTimeout: WorkflowExecTimeout{ - Duration: "invalid", - }, + Desp: "required", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.Start.StateName = "" + return *model }, - err: `Key: 'ContinueAs.workflowExecTimeout' Error:Field validation for 'workflowExecTimeout' failed on the 'iso8601duration' tag`, + Err: `workflow.start.stateName is required`, + }, + { + Desp: "exists", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.Start.StateName = "start state not found" + return *model + }, + Err: `workflow.start.stateName don't exist "start state not found"`, }, } - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - err := val.GetValidator().Struct(tc.instance) + StructLevelValidationCtx(t, testCases) +} + +func TestTransitionStructLevelValidation(t *testing.T) { + baseWorkflow := buildWorkflow() + baseWorkflow.States = make(States, 0, 5) + + operationState := buildOperationState(baseWorkflow, "start state") + action1 := buildActionByOperationState(operationState, "action 1") + buildFunctionRef(baseWorkflow, action1, "function 1") + + operationState2 := buildOperationState(baseWorkflow, "next state") + buildEndByState(operationState2, true, false) + operationState2.BaseState.CompensatedBy = "compensation next state 1" + action2 := buildActionByOperationState(operationState2, "action 1") + buildFunctionRef(baseWorkflow, action2, "function 2") + + buildTransitionByState(operationState, operationState2, false) + + operationState3 := buildOperationState(baseWorkflow, "compensation next state 1") + operationState3.BaseState.UsedForCompensation = true + action3 := buildActionByOperationState(operationState3, "action 1") + buildFunctionRef(baseWorkflow, action3, "function 3") + + operationState4 := buildOperationState(baseWorkflow, "compensation next state 2") + operationState4.BaseState.UsedForCompensation = true + action4 := buildActionByOperationState(operationState4, "action 1") + buildFunctionRef(baseWorkflow, action4, "function 4") + + buildTransitionByState(operationState3, operationState4, false) + + operationState5 := buildOperationState(baseWorkflow, "compensation next state 3") + buildEndByState(operationState5, true, false) + operationState5.BaseState.UsedForCompensation = true + action5 := buildActionByOperationState(operationState5, "action 5") + buildFunctionRef(baseWorkflow, action5, "function 5") + + buildTransitionByState(operationState4, operationState5, false) + + testCases := []ValidationCase{ + { + Desp: "success", + Model: func() Workflow { + return *baseWorkflow.DeepCopy() + }, + }, + { + Desp: "state recursive", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].BaseState.Transition.NextState = model.States[0].BaseState.Name + return *model + }, + Err: `workflow.states[0].transition.nextState can't no be recursive "start state"`, + }, + { + Desp: "exists", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].BaseState.Transition.NextState = "invalid next state" + return *model + }, + Err: `workflow.states[0].transition.nextState don't exist "invalid next state"`, + }, + { + Desp: "transitionusedforcompensation", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[3].BaseState.UsedForCompensation = false + return *model + }, + Err: `Key: 'Workflow.States[2].BaseState.Transition.NextState' Error:Field validation for 'NextState' failed on the 'transitionusedforcompensation' tag +Key: 'Workflow.States[3].BaseState.Transition.NextState' Error:Field validation for 'NextState' failed on the 'transtionmainworkflow' tag`, + }, + { + Desp: "transtionmainworkflow", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.States[0].BaseState.Transition.NextState = model.States[3].BaseState.Name + return *model + }, + Err: `Key: 'Workflow.States[0].BaseState.Transition.NextState' Error:Field validation for 'NextState' failed on the 'transtionmainworkflow' tag`, + }, + } + + StructLevelValidationCtx(t, testCases) +} + +func TestSecretsStructLevelValidation(t *testing.T) { + baseWorkflow := buildWorkflow() + + operationState := buildOperationState(baseWorkflow, "start state") + buildEndByState(operationState, true, false) + action1 := buildActionByOperationState(operationState, "action 1") + buildFunctionRef(baseWorkflow, action1, "function 1") + + testCases := []ValidationCase{ + { + Desp: "workflow secrets.name repeat", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.Secrets = []string{"secret 1", "secret 1"} + return *model + }, + Err: `workflow.secrets has duplicate value`, + }, + } + + StructLevelValidationCtx(t, testCases) +} + +func TestErrorStructLevelValidation(t *testing.T) { + baseWorkflow := buildWorkflow() + + operationState := buildOperationState(baseWorkflow, "start state") + buildEndByState(operationState, true, false) + action1 := buildActionByOperationState(operationState, "action 1") + buildFunctionRef(baseWorkflow, action1, "function 1") - if tc.err != "" { - assert.Error(t, err) - if err != nil { - assert.Equal(t, tc.err, err.Error()) + baseWorkflow.BaseWorkflow.Errors = Errors{{ + Name: "error 1", + }, { + Name: "error 2", + }} + + testCases := []ValidationCase{ + { + Desp: "success", + Model: func() Workflow { + return *baseWorkflow.DeepCopy() + }, + }, + { + Desp: "required", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.Errors[0].Name = "" + return *model + }, + Err: `workflow.errors[0].name is required`, + }, + { + Desp: "repeat", + Model: func() Workflow { + model := baseWorkflow.DeepCopy() + model.Errors = Errors{model.Errors[0], model.Errors[0]} + return *model + }, + Err: `workflow.errors has duplicate "name"`, + }, + } + + StructLevelValidationCtx(t, testCases) +} + +type ValidationCase struct { + Desp string + Model func() Workflow + Err string +} + +func StructLevelValidationCtx(t *testing.T, testCases []ValidationCase) { + for _, tc := range testCases { + t.Run(tc.Desp, func(t *testing.T) { + model := tc.Model() + err := val.GetValidator().StructCtx(NewValidatorContext(&model), model) + err = val.WorkflowError(err) + if tc.Err != "" { + if assert.Error(t, err) { + assert.Equal(t, tc.Err, err.Error()) } - return + } else { + assert.NoError(t, err) } - assert.NoError(t, err) }) } } diff --git a/model/zz_generated.deepcopy.go b/model/zz_generated.deepcopy.go index d04a11b..804706f 100644 --- a/model/zz_generated.deepcopy.go +++ b/model/zz_generated.deepcopy.go @@ -747,6 +747,28 @@ func (in *EventCondition) DeepCopy() *EventCondition { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in EventConditions) DeepCopyInto(out *EventConditions) { + { + in := &in + *out = make(EventConditions, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + return + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EventConditions. +func (in EventConditions) DeepCopy() EventConditions { + if in == nil { + return nil + } + out := new(EventConditions) + in.DeepCopyInto(out) + return *out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *EventDataFilter) DeepCopyInto(out *EventDataFilter) { *out = *in @@ -1567,7 +1589,7 @@ func (in *SwitchState) DeepCopyInto(out *SwitchState) { in.DefaultCondition.DeepCopyInto(&out.DefaultCondition) if in.EventConditions != nil { in, out := &in.EventConditions, &out.EventConditions - *out = make([]EventCondition, len(*in)) + *out = make(EventConditions, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) } @@ -1647,6 +1669,11 @@ func (in *Timeouts) DeepCopy() *Timeouts { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Transition) DeepCopyInto(out *Transition) { *out = *in + if in.stateParent != nil { + in, out := &in.stateParent, &out.stateParent + *out = new(State) + (*in).DeepCopyInto(*out) + } if in.ProduceEvents != nil { in, out := &in.ProduceEvents, &out.ProduceEvents *out = make([]ProduceEvent, len(*in)) @@ -1667,13 +1694,64 @@ func (in *Transition) DeepCopy() *Transition { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ValidatorContext) DeepCopyInto(out *ValidatorContext) { + *out = *in + if in.States != nil { + in, out := &in.States, &out.States + *out = make(map[string]State, len(*in)) + for key, val := range *in { + (*out)[key] = *val.DeepCopy() + } + } + if in.Functions != nil { + in, out := &in.Functions, &out.Functions + *out = make(map[string]Function, len(*in)) + for key, val := range *in { + (*out)[key] = *val.DeepCopy() + } + } + if in.Events != nil { + in, out := &in.Events, &out.Events + *out = make(map[string]Event, len(*in)) + for key, val := range *in { + (*out)[key] = *val.DeepCopy() + } + } + if in.Retries != nil { + in, out := &in.Retries, &out.Retries + *out = make(map[string]Retry, len(*in)) + for key, val := range *in { + (*out)[key] = *val.DeepCopy() + } + } + if in.Errors != nil { + in, out := &in.Errors, &out.Errors + *out = make(map[string]Error, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ValidatorContext. +func (in *ValidatorContext) DeepCopy() *ValidatorContext { + if in == nil { + return nil + } + out := new(ValidatorContext) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Workflow) DeepCopyInto(out *Workflow) { *out = *in in.BaseWorkflow.DeepCopyInto(&out.BaseWorkflow) if in.States != nil { in, out := &in.States, &out.States - *out = make([]State, len(*in)) + *out = make(States, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) } diff --git a/parser/parser.go b/parser/parser.go index fe9972d..fc50692 100644 --- a/parser/parser.go +++ b/parser/parser.go @@ -21,10 +21,10 @@ import ( "path/filepath" "strings" - "github.com/serverlessworkflow/sdk-go/v2/validator" + "sigs.k8s.io/yaml" "github.com/serverlessworkflow/sdk-go/v2/model" - "sigs.k8s.io/yaml" + val "github.com/serverlessworkflow/sdk-go/v2/validator" ) const ( @@ -50,7 +50,9 @@ func FromJSONSource(source []byte) (workflow *model.Workflow, err error) { if err := json.Unmarshal(source, workflow); err != nil { return nil, err } - if err := validator.GetValidator().Struct(workflow); err != nil { + + ctx := model.NewValidatorContext(workflow) + if err := val.GetValidator().StructCtx(ctx, workflow); err != nil { return nil, err } return workflow, nil diff --git a/parser/parser_test.go b/parser/parser_test.go index 5913ea2..c5cf0f0 100644 --- a/parser/parser_test.go +++ b/parser/parser_test.go @@ -16,18 +16,17 @@ package parser import ( "encoding/json" - "fmt" "os" "path/filepath" "strings" "testing" - "k8s.io/apimachinery/pkg/util/intstr" - "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/util/intstr" "github.com/serverlessworkflow/sdk-go/v2/model" "github.com/serverlessworkflow/sdk-go/v2/test" + "github.com/serverlessworkflow/sdk-go/v2/util" ) func TestBasicValidation(t *testing.T) { @@ -35,7 +34,7 @@ func TestBasicValidation(t *testing.T) { files, err := os.ReadDir(rootPath) assert.NoError(t, err) - model.SetIncludePaths(append(model.IncludePaths(), filepath.Join(test.CurrentProjectPath(), "./parser/testdata"))) + util.SetIncludePaths(append(util.IncludePaths(), filepath.Join(test.CurrentProjectPath(), "./parser/testdata"))) for _, file := range files { if !file.IsDir() { @@ -351,7 +350,7 @@ func TestFromFile(t *testing.T) { "./testdata/workflows/purchaseorderworkflow.sw.json", func(t *testing.T, w *model.Workflow) { assert.Equal(t, "Purchase Order Workflow", w.Name) assert.NotNil(t, w.Timeouts) - assert.Equal(t, "PT30D", w.Timeouts.WorkflowExecTimeout.Duration) + assert.Equal(t, "P30D", w.Timeouts.WorkflowExecTimeout.Duration) assert.Equal(t, "CancelOrder", w.Timeouts.WorkflowExecTimeout.RunBefore) }, }, { @@ -394,7 +393,7 @@ func TestFromFile(t *testing.T) { assert.NotEmpty(t, w.Functions[2]) assert.Equal(t, "greetingFunction", w.Functions[2].Name) - assert.Empty(t, w.Functions[2].Type) + assert.Equal(t, model.FunctionTypeREST, w.Functions[2].Type) assert.Equal(t, "file://myapis/greetingapis.json#greeting", w.Functions[2].Operation) // Delay state @@ -466,7 +465,7 @@ func TestFromFile(t *testing.T) { assert.Equal(t, "PT1H", w.States[3].SwitchState.Timeouts.EventTimeout) assert.Equal(t, "PT1S", w.States[3].SwitchState.Timeouts.StateExecTimeout.Total) assert.Equal(t, "PT2S", w.States[3].SwitchState.Timeouts.StateExecTimeout.Single) - assert.Equal(t, &model.Transition{NextState: "HandleNoVisaDecision"}, w.States[3].SwitchState.DefaultCondition.Transition) + assert.Equal(t, "HandleNoVisaDecision", w.States[3].SwitchState.DefaultCondition.Transition.NextState) // DataBasedSwitchState dataBased := w.States[4].SwitchState @@ -475,9 +474,7 @@ func TestFromFile(t *testing.T) { dataCondition := dataBased.DataConditions[0] assert.Equal(t, "${ .applicants | .age >= 18 }", dataCondition.Condition) assert.Equal(t, "StartApplication", dataCondition.Transition.NextState) - assert.Equal(t, &model.Transition{ - NextState: "RejectApplication", - }, w.States[4].DefaultCondition.Transition) + assert.Equal(t, "RejectApplication", w.States[4].DefaultCondition.Transition.NextState) assert.Equal(t, "PT1S", w.States[4].SwitchState.Timeouts.StateExecTimeout.Total) assert.Equal(t, "PT2S", w.States[4].SwitchState.Timeouts.StateExecTimeout.Single) @@ -490,9 +487,10 @@ func TestFromFile(t *testing.T) { assert.Equal(t, "greetingCustomFunction", w.States[5].OperationState.Actions[0].Name) assert.NotNil(t, w.States[5].OperationState.Actions[0].FunctionRef) assert.Equal(t, "greetingCustomFunction", w.States[5].OperationState.Actions[0].FunctionRef.RefName) - assert.Equal(t, "example", w.States[5].OperationState.Actions[0].EventRef.TriggerEventRef) - assert.Equal(t, "example", w.States[5].OperationState.Actions[0].EventRef.ResultEventRef) - assert.Equal(t, "PT1H", w.States[5].OperationState.Actions[0].EventRef.ResultEventTimeout) + + // assert.Equal(t, "example", w.States[5].OperationState.Actions[0].EventRef.TriggerEventRef) + // assert.Equal(t, "example", w.States[5].OperationState.Actions[0].EventRef.ResultEventRef) + // assert.Equal(t, "PT1H", w.States[5].OperationState.Actions[0].EventRef.ResultEventTimeout) assert.Equal(t, "PT1H", w.States[5].OperationState.Timeouts.ActionExecTimeout) assert.Equal(t, "PT1S", w.States[5].OperationState.Timeouts.StateExecTimeout.Total) assert.Equal(t, "PT2S", w.States[5].OperationState.Timeouts.StateExecTimeout.Single) @@ -515,9 +513,9 @@ func TestFromFile(t *testing.T) { assert.Equal(t, "sendTextFunction", w.States[6].ForEachState.Actions[0].FunctionRef.RefName) assert.Equal(t, map[string]model.Object{"message": model.FromString("${ .singlemessage }")}, w.States[6].ForEachState.Actions[0].FunctionRef.Arguments) - assert.Equal(t, "example1", w.States[6].ForEachState.Actions[0].EventRef.TriggerEventRef) - assert.Equal(t, "example2", w.States[6].ForEachState.Actions[0].EventRef.ResultEventRef) - assert.Equal(t, "PT12H", w.States[6].ForEachState.Actions[0].EventRef.ResultEventTimeout) + // assert.Equal(t, "example1", w.States[6].ForEachState.Actions[0].EventRef.TriggerEventRef) + // assert.Equal(t, "example2", w.States[6].ForEachState.Actions[0].EventRef.ResultEventRef) + // assert.Equal(t, "PT12H", w.States[6].ForEachState.Actions[0].EventRef.ResultEventTimeout) assert.Equal(t, "PT11H", w.States[6].ForEachState.Timeouts.ActionExecTimeout) assert.Equal(t, "PT11S", w.States[6].ForEachState.Timeouts.StateExecTimeout.Total) @@ -526,7 +524,8 @@ func TestFromFile(t *testing.T) { // Inject state assert.Equal(t, "HelloInject", w.States[7].Name) assert.Equal(t, model.StateTypeInject, w.States[7].Type) - assert.Equal(t, map[string]model.Object{"result": model.FromString("Hello World, last state!")}, w.States[7].InjectState.Data) + assert.Equal(t, model.FromString("Hello World, last state!"), w.States[7].InjectState.Data["result"]) + assert.Equal(t, model.FromBool(false), w.States[7].InjectState.Data["boolValue"]) assert.Equal(t, "PT11M", w.States[7].InjectState.Timeouts.StateExecTimeout.Total) assert.Equal(t, "PT22M", w.States[7].InjectState.Timeouts.StateExecTimeout.Single) @@ -744,6 +743,22 @@ auth: metadata: auth1: auth1 auth2: auth2 +events: +- name: StoreBidFunction + type: store +- name: CarBidEvent + type: store +- name: visaRejectedEvent + type: store +- name: visaApprovedEventRef + type: store +functions: +- name: callCreditCheckMicroservice + operation: http://myapis.org/creditcheck.json#checkCredit +- name: StoreBidFunction + operation: http://myapis.org/storebid.json#storeBid +- name: sendTextFunction + operation: http://myapis.org/inboxapi.json#sendText states: - name: GreetDelay type: delay @@ -848,11 +863,6 @@ states: refName: sendTextFunction arguments: message: "${ .singlemessage }" - eventRef: - triggerEventRef: example1 - resultEventRef: example2 - # Added "resultEventTimeout" for action eventref - resultEventTimeout: PT12H timeouts: actionExecTimeout: PT11H stateExecTimeout: @@ -910,9 +920,6 @@ states: - name: HandleApprovedVisa type: operation actions: - - subFlowRef: - workflowId: handleApprovedVisaWorkflowID - name: subFlowRefName - eventRef: triggerEventRef: StoreBidFunction data: "${ .patientInfo }" @@ -926,15 +933,28 @@ states: stateExecTimeout: total: PT33M single: PT123M + transition: HandleApprovedVisaSubFlow +- name: HandleApprovedVisaSubFlow + type: operation + actions: + - subFlowRef: + workflowId: handleApprovedVisaWorkflowID + name: subFlowRefName + end: + terminate: true +- name: HandleRejectedVisa + type: operation + actions: + - subFlowRef: + workflowId: handleApprovedVisaWorkflowID + name: subFlowRefName end: terminate: true `)) - assert.Nil(t, err) - fmt.Println(err) + assert.NoError(t, err) assert.NotNil(t, workflow) b, err := json.Marshal(workflow) - - assert.Nil(t, err) + assert.NoError(t, err) // workflow and auth metadata assert.True(t, strings.Contains(string(b), "\"metadata\":{\"metadata1\":\"metadata1\",\"metadata2\":\"metadata2\"}")) @@ -944,7 +964,7 @@ states: assert.True(t, strings.Contains(string(b), "{\"name\":\"CheckCreditCallback\",\"type\":\"callback\",\"transition\":{\"nextState\":\"HandleApprovedVisa\"},\"action\":{\"functionRef\":{\"refName\":\"callCreditCheckMicroservice\",\"arguments\":{\"argsObj\":{\"age\":{\"final\":32,\"initial\":10},\"name\":\"hi\"},\"customer\":\"${ .customer }\",\"time\":48},\"invoke\":\"sync\"},\"sleep\":{\"before\":\"PT10S\",\"after\":\"PT20S\"},\"actionDataFilter\":{\"useResults\":true}},\"eventRef\":\"CreditCheckCompletedEvent\",\"eventDataFilter\":{\"useData\":true,\"data\":\"test data\",\"toStateData\":\"${ .customer }\"},\"timeouts\":{\"stateExecTimeout\":{\"single\":\"PT22M\",\"total\":\"PT115M\"},\"actionExecTimeout\":\"PT199M\",\"eventTimeout\":\"PT348S\"}}")) // Operation State - assert.True(t, strings.Contains(string(b), "{\"name\":\"HandleApprovedVisa\",\"type\":\"operation\",\"end\":{\"terminate\":true},\"actionMode\":\"sequential\",\"actions\":[{\"name\":\"subFlowRefName\",\"subFlowRef\":{\"workflowId\":\"handleApprovedVisaWorkflowID\",\"invoke\":\"sync\",\"onParentComplete\":\"terminate\"},\"actionDataFilter\":{\"useResults\":true}},{\"name\":\"eventRefName\",\"eventRef\":{\"triggerEventRef\":\"StoreBidFunction\",\"resultEventRef\":\"StoreBidFunction\",\"data\":\"${ .patientInfo }\",\"contextAttributes\":{\"customer\":\"${ .customer }\",\"time\":50},\"invoke\":\"sync\"},\"actionDataFilter\":{\"useResults\":true}}],\"timeouts\":{\"stateExecTimeout\":{\"single\":\"PT123M\",\"total\":\"PT33M\"},\"actionExecTimeout\":\"PT777S\"}}")) + assert.True(t, strings.Contains(string(b), `{"name":"HandleApprovedVisa","type":"operation","transition":{"nextState":"HandleApprovedVisaSubFlow"},"actionMode":"sequential","actions":[{"name":"eventRefName","eventRef":{"triggerEventRef":"StoreBidFunction","resultEventRef":"StoreBidFunction","data":"${ .patientInfo }","contextAttributes":{"customer":"${ .customer }","time":50},"invoke":"sync"},"actionDataFilter":{"useResults":true}}],"timeouts":{"stateExecTimeout":{"single":"PT123M","total":"PT33M"},"actionExecTimeout":"PT777S"}}`)) // Delay State assert.True(t, strings.Contains(string(b), "{\"name\":\"GreetDelay\",\"type\":\"delay\",\"transition\":{\"nextState\":\"StoreCarAuctionBid\"},\"timeDelay\":\"PT5S\"}")) @@ -962,7 +982,7 @@ states: assert.True(t, strings.Contains(string(b), "{\"name\":\"HelloStateWithDefaultConditionString\",\"type\":\"switch\",\"defaultCondition\":{\"transition\":{\"nextState\":\"SendTextForHighPriority\"}},\"dataConditions\":[{\"condition\":\"${ true }\",\"transition\":{\"nextState\":\"HandleApprovedVisa\"}},{\"condition\":\"${ false }\",\"transition\":{\"nextState\":\"HandleRejectedVisa\"}}]}")) // Foreach State - assert.True(t, strings.Contains(string(b), "{\"name\":\"SendTextForHighPriority\",\"type\":\"foreach\",\"transition\":{\"nextState\":\"HelloInject\"},\"inputCollection\":\"${ .messages }\",\"outputCollection\":\"${ .outputMessages }\",\"iterationParam\":\"${ .this }\",\"batchSize\":45,\"actions\":[{\"name\":\"test\",\"functionRef\":{\"refName\":\"sendTextFunction\",\"arguments\":{\"message\":\"${ .singlemessage }\"},\"invoke\":\"sync\"},\"eventRef\":{\"triggerEventRef\":\"example1\",\"resultEventRef\":\"example2\",\"resultEventTimeout\":\"PT12H\",\"invoke\":\"sync\"},\"actionDataFilter\":{\"useResults\":true}}],\"mode\":\"sequential\",\"timeouts\":{\"stateExecTimeout\":{\"single\":\"PT22S\",\"total\":\"PT11S\"},\"actionExecTimeout\":\"PT11H\"}}")) + assert.True(t, strings.Contains(string(b), `{"name":"SendTextForHighPriority","type":"foreach","transition":{"nextState":"HelloInject"},"inputCollection":"${ .messages }","outputCollection":"${ .outputMessages }","iterationParam":"${ .this }","batchSize":45,"actions":[{"name":"test","functionRef":{"refName":"sendTextFunction","arguments":{"message":"${ .singlemessage }"},"invoke":"sync"},"actionDataFilter":{"useResults":true}}],"mode":"sequential","timeouts":{"stateExecTimeout":{"single":"PT22S","total":"PT11S"},"actionExecTimeout":"PT11H"}}`)) // Inject State assert.True(t, strings.Contains(string(b), "{\"name\":\"HelloInject\",\"type\":\"inject\",\"transition\":{\"nextState\":\"WaitForCompletionSleep\"},\"data\":{\"result\":\"Hello World, another state!\"},\"timeouts\":{\"stateExecTimeout\":{\"single\":\"PT22M\",\"total\":\"PT11M\"}}}")) @@ -972,6 +992,9 @@ states: workflow = nil err = json.Unmarshal(b, &workflow) + // Make sure that the Action FunctionRef is unmarshalled correctly + assert.Equal(t, model.FromString("${ .singlemessage }"), workflow.States[5].ForEachState.Actions[0].FunctionRef.Arguments["message"]) + assert.Equal(t, "sendTextFunction", workflow.States[5].ForEachState.Actions[0].FunctionRef.RefName) assert.Nil(t, err) }) @@ -1023,4 +1046,17 @@ states: assert.Regexp(t, `validation for \'DataConditions\' failed on the \'required\' tag`, err) assert.Nil(t, workflow) }) + + t.Run("Test complex workflow with compensate transitions", func(t *testing.T) { + workflow, err := FromFile("./testdata/workflows/compensate.sw.json") + + assert.Nil(t, err) + assert.NotNil(t, workflow) + b, err := json.Marshal(workflow) + assert.Nil(t, err) + + workflow = nil + err = json.Unmarshal(b, &workflow) + assert.Nil(t, err) + }) } diff --git a/parser/testdata/workflows/compensate.sw.json b/parser/testdata/workflows/compensate.sw.json new file mode 100644 index 0000000..9f6ab1f --- /dev/null +++ b/parser/testdata/workflows/compensate.sw.json @@ -0,0 +1,99 @@ +{ + "id": "compensation", + "version": "1.0", + "name": "Workflow Error example", + "description": "An example of how compensation works", + "specVersion": "0.8", + "start": "printStatus", + "functions": [ + { + "name": "PrintOutput", + "type": "custom", + "operation": "sysout" + } + ], + "states": [ + { + "name": "printStatus", + "type": "inject", + "data": { + "compensated": false + }, + "compensatedBy": "compensating", + "transition": "branch" + }, + { + "name": "branch", + "type": "switch", + "dataConditions": [ + { + "condition": ".shouldCompensate==true", + "transition": { + "nextState": "finish_compensate", + "compensate": true + } + }, + { + "condition": ".shouldCompensate==false", + "transition": { + "nextState": "finish_not_compensate", + "compensate": false + } + } + ], + "defaultCondition": { + "end": true + } + }, + { + "name": "compensating", + "usedForCompensation": true, + "type": "inject", + "data": { + "compensated": true + }, + "transition": "compensating_more" + }, + { + "name": "compensating_more", + "usedForCompensation": true, + "type": "inject", + "data": { + "compensating_more": "Real Betis Balompie" + }, + "end": true + }, + { + "name": "finish_compensate", + "type": "operation", + "actions": [ + { + "name": "finish_compensate_sysout", + "functionRef": { + "refName": "PrintOutput", + "arguments": { + "message": "completed" + } + } + } + ], + "end": true + }, + { + "name": "finish_not_compensate", + "type": "operation", + "actions": [ + { + "name": "finish_not_compensate_sysout", + "functionRef": { + "refName": "PrintOutput", + "arguments": { + "message": "completed" + } + } + } + ], + "end": true + } + ] +} \ No newline at end of file diff --git a/parser/testdata/workflows/customerbankingtransactions.json b/parser/testdata/workflows/customerbankingtransactions.json index 933c7e4..98fbd34 100644 --- a/parser/testdata/workflows/customerbankingtransactions.json +++ b/parser/testdata/workflows/customerbankingtransactions.json @@ -35,7 +35,7 @@ "operation": "banking.yaml#largerTransation" }, { - "name": "Banking Service - Smaller T", + "name": "Banking Service - Smaller Tx", "type": "asyncapi", "operation": "banking.yaml#smallerTransation" } diff --git a/parser/testdata/workflows/customercreditcheck.json b/parser/testdata/workflows/customercreditcheck.json index d19c009..8a3914f 100644 --- a/parser/testdata/workflows/customercreditcheck.json +++ b/parser/testdata/workflows/customercreditcheck.json @@ -13,6 +13,10 @@ { "name": "sendRejectionEmailFunction", "operation": "http://myapis.org/creditcheckapi.json#rejectionEmail" + }, + { + "name": "callCreditCheckMicroservice", + "operation": "http://myapis.org/creditcheckapi.json#creditCheckMicroservice" } ], "events": [ diff --git a/parser/testdata/workflows/eventbasedgreetingexclusive.sw.json b/parser/testdata/workflows/eventbasedgreetingexclusive.sw.json index df9d7dd..80e81b0 100644 --- a/parser/testdata/workflows/eventbasedgreetingexclusive.sw.json +++ b/parser/testdata/workflows/eventbasedgreetingexclusive.sw.json @@ -23,6 +23,10 @@ { "name": "greetingFunction", "operation": "file://myapis/greetingapis.json#greeting" + }, + { + "name": "greetingFunction2", + "operation": "file://myapis/greetingapis.json#greeting2" } ], "states": [ diff --git a/parser/testdata/workflows/greetings-v08-spec.sw.yaml b/parser/testdata/workflows/greetings-v08-spec.sw.yaml index 13b0d75..015a711 100644 --- a/parser/testdata/workflows/greetings-v08-spec.sw.yaml +++ b/parser/testdata/workflows/greetings-v08-spec.sw.yaml @@ -28,6 +28,23 @@ functions: type: graphql - name: greetingFunction operation: file://myapis/greetingapis.json#greeting + - name: StoreBidFunction + operation: http://myapis.org/inboxapi.json#storeBidFunction + - name: callCreditCheckMicroservice + operation: http://myapis.org/inboxapi.json#callCreditCheckMicroservice +events: + - name: StoreBidFunction + type: StoreBidFunction + source: StoreBidFunction + - name: CarBidEvent + type: typeCarBidEvent + source: sourceCarBidEvent + - name: visaApprovedEventRef + type: typeVisaApprovedEventRef + source: sourceVisaApprovedEventRef + - name: visaRejectedEvent + type: typeVisaRejectedEvent + source: sourceVisaRejectedEvent states: - name: GreetDelay type: delay @@ -129,11 +146,6 @@ states: name: "${ .greet | .name }" actionDataFilter: dataResultsPath: "${ .payload | .greeting }" - eventRef: - triggerEventRef: example - resultEventRef: example - # Added "resultEventTimeout" for action eventref - resultEventTimeout: PT1H timeouts: actionExecTimeout: PT1H stateExecTimeout: @@ -155,11 +167,6 @@ states: refName: sendTextFunction arguments: message: "${ .singlemessage }" - eventRef: - triggerEventRef: example1 - resultEventRef: example2 - # Added "resultEventTimeout" for action eventref - resultEventTimeout: PT12H timeouts: actionExecTimeout: PT11H stateExecTimeout: @@ -170,6 +177,7 @@ states: type: inject data: result: Hello World, last state! + boolValue: false timeouts: stateExecTimeout: total: PT11M @@ -220,4 +228,46 @@ states: transition: nextState: HandleRejectedVisa defaultCondition: SendTextForHighPriority - end: true \ No newline at end of file + end: true + - name: RejectApplication + type: switch + dataConditions: + - condition: ${ true } + transition: HandleApprovedVisa + - condition: ${ false } + transition: + nextState: HandleRejectedVisa + defaultCondition: SendTextForHighPriority + end: true + - name: HandleNoVisaDecision + type: operation + actionMode: sequential + actions: + - name: greetingCustomFunction + functionRef: + refName: greetingCustomFunction + end: true + - name: StartApplication + type: operation + actionMode: sequential + actions: + - name: greetingCustomFunction + functionRef: + refName: greetingCustomFunction + end: true + - name: HandleApprovedVisa + type: operation + actionMode: sequential + actions: + - name: greetingCustomFunction + functionRef: + refName: greetingCustomFunction + end: true + - name: HandleRejectedVisa + type: operation + actionMode: sequential + actions: + - name: greetingCustomFunction + functionRef: + refName: greetingCustomFunction + end: true diff --git a/parser/testdata/workflows/patientonboarding.sw.yaml b/parser/testdata/workflows/patientonboarding.sw.yaml index c2a5808..6ceb1a1 100644 --- a/parser/testdata/workflows/patientonboarding.sw.yaml +++ b/parser/testdata/workflows/patientonboarding.sw.yaml @@ -41,10 +41,12 @@ states: end: true end: true events: - - name: StorePatient + - name: NewPatientEvent type: new.patients.event source: newpatient/+ functions: + - name: StorePatient + operation: api/services.json#storePatient - name: StoreNewPatientInfo operation: api/services.json#addPatient - name: AssignDoctor diff --git a/parser/testdata/workflows/purchaseorderworkflow.sw.json b/parser/testdata/workflows/purchaseorderworkflow.sw.json index 2bde03c..2596b04 100644 --- a/parser/testdata/workflows/purchaseorderworkflow.sw.json +++ b/parser/testdata/workflows/purchaseorderworkflow.sw.json @@ -6,7 +6,7 @@ "start": "StartNewOrder", "timeouts": { "workflowExecTimeout": { - "duration": "PT30D", + "duration": "P30D", "runBefore": "CancelOrder" } }, diff --git a/parser/testdata/workflows/vitalscheck.json b/parser/testdata/workflows/vitalscheck.json index feb1c41..3a89b78 100644 --- a/parser/testdata/workflows/vitalscheck.json +++ b/parser/testdata/workflows/vitalscheck.json @@ -34,19 +34,19 @@ ], "functions": [ { - "name": "checkTirePressure", + "name": "Check Tire Pressure", "operation": "mycarservices.json#checktirepressure" }, { - "name": "checkOilPressure", + "name": "Check Oil Pressure", "operation": "mycarservices.json#checkoilpressure" }, { - "name": "checkCoolantLevel", + "name": "Check Coolant Level", "operation": "mycarservices.json#checkcoolantlevel" }, { - "name": "checkBattery", + "name": "Check Battery", "operation": "mycarservices.json#checkbattery" } ] diff --git a/model/util.go b/util/unmarshal.go similarity index 88% rename from model/util.go rename to util/unmarshal.go index 2ae4226..6c70f4a 100644 --- a/model/util.go +++ b/util/unmarshal.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package model +package util import ( "bytes" @@ -23,12 +23,14 @@ import ( "os" "path/filepath" "reflect" + "runtime" "strings" "sync/atomic" "time" - "github.com/serverlessworkflow/sdk-go/v2/validator" "sigs.k8s.io/yaml" + + val "github.com/serverlessworkflow/sdk-go/v2/validator" ) // Kind ... @@ -39,7 +41,7 @@ type Kind interface { } // TODO: Remove global variable -var httpClient = http.Client{Timeout: time.Duration(1) * time.Second} +var HttpClient = http.Client{Timeout: time.Duration(1) * time.Second} // UnmarshalError ... // +k8s:deepcopy-gen=false @@ -87,8 +89,8 @@ func (e *UnmarshalError) unmarshalMessageError(err *json.UnmarshalTypeError) str } else if err.Struct != "" && err.Field != "" { var primitiveTypeName string - val := reflect.New(err.Type) - if valKinds, ok := val.Elem().Interface().(validator.Kind); ok { + value := reflect.New(err.Type) + if valKinds, ok := value.Elem().Interface().(val.Kind); ok { values := valKinds.KindValues() if len(values) <= 2 { primitiveTypeName = strings.Join(values, " or ") @@ -139,6 +141,10 @@ func loadExternalResource(url string) (b []byte, err error) { } func getBytesFromFile(path string) ([]byte, error) { + if WebAssembly() { + return nil, fmt.Errorf("unsupported open file") + } + // if path is relative, search in include paths if !filepath.IsAbs(path) { paths := IncludePaths() @@ -169,7 +175,7 @@ func getBytesFromHttp(url string) ([]byte, error) { return nil, err } - resp, err := httpClient.Do(req) + resp, err := HttpClient.Do(req) if err != nil { return nil, err } @@ -183,9 +189,10 @@ func getBytesFromHttp(url string) ([]byte, error) { return buf.Bytes(), nil } -func unmarshalObjectOrFile[U any](parameterName string, data []byte, valObject *U) error { +// +k8s:deepcopy-gen=false +func UnmarshalObjectOrFile[U any](parameterName string, data []byte, valObject *U) error { var valString string - err := unmarshalPrimitiveOrObject(parameterName, data, &valString, valObject) + err := UnmarshalPrimitiveOrObject(parameterName, data, &valString, valObject) if err != nil || valString == "" { return err } @@ -224,10 +231,10 @@ func unmarshalObjectOrFile[U any](parameterName string, data []byte, valObject * } } - return unmarshalObject(parameterName, data, valObject) + return UnmarshalObject(parameterName, data, valObject) } -func unmarshalPrimitiveOrObject[T string | bool, U any](parameterName string, data []byte, valPrimitive *T, valStruct *U) error { +func UnmarshalPrimitiveOrObject[T string | bool, U any](parameterName string, data []byte, valPrimitive *T, valStruct *U) error { data = bytes.TrimSpace(data) if len(data) == 0 { // TODO: Normalize error messages @@ -237,7 +244,7 @@ func unmarshalPrimitiveOrObject[T string | bool, U any](parameterName string, da isObject := data[0] == '{' || data[0] == '[' var err error if isObject { - err = unmarshalObject(parameterName, data, valStruct) + err = UnmarshalObject(parameterName, data, valStruct) } else { err = unmarshalPrimitive(parameterName, data, valPrimitive) } @@ -268,7 +275,7 @@ func unmarshalPrimitive[T string | bool](parameterName string, data []byte, valu return nil } -func unmarshalObject[U any](parameterName string, data []byte, value *U) error { +func UnmarshalObject[U any](parameterName string, data []byte, value *U) error { if value == nil { return nil } @@ -288,11 +295,15 @@ func unmarshalObject[U any](parameterName string, data []byte, value *U) error { var defaultIncludePaths atomic.Value func init() { + // No execute set include path to suport webassembly + if WebAssembly() { + return + } + wd, err := os.Getwd() if err != nil { panic(err) } - SetIncludePaths([]string{wd}) } @@ -311,3 +322,7 @@ func SetIncludePaths(paths []string) { defaultIncludePaths.Store(paths) } + +func WebAssembly() bool { + return runtime.GOOS == "js" && runtime.GOARCH == "wasm" +} diff --git a/model/util_benchmark_test.go b/util/unmarshal_benchmark_test.go similarity index 98% rename from model/util_benchmark_test.go rename to util/unmarshal_benchmark_test.go index 4048a6b..1a81b41 100644 --- a/model/util_benchmark_test.go +++ b/util/unmarshal_benchmark_test.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package model +package util import ( "fmt" diff --git a/model/util_test.go b/util/unmarshal_test.go similarity index 79% rename from model/util_test.go rename to util/unmarshal_test.go index b81b315..0227123 100644 --- a/model/util_test.go +++ b/util/unmarshal_test.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package model +package util import ( "encoding/json" @@ -22,8 +22,9 @@ import ( "path/filepath" "testing" - "github.com/serverlessworkflow/sdk-go/v2/test" "github.com/stretchr/testify/assert" + + "github.com/serverlessworkflow/sdk-go/v2/test" ) func TestIncludePaths(t *testing.T) { @@ -55,7 +56,7 @@ func Test_loadExternalResource(t *testing.T) { } })) defer server.Close() - httpClient = *server.Client() + HttpClient = *server.Client() data, err := loadExternalResource(server.URL + "/test.json") assert.NoError(t, err) @@ -94,40 +95,26 @@ func Test_unmarshalObjectOrFile(t *testing.T) { } })) defer server.Close() - httpClient = *server.Client() + HttpClient = *server.Client() structValue := &structString{} data := []byte(`"fieldValue": "value"`) - err := unmarshalObjectOrFile("structString", data, structValue) + err := UnmarshalObjectOrFile("structString", data, structValue) assert.Error(t, err) assert.Equal(t, &structString{}, structValue) listStructValue := &listStructString{} data = []byte(`[{"fieldValue": "value"}]`) - err = unmarshalObjectOrFile("listStructString", data, listStructValue) + err = UnmarshalObjectOrFile("listStructString", data, listStructValue) assert.NoError(t, err) assert.Equal(t, listStructString{{FieldValue: "value"}}, *listStructValue) listStructValue = &listStructString{} data = []byte(fmt.Sprintf(`"%s/test.json"`, server.URL)) - err = unmarshalObjectOrFile("listStructString", data, listStructValue) + err = UnmarshalObjectOrFile("listStructString", data, listStructValue) assert.NoError(t, err) assert.Equal(t, listStructString{{FieldValue: "value"}}, *listStructValue) }) - - t.Run("file://", func(t *testing.T) { - retries := &Retries{} - data := []byte(`"file://../parser/testdata/applicationrequestretries.json"`) - err := unmarshalObjectOrFile("retries", data, retries) - assert.NoError(t, err) - }) - - t.Run("external url", func(t *testing.T) { - retries := &Retries{} - data := []byte(`"https://raw.githubusercontent.com/serverlessworkflow/sdk-java/main/api/src/test/resources/features/applicantrequestretries.json"`) - err := unmarshalObjectOrFile("retries", data, retries) - assert.NoError(t, err) - }) } func Test_primitiveOrMapType(t *testing.T) { @@ -137,31 +124,31 @@ func Test_primitiveOrMapType(t *testing.T) { var valBool bool valMap := &dataMap{} data := []byte(`"value":true`) - err := unmarshalPrimitiveOrObject("dataMap", data, &valBool, valMap) + err := UnmarshalPrimitiveOrObject("dataMap", data, &valBool, valMap) assert.Error(t, err) valBool = false valMap = &dataMap{} data = []byte(`{value":true}`) - err = unmarshalPrimitiveOrObject("dataMap", data, &valBool, valMap) + err = UnmarshalPrimitiveOrObject("dataMap", data, &valBool, valMap) assert.Error(t, err) valBool = false valMap = &dataMap{} data = []byte(`value":true}`) - err = unmarshalPrimitiveOrObject("dataMap", data, &valBool, valMap) + err = UnmarshalPrimitiveOrObject("dataMap", data, &valBool, valMap) assert.Error(t, err) valBool = false valMap = &dataMap{} data = []byte(`"true"`) - err = unmarshalPrimitiveOrObject("dataMap", data, &valBool, valMap) + err = UnmarshalPrimitiveOrObject("dataMap", data, &valBool, valMap) assert.Error(t, err) valBool = false valMap = &dataMap{} data = []byte(`true`) - err = unmarshalPrimitiveOrObject("dataMap", data, &valBool, valMap) + err = UnmarshalPrimitiveOrObject("dataMap", data, &valBool, valMap) assert.NoError(t, err) assert.Equal(t, &dataMap{}, valMap) assert.True(t, valBool) @@ -169,7 +156,7 @@ func Test_primitiveOrMapType(t *testing.T) { valString := "" valMap = &dataMap{} data = []byte(`"true"`) - err = unmarshalPrimitiveOrObject("dataMap", data, &valString, valMap) + err = UnmarshalPrimitiveOrObject("dataMap", data, &valString, valMap) assert.NoError(t, err) assert.Equal(t, &dataMap{}, valMap) assert.Equal(t, `true`, valString) @@ -177,7 +164,7 @@ func Test_primitiveOrMapType(t *testing.T) { valBool = false valMap = &dataMap{} data = []byte(`{"value":true}`) - err = unmarshalPrimitiveOrObject("dataMap", data, &valBool, valMap) + err = UnmarshalPrimitiveOrObject("dataMap", data, &valBool, valMap) assert.NoError(t, err) assert.NotNil(t, valMap) assert.Equal(t, valMap, &dataMap{"value": []byte("true")}) @@ -186,7 +173,7 @@ func Test_primitiveOrMapType(t *testing.T) { valBool = false valMap = &dataMap{} data = []byte(`{"value": "true"}`) - err = unmarshalPrimitiveOrObject("dataMap", data, &valBool, valMap) + err = UnmarshalPrimitiveOrObject("dataMap", data, &valBool, valMap) assert.NoError(t, err) assert.NotNil(t, valMap) assert.Equal(t, valMap, &dataMap{"value": []byte(`"true"`)}) @@ -201,12 +188,12 @@ func Test_primitiveOrMapType(t *testing.T) { var valString string valStruct := &structString{} data := []byte(`{"fieldValue": "value"`) - err := unmarshalPrimitiveOrObject("structBool", data, &valString, valStruct) + err := UnmarshalPrimitiveOrObject("structBool", data, &valString, valStruct) assert.Error(t, err) assert.Equal(t, "structBool has a syntax error \"unexpected end of JSON input\"", err.Error()) data = []byte(`{\n "fieldValue": value\n}`) - err = unmarshalPrimitiveOrObject("structBool", data, &valString, valStruct) + err = UnmarshalPrimitiveOrObject("structBool", data, &valString, valStruct) assert.Error(t, err) assert.Equal(t, "structBool has a syntax error \"invalid character '\\\\\\\\' looking for beginning of object key string\"", err.Error()) // assert.Equal(t, `structBool value '{"fieldValue": value}' is not supported, it has a syntax error "invalid character 'v' looking for beginning of value"`, err.Error()) @@ -222,14 +209,14 @@ func Test_primitiveOrMapType(t *testing.T) { data := []byte(`{ "fieldValue": "true" }`) - err := unmarshalPrimitiveOrObject("structBool", data, &valBool, valStruct) + err := UnmarshalPrimitiveOrObject("structBool", data, &valBool, valStruct) assert.Error(t, err) assert.Equal(t, "structBool.fieldValue must be bool", err.Error()) valBool = false valStruct = &structBool{} data = []byte(`"true"`) - err = unmarshalPrimitiveOrObject("structBool", data, &valBool, valStruct) + err = UnmarshalPrimitiveOrObject("structBool", data, &valBool, valStruct) assert.Error(t, err) assert.Equal(t, "structBool must be bool or object", err.Error()) }) @@ -238,19 +225,19 @@ func Test_primitiveOrMapType(t *testing.T) { var valBool bool valStruct := &dataMap{} data := []byte(` {"value": "true"} `) - err := unmarshalPrimitiveOrObject("dataMap", data, &valBool, valStruct) + err := UnmarshalPrimitiveOrObject("dataMap", data, &valBool, valStruct) assert.NoError(t, err) valBool = false valStruct = &dataMap{} data = []byte(` true `) - err = unmarshalPrimitiveOrObject("dataMap", data, &valBool, valStruct) + err = UnmarshalPrimitiveOrObject("dataMap", data, &valBool, valStruct) assert.NoError(t, err) valString := "" valStruct = &dataMap{} data = []byte(` "true" `) - err = unmarshalPrimitiveOrObject("dataMap", data, &valString, valStruct) + err = UnmarshalPrimitiveOrObject("dataMap", data, &valString, valStruct) assert.NoError(t, err) }) @@ -258,13 +245,13 @@ func Test_primitiveOrMapType(t *testing.T) { valString := "" valStruct := &dataMap{} data := []byte(string('\t') + `"true"` + string('\t')) - err := unmarshalPrimitiveOrObject("dataMap", data, &valString, valStruct) + err := UnmarshalPrimitiveOrObject("dataMap", data, &valString, valStruct) assert.NoError(t, err) valBool := false valStruct = &dataMap{} data = []byte(string('\t') + `true` + string('\t')) - err = unmarshalPrimitiveOrObject("dataMap", data, &valBool, valStruct) + err = UnmarshalPrimitiveOrObject("dataMap", data, &valBool, valStruct) assert.NoError(t, err) }) @@ -272,13 +259,13 @@ func Test_primitiveOrMapType(t *testing.T) { valString := "" valStruct := &dataMap{} data := []byte(string('\n') + `"true"` + string('\n')) - err := unmarshalPrimitiveOrObject("dataMap", data, &valString, valStruct) + err := UnmarshalPrimitiveOrObject("dataMap", data, &valString, valStruct) assert.NoError(t, err) valBool := false valStruct = &dataMap{} data = []byte(string('\n') + `true` + string('\n')) - err = unmarshalPrimitiveOrObject("dataMap", data, &valBool, valStruct) + err = UnmarshalPrimitiveOrObject("dataMap", data, &valBool, valStruct) assert.NoError(t, err) }) @@ -299,5 +286,5 @@ type structBoolUnmarshal structBool func (s *structBool) UnmarshalJSON(data []byte) error { s.FieldValue = true - return unmarshalObject("unmarshalJSON", data, (*structBoolUnmarshal)(s)) + return UnmarshalObject("unmarshalJSON", data, (*structBoolUnmarshal)(s)) } diff --git a/validator/validator.go b/validator/validator.go index 846203d..1e77b36 100644 --- a/validator/validator.go +++ b/validator/validator.go @@ -16,9 +16,12 @@ package validator import ( "context" + "strconv" - validator "github.com/go-playground/validator/v10" "github.com/senseyeio/duration" + "k8s.io/apimachinery/pkg/util/intstr" + + validator "github.com/go-playground/validator/v10" ) // TODO: expose a better validation message. See: https://pkg.go.dev/gopkg.in/go-playground/validator.v8#section-documentation @@ -42,7 +45,6 @@ func init() { if err != nil { panic(err) } - } // GetValidator gets the default validator.Validate reference @@ -72,3 +74,23 @@ func oneOfKind(fl validator.FieldLevel) bool { return false } + +func ValidateGt0IntStr(value *intstr.IntOrString) bool { + switch value.Type { + case intstr.Int: + if value.IntVal <= 0 { + return false + } + case intstr.String: + v, err := strconv.Atoi(value.StrVal) + if err != nil { + return false + } + + if v <= 0 { + return false + } + } + + return true +} diff --git a/validator/validator_test.go b/validator/validator_test.go index a0b273e..73ef555 100644 --- a/validator/validator_test.go +++ b/validator/validator_test.go @@ -18,6 +18,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/util/intstr" ) func TestValidateISO8601TimeDuration(t *testing.T) { @@ -115,3 +116,60 @@ func Test_oneOfKind(t *testing.T) { }) } + +func TestValidateIntStr(t *testing.T) { + + testCase := []struct { + Desp string + Test *intstr.IntOrString + Return bool + }{ + { + Desp: "success int", + Test: &intstr.IntOrString{ + Type: intstr.Int, + IntVal: 1, + }, + Return: true, + }, + { + Desp: "success string", + Test: &intstr.IntOrString{ + Type: intstr.String, + StrVal: "1", + }, + Return: true, + }, + { + Desp: "fail int", + Test: &intstr.IntOrString{ + Type: intstr.Int, + IntVal: 0, + }, + Return: false, + }, + { + Desp: "fail string", + Test: &intstr.IntOrString{ + Type: intstr.String, + StrVal: "0", + }, + Return: false, + }, + { + Desp: "fail invalid string", + Test: &intstr.IntOrString{ + Type: intstr.String, + StrVal: "aa", + }, + Return: false, + }, + } + + for _, c := range testCase { + t.Run(c.Desp, func(t *testing.T) { + valid := ValidateGt0IntStr(c.Test) + assert.Equal(t, c.Return, valid) + }) + } +} diff --git a/validator/workflow.go b/validator/workflow.go new file mode 100644 index 0000000..d5be7b5 --- /dev/null +++ b/validator/workflow.go @@ -0,0 +1,154 @@ +// Copyright 2023 The Serverless Workflow Specification Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package validator + +import ( + "errors" + "fmt" + "reflect" + "strings" + + validator "github.com/go-playground/validator/v10" +) + +const ( + TagExists string = "exists" + TagRequired string = "required" + TagExclusive string = "exclusive" + + TagRecursiveState string = "recursivestate" + + // States referenced by compensatedBy (as well as any other states that they transition to) must obey following rules: + TagTransitionMainWorkflow string = "transtionmainworkflow" // They should not have any incoming transitions (should not be part of the main workflow control-flow logic) + TagCompensatedbyEventState string = "compensatedbyeventstate" // They cannot be an event state + TagRecursiveCompensation string = "recursivecompensation" // They cannot themselves set their compensatedBy property to true (compensation is not recursive) + TagCompensatedby string = "compensatedby" // They must define the usedForCompensation property and set it to true + TagTransitionUseForCompensation string = "transitionusedforcompensation" // They can transition only to states which also have their usedForCompensation property and set to true +) + +type WorkflowErrors []error + +func (e WorkflowErrors) Error() string { + errors := []string{} + for _, err := range []error(e) { + errors = append(errors, err.Error()) + } + return strings.Join(errors, "\n") +} + +func WorkflowError(err error) error { + if err == nil { + return nil + } + + var invalidErr *validator.InvalidValidationError + if errors.As(err, &invalidErr) { + return err + } + + var validationErrors validator.ValidationErrors + if !errors.As(err, &validationErrors) { + return err + } + + removeNamespace := []string{ + "BaseWorkflow", + "BaseState", + "OperationState", + } + + workflowErrors := []error{} + for _, err := range validationErrors { + // normalize namespace + namespaceList := strings.Split(err.Namespace(), ".") + normalizedNamespaceList := []string{} + for i := range namespaceList { + part := namespaceList[i] + if !contains(removeNamespace, part) { + part := strings.ToLower(part[:1]) + part[1:] + normalizedNamespaceList = append(normalizedNamespaceList, part) + } + } + namespace := strings.Join(normalizedNamespaceList, ".") + + switch err.Tag() { + case "unique": + if err.Param() == "" { + workflowErrors = append(workflowErrors, fmt.Errorf("%s has duplicate value", namespace)) + } else { + workflowErrors = append(workflowErrors, fmt.Errorf("%s has duplicate %q", namespace, strings.ToLower(err.Param()))) + } + case "min": + workflowErrors = append(workflowErrors, fmt.Errorf("%s must have the minimum %s", namespace, err.Param())) + case "required_without": + if namespace == "workflow.iD" { + workflowErrors = append(workflowErrors, errors.New("workflow.id required when \"workflow.key\" is not defined")) + } else if namespace == "workflow.key" { + workflowErrors = append(workflowErrors, errors.New("workflow.key required when \"workflow.id\" is not defined")) + } else if err.StructField() == "FunctionRef" { + workflowErrors = append(workflowErrors, fmt.Errorf("%s required when \"eventRef\" or \"subFlowRef\" is not defined", namespace)) + } else { + workflowErrors = append(workflowErrors, err) + } + case "oneofkind": + value := reflect.New(err.Type()).Elem().Interface().(Kind) + workflowErrors = append(workflowErrors, fmt.Errorf("%s need by one of %s", namespace, value.KindValues())) + case "gt0": + workflowErrors = append(workflowErrors, fmt.Errorf("%s must be greater than 0", namespace)) + case TagExists: + workflowErrors = append(workflowErrors, fmt.Errorf("%s don't exist %q", namespace, err.Value())) + case TagRequired: + workflowErrors = append(workflowErrors, fmt.Errorf("%s is required", namespace)) + case TagExclusive: + if err.StructField() == "ErrorRef" { + workflowErrors = append(workflowErrors, fmt.Errorf("%s or %s are exclusive", namespace, replaceLastNamespace(namespace, "errorRefs"))) + } else { + workflowErrors = append(workflowErrors, fmt.Errorf("%s exclusive", namespace)) + } + case TagCompensatedby: + workflowErrors = append(workflowErrors, fmt.Errorf("%s = %q is not defined as usedForCompensation", namespace, err.Value())) + case TagCompensatedbyEventState: + workflowErrors = append(workflowErrors, fmt.Errorf("%s = %q is defined as usedForCompensation and cannot be an event state", namespace, err.Value())) + case TagRecursiveCompensation: + workflowErrors = append(workflowErrors, fmt.Errorf("%s = %q is defined as usedForCompensation (cannot themselves set their compensatedBy)", namespace, err.Value())) + case TagRecursiveState: + workflowErrors = append(workflowErrors, fmt.Errorf("%s can't no be recursive %q", namespace, strings.ToLower(err.Param()))) + case TagISO8601Duration: + workflowErrors = append(workflowErrors, fmt.Errorf("%s invalid iso8601 duration %q", namespace, err.Value())) + default: + workflowErrors = append(workflowErrors, err) + } + } + + return WorkflowErrors(workflowErrors) +} + +func contains(a []string, x string) bool { + for _, n := range a { + if x == n { + return true + } + } + return false +} + +func replaceLastNamespace(namespace, replace string) string { + index := strings.LastIndex(namespace, ".") + if index == -1 { + return namespace + } + + return fmt.Sprintf("%s.%s", namespace[:index], replace) +} pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy