workato-platform-cli 1.0.0rc5.dev5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- workato_platform_cli/__init__.py +135 -0
- workato_platform_cli/_version.py +34 -0
- workato_platform_cli/cli/__init__.py +126 -0
- workato_platform_cli/cli/commands/__init__.py +0 -0
- workato_platform_cli/cli/commands/api_clients.py +627 -0
- workato_platform_cli/cli/commands/api_collections.py +497 -0
- workato_platform_cli/cli/commands/assets.py +82 -0
- workato_platform_cli/cli/commands/connections.py +1205 -0
- workato_platform_cli/cli/commands/connectors/__init__.py +0 -0
- workato_platform_cli/cli/commands/connectors/command.py +178 -0
- workato_platform_cli/cli/commands/connectors/connector_manager.py +351 -0
- workato_platform_cli/cli/commands/data_tables.py +345 -0
- workato_platform_cli/cli/commands/guide.py +315 -0
- workato_platform_cli/cli/commands/init.py +229 -0
- workato_platform_cli/cli/commands/profiles.py +364 -0
- workato_platform_cli/cli/commands/projects/__init__.py +0 -0
- workato_platform_cli/cli/commands/projects/command.py +513 -0
- workato_platform_cli/cli/commands/projects/project_manager.py +338 -0
- workato_platform_cli/cli/commands/properties.py +174 -0
- workato_platform_cli/cli/commands/pull.py +327 -0
- workato_platform_cli/cli/commands/push/__init__.py +0 -0
- workato_platform_cli/cli/commands/push/command.py +320 -0
- workato_platform_cli/cli/commands/recipes/__init__.py +0 -0
- workato_platform_cli/cli/commands/recipes/command.py +847 -0
- workato_platform_cli/cli/commands/recipes/validator.py +1740 -0
- workato_platform_cli/cli/commands/workspace.py +73 -0
- workato_platform_cli/cli/containers.py +80 -0
- workato_platform_cli/cli/resources/data/connection-data.json +7364 -0
- workato_platform_cli/cli/resources/data/picklist-data.json +3706 -0
- workato_platform_cli/cli/resources/docs/README.md +178 -0
- workato_platform_cli/cli/resources/docs/actions.md +452 -0
- workato_platform_cli/cli/resources/docs/block-structure.md +424 -0
- workato_platform_cli/cli/resources/docs/connections-parameters.md +11946 -0
- workato_platform_cli/cli/resources/docs/data-mapping.md +779 -0
- workato_platform_cli/cli/resources/docs/formulas/array-list-formulas.md +1276 -0
- workato_platform_cli/cli/resources/docs/formulas/conditions.md +102 -0
- workato_platform_cli/cli/resources/docs/formulas/date-formulas.md +798 -0
- workato_platform_cli/cli/resources/docs/formulas/number-formulas.md +507 -0
- workato_platform_cli/cli/resources/docs/formulas/other-formulas.md +419 -0
- workato_platform_cli/cli/resources/docs/formulas/string-formulas.md +1353 -0
- workato_platform_cli/cli/resources/docs/formulas.md +214 -0
- workato_platform_cli/cli/resources/docs/naming-conventions.md +163 -0
- workato_platform_cli/cli/resources/docs/recipe-deployment-workflow.md +352 -0
- workato_platform_cli/cli/resources/docs/recipe-fundamentals.md +179 -0
- workato_platform_cli/cli/resources/docs/triggers.md +360 -0
- workato_platform_cli/cli/utils/__init__.py +10 -0
- workato_platform_cli/cli/utils/config/__init__.py +33 -0
- workato_platform_cli/cli/utils/config/manager.py +1001 -0
- workato_platform_cli/cli/utils/config/models.py +89 -0
- workato_platform_cli/cli/utils/config/profiles.py +491 -0
- workato_platform_cli/cli/utils/config/workspace.py +113 -0
- workato_platform_cli/cli/utils/exception_handler.py +531 -0
- workato_platform_cli/cli/utils/gitignore.py +32 -0
- workato_platform_cli/cli/utils/ignore_patterns.py +44 -0
- workato_platform_cli/cli/utils/spinner.py +63 -0
- workato_platform_cli/cli/utils/version_checker.py +237 -0
- workato_platform_cli/client/__init__.py +0 -0
- workato_platform_cli/client/workato_api/__init__.py +202 -0
- workato_platform_cli/client/workato_api/api/__init__.py +15 -0
- workato_platform_cli/client/workato_api/api/api_platform_api.py +2875 -0
- workato_platform_cli/client/workato_api/api/connections_api.py +1807 -0
- workato_platform_cli/client/workato_api/api/connectors_api.py +840 -0
- workato_platform_cli/client/workato_api/api/data_tables_api.py +604 -0
- workato_platform_cli/client/workato_api/api/export_api.py +621 -0
- workato_platform_cli/client/workato_api/api/folders_api.py +621 -0
- workato_platform_cli/client/workato_api/api/packages_api.py +1197 -0
- workato_platform_cli/client/workato_api/api/projects_api.py +590 -0
- workato_platform_cli/client/workato_api/api/properties_api.py +620 -0
- workato_platform_cli/client/workato_api/api/recipes_api.py +1379 -0
- workato_platform_cli/client/workato_api/api/users_api.py +285 -0
- workato_platform_cli/client/workato_api/api_client.py +807 -0
- workato_platform_cli/client/workato_api/api_response.py +21 -0
- workato_platform_cli/client/workato_api/configuration.py +601 -0
- workato_platform_cli/client/workato_api/docs/APIPlatformApi.md +844 -0
- workato_platform_cli/client/workato_api/docs/ApiClient.md +46 -0
- workato_platform_cli/client/workato_api/docs/ApiClientApiCollectionsInner.md +30 -0
- workato_platform_cli/client/workato_api/docs/ApiClientApiPoliciesInner.md +30 -0
- workato_platform_cli/client/workato_api/docs/ApiClientCreateRequest.md +46 -0
- workato_platform_cli/client/workato_api/docs/ApiClientListResponse.md +32 -0
- workato_platform_cli/client/workato_api/docs/ApiClientResponse.md +29 -0
- workato_platform_cli/client/workato_api/docs/ApiCollection.md +38 -0
- workato_platform_cli/client/workato_api/docs/ApiCollectionCreateRequest.md +32 -0
- workato_platform_cli/client/workato_api/docs/ApiEndpoint.md +41 -0
- workato_platform_cli/client/workato_api/docs/ApiKey.md +36 -0
- workato_platform_cli/client/workato_api/docs/ApiKeyCreateRequest.md +32 -0
- workato_platform_cli/client/workato_api/docs/ApiKeyListResponse.md +32 -0
- workato_platform_cli/client/workato_api/docs/ApiKeyResponse.md +29 -0
- workato_platform_cli/client/workato_api/docs/Asset.md +39 -0
- workato_platform_cli/client/workato_api/docs/AssetReference.md +37 -0
- workato_platform_cli/client/workato_api/docs/Connection.md +44 -0
- workato_platform_cli/client/workato_api/docs/ConnectionCreateRequest.md +35 -0
- workato_platform_cli/client/workato_api/docs/ConnectionUpdateRequest.md +34 -0
- workato_platform_cli/client/workato_api/docs/ConnectionsApi.md +526 -0
- workato_platform_cli/client/workato_api/docs/ConnectorAction.md +33 -0
- workato_platform_cli/client/workato_api/docs/ConnectorVersion.md +32 -0
- workato_platform_cli/client/workato_api/docs/ConnectorsApi.md +249 -0
- workato_platform_cli/client/workato_api/docs/CreateExportManifestRequest.md +29 -0
- workato_platform_cli/client/workato_api/docs/CreateFolderRequest.md +30 -0
- workato_platform_cli/client/workato_api/docs/CustomConnector.md +35 -0
- workato_platform_cli/client/workato_api/docs/CustomConnectorCodeResponse.md +29 -0
- workato_platform_cli/client/workato_api/docs/CustomConnectorCodeResponseData.md +29 -0
- workato_platform_cli/client/workato_api/docs/CustomConnectorListResponse.md +29 -0
- workato_platform_cli/client/workato_api/docs/DataTable.md +34 -0
- workato_platform_cli/client/workato_api/docs/DataTableColumn.md +37 -0
- workato_platform_cli/client/workato_api/docs/DataTableColumnRequest.md +37 -0
- workato_platform_cli/client/workato_api/docs/DataTableCreateRequest.md +31 -0
- workato_platform_cli/client/workato_api/docs/DataTableCreateResponse.md +29 -0
- workato_platform_cli/client/workato_api/docs/DataTableListResponse.md +29 -0
- workato_platform_cli/client/workato_api/docs/DataTableRelation.md +30 -0
- workato_platform_cli/client/workato_api/docs/DataTablesApi.md +172 -0
- workato_platform_cli/client/workato_api/docs/DeleteProject403Response.md +29 -0
- workato_platform_cli/client/workato_api/docs/Error.md +29 -0
- workato_platform_cli/client/workato_api/docs/ExportApi.md +175 -0
- workato_platform_cli/client/workato_api/docs/ExportManifestRequest.md +35 -0
- workato_platform_cli/client/workato_api/docs/ExportManifestResponse.md +29 -0
- workato_platform_cli/client/workato_api/docs/ExportManifestResponseResult.md +36 -0
- workato_platform_cli/client/workato_api/docs/Folder.md +35 -0
- workato_platform_cli/client/workato_api/docs/FolderAssetsResponse.md +29 -0
- workato_platform_cli/client/workato_api/docs/FolderAssetsResponseResult.md +29 -0
- workato_platform_cli/client/workato_api/docs/FolderCreationResponse.md +35 -0
- workato_platform_cli/client/workato_api/docs/FoldersApi.md +176 -0
- workato_platform_cli/client/workato_api/docs/ImportResults.md +32 -0
- workato_platform_cli/client/workato_api/docs/OAuthUrlResponse.md +29 -0
- workato_platform_cli/client/workato_api/docs/OAuthUrlResponseData.md +29 -0
- workato_platform_cli/client/workato_api/docs/OpenApiSpec.md +30 -0
- workato_platform_cli/client/workato_api/docs/PackageDetailsResponse.md +35 -0
- workato_platform_cli/client/workato_api/docs/PackageDetailsResponseRecipeStatusInner.md +30 -0
- workato_platform_cli/client/workato_api/docs/PackageResponse.md +33 -0
- workato_platform_cli/client/workato_api/docs/PackagesApi.md +364 -0
- workato_platform_cli/client/workato_api/docs/PicklistRequest.md +30 -0
- workato_platform_cli/client/workato_api/docs/PicklistResponse.md +29 -0
- workato_platform_cli/client/workato_api/docs/PlatformConnector.md +36 -0
- workato_platform_cli/client/workato_api/docs/PlatformConnectorListResponse.md +32 -0
- workato_platform_cli/client/workato_api/docs/Project.md +32 -0
- workato_platform_cli/client/workato_api/docs/ProjectsApi.md +173 -0
- workato_platform_cli/client/workato_api/docs/PropertiesApi.md +186 -0
- workato_platform_cli/client/workato_api/docs/Recipe.md +58 -0
- workato_platform_cli/client/workato_api/docs/RecipeConfigInner.md +33 -0
- workato_platform_cli/client/workato_api/docs/RecipeConnectionUpdateRequest.md +30 -0
- workato_platform_cli/client/workato_api/docs/RecipeListResponse.md +29 -0
- workato_platform_cli/client/workato_api/docs/RecipeStartResponse.md +31 -0
- workato_platform_cli/client/workato_api/docs/RecipesApi.md +367 -0
- workato_platform_cli/client/workato_api/docs/RuntimeUserConnectionCreateRequest.md +34 -0
- workato_platform_cli/client/workato_api/docs/RuntimeUserConnectionResponse.md +29 -0
- workato_platform_cli/client/workato_api/docs/RuntimeUserConnectionResponseData.md +30 -0
- workato_platform_cli/client/workato_api/docs/SuccessResponse.md +29 -0
- workato_platform_cli/client/workato_api/docs/UpsertProjectPropertiesRequest.md +29 -0
- workato_platform_cli/client/workato_api/docs/User.md +48 -0
- workato_platform_cli/client/workato_api/docs/UsersApi.md +84 -0
- workato_platform_cli/client/workato_api/docs/ValidationError.md +30 -0
- workato_platform_cli/client/workato_api/docs/ValidationErrorErrorsValue.md +28 -0
- workato_platform_cli/client/workato_api/exceptions.py +216 -0
- workato_platform_cli/client/workato_api/models/__init__.py +83 -0
- workato_platform_cli/client/workato_api/models/api_client.py +185 -0
- workato_platform_cli/client/workato_api/models/api_client_api_collections_inner.py +89 -0
- workato_platform_cli/client/workato_api/models/api_client_api_policies_inner.py +89 -0
- workato_platform_cli/client/workato_api/models/api_client_create_request.py +138 -0
- workato_platform_cli/client/workato_api/models/api_client_list_response.py +101 -0
- workato_platform_cli/client/workato_api/models/api_client_response.py +91 -0
- workato_platform_cli/client/workato_api/models/api_collection.py +110 -0
- workato_platform_cli/client/workato_api/models/api_collection_create_request.py +97 -0
- workato_platform_cli/client/workato_api/models/api_endpoint.py +117 -0
- workato_platform_cli/client/workato_api/models/api_key.py +102 -0
- workato_platform_cli/client/workato_api/models/api_key_create_request.py +93 -0
- workato_platform_cli/client/workato_api/models/api_key_list_response.py +101 -0
- workato_platform_cli/client/workato_api/models/api_key_response.py +91 -0
- workato_platform_cli/client/workato_api/models/asset.py +124 -0
- workato_platform_cli/client/workato_api/models/asset_reference.py +110 -0
- workato_platform_cli/client/workato_api/models/connection.py +173 -0
- workato_platform_cli/client/workato_api/models/connection_create_request.py +99 -0
- workato_platform_cli/client/workato_api/models/connection_update_request.py +97 -0
- workato_platform_cli/client/workato_api/models/connector_action.py +100 -0
- workato_platform_cli/client/workato_api/models/connector_version.py +99 -0
- workato_platform_cli/client/workato_api/models/create_export_manifest_request.py +91 -0
- workato_platform_cli/client/workato_api/models/create_folder_request.py +89 -0
- workato_platform_cli/client/workato_api/models/custom_connector.py +117 -0
- workato_platform_cli/client/workato_api/models/custom_connector_code_response.py +91 -0
- workato_platform_cli/client/workato_api/models/custom_connector_code_response_data.py +87 -0
- workato_platform_cli/client/workato_api/models/custom_connector_list_response.py +95 -0
- workato_platform_cli/client/workato_api/models/data_table.py +107 -0
- workato_platform_cli/client/workato_api/models/data_table_column.py +125 -0
- workato_platform_cli/client/workato_api/models/data_table_column_request.py +130 -0
- workato_platform_cli/client/workato_api/models/data_table_create_request.py +99 -0
- workato_platform_cli/client/workato_api/models/data_table_create_response.py +91 -0
- workato_platform_cli/client/workato_api/models/data_table_list_response.py +95 -0
- workato_platform_cli/client/workato_api/models/data_table_relation.py +90 -0
- workato_platform_cli/client/workato_api/models/delete_project403_response.py +87 -0
- workato_platform_cli/client/workato_api/models/error.py +87 -0
- workato_platform_cli/client/workato_api/models/export_manifest_request.py +107 -0
- workato_platform_cli/client/workato_api/models/export_manifest_response.py +91 -0
- workato_platform_cli/client/workato_api/models/export_manifest_response_result.py +112 -0
- workato_platform_cli/client/workato_api/models/folder.py +110 -0
- workato_platform_cli/client/workato_api/models/folder_assets_response.py +91 -0
- workato_platform_cli/client/workato_api/models/folder_assets_response_result.py +95 -0
- workato_platform_cli/client/workato_api/models/folder_creation_response.py +110 -0
- workato_platform_cli/client/workato_api/models/import_results.py +93 -0
- workato_platform_cli/client/workato_api/models/o_auth_url_response.py +91 -0
- workato_platform_cli/client/workato_api/models/o_auth_url_response_data.py +87 -0
- workato_platform_cli/client/workato_api/models/open_api_spec.py +96 -0
- workato_platform_cli/client/workato_api/models/package_details_response.py +126 -0
- workato_platform_cli/client/workato_api/models/package_details_response_recipe_status_inner.py +99 -0
- workato_platform_cli/client/workato_api/models/package_response.py +109 -0
- workato_platform_cli/client/workato_api/models/picklist_request.py +89 -0
- workato_platform_cli/client/workato_api/models/picklist_response.py +88 -0
- workato_platform_cli/client/workato_api/models/platform_connector.py +116 -0
- workato_platform_cli/client/workato_api/models/platform_connector_list_response.py +101 -0
- workato_platform_cli/client/workato_api/models/project.py +93 -0
- workato_platform_cli/client/workato_api/models/recipe.py +174 -0
- workato_platform_cli/client/workato_api/models/recipe_config_inner.py +100 -0
- workato_platform_cli/client/workato_api/models/recipe_connection_update_request.py +89 -0
- workato_platform_cli/client/workato_api/models/recipe_list_response.py +95 -0
- workato_platform_cli/client/workato_api/models/recipe_start_response.py +91 -0
- workato_platform_cli/client/workato_api/models/runtime_user_connection_create_request.py +97 -0
- workato_platform_cli/client/workato_api/models/runtime_user_connection_response.py +91 -0
- workato_platform_cli/client/workato_api/models/runtime_user_connection_response_data.py +89 -0
- workato_platform_cli/client/workato_api/models/success_response.py +87 -0
- workato_platform_cli/client/workato_api/models/upsert_project_properties_request.py +88 -0
- workato_platform_cli/client/workato_api/models/user.py +151 -0
- workato_platform_cli/client/workato_api/models/validation_error.py +102 -0
- workato_platform_cli/client/workato_api/models/validation_error_errors_value.py +143 -0
- workato_platform_cli/client/workato_api/rest.py +213 -0
- workato_platform_cli/client/workato_api/test/__init__.py +0 -0
- workato_platform_cli/client/workato_api/test/test_api_client.py +94 -0
- workato_platform_cli/client/workato_api/test/test_api_client_api_collections_inner.py +52 -0
- workato_platform_cli/client/workato_api/test/test_api_client_api_policies_inner.py +52 -0
- workato_platform_cli/client/workato_api/test/test_api_client_create_request.py +75 -0
- workato_platform_cli/client/workato_api/test/test_api_client_list_response.py +114 -0
- workato_platform_cli/client/workato_api/test/test_api_client_response.py +104 -0
- workato_platform_cli/client/workato_api/test/test_api_collection.py +72 -0
- workato_platform_cli/client/workato_api/test/test_api_collection_create_request.py +57 -0
- workato_platform_cli/client/workato_api/test/test_api_endpoint.py +75 -0
- workato_platform_cli/client/workato_api/test/test_api_key.py +64 -0
- workato_platform_cli/client/workato_api/test/test_api_key_create_request.py +56 -0
- workato_platform_cli/client/workato_api/test/test_api_key_list_response.py +78 -0
- workato_platform_cli/client/workato_api/test/test_api_key_response.py +68 -0
- workato_platform_cli/client/workato_api/test/test_api_platform_api.py +101 -0
- workato_platform_cli/client/workato_api/test/test_asset.py +67 -0
- workato_platform_cli/client/workato_api/test/test_asset_reference.py +62 -0
- workato_platform_cli/client/workato_api/test/test_connection.py +81 -0
- workato_platform_cli/client/workato_api/test/test_connection_create_request.py +59 -0
- workato_platform_cli/client/workato_api/test/test_connection_update_request.py +56 -0
- workato_platform_cli/client/workato_api/test/test_connections_api.py +73 -0
- workato_platform_cli/client/workato_api/test/test_connector_action.py +59 -0
- workato_platform_cli/client/workato_api/test/test_connector_version.py +58 -0
- workato_platform_cli/client/workato_api/test/test_connectors_api.py +52 -0
- workato_platform_cli/client/workato_api/test/test_create_export_manifest_request.py +88 -0
- workato_platform_cli/client/workato_api/test/test_create_folder_request.py +53 -0
- workato_platform_cli/client/workato_api/test/test_custom_connector.py +76 -0
- workato_platform_cli/client/workato_api/test/test_custom_connector_code_response.py +54 -0
- workato_platform_cli/client/workato_api/test/test_custom_connector_code_response_data.py +52 -0
- workato_platform_cli/client/workato_api/test/test_custom_connector_list_response.py +82 -0
- workato_platform_cli/client/workato_api/test/test_data_table.py +88 -0
- workato_platform_cli/client/workato_api/test/test_data_table_column.py +72 -0
- workato_platform_cli/client/workato_api/test/test_data_table_column_request.py +64 -0
- workato_platform_cli/client/workato_api/test/test_data_table_create_request.py +82 -0
- workato_platform_cli/client/workato_api/test/test_data_table_create_response.py +90 -0
- workato_platform_cli/client/workato_api/test/test_data_table_list_response.py +94 -0
- workato_platform_cli/client/workato_api/test/test_data_table_relation.py +54 -0
- workato_platform_cli/client/workato_api/test/test_data_tables_api.py +45 -0
- workato_platform_cli/client/workato_api/test/test_delete_project403_response.py +51 -0
- workato_platform_cli/client/workato_api/test/test_error.py +52 -0
- workato_platform_cli/client/workato_api/test/test_export_api.py +45 -0
- workato_platform_cli/client/workato_api/test/test_export_manifest_request.py +69 -0
- workato_platform_cli/client/workato_api/test/test_export_manifest_response.py +68 -0
- workato_platform_cli/client/workato_api/test/test_export_manifest_response_result.py +66 -0
- workato_platform_cli/client/workato_api/test/test_folder.py +64 -0
- workato_platform_cli/client/workato_api/test/test_folder_assets_response.py +80 -0
- workato_platform_cli/client/workato_api/test/test_folder_assets_response_result.py +78 -0
- workato_platform_cli/client/workato_api/test/test_folder_creation_response.py +64 -0
- workato_platform_cli/client/workato_api/test/test_folders_api.py +45 -0
- workato_platform_cli/client/workato_api/test/test_import_results.py +58 -0
- workato_platform_cli/client/workato_api/test/test_o_auth_url_response.py +54 -0
- workato_platform_cli/client/workato_api/test/test_o_auth_url_response_data.py +52 -0
- workato_platform_cli/client/workato_api/test/test_open_api_spec.py +54 -0
- workato_platform_cli/client/workato_api/test/test_package_details_response.py +64 -0
- workato_platform_cli/client/workato_api/test/test_package_details_response_recipe_status_inner.py +52 -0
- workato_platform_cli/client/workato_api/test/test_package_response.py +58 -0
- workato_platform_cli/client/workato_api/test/test_packages_api.py +59 -0
- workato_platform_cli/client/workato_api/test/test_picklist_request.py +53 -0
- workato_platform_cli/client/workato_api/test/test_picklist_response.py +52 -0
- workato_platform_cli/client/workato_api/test/test_platform_connector.py +94 -0
- workato_platform_cli/client/workato_api/test/test_platform_connector_list_response.py +106 -0
- workato_platform_cli/client/workato_api/test/test_project.py +57 -0
- workato_platform_cli/client/workato_api/test/test_projects_api.py +45 -0
- workato_platform_cli/client/workato_api/test/test_properties_api.py +45 -0
- workato_platform_cli/client/workato_api/test/test_recipe.py +124 -0
- workato_platform_cli/client/workato_api/test/test_recipe_config_inner.py +55 -0
- workato_platform_cli/client/workato_api/test/test_recipe_connection_update_request.py +54 -0
- workato_platform_cli/client/workato_api/test/test_recipe_list_response.py +134 -0
- workato_platform_cli/client/workato_api/test/test_recipe_start_response.py +54 -0
- workato_platform_cli/client/workato_api/test/test_recipes_api.py +59 -0
- workato_platform_cli/client/workato_api/test/test_runtime_user_connection_create_request.py +59 -0
- workato_platform_cli/client/workato_api/test/test_runtime_user_connection_response.py +56 -0
- workato_platform_cli/client/workato_api/test/test_runtime_user_connection_response_data.py +54 -0
- workato_platform_cli/client/workato_api/test/test_success_response.py +52 -0
- workato_platform_cli/client/workato_api/test/test_upsert_project_properties_request.py +52 -0
- workato_platform_cli/client/workato_api/test/test_user.py +85 -0
- workato_platform_cli/client/workato_api/test/test_users_api.py +38 -0
- workato_platform_cli/client/workato_api/test/test_validation_error.py +52 -0
- workato_platform_cli/client/workato_api/test/test_validation_error_errors_value.py +50 -0
- workato_platform_cli/client/workato_api_README.md +205 -0
- workato_platform_cli-1.0.0rc5.dev5.dist-info/METADATA +185 -0
- workato_platform_cli-1.0.0rc5.dev5.dist-info/RECORD +306 -0
- workato_platform_cli-1.0.0rc5.dev5.dist-info/WHEEL +4 -0
- workato_platform_cli-1.0.0rc5.dev5.dist-info/entry_points.txt +2 -0
- workato_platform_cli-1.0.0rc5.dev5.dist-info/licenses/LICENSE +7 -0
|
@@ -0,0 +1,1740 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
10
|
+
|
|
11
|
+
from workato_platform_cli import Workato
|
|
12
|
+
from workato_platform_cli.client.workato_api.models.platform_connector import (
|
|
13
|
+
PlatformConnector,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Keyword(str, Enum):
|
|
18
|
+
"""Recipe keywords that define the structure"""
|
|
19
|
+
|
|
20
|
+
TRIGGER = "trigger"
|
|
21
|
+
ACTION = "action"
|
|
22
|
+
IF = "if"
|
|
23
|
+
ELSIF = "elsif"
|
|
24
|
+
ELSE = "else"
|
|
25
|
+
FOREACH = "foreach"
|
|
26
|
+
REPEAT = "repeat"
|
|
27
|
+
WHILE_CONDITION = "while_condition"
|
|
28
|
+
TRY = "try"
|
|
29
|
+
CATCH = "catch"
|
|
30
|
+
STOP = "stop"
|
|
31
|
+
APPLICATION = "application"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ErrorType(str, Enum):
|
|
35
|
+
"""Validation error types"""
|
|
36
|
+
|
|
37
|
+
SYNTAX_INVALID = "syntax_invalid"
|
|
38
|
+
LINE_SYNTAX_INVALID = "line_syntax_invalid"
|
|
39
|
+
LINE_ATTR_INVALID = "line_attr_invalid"
|
|
40
|
+
INPUT_FIELD_INVALID = "input_field_invalid"
|
|
41
|
+
INPUT_EXPR_INVALID = "input_expr_invalid"
|
|
42
|
+
INPUT_UNKNOWN_DATA_PILL = "input_unknown_data_pill"
|
|
43
|
+
INPUT_FIELD_BLANK = "input_field_blank"
|
|
44
|
+
INPUT_VALUE_INVALID = "input_value_invalid"
|
|
45
|
+
INPUT_INVALID_BY_ADAPTER = "input_invalid_by_adapter"
|
|
46
|
+
DEPENDS_ON_INVALID = "depends_on_invalid"
|
|
47
|
+
EXTERNAL_INPUT_INVALID = "external_input_invalid"
|
|
48
|
+
DYNAMIC_FIELD_MAPPING_INVALID = "dynamic_field_mapping_invalid"
|
|
49
|
+
EXTENDED_SCHEMA_INVALID = "extended_schema_invalid"
|
|
50
|
+
STRUCTURE_INVALID = "structure_invalid"
|
|
51
|
+
INPUT_MODE_INCONSISTENT = "input_mode_inconsistent"
|
|
52
|
+
ARRAY_MAPPING_INVALID = "array_mapping_invalid"
|
|
53
|
+
FORMULA_SYNTAX_INVALID = "formula_syntax_invalid"
|
|
54
|
+
BLOCK_NUMBERING_INVALID = "block_numbering_invalid"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class ValidationError:
|
|
59
|
+
"""Individual validation error"""
|
|
60
|
+
|
|
61
|
+
field_label: str | None = None
|
|
62
|
+
value: Any = None
|
|
63
|
+
message: str = ""
|
|
64
|
+
field_path: list[str] | None = None
|
|
65
|
+
error_type: ErrorType | None = None
|
|
66
|
+
line_number: int | None = None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class ValidationResult:
|
|
71
|
+
"""Complete validation result"""
|
|
72
|
+
|
|
73
|
+
is_valid: bool
|
|
74
|
+
errors: list[ValidationError]
|
|
75
|
+
warnings: list[ValidationError] = field(default_factory=list)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class RecipeLine(BaseModel):
|
|
79
|
+
"""Base recipe line structure"""
|
|
80
|
+
|
|
81
|
+
model_config = ConfigDict(extra="allow")
|
|
82
|
+
|
|
83
|
+
number: int
|
|
84
|
+
keyword: Keyword
|
|
85
|
+
uuid: str
|
|
86
|
+
as_: str | None = Field(None, alias="as")
|
|
87
|
+
input: dict[str, Any] | None = None
|
|
88
|
+
block: list["RecipeLine"] | None = None
|
|
89
|
+
comment: str | None = None
|
|
90
|
+
custom_title: str | None = None
|
|
91
|
+
description: str | None = None
|
|
92
|
+
hidden_config_fields: list[str] | None = None
|
|
93
|
+
name: str | None = None
|
|
94
|
+
provider: str | None = None
|
|
95
|
+
title: str | None = None
|
|
96
|
+
visible_config_fields: list[str] | None = None
|
|
97
|
+
wizard_finished: bool | None = Field(None, alias="wizardFinished")
|
|
98
|
+
mask_data: bool | None = Field(None, alias="mask_data")
|
|
99
|
+
skip: bool | None = None
|
|
100
|
+
toggle_cfg: dict[str, Any] | None = Field(None, alias="toggleCfg")
|
|
101
|
+
dynamic_pick_list_selection: dict[str, Any] | None = Field(
|
|
102
|
+
None, alias="dynamicPickListSelection"
|
|
103
|
+
)
|
|
104
|
+
extended_input_schema: list[dict[str, Any]] | None = None
|
|
105
|
+
extended_output_schema: list[dict[str, Any]] | None = None
|
|
106
|
+
requirements: dict[str, Any] | None = None
|
|
107
|
+
external_input_definition: dict[str, Any] | None = None
|
|
108
|
+
filter: dict[str, Any] | None = None
|
|
109
|
+
batch_size: int | None = Field(None, alias="batch_size")
|
|
110
|
+
clear_scope: bool | None = Field(None, alias="clear_scope")
|
|
111
|
+
repeat_mode: str | None = Field(None, alias="repeat_mode")
|
|
112
|
+
source: str | None = None
|
|
113
|
+
format_version: str | None = Field(None, alias="format_version")
|
|
114
|
+
job_report_config: dict[str, Any] | None = None
|
|
115
|
+
job_report_schema: list[dict[str, Any]] | None = None
|
|
116
|
+
param: dict[str, Any] | None = None
|
|
117
|
+
parameters_schema: list[dict[str, Any]] | None = None
|
|
118
|
+
unfinished: bool | None = None
|
|
119
|
+
|
|
120
|
+
@field_validator("as_")
|
|
121
|
+
@classmethod
|
|
122
|
+
def validate_as_length(cls, v: str) -> str:
|
|
123
|
+
if v and len(v) > 48:
|
|
124
|
+
raise ValueError("'as' field must be 48 characters or less")
|
|
125
|
+
return v
|
|
126
|
+
|
|
127
|
+
@field_validator("uuid")
|
|
128
|
+
@classmethod
|
|
129
|
+
def validate_uuid_length(cls, v: str) -> str:
|
|
130
|
+
if v and len(v) > 36:
|
|
131
|
+
raise ValueError("UUID must be 36 characters or less")
|
|
132
|
+
return v
|
|
133
|
+
|
|
134
|
+
@field_validator("job_report_schema", "job_report_config")
|
|
135
|
+
@classmethod
|
|
136
|
+
def validate_job_report_size(cls, v: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
137
|
+
if v and len(v) > 10:
|
|
138
|
+
raise ValueError("Job report schema/config must be 10 items or less")
|
|
139
|
+
return v
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class RecipeAccountId(BaseModel):
|
|
143
|
+
zip_name: str
|
|
144
|
+
name: str
|
|
145
|
+
folder: str
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class RecipeConfig(BaseModel):
|
|
149
|
+
keyword: Keyword
|
|
150
|
+
provider: str
|
|
151
|
+
skip_validation: bool | None = None
|
|
152
|
+
account_id: RecipeAccountId | None = None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class Recipe(BaseModel):
|
|
156
|
+
name: str
|
|
157
|
+
description: str | None = None
|
|
158
|
+
version: int | None = None
|
|
159
|
+
private: bool
|
|
160
|
+
concurrency: int
|
|
161
|
+
code: RecipeLine
|
|
162
|
+
config: list[RecipeConfig]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class RecipeStructure(BaseModel):
|
|
166
|
+
"""Complete recipe structure"""
|
|
167
|
+
|
|
168
|
+
root: RecipeLine
|
|
169
|
+
|
|
170
|
+
@model_validator(mode="after")
|
|
171
|
+
def validate_recipe_structure(self) -> "RecipeStructure":
|
|
172
|
+
"""Validate the overall recipe structure"""
|
|
173
|
+
if not self.root:
|
|
174
|
+
return self
|
|
175
|
+
|
|
176
|
+
errors: list[ValidationError] = []
|
|
177
|
+
|
|
178
|
+
# Must start with trigger
|
|
179
|
+
if self.root.keyword != Keyword.TRIGGER:
|
|
180
|
+
errors.append(
|
|
181
|
+
ValidationError(
|
|
182
|
+
message="Recipe must start with a trigger",
|
|
183
|
+
error_type=ErrorType.SYNTAX_INVALID,
|
|
184
|
+
line_number=self.root.number,
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Validate structure recursively
|
|
189
|
+
structure_errors = self._validate_structure_recursive(self.root, [])
|
|
190
|
+
errors.extend(structure_errors)
|
|
191
|
+
|
|
192
|
+
if errors:
|
|
193
|
+
raise ValueError(
|
|
194
|
+
f"Recipe structure validation failed: {len(errors)} errors found"
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
return self
|
|
198
|
+
|
|
199
|
+
@classmethod
|
|
200
|
+
def _validate_structure_recursive(
|
|
201
|
+
cls, line: RecipeLine, path: list[int]
|
|
202
|
+
) -> list[ValidationError]:
|
|
203
|
+
"""Recursively validate recipe structure"""
|
|
204
|
+
errors = []
|
|
205
|
+
current_path = path + [line.number]
|
|
206
|
+
|
|
207
|
+
# Validate line-specific rules
|
|
208
|
+
line_errors = cls._validate_line_structure(line, current_path)
|
|
209
|
+
errors.extend(line_errors)
|
|
210
|
+
|
|
211
|
+
# Validate children
|
|
212
|
+
if line.block:
|
|
213
|
+
for child in line.block:
|
|
214
|
+
child_errors = cls._validate_structure_recursive(child, current_path)
|
|
215
|
+
errors.extend(child_errors)
|
|
216
|
+
|
|
217
|
+
return errors
|
|
218
|
+
|
|
219
|
+
@classmethod
|
|
220
|
+
def _validate_line_structure(
|
|
221
|
+
cls, line: RecipeLine, path: list[int]
|
|
222
|
+
) -> list[ValidationError]:
|
|
223
|
+
"""Validate specific line structure rules"""
|
|
224
|
+
errors = []
|
|
225
|
+
|
|
226
|
+
# Validate keyword-specific rules
|
|
227
|
+
if line.keyword == Keyword.IF:
|
|
228
|
+
errors.extend(cls._validate_if_structure(line, path))
|
|
229
|
+
elif line.keyword == Keyword.FOREACH:
|
|
230
|
+
errors.extend(cls._validate_foreach_structure(line, path))
|
|
231
|
+
elif line.keyword == Keyword.REPEAT:
|
|
232
|
+
errors.extend(cls._validate_repeat_structure(line, path))
|
|
233
|
+
elif line.keyword == Keyword.TRY:
|
|
234
|
+
errors.extend(cls._validate_try_structure(line, path))
|
|
235
|
+
elif line.keyword == Keyword.ACTION:
|
|
236
|
+
errors.extend(cls._validate_action_structure(line, path))
|
|
237
|
+
|
|
238
|
+
return errors
|
|
239
|
+
|
|
240
|
+
@classmethod
|
|
241
|
+
def _validate_if_structure(
|
|
242
|
+
cls, line: RecipeLine, path: list[int]
|
|
243
|
+
) -> list[ValidationError]:
|
|
244
|
+
"""Validate IF block structure"""
|
|
245
|
+
errors = []
|
|
246
|
+
|
|
247
|
+
if not line.block:
|
|
248
|
+
errors.append(
|
|
249
|
+
ValidationError(
|
|
250
|
+
message="IF statement must have a block",
|
|
251
|
+
error_type=ErrorType.LINE_SYNTAX_INVALID,
|
|
252
|
+
line_number=line.number,
|
|
253
|
+
)
|
|
254
|
+
)
|
|
255
|
+
return errors
|
|
256
|
+
|
|
257
|
+
# Validate IF block structure: [top_level_lines*, elsif_lines*, else_line?]
|
|
258
|
+
block = line.block
|
|
259
|
+
i = 0
|
|
260
|
+
|
|
261
|
+
# top_level_lines*
|
|
262
|
+
while i < len(block) and block[i].keyword in [
|
|
263
|
+
Keyword.ACTION,
|
|
264
|
+
Keyword.IF,
|
|
265
|
+
Keyword.FOREACH,
|
|
266
|
+
Keyword.REPEAT,
|
|
267
|
+
Keyword.TRY,
|
|
268
|
+
Keyword.STOP,
|
|
269
|
+
]:
|
|
270
|
+
i += 1
|
|
271
|
+
|
|
272
|
+
# elsif_lines*
|
|
273
|
+
while i < len(block) and block[i].keyword == Keyword.ELSIF:
|
|
274
|
+
i += 1
|
|
275
|
+
|
|
276
|
+
# else_line?
|
|
277
|
+
if i < len(block) and block[i].keyword == Keyword.ELSE:
|
|
278
|
+
i += 1
|
|
279
|
+
|
|
280
|
+
if i < len(block):
|
|
281
|
+
errors.append(
|
|
282
|
+
ValidationError(
|
|
283
|
+
message=f"Unexpected line type '{block[i].keyword}' in IF block",
|
|
284
|
+
error_type=ErrorType.LINE_SYNTAX_INVALID,
|
|
285
|
+
line_number=block[i].number,
|
|
286
|
+
)
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
return errors
|
|
290
|
+
|
|
291
|
+
@classmethod
|
|
292
|
+
def _validate_foreach_structure(
|
|
293
|
+
cls, line: RecipeLine, path: list[int]
|
|
294
|
+
) -> list[ValidationError]:
|
|
295
|
+
"""Validate FOREACH structure"""
|
|
296
|
+
errors = []
|
|
297
|
+
|
|
298
|
+
if not line.source:
|
|
299
|
+
errors.append(
|
|
300
|
+
ValidationError(
|
|
301
|
+
message="FOREACH must have a source",
|
|
302
|
+
error_type=ErrorType.LINE_ATTR_INVALID,
|
|
303
|
+
line_number=line.number,
|
|
304
|
+
)
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
return errors
|
|
308
|
+
|
|
309
|
+
@classmethod
|
|
310
|
+
def _validate_repeat_structure(
|
|
311
|
+
cls, line: RecipeLine, path: list[int]
|
|
312
|
+
) -> list[ValidationError]:
|
|
313
|
+
"""Validate REPEAT structure"""
|
|
314
|
+
errors = []
|
|
315
|
+
|
|
316
|
+
if not line.block:
|
|
317
|
+
errors.append(
|
|
318
|
+
ValidationError(
|
|
319
|
+
message="REPEAT must have a block",
|
|
320
|
+
error_type=ErrorType.LINE_SYNTAX_INVALID,
|
|
321
|
+
line_number=line.number,
|
|
322
|
+
)
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
return errors
|
|
326
|
+
|
|
327
|
+
@classmethod
|
|
328
|
+
def _validate_try_structure(
|
|
329
|
+
cls, line: RecipeLine, path: list[int]
|
|
330
|
+
) -> list[ValidationError]:
|
|
331
|
+
"""Validate TRY block structure"""
|
|
332
|
+
errors = []
|
|
333
|
+
|
|
334
|
+
if not line.block:
|
|
335
|
+
errors.append(
|
|
336
|
+
ValidationError(
|
|
337
|
+
message="TRY statement must have a block",
|
|
338
|
+
error_type=ErrorType.LINE_SYNTAX_INVALID,
|
|
339
|
+
line_number=line.number,
|
|
340
|
+
)
|
|
341
|
+
)
|
|
342
|
+
return errors
|
|
343
|
+
|
|
344
|
+
# Validate TRY block structure: [top_level_lines*, catch_line?]
|
|
345
|
+
block = line.block
|
|
346
|
+
i = 0
|
|
347
|
+
|
|
348
|
+
# top_level_lines*
|
|
349
|
+
while i < len(block) and block[i].keyword in [
|
|
350
|
+
Keyword.ACTION,
|
|
351
|
+
Keyword.IF,
|
|
352
|
+
Keyword.FOREACH,
|
|
353
|
+
Keyword.REPEAT,
|
|
354
|
+
Keyword.TRY,
|
|
355
|
+
Keyword.STOP,
|
|
356
|
+
]:
|
|
357
|
+
i += 1
|
|
358
|
+
|
|
359
|
+
# catch_line?
|
|
360
|
+
if i < len(block) and block[i].keyword == Keyword.CATCH:
|
|
361
|
+
i += 1
|
|
362
|
+
|
|
363
|
+
if i < len(block):
|
|
364
|
+
errors.append(
|
|
365
|
+
ValidationError(
|
|
366
|
+
message=f"Unexpected line type '{block[i].keyword}' in TRY block",
|
|
367
|
+
error_type=ErrorType.LINE_SYNTAX_INVALID,
|
|
368
|
+
line_number=block[i].number,
|
|
369
|
+
)
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
return errors
|
|
373
|
+
|
|
374
|
+
@classmethod
|
|
375
|
+
def _validate_action_structure(
|
|
376
|
+
cls, line: RecipeLine, path: list[int]
|
|
377
|
+
) -> list[ValidationError]:
|
|
378
|
+
"""Validate ACTION structure"""
|
|
379
|
+
errors = []
|
|
380
|
+
|
|
381
|
+
if line.block:
|
|
382
|
+
errors.append(
|
|
383
|
+
ValidationError(
|
|
384
|
+
message="ACTION should not have a block",
|
|
385
|
+
error_type=ErrorType.LINE_SYNTAX_INVALID,
|
|
386
|
+
line_number=line.number,
|
|
387
|
+
)
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
return errors
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
class RecipeValidator:
|
|
394
|
+
"""Main recipe validator class"""
|
|
395
|
+
|
|
396
|
+
def __init__(
|
|
397
|
+
self,
|
|
398
|
+
workato_api_client: Workato,
|
|
399
|
+
):
|
|
400
|
+
self.workato_api_client = workato_api_client
|
|
401
|
+
self.known_adapters: set[str] = set()
|
|
402
|
+
self.known_data_pills: set[str] = set()
|
|
403
|
+
self.connection_configs: dict[str, Any] = {}
|
|
404
|
+
self.platform_connectors: dict[str, Any] = {}
|
|
405
|
+
self.custom_connectors: dict[str, Any] = {}
|
|
406
|
+
self.connector_metadata: dict[str, dict[str, Any]] = {}
|
|
407
|
+
self.current_recipe_root: RecipeLine | None = None
|
|
408
|
+
|
|
409
|
+
# Cache settings
|
|
410
|
+
self._cache_file = (
|
|
411
|
+
Path.home() / ".workato_platform" / "cli" / "connector_cache.json"
|
|
412
|
+
)
|
|
413
|
+
self._cache_ttl_hours = 24 # Cache for 24 hours
|
|
414
|
+
self._last_cache_update = None
|
|
415
|
+
self._connectors_loaded = False
|
|
416
|
+
|
|
417
|
+
# Connector data will be loaded lazily when first needed
|
|
418
|
+
|
|
419
|
+
async def _ensure_connectors_loaded(self) -> None:
|
|
420
|
+
"""Ensure connector metadata is loaded (either from cache or API)"""
|
|
421
|
+
if not self._connectors_loaded:
|
|
422
|
+
await self._load_builtin_connectors()
|
|
423
|
+
self._connectors_loaded = True
|
|
424
|
+
|
|
425
|
+
def _load_cached_connectors(self) -> bool:
|
|
426
|
+
"""Load connector metadata from cache if available and not expired"""
|
|
427
|
+
try:
|
|
428
|
+
if not self._cache_file.exists():
|
|
429
|
+
return False
|
|
430
|
+
|
|
431
|
+
# Check cache age
|
|
432
|
+
cache_age = time.time() - self._cache_file.stat().st_mtime
|
|
433
|
+
|
|
434
|
+
if cache_age > (self._cache_ttl_hours * 3600):
|
|
435
|
+
return False # Cache expired
|
|
436
|
+
|
|
437
|
+
# Load from cache
|
|
438
|
+
with open(self._cache_file) as f:
|
|
439
|
+
cache_data = json.load(f)
|
|
440
|
+
|
|
441
|
+
self.known_adapters = set(cache_data.get("known_adapters", []))
|
|
442
|
+
self.connector_metadata = cache_data.get("connector_metadata", {})
|
|
443
|
+
self._last_cache_update = cache_data.get("last_update", 0)
|
|
444
|
+
|
|
445
|
+
return True
|
|
446
|
+
|
|
447
|
+
except json.JSONDecodeError:
|
|
448
|
+
return False
|
|
449
|
+
|
|
450
|
+
def _save_connectors_to_cache(self) -> None:
|
|
451
|
+
"""Save connector metadata to cache file"""
|
|
452
|
+
try:
|
|
453
|
+
# Ensure cache directory exists
|
|
454
|
+
self._cache_file.parent.mkdir(parents=True, exist_ok=True)
|
|
455
|
+
|
|
456
|
+
cache_data = {
|
|
457
|
+
"known_adapters": list(self.known_adapters),
|
|
458
|
+
"connector_metadata": self.connector_metadata,
|
|
459
|
+
"last_update": time.time(),
|
|
460
|
+
"cache_version": "1.0",
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
with open(self._cache_file, "w") as f:
|
|
464
|
+
json.dump(cache_data, f, indent=2)
|
|
465
|
+
|
|
466
|
+
except (OSError, PermissionError):
|
|
467
|
+
return
|
|
468
|
+
|
|
469
|
+
async def validate_recipe(self, recipe_data: dict[str, Any]) -> ValidationResult:
|
|
470
|
+
"""Main validation entry point"""
|
|
471
|
+
# Ensure connectors are loaded before validation
|
|
472
|
+
await self._ensure_connectors_loaded()
|
|
473
|
+
|
|
474
|
+
errors: list[ValidationError] = []
|
|
475
|
+
warnings: list[ValidationError] = []
|
|
476
|
+
|
|
477
|
+
try:
|
|
478
|
+
# Extract the recipe code structure
|
|
479
|
+
if "code" not in recipe_data:
|
|
480
|
+
errors.append(
|
|
481
|
+
ValidationError(
|
|
482
|
+
message="Recipe must have a 'code' field",
|
|
483
|
+
error_type=ErrorType.SYNTAX_INVALID,
|
|
484
|
+
)
|
|
485
|
+
)
|
|
486
|
+
return ValidationResult(
|
|
487
|
+
is_valid=False, errors=errors, warnings=warnings
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
# Parse and validate basic structure
|
|
491
|
+
recipe = RecipeStructure(root=RecipeLine(**recipe_data["code"]))
|
|
492
|
+
|
|
493
|
+
# Set the current recipe root for cross-reference validation
|
|
494
|
+
self.current_recipe_root = recipe.root
|
|
495
|
+
|
|
496
|
+
# Validate unique 'as' values across all recipe steps
|
|
497
|
+
as_errors = self._validate_unique_as_values(recipe.root)
|
|
498
|
+
errors.extend(as_errors)
|
|
499
|
+
|
|
500
|
+
# Validate block structure and numbering rules
|
|
501
|
+
structure_errors = self._validate_block_structure(recipe.root)
|
|
502
|
+
errors.extend(structure_errors)
|
|
503
|
+
|
|
504
|
+
# Validate providers and trigger/action names
|
|
505
|
+
provider_errors = self._validate_providers(recipe.root)
|
|
506
|
+
errors.extend(provider_errors)
|
|
507
|
+
|
|
508
|
+
# Validate references and dependencies with step context
|
|
509
|
+
step_context: dict[str, Any] = {} # Track available data sources
|
|
510
|
+
ref_errors = self._validate_references_with_context(
|
|
511
|
+
recipe.root, step_context
|
|
512
|
+
)
|
|
513
|
+
errors.extend(ref_errors)
|
|
514
|
+
|
|
515
|
+
# Validate input/output schemas
|
|
516
|
+
schema_errors = self._validate_schemas(recipe.root)
|
|
517
|
+
errors.extend(schema_errors)
|
|
518
|
+
|
|
519
|
+
# Validate expressions
|
|
520
|
+
expr_errors = self._validate_expressions(recipe.root)
|
|
521
|
+
errors.extend(expr_errors)
|
|
522
|
+
|
|
523
|
+
# Validate array mappings and data pill structures
|
|
524
|
+
array_errors = self._validate_array_mappings(recipe.root)
|
|
525
|
+
errors.extend(array_errors)
|
|
526
|
+
|
|
527
|
+
# Validate input modes and formulas
|
|
528
|
+
input_mode_errors = self._validate_input_modes(recipe.root)
|
|
529
|
+
errors.extend(input_mode_errors)
|
|
530
|
+
|
|
531
|
+
# Validate config section coverage
|
|
532
|
+
config_errors = self._validate_config_coverage(
|
|
533
|
+
recipe.root, recipe_data.get("config", [])
|
|
534
|
+
)
|
|
535
|
+
errors.extend(config_errors)
|
|
536
|
+
|
|
537
|
+
except Exception as e:
|
|
538
|
+
errors.append(
|
|
539
|
+
ValidationError(
|
|
540
|
+
message=f"Recipe parsing failed: {str(e)}",
|
|
541
|
+
error_type=ErrorType.SYNTAX_INVALID,
|
|
542
|
+
)
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
return ValidationResult(
|
|
546
|
+
is_valid=len(errors) == 0, errors=errors, warnings=warnings or []
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
async def _load_builtin_connectors(self) -> None:
|
|
550
|
+
"""Load connector metadata from cache or Workato API"""
|
|
551
|
+
# Try to load from cache first
|
|
552
|
+
if self._load_cached_connectors():
|
|
553
|
+
return # Successfully loaded from cache
|
|
554
|
+
|
|
555
|
+
# Fetch platform connectors with pagination
|
|
556
|
+
all_connectors: list[PlatformConnector] = []
|
|
557
|
+
page: int = 1
|
|
558
|
+
per_page = 100
|
|
559
|
+
|
|
560
|
+
while True:
|
|
561
|
+
response = (
|
|
562
|
+
await self.workato_api_client.connectors_api.list_platform_connectors(
|
|
563
|
+
page=page,
|
|
564
|
+
per_page=per_page,
|
|
565
|
+
)
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
connectors = response.items
|
|
569
|
+
|
|
570
|
+
if not connectors:
|
|
571
|
+
break
|
|
572
|
+
|
|
573
|
+
all_connectors.extend(connectors)
|
|
574
|
+
|
|
575
|
+
# If we got fewer than per_page results, we're on the last page
|
|
576
|
+
if len(connectors) < per_page:
|
|
577
|
+
break
|
|
578
|
+
|
|
579
|
+
page += 1
|
|
580
|
+
|
|
581
|
+
for platform_connector in all_connectors:
|
|
582
|
+
provider_name = platform_connector.name.lower()
|
|
583
|
+
self.known_adapters.add(provider_name)
|
|
584
|
+
|
|
585
|
+
# Convert List[ConnectorAction] to dict for JSON serialization
|
|
586
|
+
# and validation. The validation logic expects dicts (calls .keys()),
|
|
587
|
+
# so we convert: List[ConnectorAction] -> {action.name: action.to_dict()}
|
|
588
|
+
self.connector_metadata[provider_name] = {
|
|
589
|
+
"type": "platform",
|
|
590
|
+
"name": platform_connector.name,
|
|
591
|
+
"deprecated": platform_connector.deprecated,
|
|
592
|
+
"categories": platform_connector.categories,
|
|
593
|
+
"triggers": {
|
|
594
|
+
trigger.name: trigger.to_dict()
|
|
595
|
+
for trigger in platform_connector.triggers
|
|
596
|
+
},
|
|
597
|
+
"actions": {
|
|
598
|
+
action.name: action.to_dict()
|
|
599
|
+
for action in platform_connector.actions
|
|
600
|
+
},
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
# Fetch custom connectors
|
|
604
|
+
custom_connectors_response = (
|
|
605
|
+
await self.workato_api_client.connectors_api.list_custom_connectors()
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
for custom_connector in custom_connectors_response.result:
|
|
609
|
+
provider_name = custom_connector.name.lower()
|
|
610
|
+
code_response = (
|
|
611
|
+
await self.workato_api_client.connectors_api.get_custom_connector_code(
|
|
612
|
+
custom_connector.id
|
|
613
|
+
)
|
|
614
|
+
)
|
|
615
|
+
self.known_adapters.add(provider_name)
|
|
616
|
+
# Note: Custom connector trigger/action parsing is not implemented
|
|
617
|
+
# Using empty dicts for consistency with platform connector structure
|
|
618
|
+
self.connector_metadata[provider_name] = {
|
|
619
|
+
"type": "custom",
|
|
620
|
+
"name": custom_connector.name,
|
|
621
|
+
"code": code_response.data.code,
|
|
622
|
+
"categories": [], # Custom connectors don't have categories
|
|
623
|
+
"triggers": {}, # Not Implemented - would need to parse code
|
|
624
|
+
"actions": {}, # Not Implemented - would need to parse code
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
# Save to cache for next time
|
|
628
|
+
self._save_connectors_to_cache()
|
|
629
|
+
|
|
630
|
+
def _validate_providers(self, line: RecipeLine) -> list[ValidationError]:
|
|
631
|
+
"""Validate provider names and trigger/action names"""
|
|
632
|
+
errors = []
|
|
633
|
+
|
|
634
|
+
# Validate provider if present
|
|
635
|
+
if line.provider:
|
|
636
|
+
provider_name = line.provider.lower()
|
|
637
|
+
|
|
638
|
+
# Check if provider is known
|
|
639
|
+
if self.known_adapters and provider_name not in self.known_adapters:
|
|
640
|
+
errors.append(
|
|
641
|
+
ValidationError(
|
|
642
|
+
message=(
|
|
643
|
+
f"Unknown provider '{line.provider}'. Provider not found in"
|
|
644
|
+
" available connectors."
|
|
645
|
+
),
|
|
646
|
+
error_type=ErrorType.INPUT_INVALID_BY_ADAPTER,
|
|
647
|
+
field_label="provider",
|
|
648
|
+
line_number=line.number,
|
|
649
|
+
)
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
# Validate trigger/action name if we have metadata
|
|
653
|
+
if line.keyword not in [Keyword.TRIGGER, Keyword.ACTION]:
|
|
654
|
+
return errors
|
|
655
|
+
|
|
656
|
+
if provider_name in self.connector_metadata:
|
|
657
|
+
connector_meta = self.connector_metadata[provider_name]
|
|
658
|
+
|
|
659
|
+
if line.keyword == Keyword.TRIGGER:
|
|
660
|
+
available_triggers = connector_meta.get("triggers", {})
|
|
661
|
+
if available_triggers and line.name not in available_triggers:
|
|
662
|
+
trigger_names = ", ".join(list(available_triggers.keys()))
|
|
663
|
+
errors.append(
|
|
664
|
+
ValidationError(
|
|
665
|
+
message=(
|
|
666
|
+
f"Unknown trigger '{line.name}' for provider "
|
|
667
|
+
f"'{line.provider}'. Available "
|
|
668
|
+
f"triggers: {trigger_names}"
|
|
669
|
+
),
|
|
670
|
+
error_type=ErrorType.INPUT_INVALID_BY_ADAPTER,
|
|
671
|
+
field_label="name",
|
|
672
|
+
line_number=line.number,
|
|
673
|
+
)
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
elif line.keyword == Keyword.ACTION:
|
|
677
|
+
available_actions = connector_meta.get("actions", {})
|
|
678
|
+
if available_actions and line.name not in available_actions:
|
|
679
|
+
action_names = ", ".join(list(available_actions.keys()))
|
|
680
|
+
errors.append(
|
|
681
|
+
ValidationError(
|
|
682
|
+
message=(
|
|
683
|
+
f"Unknown action '{line.name}' for provider "
|
|
684
|
+
f"'{line.provider}'. Available "
|
|
685
|
+
f"actions: {action_names}"
|
|
686
|
+
),
|
|
687
|
+
error_type=ErrorType.INPUT_INVALID_BY_ADAPTER,
|
|
688
|
+
field_label="name",
|
|
689
|
+
line_number=line.number,
|
|
690
|
+
)
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
# Recursively validate children
|
|
694
|
+
if line.block:
|
|
695
|
+
for child in line.block:
|
|
696
|
+
child_errors = self._validate_providers(child)
|
|
697
|
+
errors.extend(child_errors)
|
|
698
|
+
|
|
699
|
+
return errors
|
|
700
|
+
|
|
701
|
+
def _validate_unique_as_values(self, line: RecipeLine) -> list[ValidationError]:
|
|
702
|
+
"""Validate that all 'as' values are unique across the recipe"""
|
|
703
|
+
errors = []
|
|
704
|
+
# as_value -> {"line_number": int, "provider": str, "keyword": str}
|
|
705
|
+
as_tracker: dict[str, dict[str, Any]] = {}
|
|
706
|
+
|
|
707
|
+
def collect_as_values(
|
|
708
|
+
current_line: RecipeLine,
|
|
709
|
+
path: list[int] | None = None,
|
|
710
|
+
) -> None:
|
|
711
|
+
"""Recursively collect all 'as' values and their locations"""
|
|
712
|
+
if path is None:
|
|
713
|
+
path = []
|
|
714
|
+
|
|
715
|
+
current_path = path + [current_line.number]
|
|
716
|
+
|
|
717
|
+
# Check if this line has an 'as' value
|
|
718
|
+
if current_line.as_:
|
|
719
|
+
as_value = current_line.as_
|
|
720
|
+
|
|
721
|
+
if as_value in as_tracker:
|
|
722
|
+
# Found duplicate!
|
|
723
|
+
existing_info = as_tracker[as_value]
|
|
724
|
+
current_keyword = (
|
|
725
|
+
current_line.keyword.value
|
|
726
|
+
if hasattr(current_line.keyword, "value")
|
|
727
|
+
else str(current_line.keyword)
|
|
728
|
+
)
|
|
729
|
+
|
|
730
|
+
errors.append(
|
|
731
|
+
ValidationError(
|
|
732
|
+
message=(
|
|
733
|
+
f"Duplicate 'as' value '{as_value}' found. First used "
|
|
734
|
+
f"in {existing_info['keyword']} step "
|
|
735
|
+
f"{existing_info['line_number']} "
|
|
736
|
+
f"({existing_info['provider']}), also used in "
|
|
737
|
+
f"{current_keyword} step {current_line.number} "
|
|
738
|
+
f"({current_line.provider})"
|
|
739
|
+
),
|
|
740
|
+
error_type=ErrorType.INPUT_INVALID_BY_ADAPTER,
|
|
741
|
+
field_path=["as"],
|
|
742
|
+
line_number=current_line.number,
|
|
743
|
+
)
|
|
744
|
+
)
|
|
745
|
+
else:
|
|
746
|
+
# Record this 'as' value
|
|
747
|
+
current_keyword = (
|
|
748
|
+
current_line.keyword.value
|
|
749
|
+
if hasattr(current_line.keyword, "value")
|
|
750
|
+
else str(current_line.keyword)
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
as_tracker[as_value] = {
|
|
754
|
+
"line_number": current_line.number,
|
|
755
|
+
"provider": current_line.provider or "unknown",
|
|
756
|
+
"keyword": current_keyword,
|
|
757
|
+
"path": current_path,
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
# Recursively check children
|
|
761
|
+
if current_line.block:
|
|
762
|
+
for child in current_line.block:
|
|
763
|
+
collect_as_values(child, current_path)
|
|
764
|
+
|
|
765
|
+
# Collect all 'as' values starting from root
|
|
766
|
+
collect_as_values(line)
|
|
767
|
+
|
|
768
|
+
return errors
|
|
769
|
+
|
|
770
|
+
def _validate_references_with_context(
|
|
771
|
+
self, line: RecipeLine, step_context: dict[str, Any]
|
|
772
|
+
) -> list[ValidationError]:
|
|
773
|
+
"""Validate references between steps with context tracking"""
|
|
774
|
+
errors: list[ValidationError] = []
|
|
775
|
+
|
|
776
|
+
# Add current step to context if it has an 'as' name
|
|
777
|
+
# Note: Duplicate 'as' values are now caught early by _validate_unique_as_values
|
|
778
|
+
if line.as_ and line.provider:
|
|
779
|
+
step_context[line.as_] = {
|
|
780
|
+
"provider": line.provider,
|
|
781
|
+
"keyword": line.keyword.value
|
|
782
|
+
if hasattr(line.keyword, "value")
|
|
783
|
+
else str(line.keyword),
|
|
784
|
+
"number": line.number,
|
|
785
|
+
"name": line.name,
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
# Validate data pill references in input
|
|
789
|
+
if line.input:
|
|
790
|
+
pill_errors = self._validate_data_pill_references_with_context(
|
|
791
|
+
line.input, line.number, step_context
|
|
792
|
+
)
|
|
793
|
+
errors.extend(pill_errors)
|
|
794
|
+
|
|
795
|
+
# Validate data pill references in conditional input
|
|
796
|
+
# (for if/elsif/while_condition)
|
|
797
|
+
if (
|
|
798
|
+
hasattr(line, "input")
|
|
799
|
+
and line.input
|
|
800
|
+
and line.keyword in [Keyword.IF, Keyword.ELSIF]
|
|
801
|
+
):
|
|
802
|
+
pill_errors = self._validate_data_pill_references_with_context(
|
|
803
|
+
line.input, line.number, step_context
|
|
804
|
+
)
|
|
805
|
+
errors.extend(pill_errors)
|
|
806
|
+
|
|
807
|
+
# Validate children with updated context
|
|
808
|
+
if line.block:
|
|
809
|
+
# For repeat blocks, add special repeat context
|
|
810
|
+
if line.keyword == Keyword.REPEAT and line.as_:
|
|
811
|
+
repeat_context = step_context.copy()
|
|
812
|
+
repeat_context[line.as_] = {
|
|
813
|
+
"provider": "repeat",
|
|
814
|
+
"keyword": "repeat",
|
|
815
|
+
"number": line.number,
|
|
816
|
+
"name": "repeat_processor",
|
|
817
|
+
}
|
|
818
|
+
for child in line.block:
|
|
819
|
+
child_errors = self._validate_references_with_context(
|
|
820
|
+
child, repeat_context
|
|
821
|
+
)
|
|
822
|
+
errors.extend(child_errors)
|
|
823
|
+
else:
|
|
824
|
+
for child in line.block:
|
|
825
|
+
child_errors = self._validate_references_with_context(
|
|
826
|
+
child, step_context
|
|
827
|
+
)
|
|
828
|
+
errors.extend(child_errors)
|
|
829
|
+
|
|
830
|
+
return errors
|
|
831
|
+
|
|
832
|
+
def _validate_data_pill_references_with_context(
|
|
833
|
+
self, input_data: dict[str, Any], line_number: int, step_context: dict[str, Any]
|
|
834
|
+
) -> list[ValidationError]:
|
|
835
|
+
"""Validate data pill references in input fields with step context"""
|
|
836
|
+
errors: list[ValidationError] = []
|
|
837
|
+
|
|
838
|
+
def check_value(value: Any, field_path: list[str]) -> None:
|
|
839
|
+
if isinstance(value, str):
|
|
840
|
+
# Look for data pill patterns like #{_('data.provider.as.field')}
|
|
841
|
+
pill_matches = self._extract_data_pills(value)
|
|
842
|
+
for pill in pill_matches:
|
|
843
|
+
if not self._is_valid_data_pill(pill):
|
|
844
|
+
errors.append(
|
|
845
|
+
ValidationError(
|
|
846
|
+
message=(
|
|
847
|
+
f"Invalid data pill format: '{pill}'. Expected "
|
|
848
|
+
f"format: data.provider.as.field"
|
|
849
|
+
),
|
|
850
|
+
error_type=ErrorType.INPUT_UNKNOWN_DATA_PILL,
|
|
851
|
+
field_path=field_path,
|
|
852
|
+
line_number=line_number,
|
|
853
|
+
)
|
|
854
|
+
)
|
|
855
|
+
else:
|
|
856
|
+
# Validate cross-reference
|
|
857
|
+
validation_error = self._validate_data_pill_cross_reference(
|
|
858
|
+
pill, line_number, step_context, field_path
|
|
859
|
+
)
|
|
860
|
+
if validation_error:
|
|
861
|
+
errors.append(validation_error)
|
|
862
|
+
elif isinstance(value, dict):
|
|
863
|
+
for key, val in value.items():
|
|
864
|
+
check_value(val, field_path + [key])
|
|
865
|
+
elif type(value).__name__ == "list":
|
|
866
|
+
for i, val in enumerate(value):
|
|
867
|
+
check_value(val, field_path + [str(i)])
|
|
868
|
+
|
|
869
|
+
check_value(input_data, [])
|
|
870
|
+
return errors
|
|
871
|
+
|
|
872
|
+
def _validate_data_pill_cross_reference(
|
|
873
|
+
self,
|
|
874
|
+
pill: str,
|
|
875
|
+
line_number: int,
|
|
876
|
+
step_context: dict[str, Any],
|
|
877
|
+
field_path: list[str],
|
|
878
|
+
) -> ValidationError | None:
|
|
879
|
+
"""Validate that data pill references point to valid previous steps"""
|
|
880
|
+
# Parse data pill: data.provider.as.field_path
|
|
881
|
+
parts = pill.split(".")
|
|
882
|
+
if len(parts) < 4:
|
|
883
|
+
return None
|
|
884
|
+
|
|
885
|
+
provider = parts[1]
|
|
886
|
+
as_name = parts[2]
|
|
887
|
+
|
|
888
|
+
# Check if the referenced step exists
|
|
889
|
+
if as_name not in step_context:
|
|
890
|
+
available_steps = list(step_context.keys())
|
|
891
|
+
suggestion = (
|
|
892
|
+
f" Available steps: {', '.join(available_steps[:5])}"
|
|
893
|
+
if available_steps
|
|
894
|
+
else ""
|
|
895
|
+
)
|
|
896
|
+
return ValidationError(
|
|
897
|
+
message=f"Data pill references unknown step '{as_name}'.{suggestion}",
|
|
898
|
+
error_type=ErrorType.INPUT_UNKNOWN_DATA_PILL,
|
|
899
|
+
field_path=field_path,
|
|
900
|
+
line_number=line_number,
|
|
901
|
+
)
|
|
902
|
+
|
|
903
|
+
# Validate provider matches
|
|
904
|
+
step_info = step_context[as_name]
|
|
905
|
+
if step_info["provider"] != provider:
|
|
906
|
+
return ValidationError(
|
|
907
|
+
message=(
|
|
908
|
+
f"Data pill provider mismatch: step '{as_name}' uses "
|
|
909
|
+
f"provider '{step_info['provider']}', not '{provider}'"
|
|
910
|
+
),
|
|
911
|
+
error_type=ErrorType.INPUT_UNKNOWN_DATA_PILL,
|
|
912
|
+
field_path=field_path,
|
|
913
|
+
line_number=line_number,
|
|
914
|
+
)
|
|
915
|
+
|
|
916
|
+
return None
|
|
917
|
+
|
|
918
|
+
def _validate_data_pill_references(
|
|
919
|
+
self, input_data: dict[str, Any], line_number: int
|
|
920
|
+
) -> list[ValidationError]:
|
|
921
|
+
"""Legacy method - kept for compatibility"""
|
|
922
|
+
return self._validate_data_pill_references_with_context(
|
|
923
|
+
input_data, line_number, {}
|
|
924
|
+
)
|
|
925
|
+
|
|
926
|
+
def _extract_data_pills(self, text: str | None) -> list[str]:
|
|
927
|
+
"""Extract data pill references from text"""
|
|
928
|
+
import re
|
|
929
|
+
|
|
930
|
+
if not isinstance(text, str):
|
|
931
|
+
return []
|
|
932
|
+
|
|
933
|
+
# Match Workato data pill formats: #{_dp('data.path')}
|
|
934
|
+
# or legacy #{_('data.path')}
|
|
935
|
+
pattern = r"#\{_(?:dp)?\(['\"]([^'\"]+)['\"]\)\}"
|
|
936
|
+
return re.findall(pattern, text)
|
|
937
|
+
|
|
938
|
+
def _is_valid_data_pill(self, pill: str) -> bool:
|
|
939
|
+
"""Check if data pill reference is valid"""
|
|
940
|
+
# Validate data pill format: data.provider.as.field_path
|
|
941
|
+
if not pill.startswith("data."):
|
|
942
|
+
return False
|
|
943
|
+
|
|
944
|
+
parts = pill.split(".")
|
|
945
|
+
if len(parts) < 4: # Need at least: data.provider.as.field
|
|
946
|
+
return False
|
|
947
|
+
|
|
948
|
+
# Extract components
|
|
949
|
+
prefix = parts[0] # should be 'data'
|
|
950
|
+
provider = parts[1]
|
|
951
|
+
as_name = parts[2]
|
|
952
|
+
# field_path is parts[3:]
|
|
953
|
+
|
|
954
|
+
return prefix == "data" and provider is not None and as_name is not None
|
|
955
|
+
|
|
956
|
+
def _validate_schemas(self, line: RecipeLine) -> list[ValidationError]:
|
|
957
|
+
"""Validate input/output schemas"""
|
|
958
|
+
errors: list[ValidationError] = []
|
|
959
|
+
|
|
960
|
+
# Validate children
|
|
961
|
+
if line.block:
|
|
962
|
+
for child in line.block:
|
|
963
|
+
child_errors = self._validate_schemas(child)
|
|
964
|
+
errors.extend(child_errors)
|
|
965
|
+
|
|
966
|
+
return errors
|
|
967
|
+
|
|
968
|
+
def _validate_expressions(self, line: RecipeLine) -> list[ValidationError]:
|
|
969
|
+
"""Validate expressions in recipe"""
|
|
970
|
+
errors: list[ValidationError] = []
|
|
971
|
+
|
|
972
|
+
# Validate expressions in input fields
|
|
973
|
+
if line.input:
|
|
974
|
+
expr_errors = self._validate_input_expressions(line.input, line.number)
|
|
975
|
+
errors.extend(expr_errors)
|
|
976
|
+
|
|
977
|
+
# Validate children
|
|
978
|
+
if line.block:
|
|
979
|
+
for child in line.block:
|
|
980
|
+
child_errors = self._validate_expressions(child)
|
|
981
|
+
errors.extend(child_errors)
|
|
982
|
+
|
|
983
|
+
return errors
|
|
984
|
+
|
|
985
|
+
def _validate_input_expressions(
|
|
986
|
+
self, input_data: dict[str, Any], line_number: int
|
|
987
|
+
) -> list[ValidationError]:
|
|
988
|
+
"""Validate expressions in input fields"""
|
|
989
|
+
errors = []
|
|
990
|
+
|
|
991
|
+
def check_expression(value: Any, field_path: list[str]) -> None:
|
|
992
|
+
if (
|
|
993
|
+
isinstance(value, str)
|
|
994
|
+
and self._is_expression(value)
|
|
995
|
+
and not self._is_valid_expression(value)
|
|
996
|
+
):
|
|
997
|
+
errors.append(
|
|
998
|
+
ValidationError(
|
|
999
|
+
message=f"Invalid expression syntax: {value}",
|
|
1000
|
+
error_type=ErrorType.INPUT_EXPR_INVALID,
|
|
1001
|
+
field_path=field_path,
|
|
1002
|
+
line_number=line_number,
|
|
1003
|
+
)
|
|
1004
|
+
)
|
|
1005
|
+
elif isinstance(value, dict):
|
|
1006
|
+
for key, val in value.items():
|
|
1007
|
+
check_expression(val, field_path + [key])
|
|
1008
|
+
elif type(value).__name__ == "list":
|
|
1009
|
+
for i, val in enumerate(value):
|
|
1010
|
+
check_expression(val, field_path + [str(i)])
|
|
1011
|
+
|
|
1012
|
+
check_expression(input_data, [])
|
|
1013
|
+
return errors
|
|
1014
|
+
|
|
1015
|
+
def _is_expression(self, text: str | None) -> bool:
|
|
1016
|
+
"""Check if text is an expression"""
|
|
1017
|
+
if not isinstance(text, str):
|
|
1018
|
+
return False
|
|
1019
|
+
|
|
1020
|
+
stripped = text.strip()
|
|
1021
|
+
# Basic expression detection covering formulas, Jinja, and data pills
|
|
1022
|
+
return stripped.startswith("=") or "{{" in stripped or "#{_" in stripped
|
|
1023
|
+
|
|
1024
|
+
def _is_valid_expression(self, expression: str) -> bool:
|
|
1025
|
+
"""Validate expression syntax"""
|
|
1026
|
+
# Basic expression validation - could be enhanced
|
|
1027
|
+
# For now, just check if it's not empty
|
|
1028
|
+
return bool(expression and expression.strip())
|
|
1029
|
+
|
|
1030
|
+
def _validate_config_coverage(
|
|
1031
|
+
self, line: RecipeLine, config: list[dict[str, Any]]
|
|
1032
|
+
) -> list[ValidationError]:
|
|
1033
|
+
"""Validate that all providers used in recipe have config entries"""
|
|
1034
|
+
errors = []
|
|
1035
|
+
|
|
1036
|
+
# Collect all providers used in the recipe
|
|
1037
|
+
used_providers: set[str] = set()
|
|
1038
|
+
self._collect_providers(line, used_providers)
|
|
1039
|
+
|
|
1040
|
+
# Collect providers declared in config
|
|
1041
|
+
config_providers: set[str] = set()
|
|
1042
|
+
for config_entry in config:
|
|
1043
|
+
if isinstance(config_entry, dict) and config_entry.get("provider"):
|
|
1044
|
+
config_providers.add(config_entry["provider"])
|
|
1045
|
+
|
|
1046
|
+
builtin_connectors = {
|
|
1047
|
+
key
|
|
1048
|
+
for key, meta in self.connector_metadata.items()
|
|
1049
|
+
if key != "workato_app" and "Workato" not in meta["categories"]
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
# Check for missing config entries (excluding built-in connectors)
|
|
1053
|
+
missing_providers = used_providers - config_providers - builtin_connectors
|
|
1054
|
+
for provider in missing_providers:
|
|
1055
|
+
errors.append(
|
|
1056
|
+
ValidationError(
|
|
1057
|
+
message=(
|
|
1058
|
+
f"Provider '{provider}' is used in recipe but missing from "
|
|
1059
|
+
f"config section"
|
|
1060
|
+
),
|
|
1061
|
+
error_type=ErrorType.INPUT_INVALID_BY_ADAPTER,
|
|
1062
|
+
field_path=["config"],
|
|
1063
|
+
)
|
|
1064
|
+
)
|
|
1065
|
+
|
|
1066
|
+
return errors
|
|
1067
|
+
|
|
1068
|
+
def _collect_providers(self, line: RecipeLine, providers: set[str]) -> None:
|
|
1069
|
+
"""Recursively collect all providers used in the recipe"""
|
|
1070
|
+
if line.provider:
|
|
1071
|
+
providers.add(line.provider)
|
|
1072
|
+
|
|
1073
|
+
if line.block:
|
|
1074
|
+
for child in line.block:
|
|
1075
|
+
self._collect_providers(child, providers)
|
|
1076
|
+
|
|
1077
|
+
def _validate_generic_schema_usage(self, line: RecipeLine) -> list[ValidationError]:
|
|
1078
|
+
"""Generic schema validation based on actual usage patterns for any provider"""
|
|
1079
|
+
errors = []
|
|
1080
|
+
|
|
1081
|
+
# Check if this step is referenced by others - needs extended_output_schema
|
|
1082
|
+
if self._step_is_referenced(line) and (
|
|
1083
|
+
not hasattr(line, "extended_output_schema")
|
|
1084
|
+
or not line.extended_output_schema
|
|
1085
|
+
):
|
|
1086
|
+
errors.append(
|
|
1087
|
+
ValidationError(
|
|
1088
|
+
message=(
|
|
1089
|
+
f"Step {line.number} ({line.provider}) requires "
|
|
1090
|
+
f"'extended_output_schema' because it's referenced by "
|
|
1091
|
+
f"other steps"
|
|
1092
|
+
),
|
|
1093
|
+
error_type=ErrorType.EXTENDED_SCHEMA_INVALID,
|
|
1094
|
+
line_number=line.number,
|
|
1095
|
+
field_path=["extended_output_schema"],
|
|
1096
|
+
)
|
|
1097
|
+
)
|
|
1098
|
+
|
|
1099
|
+
return errors
|
|
1100
|
+
|
|
1101
|
+
def _is_control_block(self, line: RecipeLine) -> bool:
|
|
1102
|
+
"""Check if a step is a control block that doesn't need input schemas"""
|
|
1103
|
+
# Control blocks are if, while, repeat, etc. - they use data pills for
|
|
1104
|
+
# conditions but don't process input data that needs schemas
|
|
1105
|
+
return line.keyword in [
|
|
1106
|
+
Keyword.IF,
|
|
1107
|
+
Keyword.ELSIF,
|
|
1108
|
+
Keyword.WHILE_CONDITION,
|
|
1109
|
+
Keyword.REPEAT,
|
|
1110
|
+
]
|
|
1111
|
+
|
|
1112
|
+
def _step_uses_data_pills(self, line: RecipeLine) -> bool:
|
|
1113
|
+
"""Check if a step uses data pill references in its input"""
|
|
1114
|
+
if not line.input:
|
|
1115
|
+
return False
|
|
1116
|
+
|
|
1117
|
+
# Convert input to string to search for data pill patterns
|
|
1118
|
+
input_str = str(line.input)
|
|
1119
|
+
|
|
1120
|
+
# Look for data pill patterns: #{_('...')} or #{...}
|
|
1121
|
+
import re
|
|
1122
|
+
|
|
1123
|
+
data_pill_patterns = [
|
|
1124
|
+
r"#\{\s*_\([^)]+\)\s*\}", # #{_('data.provider.as.field')}
|
|
1125
|
+
r"#\{[^}]+\}", # #{simple_reference}
|
|
1126
|
+
]
|
|
1127
|
+
|
|
1128
|
+
return any(re.search(pattern, input_str) for pattern in data_pill_patterns)
|
|
1129
|
+
|
|
1130
|
+
def _step_is_referenced(self, target_line: RecipeLine) -> bool:
|
|
1131
|
+
"""Check if this step is referenced by other steps via data pills"""
|
|
1132
|
+
if not target_line.as_:
|
|
1133
|
+
return False # Can't be referenced without an 'as' value
|
|
1134
|
+
|
|
1135
|
+
if not self.current_recipe_root:
|
|
1136
|
+
return False
|
|
1137
|
+
|
|
1138
|
+
# We need to check the entire recipe tree for references to this step
|
|
1139
|
+
# This requires access to the root of the recipe
|
|
1140
|
+
return self._find_references_to_step(self.current_recipe_root, target_line)
|
|
1141
|
+
|
|
1142
|
+
def _find_references_to_step(
|
|
1143
|
+
self, search_root: RecipeLine, target_line: RecipeLine
|
|
1144
|
+
) -> bool:
|
|
1145
|
+
"""Recursively search for data pill references to a specific step"""
|
|
1146
|
+
if not target_line.as_ or not target_line.provider:
|
|
1147
|
+
return False
|
|
1148
|
+
|
|
1149
|
+
# Pattern to look for: data.{provider}.{as_value}
|
|
1150
|
+
reference_pattern = f"data.{target_line.provider}.{target_line.as_}"
|
|
1151
|
+
|
|
1152
|
+
return self._search_for_reference_pattern(search_root, reference_pattern)
|
|
1153
|
+
|
|
1154
|
+
def _search_for_reference_pattern(self, line: RecipeLine, pattern: str) -> bool:
|
|
1155
|
+
"""Recursively search for a data pill reference pattern"""
|
|
1156
|
+
# Check current line's input for the pattern
|
|
1157
|
+
if line.input:
|
|
1158
|
+
input_str = str(line.input)
|
|
1159
|
+
if pattern in input_str:
|
|
1160
|
+
return True
|
|
1161
|
+
|
|
1162
|
+
# Check children
|
|
1163
|
+
if line.block:
|
|
1164
|
+
for child in line.block:
|
|
1165
|
+
if self._search_for_reference_pattern(child, pattern):
|
|
1166
|
+
return True
|
|
1167
|
+
|
|
1168
|
+
return False
|
|
1169
|
+
|
|
1170
|
+
def _validate_array_mappings(self, line: RecipeLine) -> list[ValidationError]:
|
|
1171
|
+
"""Validate array mapping structures and data pill patterns"""
|
|
1172
|
+
errors = []
|
|
1173
|
+
|
|
1174
|
+
if line.input:
|
|
1175
|
+
errors.extend(self._validate_data_pill_structures(line.input, line.number))
|
|
1176
|
+
errors.extend(self._validate_array_consistency(line.input, line.number))
|
|
1177
|
+
|
|
1178
|
+
# Check children
|
|
1179
|
+
if line.block:
|
|
1180
|
+
for child in line.block:
|
|
1181
|
+
child_errors = self._validate_array_mappings(child)
|
|
1182
|
+
errors.extend(child_errors)
|
|
1183
|
+
|
|
1184
|
+
return errors
|
|
1185
|
+
|
|
1186
|
+
def _validate_data_pill_structures(
|
|
1187
|
+
self, input_data: dict[str, Any], line_number: int
|
|
1188
|
+
) -> list[ValidationError]:
|
|
1189
|
+
"""Validate data pill JSON structures in _dp() patterns"""
|
|
1190
|
+
errors: list[ValidationError] = []
|
|
1191
|
+
import json
|
|
1192
|
+
import re
|
|
1193
|
+
|
|
1194
|
+
def check_value(value: Any, field_path: list[str] | None = None) -> None:
|
|
1195
|
+
if field_path is None:
|
|
1196
|
+
field_path = []
|
|
1197
|
+
if isinstance(value, str):
|
|
1198
|
+
# Find all _dp() patterns
|
|
1199
|
+
dp_patterns = re.findall(r"#\{_dp\(\'([^\']+)\'\)\}", value)
|
|
1200
|
+
for dp_json in dp_patterns:
|
|
1201
|
+
# Check if this is simple syntax (data.provider.as.field)
|
|
1202
|
+
if dp_json.startswith("data."):
|
|
1203
|
+
# Simple syntax - validate the data path structure
|
|
1204
|
+
path_parts = dp_json.split(".")
|
|
1205
|
+
if len(path_parts) < 4: # data.provider.as.field (minimum)
|
|
1206
|
+
errors.append(
|
|
1207
|
+
ValidationError(
|
|
1208
|
+
message=(
|
|
1209
|
+
f"Simple data pill syntax must have at least 4 "
|
|
1210
|
+
"parts: data.provider.as.field in step "
|
|
1211
|
+
f"{line_number}"
|
|
1212
|
+
),
|
|
1213
|
+
error_type=ErrorType.INPUT_INVALID_BY_ADAPTER,
|
|
1214
|
+
line_number=line_number,
|
|
1215
|
+
field_path=field_path + ["_dp"],
|
|
1216
|
+
)
|
|
1217
|
+
)
|
|
1218
|
+
else:
|
|
1219
|
+
# Validate provider/as reference exists
|
|
1220
|
+
provider = path_parts[1]
|
|
1221
|
+
as_value = path_parts[2]
|
|
1222
|
+
if not self._step_exists(provider, as_value):
|
|
1223
|
+
errors.append(
|
|
1224
|
+
ValidationError(
|
|
1225
|
+
message=(
|
|
1226
|
+
f"Simple data pill references non-existent "
|
|
1227
|
+
"step: provider='{provider}', "
|
|
1228
|
+
f"as='{as_value}' in step {line_number}"
|
|
1229
|
+
),
|
|
1230
|
+
error_type=ErrorType.INPUT_INVALID_BY_ADAPTER,
|
|
1231
|
+
line_number=line_number,
|
|
1232
|
+
field_path=field_path + ["_dp"],
|
|
1233
|
+
)
|
|
1234
|
+
)
|
|
1235
|
+
else:
|
|
1236
|
+
# Complex JSON syntax - parse and validate
|
|
1237
|
+
try:
|
|
1238
|
+
# Parse the JSON structure
|
|
1239
|
+
dp_data = json.loads(dp_json)
|
|
1240
|
+
|
|
1241
|
+
# Validate required fields based on pill_type
|
|
1242
|
+
pill_type = dp_data.get("pill_type")
|
|
1243
|
+
|
|
1244
|
+
# All data pills must have pill_type
|
|
1245
|
+
if not pill_type:
|
|
1246
|
+
errors.append(
|
|
1247
|
+
ValidationError(
|
|
1248
|
+
message=(
|
|
1249
|
+
"Data pill missing required field "
|
|
1250
|
+
f"'pill_type' in step {line_number}"
|
|
1251
|
+
),
|
|
1252
|
+
error_type=ErrorType.INPUT_INVALID_BY_ADAPTER,
|
|
1253
|
+
line_number=line_number,
|
|
1254
|
+
field_path=field_path + ["_dp"],
|
|
1255
|
+
)
|
|
1256
|
+
)
|
|
1257
|
+
else:
|
|
1258
|
+
# Validate based on pill_type
|
|
1259
|
+
if pill_type in ("output", "refs"):
|
|
1260
|
+
# Output/refs pills need provider, line, path
|
|
1261
|
+
required = ["provider", "line", "path"]
|
|
1262
|
+
for field in required:
|
|
1263
|
+
if field not in dp_data:
|
|
1264
|
+
errors.append(
|
|
1265
|
+
ValidationError(
|
|
1266
|
+
message=(
|
|
1267
|
+
f"Data pill with pill_type "
|
|
1268
|
+
f"'{pill_type}' missing "
|
|
1269
|
+
f"required field '{field}' "
|
|
1270
|
+
f"in step {line_number}"
|
|
1271
|
+
),
|
|
1272
|
+
error_type=(
|
|
1273
|
+
ErrorType.INPUT_INVALID_BY_ADAPTER
|
|
1274
|
+
),
|
|
1275
|
+
line_number=line_number,
|
|
1276
|
+
field_path=field_path + ["_dp"],
|
|
1277
|
+
)
|
|
1278
|
+
)
|
|
1279
|
+
elif pill_type == "project_property":
|
|
1280
|
+
# Project property pills need property_name
|
|
1281
|
+
if "property_name" not in dp_data:
|
|
1282
|
+
errors.append(
|
|
1283
|
+
ValidationError(
|
|
1284
|
+
message=(
|
|
1285
|
+
"Data pill with pill_type "
|
|
1286
|
+
"'project_property' missing "
|
|
1287
|
+
f"required field 'property_name' "
|
|
1288
|
+
f"in step {line_number}"
|
|
1289
|
+
),
|
|
1290
|
+
error_type=(
|
|
1291
|
+
ErrorType.INPUT_INVALID_BY_ADAPTER
|
|
1292
|
+
),
|
|
1293
|
+
line_number=line_number,
|
|
1294
|
+
field_path=field_path + ["_dp"],
|
|
1295
|
+
)
|
|
1296
|
+
)
|
|
1297
|
+
# Other pill types (e.g., "lookup", "variable")
|
|
1298
|
+
# can be added here as needed
|
|
1299
|
+
|
|
1300
|
+
# Validate provider/line references exist (only for
|
|
1301
|
+
# output/refs pills)
|
|
1302
|
+
if (
|
|
1303
|
+
pill_type in ("output", "refs")
|
|
1304
|
+
and "provider" in dp_data
|
|
1305
|
+
and "line" in dp_data
|
|
1306
|
+
and not self._step_exists(
|
|
1307
|
+
dp_data["provider"], dp_data["line"]
|
|
1308
|
+
)
|
|
1309
|
+
):
|
|
1310
|
+
errors.append(
|
|
1311
|
+
ValidationError(
|
|
1312
|
+
message=(
|
|
1313
|
+
f"Data pill references non-existent step: "
|
|
1314
|
+
f"provider='{dp_data['provider']}', "
|
|
1315
|
+
f"line='{dp_data['line']}' in step "
|
|
1316
|
+
f"{line_number}"
|
|
1317
|
+
),
|
|
1318
|
+
error_type=ErrorType.INPUT_INVALID_BY_ADAPTER,
|
|
1319
|
+
line_number=line_number,
|
|
1320
|
+
field_path=field_path + ["_dp"],
|
|
1321
|
+
)
|
|
1322
|
+
)
|
|
1323
|
+
|
|
1324
|
+
# Validate path is an array (only for output/refs pills)
|
|
1325
|
+
if (
|
|
1326
|
+
pill_type in ("output", "refs")
|
|
1327
|
+
and "path" in dp_data
|
|
1328
|
+
and not isinstance(dp_data["path"], list)
|
|
1329
|
+
):
|
|
1330
|
+
errors.append(
|
|
1331
|
+
ValidationError(
|
|
1332
|
+
message=(
|
|
1333
|
+
f"Data pill 'path' must be an array in "
|
|
1334
|
+
f"step {line_number}"
|
|
1335
|
+
),
|
|
1336
|
+
error_type=ErrorType.INPUT_INVALID_BY_ADAPTER,
|
|
1337
|
+
line_number=line_number,
|
|
1338
|
+
field_path=field_path + ["_dp", "path"],
|
|
1339
|
+
)
|
|
1340
|
+
)
|
|
1341
|
+
|
|
1342
|
+
except json.JSONDecodeError:
|
|
1343
|
+
errors.append(
|
|
1344
|
+
ValidationError(
|
|
1345
|
+
message=(
|
|
1346
|
+
f"Invalid JSON in data pill _dp() pattern "
|
|
1347
|
+
f"in step {line_number}"
|
|
1348
|
+
),
|
|
1349
|
+
error_type=ErrorType.INPUT_INVALID_BY_ADAPTER,
|
|
1350
|
+
line_number=line_number,
|
|
1351
|
+
field_path=field_path + ["_dp"],
|
|
1352
|
+
)
|
|
1353
|
+
)
|
|
1354
|
+
|
|
1355
|
+
elif isinstance(value, dict):
|
|
1356
|
+
for key, subvalue in value.items():
|
|
1357
|
+
check_value(subvalue, field_path + [key])
|
|
1358
|
+
|
|
1359
|
+
elif isinstance(value, list):
|
|
1360
|
+
for i, subvalue in enumerate(value):
|
|
1361
|
+
check_value(subvalue, field_path + [str(i)])
|
|
1362
|
+
|
|
1363
|
+
check_value(input_data)
|
|
1364
|
+
return errors
|
|
1365
|
+
|
|
1366
|
+
def _validate_array_consistency(
|
|
1367
|
+
self, input_data: dict[str, Any], line_number: int
|
|
1368
|
+
) -> list[ValidationError]:
|
|
1369
|
+
"""Validate array structure consistency (____source + element mappings)"""
|
|
1370
|
+
errors = []
|
|
1371
|
+
|
|
1372
|
+
def check_array_structure(
|
|
1373
|
+
obj: Any, field_path: list[str] | None = None
|
|
1374
|
+
) -> None:
|
|
1375
|
+
if field_path is None:
|
|
1376
|
+
field_path = []
|
|
1377
|
+
if isinstance(obj, dict):
|
|
1378
|
+
# Check if this looks like an array mapping structure
|
|
1379
|
+
if "____source" in obj:
|
|
1380
|
+
source_path = self._extract_path_from_dp(obj["____source"])
|
|
1381
|
+
|
|
1382
|
+
# Check if there are any individual field mappings
|
|
1383
|
+
has_element_mappings = False
|
|
1384
|
+
|
|
1385
|
+
# Check other fields for consistent element mappings
|
|
1386
|
+
for key, value in obj.items():
|
|
1387
|
+
if (
|
|
1388
|
+
key != "____source"
|
|
1389
|
+
and isinstance(value, str)
|
|
1390
|
+
and "_dp(" in value
|
|
1391
|
+
):
|
|
1392
|
+
has_element_mappings = True
|
|
1393
|
+
element_path = self._extract_path_from_dp(value)
|
|
1394
|
+
|
|
1395
|
+
# Validate element path consistency
|
|
1396
|
+
if (
|
|
1397
|
+
source_path
|
|
1398
|
+
and element_path
|
|
1399
|
+
and not self._is_valid_element_path(
|
|
1400
|
+
source_path, element_path
|
|
1401
|
+
)
|
|
1402
|
+
):
|
|
1403
|
+
errors.append(
|
|
1404
|
+
ValidationError(
|
|
1405
|
+
message=(
|
|
1406
|
+
f"Array element path inconsistent with "
|
|
1407
|
+
f"source path in step {line_number}. "
|
|
1408
|
+
f"Source: {source_path}, Element: "
|
|
1409
|
+
f"{element_path}"
|
|
1410
|
+
),
|
|
1411
|
+
error_type=ErrorType.INPUT_INVALID_BY_ADAPTER,
|
|
1412
|
+
line_number=line_number,
|
|
1413
|
+
field_path=field_path + [key],
|
|
1414
|
+
)
|
|
1415
|
+
)
|
|
1416
|
+
|
|
1417
|
+
# Warn if ____source exists but no individual field mappings found
|
|
1418
|
+
if not has_element_mappings:
|
|
1419
|
+
# Check if there are any non-____source fields that could be
|
|
1420
|
+
# element mappings
|
|
1421
|
+
other_fields = [k for k in obj if k != "____source"]
|
|
1422
|
+
if other_fields:
|
|
1423
|
+
errors.append(
|
|
1424
|
+
ValidationError(
|
|
1425
|
+
message=(
|
|
1426
|
+
f"Array mapping with ____source found but no "
|
|
1427
|
+
f"individual field mappings using _dp() with "
|
|
1428
|
+
f"current_item in step {line_number}. Consider "
|
|
1429
|
+
f"mapping individual fields: "
|
|
1430
|
+
f"{', '.join(other_fields[:3])}"
|
|
1431
|
+
),
|
|
1432
|
+
error_type=ErrorType.INPUT_INVALID_BY_ADAPTER,
|
|
1433
|
+
line_number=line_number,
|
|
1434
|
+
field_path=field_path + ["____source"],
|
|
1435
|
+
)
|
|
1436
|
+
)
|
|
1437
|
+
else:
|
|
1438
|
+
errors.append(
|
|
1439
|
+
ValidationError(
|
|
1440
|
+
message=(
|
|
1441
|
+
f"Array mapping with ____source found but "
|
|
1442
|
+
"no individual field mappings in step "
|
|
1443
|
+
f"{line_number}. Array elements cannot be "
|
|
1444
|
+
"accessed without field mappings."
|
|
1445
|
+
),
|
|
1446
|
+
error_type=ErrorType.INPUT_INVALID_BY_ADAPTER,
|
|
1447
|
+
line_number=line_number,
|
|
1448
|
+
field_path=field_path + ["____source"],
|
|
1449
|
+
)
|
|
1450
|
+
)
|
|
1451
|
+
|
|
1452
|
+
# Recursively check nested objects
|
|
1453
|
+
for key, value in obj.items():
|
|
1454
|
+
check_array_structure(value, field_path + [key])
|
|
1455
|
+
|
|
1456
|
+
elif isinstance(obj, list):
|
|
1457
|
+
for i, item in enumerate(obj):
|
|
1458
|
+
check_array_structure(item, field_path + [str(i)])
|
|
1459
|
+
|
|
1460
|
+
check_array_structure(input_data)
|
|
1461
|
+
return errors
|
|
1462
|
+
|
|
1463
|
+
def _step_exists(self, provider: str, as_value: str) -> bool:
|
|
1464
|
+
"""Check if a step with given provider and as_value exists in the recipe"""
|
|
1465
|
+
if not hasattr(self, "current_recipe_root") or not self.current_recipe_root:
|
|
1466
|
+
return True # Skip validation if we don't have recipe context
|
|
1467
|
+
|
|
1468
|
+
return (
|
|
1469
|
+
self._find_step_by_as(self.current_recipe_root, provider, as_value)
|
|
1470
|
+
is not None
|
|
1471
|
+
)
|
|
1472
|
+
|
|
1473
|
+
def _find_step_by_as(
|
|
1474
|
+
self, line: RecipeLine, provider: str, as_value: str
|
|
1475
|
+
) -> RecipeLine | None:
|
|
1476
|
+
"""Find a step by provider and as_value"""
|
|
1477
|
+
if line.provider == provider and line.as_ == as_value:
|
|
1478
|
+
return line
|
|
1479
|
+
|
|
1480
|
+
if line.block:
|
|
1481
|
+
for child in line.block:
|
|
1482
|
+
result = self._find_step_by_as(child, provider, as_value)
|
|
1483
|
+
if result:
|
|
1484
|
+
return result
|
|
1485
|
+
|
|
1486
|
+
return None
|
|
1487
|
+
|
|
1488
|
+
def _extract_path_from_dp(self, dp_string: str) -> list[Any]:
|
|
1489
|
+
"""Extract path array from _dp() string"""
|
|
1490
|
+
import json
|
|
1491
|
+
import re
|
|
1492
|
+
|
|
1493
|
+
match = re.search(r"#\{_dp\(\'([^\']+)\'\)\}", dp_string)
|
|
1494
|
+
if match:
|
|
1495
|
+
try:
|
|
1496
|
+
dp_data = json.loads(match.group(1))
|
|
1497
|
+
return dp_data.get("path", []) or []
|
|
1498
|
+
except json.JSONDecodeError:
|
|
1499
|
+
pass
|
|
1500
|
+
return []
|
|
1501
|
+
|
|
1502
|
+
def _is_valid_element_path(self, source_path: list, element_path: list) -> bool:
|
|
1503
|
+
"""Check if element path is a valid extension of source path"""
|
|
1504
|
+
if not source_path or not element_path:
|
|
1505
|
+
return True # Skip validation if we can't parse paths
|
|
1506
|
+
|
|
1507
|
+
# Element path should start with source path, then have current_item,
|
|
1508
|
+
# then additional fields
|
|
1509
|
+
if len(element_path) < len(source_path) + 2:
|
|
1510
|
+
return False
|
|
1511
|
+
|
|
1512
|
+
# Check source path prefix matches
|
|
1513
|
+
if element_path[: len(source_path)] != source_path:
|
|
1514
|
+
return False
|
|
1515
|
+
|
|
1516
|
+
# Check for current_item marker
|
|
1517
|
+
current_item_index = len(source_path)
|
|
1518
|
+
return (
|
|
1519
|
+
current_item_index < len(element_path)
|
|
1520
|
+
and isinstance(element_path[current_item_index], dict)
|
|
1521
|
+
and element_path[current_item_index].get("path_element_type")
|
|
1522
|
+
== "current_item"
|
|
1523
|
+
)
|
|
1524
|
+
|
|
1525
|
+
def _validate_block_structure(self, line: RecipeLine) -> list[ValidationError]:
|
|
1526
|
+
"""Validate block numbering and keyword rules"""
|
|
1527
|
+
errors = []
|
|
1528
|
+
|
|
1529
|
+
# Block 0 must be trigger
|
|
1530
|
+
if line.number == 0 and line.keyword != Keyword.TRIGGER:
|
|
1531
|
+
errors.append(
|
|
1532
|
+
ValidationError(
|
|
1533
|
+
message="Block 0 must be a trigger",
|
|
1534
|
+
error_type=ErrorType.STRUCTURE_INVALID,
|
|
1535
|
+
line_number=line.number,
|
|
1536
|
+
field_label="keyword",
|
|
1537
|
+
)
|
|
1538
|
+
)
|
|
1539
|
+
|
|
1540
|
+
# Note: Removed the rule that blocks 1+ must be actions
|
|
1541
|
+
# This was incorrect - recipes can have various block structures
|
|
1542
|
+
# including conditional blocks, repeat blocks, etc.
|
|
1543
|
+
|
|
1544
|
+
# Recursively validate children
|
|
1545
|
+
if line.block:
|
|
1546
|
+
for child in line.block:
|
|
1547
|
+
child_errors = self._validate_block_structure(child)
|
|
1548
|
+
errors.extend(child_errors)
|
|
1549
|
+
|
|
1550
|
+
return errors
|
|
1551
|
+
|
|
1552
|
+
def _validate_input_modes(self, line: RecipeLine) -> list[ValidationError]:
|
|
1553
|
+
"""Validate input mode consistency and formulas"""
|
|
1554
|
+
errors = []
|
|
1555
|
+
|
|
1556
|
+
if line.input:
|
|
1557
|
+
for input_field, value in line.input.items():
|
|
1558
|
+
if isinstance(value, str):
|
|
1559
|
+
# Check for mixed modes
|
|
1560
|
+
has_formula = "=_dp(" in value
|
|
1561
|
+
has_text = "#{_dp(" in value
|
|
1562
|
+
|
|
1563
|
+
if has_formula and has_text:
|
|
1564
|
+
errors.append(
|
|
1565
|
+
ValidationError(
|
|
1566
|
+
message=(
|
|
1567
|
+
f"Mixed input modes detected in field "
|
|
1568
|
+
f"'{input_field}'. Consider using one mode "
|
|
1569
|
+
"per field for clarity."
|
|
1570
|
+
),
|
|
1571
|
+
error_type=ErrorType.INPUT_MODE_INCONSISTENT,
|
|
1572
|
+
line_number=line.number,
|
|
1573
|
+
field_path=[input_field],
|
|
1574
|
+
)
|
|
1575
|
+
)
|
|
1576
|
+
|
|
1577
|
+
# Validate formula syntax if in formula mode
|
|
1578
|
+
if has_formula:
|
|
1579
|
+
formula_errors = self._validate_formula_syntax(
|
|
1580
|
+
value, input_field, line.number
|
|
1581
|
+
)
|
|
1582
|
+
errors.extend(formula_errors)
|
|
1583
|
+
|
|
1584
|
+
# Recursively validate children
|
|
1585
|
+
if line.block:
|
|
1586
|
+
for child in line.block:
|
|
1587
|
+
child_errors = self._validate_input_modes(child)
|
|
1588
|
+
errors.extend(child_errors)
|
|
1589
|
+
|
|
1590
|
+
return errors
|
|
1591
|
+
|
|
1592
|
+
def _validate_formula_syntax(
|
|
1593
|
+
self, value: str, field: str, line_number: int
|
|
1594
|
+
) -> list[ValidationError]:
|
|
1595
|
+
"""Validate formula syntax and allowed methods"""
|
|
1596
|
+
errors = []
|
|
1597
|
+
|
|
1598
|
+
# Check if formula starts with =_dp(
|
|
1599
|
+
if not value.strip().startswith("=_dp("):
|
|
1600
|
+
errors.append(
|
|
1601
|
+
ValidationError(
|
|
1602
|
+
message=f"Formula in field '{field}' must start with '=_dp('",
|
|
1603
|
+
error_type=ErrorType.FORMULA_SYNTAX_INVALID,
|
|
1604
|
+
line_number=line_number,
|
|
1605
|
+
field_path=[field],
|
|
1606
|
+
)
|
|
1607
|
+
)
|
|
1608
|
+
|
|
1609
|
+
# Check for common formula syntax issues
|
|
1610
|
+
if "=_dp(" in value:
|
|
1611
|
+
# Extract the data pill part
|
|
1612
|
+
start = value.find("=_dp(")
|
|
1613
|
+
end = value.find(")", start)
|
|
1614
|
+
if end == -1:
|
|
1615
|
+
errors.append(
|
|
1616
|
+
ValidationError(
|
|
1617
|
+
message=f"Formula in field '{field}' has unmatched parentheses",
|
|
1618
|
+
error_type=ErrorType.FORMULA_SYNTAX_INVALID,
|
|
1619
|
+
line_number=line_number,
|
|
1620
|
+
field_path=[field],
|
|
1621
|
+
)
|
|
1622
|
+
)
|
|
1623
|
+
else:
|
|
1624
|
+
# Check for common Ruby method calls
|
|
1625
|
+
formula_part = value[end + 1 :]
|
|
1626
|
+
if formula_part:
|
|
1627
|
+
# Validate common Ruby methods
|
|
1628
|
+
allowed_methods = [
|
|
1629
|
+
"split",
|
|
1630
|
+
"first",
|
|
1631
|
+
"last",
|
|
1632
|
+
"length",
|
|
1633
|
+
"size",
|
|
1634
|
+
"count",
|
|
1635
|
+
"upcase",
|
|
1636
|
+
"downcase",
|
|
1637
|
+
"capitalize",
|
|
1638
|
+
"strip",
|
|
1639
|
+
"chomp",
|
|
1640
|
+
"gsub",
|
|
1641
|
+
"sub",
|
|
1642
|
+
"replace",
|
|
1643
|
+
"to_i",
|
|
1644
|
+
"to_f",
|
|
1645
|
+
"to_s",
|
|
1646
|
+
"present?",
|
|
1647
|
+
"blank?",
|
|
1648
|
+
"nil?",
|
|
1649
|
+
"empty?",
|
|
1650
|
+
"map",
|
|
1651
|
+
"select",
|
|
1652
|
+
"filter",
|
|
1653
|
+
"reject",
|
|
1654
|
+
"find",
|
|
1655
|
+
"detect",
|
|
1656
|
+
"any?",
|
|
1657
|
+
"all?",
|
|
1658
|
+
"none?",
|
|
1659
|
+
"one?",
|
|
1660
|
+
"sum",
|
|
1661
|
+
"min",
|
|
1662
|
+
"max",
|
|
1663
|
+
"average",
|
|
1664
|
+
"mean",
|
|
1665
|
+
]
|
|
1666
|
+
|
|
1667
|
+
# Extract method calls (simple pattern matching)
|
|
1668
|
+
import re
|
|
1669
|
+
|
|
1670
|
+
method_calls = re.findall(r"\.(\w+)\s*\(?", formula_part)
|
|
1671
|
+
for method in method_calls:
|
|
1672
|
+
if method not in allowed_methods:
|
|
1673
|
+
errors.append(
|
|
1674
|
+
ValidationError(
|
|
1675
|
+
message=(
|
|
1676
|
+
f"Method '{method}' in field '{field}' "
|
|
1677
|
+
"may not be supported. Common methods: "
|
|
1678
|
+
f"{', '.join(allowed_methods[:10])}..."
|
|
1679
|
+
),
|
|
1680
|
+
error_type=ErrorType.FORMULA_SYNTAX_INVALID,
|
|
1681
|
+
line_number=line_number,
|
|
1682
|
+
field_path=[field],
|
|
1683
|
+
)
|
|
1684
|
+
)
|
|
1685
|
+
|
|
1686
|
+
return errors
|
|
1687
|
+
|
|
1688
|
+
def _validate_array_mappings_enhanced(
|
|
1689
|
+
self, line: RecipeLine
|
|
1690
|
+
) -> list[ValidationError]:
|
|
1691
|
+
"""Enhanced array mapping validation"""
|
|
1692
|
+
errors = []
|
|
1693
|
+
|
|
1694
|
+
def check_array_mapping(obj: Any, field_path: list[str] | None = None) -> None:
|
|
1695
|
+
if field_path is None:
|
|
1696
|
+
field_path = []
|
|
1697
|
+
if isinstance(obj, dict):
|
|
1698
|
+
if "____source" in obj:
|
|
1699
|
+
# Check if there are current_item mappings in the same object
|
|
1700
|
+
has_current_item = False
|
|
1701
|
+
for key, value in obj.items():
|
|
1702
|
+
if (
|
|
1703
|
+
key != "____source"
|
|
1704
|
+
and isinstance(value, str)
|
|
1705
|
+
and "current_item" in value
|
|
1706
|
+
):
|
|
1707
|
+
has_current_item = True
|
|
1708
|
+
break
|
|
1709
|
+
|
|
1710
|
+
if not has_current_item:
|
|
1711
|
+
errors.append(
|
|
1712
|
+
ValidationError(
|
|
1713
|
+
message=(
|
|
1714
|
+
f"Field '{'.'.join(field_path)}' uses ____source "
|
|
1715
|
+
"but no current_item mappings found. Array "
|
|
1716
|
+
"elements cannot be accessed without mappings."
|
|
1717
|
+
),
|
|
1718
|
+
error_type=ErrorType.ARRAY_MAPPING_INVALID,
|
|
1719
|
+
line_number=line.number,
|
|
1720
|
+
field_path=field_path,
|
|
1721
|
+
)
|
|
1722
|
+
)
|
|
1723
|
+
|
|
1724
|
+
# Recursively check nested objects
|
|
1725
|
+
for key, value in obj.items():
|
|
1726
|
+
check_array_mapping(value, field_path + [key])
|
|
1727
|
+
elif isinstance(obj, list):
|
|
1728
|
+
for i, item in enumerate(obj):
|
|
1729
|
+
check_array_mapping(item, field_path + [str(i)])
|
|
1730
|
+
|
|
1731
|
+
if line.input:
|
|
1732
|
+
check_array_mapping(line.input)
|
|
1733
|
+
|
|
1734
|
+
# Recursively validate children
|
|
1735
|
+
if line.block:
|
|
1736
|
+
for child in line.block:
|
|
1737
|
+
child_errors = self._validate_array_mappings_enhanced(child)
|
|
1738
|
+
errors.extend(child_errors)
|
|
1739
|
+
|
|
1740
|
+
return errors
|