refactor(api): ♻️ Convery more routes to use OpenAPI

This commit is contained in:
Jesse Wierzbinski 2024-08-27 17:40:58 +02:00
parent 1ab1c68d36
commit b0b750c05d
No known key found for this signature in database
6 changed files with 289 additions and 131 deletions

View file

@ -1,10 +1,11 @@
import { apiRoute, applyConfig, auth, handleZodError } from "@/api"; import { apiRoute, applyConfig, auth } from "@/api";
import { zValidator } from "@hono/zod-validator"; import { createRoute } from "@hono/zod-openapi";
import ISO6391 from "iso-639-1"; import ISO6391 from "iso-639-1";
import { z } from "zod"; import { z } from "zod";
import { RolePermissions } from "~/drizzle/schema"; import { RolePermissions } from "~/drizzle/schema";
import { Relationship } from "~/packages/database-interface/relationship"; import { Relationship } from "~/packages/database-interface/relationship";
import { User } from "~/packages/database-interface/user"; import { User } from "~/packages/database-interface/user";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["POST"], allowedMethods: ["POST"],
@ -41,42 +42,79 @@ export const schemas = {
.default({ reblogs: true, notify: false, languages: [] }), .default({ reblogs: true, notify: false, languages: [] }),
}; };
export default apiRoute((app) => const route = createRoute({
app.on( method: "post",
meta.allowedMethods, path: "/api/v1/accounts/{id}/follow",
meta.route, summary: "Follow user",
zValidator("param", schemas.param, handleZodError), description: "Follow a user",
zValidator("json", schemas.json, handleZodError), middleware: [auth(meta.auth, meta.permissions)],
auth(meta.auth, meta.permissions), responses: {
async (context) => { 200: {
const { id } = context.req.valid("param"); description: "User followed",
const { user } = context.get("auth"); content: {
const { reblogs, notify, languages } = context.req.valid("json"); "application/json": {
schema: Relationship.schema,
if (!user) { },
return context.json({ error: "Unauthorized" }, 401); },
}
const otherUser = await User.fromId(id);
if (!otherUser) {
return context.json({ error: "User not found" }, 404);
}
let relationship = await Relationship.fromOwnerAndSubject(
user,
otherUser,
);
if (!relationship.data.following) {
relationship = await user.followRequest(otherUser, {
reblogs,
notify,
languages,
});
}
return context.json(relationship.toApi());
}, },
), 401: {
description: "Unauthorized",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
404: {
description: "User not found",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
request: {
params: schemas.param,
body: {
content: {
"application/json": {
schema: schemas.json,
},
},
},
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { id } = context.req.valid("param");
const { user } = context.get("auth");
const { reblogs, notify, languages } = context.req.valid("json");
if (!user) {
return context.json({ error: "Unauthorized" }, 401);
}
const otherUser = await User.fromId(id);
if (!otherUser) {
return context.json({ error: "User not found" }, 404);
}
let relationship = await Relationship.fromOwnerAndSubject(
user,
otherUser,
);
if (!relationship.data.following) {
relationship = await user.followRequest(otherUser, {
reblogs,
notify,
languages,
});
}
return context.json(relationship.toApi(), 200);
}),
); );

View file

@ -1,16 +1,11 @@
import { import { apiRoute, applyConfig, auth, idValidator } from "@/api";
apiRoute, import { createRoute } from "@hono/zod-openapi";
applyConfig,
auth,
handleZodError,
idValidator,
} from "@/api";
import { zValidator } from "@hono/zod-validator";
import { and, gt, gte, lt, sql } from "drizzle-orm"; import { and, gt, gte, lt, sql } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { RolePermissions, Users } from "~/drizzle/schema"; import { RolePermissions, Users } from "~/drizzle/schema";
import { Timeline } from "~/packages/database-interface/timeline"; import { Timeline } from "~/packages/database-interface/timeline";
import { User } from "~/packages/database-interface/user"; import { User } from "~/packages/database-interface/user";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
@ -43,44 +38,72 @@ export const schemas = {
}), }),
}; };
export default apiRoute((app) => const route = createRoute({
app.on( method: "get",
meta.allowedMethods, path: "/api/v1/accounts/{id}/followers",
meta.route, summary: "Get account followers",
zValidator("query", schemas.query, handleZodError), description:
zValidator("param", schemas.param, handleZodError), "Gets an paginated list of accounts that follow the specified account",
auth(meta.auth, meta.permissions), middleware: [auth(meta.auth, meta.permissions)],
async (context) => { request: {
const { id } = context.req.valid("param"); params: schemas.param,
const { max_id, since_id, min_id, limit } = query: schemas.query,
context.req.valid("query"); },
responses: {
const otherUser = await User.fromId(id); 200: {
description: "A list of accounts that follow the specified account",
// TODO: Add follower/following privacy settings content: {
"application/json": {
if (!otherUser) { schema: z.array(User.schema),
return context.json({ error: "User not found" }, 404);
}
const { objects, link } = await Timeline.getUserTimeline(
and(
max_id ? lt(Users.id, max_id) : undefined,
since_id ? gte(Users.id, since_id) : undefined,
min_id ? gt(Users.id, min_id) : undefined,
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${otherUser.id} AND "Relationships"."ownerId" = ${Users.id} AND "Relationships"."following" = true)`,
),
limit,
context.req.url,
);
return context.json(
await Promise.all(objects.map((object) => object.toApi())),
200,
{
Link: link,
}, },
); },
headers: {
Link: {
description: "Links to the next and previous pages",
},
},
}, },
), 404: {
description: "The specified account was not found",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { id } = context.req.valid("param");
const { max_id, since_id, min_id, limit } = context.req.valid("query");
const otherUser = await User.fromId(id);
// TODO: Add follower/following privacy settings
if (!otherUser) {
return context.json({ error: "User not found" }, 404);
}
const { objects, link } = await Timeline.getUserTimeline(
and(
max_id ? lt(Users.id, max_id) : undefined,
since_id ? gte(Users.id, since_id) : undefined,
min_id ? gt(Users.id, min_id) : undefined,
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${otherUser.id} AND "Relationships"."ownerId" = ${Users.id} AND "Relationships"."following" = true)`,
),
limit,
context.req.url,
);
return context.json(
await Promise.all(objects.map((object) => object.toApi())),
200,
{
Link: link,
},
);
}),
); );

View file

@ -1,16 +1,11 @@
import { import { apiRoute, applyConfig, auth, idValidator } from "@/api";
apiRoute, import { createRoute } from "@hono/zod-openapi";
applyConfig,
auth,
handleZodError,
idValidator,
} from "@/api";
import { zValidator } from "@hono/zod-validator";
import { and, gt, gte, lt, sql } from "drizzle-orm"; import { and, gt, gte, lt, sql } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { RolePermissions, Users } from "~/drizzle/schema"; import { RolePermissions, Users } from "~/drizzle/schema";
import { Timeline } from "~/packages/database-interface/timeline"; import { Timeline } from "~/packages/database-interface/timeline";
import { User } from "~/packages/database-interface/user"; import { User } from "~/packages/database-interface/user";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
@ -43,43 +38,73 @@ export const schemas = {
}), }),
}; };
export default apiRoute((app) => const route = createRoute({
app.on( method: "get",
meta.allowedMethods, path: "/api/v1/accounts/{id}/following",
meta.route, summary: "Get account following",
zValidator("query", schemas.query, handleZodError), description:
zValidator("param", schemas.param, handleZodError), "Gets an paginated list of accounts that the specified account follows",
auth(meta.auth, meta.permissions), middleware: [auth(meta.auth, meta.permissions)],
async (context) => { request: {
const { id } = context.req.valid("param"); params: schemas.param,
const { max_id, since_id, min_id } = context.req.valid("query"); query: schemas.query,
},
const otherUser = await User.fromId(id); responses: {
200: {
if (!otherUser) { description:
return context.json({ error: "User not found" }, 404); "A list of accounts that the specified account follows",
} content: {
"application/json": {
// TODO: Add follower/following privacy settings schema: z.array(User.schema),
const { objects, link } = await Timeline.getUserTimeline(
and(
max_id ? lt(Users.id, max_id) : undefined,
since_id ? gte(Users.id, since_id) : undefined,
min_id ? gt(Users.id, min_id) : undefined,
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${otherUser.id} AND "Relationships"."following" = true)`,
),
context.req.valid("query").limit,
context.req.url,
);
return context.json(
await Promise.all(objects.map((object) => object.toApi())),
200,
{
Link: link,
}, },
); },
headers: {
Link: {
description: "Link to the next page of results",
},
},
}, },
), 404: {
description: "User not found",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { id } = context.req.valid("param");
const { max_id, since_id, min_id } = context.req.valid("query");
const otherUser = await User.fromId(id);
if (!otherUser) {
return context.json({ error: "User not found" }, 404);
}
// TODO: Add follower/following privacy settings
const { objects, link } = await Timeline.getUserTimeline(
and(
max_id ? lt(Users.id, max_id) : undefined,
since_id ? gte(Users.id, since_id) : undefined,
min_id ? gt(Users.id, min_id) : undefined,
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${otherUser.id} AND "Relationships"."following" = true)`,
),
context.req.valid("query").limit,
context.req.url,
);
return context.json(
await Promise.all(objects.map((object) => object.toApi())),
200,
{
Link: link,
},
);
}),
); );

View file

@ -11,6 +11,7 @@ import {
eq, eq,
inArray, inArray,
} from "drizzle-orm"; } from "drizzle-orm";
import { z } from "zod";
import { db } from "~/drizzle/db"; import { db } from "~/drizzle/db";
import { Emojis, Instances } from "~/drizzle/schema"; import { Emojis, Instances } from "~/drizzle/schema";
import { BaseInterface } from "./base"; import { BaseInterface } from "./base";
@ -21,6 +22,14 @@ export type EmojiWithInstance = InferSelectModel<typeof Emojis> & {
}; };
export class Emoji extends BaseInterface<typeof Emojis, EmojiWithInstance> { export class Emoji extends BaseInterface<typeof Emojis, EmojiWithInstance> {
static schema = z.object({
shortcode: z.string(),
url: z.string(),
visible_in_picker: z.boolean(),
category: z.string().optional(),
static_url: z.string(),
});
async reload(): Promise<void> { async reload(): Promise<void> {
const reloaded = await Emoji.fromId(this.data.id); const reloaded = await Emoji.fromId(this.data.id);

View file

@ -1,5 +1,5 @@
import { proxyUrl } from "@/response"; import { proxyUrl } from "@/response";
import type { RolePermission } from "@versia/client/types"; import { RolePermission } from "@versia/client/types";
import { import {
type InferInsertModel, type InferInsertModel,
type InferSelectModel, type InferSelectModel,
@ -9,6 +9,7 @@ import {
eq, eq,
inArray, inArray,
} from "drizzle-orm"; } from "drizzle-orm";
import { z } from "zod";
import { db } from "~/drizzle/db"; import { db } from "~/drizzle/db";
import { RoleToUsers, Roles } from "~/drizzle/schema"; import { RoleToUsers, Roles } from "~/drizzle/schema";
import { config } from "~/packages/config-manager/index"; import { config } from "~/packages/config-manager/index";
@ -17,6 +18,16 @@ import { BaseInterface } from "./base";
export type RoleType = InferSelectModel<typeof Roles>; export type RoleType = InferSelectModel<typeof Roles>;
export class Role extends BaseInterface<typeof Roles> { export class Role extends BaseInterface<typeof Roles> {
static schema = z.object({
id: z.string(),
name: z.string(),
permissions: z.array(z.nativeEnum(RolePermission)),
priority: z.number(),
description: z.string().nullable(),
visible: z.boolean(),
icon: z.string().nullable(),
});
async reload(): Promise<void> { async reload(): Promise<void> {
const reloaded = await Role.fromId(this.data.id); const reloaded = await Role.fromId(this.data.id);

View file

@ -35,6 +35,7 @@ import {
sql, sql,
} from "drizzle-orm"; } from "drizzle-orm";
import { htmlToText } from "html-to-text"; import { htmlToText } from "html-to-text";
import { z } from "zod";
import { import {
type UserWithRelations, type UserWithRelations,
findManyUsers, findManyUsers,
@ -64,6 +65,57 @@ import { Role } from "./role";
* Gives helpers to fetch users from database in a nice format * Gives helpers to fetch users from database in a nice format
*/ */
export class User extends BaseInterface<typeof Users, UserWithRelations> { export class User extends BaseInterface<typeof Users, UserWithRelations> {
static schema = z.object({
id: z.string(),
username: z.string(),
acct: z.string(),
display_name: z.string(),
locked: z.boolean(),
discoverable: z.boolean().optional(),
group: z.boolean().nullable(),
noindex: z.boolean().nullable(),
suspended: z.boolean().nullable(),
limited: z.boolean().nullable(),
created_at: z.string(),
followers_count: z.number(),
following_count: z.number(),
statuses_count: z.number(),
note: z.string(),
uri: z.string(),
url: z.string(),
avatar: z.string(),
avatar_static: z.string(),
header: z.string(),
header_static: z.string(),
emojis: z.array(Emoji.schema),
fields: z.array(
z.object({
name: z.string(),
value: z.string(),
verified: z.boolean().nullable().optional(),
verified_at: z.string().nullable().optional(),
}),
),
// FIXME: Use a proper type
moved: z.any().nullable(),
bot: z.boolean().nullable(),
source: z
.object({
privacy: z.string().nullable(),
sensitive: z.boolean().nullable(),
language: z.string().nullable(),
note: z.string(),
})
.optional(),
role: z
.object({
name: z.string(),
})
.optional(),
roles: z.array(Role.schema),
mute_expires_at: z.string().optional(),
});
async reload(): Promise<void> { async reload(): Promise<void> {
const reloaded = await User.fromId(this.data.id); const reloaded = await User.fromId(this.data.id);