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;