diff --git a/server/app.ts b/server/app.ts index 2785bc6..36a2d6e 100644 --- a/server/app.ts +++ b/server/app.ts @@ -11,6 +11,7 @@ import { config } from "./config"; import type { CleartextPassword, PasswordHash, Username } from "./types"; import { isString } from "./types"; import Logger from "./logger"; +import { HttpHeader, HttpStatusCode, MimeType } from "./shared/utils/http"; export const app: Express = express(); @@ -111,7 +112,7 @@ export function init(): void { router.use(compress()); async function serveTemplate( - mimeType: string, + mimeType: MimeType, req: Request, res: Response ): Promise { @@ -120,13 +121,15 @@ export function init(): void { "utf8" ); - res.writeHead(200, { "Content-Type": mimeType }); + res.writeHead(HttpStatusCode.OK, { + [HttpHeader.CONTENT_TYPE]: mimeType, + }); res.end(_.template(body)({ config: config.client })); } usePromise(async (req: Request, res: Response): Promise => { if (jsTemplateFiles.indexOf(req.path) >= 0) { - await serveTemplate("application/javascript", req, res); + await serveTemplate(MimeType.APPLICATION_JSON, req, res); } }); diff --git a/server/resources/mailResource.ts b/server/resources/mailResource.ts index 6c0cea1..29568e9 100644 --- a/server/resources/mailResource.ts +++ b/server/resources/mailResource.ts @@ -7,6 +7,7 @@ import { normalizeString, parseInteger } from "../shared/utils/strings"; import { forConstraint } from "../shared/validation/validator"; import type { Request, Response } from "express"; import { isString, Mail, MailId } from "../types"; +import { HttpHeader } from "../shared/utils/http"; const isValidId = forConstraint(CONSTRAINTS.id, false); @@ -39,7 +40,7 @@ async function doGetAll( export function getAll(req: Request, res: Response): void { doGetAll(req) .then(({ total, mails }) => { - res.set("X-Total-Count", total.toString(10)); + res.set(HttpHeader.X_TOTAL_COUNT, total.toString(10)); return Resources.success(res, mails); }) .catch((err) => Resources.error(res, err)); diff --git a/server/resources/monitoringResource.ts b/server/resources/monitoringResource.ts index a1be4bb..6dfca82 100644 --- a/server/resources/monitoringResource.ts +++ b/server/resources/monitoringResource.ts @@ -14,6 +14,7 @@ import { NodeMonitoringStateResponse, toMonitoringResponse, } from "../types"; +import { HttpHeader } from "../shared/utils/http"; const isValidToken = forConstraint(CONSTRAINTS.token, false); @@ -33,7 +34,7 @@ async function doGetAll( export function getAll(req: Request, res: Response): void { doGetAll(req) .then(({ total, result }) => { - res.set("X-Total-Count", total.toString(10)); + res.set(HttpHeader.X_TOTAL_COUNT, total.toString(10)); Resources.success(res, result); }) .catch((err) => Resources.error(res, err)); diff --git a/server/resources/nodeResource.ts b/server/resources/nodeResource.ts index 10b6d62..5f87796 100644 --- a/server/resources/nodeResource.ts +++ b/server/resources/nodeResource.ts @@ -27,6 +27,7 @@ import { toNodeTokenResponse, } from "../types"; import { filterUndefinedFromJSON } from "../shared/utils/json"; +import { HttpHeader } from "../shared/utils/http"; const nodeFields = [ "hostname", @@ -165,7 +166,7 @@ export function getAll(req: Request, res: Response): void { total: number; pageNodes: DomainSpecificNodeResponse[]; }) => { - res.set("X-Total-Count", result.total.toString(10)); + res.set(HttpHeader.X_TOTAL_COUNT, result.total.toString(10)); return Resources.success( res, result.pageNodes.map(filterUndefinedFromJSON) diff --git a/server/resources/taskResource.ts b/server/resources/taskResource.ts index feb03f6..01585a1 100644 --- a/server/resources/taskResource.ts +++ b/server/resources/taskResource.ts @@ -14,6 +14,7 @@ import { TaskState, UnixTimestampSeconds, } from "../types"; +import { HttpHeader } from "../shared/utils/http"; const isValidId = forConstraint(CONSTRAINTS.id, false); @@ -109,7 +110,7 @@ async function doGetAll( export function getAll(req: Request, res: Response): void { doGetAll(req) .then(({ total, pageTasks }) => { - res.set("X-Total-Count", total.toString(10)); + res.set(HttpHeader.X_TOTAL_COUNT, total.toString(10)); Resources.success(res, pageTasks.map(toTaskResponse)); }) .catch((err) => Resources.error(res, err)); diff --git a/server/shared/types/newtypes.ts b/server/shared/types/newtypes.ts index 144c0c0..057b961 100644 --- a/server/shared/types/newtypes.ts +++ b/server/shared/types/newtypes.ts @@ -68,6 +68,18 @@ export type Url = string & { readonly __tag: unique symbol }; */ export const isUrl = toIsNewtype(isString, "" as Url); +/** + * Typesafe string representation of paths. + */ +export type Path = string & { readonly __tag: unique symbol }; + +/** + * Type guard for {@link Path}. + * + * @param arg - Value to check. + */ +export const isPath = toIsNewtype(isString, "" as Url); + /** * Fastd VPN key of a Freifunk node. This is the key used by the node to open a VPN tunnel to Freifunk gateways. */ diff --git a/server/shared/utils/http.ts b/server/shared/utils/http.ts new file mode 100644 index 0000000..d0642ba --- /dev/null +++ b/server/shared/utils/http.ts @@ -0,0 +1,84 @@ +/** + * Enum representing supported HTTP methods. + */ +export enum HttpMethod { + /** + * See {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/GET}. + */ + GET = "GET", + + /** + * See {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/POST}. + */ + POST = "POST", + + /** + * See {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/PUT}. + */ + PUT = "PUT", + + /** + * See {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/DELETE}. + */ + DELETE = "DELETE", +} + +/** + * Enum representing supported HTTP headers. + */ +export enum HttpHeader { + /** + * See {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type}. + */ + CONTENT_TYPE = "Content-Type", + + /** + * Holds the total number of entities known by the server matching the request (ignoring paging parameters). + */ + X_TOTAL_COUNT = "X-Total-Count", +} + +/** + * Enum representing supported mime-types. + */ +export enum MimeType { + /** + * The content is JSON. + */ + APPLICATION_JSON = "application/json", + + /** + * The content is (X)HTML. + */ + TEXT_HTML = "text/html", +} + +/** + * Enum representing supported HTTP response status codes. + */ +export enum HttpStatusCode { + /** + * See {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/200}. + */ + OK = 200, + + /** + * See {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/400}. + */ + BAD_REQUEST = 400, + + /** + * See {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404}. + */ + NOT_FOUND = 404, + + /** + * See {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/409}. + */ + CONFLICT = 409, + + /** + * See {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500}. + */ + INTERNAL_SERVER_ERROR = 500, +} diff --git a/server/utils/errorTypes.ts b/server/utils/errorTypes.ts index 63de38a..b3d74a6 100644 --- a/server/utils/errorTypes.ts +++ b/server/utils/errorTypes.ts @@ -1,6 +1,9 @@ +import { HttpStatusCode } from "../shared/utils/http"; + +// TODO: Replace this by throwing typed errors. export default { - badRequest: { code: 400 }, - notFound: { code: 404 }, - conflict: { code: 409 }, - internalError: { code: 500 }, + badRequest: { code: HttpStatusCode.BAD_REQUEST }, + notFound: { code: HttpStatusCode.NOT_FOUND }, + conflict: { code: HttpStatusCode.CONFLICT }, + internalError: { code: HttpStatusCode.INTERNAL_SERVER_ERROR }, }; diff --git a/server/utils/resources.ts b/server/utils/resources.ts index 1600acc..cf2c9c9 100644 --- a/server/utils/resources.ts +++ b/server/utils/resources.ts @@ -22,6 +22,7 @@ import { TypeGuard, } from "../types"; import { getFieldIfExists } from "../shared/utils/objects"; +import { HttpHeader, HttpStatusCode, MimeType } from "../shared/utils/http"; export type RequestData = JSONObject; export type RequestHandler = (request: Request, response: Response) => void; @@ -46,30 +47,34 @@ export type FilterClause = { query: string; params: unknown[] }; function respond( res: Response, - httpCode: number, + httpCode: HttpStatusCode, data: string, - type: "html" + type: MimeType.TEXT_HTML ): void; function respond( res: Response, - httpCode: number, + httpCode: HttpStatusCode, data: JSONValue, - type: "json" + type: MimeType.APPLICATION_JSON ): void; function respond( res: Response, - httpCode: number, + httpCode: HttpStatusCode, data: JSONValue, - type: "html" | "json" + type: MimeType.APPLICATION_JSON | MimeType.TEXT_HTML ): void { switch (type) { - case "html": - res.writeHead(httpCode, { "Content-Type": "text/html" }); + case MimeType.TEXT_HTML: + res.writeHead(httpCode, { + [HttpHeader.CONTENT_TYPE]: MimeType.TEXT_HTML, + }); res.end(data); break; default: - res.writeHead(httpCode, { "Content-Type": "application/json" }); + res.writeHead(httpCode, { + [HttpHeader.CONTENT_TYPE]: MimeType.APPLICATION_JSON, + }); res.end(JSON.stringify(data)); break; } @@ -365,18 +370,18 @@ export function filterClause( } export function success(res: Response, data: JSONValue) { - respond(res, 200, data, "json"); + respond(res, HttpStatusCode.OK, data, MimeType.APPLICATION_JSON); } export function successHtml(res: Response, html: string) { - respond(res, 200, html, "html"); + respond(res, HttpStatusCode.OK, html, MimeType.APPLICATION_JSON); } export function error( res: Response, - err: { data: JSONValue; type: { code: number } } + err: { data: JSONValue; type: { code: HttpStatusCode } } ) { - respond(res, err.type.code, err.data, "json"); + respond(res, err.type.code, err.data, MimeType.APPLICATION_JSON); } export function handleJSON(