feat(federation): Add new RequestParserHandler to less verbosely handle body parsing

This commit is contained in:
Jesse Wierzbinski 2024-05-28 13:38:47 -10:00
parent 8860d09eb4
commit 09a9f0bbf5
No known key found for this signature in database
6 changed files with 541 additions and 316 deletions

View file

@ -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",

View file

@ -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: "<p>Hey</p>\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: "<p>Identity</p>\n" } },
value: {
"text/html": {
content:
'<p><a href="https://keyoxide.org/aspe:keyoxide.org:NKLLPWPV7P35NEU7JP4K4ID4CA">https://keyoxide.org/aspe:keyoxide.org:NKLLPWPV7P35NEU7JP4K4ID4CA</a></p>\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();
});
});

129
federation/http/index.ts Normal file
View file

@ -0,0 +1,129 @@
import type { EntityValidator } from "../validator/index";
type MaybePromise<T> = T | Promise<T>;
type ParserCallbacks = {
note: (note: typeof EntityValidator.$Note) => MaybePromise<void>;
follow: (follow: typeof EntityValidator.$Follow) => MaybePromise<void>;
followAccept: (
followAccept: typeof EntityValidator.$FollowAccept,
) => MaybePromise<void>;
followReject: (
followReject: typeof EntityValidator.$FollowReject,
) => MaybePromise<void>;
user: (user: typeof EntityValidator.$User) => MaybePromise<void>;
like: (like: typeof EntityValidator.$Like) => MaybePromise<void>;
dislike: (dislike: typeof EntityValidator.$Dislike) => MaybePromise<void>;
undo: (undo: typeof EntityValidator.$Undo) => MaybePromise<void>;
serverMetadata: (
serverMetadata: typeof EntityValidator.$ServerMetadata,
) => MaybePromise<void>;
extension: (
extension: typeof EntityValidator.$Extension,
) => MaybePromise<void>;
};
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<ParserCallbacks>): Promise<void> {
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}`,
);
}
}
}

View file

@ -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<any, any, any>;
type InferType<T extends AnyZod> = z.infer<T>;
/**
* 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<T extends AnyZod>(
schema: T,
data: unknown,
): Promise<InferType<T>> {
try {
return (await schema.parseAsync(data)) as InferType<T>;
} catch (error) {
throw fromError(error);
}
}
declare static $Note: InferType<typeof NoteSchema>;
declare static $Patch: InferType<typeof PatchSchema>;
declare static $ActorPublicKeyData: InferType<
typeof ActorPublicKeyDataSchema
>;
declare static $ExtensionProperty: InferType<
typeof ExtensionPropertySchema
>;
declare static $VanityExtension: InferType<typeof VanityExtensionSchema>;
declare static $User: InferType<typeof UserSchema>;
declare static $Action: InferType<typeof ActionSchema>;
declare static $Like: InferType<typeof LikeSchema>;
declare static $Undo: InferType<typeof UndoSchema>;
declare static $Dislike: InferType<typeof DislikeSchema>;
declare static $Follow: InferType<typeof FollowSchema>;
declare static $FollowAccept: InferType<typeof FollowAcceptSchema>;
declare static $FollowReject: InferType<typeof FollowRejectSchema>;
declare static $Extension: InferType<typeof ExtensionSchema>;
declare static $Report: InferType<typeof ReportSchema>;
declare static $ServerMetadata: InferType<typeof ServerMetadataSchema>;
declare static $ContentFormat: InferType<typeof ContentFormatSchema>;
declare static $CustomEmojiExtension: InferType<
typeof CustomEmojiExtension
>;
declare static $Visibility: InferType<typeof VisibilitySchema>;
declare static $Publication: InferType<typeof PublicationSchema>;
declare static $Entity: InferType<typeof EntitySchema>;
/**
* Validates a Note entity.
* @param data - The data to validate
* @returns A promise that resolves to the validated data.
*/
public Note(data: unknown): Promise<InferType<typeof NoteSchema>> {
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<InferType<typeof PatchSchema>> {
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<InferType<typeof ActorPublicKeyDataSchema>> {
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<InferType<typeof VanityExtensionSchema>> {
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<InferType<typeof UserSchema>> {
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<InferType<typeof ActionSchema>> {
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<InferType<typeof LikeSchema>> {
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<InferType<typeof UndoSchema>> {
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<InferType<typeof DislikeSchema>> {
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<InferType<typeof FollowSchema>> {
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<InferType<typeof FollowAcceptSchema>> {
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<InferType<typeof FollowRejectSchema>> {
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<InferType<typeof ExtensionSchema>> {
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<InferType<typeof ReportSchema>> {
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<InferType<typeof ServerMetadataSchema>> {
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<InferType<typeof ContentFormatSchema>> {
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<InferType<typeof CustomEmojiExtension>> {
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<InferType<typeof VisibilitySchema>> {
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<InferType<typeof PublicationSchema>> {
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<InferType<typeof EntitySchema>> {
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<InferType<typeof ExtensionPropertySchema>> {
return this.validate(ExtensionPropertySchema, data);
}
}
import { RequestParserHandler } from "./http";
import { EntityValidator } from "./validator";
export {
EntityValidator,
type ValidationError,
SignatureConstructor,
SignatureValidator,
RequestParserHandler,
};

View file

@ -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", () => {

View file

@ -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<any, any, any>;
type InferType<T extends AnyZod> = z.infer<T>;
/**
* 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<T extends AnyZod>(
schema: T,
data: unknown,
): Promise<InferType<T>> {
try {
return (await schema.parseAsync(data)) as InferType<T>;
} catch (error) {
throw fromError(error);
}
}
declare static $Note: InferType<typeof NoteSchema>;
declare static $Patch: InferType<typeof PatchSchema>;
declare static $ActorPublicKeyData: InferType<
typeof ActorPublicKeyDataSchema
>;
declare static $ExtensionProperty: InferType<
typeof ExtensionPropertySchema
>;
declare static $VanityExtension: InferType<typeof VanityExtensionSchema>;
declare static $User: InferType<typeof UserSchema>;
declare static $Action: InferType<typeof ActionSchema>;
declare static $Like: InferType<typeof LikeSchema>;
declare static $Undo: InferType<typeof UndoSchema>;
declare static $Dislike: InferType<typeof DislikeSchema>;
declare static $Follow: InferType<typeof FollowSchema>;
declare static $FollowAccept: InferType<typeof FollowAcceptSchema>;
declare static $FollowReject: InferType<typeof FollowRejectSchema>;
declare static $Extension: InferType<typeof ExtensionSchema>;
declare static $Report: InferType<typeof ReportSchema>;
declare static $ServerMetadata: InferType<typeof ServerMetadataSchema>;
declare static $ContentFormat: InferType<typeof ContentFormatSchema>;
declare static $CustomEmojiExtension: InferType<
typeof CustomEmojiExtension
>;
declare static $Visibility: InferType<typeof VisibilitySchema>;
declare static $Publication: InferType<typeof PublicationSchema>;
declare static $Entity: InferType<typeof EntitySchema>;
/**
* Validates a Note entity.
* @param data - The data to validate
* @returns A promise that resolves to the validated data.
*/
public Note(data: unknown): Promise<InferType<typeof NoteSchema>> {
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<InferType<typeof PatchSchema>> {
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<InferType<typeof ActorPublicKeyDataSchema>> {
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<InferType<typeof VanityExtensionSchema>> {
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<InferType<typeof UserSchema>> {
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<InferType<typeof ActionSchema>> {
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<InferType<typeof LikeSchema>> {
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<InferType<typeof UndoSchema>> {
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<InferType<typeof DislikeSchema>> {
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<InferType<typeof FollowSchema>> {
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<InferType<typeof FollowAcceptSchema>> {
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<InferType<typeof FollowRejectSchema>> {
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<InferType<typeof ExtensionSchema>> {
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<InferType<typeof ReportSchema>> {
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<InferType<typeof ServerMetadataSchema>> {
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<InferType<typeof ContentFormatSchema>> {
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<InferType<typeof CustomEmojiExtension>> {
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<InferType<typeof VisibilitySchema>> {
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<InferType<typeof PublicationSchema>> {
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<InferType<typeof EntitySchema>> {
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<InferType<typeof ExtensionPropertySchema>> {
return this.validate(ExtensionPropertySchema, data);
}
}