Skip to content

Commit

Permalink
Add arg validation + formatting to MultilineParser
Browse files Browse the repository at this point in the history
Param names are now the camelcased variety, and param values are formatted according to their type defined in `api.ts`.
  • Loading branch information
meyer committed Jun 28, 2020
1 parent 80bd642 commit 05888fa
Show file tree
Hide file tree
Showing 7 changed files with 343 additions and 292 deletions.
14 changes: 9 additions & 5 deletions src/HyperDeckSocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export class HyperDeckSocket extends EventEmitter {
};

private onMessage(data: string): void {
this.logger.info({ data }, '<-- received message from client');
this.logger.info({ data }, '<--- received message from client');

this.lastReceivedMS = Date.now();

Expand Down Expand Up @@ -154,10 +154,14 @@ export class HyperDeckSocket extends EventEmitter {
paramsOrMessage?: Record<string, unknown> | string,
cmd?: DeserializedCommand
): void {
const responseText = messageForCode(code, paramsOrMessage);
const method = ErrorCode[code] ? 'error' : 'info';
this.logger[method]({ responseText, cmd }, '--> send response to client');
this.socket.write(responseText);
try {
const responseText = messageForCode(code, paramsOrMessage);
const method = ErrorCode[code] ? 'error' : 'info';
this.logger[method]({ responseText, cmd }, '---> send response to client');
this.socket.write(responseText);
} catch (err) {
this.logger.error({ cmd }, '-x-> Error sending response: %s', err);
}
}

notify(type: NotifyType, params: Record<string, string>): void {
Expand Down
141 changes: 91 additions & 50 deletions src/MultilineParser.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import type { DeserializedCommand } from './types';
import { DeserializedCommand, stringToValueFns } from './types';
import { CRLF } from './constants';
import type { Logger } from 'pino';
import { invariant } from './invariant';
import { paramsByKey } from './api';
import { invariant, FormattedError } from './invariant';
import { paramsByCommandName, assertValidCommandName } from './api';

export class MultilineParser {
private logger: Logger;
private linesQueue: string[] = [];
private logger: Logger;

constructor(logger: Logger) {
this.logger = logger.child({ name: 'MultilineParser' });
Expand Down Expand Up @@ -49,82 +49,123 @@ export class MultilineParser {
}

const lines = this.linesQueue.splice(0, endLine + 1);
const r = this.parseResponse(lines);
if (r) {
res.push(r);
const parsedResponse = this.parseResponse(lines);
if (parsedResponse) {
res.push(parsedResponse);
}
}

return res;
}

private parseResponse(responseLines: string[]): DeserializedCommand | null {
const lines = responseLines.map((l) => l.trim());
try {
const lines = responseLines.map((l) => l.trim());
const firstLine = lines[0];

if (lines.length === 1) {
if (!firstLine.includes(':')) {
assertValidCommandName(firstLine);
return {
raw: lines.join(CRLF),
name: firstLine,
parameters: {},
};
}

if (lines.length === 1 && lines[0].includes(':')) {
const bits = lines[0].split(': ');
// single-line command with params

const msg = bits.shift() as keyof typeof paramsByKey;
invariant(msg, 'Unrecognised command');
invariant(paramsByKey.hasOwnProperty(msg), 'Invalid command: `%s`', msg);
const bits = firstLine.split(': ');

const params: Record<string, string> = {};
const paramNames = paramsByKey[msg];
let param = bits.shift();
invariant(param, 'No named parameters found');
const commandName = bits.shift();
assertValidCommandName(commandName);

for (let i = 0; i < bits.length - 1; i++) {
const bit = bits[i];
const bobs = bit.split(' ');
const params: Record<string, any> = {};
const paramNames = paramsByCommandName[commandName];
let param = bits.shift();
invariant(param, 'No named parameters found');

let nextParam = '';
for (let i = bobs.length - 1; i >= 0; i--) {
nextParam = (bobs.pop() + ' ' + nextParam).trim();
if (paramNames.hasOwnProperty(nextParam)) {
break;
for (let i = 0; i < bits.length - 1; i++) {
const bit = bits[i];
const bobs = bit.split(' ');

let nextParam = '';
for (let i = bobs.length - 1; i >= 0; i--) {
nextParam = (bobs.pop() + ' ' + nextParam).trim();
if (paramNames.hasOwnProperty(nextParam)) {
break;
}
}

invariant(bobs.length > 0, 'Command malformed / paramName not recognised: `%s`', bit);
invariant(paramNames.hasOwnProperty(param), 'Unsupported param: `%o`', param);

const value = bobs.join(' ');
const { paramName, paramType } = paramNames[param];

const formatter = stringToValueFns[paramType];
params[paramName] = formatter(value);
param = nextParam;
}

invariant(bobs.length > 0, 'Command malformed / paramName not recognised: `%s`', bit);
invariant(paramNames.hasOwnProperty(param), 'Unsupported param: `%o`', param);

params[param] = bobs.join(' ');
param = nextParam;
}
const value = bits[bits.length - 1];
const { paramName, paramType } = paramNames[param];

params[param] = bits[bits.length - 1];
const formatter = stringToValueFns[paramType];
params[paramName] = formatter(value);

return {
raw: lines.join(CRLF),
name: msg,
parameters: params,
};
} else {
const headerMatch = lines[0].match(/(.+?)(:|)$/im);
if (!headerMatch) {
this.logger.error({ header: lines[0] }, 'failed to parse header');
return null;
return {
raw: lines.join(CRLF),
name: commandName,
parameters: params,
};
}

const msg = headerMatch[1];
invariant(
firstLine.endsWith(':'),
'Expected a line ending in semicolon, received `%o`',
firstLine
);

const params: Record<string, string> = {};
// remove the semicolon at the end of the command
const commandName = firstLine.slice(0, -1);

for (let i = 1; i < lines.length; i++) {
const lineMatch = lines[i].match(/^(.*?): (.*)$/im);
if (!lineMatch) {
this.logger.error({ line: lines[i] }, 'failed to parse line');
continue;
}
assertValidCommandName(commandName);

const paramNames = paramsByCommandName[commandName];

const params: Record<string, any> = {};

for (const line of lines) {
const lineMatch = line.match(/^(.*?): (.*)$/im);
invariant(lineMatch, 'Failed to parse line: `%o`', line);

params[lineMatch[1]] = lineMatch[2];
const param = lineMatch[1];
const value = lineMatch[2];
invariant(paramNames.hasOwnProperty(param), 'Unsupported param: `%o`', param);
const { paramName, paramType } = paramNames[param];

const formatter = stringToValueFns[paramType];
params[paramName] = formatter(value);
}

const res: DeserializedCommand = {
raw: lines.join(CRLF),
name: msg,
name: commandName,
parameters: params,
};

return res;
} catch (err) {
if (err instanceof FormattedError) {
this.logger.error(err.template, ...err.args);
} else {
this.logger.error({ err: err + '' }, 'parseResponse error');
}

return null;
}
}
}
114 changes: 39 additions & 75 deletions src/__tests__/HyperDeckServer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,84 +50,48 @@ describe('HyperdeckServer', () => {
await new Promise((resolve) => setTimeout(() => resolve(), 500));

expect((socket.write as jest.Mock).mock.calls).toMatchInlineSnapshot(`
Array [
Array [
"500 connection info:
protocol version: 1.11
model: NodeJS HyperDeck Server Library
Array [
Array [
"500 connection info:
protocol version: 1.11
model: NodeJS HyperDeck Server Library
",
],
Array [
"108 internal error
",
],
]
`);
",
],
]
`);

expect(logger.getLoggedOutput()).toMatchInlineSnapshot(`
Array [
Object {
"level": 30,
"msg": "connection",
},
Object {
"cmd": undefined,
"level": 30,
"msg": "--> send response to client",
"responseText": "500 connection info:
protocol version: 1.11
model: NodeJS HyperDeck Server Library
Array [
Object {
"level": 30,
"msg": "connection",
},
Object {
"cmd": undefined,
"level": 30,
"msg": "---> send response to client",
"responseText": "500 connection info:
protocol version: 1.11
model: NodeJS HyperDeck Server Library
",
},
Object {
"data": "banana",
"level": 30,
"msg": "<-- received message from client",
},
Object {
"cmds": Array [
Object {
"name": "banana",
"parameters": Object {},
"raw": "banana",
},
],
"level": 30,
"msg": "parsed commands",
},
Object {
"cmd": Object {
"name": "banana",
"parameters": Object {},
"raw": "banana",
},
"level": 30,
"msg": "<-- banana",
},
Object {
"cmd": Object {
"name": "banana",
"parameters": Object {},
"raw": "banana",
},
"err": "Unhandled command name: \`banana\`",
"level": 50,
"msg": "unhandled command name",
},
Object {
"cmd": Object {
"name": "banana",
"parameters": Object {},
"raw": "banana",
},
"level": 50,
"msg": "--> send response to client",
"responseText": "108 internal error
",
},
]
`);
",
},
Object {
"data": "banana",
"level": 30,
"msg": "<--- received message from client",
},
Object {
"level": 50,
"msg": "Invalid command: \`'banana'\`",
},
Object {
"cmds": Array [],
"level": 30,
"msg": "parsed commands",
},
]
`);
});
});
Loading

0 comments on commit 05888fa

Please sign in to comment.