mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 08:28:19 +01:00
refactor(api): 🏷️ Finish OpenAPI documentation refactor
This commit is contained in:
parent
1856176de5
commit
6a810529bc
|
|
@ -46,6 +46,7 @@ export default apiRoute((app) =>
|
|||
|
||||
const userCount = await User.getCount();
|
||||
|
||||
// Get first admin, or first user if no admin exists
|
||||
const contactAccount =
|
||||
(await User.fromSql(
|
||||
and(isNull(Users.instanceId), eq(Users.isAdmin, true)),
|
||||
|
|
@ -138,7 +139,7 @@ export default apiRoute((app) =>
|
|||
id: p.id,
|
||||
})) ?? [],
|
||||
},
|
||||
contact_account: (contactAccount as User).toApi(),
|
||||
contact_account: (contactAccount as User)?.toApi(),
|
||||
} satisfies z.infer<typeof InstanceV1Schema>);
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -97,10 +97,17 @@ export default apiRoute((app) =>
|
|||
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()) {
|
||||
await note.author.notify("reblog", user, newReblog);
|
||||
}
|
||||
|
||||
return context.json(await newReblog.toApi(user), 200);
|
||||
return context.json(await finalNewReblog.toApi(user), 200);
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,23 +1,19 @@
|
|||
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 { Notes, RolePermissions } from "@versia/kit/tables";
|
||||
import { and, eq, gt, gte, inArray, lt, or, sql } from "drizzle-orm";
|
||||
import { Status } 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),
|
||||
}),
|
||||
};
|
||||
import { Status as StatusSchema } from "~/classes/schemas/status";
|
||||
|
||||
const route = createRoute({
|
||||
method: "get",
|
||||
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: [
|
||||
auth({
|
||||
auth: true,
|
||||
|
|
@ -30,17 +26,49 @@ const route = createRoute({
|
|||
}),
|
||||
] as const,
|
||||
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: {
|
||||
200: {
|
||||
description: "Home timeline",
|
||||
description: "Statuses in your home timeline will be returned",
|
||||
content: {
|
||||
"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],
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,35 +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 { Notes, RolePermissions } from "@versia/kit/tables";
|
||||
import { and, eq, gt, gte, inArray, lt, or, sql } from "drizzle-orm";
|
||||
import { Status } 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),
|
||||
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(),
|
||||
}),
|
||||
};
|
||||
import { Status as StatusSchema } from "~/classes/schemas/status";
|
||||
import { zBoolean } from "~/packages/config-manager/config.type";
|
||||
|
||||
const route = createRoute({
|
||||
method: "get",
|
||||
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: [
|
||||
auth({
|
||||
auth: false,
|
||||
|
|
@ -41,17 +26,69 @@ const route = createRoute({
|
|||
}),
|
||||
] as const,
|
||||
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: {
|
||||
200: {
|
||||
description: "Public timeline",
|
||||
content: {
|
||||
"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],
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 { db } from "@versia/kit/db";
|
||||
import { FilterKeywords, Filters, RolePermissions } from "@versia/kit/tables";
|
||||
import { type SQL, and, eq, inArray } from "drizzle-orm";
|
||||
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";
|
||||
|
||||
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({
|
||||
method: "get",
|
||||
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: [
|
||||
auth({
|
||||
auth: true,
|
||||
|
|
@ -81,18 +26,19 @@ const routeGet = createRoute({
|
|||
}),
|
||||
] as const,
|
||||
request: {
|
||||
params: schemas.param,
|
||||
params: z.object({
|
||||
id: FilterSchema.shape.id,
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Filter",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: filterSchema,
|
||||
schema: FilterSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
404: {
|
||||
description: "Filter not found",
|
||||
content: {
|
||||
|
|
@ -101,13 +47,19 @@ const routeGet = createRoute({
|
|||
},
|
||||
},
|
||||
},
|
||||
401: reusedResponses[401],
|
||||
},
|
||||
});
|
||||
|
||||
const routePut = createRoute({
|
||||
method: "put",
|
||||
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: [
|
||||
auth({
|
||||
auth: true,
|
||||
|
|
@ -116,11 +68,45 @@ const routePut = createRoute({
|
|||
jsonOrForm(),
|
||||
] as const,
|
||||
request: {
|
||||
params: schemas.param,
|
||||
params: z.object({
|
||||
id: FilterSchema.shape.id,
|
||||
}),
|
||||
body: {
|
||||
content: {
|
||||
"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",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: filterSchema,
|
||||
schema: FilterSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
404: {
|
||||
description: "Filter not found",
|
||||
content: {
|
||||
|
|
@ -143,13 +128,19 @@ const routePut = createRoute({
|
|||
},
|
||||
},
|
||||
},
|
||||
...reusedResponses,
|
||||
},
|
||||
});
|
||||
|
||||
const routeDelete = createRoute({
|
||||
method: "delete",
|
||||
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: [
|
||||
auth({
|
||||
auth: true,
|
||||
|
|
@ -157,13 +148,14 @@ const routeDelete = createRoute({
|
|||
}),
|
||||
] as const,
|
||||
request: {
|
||||
params: schemas.param,
|
||||
params: z.object({
|
||||
id: FilterSchema.shape.id,
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
204: {
|
||||
description: "Filter deleted",
|
||||
200: {
|
||||
description: "Filter successfully deleted",
|
||||
},
|
||||
|
||||
404: {
|
||||
description: "Filter not found",
|
||||
content: {
|
||||
|
|
@ -172,6 +164,7 @@ const routeDelete = createRoute({
|
|||
},
|
||||
},
|
||||
},
|
||||
401: reusedResponses[401],
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 { db } from "@versia/kit/db";
|
||||
import { FilterKeywords, Filters, RolePermissions } from "@versia/kit/tables";
|
||||
import type { SQL } from "drizzle-orm";
|
||||
|
||||
const schemas = {
|
||||
json: z.object({
|
||||
title: z.string().trim().min(1).max(100),
|
||||
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()),
|
||||
});
|
||||
import {
|
||||
FilterKeyword as FilterKeywordSchema,
|
||||
Filter as FilterSchema,
|
||||
} from "~/classes/schemas/filters";
|
||||
|
||||
const routeGet = createRoute({
|
||||
method: "get",
|
||||
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: [
|
||||
auth({
|
||||
auth: true,
|
||||
|
|
@ -73,17 +29,23 @@ const routeGet = createRoute({
|
|||
description: "Filters",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.array(filterSchema),
|
||||
schema: z.array(FilterSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
401: reusedResponses[401],
|
||||
},
|
||||
});
|
||||
|
||||
const routePost = createRoute({
|
||||
method: "post",
|
||||
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: [
|
||||
auth({
|
||||
auth: true,
|
||||
|
|
@ -95,20 +57,43 @@ const routePost = createRoute({
|
|||
body: {
|
||||
content: {
|
||||
"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: {
|
||||
200: {
|
||||
description: "Filter created",
|
||||
description: "Created filter",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: filterSchema,
|
||||
schema: FilterSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
...reusedResponses,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -158,8 +143,8 @@ export default apiRoute((app) => {
|
|||
await db
|
||||
.insert(Filters)
|
||||
.values({
|
||||
title: title ?? "",
|
||||
context: ctx ?? [],
|
||||
title: title,
|
||||
context: ctx,
|
||||
filterAction: filter_action,
|
||||
expireAt: new Date(
|
||||
Date.now() + (expires_in ?? 0),
|
||||
|
|
@ -202,18 +187,6 @@ export default apiRoute((app) => {
|
|||
whole_word: keyword.wholeWord,
|
||||
})),
|
||||
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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,109 +1,28 @@
|
|||
import { apiRoute } from "@/api";
|
||||
import { proxyUrl } from "@/response";
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import { createRoute } from "@hono/zod-openapi";
|
||||
import { User } from "@versia/kit/db";
|
||||
import { Users } from "@versia/kit/tables";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { Account } from "~/classes/schemas/account";
|
||||
import manifest from "~/package.json";
|
||||
import { Instance as InstanceSchema } from "~/classes/schemas/instance";
|
||||
import pkg from "~/package.json";
|
||||
import { config } from "~/packages/config-manager";
|
||||
|
||||
const route = createRoute({
|
||||
method: "get",
|
||||
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: {
|
||||
200: {
|
||||
description: "Instance metadata",
|
||||
description: "Server information",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
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(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
}),
|
||||
schema: InstanceSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -112,12 +31,11 @@ const route = createRoute({
|
|||
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(route, async (context) => {
|
||||
// Get software version from package.json
|
||||
const version = manifest.version;
|
||||
|
||||
const contactAccount = await User.fromSql(
|
||||
// Get first admin, or first user if no admin exists
|
||||
const contactAccount =
|
||||
(await User.fromSql(
|
||||
and(isNull(Users.instanceId), eq(Users.isAdmin, true)),
|
||||
);
|
||||
)) ?? (await User.fromSql(isNull(Users.instanceId)));
|
||||
|
||||
const monthlyActiveUsers = await User.getActiveInPeriod(
|
||||
30 * 24 * 60 * 60 * 1000,
|
||||
|
|
@ -139,44 +57,55 @@ export default apiRoute((app) =>
|
|||
domain: config.http.base_url.hostname,
|
||||
title: config.instance.name,
|
||||
version: "4.3.0-alpha.3+glitch",
|
||||
versia_version: version,
|
||||
source_url: "https://github.com/versia-pub/server",
|
||||
versia_version: pkg.version,
|
||||
source_url: pkg.repository.url,
|
||||
description: config.instance.description,
|
||||
usage: {
|
||||
users: {
|
||||
active_month: monthlyActiveUsers,
|
||||
},
|
||||
},
|
||||
api_versions: {
|
||||
mastodon: 1,
|
||||
},
|
||||
thumbnail: {
|
||||
url: config.instance.logo
|
||||
? proxyUrl(config.instance.logo)
|
||||
: null,
|
||||
? proxyUrl(config.instance.logo).toString()
|
||||
: pkg.icon,
|
||||
},
|
||||
banner: {
|
||||
url: config.instance.banner
|
||||
? proxyUrl(config.instance.banner)
|
||||
? proxyUrl(config.instance.banner).toString()
|
||||
: null,
|
||||
},
|
||||
icon: [],
|
||||
languages: ["en"],
|
||||
configuration: {
|
||||
urls: {
|
||||
streaming: null,
|
||||
status: null,
|
||||
// TODO: Implement Streaming API
|
||||
streaming: "",
|
||||
},
|
||||
vapid: {
|
||||
// TODO: Fill in vapid values
|
||||
public_key: "",
|
||||
},
|
||||
accounts: {
|
||||
max_featured_tags: 100,
|
||||
max_displayname_characters:
|
||||
config.validation.max_displayname_size,
|
||||
avatar_size_limit: config.validation.max_avatar_size,
|
||||
header_size_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,
|
||||
avatar_limit: config.validation.max_avatar_size,
|
||||
header_limit: config.validation.max_header_size,
|
||||
max_username_characters:
|
||||
config.validation.max_username_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: {
|
||||
max_characters: config.validation.max_note_size,
|
||||
|
|
@ -191,14 +120,14 @@ export default apiRoute((app) =>
|
|||
video_size_limit: config.validation.max_media_size,
|
||||
video_frame_rate_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,
|
||||
},
|
||||
emojis: {
|
||||
emoji_size_limit: config.validation.max_emoji_size,
|
||||
max_emoji_shortcode_characters:
|
||||
max_shortcode_characters:
|
||||
config.validation.max_emoji_shortcode_size,
|
||||
max_emoji_description_characters:
|
||||
max_description_characters:
|
||||
config.validation.max_emoji_description_size,
|
||||
},
|
||||
polls: {
|
||||
|
|
@ -216,11 +145,11 @@ export default apiRoute((app) =>
|
|||
enabled: config.signups.registration,
|
||||
approval_required: false,
|
||||
message: null,
|
||||
url: null,
|
||||
},
|
||||
contact: {
|
||||
email: contactAccount?.data.email || null,
|
||||
account: contactAccount?.toApi() || null,
|
||||
// TODO: Add contact email
|
||||
email: "",
|
||||
account: (contactAccount as User)?.toApi(),
|
||||
},
|
||||
rules: config.signups.rules.map((rule, index) => ({
|
||||
id: String(index),
|
||||
|
|
|
|||
|
|
@ -1,27 +1,20 @@
|
|||
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/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: [
|
||||
auth({
|
||||
auth: true,
|
||||
|
|
@ -33,20 +26,52 @@ 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: "Uploaded media",
|
||||
description:
|
||||
"MediaAttachment was created successfully, and the full-size file was processed synchronously.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: AttachmentSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
202: {
|
||||
description:
|
||||
"MediaAttachment was created successfully, but the full-size file is still processing. Note that the MediaAttachment’s 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: {
|
||||
description: "Payload too large",
|
||||
content: {
|
||||
|
|
@ -63,6 +88,7 @@ const route = createRoute({
|
|||
},
|
||||
},
|
||||
},
|
||||
...reusedResponses,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -72,7 +98,7 @@ export default apiRoute((app) =>
|
|||
|
||||
const attachment = await Media.fromFile(file, {
|
||||
thumbnail,
|
||||
description,
|
||||
description: description ?? undefined,
|
||||
});
|
||||
|
||||
return context.json(attachment.toApi(), 200);
|
||||
|
|
|
|||
|
|
@ -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 { Note, User, db } from "@versia/kit/db";
|
||||
import { Instances, Notes, RolePermissions, Users } from "@versia/kit/tables";
|
||||
import { and, eq, inArray, isNull, sql } from "drizzle-orm";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
import { Account } from "~/classes/schemas/account";
|
||||
import { Status } from "~/classes/schemas/status";
|
||||
import { Account as AccountSchema } from "~/classes/schemas/account";
|
||||
import { Id } from "~/classes/schemas/common";
|
||||
import { Search as SearchSchema } from "~/classes/schemas/search";
|
||||
import { searchManager } from "~/classes/search/search-manager";
|
||||
import { config } from "~/packages/config-manager";
|
||||
import { zBoolean } from "~/packages/config-manager/config.type";
|
||||
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({
|
||||
method: "get",
|
||||
path: "/api/v2/search",
|
||||
summary: "Instance database search",
|
||||
summary: "Perform a search",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/search/#v2",
|
||||
},
|
||||
tags: ["Search"],
|
||||
middleware: [
|
||||
auth({
|
||||
auth: false,
|
||||
|
|
@ -40,18 +38,64 @@ const route = createRoute({
|
|||
}),
|
||||
] as const,
|
||||
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: {
|
||||
200: {
|
||||
description: "Search results",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
accounts: z.array(Account),
|
||||
statuses: z.array(Status),
|
||||
hashtags: z.array(z.string()),
|
||||
}),
|
||||
schema: SearchSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -72,6 +116,7 @@ const route = createRoute({
|
|||
},
|
||||
},
|
||||
},
|
||||
422: reusedResponses[422],
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -164,16 +209,16 @@ export default apiRoute((app) =>
|
|||
|
||||
accountResults = await searchManager.searchAccounts(
|
||||
q,
|
||||
Number(limit) || 10,
|
||||
Number(offset) || 0,
|
||||
limit,
|
||||
offset,
|
||||
);
|
||||
}
|
||||
|
||||
if (!type || type === "statuses") {
|
||||
statusResults = await searchManager.searchStatuses(
|
||||
q,
|
||||
Number(limit) || 10,
|
||||
Number(offset) || 0,
|
||||
limit,
|
||||
offset,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { z } from "@hono/zod-openapi";
|
||||
import { zBoolean } from "~/packages/config-manager/config.type.ts";
|
||||
import { Id } from "./common.ts";
|
||||
|
||||
export const FilterStatus = z
|
||||
|
|
@ -42,7 +43,7 @@ export const FilterKeyword = z
|
|||
url: "https://docs.joinmastodon.org/entities/FilterKeyword/#keyword",
|
||||
},
|
||||
}),
|
||||
whole_word: z.boolean().openapi({
|
||||
whole_word: zBoolean.openapi({
|
||||
description:
|
||||
"Should the filter consider word boundaries? See implementation guidelines for filters.",
|
||||
example: false,
|
||||
|
|
@ -68,7 +69,12 @@ export const Filter = z
|
|||
url: "https://docs.joinmastodon.org/entities/Filter/#id",
|
||||
},
|
||||
}),
|
||||
title: z.string().openapi({
|
||||
title: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(255)
|
||||
.openapi({
|
||||
description: "A title given by the user to name the filter.",
|
||||
example: "Test filter",
|
||||
externalDocs: {
|
||||
|
|
@ -85,6 +91,7 @@ export const Filter = z
|
|||
"account",
|
||||
]),
|
||||
)
|
||||
.default([])
|
||||
.openapi({
|
||||
description:
|
||||
"The contexts in which the filter should be applied.",
|
||||
|
|
@ -103,7 +110,10 @@ export const Filter = z
|
|||
url: "https://docs.joinmastodon.org/entities/Filter/#expires_at",
|
||||
},
|
||||
}),
|
||||
filter_action: z.enum(["warn", "hide"]).openapi({
|
||||
filter_action: z
|
||||
.enum(["warn", "hide"])
|
||||
.default("warn")
|
||||
.openapi({
|
||||
description:
|
||||
"The action to be taken when a status matches this filter.",
|
||||
example: "warn",
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
"bugs": {
|
||||
"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",
|
||||
"keywords": ["federated", "activitypub", "bun"],
|
||||
"workspaces": ["packages/plugin-kit"],
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ describe("API Tests", () => {
|
|||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toContain(
|
||||
"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(
|
||||
"application/json",
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue