yaml to json helpers

This commit is contained in:
Nate Kelley 2025-07-29 22:49:29 -06:00
parent 7cb0b3fe67
commit f035b92579
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
2 changed files with 370 additions and 0 deletions

View File

@ -0,0 +1,244 @@
import { describe, it, expect } from 'vitest';
import { yamlToJson, yamlToJsonSafe, YamlParseError, isJsonSerializable } from './yaml-to-json';
describe('yamlToJson', () => {
it('should parse simple YAML to JSON object', () => {
const yamlString = `
name: "Test App"
version: 1.0
enabled: true
`;
interface Config {
name: string;
version: number;
enabled: boolean;
}
const result = yamlToJson<Config>(yamlString);
expect(result).toEqual({
name: 'Test App',
version: 1.0,
enabled: true
});
});
it('should handle nested objects', () => {
const yamlString = `
database:
host: localhost
port: 5432
credentials:
username: admin
password: secret
`;
interface DatabaseConfig {
database: {
host: string;
port: number;
credentials: {
username: string;
password: string;
};
};
}
const result = yamlToJson<DatabaseConfig>(yamlString);
expect(result.database.host).toBe('localhost');
expect(result.database.port).toBe(5432);
expect(result.database.credentials.username).toBe('admin');
});
it('should handle arrays', () => {
const yamlString = `
fruits:
- apple
- banana
- orange
numbers:
- 1
- 2
- 3
`;
interface ListConfig {
fruits: string[];
numbers: number[];
}
const result = yamlToJson<ListConfig>(yamlString);
expect(result.fruits).toEqual(['apple', 'banana', 'orange']);
expect(result.numbers).toEqual([1, 2, 3]);
});
it('should throw YamlParseError for invalid YAML', () => {
const invalidYaml = `
name: "Test
version: 1.0
invalid indentation
`;
expect(() => yamlToJson(invalidYaml)).toThrow(YamlParseError);
});
it('should throw YamlParseError for non-string input', () => {
expect(() => yamlToJson(123 as any)).toThrow(YamlParseError);
expect(() => yamlToJson(null as any)).toThrow(YamlParseError);
expect(() => yamlToJson(undefined as any)).toThrow(YamlParseError);
});
it('should throw YamlParseError for empty string', () => {
expect(() => yamlToJson('')).toThrow(YamlParseError);
expect(() => yamlToJson(' ')).toThrow(YamlParseError);
});
it('should handle options parameter', () => {
const yamlString = `
name: "Test App"
version: 1.0
`;
const result = yamlToJson(yamlString, {
filename: 'test.yaml'
});
expect(result).toEqual({
name: 'Test App',
version: 1.0
});
});
it('should throw YamlParseError for duplicate keys', () => {
const yamlWithDuplicates = `
name: "First"
name: "Second"
`;
// js-yaml always throws for duplicate keys
expect(() => yamlToJson(yamlWithDuplicates)).toThrow(YamlParseError);
});
});
describe('isJsonSerializable', () => {
it('should return true for JSON-serializable values', () => {
expect(isJsonSerializable({})).toBe(true);
expect(isJsonSerializable([])).toBe(true);
expect(isJsonSerializable('string')).toBe(true);
expect(isJsonSerializable(123)).toBe(true);
expect(isJsonSerializable(true)).toBe(true);
expect(isJsonSerializable(null)).toBe(true);
expect(isJsonSerializable({ name: 'test', value: 42 })).toBe(true);
});
it('should return false for non-JSON-serializable values', () => {
expect(isJsonSerializable(undefined)).toBe(false);
expect(isJsonSerializable(() => {})).toBe(false);
expect(isJsonSerializable(Symbol('test'))).toBe(false);
// Circular reference
const circular: any = {};
circular.self = circular;
expect(isJsonSerializable(circular)).toBe(false);
});
});
describe('yamlToJsonSafe', () => {
it('should parse valid YAML that is JSON-serializable', () => {
const yamlString = `
name: "Test App"
version: 1.0
settings:
debug: true
maxRetries: 3
`;
interface Config {
name: string;
version: number;
settings: {
debug: boolean;
maxRetries: number;
};
}
const result = yamlToJsonSafe<Config>(yamlString);
expect(result).toEqual({
name: 'Test App',
version: 1.0,
settings: {
debug: true,
maxRetries: 3
}
});
});
it('should throw YamlParseError for invalid YAML', () => {
const invalidYaml = `
name: "Test
invalid: yaml
`;
expect(() => yamlToJsonSafe(invalidYaml)).toThrow(YamlParseError);
});
});
describe('YamlParseError', () => {
it('should store original error', () => {
const originalError = new Error('Original error message');
const yamlError = new YamlParseError('YAML parse failed', originalError);
expect(yamlError.message).toBe('YAML parse failed');
expect(yamlError.name).toBe('YamlParseError');
expect(yamlError.originalError).toBe(originalError);
});
it('should be instanceof Error and YamlParseError', () => {
const yamlError = new YamlParseError('Test error', new Error());
expect(yamlError instanceof Error).toBe(true);
expect(yamlError instanceof YamlParseError).toBe(true);
});
});
// Type safety tests (these test TypeScript compilation)
describe('Type Safety', () => {
it('should provide proper type inference', () => {
interface TestConfig {
name: string;
count: number;
}
const yaml = `
name: "test"
count: 42
`;
const result = yamlToJson<TestConfig>(yaml);
// These should be properly typed
expect(typeof result.name).toBe('string');
expect(typeof result.count).toBe('number');
// TypeScript should catch these at compile time
// result.name = 123; // This would be a TypeScript error
// result.invalidProperty; // This would be a TypeScript error
});
it('should work with unknown type as default', () => {
const yaml = `
unknown: "structure"
dynamic: true
`;
const result = yamlToJson(yaml); // No generic type specified
// Result should be typed as unknown
expect(result).toBeDefined();
expect(typeof result).toBe('object');
});
});

View File

@ -0,0 +1,126 @@
import { load, type LoadOptions } from 'js-yaml';
/**
* Error thrown when YAML parsing fails
*/
export class YamlParseError extends Error {
constructor(
message: string,
public readonly originalError: unknown
) {
super(message);
this.name = 'YamlParseError';
}
}
/**
* Options for YAML to JSON conversion
*/
export interface YamlToJsonOptions {
/** Custom schema to use for parsing. Defaults to DEFAULT_SCHEMA */
schema?: LoadOptions['schema'];
/** Custom file name for error messages */
filename?: string;
}
/**
* Converts a YAML string to a JSON object with type safety
*
* @template T - The expected type of the parsed JSON object
* @param yamlString - The YAML string to parse
* @param options - Optional parsing configuration
* @returns The parsed object typed as T
* @throws {YamlParseError} When YAML parsing fails
*
* @example
* ```typescript
* interface Config {
* name: string;
* version: number;
* }
*
* const yaml = `
* name: "My App"
* version: 1.0
* `;
*
* const config = yamlToJson<Config>(yaml);
* // config is now typed as Config
* ```
*/
export function yamlToJson<T = unknown>(yamlString: string, options: YamlToJsonOptions = {}): T {
// Handle edge cases
if (typeof yamlString !== 'string') {
throw new YamlParseError('Input must be a string', new TypeError('Expected string input'));
}
if (yamlString.trim() === '') {
throw new YamlParseError('YAML string cannot be empty', new Error('Empty input'));
}
try {
const loadOptions: LoadOptions = {
schema: options.schema,
filename: options.filename
};
const result = load(yamlString, loadOptions);
// Ensure we got a valid result
if (result === undefined) {
throw new YamlParseError('YAML parsing resulted in undefined', new Error('Undefined result'));
}
return result as T;
} catch (error) {
if (error instanceof YamlParseError) {
throw error;
}
// Wrap js-yaml errors with our custom error
const errorMessage = error instanceof Error ? error.message : 'Unknown parsing error';
throw new YamlParseError(`Failed to parse YAML: ${errorMessage}`, error);
}
}
/**
* Type guard to check if a value is a valid JSON-serializable object
* Useful for runtime validation after YAML parsing
*
* @param value - The value to check
* @returns True if the value can be safely serialized to JSON
*/
export function isJsonSerializable(value: unknown): boolean {
try {
const result = JSON.stringify(value);
// JSON.stringify returns undefined for undefined values, functions, symbols
return result !== undefined;
} catch {
return false;
}
}
/**
* Safely converts YAML to JSON with additional validation
*
* @template T - The expected type of the parsed JSON object
* @param yamlString - The YAML string to parse
* @param options - Optional parsing configuration
* @returns The parsed and validated object typed as T
* @throws {YamlParseError} When YAML parsing or validation fails
*/
export function yamlToJsonSafe<T = unknown>(
yamlString: string,
options: YamlToJsonOptions = {}
): T {
const result = yamlToJson<T>(yamlString, options);
if (!isJsonSerializable(result)) {
throw new YamlParseError(
'Parsed YAML contains non-JSON-serializable values',
new Error('Non-serializable content')
);
}
return result;
}