UNPKG

@azure/functions-extensions-servicebus

Version:
443 lines (397 loc) 17.9 kB
// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. /** * AMQP Property Encoder Utility * * Converts TypeScript/JavaScript property values to AMQP-encoded byte arrays. * Date objects are automatically converted to UTC ISO format for cross-platform compatibility. */ /** * Supported AMQP property types */ enum AmqpType { Null = 'null', Byte = 'byte', SByte = 'sbyte', Char = 'char', Int16 = 'int16', UInt16 = 'uint16', Int32 = 'int32', UInt32 = 'uint32', Int64 = 'int64', UInt64 = 'uint64', Single = 'single', Double = 'double', Decimal = 'decimal', Boolean = 'boolean', Guid = 'guid', String = 'string', Uri = 'uri', DateTime = 'datetime', DateTimeOffset = 'datetimeoffset', TimeSpan = 'timespan', Stream = 'stream', Array = 'array', Unknown = 'unknown', } /** * Type mapping from JavaScript primitive types to AMQP types */ const PRIMITIVE_TYPE_MAP = new Map<string, AmqpType>([ ['number', AmqpType.Double], ['boolean', AmqpType.Boolean], ['string', AmqpType.String], ['bigint', AmqpType.Int64], ]); /** * Type conversion utilities for specific AMQP types * * Contains validation functions to determine if JavaScript values fit within * specific AMQP type ranges and formats. */ const TypeConverters = { /** Checks if number fits in unsigned 8-bit range (0-255) */ isByte: (value: number): boolean => Number.isInteger(value) && value >= 0 && value <= 255, /** Checks if number fits in signed 8-bit range (-128 to 127) */ isSByte: (value: number): boolean => Number.isInteger(value) && value >= -128 && value <= 127, /** Checks if number fits in signed 16-bit range (-32,768 to 32,767) */ isInt16: (value: number): boolean => Number.isInteger(value) && value >= -32768 && value <= 32767, /** Checks if number fits in unsigned 16-bit range (0 to 65,535) */ isUInt16: (value: number): boolean => Number.isInteger(value) && value >= 0 && value <= 65535, /** Checks if number fits in signed 32-bit range (-2,147,483,648 to 2,147,483,647) */ isInt32: (value: number): boolean => Number.isInteger(value) && value >= -2147483648 && value <= 2147483647, /** Checks if number fits in unsigned 32-bit range (0 to 4,294,967,295) */ isUInt32: (value: number): boolean => Number.isInteger(value) && value >= 0 && value <= 4294967295, /** Checks if floating-point number fits in 32-bit single precision range */ isSingle: (value: number): boolean => !Number.isInteger(value) && Math.abs(value) <= 3.4028235e38, /** Checks if string is a single character */ isChar: (value: string): boolean => value.length === 1, /** Checks if string matches GUID/UUID format (8-4-4-4-12 hex digits) */ isGuid: (value: string): boolean => /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value), /** Checks if string is a valid URI by attempting URL construction */ isUri: (value: string): boolean => { try { new URL(value); return true; } catch { return false; } }, /** Checks if string can be parsed as a date */ isDateTime: (value: string): boolean => !isNaN(Date.parse(value)), /** Checks if string matches TimeSpan format ([-][d.]hh:mm:ss[.fffffff]) */ isTimeSpan: (value: string): boolean => /^-?(\d+\.)?(\d{2}:)?(\d{2}:)?\d{2}(\.\d{1,7})?$/.test(value), }; /** * Checks if an object represents a decimal-like value structure * @param obj - The object to check * @returns True if the object has properties indicating it's a decimal type */ function isDecimalLike(obj: unknown): boolean { return ( obj !== null && typeof obj === 'object' && 'toString' in obj && ('precision' in obj || 'scale' in obj || 'value' in obj) ); } /** * Wraps a value with the appropriate AMQP type using the rhea library * @param type - The AMQP type string identifier * @param value - The value to wrap * @returns The wrapped value ready for AMQP encoding */ function wrapAmqpValue(type: string, value: unknown): unknown { // eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-unsafe-assignment const rhea = require('rhea'); switch (type) { case 'null': return null; case 'boolean': return value; case 'byte': case 'sbyte': case 'int16': case 'uint16': case 'int32': case 'uint32': case 'single': case 'double': return value; case 'int64': case 'uint64': // Convert BigInt to number for rhea compatibility if (typeof value === 'bigint') { // For BigInt values that are within safe integer range, convert to number if (value >= BigInt(Number.MIN_SAFE_INTEGER) && value <= BigInt(Number.MAX_SAFE_INTEGER)) { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call return rhea.types.wrap_long(Number(value)); } else { // For very large values, handle as number but this may lose precision // This is a limitation of the JavaScript-AMQP bridge console.warn(`BigInt value ${value} is outside safe integer range, precision may be lost`); // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call return rhea.types.wrap_long(Number(value)); } } // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call return rhea.types.wrap_long(value); case 'decimal': // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call return rhea.types.wrap_decimal128(value); case 'char': case 'string': case 'guid': case 'uri': case 'datetime': case 'datetimeoffset': case 'timespan': return value; case 'stream': // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call return rhea.types.wrap_binary(value); default: return value; } } const SUPPORTED_TYPES_MESSAGE = 'Supported types: ' + 'Primitives: null, boolean, string (including char, guid, uri, datetime, timespan), ' + 'Numbers: byte, sbyte, int16, uint16, int32, uint32, int64, uint64, single, double, decimal, ' + 'Objects: Date (datetimeoffset), URL (uri), Buffer/Uint8Array (stream), arrays of supported types'; /** * Converts a Record<string, any> to AMQP-encoded byte array * * @param propertiesToModify - The properties to encode * @returns Uint8Array containing AMQP-encoded properties */ export function convertPropertiesToAmqpBytes(propertiesToModify: Record<string, any>): Uint8Array { const amqpMap = new Map<string, unknown>(); for (const [key, value] of Object.entries(propertiesToModify)) { const amqpValue = tryCreateAmqpPropertyValue(value); if (amqpValue !== null) { amqpMap.set(key, amqpValue); } else { const error = new Error( `The key '${key}' has a value of type '${typeof value}' which is not supported for AMQP transport. ${SUPPORTED_TYPES_MESSAGE}` ); throw error; } } const encodedBytes = encodeAmqpMap(amqpMap); return encodedBytes; } /** * Encodes properties for Service Bus message operations with proper error handling * * This function provides a safe wrapper around AMQP property encoding specifically * designed for Service Bus message operations (abandon, deadletter, defer). * It handles null/undefined properties and provides operation-specific error messages. * * @param propertiesToModify - Optional properties to modify on the message * @param operationName - The name of the operation (for error messages) * @returns Encoded properties as Uint8Array, or empty array if no properties provided * @throws Error with operation-specific context if encoding fails * * @example * ```typescript * // For abandon operation * const encoded = encodePropertiesForOperation( * { messageId: 'test-123', priority: 1 }, * 'abandon' * ); * * // For operations without properties * const empty = encodePropertiesForOperation(undefined, 'defer'); * // Returns: new Uint8Array() * ``` */ export function encodePropertiesForOperation( propertiesToModify: Record<string, any> | undefined, operationName: string ): Uint8Array { // Return empty array if no properties provided if (!propertiesToModify || Object.keys(propertiesToModify).length === 0) { return new Uint8Array(); } try { return new Uint8Array(convertPropertiesToAmqpBytes(propertiesToModify)); } catch (error) { throw new Error( `Failed to encode properties for ${operationName} operation: ${ error instanceof Error ? error.message : String(error) }` ); } } /** * Attempts to create an AMQP property value from a JavaScript value with intelligent type detection * * Performs automatic type detection and conversion: * - Numbers: Detects optimal integer types (byte, int16, int32, int64) or floating point (single, double) * - Strings: Detects char, GUID, URI, DateTime, TimeSpan patterns * - Objects: Handles Date, URL, Buffer/Uint8Array, decimal-like objects, arrays * - Primitives: Boolean, BigInt, null/undefined * * @param propertyValue - The JavaScript value to convert to AMQP format * @returns Typed AMQP value object with { type, value } structure, or null if conversion fails */ function tryCreateAmqpPropertyValue(propertyValue: unknown): unknown { if (propertyValue === null || propertyValue === undefined) { return { type: 'null', value: null }; } const valueType = typeof propertyValue; switch (valueType) { case 'string': { const strValue = propertyValue as string; if (strValue === '') return { type: 'string', value: strValue }; if (TypeConverters.isChar(strValue)) return { type: 'char', value: strValue }; if (TypeConverters.isGuid(strValue)) return { type: 'guid', value: strValue }; if (TypeConverters.isUri(strValue)) return { type: 'uri', value: strValue }; if (TypeConverters.isTimeSpan(strValue)) return { type: 'timespan', value: strValue }; if (TypeConverters.isDateTime(strValue)) return { type: 'datetime', value: strValue }; return { type: 'string', value: strValue }; } case 'number': { const numValue = propertyValue as number; if (!Number.isFinite(numValue)) { return numValue; } if (Number.isInteger(numValue)) { if (TypeConverters.isByte(numValue)) return { type: 'byte', value: numValue }; if (TypeConverters.isSByte(numValue)) return { type: 'sbyte', value: numValue }; if (TypeConverters.isInt16(numValue)) return { type: 'int16', value: numValue }; if (TypeConverters.isUInt16(numValue)) return { type: 'uint16', value: numValue }; if (TypeConverters.isInt32(numValue)) return { type: 'int32', value: numValue }; if (TypeConverters.isUInt32(numValue)) return { type: 'uint32', value: numValue }; return { type: 'int64', value: BigInt(numValue) }; } if (TypeConverters.isSingle(numValue)) { return { type: 'single', value: numValue }; } return { type: 'double', value: numValue }; } case 'boolean': return { type: 'boolean', value: propertyValue as boolean }; case 'bigint': { const bigintValue = propertyValue as bigint; if (bigintValue >= BigInt(0) && bigintValue <= BigInt('18446744073709551615')) { return { type: 'uint64', value: bigintValue }; } return { type: 'int64', value: bigintValue }; } case 'object': { if (propertyValue instanceof Date) { return { type: 'datetimeoffset', value: propertyValue.toISOString() }; } if (propertyValue instanceof Buffer || propertyValue instanceof Uint8Array) { return { type: 'stream', value: propertyValue }; } if (propertyValue instanceof URL) { return { type: 'uri', value: propertyValue.href }; } if (isDecimalLike(propertyValue)) { return { type: 'decimal', value: propertyValue }; } if (Array.isArray(propertyValue)) { return propertyValue.map((item: unknown) => tryCreateAmqpPropertyValue(item)); } return null; } default: return null; } } /** * Encodes an AMQP map to byte array using proper AMQP encoding * * Uses the rhea library to create AMQP-encoded bytes compatible with .NET AMQP decoders. * Handles typed values by applying appropriate AMQP type wrappers before encoding. * * @param amqpMap - Map of string keys to AMQP-compatible values * @returns Uint8Array containing the AMQP-encoded map * @throws Error if encoding fails */ function encodeAmqpMap(amqpMap: Map<string, unknown>): Uint8Array { try { // eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-unsafe-assignment const rhea = require('rhea'); const objectMap: Record<string, unknown> = {}; for (const [key, value] of amqpMap.entries()) { if (value && typeof value === 'object' && 'type' in value && 'value' in value) { const typedValue = value as { type: string; value: unknown }; objectMap[key] = wrapAmqpValue(typedValue.type, typedValue.value); } else { objectMap[key] = value; } } // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call const wrappedMap = rhea.types.wrap_map(objectMap); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call const writer = new rhea.types.Writer(); // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call writer.write(wrappedMap); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call const buffer = writer.toBuffer(); // eslint-disable-next-line @typescript-eslint/no-unsafe-argument const result = new Uint8Array(buffer); return result; } catch (error) { throw new Error(`Failed to encode AMQP map: ${error instanceof Error ? error.message : String(error)}`); } } /** * Gets the AMQP type identifier for a given value * * Analyzes the value to determine its corresponding AMQP type. Handles both * typed values (from tryCreateAmqpPropertyValue) and raw JavaScript values. * * @param value - The value to analyze * @returns The corresponding AmqpType enum value */ function getAmqpTypeIdentifier(value: unknown): AmqpType { if (value === null || value === undefined) { return AmqpType.Null; } if (value && typeof value === 'object' && 'type' in value && 'value' in value) { const typedValue = value as { type: string; value: unknown }; const amqpType = Object.values(AmqpType).find((type) => type === typedValue.type); return amqpType || AmqpType.Unknown; } const jsType = typeof value; const primitiveType = PRIMITIVE_TYPE_MAP.get(jsType); if (primitiveType) { return primitiveType; } if (jsType === 'object') { if (value instanceof Date) { return AmqpType.DateTimeOffset; } else if (value instanceof URL) { return AmqpType.Uri; } else if (value instanceof Buffer || value instanceof Uint8Array) { return AmqpType.Stream; } else if (Array.isArray(value)) { // Arrays are supported, determine their type by content return AmqpType.Array; } } return AmqpType.Unknown; } /** * Validates that all properties in the record are supported AMQP types * * @param properties - The properties to validate * @throws Error if any property has an unsupported type */ export function validateAmqpProperties(properties: Record<string, any>): void { for (const [key, value] of Object.entries(properties)) { const amqpType = getAmqpTypeIdentifier(value); if (amqpType === AmqpType.Unknown) { const error = new Error( `Property '${key}' has unsupported type '${typeof value}' for AMQP transport. ${SUPPORTED_TYPES_MESSAGE}` ); throw error; } } }