diff --git a/package.json b/package.json index bc02fdd6..e68c401b 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "gulp-tslint": "^8.1.1", "gulp-typescript": "^3.2.1", "gulp-util": "^3.0.8", + "h2-types": "git+https://github.com/kjin/node-h2-types.git", "merge2": "^1.1.0", "mocha": "^3.5.0", "through2": "^2.0.3", @@ -48,6 +49,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/call-credentials.ts b/src/call-credentials.ts index cd79315b..6e196823 100644 --- a/src/call-credentials.ts +++ b/src/call-credentials.ts @@ -63,15 +63,10 @@ class CallCredentialsImpl { cb(err || new Error('Unknown error')); return; } else { - const result = Metadata.createMetadata(); + const result: Metadata = new Metadata(); metadataArray.forEach((metadata) => { if (metadata) { - const metadataObj = metadata.getMap(); - Object.keys(metadataObj).forEach((key) => { - metadataObj[key].forEach((value) => { - result.add(key, value); - }); - }); + result.merge(metadata); } }); cb(null, result); diff --git a/src/metadata.ts b/src/metadata.ts index ffa78996..e9e71208 100644 --- a/src/metadata.ts +++ b/src/metadata.ts @@ -1,35 +1,211 @@ +import { forOwn } from 'lodash'; +import * as http2 from 'http2'; + 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 isLegalKey(key: string): boolean { + return !!key.match(/^[0-9a-z_.-]+$/); +} + +function isLegalNonBinaryValue(value: string): boolean { + return !!value.match(/^[ -~]+$/); +} + +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'); + } + } + } +} + +/** + * A class for storing metadata. Keys are normalized to lowercase ASCII. + */ export class Metadata { - static createMetadata(): Metadata { - return new Metadata(); + constructor(protected readonly internalRepr: MetadataObject = {}) {} + + /** + * Sets the given value for the given key by replacing any other values + * associated with that key. Normalizes the key. + * @param key The key to whose value should be set. + * @param value The value to set. Must be a buffer if and only + * if the normalized key ends with '-bin'. + */ + 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'); + /** + * Adds the given value for the given key by appending to a list of previous + * values associated with that key. Normalizes the key. + * @param key The key for which a new value should be appended. + * @param value The value to add. Must be a buffer if and only + * if the normalized key ends with '-bin'. + */ + 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'); + /** + * Removes the given key and any associated values. Normalizes the key. + * @param key The key whose values should be removed. + */ + 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'); + /** + * Gets a list of all values associated with the key. Normalizes the key. + * @param key The key whose value should be retrieved. + * @return A list of values associated with the given key. + */ + 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'); + /** + * Gets a plain object mapping each key to the first value associated with it. + * This reflects the most common way that people will want to see metadata. + * @return A key/value mapping of the metadata. + */ + getMap(): { [key: string]: MetadataValue } { + const result: { [key: string]: MetadataValue } = {}; + forOwn(this.internalRepr, (values, key) => { + if(values.length > 0) { + const v = values[0]; + result[key] = v instanceof Buffer ? v.slice() : v; + } + }); + return result; } + /** + * Clones the metadata object. + * @return The newly cloned object. + */ clone(): Metadata { - throw new Error('Not implemented'); + return new Metadata(cloneMetadataObject(this.internalRepr)); + } + + /** + * Merges all key-value pairs from a given Metadata object into this one. + * If both this object and the given object have values in the same key, + * values from the other Metadata object will be appended to this object's + * values. + * @param other A Metadata object. + */ + merge(other: Metadata): void { + forOwn(other.internalRepr, (values, key) => { + this.internalRepr[key] = (this.internalRepr[key] || []).concat(values); + }); + } + + /** + * Creates an OutgoingHttpHeaders object that can be used with the http2 API. + */ + toHttp2Headers(): http2.OutgoingHttpHeaders { + const result: http2.OutgoingHttpHeaders = {}; + forOwn(this.internalRepr, (values, key) => { + // We assume that the user's interaction with this object is limited to + // through its public API (i.e. keys and values are already validated). + result[key] = values.map((value) => { + if (value instanceof Buffer) { + return value.toString('base64'); + } else { + return value; + } + }); + }); + return result; + } + + /** + * Returns a new Metadata object based fields in a given IncomingHttpHeaders + * object. + * @param headers An IncomingHttpHeaders object. + */ + static fromHttp2Headers(headers: http2.IncomingHttpHeaders): Metadata { + const result = new Metadata(); + forOwn(headers, (values, key) => { + if (isBinaryKey(key)) { + if (Array.isArray(values)) { + values.forEach((value) => { + result.add(key, Buffer.from(value, 'base64')); + }) + } else { + result.add(key, Buffer.from(values, 'base64')); + } + } else { + if (Array.isArray(values)) { + values.forEach((value) => { + result.add(key, value); + }) + } else { + result.add(key, values); + } + } + }); + return result; } } diff --git a/test/test-call-credentials.ts b/test/test-call-credentials.ts index 155fb1a6..13b2302d 100644 --- a/test/test-call-credentials.ts +++ b/test/test-call-credentials.ts @@ -1,28 +1,7 @@ import { Metadata } from '../src/metadata'; import { CallCredentials, CallMetadataGenerator } from '../src/call-credentials'; -import { mockFunction } from './common'; import * as assert from 'assert'; -class MetadataMock extends Metadata { - constructor(private obj: { [propName: string]: Array } = {}) { - super(); - } - - add(key: string, value: string) { - if (!this.obj[key]) { - this.obj[key] = [value]; - } else { - this.obj[key].push(value); - } - } - clone() { return new MetadataMock(Object.create(this.obj)); }; - get(key: string) { return this.obj[key]; } - getMap() { return this.obj; } - set() { mockFunction() } - remove() { mockFunction() } -} -Metadata.createMetadata = () => new MetadataMock(); - // Returns a Promise that resolves to an object containing either an error or // metadata function generateMetadata( @@ -40,7 +19,7 @@ function generateMetadata( function makeGenerator(props: Array): CallMetadataGenerator { return (options: { [propName: string]: string }, cb) => { - const metadata: Metadata = new MetadataMock(); + const metadata: Metadata = new Metadata(); props.forEach((prop) => { if (options[prop]) { metadata.add(prop, options[prop]); @@ -52,7 +31,7 @@ function makeGenerator(props: Array): CallMetadataGenerator { function makeAfterMsElapsedGenerator(ms: number): CallMetadataGenerator { return (_options, cb) => { - const metadata = new MetadataMock(); + const metadata = new Metadata(); metadata.add('msElapsed', `${ms}`); setTimeout(() => cb(null, metadata), ms); }; @@ -101,9 +80,7 @@ describe('CallCredentials', () => { assert.ok(!err); assert.ok(metadata); if (metadata) { - assert.deepEqual(metadata.getMap(), { - name: ['foo'] - }); + assert.deepEqual(metadata.get('name'), ['foo']); } } ); @@ -153,9 +130,7 @@ describe('CallCredentials', () => { assert.ok(!err); assert.ok(metadata); if (metadata) { - assert.deepEqual(metadata.getMap(), { - msElapsed: expected - }); + assert.deepEqual(metadata.get('msElapsed'), expected); } })); }); diff --git a/test/test-metadata.ts b/test/test-metadata.ts new file mode 100644 index 00000000..f884c14d --- /dev/null +++ b/test/test-metadata.ts @@ -0,0 +1,293 @@ +import * as assert from 'assert'; +import * as http2 from 'http2'; +import { range } from 'lodash'; +import * as metadata from '../src/metadata'; + +class Metadata extends metadata.Metadata { + getInternalRepresentation() { + return this.internalRepr; + } + + static fromHttp2Headers(headers: http2.IncomingHttpHeaders): Metadata { + const result = metadata.Metadata.fromHttp2Headers(headers) as Metadata; + result.getInternalRepresentation = + Metadata.prototype.getInternalRepresentation; + return result; + } +} + +const validKeyChars = '0123456789abcdefghijklmnopqrstuvwxyz_-.'; +const validNonBinValueChars = range(0x20, 0x7f) + .map(code => String.fromCharCode(code)) + .join(''); + +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.doesNotThrow(() => { + metadata.set(validKeyChars, 'value'); + }); + assert.throws(() => { + metadata.set('key$', 'value'); + }); + assert.throws(() => { + metadata.set('', 'value'); + }); + }); + + it('Rejects values with non-ASCII characters', () => { + assert.doesNotThrow(() => { + metadata.set('key', validNonBinValueChars); + }); + 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', 'value3a'); + metadata.add('KEY3', 'value3b'); + assert.deepEqual(metadata.getMap(), + {key1: 'value1', + key2: 'value2', + key3: 'value3a'}); + }); + }); + + 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']); + }); + }); + + describe('merge', () => { + it('appends values from a given metadata object', () => { + metadata.add('key1', 'value1'); + metadata.add('Key2', 'value2a'); + metadata.add('KEY3', 'value3a'); + metadata.add('key4', 'value4'); + const metadata2 = new Metadata(); + metadata2.add('KEY1', 'value1'); + metadata2.add('key2', 'value2b'); + metadata2.add('key3', 'value3b'); + metadata2.add('key5', 'value5a'); + metadata2.add('key5', 'value5b'); + const metadata2IR = metadata2.getInternalRepresentation(); + metadata.merge(metadata2); + // Ensure metadata2 didn't change + assert.deepEqual(metadata2.getInternalRepresentation(), metadata2IR); + assert.deepEqual(metadata.get('key1'), ['value1', 'value1']); + assert.deepEqual(metadata.get('key2'), ['value2a', 'value2b']); + assert.deepEqual(metadata.get('key3'), ['value3a', 'value3b']); + assert.deepEqual(metadata.get('key4'), ['value4']); + assert.deepEqual(metadata.get('key5'), ['value5a', 'value5b']); + }); + }); + + describe('toHttp2Headers', () => { + it('creates an OutgoingHttpHeaders object with expected values', () => { + metadata.add('key1', 'value1'); + metadata.add('Key2', 'value2'); + metadata.add('KEY3', 'value3a'); + metadata.add('key3', 'value3b'); + metadata.add('key-bin', Buffer.from(range(0, 16))); + metadata.add('key-bin', Buffer.from(range(16, 32))); + metadata.add('key-bin', Buffer.from(range(0, 32))); + const headers = metadata.toHttp2Headers(); + assert.deepEqual(headers, { + key1: ['value1'], + key2: ['value2'], + key3: ['value3a', 'value3b'], + 'key-bin': [ + 'AAECAwQFBgcICQoLDA0ODw==', + 'EBESExQVFhcYGRobHB0eHw==', + 'AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=' + ] + }); + }); + + it('creates an empty header object from empty Metadata', () => { + assert.deepEqual(metadata.toHttp2Headers(), {}); + }); + }); + + describe('fromHttp2Headers', () => { + it('creates a Metadata object with expected values', () => { + const headers = { + key1: 'value1', + key2: ['value2'], + key3: ['value3a', 'value3b'], + 'key-bin': [ + 'AAECAwQFBgcICQoLDA0ODw==', + 'EBESExQVFhcYGRobHB0eHw==', + 'AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=' + ] + }; + const metadataFromHeaders = Metadata.fromHttp2Headers(headers); + const internalRepr = metadataFromHeaders.getInternalRepresentation(); + assert.deepEqual(internalRepr, { + key1: ['value1'], + key2: ['value2'], + key3: ['value3a', 'value3b'], + 'key-bin': [ + Buffer.from(range(0, 16)), + Buffer.from(range(16, 32)), + Buffer.from(range(0, 32)) + ] + }); + }); + + it('creates an empty Metadata object from empty headers', () => { + const metadataFromHeaders = Metadata.fromHttp2Headers({}); + const internalRepr = metadataFromHeaders.getInternalRepresentation(); + assert.deepEqual(internalRepr, {}); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index cc6364d9..e189ea83 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,10 @@ { "extends": "./node_modules/google-ts-style/tsconfig-google.json", "compilerOptions": { - "lib": [ "es6" ] + "lib": [ "es6" ], + "typeRoots": [ + "node_modules/h2-types", "node_modules/@types" + ] }, "include": [ "src/**/*.ts",