Implementing API - WIP frontend

This commit is contained in:
2026-07-26 11:37:03 +02:00
parent e989594b96
commit 8b1d3288c0
12 changed files with 2222 additions and 519 deletions
+136 -42
View File
@@ -7,18 +7,17 @@
* @requires node:http
* @requires node:fs/promises
* @requires node:path
* @requires ./vmdParser.js
* @requires ./gpxParser.js
* @requires ./files.js
*
* @see {@link https://nodejs.org/api/http.html}
* @see {@link https://nodejs.org/api/fs.html}
*/
import * as http from "node:http";
import {URL} from "node:url";
import fs from "node:fs/promises";
import {join, resolve, extname} from "node:path";
import {vmdParser, VMDReference} from "./vmdParser.js";
import {gpxParser} from "./gpxParser.js";
import {DataFile, GpxFile, VmdFile, VMDReference} from "./fileHandler.js";
/**
* Configuration options for the web server.
@@ -26,7 +25,7 @@ import {gpxParser} from "./gpxParser.js";
* @typedef {Object} WebserverOptions
* @property {number} [port=4399] - Port number to listen on
* @property {string} [publicDir='app/public'] - Directory for static assets
* @property {string} [dataDir='data/routes'] - Directory for route data files
* @property {string} [dataDir='data'] - Path to data directory
* @property {number} [maxBodySize=1048576] - Maximum request body size in bytes (1MB)
* @property {number} [timeout=30000] - Request timeout in milliseconds (30s)
*/
@@ -154,7 +153,7 @@ export default class Webserver {
* maxBodySize: 2097152
* });
*/
constructor(options = {}) {
constructor(options = {dataDir: null}) {
this.#validateOptions(options);
this.port = options.port ?? 4399;
@@ -195,6 +194,9 @@ export default class Webserver {
if (options.publicDir !== undefined && typeof options.publicDir !== "string")
throw new TypeError("publicDir must be a string");
if (options.dataDir !== undefined && typeof options.dataDir !== "string")
throw new TypeError("dataDir must be a string");
if (
options.maxBodySize !== undefined &&
(typeof options.maxBodySize !== "number" || options.maxBodySize <= 0)
@@ -342,8 +344,7 @@ export default class Webserver {
* // Returns "text/html; charset=utf-8"
*/
#getMimeType(filePath) {
const ext = extname(filePath).toLowerCase();
return MIME_TYPES[ext] || "application/text";
return MIME_TYPES[extname(filePath).toLowerCase()] || "application/text";
}
/**
@@ -398,20 +399,28 @@ export default class Webserver {
*/
async #handleGpxRequest(req, res) {
const relativePath = req.url.slice(1); // Remove leading slash
const safePath = this.#resolveSafePath(this.#dataDir, relativePath);
if (!safePath) {
this.#sendErrorPage(res, STATUS_CODES.FORBIDDEN, "Invalid path");
return;
}
try {
const data = await gpxParser(safePath, "auto-generated-ref");
// Use DataFile.open() which returns GpxFile for .gpx extensions
const file = DataFile.open(relativePath, this.#dataDir);
// Parse the GPX file
await file.parse(null);
// Return parsed data
res.writeHead(STATUS_CODES.OK, {"Content-Type": "application/json; charset=utf-8"});
res.end(JSON.stringify(data, null, 2));
res.end(
JSON.stringify(
{Reference: file.reference, Title: file.title, points: file.points},
null,
2
)
);
} catch (error) {
console.error("[GPX ERROR]", error.message);
this.#sendErrorPage(res, STATUS_CODES.INTERNAL_ERROR, "Failed to parse GPX file");
if (error.message.includes("not found"))
this.#sendErrorPage(res, STATUS_CODES.NOT_FOUND, error.message);
else this.#sendErrorPage(res, STATUS_CODES.INTERNAL_ERROR, "Failed to parse GPX file");
}
}
@@ -502,6 +511,82 @@ export default class Webserver {
}
}
/**
* Handle API calls
*
* @private
* @method handleAPIcall
* @param {http.IncomingMessage} req - Request object
* @param {http.ServerResponse} res - Response object
* @param {string} body - Parsed request body content
*
* @returns {Promise<void>}
*/
async #handleAPIcall(req, res, body) {
const apiurl = new URL(req.url, `api://localhost:${this.port}/`);
const [contentType, action] = apiurl.pathname.replace("/api/", "").split("/");
switch (contentType) {
case "vmd": {
switch (action) {
case "countries": {
const list = await DataFile.list(
apiurl.searchParams.get("type"),
this.#dataDir,
{onlyfiles: false}
);
res.writeHead(STATUS_CODES.OK, {
"Content-Type": "application/json; charset=utf-8",
});
res.end(JSON.stringify(list));
break;
}
case "entries": {
const relativePath =
apiurl.searchParams.get("type") +
"/" +
apiurl.searchParams.get("country");
console.log(relativePath);
const list = await VmdFile.list(relativePath, this.#dataDir, {depth: 1});
res.writeHead(STATUS_CODES.OK, {
"Content-Type": "application/json; charset=utf-8",
});
res.end(JSON.stringify(list));
break;
}
case "entry": {
const vmdobj = new VmdFile(body, this.#dataDir);
const t = await vmdobj.parse();
res.writeHead(STATUS_CODES.OK, {
"Content-Type": "application/json; charset=utf-8",
});
res.end(JSON.stringify(vmdobj));
break;
}
case "reference": {
res.writeHead(STATUS_CODES.OK, {
"Content-Type": "application/json; charset=utf-8",
});
res.end(JSON.stringify(new VMDReference(body, this.#dataDir)));
break;
}
default:
res.writeHead(STATUS_CODES.NOT_FOUND, {
"Content-Type": "application/json; charset=utf-8",
});
res.end(
JSON.stringify({error: "endpoint does not exist or is not implemented"})
);
break;
}
break;
}
}
return;
}
/**
* Handle VMD reference requests.
* Processes POST requests with VMD reference data.
@@ -517,7 +602,7 @@ export default class Webserver {
*/
async #handleVmdRequest(req, res, body) {
try {
const vmd = new VMDReference(body);
const vmd = new VMDReference(body, this.#dataDir);
if (!vmd.path) {
const defaultResponse = {
@@ -532,26 +617,35 @@ export default class Webserver {
return;
}
const safePath = this.#resolveSafePath(this.#dataDir, vmd.path);
// Use DataFile.open() which returns VmdFile or GpxFile based on extension
const file = DataFile.open(vmd.path, this.#dataDir);
if (!safePath)
return this.#sendErrorPage(res, STATUS_CODES.FORBIDDEN, "Invalid VMD path");
// Parse the file (handles both VMD and GPX)
await file.parse(vmd);
if (vmd.path.endsWith(".vmd")) {
const data = await fs.readFile(safePath, "utf-8");
const vmdObject = vmdParser(data, vmd);
// Return parsed data
let responseData;
if (file instanceof VmdFile) {
responseData = file.toJSON();
} else if (file instanceof GpxFile) {
responseData = {Reference: file.reference, Title: file.title, points: file.points};
} else {
// Generic file
responseData = {content: file.content};
}
res.writeHead(STATUS_CODES.OK, {"Content-Type": "application/json; charset=utf-8"});
res.end(JSON.stringify(vmdObject, null, 2));
} else if (vmd.path.endsWith(".gpx")) {
const data = await gpxParser(safePath, vmd);
res.writeHead(STATUS_CODES.OK, {"Content-Type": "application/json; charset=utf-8"});
res.end(JSON.stringify(data, null, 2));
} else this.#sendErrorPage(res, STATUS_CODES.BAD_REQUEST, "Unsupported file type");
res.writeHead(STATUS_CODES.OK, {"Content-Type": "application/json; charset=utf-8"});
res.end(JSON.stringify(responseData, null, 2));
} catch (error) {
console.error("[VMD ERROR]", error.message);
this.#sendErrorPage(res, STATUS_CODES.INTERNAL_ERROR, "Failed to process VMD request");
if (error.message.includes("not found"))
this.#sendErrorPage(res, STATUS_CODES.NOT_FOUND, error.message);
else
this.#sendErrorPage(
res,
STATUS_CODES.INTERNAL_ERROR,
"Failed to process VMD request"
);
}
}
@@ -632,6 +726,11 @@ export default class Webserver {
return;
}
if (decodedUrl.startsWith("/api/")) {
const body = await this.#collectBody(req);
return await this.#handleAPIcall(req, res, body);
}
// 2. POST requests with VMD reference
if (req.method === "POST" && req.headers["content-type"] === "vmd/reference") {
const body = await this.#collectBody(req);
@@ -646,24 +745,19 @@ export default class Webserver {
}
// 4. Static public files
const relativePath = decodedUrl.slice(1) || "index.html";
let filePath = join(this.#publicDir, relativePath);
let relativePath = decodedUrl.slice(1) || "index.html";
// Append .html extension if no extension present
if (!extname(relativePath)) {
filePath = join(this.#publicDir, relativePath + ".html");
}
if (!extname(relativePath)) relativePath += ".html";
const safePath = this.#resolveSafePath(this.#publicDir, relativePath);
if (!safePath) {
this.#sendErrorPage(res, STATUS_CODES.FORBIDDEN, "Invalid path");
return;
}
if (!safePath) return this.#sendErrorPage(res, STATUS_CODES.FORBIDDEN, "Invalid path");
await this.#serveStaticFile(res, safePath, true);
} catch (error) {
console.error("[REQUEST ERROR]", error.message);
throw error;
this.#sendErrorPage(res, STATUS_CODES.INTERNAL_ERROR, "An unexpected error occurred");
} finally {
const duration = Date.now() - startTime;