|
3 | 3 | * JSON-Schema-shaped manifest `configSchema` block used by both integrations |
4 | 4 | * and services. |
5 | 5 | * |
6 | | - * Supports the field shapes the admin config form actually emits: |
7 | | - * - `type: "boolean" | "number" | "integer" | "string"` |
8 | | - * - `enum: unknown[]` |
| 6 | + * Supports the JSON Schema subset emitted by integration manifests: |
| 7 | + * - scalar, array, and object types |
| 8 | + * - enum / const / oneOf / allOf / if-then-else |
| 9 | + * - required and additionalProperties |
| 10 | + * - string, numeric, and collection bounds |
| 11 | + * - nested URL validation |
9 | 12 | * - `x-openmapx-secret: true` (must be set via credentials API, not config) |
10 | 13 | * |
11 | | - * Anything more complex (oneOf, refs, nested objects, arrays) falls through |
12 | | - * unchanged — those shapes don't render in the form yet anyway. The validator |
13 | | - * returns an `{ updates, errors }` pair; callers persist `updates` only when |
14 | | - * `errors` is empty. |
| 14 | + * The validator returns an `{ updates, errors }` pair; callers persist updates |
| 15 | + * only when errors is empty. |
15 | 16 | */ |
16 | 17 |
|
17 | 18 | import { type CredentialSetup, readCredentialSetup } from "@openmapx/integration-framework"; |
@@ -70,6 +71,129 @@ export interface ValidateConfigOptions { |
70 | 71 | rejectSecrets?: boolean; |
71 | 72 | } |
72 | 73 |
|
| 74 | +function isRecord(value: unknown): value is Record<string, unknown> { |
| 75 | + return typeof value === "object" && value !== null && !Array.isArray(value); |
| 76 | +} |
| 77 | + |
| 78 | +function validateSchemaValue( |
| 79 | + value: unknown, |
| 80 | + schema: Record<string, unknown>, |
| 81 | + path: string, |
| 82 | +): string[] { |
| 83 | + if (Array.isArray(schema.allOf)) { |
| 84 | + const { allOf, ...baseSchema } = schema; |
| 85 | + return [ |
| 86 | + ...validateSchemaValue(value, baseSchema, path), |
| 87 | + ...allOf.flatMap((entry) => |
| 88 | + isRecord(entry) |
| 89 | + ? validateSchemaValue(value, entry, path) |
| 90 | + : [`${path} has an invalid schema`], |
| 91 | + ), |
| 92 | + ]; |
| 93 | + } |
| 94 | + |
| 95 | + if (isRecord(schema.if)) { |
| 96 | + const { if: condition, then, else: otherwise, ...baseSchema } = schema; |
| 97 | + const conditionMatches = validateSchemaValue(value, condition, path).length === 0; |
| 98 | + const branch = conditionMatches ? then : otherwise; |
| 99 | + return [ |
| 100 | + ...validateSchemaValue(value, baseSchema, path), |
| 101 | + ...(isRecord(branch) ? validateSchemaValue(value, branch, path) : []), |
| 102 | + ]; |
| 103 | + } |
| 104 | + |
| 105 | + const oneOf = schema.oneOf; |
| 106 | + if (Array.isArray(oneOf)) { |
| 107 | + const candidateResults = oneOf.map((candidate) => |
| 108 | + isRecord(candidate) |
| 109 | + ? validateSchemaValue(value, candidate, path) |
| 110 | + : [`${path} has an invalid schema`], |
| 111 | + ); |
| 112 | + const matches = candidateResults.filter((errors) => errors.length === 0); |
| 113 | + if (matches.length === 1) return []; |
| 114 | + if (matches.length > 1) return [`${path} matches more than one allowed shape`]; |
| 115 | + const discriminator = |
| 116 | + isRecord(value) && typeof value.type === "string" ? ` for type "${value.type}"` : ""; |
| 117 | + return [`${path} does not match an allowed shape${discriminator}`]; |
| 118 | + } |
| 119 | + |
| 120 | + if ("const" in schema && value !== schema.const) { |
| 121 | + return [`${path} must equal ${JSON.stringify(schema.const)}`]; |
| 122 | + } |
| 123 | + if (Array.isArray(schema.enum) && !schema.enum.includes(value)) { |
| 124 | + return [`${path} must be one of: ${schema.enum.join(", ")}`]; |
| 125 | + } |
| 126 | + |
| 127 | + const type = schema.type; |
| 128 | + if (type === "boolean" && typeof value !== "boolean") return [`${path} must be a boolean`]; |
| 129 | + if (type === "string") { |
| 130 | + if (typeof value !== "string") return [`${path} must be a string`]; |
| 131 | + if (typeof schema.minLength === "number" && value.length < schema.minLength) { |
| 132 | + return [`${path} must have at least ${schema.minLength} characters`]; |
| 133 | + } |
| 134 | + if (typeof schema.maxLength === "number" && value.length > schema.maxLength) { |
| 135 | + return [`${path} must have at most ${schema.maxLength} characters`]; |
| 136 | + } |
| 137 | + if (typeof schema.pattern === "string" && !new RegExp(schema.pattern).test(value)) { |
| 138 | + return [`${path} has an invalid format`]; |
| 139 | + } |
| 140 | + if (schema.format === "url" && value !== "") { |
| 141 | + try { |
| 142 | + const parsed = new URL(value); |
| 143 | + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { |
| 144 | + return [`${path} must be a valid http(s) URL`]; |
| 145 | + } |
| 146 | + } catch { |
| 147 | + return [`${path} must be a valid http(s) URL`]; |
| 148 | + } |
| 149 | + } |
| 150 | + return []; |
| 151 | + } |
| 152 | + if (type === "number" || type === "integer") { |
| 153 | + if (typeof value !== "number" || !Number.isFinite(value)) return [`${path} must be a number`]; |
| 154 | + if (type === "integer" && !Number.isInteger(value)) return [`${path} must be an integer`]; |
| 155 | + if (typeof schema.minimum === "number" && value < schema.minimum) { |
| 156 | + return [`${path} must be at least ${schema.minimum}`]; |
| 157 | + } |
| 158 | + if (typeof schema.maximum === "number" && value > schema.maximum) { |
| 159 | + return [`${path} must be at most ${schema.maximum}`]; |
| 160 | + } |
| 161 | + return []; |
| 162 | + } |
| 163 | + if (type === "array") { |
| 164 | + if (!Array.isArray(value)) return [`${path} must be an array`]; |
| 165 | + if (typeof schema.minItems === "number" && value.length < schema.minItems) { |
| 166 | + return [`${path} must contain at least ${schema.minItems} item(s)`]; |
| 167 | + } |
| 168 | + if (typeof schema.maxItems === "number" && value.length > schema.maxItems) { |
| 169 | + return [`${path} must contain at most ${schema.maxItems} item(s)`]; |
| 170 | + } |
| 171 | + if (!isRecord(schema.items)) return []; |
| 172 | + return value.flatMap((entry, index) => |
| 173 | + validateSchemaValue(entry, schema.items as Record<string, unknown>, `${path}[${index}]`), |
| 174 | + ); |
| 175 | + } |
| 176 | + if (type === "object") { |
| 177 | + if (!isRecord(value)) return [`${path} must be an object`]; |
| 178 | + const properties = isRecord(schema.properties) ? schema.properties : {}; |
| 179 | + const required = Array.isArray(schema.required) ? new Set(schema.required) : new Set<unknown>(); |
| 180 | + const errors: string[] = []; |
| 181 | + for (const key of required) { |
| 182 | + if (typeof key === "string" && !(key in value)) errors.push(`${path}.${key} is required`); |
| 183 | + } |
| 184 | + for (const [key, nestedValue] of Object.entries(value)) { |
| 185 | + const nestedSchema = properties[key]; |
| 186 | + if (!isRecord(nestedSchema)) { |
| 187 | + if (schema.additionalProperties === false) errors.push(`${path}.${key} is not allowed`); |
| 188 | + continue; |
| 189 | + } |
| 190 | + errors.push(...validateSchemaValue(nestedValue, nestedSchema, `${path}.${key}`)); |
| 191 | + } |
| 192 | + return errors; |
| 193 | + } |
| 194 | + return []; |
| 195 | +} |
| 196 | + |
73 | 197 | export function validateConfigBody( |
74 | 198 | body: unknown, |
75 | 199 | configSchema: Record<string, unknown> | undefined, |
@@ -104,34 +228,9 @@ export function validateConfigBody( |
104 | 228 | continue; |
105 | 229 | } |
106 | 230 |
|
107 | | - const type = def.type as string | undefined; |
108 | | - if (type === "boolean" && typeof value !== "boolean") { |
109 | | - result.errors.push(`"${key}" must be a boolean`); |
110 | | - continue; |
111 | | - } |
112 | | - if ((type === "number" || type === "integer") && typeof value !== "number") { |
113 | | - result.errors.push(`"${key}" must be a number`); |
114 | | - continue; |
115 | | - } |
116 | | - if (type === "string" && typeof value !== "string") { |
117 | | - result.errors.push(`"${key}" must be a string`); |
118 | | - continue; |
119 | | - } |
120 | | - const format = def.format as string | undefined; |
121 | | - if (format === "url" && typeof value === "string" && value !== "") { |
122 | | - let parsed: URL | null = null; |
123 | | - try { |
124 | | - parsed = new URL(value); |
125 | | - } catch { |
126 | | - parsed = null; |
127 | | - } |
128 | | - if (!parsed || (parsed.protocol !== "http:" && parsed.protocol !== "https:")) { |
129 | | - result.errors.push(`"${key}" must be a valid http(s) URL`); |
130 | | - continue; |
131 | | - } |
132 | | - } |
133 | | - if (def.enum && !(def.enum as unknown[]).includes(value)) { |
134 | | - result.errors.push(`"${key}" must be one of: ${(def.enum as unknown[]).join(", ")}`); |
| 231 | + const errors = validateSchemaValue(value, def, `"${key}"`); |
| 232 | + if (errors.length > 0) { |
| 233 | + result.errors.push(...errors); |
135 | 234 | continue; |
136 | 235 | } |
137 | 236 |
|
|
0 commit comments