From 2aeada49044387f49a0d8eaa253c695b62002283 Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Wed, 5 Feb 2025 21:49:39 +0100 Subject: [PATCH 01/11] feat(api): :label: Port Account OpenAPI schemas from Mastodon API docs --- api/api/auth/login/index.ts | 3 +- api/api/auth/redirect/index.ts | 3 +- api/api/auth/reset/index.ts | 3 +- api/api/v1/accounts/:id/block.ts | 3 +- api/api/v1/accounts/:id/follow.ts | 3 +- api/api/v1/accounts/:id/followers.ts | 8 +- api/api/v1/accounts/:id/following.ts | 8 +- api/api/v1/accounts/:id/index.ts | 7 +- api/api/v1/accounts/:id/mute.ts | 3 +- api/api/v1/accounts/:id/note.ts | 3 +- api/api/v1/accounts/:id/pin.ts | 3 +- api/api/v1/accounts/:id/refetch.ts | 7 +- .../v1/accounts/:id/remove_from_followers.ts | 3 +- .../v1/accounts/:id/roles/:role_id/index.ts | 3 +- api/api/v1/accounts/:id/roles/index.ts | 3 +- api/api/v1/accounts/:id/statuses.ts | 3 +- api/api/v1/accounts/:id/unblock.ts | 3 +- api/api/v1/accounts/:id/unfollow.ts | 3 +- api/api/v1/accounts/:id/unmute.ts | 3 +- api/api/v1/accounts/:id/unpin.ts | 3 +- .../v1/accounts/familiar_followers/index.ts | 6 +- api/api/v1/accounts/id/index.ts | 6 +- api/api/v1/accounts/index.ts | 3 +- api/api/v1/accounts/lookup/index.ts | 6 +- api/api/v1/accounts/relationships/index.ts | 3 +- api/api/v1/accounts/search/index.ts | 6 +- .../v1/accounts/update_credentials/index.ts | 178 +++----- .../v1/accounts/verify_credentials/index.ts | 4 +- api/api/v1/apps/index.ts | 3 +- api/api/v1/blocks/index.ts | 8 +- api/api/v1/emojis/:id/index.ts | 3 +- api/api/v1/emojis/index.ts | 3 +- api/api/v1/favourites/index.ts | 3 +- .../follow_requests/:account_id/authorize.ts | 3 +- .../v1/follow_requests/:account_id/reject.ts | 3 +- api/api/v1/follow_requests/index.ts | 8 +- api/api/v1/markers/index.ts | 3 +- api/api/v1/media/:id/index.ts | 3 +- api/api/v1/media/index.ts | 3 +- api/api/v1/mutes/index.ts | 8 +- api/api/v1/notifications/:id/dismiss.ts | 3 +- api/api/v1/notifications/:id/index.ts | 3 +- .../notifications/destroy_multiple/index.ts | 3 +- api/api/v1/notifications/index.ts | 3 +- api/api/v1/profile/avatar.ts | 4 +- api/api/v1/profile/header.ts | 4 +- api/api/v1/roles/:id/index.ts | 3 +- api/api/v1/statuses/:id/context.ts | 3 +- api/api/v1/statuses/:id/favourite.ts | 3 +- api/api/v1/statuses/:id/favourited_by.ts | 8 +- api/api/v1/statuses/:id/index.ts | 3 +- api/api/v1/statuses/:id/pin.ts | 3 +- api/api/v1/statuses/:id/reblog.ts | 3 +- api/api/v1/statuses/:id/reblogged_by.ts | 8 +- api/api/v1/statuses/:id/source.ts | 3 +- api/api/v1/statuses/:id/unfavourite.ts | 3 +- api/api/v1/statuses/:id/unpin.ts | 3 +- api/api/v1/statuses/:id/unreblog.ts | 3 +- api/api/v1/statuses/index.ts | 3 +- api/api/v1/timelines/home.ts | 3 +- api/api/v1/timelines/public.ts | 3 +- api/api/v2/filters/:id/index.ts | 3 +- api/api/v2/filters/index.ts | 3 +- api/api/v2/instance/index.ts | 3 +- api/api/v2/media/index.ts | 3 +- api/api/v2/search/index.ts | 6 +- api/inbox/index.ts | 3 +- api/media/:hash/:name/index.ts | 3 +- api/media/proxy/:id.ts | 3 +- api/messaging/index.ts | 3 +- api/objects/:id/index.ts | 3 +- api/users/:uuid/inbox/index.ts | 3 +- api/users/:uuid/index.ts | 3 +- api/users/:uuid/outbox/index.ts | 3 +- api/well-known/webfinger/index.ts | 3 +- classes/database/application.ts | 2 +- classes/database/emoji.ts | 2 +- classes/database/like.ts | 2 +- classes/database/media.ts | 2 +- classes/database/note.ts | 7 +- classes/database/notification.ts | 7 +- classes/database/reaction.ts | 2 +- classes/database/relationship.ts | 2 +- classes/database/role.ts | 2 +- classes/database/token.ts | 2 +- classes/database/user.ts | 83 +--- classes/schemas/account.ts | 403 ++++++++++++++++++ packages/config-manager/config.type.ts | 7 +- packages/plugin-kit/example.ts | 2 +- packages/plugin-kit/schema.ts | 2 +- plugins/openid/index.ts | 2 +- plugins/openid/routes/authorize.ts | 2 +- plugins/openid/routes/sso/index.ts | 2 +- types/api.ts | 2 +- utils/api.ts | 2 +- 95 files changed, 610 insertions(+), 388 deletions(-) create mode 100644 classes/schemas/account.ts diff --git a/api/api/auth/login/index.ts b/api/api/auth/login/index.ts index 93d59c89..5458724b 100644 --- a/api/api/auth/login/index.ts +++ b/api/api/auth/login/index.ts @@ -1,12 +1,11 @@ import { apiRoute } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { Application, User } from "@versia/kit/db"; import { Users } from "@versia/kit/tables"; import { eq, or } from "drizzle-orm"; import type { Context } from "hono"; import { setCookie } from "hono/cookie"; import { SignJWT } from "jose"; -import { z } from "zod"; import { ApiError } from "~/classes/errors/api-error"; import { config } from "~/packages/config-manager"; diff --git a/api/api/auth/redirect/index.ts b/api/api/auth/redirect/index.ts index c35033ec..07310ff4 100644 --- a/api/api/auth/redirect/index.ts +++ b/api/api/auth/redirect/index.ts @@ -1,9 +1,8 @@ import { apiRoute } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { db } from "@versia/kit/db"; import { Applications, Tokens } from "@versia/kit/tables"; import { and, eq } from "drizzle-orm"; -import { z } from "zod"; import { config } from "~/packages/config-manager"; const schemas = { diff --git a/api/api/auth/reset/index.ts b/api/api/auth/reset/index.ts index 9a04d4b0..dbc9a487 100644 --- a/api/api/auth/reset/index.ts +++ b/api/api/auth/reset/index.ts @@ -1,10 +1,9 @@ import { apiRoute } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { User } from "@versia/kit/db"; import { Users } from "@versia/kit/tables"; import { eq } from "drizzle-orm"; import type { Context } from "hono"; -import { z } from "zod"; import { config } from "~/packages/config-manager"; const schemas = { diff --git a/api/api/v1/accounts/:id/block.ts b/api/api/v1/accounts/:id/block.ts index 595e25a5..58b7f8fb 100644 --- a/api/api/v1/accounts/:id/block.ts +++ b/api/api/v1/accounts/:id/block.ts @@ -1,8 +1,7 @@ import { apiRoute, auth, withUserParam } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { Relationship } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; -import { z } from "zod"; const route = createRoute({ method: "post", diff --git a/api/api/v1/accounts/:id/follow.ts b/api/api/v1/accounts/:id/follow.ts index c6db8951..dc443f53 100644 --- a/api/api/v1/accounts/:id/follow.ts +++ b/api/api/v1/accounts/:id/follow.ts @@ -1,9 +1,8 @@ import { apiRoute, auth, withUserParam } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { Relationship } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import ISO6391 from "iso-639-1"; -import { z } from "zod"; const schemas = { param: z.object({ diff --git a/api/api/v1/accounts/:id/followers.ts b/api/api/v1/accounts/:id/followers.ts index 9eafe3cc..8a84dc45 100644 --- a/api/api/v1/accounts/:id/followers.ts +++ b/api/api/v1/accounts/:id/followers.ts @@ -1,9 +1,9 @@ import { apiRoute, auth, withUserParam } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; -import { Timeline, User } from "@versia/kit/db"; +import { createRoute, z } from "@hono/zod-openapi"; +import { Timeline } from "@versia/kit/db"; import { RolePermissions, Users } from "@versia/kit/tables"; import { and, gt, gte, lt, sql } from "drizzle-orm"; -import { z } from "zod"; +import { Account } from "~/classes/schemas/account"; const schemas = { query: z.object({ @@ -43,7 +43,7 @@ const route = createRoute({ description: "A list of accounts that follow the specified account", content: { "application/json": { - schema: z.array(User.schema), + schema: z.array(Account), }, }, headers: { diff --git a/api/api/v1/accounts/:id/following.ts b/api/api/v1/accounts/:id/following.ts index bcdcf4ae..409f5286 100644 --- a/api/api/v1/accounts/:id/following.ts +++ b/api/api/v1/accounts/:id/following.ts @@ -1,9 +1,9 @@ import { apiRoute, auth, withUserParam } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; -import { Timeline, User } from "@versia/kit/db"; +import { createRoute, z } from "@hono/zod-openapi"; +import { Timeline } from "@versia/kit/db"; import { RolePermissions, Users } from "@versia/kit/tables"; import { and, gt, gte, lt, sql } from "drizzle-orm"; -import { z } from "zod"; +import { Account } from "~/classes/schemas/account"; const schemas = { query: z.object({ @@ -44,7 +44,7 @@ const route = createRoute({ "A list of accounts that the specified account follows", content: { "application/json": { - schema: z.array(User.schema), + schema: z.array(Account), }, }, headers: { diff --git a/api/api/v1/accounts/:id/index.ts b/api/api/v1/accounts/:id/index.ts index bed7d8b2..1f66730f 100644 --- a/api/api/v1/accounts/:id/index.ts +++ b/api/api/v1/accounts/:id/index.ts @@ -1,8 +1,7 @@ import { apiRoute, auth, withUserParam } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; -import { User } from "@versia/kit/db"; +import { createRoute, z } from "@hono/zod-openapi"; import { RolePermissions } from "@versia/kit/tables"; -import { z } from "zod"; +import { Account } from "~/classes/schemas/account"; const route = createRoute({ method: "get", @@ -26,7 +25,7 @@ const route = createRoute({ description: "Account data", content: { "application/json": { - schema: User.schema, + schema: Account, }, }, }, diff --git a/api/api/v1/accounts/:id/mute.ts b/api/api/v1/accounts/:id/mute.ts index 41b393f2..45b49541 100644 --- a/api/api/v1/accounts/:id/mute.ts +++ b/api/api/v1/accounts/:id/mute.ts @@ -1,8 +1,7 @@ import { apiRoute, auth, withUserParam } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { Relationship } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; -import { z } from "zod"; const schemas = { param: z.object({ diff --git a/api/api/v1/accounts/:id/note.ts b/api/api/v1/accounts/:id/note.ts index 5533b91c..c0f04822 100644 --- a/api/api/v1/accounts/:id/note.ts +++ b/api/api/v1/accounts/:id/note.ts @@ -1,8 +1,7 @@ import { apiRoute, auth, withUserParam } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { Relationship } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; -import { z } from "zod"; const schemas = { param: z.object({ diff --git a/api/api/v1/accounts/:id/pin.ts b/api/api/v1/accounts/:id/pin.ts index d33f7407..72a13771 100644 --- a/api/api/v1/accounts/:id/pin.ts +++ b/api/api/v1/accounts/:id/pin.ts @@ -1,8 +1,7 @@ import { apiRoute, auth, withUserParam } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { Relationship } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; -import { z } from "zod"; const route = createRoute({ method: "post", diff --git a/api/api/v1/accounts/:id/refetch.ts b/api/api/v1/accounts/:id/refetch.ts index f5da0f71..54d6dcd5 100644 --- a/api/api/v1/accounts/:id/refetch.ts +++ b/api/api/v1/accounts/:id/refetch.ts @@ -1,9 +1,8 @@ import { apiRoute, auth, withUserParam } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; -import { User } from "@versia/kit/db"; +import { createRoute, z } from "@hono/zod-openapi"; import { RolePermissions } from "@versia/kit/tables"; -import { z } from "zod"; import { ApiError } from "~/classes/errors/api-error"; +import { Account } from "~/classes/schemas/account"; import { ErrorSchema } from "~/types/api"; const route = createRoute({ @@ -29,7 +28,7 @@ const route = createRoute({ description: "Updated user data", content: { "application/json": { - schema: User.schema, + schema: Account, }, }, }, diff --git a/api/api/v1/accounts/:id/remove_from_followers.ts b/api/api/v1/accounts/:id/remove_from_followers.ts index 2ee7a465..aee745c1 100644 --- a/api/api/v1/accounts/:id/remove_from_followers.ts +++ b/api/api/v1/accounts/:id/remove_from_followers.ts @@ -1,8 +1,7 @@ import { apiRoute, auth, withUserParam } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { Relationship } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; -import { z } from "zod"; const route = createRoute({ method: "post", diff --git a/api/api/v1/accounts/:id/roles/:role_id/index.ts b/api/api/v1/accounts/:id/roles/:role_id/index.ts index af3840e5..e4a72cdb 100644 --- a/api/api/v1/accounts/:id/roles/:role_id/index.ts +++ b/api/api/v1/accounts/:id/roles/:role_id/index.ts @@ -1,8 +1,7 @@ import { apiRoute, auth, withUserParam } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { Role } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; -import { z } from "zod"; import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; diff --git a/api/api/v1/accounts/:id/roles/index.ts b/api/api/v1/accounts/:id/roles/index.ts index fedf9adc..4adcba97 100644 --- a/api/api/v1/accounts/:id/roles/index.ts +++ b/api/api/v1/accounts/:id/roles/index.ts @@ -1,7 +1,6 @@ import { apiRoute, auth, withUserParam } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { Role } from "@versia/kit/db"; -import { z } from "zod"; const route = createRoute({ method: "get", diff --git a/api/api/v1/accounts/:id/statuses.ts b/api/api/v1/accounts/:id/statuses.ts index 230c01a0..1b5649b2 100644 --- a/api/api/v1/accounts/:id/statuses.ts +++ b/api/api/v1/accounts/:id/statuses.ts @@ -1,9 +1,8 @@ import { apiRoute, auth, withUserParam } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { Note, 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 { z } from "zod"; const schemas = { param: z.object({ diff --git a/api/api/v1/accounts/:id/unblock.ts b/api/api/v1/accounts/:id/unblock.ts index fe892b08..f480b575 100644 --- a/api/api/v1/accounts/:id/unblock.ts +++ b/api/api/v1/accounts/:id/unblock.ts @@ -1,8 +1,7 @@ import { apiRoute, auth, withUserParam } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { Relationship } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; -import { z } from "zod"; const route = createRoute({ method: "post", diff --git a/api/api/v1/accounts/:id/unfollow.ts b/api/api/v1/accounts/:id/unfollow.ts index 19cf3b57..d38ecef8 100644 --- a/api/api/v1/accounts/:id/unfollow.ts +++ b/api/api/v1/accounts/:id/unfollow.ts @@ -1,8 +1,7 @@ import { apiRoute, auth, withUserParam } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { Relationship } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; -import { z } from "zod"; import { ErrorSchema } from "~/types/api"; const route = createRoute({ diff --git a/api/api/v1/accounts/:id/unmute.ts b/api/api/v1/accounts/:id/unmute.ts index c9ebe777..4bbd9cec 100644 --- a/api/api/v1/accounts/:id/unmute.ts +++ b/api/api/v1/accounts/:id/unmute.ts @@ -1,8 +1,7 @@ import { apiRoute, auth, withUserParam } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { Relationship } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; -import { z } from "zod"; const route = createRoute({ method: "post", diff --git a/api/api/v1/accounts/:id/unpin.ts b/api/api/v1/accounts/:id/unpin.ts index ee4baf93..59aa469b 100644 --- a/api/api/v1/accounts/:id/unpin.ts +++ b/api/api/v1/accounts/:id/unpin.ts @@ -1,8 +1,7 @@ import { apiRoute, auth, withUserParam } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { Relationship } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; -import { z } from "zod"; const route = createRoute({ method: "post", diff --git a/api/api/v1/accounts/familiar_followers/index.ts b/api/api/v1/accounts/familiar_followers/index.ts index be9632f2..2fc5caa9 100644 --- a/api/api/v1/accounts/familiar_followers/index.ts +++ b/api/api/v1/accounts/familiar_followers/index.ts @@ -1,9 +1,9 @@ import { apiRoute, auth, qsQuery } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { User, db } from "@versia/kit/db"; import { RolePermissions, type Users } from "@versia/kit/tables"; import { type InferSelectModel, sql } from "drizzle-orm"; -import { z } from "zod"; +import { Account } from "~/classes/schemas/account"; const schemas = { query: z.object({ @@ -41,7 +41,7 @@ const route = createRoute({ schema: z.array( z.object({ id: z.string().uuid(), - accounts: z.array(User.schema), + accounts: z.array(Account), }), ), }, diff --git a/api/api/v1/accounts/id/index.ts b/api/api/v1/accounts/id/index.ts index a2c4a050..8f355c97 100644 --- a/api/api/v1/accounts/id/index.ts +++ b/api/api/v1/accounts/id/index.ts @@ -1,10 +1,10 @@ import { apiRoute, auth } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { User } from "@versia/kit/db"; import { RolePermissions, Users } from "@versia/kit/tables"; import { and, eq, isNull } from "drizzle-orm"; -import { z } from "zod"; import { ApiError } from "~/classes/errors/api-error"; +import { Account } from "~/classes/schemas/account"; import { ErrorSchema } from "~/types/api"; const schemas = { @@ -32,7 +32,7 @@ const route = createRoute({ description: "Account", content: { "application/json": { - schema: User.schema, + schema: Account, }, }, }, diff --git a/api/api/v1/accounts/index.ts b/api/api/v1/accounts/index.ts index a22918e9..b76e6fdc 100644 --- a/api/api/v1/accounts/index.ts +++ b/api/api/v1/accounts/index.ts @@ -1,11 +1,10 @@ import { apiRoute, auth, jsonOrForm } from "@/api"; import { tempmailDomains } from "@/tempmail"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { User } from "@versia/kit/db"; import { Users } from "@versia/kit/tables"; import { and, eq, isNull } from "drizzle-orm"; import ISO6391 from "iso-639-1"; -import { z } from "zod"; import { ApiError } from "~/classes/errors/api-error"; import { config } from "~/packages/config-manager"; diff --git a/api/api/v1/accounts/lookup/index.ts b/api/api/v1/accounts/lookup/index.ts index 8763d1e4..ae6b1f5b 100644 --- a/api/api/v1/accounts/lookup/index.ts +++ b/api/api/v1/accounts/lookup/index.ts @@ -1,10 +1,10 @@ import { apiRoute, auth, parseUserAddress } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { Instance, User } from "@versia/kit/db"; import { RolePermissions, Users } from "@versia/kit/tables"; import { and, eq, isNull } from "drizzle-orm"; -import { z } from "zod"; import { ApiError } from "~/classes/errors/api-error"; +import { Account } from "~/classes/schemas/account"; import { config } from "~/packages/config-manager"; import { ErrorSchema } from "~/types/api"; @@ -33,7 +33,7 @@ const route = createRoute({ description: "Account", content: { "application/json": { - schema: User.schema, + schema: Account, }, }, }, diff --git a/api/api/v1/accounts/relationships/index.ts b/api/api/v1/accounts/relationships/index.ts index 5b4b4bcd..f3a81c56 100644 --- a/api/api/v1/accounts/relationships/index.ts +++ b/api/api/v1/accounts/relationships/index.ts @@ -1,8 +1,7 @@ import { apiRoute, auth, qsQuery } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { Relationship } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; -import { z } from "zod"; const schemas = { query: z.object({ diff --git a/api/api/v1/accounts/search/index.ts b/api/api/v1/accounts/search/index.ts index 78efea7e..8fed1c62 100644 --- a/api/api/v1/accounts/search/index.ts +++ b/api/api/v1/accounts/search/index.ts @@ -1,11 +1,11 @@ import { apiRoute, auth, parseUserAddress, userAddressValidator } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { User } from "@versia/kit/db"; import { RolePermissions, Users } from "@versia/kit/tables"; import { eq, ilike, not, or, sql } from "drizzle-orm"; import stringComparison from "string-comparison"; -import { z } from "zod"; import { ApiError } from "~/classes/errors/api-error"; +import { Account } from "~/classes/schemas/account"; const schemas = { query: z.object({ @@ -43,7 +43,7 @@ export const route = createRoute({ description: "Accounts", content: { "application/json": { - schema: z.array(User.schema), + schema: z.array(Account), }, }, }, diff --git a/api/api/v1/accounts/update_credentials/index.ts b/api/api/v1/accounts/update_credentials/index.ts index f44ade7f..868886dc 100644 --- a/api/api/v1/accounts/update_credentials/index.ts +++ b/api/api/v1/accounts/update_credentials/index.ts @@ -1,134 +1,72 @@ import { apiRoute, auth, jsonOrForm } from "@/api"; import { mergeAndDeduplicate } from "@/lib"; import { sanitizedHtmlStrip } from "@/sanitization"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { Emoji, User } from "@versia/kit/db"; import { RolePermissions, Users } from "@versia/kit/tables"; import { and, eq, isNull } from "drizzle-orm"; -import ISO6391 from "iso-639-1"; -import { z } from "zod"; import { ApiError } from "~/classes/errors/api-error"; import { contentToHtml } from "~/classes/functions/status"; +import { Account } from "~/classes/schemas/account"; import { config } from "~/packages/config-manager/index.ts"; import { ErrorSchema } from "~/types/api"; const schemas = { - json: z.object({ - display_name: z - .string() - .min(3) - .trim() - .max(config.validation.max_displayname_size) - .refine( - (s) => - !config.filters.displayname.some((filter) => - s.match(filter), - ), - "Display name contains blocked words", - ) - .optional(), - username: z - .string() - .min(3) - .trim() - .max(config.validation.max_username_size) - .toLowerCase() - .regex( - /^[a-z0-9_-]+$/, - "Username can only contain letters, numbers, underscores and hyphens", - ) - .refine( - (s) => - !config.filters.username.some((filter) => s.match(filter)), - "Username contains blocked words", - ) - .optional(), - note: z - .string() - .min(0) - .max(config.validation.max_bio_size) - .trim() - .refine( - (s) => !config.filters.bio.some((filter) => s.match(filter)), - "Bio contains blocked words", - ) - .optional(), - avatar: z - .string() - .trim() - .min(1) - .max(2000) - .url() - .transform((a) => new URL(a)) - .or( - z - .instanceof(File) - .refine( - (v) => v.size <= config.validation.max_avatar_size, - `Avatar must be less than ${config.validation.max_avatar_size} bytes`, - ), - ) - .optional(), - header: z - .string() - .trim() - .min(1) - .max(2000) - .url() - .transform((v) => new URL(v)) - .or( - z - .instanceof(File) - .refine( - (v) => v.size <= config.validation.max_header_size, - `Header must be less than ${config.validation.max_header_size} bytes`, - ), - ) - .optional(), - locked: z - .string() - .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) - .optional(), - bot: z - .string() - .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) - .optional(), - discoverable: z - .string() - .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) - .optional(), - source: z - .object({ - privacy: z - .enum(["public", "unlisted", "private", "direct"]) - .optional(), - sensitive: z - .string() - .transform((v) => - ["true", "1", "on"].includes(v.toLowerCase()), - ) - .optional(), - language: z - .enum(ISO6391.getAllCodes() as [string, ...string[]]) - .optional(), - }) - .optional(), - fields_attributes: z - .array( - z.object({ - name: z - .string() - .trim() - .max(config.validation.max_field_name_size), - value: z - .string() - .trim() - .max(config.validation.max_field_value_size), - }), - ) - .max(config.validation.max_field_count) - .optional(), - }), + json: z + .object({ + display_name: Account.shape.display_name, + username: Account.shape.username, + note: Account.shape.note, + avatar: z + .string() + .trim() + .min(1) + .max(2000) + .url() + .transform((a) => new URL(a)) + .or( + z + .instanceof(File) + .refine( + (v) => v.size <= config.validation.max_avatar_size, + `Avatar must be less than ${config.validation.max_avatar_size} bytes`, + ), + ), + header: z + .string() + .trim() + .min(1) + .max(2000) + .url() + .transform((v) => new URL(v)) + .or( + z + .instanceof(File) + .refine( + (v) => v.size <= config.validation.max_header_size, + `Header must be less than ${config.validation.max_header_size} bytes`, + ), + ), + locked: Account.shape.locked, + bot: Account.shape.bot, + discoverable: Account.shape.discoverable, + source: z + .object({ + privacy: Account.shape.source.unwrap().shape.privacy, + sensitive: Account.shape.source.unwrap().shape.sensitive, + language: Account.shape.source.unwrap().shape.language, + }) + .partial(), + fields_attributes: z + .array( + z.object({ + name: Account.shape.fields.element.shape.name, + value: Account.shape.fields.element.shape.value, + }), + ) + .max(config.validation.max_field_count), + }) + .partial(), }; const route = createRoute({ @@ -158,7 +96,7 @@ const route = createRoute({ description: "Updated user", content: { "application/json": { - schema: User.schema, + schema: Account, }, }, }, diff --git a/api/api/v1/accounts/verify_credentials/index.ts b/api/api/v1/accounts/verify_credentials/index.ts index 6898807c..d14d76f6 100644 --- a/api/api/v1/accounts/verify_credentials/index.ts +++ b/api/api/v1/accounts/verify_credentials/index.ts @@ -1,6 +1,6 @@ import { apiRoute, auth } from "@/api"; import { createRoute } from "@hono/zod-openapi"; -import { User } from "@versia/kit/db"; +import { Account } from "~/classes/schemas/account"; const route = createRoute({ method: "get", @@ -18,7 +18,7 @@ const route = createRoute({ description: "Account", content: { "application/json": { - schema: User.schema, + schema: Account, }, }, }, diff --git a/api/api/v1/apps/index.ts b/api/api/v1/apps/index.ts index d9ba9bf0..69227bdc 100644 --- a/api/api/v1/apps/index.ts +++ b/api/api/v1/apps/index.ts @@ -1,8 +1,7 @@ import { apiRoute, jsonOrForm } from "@/api"; import { randomString } from "@/math"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { Application } from "@versia/kit/db"; -import { z } from "zod"; const schemas = { json: z.object({ diff --git a/api/api/v1/blocks/index.ts b/api/api/v1/blocks/index.ts index fe5c6666..a28a8484 100644 --- a/api/api/v1/blocks/index.ts +++ b/api/api/v1/blocks/index.ts @@ -1,9 +1,9 @@ import { apiRoute, auth } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; -import { Timeline, User } from "@versia/kit/db"; +import { createRoute, z } from "@hono/zod-openapi"; +import { Timeline } from "@versia/kit/db"; import { RolePermissions, Users } from "@versia/kit/tables"; import { and, gt, gte, lt, sql } from "drizzle-orm"; -import { z } from "zod"; +import { Account } from "~/classes/schemas/account"; const schemas = { query: z.object({ @@ -34,7 +34,7 @@ const route = createRoute({ description: "Blocks", content: { "application/json": { - schema: z.array(User.schema), + schema: z.array(Account), }, }, }, diff --git a/api/api/v1/emojis/:id/index.ts b/api/api/v1/emojis/:id/index.ts index a1a80a15..7f69fb12 100644 --- a/api/api/v1/emojis/:id/index.ts +++ b/api/api/v1/emojis/:id/index.ts @@ -1,9 +1,8 @@ import { apiRoute, auth, emojiValidator, jsonOrForm } from "@/api"; import { mimeLookup } from "@/content_types"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { Emoji } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; -import { z } from "zod"; import { ApiError } from "~/classes/errors/api-error"; import { config } from "~/packages/config-manager"; import { ErrorSchema } from "~/types/api"; diff --git a/api/api/v1/emojis/index.ts b/api/api/v1/emojis/index.ts index b198d65d..75b60d80 100644 --- a/api/api/v1/emojis/index.ts +++ b/api/api/v1/emojis/index.ts @@ -1,10 +1,9 @@ import { apiRoute, auth, emojiValidator, jsonOrForm } from "@/api"; import { mimeLookup } from "@/content_types"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { Emoji, Media } from "@versia/kit/db"; import { Emojis, RolePermissions } from "@versia/kit/tables"; import { and, eq, isNull, or } from "drizzle-orm"; -import { z } from "zod"; import { ApiError } from "~/classes/errors/api-error"; import { config } from "~/packages/config-manager"; import { ErrorSchema } from "~/types/api"; diff --git a/api/api/v1/favourites/index.ts b/api/api/v1/favourites/index.ts index 4d667acf..886dee91 100644 --- a/api/api/v1/favourites/index.ts +++ b/api/api/v1/favourites/index.ts @@ -1,9 +1,8 @@ import { apiRoute, auth } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { Note, Timeline } from "@versia/kit/db"; import { Notes, RolePermissions } from "@versia/kit/tables"; import { and, gt, gte, lt, sql } from "drizzle-orm"; -import { z } from "zod"; const schemas = { query: z.object({ diff --git a/api/api/v1/follow_requests/:account_id/authorize.ts b/api/api/v1/follow_requests/:account_id/authorize.ts index 371f5948..2cb28c6d 100644 --- a/api/api/v1/follow_requests/:account_id/authorize.ts +++ b/api/api/v1/follow_requests/:account_id/authorize.ts @@ -1,8 +1,7 @@ import { apiRoute, auth } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { Relationship, User } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; -import { z } from "zod"; import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; diff --git a/api/api/v1/follow_requests/:account_id/reject.ts b/api/api/v1/follow_requests/:account_id/reject.ts index 7341744b..e487cc88 100644 --- a/api/api/v1/follow_requests/:account_id/reject.ts +++ b/api/api/v1/follow_requests/:account_id/reject.ts @@ -1,8 +1,7 @@ import { apiRoute, auth } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { Relationship, User } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; -import { z } from "zod"; import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; diff --git a/api/api/v1/follow_requests/index.ts b/api/api/v1/follow_requests/index.ts index ffb638bb..d6f70b22 100644 --- a/api/api/v1/follow_requests/index.ts +++ b/api/api/v1/follow_requests/index.ts @@ -1,9 +1,9 @@ import { apiRoute, auth } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; -import { Timeline, User } from "@versia/kit/db"; +import { createRoute, z } from "@hono/zod-openapi"; +import { Timeline } from "@versia/kit/db"; import { RolePermissions, Users } from "@versia/kit/tables"; import { and, gt, gte, lt, sql } from "drizzle-orm"; -import { z } from "zod"; +import { Account } from "~/classes/schemas/account"; const schemas = { query: z.object({ @@ -32,7 +32,7 @@ const route = createRoute({ description: "Follow requests", content: { "application/json": { - schema: z.array(User.schema), + schema: z.array(Account), }, }, }, diff --git a/api/api/v1/markers/index.ts b/api/api/v1/markers/index.ts index 92f2b881..864095bb 100644 --- a/api/api/v1/markers/index.ts +++ b/api/api/v1/markers/index.ts @@ -1,10 +1,9 @@ import { apiRoute, auth } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import type { Marker as ApiMarker } from "@versia/client/types"; import { db } from "@versia/kit/db"; import { Markers, RolePermissions } from "@versia/kit/tables"; import { type SQL, and, eq } from "drizzle-orm"; -import { z } from "zod"; const schemas = { markers: z.object({ diff --git a/api/api/v1/media/:id/index.ts b/api/api/v1/media/:id/index.ts index a7ce3e91..abf63bda 100644 --- a/api/api/v1/media/:id/index.ts +++ b/api/api/v1/media/:id/index.ts @@ -1,8 +1,7 @@ import { apiRoute, auth } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { Media } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; -import { z } from "zod"; import { ApiError } from "~/classes/errors/api-error"; import { config } from "~/packages/config-manager/index.ts"; import { ErrorSchema } from "~/types/api"; diff --git a/api/api/v1/media/index.ts b/api/api/v1/media/index.ts index dfc8f130..25b8b349 100644 --- a/api/api/v1/media/index.ts +++ b/api/api/v1/media/index.ts @@ -1,8 +1,7 @@ import { apiRoute, auth } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { Media } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; -import { z } from "zod"; import { config } from "~/packages/config-manager/index.ts"; import { ErrorSchema } from "~/types/api"; diff --git a/api/api/v1/mutes/index.ts b/api/api/v1/mutes/index.ts index f7c2c50e..f771d996 100644 --- a/api/api/v1/mutes/index.ts +++ b/api/api/v1/mutes/index.ts @@ -1,9 +1,9 @@ import { apiRoute, auth } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; -import { Timeline, User } from "@versia/kit/db"; +import { createRoute, z } from "@hono/zod-openapi"; +import { Timeline } from "@versia/kit/db"; import { RolePermissions, Users } from "@versia/kit/tables"; import { and, gt, gte, lt, sql } from "drizzle-orm"; -import { z } from "zod"; +import { Account } from "~/classes/schemas/account"; const schemas = { query: z.object({ @@ -33,7 +33,7 @@ const route = createRoute({ description: "Muted users", content: { "application/json": { - schema: z.array(User.schema), + schema: z.array(Account), }, }, }, diff --git a/api/api/v1/notifications/:id/dismiss.ts b/api/api/v1/notifications/:id/dismiss.ts index 143f5a0c..86e3415d 100644 --- a/api/api/v1/notifications/:id/dismiss.ts +++ b/api/api/v1/notifications/:id/dismiss.ts @@ -1,8 +1,7 @@ import { apiRoute, auth } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { Notification } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; -import { z } from "zod"; import { ApiError } from "~/classes/errors/api-error"; const route = createRoute({ diff --git a/api/api/v1/notifications/:id/index.ts b/api/api/v1/notifications/:id/index.ts index c1a5cbe6..5a1166ba 100644 --- a/api/api/v1/notifications/:id/index.ts +++ b/api/api/v1/notifications/:id/index.ts @@ -1,8 +1,7 @@ import { apiRoute, auth } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { Notification } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; -import { z } from "zod"; import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; diff --git a/api/api/v1/notifications/destroy_multiple/index.ts b/api/api/v1/notifications/destroy_multiple/index.ts index 0ddc6ab4..17e1a322 100644 --- a/api/api/v1/notifications/destroy_multiple/index.ts +++ b/api/api/v1/notifications/destroy_multiple/index.ts @@ -1,7 +1,6 @@ import { apiRoute, auth } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { RolePermissions } from "@versia/kit/tables"; -import { z } from "zod"; const schemas = { query: z.object({ diff --git a/api/api/v1/notifications/index.ts b/api/api/v1/notifications/index.ts index cbf2b16f..b2bfd987 100644 --- a/api/api/v1/notifications/index.ts +++ b/api/api/v1/notifications/index.ts @@ -1,9 +1,8 @@ import { apiRoute, auth } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { Notification, Timeline } from "@versia/kit/db"; import { Notifications, RolePermissions } from "@versia/kit/tables"; import { and, eq, gt, gte, inArray, lt, not, sql } from "drizzle-orm"; -import { z } from "zod"; const schemas = { query: z diff --git a/api/api/v1/profile/avatar.ts b/api/api/v1/profile/avatar.ts index e7380bc8..9c02e985 100644 --- a/api/api/v1/profile/avatar.ts +++ b/api/api/v1/profile/avatar.ts @@ -1,7 +1,7 @@ import { apiRoute, auth } from "@/api"; import { createRoute } from "@hono/zod-openapi"; -import { User } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; +import { Account } from "~/classes/schemas/account"; const route = createRoute({ method: "delete", @@ -19,7 +19,7 @@ const route = createRoute({ description: "User", content: { "application/json": { - schema: User.schema, + schema: Account, }, }, }, diff --git a/api/api/v1/profile/header.ts b/api/api/v1/profile/header.ts index d6389d94..651f6ce4 100644 --- a/api/api/v1/profile/header.ts +++ b/api/api/v1/profile/header.ts @@ -1,7 +1,7 @@ import { apiRoute, auth } from "@/api"; import { createRoute } from "@hono/zod-openapi"; -import { User } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; +import { Account } from "~/classes/schemas/account"; const route = createRoute({ method: "delete", @@ -19,7 +19,7 @@ const route = createRoute({ description: "User", content: { "application/json": { - schema: User.schema, + schema: Account, }, }, }, diff --git a/api/api/v1/roles/:id/index.ts b/api/api/v1/roles/:id/index.ts index 4f0de903..521cc385 100644 --- a/api/api/v1/roles/:id/index.ts +++ b/api/api/v1/roles/:id/index.ts @@ -1,8 +1,7 @@ import { apiRoute, auth } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { Role } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; -import { z } from "zod"; import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; diff --git a/api/api/v1/statuses/:id/context.ts b/api/api/v1/statuses/:id/context.ts index b21e58e4..f2ed139b 100644 --- a/api/api/v1/statuses/:id/context.ts +++ b/api/api/v1/statuses/:id/context.ts @@ -1,8 +1,7 @@ import { apiRoute, auth, withNoteParam } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { Note } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; -import { z } from "zod"; import { ErrorSchema } from "~/types/api"; const route = createRoute({ diff --git a/api/api/v1/statuses/:id/favourite.ts b/api/api/v1/statuses/:id/favourite.ts index e89bd84e..106056f5 100644 --- a/api/api/v1/statuses/:id/favourite.ts +++ b/api/api/v1/statuses/:id/favourite.ts @@ -1,8 +1,7 @@ import { apiRoute, auth, withNoteParam } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { Note } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; -import { z } from "zod"; const route = createRoute({ method: "post", diff --git a/api/api/v1/statuses/:id/favourited_by.ts b/api/api/v1/statuses/:id/favourited_by.ts index 8d0d6269..3c1171fe 100644 --- a/api/api/v1/statuses/:id/favourited_by.ts +++ b/api/api/v1/statuses/:id/favourited_by.ts @@ -1,9 +1,9 @@ import { apiRoute, auth, withNoteParam } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; -import { Timeline, User } from "@versia/kit/db"; +import { createRoute, z } from "@hono/zod-openapi"; +import { Timeline } from "@versia/kit/db"; import { RolePermissions, Users } from "@versia/kit/tables"; import { and, gt, gte, lt, sql } from "drizzle-orm"; -import { z } from "zod"; +import { Account } from "~/classes/schemas/account"; const schemas = { query: z.object({ @@ -40,7 +40,7 @@ const route = createRoute({ description: "Users who favourited a status", content: { "application/json": { - schema: z.array(User.schema), + schema: z.array(Account), }, }, }, diff --git a/api/api/v1/statuses/:id/index.ts b/api/api/v1/statuses/:id/index.ts index 6bacd8ac..7643d72e 100644 --- a/api/api/v1/statuses/:id/index.ts +++ b/api/api/v1/statuses/:id/index.ts @@ -1,9 +1,8 @@ import { apiRoute, auth, jsonOrForm, withNoteParam } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { Media, Note } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import ISO6391 from "iso-639-1"; -import { z } from "zod"; import { ApiError } from "~/classes/errors/api-error"; import { config } from "~/packages/config-manager/index.ts"; import { ErrorSchema } from "~/types/api"; diff --git a/api/api/v1/statuses/:id/pin.ts b/api/api/v1/statuses/:id/pin.ts index 899412e6..2dd262d1 100644 --- a/api/api/v1/statuses/:id/pin.ts +++ b/api/api/v1/statuses/:id/pin.ts @@ -1,9 +1,8 @@ import { apiRoute, auth, withNoteParam } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { Note, db } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import type { SQL } from "drizzle-orm"; -import { z } from "zod"; import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; diff --git a/api/api/v1/statuses/:id/reblog.ts b/api/api/v1/statuses/:id/reblog.ts index 6651fae4..cfc5d046 100644 --- a/api/api/v1/statuses/:id/reblog.ts +++ b/api/api/v1/statuses/:id/reblog.ts @@ -1,9 +1,8 @@ import { apiRoute, auth, jsonOrForm, withNoteParam } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { Note } from "@versia/kit/db"; import { Notes, RolePermissions } from "@versia/kit/tables"; import { and, eq } from "drizzle-orm"; -import { z } from "zod"; import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; diff --git a/api/api/v1/statuses/:id/reblogged_by.ts b/api/api/v1/statuses/:id/reblogged_by.ts index 72392182..4e6113e8 100644 --- a/api/api/v1/statuses/:id/reblogged_by.ts +++ b/api/api/v1/statuses/:id/reblogged_by.ts @@ -1,9 +1,9 @@ import { apiRoute, auth, withNoteParam } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; -import { Timeline, User } from "@versia/kit/db"; +import { createRoute, z } from "@hono/zod-openapi"; +import { Timeline } from "@versia/kit/db"; import { RolePermissions, Users } from "@versia/kit/tables"; import { and, gt, gte, lt, sql } from "drizzle-orm"; -import { z } from "zod"; +import { Account } from "~/classes/schemas/account"; const schemas = { param: z.object({ @@ -40,7 +40,7 @@ const route = createRoute({ description: "Users who reblogged a status", content: { "application/json": { - schema: z.array(User.schema), + schema: z.array(Account), }, }, }, diff --git a/api/api/v1/statuses/:id/source.ts b/api/api/v1/statuses/:id/source.ts index a54a53da..a483dd11 100644 --- a/api/api/v1/statuses/:id/source.ts +++ b/api/api/v1/statuses/:id/source.ts @@ -1,8 +1,7 @@ import { apiRoute, auth, withNoteParam } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import type { StatusSource as ApiStatusSource } from "@versia/client/types"; import { RolePermissions } from "@versia/kit/tables"; -import { z } from "zod"; const route = createRoute({ method: "get", diff --git a/api/api/v1/statuses/:id/unfavourite.ts b/api/api/v1/statuses/:id/unfavourite.ts index 09c75dd1..944c3d09 100644 --- a/api/api/v1/statuses/:id/unfavourite.ts +++ b/api/api/v1/statuses/:id/unfavourite.ts @@ -1,8 +1,7 @@ import { apiRoute, auth, withNoteParam } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { Note } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; -import { z } from "zod"; const route = createRoute({ method: "post", diff --git a/api/api/v1/statuses/:id/unpin.ts b/api/api/v1/statuses/:id/unpin.ts index bf5561fa..9b47440c 100644 --- a/api/api/v1/statuses/:id/unpin.ts +++ b/api/api/v1/statuses/:id/unpin.ts @@ -1,8 +1,7 @@ import { apiRoute, auth, withNoteParam } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { Note } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; -import { z } from "zod"; import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; diff --git a/api/api/v1/statuses/:id/unreblog.ts b/api/api/v1/statuses/:id/unreblog.ts index e56f2082..98bf280e 100644 --- a/api/api/v1/statuses/:id/unreblog.ts +++ b/api/api/v1/statuses/:id/unreblog.ts @@ -1,9 +1,8 @@ import { apiRoute, auth, withNoteParam } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { Note } from "@versia/kit/db"; import { Notes, RolePermissions } from "@versia/kit/tables"; import { and, eq } from "drizzle-orm"; -import { z } from "zod"; import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; diff --git a/api/api/v1/statuses/index.ts b/api/api/v1/statuses/index.ts index 346c90d6..acf8b25e 100644 --- a/api/api/v1/statuses/index.ts +++ b/api/api/v1/statuses/index.ts @@ -1,9 +1,8 @@ import { apiRoute, auth, jsonOrForm } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { Media, Note } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import ISO6391 from "iso-639-1"; -import { z } from "zod"; import { ApiError } from "~/classes/errors/api-error"; import { config } from "~/packages/config-manager/index.ts"; import { ErrorSchema } from "~/types/api"; diff --git a/api/api/v1/timelines/home.ts b/api/api/v1/timelines/home.ts index 7a98ce38..cca26065 100644 --- a/api/api/v1/timelines/home.ts +++ b/api/api/v1/timelines/home.ts @@ -1,9 +1,8 @@ import { apiRoute, auth } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { Note, 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 { z } from "zod"; const schemas = { query: z.object({ diff --git a/api/api/v1/timelines/public.ts b/api/api/v1/timelines/public.ts index 9b239dfc..02170ee0 100644 --- a/api/api/v1/timelines/public.ts +++ b/api/api/v1/timelines/public.ts @@ -1,9 +1,8 @@ import { apiRoute, auth } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { Note, 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 { z } from "zod"; const schemas = { query: z.object({ diff --git a/api/api/v2/filters/:id/index.ts b/api/api/v2/filters/:id/index.ts index ed3d45b9..84242575 100644 --- a/api/api/v2/filters/:id/index.ts +++ b/api/api/v2/filters/:id/index.ts @@ -1,9 +1,8 @@ import { apiRoute, auth, jsonOrForm } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { db } from "@versia/kit/db"; import { FilterKeywords, Filters, RolePermissions } from "@versia/kit/tables"; import { type SQL, and, eq, inArray } from "drizzle-orm"; -import { z } from "zod"; import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; diff --git a/api/api/v2/filters/index.ts b/api/api/v2/filters/index.ts index 2bb49d24..2d609c5f 100644 --- a/api/api/v2/filters/index.ts +++ b/api/api/v2/filters/index.ts @@ -1,9 +1,8 @@ import { apiRoute, auth, jsonOrForm } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { db } from "@versia/kit/db"; import { FilterKeywords, Filters, RolePermissions } from "@versia/kit/tables"; import type { SQL } from "drizzle-orm"; -import { z } from "zod"; const schemas = { json: z.object({ diff --git a/api/api/v2/instance/index.ts b/api/api/v2/instance/index.ts index ce999dd6..e403733a 100644 --- a/api/api/v2/instance/index.ts +++ b/api/api/v2/instance/index.ts @@ -4,6 +4,7 @@ import { createRoute, z } from "@hono/zod-openapi"; import { User } from "@versia/kit/db"; import { Users } from "@versia/kit/tables"; import { and, eq, isNull } from "drizzle-orm"; +import { Account } from "~/classes/schemas/account"; import manifest from "~/package.json"; import { config } from "~/packages/config-manager"; @@ -83,7 +84,7 @@ const route = createRoute({ }), contact: z.object({ email: z.string().nullable(), - account: User.schema.nullable(), + account: Account.nullable(), }), rules: z.array( z.object({ diff --git a/api/api/v2/media/index.ts b/api/api/v2/media/index.ts index f2f8abf6..48b0f838 100644 --- a/api/api/v2/media/index.ts +++ b/api/api/v2/media/index.ts @@ -1,8 +1,7 @@ import { apiRoute, auth } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { Media } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; -import { z } from "zod"; import { config } from "~/packages/config-manager/index.ts"; import { ErrorSchema } from "~/types/api"; diff --git a/api/api/v2/search/index.ts b/api/api/v2/search/index.ts index a877e797..add9e5fa 100644 --- a/api/api/v2/search/index.ts +++ b/api/api/v2/search/index.ts @@ -1,10 +1,10 @@ import { apiRoute, auth, parseUserAddress, userAddressValidator } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { Note, User, db } from "@versia/kit/db"; import { Instances, Notes, RolePermissions, Users } from "@versia/kit/tables"; import { and, eq, inArray, isNull, sql } from "drizzle-orm"; -import { z } from "zod"; import { ApiError } from "~/classes/errors/api-error"; +import { Account } from "~/classes/schemas/account"; import { searchManager } from "~/classes/search/search-manager"; import { config } from "~/packages/config-manager"; import { ErrorSchema } from "~/types/api"; @@ -47,7 +47,7 @@ const route = createRoute({ content: { "application/json": { schema: z.object({ - accounts: z.array(User.schema), + accounts: z.array(Account), statuses: z.array(Note.schema), hashtags: z.array(z.string()), }), diff --git a/api/inbox/index.ts b/api/inbox/index.ts index 9b466fe1..5256a6bc 100644 --- a/api/inbox/index.ts +++ b/api/inbox/index.ts @@ -1,7 +1,6 @@ import { apiRoute } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import type { Entity } from "@versia/federation/types"; -import { z } from "zod"; import { InboxJobType, inboxQueue } from "~/classes/queues/inbox"; const schemas = { diff --git a/api/media/:hash/:name/index.ts b/api/media/:hash/:name/index.ts index ec8b9e53..4b1a83c3 100644 --- a/api/media/:hash/:name/index.ts +++ b/api/media/:hash/:name/index.ts @@ -1,6 +1,5 @@ import { apiRoute } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; -import { z } from "zod"; +import { createRoute, z } from "@hono/zod-openapi"; import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; diff --git a/api/media/proxy/:id.ts b/api/media/proxy/:id.ts index 5fe97b00..d2089f03 100644 --- a/api/media/proxy/:id.ts +++ b/api/media/proxy/:id.ts @@ -1,7 +1,6 @@ import { apiRoute } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import type { ContentfulStatusCode, StatusCode } from "hono/utils/http-status"; -import { z } from "zod"; import { ApiError } from "~/classes/errors/api-error"; import { config } from "~/packages/config-manager"; import { ErrorSchema } from "~/types/api"; diff --git a/api/messaging/index.ts b/api/messaging/index.ts index e1d2d284..abee0998 100644 --- a/api/messaging/index.ts +++ b/api/messaging/index.ts @@ -1,8 +1,7 @@ import { apiRoute } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { getLogger } from "@logtape/logtape"; import chalk from "chalk"; -import { z } from "zod"; const route = createRoute({ method: "post", diff --git a/api/objects/:id/index.ts b/api/objects/:id/index.ts index dd6b9030..ce480dab 100644 --- a/api/objects/:id/index.ts +++ b/api/objects/:id/index.ts @@ -1,5 +1,5 @@ import { apiRoute } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { LikeExtension as LikeSchema, Note as NoteSchema, @@ -7,7 +7,6 @@ import { import { Like, Note, User } from "@versia/kit/db"; import { Likes, Notes } from "@versia/kit/tables"; import { and, eq, inArray, sql } from "drizzle-orm"; -import { z } from "zod"; import { ApiError } from "~/classes/errors/api-error"; import { config } from "~/packages/config-manager"; import { ErrorSchema, type KnownEntity } from "~/types/api"; diff --git a/api/users/:uuid/inbox/index.ts b/api/users/:uuid/inbox/index.ts index fa003880..9303240d 100644 --- a/api/users/:uuid/inbox/index.ts +++ b/api/users/:uuid/inbox/index.ts @@ -1,7 +1,6 @@ import { apiRoute } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import type { Entity } from "@versia/federation/types"; -import { z } from "zod"; import { InboxJobType, inboxQueue } from "~/classes/queues/inbox"; import { ErrorSchema } from "~/types/api"; diff --git a/api/users/:uuid/index.ts b/api/users/:uuid/index.ts index ff416cb5..647cccd2 100644 --- a/api/users/:uuid/index.ts +++ b/api/users/:uuid/index.ts @@ -1,8 +1,7 @@ import { apiRoute } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { User as UserSchema } from "@versia/federation/schemas"; import { User } from "@versia/kit/db"; -import { z } from "zod"; import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; diff --git a/api/users/:uuid/outbox/index.ts b/api/users/:uuid/outbox/index.ts index 1eeb5cc6..0183e7a0 100644 --- a/api/users/:uuid/outbox/index.ts +++ b/api/users/:uuid/outbox/index.ts @@ -1,5 +1,5 @@ import { apiRoute } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { Collection as CollectionSchema, Note as NoteSchema, @@ -7,7 +7,6 @@ import { import { Note, User, db } from "@versia/kit/db"; import { Notes } from "@versia/kit/tables"; import { and, eq, inArray } from "drizzle-orm"; -import { z } from "zod"; import { ApiError } from "~/classes/errors/api-error"; import { config } from "~/packages/config-manager"; import { ErrorSchema } from "~/types/api"; diff --git a/api/well-known/webfinger/index.ts b/api/well-known/webfinger/index.ts index d7089c91..8cab7a37 100644 --- a/api/well-known/webfinger/index.ts +++ b/api/well-known/webfinger/index.ts @@ -4,14 +4,13 @@ import { parseUserAddress, webfingerMention, } from "@/api"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { getLogger } from "@logtape/logtape"; import type { ResponseError } from "@versia/federation"; import { WebFinger } from "@versia/federation/schemas"; import { User } from "@versia/kit/db"; import { Users } from "@versia/kit/tables"; import { and, eq, isNull } from "drizzle-orm"; -import { z } from "zod"; import { ApiError } from "~/classes/errors/api-error"; import { config } from "~/packages/config-manager"; import { ErrorSchema } from "~/types/api"; diff --git a/classes/database/application.ts b/classes/database/application.ts index 945254fa..276c4e25 100644 --- a/classes/database/application.ts +++ b/classes/database/application.ts @@ -1,3 +1,4 @@ +import { z } from "@hono/zod-openapi"; import type { Application as APIApplication } from "@versia/client/types"; import { Token, db } from "@versia/kit/db"; import { Applications } from "@versia/kit/tables"; @@ -9,7 +10,6 @@ import { eq, inArray, } from "drizzle-orm"; -import { z } from "zod"; import { BaseInterface } from "./base.ts"; type ApplicationType = InferSelectModel; diff --git a/classes/database/emoji.ts b/classes/database/emoji.ts index 08d103d7..4b15a58a 100644 --- a/classes/database/emoji.ts +++ b/classes/database/emoji.ts @@ -1,5 +1,6 @@ import { emojiValidatorWithColons, emojiValidatorWithIdentifiers } from "@/api"; import { proxyUrl } from "@/response"; +import { z } from "@hono/zod-openapi"; import type { Emoji as APIEmoji } from "@versia/client/types"; import type { CustomEmojiExtension } from "@versia/federation/types"; import { type Instance, Media, db } from "@versia/kit/db"; @@ -14,7 +15,6 @@ import { inArray, isNull, } from "drizzle-orm"; -import { z } from "zod"; import { BaseInterface } from "./base.ts"; type EmojiType = InferSelectModel & { diff --git a/classes/database/like.ts b/classes/database/like.ts index a6d5d948..629f26f5 100644 --- a/classes/database/like.ts +++ b/classes/database/like.ts @@ -1,3 +1,4 @@ +import { z } from "@hono/zod-openapi"; import { RolePermission } from "@versia/client/types"; import type { Delete, LikeExtension } from "@versia/federation/types"; import { db } from "@versia/kit/db"; @@ -16,7 +17,6 @@ import { eq, inArray, } from "drizzle-orm"; -import { z } from "zod"; import { config } from "~/packages/config-manager/index.ts"; import { BaseInterface } from "./base.ts"; import { Note } from "./note.ts"; diff --git a/classes/database/media.ts b/classes/database/media.ts index b5b44881..06a85903 100644 --- a/classes/database/media.ts +++ b/classes/database/media.ts @@ -1,6 +1,7 @@ import { join } from "node:path"; import { mimeLookup } from "@/content_types.ts"; import { proxyUrl } from "@/response"; +import { z } from "@hono/zod-openapi"; import type { Attachment as ApiAttachment } from "@versia/client/types"; import type { ContentFormat } from "@versia/federation/types"; import { db } from "@versia/kit/db"; @@ -15,7 +16,6 @@ import { inArray, } from "drizzle-orm"; import sharp from "sharp"; -import { z } from "zod"; import { MediaBackendType } from "~/packages/config-manager/config.type"; import { config } from "~/packages/config-manager/index.ts"; import { ApiError } from "../errors/api-error.ts"; diff --git a/classes/database/note.ts b/classes/database/note.ts index 23eb1cf1..70c5188d 100644 --- a/classes/database/note.ts +++ b/classes/database/note.ts @@ -3,6 +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 { getLogger } from "@logtape/logtape"; import type { Attachment as ApiAttachment, @@ -35,7 +36,6 @@ import { } from "drizzle-orm"; import { htmlToText } from "html-to-text"; import { createRegExp, exactly, global } from "magic-regexp"; -import { z } from "zod"; import { contentToHtml, findManyNotes, @@ -43,6 +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 { Application } from "./application.ts"; import { BaseInterface } from "./base.ts"; import { Emoji } from "./emoji.ts"; @@ -84,7 +85,7 @@ export class Note extends BaseInterface { id: z.string().uuid(), uri: z.string().url(), url: z.string().url(), - account: z.lazy(() => User.schema), + 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(), @@ -162,7 +163,7 @@ export class Note extends BaseInterface { name: z.string(), url: z.string().url().optional(), static_url: z.string().url().optional(), - accounts: z.array(z.lazy(() => User.schema)).optional(), + accounts: z.array(Account).optional(), account_ids: z.array(z.string().uuid()).optional(), }), ), diff --git a/classes/database/notification.ts b/classes/database/notification.ts index ebe3fae9..67effe85 100644 --- a/classes/database/notification.ts +++ b/classes/database/notification.ts @@ -1,3 +1,4 @@ +import { z } from "@hono/zod-openapi"; import type { Notification as APINotification } from "@versia/client/types"; import { Note, User, db } from "@versia/kit/db"; import { Notifications } from "@versia/kit/tables"; @@ -9,12 +10,12 @@ import { eq, inArray, } from "drizzle-orm"; -import { z } from "zod"; import { transformOutputToUserWithRelations, userExtrasTemplate, userRelations, } from "../functions/user.ts"; +import { Account } from "../schemas/account.ts"; import { BaseInterface } from "./base.ts"; export type NotificationType = InferSelectModel & { @@ -27,7 +28,7 @@ export class Notification extends BaseInterface< NotificationType > { public static schema: z.ZodType = z.object({ - account: z.lazy(() => User.schema).nullable(), + account: Account.nullable(), created_at: z.string(), id: z.string().uuid(), status: z.lazy(() => Note.schema).optional(), @@ -54,7 +55,7 @@ export class Notification extends BaseInterface< "group_favourite", "user_approved", ]), - target: z.lazy(() => User.schema).optional(), + target: Account.optional(), }); public async reload(): Promise { diff --git a/classes/database/reaction.ts b/classes/database/reaction.ts index 8aa2b86f..36992594 100644 --- a/classes/database/reaction.ts +++ b/classes/database/reaction.ts @@ -1,3 +1,4 @@ +import { z } from "@hono/zod-openapi"; import type { Emoji as APIEmoji } from "@versia/client/types"; import type { ReactionExtension } from "@versia/federation/types"; import { Emoji, Instance, Note, User, db } from "@versia/kit/db"; @@ -10,7 +11,6 @@ import { eq, inArray, } from "drizzle-orm"; -import { z } from "zod"; import { config } from "~/packages/config-manager/index.ts"; import { BaseInterface } from "./base.ts"; diff --git a/classes/database/relationship.ts b/classes/database/relationship.ts index 5e5e4b8d..1a431346 100644 --- a/classes/database/relationship.ts +++ b/classes/database/relationship.ts @@ -1,3 +1,4 @@ +import { z } from "@hono/zod-openapi"; import type { Relationship as APIRelationship } from "@versia/client/types"; import { db } from "@versia/kit/db"; import { Relationships } from "@versia/kit/tables"; @@ -10,7 +11,6 @@ import { eq, inArray, } from "drizzle-orm"; -import { z } from "zod"; import { BaseInterface } from "./base.ts"; import type { User } from "./user.ts"; diff --git a/classes/database/role.ts b/classes/database/role.ts index 0f439a6d..46fbb308 100644 --- a/classes/database/role.ts +++ b/classes/database/role.ts @@ -1,4 +1,5 @@ import { proxyUrl } from "@/response"; +import { z } from "@hono/zod-openapi"; import { type VersiaRole as APIRole, RolePermission, @@ -14,7 +15,6 @@ import { eq, inArray, } from "drizzle-orm"; -import { z } from "zod"; import { config } from "~/packages/config-manager/index.ts"; import { BaseInterface } from "./base.ts"; diff --git a/classes/database/token.ts b/classes/database/token.ts index 25e564c2..a4b433af 100644 --- a/classes/database/token.ts +++ b/classes/database/token.ts @@ -1,3 +1,4 @@ +import { z } from "@hono/zod-openapi"; import type { Token as ApiToken } from "@versia/client/types"; import { type Application, User, db } from "@versia/kit/db"; import { Tokens } from "@versia/kit/tables"; @@ -9,7 +10,6 @@ import { eq, inArray, } from "drizzle-orm"; -import { z } from "zod"; import { BaseInterface } from "./base.ts"; type TokenType = InferSelectModel & { diff --git a/classes/database/user.ts b/classes/database/user.ts index ec4d2557..2f6db3a2 100644 --- a/classes/database/user.ts +++ b/classes/database/user.ts @@ -4,10 +4,7 @@ import { randomString } from "@/math"; import { proxyUrl } from "@/response"; import { sentry } from "@/sentry"; import { getLogger } from "@logtape/logtape"; -import type { - Account as ApiAccount, - Mention as ApiMention, -} from "@versia/client/types"; +import type { Mention as ApiMention } from "@versia/client/types"; import { EntityValidator, FederationRequester, @@ -48,13 +45,14 @@ import { sql, } from "drizzle-orm"; import { htmlToText } from "html-to-text"; -import { z } from "zod"; +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"; import type { KnownEntity } from "~/types/api.ts"; import { DeliveryJobType, deliveryQueue } from "../queues/delivery.ts"; import { PushJobType, pushQueue } from "../queues/push.ts"; +import type { Account } from "../schemas/account.ts"; import { BaseInterface } from "./base.ts"; import { Emoji } from "./emoji.ts"; import { Instance } from "./instance.ts"; @@ -81,74 +79,6 @@ type UserWithRelations = UserWithInstance & { * Gives helpers to fetch users from database in a nice format */ export class User extends BaseInterface { - // @ts-expect-error Roles are weird - public static schema: z.ZodType = z.object({ - id: z.string(), - username: z.string(), - acct: z.string(), - display_name: z.string(), - locked: z.boolean(), - discoverable: z.boolean().optional(), - group: z.boolean().nullable(), - noindex: z.boolean().nullable(), - suspended: z.boolean().nullable(), - limited: z.boolean().nullable(), - created_at: z.string(), - followers_count: z.number(), - following_count: z.number(), - statuses_count: z.number(), - note: z.string(), - uri: z.string(), - url: z.string(), - avatar: z.string(), - avatar_static: z.string(), - header: z.string(), - header_static: z.string(), - emojis: z.array(Emoji.schema), - fields: z.array( - z.object({ - name: z.string(), - value: z.string(), - verified: z.boolean().optional(), - verified_at: z.string().nullable().optional(), - }), - ), - // FIXME: Use a proper type - moved: z.lazy(() => User.schema).nullable(), - bot: z.boolean().nullable(), - source: z - .object({ - privacy: z.string().nullable(), - sensitive: z.boolean().nullable(), - language: z.string().nullable(), - note: z.string(), - fields: z.array( - z.object({ - name: z.string(), - value: z.string(), - }), - ), - avatar: z - .object({ - content_type: z.string(), - }) - .optional(), - header: z - .object({ - content_type: z.string(), - }) - .optional(), - }) - .optional(), - role: z - .object({ - name: z.string(), - }) - .optional(), - roles: z.array(Role.schema), - mute_expires_at: z.string().optional(), - }); - public static $type: UserWithRelations; public avatar: Media | null; @@ -1175,7 +1105,7 @@ export class User extends BaseInterface { return { ok: true }; } - public toApi(isOwnAccount = false): ApiAccount { + public toApi(isOwnAccount = false): z.infer { const user = this.data; return { id: user.id, @@ -1199,6 +1129,7 @@ export class User extends BaseInterface { fields: user.fields.map((field) => ({ name: htmlToText(getBestContentType(field.key).content), value: getBestContentType(field.value).content, + verified_at: null, })), bot: user.isBot, source: isOwnAccount ? user.source : undefined, @@ -1213,8 +1144,8 @@ export class User extends BaseInterface { moved: null, noindex: false, suspended: false, - discoverable: undefined, - mute_expires_at: undefined, + discoverable: null, + mute_expires_at: null, roles: user.roles .map((role) => new Role(role)) .concat( diff --git a/classes/schemas/account.ts b/classes/schemas/account.ts new file mode 100644 index 00000000..cf5c0b6e --- /dev/null +++ b/classes/schemas/account.ts @@ -0,0 +1,403 @@ +import { z } from "@hono/zod-openapi"; +import type { Account as ApiAccount } from "@versia/client/types"; +import ISO6391 from "iso-639-1"; +import { config } from "~/packages/config-manager"; +import { zBoolean } from "~/packages/config-manager/config.type"; +import { Emoji } from "../database/emoji.ts"; +import { Role } from "../database/role.ts"; + +export const Field = z.object({ + name: z + .string() + .trim() + .min(1) + .max(config.validation.max_field_name_size) + .openapi({ + description: "The key of a given field’s key-value pair.", + example: "Freak level", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Account/#name", + }, + }), + value: z + .string() + .trim() + .min(1) + .max(config.validation.max_field_value_size) + .openapi({ + description: "The value associated with the name key.", + example: "

High

", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Account/#value", + }, + }), + verified_at: z + .string() + .datetime() + .nullable() + .openapi({ + description: + "Timestamp of when the server verified a URL value for a rel=“me” link.", + example: null, + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Account/#verified_at", + }, + }), +}); + +export const Source = z + .object({ + privacy: z.enum(["public", "unlisted", "private", "direct"]).openapi({ + description: + "The default post privacy to be used for new statuses.", + example: "unlisted", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Account/#source-privacy", + }, + }), + sensitive: zBoolean.openapi({ + description: + "Whether new statuses should be marked sensitive by default.", + example: false, + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Account/#source-sensitive", + }, + }), + language: z + .enum(ISO6391.getAllCodes() as [string, ...string[]]) + .openapi({ + description: "The default posting language for new statuses.", + example: "en", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Account/#source-language", + }, + }), + follow_requests_count: z + .number() + .int() + .optional() + .openapi({ + description: "The number of pending follow requests.", + example: 3, + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Account/#follow_requests_count", + }, + }), + note: z + .string() + .trim() + .min(0) + .max(config.validation.max_bio_size) + .refine( + (s) => !config.filters.bio.some((filter) => s.match(filter)), + "Bio contains blocked words", + ) + .openapi({ + description: "Profile bio, in plain-text instead of in HTML.", + example: "ermmm what the meow meow", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Account/#source-note", + }, + }), + fields: z.array(Field).max(config.validation.max_field_count).openapi({ + description: "Metadata about the account.", + }), + }) + .openapi({ + description: + "An extra attribute that contains source values to be used with API methods that verify credentials and update credentials.", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Account/#source", + }, + }); + +export const Account = z.object({ + id: z + .string() + .uuid() + .openapi({ + description: "The account id.", + example: "9e84842b-4db6-4a9b-969d-46ab408278da", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Account/#id", + }, + }), + username: z + .string() + .min(3) + .trim() + .max(config.validation.max_username_size) + .toLowerCase() + .regex( + /^[a-z0-9_-]+$/, + "Username can only contain letters, numbers, underscores and hyphens", + ) + .refine( + (s) => !config.filters.username.some((filter) => s.match(filter)), + "Username contains blocked words", + ) + .openapi({ + description: "The username of the account, not including domain.", + example: "lexi", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Account/#username", + }, + }), + acct: z.string().openapi({ + description: + "The Webfinger account URI. Equal to username for local users, or username@domain for remote users.", + example: "lexi@beta.versia.social", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Account/#acct", + }, + }), + url: z + .string() + .url() + .openapi({ + description: "The location of the user’s profile page.", + example: "https://beta.versia.social/@lexi", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Account/#url", + }, + }), + display_name: z + .string() + .min(3) + .trim() + .max(config.validation.max_displayname_size) + .refine( + (s) => + !config.filters.displayname.some((filter) => s.match(filter)), + "Display name contains blocked words", + ) + .openapi({ + description: "The profile’s display name.", + example: "Lexi :flower:", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Account/#display_name", + }, + }), + note: z + .string() + .min(0) + .max(config.validation.max_bio_size) + .trim() + .refine( + (s) => !config.filters.bio.some((filter) => s.match(filter)), + "Bio contains blocked words", + ) + .openapi({ + description: "The profile’s bio or description.", + example: "

ermmm what the meow meow

", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Account/#note", + }, + }), + avatar: z + .string() + .url() + .openapi({ + description: + "An image icon that is shown next to statuses and in the profile.", + example: + "https://cdn.versia.social/avatars/cff9aea0-0000-43fe-8b5e-e7c7ea69a488/lexi.webp", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Account/#avatar", + }, + }), + avatar_static: z + .string() + .url() + .openapi({ + description: + "A static version of the avatar. Equal to avatar if its value is a static image; different if avatar is an animated GIF.", + example: + "https://cdn.versia.social/avatars/cff9aea0-0000-43fe-8b5e-e7c7ea69a488/lexi.webp", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Account/#avatar_static", + }, + }), + header: z + .string() + .url() + .openapi({ + description: + "An image banner that is shown above the profile and in profile cards.", + example: + "https://cdn.versia.social/headers/a049f8e3-878c-4faa-ae4c-a6bcceddbd9d/femboy_2.webp", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Account/#header", + }, + }), + header_static: z + .string() + .url() + .openapi({ + description: + "A static version of the header. Equal to header if its value is a static image; different if header is an animated GIF.", + example: + "https://cdn.versia.social/headers/a049f8e3-878c-4faa-ae4c-a6bcceddbd9d/femboy_2.webp", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Account/#header_static", + }, + }), + locked: zBoolean.openapi({ + description: "Whether the account manually approves follow requests.", + example: false, + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Account/#locked", + }, + }), + fields: z + .array(Field) + .max(config.validation.max_field_count) + .openapi({ + description: + "Additional metadata attached to a profile as name-value pairs.", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Account/#fields", + }, + }), + emojis: z.array(Emoji.schema).openapi({ + description: + "Custom emoji entities to be used when rendering the profile.", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Account/#emojis", + }, + }), + bot: zBoolean.openapi({ + description: + "Indicates that the account may perform automated actions, may not be monitored, or identifies as a robot.", + example: false, + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Account/#bot", + }, + }), + group: z.literal(false).openapi({ + description: "Indicates that the account represents a Group actor.", + example: false, + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Account/#group", + }, + }), + discoverable: zBoolean.nullable().openapi({ + description: + "Whether the account has opted into discovery features such as the profile directory.", + example: true, + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Account/#discoverable", + }, + }), + noindex: zBoolean + .nullable() + .optional() + .openapi({ + description: + "Whether the local user has opted out of being indexed by search engines.", + example: false, + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Account/#noindex", + }, + }), + // FIXME: Use a proper type + moved: z + .lazy((): z.ZodType => Account as z.ZodType) + .nullable() + .optional() + .openapi({ + description: + "Indicates that the profile is currently inactive and that its user has moved to a new account.", + example: null, + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Account/#moved", + }, + }), + suspended: zBoolean.optional().openapi({ + description: + "An extra attribute returned only when an account is suspended.", + example: false, + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Account/#suspended", + }, + }), + limited: zBoolean.optional().openapi({ + description: + "An extra attribute returned only when an account is silenced. If true, indicates that the account should be hidden behind a warning screen.", + example: false, + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Account/#limited", + }, + }), + created_at: z + .string() + .datetime() + .openapi({ + description: "When the account was created.", + example: "2024-10-15T22:00:00.000Z", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Account/#created_at", + }, + }), + // TODO + last_status_at: z + .literal(null) + .openapi({ + description: "When the most recent status was posted.", + example: null, + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Account/#last_status_at", + }, + }) + .nullable(), + statuses_count: z + .number() + .min(0) + .int() + .openapi({ + description: "How many statuses are attached to this account.", + example: 42, + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Account/#statuses_count", + }, + }), + followers_count: z + .number() + .min(0) + .int() + .openapi({ + description: "The reported followers of this profile.", + example: 6, + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Account/#followers_count", + }, + }), + following_count: z + .number() + .min(0) + .int() + .openapi({ + description: "The reported follows of this profile.", + example: 23, + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Account/#following_count", + }, + }), + uri: z.string().url().openapi({ + description: + "The location of the user's Versia profile page, as opposed to the local representation.", + example: + "https://beta.versia.social/users/9e84842b-4db6-4a9b-969d-46ab408278da", + }), + source: Source.optional(), + role: z + .object({ + name: z.string(), + }) + .optional(), + roles: z.array(Role.schema), + mute_expires_at: z.string().datetime().nullable().openapi({ + description: "When a timed mute will expire, if applicable.", + example: "2025-03-01T14:00:00.000Z", + }), +}); diff --git a/packages/config-manager/config.type.ts b/packages/config-manager/config.type.ts index 0818f50c..63b84db4 100644 --- a/packages/config-manager/config.type.ts +++ b/packages/config-manager/config.type.ts @@ -1,10 +1,10 @@ +import { z } from "@hono/zod-openapi"; import { ADMIN_ROLES, DEFAULT_ROLES, RolePermissions, } from "@versia/kit/tables"; import { types as mimeTypes } from "mime-types"; -import { z } from "zod"; export enum MediaBackendType { Local = "local", @@ -26,6 +26,11 @@ const zUrl = z .transform((arg) => arg.replace(/\/$/, "")) .transform((arg) => new URL(arg)); +export const zBoolean = z + .string() + .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) + .or(z.boolean()); + export const configValidator = z .object({ database: z diff --git a/packages/plugin-kit/example.ts b/packages/plugin-kit/example.ts index 8ebb14cf..92c01b00 100644 --- a/packages/plugin-kit/example.ts +++ b/packages/plugin-kit/example.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { z } from "@hono/zod-openapi"; import { Hooks } from "./hooks.ts"; import { Plugin } from "./plugin.ts"; diff --git a/packages/plugin-kit/schema.ts b/packages/plugin-kit/schema.ts index 97288af3..7c04abad 100644 --- a/packages/plugin-kit/schema.ts +++ b/packages/plugin-kit/schema.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { z } from "@hono/zod-openapi"; export const manifestSchema = z.object({ // biome-ignore lint/style/useNamingConvention: diff --git a/plugins/openid/index.ts b/plugins/openid/index.ts index 94d4345e..7edcdaf1 100644 --- a/plugins/openid/index.ts +++ b/plugins/openid/index.ts @@ -1,10 +1,10 @@ +import { z } from "@hono/zod-openapi"; import { Hooks, Plugin } from "@versia/kit"; import { User } from "@versia/kit/db"; import chalk from "chalk"; import { getCookie } from "hono/cookie"; import { jwtVerify } from "jose"; import { JOSEError, JWTExpired } from "jose/errors"; -import { z } from "zod"; import { ApiError } from "~/classes/errors/api-error.ts"; import { RolePermissions } from "~/drizzle/schema.ts"; import authorizeRoute from "./routes/authorize.ts"; diff --git a/plugins/openid/routes/authorize.ts b/plugins/openid/routes/authorize.ts index 27dd22e3..b7bf489b 100644 --- a/plugins/openid/routes/authorize.ts +++ b/plugins/openid/routes/authorize.ts @@ -1,10 +1,10 @@ import { auth, jsonOrForm } from "@/api"; import { randomString } from "@/math"; +import { z } from "@hono/zod-openapi"; import { Application, Token, User } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import { type JWTPayload, SignJWT, jwtVerify } from "jose"; import { JOSEError } from "jose/errors"; -import { z } from "zod"; import { errorRedirect, errors } from "../errors.ts"; import type { PluginType } from "../index.ts"; diff --git a/plugins/openid/routes/sso/index.ts b/plugins/openid/routes/sso/index.ts index 67b7b004..544c6652 100644 --- a/plugins/openid/routes/sso/index.ts +++ b/plugins/openid/routes/sso/index.ts @@ -1,11 +1,11 @@ import { auth } from "@/api"; +import { z } from "@hono/zod-openapi"; import { Application, db } from "@versia/kit/db"; import { OpenIdLoginFlows, RolePermissions } from "@versia/kit/tables"; import { calculatePKCECodeChallenge, generateRandomCodeVerifier, } from "oauth4webapi"; -import { z } from "zod"; import { ErrorSchema } from "~/types/api"; import type { PluginType } from "../../index.ts"; import { oauthDiscoveryRequest, oauthRedirectUri } from "../../utils.ts"; diff --git a/types/api.ts b/types/api.ts index 068e1aec..e79cddc6 100644 --- a/types/api.ts +++ b/types/api.ts @@ -1,4 +1,5 @@ import type { OpenAPIHono } from "@hono/zod-openapi"; +import { z } from "@hono/zod-openapi"; import type { Delete, Follow, @@ -12,7 +13,6 @@ import type { } from "@versia/federation/types"; import type { SocketAddress } from "bun"; import type { RouterRoute } from "hono/types"; -import { z } from "zod"; import type { AuthData } from "~/classes/functions/user"; import type { Config } from "~/packages/config-manager"; diff --git a/utils/api.ts b/utils/api.ts index ecf3608f..b9c32805 100644 --- a/utils/api.ts +++ b/utils/api.ts @@ -1,4 +1,5 @@ import type { OpenAPIHono } from "@hono/zod-openapi"; +import { z } from "@hono/zod-openapi"; import { zValidator } from "@hono/zod-validator"; import { getLogger } from "@logtape/logtape"; import { Application, Note, Token, User, db } from "@versia/kit/db"; @@ -24,7 +25,6 @@ import { oneOrMore, } from "magic-regexp"; import { type ParsedQs, parse } from "qs"; -import { z } from "zod"; import { fromZodError } from "zod-validation-error"; import { ApiError } from "~/classes/errors/api-error"; import type { AuthData } from "~/classes/functions/user"; From 7c622730dc8bf12a26d1c2ca0859be15e2407ccc Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Wed, 5 Feb 2025 22:49:07 +0100 Subject: [PATCH 02/11] feat(api): :label: Port Status OpenAPI schemas from Mastodon API docs --- api/api/v1/accounts/:id/statuses.ts | 5 +- api/api/v1/apps/verify_credentials/index.ts | 12 +- api/api/v1/favourites/index.ts | 5 +- api/api/v1/statuses/:id/context.ts | 6 +- api/api/v1/statuses/:id/favourite.ts | 4 +- api/api/v1/statuses/:id/index.ts | 9 +- api/api/v1/statuses/:id/pin.ts | 5 +- api/api/v1/statuses/:id/reblog.ts | 3 +- api/api/v1/statuses/:id/unfavourite.ts | 4 +- api/api/v1/statuses/:id/unpin.ts | 4 +- api/api/v1/statuses/:id/unreblog.ts | 3 +- api/api/v1/statuses/index.ts | 3 +- api/api/v1/timelines/home.ts | 5 +- api/api/v1/timelines/public.ts | 5 +- api/api/v2/search/index.ts | 3 +- classes/database/application.ts | 34 +- classes/database/note.ts | 100 +--- classes/database/notification.ts | 3 +- classes/database/user.ts | 2 +- classes/schemas/account.ts | 7 +- classes/schemas/application.ts | 71 +++ classes/schemas/card.ts | 151 ++++++ classes/schemas/common.ts | 3 + classes/schemas/filters.ts | 129 ++++++ classes/schemas/status.ts | 490 ++++++++++++++++++++ tests/api/statuses.test.ts | 2 +- 26 files changed, 920 insertions(+), 148 deletions(-) create mode 100644 classes/schemas/application.ts create mode 100644 classes/schemas/card.ts create mode 100644 classes/schemas/common.ts create mode 100644 classes/schemas/filters.ts create mode 100644 classes/schemas/status.ts 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(); From 264e2fe8ac539d8b6032715f98138e0594a88e49 Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Tue, 11 Feb 2025 18:22:39 +0100 Subject: [PATCH 03/11] feat(api): :label: Port Role and CustomEmoji OpenAPI schemas --- api/api/v1/accounts/:id/roles/index.ts | 3 +- api/api/v1/custom_emojis/index.ts | 3 +- api/api/v1/emojis/:id/index.ts | 5 +- api/api/v1/emojis/index.ts | 3 +- api/api/v1/roles/:id/index.ts | 5 +- api/api/v1/roles/index.ts | 7 +- bun.lock | 162 ++++++++++++------------- classes/database/emoji.ts | 20 +-- classes/database/reaction.ts | 13 +- classes/database/role.ts | 15 +-- classes/database/user.ts | 28 ++--- classes/schemas/account.ts | 14 ++- classes/schemas/context.ts | 25 ++++ classes/schemas/emoji.ts | 82 +++++++++++++ classes/schemas/status.ts | 20 +-- classes/schemas/versia.ts | 77 ++++++++++++ drizzle/schema.ts | 14 +-- 17 files changed, 319 insertions(+), 177 deletions(-) create mode 100644 classes/schemas/context.ts create mode 100644 classes/schemas/emoji.ts create mode 100644 classes/schemas/versia.ts diff --git a/api/api/v1/accounts/:id/roles/index.ts b/api/api/v1/accounts/:id/roles/index.ts index 4adcba97..7e24d2af 100644 --- a/api/api/v1/accounts/:id/roles/index.ts +++ b/api/api/v1/accounts/:id/roles/index.ts @@ -1,6 +1,7 @@ import { apiRoute, auth, withUserParam } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { Role } from "@versia/kit/db"; +import { Role as RoleSchema } from "~/classes/schemas/versia.ts"; const route = createRoute({ method: "get", @@ -22,7 +23,7 @@ const route = createRoute({ description: "List of roles", content: { "application/json": { - schema: z.array(Role.schema), + schema: z.array(RoleSchema), }, }, }, diff --git a/api/api/v1/custom_emojis/index.ts b/api/api/v1/custom_emojis/index.ts index eb49c728..fd754c36 100644 --- a/api/api/v1/custom_emojis/index.ts +++ b/api/api/v1/custom_emojis/index.ts @@ -3,6 +3,7 @@ import { createRoute, z } from "@hono/zod-openapi"; import { Emoji } from "@versia/kit/db"; import { Emojis, RolePermissions } from "@versia/kit/tables"; import { and, eq, isNull, or } from "drizzle-orm"; +import { CustomEmoji } from "~/classes/schemas/emoji"; const route = createRoute({ method: "get", @@ -20,7 +21,7 @@ const route = createRoute({ description: "Emojis", content: { "application/json": { - schema: z.array(Emoji.schema), + schema: z.array(CustomEmoji), }, }, }, diff --git a/api/api/v1/emojis/:id/index.ts b/api/api/v1/emojis/:id/index.ts index 7f69fb12..f40c6a21 100644 --- a/api/api/v1/emojis/:id/index.ts +++ b/api/api/v1/emojis/:id/index.ts @@ -4,6 +4,7 @@ import { createRoute, z } from "@hono/zod-openapi"; import { Emoji } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import { ApiError } from "~/classes/errors/api-error"; +import { CustomEmoji } from "~/classes/schemas/emoji"; import { config } from "~/packages/config-manager"; import { ErrorSchema } from "~/types/api"; @@ -69,7 +70,7 @@ const routeGet = createRoute({ description: "Emoji", content: { "application/json": { - schema: Emoji.schema, + schema: CustomEmoji, }, }, }, @@ -120,7 +121,7 @@ const routePatch = createRoute({ description: "Emoji modified", content: { "application/json": { - schema: Emoji.schema, + schema: CustomEmoji, }, }, }, diff --git a/api/api/v1/emojis/index.ts b/api/api/v1/emojis/index.ts index 75b60d80..fe1bddd7 100644 --- a/api/api/v1/emojis/index.ts +++ b/api/api/v1/emojis/index.ts @@ -5,6 +5,7 @@ import { Emoji, Media } from "@versia/kit/db"; import { Emojis, RolePermissions } from "@versia/kit/tables"; import { and, eq, isNull, or } from "drizzle-orm"; import { ApiError } from "~/classes/errors/api-error"; +import { CustomEmoji } from "~/classes/schemas/emoji"; import { config } from "~/packages/config-manager"; import { ErrorSchema } from "~/types/api"; @@ -82,7 +83,7 @@ const route = createRoute({ description: "Uploaded emoji", content: { "application/json": { - schema: Emoji.schema, + schema: CustomEmoji, }, }, }, diff --git a/api/api/v1/roles/:id/index.ts b/api/api/v1/roles/:id/index.ts index 521cc385..9196f091 100644 --- a/api/api/v1/roles/:id/index.ts +++ b/api/api/v1/roles/:id/index.ts @@ -3,6 +3,7 @@ import { createRoute, z } from "@hono/zod-openapi"; import { Role } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import { ApiError } from "~/classes/errors/api-error"; +import { Role as RoleSchema } from "~/classes/schemas/versia.ts"; import { ErrorSchema } from "~/types/api"; const routeGet = createRoute({ @@ -24,7 +25,7 @@ const routeGet = createRoute({ description: "Role", content: { "application/json": { - schema: Role.schema, + schema: RoleSchema, }, }, }, @@ -57,7 +58,7 @@ const routePatch = createRoute({ body: { content: { "application/json": { - schema: Role.schema.partial(), + schema: RoleSchema.omit({ id: true }).partial(), }, }, }, diff --git a/api/api/v1/roles/index.ts b/api/api/v1/roles/index.ts index ede0e6db..adb6a839 100644 --- a/api/api/v1/roles/index.ts +++ b/api/api/v1/roles/index.ts @@ -2,6 +2,7 @@ import { apiRoute, auth } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { Role } from "@versia/kit/db"; import { ApiError } from "~/classes/errors/api-error"; +import { Role as RoleSchema } from "~/classes/schemas/versia.ts"; import { RolePermissions } from "~/drizzle/schema"; import { ErrorSchema } from "~/types/api"; @@ -19,7 +20,7 @@ const routeGet = createRoute({ description: "List of all roles", content: { "application/json": { - schema: z.array(Role.schema), + schema: z.array(RoleSchema), }, }, }, @@ -40,7 +41,7 @@ const routePost = createRoute({ body: { content: { "application/json": { - schema: Role.schema.omit({ id: true }), + schema: RoleSchema.omit({ id: true }), }, }, }, @@ -50,7 +51,7 @@ const routePost = createRoute({ description: "Role created", content: { "application/json": { - schema: Role.schema, + schema: RoleSchema, }, }, }, diff --git a/bun.lock b/bun.lock index 2d3ae81f..a387c94d 100644 --- a/bun.lock +++ b/bun.lock @@ -4,90 +4,90 @@ "": { "name": "versia-server", "dependencies": { - "@bull-board/api": "latest", - "@bull-board/hono": "latest", - "@hackmd/markdown-it-task-lists": "latest", - "@hono/prometheus": "latest", - "@hono/swagger-ui": "latest", - "@hono/zod-openapi": "latest", - "@hono/zod-validator": "latest", - "@inquirer/confirm": "latest", - "@inquirer/input": "latest", - "@json2csv/plainjs": "latest", - "@logtape/logtape": "npm:@jsr/logtape__logtape@latest", - "@oclif/core": "latest", - "@sentry/bun": "latest", - "@tufjs/canonical-json": "latest", - "@versia/client": "latest", - "@versia/federation": "latest", + "@bull-board/api": "^6.7.4", + "@bull-board/hono": "^6.7.4", + "@hackmd/markdown-it-task-lists": "^2.1.4", + "@hono/prometheus": "^1.0.1", + "@hono/swagger-ui": "^0.5.0", + "@hono/zod-openapi": "0.18.3", + "@hono/zod-validator": "^0.4.2", + "@inquirer/confirm": "^5.1.4", + "@inquirer/input": "^4.1.4", + "@json2csv/plainjs": "^7.0.6", + "@logtape/logtape": "npm:@jsr/logtape__logtape@0.9.0-dev.114+327c9473", + "@oclif/core": "^4.2.5", + "@sentry/bun": "^8.53.0", + "@tufjs/canonical-json": "^2.0.0", + "@versia/client": "^0.1.5", + "@versia/federation": "^0.1.4", "@versia/kit": "workspace:*", - "altcha-lib": "latest", - "blurhash": "latest", - "bullmq": "latest", - "c12": "latest", - "chalk": "latest", - "cli-progress": "latest", - "cli-table": "latest", - "confbox": "latest", - "drizzle-orm": "latest", - "extract-zip": "latest", - "hono": "latest", - "html-to-text": "latest", - "ioredis": "latest", - "ip-matching": "latest", - "iso-639-1": "latest", - "jose": "latest", - "linkify-html": "latest", - "linkify-string": "latest", - "linkifyjs": "latest", - "magic-regexp": "latest", - "markdown-it": "latest", - "markdown-it-anchor": "latest", - "markdown-it-container": "latest", - "markdown-it-toc-done-right": "latest", - "mime-types": "latest", - "mitata": "latest", - "oauth4webapi": "latest", - "ora": "latest", - "pg": "latest", - "prom-client": "latest", - "qs": "latest", - "sharp": "latest", - "sonic-channel": "latest", - "string-comparison": "latest", - "stringify-entities": "latest", - "strip-ansi": "latest", - "table": "latest", - "unzipit": "latest", - "uqr": "latest", - "web-push": "latest", - "xss": "latest", - "zod": "latest", - "zod-validation-error": "latest", + "altcha-lib": "^1.2.0", + "blurhash": "^2.0.5", + "bullmq": "^5.39.1", + "c12": "^2.0.1", + "chalk": "^5.4.1", + "cli-progress": "^3.12.0", + "cli-table": "^0.3.11", + "confbox": "^0.1.8", + "drizzle-orm": "^0.39.1", + "extract-zip": "^2.0.1", + "hono": "^4.6.20", + "html-to-text": "^9.0.5", + "ioredis": "^5.4.2", + "ip-matching": "^2.1.2", + "iso-639-1": "^3.1.4", + "jose": "^5.9.6", + "linkify-html": "^4.2.0", + "linkify-string": "^4.2.0", + "linkifyjs": "^4.2.0", + "magic-regexp": "^0.8.0", + "markdown-it": "^14.1.0", + "markdown-it-anchor": "^9.2.0", + "markdown-it-container": "^4.0.0", + "markdown-it-toc-done-right": "^4.2.0", + "mime-types": "^2.1.35", + "mitata": "^1.0.33", + "oauth4webapi": "^3.1.4", + "ora": "^8.1.1", + "pg": "^8.13.1", + "prom-client": "^15.1.3", + "qs": "^6.14.0", + "sharp": "^0.33.5", + "sonic-channel": "^1.3.1", + "string-comparison": "^1.3.0", + "stringify-entities": "^4.0.4", + "strip-ansi": "^7.1.0", + "table": "^6.9.0", + "unzipit": "^1.4.3", + "uqr": "^0.1.2", + "web-push": "^3.6.7", + "xss": "^1.0.15", + "zod": "^3.24.1", + "zod-validation-error": "^3.4.0", }, "devDependencies": { - "@biomejs/biome": "latest", - "@types/bun": "latest", - "@types/cli-progress": "latest", - "@types/cli-table": "latest", - "@types/html-to-text": "latest", - "@types/jsonld": "latest", - "@types/markdown-it-container": "latest", - "@types/mime-types": "latest", - "@types/pg": "latest", - "@types/qs": "latest", - "@types/web-push": "latest", - "drizzle-kit": "latest", - "markdown-it-image-figures": "latest", - "markdown-it-mathjax3": "latest", - "oclif": "latest", - "ts-prune": "latest", - "typescript": "latest", - "vitepress": "latest", - "vitepress-plugin-tabs": "latest", - "vitepress-sidebar": "latest", - "vue": "latest", - "zod-to-json-schema": "latest", + "@biomejs/biome": "^1.9.4", + "@types/bun": "^1.2.2", + "@types/cli-progress": "^3.11.6", + "@types/cli-table": "^0.3.4", + "@types/html-to-text": "^9.0.4", + "@types/jsonld": "^1.5.15", + "@types/markdown-it-container": "^2.0.10", + "@types/mime-types": "^2.1.4", + "@types/pg": "^8.11.11", + "@types/qs": "^6.9.18", + "@types/web-push": "^3.6.4", + "drizzle-kit": "^0.30.4", + "markdown-it-image-figures": "^2.1.1", + "markdown-it-mathjax3": "^4.3.2", + "oclif": "^4.17.21", + "ts-prune": "^0.10.3", + "typescript": "^5.7.3", + "vitepress": "^1.6.3", + "vitepress-plugin-tabs": "^0.5.0", + "vitepress-sidebar": "^1.30.2", + "vue": "^3.5.13", + "zod-to-json-schema": "^3.24.1", }, "peerDependencies": { "typescript": "^5.7.2", diff --git a/classes/database/emoji.ts b/classes/database/emoji.ts index 4b15a58a..41fe92d1 100644 --- a/classes/database/emoji.ts +++ b/classes/database/emoji.ts @@ -1,7 +1,6 @@ import { emojiValidatorWithColons, emojiValidatorWithIdentifiers } from "@/api"; import { proxyUrl } from "@/response"; -import { z } from "@hono/zod-openapi"; -import type { Emoji as APIEmoji } from "@versia/client/types"; +import type { z } from "@hono/zod-openapi"; import type { CustomEmojiExtension } from "@versia/federation/types"; import { type Instance, Media, db } from "@versia/kit/db"; import { Emojis, type Instances, type Medias } from "@versia/kit/tables"; @@ -15,6 +14,7 @@ import { inArray, isNull, } from "drizzle-orm"; +import type { CustomEmoji } from "../schemas/emoji.ts"; import { BaseInterface } from "./base.ts"; type EmojiType = InferSelectModel & { @@ -23,16 +23,6 @@ type EmojiType = InferSelectModel & { }; export class Emoji extends BaseInterface { - public static schema = z.object({ - id: z.string(), - shortcode: z.string(), - url: z.string(), - visible_in_picker: z.boolean(), - category: z.string().optional(), - static_url: z.string(), - global: z.boolean(), - }); - public static $type: EmojiType; public media: Media; @@ -184,18 +174,18 @@ export class Emoji extends BaseInterface { ); } - public toApi(): APIEmoji { + public toApi(): z.infer { return { id: this.id, shortcode: this.data.shortcode, static_url: proxyUrl(this.media.getUrl()).toString(), url: proxyUrl(this.media.getUrl()).toString(), visible_in_picker: this.data.visibleInPicker, - category: this.data.category ?? undefined, + category: this.data.category, global: this.data.ownerId === null, description: this.media.data.content[this.media.getPreferredMimeType()] - .description ?? undefined, + .description ?? null, }; } diff --git a/classes/database/reaction.ts b/classes/database/reaction.ts index 36992594..66226651 100644 --- a/classes/database/reaction.ts +++ b/classes/database/reaction.ts @@ -12,6 +12,7 @@ import { inArray, } from "drizzle-orm"; import { config } from "~/packages/config-manager/index.ts"; +import { CustomEmoji } from "../schemas/emoji.ts"; import { BaseInterface } from "./base.ts"; type ReactionType = InferSelectModel & { @@ -30,7 +31,7 @@ export class Reaction extends BaseInterface { public static schema: z.ZodType = z.object({ id: z.string().uuid(), author_id: z.string().uuid(), - emoji: z.lazy(() => Emoji.schema), + emoji: CustomEmoji, }); public static $type: ReactionType; @@ -169,16 +170,6 @@ export class Reaction extends BaseInterface { return this.data.id; } - public toApi(): APIReaction { - return { - id: this.data.id, - author_id: this.data.authorId, - emoji: this.hasCustomEmoji() - ? new Emoji(this.data.emoji as typeof Emoji.$type).toApi() - : this.data.emojiText || "", - }; - } - public getUri(baseUrl: URL): URL { return this.data.uri ? new URL(this.data.uri) diff --git a/classes/database/role.ts b/classes/database/role.ts index 46fbb308..f29d6cea 100644 --- a/classes/database/role.ts +++ b/classes/database/role.ts @@ -1,7 +1,6 @@ import { proxyUrl } from "@/response"; -import { z } from "@hono/zod-openapi"; -import { - type VersiaRole as APIRole, +import type { + VersiaRole as APIRole, RolePermission, } from "@versia/client/types"; import { db } from "@versia/kit/db"; @@ -21,16 +20,6 @@ import { BaseInterface } from "./base.ts"; type RoleType = InferSelectModel; export class Role extends BaseInterface { - public static schema = z.object({ - id: z.string().uuid(), - name: z.string().min(1).max(128), - permissions: z.array(z.nativeEnum(RolePermission)).default([]), - priority: z.number().int().default(0), - description: z.string().min(0).max(1024).optional(), - visible: z.boolean().default(true), - icon: z.string().url().optional(), - }); - public static $type: RoleType; public async reload(): Promise { diff --git a/classes/database/user.ts b/classes/database/user.ts index e52d7bca..36709e6a 100644 --- a/classes/database/user.ts +++ b/classes/database/user.ts @@ -1,5 +1,5 @@ import { idValidator } from "@/api"; -import { getBestContentType, urlToContentFormat } from "@/content_types"; +import { getBestContentType } from "@/content_types"; import { randomString } from "@/math"; import { proxyUrl } from "@/response"; import { sentry } from "@/sentry"; @@ -52,7 +52,7 @@ import { type Config, config } from "~/packages/config-manager"; import type { KnownEntity } from "~/types/api.ts"; import { DeliveryJobType, deliveryQueue } from "../queues/delivery.ts"; import { PushJobType, pushQueue } from "../queues/push.ts"; -import type { Account } from "../schemas/account.ts"; +import type { Account, Source } from "../schemas/account.ts"; import { BaseInterface } from "./base.ts"; import { Emoji } from "./emoji.ts"; import { Instance } from "./instance.ts"; @@ -686,12 +686,12 @@ export class User extends BaseInterface { note: getBestContentType(user.bio).content, publicKey: user.public_key.key, source: { - language: null, + language: "en", note: "", privacy: "public", sensitive: false, fields: [], - }, + } as z.infer, }; const userEmojis = @@ -888,12 +888,12 @@ export class User extends BaseInterface { privateKey: keys.private_key, updatedAt: new Date().toISOString(), source: { - language: null, + language: "en", note: "", privacy: "public", sensitive: false, fields: [], - }, + } as z.infer, }) .returning() )[0]; @@ -1107,6 +1107,7 @@ export class User extends BaseInterface { public toApi(isOwnAccount = false): z.infer { const user = this.data; + return { id: user.id, username: user.username, @@ -1177,6 +1178,8 @@ export class User extends BaseInterface { ) .map((r) => r.toApi()), group: false, + // TODO + last_status_at: null, }; } @@ -1235,17 +1238,8 @@ export class User extends BaseInterface { indexable: false, username: user.username, manually_approves_followers: this.data.isLocked, - avatar: - urlToContentFormat( - this.getAvatarUrl(config), - this.data.source.avatar?.content_type, - ) ?? undefined, - header: this.getHeaderUrl(config) - ? (urlToContentFormat( - this.getHeaderUrl(config) as URL, - this.data.source.header?.content_type, - ) ?? undefined) - : undefined, + avatar: this.avatar?.toVersia(), + header: this.header?.toVersia(), display_name: user.displayName, fields: user.fields, public_key: { diff --git a/classes/schemas/account.ts b/classes/schemas/account.ts index dbcb418b..fed37940 100644 --- a/classes/schemas/account.ts +++ b/classes/schemas/account.ts @@ -3,8 +3,8 @@ import type { Account as ApiAccount } from "@versia/client/types"; import ISO6391 from "iso-639-1"; import { config } from "~/packages/config-manager"; import { zBoolean } from "~/packages/config-manager/config.type"; -import { Emoji } from "../database/emoji.ts"; -import { Role } from "../database/role.ts"; +import { CustomEmoji } from "./emoji.ts"; +import { Role } from "./versia.ts"; export const Field = z.object({ name: z @@ -117,7 +117,7 @@ export const Account = z.object({ .string() .uuid() .openapi({ - description: "The account id.", + description: "The account ID in the database.", example: "9e84842b-4db6-4a9b-969d-46ab408278da", externalDocs: { url: "https://docs.joinmastodon.org/entities/Account/#id", @@ -260,7 +260,7 @@ export const Account = z.object({ url: "https://docs.joinmastodon.org/entities/Account/#fields", }, }), - emojis: z.array(Emoji.schema).openapi({ + emojis: z.array(CustomEmoji).openapi({ description: "Custom emoji entities to be used when rendering the profile.", externalDocs: { @@ -384,6 +384,7 @@ export const Account = z.object({ url: "https://docs.joinmastodon.org/entities/Account/#following_count", }, }), + /* Versia Server API extension */ uri: z.string().url().openapi({ description: "The location of the user's Versia profile page, as opposed to the local representation.", @@ -396,7 +397,10 @@ export const Account = z.object({ name: z.string(), }) .optional(), - roles: z.array(Role.schema), + /* Versia Server API extension */ + roles: z.array(Role).openapi({ + description: "Roles assigned to the account.", + }), mute_expires_at: z.string().datetime().nullable().openapi({ description: "When a timed mute will expire, if applicable.", example: "2025-03-01T14:00:00.000Z", diff --git a/classes/schemas/context.ts b/classes/schemas/context.ts new file mode 100644 index 00000000..e29a4b85 --- /dev/null +++ b/classes/schemas/context.ts @@ -0,0 +1,25 @@ +import { z } from "@hono/zod-openapi"; +import { Status } from "./status.ts"; + +export const Context = z + .object({ + ancestors: z.array(Status).openapi({ + description: "Parents in the thread.", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Context/#ancestors", + }, + }), + descendants: z.array(Status).openapi({ + description: "Children in the thread.", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Context/#descendants", + }, + }), + }) + .openapi({ + description: + "Represents the tree around a given status. Used for reconstructing threads of statuses.", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Context/#context", + }, + }); diff --git a/classes/schemas/emoji.ts b/classes/schemas/emoji.ts new file mode 100644 index 00000000..0d71c13b --- /dev/null +++ b/classes/schemas/emoji.ts @@ -0,0 +1,82 @@ +import { z } from "@hono/zod-openapi"; +import { zBoolean } from "~/packages/config-manager/config.type"; +import { Id } from "./common.ts"; + +export const CustomEmoji = z + .object({ + /* Versia Server API extension */ + id: Id.openapi({ + description: "ID of the custom emoji in the database.", + example: "af9ccd29-c689-477f-aa27-d7d95fd8fb05", + }), + shortcode: z.string().openapi({ + description: "The name of the custom emoji.", + example: "blobaww", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/CustomEmoji/#shortcode", + }, + }), + url: z + .string() + .url() + .openapi({ + description: "A link to the custom emoji.", + example: + "https://cdn.versia.social/emojis/images/000/011/739/original/blobaww.png", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/CustomEmoji/#url", + }, + }), + static_url: z + .string() + .url() + .openapi({ + description: "A link to a static copy of the custom emoji.", + example: + "https://cdn.versia.social/emojis/images/000/011/739/static/blobaww.png", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/CustomEmoji/#static_url", + }, + }), + visible_in_picker: z.boolean().openapi({ + description: + "Whether this Emoji should be visible in the picker or unlisted.", + example: true, + externalDocs: { + url: "https://docs.joinmastodon.org/entities/CustomEmoji/#visible_in_picker", + }, + }), + category: z + .string() + .nullable() + .openapi({ + description: "Used for sorting custom emoji in the picker.", + example: "Blobs", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/CustomEmoji/#category", + }, + }), + /* Versia Server API extension */ + global: zBoolean.openapi({ + description: "Whether this emoji is visible to all users.", + example: false, + }), + /* Versia Server API extension */ + description: z + .string() + .nullable() + .openapi({ + description: + "Emoji description for users using screen readers.", + example: "A cute blob.", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/CustomEmoji/#description", + }, + }), + }) + .openapi({ + description: "Represents a custom emoji.", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/CustomEmoji", + }, + }); diff --git a/classes/schemas/status.ts b/classes/schemas/status.ts index 9f2d205a..9a7bf0a1 100644 --- a/classes/schemas/status.ts +++ b/classes/schemas/status.ts @@ -1,12 +1,14 @@ import { z } from "@hono/zod-openapi"; import type { Status as ApiNote } from "@versia/client/types"; -import { Emoji, Media } from "@versia/kit/db"; +import { 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 { CustomEmoji } from "./emoji.ts"; import { FilterResult } from "./filters.ts"; +import { NoteReaction } from "./versia.ts"; export const Mention = z .object({ @@ -168,7 +170,7 @@ export const Poll = z.object({ url: "https://docs.joinmastodon.org/entities/Poll/#options", }, }), - emojis: z.array(Emoji.schema).openapi({ + emojis: z.array(CustomEmoji).openapi({ description: "Custom emoji to be used for rendering poll options.", externalDocs: { url: "https://docs.joinmastodon.org/entities/Poll/#emojis", @@ -284,7 +286,7 @@ export const Status = z.object({ url: "https://docs.joinmastodon.org/entities/Status/#edited_at", }, }), - emojis: z.array(Emoji.schema).openapi({ + emojis: z.array(CustomEmoji).openapi({ description: "Custom emoji to be used when rendering status content.", externalDocs: { url: "https://docs.joinmastodon.org/entities/Status/#emojis", @@ -455,17 +457,7 @@ export const Status = z.object({ 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(), - }), - ), + reactions: z.array(NoteReaction).openapi({}), quote: z .lazy((): z.ZodType => Status as z.ZodType) .nullable(), diff --git a/classes/schemas/versia.ts b/classes/schemas/versia.ts new file mode 100644 index 00000000..fd44db0c --- /dev/null +++ b/classes/schemas/versia.ts @@ -0,0 +1,77 @@ +import { z } from "@hono/zod-openapi"; +import { RolePermission } from "@versia/client/types"; +import { Id } from "./common.ts"; +import { config } from "~/packages/config-manager/index.ts"; + +/* Versia Server API extension */ +export const Role = z + .object({ + id: Id.openapi({}).openapi({ + description: "The role ID in the database.", + example: "b4a7e0f0-8f6a-479b-910b-9265c070d5bd", + }), + name: z.string().min(1).max(128).trim().openapi({ + description: "The name of the role.", + example: "Moderator", + }), + permissions: z + .array(z.nativeEnum(RolePermission)) + .transform( + // Deduplicate permissions + (permissions) => Array.from(new Set(permissions)), + ) + .default([]) + .openapi({ + description: "The permissions granted to the role.", + example: [ + RolePermission.ManageEmojis, + RolePermission.ManageAccounts, + ], + }), + priority: z.number().int().default(0).openapi({ + description: + "Role priority. Higher priority roles allow overriding lower priority roles.", + example: 100, + }), + description: z.string().min(0).max(1024).trim().optional().openapi({ + description: "Short role description.", + example: "Allows managing emojis and accounts.", + }), + visible: z.boolean().default(true).openapi({ + description: "Whether the role should be shown in the UI.", + }), + icon: z.string().url().optional().openapi({ + description: "URL to the role icon.", + example: "https://example.com/role-icon.png", + }), + }) + .openapi({ + description: + "Information about a role in the system, as well as its permissions.", + }); + +/* Versia Server API extension */ +export const NoteReaction = z + .object({ + name: z + .string() + .min(1) + .max(config.validation.max_emoji_shortcode_size) + .trim() + .openapi({ + description: "Custom Emoji shortcode or Unicode emoji.", + example: "blobfox_coffee", + }), + count: z.number().int().nonnegative().openapi({ + description: "Number of users who reacted with this emoji.", + example: 5, + }), + me: z.boolean().optional().openapi({ + description: + "Whether the current authenticated user reacted with this emoji.", + example: true, + }), + }) + .openapi({ + description: "Information about a reaction to a note.", + }); diff --git a/drizzle/schema.ts b/drizzle/schema.ts index aa3d47f7..8847905d 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -1,4 +1,4 @@ -import type { Source as ApiSource } from "@versia/client/types"; +import type { z } from "@hono/zod-openapi"; import type { ContentFormat, InstanceMetadata } from "@versia/federation/types"; import type { Challenge } from "altcha-lib/types"; import { relations, sql } from "drizzle-orm"; @@ -14,6 +14,7 @@ import { uniqueIndex, uuid, } from "drizzle-orm/pg-core"; +import type { Source } from "~/classes/schemas/account"; // biome-ignore lint/nursery/useExplicitType: Type is too complex const createdAt = () => @@ -553,16 +554,7 @@ export const Users = pgTable( inbox: string; outbox: string; }> | null>(), - source: jsonb("source").notNull().$type< - ApiSource & { - avatar?: { - content_type: string; - }; - header?: { - content_type: string; - }; - } - >(), + source: jsonb("source").notNull().$type>(), avatarId: uuid("avatarId").references(() => Medias.id, { onDelete: "set null", onUpdate: "cascade", From fda11672342b73b8d1acaa96995e57759c318701 Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Wed, 12 Feb 2025 23:04:44 +0100 Subject: [PATCH 04/11] feat(api): :label: Finish porting full Mastodon API to OpenAPI --- api/api/v1/accounts/:id/follow.ts | 6 +- classes/schemas/account-warning.ts | 57 ++++ classes/schemas/account.ts | 18 +- classes/schemas/appeal.ts | 21 ++ classes/schemas/attachment.ts | 70 +++++ classes/schemas/card.ts | 232 +++++++------- classes/schemas/common.ts | 3 + classes/schemas/extended-description.ts | 31 ++ classes/schemas/familiar-followers.ts | 26 ++ classes/schemas/filters.ts | 256 +++++++++------- classes/schemas/instance-v1.ts | 141 +++++++++ classes/schemas/instance.ts | 382 ++++++++++++++++++++++++ classes/schemas/marker.ts | 26 ++ classes/schemas/notification.ts | 70 +++++ classes/schemas/poll.ts | 132 ++++++++ classes/schemas/preferences.ts | 50 ++++ classes/schemas/privacy-policy.ts | 29 ++ classes/schemas/relationship.ts | 74 +++++ classes/schemas/report.ts | 59 ++++ classes/schemas/rule.ts | 23 ++ classes/schemas/search.ts | 23 ++ classes/schemas/status.ts | 171 +---------- classes/schemas/tag.ts | 31 ++ classes/schemas/token.ts | 29 ++ classes/schemas/versia.ts | 32 +- 25 files changed, 1597 insertions(+), 395 deletions(-) create mode 100644 classes/schemas/account-warning.ts create mode 100644 classes/schemas/appeal.ts create mode 100644 classes/schemas/attachment.ts create mode 100644 classes/schemas/extended-description.ts create mode 100644 classes/schemas/familiar-followers.ts create mode 100644 classes/schemas/instance-v1.ts create mode 100644 classes/schemas/instance.ts create mode 100644 classes/schemas/marker.ts create mode 100644 classes/schemas/notification.ts create mode 100644 classes/schemas/poll.ts create mode 100644 classes/schemas/preferences.ts create mode 100644 classes/schemas/privacy-policy.ts create mode 100644 classes/schemas/relationship.ts create mode 100644 classes/schemas/report.ts create mode 100644 classes/schemas/rule.ts create mode 100644 classes/schemas/search.ts create mode 100644 classes/schemas/tag.ts create mode 100644 classes/schemas/token.ts diff --git a/api/api/v1/accounts/:id/follow.ts b/api/api/v1/accounts/:id/follow.ts index dc443f53..3b3edc07 100644 --- a/api/api/v1/accounts/:id/follow.ts +++ b/api/api/v1/accounts/:id/follow.ts @@ -2,7 +2,7 @@ import { apiRoute, auth, withUserParam } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { Relationship } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; -import ISO6391 from "iso-639-1"; +import { iso631 } from "~/classes/schemas/common"; const schemas = { param: z.object({ @@ -12,9 +12,7 @@ const schemas = { .object({ reblogs: z.coerce.boolean().optional(), notify: z.coerce.boolean().optional(), - languages: z - .array(z.enum(ISO6391.getAllCodes() as [string, ...string[]])) - .optional(), + languages: z.array(iso631).optional(), }) .optional() .default({ reblogs: true, notify: false, languages: [] }), diff --git a/classes/schemas/account-warning.ts b/classes/schemas/account-warning.ts new file mode 100644 index 00000000..4bdd730d --- /dev/null +++ b/classes/schemas/account-warning.ts @@ -0,0 +1,57 @@ +import { z } from "@hono/zod-openapi"; +import { Account } from "./account.ts"; +import { Appeal } from "./appeal.ts"; +import { Id } from "./common.ts"; + +export const AccountWarning = z + .object({ + id: Id.openapi({ + description: "The ID of the account warning in the database.", + example: "0968680e-fd64-4525-b818-6e1c46fbdb28", + }), + action: z + .enum([ + "none", + "disable", + "mark_statuses_as_sensitive", + "delete_statuses", + "sensitive", + "silence", + "suspend", + ]) + .openapi({ + description: + "Action taken against the account. 'none' = No action was taken, this is a simple warning; 'disable' = The account has been disabled; 'mark_statuses_as_sensitive' = Specific posts from the target account have been marked as sensitive; 'delete_statuses' = Specific statuses from the target account have been deleted; 'sensitive' = All posts from the target account are marked as sensitive; 'silence' = The target account has been limited; 'suspend' = The target account has been suspended.", + example: "none", + }), + text: z.string().openapi({ + description: "Message from the moderator to the target account.", + example: "Please adhere to our community guidelines.", + }), + status_ids: z + .array(Id) + .nullable() + .openapi({ + description: + "List of status IDs that are relevant to the warning. When action is mark_statuses_as_sensitive or delete_statuses, those are the affected statuses.", + example: ["5ee59275-c308-4173-bb1f-58646204579b"], + }), + target_account: Account.openapi({ + description: + "Account against which a moderation decision has been taken.", + }), + appeal: Appeal.nullable().openapi({ + description: "Appeal submitted by the target account, if any.", + example: null, + }), + created_at: z.string().datetime().openapi({ + description: "When the event took place.", + example: "2025-01-04T14:11:00Z", + }), + }) + .openapi({ + description: "Moderation warning against a particular account.", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/AccountWarning", + }, + }); diff --git a/classes/schemas/account.ts b/classes/schemas/account.ts index fed37940..818a2aa8 100644 --- a/classes/schemas/account.ts +++ b/classes/schemas/account.ts @@ -1,8 +1,8 @@ import { z } from "@hono/zod-openapi"; import type { Account as ApiAccount } from "@versia/client/types"; -import ISO6391 from "iso-639-1"; import { config } from "~/packages/config-manager"; import { zBoolean } from "~/packages/config-manager/config.type"; +import { iso631 } from "./common.ts"; import { CustomEmoji } from "./emoji.ts"; import { Role } from "./versia.ts"; @@ -63,15 +63,13 @@ export const Source = z url: "https://docs.joinmastodon.org/entities/Account/#source-sensitive", }, }), - language: z - .enum(ISO6391.getAllCodes() as [string, ...string[]]) - .openapi({ - description: "The default posting language for new statuses.", - example: "en", - externalDocs: { - url: "https://docs.joinmastodon.org/entities/Account/#source-language", - }, - }), + language: iso631.openapi({ + description: "The default posting language for new statuses.", + example: "en", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Account/#source-language", + }, + }), follow_requests_count: z .number() .int() diff --git a/classes/schemas/appeal.ts b/classes/schemas/appeal.ts new file mode 100644 index 00000000..8c10bd6a --- /dev/null +++ b/classes/schemas/appeal.ts @@ -0,0 +1,21 @@ +import { z } from "@hono/zod-openapi"; + +export const Appeal = z + .object({ + text: z.string().openapi({ + description: + "Text of the appeal from the moderated account to the moderators.", + example: "I believe this action was taken in error.", + }), + state: z.enum(["approved", "rejected", "pending"]).openapi({ + description: + "State of the appeal. 'approved' = The appeal has been approved by a moderator, 'rejected' = The appeal has been rejected by a moderator, 'pending' = The appeal has been submitted, but neither approved nor rejected yet.", + example: "pending", + }), + }) + .openapi({ + description: "Appeal against a moderation action.", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Appeal", + }, + }); diff --git a/classes/schemas/attachment.ts b/classes/schemas/attachment.ts new file mode 100644 index 00000000..9200632c --- /dev/null +++ b/classes/schemas/attachment.ts @@ -0,0 +1,70 @@ +import { z } from "@hono/zod-openapi"; +import { Id } from "./common.ts"; + +export const Attachment = z + .object({ + id: Id.openapi({ + description: "The ID of the attachment in the database.", + example: "8c33d4c6-2292-4f4d-945d-261836e09647", + }), + type: z.enum(["unknown", "image", "gifv", "video", "audio"]).openapi({ + description: + "The type of the attachment. 'unknown' = unsupported or unrecognized file type, 'image' = Static image, 'gifv' = Looping, soundless animation, 'video' = Video clip, 'audio' = Audio track.", + example: "image", + }), + url: z.string().url().openapi({ + description: "The location of the original full-size attachment.", + example: + "https://files.mastodon.social/media_attachments/files/022/345/792/original/57859aede991da25.jpeg", + }), + preview_url: z.string().url().nullable().openapi({ + description: + "The location of a scaled-down preview of the attachment.", + example: + "https://files.mastodon.social/media_attachments/files/022/345/792/small/57859aede991da25.jpeg", + }), + remote_url: z.string().url().nullable().openapi({ + description: + "The location of the full-size original attachment on the remote website, or null if the attachment is local.", + example: null, + }), + meta: z.record(z.any()).openapi({ + description: + "Metadata. May contain subtrees like 'small' and 'original', and possibly a 'focus' object for smart thumbnail cropping.", + example: { + original: { + width: 640, + height: 480, + size: "640x480", + aspect: 1.3333333333333333, + }, + small: { + width: 461, + height: 346, + size: "461x346", + aspect: 1.3323699421965318, + }, + focus: { + x: -0.27, + y: 0.51, + }, + }, + }), + description: z.string().nullable().openapi({ + description: + "Alternate text that describes what is in the media attachment, to be used for the visually impaired or when media attachments do not load.", + example: "test media description", + }), + 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: "UFBWY:8_0Jxv4mx]t8t64.%M-:IUWGWAt6M}", + }), + }) + .openapi({ + description: + "Represents a file or media attachment that can be added to a status.", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Attachment", + }, + }); diff --git a/classes/schemas/card.ts b/classes/schemas/card.ts index fd65c403..0b367b78 100644 --- a/classes/schemas/card.ts +++ b/classes/schemas/card.ts @@ -27,125 +27,133 @@ export const PreviewCardAuthor = z.object({ }), }); -export const PreviewCard = z.object({ - url: z - .string() - .url() - .openapi({ - description: "Location of linked resource.", - example: "https://www.youtube.com/watch?v=OMv_EPMED8Y", +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/#url", + url: "https://docs.joinmastodon.org/entities/PreviewCard/#description", }, }), - title: z - .string() - .min(1) - .openapi({ - description: "Title of linked resource.", - example: "♪ Brand New Friend (Christmas Song!)", + type: z.enum(["link", "photo", "video"]).openapi({ + description: "The type of the preview card.", + example: "video", externalDocs: { - url: "https://docs.joinmastodon.org/entities/PreviewCard/#title", + url: "https://docs.joinmastodon.org/entities/PreviewCard/#type", }, }), - 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({ + authors: z.array(PreviewCardAuthor).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@", + "Fediverse account of the authors of the original resource.", externalDocs: { - url: "https://docs.joinmastodon.org/entities/PreviewCard/#blurhash", + 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", + }, + }), + }) + .openapi({ + description: + "Represents a rich preview card that is generated using OpenGraph tags from a URL.", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/PreviewCard", + }, + }); diff --git a/classes/schemas/common.ts b/classes/schemas/common.ts index ddb0cfcb..d3a40c18 100644 --- a/classes/schemas/common.ts +++ b/classes/schemas/common.ts @@ -1,3 +1,6 @@ import { z } from "@hono/zod-openapi"; +import ISO6391 from "iso-639-1"; export const Id = z.string().uuid(); + +export const iso631 = z.enum(ISO6391.getAllCodes() as [string, ...string[]]); diff --git a/classes/schemas/extended-description.ts b/classes/schemas/extended-description.ts new file mode 100644 index 00000000..261dbf1e --- /dev/null +++ b/classes/schemas/extended-description.ts @@ -0,0 +1,31 @@ +import { z } from "@hono/zod-openapi"; + +export const ExtendedDescription = z + .object({ + updated_at: z + .string() + .datetime() + .openapi({ + description: + "A timestamp of when the extended description was last updated.", + example: "2025-01-12T13:11:00Z", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/ExtendedDescription/#updated_at", + }, + }), + content: z.string().openapi({ + description: + "The rendered HTML content of the extended description.", + example: "

We love casting spells.

", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/ExtendedDescription/#content", + }, + }), + }) + .openapi({ + description: + "Represents an extended description for the instance, to be shown on its about page.", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/ExtendedDescription", + }, + }); diff --git a/classes/schemas/familiar-followers.ts b/classes/schemas/familiar-followers.ts new file mode 100644 index 00000000..d4de1c7b --- /dev/null +++ b/classes/schemas/familiar-followers.ts @@ -0,0 +1,26 @@ +import { z } from "@hono/zod-openapi"; +import { Account } from "./account.ts"; + +export const FamiliarFollowers = z + .object({ + id: Account.shape.id.openapi({ + description: "The ID of the Account in the database.", + example: "48214efb-1f3c-459a-abfa-618a5aeb2f7a", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/FamiliarFollowers/#id", + }, + }), + accounts: z.array(Account).openapi({ + description: "Accounts you follow that also follow this account.", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/FamiliarFollowers/#accounts", + }, + }), + }) + .openapi({ + description: + "Represents a subset of your follows who also follow some other user.", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/FamiliarFollowers", + }, + }); diff --git a/classes/schemas/filters.ts b/classes/schemas/filters.ts index 3059e868..9ac25a10 100644 --- a/classes/schemas/filters.ts +++ b/classes/schemas/filters.ts @@ -1,129 +1,171 @@ 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({ +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", + }, + }), + }) + .openapi({ description: - "Should the filter consider word boundaries? See implementation guidelines for filters.", - example: false, + "Represents a status ID that, if matched, should cause the filter action to be taken.", externalDocs: { - url: "https://docs.joinmastodon.org/entities/FilterKeyword/#whole_word", + url: "https://docs.joinmastodon.org/entities/FilterStatus", }, - }), -}); + }); -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"], +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/Filter/#context", + url: "https://docs.joinmastodon.org/entities/FilterKeyword/#id", }, }), - expires_at: z - .string() - .nullable() - .openapi({ - description: "When the filter should no longer be applied.", - example: "2026-09-20T17:27:39.296Z", + keyword: z.string().openapi({ + description: "The phrase to be matched against.", + example: "badword", externalDocs: { - url: "https://docs.joinmastodon.org/entities/Filter/#expires_at", + url: "https://docs.joinmastodon.org/entities/FilterKeyword/#keyword", }, }), - filter_action: z.enum(["warn", "hide"]).openapi({ + 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", + }, + }), + }) + .openapi({ description: - "The action to be taken when a status matches this filter.", - example: "warn", + "Represents a keyword that, if matched, should cause the filter action to be taken.", externalDocs: { - url: "https://docs.joinmastodon.org/entities/Filter/#filter_action", + url: "https://docs.joinmastodon.org/entities/FilterKeyword", }, - }), - 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.", +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", + }, + }), + }) + .openapi({ + description: + "Represents a user-defined filter for determining which statuses should not be shown to the user.", externalDocs: { - url: "https://docs.joinmastodon.org/entities/FilterResult/#filter", + url: "https://docs.joinmastodon.org/entities/Filter", }, - }), - keyword_matches: z - .array(z.string()) - .nullable() - .openapi({ - description: "The keyword within the filter that was matched.", - example: ["badword"], + }); + +export const FilterResult = z + .object({ + filter: Filter.openapi({ + description: "The filter that was matched.", externalDocs: { - url: "https://docs.joinmastodon.org/entities/FilterResult/#keyword_matches", + url: "https://docs.joinmastodon.org/entities/FilterResult/#filter", }, }), - 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", - }, - }), -}); + 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", + }, + }), + }) + .openapi({ + description: + "Represents a filter whose keywords matched a given status.", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/FilterResult", + }, + }); diff --git a/classes/schemas/instance-v1.ts b/classes/schemas/instance-v1.ts new file mode 100644 index 00000000..88906383 --- /dev/null +++ b/classes/schemas/instance-v1.ts @@ -0,0 +1,141 @@ +import { z } from "@hono/zod-openapi"; +import { Instance } from "./instance.ts"; +import { SSOConfig } from "./versia.ts"; + +export const InstanceV1 = z + .object({ + uri: Instance.shape.domain, + title: Instance.shape.title, + short_description: Instance.shape.description, + description: z.string().openapi({ + description: "An HTML-permitted description of the site.", + example: "

Join the world's smallest social network.

", + }), + email: Instance.shape.contact.shape.email, + version: Instance.shape.version, + /* Versia Server API extension */ + versia_version: Instance.shape.versia_version, + urls: z + .object({ + streaming_api: + Instance.shape.configuration.shape.urls.shape.streaming, + }) + .openapi({ + description: "URLs of interest for clients apps.", + }), + stats: z + .object({ + user_count: z.number().openapi({ + description: "Total users on this instance.", + example: 812303, + }), + status_count: z.number().openapi({ + description: "Total statuses on this instance.", + example: 38151616, + }), + domain_count: z.number().openapi({ + description: "Total domains discovered by this instance.", + example: 25255, + }), + }) + .openapi({ + description: + "Statistics about how much information the instance contains.", + }), + thumbnail: z.string().url().nullable().openapi({ + description: "Banner image for the website.", + example: + "https://files.mastodon.social/site_uploads/files/000/000/001/original/vlcsnap-2018-08-27-16h43m11s127.png", + }), + languages: Instance.shape.languages, + registrations: Instance.shape.registrations.shape.enabled, + approval_required: Instance.shape.registrations.shape.approval_required, + invites_enabled: z.boolean().openapi({ + description: "Whether invites are enabled.", + example: true, + }), + configuration: z + .object({ + accounts: z + .object({ + max_featured_tags: + Instance.shape.configuration.shape.accounts.shape + .max_featured_tags, + }) + .openapi({ + description: "Limits related to accounts.", + }), + statuses: z + .object({ + max_characters: + Instance.shape.configuration.shape.statuses.shape + .max_characters, + max_media_attachments: + Instance.shape.configuration.shape.statuses.shape + .max_media_attachments, + characters_reserved_per_url: + Instance.shape.configuration.shape.statuses.shape + .characters_reserved_per_url, + }) + .openapi({ + description: "Limits related to authoring statuses.", + }), + media_attachments: z + .object({ + supported_mime_types: + Instance.shape.configuration.shape.media_attachments + .shape.supported_mime_types, + image_size_limit: + Instance.shape.configuration.shape.media_attachments + .shape.image_size_limit, + image_matrix_limit: + Instance.shape.configuration.shape.media_attachments + .shape.image_matrix_limit, + video_size_limit: + Instance.shape.configuration.shape.media_attachments + .shape.video_size_limit, + video_frame_rate_limit: + Instance.shape.configuration.shape.media_attachments + .shape.video_frame_rate_limit, + video_matrix_limit: + Instance.shape.configuration.shape.media_attachments + .shape.video_matrix_limit, + }) + .openapi({ + description: + "Hints for which attachments will be accepted.", + }), + polls: z + .object({ + max_options: + Instance.shape.configuration.shape.polls.shape + .max_options, + max_characters_per_option: + Instance.shape.configuration.shape.polls.shape + .max_characters_per_option, + min_expiration: + Instance.shape.configuration.shape.polls.shape + .min_expiration, + max_expiration: + Instance.shape.configuration.shape.polls.shape + .max_expiration, + }) + .openapi({ + description: "Limits related to polls.", + }), + }) + .openapi({ + description: "Configured values and limits for this website.", + }), + contact_account: Instance.shape.contact.shape.account, + rules: Instance.shape.rules, + /* Versia Server API extension */ + sso: SSOConfig, + }) + .openapi({ + description: + "Represents the software instance of Versia Server running on this domain.", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/V1_Instance", + }, + }); diff --git a/classes/schemas/instance.ts b/classes/schemas/instance.ts new file mode 100644 index 00000000..a9d535c4 --- /dev/null +++ b/classes/schemas/instance.ts @@ -0,0 +1,382 @@ +import { z } from "@hono/zod-openapi"; +import pkg from "~/package.json"; +import { Account } from "./account.ts"; +import { iso631 } from "./common.ts"; +import { Rule } from "./rule.ts"; +import { SSOConfig } from "./versia.ts"; + +const InstanceIcon = z + .object({ + src: z.string().url().openapi({ + description: "The URL of this icon.", + example: + "https://files.mastodon.social/site_uploads/files/000/000/003/36/accf17b0104f18e5.png", + }), + size: z.string().openapi({ + description: + "The size of this icon (in the form of 12x34, where 12 is the width and 34 is the height of the icon).", + example: "36x36", + }), + }) + .openapi({ + externalDocs: { + url: "https://docs.joinmastodon.org/entities/InstanceIcon", + }, + }); + +export const Instance = z + .object({ + domain: z.string().openapi({ + description: "The domain name of the instance.", + example: "versia.social", + }), + title: z.string().openapi({ + description: "The title of the website.", + example: "Versia Social • Now with 100% more blobs!", + }), + version: z.string().openapi({ + description: + "Mastodon version that the API is compatible with. Used for compatibility with Mastodon clients.", + example: "4.3.0+glitch", + }), + /* Versia Server API extension */ + versia_version: z.string().openapi({ + description: "Versia Server version.", + example: "0.8.0", + }), + source_url: z.string().url().openapi({ + description: + "The URL for the source code of the software running on this instance, in keeping with AGPL license requirements.", + example: pkg.repository.url, + }), + description: z.string().openapi({ + description: + "A short, plain-text description defined by the admin.", + example: "The flagship Versia Server instance. Join for free hugs!", + }), + usage: z + .object({ + users: z + .object({ + active_month: z.number().openapi({ + description: + "The number of active users in the past 4 weeks.", + example: 1_261, + }), + }) + .openapi({ + description: + "Usage data related to users on this instance.", + }), + }) + .openapi({ description: "Usage data for this instance." }), + thumbnail: z + .object({ + url: z.string().url().openapi({ + description: "The URL for the thumbnail image.", + example: + "https://files.mastodon.social/site_uploads/files/000/000/001/@1x/57c12f441d083cde.png", + }), + blurhash: z.string().optional().openapi({ + description: + "A hash computed by the BlurHash algorithm, for generating colorful preview thumbnails when media has not been downloaded yet.", + example: "UUKJMXv|x]t7^*t7Rjaz^jazRjaz", + }), + versions: z + .object({ + "@1x": z.string().url().optional().openapi({ + description: + "The URL for the thumbnail image at 1x resolution.", + }), + "@2x": z.string().url().optional().openapi({ + description: + "The URL for the thumbnail image at 2x resolution.", + }), + }) + .optional() + .openapi({ + description: + "Links to scaled resolution images, for high DPI screens.", + }), + }) + .openapi({ + description: "An image used to represent this instance.", + }), + icon: z.array(InstanceIcon).openapi({ + description: + "The list of available size variants for this instance configured icon.", + }), + languages: z.array(iso631).openapi({ + description: "Primary languages of the website and its staff.", + example: ["en"], + }), + configuration: z + .object({ + urls: z + .object({ + streaming: z.string().url().openapi({ + description: + "The Websockets URL for connecting to the streaming API.", + example: "wss://versia.social", + }), + }) + .openapi({ + description: "URLs of interest for clients apps.", + }), + vapid: z + .object({ + public_key: z.string().openapi({ + description: + "The instance's VAPID public key, used for push notifications, the same as WebPushSubscription#server_key.", + example: + "BCkMmVdKDnKYwzVCDC99Iuc9GvId-x7-kKtuHnLgfF98ENiZp_aj-UNthbCdI70DqN1zUVis-x0Wrot2sBagkMc=", + }), + }) + .openapi({ description: "VAPID configuration." }), + accounts: z + .object({ + max_featured_tags: z.number().openapi({ + description: + "The maximum number of featured tags allowed for each account.", + example: 10, + }), + max_pinned_statuses: z.number().openapi({ + description: + "The maximum number of pinned statuses for each account.", + example: 4, + }), + /* Versia Server API extension */ + max_displayname_characters: z.number().openapi({ + description: + "The maximum number of characters allowed in a display name.", + example: 30, + }), + /* Versia Server API extension */ + max_username_characters: z.number().openapi({ + description: + "The maximum number of characters allowed in a username.", + example: 30, + }), + /* Versia Server API extension */ + max_note_characters: z.number().openapi({ + description: + "The maximum number of characters allowed in an account's bio/note.", + example: 500, + }), + /* Versia Server API extension */ + avatar_limit: z.number().openapi({ + description: + "The maximum size of an avatar image, in bytes.", + example: 1048576, + }), + /* Versia Server API extension */ + header_limit: z.number().openapi({ + description: + "The maximum size of a header image, in bytes.", + example: 2097152, + }), + /* Versia Server API extension */ + fields: z + .object({ + max_fields: z.number().openapi({ + description: + "The maximum number of fields allowed per account.", + example: 4, + }), + max_name_characters: z.number().openapi({ + description: + "The maximum number of characters allowed in a field name.", + example: 30, + }), + max_value_characters: z.number().openapi({ + description: + "The maximum number of characters allowed in a field value.", + example: 100, + }), + }) + .openapi({ + description: + "Limits related to account fields.", + }), + }) + .openapi({ description: "Limits related to accounts." }), + statuses: z + .object({ + max_characters: z.number().openapi({ + description: + "The maximum number of allowed characters per status.", + example: 500, + }), + max_media_attachments: z.number().openapi({ + description: + "The maximum number of media attachments that can be added to a status.", + example: 4, + }), + characters_reserved_per_url: z.number().openapi({ + description: + "Each URL in a status will be assumed to be exactly this many characters.", + example: 23, + }), + }) + .openapi({ + description: "Limits related to authoring statuses.", + }), + media_attachments: z + .object({ + supported_mime_types: z.array(z.string()).openapi({ + description: + "Contains MIME types that can be uploaded.", + example: ["image/jpeg", "image/png", "image/gif"], + }), + description_limit: z.number().openapi({ + description: + "The maximum size of a description, in characters.", + example: 1500, + }), + image_size_limit: z.number().openapi({ + description: + "The maximum size of any uploaded image, in bytes.", + example: 10485760, + }), + image_matrix_limit: z.number().openapi({ + description: + "The maximum number of pixels (width times height) for image uploads.", + example: 16777216, + }), + video_size_limit: z.number().openapi({ + description: + "The maximum size of any uploaded video, in bytes.", + example: 41943040, + }), + video_frame_rate_limit: z.number().openapi({ + description: + "The maximum frame rate for any uploaded video.", + example: 60, + }), + video_matrix_limit: z.number().openapi({ + description: + "The maximum number of pixels (width times height) for video uploads.", + example: 2304000, + }), + }) + .openapi({ + description: + "Hints for which attachments will be accepted.", + }), + /* Versia Server API extension */ + emojis: z + .object({ + emoji_size_limit: z.number().openapi({ + description: + "The maximum size of an emoji image, in bytes.", + example: 1048576, + }), + max_shortcode_characters: z.number().openapi({ + description: + "The maximum number of characters allowed in an emoji shortcode.", + example: 30, + }), + max_description_characters: z.number().openapi({ + description: + "The maximum number of characters allowed in an emoji description.", + example: 100, + }), + }) + .openapi({ + description: "Limits related to custom emojis.", + }), + polls: z + .object({ + max_options: z.number().openapi({ + description: + "Each poll is allowed to have up to this many options.", + example: 4, + }), + max_characters_per_option: z.number().openapi({ + description: + "Each poll option is allowed to have this many characters.", + example: 50, + }), + min_expiration: z.number().openapi({ + description: + "The shortest allowed poll duration, in seconds.", + example: 300, + }), + max_expiration: z.number().openapi({ + description: + "The longest allowed poll duration, in seconds.", + example: 2629746, + }), + }) + .openapi({ description: "Limits related to polls." }), + translation: z + .object({ + enabled: z.boolean().openapi({ + description: + "Whether the Translations API is available on this instance.", + example: true, + }), + }) + .openapi({ description: "Hints related to translation." }), + }) + .openapi({ + description: "Configured values and limits for this website.", + }), + registrations: z + .object({ + enabled: z.boolean().openapi({ + description: "Whether registrations are enabled.", + example: false, + }), + approval_required: z.boolean().openapi({ + description: + "Whether registrations require moderator approval.", + example: false, + }), + message: z.string().nullable().openapi({ + description: + "A custom message to be shown when registrations are closed.", + }), + }) + .openapi({ + description: "Information about registering for this website.", + }), + api_versions: z + .object({ + mastodon: z.number().openapi({ + description: + "API version number that this server implements.", + example: 1, + }), + }) + .openapi({ + description: + "Information about which version of the API is implemented by this server.", + }), + contact: z + .object({ + email: z.string().email().openapi({ + description: + "An email address that can be messaged regarding inquiries or issues.", + example: "contact@versia.social", + }), + account: Account.nullable().openapi({ + description: + "An account that can be contacted regarding inquiries or issues.", + }), + }) + .openapi({ + description: + "Hints related to contacting a representative of the website.", + }), + rules: z.array(Rule).openapi({ + description: "An itemized list of rules for this website.", + }), + /* Versia Server API extension */ + sso: SSOConfig, + }) + .openapi({ + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Instance", + }, + }); diff --git a/classes/schemas/marker.ts b/classes/schemas/marker.ts new file mode 100644 index 00000000..e9a6cdb7 --- /dev/null +++ b/classes/schemas/marker.ts @@ -0,0 +1,26 @@ +import { z } from "@hono/zod-openapi"; +import { Id } from "./common.ts"; + +export const Marker = z + .object({ + last_read_id: Id.openapi({ + description: "The ID of the most recently viewed entity.", + example: "ead15c9d-8eda-4b2c-9546-ecbf851f001c", + }), + version: z.number().openapi({ + description: + "An incrementing counter, used for locking to prevent write conflicts.", + example: 462, + }), + updated_at: z.string().datetime().openapi({ + description: "The timestamp of when the marker was set.", + example: "2025-01-12T13:11:00Z", + }), + }) + .openapi({ + description: + "Represents the last read position within a user's timelines.", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Marker", + }, + }); diff --git a/classes/schemas/notification.ts b/classes/schemas/notification.ts new file mode 100644 index 00000000..9104fae0 --- /dev/null +++ b/classes/schemas/notification.ts @@ -0,0 +1,70 @@ +import { z } from "@hono/zod-openapi"; +import { AccountWarning } from "./account-warning.ts"; +import { Account } from "./account.ts"; +import { Id } from "./common.ts"; +import { Report } from "./report.ts"; +import { Status } from "./status.ts"; + +export const Notification = z + .object({ + id: Id.openapi({ + description: "The ID of the notification in the database.", + example: "6405f495-da55-4ad7-b5d6-9a773360fc07", + }), + type: z + .enum([ + "mention", + "status", + "reblog", + "follow", + "follow_request", + "favourite", + "poll", + "update", + "admin.sign_up", + "admin.report", + "severed_relationships", + "moderation_warning", + ]) + .openapi({ + description: + "The type of event that resulted in the notification.", + example: "mention", + }), + group_key: z.string().openapi({ + description: + "Group key shared by similar notifications, to be used in the grouped notifications feature.", + example: "ungrouped-34975861", + }), + created_at: z.string().datetime().openapi({ + description: "The timestamp of the notification.", + example: "2025-01-12T13:11:00Z", + }), + account: Account.openapi({ + description: + "The account that performed the action that generated the notification.", + }), + status: Status.optional().openapi({ + description: + "Status that was the object of the notification. Attached when type of the notification is favourite, reblog, status, mention, poll, or update.", + }), + report: Report.optional().openapi({ + description: + "Report that was the object of the notification. Attached when type of the notification is admin.report.", + }), + event: z.undefined().openapi({ + description: + "Versia Server does not sever relationships, so this field is always empty.", + }), + moderation_warning: AccountWarning.optional().openapi({ + description: + "Moderation warning that caused the notification. Attached when type of the notification is moderation_warning.", + }), + }) + .openapi({ + description: + "Represents a notification of an event relevant to the user.", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Notification", + }, + }); diff --git a/classes/schemas/poll.ts b/classes/schemas/poll.ts new file mode 100644 index 00000000..75465830 --- /dev/null +++ b/classes/schemas/poll.ts @@ -0,0 +1,132 @@ +import { z } from "@hono/zod-openapi"; +import { Id } from "./common.ts"; +import { CustomEmoji } from "./emoji.ts"; + +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: z.boolean().openapi({ + description: "Is the poll currently expired?", + example: false, + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Poll/#expired", + }, + }), + multiple: z.boolean().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(CustomEmoji).openapi({ + description: "Custom emoji to be used for rendering poll options.", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Poll/#emojis", + }, + }), + voted: z + .boolean() + .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", + }, + }), + }) + .openapi({ + description: "Represents a poll attached to a status.", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Poll", + }, + }); diff --git a/classes/schemas/preferences.ts b/classes/schemas/preferences.ts new file mode 100644 index 00000000..65e104c1 --- /dev/null +++ b/classes/schemas/preferences.ts @@ -0,0 +1,50 @@ +import { z } from "@hono/zod-openapi"; +import { Source } from "./account.ts"; + +export const Preferences = z + .object({ + "posting:default:visibility": Source.shape.privacy.openapi({ + description: "Default visibility for new posts.", + example: "public", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Preferences/#posting-default-visibility", + }, + }), + "posting:default:sensitive": Source.shape.sensitive.openapi({ + description: "Default sensitivity flag for new posts.", + example: false, + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Preferences/#posting-default-sensitive", + }, + }), + "posting:default:language": Source.shape.language.nullable().openapi({ + description: "Default language for new posts.", + example: null, + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Preferences/#posting-default-language", + }, + }), + "reading:expand:media": z + .enum(["default", "show_all", "hide_all"]) + .openapi({ + description: + "Whether media attachments should be automatically displayed or blurred/hidden.", + example: "default", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Preferences/#reading-expand-media", + }, + }), + "reading:expand:spoilers": z.boolean().openapi({ + description: "Whether CWs should be expanded by default.", + example: false, + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Preferences/#reading-expand-spoilers", + }, + }), + }) + .openapi({ + description: "Represents a user's preferences.", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Preferences", + }, + }); diff --git a/classes/schemas/privacy-policy.ts b/classes/schemas/privacy-policy.ts new file mode 100644 index 00000000..dee471ec --- /dev/null +++ b/classes/schemas/privacy-policy.ts @@ -0,0 +1,29 @@ +import { z } from "@hono/zod-openapi"; + +export const PrivacyPolicy = z + .object({ + updated_at: z + .string() + .datetime() + .openapi({ + description: + "A timestamp of when the privacy policy was last updated.", + example: "2025-01-12T13:11:00Z", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/PrivacyPolicy/#updated_at", + }, + }), + content: z.string().openapi({ + description: "The rendered HTML content of the privacy policy.", + example: "

Privacy Policy

None, good luck.

", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/PrivacyPolicy/#content", + }, + }), + }) + .openapi({ + description: "Represents the privacy policy of the instance.", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/PrivacyPolicy", + }, + }); diff --git a/classes/schemas/relationship.ts b/classes/schemas/relationship.ts new file mode 100644 index 00000000..8d2f8889 --- /dev/null +++ b/classes/schemas/relationship.ts @@ -0,0 +1,74 @@ +import { z } from "@hono/zod-openapi"; +import { Id, iso631 } from "./common.ts"; + +export const Relationship = z + .object({ + id: Id.openapi({ + description: "The account ID.", + example: "51f34c31-c8c6-4dc2-9df1-3704fcdde9b6", + }), + following: z.boolean().openapi({ + description: "Are you following this user?", + example: true, + }), + showing_reblogs: z.boolean().openapi({ + description: + "Are you receiving this user’s boosts in your home timeline?", + example: true, + }), + notifying: z.boolean().openapi({ + description: "Have you enabled notifications for this user?", + example: false, + }), + languages: z.array(iso631).openapi({ + description: "Which languages are you following from this user?", + example: ["en"], + }), + followed_by: z.boolean().openapi({ + description: "Are you followed by this user?", + example: true, + }), + blocking: z.boolean().openapi({ + description: "Are you blocking this user?", + example: false, + }), + blocked_by: z.boolean().openapi({ + description: "Is this user blocking you?", + example: false, + }), + muting: z.boolean().openapi({ + description: "Are you muting this user?", + example: false, + }), + muting_notifications: z.boolean().openapi({ + description: "Are you muting notifications from this user?", + example: false, + }), + requested: z.boolean().openapi({ + description: "Do you have a pending follow request for this user?", + example: false, + }), + requested_by: z.boolean().openapi({ + description: "Has this user requested to follow you?", + example: false, + }), + domain_blocking: z.boolean().openapi({ + description: "Are you blocking this user’s domain?", + example: false, + }), + endorsed: z.boolean().openapi({ + description: "Are you featuring this user on your profile?", + example: false, + }), + note: z.string().openapi({ + description: "This user’s profile bio", + example: "they also like Kerbal Space Program", + }), + }) + .openapi({ + description: + "Represents the relationship between accounts, such as following / blocking / muting / etc.", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Relationship", + }, + }); diff --git a/classes/schemas/report.ts b/classes/schemas/report.ts new file mode 100644 index 00000000..4d4049ec --- /dev/null +++ b/classes/schemas/report.ts @@ -0,0 +1,59 @@ +import { z } from "@hono/zod-openapi"; +import { Account } from "./account.ts"; +import { Id } from "./common.ts"; + +export const Report = z + .object({ + id: Id.openapi({ + description: "The ID of the report in the database.", + example: "9b0cd757-324b-4ea6-beab-f6226e138886", + }), + action_taken: z.boolean().openapi({ + description: "Whether an action was taken yet.", + example: false, + }), + action_taken_at: z.string().datetime().nullable().openapi({ + description: "When an action was taken against the report.", + example: null, + }), + category: z.enum(["spam", "violation", "other"]).openapi({ + description: + "The generic reason for the report. 'spam' = Unwanted or repetitive content, 'violation' = A specific rule was violated, 'other' = Some other reason.", + example: "spam", + }), + comment: z.string().openapi({ + description: "The reason for the report.", + example: "Spam account", + }), + forwarded: z.boolean().openapi({ + description: "Whether the report was forwarded to a remote domain.", + example: false, + }), + created_at: z.string().datetime().openapi({ + description: "When the report was created.", + example: "2024-12-31T23:59:59.999Z", + }), + status_ids: z + .array(Id) + .nullable() + .openapi({ + description: + "IDs of statuses that have been attached to this report for additional context.", + example: ["1abf027c-af03-46ff-8d17-9ee799a17ca7"], + }), + rule_ids: z.array(z.string()).nullable().openapi({ + description: + "IDs of the rules that have been cited as a violation by this report.", + example: null, + }), + target_account: Account.openapi({ + description: "The account that was reported.", + }), + }) + .openapi({ + description: + "Reports filed against users and/or statuses, to be taken action on by moderators.", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Report", + }, + }); diff --git a/classes/schemas/rule.ts b/classes/schemas/rule.ts new file mode 100644 index 00000000..0a8db603 --- /dev/null +++ b/classes/schemas/rule.ts @@ -0,0 +1,23 @@ +import { z } from "@hono/zod-openapi"; + +export const Rule = z + .object({ + id: z.string().openapi({ + description: "The identifier for the rule.", + example: "1", + }), + text: z.string().openapi({ + description: "The rule to be followed.", + example: "Do not spam pictures of skibidi toilet.", + }), + hint: z.string().optional().openapi({ + description: "Longer-form description of the rule.", + example: "Please, we beg you.", + }), + }) + .openapi({ + description: "Represents a rule that server users should follow.", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Rule", + }, + }); diff --git a/classes/schemas/search.ts b/classes/schemas/search.ts new file mode 100644 index 00000000..e3286479 --- /dev/null +++ b/classes/schemas/search.ts @@ -0,0 +1,23 @@ +import { z } from "@hono/zod-openapi"; +import { Account } from "./account.ts"; +import { Status } from "./status.ts"; +import { Tag } from "./tag.ts"; + +export const Search = z + .object({ + accounts: z.array(Account).openapi({ + description: "Accounts which match the given query", + }), + statuses: z.array(Status).openapi({ + description: "Statuses which match the given query", + }), + hashtags: z.array(Tag).openapi({ + description: "Hashtags which match the given query", + }), + }) + .openapi({ + description: "Represents the results of a search.", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Search", + }, + }); diff --git a/classes/schemas/status.ts b/classes/schemas/status.ts index 9a7bf0a1..88ff73cd 100644 --- a/classes/schemas/status.ts +++ b/classes/schemas/status.ts @@ -1,13 +1,14 @@ import { z } from "@hono/zod-openapi"; import type { Status as ApiNote } from "@versia/client/types"; import { 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 { Id, iso631 } from "./common.ts"; import { CustomEmoji } from "./emoji.ts"; import { FilterResult } from "./filters.ts"; +import { Poll } from "./poll.ts"; +import { Tag } from "./tag.ts"; import { NoteReaction } from "./versia.ts"; export const Mention = z @@ -48,155 +49,6 @@ export const Mention = z }, }); -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(CustomEmoji).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.", @@ -429,16 +281,13 @@ export const Status = z.object({ 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", - }, - }), + language: iso631.nullable().openapi({ + description: "Primary language of this status.", + example: "en", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Status/#language", + }, + }), text: z .string() .nullable() diff --git a/classes/schemas/tag.ts b/classes/schemas/tag.ts new file mode 100644 index 00000000..4cc9eac6 --- /dev/null +++ b/classes/schemas/tag.ts @@ -0,0 +1,31 @@ +import { z } from "@hono/zod-openapi"; + +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", + }, + }); diff --git a/classes/schemas/token.ts b/classes/schemas/token.ts new file mode 100644 index 00000000..11c942dd --- /dev/null +++ b/classes/schemas/token.ts @@ -0,0 +1,29 @@ +import { z } from "@hono/zod-openapi"; + +export const Token = z + .object({ + access_token: z.string().openapi({ + description: "An OAuth token to be used for authorization.", + example: "ZA-Yj3aBD8U8Cm7lKUp-lm9O9BmDgdhHzDeqsY8tlL0", + }), + token_type: z.string().openapi({ + description: "The OAuth token type. Versia uses Bearer tokens.", + example: "Bearer", + }), + scope: z.string().openapi({ + description: + "The OAuth scopes granted by this token, space-separated.", + example: "read write follow push", + }), + created_at: z.number().nonnegative().openapi({ + description: "When the token was generated. UNIX timestamp.", + example: 1573979017, + }), + }) + .openapi({ + description: + "Represents an OAuth token used for authenticating with the API and performing actions.", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Token", + }, + }); diff --git a/classes/schemas/versia.ts b/classes/schemas/versia.ts index fd44db0c..ef8d5cd1 100644 --- a/classes/schemas/versia.ts +++ b/classes/schemas/versia.ts @@ -1,7 +1,7 @@ import { z } from "@hono/zod-openapi"; import { RolePermission } from "@versia/client/types"; -import { Id } from "./common.ts"; import { config } from "~/packages/config-manager/index.ts"; +import { Id } from "./common.ts"; /* Versia Server API extension */ export const Role = z @@ -75,3 +75,33 @@ export const NoteReaction = z .openapi({ description: "Information about a reaction to a note.", }); + +/* Versia Server API extension */ +export const SSOConfig = z.object({ + forced: z.boolean().openapi({ + description: + "If this is enabled, normal identifier/password login is disabled and login must be done through SSO.", + example: false, + }), + providers: z + .array( + z.object({ + id: z.string().min(1).openapi({ + description: "The ID of the provider.", + example: "google", + }), + name: z.string().min(1).openapi({ + description: "Human-readable provider name.", + example: "Google", + }), + icon: z.string().url().optional().openapi({ + description: "URL to the provider icon.", + example: "https://cdn.versia.social/google-icon.png", + }), + }), + ) + .openapi({ + description: + "An array of external OpenID Connect providers that users can link their accounts to.", + }), +}); From bff1c5f734cd4674ac9cc4fc0f1f5efa3c0e1fa7 Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Wed, 12 Feb 2025 23:25:22 +0100 Subject: [PATCH 05/11] refactor(api): :label: Begin porting all code over to new schemas --- api/api/v1/accounts/:id/block.ts | 3 +- api/api/v1/accounts/:id/follow.ts | 3 +- api/api/v1/accounts/:id/mute.ts | 3 +- api/api/v1/accounts/:id/note.ts | 3 +- api/api/v1/accounts/:id/pin.ts | 3 +- .../v1/accounts/:id/remove_from_followers.ts | 3 +- api/api/v1/accounts/:id/unblock.ts | 3 +- api/api/v1/accounts/:id/unfollow.ts | 3 +- api/api/v1/accounts/:id/unmute.ts | 3 +- api/api/v1/accounts/:id/unpin.ts | 3 +- api/api/v1/accounts/relationships/index.ts | 3 +- .../v1/accounts/update_credentials/index.ts | 1 + .../follow_requests/:account_id/authorize.ts | 3 +- .../v1/follow_requests/:account_id/reject.ts | 3 +- api/api/v1/media/:id/index.ts | 5 +- api/api/v1/media/index.ts | 3 +- api/api/v1/notifications/:id/index.ts | 3 +- api/api/v1/notifications/index.ts | 68 +++------------ api/api/v1/push/subscription/index.get.ts | 3 +- api/api/v1/push/subscription/index.post.ts | 3 +- api/api/v1/push/subscription/index.put.ts | 7 +- api/api/v2/media/index.ts | 3 +- api/well-known/webfinger/index.ts | 5 +- classes/database/media.ts | 39 ++------- classes/database/note.ts | 17 ++-- classes/database/notification.ts | 40 +-------- classes/database/pushsubscription.ts | 80 ----------------- classes/database/reaction.ts | 15 ---- classes/database/relationship.ts | 5 +- classes/schemas/pushsubscription.ts | 85 ++++++++++++++++++- classes/schemas/status.ts | 4 +- drizzle/schema.ts | 10 ++- 32 files changed, 171 insertions(+), 264 deletions(-) diff --git a/api/api/v1/accounts/:id/block.ts b/api/api/v1/accounts/:id/block.ts index 58b7f8fb..b82ef2c6 100644 --- a/api/api/v1/accounts/:id/block.ts +++ b/api/api/v1/accounts/:id/block.ts @@ -2,6 +2,7 @@ import { apiRoute, auth, withUserParam } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { Relationship } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; +import { Relationship as RelationshipSchema } from "~/classes/schemas/relationship"; const route = createRoute({ method: "post", @@ -24,7 +25,7 @@ const route = createRoute({ description: "Updated relationship", content: { "application/json": { - schema: Relationship.schema, + schema: RelationshipSchema, }, }, }, diff --git a/api/api/v1/accounts/:id/follow.ts b/api/api/v1/accounts/:id/follow.ts index 3b3edc07..ed50a53c 100644 --- a/api/api/v1/accounts/:id/follow.ts +++ b/api/api/v1/accounts/:id/follow.ts @@ -3,6 +3,7 @@ import { createRoute, z } from "@hono/zod-openapi"; import { Relationship } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import { iso631 } from "~/classes/schemas/common"; +import { Relationship as RelationshipSchema } from "~/classes/schemas/relationship"; const schemas = { param: z.object({ @@ -39,7 +40,7 @@ const route = createRoute({ description: "Updated relationship", content: { "application/json": { - schema: Relationship.schema, + schema: RelationshipSchema, }, }, }, diff --git a/api/api/v1/accounts/:id/mute.ts b/api/api/v1/accounts/:id/mute.ts index 45b49541..c896987a 100644 --- a/api/api/v1/accounts/:id/mute.ts +++ b/api/api/v1/accounts/:id/mute.ts @@ -2,6 +2,7 @@ import { apiRoute, auth, withUserParam } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { Relationship } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; +import { Relationship as RelationshipSchema } from "~/classes/schemas/relationship"; const schemas = { param: z.object({ @@ -49,7 +50,7 @@ const route = createRoute({ description: "Updated relationship", content: { "application/json": { - schema: Relationship.schema, + schema: RelationshipSchema, }, }, }, diff --git a/api/api/v1/accounts/:id/note.ts b/api/api/v1/accounts/:id/note.ts index c0f04822..673d99f9 100644 --- a/api/api/v1/accounts/:id/note.ts +++ b/api/api/v1/accounts/:id/note.ts @@ -2,6 +2,7 @@ import { apiRoute, auth, withUserParam } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { Relationship } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; +import { Relationship as RelationshipSchema } from "~/classes/schemas/relationship"; const schemas = { param: z.object({ @@ -43,7 +44,7 @@ const route = createRoute({ description: "Updated relationship", content: { "application/json": { - schema: Relationship.schema, + schema: RelationshipSchema, }, }, }, diff --git a/api/api/v1/accounts/:id/pin.ts b/api/api/v1/accounts/:id/pin.ts index 72a13771..6eefbabb 100644 --- a/api/api/v1/accounts/:id/pin.ts +++ b/api/api/v1/accounts/:id/pin.ts @@ -2,6 +2,7 @@ import { apiRoute, auth, withUserParam } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { Relationship } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; +import { Relationship as RelationshipSchema } from "~/classes/schemas/relationship"; const route = createRoute({ method: "post", @@ -29,7 +30,7 @@ const route = createRoute({ description: "Updated relationship", content: { "application/json": { - schema: Relationship.schema, + schema: RelationshipSchema, }, }, }, diff --git a/api/api/v1/accounts/:id/remove_from_followers.ts b/api/api/v1/accounts/:id/remove_from_followers.ts index aee745c1..3c3f6bef 100644 --- a/api/api/v1/accounts/:id/remove_from_followers.ts +++ b/api/api/v1/accounts/:id/remove_from_followers.ts @@ -2,6 +2,7 @@ import { apiRoute, auth, withUserParam } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { Relationship } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; +import { Relationship as RelationshipSchema } from "~/classes/schemas/relationship"; const route = createRoute({ method: "post", @@ -29,7 +30,7 @@ const route = createRoute({ description: "Updated relationship", content: { "application/json": { - schema: Relationship.schema, + schema: RelationshipSchema, }, }, }, diff --git a/api/api/v1/accounts/:id/unblock.ts b/api/api/v1/accounts/:id/unblock.ts index f480b575..b1c1e56a 100644 --- a/api/api/v1/accounts/:id/unblock.ts +++ b/api/api/v1/accounts/:id/unblock.ts @@ -2,6 +2,7 @@ import { apiRoute, auth, withUserParam } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { Relationship } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; +import { Relationship as RelationshipSchema } from "~/classes/schemas/relationship"; const route = createRoute({ method: "post", @@ -29,7 +30,7 @@ const route = createRoute({ description: "Updated relationship", content: { "application/json": { - schema: Relationship.schema, + schema: RelationshipSchema, }, }, }, diff --git a/api/api/v1/accounts/:id/unfollow.ts b/api/api/v1/accounts/:id/unfollow.ts index d38ecef8..57eb749e 100644 --- a/api/api/v1/accounts/:id/unfollow.ts +++ b/api/api/v1/accounts/:id/unfollow.ts @@ -2,6 +2,7 @@ import { apiRoute, auth, withUserParam } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { Relationship } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; +import { Relationship as RelationshipSchema } from "~/classes/schemas/relationship"; import { ErrorSchema } from "~/types/api"; const route = createRoute({ @@ -30,7 +31,7 @@ const route = createRoute({ description: "Updated relationship", content: { "application/json": { - schema: Relationship.schema, + schema: RelationshipSchema, }, }, }, diff --git a/api/api/v1/accounts/:id/unmute.ts b/api/api/v1/accounts/:id/unmute.ts index 4bbd9cec..0b2e0f46 100644 --- a/api/api/v1/accounts/:id/unmute.ts +++ b/api/api/v1/accounts/:id/unmute.ts @@ -2,6 +2,7 @@ import { apiRoute, auth, withUserParam } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { Relationship } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; +import { Relationship as RelationshipSchema } from "~/classes/schemas/relationship"; const route = createRoute({ method: "post", @@ -29,7 +30,7 @@ const route = createRoute({ description: "Updated relationship", content: { "application/json": { - schema: Relationship.schema, + schema: RelationshipSchema, }, }, }, diff --git a/api/api/v1/accounts/:id/unpin.ts b/api/api/v1/accounts/:id/unpin.ts index 59aa469b..57925ceb 100644 --- a/api/api/v1/accounts/:id/unpin.ts +++ b/api/api/v1/accounts/:id/unpin.ts @@ -2,6 +2,7 @@ import { apiRoute, auth, withUserParam } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { Relationship } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; +import { Relationship as RelationshipSchema } from "~/classes/schemas/relationship"; const route = createRoute({ method: "post", @@ -29,7 +30,7 @@ const route = createRoute({ description: "Updated relationship", content: { "application/json": { - schema: Relationship.schema, + schema: RelationshipSchema, }, }, }, diff --git a/api/api/v1/accounts/relationships/index.ts b/api/api/v1/accounts/relationships/index.ts index f3a81c56..f76f3d99 100644 --- a/api/api/v1/accounts/relationships/index.ts +++ b/api/api/v1/accounts/relationships/index.ts @@ -2,6 +2,7 @@ import { apiRoute, auth, qsQuery } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { Relationship } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; +import { Relationship as RelationshipSchema } from "~/classes/schemas/relationship"; const schemas = { query: z.object({ @@ -30,7 +31,7 @@ const route = createRoute({ description: "Relationships", content: { "application/json": { - schema: z.array(Relationship.schema), + schema: z.array(RelationshipSchema), }, }, }, diff --git a/api/api/v1/accounts/update_credentials/index.ts b/api/api/v1/accounts/update_credentials/index.ts index 868886dc..fca71f64 100644 --- a/api/api/v1/accounts/update_credentials/index.ts +++ b/api/api/v1/accounts/update_credentials/index.ts @@ -263,6 +263,7 @@ export default apiRoute((app) => self.source.fields.push({ name: field.name, value: field.value, + verified_at: null, }); } } diff --git a/api/api/v1/follow_requests/:account_id/authorize.ts b/api/api/v1/follow_requests/:account_id/authorize.ts index 2cb28c6d..1e253b78 100644 --- a/api/api/v1/follow_requests/:account_id/authorize.ts +++ b/api/api/v1/follow_requests/:account_id/authorize.ts @@ -3,6 +3,7 @@ import { createRoute, z } from "@hono/zod-openapi"; import { Relationship, User } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import { ApiError } from "~/classes/errors/api-error"; +import { Relationship as RelationshipSchema } from "~/classes/schemas/relationship"; import { ErrorSchema } from "~/types/api"; const schemas = { @@ -29,7 +30,7 @@ const route = createRoute({ description: "Relationship", content: { "application/json": { - schema: Relationship.schema, + schema: RelationshipSchema, }, }, }, diff --git a/api/api/v1/follow_requests/:account_id/reject.ts b/api/api/v1/follow_requests/:account_id/reject.ts index e487cc88..00d5fc02 100644 --- a/api/api/v1/follow_requests/:account_id/reject.ts +++ b/api/api/v1/follow_requests/:account_id/reject.ts @@ -3,6 +3,7 @@ import { createRoute, z } from "@hono/zod-openapi"; import { Relationship, User } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import { ApiError } from "~/classes/errors/api-error"; +import { Relationship as RelationshipSchema } from "~/classes/schemas/relationship"; import { ErrorSchema } from "~/types/api"; const schemas = { @@ -29,7 +30,7 @@ const route = createRoute({ description: "Relationship", content: { "application/json": { - schema: Relationship.schema, + schema: RelationshipSchema, }, }, }, diff --git a/api/api/v1/media/:id/index.ts b/api/api/v1/media/:id/index.ts index abf63bda..713cc971 100644 --- a/api/api/v1/media/:id/index.ts +++ b/api/api/v1/media/:id/index.ts @@ -3,6 +3,7 @@ import { createRoute, z } from "@hono/zod-openapi"; import { Media } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import { ApiError } from "~/classes/errors/api-error"; +import { Attachment as AttachmentSchema } from "~/classes/schemas/attachment"; import { config } from "~/packages/config-manager/index.ts"; import { ErrorSchema } from "~/types/api"; @@ -46,7 +47,7 @@ const routePut = createRoute({ description: "Media updated", content: { "application/json": { - schema: Media.schema, + schema: AttachmentSchema, }, }, }, @@ -80,7 +81,7 @@ const routeGet = createRoute({ description: "Media", content: { "application/json": { - schema: Media.schema, + schema: AttachmentSchema, }, }, }, diff --git a/api/api/v1/media/index.ts b/api/api/v1/media/index.ts index 25b8b349..98485aca 100644 --- a/api/api/v1/media/index.ts +++ b/api/api/v1/media/index.ts @@ -2,6 +2,7 @@ import { apiRoute, auth } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { Media } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; +import { Attachment as AttachmentSchema } from "~/classes/schemas/attachment"; import { config } from "~/packages/config-manager/index.ts"; import { ErrorSchema } from "~/types/api"; @@ -42,7 +43,7 @@ const route = createRoute({ description: "Attachment", content: { "application/json": { - schema: Media.schema, + schema: AttachmentSchema, }, }, }, diff --git a/api/api/v1/notifications/:id/index.ts b/api/api/v1/notifications/:id/index.ts index 5a1166ba..7ae0a4e6 100644 --- a/api/api/v1/notifications/:id/index.ts +++ b/api/api/v1/notifications/:id/index.ts @@ -3,6 +3,7 @@ import { createRoute, z } from "@hono/zod-openapi"; import { Notification } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import { ApiError } from "~/classes/errors/api-error"; +import { Notification as NotificationSchema } from "~/classes/schemas/notification.ts"; import { ErrorSchema } from "~/types/api"; const route = createRoute({ @@ -26,7 +27,7 @@ const route = createRoute({ description: "Notification", content: { "application/json": { - schema: Notification.schema, + schema: NotificationSchema, }, }, }, diff --git a/api/api/v1/notifications/index.ts b/api/api/v1/notifications/index.ts index b2bfd987..88d766f4 100644 --- a/api/api/v1/notifications/index.ts +++ b/api/api/v1/notifications/index.ts @@ -1,72 +1,26 @@ import { apiRoute, auth } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; -import { Notification, Timeline } from "@versia/kit/db"; +import { Timeline } from "@versia/kit/db"; import { Notifications, RolePermissions } from "@versia/kit/tables"; import { and, eq, gt, gte, inArray, lt, not, sql } from "drizzle-orm"; +import { Account as AccountSchema } from "~/classes/schemas/account"; +import { Notification as NotificationSchema } from "~/classes/schemas/notification.ts"; const schemas = { query: z .object({ - max_id: z.string().uuid().optional(), - since_id: z.string().uuid().optional(), - min_id: z.string().uuid().optional(), + max_id: NotificationSchema.shape.id.optional(), + since_id: NotificationSchema.shape.id.optional(), + min_id: NotificationSchema.shape.id.optional(), limit: z.coerce.number().int().min(1).max(80).default(15), - exclude_types: z - .enum([ - "mention", - "status", - "follow", - "follow_request", - "reblog", - "poll", - "favourite", - "update", - "admin.sign_up", - "admin.report", - "chat", - "pleroma:chat_mention", - "pleroma:emoji_reaction", - "pleroma:event_reminder", - "pleroma:participation_request", - "pleroma:participation_accepted", - "move", - "group_reblog", - "group_favourite", - "user_approved", - ]) - .array() - .optional(), - types: z - .enum([ - "mention", - "status", - "follow", - "follow_request", - "reblog", - "poll", - "favourite", - "update", - "admin.sign_up", - "admin.report", - "chat", - "pleroma:chat_mention", - "pleroma:emoji_reaction", - "pleroma:event_reminder", - "pleroma:participation_request", - "pleroma:participation_accepted", - "move", - "group_reblog", - "group_favourite", - "user_approved", - ]) - .array() - .optional(), - account_id: z.string().uuid().optional(), + exclude_types: z.array(NotificationSchema.shape.type).optional(), + types: z.array(NotificationSchema.shape.type).optional(), + account_id: AccountSchema.shape.id.optional(), }) .refine((val) => { // Can't use both exclude_types and types return !(val.exclude_types && val.types); - }), + }, "Can't use both exclude_types and types"), }; const route = createRoute({ @@ -90,7 +44,7 @@ const route = createRoute({ description: "Notifications", content: { "application/json": { - schema: z.array(Notification.schema), + schema: z.array(NotificationSchema), }, }, }, diff --git a/api/api/v1/push/subscription/index.get.ts b/api/api/v1/push/subscription/index.get.ts index 33c8bd2b..157e36b5 100644 --- a/api/api/v1/push/subscription/index.get.ts +++ b/api/api/v1/push/subscription/index.get.ts @@ -2,6 +2,7 @@ import { apiRoute, auth } from "@/api"; import { createRoute } from "@hono/zod-openapi"; import { PushSubscription } from "@versia/kit/db"; import { ApiError } from "~/classes/errors/api-error"; +import { WebPushSubscription as WebPushSubscriptionSchema } from "~/classes/schemas/pushsubscription"; import { RolePermissions } from "~/drizzle/schema"; export default apiRoute((app) => @@ -27,7 +28,7 @@ export default apiRoute((app) => description: "WebPushSubscription", content: { "application/json": { - schema: PushSubscription.schema, + schema: WebPushSubscriptionSchema, }, }, }, diff --git a/api/api/v1/push/subscription/index.post.ts b/api/api/v1/push/subscription/index.post.ts index 926aae32..2f76301f 100644 --- a/api/api/v1/push/subscription/index.post.ts +++ b/api/api/v1/push/subscription/index.post.ts @@ -3,6 +3,7 @@ import { auth, jsonOrForm } from "@/api"; import { createRoute } from "@hono/zod-openapi"; import { PushSubscription } from "@versia/kit/db"; import { WebPushSubscriptionInput } from "~/classes/schemas/pushsubscription"; +import { WebPushSubscription as WebPushSubscriptionSchema } from "~/classes/schemas/pushsubscription"; import { RolePermissions } from "~/drizzle/schema"; export default apiRoute((app) => @@ -39,7 +40,7 @@ export default apiRoute((app) => "A new PushSubscription has been generated, which will send the requested alerts to your endpoint.", content: { "application/json": { - schema: PushSubscription.schema, + schema: WebPushSubscriptionSchema, }, }, }, diff --git a/api/api/v1/push/subscription/index.put.ts b/api/api/v1/push/subscription/index.put.ts index c9736c95..3cb454e4 100644 --- a/api/api/v1/push/subscription/index.put.ts +++ b/api/api/v1/push/subscription/index.put.ts @@ -2,7 +2,10 @@ import { apiRoute, auth, jsonOrForm } from "@/api"; import { createRoute } from "@hono/zod-openapi"; import { PushSubscription } from "@versia/kit/db"; import { ApiError } from "~/classes/errors/api-error"; -import { WebPushSubscriptionInput } from "~/classes/schemas/pushsubscription"; +import { + WebPushSubscriptionInput, + WebPushSubscription as WebPushSubscriptionSchema, +} from "~/classes/schemas/pushsubscription"; import { RolePermissions } from "~/drizzle/schema"; export default apiRoute((app) => @@ -41,7 +44,7 @@ export default apiRoute((app) => description: "The WebPushSubscription has been updated.", content: { "application/json": { - schema: PushSubscription.schema, + schema: WebPushSubscriptionSchema, }, }, }, diff --git a/api/api/v2/media/index.ts b/api/api/v2/media/index.ts index 48b0f838..44f06e05 100644 --- a/api/api/v2/media/index.ts +++ b/api/api/v2/media/index.ts @@ -2,6 +2,7 @@ import { apiRoute, auth } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { Media } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; +import { Attachment as AttachmentSchema } from "~/classes/schemas/attachment"; import { config } from "~/packages/config-manager/index.ts"; import { ErrorSchema } from "~/types/api"; @@ -42,7 +43,7 @@ const route = createRoute({ description: "Uploaded media", content: { "application/json": { - schema: Media.schema, + schema: AttachmentSchema, }, }, }, diff --git a/api/well-known/webfinger/index.ts b/api/well-known/webfinger/index.ts index 8cab7a37..c749c032 100644 --- a/api/well-known/webfinger/index.ts +++ b/api/well-known/webfinger/index.ts @@ -132,9 +132,10 @@ export default apiRoute((app) => }, { rel: "avatar", + // Default avatars are SVGs type: - user.data.source.avatar?.content_type || - "application/octet-stream", + user.avatar?.getPreferredMimeType() ?? + "image/svg+xml", href: user.getAvatarUrl(config), }, ].filter(Boolean) as { diff --git a/classes/database/media.ts b/classes/database/media.ts index 06a85903..715e51c8 100644 --- a/classes/database/media.ts +++ b/classes/database/media.ts @@ -1,8 +1,7 @@ import { join } from "node:path"; import { mimeLookup } from "@/content_types.ts"; import { proxyUrl } from "@/response"; -import { z } from "@hono/zod-openapi"; -import type { Attachment as ApiAttachment } from "@versia/client/types"; +import type { z } from "@hono/zod-openapi"; import type { ContentFormat } from "@versia/federation/types"; import { db } from "@versia/kit/db"; import { Medias } from "@versia/kit/tables"; @@ -16,6 +15,7 @@ import { inArray, } from "drizzle-orm"; import sharp from "sharp"; +import type { Attachment as AttachmentSchema } from "~/classes/schemas/attachment.ts"; import { MediaBackendType } from "~/packages/config-manager/config.type"; import { config } from "~/packages/config-manager/index.ts"; import { ApiError } from "../errors/api-error.ts"; @@ -26,34 +26,6 @@ import { BaseInterface } from "./base.ts"; type MediaType = InferSelectModel; export class Media extends BaseInterface { - public static schema: z.ZodType = z.object({ - id: z.string().uuid(), - type: z.enum(["unknown", "image", "gifv", "video", "audio"]), - url: z.string().url(), - remote_url: z.string().url().nullable(), - preview_url: z.string().url().nullable(), - text_url: z.string().url().nullable(), - meta: z - .object({ - width: z.number().optional(), - height: z.number().optional(), - fps: z.number().optional(), - size: z.string().optional(), - duration: z.number().optional(), - length: z.string().optional(), - aspect: z.number().optional(), - original: z.object({ - width: z.number().optional(), - height: z.number().optional(), - size: z.string().optional(), - aspect: z.number().optional(), - }), - }) - .nullable(), - description: z.string().nullable(), - blurhash: z.string().nullable(), - }); - public static $type: MediaType; public async reload(): Promise { @@ -446,7 +418,7 @@ export class Media extends BaseInterface { * * @returns */ - public getMastodonType(): ApiAttachment["type"] { + public getMastodonType(): z.infer { const type = this.getPreferredMimeType(); if (type.startsWith("image/")) { @@ -500,7 +472,7 @@ export class Media extends BaseInterface { }; } - public toApiMeta(): ApiAttachment["meta"] { + public toApiMeta(): z.infer { const type = this.getPreferredMimeType(); const data = this.data.content[type]; const size = @@ -529,7 +501,7 @@ export class Media extends BaseInterface { }; } - public toApi(): ApiAttachment { + public toApi(): z.infer { const type = this.getPreferredMimeType(); const data = this.data.content[type]; @@ -545,7 +517,6 @@ export class Media extends BaseInterface { preview_url: thumbnailData?.content ? proxyUrl(new URL(thumbnailData.content)).toString() : null, - text_url: null, meta: this.toApiMeta(), description: data.description || null, blurhash: this.data.blurhash, diff --git a/classes/database/note.ts b/classes/database/note.ts index ac9710ed..1edabc7d 100644 --- a/classes/database/note.ts +++ b/classes/database/note.ts @@ -5,10 +5,6 @@ import { sanitizedHtmlStrip } from "@/sanitization"; import { sentry } from "@/sentry"; import type { z } from "@hono/zod-openapi"; import { getLogger } from "@logtape/logtape"; -import type { - Attachment as ApiAttachment, - Status as ApiStatus, -} from "@versia/client/types"; import { EntityValidator } from "@versia/federation"; import type { ContentFormat, @@ -41,6 +37,7 @@ import { findManyNotes, parseTextMentions, } from "~/classes/functions/status"; +import type { Status as StatusSchema } from "~/classes/schemas/status.ts"; import { config } from "~/packages/config-manager"; import { DeliveryJobType, deliveryQueue } from "../queues/delivery.ts"; import type { Status } from "../schemas/status.ts"; @@ -346,7 +343,7 @@ export class Note extends BaseInterface { public static async fromData(data: { author: User; content: ContentFormat; - visibility: ApiStatus["visibility"]; + visibility: z.infer; isSensitive: boolean; spoilerText: string; emojis?: Emoji[]; @@ -420,7 +417,7 @@ export class Note extends BaseInterface { public async updateFromData(data: { author: User; content?: ContentFormat; - visibility?: ApiStatus["visibility"]; + visibility?: z.infer; isSensitive?: boolean; spoilerText?: string; emojis?: Emoji[]; @@ -677,7 +674,7 @@ export class Note extends BaseInterface { remote: false, }, }, - visibility: visibility as ApiStatus["visibility"], + visibility, isSensitive: note.is_sensitive ?? false, spoilerText: note.subject ?? "", emojis, @@ -811,8 +808,8 @@ export class Note extends BaseInterface { emojis: data.emojis.map((emoji) => new Emoji(emoji).toApi()), favourited: data.liked, favourites_count: data.likeCount, - media_attachments: (data.attachments ?? []).map( - (a) => new Media(a).toApi() as ApiAttachment, + media_attachments: (data.attachments ?? []).map((a) => + new Media(a).toApi(), ), mentions: data.mentions.map((mention) => ({ id: mention.id, @@ -844,7 +841,7 @@ export class Note extends BaseInterface { spoiler_text: data.spoilerText, tags: [], uri: data.uri || this.getUri().toString(), - visibility: data.visibility as ApiStatus["visibility"], + visibility: data.visibility, url: data.uri || this.getMastoUri().toString(), bookmarked: false, quote: data.quotingId diff --git a/classes/database/notification.ts b/classes/database/notification.ts index 8e9b8c21..8a706ef3 100644 --- a/classes/database/notification.ts +++ b/classes/database/notification.ts @@ -1,5 +1,4 @@ -import { z } from "@hono/zod-openapi"; -import type { Notification as APINotification } from "@versia/client/types"; +import type { z } from "@hono/zod-openapi"; import { Note, User, db } from "@versia/kit/db"; import { Notifications } from "@versia/kit/tables"; import { @@ -10,13 +9,12 @@ import { eq, inArray, } from "drizzle-orm"; +import type { Notification as NotificationSchema } from "~/classes/schemas/notification.ts"; import { transformOutputToUserWithRelations, userExtrasTemplate, 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 & { @@ -28,37 +26,6 @@ export class Notification extends BaseInterface< typeof Notifications, NotificationType > { - public static schema: z.ZodType = z.object({ - account: Account.nullable(), - created_at: z.string(), - id: z.string().uuid(), - status: Status.optional(), - // TODO: Add reactions - type: z.enum([ - "mention", - "status", - "follow", - "follow_request", - "reblog", - "poll", - "favourite", - "update", - "admin.sign_up", - "admin.report", - "chat", - "pleroma:chat_mention", - "pleroma:emoji_reaction", - "pleroma:event_reminder", - "pleroma:participation_request", - "pleroma:participation_accepted", - "move", - "group_reblog", - "group_favourite", - "user_approved", - ]), - target: Account.optional(), - }); - public async reload(): Promise { const reloaded = await Notification.fromId(this.data.id); @@ -215,7 +182,7 @@ export class Notification extends BaseInterface< return this.data.id; } - public async toApi(): Promise { + public async toApi(): Promise> { const account = new User(this.data.account); return { @@ -226,6 +193,7 @@ export class Notification extends BaseInterface< status: this.data.status ? await new Note(this.data.status).toApi(account) : undefined, + group_key: `ungrouped-${this.data.id}`, }; } } diff --git a/classes/database/pushsubscription.ts b/classes/database/pushsubscription.ts index 336b39f0..b8cf4833 100644 --- a/classes/database/pushsubscription.ts +++ b/classes/database/pushsubscription.ts @@ -1,4 +1,3 @@ -import { z } from "@hono/zod-openapi"; import type { Alerts, PushSubscription as ApiPushSubscription, @@ -21,85 +20,6 @@ export class PushSubscription extends BaseInterface< typeof PushSubscriptions, PushSubscriptionType > { - public static schema = z.object({ - id: z.string().uuid().openapi({ - example: "24eb1891-accc-43b4-b213-478e37d525b4", - description: "The ID of the Web Push subscription in the database.", - }), - endpoint: z.string().url().openapi({ - example: "https://yourdomain.example/listener", - description: "Where push alerts will be sent to.", - }), - alerts: z - .object({ - mention: z.boolean().optional().openapi({ - example: true, - description: "Receive mention notifications?", - }), - favourite: z.boolean().optional().openapi({ - example: true, - description: "Receive favourite notifications?", - }), - reblog: z.boolean().optional().openapi({ - example: true, - description: "Receive reblog notifications?", - }), - follow: z.boolean().optional().openapi({ - example: true, - description: "Receive follow notifications?", - }), - poll: z.boolean().optional().openapi({ - example: false, - description: "Receive poll notifications?", - }), - follow_request: z.boolean().optional().openapi({ - example: false, - description: "Receive follow request notifications?", - }), - status: z.boolean().optional().openapi({ - example: false, - description: - "Receive new subscribed account notifications?", - }), - update: z.boolean().optional().openapi({ - example: false, - description: "Receive status edited notifications?", - }), - "admin.sign_up": z.boolean().optional().openapi({ - example: false, - description: - "Receive new user signup notifications? Must have a role with the appropriate permissions.", - }), - "admin.report": z.boolean().optional().openapi({ - example: false, - description: - "Receive new report notifications? Must have a role with the appropriate permissions.", - }), - }) - .default({}) - .openapi({ - example: { - mention: true, - favourite: true, - reblog: true, - follow: true, - poll: false, - follow_request: false, - status: false, - update: false, - "admin.sign_up": false, - "admin.report": false, - }, - description: - "Which alerts should be delivered to the endpoint.", - }), - server_key: z.string().openapi({ - example: - "BCk-QqERU0q-CfYZjcuB6lnyyOYfJ2AifKqfeGIm7Z-HiTU5T9eTG5GxVA0_OH5mMlI4UkkDTpaZwozy0TzdZ2M=", - description: "The streaming server’s VAPID key.", - }), - }); - public static $type: PushSubscriptionType; public async reload(): Promise { diff --git a/classes/database/reaction.ts b/classes/database/reaction.ts index 66226651..41e07acf 100644 --- a/classes/database/reaction.ts +++ b/classes/database/reaction.ts @@ -1,5 +1,3 @@ -import { z } from "@hono/zod-openapi"; -import type { Emoji as APIEmoji } from "@versia/client/types"; import type { ReactionExtension } from "@versia/federation/types"; import { Emoji, Instance, Note, User, db } from "@versia/kit/db"; import { type Notes, Reactions, type Users } from "@versia/kit/tables"; @@ -12,7 +10,6 @@ import { inArray, } from "drizzle-orm"; import { config } from "~/packages/config-manager/index.ts"; -import { CustomEmoji } from "../schemas/emoji.ts"; import { BaseInterface } from "./base.ts"; type ReactionType = InferSelectModel & { @@ -21,19 +18,7 @@ type ReactionType = InferSelectModel & { note: InferSelectModel; }; -export interface APIReaction { - id: string; - author_id: string; - emoji: APIEmoji | string; -} - export class Reaction extends BaseInterface { - public static schema: z.ZodType = z.object({ - id: z.string().uuid(), - author_id: z.string().uuid(), - emoji: CustomEmoji, - }); - public static $type: ReactionType; public async reload(): Promise { diff --git a/classes/database/relationship.ts b/classes/database/relationship.ts index 1a431346..48827df1 100644 --- a/classes/database/relationship.ts +++ b/classes/database/relationship.ts @@ -1,5 +1,4 @@ import { z } from "@hono/zod-openapi"; -import type { Relationship as APIRelationship } from "@versia/client/types"; import { db } from "@versia/kit/db"; import { Relationships } from "@versia/kit/tables"; import { @@ -11,6 +10,7 @@ import { eq, inArray, } from "drizzle-orm"; +import type { Relationship as RelationshipSchema } from "~/classes/schemas/relationship"; import { BaseInterface } from "./base.ts"; import type { User } from "./user.ts"; @@ -292,7 +292,7 @@ export class Relationship extends BaseInterface< return this.data.id; } - public toApi(): APIRelationship { + public toApi(): z.infer { return { id: this.data.subjectId, blocked_by: this.data.blockedBy, @@ -308,6 +308,7 @@ export class Relationship extends BaseInterface< requested_by: this.data.requestedBy, requested: this.data.requested, showing_reblogs: this.data.showingReblogs, + languages: this.data.languages ?? [], }; } } diff --git a/classes/schemas/pushsubscription.ts b/classes/schemas/pushsubscription.ts index 0d8b565c..0e58643d 100644 --- a/classes/schemas/pushsubscription.ts +++ b/classes/schemas/pushsubscription.ts @@ -1,5 +1,86 @@ import { z } from "@hono/zod-openapi"; -import { PushSubscription } from "@versia/kit/db"; +import { Id } from "./common.ts"; + +export const WebPushSubscription = z + .object({ + id: Id.openapi({ + example: "24eb1891-accc-43b4-b213-478e37d525b4", + description: "The ID of the Web Push subscription in the database.", + }), + endpoint: z.string().url().openapi({ + example: "https://yourdomain.example/listener", + description: "Where push alerts will be sent to.", + }), + alerts: z + .object({ + mention: z.boolean().optional().openapi({ + example: true, + description: "Receive mention notifications?", + }), + favourite: z.boolean().optional().openapi({ + example: true, + description: "Receive favourite notifications?", + }), + reblog: z.boolean().optional().openapi({ + example: true, + description: "Receive reblog notifications?", + }), + follow: z.boolean().optional().openapi({ + example: true, + description: "Receive follow notifications?", + }), + poll: z.boolean().optional().openapi({ + example: false, + description: "Receive poll notifications?", + }), + follow_request: z.boolean().optional().openapi({ + example: false, + description: "Receive follow request notifications?", + }), + status: z.boolean().optional().openapi({ + example: false, + description: + "Receive new subscribed account notifications?", + }), + update: z.boolean().optional().openapi({ + example: false, + description: "Receive status edited notifications?", + }), + "admin.sign_up": z.boolean().optional().openapi({ + example: false, + description: + "Receive new user signup notifications? Must have a role with the appropriate permissions.", + }), + "admin.report": z.boolean().optional().openapi({ + example: false, + description: + "Receive new report notifications? Must have a role with the appropriate permissions.", + }), + }) + .default({}) + .openapi({ + example: { + mention: true, + favourite: true, + reblog: true, + follow: true, + poll: false, + follow_request: false, + status: false, + update: false, + "admin.sign_up": false, + "admin.report": false, + }, + description: + "Which alerts should be delivered to the endpoint.", + }), + server_key: z.string().openapi({ + example: + "BCk-QqERU0q-CfYZjcuB6lnyyOYfJ2AifKqfeGIm7Z-HiTU5T9eTG5GxVA0_OH5mMlI4UkkDTpaZwozy0TzdZ2M=", + description: "The streaming server’s VAPID key.", + }), + }) + .openapi({}); export const WebPushSubscriptionInput = z .object({ @@ -33,7 +114,7 @@ export const WebPushSubscriptionInput = z }), data: z .object({ - alerts: PushSubscription.schema.shape.alerts, + alerts: WebPushSubscription.shape.alerts, }) .strict() .default({ diff --git a/classes/schemas/status.ts b/classes/schemas/status.ts index 88ff73cd..9b3275ed 100644 --- a/classes/schemas/status.ts +++ b/classes/schemas/status.ts @@ -1,8 +1,8 @@ import { z } from "@hono/zod-openapi"; import type { Status as ApiNote } from "@versia/client/types"; -import { Media } from "@versia/kit/db"; import { zBoolean } from "~/packages/config-manager/config.type.ts"; import { Account } from "./account.ts"; +import { Attachment } from "./attachment.ts"; import { PreviewCard } from "./card.ts"; import { Id, iso631 } from "./common.ts"; import { CustomEmoji } from "./emoji.ts"; @@ -223,7 +223,7 @@ export const Status = z.object({ url: "https://docs.joinmastodon.org/entities/Status/#visibility", }, }), - media_attachments: z.array(Media.schema).openapi({ + media_attachments: z.array(Attachment).openapi({ description: "Media that is attached to this status.", externalDocs: { url: "https://docs.joinmastodon.org/entities/Status/#media_attachments", diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 8847905d..14e548cf 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -15,6 +15,8 @@ import { uuid, } from "drizzle-orm/pg-core"; import type { Source } from "~/classes/schemas/account"; +import type { Notification as NotificationSchema } from "~/classes/schemas/notification.ts"; +import type { Status as StatusSchema } from "~/classes/schemas/status.ts"; // biome-ignore lint/nursery/useExplicitType: Type is too complex const createdAt = () => @@ -376,7 +378,9 @@ export const MediasRelations = relations(Medias, ({ many }) => ({ export const Notifications = pgTable("Notifications", { id: id(), - type: text("type").notNull(), + type: text("type") + .$type>() + .notNull(), createdAt: createdAt(), notifiedId: uuid("notifiedId") .notNull() @@ -431,7 +435,9 @@ export const Notes = pgTable("Notes", { }), content: text("content").default("").notNull(), contentType: text("content_type").default("text/plain").notNull(), - visibility: text("visibility").notNull(), + visibility: text("visibility") + .$type>() + .notNull(), replyId: uuid("replyId").references((): AnyPgColumn => Notes.id, { onDelete: "cascade", onUpdate: "cascade", From a0ce18337a28081a8a4158b5903942f7ca79a346 Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Wed, 12 Feb 2025 23:33:07 +0100 Subject: [PATCH 06/11] refactor(api): :label: Use more new schemas --- api/api/v1/markers/index.ts | 41 ++++++++++------------------ classes/database/note.ts | 2 ++ classes/database/pushsubscription.ts | 10 +++---- classes/database/token.ts | 13 ++------- classes/database/user.ts | 4 +-- classes/schemas/status.ts | 2 ++ 6 files changed, 27 insertions(+), 45 deletions(-) diff --git a/api/api/v1/markers/index.ts b/api/api/v1/markers/index.ts index 864095bb..162dadf1 100644 --- a/api/api/v1/markers/index.ts +++ b/api/api/v1/markers/index.ts @@ -1,30 +1,16 @@ import { apiRoute, auth } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; -import type { Marker as ApiMarker } from "@versia/client/types"; import { db } from "@versia/kit/db"; import { Markers, RolePermissions } from "@versia/kit/tables"; import { type SQL, and, eq } from "drizzle-orm"; +import { Marker as MarkerSchema } from "~/classes/schemas/marker"; +import { Notification as NotificationSchema } from "~/classes/schemas/notification"; +import { Status as StatusSchema } from "~/classes/schemas/status"; -const schemas = { - markers: z.object({ - home: z - .object({ - last_read_id: z.string().uuid(), - version: z.number(), - updated_at: z.string(), - }) - .nullable() - .optional(), - notifications: z - .object({ - last_read_id: z.string().uuid(), - version: z.number(), - updated_at: z.string(), - }) - .nullable() - .optional(), - }), -}; +const MarkerResponseSchema = z.object({ + notifications: MarkerSchema.optional(), + home: MarkerSchema.optional(), +}); const routeGet = createRoute({ method: "get", @@ -50,7 +36,7 @@ const routeGet = createRoute({ description: "Markers", content: { "application/json": { - schema: schemas.markers, + schema: MarkerResponseSchema, }, }, }, @@ -69,8 +55,9 @@ const routePost = createRoute({ ] as const, request: { query: z.object({ - "home[last_read_id]": z.string().uuid().optional(), - "notifications[last_read_id]": z.string().uuid().optional(), + "home[last_read_id]": StatusSchema.shape.id.optional(), + "notifications[last_read_id]": + NotificationSchema.shape.id.optional(), }), }, responses: { @@ -78,7 +65,7 @@ const routePost = createRoute({ description: "Markers", content: { "application/json": { - schema: schemas.markers, + schema: MarkerResponseSchema, }, }, }, @@ -96,7 +83,7 @@ export default apiRoute((app) => { return context.json({}, 200); } - const markers: ApiMarker = { + const markers: z.infer = { home: undefined, notifications: undefined, }; @@ -160,7 +147,7 @@ export default apiRoute((app) => { } = context.req.valid("query"); const { user } = context.get("auth"); - const markers: ApiMarker = { + const markers: z.infer = { home: undefined, notifications: undefined, }; diff --git a/classes/database/note.ts b/classes/database/note.ts index 1edabc7d..929df1ae 100644 --- a/classes/database/note.ts +++ b/classes/database/note.ts @@ -829,6 +829,7 @@ export class Note extends BaseInterface { pinned: data.pinned, // TODO: Add polls poll: null, + // @ts-expect-error broken recursive types reblog: data.reblog ? await new Note(data.reblog as NoteTypeWithRelations).toApi( userFetching, @@ -844,6 +845,7 @@ export class Note extends BaseInterface { visibility: data.visibility, url: data.uri || this.getMastoUri().toString(), bookmarked: false, + // @ts-expect-error broken recursive types quote: data.quotingId ? ((await Note.fromId(data.quotingId, userFetching?.id).then( (n) => n?.toApi(userFetching), diff --git a/classes/database/pushsubscription.ts b/classes/database/pushsubscription.ts index b8cf4833..218a1bc4 100644 --- a/classes/database/pushsubscription.ts +++ b/classes/database/pushsubscription.ts @@ -1,7 +1,4 @@ -import type { - Alerts, - PushSubscription as ApiPushSubscription, -} from "@versia/client/types"; +import type { z } from "@hono/zod-openapi"; import { type Token, type User, db } from "@versia/kit/db"; import { PushSubscriptions, Tokens } from "@versia/kit/tables"; import { @@ -12,6 +9,7 @@ import { eq, inArray, } from "drizzle-orm"; +import type { WebPushSubscription as WebPushSubscriptionSchema } from "../schemas/pushsubscription.ts"; import { BaseInterface } from "./base.ts"; type PushSubscriptionType = InferSelectModel; @@ -165,7 +163,7 @@ export class PushSubscription extends BaseInterface< return this.data.id; } - public getAlerts(): Alerts { + public getAlerts(): z.infer { return { mention: this.data.alerts.mention ?? false, favourite: this.data.alerts.favourite ?? false, @@ -180,7 +178,7 @@ export class PushSubscription extends BaseInterface< }; } - public toApi(): ApiPushSubscription { + public toApi(): z.infer { return { id: this.data.id, alerts: this.getAlerts(), diff --git a/classes/database/token.ts b/classes/database/token.ts index a4b433af..c1fdadab 100644 --- a/classes/database/token.ts +++ b/classes/database/token.ts @@ -1,5 +1,4 @@ -import { z } from "@hono/zod-openapi"; -import type { Token as ApiToken } from "@versia/client/types"; +import type { z } from "@hono/zod-openapi"; import { type Application, User, db } from "@versia/kit/db"; import { Tokens } from "@versia/kit/tables"; import { @@ -10,6 +9,7 @@ import { eq, inArray, } from "drizzle-orm"; +import type { Token as TokenSchema } from "../schemas/token.ts"; import { BaseInterface } from "./base.ts"; type TokenType = InferSelectModel & { @@ -17,13 +17,6 @@ type TokenType = InferSelectModel & { }; export class Token extends BaseInterface { - public static schema: z.ZodType = z.object({ - access_token: z.string(), - token_type: z.enum(["bearer"]), - scope: z.string(), - created_at: z.number(), - }); - public static $type: TokenType; public async reload(): Promise { @@ -160,7 +153,7 @@ export class Token extends BaseInterface { return await User.fromId(this.data.userId); } - public toApi(): ApiToken { + public toApi(): z.infer { return { access_token: this.data.accessToken, token_type: "Bearer", diff --git a/classes/database/user.ts b/classes/database/user.ts index 36709e6a..6d403179 100644 --- a/classes/database/user.ts +++ b/classes/database/user.ts @@ -5,7 +5,6 @@ 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 { EntityValidator, FederationRequester, @@ -53,6 +52,7 @@ import type { KnownEntity } from "~/types/api.ts"; import { DeliveryJobType, deliveryQueue } from "../queues/delivery.ts"; import { PushJobType, pushQueue } from "../queues/push.ts"; import type { Account, Source } from "../schemas/account.ts"; +import type { Mention as MentionSchema } from "../schemas/status.ts"; import { BaseInterface } from "./base.ts"; import { Emoji } from "./emoji.ts"; import { Instance } from "./instance.ts"; @@ -1260,7 +1260,7 @@ export class User extends BaseInterface { }; } - public toMention(): ApiMention { + public toMention(): z.infer { return { url: this.getUri().toString(), username: this.data.username, diff --git a/classes/schemas/status.ts b/classes/schemas/status.ts index 9b3275ed..a8b32971 100644 --- a/classes/schemas/status.ts +++ b/classes/schemas/status.ts @@ -102,6 +102,7 @@ export const Status = z.object({ }, }), reblog: z + // @ts-expect-error broken recursive types .lazy((): z.ZodType => Status as z.ZodType) .nullable() .openapi({ @@ -308,6 +309,7 @@ export const Status = z.object({ }), reactions: z.array(NoteReaction).openapi({}), quote: z + // @ts-expect-error broken recursive types .lazy((): z.ZodType => Status as z.ZodType) .nullable(), bookmarked: zBoolean.optional().openapi({ From e3e285571ec17988aee9592467dfbf9c7fa29b05 Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Thu, 13 Feb 2025 01:31:15 +0100 Subject: [PATCH 07/11] refactor(api): :label: Port all /api/v1/accounts to use new schemas --- api/api/auth/login/index.ts | 2 +- api/api/v1/accounts/:id/block.ts | 25 ++- api/api/v1/accounts/:id/follow.ts | 72 ++++-- api/api/v1/accounts/:id/followers.ts | 75 +++++-- api/api/v1/accounts/:id/following.ts | 76 +++++-- api/api/v1/accounts/:id/index.ts | 24 +- api/api/v1/accounts/:id/mute.ts | 61 +++-- api/api/v1/accounts/:id/note.ts | 45 ++-- api/api/v1/accounts/:id/pin.ts | 12 +- api/api/v1/accounts/:id/refetch.ts | 20 +- .../v1/accounts/:id/remove_from_followers.ts | 24 +- .../v1/accounts/:id/roles/:role_id/index.ts | 22 +- api/api/v1/accounts/:id/roles/index.ts | 6 +- api/api/v1/accounts/:id/statuses.ts | 110 ++++----- api/api/v1/accounts/:id/unblock.ts | 24 +- api/api/v1/accounts/:id/unfollow.ts | 33 +-- api/api/v1/accounts/:id/unmute.ts | 23 +- api/api/v1/accounts/:id/unpin.ts | 24 +- .../v1/accounts/familiar_followers/index.ts | 44 ++-- api/api/v1/accounts/id/index.test.ts | 2 +- api/api/v1/accounts/id/index.ts | 27 +-- api/api/v1/accounts/index.ts | 71 ++++-- api/api/v1/accounts/lookup/index.test.ts | 15 +- api/api/v1/accounts/lookup/index.ts | 52 ++--- api/api/v1/accounts/relationships/index.ts | 43 +++- api/api/v1/accounts/search/index.ts | 57 +++-- .../v1/accounts/update_credentials/index.ts | 208 +++++++++++------- .../v1/accounts/verify_credentials/index.ts | 15 +- api/api/v1/statuses/index.test.ts | 2 +- api/api/v2/instance/index.ts | 2 +- api/objects/:id/index.ts | 2 +- api/well-known/openid-configuration/index.ts | 2 +- api/well-known/versia.ts | 2 +- api/well-known/webfinger/index.ts | 4 +- classes/database/note.ts | 4 +- classes/functions/status.ts | 4 +- classes/schemas/account.ts | 23 +- classes/schemas/relationship.ts | 2 +- middlewares/url-check.ts | 2 +- plugins/openid/routes/authorize.test.ts | 16 +- tests/api.test.ts | 2 +- tests/api/accounts.test.ts | 2 +- tests/utils.ts | 4 +- utils/api.ts | 30 ++- 44 files changed, 840 insertions(+), 475 deletions(-) diff --git a/api/api/auth/login/index.ts b/api/api/auth/login/index.ts index 5458724b..c3c46202 100644 --- a/api/api/auth/login/index.ts +++ b/api/api/auth/login/index.ts @@ -180,7 +180,7 @@ export default apiRoute((app) => // Generate JWT const jwt = await new SignJWT({ sub: user.id, - iss: new URL(config.http.base_url).origin, + iss: config.http.base_url.origin, aud: client_id, exp: Math.floor(Date.now() / 1000) + 60 * 60, iat: Math.floor(Date.now() / 1000), diff --git a/api/api/v1/accounts/:id/block.ts b/api/api/v1/accounts/:id/block.ts index b82ef2c6..31d655b6 100644 --- a/api/api/v1/accounts/:id/block.ts +++ b/api/api/v1/accounts/:id/block.ts @@ -1,14 +1,26 @@ -import { apiRoute, auth, withUserParam } from "@/api"; +import { + accountNotFound, + apiRoute, + auth, + reusedResponses, + withUserParam, +} from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { Relationship } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; +import { Account as AccountSchema } from "~/classes/schemas/account"; import { Relationship as RelationshipSchema } from "~/classes/schemas/relationship"; const route = createRoute({ method: "post", path: "/api/v1/accounts/{id}/block", - summary: "Block user", - description: "Block a user", + summary: "Block account", + description: + "Block the given account. Clients should filter statuses from this account if received (e.g. due to a boost in the Home timeline)", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/accounts/#block", + }, + tags: ["Accounts"], middleware: [ auth({ auth: true, @@ -22,17 +34,20 @@ const route = createRoute({ ] as const, responses: { 200: { - description: "Updated relationship", + description: + "Successfully blocked, or account was already blocked.", content: { "application/json": { schema: RelationshipSchema, }, }, }, + 404: accountNotFound, + ...reusedResponses, }, request: { params: z.object({ - id: z.string().uuid(), + id: AccountSchema.shape.id, }), }, }); diff --git a/api/api/v1/accounts/:id/follow.ts b/api/api/v1/accounts/:id/follow.ts index ed50a53c..2c3e5424 100644 --- a/api/api/v1/accounts/:id/follow.ts +++ b/api/api/v1/accounts/:id/follow.ts @@ -1,29 +1,28 @@ -import { apiRoute, auth, withUserParam } from "@/api"; +import { + accountNotFound, + apiRoute, + auth, + reusedResponses, + withUserParam, +} from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { Relationship } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; +import { Account as AccountSchema } from "~/classes/schemas/account"; import { iso631 } from "~/classes/schemas/common"; import { Relationship as RelationshipSchema } from "~/classes/schemas/relationship"; - -const schemas = { - param: z.object({ - id: z.string().uuid(), - }), - json: z - .object({ - reblogs: z.coerce.boolean().optional(), - notify: z.coerce.boolean().optional(), - languages: z.array(iso631).optional(), - }) - .optional() - .default({ reblogs: true, notify: false, languages: [] }), -}; +import { ErrorSchema } from "~/types/api"; const route = createRoute({ method: "post", path: "/api/v1/accounts/{id}/follow", - summary: "Follow user", - description: "Follow a user", + summary: "Follow account", + description: + "Follow the given account. Can also be used to update whether to show reblogs or enable notifications.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/accounts/#follow", + }, + tags: ["Accounts"], middleware: [ auth({ auth: true, @@ -37,20 +36,53 @@ const route = createRoute({ ] as const, responses: { 200: { - description: "Updated relationship", + description: + "Successfully followed, or account was already followed", content: { "application/json": { schema: RelationshipSchema, }, }, }, + 403: { + description: + "Trying to follow someone that you block or that blocks you", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 404: accountNotFound, + ...reusedResponses, }, request: { - params: schemas.param, + params: z.object({ + id: AccountSchema.shape.id, + }), body: { content: { "application/json": { - schema: schemas.json, + schema: z.object({ + reblogs: z.boolean().default(true).openapi({ + description: + "Receive this account’s reblogs in home timeline?", + example: true, + }), + notify: z.boolean().default(false).openapi({ + description: + "Receive notifications when this account posts a status?", + example: false, + }), + languages: z + .array(iso631) + .default([]) + .openapi({ + description: + "Array of String (ISO 639-1 language two-letter code). Filter received statuses for these languages. If not provided, you will receive this account’s posts in all languages.", + example: ["en", "fr"], + }), + }), }, }, }, diff --git a/api/api/v1/accounts/:id/followers.ts b/api/api/v1/accounts/:id/followers.ts index 8a84dc45..f5ac44c8 100644 --- a/api/api/v1/accounts/:id/followers.ts +++ b/api/api/v1/accounts/:id/followers.ts @@ -1,28 +1,27 @@ -import { apiRoute, auth, withUserParam } from "@/api"; +import { + accountNotFound, + apiRoute, + auth, + reusedResponses, + withUserParam, +} from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { Timeline } from "@versia/kit/db"; import { RolePermissions, Users } from "@versia/kit/tables"; import { and, gt, gte, lt, sql } from "drizzle-orm"; import { Account } from "~/classes/schemas/account"; - -const schemas = { - query: z.object({ - max_id: z.string().uuid().optional(), - since_id: z.string().uuid().optional(), - min_id: z.string().uuid().optional(), - limit: z.coerce.number().int().min(1).max(40).optional().default(20), - }), - param: z.object({ - id: z.string().uuid(), - }), -}; +import { Account as AccountSchema } from "~/classes/schemas/account"; const route = createRoute({ method: "get", path: "/api/v1/accounts/{id}/followers", - summary: "Get account followers", + summary: "Get account’s followers", description: - "Gets an paginated list of accounts that follow the specified account", + "Accounts which follow the given account, if network is not hidden by the account owner.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/accounts/#followers", + }, + tags: ["Accounts"], middleware: [ auth({ auth: false, @@ -35,23 +34,53 @@ const route = createRoute({ withUserParam, ] as const, request: { - params: schemas.param, - query: schemas.query, + params: z.object({ + id: AccountSchema.shape.id, + }), + query: z.object({ + max_id: AccountSchema.shape.id.optional().openapi({ + description: + "All results returned will be lesser than this ID. In effect, sets an upper bound on results.", + example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa", + }), + since_id: AccountSchema.shape.id.optional().openapi({ + description: + "All results returned will be greater than this ID. In effect, sets a lower bound on results.", + example: undefined, + }), + min_id: AccountSchema.shape.id.optional().openapi({ + description: + "Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.", + example: undefined, + }), + limit: z.number().int().min(1).max(40).default(20).openapi({ + description: "Maximum number of results to return.", + }), + }), }, responses: { 200: { - description: "A list of accounts that follow the specified account", + description: "Accounts which follow the given account.", content: { "application/json": { schema: z.array(Account), }, }, - headers: { - Link: { - description: "Links to the next and previous pages", - }, - }, + headers: z.object({ + link: z + .string() + .optional() + .openapi({ + description: "Links to the next and previous pages", + example: `; rel="next", ; rel="prev"`, + externalDocs: { + url: "https://docs.joinmastodon.org/api/guidelines/#pagination", + }, + }), + }), }, + 404: accountNotFound, + 422: reusedResponses[422], }, }); diff --git a/api/api/v1/accounts/:id/following.ts b/api/api/v1/accounts/:id/following.ts index 409f5286..d58ca156 100644 --- a/api/api/v1/accounts/:id/following.ts +++ b/api/api/v1/accounts/:id/following.ts @@ -1,28 +1,27 @@ -import { apiRoute, auth, withUserParam } from "@/api"; +import { + accountNotFound, + apiRoute, + auth, + reusedResponses, + withUserParam, +} from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { Timeline } from "@versia/kit/db"; import { RolePermissions, Users } from "@versia/kit/tables"; import { and, gt, gte, lt, sql } from "drizzle-orm"; import { Account } from "~/classes/schemas/account"; - -const schemas = { - query: z.object({ - max_id: z.string().uuid().optional(), - since_id: z.string().uuid().optional(), - min_id: z.string().uuid().optional(), - limit: z.coerce.number().int().min(1).max(40).optional().default(20), - }), - param: z.object({ - id: z.string().uuid(), - }), -}; +import { Account as AccountSchema } from "~/classes/schemas/account"; const route = createRoute({ method: "get", path: "/api/v1/accounts/{id}/following", - summary: "Get account following", + summary: "Get account’s following", description: - "Gets an paginated list of accounts that the specified account follows", + "Accounts which the given account is following, if network is not hidden by the account owner.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/accounts/#following", + }, + tags: ["Accounts"], middleware: [ auth({ auth: false, @@ -35,24 +34,53 @@ const route = createRoute({ withUserParam, ] as const, request: { - params: schemas.param, - query: schemas.query, + params: z.object({ + id: AccountSchema.shape.id, + }), + query: z.object({ + max_id: AccountSchema.shape.id.optional().openapi({ + description: + "All results returned will be lesser than this ID. In effect, sets an upper bound on results.", + example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa", + }), + since_id: AccountSchema.shape.id.optional().openapi({ + description: + "All results returned will be greater than this ID. In effect, sets a lower bound on results.", + example: undefined, + }), + min_id: AccountSchema.shape.id.optional().openapi({ + description: + "Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.", + example: undefined, + }), + limit: z.number().int().min(1).max(40).default(20).openapi({ + description: "Maximum number of results to return.", + }), + }), }, responses: { 200: { - description: - "A list of accounts that the specified account follows", + description: "Accounts which the given account is following.", content: { "application/json": { schema: z.array(Account), }, }, - headers: { - Link: { - description: "Link to the next page of results", - }, - }, + headers: z.object({ + link: z + .string() + .optional() + .openapi({ + description: "Links to the next and previous pages", + example: `; rel="next", ; rel="prev"`, + externalDocs: { + url: "https://docs.joinmastodon.org/api/guidelines/#pagination", + }, + }), + }), }, + 404: accountNotFound, + 422: reusedResponses[422], }, }); diff --git a/api/api/v1/accounts/:id/index.ts b/api/api/v1/accounts/:id/index.ts index 1f66730f..c770c756 100644 --- a/api/api/v1/accounts/:id/index.ts +++ b/api/api/v1/accounts/:id/index.ts @@ -1,13 +1,24 @@ -import { apiRoute, auth, withUserParam } from "@/api"; +import { + accountNotFound, + apiRoute, + auth, + reusedResponses, + withUserParam, +} from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { RolePermissions } from "@versia/kit/tables"; import { Account } from "~/classes/schemas/account"; +import { Account as AccountSchema } from "~/classes/schemas/account"; const route = createRoute({ method: "get", path: "/api/v1/accounts/{id}", - summary: "Get account data", - description: "Gets the specified account data", + summary: "Get account", + description: "View information about a profile.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/accounts/#get", + }, + tags: ["Accounts"], middleware: [ auth({ auth: false, @@ -17,18 +28,21 @@ const route = createRoute({ ] as const, request: { params: z.object({ - id: z.string().uuid(), + id: AccountSchema.shape.id, }), }, responses: { 200: { - description: "Account data", + description: + "The Account record will be returned. Note that acct of local users does not include the domain name.", content: { "application/json": { schema: Account, }, }, }, + 404: accountNotFound, + 422: reusedResponses[422], }, }); diff --git a/api/api/v1/accounts/:id/mute.ts b/api/api/v1/accounts/:id/mute.ts index c896987a..c99688a1 100644 --- a/api/api/v1/accounts/:id/mute.ts +++ b/api/api/v1/accounts/:id/mute.ts @@ -1,29 +1,26 @@ -import { apiRoute, auth, withUserParam } from "@/api"; +import { + accountNotFound, + apiRoute, + auth, + reusedResponses, + withUserParam, +} from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { Relationship } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; +import { Account as AccountSchema } from "~/classes/schemas/account"; import { Relationship as RelationshipSchema } from "~/classes/schemas/relationship"; -const schemas = { - param: z.object({ - id: z.string().uuid(), - }), - json: z.object({ - notifications: z.boolean().optional(), - duration: z - .number() - .int() - .min(60) - .max(60 * 60 * 24 * 365 * 5) - .optional(), - }), -}; - const route = createRoute({ method: "post", path: "/api/v1/accounts/{id}/mute", - summary: "Mute user", - description: "Mute a user", + summary: "Mute account", + description: + "Mute the given account. Clients should filter statuses and notifications from this account, if received (e.g. due to a boost in the Home timeline).", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/accounts/#mute", + }, + tags: ["Accounts"], middleware: [ auth({ auth: true, @@ -36,24 +33,44 @@ const route = createRoute({ withUserParam, ] as const, request: { - params: schemas.param, + params: z.object({ + id: AccountSchema.shape.id, + }), body: { content: { "application/json": { - schema: schemas.json, + schema: z.object({ + notifications: z.boolean().default(true).openapi({ + description: + "Mute notifications in addition to statuses?", + }), + duration: z + .number() + .int() + .min(0) + .max(60 * 60 * 24 * 365 * 5) + .default(0) + .openapi({ + description: + "How long the mute should last, in seconds.", + }), + }), }, }, }, }, responses: { 200: { - description: "Updated relationship", + description: + "Successfully muted, or account was already muted. Note that you can call this API method again with notifications=false to update the relationship so that only statuses are muted.", content: { "application/json": { schema: RelationshipSchema, }, }, }, + 404: accountNotFound, + ...reusedResponses, }, }); @@ -72,7 +89,7 @@ export default apiRoute((app) => // TODO: Implement duration await foundRelationship.update({ muting: true, - mutingNotifications: notifications ?? true, + mutingNotifications: notifications, }); return context.json(foundRelationship.toApi(), 200); diff --git a/api/api/v1/accounts/:id/note.ts b/api/api/v1/accounts/:id/note.ts index 673d99f9..aef95afe 100644 --- a/api/api/v1/accounts/:id/note.ts +++ b/api/api/v1/accounts/:id/note.ts @@ -1,23 +1,25 @@ -import { apiRoute, auth, withUserParam } from "@/api"; +import { + accountNotFound, + apiRoute, + auth, + reusedResponses, + withUserParam, +} from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { Relationship } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; +import { Account as AccountSchema } from "~/classes/schemas/account"; import { Relationship as RelationshipSchema } from "~/classes/schemas/relationship"; -const schemas = { - param: z.object({ - id: z.string().uuid(), - }), - json: z.object({ - comment: z.string().min(0).max(5000).trim().optional(), - }), -}; - const route = createRoute({ method: "post", path: "/api/v1/accounts/{id}/note", - summary: "Set note", - description: "Set a note on a user's profile, visible only to you", + summary: "Set private note on profile", + description: "Sets a private note on a user.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/accounts/#note", + }, + tags: ["Accounts"], middleware: [ auth({ auth: true, @@ -30,24 +32,35 @@ const route = createRoute({ withUserParam, ] as const, request: { - params: schemas.param, + params: z.object({ + id: AccountSchema.shape.id, + }), body: { content: { "application/json": { - schema: schemas.json, + schema: z.object({ + comment: RelationshipSchema.shape.note + .optional() + .openapi({ + description: + "The comment to be set on that user. Provide an empty string or leave out this parameter to clear the currently set note.", + }), + }), }, }, }, }, responses: { 200: { - description: "Updated relationship", + description: "Successfully updated profile note", content: { "application/json": { schema: RelationshipSchema, }, }, }, + 404: accountNotFound, + ...reusedResponses, }, }); @@ -63,7 +76,7 @@ export default apiRoute((app) => ); await foundRelationship.update({ - note: comment, + note: comment ?? "", }); return context.json(foundRelationship.toApi(), 200); diff --git a/api/api/v1/accounts/:id/pin.ts b/api/api/v1/accounts/:id/pin.ts index 6eefbabb..45b68149 100644 --- a/api/api/v1/accounts/:id/pin.ts +++ b/api/api/v1/accounts/:id/pin.ts @@ -2,13 +2,19 @@ import { apiRoute, auth, withUserParam } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { Relationship } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; +import { Account as AccountSchema } from "~/classes/schemas/account"; import { Relationship as RelationshipSchema } from "~/classes/schemas/relationship"; const route = createRoute({ method: "post", path: "/api/v1/accounts/{id}/pin", - summary: "Pin user", - description: "Pin a user to your profile", + summary: "Feature account on your profile", + description: + "Add the given account to the user’s featured profiles. (Featured profiles are currently shown on the user’s own public profile.)", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/accounts/#pin", + }, + tags: ["Accounts"], middleware: [ auth({ auth: true, @@ -22,7 +28,7 @@ const route = createRoute({ ] as const, request: { params: z.object({ - id: z.string().uuid(), + id: AccountSchema.shape.id, }), }, responses: { diff --git a/api/api/v1/accounts/:id/refetch.ts b/api/api/v1/accounts/:id/refetch.ts index 54d6dcd5..b84dab62 100644 --- a/api/api/v1/accounts/:id/refetch.ts +++ b/api/api/v1/accounts/:id/refetch.ts @@ -1,15 +1,23 @@ -import { apiRoute, auth, withUserParam } from "@/api"; +import { + accountNotFound, + apiRoute, + auth, + reusedResponses, + withUserParam, +} from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { RolePermissions } from "@versia/kit/tables"; import { ApiError } from "~/classes/errors/api-error"; import { Account } from "~/classes/schemas/account"; +import { Account as AccountSchema } from "~/classes/schemas/account"; import { ErrorSchema } from "~/types/api"; const route = createRoute({ method: "post", path: "/api/v1/accounts/{id}/refetch", - summary: "Refetch user", - description: "Refetch a user's profile from the remote server", + summary: "Refetch account", + description: "Refetch the given account's profile from the remote server", + tags: ["Accounts"], middleware: [ auth({ auth: true, @@ -20,12 +28,12 @@ const route = createRoute({ ] as const, request: { params: z.object({ - id: z.string().uuid(), + id: AccountSchema.shape.id, }), }, responses: { 200: { - description: "Updated user data", + description: "Refetched account data", content: { "application/json": { schema: Account, @@ -40,6 +48,8 @@ const route = createRoute({ }, }, }, + 404: accountNotFound, + ...reusedResponses, }, }); diff --git a/api/api/v1/accounts/:id/remove_from_followers.ts b/api/api/v1/accounts/:id/remove_from_followers.ts index 3c3f6bef..e6c3d4ba 100644 --- a/api/api/v1/accounts/:id/remove_from_followers.ts +++ b/api/api/v1/accounts/:id/remove_from_followers.ts @@ -1,14 +1,25 @@ -import { apiRoute, auth, withUserParam } from "@/api"; +import { + accountNotFound, + apiRoute, + auth, + reusedResponses, + withUserParam, +} from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { Relationship } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; +import { Account as AccountSchema } from "~/classes/schemas/account"; import { Relationship as RelationshipSchema } from "~/classes/schemas/relationship"; const route = createRoute({ method: "post", path: "/api/v1/accounts/{id}/remove_from_followers", - summary: "Remove user from followers", - description: "Remove a user from your followers", + summary: "Remove account from followers", + description: "Remove the given account from your followers.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/accounts/#remove_from_followers", + }, + tags: ["Accounts"], middleware: [ auth({ auth: true, @@ -22,18 +33,21 @@ const route = createRoute({ ] as const, request: { params: z.object({ - id: z.string().uuid(), + id: AccountSchema.shape.id, }), }, responses: { 200: { - description: "Updated relationship", + description: + "Successfully removed from followers, or account was already not following you", content: { "application/json": { schema: RelationshipSchema, }, }, }, + 404: accountNotFound, + ...reusedResponses, }, }); diff --git a/api/api/v1/accounts/:id/roles/:role_id/index.ts b/api/api/v1/accounts/:id/roles/:role_id/index.ts index e4a72cdb..4bb23ebf 100644 --- a/api/api/v1/accounts/:id/roles/:role_id/index.ts +++ b/api/api/v1/accounts/:id/roles/:role_id/index.ts @@ -3,19 +3,15 @@ import { createRoute, z } from "@hono/zod-openapi"; import { Role } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import { ApiError } from "~/classes/errors/api-error"; +import { Account as AccountSchema } from "~/classes/schemas/account"; +import { Role as RoleSchema } from "~/classes/schemas/versia"; import { ErrorSchema } from "~/types/api"; -const schemas = { - param: z.object({ - id: z.string().uuid(), - role_id: z.string().uuid(), - }), -}; - const routePost = createRoute({ method: "post", path: "/api/v1/accounts/{id}/roles/{role_id}", - summary: "Assign role to user", + summary: "Assign role to account", + tags: ["Accounts"], middleware: [ auth({ auth: true, @@ -24,7 +20,10 @@ const routePost = createRoute({ withUserParam, ] as const, request: { - params: schemas.param, + params: z.object({ + id: AccountSchema.shape.id, + role_id: RoleSchema.shape.id, + }), }, responses: { 204: { @@ -61,7 +60,10 @@ const routeDelete = createRoute({ withUserParam, ] as const, request: { - params: schemas.param, + params: z.object({ + id: AccountSchema.shape.id, + role_id: RoleSchema.shape.id, + }), }, responses: { 204: { diff --git a/api/api/v1/accounts/:id/roles/index.ts b/api/api/v1/accounts/:id/roles/index.ts index 7e24d2af..54de5505 100644 --- a/api/api/v1/accounts/:id/roles/index.ts +++ b/api/api/v1/accounts/:id/roles/index.ts @@ -1,12 +1,14 @@ import { apiRoute, auth, withUserParam } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { Role } from "@versia/kit/db"; +import { Account as AccountSchema } from "~/classes/schemas/account"; import { Role as RoleSchema } from "~/classes/schemas/versia.ts"; const route = createRoute({ method: "get", path: "/api/v1/accounts/{id}/roles", - summary: "List user roles", + summary: "List account roles", + tags: ["Accounts"], middleware: [ auth({ auth: false, @@ -15,7 +17,7 @@ const route = createRoute({ ] as const, request: { params: z.object({ - id: z.string().uuid(), + id: AccountSchema.shape.id, }), }, responses: { diff --git a/api/api/v1/accounts/:id/statuses.ts b/api/api/v1/accounts/:id/statuses.ts index dc61d337..d64726fe 100644 --- a/api/api/v1/accounts/:id/statuses.ts +++ b/api/api/v1/accounts/:id/statuses.ts @@ -1,47 +1,27 @@ -import { apiRoute, auth, withUserParam } from "@/api"; +import { + accountNotFound, + apiRoute, + auth, + reusedResponses, + withUserParam, +} from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; 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({ - id: z.string().uuid(), - }), - query: z.object({ - max_id: z.string().uuid().optional(), - since_id: z.string().uuid().optional(), - min_id: z.string().uuid().optional(), - limit: z.coerce.number().int().min(1).max(40).optional().default(20), - only_media: z - .string() - .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) - .optional(), - exclude_replies: z - .string() - .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) - .optional(), - exclude_reblogs: z - .string() - .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) - .optional(), - pinned: z - .string() - .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) - .optional(), - tagged: z - .string() - .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) - .optional(), - }), -}; +import { Account as AccountSchema } from "~/classes/schemas/account"; +import { Status as StatusSchema } from "~/classes/schemas/status"; +import { zBoolean } from "~/packages/config-manager/config.type"; const route = createRoute({ method: "get", path: "/api/v1/accounts/{id}/statuses", - summary: "Get account statuses", - description: "Gets an paginated list of statuses by the specified account", + summary: "Get account’s statuses", + description: "Statuses posted to the given account.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/accounts/#statuses", + }, + tags: ["Accounts"], middleware: [ auth({ auth: false, @@ -54,23 +34,58 @@ const route = createRoute({ withUserParam, ] as const, request: { - params: schemas.param, - query: schemas.query, + params: z.object({ + id: AccountSchema.shape.id, + }), + query: z.object({ + max_id: StatusSchema.shape.id.optional().openapi({ + description: + "All results returned will be lesser than this ID. In effect, sets an upper bound on results.", + example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa", + }), + since_id: StatusSchema.shape.id.optional().openapi({ + description: + "All results returned will be greater than this ID. In effect, sets a lower bound on results.", + example: undefined, + }), + min_id: StatusSchema.shape.id.optional().openapi({ + description: + "Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.", + example: undefined, + }), + limit: z.coerce.number().int().min(1).max(40).default(20).openapi({ + description: "Maximum number of results to return.", + }), + only_media: zBoolean.default(false).openapi({ + description: "Filter out statuses without attachments.", + }), + exclude_replies: zBoolean.default(false).openapi({ + description: + "Filter out statuses in reply to a different account.", + }), + exclude_reblogs: zBoolean.default(false).openapi({ + description: "Filter out boosts from the response.", + }), + pinned: zBoolean.default(false).openapi({ + description: + "Filter for pinned statuses only. Pinned statuses do not receive special priority in the order of the returned results.", + }), + tagged: z.string().optional().openapi({ + description: "Filter for statuses using a specific hashtag.", + }), + }), }, responses: { 200: { - description: "A list of statuses by the specified account", + description: "Statuses posted to the given account.", content: { "application/json": { - schema: z.array(Status), - }, - }, - headers: { - Link: { - description: "Links to the next and previous pages", + schema: z.array(StatusSchema), }, }, }, + 404: accountNotFound, + 422: reusedResponses[422], }, }); @@ -90,7 +105,7 @@ export default apiRoute((app) => pinned, } = context.req.valid("query"); - const { objects, link } = await Timeline.getNoteTimeline( + const { objects } = await Timeline.getNoteTimeline( and( max_id ? lt(Notes.id, max_id) : undefined, since_id ? gte(Notes.id, since_id) : undefined, @@ -122,9 +137,6 @@ export default apiRoute((app) => return context.json( await Promise.all(objects.map((note) => note.toApi(otherUser))), 200, - { - link, - }, ); }), ); diff --git a/api/api/v1/accounts/:id/unblock.ts b/api/api/v1/accounts/:id/unblock.ts index b1c1e56a..2b18d341 100644 --- a/api/api/v1/accounts/:id/unblock.ts +++ b/api/api/v1/accounts/:id/unblock.ts @@ -1,14 +1,25 @@ -import { apiRoute, auth, withUserParam } from "@/api"; +import { + accountNotFound, + apiRoute, + auth, + reusedResponses, + withUserParam, +} from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { Relationship } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; +import { Account as AccountSchema } from "~/classes/schemas/account"; import { Relationship as RelationshipSchema } from "~/classes/schemas/relationship"; const route = createRoute({ method: "post", path: "/api/v1/accounts/{id}/unblock", - summary: "Unblock user", - description: "Unblock a user", + summary: "Unblock account", + description: "Unblock the given account.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/accounts/#unblock", + }, + tags: ["Accounts"], middleware: [ auth({ auth: true, @@ -22,18 +33,21 @@ const route = createRoute({ ] as const, request: { params: z.object({ - id: z.string().uuid(), + id: AccountSchema.shape.id, }), }, responses: { 200: { - description: "Updated relationship", + description: + "Successfully unblocked, or account was already not blocked", content: { "application/json": { schema: RelationshipSchema, }, }, }, + 404: accountNotFound, + ...reusedResponses, }, }); diff --git a/api/api/v1/accounts/:id/unfollow.ts b/api/api/v1/accounts/:id/unfollow.ts index 57eb749e..6e39491d 100644 --- a/api/api/v1/accounts/:id/unfollow.ts +++ b/api/api/v1/accounts/:id/unfollow.ts @@ -1,15 +1,25 @@ -import { apiRoute, auth, withUserParam } from "@/api"; +import { + accountNotFound, + apiRoute, + auth, + reusedResponses, + withUserParam, +} from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { Relationship } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; +import { Account as AccountSchema } from "~/classes/schemas/account"; import { Relationship as RelationshipSchema } from "~/classes/schemas/relationship"; -import { ErrorSchema } from "~/types/api"; const route = createRoute({ method: "post", path: "/api/v1/accounts/{id}/unfollow", - summary: "Unfollow user", - description: "Unfollow a user", + summary: "Unfollow account", + description: "Unfollow the given account.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/accounts/#unfollow", + }, + tags: ["Accounts"], middleware: [ auth({ auth: true, @@ -23,26 +33,21 @@ const route = createRoute({ ] as const, request: { params: z.object({ - id: z.string().uuid(), + id: AccountSchema.shape.id, }), }, responses: { 200: { - description: "Updated relationship", + description: + "Successfully unfollowed, or account was already not followed", content: { "application/json": { schema: RelationshipSchema, }, }, }, - 500: { - description: "Failed to unfollow user during federation", - content: { - "application/json": { - schema: ErrorSchema, - }, - }, - }, + 404: accountNotFound, + ...reusedResponses, }, }); diff --git a/api/api/v1/accounts/:id/unmute.ts b/api/api/v1/accounts/:id/unmute.ts index 0b2e0f46..9bab74fe 100644 --- a/api/api/v1/accounts/:id/unmute.ts +++ b/api/api/v1/accounts/:id/unmute.ts @@ -1,14 +1,25 @@ -import { apiRoute, auth, withUserParam } from "@/api"; +import { + accountNotFound, + apiRoute, + auth, + reusedResponses, + withUserParam, +} from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { Relationship } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; +import { Account as AccountSchema } from "~/classes/schemas/account"; import { Relationship as RelationshipSchema } from "~/classes/schemas/relationship"; const route = createRoute({ method: "post", path: "/api/v1/accounts/{id}/unmute", - summary: "Unmute user", - description: "Unmute a user", + summary: "Unmute account", + description: "Unmute the given account.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/accounts/#unmute", + }, + tags: ["Accounts"], middleware: [ auth({ auth: true, @@ -22,18 +33,20 @@ const route = createRoute({ ] as const, request: { params: z.object({ - id: z.string().uuid(), + id: AccountSchema.shape.id, }), }, responses: { 200: { - description: "Updated relationship", + description: "Successfully unmuted, or account was already unmuted", content: { "application/json": { schema: RelationshipSchema, }, }, }, + 404: accountNotFound, + ...reusedResponses, }, }); diff --git a/api/api/v1/accounts/:id/unpin.ts b/api/api/v1/accounts/:id/unpin.ts index 57925ceb..2dc32669 100644 --- a/api/api/v1/accounts/:id/unpin.ts +++ b/api/api/v1/accounts/:id/unpin.ts @@ -1,14 +1,25 @@ -import { apiRoute, auth, withUserParam } from "@/api"; +import { + accountNotFound, + apiRoute, + auth, + reusedResponses, + withUserParam, +} from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { Relationship } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; +import { Account as AccountSchema } from "~/classes/schemas/account"; import { Relationship as RelationshipSchema } from "~/classes/schemas/relationship"; const route = createRoute({ method: "post", path: "/api/v1/accounts/{id}/unpin", - summary: "Unpin user", - description: "Unpin a user from your profile", + summary: "Unfeature account from profile", + description: "Remove the given account from the user’s featured profiles.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/accounts/#unpin", + }, + tags: ["Accounts"], middleware: [ auth({ auth: true, @@ -22,18 +33,21 @@ const route = createRoute({ ] as const, request: { params: z.object({ - id: z.string().uuid(), + id: AccountSchema.shape.id, }), }, responses: { 200: { - description: "Updated relationship", + description: + "Successfully unendorsed, or account was already not endorsed", content: { "application/json": { schema: RelationshipSchema, }, }, }, + 404: accountNotFound, + ...reusedResponses, }, }); diff --git a/api/api/v1/accounts/familiar_followers/index.ts b/api/api/v1/accounts/familiar_followers/index.ts index 2fc5caa9..f1c63534 100644 --- a/api/api/v1/accounts/familiar_followers/index.ts +++ b/api/api/v1/accounts/familiar_followers/index.ts @@ -1,20 +1,10 @@ -import { apiRoute, auth, qsQuery } from "@/api"; +import { apiRoute, auth, qsQuery, reusedResponses } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { User, db } from "@versia/kit/db"; import { RolePermissions, type Users } from "@versia/kit/tables"; import { type InferSelectModel, sql } from "drizzle-orm"; -import { Account } from "~/classes/schemas/account"; - -const schemas = { - query: z.object({ - id: z - .array(z.string().uuid()) - .min(1) - .max(10) - .or(z.string().uuid()) - .transform((v) => (Array.isArray(v) ? v : [v])), - }), -}; +import { Account as AccountSchema } from "~/classes/schemas/account"; +import { FamiliarFollowers as FamiliarFollowersSchema } from "~/classes/schemas/familiar-followers"; const route = createRoute({ method: "get", @@ -22,6 +12,10 @@ const route = createRoute({ summary: "Get familiar followers", description: "Obtain a list of all accounts that follow a given account, filtered for accounts you follow.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/accounts/#familiar_followers", + }, + tags: ["Accounts"], middleware: [ auth({ auth: true, @@ -31,22 +25,32 @@ const route = createRoute({ qsQuery(), ] as const, request: { - query: schemas.query, + query: z.object({ + id: z + .array(AccountSchema.shape.id) + .min(1) + .max(10) + .or(AccountSchema.shape.id.transform((v) => [v])) + .openapi({ + description: + "Find familiar followers for the provided account IDs.", + example: [ + "f137ce6f-ff5e-4998-b20f-0361ba9be007", + "8424c654-5d03-4a1b-bec8-4e87db811b5d", + ], + }), + }), }, responses: { 200: { description: "Familiar followers", content: { "application/json": { - schema: z.array( - z.object({ - id: z.string().uuid(), - accounts: z.array(Account), - }), - ), + schema: z.array(FamiliarFollowersSchema), }, }, }, + ...reusedResponses, }, }); diff --git a/api/api/v1/accounts/id/index.test.ts b/api/api/v1/accounts/id/index.test.ts index 30f036ea..fd446335 100644 --- a/api/api/v1/accounts/id/index.test.ts +++ b/api/api/v1/accounts/id/index.test.ts @@ -24,7 +24,7 @@ describe("/api/v1/accounts/id", () => { test("should return 404 for non-existent user", async () => { const response = await fakeRequest( - `/api/v1/accounts/id?username=${users[0].data.username}-nonexistent`, + "/api/v1/accounts/id?username=nonexistent", ); expect(response.status).toBe(404); diff --git a/api/api/v1/accounts/id/index.ts b/api/api/v1/accounts/id/index.ts index 8f355c97..34a5bf4d 100644 --- a/api/api/v1/accounts/id/index.ts +++ b/api/api/v1/accounts/id/index.ts @@ -1,23 +1,18 @@ -import { apiRoute, auth } from "@/api"; +import { accountNotFound, apiRoute, auth, reusedResponses } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { User } from "@versia/kit/db"; import { RolePermissions, Users } from "@versia/kit/tables"; import { and, eq, isNull } from "drizzle-orm"; import { ApiError } from "~/classes/errors/api-error"; import { Account } from "~/classes/schemas/account"; -import { ErrorSchema } from "~/types/api"; - -const schemas = { - query: z.object({ - username: z.string().min(1).max(512).toLowerCase(), - }), -}; +import { Account as AccountSchema } from "~/classes/schemas/account"; const route = createRoute({ method: "get", path: "/api/v1/accounts/id", summary: "Get account by username", description: "Get an account by username", + tags: ["Accounts"], middleware: [ auth({ auth: false, @@ -25,7 +20,11 @@ const route = createRoute({ }), ] as const, request: { - query: schemas.query, + query: z.object({ + username: AccountSchema.shape.username.transform((v) => + v.toLowerCase(), + ), + }), }, responses: { 200: { @@ -36,14 +35,8 @@ const route = createRoute({ }, }, }, - 404: { - description: "Not found", - content: { - "application/json": { - schema: ErrorSchema, - }, - }, - }, + 404: accountNotFound, + 422: reusedResponses[422], }, }); diff --git a/api/api/v1/accounts/index.ts b/api/api/v1/accounts/index.ts index b76e6fdc..b599a4f3 100644 --- a/api/api/v1/accounts/index.ts +++ b/api/api/v1/accounts/index.ts @@ -7,26 +7,48 @@ import { and, eq, isNull } from "drizzle-orm"; import ISO6391 from "iso-639-1"; import { ApiError } from "~/classes/errors/api-error"; import { config } from "~/packages/config-manager"; +import { zBoolean } from "~/packages/config-manager/config.type"; -const schemas = { - json: z.object({ - username: z.string(), - email: z.string().toLowerCase(), - password: z.string().optional(), - agreement: z - .string() - .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) - .or(z.boolean()), - locale: z.string(), - reason: z.string(), +const schema = z.object({ + username: z.string().openapi({ + description: "The desired username for the account", + example: "alice", }), -}; + email: z.string().toLowerCase().openapi({ + description: + "The email address to be used for login. Transformed to lowercase.", + example: "alice@gmail.com", + }), + password: z.string().openapi({ + description: "The password to be used for login", + example: "hunter2", + }), + agreement: zBoolean.openapi({ + description: + "Whether the user agrees to the local rules, terms, and policies. These should be presented to the user in order to allow them to consent before setting this parameter to TRUE.", + example: true, + }), + locale: z.string().openapi({ + description: + "The language of the confirmation email that will be sent. ISO 639-1 code.", + example: "en", + }), + reason: z.string().optional().openapi({ + description: + "If registrations require manual approval, this text will be reviewed by moderators.", + }), +}); const route = createRoute({ method: "post", path: "/api/v1/accounts", - summary: "Create account", - description: "Register a new account", + summary: "Register an account", + description: + "Creates a user and account records. Returns an account access token for the app that initiated the request. The app should save this token for later, and should wait for the user to confirm their account by clicking a link in their email inbox.\n\nA relationship between the OAuth Application and created user account is stored.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/accounts/#create", + }, + tags: ["Accounts"], middleware: [ auth({ auth: false, @@ -34,18 +56,18 @@ const route = createRoute({ challenge: true, }), jsonOrForm(), - ], + ] as const, request: { body: { content: { "application/json": { - schema: schemas.json, + schema: schema, }, "multipart/form-data": { - schema: schemas.json, + schema: schema, }, "application/x-www-form-urlencoded": { - schema: schemas.json, + schema: schema, }, }, }, @@ -113,7 +135,10 @@ const route = createRoute({ ), reason: z.array( z.object({ - error: z.enum(["ERR_BLANK"]), + error: z.enum([ + "ERR_BLANK", + "ERR_TOO_LONG", + ]), description: z.string(), }), ), @@ -288,6 +313,14 @@ export default apiRoute((app) => }); } + // Check if reason is too long + if ((form.reason?.length ?? 0) > 10_000) { + errors.details.reason.push({ + error: "ERR_TOO_LONG", + description: `is too long (maximum is ${10_000} characters)`, + }); + } + // If any errors are present, return them if (Object.values(errors.details).some((value) => value.length > 0)) { // Error is something like "Validation failed: Password can't be blank, Username must contain only letters, numbers and underscores, Agreement must be accepted" diff --git a/api/api/v1/accounts/lookup/index.test.ts b/api/api/v1/accounts/lookup/index.test.ts index 1660c9a9..cb10dcf8 100644 --- a/api/api/v1/accounts/lookup/index.test.ts +++ b/api/api/v1/accounts/lookup/index.test.ts @@ -34,7 +34,7 @@ describe("/api/v1/accounts/lookup", () => { ); }); - test("should automatically lowercase the acct", async () => { + test("should require exact case", async () => { const response = await fakeRequest( `/api/v1/accounts/lookup?acct=${users[0].data.username.toUpperCase()}`, { @@ -44,17 +44,6 @@ describe("/api/v1/accounts/lookup", () => { }, ); - expect(response.status).toBe(200); - - const data = (await response.json()) as ApiAccount[]; - expect(data).toEqual( - expect.objectContaining({ - id: users[0].id, - username: users[0].data.username, - display_name: users[0].data.displayName, - avatar: expect.any(String), - header: expect.any(String), - }), - ); + expect(response.status).toBe(404); }); }); diff --git a/api/api/v1/accounts/lookup/index.ts b/api/api/v1/accounts/lookup/index.ts index ae6b1f5b..70be012a 100644 --- a/api/api/v1/accounts/lookup/index.ts +++ b/api/api/v1/accounts/lookup/index.ts @@ -1,24 +1,25 @@ -import { apiRoute, auth, parseUserAddress } from "@/api"; +import { + accountNotFound, + apiRoute, + auth, + parseUserAddress, + reusedResponses, +} from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { Instance, User } from "@versia/kit/db"; import { RolePermissions, Users } from "@versia/kit/tables"; import { and, eq, isNull } from "drizzle-orm"; import { ApiError } from "~/classes/errors/api-error"; import { Account } from "~/classes/schemas/account"; +import { Account as AccountSchema } from "~/classes/schemas/account"; import { config } from "~/packages/config-manager"; -import { ErrorSchema } from "~/types/api"; - -const schemas = { - query: z.object({ - acct: z.string().min(1).max(512).toLowerCase(), - }), -}; const route = createRoute({ method: "get", path: "/api/v1/accounts/lookup", - summary: "Lookup account", - description: "Lookup an account by acct", + summary: "Lookup account ID from Webfinger address", + description: + "Quickly lookup a username to see if it is available, skipping WebFinger resolution.", middleware: [ auth({ auth: false, @@ -26,7 +27,12 @@ const route = createRoute({ }), ] as const, request: { - query: schemas.query, + query: z.object({ + acct: AccountSchema.shape.acct.openapi({ + description: "The username or Webfinger address to lookup.", + example: "lexi@beta.versia.social", + }), + }), }, responses: { 200: { @@ -37,22 +43,8 @@ const route = createRoute({ }, }, }, - 404: { - description: "Not found", - content: { - "application/json": { - schema: ErrorSchema, - }, - }, - }, - 422: { - description: "Invalid parameter", - content: { - "application/json": { - schema: ErrorSchema, - }, - }, - }, + 404: accountNotFound, + 422: reusedResponses[422], }, }); @@ -64,12 +56,8 @@ export default apiRoute((app) => // Check if acct is matching format username@domain.com or @username@domain.com const { username, domain } = parseUserAddress(acct); - if (!username) { - throw new Error("Invalid username"); - } - // User is local - if (!domain || domain === new URL(config.http.base_url).host) { + if (!domain || domain === config.http.base_url.host) { const account = await User.fromSql( and(eq(Users.username, username), isNull(Users.instanceId)), ); diff --git a/api/api/v1/accounts/relationships/index.ts b/api/api/v1/accounts/relationships/index.ts index f76f3d99..b5e93e13 100644 --- a/api/api/v1/accounts/relationships/index.ts +++ b/api/api/v1/accounts/relationships/index.ts @@ -1,20 +1,21 @@ -import { apiRoute, auth, qsQuery } from "@/api"; +import { apiRoute, auth, qsQuery, reusedResponses } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { Relationship } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; +import { Account as AccountSchema } from "~/classes/schemas/account"; import { Relationship as RelationshipSchema } from "~/classes/schemas/relationship"; - -const schemas = { - query: z.object({ - id: z.array(z.string().uuid()).min(1).max(10).or(z.string().uuid()), - }), -}; +import { zBoolean } from "~/packages/config-manager/config.type"; const route = createRoute({ method: "get", path: "/api/v1/accounts/relationships", - summary: "Get relationships", - description: "Get relationships by account ID", + summary: "Check relationships to other accounts", + description: + "Find out whether a given account is followed, blocked, muted, etc.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/accounts/#relationships", + }, + tags: ["Accounts"], middleware: [ auth({ auth: true, @@ -24,7 +25,26 @@ const route = createRoute({ qsQuery(), ] as const, request: { - query: schemas.query, + query: z.object({ + id: z + .array(AccountSchema.shape.id) + .min(1) + .max(10) + .or(AccountSchema.shape.id.transform((v) => [v])) + .openapi({ + description: + "Check relationships for the provided account IDs.", + example: [ + "f137ce6f-ff5e-4998-b20f-0361ba9be007", + "8424c654-5d03-4a1b-bec8-4e87db811b5d", + ], + }), + with_suspended: zBoolean.default(false).openapi({ + description: + "Whether relationships should be returned for suspended users", + example: false, + }), + }), }, responses: { 200: { @@ -35,12 +55,15 @@ const route = createRoute({ }, }, }, + ...reusedResponses, }, }); export default apiRoute((app) => app.openapi(route, async (context) => { const { user } = context.get("auth"); + + // TODO: Implement with_suspended const { id } = context.req.valid("query"); const ids = Array.isArray(id) ? id : [id]; diff --git a/api/api/v1/accounts/search/index.ts b/api/api/v1/accounts/search/index.ts index 8fed1c62..4d47064f 100644 --- a/api/api/v1/accounts/search/index.ts +++ b/api/api/v1/accounts/search/index.ts @@ -1,33 +1,22 @@ -import { apiRoute, auth, parseUserAddress, userAddressValidator } from "@/api"; +import { apiRoute, auth, parseUserAddress } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { User } from "@versia/kit/db"; import { RolePermissions, Users } from "@versia/kit/tables"; import { eq, ilike, not, or, sql } from "drizzle-orm"; import stringComparison from "string-comparison"; import { ApiError } from "~/classes/errors/api-error"; -import { Account } from "~/classes/schemas/account"; - -const schemas = { - query: z.object({ - q: z.string().min(1).max(512).regex(userAddressValidator), - limit: z.coerce.number().int().min(1).max(80).default(40), - offset: z.coerce.number().int().optional(), - resolve: z - .string() - .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) - .optional(), - following: z - .string() - .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) - .optional(), - }), -}; +import { Account as AccountSchema } from "~/classes/schemas/account"; +import { zBoolean } from "~/packages/config-manager/config.type"; export const route = createRoute({ method: "get", path: "/api/v1/accounts/search", - summary: "Search accounts", - description: "Search for accounts", + summary: "Search for matching accounts", + description: "Search for matching accounts by username or display name.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/accounts/#search", + }, + tags: ["Accounts"], middleware: [ auth({ auth: false, @@ -36,14 +25,38 @@ export const route = createRoute({ }), ] as const, request: { - query: schemas.query, + query: z.object({ + q: AccountSchema.shape.username + .or(AccountSchema.shape.acct) + .openapi({ + description: "Search query for accounts.", + example: "username", + }), + limit: z.coerce.number().int().min(1).max(80).default(40).openapi({ + description: "Maximum number of results.", + example: 40, + }), + offset: z.coerce.number().int().default(0).openapi({ + description: "Skip the first n results.", + example: 0, + }), + resolve: zBoolean.default(false).openapi({ + description: + "Attempt WebFinger lookup. Use this when q is an exact address.", + example: false, + }), + following: zBoolean.default(false).openapi({ + description: "Limit the search to users you are following.", + example: false, + }), + }), }, responses: { 200: { description: "Accounts", content: { "application/json": { - schema: z.array(Account), + schema: z.array(AccountSchema), }, }, }, diff --git a/api/api/v1/accounts/update_credentials/index.ts b/api/api/v1/accounts/update_credentials/index.ts index fca71f64..65eae4a5 100644 --- a/api/api/v1/accounts/update_credentials/index.ts +++ b/api/api/v1/accounts/update_credentials/index.ts @@ -1,4 +1,4 @@ -import { apiRoute, auth, jsonOrForm } from "@/api"; +import { apiRoute, auth, jsonOrForm, reusedResponses } from "@/api"; import { mergeAndDeduplicate } from "@/lib"; import { sanitizedHtmlStrip } from "@/sanitization"; import { createRoute, z } from "@hono/zod-openapi"; @@ -7,73 +7,19 @@ import { RolePermissions, Users } from "@versia/kit/tables"; import { and, eq, isNull } from "drizzle-orm"; import { ApiError } from "~/classes/errors/api-error"; import { contentToHtml } from "~/classes/functions/status"; -import { Account } from "~/classes/schemas/account"; +import { Account as AccountSchema } from "~/classes/schemas/account"; +import { zBoolean } from "~/packages/config-manager/config.type"; import { config } from "~/packages/config-manager/index.ts"; -import { ErrorSchema } from "~/types/api"; - -const schemas = { - json: z - .object({ - display_name: Account.shape.display_name, - username: Account.shape.username, - note: Account.shape.note, - avatar: z - .string() - .trim() - .min(1) - .max(2000) - .url() - .transform((a) => new URL(a)) - .or( - z - .instanceof(File) - .refine( - (v) => v.size <= config.validation.max_avatar_size, - `Avatar must be less than ${config.validation.max_avatar_size} bytes`, - ), - ), - header: z - .string() - .trim() - .min(1) - .max(2000) - .url() - .transform((v) => new URL(v)) - .or( - z - .instanceof(File) - .refine( - (v) => v.size <= config.validation.max_header_size, - `Header must be less than ${config.validation.max_header_size} bytes`, - ), - ), - locked: Account.shape.locked, - bot: Account.shape.bot, - discoverable: Account.shape.discoverable, - source: z - .object({ - privacy: Account.shape.source.unwrap().shape.privacy, - sensitive: Account.shape.source.unwrap().shape.sensitive, - language: Account.shape.source.unwrap().shape.language, - }) - .partial(), - fields_attributes: z - .array( - z.object({ - name: Account.shape.fields.element.shape.name, - value: Account.shape.fields.element.shape.value, - }), - ) - .max(config.validation.max_field_count), - }) - .partial(), -}; const route = createRoute({ method: "patch", path: "/api/v1/accounts/update_credentials", - summary: "Update credentials", - description: "Update user credentials", + summary: "Update account credentials", + description: "Update the user’s display and preferences.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/accounts/#update_credentials", + }, + tags: ["Accounts"], middleware: [ auth({ auth: true, @@ -86,7 +32,121 @@ const route = createRoute({ body: { content: { "application/json": { - schema: schemas.json, + schema: z + .object({ + display_name: + AccountSchema.shape.display_name.openapi({ + description: + "The display name to use for the profile.", + example: "Lexi", + }), + username: AccountSchema.shape.username.openapi({ + description: + "The username to use for the profile.", + example: "lexi", + }), + note: AccountSchema.shape.note.openapi({ + description: + "The account bio. Markdown is supported.", + }), + avatar: z + .string() + .url() + .transform((a) => new URL(a)) + .openapi({ + description: "Avatar image URL", + }) + .or( + z + .instanceof(File) + .refine( + (v) => + v.size <= + config.validation + .max_avatar_size, + `Avatar must be less than ${config.validation.max_avatar_size} bytes`, + ) + .openapi({ + description: + "Avatar image encoded using multipart/form-data", + }), + ), + header: z + .string() + .url() + .transform((v) => new URL(v)) + .openapi({ + description: "Header image URL", + }) + .or( + z + .instanceof(File) + .refine( + (v) => + v.size <= + config.validation + .max_header_size, + `Header must be less than ${config.validation.max_header_size} bytes`, + ) + .openapi({ + description: + "Header image encoded using multipart/form-data", + }), + ), + locked: AccountSchema.shape.locked.openapi({ + description: + "Whether manual approval of follow requests is required.", + }), + bot: AccountSchema.shape.bot.openapi({ + description: + "Whether the account has a bot flag.", + }), + discoverable: + AccountSchema.shape.discoverable.openapi({ + description: + "Whether the account should be shown in the profile directory.", + }), + // TODO: Implement :( + hide_collections: zBoolean.openapi({ + description: + "Whether to hide followers and followed accounts.", + }), + // TODO: Implement :( + indexable: zBoolean.openapi({ + description: + "Whether public posts should be searchable to anyone.", + }), + // TODO: Implement :( + attribution_domains: z.array(z.string()).openapi({ + description: + "Domains of websites allowed to credit the account.", + example: ["cnn.com", "myblog.com"], + }), + source: z + .object({ + privacy: + AccountSchema.shape.source.unwrap() + .shape.privacy, + sensitive: + AccountSchema.shape.source.unwrap() + .shape.sensitive, + language: + AccountSchema.shape.source.unwrap() + .shape.language, + }) + .partial(), + fields_attributes: z + .array( + z.object({ + name: AccountSchema.shape.fields.element + .shape.name, + value: AccountSchema.shape.fields + .element.shape.value, + }), + ) + .max(config.validation.max_field_count), + }) + .partial(), }, }, }, @@ -96,27 +156,11 @@ const route = createRoute({ description: "Updated user", content: { "application/json": { - schema: Account, - }, - }, - }, - - 422: { - description: "Validation error", - content: { - "application/json": { - schema: ErrorSchema, - }, - }, - }, - 500: { - description: "Couldn't edit user", - content: { - "application/json": { - schema: ErrorSchema, + schema: AccountSchema, }, }, }, + ...reusedResponses, }, }); diff --git a/api/api/v1/accounts/verify_credentials/index.ts b/api/api/v1/accounts/verify_credentials/index.ts index d14d76f6..1ca7bdfa 100644 --- a/api/api/v1/accounts/verify_credentials/index.ts +++ b/api/api/v1/accounts/verify_credentials/index.ts @@ -1,12 +1,16 @@ -import { apiRoute, auth } from "@/api"; +import { apiRoute, auth, reusedResponses } from "@/api"; import { createRoute } from "@hono/zod-openapi"; import { Account } from "~/classes/schemas/account"; const route = createRoute({ method: "get", path: "/api/v1/accounts/verify_credentials", - summary: "Verify credentials", - description: "Get your own account information", + summary: "Verify account credentials", + description: "Test to make sure that the user token works.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/accounts/#verify_credentials", + }, + tags: ["Accounts"], middleware: [ auth({ auth: true, @@ -15,13 +19,16 @@ const route = createRoute({ ] as const, responses: { 200: { - description: "Account", + // TODO: Implement CredentialAccount + description: + "Note the extra source property, which is not visible on accounts other than your own. Also note that plain-text is used within source and HTML is used for their corresponding properties such as note and fields.", content: { "application/json": { schema: Account, }, }, }, + ...reusedResponses, }, }); diff --git a/api/api/v1/statuses/index.test.ts b/api/api/v1/statuses/index.test.ts index ff12b272..341e420f 100644 --- a/api/api/v1/statuses/index.test.ts +++ b/api/api/v1/statuses/index.test.ts @@ -336,7 +336,7 @@ describe("/api/v1/statuses", () => { }, body: new URLSearchParams({ status: `Hello, @${users[1].data.username}@${ - new URL(config.http.base_url).host + config.http.base_url.host }!`, local_only: "true", }), diff --git a/api/api/v2/instance/index.ts b/api/api/v2/instance/index.ts index e403733a..94ec504b 100644 --- a/api/api/v2/instance/index.ts +++ b/api/api/v2/instance/index.ts @@ -136,7 +136,7 @@ export default apiRoute((app) => // TODO: fill in more values return context.json({ - domain: new URL(config.http.base_url).hostname, + domain: config.http.base_url.hostname, title: config.instance.name, version: "4.3.0-alpha.3+glitch", versia_version: version, diff --git a/api/objects/:id/index.ts b/api/objects/:id/index.ts index ce480dab..136b6c60 100644 --- a/api/objects/:id/index.ts +++ b/api/objects/:id/index.ts @@ -97,7 +97,7 @@ export default apiRoute((app) => // This fixes reverse proxy errors const reqUrl = new URL(context.req.url); if ( - new URL(config.http.base_url).protocol === "https:" && + config.http.base_url.protocol === "https:" && reqUrl.protocol === "http:" ) { reqUrl.protocol = "https:"; diff --git a/api/well-known/openid-configuration/index.ts b/api/well-known/openid-configuration/index.ts index 0ccfea00..32d07668 100644 --- a/api/well-known/openid-configuration/index.ts +++ b/api/well-known/openid-configuration/index.ts @@ -36,7 +36,7 @@ const route = createRoute({ export default apiRoute((app) => app.openapi(route, (context) => { - const baseUrl = new URL(config.http.base_url); + const baseUrl = config.http.base_url; return context.json( { issuer: baseUrl.origin.toString(), diff --git a/api/well-known/versia.ts b/api/well-known/versia.ts index 73d3e449..41b13688 100644 --- a/api/well-known/versia.ts +++ b/api/well-known/versia.ts @@ -39,7 +39,7 @@ export default apiRoute((app) => ], versions: ["0.4.0"], }, - host: new URL(config.http.base_url).host, + host: config.http.base_url.host, name: config.instance.name, description: config.instance.description, public_key: { diff --git a/api/well-known/webfinger/index.ts b/api/well-known/webfinger/index.ts index c749c032..4c8e9ac6 100644 --- a/api/well-known/webfinger/index.ts +++ b/api/well-known/webfinger/index.ts @@ -63,7 +63,7 @@ export default apiRoute((app) => const requestedUser = resource.split("acct:")[1]; - const host = new URL(config.http.base_url).host; + const host = config.http.base_url.host; const { username, domain } = parseUserAddress(requestedUser); @@ -96,7 +96,7 @@ export default apiRoute((app) => try { activityPubUrl = await manager.webFinger( user.data.username, - new URL(config.http.base_url).host, + config.http.base_url.host, "application/activity+json", config.federation.bridge.url?.toString(), ); diff --git a/classes/database/note.ts b/classes/database/note.ts index 929df1ae..3fb01f44 100644 --- a/classes/database/note.ts +++ b/classes/database/note.ts @@ -784,9 +784,7 @@ export class Note extends BaseInterface { replacedContent = replacedContent.replace( createRegExp( exactly( - `@${mention.username}@${ - new URL(config.http.base_url).host - }`, + `@${mention.username}@${config.http.base_url.host}`, ), [global], ), diff --git a/classes/functions/status.ts b/classes/functions/status.ts index c00b9c4a..42874851 100644 --- a/classes/functions/status.ts +++ b/classes/functions/status.ts @@ -231,7 +231,7 @@ export const parseTextMentions = async ( return []; } - const baseUrlHost = new URL(config.http.base_url).host; + const baseUrlHost = config.http.base_url.host; const isLocal = (host?: string): boolean => host === baseUrlHost || !host; // Find local and matching users @@ -301,7 +301,7 @@ export const replaceTextMentions = (text: string, mentions: User[]): string => { return mentions.reduce((finalText, mention) => { const { username, instance } = mention.data; const uri = mention.getUri(); - const baseHost = new URL(config.http.base_url).host; + const baseHost = config.http.base_url.host; const linkTemplate = (displayText: string): string => `${displayText}`; diff --git a/classes/schemas/account.ts b/classes/schemas/account.ts index 818a2aa8..32cb9bc2 100644 --- a/classes/schemas/account.ts +++ b/classes/schemas/account.ts @@ -1,3 +1,4 @@ +import { userAddressValidator } from "@/api.ts"; import { z } from "@hono/zod-openapi"; import type { Account as ApiAccount } from "@versia/client/types"; import { config } from "~/packages/config-manager"; @@ -126,7 +127,6 @@ export const Account = z.object({ .min(3) .trim() .max(config.validation.max_username_size) - .toLowerCase() .regex( /^[a-z0-9_-]+$/, "Username can only contain letters, numbers, underscores and hyphens", @@ -142,14 +142,19 @@ export const Account = z.object({ url: "https://docs.joinmastodon.org/entities/Account/#username", }, }), - acct: z.string().openapi({ - description: - "The Webfinger account URI. Equal to username for local users, or username@domain for remote users.", - example: "lexi@beta.versia.social", - externalDocs: { - url: "https://docs.joinmastodon.org/entities/Account/#acct", - }, - }), + acct: z + .string() + .min(1) + .trim() + .regex(userAddressValidator, "Invalid user address") + .openapi({ + description: + "The Webfinger account URI. Equal to username for local users, or username@domain for remote users.", + example: "lexi@beta.versia.social", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Account/#acct", + }, + }), url: z .string() .url() diff --git a/classes/schemas/relationship.ts b/classes/schemas/relationship.ts index 8d2f8889..b3570495 100644 --- a/classes/schemas/relationship.ts +++ b/classes/schemas/relationship.ts @@ -60,7 +60,7 @@ export const Relationship = z description: "Are you featuring this user on your profile?", example: false, }), - note: z.string().openapi({ + note: z.string().min(0).max(5000).trim().openapi({ description: "This user’s profile bio", example: "they also like Kerbal Space Program", }), diff --git a/middlewares/url-check.ts b/middlewares/url-check.ts index 42602766..7b09cc20 100644 --- a/middlewares/url-check.ts +++ b/middlewares/url-check.ts @@ -3,7 +3,7 @@ import { config } from "~/packages/config-manager"; export const urlCheck = createMiddleware(async (context, next) => { // Check that request URL matches base_url - const baseUrl = new URL(config.http.base_url); + const baseUrl = config.http.base_url; if (new URL(context.req.url).origin !== baseUrl.origin) { return context.json( diff --git a/plugins/openid/routes/authorize.test.ts b/plugins/openid/routes/authorize.test.ts index d71ba7bd..776eedf7 100644 --- a/plugins/openid/routes/authorize.test.ts +++ b/plugins/openid/routes/authorize.test.ts @@ -35,7 +35,7 @@ describe("/oauth/authorize", () => { test("should authorize and redirect with valid inputs", async () => { const jwt = await new SignJWT({ sub: users[0].id, - iss: new URL(config.http.base_url).origin, + iss: config.http.base_url.origin, aud: application.data.clientId, exp: Math.floor(Date.now() / 1000) + 60 * 60, iat: Math.floor(Date.now() / 1000), @@ -109,7 +109,7 @@ describe("/oauth/authorize", () => { test("should return error for missing required fields in JWT", async () => { const jwt = await new SignJWT({ sub: users[0].id, - iss: new URL(config.http.base_url).origin, + iss: config.http.base_url.origin, aud: application.data.clientId, }) .setProtectedHeader({ alg: "EdDSA" }) @@ -150,7 +150,7 @@ describe("/oauth/authorize", () => { sub: "non-existent-user", aud: application.data.clientId, exp: Math.floor(Date.now() / 1000) + 60 * 60, - iss: new URL(config.http.base_url).origin, + iss: config.http.base_url.origin, iat: Math.floor(Date.now() / 1000), nbf: Math.floor(Date.now() / 1000), }) @@ -190,7 +190,7 @@ describe("/oauth/authorize", () => { sub: "23e42862-d5df-49a8-95b5-52d8c6a11aea", aud: application.data.clientId, exp: Math.floor(Date.now() / 1000) + 60 * 60, - iss: new URL(config.http.base_url).origin, + iss: config.http.base_url.origin, iat: Math.floor(Date.now() / 1000), nbf: Math.floor(Date.now() / 1000), }) @@ -233,7 +233,7 @@ describe("/oauth/authorize", () => { const jwt = await new SignJWT({ sub: users[0].id, - iss: new URL(config.http.base_url).origin, + iss: config.http.base_url.origin, aud: application.data.clientId, exp: Math.floor(Date.now() / 1000) + 60 * 60, iat: Math.floor(Date.now() / 1000), @@ -278,7 +278,7 @@ describe("/oauth/authorize", () => { const jwt = await new SignJWT({ sub: users[0].id, aud: "invalid-client-id", - iss: new URL(config.http.base_url).origin, + iss: config.http.base_url.origin, exp: Math.floor(Date.now() / 1000) + 60 * 60, iat: Math.floor(Date.now() / 1000), nbf: Math.floor(Date.now() / 1000), @@ -319,7 +319,7 @@ describe("/oauth/authorize", () => { test("should return error for invalid redirect_uri", async () => { const jwt = await new SignJWT({ sub: users[0].id, - iss: new URL(config.http.base_url).origin, + iss: config.http.base_url.origin, aud: application.data.clientId, exp: Math.floor(Date.now() / 1000) + 60 * 60, iat: Math.floor(Date.now() / 1000), @@ -361,7 +361,7 @@ describe("/oauth/authorize", () => { test("should return error for invalid scope", async () => { const jwt = await new SignJWT({ sub: users[0].id, - iss: new URL(config.http.base_url).origin, + iss: config.http.base_url.origin, aud: application.data.clientId, exp: Math.floor(Date.now() / 1000) + 60 * 60, iat: Math.floor(Date.now() / 1000), diff --git a/tests/api.test.ts b/tests/api.test.ts index 60505640..799d7bbe 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -30,7 +30,7 @@ describe("API Tests", () => { // Now automatically mitigated by the server /* test("try sending a request with a different origin", async () => { - if (new URL(config.http.base_url).protocol === "http:") { + if (config.http.base_url.protocol === "http:") { return; } diff --git a/tests/api/accounts.test.ts b/tests/api/accounts.test.ts index 3e79d7ba..b4b46642 100644 --- a/tests/api/accounts.test.ts +++ b/tests/api/accounts.test.ts @@ -93,7 +93,7 @@ describe("API Tests", () => { expect(account.fields).toEqual([]); expect(account.source?.fields).toEqual([]); expect(account.source?.privacy).toBe("public"); - expect(account.source?.language).toBeNull(); + expect(account.source?.language).toBe("en"); expect(account.source?.note).toBe(""); expect(account.source?.sensitive).toBe(false); }); diff --git a/tests/utils.ts b/tests/utils.ts index cfcf9114..0c632b5c 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -45,8 +45,8 @@ export const getTestUsers = async ( const password = randomString(32, "hex"); const user = await User.fromDataLocal({ - username: `test-${randomString(32, "hex")}`, - email: `${randomString(32, "hex")}@test.com`, + username: `test-${randomString(8, "hex")}`, + email: `${randomString(16, "hex")}@test.com`, password, }); diff --git a/utils/api.ts b/utils/api.ts index b9c32805..736035f0 100644 --- a/utils/api.ts +++ b/utils/api.ts @@ -29,7 +29,35 @@ import { fromZodError } from "zod-validation-error"; import { ApiError } from "~/classes/errors/api-error"; import type { AuthData } from "~/classes/functions/user"; import { config } from "~/packages/config-manager/index.ts"; -import type { HonoEnv } from "~/types/api"; +import { ErrorSchema, type HonoEnv } from "~/types/api"; + +export const reusedResponses = { + 401: { + description: "Invalid or missing Authorization header.", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 422: { + description: "Invalid values in request", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, +}; + +export const accountNotFound = { + description: "Account does not exist", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, +}; export const apiRoute = (fn: (app: OpenAPIHono) => void): typeof fn => fn; From 247a8fbce3261419da5dd29b58bb721578fa025e Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Thu, 13 Feb 2025 02:34:44 +0100 Subject: [PATCH 08/11] refactor(api): :label: Port more misc endpoints to use new schemas --- api/api/v1/accounts/index.ts | 11 +- api/api/v1/apps/index.ts | 92 +++++----- api/api/v1/apps/verify_credentials/index.ts | 23 ++- api/api/v1/blocks/index.ts | 59 +++++-- api/api/v1/custom_emojis/index.ts | 17 +- api/api/v1/emojis/:id/index.ts | 157 ++++++++---------- api/api/v1/emojis/index.ts | 92 ++++------ api/api/v1/favourites/index.ts | 58 +++++-- .../follow_requests/:account_id/authorize.ts | 34 ++-- .../v1/follow_requests/:account_id/reject.ts | 32 ++-- api/api/v1/follow_requests/index.ts | 59 +++++-- api/api/v1/mutes/index.ts | 58 +++++-- classes/schemas/application.ts | 49 ++++-- classes/schemas/emoji.ts | 28 +++- tests/oauth.test.ts | 5 +- utils/api.ts | 39 ++++- 16 files changed, 462 insertions(+), 351 deletions(-) diff --git a/api/api/v1/accounts/index.ts b/api/api/v1/accounts/index.ts index b599a4f3..f0f3ad80 100644 --- a/api/api/v1/accounts/index.ts +++ b/api/api/v1/accounts/index.ts @@ -1,4 +1,4 @@ -import { apiRoute, auth, jsonOrForm } from "@/api"; +import { apiRoute, auth, jsonOrForm, reusedResponses } from "@/api"; import { tempmailDomains } from "@/tempmail"; import { createRoute, z } from "@hono/zod-openapi"; import { User } from "@versia/kit/db"; @@ -74,8 +74,9 @@ const route = createRoute({ }, responses: { 200: { - description: "Account created", + description: "Token for the created account", }, + 401: reusedResponses[401], 422: { description: "Validation failed", content: { @@ -346,9 +347,9 @@ export default apiRoute((app) => } await User.fromDataLocal({ - username: username ?? "", - password: password ?? "", - email: email ?? "", + username: username, + password: password, + email: email, }); return context.text("", 200); diff --git a/api/api/v1/apps/index.ts b/api/api/v1/apps/index.ts index 69227bdc..2670f27f 100644 --- a/api/api/v1/apps/index.ts +++ b/api/api/v1/apps/index.ts @@ -1,62 +1,61 @@ -import { apiRoute, jsonOrForm } from "@/api"; +import { apiRoute, jsonOrForm, reusedResponses } from "@/api"; import { randomString } from "@/math"; import { createRoute, z } from "@hono/zod-openapi"; import { Application } from "@versia/kit/db"; - -const schemas = { - json: z.object({ - client_name: z.string().trim().min(1).max(100), - redirect_uris: z - .string() - .min(0) - .max(2000) - .url() - .or(z.literal("urn:ietf:wg:oauth:2.0:oob")), - scopes: z.string().min(1).max(200), - website: z - .string() - .min(0) - .max(2000) - .url() - .optional() - // Allow empty websites because Traewelling decides to give an empty - // value instead of not providing anything at all - .or(z.literal("").transform(() => undefined)), - }), -}; +import { + Application as ApplicationSchema, + CredentialApplication as CredentialApplicationSchema, +} from "~/classes/schemas/application"; const route = createRoute({ method: "post", path: "/api/v1/apps", - summary: "Create app", - description: "Create an OAuth2 app", + summary: "Create an application", + description: "Create a new application to obtain OAuth2 credentials.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/apps/#create", + }, + tags: ["Apps"], middleware: [jsonOrForm()], request: { body: { content: { "application/json": { - schema: schemas.json, + schema: z.object({ + client_name: ApplicationSchema.shape.name, + redirect_uris: ApplicationSchema.shape.redirect_uris.or( + ApplicationSchema.shape.redirect_uri.transform( + (u) => u.split("\n"), + ), + ), + scopes: z + .string() + .default("read") + .transform((s) => s.split(" ")) + .openapi({ + description: "Space separated list of scopes.", + }), + // Allow empty websites because Traewelling decides to give an empty + // value instead of not providing anything at all + website: ApplicationSchema.shape.website + .optional() + .or(z.literal("").transform(() => undefined)), + }), }, }, }, }, responses: { 200: { - description: "App", + description: + "Store the client_id and client_secret in your cache, as these will be used to obtain OAuth tokens.", content: { "application/json": { - schema: z.object({ - id: z.string().uuid(), - name: z.string(), - website: z.string().nullable(), - client_id: z.string(), - client_secret: z.string(), - redirect_uri: z.string(), - vapid_link: z.string().nullable(), - }), + schema: CredentialApplicationSchema, }, }, }, + 422: reusedResponses[422], }, }); @@ -66,25 +65,14 @@ export default apiRoute((app) => context.req.valid("json"); const app = await Application.insert({ - name: client_name || "", - redirectUri: decodeURI(redirect_uris) || "", - scopes: scopes || "read", - website: website || null, + name: client_name, + redirectUri: redirect_uris.join("\n"), + scopes: scopes.join(" "), + website: website, clientId: randomString(32, "base64url"), secret: randomString(64, "base64url"), }); - return context.json( - { - id: app.id, - name: app.data.name, - website: app.data.website, - client_id: app.data.clientId, - client_secret: app.data.secret, - redirect_uri: app.data.redirectUri, - vapid_link: app.data.vapidKey, - }, - 200, - ); + return context.json(app.toApiCredential(), 200); }), ); diff --git a/api/api/v1/apps/verify_credentials/index.ts b/api/api/v1/apps/verify_credentials/index.ts index 43de0474..c1a8ef5c 100644 --- a/api/api/v1/apps/verify_credentials/index.ts +++ b/api/api/v1/apps/verify_credentials/index.ts @@ -1,16 +1,19 @@ -import { apiRoute, auth } from "@/api"; +import { apiRoute, auth, reusedResponses } from "@/api"; 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({ method: "get", path: "/api/v1/apps/verify_credentials", - summary: "Verify credentials", - description: "Get your own application information", + summary: "Verify your app works", + description: "Confirm that the app’s OAuth2 credentials work.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/apps/#verify_credentials", + }, + tags: ["Apps"], middleware: [ auth({ auth: true, @@ -19,21 +22,15 @@ const route = createRoute({ ] as const, responses: { 200: { - description: "Application", + description: + "If the Authorization header was provided with a valid token, you should see your app returned as an Application entity.", content: { "application/json": { schema: ApplicationSchema, }, }, }, - 401: { - description: "Unauthorized", - content: { - "application/json": { - schema: ErrorSchema, - }, - }, - }, + ...reusedResponses, }, }); diff --git a/api/api/v1/blocks/index.ts b/api/api/v1/blocks/index.ts index a28a8484..429725a5 100644 --- a/api/api/v1/blocks/index.ts +++ b/api/api/v1/blocks/index.ts @@ -1,24 +1,19 @@ -import { apiRoute, auth } from "@/api"; +import { apiRoute, auth, reusedResponses } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { Timeline } from "@versia/kit/db"; import { RolePermissions, Users } from "@versia/kit/tables"; import { and, gt, gte, lt, sql } from "drizzle-orm"; -import { Account } from "~/classes/schemas/account"; - -const schemas = { - query: z.object({ - max_id: z.string().uuid().optional(), - since_id: z.string().uuid().optional(), - min_id: z.string().uuid().optional(), - limit: z.coerce.number().int().min(1).max(80).default(40), - }), -}; +import { Account as AccountSchema } from "~/classes/schemas/account"; const route = createRoute({ method: "get", path: "/api/v1/blocks", - summary: "Get blocks", - description: "Get users you have blocked", + summary: "View your blocks.", + description: "View blocked users.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/blocks/#get", + }, + tags: ["Blocks"], middleware: [ auth({ auth: true, @@ -27,17 +22,49 @@ const route = createRoute({ }), ] as const, request: { - query: schemas.query, + query: z.object({ + max_id: AccountSchema.shape.id.optional().openapi({ + description: + "All results returned will be lesser than this ID. In effect, sets an upper bound on results.", + example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa", + }), + since_id: AccountSchema.shape.id.optional().openapi({ + description: + "All results returned will be greater than this ID. In effect, sets a lower bound on results.", + example: undefined, + }), + min_id: AccountSchema.shape.id.optional().openapi({ + description: + "Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.", + example: undefined, + }), + limit: z.coerce.number().int().min(1).max(80).default(40).openapi({ + description: "Maximum number of results to return.", + }), + }), }, responses: { 200: { - description: "Blocks", + description: "List of blocked users", content: { "application/json": { - schema: z.array(Account), + schema: z.array(AccountSchema), }, }, + headers: z.object({ + link: z + .string() + .optional() + .openapi({ + description: "Links to the next and previous pages", + example: `; rel="next", ; rel="prev"`, + externalDocs: { + url: "https://docs.joinmastodon.org/api/guidelines/#pagination", + }, + }), + }), }, + ...reusedResponses, }, }); diff --git a/api/api/v1/custom_emojis/index.ts b/api/api/v1/custom_emojis/index.ts index fd754c36..5cb82028 100644 --- a/api/api/v1/custom_emojis/index.ts +++ b/api/api/v1/custom_emojis/index.ts @@ -1,15 +1,19 @@ -import { apiRoute, auth } from "@/api"; +import { apiRoute, auth, reusedResponses } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { Emoji } from "@versia/kit/db"; import { Emojis, RolePermissions } from "@versia/kit/tables"; import { and, eq, isNull, or } from "drizzle-orm"; -import { CustomEmoji } from "~/classes/schemas/emoji"; +import { CustomEmoji as CustomEmojiSchema } from "~/classes/schemas/emoji"; const route = createRoute({ method: "get", path: "/api/v1/custom_emojis", - summary: "Get custom emojis", - description: "Get custom emojis", + summary: "View all custom emoji", + description: "Returns custom emojis that are available on the server.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/custom_emojis/#get", + }, + tags: ["Emojis"], middleware: [ auth({ auth: false, @@ -18,13 +22,14 @@ const route = createRoute({ ] as const, responses: { 200: { - description: "Emojis", + description: "List of custom emojis", content: { "application/json": { - schema: z.array(CustomEmoji), + schema: z.array(CustomEmojiSchema), }, }, }, + 422: reusedResponses[422], }, }); diff --git a/api/api/v1/emojis/:id/index.ts b/api/api/v1/emojis/:id/index.ts index f40c6a21..4a7a5811 100644 --- a/api/api/v1/emojis/:id/index.ts +++ b/api/api/v1/emojis/:id/index.ts @@ -1,80 +1,73 @@ -import { apiRoute, auth, emojiValidator, jsonOrForm } from "@/api"; +import { + apiRoute, + auth, + jsonOrForm, + reusedResponses, + withEmojiParam, +} from "@/api"; import { mimeLookup } from "@/content_types"; import { createRoute, z } from "@hono/zod-openapi"; -import { Emoji } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import { ApiError } from "~/classes/errors/api-error"; -import { CustomEmoji } from "~/classes/schemas/emoji"; +import { CustomEmoji as CustomEmojiSchema } from "~/classes/schemas/emoji"; import { config } from "~/packages/config-manager"; import { ErrorSchema } from "~/types/api"; -const schemas = { - param: z.object({ - id: z.string().uuid(), - }), - json: z - .object({ - shortcode: z - .string() - .trim() - .min(1) - .max(config.validation.max_emoji_shortcode_size) - .regex( - emojiValidator, - "Shortcode must only contain letters (any case), numbers, dashes or underscores.", - ), - element: z - .string() - .trim() - .min(1) - .max(2000) - .url() - .transform((a) => new URL(a)) - .or( - z - .instanceof(File) - .refine( - (v) => v.size <= config.validation.max_emoji_size, - `Emoji must be less than ${config.validation.max_emoji_size} bytes`, - ), - ), - category: z.string().max(64).optional(), - alt: z - .string() - .max(config.validation.max_emoji_description_size) - .optional(), - global: z - .string() - .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) - .or(z.boolean()) - .optional(), - }) - .partial(), -}; +const schema = z + .object({ + shortcode: CustomEmojiSchema.shape.shortcode, + element: z + .string() + .url() + .transform((a) => new URL(a)) + .openapi({ + description: "Emoji image URL", + }) + .or( + z + .instanceof(File) + .openapi({ + description: + "Emoji image encoded using multipart/form-data", + }) + .refine( + (v) => v.size <= config.validation.max_emoji_size, + `Emoji must be less than ${config.validation.max_emoji_size} bytes`, + ), + ), + category: CustomEmojiSchema.shape.category.optional(), + alt: CustomEmojiSchema.shape.description.optional(), + global: CustomEmojiSchema.shape.global.default(false), + }) + .partial(); const routeGet = createRoute({ method: "get", path: "/api/v1/emojis/{id}", - summary: "Get emoji data", + summary: "Get emoji", + description: "Retrieves a custom emoji from database by ID.", + tags: ["Emojis"], middleware: [ auth({ auth: true, permissions: [RolePermissions.ViewEmojis], }), + withEmojiParam, ] as const, request: { - params: schemas.param, + params: z.object({ + id: CustomEmojiSchema.shape.id, + }), }, responses: { 200: { description: "Emoji", content: { "application/json": { - schema: CustomEmoji, + schema: CustomEmojiSchema, }, }, }, - 404: { description: "Emoji not found", content: { @@ -83,6 +76,7 @@ const routeGet = createRoute({ }, }, }, + ...reusedResponses, }, }); @@ -90,6 +84,8 @@ const routePatch = createRoute({ method: "patch", path: "/api/v1/emojis/{id}", summary: "Modify emoji", + description: "Edit image or metadata of an emoji.", + tags: ["Emojis"], middleware: [ auth({ auth: true, @@ -99,19 +95,22 @@ const routePatch = createRoute({ ], }), jsonOrForm(), + withEmojiParam, ] as const, request: { - params: schemas.param, + params: z.object({ + id: CustomEmojiSchema.shape.id, + }), body: { content: { "application/json": { - schema: schemas.json, + schema: schema, }, "application/x-www-form-urlencoded": { - schema: schemas.json, + schema: schema, }, "multipart/form-data": { - schema: schemas.json, + schema: schema, }, }, }, @@ -121,13 +120,12 @@ const routePatch = createRoute({ description: "Emoji modified", content: { "application/json": { - schema: CustomEmoji, + schema: CustomEmojiSchema, }, }, }, - 403: { - description: "Insufficient credentials", + description: "Insufficient permissions", content: { "application/json": { schema: ErrorSchema, @@ -142,14 +140,7 @@ const routePatch = createRoute({ }, }, }, - 422: { - description: "Invalid form data", - content: { - "application/json": { - schema: ErrorSchema, - }, - }, - }, + ...reusedResponses, }, }); @@ -157,6 +148,8 @@ const routeDelete = createRoute({ method: "delete", path: "/api/v1/emojis/{id}", summary: "Delete emoji", + description: "Delete a custom emoji from the database.", + tags: ["Emojis"], middleware: [ auth({ auth: true, @@ -165,15 +158,17 @@ const routeDelete = createRoute({ RolePermissions.ViewEmojis, ], }), + withEmojiParam, ] as const, request: { - params: schemas.param, + params: z.object({ + id: CustomEmojiSchema.shape.id, + }), }, responses: { 204: { description: "Emoji deleted", }, - 404: { description: "Emoji not found", content: { @@ -186,15 +181,9 @@ const routeDelete = createRoute({ }); export default apiRoute((app) => { - app.openapi(routeGet, async (context) => { - const { id } = context.req.valid("param"); + app.openapi(routeGet, (context) => { const { user } = context.get("auth"); - - const emoji = await Emoji.fromId(id); - - if (!emoji) { - throw new ApiError(404, "Emoji not found"); - } + const emoji = context.get("emoji"); // Don't leak non-global emojis to non-admins if ( @@ -208,14 +197,8 @@ export default apiRoute((app) => { }); app.openapi(routePatch, async (context) => { - const { id } = context.req.valid("param"); const { user } = context.get("auth"); - - const emoji = await Emoji.fromId(id); - - if (!emoji) { - throw new ApiError(404, "Emoji not found"); - } + const emoji = context.get("emoji"); // Check if user is admin if ( @@ -246,7 +229,7 @@ export default apiRoute((app) => { } if (element) { - // Check of emoji is an image + // Check if emoji is an image const contentType = element instanceof File ? element.type @@ -283,14 +266,8 @@ export default apiRoute((app) => { }); app.openapi(routeDelete, async (context) => { - const { id } = context.req.valid("param"); const { user } = context.get("auth"); - - const emoji = await Emoji.fromId(id); - - if (!emoji) { - throw new ApiError(404, "Emoji not found"); - } + const emoji = context.get("emoji"); // Check if user is admin if ( diff --git a/api/api/v1/emojis/index.ts b/api/api/v1/emojis/index.ts index fe1bddd7..81105340 100644 --- a/api/api/v1/emojis/index.ts +++ b/api/api/v1/emojis/index.ts @@ -1,58 +1,44 @@ -import { apiRoute, auth, emojiValidator, jsonOrForm } from "@/api"; +import { apiRoute, auth, jsonOrForm, reusedResponses } from "@/api"; import { mimeLookup } from "@/content_types"; import { createRoute, z } from "@hono/zod-openapi"; import { Emoji, Media } from "@versia/kit/db"; import { Emojis, RolePermissions } from "@versia/kit/tables"; import { and, eq, isNull, or } from "drizzle-orm"; import { ApiError } from "~/classes/errors/api-error"; -import { CustomEmoji } from "~/classes/schemas/emoji"; +import { CustomEmoji as CustomEmojiSchema } from "~/classes/schemas/emoji"; import { config } from "~/packages/config-manager"; -import { ErrorSchema } from "~/types/api"; -const schemas = { - json: z.object({ - shortcode: z - .string() - .trim() - .min(1) - .max(config.validation.max_emoji_shortcode_size) - .regex( - emojiValidator, - "Shortcode must only contain letters (any case), numbers, dashes or underscores.", - ), - element: z - .string() - .trim() - .min(1) - .max(2000) - .url() - .transform((a) => new URL(a)) - .or( - z - .instanceof(File) - .refine( - (v) => v.size <= config.validation.max_emoji_size, - `Emoji must be less than ${config.validation.max_emoji_size} bytes`, - ), - ), - category: z.string().max(64).optional(), - alt: z - .string() - .max(config.validation.max_emoji_description_size) - .optional(), - global: z - .string() - .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) - .or(z.boolean()) - .optional(), - }), -}; +const schema = z.object({ + shortcode: CustomEmojiSchema.shape.shortcode, + element: z + .string() + .url() + .transform((a) => new URL(a)) + .openapi({ + description: "Emoji image URL", + }) + .or( + z + .instanceof(File) + .openapi({ + description: + "Emoji image encoded using multipart/form-data", + }) + .refine( + (v) => v.size <= config.validation.max_emoji_size, + `Emoji must be less than ${config.validation.max_emoji_size} bytes`, + ), + ), + category: CustomEmojiSchema.shape.category.optional(), + alt: CustomEmojiSchema.shape.description.optional(), + global: CustomEmojiSchema.shape.global.default(false), +}); const route = createRoute({ method: "post", path: "/api/v1/emojis", summary: "Upload emoji", - description: "Upload an emoji", + description: "Upload a new emoji to the server.", middleware: [ auth({ auth: true, @@ -67,13 +53,13 @@ const route = createRoute({ body: { content: { "application/json": { - schema: schemas.json, + schema: schema, }, "multipart/form-data": { - schema: schemas.json, + schema: schema, }, "application/x-www-form-urlencoded": { - schema: schemas.json, + schema: schema, }, }, }, @@ -83,19 +69,11 @@ const route = createRoute({ description: "Uploaded emoji", content: { "application/json": { - schema: CustomEmoji, - }, - }, - }, - - 422: { - description: "Invalid data", - content: { - "application/json": { - schema: ErrorSchema, + schema: CustomEmojiSchema, }, }, }, + ...reusedResponses, }, }); @@ -145,10 +123,10 @@ export default apiRoute((app) => const media = element instanceof File ? await Media.fromFile(element, { - description: alt, + description: alt ?? undefined, }) : await Media.fromUrl(element, { - description: alt, + description: alt ?? undefined, }); const emoji = await Emoji.insert({ diff --git a/api/api/v1/favourites/index.ts b/api/api/v1/favourites/index.ts index 09956e8c..9c4d2d04 100644 --- a/api/api/v1/favourites/index.ts +++ b/api/api/v1/favourites/index.ts @@ -1,23 +1,19 @@ -import { apiRoute, auth } from "@/api"; +import { apiRoute, auth, reusedResponses } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; 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({ - max_id: z.string().uuid().optional(), - since_id: z.string().uuid().optional(), - min_id: z.string().uuid().optional(), - limit: z.coerce.number().int().min(1).max(80).default(40), - }), -}; +import { Status as StatusSchema } from "~/classes/schemas/status"; const route = createRoute({ method: "get", path: "/api/v1/favourites", - summary: "Get favourites", + summary: "View favourited statuses", + description: "Statuses the user has favourited.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/favourites/#get", + }, + tags: ["Favourites"], middleware: [ auth({ auth: true, @@ -25,17 +21,49 @@ const route = createRoute({ }), ] as const, request: { - query: schemas.query, + query: z.object({ + max_id: StatusSchema.shape.id.optional().openapi({ + description: + "All results returned will be lesser than this ID. In effect, sets an upper bound on results.", + example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa", + }), + since_id: StatusSchema.shape.id.optional().openapi({ + description: + "All results returned will be greater than this ID. In effect, sets a lower bound on results.", + example: undefined, + }), + min_id: StatusSchema.shape.id.optional().openapi({ + description: + "Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.", + example: undefined, + }), + limit: z.coerce.number().int().min(1).max(80).default(40).openapi({ + description: "Maximum number of results to return.", + }), + }), }, responses: { 200: { - description: "Favourites", + description: "List of favourited statuses", content: { "application/json": { - schema: z.array(Status), + schema: z.array(StatusSchema), }, }, + headers: z.object({ + link: z + .string() + .optional() + .openapi({ + description: "Links to the next and previous pages", + example: `; rel="next", ; rel="prev"`, + externalDocs: { + url: "https://docs.joinmastodon.org/api/guidelines/#pagination", + }, + }), + }), }, + ...reusedResponses, }, }); diff --git a/api/api/v1/follow_requests/:account_id/authorize.ts b/api/api/v1/follow_requests/:account_id/authorize.ts index 1e253b78..78bfbcb7 100644 --- a/api/api/v1/follow_requests/:account_id/authorize.ts +++ b/api/api/v1/follow_requests/:account_id/authorize.ts @@ -1,21 +1,19 @@ -import { apiRoute, auth } from "@/api"; +import { accountNotFound, apiRoute, auth, reusedResponses } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { Relationship, User } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import { ApiError } from "~/classes/errors/api-error"; +import { Account as AccountSchema } from "~/classes/schemas/account"; import { Relationship as RelationshipSchema } from "~/classes/schemas/relationship"; -import { ErrorSchema } from "~/types/api"; - -const schemas = { - param: z.object({ - account_id: z.string().uuid(), - }), -}; const route = createRoute({ method: "post", path: "/api/v1/follow_requests/{account_id}/authorize", - summary: "Authorize follow request", + summary: "Accept follow request", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/follow_requests/#accept", + }, + tags: ["Follows"], middleware: [ auth({ auth: true, @@ -23,26 +21,22 @@ const route = createRoute({ }), ] as const, request: { - params: schemas.param, + params: z.object({ + account_id: AccountSchema.shape.id, + }), }, responses: { 200: { - description: "Relationship", + description: + "Your Relationship with this account should be updated so that you are followed_by this account.", content: { "application/json": { schema: RelationshipSchema, }, }, }, - - 404: { - description: "Account not found", - content: { - "application/json": { - schema: ErrorSchema, - }, - }, - }, + 404: accountNotFound, + ...reusedResponses, }, }); diff --git a/api/api/v1/follow_requests/:account_id/reject.ts b/api/api/v1/follow_requests/:account_id/reject.ts index 00d5fc02..b664dd4d 100644 --- a/api/api/v1/follow_requests/:account_id/reject.ts +++ b/api/api/v1/follow_requests/:account_id/reject.ts @@ -1,21 +1,19 @@ -import { apiRoute, auth } from "@/api"; +import { accountNotFound, apiRoute, auth, reusedResponses } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { Relationship, User } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import { ApiError } from "~/classes/errors/api-error"; +import { Account as AccountSchema } from "~/classes/schemas/account"; import { Relationship as RelationshipSchema } from "~/classes/schemas/relationship"; -import { ErrorSchema } from "~/types/api"; - -const schemas = { - param: z.object({ - account_id: z.string().uuid(), - }), -}; const route = createRoute({ method: "post", path: "/api/v1/follow_requests/{account_id}/reject", summary: "Reject follow request", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/follow_requests/#reject", + }, + tags: ["Follows"], middleware: [ auth({ auth: true, @@ -23,26 +21,22 @@ const route = createRoute({ }), ] as const, request: { - params: schemas.param, + params: z.object({ + account_id: AccountSchema.shape.id, + }), }, responses: { 200: { - description: "Relationship", + description: + "Your Relationship with this account should be unchanged.", content: { "application/json": { schema: RelationshipSchema, }, }, }, - - 404: { - description: "Account not found", - content: { - "application/json": { - schema: ErrorSchema, - }, - }, - }, + 404: accountNotFound, + ...reusedResponses, }, }); diff --git a/api/api/v1/follow_requests/index.ts b/api/api/v1/follow_requests/index.ts index d6f70b22..cf3ac349 100644 --- a/api/api/v1/follow_requests/index.ts +++ b/api/api/v1/follow_requests/index.ts @@ -1,23 +1,19 @@ -import { apiRoute, auth } from "@/api"; +import { apiRoute, auth, reusedResponses } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { Timeline } from "@versia/kit/db"; import { RolePermissions, Users } from "@versia/kit/tables"; import { and, gt, gte, lt, sql } from "drizzle-orm"; -import { Account } from "~/classes/schemas/account"; - -const schemas = { - query: z.object({ - max_id: z.string().uuid().optional(), - since_id: z.string().uuid().optional(), - min_id: z.string().uuid().optional(), - limit: z.coerce.number().int().min(1).max(80).default(40), - }), -}; +import { Account as AccountSchema } from "~/classes/schemas/account"; const route = createRoute({ method: "get", path: "/api/v1/follow_requests", - summary: "Get follow requests", + summary: "View pending follow requests", + description: "Get a list of follow requests that the user has received.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/follow_requests/#get", + }, + tags: ["Follows"], middleware: [ auth({ auth: true, @@ -25,17 +21,50 @@ const route = createRoute({ }), ] as const, request: { - query: schemas.query, + query: z.object({ + max_id: AccountSchema.shape.id.optional().openapi({ + description: + "All results returned will be lesser than this ID. In effect, sets an upper bound on results.", + example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa", + }), + since_id: AccountSchema.shape.id.optional().openapi({ + description: + "All results returned will be greater than this ID. In effect, sets a lower bound on results.", + example: undefined, + }), + min_id: AccountSchema.shape.id.optional().openapi({ + description: + "Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.", + example: undefined, + }), + limit: z.coerce.number().int().min(1).max(80).default(40).openapi({ + description: "Maximum number of results to return.", + }), + }), }, responses: { 200: { - description: "Follow requests", + description: + "List of accounts that have requested to follow the user", content: { "application/json": { - schema: z.array(Account), + schema: z.array(AccountSchema), }, }, + headers: z.object({ + link: z + .string() + .optional() + .openapi({ + description: "Links to the next and previous pages", + example: `; rel="next", ; rel="prev"`, + externalDocs: { + url: "https://docs.joinmastodon.org/api/guidelines/#pagination", + }, + }), + }), }, + ...reusedResponses, }, }); diff --git a/api/api/v1/mutes/index.ts b/api/api/v1/mutes/index.ts index f771d996..6b3230b9 100644 --- a/api/api/v1/mutes/index.ts +++ b/api/api/v1/mutes/index.ts @@ -1,23 +1,19 @@ -import { apiRoute, auth } from "@/api"; +import { apiRoute, auth, reusedResponses } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { Timeline } from "@versia/kit/db"; import { RolePermissions, Users } from "@versia/kit/tables"; import { and, gt, gte, lt, sql } from "drizzle-orm"; -import { Account } from "~/classes/schemas/account"; - -const schemas = { - query: z.object({ - max_id: z.string().uuid().optional(), - since_id: z.string().uuid().optional(), - min_id: z.string().uuid().optional(), - limit: z.coerce.number().int().min(1).max(80).default(40), - }), -}; +import { Account as AccountSchema } from "~/classes/schemas/account"; const route = createRoute({ method: "get", path: "/api/v1/mutes", - summary: "Get muted users", + summary: "View muted accounts", + description: "View your mutes.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/mutes/#get", + }, + tags: ["Mutes"], middleware: [ auth({ auth: true, @@ -26,17 +22,49 @@ const route = createRoute({ }), ] as const, request: { - query: schemas.query, + query: z.object({ + max_id: AccountSchema.shape.id.optional().openapi({ + description: + "All results returned will be lesser than this ID. In effect, sets an upper bound on results.", + example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa", + }), + since_id: AccountSchema.shape.id.optional().openapi({ + description: + "All results returned will be greater than this ID. In effect, sets a lower bound on results.", + example: undefined, + }), + min_id: AccountSchema.shape.id.optional().openapi({ + description: + "Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.", + example: undefined, + }), + limit: z.coerce.number().int().min(1).max(80).default(40).openapi({ + description: "Maximum number of results to return.", + }), + }), }, responses: { 200: { - description: "Muted users", + description: "List of muted users", content: { "application/json": { - schema: z.array(Account), + schema: z.array(AccountSchema), }, }, + headers: z.object({ + link: z + .string() + .optional() + .openapi({ + description: "Links to the next and previous pages", + example: `; rel="next", ; rel="prev"`, + externalDocs: { + url: "https://docs.joinmastodon.org/api/guidelines/#pagination", + }, + }), + }), }, + ...reusedResponses, }, }); diff --git a/classes/schemas/application.ts b/classes/schemas/application.ts index 5ec11ecd..ff4271d5 100644 --- a/classes/schemas/application.ts +++ b/classes/schemas/application.ts @@ -1,13 +1,18 @@ 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", - }, - }), + name: z + .string() + .trim() + .min(1) + .max(200) + .openapi({ + description: "The name of your application.", + example: "Test Application", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Application/#name", + }, + }), website: z .string() .nullable() @@ -18,19 +23,26 @@ export const Application = z.object({ 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", - }, - }), + scopes: z + .array(z.string()) + .default(["read"]) + .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'", - }), + z + .string() + .url() + .or(z.literal("urn:ietf:wg:oauth:2.0:oob")) + .openapi({ + description: "URL or 'urn:ietf:wg:oauth:2.0:oob'", + }), ) .openapi({ description: @@ -40,6 +52,7 @@ export const Application = z.object({ }, }), redirect_uri: z.string().openapi({ + deprecated: true, description: "The registered redirection URI(s) for your application. May contain \\n characters when multiple redirect URIs are registered.", externalDocs: { diff --git a/classes/schemas/emoji.ts b/classes/schemas/emoji.ts index 0d71c13b..6fcc0f5d 100644 --- a/classes/schemas/emoji.ts +++ b/classes/schemas/emoji.ts @@ -1,5 +1,7 @@ +import { emojiValidator } from "@/api.ts"; import { z } from "@hono/zod-openapi"; import { zBoolean } from "~/packages/config-manager/config.type"; +import { config } from "~/packages/config-manager/index.ts"; import { Id } from "./common.ts"; export const CustomEmoji = z @@ -9,13 +11,22 @@ export const CustomEmoji = z description: "ID of the custom emoji in the database.", example: "af9ccd29-c689-477f-aa27-d7d95fd8fb05", }), - shortcode: z.string().openapi({ - description: "The name of the custom emoji.", - example: "blobaww", - externalDocs: { - url: "https://docs.joinmastodon.org/entities/CustomEmoji/#shortcode", - }, - }), + shortcode: z + .string() + .trim() + .min(1) + .max(config.validation.max_emoji_shortcode_size) + .regex( + emojiValidator, + "Shortcode must only contain letters (any case), numbers, dashes or underscores.", + ) + .openapi({ + description: "The name of the custom emoji.", + example: "blobaww", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/CustomEmoji/#shortcode", + }, + }), url: z .string() .url() @@ -48,6 +59,8 @@ export const CustomEmoji = z }), category: z .string() + .trim() + .max(64) .nullable() .openapi({ description: "Used for sorting custom emoji in the picker.", @@ -64,6 +77,7 @@ export const CustomEmoji = z /* Versia Server API extension */ description: z .string() + .max(config.validation.max_emoji_description_size) .nullable() .openapi({ description: diff --git a/tests/oauth.test.ts b/tests/oauth.test.ts index 2190cc2a..94653b42 100644 --- a/tests/oauth.test.ts +++ b/tests/oauth.test.ts @@ -49,13 +49,14 @@ describe("POST /api/v1/apps/", () => { const json = await response.json(); expect(json).toEqual({ - id: expect.any(String), name: "Test Application", website: "https://example.com", client_id: expect.any(String), client_secret: expect.any(String), + client_secret_expires_at: "0", redirect_uri: "https://example.com", - vapid_link: null, + redirect_uris: ["https://example.com"], + scopes: ["read", "write"], }); clientId = json.client_id; diff --git a/utils/api.ts b/utils/api.ts index 736035f0..69279b16 100644 --- a/utils/api.ts +++ b/utils/api.ts @@ -2,7 +2,7 @@ import type { OpenAPIHono } from "@hono/zod-openapi"; import { z } from "@hono/zod-openapi"; import { zValidator } from "@hono/zod-validator"; import { getLogger } from "@logtape/logtape"; -import { Application, Note, Token, User, db } from "@versia/kit/db"; +import { Application, Emoji, Note, Token, User, db } from "@versia/kit/db"; import { Challenges, type RolePermissions } from "@versia/kit/tables"; import { extractParams, verifySolution } from "altcha-lib"; import chalk from "chalk"; @@ -404,6 +404,43 @@ export const withUserParam = every( } >; +/** + * Middleware to check if an emoji exists and is viewable by the user + * + * Useful in /api/v1/emojis/:id/* routes + * @returns + */ +export const withEmojiParam = every( + zValidator("param", z.object({ id: z.string().uuid() }), handleZodError), + createMiddleware< + HonoEnv & { + Variables: { + emoji: Emoji; + }; + }, + string, + WithIdParam + >(async (context, next) => { + const { id } = context.req.valid("param"); + + const emoji = await Emoji.fromId(id); + + if (!emoji) { + throw new ApiError(404, "Emoji not found"); + } + + context.set("emoji", emoji); + + await next(); + }), +) as MiddlewareHandler< + HonoEnv & { + Variables: { + emoji: Emoji; + }; + } +>; + // Helper function to parse form data async function parseFormData(context: Context): Promise<{ parsed: ParsedQs; From 1856176de58596873f52e8b770900aad848f87f9 Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Fri, 14 Feb 2025 16:44:32 +0100 Subject: [PATCH 09/11] refactor(api): :label: Port almost all remaining v1 endpoints to OpenAPI --- api/api/v1/accounts/:id/statuses.test.ts | 4 +- api/api/v1/challenges/index.ts | 1 + api/api/v1/instance/extended_description.ts | 17 +- api/api/v1/instance/index.ts | 54 +++-- api/api/v1/instance/privacy_policy.ts | 17 +- api/api/v1/instance/rules.ts | 16 +- .../{tos.test.ts => terms_of_service.test.ts} | 6 +- .../instance/{tos.ts => terms_of_service.ts} | 20 +- api/api/v1/markers/index.ts | 48 +++- api/api/v1/media/:id/index.ts | 69 ++++-- api/api/v1/media/index.ts | 54 +++-- api/api/v1/notifications/:id/dismiss.ts | 15 +- api/api/v1/notifications/:id/index.ts | 15 +- api/api/v1/notifications/clear/index.ts | 12 +- .../notifications/destroy_multiple/index.ts | 3 +- api/api/v1/notifications/index.test.ts | 4 +- api/api/v1/notifications/index.ts | 80 ++++-- api/api/v1/profile/avatar.ts | 14 +- api/api/v1/profile/header.ts | 13 +- api/api/v1/push/subscription/index.delete.ts | 4 +- api/api/v1/push/subscription/index.get.ts | 4 +- api/api/v1/push/subscription/index.post.ts | 4 +- api/api/v1/push/subscription/index.put.ts | 4 +- api/api/v1/statuses/:id/context.ts | 38 +-- api/api/v1/statuses/:id/favourite.ts | 23 +- api/api/v1/statuses/:id/favourited_by.ts | 73 ++++-- api/api/v1/statuses/:id/index.ts | 229 +++++++++--------- api/api/v1/statuses/:id/pin.ts | 44 ++-- api/api/v1/statuses/:id/reblog.ts | 83 +++---- api/api/v1/statuses/:id/reblogged_by.ts | 73 ++++-- api/api/v1/statuses/:id/source.ts | 33 ++- api/api/v1/statuses/:id/unfavourite.ts | 25 +- api/api/v1/statuses/:id/unpin.ts | 34 +-- api/api/v1/statuses/:id/unreblog.ts | 36 +-- api/api/v1/statuses/index.test.ts | 20 +- api/api/v1/statuses/index.ts | 208 ++++++++-------- classes/schemas/attachment.ts | 16 +- classes/schemas/poll.ts | 20 +- classes/schemas/status.ts | 34 +++ classes/schemas/tos.ts | 16 ++ tests/api/statuses.test.ts | 2 +- utils/api.ts | 8 + 42 files changed, 919 insertions(+), 574 deletions(-) rename api/api/v1/instance/{tos.test.ts => terms_of_service.test.ts} (75%) rename api/api/v1/instance/{tos.ts => terms_of_service.ts} (59%) create mode 100644 classes/schemas/tos.ts diff --git a/api/api/v1/accounts/:id/statuses.test.ts b/api/api/v1/accounts/:id/statuses.test.ts index c65263fd..ff561ce7 100644 --- a/api/api/v1/accounts/:id/statuses.test.ts +++ b/api/api/v1/accounts/:id/statuses.test.ts @@ -21,7 +21,7 @@ beforeAll(async () => { }, ); - expect(response.status).toBe(201); + expect(response.status).toBe(200); }); // /api/v1/accounts/:id/statuses @@ -78,7 +78,7 @@ describe("/api/v1/accounts/:id/statuses", () => { }), }); - expect(replyResponse.status).toBe(201); + expect(replyResponse.status).toBe(200); const response = await fakeRequest( `/api/v1/accounts/${users[1].id}/statuses?exclude_replies=true`, diff --git a/api/api/v1/challenges/index.ts b/api/api/v1/challenges/index.ts index 4e191acf..b25ae8d7 100644 --- a/api/api/v1/challenges/index.ts +++ b/api/api/v1/challenges/index.ts @@ -10,6 +10,7 @@ const route = createRoute({ path: "/api/v1/challenges", summary: "Generate a challenge", description: "Generate a challenge to solve", + tags: ["Challenges"], middleware: [ auth({ auth: false, diff --git a/api/api/v1/instance/extended_description.ts b/api/api/v1/instance/extended_description.ts index f23f9cc3..810eaa3d 100644 --- a/api/api/v1/instance/extended_description.ts +++ b/api/api/v1/instance/extended_description.ts @@ -1,21 +1,24 @@ import { apiRoute } from "@/api"; import { renderMarkdownInPath } from "@/markdown"; -import { createRoute, z } from "@hono/zod-openapi"; +import { createRoute } from "@hono/zod-openapi"; +import { ExtendedDescription as ExtendedDescriptionSchema } from "~/classes/schemas/extended-description"; import { config } from "~/packages/config-manager"; const route = createRoute({ method: "get", path: "/api/v1/instance/extended_description", - summary: "Get extended description", + summary: "View extended description", + description: "Obtain an extended description of this server", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/instance/#extended_description", + }, + tags: ["Instance"], responses: { 200: { - description: "Extended description", + description: "Server extended description", content: { "application/json": { - schema: z.object({ - updated_at: z.string(), - content: z.string(), - }), + schema: ExtendedDescriptionSchema, }, }, }, diff --git a/api/api/v1/instance/index.ts b/api/api/v1/instance/index.ts index 208dd442..963ea4b9 100644 --- a/api/api/v1/instance/index.ts +++ b/api/api/v1/instance/index.ts @@ -1,16 +1,25 @@ import { apiRoute, auth } from "@/api"; +import { renderMarkdownInPath } from "@/markdown"; import { proxyUrl } from "@/response"; -import { createRoute, z } from "@hono/zod-openapi"; +import { createRoute, type z } from "@hono/zod-openapi"; import { Instance, Note, User } from "@versia/kit/db"; import { Users } from "@versia/kit/tables"; import { and, eq, isNull } from "drizzle-orm"; +import { InstanceV1 as InstanceV1Schema } from "~/classes/schemas/instance-v1"; import manifest from "~/package.json"; import { config } from "~/packages/config-manager"; const route = createRoute({ method: "get", path: "/api/v1/instance", - summary: "Get instance information", + summary: "View server information (v1)", + description: + "Obtain general information about the server. See api/v2/instance instead.", + deprecated: true, + externalDocs: { + url: "https://docs.joinmastodon.org/methods/instance/#v1", + }, + tags: ["Instance"], middleware: [ auth({ auth: false, @@ -20,9 +29,8 @@ const route = createRoute({ 200: { description: "Instance information", content: { - // TODO: Add schemas for this response "application/json": { - schema: z.any(), + schema: InstanceV1Schema, }, }, }, @@ -38,9 +46,10 @@ export default apiRoute((app) => const userCount = await User.getCount(); - const contactAccount = await User.fromSql( - and(isNull(Users.instanceId), eq(Users.isAdmin, true)), - ); + const contactAccount = + (await User.fromSql( + and(isNull(Users.instanceId), eq(Users.isAdmin, true)), + )) ?? (await User.fromSql(isNull(Users.instanceId))); const knownDomainsCount = await Instance.getCount(); @@ -55,6 +64,11 @@ export default apiRoute((app) => } | undefined; + const { content } = await renderMarkdownInPath( + config.instance.extended_description_path ?? "", + "This is a [Versia](https://versia.pub) server with the default extended description.", + ); + // TODO: fill in more values return context.json({ approval_required: false, @@ -84,10 +98,13 @@ export default apiRoute((app) => max_featured_tags: 100, }, }, - description: config.instance.description, + short_description: config.instance.description, + description: content, + // TODO: Add contact email email: "", invites_enabled: false, registrations: config.signups.registration, + // TODO: Implement languages: ["en"], rules: config.signups.rules.map((r, index) => ({ id: String(index), @@ -101,12 +118,10 @@ export default apiRoute((app) => thumbnail: config.instance.logo ? proxyUrl(config.instance.logo).toString() : null, - banner: config.instance.banner - ? proxyUrl(config.instance.banner).toString() - : null, title: config.instance.name, - uri: config.http.base_url, + uri: config.http.base_url.host, urls: { + // TODO: Implement Streaming API streaming_api: "", }, version: "4.3.0-alpha.3+glitch", @@ -123,18 +138,7 @@ export default apiRoute((app) => id: p.id, })) ?? [], }, - contact_account: contactAccount?.toApi() || undefined, - } satisfies Record & { - banner: string | null; - versia_version: string; - sso: { - forced: boolean; - providers: { - id: string; - name: string; - icon?: string; - }[]; - }; - }); + contact_account: (contactAccount as User).toApi(), + } satisfies z.infer); }), ); diff --git a/api/api/v1/instance/privacy_policy.ts b/api/api/v1/instance/privacy_policy.ts index ee7deaa0..f8cb35a4 100644 --- a/api/api/v1/instance/privacy_policy.ts +++ b/api/api/v1/instance/privacy_policy.ts @@ -1,12 +1,18 @@ import { apiRoute, auth } from "@/api"; import { renderMarkdownInPath } from "@/markdown"; -import { createRoute, z } from "@hono/zod-openapi"; +import { createRoute } from "@hono/zod-openapi"; +import { PrivacyPolicy as PrivacyPolicySchema } from "~/classes/schemas/privacy-policy"; import { config } from "~/packages/config-manager"; const route = createRoute({ method: "get", path: "/api/v1/instance/privacy_policy", - summary: "Get instance privacy policy", + summary: "View privacy policy", + description: "Obtain the contents of this server’s privacy policy.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/instance/#privacy_policy", + }, + tags: ["Instance"], middleware: [ auth({ auth: false, @@ -14,13 +20,10 @@ const route = createRoute({ ], responses: { 200: { - description: "Instance privacy policy", + description: "Server privacy policy", content: { "application/json": { - schema: z.object({ - updated_at: z.string(), - content: z.string(), - }), + schema: PrivacyPolicySchema, }, }, }, diff --git a/api/api/v1/instance/rules.ts b/api/api/v1/instance/rules.ts index 24a75528..7826a888 100644 --- a/api/api/v1/instance/rules.ts +++ b/api/api/v1/instance/rules.ts @@ -1,11 +1,17 @@ import { apiRoute, auth } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; +import { Rule as RuleSchema } from "~/classes/schemas/rule"; import { config } from "~/packages/config-manager"; const route = createRoute({ method: "get", path: "/api/v1/instance/rules", - summary: "Get instance rules", + summary: "List of rules", + description: "Rules that the users of this service should follow.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/instance/#rules", + }, + tags: ["Instance"], middleware: [ auth({ auth: false, @@ -16,13 +22,7 @@ const route = createRoute({ description: "Instance rules", content: { "application/json": { - schema: z.array( - z.object({ - id: z.string(), - text: z.string(), - hint: z.string(), - }), - ), + schema: z.array(RuleSchema), }, }, }, diff --git a/api/api/v1/instance/tos.test.ts b/api/api/v1/instance/terms_of_service.test.ts similarity index 75% rename from api/api/v1/instance/tos.test.ts rename to api/api/v1/instance/terms_of_service.test.ts index 2350b4db..be42a689 100644 --- a/api/api/v1/instance/tos.test.ts +++ b/api/api/v1/instance/terms_of_service.test.ts @@ -1,10 +1,10 @@ import { describe, expect, test } from "bun:test"; import { fakeRequest } from "~/tests/utils"; -// /api/v1/instance/tos -describe("/api/v1/instance/tos", () => { +// /api/v1/instance/terms_of_service +describe("/api/v1/instance/terms_of_service", () => { test("should return terms of service", async () => { - const response = await fakeRequest("/api/v1/instance/tos"); + const response = await fakeRequest("/api/v1/instance/terms_of_service"); expect(response.status).toBe(200); diff --git a/api/api/v1/instance/tos.ts b/api/api/v1/instance/terms_of_service.ts similarity index 59% rename from api/api/v1/instance/tos.ts rename to api/api/v1/instance/terms_of_service.ts index aa8ba316..bc5b4d8a 100644 --- a/api/api/v1/instance/tos.ts +++ b/api/api/v1/instance/terms_of_service.ts @@ -1,12 +1,19 @@ import { apiRoute, auth } from "@/api"; import { renderMarkdownInPath } from "@/markdown"; -import { createRoute, z } from "@hono/zod-openapi"; +import { createRoute } from "@hono/zod-openapi"; +import { TermsOfService as TermsOfServiceSchema } from "~/classes/schemas/tos"; import { config } from "~/packages/config-manager"; const route = createRoute({ method: "get", - path: "/api/v1/instance/tos", - summary: "Get instance terms of service", + path: "/api/v1/instance/terms_of_service", + summary: "View terms of service", + description: + "Obtain the contents of this server’s terms of service, if configured.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/instance/#terms_of_service", + }, + tags: ["Instance"], middleware: [ auth({ auth: false, @@ -14,13 +21,10 @@ const route = createRoute({ ], responses: { 200: { - description: "Instance terms of service", + description: "Server terms of service", content: { "application/json": { - schema: z.object({ - updated_at: z.string(), - content: z.string(), - }), + schema: TermsOfServiceSchema, }, }, }, diff --git a/api/api/v1/markers/index.ts b/api/api/v1/markers/index.ts index 162dadf1..61939897 100644 --- a/api/api/v1/markers/index.ts +++ b/api/api/v1/markers/index.ts @@ -1,4 +1,4 @@ -import { apiRoute, auth } from "@/api"; +import { apiRoute, auth, reusedResponses } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { db } from "@versia/kit/db"; import { Markers, RolePermissions } from "@versia/kit/tables"; @@ -15,7 +15,12 @@ const MarkerResponseSchema = z.object({ const routeGet = createRoute({ method: "get", path: "/api/v1/markers", - summary: "Get markers", + summary: "Get saved timeline positions", + description: "Get current positions in timelines.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/markers/#get", + }, + tags: ["Timelines"], middleware: [ auth({ auth: true, @@ -27,8 +32,12 @@ const routeGet = createRoute({ "timeline[]": z .array(z.enum(["home", "notifications"])) .max(2) - .or(z.enum(["home", "notifications"])) - .optional(), + .or(z.enum(["home", "notifications"]).transform((t) => [t])) + .optional() + .openapi({ + description: + "Specify the timeline(s) for which markers should be fetched. Possible values: home, notifications. If not provided, an empty object will be returned.", + }), }), }, responses: { @@ -40,13 +49,19 @@ const routeGet = createRoute({ }, }, }, + ...reusedResponses, }, }); const routePost = createRoute({ method: "post", path: "/api/v1/markers", - summary: "Update markers", + summary: "Save your position in a timeline", + description: "Save current position in timeline.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/markers/#create", + }, + tags: ["Timelines"], middleware: [ auth({ auth: true, @@ -54,11 +69,19 @@ const routePost = createRoute({ }), ] as const, request: { - query: z.object({ - "home[last_read_id]": StatusSchema.shape.id.optional(), - "notifications[last_read_id]": - NotificationSchema.shape.id.optional(), - }), + query: z + .object({ + "home[last_read_id]": StatusSchema.shape.id.openapi({ + description: + "ID of the last status read in the home timeline.", + example: "c62aa212-8198-4ce5-a388-2cc8344a84ef", + }), + "notifications[last_read_id]": + NotificationSchema.shape.id.openapi({ + description: "ID of the last notification read.", + }), + }) + .partial(), }, responses: { 200: { @@ -69,16 +92,15 @@ const routePost = createRoute({ }, }, }, + ...reusedResponses, }, }); export default apiRoute((app) => { app.openapi(routeGet, async (context) => { - const { "timeline[]": timelines } = context.req.valid("query"); + const { "timeline[]": timeline } = context.req.valid("query"); const { user } = context.get("auth"); - const timeline = Array.isArray(timelines) ? timelines : []; - if (!timeline) { return context.json({}, 200); } diff --git a/api/api/v1/media/:id/index.ts b/api/api/v1/media/:id/index.ts index 713cc971..6e0cf8d5 100644 --- a/api/api/v1/media/:id/index.ts +++ b/api/api/v1/media/:id/index.ts @@ -1,30 +1,21 @@ -import { apiRoute, auth } from "@/api"; +import { apiRoute, auth, reusedResponses } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { Media } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import { ApiError } from "~/classes/errors/api-error"; import { Attachment as AttachmentSchema } from "~/classes/schemas/attachment"; -import { config } from "~/packages/config-manager/index.ts"; import { ErrorSchema } from "~/types/api"; -const schemas = { - param: z.object({ - id: z.string().uuid(), - }), - form: z.object({ - thumbnail: z.instanceof(File).optional(), - description: z - .string() - .max(config.validation.max_media_description_size) - .optional(), - focus: z.string().optional(), - }), -}; - const routePut = createRoute({ method: "put", path: "/api/v1/media/{id}", - summary: "Update media", + summary: "Update media attachment", + description: + "Update a MediaAttachment’s parameters, before it is attached to a status and posted.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/media/#update", + }, + tags: ["Media"], middleware: [ auth({ auth: true, @@ -33,40 +24,63 @@ const routePut = createRoute({ }), ] as const, request: { - params: schemas.param, + params: z.object({ + id: AttachmentSchema.shape.id, + }), body: { content: { "multipart/form-data": { - schema: schemas.form, + schema: z + .object({ + thumbnail: z.instanceof(File).openapi({ + description: + "The custom thumbnail of the media to be attached, encoded using multipart form data.", + }), + description: AttachmentSchema.shape.description, + focus: z.string().openapi({ + description: + "Two floating points (x,y), comma-delimited, ranging from -1.0 to 1.0. Used for media cropping on clients.", + externalDocs: { + url: "https://docs.joinmastodon.org/api/guidelines/#focal-points", + }, + }), + }) + .partial(), }, }, }, }, responses: { 200: { - description: "Media updated", + description: "Updated attachment", content: { "application/json": { schema: AttachmentSchema, }, }, }, - 404: { - description: "Media not found", + description: "Attachment not found", content: { "application/json": { schema: ErrorSchema, }, }, }, + ...reusedResponses, }, }); const routeGet = createRoute({ method: "get", path: "/api/v1/media/{id}", - summary: "Get media", + summary: "Get media attachment", + description: + "Get a media attachment, before it is attached to a status and posted, but after it is accepted for processing. Use this method to check that the full-sized media has finished processing.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/media/#get", + }, + tags: ["Media"], middleware: [ auth({ auth: true, @@ -74,11 +88,13 @@ const routeGet = createRoute({ }), ] as const, request: { - params: schemas.param, + params: z.object({ + id: AttachmentSchema.shape.id, + }), }, responses: { 200: { - description: "Media", + description: "Attachment", content: { "application/json": { schema: AttachmentSchema, @@ -86,13 +102,14 @@ const routeGet = createRoute({ }, }, 404: { - description: "Media not found", + description: "Attachment not found", content: { "application/json": { schema: ErrorSchema, }, }, }, + ...reusedResponses, }, }); diff --git a/api/api/v1/media/index.ts b/api/api/v1/media/index.ts index 98485aca..a8335b42 100644 --- a/api/api/v1/media/index.ts +++ b/api/api/v1/media/index.ts @@ -1,27 +1,21 @@ -import { apiRoute, auth } from "@/api"; +import { apiRoute, auth, reusedResponses } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { Media } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import { Attachment as AttachmentSchema } from "~/classes/schemas/attachment"; -import { config } from "~/packages/config-manager/index.ts"; import { ErrorSchema } from "~/types/api"; -const schemas = { - form: z.object({ - file: z.instanceof(File), - thumbnail: z.instanceof(File).optional(), - description: z - .string() - .max(config.validation.max_media_description_size) - .optional(), - focus: z.string().optional(), - }), -}; - const route = createRoute({ method: "post", path: "/api/v1/media", - summary: "Upload media", + summary: "Upload media as an attachment (v1)", + description: + "Creates an attachment to be used with a new status. This method will return after the full sized media is done processing.", + deprecated: true, + externalDocs: { + url: "https://docs.joinmastodon.org/methods/media/#v1", + }, + tags: ["Media"], middleware: [ auth({ auth: true, @@ -33,21 +27,42 @@ const route = createRoute({ body: { content: { "multipart/form-data": { - schema: schemas.form, + schema: z.object({ + file: z.instanceof(File).openapi({ + description: + "The file to be attached, encoded using multipart form data. The file must have a MIME type.", + }), + thumbnail: z.instanceof(File).optional().openapi({ + description: + "The custom thumbnail of the media to be attached, encoded using multipart form data.", + }), + description: + AttachmentSchema.shape.description.optional(), + focus: z + .string() + .optional() + .openapi({ + description: + "Two floating points (x,y), comma-delimited, ranging from -1.0 to 1.0. Used for media cropping on clients.", + externalDocs: { + url: "https://docs.joinmastodon.org/api/guidelines/#focal-points", + }, + }), + }), }, }, }, }, responses: { 200: { - description: "Attachment", + description: + "Attachment created successfully. Note that the MediaAttachment will be created even if the file is not understood correctly due to failed processing.", content: { "application/json": { schema: AttachmentSchema, }, }, }, - 413: { description: "File too large", content: { @@ -64,6 +79,7 @@ const route = createRoute({ }, }, }, + ...reusedResponses, }, }); @@ -73,7 +89,7 @@ export default apiRoute((app) => const attachment = await Media.fromFile(file, { thumbnail, - description, + description: description ?? undefined, }); return context.json(attachment.toApi(), 200); diff --git a/api/api/v1/notifications/:id/dismiss.ts b/api/api/v1/notifications/:id/dismiss.ts index 86e3415d..e9c4df6a 100644 --- a/api/api/v1/notifications/:id/dismiss.ts +++ b/api/api/v1/notifications/:id/dismiss.ts @@ -1,13 +1,19 @@ -import { apiRoute, auth } from "@/api"; +import { apiRoute, auth, reusedResponses } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { Notification } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import { ApiError } from "~/classes/errors/api-error"; +import { Notification as NotificationSchema } from "~/classes/schemas/notification"; const route = createRoute({ method: "post", path: "/api/v1/notifications/{id}/dismiss", - summary: "Dismiss notification", + summary: "Dismiss a single notification", + description: "Dismiss a single notification from the server.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/notifications/#dismiss", + }, + tags: ["Notifications"], middleware: [ auth({ auth: true, @@ -17,13 +23,14 @@ const route = createRoute({ ] as const, request: { params: z.object({ - id: z.string().uuid(), + id: NotificationSchema.shape.id, }), }, responses: { 200: { - description: "Notification dismissed", + description: "Notification with given ID successfully dismissed", }, + 401: reusedResponses[401], }, }); diff --git a/api/api/v1/notifications/:id/index.ts b/api/api/v1/notifications/:id/index.ts index 7ae0a4e6..9b52cef7 100644 --- a/api/api/v1/notifications/:id/index.ts +++ b/api/api/v1/notifications/:id/index.ts @@ -1,4 +1,4 @@ -import { apiRoute, auth } from "@/api"; +import { apiRoute, auth, reusedResponses } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { Notification } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; @@ -9,7 +9,12 @@ import { ErrorSchema } from "~/types/api"; const route = createRoute({ method: "get", path: "/api/v1/notifications/{id}", - summary: "Get notification", + summary: "Get a single notification", + description: "View information about a notification with a given ID.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/notifications/#get", + }, + tags: ["Notifications"], middleware: [ auth({ auth: true, @@ -19,19 +24,18 @@ const route = createRoute({ ] as const, request: { params: z.object({ - id: z.string().uuid(), + id: NotificationSchema.shape.id, }), }, responses: { 200: { - description: "Notification", + description: "A single Notification", content: { "application/json": { schema: NotificationSchema, }, }, }, - 404: { description: "Notification not found", content: { @@ -40,6 +44,7 @@ const route = createRoute({ }, }, }, + 401: reusedResponses[401], }, }); diff --git a/api/api/v1/notifications/clear/index.ts b/api/api/v1/notifications/clear/index.ts index 4d5d0174..a4853a40 100644 --- a/api/api/v1/notifications/clear/index.ts +++ b/api/api/v1/notifications/clear/index.ts @@ -1,11 +1,16 @@ -import { apiRoute, auth } from "@/api"; +import { apiRoute, auth, reusedResponses } from "@/api"; import { createRoute } from "@hono/zod-openapi"; import { RolePermissions } from "@versia/kit/tables"; const route = createRoute({ method: "post", path: "/api/v1/notifications/clear", - summary: "Clear notifications", + summary: "Dismiss all notifications", + description: "Clear all notifications from the server.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/notifications/#clear", + }, + tags: ["Notifications"], middleware: [ auth({ auth: true, @@ -15,8 +20,9 @@ const route = createRoute({ ] as const, responses: { 200: { - description: "Notifications cleared", + description: "Notifications successfully cleared.", }, + 401: reusedResponses[401], }, }); diff --git a/api/api/v1/notifications/destroy_multiple/index.ts b/api/api/v1/notifications/destroy_multiple/index.ts index 17e1a322..419c9bd7 100644 --- a/api/api/v1/notifications/destroy_multiple/index.ts +++ b/api/api/v1/notifications/destroy_multiple/index.ts @@ -1,4 +1,4 @@ -import { apiRoute, auth } from "@/api"; +import { apiRoute, auth, reusedResponses } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { RolePermissions } from "@versia/kit/tables"; @@ -26,6 +26,7 @@ const route = createRoute({ 200: { description: "Notifications dismissed", }, + 401: reusedResponses[401], }, }); diff --git a/api/api/v1/notifications/index.test.ts b/api/api/v1/notifications/index.test.ts index 206e4803..9f9ea7d0 100644 --- a/api/api/v1/notifications/index.test.ts +++ b/api/api/v1/notifications/index.test.ts @@ -50,7 +50,7 @@ beforeAll(async () => { }, ); - expect(res3.status).toBe(201); + expect(res3.status).toBe(200); const res4 = await fakeRequest("/api/v1/statuses", { method: "POST", @@ -64,7 +64,7 @@ beforeAll(async () => { }), }); - expect(res4.status).toBe(201); + expect(res4.status).toBe(200); }); afterAll(async () => { diff --git a/api/api/v1/notifications/index.ts b/api/api/v1/notifications/index.ts index 88d766f4..e38a4f18 100644 --- a/api/api/v1/notifications/index.ts +++ b/api/api/v1/notifications/index.ts @@ -1,32 +1,20 @@ -import { apiRoute, auth } from "@/api"; +import { apiRoute, auth, reusedResponses } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { Timeline } from "@versia/kit/db"; import { Notifications, RolePermissions } from "@versia/kit/tables"; import { and, eq, gt, gte, inArray, lt, not, sql } from "drizzle-orm"; import { Account as AccountSchema } from "~/classes/schemas/account"; import { Notification as NotificationSchema } from "~/classes/schemas/notification.ts"; - -const schemas = { - query: z - .object({ - max_id: NotificationSchema.shape.id.optional(), - since_id: NotificationSchema.shape.id.optional(), - min_id: NotificationSchema.shape.id.optional(), - limit: z.coerce.number().int().min(1).max(80).default(15), - exclude_types: z.array(NotificationSchema.shape.type).optional(), - types: z.array(NotificationSchema.shape.type).optional(), - account_id: AccountSchema.shape.id.optional(), - }) - .refine((val) => { - // Can't use both exclude_types and types - return !(val.exclude_types && val.types); - }, "Can't use both exclude_types and types"), -}; +import { zBoolean } from "~/packages/config-manager/config.type"; const route = createRoute({ method: "get", path: "/api/v1/notifications", - summary: "Get notifications", + summary: "Get all notifications", + description: "Notifications concerning the user.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/notifications/#get", + }, middleware: [ auth({ auth: true, @@ -37,7 +25,58 @@ const route = createRoute({ }), ] as const, request: { - query: schemas.query, + query: z + .object({ + max_id: NotificationSchema.shape.id.optional().openapi({ + description: + "All results returned will be lesser than this ID. In effect, sets an upper bound on results.", + example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa", + }), + since_id: NotificationSchema.shape.id.optional().openapi({ + description: + "All results returned will be greater than this ID. In effect, sets a lower bound on results.", + example: undefined, + }), + min_id: NotificationSchema.shape.id.optional().openapi({ + description: + "Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.", + example: undefined, + }), + limit: z.coerce + .number() + .int() + .min(1) + .max(80) + .default(40) + .openapi({ + description: "Maximum number of results to return.", + }), + types: z + .array(NotificationSchema.shape.type) + .optional() + .openapi({ + description: "Types to include in the result.", + }), + exclude_types: z + .array(NotificationSchema.shape.type) + .optional() + .openapi({ + description: "Types to exclude from the results.", + }), + account_id: AccountSchema.shape.id.optional().openapi({ + description: + "Return only notifications received from the specified account.", + }), + // TODO: Implement + include_filtered: zBoolean.default(false).openapi({ + description: + "Whether to include notifications filtered by the user’s NotificationPolicy.", + }), + }) + .refine((val) => { + // Can't use both exclude_types and types + return !(val.exclude_types && val.types); + }, "Can't use both exclude_types and types"), }, responses: { 200: { @@ -48,6 +87,7 @@ const route = createRoute({ }, }, }, + ...reusedResponses, }, }); diff --git a/api/api/v1/profile/avatar.ts b/api/api/v1/profile/avatar.ts index 9c02e985..d5143d68 100644 --- a/api/api/v1/profile/avatar.ts +++ b/api/api/v1/profile/avatar.ts @@ -1,4 +1,4 @@ -import { apiRoute, auth } from "@/api"; +import { apiRoute, auth, reusedResponses } from "@/api"; import { createRoute } from "@hono/zod-openapi"; import { RolePermissions } from "@versia/kit/tables"; import { Account } from "~/classes/schemas/account"; @@ -6,7 +6,12 @@ import { Account } from "~/classes/schemas/account"; const route = createRoute({ method: "delete", path: "/api/v1/profile/avatar", - summary: "Delete avatar", + summary: "Delete profile avatar", + description: "Deletes the avatar associated with the user’s profile.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/profile/#delete-profile-avatar", + }, + tags: ["Profile"], middleware: [ auth({ auth: true, @@ -16,13 +21,16 @@ const route = createRoute({ ] as const, responses: { 200: { - description: "User", + description: + "The avatar was successfully deleted from the user’s profile. If there were no avatar associated with the profile, the response will still indicate a successful deletion.", content: { "application/json": { + // TODO: Return a CredentialAccount schema: Account, }, }, }, + ...reusedResponses, }, }); diff --git a/api/api/v1/profile/header.ts b/api/api/v1/profile/header.ts index 651f6ce4..166f865c 100644 --- a/api/api/v1/profile/header.ts +++ b/api/api/v1/profile/header.ts @@ -1,4 +1,4 @@ -import { apiRoute, auth } from "@/api"; +import { apiRoute, auth, reusedResponses } from "@/api"; import { createRoute } from "@hono/zod-openapi"; import { RolePermissions } from "@versia/kit/tables"; import { Account } from "~/classes/schemas/account"; @@ -6,7 +6,12 @@ import { Account } from "~/classes/schemas/account"; const route = createRoute({ method: "delete", path: "/api/v1/profile/header", - summary: "Delete header", + summary: "Delete profile header", + description: "Deletes the header image associated with the user’s profile.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/profile/#delete-profile-header", + }, + tags: ["Profiles"], middleware: [ auth({ auth: true, @@ -16,13 +21,15 @@ const route = createRoute({ ] as const, responses: { 200: { - description: "User", + description: + "The header was successfully deleted from the user’s profile. If there were no header associated with the profile, the response will still indicate a successful deletion.", content: { "application/json": { schema: Account, }, }, }, + ...reusedResponses, }, }); diff --git a/api/api/v1/push/subscription/index.delete.ts b/api/api/v1/push/subscription/index.delete.ts index 6bd31558..49c9a904 100644 --- a/api/api/v1/push/subscription/index.delete.ts +++ b/api/api/v1/push/subscription/index.delete.ts @@ -1,4 +1,4 @@ -import { apiRoute, auth } from "@/api"; +import { apiRoute, auth, reusedResponses } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { PushSubscription } from "@versia/kit/db"; import { ApiError } from "~/classes/errors/api-error"; @@ -14,6 +14,7 @@ export default apiRoute((app) => externalDocs: { url: "https://docs.joinmastodon.org/methods/push/#delete", }, + tags: ["Push Notifications"], middleware: [ auth({ auth: true, @@ -31,6 +32,7 @@ export default apiRoute((app) => }, }, }, + ...reusedResponses, }, }), async (context) => { diff --git a/api/api/v1/push/subscription/index.get.ts b/api/api/v1/push/subscription/index.get.ts index 157e36b5..375e44d2 100644 --- a/api/api/v1/push/subscription/index.get.ts +++ b/api/api/v1/push/subscription/index.get.ts @@ -1,4 +1,4 @@ -import { apiRoute, auth } from "@/api"; +import { apiRoute, auth, reusedResponses } from "@/api"; import { createRoute } from "@hono/zod-openapi"; import { PushSubscription } from "@versia/kit/db"; import { ApiError } from "~/classes/errors/api-error"; @@ -16,6 +16,7 @@ export default apiRoute((app) => externalDocs: { url: "https://docs.joinmastodon.org/methods/push/#get", }, + tags: ["Push Notifications"], middleware: [ auth({ auth: true, @@ -32,6 +33,7 @@ export default apiRoute((app) => }, }, }, + ...reusedResponses, }, }), async (context) => { diff --git a/api/api/v1/push/subscription/index.post.ts b/api/api/v1/push/subscription/index.post.ts index 2f76301f..9a0e634c 100644 --- a/api/api/v1/push/subscription/index.post.ts +++ b/api/api/v1/push/subscription/index.post.ts @@ -1,4 +1,4 @@ -import { apiRoute } from "@/api"; +import { apiRoute, reusedResponses } from "@/api"; import { auth, jsonOrForm } from "@/api"; import { createRoute } from "@hono/zod-openapi"; import { PushSubscription } from "@versia/kit/db"; @@ -17,6 +17,7 @@ export default apiRoute((app) => externalDocs: { url: "https://docs.joinmastodon.org/methods/push/#create", }, + tags: ["Push Notifications"], middleware: [ auth({ auth: true, @@ -44,6 +45,7 @@ export default apiRoute((app) => }, }, }, + ...reusedResponses, }, }), async (context) => { diff --git a/api/api/v1/push/subscription/index.put.ts b/api/api/v1/push/subscription/index.put.ts index 3cb454e4..d08c1a0d 100644 --- a/api/api/v1/push/subscription/index.put.ts +++ b/api/api/v1/push/subscription/index.put.ts @@ -1,4 +1,4 @@ -import { apiRoute, auth, jsonOrForm } from "@/api"; +import { apiRoute, auth, jsonOrForm, reusedResponses } from "@/api"; import { createRoute } from "@hono/zod-openapi"; import { PushSubscription } from "@versia/kit/db"; import { ApiError } from "~/classes/errors/api-error"; @@ -19,6 +19,7 @@ export default apiRoute((app) => externalDocs: { url: "https://docs.joinmastodon.org/methods/push/#update", }, + tags: ["Push Notifications"], middleware: [ auth({ auth: true, @@ -48,6 +49,7 @@ export default apiRoute((app) => }, }, }, + ...reusedResponses, }, }), async (context) => { diff --git a/api/api/v1/statuses/:id/context.ts b/api/api/v1/statuses/:id/context.ts index 86f3689a..a823f24e 100644 --- a/api/api/v1/statuses/:id/context.ts +++ b/api/api/v1/statuses/:id/context.ts @@ -1,12 +1,24 @@ -import { apiRoute, auth, withNoteParam } from "@/api"; +import { + apiRoute, + auth, + noteNotFound, + reusedResponses, + withNoteParam, +} from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { RolePermissions } from "@versia/kit/tables"; -import { Status } from "~/classes/schemas/status"; -import { ErrorSchema } from "~/types/api"; +import { Context as ContextSchema } from "~/classes/schemas/context"; +import { Status as StatusSchema } from "~/classes/schemas/status"; const route = createRoute({ method: "get", path: "/api/v1/statuses/{id}/context", + summary: "Get parent and child statuses in context", + description: "View statuses above and below this status in the thread.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/statuses/#context", + }, + tags: ["Statuses"], middleware: [ auth({ auth: false, @@ -14,32 +26,22 @@ const route = createRoute({ }), withNoteParam, ] as const, - summary: "Get status context", request: { params: z.object({ - id: z.string().uuid(), + id: StatusSchema.shape.id, }), }, responses: { 200: { - description: "Status context", + description: "Status parent and children", content: { "application/json": { - schema: z.object({ - ancestors: z.array(Status), - descendants: z.array(Status), - }), - }, - }, - }, - 404: { - description: "Record not found", - content: { - "application/json": { - schema: ErrorSchema, + schema: ContextSchema, }, }, }, + 404: noteNotFound, + 401: reusedResponses[401], }, }); diff --git a/api/api/v1/statuses/:id/favourite.ts b/api/api/v1/statuses/:id/favourite.ts index f0ceb0bb..88ed537e 100644 --- a/api/api/v1/statuses/:id/favourite.ts +++ b/api/api/v1/statuses/:id/favourite.ts @@ -1,12 +1,23 @@ -import { apiRoute, auth, withNoteParam } from "@/api"; +import { + apiRoute, + auth, + noteNotFound, + reusedResponses, + withNoteParam, +} from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { RolePermissions } from "@versia/kit/tables"; -import { Status } from "~/classes/schemas/status"; +import { Status as StatusSchema } from "~/classes/schemas/status"; const route = createRoute({ method: "post", path: "/api/v1/statuses/{id}/favourite", summary: "Favourite a status", + description: "Add a status to your favourites list.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/statuses/#favourite", + }, + tags: ["Statuses"], middleware: [ auth({ auth: true, @@ -19,18 +30,20 @@ const route = createRoute({ ] as const, request: { params: z.object({ - id: z.string().uuid(), + id: StatusSchema.shape.id, }), }, responses: { 200: { - description: "Favourited status", + description: "Status favourited or was already favourited", content: { "application/json": { - schema: Status, + schema: StatusSchema, }, }, }, + 404: noteNotFound, + 401: reusedResponses[401], }, }); diff --git a/api/api/v1/statuses/:id/favourited_by.ts b/api/api/v1/statuses/:id/favourited_by.ts index 3c1171fe..33a4293e 100644 --- a/api/api/v1/statuses/:id/favourited_by.ts +++ b/api/api/v1/statuses/:id/favourited_by.ts @@ -1,26 +1,26 @@ -import { apiRoute, auth, withNoteParam } from "@/api"; +import { + apiRoute, + auth, + noteNotFound, + reusedResponses, + withNoteParam, +} from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { Timeline } from "@versia/kit/db"; import { RolePermissions, Users } from "@versia/kit/tables"; import { and, gt, gte, lt, sql } from "drizzle-orm"; -import { Account } from "~/classes/schemas/account"; - -const schemas = { - query: z.object({ - max_id: z.string().uuid().optional(), - since_id: z.string().uuid().optional(), - min_id: z.string().uuid().optional(), - limit: z.coerce.number().int().min(1).max(80).default(40), - }), - param: z.object({ - id: z.string().uuid(), - }), -}; +import { Account as AccountSchema } from "~/classes/schemas/account"; +import { Status as StatusSchema } from "~/classes/schemas/status"; const route = createRoute({ method: "get", path: "/api/v1/statuses/{id}/favourited_by", - summary: "Get users who favourited a status", + summary: "See who favourited a status", + description: "View who favourited a given status.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/statuses/#favourited_by", + }, + tags: ["Statuses"], middleware: [ auth({ auth: true, @@ -32,18 +32,53 @@ const route = createRoute({ withNoteParam, ] as const, request: { - params: schemas.param, - query: schemas.query, + params: z.object({ + id: StatusSchema.shape.id, + }), + query: z.object({ + max_id: AccountSchema.shape.id.optional().openapi({ + description: + "All results returned will be lesser than this ID. In effect, sets an upper bound on results.", + example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa", + }), + since_id: AccountSchema.shape.id.optional().openapi({ + description: + "All results returned will be greater than this ID. In effect, sets a lower bound on results.", + example: undefined, + }), + min_id: AccountSchema.shape.id.optional().openapi({ + description: + "Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.", + example: undefined, + }), + limit: z.coerce.number().int().min(1).max(80).default(40).openapi({ + description: "Maximum number of results to return.", + }), + }), }, responses: { 200: { - description: "Users who favourited a status", + description: "A list of accounts who favourited the status", content: { "application/json": { - schema: z.array(Account), + schema: z.array(AccountSchema), }, }, + headers: z.object({ + link: z + .string() + .optional() + .openapi({ + description: "Links to the next and previous pages", + example: `; rel="next", ; rel="prev"`, + externalDocs: { + url: "https://docs.joinmastodon.org/api/guidelines/#pagination", + }, + }), + }), }, + 404: noteNotFound, + ...reusedResponses, }, }); diff --git a/api/api/v1/statuses/:id/index.ts b/api/api/v1/statuses/:id/index.ts index 78295996..f9495858 100644 --- a/api/api/v1/statuses/:id/index.ts +++ b/api/api/v1/statuses/:id/index.ts @@ -1,75 +1,93 @@ -import { apiRoute, auth, jsonOrForm, withNoteParam } from "@/api"; +import { + apiRoute, + auth, + jsonOrForm, + noteNotFound, + reusedResponses, + withNoteParam, +} from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; 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 { Attachment as AttachmentSchema } from "~/classes/schemas/attachment"; +import { PollOption } from "~/classes/schemas/poll"; +import { + Status as StatusSchema, + StatusSource as StatusSourceSchema, +} from "~/classes/schemas/status"; +import { zBoolean } from "~/packages/config-manager/config.type"; import { config } from "~/packages/config-manager/index.ts"; -import { ErrorSchema } from "~/types/api"; -const schemas = { - param: z.object({ - id: z.string().uuid(), - }), - json: z - .object({ - status: z - .string() - .max(config.validation.max_note_size) - .refine( - (s) => - !config.filters.note_content.some((filter) => - s.match(filter), - ), - "Status contains blocked words", - ) - .optional(), - content_type: z.string().optional().default("text/plain"), - media_ids: z - .array(z.string().uuid()) - .max(config.validation.max_media_attachments) - .default([]), - spoiler_text: z.string().max(255).optional(), - sensitive: z - .string() - .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) - .or(z.boolean()) - .optional(), - language: z - .enum(ISO6391.getAllCodes() as [string, ...string[]]) - .optional(), - "poll[options]": z - .array(z.string().max(config.validation.max_poll_option_size)) - .max(config.validation.max_poll_options) - .optional(), - "poll[expires_in]": z.coerce - .number() - .int() - .min(config.validation.min_poll_duration) - .max(config.validation.max_poll_duration) - .optional(), - "poll[multiple]": z - .string() - .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) - .or(z.boolean()) - .optional(), - "poll[hide_totals]": z - .string() - .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) - .or(z.boolean()) - .optional(), - }) - .refine( - (obj) => !(obj.media_ids.length > 0 && obj["poll[options]"]), - "Cannot attach poll to media", - ), -}; +const schema = z + .object({ + status: StatusSourceSchema.shape.text.optional().openapi({ + description: + "The text content of the status. If media_ids is provided, this becomes optional. Attaching a poll is optional while status is provided.", + }), + /* Versia Server API Extension */ + content_type: z + .enum(["text/plain", "text/html", "text/markdown"]) + .default("text/plain") + .openapi({ + description: "Content-Type of the status text.", + example: "text/markdown", + }), + media_ids: z + .array(AttachmentSchema.shape.id) + .max(config.validation.max_media_attachments) + .default([]) + .openapi({ + description: + "Include Attachment IDs to be attached as media. If provided, status becomes optional, and poll cannot be used.", + }), + spoiler_text: StatusSourceSchema.shape.spoiler_text.optional().openapi({ + description: + "Text to be shown as a warning or subject before the actual content. Statuses are generally collapsed behind this field.", + }), + sensitive: zBoolean.default(false).openapi({ + description: "Mark status and attached media as sensitive?", + }), + language: StatusSchema.shape.language.optional(), + "poll[options]": z + .array(PollOption.shape.title) + .max(config.validation.max_poll_options) + .optional() + .openapi({ + description: + "Possible answers to the poll. If provided, media_ids cannot be used, and poll[expires_in] must be provided.", + }), + "poll[expires_in]": z.coerce + .number() + .int() + .min(config.validation.min_poll_duration) + .max(config.validation.max_poll_duration) + .optional() + .openapi({ + description: + "Duration that the poll should be open, in seconds. If provided, media_ids cannot be used, and poll[options] must be provided.", + }), + "poll[multiple]": zBoolean.optional().openapi({ + description: "Allow multiple choices?", + }), + "poll[hide_totals]": zBoolean.optional().openapi({ + description: "Hide vote counts until the poll ends?", + }), + }) + .refine( + (obj) => !(obj.media_ids.length > 0 && obj["poll[options]"]), + "Cannot attach poll to media", + ); const routeGet = createRoute({ method: "get", path: "/api/v1/statuses/{id}", - summary: "Get status", + summary: "View a single status", + description: "Obtain information about a status.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/statuses/#get", + }, + tags: ["Statuses"], middleware: [ auth({ auth: false, @@ -78,25 +96,20 @@ const routeGet = createRoute({ withNoteParam, ] as const, request: { - params: schemas.param, + params: z.object({ + id: StatusSchema.shape.id, + }), }, responses: { 200: { description: "Status", content: { "application/json": { - schema: Status, - }, - }, - }, - 404: { - description: "Record not found", - content: { - "application/json": { - schema: ErrorSchema, + schema: StatusSchema, }, }, }, + 404: noteNotFound, }, }); @@ -104,6 +117,11 @@ const routeDelete = createRoute({ method: "delete", path: "/api/v1/statuses/{id}", summary: "Delete a status", + description: "Delete one of your own statuses.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/statuses/#delete", + }, + tags: ["Statuses"], middleware: [ auth({ auth: true, @@ -115,40 +133,35 @@ const routeDelete = createRoute({ withNoteParam, ] as const, request: { - params: schemas.param, + params: z.object({ + id: StatusSchema.shape.id, + }), }, responses: { 200: { - description: "Deleted status", + description: + "Note the special properties text and poll or media_attachments which may be used to repost the status, e.g. in case of delete-and-redraft functionality.", content: { "application/json": { - schema: Status, - }, - }, - }, - 401: { - description: "Unauthorized", - content: { - "application/json": { - schema: ErrorSchema, - }, - }, - }, - 404: { - description: "Record not found", - content: { - "application/json": { - schema: ErrorSchema, + schema: StatusSchema, }, }, }, + 404: noteNotFound, + 401: reusedResponses[401], }, }); const routePut = createRoute({ method: "put", path: "/api/v1/statuses/{id}", - summary: "Update a status", + summary: "Edit a status", + description: + "Edit a given status to change its text, sensitivity, media attachments, or poll. Note that editing a poll’s options will reset the votes.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/statuses/#edit", + }, + tags: ["Statuses"], middleware: [ auth({ auth: true, @@ -161,46 +174,34 @@ const routePut = createRoute({ withNoteParam, ] as const, request: { - params: schemas.param, + params: z.object({ + id: StatusSchema.shape.id, + }), body: { content: { "application/json": { - schema: schemas.json, + schema: schema, }, "application/x-www-form-urlencoded": { - schema: schemas.json, + schema: schema, }, "multipart/form-data": { - schema: schemas.json, + schema: schema, }, }, }, }, responses: { 200: { - description: "Updated status", + description: "Status has been successfully edited.", content: { "application/json": { - schema: Status, - }, - }, - }, - 401: { - description: "Unauthorized", - content: { - "application/json": { - schema: ErrorSchema, - }, - }, - }, - 422: { - description: "Invalid media IDs", - content: { - "application/json": { - schema: ErrorSchema, + schema: StatusSchema, }, }, }, + 404: noteNotFound, + ...reusedResponses, }, }); diff --git a/api/api/v1/statuses/:id/pin.ts b/api/api/v1/statuses/:id/pin.ts index 37d16596..3795cbce 100644 --- a/api/api/v1/statuses/:id/pin.ts +++ b/api/api/v1/statuses/:id/pin.ts @@ -1,16 +1,27 @@ -import { apiRoute, auth, withNoteParam } from "@/api"; +import { + apiRoute, + auth, + noteNotFound, + reusedResponses, + withNoteParam, +} from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; 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"; +import { Status as StatusSchema } from "~/classes/schemas/status"; const route = createRoute({ method: "post", path: "/api/v1/statuses/{id}/pin", - summary: "Pin a status", + summary: "Pin status to profile", + description: + "Feature one of your own public statuses at the top of your profile.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/statuses/#pin", + }, + tags: ["Statuses"], middleware: [ auth({ auth: true, @@ -23,34 +34,21 @@ const route = createRoute({ ] as const, request: { params: z.object({ - id: z.string().uuid(), + id: StatusSchema.shape.id, }), }, responses: { 200: { - description: "Pinned status", + description: + "Status pinned. Note the status is not a reblog and its authoring account is your own.", content: { "application/json": { - schema: Status, - }, - }, - }, - 401: { - description: "Unauthorized", - content: { - "application/json": { - schema: ErrorSchema, - }, - }, - }, - 422: { - description: "Already pinned", - content: { - "application/json": { - schema: ErrorSchema, + schema: StatusSchema, }, }, }, + 404: noteNotFound, + 401: reusedResponses[401], }, }); diff --git a/api/api/v1/statuses/:id/reblog.ts b/api/api/v1/statuses/:id/reblog.ts index 20e92ec5..c1b84cfe 100644 --- a/api/api/v1/statuses/:id/reblog.ts +++ b/api/api/v1/statuses/:id/reblog.ts @@ -1,25 +1,26 @@ -import { apiRoute, auth, jsonOrForm, withNoteParam } from "@/api"; +import { + apiRoute, + auth, + jsonOrForm, + noteNotFound, + reusedResponses, + withNoteParam, +} from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; 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 = { - param: z.object({ - id: z.string().uuid(), - }), - json: z.object({ - visibility: z.enum(["public", "unlisted", "private"]).default("public"), - }), -}; +import { Status as StatusSchema } from "~/classes/schemas/status"; const route = createRoute({ method: "post", path: "/api/v1/statuses/{id}/reblog", - summary: "Reblog a status", + summary: "Boost a status", + description: "Reshare a status on your own profile.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/statuses/#boost", + }, + tags: ["Statuses"], middleware: [ auth({ auth: true, @@ -32,46 +33,44 @@ const route = createRoute({ withNoteParam, ] as const, request: { - params: schemas.param, + params: z.object({ + id: StatusSchema.shape.id, + }), body: { content: { "application/json": { - schema: schemas.json, + schema: z.object({ + visibility: + StatusSchema.shape.visibility.default("public"), + }), }, "application/x-www-form-urlencoded": { - schema: schemas.json, + schema: z.object({ + visibility: + StatusSchema.shape.visibility.default("public"), + }), }, "multipart/form-data": { - schema: schemas.json, + schema: z.object({ + visibility: + StatusSchema.shape.visibility.default("public"), + }), }, }, }, }, responses: { - 201: { - description: "Reblogged status", + 200: { + description: + "Status has been reblogged. Note that the top-level ID has changed. The ID of the boosted status is now inside the reblog property. The top-level ID is the ID of the reblog itself. Also note that reblogs cannot be pinned.", content: { "application/json": { - schema: Status, - }, - }, - }, - 422: { - description: "Already reblogged", - content: { - "application/json": { - schema: ErrorSchema, - }, - }, - }, - 500: { - description: "Failed to reblog", - content: { - "application/json": { - schema: ErrorSchema, + schema: StatusSchema, }, }, }, + 404: noteNotFound, + ...reusedResponses, }, }); @@ -86,7 +85,7 @@ export default apiRoute((app) => ); if (existingReblog) { - throw new ApiError(422, "Already reblogged"); + return context.json(await existingReblog.toApi(user), 200); } const newReblog = await Note.insert({ @@ -98,16 +97,10 @@ export default apiRoute((app) => applicationId: null, }); - const finalNewReblog = await Note.fromId(newReblog.id, user?.id); - - if (!finalNewReblog) { - throw new ApiError(500, "Failed to reblog"); - } - if (note.author.isLocal() && user.isLocal()) { await note.author.notify("reblog", user, newReblog); } - return context.json(await finalNewReblog.toApi(user), 201); + return context.json(await newReblog.toApi(user), 200); }), ); diff --git a/api/api/v1/statuses/:id/reblogged_by.ts b/api/api/v1/statuses/:id/reblogged_by.ts index 4e6113e8..d94908fd 100644 --- a/api/api/v1/statuses/:id/reblogged_by.ts +++ b/api/api/v1/statuses/:id/reblogged_by.ts @@ -1,26 +1,26 @@ -import { apiRoute, auth, withNoteParam } from "@/api"; +import { + apiRoute, + auth, + noteNotFound, + reusedResponses, + withNoteParam, +} from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { Timeline } from "@versia/kit/db"; import { RolePermissions, Users } from "@versia/kit/tables"; import { and, gt, gte, lt, sql } from "drizzle-orm"; -import { Account } from "~/classes/schemas/account"; - -const schemas = { - param: z.object({ - id: z.string().uuid(), - }), - query: z.object({ - max_id: z.string().uuid().optional(), - since_id: z.string().uuid().optional(), - min_id: z.string().uuid().optional(), - limit: z.coerce.number().int().min(1).max(80).default(40), - }), -}; +import { Account as AccountSchema } from "~/classes/schemas/account"; +import { Status as StatusSchema } from "~/classes/schemas/status"; const route = createRoute({ method: "get", path: "/api/v1/statuses/{id}/reblogged_by", - summary: "Get users who reblogged a status", + summary: "See who boosted a status", + description: "View who boosted a given status.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/statuses/#reblogged_by", + }, + tags: ["Statuses"], middleware: [ auth({ auth: true, @@ -32,18 +32,53 @@ const route = createRoute({ withNoteParam, ] as const, request: { - params: schemas.param, - query: schemas.query, + params: z.object({ + id: StatusSchema.shape.id, + }), + query: z.object({ + max_id: AccountSchema.shape.id.optional().openapi({ + description: + "All results returned will be lesser than this ID. In effect, sets an upper bound on results.", + example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa", + }), + since_id: AccountSchema.shape.id.optional().openapi({ + description: + "All results returned will be greater than this ID. In effect, sets a lower bound on results.", + example: undefined, + }), + min_id: AccountSchema.shape.id.optional().openapi({ + description: + "Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.", + example: undefined, + }), + limit: z.coerce.number().int().min(1).max(80).default(40).openapi({ + description: "Maximum number of results to return.", + }), + }), }, responses: { 200: { - description: "Users who reblogged a status", + description: "A list of accounts that boosted the status", content: { "application/json": { - schema: z.array(Account), + schema: z.array(AccountSchema), }, }, + headers: z.object({ + link: z + .string() + .optional() + .openapi({ + description: "Links to the next and previous pages", + example: `; rel="next", ; rel="prev"`, + externalDocs: { + url: "https://docs.joinmastodon.org/api/guidelines/#pagination", + }, + }), + }), }, + 404: noteNotFound, + ...reusedResponses, }, }); diff --git a/api/api/v1/statuses/:id/source.ts b/api/api/v1/statuses/:id/source.ts index a483dd11..4c101683 100644 --- a/api/api/v1/statuses/:id/source.ts +++ b/api/api/v1/statuses/:id/source.ts @@ -1,12 +1,27 @@ -import { apiRoute, auth, withNoteParam } from "@/api"; +import { + apiRoute, + auth, + noteNotFound, + reusedResponses, + withNoteParam, +} from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; -import type { StatusSource as ApiStatusSource } from "@versia/client/types"; import { RolePermissions } from "@versia/kit/tables"; +import { + Status as StatusSchema, + StatusSource as StatusSourceSchema, +} from "~/classes/schemas/status"; const route = createRoute({ method: "get", path: "/api/v1/statuses/{id}/source", - summary: "Get status source", + summary: "View status source", + description: + "Obtain the source properties for a status so that it can be edited.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/statuses/#source", + }, + tags: ["Statuses"], middleware: [ auth({ auth: true, @@ -19,7 +34,7 @@ const route = createRoute({ ] as const, request: { params: z.object({ - id: z.string().uuid(), + id: StatusSchema.shape.id, }), }, responses: { @@ -27,14 +42,12 @@ const route = createRoute({ description: "Status source", content: { "application/json": { - schema: z.object({ - id: z.string().uuid(), - spoiler_text: z.string(), - text: z.string(), - }), + schema: StatusSourceSchema, }, }, }, + 404: noteNotFound, + 401: reusedResponses[401], }, }); @@ -48,7 +61,7 @@ export default apiRoute((app) => // TODO: Give real source for spoilerText spoiler_text: note.data.spoilerText, text: note.data.contentSource, - } satisfies ApiStatusSource, + }, 200, ); }), diff --git a/api/api/v1/statuses/:id/unfavourite.ts b/api/api/v1/statuses/:id/unfavourite.ts index 0530467e..a4a848ba 100644 --- a/api/api/v1/statuses/:id/unfavourite.ts +++ b/api/api/v1/statuses/:id/unfavourite.ts @@ -1,12 +1,23 @@ -import { apiRoute, auth, withNoteParam } from "@/api"; +import { + apiRoute, + auth, + noteNotFound, + reusedResponses, + withNoteParam, +} from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { RolePermissions } from "@versia/kit/tables"; -import { Status } from "~/classes/schemas/status"; +import { Status as StatusSchema } from "~/classes/schemas/status"; const route = createRoute({ method: "post", path: "/api/v1/statuses/{id}/unfavourite", - summary: "Unfavourite a status", + summary: "Undo favourite of a status", + description: "Remove a status from your favourites list.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/statuses/#unfavourite", + }, + tags: ["Statuses"], middleware: [ auth({ auth: true, @@ -19,18 +30,20 @@ const route = createRoute({ ] as const, request: { params: z.object({ - id: z.string().uuid(), + id: StatusSchema.shape.id, }), }, responses: { 200: { - description: "Unfavourited status", + description: "Status unfavourited or was already not favourited", content: { "application/json": { - schema: Status, + schema: StatusSchema, }, }, }, + 404: noteNotFound, + 401: reusedResponses[401], }, }); diff --git a/api/api/v1/statuses/:id/unpin.ts b/api/api/v1/statuses/:id/unpin.ts index e531a73a..52b28854 100644 --- a/api/api/v1/statuses/:id/unpin.ts +++ b/api/api/v1/statuses/:id/unpin.ts @@ -1,14 +1,24 @@ -import { apiRoute, auth, withNoteParam } from "@/api"; +import { + apiRoute, + auth, + noteNotFound, + reusedResponses, + withNoteParam, +} from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { RolePermissions } from "@versia/kit/tables"; import { ApiError } from "~/classes/errors/api-error"; -import { Status } from "~/classes/schemas/status"; -import { ErrorSchema } from "~/types/api"; +import { Status as StatusSchema } from "~/classes/schemas/status"; const route = createRoute({ method: "post", path: "/api/v1/statuses/{id}/unpin", - summary: "Unpin a status", + summary: "Unpin status from profile", + description: "Unfeature a status from the top of your profile.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/statuses/#unpin", + }, + tags: ["Statuses"], middleware: [ auth({ auth: true, @@ -21,26 +31,20 @@ const route = createRoute({ ] as const, request: { params: z.object({ - id: z.string().uuid(), + id: StatusSchema.shape.id, }), }, responses: { 200: { - description: "Unpinned status", + description: "Status unpinned, or was already not pinned", content: { "application/json": { - schema: Status, - }, - }, - }, - 401: { - description: "Unauthorized", - content: { - "application/json": { - schema: ErrorSchema, + schema: StatusSchema, }, }, }, + 404: noteNotFound, + 401: reusedResponses[401], }, }); diff --git a/api/api/v1/statuses/:id/unreblog.ts b/api/api/v1/statuses/:id/unreblog.ts index fca1f276..19641e35 100644 --- a/api/api/v1/statuses/:id/unreblog.ts +++ b/api/api/v1/statuses/:id/unreblog.ts @@ -1,16 +1,26 @@ -import { apiRoute, auth, withNoteParam } from "@/api"; +import { + apiRoute, + auth, + noteNotFound, + reusedResponses, + withNoteParam, +} from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; 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"; +import { Status as StatusSchema } from "~/classes/schemas/status"; const route = createRoute({ method: "post", path: "/api/v1/statuses/{id}/unreblog", - summary: "Unreblog a status", + summary: "Undo boost of a status", + description: "Undo a reshare of a status.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/statuses/#unreblog", + }, + tags: ["Statuses"], middleware: [ auth({ auth: true, @@ -23,26 +33,20 @@ const route = createRoute({ ] as const, request: { params: z.object({ - id: z.string().uuid(), + id: StatusSchema.shape.id, }), }, responses: { 200: { - description: "Unreblogged status", + description: "Status unboosted or was already not boosted", content: { "application/json": { - schema: Status, - }, - }, - }, - 422: { - description: "Not already reblogged", - content: { - "application/json": { - schema: ErrorSchema, + schema: StatusSchema, }, }, }, + 404: noteNotFound, + 401: reusedResponses[401], }, }); @@ -59,7 +63,7 @@ export default apiRoute((app) => ); if (!existingReblog) { - throw new ApiError(422, "Note already reblogged"); + return context.json(await note.toApi(user), 200); } await existingReblog.delete(); diff --git a/api/api/v1/statuses/index.test.ts b/api/api/v1/statuses/index.test.ts index 341e420f..f04f90d5 100644 --- a/api/api/v1/statuses/index.test.ts +++ b/api/api/v1/statuses/index.test.ts @@ -161,7 +161,7 @@ describe("/api/v1/statuses", () => { }), }); - expect(response.status).toBe(201); + expect(response.status).toBe(200); expect(response.headers.get("content-type")).toContain( "application/json", ); @@ -186,7 +186,7 @@ describe("/api/v1/statuses", () => { }), }); - expect(response.status).toBe(201); + expect(response.status).toBe(200); expect(response.headers.get("content-type")).toContain( "application/json", ); @@ -223,7 +223,7 @@ describe("/api/v1/statuses", () => { }), }); - expect(response2.status).toBe(201); + expect(response2.status).toBe(200); expect(response2.headers.get("content-type")).toContain( "application/json", ); @@ -260,7 +260,7 @@ describe("/api/v1/statuses", () => { }), }); - expect(response2.status).toBe(201); + expect(response2.status).toBe(200); expect(response2.headers.get("content-type")).toContain( "application/json", ); @@ -283,7 +283,7 @@ describe("/api/v1/statuses", () => { }), }); - expect(response.status).toBe(201); + expect(response.status).toBe(200); expect(response.headers.get("content-type")).toContain( "application/json", ); @@ -310,7 +310,7 @@ describe("/api/v1/statuses", () => { }), }); - expect(response.status).toBe(201); + expect(response.status).toBe(200); expect(response.headers.get("content-type")).toContain( "application/json", ); @@ -342,7 +342,7 @@ describe("/api/v1/statuses", () => { }), }); - expect(response.status).toBe(201); + expect(response.status).toBe(200); expect(response.headers.get("content-type")).toContain( "application/json", ); @@ -371,7 +371,7 @@ describe("/api/v1/statuses", () => { }), }); - expect(response.status).toBe(201); + expect(response.status).toBe(200); expect(response.headers.get("content-type")).toContain( "application/json", ); @@ -397,7 +397,7 @@ describe("/api/v1/statuses", () => { }), }); - expect(response.status).toBe(201); + expect(response.status).toBe(200); expect(response.headers.get("content-type")).toContain( "application/json", ); @@ -421,7 +421,7 @@ describe("/api/v1/statuses", () => { }), }); - expect(response.status).toBe(201); + expect(response.status).toBe(200); expect(response.headers.get("content-type")).toContain( "application/json", ); diff --git a/api/api/v1/statuses/index.ts b/api/api/v1/statuses/index.ts index cbf96b67..1635897b 100644 --- a/api/api/v1/statuses/index.ts +++ b/api/api/v1/statuses/index.ts @@ -1,94 +1,115 @@ -import { apiRoute, auth, jsonOrForm } from "@/api"; +import { apiRoute, auth, jsonOrForm, reusedResponses } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; 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 { Attachment as AttachmentSchema } from "~/classes/schemas/attachment"; +import { PollOption } from "~/classes/schemas/poll"; +import { + Status as StatusSchema, + StatusSource as StatusSourceSchema, +} from "~/classes/schemas/status"; +import { zBoolean } from "~/packages/config-manager/config.type"; import { config } from "~/packages/config-manager/index.ts"; -import { ErrorSchema } from "~/types/api"; -const schemas = { - json: z - .object({ - status: z - .string() - .max(config.validation.max_note_size) - .trim() - .refine( - (s) => - !config.filters.note_content.some((filter) => - s.match(filter), - ), - "Status contains blocked words", - ) - .optional(), - // TODO: Add regex to validate - content_type: z.string().optional().default("text/plain"), - media_ids: z - .array(z.string().uuid()) - .max(config.validation.max_media_attachments) - .default([]), - spoiler_text: z.string().max(255).trim().optional(), - sensitive: z - .string() - .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) - .or(z.boolean()) - .optional(), - language: z - .enum(ISO6391.getAllCodes() as [string, ...string[]]) - .optional(), - "poll[options]": z - .array(z.string().max(config.validation.max_poll_option_size)) - .max(config.validation.max_poll_options) - .optional(), - "poll[expires_in]": z.coerce - .number() - .int() - .min(config.validation.min_poll_duration) - .max(config.validation.max_poll_duration) - .optional(), - "poll[multiple]": z - .string() - .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) - .or(z.boolean()) - .optional(), - "poll[hide_totals]": z - .string() - .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) - .or(z.boolean()) - .optional(), - in_reply_to_id: z.string().uuid().optional().nullable(), - quote_id: z.string().uuid().optional().nullable(), - visibility: z - .enum(["public", "unlisted", "private", "direct"]) - .optional() - .default("public"), - scheduled_at: z.coerce - .date() - .min(new Date(), "Scheduled time must be in the future") - .optional() - .nullable(), - local_only: z - .string() - .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) - .or(z.boolean()) - .optional() - .default(false), - }) - .refine( - (obj) => obj.status || obj.media_ids.length > 0, - "Status is required unless media is attached", - ) - .refine( - (obj) => !(obj.media_ids.length > 0 && obj["poll[options]"]), - "Cannot attach poll to media", - ), -}; +const schema = z + .object({ + status: StatusSourceSchema.shape.text.optional().openapi({ + description: + "The text content of the status. If media_ids is provided, this becomes optional. Attaching a poll is optional while status is provided.", + }), + /* Versia Server API Extension */ + content_type: z + .enum(["text/plain", "text/html", "text/markdown"]) + .default("text/plain") + .openapi({ + description: "Content-Type of the status text.", + example: "text/markdown", + }), + media_ids: z + .array(AttachmentSchema.shape.id) + .max(config.validation.max_media_attachments) + .default([]) + .openapi({ + description: + "Include Attachment IDs to be attached as media. If provided, status becomes optional, and poll cannot be used.", + }), + spoiler_text: StatusSourceSchema.shape.spoiler_text.optional().openapi({ + description: + "Text to be shown as a warning or subject before the actual content. Statuses are generally collapsed behind this field.", + }), + sensitive: zBoolean.default(false).openapi({ + description: "Mark status and attached media as sensitive?", + }), + language: StatusSchema.shape.language.optional(), + "poll[options]": z + .array(PollOption.shape.title) + .max(config.validation.max_poll_options) + .optional() + .openapi({ + description: + "Possible answers to the poll. If provided, media_ids cannot be used, and poll[expires_in] must be provided.", + }), + "poll[expires_in]": z.coerce + .number() + .int() + .min(config.validation.min_poll_duration) + .max(config.validation.max_poll_duration) + .optional() + .openapi({ + description: + "Duration that the poll should be open, in seconds. If provided, media_ids cannot be used, and poll[options] must be provided.", + }), + "poll[multiple]": zBoolean.optional().openapi({ + description: "Allow multiple choices?", + }), + "poll[hide_totals]": zBoolean.optional().openapi({ + description: "Hide vote counts until the poll ends?", + }), + in_reply_to_id: StatusSchema.shape.id.optional().nullable().openapi({ + description: + "ID of the status being replied to, if status is a reply.", + }), + /* Versia Server API Extension */ + quote_id: StatusSchema.shape.id.optional().nullable().openapi({ + description: "ID of the status being quoted, if status is a quote.", + }), + visibility: StatusSchema.shape.visibility.default("public"), + scheduled_at: z.coerce + .date() + .min( + new Date(Date.now() + 5 * 60 * 1000), + "must be at least 5 minutes in the future.", + ) + .optional() + .nullable() + .openapi({ + description: + "Datetime at which to schedule a status. Providing this parameter will cause ScheduledStatus to be returned instead of Status. Must be at least 5 minutes in the future.", + }), + /* Versia Server API Extension */ + local_only: zBoolean.default(false).openapi({ + description: "If true, this status will not be federated.", + }), + }) + .refine( + (obj) => obj.status || obj.media_ids.length > 0 || obj["poll[options]"], + "Status is required unless media or poll is attached", + ) + .refine( + (obj) => !(obj.media_ids.length > 0 && obj["poll[options]"]), + "Cannot attach poll to media", + ); const route = createRoute({ method: "post", path: "/api/v1/statuses", + summary: "Post a new status", + description: "Publish a status with the given parameters.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/statuses/#create", + }, + tags: ["Statuses"], middleware: [ auth({ auth: true, @@ -96,40 +117,31 @@ const route = createRoute({ }), jsonOrForm(), ] as const, - summary: "Post a new status", request: { body: { content: { "application/json": { - schema: schemas.json, + schema: schema, }, "application/x-www-form-urlencoded": { - schema: schemas.json, + schema: schema, }, "multipart/form-data": { - schema: schemas.json, + schema: schema, }, }, }, }, responses: { - 201: { - description: "The new status", + 200: { + description: "Status will be posted with chosen parameters.", content: { "application/json": { - schema: Status, - }, - }, - }, - - 422: { - description: "Invalid data", - content: { - "application/json": { - schema: ErrorSchema, + schema: StatusSchema, }, }, }, + ...reusedResponses, }, }); @@ -193,6 +205,6 @@ export default apiRoute((app) => await newNote.federateToUsers(); } - return context.json(await newNote.toApi(user), 201); + return context.json(await newNote.toApi(user), 200); }), ); diff --git a/classes/schemas/attachment.ts b/classes/schemas/attachment.ts index 9200632c..215d6d20 100644 --- a/classes/schemas/attachment.ts +++ b/classes/schemas/attachment.ts @@ -1,4 +1,5 @@ import { z } from "@hono/zod-openapi"; +import { config } from "~/packages/config-manager/index.ts"; import { Id } from "./common.ts"; export const Attachment = z @@ -50,11 +51,16 @@ export const Attachment = z }, }, }), - description: z.string().nullable().openapi({ - description: - "Alternate text that describes what is in the media attachment, to be used for the visually impaired or when media attachments do not load.", - example: "test media description", - }), + description: z + .string() + .trim() + .max(config.validation.max_media_description_size) + .nullable() + .openapi({ + description: + "Alternate text that describes what is in the media attachment, to be used for the visually impaired or when media attachments do not load.", + example: "test media description", + }), 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.", diff --git a/classes/schemas/poll.ts b/classes/schemas/poll.ts index 75465830..37064b67 100644 --- a/classes/schemas/poll.ts +++ b/classes/schemas/poll.ts @@ -1,16 +1,22 @@ import { z } from "@hono/zod-openapi"; +import { config } from "~/packages/config-manager/index.ts"; import { Id } from "./common.ts"; import { CustomEmoji } from "./emoji.ts"; 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", - }, - }), + title: z + .string() + .trim() + .min(1) + .max(config.validation.max_poll_option_size) + .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() diff --git a/classes/schemas/status.ts b/classes/schemas/status.ts index a8b32971..dbbb1c0e 100644 --- a/classes/schemas/status.ts +++ b/classes/schemas/status.ts @@ -1,6 +1,7 @@ import { z } from "@hono/zod-openapi"; import type { Status as ApiNote } from "@versia/client/types"; import { zBoolean } from "~/packages/config-manager/config.type.ts"; +import { config } from "~/packages/config-manager/index.ts"; import { Account } from "./account.ts"; import { Attachment } from "./attachment.ts"; import { PreviewCard } from "./card.ts"; @@ -49,6 +50,39 @@ export const Mention = z }, }); +export const StatusSource = z + .object({ + id: Id.openapi({ + description: "ID of the status in the database.", + example: "c7db92a4-e472-4e94-a115-7411ee934ba1", + }), + text: z + .string() + .max(config.validation.max_note_size) + .trim() + .refine( + (s) => + !config.filters.note_content.some((filter) => + s.match(filter), + ), + "Status contains blocked words", + ) + .openapi({ + description: "The plain text used to compose the status.", + example: "this is a status that will be edited", + }), + spoiler_text: z.string().trim().min(1).max(1024).openapi({ + description: + "The plain text used to compose the status’s subject or content warning.", + example: "", + }), + }) + .openapi({ + externalDocs: { + url: "https://docs.joinmastodon.org/entities/StatusSource", + }, + }); + export const Status = z.object({ id: Id.openapi({ description: "ID of the status in the database.", diff --git a/classes/schemas/tos.ts b/classes/schemas/tos.ts new file mode 100644 index 00000000..a47646a6 --- /dev/null +++ b/classes/schemas/tos.ts @@ -0,0 +1,16 @@ +import { z } from "@hono/zod-openapi"; + +export const TermsOfService = z + .object({ + updated_at: z.string().datetime().openapi({ + description: "A timestamp of when the ToS was last updated.", + example: "2025-01-12T13:11:00Z", + }), + content: z.string().openapi({ + description: "The rendered HTML content of the ToS.", + example: "

ToS

None, have fun.

", + }), + }) + .openapi({ + description: "Represents the ToS of the instance.", + }); diff --git a/tests/api/statuses.test.ts b/tests/api/statuses.test.ts index 167b55e4..100dd011 100644 --- a/tests/api/statuses.test.ts +++ b/tests/api/statuses.test.ts @@ -189,7 +189,7 @@ describe("API Tests", () => { }, ); - expect(response.status).toBe(201); + expect(response.status).toBe(200); expect(response.headers.get("content-type")).toContain( "application/json", ); diff --git a/utils/api.ts b/utils/api.ts index 69279b16..9c6d6ac3 100644 --- a/utils/api.ts +++ b/utils/api.ts @@ -58,6 +58,14 @@ export const accountNotFound = { }, }, }; +export const noteNotFound = { + description: "Status does not exist", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, +}; export const apiRoute = (fn: (app: OpenAPIHono) => void): typeof fn => fn; From 6a810529bcd1d19f6b1bc69cf8d6fa8cc27076ad Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Fri, 14 Feb 2025 17:49:34 +0100 Subject: [PATCH 10/11] refactor(api): :label: Finish OpenAPI documentation refactor --- api/api/v1/instance/index.ts | 3 +- api/api/v1/statuses/:id/reblog.ts | 9 +- api/api/v1/timelines/home.ts | 58 ++++++++--- api/api/v1/timelines/public.ts | 89 +++++++++++----- api/api/v2/filters/:id/index.ts | 151 +++++++++++++-------------- api/api/v2/filters/index.ts | 121 +++++++++------------- api/api/v2/instance/index.ts | 165 +++++++++--------------------- api/api/v2/media/index.ts | 62 +++++++---- api/api/v2/search/index.ts | 101 +++++++++++++----- classes/schemas/filters.ts | 42 +++++--- package.json | 2 +- tests/api/statuses.test.ts | 4 +- 12 files changed, 428 insertions(+), 379 deletions(-) diff --git a/api/api/v1/instance/index.ts b/api/api/v1/instance/index.ts index 963ea4b9..09fd6710 100644 --- a/api/api/v1/instance/index.ts +++ b/api/api/v1/instance/index.ts @@ -46,6 +46,7 @@ export default apiRoute((app) => const userCount = await User.getCount(); + // Get first admin, or first user if no admin exists const contactAccount = (await User.fromSql( and(isNull(Users.instanceId), eq(Users.isAdmin, true)), @@ -138,7 +139,7 @@ export default apiRoute((app) => id: p.id, })) ?? [], }, - contact_account: (contactAccount as User).toApi(), + contact_account: (contactAccount as User)?.toApi(), } satisfies z.infer); }), ); diff --git a/api/api/v1/statuses/:id/reblog.ts b/api/api/v1/statuses/:id/reblog.ts index c1b84cfe..6bc451e0 100644 --- a/api/api/v1/statuses/:id/reblog.ts +++ b/api/api/v1/statuses/:id/reblog.ts @@ -97,10 +97,17 @@ export default apiRoute((app) => applicationId: null, }); + // Refetch the note *again* to get the proper value of .reblogged + const finalNewReblog = await Note.fromId(newReblog.id, user?.id); + + if (!finalNewReblog) { + throw new Error("Failed to reblog"); + } + if (note.author.isLocal() && user.isLocal()) { await note.author.notify("reblog", user, newReblog); } - return context.json(await newReblog.toApi(user), 200); + return context.json(await finalNewReblog.toApi(user), 200); }), ); diff --git a/api/api/v1/timelines/home.ts b/api/api/v1/timelines/home.ts index 68d302e0..3179f0b2 100644 --- a/api/api/v1/timelines/home.ts +++ b/api/api/v1/timelines/home.ts @@ -1,23 +1,19 @@ -import { apiRoute, auth } from "@/api"; +import { apiRoute, auth, reusedResponses } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; 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({ - max_id: z.string().uuid().optional(), - since_id: z.string().uuid().optional(), - min_id: z.string().uuid().optional(), - limit: z.coerce.number().int().min(1).max(80).default(20), - }), -}; +import { Status as StatusSchema } from "~/classes/schemas/status"; const route = createRoute({ method: "get", path: "/api/v1/timelines/home", - summary: "Get home timeline", + summary: "View home timeline", + description: "View statuses from followed users and hashtags.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/timelines/#home", + }, + tags: ["Timelines"], middleware: [ auth({ auth: true, @@ -30,17 +26,49 @@ const route = createRoute({ }), ] as const, request: { - query: schemas.query, + query: z.object({ + max_id: StatusSchema.shape.id.optional().openapi({ + description: + "All results returned will be lesser than this ID. In effect, sets an upper bound on results.", + example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa", + }), + since_id: StatusSchema.shape.id.optional().openapi({ + description: + "All results returned will be greater than this ID. In effect, sets a lower bound on results.", + example: undefined, + }), + min_id: StatusSchema.shape.id.optional().openapi({ + description: + "Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.", + example: undefined, + }), + limit: z.coerce.number().int().min(1).max(40).default(20).openapi({ + description: "Maximum number of results to return.", + }), + }), }, responses: { 200: { - description: "Home timeline", + description: "Statuses in your home timeline will be returned", content: { "application/json": { - schema: z.array(Status), + schema: z.array(StatusSchema), }, }, + headers: z.object({ + link: z + .string() + .optional() + .openapi({ + description: "Links to the next and previous pages", + example: `; rel="next", ; rel="prev"`, + externalDocs: { + url: "https://docs.joinmastodon.org/api/guidelines/#pagination", + }, + }), + }), }, + 422: reusedResponses[422], }, }); diff --git a/api/api/v1/timelines/public.ts b/api/api/v1/timelines/public.ts index 2004cf11..9143dda5 100644 --- a/api/api/v1/timelines/public.ts +++ b/api/api/v1/timelines/public.ts @@ -1,35 +1,20 @@ -import { apiRoute, auth } from "@/api"; +import { apiRoute, auth, reusedResponses } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; 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({ - max_id: z.string().uuid().optional(), - since_id: z.string().uuid().optional(), - min_id: z.string().uuid().optional(), - limit: z.coerce.number().int().min(1).max(80).default(20), - local: z - .string() - .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) - .optional(), - remote: z - .string() - .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) - .optional(), - only_media: z - .string() - .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) - .optional(), - }), -}; +import { Status as StatusSchema } from "~/classes/schemas/status"; +import { zBoolean } from "~/packages/config-manager/config.type"; const route = createRoute({ method: "get", path: "/api/v1/timelines/public", - summary: "Get public timeline", + summary: "View public timeline", + description: "View public statuses.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/timelines/#public", + }, + tags: ["Timelines"], middleware: [ auth({ auth: false, @@ -41,17 +26,69 @@ const route = createRoute({ }), ] as const, request: { - query: schemas.query, + query: z + .object({ + max_id: StatusSchema.shape.id.optional().openapi({ + description: + "All results returned will be lesser than this ID. In effect, sets an upper bound on results.", + example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa", + }), + since_id: StatusSchema.shape.id.optional().openapi({ + description: + "All results returned will be greater than this ID. In effect, sets a lower bound on results.", + example: undefined, + }), + min_id: StatusSchema.shape.id.optional().openapi({ + description: + "Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.", + example: undefined, + }), + local: zBoolean.default(false).openapi({ + description: "Show only local statuses?", + }), + remote: zBoolean.default(false).openapi({ + description: "Show only remote statuses?", + }), + only_media: zBoolean.default(false).openapi({ + description: "Show only statuses with media attached?", + }), + limit: z.coerce + .number() + .int() + .min(1) + .max(40) + .default(20) + .openapi({ + description: "Maximum number of results to return.", + }), + }) + .refine( + (o) => !(o.local && o.remote), + "'local' and 'remote' cannot be both true", + ), }, responses: { 200: { description: "Public timeline", content: { "application/json": { - schema: z.array(Status), + schema: z.array(StatusSchema), }, }, + headers: z.object({ + link: z + .string() + .optional() + .openapi({ + description: "Links to the next and previous pages", + example: `; rel="next", ; rel="prev"`, + externalDocs: { + url: "https://docs.joinmastodon.org/api/guidelines/#pagination", + }, + }), + }), }, + 422: reusedResponses[422], }, }); diff --git a/api/api/v2/filters/:id/index.ts b/api/api/v2/filters/:id/index.ts index 84242575..17094669 100644 --- a/api/api/v2/filters/:id/index.ts +++ b/api/api/v2/filters/:id/index.ts @@ -1,79 +1,24 @@ -import { apiRoute, auth, jsonOrForm } from "@/api"; +import { apiRoute, auth, jsonOrForm, reusedResponses } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { db } from "@versia/kit/db"; import { FilterKeywords, Filters, RolePermissions } from "@versia/kit/tables"; import { type SQL, and, eq, inArray } from "drizzle-orm"; import { ApiError } from "~/classes/errors/api-error"; +import { + FilterKeyword as FilterKeywordSchema, + Filter as FilterSchema, +} from "~/classes/schemas/filters"; +import { zBoolean } from "~/packages/config-manager/config.type"; import { ErrorSchema } from "~/types/api"; -const schemas = { - param: z.object({ - id: z.string().uuid(), - }), - json: z.object({ - title: z.string().trim().min(1).max(100).optional(), - context: z - .array( - z.enum([ - "home", - "notifications", - "public", - "thread", - "account", - ]), - ) - .optional(), - filter_action: z.enum(["warn", "hide"]).optional().default("warn"), - expires_in: z.coerce - .number() - .int() - .min(60) - .max(60 * 60 * 24 * 365 * 5) - .optional(), - keywords_attributes: z - .array( - z.object({ - keyword: z.string().trim().min(1).max(100).optional(), - id: z.string().uuid().optional(), - whole_word: z - .string() - .transform((v) => - ["true", "1", "on"].includes(v.toLowerCase()), - ) - .optional(), - // biome-ignore lint/style/useNamingConvention: _destroy is a Mastodon API imposed variable name - _destroy: z - .string() - .transform((v) => - ["true", "1", "on"].includes(v.toLowerCase()), - ) - .optional(), - }), - ) - .optional(), - }), -}; - -const filterSchema = z.object({ - id: z.string(), - title: z.string(), - context: z.array(z.string()), - expires_at: z.string().nullable(), - filter_action: z.enum(["warn", "hide"]), - keywords: z.array( - z.object({ - id: z.string(), - keyword: z.string(), - whole_word: z.boolean(), - }), - ), - statuses: z.array(z.string()), -}); - const routeGet = createRoute({ method: "get", path: "/api/v2/filters/{id}", - summary: "Get filter", + summary: "View a specific filter", + externalDocs: { + url: "Obtain a single filter group owned by the current user.", + }, + tags: ["Filters"], middleware: [ auth({ auth: true, @@ -81,18 +26,19 @@ const routeGet = createRoute({ }), ] as const, request: { - params: schemas.param, + params: z.object({ + id: FilterSchema.shape.id, + }), }, responses: { 200: { description: "Filter", content: { "application/json": { - schema: filterSchema, + schema: FilterSchema, }, }, }, - 404: { description: "Filter not found", content: { @@ -101,13 +47,19 @@ const routeGet = createRoute({ }, }, }, + 401: reusedResponses[401], }, }); const routePut = createRoute({ method: "put", path: "/api/v2/filters/{id}", - summary: "Update filter", + summary: "Update a filter", + description: "Update a filter group with the given parameters.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/filters/#update", + }, + tags: ["Filters"], middleware: [ auth({ auth: true, @@ -116,11 +68,45 @@ const routePut = createRoute({ jsonOrForm(), ] as const, request: { - params: schemas.param, + params: z.object({ + id: FilterSchema.shape.id, + }), body: { content: { "application/json": { - schema: schemas.json, + schema: z + .object({ + context: FilterSchema.shape.context, + title: FilterSchema.shape.title, + filter_action: FilterSchema.shape.filter_action, + expires_in: z.coerce + .number() + .int() + .min(60) + .max(60 * 60 * 24 * 365 * 5) + .openapi({ + description: + "How many seconds from now should the filter expire?", + }), + keywords_attributes: z.array( + FilterKeywordSchema.pick({ + keyword: true, + whole_word: true, + id: true, + }) + .extend({ + // biome-ignore lint/style/useNamingConvention: _destroy is a Mastodon API imposed variable name + _destroy: zBoolean + .default(false) + .openapi({ + description: + "If true, will remove the keyword with the given ID.", + }), + }) + .partial(), + ), + }) + .partial(), }, }, }, @@ -130,11 +116,10 @@ const routePut = createRoute({ description: "Filter updated", content: { "application/json": { - schema: filterSchema, + schema: FilterSchema, }, }, }, - 404: { description: "Filter not found", content: { @@ -143,13 +128,19 @@ const routePut = createRoute({ }, }, }, + ...reusedResponses, }, }); const routeDelete = createRoute({ method: "delete", path: "/api/v2/filters/{id}", - summary: "Delete filter", + summary: "Delete a filter", + description: "Delete a filter group with the given id.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/filters/#delete", + }, + tags: ["Filters"], middleware: [ auth({ auth: true, @@ -157,13 +148,14 @@ const routeDelete = createRoute({ }), ] as const, request: { - params: schemas.param, + params: z.object({ + id: FilterSchema.shape.id, + }), }, responses: { - 204: { - description: "Filter deleted", + 200: { + description: "Filter successfully deleted", }, - 404: { description: "Filter not found", content: { @@ -172,6 +164,7 @@ const routeDelete = createRoute({ }, }, }, + 401: reusedResponses[401], }, }); diff --git a/api/api/v2/filters/index.ts b/api/api/v2/filters/index.ts index 2d609c5f..97b28d44 100644 --- a/api/api/v2/filters/index.ts +++ b/api/api/v2/filters/index.ts @@ -1,66 +1,22 @@ -import { apiRoute, auth, jsonOrForm } from "@/api"; +import { apiRoute, auth, jsonOrForm, reusedResponses } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { db } from "@versia/kit/db"; import { FilterKeywords, Filters, RolePermissions } from "@versia/kit/tables"; import type { SQL } from "drizzle-orm"; - -const schemas = { - json: z.object({ - title: z.string().trim().min(1).max(100), - context: z - .array( - z.enum([ - "home", - "notifications", - "public", - "thread", - "account", - ]), - ) - .min(1), - filter_action: z.enum(["warn", "hide"]).optional().default("warn"), - expires_in: z.coerce - .number() - .int() - .min(60) - .max(60 * 60 * 24 * 365 * 5) - .optional(), - keywords_attributes: z - .array( - z.object({ - keyword: z.string().trim().min(1).max(100), - whole_word: z - .string() - .transform((v) => - ["true", "1", "on"].includes(v.toLowerCase()), - ) - .optional(), - }), - ) - .optional(), - }), -}; - -const filterSchema = z.object({ - id: z.string(), - title: z.string(), - context: z.array(z.string()), - expires_at: z.string().nullable(), - filter_action: z.enum(["warn", "hide"]), - keywords: z.array( - z.object({ - id: z.string(), - keyword: z.string(), - whole_word: z.boolean(), - }), - ), - statuses: z.array(z.string()), -}); +import { + FilterKeyword as FilterKeywordSchema, + Filter as FilterSchema, +} from "~/classes/schemas/filters"; const routeGet = createRoute({ method: "get", path: "/api/v2/filters", - summary: "Get filters", + summary: "View all filters", + description: "Obtain a list of all filter groups for the current user.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/filters/#get", + }, + tags: ["Filters"], middleware: [ auth({ auth: true, @@ -73,17 +29,23 @@ const routeGet = createRoute({ description: "Filters", content: { "application/json": { - schema: z.array(filterSchema), + schema: z.array(FilterSchema), }, }, }, + 401: reusedResponses[401], }, }); const routePost = createRoute({ method: "post", path: "/api/v2/filters", - summary: "Create filter", + summary: "Create a filter", + description: "Create a filter group with the given parameters.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/filters/#create", + }, + tags: ["Filters"], middleware: [ auth({ auth: true, @@ -95,20 +57,43 @@ const routePost = createRoute({ body: { content: { "application/json": { - schema: schemas.json, + schema: z.object({ + context: FilterSchema.shape.context, + title: FilterSchema.shape.title, + filter_action: FilterSchema.shape.filter_action, + expires_in: z.coerce + .number() + .int() + .min(60) + .max(60 * 60 * 24 * 365 * 5) + .optional() + .openapi({ + description: + "How many seconds from now should the filter expire?", + }), + keywords_attributes: z + .array( + FilterKeywordSchema.pick({ + keyword: true, + whole_word: true, + }), + ) + .optional(), + }), }, }, }, }, responses: { 200: { - description: "Filter created", + description: "Created filter", content: { "application/json": { - schema: filterSchema, + schema: FilterSchema, }, }, }, + ...reusedResponses, }, }); @@ -158,8 +143,8 @@ export default apiRoute((app) => { await db .insert(Filters) .values({ - title: title ?? "", - context: ctx ?? [], + title: title, + context: ctx, filterAction: filter_action, expireAt: new Date( Date.now() + (expires_in ?? 0), @@ -202,18 +187,6 @@ export default apiRoute((app) => { whole_word: keyword.wholeWord, })), statuses: [], - } as { - id: string; - title: string; - context: string[]; - expires_at: string; - filter_action: "warn" | "hide"; - keywords: { - id: string; - keyword: string; - whole_word: boolean; - }[]; - statuses: []; }, 200, ); diff --git a/api/api/v2/instance/index.ts b/api/api/v2/instance/index.ts index 94ec504b..b02cd3e3 100644 --- a/api/api/v2/instance/index.ts +++ b/api/api/v2/instance/index.ts @@ -1,109 +1,28 @@ import { apiRoute } from "@/api"; import { proxyUrl } from "@/response"; -import { createRoute, z } from "@hono/zod-openapi"; +import { createRoute } from "@hono/zod-openapi"; import { User } from "@versia/kit/db"; import { Users } from "@versia/kit/tables"; import { and, eq, isNull } from "drizzle-orm"; -import { Account } from "~/classes/schemas/account"; -import manifest from "~/package.json"; +import { Instance as InstanceSchema } from "~/classes/schemas/instance"; +import pkg from "~/package.json"; import { config } from "~/packages/config-manager"; const route = createRoute({ method: "get", path: "/api/v2/instance", - summary: "Get instance metadata", + summary: "View server information", + description: "Obtain general information about the server.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/instance/#v2", + }, + tags: ["Instance"], responses: { 200: { - description: "Instance metadata", + description: "Server information", content: { "application/json": { - schema: z.object({ - domain: z.string(), - title: z.string(), - version: z.string(), - versia_version: z.string(), - source_url: z.string(), - description: z.string(), - usage: z.object({ - users: z.object({ - active_month: z.number(), - }), - }), - thumbnail: z.object({ - url: z.string().nullable(), - }), - banner: z.object({ - url: z.string().nullable(), - }), - languages: z.array(z.string()), - configuration: z.object({ - urls: z.object({ - streaming: z.string().nullable(), - status: z.string().nullable(), - }), - accounts: z.object({ - max_featured_tags: z.number(), - max_displayname_characters: z.number(), - avatar_size_limit: z.number(), - header_size_limit: z.number(), - max_fields_name_characters: z.number(), - max_fields_value_characters: z.number(), - max_fields: z.number(), - max_username_characters: z.number(), - max_note_characters: z.number(), - }), - statuses: z.object({ - max_characters: z.number(), - max_media_attachments: z.number(), - characters_reserved_per_url: z.number(), - }), - media_attachments: z.object({ - supported_mime_types: z.array(z.string()), - image_size_limit: z.number(), - image_matrix_limit: z.number(), - video_size_limit: z.number(), - video_frame_rate_limit: z.number(), - video_matrix_limit: z.number(), - max_description_characters: z.number(), - }), - polls: z.object({ - max_characters_per_option: z.number(), - max_expiration: z.number(), - max_options: z.number(), - min_expiration: z.number(), - }), - translation: z.object({ - enabled: z.boolean(), - }), - }), - registrations: z.object({ - enabled: z.boolean(), - approval_required: z.boolean(), - message: z.string().nullable(), - url: z.string().nullable(), - }), - contact: z.object({ - email: z.string().nullable(), - account: Account.nullable(), - }), - rules: z.array( - z.object({ - id: z.string(), - text: z.string(), - hint: z.string(), - }), - ), - sso: z.object({ - forced: z.boolean(), - providers: z.array( - z.object({ - name: z.string(), - icon: z.string(), - id: z.string(), - }), - ), - }), - }), + schema: InstanceSchema, }, }, }, @@ -112,12 +31,11 @@ const route = createRoute({ export default apiRoute((app) => app.openapi(route, async (context) => { - // Get software version from package.json - const version = manifest.version; - - const contactAccount = await User.fromSql( - and(isNull(Users.instanceId), eq(Users.isAdmin, true)), - ); + // Get first admin, or first user if no admin exists + const contactAccount = + (await User.fromSql( + and(isNull(Users.instanceId), eq(Users.isAdmin, true)), + )) ?? (await User.fromSql(isNull(Users.instanceId))); const monthlyActiveUsers = await User.getActiveInPeriod( 30 * 24 * 60 * 60 * 1000, @@ -139,44 +57,55 @@ export default apiRoute((app) => domain: config.http.base_url.hostname, title: config.instance.name, version: "4.3.0-alpha.3+glitch", - versia_version: version, - source_url: "https://github.com/versia-pub/server", + versia_version: pkg.version, + source_url: pkg.repository.url, description: config.instance.description, usage: { users: { active_month: monthlyActiveUsers, }, }, + api_versions: { + mastodon: 1, + }, thumbnail: { url: config.instance.logo - ? proxyUrl(config.instance.logo) - : null, + ? proxyUrl(config.instance.logo).toString() + : pkg.icon, }, banner: { url: config.instance.banner - ? proxyUrl(config.instance.banner) + ? proxyUrl(config.instance.banner).toString() : null, }, + icon: [], languages: ["en"], configuration: { urls: { - streaming: null, - status: null, + // TODO: Implement Streaming API + streaming: "", + }, + vapid: { + // TODO: Fill in vapid values + public_key: "", }, accounts: { max_featured_tags: 100, max_displayname_characters: config.validation.max_displayname_size, - avatar_size_limit: config.validation.max_avatar_size, - header_size_limit: config.validation.max_header_size, - max_fields_name_characters: - config.validation.max_field_name_size, - max_fields_value_characters: - config.validation.max_field_value_size, - max_fields: config.validation.max_field_count, + avatar_limit: config.validation.max_avatar_size, + header_limit: config.validation.max_header_size, max_username_characters: config.validation.max_username_size, max_note_characters: config.validation.max_bio_size, + max_pinned_statuses: 100, + fields: { + max_fields: config.validation.max_field_count, + max_name_characters: + config.validation.max_field_name_size, + max_value_characters: + config.validation.max_field_value_size, + }, }, statuses: { max_characters: config.validation.max_note_size, @@ -191,14 +120,14 @@ export default apiRoute((app) => video_size_limit: config.validation.max_media_size, video_frame_rate_limit: config.validation.max_media_size, video_matrix_limit: config.validation.max_media_size, - max_description_characters: + description_limit: config.validation.max_media_description_size, }, emojis: { emoji_size_limit: config.validation.max_emoji_size, - max_emoji_shortcode_characters: + max_shortcode_characters: config.validation.max_emoji_shortcode_size, - max_emoji_description_characters: + max_description_characters: config.validation.max_emoji_description_size, }, polls: { @@ -216,11 +145,11 @@ export default apiRoute((app) => enabled: config.signups.registration, approval_required: false, message: null, - url: null, }, contact: { - email: contactAccount?.data.email || null, - account: contactAccount?.toApi() || null, + // TODO: Add contact email + email: "", + account: (contactAccount as User)?.toApi(), }, rules: config.signups.rules.map((rule, index) => ({ id: String(index), diff --git a/api/api/v2/media/index.ts b/api/api/v2/media/index.ts index 44f06e05..5d6d9e7d 100644 --- a/api/api/v2/media/index.ts +++ b/api/api/v2/media/index.ts @@ -1,27 +1,20 @@ -import { apiRoute, auth } from "@/api"; +import { apiRoute, auth, reusedResponses } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { Media } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import { Attachment as AttachmentSchema } from "~/classes/schemas/attachment"; -import { config } from "~/packages/config-manager/index.ts"; import { ErrorSchema } from "~/types/api"; -const schemas = { - form: z.object({ - file: z.instanceof(File), - thumbnail: z.instanceof(File).optional(), - description: z - .string() - .max(config.validation.max_media_description_size) - .optional(), - focus: z.string().optional(), - }), -}; - const route = createRoute({ method: "post", path: "/api/v2/media", - summary: "Upload media", + summary: "Upload media as an attachment (async)", + description: + "Creates a media attachment to be used with a new status. The full sized media will be processed asynchronously in the background for large uploads.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/media/#v2", + }, + tags: ["Media"], middleware: [ auth({ auth: true, @@ -33,20 +26,52 @@ const route = createRoute({ body: { content: { "multipart/form-data": { - schema: schemas.form, + schema: z.object({ + file: z.instanceof(File).openapi({ + description: + "The file to be attached, encoded using multipart form data. The file must have a MIME type.", + }), + thumbnail: z.instanceof(File).optional().openapi({ + description: + "The custom thumbnail of the media to be attached, encoded using multipart form data.", + }), + description: + AttachmentSchema.shape.description.optional(), + focus: z + .string() + .optional() + .openapi({ + description: + "Two floating points (x,y), comma-delimited, ranging from -1.0 to 1.0. Used for media cropping on clients.", + externalDocs: { + url: "https://docs.joinmastodon.org/api/guidelines/#focal-points", + }, + }), + }), }, }, }, }, responses: { 200: { - description: "Uploaded media", + description: + "MediaAttachment was created successfully, and the full-size file was processed synchronously.", content: { "application/json": { schema: AttachmentSchema, }, }, }, + 202: { + description: + "MediaAttachment was created successfully, but the full-size file is still processing. Note that the MediaAttachment’s url will still be null, as the media is still being processed in the background. However, the preview_url should be available. Use GET /api/v1/media/:id to check the status of the media attachment.", + content: { + "application/json": { + // FIXME: Can't .extend the type to have a null url because it crashes zod-to-openapi + schema: AttachmentSchema, + }, + }, + }, 413: { description: "Payload too large", content: { @@ -63,6 +88,7 @@ const route = createRoute({ }, }, }, + ...reusedResponses, }, }); @@ -72,7 +98,7 @@ export default apiRoute((app) => const attachment = await Media.fromFile(file, { thumbnail, - description, + description: description ?? undefined, }); return context.json(attachment.toApi(), 200); diff --git a/api/api/v2/search/index.ts b/api/api/v2/search/index.ts index c3c358c3..6cca58f8 100644 --- a/api/api/v2/search/index.ts +++ b/api/api/v2/search/index.ts @@ -1,33 +1,31 @@ -import { apiRoute, auth, parseUserAddress, userAddressValidator } from "@/api"; +import { + apiRoute, + auth, + parseUserAddress, + reusedResponses, + userAddressValidator, +} from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { Note, User, db } from "@versia/kit/db"; 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 { Account as AccountSchema } from "~/classes/schemas/account"; +import { Id } from "~/classes/schemas/common"; +import { Search as SearchSchema } from "~/classes/schemas/search"; import { searchManager } from "~/classes/search/search-manager"; import { config } from "~/packages/config-manager"; +import { zBoolean } from "~/packages/config-manager/config.type"; import { ErrorSchema } from "~/types/api"; -const schemas = { - query: z.object({ - q: z.string().trim(), - type: z.string().optional(), - resolve: z.coerce.boolean().optional(), - following: z.coerce.boolean().optional(), - account_id: z.string().optional(), - max_id: z.string().optional(), - min_id: z.string().optional(), - limit: z.coerce.number().int().min(1).max(40).optional(), - offset: z.coerce.number().int().optional(), - }), -}; - const route = createRoute({ method: "get", path: "/api/v2/search", - summary: "Instance database search", + summary: "Perform a search", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/search/#v2", + }, + tags: ["Search"], middleware: [ auth({ auth: false, @@ -40,18 +38,64 @@ const route = createRoute({ }), ] as const, request: { - query: schemas.query, + query: z.object({ + q: z.string().trim().openapi({ + description: "The search query.", + example: "versia", + }), + type: z + .enum(["accounts", "hashtags", "statuses"]) + .optional() + .openapi({ + description: + "Specify whether to search for only accounts, hashtags, statuses", + example: "accounts", + }), + resolve: zBoolean.default(false).openapi({ + description: + "Only relevant if type includes accounts. If true and (a) the search query is for a remote account (e.g., someaccount@someother.server) and (b) the local server does not know about the account, WebFinger is used to try and resolve the account at someother.server. This provides the best recall at higher latency. If false only accounts the server knows about are returned.", + }), + following: zBoolean.default(false).openapi({ + description: + "Only include accounts that the user is following?", + }), + account_id: AccountSchema.shape.id.optional().openapi({ + description: + " If provided, will only return statuses authored by this account.", + }), + exclude_unreviewed: zBoolean.default(false).openapi({ + description: + "Filter out unreviewed tags? Use true when trying to find trending tags.", + }), + max_id: Id.optional().openapi({ + description: + "All results returned will be lesser than this ID. In effect, sets an upper bound on results.", + example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa", + }), + since_id: Id.optional().openapi({ + description: + "All results returned will be greater than this ID. In effect, sets a lower bound on results.", + example: undefined, + }), + min_id: Id.optional().openapi({ + description: + "Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.", + example: undefined, + }), + limit: z.coerce.number().int().min(1).max(40).default(20).openapi({ + description: "Maximum number of results to return.", + }), + offset: z.coerce.number().int().min(0).default(0).openapi({ + description: "Skip the first n results.", + }), + }), }, responses: { 200: { description: "Search results", content: { "application/json": { - schema: z.object({ - accounts: z.array(Account), - statuses: z.array(Status), - hashtags: z.array(z.string()), - }), + schema: SearchSchema, }, }, }, @@ -72,6 +116,7 @@ const route = createRoute({ }, }, }, + 422: reusedResponses[422], }, }); @@ -164,16 +209,16 @@ export default apiRoute((app) => accountResults = await searchManager.searchAccounts( q, - Number(limit) || 10, - Number(offset) || 0, + limit, + offset, ); } if (!type || type === "statuses") { statusResults = await searchManager.searchStatuses( q, - Number(limit) || 10, - Number(offset) || 0, + limit, + offset, ); } diff --git a/classes/schemas/filters.ts b/classes/schemas/filters.ts index 9ac25a10..e1e57de5 100644 --- a/classes/schemas/filters.ts +++ b/classes/schemas/filters.ts @@ -1,4 +1,5 @@ import { z } from "@hono/zod-openapi"; +import { zBoolean } from "~/packages/config-manager/config.type.ts"; import { Id } from "./common.ts"; export const FilterStatus = z @@ -42,7 +43,7 @@ export const FilterKeyword = z url: "https://docs.joinmastodon.org/entities/FilterKeyword/#keyword", }, }), - whole_word: z.boolean().openapi({ + whole_word: zBoolean.openapi({ description: "Should the filter consider word boundaries? See implementation guidelines for filters.", example: false, @@ -68,13 +69,18 @@ export const Filter = z 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", - }, - }), + title: z + .string() + .trim() + .min(1) + .max(255) + .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([ @@ -85,6 +91,7 @@ export const Filter = z "account", ]), ) + .default([]) .openapi({ description: "The contexts in which the filter should be applied.", @@ -103,14 +110,17 @@ export const Filter = z 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", - }, - }), + filter_action: z + .enum(["warn", "hide"]) + .default("warn") + .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: { diff --git a/package.json b/package.json index fcc23531..1f2ebd2f 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "bugs": { "url": "https://github.com/versia-pub/server/issues" }, - "icon": "https://github.com/versia-pub/server", + "icon": "https://cdn.versia.pub/branding/icon.svg", "license": "AGPL-3.0-or-later", "keywords": ["federated", "activitypub", "bun"], "workspaces": ["packages/plugin-kit"], diff --git a/tests/api/statuses.test.ts b/tests/api/statuses.test.ts index 100dd011..0170323f 100644 --- a/tests/api/statuses.test.ts +++ b/tests/api/statuses.test.ts @@ -62,7 +62,7 @@ describe("API Tests", () => { }), }); - expect(response.status).toBe(201); + expect(response.status).toBe(200); expect(response.headers.get("content-type")).toContain( "application/json", ); @@ -104,7 +104,7 @@ describe("API Tests", () => { }), }); - expect(response.status).toBe(201); + expect(response.status).toBe(200); expect(response.headers.get("content-type")).toContain( "application/json", ); From 59a3463c725e0c4eb9ecb351381bc809c3125e35 Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Fri, 14 Feb 2025 17:55:54 +0100 Subject: [PATCH 11/11] fix(api): :rotating_light: Use shorthand property syntax everywhere, remove useless template literals --- api/api/v1/accounts/:id/followers.ts | 3 ++- api/api/v1/accounts/:id/following.ts | 3 ++- api/api/v1/accounts/index.ts | 12 ++++++------ api/api/v1/apps/index.ts | 2 +- api/api/v1/blocks/index.ts | 3 ++- api/api/v1/emojis/:id/index.ts | 6 +++--- api/api/v1/emojis/index.ts | 6 +++--- api/api/v1/favourites/index.ts | 3 ++- api/api/v1/follow_requests/index.ts | 3 ++- api/api/v1/mutes/index.ts | 3 ++- api/api/v1/statuses/:id/favourited_by.ts | 3 ++- api/api/v1/statuses/:id/index.ts | 6 +++--- api/api/v1/statuses/:id/reblogged_by.ts | 3 ++- api/api/v1/statuses/index.ts | 6 +++--- api/api/v1/timelines/home.ts | 3 ++- api/api/v1/timelines/public.ts | 3 ++- api/api/v2/filters/index.ts | 2 +- 17 files changed, 40 insertions(+), 30 deletions(-) diff --git a/api/api/v1/accounts/:id/followers.ts b/api/api/v1/accounts/:id/followers.ts index f5ac44c8..1d7c7cd9 100644 --- a/api/api/v1/accounts/:id/followers.ts +++ b/api/api/v1/accounts/:id/followers.ts @@ -72,7 +72,8 @@ const route = createRoute({ .optional() .openapi({ description: "Links to the next and previous pages", - example: `; rel="next", ; rel="prev"`, + example: + '; rel="next", ; rel="prev"', externalDocs: { url: "https://docs.joinmastodon.org/api/guidelines/#pagination", }, diff --git a/api/api/v1/accounts/:id/following.ts b/api/api/v1/accounts/:id/following.ts index d58ca156..afa75033 100644 --- a/api/api/v1/accounts/:id/following.ts +++ b/api/api/v1/accounts/:id/following.ts @@ -72,7 +72,8 @@ const route = createRoute({ .optional() .openapi({ description: "Links to the next and previous pages", - example: `; rel="next", ; rel="prev"`, + example: + '; rel="next", ; rel="prev"', externalDocs: { url: "https://docs.joinmastodon.org/api/guidelines/#pagination", }, diff --git a/api/api/v1/accounts/index.ts b/api/api/v1/accounts/index.ts index f0f3ad80..86585dc5 100644 --- a/api/api/v1/accounts/index.ts +++ b/api/api/v1/accounts/index.ts @@ -61,13 +61,13 @@ const route = createRoute({ body: { content: { "application/json": { - schema: schema, + schema, }, "multipart/form-data": { - schema: schema, + schema, }, "application/x-www-form-urlencoded": { - schema: schema, + schema, }, }, }, @@ -347,9 +347,9 @@ export default apiRoute((app) => } await User.fromDataLocal({ - username: username, - password: password, - email: email, + username, + password, + email, }); return context.text("", 200); diff --git a/api/api/v1/apps/index.ts b/api/api/v1/apps/index.ts index 2670f27f..78a3b946 100644 --- a/api/api/v1/apps/index.ts +++ b/api/api/v1/apps/index.ts @@ -68,7 +68,7 @@ export default apiRoute((app) => name: client_name, redirectUri: redirect_uris.join("\n"), scopes: scopes.join(" "), - website: website, + website, clientId: randomString(32, "base64url"), secret: randomString(64, "base64url"), }); diff --git a/api/api/v1/blocks/index.ts b/api/api/v1/blocks/index.ts index 429725a5..ac9698b0 100644 --- a/api/api/v1/blocks/index.ts +++ b/api/api/v1/blocks/index.ts @@ -57,7 +57,8 @@ const route = createRoute({ .optional() .openapi({ description: "Links to the next and previous pages", - example: `; rel="next", ; rel="prev"`, + example: + '; rel="next", ; rel="prev"', externalDocs: { url: "https://docs.joinmastodon.org/api/guidelines/#pagination", }, diff --git a/api/api/v1/emojis/:id/index.ts b/api/api/v1/emojis/:id/index.ts index 4a7a5811..61618685 100644 --- a/api/api/v1/emojis/:id/index.ts +++ b/api/api/v1/emojis/:id/index.ts @@ -104,13 +104,13 @@ const routePatch = createRoute({ body: { content: { "application/json": { - schema: schema, + schema, }, "application/x-www-form-urlencoded": { - schema: schema, + schema, }, "multipart/form-data": { - schema: schema, + schema, }, }, }, diff --git a/api/api/v1/emojis/index.ts b/api/api/v1/emojis/index.ts index 81105340..1acfd351 100644 --- a/api/api/v1/emojis/index.ts +++ b/api/api/v1/emojis/index.ts @@ -53,13 +53,13 @@ const route = createRoute({ body: { content: { "application/json": { - schema: schema, + schema, }, "multipart/form-data": { - schema: schema, + schema, }, "application/x-www-form-urlencoded": { - schema: schema, + schema, }, }, }, diff --git a/api/api/v1/favourites/index.ts b/api/api/v1/favourites/index.ts index 9c4d2d04..fd2c871f 100644 --- a/api/api/v1/favourites/index.ts +++ b/api/api/v1/favourites/index.ts @@ -56,7 +56,8 @@ const route = createRoute({ .optional() .openapi({ description: "Links to the next and previous pages", - example: `; rel="next", ; rel="prev"`, + example: + '; rel="next", ; rel="prev"', externalDocs: { url: "https://docs.joinmastodon.org/api/guidelines/#pagination", }, diff --git a/api/api/v1/follow_requests/index.ts b/api/api/v1/follow_requests/index.ts index cf3ac349..6e72eb73 100644 --- a/api/api/v1/follow_requests/index.ts +++ b/api/api/v1/follow_requests/index.ts @@ -57,7 +57,8 @@ const route = createRoute({ .optional() .openapi({ description: "Links to the next and previous pages", - example: `; rel="next", ; rel="prev"`, + example: + '; rel="next", ; rel="prev"', externalDocs: { url: "https://docs.joinmastodon.org/api/guidelines/#pagination", }, diff --git a/api/api/v1/mutes/index.ts b/api/api/v1/mutes/index.ts index 6b3230b9..7d99c7b7 100644 --- a/api/api/v1/mutes/index.ts +++ b/api/api/v1/mutes/index.ts @@ -57,7 +57,8 @@ const route = createRoute({ .optional() .openapi({ description: "Links to the next and previous pages", - example: `; rel="next", ; rel="prev"`, + example: + '; rel="next", ; rel="prev"', externalDocs: { url: "https://docs.joinmastodon.org/api/guidelines/#pagination", }, diff --git a/api/api/v1/statuses/:id/favourited_by.ts b/api/api/v1/statuses/:id/favourited_by.ts index 33a4293e..74c607c2 100644 --- a/api/api/v1/statuses/:id/favourited_by.ts +++ b/api/api/v1/statuses/:id/favourited_by.ts @@ -70,7 +70,8 @@ const route = createRoute({ .optional() .openapi({ description: "Links to the next and previous pages", - example: `; rel="next", ; rel="prev"`, + example: + '; rel="next", ; rel="prev"', externalDocs: { url: "https://docs.joinmastodon.org/api/guidelines/#pagination", }, diff --git a/api/api/v1/statuses/:id/index.ts b/api/api/v1/statuses/:id/index.ts index f9495858..a29bd3ac 100644 --- a/api/api/v1/statuses/:id/index.ts +++ b/api/api/v1/statuses/:id/index.ts @@ -180,13 +180,13 @@ const routePut = createRoute({ body: { content: { "application/json": { - schema: schema, + schema, }, "application/x-www-form-urlencoded": { - schema: schema, + schema, }, "multipart/form-data": { - schema: schema, + schema, }, }, }, diff --git a/api/api/v1/statuses/:id/reblogged_by.ts b/api/api/v1/statuses/:id/reblogged_by.ts index d94908fd..ab7ca237 100644 --- a/api/api/v1/statuses/:id/reblogged_by.ts +++ b/api/api/v1/statuses/:id/reblogged_by.ts @@ -70,7 +70,8 @@ const route = createRoute({ .optional() .openapi({ description: "Links to the next and previous pages", - example: `; rel="next", ; rel="prev"`, + example: + '; rel="next", ; rel="prev"', externalDocs: { url: "https://docs.joinmastodon.org/api/guidelines/#pagination", }, diff --git a/api/api/v1/statuses/index.ts b/api/api/v1/statuses/index.ts index 1635897b..8f56c010 100644 --- a/api/api/v1/statuses/index.ts +++ b/api/api/v1/statuses/index.ts @@ -121,13 +121,13 @@ const route = createRoute({ body: { content: { "application/json": { - schema: schema, + schema, }, "application/x-www-form-urlencoded": { - schema: schema, + schema, }, "multipart/form-data": { - schema: schema, + schema, }, }, }, diff --git a/api/api/v1/timelines/home.ts b/api/api/v1/timelines/home.ts index 3179f0b2..4455bebd 100644 --- a/api/api/v1/timelines/home.ts +++ b/api/api/v1/timelines/home.ts @@ -61,7 +61,8 @@ const route = createRoute({ .optional() .openapi({ description: "Links to the next and previous pages", - example: `; rel="next", ; rel="prev"`, + example: + '; rel="next", ; rel="prev"', externalDocs: { url: "https://docs.joinmastodon.org/api/guidelines/#pagination", }, diff --git a/api/api/v1/timelines/public.ts b/api/api/v1/timelines/public.ts index 9143dda5..7a9306b9 100644 --- a/api/api/v1/timelines/public.ts +++ b/api/api/v1/timelines/public.ts @@ -81,7 +81,8 @@ const route = createRoute({ .optional() .openapi({ description: "Links to the next and previous pages", - example: `; rel="next", ; rel="prev"`, + example: + '; rel="next", ; rel="prev"', externalDocs: { url: "https://docs.joinmastodon.org/api/guidelines/#pagination", }, diff --git a/api/api/v2/filters/index.ts b/api/api/v2/filters/index.ts index 97b28d44..12a09b2a 100644 --- a/api/api/v2/filters/index.ts +++ b/api/api/v2/filters/index.ts @@ -143,7 +143,7 @@ export default apiRoute((app) => { await db .insert(Filters) .values({ - title: title, + title, context: ctx, filterAction: filter_action, expireAt: new Date(