feat(cli): add lint command (#7917)
* feat(yaml-validator)!: unify flow, schedule, and trigger validation - replace FlowValidator with WindmillYamlValidator.validate(doc, target) - generate schedule/trigger schemas from backend OpenAPI and OpenFlow refs - add schedule/trigger/filename-target tests and update AI agent fixtures - bump windmill-yaml-validator to 2.0.0 BREAKING CHANGE: FlowValidator and validateFlow() are replaced by WindmillYamlValidator.validate(doc, target). * add lint command * add deno-compat script and docs for local yaml-validator testing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: make nullable fields pass yaml validation Add nullable: true to static_asset_config and authentication_resource_path in HttpTrigger schema. Post-process generated JSON schemas to add null to enums with nullable: true (AJV doesn't handle OpenAPI 3.0 nullable + enum). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: add nullable to all Option<T> fields in trigger and schedule OpenAPI schemas Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(frontend): handle nullable fields from updated OpenAPI types Add ?? undefined coalescing at assignment sites where generated types now include | null from the OpenAPI nullable additions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(lint): show allowed values in enum validation errors Instead of "must be equal to one of the allowed values", now shows "must be one of: 'r', 'w', 'rw'" for enum validation failures. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: add nullable to Edit/New trigger and schedule OpenAPI schemas Ensures create/update request body types accept null for the same fields that GET response types return as nullable, enabling clean round-tripping without type mismatches. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * use published package * publish * refactor(lint): remove unused --includes/--excludes/--extra-includes CLI options These options were defined but never wired to the file filtering logic. The lint command still respects includes/excludes from wmill.yaml via mergeConfigWithConfigFile. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(lint): handle additionalProperties errors and expand test coverage Add formatting for AJV additionalProperties keyword to show the unknown property name. Add unit tests for all formatValidationError branches and integration tests for --json report shape, --fail-on-warn with mixed files, non-existent directory, and enum error output. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add realistic validator tests for schedules, triggers, and edge cases Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add email trigger validation support Add email trigger schema generation, validation, and linting. Email triggers are no longer skipped with a warning — they are validated like all other trigger types. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore(cli): bump windmill-yaml-validator to 1.1.1 (email trigger support) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * publish * rm * fix: address PR review feedback for lint command - Add email to trigger kinds test loop instead of separate test - Add email to ValidationTarget docs in README - Type formatYamlDiagnostics param directly instead of unsafe cast - Destructure json option before mergeConfigWithConfigFile for clarity Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(cli): add --lint option to sync push command Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,19 @@
|
||||
# Windmill YAML Validator
|
||||
|
||||
A TypeScript-based YAML validator for Windmill flow files. This package validates flow.yaml files against the OpenFlow JSON schema to ensure they conform to the Windmill flow specification.
|
||||
A TypeScript-based YAML validator for Windmill flow, schedule, and trigger files.
|
||||
|
||||
## Overview
|
||||
|
||||
The windmill-yaml-validator provides runtime validation for Windmill flow YAML files. It is currently used in the **Windmill VSCode extension** to show syntax errors and validation issues on `flow.yaml` files in real-time as developers edit them.
|
||||
The windmill-yaml-validator provides runtime validation for Windmill YAML files. It is used by editor integrations to show validation errors while editing:
|
||||
|
||||
- `flow.yaml` / `flow.yml`
|
||||
- `*.schedule.yaml` / `*.schedule.yml`
|
||||
- `*.{http|websocket|kafka|nats|postgres|mqtt|sqs|gcp}_trigger.yaml` (or `.yml`)
|
||||
|
||||
## Features
|
||||
|
||||
- **Schema-based validation**: Validates against the official OpenFlow JSON schema
|
||||
- **Unified validation API**: One validator class for flow/schedule/trigger files
|
||||
- **Schema-based validation**: Uses OpenFlow and backend OpenAPI-derived schemas
|
||||
- **Detailed error reporting**: Returns comprehensive error information with specific paths to invalid fields
|
||||
|
||||
## Installation
|
||||
@@ -22,29 +27,70 @@ npm install windmill-yaml-validator
|
||||
### Basic Validation
|
||||
|
||||
```typescript
|
||||
import { FlowValidator } from "windmill-yaml-validator";
|
||||
import { WindmillYamlValidator } from "windmill-yaml-validator";
|
||||
|
||||
const validator = new FlowValidator();
|
||||
const validator = new WindmillYamlValidator();
|
||||
|
||||
const yamlContent = `
|
||||
const flowYaml = `
|
||||
summary: Test Flow
|
||||
value:
|
||||
modules: []
|
||||
`;
|
||||
|
||||
const result = validator.validateFlow(yamlContent);
|
||||
const flowResult = validator.validate(flowYaml, { type: "flow" });
|
||||
|
||||
if (result.errors.length === 0) {
|
||||
console.log("Flow is valid!");
|
||||
} else {
|
||||
console.log("Validation errors:", result.errors);
|
||||
const scheduleYaml = `
|
||||
schedule: "0 0 12 * * *"
|
||||
timezone: "UTC"
|
||||
enabled: true
|
||||
script_path: "f/jobs/daily_sync"
|
||||
is_flow: false
|
||||
`;
|
||||
|
||||
const scheduleResult = validator.validate(scheduleYaml, { type: "schedule" });
|
||||
|
||||
const triggerYaml = `
|
||||
script_path: "f/triggers/http_handler"
|
||||
is_flow: false
|
||||
route_path: "api/webhook"
|
||||
request_type: "sync"
|
||||
authentication_method: "none"
|
||||
http_method: "post"
|
||||
is_static_website: false
|
||||
workspaced_route: false
|
||||
wrap_body: false
|
||||
raw_string: false
|
||||
`;
|
||||
|
||||
const triggerResult = validator.validate(triggerYaml, {
|
||||
type: "trigger",
|
||||
triggerKind: "http",
|
||||
});
|
||||
|
||||
console.log(flowResult.errors, scheduleResult.errors, triggerResult.errors);
|
||||
```
|
||||
|
||||
### Target Inference by Filename
|
||||
|
||||
```typescript
|
||||
import {
|
||||
WindmillYamlValidator,
|
||||
getValidationTargetFromFilename,
|
||||
} from "windmill-yaml-validator";
|
||||
|
||||
const validator = new WindmillYamlValidator();
|
||||
const target = getValidationTargetFromFilename(
|
||||
"f/webhooks/order_created.http_trigger.yaml"
|
||||
);
|
||||
|
||||
if (target) {
|
||||
const result = validator.validate(fileContents, target);
|
||||
console.log(result.errors);
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
The validator returns detailed error information for invalid flows:
|
||||
|
||||
```typescript
|
||||
const invalidYaml = `
|
||||
summary: 123 # Should be a string
|
||||
@@ -56,7 +102,7 @@ value:
|
||||
language: invalid_language # Invalid enum value
|
||||
`;
|
||||
|
||||
const result = validator.validateFlow(invalidYaml);
|
||||
const result = validator.validate(invalidYaml, { type: "flow" });
|
||||
|
||||
result.errors.forEach((error) => {
|
||||
console.log(`Error at ${error.instancePath}: ${error.message}`);
|
||||
@@ -68,27 +114,31 @@ result.errors.forEach((error) => {
|
||||
|
||||
## API
|
||||
|
||||
### `FlowValidator`
|
||||
### `WindmillYamlValidator`
|
||||
|
||||
Main validator class that validates Windmill flow YAML files.
|
||||
Main validator class for Windmill YAML validation.
|
||||
|
||||
#### Constructor
|
||||
|
||||
```typescript
|
||||
new FlowValidator();
|
||||
new WindmillYamlValidator();
|
||||
```
|
||||
|
||||
Creates a new validator instance. The constructor initializes the AJV validator with the OpenFlow schema.
|
||||
Initializes AJV validators for flow, schedule, and trigger schemas.
|
||||
|
||||
#### Methods
|
||||
|
||||
##### `validateFlow(doc: string)`
|
||||
##### `validate(doc: string, target: ValidationTarget)`
|
||||
|
||||
Validates a flow document against the OpenFlow schema.
|
||||
Validates a YAML document against the selected target schema.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `doc` (string): The YAML flow document as a string
|
||||
- `doc` (string): YAML document string
|
||||
- `target` (`ValidationTarget`):
|
||||
- `{ type: "flow" }`
|
||||
- `{ type: "schedule" }`
|
||||
- `{ type: "trigger", triggerKind: "http" | "websocket" | "kafka" | "nats" | "postgres" | "mqtt" | "sqs" | "gcp" | "email" }`
|
||||
|
||||
**Returns:**
|
||||
|
||||
@@ -103,6 +153,10 @@ Validates a flow document against the OpenFlow schema.
|
||||
|
||||
- Error if `doc` is not a string
|
||||
|
||||
### `getValidationTargetFromFilename(path: string)`
|
||||
|
||||
Infers validation target from file naming conventions. Returns `null` for unsupported files.
|
||||
|
||||
## Development
|
||||
|
||||
### Building
|
||||
@@ -113,7 +167,10 @@ npm run build
|
||||
|
||||
The build process:
|
||||
|
||||
1. Runs `gen_openflow_schema.sh` to generate the OpenFlow JSON schema from `openflow.openapi.yaml`
|
||||
1. Runs `gen_openflow_schema.sh` to generate:
|
||||
- `src/gen/openflow.json`
|
||||
- `src/gen/schedule.json`
|
||||
- `src/gen/triggers/*.json`
|
||||
2. Removes discriminator mappings (not supported by AJV)
|
||||
3. Compiles TypeScript to JavaScript
|
||||
|
||||
@@ -129,6 +186,44 @@ Run tests in watch mode:
|
||||
npm test:watch
|
||||
```
|
||||
|
||||
### Testing locally with the CLI
|
||||
|
||||
The Windmill CLI (`cli/`) is Deno-based and imports this package via `npm:windmill-yaml-validator@1.1.0`. Since Deno's `npm:` specifier always resolves from the npm registry, local testing requires a compatibility script that makes the TypeScript sources directly importable by Deno.
|
||||
|
||||
The `deno-compat.sh` script handles two Deno requirements:
|
||||
- Adding `.ts` extensions to relative imports
|
||||
- Adding `with { type: "json" }` assertions to JSON imports
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Apply Deno compatibility:
|
||||
|
||||
```bash
|
||||
./deno-compat.sh
|
||||
```
|
||||
|
||||
2. Add the following entries to `cli/deno.json` imports:
|
||||
|
||||
```json
|
||||
"npm:windmill-yaml-validator@1.1.0": "../windmill-yaml-validator/src/index.ts",
|
||||
"ajv": "npm:ajv@^8.17.1",
|
||||
"@stoplight/yaml": "npm:@stoplight/yaml@^4.3.0"
|
||||
```
|
||||
|
||||
3. Run the CLI directly with Deno:
|
||||
|
||||
```bash
|
||||
cd ../cli
|
||||
deno run -A src/main.ts lint
|
||||
```
|
||||
|
||||
4. When done, restore everything:
|
||||
|
||||
```bash
|
||||
./deno-compat.sh -r # restore original imports
|
||||
# Remove the 3 import map lines from cli/deno.json
|
||||
```
|
||||
|
||||
### Schema Generation
|
||||
|
||||
The validator uses a JSON schema generated from the OpenAPI specification:
|
||||
@@ -139,10 +234,10 @@ The validator uses a JSON schema generated from the OpenAPI specification:
|
||||
|
||||
This script:
|
||||
|
||||
- Bundles `openflow.openapi.yaml` into a single JSON schema
|
||||
- Converts `openflow.openapi.yaml` and `backend/windmill-api/openapi.yaml` into JSON
|
||||
- Removes discriminator mappings for AJV compatibility
|
||||
- Removes the `ToolValue` discriminator entirely (see below)
|
||||
- Outputs to `src/gen/openflow.json`
|
||||
- Generates standalone schedule/trigger schemas for CLI file shape
|
||||
|
||||
#### Why Remove Discriminators?
|
||||
|
||||
@@ -157,3 +252,7 @@ The OpenFlow schema uses OpenAPI discriminators for efficient type resolution in
|
||||
- Is slightly slower but still performant for our use case
|
||||
- Provides the same validation correctness
|
||||
- Works correctly with complex schema compositions like `allOf`
|
||||
|
||||
## Breaking Change
|
||||
|
||||
`FlowValidator` and `validateFlow()` were replaced by `WindmillYamlValidator` and `validate(doc, target)`.
|
||||
|
||||
55
windmill-yaml-validator/deno-compat.sh
Executable file
55
windmill-yaml-validator/deno-compat.sh
Executable file
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Makes windmill-yaml-validator source files Deno-compatible by:
|
||||
# 1. Adding .ts extensions to relative imports
|
||||
# 2. Adding `with { type: "json" }` to JSON imports
|
||||
# Use -r to restore (undo changes).
|
||||
|
||||
set -e
|
||||
script_dirpath="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
RESTORE_MODE=false
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-r)
|
||||
RESTORE_MODE=true
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
echo "Usage: $0 [-r]"
|
||||
echo " -r Restore original imports"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
SED=gsed
|
||||
if ! command -v gsed &> /dev/null; then
|
||||
echo "Error: gsed not found. Run: brew install gnu-sed"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
SED=sed
|
||||
fi
|
||||
|
||||
if [[ "$RESTORE_MODE" == true ]]; then
|
||||
echo "Restoring original imports..."
|
||||
find "$script_dirpath"/src -name "*.ts" -type f ! -path '*__tests__*' | while read -r file; do
|
||||
# Remove .ts from relative imports: from "./foo.ts" -> from "./foo"
|
||||
$SED -E -i 's|(from "\.\.?/[^"]*)\.ts(")|\1\2|g' "$file"
|
||||
# Remove ` with { type: "json" }` from JSON imports
|
||||
$SED -E -i 's/ with \{ type: "json" \}//' "$file"
|
||||
done
|
||||
echo "✓ Restored original imports"
|
||||
else
|
||||
echo "Making sources Deno-compatible..."
|
||||
find "$script_dirpath"/src -name "*.ts" -type f ! -path '*__tests__*' | while read -r file; do
|
||||
# Add .ts to relative imports that don't already end in .ts or .json
|
||||
$SED -E -i '/\.json"/! { /\.ts"/! s|(from "(\.\.?/[^"]*[^/]))"(;?)$|\1.ts"\3|; }' "$file"
|
||||
# Add `with { type: "json" }` to .json imports that don't already have it
|
||||
$SED -E -i '/with \{ type: "json" \}/! s/(from "[^"]*\.json")(;?)$/\1 with { type: "json" }\2/' "$file"
|
||||
done
|
||||
echo "✓ Sources are now Deno-compatible"
|
||||
fi
|
||||
@@ -1,10 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
script_dirpath="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
output_dirpath="${script_dirpath}/src/gen"
|
||||
tmp_dirpath="$(mktemp -d)"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "${tmp_dirpath}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "${output_dirpath}"
|
||||
npx @redocly/openapi-cli@latest bundle "${script_dirpath}/../openflow.openapi.yaml" --ext json > "${output_dirpath}/openflow.json"
|
||||
mkdir -p "${output_dirpath}/triggers"
|
||||
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const yaml = require('js-yaml');
|
||||
|
||||
const openflowPath = '${script_dirpath}/../openflow.openapi.yaml';
|
||||
const backendPath = '${script_dirpath}/../backend/windmill-api/openapi.yaml';
|
||||
const openflowOutputPath = '${output_dirpath}/openflow.json';
|
||||
const backendOutputPath = '${tmp_dirpath}/backend-openapi.json';
|
||||
|
||||
const openflowData = yaml.load(fs.readFileSync(openflowPath, 'utf8'));
|
||||
const backendData = yaml.load(fs.readFileSync(backendPath, 'utf8'));
|
||||
|
||||
fs.writeFileSync(openflowOutputPath, JSON.stringify(openflowData, null, 2) + '\\n');
|
||||
fs.writeFileSync(backendOutputPath, JSON.stringify(backendData, null, 2) + '\\n');
|
||||
"
|
||||
|
||||
# Remove discriminator mapping from openflow.json as it's not supported by ajv
|
||||
node -e "
|
||||
@@ -34,4 +58,39 @@ try {
|
||||
} catch (e) {
|
||||
console.error('Error removing discriminator mappings:', e);
|
||||
}
|
||||
"
|
||||
"
|
||||
|
||||
node "${script_dirpath}/scripts/generate-resource-schemas.js" \
|
||||
"${tmp_dirpath}/backend-openapi.json" \
|
||||
"${output_dirpath}/openflow.json" \
|
||||
"${output_dirpath}"
|
||||
|
||||
# AJV does not handle OpenAPI 3.0 `nullable: true` combined with `enum` — null must
|
||||
# be explicitly listed in the enum for validation to accept null values.
|
||||
# We post-process all generated JSON schemas to add null to such enums.
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function addNullToNullableEnums(obj) {
|
||||
if (!obj || typeof obj !== 'object') return;
|
||||
if (Array.isArray(obj)) { obj.forEach(addNullToNullableEnums); return; }
|
||||
if (obj.nullable === true && Array.isArray(obj.enum) && !obj.enum.includes(null)) {
|
||||
obj.enum.push(null);
|
||||
}
|
||||
for (const v of Object.values(obj)) addNullToNullableEnums(v);
|
||||
}
|
||||
|
||||
const files = [
|
||||
'${output_dirpath}/openflow.json',
|
||||
'${output_dirpath}/schedule.json',
|
||||
...fs.readdirSync('${output_dirpath}/triggers').map(f => path.join('${output_dirpath}/triggers', f))
|
||||
].filter(f => f.endsWith('.json'));
|
||||
|
||||
for (const file of files) {
|
||||
const schema = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
addNullToNullableEnums(schema);
|
||||
fs.writeFileSync(file, JSON.stringify(schema, null, 2) + '\n');
|
||||
}
|
||||
console.log('Added null to nullable enums in generated schemas');
|
||||
"
|
||||
|
||||
5
windmill-yaml-validator/package-lock.json
generated
5
windmill-yaml-validator/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "windmill-yaml-validator",
|
||||
"version": "1.0.4",
|
||||
"version": "1.1.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "windmill-yaml-validator",
|
||||
"version": "1.0.4",
|
||||
"version": "1.1.1",
|
||||
"license": "Apache 2.0",
|
||||
"dependencies": {
|
||||
"@stoplight/yaml": "^4.3.0",
|
||||
@@ -16,6 +16,7 @@
|
||||
"@types/jest": "^29.5.0",
|
||||
"@types/node": "^24.1.0",
|
||||
"jest": "^29.5.0",
|
||||
"js-yaml": "^3.14.1",
|
||||
"ts-jest": "^29.1.0",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "windmill-yaml-validator",
|
||||
"version": "1.0.4",
|
||||
"description": "YAML validator for Windmill",
|
||||
"version": "1.1.1",
|
||||
"description": "YAML validator for Windmill flow, schedule, and trigger files",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "./gen_openflow_schema.sh && tsc",
|
||||
"pretest": "./gen_openflow_schema.sh",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"prepublishOnly": "npm run build"
|
||||
@@ -18,6 +19,7 @@
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.0",
|
||||
"@types/node": "^24.1.0",
|
||||
"js-yaml": "^3.14.1",
|
||||
"jest": "^29.5.0",
|
||||
"ts-jest": "^29.1.0",
|
||||
"typescript": "^5.0.0"
|
||||
@@ -29,4 +31,4 @@
|
||||
"@stoplight/yaml": "^4.3.0",
|
||||
"ajv": "^8.17.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
234
windmill-yaml-validator/scripts/generate-resource-schemas.js
Normal file
234
windmill-yaml-validator/scripts/generate-resource-schemas.js
Normal file
@@ -0,0 +1,234 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const CLI_EXCLUDED_FIELDS = new Set([
|
||||
"workspace_id",
|
||||
"path",
|
||||
"name",
|
||||
"versions",
|
||||
"id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"edited_at",
|
||||
"edited_by",
|
||||
"archived",
|
||||
"has_draft",
|
||||
"error",
|
||||
"last_server_ping",
|
||||
"server_id",
|
||||
"extra_perms",
|
||||
"email",
|
||||
"mode",
|
||||
]);
|
||||
|
||||
const TARGET_SCHEMAS = {
|
||||
schedule: "Schedule",
|
||||
triggers: {
|
||||
http: "HttpTrigger",
|
||||
websocket: "WebsocketTrigger",
|
||||
kafka: "KafkaTrigger",
|
||||
nats: "NatsTrigger",
|
||||
postgres: "PostgresTrigger",
|
||||
mqtt: "MqttTrigger",
|
||||
sqs: "SqsTrigger",
|
||||
gcp: "GcpTrigger",
|
||||
email: "EmailTrigger",
|
||||
},
|
||||
};
|
||||
|
||||
function deepClone(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function loadJson(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
}
|
||||
|
||||
function mergeSchemas(target, source) {
|
||||
if (!source || typeof source !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (source.type && !target.type) {
|
||||
target.type = source.type;
|
||||
}
|
||||
|
||||
if (source.properties && typeof source.properties === "object") {
|
||||
target.properties = target.properties || {};
|
||||
Object.assign(target.properties, deepClone(source.properties));
|
||||
}
|
||||
|
||||
if (Array.isArray(source.required)) {
|
||||
target.required = target.required || [];
|
||||
target.required.push(...source.required);
|
||||
}
|
||||
}
|
||||
|
||||
function getRefName(refPath) {
|
||||
const marker = "#/components/schemas/";
|
||||
const markerIndex = refPath.indexOf(marker);
|
||||
if (markerIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
return refPath.slice(markerIndex + marker.length);
|
||||
}
|
||||
|
||||
function resolveRef(refPath, backendSchemas, openflowSchemas) {
|
||||
const refName = getRefName(refPath);
|
||||
if (!refName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (refPath.startsWith("#/components/schemas/")) {
|
||||
if (backendSchemas[refName]) {
|
||||
return deepClone(backendSchemas[refName]);
|
||||
}
|
||||
if (openflowSchemas[refName]) {
|
||||
return deepClone(openflowSchemas[refName]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (refPath.includes("openflow.openapi.yaml#/components/schemas/")) {
|
||||
if (openflowSchemas[refName]) {
|
||||
return deepClone(openflowSchemas[refName]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractCliSchema(schema, allSchemas, openflowSchemas) {
|
||||
if (!schema || typeof schema !== "object") {
|
||||
return {};
|
||||
}
|
||||
|
||||
const result = { type: "object", properties: {}, required: [] };
|
||||
|
||||
if (Array.isArray(schema.allOf)) {
|
||||
for (const item of schema.allOf) {
|
||||
if (!item || typeof item !== "object") {
|
||||
continue;
|
||||
}
|
||||
if (item.$ref) {
|
||||
const refSchema = resolveRef(item.$ref, allSchemas, openflowSchemas);
|
||||
const transformed = extractCliSchema(refSchema, allSchemas, openflowSchemas);
|
||||
mergeSchemas(result, transformed);
|
||||
} else {
|
||||
const transformed = extractCliSchema(item, allSchemas, openflowSchemas);
|
||||
mergeSchemas(result, transformed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (schema.properties && typeof schema.properties === "object") {
|
||||
for (const [key, value] of Object.entries(schema.properties)) {
|
||||
if (CLI_EXCLUDED_FIELDS.has(key)) {
|
||||
continue;
|
||||
}
|
||||
result.properties[key] = deepClone(value);
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(schema.required)) {
|
||||
for (const field of schema.required) {
|
||||
if (!CLI_EXCLUDED_FIELDS.has(field)) {
|
||||
result.required.push(field);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dedupRequired = Array.from(new Set(result.required));
|
||||
result.required = dedupRequired.filter((key) => key in result.properties);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function resolveSchemaRefs(value, backendSchemas, openflowSchemas, stack = new Set()) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => resolveSchemaRefs(item, backendSchemas, openflowSchemas, stack));
|
||||
}
|
||||
|
||||
if (!value || typeof value !== "object") {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value.$ref === "string") {
|
||||
const refName = getRefName(value.$ref);
|
||||
if (!refName) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (stack.has(refName)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const resolved = resolveRef(value.$ref, backendSchemas, openflowSchemas);
|
||||
if (!resolved) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const merged = { ...resolved, ...value };
|
||||
delete merged.$ref;
|
||||
const nextStack = new Set(stack);
|
||||
nextStack.add(refName);
|
||||
return resolveSchemaRefs(merged, backendSchemas, openflowSchemas, nextStack);
|
||||
}
|
||||
|
||||
const result = {};
|
||||
for (const [key, nested] of Object.entries(value)) {
|
||||
result[key] = resolveSchemaRefs(nested, backendSchemas, openflowSchemas, stack);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function writeJson(filePath, value) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(value, null, 2) + "\n", "utf8");
|
||||
}
|
||||
|
||||
function generateSchemas(backendPath, openflowPath, outputDir) {
|
||||
const backendOpenapi = loadJson(backendPath);
|
||||
const openflowOpenapi = loadJson(openflowPath);
|
||||
|
||||
const backendSchemas = backendOpenapi.components?.schemas || {};
|
||||
const openflowSchemas = openflowOpenapi.components?.schemas || {};
|
||||
|
||||
const scheduleSchema = extractCliSchema(
|
||||
backendSchemas[TARGET_SCHEMAS.schedule],
|
||||
backendSchemas,
|
||||
openflowSchemas
|
||||
);
|
||||
const resolvedScheduleSchema = resolveSchemaRefs(scheduleSchema, backendSchemas, openflowSchemas);
|
||||
writeJson(path.join(outputDir, "schedule.json"), resolvedScheduleSchema);
|
||||
|
||||
for (const [triggerKind, schemaName] of Object.entries(TARGET_SCHEMAS.triggers)) {
|
||||
const triggerSchema = extractCliSchema(
|
||||
backendSchemas[schemaName],
|
||||
backendSchemas,
|
||||
openflowSchemas
|
||||
);
|
||||
const resolvedTriggerSchema = resolveSchemaRefs(triggerSchema, backendSchemas, openflowSchemas);
|
||||
writeJson(path.join(outputDir, "triggers", `${triggerKind}.json`), resolvedTriggerSchema);
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const [backendPath, openflowPath, outputDir] = process.argv.slice(2);
|
||||
if (!backendPath || !openflowPath || !outputDir) {
|
||||
console.error(
|
||||
"Usage: node scripts/generate-resource-schemas.js <backend-openapi.json> <openflow.json> <output-dir>"
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
generateSchemas(backendPath, openflowPath, outputDir);
|
||||
console.log("Generated schedule and trigger schemas");
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1 +1 @@
|
||||
export * from "./validation";
|
||||
export * from "./validation/index";
|
||||
@@ -1,9 +1,9 @@
|
||||
import { FlowValidator } from "../flow-validator";
|
||||
import { WindmillYamlValidator } from "../yaml-validator";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
describe("FlowValidator", () => {
|
||||
let validator: FlowValidator;
|
||||
describe("WindmillYamlValidator", () => {
|
||||
let validator: WindmillYamlValidator;
|
||||
const samplesDir = path.join(__dirname, "test-samples");
|
||||
|
||||
const readSample = (filename: string): string => {
|
||||
@@ -11,31 +11,31 @@ describe("FlowValidator", () => {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
validator = new FlowValidator();
|
||||
validator = new WindmillYamlValidator();
|
||||
});
|
||||
|
||||
describe("constructor", () => {
|
||||
it("should create a validator instance", () => {
|
||||
expect(validator).toBeInstanceOf(FlowValidator);
|
||||
expect(validator).toBeInstanceOf(WindmillYamlValidator);
|
||||
});
|
||||
|
||||
it("should initialize without throwing", () => {
|
||||
expect(() => new FlowValidator()).not.toThrow();
|
||||
expect(() => new WindmillYamlValidator()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateFlow", () => {
|
||||
describe("validate (flow target)", () => {
|
||||
it("should throw error for non-string input", () => {
|
||||
expect(() => validator.validateFlow(null as any)).toThrow(
|
||||
expect(() => validator.validate(null as any, { type: "flow" })).toThrow(
|
||||
"Document must be a string"
|
||||
);
|
||||
expect(() => validator.validateFlow(123 as any)).toThrow(
|
||||
expect(() => validator.validate(123 as any, { type: "flow" })).toThrow(
|
||||
"Document must be a string"
|
||||
);
|
||||
expect(() => validator.validateFlow({} as any)).toThrow(
|
||||
expect(() => validator.validate({} as any, { type: "flow" })).toThrow(
|
||||
"Document must be a string"
|
||||
);
|
||||
expect(() => validator.validateFlow([] as any)).toThrow(
|
||||
expect(() => validator.validate([] as any, { type: "flow" })).toThrow(
|
||||
"Document must be a string"
|
||||
);
|
||||
});
|
||||
@@ -44,7 +44,7 @@ describe("FlowValidator", () => {
|
||||
it("should validate a valid minimal flow from sample file", () => {
|
||||
const validFlow = readSample("valid-minimal.yaml");
|
||||
|
||||
const result = validator.validateFlow(validFlow);
|
||||
const result = validator.validate(validFlow, { type: "flow" });
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.parsed).toBeDefined();
|
||||
@@ -59,7 +59,7 @@ describe("FlowValidator", () => {
|
||||
it("should validate a script flow from sample file", () => {
|
||||
const validFlow = readSample("valid-script-flow.yaml");
|
||||
|
||||
const result = validator.validateFlow(validFlow);
|
||||
const result = validator.validate(validFlow, { type: "flow" });
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.parsed.data).toMatchObject({
|
||||
@@ -94,7 +94,7 @@ describe("FlowValidator", () => {
|
||||
it("should return errors for missing summary from sample file", () => {
|
||||
const invalidFlow = readSample("invalid-missing-summary.yaml");
|
||||
|
||||
const result = validator.validateFlow(invalidFlow);
|
||||
const result = validator.validate(invalidFlow, { type: "flow" });
|
||||
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
@@ -110,7 +110,7 @@ describe("FlowValidator", () => {
|
||||
it("should return errors for invalid types from sample file", () => {
|
||||
const invalidFlow = readSample("invalid-wrong-types.yaml");
|
||||
|
||||
const result = validator.validateFlow(invalidFlow);
|
||||
const result = validator.validate(invalidFlow, { type: "flow" });
|
||||
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
@@ -124,7 +124,7 @@ describe("FlowValidator", () => {
|
||||
it("should return errors for invalid language from sample file", () => {
|
||||
const invalidFlow = readSample("invalid-language.yaml");
|
||||
|
||||
const result = validator.validateFlow(invalidFlow);
|
||||
const result = validator.validate(invalidFlow, { type: "flow" });
|
||||
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
@@ -139,7 +139,7 @@ describe("FlowValidator", () => {
|
||||
it("should handle empty file from sample", () => {
|
||||
const emptyFlow = readSample("empty.yaml");
|
||||
|
||||
const result = validator.validateFlow(emptyFlow);
|
||||
const result = validator.validate(emptyFlow, { type: "flow" });
|
||||
|
||||
expect(result.parsed).toBeDefined();
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
@@ -148,7 +148,7 @@ describe("FlowValidator", () => {
|
||||
it("should handle complex invalid flow with comprehensive error detection", () => {
|
||||
const complexInvalidFlow = readSample("invalid-complex-flow.yaml");
|
||||
|
||||
const result = validator.validateFlow(complexInvalidFlow);
|
||||
const result = validator.validate(complexInvalidFlow, { type: "flow" });
|
||||
|
||||
expect(result.errors.length).toBeGreaterThan(20); // Should have many errors
|
||||
|
||||
@@ -202,7 +202,7 @@ describe("FlowValidator", () => {
|
||||
it("should handle deeply nested invalid structures with detailed error reporting", () => {
|
||||
const nestedInvalidFlow = readSample("invalid-nested-structures.yaml");
|
||||
|
||||
const result = validator.validateFlow(nestedInvalidFlow);
|
||||
const result = validator.validate(nestedInvalidFlow, { type: "flow" });
|
||||
|
||||
expect(result.errors.length).toBeGreaterThan(10); // Should have many nested errors
|
||||
|
||||
@@ -244,7 +244,7 @@ describe("FlowValidator", () => {
|
||||
it("should provide specific error locations for complex validation failures", () => {
|
||||
const complexInvalidFlow = readSample("invalid-complex-flow.yaml");
|
||||
|
||||
const result = validator.validateFlow(complexInvalidFlow);
|
||||
const result = validator.validate(complexInvalidFlow, { type: "flow" });
|
||||
|
||||
// Verify that errors have meaningful instance paths
|
||||
const errorsWithPaths = result.errors.filter(
|
||||
@@ -268,7 +268,7 @@ describe("FlowValidator", () => {
|
||||
it("should handle all major flow control structures with validation errors", () => {
|
||||
const complexInvalidFlow = readSample("invalid-complex-flow.yaml");
|
||||
|
||||
const result = validator.validateFlow(complexInvalidFlow);
|
||||
const result = validator.validate(complexInvalidFlow, { type: "flow" });
|
||||
|
||||
expect(result.errors.length).toBeGreaterThan(20);
|
||||
|
||||
@@ -317,7 +317,7 @@ describe("FlowValidator", () => {
|
||||
it("should validate a basic AI agent flow with FlowModule tools", () => {
|
||||
const validFlow = readSample("valid-aiagent-basic.yaml");
|
||||
|
||||
const result = validator.validateFlow(validFlow);
|
||||
const result = validator.validate(validFlow, { type: "flow" });
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
@@ -325,7 +325,7 @@ describe("FlowValidator", () => {
|
||||
it("should validate an AI agent flow with MCP tools", () => {
|
||||
const validFlow = readSample("valid-aiagent-mcp.yaml");
|
||||
|
||||
const result = validator.validateFlow(validFlow);
|
||||
const result = validator.validate(validFlow, { type: "flow" });
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
@@ -333,7 +333,7 @@ describe("FlowValidator", () => {
|
||||
it("should validate an AI agent flow with mixed FlowModule and MCP tools", () => {
|
||||
const validFlow = readSample("valid-aiagent-mixed.yaml");
|
||||
|
||||
const result = validator.validateFlow(validFlow);
|
||||
const result = validator.validate(validFlow, { type: "flow" });
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
@@ -341,7 +341,7 @@ describe("FlowValidator", () => {
|
||||
it("should validate an AI agent flow with parallel execution enabled", () => {
|
||||
const validFlow = readSample("valid-aiagent-parallel.yaml");
|
||||
|
||||
const result = validator.validateFlow(validFlow);
|
||||
const result = validator.validate(validFlow, { type: "flow" });
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
// Verify tools array has expected structure
|
||||
@@ -354,7 +354,7 @@ describe("FlowValidator", () => {
|
||||
it("should return errors for AI agent missing required tools field", () => {
|
||||
const invalidFlow = readSample("invalid-aiagent-missing-tools.yaml");
|
||||
|
||||
const result = validator.validateFlow(invalidFlow);
|
||||
const result = validator.validate(invalidFlow, { type: "flow" });
|
||||
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
@@ -369,7 +369,7 @@ describe("FlowValidator", () => {
|
||||
it("should return errors for AI agent missing type field", () => {
|
||||
const invalidFlow = readSample("invalid-aiagent-missing-type.yaml");
|
||||
|
||||
const result = validator.validateFlow(invalidFlow);
|
||||
const result = validator.validate(invalidFlow, { type: "flow" });
|
||||
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
// Should fail discriminator validation since type is missing
|
||||
@@ -387,7 +387,7 @@ describe("FlowValidator", () => {
|
||||
"invalid-aiagent-invalid-tool-type.yaml"
|
||||
);
|
||||
|
||||
const result = validator.validateFlow(invalidFlow);
|
||||
const result = validator.validate(invalidFlow, { type: "flow" });
|
||||
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
// Should fail discriminator validation for invalid tool_type
|
||||
@@ -404,7 +404,7 @@ describe("FlowValidator", () => {
|
||||
"invalid-aiagent-mcp-missing-resource.yaml"
|
||||
);
|
||||
|
||||
const result = validator.validateFlow(invalidFlow);
|
||||
const result = validator.validate(invalidFlow, { type: "flow" });
|
||||
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
|
||||
@@ -0,0 +1,643 @@
|
||||
import {
|
||||
getValidationTargetFromFilename,
|
||||
TriggerKind,
|
||||
WindmillYamlValidator,
|
||||
} from "../yaml-validator";
|
||||
|
||||
describe("WindmillYamlValidator resource validation", () => {
|
||||
let validator: WindmillYamlValidator;
|
||||
|
||||
beforeEach(() => {
|
||||
validator = new WindmillYamlValidator();
|
||||
});
|
||||
|
||||
// ── Schedules ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("schedules", () => {
|
||||
it("validates a minimal schedule", () => {
|
||||
const result = validator.validate(
|
||||
JSON.stringify({
|
||||
schedule: "0 0 12 * * *",
|
||||
timezone: "UTC",
|
||||
enabled: true,
|
||||
script_path: "f/jobs/daily_sync",
|
||||
is_flow: false,
|
||||
}),
|
||||
{ type: "schedule" }
|
||||
);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("validates a fully-configured schedule with handlers and retry", () => {
|
||||
const result = validator.validate(
|
||||
JSON.stringify({
|
||||
schedule: "0 */5 * * * *",
|
||||
timezone: "Europe/Paris",
|
||||
enabled: true,
|
||||
script_path: "f/jobs/full_sync",
|
||||
is_flow: true,
|
||||
args: { batch_size: 100, dry_run: false },
|
||||
on_failure: "f/handlers/on_fail",
|
||||
on_failure_times: 3,
|
||||
on_failure_exact: true,
|
||||
on_failure_extra_args: { notify: true },
|
||||
on_recovery: "f/handlers/on_recover",
|
||||
on_recovery_times: 2,
|
||||
on_recovery_extra_args: { channel: "#ops" },
|
||||
on_success: "f/handlers/on_success",
|
||||
on_success_extra_args: { log: true },
|
||||
ws_error_handler_muted: true,
|
||||
retry: {
|
||||
constant: { attempts: 3, seconds: 10 },
|
||||
exponential: {
|
||||
attempts: 5,
|
||||
multiplier: 2,
|
||||
seconds: 1,
|
||||
random_factor: 50,
|
||||
},
|
||||
retry_if: { expr: "error.message.includes('timeout')" },
|
||||
},
|
||||
summary: "Full sync every 5 minutes",
|
||||
description: "Runs the full synchronization pipeline",
|
||||
no_flow_overlap: true,
|
||||
tag: "heavy",
|
||||
paused_until: "2026-03-01T00:00:00Z",
|
||||
cron_version: "v2",
|
||||
dynamic_skip: "f/helpers/should_skip",
|
||||
}),
|
||||
{ type: "schedule" }
|
||||
);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("accepts null for all nullable optional fields", () => {
|
||||
const result = validator.validate(
|
||||
JSON.stringify({
|
||||
schedule: "0 0 12 * * *",
|
||||
timezone: "UTC",
|
||||
enabled: true,
|
||||
script_path: "f/jobs/daily_sync",
|
||||
is_flow: false,
|
||||
args: null,
|
||||
on_failure: null,
|
||||
tag: null,
|
||||
retry: null,
|
||||
paused_until: null,
|
||||
summary: null,
|
||||
description: null,
|
||||
cron_version: null,
|
||||
dynamic_skip: null,
|
||||
}),
|
||||
{ type: "schedule" }
|
||||
);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects a schedule missing required fields", () => {
|
||||
const result = validator.validate(
|
||||
JSON.stringify({ timezone: "UTC" }),
|
||||
{ type: "schedule" }
|
||||
);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("rejects wrong types in schedule fields", () => {
|
||||
const result = validator.validate(
|
||||
JSON.stringify({
|
||||
schedule: 12345,
|
||||
timezone: "UTC",
|
||||
enabled: "yes",
|
||||
script_path: "f/jobs/daily_sync",
|
||||
is_flow: "true",
|
||||
}),
|
||||
{ type: "schedule" }
|
||||
);
|
||||
// Should catch schedule (not string), enabled (not boolean), is_flow (not boolean)
|
||||
expect(result.errors.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("rejects invalid retry constraints", () => {
|
||||
const result = validator.validate(
|
||||
JSON.stringify({
|
||||
schedule: "0 0 12 * * *",
|
||||
timezone: "UTC",
|
||||
enabled: true,
|
||||
script_path: "f/jobs/daily_sync",
|
||||
is_flow: false,
|
||||
retry: {
|
||||
exponential: { attempts: 3, seconds: 0, random_factor: 150 },
|
||||
retry_if: {},
|
||||
},
|
||||
}),
|
||||
{ type: "schedule" }
|
||||
);
|
||||
// seconds < 1, random_factor > 100, retry_if missing expr
|
||||
expect(result.errors.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Triggers — valid minimal + valid with missing required ─────────────
|
||||
|
||||
describe("trigger schemas", () => {
|
||||
const validTriggers: Record<TriggerKind, Record<string, unknown>> = {
|
||||
http: {
|
||||
script_path: "f/triggers/http_handler",
|
||||
is_flow: false,
|
||||
route_path: "api/webhook",
|
||||
request_type: "sync",
|
||||
authentication_method: "none",
|
||||
http_method: "post",
|
||||
is_static_website: false,
|
||||
workspaced_route: false,
|
||||
wrap_body: false,
|
||||
raw_string: false,
|
||||
},
|
||||
websocket: {
|
||||
script_path: "f/triggers/ws_handler",
|
||||
is_flow: false,
|
||||
url: "wss://example.com/socket",
|
||||
filters: [],
|
||||
can_return_message: false,
|
||||
can_return_error_result: true,
|
||||
},
|
||||
kafka: {
|
||||
script_path: "f/triggers/kafka_handler",
|
||||
is_flow: false,
|
||||
kafka_resource_path: "f/resources/kafka",
|
||||
group_id: "group-a",
|
||||
topics: ["topic-a"],
|
||||
filters: [],
|
||||
},
|
||||
nats: {
|
||||
script_path: "f/triggers/nats_handler",
|
||||
is_flow: false,
|
||||
nats_resource_path: "f/resources/nats",
|
||||
use_jetstream: false,
|
||||
subjects: ["events.>"],
|
||||
},
|
||||
postgres: {
|
||||
script_path: "f/triggers/postgres_handler",
|
||||
is_flow: false,
|
||||
postgres_resource_path: "f/resources/postgres",
|
||||
publication_name: "pub_main",
|
||||
replication_slot_name: "slot_main",
|
||||
},
|
||||
mqtt: {
|
||||
script_path: "f/triggers/mqtt_handler",
|
||||
is_flow: false,
|
||||
mqtt_resource_path: "f/resources/mqtt",
|
||||
subscribe_topics: [],
|
||||
},
|
||||
sqs: {
|
||||
script_path: "f/triggers/sqs_handler",
|
||||
is_flow: false,
|
||||
queue_url: "https://sqs.us-east-1.amazonaws.com/12345/my-queue",
|
||||
aws_resource_path: "f/resources/aws",
|
||||
aws_auth_resource_type: "credentials",
|
||||
},
|
||||
gcp: {
|
||||
script_path: "f/triggers/gcp_handler",
|
||||
is_flow: false,
|
||||
gcp_resource_path: "f/resources/gcp",
|
||||
topic_id: "topic-a",
|
||||
subscription_id: "sub-a",
|
||||
delivery_type: "pull",
|
||||
subscription_mode: "existing",
|
||||
},
|
||||
email: {
|
||||
script_path: "f/triggers/email_handler",
|
||||
is_flow: false,
|
||||
local_part: "inbox",
|
||||
},
|
||||
};
|
||||
|
||||
const missingRequiredField: Record<TriggerKind, string> = {
|
||||
http: "request_type",
|
||||
websocket: "url",
|
||||
kafka: "topics",
|
||||
nats: "subjects",
|
||||
postgres: "publication_name",
|
||||
mqtt: "mqtt_resource_path",
|
||||
sqs: "queue_url",
|
||||
gcp: "topic_id",
|
||||
email: "local_part",
|
||||
};
|
||||
|
||||
for (const [kind, validDocument] of Object.entries(validTriggers) as [
|
||||
TriggerKind,
|
||||
Record<string, unknown>,
|
||||
][]) {
|
||||
it(`validates a valid ${kind} trigger file`, () => {
|
||||
const result = validator.validate(JSON.stringify(validDocument), {
|
||||
type: "trigger",
|
||||
triggerKind: kind,
|
||||
});
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it(`returns required-field errors for invalid ${kind} trigger file`, () => {
|
||||
const missingField = missingRequiredField[kind];
|
||||
const invalidDocument = { ...validDocument };
|
||||
delete invalidDocument[missingField];
|
||||
|
||||
const result = validator.validate(JSON.stringify(invalidDocument), {
|
||||
type: "trigger",
|
||||
triggerKind: kind,
|
||||
});
|
||||
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
result.errors.some(
|
||||
(error) =>
|
||||
error.keyword === "required" &&
|
||||
(error.params as { missingProperty?: string })?.missingProperty ===
|
||||
missingField
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("throws for unsupported trigger kinds", () => {
|
||||
expect(() =>
|
||||
validator.validate("{}", {
|
||||
type: "trigger",
|
||||
triggerKind: "foobar" as any,
|
||||
})
|
||||
).toThrow("Unsupported trigger kind: foobar");
|
||||
});
|
||||
|
||||
// ── Triggers — realistic full documents with all optional fields ───────
|
||||
|
||||
describe("fully-configured triggers", () => {
|
||||
it("validates http trigger with static assets, auth, error handling and retry", () => {
|
||||
const result = validator.validate(
|
||||
JSON.stringify({
|
||||
script_path: "f/triggers/http_handler",
|
||||
is_flow: false,
|
||||
route_path: "api/webhook",
|
||||
request_type: "sync_sse",
|
||||
authentication_method: "api_key",
|
||||
http_method: "put",
|
||||
is_static_website: true,
|
||||
workspaced_route: true,
|
||||
wrap_body: true,
|
||||
raw_string: false,
|
||||
summary: "Incoming webhook",
|
||||
description: "Handles external webhook deliveries",
|
||||
authentication_resource_path: "f/resources/api_key_config",
|
||||
static_asset_config: { s3: "my-bucket/assets", filename: "index.html" },
|
||||
error_handler_path: "f/handlers/on_error",
|
||||
error_handler_args: { notify: true },
|
||||
retry: {
|
||||
constant: { attempts: 2, seconds: 5 },
|
||||
retry_if: { expr: "error.status === 429" },
|
||||
},
|
||||
}),
|
||||
{ type: "trigger", triggerKind: "http" }
|
||||
);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("validates websocket trigger with initial messages and filters", () => {
|
||||
const result = validator.validate(
|
||||
JSON.stringify({
|
||||
script_path: "f/triggers/ws_handler",
|
||||
is_flow: true,
|
||||
url: "wss://stream.example.com/v1",
|
||||
filters: [{ key: "event", value: "trade" }],
|
||||
can_return_message: true,
|
||||
can_return_error_result: false,
|
||||
initial_messages: [
|
||||
{ raw_message: '{"action":"subscribe","channel":"trades"}' },
|
||||
{
|
||||
runnable_result: {
|
||||
path: "f/helpers/ws_auth",
|
||||
args: { token: "abc" },
|
||||
is_flow: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
url_runnable_args: { env: "production" },
|
||||
error_handler_path: "f/handlers/on_error",
|
||||
retry: { exponential: { attempts: 5, multiplier: 2, seconds: 1, random_factor: 25 } },
|
||||
}),
|
||||
{ type: "trigger", triggerKind: "websocket" }
|
||||
);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("validates mqtt trigger with v5 config and subscribe topics", () => {
|
||||
const result = validator.validate(
|
||||
JSON.stringify({
|
||||
script_path: "f/triggers/mqtt_handler",
|
||||
is_flow: false,
|
||||
mqtt_resource_path: "f/resources/mqtt",
|
||||
subscribe_topics: [
|
||||
{ topic: "sensor/+/data", qos: "qos1" },
|
||||
{ topic: "alerts/#", qos: "qos2" },
|
||||
],
|
||||
client_version: "v5",
|
||||
client_id: "windmill-consumer-1",
|
||||
v5_config: {
|
||||
clean_start: true,
|
||||
topic_alias_maximum: 10,
|
||||
session_expiry_interval: 300,
|
||||
},
|
||||
error_handler_path: "f/handlers/on_error",
|
||||
retry: { constant: { attempts: 3, seconds: 10 } },
|
||||
}),
|
||||
{ type: "trigger", triggerKind: "mqtt" }
|
||||
);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("validates nats trigger with jetstream config", () => {
|
||||
const result = validator.validate(
|
||||
JSON.stringify({
|
||||
script_path: "f/triggers/nats_handler",
|
||||
is_flow: true,
|
||||
nats_resource_path: "f/resources/nats",
|
||||
use_jetstream: true,
|
||||
subjects: ["orders.>"],
|
||||
stream_name: "ORDERS",
|
||||
consumer_name: "windmill-consumer",
|
||||
error_handler_path: "f/handlers/on_error",
|
||||
error_handler_args: {},
|
||||
retry: { constant: { attempts: 5, seconds: 30 } },
|
||||
}),
|
||||
{ type: "trigger", triggerKind: "nats" }
|
||||
);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("validates sqs trigger with message attributes and oidc auth", () => {
|
||||
const result = validator.validate(
|
||||
JSON.stringify({
|
||||
script_path: "f/triggers/sqs_handler",
|
||||
is_flow: false,
|
||||
queue_url: "https://sqs.us-east-1.amazonaws.com/12345/my-queue",
|
||||
aws_resource_path: "f/resources/aws",
|
||||
aws_auth_resource_type: "oidc",
|
||||
message_attributes: ["traceId", "source"],
|
||||
error_handler_path: "f/handlers/on_error",
|
||||
}),
|
||||
{ type: "trigger", triggerKind: "sqs" }
|
||||
);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("validates gcp trigger with push delivery config", () => {
|
||||
const result = validator.validate(
|
||||
JSON.stringify({
|
||||
script_path: "f/triggers/gcp_handler",
|
||||
is_flow: false,
|
||||
gcp_resource_path: "f/resources/gcp",
|
||||
topic_id: "topic-a",
|
||||
subscription_id: "sub-push",
|
||||
delivery_type: "push",
|
||||
subscription_mode: "create_update",
|
||||
delivery_config: {
|
||||
authenticate: true,
|
||||
base_endpoint: "https://app.example.com/webhook",
|
||||
audience: "https://app.example.com",
|
||||
},
|
||||
error_handler_path: "f/handlers/on_error",
|
||||
retry: { retry_if: { expr: "error.message.includes('quota')" } },
|
||||
}),
|
||||
{ type: "trigger", triggerKind: "gcp" }
|
||||
);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Realistic user mistakes ────────────────────────────────────────────
|
||||
|
||||
describe("realistic user mistakes", () => {
|
||||
it("catches invalid enum values across trigger types", () => {
|
||||
// User typos an http_method as uppercase
|
||||
const httpResult = validator.validate(
|
||||
JSON.stringify({
|
||||
script_path: "f/triggers/http_handler",
|
||||
is_flow: false,
|
||||
route_path: "api/webhook",
|
||||
request_type: "sync",
|
||||
authentication_method: "none",
|
||||
http_method: "POST",
|
||||
is_static_website: false,
|
||||
workspaced_route: false,
|
||||
wrap_body: false,
|
||||
raw_string: false,
|
||||
}),
|
||||
{ type: "trigger", triggerKind: "http" }
|
||||
);
|
||||
expect(httpResult.errors.length).toBeGreaterThan(0);
|
||||
|
||||
// User writes "iam_role" instead of "oidc" or "credentials"
|
||||
const sqsResult = validator.validate(
|
||||
JSON.stringify({
|
||||
script_path: "f/triggers/sqs_handler",
|
||||
is_flow: false,
|
||||
queue_url: "https://sqs.us-east-1.amazonaws.com/12345/q",
|
||||
aws_resource_path: "f/resources/aws",
|
||||
aws_auth_resource_type: "iam_role",
|
||||
}),
|
||||
{ type: "trigger", triggerKind: "sqs" }
|
||||
);
|
||||
expect(sqsResult.errors.length).toBeGreaterThan(0);
|
||||
|
||||
// User writes "stream" instead of "push" or "pull"
|
||||
const gcpResult = validator.validate(
|
||||
JSON.stringify({
|
||||
script_path: "f/triggers/gcp_handler",
|
||||
is_flow: false,
|
||||
gcp_resource_path: "f/resources/gcp",
|
||||
topic_id: "t",
|
||||
subscription_id: "s",
|
||||
delivery_type: "stream",
|
||||
subscription_mode: "existing",
|
||||
}),
|
||||
{ type: "trigger", triggerKind: "gcp" }
|
||||
);
|
||||
expect(gcpResult.errors.length).toBeGreaterThan(0);
|
||||
|
||||
// User writes "v4" instead of "v3" or "v5"
|
||||
const mqttResult = validator.validate(
|
||||
JSON.stringify({
|
||||
script_path: "f/triggers/mqtt_handler",
|
||||
is_flow: false,
|
||||
mqtt_resource_path: "f/resources/mqtt",
|
||||
subscribe_topics: [{ topic: "t", qos: "qos1" }],
|
||||
client_version: "v4",
|
||||
}),
|
||||
{ type: "trigger", triggerKind: "mqtt" }
|
||||
);
|
||||
expect(mqttResult.errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("catches malformed nested structures", () => {
|
||||
// MQTT topic entry missing qos
|
||||
const mqttResult = validator.validate(
|
||||
JSON.stringify({
|
||||
script_path: "f/triggers/mqtt_handler",
|
||||
is_flow: false,
|
||||
mqtt_resource_path: "f/resources/mqtt",
|
||||
subscribe_topics: [{ topic: "test/topic" }],
|
||||
}),
|
||||
{ type: "trigger", triggerKind: "mqtt" }
|
||||
);
|
||||
expect(mqttResult.errors.length).toBeGreaterThan(0);
|
||||
|
||||
// HTTP static_asset_config without required s3 field
|
||||
const httpResult = validator.validate(
|
||||
JSON.stringify({
|
||||
script_path: "f/triggers/http_handler",
|
||||
is_flow: false,
|
||||
route_path: "api/assets",
|
||||
request_type: "sync",
|
||||
authentication_method: "none",
|
||||
http_method: "get",
|
||||
is_static_website: true,
|
||||
workspaced_route: false,
|
||||
wrap_body: false,
|
||||
raw_string: false,
|
||||
static_asset_config: { filename: "index.html" },
|
||||
}),
|
||||
{ type: "trigger", triggerKind: "http" }
|
||||
);
|
||||
expect(httpResult.errors.length).toBeGreaterThan(0);
|
||||
|
||||
// GCP delivery_config without required authenticate / base_endpoint
|
||||
const gcpResult = validator.validate(
|
||||
JSON.stringify({
|
||||
script_path: "f/triggers/gcp_handler",
|
||||
is_flow: false,
|
||||
gcp_resource_path: "f/resources/gcp",
|
||||
topic_id: "t",
|
||||
subscription_id: "s",
|
||||
delivery_type: "push",
|
||||
subscription_mode: "create_update",
|
||||
delivery_config: { audience: "test" },
|
||||
}),
|
||||
{ type: "trigger", triggerKind: "gcp" }
|
||||
);
|
||||
expect(gcpResult.errors.length).toBeGreaterThan(0);
|
||||
|
||||
// Websocket filter missing key
|
||||
const wsResult = validator.validate(
|
||||
JSON.stringify({
|
||||
script_path: "f/triggers/ws_handler",
|
||||
is_flow: false,
|
||||
url: "wss://example.com/socket",
|
||||
filters: [{ value: "test" }],
|
||||
can_return_message: false,
|
||||
can_return_error_result: true,
|
||||
}),
|
||||
{ type: "trigger", triggerKind: "websocket" }
|
||||
);
|
||||
expect(wsResult.errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("catches wrong types in YAML (string-for-boolean, object-for-array)", () => {
|
||||
// is_flow: "false" — YAML without quotes would parse to boolean,
|
||||
// but a quoted "false" parses as string
|
||||
const natsResult = validator.validate(
|
||||
JSON.stringify({
|
||||
script_path: "f/triggers/nats_handler",
|
||||
is_flow: "false",
|
||||
nats_resource_path: "f/resources/nats",
|
||||
use_jetstream: "yes",
|
||||
subjects: ["events.>"],
|
||||
}),
|
||||
{ type: "trigger", triggerKind: "nats" }
|
||||
);
|
||||
expect(natsResult.errors.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// topics as {} instead of []
|
||||
const kafkaResult = validator.validate(
|
||||
JSON.stringify({
|
||||
script_path: "f/triggers/kafka_handler",
|
||||
is_flow: false,
|
||||
kafka_resource_path: "f/resources/kafka",
|
||||
group_id: "g",
|
||||
topics: {},
|
||||
filters: "not-an-array",
|
||||
}),
|
||||
{ type: "trigger", triggerKind: "kafka" }
|
||||
);
|
||||
expect(kafkaResult.errors.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getValidationTargetFromFilename", () => {
|
||||
it("detects flow files", () => {
|
||||
expect(getValidationTargetFromFilename("f/my.flow/flow.yaml")).toEqual({
|
||||
type: "flow",
|
||||
});
|
||||
expect(getValidationTargetFromFilename("flow.yml")).toEqual({
|
||||
type: "flow",
|
||||
});
|
||||
});
|
||||
|
||||
it("detects schedule files", () => {
|
||||
expect(
|
||||
getValidationTargetFromFilename("f/folder/daily.schedule.yaml")
|
||||
).toEqual({
|
||||
type: "schedule",
|
||||
});
|
||||
});
|
||||
|
||||
it("detects all 9 trigger kinds", () => {
|
||||
const kinds = [
|
||||
"http",
|
||||
"websocket",
|
||||
"kafka",
|
||||
"nats",
|
||||
"postgres",
|
||||
"mqtt",
|
||||
"sqs",
|
||||
"gcp",
|
||||
"email",
|
||||
] as const;
|
||||
|
||||
for (const kind of kinds) {
|
||||
expect(
|
||||
getValidationTargetFromFilename(
|
||||
`f/triggers/handler.${kind}_trigger.yaml`
|
||||
)
|
||||
).toEqual({
|
||||
type: "trigger",
|
||||
triggerKind: kind,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("is case-insensitive for extensions", () => {
|
||||
expect(getValidationTargetFromFilename("f/my.flow/flow.YAML")).toEqual({
|
||||
type: "flow",
|
||||
});
|
||||
expect(
|
||||
getValidationTargetFromFilename("f/folder/daily.schedule.YML")
|
||||
).toEqual({
|
||||
type: "schedule",
|
||||
});
|
||||
});
|
||||
|
||||
it("handles dots in directory names", () => {
|
||||
expect(
|
||||
getValidationTargetFromFilename("f/my.app.flow/flow.yaml")
|
||||
).toEqual({
|
||||
type: "flow",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for unsupported trigger kinds and non-windmill files", () => {
|
||||
expect(getValidationTargetFromFilename("README.md")).toBeNull();
|
||||
expect(getValidationTargetFromFilename("f/folder/script.py")).toBeNull();
|
||||
expect(
|
||||
getValidationTargetFromFilename("f/folder/resource.yaml")
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -12,9 +12,18 @@ value:
|
||||
value:
|
||||
type: aiagent
|
||||
input_transforms:
|
||||
prompt:
|
||||
provider:
|
||||
type: static
|
||||
value:
|
||||
kind: openai
|
||||
resource: "$res:u/admin/openai"
|
||||
model: gpt-4o-mini
|
||||
user_message:
|
||||
type: static
|
||||
value: "Please analyze the data"
|
||||
output_type:
|
||||
type: static
|
||||
value: text
|
||||
tools:
|
||||
- id: tool_1
|
||||
summary: Data fetcher
|
||||
|
||||
@@ -6,9 +6,18 @@ value:
|
||||
value:
|
||||
type: aiagent
|
||||
input_transforms:
|
||||
task:
|
||||
provider:
|
||||
type: static
|
||||
value:
|
||||
kind: openai
|
||||
resource: "$res:u/admin/openai"
|
||||
model: gpt-4o-mini
|
||||
user_message:
|
||||
type: static
|
||||
value: "Search for information"
|
||||
output_type:
|
||||
type: static
|
||||
value: text
|
||||
tools:
|
||||
- id: mcp_search
|
||||
summary: Search tool from MCP
|
||||
|
||||
@@ -11,9 +11,18 @@ value:
|
||||
value:
|
||||
type: aiagent
|
||||
input_transforms:
|
||||
query:
|
||||
provider:
|
||||
type: static
|
||||
value:
|
||||
kind: openai
|
||||
resource: "$res:u/admin/openai"
|
||||
model: gpt-4o-mini
|
||||
user_message:
|
||||
type: javascript
|
||||
expr: "flow_input.user_query"
|
||||
output_type:
|
||||
type: static
|
||||
value: text
|
||||
tools:
|
||||
- id: custom_script
|
||||
value:
|
||||
|
||||
@@ -7,12 +7,18 @@ value:
|
||||
type: aiagent
|
||||
parallel: true
|
||||
input_transforms:
|
||||
instruction:
|
||||
provider:
|
||||
type: static
|
||||
value:
|
||||
kind: openai
|
||||
resource: "$res:u/admin/openai"
|
||||
model: gpt-4o-mini
|
||||
user_message:
|
||||
type: static
|
||||
value: "Process data in parallel"
|
||||
model:
|
||||
type: javascript
|
||||
expr: "'gpt-4'"
|
||||
output_type:
|
||||
type: static
|
||||
value: text
|
||||
tools:
|
||||
- id: parallel_tool_1
|
||||
summary: First parallel tool
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import Ajv, { AnySchema, ErrorObject, ValidateFunction } from 'ajv';
|
||||
import { parseWithPointers, YamlParserResult } from '@stoplight/yaml';
|
||||
import openFlowSchema from '../gen/openflow.json';
|
||||
|
||||
/**
|
||||
* Flow validator class that initializes AJV once and reuses it for validation.
|
||||
*/
|
||||
export class FlowValidator {
|
||||
private readonly validate: ValidateFunction;
|
||||
|
||||
constructor() {
|
||||
const ajv = new Ajv({ strict: false, allErrors: true, discriminator: true });
|
||||
|
||||
for (const [n, s] of Object.entries(openFlowSchema.components.schemas)) {
|
||||
ajv.addSchema(s as AnySchema, `#/components/schemas/${n}`);
|
||||
}
|
||||
|
||||
this.validate = ajv.getSchema('#/components/schemas/OpenFlow')!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a flow document against the OpenFlow schema.
|
||||
* @param doc - The YAML flow document as a string
|
||||
* @returns Object containing the parsed document and any validation errors
|
||||
*/
|
||||
validateFlow(doc: string): {
|
||||
parsed: YamlParserResult<unknown>;
|
||||
errors: ErrorObject[];
|
||||
} {
|
||||
if (typeof doc !== 'string') {
|
||||
throw new Error('Document must be a string');
|
||||
}
|
||||
|
||||
const parsed = parseWithPointers(doc);
|
||||
const { data } = parsed;
|
||||
const ok = this.validate(data);
|
||||
|
||||
if (ok) {
|
||||
return {
|
||||
parsed,
|
||||
errors: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
parsed,
|
||||
errors: this.validate.errors!,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
export * from './flow-validator';
|
||||
export * from "./yaml-validator";
|
||||
|
||||
141
windmill-yaml-validator/src/validation/yaml-validator.ts
Normal file
141
windmill-yaml-validator/src/validation/yaml-validator.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import Ajv, { AnySchema, ErrorObject, ValidateFunction } from "ajv";
|
||||
import { parseWithPointers, YamlParserResult } from "@stoplight/yaml";
|
||||
import openFlowSchema from "../gen/openflow.json";
|
||||
import scheduleSchema from "../gen/schedule.json";
|
||||
import gcpTriggerSchema from "../gen/triggers/gcp.json";
|
||||
import httpTriggerSchema from "../gen/triggers/http.json";
|
||||
import kafkaTriggerSchema from "../gen/triggers/kafka.json";
|
||||
import mqttTriggerSchema from "../gen/triggers/mqtt.json";
|
||||
import natsTriggerSchema from "../gen/triggers/nats.json";
|
||||
import postgresTriggerSchema from "../gen/triggers/postgres.json";
|
||||
import sqsTriggerSchema from "../gen/triggers/sqs.json";
|
||||
import websocketTriggerSchema from "../gen/triggers/websocket.json";
|
||||
import emailTriggerSchema from "../gen/triggers/email.json";
|
||||
|
||||
export const SUPPORTED_TRIGGER_KINDS = [
|
||||
"http",
|
||||
"websocket",
|
||||
"kafka",
|
||||
"nats",
|
||||
"postgres",
|
||||
"mqtt",
|
||||
"sqs",
|
||||
"gcp",
|
||||
"email",
|
||||
] as const;
|
||||
|
||||
export type TriggerKind = (typeof SUPPORTED_TRIGGER_KINDS)[number];
|
||||
|
||||
export type ValidationTarget =
|
||||
| { type: "flow" }
|
||||
| { type: "schedule" }
|
||||
| { type: "trigger"; triggerKind: TriggerKind };
|
||||
|
||||
const TRIGGER_SCHEMAS: Record<TriggerKind, AnySchema> = {
|
||||
http: httpTriggerSchema as AnySchema,
|
||||
websocket: websocketTriggerSchema as AnySchema,
|
||||
kafka: kafkaTriggerSchema as AnySchema,
|
||||
nats: natsTriggerSchema as AnySchema,
|
||||
postgres: postgresTriggerSchema as AnySchema,
|
||||
mqtt: mqttTriggerSchema as AnySchema,
|
||||
sqs: sqsTriggerSchema as AnySchema,
|
||||
gcp: gcpTriggerSchema as AnySchema,
|
||||
email: emailTriggerSchema as AnySchema,
|
||||
};
|
||||
|
||||
/**
|
||||
* Infers validation target from file name conventions used by Windmill sync.
|
||||
*/
|
||||
export function getValidationTargetFromFilename(
|
||||
filePath: string
|
||||
): ValidationTarget | null {
|
||||
const path = filePath.toLowerCase();
|
||||
|
||||
if (/[/\\]flow\.ya?ml$/.test(path) || /^flow\.ya?ml$/.test(path)) {
|
||||
return { type: "flow" };
|
||||
}
|
||||
|
||||
if (/\.schedule\.ya?ml$/.test(path)) {
|
||||
return { type: "schedule" };
|
||||
}
|
||||
|
||||
const triggerMatch = path.match(
|
||||
/\.(http|websocket|kafka|nats|postgres|mqtt|sqs|gcp|email)_trigger\.ya?ml$/
|
||||
);
|
||||
if (triggerMatch) {
|
||||
return {
|
||||
type: "trigger",
|
||||
triggerKind: triggerMatch[1] as TriggerKind,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified YAML validator for Windmill flow, schedule, and trigger files.
|
||||
*/
|
||||
export class WindmillYamlValidator {
|
||||
private readonly validateFlow: ValidateFunction;
|
||||
private readonly validateSchedule: ValidateFunction;
|
||||
private readonly validateTrigger: Record<TriggerKind, ValidateFunction>;
|
||||
|
||||
constructor() {
|
||||
const ajv = new Ajv({
|
||||
strict: false,
|
||||
allErrors: true,
|
||||
discriminator: true,
|
||||
validateFormats: false,
|
||||
});
|
||||
|
||||
for (const [name, schema] of Object.entries(openFlowSchema.components.schemas)) {
|
||||
ajv.addSchema(schema as AnySchema, `#/components/schemas/${name}`);
|
||||
}
|
||||
|
||||
this.validateFlow = ajv.getSchema("#/components/schemas/OpenFlow")!;
|
||||
this.validateSchedule = ajv.compile(scheduleSchema as AnySchema);
|
||||
|
||||
this.validateTrigger = Object.fromEntries(
|
||||
SUPPORTED_TRIGGER_KINDS.map((kind) => [kind, ajv.compile(TRIGGER_SCHEMAS[kind])])
|
||||
) as Record<TriggerKind, ValidateFunction>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a Windmill YAML document based on the selected target.
|
||||
* @param doc - The YAML document as string
|
||||
* @param target - Which Windmill schema to validate against
|
||||
*/
|
||||
validate(
|
||||
doc: string,
|
||||
target: ValidationTarget
|
||||
): { parsed: YamlParserResult<unknown>; errors: ErrorObject[] } {
|
||||
if (typeof doc !== "string") {
|
||||
throw new Error("Document must be a string");
|
||||
}
|
||||
|
||||
const parsed = parseWithPointers(doc);
|
||||
const { data } = parsed;
|
||||
|
||||
let validator: ValidateFunction;
|
||||
if (target.type === "flow") {
|
||||
validator = this.validateFlow;
|
||||
} else if (target.type === "schedule") {
|
||||
validator = this.validateSchedule;
|
||||
} else {
|
||||
validator = this.validateTrigger[target.triggerKind];
|
||||
if (!validator) {
|
||||
throw new Error(`Unsupported trigger kind: ${target.triggerKind}`);
|
||||
}
|
||||
}
|
||||
|
||||
const ok = validator(data);
|
||||
if (ok) {
|
||||
return { parsed, errors: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
parsed,
|
||||
errors: validator.errors || [],
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user