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;