All files / core/blong-browser/src/schema registry.ts

66.25% Statements 53/80
63.93% Branches 39/61
63.15% Functions 12/19
68.75% Lines 44/64

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191                35x     35x 35x 35x                                             2x       1x 1x           4x       3x                           14x         10x 10x 10x   10x 12x             10x                                   35x                 12x 19x 1x                           12x 12x 12x                                             12x 12x     10x 10x 9x 8x 8x 8x             8x 8x 7x 1x 6x 5x 5x 4x 1x   3x         11x     11x         35x  
/**
 * Schema registry — fetches, normalizes, and enriches OpenAPI/JSON Schema objects.
 * The registry is the single source of truth for schema metadata in the browser.
 */
import type {IEnrichedFieldSchema, IEnrichedSchema, IWidgetConfig} from '@feasibleone/blong';
import type {IJsonSchemaExtended, ISchemaDocument, ISchemaRegistry} from '../types/schema.js';
 
/** Default OpenAPI endpoint */
const DEFAULT_SCHEMA_URL = '/openapi.json';
 
class SchemaRegistry implements ISchemaRegistry {
    private cache = new Map<string, IEnrichedSchema>();
    private document: ISchemaDocument | null = null;
    private loadPromise: Promise<void> | null = null;
 
    async load(url = DEFAULT_SCHEMA_URL): Promise<void> {
        if (this.loadPromise) return this.loadPromise;
        this.loadPromise = fetch(url)
            .then(r => r.json() as Promise<ISchemaDocument>)
            .then(doc => {
                this.document = doc;
                // Pre-populate cache from components.schemas
                const schemas = doc.components?.schemas ?? {};
                for (const [name, schema] of Object.entries(schemas)) {
                    const key = schemaNameToKey(name);
                    this.cache.set(key, enrichSchema(key, schema));
                }
            })
            .catch(err => {
                console.warn('[blong-browser] Failed to load schema document:', err);
                this.loadPromise = null;
            });
        return this.loadPromise;
    }
 
    get(name: string): IEnrichedSchema | undefined {
        return this.cache.get(normalizeName(name));
    }
 
    async resolve(name: string): Promise<IEnrichedSchema> {
        const key = normalizeName(name);
        Eif (this.cache.has(key)) return this.cache.get(key)!;
        await this.load();
        return this.cache.get(key) ?? {name: key, title: key, properties: {}};
    }
 
    set(name: string, schema: IEnrichedSchema): void {
        this.cache.set(normalizeName(name), {...schema, name: normalizeName(name)});
    }
 
    has(name: string): boolean {
        return this.cache.has(normalizeName(name));
    }
}
 
/** Convert OpenAPI component name to dotted key (e.g. 'ModelTree' → 'model.tree') */
function schemaNameToKey(name: string): string {
    // PascalCase → dotted.lower (naive, adjust for actual naming conventions)
    return name
        .replace(/([A-Z])/g, (_, c, i) => (i === 0 ? c.toLowerCase() : `.${c.toLowerCase()}`))
        .replace(/\.$/, '');
}
 
/** Normalize a schema name to lowercase dotted form */
function normalizeName(name: string): string {
    return name.toLowerCase().replace(/\//g, '.').replace(/\s+/g, '.');
}
 
/** Enrich a raw JSON Schema into a normalized IEnrichedSchema */
export function enrichSchema(name: string, raw: IJsonSchemaExtended): IEnrichedSchema {
    const properties: Record<string, IEnrichedFieldSchema> = {};
    const rawProps = raw.properties ?? {};
    const required = new Set(raw.required ?? []);
 
    for (const [fieldName, fieldSchema] of Object.entries(rawProps)) {
        properties[fieldName] = enrichField(
            fieldName,
            fieldSchema as IJsonSchemaExtended,
            required,
        );
    }
 
    return {
        name,
        title: raw.title ?? name,
        description: raw.description,
        properties,
        required: raw.required,
    };
}
 
/**
 * Known `x-*` extension keys understood by blong-browser.
 *
 * - `x-filter`      — include the field in the Explorer filter panel
 * - `x-filterable`  — enable inline column filtering in DataTable
 * - `x-sort`        — enable column sorting in DataTable
 * - `x-cards`       — list of card names that should display this field
 * - `x-widget`      — override widget type and configuration for this field
 */
const KNOWN_X_EXTENSIONS = new Set(['x-filter', 'x-filterable', 'x-sort', 'x-cards', 'x-widget']);
 
/**
 * Emit a console warning for any x-* extension key in the given schema that
 * is not recognized by blong-browser. Unknown keys are silently ignored at
 * runtime; this warning makes typos and unsupported extensions visible during
 * development rather than causing silent UI misbehaviour.
 */
function warnUnknownExtensions(fieldName: string, raw: IJsonSchemaExtended): void {
    for (const key of Object.keys(raw)) {
        if (key.startsWith('x-') && !KNOWN_X_EXTENSIONS.has(key)) {
            console.warn(
                `[blong-browser] Unknown schema extension "${key}" on field "${fieldName}". ` +
                    `Known extensions: ${[...KNOWN_X_EXTENSIONS].join(', ')}.`,
            );
        }
    }
}
 
/** Normalize a single field schema */
function enrichField(
    name: string,
    raw: IJsonSchemaExtended,
    required: Set<string>,
): IEnrichedFieldSchema {
    warnUnknownExtensions(name, raw);
    const widget = resolveWidget(name, raw);
    return {
        name,
        title: raw.title ?? formatTitle(name),
        description: raw.description,
        type: raw.type,
        minLength: raw.minLength,
        maxLength: raw.maxLength,
        minimum: raw.minimum,
        maximum: raw.maximum,
        pattern: raw.pattern,
        enum: raw.enum,
        readOnly: raw.readOnly,
        fieldRequired: required.has(name),
        'x-filter': raw['x-filter'] as boolean | undefined,
        'x-sort': raw['x-sort'] as boolean | undefined,
        'x-cards': raw['x-cards'] as string[] | undefined,
        'x-widget': raw['x-widget'] as Partial<IWidgetConfig> | undefined,
        widget,
    };
}
 
/** Infer widget config from field type and extensions */
function resolveWidget(name: string, raw: IJsonSchemaExtended): IWidgetConfig {
    const xWidget = (raw['x-widget'] ?? {}) as Partial<IWidgetConfig>;
    if (xWidget.type) return xWidget as IWidgetConfig;
 
    // Infer from JSON Schema type
    const type = raw.type;
    if (type === 'boolean') return {type: 'boolean'};
    if (type === 'integer') return {type: 'integer'};
    Iif (type === 'number') return {type: 'number'};
    Iif (type === 'bigint') return {type: 'bigint'};
    Iif (raw.anyOf) {
        const anyType = (raw.anyOf as Array<{type?: string}>).find(s => s.type !== 'null')?.type;
        if (anyType === 'bigint') return {type: 'bigint'};
        if (anyType === 'integer') return {type: 'integer'};
        if (anyType === 'number') return {type: 'number'};
        if (anyType === 'boolean') return {type: 'boolean'};
    }
    if (raw.enum)
        return {type: 'select', options: raw.enum.map(v => ({value: v, label: String(v)}))};
    if (name.toLowerCase().includes('date') && !name.toLowerCase().includes('time'))
        return {type: 'date'};
    if (name.toLowerCase().includes('datetime')) return {type: 'dateTime'};
    Iif (name.toLowerCase().includes('time')) return {type: 'time'};
    if (name.toLowerCase().includes('password')) return {type: 'password'};
    if (name.toLowerCase().includes('description') || name.toLowerCase().includes('notes'))
        return {type: 'textArea'};
 
    return {type: 'input'};
}
 
/** Convert camelCase or snake_case field name to Title Case label */
function formatTitle(name: string): string {
    return name
        .replace(/([A-Z])/g, ' $1')
        .replace(/_/g, ' ')
        .replace(/^./, s => s.toUpperCase())
        .trim();
}
 
/** Singleton registry instance */
export const schemaRegistry = new SchemaRegistry();