From bcbc9e6bf1a83ff76945658127006830616f2acf Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Tue, 27 Aug 2024 18:55:02 +0200 Subject: [PATCH] refactor(api): :recycle: Refactor more routes into OpenAPI-compatible formats --- api/api/v1/accounts/:id/block.ts | 2 +- api/api/v1/accounts/:id/follow.ts | 2 +- api/api/v1/accounts/:id/index.ts | 64 +++++--- api/api/v1/accounts/:id/mute.ts | 114 ++++++++----- api/api/v1/accounts/:id/note.ts | 108 +++++++++---- api/api/v1/accounts/:id/pin.ts | 98 ++++++++---- api/api/v1/accounts/:id/refetch.ts | 96 ++++++++--- .../v1/accounts/:id/remove_from_followers.ts | 112 ++++++++----- api/api/v1/accounts/:id/statuses.ts | 150 ++++++++++-------- api/api/v1/accounts/:id/unblock.ts | 102 ++++++++---- api/api/v1/accounts/:id/unfollow.ts | 106 +++++++++---- api/api/v1/accounts/:id/unmute.ts | 104 +++++++----- api/api/v1/accounts/:id/unpin.ts | 102 ++++++++---- app.ts | 4 +- packages/database-interface/attachment.ts | 29 ++++ packages/database-interface/note.ts | 91 +++++++++++ packages/database-interface/user.ts | 12 +- 17 files changed, 896 insertions(+), 400 deletions(-) diff --git a/api/api/v1/accounts/:id/block.ts b/api/api/v1/accounts/:id/block.ts index d7e34e94..22352c07 100644 --- a/api/api/v1/accounts/:id/block.ts +++ b/api/api/v1/accounts/:id/block.ts @@ -39,7 +39,7 @@ const route = createRoute({ middleware: [auth(meta.auth, meta.permissions)], responses: { 200: { - description: "User blocked", + description: "Updated relationship", content: { "application/json": { schema: Relationship.schema, diff --git a/api/api/v1/accounts/:id/follow.ts b/api/api/v1/accounts/:id/follow.ts index baf627f8..205cac22 100644 --- a/api/api/v1/accounts/:id/follow.ts +++ b/api/api/v1/accounts/:id/follow.ts @@ -50,7 +50,7 @@ const route = createRoute({ middleware: [auth(meta.auth, meta.permissions)], responses: { 200: { - description: "User followed", + description: "Updated relationship", content: { "application/json": { schema: Relationship.schema, diff --git a/api/api/v1/accounts/:id/index.ts b/api/api/v1/accounts/:id/index.ts index c48f9f58..c5091da4 100644 --- a/api/api/v1/accounts/:id/index.ts +++ b/api/api/v1/accounts/:id/index.ts @@ -1,8 +1,9 @@ -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 { z } from "zod"; import { RolePermissions } from "~/drizzle/schema"; import { User } from "~/packages/database-interface/user"; +import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -26,23 +27,46 @@ export const schemas = { }), }; -export default apiRoute((app) => - app.on( - meta.allowedMethods, - meta.route, - zValidator("param", schemas.param, handleZodError), - auth(meta.auth, meta.permissions), - async (context) => { - const { id } = context.req.valid("param"); - const { user } = context.get("auth"); - - const foundUser = await User.fromId(id); - - if (!foundUser) { - return context.json({ error: "User not found" }, 404); - } - - return context.json(foundUser.toApi(user?.id === foundUser.id)); +const route = createRoute({ + method: "get", + path: "/api/v1/accounts/{id}", + summary: "Get account data", + description: "Gets the specified account data", + middleware: [auth(meta.auth, meta.permissions)], + request: { + params: schemas.param, + }, + responses: { + 200: { + description: "Account data", + content: { + "application/json": { + schema: User.schema, + }, + }, }, - ), + 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 { user } = context.get("auth"); + + const foundUser = await User.fromId(id); + + if (!foundUser) { + return context.json({ error: "User not found" }, 404); + } + + return context.json(foundUser.toApi(user?.id === foundUser.id), 200); + }), ); diff --git a/api/api/v1/accounts/:id/mute.ts b/api/api/v1/accounts/:id/mute.ts index e9844c53..6791ba92 100644 --- a/api/api/v1/accounts/:id/mute.ts +++ b/api/api/v1/accounts/:id/mute.ts @@ -1,9 +1,10 @@ -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 { 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"], @@ -39,41 +40,78 @@ export const schemas = { }), }; -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"); - // TODO: Add duration support - const { notifications } = 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); - } - - const foundRelationship = await Relationship.fromOwnerAndSubject( - user, - otherUser, - ); - - // TODO: Implement duration - await foundRelationship.update({ - muting: true, - mutingNotifications: notifications ?? true, - }); - - return context.json(foundRelationship.toApi()); +const route = createRoute({ + method: "post", + path: "/api/v1/accounts/{id}/mute", + summary: "Mute user", + description: "Mute a user", + middleware: [auth(meta.auth, meta.permissions)], + request: { + params: schemas.param, + body: { + content: { + "application/json": { + schema: schemas.json, + }, + }, }, - ), + }, + responses: { + 200: { + description: "Updated relationship", + content: { + "application/json": { + schema: Relationship.schema, + }, + }, + }, + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 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 { user } = context.get("auth"); + // TODO: Add duration support + const { notifications } = 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); + } + + const foundRelationship = await Relationship.fromOwnerAndSubject( + user, + otherUser, + ); + + // TODO: Implement duration + await foundRelationship.update({ + muting: true, + mutingNotifications: notifications ?? true, + }); + + return context.json(foundRelationship.toApi(), 200); + }), ); diff --git a/api/api/v1/accounts/:id/note.ts b/api/api/v1/accounts/:id/note.ts index 25d0be9d..80f814a9 100644 --- a/api/api/v1/accounts/:id/note.ts +++ b/api/api/v1/accounts/:id/note.ts @@ -1,9 +1,10 @@ -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 { 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"], @@ -33,38 +34,75 @@ export const schemas = { }), }; -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 { comment } = 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); - } - - const foundRelationship = await Relationship.fromOwnerAndSubject( - user, - otherUser, - ); - - await foundRelationship.update({ - note: comment, - }); - - return context.json(foundRelationship.toApi()); +const route = createRoute({ + method: "post", + path: "/api/v1/accounts/{id}/note", + summary: "Set note", + description: "Set a note on a user's profile, visible only to you", + middleware: [auth(meta.auth, meta.permissions)], + request: { + params: schemas.param, + body: { + content: { + "application/json": { + schema: schemas.json, + }, + }, }, - ), + }, + responses: { + 200: { + description: "Updated relationship", + content: { + "application/json": { + schema: Relationship.schema, + }, + }, + }, + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 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 { user } = context.get("auth"); + const { comment } = 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); + } + + const foundRelationship = await Relationship.fromOwnerAndSubject( + user, + otherUser, + ); + + await foundRelationship.update({ + note: comment, + }); + + return context.json(foundRelationship.toApi(), 200); + }), ); diff --git a/api/api/v1/accounts/:id/pin.ts b/api/api/v1/accounts/:id/pin.ts index ee07fa89..f61c461e 100644 --- a/api/api/v1/accounts/:id/pin.ts +++ b/api/api/v1/accounts/:id/pin.ts @@ -1,9 +1,10 @@ -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 { 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"], @@ -30,36 +31,67 @@ export const schemas = { }), }; -export default apiRoute((app) => - app.on( - meta.allowedMethods, - meta.route, - zValidator("param", schemas.param, handleZodError), - auth(meta.auth, meta.permissions), - async (context) => { - const { id } = context.req.valid("param"); - const { user } = context.get("auth"); - - if (!user) { - return context.json({ error: "Unauthorized" }, 401); - } - - const otherUser = await User.fromId(id); - - if (!otherUser) { - return context.json({ error: "User not found" }, 404); - } - - const foundRelationship = await Relationship.fromOwnerAndSubject( - user, - otherUser, - ); - - await foundRelationship.update({ - endorsed: true, - }); - - return context.json(foundRelationship.toApi()); +const route = createRoute({ + method: "post", + path: "/api/v1/accounts/{id}/pin", + summary: "Pin user", + description: "Pin a user to your profile", + middleware: [auth(meta.auth, meta.permissions)], + request: { + params: schemas.param, + }, + responses: { + 200: { + description: "Updated relationship", + content: { + "application/json": { + schema: Relationship.schema, + }, + }, }, - ), + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 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 { user } = context.get("auth"); + + if (!user) { + return context.json({ error: "Unauthorized" }, 401); + } + + const otherUser = await User.fromId(id); + + if (!otherUser) { + return context.json({ error: "User not found" }, 404); + } + + const foundRelationship = await Relationship.fromOwnerAndSubject( + user, + otherUser, + ); + + await foundRelationship.update({ + endorsed: true, + }); + + return context.json(foundRelationship.toApi(), 200); + }), ); diff --git a/api/api/v1/accounts/:id/refetch.ts b/api/api/v1/accounts/:id/refetch.ts index 997b4e84..c6cd11a2 100644 --- a/api/api/v1/accounts/:id/refetch.ts +++ b/api/api/v1/accounts/:id/refetch.ts @@ -1,8 +1,9 @@ -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 { z } from "zod"; import { RolePermissions } from "~/drizzle/schema"; import { User } from "~/packages/database-interface/user"; +import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -26,29 +27,72 @@ export const schemas = { }), }; -export default apiRoute((app) => - app.on( - meta.allowedMethods, - meta.route, - zValidator("param", schemas.param, handleZodError), - auth(meta.auth, meta.permissions), - async (context) => { - const { id } = context.req.valid("param"); - const { user } = context.get("auth"); - - if (!user) { - return context.json({ error: "Unauthorized" }, 401); - } - - const otherUser = await User.fromId(id); - - if (!otherUser) { - return context.json({ error: "User not found" }, 404); - } - - const newUser = await otherUser.updateFromRemote(); - - return context.json(newUser.toApi(false)); +const route = createRoute({ + method: "post", + path: "/api/v1/accounts/{id}/refetch", + summary: "Refetch user", + description: "Refetch a user's profile from the remote server", + middleware: [auth(meta.auth, meta.permissions)], + request: { + params: schemas.param, + }, + responses: { + 200: { + description: "Updated user data", + content: { + "application/json": { + schema: User.schema, + }, + }, }, - ), + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 404: { + description: "User not found", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 400: { + description: "User is local", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + }, +}); + +export default apiRoute((app) => + app.openapi(route, async (context) => { + const { id } = context.req.valid("param"); + const { user } = context.get("auth"); + + if (!user) { + return context.json({ error: "Unauthorized" }, 401); + } + + const otherUser = await User.fromId(id); + + if (!otherUser) { + return context.json({ error: "User not found" }, 404); + } + + if (otherUser.isLocal()) { + return context.json({ error: "Cannot refetch a local user" }, 400); + } + + const newUser = await otherUser.updateFromRemote(); + + return context.json(newUser.toApi(false), 200); + }), ); diff --git a/api/api/v1/accounts/:id/remove_from_followers.ts b/api/api/v1/accounts/:id/remove_from_followers.ts index ae346a97..cab432c3 100644 --- a/api/api/v1/accounts/:id/remove_from_followers.ts +++ b/api/api/v1/accounts/:id/remove_from_followers.ts @@ -1,9 +1,10 @@ -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 { 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"], @@ -30,43 +31,74 @@ export const schemas = { }), }; -export default apiRoute((app) => - app.on( - meta.allowedMethods, - meta.route, - zValidator("param", schemas.param, handleZodError), - auth(meta.auth, meta.permissions), - async (context) => { - const { id } = context.req.valid("param"); - const { user: self } = context.get("auth"); - - if (!self) { - return context.json({ error: "Unauthorized" }, 401); - } - - const otherUser = await User.fromId(id); - - if (!otherUser) { - return context.json({ error: "User not found" }, 404); - } - - const oppositeRelationship = await Relationship.fromOwnerAndSubject( - otherUser, - self, - ); - - if (oppositeRelationship.data.following) { - await oppositeRelationship.update({ - following: false, - }); - } - - const foundRelationship = await Relationship.fromOwnerAndSubject( - self, - otherUser, - ); - - return context.json(foundRelationship.toApi()); +const route = createRoute({ + method: "post", + path: "/api/v1/accounts/{id}/remove_from_followers", + summary: "Remove user from followers", + description: "Remove a user from your followers", + middleware: [auth(meta.auth, meta.permissions)], + request: { + params: schemas.param, + }, + responses: { + 200: { + description: "Updated relationship", + content: { + "application/json": { + schema: Relationship.schema, + }, + }, }, - ), + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 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 { user: self } = context.get("auth"); + + if (!self) { + return context.json({ error: "Unauthorized" }, 401); + } + + const otherUser = await User.fromId(id); + + if (!otherUser) { + return context.json({ error: "User not found" }, 404); + } + + const oppositeRelationship = await Relationship.fromOwnerAndSubject( + otherUser, + self, + ); + + if (oppositeRelationship.data.following) { + await oppositeRelationship.update({ + following: false, + }); + } + + const foundRelationship = await Relationship.fromOwnerAndSubject( + self, + otherUser, + ); + + return context.json(foundRelationship.toApi(), 200); + }), ); diff --git a/api/api/v1/accounts/:id/statuses.ts b/api/api/v1/accounts/:id/statuses.ts index 1a78f28a..04c7399c 100644 --- a/api/api/v1/accounts/:id/statuses.ts +++ b/api/api/v1/accounts/:id/statuses.ts @@ -1,16 +1,12 @@ -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, eq, gt, gte, isNull, lt, sql } from "drizzle-orm"; import { z } from "zod"; import { Notes, RolePermissions } from "~/drizzle/schema"; +import { Note } from "~/packages/database-interface/note"; 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"], @@ -60,61 +56,89 @@ export const schemas = { }), }; -export default apiRoute((app) => - app.on( - meta.allowedMethods, - meta.route, - zValidator("param", schemas.param, handleZodError), - zValidator("query", schemas.query, handleZodError), - auth(meta.auth, meta.permissions), - async (context) => { - const { id } = context.req.valid("param"); - const { user } = context.get("auth"); - - const otherUser = await User.fromId(id); - - if (!otherUser) { - return context.json({ error: "User not found" }, 404); - } - - const { - max_id, - min_id, - since_id, - limit, - exclude_reblogs, - only_media, - exclude_replies, - pinned, - } = context.req.valid("query"); - - const { objects, link } = await Timeline.getNoteTimeline( - and( - max_id ? lt(Notes.id, max_id) : undefined, - since_id ? gte(Notes.id, since_id) : undefined, - min_id ? gt(Notes.id, min_id) : undefined, - eq(Notes.authorId, id), - only_media - ? sql`EXISTS (SELECT 1 FROM "Attachments" WHERE "Attachments"."noteId" = ${Notes.id})` - : undefined, - pinned - ? sql`EXISTS (SELECT 1 FROM "UserToPinnedNotes" WHERE "UserToPinnedNotes"."noteId" = ${Notes.id} AND "UserToPinnedNotes"."userId" = ${otherUser.id})` - : undefined, - exclude_reblogs ? isNull(Notes.reblogId) : undefined, - exclude_replies ? isNull(Notes.replyId) : undefined, - ), - limit, - context.req.url, - user?.id, - ); - - return context.json( - await Promise.all(objects.map((note) => note.toApi(otherUser))), - 200, - { - link, +const route = createRoute({ + method: "get", + path: "/api/v1/accounts/{id}/statuses", + summary: "Get account statuses", + description: "Gets an paginated list of statuses by the specified account", + middleware: [auth(meta.auth, meta.permissions)], + request: { + params: schemas.param, + query: schemas.query, + }, + responses: { + 200: { + description: "A list of statuses by the specified account", + content: { + "application/json": { + schema: z.array(Note.schema), }, - ); + }, + headers: { + Link: { + description: "Links to the next and previous pages", + }, + }, }, - ), + 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 { user } = context.get("auth"); + + const otherUser = await User.fromId(id); + + if (!otherUser) { + return context.json({ error: "User not found" }, 404); + } + + const { + max_id, + min_id, + since_id, + limit, + exclude_reblogs, + only_media, + exclude_replies, + pinned, + } = context.req.valid("query"); + + const { objects, link } = await Timeline.getNoteTimeline( + and( + max_id ? lt(Notes.id, max_id) : undefined, + since_id ? gte(Notes.id, since_id) : undefined, + min_id ? gt(Notes.id, min_id) : undefined, + eq(Notes.authorId, id), + only_media + ? sql`EXISTS (SELECT 1 FROM "Attachments" WHERE "Attachments"."noteId" = ${Notes.id})` + : undefined, + pinned + ? sql`EXISTS (SELECT 1 FROM "UserToPinnedNotes" WHERE "UserToPinnedNotes"."noteId" = ${Notes.id} AND "UserToPinnedNotes"."userId" = ${otherUser.id})` + : undefined, + exclude_reblogs ? isNull(Notes.reblogId) : undefined, + exclude_replies ? isNull(Notes.replyId) : undefined, + ), + limit, + context.req.url, + user?.id, + ); + + return context.json( + await Promise.all(objects.map((note) => note.toApi(otherUser))), + 200, + { + link, + }, + ); + }), ); diff --git a/api/api/v1/accounts/:id/unblock.ts b/api/api/v1/accounts/:id/unblock.ts index fcfea7ad..03d531d6 100644 --- a/api/api/v1/accounts/:id/unblock.ts +++ b/api/api/v1/accounts/:id/unblock.ts @@ -1,9 +1,10 @@ -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 { 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"], @@ -30,38 +31,69 @@ export const schemas = { }), }; -export default apiRoute((app) => - app.on( - meta.allowedMethods, - meta.route, - zValidator("param", schemas.param, handleZodError), - auth(meta.auth, meta.permissions), - async (context) => { - const { id } = context.req.valid("param"); - const { user } = context.get("auth"); - - if (!user) { - return context.json({ error: "Unauthorized" }, 401); - } - - const otherUser = await User.fromId(id); - - if (!otherUser) { - return context.json({ error: "User not found" }, 404); - } - - const foundRelationship = await Relationship.fromOwnerAndSubject( - user, - otherUser, - ); - - if (foundRelationship.data.blocking) { - await foundRelationship.update({ - blocking: false, - }); - } - - return context.json(foundRelationship.toApi()); +const route = createRoute({ + method: "post", + path: "/api/v1/accounts/{id}/unblock", + summary: "Unblock user", + description: "Unblock a user", + middleware: [auth(meta.auth, meta.permissions)], + request: { + params: schemas.param, + }, + responses: { + 200: { + description: "Updated relationship", + content: { + "application/json": { + schema: Relationship.schema, + }, + }, }, - ), + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 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 { user } = context.get("auth"); + + if (!user) { + return context.json({ error: "Unauthorized" }, 401); + } + + const otherUser = await User.fromId(id); + + if (!otherUser) { + return context.json({ error: "User not found" }, 404); + } + + const foundRelationship = await Relationship.fromOwnerAndSubject( + user, + otherUser, + ); + + if (foundRelationship.data.blocking) { + await foundRelationship.update({ + blocking: false, + }); + } + + return context.json(foundRelationship.toApi(), 200); + }), ); diff --git a/api/api/v1/accounts/:id/unfollow.ts b/api/api/v1/accounts/:id/unfollow.ts index a366311b..57572929 100644 --- a/api/api/v1/accounts/:id/unfollow.ts +++ b/api/api/v1/accounts/:id/unfollow.ts @@ -1,9 +1,10 @@ -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 { 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"], @@ -30,36 +31,75 @@ export const schemas = { }), }; -export default apiRoute((app) => - app.on( - meta.allowedMethods, - meta.route, - zValidator("param", schemas.param, handleZodError), - auth(meta.auth, meta.permissions), - async (context) => { - const { id } = context.req.valid("param"); - const { user: self } = context.get("auth"); - - if (!self) { - return context.json({ error: "Unauthorized" }, 401); - } - - const otherUser = await User.fromId(id); - - if (!otherUser) { - return context.json({ error: "User not found" }, 404); - } - - const foundRelationship = await Relationship.fromOwnerAndSubject( - self, - otherUser, - ); - - if (!(await self.unfollow(otherUser, foundRelationship))) { - return context.json({ error: "Failed to unfollow user" }, 500); - } - - return context.json(foundRelationship.toApi()); +const route = createRoute({ + method: "post", + path: "/api/v1/accounts/{id}/unfollow", + summary: "Unfollow user", + description: "Unfollow a user", + middleware: [auth(meta.auth, meta.permissions)], + request: { + params: schemas.param, + }, + responses: { + 200: { + description: "Updated relationship", + content: { + "application/json": { + schema: Relationship.schema, + }, + }, }, - ), + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 404: { + description: "User not found", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 500: { + description: "Failed to unfollow user during federation", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + }, +}); + +export default apiRoute((app) => + app.openapi(route, async (context) => { + const { id } = context.req.valid("param"); + const { user: self } = context.get("auth"); + + if (!self) { + return context.json({ error: "Unauthorized" }, 401); + } + + const otherUser = await User.fromId(id); + + if (!otherUser) { + return context.json({ error: "User not found" }, 404); + } + + const foundRelationship = await Relationship.fromOwnerAndSubject( + self, + otherUser, + ); + + if (!(await self.unfollow(otherUser, foundRelationship))) { + return context.json({ error: "Failed to unfollow user" }, 500); + } + + return context.json(foundRelationship.toApi(), 200); + }), ); diff --git a/api/api/v1/accounts/:id/unmute.ts b/api/api/v1/accounts/:id/unmute.ts index 41d6e76d..4db5e6ea 100644 --- a/api/api/v1/accounts/:id/unmute.ts +++ b/api/api/v1/accounts/:id/unmute.ts @@ -1,9 +1,10 @@ -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 { 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"], @@ -30,39 +31,70 @@ export const schemas = { }), }; -export default apiRoute((app) => - app.on( - meta.allowedMethods, - meta.route, - zValidator("param", schemas.param, handleZodError), - auth(meta.auth, meta.permissions), - async (context) => { - const { id } = context.req.valid("param"); - const { user: self } = context.get("auth"); - - if (!self) { - return context.json({ error: "Unauthorized" }, 401); - } - - const user = await User.fromId(id); - - if (!user) { - return context.json({ error: "User not found" }, 404); - } - - const foundRelationship = await Relationship.fromOwnerAndSubject( - self, - user, - ); - - if (foundRelationship.data.muting) { - await foundRelationship.update({ - muting: false, - mutingNotifications: false, - }); - } - - return context.json(foundRelationship.toApi()); +const route = createRoute({ + method: "post", + path: "/api/v1/accounts/{id}/unmute", + summary: "Unmute user", + description: "Unmute a user", + middleware: [auth(meta.auth, meta.permissions)], + request: { + params: schemas.param, + }, + responses: { + 200: { + description: "Updated relationship", + content: { + "application/json": { + schema: Relationship.schema, + }, + }, }, - ), + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 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 { user: self } = context.get("auth"); + + if (!self) { + return context.json({ error: "Unauthorized" }, 401); + } + + const user = await User.fromId(id); + + if (!user) { + return context.json({ error: "User not found" }, 404); + } + + const foundRelationship = await Relationship.fromOwnerAndSubject( + self, + user, + ); + + if (foundRelationship.data.muting) { + await foundRelationship.update({ + muting: false, + mutingNotifications: false, + }); + } + + return context.json(foundRelationship.toApi(), 200); + }), ); diff --git a/api/api/v1/accounts/:id/unpin.ts b/api/api/v1/accounts/:id/unpin.ts index 5ad01ffe..d35ac720 100644 --- a/api/api/v1/accounts/:id/unpin.ts +++ b/api/api/v1/accounts/:id/unpin.ts @@ -1,9 +1,10 @@ -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 { 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"], @@ -30,38 +31,69 @@ export const schemas = { }), }; -export default apiRoute((app) => - app.on( - meta.allowedMethods, - meta.route, - zValidator("param", schemas.param, handleZodError), - auth(meta.auth, meta.permissions), - async (context) => { - const { id } = context.req.valid("param"); - const { user: self } = context.get("auth"); - - if (!self) { - return context.json({ error: "Unauthorized" }, 401); - } - - const otherUser = await User.fromId(id); - - if (!otherUser) { - return context.json({ error: "User not found" }, 404); - } - - const foundRelationship = await Relationship.fromOwnerAndSubject( - self, - otherUser, - ); - - if (foundRelationship.data.endorsed) { - await foundRelationship.update({ - endorsed: false, - }); - } - - return context.json(foundRelationship.toApi()); +const route = createRoute({ + method: "post", + path: "/api/v1/accounts/{id}/unpin", + summary: "Unpin user", + description: "Unpin a user from your profile", + middleware: [auth(meta.auth, meta.permissions)], + request: { + params: schemas.param, + }, + responses: { + 200: { + description: "Updated relationship", + content: { + "application/json": { + schema: Relationship.schema, + }, + }, }, - ), + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 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 { user: self } = context.get("auth"); + + if (!self) { + return context.json({ error: "Unauthorized" }, 401); + } + + const otherUser = await User.fromId(id); + + if (!otherUser) { + return context.json({ error: "User not found" }, 404); + } + + const foundRelationship = await Relationship.fromOwnerAndSubject( + self, + otherUser, + ); + + if (foundRelationship.data.endorsed) { + await foundRelationship.update({ + endorsed: false, + }); + } + + return context.json(foundRelationship.toApi(), 200); + }), ); diff --git a/app.ts b/app.ts index 3bef1c7a..bd49356b 100644 --- a/app.ts +++ b/app.ts @@ -2,6 +2,7 @@ import { sentry } from "@/sentry"; import { cors } from "@hono/hono/cors"; import { prettyJSON } from "@hono/hono/pretty-json"; import { secureHeaders } from "@hono/hono/secure-headers"; +import { swaggerUI } from "@hono/swagger-ui"; import { OpenAPIHono } from "@hono/zod-openapi"; /* import { prometheus } from "@hono/prometheus"; */ import { getLogger } from "@logtape/logtape"; @@ -13,14 +14,15 @@ import { boundaryCheck } from "./middlewares/boundary-check"; import { ipBans } from "./middlewares/ip-bans"; import { logger } from "./middlewares/logger"; import { routes } from "./routes"; -import { swaggerUI } from "@hono/swagger-ui"; import type { ApiRouteExports, HonoEnv } from "./types/api"; +import { handleZodError } from "@/api"; export const appFactory = async () => { const serverLogger = getLogger("server"); const app = new OpenAPIHono({ strict: false, + defaultHook: handleZodError, }); /* const { printMetrics, registerMetrics } = prometheus({ diff --git a/packages/database-interface/attachment.ts b/packages/database-interface/attachment.ts index 4c53f047..747e1d01 100644 --- a/packages/database-interface/attachment.ts +++ b/packages/database-interface/attachment.ts @@ -12,6 +12,7 @@ import { eq, inArray, } from "drizzle-orm"; +import { z } from "zod"; import { db } from "~/drizzle/db"; import { Attachments } from "~/drizzle/schema"; import { MediaBackendType } from "~/packages/config-manager/config.type"; @@ -21,6 +22,34 @@ import { BaseInterface } from "./base"; export type AttachmentType = InferSelectModel; export class Attachment extends BaseInterface { + static schema: z.ZodType = z.object({ + id: z.string().uuid(), + type: z.enum(["unknown", "image", "gifv", "video", "audio"]), + url: z.string().url(), + remote_url: z.string().url().nullable(), + preview_url: z.string().url().nullable(), + text_url: z.string().url().nullable(), + meta: z + .object({ + width: z.number().optional(), + height: z.number().optional(), + fps: z.number().optional(), + size: z.string().optional(), + duration: z.number().optional(), + length: z.string().optional(), + aspect: z.number().optional(), + original: z.object({ + width: z.number().optional(), + height: z.number().optional(), + size: z.string().optional(), + aspect: z.number().optional(), + }), + }) + .nullable(), + description: z.string().nullable(), + blurhash: z.string().nullable(), + }); + async reload(): Promise { const reloaded = await Attachment.fromId(this.data.id); diff --git a/packages/database-interface/note.ts b/packages/database-interface/note.ts index 110fd597..0002c7b1 100644 --- a/packages/database-interface/note.ts +++ b/packages/database-interface/note.ts @@ -27,6 +27,7 @@ import { } from "drizzle-orm"; import { htmlToText } from "html-to-text"; import { createRegExp, exactly, global } from "magic-regexp"; +import { z } from "zod"; import { type Application, applicationToApi, @@ -56,6 +57,96 @@ import { User } from "./user"; * Gives helpers to fetch notes from database in a nice format */ export class Note extends BaseInterface { + static schema: z.ZodType = z.object({ + id: z.string().uuid(), + uri: z.string().url(), + url: z.string().url(), + account: z.lazy(() => User.schema), + in_reply_to_id: z.string().uuid().nullable(), + in_reply_to_account_id: z.string().uuid().nullable(), + reblog: z.lazy(() => Note.schema).nullable(), + content: z.string(), + plain_content: z.string().nullable(), + created_at: z.string(), + edited_at: z.string().nullable(), + emojis: z.array(Emoji.schema), + replies_count: z.number().int().nonnegative(), + reblogs_count: z.number().int().nonnegative(), + favourites_count: z.number().int().nonnegative(), + reblogged: z.boolean().nullable(), + favourited: z.boolean().nullable(), + muted: z.boolean().nullable(), + sensitive: z.boolean(), + spoiler_text: z.string(), + visibility: z.enum(["public", "unlisted", "private", "direct"]), + media_attachments: z.array(Attachment.schema), + mentions: z.array( + z.object({ + id: z.string().uuid(), + username: z.string(), + acct: z.string(), + url: z.string().url(), + }), + ), + tags: z.array(z.object({ name: z.string(), url: z.string().url() })), + card: z + .object({ + url: z.string().url(), + title: z.string(), + description: z.string(), + type: z.enum(["link", "photo", "video", "rich"]), + image: z.string().url().nullable(), + author_name: z.string().nullable(), + author_url: z.string().url().nullable(), + provider_name: z.string().nullable(), + provider_url: z.string().url().nullable(), + html: z.string().nullable(), + width: z.number().int().nonnegative().nullable(), + height: z.number().int().nonnegative().nullable(), + embed_url: z.string().url().nullable(), + blurhash: z.string().nullable(), + }) + .nullable(), + poll: z + .object({ + id: z.string().uuid(), + expires_at: z.string(), + expired: z.boolean(), + multiple: z.boolean(), + votes_count: z.number().int().nonnegative(), + voted: z.boolean(), + options: z.array( + z.object({ + title: z.string(), + votes_count: z.number().int().nonnegative().nullable(), + }), + ), + }) + .nullable(), + application: z + .object({ + name: z.string(), + website: z.string().url().nullable().optional(), + vapid_key: z.string().nullable().optional(), + }) + .nullable(), + language: z.string().nullable(), + pinned: z.boolean().nullable(), + emoji_reactions: z.array( + z.object({ + count: z.number().int().nonnegative(), + me: z.boolean(), + name: z.string(), + url: z.string().url().optional(), + static_url: z.string().url().optional(), + accounts: z.array(z.lazy(() => User.schema)).optional(), + account_ids: z.array(z.string().uuid()).optional(), + }), + ), + quote: z.lazy(() => Note.schema).nullable(), + bookmarked: z.boolean(), + }); + save(): Promise { return this.update(this.data); } diff --git a/packages/database-interface/user.ts b/packages/database-interface/user.ts index d7f15207..a8d96f3d 100644 --- a/packages/database-interface/user.ts +++ b/packages/database-interface/user.ts @@ -65,7 +65,7 @@ import { Role } from "./role"; * Gives helpers to fetch users from database in a nice format */ export class User extends BaseInterface { - static schema = z.object({ + static schema: z.ZodType = z.object({ id: z.string(), username: z.string(), acct: z.string(), @@ -92,12 +92,12 @@ export class User extends BaseInterface { z.object({ name: z.string(), value: z.string(), - verified: z.boolean().nullable().optional(), + verified: z.boolean().optional(), verified_at: z.string().nullable().optional(), }), ), // FIXME: Use a proper type - moved: z.any().nullable(), + moved: z.lazy(() => User.schema).nullable(), bot: z.boolean().nullable(), source: z .object({ @@ -105,6 +105,12 @@ export class User extends BaseInterface { sensitive: z.boolean().nullable(), language: z.string().nullable(), note: z.string(), + fields: z.array( + z.object({ + name: z.string(), + value: z.string(), + }), + ), }) .optional(), role: z