diff --git a/.eslintrc.json b/.eslintrc.json index 0644df09..5c40701f 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -49,6 +49,7 @@ "unicorn/no-null": "off", "import/no-extraneous-dependencies": ["error", { "devDependencies": true - }] + }], + "import/no-cycle": "off" } } diff --git a/package-lock.json b/package-lock.json index 232b5615..88a465b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,8 @@ "lodash": "^4.17.21", "log4js": "^6.3.0", "luxon": "^2.2.0", + "nice-grpc": "^1.0.4", + "nice-grpc-client-middleware-deadline": "^1.0.4", "node-fetch": "^3.1.0", "protobufjs": "^6.8.8" }, @@ -1718,6 +1720,14 @@ "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", "dev": true }, + "node_modules/abort-controller-x": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/abort-controller-x/-/abort-controller-x-0.2.6.tgz", + "integrity": "sha512-U8MmmcfIzl7qnzoog1woxKX/eYkQin3WR7k/S2dtpGLlSlsndXnvOYQEq8y1VnHC3+ofNFAT0GRgHq1lBbXlDQ==", + "dependencies": { + "node-abort-controller": "^1.2.1 || ^2.0.0" + } + }, "node_modules/acorn": { "version": "8.6.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.6.0.tgz", @@ -6058,6 +6068,49 @@ "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", "dev": true }, + "node_modules/nice-grpc": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/nice-grpc/-/nice-grpc-1.0.4.tgz", + "integrity": "sha512-/1fAKywTdwHzVxt1Ski6120lx6S++RpGjXp7y0OUTZze4wHrwgC64xuuRTT6COz5BcX+Pch7gTc2m5fz7+M4nA==", + "dependencies": { + "@grpc/grpc-js": "^1.2.6", + "abort-controller-x": "^0.2.4", + "nice-grpc-common": "^1.0.3", + "node-abort-controller": "^1.2.1" + } + }, + "node_modules/nice-grpc-client-middleware-deadline": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/nice-grpc-client-middleware-deadline/-/nice-grpc-client-middleware-deadline-1.0.4.tgz", + "integrity": "sha512-IYLEzWkLI0ij41WVDLBjBJohmlh2cI+2ttMDawK8h7G209vrAndEJ4iiN9gQUqtguVzq4S3e8BzQgJ26hBMQtw==", + "dependencies": { + "nice-grpc-common": "^1.0.3", + "node-abort-controller": "^2.0.0" + } + }, + "node_modules/nice-grpc-client-middleware-deadline/node_modules/node-abort-controller": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-2.0.0.tgz", + "integrity": "sha512-L8RfEgjBTHAISTuagw51PprVAqNZoG6KSB6LQ6H1bskMVkFs5E71IyjauLBv3XbuomJlguWF/VnRHdJ1gqiAqA==" + }, + "node_modules/nice-grpc-common": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/nice-grpc-common/-/nice-grpc-common-1.0.3.tgz", + "integrity": "sha512-bFETAyaUxcPgcNL6ZW+aOxzrBig9t/3I6ikKBw5dpxzthd9gfNPOG3W8+KPbIMxnHi6ANmbj57wwPoOI6m0qNg==", + "dependencies": { + "node-abort-controller": "^2.0.0" + } + }, + "node_modules/nice-grpc-common/node_modules/node-abort-controller": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-2.0.0.tgz", + "integrity": "sha512-L8RfEgjBTHAISTuagw51PprVAqNZoG6KSB6LQ6H1bskMVkFs5E71IyjauLBv3XbuomJlguWF/VnRHdJ1gqiAqA==" + }, + "node_modules/node-abort-controller": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-1.2.1.tgz", + "integrity": "sha512-79PYeJuj6S9+yOHirR0JBLFOgjB6sQCir10uN6xRx25iD+ZD4ULqgRn3MwWBRaQGB0vEgReJzWwJo42T1R6YbQ==" + }, "node_modules/node-fetch": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.1.0.tgz", @@ -9427,6 +9480,14 @@ "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", "dev": true }, + "abort-controller-x": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/abort-controller-x/-/abort-controller-x-0.2.6.tgz", + "integrity": "sha512-U8MmmcfIzl7qnzoog1woxKX/eYkQin3WR7k/S2dtpGLlSlsndXnvOYQEq8y1VnHC3+ofNFAT0GRgHq1lBbXlDQ==", + "requires": { + "node-abort-controller": "^1.2.1 || ^2.0.0" + } + }, "acorn": { "version": "8.6.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.6.0.tgz", @@ -12765,6 +12826,53 @@ "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", "dev": true }, + "nice-grpc": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/nice-grpc/-/nice-grpc-1.0.4.tgz", + "integrity": "sha512-/1fAKywTdwHzVxt1Ski6120lx6S++RpGjXp7y0OUTZze4wHrwgC64xuuRTT6COz5BcX+Pch7gTc2m5fz7+M4nA==", + "requires": { + "@grpc/grpc-js": "^1.2.6", + "abort-controller-x": "^0.2.4", + "nice-grpc-common": "^1.0.3", + "node-abort-controller": "^1.2.1" + } + }, + "nice-grpc-client-middleware-deadline": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/nice-grpc-client-middleware-deadline/-/nice-grpc-client-middleware-deadline-1.0.4.tgz", + "integrity": "sha512-IYLEzWkLI0ij41WVDLBjBJohmlh2cI+2ttMDawK8h7G209vrAndEJ4iiN9gQUqtguVzq4S3e8BzQgJ26hBMQtw==", + "requires": { + "nice-grpc-common": "^1.0.3", + "node-abort-controller": "^2.0.0" + }, + "dependencies": { + "node-abort-controller": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-2.0.0.tgz", + "integrity": "sha512-L8RfEgjBTHAISTuagw51PprVAqNZoG6KSB6LQ6H1bskMVkFs5E71IyjauLBv3XbuomJlguWF/VnRHdJ1gqiAqA==" + } + } + }, + "nice-grpc-common": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/nice-grpc-common/-/nice-grpc-common-1.0.3.tgz", + "integrity": "sha512-bFETAyaUxcPgcNL6ZW+aOxzrBig9t/3I6ikKBw5dpxzthd9gfNPOG3W8+KPbIMxnHi6ANmbj57wwPoOI6m0qNg==", + "requires": { + "node-abort-controller": "^2.0.0" + }, + "dependencies": { + "node-abort-controller": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-2.0.0.tgz", + "integrity": "sha512-L8RfEgjBTHAISTuagw51PprVAqNZoG6KSB6LQ6H1bskMVkFs5E71IyjauLBv3XbuomJlguWF/VnRHdJ1gqiAqA==" + } + } + }, + "node-abort-controller": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-1.2.1.tgz", + "integrity": "sha512-79PYeJuj6S9+yOHirR0JBLFOgjB6sQCir10uN6xRx25iD+ZD4ULqgRn3MwWBRaQGB0vEgReJzWwJo42T1R6YbQ==" + }, "node-fetch": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.1.0.tgz", @@ -13799,7 +13907,7 @@ "ts-proto": { "version": "git+ssh://git@github.com/DavyJohnes/ts-proto.git#0a9f2b81b52a2e1042d49b308c599549f456beae", "dev": true, - "from": "ts-proto@DavyJohnes/ts-proto#add-service-property-with-build", + "from": "ts-proto@github:DavyJohnes/ts-proto#add-service-property-with-build", "requires": { "@types/object-hash": "^1.3.0", "dataloader": "^1.4.0", diff --git a/package.json b/package.json index 8e06829f..8fd6a9e4 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "lodash": "^4.17.21", "log4js": "^6.3.0", "luxon": "^2.2.0", + "nice-grpc": "^1.0.4", + "nice-grpc-client-middleware-deadline": "^1.0.4", "node-fetch": "^3.1.0", "protobufjs": "^6.8.8" }, diff --git a/src/index.ts b/src/index.ts index 5c1e0319..514aaf62 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,3 @@ export * as serviceClients from './generated/yandex/cloud/service_clients'; export * as cloudApi from './generated/yandex/cloud'; +export * from './session'; diff --git a/src/service-endpoints.test.ts b/src/service-endpoints.test.ts index 2ed5b2aa..d3f485c4 100644 --- a/src/service-endpoints.test.ts +++ b/src/service-endpoints.test.ts @@ -1,13 +1,16 @@ -import { ServiceClientConstructor } from '@grpc/grpc-js'; import { getServiceClientEndpoint } from './service-endpoints'; import { serviceClients } from '.'; +import { GeneratedServiceClientCtor } from './types'; + +// eslint-disable-next-line @typescript-eslint/ban-types +type MockServiceClientCtor = GeneratedServiceClientCtor<{}>; describe('service endpoints', () => { it('each service in generated service_clients module should have endpoint declared in service-endpoints', () => { for (const [, ServiceClient] of Object.entries(serviceClients)) { // eslint-disable-next-line @typescript-eslint/no-loop-func expect(() => { - const endpoint = getServiceClientEndpoint(ServiceClient); + const endpoint = getServiceClientEndpoint(ServiceClient as MockServiceClientCtor); expect(endpoint).toBeTruthy(); }).not.toThrow(); @@ -18,13 +21,13 @@ describe('service endpoints', () => { const serviceName = 'myCustomService'; expect(() => { - getServiceClientEndpoint({ options: { serviceName } } as unknown as ServiceClientConstructor); + getServiceClientEndpoint({ options: { serviceName } } as unknown as MockServiceClientCtor); }).toThrow(`Endpoint for service ${serviceName} is no defined`); }); it('should throw exception if client class has no serviceName option', () => { expect(() => { - getServiceClientEndpoint({ options: {} } as unknown as ServiceClientConstructor); + getServiceClientEndpoint({ options: {} } as unknown as MockServiceClientCtor); }).toThrow('Unable to retrieve serviceName of provided service client class'); }); }); diff --git a/src/service-endpoints.ts b/src/service-endpoints.ts index 54a61de1..a7c098c9 100644 --- a/src/service-endpoints.ts +++ b/src/service-endpoints.ts @@ -1,4 +1,4 @@ -import { ServiceClientConstructor } from '@grpc/grpc-js'; +import { ServiceClientConstructor, ServiceDefinition } from '@grpc/grpc-js'; import { GeneratedServiceClientCtor } from './types'; interface ServiceEndpoint { @@ -305,7 +305,7 @@ const SERVICE_ENDPOINTS_LIST: ServiceEndpointsList = [ }, ]; -export const getServiceClientEndpoint = (generatedClientCtor: GeneratedServiceClientCtor): string => { +export const getServiceClientEndpoint = (generatedClientCtor: GeneratedServiceClientCtor): string => { const clientCtor = generatedClientCtor as unknown as ServiceClientConstructor; // eslint-disable-next-line prefer-destructuring const serviceName: string = clientCtor.options.serviceName as string; diff --git a/src/session.ts b/src/session.ts new file mode 100644 index 00000000..5544f83f --- /dev/null +++ b/src/session.ts @@ -0,0 +1,102 @@ +import { + ChannelCredentials, credentials, Metadata, ServiceDefinition, +} from '@grpc/grpc-js'; +import { createChannel } from 'nice-grpc'; +import { + GeneratedServiceClientCtor, + IamTokenCredentialsConfig, + OAuthCredentialsConfig, + ServiceAccountCredentialsConfig, + SessionConfig, +} from './types'; +import { IamTokenService } from './token-service/iam-token-service'; +import { MetadataTokenService } from './token-service/metadata-token-service'; +import { clientFactory } from './utils/client-factory'; +import { serviceClients, cloudApi } from '.'; +import { getServiceClientEndpoint } from './service-endpoints'; + +const isOAuth = (config: SessionConfig): config is OAuthCredentialsConfig => 'oauthToken' in config; + +const isIamToken = (config: SessionConfig): config is IamTokenCredentialsConfig => 'iamToken' in config; + +const isServiceAccount = (config: SessionConfig): config is ServiceAccountCredentialsConfig => 'serviceAccountJson' in config; + +const createIamToken = async (iamEndpoint: string, req: Partial) => { + const channel = createChannel(iamEndpoint, credentials.createSsl()); + const client = clientFactory.create(serviceClients.IamTokenServiceClient.service, channel); + const resp = await client.create(cloudApi.iam.iam_token.CreateIamTokenRequest.fromPartial(req)); + + return resp.iamToken; +}; + +const newTokenCreator = (config: SessionConfig): () => Promise => { + if (isOAuth(config)) { + return () => { + const iamEndpoint = getServiceClientEndpoint(serviceClients.IamTokenServiceClient); + + return createIamToken(iamEndpoint, { + yandexPassportOauthToken: config.oauthToken, + }); + }; + } if (isIamToken(config)) { + const { iamToken } = config; + + return async () => iamToken; + } + + const tokenService = isServiceAccount(config) ? new IamTokenService(config.serviceAccountJson) : new MetadataTokenService(); + + return async () => tokenService.getToken(); +}; + +const newChannelCredentials = (tokenCreator: TokenCreator) => credentials.combineChannelCredentials( + credentials.createSsl(), + credentials.createFromMetadataGenerator( + ( + params: { service_url: string }, + callback: (error: any, result?: any) => void, + ) => { + tokenCreator() + .then((token) => { + const md = new Metadata(); + + md.set('authorization', `Bearer ${token}`); + + return callback(null, md); + }) + .catch((error) => callback(error)); + }, + ), +); + +type TokenCreator = () => Promise; + +export class Session { + private readonly config: SessionConfig; + private readonly channelCredentials: ChannelCredentials; + private readonly tokenCreator: TokenCreator; + + private static readonly DEFAULT_CONFIG: SessionConfig = { + pollInterval: 1000, + }; + + constructor(config?: SessionConfig) { + this.config = { + ...Session.DEFAULT_CONFIG, + ...config, + }; + this.tokenCreator = newTokenCreator(this.config); + this.channelCredentials = newChannelCredentials(this.tokenCreator); + } + + get pollInterval() { + return this.config.pollInterval; + } + + client(clientClass: GeneratedServiceClientCtor) { + const endpoint = getServiceClientEndpoint(clientClass); + const channel = createChannel(endpoint, this.channelCredentials); + + return clientFactory.create(clientClass.service, channel); + } +} diff --git a/src/token-service/iam-token-service.ts b/src/token-service/iam-token-service.ts index bd557aaf..370fa100 100644 --- a/src/token-service/iam-token-service.ts +++ b/src/token-service/iam-token-service.ts @@ -1,24 +1,14 @@ import { credentials, Metadata } from '@grpc/grpc-js'; import * as jwt from 'jsonwebtoken'; import { DateTime } from 'luxon'; +import { createChannel } from 'nice-grpc'; import { cloudApi, serviceClients } from '..'; import { getServiceClientEndpoint } from '../service-endpoints'; -import { TokenService } from '../types'; +import { IIAmCredentials, ISslCredentials, TokenService } from '../types'; +import { clientFactory } from '../utils/client-factory'; const { IamTokenServiceClient } = serviceClients; -export interface IIAmCredentials { - serviceAccountId: string; - accessKeyId: string; - privateKey: Buffer | string; -} - -export interface ISslCredentials { - rootCertificates?: Buffer; - clientPrivateKey?: Buffer; - clientCertChain?: Buffer; -} - export class IamTokenService implements TokenService { public readonly sslCredentials?: ISslCredentials; @@ -53,11 +43,9 @@ export class IamTokenService implements TokenService { private client() { const endpoint = getServiceClientEndpoint(IamTokenServiceClient); + const channel = createChannel(endpoint, credentials.createSsl()); - return new serviceClients.IamTokenServiceClient( - endpoint, - credentials.createSsl(), - ); + return clientFactory.create(IamTokenServiceClient.service, channel); } private getJwtRequest() { @@ -91,21 +79,11 @@ export class IamTokenService implements TokenService { private async requestToken(): Promise { const deadline = DateTime.now().plus({ millisecond: this.tokenRequestTimeout }).toJSDate(); - return new Promise((resolve, reject) => { - this.client().create( - cloudApi.iam.iam_token.CreateIamTokenRequest.fromPartial({ - jwt: this.getJwtRequest(), - }), - new Metadata(), - { deadline }, - (err, response) => { - if (err) { - reject(err); - } else { - resolve(response); - } - }, - ); - }); + return this.client().create( + cloudApi.iam.iam_token.CreateIamTokenRequest.fromPartial({ + jwt: this.getJwtRequest(), + }), + { deadline }, + ); } } diff --git a/src/types.ts b/src/types.ts index 9e92ed6d..f9560d51 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,13 +1,50 @@ -import { ChannelCredentials, ChannelOptions, Client } from '@grpc/grpc-js'; +import { + ChannelCredentials, ChannelOptions, Client, ServiceDefinition, +} from '@grpc/grpc-js'; export interface TokenService { getToken: () => Promise; } -export interface GeneratedServiceClientCtor { +export interface GeneratedServiceClientCtor { new( address: string, credentials: ChannelCredentials, options?: Partial, ): Client; + service: T } + +export interface IIAmCredentials { + serviceAccountId: string; + accessKeyId: string; + privateKey: Buffer | string; +} + +export interface ISslCredentials { + rootCertificates?: Buffer; + clientPrivateKey?: Buffer; + clientCertChain?: Buffer; +} + +export interface GenericCredentialsConfig { + pollInterval?: number; +} + +export interface OAuthCredentialsConfig extends GenericCredentialsConfig { + oauthToken: string; +} + +export interface IamTokenCredentialsConfig extends GenericCredentialsConfig { + iamToken: string; +} + +export interface ServiceAccountCredentialsConfig extends GenericCredentialsConfig { + serviceAccountJson: IIAmCredentials; +} + +export type SessionConfig = + | OAuthCredentialsConfig + | IamTokenCredentialsConfig + | ServiceAccountCredentialsConfig + | GenericCredentialsConfig; diff --git a/src/utils/client-factory.ts b/src/utils/client-factory.ts new file mode 100644 index 00000000..86a1d26c --- /dev/null +++ b/src/utils/client-factory.ts @@ -0,0 +1,4 @@ +import { createClientFactory } from 'nice-grpc'; +import { deadlineMiddleware } from 'nice-grpc-client-middleware-deadline'; + +export const clientFactory = createClientFactory().use(deadlineMiddleware);