diff --git a/packages/client-sdk-nodejs/package-lock.json b/packages/client-sdk-nodejs/package-lock.json index b03161818..6c0eae0dc 100644 --- a/packages/client-sdk-nodejs/package-lock.json +++ b/packages/client-sdk-nodejs/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.1", "license": "Apache-2.0", "dependencies": { - "@gomomento/generated-types": "0.126.0", + "@gomomento/generated-types": "0.133.0", "@gomomento/sdk-core": "file:../core", "@grpc/grpc-js": "1.13.1", "@types/google-protobuf": "3.15.10", @@ -13141,7 +13141,9 @@ "link": true }, "node_modules/@gomomento/generated-types": { - "version": "0.126.0", + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@gomomento/generated-types/-/generated-types-0.133.0.tgz", + "integrity": "sha512-kVTbNDgZ1z11aKZk/rrCH9HvC0ngqFfU5gx2kxCytV+IA7TRtnImzl80DdKiM2/m2SLG8P8qP82x7b7O7qFy3w==", "license": "Apache-2.0", "peerDependencies": { "@grpc/grpc-js": "1.13.1", diff --git a/packages/client-sdk-nodejs/package.json b/packages/client-sdk-nodejs/package.json index 299402833..1346d22f7 100644 --- a/packages/client-sdk-nodejs/package.json +++ b/packages/client-sdk-nodejs/package.json @@ -20,6 +20,7 @@ "integration-test-cache": "jest cache/ --maxWorkers 1 --testPathIgnorePatterns=\"abort-signal\" -- useConsistentReads", "integration-test-control-cache-topics": "npm run integration-test-cache && npm run integration-test-topics", "integration-test-leaderboard": "jest leaderboard/ --maxWorkers 1 -- useConsistentReads", + "integration-test-function": "jest function/ --maxWorkers 1", "integration-test-topics": "jest topics/ --maxWorkers 1 -- useConsistentReads", "integration-test-retry": "jest retry/ --maxWorkers 1 -- useConsistentReads", "integration-test-consistent-reads": "jest integration --testPathIgnorePatterns=\"retry/\" --maxWorkers 1 -- useConsistentReads", @@ -58,7 +59,7 @@ "uuid": "8.3.2" }, "dependencies": { - "@gomomento/generated-types": "0.126.0", + "@gomomento/generated-types": "0.133.0", "@gomomento/sdk-core": "file:../core", "@grpc/grpc-js": "1.13.1", "@types/google-protobuf": "3.15.10", diff --git a/packages/client-sdk-nodejs/src/config/function-configuration.ts b/packages/client-sdk-nodejs/src/config/function-configuration.ts new file mode 100644 index 000000000..fa685f4a7 --- /dev/null +++ b/packages/client-sdk-nodejs/src/config/function-configuration.ts @@ -0,0 +1,160 @@ +import {MomentoLoggerFactory, TransportStrategy} from '../'; +import {Middleware} from './middleware/middleware'; + +/** + * Configuration options for Momento FunctionClient + * + * @export + * @interface FunctionConfiguration + */ +export interface FunctionConfiguration { + /** + * @returns {MomentoLoggerFactory} the current configuration options for logging verbosity and format + */ + getLoggerFactory(): MomentoLoggerFactory; + + /** + * @returns {TransportStrategy} the current configuration options for wire interactions with the Momento service + */ + getTransportStrategy(): TransportStrategy; + + /** + * Convenience copy constructor that updates the client-side timeout setting in the TransportStrategy + * @param {number} clientTimeoutMillis + * @returns {FunctionConfiguration} a new Configuration object with its TransportStrategy updated to use the specified client timeout + */ + withClientTimeoutMillis(clientTimeoutMillis: number): FunctionConfiguration; + + /** + * Copy constructor for overriding TransportStrategy + * @param {TransportStrategy} transportStrategy + * @returns {FunctionConfiguration} a new Configuration object with the specified TransportStrategy + */ + withTransportStrategy( + transportStrategy: TransportStrategy + ): FunctionConfiguration; + + /** + * @returns {boolean} Configures whether the client should return a Momento Error object or throw an exception when an + * error occurs. By default, this is set to false, and the client will return a Momento Error object on errors. Set it + * to true if you prefer for exceptions to be thrown. + */ + getThrowOnErrors(): boolean; + + /** + * Copy constructor for configuring whether the client should return a Momento Error object or throw an exception when an + * error occurs. By default, this is set to false, and the client will return a Momento Error object on errors. Set it + * to true if you prefer for exceptions to be thrown. + * @param {boolean} throwOnErrors + * @returns {FunctionConfiguration} a new Configuration object with the specified throwOnErrors setting + */ + withThrowOnErrors(throwOnErrors: boolean): FunctionConfiguration; + + /** + * @returns {Middleware[]} the middleware functions that will wrap each request + */ + getMiddlewares(): Middleware[]; + + /** + * Copy constructor for overriding Middlewares + * @param {Middleware[]} middlewares + * @returns {FunctionConfiguration} a new Configuration object with the specified Middlewares + */ + withMiddlewares(middlewares: Middleware[]): FunctionConfiguration; + + /** + * Copy constructor that adds a single middleware to the existing middlewares + * @param {Middleware} middleware + * @returns {FunctionConfiguration} a new Configuration object with the specified Middleware prepended to the list of existing Middlewares + */ + addMiddleware(middleware: Middleware): FunctionConfiguration; +} + +export interface FunctionConfigurationProps { + /** + * Configures logging verbosity and format + */ + loggerFactory: MomentoLoggerFactory; + /** + * Configures low-level options for network interactions with the Momento service + */ + transportStrategy: TransportStrategy; + + /** + * Configures whether the client should return a Momento Error object or throw an exception when an error occurs. + */ + throwOnErrors: boolean; + + /** + * Configures middleware functions that will wrap each request + */ + middlewares: Middleware[]; +} + +export class FunctionClientConfiguration implements FunctionConfiguration { + private readonly loggerFactory: MomentoLoggerFactory; + private readonly transportStrategy: TransportStrategy; + private readonly throwOnErrors: boolean; + private readonly middlewares: Middleware[]; + + constructor(props: FunctionConfigurationProps) { + this.loggerFactory = props.loggerFactory; + this.transportStrategy = props.transportStrategy; + this.throwOnErrors = props.throwOnErrors; + this.middlewares = props.middlewares; + } + + getLoggerFactory(): MomentoLoggerFactory { + return this.loggerFactory; + } + + getTransportStrategy(): TransportStrategy { + return this.transportStrategy; + } + + withClientTimeoutMillis(clientTimeoutMillis: number): FunctionConfiguration { + return new FunctionClientConfiguration({ + ...this, + transportStrategy: + this.transportStrategy.withClientTimeoutMillis(clientTimeoutMillis), + }); + } + + withTransportStrategy( + transportStrategy: TransportStrategy + ): FunctionConfiguration { + return new FunctionClientConfiguration({ + ...this, + transportStrategy, + }); + } + + getThrowOnErrors(): boolean { + return this.throwOnErrors; + } + + withThrowOnErrors(throwOnErrors: boolean): FunctionConfiguration { + return new FunctionClientConfiguration({ + ...this, + throwOnErrors, + }); + } + + getMiddlewares(): Middleware[] { + return this.middlewares; + } + + withMiddlewares(middlewares: Middleware[]): FunctionConfiguration { + return new FunctionClientConfiguration({ + ...this, + middlewares, + }); + } + + addMiddleware(middleware: Middleware): FunctionConfiguration { + return new FunctionClientConfiguration({ + ...this, + middlewares: [middleware, ...this.middlewares], + }); + } +} diff --git a/packages/client-sdk-nodejs/src/config/function-configurations.ts b/packages/client-sdk-nodejs/src/config/function-configurations.ts new file mode 100644 index 000000000..c0d860ef9 --- /dev/null +++ b/packages/client-sdk-nodejs/src/config/function-configurations.ts @@ -0,0 +1,71 @@ +import { + FunctionClientConfiguration, + FunctionConfiguration, +} from './function-configuration'; +import {MomentoLoggerFactory} from '@gomomento/sdk-core'; + +import { + GrpcConfiguration, + StaticGrpcConfiguration, + StaticTransportStrategy, + TransportStrategy, +} from '../'; +import {DefaultMomentoLoggerFactory} from './logging/default-momento-logger'; +import {Middleware} from './middleware/middleware'; + +// 4 minutes. We want to remain comfortably underneath the idle timeout for AWS NLB, which is 350s. +const defaultMaxIdleMillis = 4 * 60 * 1_000; +const defaultMaxSessionMemoryMb = 256; +const defaultLoggerFactory: MomentoLoggerFactory = + new DefaultMomentoLoggerFactory(); +const defaultMiddlewares: Middleware[] = []; + +/** + * Laptop config provides defaults suitable for a medium-to-high-latency dev environment. + * @export + * @class Laptop + */ +export class Laptop extends FunctionClientConfiguration { + /** + * Provides the latest recommended configuration for a laptop development environment. NOTE: this configuration may + * change in future releases to take advantage of improvements we identify for default configurations. + * @param {MomentoLoggerFactory} [loggerFactory=defaultLoggerFactory] + * @returns {FunctionConfiguration} + */ + static latest( + loggerFactory: MomentoLoggerFactory = defaultLoggerFactory + ): FunctionConfiguration { + return Laptop.v1(loggerFactory); + } + + /** + * Provides v1 recommended configuration for a laptop development environment. This configuration is guaranteed not + * to change in future releases of the Momento SDK. + * @param {MomentoLoggerFactory} [loggerFactory=defaultLoggerFactory] + * @returns {FunctionConfiguration} + */ + static v1( + loggerFactory: MomentoLoggerFactory = defaultLoggerFactory + ): FunctionConfiguration { + // Deploying a function uploads the wasm artifact inline, so the deadline is more generous than the + // cache-data defaults. + const deadlineMillis = 60000; + const grpcConfig: GrpcConfiguration = new StaticGrpcConfiguration({ + deadlineMillis: deadlineMillis, + maxSessionMemoryMb: defaultMaxSessionMemoryMb, + keepAlivePermitWithoutCalls: 1, + keepAliveTimeMs: 5000, + keepAliveTimeoutMs: 1000, + }); + const transportStrategy: TransportStrategy = new StaticTransportStrategy({ + grpcConfiguration: grpcConfig, + maxIdleMillis: defaultMaxIdleMillis, + }); + return new Laptop({ + loggerFactory: loggerFactory, + transportStrategy: transportStrategy, + throwOnErrors: false, + middlewares: defaultMiddlewares, + }); + } +} diff --git a/packages/client-sdk-nodejs/src/function-client-props.ts b/packages/client-sdk-nodejs/src/function-client-props.ts new file mode 100644 index 000000000..eb10192a5 --- /dev/null +++ b/packages/client-sdk-nodejs/src/function-client-props.ts @@ -0,0 +1,13 @@ +import {CredentialProvider} from '@gomomento/sdk-core'; +import {FunctionConfiguration} from './config/function-configuration'; + +export interface FunctionClientProps { + /** + * Configuration settings for the function client + */ + configuration?: FunctionConfiguration; + /** + * controls how the client will get authentication information for connecting to the Momento service + */ + credentialProvider?: CredentialProvider; +} diff --git a/packages/client-sdk-nodejs/src/index.ts b/packages/client-sdk-nodejs/src/index.ts index b1e025d8c..62c46028c 100644 --- a/packages/client-sdk-nodejs/src/index.ts +++ b/packages/client-sdk-nodejs/src/index.ts @@ -4,6 +4,7 @@ import * as Configurations from './config/configurations'; import * as AuthClientConfigurations from './config/auth-client-configurations'; import * as TopicConfigurations from './config/topic-configurations'; import * as LeaderboardConfigurations from './config/leaderboard-configurations'; +import * as FunctionConfigurations from './config/function-configurations'; import * as BatchUtils from './batchutils/batch-functions'; import {TopicClientProps} from './topic-client-props'; @@ -96,6 +97,10 @@ import * as GenerateDisposableToken from '@gomomento/sdk-core/dist/src/messages/ export {leaderboard} from '@gomomento/sdk-core'; export * from '@gomomento/sdk-core/dist/src/messages/responses/leaderboard'; +// FunctionClient Response Types +export {functions} from '@gomomento/sdk-core'; +export * from '@gomomento/sdk-core/dist/src/messages/responses/function'; + // Enums representing the different types available for each response export * from '@gomomento/sdk-core/dist/src/messages/responses/enums'; @@ -116,6 +121,10 @@ import { SetIfAbsentOrHashEqualOptions, SetIfAbsentOrHashNotEqualOptions, CacheInfo, + FunctionInfo, + FunctionVersionInfo, + IFunctionClient, + PutFunctionOptions, CollectionTtl, ItemType, SortedSetOrder, @@ -138,6 +147,7 @@ import { BadRequestError, PermissionError, CacheNotFoundError, + FunctionNotFoundError, UnknownError, MomentoLogger, MomentoLoggerFactory, @@ -204,6 +214,12 @@ import { LeaderboardClientConfiguration, } from './config/leaderboard-configuration'; import {PreviewLeaderboardClient} from './preview-leaderboard-client'; +import { + FunctionConfiguration, + FunctionClientConfiguration, +} from './config/function-configuration'; +import {PreviewFunctionClient} from './preview-function-client'; +import {FunctionClientProps} from './function-client-props'; export { DefaultMomentoLoggerFactory, @@ -462,6 +478,16 @@ export { PreviewLeaderboardClient, LeaderboardOrder, ILeaderboard, + // FunctionClient + FunctionConfigurations, + FunctionConfiguration, + FunctionClientConfiguration, + PreviewFunctionClient, + FunctionClientProps, + IFunctionClient, + PutFunctionOptions, + FunctionInfo, + FunctionVersionInfo, // Errors MomentoErrorCode, SdkError, @@ -478,6 +504,7 @@ export { BadRequestError, PermissionError, CacheNotFoundError, + FunctionNotFoundError, UnknownError, // Logging MomentoLogger, diff --git a/packages/client-sdk-nodejs/src/internal/function-client-all-props.ts b/packages/client-sdk-nodejs/src/internal/function-client-all-props.ts new file mode 100644 index 000000000..0e0b6e657 --- /dev/null +++ b/packages/client-sdk-nodejs/src/internal/function-client-all-props.ts @@ -0,0 +1,8 @@ +import {FunctionClientProps} from '../function-client-props'; +import {FunctionConfiguration} from '../config/function-configuration'; +import {CredentialProvider} from '@gomomento/sdk-core'; + +export interface FunctionClientAllProps extends FunctionClientProps { + configuration: FunctionConfiguration; + credentialProvider: CredentialProvider; +} diff --git a/packages/client-sdk-nodejs/src/internal/function-client.ts b/packages/client-sdk-nodejs/src/internal/function-client.ts new file mode 100644 index 000000000..4728c6483 --- /dev/null +++ b/packages/client-sdk-nodejs/src/internal/function-client.ts @@ -0,0 +1,492 @@ +import { + CredentialProvider, + DeleteFunction, + FunctionInfo, + FunctionNotFoundError, + FunctionVersionInfo, + IFunctionClient, + InvalidArgumentError, + ListFunctions, + ListFunctionVersions, + MomentoLogger, + MomentoLoggerFactory, + PutFunction, + PutFunctionOptions, + SdkError, + UnknownError, +} from '@gomomento/sdk-core'; +import { + validateCacheName, + validateFunctionId, + validateFunctionName, +} from '@gomomento/sdk-core/dist/src/internal/utils'; +import {FunctionConfiguration} from '../config/function-configuration'; +import {function_client} from '@gomomento/generated-types/dist/function'; +import {function_types} from '@gomomento/generated-types/dist/function_types'; +import {IdleGrpcClientWrapper} from './grpc/idle-grpc-client-wrapper'; +import {GrpcClientWrapper} from './grpc/grpc-client-wrapper'; +import {Header, HeaderInterceptor} from './grpc/headers-interceptor'; +import {CacheServiceErrorMapper} from '../errors/cache-service-error-mapper'; +import { + ChannelCredentials, + ClientReadableStream, + Interceptor, + Metadata, + ServiceError, + status, +} from '@grpc/grpc-js'; +import {version} from '../../package.json'; +import {FunctionClientAllProps} from './function-client-all-props'; +import {middlewaresInterceptor} from './grpc/middlewares-interceptor'; +import { + Middleware, + MiddlewareRequestHandlerContext, +} from '../config/middleware/middleware'; +import {grpcChannelOptionsFromGrpcConfig} from './grpc/grpc-channel-options'; +import {RetryInterceptor} from './grpc/retry-interceptor'; + +export const CONNECTION_ID_KEY = Symbol('connectionID'); + +/** Map the generated `_Function` proto into the public `FunctionInfo`, resolving the current version. */ +function toFunctionInfo(fn: function_types._Function): FunctionInfo { + // current_version is a oneof: a pinned version resolves to its number, otherwise the latest version is active. + const currentVersion = + fn.current_version?.pinned?.pinned_version ?? fn.latest_version; + return new FunctionInfo( + fn.function_id, + fn.name, + fn.description, + fn.latest_version, + currentVersion, + fn.last_updated_at + ); +} + +// Wasm artifacts ship inline in the PutFunction request and can be large, so raise the gRPC message-size +// caps above the data-plane defaults (which are tuned for small cache items). +const MAX_MESSAGE_SIZE_BYTES = 32 * 1024 * 1024; + +export class FunctionClient implements IFunctionClient { + private readonly configuration: FunctionConfiguration; + private readonly credentialProvider: CredentialProvider; + private readonly logger: MomentoLogger; + private readonly cacheServiceErrorMapper: CacheServiceErrorMapper; + private readonly throwOnErrors: boolean; + private readonly requestTimeoutMs: number; + private readonly clientWrapper: GrpcClientWrapper; + private readonly interceptors: Interceptor[]; + // Server-streaming calls (list*) use interceptors WITHOUT the retry interceptor — a stream cannot be + // naively retried. + private readonly streamingInterceptors: Interceptor[]; + + constructor(props: FunctionClientAllProps, functionClientId: string) { + this.configuration = props.configuration; + this.throwOnErrors = props.configuration.getThrowOnErrors(); + this.cacheServiceErrorMapper = new CacheServiceErrorMapper( + this.throwOnErrors + ); + this.credentialProvider = props.credentialProvider; + this.logger = this.configuration.getLoggerFactory().getLogger(this); + const grpcConfig = this.configuration + .getTransportStrategy() + .getGrpcConfig(); + + this.requestTimeoutMs = grpcConfig.getDeadlineMillis(); + this.validateRequestTimeout(this.requestTimeoutMs); + this.logger.debug( + `Creating function client using endpoint: '${this.credentialProvider.getCacheEndpoint()}'` + ); + + const channelOptions = { + ...grpcChannelOptionsFromGrpcConfig(grpcConfig), + 'grpc.max_send_message_length': MAX_MESSAGE_SIZE_BYTES, + 'grpc.max_receive_message_length': MAX_MESSAGE_SIZE_BYTES, + }; + + this.clientWrapper = new IdleGrpcClientWrapper({ + clientFactoryFn: () => + new function_client.FunctionRegistryClient( + this.credentialProvider.getCacheEndpoint(), + this.credentialProvider.isEndpointSecure() + ? ChannelCredentials.createSsl() + : ChannelCredentials.createInsecure(), + channelOptions + ), + loggerFactory: this.configuration.getLoggerFactory(), + clientTimeoutMillis: this.requestTimeoutMs, + maxIdleMillis: this.configuration + .getTransportStrategy() + .getMaxIdleMillis(), + }); + + const context: MiddlewareRequestHandlerContext = {}; + context[CONNECTION_ID_KEY] = functionClientId; + this.interceptors = this.initializeInterceptors( + this.configuration.getLoggerFactory(), + this.configuration.getMiddlewares(), + context + ); + this.streamingInterceptors = this.initializeStreamingInterceptors( + this.configuration.getLoggerFactory(), + this.configuration.getMiddlewares(), + context + ); + } + + close() { + this.logger.debug('Closing function client'); + this.clientWrapper.getClient().close(); + } + + private validateRequestTimeout(timeout?: number) { + this.logger.debug(`Request timeout ms: ${String(timeout)}`); + if (timeout !== undefined && timeout <= 0) { + throw new InvalidArgumentError( + 'request timeout must be greater than zero.' + ); + } + } + + private buildHeaders(): Header[] { + return [ + new Header('Authorization', this.credentialProvider.getAuthToken()), + new Header('agent', `nodejs:function:${version}`), + new Header('runtime-version', `nodejs:${process.versions.node}`), + ]; + } + + private initializeInterceptors( + _loggerFactory: MomentoLoggerFactory, + middlewares: Middleware[], + middlewareRequestContext: MiddlewareRequestHandlerContext + ): Interceptor[] { + return [ + middlewaresInterceptor( + _loggerFactory, + middlewares, + middlewareRequestContext + ), + HeaderInterceptor.createHeadersInterceptor(this.buildHeaders()), + RetryInterceptor.createRetryInterceptor({ + clientName: 'FunctionClient', + loggerFactory: _loggerFactory, + overallRequestTimeoutMs: this.requestTimeoutMs, + }), + ]; + } + + private initializeStreamingInterceptors( + _loggerFactory: MomentoLoggerFactory, + middlewares: Middleware[], + middlewareRequestContext: MiddlewareRequestHandlerContext + ): Interceptor[] { + return [ + middlewaresInterceptor( + _loggerFactory, + middlewares, + middlewareRequestContext + ), + HeaderInterceptor.createHeadersInterceptor(this.buildHeaders()), + ]; + } + + private createMetadata(cacheName: string): Metadata { + const metadata = new Metadata(); + metadata.set('cache', cacheName); + return metadata; + } + + /** + * Like the shared cache error mapper, but surfaces a gRPC NOT_FOUND as a {@link FunctionNotFoundError} — + * the shared mapper would otherwise render a missing function as a cache-flavored CacheNotFoundError. + */ + private resolveOrRejectFunctionError(opts: { + err: ServiceError | null; + errorResponseFactoryFn: (e: SdkError) => T; + resolveFn: (response: T) => void; + rejectFn: (err: SdkError) => void; + }): void { + if (opts.err?.code === status.NOT_FOUND) { + const notFound = new FunctionNotFoundError( + opts.err.message, + opts.err.code, + opts.err.metadata, + opts.err.stack + ); + if (this.throwOnErrors) { + opts.rejectFn(notFound); + } else { + opts.resolveFn(opts.errorResponseFactoryFn(notFound)); + } + return; + } + this.cacheServiceErrorMapper.resolveOrRejectError(opts); + } + + public async putFunction( + cacheName: string, + functionName: string, + wasmBytes: Uint8Array, + options?: PutFunctionOptions + ): Promise { + try { + validateCacheName(cacheName); + validateFunctionName(functionName); + if (wasmBytes.length === 0) { + throw new InvalidArgumentError('wasm bytes must not be empty'); + } + } catch (err) { + return this.cacheServiceErrorMapper.returnOrThrowError( + err as Error, + err => new PutFunction.Error(err) + ); + } + this.logger.trace( + `Issuing 'putFunction' request; cache: ${cacheName}, name: ${functionName}, wasm bytes: ${wasmBytes.length}` + ); + return await this.sendPutFunction( + cacheName, + functionName, + wasmBytes, + options + ); + } + + private async sendPutFunction( + cacheName: string, + functionName: string, + wasmBytes: Uint8Array, + options?: PutFunctionOptions + ): Promise { + const environment = new Map(); + for (const [key, value] of Object.entries( + options?.environmentVariables ?? {} + )) { + environment.set( + key, + new function_types._EnvironmentValue({literal: value}) + ); + } + const request = new function_client._PutFunctionRequest({ + cache_name: cacheName, + name: functionName, + description: options?.description ?? '', + environment: environment, + inline: wasmBytes, + }); + const metadata = this.createMetadata(cacheName); + return await new Promise((resolve, reject) => { + this.clientWrapper + .getClient() + .PutFunction( + request, + metadata, + {interceptors: this.interceptors}, + (err: ServiceError | null, resp: unknown) => { + const fn = ( + resp as function_client._PutFunctionResponse | undefined + )?.function; + if (fn) { + resolve(new PutFunction.Success(toFunctionInfo(fn))); + } else { + // resp present but no function is an anomalous response; convertError(null) yields a generic + // SdkError, so this never throws/hangs. + this.resolveOrRejectFunctionError({ + err: err, + errorResponseFactoryFn: e => new PutFunction.Error(e), + resolveFn: resolve, + rejectFn: reject, + }); + } + } + ); + }); + } + + public async deleteFunction( + cacheName: string, + functionName: string + ): Promise { + try { + validateCacheName(cacheName); + validateFunctionName(functionName); + } catch (err) { + return this.cacheServiceErrorMapper.returnOrThrowError( + err as Error, + err => new DeleteFunction.Error(err) + ); + } + this.logger.trace( + `Issuing 'deleteFunction' request; cache: ${cacheName}, name: ${functionName}` + ); + return await this.sendDeleteFunction(cacheName, functionName); + } + + private async sendDeleteFunction( + cacheName: string, + functionName: string + ): Promise { + const request = new function_client._DeleteFunctionRequest({ + cache_name: cacheName, + name: functionName, + }); + const metadata = this.createMetadata(cacheName); + return await new Promise((resolve, reject) => { + this.clientWrapper + .getClient() + .DeleteFunction( + request, + metadata, + {interceptors: this.interceptors}, + (err: ServiceError | null, resp: unknown) => { + if (resp) { + resolve(new DeleteFunction.Success()); + } else { + this.resolveOrRejectFunctionError({ + err: err, + errorResponseFactoryFn: e => new DeleteFunction.Error(e), + resolveFn: resolve, + rejectFn: reject, + }); + } + } + ); + }); + } + + public async listFunctions( + cacheName: string + ): Promise { + try { + validateCacheName(cacheName); + } catch (err) { + return this.cacheServiceErrorMapper.returnOrThrowError( + err as Error, + err => new ListFunctions.Error(err) + ); + } + this.logger.trace(`Issuing 'listFunctions' request; cache: ${cacheName}`); + return await this.sendListFunctions(cacheName); + } + + private async sendListFunctions( + cacheName: string + ): Promise { + const request = new function_client._ListFunctionsRequest({ + cache_name: cacheName, + }); + const metadata = this.createMetadata(cacheName); + return await new Promise((resolve, reject) => { + const functions: FunctionInfo[] = []; + const call: ClientReadableStream = + this.clientWrapper.getClient().ListFunctions(request, metadata, { + interceptors: this.streamingInterceptors, + deadline: Date.now() + this.requestTimeoutMs, + }); + call.on('data', (resp: function_types._Function) => { + try { + functions.push(toFunctionInfo(resp)); + } catch (e) { + call.cancel(); + resolve( + new ListFunctions.Error( + new UnknownError(e instanceof Error ? e.message : String(e)) + ) + ); + } + }); + call.on('error', (err: ServiceError) => { + this.resolveOrRejectFunctionError({ + err: err, + errorResponseFactoryFn: e => new ListFunctions.Error(e), + resolveFn: resolve, + rejectFn: reject, + }); + }); + call.on('end', () => { + resolve(new ListFunctions.Success(functions)); + }); + }); + } + + public async listFunctionVersions( + functionId: string + ): Promise { + try { + validateFunctionId(functionId); + } catch (err) { + return this.cacheServiceErrorMapper.returnOrThrowError( + err as Error, + err => new ListFunctionVersions.Error(err) + ); + } + this.logger.trace( + `Issuing 'listFunctionVersions' request; functionId: ${functionId}` + ); + return await this.sendListFunctionVersions(functionId); + } + + private async sendListFunctionVersions( + functionId: string + ): Promise { + const request = new function_client._ListFunctionVersionsRequest({ + function_id: functionId, + }); + // listFunctionVersions is keyed by function id, not by cache, so no cache metadata header is set. + const metadata = new Metadata(); + return await new Promise((resolve, reject) => { + const versions: FunctionVersionInfo[] = []; + const call: ClientReadableStream = + this.clientWrapper.getClient().ListFunctionVersions(request, metadata, { + interceptors: this.streamingInterceptors, + deadline: Date.now() + this.requestTimeoutMs, + }); + call.on('data', (resp: function_types._FunctionVersion) => { + try { + // id and wasm_id are proto submessages whose getters return undefined when absent. A row missing + // them is a malformed server/proto response, so fail the call rather than emit a degraded version + // (and never let the access throw uncaught inside this listener — that would hang the promise). + const id = resp.id; + const wasmId = resp.wasm_id; + if (id === undefined || wasmId === undefined) { + call.cancel(); + resolve( + new ListFunctionVersions.Error( + new UnknownError( + 'ListFunctionVersions returned a version row missing id or wasm_id' + ) + ) + ); + return; + } + versions.push( + new FunctionVersionInfo( + id.id, + id.version, + resp.description, + wasmId.id + ) + ); + } catch (e) { + call.cancel(); + resolve( + new ListFunctionVersions.Error( + new UnknownError(e instanceof Error ? e.message : String(e)) + ) + ); + } + }); + call.on('error', (err: ServiceError) => { + this.resolveOrRejectFunctionError({ + err: err, + errorResponseFactoryFn: e => new ListFunctionVersions.Error(e), + resolveFn: resolve, + rejectFn: reject, + }); + }); + call.on('end', () => { + resolve(new ListFunctionVersions.Success(versions)); + }); + }); + } +} diff --git a/packages/client-sdk-nodejs/src/preview-function-client.ts b/packages/client-sdk-nodejs/src/preview-function-client.ts new file mode 100644 index 000000000..0187d97fa --- /dev/null +++ b/packages/client-sdk-nodejs/src/preview-function-client.ts @@ -0,0 +1,111 @@ +import { + DeleteFunction, + getDefaultCredentialProvider, + IFunctionClient, + ListFunctions, + ListFunctionVersions, + MomentoLogger, + PutFunction, + PutFunctionOptions, +} from '@gomomento/sdk-core'; +import {FunctionClient} from './internal/function-client'; +import {FunctionClientProps} from './function-client-props'; +import {FunctionConfiguration, FunctionConfigurations} from './index'; +import {FunctionClientAllProps} from './internal/function-client-all-props'; + +/** + * PREVIEW Momento Function Client + * WARNING: the API for this client is not yet stable and may change without notice. + * Please contact Momento if you would like to try this preview. + * + * Deploys and removes Momento Functions (wasm) in a cache. Methods return a response object unique to each + * request, resolved to a type-safe success/error sub-type — see each response type for details. + */ +export class PreviewFunctionClient implements IFunctionClient { + protected readonly logger: MomentoLogger; + private readonly dataClient: IFunctionClient; + private readonly configuration: FunctionConfiguration; + + constructor(props?: FunctionClientProps) { + const configuration = + props?.configuration ?? getDefaultFunctionConfiguration(); + const allProps: FunctionClientAllProps = { + configuration: configuration, + credentialProvider: + props?.credentialProvider ?? getDefaultCredentialProvider(), + }; + this.configuration = configuration; + + this.logger = configuration.getLoggerFactory().getLogger(this); + this.logger.debug('Creating Momento FunctionClient'); + this.dataClient = new FunctionClient(allProps, '0'); + + this.configuration.getMiddlewares().forEach(m => { + if (m.init) { + m.init(); + } + }); + } + + public close() { + this.dataClient.close(); + this.configuration.getMiddlewares().forEach(m => { + if (m.close) { + m.close(); + } + }); + } + + /** + * Deploys a Momento Function in the given cache: creates it, or updates it if one already exists with the + * same name (each update creates a new version). + */ + public putFunction( + cacheName: string, + functionName: string, + wasmBytes: Uint8Array, + options?: PutFunctionOptions + ): Promise { + return this.dataClient.putFunction( + cacheName, + functionName, + wasmBytes, + options + ); + } + + /** + * Deletes a Momento Function from the given cache. + */ + public deleteFunction( + cacheName: string, + functionName: string + ): Promise { + return this.dataClient.deleteFunction(cacheName, functionName); + } + + /** + * Lists the Momento Functions in the given cache. + */ + public listFunctions(cacheName: string): Promise { + return this.dataClient.listFunctions(cacheName); + } + + /** + * Lists the versions of a Momento Function, by function id. + */ + public listFunctionVersions( + functionId: string + ): Promise { + return this.dataClient.listFunctionVersions(functionId); + } +} + +function getDefaultFunctionConfiguration(): FunctionConfiguration { + const config = FunctionConfigurations.Laptop.latest(); + const logger = config.getLoggerFactory().getLogger('FunctionClient'); + logger.info( + 'No configuration provided to FunctionClient. Using default "Laptop" configuration, suitable for development. For production use, consider specifying an explicit configuration.' + ); + return config; +} diff --git a/packages/client-sdk-nodejs/test/integration/function/function-client.test.ts b/packages/client-sdk-nodejs/test/integration/function/function-client.test.ts new file mode 100644 index 000000000..2c1aa1e71 --- /dev/null +++ b/packages/client-sdk-nodejs/test/integration/function/function-client.test.ts @@ -0,0 +1,101 @@ +import { + CacheClient, + Configurations, + CreateCache, + CredentialProvider, + DeleteFunction, + FunctionNotFoundError, + ListFunctions, + ListFunctionVersions, + MomentoErrorCode, + PreviewFunctionClient, + PutFunction, +} from '../../../src'; +import * as fs from 'fs'; +import * as path from 'path'; +import {v4} from 'uuid'; + +// Runs whenever MOMENTO_API_KEY is set (the normal integration sweep), using the same MOMENTO_API_KEY + +// MOMENTO_ENDPOINT env vars as the rest of the suite via CredentialProvider.fromEnvVarV2(). Functions are +// enabled in all cells, so no special gating is needed. +const describeIfLive = process.env.MOMENTO_API_KEY ? describe : describe.skip; + +describeIfLive('PreviewFunctionClient (integration)', () => { + const cacheName = `js-fn-it-${v4().slice(0, 8)}`; + const wasm = fs.readFileSync(path.join(__dirname, 'test-function.wasm')); + let cacheClient: CacheClient; + let functionClient: PreviewFunctionClient; + + beforeAll(async () => { + const credentialProvider = CredentialProvider.fromEnvVarV2(); + cacheClient = new CacheClient({ + configuration: Configurations.Laptop.latest(), + credentialProvider, + defaultTtlSeconds: 60, + }); + const create = await cacheClient.createCache(cacheName); + if (create instanceof CreateCache.Error) { + throw create.innerException(); + } + functionClient = new PreviewFunctionClient({credentialProvider}); + }); + + afterAll(async () => { + functionClient?.close(); + if (cacheClient) { + await cacheClient.deleteCache(cacheName); + } + }); + + it('puts, lists, lists versions of, and deletes a function', async () => { + const name = `it-fn-${v4().slice(0, 8)}`; + + const put = await functionClient.putFunction(cacheName, name, wasm, { + description: 'integration test function', + environmentVariables: {E2E_GREETING: 'hello'}, + }); + expect(put).toBeInstanceOf(PutFunction.Success); + const deployed = (put as PutFunction.Success).getFunction(); + const functionId = deployed.getFunctionId(); + expect(functionId).toBeTruthy(); + expect(deployed.getName()).toEqual(name); + expect(deployed.getCurrentVersion()).toBeGreaterThanOrEqual(0); + expect(deployed.getLastUpdatedAt()).toBeTruthy(); + + const list = await functionClient.listFunctions(cacheName); + expect(list).toBeInstanceOf(ListFunctions.Success); + const listedNames = (list as ListFunctions.Success) + .getFunctions() + .map(f => f.getName()); + expect(listedNames).toContain(name); + + const versions = await functionClient.listFunctionVersions(functionId); + expect(versions).toBeInstanceOf(ListFunctionVersions.Success); + expect( + (versions as ListFunctionVersions.Success).getVersions().length + ).toBeGreaterThanOrEqual(1); + + const del = await functionClient.deleteFunction(cacheName, name); + expect(del).toBeInstanceOf(DeleteFunction.Success); + + const after = await functionClient.listFunctions(cacheName); + expect(after).toBeInstanceOf(ListFunctions.Success); + expect( + (after as ListFunctions.Success).getFunctions().map(f => f.getName()) + ).not.toContain(name); + }, 30000); + + it('returns a FunctionNotFoundError when deleting a missing function', async () => { + const response = await functionClient.deleteFunction( + cacheName, + `missing-${v4().slice(0, 8)}` + ); + expect(response).toBeInstanceOf(DeleteFunction.Error); + if (response instanceof DeleteFunction.Error) { + expect(response.innerException()).toBeInstanceOf(FunctionNotFoundError); + expect(response.errorCode()).toEqual( + MomentoErrorCode.FUNCTION_NOT_FOUND_ERROR + ); + } + }, 30000); +}); diff --git a/packages/client-sdk-nodejs/test/integration/function/test-function.wasm b/packages/client-sdk-nodejs/test/integration/function/test-function.wasm new file mode 100644 index 000000000..88aa9c3c7 Binary files /dev/null and b/packages/client-sdk-nodejs/test/integration/function/test-function.wasm differ diff --git a/packages/client-sdk-nodejs/test/unit/function-client.test.ts b/packages/client-sdk-nodejs/test/unit/function-client.test.ts new file mode 100644 index 000000000..62b131276 --- /dev/null +++ b/packages/client-sdk-nodejs/test/unit/function-client.test.ts @@ -0,0 +1,132 @@ +import { + DeleteFunction, + FunctionConfigurations, + InvalidArgumentError, + ListFunctions, + ListFunctionVersions, + PreviewFunctionClient, + PutFunction, + StringMomentoTokenProvider, +} from '../../src'; + +// Syntactically correct but not a real token; used only for unit testing constructors + the validation path. +const fakeAuthTokenForTesting = + 'eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJzcXVpcnJlbCIsImNwIjoiY29udHJvbCBwbGFuZSBlbmRwb2ludCIsImMiOiJkYXRhIHBsYW5lIGVuZHBvaW50In0.zsTsEXFawetTCZI'; +const credentialProvider = new StringMomentoTokenProvider({ + authToken: fakeAuthTokenForTesting, +}); +const configuration = FunctionConfigurations.Laptop.latest(); + +describe('PreviewFunctionClient', () => { + it('can construct a PreviewFunctionClient', () => { + const client = new PreviewFunctionClient({ + configuration, + credentialProvider, + }); + client.close(); + }); + + it('returns an error on putFunction with an invalid cache name', async () => { + const client = new PreviewFunctionClient({ + configuration, + credentialProvider, + }); + const response = await client.putFunction( + ' ', + 'my-function', + new Uint8Array([0, 1, 2]), + {environmentVariables: {GREETING: 'hello'}} + ); + expect(response).toBeInstanceOf(PutFunction.Error); + if (response instanceof PutFunction.Error) { + expect(response.innerException()).toBeInstanceOf(InvalidArgumentError); + } + client.close(); + }); + + it('returns an error on deleteFunction with an invalid cache name', async () => { + const client = new PreviewFunctionClient({ + configuration, + credentialProvider, + }); + const response = await client.deleteFunction(' ', 'my-function'); + expect(response).toBeInstanceOf(DeleteFunction.Error); + if (response instanceof DeleteFunction.Error) { + expect(response.innerException()).toBeInstanceOf(InvalidArgumentError); + } + client.close(); + }); + + it('returns an error on listFunctions with an invalid cache name', async () => { + const client = new PreviewFunctionClient({ + configuration, + credentialProvider, + }); + const response = await client.listFunctions(' '); + expect(response).toBeInstanceOf(ListFunctions.Error); + if (response instanceof ListFunctions.Error) { + expect(response.innerException()).toBeInstanceOf(InvalidArgumentError); + } + client.close(); + }); + + it('returns an error on listFunctionVersions with an empty function id', async () => { + const client = new PreviewFunctionClient({ + configuration, + credentialProvider, + }); + const response = await client.listFunctionVersions(' '); + expect(response).toBeInstanceOf(ListFunctionVersions.Error); + if (response instanceof ListFunctionVersions.Error) { + expect(response.innerException()).toBeInstanceOf(InvalidArgumentError); + } + client.close(); + }); + + it('returns an error on putFunction with an empty function name', async () => { + const client = new PreviewFunctionClient({ + configuration, + credentialProvider, + }); + const response = await client.putFunction( + 'a-cache', + ' ', + new Uint8Array([0, 1, 2]) + ); + expect(response).toBeInstanceOf(PutFunction.Error); + if (response instanceof PutFunction.Error) { + expect(response.innerException()).toBeInstanceOf(InvalidArgumentError); + } + client.close(); + }); + + it('returns an error on putFunction with empty wasm bytes', async () => { + const client = new PreviewFunctionClient({ + configuration, + credentialProvider, + }); + const response = await client.putFunction( + 'a-cache', + 'my-function', + new Uint8Array([]) + ); + expect(response).toBeInstanceOf(PutFunction.Error); + if (response instanceof PutFunction.Error) { + expect(response.innerException()).toBeInstanceOf(InvalidArgumentError); + } + client.close(); + }); + + it('returns an error on deleteFunction with an empty function name', async () => { + const client = new PreviewFunctionClient({ + configuration, + credentialProvider, + }); + const response = await client.deleteFunction('a-cache', ' '); + expect(response).toBeInstanceOf(DeleteFunction.Error); + if (response instanceof DeleteFunction.Error) { + expect(response.innerException()).toBeInstanceOf(InvalidArgumentError); + } + client.close(); + }); +}); diff --git a/packages/core/src/clients/IFunctionClient.ts b/packages/core/src/clients/IFunctionClient.ts new file mode 100644 index 000000000..275f5b37d --- /dev/null +++ b/packages/core/src/clients/IFunctionClient.ts @@ -0,0 +1,34 @@ +import { + DeleteFunction, + ListFunctions, + ListFunctionVersions, + PutFunction, +} from '../messages/responses/function'; + +/** + * Options for a {@link IFunctionClient.putFunction} request. + */ +export interface PutFunctionOptions { + /** A human-readable description stored alongside the function. */ + description?: string; + /** Non-secret environment variables made available to the function at runtime. */ + environmentVariables?: Record; +} + +export interface IFunctionClient { + putFunction( + cacheName: string, + functionName: string, + wasmBytes: Uint8Array, + options?: PutFunctionOptions + ): Promise; + deleteFunction( + cacheName: string, + functionName: string + ): Promise; + listFunctions(cacheName: string): Promise; + listFunctionVersions( + functionId: string + ): Promise; + close(): void; +} diff --git a/packages/core/src/errors/errors.ts b/packages/core/src/errors/errors.ts index 20e1fe635..2195fbbf3 100644 --- a/packages/core/src/errors/errors.ts +++ b/packages/core/src/errors/errors.ts @@ -17,6 +17,8 @@ export enum MomentoErrorCode { STORE_NOT_FOUND_ERROR = 'STORE_NOT_FOUND_ERROR', // Item with specified key doesn't exist STORE_ITEM_NOT_FOUND_ERROR = 'STORE_ITEM_NOT_FOUND_ERROR', + // Function with specified name doesn't exist + FUNCTION_NOT_FOUND_ERROR = 'FUNCTION_NOT_FOUND_ERROR', // An unexpected error occurred while trying to fulfill the request INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR', // Insufficient permissions to perform operation @@ -278,6 +280,16 @@ export class StoreItemNotFoundError extends SdkError { override _messageWrapper = 'An item with the specified key does not exist'; } +/** + * Error that occurs when trying to operate on a function that doesn't exist. To resolve, make sure that the + * function you are trying to use exists; if it doesn't, create it first and then try again. + */ +export class FunctionNotFoundError extends SdkError { + override _errorCode = MomentoErrorCode.FUNCTION_NOT_FOUND_ERROR; + override _messageWrapper = + 'A function with the specified name does not exist. To resolve this error, make sure you have created the function before attempting to use it'; +} + /** * Insufficient permissions to perform an operation on Cache Service */ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 15139b148..f71424143 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -88,11 +88,16 @@ import * as GenerateDisposableToken from './messages/responses/generate-disposab export * as leaderboard from './messages/responses/leaderboard'; export * from './messages/responses/leaderboard'; +// Function Response Types +export * as functions from './messages/responses/function'; +export * from './messages/responses/function'; + export * as webhook from './messages/responses/webhook'; export * from './messages/responses/webhook'; export {Webhook, WebhookId} from './messages/webhook'; import {CacheInfo} from './messages/cache-info'; +import {FunctionInfo, FunctionVersionInfo} from './messages/function-info'; import { SubscribeCallOptions, CollectionTtl, @@ -139,6 +144,7 @@ import { CacheNotFoundError, StoreItemNotFoundError, StoreNotFoundError, + FunctionNotFoundError, UnknownError, } from './errors'; @@ -184,6 +190,8 @@ export {IMomentoCache} from './clients/IMomentoCache'; export {ILeaderboardClient} from './clients/ILeaderboardClient'; export {ILeaderboard} from './clients/ILeaderboard'; +export {IFunctionClient, PutFunctionOptions} from './clients/IFunctionClient'; + export { CacheRole, CachePermission, @@ -329,6 +337,8 @@ export { CacheIncreaseTtl, CacheDecreaseTtl, CacheInfo, + FunctionInfo, + FunctionVersionInfo, CacheSetBatch, CacheGetBatch, CacheSetWithHash, @@ -375,5 +385,6 @@ export { CacheNotFoundError, StoreItemNotFoundError, StoreNotFoundError, + FunctionNotFoundError, UnknownError, }; diff --git a/packages/core/src/internal/utils/validators.ts b/packages/core/src/internal/utils/validators.ts index 849e51054..60d7571c4 100644 --- a/packages/core/src/internal/utils/validators.ts +++ b/packages/core/src/internal/utils/validators.ts @@ -118,6 +118,18 @@ export function validateWebhookName(name: string) { } } +export function validateFunctionName(name: string) { + if (isEmpty(name)) { + throw new InvalidArgumentError('function name must not be empty'); + } +} + +export function validateFunctionId(functionId: string) { + if (isEmpty(functionId)) { + throw new InvalidArgumentError('function id must not be empty'); + } +} + export function validateIndexName(name: string) { if (isEmpty(name)) { throw new InvalidArgumentError('index name must not be empty'); diff --git a/packages/core/src/messages/function-info.ts b/packages/core/src/messages/function-info.ts new file mode 100644 index 000000000..fc654da66 --- /dev/null +++ b/packages/core/src/messages/function-info.ts @@ -0,0 +1,92 @@ +/** + * Metadata about a Momento Function, returned by `putFunction` and `listFunctions`. + */ +export class FunctionInfo { + private readonly _functionId: string; + private readonly _name: string; + private readonly _description: string; + private readonly _latestVersion: number; + private readonly _currentVersion: number; + private readonly _lastUpdatedAt: string; + + constructor( + functionId: string, + name: string, + description: string, + latestVersion: number, + currentVersion: number, + lastUpdatedAt: string + ) { + this._functionId = functionId; + this._name = name; + this._description = description; + this._latestVersion = latestVersion; + this._currentVersion = currentVersion; + this._lastUpdatedAt = lastUpdatedAt; + } + + public getFunctionId(): string { + return this._functionId; + } + + public getName(): string { + return this._name; + } + + public getDescription(): string { + return this._description; + } + + /** The latest version of the function. */ + public getLatestVersion(): number { + return this._latestVersion; + } + + /** The version currently serving traffic (the pinned version, or the latest if not pinned). */ + public getCurrentVersion(): number { + return this._currentVersion; + } + + /** When the function was last updated (ISO 8601). */ + public getLastUpdatedAt(): string { + return this._lastUpdatedAt; + } +} + +/** + * Metadata about a single version of a Momento Function, returned by `listFunctionVersions`. + */ +export class FunctionVersionInfo { + private readonly _functionId: string; + private readonly _version: number; + private readonly _description: string; + private readonly _wasmId: string; + + constructor( + functionId: string, + version: number, + description: string, + wasmId: string + ) { + this._functionId = functionId; + this._version = version; + this._description = description; + this._wasmId = wasmId; + } + + public getFunctionId(): string { + return this._functionId; + } + + public getVersion(): number { + return this._version; + } + + public getDescription(): string { + return this._description; + } + + public getWasmId(): string { + return this._wasmId; + } +} diff --git a/packages/core/src/messages/responses/enums/function/index.ts b/packages/core/src/messages/responses/enums/function/index.ts new file mode 100644 index 000000000..5e5d7b234 --- /dev/null +++ b/packages/core/src/messages/responses/enums/function/index.ts @@ -0,0 +1,19 @@ +export enum PutFunctionResponse { + Success = 'Success', + Error = 'Error', +} + +export enum DeleteFunctionResponse { + Success = 'Success', + Error = 'Error', +} + +export enum ListFunctionsResponse { + Success = 'Success', + Error = 'Error', +} + +export enum ListFunctionVersionsResponse { + Success = 'Success', + Error = 'Error', +} diff --git a/packages/core/src/messages/responses/enums/index.ts b/packages/core/src/messages/responses/enums/index.ts index bdcd6688f..2ebed9a45 100644 --- a/packages/core/src/messages/responses/enums/index.ts +++ b/packages/core/src/messages/responses/enums/index.ts @@ -2,5 +2,6 @@ export * from './auth'; export * from './cache'; export * from './topics'; export * from './leaderboard'; +export * from './function'; export * from './store'; export * from './webhook'; diff --git a/packages/core/src/messages/responses/function/delete-function.ts b/packages/core/src/messages/responses/function/delete-function.ts new file mode 100644 index 000000000..5cc1510ab --- /dev/null +++ b/packages/core/src/messages/responses/function/delete-function.ts @@ -0,0 +1,36 @@ +import {SdkError} from '../../../errors'; +import {BaseResponseError, BaseResponseSuccess} from '../response-base'; +import {DeleteFunctionResponse} from '../enums'; + +interface IResponse { + readonly type: DeleteFunctionResponse; +} + +/** + * Indicates a successful delete-function request. Deleting a function that does not exist returns an Error + * with code NOT_FOUND (deletes are not idempotent). + */ +export class Success extends BaseResponseSuccess implements IResponse { + readonly type: DeleteFunctionResponse.Success = + DeleteFunctionResponse.Success; +} + +/** + * Indicates that an error occurred during the delete-function request. + * + * This response object includes the following fields that you can use to determine + * how you would like to handle the error: + * + * - `errorCode()` - a unique Momento error code indicating the type of error that occurred. + * - `message()` - a human-readable description of the error + * - `innerException()` - the original error that caused the failure; can be re-thrown. + */ +export class Error extends BaseResponseError implements IResponse { + readonly type: DeleteFunctionResponse.Error = DeleteFunctionResponse.Error; + + constructor(_innerException: SdkError) { + super(_innerException); + } +} + +export type Response = Success | Error; diff --git a/packages/core/src/messages/responses/function/index.ts b/packages/core/src/messages/responses/function/index.ts new file mode 100644 index 000000000..6436d9387 --- /dev/null +++ b/packages/core/src/messages/responses/function/index.ts @@ -0,0 +1,4 @@ +export * as PutFunction from './put-function'; +export * as DeleteFunction from './delete-function'; +export * as ListFunctions from './list-functions'; +export * as ListFunctionVersions from './list-function-versions'; diff --git a/packages/core/src/messages/responses/function/list-function-versions.ts b/packages/core/src/messages/responses/function/list-function-versions.ts new file mode 100644 index 000000000..cd70aefd8 --- /dev/null +++ b/packages/core/src/messages/responses/function/list-function-versions.ts @@ -0,0 +1,55 @@ +import {SdkError} from '../../../errors'; +import {FunctionVersionInfo} from '../../function-info'; +import {BaseResponseError, BaseResponseSuccess} from '../response-base'; +import {ListFunctionVersionsResponse} from '../enums'; + +interface IResponse { + readonly type: ListFunctionVersionsResponse; +} + +/** + * Indicates a successful list-function-versions request, carrying the versions of the function. + */ +export class Success extends BaseResponseSuccess implements IResponse { + readonly type: ListFunctionVersionsResponse.Success = + ListFunctionVersionsResponse.Success; + private readonly _versions: FunctionVersionInfo[]; + + constructor(versions: FunctionVersionInfo[]) { + super(); + this._versions = versions; + } + + /** + * The versions of the function. + * @returns {FunctionVersionInfo[]} + */ + public getVersions(): FunctionVersionInfo[] { + return [...this._versions]; + } + + public override toString(): string { + return `${super.toString()}: ${this._versions.length} versions`; + } +} + +/** + * Indicates that an error occurred during the list-function-versions request. + * + * This response object includes the following fields that you can use to determine + * how you would like to handle the error: + * + * - `errorCode()` - a unique Momento error code indicating the type of error that occurred. + * - `message()` - a human-readable description of the error + * - `innerException()` - the original error that caused the failure; can be re-thrown. + */ +export class Error extends BaseResponseError implements IResponse { + readonly type: ListFunctionVersionsResponse.Error = + ListFunctionVersionsResponse.Error; + + constructor(_innerException: SdkError) { + super(_innerException); + } +} + +export type Response = Success | Error; diff --git a/packages/core/src/messages/responses/function/list-functions.ts b/packages/core/src/messages/responses/function/list-functions.ts new file mode 100644 index 000000000..235d6f1e8 --- /dev/null +++ b/packages/core/src/messages/responses/function/list-functions.ts @@ -0,0 +1,53 @@ +import {SdkError} from '../../../errors'; +import {FunctionInfo} from '../../function-info'; +import {BaseResponseError, BaseResponseSuccess} from '../response-base'; +import {ListFunctionsResponse} from '../enums'; + +interface IResponse { + readonly type: ListFunctionsResponse; +} + +/** + * Indicates a successful list-functions request, carrying the functions in the cache. + */ +export class Success extends BaseResponseSuccess implements IResponse { + readonly type: ListFunctionsResponse.Success = ListFunctionsResponse.Success; + private readonly _functions: FunctionInfo[]; + + constructor(functions: FunctionInfo[]) { + super(); + this._functions = functions; + } + + /** + * The functions in the cache. + * @returns {FunctionInfo[]} + */ + public getFunctions(): FunctionInfo[] { + return [...this._functions]; + } + + public override toString(): string { + return `${super.toString()}: ${this._functions.length} functions`; + } +} + +/** + * Indicates that an error occurred during the list-functions request. + * + * This response object includes the following fields that you can use to determine + * how you would like to handle the error: + * + * - `errorCode()` - a unique Momento error code indicating the type of error that occurred. + * - `message()` - a human-readable description of the error + * - `innerException()` - the original error that caused the failure; can be re-thrown. + */ +export class Error extends BaseResponseError implements IResponse { + readonly type: ListFunctionsResponse.Error = ListFunctionsResponse.Error; + + constructor(_innerException: SdkError) { + super(_innerException); + } +} + +export type Response = Success | Error; diff --git a/packages/core/src/messages/responses/function/put-function.ts b/packages/core/src/messages/responses/function/put-function.ts new file mode 100644 index 000000000..27971f779 --- /dev/null +++ b/packages/core/src/messages/responses/function/put-function.ts @@ -0,0 +1,70 @@ +import {SdkError} from '../../../errors'; +import {FunctionInfo} from '../../function-info'; +import {BaseResponseError, BaseResponseSuccess} from '../response-base'; +import {PutFunctionResponse} from '../enums'; + +interface IResponse { + readonly type: PutFunctionResponse; +} + +/** + * Indicates a successful put-function request. The function is created, or updated if one already exists with + * the same cache + name (each update creates a new version); the returned function carries its full metadata. + */ +export class Success extends BaseResponseSuccess implements IResponse { + readonly type: PutFunctionResponse.Success = PutFunctionResponse.Success; + private readonly _function: FunctionInfo; + + constructor(deployedFunction: FunctionInfo) { + super(); + this._function = deployedFunction; + } + + /** + * The deployed function's metadata. + * @returns {FunctionInfo} + */ + public getFunction(): FunctionInfo { + return this._function; + } + + /** + * The id of the deployed function. + * @returns {string} + */ + public functionId(): string { + return this._function.getFunctionId(); + } + + /** + * The name of the deployed function. + * @returns {string} + */ + public name(): string { + return this._function.getName(); + } + + public override toString(): string { + return `${super.toString()}: functionId: ${this.functionId()}, name: ${this.name()}`; + } +} + +/** + * Indicates that an error occurred during the put-function request. + * + * This response object includes the following fields that you can use to determine + * how you would like to handle the error: + * + * - `errorCode()` - a unique Momento error code indicating the type of error that occurred. + * - `message()` - a human-readable description of the error + * - `innerException()` - the original error that caused the failure; can be re-thrown. + */ +export class Error extends BaseResponseError implements IResponse { + readonly type: PutFunctionResponse.Error = PutFunctionResponse.Error; + + constructor(_innerException: SdkError) { + super(_innerException); + } +} + +export type Response = Success | Error;