refactor(api): 🏷️ Finish OpenAPI documentation refactor

This commit is contained in:
Jesse Wierzbinski 2025-02-14 17:49:34 +01:00
parent 1856176de5
commit 6a810529bc
No known key found for this signature in database
12 changed files with 428 additions and 379 deletions

View file

@ -46,6 +46,7 @@ export default apiRoute((app) =>
const userCount = await User.getCount(); const userCount = await User.getCount();
// Get first admin, or first user if no admin exists
const contactAccount = const contactAccount =
(await User.fromSql( (await User.fromSql(
and(isNull(Users.instanceId), eq(Users.isAdmin, true)), and(isNull(Users.instanceId), eq(Users.isAdmin, true)),
@ -138,7 +139,7 @@ export default apiRoute((app) =>
id: p.id, id: p.id,
})) ?? [], })) ?? [],
}, },
contact_account: (contactAccount as User).toApi(), contact_account: (contactAccount as User)?.toApi(),
} satisfies z.infer<typeof InstanceV1Schema>); } satisfies z.infer<typeof InstanceV1Schema>);
}), }),
); );

View file

@ -97,10 +97,17 @@ export default apiRoute((app) =>
applicationId: null, applicationId: null,
}); });
// Refetch the note *again* to get the proper value of .reblogged
const finalNewReblog = await Note.fromId(newReblog.id, user?.id);
if (!finalNewReblog) {
throw new Error("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 newReblog.toApi(user), 200); return context.json(await finalNewReblog.toApi(user), 200);
}), }),
); );

View file

@ -1,23 +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 { Timeline } from "@versia/kit/db"; import { Timeline } from "@versia/kit/db";
import { Notes, RolePermissions } from "@versia/kit/tables"; import { Notes, RolePermissions } from "@versia/kit/tables";
import { and, eq, gt, gte, inArray, lt, or, sql } from "drizzle-orm"; import { and, eq, gt, gte, inArray, lt, or, sql } from "drizzle-orm";
import { Status } from "~/classes/schemas/status"; 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(20),
}),
};
const route = createRoute({ const route = createRoute({
method: "get", method: "get",
path: "/api/v1/timelines/home", path: "/api/v1/timelines/home",
summary: "Get home timeline", summary: "View home timeline",
description: "View statuses from followed users and hashtags.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/timelines/#home",
},
tags: ["Timelines"],
middleware: [ middleware: [
auth({ auth({
auth: true, auth: true,
@ -30,17 +26,49 @@ const route = createRoute({
}), }),
] as const, ] as const,
request: { request: {
query: schemas.query, query: z.object({
max_id: StatusSchema.shape.id.optional().openapi({
description:
"All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
}),
since_id: StatusSchema.shape.id.optional().openapi({
description:
"All results returned will be greater than this ID. In effect, sets a lower bound on results.",
example: undefined,
}),
min_id: StatusSchema.shape.id.optional().openapi({
description:
"Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
example: undefined,
}),
limit: z.coerce.number().int().min(1).max(40).default(20).openapi({
description: "Maximum number of results to return.",
}),
}),
}, },
responses: { responses: {
200: { 200: {
description: "Home timeline", description: "Statuses in your home timeline will be returned",
content: { content: {
"application/json": { "application/json": {
schema: z.array(Status), schema: z.array(StatusSchema),
}, },
}, },
headers: z.object({
link: z
.string()
.optional()
.openapi({
description: "Links to the next and previous pages",
example: `<https://versia.social/api/v1/timelines/home?limit=2&max_id=359ae97f-78dd-43e7-8e13-1d8e1d7829b5>; rel="next", <https://versia.social/api/v1/timelines/home?limit=2&since_id=75e9f5a9-f455-48eb-8f60-435b4a088bc0>; rel="prev"`,
externalDocs: {
url: "https://docs.joinmastodon.org/api/guidelines/#pagination",
},
}),
}),
}, },
422: reusedResponses[422],
}, },
}); });

View file

@ -1,35 +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 { Notes, RolePermissions } from "@versia/kit/tables"; import { Notes, RolePermissions } from "@versia/kit/tables";
import { and, eq, gt, gte, inArray, lt, or, sql } from "drizzle-orm"; import { and, eq, gt, gte, inArray, lt, or, sql } from "drizzle-orm";
import { Status } from "~/classes/schemas/status"; import { Status as StatusSchema } from "~/classes/schemas/status";
import { zBoolean } from "~/packages/config-manager/config.type";
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(20),
local: z
.string()
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
.optional(),
remote: z
.string()
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
.optional(),
only_media: z
.string()
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
.optional(),
}),
};
const route = createRoute({ const route = createRoute({
method: "get", method: "get",
path: "/api/v1/timelines/public", path: "/api/v1/timelines/public",
summary: "Get public timeline", summary: "View public timeline",
description: "View public statuses.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/timelines/#public",
},
tags: ["Timelines"],
middleware: [ middleware: [
auth({ auth({
auth: false, auth: false,
@ -41,17 +26,69 @@ const route = createRoute({
}), }),
] as const, ] as const,
request: { request: {
query: schemas.query, query: z
.object({
max_id: StatusSchema.shape.id.optional().openapi({
description:
"All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
}),
since_id: StatusSchema.shape.id.optional().openapi({
description:
"All results returned will be greater than this ID. In effect, sets a lower bound on results.",
example: undefined,
}),
min_id: StatusSchema.shape.id.optional().openapi({
description:
"Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
example: undefined,
}),
local: zBoolean.default(false).openapi({
description: "Show only local statuses?",
}),
remote: zBoolean.default(false).openapi({
description: "Show only remote statuses?",
}),
only_media: zBoolean.default(false).openapi({
description: "Show only statuses with media attached?",
}),
limit: z.coerce
.number()
.int()
.min(1)
.max(40)
.default(20)
.openapi({
description: "Maximum number of results to return.",
}),
})
.refine(
(o) => !(o.local && o.remote),
"'local' and 'remote' cannot be both true",
),
}, },
responses: { responses: {
200: { 200: {
description: "Public timeline", description: "Public timeline",
content: { content: {
"application/json": { "application/json": {
schema: z.array(Status), schema: z.array(StatusSchema),
}, },
}, },
headers: z.object({
link: z
.string()
.optional()
.openapi({
description: "Links to the next and previous pages",
example: `<https://versia.social/api/v1/timelines/public?limit=2&max_id=359ae97f-78dd-43e7-8e13-1d8e1d7829b5>; rel="next", <https://versia.social/api/v1/timelines/public?limit=2&since_id=75e9f5a9-f455-48eb-8f60-435b4a088bc0>; rel="prev"`,
externalDocs: {
url: "https://docs.joinmastodon.org/api/guidelines/#pagination",
},
}),
}),
}, },
422: reusedResponses[422],
}, },
}); });

View file

@ -1,79 +1,24 @@
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 { db } from "@versia/kit/db"; import { db } from "@versia/kit/db";
import { FilterKeywords, Filters, RolePermissions } from "@versia/kit/tables"; import { FilterKeywords, Filters, RolePermissions } from "@versia/kit/tables";
import { type SQL, and, eq, inArray } from "drizzle-orm"; import { type SQL, and, eq, inArray } from "drizzle-orm";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
import {
FilterKeyword as FilterKeywordSchema,
Filter as FilterSchema,
} from "~/classes/schemas/filters";
import { zBoolean } from "~/packages/config-manager/config.type";
import { ErrorSchema } from "~/types/api"; import { ErrorSchema } from "~/types/api";
const schemas = {
param: z.object({
id: z.string().uuid(),
}),
json: z.object({
title: z.string().trim().min(1).max(100).optional(),
context: z
.array(
z.enum([
"home",
"notifications",
"public",
"thread",
"account",
]),
)
.optional(),
filter_action: z.enum(["warn", "hide"]).optional().default("warn"),
expires_in: z.coerce
.number()
.int()
.min(60)
.max(60 * 60 * 24 * 365 * 5)
.optional(),
keywords_attributes: z
.array(
z.object({
keyword: z.string().trim().min(1).max(100).optional(),
id: z.string().uuid().optional(),
whole_word: z
.string()
.transform((v) =>
["true", "1", "on"].includes(v.toLowerCase()),
)
.optional(),
// biome-ignore lint/style/useNamingConvention: _destroy is a Mastodon API imposed variable name
_destroy: z
.string()
.transform((v) =>
["true", "1", "on"].includes(v.toLowerCase()),
)
.optional(),
}),
)
.optional(),
}),
};
const filterSchema = z.object({
id: z.string(),
title: z.string(),
context: z.array(z.string()),
expires_at: z.string().nullable(),
filter_action: z.enum(["warn", "hide"]),
keywords: z.array(
z.object({
id: z.string(),
keyword: z.string(),
whole_word: z.boolean(),
}),
),
statuses: z.array(z.string()),
});
const routeGet = createRoute({ const routeGet = createRoute({
method: "get", method: "get",
path: "/api/v2/filters/{id}", path: "/api/v2/filters/{id}",
summary: "Get filter", summary: "View a specific filter",
externalDocs: {
url: "Obtain a single filter group owned by the current user.",
},
tags: ["Filters"],
middleware: [ middleware: [
auth({ auth({
auth: true, auth: true,
@ -81,18 +26,19 @@ const routeGet = createRoute({
}), }),
] as const, ] as const,
request: { request: {
params: schemas.param, params: z.object({
id: FilterSchema.shape.id,
}),
}, },
responses: { responses: {
200: { 200: {
description: "Filter", description: "Filter",
content: { content: {
"application/json": { "application/json": {
schema: filterSchema, schema: FilterSchema,
}, },
}, },
}, },
404: { 404: {
description: "Filter not found", description: "Filter not found",
content: { content: {
@ -101,13 +47,19 @@ const routeGet = createRoute({
}, },
}, },
}, },
401: reusedResponses[401],
}, },
}); });
const routePut = createRoute({ const routePut = createRoute({
method: "put", method: "put",
path: "/api/v2/filters/{id}", path: "/api/v2/filters/{id}",
summary: "Update filter", summary: "Update a filter",
description: "Update a filter group with the given parameters.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/filters/#update",
},
tags: ["Filters"],
middleware: [ middleware: [
auth({ auth({
auth: true, auth: true,
@ -116,11 +68,45 @@ const routePut = createRoute({
jsonOrForm(), jsonOrForm(),
] as const, ] as const,
request: { request: {
params: schemas.param, params: z.object({
id: FilterSchema.shape.id,
}),
body: { body: {
content: { content: {
"application/json": { "application/json": {
schema: schemas.json, schema: z
.object({
context: FilterSchema.shape.context,
title: FilterSchema.shape.title,
filter_action: FilterSchema.shape.filter_action,
expires_in: z.coerce
.number()
.int()
.min(60)
.max(60 * 60 * 24 * 365 * 5)
.openapi({
description:
"How many seconds from now should the filter expire?",
}),
keywords_attributes: z.array(
FilterKeywordSchema.pick({
keyword: true,
whole_word: true,
id: true,
})
.extend({
// biome-ignore lint/style/useNamingConvention: _destroy is a Mastodon API imposed variable name
_destroy: zBoolean
.default(false)
.openapi({
description:
"If true, will remove the keyword with the given ID.",
}),
})
.partial(),
),
})
.partial(),
}, },
}, },
}, },
@ -130,11 +116,10 @@ const routePut = createRoute({
description: "Filter updated", description: "Filter updated",
content: { content: {
"application/json": { "application/json": {
schema: filterSchema, schema: FilterSchema,
}, },
}, },
}, },
404: { 404: {
description: "Filter not found", description: "Filter not found",
content: { content: {
@ -143,13 +128,19 @@ const routePut = createRoute({
}, },
}, },
}, },
...reusedResponses,
}, },
}); });
const routeDelete = createRoute({ const routeDelete = createRoute({
method: "delete", method: "delete",
path: "/api/v2/filters/{id}", path: "/api/v2/filters/{id}",
summary: "Delete filter", summary: "Delete a filter",
description: "Delete a filter group with the given id.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/filters/#delete",
},
tags: ["Filters"],
middleware: [ middleware: [
auth({ auth({
auth: true, auth: true,
@ -157,13 +148,14 @@ const routeDelete = createRoute({
}), }),
] as const, ] as const,
request: { request: {
params: schemas.param, params: z.object({
id: FilterSchema.shape.id,
}),
}, },
responses: { responses: {
204: { 200: {
description: "Filter deleted", description: "Filter successfully deleted",
}, },
404: { 404: {
description: "Filter not found", description: "Filter not found",
content: { content: {
@ -172,6 +164,7 @@ const routeDelete = createRoute({
}, },
}, },
}, },
401: reusedResponses[401],
}, },
}); });

View file

@ -1,66 +1,22 @@
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 { db } from "@versia/kit/db"; import { db } from "@versia/kit/db";
import { FilterKeywords, Filters, RolePermissions } from "@versia/kit/tables"; import { FilterKeywords, Filters, RolePermissions } from "@versia/kit/tables";
import type { SQL } from "drizzle-orm"; import type { SQL } from "drizzle-orm";
import {
const schemas = { FilterKeyword as FilterKeywordSchema,
json: z.object({ Filter as FilterSchema,
title: z.string().trim().min(1).max(100), } from "~/classes/schemas/filters";
context: z
.array(
z.enum([
"home",
"notifications",
"public",
"thread",
"account",
]),
)
.min(1),
filter_action: z.enum(["warn", "hide"]).optional().default("warn"),
expires_in: z.coerce
.number()
.int()
.min(60)
.max(60 * 60 * 24 * 365 * 5)
.optional(),
keywords_attributes: z
.array(
z.object({
keyword: z.string().trim().min(1).max(100),
whole_word: z
.string()
.transform((v) =>
["true", "1", "on"].includes(v.toLowerCase()),
)
.optional(),
}),
)
.optional(),
}),
};
const filterSchema = z.object({
id: z.string(),
title: z.string(),
context: z.array(z.string()),
expires_at: z.string().nullable(),
filter_action: z.enum(["warn", "hide"]),
keywords: z.array(
z.object({
id: z.string(),
keyword: z.string(),
whole_word: z.boolean(),
}),
),
statuses: z.array(z.string()),
});
const routeGet = createRoute({ const routeGet = createRoute({
method: "get", method: "get",
path: "/api/v2/filters", path: "/api/v2/filters",
summary: "Get filters", summary: "View all filters",
description: "Obtain a list of all filter groups for the current user.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/filters/#get",
},
tags: ["Filters"],
middleware: [ middleware: [
auth({ auth({
auth: true, auth: true,
@ -73,17 +29,23 @@ const routeGet = createRoute({
description: "Filters", description: "Filters",
content: { content: {
"application/json": { "application/json": {
schema: z.array(filterSchema), schema: z.array(FilterSchema),
}, },
}, },
}, },
401: reusedResponses[401],
}, },
}); });
const routePost = createRoute({ const routePost = createRoute({
method: "post", method: "post",
path: "/api/v2/filters", path: "/api/v2/filters",
summary: "Create filter", summary: "Create a filter",
description: "Create a filter group with the given parameters.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/filters/#create",
},
tags: ["Filters"],
middleware: [ middleware: [
auth({ auth({
auth: true, auth: true,
@ -95,20 +57,43 @@ const routePost = createRoute({
body: { body: {
content: { content: {
"application/json": { "application/json": {
schema: schemas.json, schema: z.object({
context: FilterSchema.shape.context,
title: FilterSchema.shape.title,
filter_action: FilterSchema.shape.filter_action,
expires_in: z.coerce
.number()
.int()
.min(60)
.max(60 * 60 * 24 * 365 * 5)
.optional()
.openapi({
description:
"How many seconds from now should the filter expire?",
}),
keywords_attributes: z
.array(
FilterKeywordSchema.pick({
keyword: true,
whole_word: true,
}),
)
.optional(),
}),
}, },
}, },
}, },
}, },
responses: { responses: {
200: { 200: {
description: "Filter created", description: "Created filter",
content: { content: {
"application/json": { "application/json": {
schema: filterSchema, schema: FilterSchema,
}, },
}, },
}, },
...reusedResponses,
}, },
}); });
@ -158,8 +143,8 @@ export default apiRoute((app) => {
await db await db
.insert(Filters) .insert(Filters)
.values({ .values({
title: title ?? "", title: title,
context: ctx ?? [], context: ctx,
filterAction: filter_action, filterAction: filter_action,
expireAt: new Date( expireAt: new Date(
Date.now() + (expires_in ?? 0), Date.now() + (expires_in ?? 0),
@ -202,18 +187,6 @@ export default apiRoute((app) => {
whole_word: keyword.wholeWord, whole_word: keyword.wholeWord,
})), })),
statuses: [], statuses: [],
} as {
id: string;
title: string;
context: string[];
expires_at: string;
filter_action: "warn" | "hide";
keywords: {
id: string;
keyword: string;
whole_word: boolean;
}[];
statuses: [];
}, },
200, 200,
); );

View file

@ -1,109 +1,28 @@
import { apiRoute } from "@/api"; import { apiRoute } from "@/api";
import { proxyUrl } from "@/response"; import { proxyUrl } from "@/response";
import { createRoute, z } from "@hono/zod-openapi"; import { createRoute } from "@hono/zod-openapi";
import { User } from "@versia/kit/db"; import { 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 { Account } from "~/classes/schemas/account"; import { Instance as InstanceSchema } from "~/classes/schemas/instance";
import manifest from "~/package.json"; import pkg 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/v2/instance", path: "/api/v2/instance",
summary: "Get instance metadata", summary: "View server information",
description: "Obtain general information about the server.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/instance/#v2",
},
tags: ["Instance"],
responses: { responses: {
200: { 200: {
description: "Instance metadata", description: "Server information",
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: InstanceSchema,
domain: z.string(),
title: z.string(),
version: z.string(),
versia_version: z.string(),
source_url: z.string(),
description: z.string(),
usage: z.object({
users: z.object({
active_month: z.number(),
}),
}),
thumbnail: z.object({
url: z.string().nullable(),
}),
banner: z.object({
url: z.string().nullable(),
}),
languages: z.array(z.string()),
configuration: z.object({
urls: z.object({
streaming: z.string().nullable(),
status: z.string().nullable(),
}),
accounts: z.object({
max_featured_tags: z.number(),
max_displayname_characters: z.number(),
avatar_size_limit: z.number(),
header_size_limit: z.number(),
max_fields_name_characters: z.number(),
max_fields_value_characters: z.number(),
max_fields: z.number(),
max_username_characters: z.number(),
max_note_characters: z.number(),
}),
statuses: z.object({
max_characters: z.number(),
max_media_attachments: z.number(),
characters_reserved_per_url: z.number(),
}),
media_attachments: z.object({
supported_mime_types: z.array(z.string()),
image_size_limit: z.number(),
image_matrix_limit: z.number(),
video_size_limit: z.number(),
video_frame_rate_limit: z.number(),
video_matrix_limit: z.number(),
max_description_characters: z.number(),
}),
polls: z.object({
max_characters_per_option: z.number(),
max_expiration: z.number(),
max_options: z.number(),
min_expiration: z.number(),
}),
translation: z.object({
enabled: z.boolean(),
}),
}),
registrations: z.object({
enabled: z.boolean(),
approval_required: z.boolean(),
message: z.string().nullable(),
url: z.string().nullable(),
}),
contact: z.object({
email: z.string().nullable(),
account: Account.nullable(),
}),
rules: z.array(
z.object({
id: z.string(),
text: z.string(),
hint: z.string(),
}),
),
sso: z.object({
forced: z.boolean(),
providers: z.array(
z.object({
name: z.string(),
icon: z.string(),
id: z.string(),
}),
),
}),
}),
}, },
}, },
}, },
@ -112,12 +31,11 @@ const route = createRoute({
export default apiRoute((app) => export default apiRoute((app) =>
app.openapi(route, async (context) => { app.openapi(route, async (context) => {
// Get software version from package.json // Get first admin, or first user if no admin exists
const version = manifest.version; const contactAccount =
(await User.fromSql(
const contactAccount = await User.fromSql( and(isNull(Users.instanceId), eq(Users.isAdmin, true)),
and(isNull(Users.instanceId), eq(Users.isAdmin, true)), )) ?? (await User.fromSql(isNull(Users.instanceId)));
);
const monthlyActiveUsers = await User.getActiveInPeriod( const monthlyActiveUsers = await User.getActiveInPeriod(
30 * 24 * 60 * 60 * 1000, 30 * 24 * 60 * 60 * 1000,
@ -139,44 +57,55 @@ export default apiRoute((app) =>
domain: config.http.base_url.hostname, domain: config.http.base_url.hostname,
title: config.instance.name, title: config.instance.name,
version: "4.3.0-alpha.3+glitch", version: "4.3.0-alpha.3+glitch",
versia_version: version, versia_version: pkg.version,
source_url: "https://github.com/versia-pub/server", source_url: pkg.repository.url,
description: config.instance.description, description: config.instance.description,
usage: { usage: {
users: { users: {
active_month: monthlyActiveUsers, active_month: monthlyActiveUsers,
}, },
}, },
api_versions: {
mastodon: 1,
},
thumbnail: { thumbnail: {
url: config.instance.logo url: config.instance.logo
? proxyUrl(config.instance.logo) ? proxyUrl(config.instance.logo).toString()
: null, : pkg.icon,
}, },
banner: { banner: {
url: config.instance.banner url: config.instance.banner
? proxyUrl(config.instance.banner) ? proxyUrl(config.instance.banner).toString()
: null, : null,
}, },
icon: [],
languages: ["en"], languages: ["en"],
configuration: { configuration: {
urls: { urls: {
streaming: null, // TODO: Implement Streaming API
status: null, streaming: "",
},
vapid: {
// TODO: Fill in vapid values
public_key: "",
}, },
accounts: { accounts: {
max_featured_tags: 100, max_featured_tags: 100,
max_displayname_characters: max_displayname_characters:
config.validation.max_displayname_size, config.validation.max_displayname_size,
avatar_size_limit: config.validation.max_avatar_size, avatar_limit: config.validation.max_avatar_size,
header_size_limit: config.validation.max_header_size, header_limit: config.validation.max_header_size,
max_fields_name_characters:
config.validation.max_field_name_size,
max_fields_value_characters:
config.validation.max_field_value_size,
max_fields: config.validation.max_field_count,
max_username_characters: max_username_characters:
config.validation.max_username_size, config.validation.max_username_size,
max_note_characters: config.validation.max_bio_size, max_note_characters: config.validation.max_bio_size,
max_pinned_statuses: 100,
fields: {
max_fields: config.validation.max_field_count,
max_name_characters:
config.validation.max_field_name_size,
max_value_characters:
config.validation.max_field_value_size,
},
}, },
statuses: { statuses: {
max_characters: config.validation.max_note_size, max_characters: config.validation.max_note_size,
@ -191,14 +120,14 @@ export default apiRoute((app) =>
video_size_limit: config.validation.max_media_size, video_size_limit: config.validation.max_media_size,
video_frame_rate_limit: config.validation.max_media_size, video_frame_rate_limit: config.validation.max_media_size,
video_matrix_limit: config.validation.max_media_size, video_matrix_limit: config.validation.max_media_size,
max_description_characters: description_limit:
config.validation.max_media_description_size, config.validation.max_media_description_size,
}, },
emojis: { emojis: {
emoji_size_limit: config.validation.max_emoji_size, emoji_size_limit: config.validation.max_emoji_size,
max_emoji_shortcode_characters: max_shortcode_characters:
config.validation.max_emoji_shortcode_size, config.validation.max_emoji_shortcode_size,
max_emoji_description_characters: max_description_characters:
config.validation.max_emoji_description_size, config.validation.max_emoji_description_size,
}, },
polls: { polls: {
@ -216,11 +145,11 @@ export default apiRoute((app) =>
enabled: config.signups.registration, enabled: config.signups.registration,
approval_required: false, approval_required: false,
message: null, message: null,
url: null,
}, },
contact: { contact: {
email: contactAccount?.data.email || null, // TODO: Add contact email
account: contactAccount?.toApi() || null, email: "",
account: (contactAccount as User)?.toApi(),
}, },
rules: config.signups.rules.map((rule, index) => ({ rules: config.signups.rules.map((rule, index) => ({
id: String(index), id: String(index),

View file

@ -1,27 +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 { 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/v2/media", path: "/api/v2/media",
summary: "Upload media", summary: "Upload media as an attachment (async)",
description:
"Creates a media attachment to be used with a new status. The full sized media will be processed asynchronously in the background for large uploads.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/media/#v2",
},
tags: ["Media"],
middleware: [ middleware: [
auth({ auth({
auth: true, auth: true,
@ -33,20 +26,52 @@ 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: "Uploaded media", description:
"MediaAttachment was created successfully, and the full-size file was processed synchronously.",
content: { content: {
"application/json": { "application/json": {
schema: AttachmentSchema, schema: AttachmentSchema,
}, },
}, },
}, },
202: {
description:
"MediaAttachment was created successfully, but the full-size file is still processing. Note that the MediaAttachments url will still be null, as the media is still being processed in the background. However, the preview_url should be available. Use GET /api/v1/media/:id to check the status of the media attachment.",
content: {
"application/json": {
// FIXME: Can't .extend the type to have a null url because it crashes zod-to-openapi
schema: AttachmentSchema,
},
},
},
413: { 413: {
description: "Payload too large", description: "Payload too large",
content: { content: {
@ -63,6 +88,7 @@ const route = createRoute({
}, },
}, },
}, },
...reusedResponses,
}, },
}); });
@ -72,7 +98,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,33 +1,31 @@
import { apiRoute, auth, parseUserAddress, userAddressValidator } from "@/api"; import {
apiRoute,
auth,
parseUserAddress,
reusedResponses,
userAddressValidator,
} from "@/api";
import { createRoute, z } from "@hono/zod-openapi"; import { createRoute, z } from "@hono/zod-openapi";
import { Note, User, db } from "@versia/kit/db"; import { Note, User, db } from "@versia/kit/db";
import { Instances, Notes, RolePermissions, Users } from "@versia/kit/tables"; import { Instances, Notes, RolePermissions, Users } from "@versia/kit/tables";
import { and, eq, inArray, isNull, sql } from "drizzle-orm"; import { and, eq, inArray, isNull, sql } from "drizzle-orm";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
import { Account } from "~/classes/schemas/account"; import { Account as AccountSchema } from "~/classes/schemas/account";
import { Status } from "~/classes/schemas/status"; import { Id } from "~/classes/schemas/common";
import { Search as SearchSchema } from "~/classes/schemas/search";
import { searchManager } from "~/classes/search/search-manager"; import { searchManager } from "~/classes/search/search-manager";
import { config } from "~/packages/config-manager"; import { config } from "~/packages/config-manager";
import { zBoolean } from "~/packages/config-manager/config.type";
import { ErrorSchema } from "~/types/api"; import { ErrorSchema } from "~/types/api";
const schemas = {
query: z.object({
q: z.string().trim(),
type: z.string().optional(),
resolve: z.coerce.boolean().optional(),
following: z.coerce.boolean().optional(),
account_id: z.string().optional(),
max_id: z.string().optional(),
min_id: z.string().optional(),
limit: z.coerce.number().int().min(1).max(40).optional(),
offset: z.coerce.number().int().optional(),
}),
};
const route = createRoute({ const route = createRoute({
method: "get", method: "get",
path: "/api/v2/search", path: "/api/v2/search",
summary: "Instance database search", summary: "Perform a search",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/search/#v2",
},
tags: ["Search"],
middleware: [ middleware: [
auth({ auth({
auth: false, auth: false,
@ -40,18 +38,64 @@ const route = createRoute({
}), }),
] as const, ] as const,
request: { request: {
query: schemas.query, query: z.object({
q: z.string().trim().openapi({
description: "The search query.",
example: "versia",
}),
type: z
.enum(["accounts", "hashtags", "statuses"])
.optional()
.openapi({
description:
"Specify whether to search for only accounts, hashtags, statuses",
example: "accounts",
}),
resolve: zBoolean.default(false).openapi({
description:
"Only relevant if type includes accounts. If true and (a) the search query is for a remote account (e.g., someaccount@someother.server) and (b) the local server does not know about the account, WebFinger is used to try and resolve the account at someother.server. This provides the best recall at higher latency. If false only accounts the server knows about are returned.",
}),
following: zBoolean.default(false).openapi({
description:
"Only include accounts that the user is following?",
}),
account_id: AccountSchema.shape.id.optional().openapi({
description:
" If provided, will only return statuses authored by this account.",
}),
exclude_unreviewed: zBoolean.default(false).openapi({
description:
"Filter out unreviewed tags? Use true when trying to find trending tags.",
}),
max_id: 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: 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: 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(40).default(20).openapi({
description: "Maximum number of results to return.",
}),
offset: z.coerce.number().int().min(0).default(0).openapi({
description: "Skip the first n results.",
}),
}),
}, },
responses: { responses: {
200: { 200: {
description: "Search results", description: "Search results",
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: SearchSchema,
accounts: z.array(Account),
statuses: z.array(Status),
hashtags: z.array(z.string()),
}),
}, },
}, },
}, },
@ -72,6 +116,7 @@ const route = createRoute({
}, },
}, },
}, },
422: reusedResponses[422],
}, },
}); });
@ -164,16 +209,16 @@ export default apiRoute((app) =>
accountResults = await searchManager.searchAccounts( accountResults = await searchManager.searchAccounts(
q, q,
Number(limit) || 10, limit,
Number(offset) || 0, offset,
); );
} }
if (!type || type === "statuses") { if (!type || type === "statuses") {
statusResults = await searchManager.searchStatuses( statusResults = await searchManager.searchStatuses(
q, q,
Number(limit) || 10, limit,
Number(offset) || 0, offset,
); );
} }

View file

@ -1,4 +1,5 @@
import { z } from "@hono/zod-openapi"; import { z } from "@hono/zod-openapi";
import { zBoolean } from "~/packages/config-manager/config.type.ts";
import { Id } from "./common.ts"; import { Id } from "./common.ts";
export const FilterStatus = z export const FilterStatus = z
@ -42,7 +43,7 @@ export const FilterKeyword = z
url: "https://docs.joinmastodon.org/entities/FilterKeyword/#keyword", url: "https://docs.joinmastodon.org/entities/FilterKeyword/#keyword",
}, },
}), }),
whole_word: z.boolean().openapi({ whole_word: zBoolean.openapi({
description: description:
"Should the filter consider word boundaries? See implementation guidelines for filters.", "Should the filter consider word boundaries? See implementation guidelines for filters.",
example: false, example: false,
@ -68,13 +69,18 @@ export const Filter = z
url: "https://docs.joinmastodon.org/entities/Filter/#id", url: "https://docs.joinmastodon.org/entities/Filter/#id",
}, },
}), }),
title: z.string().openapi({ title: z
description: "A title given by the user to name the filter.", .string()
example: "Test filter", .trim()
externalDocs: { .min(1)
url: "https://docs.joinmastodon.org/entities/Filter/#title", .max(255)
}, .openapi({
}), description: "A title given by the user to name the filter.",
example: "Test filter",
externalDocs: {
url: "https://docs.joinmastodon.org/entities/Filter/#title",
},
}),
context: z context: z
.array( .array(
z.enum([ z.enum([
@ -85,6 +91,7 @@ export const Filter = z
"account", "account",
]), ]),
) )
.default([])
.openapi({ .openapi({
description: description:
"The contexts in which the filter should be applied.", "The contexts in which the filter should be applied.",
@ -103,14 +110,17 @@ export const Filter = z
url: "https://docs.joinmastodon.org/entities/Filter/#expires_at", url: "https://docs.joinmastodon.org/entities/Filter/#expires_at",
}, },
}), }),
filter_action: z.enum(["warn", "hide"]).openapi({ filter_action: z
description: .enum(["warn", "hide"])
"The action to be taken when a status matches this filter.", .default("warn")
example: "warn", .openapi({
externalDocs: { description:
url: "https://docs.joinmastodon.org/entities/Filter/#filter_action", "The action to be taken when a status matches this filter.",
}, example: "warn",
}), externalDocs: {
url: "https://docs.joinmastodon.org/entities/Filter/#filter_action",
},
}),
keywords: z.array(FilterKeyword).openapi({ keywords: z.array(FilterKeyword).openapi({
description: "The keywords grouped under this filter.", description: "The keywords grouped under this filter.",
externalDocs: { externalDocs: {

View file

@ -12,7 +12,7 @@
"bugs": { "bugs": {
"url": "https://github.com/versia-pub/server/issues" "url": "https://github.com/versia-pub/server/issues"
}, },
"icon": "https://github.com/versia-pub/server", "icon": "https://cdn.versia.pub/branding/icon.svg",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"keywords": ["federated", "activitypub", "bun"], "keywords": ["federated", "activitypub", "bun"],
"workspaces": ["packages/plugin-kit"], "workspaces": ["packages/plugin-kit"],

View file

@ -62,7 +62,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",
); );
@ -104,7 +104,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",
); );