refactor(api): 🏷️ Port almost all remaining v1 endpoints to OpenAPI

This commit is contained in:
Jesse Wierzbinski 2025-02-14 16:44:32 +01:00
parent 247a8fbce3
commit 1856176de5
No known key found for this signature in database
42 changed files with 919 additions and 574 deletions

View file

@ -21,7 +21,7 @@ beforeAll(async () => {
}, },
); );
expect(response.status).toBe(201); expect(response.status).toBe(200);
}); });
// /api/v1/accounts/:id/statuses // /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( const response = await fakeRequest(
`/api/v1/accounts/${users[1].id}/statuses?exclude_replies=true`, `/api/v1/accounts/${users[1].id}/statuses?exclude_replies=true`,

View file

@ -10,6 +10,7 @@ const route = createRoute({
path: "/api/v1/challenges", path: "/api/v1/challenges",
summary: "Generate a challenge", summary: "Generate a challenge",
description: "Generate a challenge to solve", description: "Generate a challenge to solve",
tags: ["Challenges"],
middleware: [ middleware: [
auth({ auth({
auth: false, auth: false,

View file

@ -1,21 +1,24 @@
import { apiRoute } from "@/api"; import { apiRoute } from "@/api";
import { renderMarkdownInPath } from "@/markdown"; 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"; import { config } from "~/packages/config-manager";
const route = createRoute({ const route = createRoute({
method: "get", method: "get",
path: "/api/v1/instance/extended_description", 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: { responses: {
200: { 200: {
description: "Extended description", description: "Server extended description",
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: ExtendedDescriptionSchema,
updated_at: z.string(),
content: z.string(),
}),
}, },
}, },
}, },

View file

@ -1,16 +1,25 @@
import { apiRoute, auth } from "@/api"; import { apiRoute, auth } from "@/api";
import { renderMarkdownInPath } from "@/markdown";
import { proxyUrl } from "@/response"; 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 { Instance, Note, User } from "@versia/kit/db";
import { Users } from "@versia/kit/tables"; import { Users } from "@versia/kit/tables";
import { and, eq, isNull } from "drizzle-orm"; import { and, eq, isNull } from "drizzle-orm";
import { InstanceV1 as InstanceV1Schema } from "~/classes/schemas/instance-v1";
import manifest from "~/package.json"; import manifest from "~/package.json";
import { config } from "~/packages/config-manager"; import { config } from "~/packages/config-manager";
const route = createRoute({ const route = createRoute({
method: "get", method: "get",
path: "/api/v1/instance", 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: [ middleware: [
auth({ auth({
auth: false, auth: false,
@ -20,9 +29,8 @@ const route = createRoute({
200: { 200: {
description: "Instance information", description: "Instance information",
content: { content: {
// TODO: Add schemas for this response
"application/json": { "application/json": {
schema: z.any(), schema: InstanceV1Schema,
}, },
}, },
}, },
@ -38,9 +46,10 @@ export default apiRoute((app) =>
const userCount = await User.getCount(); const userCount = await User.getCount();
const contactAccount = await User.fromSql( const contactAccount =
and(isNull(Users.instanceId), eq(Users.isAdmin, true)), (await User.fromSql(
); and(isNull(Users.instanceId), eq(Users.isAdmin, true)),
)) ?? (await User.fromSql(isNull(Users.instanceId)));
const knownDomainsCount = await Instance.getCount(); const knownDomainsCount = await Instance.getCount();
@ -55,6 +64,11 @@ export default apiRoute((app) =>
} }
| undefined; | 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 // TODO: fill in more values
return context.json({ return context.json({
approval_required: false, approval_required: false,
@ -84,10 +98,13 @@ export default apiRoute((app) =>
max_featured_tags: 100, max_featured_tags: 100,
}, },
}, },
description: config.instance.description, short_description: config.instance.description,
description: content,
// TODO: Add contact email
email: "", email: "",
invites_enabled: false, invites_enabled: false,
registrations: config.signups.registration, registrations: config.signups.registration,
// TODO: Implement
languages: ["en"], languages: ["en"],
rules: config.signups.rules.map((r, index) => ({ rules: config.signups.rules.map((r, index) => ({
id: String(index), id: String(index),
@ -101,12 +118,10 @@ export default apiRoute((app) =>
thumbnail: config.instance.logo thumbnail: config.instance.logo
? proxyUrl(config.instance.logo).toString() ? proxyUrl(config.instance.logo).toString()
: null, : null,
banner: config.instance.banner
? proxyUrl(config.instance.banner).toString()
: null,
title: config.instance.name, title: config.instance.name,
uri: config.http.base_url, uri: config.http.base_url.host,
urls: { urls: {
// TODO: Implement Streaming API
streaming_api: "", streaming_api: "",
}, },
version: "4.3.0-alpha.3+glitch", version: "4.3.0-alpha.3+glitch",
@ -123,18 +138,7 @@ export default apiRoute((app) =>
id: p.id, id: p.id,
})) ?? [], })) ?? [],
}, },
contact_account: contactAccount?.toApi() || undefined, contact_account: (contactAccount as User).toApi(),
} satisfies Record<string, unknown> & { } satisfies z.infer<typeof InstanceV1Schema>);
banner: string | null;
versia_version: string;
sso: {
forced: boolean;
providers: {
id: string;
name: string;
icon?: string;
}[];
};
});
}), }),
); );

View file

@ -1,12 +1,18 @@
import { apiRoute, auth } from "@/api"; import { apiRoute, auth } from "@/api";
import { renderMarkdownInPath } from "@/markdown"; 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"; import { config } from "~/packages/config-manager";
const route = createRoute({ const route = createRoute({
method: "get", method: "get",
path: "/api/v1/instance/privacy_policy", path: "/api/v1/instance/privacy_policy",
summary: "Get instance privacy policy", summary: "View privacy policy",
description: "Obtain the contents of this servers privacy policy.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/instance/#privacy_policy",
},
tags: ["Instance"],
middleware: [ middleware: [
auth({ auth({
auth: false, auth: false,
@ -14,13 +20,10 @@ const route = createRoute({
], ],
responses: { responses: {
200: { 200: {
description: "Instance privacy policy", description: "Server privacy policy",
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: PrivacyPolicySchema,
updated_at: z.string(),
content: z.string(),
}),
}, },
}, },
}, },

View file

@ -1,11 +1,17 @@
import { apiRoute, auth } from "@/api"; import { apiRoute, auth } from "@/api";
import { createRoute, z } from "@hono/zod-openapi"; import { createRoute, z } from "@hono/zod-openapi";
import { Rule as RuleSchema } from "~/classes/schemas/rule";
import { config } from "~/packages/config-manager"; import { config } from "~/packages/config-manager";
const route = createRoute({ const route = createRoute({
method: "get", method: "get",
path: "/api/v1/instance/rules", 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: [ middleware: [
auth({ auth({
auth: false, auth: false,
@ -16,13 +22,7 @@ const route = createRoute({
description: "Instance rules", description: "Instance rules",
content: { content: {
"application/json": { "application/json": {
schema: z.array( schema: z.array(RuleSchema),
z.object({
id: z.string(),
text: z.string(),
hint: z.string(),
}),
),
}, },
}, },
}, },

View file

@ -1,10 +1,10 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { fakeRequest } from "~/tests/utils"; import { fakeRequest } from "~/tests/utils";
// /api/v1/instance/tos // /api/v1/instance/terms_of_service
describe("/api/v1/instance/tos", () => { describe("/api/v1/instance/terms_of_service", () => {
test("should return terms of service", async () => { 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); expect(response.status).toBe(200);

View file

@ -1,12 +1,19 @@
import { apiRoute, auth } from "@/api"; import { apiRoute, auth } from "@/api";
import { renderMarkdownInPath } from "@/markdown"; 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"; import { config } from "~/packages/config-manager";
const route = createRoute({ const route = createRoute({
method: "get", method: "get",
path: "/api/v1/instance/tos", path: "/api/v1/instance/terms_of_service",
summary: "Get instance terms of service", summary: "View terms of service",
description:
"Obtain the contents of this servers terms of service, if configured.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/instance/#terms_of_service",
},
tags: ["Instance"],
middleware: [ middleware: [
auth({ auth({
auth: false, auth: false,
@ -14,13 +21,10 @@ const route = createRoute({
], ],
responses: { responses: {
200: { 200: {
description: "Instance terms of service", description: "Server terms of service",
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: TermsOfServiceSchema,
updated_at: z.string(),
content: z.string(),
}),
}, },
}, },
}, },

View file

@ -1,4 +1,4 @@
import { apiRoute, auth } from "@/api"; import { apiRoute, auth, reusedResponses } from "@/api";
import { createRoute, z } from "@hono/zod-openapi"; import { createRoute, z } from "@hono/zod-openapi";
import { db } from "@versia/kit/db"; import { db } from "@versia/kit/db";
import { Markers, RolePermissions } from "@versia/kit/tables"; import { Markers, RolePermissions } from "@versia/kit/tables";
@ -15,7 +15,12 @@ const MarkerResponseSchema = z.object({
const routeGet = createRoute({ const routeGet = createRoute({
method: "get", method: "get",
path: "/api/v1/markers", 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: [ middleware: [
auth({ auth({
auth: true, auth: true,
@ -27,8 +32,12 @@ const routeGet = createRoute({
"timeline[]": z "timeline[]": z
.array(z.enum(["home", "notifications"])) .array(z.enum(["home", "notifications"]))
.max(2) .max(2)
.or(z.enum(["home", "notifications"])) .or(z.enum(["home", "notifications"]).transform((t) => [t]))
.optional(), .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: { responses: {
@ -40,13 +49,19 @@ const routeGet = createRoute({
}, },
}, },
}, },
...reusedResponses,
}, },
}); });
const routePost = createRoute({ const routePost = createRoute({
method: "post", method: "post",
path: "/api/v1/markers", 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: [ middleware: [
auth({ auth({
auth: true, auth: true,
@ -54,11 +69,19 @@ const routePost = createRoute({
}), }),
] as const, ] as const,
request: { request: {
query: z.object({ query: z
"home[last_read_id]": StatusSchema.shape.id.optional(), .object({
"notifications[last_read_id]": "home[last_read_id]": StatusSchema.shape.id.openapi({
NotificationSchema.shape.id.optional(), 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: { responses: {
200: { 200: {
@ -69,16 +92,15 @@ const routePost = createRoute({
}, },
}, },
}, },
...reusedResponses,
}, },
}); });
export default apiRoute((app) => { export default apiRoute((app) => {
app.openapi(routeGet, async (context) => { 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 { user } = context.get("auth");
const timeline = Array.isArray(timelines) ? timelines : [];
if (!timeline) { if (!timeline) {
return context.json({}, 200); return context.json({}, 200);
} }

View file

@ -1,30 +1,21 @@
import { apiRoute, auth } from "@/api"; import { apiRoute, auth, reusedResponses } from "@/api";
import { createRoute, z } from "@hono/zod-openapi"; import { createRoute, z } from "@hono/zod-openapi";
import { Media } from "@versia/kit/db"; import { Media } from "@versia/kit/db";
import { RolePermissions } from "@versia/kit/tables"; import { RolePermissions } from "@versia/kit/tables";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
import { Attachment as AttachmentSchema } from "~/classes/schemas/attachment"; import { Attachment as AttachmentSchema } from "~/classes/schemas/attachment";
import { config } from "~/packages/config-manager/index.ts";
import { ErrorSchema } from "~/types/api"; 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({ const routePut = createRoute({
method: "put", method: "put",
path: "/api/v1/media/{id}", path: "/api/v1/media/{id}",
summary: "Update media", summary: "Update media attachment",
description:
"Update a MediaAttachments parameters, before it is attached to a status and posted.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/media/#update",
},
tags: ["Media"],
middleware: [ middleware: [
auth({ auth({
auth: true, auth: true,
@ -33,40 +24,63 @@ const routePut = createRoute({
}), }),
] as const, ] as const,
request: { request: {
params: schemas.param, params: z.object({
id: AttachmentSchema.shape.id,
}),
body: { body: {
content: { content: {
"multipart/form-data": { "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: { responses: {
200: { 200: {
description: "Media updated", description: "Updated attachment",
content: { content: {
"application/json": { "application/json": {
schema: AttachmentSchema, schema: AttachmentSchema,
}, },
}, },
}, },
404: { 404: {
description: "Media not found", description: "Attachment not found",
content: { content: {
"application/json": { "application/json": {
schema: ErrorSchema, schema: ErrorSchema,
}, },
}, },
}, },
...reusedResponses,
}, },
}); });
const routeGet = createRoute({ const routeGet = createRoute({
method: "get", method: "get",
path: "/api/v1/media/{id}", 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: [ middleware: [
auth({ auth({
auth: true, auth: true,
@ -74,11 +88,13 @@ const routeGet = createRoute({
}), }),
] as const, ] as const,
request: { request: {
params: schemas.param, params: z.object({
id: AttachmentSchema.shape.id,
}),
}, },
responses: { responses: {
200: { 200: {
description: "Media", description: "Attachment",
content: { content: {
"application/json": { "application/json": {
schema: AttachmentSchema, schema: AttachmentSchema,
@ -86,13 +102,14 @@ const routeGet = createRoute({
}, },
}, },
404: { 404: {
description: "Media not found", description: "Attachment not found",
content: { content: {
"application/json": { "application/json": {
schema: ErrorSchema, schema: ErrorSchema,
}, },
}, },
}, },
...reusedResponses,
}, },
}); });

View file

@ -1,27 +1,21 @@
import { apiRoute, auth } from "@/api"; import { apiRoute, auth, reusedResponses } from "@/api";
import { createRoute, z } from "@hono/zod-openapi"; import { createRoute, z } from "@hono/zod-openapi";
import { Media } from "@versia/kit/db"; import { Media } from "@versia/kit/db";
import { RolePermissions } from "@versia/kit/tables"; import { RolePermissions } from "@versia/kit/tables";
import { Attachment as AttachmentSchema } from "~/classes/schemas/attachment"; import { Attachment as AttachmentSchema } from "~/classes/schemas/attachment";
import { config } from "~/packages/config-manager/index.ts";
import { ErrorSchema } from "~/types/api"; 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({ const route = createRoute({
method: "post", method: "post",
path: "/api/v1/media", 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: [ middleware: [
auth({ auth({
auth: true, auth: true,
@ -33,21 +27,42 @@ const route = createRoute({
body: { body: {
content: { content: {
"multipart/form-data": { "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: { responses: {
200: { 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: { content: {
"application/json": { "application/json": {
schema: AttachmentSchema, schema: AttachmentSchema,
}, },
}, },
}, },
413: { 413: {
description: "File too large", description: "File too large",
content: { content: {
@ -64,6 +79,7 @@ const route = createRoute({
}, },
}, },
}, },
...reusedResponses,
}, },
}); });
@ -73,7 +89,7 @@ export default apiRoute((app) =>
const attachment = await Media.fromFile(file, { const attachment = await Media.fromFile(file, {
thumbnail, thumbnail,
description, description: description ?? undefined,
}); });
return context.json(attachment.toApi(), 200); return context.json(attachment.toApi(), 200);

View file

@ -1,13 +1,19 @@
import { apiRoute, auth } from "@/api"; import { apiRoute, auth, reusedResponses } from "@/api";
import { createRoute, z } from "@hono/zod-openapi"; import { createRoute, z } from "@hono/zod-openapi";
import { Notification } from "@versia/kit/db"; import { Notification } from "@versia/kit/db";
import { RolePermissions } from "@versia/kit/tables"; import { RolePermissions } from "@versia/kit/tables";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
import { Notification as NotificationSchema } from "~/classes/schemas/notification";
const route = createRoute({ const route = createRoute({
method: "post", method: "post",
path: "/api/v1/notifications/{id}/dismiss", 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: [ middleware: [
auth({ auth({
auth: true, auth: true,
@ -17,13 +23,14 @@ const route = createRoute({
] as const, ] as const,
request: { request: {
params: z.object({ params: z.object({
id: z.string().uuid(), id: NotificationSchema.shape.id,
}), }),
}, },
responses: { responses: {
200: { 200: {
description: "Notification dismissed", description: "Notification with given ID successfully dismissed",
}, },
401: reusedResponses[401],
}, },
}); });

View file

@ -1,4 +1,4 @@
import { apiRoute, auth } from "@/api"; import { apiRoute, auth, reusedResponses } from "@/api";
import { createRoute, z } from "@hono/zod-openapi"; import { createRoute, z } from "@hono/zod-openapi";
import { Notification } from "@versia/kit/db"; import { Notification } from "@versia/kit/db";
import { RolePermissions } from "@versia/kit/tables"; import { RolePermissions } from "@versia/kit/tables";
@ -9,7 +9,12 @@ import { ErrorSchema } from "~/types/api";
const route = createRoute({ const route = createRoute({
method: "get", method: "get",
path: "/api/v1/notifications/{id}", 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: [ middleware: [
auth({ auth({
auth: true, auth: true,
@ -19,19 +24,18 @@ const route = createRoute({
] as const, ] as const,
request: { request: {
params: z.object({ params: z.object({
id: z.string().uuid(), id: NotificationSchema.shape.id,
}), }),
}, },
responses: { responses: {
200: { 200: {
description: "Notification", description: "A single Notification",
content: { content: {
"application/json": { "application/json": {
schema: NotificationSchema, schema: NotificationSchema,
}, },
}, },
}, },
404: { 404: {
description: "Notification not found", description: "Notification not found",
content: { content: {
@ -40,6 +44,7 @@ const route = createRoute({
}, },
}, },
}, },
401: reusedResponses[401],
}, },
}); });

View file

@ -1,11 +1,16 @@
import { apiRoute, auth } from "@/api"; import { apiRoute, auth, reusedResponses } from "@/api";
import { createRoute } from "@hono/zod-openapi"; import { createRoute } from "@hono/zod-openapi";
import { RolePermissions } from "@versia/kit/tables"; import { RolePermissions } from "@versia/kit/tables";
const route = createRoute({ const route = createRoute({
method: "post", method: "post",
path: "/api/v1/notifications/clear", 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: [ middleware: [
auth({ auth({
auth: true, auth: true,
@ -15,8 +20,9 @@ const route = createRoute({
] as const, ] as const,
responses: { responses: {
200: { 200: {
description: "Notifications cleared", description: "Notifications successfully cleared.",
}, },
401: reusedResponses[401],
}, },
}); });

View file

@ -1,4 +1,4 @@
import { apiRoute, auth } from "@/api"; import { apiRoute, auth, reusedResponses } from "@/api";
import { createRoute, z } from "@hono/zod-openapi"; import { createRoute, z } from "@hono/zod-openapi";
import { RolePermissions } from "@versia/kit/tables"; import { RolePermissions } from "@versia/kit/tables";
@ -26,6 +26,7 @@ const route = createRoute({
200: { 200: {
description: "Notifications dismissed", description: "Notifications dismissed",
}, },
401: reusedResponses[401],
}, },
}); });

View file

@ -50,7 +50,7 @@ beforeAll(async () => {
}, },
); );
expect(res3.status).toBe(201); expect(res3.status).toBe(200);
const res4 = await fakeRequest("/api/v1/statuses", { const res4 = await fakeRequest("/api/v1/statuses", {
method: "POST", method: "POST",
@ -64,7 +64,7 @@ beforeAll(async () => {
}), }),
}); });
expect(res4.status).toBe(201); expect(res4.status).toBe(200);
}); });
afterAll(async () => { afterAll(async () => {

View file

@ -1,32 +1,20 @@
import { apiRoute, auth } from "@/api"; import { apiRoute, auth, reusedResponses } from "@/api";
import { createRoute, z } from "@hono/zod-openapi"; import { createRoute, z } from "@hono/zod-openapi";
import { Timeline } from "@versia/kit/db"; import { Timeline } from "@versia/kit/db";
import { Notifications, RolePermissions } from "@versia/kit/tables"; import { Notifications, RolePermissions } from "@versia/kit/tables";
import { and, eq, gt, gte, inArray, lt, not, sql } from "drizzle-orm"; import { and, eq, gt, gte, inArray, lt, not, sql } from "drizzle-orm";
import { Account as AccountSchema } from "~/classes/schemas/account"; import { Account as AccountSchema } from "~/classes/schemas/account";
import { Notification as NotificationSchema } from "~/classes/schemas/notification.ts"; import { Notification as NotificationSchema } from "~/classes/schemas/notification.ts";
import { zBoolean } from "~/packages/config-manager/config.type";
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"),
};
const route = createRoute({ const route = createRoute({
method: "get", method: "get",
path: "/api/v1/notifications", 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: [ middleware: [
auth({ auth({
auth: true, auth: true,
@ -37,7 +25,58 @@ const route = createRoute({
}), }),
] as const, ] as const,
request: { 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 users 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: { responses: {
200: { 200: {
@ -48,6 +87,7 @@ const route = createRoute({
}, },
}, },
}, },
...reusedResponses,
}, },
}); });

View file

@ -1,4 +1,4 @@
import { apiRoute, auth } from "@/api"; import { apiRoute, auth, reusedResponses } from "@/api";
import { createRoute } from "@hono/zod-openapi"; import { createRoute } from "@hono/zod-openapi";
import { RolePermissions } from "@versia/kit/tables"; import { RolePermissions } from "@versia/kit/tables";
import { Account } from "~/classes/schemas/account"; import { Account } from "~/classes/schemas/account";
@ -6,7 +6,12 @@ import { Account } from "~/classes/schemas/account";
const route = createRoute({ const route = createRoute({
method: "delete", method: "delete",
path: "/api/v1/profile/avatar", path: "/api/v1/profile/avatar",
summary: "Delete avatar", summary: "Delete profile avatar",
description: "Deletes the avatar associated with the users profile.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/profile/#delete-profile-avatar",
},
tags: ["Profile"],
middleware: [ middleware: [
auth({ auth({
auth: true, auth: true,
@ -16,13 +21,16 @@ const route = createRoute({
] as const, ] as const,
responses: { responses: {
200: { 200: {
description: "User", description:
"The avatar was successfully deleted from the users profile. If there were no avatar associated with the profile, the response will still indicate a successful deletion.",
content: { content: {
"application/json": { "application/json": {
// TODO: Return a CredentialAccount
schema: Account, schema: Account,
}, },
}, },
}, },
...reusedResponses,
}, },
}); });

View file

@ -1,4 +1,4 @@
import { apiRoute, auth } from "@/api"; import { apiRoute, auth, reusedResponses } from "@/api";
import { createRoute } from "@hono/zod-openapi"; import { createRoute } from "@hono/zod-openapi";
import { RolePermissions } from "@versia/kit/tables"; import { RolePermissions } from "@versia/kit/tables";
import { Account } from "~/classes/schemas/account"; import { Account } from "~/classes/schemas/account";
@ -6,7 +6,12 @@ import { Account } from "~/classes/schemas/account";
const route = createRoute({ const route = createRoute({
method: "delete", method: "delete",
path: "/api/v1/profile/header", path: "/api/v1/profile/header",
summary: "Delete header", summary: "Delete profile header",
description: "Deletes the header image associated with the users profile.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/profile/#delete-profile-header",
},
tags: ["Profiles"],
middleware: [ middleware: [
auth({ auth({
auth: true, auth: true,
@ -16,13 +21,15 @@ const route = createRoute({
] as const, ] as const,
responses: { responses: {
200: { 200: {
description: "User", description:
"The header was successfully deleted from the users profile. If there were no header associated with the profile, the response will still indicate a successful deletion.",
content: { content: {
"application/json": { "application/json": {
schema: Account, schema: Account,
}, },
}, },
}, },
...reusedResponses,
}, },
}); });

View file

@ -1,4 +1,4 @@
import { apiRoute, auth } from "@/api"; import { apiRoute, auth, reusedResponses } from "@/api";
import { createRoute, z } from "@hono/zod-openapi"; import { createRoute, z } from "@hono/zod-openapi";
import { PushSubscription } from "@versia/kit/db"; import { PushSubscription } from "@versia/kit/db";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
@ -14,6 +14,7 @@ export default apiRoute((app) =>
externalDocs: { externalDocs: {
url: "https://docs.joinmastodon.org/methods/push/#delete", url: "https://docs.joinmastodon.org/methods/push/#delete",
}, },
tags: ["Push Notifications"],
middleware: [ middleware: [
auth({ auth({
auth: true, auth: true,
@ -31,6 +32,7 @@ export default apiRoute((app) =>
}, },
}, },
}, },
...reusedResponses,
}, },
}), }),
async (context) => { async (context) => {

View file

@ -1,4 +1,4 @@
import { apiRoute, auth } from "@/api"; import { apiRoute, auth, reusedResponses } from "@/api";
import { createRoute } from "@hono/zod-openapi"; import { createRoute } from "@hono/zod-openapi";
import { PushSubscription } from "@versia/kit/db"; import { PushSubscription } from "@versia/kit/db";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
@ -16,6 +16,7 @@ export default apiRoute((app) =>
externalDocs: { externalDocs: {
url: "https://docs.joinmastodon.org/methods/push/#get", url: "https://docs.joinmastodon.org/methods/push/#get",
}, },
tags: ["Push Notifications"],
middleware: [ middleware: [
auth({ auth({
auth: true, auth: true,
@ -32,6 +33,7 @@ export default apiRoute((app) =>
}, },
}, },
}, },
...reusedResponses,
}, },
}), }),
async (context) => { async (context) => {

View file

@ -1,4 +1,4 @@
import { apiRoute } from "@/api"; import { apiRoute, reusedResponses } from "@/api";
import { auth, jsonOrForm } from "@/api"; import { auth, jsonOrForm } from "@/api";
import { createRoute } from "@hono/zod-openapi"; import { createRoute } from "@hono/zod-openapi";
import { PushSubscription } from "@versia/kit/db"; import { PushSubscription } from "@versia/kit/db";
@ -17,6 +17,7 @@ export default apiRoute((app) =>
externalDocs: { externalDocs: {
url: "https://docs.joinmastodon.org/methods/push/#create", url: "https://docs.joinmastodon.org/methods/push/#create",
}, },
tags: ["Push Notifications"],
middleware: [ middleware: [
auth({ auth({
auth: true, auth: true,
@ -44,6 +45,7 @@ export default apiRoute((app) =>
}, },
}, },
}, },
...reusedResponses,
}, },
}), }),
async (context) => { async (context) => {

View file

@ -1,4 +1,4 @@
import { apiRoute, auth, jsonOrForm } from "@/api"; import { apiRoute, auth, jsonOrForm, reusedResponses } from "@/api";
import { createRoute } from "@hono/zod-openapi"; import { createRoute } from "@hono/zod-openapi";
import { PushSubscription } from "@versia/kit/db"; import { PushSubscription } from "@versia/kit/db";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
@ -19,6 +19,7 @@ export default apiRoute((app) =>
externalDocs: { externalDocs: {
url: "https://docs.joinmastodon.org/methods/push/#update", url: "https://docs.joinmastodon.org/methods/push/#update",
}, },
tags: ["Push Notifications"],
middleware: [ middleware: [
auth({ auth({
auth: true, auth: true,
@ -48,6 +49,7 @@ export default apiRoute((app) =>
}, },
}, },
}, },
...reusedResponses,
}, },
}), }),
async (context) => { async (context) => {

View file

@ -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 { createRoute, z } from "@hono/zod-openapi";
import { RolePermissions } from "@versia/kit/tables"; import { RolePermissions } from "@versia/kit/tables";
import { Status } from "~/classes/schemas/status"; import { Context as ContextSchema } from "~/classes/schemas/context";
import { ErrorSchema } from "~/types/api"; import { Status as StatusSchema } from "~/classes/schemas/status";
const route = createRoute({ const route = createRoute({
method: "get", method: "get",
path: "/api/v1/statuses/{id}/context", 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: [ middleware: [
auth({ auth({
auth: false, auth: false,
@ -14,32 +26,22 @@ const route = createRoute({
}), }),
withNoteParam, withNoteParam,
] as const, ] as const,
summary: "Get status context",
request: { request: {
params: z.object({ params: z.object({
id: z.string().uuid(), id: StatusSchema.shape.id,
}), }),
}, },
responses: { responses: {
200: { 200: {
description: "Status context", description: "Status parent and children",
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: ContextSchema,
ancestors: z.array(Status),
descendants: z.array(Status),
}),
},
},
},
404: {
description: "Record not found",
content: {
"application/json": {
schema: ErrorSchema,
}, },
}, },
}, },
404: noteNotFound,
401: reusedResponses[401],
}, },
}); });

View file

@ -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 { createRoute, z } from "@hono/zod-openapi";
import { RolePermissions } from "@versia/kit/tables"; import { RolePermissions } from "@versia/kit/tables";
import { Status } from "~/classes/schemas/status"; import { Status as StatusSchema } from "~/classes/schemas/status";
const route = createRoute({ const route = createRoute({
method: "post", method: "post",
path: "/api/v1/statuses/{id}/favourite", path: "/api/v1/statuses/{id}/favourite",
summary: "Favourite a status", summary: "Favourite a status",
description: "Add a status to your favourites list.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/statuses/#favourite",
},
tags: ["Statuses"],
middleware: [ middleware: [
auth({ auth({
auth: true, auth: true,
@ -19,18 +30,20 @@ const route = createRoute({
] as const, ] as const,
request: { request: {
params: z.object({ params: z.object({
id: z.string().uuid(), id: StatusSchema.shape.id,
}), }),
}, },
responses: { responses: {
200: { 200: {
description: "Favourited status", description: "Status favourited or was already favourited",
content: { content: {
"application/json": { "application/json": {
schema: Status, schema: StatusSchema,
}, },
}, },
}, },
404: noteNotFound,
401: reusedResponses[401],
}, },
}); });

View file

@ -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 { createRoute, z } from "@hono/zod-openapi";
import { Timeline } from "@versia/kit/db"; import { Timeline } from "@versia/kit/db";
import { RolePermissions, Users } from "@versia/kit/tables"; import { RolePermissions, Users } from "@versia/kit/tables";
import { and, gt, gte, lt, sql } from "drizzle-orm"; import { and, gt, gte, lt, sql } from "drizzle-orm";
import { Account } from "~/classes/schemas/account"; import { Account as AccountSchema } from "~/classes/schemas/account";
import { Status as StatusSchema } from "~/classes/schemas/status";
const schemas = {
query: z.object({
max_id: z.string().uuid().optional(),
since_id: z.string().uuid().optional(),
min_id: z.string().uuid().optional(),
limit: z.coerce.number().int().min(1).max(80).default(40),
}),
param: z.object({
id: z.string().uuid(),
}),
};
const route = createRoute({ const route = createRoute({
method: "get", method: "get",
path: "/api/v1/statuses/{id}/favourited_by", 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: [ middleware: [
auth({ auth({
auth: true, auth: true,
@ -32,18 +32,53 @@ const route = createRoute({
withNoteParam, withNoteParam,
] as const, ] as const,
request: { request: {
params: schemas.param, params: z.object({
query: schemas.query, 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: { responses: {
200: { 200: {
description: "Users who favourited a status", description: "A list of accounts who favourited the status",
content: { content: {
"application/json": { "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: `<https://versia.social/api/v1/statuses/f048addc-49ca-4443-bdd8-a1b641ae8adc/favourited_by?limit=2&max_id=359ae97f-78dd-43e7-8e13-1d8e1d7829b5>; rel="next", <https://versia.social/api/v1/statuses/f048addc-49ca-4443-bdd8-a1b641ae8adc/favourited_by?limit=2&since_id=75e9f5a9-f455-48eb-8f60-435b4a088bc0>; rel="prev"`,
externalDocs: {
url: "https://docs.joinmastodon.org/api/guidelines/#pagination",
},
}),
}),
}, },
404: noteNotFound,
...reusedResponses,
}, },
}); });

View file

@ -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 { createRoute, z } from "@hono/zod-openapi";
import { Media } from "@versia/kit/db"; import { Media } from "@versia/kit/db";
import { RolePermissions } from "@versia/kit/tables"; import { RolePermissions } from "@versia/kit/tables";
import ISO6391 from "iso-639-1";
import { ApiError } from "~/classes/errors/api-error"; 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 { config } from "~/packages/config-manager/index.ts";
import { ErrorSchema } from "~/types/api";
const schemas = { const schema = z
param: z.object({ .object({
id: z.string().uuid(), status: StatusSourceSchema.shape.text.optional().openapi({
}), description:
json: z "The text content of the status. If media_ids is provided, this becomes optional. Attaching a poll is optional while status is provided.",
.object({ }),
status: z /* Versia Server API Extension */
.string() content_type: z
.max(config.validation.max_note_size) .enum(["text/plain", "text/html", "text/markdown"])
.refine( .default("text/plain")
(s) => .openapi({
!config.filters.note_content.some((filter) => description: "Content-Type of the status text.",
s.match(filter), example: "text/markdown",
), }),
"Status contains blocked words", media_ids: z
) .array(AttachmentSchema.shape.id)
.optional(), .max(config.validation.max_media_attachments)
content_type: z.string().optional().default("text/plain"), .default([])
media_ids: z .openapi({
.array(z.string().uuid()) description:
.max(config.validation.max_media_attachments) "Include Attachment IDs to be attached as media. If provided, status becomes optional, and poll cannot be used.",
.default([]), }),
spoiler_text: z.string().max(255).optional(), spoiler_text: StatusSourceSchema.shape.spoiler_text.optional().openapi({
sensitive: z description:
.string() "Text to be shown as a warning or subject before the actual content. Statuses are generally collapsed behind this field.",
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) }),
.or(z.boolean()) sensitive: zBoolean.default(false).openapi({
.optional(), description: "Mark status and attached media as sensitive?",
language: z }),
.enum(ISO6391.getAllCodes() as [string, ...string[]]) language: StatusSchema.shape.language.optional(),
.optional(), "poll[options]": z
"poll[options]": z .array(PollOption.shape.title)
.array(z.string().max(config.validation.max_poll_option_size)) .max(config.validation.max_poll_options)
.max(config.validation.max_poll_options) .optional()
.optional(), .openapi({
"poll[expires_in]": z.coerce description:
.number() "Possible answers to the poll. If provided, media_ids cannot be used, and poll[expires_in] must be provided.",
.int() }),
.min(config.validation.min_poll_duration) "poll[expires_in]": z.coerce
.max(config.validation.max_poll_duration) .number()
.optional(), .int()
"poll[multiple]": z .min(config.validation.min_poll_duration)
.string() .max(config.validation.max_poll_duration)
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) .optional()
.or(z.boolean()) .openapi({
.optional(), description:
"poll[hide_totals]": z "Duration that the poll should be open, in seconds. If provided, media_ids cannot be used, and poll[options] must be provided.",
.string() }),
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) "poll[multiple]": zBoolean.optional().openapi({
.or(z.boolean()) description: "Allow multiple choices?",
.optional(), }),
}) "poll[hide_totals]": zBoolean.optional().openapi({
.refine( description: "Hide vote counts until the poll ends?",
(obj) => !(obj.media_ids.length > 0 && obj["poll[options]"]), }),
"Cannot attach poll to media", })
), .refine(
}; (obj) => !(obj.media_ids.length > 0 && obj["poll[options]"]),
"Cannot attach poll to media",
);
const routeGet = createRoute({ const routeGet = createRoute({
method: "get", method: "get",
path: "/api/v1/statuses/{id}", 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: [ middleware: [
auth({ auth({
auth: false, auth: false,
@ -78,25 +96,20 @@ const routeGet = createRoute({
withNoteParam, withNoteParam,
] as const, ] as const,
request: { request: {
params: schemas.param, params: z.object({
id: StatusSchema.shape.id,
}),
}, },
responses: { responses: {
200: { 200: {
description: "Status", description: "Status",
content: { content: {
"application/json": { "application/json": {
schema: Status, schema: StatusSchema,
},
},
},
404: {
description: "Record not found",
content: {
"application/json": {
schema: ErrorSchema,
}, },
}, },
}, },
404: noteNotFound,
}, },
}); });
@ -104,6 +117,11 @@ const routeDelete = createRoute({
method: "delete", method: "delete",
path: "/api/v1/statuses/{id}", path: "/api/v1/statuses/{id}",
summary: "Delete a status", summary: "Delete a status",
description: "Delete one of your own statuses.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/statuses/#delete",
},
tags: ["Statuses"],
middleware: [ middleware: [
auth({ auth({
auth: true, auth: true,
@ -115,40 +133,35 @@ const routeDelete = createRoute({
withNoteParam, withNoteParam,
] as const, ] as const,
request: { request: {
params: schemas.param, params: z.object({
id: StatusSchema.shape.id,
}),
}, },
responses: { responses: {
200: { 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: { content: {
"application/json": { "application/json": {
schema: Status, schema: StatusSchema,
},
},
},
401: {
description: "Unauthorized",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
404: {
description: "Record not found",
content: {
"application/json": {
schema: ErrorSchema,
}, },
}, },
}, },
404: noteNotFound,
401: reusedResponses[401],
}, },
}); });
const routePut = createRoute({ const routePut = createRoute({
method: "put", method: "put",
path: "/api/v1/statuses/{id}", 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 polls options will reset the votes.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/statuses/#edit",
},
tags: ["Statuses"],
middleware: [ middleware: [
auth({ auth({
auth: true, auth: true,
@ -161,46 +174,34 @@ const routePut = createRoute({
withNoteParam, withNoteParam,
] as const, ] as const,
request: { request: {
params: schemas.param, params: z.object({
id: StatusSchema.shape.id,
}),
body: { body: {
content: { content: {
"application/json": { "application/json": {
schema: schemas.json, schema: schema,
}, },
"application/x-www-form-urlencoded": { "application/x-www-form-urlencoded": {
schema: schemas.json, schema: schema,
}, },
"multipart/form-data": { "multipart/form-data": {
schema: schemas.json, schema: schema,
}, },
}, },
}, },
}, },
responses: { responses: {
200: { 200: {
description: "Updated status", description: "Status has been successfully edited.",
content: { content: {
"application/json": { "application/json": {
schema: Status, schema: StatusSchema,
},
},
},
401: {
description: "Unauthorized",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
422: {
description: "Invalid media IDs",
content: {
"application/json": {
schema: ErrorSchema,
}, },
}, },
}, },
404: noteNotFound,
...reusedResponses,
}, },
}); });

View file

@ -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 { createRoute, z } from "@hono/zod-openapi";
import { db } from "@versia/kit/db"; import { db } from "@versia/kit/db";
import { RolePermissions } from "@versia/kit/tables"; import { RolePermissions } from "@versia/kit/tables";
import type { SQL } from "drizzle-orm"; import type { SQL } from "drizzle-orm";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
import { Status } from "~/classes/schemas/status"; import { Status as StatusSchema } from "~/classes/schemas/status";
import { ErrorSchema } from "~/types/api";
const route = createRoute({ const route = createRoute({
method: "post", method: "post",
path: "/api/v1/statuses/{id}/pin", 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: [ middleware: [
auth({ auth({
auth: true, auth: true,
@ -23,34 +34,21 @@ const route = createRoute({
] as const, ] as const,
request: { request: {
params: z.object({ params: z.object({
id: z.string().uuid(), id: StatusSchema.shape.id,
}), }),
}, },
responses: { responses: {
200: { 200: {
description: "Pinned status", description:
"Status pinned. Note the status is not a reblog and its authoring account is your own.",
content: { content: {
"application/json": { "application/json": {
schema: Status, schema: StatusSchema,
},
},
},
401: {
description: "Unauthorized",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
422: {
description: "Already pinned",
content: {
"application/json": {
schema: ErrorSchema,
}, },
}, },
}, },
404: noteNotFound,
401: reusedResponses[401],
}, },
}); });

View file

@ -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 { createRoute, z } from "@hono/zod-openapi";
import { Note } from "@versia/kit/db"; import { Note } from "@versia/kit/db";
import { Notes, RolePermissions } from "@versia/kit/tables"; import { Notes, RolePermissions } from "@versia/kit/tables";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { ApiError } from "~/classes/errors/api-error"; import { Status as StatusSchema } from "~/classes/schemas/status";
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"),
}),
};
const route = createRoute({ const route = createRoute({
method: "post", method: "post",
path: "/api/v1/statuses/{id}/reblog", 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: [ middleware: [
auth({ auth({
auth: true, auth: true,
@ -32,46 +33,44 @@ const route = createRoute({
withNoteParam, withNoteParam,
] as const, ] as const,
request: { request: {
params: schemas.param, params: z.object({
id: StatusSchema.shape.id,
}),
body: { body: {
content: { content: {
"application/json": { "application/json": {
schema: schemas.json, schema: z.object({
visibility:
StatusSchema.shape.visibility.default("public"),
}),
}, },
"application/x-www-form-urlencoded": { "application/x-www-form-urlencoded": {
schema: schemas.json, schema: z.object({
visibility:
StatusSchema.shape.visibility.default("public"),
}),
}, },
"multipart/form-data": { "multipart/form-data": {
schema: schemas.json, schema: z.object({
visibility:
StatusSchema.shape.visibility.default("public"),
}),
}, },
}, },
}, },
}, },
responses: { responses: {
201: { 200: {
description: "Reblogged status", 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: { content: {
"application/json": { "application/json": {
schema: Status, schema: StatusSchema,
},
},
},
422: {
description: "Already reblogged",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
500: {
description: "Failed to reblog",
content: {
"application/json": {
schema: ErrorSchema,
}, },
}, },
}, },
404: noteNotFound,
...reusedResponses,
}, },
}); });
@ -86,7 +85,7 @@ export default apiRoute((app) =>
); );
if (existingReblog) { if (existingReblog) {
throw new ApiError(422, "Already reblogged"); return context.json(await existingReblog.toApi(user), 200);
} }
const newReblog = await Note.insert({ const newReblog = await Note.insert({
@ -98,16 +97,10 @@ export default apiRoute((app) =>
applicationId: null, 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()) { if (note.author.isLocal() && user.isLocal()) {
await note.author.notify("reblog", user, newReblog); await note.author.notify("reblog", user, newReblog);
} }
return context.json(await finalNewReblog.toApi(user), 201); return context.json(await newReblog.toApi(user), 200);
}), }),
); );

View file

@ -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 { createRoute, z } from "@hono/zod-openapi";
import { Timeline } from "@versia/kit/db"; import { Timeline } from "@versia/kit/db";
import { RolePermissions, Users } from "@versia/kit/tables"; import { RolePermissions, Users } from "@versia/kit/tables";
import { and, gt, gte, lt, sql } from "drizzle-orm"; import { and, gt, gte, lt, sql } from "drizzle-orm";
import { Account } from "~/classes/schemas/account"; import { Account as AccountSchema } from "~/classes/schemas/account";
import { Status as StatusSchema } from "~/classes/schemas/status";
const schemas = {
param: z.object({
id: z.string().uuid(),
}),
query: z.object({
max_id: z.string().uuid().optional(),
since_id: z.string().uuid().optional(),
min_id: z.string().uuid().optional(),
limit: z.coerce.number().int().min(1).max(80).default(40),
}),
};
const route = createRoute({ const route = createRoute({
method: "get", method: "get",
path: "/api/v1/statuses/{id}/reblogged_by", 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: [ middleware: [
auth({ auth({
auth: true, auth: true,
@ -32,18 +32,53 @@ const route = createRoute({
withNoteParam, withNoteParam,
] as const, ] as const,
request: { request: {
params: schemas.param, params: z.object({
query: schemas.query, 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: { responses: {
200: { 200: {
description: "Users who reblogged a status", description: "A list of accounts that boosted the status",
content: { content: {
"application/json": { "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: `<https://versia.social/api/v1/statuses/f048addc-49ca-4443-bdd8-a1b641ae8adc/reblogged_by?limit=2&max_id=359ae97f-78dd-43e7-8e13-1d8e1d7829b5>; rel="next", <https://versia.social/api/v1/statuses/f048addc-49ca-4443-bdd8-a1b641ae8adc/reblogged_by?limit=2&since_id=75e9f5a9-f455-48eb-8f60-435b4a088bc0>; rel="prev"`,
externalDocs: {
url: "https://docs.joinmastodon.org/api/guidelines/#pagination",
},
}),
}),
}, },
404: noteNotFound,
...reusedResponses,
}, },
}); });

View file

@ -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 { createRoute, z } from "@hono/zod-openapi";
import type { StatusSource as ApiStatusSource } from "@versia/client/types";
import { RolePermissions } from "@versia/kit/tables"; import { RolePermissions } from "@versia/kit/tables";
import {
Status as StatusSchema,
StatusSource as StatusSourceSchema,
} from "~/classes/schemas/status";
const route = createRoute({ const route = createRoute({
method: "get", method: "get",
path: "/api/v1/statuses/{id}/source", 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: [ middleware: [
auth({ auth({
auth: true, auth: true,
@ -19,7 +34,7 @@ const route = createRoute({
] as const, ] as const,
request: { request: {
params: z.object({ params: z.object({
id: z.string().uuid(), id: StatusSchema.shape.id,
}), }),
}, },
responses: { responses: {
@ -27,14 +42,12 @@ const route = createRoute({
description: "Status source", description: "Status source",
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: StatusSourceSchema,
id: z.string().uuid(),
spoiler_text: z.string(),
text: z.string(),
}),
}, },
}, },
}, },
404: noteNotFound,
401: reusedResponses[401],
}, },
}); });
@ -48,7 +61,7 @@ export default apiRoute((app) =>
// TODO: Give real source for spoilerText // TODO: Give real source for spoilerText
spoiler_text: note.data.spoilerText, spoiler_text: note.data.spoilerText,
text: note.data.contentSource, text: note.data.contentSource,
} satisfies ApiStatusSource, },
200, 200,
); );
}), }),

View file

@ -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 { createRoute, z } from "@hono/zod-openapi";
import { RolePermissions } from "@versia/kit/tables"; import { RolePermissions } from "@versia/kit/tables";
import { Status } from "~/classes/schemas/status"; import { Status as StatusSchema } from "~/classes/schemas/status";
const route = createRoute({ const route = createRoute({
method: "post", method: "post",
path: "/api/v1/statuses/{id}/unfavourite", 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: [ middleware: [
auth({ auth({
auth: true, auth: true,
@ -19,18 +30,20 @@ const route = createRoute({
] as const, ] as const,
request: { request: {
params: z.object({ params: z.object({
id: z.string().uuid(), id: StatusSchema.shape.id,
}), }),
}, },
responses: { responses: {
200: { 200: {
description: "Unfavourited status", description: "Status unfavourited or was already not favourited",
content: { content: {
"application/json": { "application/json": {
schema: Status, schema: StatusSchema,
}, },
}, },
}, },
404: noteNotFound,
401: reusedResponses[401],
}, },
}); });

View file

@ -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 { createRoute, z } from "@hono/zod-openapi";
import { RolePermissions } from "@versia/kit/tables"; import { RolePermissions } from "@versia/kit/tables";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
import { Status } from "~/classes/schemas/status"; import { Status as StatusSchema } from "~/classes/schemas/status";
import { ErrorSchema } from "~/types/api";
const route = createRoute({ const route = createRoute({
method: "post", method: "post",
path: "/api/v1/statuses/{id}/unpin", 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: [ middleware: [
auth({ auth({
auth: true, auth: true,
@ -21,26 +31,20 @@ const route = createRoute({
] as const, ] as const,
request: { request: {
params: z.object({ params: z.object({
id: z.string().uuid(), id: StatusSchema.shape.id,
}), }),
}, },
responses: { responses: {
200: { 200: {
description: "Unpinned status", description: "Status unpinned, or was already not pinned",
content: { content: {
"application/json": { "application/json": {
schema: Status, schema: StatusSchema,
},
},
},
401: {
description: "Unauthorized",
content: {
"application/json": {
schema: ErrorSchema,
}, },
}, },
}, },
404: noteNotFound,
401: reusedResponses[401],
}, },
}); });

View file

@ -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 { createRoute, z } from "@hono/zod-openapi";
import { Note } from "@versia/kit/db"; import { Note } from "@versia/kit/db";
import { Notes, RolePermissions } from "@versia/kit/tables"; import { Notes, RolePermissions } from "@versia/kit/tables";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
import { Status } from "~/classes/schemas/status"; import { Status as StatusSchema } from "~/classes/schemas/status";
import { ErrorSchema } from "~/types/api";
const route = createRoute({ const route = createRoute({
method: "post", method: "post",
path: "/api/v1/statuses/{id}/unreblog", 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: [ middleware: [
auth({ auth({
auth: true, auth: true,
@ -23,26 +33,20 @@ const route = createRoute({
] as const, ] as const,
request: { request: {
params: z.object({ params: z.object({
id: z.string().uuid(), id: StatusSchema.shape.id,
}), }),
}, },
responses: { responses: {
200: { 200: {
description: "Unreblogged status", description: "Status unboosted or was already not boosted",
content: { content: {
"application/json": { "application/json": {
schema: Status, schema: StatusSchema,
},
},
},
422: {
description: "Not already reblogged",
content: {
"application/json": {
schema: ErrorSchema,
}, },
}, },
}, },
404: noteNotFound,
401: reusedResponses[401],
}, },
}); });
@ -59,7 +63,7 @@ export default apiRoute((app) =>
); );
if (!existingReblog) { if (!existingReblog) {
throw new ApiError(422, "Note already reblogged"); return context.json(await note.toApi(user), 200);
} }
await existingReblog.delete(); await existingReblog.delete();

View file

@ -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( expect(response.headers.get("content-type")).toContain(
"application/json", "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( expect(response.headers.get("content-type")).toContain(
"application/json", "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( expect(response2.headers.get("content-type")).toContain(
"application/json", "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( expect(response2.headers.get("content-type")).toContain(
"application/json", "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( expect(response.headers.get("content-type")).toContain(
"application/json", "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( expect(response.headers.get("content-type")).toContain(
"application/json", "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( expect(response.headers.get("content-type")).toContain(
"application/json", "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( expect(response.headers.get("content-type")).toContain(
"application/json", "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( expect(response.headers.get("content-type")).toContain(
"application/json", "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( expect(response.headers.get("content-type")).toContain(
"application/json", "application/json",
); );

View file

@ -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 { createRoute, z } from "@hono/zod-openapi";
import { Media, Note } from "@versia/kit/db"; import { Media, Note } from "@versia/kit/db";
import { RolePermissions } from "@versia/kit/tables"; import { RolePermissions } from "@versia/kit/tables";
import ISO6391 from "iso-639-1";
import { ApiError } from "~/classes/errors/api-error"; 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 { config } from "~/packages/config-manager/index.ts";
import { ErrorSchema } from "~/types/api";
const schemas = { const schema = z
json: z .object({
.object({ status: StatusSourceSchema.shape.text.optional().openapi({
status: z description:
.string() "The text content of the status. If media_ids is provided, this becomes optional. Attaching a poll is optional while status is provided.",
.max(config.validation.max_note_size) }),
.trim() /* Versia Server API Extension */
.refine( content_type: z
(s) => .enum(["text/plain", "text/html", "text/markdown"])
!config.filters.note_content.some((filter) => .default("text/plain")
s.match(filter), .openapi({
), description: "Content-Type of the status text.",
"Status contains blocked words", example: "text/markdown",
) }),
.optional(), media_ids: z
// TODO: Add regex to validate .array(AttachmentSchema.shape.id)
content_type: z.string().optional().default("text/plain"), .max(config.validation.max_media_attachments)
media_ids: z .default([])
.array(z.string().uuid()) .openapi({
.max(config.validation.max_media_attachments) description:
.default([]), "Include Attachment IDs to be attached as media. If provided, status becomes optional, and poll cannot be used.",
spoiler_text: z.string().max(255).trim().optional(), }),
sensitive: z spoiler_text: StatusSourceSchema.shape.spoiler_text.optional().openapi({
.string() description:
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) "Text to be shown as a warning or subject before the actual content. Statuses are generally collapsed behind this field.",
.or(z.boolean()) }),
.optional(), sensitive: zBoolean.default(false).openapi({
language: z description: "Mark status and attached media as sensitive?",
.enum(ISO6391.getAllCodes() as [string, ...string[]]) }),
.optional(), language: StatusSchema.shape.language.optional(),
"poll[options]": z "poll[options]": z
.array(z.string().max(config.validation.max_poll_option_size)) .array(PollOption.shape.title)
.max(config.validation.max_poll_options) .max(config.validation.max_poll_options)
.optional(), .optional()
"poll[expires_in]": z.coerce .openapi({
.number() description:
.int() "Possible answers to the poll. If provided, media_ids cannot be used, and poll[expires_in] must be provided.",
.min(config.validation.min_poll_duration) }),
.max(config.validation.max_poll_duration) "poll[expires_in]": z.coerce
.optional(), .number()
"poll[multiple]": z .int()
.string() .min(config.validation.min_poll_duration)
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) .max(config.validation.max_poll_duration)
.or(z.boolean()) .optional()
.optional(), .openapi({
"poll[hide_totals]": z description:
.string() "Duration that the poll should be open, in seconds. If provided, media_ids cannot be used, and poll[options] must be provided.",
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) }),
.or(z.boolean()) "poll[multiple]": zBoolean.optional().openapi({
.optional(), description: "Allow multiple choices?",
in_reply_to_id: z.string().uuid().optional().nullable(), }),
quote_id: z.string().uuid().optional().nullable(), "poll[hide_totals]": zBoolean.optional().openapi({
visibility: z description: "Hide vote counts until the poll ends?",
.enum(["public", "unlisted", "private", "direct"]) }),
.optional() in_reply_to_id: StatusSchema.shape.id.optional().nullable().openapi({
.default("public"), description:
scheduled_at: z.coerce "ID of the status being replied to, if status is a reply.",
.date() }),
.min(new Date(), "Scheduled time must be in the future") /* Versia Server API Extension */
.optional() quote_id: StatusSchema.shape.id.optional().nullable().openapi({
.nullable(), description: "ID of the status being quoted, if status is a quote.",
local_only: z }),
.string() visibility: StatusSchema.shape.visibility.default("public"),
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) scheduled_at: z.coerce
.or(z.boolean()) .date()
.optional() .min(
.default(false), new Date(Date.now() + 5 * 60 * 1000),
}) "must be at least 5 minutes in the future.",
.refine( )
(obj) => obj.status || obj.media_ids.length > 0, .optional()
"Status is required unless media is attached", .nullable()
) .openapi({
.refine( description:
(obj) => !(obj.media_ids.length > 0 && obj["poll[options]"]), "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.",
"Cannot attach poll to media", }),
), /* 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({ const route = createRoute({
method: "post", method: "post",
path: "/api/v1/statuses", 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: [ middleware: [
auth({ auth({
auth: true, auth: true,
@ -96,40 +117,31 @@ const route = createRoute({
}), }),
jsonOrForm(), jsonOrForm(),
] as const, ] as const,
summary: "Post a new status",
request: { request: {
body: { body: {
content: { content: {
"application/json": { "application/json": {
schema: schemas.json, schema: schema,
}, },
"application/x-www-form-urlencoded": { "application/x-www-form-urlencoded": {
schema: schemas.json, schema: schema,
}, },
"multipart/form-data": { "multipart/form-data": {
schema: schemas.json, schema: schema,
}, },
}, },
}, },
}, },
responses: { responses: {
201: { 200: {
description: "The new status", description: "Status will be posted with chosen parameters.",
content: { content: {
"application/json": { "application/json": {
schema: Status, schema: StatusSchema,
},
},
},
422: {
description: "Invalid data",
content: {
"application/json": {
schema: ErrorSchema,
}, },
}, },
}, },
...reusedResponses,
}, },
}); });
@ -193,6 +205,6 @@ export default apiRoute((app) =>
await newNote.federateToUsers(); await newNote.federateToUsers();
} }
return context.json(await newNote.toApi(user), 201); return context.json(await newNote.toApi(user), 200);
}), }),
); );

View file

@ -1,4 +1,5 @@
import { z } from "@hono/zod-openapi"; import { z } from "@hono/zod-openapi";
import { config } from "~/packages/config-manager/index.ts";
import { Id } from "./common.ts"; import { Id } from "./common.ts";
export const Attachment = z export const Attachment = z
@ -50,11 +51,16 @@ export const Attachment = z
}, },
}, },
}), }),
description: z.string().nullable().openapi({ description: z
description: .string()
"Alternate text that describes what is in the media attachment, to be used for the visually impaired or when media attachments do not load.", .trim()
example: "test media description", .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({ blurhash: z.string().nullable().openapi({
description: description:
"A hash computed by the BlurHash algorithm, for generating colorful preview thumbnails when media has not been downloaded yet.", "A hash computed by the BlurHash algorithm, for generating colorful preview thumbnails when media has not been downloaded yet.",

View file

@ -1,16 +1,22 @@
import { z } from "@hono/zod-openapi"; import { z } from "@hono/zod-openapi";
import { config } from "~/packages/config-manager/index.ts";
import { Id } from "./common.ts"; import { Id } from "./common.ts";
import { CustomEmoji } from "./emoji.ts"; import { CustomEmoji } from "./emoji.ts";
export const PollOption = z export const PollOption = z
.object({ .object({
title: z.string().openapi({ title: z
description: "The text value of the poll option.", .string()
example: "yes", .trim()
externalDocs: { .min(1)
url: "https://docs.joinmastodon.org/entities/Poll/#Option-title", .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 votes_count: z
.number() .number()
.int() .int()

View file

@ -1,6 +1,7 @@
import { z } from "@hono/zod-openapi"; import { z } from "@hono/zod-openapi";
import type { Status as ApiNote } from "@versia/client/types"; import type { Status as ApiNote } from "@versia/client/types";
import { zBoolean } from "~/packages/config-manager/config.type.ts"; import { zBoolean } from "~/packages/config-manager/config.type.ts";
import { config } from "~/packages/config-manager/index.ts";
import { Account } from "./account.ts"; import { Account } from "./account.ts";
import { Attachment } from "./attachment.ts"; import { Attachment } from "./attachment.ts";
import { PreviewCard } from "./card.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 statuss subject or content warning.",
example: "",
}),
})
.openapi({
externalDocs: {
url: "https://docs.joinmastodon.org/entities/StatusSource",
},
});
export const Status = z.object({ export const Status = z.object({
id: Id.openapi({ id: Id.openapi({
description: "ID of the status in the database.", description: "ID of the status in the database.",

16
classes/schemas/tos.ts Normal file
View file

@ -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: "<p><h1>ToS</h1><p>None, have fun.</p></p>",
}),
})
.openapi({
description: "Represents the ToS of the instance.",
});

View file

@ -189,7 +189,7 @@ describe("API Tests", () => {
}, },
); );
expect(response.status).toBe(201); expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain( expect(response.headers.get("content-type")).toContain(
"application/json", "application/json",
); );

View file

@ -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<HonoEnv>) => void): typeof fn => export const apiRoute = (fn: (app: OpenAPIHono<HonoEnv>) => void): typeof fn =>
fn; fn;