diff --git a/server/resources/mailResource.ts b/server/resources/mailResource.ts index 29568e9..7210b95 100644 --- a/server/resources/mailResource.ts +++ b/server/resources/mailResource.ts @@ -3,11 +3,12 @@ import ErrorTypes from "../utils/errorTypes"; import * as MailService from "../services/mailService"; import * as Resources from "../utils/resources"; import { handleJSONWithData, RequestData } from "../utils/resources"; -import { normalizeString, parseInteger } from "../shared/utils/strings"; +import { normalizeString } 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"; +import { parseToInteger } from "../shared/utils/numbers"; const isValidId = forConstraint(CONSTRAINTS.id, false); @@ -22,7 +23,7 @@ async function withValidMailId(data: RequestData): Promise { throw { data: "Invalid mail id.", type: ErrorTypes.badRequest }; } - return parseInteger(id) as MailId; + return parseToInteger(id) as MailId; } export const get = handleJSONWithData(async (data) => { diff --git a/server/services/monitoringService.ts b/server/services/monitoringService.ts index 57c915a..0736402 100644 --- a/server/services/monitoringService.ts +++ b/server/services/monitoringService.ts @@ -11,7 +11,8 @@ import * as MailService from "../services/mailService"; import * as NodeService from "../services/nodeService"; import * as Resources from "../utils/resources"; import type { RestParams } from "../utils/resources"; -import { normalizeMac, parseInteger } from "../shared/utils/strings"; +import { normalizeMac } from "../shared/utils/strings"; +import { parseToInteger } from "../shared/utils/numbers"; import { monitoringDisableUrl } from "../utils/urlBuilder"; import CONSTRAINTS from "../shared/validation/constraints"; import { forConstraint } from "../shared/validation/validator"; @@ -525,7 +526,7 @@ async function sendOfflineMails( startTime: UnixTimestampSeconds, mailType: MailType ): Promise { - const mailNumber = parseInteger(mailType.split("-")[2]); + const mailNumber = parseToInteger(mailType.split("-")[2]); await sendMonitoringMailsBatched( "offline " + mailNumber, mailType, diff --git a/server/shared/types/primitives.ts b/server/shared/types/primitives.ts index d9a6acf..fcc48a7 100644 --- a/server/shared/types/primitives.ts +++ b/server/shared/types/primitives.ts @@ -24,6 +24,15 @@ export function isInteger(arg: unknown): arg is number { return isNumber(arg) && Number.isInteger(arg); } +/** + * Type guard checking the given value is a floating point `number`. + * + * @param arg - Value to check. + */ +export function isFloat(arg: unknown): arg is number { + return isNumber(arg) && Number.isFinite(arg); +} + // ===================================================================================================================== // Strings // ===================================================================================================================== diff --git a/server/shared/utils/numbers.ts b/server/shared/utils/numbers.ts new file mode 100644 index 0000000..0955ef0 --- /dev/null +++ b/server/shared/utils/numbers.ts @@ -0,0 +1,163 @@ +/** + * Utility functions for numbers. + */ +import { isFloat, isInteger } from "../types"; + +// TODO: Write tests! + +/** + * Normalizes the given `string` before parsing as a `number` by: + * + * * Removing whitespace from beginning and end of the `string`. + * * Removing one `+` at the beginning of the number. + * + * @param arg - String to normalize. + * @returns The normalized string. + */ +function normalizeStringToParse(arg: string): string { + const str = (arg as string).trim(); + return str.startsWith("+") ? str.slice(1) : str; +} + +/** + * Parses the given value and converts it into an integer. + * + * For a `string` to be considered a valid representation of an integer `number` it has to satisfy the + * following criteria: + * + * * The integer must be base 10. + * * The `string` starts with an optional `+` or `-` sign followed by one or more digits. + * * The first digit must not be `0`. + * * Surrounding whitespaces will be ignored. + * + * @param arg - Value to parse. + * @returns The parsed integer `number`. + * @throws {@link SyntaxError} - If the given value does not represent a valid integer. + * @throws {@link RangeError} - If the given value is a non-integer number. + * @throws {@link TypeError} - If the given value is neither an integer `number` nor a `string`. + */ +export function parseToInteger(arg: unknown): number; + +/** + * Parses the given value and converts it into an integer. + * + * For a `string` to be considered a valid representation of an integer `number` it has to satisfy the + * following criteria: + * + * * The integer base matches the given radix. + * * The `string` starts with an optional `+` or `-` sign followed by one or more digits. + * * The first digit must not be `0`. + * * Surrounding whitespaces will be ignored. + * + * @param arg - Value to parse. + * @param radix - Integer base to parse against. Must be an integer in range `2 <= radix <= 36`. + * @returns The parsed integer `number`. + * @throws {@link SyntaxError} - If the given value does not represent a valid integer. + * @throws {@link RangeError} - If the given value is a non-integer number or the given radix is out of range. + * @throws {@link TypeError} - If the given value is neither an integer `number` nor a `string`. + */ +export function parseToInteger(arg: unknown, radix: number): number; + +/** + * Parses the given value and converts it into an integer. + * + * For a `string` to be considered a valid representation of an integer `number` it has to satisfy the + * following criteria: + * + * * The integer base matches the given or default radix. + * * The `string` starts with an optional `+` or `-` sign followed by one or more digits. + * * The first digit must not be `0`. + * * Surrounding whitespaces will be ignored. + * + * @param arg - Value to parse. + * @param radix - Optional: Integer base to parse against. Must be an integer in range `2 <= radix <= 36`. + * @returns The parsed integer `number`. + * @throws {@link SyntaxError} - If the given value does not represent a valid integer. + * @throws {@link RangeError} - If the given value is a non-integer number or the given radix is out of range. + * @throws {@link TypeError} - If the given value is neither an integer `number` nor a `string`. + */ +export function parseToInteger(arg: unknown, radix?: number): number { + if (!radix) { + radix = 10; + } + if (radix < 2 || radix > 36 || isNaN(radix)) { + throw new RangeError(`Radix out of range: ${radix}`); + } + + if (isInteger(arg)) { + return arg; + } + + switch (typeof arg) { + case "number": + throw new RangeError(`Not an integer: ${arg}`); + case "string": { + const str = normalizeStringToParse(arg as string); + const num = parseInt(str, radix); + if (isNaN(num)) { + throw new SyntaxError( + `Not a valid number (radix: ${radix}): ${str}` + ); + } + if (num.toString(radix).toLowerCase() !== str.toLowerCase()) { + throw new SyntaxError( + `Parsed integer does not match given string (radix: ${radix}): ${str}` + ); + } + return num; + } + default: + throw new TypeError( + `Cannot parse number (radix: ${radix}): ${arg} of type ${typeof arg}` + ); + } +} + +/** + * Parses the given value and converts it into a finite floating point `number`. + * + * For a `string` to be considered a valid representation of a floating point `number` it has to satisfy the + * following criteria: + * + * * The number is base 10. + * * The `string` starts with an optional `+` or `-` sign followed by one or more digits and at most one decimal point. + * * It starts with at most one leading `0` (after the optional sign). + * * Surrounding whitespaces will be ignored. + * + * @param arg - Value to parse. + * @returns The parsed floating point `number`. + * @throws {@link SyntaxError} - If the given value does not represent a finite floating point number. + * @throws {@link RangeError} - If the given value is number that is either not finite or is `NaN`. + * @throws {@link TypeError} - If the given value is neither a floating point `number` nor a `string`. + */ +export function parseToFloat(arg: unknown): number { + if (isFloat(arg)) { + return arg; + } + switch (typeof arg) { + case "number": + throw new Error(`Not a finite number: ${arg}`); + case "string": { + let str = (arg as string).trim(); + const num = parseFloat(str); + if (isNaN(num)) { + throw new Error(`Not a valid number: ${str}`); + } + + if (Number.isInteger(num)) { + str = str.replace(/\.0+$/, ""); + } + + if (num.toString(10) !== str) { + throw new Error( + `Parsed float does not match given string: ${num.toString( + 10 + )} !== ${str}` + ); + } + return num; + } + default: + throw new Error(`Cannot parse number: ${arg}`); + } +} diff --git a/server/shared/utils/strings.ts b/server/shared/utils/strings.ts index 0068e8a..95df1ab 100644 --- a/server/shared/utils/strings.ts +++ b/server/shared/utils/strings.ts @@ -1,7 +1,7 @@ /** * Utility functions all around strings. */ -import { isInteger, type MAC } from "../types"; +import type { MAC } from "../types"; /** * Trims the given `string` and replaces multiple whitespaces by one space each. @@ -39,31 +39,3 @@ export function normalizeMac(mac: MAC): MAC { return macParts.join(":") as MAC; } - -/** - * Parses the given `string` and converts it into an integer. - * - * For a `string` to be considered a valid representation of an integer `number` it has to satisfy the - * following criteria: - * - * * The integer is base `10`. - * * The `string` starts with an optional `+` or `-` sign followed by one or more digits. - * * The first digit must not be `0`. - * * The `string` does not contain any other characters. - * - * @param str - `string` to parse. - * @returns The parsed integer `number`. - * @throws {@link SyntaxError} - If the given `string` does not represent a valid integer. - */ -export function parseInteger(str: string): number { - const parsed = parseInt(str, 10); - const original = str.startsWith("+") ? str.slice(1) : str; - - if (isInteger(parsed) && parsed.toString() === original) { - return parsed; - } else { - throw new SyntaxError( - `String does not represent a valid integer: "${str}"` - ); - } -} diff --git a/server/shared/validation/validator.ts b/server/shared/validation/validator.ts index 02940cd..983a49b 100644 --- a/server/shared/validation/validator.ts +++ b/server/shared/validation/validator.ts @@ -1,4 +1,4 @@ -import { parseInteger } from "../utils/strings"; +import { parseToInteger } from "../utils/numbers"; import { isBoolean, isNumber, @@ -67,7 +67,7 @@ function isValidBoolean(value: unknown): boolean { function isValidNumber(constraint: Constraint, value: unknown): boolean { if (isString(value)) { - value = parseInteger(value); + value = parseToInteger(value); } if (!isNumber(value)) {