From 6a810529bcd1d19f6b1bc69cf8d6fa8cc27076ad Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Fri, 14 Feb 2025 17:49:34 +0100 Subject: [PATCH] refactor(api): :label: Finish OpenAPI documentation refactor --- api/api/v1/instance/index.ts | 3 +- api/api/v1/statuses/:id/reblog.ts | 9 +- api/api/v1/timelines/home.ts | 58 ++++++++--- api/api/v1/timelines/public.ts | 89 +++++++++++----- api/api/v2/filters/:id/index.ts | 151 +++++++++++++-------------- api/api/v2/filters/index.ts | 121 +++++++++------------- api/api/v2/instance/index.ts | 165 +++++++++--------------------- api/api/v2/media/index.ts | 62 +++++++---- api/api/v2/search/index.ts | 101 +++++++++++++----- classes/schemas/filters.ts | 42 +++++--- package.json | 2 +- tests/api/statuses.test.ts | 4 +- 12 files changed, 428 insertions(+), 379 deletions(-) diff --git a/api/api/v1/instance/index.ts b/api/api/v1/instance/index.ts index 963ea4b9..09fd6710 100644 --- a/api/api/v1/instance/index.ts +++ b/api/api/v1/instance/index.ts @@ -46,6 +46,7 @@ export default apiRoute((app) => const userCount = await User.getCount(); + // Get first admin, or first user if no admin exists const contactAccount = (await User.fromSql( and(isNull(Users.instanceId), eq(Users.isAdmin, true)), @@ -138,7 +139,7 @@ export default apiRoute((app) => id: p.id, })) ?? [], }, - contact_account: (contactAccount as User).toApi(), + contact_account: (contactAccount as User)?.toApi(), } satisfies z.infer); }), ); diff --git a/api/api/v1/statuses/:id/reblog.ts b/api/api/v1/statuses/:id/reblog.ts index c1b84cfe..6bc451e0 100644 --- a/api/api/v1/statuses/:id/reblog.ts +++ b/api/api/v1/statuses/:id/reblog.ts @@ -97,10 +97,17 @@ export default apiRoute((app) => applicationId: null, }); + // Refetch the note *again* to get the proper value of .reblogged + const finalNewReblog = await Note.fromId(newReblog.id, user?.id); + + if (!finalNewReblog) { + throw new Error("Failed to reblog"); + } + if (note.author.isLocal() && user.isLocal()) { await note.author.notify("reblog", user, newReblog); } - return context.json(await newReblog.toApi(user), 200); + return context.json(await finalNewReblog.toApi(user), 200); }), ); diff --git a/api/api/v1/timelines/home.ts b/api/api/v1/timelines/home.ts index 68d302e0..3179f0b2 100644 --- a/api/api/v1/timelines/home.ts +++ b/api/api/v1/timelines/home.ts @@ -1,23 +1,19 @@ -import { apiRoute, auth } from "@/api"; +import { apiRoute, auth, reusedResponses } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { Timeline } from "@versia/kit/db"; import { Notes, RolePermissions } from "@versia/kit/tables"; import { and, eq, gt, gte, inArray, lt, or, sql } from "drizzle-orm"; -import { Status } from "~/classes/schemas/status"; - -const schemas = { - query: z.object({ - max_id: z.string().uuid().optional(), - since_id: z.string().uuid().optional(), - min_id: z.string().uuid().optional(), - limit: z.coerce.number().int().min(1).max(80).default(20), - }), -}; +import { Status as StatusSchema } from "~/classes/schemas/status"; const route = createRoute({ method: "get", path: "/api/v1/timelines/home", - summary: "Get home timeline", + summary: "View home timeline", + description: "View statuses from followed users and hashtags.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/timelines/#home", + }, + tags: ["Timelines"], middleware: [ auth({ auth: true, @@ -30,17 +26,49 @@ const route = createRoute({ }), ] as const, request: { - query: schemas.query, + query: z.object({ + max_id: StatusSchema.shape.id.optional().openapi({ + description: + "All results returned will be lesser than this ID. In effect, sets an upper bound on results.", + example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa", + }), + since_id: StatusSchema.shape.id.optional().openapi({ + description: + "All results returned will be greater than this ID. In effect, sets a lower bound on results.", + example: undefined, + }), + min_id: StatusSchema.shape.id.optional().openapi({ + description: + "Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.", + example: undefined, + }), + limit: z.coerce.number().int().min(1).max(40).default(20).openapi({ + description: "Maximum number of results to return.", + }), + }), }, responses: { 200: { - description: "Home timeline", + description: "Statuses in your home timeline will be returned", content: { "application/json": { - schema: z.array(Status), + schema: z.array(StatusSchema), }, }, + headers: z.object({ + link: z + .string() + .optional() + .openapi({ + description: "Links to the next and previous pages", + example: `; rel="next", ; rel="prev"`, + externalDocs: { + url: "https://docs.joinmastodon.org/api/guidelines/#pagination", + }, + }), + }), }, + 422: reusedResponses[422], }, }); diff --git a/api/api/v1/timelines/public.ts b/api/api/v1/timelines/public.ts index 2004cf11..9143dda5 100644 --- a/api/api/v1/timelines/public.ts +++ b/api/api/v1/timelines/public.ts @@ -1,35 +1,20 @@ -import { apiRoute, auth } from "@/api"; +import { apiRoute, auth, reusedResponses } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { Timeline } from "@versia/kit/db"; import { Notes, RolePermissions } from "@versia/kit/tables"; import { and, eq, gt, gte, inArray, lt, or, sql } from "drizzle-orm"; -import { Status } from "~/classes/schemas/status"; - -const schemas = { - query: z.object({ - max_id: z.string().uuid().optional(), - since_id: z.string().uuid().optional(), - min_id: z.string().uuid().optional(), - limit: z.coerce.number().int().min(1).max(80).default(20), - local: z - .string() - .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) - .optional(), - remote: z - .string() - .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) - .optional(), - only_media: z - .string() - .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) - .optional(), - }), -}; +import { Status as StatusSchema } from "~/classes/schemas/status"; +import { zBoolean } from "~/packages/config-manager/config.type"; const route = createRoute({ method: "get", path: "/api/v1/timelines/public", - summary: "Get public timeline", + summary: "View public timeline", + description: "View public statuses.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/timelines/#public", + }, + tags: ["Timelines"], middleware: [ auth({ auth: false, @@ -41,17 +26,69 @@ const route = createRoute({ }), ] as const, request: { - query: schemas.query, + query: z + .object({ + max_id: StatusSchema.shape.id.optional().openapi({ + description: + "All results returned will be lesser than this ID. In effect, sets an upper bound on results.", + example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa", + }), + since_id: StatusSchema.shape.id.optional().openapi({ + description: + "All results returned will be greater than this ID. In effect, sets a lower bound on results.", + example: undefined, + }), + min_id: StatusSchema.shape.id.optional().openapi({ + description: + "Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.", + example: undefined, + }), + local: zBoolean.default(false).openapi({ + description: "Show only local statuses?", + }), + remote: zBoolean.default(false).openapi({ + description: "Show only remote statuses?", + }), + only_media: zBoolean.default(false).openapi({ + description: "Show only statuses with media attached?", + }), + limit: z.coerce + .number() + .int() + .min(1) + .max(40) + .default(20) + .openapi({ + description: "Maximum number of results to return.", + }), + }) + .refine( + (o) => !(o.local && o.remote), + "'local' and 'remote' cannot be both true", + ), }, responses: { 200: { description: "Public timeline", content: { "application/json": { - schema: z.array(Status), + schema: z.array(StatusSchema), }, }, + headers: z.object({ + link: z + .string() + .optional() + .openapi({ + description: "Links to the next and previous pages", + example: `; rel="next", ; rel="prev"`, + externalDocs: { + url: "https://docs.joinmastodon.org/api/guidelines/#pagination", + }, + }), + }), }, + 422: reusedResponses[422], }, }); diff --git a/api/api/v2/filters/:id/index.ts b/api/api/v2/filters/:id/index.ts index 84242575..17094669 100644 --- a/api/api/v2/filters/:id/index.ts +++ b/api/api/v2/filters/:id/index.ts @@ -1,79 +1,24 @@ -import { apiRoute, auth, jsonOrForm } from "@/api"; +import { apiRoute, auth, jsonOrForm, reusedResponses } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { db } from "@versia/kit/db"; import { FilterKeywords, Filters, RolePermissions } from "@versia/kit/tables"; import { type SQL, and, eq, inArray } from "drizzle-orm"; import { ApiError } from "~/classes/errors/api-error"; +import { + FilterKeyword as FilterKeywordSchema, + Filter as FilterSchema, +} from "~/classes/schemas/filters"; +import { zBoolean } from "~/packages/config-manager/config.type"; import { ErrorSchema } from "~/types/api"; -const schemas = { - param: z.object({ - id: z.string().uuid(), - }), - json: z.object({ - title: z.string().trim().min(1).max(100).optional(), - context: z - .array( - z.enum([ - "home", - "notifications", - "public", - "thread", - "account", - ]), - ) - .optional(), - filter_action: z.enum(["warn", "hide"]).optional().default("warn"), - expires_in: z.coerce - .number() - .int() - .min(60) - .max(60 * 60 * 24 * 365 * 5) - .optional(), - keywords_attributes: z - .array( - z.object({ - keyword: z.string().trim().min(1).max(100).optional(), - id: z.string().uuid().optional(), - whole_word: z - .string() - .transform((v) => - ["true", "1", "on"].includes(v.toLowerCase()), - ) - .optional(), - // biome-ignore lint/style/useNamingConvention: _destroy is a Mastodon API imposed variable name - _destroy: z - .string() - .transform((v) => - ["true", "1", "on"].includes(v.toLowerCase()), - ) - .optional(), - }), - ) - .optional(), - }), -}; - -const filterSchema = z.object({ - id: z.string(), - title: z.string(), - context: z.array(z.string()), - expires_at: z.string().nullable(), - filter_action: z.enum(["warn", "hide"]), - keywords: z.array( - z.object({ - id: z.string(), - keyword: z.string(), - whole_word: z.boolean(), - }), - ), - statuses: z.array(z.string()), -}); - const routeGet = createRoute({ method: "get", path: "/api/v2/filters/{id}", - summary: "Get filter", + summary: "View a specific filter", + externalDocs: { + url: "Obtain a single filter group owned by the current user.", + }, + tags: ["Filters"], middleware: [ auth({ auth: true, @@ -81,18 +26,19 @@ const routeGet = createRoute({ }), ] as const, request: { - params: schemas.param, + params: z.object({ + id: FilterSchema.shape.id, + }), }, responses: { 200: { description: "Filter", content: { "application/json": { - schema: filterSchema, + schema: FilterSchema, }, }, }, - 404: { description: "Filter not found", content: { @@ -101,13 +47,19 @@ const routeGet = createRoute({ }, }, }, + 401: reusedResponses[401], }, }); const routePut = createRoute({ method: "put", path: "/api/v2/filters/{id}", - summary: "Update filter", + summary: "Update a filter", + description: "Update a filter group with the given parameters.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/filters/#update", + }, + tags: ["Filters"], middleware: [ auth({ auth: true, @@ -116,11 +68,45 @@ const routePut = createRoute({ jsonOrForm(), ] as const, request: { - params: schemas.param, + params: z.object({ + id: FilterSchema.shape.id, + }), body: { content: { "application/json": { - schema: schemas.json, + schema: z + .object({ + context: FilterSchema.shape.context, + title: FilterSchema.shape.title, + filter_action: FilterSchema.shape.filter_action, + expires_in: z.coerce + .number() + .int() + .min(60) + .max(60 * 60 * 24 * 365 * 5) + .openapi({ + description: + "How many seconds from now should the filter expire?", + }), + keywords_attributes: z.array( + FilterKeywordSchema.pick({ + keyword: true, + whole_word: true, + id: true, + }) + .extend({ + // biome-ignore lint/style/useNamingConvention: _destroy is a Mastodon API imposed variable name + _destroy: zBoolean + .default(false) + .openapi({ + description: + "If true, will remove the keyword with the given ID.", + }), + }) + .partial(), + ), + }) + .partial(), }, }, }, @@ -130,11 +116,10 @@ const routePut = createRoute({ description: "Filter updated", content: { "application/json": { - schema: filterSchema, + schema: FilterSchema, }, }, }, - 404: { description: "Filter not found", content: { @@ -143,13 +128,19 @@ const routePut = createRoute({ }, }, }, + ...reusedResponses, }, }); const routeDelete = createRoute({ method: "delete", path: "/api/v2/filters/{id}", - summary: "Delete filter", + summary: "Delete a filter", + description: "Delete a filter group with the given id.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/filters/#delete", + }, + tags: ["Filters"], middleware: [ auth({ auth: true, @@ -157,13 +148,14 @@ const routeDelete = createRoute({ }), ] as const, request: { - params: schemas.param, + params: z.object({ + id: FilterSchema.shape.id, + }), }, responses: { - 204: { - description: "Filter deleted", + 200: { + description: "Filter successfully deleted", }, - 404: { description: "Filter not found", content: { @@ -172,6 +164,7 @@ const routeDelete = createRoute({ }, }, }, + 401: reusedResponses[401], }, }); diff --git a/api/api/v2/filters/index.ts b/api/api/v2/filters/index.ts index 2d609c5f..97b28d44 100644 --- a/api/api/v2/filters/index.ts +++ b/api/api/v2/filters/index.ts @@ -1,66 +1,22 @@ -import { apiRoute, auth, jsonOrForm } from "@/api"; +import { apiRoute, auth, jsonOrForm, reusedResponses } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { db } from "@versia/kit/db"; import { FilterKeywords, Filters, RolePermissions } from "@versia/kit/tables"; import type { SQL } from "drizzle-orm"; - -const schemas = { - json: z.object({ - title: z.string().trim().min(1).max(100), - context: z - .array( - z.enum([ - "home", - "notifications", - "public", - "thread", - "account", - ]), - ) - .min(1), - filter_action: z.enum(["warn", "hide"]).optional().default("warn"), - expires_in: z.coerce - .number() - .int() - .min(60) - .max(60 * 60 * 24 * 365 * 5) - .optional(), - keywords_attributes: z - .array( - z.object({ - keyword: z.string().trim().min(1).max(100), - whole_word: z - .string() - .transform((v) => - ["true", "1", "on"].includes(v.toLowerCase()), - ) - .optional(), - }), - ) - .optional(), - }), -}; - -const filterSchema = z.object({ - id: z.string(), - title: z.string(), - context: z.array(z.string()), - expires_at: z.string().nullable(), - filter_action: z.enum(["warn", "hide"]), - keywords: z.array( - z.object({ - id: z.string(), - keyword: z.string(), - whole_word: z.boolean(), - }), - ), - statuses: z.array(z.string()), -}); +import { + FilterKeyword as FilterKeywordSchema, + Filter as FilterSchema, +} from "~/classes/schemas/filters"; const routeGet = createRoute({ method: "get", path: "/api/v2/filters", - summary: "Get filters", + summary: "View all filters", + description: "Obtain a list of all filter groups for the current user.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/filters/#get", + }, + tags: ["Filters"], middleware: [ auth({ auth: true, @@ -73,17 +29,23 @@ const routeGet = createRoute({ description: "Filters", content: { "application/json": { - schema: z.array(filterSchema), + schema: z.array(FilterSchema), }, }, }, + 401: reusedResponses[401], }, }); const routePost = createRoute({ method: "post", path: "/api/v2/filters", - summary: "Create filter", + summary: "Create a filter", + description: "Create a filter group with the given parameters.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/filters/#create", + }, + tags: ["Filters"], middleware: [ auth({ auth: true, @@ -95,20 +57,43 @@ const routePost = createRoute({ body: { content: { "application/json": { - schema: schemas.json, + schema: z.object({ + context: FilterSchema.shape.context, + title: FilterSchema.shape.title, + filter_action: FilterSchema.shape.filter_action, + expires_in: z.coerce + .number() + .int() + .min(60) + .max(60 * 60 * 24 * 365 * 5) + .optional() + .openapi({ + description: + "How many seconds from now should the filter expire?", + }), + keywords_attributes: z + .array( + FilterKeywordSchema.pick({ + keyword: true, + whole_word: true, + }), + ) + .optional(), + }), }, }, }, }, responses: { 200: { - description: "Filter created", + description: "Created filter", content: { "application/json": { - schema: filterSchema, + schema: FilterSchema, }, }, }, + ...reusedResponses, }, }); @@ -158,8 +143,8 @@ export default apiRoute((app) => { await db .insert(Filters) .values({ - title: title ?? "", - context: ctx ?? [], + title: title, + context: ctx, filterAction: filter_action, expireAt: new Date( Date.now() + (expires_in ?? 0), @@ -202,18 +187,6 @@ export default apiRoute((app) => { whole_word: keyword.wholeWord, })), statuses: [], - } as { - id: string; - title: string; - context: string[]; - expires_at: string; - filter_action: "warn" | "hide"; - keywords: { - id: string; - keyword: string; - whole_word: boolean; - }[]; - statuses: []; }, 200, ); diff --git a/api/api/v2/instance/index.ts b/api/api/v2/instance/index.ts index 94ec504b..b02cd3e3 100644 --- a/api/api/v2/instance/index.ts +++ b/api/api/v2/instance/index.ts @@ -1,109 +1,28 @@ import { apiRoute } from "@/api"; import { proxyUrl } from "@/response"; -import { createRoute, z } from "@hono/zod-openapi"; +import { createRoute } from "@hono/zod-openapi"; import { User } from "@versia/kit/db"; import { Users } from "@versia/kit/tables"; import { and, eq, isNull } from "drizzle-orm"; -import { Account } from "~/classes/schemas/account"; -import manifest from "~/package.json"; +import { Instance as InstanceSchema } from "~/classes/schemas/instance"; +import pkg from "~/package.json"; import { config } from "~/packages/config-manager"; const route = createRoute({ method: "get", path: "/api/v2/instance", - summary: "Get instance metadata", + summary: "View server information", + description: "Obtain general information about the server.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/instance/#v2", + }, + tags: ["Instance"], responses: { 200: { - description: "Instance metadata", + description: "Server information", content: { "application/json": { - schema: z.object({ - domain: z.string(), - title: z.string(), - version: z.string(), - versia_version: z.string(), - source_url: z.string(), - description: z.string(), - usage: z.object({ - users: z.object({ - active_month: z.number(), - }), - }), - thumbnail: z.object({ - url: z.string().nullable(), - }), - banner: z.object({ - url: z.string().nullable(), - }), - languages: z.array(z.string()), - configuration: z.object({ - urls: z.object({ - streaming: z.string().nullable(), - status: z.string().nullable(), - }), - accounts: z.object({ - max_featured_tags: z.number(), - max_displayname_characters: z.number(), - avatar_size_limit: z.number(), - header_size_limit: z.number(), - max_fields_name_characters: z.number(), - max_fields_value_characters: z.number(), - max_fields: z.number(), - max_username_characters: z.number(), - max_note_characters: z.number(), - }), - statuses: z.object({ - max_characters: z.number(), - max_media_attachments: z.number(), - characters_reserved_per_url: z.number(), - }), - media_attachments: z.object({ - supported_mime_types: z.array(z.string()), - image_size_limit: z.number(), - image_matrix_limit: z.number(), - video_size_limit: z.number(), - video_frame_rate_limit: z.number(), - video_matrix_limit: z.number(), - max_description_characters: z.number(), - }), - polls: z.object({ - max_characters_per_option: z.number(), - max_expiration: z.number(), - max_options: z.number(), - min_expiration: z.number(), - }), - translation: z.object({ - enabled: z.boolean(), - }), - }), - registrations: z.object({ - enabled: z.boolean(), - approval_required: z.boolean(), - message: z.string().nullable(), - url: z.string().nullable(), - }), - contact: z.object({ - email: z.string().nullable(), - account: Account.nullable(), - }), - rules: z.array( - z.object({ - id: z.string(), - text: z.string(), - hint: z.string(), - }), - ), - sso: z.object({ - forced: z.boolean(), - providers: z.array( - z.object({ - name: z.string(), - icon: z.string(), - id: z.string(), - }), - ), - }), - }), + schema: InstanceSchema, }, }, }, @@ -112,12 +31,11 @@ const route = createRoute({ export default apiRoute((app) => app.openapi(route, async (context) => { - // Get software version from package.json - const version = manifest.version; - - const contactAccount = await User.fromSql( - and(isNull(Users.instanceId), eq(Users.isAdmin, true)), - ); + // Get first admin, or first user if no admin exists + const contactAccount = + (await User.fromSql( + and(isNull(Users.instanceId), eq(Users.isAdmin, true)), + )) ?? (await User.fromSql(isNull(Users.instanceId))); const monthlyActiveUsers = await User.getActiveInPeriod( 30 * 24 * 60 * 60 * 1000, @@ -139,44 +57,55 @@ export default apiRoute((app) => domain: config.http.base_url.hostname, title: config.instance.name, version: "4.3.0-alpha.3+glitch", - versia_version: version, - source_url: "https://github.com/versia-pub/server", + versia_version: pkg.version, + source_url: pkg.repository.url, description: config.instance.description, usage: { users: { active_month: monthlyActiveUsers, }, }, + api_versions: { + mastodon: 1, + }, thumbnail: { url: config.instance.logo - ? proxyUrl(config.instance.logo) - : null, + ? proxyUrl(config.instance.logo).toString() + : pkg.icon, }, banner: { url: config.instance.banner - ? proxyUrl(config.instance.banner) + ? proxyUrl(config.instance.banner).toString() : null, }, + icon: [], languages: ["en"], configuration: { urls: { - streaming: null, - status: null, + // TODO: Implement Streaming API + streaming: "", + }, + vapid: { + // TODO: Fill in vapid values + public_key: "", }, accounts: { max_featured_tags: 100, max_displayname_characters: config.validation.max_displayname_size, - avatar_size_limit: config.validation.max_avatar_size, - header_size_limit: config.validation.max_header_size, - max_fields_name_characters: - config.validation.max_field_name_size, - max_fields_value_characters: - config.validation.max_field_value_size, - max_fields: config.validation.max_field_count, + avatar_limit: config.validation.max_avatar_size, + header_limit: config.validation.max_header_size, max_username_characters: config.validation.max_username_size, max_note_characters: config.validation.max_bio_size, + max_pinned_statuses: 100, + fields: { + max_fields: config.validation.max_field_count, + max_name_characters: + config.validation.max_field_name_size, + max_value_characters: + config.validation.max_field_value_size, + }, }, statuses: { max_characters: config.validation.max_note_size, @@ -191,14 +120,14 @@ export default apiRoute((app) => video_size_limit: config.validation.max_media_size, video_frame_rate_limit: config.validation.max_media_size, video_matrix_limit: config.validation.max_media_size, - max_description_characters: + description_limit: config.validation.max_media_description_size, }, emojis: { emoji_size_limit: config.validation.max_emoji_size, - max_emoji_shortcode_characters: + max_shortcode_characters: config.validation.max_emoji_shortcode_size, - max_emoji_description_characters: + max_description_characters: config.validation.max_emoji_description_size, }, polls: { @@ -216,11 +145,11 @@ export default apiRoute((app) => enabled: config.signups.registration, approval_required: false, message: null, - url: null, }, contact: { - email: contactAccount?.data.email || null, - account: contactAccount?.toApi() || null, + // TODO: Add contact email + email: "", + account: (contactAccount as User)?.toApi(), }, rules: config.signups.rules.map((rule, index) => ({ id: String(index), diff --git a/api/api/v2/media/index.ts b/api/api/v2/media/index.ts index 44f06e05..5d6d9e7d 100644 --- a/api/api/v2/media/index.ts +++ b/api/api/v2/media/index.ts @@ -1,27 +1,20 @@ -import { apiRoute, auth } from "@/api"; +import { apiRoute, auth, reusedResponses } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { Media } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import { Attachment as AttachmentSchema } from "~/classes/schemas/attachment"; -import { config } from "~/packages/config-manager/index.ts"; import { ErrorSchema } from "~/types/api"; -const schemas = { - form: z.object({ - file: z.instanceof(File), - thumbnail: z.instanceof(File).optional(), - description: z - .string() - .max(config.validation.max_media_description_size) - .optional(), - focus: z.string().optional(), - }), -}; - const route = createRoute({ method: "post", path: "/api/v2/media", - summary: "Upload media", + summary: "Upload media as an attachment (async)", + description: + "Creates a media attachment to be used with a new status. The full sized media will be processed asynchronously in the background for large uploads.", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/media/#v2", + }, + tags: ["Media"], middleware: [ auth({ auth: true, @@ -33,20 +26,52 @@ const route = createRoute({ body: { content: { "multipart/form-data": { - schema: schemas.form, + schema: z.object({ + file: z.instanceof(File).openapi({ + description: + "The file to be attached, encoded using multipart form data. The file must have a MIME type.", + }), + thumbnail: z.instanceof(File).optional().openapi({ + description: + "The custom thumbnail of the media to be attached, encoded using multipart form data.", + }), + description: + AttachmentSchema.shape.description.optional(), + focus: z + .string() + .optional() + .openapi({ + description: + "Two floating points (x,y), comma-delimited, ranging from -1.0 to 1.0. Used for media cropping on clients.", + externalDocs: { + url: "https://docs.joinmastodon.org/api/guidelines/#focal-points", + }, + }), + }), }, }, }, }, responses: { 200: { - description: "Uploaded media", + description: + "MediaAttachment was created successfully, and the full-size file was processed synchronously.", content: { "application/json": { schema: AttachmentSchema, }, }, }, + 202: { + description: + "MediaAttachment was created successfully, but the full-size file is still processing. Note that the MediaAttachment’s url will still be null, as the media is still being processed in the background. However, the preview_url should be available. Use GET /api/v1/media/:id to check the status of the media attachment.", + content: { + "application/json": { + // FIXME: Can't .extend the type to have a null url because it crashes zod-to-openapi + schema: AttachmentSchema, + }, + }, + }, 413: { description: "Payload too large", content: { @@ -63,6 +88,7 @@ const route = createRoute({ }, }, }, + ...reusedResponses, }, }); @@ -72,7 +98,7 @@ export default apiRoute((app) => const attachment = await Media.fromFile(file, { thumbnail, - description, + description: description ?? undefined, }); return context.json(attachment.toApi(), 200); diff --git a/api/api/v2/search/index.ts b/api/api/v2/search/index.ts index c3c358c3..6cca58f8 100644 --- a/api/api/v2/search/index.ts +++ b/api/api/v2/search/index.ts @@ -1,33 +1,31 @@ -import { apiRoute, auth, parseUserAddress, userAddressValidator } from "@/api"; +import { + apiRoute, + auth, + parseUserAddress, + reusedResponses, + userAddressValidator, +} from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { Note, User, db } from "@versia/kit/db"; import { Instances, Notes, RolePermissions, Users } from "@versia/kit/tables"; import { and, eq, inArray, isNull, sql } from "drizzle-orm"; import { ApiError } from "~/classes/errors/api-error"; -import { Account } from "~/classes/schemas/account"; -import { Status } from "~/classes/schemas/status"; +import { Account as AccountSchema } from "~/classes/schemas/account"; +import { Id } from "~/classes/schemas/common"; +import { Search as SearchSchema } from "~/classes/schemas/search"; import { searchManager } from "~/classes/search/search-manager"; import { config } from "~/packages/config-manager"; +import { zBoolean } from "~/packages/config-manager/config.type"; import { ErrorSchema } from "~/types/api"; -const schemas = { - query: z.object({ - q: z.string().trim(), - type: z.string().optional(), - resolve: z.coerce.boolean().optional(), - following: z.coerce.boolean().optional(), - account_id: z.string().optional(), - max_id: z.string().optional(), - min_id: z.string().optional(), - limit: z.coerce.number().int().min(1).max(40).optional(), - offset: z.coerce.number().int().optional(), - }), -}; - const route = createRoute({ method: "get", path: "/api/v2/search", - summary: "Instance database search", + summary: "Perform a search", + externalDocs: { + url: "https://docs.joinmastodon.org/methods/search/#v2", + }, + tags: ["Search"], middleware: [ auth({ auth: false, @@ -40,18 +38,64 @@ const route = createRoute({ }), ] as const, request: { - query: schemas.query, + query: z.object({ + q: z.string().trim().openapi({ + description: "The search query.", + example: "versia", + }), + type: z + .enum(["accounts", "hashtags", "statuses"]) + .optional() + .openapi({ + description: + "Specify whether to search for only accounts, hashtags, statuses", + example: "accounts", + }), + resolve: zBoolean.default(false).openapi({ + description: + "Only relevant if type includes accounts. If true and (a) the search query is for a remote account (e.g., someaccount@someother.server) and (b) the local server does not know about the account, WebFinger is used to try and resolve the account at someother.server. This provides the best recall at higher latency. If false only accounts the server knows about are returned.", + }), + following: zBoolean.default(false).openapi({ + description: + "Only include accounts that the user is following?", + }), + account_id: AccountSchema.shape.id.optional().openapi({ + description: + " If provided, will only return statuses authored by this account.", + }), + exclude_unreviewed: zBoolean.default(false).openapi({ + description: + "Filter out unreviewed tags? Use true when trying to find trending tags.", + }), + max_id: Id.optional().openapi({ + description: + "All results returned will be lesser than this ID. In effect, sets an upper bound on results.", + example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa", + }), + since_id: Id.optional().openapi({ + description: + "All results returned will be greater than this ID. In effect, sets a lower bound on results.", + example: undefined, + }), + min_id: Id.optional().openapi({ + description: + "Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.", + example: undefined, + }), + limit: z.coerce.number().int().min(1).max(40).default(20).openapi({ + description: "Maximum number of results to return.", + }), + offset: z.coerce.number().int().min(0).default(0).openapi({ + description: "Skip the first n results.", + }), + }), }, responses: { 200: { description: "Search results", content: { "application/json": { - schema: z.object({ - accounts: z.array(Account), - statuses: z.array(Status), - hashtags: z.array(z.string()), - }), + schema: SearchSchema, }, }, }, @@ -72,6 +116,7 @@ const route = createRoute({ }, }, }, + 422: reusedResponses[422], }, }); @@ -164,16 +209,16 @@ export default apiRoute((app) => accountResults = await searchManager.searchAccounts( q, - Number(limit) || 10, - Number(offset) || 0, + limit, + offset, ); } if (!type || type === "statuses") { statusResults = await searchManager.searchStatuses( q, - Number(limit) || 10, - Number(offset) || 0, + limit, + offset, ); } diff --git a/classes/schemas/filters.ts b/classes/schemas/filters.ts index 9ac25a10..e1e57de5 100644 --- a/classes/schemas/filters.ts +++ b/classes/schemas/filters.ts @@ -1,4 +1,5 @@ import { z } from "@hono/zod-openapi"; +import { zBoolean } from "~/packages/config-manager/config.type.ts"; import { Id } from "./common.ts"; export const FilterStatus = z @@ -42,7 +43,7 @@ export const FilterKeyword = z url: "https://docs.joinmastodon.org/entities/FilterKeyword/#keyword", }, }), - whole_word: z.boolean().openapi({ + whole_word: zBoolean.openapi({ description: "Should the filter consider word boundaries? See implementation guidelines for filters.", example: false, @@ -68,13 +69,18 @@ export const Filter = z url: "https://docs.joinmastodon.org/entities/Filter/#id", }, }), - title: z.string().openapi({ - description: "A title given by the user to name the filter.", - example: "Test filter", - externalDocs: { - url: "https://docs.joinmastodon.org/entities/Filter/#title", - }, - }), + title: z + .string() + .trim() + .min(1) + .max(255) + .openapi({ + description: "A title given by the user to name the filter.", + example: "Test filter", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Filter/#title", + }, + }), context: z .array( z.enum([ @@ -85,6 +91,7 @@ export const Filter = z "account", ]), ) + .default([]) .openapi({ description: "The contexts in which the filter should be applied.", @@ -103,14 +110,17 @@ export const Filter = z url: "https://docs.joinmastodon.org/entities/Filter/#expires_at", }, }), - filter_action: z.enum(["warn", "hide"]).openapi({ - description: - "The action to be taken when a status matches this filter.", - example: "warn", - externalDocs: { - url: "https://docs.joinmastodon.org/entities/Filter/#filter_action", - }, - }), + filter_action: z + .enum(["warn", "hide"]) + .default("warn") + .openapi({ + description: + "The action to be taken when a status matches this filter.", + example: "warn", + externalDocs: { + url: "https://docs.joinmastodon.org/entities/Filter/#filter_action", + }, + }), keywords: z.array(FilterKeyword).openapi({ description: "The keywords grouped under this filter.", externalDocs: { diff --git a/package.json b/package.json index fcc23531..1f2ebd2f 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "bugs": { "url": "https://github.com/versia-pub/server/issues" }, - "icon": "https://github.com/versia-pub/server", + "icon": "https://cdn.versia.pub/branding/icon.svg", "license": "AGPL-3.0-or-later", "keywords": ["federated", "activitypub", "bun"], "workspaces": ["packages/plugin-kit"], diff --git a/tests/api/statuses.test.ts b/tests/api/statuses.test.ts index 100dd011..0170323f 100644 --- a/tests/api/statuses.test.ts +++ b/tests/api/statuses.test.ts @@ -62,7 +62,7 @@ describe("API Tests", () => { }), }); - expect(response.status).toBe(201); + expect(response.status).toBe(200); expect(response.headers.get("content-type")).toContain( "application/json", ); @@ -104,7 +104,7 @@ describe("API Tests", () => { }), }); - expect(response.status).toBe(201); + expect(response.status).toBe(200); expect(response.headers.get("content-type")).toContain( "application/json", );