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;