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
@ -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`,

View file

@ -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,

View file

@ -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,
},
},
},

View file

@ -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(
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<string, unknown> & {
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<typeof InstanceV1Schema>);
}),
);

View file

@ -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 servers 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,
},
},
},

View file

@ -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),
},
},
},

View file

@ -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);

View file

@ -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 servers 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,
},
},
},

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 { 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);
}

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 { 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 MediaAttachments 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,
},
});

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 { 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);

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 { 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],
},
});

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 { 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],
},
});

View file

@ -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],
},
});

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 { RolePermissions } from "@versia/kit/tables";
@ -26,6 +26,7 @@ const route = createRoute({
200: {
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", {
method: "POST",
@ -64,7 +64,7 @@ beforeAll(async () => {
}),
});
expect(res4.status).toBe(201);
expect(res4.status).toBe(200);
});
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 { 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 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: {
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 { 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 users 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 users 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,
},
});

View file

@ -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 users 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 users profile. If there were no header associated with the profile, the response will still indicate a successful deletion.",
content: {
"application/json": {
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 { 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) => {

View file

@ -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) => {

View file

@ -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) => {

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 { 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) => {

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 { 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],
},
});

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 { 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],
},
});

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 { 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: `<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 { 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
const schema = 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"),
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(z.string().uuid())
.array(AttachmentSchema.shape.id)
.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(),
.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(z.string().max(config.validation.max_poll_option_size))
.array(PollOption.shape.title)
.max(config.validation.max_poll_options)
.optional(),
.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(),
"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(),
.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 polls 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,
},
});

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 { 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],
},
});

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 { 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);
}),
);

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 { 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: `<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 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,
);
}),

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 { 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],
},
});

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 { 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],
},
});

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 { 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();

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(
"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",
);

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 { 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
const schema = 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"),
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(z.string().uuid())
.array(AttachmentSchema.shape.id)
.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(),
.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(z.string().max(config.validation.max_poll_option_size))
.array(PollOption.shape.title)
.max(config.validation.max_poll_options)
.optional(),
.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(),
"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"),
.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(), "Scheduled time must be in the future")
.min(
new Date(Date.now() + 5 * 60 * 1000),
"must be at least 5 minutes in the future.",
)
.optional()
.nullable(),
local_only: z
.string()
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
.or(z.boolean())
.optional()
.default(false),
.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,
"Status is required unless media is attached",
(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);
}),
);

View file

@ -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,7 +51,12 @@ export const Attachment = z
},
},
}),
description: z.string().nullable().openapi({
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",

View file

@ -1,10 +1,16 @@
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({
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: {

View file

@ -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 statuss 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.",

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(
"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 =>
fn;