From b2dc9dd53e8232a01f9dc1fa94b8d8605f7b3e18 Mon Sep 17 00:00:00 2001 From: Kelvin Jin Date: Thu, 17 Aug 2017 13:56:37 -0700 Subject: [PATCH 1/9] Add Call & Channel Credentials, and accompanying tests (#7) * Add ChannelCredential tests * Work on channel credentials * Remove context.context checks * Make call credentials an interface * Write comments for call credentials * fixtures comment in test-channel-credentials * small changes * Address name change comment * define metadata interface * Call credentials * Reorganize channel-credentials file * Composed call credentials tests * Simplified CallCredentials composition * Use async.parallel in call-credentials --- package.json | 4 + src/call-credentials.ts | 89 +++++++++++++++-- src/channel-credentials.ts | 130 ++++++++++++++++++++---- src/metadata.ts | 36 ++++++- test/common.ts | 16 +++ test/fixtures/README | 1 + test/fixtures/ca.pem | 15 +++ test/fixtures/server1.key | 16 +++ test/fixtures/server1.pem | 16 +++ test/test-call-credentials.ts | 163 +++++++++++++++++++++++++++++++ test/test-channel-credentials.ts | 120 +++++++++++++++++++++++ test/test.ts | 8 -- tsconfig.json | 5 +- 13 files changed, 580 insertions(+), 39 deletions(-) create mode 100644 test/common.ts create mode 100644 test/fixtures/README create mode 100644 test/fixtures/ca.pem create mode 100644 test/fixtures/server1.key create mode 100644 test/fixtures/server1.pem create mode 100644 test/test-call-credentials.ts create mode 100644 test/test-channel-credentials.ts delete mode 100644 test/test.ts diff --git a/package.json b/package.json index 57395bae..bc02fdd6 100644 --- a/package.json +++ b/package.json @@ -45,5 +45,9 @@ "format": "clang-format -i -style=\"{Language: JavaScript, BasedOnStyle: Google, ColumnLimit: 80}\" src/*.ts test/*.ts", "lint": "tslint -c node_modules/google-ts-style/tslint.json -p . -t codeFrame --type-check", "test": "gulp test" + }, + "dependencies": { + "@types/async": "^2.0.41", + "async": "^2.5.0" } } diff --git a/src/call-credentials.ts b/src/call-credentials.ts index ef782757..cd79315b 100644 --- a/src/call-credentials.ts +++ b/src/call-credentials.ts @@ -1,15 +1,90 @@ -import { Metadata } from './metadata' +import { Metadata } from './metadata'; +import * as async from 'async'; -export class CallCredentials { - static createFromMetadataGenerator(metadataGenerator: (options: Object, cb: (err: Error, metadata: Metadata) => void) => void): CallCredentials { - throw new Error(); +export type CallMetadataGenerator = ( + options: Object, + cb: (err: Error | null, metadata?: Metadata) => void +) => void + +/** + * A class that represents a generic method of adding authentication-related + * metadata on a per-request basis. + */ +export interface CallCredentials { + /** + * Asynchronously generates a new Metadata object. + * @param options Options used in generating the Metadata object. + * @param cb A callback of the form (err, metadata) which will be called with + * either the generated metadata, or an error if one occurred. + */ + generateMetadata: CallMetadataGenerator; + /** + * Creates a new CallCredentials object from properties of both this and + * another CallCredentials object. This object's metadata generator will be + * called first. + * @param callCredentials The other CallCredentials object. + */ + compose: (callCredentials: CallCredentials) => CallCredentials; +} + +export namespace CallCredentials { + /** + * Creates a new CallCredentials object from a given function that generates + * Metadata objects. + * @param metadataGenerator A function that accepts a set of options, and + * generates a Metadata object based on these options, which is passed back + * to the caller via a supplied (err, metadata) callback. + */ + export function createFromMetadataGenerator( + metadataGenerator: CallMetadataGenerator + ): CallCredentials { + return new CallCredentialsImpl([metadataGenerator]); } +} - call(options: Object, cb: (err: Error, metadata: Metadata) => void): void { - throw new Error(); + +class CallCredentialsImpl { + constructor(private metadataGenerators: Array) {} + + generateMetadata( + options: Object, + cb: (err: Error | null, metadata?: Metadata) => void + ): void { + if (this.metadataGenerators.length === 1) { + this.metadataGenerators[0](options, cb); + return; + } + + const tasks: Array> = + this.metadataGenerators.map(fn => fn.bind(null, options)); + const callback: AsyncResultArrayCallback = + (err, metadataArray) => { + if (err || !metadataArray) { + cb(err || new Error('Unknown error')); + return; + } else { + const result = Metadata.createMetadata(); + metadataArray.forEach((metadata) => { + if (metadata) { + const metadataObj = metadata.getMap(); + Object.keys(metadataObj).forEach((key) => { + metadataObj[key].forEach((value) => { + result.add(key, value); + }); + }); + } + }); + cb(null, result); + } + }; + async.parallel(tasks, callback); } compose(callCredentials: CallCredentials): CallCredentials { - throw new Error(); + if (!(callCredentials instanceof CallCredentialsImpl)) { + throw new Error('Unknown CallCredentials implementation provided'); + } + return new CallCredentialsImpl(this.metadataGenerators.concat( + (callCredentials as CallCredentialsImpl).metadataGenerators)); } } diff --git a/src/channel-credentials.ts b/src/channel-credentials.ts index 4998a9e0..975ff310 100644 --- a/src/channel-credentials.ts +++ b/src/channel-credentials.ts @@ -1,29 +1,119 @@ import { CallCredentials } from './call-credentials'; -import { SecureContext } from 'tls'; // or whatever it's actually called +import { createSecureContext, SecureContext } from 'tls'; /** - * A class that contains credentials for communicating over a channel. + * A class that contains credentials for communicating over a channel, as well + * as a set of per-call credentials, which are applied to every method call made + * over a channel initialized with an instance of this class. */ -export class ChannelCredentials { - private constructor() {} +export interface ChannelCredentials { + /** + * Returns a copy of this object with the included set of per-call credentials + * expanded to include callCredentials. + * @param callCredentials A CallCredentials object to associate with this + * instance. + */ + compose(callCredentials: CallCredentials) : ChannelCredentials; - static createSsl(rootCerts?: Buffer, privateKey?: Buffer, certChain?: Buffer) : ChannelCredentials { - throw new Error(); + /** + * Gets the set of per-call credentials associated with this instance. + */ + getCallCredentials() : CallCredentials | null; + + /** + * Gets a SecureContext object generated from input parameters if this + * instance was created with createSsl, or null if this instance was created + * with createInsecure. + */ + getSecureContext() : SecureContext | null; +} + +export namespace ChannelCredentials { + /** + * Return a new ChannelCredentials instance with a given set of credentials. + * The resulting instance can be used to construct a Channel that communicates + * over TLS. + * @param rootCerts The root certificate data. + * @param privateKey The client certificate private key, if available. + * @param certChain The client certificate key chain, if available. + */ + export function createSsl(rootCerts?: Buffer | null, privateKey?: Buffer | null, certChain?: Buffer | null) : ChannelCredentials { + if (privateKey && !certChain) { + throw new Error('Private key must be given with accompanying certificate chain'); + } + if (!privateKey && certChain) { + throw new Error('Certificate chain must be given with accompanying private key'); + } + const secureContext = createSecureContext({ + ca: rootCerts || undefined, + key: privateKey || undefined, + cert: certChain || undefined + }); + return new SecureChannelCredentialsImpl(secureContext); } - static createInsecure() : ChannelCredentials { - throw new Error(); - } - - compose(callCredentials: CallCredentials) : ChannelCredentials { - throw new Error(); - } - - getCallCredentials() : CallCredentials { - throw new Error(); - } - - getSecureContext() : SecureContext { - throw new Error(); + /** + * Return a new ChannelCredentials instance with no credentials. + */ + export function createInsecure() : ChannelCredentials { + return new InsecureChannelCredentialsImpl(); + } +} + + +abstract class ChannelCredentialsImpl implements ChannelCredentials { + protected callCredentials: CallCredentials | null; + + protected constructor(callCredentials?: CallCredentials) { + this.callCredentials = callCredentials || null; + } + + abstract compose(callCredentials: CallCredentials) : ChannelCredentialsImpl; + + getCallCredentials() : CallCredentials | null { + return this.callCredentials; + } + + abstract getSecureContext() : SecureContext | null; +} + +class InsecureChannelCredentialsImpl extends ChannelCredentialsImpl { + constructor(callCredentials?: CallCredentials) { + super(callCredentials); + } + + compose(callCredentials: CallCredentials) : ChannelCredentialsImpl { + const combinedCallCredentials = this.callCredentials ? + this.callCredentials.compose(callCredentials) : + callCredentials; + return new InsecureChannelCredentialsImpl(combinedCallCredentials); + } + + getSecureContext() : SecureContext | null { + return null; + } +} + +class SecureChannelCredentialsImpl extends ChannelCredentialsImpl { + secureContext: SecureContext; + + constructor( + secureContext: SecureContext, + callCredentials?: CallCredentials + ) { + super(callCredentials); + this.secureContext = secureContext; + } + + compose(callCredentials: CallCredentials) : ChannelCredentialsImpl { + const combinedCallCredentials = this.callCredentials ? + this.callCredentials.compose(callCredentials) : + callCredentials; + return new SecureChannelCredentialsImpl(this.secureContext, + combinedCallCredentials); + } + + getSecureContext() : SecureContext | null { + return this.secureContext; } } diff --git a/src/metadata.ts b/src/metadata.ts index 0bb8d8b9..ffa78996 100644 --- a/src/metadata.ts +++ b/src/metadata.ts @@ -1,3 +1,35 @@ -export class Metadata { - +export type MetadataValue = string | Buffer; + +export interface MetadataObject { + [propName: string]: Array; +} + +export class Metadata { + static createMetadata(): Metadata { + return new Metadata(); + } + + set(_key: string, _value: MetadataValue): void { + throw new Error('Not implemented'); + } + + add(_key: string, _value: MetadataValue): void { + throw new Error('Not implemented'); + } + + remove(_key: string): void { + throw new Error('Not implemented'); + } + + get(_key: string): Array { + throw new Error('Not implemented'); + } + + getMap(): MetadataObject { + throw new Error('Not implemented'); + } + + clone(): Metadata { + throw new Error('Not implemented'); + } } diff --git a/test/common.ts b/test/common.ts new file mode 100644 index 00000000..fbb6a2a1 --- /dev/null +++ b/test/common.ts @@ -0,0 +1,16 @@ +import * as assert from 'assert'; + +export function mockFunction(): never { + throw new Error('Not implemented'); +} + +export namespace assert2 { + export function noThrowAndReturn(fn: () => T): T { + try { + return fn(); + } catch (e) { + assert.throws(() => { throw e }); + throw e; // for type safety only + } + } +} diff --git a/test/fixtures/README b/test/fixtures/README new file mode 100644 index 00000000..888d95b9 --- /dev/null +++ b/test/fixtures/README @@ -0,0 +1 @@ +CONFIRMEDTESTKEY diff --git a/test/fixtures/ca.pem b/test/fixtures/ca.pem new file mode 100644 index 00000000..6c8511a7 --- /dev/null +++ b/test/fixtures/ca.pem @@ -0,0 +1,15 @@ +-----BEGIN CERTIFICATE----- +MIICSjCCAbOgAwIBAgIJAJHGGR4dGioHMA0GCSqGSIb3DQEBCwUAMFYxCzAJBgNV +BAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX +aWRnaXRzIFB0eSBMdGQxDzANBgNVBAMTBnRlc3RjYTAeFw0xNDExMTEyMjMxMjla +Fw0yNDExMDgyMjMxMjlaMFYxCzAJBgNVBAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0 +YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQxDzANBgNVBAMT +BnRlc3RjYTCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAwEDfBV5MYdlHVHJ7 ++L4nxrZy7mBfAVXpOc5vMYztssUI7mL2/iYujiIXM+weZYNTEpLdjyJdu7R5gGUu +g1jSVK/EPHfc74O7AyZU34PNIP4Sh33N+/A5YexrNgJlPY+E3GdVYi4ldWJjgkAd +Qah2PH5ACLrIIC6tRka9hcaBlIECAwEAAaMgMB4wDAYDVR0TBAUwAwEB/zAOBgNV +HQ8BAf8EBAMCAgQwDQYJKoZIhvcNAQELBQADgYEAHzC7jdYlzAVmddi/gdAeKPau +sPBG/C2HCWqHzpCUHcKuvMzDVkY/MP2o6JIW2DBbY64bO/FceExhjcykgaYtCH/m +oIU63+CFOTtR7otyQAWHqXa7q4SbCDlG7DyRFxqG0txPtGvy12lgldA2+RgcigQG +Dfcog5wrJytaQ6UA0wE= +-----END CERTIFICATE----- diff --git a/test/fixtures/server1.key b/test/fixtures/server1.key new file mode 100644 index 00000000..143a5b87 --- /dev/null +++ b/test/fixtures/server1.key @@ -0,0 +1,16 @@ +-----BEGIN PRIVATE KEY----- +MIICdQIBADANBgkqhkiG9w0BAQEFAASCAl8wggJbAgEAAoGBAOHDFScoLCVJpYDD +M4HYtIdV6Ake/sMNaaKdODjDMsux/4tDydlumN+fm+AjPEK5GHhGn1BgzkWF+slf +3BxhrA/8dNsnunstVA7ZBgA/5qQxMfGAq4wHNVX77fBZOgp9VlSMVfyd9N8YwbBY +AckOeUQadTi2X1S6OgJXgQ0m3MWhAgMBAAECgYAn7qGnM2vbjJNBm0VZCkOkTIWm +V10okw7EPJrdL2mkre9NasghNXbE1y5zDshx5Nt3KsazKOxTT8d0Jwh/3KbaN+YY +tTCbKGW0pXDRBhwUHRcuRzScjli8Rih5UOCiZkhefUTcRb6xIhZJuQy71tjaSy0p +dHZRmYyBYO2YEQ8xoQJBAPrJPhMBkzmEYFtyIEqAxQ/o/A6E+E4w8i+KM7nQCK7q +K4JXzyXVAjLfyBZWHGM2uro/fjqPggGD6QH1qXCkI4MCQQDmdKeb2TrKRh5BY1LR +81aJGKcJ2XbcDu6wMZK4oqWbTX2KiYn9GB0woM6nSr/Y6iy1u145YzYxEV/iMwff +DJULAkB8B2MnyzOg0pNFJqBJuH29bKCcHa8gHJzqXhNO5lAlEbMK95p/P2Wi+4Hd +aiEIAF1BF326QJcvYKmwSmrORp85AkAlSNxRJ50OWrfMZnBgzVjDx3xG6KsFQVk2 +ol6VhqL6dFgKUORFUWBvnKSyhjJxurlPEahV6oo6+A+mPhFY8eUvAkAZQyTdupP3 +XEFQKctGz+9+gKkemDp7LBBMEMBXrGTLPhpEfcjv/7KPdnFHYmhYeBTBnuVmTVWe +F98XJ7tIFfJq +-----END PRIVATE KEY----- diff --git a/test/fixtures/server1.pem b/test/fixtures/server1.pem new file mode 100644 index 00000000..f3d43fcc --- /dev/null +++ b/test/fixtures/server1.pem @@ -0,0 +1,16 @@ +-----BEGIN CERTIFICATE----- +MIICnDCCAgWgAwIBAgIBBzANBgkqhkiG9w0BAQsFADBWMQswCQYDVQQGEwJBVTET +MBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50ZXJuZXQgV2lkZ2l0cyBQ +dHkgTHRkMQ8wDQYDVQQDEwZ0ZXN0Y2EwHhcNMTUxMTA0MDIyMDI0WhcNMjUxMTAx +MDIyMDI0WjBlMQswCQYDVQQGEwJVUzERMA8GA1UECBMISWxsaW5vaXMxEDAOBgNV +BAcTB0NoaWNhZ28xFTATBgNVBAoTDEV4YW1wbGUsIENvLjEaMBgGA1UEAxQRKi50 +ZXN0Lmdvb2dsZS5jb20wgZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBAOHDFSco +LCVJpYDDM4HYtIdV6Ake/sMNaaKdODjDMsux/4tDydlumN+fm+AjPEK5GHhGn1Bg +zkWF+slf3BxhrA/8dNsnunstVA7ZBgA/5qQxMfGAq4wHNVX77fBZOgp9VlSMVfyd +9N8YwbBYAckOeUQadTi2X1S6OgJXgQ0m3MWhAgMBAAGjazBpMAkGA1UdEwQCMAAw +CwYDVR0PBAQDAgXgME8GA1UdEQRIMEaCECoudGVzdC5nb29nbGUuZnKCGHdhdGVy +em9vaS50ZXN0Lmdvb2dsZS5iZYISKi50ZXN0LnlvdXR1YmUuY29thwTAqAEDMA0G +CSqGSIb3DQEBCwUAA4GBAJFXVifQNub1LUP4JlnX5lXNlo8FxZ2a12AFQs+bzoJ6 +hM044EDjqyxUqSbVePK0ni3w1fHQB5rY9yYC5f8G7aqqTY1QOhoUk8ZTSTRpnkTh +y4jjdvTZeLDVBlueZUTDRmy2feY5aZIU18vFDK08dTG0A87pppuv1LNIR3loveU8 +-----END CERTIFICATE----- diff --git a/test/test-call-credentials.ts b/test/test-call-credentials.ts new file mode 100644 index 00000000..155fb1a6 --- /dev/null +++ b/test/test-call-credentials.ts @@ -0,0 +1,163 @@ +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( + callCredentials: CallCredentials, + options: Object +): Promise<{ err?: Error, metadata?: Metadata }> { + return new Promise((resolve) => { + callCredentials.generateMetadata(options, (err, metadata) => { + resolve({ err: err || undefined, metadata: metadata || undefined }); + }); + }); +} + +// Metadata generators + +function makeGenerator(props: Array): CallMetadataGenerator { + return (options: { [propName: string]: string }, cb) => { + const metadata: Metadata = new MetadataMock(); + props.forEach((prop) => { + if (options[prop]) { + metadata.add(prop, options[prop]); + } + }); + cb(null, metadata); + } +} + +function makeAfterMsElapsedGenerator(ms: number): CallMetadataGenerator { + return (_options, cb) => { + const metadata = new MetadataMock(); + metadata.add('msElapsed', `${ms}`); + setTimeout(() => cb(null, metadata), ms); + }; +}; + +const generateFromName: CallMetadataGenerator = makeGenerator(['name']); +const generateWithError: CallMetadataGenerator = (_options, cb) => + cb(new Error()); + +// Tests + +describe('CallCredentials', () => { + describe('createFromMetadataGenerator', () => { + it('should accept a metadata generator', () => { + assert.doesNotThrow(() => + CallCredentials.createFromMetadataGenerator(generateFromName)); + }); + }); + + describe('compose', () => { + it('should accept a CallCredentials object and return a new object', () => { + const callCredentials1 = CallCredentials.createFromMetadataGenerator(generateFromName); + const callCredentials2 = CallCredentials.createFromMetadataGenerator(generateFromName); + const combinedCredentials = callCredentials1.compose(callCredentials2); + assert.notEqual(combinedCredentials, callCredentials1); + assert.notEqual(combinedCredentials, callCredentials2); + }); + + it('should be chainable', () => { + const callCredentials1 = CallCredentials.createFromMetadataGenerator(generateFromName); + const callCredentials2 = CallCredentials.createFromMetadataGenerator(generateFromName); + assert.doesNotThrow(() => { + callCredentials1.compose(callCredentials2) + .compose(callCredentials2) + .compose(callCredentials2); + }); + }); + }); + + describe('generateMetadata', () => { + it('should call the function passed to createFromMetadataGenerator', + async () => { + const callCredentials = CallCredentials.createFromMetadataGenerator(generateFromName); + const { err, metadata } = await generateMetadata(callCredentials, + { name: 'foo' }); + assert.ok(!err); + assert.ok(metadata); + if (metadata) { + assert.deepEqual(metadata.getMap(), { + name: ['foo'] + }); + } + } + ); + + it('should emit an error if the associated metadataGenerator does', + async () => { + const callCredentials = CallCredentials.createFromMetadataGenerator( + generateWithError); + const { err, metadata } = await generateMetadata(callCredentials, {}); + assert.ok(err instanceof Error); + assert.ok(!metadata); + } + ); + + it('should combine metadata from multiple generators', async () => { + const [callCreds1, callCreds2, callCreds3, callCreds4] = + [50, 100, 150, 200].map((ms) => { + const generator: CallMetadataGenerator = + makeAfterMsElapsedGenerator(ms); + return CallCredentials.createFromMetadataGenerator(generator); + }); + const testCases = [{ + credentials: callCreds1 + .compose(callCreds2) + .compose(callCreds3) + .compose(callCreds4), + expected: ['50', '100', '150', '200'] + }, { + credentials: callCreds4 + .compose(callCreds3 + .compose(callCreds2 + .compose(callCreds1))), + expected: ['200', '150', '100', '50'] + }, { + credentials: callCreds3 + .compose(callCreds4 + .compose(callCreds1) + .compose(callCreds2)), + expected: ['150', '200', '50', '100'] + } + ]; + const options = {}; + // Try each test case and make sure the msElapsed field is as expected + await Promise.all(testCases.map(async (testCase) => { + const { credentials, expected } = testCase; + const { err, metadata } = await generateMetadata(credentials, options); + assert.ok(!err); + assert.ok(metadata); + if (metadata) { + assert.deepEqual(metadata.getMap(), { + msElapsed: expected + }); + } + })); + }); + }); +}); diff --git a/test/test-channel-credentials.ts b/test/test-channel-credentials.ts new file mode 100644 index 00000000..e2270f9e --- /dev/null +++ b/test/test-channel-credentials.ts @@ -0,0 +1,120 @@ +import * as assert from 'assert'; +import { CallCredentials } from '../src/call-credentials'; +import { ChannelCredentials } from '../src/channel-credentials'; +import { mockFunction, assert2 } from './common'; +import * as fs from 'fs'; +import { promisify } from 'util'; + +class CallCredentialsMock implements CallCredentials { + child: CallCredentialsMock; + constructor(child?: CallCredentialsMock) { + if (child) { + this.child = child; + } + } + + generateMetadata = mockFunction; + + compose(callCredentials: CallCredentialsMock): CallCredentialsMock { + return new CallCredentialsMock(callCredentials); + } + + isEqual(other: CallCredentialsMock): boolean { + if (!this.child) { + return this === other; + } else if (!other || !other.child) { + return false; + } else { + return this.child.isEqual(other.child); + } + } +} + +const readFile: (...args: any[]) => Promise = promisify(fs.readFile); +// A promise which resolves to loaded files in the form { ca, key, cert } +const pFixtures = Promise.all([ + 'ca.pem', + 'server1.key', + 'server1.pem' + ].map((file) => readFile(`test/fixtures/${file}`)) +).then((result) => { + return { + ca: result[0], + key: result[1], + cert: result[2] + }; +}); + +describe('ChannelCredentials Implementation', () => { + describe('createInsecure', () => { + it('should return a ChannelCredentials object with no associated secure context', () => { + const creds = assert2.noThrowAndReturn( + () => ChannelCredentials.createInsecure()); + assert.ok(!creds.getSecureContext()); + }); + }); + + describe('createSsl', () => { + it('should work when given no arguments', () => { + const creds: ChannelCredentials = assert2.noThrowAndReturn( + () => ChannelCredentials.createSsl()); + assert.ok(!!creds.getSecureContext()); + }); + + it('should work with just a CA override', async () => { + const { ca } = await pFixtures; + const creds = assert2.noThrowAndReturn( + () => ChannelCredentials.createSsl(ca)); + assert.ok(!!creds.getSecureContext()); + }); + + it('should work with just a private key and cert chain', async () => { + const { key, cert } = await pFixtures; + const creds = assert2.noThrowAndReturn( + () => ChannelCredentials.createSsl(null, key, cert)); + assert.ok(!!creds.getSecureContext()); + }); + + it('should work with all three parameters specified', async () => { + const { ca, key, cert } = await pFixtures; + const creds = assert2.noThrowAndReturn( + () => ChannelCredentials.createSsl(ca, key, cert)); + assert.ok(!!creds.getSecureContext()); + }); + + it('should throw if just one of private key and cert chain are missing', + async () => { + const { ca, key, cert } = await pFixtures; + assert.throws(() => ChannelCredentials.createSsl(ca, key)); + assert.throws(() => ChannelCredentials.createSsl(ca, key, null)); + assert.throws(() => ChannelCredentials.createSsl(ca, null, cert)); + assert.throws(() => ChannelCredentials.createSsl(null, key)); + assert.throws(() => ChannelCredentials.createSsl(null, key, null)); + assert.throws(() => ChannelCredentials.createSsl(null, null, cert)); + }); + }); + + describe('compose', () => { + it('should return a ChannelCredentials object', () => { + const channelCreds = ChannelCredentials.createInsecure(); + const callCreds = new CallCredentialsMock(); + const composedChannelCreds = channelCreds.compose(callCreds); + assert.ok(!channelCreds.getCallCredentials()); + assert.strictEqual(composedChannelCreds.getCallCredentials(), + callCreds); + }); + + it('should be chainable', () => { + const callCreds1 = new CallCredentialsMock(); + const callCreds2 = new CallCredentialsMock(); + // Associate both call credentials with channelCreds + const composedChannelCreds = ChannelCredentials.createInsecure() + .compose(callCreds1) + .compose(callCreds2); + // Build a mock object that should be an identical copy + const composedCallCreds = callCreds1.compose(callCreds2); + assert.ok(composedCallCreds.isEqual( + composedChannelCreds.getCallCredentials() as CallCredentialsMock)); + }); + }); +}); diff --git a/test/test.ts b/test/test.ts deleted file mode 100644 index dbfe3c30..00000000 --- a/test/test.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ChannelCredentials } from '../src/channel-credentials'; -import * as assert from 'assert'; - -describe('Channel Credentials', function() { - it('should be an object', function() { - assert.ok(ChannelCredentials); - }); -}); diff --git a/tsconfig.json b/tsconfig.json index 4be32f59..cc6364d9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,9 +1,10 @@ { "extends": "./node_modules/google-ts-style/tsconfig-google.json", + "compilerOptions": { + "lib": [ "es6" ] + }, "include": [ - "src/*.ts", "src/**/*.ts", - "test/*.ts", "test/**/*.ts" ], "exclude": [ From ac1bb50f7ee29e5d6dc37d6dfab47a3140c4e706 Mon Sep 17 00:00:00 2001 From: Kelvin Jin Date: Tue, 15 Aug 2017 18:56:30 -0700 Subject: [PATCH 2/9] Add metadata + tests --- package.json | 4 +- src/metadata.ts | 132 ++++++++++++++++++++++++++---- test/test-metadata.ts | 182 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 301 insertions(+), 17 deletions(-) create mode 100644 test/test-metadata.ts 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 From 72f4bf47957f6b210d45e61e8945106fae9a0761 Mon Sep 17 00:00:00 2001 From: Kelvin Jin Date: Tue, 15 Aug 2017 19:07:59 -0700 Subject: [PATCH 3/9] Added comments --- src/metadata.ts | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/metadata.ts b/src/metadata.ts index 1936bfa2..5b3c10bd 100644 --- a/src/metadata.ts +++ b/src/metadata.ts @@ -81,15 +81,32 @@ function validate(key: string, value?: MetadataValue): void { } } +/** + * A class for storing metadata. Keys are normalized to lowercase ASCII. + */ export class Metadata { constructor(private 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]; } + /** + * 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); @@ -100,6 +117,10 @@ export class Metadata { } } + /** + * 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); @@ -108,6 +129,11 @@ export class Metadata { } } + /** + * 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); @@ -118,6 +144,11 @@ export class Metadata { } } + /** + * 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, function(values, key) { @@ -129,6 +160,10 @@ export class Metadata { return result; } + /** + * Clones the metadata object. + * @return The newly cloned object. + */ clone(): Metadata { return new Metadata(cloneMetadataObject(this.internalRepr)); } From 0a1eb630f3b9e28706933d4982c0f09a34ec9455 Mon Sep 17 00:00:00 2001 From: Kelvin Jin Date: Thu, 17 Aug 2017 11:55:16 -0700 Subject: [PATCH 4/9] add Metadata#merge --- src/metadata.ts | 17 +++++++++++++++-- test/test-metadata.ts | 37 ++++++++++++++++++++++++++++++++++--- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/src/metadata.ts b/src/metadata.ts index 5b3c10bd..bac6ef66 100644 --- a/src/metadata.ts +++ b/src/metadata.ts @@ -85,7 +85,7 @@ function validate(key: string, value?: MetadataValue): void { * A class for storing metadata. Keys are normalized to lowercase ASCII. */ export class Metadata { - constructor(private readonly internalRepr: MetadataObject = {}) {} + constructor(protected readonly internalRepr: MetadataObject = {}) {} /** * Sets the given value for the given key by replacing any other values @@ -151,7 +151,7 @@ export class Metadata { */ getMap(): { [key: string]: MetadataValue } { const result: { [key: string]: MetadataValue } = {}; - forOwn(this.internalRepr, function(values, key) { + forOwn(this.internalRepr, (values, key) => { if(values.length > 0) { const v = values[0]; result[key] = v instanceof Buffer ? v.slice() : v; @@ -167,4 +167,17 @@ export class Metadata { clone(): Metadata { 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); + }); + } } diff --git a/test/test-metadata.ts b/test/test-metadata.ts index 17c89a48..ee4b25c6 100644 --- a/test/test-metadata.ts +++ b/test/test-metadata.ts @@ -1,5 +1,11 @@ import * as assert from 'assert'; -import { Metadata } from '../src/metadata'; +import * as metadata from '../src/metadata'; + +class Metadata extends metadata.Metadata { + getInternalRepresentation() { + return this.internalRepr; + } +} describe('Metadata', () => { let metadata: Metadata; @@ -150,11 +156,12 @@ describe('Metadata', () => { it('gets a map of keys to values', () => { metadata.add('key1', 'value1'); metadata.add('Key2', 'value2'); - metadata.add('KEY3', 'value3'); + metadata.add('KEY3', 'value3a'); + metadata.add('KEY3', 'value3b'); assert.deepEqual(metadata.getMap(), {key1: 'value1', key2: 'value2', - key3: 'value3'}); + key3: 'value3a'}); }); }); @@ -179,4 +186,28 @@ describe('Metadata', () => { 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']); + }); + }); }); \ No newline at end of file From 44b97e660b3d6d1c70af7d4b9578ada89f075310 Mon Sep 17 00:00:00 2001 From: Kelvin Jin Date: Thu, 17 Aug 2017 12:07:14 -0700 Subject: [PATCH 5/9] Use regexes to validate metadata --- src/metadata.ts | 25 ++----------------------- test/test-metadata.ts | 12 ++++++++++++ 2 files changed, 14 insertions(+), 23 deletions(-) diff --git a/src/metadata.ts b/src/metadata.ts index bac6ef66..62e61096 100644 --- a/src/metadata.ts +++ b/src/metadata.ts @@ -22,33 +22,12 @@ function cloneMetadataObject(repr: MetadataObject): MetadataObject { 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); + return !!key.match(/^[0-9a-z_.-]+$/); } function isLegalNonBinaryValue(value: string): boolean { - return isLegal(legalNonBinValueChars, value); + return !!value.match(/^[ -~]+$/); } function isBinaryKey(key: string): boolean { diff --git a/test/test-metadata.ts b/test/test-metadata.ts index ee4b25c6..3d28cd40 100644 --- a/test/test-metadata.ts +++ b/test/test-metadata.ts @@ -1,4 +1,5 @@ import * as assert from 'assert'; +import { range } from 'lodash'; import * as metadata from '../src/metadata'; class Metadata extends metadata.Metadata { @@ -7,6 +8,11 @@ class Metadata extends metadata.Metadata { } } +const validKeyChars = '0123456789abcdefghijklmnopqrstuvwxyz_-.'; +const validNonBinValueChars = range(0x20, 0x7f) + .map(code => String.fromCharCode(code)) + .join(''); + describe('Metadata', () => { let metadata: Metadata; @@ -34,6 +40,9 @@ describe('Metadata', () => { }); it('Rejects invalid keys', () => { + assert.doesNotThrow(() => { + metadata.set(validKeyChars, 'value'); + }); assert.throws(() => { metadata.set('key$', 'value'); }); @@ -43,6 +52,9 @@ describe('Metadata', () => { }); it('Rejects values with non-ASCII characters', () => { + assert.doesNotThrow(() => { + metadata.set('key', validNonBinValueChars); + }); assert.throws(() => { metadata.set('key', 'résumé'); }); From 9403167d5f442fdaf539fdd03c752f12e2928026 Mon Sep 17 00:00:00 2001 From: Kelvin Jin Date: Thu, 17 Aug 2017 13:55:16 -0700 Subject: [PATCH 6/9] add toHttp2Headers --- package.json | 1 + src/metadata.ts | 20 ++++++++++++++++++++ test/test-metadata.ts | 27 +++++++++++++++++++++++++++ tsconfig.json | 5 ++++- 4 files changed, 52 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index a92c3267..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", diff --git a/src/metadata.ts b/src/metadata.ts index 62e61096..b395c0f8 100644 --- a/src/metadata.ts +++ b/src/metadata.ts @@ -1,4 +1,5 @@ import { forOwn } from 'lodash'; +import * as http2 from 'http2'; export type MetadataValue = string | Buffer; @@ -159,4 +160,23 @@ export class Metadata { 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; + } } diff --git a/test/test-metadata.ts b/test/test-metadata.ts index 3d28cd40..7d6d3851 100644 --- a/test/test-metadata.ts +++ b/test/test-metadata.ts @@ -222,4 +222,31 @@ describe('Metadata', () => { assert.deepEqual(metadata.get('key5'), ['value5a', 'value5b']); }); }); + + describe('toHttp2Headers', () => { + it('creates an http2.OutgoingHttpHeaders object', () => { + 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(), {}); + }); + }); }); \ No newline at end of file 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", From ae20e248eac4b41623959a7d548dc04acbe1059c Mon Sep 17 00:00:00 2001 From: Kelvin Jin Date: Thu, 17 Aug 2017 14:07:43 -0700 Subject: [PATCH 7/9] Modifying call credentials to reflect metadata changes --- src/call-credentials.ts | 9 ++------- test/test-call-credentials.ts | 33 ++++----------------------------- 2 files changed, 6 insertions(+), 36 deletions(-) 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/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); } })); }); From 95827662b4178fc5f42195ed715ae7df0377ea13 Mon Sep 17 00:00:00 2001 From: Kelvin Jin Date: Thu, 17 Aug 2017 14:09:59 -0700 Subject: [PATCH 8/9] add newline --- test/test-metadata.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test-metadata.ts b/test/test-metadata.ts index 7d6d3851..6c3e99cb 100644 --- a/test/test-metadata.ts +++ b/test/test-metadata.ts @@ -249,4 +249,4 @@ describe('Metadata', () => { assert.deepEqual(metadata.toHttp2Headers(), {}); }); }); -}); \ No newline at end of file +}); From fb38744985f4c93d82557738a061b856eb09691f Mon Sep 17 00:00:00 2001 From: Kelvin Jin Date: Wed, 23 Aug 2017 10:51:37 -0700 Subject: [PATCH 9/9] Add fromHttp2Headers --- src/metadata.ts | 29 +++++++++++++++++++++++++++++ test/test-metadata.ts | 43 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/src/metadata.ts b/src/metadata.ts index b395c0f8..e9e71208 100644 --- a/src/metadata.ts +++ b/src/metadata.ts @@ -179,4 +179,33 @@ export class Metadata { }); 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-metadata.ts b/test/test-metadata.ts index 6c3e99cb..f884c14d 100644 --- a/test/test-metadata.ts +++ b/test/test-metadata.ts @@ -1,4 +1,5 @@ import * as assert from 'assert'; +import * as http2 from 'http2'; import { range } from 'lodash'; import * as metadata from '../src/metadata'; @@ -6,6 +7,13 @@ 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_-.'; @@ -224,7 +232,7 @@ describe('Metadata', () => { }); describe('toHttp2Headers', () => { - it('creates an http2.OutgoingHttpHeaders object', () => { + it('creates an OutgoingHttpHeaders object with expected values', () => { metadata.add('key1', 'value1'); metadata.add('Key2', 'value2'); metadata.add('KEY3', 'value3a'); @@ -249,4 +257,37 @@ describe('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, {}); + }); + }); });