diff --git a/package.json b/package.json index bc02fdd6..a92c3267 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,8 @@ }, "dependencies": { "@types/async": "^2.0.41", - "async": "^2.5.0" + "@types/lodash": "^4.14.73", + "async": "^2.5.0", + "lodash": "^4.17.4" } } diff --git a/src/metadata.ts b/src/metadata.ts index ffa78996..1936bfa2 100644 --- a/src/metadata.ts +++ b/src/metadata.ts @@ -1,35 +1,135 @@ +import { forOwn } from 'lodash'; + export type MetadataValue = string | Buffer; export interface MetadataObject { - [propName: string]: Array; + [key: string]: Array; +} + +function cloneMetadataObject(repr: MetadataObject): MetadataObject { + const result: MetadataObject = {}; + forOwn(repr, (value, key) => { + // v.slice copies individual buffer values in value. + // TODO(kjin): Is this necessary + result[key] = value.map(v => { + if (v instanceof Buffer) { + return v.slice(); + } else { + return v; + } + }); + }); + return result; +} + +function isLegal(legalChars: Array, str: string): boolean { + for (let i = 0; i < str.length; i++) { + const legalCharsIndex = str.charCodeAt(i) >> 3; + if (!(1 << (str.charCodeAt(i) & 7) & legalChars[legalCharsIndex])) { + return false; + } + } + return true; +} + +const legalKeyChars = [ + 0x00, 0x00, 0x00, 0x00, 0x00, 0x60, 0xff, 0x03, 0x00, 0x00, 0x00, + 0x80, 0xfe, 0xff, 0xff, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 +]; +const legalNonBinValueChars = [ + 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 +]; + +function isLegalKey(key: string): boolean { + return key.length > 0 && isLegal(legalKeyChars, key); +} + +function isLegalNonBinaryValue(value: string): boolean { + return isLegal(legalNonBinValueChars, value); +} + +function isBinaryKey(key: string): boolean { + return key.endsWith('-bin'); +} + +function normalizeKey(key: string): string { + return key.toLowerCase(); +} + +function validate(key: string, value?: MetadataValue): void { + if (!isLegalKey(key)) { + throw new Error('Metadata key"' + key + '" contains illegal characters'); + } + if (value != null) { + if (isBinaryKey(key)) { + if (!(value instanceof Buffer)) { + throw new Error('keys that end with \'-bin\' must have Buffer values'); + } + } else { + if (value instanceof Buffer) { + throw new Error( + 'keys that don\'t end with \'-bin\' must have String values'); + } + if (!isLegalNonBinaryValue(value)) { + throw new Error('Metadata string value "' + value + + '" contains illegal characters'); + } + } + } } export class Metadata { - static createMetadata(): Metadata { - return new Metadata(); + constructor(private readonly internalRepr: MetadataObject = {}) {} + + set(key: string, value: MetadataValue): void { + key = normalizeKey(key); + validate(key, value); + this.internalRepr[key] = [value]; } - set(_key: string, _value: MetadataValue): void { - throw new Error('Not implemented'); + add(key: string, value: MetadataValue): void { + key = normalizeKey(key); + validate(key, value); + if (!this.internalRepr[key]) { + this.internalRepr[key] = [value]; + } else { + this.internalRepr[key].push(value); + } } - add(_key: string, _value: MetadataValue): void { - throw new Error('Not implemented'); + remove(key: string): void { + key = normalizeKey(key); + validate(key); + if (Object.prototype.hasOwnProperty.call(this.internalRepr, key)) { + delete this.internalRepr[key]; + } } - remove(_key: string): void { - throw new Error('Not implemented'); + get(key: string): Array { + key = normalizeKey(key); + validate(key); + if (Object.prototype.hasOwnProperty.call(this.internalRepr, key)) { + return this.internalRepr[key]; + } else { + return []; + } } - get(_key: string): Array { - throw new Error('Not implemented'); - } - - getMap(): MetadataObject { - throw new Error('Not implemented'); + getMap(): { [key: string]: MetadataValue } { + const result: { [key: string]: MetadataValue } = {}; + forOwn(this.internalRepr, function(values, key) { + if(values.length > 0) { + const v = values[0]; + result[key] = v instanceof Buffer ? v.slice() : v; + } + }); + return result; } clone(): Metadata { - throw new Error('Not implemented'); + return new Metadata(cloneMetadataObject(this.internalRepr)); } } diff --git a/test/test-metadata.ts b/test/test-metadata.ts new file mode 100644 index 00000000..17c89a48 --- /dev/null +++ b/test/test-metadata.ts @@ -0,0 +1,182 @@ +import * as assert from 'assert'; +import { Metadata } from '../src/metadata'; + +describe('Metadata', () => { + let metadata: Metadata; + + beforeEach(() => { + metadata = new Metadata(); + }); + + describe('set', () => { + it('Only accepts string values for non "-bin" keys', () => { + assert.throws(() => { + metadata.set('key', new Buffer('value')); + }); + assert.doesNotThrow(() => { + metadata.set('key', 'value'); + }); + }); + + it('Only accepts Buffer values for "-bin" keys', () => { + assert.throws(() => { + metadata.set('key-bin', 'value'); + }); + assert.doesNotThrow(() => { + metadata.set('key-bin', new Buffer('value')); + }); + }); + + it('Rejects invalid keys', () => { + assert.throws(() => { + metadata.set('key$', 'value'); + }); + assert.throws(() => { + metadata.set('', 'value'); + }); + }); + + it('Rejects values with non-ASCII characters', () => { + assert.throws(() => { + metadata.set('key', 'résumé'); + }); + }); + + it('Saves values that can be retrieved', () => { + metadata.set('key', 'value'); + assert.deepEqual(metadata.get('key'), ['value']); + }); + + it('Overwrites previous values', () => { + metadata.set('key', 'value1'); + metadata.set('key', 'value2'); + assert.deepEqual(metadata.get('key'), ['value2']); + }); + + it('Normalizes keys', () => { + metadata.set('Key', 'value1'); + assert.deepEqual(metadata.get('key'), ['value1']); + metadata.set('KEY', 'value2'); + assert.deepEqual(metadata.get('key'), ['value2']); + }); + }); + + describe('add', () => { + it('Only accepts string values for non "-bin" keys', () => { + assert.throws(() => { + metadata.add('key', new Buffer('value')); + }); + assert.doesNotThrow(() => { + metadata.add('key', 'value'); + }); + }); + + it('Only accepts Buffer values for "-bin" keys', () => { + assert.throws(() => { + metadata.add('key-bin', 'value'); + }); + assert.doesNotThrow(() => { + metadata.add('key-bin', new Buffer('value')); + }); + }); + + it('Rejects invalid keys', () => { + assert.throws(() => { + metadata.add('key$', 'value'); + }); + assert.throws(() => { + metadata.add('', 'value'); + }); + }); + + it('Saves values that can be retrieved', () => { + metadata.add('key', 'value'); + assert.deepEqual(metadata.get('key'), ['value']); + }); + + it('Combines with previous values', () => { + metadata.add('key', 'value1'); + metadata.add('key', 'value2'); + assert.deepEqual(metadata.get('key'), ['value1', 'value2']); + }); + + it('Normalizes keys', () => { + metadata.add('Key', 'value1'); + assert.deepEqual(metadata.get('key'), ['value1']); + metadata.add('KEY', 'value2'); + assert.deepEqual(metadata.get('key'), ['value1', 'value2']); + }); + }); + + describe('remove', () => { + it('clears values from a key', () => { + metadata.add('key', 'value'); + metadata.remove('key'); + assert.deepEqual(metadata.get('key'), []); + }); + + it('Normalizes keys', () => { + metadata.add('key', 'value'); + metadata.remove('KEY'); + assert.deepEqual(metadata.get('key'), []); + }); + }); + + describe('get', () => { + beforeEach(() => { + metadata.add('key', 'value1'); + metadata.add('key', 'value2'); + metadata.add('key-bin', new Buffer('value')); + }); + + it('gets all values associated with a key', () => { + assert.deepEqual(metadata.get('key'), ['value1', 'value2']); + }); + + it('Normalizes keys', () => { + assert.deepEqual(metadata.get('KEY'), ['value1', 'value2']); + }); + + it('returns an empty list for non-existent keys', () => { + assert.deepEqual(metadata.get('non-existent-key'), []); + }); + + it('returns Buffers for "-bin" keys', () => { + assert.ok(metadata.get('key-bin')[0] instanceof Buffer); + }); + }); + + describe('getMap', () => { + it('gets a map of keys to values', () => { + metadata.add('key1', 'value1'); + metadata.add('Key2', 'value2'); + metadata.add('KEY3', 'value3'); + assert.deepEqual(metadata.getMap(), + {key1: 'value1', + key2: 'value2', + key3: 'value3'}); + }); + }); + + describe('clone', () => { + it('retains values from the original', () => { + metadata.add('key', 'value'); + const copy = metadata.clone(); + assert.deepEqual(copy.get('key'), ['value']); + }); + + it('Does not see newly added values', () => { + metadata.add('key', 'value1'); + const copy = metadata.clone(); + metadata.add('key', 'value2'); + assert.deepEqual(copy.get('key'), ['value1']); + }); + + it('Does not add new values to the original', () => { + metadata.add('key', 'value1'); + const copy = metadata.clone(); + copy.add('key', 'value2'); + assert.deepEqual(metadata.get('key'), ['value1']); + }); + }); +}); \ No newline at end of file