Skip to content

Commit b54bf2c

Browse files
authored
Api Key Support (#1385)
1 parent 41d5005 commit b54bf2c

File tree

13 files changed

+504
-251
lines changed

13 files changed

+504
-251
lines changed

packages/client/src/connection.ts

Lines changed: 94 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,11 +95,21 @@ export interface ConnectionOptions {
9595

9696
/**
9797
* Optional mapping of gRPC metadata (HTTP headers) to send with each request to the server.
98+
* Setting the `Authorization` header is mutually exclusive with the {@link apiKey} option.
9899
*
99100
* In order to dynamically set metadata, use {@link Connection.withMetadata}
100101
*/
101102
metadata?: Metadata;
102103

104+
/**
105+
* API key for Temporal. This becomes the "Authorization" HTTP header with "Bearer " prepended.
106+
* This is mutually exclusive with the `Authorization` header in {@link ConnectionOptions.metadata}.
107+
*
108+
* You may provide a static string or a callback. Also see {@link Connection.withApiKey} or
109+
* {@link Connection.setApiKey}
110+
*/
111+
apiKey?: string | (() => string);
112+
103113
/**
104114
* Milliseconds to wait until establishing a connection with the server.
105115
*
@@ -113,7 +123,7 @@ export interface ConnectionOptions {
113123
}
114124

115125
export type ConnectionOptionsWithDefaults = Required<
116-
Omit<ConnectionOptions, 'tls' | 'connectTimeout' | 'callCredentials'>
126+
Omit<ConnectionOptions, 'tls' | 'connectTimeout' | 'callCredentials' | 'apiKey'>
117127
> & {
118128
connectTimeoutMs: number;
119129
};
@@ -142,9 +152,22 @@ function addDefaults(options: ConnectionOptions): ConnectionOptionsWithDefaults
142152
* - Convert {@link ConnectionOptions.tls} to {@link grpc.ChannelCredentials}
143153
* - Add the grpc.ssl_target_name_override GRPC {@link ConnectionOptions.channelArgs | channel arg}
144154
* - Add default port to address if port not specified
155+
* - Set `Authorization` header based on {@link ConnectionOptions.apiKey}
145156
*/
146157
function normalizeGRPCConfig(options?: ConnectionOptions): ConnectionOptions {
147158
const { tls: tlsFromConfig, credentials, callCredentials, ...rest } = options || {};
159+
if (rest.apiKey) {
160+
if (rest.metadata?.['Authorization']) {
161+
throw new TypeError(
162+
'Both `apiKey` option and `Authorization` header were provided, but only one makes sense to use at a time.'
163+
);
164+
}
165+
if (credentials !== undefined) {
166+
throw new TypeError(
167+
'Both `apiKey` and `credentials` ConnectionOptions were provided, but only one makes sense to use at a time'
168+
);
169+
}
170+
}
148171
if (rest.address) {
149172
// eslint-disable-next-line prefer-const
150173
let [host, port] = rest.address.split(':', 2);
@@ -189,6 +212,7 @@ export interface RPCImplOptions {
189212
callContextStorage: AsyncLocalStorage<CallContext>;
190213
interceptors?: grpc.Interceptor[];
191214
staticMetadata: Metadata;
215+
apiKeyFnRef: { fn?: () => string };
192216
}
193217

194218
export interface ConnectionCtorOptions {
@@ -209,6 +233,7 @@ export interface ConnectionCtorOptions {
209233
*/
210234
readonly healthService: HealthService;
211235
readonly callContextStorage: AsyncLocalStorage<CallContext>;
236+
readonly apiKeyFnRef: { fn?: () => string };
212237
}
213238

214239
/**
@@ -250,9 +275,20 @@ export class Connection {
250275
public readonly operatorService: OperatorService;
251276
public readonly healthService: HealthService;
252277
readonly callContextStorage: AsyncLocalStorage<CallContext>;
278+
private readonly apiKeyFnRef: { fn?: () => string };
253279

254280
protected static createCtorOptions(options?: ConnectionOptions): ConnectionCtorOptions {
255-
const optionsWithDefaults = addDefaults(normalizeGRPCConfig(options));
281+
const normalizedOptions = normalizeGRPCConfig(options);
282+
const apiKeyFnRef: { fn?: () => string } = {};
283+
if (normalizedOptions.apiKey) {
284+
if (typeof normalizedOptions.apiKey === 'string') {
285+
const apiKey = normalizedOptions.apiKey;
286+
apiKeyFnRef.fn = () => apiKey;
287+
} else {
288+
apiKeyFnRef.fn = normalizedOptions.apiKey;
289+
}
290+
}
291+
const optionsWithDefaults = addDefaults(normalizedOptions);
256292
// Allow overriding this
257293
optionsWithDefaults.metadata['client-name'] ??= 'temporal-typescript';
258294
optionsWithDefaults.metadata['client-version'] ??= pkg.version;
@@ -270,6 +306,7 @@ export class Connection {
270306
callContextStorage,
271307
interceptors: optionsWithDefaults?.interceptors,
272308
staticMetadata: optionsWithDefaults.metadata,
309+
apiKeyFnRef,
273310
});
274311
const workflowService = WorkflowService.create(workflowRpcImpl, false, false);
275312
const operatorRpcImpl = this.generateRPCImplementation({
@@ -278,6 +315,7 @@ export class Connection {
278315
callContextStorage,
279316
interceptors: optionsWithDefaults?.interceptors,
280317
staticMetadata: optionsWithDefaults.metadata,
318+
apiKeyFnRef,
281319
});
282320
const operatorService = OperatorService.create(operatorRpcImpl, false, false);
283321
const healthRpcImpl = this.generateRPCImplementation({
@@ -286,6 +324,7 @@ export class Connection {
286324
callContextStorage,
287325
interceptors: optionsWithDefaults?.interceptors,
288326
staticMetadata: optionsWithDefaults.metadata,
327+
apiKeyFnRef,
289328
});
290329
const healthService = HealthService.create(healthRpcImpl, false, false);
291330

@@ -296,6 +335,7 @@ export class Connection {
296335
operatorService,
297336
healthService,
298337
options: optionsWithDefaults,
338+
apiKeyFnRef,
299339
};
300340
}
301341

@@ -359,13 +399,15 @@ export class Connection {
359399
operatorService,
360400
healthService,
361401
callContextStorage,
402+
apiKeyFnRef,
362403
}: ConnectionCtorOptions) {
363404
this.options = options;
364405
this.client = client;
365406
this.workflowService = workflowService;
366407
this.operatorService = operatorService;
367408
this.healthService = healthService;
368409
this.callContextStorage = callContextStorage;
410+
this.apiKeyFnRef = apiKeyFnRef;
369411
}
370412

371413
protected static generateRPCImplementation({
@@ -374,10 +416,14 @@ export class Connection {
374416
callContextStorage,
375417
interceptors,
376418
staticMetadata,
419+
apiKeyFnRef,
377420
}: RPCImplOptions): RPCImpl {
378421
return (method: { name: string }, requestData: any, callback: grpc.requestCallback<any>) => {
379422
const metadataContainer = new grpc.Metadata();
380423
const { metadata, deadline, abortSignal } = callContextStorage.getStore() ?? {};
424+
if (apiKeyFnRef.fn) {
425+
metadataContainer.set('Authorization', `Bearer ${apiKeyFnRef.fn()}`);
426+
}
381427
for (const [k, v] of Object.entries(staticMetadata)) {
382428
metadataContainer.set(k, v);
383429
}
@@ -451,7 +497,52 @@ export class Connection {
451497
*/
452498
async withMetadata<ReturnType>(metadata: Metadata, fn: () => Promise<ReturnType>): Promise<ReturnType> {
453499
const cc = this.callContextStorage.getStore();
454-
return await this.callContextStorage.run({ ...cc, metadata: { ...cc?.metadata, ...metadata } }, fn);
500+
return await this.callContextStorage.run(
501+
{
502+
...cc,
503+
metadata: { ...cc?.metadata, ...metadata },
504+
},
505+
fn
506+
);
507+
}
508+
509+
/**
510+
* Set the apiKey for any service requests executed in `fn`'s scope (thus changing the `Authorization` header).
511+
*
512+
* @returns value returned from `fn`
513+
*
514+
* @example
515+
*
516+
* ```ts
517+
* const workflowHandle = await conn.withApiKey('secret', () =>
518+
* conn.withMetadata({ otherKey: 'set' }, () => client.start(options)))
519+
* );
520+
* ```
521+
*/
522+
async withApiKey<ReturnType>(apiKey: string, fn: () => Promise<ReturnType>): Promise<ReturnType> {
523+
const cc = this.callContextStorage.getStore();
524+
return await this.callContextStorage.run(
525+
{
526+
...cc,
527+
metadata: { ...cc?.metadata, Authorization: `Bearer ${apiKey}` },
528+
},
529+
fn
530+
);
531+
}
532+
533+
/**
534+
* Set the {@link ConnectionOptions.apiKey} for all subsequent requests. A static string or a
535+
* callback function may be provided.
536+
*/
537+
setApiKey(apiKey: string | (() => string)): void {
538+
if (typeof apiKey === 'string') {
539+
if (apiKey === '') {
540+
throw new TypeError('`apiKey` must not be an empty string');
541+
}
542+
this.apiKeyFnRef.fn = () => apiKey;
543+
} else {
544+
this.apiKeyFnRef.fn = apiKey;
545+
}
455546
}
456547

457548
/**

0 commit comments

Comments
 (0)