diff --git a/src/components/config-builder/ConfigBuilder.tsx b/src/components/config-builder/ConfigBuilder.tsx new file mode 100644 index 000000000..8d2f775be --- /dev/null +++ b/src/components/config-builder/ConfigBuilder.tsx @@ -0,0 +1,1100 @@ +import React, {useEffect, useMemo, useState} from "react" +import {AlertCircle, CheckCircle2, Copy, Download, FileJson, Plus, RefreshCw, Trash2, X} from "lucide-react" + +type JsonPrimitive = string | number | boolean | null +type JsonValue = JsonPrimitive | JsonValue[] | {[key: string]: JsonValue} +type JsonObject = {[key: string]: JsonValue} +type PathPart = string | number + +type JsonSchema = { + $ref?: string + title?: string + description?: string + type?: string | string[] + properties?: Record + items?: JsonSchema + required?: string[] + enum?: string[] + oneOf?: JsonSchema[] + anyOf?: JsonSchema[] + allOf?: JsonSchema[] + default?: JsonValue + minimum?: number + format?: string + additionalProperties?: boolean +} + +type RootSchema = JsonSchema & { + definitions?: Record +} + +type OutputFormat = "json" | "yaml" | "graphql" + +const schemaUrl = "https://raw.githubusercontent.com/tailcallhq/tailcall/main/generated/.tailcallrc.schema.json" +const rootSections = ["links", "server", "upstream", "telemetry"] + +const inputClasses = + "min-h-11 w-full rounded-md border border-solid border-tailCall-border-light-500 bg-white px-SPACE_03 py-SPACE_02 font-space-grotesk text-content-tiny outline-none transition focus:border-tailCall-dark-100" + +const textAreaClasses = `${inputClasses} min-h-28 font-space-mono` + +const isRecord = (value: unknown): value is JsonObject => { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +const humanize = (key: string) => { + return key + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/[_-]/g, " ") + .replace(/^./, (letter) => letter.toUpperCase()) +} + +const cleanDescription = (description?: string) => { + if (!description) return "" + return description + .replace(/[`*_#]/g, "") + .replace(/\s+/g, " ") + .trim() +} + +const isNullSchema = (schema?: JsonSchema) => { + if (!schema) return false + if (schema.type === "null") return true + return Array.isArray(schema.type) && schema.type.length === 1 && schema.type[0] === "null" +} + +const resolveRef = (schema: JsonSchema, root: RootSchema): JsonSchema => { + if (!schema.$ref) return schema + const definitionName = schema.$ref.replace("#/definitions/", "") + return root.definitions?.[definitionName] ?? schema +} + +const mergeSchemas = (schemas: JsonSchema[]): JsonSchema => { + return schemas.reduce((merged, schema) => { + return { + ...merged, + ...schema, + properties: { + ...(merged.properties ?? {}), + ...(schema.properties ?? {}), + }, + required: Array.from(new Set([...(merged.required ?? []), ...(schema.required ?? [])])), + } + }, {}) +} + +const normalizeSchema = (schema: JsonSchema, root: RootSchema): JsonSchema => { + const resolved = resolveRef(schema, root) + + if (resolved.allOf?.length) { + const normalizedParts = resolved.allOf.map((part) => normalizeSchema(part, root)) + const ownSchema = {...resolved} + delete ownSchema.allOf + return mergeSchemas([...normalizedParts, ownSchema]) + } + + return resolved +} + +const pickRenderableSchema = (schema: JsonSchema, root: RootSchema): JsonSchema => { + const normalized = normalizeSchema(schema, root) + if (normalized.anyOf?.length) { + const firstNonNull = normalized.anyOf.find((item) => !isNullSchema(normalizeSchema(item, root))) + if (firstNonNull) return normalizeSchema(firstNonNull, root) + } + return normalized +} + +const getSchemaType = (schema: JsonSchema, root: RootSchema): string => { + const normalized = pickRenderableSchema(schema, root) + const schemaType = normalized.type + if (Array.isArray(schemaType)) { + return schemaType.find((type) => type !== "null") ?? "string" + } + if (schemaType) return schemaType + if (normalized.properties) return "object" + if (normalized.items) return "array" + if (getEnumValues(normalized, root).length > 0) return "string" + return "unknown" +} + +const getEnumValues = (schema: JsonSchema, root: RootSchema): string[] => { + const normalized = normalizeSchema(schema, root) + if (normalized.enum?.length) return normalized.enum + + if (normalized.oneOf?.length) { + const values = normalized.oneOf.flatMap((option) => getEnumValues(option, root)) + return Array.from(new Set(values)) + } + + if (normalized.anyOf?.length) { + const values = normalized.anyOf.flatMap((option) => getEnumValues(option, root)) + return Array.from(new Set(values)) + } + + if (normalized.allOf?.length) { + const values = normalized.allOf.flatMap((option) => getEnumValues(option, root)) + return Array.from(new Set(values)) + } + + return [] +} + +const getOneOfObjectVariants = (schema: JsonSchema, root: RootSchema) => { + const normalized = normalizeSchema(schema, root) + if (!normalized.oneOf?.length) return [] + + return normalized.oneOf + .map((option) => normalizeSchema(option, root)) + .map((option) => { + const keys = Object.keys(option.properties ?? {}) + if (keys.length !== 1) return null + const key = keys[0] + return { + key, + label: humanize(key), + schema: option.properties?.[key] as JsonSchema, + } + }) + .filter(Boolean) as Array<{key: string; label: string; schema: JsonSchema}> +} + +const setValueAtPath = (source: JsonObject, path: PathPart[], nextValue: JsonValue): JsonObject => { + const root: JsonObject = {...source} + let cursor: JsonObject | JsonValue[] = root + + path.forEach((key, index) => { + const isLast = index === path.length - 1 + if (isLast) { + ;(cursor as any)[key] = nextValue + return + } + + const nextKey = path[index + 1] + const existing = (cursor as any)[key] + const nextContainer = + typeof nextKey === "number" + ? Array.isArray(existing) + ? [...existing] + : [] + : isRecord(existing) + ? {...existing} + : {} + + ;(cursor as any)[key] = nextContainer + cursor = nextContainer + }) + + return root +} + +const deleteValueAtPath = (source: JsonObject, path: PathPart[]): JsonObject => { + const root: JsonObject = {...source} + let cursor: JsonObject | JsonValue[] = root + + path.slice(0, -1).forEach((key) => { + const existing = (cursor as any)[key] + if (Array.isArray(existing)) { + const copy = [...existing] + ;(cursor as any)[key] = copy + cursor = copy + } else if (isRecord(existing)) { + const copy = {...existing} + ;(cursor as any)[key] = copy + cursor = copy + } + }) + + const last = path[path.length - 1] + if (Array.isArray(cursor) && typeof last === "number") { + cursor.splice(last, 1) + } else if (isRecord(cursor) && typeof last === "string") { + delete cursor[last] + } + + return root +} + +const isEmptyValue = (value: JsonValue | undefined): boolean => { + if (value === undefined || value === null || value === "") return true + if (Array.isArray(value)) return value.length === 0 + if (isRecord(value)) return Object.keys(value).length === 0 + return false +} + +const createEmptyValue = (schema: JsonSchema, root: RootSchema): JsonValue => { + const normalized = pickRenderableSchema(schema, root) + if (normalized.default !== undefined) return normalized.default + + const variants = getOneOfObjectVariants(normalized, root) + if (variants.length > 0) { + const variant = variants[0] + return {[variant.key]: createEmptyValue(variant.schema, root)} + } + + const enumValues = getEnumValues(normalized, root) + if (enumValues.length > 0) return enumValues[0] + + const schemaType = getSchemaType(normalized, root) + if (schemaType === "object") return {} + if (schemaType === "array") return [] + if (schemaType === "boolean") return false + if (schemaType === "integer" || schemaType === "number") return 0 + return "" +} + +const pruneValue = (value: JsonValue | undefined, schema: JsonSchema, root: RootSchema): JsonValue | undefined => { + if (value === undefined || value === null || value === "") return undefined + + const normalized = pickRenderableSchema(schema, root) + const variants = getOneOfObjectVariants(normalized, root) + if (variants.length > 0 && isRecord(value)) { + const selectedVariant = variants.find((variant) => Object.prototype.hasOwnProperty.call(value, variant.key)) + if (!selectedVariant) return undefined + + const childValue = pruneValue(value[selectedVariant.key], selectedVariant.schema, root) + if (childValue === undefined && getSchemaType(selectedVariant.schema, root) !== "object") return undefined + return {[selectedVariant.key]: childValue ?? {}} + } + + const schemaType = getSchemaType(normalized, root) + + if (schemaType === "array") { + if (!Array.isArray(value)) return undefined + const prunedItems = value + .map((item) => pruneValue(item, normalized.items ?? {}, root)) + .filter((item): item is JsonValue => item !== undefined) + return prunedItems.length > 0 ? prunedItems : undefined + } + + if (schemaType === "object") { + if (!isRecord(value)) return undefined + const output: JsonObject = {} + Object.entries(normalized.properties ?? {}).forEach(([key, propertySchema]) => { + const prunedChild = pruneValue(value[key], propertySchema, root) + if (prunedChild !== undefined) { + output[key] = prunedChild + } + }) + return Object.keys(output).length > 0 ? output : undefined + } + + return value +} + +const validateValue = (value: JsonValue | undefined, schema: JsonSchema, root: RootSchema, label: string): string[] => { + if (value === undefined) return [] + + const normalized = pickRenderableSchema(schema, root) + + if (normalized.anyOf?.length) { + const nonNullOptions = normalized.anyOf.filter((option) => !isNullSchema(normalizeSchema(option, root))) + const matched = nonNullOptions.some((option) => validateValue(value, option, root, label).length === 0) + return matched ? [] : [`${label} does not match the schema`] + } + + const variants = getOneOfObjectVariants(normalized, root) + if (variants.length > 0) { + if (!isRecord(value)) return [`${label} must select one exporter`] + const selected = variants.filter((variant) => Object.prototype.hasOwnProperty.call(value, variant.key)) + if (selected.length !== 1) return [`${label} must select one exporter`] + return validateValue(value[selected[0].key], selected[0].schema, root, `${label}.${selected[0].key}`) + } + + const enumValues = getEnumValues(normalized, root) + if (enumValues.length > 0 && typeof value === "string" && !enumValues.includes(value)) { + return [`${label} must be one of ${enumValues.join(", ")}`] + } + + const schemaType = getSchemaType(normalized, root) + + if (schemaType === "object") { + if (!isRecord(value)) return [`${label} must be an object`] + const errors: string[] = [] + ;(normalized.required ?? []).forEach((requiredKey) => { + if (isEmptyValue(value[requiredKey])) { + errors.push(`${label}.${requiredKey} is required`) + } + }) + Object.entries(normalized.properties ?? {}).forEach(([key, propertySchema]) => { + errors.push(...validateValue(value[key], propertySchema, root, `${label}.${key}`)) + }) + return errors + } + + if (schemaType === "array") { + if (!Array.isArray(value)) return [`${label} must be a list`] + return value.flatMap((item, index) => validateValue(item, normalized.items ?? {}, root, `${label}[${index}]`)) + } + + if (schemaType === "integer" || schemaType === "number") { + if (typeof value !== "number" || Number.isNaN(value)) return [`${label} must be a number`] + if (normalized.minimum !== undefined && value < normalized.minimum) { + return [`${label} must be at least ${normalized.minimum}`] + } + } + + if (schemaType === "boolean" && typeof value !== "boolean") return [`${label} must be true or false`] + if (schemaType === "string" && typeof value !== "string") return [`${label} must be text`] + + return [] +} + +const yamlScalar = (value: JsonPrimitive): string => { + if (value === null) return "null" + if (typeof value === "boolean" || typeof value === "number") return String(value) + if (value === "") return '""' + if (/^[A-Za-z0-9_./:-]+$/.test(value)) return value + return JSON.stringify(value) +} + +const toYaml = (value: JsonValue, depth = 0): string => { + const indent = " ".repeat(depth) + const childIndent = " ".repeat(depth + 1) + + if (Array.isArray(value)) { + if (value.length === 0) return "[]" + return value + .map((item) => { + if (isRecord(item) || Array.isArray(item)) { + return `${indent}-\n${toYaml(item, depth + 1)}` + } + return `${indent}- ${yamlScalar(item as JsonPrimitive)}` + }) + .join("\n") + } + + if (isRecord(value)) { + const entries = Object.entries(value) + if (entries.length === 0) return "{}" + return entries + .map(([key, item]) => { + if (isRecord(item) || Array.isArray(item)) { + return `${indent}${key}:\n${toYaml(item, depth + 1)}` + } + return `${indent}${key}: ${yamlScalar(item as JsonPrimitive)}` + }) + .join("\n") + } + + return `${childIndent}${yamlScalar(value as JsonPrimitive)}` +} + +const getPropertySchema = (schema: JsonSchema, key: string, root: RootSchema) => { + const normalized = pickRenderableSchema(schema, root) + return normalized.properties?.[key] ?? {} +} + +const toGraphqlValue = (value: JsonValue, schema: JsonSchema, root: RootSchema): string => { + const normalized = pickRenderableSchema(schema, root) + const enumValues = getEnumValues(normalized, root) + + if (value === null) return "null" + if (typeof value === "number" || typeof value === "boolean") return String(value) + if (typeof value === "string") { + return enumValues.includes(value) ? value : JSON.stringify(value) + } + if (Array.isArray(value)) { + const itemSchema = normalized.items ?? {} + return `[${value.map((item) => toGraphqlValue(item, itemSchema, root)).join(", ")}]` + } + if (isRecord(value)) { + const entries = Object.entries(value) + return `{${entries + .map(([key, item]) => `${key}: ${toGraphqlValue(item, getPropertySchema(normalized, key, root), root)}`) + .join(", ")}}` + } + return JSON.stringify(value) +} + +const toGraphqlArgs = (value: JsonValue | undefined, schema: JsonSchema, root: RootSchema) => { + if (!isRecord(value)) return "" + return Object.entries(value) + .map(([key, item]) => `${key}: ${toGraphqlValue(item, getPropertySchema(schema, key, root), root)}`) + .join(", ") +} + +const toGraphqlDirective = (name: string, value: JsonValue | undefined, schema: JsonSchema, root: RootSchema) => { + const args = toGraphqlArgs(value, schema, root) + return args ? `@${name}(${args})` : `@${name}` +} + +const toGraphql = (value: JsonObject, root: RootSchema): string => { + const directives: string[] = [] + + if (Array.isArray(value.links)) { + const linkSchema = getPropertySchema(root, "links", root).items ?? {} + value.links.forEach((link) => { + directives.push(toGraphqlDirective("link", link, linkSchema, root)) + }) + } + + ;(["server", "upstream", "telemetry"] as const).forEach((sectionKey) => { + if (value[sectionKey] !== undefined) { + directives.push( + toGraphqlDirective(sectionKey, value[sectionKey], getPropertySchema(root, sectionKey, root), root), + ) + } + }) + + const directiveLines = directives.length > 0 ? `\n ${directives.join("\n ")}` : "" + return `schema${directiveLines} {\n query: Query\n}\n\ntype Query {\n _empty: String\n}` +} + +const formatOutput = (value: JsonObject, format: OutputFormat, root?: RootSchema) => { + if (format === "json") return JSON.stringify(value, null, 2) + if (format === "graphql" && root) return toGraphql(value, root) + return toYaml(value) +} + +type SchemaFieldProps = { + root: RootSchema + schema: JsonSchema + value: JsonValue | undefined + path: PathPart[] + label: string + required?: boolean + rootField?: boolean + onChange: (path: PathPart[], value: JsonValue) => void + onDelete: (path: PathPart[]) => void +} + +const SchemaField = ({root, schema, value, path, label, required, rootField, onChange, onDelete}: SchemaFieldProps) => { + const normalized = pickRenderableSchema(schema, root) + const schemaType = getSchemaType(normalized, root) + const description = cleanDescription(normalized.description) + const isOptionalObject = !required && !rootField && schemaType === "object" && isEmptyValue(value) + const isOptionalArray = !required && !rootField && schemaType === "array" && isEmptyValue(value) + const variants = getOneOfObjectVariants(normalized, root) + + if (variants.length > 0) { + const selectedKey = isRecord(value) + ? Object.keys(value).find((key) => variants.some((variant) => variant.key === key)) + : undefined + const activeVariant = variants.find((variant) => variant.key === selectedKey) ?? variants[0] + const activeValue = isRecord(value) ? value[activeVariant.key] : undefined + + return ( +
+ onDelete(path) : undefined} + /> + + +
+ +
+
+ ) + } + + if (isOptionalObject) { + return onChange(path, createEmptyValue(normalized, root))} /> + } + + if (isOptionalArray) { + return ( +
+ + +
+ ) + } + + if (schemaType === "object") { + const properties = normalized.properties ?? {} + const requiredFields = new Set(normalized.required ?? []) + + return ( +
+ onDelete(path) : undefined} + /> +
+ {Object.entries(properties).map(([key, propertySchema]) => ( + + ))} +
+
+ ) + } + + if (schemaType === "array") { + const arrayValue = Array.isArray(value) ? value : [] + return ( +
+ onDelete(path) : undefined} + /> +
+ {arrayValue.map((item, index) => ( +
+
+ + {label} {index + 1} + + onDelete([...path, index])}> + + +
+ +
+ ))} + +
+
+ ) + } + + return ( + + ) +} + +type PrimitiveFieldProps = { + root: RootSchema + schema: JsonSchema + value: JsonValue | undefined + path: PathPart[] + label: string + required?: boolean + onChange: (path: PathPart[], value: JsonValue) => void + onDelete: (path: PathPart[]) => void +} + +const PrimitiveField = ({root, schema, value, path, label, required, onChange, onDelete}: PrimitiveFieldProps) => { + const schemaType = getSchemaType(schema, root) + const enumValues = getEnumValues(schema, root) + const description = cleanDescription(schema.description) + const inputId = `config-${path.join("-")}` + + if (schemaType === "boolean") { + return ( +
+ +
+ {[ + ["unset", "Unset"], + ["true", "True"], + ["false", "False"], + ].map(([optionValue, optionLabel]) => { + const active = + (optionValue === "unset" && value === undefined) || + (optionValue === "true" && value === true) || + (optionValue === "false" && value === false) + return ( + + ) + })} +
+
+ ) + } + + if (schemaType === "integer" || schemaType === "number") { + return ( +
+ + { + if (event.target.value === "") { + onDelete(path) + return + } + const parsedValue = Number(event.target.value) + if (!Number.isNaN(parsedValue)) { + onChange(path, parsedValue) + } + }} + /> +
+ ) + } + + if (enumValues.length > 0) { + const listId = `${inputId}-list` + return ( +
+ + { + if (event.target.value === "") { + onDelete(path) + return + } + onChange(path, event.target.value) + }} + /> + + {enumValues.map((option) => ( + +
+ ) + } + + if (schemaType === "unknown") { + return ( +
+ +