From 4621a81384ad96ceaab823ea90b73d6440a36ac7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20T=C3=A4kker?= Date: Tue, 29 Sep 2026 01:54:51 +0200 Subject: [PATCH] Added API schema and docs routes --- server/routes/docs.get.ts | 11 ++ server/routes/schema.get.ts | 3 + server/utils/openapi.ts | 248 ++++++++++++++++++++++++++++++++++++ 3 files changed, 262 insertions(+) create mode 100644 server/routes/docs.get.ts create mode 100644 server/routes/schema.get.ts create mode 100644 server/utils/openapi.ts diff --git a/server/routes/docs.get.ts b/server/routes/docs.get.ts new file mode 100644 index 0000000..8703e3c --- /dev/null +++ b/server/routes/docs.get.ts @@ -0,0 +1,11 @@ +const swaggerUi = ` +
+ + + +` + +export default defineEventHandler((event) => { + setHeader(event, "content-type", "text/html; charset=utf-8") + return `MusicBackend API${swaggerUi}` +}) diff --git a/server/routes/schema.get.ts b/server/routes/schema.get.ts new file mode 100644 index 0000000..bb596c8 --- /dev/null +++ b/server/routes/schema.get.ts @@ -0,0 +1,3 @@ +import { openApiDocument } from "../utils/openapi" + +export default defineEventHandler(() => openApiDocument) diff --git a/server/utils/openapi.ts b/server/utils/openapi.ts new file mode 100644 index 0000000..53be856 --- /dev/null +++ b/server/utils/openapi.ts @@ -0,0 +1,248 @@ +type HttpMethod = "get" | "post" | "patch" | "delete" + +type ApiOperation = { + method: HttpMethod + path: string + tag: string + summary: string + auth?: boolean + admin?: boolean + query?: string[] + body?: "json" | "multipart" + response?: string + status?: number +} + +const operations: ApiOperation[] = [ + { method: "post", path: "/auth/register", tag: "Auth", summary: "Register a user", body: "json", response: "EmptyObject", status: 201 }, + { method: "post", path: "/auth/signin", tag: "Auth", summary: "Sign in", body: "json", response: "EmptyObject" }, + { method: "post", path: "/auth/session", tag: "Auth", summary: "Create a session", body: "json", response: "Session", status: 201 }, + { method: "get", path: "/auth/session", tag: "Auth", summary: "Get or renew the current session", response: "SessionStatus" }, + { method: "post", path: "/auth/signout", tag: "Auth", summary: "Sign out", response: "Signout" }, + { method: "get", path: "/artists", tag: "Artist", summary: "Get artists by ID", auth: true, query: ["ids"], response: "ArtistList" }, + { method: "get", path: "/artists/{id}", tag: "Artist", summary: "Get an artist", auth: true, response: "ArtistOrNull" }, + { method: "get", path: "/artists/{id}/tracks", tag: "Artist", summary: "Get tracks for an artist", auth: true, query: ["limit", "skip"], response: "TrackList" }, + { method: "get", path: "/artists/{id}/albums", tag: "Artist", summary: "Get albums for an artist", auth: true, query: ["limit", "skip"], response: "AlbumList" }, + { method: "get", path: "/albums", tag: "Album", summary: "Get albums by ID", auth: true, query: ["ids"], response: "AlbumList" }, + { method: "get", path: "/albums/{id}", tag: "Album", summary: "Get an album", auth: true, response: "AlbumOrNull" }, + { method: "get", path: "/albums/{id}/tracks", tag: "Album", summary: "Get tracks for an album", auth: true, query: ["limit", "skip"], response: "TrackList" }, + { method: "get", path: "/genres/random", tag: "Genre", summary: "Get random genres", auth: true, query: ["limit"], response: "GenreList" }, + { method: "get", path: "/genres/albums/{id}", tag: "Genre", summary: "Get albums for a genre", auth: true, query: ["limit", "skip"], response: "AlbumList" }, + { method: "get", path: "/genres/tracks/{id}", tag: "Genre", summary: "Get tracks for a genre", auth: true, query: ["limit", "skip"], response: "TrackList" }, + { method: "get", path: "/tracks", tag: "Track", summary: "Get tracks by ID", auth: true, query: ["ids"], response: "TrackList" }, + { method: "get", path: "/tracks/{id}", tag: "Track", summary: "Get a track", auth: true, response: "TrackOrNull" }, + { method: "get", path: "/tracks/{id}/lyrics", tag: "Track", summary: "Get track lyrics", auth: true, response: "Lyrics" }, + { method: "get", path: "/all/albums", tag: "All", summary: "Get all albums", auth: true, response: "AlbumList" }, + { method: "get", path: "/all/artists", tag: "All", summary: "Get all artists", auth: true, response: "ArtistList" }, + { method: "get", path: "/all/genres", tag: "All", summary: "Get all genres", auth: true, response: "GenreList" }, + { method: "get", path: "/all/tracks", tag: "All", summary: "Get all tracks", auth: true, response: "TrackList" }, + { method: "get", path: "/search", tag: "Search", summary: "Search tracks, albums and artists", auth: true, query: ["q", "type", "limit"], response: "SearchResults" }, + { method: "get", path: "/user", tag: "User", summary: "Get the current user", auth: true, response: "User" }, + { method: "get", path: "/user/tracks", tag: "User", summary: "Get saved tracks", auth: true, response: "TrackList" }, + { method: "patch", path: "/user/tracks", tag: "User", summary: "Save a track", auth: true, query: ["id"], response: "EmptyObject" }, + { method: "post", path: "/admin/artist", tag: "Admin", summary: "Create an artist", admin: true, body: "multipart", response: "Artist", status: 201 }, + { method: "patch", path: "/admin/artist", tag: "Admin", summary: "Update an artist", admin: true, query: ["id"], body: "multipart", response: "Artist" }, + { method: "delete", path: "/admin/artist", tag: "Admin", summary: "Delete an artist", admin: true, query: ["id", "force"], response: "EmptyObject" }, + { method: "post", path: "/admin/album", tag: "Admin", summary: "Create an album", admin: true, body: "multipart", response: "Album", status: 201 }, + { method: "patch", path: "/admin/album", tag: "Admin", summary: "Update an album", admin: true, query: ["id"], body: "multipart", response: "Album" }, + { method: "delete", path: "/admin/album", tag: "Admin", summary: "Delete an album", admin: true, query: ["id", "force"], response: "EmptyObject" }, + { method: "post", path: "/admin/genre", tag: "Admin", summary: "Create a genre", admin: true, body: "json", response: "Genre", status: 201 }, + { method: "patch", path: "/admin/genre", tag: "Admin", summary: "Update a genre", admin: true, query: ["id"], body: "json", response: "Genre" }, + { method: "delete", path: "/admin/genre", tag: "Admin", summary: "Delete a genre", admin: true, query: ["id", "force"], response: "EmptyObject" }, + { method: "post", path: "/admin/track", tag: "Admin", summary: "Create a track", admin: true, body: "multipart", response: "Track", status: 201 }, + { method: "patch", path: "/admin/track", tag: "Admin", summary: "Update a track", admin: true, query: ["id"], body: "multipart", response: "Track" }, + { method: "delete", path: "/admin/track", tag: "Admin", summary: "Delete a track", admin: true, query: ["id"], response: "EmptyObject" }, +] + +const ref = (name: string) => ({ $ref: `#/components/schemas/${name}` }) +const jsonResponse = (description: string, schema?: unknown) => ({ + description, + ...(schema ? { content: { "application/json": { schema } } } : {}), +}) +const fieldTypes: Record = { + id: { type: "string", description: "MongoDB ObjectId" }, + ids: { + anyOf: [ + { type: "string", description: "One MongoDB ObjectId" }, + { type: "array", items: { type: "string" }, description: "Repeated query parameter values" }, + ], + description: "A single ID or repeated ids query parameters; comma-separated values are not accepted.", + }, + limit: { type: "integer", minimum: 1 }, + skip: { type: "integer", minimum: 0 }, + q: { type: "string" }, + type: { type: "string", enum: ["track", "album", "artist", "default"] }, + force: { type: "boolean" }, +} + +function operationSpec(operation: ApiOperation) { + const pathParams = [...operation.path.matchAll(/\{([^}]+)\}/g)].map((match) => match[1]) + const parameters = [ + ...pathParams.map((name) => ({ name, in: "path", required: true, schema: { type: "string" } })), + ...(operation.path === "/auth/session" && operation.method === "post" + ? [{ name: "refreshToken", in: "cookie", required: false, schema: { type: "string" } }] + : []), + ...(operation.query ?? []).map((name) => ({ + name, + in: "query", + required: name === "q" || name === "ids" || name === "id" || (name === "force" && operation.path === "/admin/genre"), + schema: fieldTypes[name] ?? { type: "string" }, + ...(name === "ids" ? { style: "form", explode: true } : {}), + })), + ] + const bodySchema = operation.body === "json" + ? operation.path === "/auth/register" + ? { type: "object", required: ["name", "email", "password"], properties: { name: { type: "string" }, email: { type: "string", format: "email" }, password: { type: "string", minLength: 8 } } } + : operation.path === "/auth/signin" + ? { type: "object", required: ["email", "password"], properties: { email: { type: "string", format: "email" }, password: { type: "string" }, remember: { type: "boolean", description: "When true, makes the refresh cookie persistent for 30 days." } } } + : operation.path === "/auth/session" + ? { type: "object", required: ["refreshToken"], properties: { refreshToken: { type: "string" } } } + : { type: "object", ...(operation.method === "post" ? { required: ["name"] } : {}), properties: { name: { type: "string" } } } + : operation.body === "multipart" + ? operation.path === "/admin/artist" + ? { type: "object", properties: { name: { type: "string" }, file: { type: "string", format: "binary" } }, ...(operation.method === "post" ? { required: ["name", "file"] } : {}) } + : operation.path === "/admin/album" + ? { type: "object", properties: { name: { type: "string" }, artists: { type: "array", items: { type: "string" }, description: "Comma-separated MongoDB ObjectIds" }, genre: { type: "string" }, file: { type: "string", format: "binary" } }, ...(operation.method === "post" ? { required: ["name", "artists", "genre", "file"] } : {}) } + : { type: "object", properties: { name: { type: "string" }, album: { type: "string" }, artists: { type: "array", items: { type: "string" }, description: "Comma-separated MongoDB ObjectIds" }, file: { type: "string", format: "binary" }, lyrics: { oneOf: [ref("Lyrics"), { type: "string" }] } }, ...(operation.method === "post" ? { required: ["name", "album", "artists", "file"] } : {}) } + : undefined + const responses: Record = { + [operation.status ?? 200]: jsonResponse( + operation.path === "/tracks/{id}/lyrics" + ? "Returns the lyrics object when present. The handler returns an empty body when the track exists without lyrics, and an empty object when the track does not exist." + : "Successful response", + operation.response ? ref(operation.response) : undefined, + ), + ...(pathParams.length || operation.query?.length || operation.body + ? { 400: jsonResponse("Invalid request", ref("InvalidRequest")) } + : {}), + ...(operation.auth || operation.admin || (operation.path === "/auth/session" && operation.method === "post") + ? { + 401: jsonResponse( + "Authentication required", + ref(operation.auth || operation.admin ? "UnauthorizedError" : "Error"), + ), + } + : {}), + ...(operation.path === "/user/tracks" || (operation.path.startsWith("/admin/") && (operation.method === "patch" || operation.method === "delete")) + ? { 404: jsonResponse("Resource not found", ref("NotFound")) } + : {}), + ...(operation.path === "/admin/track" && operation.method === "post" + ? { 500: jsonResponse("Track processing failed") } + : {}), + ...(operation.method === "delete" && operation.path !== "/admin/track" + ? { 409: jsonResponse("Resource has dependents", ref("DependentError")) } + : {}), + } + return { + tags: [operation.tag], + summary: operation.summary, + ...(operation.auth || operation.admin ? { security: [{ Bearer: [] }, { SessionCookie: [] }] } : {}), + ...(parameters.length ? { parameters } : {}), + ...(operation.body + ? { + requestBody: { + required: operation.path !== "/auth/session", + ...(operation.path === "/auth/session" ? { description: "May be omitted when the refreshToken HttpOnly cookie is present." } : {}), + content: { + [operation.body === "json" ? "application/json" : "multipart/form-data"]: { + schema: bodySchema, + }, + }, + }, + } + : {}), + responses, + } +} + +const paths: Record> = {} +for (const operation of operations) { + const pathItem = paths[operation.path] ?? {} + pathItem[operation.method] = operationSpec(operation) + paths[operation.path] = pathItem +} + +export const openApiDocument = { + openapi: "3.1.0", + info: { title: "MusicBackend", version: "v2" }, + servers: [{ url: "/api", description: "Nuxt API routes" }], + tags: ["Auth", "Artist", "Album", "Genre", "Track", "All", "Search", "User", "Admin"].map((name) => ({ name })), + paths, + components: { + securitySchemes: { + Bearer: { type: "http", scheme: "bearer" }, + SessionCookie: { type: "apiKey", in: "cookie", name: "musicSession" }, + }, + schemas: { + Error: { type: "object", required: ["message"], properties: { message: { type: "string" } } }, + UnauthorizedError: { + type: "object", + required: ["statusCode", "statusMessage", "data"], + properties: { + statusCode: { type: "integer", enum: [401] }, + statusMessage: { type: "string", enum: ["Unauthorized"] }, + data: ref("Error"), + }, + }, + InvalidRequest: { oneOf: [ref("Error"), ref("H3Error")] }, + H3Error: { + type: "object", + required: ["statusCode", "statusMessage"], + properties: { + statusCode: { type: "integer" }, + statusMessage: { type: "string" }, + message: { type: "string" }, + data: {}, + }, + }, + Signout: { type: "object", properties: { success: { type: "boolean" } } }, + ArtistOrNull: { anyOf: [ref("Artist"), { type: "null" }] }, + AlbumOrNull: { anyOf: [ref("Album"), { type: "null" }] }, + TrackOrNull: { anyOf: [ref("Track"), { type: "null" }] }, + EmptyObject: { type: "object", properties: {}, additionalProperties: false }, + NotFound: { oneOf: [ref("Error"), ref("EmptyObject")] }, + DependentError: { + type: "object", + required: ["message", "dependentType", "dependents"], + properties: { + message: { type: "string" }, + dependentType: { type: "string" }, + dependents: { type: "array", items: { type: "object", properties: { _id: { type: "string" }, name: { type: "string" } } } }, + }, + }, + Artist: { type: "object", properties: { _id: { type: "string" }, name: { type: "string" }, file: { type: "string" } } }, + Genre: { type: "object", properties: { _id: { type: "string" }, name: { type: "string" } } }, + Album: { + type: "object", + properties: { + _id: { type: "string" }, + name: { type: "string" }, + artists: { type: "array", items: { anyOf: [{ type: "string", description: "MongoDB ObjectId" }, ref("Artist")] } }, + file: { type: "string" }, + genre: { anyOf: [{ type: "string", description: "MongoDB ObjectId" }, ref("Genre")] }, + }, + }, + Lyrics: { type: "object", properties: { synced: { type: "boolean" }, text: { type: "string" } } }, + Track: { + type: "object", + properties: { + _id: { type: "string" }, + name: { type: "string" }, + album: { anyOf: [{ type: "string", description: "MongoDB ObjectId" }, ref("Album")] }, + artists: { type: "array", items: { anyOf: [{ type: "string", description: "MongoDB ObjectId" }, ref("Artist")] } }, + fileDir: { type: "string" }, + durationInSeconds: { type: "number" }, + lyrics: ref("Lyrics"), + }, + }, + User: { type: "object", properties: { _id: { type: "string" }, name: { type: "string" }, email: { type: "string", format: "email" }, role: { type: "string" }, verified: { type: "boolean" }, savedTracks: { type: "array", items: { type: "string", description: "MongoDB ObjectId" } } } }, + Session: { type: "object", properties: { sessionToken: { type: "string" }, expireAt: { type: "string", format: "date-time" } } }, + SessionStatus: { type: "object", properties: { authenticated: { type: "boolean" }, sessionToken: { type: "string" } } }, + SearchResults: { type: "object", properties: { tracks: { type: "array", items: ref("Track") }, albums: { type: "array", items: ref("Album") }, artists: { type: "array", items: ref("Artist") } } }, + ArtistList: { type: "array", items: ref("Artist") }, + GenreList: { type: "array", items: ref("Genre") }, + AlbumList: { type: "array", items: ref("Album") }, + TrackList: { type: "array", items: ref("Track") }, + }, + }, +}