From 6be90dc5873d55337dce19f1049d379c41986e01 Mon Sep 17 00:00:00 2001 From: Nick Aguilar Date: Wed, 29 Jan 2025 14:19:53 -0800 Subject: [PATCH] Required Fields V2 - Conditionally Required Fields (#2099) * PoC for direct builder defined JSON Schema with working unit test * Adds more unit tests * Renames definition.validSchema to definition.groupConditions and restricts them to anyOf, allOf, oneOf which are all a list of fieldKeys * WIP - implementation of DependsOnConditions conversion to JsonSchema * Generates a basic schema. Passes basic test case * More unit tests, non-passing * Able to generate schema when there are multiple conditions on one field defined for one field. * WIP - unit tests * WIP - two cases failing, not from an incorrectly created schema * All test cases for multiple conditions on same field passing * Can validate a basic object dependency * Can validate a basic object dependency with an is_not operator * Adds more object test cases * Adds unit tests for different value data types * Can validate simple conditions with undefined values * Adds new unit tests for multiple conditions with an undefined value. Adds a new unit test for multiple object dependant conditions * Some helper methods and DRYing * WIP - only test object case * WIP - renames helper method to be more descriptive * WIP - enable all unit tests * WIP - factors out singleConditionSingleDependency case * WIP - factors out singleConditionSingleDependency case * WIP - simplifies multiple non-object case * WIP - simplifies multiple object case. actually passes multiple: true parameter which fixes 3 unit tests * Removes commented out code * Fixed incorrect test case, all test cases passing * Removes 3 year old todo unit test * Removes groupConditions as they'll be implemented in a separate PR * Renames fields-to-jsonschema file to indicate it only contains snapshot tests. generates snapshots * Removes dedicated snapshot tests and just makes all existing tests also a snapshot test * Skips conditions when generating types for the stored file * WIP - unit test for null values * Adds conditional requirement to Lead V2 action as well * Handles explicitly null values as if they were undefined fields * Removes conditionally required from Lead V2 since that action relies on syncMode * Consolidates two test cases into one since they share a schema. Removes a console log * WIP - conditionally required object sub-property unit test * WIP - working on conditionally required object properties * Can validate conditionally required object properties * Unit test for when two fields depend on each other * Updates readme and the description for the required property with info on how conditional fields work * Adds dot notation example in readme --- README.md | 122 +- .../schema-validation.test.ts.snap | 1592 +++++++++++++++++ .../src/__tests__/schema-validation.test.ts | 1231 ++++++++++++- .../destination-kit/fields-to-jsonschema.ts | 311 +++- packages/core/src/destination-kit/types.ts | 9 +- .../src/destinations/salesforce/lead/index.ts | 4 +- .../destinations/salesforce/sf-properties.ts | 10 + 7 files changed, 3266 insertions(+), 13 deletions(-) create mode 100644 packages/core/src/__tests__/__snapshots__/schema-validation.test.ts.snap diff --git a/README.md b/README.md index ee859d29c5..47093dec00 100644 --- a/README.md +++ b/README.md @@ -267,7 +267,7 @@ interface InputField { dynamic?: boolean /** Whether or not the field is required */ - required?: boolean + required?: boolean | DependsOnConditions /** * Optional definition for the properties of `type: 'object'` fields @@ -358,6 +358,126 @@ const destination = { In addition to default values for input fields, you can also specify the defaultSubscription for a given action – this is the FQL query that will be automatically populated when a customer configures a new subscription triggering a given action. +## Required Fields + +You may configure a field to either be always required, not required, or conditionally required. Validation for required fields is performed both when a user is configuring a mapping in the UI and when an event payload is delivered through a `perform` block. + +**An example of each possible value for `required`** + +```js +const destination = { + actions: { + readmeAction: { + fields: { + operation: { + label: 'An operation for the readme action', + required: true // This field is always required and any payloads omitting it will fail + }, + creationName: { + label: "The name of the resource to create, required when operation = 'create'", + required: { + // This field is required only when the 'operation' field has the value 'create' + match: 'all', + conditions: [ + { + fieldKey: 'operation', + operator: 'is', + value: 'create' + } + ] + } + }, + email: { + label: 'The customer email', + required: false // This field is not required. This is the same as not including the 'required' property at all + }, + userIdentifiers: { + phone: { + label: 'The customer phone number', + required: { + // If email is not provided then a phone number is required + conditions: [{ fieldKey: 'email', operator: 'is', value: undefined }] + } + }, + countryCode: { + label: 'The country code for the customer phone number', + required: { + // If a userIdentifiers.phone is provided then the country code is also required + conditions: [ + { + fieldKey: 'userIdentifiers.phone', // Dot notation may be used to address object fields. + operator: 'is_not', + value: undefined + } + ] + } + } + } + } + } + } +} +``` + +**Examples of valid and invalid payloads for the fields above** + +```json +// This payload is valid since the only required field, 'operation', is defined. +{ + "operation": "update", + "email": "read@me.com" +} +``` + +```json +// This payload is invalid since 'creationName' is required because 'operation' is 'create' +{ + "operation": "create", + "email": "read@me.com" +} +// This error will be thrown: +"message": "The root value is missing the required field 'creationName'. The root value must match \"then\" schema." +``` + +```json +// This payload is valid since the two required fields, 'operation' and 'creationName' are defined. +{ + "operation": "create", + "creationName": "readme", + "email": "read@me.com" +} +``` + +```json +// This payload is invalid since 'phone' is required when 'email' is missing. +{ + "operation": "update", +} +// This error will be thrown: +"message": "The root value is missing the required field 'phone'. The root value must match \"then\" schema." +``` + +```json +// This payload is invalid since 'countryCode' is required when 'phone' is defined +{ + "operation": "update", + "userIdentifiers": { "phone": "619-555-5555" } +} +// This error will be thrown: +"message": "The root value is missing the required field 'countryCode'. The root value must match \"then\" schema." +``` + +```json +// This payload is valid since all conditionally required fields are included +{ + "operation": "update", + "userIdentifiers": { + "phone": "619-555-5555", + "countryCode": "+1" + } +} +``` + ## Dynamic Fields You can setup a field which dynamically fetches inputs from your destination. These dynamic fields can be used to populate a dropdown menu of options for your users to select. diff --git a/packages/core/src/__tests__/__snapshots__/schema-validation.test.ts.snap b/packages/core/src/__tests__/__snapshots__/schema-validation.test.ts.snap new file mode 100644 index 0000000000..d4ca05feef --- /dev/null +++ b/packages/core/src/__tests__/__snapshots__/schema-validation.test.ts.snap @@ -0,0 +1,1592 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`conditionally required fields should handle different data types should handle when allowNull is true and the field is null 1`] = ` +Object { + "$schema": "http://json-schema.org/schema#", + "additionalProperties": false, + "allOf": Array [ + Object { + "if": Object { + "anyOf": Array [ + Object { + "not": Object { + "required": Array [ + "a", + ], + }, + }, + Object { + "properties": Object { + "a": Object { + "type": "null", + }, + }, + }, + ], + }, + "then": Object { + "required": Array [ + "b", + ], + }, + }, + ], + "properties": Object { + "a": Object { + "default": undefined, + "description": "a", + "format": undefined, + "title": "a", + "type": Array [ + "string", + "null", + ], + }, + "b": Object { + "default": undefined, + "description": "b", + "format": undefined, + "title": "b", + "type": "string", + }, + }, + "required": Array [], + "type": "object", +} +`; + +exports[`conditionally required fields should handle different data types should validate boolean fields 1`] = ` +Object { + "$schema": "http://json-schema.org/schema#", + "additionalProperties": false, + "allOf": Array [ + Object { + "if": Object { + "properties": Object { + "a": Object { + "const": true, + }, + }, + }, + "then": Object { + "required": Array [ + "b", + ], + }, + }, + ], + "properties": Object { + "a": Object { + "default": undefined, + "description": "a", + "format": undefined, + "title": "a", + "type": "boolean", + }, + "b": Object { + "default": undefined, + "description": "b", + "format": undefined, + "title": "b", + "type": "string", + }, + }, + "required": Array [], + "type": "object", +} +`; + +exports[`conditionally required fields should handle different data types should validate number fields 1`] = ` +Object { + "$schema": "http://json-schema.org/schema#", + "additionalProperties": false, + "allOf": Array [ + Object { + "if": Object { + "properties": Object { + "a": Object { + "const": 10, + }, + }, + }, + "then": Object { + "required": Array [ + "b", + ], + }, + }, + ], + "properties": Object { + "a": Object { + "default": undefined, + "description": "a", + "format": undefined, + "title": "a", + "type": "number", + }, + "b": Object { + "default": undefined, + "description": "b", + "format": undefined, + "title": "b", + "type": "string", + }, + }, + "required": Array [], + "type": "object", +} +`; + +exports[`conditionally required fields should handle different data types should validate when multiple values of a condition are undefined 1`] = ` +Object { + "$schema": "http://json-schema.org/schema#", + "additionalProperties": false, + "allOf": Array [ + Object { + "if": Object { + "allOf": Array [ + Object { + "not": Object { + "required": Array [ + "a", + ], + }, + }, + Object { + "not": Object { + "required": Array [ + "b", + ], + }, + }, + ], + }, + "then": Object { + "required": Array [ + "c", + ], + }, + }, + ], + "properties": Object { + "a": Object { + "default": undefined, + "description": "a", + "format": undefined, + "title": "a", + "type": "string", + }, + "b": Object { + "default": undefined, + "description": "b", + "format": undefined, + "title": "b", + "type": "string", + }, + "c": Object { + "default": undefined, + "description": "c", + "format": undefined, + "title": "c", + "type": "string", + }, + }, + "required": Array [], + "type": "object", +} +`; + +exports[`conditionally required fields should handle different data types should validate when multiple values of a condition are undefined, any matcher 1`] = ` +Object { + "$schema": "http://json-schema.org/schema#", + "additionalProperties": false, + "allOf": Array [ + Object { + "if": Object { + "anyOf": Array [ + Object { + "not": Object { + "required": Array [ + "a", + ], + }, + }, + Object { + "not": Object { + "required": Array [ + "b", + ], + }, + }, + ], + }, + "then": Object { + "required": Array [ + "c", + ], + }, + }, + ], + "properties": Object { + "a": Object { + "default": undefined, + "description": "a", + "format": undefined, + "title": "a", + "type": "string", + }, + "b": Object { + "default": undefined, + "description": "b", + "format": undefined, + "title": "b", + "type": "string", + }, + "c": Object { + "default": undefined, + "description": "c", + "format": undefined, + "title": "c", + "type": "string", + }, + }, + "required": Array [], + "type": "object", +} +`; + +exports[`conditionally required fields should handle different data types should validate when the value of a condition is undefined, is operator 1`] = ` +Object { + "$schema": "http://json-schema.org/schema#", + "additionalProperties": false, + "allOf": Array [ + Object { + "if": Object { + "anyOf": Array [ + Object { + "not": Object { + "required": Array [ + "a", + ], + }, + }, + Object { + "properties": Object { + "a": Object { + "type": "null", + }, + }, + }, + ], + }, + "then": Object { + "required": Array [ + "b", + ], + }, + }, + ], + "properties": Object { + "a": Object { + "default": undefined, + "description": "a", + "format": undefined, + "title": "a", + "type": "string", + }, + "b": Object { + "default": undefined, + "description": "b", + "format": undefined, + "title": "b", + "type": "string", + }, + }, + "required": Array [], + "type": "object", +} +`; + +exports[`conditionally required fields should handle different data types should validate when the value of a condition is undefined, is_not operator 1`] = ` +Object { + "$schema": "http://json-schema.org/schema#", + "additionalProperties": false, + "allOf": Array [ + Object { + "if": Object { + "allOf": Array [ + Object { + "required": Array [ + "a", + ], + }, + Object { + "not": Object { + "properties": Object { + "a": Object { + "type": "null", + }, + }, + }, + }, + ], + }, + "then": Object { + "required": Array [ + "b", + ], + }, + }, + ], + "properties": Object { + "a": Object { + "default": undefined, + "description": "a", + "format": undefined, + "title": "a", + "type": "string", + }, + "b": Object { + "default": undefined, + "description": "b", + "format": undefined, + "title": "b", + "type": "string", + }, + }, + "required": Array [], + "type": "object", +} +`; + +exports[`conditionally required fields should handle multiple conditions on the same field should handle when one field has multiple conditions for multiple other fields 1`] = ` +Object { + "$schema": "http://json-schema.org/schema#", + "additionalProperties": false, + "allOf": Array [ + Object { + "if": Object { + "allOf": Array [ + Object { + "properties": Object { + "a": Object { + "const": "a_value", + }, + }, + "required": Array [ + "a", + ], + }, + Object { + "properties": Object { + "c": Object { + "const": "c_value", + }, + }, + "required": Array [ + "c", + ], + }, + Object { + "properties": Object { + "d": Object { + "const": "d_value", + }, + }, + "required": Array [ + "d", + ], + }, + ], + }, + "then": Object { + "required": Array [ + "b", + ], + }, + }, + ], + "properties": Object { + "a": Object { + "default": undefined, + "description": "a", + "format": undefined, + "title": "a", + "type": "string", + }, + "b": Object { + "default": undefined, + "description": "b", + "format": undefined, + "title": "b", + "type": "string", + }, + "c": Object { + "default": undefined, + "description": "c", + "format": undefined, + "title": "c", + "type": "string", + }, + "d": Object { + "default": undefined, + "description": "d", + "format": undefined, + "title": "d", + "type": "string", + }, + }, + "required": Array [ + "a", + ], + "type": "object", +} +`; + +exports[`conditionally required fields should handle multiple conditions on the same field should handle when one field has multiple conditions for multiple other fields with an any matcher 1`] = ` +Object { + "$schema": "http://json-schema.org/schema#", + "additionalProperties": false, + "allOf": Array [ + Object { + "if": Object { + "anyOf": Array [ + Object { + "properties": Object { + "a": Object { + "const": "a_value", + }, + }, + "required": Array [ + "a", + ], + }, + Object { + "properties": Object { + "c": Object { + "const": "c_value", + }, + }, + "required": Array [ + "c", + ], + }, + Object { + "properties": Object { + "d": Object { + "const": "d_value", + }, + }, + "required": Array [ + "d", + ], + }, + ], + }, + "then": Object { + "required": Array [ + "b", + ], + }, + }, + ], + "properties": Object { + "a": Object { + "default": undefined, + "description": "a", + "format": undefined, + "title": "a", + "type": "string", + }, + "b": Object { + "default": undefined, + "description": "b", + "format": undefined, + "title": "b", + "type": "string", + }, + "c": Object { + "default": undefined, + "description": "c", + "format": undefined, + "title": "c", + "type": "string", + }, + "d": Object { + "default": undefined, + "description": "d", + "format": undefined, + "title": "d", + "type": "string", + }, + "e": Object { + "default": undefined, + "description": "e", + "format": undefined, + "title": "e", + "type": "string", + }, + }, + "required": Array [], + "type": "object", +} +`; + +exports[`conditionally required fields should handle multiple conditions on the same field should handle when one field has multiple values on another for which it is required, any matcher 1`] = ` +Object { + "$schema": "http://json-schema.org/schema#", + "additionalProperties": false, + "allOf": Array [ + Object { + "if": Object { + "anyOf": Array [ + Object { + "properties": Object { + "a": Object { + "const": "a_value", + }, + }, + "required": Array [ + "a", + ], + }, + Object { + "properties": Object { + "a": Object { + "const": "a_value2", + }, + }, + "required": Array [ + "a", + ], + }, + ], + }, + "then": Object { + "required": Array [ + "b", + ], + }, + }, + ], + "properties": Object { + "a": Object { + "default": undefined, + "description": "a", + "format": undefined, + "title": "a", + "type": "string", + }, + "b": Object { + "default": undefined, + "description": "b", + "format": undefined, + "title": "b", + "type": "string", + }, + }, + "required": Array [ + "a", + ], + "type": "object", +} +`; + +exports[`conditionally required fields should handle object conditions should validate a single object condition 1`] = ` +Object { + "$schema": "http://json-schema.org/schema#", + "additionalProperties": false, + "allOf": Array [ + Object { + "if": Object { + "properties": Object { + "a": Object { + "properties": Object { + "b": Object { + "const": "b_value", + }, + }, + "required": Array [ + "b", + ], + }, + }, + "required": Array [ + "a", + ], + }, + "then": Object { + "required": Array [ + "c", + ], + }, + }, + ], + "properties": Object { + "a": Object { + "$schema": "http://json-schema.org/schema#", + "additionalProperties": false, + "default": undefined, + "description": "a", + "format": undefined, + "properties": Object { + "b": Object { + "default": undefined, + "description": undefined, + "format": undefined, + "title": "b", + "type": "string", + }, + }, + "required": Array [], + "title": "a", + "type": "object", + }, + "c": Object { + "default": undefined, + "description": "c", + "format": undefined, + "title": "c", + "type": "string", + }, + }, + "required": Array [], + "type": "object", +} +`; + +exports[`conditionally required fields should handle object conditions should validate a single object condition with an is_not operator 1`] = ` +Object { + "$schema": "http://json-schema.org/schema#", + "additionalProperties": false, + "allOf": Array [ + Object { + "if": Object { + "properties": Object { + "a": Object { + "properties": Object { + "b": Object { + "not": Object { + "const": "b_value", + }, + }, + }, + "required": Array [ + "b", + ], + }, + }, + "required": Array [ + "a", + ], + }, + "then": Object { + "required": Array [ + "c", + ], + }, + }, + ], + "properties": Object { + "a": Object { + "$schema": "http://json-schema.org/schema#", + "additionalProperties": false, + "default": undefined, + "description": "a", + "format": undefined, + "properties": Object { + "b": Object { + "default": undefined, + "description": undefined, + "format": undefined, + "title": "b", + "type": "string", + }, + }, + "required": Array [], + "title": "a", + "type": "object", + }, + "c": Object { + "default": undefined, + "description": "c", + "format": undefined, + "title": "c", + "type": "string", + }, + }, + "required": Array [], + "type": "object", +} +`; + +exports[`conditionally required fields should handle object conditions should validate an inner conditionally required property on an object correctly 1`] = ` +Object { + "$schema": "http://json-schema.org/schema#", + "additionalProperties": false, + "allOf": Array [ + Object { + "if": Object { + "properties": Object { + "a": Object { + "const": "a_value", + }, + }, + }, + "then": Object { + "properties": Object { + "b": Object { + "required": Array [ + "c", + ], + }, + }, + "required": Array [ + "b", + ], + }, + }, + ], + "properties": Object { + "a": Object { + "default": undefined, + "description": "a", + "format": undefined, + "title": "a", + "type": "string", + }, + "b": Object { + "$schema": "http://json-schema.org/schema#", + "additionalProperties": false, + "default": undefined, + "description": "b", + "format": undefined, + "properties": Object { + "c": Object { + "default": undefined, + "description": "c", + "format": undefined, + "title": "c", + "type": "string", + }, + }, + "title": "b", + "type": "object", + }, + }, + "required": Array [], + "type": "object", +} +`; + +exports[`conditionally required fields should handle object conditions should validate multiple object conditions 1`] = ` +Object { + "$schema": "http://json-schema.org/schema#", + "additionalProperties": false, + "allOf": Array [ + Object { + "if": Object { + "allOf": Array [ + Object { + "properties": Object { + "a": Object { + "properties": Object { + "b": Object { + "const": "b_value", + }, + }, + "required": Array [ + "b", + ], + }, + }, + "required": Array [ + "a", + ], + }, + Object { + "properties": Object { + "a": Object { + "properties": Object { + "c": Object { + "const": "c_value", + }, + }, + "required": Array [ + "c", + ], + }, + }, + "required": Array [ + "a", + ], + }, + ], + }, + "then": Object { + "required": Array [ + "d", + ], + }, + }, + ], + "properties": Object { + "a": Object { + "$schema": "http://json-schema.org/schema#", + "additionalProperties": false, + "default": undefined, + "description": "a", + "format": undefined, + "properties": Object { + "b": Object { + "default": undefined, + "description": undefined, + "format": undefined, + "title": "b", + "type": "string", + }, + "c": Object { + "default": undefined, + "description": undefined, + "format": undefined, + "title": "c", + "type": "string", + }, + }, + "required": Array [], + "title": "a", + "type": "object", + }, + "d": Object { + "default": undefined, + "description": "d", + "format": undefined, + "title": "d", + "type": "string", + }, + }, + "required": Array [], + "type": "object", +} +`; + +exports[`conditionally required fields should handle object conditions should validate multiple object conditions where an object and a field are referenced 1`] = ` +Object { + "$schema": "http://json-schema.org/schema#", + "additionalProperties": false, + "allOf": Array [ + Object { + "if": Object { + "allOf": Array [ + Object { + "properties": Object { + "a": Object { + "properties": Object { + "b": Object { + "const": "b_value", + }, + }, + "required": Array [ + "b", + ], + }, + }, + "required": Array [ + "a", + ], + }, + Object { + "properties": Object { + "d": Object { + "const": "d_value", + }, + }, + "required": Array [ + "d", + ], + }, + ], + }, + "then": Object { + "required": Array [ + "e", + ], + }, + }, + ], + "properties": Object { + "a": Object { + "$schema": "http://json-schema.org/schema#", + "additionalProperties": false, + "default": undefined, + "description": "a", + "format": undefined, + "properties": Object { + "b": Object { + "default": undefined, + "description": undefined, + "format": undefined, + "title": "b", + "type": "string", + }, + "c": Object { + "default": undefined, + "description": undefined, + "format": undefined, + "title": "c", + "type": "string", + }, + }, + "required": Array [], + "title": "a", + "type": "object", + }, + "d": Object { + "default": undefined, + "description": "d", + "format": undefined, + "title": "d", + "type": "string", + }, + "e": Object { + "default": undefined, + "description": "e", + "format": undefined, + "title": "e", + "type": "string", + }, + }, + "required": Array [], + "type": "object", +} +`; + +exports[`conditionally required fields should handle object conditions should validate multiple object conditions where multiple objects are referenced 1`] = ` +Object { + "$schema": "http://json-schema.org/schema#", + "additionalProperties": false, + "allOf": Array [ + Object { + "if": Object { + "allOf": Array [ + Object { + "properties": Object { + "a": Object { + "properties": Object { + "b": Object { + "const": "b_value", + }, + }, + "required": Array [ + "b", + ], + }, + }, + "required": Array [ + "a", + ], + }, + Object { + "properties": Object { + "c": Object { + "properties": Object { + "d": Object { + "const": "d_value", + }, + }, + "required": Array [ + "d", + ], + }, + }, + "required": Array [ + "c", + ], + }, + ], + }, + "then": Object { + "required": Array [ + "e", + ], + }, + }, + ], + "properties": Object { + "a": Object { + "$schema": "http://json-schema.org/schema#", + "additionalProperties": false, + "default": undefined, + "description": "a", + "format": undefined, + "properties": Object { + "b": Object { + "default": undefined, + "description": undefined, + "format": undefined, + "title": "b", + "type": "string", + }, + }, + "required": Array [], + "title": "a", + "type": "object", + }, + "c": Object { + "$schema": "http://json-schema.org/schema#", + "additionalProperties": false, + "default": undefined, + "description": "c", + "format": undefined, + "properties": Object { + "d": Object { + "default": undefined, + "description": undefined, + "format": undefined, + "title": "d", + "type": "string", + }, + }, + "required": Array [], + "title": "c", + "type": "object", + }, + "e": Object { + "default": undefined, + "description": "e", + "format": undefined, + "title": "e", + "type": "string", + }, + }, + "required": Array [], + "type": "object", +} +`; + +exports[`conditionally required fields should handle object conditions should validate multiple object conditions with an any matcher 1`] = ` +Object { + "$schema": "http://json-schema.org/schema#", + "additionalProperties": false, + "allOf": Array [ + Object { + "if": Object { + "anyOf": Array [ + Object { + "properties": Object { + "a": Object { + "properties": Object { + "b": Object { + "const": "b_value", + }, + }, + "required": Array [ + "b", + ], + }, + }, + "required": Array [ + "a", + ], + }, + Object { + "properties": Object { + "a": Object { + "properties": Object { + "c": Object { + "const": "c_value", + }, + }, + "required": Array [ + "c", + ], + }, + }, + "required": Array [ + "a", + ], + }, + ], + }, + "then": Object { + "required": Array [ + "d", + ], + }, + }, + ], + "properties": Object { + "a": Object { + "$schema": "http://json-schema.org/schema#", + "additionalProperties": false, + "default": undefined, + "description": "a", + "format": undefined, + "properties": Object { + "b": Object { + "default": undefined, + "description": undefined, + "format": undefined, + "title": "b", + "type": "string", + }, + "c": Object { + "default": undefined, + "description": undefined, + "format": undefined, + "title": "c", + "type": "string", + }, + }, + "required": Array [], + "title": "a", + "type": "object", + }, + "d": Object { + "default": undefined, + "description": "d", + "format": undefined, + "title": "d", + "type": "string", + }, + "e": Object { + "default": undefined, + "description": "e", + "format": undefined, + "title": "e", + "type": "string", + }, + }, + "required": Array [], + "type": "object", +} +`; + +exports[`conditionally required fields should handle object conditions should validate when referencing a child field which is not explicitly defined in properties 1`] = ` +Object { + "$schema": "http://json-schema.org/schema#", + "additionalProperties": false, + "allOf": Array [ + Object { + "if": Object { + "properties": Object { + "a": Object { + "properties": Object { + "b": Object { + "const": "b_value", + }, + }, + "required": Array [ + "b", + ], + }, + }, + "required": Array [ + "a", + ], + }, + "then": Object { + "required": Array [ + "c", + ], + }, + }, + ], + "properties": Object { + "a": Object { + "default": undefined, + "description": "a", + "format": undefined, + "title": "a", + "type": "object", + }, + "c": Object { + "default": undefined, + "description": "c", + "format": undefined, + "title": "c", + "type": "string", + }, + }, + "required": Array [], + "type": "object", +} +`; + +exports[`conditionally required fields should validate a single conditional requirement should validate b when it is required and when it is not required 1`] = ` +Object { + "$schema": "http://json-schema.org/schema#", + "additionalProperties": false, + "allOf": Array [ + Object { + "if": Object { + "properties": Object { + "a": Object { + "const": "a_value", + }, + }, + }, + "then": Object { + "required": Array [ + "b", + ], + }, + }, + ], + "properties": Object { + "a": Object { + "default": undefined, + "description": "a", + "format": undefined, + "title": "a", + "type": "string", + }, + "b": Object { + "default": undefined, + "description": "b", + "format": undefined, + "title": "b", + "type": "string", + }, + }, + "required": Array [ + "a", + ], + "type": "object", +} +`; + +exports[`conditionally required fields should validate multiple conditional requirements on different fields should validate when both b and c are required 1`] = ` +Object { + "$schema": "http://json-schema.org/schema#", + "additionalProperties": false, + "allOf": Array [ + Object { + "if": Object { + "properties": Object { + "a": Object { + "const": "a_value", + }, + }, + }, + "then": Object { + "required": Array [ + "b", + ], + }, + }, + Object { + "if": Object { + "properties": Object { + "a": Object { + "not": Object { + "const": "value", + }, + }, + }, + }, + "then": Object { + "required": Array [ + "c", + ], + }, + }, + ], + "properties": Object { + "a": Object { + "default": undefined, + "description": "a", + "format": undefined, + "title": "a", + "type": "string", + }, + "b": Object { + "default": undefined, + "description": "b", + "format": undefined, + "title": "b", + "type": "string", + }, + "c": Object { + "default": undefined, + "description": "c", + "format": undefined, + "title": "c", + "type": "string", + }, + }, + "required": Array [ + "a", + ], + "type": "object", +} +`; + +exports[`conditionally required fields should validate multiple conditional requirements on different fields should validate when neither b nor c are required 1`] = ` +Object { + "$schema": "http://json-schema.org/schema#", + "additionalProperties": false, + "allOf": Array [ + Object { + "if": Object { + "properties": Object { + "a": Object { + "const": "a_value", + }, + }, + }, + "then": Object { + "required": Array [ + "b", + ], + }, + }, + Object { + "if": Object { + "properties": Object { + "a": Object { + "const": "value", + }, + }, + }, + "then": Object { + "required": Array [ + "c", + ], + }, + }, + ], + "properties": Object { + "a": Object { + "default": undefined, + "description": "a", + "format": undefined, + "title": "a", + "type": "string", + }, + "b": Object { + "default": undefined, + "description": "b", + "format": undefined, + "title": "b", + "type": "string", + }, + "c": Object { + "default": undefined, + "description": "c", + "format": undefined, + "title": "c", + "type": "string", + }, + }, + "required": Array [ + "a", + ], + "type": "object", +} +`; + +exports[`conditionally required fields should validate multiple conditional requirements on different fields should validate when only b is required 1`] = ` +Object { + "$schema": "http://json-schema.org/schema#", + "additionalProperties": false, + "allOf": Array [ + Object { + "if": Object { + "properties": Object { + "a": Object { + "const": "a_value", + }, + }, + }, + "then": Object { + "required": Array [ + "b", + ], + }, + }, + Object { + "if": Object { + "properties": Object { + "a": Object { + "const": "value", + }, + }, + }, + "then": Object { + "required": Array [ + "c", + ], + }, + }, + ], + "properties": Object { + "a": Object { + "default": undefined, + "description": "a", + "format": undefined, + "title": "a", + "type": "string", + }, + "b": Object { + "default": undefined, + "description": "b", + "format": undefined, + "title": "b", + "type": "string", + }, + "c": Object { + "default": undefined, + "description": "c", + "format": undefined, + "title": "c", + "type": "string", + }, + }, + "required": Array [ + "a", + ], + "type": "object", +} +`; + +exports[`conditionally required fields should validate multiple conditional requirements on different fields should validate when only c is required 1`] = ` +Object { + "$schema": "http://json-schema.org/schema#", + "additionalProperties": false, + "allOf": Array [ + Object { + "if": Object { + "properties": Object { + "a": Object { + "const": "a_value", + }, + }, + }, + "then": Object { + "required": Array [ + "b", + ], + }, + }, + Object { + "if": Object { + "properties": Object { + "a": Object { + "const": "value", + }, + }, + }, + "then": Object { + "required": Array [ + "c", + ], + }, + }, + ], + "properties": Object { + "a": Object { + "default": undefined, + "description": "a", + "format": undefined, + "title": "a", + "type": "string", + }, + "b": Object { + "default": undefined, + "description": "b", + "format": undefined, + "title": "b", + "type": "string", + }, + "c": Object { + "default": undefined, + "description": "c", + "format": undefined, + "title": "c", + "type": "string", + }, + }, + "required": Array [ + "a", + ], + "type": "object", +} +`; + +exports[`conditionally required fields should validate multiple conditional requirements on different fields should validate when two fields depend on each other 1`] = ` +Object { + "$schema": "http://json-schema.org/schema#", + "additionalProperties": false, + "allOf": Array [ + Object { + "if": Object { + "anyOf": Array [ + Object { + "not": Object { + "required": Array [ + "b", + ], + }, + }, + Object { + "properties": Object { + "b": Object { + "type": "null", + }, + }, + }, + ], + }, + "then": Object { + "required": Array [ + "a", + ], + }, + }, + Object { + "if": Object { + "anyOf": Array [ + Object { + "not": Object { + "required": Array [ + "a", + ], + }, + }, + Object { + "properties": Object { + "a": Object { + "type": "null", + }, + }, + }, + ], + }, + "then": Object { + "required": Array [ + "b", + ], + }, + }, + ], + "properties": Object { + "a": Object { + "default": undefined, + "description": "a", + "format": undefined, + "title": "a", + "type": "string", + }, + "b": Object { + "default": undefined, + "description": "b", + "format": undefined, + "title": "b", + "type": "string", + }, + }, + "required": Array [], + "type": "object", +} +`; diff --git a/packages/core/src/__tests__/schema-validation.test.ts b/packages/core/src/__tests__/schema-validation.test.ts index ccd12b342a..0aa56eb63f 100644 --- a/packages/core/src/__tests__/schema-validation.test.ts +++ b/packages/core/src/__tests__/schema-validation.test.ts @@ -1,5 +1,6 @@ import { validateSchema } from '../schema-validation' import { fieldsToJsonSchema } from '../destination-kit/fields-to-jsonschema' +import { InputField } from '../destination-kit/types' const schema = fieldsToJsonSchema({ a: { @@ -61,6 +62,1233 @@ const schema = fieldsToJsonSchema({ } }) +// Note: For easier debugging of these test cases you can switch `throwIfInvalid` to `true` to see the AJV error message +describe('conditionally required fields', () => { + let mockActionFields: Record = {} + + beforeEach(() => { + mockActionFields = {} + }) + + describe('should validate a single conditional requirement', () => { + it('should validate b when it is required and when it is not required', async () => { + mockActionFields['a'] = { + label: 'a', + type: 'string', + required: true, + description: 'a' + } + + mockActionFields['b'] = { + label: 'b', + type: 'string', + description: 'b', + required: { + conditions: [{ fieldKey: 'a', operator: 'is', value: 'a_value' }] + } + } + + const schema = fieldsToJsonSchema(mockActionFields) + expect(schema).toMatchSnapshot() + const b_required_mappings = [{ a: 'a_value' }, { a: 'a_value', b: 'b_value' }] + const b_not_required_mapping = [{ a: 'not value' }, { a: 'not value', b: 'b_value' }] + + let isValid + isValid = validateSchema(b_required_mappings[0], schema, { throwIfInvalid: false }) + expect(isValid).toBe(false) + + isValid = validateSchema(b_required_mappings[1], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(b_not_required_mapping[0], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(b_not_required_mapping[1], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + }) + }) + + describe('should validate multiple conditional requirements on different fields', () => { + it('should validate when both b and c are required', async () => { + mockActionFields['a'] = { + label: 'a', + type: 'string', + required: true, + description: 'a' + } + + mockActionFields['b'] = { + label: 'b', + type: 'string', + description: 'b', + required: { + conditions: [{ fieldKey: 'a', operator: 'is', value: 'a_value' }] + } + } + + mockActionFields['c'] = { + label: 'c', + type: 'string', + description: 'c', + required: { + // if a is not 'value', c is required + conditions: [{ fieldKey: 'a', operator: 'is_not', value: 'value' }] + } + } + + const both_required_mappings = [ + { a: 'a_value' }, + { a: 'a_value', b: 'b_value' }, + { a: 'a_value', c: 'c_value' }, + { a: 'a_value', b: 'b_value', c: 'c_value' } + ] + + const schema = fieldsToJsonSchema(mockActionFields) + expect(schema).toMatchSnapshot() + + let isValid + isValid = validateSchema(both_required_mappings[0], schema, { throwIfInvalid: false }) + expect(isValid).toBe(false) + + isValid = validateSchema(both_required_mappings[1], schema, { throwIfInvalid: false }) + expect(isValid).toBe(false) + + isValid = validateSchema(both_required_mappings[2], schema, { throwIfInvalid: false }) + expect(isValid).toBe(false) + + isValid = validateSchema(both_required_mappings[3], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + }) + + it('should validate when only b is required', async () => { + mockActionFields['a'] = { + label: 'a', + type: 'string', + required: true, + description: 'a' + } + + mockActionFields['b'] = { + label: 'b', + type: 'string', + description: 'b', + required: { + conditions: [{ fieldKey: 'a', operator: 'is', value: 'a_value' }] + } + } + + mockActionFields['c'] = { + label: 'c', + type: 'string', + description: 'c', + required: { + conditions: [{ fieldKey: 'a', operator: 'is', value: 'value' }] + } + } + + const b_required_mappings = [{ a: 'a_value' }, { a: 'a_value', b: 'b_value' }] + + const schema = fieldsToJsonSchema(mockActionFields) + expect(schema).toMatchSnapshot() + + let isValid + isValid = validateSchema(b_required_mappings[0], schema, { throwIfInvalid: false }) + expect(isValid).toBe(false) + + isValid = validateSchema(b_required_mappings[1], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + }) + + it('should validate when only c is required', async () => { + mockActionFields['a'] = { + label: 'a', + type: 'string', + required: true, + description: 'a' + } + + mockActionFields['b'] = { + label: 'b', + type: 'string', + description: 'b', + required: { + conditions: [{ fieldKey: 'a', operator: 'is', value: 'a_value' }] + } + } + + mockActionFields['c'] = { + label: 'c', + type: 'string', + description: 'c', + required: { + conditions: [{ fieldKey: 'a', operator: 'is', value: 'value' }] + } + } + + const c_required_mappings = [{ a: 'value' }, { a: 'value', c: 'c_value' }] + + const schema = fieldsToJsonSchema(mockActionFields) + expect(schema).toMatchSnapshot() + + let isValid + isValid = validateSchema(c_required_mappings[0], schema, { throwIfInvalid: false }) + expect(isValid).toBe(false) + + isValid = validateSchema(c_required_mappings[1], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + }) + + it('should validate when neither b nor c are required', async () => { + mockActionFields['a'] = { + label: 'a', + type: 'string', + required: true, + description: 'a' + } + + mockActionFields['b'] = { + label: 'b', + type: 'string', + description: 'b', + required: { + conditions: [{ fieldKey: 'a', operator: 'is', value: 'a_value' }] + } + } + + mockActionFields['c'] = { + label: 'c', + type: 'string', + description: 'c', + required: { + conditions: [{ fieldKey: 'a', operator: 'is', value: 'value' }] + } + } + + const neither_required_mappings = [ + { a: 'not value' }, + { a: 'not value', b: 'b_value' }, + { a: 'not value', c: 'c_value' } + ] + const schema = fieldsToJsonSchema(mockActionFields) + expect(schema).toMatchSnapshot() + + let isValid + isValid = validateSchema(neither_required_mappings[0], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(neither_required_mappings[1], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(neither_required_mappings[2], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + }) + + it('should validate when two fields depend on each other', async () => { + mockActionFields['a'] = { + label: 'a', + type: 'string', + required: { + conditions: [{ fieldKey: 'b', operator: 'is', value: undefined }] + }, + description: 'a' + } + + mockActionFields['b'] = { + label: 'b', + type: 'string', + description: 'b', + required: { + conditions: [{ fieldKey: 'a', operator: 'is', value: undefined }] + } + } + + const b_required_mappings = [{ a: 'a_value' }, { a: 'a_value', b: 'b_value' }] + const a_required_mappings = [{ b: 'b_value' }, { a: 'a_value', b: 'b_value' }] + const both_required = {} + + const schema = fieldsToJsonSchema(mockActionFields) + expect(schema).toMatchSnapshot() + + let isValid + isValid = validateSchema(b_required_mappings[0], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(b_required_mappings[1], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(a_required_mappings[0], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(a_required_mappings[1], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(both_required, schema, { throwIfInvalid: false }) + expect(isValid).toBe(false) + }) + }) + + describe('should handle multiple conditions on the same field', () => { + it('should handle when one field has multiple values on another for which it is required, any matcher', async () => { + mockActionFields['a'] = { + label: 'a', + type: 'string', + required: true, + description: 'a' + } + + mockActionFields['b'] = { + label: 'b', + type: 'string', + description: 'b', + required: { + match: 'any', + conditions: [ + { fieldKey: 'a', operator: 'is', value: 'a_value' }, + { fieldKey: 'a', operator: 'is', value: 'a_value2' } + ] + } + } + + const schema = fieldsToJsonSchema(mockActionFields) + expect(schema).toMatchSnapshot() + + const b_required_mappings = [ + { a: 'a_value' }, + { a: 'a_value', b: 'b_value' }, + { a: 'a_value2' }, + { a: 'a_value2', b: 'b_value' } + ] + + const b_not_required_mapping = { a: 'b is not required' } + + let isValid + isValid = validateSchema(b_required_mappings[0], schema, { throwIfInvalid: false }) + expect(isValid).toBe(false) + + isValid = validateSchema(b_required_mappings[1], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(b_required_mappings[2], schema, { throwIfInvalid: false }) + expect(isValid).toBe(false) + + isValid = validateSchema(b_required_mappings[3], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(b_not_required_mapping, schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + }) + + it('should handle when one field has multiple conditions for multiple other fields', async () => { + mockActionFields['a'] = { + label: 'a', + type: 'string', + required: true, + description: 'a' + } + + mockActionFields['b'] = { + label: 'b', + type: 'string', + description: 'b', + required: { + // infered match: 'all' + conditions: [ + { fieldKey: 'a', operator: 'is', value: 'a_value' }, + { fieldKey: 'c', operator: 'is', value: 'c_value' }, + { fieldKey: 'd', operator: 'is', value: 'd_value' } + ] + } + } + + mockActionFields['c'] = { + label: 'c', + type: 'string', + description: 'c' + } + + mockActionFields['d'] = { + label: 'd', + type: 'string', + description: 'd' + } + + const schema = fieldsToJsonSchema(mockActionFields) + expect(schema).toMatchSnapshot() + + const b_required_mappings = [ + { a: 'a_value', c: 'c_value', d: 'd_value' }, + { a: 'a_value', c: 'c_value', d: 'd_value', b: 'b_value' } + ] + + const b_not_required_mappings = [{ a: 'a_value', d: 'd_value' }, { a: 'a' }, { a: 'a', b: 'b' }] + + let isValid + isValid = validateSchema(b_required_mappings[0], schema, { throwIfInvalid: false }) + expect(isValid).toBe(false) + + isValid = validateSchema(b_required_mappings[1], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(b_not_required_mappings[0], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(b_not_required_mappings[1], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(b_not_required_mappings[2], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + }) + + it('should handle when one field has multiple conditions for multiple other fields with an any matcher', async () => { + mockActionFields['a'] = { + label: 'a', + type: 'string', + description: 'a' + } + + mockActionFields['b'] = { + label: 'b', + type: 'string', + description: 'b', + required: { + match: 'any', + conditions: [ + { fieldKey: 'a', operator: 'is', value: 'a_value' }, + { fieldKey: 'c', operator: 'is', value: 'c_value' }, + { fieldKey: 'd', operator: 'is', value: 'd_value' } + ] + } + } + + mockActionFields['c'] = { + label: 'c', + type: 'string', + description: 'c' + } + + mockActionFields['d'] = { + label: 'd', + type: 'string', + description: 'd' + } + + mockActionFields['e'] = { + label: 'e', + type: 'string', + description: 'e' + } + + const schema = fieldsToJsonSchema(mockActionFields) + expect(schema).toMatchSnapshot() + + const b_required_mappings = [ + { a: 'a_value', c: 'c_value', d: 'd_value' }, + { a: 'a_value', c: 'c_value', d: 'd_value', b: 'b_value' }, + { c: 'c_value' }, + { c: 'c_value', b: 'b_value' } + ] + + const b_not_required_mapping = { e: 'e_value' } + + let isValid + isValid = validateSchema(b_required_mappings[0], schema, { throwIfInvalid: false }) + expect(isValid).toBe(false) + + isValid = validateSchema(b_required_mappings[1], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(b_required_mappings[2], schema, { throwIfInvalid: false }) + expect(isValid).toBe(false) + + isValid = validateSchema(b_required_mappings[3], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(b_not_required_mapping, schema, { throwIfInvalid: true }) + expect(isValid).toBe(true) + }) + }) + + describe('should handle object conditions', () => { + it('should validate a single object condition', async () => { + mockActionFields['a'] = { + type: 'object', + label: 'a', + description: 'a', + properties: { + b: { + type: 'string', + label: 'b' + } + } + } + + mockActionFields['c'] = { + type: 'string', + label: 'c', + description: 'c', + required: { + conditions: [{ fieldKey: 'a.b', operator: 'is', value: 'b_value' }] + } + } + + const schema = fieldsToJsonSchema(mockActionFields) + expect(schema).toMatchSnapshot() + + const c_required_mappings = [{ a: { b: 'b_value' } }, { a: { b: 'b_value' }, c: 'c_value' }] + + const c_not_required_mappings = [{ a: { b: 'not b_value' } }, {}] + + let isValid + isValid = validateSchema(c_required_mappings[0], schema, { throwIfInvalid: false }) + expect(isValid).toBe(false) + + isValid = validateSchema(c_required_mappings[1], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(c_not_required_mappings[0], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(c_not_required_mappings[1], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + }) + + it('should validate a single object condition with an is_not operator', async () => { + mockActionFields['a'] = { + type: 'object', + label: 'a', + description: 'a', + properties: { + b: { + type: 'string', + label: 'b' + } + } + } + + mockActionFields['c'] = { + type: 'string', + label: 'c', + description: 'c', + required: { + conditions: [{ fieldKey: 'a.b', operator: 'is_not', value: 'b_value' }] + } + } + + const schema = fieldsToJsonSchema(mockActionFields) + expect(schema).toMatchSnapshot() + + const c_required_mappings = [{ a: { b: 'not b_value' } }, { a: { b: 'not b_value' }, c: 'c_value' }] + + const c_not_required_mappings = [{ a: { b: 'b_value' }, c: 'c_value' }, { a: { b: 'b_value' } }] + + let isValid + isValid = validateSchema(c_required_mappings[0], schema, { throwIfInvalid: false }) + expect(isValid).toBe(false) + + isValid = validateSchema(c_required_mappings[1], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(c_not_required_mappings[0], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(c_not_required_mappings[1], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + }) + + it('should validate multiple object conditions', async () => { + mockActionFields['a'] = { + type: 'object', + label: 'a', + description: 'a', + properties: { + b: { + type: 'string', + label: 'b' + }, + c: { + type: 'string', + label: 'c' + } + } + } + + mockActionFields['d'] = { + type: 'string', + label: 'd', + description: 'd', + required: { + // infered match: 'all' + conditions: [ + { fieldKey: 'a.b', operator: 'is', value: 'b_value' }, + { fieldKey: 'a.c', operator: 'is', value: 'c_value' } + ] + } + } + + const schema = fieldsToJsonSchema(mockActionFields) + expect(schema).toMatchSnapshot() + + const d_required_mappings = [ + { a: { b: 'b_value', c: 'c_value' } }, + { a: { b: 'b_value', c: 'c_value' }, d: 'd_value' } + ] + + const d_not_required_mappings = [ + { a: { b: 'b_value', c: 'not c_value' } }, + { a: { b: 'not b_value', c: 'c_value' } }, + { a: { b: 'not b_value', c: 'not c_value' } }, + { a: { b: 'b_value' }, d: 'd_value' } + ] + + let isValid + isValid = validateSchema(d_required_mappings[0], schema, { throwIfInvalid: false }) + expect(isValid).toBe(false) + + isValid = validateSchema(d_required_mappings[1], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(d_not_required_mappings[0], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(d_not_required_mappings[1], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(d_not_required_mappings[2], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(d_not_required_mappings[3], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + }) + + it('should validate multiple object conditions with an any matcher', async () => { + mockActionFields['a'] = { + type: 'object', + label: 'a', + description: 'a', + properties: { + b: { + type: 'string', + label: 'b' + }, + c: { + type: 'string', + label: 'c' + } + } + } + + mockActionFields['d'] = { + type: 'string', + label: 'd', + description: 'd', + required: { + match: 'any', + conditions: [ + { fieldKey: 'a.b', operator: 'is', value: 'b_value' }, + { fieldKey: 'a.c', operator: 'is', value: 'c_value' } + ] + } + } + + mockActionFields['e'] = { + type: 'string', + label: 'e', + description: 'e' + } + + const schema = fieldsToJsonSchema(mockActionFields) + expect(schema).toMatchSnapshot() + + const d_required_mappings = [ + { a: { b: 'b_value', c: 'c_value' } }, + { a: { b: 'b_value', c: 'c_value' }, d: 'd_value' }, + { a: { b: 'b_value' }, d: 'd_value' }, + { a: { c: 'c_value' }, d: 'd_value' } + ] + + const d_not_required_mappings = [{ a: {} }, { e: 'e_value' }] + + let isValid + isValid = validateSchema(d_required_mappings[0], schema, { throwIfInvalid: false }) + expect(isValid).toBe(false) + + isValid = validateSchema(d_required_mappings[1], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(d_required_mappings[2], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(d_required_mappings[3], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(d_not_required_mappings[0], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(d_not_required_mappings[1], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + }) + + it('should validate multiple object conditions where an object and a field are referenced', async () => { + mockActionFields['a'] = { + type: 'object', + label: 'a', + description: 'a', + properties: { + b: { + type: 'string', + label: 'b' + }, + c: { + type: 'string', + label: 'c' + } + } + } + + mockActionFields['d'] = { + type: 'string', + label: 'd', + description: 'd' + } + + mockActionFields['e'] = { + type: 'string', + label: 'e', + description: 'e', + required: { + conditions: [ + { fieldKey: 'a.b', operator: 'is', value: 'b_value' }, + { fieldKey: 'd', operator: 'is', value: 'd_value' } + ] + } + } + + const schema = fieldsToJsonSchema(mockActionFields) + expect(schema).toMatchSnapshot() + + const e_required_mappings = [ + { a: { b: 'b_value' }, d: 'd_value' }, + { a: { b: 'b_value' }, d: 'd_value', e: 'e_value' } + ] + + const e_not_required_mappings = [ + { a: { b: 'b_value' }, c: 'not c_value' }, + { a: { b: 'not b_value' }, d: 'd_value' }, + { a: { b: 'not b_value' }, c: 'not c_value' }, + { a: { b: 'b_value' }, d: 'not_d_value' } + ] + + let isValid + isValid = validateSchema(e_required_mappings[0], schema, { throwIfInvalid: false }) + expect(isValid).toBe(false) + + isValid = validateSchema(e_required_mappings[1], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(e_not_required_mappings[0], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(e_not_required_mappings[1], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(e_not_required_mappings[2], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(e_not_required_mappings[3], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + }) + + it('should validate multiple object conditions where multiple objects are referenced', async () => { + mockActionFields['a'] = { + type: 'object', + label: 'a', + description: 'a', + properties: { + b: { + type: 'string', + label: 'b' + } + } + } + + mockActionFields['c'] = { + type: 'object', + label: 'c', + description: 'c', + properties: { + d: { + type: 'string', + label: 'd' + } + } + } + + mockActionFields['e'] = { + type: 'string', + label: 'e', + description: 'e', + required: { + // e is required when a.b is 'b_value' and c.d is 'd_value' + conditions: [ + { fieldKey: 'a.b', operator: 'is', value: 'b_value' }, + { fieldKey: 'c.d', operator: 'is', value: 'd_value' } + ] + } + } + + const schema = fieldsToJsonSchema(mockActionFields) + expect(schema).toMatchSnapshot() + + const e_required_mappings = [ + { a: { b: 'b_value' }, c: { d: 'd_value' } }, + { a: { b: 'b_value' }, c: { d: 'd_value' }, e: 'e_value' } + ] + + const e_not_required_mappings = [ + { a: { b: 'b_value' }, c: { d: 'not d_value' } }, + { a: { b: 'not b_value' }, c: { d: 'd_value' } }, + { a: { b: 'not b_value' }, c: { d: 'not d_value' } }, + { a: { b: 'b_value' }, e: 'e_value' } + ] + + let isValid + isValid = validateSchema(e_required_mappings[0], schema, { throwIfInvalid: false }) + expect(isValid).toBe(false) + + isValid = validateSchema(e_required_mappings[1], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(e_not_required_mappings[0], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(e_not_required_mappings[1], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(e_not_required_mappings[2], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(e_not_required_mappings[3], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + }) + + it('should validate an inner conditionally required property on an object correctly', async () => { + mockActionFields['a'] = { + type: 'string', + label: 'a', + description: 'a' + } + + mockActionFields['b'] = { + type: 'object', + label: 'b', + description: 'b', + properties: { + c: { + type: 'string', + label: 'c', + description: 'c', + required: { + conditions: [{ fieldKey: 'a', operator: 'is', value: 'a_value' }] + } + } + } + } + + const schema = fieldsToJsonSchema(mockActionFields) + expect(schema).toMatchSnapshot() + + const c_required_mappings = [{ a: 'a_value' }, { a: 'a_value', b: { c: 'c_value' } }] + const c_not_required_mappings = [{ a: 'not a_value' }, { a: 'not a_value', b: { c: 'c_value' } }] + + let isValid + isValid = validateSchema(c_required_mappings[0], schema, { throwIfInvalid: false }) + expect(isValid).toBe(false) + + isValid = validateSchema(c_required_mappings[1], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(c_not_required_mappings[0], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(c_not_required_mappings[1], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + }) + + it('should validate when referencing a child field which is not explicitly defined in properties', async () => { + mockActionFields['a'] = { + type: 'object', + label: 'a', + description: 'a', + additionalProperties: true + } + + mockActionFields['c'] = { + type: 'string', + label: 'c', + description: 'c', + required: { + conditions: [{ fieldKey: 'a.b', operator: 'is', value: 'b_value' }] + } + } + + const schema = fieldsToJsonSchema(mockActionFields) + expect(schema).toMatchSnapshot() + + const c_required_mappings = [{ a: { b: 'b_value' } }, { a: { b: 'b_value' }, c: 'c_value' }] + + const c_not_required_mappings = [{ a: { b: 'not b_value' } }, { a: { z: 'z_value' } }] + + let isValid + isValid = validateSchema(c_required_mappings[0], schema, { throwIfInvalid: false }) + expect(isValid).toBe(false) + + isValid = validateSchema(c_required_mappings[1], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(c_not_required_mappings[0], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(c_not_required_mappings[1], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + }) + }) + + describe('should handle different data types', () => { + it('should validate number fields', async () => { + mockActionFields['a'] = { + type: 'number', + label: 'a', + description: 'a' + } + + mockActionFields['b'] = { + type: 'string', + label: 'b', + description: 'b', + required: { + conditions: [{ fieldKey: 'a', operator: 'is', value: 10 }] + } + } + + const schema = fieldsToJsonSchema(mockActionFields) + expect(schema).toMatchSnapshot() + + const b_required_mappings = [{ a: 10 }, { a: 10, b: 'b_value' }] + + const b_not_required_mappings = [{ a: 9 }, { a: 9, b: 'b_value' }] + + let isValid + isValid = validateSchema(b_required_mappings[0], schema, { throwIfInvalid: false }) + expect(isValid).toBe(false) + + isValid = validateSchema(b_required_mappings[1], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(b_not_required_mappings[0], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(b_not_required_mappings[1], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + }) + + it('should validate boolean fields', async () => { + mockActionFields['a'] = { + type: 'boolean', + label: 'a', + description: 'a' + } + + mockActionFields['b'] = { + type: 'string', + label: 'b', + description: 'b', + required: { + conditions: [{ fieldKey: 'a', operator: 'is', value: true }] + } + } + + const schema = fieldsToJsonSchema(mockActionFields) + expect(schema).toMatchSnapshot() + + const b_required_mappings = [{ a: true }, { a: true, b: 'b_value' }] + + const b_not_required_mappings = [{ a: false }, { a: false, b: 'b_value' }] + + let isValid + isValid = validateSchema(b_required_mappings[0], schema, { throwIfInvalid: false }) + expect(isValid).toBe(false) + + isValid = validateSchema(b_required_mappings[1], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(b_not_required_mappings[0], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(b_not_required_mappings[1], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + }) + + it('should validate when the value of a condition is undefined, is_not operator', async () => { + mockActionFields['a'] = { + type: 'string', + label: 'a', + description: 'a' + } + + mockActionFields['b'] = { + type: 'string', + label: 'b', + description: 'b', + required: { + // if a is not undefined, b is required + conditions: [{ fieldKey: 'a', operator: 'is_not', value: undefined }] + } + } + + const schema = fieldsToJsonSchema(mockActionFields) + expect(schema).toMatchSnapshot() + + const b_required_mappings = [{ a: 'a_value' }, { a: 'a_value', b: 'b_value' }] + + const b_not_required_mappings = [{}, { b: 'b_value' }, { a: undefined }] + + let isValid + isValid = validateSchema(b_required_mappings[0], schema, { throwIfInvalid: false }) + expect(isValid).toBe(false) + + isValid = validateSchema(b_required_mappings[1], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(b_not_required_mappings[0], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(b_not_required_mappings[1], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(b_not_required_mappings[2], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + }) + + it('should validate when the value of a condition is undefined, is operator', async () => { + mockActionFields['a'] = { + type: 'string', + label: 'a', + description: 'a' + } + + mockActionFields['b'] = { + type: 'string', + label: 'b', + description: 'b', + required: { + // if a is undefined, b is required + conditions: [{ fieldKey: 'a', operator: 'is', value: undefined }] + } + } + + const schema = fieldsToJsonSchema(mockActionFields) + expect(schema).toMatchSnapshot() + + const b_required_mappings = [{ a: undefined }, { a: undefined, b: 'b_value' }, { c: 'c_value' }, { b: 'b_value' }] + const b_not_required_mappings = [{ a: 'a_value' }, { a: 'a_value', b: 'b_value' }] + + let isValid + isValid = validateSchema(b_required_mappings[0], schema, { throwIfInvalid: false }) + expect(isValid).toBe(false) + + isValid = validateSchema(b_required_mappings[1], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(b_required_mappings[2], schema, { throwIfInvalid: false }) + expect(isValid).toBe(false) + + isValid = validateSchema(b_required_mappings[3], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(b_not_required_mappings[0], schema, { throwIfInvalid: true }) + expect(isValid).toBe(true) + + isValid = validateSchema(b_not_required_mappings[1], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + }) + + it('should validate when multiple values of a condition are undefined', async () => { + mockActionFields['a'] = { + type: 'string', + label: 'a', + description: 'a' + } + + mockActionFields['b'] = { + type: 'string', + label: 'b', + description: 'b' + } + + mockActionFields['c'] = { + type: 'string', + label: 'c', + description: 'c', + required: { + conditions: [ + // if a and b are undefined, c is required + { fieldKey: 'a', operator: 'is', value: undefined }, + { fieldKey: 'b', operator: 'is', value: undefined } + ] + } + } + + const schema = fieldsToJsonSchema(mockActionFields) + expect(schema).toMatchSnapshot() + + const b_required_mappings = [ + { a: undefined }, + { a: undefined, b: undefined }, + { a: undefined, c: 'c_value' }, + { a: undefined, b: undefined, c: 'c_value' } + ] + const b_not_required_mappings = [{ a: 'a_value' }, { a: 'a_value', b: 'b_value' }, { a: 'a_value', c: 'c_value' }] + + let isValid + isValid = validateSchema(b_required_mappings[0], schema, { throwIfInvalid: false }) + expect(isValid).toBe(false) + + isValid = validateSchema(b_required_mappings[1], schema, { throwIfInvalid: false }) + expect(isValid).toBe(false) + + isValid = validateSchema(b_required_mappings[2], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(b_required_mappings[3], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(b_not_required_mappings[0], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(b_not_required_mappings[1], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(b_not_required_mappings[2], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + }) + + it('should validate when multiple values of a condition are undefined, any matcher', async () => { + mockActionFields['a'] = { + type: 'string', + label: 'a', + description: 'a' + } + + mockActionFields['b'] = { + type: 'string', + label: 'b', + description: 'b' + } + + mockActionFields['c'] = { + type: 'string', + label: 'c', + description: 'c', + required: { + match: 'any', + conditions: [ + // if a or b are undefined, c is required + { fieldKey: 'a', operator: 'is', value: undefined }, + { fieldKey: 'b', operator: 'is', value: undefined } + ] + } + } + + const schema = fieldsToJsonSchema(mockActionFields) + expect(schema).toMatchSnapshot() + + const b_required_mappings = [ + { a: undefined }, + { a: undefined, b: undefined }, + { a: 'a_value' }, + { a: undefined, c: 'c_value' }, + { a: undefined, b: undefined, c: 'c_value' }, + { b: undefined, c: 'c_value' } + ] + const b_not_required_mappings = [ + { a: 'a_value', b: 'b_value' }, + { a: 'a_value', b: 'b_value', c: 'c_value' } + ] + + let isValid + isValid = validateSchema(b_required_mappings[0], schema, { throwIfInvalid: false }) + expect(isValid).toBe(false) + + isValid = validateSchema(b_required_mappings[1], schema, { throwIfInvalid: false }) + expect(isValid).toBe(false) + + isValid = validateSchema(b_required_mappings[2], schema, { throwIfInvalid: false }) + expect(isValid).toBe(false) + + isValid = validateSchema(b_required_mappings[3], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(b_required_mappings[4], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(b_required_mappings[5], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(b_not_required_mappings[0], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(b_not_required_mappings[1], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + }) + + it('should handle when allowNull is true and the field is null', async () => { + mockActionFields['a'] = { + type: 'string', + label: 'a', + description: 'a', + allowNull: true + } + + mockActionFields['b'] = { + type: 'string', + label: 'b', + description: 'b', + required: { + conditions: [{ fieldKey: 'a', operator: 'is', value: undefined }] + } + } + + const schema = fieldsToJsonSchema(mockActionFields) + expect(schema).toMatchSnapshot() + + const b_required_mappings = [{ a: null }, { a: null, b: 'b_value' }, {}, { b: 'b_value' }] + const b_not_required_mappings = [{ a: 'a_value' }, { a: 'a_value', b: 'b_value' }] + + let isValid + isValid = validateSchema(b_required_mappings[0], schema, { throwIfInvalid: false }) + expect(isValid).toBe(false) + + isValid = validateSchema(b_required_mappings[1], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(b_required_mappings[2], schema, { throwIfInvalid: false }) + expect(isValid).toBe(false) + + isValid = validateSchema(b_required_mappings[3], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(b_not_required_mappings[0], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + + isValid = validateSchema(b_not_required_mappings[1], schema, { throwIfInvalid: false }) + expect(isValid).toBe(true) + }) + }) + + // TODO: support sync mode conditions + describe.skip('should validate based on sync mode value', () => {}) +}) + describe('validateSchema', () => { it('should remove any keys that are not specified', () => { const payload = { @@ -126,9 +1354,6 @@ describe('validateSchema', () => { expect(isValid).toBe(true) }) - // For now we always remove unknown keys, until builders have a way to specify the behavior - it.todo('should not remove nested keys for valid properties') - it('should coerce properties for more flexible but type-safe inputs', () => { const payload = { a: 1234, diff --git a/packages/core/src/destination-kit/fields-to-jsonschema.ts b/packages/core/src/destination-kit/fields-to-jsonschema.ts index 2c54425431..572f63ca9c 100644 --- a/packages/core/src/destination-kit/fields-to-jsonschema.ts +++ b/packages/core/src/destination-kit/fields-to-jsonschema.ts @@ -1,5 +1,13 @@ import { JSONSchema4, JSONSchema4Type, JSONSchema4TypeName } from 'json-schema' -import type { InputField, GlobalSetting, FieldTypeName, Optional } from './types' +import type { + InputField, + GlobalSetting, + FieldTypeName, + Optional, + DependsOnConditions, + FieldCondition, + Condition +} from './types' function toJsonSchemaType(type: FieldTypeName): JSONSchema4TypeName | JSONSchema4TypeName[] { switch (type) { @@ -23,11 +31,261 @@ export type MinimalFields = Record interface SchemaOptions { tsType?: boolean additionalProperties?: boolean + omitRequiredSchemas?: boolean } -export function fieldsToJsonSchema(fields: MinimalFields = {}, options?: SchemaOptions): JSONSchema4 { +const fieldKeyIsDotNotation = (fieldKey: string): boolean => { + return fieldKey.split('.').length > 1 +} + +const generateThenStatement = (fieldKey: string): JSONSchema4 => { + if (fieldKeyIsDotNotation(fieldKey)) { + const [parentKey, childKey] = fieldKey.split('.') + + return { + required: [parentKey], + properties: { + [parentKey]: { + required: [childKey] + } + } + } + } + + return { + required: [fieldKey] + } +} + +const undefinedConditionValueToJSONSchema = ( + dependantFieldKey: string, + fieldKey: string, + operator: 'is' | 'is_not', + multiple?: boolean +): JSONSchema4 => { + if (operator !== 'is' && operator !== 'is_not') { + throw new Error(`Unsupported conditionally required field operator: ${operator}`) + } + + const insideIfStatement: JSONSchema4 = + operator === 'is' ? { not: { required: [dependantFieldKey] } } : { required: [dependantFieldKey] } + + if (multiple) { + return insideIfStatement + } + + if (operator === 'is') { + const fieldIsNull: JSONSchema4 = { properties: { [dependantFieldKey]: { type: 'null' } } } + return { + if: { anyOf: [insideIfStatement, fieldIsNull] }, + then: generateThenStatement(fieldKey) + } + } + + // operator === 'is_not' + const fieldIsNotNull: JSONSchema4 = { not: { properties: { [dependantFieldKey]: { type: 'null' } } } } + return { + if: { allOf: [insideIfStatement, fieldIsNotNull] }, + then: generateThenStatement(fieldKey) + } +} + +const simpleConditionToJSONSchema = ( + dependantFieldKey: string, + fieldKey: string, + dependantValue: string, + operator: 'is' | 'is_not', + multiple?: boolean +): JSONSchema4 => { + const dependantValueToJSONSchema: JSONSchema4 = + operator === 'is' ? { const: dependantValue } : { not: { const: dependantValue } } + + if (multiple) { + return { + properties: { [dependantFieldKey]: dependantValueToJSONSchema }, + required: [dependantFieldKey] + } + } + + return { + if: { + properties: { [dependantFieldKey]: dependantValueToJSONSchema } + }, + then: generateThenStatement(fieldKey) + } +} + +const objectConditionToJSONSchema = ( + objectParentKey: string, + objectChildKey: string, + fieldKey: string, + dependantValue: string, + operator: 'is' | 'is_not', + multiple?: boolean +): JSONSchema4 => { + const dependantValueToJSONSchema: JSONSchema4 = + operator === 'is' ? { const: dependantValue } : { not: { const: dependantValue } } + + if (multiple) { + return { + properties: { + [objectParentKey]: { properties: { [objectChildKey]: dependantValueToJSONSchema }, required: [objectChildKey] } + }, + required: [objectParentKey] + } + } + + return { + if: { + properties: { + [objectParentKey]: { properties: { [objectChildKey]: dependantValueToJSONSchema }, required: [objectChildKey] } + }, + required: [objectParentKey] + }, + then: generateThenStatement(fieldKey) + } +} + +export function fieldConditionSingleDependencyToJsonSchema(condition: Condition, fieldKey: string) { + let jsonCondition: JSONSchema4 | undefined = undefined + const innerCondition = condition + + // object handling + const dependentFieldKey = (innerCondition as FieldCondition).fieldKey + if (dependentFieldKey.split('.').length > 1) { + const [parentKey, childKey] = dependentFieldKey.split('.') + + if (innerCondition.operator === 'is') { + jsonCondition = objectConditionToJSONSchema(parentKey, childKey, fieldKey, innerCondition.value as string, 'is') + } else if (innerCondition.operator === 'is_not') { + jsonCondition = objectConditionToJSONSchema( + parentKey, + childKey, + fieldKey, + innerCondition.value as string, + 'is_not' + ) + } else { + throw new Error(`Unsupported conditionally required field operator: ${innerCondition.operator}`) + } + return jsonCondition + } + + if (innerCondition.operator === 'is') { + if (innerCondition.value === undefined) { + return undefinedConditionValueToJSONSchema(innerCondition.fieldKey, fieldKey, 'is') + } + + jsonCondition = simpleConditionToJSONSchema( + (innerCondition as FieldCondition).fieldKey, + fieldKey, + innerCondition.value as string, + 'is' + ) + } else if (innerCondition.operator === 'is_not') { + if (innerCondition.value === undefined) { + return undefinedConditionValueToJSONSchema(innerCondition.fieldKey, fieldKey, 'is_not') + } + + jsonCondition = simpleConditionToJSONSchema( + (innerCondition as FieldCondition).fieldKey, + fieldKey, + innerCondition.value as string, + 'is_not' + ) + } else { + throw new Error(`Unsupported conditionally required field operator: ${innerCondition.operator}`) + } + + return jsonCondition +} + +export function singleFieldConditionsToJsonSchema( + fieldKey: string, + singleFieldConditions: DependsOnConditions +): JSONSchema4 | undefined { + let jsonCondition: JSONSchema4 | undefined = undefined + + if (singleFieldConditions.conditions.length === 1) { + return fieldConditionSingleDependencyToJsonSchema(singleFieldConditions.conditions[0], fieldKey) + } + + const innerConditionArray: JSONSchema4[] = [] + singleFieldConditions.conditions.forEach((innerCondition) => { + const dependentFieldKey = (innerCondition as FieldCondition).fieldKey + if (dependentFieldKey.split('.').length > 1) { + const [parentKey, childKey] = dependentFieldKey.split('.') + + const conditionToJSON = objectConditionToJSONSchema( + parentKey, + childKey, + fieldKey, + innerCondition.value as string, + innerCondition.operator, + true + ) + innerConditionArray.push(conditionToJSON) + + const innerIfStatement: JSONSchema4 = + singleFieldConditions.match === 'any' ? { anyOf: innerConditionArray } : { allOf: innerConditionArray } + jsonCondition = { if: innerIfStatement, then: generateThenStatement(fieldKey) } + + return jsonCondition + } + + if (innerCondition.value === undefined) { + innerConditionArray.push( + undefinedConditionValueToJSONSchema(innerCondition.fieldKey, fieldKey, innerCondition.operator, true) + ) + } else { + const conditionToJSON = simpleConditionToJSONSchema( + dependentFieldKey, + fieldKey, + innerCondition.value as string, + innerCondition.operator, + true + ) + innerConditionArray.push(conditionToJSON) + } + }) + + const innerIfStatement: JSONSchema4 = + singleFieldConditions.match === 'any' ? { anyOf: innerConditionArray } : { allOf: innerConditionArray } + jsonCondition = { if: innerIfStatement, then: { required: [fieldKey] } } + + return jsonCondition +} + +export function conditionsToJsonSchema(allFieldConditions: Record): JSONSchema4 { + const additionalSchema: JSONSchema4[] = [] + + for (const [fieldKey, singleFieldCondition] of Object.entries(allFieldConditions)) { + const jsonCondition = singleFieldConditionsToJsonSchema(fieldKey, singleFieldCondition) + + if (jsonCondition === undefined) { + throw new Error(`Unsupported conditionally required field condition: ${singleFieldCondition}`) + } + + if (jsonCondition) { + additionalSchema.push(jsonCondition) + } + } + + if (additionalSchema.length === 0) { + return {} + } + + return { allOf: additionalSchema } +} + +export function fieldsToJsonSchema( + fields: MinimalFields = {}, + options?: SchemaOptions, + additionalSchema?: JSONSchema4 +): JSONSchema4 { const required: string[] = [] const properties: Record = {} + const conditions: Record = {} for (const [key, field] of Object.entries(fields)) { const schemaType = toJsonSchemaType(field.type) @@ -96,14 +354,30 @@ export function fieldsToJsonSchema(fields: MinimalFields = {}, options?: SchemaO } if (schemaType === 'object' && field.properties) { + const propertiesContainsConditionallyRequired = Object.values(field.properties).some( + (field) => field.required && typeof field.required === 'object' + ) if (isMulti) { schema.items = fieldsToJsonSchema(field.properties, { - additionalProperties: field?.additionalProperties || false + additionalProperties: field?.additionalProperties || false, + omitRequiredSchemas: propertiesContainsConditionallyRequired }) } else { schema = { ...schema, - ...fieldsToJsonSchema(field.properties, { additionalProperties: field?.additionalProperties || false }) + ...fieldsToJsonSchema(field.properties, { + additionalProperties: field?.additionalProperties || false, + omitRequiredSchemas: propertiesContainsConditionallyRequired + }) + } + } + + for (const [propertyKey, objectField] of Object.entries(field.properties)) { + if (objectField.required === true) { + continue + } else if (objectField.required && typeof objectField.required === 'object') { + const dotNotationKey = `${key}.${propertyKey}` + conditions[dotNotationKey] = objectField.required } } } @@ -111,8 +385,31 @@ export function fieldsToJsonSchema(fields: MinimalFields = {}, options?: SchemaO properties[key] = schema // Grab all the field keys with `required: true` - if (field.required) { + if (field.required === true) { required.push(key) + } else if (field.required && typeof field.required === 'object') { + conditions[key] = field.required + } + } + + // When generating types for the generated-types.ts file conditions are ignored + if (options?.tsType === true) { + return { + $schema: 'http://json-schema.org/schema#', + type: 'object', + additionalProperties: options?.additionalProperties || false, + properties, + required + } + } + + if (options?.omitRequiredSchemas === true) { + return { + $schema: 'http://json-schema.org/schema#', + type: 'object', + additionalProperties: options?.additionalProperties || false, + properties, + ...additionalSchema } } @@ -121,6 +418,8 @@ export function fieldsToJsonSchema(fields: MinimalFields = {}, options?: SchemaO type: 'object', additionalProperties: options?.additionalProperties || false, properties, - required + required, + ...conditionsToJsonSchema(conditions), + ...additionalSchema } } diff --git a/packages/core/src/destination-kit/types.ts b/packages/core/src/destination-kit/types.ts index 7dd4df3d76..dc84cc9169 100644 --- a/packages/core/src/destination-kit/types.ts +++ b/packages/core/src/destination-kit/types.ts @@ -166,8 +166,13 @@ export interface InputFieldJSONSchema { /** A human-friendly label for the option */ label: string }> - /** Whether or not the field is required */ - required?: boolean + /** + * Whether or not the field is required. If set to true the field must always be included. + * If a DependsOnConditions object is defined then the field will be required based on the conditions defined. + * This validation is done both when an event payload is sent through the perform block and when a user configures + * a mapping in the UI. + * */ + required?: boolean | DependsOnConditions /** * Optional definition for the properties of `type: 'object'` fields * (also arrays of objects when using `multiple: true`) diff --git a/packages/destination-actions/src/destinations/salesforce/lead/index.ts b/packages/destination-actions/src/destinations/salesforce/lead/index.ts index d7dede2ad6..411a22b5a4 100644 --- a/packages/destination-actions/src/destinations/salesforce/lead/index.ts +++ b/packages/destination-actions/src/destinations/salesforce/lead/index.ts @@ -11,7 +11,8 @@ import { enable_batching, recordMatcherOperator, batch_size, - hideIfDeleteOperation + hideIfDeleteOperation, + requiredIfCreateOperation } from '../sf-properties' import Salesforce, { generateSalesforceRequest } from '../sf-operations' @@ -53,6 +54,7 @@ const action: ActionDefinition = { else: { '@path': '$.properties.last_name' } } }, + required: requiredIfCreateOperation, depends_on: hideIfDeleteOperation }, first_name: { diff --git a/packages/destination-actions/src/destinations/salesforce/sf-properties.ts b/packages/destination-actions/src/destinations/salesforce/sf-properties.ts index 73aa33e752..5f7e5a1036 100644 --- a/packages/destination-actions/src/destinations/salesforce/sf-properties.ts +++ b/packages/destination-actions/src/destinations/salesforce/sf-properties.ts @@ -11,6 +11,16 @@ export const hideIfDeleteOperation: DependsOnConditions = { ] } +export const requiredIfCreateOperation: DependsOnConditions = { + conditions: [ + { + fieldKey: 'operation', + operator: 'is', + value: 'create' + } + ] +} + export const operation: InputField = { label: 'Operation', description: