diff --git a/assets/js/lib/configSchemaTransform.mjs b/assets/js/lib/configSchemaTransform.mjs new file mode 100644 index 000000000000..a6ddc4e487a8 --- /dev/null +++ b/assets/js/lib/configSchemaTransform.mjs @@ -0,0 +1,351 @@ +/** + * Configuration Schema Transformer + * + * Transforms the raw OpenTelemetry configuration JSON Schema into a simplified + * format optimized for the configuration types accordion UI. + * + * Input: Raw JSON Schema from /schemas/opentelemetry_configuration.json + * Output: Simplified structure for accordion rendering + * + * This module extracts type definitions, processes properties, and resolves constraints + */ + +/** + * Resolve type information from a property definition + * Handles arrays, single values, and $ref pointers + * @param {Object} propDef - Property definition from JSON Schema + * @returns {string} Comma-delimited type string + */ +export function resolveType(propDef) { + let types; + + if (Array.isArray(propDef.type)) { + types = propDef.type; + } else if (propDef.type) { + types = [propDef.type]; + } else if (propDef.$ref) { + // Extract type name from reference like "#/$defs/TypeName" + const refParts = propDef.$ref.split('/'); + types = [refParts[refParts.length - 1]]; + } else { + types = ['object']; + } + + return types.join(', '); +} + +/** + * Build constraints string from property definition + * Extracts validation rules like minimum, maximum, pattern, enum, etc. + * @param {Object} propDef - Property definition from JSON Schema + * @returns {string} Comma-delimited constraints string + */ +export function buildConstraints(propDef) { + const parts = []; + + if (propDef.minimum !== undefined) { + parts.push(`minimum: ${propDef.minimum}`); + } + if (propDef.maximum !== undefined) { + parts.push(`maximum: ${propDef.maximum}`); + } + + if (propDef.minLength !== undefined) { + parts.push(`minLength: ${propDef.minLength}`); + } + if (propDef.maxLength !== undefined) { + parts.push(`maxLength: ${propDef.maxLength}`); + } + if (propDef.pattern) { + parts.push(`pattern: ${propDef.pattern}`); + } + + if (propDef.enum) { + const enumVals = propDef.enum.join(', '); + parts.push(`enum: [${enumVals}]`); + } + + if (propDef.minProperties !== undefined) { + parts.push(`minProperties: ${propDef.minProperties}`); + } + if (propDef.maxProperties !== undefined) { + parts.push(`maxProperties: ${propDef.maxProperties}`); + } + + if (propDef.minItems !== undefined) { + parts.push(`minItems: ${propDef.minItems}`); + } + if (propDef.maxItems !== undefined) { + parts.push(`maxItems: ${propDef.maxItems}`); + } + + return parts.join(', '); +} + +/** + * Escape HTML entities to prevent XSS + * Only allows a specific allowlist of tags (ul, ol, li, a) generated by this module + * @param {string} text - Raw text to escape + * @returns {string} HTML-safe escaped text + */ +export function escapeHtml(text) { + const htmlEscapes = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }; + return text.replace(/[&<>"']/g, (char) => htmlEscapes[char]); +} + +/** + * Linkify URLs in PLAIN TEXT with proper HTML escaping + * IMPORTANT: This function must only be called on plain text BEFORE any HTML generation. + * It escapes all text content and only creates tags for detected URLs. + * + * Security model: + * 1. All non-URL text is HTML-escaped to prevent XSS + * 2. URLs are escaped and wrapped in tags (our only generated HTML at this stage) + * 3. This function is called BEFORE wrapping text in list tags, preventing double-linkification + * + * @param {string} plainText - Raw plain text from schema (not HTML) + * @returns {string} Escaped text with linkified URLs + */ +export function linkifyUrls(plainText) { + // Regex matches URLs but stops at quotes/brackets to avoid matching inside attributes + const urlRegex = /(https?:\/\/[^\s<>"]+)/g; + const parts = []; + let lastIndex = 0; + let match; + + while ((match = urlRegex.exec(plainText)) !== null) { + // Escape all text before the URL + if (match.index > lastIndex) { + parts.push(escapeHtml(plainText.substring(lastIndex, match.index))); + } + + // Linkify the URL with escaping + const url = match[0]; + const escapedUrl = escapeHtml(url); + parts.push( + `${escapedUrl}`, + ); + + lastIndex = match.index + url.length; + } + + // Escape any remaining text after the last URL + if (lastIndex < plainText.length) { + parts.push(escapeHtml(plainText.substring(lastIndex))); + } + + return parts.join(''); +} + +/** + * Clean description text + * Converts markdown lists to HTML, linkifies URLs, and normalizes whitespace + * All text content is HTML-escaped to prevent XSS attacks + * @param {string} description - Raw description text + * @returns {string} Cleaned description with HTML formatting + */ +export function cleanDescription(description) { + if (!description) return ''; + + let result = description.trim(); + + const lines = result.split('\n'); + const processed = []; + let currentList = null; + let listType = null; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + + if (!line) continue; + + // Check for unordered list item (- or *) + const unorderedMatch = line.match(/^[-*]\s+(.+)$/); + if (unorderedMatch) { + if (listType !== 'ul') { + // Close any open list + if (currentList) { + processed.push(currentList); + } + currentList = { type: 'ul', items: [] }; + listType = 'ul'; + } + currentList.items.push(unorderedMatch[1]); + continue; + } + + // Check for ordered list item (1. 2. etc.) + const orderedMatch = line.match(/^\d+\.\s+(.+)$/); + if (orderedMatch) { + if (listType !== 'ol') { + // Close any open list + if (currentList) { + processed.push(currentList); + } + currentList = { type: 'ol', items: [] }; + listType = 'ol'; + } + currentList.items.push(orderedMatch[1]); + continue; + } + + // Not a list item - close any open list + if (currentList) { + processed.push(currentList); + currentList = null; + listType = null; + } + + // Add non-list line + processed.push(line); + } + + // Close final list if still open + if (currentList) { + processed.push(currentList); + } + + // Convert processed structure to HTML with proper escaping and URL linkification + // Only generates allowlisted tags: ul, ol, li, a + result = processed + .map((item) => { + if (typeof item === 'string') { + return linkifyUrls(item); + } else if (item.type === 'ul') { + const items = item.items + .map((i) => `
  • ${linkifyUrls(i)}
  • `) + .join(''); + return ``; + } else if (item.type === 'ol') { + const items = item.items + .map((i) => `
  • ${linkifyUrls(i)}
  • `) + .join(''); + return `
      ${items}
    `; + } + return ''; + }) + .join(' '); + + // Normalize excessive whitespace between non-HTML content + result = result.replace(/>\s+<'); // Remove whitespace between tags + result = result.replace(/\s+/g, ' ').trim(); // Normalize other whitespace + + return result; +} + +/** + * Process a single property definition + * @param {string} propName - Property name + * @param {Object} propDef - Property definition from JSON Schema + * @returns {Object} Simplified property object + */ +export function processProperty(propName, propDef) { + return { + name: propName, + type: resolveType(propDef), + default: propDef.default, + constraints: buildConstraints(propDef), + description: cleanDescription(propDef.description), + }; +} + +/** + * Build type-level constraints string + * @param {Object} typeDef - Type definition from JSON Schema + * @returns {string} Formatted constraints string + */ +export function buildTypeConstraints(typeDef) { + const parts = []; + + if (typeDef.additionalProperties === false) { + parts.push('additionalProperties: false'); + } + + if (typeDef.minProperties !== undefined) { + parts.push(`minProperties: ${typeDef.minProperties}`); + } + + if (typeDef.maxProperties !== undefined) { + parts.push(`maxProperties: ${typeDef.maxProperties}`); + } + + if (typeDef.required && typeDef.required.length > 0) { + const requiredProps = typeDef.required.join(', '); + parts.push(`Required properties: ${requiredProps}`); + } + + if (parts.length === 0) return ''; + + return parts.join('. ') + '.'; +} + +/** + * Process a single type definition + * @param {string} typeName - Type name + * @param {Object} typeDef - Type definition from JSON Schema + * @returns {Object} Simplified type object + */ +export function processType(typeName, typeDef) { + const properties = []; + let hasNoProperties = true; + + if (typeDef.properties && Object.keys(typeDef.properties).length > 0) { + hasNoProperties = false; + for (const [propName, propDef] of Object.entries(typeDef.properties)) { + properties.push(processProperty(propName, propDef)); + } + } + + return { + id: typeName.toLowerCase(), + name: typeName, + isExperimental: typeName.startsWith('Experimental'), + hasNoProperties, + properties, + constraints: buildTypeConstraints(typeDef), + }; +} + +/** + * Extract and process all type definitions from JSON Schema + * @param {Object} schema - Complete JSON Schema object + * @returns {Array} Array of processed type objects + */ +export function extractTypes(schema) { + if (!schema.$defs) { + throw new Error('No $defs found in schema'); + } + + const types = []; + + for (const [typeName, typeDef] of Object.entries(schema.$defs)) { + // Skip private types (those starting with underscore) + if (typeName.startsWith('_')) { + continue; + } + + types.push(processType(typeName, typeDef)); + } + + types.sort((a, b) => a.name.localeCompare(b.name)); + + return types; +} + +/** + * Main transformation function + * Transforms raw JSON Schema to simplified format for accordion UI + * @param {Object} rawSchema - Raw JSON Schema object + * @returns {Object} Simplified data structure { types: [...] } + */ +export function transformSchema(rawSchema) { + const types = extractTypes(rawSchema); + + return { types }; +} diff --git a/assets/js/lib/configSchemaTransform.test.mjs b/assets/js/lib/configSchemaTransform.test.mjs new file mode 100644 index 000000000000..b46d0dec7933 --- /dev/null +++ b/assets/js/lib/configSchemaTransform.test.mjs @@ -0,0 +1,338 @@ +/** + * Unit tests for Configuration Schema Transformer + */ + +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { + resolveType, + buildConstraints, + cleanDescription, + processProperty, + buildTypeConstraints, + processType, + extractTypes, + transformSchema, + escapeHtml, + linkifyUrls, +} from './configSchemaTransform.mjs'; + +describe('resolveType', () => { + test('resolves types correctly', () => { + const cases = [ + [{ type: 'string' }, 'string'], + [{ type: ['string', 'null'] }, 'string, null'], + [{ $ref: '#/$defs/LogRecordProcessor' }, 'LogRecordProcessor'], + [{ description: 'Some property' }, 'object'], + ]; + + cases.forEach(([input, expected]) => { + assert.equal(resolveType(input), expected); + }); + }); +}); + +describe('buildConstraints', () => { + test('builds constraints correctly', () => { + const cases = [ + [{ minimum: 1, maximum: 100 }, 'minimum: 1, maximum: 100'], + [{ minLength: 1, maxLength: 50 }, 'minLength: 1, maxLength: 50'], + [{ pattern: '^[a-z]+$' }, 'pattern: ^[a-z]+$'], + [ + { enum: ['debug', 'info', 'warn', 'error'] }, + 'enum: [debug, info, warn, error]', + ], + [ + { minProperties: 1, maxProperties: 10 }, + 'minProperties: 1, maxProperties: 10', + ], + [{ minItems: 1, maxItems: 5 }, 'minItems: 1, maxItems: 5'], + [{ type: 'string' }, ''], + ]; + + cases.forEach(([input, expected]) => { + assert.equal(buildConstraints(input), expected); + }); + }); +}); + +describe('escapeHtml', () => { + test('escapes HTML entities', () => { + assert.equal( + escapeHtml(''), + '<script>alert("xss")</script>', + ); + assert.equal(escapeHtml('5 > 3 & 2 < 4'), '5 > 3 & 2 < 4'); + assert.equal(escapeHtml('it\'s a "test"'), 'it's a "test"'); + }); + + test('returns unchanged text without special characters', () => { + assert.equal(escapeHtml('plain text'), 'plain text'); + }); +}); + +describe('linkifyUrls', () => { + test('linkifies URLs and escapes surrounding text', () => { + const input = 'See https://example.com for
    '; + const result = linkifyUrls(input); + assert.ok(result.includes(' { + const input = 'Visit https://example.com or https://test.org'; + const result = linkifyUrls(input); + const matches = result.match(/ { + assert.equal( + linkifyUrls(''), + '<script>alert(1)</script>', + ); + }); +}); + +describe('cleanDescription', () => { + test('returns empty string for null/undefined', () => { + assert.equal(cleanDescription(null), ''); + assert.equal(cleanDescription(undefined), ''); + assert.equal(cleanDescription(''), ''); + }); + + test('trims whitespace', () => { + assert.equal(cleanDescription(' Hello world '), 'Hello world'); + }); + + test('converts markdown unordered list to HTML', () => { + const input = '- Item 1\n- Item 2\n- Item 3'; + const expected = ''; + assert.equal(cleanDescription(input), expected); + }); + + test('converts markdown ordered list to HTML', () => { + const input = '1. First\n2. Second\n3. Third'; + const expected = '
    1. First
    2. Second
    3. Third
    '; + assert.equal(cleanDescription(input), expected); + }); + + test('linkifies URLs with correct attributes', () => { + const result = cleanDescription('See https://example.com for details'); + assert.match( + result, + /
    ]*target="_blank"[^>]*rel="noopener noreferrer"/, + ); + }); + + test('handles mixed content with lists and text', () => { + const input = 'Introduction:\n- Item 1\n- Item 2\nConclusion'; + const result = cleanDescription(input); + assert.ok(result.includes('Introduction:')); + assert.ok(result.includes('