Skip to content

Commit

Permalink
feat: Removed fast-xml-parser and guid-typescript
Browse files Browse the repository at this point in the history
  • Loading branch information
svrooij committed Feb 7, 2024
1 parent 86a6963 commit 466220f
Show file tree
Hide file tree
Showing 17 changed files with 543 additions and 355 deletions.
656 changes: 375 additions & 281 deletions package-lock.json

Large diffs are not rendered by default.

11 changes: 5 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,20 +45,19 @@
"eslint-config-airbnb-typescript": "^9.0.0",
"eslint-plugin-import": "^2.22.1",
"glob": "^7.1.6",
"handlebars": "^4.7.7",
"handlebars": "^4.7.8",
"jest": "^26.6.3",
"nock": "^13.0.11",
"ts-jest": "^26.5.5",
"ts-node": "^9.1.1",
"typescript": "^3.8.5"
},
"dependencies": {
"debug": "4.3.1",
"fast-xml-parser": "3.19.0",
"guid-typescript": "^1.0.9",
"html-entities": "^2.3.2",
"@rgrove/parse-xml": "^4.1.0",
"debug": "4.3.4",
"html-entities": "^2.4.0",
"node-fetch": "^2.6.1",
"typed-emitter": "^1.3.1",
"typed-emitter": "^2.1.0",
"ws": "^8.12.1"
},
"files": [
Expand Down
9 changes: 5 additions & 4 deletions src/helpers/metadata-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ export default class MetadataHelper {
Title: XmlHelper.DecodeHtml(didlItem['dc:title']),
UpnpClass: didlItem['upnp:class'],
Duration: undefined,
ItemId: didlItem._id,
ParentId: didlItem._parentID,
ItemId: didlItem.id ?? didlItem._id, // the previous xml parser was prefixing all attributes with _
ParentId: didlItem.parentID ?? didlItem.parentID,
TrackUri: undefined,
ProtocolInfo: undefined,
};
Expand All @@ -54,9 +54,10 @@ export default class MetadataHelper {
}

if (didlItem.res) {
track.Duration = didlItem.res._duration;
// the previous xml parser was prefixing all attributes with _
track.Duration = didlItem.res.duration ?? didlItem.res._duration;
track.TrackUri = XmlHelper.DecodeTrackUri(didlItem.res['#text']);
track.ProtocolInfo = didlItem.res._protocolInfo;
track.ProtocolInfo = didlItem.res.protocolInfo ?? didlItem.res._protocolInfo;
}

return track;
Expand Down
102 changes: 97 additions & 5 deletions src/helpers/xml-helper.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { parse } from 'fast-xml-parser';
import {
XmlCdata,
XmlComment,
XmlDeclaration,
XmlDocument, XmlDocumentType, XmlElement, XmlProcessingInstruction, XmlText, parseXml,
} from '@rgrove/parse-xml';
import { encode, decode } from 'html-entities';

export default class XmlHelper {
Expand All @@ -14,6 +19,9 @@ export default class XmlHelper {
if (typeof text !== 'string' || text === '') {
return undefined;
}
if (typeof text === 'string' && !text.startsWith('$lt;')) {
return text;
}

return decode(text, { level: 'xml' });
}
Expand Down Expand Up @@ -44,10 +52,10 @@ export default class XmlHelper {
* @returns {*} a parsed Object of the XML string
* @memberof XmlHelper
*/
static DecodeAndParseXml(encodedXml: unknown, attributeNamePrefix = '_'): unknown {
static DecodeAndParseXml(encodedXml: unknown): Record<string, unknown> | unknown {
const decoded = XmlHelper.DecodeXml(encodedXml);
if (typeof decoded === 'undefined') return undefined;
return parse(decoded, { ignoreAttributes: false, attributeNamePrefix });
return this.ParseXml(decoded, false);
}

/**
Expand All @@ -58,9 +66,93 @@ export default class XmlHelper {
* @returns {*} a parsed Object of the XML string
* @memberof XmlHelper
*/
static DecodeAndParseXmlNoNS(encodedXml: unknown, attributeNamePrefix = '_'): unknown {
static DecodeAndParseXmlNoNS(encodedXml: unknown): Record<string, unknown> | unknown {
const decoded = XmlHelper.DecodeXml(encodedXml);
return decoded ? parse(decoded, { ignoreAttributes: false, ignoreNameSpace: true, attributeNamePrefix }) : undefined;
return decoded ? this.ParseXml(decoded, true) : undefined;
}

static ParseEmbeddedXml(input: unknown): Record<string, unknown> | unknown {
if (typeof input !== 'string' || input === '') return undefined;
const inputToParse = input.indexOf('\\"') > -1 ? input.replace('\\"', '"') : input;

const xmlDocument = parseXml(inputToParse as string);

const result = this.NormalizeXml(xmlDocument, true);
return result;
}

static ParseXml(input: unknown, removeNamespace = false): Record<string, unknown> | unknown {
if (typeof input !== 'string' || input === '') return undefined;
const xmlDocument = parseXml(input);

const result = this.NormalizeXml(xmlDocument, removeNamespace);
return result;
}

private static ParseValue(input: unknown): unknown {
if (typeof input === 'string') {
if (input === 'true') return true;
if (input === 'false') return false;
if (input === 'null') return null;
// check if the supplied input value might be a string representation of a number
if (!Number.isNaN(Number(input))) return Number(input);
return input;
}
return input;
}

static NormalizeXml(input: XmlDocument | XmlElement | unknown, removeNamespace: boolean): Record<string, unknown> | unknown {
if (input === null) return undefined;
if (input instanceof XmlText) {
return input.text;
}

if ((input instanceof XmlDocument) || (input instanceof XmlElement)) {
if (input.children.length === 1 && input.children[0] instanceof XmlText && (input instanceof XmlElement && Object.keys(input.attributes).length === 0)) {
return this.ParseValue(input.children[0].text);
}
const result: Record<string, unknown> = {};

if (input instanceof XmlElement) {
// itereate over the attributes and add them to the result
// tell eslint to ignore the next line as it is a for of loop
// eslint-disable-next-line no-restricted-syntax
for (const [key, value] of Object.entries(input.attributes)) {
if (!key.startsWith('xmlns')) {
const finalKey = removeNamespace ? key.replace(/.*:/, '') : key;
result[finalKey] = this.ParseValue(value);
}
}
}

input.children.forEach((child: XmlElement | XmlText | XmlCdata | XmlComment | XmlProcessingInstruction| XmlDeclaration | XmlDocumentType) => {
// for (const child of input.children) {
if (child instanceof XmlElement) {
const value = this.NormalizeXml(child, removeNamespace);
const name = removeNamespace ? child.name.replace(/.*:/, '') : child.name;
if (name in result) {
const existing = result[name];
if (Array.isArray(existing)) {
existing.push(value);
} else {
result[name] = [existing, value];
}
} else {
result[name] = value;
}
} else if (child instanceof XmlText) {
if (child.text.trim() !== '') {
result['#text'] = this.ParseValue(child.text);
}
}
// }
});
return result;
}
if (input instanceof XmlText) {
return this.ParseValue(input.text);
}
return input;
}

/**
Expand Down
4 changes: 2 additions & 2 deletions src/models/service-event.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { EventsError } from './event-errors';

export interface ServiceEvent<TEventType> {
export type ServiceEvent<TEventType> = {
serviceEvent: (eventData: TEventType) => void;
subscriptionError: (error: EventsError) => void;
rawEvent: (eventData: unknown) => void;
removeListener: (eventName: string | symbol) => void;
newListener: (eventName: string | symbol) => void;
}
};

export enum ServiceEvents {
/**
Expand Down
4 changes: 2 additions & 2 deletions src/models/strong-sonos-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Track } from './track';
import { AVTransportServiceEvent, RenderingControlServiceEvent } from '../services/index';
import { EventsError } from './event-errors';

export interface StrongSonosEvents {
export type StrongSonosEvents = {
avtransport: (data: AVTransportServiceEvent) => void;
currentTrack: (track: Track) => void;
currentTrackUri: (trachUri: string) => void;
Expand All @@ -27,4 +27,4 @@ export interface StrongSonosEvents {
// For internal use to unsubscribe on last user.
removeListener: (eventName: string | symbol) => void;
newListener: (eventName: string | symbol) => void;
}
};
5 changes: 2 additions & 3 deletions src/musicservices/smapi-client.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import fetch, { Request } from 'node-fetch';
import { parse } from 'fast-xml-parser';
import debug, { Debugger } from 'debug';

import SmapiError from './smapi-error';
import ArrayHelper from '../helpers/array-helper';
import XmlHelper from '../helpers/xml-helper';

/**
* Options to create a sonos music api client.
Expand Down Expand Up @@ -265,7 +264,7 @@ export class SmapiClient {
// throw new Error(`Http status ${response.status} (${response.statusText})`);
// }

const result = parse(await response.text(), { ignoreNameSpace: true });
const result = XmlHelper.ParseXml(await response.text(), true) as any;
if (!result || !result.Envelope || !result.Envelope.Body) {
this.debug('Invalid response for %s %o', action, result);
throw new Error(`Invalid response for ${action}: ${result}`);
Expand Down
4 changes: 2 additions & 2 deletions src/services/alarm-clock.service.extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export class AlarmClockService extends AlarmClockServiceBase {
if (alarmList.CurrentAlarmList === undefined || alarmList.CurrentAlarmList === '') {
return [];
}
const parsedList = XmlHelper.DecodeAndParseXml(alarmList.CurrentAlarmList, '');
const parsedList = XmlHelper.DecodeAndParseXml(alarmList.CurrentAlarmList);
const alarms = ArrayHelper.ForceArray<any>((parsedList as any).Alarms.Alarm);
const results: Array<Alarm> = [];
alarms.forEach((alarm: any) => {
Expand All @@ -38,7 +38,7 @@ export class AlarmClockService extends AlarmClockServiceBase {
IncludeLinkedZones: alarm.IncludeLinkedZones === '1',
PlayMode: alarm.PlayMode,
ProgramMetaData: MetadataHelper.ParseDIDLTrack(XmlHelper.DecodeAndParseXml(alarm.ProgramMetaData), this.host, this.port),
ProgramURI: XmlHelper.DecodeTrackUri(alarm.ProgramURI),
ProgramURI: alarm.ProgramURI, // XmlHelper.DecodeTrackUri(alarm.ProgramURI),
Recurrence: alarm.Recurrence,
RoomUUID: alarm.RoomUUID,
StartLocalTime: alarm.StartTime,
Expand Down
40 changes: 22 additions & 18 deletions src/services/base-service.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import fetch, { Request, Response } from 'node-fetch';

import { parse } from 'fast-xml-parser';
import { Guid } from 'guid-typescript';
import { EventEmitter } from 'events';
import debug, { Debugger } from 'debug';
import { randomUUID } from 'crypto';
import TypedEmitter from 'typed-emitter';
import IpHelper from '../helpers/ip-helper';
import SoapHelper from '../helpers/soap-helper';
Expand Down Expand Up @@ -91,10 +89,10 @@ export default abstract class BaseService <TServiceEvent> {
* Creates an instance of the implemented service.
* @param {string} host The ip (or hostname) of the sonos speaker
* @param {number} [port=1400] The port of the sonos speaker (defaults to 1400)
* @param {string} [uuid=Guid.create().toString()] The uuid of the speaker, used for grouping and events.
* @param {string} [uuid=crypto.randomUUID().toString()] The uuid of the speaker, used for grouping and events.
* @memberof BaseService
*/
constructor(host: string, port = 1400, private uuid: string = Guid.create().toString()) {
constructor(host: string, port = 1400, private uuid: string = randomUUID().toString()) {
this.host = host;
this.port = port;
}
Expand Down Expand Up @@ -261,7 +259,7 @@ export default abstract class BaseService <TServiceEvent> {
? await response.text()
: await this.handleErrorResponse<string>(action, response);

Check failure on line 260 in src/services/base-service.ts

View workflow job for this annotation

GitHub Actions / Build and test on node v16

Argument of type 'Response' is not assignable to parameter of type 'import("/home/runner/work/node-sonos-ts/node-sonos-ts/node_modules/@types/node-fetch/index").Response'.

Check failure on line 260 in src/services/base-service.ts

View workflow job for this annotation

GitHub Actions / Build and test on node v18

Argument of type 'Response' is not assignable to parameter of type 'import("/home/runner/work/node-sonos-ts/node-sonos-ts/node_modules/@types/node-fetch/index").Response'.

const result = parse(responseText);
const result = XmlHelper.ParseXml(responseText) as any;
if (!result || !result['s:Envelope']) {
this.debug('Invalid response for %s %o', action, result);
throw new Error(`Invalid response for ${action}: ${result}`);
Expand All @@ -281,7 +279,7 @@ export default abstract class BaseService <TServiceEvent> {
private async handleErrorResponse<TResponse>(action: string, response: Response): Promise<TResponse> {
const responseText = await response.text();
if (responseText !== '') {
const errorResponse = parse(responseText);
const errorResponse = XmlHelper.ParseXml(responseText) as any;
if (errorResponse['s:Envelope'] && errorResponse['s:Envelope']['s:Body'] && errorResponse['s:Envelope']['s:Body']['s:Fault'] !== undefined) {
const error = errorResponse['s:Envelope']['s:Body']['s:Fault'];
this.debug('Sonos error on %s %o', action, error);
Expand Down Expand Up @@ -316,14 +314,12 @@ export default abstract class BaseService <TServiceEvent> {

protected parseValue(name: string, input: unknown, expectedType: string): Track | string | boolean | number | unknown {
if (expectedType === 'Track | string' && typeof input === 'string') {
if (input.startsWith('&lt;')) {
return MetadataHelper.ParseDIDLTrack(XmlHelper.DecodeAndParseXml(input), this.host, this.port);
}
return undefined; // undefined is more appropriate, but that would be a breaking change.
const trackObject = XmlHelper.DecodeAndParseXml(input);
return MetadataHelper.ParseDIDLTrack(trackObject, this.host, this.port);
}

if (name.indexOf('URI') > -1 && typeof input === 'string') {
return input === '' ? undefined : XmlHelper.DecodeTrackUri(input);
return input === '' ? undefined : input; // XmlHelper.DecodeTrackUri(input);
}

switch (expectedType) {
Expand Down Expand Up @@ -354,7 +350,7 @@ export default abstract class BaseService <TServiceEvent> {
*/
public get Events(): TypedEmitter<ServiceEvent<TServiceEvent>> {
if (this.events === undefined) {
this.events = new EventEmitter();
this.events = new EventEmitter() as TypedEmitter<ServiceEvent<TServiceEvent>>;
this.events.on('removeListener', async (eventName: string | symbol) => {
this.debug('Listener removed for %s', eventName);
// The ZoneGroupTopology service might resubscribe really soon after unsubscribing.
Expand Down Expand Up @@ -524,10 +520,17 @@ export default abstract class BaseService <TServiceEvent> {
*/
public ParseEvent(xml: string): void {
this.debug('Got event');
const rawBody = parse(xml, { attributeNamePrefix: '', ignoreNameSpace: true }).propertyset.property;
// const rawBody = parse(xml, { attributeNamePrefix: '', ignoreNameSpace: true }).propertyset.property;
const parsed = XmlHelper.ParseXml(xml, true) as any;
if (parsed === undefined || typeof parsed === 'string') {
this.debug('Invalid event %o', parsed);
return;
}

const rawBody = parsed.propertyset.property;
this.Events.emit(ServiceEvents.Unprocessed, rawBody);
if (rawBody.LastChange) {
const rawEventWrapper = XmlHelper.DecodeAndParseXmlNoNS(rawBody.LastChange, '') as any;
const rawEventWrapper = XmlHelper.ParseXml(rawBody.LastChange) as any;
const rawEvent = rawEventWrapper.Event.InstanceID ? rawEventWrapper.Event.InstanceID : rawEventWrapper.Event;
const parsedEvent = this.cleanEventLastChange(rawEvent);
// console.log(rawEvent)
Expand All @@ -544,11 +547,11 @@ export default abstract class BaseService <TServiceEvent> {
}

protected ResolveEventPropertyValue(name: string, originalValue: unknown, type: string): unknown {
if (typeof originalValue === 'string' && originalValue.startsWith('&lt;')) {
if (typeof originalValue === 'string' && (originalValue.startsWith('&lt;') || originalValue.startsWith('<'))) {
if (name.endsWith('MetaData')) {
return MetadataHelper.ParseDIDLTrack(XmlHelper.DecodeAndParseXml(originalValue), this.host, this.port);
}
return XmlHelper.DecodeAndParseXml(originalValue, '');
return XmlHelper.DecodeAndParseXml(originalValue);
}

switch (type) {
Expand All @@ -573,7 +576,8 @@ export default abstract class BaseService <TServiceEvent> {

keys.forEach((k) => {
const originalValue = input[k].val ?? input[k];
if (originalValue === undefined || originalValue === '') return;
// validate that originalValue is not undefined or empty or is not an empty object
if (originalValue === undefined || originalValue === '' || (typeof originalValue === 'object' && Object.keys(originalValue).length === 0)) return;
output[k] = this.ResolveEventPropertyValue(k, originalValue, properties[k]);
});

Expand Down
2 changes: 1 addition & 1 deletion src/services/music-services.service.extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export class MusicServicesService extends MusicServicesServiceBase {
return this.musicServices;
}
const encodedResponse = await this.ListAvailableServices();
const raw = XmlHelper.DecodeAndParseXml(encodedResponse.AvailableServiceDescriptorList, '');
const raw = XmlHelper.DecodeAndParseXml(encodedResponse.AvailableServiceDescriptorList);
const result = ArrayHelper.ForceArray((raw as any).Services.Service)
.map((service) => MusicServicesService.ParseMusicService(service))
.sort((a, b) => a.Name.localeCompare(b.Name));
Expand Down
2 changes: 1 addition & 1 deletion src/services/zone-group-topology.service.extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export class ZoneGroupTopologyService extends ZoneGroupTopologyServiceBase {
async GetParsedZoneGroupState(): Promise<ZoneGroup[]> {
const groupStateResponse = await this.GetZoneGroupState();
if (typeof groupStateResponse.ZoneGroupState === 'string') {
const decodedGroupState = XmlHelper.DecodeAndParseXml(groupStateResponse.ZoneGroupState, '');
const decodedGroupState = XmlHelper.DecodeAndParseXml(groupStateResponse.ZoneGroupState);
const groups = ArrayHelper.ForceArray((decodedGroupState as any).ZoneGroupState.ZoneGroups.ZoneGroup);
return groups.map((g: any) => ZoneGroupTopologyService.ParseGroup(g));
}
Expand Down
Loading

0 comments on commit 466220f

Please sign in to comment.