feat(api): Add OpenAPI visualizer
Some checks failed
CodeQL Scan / Analyze (javascript-typescript) (push) Failing after 53s
Build Docker Images / lint (push) Failing after 10s
Build Docker Images / check (push) Failing after 10s
Build Docker Images / tests (push) Failing after 6s
Build Docker Images / build (server, Dockerfile, ${{ github.repository_owner }}/server) (push) Has been skipped
Build Docker Images / build (worker, Worker.Dockerfile, ${{ github.repository_owner }}/worker) (push) Has been skipped
Deploy Docs to GitHub Pages / build (push) Failing after 5s
Mirror to Codeberg / Mirror (push) Failing after 0s
Deploy Docs to GitHub Pages / Deploy (push) Has been skipped
Nix Build / check (push) Failing after 5s

This commit is contained in:
Jesse Wierzbinski 2025-03-24 15:25:40 +01:00
parent 65e2e19ff1
commit c674a1309c
No known key found for this signature in database
32 changed files with 772 additions and 732 deletions

12
app.ts
View file

@ -3,10 +3,10 @@ import { handleZodError } from "@/api";
import { applyToHono } from "@/bull-board.ts";
import { configureLoggers } from "@/loggers";
import { sentry } from "@/sentry";
import { swaggerUI } from "@hono/swagger-ui";
import { OpenAPIHono } from "@hono/zod-openapi";
/* import { prometheus } from "@hono/prometheus"; */
import { getLogger } from "@logtape/logtape";
import { apiReference } from "@scalar/hono-api-reference";
import { inspect } from "bun";
import chalk from "chalk";
import { cors } from "hono/cors";
@ -148,7 +148,15 @@ export const appFactory = async (): Promise<OpenAPIHono<HonoEnv>> => {
contact: pkg.author,
},
});
app.get("/docs", swaggerUI({ url: "/openapi.json" }));
app.get(
"/docs",
apiReference({
theme: "deepSpace",
hideClientButton: true,
pageTitle: "Versia Server API",
url: "/openapi.json",
}),
);
applyToHono(app);
app.options("*", (context) => {

View file

@ -13,12 +13,12 @@
"@clerc/plugin-version": "^0.44.0",
"@hackmd/markdown-it-task-lists": "^2.1.4",
"@hono/prometheus": "^1.0.1",
"@hono/swagger-ui": "^0.5.1",
"@hono/zod-openapi": "0.19.2",
"@hono/zod-validator": "^0.4.3",
"@inquirer/confirm": "^5.1.8",
"@logtape/file": "^0.9.0",
"@logtape/logtape": "^0.9.0",
"@scalar/hono-api-reference": "^0.7.2",
"@sentry/bun": "^9.8.0",
"@versia/client": "workspace:*",
"@versia/federation": "^0.2.1",
@ -271,8 +271,6 @@
"@hono/prometheus": ["@hono/prometheus@1.0.1", "", { "peerDependencies": { "hono": ">=3.*", "prom-client": "^15.0.0" } }, "sha512-PjMbjAppCgbvRP2aLxqJc1XJLxfmg4dLsS5R5ITt7qCf9Ab/xSRul/LHNVvYK2/ECi3BOPprSnlSizksBJmXBQ=="],
"@hono/swagger-ui": ["@hono/swagger-ui@0.5.1", "", { "peerDependencies": { "hono": "*" } }, "sha512-XpUCfszLJ9b1rtFdzqOSHfdg9pfBiC2J5piEjuSanYpDDTIwpMz0ciiv5N3WWUaQpz9fEgH8lttQqL41vIFuDA=="],
"@hono/zod-openapi": ["@hono/zod-openapi@0.19.2", "", { "dependencies": { "@asteasolutions/zod-to-openapi": "^7.1.0", "@hono/zod-validator": "^0.4.1" }, "peerDependencies": { "hono": ">=4.3.6", "zod": "3.*" } }, "sha512-lkFa6wdQVgY7d7/m++Ixr3hvKCF5Y+zjTIPM37fex5ylCfX53A/W28gZRDuFZx3aR+noKob7lHfwdk9dURLzxw=="],
"@hono/zod-validator": ["@hono/zod-validator@0.4.3", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.19.1" } }, "sha512-xIgMYXDyJ4Hj6ekm9T9Y27s080Nl9NXHcJkOvkXPhubOLj8hZkOL8pDnnXfvCf5xEE8Q4oMFenQUZZREUY2gqQ=="],
@ -465,6 +463,14 @@
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.36.0", "", { "os": "win32", "cpu": "x64" }, "sha512-aRXd7tRZkWLqGbChgcMMDEHjOKudo1kChb1Jt1IfR8cY/KIpgNviLeJy5FUb9IpSuQj8dU2fAYNMPW/hLKOSTw=="],
"@scalar/core": ["@scalar/core@0.2.2", "", { "dependencies": { "@scalar/types": "0.1.2" } }, "sha512-jT6vfz37yQnqVjj8kXYEmV2cZvODW1A0PXjxZ9DzKqjm9tIssNwP4vvcdD1FSuiMcj+rgxAxOjIYMI+ybI/9RQ=="],
"@scalar/hono-api-reference": ["@scalar/hono-api-reference@0.7.2", "", { "dependencies": { "@scalar/core": "0.2.2" }, "peerDependencies": { "hono": "^4.0.0" } }, "sha512-CnxRjGfAWPGkV0D5TEwogvn7JSx/f9+ag6vQ6g25GigSDyj/UkxYbZqwe/QOV/+2EWruY3ypOvPuNMf7nEQhdQ=="],
"@scalar/openapi-types": ["@scalar/openapi-types@0.1.9", "", {}, "sha512-HQQudOSQBU7ewzfnBW9LhDmBE2XOJgSfwrh5PlUB7zJup/kaRkBGNgV2wMjNz9Af/uztiU/xNrO179FysmUT+g=="],
"@scalar/types": ["@scalar/types@0.1.2", "", { "dependencies": { "@scalar/openapi-types": "0.1.9", "@unhead/schema": "^1.11.11", "zod": "^3.23.8" } }, "sha512-5kCLQRwAYWt1ds110EaUb9yonc3KoQYNyo4YUCigJLOnoNugbqkEX0zRudGevItiuk+xg4uOYd30r3C+6xAasA=="],
"@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="],
"@sentry/bun": ["@sentry/bun@9.8.0", "", { "dependencies": { "@sentry/core": "9.8.0", "@sentry/node": "9.8.0", "@sentry/opentelemetry": "9.8.0" } }, "sha512-3JSaxyEomfZYLp1mwvwugj308QeZ4fYRzjAOQcWZqHFKbr6pMFKTvZKfEKIoAEXsNfka33+VpPdltlMgGe9auw=="],
@ -543,6 +549,8 @@
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
"@unhead/schema": ["@unhead/schema@1.11.20", "", { "dependencies": { "hookable": "^5.5.3", "zhead": "^2.2.4" } }, "sha512-0zWykKAaJdm+/Y7yi/Yds20PrUK7XabLe9c3IRcjnwYmSWY6z0Cr19VIs3ozCj8P+GhR+/TI2mwtGlueCEYouA=="],
"@versia/client": ["@versia/client@workspace:packages/client"],
"@versia/federation": ["@versia/federation@0.2.1", "", { "dependencies": { "magic-regexp": "^0.8.0", "mime-types": "^2.1.35", "zod": "^3.24.1", "zod-validation-error": "^3.4.0" } }, "sha512-FTo3VGNJBGmCi0ZEQMzqFZBbcfbX81kmg0UgY4cKamr1dJWgEf72IAZnEDgrBffFjYtreLGdEjFkkcq3JfS8oQ=="],
@ -1339,6 +1347,8 @@
"yoctocolors-cjs": ["yoctocolors-cjs@2.1.2", "", {}, "sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA=="],
"zhead": ["zhead@2.2.4", "", {}, "sha512-8F0OI5dpWIA5IGG5NHUg9staDwz/ZPxZtvGVf01j7vHqSyZ0raHY+78atOVxRqb73AotX22uV1pXt3gYSstGag=="],
"zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="],
"zod-to-json-schema": ["zod-to-json-schema@3.24.5", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g=="],

View file

@ -85,12 +85,12 @@
"@clerc/plugin-version": "^0.44.0",
"@hackmd/markdown-it-task-lists": "^2.1.4",
"@hono/prometheus": "^1.0.1",
"@hono/swagger-ui": "^0.5.1",
"@hono/zod-openapi": "0.19.2",
"@hono/zod-validator": "^0.4.3",
"@inquirer/confirm": "^5.1.8",
"@logtape/file": "^0.9.0",
"@logtape/logtape": "^0.9.0",
"@scalar/hono-api-reference": "^0.7.2",
"@sentry/bun": "^9.8.0",
"@versia/client": "workspace:*",
"@versia/federation": "^0.2.1",

View file

@ -49,7 +49,7 @@ export const AccountWarning = z
example: "2025-01-04T14:11:00Z",
}),
})
.openapi({
.openapi("AccountWarning", {
description: "Moderation warning against a particular account.",
externalDocs: {
url: "https://docs.joinmastodon.org/entities/AccountWarning",

View file

@ -5,7 +5,8 @@ import { iso631, zBoolean } from "./common.ts";
import { CustomEmoji } from "./emoji.ts";
import { Role } from "./versia.ts";
export const Field = z.object({
export const Field = z
.object({
name: z
.string()
.trim()
@ -42,7 +43,8 @@ export const Field = z.object({
url: "https://docs.joinmastodon.org/entities/Account/#verified_at",
},
}),
});
})
.openapi("AccountField");
export const Source = z
.object({
@ -107,7 +109,7 @@ export const Source = z
description: "Metadata about the account.",
}),
})
.openapi({
.openapi("AccountSource", {
description:
"An extra attribute that contains source values to be used with API methods that verify credentials and update credentials.",
externalDocs: {
@ -116,7 +118,8 @@ export const Source = z
});
// Because Account has some recursive references, we need to define it like this
const BaseAccount = z.object({
const BaseAccount = z
.object({
id: z
.string()
.uuid()
@ -151,7 +154,8 @@ const BaseAccount = z.object({
"Username is disallowed",
)
.openapi({
description: "The username of the account, not including domain.",
description:
"The username of the account, not including domain.",
example: "lexi",
externalDocs: {
url: "https://docs.joinmastodon.org/entities/Account/#username",
@ -206,7 +210,9 @@ const BaseAccount = z.object({
.trim()
.refine(
(s) =>
!config.validation.filters.bio.some((filter) => filter.test(s)),
!config.validation.filters.bio.some((filter) =>
filter.test(s),
),
"Bio contains blocked words",
)
.openapi({
@ -265,7 +271,8 @@ const BaseAccount = z.object({
},
}),
locked: zBoolean.openapi({
description: "Whether the account manually approves follow requests.",
description:
"Whether the account manually approves follow requests.",
example: false,
externalDocs: {
url: "https://docs.joinmastodon.org/entities/Account/#locked",
@ -413,7 +420,8 @@ const BaseAccount = z.object({
description: "When a timed mute will expire, if applicable.",
example: "2025-03-01T14:00:00.000Z",
}),
});
})
.openapi("BaseAccount");
export const Account = BaseAccount.extend({
moved: BaseAccount.nullable()
@ -426,4 +434,4 @@ export const Account = BaseAccount.extend({
url: "https://docs.joinmastodon.org/entities/Account/#moved",
},
}),
});
}).openapi("Account");

View file

@ -13,7 +13,7 @@ export const Appeal = z
example: "pending",
}),
})
.openapi({
.openapi("Appeal", {
description: "Appeal against a moderation action.",
externalDocs: {
url: "https://docs.joinmastodon.org/entities/Appeal",

View file

@ -1,6 +1,7 @@
import { z } from "@hono/zod-openapi";
export const Application = z.object({
export const Application = z
.object({
name: z
.string()
.trim()
@ -59,7 +60,8 @@ export const Application = z.object({
url: "https://docs.joinmastodon.org/entities/Application/#redirect_uri",
},
}),
});
})
.openapi("Application");
export const CredentialApplication = Application.extend({
client_id: z.string().openapi({
@ -81,4 +83,4 @@ export const CredentialApplication = Application.extend({
url: "https://docs.joinmastodon.org/entities/CredentialApplication/#client_secret_expires_at",
},
}),
});
}).openapi("CredentialApplication");

View file

@ -67,7 +67,7 @@ export const Attachment = z
example: "UFBWY:8_0Jxv4mx]t8t64.%M-:IUWGWAt6M}",
}),
})
.openapi({
.openapi("Attachment", {
description:
"Represents a file or media attachment that can be added to a status.",
externalDocs: {

View file

@ -1,7 +1,8 @@
import { z } from "@hono/zod-openapi";
import { Account } from "./account.ts";
export const PreviewCardAuthor = z.object({
export const PreviewCardAuthor = z
.object({
name: z.string().openapi({
description: "The original resource authors name.",
example: "The Doubleclicks",
@ -25,7 +26,8 @@ export const PreviewCardAuthor = z.object({
url: "https://docs.joinmastodon.org/entities/PreviewCardAuthor/#account",
},
}),
});
})
.openapi("PreviewCardAuthor");
export const PreviewCard = z
.object({
@ -150,7 +152,7 @@ export const PreviewCard = z
},
}),
})
.openapi({
.openapi("PreviewCard", {
description:
"Represents a rich preview card that is generated using OpenGraph tags from a URL.",
externalDocs: {

View file

@ -3,7 +3,9 @@ import ISO6391 from "iso-639-1";
export const Id = z.string().uuid();
export const iso631 = z.enum(ISO6391.getAllCodes() as [string, ...string[]]);
export const iso631 = z
.enum(ISO6391.getAllCodes() as [string, ...string[]])
.openapi("ISO631");
export const zBoolean = z
.string()

View file

@ -16,7 +16,7 @@ export const Context = z
},
}),
})
.openapi({
.openapi("Context", {
description:
"Represents the tree around a given status. Used for reconstructing threads of statuses.",
externalDocs: {

View file

@ -87,7 +87,7 @@ export const CustomEmoji = z
},
}),
})
.openapi({
.openapi("CustomEmoji", {
description: "Represents a custom emoji.",
externalDocs: {
url: "https://docs.joinmastodon.org/entities/CustomEmoji",

View file

@ -22,7 +22,7 @@ export const ExtendedDescription = z
},
}),
})
.openapi({
.openapi("ExtendedDescription", {
description:
"Represents an extended description for the instance, to be shown on its about page.",
externalDocs: {

View file

@ -17,7 +17,7 @@ export const FamiliarFollowers = z
},
}),
})
.openapi({
.openapi("FamiliarFollowers", {
description:
"Represents a subset of your follows who also follow some other user.",
externalDocs: {

View file

@ -18,7 +18,7 @@ export const FilterStatus = z
},
}),
})
.openapi({
.openapi("FilterStatus", {
description:
"Represents a status ID that, if matched, should cause the filter action to be taken.",
externalDocs: {
@ -51,7 +51,7 @@ export const FilterKeyword = z
},
}),
})
.openapi({
.openapi("FilterKeyword", {
description:
"Represents a keyword that, if matched, should cause the filter action to be taken.",
externalDocs: {
@ -133,7 +133,7 @@ export const Filter = z
},
}),
})
.openapi({
.openapi("Filter", {
description:
"Represents a user-defined filter for determining which statuses should not be shown to the user.",
externalDocs: {
@ -171,7 +171,7 @@ export const FilterResult = z
},
}),
})
.openapi({
.openapi("FilterResult", {
description:
"Represents a filter whose keywords matched a given status.",
externalDocs: {

View file

@ -132,7 +132,7 @@ export const InstanceV1 = z
/* Versia Server API extension */
sso: SSOConfig,
})
.openapi({
.openapi("InstanceV1", {
description:
"Represents the software instance of Versia Server running on this domain.",
externalDocs: {

View file

@ -18,7 +18,7 @@ const InstanceIcon = z
example: "36x36",
}),
})
.openapi({
.openapi("InstanceIcon", {
externalDocs: {
url: "https://docs.joinmastodon.org/entities/InstanceIcon",
},
@ -375,7 +375,7 @@ export const Instance = z
/* Versia Server API extension */
sso: SSOConfig,
})
.openapi({
.openapi("Instance", {
externalDocs: {
url: "https://docs.joinmastodon.org/entities/Instance",
},

View file

@ -17,7 +17,7 @@ export const Marker = z
example: "2025-01-12T13:11:00Z",
}),
})
.openapi({
.openapi("Marker", {
description:
"Represents the last read position within a user's timelines.",
externalDocs: {

View file

@ -62,7 +62,7 @@ export const Notification = z
"Moderation warning that caused the notification. Attached when type of the notification is moderation_warning.",
}),
})
.openapi({
.openapi("Notification", {
description:
"Represents a notification of an event relevant to the user.",
externalDocs: {

View file

@ -31,7 +31,7 @@ export const PollOption = z
},
}),
})
.openapi({
.openapi("PollOption", {
externalDocs: {
url: "https://docs.joinmastodon.org/entities/Poll/#Option",
},
@ -130,7 +130,7 @@ export const Poll = z
},
}),
})
.openapi({
.openapi("Poll", {
description: "Represents a poll attached to a status.",
externalDocs: {
url: "https://docs.joinmastodon.org/entities/Poll",

View file

@ -42,7 +42,7 @@ export const Preferences = z
},
}),
})
.openapi({
.openapi("Preferences", {
description: "Represents a user's preferences.",
externalDocs: {
url: "https://docs.joinmastodon.org/entities/Preferences",

View file

@ -21,7 +21,7 @@ export const PrivacyPolicy = z
},
}),
})
.openapi({
.openapi("PrivacyPolicy", {
description: "Represents the privacy policy of the instance.",
externalDocs: {
url: "https://docs.joinmastodon.org/entities/PrivacyPolicy",

View file

@ -80,7 +80,7 @@ export const WebPushSubscription = z
description: "The streaming servers VAPID key.",
}),
})
.openapi({});
.openapi("WebPushSubscription");
export const WebPushSubscriptionInput = z
.object({

View file

@ -65,7 +65,7 @@ export const Relationship = z
example: "they also like Kerbal Space Program",
}),
})
.openapi({
.openapi("Relationship", {
description:
"Represents the relationship between accounts, such as following / blocking / muting / etc.",
externalDocs: {

View file

@ -50,7 +50,7 @@ export const Report = z
description: "The account that was reported.",
}),
})
.openapi({
.openapi("Report", {
description:
"Reports filed against users and/or statuses, to be taken action on by moderators.",
externalDocs: {

View file

@ -15,7 +15,7 @@ export const Rule = z
example: "Please, we beg you.",
}),
})
.openapi({
.openapi("Rule", {
description: "Represents a rule that server users should follow.",
externalDocs: {
url: "https://docs.joinmastodon.org/entities/Rule",

View file

@ -15,7 +15,7 @@ export const Search = z
description: "Hashtags which match the given query",
}),
})
.openapi({
.openapi("Search", {
description: "Represents the results of a search.",
externalDocs: {
url: "https://docs.joinmastodon.org/entities/Search",

View file

@ -42,7 +42,7 @@ export const Mention = z
},
}),
})
.openapi({
.openapi("Mention", {
externalDocs: {
url: "https://docs.joinmastodon.org/entities/Status/#Mention",
},
@ -75,14 +75,15 @@ export const StatusSource = z
example: "",
}),
})
.openapi({
.openapi("StatusSource", {
externalDocs: {
url: "https://docs.joinmastodon.org/entities/StatusSource",
},
});
// Because Status has some recursive references, we need to define it like this
const BaseStatus = z.object({
const BaseStatus = z
.object({
id: Id.openapi({
description: "ID of the status in the database.",
example: "2de861d3-a3dd-42ee-ba38-2c7d3f4af588",
@ -164,7 +165,8 @@ const BaseStatus = z.object({
},
}),
emojis: z.array(CustomEmoji).openapi({
description: "Custom emoji to be used when rendering status content.",
description:
"Custom emoji to be used when rendering status content.",
externalDocs: {
url: "https://docs.joinmastodon.org/entities/Status/#emojis",
},
@ -241,7 +243,9 @@ const BaseStatus = z.object({
url: "https://docs.joinmastodon.org/entities/Status/#spoiler_text",
},
}),
visibility: z.enum(["public", "unlisted", "private", "direct"]).openapi({
visibility: z
.enum(["public", "unlisted", "private", "direct"])
.openapi({
description: "Visibility of this status.",
example: "public",
externalDocs: {
@ -267,7 +271,8 @@ const BaseStatus = z.object({
},
}),
card: PreviewCard.nullable().openapi({
description: "Preview card for links included within status content.",
description:
"Preview card for links included within status content.",
externalDocs: {
url: "https://docs.joinmastodon.org/entities/Status/#card",
},
@ -350,7 +355,8 @@ const BaseStatus = z.object({
url: "https://docs.joinmastodon.org/entities/Status/#filtered",
},
}),
});
})
.openapi("BaseStatus");
export const Status = BaseStatus.extend({
reblog: BaseStatus.nullable().openapi({
@ -360,9 +366,10 @@ export const Status = BaseStatus.extend({
},
}),
quote: BaseStatus.nullable(),
});
}).openapi("Status");
export const ScheduledStatus = z.object({
export const ScheduledStatus = z
.object({
id: Id.openapi({
description: "ID of the scheduled status in the database.",
example: "2de861d3-a3dd-42ee-ba38-2c7d3f4af588",
@ -406,4 +413,5 @@ export const ScheduledStatus = z.object({
example: "1234567890",
}),
}),
});
})
.openapi("ScheduledStatus");

View file

@ -24,7 +24,7 @@ export const Tag = z
},
}),
})
.openapi({
.openapi("Tag", {
externalDocs: {
url: "https://docs.joinmastodon.org/entities/Status/#Tag",
},

View file

@ -20,7 +20,7 @@ export const Token = z
example: 1573979017,
}),
})
.openapi({
.openapi("Token", {
description:
"Represents an OAuth token used for authenticating with the API and performing actions.",
externalDocs: {

View file

@ -11,6 +11,6 @@ export const TermsOfService = z
example: "<p><h1>ToS</h1><p>None, have fun.</p></p>",
}),
})
.openapi({
.openapi("TermsOfService", {
description: "Represents the ToS of the instance.",
});

View file

@ -45,7 +45,7 @@ export const Role = z
example: "https://example.com/role-icon.png",
}),
})
.openapi({
.openapi("Role", {
description:
"Information about a role in the system, as well as its permissions.",
});
@ -72,7 +72,7 @@ export const NoteReaction = z
example: true,
}),
})
.openapi({
.openapi("NoteReaction", {
description: "Information about a reaction to a note.",
});
@ -134,6 +134,6 @@ export const Challenge = z
example: "1234567890",
}),
})
.openapi({
.openapi("Challenge", {
description: "A cryptographic challenge to solve. Used for Captchas.",
});