diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a45f34..57034bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # @123ishatest/louter +## 0.5.2 + +### Patch Changes + +- Recursively parse references in keys and values + ## 0.5.1 ### Patch Changes diff --git a/package-lock.json b/package-lock.json index cd57bda..5105d80 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,13 @@ { "name": "@123ishatest/louter", - "version": "0.4.0", + "version": "0.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@123ishatest/louter", - "version": "0.4.0", + "version": "0.5.1", "dependencies": { - "es-toolkit": "^1.46.1", "yaml": "^2.8.2" }, "devDependencies": { @@ -3500,16 +3499,6 @@ "dev": true, "license": "MIT" }, - "node_modules/es-toolkit": { - "version": "1.46.1", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz", - "integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==", - "license": "MIT", - "workspaces": [ - "docs", - "benchmarks" - ] - }, "node_modules/esbuild": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", diff --git a/package.json b/package.json index 05ec37d..02ac0a7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@123ishatest/louter", "private": false, - "version": "0.5.1", + "version": "0.5.2", "publishConfig": { "access": "public", "provenance": true @@ -58,7 +58,6 @@ "zod": "^4.0.0" }, "dependencies": { - "es-toolkit": "^1.46.1", "yaml": "^2.8.2" } } diff --git a/src/core/references.ts b/src/core/references.ts index 1d2fbb8..23a07fe 100644 --- a/src/core/references.ts +++ b/src/core/references.ts @@ -1,8 +1,8 @@ import { z } from 'zod'; -export const LOUTER_REFERENCE_PREFIX = '$ref'; +export const LOUTER_REFERENCE_MARKER = '$ref'; export const LOUTER_SEPARATOR = ':'; export function ref(kind: string) { - return z.string().transform((v) => `${LOUTER_REFERENCE_PREFIX}${LOUTER_SEPARATOR}${kind}${LOUTER_SEPARATOR}${v}`); + return z.string().transform((v) => `${LOUTER_REFERENCE_MARKER}${LOUTER_SEPARATOR}${kind}${LOUTER_SEPARATOR}${v}`); } diff --git a/src/validator/LouterValidator.ts b/src/validator/LouterValidator.ts index 9367691..ddacf7a 100644 --- a/src/validator/LouterValidator.ts +++ b/src/validator/LouterValidator.ts @@ -1,10 +1,9 @@ -import { prettifyError } from 'zod'; +import { prettifyError, z } from 'zod'; import type { KindDefinitions } from '@louter/core/types'; import type { LouterStage } from '@louter/core/LouterStage'; import type { LouterContext } from '@louter/core/LouterContext'; import { LouterWarningType } from '@louter/core/LouterWarningType'; -import { flattenObject } from 'es-toolkit'; -import { LOUTER_REFERENCE_PREFIX, LOUTER_SEPARATOR } from '@louter/core/references'; +import { LOUTER_REFERENCE_MARKER, LOUTER_SEPARATOR } from '@louter/core/references'; /** * Validate all LouterObjects through their Zod schemas @@ -55,42 +54,78 @@ export class LouterValidator implements LouterStage { }); return; } - // TODO(@Isha): Fix? - // @ts-expect-error Fix map already existing - ctx.content[object.kind][id] = zodResult.data; + ctx.content[object.kind][id] = zodResult.data as z.output; }); } + /** + * Add an error to the context if the reference can not be found + * @param ctx + * @param reference + * @private + */ + private validateReference(ctx: LouterContext, reference: string): string { + if (!reference.startsWith(LOUTER_REFERENCE_MARKER)) { + return reference; + } + + const [, kind, ...rest] = reference.split(LOUTER_SEPARATOR); + const refId = rest.join(LOUTER_SEPARATOR); + + if (!ctx.content[kind]) { + ctx.warnings.push({ + type: LouterWarningType.MissingReferenceKind, + message: `Missing reference kind '${kind}'`, + path: 'TODO', + }); + + return refId; + } + + if (!ctx.content[kind][refId]) { + ctx.warnings.push({ + type: LouterWarningType.MissingReference, + message: `Missing reference '${reference}'`, + path: 'TODO', + }); + + return refId; + } + + return refId; + } + + private validateRecursive(ctx: LouterContext, value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((entry) => this.validateRecursive(ctx, entry)); + } + + if (value && typeof value === 'object') { + const result: Record = {}; + + for (const [key, val] of Object.entries(value)) { + const newKey = this.validateReference(ctx, key); + result[newKey] = this.validateRecursive(ctx, val); + } + + return result; + } + + if (typeof value === 'string') { + return this.validateReference(ctx, value); + } + + return value; + } + + /** + * Recursively validate all objects and replace reference markers + * @param ctx + */ validateReferences(ctx: LouterContext): void { - Object.values(ctx.content).forEach((items) => { - Object.values(items).forEach((item) => { - const flat = flattenObject(item as object); - - Object.entries(flat).forEach(([key, value]) => { - if (value.toString().startsWith(LOUTER_REFERENCE_PREFIX)) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [_, kind, ...rest] = value.split(LOUTER_SEPARATOR); - const refId = rest.join(LOUTER_SEPARATOR); - - if (!ctx.content[kind]) { - ctx.warnings.push({ - type: LouterWarningType.MissingReferenceKind, - message: `Missing reference kind '${kind}'`, - path: key, - }); - return; - } - - if (!ctx.content[kind][refId]) { - ctx.warnings.push({ - type: LouterWarningType.MissingReference, - message: `Missing reference '${value}'`, - path: key, - }); - return; - } - } - }); + Object.entries(ctx.content).forEach(([kind, items]) => { + Object.entries(items).forEach(([id, item]) => { + ctx.content[kind][id] = this.validateRecursive(ctx, item) as z.output; }); }); } diff --git a/tests/util/type.spec.ts b/tests/util/type.spec.ts new file mode 100644 index 0000000..b6b3a24 --- /dev/null +++ b/tests/util/type.spec.ts @@ -0,0 +1,27 @@ +import { expect, it } from 'vitest'; +import { isJsonSchemaEntry } from '@louter/util/type'; + +it('Calculates json schema entries', () => { + // Arrange + const schema = { + fileMatch: ['**/*.example.json'], + url: '.generated/example.schema.json', + }; + + // Act + const result = isJsonSchemaEntry(schema); + + // Assert + expect(result).toBe(true); +}); + +it("Doesn't fall for not schemas", () => { + // Arrange + const notSchema = 3; + + // Act + const result = isJsonSchemaEntry(notSchema); + + // Assert + expect(result).toBe(false); +}); diff --git a/tests/validator/louter-reference-validator.spec.ts b/tests/validator/louter-reference-validator.spec.ts index 99fd30d..1bcd6a5 100644 --- a/tests/validator/louter-reference-validator.spec.ts +++ b/tests/validator/louter-reference-validator.spec.ts @@ -19,12 +19,97 @@ it('resolves references', () => { }); const firstPiece = { id: 'a' }; const secondPiece = { id: 'b', first: 'a' }; - ctx.content.first = { - a: firstPiece, - }; - ctx.content.second = { - b: secondPiece, - }; + ctx.objects = [ + { path: 'a.first.json', kind: 'first', data: firstPiece }, + { path: 'b.second.json', kind: 'second', data: secondPiece }, + ]; + + // Act + parser.run(ctx); + + // Assert + expect(ctx.warnings).toStrictEqual([]); + expect(ctx.content.first.a).toStrictEqual(firstPiece); + expect(ctx.content.second.b).toStrictEqual(secondPiece); +}); + +it('resolves nested references', () => { + // Arrange + const parser = new LouterValidator(); + const ctx = createContext({ + first: z.strictObject({ + id: z.string(), + }), + second: z.strictObject({ + id: z.string(), + nested: z.strictObject({ + other: z.number(), + first: ref('first'), + }), + }), + }); + const firstPiece = { id: 'a' }; + const secondPiece = { id: 'b', nested: { first: 'a', other: 4 } }; + ctx.objects = [ + { path: 'a.first.json', kind: 'first', data: firstPiece }, + { path: 'b.second.json', kind: 'second', data: secondPiece }, + ]; + + // Act + parser.run(ctx); + + // Assert + expect(ctx.warnings).toStrictEqual([]); + expect(ctx.content.first.a).toStrictEqual(firstPiece); + expect(ctx.content.second.b).toStrictEqual(secondPiece); +}); + +it('resolves references in arrays', () => { + // Arrange + const parser = new LouterValidator(); + const ctx = createContext({ + first: z.strictObject({ + id: z.string(), + }), + second: z.strictObject({ + id: z.string(), + array: z.array(ref('first')), + }), + }); + const firstPiece = { id: 'a' }; + const secondPiece = { id: 'b', array: ['a'] }; + ctx.objects = [ + { path: 'a.first.json', kind: 'first', data: firstPiece }, + { path: 'b.second.json', kind: 'second', data: secondPiece }, + ]; + + // Act + parser.run(ctx); + + // Assert + expect(ctx.warnings).toStrictEqual([]); + expect(ctx.content.first.a).toStrictEqual(firstPiece); + expect(ctx.content.second.b).toStrictEqual(secondPiece); +}); + +it('resolves references as keys', () => { + // Arrange + const parser = new LouterValidator(); + const ctx = createContext({ + first: z.strictObject({ + id: z.string(), + }), + second: z.strictObject({ + id: z.string(), + object: z.record(ref('first'), z.number()), + }), + }); + const firstPiece = { id: 'a' }; + const secondPiece = { id: 'b', object: { a: 4 } }; + ctx.objects = [ + { path: 'a.first.json', kind: 'first', data: firstPiece }, + { path: 'b.second.json', kind: 'second', data: secondPiece }, + ]; // Act parser.run(ctx); @@ -79,6 +164,58 @@ it('fails on incorrect references', () => { expect(ctx.warnings[0].type).toBe(LouterWarningType.MissingReference); }); +it('fails on incorrect key references', () => { + // Arrange + const validator = new LouterValidator(); + const ctx = createContext({ + first: z.strictObject({ + id: z.string(), + }), + second: z.strictObject({ + id: z.string(), + object: z.record(ref('first'), z.number()), + }), + }); + ctx.objects = [ + { path: 'a.first.json', kind: 'first', data: { id: 'a' } }, + { path: 'b.second.json', kind: 'second', data: { id: 'b', object: { wrong: 4 } } }, + ]; + + // Act + validator.run(ctx); + + // Assert + expect(ctx.warnings).toHaveLength(1); + expect(ctx.warnings[0].type).toBe(LouterWarningType.MissingReference); +}); + +it('fails on incorrect references in arrays', () => { + // Arrange + const parser = new LouterValidator(); + const ctx = createContext({ + first: z.strictObject({ + id: z.string(), + }), + second: z.strictObject({ + id: z.string(), + array: z.array(ref('first')), + }), + }); + const firstPiece = { id: 'a' }; + const secondPiece = { id: 'b', array: ['b'] }; + ctx.objects = [ + { path: 'a.first.json', kind: 'first', data: firstPiece }, + { path: 'b.second.json', kind: 'second', data: secondPiece }, + ]; + + // Act + parser.run(ctx); + + // Assert + expect(ctx.warnings).toHaveLength(1); + expect(ctx.warnings[0].type).toBe(LouterWarningType.MissingReference); +}); + it("doesn't break when using special characters", () => { // Arrange const validator = new LouterValidator();