diff --git a/api/api/v1/accounts/:id/statuses.ts b/api/api/v1/accounts/:id/statuses.ts index 1b5649b2..dc61d337 100644 --- a/api/api/v1/accounts/:id/statuses.ts +++ b/api/api/v1/accounts/:id/statuses.ts @@ -1,8 +1,9 @@ import { apiRoute, auth, withUserParam } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; -import { Note, Timeline } from "@versia/kit/db"; +import { Timeline } from "@versia/kit/db"; import { Notes, RolePermissions } from "@versia/kit/tables"; import { and, eq, gt, gte, inArray, isNull, lt, or, sql } from "drizzle-orm"; +import { Status } from "~/classes/schemas/status"; const schemas = { param: z.object({ @@ -61,7 +62,7 @@ const route = createRoute({ description: "A list of statuses by the specified account", content: { "application/json": { - schema: z.array(Note.schema), + schema: z.array(Status), }, }, headers: { diff --git a/api/api/v1/apps/verify_credentials/index.ts b/api/api/v1/apps/verify_credentials/index.ts index 80a9327a..43de0474 100644 --- a/api/api/v1/apps/verify_credentials/index.ts +++ b/api/api/v1/apps/verify_credentials/index.ts @@ -3,6 +3,7 @@ import { createRoute } from "@hono/zod-openapi"; import { Application } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import { ApiError } from "~/classes/errors/api-error"; +import { Application as ApplicationSchema } from "~/classes/schemas/application"; import { ErrorSchema } from "~/types/api"; const route = createRoute({ @@ -21,7 +22,7 @@ const route = createRoute({ description: "Application", content: { "application/json": { - schema: Application.schema, + schema: ApplicationSchema, }, }, }, @@ -52,13 +53,6 @@ export default apiRoute((app) => throw new ApiError(401, "Application not found"); } - return context.json( - { - ...application.toApi(), - redirect_uris: application.data.redirectUri, - scopes: application.data.scopes, - }, - 200, - ); + return context.json(application.toApi(), 200); }), ); diff --git a/api/api/v1/favourites/index.ts b/api/api/v1/favourites/index.ts index 886dee91..09956e8c 100644 --- a/api/api/v1/favourites/index.ts +++ b/api/api/v1/favourites/index.ts @@ -1,8 +1,9 @@ import { apiRoute, auth } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; -import { Note, Timeline } from "@versia/kit/db"; +import { Timeline } from "@versia/kit/db"; import { Notes, RolePermissions } from "@versia/kit/tables"; import { and, gt, gte, lt, sql } from "drizzle-orm"; +import { Status } from "~/classes/schemas/status"; const schemas = { query: z.object({ @@ -31,7 +32,7 @@ const route = createRoute({ description: "Favourites", content: { "application/json": { - schema: z.array(Note.schema), + schema: z.array(Status), }, }, }, diff --git a/api/api/v1/statuses/:id/context.ts b/api/api/v1/statuses/:id/context.ts index f2ed139b..86f3689a 100644 --- a/api/api/v1/statuses/:id/context.ts +++ b/api/api/v1/statuses/:id/context.ts @@ -1,7 +1,7 @@ import { apiRoute, auth, withNoteParam } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; -import { Note } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; +import { Status } from "~/classes/schemas/status"; import { ErrorSchema } from "~/types/api"; const route = createRoute({ @@ -26,8 +26,8 @@ const route = createRoute({ content: { "application/json": { schema: z.object({ - ancestors: z.array(Note.schema), - descendants: z.array(Note.schema), + ancestors: z.array(Status), + descendants: z.array(Status), }), }, }, diff --git a/api/api/v1/statuses/:id/favourite.ts b/api/api/v1/statuses/:id/favourite.ts index 106056f5..f0ceb0bb 100644 --- a/api/api/v1/statuses/:id/favourite.ts +++ b/api/api/v1/statuses/:id/favourite.ts @@ -1,7 +1,7 @@ import { apiRoute, auth, withNoteParam } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; -import { Note } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; +import { Status } from "~/classes/schemas/status"; const route = createRoute({ method: "post", @@ -27,7 +27,7 @@ const route = createRoute({ description: "Favourited status", content: { "application/json": { - schema: Note.schema, + schema: Status, }, }, }, diff --git a/api/api/v1/statuses/:id/index.ts b/api/api/v1/statuses/:id/index.ts index 7643d72e..78295996 100644 --- a/api/api/v1/statuses/:id/index.ts +++ b/api/api/v1/statuses/:id/index.ts @@ -1,9 +1,10 @@ import { apiRoute, auth, jsonOrForm, withNoteParam } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; -import { Media, Note } from "@versia/kit/db"; +import { Media } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import ISO6391 from "iso-639-1"; import { ApiError } from "~/classes/errors/api-error"; +import { Status } from "~/classes/schemas/status"; import { config } from "~/packages/config-manager/index.ts"; import { ErrorSchema } from "~/types/api"; @@ -84,7 +85,7 @@ const routeGet = createRoute({ description: "Status", content: { "application/json": { - schema: Note.schema, + schema: Status, }, }, }, @@ -121,7 +122,7 @@ const routeDelete = createRoute({ description: "Deleted status", content: { "application/json": { - schema: Note.schema, + schema: Status, }, }, }, @@ -180,7 +181,7 @@ const routePut = createRoute({ description: "Updated status", content: { "application/json": { - schema: Note.schema, + schema: Status, }, }, }, diff --git a/api/api/v1/statuses/:id/pin.ts b/api/api/v1/statuses/:id/pin.ts index 2dd262d1..37d16596 100644 --- a/api/api/v1/statuses/:id/pin.ts +++ b/api/api/v1/statuses/:id/pin.ts @@ -1,9 +1,10 @@ import { apiRoute, auth, withNoteParam } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; -import { Note, db } from "@versia/kit/db"; +import { db } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import type { SQL } from "drizzle-orm"; import { ApiError } from "~/classes/errors/api-error"; +import { Status } from "~/classes/schemas/status"; import { ErrorSchema } from "~/types/api"; const route = createRoute({ @@ -30,7 +31,7 @@ const route = createRoute({ description: "Pinned status", content: { "application/json": { - schema: Note.schema, + schema: Status, }, }, }, diff --git a/api/api/v1/statuses/:id/reblog.ts b/api/api/v1/statuses/:id/reblog.ts index cfc5d046..20e92ec5 100644 --- a/api/api/v1/statuses/:id/reblog.ts +++ b/api/api/v1/statuses/:id/reblog.ts @@ -4,6 +4,7 @@ import { Note } from "@versia/kit/db"; import { Notes, RolePermissions } from "@versia/kit/tables"; import { and, eq } from "drizzle-orm"; import { ApiError } from "~/classes/errors/api-error"; +import { Status } from "~/classes/schemas/status"; import { ErrorSchema } from "~/types/api"; const schemas = { @@ -51,7 +52,7 @@ const route = createRoute({ description: "Reblogged status", content: { "application/json": { - schema: Note.schema, + schema: Status, }, }, }, diff --git a/api/api/v1/statuses/:id/unfavourite.ts b/api/api/v1/statuses/:id/unfavourite.ts index 944c3d09..0530467e 100644 --- a/api/api/v1/statuses/:id/unfavourite.ts +++ b/api/api/v1/statuses/:id/unfavourite.ts @@ -1,7 +1,7 @@ import { apiRoute, auth, withNoteParam } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; -import { Note } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; +import { Status } from "~/classes/schemas/status"; const route = createRoute({ method: "post", @@ -27,7 +27,7 @@ const route = createRoute({ description: "Unfavourited status", content: { "application/json": { - schema: Note.schema, + schema: Status, }, }, }, diff --git a/api/api/v1/statuses/:id/unpin.ts b/api/api/v1/statuses/:id/unpin.ts index 9b47440c..e531a73a 100644 --- a/api/api/v1/statuses/:id/unpin.ts +++ b/api/api/v1/statuses/:id/unpin.ts @@ -1,8 +1,8 @@ import { apiRoute, auth, withNoteParam } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; -import { Note } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import { ApiError } from "~/classes/errors/api-error"; +import { Status } from "~/classes/schemas/status"; import { ErrorSchema } from "~/types/api"; const route = createRoute({ @@ -29,7 +29,7 @@ const route = createRoute({ description: "Unpinned status", content: { "application/json": { - schema: Note.schema, + schema: Status, }, }, }, diff --git a/api/api/v1/statuses/:id/unreblog.ts b/api/api/v1/statuses/:id/unreblog.ts index 98bf280e..fca1f276 100644 --- a/api/api/v1/statuses/:id/unreblog.ts +++ b/api/api/v1/statuses/:id/unreblog.ts @@ -4,6 +4,7 @@ import { Note } from "@versia/kit/db"; import { Notes, RolePermissions } from "@versia/kit/tables"; import { and, eq } from "drizzle-orm"; import { ApiError } from "~/classes/errors/api-error"; +import { Status } from "~/classes/schemas/status"; import { ErrorSchema } from "~/types/api"; const route = createRoute({ @@ -30,7 +31,7 @@ const route = createRoute({ description: "Unreblogged status", content: { "application/json": { - schema: Note.schema, + schema: Status, }, }, }, diff --git a/api/api/v1/statuses/index.ts b/api/api/v1/statuses/index.ts index acf8b25e..cbf96b67 100644 --- a/api/api/v1/statuses/index.ts +++ b/api/api/v1/statuses/index.ts @@ -4,6 +4,7 @@ import { Media, Note } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import ISO6391 from "iso-639-1"; import { ApiError } from "~/classes/errors/api-error"; +import { Status } from "~/classes/schemas/status"; import { config } from "~/packages/config-manager/index.ts"; import { ErrorSchema } from "~/types/api"; @@ -116,7 +117,7 @@ const route = createRoute({ description: "The new status", content: { "application/json": { - schema: Note.schema, + schema: Status, }, }, }, diff --git a/api/api/v1/timelines/home.ts b/api/api/v1/timelines/home.ts index cca26065..68d302e0 100644 --- a/api/api/v1/timelines/home.ts +++ b/api/api/v1/timelines/home.ts @@ -1,8 +1,9 @@ import { apiRoute, auth } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; -import { Note, Timeline } from "@versia/kit/db"; +import { Timeline } from "@versia/kit/db"; import { Notes, RolePermissions } from "@versia/kit/tables"; import { and, eq, gt, gte, inArray, lt, or, sql } from "drizzle-orm"; +import { Status } from "~/classes/schemas/status"; const schemas = { query: z.object({ @@ -36,7 +37,7 @@ const route = createRoute({ description: "Home timeline", content: { "application/json": { - schema: z.array(Note.schema), + schema: z.array(Status), }, }, }, diff --git a/api/api/v1/timelines/public.ts b/api/api/v1/timelines/public.ts index 02170ee0..2004cf11 100644 --- a/api/api/v1/timelines/public.ts +++ b/api/api/v1/timelines/public.ts @@ -1,8 +1,9 @@ import { apiRoute, auth } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; -import { Note, Timeline } from "@versia/kit/db"; +import { Timeline } from "@versia/kit/db"; import { Notes, RolePermissions } from "@versia/kit/tables"; import { and, eq, gt, gte, inArray, lt, or, sql } from "drizzle-orm"; +import { Status } from "~/classes/schemas/status"; const schemas = { query: z.object({ @@ -47,7 +48,7 @@ const route = createRoute({ description: "Public timeline", content: { "application/json": { - schema: z.array(Note.schema), + schema: z.array(Status), }, }, }, diff --git a/api/api/v2/search/index.ts b/api/api/v2/search/index.ts index add9e5fa..c3c358c3 100644 --- a/api/api/v2/search/index.ts +++ b/api/api/v2/search/index.ts @@ -5,6 +5,7 @@ import { Instances, Notes, RolePermissions, Users } from "@versia/kit/tables"; import { and, eq, inArray, isNull, sql } from "drizzle-orm"; import { ApiError } from "~/classes/errors/api-error"; import { Account } from "~/classes/schemas/account"; +import { Status } from "~/classes/schemas/status"; import { searchManager } from "~/classes/search/search-manager"; import { config } from "~/packages/config-manager"; import { ErrorSchema } from "~/types/api"; @@ -48,7 +49,7 @@ const route = createRoute({ "application/json": { schema: z.object({ accounts: z.array(Account), - statuses: z.array(Note.schema), + statuses: z.array(Status), hashtags: z.array(z.string()), }), }, diff --git a/classes/database/application.ts b/classes/database/application.ts index 276c4e25..7713637d 100644 --- a/classes/database/application.ts +++ b/classes/database/application.ts @@ -1,5 +1,4 @@ -import { z } from "@hono/zod-openapi"; -import type { Application as APIApplication } from "@versia/client/types"; +import type { z } from "@hono/zod-openapi"; import { Token, db } from "@versia/kit/db"; import { Applications } from "@versia/kit/tables"; import { @@ -10,19 +9,15 @@ import { eq, inArray, } from "drizzle-orm"; +import type { + Application as ApplicationSchema, + CredentialApplication, +} from "../schemas/application.ts"; import { BaseInterface } from "./base.ts"; type ApplicationType = InferSelectModel; export class Application extends BaseInterface { - public static schema: z.ZodType = z.object({ - name: z.string(), - website: z.string().url().optional().nullable(), - vapid_key: z.string().optional().nullable(), - redirect_uris: z.string().optional(), - scopes: z.string().optional(), - }); - public static $type: ApplicationType; public async reload(): Promise { @@ -144,11 +139,26 @@ export class Application extends BaseInterface { return this.data.id; } - public toApi(): APIApplication { + public toApi(): z.infer { return { name: this.data.name, website: this.data.website, - vapid_key: this.data.vapidKey, + scopes: this.data.scopes.split(" "), + redirect_uri: this.data.redirectUri, + redirect_uris: this.data.redirectUri.split("\n"), + }; + } + + public toApiCredential(): z.infer { + return { + name: this.data.name, + website: this.data.website, + client_id: this.data.clientId, + client_secret: this.data.secret, + client_secret_expires_at: "0", + scopes: this.data.scopes.split(" "), + redirect_uri: this.data.redirectUri, + redirect_uris: this.data.redirectUri.split("\n"), }; } } diff --git a/classes/database/note.ts b/classes/database/note.ts index 70c5188d..ac9710ed 100644 --- a/classes/database/note.ts +++ b/classes/database/note.ts @@ -3,7 +3,7 @@ import { localObjectUri } from "@/constants"; import { mergeAndDeduplicate } from "@/lib.ts"; import { sanitizedHtmlStrip } from "@/sanitization"; import { sentry } from "@/sentry"; -import { z } from "@hono/zod-openapi"; +import type { z } from "@hono/zod-openapi"; import { getLogger } from "@logtape/logtape"; import type { Attachment as ApiAttachment, @@ -43,7 +43,7 @@ import { } from "~/classes/functions/status"; import { config } from "~/packages/config-manager"; import { DeliveryJobType, deliveryQueue } from "../queues/delivery.ts"; -import { Account } from "../schemas/account.ts"; +import type { Status } from "../schemas/status.ts"; import { Application } from "./application.ts"; import { BaseInterface } from "./base.ts"; import { Emoji } from "./emoji.ts"; @@ -81,96 +81,6 @@ export type NoteTypeWithoutRecursiveRelations = Omit< * Gives helpers to fetch notes from database in a nice format */ export class Note extends BaseInterface { - public static schema: z.ZodType = z.object({ - id: z.string().uuid(), - uri: z.string().url(), - url: z.string().url(), - account: Account, - in_reply_to_id: z.string().uuid().nullable(), - in_reply_to_account_id: z.string().uuid().nullable(), - reblog: z.lazy(() => Note.schema).nullable(), - content: z.string(), - plain_content: z.string().nullable(), - created_at: z.string(), - edited_at: z.string().nullable(), - emojis: z.array(Emoji.schema), - replies_count: z.number().int().nonnegative(), - reblogs_count: z.number().int().nonnegative(), - favourites_count: z.number().int().nonnegative(), - reblogged: z.boolean().nullable(), - favourited: z.boolean().nullable(), - muted: z.boolean().nullable(), - sensitive: z.boolean(), - spoiler_text: z.string(), - visibility: z.enum(["public", "unlisted", "private", "direct"]), - media_attachments: z.array(Media.schema), - mentions: z.array( - z.object({ - id: z.string().uuid(), - username: z.string(), - acct: z.string(), - url: z.string().url(), - }), - ), - tags: z.array(z.object({ name: z.string(), url: z.string().url() })), - card: z - .object({ - url: z.string().url(), - title: z.string(), - description: z.string(), - type: z.enum(["link", "photo", "video", "rich"]), - image: z.string().url().nullable(), - author_name: z.string().nullable(), - author_url: z.string().url().nullable(), - provider_name: z.string().nullable(), - provider_url: z.string().url().nullable(), - html: z.string().nullable(), - width: z.number().int().nonnegative().nullable(), - height: z.number().int().nonnegative().nullable(), - embed_url: z.string().url().nullable(), - blurhash: z.string().nullable(), - }) - .nullable(), - poll: z - .object({ - id: z.string().uuid(), - expires_at: z.string(), - expired: z.boolean(), - multiple: z.boolean(), - votes_count: z.number().int().nonnegative(), - voted: z.boolean(), - options: z.array( - z.object({ - title: z.string(), - votes_count: z.number().int().nonnegative().nullable(), - }), - ), - }) - .nullable(), - application: z - .object({ - name: z.string(), - website: z.string().url().nullable().optional(), - vapid_key: z.string().nullable().optional(), - }) - .nullable(), - language: z.string().nullable(), - pinned: z.boolean().nullable(), - emoji_reactions: z.array( - z.object({ - count: z.number().int().nonnegative(), - me: z.boolean(), - name: z.string(), - url: z.string().url().optional(), - static_url: z.string().url().optional(), - accounts: z.array(Account).optional(), - account_ids: z.array(z.string().uuid()).optional(), - }), - ), - quote: z.lazy(() => Note.schema).nullable(), - bookmarked: z.boolean(), - }); - public static $type: NoteTypeWithRelations; public save(): Promise { @@ -861,7 +771,9 @@ export class Note extends BaseInterface { * @param userFetching - The user fetching the note (used to check if the note is favourite and such) * @returns The note in the Mastodon API format */ - public async toApi(userFetching?: User | null): Promise { + public async toApi( + userFetching?: User | null, + ): Promise> { const data = this.data; // Convert mentions of local users from @username@host to @username @@ -893,7 +805,7 @@ export class Note extends BaseInterface { created_at: new Date(data.createdAt).toISOString(), application: data.application ? new Application(data.application).toApi() - : null, + : undefined, card: null, content: replacedContent, emojis: data.emojis.map((emoji) => new Emoji(emoji).toApi()), diff --git a/classes/database/notification.ts b/classes/database/notification.ts index 67effe85..8e9b8c21 100644 --- a/classes/database/notification.ts +++ b/classes/database/notification.ts @@ -16,6 +16,7 @@ import { userRelations, } from "../functions/user.ts"; import { Account } from "../schemas/account.ts"; +import { Status } from "../schemas/status.ts"; import { BaseInterface } from "./base.ts"; export type NotificationType = InferSelectModel & { @@ -31,7 +32,7 @@ export class Notification extends BaseInterface< account: Account.nullable(), created_at: z.string(), id: z.string().uuid(), - status: z.lazy(() => Note.schema).optional(), + status: Status.optional(), // TODO: Add reactions type: z.enum([ "mention", diff --git a/classes/database/user.ts b/classes/database/user.ts index 2f6db3a2..e52d7bca 100644 --- a/classes/database/user.ts +++ b/classes/database/user.ts @@ -3,6 +3,7 @@ import { getBestContentType, urlToContentFormat } from "@/content_types"; import { randomString } from "@/math"; import { proxyUrl } from "@/response"; import { sentry } from "@/sentry"; +import type { z } from "@hono/zod-openapi"; import { getLogger } from "@logtape/logtape"; import type { Mention as ApiMention } from "@versia/client/types"; import { @@ -45,7 +46,6 @@ import { sql, } from "drizzle-orm"; import { htmlToText } from "html-to-text"; -import type { z } from "zod"; import { findManyUsers } from "~/classes/functions/user"; import { searchManager } from "~/classes/search/search-manager"; import { type Config, config } from "~/packages/config-manager"; diff --git a/classes/schemas/account.ts b/classes/schemas/account.ts index cf5c0b6e..dbcb418b 100644 --- a/classes/schemas/account.ts +++ b/classes/schemas/account.ts @@ -75,6 +75,7 @@ export const Source = z follow_requests_count: z .number() .int() + .nonnegative() .optional() .openapi({ description: "The number of pending follow requests.", @@ -352,8 +353,8 @@ export const Account = z.object({ .nullable(), statuses_count: z .number() - .min(0) .int() + .nonnegative() .openapi({ description: "How many statuses are attached to this account.", example: 42, @@ -363,8 +364,8 @@ export const Account = z.object({ }), followers_count: z .number() - .min(0) .int() + .nonnegative() .openapi({ description: "The reported followers of this profile.", example: 6, @@ -374,8 +375,8 @@ export const Account = z.object({ }), following_count: z .number() - .min(0) .int() + .nonnegative() .openapi({ description: "The reported follows of this profile.", example: 23, diff --git a/classes/schemas/application.ts b/classes/schemas/application.ts new file mode 100644 index 00000000..5ec11ecd --- /dev/null +++ b/classes/schemas/application.ts @@ -0,0 +1,71 @@ +import { z } from "@hono/zod-openapi"; + +export const Application = z.object({ + name: z.string().openapi({ + description: "The name of your application.", + example: "Test Application", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Application/#name", + }, + }), + website: z + .string() + .nullable() + .openapi({ + description: "The website associated with your application.", + example: "https://app.example", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Application/#website", + }, + }), + scopes: z.array(z.string()).openapi({ + description: + "The scopes for your application. This is the registered scopes string split on whitespace.", + example: ["read", "write", "push"], + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Application/#scopes", + }, + }), + redirect_uris: z + .array( + z.string().url().openapi({ + description: "URL or 'urn:ietf:wg:oauth:2.0:oob'", + }), + ) + .openapi({ + description: + "The registered redirection URI(s) for your application.", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Application/#redirect_uris", + }, + }), + redirect_uri: z.string().openapi({ + description: + "The registered redirection URI(s) for your application. May contain \\n characters when multiple redirect URIs are registered.", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Application/#redirect_uri", + }, + }), +}); + +export const CredentialApplication = Application.extend({ + client_id: z.string().openapi({ + description: "Client ID key, to be used for obtaining OAuth tokens", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/CredentialApplication/#client_id", + }, + }), + client_secret: z.string().openapi({ + description: "Client secret key, to be used for obtaining OAuth tokens", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/CredentialApplication/#client_secret", + }, + }), + client_secret_expires_at: z.string().openapi({ + description: + "When the client secret key will expire at, presently this always returns 0 indicating that OAuth Clients do not expire", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/CredentialApplication/#client_secret_expires_at", + }, + }), +}); diff --git a/classes/schemas/card.ts b/classes/schemas/card.ts new file mode 100644 index 00000000..fd65c403 --- /dev/null +++ b/classes/schemas/card.ts @@ -0,0 +1,151 @@ +import { z } from "@hono/zod-openapi"; +import { Account } from "./account.ts"; + +export const PreviewCardAuthor = z.object({ + name: z.string().openapi({ + description: "The original resource author’s name.", + example: "The Doubleclicks", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/PreviewCardAuthor/#name", + }, + }), + url: z + .string() + .url() + .openapi({ + description: "A link to the author of the original resource.", + example: "https://www.youtube.com/user/thedoubleclicks", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/PreviewCardAuthor/#url", + }, + }), + account: Account.nullable().openapi({ + description: "The fediverse account of the author.", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/PreviewCardAuthor/#account", + }, + }), +}); + +export const PreviewCard = z.object({ + url: z + .string() + .url() + .openapi({ + description: "Location of linked resource.", + example: "https://www.youtube.com/watch?v=OMv_EPMED8Y", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/PreviewCard/#url", + }, + }), + title: z + .string() + .min(1) + .openapi({ + description: "Title of linked resource.", + example: "♪ Brand New Friend (Christmas Song!)", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/PreviewCard/#title", + }, + }), + description: z.string().openapi({ + description: "Description of preview.", + example: "", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/PreviewCard/#description", + }, + }), + type: z.enum(["link", "photo", "video"]).openapi({ + description: "The type of the preview card.", + example: "video", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/PreviewCard/#type", + }, + }), + authors: z.array(PreviewCardAuthor).openapi({ + description: + "Fediverse account of the authors of the original resource.", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/PreviewCard/#authors", + }, + }), + provider_name: z.string().openapi({ + description: "The provider of the original resource.", + example: "YouTube", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/PreviewCard/#provider_name", + }, + }), + provider_url: z + .string() + .url() + .openapi({ + description: "A link to the provider of the original resource.", + example: "https://www.youtube.com/", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/PreviewCard/#provider_url", + }, + }), + html: z.string().openapi({ + description: "HTML to be used for generating the preview card.", + example: + '', + externalDocs: { + url: "https://docs.joinmastodon.org/entities/PreviewCard/#html", + }, + }), + width: z + .number() + .int() + .openapi({ + description: "Width of preview, in pixels.", + example: 480, + externalDocs: { + url: "https://docs.joinmastodon.org/entities/PreviewCard/#width", + }, + }), + height: z + .number() + .int() + .openapi({ + description: "Height of preview, in pixels.", + example: 270, + externalDocs: { + url: "https://docs.joinmastodon.org/entities/PreviewCard/#height", + }, + }), + image: z + .string() + .url() + .nullable() + .openapi({ + description: "Preview thumbnail.", + example: + "https://cdn.versia.social/preview_cards/images/014/179/145/original/9cf4b7cf5567b569.jpeg", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/PreviewCard/#image", + }, + }), + embed_url: z + .string() + .url() + .openapi({ + description: "Used for photo embeds, instead of custom html.", + example: + "https://live.staticflickr.com/65535/49088768431_6a4322b3bb_b.jpg", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/PreviewCard/#embed_url", + }, + }), + blurhash: z + .string() + .nullable() + .openapi({ + description: + "A hash computed by the BlurHash algorithm, for generating colorful preview thumbnails when media has not been downloaded yet.", + example: "UvK0HNkV,:s9xBR%njog0fo2W=WBS5ozofV@", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/PreviewCard/#blurhash", + }, + }), +}); diff --git a/classes/schemas/common.ts b/classes/schemas/common.ts new file mode 100644 index 00000000..ddb0cfcb --- /dev/null +++ b/classes/schemas/common.ts @@ -0,0 +1,3 @@ +import { z } from "@hono/zod-openapi"; + +export const Id = z.string().uuid(); diff --git a/classes/schemas/filters.ts b/classes/schemas/filters.ts new file mode 100644 index 00000000..3059e868 --- /dev/null +++ b/classes/schemas/filters.ts @@ -0,0 +1,129 @@ +import { z } from "@hono/zod-openapi"; +import { Id } from "./common.ts"; + +export const FilterStatus = z.object({ + id: Id.openapi({ + description: "The ID of the FilterStatus in the database.", + example: "3b19ed7c-0c4b-45e1-8c75-e21dfc8e86c3", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/FilterStatus/#id", + }, + }), + status_id: Id.openapi({ + description: "The ID of the Status that will be filtered.", + example: "4f941ac8-295c-4c2d-9300-82c162ac8028", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/FilterStatus/#status_id", + }, + }), +}); + +export const FilterKeyword = z.object({ + id: Id.openapi({ + description: "The ID of the FilterKeyword in the database.", + example: "ca921e60-5b96-4686-90f3-d7cc420d7391", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/FilterKeyword/#id", + }, + }), + keyword: z.string().openapi({ + description: "The phrase to be matched against.", + example: "badword", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/FilterKeyword/#keyword", + }, + }), + whole_word: z.boolean().openapi({ + description: + "Should the filter consider word boundaries? See implementation guidelines for filters.", + example: false, + externalDocs: { + url: "https://docs.joinmastodon.org/entities/FilterKeyword/#whole_word", + }, + }), +}); + +export const Filter = z.object({ + id: Id.openapi({ + description: "The ID of the Filter in the database.", + example: "6b8fa22f-b128-43c2-9a1f-3c0499ef3a51", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Filter/#id", + }, + }), + title: z.string().openapi({ + description: "A title given by the user to name the filter.", + example: "Test filter", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Filter/#title", + }, + }), + context: z + .array(z.enum(["home", "notifications", "public", "thread", "account"])) + .openapi({ + description: "The contexts in which the filter should be applied.", + example: ["home"], + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Filter/#context", + }, + }), + expires_at: z + .string() + .nullable() + .openapi({ + description: "When the filter should no longer be applied.", + example: "2026-09-20T17:27:39.296Z", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Filter/#expires_at", + }, + }), + filter_action: z.enum(["warn", "hide"]).openapi({ + description: + "The action to be taken when a status matches this filter.", + example: "warn", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Filter/#filter_action", + }, + }), + keywords: z.array(FilterKeyword).openapi({ + description: "The keywords grouped under this filter.", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Filter/#keywords", + }, + }), + statuses: z.array(FilterStatus).openapi({ + description: "The statuses grouped under this filter.", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Filter/#statuses", + }, + }), +}); + +export const FilterResult = z.object({ + filter: Filter.openapi({ + description: "The filter that was matched.", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/FilterResult/#filter", + }, + }), + keyword_matches: z + .array(z.string()) + .nullable() + .openapi({ + description: "The keyword within the filter that was matched.", + example: ["badword"], + externalDocs: { + url: "https://docs.joinmastodon.org/entities/FilterResult/#keyword_matches", + }, + }), + status_matches: z + .array(Id) + .nullable() + .openapi({ + description: "The status ID within the filter that was matched.", + example: ["3819515a-5ceb-4078-8524-c939e38dcf8f"], + externalDocs: { + url: "https://docs.joinmastodon.org/entities/FilterResult/#status_matches", + }, + }), +}); diff --git a/classes/schemas/status.ts b/classes/schemas/status.ts new file mode 100644 index 00000000..9f2d205a --- /dev/null +++ b/classes/schemas/status.ts @@ -0,0 +1,490 @@ +import { z } from "@hono/zod-openapi"; +import type { Status as ApiNote } from "@versia/client/types"; +import { Emoji, Media } from "@versia/kit/db"; +import ISO6391 from "iso-639-1"; +import { zBoolean } from "~/packages/config-manager/config.type.ts"; +import { Account } from "./account.ts"; +import { PreviewCard } from "./card.ts"; +import { Id } from "./common.ts"; +import { FilterResult } from "./filters.ts"; + +export const Mention = z + .object({ + id: Account.shape.id.openapi({ + description: "The account ID of the mentioned user.", + example: "b9dcb548-bd4d-42af-8b48-3693e6d298e6", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Status/#Mention-id", + }, + }), + username: Account.shape.username.openapi({ + description: "The username of the mentioned user.", + example: "lexi", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Status/#Mention-username", + }, + }), + url: Account.shape.url.openapi({ + description: "The location of the mentioned user’s profile.", + example: "https://beta.versia.social/@lexi", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Status/#Mention-url", + }, + }), + acct: Account.shape.acct.openapi({ + description: + "The webfinger acct: URI of the mentioned user. Equivalent to username for local users, or username@domain for remote users.", + example: "lexi@beta.versia.social", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Status/#Mention-acct", + }, + }), + }) + .openapi({ + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Status/#Mention", + }, + }); + +export const Tag = z + .object({ + name: z + .string() + .min(1) + .max(128) + .openapi({ + description: "The value of the hashtag after the # sign.", + example: "versia", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Status/#Tag-name", + }, + }), + url: z + .string() + .url() + .openapi({ + description: "A link to the hashtag on the instance.", + example: "https://beta.versia.social/tags/versia", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Status/#Tag-url", + }, + }), + }) + .openapi({ + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Status/#Tag", + }, + }); + +export const PollOption = z + .object({ + title: z.string().openapi({ + description: "The text value of the poll option.", + example: "yes", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Poll/#Option-title", + }, + }), + votes_count: z + .number() + .int() + .nonnegative() + .nullable() + .openapi({ + description: + "The total number of received votes for this option.", + example: 6, + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Poll/#Option-votes_count", + }, + }), + }) + .openapi({ + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Poll/#Option", + }, + }); + +export const Poll = z.object({ + id: Id.openapi({ + description: "ID of the poll in the database.", + example: "d87d230f-e401-4282-80c7-2044ab989662", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Poll/#id", + }, + }), + expires_at: z + .string() + .datetime() + .nullable() + .openapi({ + description: "When the poll ends.", + example: "2025-01-07T14:11:00.000Z", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Poll/#expires_at", + }, + }), + expired: zBoolean.openapi({ + description: "Is the poll currently expired?", + example: false, + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Poll/#expired", + }, + }), + multiple: zBoolean.openapi({ + description: "Does the poll allow multiple-choice answers?", + example: false, + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Poll/#multiple", + }, + }), + votes_count: z + .number() + .int() + .nonnegative() + .openapi({ + description: "How many votes have been received.", + example: 6, + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Poll/#votes_count", + }, + }), + voters_count: z + .number() + .int() + .nonnegative() + .nullable() + .openapi({ + description: + "How many unique accounts have voted on a multiple-choice poll.", + example: 3, + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Poll/#voters_count", + }, + }), + options: z.array(PollOption).openapi({ + description: "Possible answers for the poll.", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Poll/#options", + }, + }), + emojis: z.array(Emoji.schema).openapi({ + description: "Custom emoji to be used for rendering poll options.", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Poll/#emojis", + }, + }), + voted: zBoolean.optional().openapi({ + description: + "When called with a user token, has the authorized user voted?", + example: true, + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Poll/#voted", + }, + }), + own_votes: z + .array(z.number().int()) + .optional() + .openapi({ + description: + "When called with a user token, which options has the authorized user chosen? Contains an array of index values for options.", + example: [0], + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Poll/#own_votes", + }, + }), +}); + +export const Status = z.object({ + id: Id.openapi({ + description: "ID of the status in the database.", + example: "2de861d3-a3dd-42ee-ba38-2c7d3f4af588", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Status/#id", + }, + }), + uri: z + .string() + .url() + .openapi({ + description: "URI of the status used for federation.", + example: + "https://beta.versia.social/@lexi/2de861d3-a3dd-42ee-ba38-2c7d3f4af588", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Status/#uri", + }, + }), + url: z + .string() + .url() + .nullable() + .openapi({ + description: "A link to the status’s HTML representation.", + example: + "https://beta.versia.social/@lexi/2de861d3-a3dd-42ee-ba38-2c7d3f4af588", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Status/#url", + }, + }), + account: Account.openapi({ + description: "The account that authored this status.", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Status/#account", + }, + }), + in_reply_to_id: Id.nullable().openapi({ + description: "ID of the status being replied to.", + example: "c41c9fe9-919a-4d35-a921-d3e79a5c95f8", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Status/#in_reply_to_id", + }, + }), + in_reply_to_account_id: Account.shape.id.nullable().openapi({ + description: + "ID of the account that authored the status being replied to.", + example: "7b9b3ec6-1013-4cc6-8902-94ad00cf2ccc", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Status/#in_reply_to_account_id", + }, + }), + reblog: z + .lazy((): z.ZodType => Status as z.ZodType) + .nullable() + .openapi({ + description: "The status being reblogged.", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Status/#reblog", + }, + }), + content: z.string().openapi({ + description: "HTML-encoded status content.", + example: "

hello world

", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Status/#content", + }, + }), + created_at: z + .string() + .datetime() + .openapi({ + description: "The date when this status was created.", + example: "2025-01-07T14:11:00.000Z", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Status/#created_at", + }, + }), + edited_at: z + .string() + .datetime() + .nullable() + .openapi({ + description: "Timestamp of when the status was last edited.", + example: "2025-01-07T14:11:00.000Z", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Status/#edited_at", + }, + }), + emojis: z.array(Emoji.schema).openapi({ + description: "Custom emoji to be used when rendering status content.", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Status/#emojis", + }, + }), + replies_count: z + .number() + .int() + .nonnegative() + .openapi({ + description: "How many replies this status has received.", + example: 1, + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Status/#replies_count", + }, + }), + reblogs_count: z + .number() + .int() + .nonnegative() + .openapi({ + description: "How many boosts this status has received.", + example: 6, + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Status/#reblogs_count", + }, + }), + favourites_count: z + .number() + .int() + .nonnegative() + .openapi({ + description: "How many favourites this status has received.", + example: 11, + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Status/#favourites_count", + }, + }), + reblogged: zBoolean.optional().openapi({ + description: + "If the current token has an authorized user: Have you boosted this status?", + example: false, + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Status/#reblogged", + }, + }), + favourited: zBoolean.optional().openapi({ + description: + "If the current token has an authorized user: Have you favourited this status?", + example: true, + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Status/#favourited", + }, + }), + muted: zBoolean.optional().openapi({ + description: + "If the current token has an authorized user: Have you muted notifications for this status’s conversation?", + example: false, + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Status/#muted", + }, + }), + sensitive: zBoolean.openapi({ + description: "Is this status marked as sensitive content?", + example: false, + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Status/#sensitive", + }, + }), + spoiler_text: z.string().openapi({ + description: + "Subject or summary line, below which status content is collapsed until expanded.", + example: "lewd text", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Status/#spoiler_text", + }, + }), + visibility: z.enum(["public", "unlisted", "private", "direct"]).openapi({ + description: "Visibility of this status.", + example: "public", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Status/#visibility", + }, + }), + media_attachments: z.array(Media.schema).openapi({ + description: "Media that is attached to this status.", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Status/#media_attachments", + }, + }), + mentions: z.array(Mention).openapi({ + description: "Mentions of users within the status content.", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Status/#mentions", + }, + }), + tags: z.array(Tag).openapi({ + description: "Hashtags used within the status content.", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Status/#tags", + }, + }), + card: PreviewCard.nullable().openapi({ + description: "Preview card for links included within status content.", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Status/#card", + }, + }), + poll: Poll.nullable().openapi({ + description: "The poll attached to the status.", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Status/#poll", + }, + }), + application: z + .object({ + name: z.string().openapi({ + description: + "The name of the application that posted this status.", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Status/#application-name", + }, + }), + website: z + .string() + .url() + .nullable() + .openapi({ + description: + "The website associated with the application that posted this status.", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Status/#application-website", + }, + }), + }) + .optional() + .openapi({ + description: "The application used to post this status.", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Status/#application", + }, + }), + language: z + .enum(ISO6391.getAllCodes() as [string, ...string[]]) + .nullable() + .openapi({ + description: "Primary language of this status.", + example: "en", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Status/#language", + }, + }), + text: z + .string() + .nullable() + .openapi({ + description: + "Plain-text source of a status. Returned instead of content when status is deleted, so the user may redraft from the source text without the client having to reverse-engineer the original text from the HTML content.", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Status/#text", + }, + }), + pinned: zBoolean.optional().openapi({ + description: + "If the current token has an authorized user: Have you pinned this status? Only appears if the status is pinnable.", + example: true, + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Status/#pinned", + }, + }), + emoji_reactions: z.array( + z.object({ + count: z.number().int().nonnegative(), + me: zBoolean, + name: z.string(), + url: z.string().url().optional(), + static_url: z.string().url().optional(), + accounts: z.array(Account).optional(), + account_ids: z.array(z.string().uuid()).optional(), + }), + ), + quote: z + .lazy((): z.ZodType => Status as z.ZodType) + .nullable(), + bookmarked: zBoolean.optional().openapi({ + description: + "If the current token has an authorized user: Have you bookmarked this status?", + example: false, + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Status/#bookmarked", + }, + }), + filtered: z + .array(FilterResult) + .optional() + .openapi({ + description: + "If the current token has an authorized user: The filter and keywords that matched this status.", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Status/#filtered", + }, + }), +}); diff --git a/tests/api/statuses.test.ts b/tests/api/statuses.test.ts index 74f513ec..167b55e4 100644 --- a/tests/api/statuses.test.ts +++ b/tests/api/statuses.test.ts @@ -156,7 +156,7 @@ describe("API Tests", () => { expect(statusJson.created_at).toBeDefined(); expect(statusJson.account).toBeDefined(); expect(statusJson.reblog).toBeDefined(); - expect(statusJson.application).toBeDefined(); + expect(statusJson.application).toBeUndefined(); expect(statusJson.emojis).toBeDefined(); expect(statusJson.media_attachments).toBeDefined(); expect(statusJson.poll).toBeDefined();