From 4afc5f310fe575b3108e771c8ae11ddde4308e31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20R=2E=20de=20Miranda?= Date: Tue, 11 Jul 2023 09:21:20 -0300 Subject: [PATCH] Validate the refs exists (#164) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add validator with context to validate the refs (functions, events, retries, etc) exists Signed-off-by: André R. de Miranda * Valid errors exist and change unique_struct to unique Signed-off-by: André R. de Miranda * Fix lint Signed-off-by: André R. de Miranda * Add transition and compensation validations. Refactor state exists Signed-off-by: André R. de Miranda * Json ignore field Signed-off-by: André R. de Miranda * Fix tests Signed-off-by: André R. de Miranda * Add validations: OnEvent.EventRefs, EventCondition.EventRef, and FunctionType Signed-off-by: André R. de Miranda * Fix tests Signed-off-by: André R. de Miranda * Replace oneof to oneofkind, and improve the error message Signed-off-by: André R. de Miranda * Validation refactoring for each struct to have its test case. Revision suggestions Signed-off-by: André R. de Miranda * Add validation oneofkind validation auth struct Signed-off-by: André R. de Miranda * Add new tests and improve error description Signed-off-by: André R. de Miranda * Add new unit tests, refactor intstr validator, and add new validation description Signed-off-by: André R. de Miranda * Remove reflection from validation Signed-off-by: André R. de Miranda * Remove commented code Signed-off-by: André R. de Miranda --------- Signed-off-by: André R. de Miranda --- hack/deepcopy-gen.sh | 1 + model/action.go | 8 +- model/action_data_filter.go | 4 +- model/action_data_filter_validator_test.go | 22 + model/action_test.go | 73 --- model/action_validator.go | 58 ++ model/action_validator_test.go | 200 ++++++ model/auth.go | 38 +- model/auth_validator_test.go | 210 ++++++ model/callback_state.go | 2 +- model/callback_state_test.go | 96 --- model/callback_state_validator_test.go | 116 ++++ model/delay_state_test.go | 73 --- model/delay_state_validator_test.go | 68 ++ model/event.go | 21 +- model/event_data_filter.go | 4 +- model/event_data_filter_validator_test.go | 22 + model/event_state.go | 10 +- model/event_state_validator.go | 39 ++ model/event_state_validator_test.go | 189 ++++++ model/event_validator.go | 20 +- model/event_validator_test.go | 218 +++++- model/foreach_state.go | 4 +- model/foreach_state_validator.go | 20 +- model/foreach_state_validator_test.go | 214 +++--- model/function.go | 37 +- model/function_validator_test.go | 74 +++ model/inject_state_validator_test.go | 28 + model/operation_state.go | 8 +- model/operation_state_validator_test.go | 121 ++++ model/parallel_state.go | 17 +- model/parallel_state_validator.go | 24 +- model/parallel_state_validator_test.go | 338 ++++++---- model/retry.go | 13 + model/retry_validator.go | 1 + model/retry_validator_test.go | 129 ++-- model/sleep_state_test.go | 61 -- model/sleep_state_validator_test.go | 95 +++ model/state_exec_timeout.go | 4 +- model/state_exec_timeout_test.go | 64 -- model/state_exec_timeout_validator_test.go | 95 +++ model/states.go | 6 +- model/states_validator.go | 28 +- model/states_validator_test.go | 200 +++--- model/switch_state.go | 12 +- model/switch_state_validator.go | 31 +- model/switch_state_validator_test.go | 455 ++++++------- model/workflow.go | 88 ++- model/workflow_ref.go | 25 +- model/workflow_ref_test.go | 75 --- model/workflow_ref_validator_test.go | 68 ++ model/workflow_test.go | 3 +- model/workflow_validator.go | 225 +++++-- model/workflow_validator_test.go | 619 +++++++++++++----- model/zz_generated.deepcopy.go | 82 ++- parser/parser.go | 8 +- parser/parser_test.go | 76 ++- .../customerbankingtransactions.json | 2 +- .../workflows/customercreditcheck.json | 4 + .../eventbasedgreetingexclusive.sw.json | 4 + .../workflows/greetings-v08-spec.sw.yaml | 71 +- .../workflows/patientonboarding.sw.yaml | 4 +- .../workflows/purchaseorderworkflow.sw.json | 2 +- parser/testdata/workflows/vitalscheck.json | 8 +- model/util.go => util/unmarshal.go | 26 +- .../unmarshal_benchmark_test.go | 2 +- model/util_test.go => util/unmarshal_test.go | 69 +- validator/validator.go | 26 +- validator/validator_test.go | 58 ++ validator/workflow.go | 154 +++++ 70 files changed, 3645 insertions(+), 1625 deletions(-) create mode 100644 model/action_data_filter_validator_test.go create mode 100644 model/action_validator.go create mode 100644 model/action_validator_test.go create mode 100644 model/auth_validator_test.go delete mode 100644 model/callback_state_test.go create mode 100644 model/callback_state_validator_test.go create mode 100644 model/delay_state_validator_test.go create mode 100644 model/event_data_filter_validator_test.go create mode 100644 model/event_state_validator.go create mode 100644 model/event_state_validator_test.go create mode 100644 model/function_validator_test.go create mode 100644 model/inject_state_validator_test.go create mode 100644 model/operation_state_validator_test.go create mode 100644 model/sleep_state_validator_test.go create mode 100644 model/state_exec_timeout_validator_test.go create mode 100644 model/workflow_ref_validator_test.go rename model/util.go => util/unmarshal.go (91%) rename model/util_benchmark_test.go => util/unmarshal_benchmark_test.go (98%) rename model/util_test.go => util/unmarshal_test.go (79%) create mode 100644 validator/workflow.go diff --git a/hack/deepcopy-gen.sh b/hack/deepcopy-gen.sh index 353a682..8069d7e 100755 --- a/hack/deepcopy-gen.sh +++ b/hack/deepcopy-gen.sh @@ -44,5 +44,6 @@ if [ "${GENS}" = "all" ] || grep -qw "deepcopy" <<<"${GENS}"; then "${GOPATH}/bin/deepcopy-gen" -v 1 \ --input-dirs ./model -O zz_generated.deepcopy \ --go-header-file "${SCRIPT_ROOT}/hack/boilerplate.txt" \ + --output-base ./ "$@" 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..7202614 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) @@ -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..bc48a6c 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: `workflow.states[0].forEachState.actions must have the minimum 1`, }, } - 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/operation_state.go b/model/operation_state.go index ebe97e0..da523ea 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"` + Actions []Action `json:"actions" validate:"min=1,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..ead04a8 --- /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: `workflow.states[0].actions must have the minimum 1`, + }, + { + 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..0ce87dc 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) + 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 be0ac4d..b52840e 100644 --- a/parser/parser_test.go +++ b/parser/parser_test.go @@ -21,12 +21,12 @@ import ( "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) { @@ -34,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() { @@ -350,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) }, }, { @@ -393,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 @@ -465,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 @@ -474,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) @@ -489,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) @@ -514,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) @@ -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,13 +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) + 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\"}")) @@ -942,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\"}")) @@ -960,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\"}}}")) 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 ff4b21f..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: @@ -221,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 91% rename from model/util.go rename to util/unmarshal.go index 645b4f5..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" @@ -28,8 +28,9 @@ import ( "sync/atomic" "time" - "github.com/serverlessworkflow/sdk-go/v2/validator" "sigs.k8s.io/yaml" + + val "github.com/serverlessworkflow/sdk-go/v2/validator" ) // Kind ... @@ -40,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 @@ -88,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 ") @@ -174,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 } @@ -188,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 } @@ -229,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 @@ -242,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) } @@ -273,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 } 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) +}