feat(federation): Improve federation module

This commit is contained in:
Jesse Wierzbinski 2024-05-14 09:08:46 -10:00
parent e0b6d57470
commit 407e57fe34
No known key found for this signature in database
10 changed files with 382 additions and 116 deletions

View file

@ -1,3 +1,3 @@
{ {
"conventionalCommits.scopes": ["docs", "build"] "conventionalCommits.scopes": ["docs", "build", "federation"]
} }

View file

@ -12,6 +12,12 @@ await Bun.build({
splitting: true, splitting: true,
target: "browser", target: "browser",
plugins: [dts()], 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"); spinner.succeed("Built federation module");

View file

@ -6,48 +6,301 @@
*/ */
import type { z } from "zod"; import type { z } from "zod";
import { type ValidationError, fromError } from "zod-validation-error";
import { import {
Action, ActionSchema,
ActorPublicKeyData, ActorPublicKeyDataSchema,
ContentFormat, ContentFormatSchema,
Dislike, CustomEmojiExtension,
Entity, DislikeSchema,
Extension, EntitySchema,
Follow, ExtensionSchema,
FollowAccept, FollowAcceptSchema,
FollowReject, FollowRejectSchema,
Like, FollowSchema,
Note, LikeSchema,
Patch, NoteSchema,
Publication, PatchSchema,
Report, PublicationSchema,
ServerMetadata, ReportSchema,
Undo, ServerMetadataSchema,
User, UndoSchema,
VanityExtension, UserSchema,
Visibility, VanityExtensionSchema,
VisibilitySchema,
} from "./schemas/base"; } from "./schemas/base";
export type InferType<T extends z.AnyZodObject> = z.infer<T>; // biome-ignore lint/suspicious/noExplicitAny: Used only as a base type
type AnyZod = z.ZodType<any, any, any>;
export { export type InferType<T extends AnyZod> = z.infer<T>;
Entity,
ContentFormat, /**
Visibility, * Validates entities against their respective schemas.
Publication, * @module federation/validator
Note, * @see module:federation/schemas/base
Patch, * @example
ActorPublicKeyData, * import { EntityValidator, type ValidationError } from "@lysand-org/federation";
VanityExtension, * const validator = new EntityValidator();
User, *
Action, * // Will throw a special ValidationError with a human-friendly error message
Like, * // and a machine-friendly error object if the data is invalid.
Undo, * const note = await validator.Note({
Dislike, * type: "Note",
Follow, * content: "Hello, world!",
FollowAccept, * });
FollowReject, *
Extension, * try {
Report, * await validator.Note({
ServerMetadata, * 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 $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);
}
}
export { EntityValidator, type ValidationError };

View file

@ -1,10 +1,10 @@
import { z } from "zod"; import { z } from "zod";
import { ContentFormat } from "./content_format"; import { ContentFormatSchema } from "./content_format";
import { CustomEmojiExtension } from "./extensions/custom_emojis"; import { CustomEmojiExtension } from "./extensions/custom_emojis";
import { VanityExtension } from "./extensions/vanity"; import { VanityExtensionSchema } from "./extensions/vanity";
import { extensionTypeRegex } from "./regex"; import { extensionTypeRegex } from "./regex";
const Entity = z.object({ const EntitySchema = z.object({
id: z.string().uuid(), id: z.string().uuid(),
created_at: z.string(), created_at: z.string(),
uri: z.string().url(), 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"]), type: z.enum(["Note", "Patch"]),
author: z.string().url(), author: z.string().url(),
content: ContentFormat.optional(), content: ContentFormatSchema.optional(),
attachments: z.array(ContentFormat).optional(), attachments: z.array(ContentFormatSchema).optional(),
replies_to: z.string().url().optional(), replies_to: z.string().url().optional(),
quotes: z.string().url().optional(), quotes: z.string().url().optional(),
mentions: z.array(z.string().url()).optional(), mentions: z.array(z.string().url()).optional(),
subject: z.string().optional(), subject: z.string().optional(),
is_sensitive: z.boolean().optional(), is_sensitive: z.boolean().optional(),
visibility: Visibility, visibility: VisibilitySchema,
extensions: Entity.shape.extensions.extend({ extensions: EntitySchema.shape.extensions.extend({
"org.lysand:reactions": z "org.lysand:reactions": z
.object({ .object({
reactions: z.string(), reactions: z.string(),
@ -36,7 +36,7 @@ const Publication = Entity.extend({
"org.lysand:polls": z "org.lysand:polls": z
.object({ .object({
poll: z.object({ poll: z.object({
options: z.array(ContentFormat), options: z.array(ContentFormatSchema),
votes: z.array(z.number().int().nonnegative()), votes: z.array(z.number().int().nonnegative()),
multiple_choice: z.boolean().optional(), multiple_choice: z.boolean().optional(),
expires_at: z.string(), expires_at: z.string(),
@ -46,35 +46,35 @@ const Publication = Entity.extend({
}), }),
}); });
const Note = Publication.extend({ const NoteSchema = PublicationSchema.extend({
type: z.literal("Note"), type: z.literal("Note"),
}); });
const Patch = Publication.extend({ const PatchSchema = PublicationSchema.extend({
type: z.literal("Patch"), type: z.literal("Patch"),
patched_id: z.string().uuid(), patched_id: z.string().uuid(),
patched_at: z.string(), patched_at: z.string(),
}); });
const ActorPublicKeyData = z.object({ const ActorPublicKeyDataSchema = z.object({
public_key: z.string(), public_key: z.string(),
actor: z.string().url(), actor: z.string().url(),
}); });
const User = Entity.extend({ const UserSchema = EntitySchema.extend({
type: z.literal("User"), type: z.literal("User"),
display_name: z.string().optional(), display_name: z.string().optional(),
username: z.string(), username: z.string(),
avatar: ContentFormat.optional(), avatar: ContentFormatSchema.optional(),
header: ContentFormat.optional(), header: ContentFormatSchema.optional(),
indexable: z.boolean(), indexable: z.boolean(),
public_key: ActorPublicKeyData, public_key: ActorPublicKeyDataSchema,
bio: ContentFormat.optional(), bio: ContentFormatSchema.optional(),
fields: z fields: z
.array( .array(
z.object({ z.object({
name: ContentFormat, name: ContentFormatSchema,
value: ContentFormat, value: ContentFormatSchema,
}), }),
) )
.optional(), .optional(),
@ -85,12 +85,12 @@ const User = Entity.extend({
dislikes: z.string().url(), dislikes: z.string().url(),
inbox: z.string().url(), inbox: z.string().url(),
outbox: z.string().url(), outbox: z.string().url(),
extensions: Entity.shape.extensions.extend({ extensions: EntitySchema.shape.extensions.extend({
"org.lysand:vanity": VanityExtension.optional(), "org.lysand:vanity": VanityExtensionSchema.optional(),
}), }),
}); });
const Action = Entity.extend({ const ActionSchema = EntitySchema.extend({
type: z.union([ type: z.union([
z.literal("Like"), z.literal("Like"),
z.literal("Dislike"), z.literal("Dislike"),
@ -103,37 +103,37 @@ const Action = Entity.extend({
author: z.string().url(), author: z.string().url(),
}); });
const Like = Action.extend({ const LikeSchema = ActionSchema.extend({
type: z.literal("Like"), type: z.literal("Like"),
object: z.string().url(), object: z.string().url(),
}); });
const Undo = Action.extend({ const UndoSchema = ActionSchema.extend({
type: z.literal("Undo"), type: z.literal("Undo"),
object: z.string().url(), object: z.string().url(),
}); });
const Dislike = Action.extend({ const DislikeSchema = ActionSchema.extend({
type: z.literal("Dislike"), type: z.literal("Dislike"),
object: z.string().url(), object: z.string().url(),
}); });
const Follow = Action.extend({ const FollowSchema = ActionSchema.extend({
type: z.literal("Follow"), type: z.literal("Follow"),
followee: z.string().url(), followee: z.string().url(),
}); });
const FollowAccept = Action.extend({ const FollowAcceptSchema = ActionSchema.extend({
type: z.literal("FollowAccept"), type: z.literal("FollowAccept"),
follower: z.string().url(), follower: z.string().url(),
}); });
const FollowReject = Action.extend({ const FollowRejectSchema = ActionSchema.extend({
type: z.literal("FollowReject"), type: z.literal("FollowReject"),
follower: z.string().url(), follower: z.string().url(),
}); });
const Extension = Entity.extend({ const ExtensionSchema = EntitySchema.extend({
type: z.literal("Extension"), type: z.literal("Extension"),
extension_type: z extension_type: z
.string() .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"), extension_type: z.literal("org.lysand:reports/Report"),
objects: z.array(z.string().url()), objects: z.array(z.string().url()),
reason: z.string(), reason: z.string(),
comment: z.string().optional(), comment: z.string().optional(),
}); });
const ServerMetadata = Entity.extend({ const ServerMetadataSchema = EntitySchema.extend({
type: z.literal("ServerMetadata"), type: z.literal("ServerMetadata"),
name: z.string(), name: z.string(),
version: z.string(), version: z.string(),
@ -158,31 +158,31 @@ const ServerMetadata = Entity.extend({
website: z.string().optional(), website: z.string().optional(),
moderators: z.array(z.string()).optional(), moderators: z.array(z.string()).optional(),
admins: z.array(z.string()).optional(), admins: z.array(z.string()).optional(),
logo: ContentFormat.optional(), logo: ContentFormatSchema.optional(),
banner: ContentFormat.optional(), banner: ContentFormatSchema.optional(),
supported_extensions: z.array(z.string()), supported_extensions: z.array(z.string()),
extensions: z.record(z.string(), z.any()).optional(), extensions: z.record(z.string(), z.any()).optional(),
}); });
export { export {
Entity, EntitySchema,
Visibility, VisibilitySchema,
Publication, PublicationSchema,
Note, NoteSchema,
Patch, PatchSchema,
ActorPublicKeyData, ActorPublicKeyDataSchema,
VanityExtension, VanityExtensionSchema,
User, UserSchema,
Action, ActionSchema,
Like, LikeSchema,
Undo, UndoSchema,
Dislike, DislikeSchema,
Follow, FollowSchema,
FollowAccept, FollowAcceptSchema,
FollowReject, FollowRejectSchema,
Extension, ExtensionSchema,
Report, ReportSchema,
ServerMetadata, ServerMetadataSchema,
ContentFormat, ContentFormatSchema,
CustomEmojiExtension, CustomEmojiExtension,
}; };

View file

@ -1,7 +1,7 @@
import { types } from "mime-types"; import { types } from "mime-types";
import { z } from "zod"; import { z } from "zod";
export const ContentFormat = z.record( export const ContentFormatSchema = z.record(
z.enum(Object.values(types) as [string, ...string[]]), z.enum(Object.values(types) as [string, ...string[]]),
z.object({ z.object({
content: z.string(), content: z.string(),

View file

@ -5,7 +5,7 @@
* @see https://lysand.org/extensions/custom-emojis * @see https://lysand.org/extensions/custom-emojis
*/ */
import { z } from "zod"; import { z } from "zod";
import { ContentFormat } from "../content_format"; import { ContentFormatSchema } from "../content_format";
import { emojiRegex } from "../regex"; import { emojiRegex } from "../regex";
/** /**
@ -44,7 +44,7 @@ export const CustomEmojiExtension = z.object({
emojiRegex, emojiRegex,
"Emoji name must be alphanumeric, underscores, or dashes.", "Emoji name must be alphanumeric, underscores, or dashes.",
), ),
url: ContentFormat, url: ContentFormatSchema,
}), }),
), ),
}); });

View file

@ -5,8 +5,8 @@
* @see https://lysand.org/extensions/polls * @see https://lysand.org/extensions/polls
*/ */
import { z } from "zod"; import { z } from "zod";
import { Extension } from "../base"; import { ExtensionSchema } from "../base";
import { ContentFormat } from "../content_format"; import { ContentFormatSchema } from "../content_format";
/** /**
* @description Poll extension entity * @description Poll extension entity
@ -43,9 +43,9 @@ import { ContentFormat } from "../content_format";
* "expires_at": "2021-01-04T00:00:00.000Z" * "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"), extension_type: z.literal("org.lysand:polls/Poll"),
options: z.array(ContentFormat), options: z.array(ContentFormatSchema),
votes: z.array(z.number().int().nonnegative()), votes: z.array(z.number().int().nonnegative()),
multiple_choice: z.boolean().optional(), multiple_choice: z.boolean().optional(),
expires_at: z.string(), expires_at: z.string(),
@ -65,7 +65,7 @@ export const Poll = Extension.extend({
* "option": 1 * "option": 1
* } * }
*/ */
export const Vote = Extension.extend({ export const VoteSchema = ExtensionSchema.extend({
extension_type: z.literal("org.lysand:polls/Vote"), extension_type: z.literal("org.lysand:polls/Vote"),
poll: z.string().url(), poll: z.string().url(),
option: z.number(), option: z.number(),
@ -85,7 +85,7 @@ export const Vote = Extension.extend({
* "votes": [9, 5, 0] * "votes": [9, 5, 0]
* } * }
*/ */
export const VoteResult = Extension.extend({ export const VoteResultSchema = ExtensionSchema.extend({
extension_type: z.literal("org.lysand:polls/VoteResult"), extension_type: z.literal("org.lysand:polls/VoteResult"),
poll: z.string().url(), poll: z.string().url(),
votes: z.array(z.number().int().nonnegative()), votes: z.array(z.number().int().nonnegative()),

View file

@ -5,7 +5,7 @@
* @see https://lysand.org/extensions/reactions * @see https://lysand.org/extensions/reactions
*/ */
import { z } from "zod"; import { z } from "zod";
import { Extension } from "../base"; import { ExtensionSchema } from "../base";
/** /**
* @description Reaction extension entity * @description Reaction extension entity
@ -21,7 +21,7 @@ import { Extension } from "../base";
* "content": "👍" * "content": "👍"
* } * }
*/ */
export const Reaction = Extension.extend({ export const ReactionSchema = ExtensionSchema.extend({
extension_type: z.literal("org.lysand:reactions/Reaction"), extension_type: z.literal("org.lysand:reactions/Reaction"),
object: z.string().url(), object: z.string().url(),
content: z.string(), content: z.string(),

View file

@ -5,8 +5,8 @@
* @see https://lysand.org/extensions/vanity * @see https://lysand.org/extensions/vanity
*/ */
import { type AnyZodObject, ZodObject, z } from "zod"; import { z } from "zod";
import { ContentFormat } from "../content_format"; import { ContentFormatSchema } from "../content_format";
/** /**
* @description Vanity extension entity * @description Vanity extension entity
@ -67,11 +67,11 @@ import { ContentFormat } from "../content_format";
* } * }
* } * }
*/ */
export const VanityExtension = z.object({ export const VanityExtensionSchema = z.object({
avatar_overlay: ContentFormat.optional(), avatar_overlay: ContentFormatSchema.optional(),
avatar_mask: ContentFormat.optional(), avatar_mask: ContentFormatSchema.optional(),
background: ContentFormat.optional(), background: ContentFormatSchema.optional(),
audio: ContentFormat.optional(), audio: ContentFormatSchema.optional(),
pronouns: z.record( pronouns: z.record(
z.string(), z.string(),
z.array( z.array(

View file

@ -1,12 +1,19 @@
import { describe, expect, it } from "bun:test"; 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", () => { describe("Package testing", () => {
it("should not validate a bad Note", () => { it("should not validate a bad Note", async () => {
const badObject = { const badObject = {
IamBad: "Note", 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(),
);
}); });
}); });