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

48.9% Statements 89/182
100% Branches 2/2
7.69% Functions 1/13
48.9% Lines 89/182

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 1831x 1x 1x                           1x   1x       1x   1x 1x 1x 1x 1x             1x 1x       1x 1x                           1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x                     1x 1x 1x                               1x 1x                                                 1x 1x         1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x  
/**
 * 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);
        if (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'};
    if (type === 'number') return {type: 'number'};
    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'};
    if (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();