closes #1; openapi! yay

This commit is contained in:
Reid 2025-08-03 20:42:31 -07:00
parent b560060b45
commit 7b15834f17
Signed by: reidlab
GPG key ID: DAF5EAF6665839FD
16 changed files with 519 additions and 341 deletions

View file

@ -1,11 +1,11 @@
import fs from "node:fs";
import * as log from "./log.js";
import toml from "toml";
import { z, ZodError, ZodSchema } from "zod";
import { z, ZodError, ZodObject } from "zod";
import * as dotenv from "dotenv";
import { fromZodError } from "zod-validation-error";
dotenv.config();
dotenv.config({ quiet: true });
const configSchema = z.object({
server: z.object({
@ -57,7 +57,7 @@ if (!fs.existsSync("config.toml")) {
* @param something the thing to load the schema from--either a **file path to a toml file** or **an object** (e.g. process.env)
* @returns the inferred type of the schema
*/
function loadSchemaSomething<T extends ZodSchema>(schema: T, something: string | unknown): z.infer<T> {
function loadSchemaSomething<T extends ZodObject>(schema: T, something: string | unknown): z.infer<T> {
try {
if (typeof something === "string") {
return schema.parse(toml.parse(fs.readFileSync(something, "utf-8")));

View file

@ -6,19 +6,32 @@ import { appleMusicApi } from "../../../appleMusicApi/index.js";
import { z } from "zod";
import { validate } from "../../validate.js";
import { CodecType, regularCodecTypeSchema, webplaybackCodecTypeSchema, type RegularCodecType, type WebplaybackCodecType } from "../../../downloader/codecType.js";
import { paths } from "../../openApi.js";
const router = express.Router();
const path = "/download";
const schema = z.object({
query: z.object({
id: z.string(),
codec: regularCodecTypeSchema.or(webplaybackCodecTypeSchema)
codec: z.enum([...regularCodecTypeSchema.options, ...webplaybackCodecTypeSchema.options])
})
});
paths[path] = {
get: {
requestParams: { query: schema.shape.query },
responses: {
200: { description: "returns a song in an mp4 container" },
400: { description: "bad request, invalid query parameters. sent as a zod error with details" },
default: { description: "upstream api error, or some other error" }
}
}
};
// TODO: support more encryption schemes
// TODO: some type of agnostic-ness for the encryption schemes on regular codec
router.get("/download", async (req, res, next) => {
router.get(path, async (req, res, next) => {
try {
const { id, codec } = (await validate(req, schema)).query;

View file

@ -2,18 +2,31 @@ import { appleMusicApi } from "../../../appleMusicApi/index.js";
import express from "express";
import { validate } from "../../validate.js";
import { z } from "zod";
import { paths } from "../../openApi.js";
const router = express.Router();
const path = "/getAlbumMetadata";
const schema = z.object({
query: z.object({
id: z.string()
})
});
paths[path] = {
get: {
requestParams: { query: schema.shape.query },
responses: {
200: { description: "returns from the apple music api, album metadata with `tracks` relationship https://developer.apple.com/documentation/applemusicapi/get-a-catalog-album" },
400: { description: "bad request, invalid query parameters. sent as a zod error with details" },
default: { description: "upstream api error, or some other error" }
}
}
};
// see comments in `getTrackMetadata.ts`
// awawawawawa
router.get("/getAlbumMetadata", async (req, res, next) => {
router.get(path, async (req, res, next) => {
try {
const { id } = (await validate(req, schema)).query;

View file

@ -2,18 +2,31 @@ import { appleMusicApi } from "../../../appleMusicApi/index.js";
import express from "express";
import { validate } from "../../validate.js";
import { z } from "zod";
import { paths } from "../../openApi.js";
const router = express.Router();
const path = "/getPlaylistMetadata";
const schema = z.object({
query: z.object({
id: z.string()
})
});
paths[path] = {
get: {
requestParams: { query: schema.shape.query },
responses: {
200: { description: "returns from the apple music api, playlist metadata with `tracks` relationship https://developer.apple.com/documentation/applemusicapi/get-a-catalog-playlist" },
400: { description: "bad request, invalid query parameters. sent as a zod error with details" },
default: { description: "upstream api error, or some other error" }
}
}
};
// see comments in `getTrackMetadata.ts`
// awawawawawa
router.get("/getPlaylistMetadata", async (req, res, next) => {
router.get(path, async (req, res, next) => {
try {
const { id } = (await validate(req, schema)).query;

View file

@ -2,19 +2,32 @@ import { appleMusicApi } from "../../../appleMusicApi/index.js";
import express from "express";
import { validate } from "../../validate.js";
import { z } from "zod";
import { paths } from "../../openApi.js";
const router = express.Router();
const path = "/getTrackMetadata";
const schema = z.object({
query: z.object({
id: z.string()
})
});
paths[path] = {
get: {
requestParams: { query: schema.shape.query },
responses: {
200: { description: "returns from the apple music api, track metadata with `extendedAssetUrls` extension and `albums` relationship https://developer.apple.com/documentation/applemusicapi/get-a-catalog-song" },
400: { description: "bad request, invalid query parameters. sent as a zod error with details" },
default: { description: "upstream api error, or some other error" }
}
}
};
// this endpoint isn't actually used for anything by us
// it's for people who want to implement apple music downloading into their own apps (ex. discord music bot)
// it makes it a bit easier to get the metadata for a track knowing the trackId
router.get("/getTrackMetadata", async (req, res, next) => {
router.get(path, async (req, res, next) => {
try {
const { id } = (await validate(req, schema)).query;

View file

@ -0,0 +1,20 @@
import express from "express";
import swaggerUi from "swagger-ui-express";
import { doc } from "../../openApi.js";
const router = express.Router();
router.use("/documentation", swaggerUi.serve);
router.get("/documentation", async (_req, res, next) => {
try {
res.send(swaggerUi.generateHTML(doc, {
customCss: ".swagger-ui .topbar { display: none }",
customSiteTitle: "amdl - documentation",
customfavIcon: "/favicon.png"
}));
} catch (err) {
next(err);
}
});
export default router;

View file

@ -1,6 +1,8 @@
import documentation from "./front/documentation.js";
import frontDownload from "./front/download.js";
import search from "./front/search.js";
export const front = [
documentation,
frontDownload,
search
];

View file

@ -7,6 +7,7 @@ import { ZodError } from "zod";
import { fromZodError } from "zod-validation-error";
import { AxiosError } from "axios";
import { env } from "../config.js";
import { createOpenApiDocument } from "./openApi.js";
export class HttpException extends Error {
public readonly status?: number;
@ -38,9 +39,15 @@ app.set("views", env.VIEWS_DIR);
app.use("/", express.static(env.PUBLIC_DIR));
app.get("/favicon.ico", (_req, res) => { res.status(301).location("/favicon.png").send(); });
// TODO: customize the "/api" prefix
// currently hardcoded in places like the frontend and openapi document
back.forEach((route) => { app.use("/api", route); });
front.forEach((route) => { app.use(route); });
// this is a bit of a hack, but it works
// we need to create the openapi document after all routes are registered, but before serving starts
createOpenApiDocument();
app.use((req, _res, next) => {
next(new HttpException(404, `${req.path} not found`));
});

35
src/web/openApi.ts Normal file
View file

@ -0,0 +1,35 @@
import { createDocument } from "zod-openapi";
import packageJson from "../../package.json" with { type: "json" };
// i hate doing this
// classic maneuver tho!
type OpenAPIObject = ReturnType<typeof createDocument>;
type ZodOpenApiPathsObject = NonNullable<Parameters<typeof createDocument>[0]["paths"]>;
export const paths: ZodOpenApiPathsObject = {};
// this seems a little race-conditiony
// but, it's actually pretty safe! in the web index file, we call createOpenApiDocument
// after all routes are registered, so this will be populated by then (then being serving)
export let doc: OpenAPIObject;
export function createOpenApiDocument(): void {
doc = createDocument({
openapi: "3.1.1", // most recent at the time of writing
info: {
title: packageJson.name,
version: packageJson.version,
description: packageJson.description,
contact: {
name: "reidlab",
url: "https://reidlab.pink/socials"
}
},
servers: [{
url: "/api"
}],
paths: {
...paths
}
});
}

View file

@ -1,7 +1,7 @@
import express from "express";
import { z, ZodSchema } from "zod";
import { z, ZodObject } from "zod";
export async function validate<T extends ZodSchema>(req: express.Request, schema: T): Promise<z.infer<T>> {
export async function validate<T extends ZodObject>(req: express.Request, schema: T): Promise<z.infer<T>> {
const result = await schema.parseAsync(req);
return result;
}