diff --git a/api/api/v1/accounts/:id/follow.ts b/api/api/v1/accounts/:id/follow.ts index 0e0d6823..baf627f8 100644 --- a/api/api/v1/accounts/:id/follow.ts +++ b/api/api/v1/accounts/:id/follow.ts @@ -1,10 +1,11 @@ -import { apiRoute, applyConfig, auth, handleZodError } from "@/api"; -import { zValidator } from "@hono/zod-validator"; +import { apiRoute, applyConfig, auth } from "@/api"; +import { createRoute } from "@hono/zod-openapi"; import ISO6391 from "iso-639-1"; import { z } from "zod"; import { RolePermissions } from "~/drizzle/schema"; import { Relationship } from "~/packages/database-interface/relationship"; import { User } from "~/packages/database-interface/user"; +import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -41,42 +42,79 @@ export const schemas = { .default({ reblogs: true, notify: false, languages: [] }), }; -export default apiRoute((app) => - app.on( - meta.allowedMethods, - meta.route, - zValidator("param", schemas.param, handleZodError), - zValidator("json", schemas.json, handleZodError), - auth(meta.auth, meta.permissions), - 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()); +const route = createRoute({ + method: "post", + path: "/api/v1/accounts/{id}/follow", + summary: "Follow user", + description: "Follow a user", + middleware: [auth(meta.auth, meta.permissions)], + responses: { + 200: { + description: "User followed", + content: { + "application/json": { + schema: Relationship.schema, + }, + }, }, - ), + 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); + }), ); diff --git a/api/api/v1/accounts/:id/followers.ts b/api/api/v1/accounts/:id/followers.ts index d880c7fd..acab5c48 100644 --- a/api/api/v1/accounts/:id/followers.ts +++ b/api/api/v1/accounts/:id/followers.ts @@ -1,16 +1,11 @@ -import { - apiRoute, - applyConfig, - auth, - handleZodError, - idValidator, -} from "@/api"; -import { zValidator } from "@hono/zod-validator"; +import { apiRoute, applyConfig, auth, idValidator } from "@/api"; +import { createRoute } from "@hono/zod-openapi"; import { and, gt, gte, lt, sql } from "drizzle-orm"; import { z } from "zod"; import { RolePermissions, Users } from "~/drizzle/schema"; import { Timeline } from "~/packages/database-interface/timeline"; import { User } from "~/packages/database-interface/user"; +import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -43,44 +38,72 @@ export const schemas = { }), }; -export default apiRoute((app) => - app.on( - meta.allowedMethods, - meta.route, - zValidator("query", schemas.query, handleZodError), - zValidator("param", schemas.param, handleZodError), - auth(meta.auth, meta.permissions), - 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, +const route = createRoute({ + method: "get", + path: "/api/v1/accounts/{id}/followers", + summary: "Get account followers", + description: + "Gets an paginated list of accounts that follow the specified account", + middleware: [auth(meta.auth, meta.permissions)], + request: { + params: schemas.param, + query: schemas.query, + }, + responses: { + 200: { + description: "A list of accounts that follow the specified account", + content: { + "application/json": { + schema: z.array(User.schema), }, - ); + }, + 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, + }, + ); + }), ); diff --git a/api/api/v1/accounts/:id/following.ts b/api/api/v1/accounts/:id/following.ts index 5bb7ee7c..3bb6b4c8 100644 --- a/api/api/v1/accounts/:id/following.ts +++ b/api/api/v1/accounts/:id/following.ts @@ -1,16 +1,11 @@ -import { - apiRoute, - applyConfig, - auth, - handleZodError, - idValidator, -} from "@/api"; -import { zValidator } from "@hono/zod-validator"; +import { apiRoute, applyConfig, auth, idValidator } from "@/api"; +import { createRoute } from "@hono/zod-openapi"; import { and, gt, gte, lt, sql } from "drizzle-orm"; import { z } from "zod"; import { RolePermissions, Users } from "~/drizzle/schema"; import { Timeline } from "~/packages/database-interface/timeline"; import { User } from "~/packages/database-interface/user"; +import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -43,43 +38,73 @@ export const schemas = { }), }; -export default apiRoute((app) => - app.on( - meta.allowedMethods, - meta.route, - zValidator("query", schemas.query, handleZodError), - zValidator("param", schemas.param, handleZodError), - auth(meta.auth, meta.permissions), - 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, +const route = createRoute({ + method: "get", + path: "/api/v1/accounts/{id}/following", + summary: "Get account following", + description: + "Gets an paginated list of accounts that the specified account follows", + middleware: [auth(meta.auth, meta.permissions)], + request: { + params: schemas.param, + query: schemas.query, + }, + responses: { + 200: { + description: + "A list of accounts that the specified account follows", + content: { + "application/json": { + schema: z.array(User.schema), }, - ); + }, + 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, + }, + ); + }), ); diff --git a/packages/database-interface/emoji.ts b/packages/database-interface/emoji.ts index fe7f5786..1e2941ff 100644 --- a/packages/database-interface/emoji.ts +++ b/packages/database-interface/emoji.ts @@ -11,6 +11,7 @@ import { eq, inArray, } from "drizzle-orm"; +import { z } from "zod"; import { db } from "~/drizzle/db"; import { Emojis, Instances } from "~/drizzle/schema"; import { BaseInterface } from "./base"; @@ -21,6 +22,14 @@ export type EmojiWithInstance = InferSelectModel & { }; export class Emoji extends BaseInterface { + 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 { const reloaded = await Emoji.fromId(this.data.id); diff --git a/packages/database-interface/role.ts b/packages/database-interface/role.ts index 051de943..3040002a 100644 --- a/packages/database-interface/role.ts +++ b/packages/database-interface/role.ts @@ -1,5 +1,5 @@ import { proxyUrl } from "@/response"; -import type { RolePermission } from "@versia/client/types"; +import { RolePermission } from "@versia/client/types"; import { type InferInsertModel, type InferSelectModel, @@ -9,6 +9,7 @@ import { eq, inArray, } from "drizzle-orm"; +import { z } from "zod"; import { db } from "~/drizzle/db"; import { RoleToUsers, Roles } from "~/drizzle/schema"; import { config } from "~/packages/config-manager/index"; @@ -17,6 +18,16 @@ import { BaseInterface } from "./base"; export type RoleType = InferSelectModel; export class Role extends BaseInterface { + 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 { const reloaded = await Role.fromId(this.data.id); diff --git a/packages/database-interface/user.ts b/packages/database-interface/user.ts index ec9ac084..d7f15207 100644 --- a/packages/database-interface/user.ts +++ b/packages/database-interface/user.ts @@ -35,6 +35,7 @@ import { sql, } from "drizzle-orm"; import { htmlToText } from "html-to-text"; +import { z } from "zod"; import { type UserWithRelations, findManyUsers, @@ -64,6 +65,57 @@ import { Role } from "./role"; * Gives helpers to fetch users from database in a nice format */ export class User extends BaseInterface { + 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 { const reloaded = await User.fromId(this.data.id);