Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# @123ishatest/louter

## 0.5.2

### Patch Changes

- Recursively parse references in keys and values

## 0.5.1

### Patch Changes
Expand Down
15 changes: 2 additions & 13 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@123ishatest/louter",
"private": false,
"version": "0.5.1",
"version": "0.5.2",
"publishConfig": {
"access": "public",
"provenance": true
Expand Down Expand Up @@ -58,7 +58,6 @@
"zod": "^4.0.0"
},
"dependencies": {
"es-toolkit": "^1.46.1",
"yaml": "^2.8.2"
}
}
4 changes: 2 additions & 2 deletions src/core/references.ts
Original file line number Diff line number Diff line change
@@ -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}`);
}
105 changes: 70 additions & 35 deletions src/validator/LouterValidator.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<Kinds[string]>;
});
}

/**
* Add an error to the context if the reference can not be found
* @param ctx
* @param reference
* @private
*/
private validateReference<Kinds extends KindDefinitions>(ctx: LouterContext<Kinds>, 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<Kinds extends KindDefinitions>(ctx: LouterContext<Kinds>, value: unknown): unknown {
if (Array.isArray(value)) {
return value.map((entry) => this.validateRecursive(ctx, entry));
}

if (value && typeof value === 'object') {
const result: Record<string, unknown> = {};

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<Kinds extends KindDefinitions>(ctx: LouterContext<Kinds>): 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<Kinds[string]>;
});
});
}
Expand Down
27 changes: 27 additions & 0 deletions tests/util/type.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
149 changes: 143 additions & 6 deletions tests/validator/louter-reference-validator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down
Loading