diff --git a/api/api/v1/statuses/:id/context.ts b/api/api/v1/statuses/:id/context.ts index ed152c9c..e9364f3c 100644 --- a/api/api/v1/statuses/:id/context.ts +++ b/api/api/v1/statuses/:id/context.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 { Note } from "~/packages/database-interface/note"; +import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -25,35 +26,63 @@ export const schemas = { }), }; +const route = createRoute({ + method: "get", + path: "/api/v1/statuses/{id}/context", + middleware: [auth(meta.auth, meta.permissions)], + summary: "Get status context", + request: { + params: schemas.param, + }, + responses: { + 200: { + description: "Status context", + content: { + "application/json": { + schema: z.object({ + ancestors: z.array(Note.schema), + descendants: z.array(Note.schema), + }), + }, + }, + }, + 404: { + description: "Record not found", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + }, +}); + 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"); + app.openapi(route, async (context) => { + const { id } = context.req.valid("param"); - const { user } = context.get("auth"); + const { user } = context.get("auth"); - const foundStatus = await Note.fromId(id, user?.id); + const foundStatus = await Note.fromId(id, user?.id); - if (!foundStatus) { - return context.json({ error: "Record not found" }, 404); - } + if (!foundStatus) { + return context.json({ error: "Record not found" }, 404); + } - const ancestors = await foundStatus.getAncestors(user ?? null); + const ancestors = await foundStatus.getAncestors(user ?? null); - const descendants = await foundStatus.getDescendants(user ?? null); + const descendants = await foundStatus.getDescendants(user ?? null); - return context.json({ + return context.json( + { ancestors: await Promise.all( ancestors.map((status) => status.toApi(user)), ), descendants: await Promise.all( descendants.map((status) => status.toApi(user)), ), - }); - }, - ), + }, + 200, + ); + }), ); diff --git a/api/api/v1/statuses/:id/favourite.ts b/api/api/v1/statuses/:id/favourite.ts index 4cec924d..81831fce 100644 --- a/api/api/v1/statuses/:id/favourite.ts +++ b/api/api/v1/statuses/:id/favourite.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 { z } from "zod"; import { createLike } from "~/classes/functions/like"; import { db } from "~/drizzle/db"; import { RolePermissions } from "~/drizzle/schema"; import { Note } from "~/packages/database-interface/note"; +import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -27,46 +28,73 @@ 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 note = await Note.fromId(id, user?.id); - - if (!note?.isViewableByUser(user)) { - return context.json({ error: "Record not found" }, 404); - } - - const existingLike = await db.query.Likes.findFirst({ - where: (like, { and, eq }) => - and( - eq(like.likedId, note.data.id), - eq(like.likerId, user.id), - ), - }); - - if (!existingLike) { - await createLike(user, note); - } - - const newNote = await Note.fromId(id, user.id); - - if (!newNote) { - return context.json({ error: "Record not found" }, 404); - } - - return context.json(await newNote.toApi(user)); +const route = createRoute({ + method: "post", + path: "/api/v1/statuses/{id}/favourite", + summary: "Favourite a status", + middleware: [auth(meta.auth, meta.permissions)], + request: { + params: schemas.param, + }, + responses: { + 200: { + description: "Favourited status", + content: { + "application/json": { + schema: Note.schema, + }, + }, }, - ), + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 404: { + description: "Record 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 note = await Note.fromId(id, user?.id); + + if (!note?.isViewableByUser(user)) { + return context.json({ error: "Record not found" }, 404); + } + + const existingLike = await db.query.Likes.findFirst({ + where: (like, { and, eq }) => + and(eq(like.likedId, note.data.id), eq(like.likerId, user.id)), + }); + + if (!existingLike) { + await createLike(user, note); + } + + const newNote = await Note.fromId(id, user.id); + + if (!newNote) { + return context.json({ error: "Record not found" }, 404); + } + + return context.json(await newNote.toApi(user), 200); + }), ); diff --git a/api/api/v1/statuses/:id/favourited_by.ts b/api/api/v1/statuses/:id/favourited_by.ts index 1dfb9abc..7f7efaf0 100644 --- a/api/api/v1/statuses/:id/favourited_by.ts +++ b/api/api/v1/statuses/:id/favourited_by.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, gt, gte, lt, sql } from "drizzle-orm"; import { z } from "zod"; import { RolePermissions, Users } 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"], @@ -39,48 +35,77 @@ 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 { max_id, since_id, min_id, limit } = - context.req.valid("query"); - const { id } = context.req.valid("param"); - - const { user } = context.get("auth"); - - if (!user) { - return context.json({ error: "Unauthorized" }, 401); - } - - const status = await Note.fromId(id, user?.id); - - if (!status?.isViewableByUser(user)) { - return context.json({ error: "Record 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 "Likes" WHERE "Likes"."likedId" = ${status.id} AND "Likes"."likerId" = ${Users.id})`, - ), - limit, - context.req.url, - ); - - return context.json( - objects.map((user) => user.toApi()), - 200, - { - Link: link, +const route = createRoute({ + method: "get", + path: "/api/v1/statuses/{id}/favourited_by", + summary: "Get users who favourited a status", + middleware: [auth(meta.auth, meta.permissions)], + request: { + params: schemas.param, + query: schemas.query, + }, + responses: { + 200: { + description: "Users who favourited a status", + content: { + "application/json": { + schema: z.array(User.schema), }, - ); + }, }, - ), + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 404: { + description: "Record not found", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + }, +}); + +export default apiRoute((app) => + app.openapi(route, async (context) => { + const { max_id, since_id, min_id, limit } = context.req.valid("query"); + const { id } = context.req.valid("param"); + + const { user } = context.get("auth"); + + if (!user) { + return context.json({ error: "Unauthorized" }, 401); + } + + const status = await Note.fromId(id, user?.id); + + if (!status?.isViewableByUser(user)) { + return context.json({ error: "Record 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 "Likes" WHERE "Likes"."likedId" = ${status.id} AND "Likes"."likerId" = ${Users.id})`, + ), + limit, + context.req.url, + ); + + return context.json( + objects.map((user) => user.toApi()), + 200, + { + Link: link, + }, + ); + }), ); diff --git a/api/api/v1/statuses/:id/index.ts b/api/api/v1/statuses/:id/index.ts index 4f55e443..e7507aab 100644 --- a/api/api/v1/statuses/:id/index.ts +++ b/api/api/v1/statuses/:id/index.ts @@ -1,18 +1,12 @@ -import { - apiRoute, - applyConfig, - auth, - handleZodError, - idValidator, - jsonOrForm, -} from "@/api"; -import { zValidator } from "@hono/zod-validator"; +import { apiRoute, applyConfig, auth, idValidator, jsonOrForm } from "@/api"; +import { createRoute } from "@hono/zod-openapi"; import ISO6391 from "iso-639-1"; import { z } from "zod"; import { RolePermissions } from "~/drizzle/schema"; import { config } from "~/packages/config-manager/index"; import { Attachment } from "~/packages/database-interface/attachment"; import { Note } from "~/packages/database-interface/note"; +import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ allowedMethods: ["GET", "DELETE", "PUT"], @@ -95,89 +89,213 @@ export const schemas = { ), }; -export default apiRoute((app) => - app.on( - meta.allowedMethods, - meta.route, - jsonOrForm(), - 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: Polls - const { - status: statusText, - content_type, - media_ids, - spoiler_text, - sensitive, - } = context.req.valid("json"); - - const note = await Note.fromId(id, user?.id); - - if (!note?.isViewableByUser(user)) { - return context.json({ error: "Record not found" }, 404); - } - - switch (context.req.method) { - case "GET": { - return context.json(await note.toApi(user)); - } - case "DELETE": { - if (note.author.id !== user?.id) { - return context.json({ error: "Unauthorized" }, 401); - } - - // TODO: Delete and redraft - - await note.delete(); - - await user.federateToFollowers(note.deleteToVersia()); - - return context.json(await note.toApi(user), 200); - } - case "PUT": { - if (!user) { - return context.json({ error: "Unauthorized" }, 401); - } - - if (note.author.id !== user.id) { - return context.json({ error: "Unauthorized" }, 401); - } - - if (media_ids.length > 0) { - const foundAttachments = - await Attachment.fromIds(media_ids); - - if (foundAttachments.length !== media_ids.length) { - return context.json( - { error: "Invalid media IDs" }, - 422, - ); - } - } - - const newNote = await note.updateFromData({ - author: user, - content: statusText - ? { - [content_type]: { - content: statusText, - remote: false, - }, - } - : undefined, - isSensitive: sensitive, - spoilerText: spoiler_text, - mediaAttachments: media_ids, - }); - - return context.json(await newNote.toApi(user)); - } - } +const routeGet = createRoute({ + method: "get", + path: "/api/v1/statuses/{id}", + summary: "Get status", + middleware: [auth(meta.auth, meta.permissions)], + request: { + params: schemas.param, + }, + responses: { + 200: { + description: "Status", + content: { + "application/json": { + schema: Note.schema, + }, + }, }, - ), -); + 404: { + description: "Record not found", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + }, +}); + +const routeDelete = createRoute({ + method: "delete", + path: "/api/v1/statuses/{id}", + summary: "Delete a status", + middleware: [auth(meta.auth, meta.permissions)], + request: { + params: schemas.param, + }, + responses: { + 200: { + description: "Deleted status", + content: { + "application/json": { + schema: Note.schema, + }, + }, + }, + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 404: { + description: "Record not found", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + }, +}); + +const routePut = createRoute({ + method: "put", + path: "/api/v1/statuses/{id}", + summary: "Update a status", + middleware: [auth(meta.auth, meta.permissions), jsonOrForm()], + request: { + params: schemas.param, + body: { + content: { + "application/json": { + schema: schemas.json, + }, + "application/x-www-form-urlencoded": { + schema: schemas.json, + }, + "multipart/form-data": { + schema: schemas.json, + }, + }, + }, + }, + responses: { + 200: { + description: "Updated status", + content: { + "application/json": { + schema: Note.schema, + }, + }, + }, + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 404: { + description: "Record not found", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 422: { + description: "Invalid media IDs", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + }, +}); + +export default apiRoute((app) => { + app.openapi(routeGet, async (context) => { + const { id } = context.req.valid("param"); + const { user } = context.get("auth"); + + const note = await Note.fromId(id, user?.id); + + if (!note?.isViewableByUser(user)) { + return context.json({ error: "Record not found" }, 404); + } + + return context.json(await note.toApi(user), 200); + }); + + app.openapi(routeDelete, async (context) => { + const { id } = context.req.valid("param"); + const { user } = context.get("auth"); + + const note = await Note.fromId(id, user?.id); + + if (!note?.isViewableByUser(user)) { + return context.json({ error: "Record not found" }, 404); + } + + if (note.author.id !== user?.id) { + return context.json({ error: "Unauthorized" }, 401); + } + + // TODO: Delete and redraft + await note.delete(); + + await user.federateToFollowers(note.deleteToVersia()); + + return context.json(await note.toApi(user), 200); + }); + + app.openapi(routePut, async (context) => { + const { id } = context.req.valid("param"); + const { user } = context.get("auth"); + + if (!user) { + return context.json({ error: "Unauthorized" }, 401); + } + + const note = await Note.fromId(id, user?.id); + + if (!note?.isViewableByUser(user)) { + return context.json({ error: "Record not found" }, 404); + } + + if (note.author.id !== user.id) { + return context.json({ error: "Unauthorized" }, 401); + } + + // TODO: Polls + const { + status: statusText, + content_type, + media_ids, + spoiler_text, + sensitive, + } = context.req.valid("json"); + + if (media_ids.length > 0) { + const foundAttachments = await Attachment.fromIds(media_ids); + + if (foundAttachments.length !== media_ids.length) { + return context.json({ error: "Invalid media IDs" }, 422); + } + } + + const newNote = await note.updateFromData({ + author: user, + content: statusText + ? { + [content_type]: { + content: statusText, + remote: false, + }, + } + : undefined, + isSensitive: sensitive, + spoilerText: spoiler_text, + mediaAttachments: media_ids, + }); + + return context.json(await newNote.toApi(user), 200); + }); +}); diff --git a/api/api/v1/statuses/:id/pin.ts b/api/api/v1/statuses/:id/pin.ts index a5c31741..4af29ae3 100644 --- a/api/api/v1/statuses/:id/pin.ts +++ b/api/api/v1/statuses/:id/pin.ts @@ -1,15 +1,10 @@ -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 { z } from "zod"; import { db } from "~/drizzle/db"; import { RolePermissions } from "~/drizzle/schema"; import { Note } from "~/packages/database-interface/note"; +import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -32,45 +27,83 @@ 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 foundStatus = await Note.fromId(id, user?.id); - - if (!foundStatus) { - return context.json({ error: "Record not found" }, 404); - } - - if (foundStatus.author.id !== user.id) { - return context.json({ error: "Unauthorized" }, 401); - } - - if ( - await db.query.UserToPinnedNotes.findFirst({ - where: (userPinnedNote, { and, eq }) => - and( - eq(userPinnedNote.noteId, foundStatus.data.id), - eq(userPinnedNote.userId, user.id), - ), - }) - ) { - return context.json({ error: "Already pinned" }, 422); - } - - await user.pin(foundStatus); - - return context.json(await foundStatus.toApi(user)); +const route = createRoute({ + method: "post", + path: "/api/v1/statuses/{id}/pin", + summary: "Pin a status", + middleware: [auth(meta.auth, meta.permissions)], + request: { + params: schemas.param, + }, + responses: { + 200: { + description: "Pinned status", + content: { + "application/json": { + schema: Note.schema, + }, + }, }, - ), + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 404: { + description: "Record not found", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 422: { + description: "Already pinned", + 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 foundStatus = await Note.fromId(id, user?.id); + + if (!foundStatus) { + return context.json({ error: "Record not found" }, 404); + } + + if (foundStatus.author.id !== user.id) { + return context.json({ error: "Unauthorized" }, 401); + } + + if ( + await db.query.UserToPinnedNotes.findFirst({ + where: (userPinnedNote, { and, eq }) => + and( + eq(userPinnedNote.noteId, foundStatus.data.id), + eq(userPinnedNote.userId, user.id), + ), + }) + ) { + return context.json({ error: "Already pinned" }, 422); + } + + await user.pin(foundStatus); + + return context.json(await foundStatus.toApi(user), 200); + }), ); diff --git a/api/api/v1/statuses/:id/reblog.ts b/api/api/v1/statuses/:id/reblog.ts index 101459ea..c0e2c5f8 100644 --- a/api/api/v1/statuses/:id/reblog.ts +++ b/api/api/v1/statuses/:id/reblog.ts @@ -1,10 +1,11 @@ -import { apiRoute, applyConfig, auth, handleZodError, jsonOrForm } from "@/api"; -import { zValidator } from "@hono/zod-validator"; +import { apiRoute, applyConfig, auth, jsonOrForm } from "@/api"; +import { createRoute } from "@hono/zod-openapi"; import { and, eq } from "drizzle-orm"; import { z } from "zod"; import { db } from "~/drizzle/db"; import { Notes, Notifications, RolePermissions } from "~/drizzle/schema"; import { Note } from "~/packages/database-interface/note"; +import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -30,69 +31,126 @@ export const schemas = { }), }; -export default apiRoute((app) => - app.on( - meta.allowedMethods, - meta.route, - jsonOrForm(), - zValidator("param", schemas.param, handleZodError), - zValidator("json", schemas.json, handleZodError), - auth(meta.auth, meta.permissions), - async (context) => { - const { id } = context.req.valid("param"); - const { visibility } = context.req.valid("json"); - const { user } = context.get("auth"); - - if (!user) { - return context.json({ error: "Unauthorized" }, 401); - } - - const foundStatus = await Note.fromId(id, user.id); - - if (!foundStatus?.isViewableByUser(user)) { - return context.json({ error: "Record not found" }, 404); - } - - const existingReblog = await Note.fromSql( - and( - eq(Notes.authorId, user.id), - eq(Notes.reblogId, foundStatus.data.id), - ), - ); - - if (existingReblog) { - return context.json({ error: "Already reblogged" }, 422); - } - - const newReblog = await Note.insert({ - authorId: user.id, - reblogId: foundStatus.data.id, - visibility, - sensitive: false, - updatedAt: new Date().toISOString(), - applicationId: null, - }); - - if (!newReblog) { - return context.json({ error: "Failed to reblog" }, 500); - } - - const finalNewReblog = await Note.fromId(newReblog.id, user?.id); - - if (!finalNewReblog) { - return context.json({ error: "Failed to reblog" }, 500); - } - - if (foundStatus.author.isLocal() && user.isLocal()) { - await db.insert(Notifications).values({ - accountId: user.id, - notifiedId: foundStatus.author.id, - type: "reblog", - noteId: newReblog.data.reblogId, - }); - } - - return context.json(await finalNewReblog.toApi(user)); +const route = createRoute({ + method: "post", + path: "/api/v1/statuses/{id}/reblog", + summary: "Reblog a status", + middleware: [auth(meta.auth, meta.permissions), jsonOrForm()], + request: { + params: schemas.param, + body: { + content: { + "application/json": { + schema: schemas.json, + }, + "application/x-www-form-urlencoded": { + schema: schemas.json, + }, + "multipart/form-data": { + schema: schemas.json, + }, + }, }, - ), + }, + responses: { + 201: { + description: "Reblogged status", + content: { + "application/json": { + schema: Note.schema, + }, + }, + }, + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 404: { + description: "Record not found", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 422: { + description: "Already reblogged", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 500: { + description: "Failed to reblog", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + }, +}); + +export default apiRoute((app) => + app.openapi(route, async (context) => { + const { id } = context.req.valid("param"); + const { visibility } = context.req.valid("json"); + const { user } = context.get("auth"); + + if (!user) { + return context.json({ error: "Unauthorized" }, 401); + } + + const foundStatus = await Note.fromId(id, user.id); + + if (!foundStatus?.isViewableByUser(user)) { + return context.json({ error: "Record not found" }, 404); + } + + const existingReblog = await Note.fromSql( + and( + eq(Notes.authorId, user.id), + eq(Notes.reblogId, foundStatus.data.id), + ), + ); + + if (existingReblog) { + return context.json({ error: "Already reblogged" }, 422); + } + + const newReblog = await Note.insert({ + authorId: user.id, + reblogId: foundStatus.data.id, + visibility, + sensitive: false, + updatedAt: new Date().toISOString(), + applicationId: null, + }); + + if (!newReblog) { + return context.json({ error: "Failed to reblog" }, 500); + } + + const finalNewReblog = await Note.fromId(newReblog.id, user?.id); + + if (!finalNewReblog) { + return context.json({ error: "Failed to reblog" }, 500); + } + + if (foundStatus.author.isLocal() && user.isLocal()) { + await db.insert(Notifications).values({ + accountId: user.id, + notifiedId: foundStatus.author.id, + type: "reblog", + noteId: newReblog.data.reblogId, + }); + } + + return context.json(await finalNewReblog.toApi(user), 201); + }), ); diff --git a/api/api/v1/statuses/:id/reblogged_by.ts b/api/api/v1/statuses/:id/reblogged_by.ts index b598af4e..ffccce02 100644 --- a/api/api/v1/statuses/:id/reblogged_by.ts +++ b/api/api/v1/statuses/:id/reblogged_by.ts @@ -1,10 +1,12 @@ -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 { and, gt, gte, lt, sql } from "drizzle-orm"; import { z } from "zod"; import { RolePermissions, Users } 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"], @@ -33,47 +35,76 @@ 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 { max_id, min_id, since_id, limit } = - context.req.valid("query"); - const { user } = context.get("auth"); - - if (!user) { - return context.json({ error: "Unauthorized" }, 401); - } - - const status = await Note.fromId(id, user.id); - - if (!status?.isViewableByUser(user)) { - return context.json({ error: "Record 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 "Notes" WHERE "Notes"."reblogId" = ${status.id} AND "Notes"."authorId" = ${Users.id})`, - ), - limit, - context.req.url, - ); - - return context.json( - objects.map((user) => user.toApi()), - 200, - { - Link: link, +const route = createRoute({ + method: "get", + path: "/api/v1/statuses/{id}/reblogged_by", + summary: "Get users who reblogged a status", + middleware: [auth(meta.auth, meta.permissions)], + request: { + params: schemas.param, + query: schemas.query, + }, + responses: { + 200: { + description: "Users who reblogged a status", + content: { + "application/json": { + schema: z.array(User.schema), }, - ); + }, }, - ), + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 404: { + description: "Record 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, min_id, since_id, limit } = context.req.valid("query"); + const { user } = context.get("auth"); + + if (!user) { + return context.json({ error: "Unauthorized" }, 401); + } + + const status = await Note.fromId(id, user.id); + + if (!status?.isViewableByUser(user)) { + return context.json({ error: "Record 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 "Notes" WHERE "Notes"."reblogId" = ${status.id} AND "Notes"."authorId" = ${Users.id})`, + ), + limit, + context.req.url, + ); + + return context.json( + objects.map((user) => user.toApi()), + 200, + { + Link: link, + }, + ); + }), ); diff --git a/api/api/v1/statuses/:id/source.ts b/api/api/v1/statuses/:id/source.ts index 8a40ac74..2b2f6b3b 100644 --- a/api/api/v1/statuses/:id/source.ts +++ b/api/api/v1/statuses/:id/source.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 type { StatusSource as ApiStatusSource } from "@versia/client/types"; import { z } from "zod"; import { RolePermissions } from "~/drizzle/schema"; import { Note } from "~/packages/database-interface/note"; +import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -26,32 +27,69 @@ export const schemas = { }), }; +const route = createRoute({ + method: "get", + path: "/api/v1/statuses/{id}/source", + summary: "Get status source", + middleware: [auth(meta.auth, meta.permissions)], + request: { + params: schemas.param, + }, + responses: { + 200: { + description: "Status source", + content: { + "application/json": { + schema: z.object({ + id: z.string().uuid(), + spoiler_text: z.string(), + text: z.string(), + }), + }, + }, + }, + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 404: { + description: "Record not found", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + }, +}); + 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"); + app.openapi(route, async (context) => { + const { id } = context.req.valid("param"); + const { user } = context.get("auth"); - if (!user) { - return context.json({ error: "Unauthorized" }, 401); - } + if (!user) { + return context.json({ error: "Unauthorized" }, 401); + } - const status = await Note.fromId(id, user.id); + const status = await Note.fromId(id, user.id); - if (!status?.isViewableByUser(user)) { - return context.json({ error: "Record not found" }, 404); - } + if (!status?.isViewableByUser(user)) { + return context.json({ error: "Record not found" }, 404); + } - return context.json({ + return context.json( + { id: status.id, // TODO: Give real source for spoilerText spoiler_text: status.data.spoilerText, text: status.data.contentSource, - } as ApiStatusSource); - }, - ), + } satisfies ApiStatusSource, + 200, + ); + }), ); diff --git a/api/api/v1/statuses/:id/unfavourite.ts b/api/api/v1/statuses/:id/unfavourite.ts index 5acc731b..53f57d92 100644 --- a/api/api/v1/statuses/:id/unfavourite.ts +++ b/api/api/v1/statuses/:id/unfavourite.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 { deleteLike } from "~/classes/functions/like"; import { RolePermissions } from "~/drizzle/schema"; import { Note } from "~/packages/database-interface/note"; +import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -26,35 +27,65 @@ 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 note = await Note.fromId(id, user.id); - - if (!note?.isViewableByUser(user)) { - return context.json({ error: "Record not found" }, 404); - } - - await deleteLike(user, note); - - const newNote = await Note.fromId(id, user.id); - - if (!newNote) { - return context.json({ error: "Record not found" }, 404); - } - - return context.json(await newNote.toApi(user)); +const route = createRoute({ + method: "post", + path: "/api/v1/statuses/{id}/unfavourite", + summary: "Unfavourite a status", + middleware: [auth(meta.auth, meta.permissions)], + request: { + params: schemas.param, + }, + responses: { + 200: { + description: "Unfavourited status", + content: { + "application/json": { + schema: Note.schema, + }, + }, }, - ), + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 404: { + description: "Record 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 note = await Note.fromId(id, user.id); + + if (!note?.isViewableByUser(user)) { + return context.json({ error: "Record not found" }, 404); + } + + await deleteLike(user, note); + + const newNote = await Note.fromId(id, user.id); + + if (!newNote) { + return context.json({ error: "Record not found" }, 404); + } + + return context.json(await newNote.toApi(user), 200); + }), ); diff --git a/api/api/v1/statuses/:id/unpin.ts b/api/api/v1/statuses/:id/unpin.ts index d62777c3..1009ff87 100644 --- a/api/api/v1/statuses/:id/unpin.ts +++ b/api/api/v1/statuses/:id/unpin.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 { Note } from "~/packages/database-interface/note"; +import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -25,37 +26,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 status = await Note.fromId(id, user.id); - - if (!status) { - return context.json({ error: "Record not found" }, 404); - } - - if (status.author.id !== user.id) { - return context.json({ error: "Unauthorized" }, 401); - } - - await user.unpin(status); - - if (!status) { - return context.json({ error: "Record not found" }, 404); - } - - return context.json(await status.toApi(user)); +const route = createRoute({ + method: "post", + path: "/api/v1/statuses/{id}/unpin", + summary: "Unpin a status", + middleware: [auth(meta.auth, meta.permissions)], + request: { + params: schemas.param, + }, + responses: { + 200: { + description: "Unpinned status", + content: { + "application/json": { + schema: Note.schema, + }, + }, }, - ), + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 404: { + description: "Record 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 status = await Note.fromId(id, user.id); + + if (!status) { + return context.json({ error: "Record not found" }, 404); + } + + if (status.author.id !== user.id) { + return context.json({ error: "Unauthorized" }, 401); + } + + await user.unpin(status); + + if (!status) { + return context.json({ error: "Record not found" }, 404); + } + + return context.json(await status.toApi(user), 200); + }), ); diff --git a/api/api/v1/statuses/:id/unreblog.ts b/api/api/v1/statuses/:id/unreblog.ts index d464392b..ca393074 100644 --- a/api/api/v1/statuses/:id/unreblog.ts +++ b/api/api/v1/statuses/:id/unreblog.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 { and, eq } from "drizzle-orm"; import { z } from "zod"; import { Notes, RolePermissions } from "~/drizzle/schema"; import { Note } from "~/packages/database-interface/note"; +import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -26,51 +27,89 @@ 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 foundStatus = await Note.fromId(id, user.id); - - // Check if user is authorized to view this status (if it's private) - if (!foundStatus?.isViewableByUser(user)) { - return context.json({ error: "Record not found" }, 404); - } - - const existingReblog = await Note.fromSql( - and( - eq(Notes.authorId, user.id), - eq(Notes.reblogId, foundStatus.data.id), - ), - undefined, - user?.id, - ); - - if (!existingReblog) { - return context.json({ error: "Not already reblogged" }, 422); - } - - await existingReblog.delete(); - - await user.federateToFollowers(existingReblog.deleteToVersia()); - - const newNote = await Note.fromId(id, user.id); - - if (!newNote) { - return context.json({ error: "Record not found" }, 404); - } - - return context.json(await newNote.toApi(user)); +const route = createRoute({ + method: "post", + path: "/api/v1/statuses/{id}/unreblog", + summary: "Unreblog a status", + middleware: [auth(meta.auth, meta.permissions)], + request: { + params: schemas.param, + }, + responses: { + 200: { + description: "Unreblogged status", + content: { + "application/json": { + schema: Note.schema, + }, + }, }, - ), + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 404: { + description: "Record not found", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 422: { + description: "Not already reblogged", + 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 foundStatus = await Note.fromId(id, user.id); + + // Check if user is authorized to view this status (if it's private) + if (!foundStatus?.isViewableByUser(user)) { + return context.json({ error: "Record not found" }, 404); + } + + const existingReblog = await Note.fromSql( + and( + eq(Notes.authorId, user.id), + eq(Notes.reblogId, foundStatus.data.id), + ), + undefined, + user?.id, + ); + + if (!existingReblog) { + return context.json({ error: "Not already reblogged" }, 422); + } + + await existingReblog.delete(); + + await user.federateToFollowers(existingReblog.deleteToVersia()); + + const newNote = await Note.fromId(id, user.id); + + if (!newNote) { + return context.json({ error: "Record not found" }, 404); + } + + return context.json(await newNote.toApi(user), 200); + }), ); diff --git a/api/api/v1/statuses/index.test.ts b/api/api/v1/statuses/index.test.ts index b15286ae..3ae2106d 100644 --- a/api/api/v1/statuses/index.test.ts +++ b/api/api/v1/statuses/index.test.ts @@ -154,7 +154,7 @@ describe(meta.route, () => { }), }); - expect(response.status).toBe(200); + expect(response.status).toBe(201); expect(response.headers.get("content-type")).toContain( "application/json", ); @@ -179,7 +179,7 @@ describe(meta.route, () => { }), }); - expect(response.status).toBe(200); + expect(response.status).toBe(201); expect(response.headers.get("content-type")).toContain( "application/json", ); @@ -216,7 +216,7 @@ describe(meta.route, () => { }), }); - expect(response2.status).toBe(200); + expect(response2.status).toBe(201); expect(response2.headers.get("content-type")).toContain( "application/json", ); @@ -253,7 +253,7 @@ describe(meta.route, () => { }), }); - expect(response2.status).toBe(200); + expect(response2.status).toBe(201); expect(response2.headers.get("content-type")).toContain( "application/json", ); @@ -276,7 +276,7 @@ describe(meta.route, () => { }), }); - expect(response.status).toBe(200); + expect(response.status).toBe(201); expect(response.headers.get("content-type")).toContain( "application/json", ); @@ -303,7 +303,7 @@ describe(meta.route, () => { }), }); - expect(response.status).toBe(200); + expect(response.status).toBe(201); expect(response.headers.get("content-type")).toContain( "application/json", ); @@ -332,7 +332,7 @@ describe(meta.route, () => { }), }); - expect(response.status).toBe(200); + expect(response.status).toBe(201); expect(response.headers.get("content-type")).toContain( "application/json", ); @@ -361,7 +361,7 @@ describe(meta.route, () => { }), }); - expect(response.status).toBe(200); + expect(response.status).toBe(201); expect(response.headers.get("content-type")).toContain( "application/json", ); @@ -387,7 +387,7 @@ describe(meta.route, () => { }), }); - expect(response.status).toBe(200); + expect(response.status).toBe(201); expect(response.headers.get("content-type")).toContain( "application/json", ); @@ -411,7 +411,7 @@ describe(meta.route, () => { }), }); - expect(response.status).toBe(200); + expect(response.status).toBe(201); expect(response.headers.get("content-type")).toContain( "application/json", ); diff --git a/api/api/v1/statuses/index.ts b/api/api/v1/statuses/index.ts index af3eaa8c..ee3f3485 100644 --- a/api/api/v1/statuses/index.ts +++ b/api/api/v1/statuses/index.ts @@ -1,11 +1,12 @@ -import { apiRoute, applyConfig, auth, handleZodError, jsonOrForm } from "@/api"; -import { zValidator } from "@hono/zod-validator"; +import { apiRoute, applyConfig, auth, jsonOrForm } from "@/api"; +import { createRoute } from "@hono/zod-openapi"; import ISO6391 from "iso-639-1"; import { z } from "zod"; import { RolePermissions } from "~/drizzle/schema"; import { config } from "~/packages/config-manager/index"; import { Attachment } from "~/packages/database-interface/attachment"; import { Note } from "~/packages/database-interface/note"; +import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -100,78 +101,116 @@ export const schemas = { ), }; -export default apiRoute((app) => - app.on( - meta.allowedMethods, - meta.route, - jsonOrForm(), - zValidator("json", schemas.json, handleZodError), - auth(meta.auth, meta.permissions), - async (context) => { - const { user, application } = context.get("auth"); - - if (!user) { - return context.json({ error: "Unauthorized" }, 401); - } - - const { - status, - media_ids, - in_reply_to_id, - quote_id, - sensitive, - spoiler_text, - visibility, - content_type, - local_only, - } = context.req.valid("json"); - - // Check if media attachments are all valid - if (media_ids.length > 0) { - const foundAttachments = await Attachment.fromIds(media_ids); - - if (foundAttachments.length !== media_ids.length) { - return context.json({ error: "Invalid media IDs" }, 422); - } - } - - // Check that in_reply_to_id and quote_id are real posts if provided - if (in_reply_to_id && !(await Note.fromId(in_reply_to_id))) { - return context.json( - { error: "Invalid in_reply_to_id (not found)" }, - 422, - ); - } - - if (quote_id && !(await Note.fromId(quote_id))) { - return context.json( - { error: "Invalid quote_id (not found)" }, - 422, - ); - } - - const newNote = await Note.fromData({ - author: user, - content: { - [content_type]: { - content: status ?? "", - remote: false, - }, +const route = createRoute({ + method: "post", + path: "/api/v1/statuses", + middleware: [auth(meta.auth, meta.permissions), jsonOrForm()], + summary: "Post a new status", + request: { + body: { + content: { + "application/json": { + schema: schemas.json, }, - visibility, - isSensitive: sensitive ?? false, - spoilerText: spoiler_text ?? "", - mediaAttachments: media_ids, - replyId: in_reply_to_id ?? undefined, - quoteId: quote_id ?? undefined, - application: application ?? undefined, - }); - - if (!local_only) { - await newNote.federateToUsers(); - } - - return context.json(await newNote.toApi(user)); + "application/x-www-form-urlencoded": { + schema: schemas.json, + }, + "multipart/form-data": { + schema: schemas.json, + }, + }, }, - ), + }, + responses: { + 201: { + description: "The new status", + content: { + "application/json": { + schema: Note.schema, + }, + }, + }, + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 422: { + description: "Invalid data", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + }, +}); + +export default apiRoute((app) => + app.openapi(route, async (context) => { + const { user, application } = context.get("auth"); + + if (!user) { + return context.json({ error: "Unauthorized" }, 401); + } + + const { + status, + media_ids, + in_reply_to_id, + quote_id, + sensitive, + spoiler_text, + visibility, + content_type, + local_only, + } = context.req.valid("json"); + + // Check if media attachments are all valid + if (media_ids.length > 0) { + const foundAttachments = await Attachment.fromIds(media_ids); + + if (foundAttachments.length !== media_ids.length) { + return context.json({ error: "Invalid media IDs" }, 422); + } + } + + // Check that in_reply_to_id and quote_id are real posts if provided + if (in_reply_to_id && !(await Note.fromId(in_reply_to_id))) { + return context.json( + { error: "Invalid in_reply_to_id (not found)" }, + 422, + ); + } + + if (quote_id && !(await Note.fromId(quote_id))) { + return context.json({ error: "Invalid quote_id (not found)" }, 422); + } + + const newNote = await Note.fromData({ + author: user, + content: { + [content_type]: { + content: status ?? "", + remote: false, + }, + }, + visibility, + isSensitive: sensitive ?? false, + spoilerText: spoiler_text ?? "", + mediaAttachments: media_ids, + replyId: in_reply_to_id ?? undefined, + quoteId: quote_id ?? undefined, + application: application ?? undefined, + }); + + if (!local_only) { + await newNote.federateToUsers(); + } + + return context.json(await newNote.toApi(user), 201); + }), );