Skip to content

Commit

Permalink
feat(core): ensure result is valid json
Browse files Browse the repository at this point in the history
Validate the resulting objects and ensure that they can be serialized to json
  • Loading branch information
sdorra committed Mar 28, 2024
1 parent 49776a0 commit 6e0ee01
Show file tree
Hide file tree
Showing 11 changed files with 238 additions and 19 deletions.
5 changes: 5 additions & 0 deletions .changeset/fluffy-poets-invite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@content-collections/core": minor
---

Validate the resulting objects and ensure that they can be serialized to json
4 changes: 1 addition & 3 deletions integrations/cli/content-collections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,7 @@ const posts = defineCollection({
schema: (z) => ({
title: z.string().min(5),
description: z.string().min(10),
date: z
.union([z.string().regex(/^\d{4}-\d{2}-\d{2}$/), z.date()])
.transform((val) => new Date(val)),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
author: z.string(),
}),
directory: "posts",
Expand Down
4 changes: 2 additions & 2 deletions integrations/cli/simple.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ describe("simple", () => {
expect(post).toEqual({
title: "Post One",
description: "This is the first post",
date: "2019-01-01T00:00:00.000Z",
date: "2019-01-01",
author: {
displayName: "Tricia Marie McMillan",
email: "[email protected]",
Expand All @@ -40,7 +40,7 @@ describe("simple", () => {
expect(post).toEqual({
title: "Post Two",
description: "This is the second post",
date: "2020-01-01T00:00:00.000Z",
date: "2020-01-01",
author: {
displayName: "Tricia Marie McMillan",
email: "[email protected]",
Expand Down
13 changes: 9 additions & 4 deletions packages/core/src/__tests__/collections/posts.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
import { defineCollection } from "@content-collections/core";

export default defineCollection({
const collection = defineCollection({
name: "posts",
typeName: "Post",
schema: (z) => ({
title: z.string().min(5),
description: z.string().min(10),
date: z
.union([z.string().regex(/^\d{4}-\d{2}-\d{2}$/), z.date()])
.transform((val) => new Date(val)),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
}),
transform: (doc) => {
return {
...doc,
};
},
directory: "posts",
include: "**/*.md(x)?",
});

export default collection;
4 changes: 1 addition & 3 deletions packages/core/src/__tests__/config.001.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,7 @@ const posts = defineCollection({
schema: (z) => ({
title: z.string().min(5),
description: z.string().min(10),
date: z
.union([z.string().regex(/^\d{4}-\d{2}-\d{2}$/), z.date()])
.transform((val) => new Date(val)),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
}),
directory: "sources/posts",
include: "**/*.md(x)?",
Expand Down
18 changes: 16 additions & 2 deletions packages/core/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ZodObject, ZodRawShape, ZodString, ZodTypeAny, z } from "zod";
import { generateTypeName } from "./utils";
import { Parser, Parsers } from "./parser";
import { CacheFn } from "./cache";
import { JSONObject } from "./json";

export type Meta = {
filePath: string;
Expand Down Expand Up @@ -92,6 +93,16 @@ export type Collection<

export type AnyCollection = Collection<any, ZodRawShape, Parser, any, any, any>;

type NonJSONObjectError =
"The return type of the transform function must be an valid JSONObject, the following type is not valid:";

const InvalidTypeSymbol = Symbol(`Invalid type`);

type Invalid<TMessage extends string, TObject> = {
[InvalidTypeSymbol]: TMessage;
invalid: TObject;
};

export function defineCollection<
TName extends string,
TShape extends ZodRawShape,
Expand All @@ -101,6 +112,9 @@ export function defineCollection<
TDocument = [TTransformResult] extends [never]
? Schema<TParser, TShape>
: Awaited<TTransformResult>,
TResult = TDocument extends JSONObject
? Collection<TName, TShape, TParser, TSchema, TTransformResult, TDocument>
: Invalid<NonJSONObjectError, TDocument>,
>(
collection: CollectionRequest<
TName,
Expand All @@ -110,7 +124,7 @@ export function defineCollection<
TTransformResult,
TDocument
>
): Collection<TName, TShape, TParser, TSchema, TTransformResult, TDocument> {
): TResult {
let typeName = collection.typeName;
if (!typeName) {
typeName = generateTypeName(collection.name);
Expand All @@ -124,7 +138,7 @@ export function defineCollection<
typeName,
parser,
schema: collection.schema(z),
};
} as TResult;
}

type Cache = "memory" | "file" | "none";
Expand Down
47 changes: 47 additions & 0 deletions packages/core/src/json.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, it, expect } from "vitest";
import { jsonObjectScheme } from "./json";

describe("json", () => {
it("should pass valid json", () => {
const json = {
a: 1,
b: "string",
c: true,
d: null,
e: {
f: "nested",
},
g: [1, 2, 3],
};

const result = jsonObjectScheme.safeParse(json);
expect(result.success).toBe(true);
});

it("should allow undefined values", () => {
const json = {
a: undefined,
};

const result = jsonObjectScheme.safeParse(json);
expect(result.success).toBe(true);
});

it("should fail if object contains a date object", () => {
const json = {
a: new Date(),
};

const result = jsonObjectScheme.safeParse(json);
expect(result.success).toBe(false);
});

it("should fail if object contains a function", () => {
const json = {
a: () => {},
};

const result = jsonObjectScheme.safeParse(json);
expect(result.success).toBe(false);
});
});
22 changes: 22 additions & 0 deletions packages/core/src/json.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import z from "zod";

const literalSchema = z.union([
z.string(),
z.number(),
z.boolean(),
z.null(),
z.undefined(),
]);

type Literal = z.infer<typeof literalSchema>;

type Json = Literal | { [key: string]: Json } | Json[];


const jsonSchema: z.ZodType<Json> = z.lazy(() =>
z.union([literalSchema, z.array(jsonSchema), z.record(jsonSchema)])
);

export const jsonObjectScheme = z.record(jsonSchema);

export type JSONObject = z.infer<typeof jsonObjectScheme>;
66 changes: 66 additions & 0 deletions packages/core/src/transformer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,13 @@ const firstPost: CollectionFile = {
path: "first.md",
};

const invalidPost: CollectionFile = {
data: {
date: new Date(),
},
path: "first.md",
};

const authorTrillian: CollectionFile = {
data: {
ref: "trillian",
Expand Down Expand Up @@ -535,4 +542,63 @@ describe("transform", () => {
]);
expect(collection?.documents).toHaveLength(0);
});

it("should report an result error, if the transform result is not a valid JSON object", async () => {
const posts = defineCollection({
name: "posts",
schema: (z) => ({
title: z.string(),
}),
transform: (doc) => {
return {
...doc,
date: new Date(),
};
},
directory: "tests",
include: "*.md"
});

const errors: Array<TransformError> = [];
emitter.on("transformer:result-error", (event) => errors.push(event.error));

await createTransformer(
emitter,
noopCacheManager
)([
// @ts-expect-error posts is invalid
{
...posts,
files: [firstPost],
},
]);
expect(errors[0]?.type).toBe("Result");
});

it("should report an result error, if the schema result is not a valid JSON object", async () => {
const posts = defineCollection({
name: "posts",
parser: "json",
schema: (z) => ({
date: z.date(),
}),
directory: "tests",
include: "*.md"
});

const errors: Array<TransformError> = [];
emitter.on("transformer:result-error", (event) => errors.push(event.error));

await createTransformer(
emitter,
noopCacheManager
)([
// @ts-expect-error posts is invalid
{
...posts,
files: [invalidPost],
},
]);
expect(errors[0]?.type).toBe("Result");
});
});
37 changes: 33 additions & 4 deletions packages/core/src/transformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,19 @@ import { basename, dirname, extname } from "node:path";
import { z } from "zod";
import { Parser, parsers } from "./parser";
import { CacheManager, Cache } from "./cache";
import { jsonObjectScheme } from "./json";

export type TransformerEvents = {
"transformer:validation-error": {
collection: AnyCollection;
file: CollectionFile;
error: TransformError;
};
"transformer:result-error": {
collection: AnyCollection;
document: any;
error: TransformError;
};
"transformer:error": {
collection: AnyCollection;
error: TransformError;
Expand All @@ -31,7 +37,7 @@ export type TransformedCollection = AnyCollection & {
documents: Array<any>;
};

export type ErrorType = "Validation" | "Configuration" | "Transform";
export type ErrorType = "Validation" | "Configuration" | "Transform" | "Result";

export class TransformError extends Error {
type: ErrorType;
Expand Down Expand Up @@ -143,12 +149,16 @@ export function createTransformer(
if (collection.transform) {
const docs = [];
for (const doc of collection.documents) {
const cache = cacheManager.cache(collection.name, doc.document._meta.path);
const cache = cacheManager.cache(
collection.name,
doc.document._meta.path
);
const context = createContext(collections, cache);
try {
const document = await collection.transform(doc.document, context);
docs.push({
...doc,
document: await collection.transform(doc.document, context),
document,
});
await cache.tidyUp();
} catch (error) {
Expand All @@ -168,17 +178,36 @@ export function createTransformer(
await cacheManager.flush();
return docs;
}

return collection.documents;
}

async function validateDocuments(collection: AnyCollection, documents: Array<any>) {
const docs = [];
for (const doc of documents) {
let parsedData = await jsonObjectScheme.safeParseAsync(doc.document);
if (parsedData.success) {
docs.push(doc);
} else {
emitter.emit("transformer:result-error", {
collection,
document: doc.document,
error: new TransformError("Result", parsedData.error.message),
});
}
}
return docs;
}

return async (untransformedCollections: Array<ResolvedCollection>) => {
const promises = untransformedCollections.map((collection) =>
parseCollection(collection)
);
const collections = await Promise.all(promises);

for (const collection of collections) {
collection.documents = await transformCollection(collections, collection);
const documents = await transformCollection(collections, collection);
collection.documents = await validateDocuments(collection, documents);
}

return collections;
Expand Down
Loading

0 comments on commit 6e0ee01

Please sign in to comment.