* 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>
235 lines
6.0 KiB
JavaScript
235 lines
6.0 KiB
JavaScript
#!/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();
|