From 09a9f0bbf50d041f035ea4ee042d715c59b5e4b3 Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Tue, 28 May 2024 13:38:47 -1000 Subject: [PATCH] feat(federation): :sparkles: Add new RequestParserHandler to less verbosely handle body parsing --- federation/cryptography/index.ts | 4 +- federation/http/index.test.ts | 95 ++++++++++ federation/http/index.ts | 129 +++++++++++++ federation/index.ts | 316 +------------------------------ federation/tests/index.test.ts | 1 - federation/validator/index.ts | 312 ++++++++++++++++++++++++++++++ 6 files changed, 541 insertions(+), 316 deletions(-) create mode 100644 federation/http/index.test.ts create mode 100644 federation/http/index.ts create mode 100644 federation/validator/index.ts diff --git a/federation/cryptography/index.ts b/federation/cryptography/index.ts index 2add7c8..b386f90 100644 --- a/federation/cryptography/index.ts +++ b/federation/cryptography/index.ts @@ -321,9 +321,7 @@ export class SignatureConstructor { }\n` + `host: ${url.host}\n` + `date: ${finalDate}\n` + - `digest: SHA-256=${Buffer.from(new Uint8Array(digest)).toString( - "base64", - )}\n`; + `digest: SHA-256=${Buffer.from(digest).toString("base64")}\n`; const signature = await crypto.subtle.sign( "Ed25519", diff --git a/federation/http/index.test.ts b/federation/http/index.test.ts new file mode 100644 index 0000000..deaf372 --- /dev/null +++ b/federation/http/index.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, jest, test } from "bun:test"; +import { RequestParserHandler } from "../http/index.ts"; +import { EntityValidator } from "../validator/index.ts"; + +// Pulled from social.lysand.org +const validUser = { + id: "018eb863-753f-76ff-83d6-fd590de7740a", + type: "User", + uri: "https://social.lysand.org/users/018eb863-753f-76ff-83d6-fd590de7740a", + bio: { + "text/html": { + content: "

Hey

\n", + }, + }, + created_at: "2024-04-07T11:48:29.623Z", + dislikes: + "https://social.lysand.org/users/018eb863-753f-76ff-83d6-fd590de7740a/dislikes", + featured: + "https://social.lysand.org/users/018eb863-753f-76ff-83d6-fd590de7740a/featured", + likes: "https://social.lysand.org/users/018eb863-753f-76ff-83d6-fd590de7740a/likes", + followers: + "https://social.lysand.org/users/018eb863-753f-76ff-83d6-fd590de7740a/followers", + following: + "https://social.lysand.org/users/018eb863-753f-76ff-83d6-fd590de7740a/following", + inbox: "https://social.lysand.org/users/018eb863-753f-76ff-83d6-fd590de7740a/inbox", + outbox: "https://social.lysand.org/users/018eb863-753f-76ff-83d6-fd590de7740a/outbox", + indexable: false, + username: "jessew", + display_name: "Jesse Wierzbinski", + fields: [ + { + key: { "text/html": { content: "

Identity

\n" } }, + value: { + "text/html": { + content: + '

https://keyoxide.org/aspe:keyoxide.org:NKLLPWPV7P35NEU7JP4K4ID4CA

\n', + }, + }, + }, + ], + public_key: { + actor: "https://social.lysand.org/users/018eb863-753f-76ff-83d6-fd590de7740a", + public_key: "XXXXXXXX", + }, + extensions: { "org.lysand:custom_emojis": { emojis: [] } }, +}; + +describe("LysandRequestHandler", () => { + let validator: EntityValidator; + + beforeEach(() => { + validator = new EntityValidator(); + }); + + test("parseBody with valid User", async () => { + const handler = new RequestParserHandler(validUser, validator); + + const noteCallback = jest.fn(); + await handler.parseBody({ user: noteCallback }); + + expect(noteCallback).toHaveBeenCalled(); + }); + + test("Throw on invalid Note", async () => { + const handler = new RequestParserHandler( + { + type: "Note", + body: "bad", + }, + validator, + ); + + const noteCallback = jest.fn(); + await expect( + handler.parseBody({ note: noteCallback }), + ).rejects.toThrow(); + expect(noteCallback).not.toHaveBeenCalled(); + }); + + test("Throw on incorrect body type property", async () => { + const handler = new RequestParserHandler( + { + type: "DoesntExist", + body: "bad", + }, + validator, + ); + + const noteCallback = jest.fn(); + await expect( + handler.parseBody({ note: noteCallback }), + ).rejects.toThrow(); + expect(noteCallback).not.toHaveBeenCalled(); + }); +}); diff --git a/federation/http/index.ts b/federation/http/index.ts new file mode 100644 index 0000000..2e9fab4 --- /dev/null +++ b/federation/http/index.ts @@ -0,0 +1,129 @@ +import type { EntityValidator } from "../validator/index"; + +type MaybePromise = T | Promise; + +type ParserCallbacks = { + note: (note: typeof EntityValidator.$Note) => MaybePromise; + follow: (follow: typeof EntityValidator.$Follow) => MaybePromise; + followAccept: ( + followAccept: typeof EntityValidator.$FollowAccept, + ) => MaybePromise; + followReject: ( + followReject: typeof EntityValidator.$FollowReject, + ) => MaybePromise; + user: (user: typeof EntityValidator.$User) => MaybePromise; + like: (like: typeof EntityValidator.$Like) => MaybePromise; + dislike: (dislike: typeof EntityValidator.$Dislike) => MaybePromise; + undo: (undo: typeof EntityValidator.$Undo) => MaybePromise; + serverMetadata: ( + serverMetadata: typeof EntityValidator.$ServerMetadata, + ) => MaybePromise; + extension: ( + extension: typeof EntityValidator.$Extension, + ) => MaybePromise; +}; + +export class RequestParserHandler { + constructor( + private readonly body: Record< + string, + string | number | object | boolean | null + >, + private readonly validator: EntityValidator, + ) {} + + /** + * Parse the body of the request and call the appropriate callback. + * @param callbacks The callbacks to call when a specific entity is found. + * @returns A promise that resolves when the body has been parsed, and the callbacks have finished executing. + */ + public async parseBody(callbacks: Partial): Promise { + if (!this.body.type) throw new Error("Missing type field in body"); + + switch (this.body.type) { + case "Note": { + const note = await this.validator.Note(this.body); + + if (callbacks.note) await callbacks.note(note); + + break; + } + case "Follow": { + const follow = await this.validator.Follow(this.body); + + if (callbacks.follow) await callbacks.follow(follow); + + break; + } + case "FollowAccept": { + const followAccept = await this.validator.FollowAccept( + this.body, + ); + + if (callbacks.followAccept) + await callbacks.followAccept(followAccept); + + break; + } + case "FollowReject": { + const followReject = await this.validator.FollowReject( + this.body, + ); + + if (callbacks.followReject) + await callbacks.followReject(followReject); + + break; + } + case "User": { + const user = await this.validator.User(this.body); + + if (callbacks.user) await callbacks.user(user); + + break; + } + case "Like": { + const like = await this.validator.Like(this.body); + + if (callbacks.like) await callbacks.like(like); + + break; + } + case "Dislike": { + const dislike = await this.validator.Dislike(this.body); + + if (callbacks.dislike) await callbacks.dislike(dislike); + + break; + } + case "Undo": { + const undo = await this.validator.Undo(this.body); + + if (callbacks.undo) await callbacks.undo(undo); + + break; + } + case "ServerMetadata": { + const serverMetadata = await this.validator.ServerMetadata( + this.body, + ); + + if (callbacks.serverMetadata) + await callbacks.serverMetadata(serverMetadata); + + break; + } + case "Extension": { + const extension = await this.validator.Extension(this.body); + + if (callbacks.extension) await callbacks.extension(extension); + + break; + } + default: + throw new Error( + `Invalid type field in body: ${this.body.type}`, + ); + } + } +} diff --git a/federation/index.ts b/federation/index.ts index c92887d..76cbdf9 100644 --- a/federation/index.ts +++ b/federation/index.ts @@ -5,323 +5,15 @@ * @see module:federation/schemas/base */ -import type { z } from "zod"; -import { type ValidationError, fromError } from "zod-validation-error"; +import type { ValidationError } from "zod-validation-error"; import { SignatureConstructor, SignatureValidator } from "./cryptography"; -import { - ActionSchema, - ActorPublicKeyDataSchema, - ContentFormatSchema, - CustomEmojiExtension, - DislikeSchema, - EntitySchema, - ExtensionPropertySchema, - ExtensionSchema, - FollowAcceptSchema, - FollowRejectSchema, - FollowSchema, - LikeSchema, - NoteSchema, - PatchSchema, - PublicationSchema, - ReportSchema, - ServerMetadataSchema, - UndoSchema, - UserSchema, - VanityExtensionSchema, - VisibilitySchema, -} from "./schemas/base"; - -// biome-ignore lint/suspicious/noExplicitAny: Used only as a base type -type AnyZod = z.ZodType; - -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 $ExtensionProperty: InferType< - typeof ExtensionPropertySchema - >; - 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); - } - - /** - * Validates an ExtensionProperty. - * @param data - The data to validate - * @returns A promise that resolves to the validated data. - */ - public ExtensionProperty( - data: unknown, - ): Promise> { - return this.validate(ExtensionPropertySchema, data); - } -} +import { RequestParserHandler } from "./http"; +import { EntityValidator } from "./validator"; export { EntityValidator, type ValidationError, SignatureConstructor, SignatureValidator, + RequestParserHandler, }; diff --git a/federation/tests/index.test.ts b/federation/tests/index.test.ts index b802cb5..972510d 100644 --- a/federation/tests/index.test.ts +++ b/federation/tests/index.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it } from "bun:test"; -import type { ValidationError } from "zod-validation-error"; import { EntityValidator } from "../index"; describe("Package testing", () => { diff --git a/federation/validator/index.ts b/federation/validator/index.ts new file mode 100644 index 0000000..3a048a3 --- /dev/null +++ b/federation/validator/index.ts @@ -0,0 +1,312 @@ +import type { z } from "zod"; +import { fromError } from "zod-validation-error"; +import { + ActionSchema, + ActorPublicKeyDataSchema, + ContentFormatSchema, + CustomEmojiExtension, + DislikeSchema, + EntitySchema, + ExtensionPropertySchema, + ExtensionSchema, + FollowAcceptSchema, + FollowRejectSchema, + FollowSchema, + LikeSchema, + NoteSchema, + PatchSchema, + PublicationSchema, + ReportSchema, + ServerMetadataSchema, + UndoSchema, + UserSchema, + VanityExtensionSchema, + VisibilitySchema, +} from "../schemas/base"; + +// biome-ignore lint/suspicious/noExplicitAny: Used only as a base type +type AnyZod = z.ZodType; + +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", + * // ... + * } + */ +export 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 $ExtensionProperty: InferType< + typeof ExtensionPropertySchema + >; + 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); + } + + /** + * Validates an ExtensionProperty. + * @param data - The data to validate + * @returns A promise that resolves to the validated data. + */ + public ExtensionProperty( + data: unknown, + ): Promise> { + return this.validate(ExtensionPropertySchema, data); + } +}