diff --git a/.vscode/settings.json b/.vscode/settings.json index ed6ed12..212a69e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,3 @@ { - "conventionalCommits.scopes": ["docs", "build"] + "conventionalCommits.scopes": ["docs", "build", "federation"] } diff --git a/build.ts b/build.ts index f08fc94..6a012d6 100644 --- a/build.ts +++ b/build.ts @@ -12,6 +12,12 @@ await Bun.build({ splitting: true, target: "browser", plugins: [dts()], +}).then((output) => { + if (!output.success) { + spinner.fail("Failed to build federation module"); + console.error(output.logs); + process.exit(1); + } }); spinner.succeed("Built federation module"); diff --git a/federation/index.ts b/federation/index.ts index 5676f1a..e2fe576 100644 --- a/federation/index.ts +++ b/federation/index.ts @@ -6,48 +6,301 @@ */ import type { z } from "zod"; +import { type ValidationError, fromError } from "zod-validation-error"; import { - Action, - ActorPublicKeyData, - ContentFormat, - Dislike, - Entity, - Extension, - Follow, - FollowAccept, - FollowReject, - Like, - Note, - Patch, - Publication, - Report, - ServerMetadata, - Undo, - User, - VanityExtension, - Visibility, + ActionSchema, + ActorPublicKeyDataSchema, + ContentFormatSchema, + CustomEmojiExtension, + DislikeSchema, + EntitySchema, + ExtensionSchema, + FollowAcceptSchema, + FollowRejectSchema, + FollowSchema, + LikeSchema, + NoteSchema, + PatchSchema, + PublicationSchema, + ReportSchema, + ServerMetadataSchema, + UndoSchema, + UserSchema, + VanityExtensionSchema, + VisibilitySchema, } from "./schemas/base"; -export type InferType = z.infer; +// biome-ignore lint/suspicious/noExplicitAny: Used only as a base type +type AnyZod = z.ZodType; -export { - Entity, - ContentFormat, - Visibility, - Publication, - Note, - Patch, - ActorPublicKeyData, - VanityExtension, - User, - Action, - Like, - Undo, - Dislike, - Follow, - FollowAccept, - FollowReject, - Extension, - Report, - ServerMetadata, -}; +export type InferType = z.infer; + +/** + * Validates entities against their respective schemas. + * @module federation/validator + * @see module:federation/schemas/base + * @example + * import { EntityValidator, type ValidationError } from "@lysand-org/federation"; + * const validator = new EntityValidator(); + * + * // Will throw a special ValidationError with a human-friendly error message + * // and a machine-friendly error object if the data is invalid. + * const note = await validator.Note({ + * type: "Note", + * content: "Hello, world!", + * }); + * + * try { + * await validator.Note({ + * type: "Note", + * content: 123, + * }); + * } catch (error) { + * sendUser((error as ValidationError).toString()); + * } + * + * // Types are also included for TypeScript users that don't use the extracted ones + * const noteObject: typeof EntityValidator.$Note = { + * type: "Note", + * // ... + * } + */ +class EntityValidator { + private async validate( + schema: T, + data: unknown, + ): Promise> { + try { + return (await schema.parseAsync(data)) as InferType; + } catch (error) { + throw fromError(error); + } + } + + declare static $Note: InferType; + declare static $Patch: InferType; + declare static $ActorPublicKeyData: InferType< + typeof ActorPublicKeyDataSchema + >; + declare static $VanityExtension: InferType; + declare static $User: InferType; + declare static $Action: InferType; + declare static $Like: InferType; + declare static $Undo: InferType; + declare static $Dislike: InferType; + declare static $Follow: InferType; + declare static $FollowAccept: InferType; + declare static $FollowReject: InferType; + declare static $Extension: InferType; + declare static $Report: InferType; + declare static $ServerMetadata: InferType; + declare static $ContentFormat: InferType; + declare static $CustomEmojiExtension: InferType< + typeof CustomEmojiExtension + >; + declare static $Visibility: InferType; + declare static $Publication: InferType; + declare static $Entity: InferType; + + /** + * Validates a Note entity. + * @param data - The data to validate + * @returns A promise that resolves to the validated data. + */ + public Note(data: unknown): Promise> { + return this.validate(NoteSchema, data); + } + + /** + * Validates a Patch entity. + * @param data - The data to validate + * @returns A promise that resolves to the validated data. + */ + public Patch(data: unknown): Promise> { + return this.validate(PatchSchema, data); + } + + /** + * Validates an ActorPublicKeyData entity. + * @param data - The data to validate + * @returns A promise that resolves to the validated data. + */ + public ActorPublicKeyData( + data: unknown, + ): Promise> { + return this.validate(ActorPublicKeyDataSchema, data); + } + + /** + * Validates a VanityExtension entity. + * @param data - The data to validate + * @returns A promise that resolves to the validated data. + */ + public VanityExtension( + data: unknown, + ): Promise> { + return this.validate(VanityExtensionSchema, data); + } + + /** + * Validates a User entity. + * @param data - The data to validate + * @returns A promise that resolves to the validated data. + */ + public User(data: unknown): Promise> { + return this.validate(UserSchema, data); + } + + /** + * Validates an Action entity. + * @param data - The data to validate + * @returns A promise that resolves to the validated data. + */ + public Action(data: unknown): Promise> { + return this.validate(ActionSchema, data); + } + + /** + * Validates a Like entity. + * @param data - The data to validate + * @returns A promise that resolves to the validated data. + */ + public Like(data: unknown): Promise> { + return this.validate(LikeSchema, data); + } + + /** + * Validates an Undo entity. + * @param data - The data to validate + * @returns A promise that resolves to the validated data. + */ + public Undo(data: unknown): Promise> { + return this.validate(UndoSchema, data); + } + + /** + * Validates a Dislike entity. + * @param data - The data to validate + * @returns A promise that resolves to the validated data. + */ + public Dislike(data: unknown): Promise> { + return this.validate(DislikeSchema, data); + } + + /** + * Validates a Follow entity. + * @param data - The data to validate + * @returns A promise that resolves to the validated data. + */ + public Follow(data: unknown): Promise> { + return this.validate(FollowSchema, data); + } + + /** + * Validates a FollowAccept entity. + * @param data - The data to validate + * @returns A promise that resolves to the validated data. + */ + public FollowAccept( + data: unknown, + ): Promise> { + return this.validate(FollowAcceptSchema, data); + } + + /** + * Validates a FollowReject entity. + * @param data - The data to validate + * @returns A promise that resolves to the validated data. + */ + public FollowReject( + data: unknown, + ): Promise> { + return this.validate(FollowRejectSchema, data); + } + + /** + * Validates an Extension entity. + * @param data - The data to validate + * @returns A promise that resolves to the validated data. + */ + public Extension( + data: unknown, + ): Promise> { + return this.validate(ExtensionSchema, data); + } + + /** + * Validates a Report entity. + * @param data - The data to validate + * @returns A promise that resolves to the validated data. + */ + public Report(data: unknown): Promise> { + return this.validate(ReportSchema, data); + } + + /** + * Validates a ServerMetadata entity. + * @param data - The data to validate + * @returns A promise that resolves to the validated data. + */ + public ServerMetadata( + data: unknown, + ): Promise> { + return this.validate(ServerMetadataSchema, data); + } + + /** + * Validates a ContentFormat entity. + * @param data - The data to validate + * @returns A promise that resolves to the validated data. + */ + public ContentFormat( + data: unknown, + ): Promise> { + return this.validate(ContentFormatSchema, data); + } + + /** + * Validates a CustomEmojiExtension entity. + * @param data - The data to validate + * @returns A promise that resolves to the validated data. + */ + public CustomEmojiExtension( + data: unknown, + ): Promise> { + return this.validate(CustomEmojiExtension, data); + } + + /** + * Validates a Visibility entity. + * @param data - The data to validate + * @returns A promise that resolves to the validated data. + */ + public Visibility( + data: unknown, + ): Promise> { + return this.validate(VisibilitySchema, data); + } + + /** + * Validates a Publication entity. + * @param data - The data to validate + * @returns A promise that resolves to the validated data. + */ + public Publication( + data: unknown, + ): Promise> { + return this.validate(PublicationSchema, data); + } + + /** + * Validates an Entity. + * @param data - The data to validate + * @returns A promise that resolves to the validated data. + */ + public Entity(data: unknown): Promise> { + return this.validate(EntitySchema, data); + } +} + +export { EntityValidator, type ValidationError }; diff --git a/federation/schemas/base.ts b/federation/schemas/base.ts index f3cd237..a28bd54 100644 --- a/federation/schemas/base.ts +++ b/federation/schemas/base.ts @@ -1,10 +1,10 @@ import { z } from "zod"; -import { ContentFormat } from "./content_format"; +import { ContentFormatSchema } from "./content_format"; import { CustomEmojiExtension } from "./extensions/custom_emojis"; -import { VanityExtension } from "./extensions/vanity"; +import { VanityExtensionSchema } from "./extensions/vanity"; import { extensionTypeRegex } from "./regex"; -const Entity = z.object({ +const EntitySchema = z.object({ id: z.string().uuid(), created_at: z.string(), uri: z.string().url(), @@ -14,20 +14,20 @@ const Entity = z.object({ }), }); -const Visibility = z.enum(["public", "unlisted", "private", "direct"]); +const VisibilitySchema = z.enum(["public", "unlisted", "private", "direct"]); -const Publication = Entity.extend({ +const PublicationSchema = EntitySchema.extend({ type: z.enum(["Note", "Patch"]), author: z.string().url(), - content: ContentFormat.optional(), - attachments: z.array(ContentFormat).optional(), + content: ContentFormatSchema.optional(), + attachments: z.array(ContentFormatSchema).optional(), replies_to: z.string().url().optional(), quotes: z.string().url().optional(), mentions: z.array(z.string().url()).optional(), subject: z.string().optional(), is_sensitive: z.boolean().optional(), - visibility: Visibility, - extensions: Entity.shape.extensions.extend({ + visibility: VisibilitySchema, + extensions: EntitySchema.shape.extensions.extend({ "org.lysand:reactions": z .object({ reactions: z.string(), @@ -36,7 +36,7 @@ const Publication = Entity.extend({ "org.lysand:polls": z .object({ poll: z.object({ - options: z.array(ContentFormat), + options: z.array(ContentFormatSchema), votes: z.array(z.number().int().nonnegative()), multiple_choice: z.boolean().optional(), expires_at: z.string(), @@ -46,35 +46,35 @@ const Publication = Entity.extend({ }), }); -const Note = Publication.extend({ +const NoteSchema = PublicationSchema.extend({ type: z.literal("Note"), }); -const Patch = Publication.extend({ +const PatchSchema = PublicationSchema.extend({ type: z.literal("Patch"), patched_id: z.string().uuid(), patched_at: z.string(), }); -const ActorPublicKeyData = z.object({ +const ActorPublicKeyDataSchema = z.object({ public_key: z.string(), actor: z.string().url(), }); -const User = Entity.extend({ +const UserSchema = EntitySchema.extend({ type: z.literal("User"), display_name: z.string().optional(), username: z.string(), - avatar: ContentFormat.optional(), - header: ContentFormat.optional(), + avatar: ContentFormatSchema.optional(), + header: ContentFormatSchema.optional(), indexable: z.boolean(), - public_key: ActorPublicKeyData, - bio: ContentFormat.optional(), + public_key: ActorPublicKeyDataSchema, + bio: ContentFormatSchema.optional(), fields: z .array( z.object({ - name: ContentFormat, - value: ContentFormat, + name: ContentFormatSchema, + value: ContentFormatSchema, }), ) .optional(), @@ -85,12 +85,12 @@ const User = Entity.extend({ dislikes: z.string().url(), inbox: z.string().url(), outbox: z.string().url(), - extensions: Entity.shape.extensions.extend({ - "org.lysand:vanity": VanityExtension.optional(), + extensions: EntitySchema.shape.extensions.extend({ + "org.lysand:vanity": VanityExtensionSchema.optional(), }), }); -const Action = Entity.extend({ +const ActionSchema = EntitySchema.extend({ type: z.union([ z.literal("Like"), z.literal("Dislike"), @@ -103,37 +103,37 @@ const Action = Entity.extend({ author: z.string().url(), }); -const Like = Action.extend({ +const LikeSchema = ActionSchema.extend({ type: z.literal("Like"), object: z.string().url(), }); -const Undo = Action.extend({ +const UndoSchema = ActionSchema.extend({ type: z.literal("Undo"), object: z.string().url(), }); -const Dislike = Action.extend({ +const DislikeSchema = ActionSchema.extend({ type: z.literal("Dislike"), object: z.string().url(), }); -const Follow = Action.extend({ +const FollowSchema = ActionSchema.extend({ type: z.literal("Follow"), followee: z.string().url(), }); -const FollowAccept = Action.extend({ +const FollowAcceptSchema = ActionSchema.extend({ type: z.literal("FollowAccept"), follower: z.string().url(), }); -const FollowReject = Action.extend({ +const FollowRejectSchema = ActionSchema.extend({ type: z.literal("FollowReject"), follower: z.string().url(), }); -const Extension = Entity.extend({ +const ExtensionSchema = EntitySchema.extend({ type: z.literal("Extension"), extension_type: z .string() @@ -143,14 +143,14 @@ const Extension = Entity.extend({ ), }); -const Report = Extension.extend({ +const ReportSchema = ExtensionSchema.extend({ extension_type: z.literal("org.lysand:reports/Report"), objects: z.array(z.string().url()), reason: z.string(), comment: z.string().optional(), }); -const ServerMetadata = Entity.extend({ +const ServerMetadataSchema = EntitySchema.extend({ type: z.literal("ServerMetadata"), name: z.string(), version: z.string(), @@ -158,31 +158,31 @@ const ServerMetadata = Entity.extend({ website: z.string().optional(), moderators: z.array(z.string()).optional(), admins: z.array(z.string()).optional(), - logo: ContentFormat.optional(), - banner: ContentFormat.optional(), + logo: ContentFormatSchema.optional(), + banner: ContentFormatSchema.optional(), supported_extensions: z.array(z.string()), extensions: z.record(z.string(), z.any()).optional(), }); export { - Entity, - Visibility, - Publication, - Note, - Patch, - ActorPublicKeyData, - VanityExtension, - User, - Action, - Like, - Undo, - Dislike, - Follow, - FollowAccept, - FollowReject, - Extension, - Report, - ServerMetadata, - ContentFormat, + EntitySchema, + VisibilitySchema, + PublicationSchema, + NoteSchema, + PatchSchema, + ActorPublicKeyDataSchema, + VanityExtensionSchema, + UserSchema, + ActionSchema, + LikeSchema, + UndoSchema, + DislikeSchema, + FollowSchema, + FollowAcceptSchema, + FollowRejectSchema, + ExtensionSchema, + ReportSchema, + ServerMetadataSchema, + ContentFormatSchema, CustomEmojiExtension, }; diff --git a/federation/schemas/content_format.ts b/federation/schemas/content_format.ts index 305f547..0f740f1 100644 --- a/federation/schemas/content_format.ts +++ b/federation/schemas/content_format.ts @@ -1,7 +1,7 @@ import { types } from "mime-types"; import { z } from "zod"; -export const ContentFormat = z.record( +export const ContentFormatSchema = z.record( z.enum(Object.values(types) as [string, ...string[]]), z.object({ content: z.string(), diff --git a/federation/schemas/extensions/custom_emojis.ts b/federation/schemas/extensions/custom_emojis.ts index a848e44..cada5de 100644 --- a/federation/schemas/extensions/custom_emojis.ts +++ b/federation/schemas/extensions/custom_emojis.ts @@ -5,7 +5,7 @@ * @see https://lysand.org/extensions/custom-emojis */ import { z } from "zod"; -import { ContentFormat } from "../content_format"; +import { ContentFormatSchema } from "../content_format"; import { emojiRegex } from "../regex"; /** @@ -44,7 +44,7 @@ export const CustomEmojiExtension = z.object({ emojiRegex, "Emoji name must be alphanumeric, underscores, or dashes.", ), - url: ContentFormat, + url: ContentFormatSchema, }), ), }); diff --git a/federation/schemas/extensions/polls.ts b/federation/schemas/extensions/polls.ts index c10697b..534e767 100644 --- a/federation/schemas/extensions/polls.ts +++ b/federation/schemas/extensions/polls.ts @@ -5,8 +5,8 @@ * @see https://lysand.org/extensions/polls */ import { z } from "zod"; -import { Extension } from "../base"; -import { ContentFormat } from "../content_format"; +import { ExtensionSchema } from "../base"; +import { ContentFormatSchema } from "../content_format"; /** * @description Poll extension entity @@ -43,9 +43,9 @@ import { ContentFormat } from "../content_format"; * "expires_at": "2021-01-04T00:00:00.000Z" * } */ -export const Poll = Extension.extend({ +export const PollSchema = ExtensionSchema.extend({ extension_type: z.literal("org.lysand:polls/Poll"), - options: z.array(ContentFormat), + options: z.array(ContentFormatSchema), votes: z.array(z.number().int().nonnegative()), multiple_choice: z.boolean().optional(), expires_at: z.string(), @@ -65,7 +65,7 @@ export const Poll = Extension.extend({ * "option": 1 * } */ -export const Vote = Extension.extend({ +export const VoteSchema = ExtensionSchema.extend({ extension_type: z.literal("org.lysand:polls/Vote"), poll: z.string().url(), option: z.number(), @@ -85,7 +85,7 @@ export const Vote = Extension.extend({ * "votes": [9, 5, 0] * } */ -export const VoteResult = Extension.extend({ +export const VoteResultSchema = ExtensionSchema.extend({ extension_type: z.literal("org.lysand:polls/VoteResult"), poll: z.string().url(), votes: z.array(z.number().int().nonnegative()), diff --git a/federation/schemas/extensions/reactions.ts b/federation/schemas/extensions/reactions.ts index ed6c9ea..5475309 100644 --- a/federation/schemas/extensions/reactions.ts +++ b/federation/schemas/extensions/reactions.ts @@ -5,7 +5,7 @@ * @see https://lysand.org/extensions/reactions */ import { z } from "zod"; -import { Extension } from "../base"; +import { ExtensionSchema } from "../base"; /** * @description Reaction extension entity @@ -21,7 +21,7 @@ import { Extension } from "../base"; * "content": "👍" * } */ -export const Reaction = Extension.extend({ +export const ReactionSchema = ExtensionSchema.extend({ extension_type: z.literal("org.lysand:reactions/Reaction"), object: z.string().url(), content: z.string(), diff --git a/federation/schemas/extensions/vanity.ts b/federation/schemas/extensions/vanity.ts index f182f08..e44d048 100644 --- a/federation/schemas/extensions/vanity.ts +++ b/federation/schemas/extensions/vanity.ts @@ -5,8 +5,8 @@ * @see https://lysand.org/extensions/vanity */ -import { type AnyZodObject, ZodObject, z } from "zod"; -import { ContentFormat } from "../content_format"; +import { z } from "zod"; +import { ContentFormatSchema } from "../content_format"; /** * @description Vanity extension entity @@ -67,11 +67,11 @@ import { ContentFormat } from "../content_format"; * } * } */ -export const VanityExtension = z.object({ - avatar_overlay: ContentFormat.optional(), - avatar_mask: ContentFormat.optional(), - background: ContentFormat.optional(), - audio: ContentFormat.optional(), +export const VanityExtensionSchema = z.object({ + avatar_overlay: ContentFormatSchema.optional(), + avatar_mask: ContentFormatSchema.optional(), + background: ContentFormatSchema.optional(), + audio: ContentFormatSchema.optional(), pronouns: z.record( z.string(), z.array( diff --git a/federation/tests/index.test.ts b/federation/tests/index.test.ts index 25ce70a..b76d3c3 100644 --- a/federation/tests/index.test.ts +++ b/federation/tests/index.test.ts @@ -1,12 +1,19 @@ import { describe, expect, it } from "bun:test"; -import { Note } from "../schemas/base"; +import type { ValidationError } from "zod-validation-error"; +import { EntityValidator } from "../index"; describe("Package testing", () => { - it("should not validate a bad Note", () => { + it("should not validate a bad Note", async () => { const badObject = { IamBad: "Note", }; - expect(Note.parseAsync(badObject)).rejects.toThrow(); + const validator = new EntityValidator(); + + expect(validator.Note(badObject)).rejects.toThrow(); + + console.log( + (await validator.Note(badObject).catch((e) => e)).toString(), + ); }); });