Просмотр исходного кода

feat: migrate to ts, but something still broke

Sven 2 лет назад
Родитель
Сommit
0d92ee6cac
10 измененных файлов с 928 добавлено и 109 удалено
  1. 1 0
      .gitignore
  2. 735 3
      package-lock.json
  3. 11 4
      package.json
  4. 0 17
      src/error.js
  5. 16 0
      src/error.ts
  6. 4 4
      src/index.ts
  7. 56 51
      src/server.ts
  8. 34 30
      src/songMeta.ts
  9. 51 0
      src/types.d.ts
  10. 20 0
      tsconfig.json

+ 1 - 0
.gitignore

@@ -2,3 +2,4 @@ node_modules/
 test.js
 test.js
 .env
 .env
 *.log
 *.log
+out/

Разница между файлами не показана из-за своего большого размера
+ 735 - 3
package-lock.json


+ 11 - 4
package.json

@@ -1,10 +1,11 @@
 {
 {
   "name": "geniurl",
   "name": "geniurl",
-  "version": "1.0.0",
+  "version": "1.1.0",
   "description": "Simple JSON and XML REST API to search for song metadata and the lyrics URL on genius.com",
   "description": "Simple JSON and XML REST API to search for song metadata and the lyrics URL on genius.com",
-  "main": "src/index.js",
+  "main": "src/index.ts",
   "scripts": {
   "scripts": {
-    "test": "echo \"Error: no test specified\" && exit 1"
+    "start": "tsc && node --enable-source-maps out/src/index.js",
+    "watch": "nodemon -e \"ts,d.ts\" -x \"npm start\""
   },
   },
   "repository": {
   "repository": {
     "type": "git",
     "type": "git",
@@ -40,7 +41,13 @@
     "tcp-port-used": "^1.0.2"
     "tcp-port-used": "^1.0.2"
   },
   },
   "devDependencies": {
   "devDependencies": {
+    "@types/compression": "^1.7.2",
+    "@types/cors": "^2.8.12",
+    "@types/express": "^4.17.14",
+    "@types/node": "^18.11.3",
+    "@types/tcp-port-used": "^1.0.1",
     "dotenv": "^16.0.0",
     "dotenv": "^16.0.0",
-    "eslint": "^8.9.0"
+    "eslint": "^8.9.0",
+    "nodemon": "^2.0.20"
   }
   }
 }
 }

+ 0 - 17
src/error.js

@@ -1,17 +0,0 @@
-const k = require("kleur");
-
-/**
- * @param {string} msg Error message
- * @param {Error} [err] Error instance
- * @param {boolean} [fatal=false] Exits with code 1 if set to true
- */
-function error(msg, err, fatal = false)
-{
-    console.error("\n");
-    console.error(k.red(msg));
-    err && console.error(err);
-
-    fatal && process.exit(1);
-}
-
-module.exports = error;

+ 16 - 0
src/error.ts

@@ -0,0 +1,16 @@
+const k = require("kleur");
+
+/**
+ * Handles an error
+ * @param msg Short error message
+ * @param err Error instance that caused the error
+ * @param fatal Exits with code 1 if set to true
+ */
+export function error(msg: string, err?: Error, fatal = false)
+{
+    console.error("\n");
+    console.error(k.red(msg));
+    err && console.error(err);
+
+    fatal && process.exit(1);
+}

+ 4 - 4
src/index.js → src/index.ts

@@ -1,9 +1,9 @@
-const dotenv = require("dotenv");
+import dotenv from "dotenv";
 
 
 dotenv.config();
 dotenv.config();
 
 
-const server = require("./server");
-const error = require("./error");
+import * as server from "./server";
+import { error } from "./error";
 
 
 
 
 async function init()
 async function init()
@@ -18,7 +18,7 @@ async function init()
     }
     }
     catch(err)
     catch(err)
     {
     {
-        error(`Error while ${stage}`, err, true);
+        error(`Error while ${stage}`, err instanceof Error ? err : undefined, true);
     }
     }
 }
 }
 
 

+ 56 - 51
src/server.js → src/server.ts

@@ -1,20 +1,17 @@
-const compression = require("compression");
-const express = require("express");
-const { check: portUsed } = require("tcp-port-used");
-const helmet = require("helmet");
-const { RateLimiterMemory } = require("rate-limiter-flexible");
-const k = require("kleur");
-const cors = require("cors");
-const jsonToXml = require("js2xmlparser");
-
-const packageJson = require("../package.json");
-const error = require("./error");
-const { getMeta } = require("./songMeta");
-
-/** @typedef {import("svcorelib").JSONCompatible} JSONCompatible */
-/** @typedef {import("express").Response} Response */
-/** @typedef {import("./types").ResponseType} ResponseType */
-/** @typedef {import("./types").ResponseFormat} ResponseFormat */
+import compression from "compression";
+import express, { ErrorRequestHandler, NextFunction, Request, Response } from "express";
+import { check as portUsed } from "tcp-port-used";
+import helmet from "helmet";
+import { RateLimiterMemory, RateLimiterRes } from "rate-limiter-flexible";
+import k from "kleur";
+import cors from "cors";
+import jsonToXml from "js2xmlparser";
+
+import packageJson from "../package.json";
+import { error } from "./error";
+import { getMeta } from "./songMeta";
+import { ResponseType } from "./types";
+import { Errors, Stringifiable } from "svcorelib";
 
 
 const app = express();
 const app = express();
 
 
@@ -29,17 +26,17 @@ const rateLimiter = new RateLimiterMemory({
 });
 });
 
 
 
 
-async function init()
+export async function init()
 {
 {
-    const port = parseInt(process.env.HTTP_PORT);
+    const port = parseInt(String(process.env.HTTP_PORT));
 
 
     if(await portUsed(port))
     if(await portUsed(port))
         return error(`TCP port ${port} is already used`, undefined, true);
         return error(`TCP port ${port} is already used`, undefined, true);
 
 
     // on error
     // on error
-    app.use((err, req, res, next) => {
+    app.use((err: any, req: Request, res: Response, next: NextFunction) => {
         if(typeof err === "string" || err instanceof Error)
         if(typeof err === "string" || err instanceof Error)
-            return respond(res, "serverError", `General error in HTTP server: ${err.toString()}`, req?.query?.format);
+            return respond(res, "serverError", `General error in HTTP server: ${err.toString()}`, req?.query?.format ? String(req.query.format) : undefined);
         else
         else
             return next();
             return next();
     });
     });
@@ -49,17 +46,19 @@ async function init()
 
 
         // rate limiting
         // rate limiting
         app.use(async (req, res, next) => {
         app.use(async (req, res, next) => {
-            try
-            {
-                await rateLimiter.consume(req.ip);
-            }
-            catch(rlRejected)
-            {
-                res.set("Retry-After", String(rlRejected?.msBeforeNext ? Math.round(rlRejected.msBeforeNext / 1000) : 1));
-                return respond(res, 429, { message: "You are being rate limited" }, req?.query?.format);
-            }
-
-            return next();
+            const fmt = req?.query?.format ? String(req.query.format) : undefined;
+
+            res.setHeader("API-Info", `geniURL v${packageJson.version} (${packageJson.homepage})`);
+
+            rateLimiter.consume(req.ip)
+                .catch((err) => {
+                    if(err instanceof RateLimiterRes) {
+                        res.set("Retry-After", String(Math.ceil(err.msBeforeNext / 1000)));
+                        return respond(res, 429, { message: "You are being rate limited" }, fmt);
+                    }
+                    else return respond(res, 500, { message: "Internal error in rate limiting middleware. Please try again later." }, fmt);
+                })
+                .finally(next);
         });
         });
 
 
         registerEndpoints();
         registerEndpoints();
@@ -78,16 +77,23 @@ function registerEndpoints()
             res.redirect(packageJson.homepage);
             res.redirect(packageJson.homepage);
         });
         });
 
 
-        const hasArg = (val) => typeof val === "string" && val.length > 0;
+        const hasArg = (val: unknown) => typeof val === "string" && val.length > 0;
 
 
         app.get("/search", async (req, res) => {
         app.get("/search", async (req, res) => {
             try
             try
             {
             {
-                const { q, artist, song, format } = req.query;
+                const { q, artist, song, format: fmt } = req.query;
+
+                const format = fmt ? String(fmt) : "json";
 
 
                 if(hasArg(q) || (hasArg(artist) && hasArg(song)))
                 if(hasArg(q) || (hasArg(artist) && hasArg(song)))
                 {
                 {
-                    const meta = await getMeta({ q, artist, song });
+                    const meta = await getMeta(q ? {
+                        q: String(q),
+                    } : {
+                        artist: String(artist),
+                        song: String(song),
+                    });
 
 
                     if(!meta || meta.all.length < 1)
                     if(!meta || meta.all.length < 1)
                         return respond(res, "clientError", "Found no results matching your search query", format, 0);
                         return respond(res, "clientError", "Found no results matching your search query", format, 0);
@@ -98,22 +104,29 @@ function registerEndpoints()
                     return respond(res, "success", response, format, meta.all.length);
                     return respond(res, "success", response, format, meta.all.length);
                 }
                 }
                 else
                 else
-                    return respond(res, "clientError", "No search params (?q or ?song and ?artist) provided or they are invalid", req?.query?.format);
+                    return respond(res, "clientError", "No search params (?q or ?song and ?artist) provided or they are invalid", req?.query?.format ? String(req.query.format) : undefined);
             }
             }
             catch(err)
             catch(err)
             {
             {
-                return respond(res, "serverError", `Encountered an internal server error${err instanceof Error ? err.message : ""}`, "json");
+                return respond(res, "serverError", `Encountered an internal server error: ${err instanceof Error ? err.message : ""}`, "json");
             }
             }
         });
         });
 
 
         app.get("/search/top", async (req, res) => {
         app.get("/search/top", async (req, res) => {
             try
             try
             {
             {
-                const { q, artist, song, format } = req.query;
+                const { q, artist, song, format: fmt } = req.query;
+
+                const format = fmt ? String(fmt) : "json";
 
 
                 if(hasArg(q) || (hasArg(artist) && hasArg(song)))
                 if(hasArg(q) || (hasArg(artist) && hasArg(song)))
                 {
                 {
-                    const meta = await getMeta({ q, artist, song });
+                    const meta = await getMeta(q ? {
+                        q: String(q),
+                    } : {
+                        artist: String(artist),
+                        song: String(song),
+                    });
 
 
                     if(!meta || !meta.top)
                     if(!meta || !meta.top)
                         return respond(res, "clientError", "Found no results matching your search query", format, 0);
                         return respond(res, "clientError", "Found no results matching your search query", format, 0);
@@ -121,7 +134,7 @@ function registerEndpoints()
                     return respond(res, "success", meta.top, format, 1);
                     return respond(res, "success", meta.top, format, 1);
                 }
                 }
                 else
                 else
-                    return respond(res, "clientError", "No search params (?q or ?song and ?artist) provided or they are invalid", req?.query?.format);
+                    return respond(res, "clientError", "No search params (?q or ?song and ?artist) provided or they are invalid", req?.query?.format ? String(req.query.format) : undefined);
             }
             }
             catch(err)
             catch(err)
             {
             {
@@ -131,17 +144,11 @@ function registerEndpoints()
     }
     }
     catch(err)
     catch(err)
     {
     {
-        error("Error while registering endpoints", err, true);
+        error("Error while registering endpoints", err instanceof Error ? err : undefined, true);
     }
     }
 }
 }
 
 
-/**
- * @param {Response} res
- * @param {ResponseType|number} type Specifies the type of response and thus a predefined status code - overload: set to number for custom status code
- * @param {JSONCompatible} data JSON object for "success", else an error message string
- * @param {ResponseFormat} [format]
- */
-function respond(res, type, data, format, matchesAmt)
+function respond(res: Response, type: ResponseType | number, data: Stringifiable | Record<string, unknown>, format = "json", matchesAmt = 0)
 {
 {
     let statusCode = 500;
     let statusCode = 500;
     let error = true;
     let error = true;
@@ -158,7 +165,7 @@ function respond(res, type, data, format, matchesAmt)
     {
     {
         case "success":
         case "success":
             error = false;
             error = false;
-            matches = matchesAmt ?? 0;
+            matches = matchesAmt;
             statusCode = 200;
             statusCode = 200;
             resData = { ...data };
             resData = { ...data };
             break;
             break;
@@ -199,5 +206,3 @@ function respond(res, type, data, format, matchesAmt)
     res.setHeader("Content-Type", mimeType);
     res.setHeader("Content-Type", mimeType);
     res.status(statusCode).send(finalData);
     res.status(statusCode).send(finalData);
 }
 }
-
-module.exports = { init };

+ 34 - 30
src/songMeta.js → src/songMeta.ts

@@ -1,22 +1,22 @@
-const axios = require("axios");
-const Fuse = require("fuse.js");
-const { randomUUID } = require("crypto");
-const { reserialize } = require("svcorelib");
+import axios from "axios";
+import Fuse from "fuse.js";
+import { randomUUID } from "crypto";
+import { JSONCompatible, reserialize } from "svcorelib";
+import { ApiSearchResult, SongMeta } from "./types";
 
 
-/** @typedef {import("./types").SongMeta} SongMeta */
+type SearchHit = (SongMeta & { uuid?: string; });
 
 
 /**
 /**
  * Returns meta information about the top results of a search using the genius API
  * Returns meta information about the top results of a search using the genius API
- * @param {Record<"q"|"artist"|"song", string|undefined>} search
- * @returns {Promise<{ top: SongMeta, all: SongMeta[] } | null>} Resolves null if no results are found
+ * @param param0 Pass an object with either a `q` prop or the props `artist` and `song` to make use of fuzzy filtering
  */
  */
-async function getMeta({ q, artist, song })
+export async function getMeta({ q, artist, song }: Partial<Record<"q" | "artist" | "song", string>>): Promise<{ top: SongMeta, all: SongMeta[] } | null>
 {
 {
     const accessToken = process.env.GENIUS_ACCESS_TOKEN ?? "ERR_NO_ENV";
     const accessToken = process.env.GENIUS_ACCESS_TOKEN ?? "ERR_NO_ENV";
 
 
     const query = q ? q : `${artist} ${song}`;
     const query = q ? q : `${artist} ${song}`;
 
 
-    const { data: { response }, status } = await axios.get(`https://api.genius.com/search?q=${encodeURIComponent(query)}`, {
+    const { data: { response }, status } = await axios.get<ApiSearchResult>(`https://api.genius.com/search?q=${encodeURIComponent(query)}`, {
         headers: { "Authorization": `Bearer ${accessToken}` },
         headers: { "Authorization": `Bearer ${accessToken}` },
     });
     });
 
 
@@ -25,7 +25,7 @@ async function getMeta({ q, artist, song })
         if(response.hits.length === 0)
         if(response.hits.length === 0)
             return null;
             return null;
 
 
-        let hits = response.hits
+        let hits: SearchHit[] = response.hits
             .filter(h => h.type === "song")
             .filter(h => h.type === "song")
             .map(({ result }) => ({
             .map(({ result }) => ({
                 url: result.url,
                 url: result.url,
@@ -38,6 +38,7 @@ async function getMeta({ q, artist, song })
                         name: normalizeString(result.primary_artist.name),
                         name: normalizeString(result.primary_artist.name),
                         url: result.primary_artist.url,
                         url: result.primary_artist.url,
                     },
                     },
+                    release: result.release_date_components,
                 },
                 },
                 resources: {
                 resources: {
                     thumbnail: result.song_art_image_thumbnail_url,
                     thumbnail: result.song_art_image_thumbnail_url,
@@ -49,13 +50,12 @@ async function getMeta({ q, artist, song })
 
 
         if(artist && song)
         if(artist && song)
         {
         {
-            /** @type {Record<string, number>} */
-            const scoreMap = {};
+            const scoreMap: Record<string, number> = {};
 
 
             hits = hits.map(h => {
             hits = hits.map(h => {
                 h.uuid = randomUUID();
                 h.uuid = randomUUID();
                 return h;
                 return h;
-            });
+            }) as (SongMeta & { uuid: string })[];
 
 
             const fuseOpts = {
             const fuseOpts = {
                 ignoreLocation: true,
                 ignoreLocation: true,
@@ -67,32 +67,40 @@ async function getMeta({ q, artist, song })
             const artistFuse = new Fuse(hits, { ...fuseOpts, keys: [ "meta.primaryArtist.name" ] });
             const artistFuse = new Fuse(hits, { ...fuseOpts, keys: [ "meta.primaryArtist.name" ] });
 
 
             /** @param {({ item: { uuid: string }, score: number })[]} searchRes */
             /** @param {({ item: { uuid: string }, score: number })[]} searchRes */
-            const addScores = (searchRes) => searchRes.forEach(({ item, score }) => {
-                if(!scoreMap[item.uuid])
-                    scoreMap[item.uuid] = score;
-                else
-                    scoreMap[item.uuid] += score;
-            });
+            const addScores = (searchRes: Fuse.FuseResult<SongMeta & { uuid?: string; }>[]) =>
+                searchRes.forEach(({ item, score }) => {
+                    if(!item.uuid || !score)
+                        return;
+
+                    if(!scoreMap[item.uuid])
+                        scoreMap[item.uuid] = score;
+                    else
+                        scoreMap[item.uuid] += score;
+                });
 
 
             addScores(titleFuse.search(song));
             addScores(titleFuse.search(song));
             addScores(artistFuse.search(artist));
             addScores(artistFuse.search(artist));
 
 
             const bestMatches = Object.entries(scoreMap)
             const bestMatches = Object.entries(scoreMap)
-                .sort(([, valA], [, valB]) => valA > valB)
+                .sort(([, valA], [, valB]) => valA > valB ? 1 : -1) // TODO: check
                 .map(e => e[0]);
                 .map(e => e[0]);
 
 
-            const oldHits = reserialize(hits);
+            const oldHits = reserialize(hits as unknown as JSONCompatible) as unknown as SearchHit[];
 
 
             hits = bestMatches
             hits = bestMatches
                 .map(uuid => oldHits.find(h => h.uuid === uuid))
                 .map(uuid => oldHits.find(h => h.uuid === uuid))
                 .map(hit => {
                 .map(hit => {
-                    delete hit.uuid;
-                    return hit;
-                });
+                    if(hit)
+                    {
+                        delete hit.uuid;
+                        return hit;
+                    }
+                })
+                .filter(h => h !== undefined) as SearchHit[];
         }
         }
 
 
         return {
         return {
-            top: hits[0],
+            top: hits[0] as SearchHit,
             all: hits.slice(0, 10),
             all: hits.slice(0, 10),
         };
         };
     }
     }
@@ -102,12 +110,8 @@ async function getMeta({ q, artist, song })
 
 
 /**
 /**
  * Removes invisible characters and control characters from a string
  * Removes invisible characters and control characters from a string
- * @param {string} str
- * @returns {string}
  */
  */
-function normalizeString(str)
+function normalizeString(str: string)
 {
 {
     return str.replace(/[\u0000-\u001F\u007F-\u009F\u200B]/g, "").replace(/\u00A0/g, " ");
     return str.replace(/[\u0000-\u001F\u007F-\u009F\u200B]/g, "").replace(/\u00A0/g, " ");
 }
 }
-
-module.exports = { getMeta };

+ 51 - 0
src/types.d.ts

@@ -25,3 +25,54 @@ export interface SongMeta {
 export type ResponseType = "serverError" | "clientError" | "success";
 export type ResponseType = "serverError" | "clientError" | "success";
 
 
 export type ResponseFormat = "json" | "xml";
 export type ResponseFormat = "json" | "xml";
+
+//#SECTION API
+
+export type ApiSearchResult = {
+    response: {
+        hits: SearchHit[];
+    };
+};
+
+export type SearchHit = {
+    type: "song";
+    result: {
+        artist_names: string;
+        full_title: string;
+        header_image_thumbnail_url: string;
+        header_image_url: string;
+        id: number;
+        language: string;
+        lyrics_owner_id: number;
+        lyrics_state: "complete";
+        path: string;
+        pyongs_count: number;
+        relationships_index_url: string;
+        release_date_components: {
+            year: number;
+            month: number;
+            day: number;
+        };
+        song_art_image_thumbnail_url: string;
+        song_art_image_url: string;
+        title: string;
+        title_with_featured: string;
+        url: string;
+        featured_artists: {
+            api_path: string;
+            header_image_url: string;
+            id: number;
+            image_url: string;
+            name: string;
+            url: string;
+        }[];
+        primary_artist: {
+            api_path: string;
+            header_image_url: string;
+            id: number;
+            image_url: string;
+            name: string;
+            url: string;
+        };
+    };
+};

+ 20 - 0
tsconfig.json

@@ -0,0 +1,20 @@
+{
+  "compilerOptions": {
+    "baseUrl": ".",
+    "target": "es2022",
+    "module": "CommonJS",
+    "rootDir": ".",
+    "outDir": "./out/",
+    "moduleResolution": "node",
+    "useDefineForClassFields": true,
+    "allowJs": false,
+    "skipLibCheck": true,
+    "esModuleInterop": true,
+    "allowSyntheticDefaultImports": true,
+    "strict": true,
+    "forceConsistentCasingInFileNames": true,
+    "resolveJsonModule": true,
+    "isolatedModules": true,
+    "noUncheckedIndexedAccess": true,
+  }
+}

Некоторые файлы не были показаны из-за большого количества измененных файлов