diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1878252de2..e263ffadc2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -155,13 +155,17 @@ Then set up your Firebase/GCP project as follows: Firebase Console. Select the "Sign-in method" tab, and enable the "Email/Password" sign-in method, including the Email link (passwordless sign-in) option. -3. Enable the IAM API: Go to the +3. Enable the Firebase ML API: Go to the + [Google Developers Console]( + https://console.developers.google.com/apis/api/firebaseml.googleapis.com/overview) + and make sure your project is selected. If the API is not already enabled, click Enable. +4. Enable the IAM API: Go to the [Google Cloud Platform Console](https://console.cloud.google.com) and make sure your Firebase/GCP project is selected. Select "APIs & Services > Dashboard" from the main menu, and click the "ENABLE APIS AND SERVICES" button. Search for and enable the "Identity and Access Management (IAM) API". -4. Grant your service account the 'Firebase Authentication Admin' role. This is +5. Grant your service account the 'Firebase Authentication Admin' role. This is required to ensure that exported user records contain the password hashes of the user accounts: 1. Go to [Google Cloud Platform Console / IAM & admin](https://console.cloud.google.com/iam-admin). diff --git a/docgen/content-sources/node/toc.yaml b/docgen/content-sources/node/toc.yaml index bba7cf4f64..82b9c80f21 100644 --- a/docgen/content-sources/node/toc.yaml +++ b/docgen/content-sources/node/toc.yaml @@ -132,6 +132,22 @@ toc: - title: "InstanceId" path: /docs/reference/admin/node/admin.instanceId.InstanceId +- title: "machinelearning" + path: /docs/reference/admin/node/admin.machinelearning + section: + - title: "ListModelsOptions" + path: /docs/reference/admin/node/admin.machinelearning.ListModelsOptions + - title: "ListModelsResult" + path: /docs/reference/admin/node/admin.machinelearning.ListModelsResult + - title: "MachineLearning" + path: /docs/reference/admin/node/admin.machinelearning.MachineLearning + - title: "Model" + path: /docs/reference/admin/node/admin.machinelearning.Model + - title: "ModelOptions" + path: /docs/reference/admin/node/admin.machinelearning.ModelOptions + - title: "TFLiteModel" + path: /docs/reference/admin/node/admin.machinelearning.TFLiteModel + - title: "admin.messaging" path: /docs/reference/admin/node/admin.messaging section: diff --git a/src/firebase-app.ts b/src/firebase-app.ts index da50651eb3..c272a400d9 100644 --- a/src/firebase-app.ts +++ b/src/firebase-app.ts @@ -22,6 +22,7 @@ import {FirebaseNamespaceInternals} from './firebase-namespace'; import {AppErrorCodes, FirebaseAppError} from './utils/error'; import {Auth} from './auth/auth'; +import {MachineLearning} from './machine-learning/machine-learning'; import {Messaging} from './messaging/messaging'; import {Storage} from './storage/storage'; import {Database} from '@firebase/database'; @@ -29,6 +30,7 @@ import {DatabaseService} from './database/database'; import {Firestore} from '@google-cloud/firestore'; import {FirestoreService} from './firestore/firestore'; import {InstanceId} from './instance-id/instance-id'; + import {ProjectManagement} from './project-management/project-management'; import {SecurityRules} from './security-rules/security-rules'; @@ -354,6 +356,19 @@ export class FirebaseApp { }); } + /** + * Returns the MachineLearning service instance associated with this app. + * + * @return {MachineLearning} The Machine Learning service instance of this app + */ + public machineLearning(): MachineLearning { + return this.ensureService_('machine-learning', () => { + const machineLearningService: typeof MachineLearning = + require('./machine-learning/machine-learning').MachineLearning; + return new machineLearningService(this); + }); + } + /** * Returns the ProjectManagement service instance associated with this app. * diff --git a/src/firebase-namespace.ts b/src/firebase-namespace.ts index 433659af84..21d3a3949c 100644 --- a/src/firebase-namespace.ts +++ b/src/firebase-namespace.ts @@ -28,6 +28,7 @@ import { } from './auth/credential'; import {Auth} from './auth/auth'; +import {MachineLearning} from './machine-learning/machine-learning'; import {Messaging} from './messaging/messaging'; import {Storage} from './storage/storage'; import {Database} from '@firebase/database'; @@ -399,6 +400,21 @@ export class FirebaseNamespace { return fn; } + /** + * Gets the `MachineLearning` service namespace. The returned namespace can be + * used to get the `MachineLearning` service for the default app or an + * explicityly specified app. + */ + get machineLearning(): FirebaseServiceNamespace { + const fn: FirebaseServiceNamespace = + (app?: FirebaseApp) => { + return this.ensureApp(app).machineLearning(); + }; + const machineLearning = + require('./machine-learning/machine-learning').MachineLearning; + return Object.assign(fn, {MachineLearning: machineLearning}); + } + /** * Gets the `InstanceId` service namespace. The returned namespace can be used to get the * `Instance` service for the default app or an explicitly specified app. diff --git a/src/index.d.ts b/src/index.d.ts index 58586bac25..06b09e297c 100755 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -404,6 +404,37 @@ declare namespace admin { */ function securityRules(app?: admin.app.App): admin.securityRules.SecurityRules; + /** + * Gets the {@link admin.machineLearning.MachineLearning `MachineLearning`} service for the + * default app or a given app. + * + * `admin.machineLearning()` can be called with no arguments to access the + * default app's {@link admin.machineLearning.MachineLearning + * `MachineLearning`} service or as `admin.machineLearning(app)` to access + * the {@link admin.machineLearning.MachineLearning `MachineLearning`} + * service associated with a specific app. + * + * @example + * ```javascript + * // Get the MachineLearning service for the default app + * var defaultMachineLearning = admin.machineLearning(); + * ``` + * + * @example + * ```javascript + * // Get the MachineLearning service for a given app + * var otherMachineLearning = admin.machineLearning(otherApp); + * ``` + * + * @param app Optional app whose `MachineLearning` service to + * return. If not provided, the default `MachineLearning` service + * will be returned. + * + * @return The default `MachineLearning` service if no app is provided or the + * `MachineLearning` service associated with the provided app. + */ + function machineLearning(app?: admin.app.App): admin.machineLearning.MachineLearning; + function initializeApp(options?: admin.AppOptions, name?: string): admin.app.App; } @@ -462,6 +493,7 @@ declare namespace admin.app { database(url?: string): admin.database.Database; firestore(): admin.firestore.Firestore; instanceId(): admin.instanceId.InstanceId; + machineLearning(): admin.machineLearning.MachineLearning; messaging(): admin.messaging.Messaging; projectManagement(): admin.projectManagement.ProjectManagement; securityRules(): admin.securityRules.SecurityRules; @@ -788,6 +820,212 @@ declare namespace admin.securityRules { export import SecurityRules = _securityRules.admin.securityRules.SecurityRules; } +declare namespace admin.machineLearning { + /** + * Interface representing options for listing Models. + */ + interface ListModelsOptions { + /** + * An expression that specifies how to filter the results. + * + * Examples: + * + * ``` + * display_name = your_model + * display_name : experimental_* + * tags: face_detector AND tags: experimental + * state.published = true + * ``` + * + * See https://firebase.google.com/docs/ml-kit/manage-hosted-models#list_your_projects_models + */ + filter?: string; + + /** The number of results to return in each page. */ + pageSize?: number; + + /** A token that specifies the result page to return. */ + pageToken?: string; + } + + /** Response object for a listModels operation. */ + interface ListModelsResult { + /** A list of models in your project. */ + readonly models: Model[]; + + /** + * A token you can use to retrieve the next page of results. If null, the + * current page is the final page. + */ + readonly pageToken?: string; + } + + /** + * A TensorFlow Lite Model output object + */ + interface TFLiteModel { + /** The size of the model. */ + readonly sizeBytes: number; + + /** The URI from which the model was originally provided to Firebase. */ + readonly gcsTfliteUri?: string; + } + + /** + * A Firebase ML Model input object + */ + interface ModelOptions { + /** A name for the model. This is the name you use from your app to load the model. */ + displayName?: string; + + /** Tags for easier model management. */ + tags?: string[]; + + /** + * An object containing the URI of the model in Cloud Storage. + * + * Example: `tfliteModel: { gcsTfliteUri: 'gs://your-bucket/your-model.tflite' }` + */ + tfliteModel?: {gcsTfliteUri: string}; + } + + /** + * A Firebase ML Model output object + */ + interface Model { + /** The ID of the model. */ + readonly modelId: string; + + /** The model's name. This is the name you use from your app to load the model. */ + readonly displayName: string; + + /** The model's tags. */ + readonly tags?: string[]; + + /** The timestamp of the model's creation. */ + readonly createTime: string; + + /** The timestamp of the model's most recent update. */ + readonly updateTime: string; + + /** Error message when model validation fails. */ + readonly validationError?: string; + + /** True if the model is published. */ + readonly published: boolean; + + /** + * The ETag identifier of the current version of the model. This value + * changes whenever you update any of the model's properties. + */ + readonly etag: string; + + /** + * The hash of the model's `tflite` file. This value changes only when + * you upload a new TensorFlow Lite model. + */ + readonly modelHash?: string; + + /** + * True if the model is locked by a server-side operation. You can't make + * changes to a locked model. See {@link waitForUnlocked `waitForUnlocked()`}. + */ + readonly locked: boolean; + + /** + * Wait for the model to be unlocked. + * + * @param {number} maxTimeSeconds The maximum time in seconds to wait. + * + * @return {Promise} A promise that resolves when the model is unlocked + * or the maximum wait time has passed. + */ + waitForUnlocked(maxTimeSeconds?: number): Promise; + + /** Metadata about the model's TensorFlow Lite model file. */ + readonly tfliteModel?: TFLiteModel; + } + + /** + * The Firebase `MachineLearning` service interface. + * + * Do not call this constructor directly. Instead, use + * [`admin.machineLearning()`](admin.machineLearning#machineLearning). + */ + interface MachineLearning { + /** + * The {@link admin.app.App} associated with the current `MachineLearning` + * service instance. + */ + app: admin.app.App; + + /** + * Creates a model in Firebase ML. + * + * @param {ModelOptions} model The model to create. + * + * @return {Promise} A Promise fulfilled with the created model. + */ + createModel(model: ModelOptions): Promise; + + /** + * Updates a model in Firebase ML. + * + * @param {string} modelId The ID of the model to update. + * @param {ModelOptions} model The model fields to update. + * + * @return {Promise} A Promise fulfilled with the updated model. + */ + updateModel(modelId: string, model: ModelOptions): Promise; + + /** + * Publishes a model in Firebase ML. + * + * @param {string} modelId The ID of the model to publish. + * + * @return {Promise} A Promise fulfilled with the published model. + */ + publishModel(modelId: string): Promise; + + /** + * Unpublishes a model in Firebase ML. + * + * @param {string} modelId The ID of the model to unpublish. + * + * @return {Promise} A Promise fulfilled with the unpublished model. + */ + unpublishModel(modelId: string): Promise; + + /** + * Gets a model from Firebase ML. + * + * @param {string} modelId The ID of the model to get. + * + * @return {Promise} A Promise fulfilled with the model object. + */ + getModel(modelId: string): Promise; + + /** + * Lists models from Firebase ML. + * + * @param {ListModelsOptions} options The listing options. + * + * @return {Promise} A promise that + * resolves with the current (filtered) list of models and the next page + * token. For the last page, an empty list of models and no page token + * are returned. + */ + listModels(options?: ListModelsOptions): Promise; + + /** + * Deletes a model from Firebase ML. + * + * @param {string} modelId The ID of the model to delete. + */ + deleteModel(modelId: string): Promise; + } +} + declare module 'firebase-admin' { } diff --git a/src/machine-learning/machine-learning-api-client.ts b/src/machine-learning/machine-learning-api-client.ts new file mode 100644 index 0000000000..969ebe6ebe --- /dev/null +++ b/src/machine-learning/machine-learning-api-client.ts @@ -0,0 +1,311 @@ +/*! + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { HttpRequestConfig, HttpClient, HttpError, AuthorizedHttpClient } from '../utils/api-request'; +import { PrefixedFirebaseError } from '../utils/error'; +import { FirebaseMachineLearningError, MachineLearningErrorCode } from './machine-learning-utils'; +import * as utils from '../utils/index'; +import * as validator from '../utils/validator'; +import { FirebaseApp } from '../firebase-app'; + +const ML_V1BETA2_API = 'https://firebaseml.googleapis.com/v1beta2'; +const FIREBASE_VERSION_HEADER = { + 'X-Firebase-Client': 'fire-admin-node/', +}; + +export interface StatusErrorResponse { + readonly code: number; + readonly message: string; +} + +/** + * A Firebase ML Model input object + */ +export interface ModelOptions { + displayName?: string; + tags?: string[]; + + tfliteModel?: { gcsTfliteUri: string }; +} + +export interface ModelUpdateOptions extends ModelOptions { + state?: { published?: boolean }; +} + +/** Interface representing listModels options. */ +export interface ListModelsOptions { + filter?: string; + pageSize?: number; + pageToken?: string; +} + +export interface ModelContent { + readonly displayName?: string; + readonly tags?: string[]; + readonly state?: { + readonly validationError?: StatusErrorResponse; + readonly published?: boolean; + }; + readonly tfliteModel?: { + readonly gcsTfliteUri: string; + readonly sizeBytes: number; + }; +} + +export interface ModelResponse extends ModelContent { + readonly name: string; + readonly createTime: string; + readonly updateTime: string; + readonly etag: string; + readonly modelHash?: string; +} + +export interface ListModelsResponse { + readonly models?: ModelResponse[]; + readonly nextPageToken?: string; +} + +export interface OperationResponse { + readonly name?: string; + readonly done: boolean; + readonly error?: StatusErrorResponse; + readonly response?: ModelResponse; +} + + +/** + * Class that facilitates sending requests to the Firebase ML backend API. + * + * @private + */ +export class MachineLearningApiClient { + private readonly httpClient: HttpClient; + private projectIdPrefix?: string; + + constructor(private readonly app: FirebaseApp) { + if (!validator.isNonNullObject(app) || !('options' in app)) { + throw new FirebaseMachineLearningError( + 'invalid-argument', + 'First argument passed to admin.machineLearning() must be a valid ' + + 'Firebase app instance.'); + } + + this.httpClient = new AuthorizedHttpClient(app); + } + + public createModel(model: ModelOptions): Promise { + if (!validator.isNonNullObject(model) || + !validator.isNonEmptyString(model.displayName)) { + const err = new FirebaseMachineLearningError('invalid-argument', 'Invalid model content.'); + return Promise.reject(err); + } + return this.getUrl() + .then((url) => { + const request: HttpRequestConfig = { + method: 'POST', + url: `${url}/models`, + data: model, + }; + return this.sendRequest(request); + }); + } + + public updateModel(modelId: string, model: ModelUpdateOptions, updateMask: string[]): Promise { + if (!validator.isNonEmptyString(modelId) || + !validator.isNonNullObject(model) || + !validator.isNonEmptyArray(updateMask)) { + const err = new FirebaseMachineLearningError('invalid-argument', 'Invalid model or mask content.'); + return Promise.reject(err); + } + return this.getUrl() + .then((url) => { + const request: HttpRequestConfig = { + method: 'PATCH', + url: `${url}/models/${modelId}?updateMask=${updateMask.join()}`, + data: model, + }; + return this.sendRequest(request); + }); + } + + + public getModel(modelId: string): Promise { + return Promise.resolve() + .then(() => { + return this.getModelName(modelId); + }) + .then((modelName) => { + return this.getResource(modelName); + }); + } + + public listModels(options: ListModelsOptions = {}): Promise { + if (!validator.isNonNullObject(options)) { + const err = new FirebaseMachineLearningError('invalid-argument', 'Invalid ListModelsOptions'); + return Promise.reject(err); + } + if (typeof options.filter !== 'undefined' && !validator.isNonEmptyString(options.filter)) { + const err = new FirebaseMachineLearningError('invalid-argument', 'Invalid list filter.'); + return Promise.reject(err); + } + if (typeof options.pageSize !== 'undefined') { + if (!validator.isNumber(options.pageSize)) { + const err = new FirebaseMachineLearningError('invalid-argument', 'Invalid page size.'); + return Promise.reject(err); + } + if (options.pageSize < 1 || options.pageSize > 100) { + const err = new FirebaseMachineLearningError( + 'invalid-argument', 'Page size must be between 1 and 100.'); + return Promise.reject(err); + } + } + if (typeof options.pageToken !== 'undefined' && !validator.isNonEmptyString(options.pageToken)) { + const err = new FirebaseMachineLearningError( + 'invalid-argument', 'Next page token must be a non-empty string.'); + return Promise.reject(err); + } + return this.getUrl() + .then((url) => { + const request: HttpRequestConfig = { + method: 'GET', + url: `${url}/models`, + data: options, + }; + return this.sendRequest(request); + }); + } + + public deleteModel(modelId: string): Promise { + return this.getUrl() + .then((url) => { + const modelName = this.getModelName(modelId); + const request: HttpRequestConfig = { + method: 'DELETE', + url: `${url}/${modelName}`, + }; + return this.sendRequest(request); + }); + } + + /** + * Gets the specified resource from the ML API. Resource names must be the short names without project + * ID prefix (e.g. `models/123456789`). + * + * @param {string} name Full qualified name of the resource to get. + * @returns {Promise} A promise that fulfills with the resource. + */ + private getResource(name: string): Promise { + return this.getUrl() + .then((url) => { + const request: HttpRequestConfig = { + method: 'GET', + url: `${url}/${name}`, + }; + return this.sendRequest(request); + }); + } + + private sendRequest(request: HttpRequestConfig): Promise { + request.headers = FIREBASE_VERSION_HEADER; + return this.httpClient.send(request) + .then((resp) => { + return resp.data as T; + }) + .catch((err) => { + throw this.toFirebaseError(err); + }); + } + + private toFirebaseError(err: HttpError): PrefixedFirebaseError { + if (err instanceof PrefixedFirebaseError) { + return err; + } + + const response = err.response; + if (!response.isJson()) { + return new FirebaseMachineLearningError( + 'unknown-error', + `Unexpected response with status: ${response.status} and body: ${response.text}`); + } + + const error: Error = (response.data as ErrorResponse).error || {}; + let code: MachineLearningErrorCode = 'unknown-error'; + if (error.status && error.status in ERROR_CODE_MAPPING) { + code = ERROR_CODE_MAPPING[error.status]; + } + const message = error.message || `Unknown server error: ${response.text}`; + return new FirebaseMachineLearningError(code, message); + } + + private getUrl(): Promise { + return this.getProjectIdPrefix() + .then((projectIdPrefix) => { + return `${ML_V1BETA2_API}/${this.projectIdPrefix}`; + }); + } + + private getProjectIdPrefix(): Promise { + if (this.projectIdPrefix) { + return Promise.resolve(this.projectIdPrefix); + } + + return utils.findProjectId(this.app) + .then((projectId) => { + if (!validator.isNonEmptyString(projectId)) { + throw new FirebaseMachineLearningError( + 'invalid-argument', + 'Failed to determine project ID. Initialize the SDK with service account credentials, or ' + + 'set project ID as an app option. Alternatively, set the GOOGLE_CLOUD_PROJECT ' + + 'environment variable.'); + } + + this.projectIdPrefix = `projects/${projectId}`; + return this.projectIdPrefix; + }); + } + + private getModelName(modelId: string): string { + if (!validator.isNonEmptyString(modelId)) { + throw new FirebaseMachineLearningError( + 'invalid-argument', 'Model ID must be a non-empty string.'); + } + + if (modelId.indexOf('/') !== -1) { + throw new FirebaseMachineLearningError( + 'invalid-argument', 'Model ID must not contain any "/" characters.'); + } + + return `models/${modelId}`; + } +} + +interface ErrorResponse { + error?: Error; +} + +interface Error { + code?: number; + message?: string; + status?: string; +} + +const ERROR_CODE_MAPPING: {[key: string]: MachineLearningErrorCode} = { + INVALID_ARGUMENT: 'invalid-argument', + NOT_FOUND: 'not-found', + RESOURCE_EXHAUSTED: 'resource-exhausted', + UNAUTHENTICATED: 'authentication-error', + UNKNOWN: 'unknown-error', +}; diff --git a/src/machine-learning/machine-learning-utils.ts b/src/machine-learning/machine-learning-utils.ts new file mode 100644 index 0000000000..1202314e93 --- /dev/null +++ b/src/machine-learning/machine-learning-utils.ts @@ -0,0 +1,64 @@ +/*! + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { PrefixedFirebaseError } from '../utils/error'; + +export type MachineLearningErrorCode = + 'already-exists' + | 'authentication-error' + | 'internal-error' + | 'invalid-argument' + | 'invalid-server-response' + | 'not-found' + | 'resource-exhausted' + | 'service-unavailable' + | 'unknown-error' + | 'cancelled' + | 'deadline-exceeded' + | 'permission-denied' + | 'failed-precondition' + | 'aborted' + | 'out-of-range' + | 'data-loss' + | 'unauthenticated'; + +export class FirebaseMachineLearningError extends PrefixedFirebaseError { + public static fromOperationError(code: number, message: string): FirebaseMachineLearningError { + switch (code) { + case 1: return new FirebaseMachineLearningError('cancelled', message); + case 2: return new FirebaseMachineLearningError('unknown-error', message); + case 3: return new FirebaseMachineLearningError('invalid-argument', message); + case 4: return new FirebaseMachineLearningError('deadline-exceeded', message); + case 5: return new FirebaseMachineLearningError('not-found', message); + case 6: return new FirebaseMachineLearningError('already-exists', message); + case 7: return new FirebaseMachineLearningError('permission-denied', message); + case 8: return new FirebaseMachineLearningError('resource-exhausted', message); + case 9: return new FirebaseMachineLearningError('failed-precondition', message); + case 10: return new FirebaseMachineLearningError('aborted', message); + case 11: return new FirebaseMachineLearningError('out-of-range', message); + case 13: return new FirebaseMachineLearningError('internal-error', message); + case 14: return new FirebaseMachineLearningError('service-unavailable', message); + case 15: return new FirebaseMachineLearningError('data-loss', message); + case 16: return new FirebaseMachineLearningError('unauthenticated', message); + default: + return new FirebaseMachineLearningError('unknown-error', message); + } + } + + constructor(code: MachineLearningErrorCode, message: string) { + super('machine-learning', code, message); + } +} diff --git a/src/machine-learning/machine-learning.ts b/src/machine-learning/machine-learning.ts new file mode 100644 index 0000000000..e521c5d6af --- /dev/null +++ b/src/machine-learning/machine-learning.ts @@ -0,0 +1,322 @@ +/*! + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {FirebaseApp} from '../firebase-app'; +import {FirebaseServiceInterface, FirebaseServiceInternalsInterface} from '../firebase-service'; +import {MachineLearningApiClient, ModelResponse, OperationResponse, + ModelOptions, ModelUpdateOptions, ListModelsOptions} from './machine-learning-api-client'; +import {FirebaseError} from '../utils/error'; + +import * as validator from '../utils/validator'; +import {FirebaseMachineLearningError} from './machine-learning-utils'; +import { deepCopy } from '../utils/deep-copy'; +import * as utils from '../utils'; + +/** + * Internals of an ML instance. + */ +class MachineLearningInternals implements FirebaseServiceInternalsInterface { + /** + * Deletes the service and its associated resources. + * + * @return {Promise} An empty Promise that will be resolved when the + * service is deleted. + */ + public delete(): Promise { + // There are no resources to clean up. + return Promise.resolve(); + } +} + +/** Response object for a listModels operation. */ +export interface ListModelsResult { + models: Model[]; + pageToken?: string; +} + +/** + * The Firebase Machine Learning class + */ +export class MachineLearning implements FirebaseServiceInterface { + public readonly INTERNAL = new MachineLearningInternals(); + + private readonly client: MachineLearningApiClient; + private readonly appInternal: FirebaseApp; + + /** + * @param {FirebaseApp} app The app for this ML service. + * @constructor + */ + constructor(app: FirebaseApp) { + if (!validator.isNonNullObject(app) || !('options' in app)) { + throw new FirebaseError({ + code: 'machine-learning/invalid-argument', + message: 'First argument passed to admin.machineLearning() must be a ' + + 'valid Firebase app instance.', + }); + } + + this.appInternal = app; + this.client = new MachineLearningApiClient(app); + } + + /** + * Returns the app associated with this ML instance. + * + * @return {FirebaseApp} The app associated with this ML instance. + */ + public get app(): FirebaseApp { + return this.appInternal; + } + + /** + * Creates a model in Firebase ML. + * + * @param {ModelOptions} model The model to create. + * + * @return {Promise} A Promise fulfilled with the created model. + */ + public createModel(model: ModelOptions): Promise { + return this.signUrlIfPresent(model) + .then((modelContent) => this.client.createModel(modelContent)) + .then((operation) => handleOperation(operation)); + } + + /** + * Updates a model in Firebase ML. + * + * @param {string} modelId The id of the model to update. + * @param {ModelOptions} model The model fields to update. + * + * @return {Promise} A Promise fulfilled with the updated model. + */ + public updateModel(modelId: string, model: ModelOptions): Promise { + const updateMask = utils.generateUpdateMask(model); + return this.signUrlIfPresent(model) + .then((modelContent) => this.client.updateModel(modelId, modelContent, updateMask)) + .then((operation) => handleOperation(operation)); + } + + /** + * Publishes a model in Firebase ML. + * + * @param {string} modelId The id of the model to publish. + * + * @return {Promise} A Promise fulfilled with the published model. + */ + public publishModel(modelId: string): Promise { + return this.setPublishStatus(modelId, true); + } + + /** + * Unpublishes a model in Firebase ML. + * + * @param {string} modelId The id of the model to unpublish. + * + * @return {Promise} A Promise fulfilled with the unpublished model. + */ + public unpublishModel(modelId: string): Promise { + return this.setPublishStatus(modelId, false); + } + + /** + * Gets a model from Firebase ML. + * + * @param {string} modelId The id of the model to get. + * + * @return {Promise} A Promise fulfilled with the unpublished model. + */ + public getModel(modelId: string): Promise { + return this.client.getModel(modelId) + .then((modelResponse) => new Model(modelResponse)); + } + + /** + * Lists models from Firebase ML. + * + * @param {ListModelsOptions} options The listing options. + * + * @return {Promise<{models: Model[], pageToken?: string}>} A promise that + * resolves with the current (filtered) list of models and the next page + * token. For the last page, an empty list of models and no page token are + * returned. + */ + public listModels(options: ListModelsOptions = {}): Promise { + return this.client.listModels(options) + .then((resp) => { + if (!validator.isNonNullObject(resp)) { + throw new FirebaseMachineLearningError( + 'invalid-argument', + `Invalid ListModels response: ${JSON.stringify(resp)}`); + } + let models: Model[] = []; + if (resp.models) { + models = resp.models.map((rs) => new Model(rs)); + } + const result: ListModelsResult = {models}; + if (resp.nextPageToken) { + result.pageToken = resp.nextPageToken; + } + return result; + }); + } + + /** + * Deletes a model from Firebase ML. + * + * @param {string} modelId The id of the model to delete. + */ + public deleteModel(modelId: string): Promise { + return this.client.deleteModel(modelId); + } + + private setPublishStatus(modelId: string, publish: boolean): Promise { + const updateMask = ['state.published']; + const options: ModelUpdateOptions = {state: {published: publish}}; + return this.client.updateModel(modelId, options, updateMask) + .then((operation) => handleOperation(operation)); + } + + private signUrlIfPresent(options: ModelOptions): Promise { + const modelOptions = deepCopy(options); + if (modelOptions.tfliteModel?.gcsTfliteUri) { + return this.signUrl(modelOptions.tfliteModel.gcsTfliteUri) + .then ((uri: string) => { + modelOptions.tfliteModel!.gcsTfliteUri = uri; + return modelOptions; + }) + .catch((err: Error) => { + throw new FirebaseMachineLearningError( + 'internal-error', + `Error during signing upload url: ${err.message}`); + }); + } + return Promise.resolve(modelOptions); + } + + private signUrl(unsignedUrl: string): Promise { + const MINUTES_IN_MILLIS = 60 * 1000; + const URL_VALID_DURATION = 10 * MINUTES_IN_MILLIS; + + const gcsRegex = /^gs:\/\/([a-z0-9_.-]{3,63})\/(.+)$/; + const matches = gcsRegex.exec(unsignedUrl); + if (!matches) { + throw new FirebaseMachineLearningError( + 'invalid-argument', + `Invalid unsigned url: ${unsignedUrl}`); + } + const bucketName = matches[1]; + const blobName = matches[2]; + const bucket = this.appInternal.storage().bucket(bucketName); + const blob = bucket.file(blobName); + return blob.getSignedUrl({ + action: 'read', + expires: Date.now() + URL_VALID_DURATION, + }).then((signUrl) => signUrl[0]); + } +} + + +/** + * A Firebase ML Model output object. + */ +export class Model { + public readonly modelId: string; + public readonly displayName: string; + public readonly tags?: string[]; + public readonly createTime: string; + public readonly updateTime: string; + public readonly validationError?: string; + public readonly published: boolean; + public readonly etag: string; + public readonly modelHash?: string; + + public readonly tfliteModel?: TFLiteModel; + + constructor(model: ModelResponse) { + if (!validator.isNonNullObject(model) || + !validator.isNonEmptyString(model.name) || + !validator.isNonEmptyString(model.createTime) || + !validator.isNonEmptyString(model.updateTime) || + !validator.isNonEmptyString(model.displayName) || + !validator.isNonEmptyString(model.etag)) { + throw new FirebaseMachineLearningError( + 'invalid-server-response', + `Invalid Model response: ${JSON.stringify(model)}`); + } + + this.modelId = extractModelId(model.name); + this.displayName = model.displayName; + this.tags = model.tags || []; + this.createTime = new Date(model.createTime).toUTCString(); + this.updateTime = new Date(model.updateTime).toUTCString(); + if (model.state?.validationError?.message) { + this.validationError = model.state?.validationError?.message; + } + this.published = model.state?.published || false; + this.etag = model.etag; + if (model.modelHash) { + this.modelHash = model.modelHash; + } + if (model.tfliteModel) { + this.tfliteModel = { + gcsTfliteUri: model.tfliteModel.gcsTfliteUri, + sizeBytes: model.tfliteModel.sizeBytes, + }; + } + } + + public get locked(): boolean { + // Backend does not currently return locked models. + // This will likely change in future. + return false; + } + + public waitForUnlocked(maxTimeSeconds?: number): Promise { + // Backend does not currently return locked models. + // This will likely change in future. + return Promise.resolve(); + } +} + +/** + * A TFLite Model output object + */ +export interface TFLiteModel { + readonly sizeBytes: number; + + readonly gcsTfliteUri: string; +} + +function extractModelId(resourceName: string): string { + return resourceName.split('/').pop()!; +} + +function handleOperation(op: OperationResponse): Model { + // Backend currently does not return operations that are not done. + if (op.done) { + // Done operations must have either a response or an error. + if (op.response) { + return new Model(op.response); + } else if (op.error) { + throw FirebaseMachineLearningError.fromOperationError( + op.error.code, op.error.message); + } + } + throw new FirebaseMachineLearningError( + 'invalid-server-response', + `Invalid Operation response: ${JSON.stringify(op)}`); +} diff --git a/test/integration/machine-learning.spec.ts b/test/integration/machine-learning.spec.ts new file mode 100644 index 0000000000..9a358bf0e3 --- /dev/null +++ b/test/integration/machine-learning.spec.ts @@ -0,0 +1,499 @@ +/*! + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +import path = require('path'); +import * as chai from 'chai'; +import * as admin from '../../lib/index'; +import {Bucket} from '@google-cloud/storage'; + +const expect = chai.expect; + +describe('admin.machineLearning', () => { + + const modelsToDelete: string[] = []; + + function scheduleForDelete(model: admin.machineLearning.Model): void { + modelsToDelete.push(model.modelId); + } + + function unscheduleForDelete(model: admin.machineLearning.Model): void { + modelsToDelete.splice(modelsToDelete.indexOf(model.modelId), 1); + } + + function deleteTempModels(): Promise { + const promises: Array> = []; + modelsToDelete.forEach((modelId) => { + promises.push(admin.machineLearning().deleteModel(modelId)); + }); + modelsToDelete.splice(0, modelsToDelete.length); // Clear out the array. + return Promise.all(promises); + } + + function createTemporaryModel(options?: admin.machineLearning.ModelOptions): + Promise { + let modelOptions: admin.machineLearning.ModelOptions = { + displayName: 'nodejs_integration_temp_model', + }; + if (options) { + modelOptions = options; + } + return admin.machineLearning().createModel(modelOptions) + .then((model) => { + scheduleForDelete(model); + return model; + }); + } + + function uploadModelToGcs(localFileName: string, gcsFileName: string): Promise { + const bucket: Bucket = admin.storage().bucket(); + const tfliteFileName = path.join(__dirname, `../resources/${localFileName}`); + return bucket.upload(tfliteFileName, {destination: gcsFileName}) + .then(() => { + return `gs://${bucket.name}/${gcsFileName}`; + }); + } + + afterEach(() => { + return deleteTempModels(); + }); + + describe('createModel()', () => { + it('creates a new Model without ModelFormat', () => { + const modelOptions: admin.machineLearning.ModelOptions = { + displayName: 'node-integration-test-create-1', + tags: ['tag123', 'tag345']}; + return admin.machineLearning().createModel(modelOptions) + .then((model) => { + scheduleForDelete(model); + verifyModel(model, modelOptions); + }); + }); + + it('creates a new Model with valid ModelFormat', () => { + const modelOptions: admin.machineLearning.ModelOptions = { + displayName: 'node-integration-test-create-2', + tags: ['tag234', 'tag456'], + tfliteModel: {gcsTfliteUri: 'this will be replaced below'}, + }; + return uploadModelToGcs('model1.tflite', 'valid_model.tflite') + .then((fileName: string) => { + modelOptions.tfliteModel!.gcsTfliteUri = fileName; + return admin.machineLearning().createModel(modelOptions) + .then((model) => { + scheduleForDelete(model); + verifyModel(model, modelOptions); + }); + }); + }); + + it('creates a new Model with invalid ModelFormat', () => { + // Upload a file to default gcs bucket + const modelOptions: admin.machineLearning.ModelOptions = { + displayName: 'node-integration-test-create-3', + tags: ['tag234', 'tag456'], + tfliteModel: {gcsTfliteUri: 'this will be replaced below'}, + }; + return uploadModelToGcs('invalid_model.tflite', 'invalid_model.tflite') + .then((fileName: string) => { + modelOptions.tfliteModel!.gcsTfliteUri = fileName; + return admin.machineLearning().createModel(modelOptions) + .then((model) => { + scheduleForDelete(model); + verifyModel(model, modelOptions); + }); + }); + }); + + it ('rejects with invalid-argument when modelOptions are invalid', () => { + const modelOptions: admin.machineLearning.ModelOptions = { + displayName: 'Invalid Name#*^!', + }; + return admin.machineLearning().createModel(modelOptions) + .should.eventually.be.rejected.and.have.property('code', 'machine-learning/invalid-argument'); + }); + }); + + describe('updateModel()', () => { + + const UPDATE_NAME: admin.machineLearning.ModelOptions = { + displayName: 'update-model-new-name', + }; + + it('rejects with not-found when the Model does not exist', () => { + const nonExistingId = '00000000'; + return admin.machineLearning().updateModel(nonExistingId, UPDATE_NAME) + .should.eventually.be.rejected.and.have.property( + 'code', 'machine-learning/not-found'); + }); + + it('rejects with invalid-argument when the ModelId is invalid', () => { + return admin.machineLearning().updateModel('invalid-model-id', UPDATE_NAME) + .should.eventually.be.rejected.and.have.property( + 'code', 'machine-learning/invalid-argument'); + }); + + it ('rejects with invalid-argument when modelOptions are invalid', () => { + const modelOptions: admin.machineLearning.ModelOptions = { + displayName: 'Invalid Name#*^!', + }; + return createTemporaryModel({displayName: 'node-integration-invalid-argument'}) + .then((model) => admin.machineLearning().updateModel(model.modelId, modelOptions) + .should.eventually.be.rejected.and.have.property( + 'code', 'machine-learning/invalid-argument')); + }); + + it('updates the displayName', () => { + const DISPLAY_NAME = 'node-integration-test-update-1b'; + return createTemporaryModel({displayName: 'node-integration-test-update-1a'}) + .then((model) => { + const modelOptions: admin.machineLearning.ModelOptions = { + displayName: DISPLAY_NAME, + }; + return admin.machineLearning().updateModel(model.modelId, modelOptions) + .then((updatedModel) => { + verifyModel(updatedModel, modelOptions); + }); + }); + }); + + it('sets tags for a model', () => { + const ORIGINAL_TAGS = ['tag-node-update-1']; + const NEW_TAGS = ['tag-node-update-2', 'tag-node-update-3']; + + return createTemporaryModel({ + displayName: 'node-integration-test-update-2', + tags: ORIGINAL_TAGS, + }).then((expectedModel) => { + const modelOptions: admin.machineLearning.ModelOptions = { + tags: NEW_TAGS, + }; + return admin.machineLearning().updateModel(expectedModel.modelId, modelOptions) + .then((actualModel) => { + expect(actualModel.tags!.length).to.equal(2); + expect(actualModel.tags).to.have.same.members(NEW_TAGS); + }); + }); + }); + + it('updates the tflite file', () => { + return Promise.all([ + createTemporaryModel(), + uploadModelToGcs('model1.tflite', 'valid_model.tflite')]) + .then(([model, fileName]) => { + const modelOptions: admin.machineLearning.ModelOptions = { + tfliteModel: {gcsTfliteUri: fileName}, + }; + return admin.machineLearning().updateModel(model.modelId, modelOptions) + .then((updatedModel) => { + verifyModel(updatedModel, modelOptions); + }); + }); + }); + + it('can update more than 1 field', () => { + const DISPLAY_NAME = 'node-integration-test-update-3b'; + const TAGS = ['node-integration-tag-1', 'node-integration-tag-2']; + return createTemporaryModel({displayName: 'node-integration-test-update-3a'}) + .then((model) => { + const modelOptions: admin.machineLearning.ModelOptions = { + displayName: DISPLAY_NAME, + tags: TAGS, + }; + return admin.machineLearning().updateModel(model.modelId, modelOptions) + .then((updatedModel) => { + expect(updatedModel.displayName).to.equal(DISPLAY_NAME); + expect(updatedModel.tags).to.have.same.members(TAGS); + }); + }); + }); + }); + + describe('publishModel()', () => { + it('should reject when model does not exist', () => { + const nonExistingName = '00000000'; + return admin.machineLearning().publishModel(nonExistingName) + .should.eventually.be.rejected.and.have.property( + 'code', 'machine-learning/not-found'); + }); + + it('rejects with invalid-argument when the ModelId is invalid', () => { + return admin.machineLearning().publishModel('invalid-model-id') + .should.eventually.be.rejected.and.have.property( + 'code', 'machine-learning/invalid-argument'); + }); + + it('publishes the model successfully', () => { + const modelOptions: admin.machineLearning.ModelOptions = { + displayName: 'node-integration-test-publish-1', + tfliteModel: {gcsTfliteUri: 'this will be replaced below'}, + }; + return uploadModelToGcs('model1.tflite', 'valid_model.tflite') + .then((fileName: string) => { + modelOptions.tfliteModel!.gcsTfliteUri = fileName; + createTemporaryModel(modelOptions) + .then((createdModel) => { + expect(createdModel.validationError).to.be.empty; + expect(createdModel.published).to.be.false; + admin.machineLearning().publishModel(createdModel.modelId) + .then((publishedModel) => { + expect(publishedModel.published).to.be.true; + }); + }); + }); + }); + }); + + describe('unpublishModel()', () => { + it('should reject when model does not exist', () => { + const nonExistingName = '00000000'; + return admin.machineLearning().unpublishModel(nonExistingName) + .should.eventually.be.rejected.and.have.property( + 'code', 'machine-learning/not-found'); + }); + + it('rejects with invalid-argument when the ModelId is invalid', () => { + return admin.machineLearning().unpublishModel('invalid-model-id') + .should.eventually.be.rejected.and.have.property( + 'code', 'machine-learning/invalid-argument'); + }); + + it('unpublishes the model successfully', () => { + const modelOptions: admin.machineLearning.ModelOptions = { + displayName: 'node-integration-test-unpublish-1', + tfliteModel: {gcsTfliteUri: 'this will be replaced below'}, + }; + return uploadModelToGcs('model1.tflite', 'valid_model.tflite') + .then((fileName: string) => { + modelOptions.tfliteModel!.gcsTfliteUri = fileName; + createTemporaryModel(modelOptions) + .then((createdModel) => { + expect(createdModel.validationError).to.be.empty; + expect(createdModel.published).to.be.false; + admin.machineLearning().publishModel(createdModel.modelId) + .then((publishedModel) => { + expect(publishedModel.published).to.be.true; + admin.machineLearning().unpublishModel(publishedModel.modelId) + .then((unpublishedModel) => { + expect(unpublishedModel.published).to.be.false; + }); + }); + }); + }); + }); + }); + + + describe('getModel()', () => { + it('rejects with not-found when the Model does not exist', () => { + const nonExistingName = '00000000'; + return admin.machineLearning().getModel(nonExistingName) + .should.eventually.be.rejected.and.have.property( + 'code', 'machine-learning/not-found'); + }); + + it('rejects with invalid-argument when the ModelId is invalid', () => { + return admin.machineLearning().getModel('invalid-model-id') + .should.eventually.be.rejected.and.have.property( + 'code', 'machine-learning/invalid-argument'); + }); + + it('resolves with existing Model', () => { + return createTemporaryModel() + .then((expectedModel) => + admin.machineLearning().getModel(expectedModel.modelId) + .then((actualModel) => { + expect(actualModel).to.deep.equal(expectedModel); + }), + ); + }); + }); + + describe('listModels()', () => { + let model1: admin.machineLearning.Model; + let model2: admin.machineLearning.Model; + let model3: admin.machineLearning.Model; + + before(() => { + return Promise.all([ + admin.machineLearning().createModel({ + displayName: 'node-integration-list1', + tags: ['node-integration-tag-1'], + }), + admin.machineLearning().createModel({ + displayName: 'node-integration-list2', + tags: ['node-integration-tag-1'], + }), + admin.machineLearning().createModel({ + displayName: 'node-integration-list3', + tags: ['node-integration-tag-1'], + })]) + .then(([m1, m2, m3]: admin.machineLearning.Model[]) => { + model1 = m1; + model2 = m2; + model3 = m3; + }); + }); + + after(() => { + return Promise.all([ + admin.machineLearning().deleteModel(model1.modelId), + admin.machineLearning().deleteModel(model2.modelId), + admin.machineLearning().deleteModel(model3.modelId), + ]); + }); + + it('resolves with a list of models', () => { + return admin.machineLearning().listModels({pageSize: 100}) + .then((modelList) => { + expect(modelList.models.length).to.be.at.least(2); + expect(modelList.models).to.deep.include(model1); + expect(modelList.models).to.deep.include(model2); + expect(modelList.pageToken).to.be.empty; + }); + }); + + it('respects page size', () => { + return admin.machineLearning().listModels({pageSize: 2}) + .then((modelList) => { + expect(modelList.models.length).to.equal(2); + expect(modelList.pageToken).not.to.be.empty; + }); + }); + + it('filters by exact displayName', () => { + return admin.machineLearning().listModels({filter: 'displayName=node-integration-list1'}) + .then((modelList) => { + expect(modelList.models.length).to.equal(1); + expect(modelList.models[0]).to.deep.equal(model1); + expect(modelList.pageToken).to.be.empty; + }); + }); + + it('filters by displayName prefix', () => { + return admin.machineLearning().listModels({filter: 'displayName:node-integration-list*', pageSize: 100}) + .then((modelList) => { + expect(modelList.models.length).to.be.at.least(3); + expect(modelList.models).to.deep.include(model1); + expect(modelList.models).to.deep.include(model2); + expect(modelList.models).to.deep.include(model3); + expect(modelList.pageToken).to.be.empty; + }); + }); + + it('filters by tag', () => { + return admin.machineLearning().listModels({filter: 'tags:node-integration-tag-1', pageSize: 100}) + .then((modelList) => { + expect(modelList.models.length).to.be.at.least(3); + expect(modelList.models).to.deep.include(model1); + expect(modelList.models).to.deep.include(model2); + expect(modelList.models).to.deep.include(model3); + expect(modelList.pageToken).to.be.empty; + }); + }); + + it('handles pageTokens properly', () => { + return admin.machineLearning().listModels({filter: 'displayName:node-integration-list*', pageSize: 2}) + .then((modelList) => { + expect(modelList.models.length).to.equal(2); + expect(modelList.pageToken).not.to.be.empty; + return admin.machineLearning().listModels({ + filter: 'displayName:node-integration-list*', + pageSize: 2, + pageToken: modelList.pageToken}) + .then((modelList2) => { + expect(modelList2.models.length).to.be.at.least(1); + expect(modelList2.pageToken).to.be.empty; + }); + }); + }); + + it('successfully returns an empty list of models', () => { + return admin.machineLearning().listModels({filter: 'displayName=non-existing-model'}) + .then((modelList) => { + expect(modelList.models.length).to.equal(0); + expect(modelList.pageToken).to.be.empty; + }); + }); + + it('rejects with invalid argument if the filter is invalid', () => { + return admin.machineLearning().listModels({filter: 'invalidFilterItem=foo'}) + .should.eventually.be.rejected.and.have.property( + 'code', 'machine-learning/invalid-argument'); + }); + }); + + describe('deleteModel()', () => { + it('rejects with not-found when the Model does not exist', () => { + const nonExistingName = '00000000'; + return admin.machineLearning().deleteModel(nonExistingName) + .should.eventually.be.rejected.and.have.property( + 'code', 'machine-learning/not-found'); + }); + + it('rejects with invalid-argument when the Model ID is invalid', () => { + return admin.machineLearning().deleteModel('invalid-model-id') + .should.eventually.be.rejected.and.have.property( + 'code', 'machine-learning/invalid-argument'); + }); + + it('deletes existing Model', () => { + return createTemporaryModel().then((model) => { + return admin.machineLearning().deleteModel(model.modelId) + .then(() => { + return admin.machineLearning().getModel(model.modelId) + .should.eventually.be.rejected.and.have.property('code', 'machine-learning/not-found'); + }) + .then(() => { + unscheduleForDelete(model); // Already deleted. + }); + }); + }); + }); + + function verifyModel(model: admin.machineLearning.Model, expectedOptions: admin.machineLearning.ModelOptions): void { + if (expectedOptions.displayName) { + expect(model.displayName).to.equal(expectedOptions.displayName); + } else { + expect(model.displayName).not.to.be.empty; + } + expect(model.createTime).to.not.be.empty; + expect(model.updateTime).to.not.be.empty; + expect(model.etag).to.not.be.empty; + if (expectedOptions.tags) { + expect(model.tags).to.deep.equal(expectedOptions.tags); + } else { + expect(model.tags).to.be.empty; + } + if (expectedOptions.tfliteModel) { + verifyTfliteModel(model, expectedOptions.tfliteModel.gcsTfliteUri); + } else { + expect(model.validationError).to.equal('No model file has been uploaded.'); + } + expect(model.locked).to.be.false; + } +}); + +function verifyTfliteModel(model: admin.machineLearning.Model, expectedGcsTfliteUri: string): void { + expect(model.tfliteModel!.gcsTfliteUri).to.equal(expectedGcsTfliteUri); + if (expectedGcsTfliteUri.endsWith('invalid_model.tflite')) { + expect(model.modelHash).to.be.empty; + expect(model.validationError).to.equal('Invalid flatbuffer format'); + } else { + expect(model.modelHash).to.not.be.empty; + expect(model.validationError).to.be.empty; + } +} diff --git a/test/resources/invalid_model.tflite b/test/resources/invalid_model.tflite new file mode 100644 index 0000000000..d8482f4362 --- /dev/null +++ b/test/resources/invalid_model.tflite @@ -0,0 +1 @@ +This is not a tflite file. diff --git a/test/resources/model1.tflite b/test/resources/model1.tflite new file mode 100644 index 0000000000..c4b71b7a22 Binary files /dev/null and b/test/resources/model1.tflite differ diff --git a/test/unit/firebase-app.spec.ts b/test/unit/firebase-app.spec.ts index d28cdd166d..84155facde 100644 --- a/test/unit/firebase-app.spec.ts +++ b/test/unit/firebase-app.spec.ts @@ -32,6 +32,7 @@ import {FirebaseNamespace, FirebaseNamespaceInternals, FIREBASE_CONFIG_VAR} from import {Auth} from '../../src/auth/auth'; import {Messaging} from '../../src/messaging/messaging'; +import {MachineLearning} from '../../src/machine-learning/machine-learning'; import {Storage} from '../../src/storage/storage'; import {Firestore} from '@google-cloud/firestore'; import {Database} from '@firebase/database'; @@ -395,6 +396,32 @@ describe('FirebaseApp', () => { }); }); + describe('machineLearning()', () => { + it('should throw if the app has already been deleted', () => { + const app = firebaseNamespace.initializeApp(mocks.appOptions, mocks.appName); + + return app.delete().then(() => { + expect(() => { + return app.machineLearning(); + }).to.throw(`Firebase app named "${mocks.appName}" has already been deleted.`); + }); + }); + + it('should return the machineLearning client', () => { + const app = firebaseNamespace.initializeApp(mocks.appOptions, mocks.appName); + + const machineLearning: MachineLearning = app.machineLearning(); + expect(machineLearning).to.not.be.null; + }); + + it('should return a cached version of MachineLearning on subsequent calls', () => { + const app = firebaseNamespace.initializeApp(mocks.appOptions, mocks.appName); + const service1: MachineLearning = app.machineLearning(); + const service2: MachineLearning = app.machineLearning(); + expect(service1).to.equal(service2); + }); + }); + describe('database()', () => { afterEach(() => { try { diff --git a/test/unit/firebase-namespace.spec.ts b/test/unit/firebase-namespace.spec.ts index 7908f6d306..37ce7deb06 100644 --- a/test/unit/firebase-namespace.spec.ts +++ b/test/unit/firebase-namespace.spec.ts @@ -37,6 +37,7 @@ import { ServerValue, } from '@firebase/database'; import {Messaging} from '../../src/messaging/messaging'; +import {MachineLearning} from '../../src/machine-learning/machine-learning'; import {Storage} from '../../src/storage/storage'; import { Firestore, @@ -473,6 +474,38 @@ describe('FirebaseNamespace', () => { }); }); + describe('#machine-learning()', () => { + it('should throw when called before initializating an app', () => { + expect(() => { + firebaseNamespace.machineLearning(); + }).to.throw(DEFAULT_APP_NOT_FOUND); + }); + + it('should throw when default app is not initialized', () => { + firebaseNamespace.initializeApp(mocks.appOptions, 'testApp'); + expect(() => { + firebaseNamespace.machineLearning(); + }).to.throw(DEFAULT_APP_NOT_FOUND); + }); + + it('should return a valid namespace when the default app is initialized', () => { + const app: FirebaseApp = firebaseNamespace.initializeApp(mocks.appOptions); + const ml: MachineLearning = firebaseNamespace.machineLearning(); + expect(ml.app).to.be.deep.equal(app); + }); + + it('should return a valid namespace when the named app is initialized', () => { + const app: FirebaseApp = firebaseNamespace.initializeApp(mocks.appOptions, 'testApp'); + const ml: MachineLearning = firebaseNamespace.machineLearning(app); + expect(ml.app).to.be.deep.equal(app); + }); + + it('should return a reference to Machine Learning type', () => { + expect(firebaseNamespace.machineLearning.MachineLearning) + .to.be.deep.equal(MachineLearning); + }); + }); + describe('#storage()', () => { it('should throw when called before initializing an app', () => { expect(() => { diff --git a/test/unit/index.spec.ts b/test/unit/index.spec.ts index 6bd00c36d4..f55a8e9e73 100755 --- a/test/unit/index.spec.ts +++ b/test/unit/index.spec.ts @@ -45,6 +45,10 @@ import './database/database.spec'; import './messaging/messaging.spec'; import './messaging/batch-requests.spec'; +// Machine Learning +import './machine-learning/machine-learning.spec'; +import './machine-learning/machine-learning-api-client.spec'; + // Storage import './storage/storage.spec'; diff --git a/test/unit/machine-learning/machine-learning-api-client.spec.ts b/test/unit/machine-learning/machine-learning-api-client.spec.ts new file mode 100644 index 0000000000..b308164a8b --- /dev/null +++ b/test/unit/machine-learning/machine-learning-api-client.spec.ts @@ -0,0 +1,599 @@ +/*! + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +import * as _ from 'lodash'; +import * as chai from 'chai'; +import * as sinon from 'sinon'; +import { MachineLearningApiClient, ModelContent, + ListModelsOptions } from '../../../src/machine-learning/machine-learning-api-client'; +import { FirebaseMachineLearningError } from '../../../src/machine-learning/machine-learning-utils'; +import { HttpClient } from '../../../src/utils/api-request'; +import * as utils from '../utils'; +import * as mocks from '../../resources/mocks'; +import { FirebaseAppError } from '../../../src/utils/error'; +import { FirebaseApp } from '../../../src/firebase-app'; + +const expect = chai.expect; + +describe('MachineLearningApiClient', () => { + + const BASE_URL = 'https://firebaseml.googleapis.com/v1beta2'; + + const MODEL_ID = '1234567'; + const MODEL_RESPONSE = { + name: 'projects/test-project/models/1234567', + createTime: '2020-02-07T23:45:23.288047Z', + updateTime: '2020-02-08T23:45:23.288047Z', + etag: 'etag123', + modelHash: 'modelHash123', + displayName: 'model_1', + tags: ['tag_1', 'tag_2'], + state: {published: true}, + tfliteModel: { + gcsTfliteUri: 'gs://test-project-bucket/Firebase/ML/Models/model1.tflite', + sizeBytes: 16900988, + }, + }; + const MODEL_RESPONSE2 = { + name: 'projects/test-project/models/2345678', + createTime: '2020-02-07T23:45:22.288047Z', + updateTime: '2020-02-08T23:45:22.288047Z', + etag: 'etag234', + modelHash: 'modelHash234', + displayName: 'model_2', + tags: ['tag_2', 'tag_3'], + state: {published: true}, + tfliteModel: { + gcsTfliteUri: 'gs://test-project-bucket/Firebase/ML/Models/model2.tflite', + sizeBytes: 2220022, + }, + }; + + const STATUS_ERROR_RESPONSE = { + code: 3, + message: 'Invalid Argument message', + }; + const OPERATION_SUCCESS_RESPONSE = { + done: true, + response: MODEL_RESPONSE, + }; + const OPERATION_ERROR_RESPONSE = { + done: true, + error: STATUS_ERROR_RESPONSE, + }; + + const ERROR_RESPONSE = { + error: { + code: 404, + message: 'Requested entity not found', + status: 'NOT_FOUND', + }, + }; + const EXPECTED_HEADERS = { + 'Authorization': 'Bearer mock-token', + 'X-Firebase-Client': 'fire-admin-node/', + }; + const noProjectId = 'Failed to determine project ID. Initialize the SDK with service ' + + 'account credentials, or set project ID as an app option. Alternatively, set the ' + + 'GOOGLE_CLOUD_PROJECT environment variable.'; + + const mockOptions = { + credential: new mocks.MockCredential(), + projectId: 'test-project', + }; + + const clientWithoutProjectId = new MachineLearningApiClient( + mocks.mockCredentialApp()); + + // Stubs used to simulate underlying api calls. + let stubs: sinon.SinonStub[] = []; + let app: FirebaseApp; + let apiClient: MachineLearningApiClient; + + beforeEach(() => { + app = mocks.appWithOptions(mockOptions); + apiClient = new MachineLearningApiClient(app); + }); + + afterEach(() => { + _.forEach(stubs, (stub) => stub.restore()); + stubs = []; + return app.delete(); + }); + + describe('Constructor', () => { + it('should throw when the app is null', () => { + expect(() => new MachineLearningApiClient(null as unknown as FirebaseApp)) + .to.throw('First argument passed to admin.machineLearning() must be a valid Firebase app'); + }); + }); + + describe('createModel', () => { + const NAME_ONLY_CONTENT: ModelContent = {displayName: 'name1'}; + + + const invalidContent: any[] = [null, undefined, {}, { tags: []}]; + invalidContent.forEach((content) => { + it(`should reject when called with: ${JSON.stringify(content)}`, () => { + return apiClient.createModel(content) + .should.eventually.be.rejected.and.have.property( + 'message', 'Invalid model content.'); + }); + }); + + it('should reject when project id is not available', () => { + return clientWithoutProjectId.createModel(NAME_ONLY_CONTENT) + .should.eventually.be.rejectedWith(noProjectId); + }); + + it('should throw when an error response is received', () => { + const stub = sinon + .stub(HttpClient.prototype, 'send') + .rejects(utils.errorFrom(ERROR_RESPONSE, 404)); + stubs.push(stub); + const expected = new FirebaseMachineLearningError('not-found', 'Requested entity not found'); + return apiClient.createModel(NAME_ONLY_CONTENT) + .should.eventually.be.rejected.and.deep.equal(expected); + }); + + it('should resolve with the created resource on success', () => { + const stub = sinon + .stub(HttpClient.prototype, 'send') + .resolves(utils.responseFrom(OPERATION_SUCCESS_RESPONSE)); + stubs.push(stub); + return apiClient.createModel(NAME_ONLY_CONTENT) + .then((resp) => { + expect(resp.done).to.be.true; + expect(resp.name).to.be.empty; + expect(resp.response).to.deep.equal(MODEL_RESPONSE); + }); + }); + + it('should resolve with error when the operation fails', () => { + const stub = sinon + .stub(HttpClient.prototype, 'send') + .resolves(utils.responseFrom(OPERATION_ERROR_RESPONSE)); + stubs.push(stub); + return apiClient.createModel(NAME_ONLY_CONTENT) + .then((resp) => { + expect(resp.done).to.be.true; + expect(resp.name).to.be.empty; + expect(resp.error).to.deep.equal(STATUS_ERROR_RESPONSE); + }); + }); + + it('should reject with unknown-error when error code is not present', () => { + const stub = sinon + .stub(HttpClient.prototype, 'send') + .rejects(utils.errorFrom({}, 404)); + stubs.push(stub); + const expected = new FirebaseMachineLearningError('unknown-error', 'Unknown server error: {}'); + return apiClient.createModel(NAME_ONLY_CONTENT) + .should.eventually.be.rejected.and.deep.equal(expected); + }); + + it('should reject with unknown-error for non-json response', () => { + const stub = sinon + .stub(HttpClient.prototype, 'send') + .rejects(utils.errorFrom('not json', 404)); + stubs.push(stub); + const expected = new FirebaseMachineLearningError( + 'unknown-error', 'Unexpected response with status: 404 and body: not json'); + return apiClient.createModel(NAME_ONLY_CONTENT) + .should.eventually.be.rejected.and.deep.equal(expected); + }); + + it('should reject with when failed with a FirebaseAppError', () => { + const expected = new FirebaseAppError('network-error', 'socket hang up'); + const stub = sinon + .stub(HttpClient.prototype, 'send') + .rejects(expected); + stubs.push(stub); + return apiClient.createModel(NAME_ONLY_CONTENT) + .should.eventually.be.rejected.and.deep.equal(expected); + }); + }); + + describe('updateModel', () => { + const NAME_ONLY_CONTENT: ModelContent = {displayName: 'name1'}; + const NAME_ONLY_MASK = ['displayName']; + + const invalidContent: any[] = [null, undefined]; + invalidContent.forEach((content) => { + it(`should reject when called with: ${JSON.stringify(content)}`, () => { + return apiClient.updateModel(MODEL_ID, content, NAME_ONLY_MASK) + .should.eventually.be.rejected.and.have.property( + 'message', 'Invalid model or mask content.'); + }); + }); + + it('should reject when called with empty mask', () => { + return apiClient.updateModel(MODEL_ID, {}, []) + .should.eventually.be.rejected.and.have.property( + 'message', 'Invalid model or mask content.'); + }); + + it('should reject when project id is not available', () => { + return clientWithoutProjectId.updateModel(MODEL_ID, NAME_ONLY_CONTENT, NAME_ONLY_MASK) + .should.eventually.be.rejectedWith(noProjectId); + }); + + it('should throw when an error response is received', () => { + const stub = sinon + .stub(HttpClient.prototype, 'send') + .rejects(utils.errorFrom(ERROR_RESPONSE, 404)); + stubs.push(stub); + const expected = new FirebaseMachineLearningError('not-found', 'Requested entity not found'); + return apiClient.updateModel(MODEL_ID, NAME_ONLY_CONTENT, NAME_ONLY_MASK) + .should.eventually.be.rejected.and.deep.equal(expected); + }); + + it('should resolve with the updated resource on success', () => { + const stub = sinon + .stub(HttpClient.prototype, 'send') + .resolves(utils.responseFrom(OPERATION_SUCCESS_RESPONSE)); + stubs.push(stub); + return apiClient.updateModel(MODEL_ID, NAME_ONLY_CONTENT, NAME_ONLY_MASK) + .then((resp) => { + expect(resp.done).to.be.true; + expect(resp.name).to.be.empty; + expect(resp.response).to.deep.equal(MODEL_RESPONSE); + expect(stub).to.have.been.calledOnce.and.calledWith({ + method: 'PATCH', + headers: EXPECTED_HEADERS, + url: `${BASE_URL}/projects/test-project/models/${MODEL_ID}?updateMask=displayName`, + data: NAME_ONLY_CONTENT, + }); + }); + }); + + it('should resolve with error when the operation fails', () => { + const stub = sinon + .stub(HttpClient.prototype, 'send') + .resolves(utils.responseFrom(OPERATION_ERROR_RESPONSE)); + stubs.push(stub); + return apiClient.updateModel(MODEL_ID, NAME_ONLY_CONTENT, NAME_ONLY_MASK) + .then((resp) => { + expect(resp.done).to.be.true; + expect(resp.name).to.be.empty; + expect(resp.error).to.deep.equal(STATUS_ERROR_RESPONSE); + }); + }); + + it('should reject with unknown-error when error code is not present', () => { + const stub = sinon + .stub(HttpClient.prototype, 'send') + .rejects(utils.errorFrom({}, 404)); + stubs.push(stub); + const expected = new FirebaseMachineLearningError('unknown-error', 'Unknown server error: {}'); + return apiClient.updateModel(MODEL_ID, NAME_ONLY_CONTENT, NAME_ONLY_MASK) + .should.eventually.be.rejected.and.deep.equal(expected); + }); + + it('should reject with unknown-error for non-json response', () => { + const stub = sinon + .stub(HttpClient.prototype, 'send') + .rejects(utils.errorFrom('not json', 404)); + stubs.push(stub); + const expected = new FirebaseMachineLearningError( + 'unknown-error', 'Unexpected response with status: 404 and body: not json'); + return apiClient.updateModel(MODEL_ID, NAME_ONLY_CONTENT, NAME_ONLY_MASK) + .should.eventually.be.rejected.and.deep.equal(expected); + }); + + it('should reject with when failed with a FirebaseAppError', () => { + const expected = new FirebaseAppError('network-error', 'socket hang up'); + const stub = sinon + .stub(HttpClient.prototype, 'send') + .rejects(expected); + stubs.push(stub); + return apiClient.updateModel(MODEL_ID, NAME_ONLY_CONTENT, NAME_ONLY_MASK) + .should.eventually.be.rejected.and.deep.equal(expected); + }); + }); + + describe('getModel', () => { + const INVALID_NAMES: any[] = [null, undefined, '', 1, true, {}, []]; + INVALID_NAMES.forEach((invalidName) => { + it(`should reject when called with: ${JSON.stringify(invalidName)}`, () => { + return apiClient.getModel(invalidName) + .should.eventually.be.rejected.and.have.property( + 'message', 'Model ID must be a non-empty string.'); + }); + }); + + it(`should reject when called with prefixed name`, () => { + return apiClient.getModel('projects/foo/models/bar') + .should.eventually.be.rejected.and.have.property( + 'message', 'Model ID must not contain any "/" characters.'); + }); + + it(`should reject when project id is not available`, () => { + return clientWithoutProjectId.getModel(MODEL_ID) + .should.eventually.be.rejectedWith(noProjectId); + }); + + it('should resolve with the requested model on success', () => { + const stub = sinon + .stub(HttpClient.prototype, 'send') + .resolves(utils.responseFrom({ name: 'bar' })); + stubs.push(stub); + return apiClient.getModel(MODEL_ID) + .then((resp) => { + expect(resp.name).to.equal('bar'); + expect(stub).to.have.been.calledOnce.and.calledWith({ + method: 'GET', + url: `${BASE_URL}/projects/test-project/models/1234567`, + headers: EXPECTED_HEADERS, + }); + }); + }); + + it('should reject when a full platform error response is received', () => { + const stub = sinon + .stub(HttpClient.prototype, 'send') + .rejects(utils.errorFrom(ERROR_RESPONSE, 404)); + stubs.push(stub); + const expected = new FirebaseMachineLearningError('not-found', 'Requested entity not found'); + return apiClient.getModel(MODEL_ID) + .should.eventually.be.rejected.and.deep.equal(expected); + }); + + it('should reject unknown-error when error code is not present', () => { + const stub = sinon + .stub(HttpClient.prototype, 'send') + .rejects(utils.errorFrom({}, 404)); + stubs.push(stub); + const expected = new FirebaseMachineLearningError('unknown-error', 'Unknown server error: {}'); + return apiClient.getModel(MODEL_ID) + .should.eventually.be.rejected.and.deep.equal(expected); + }); + + it('should reject unknown-error for non-json response', () => { + const stub = sinon + .stub(HttpClient.prototype, 'send') + .rejects(utils.errorFrom('not json', 404)); + stubs.push(stub); + const expected = new FirebaseMachineLearningError( + 'unknown-error', 'Unexpected response with status: 404 and body: not json'); + return apiClient.getModel(MODEL_ID) + .should.eventually.be.rejected.and.deep.equal(expected); + }); + + it('should reject when failed with a FirebaseAppError', () => { + const expected = new FirebaseAppError('network-error', 'socket hang up'); + const stub = sinon + .stub(HttpClient.prototype, 'send') + .rejects(expected); + stubs.push(stub); + return apiClient.getModel(MODEL_ID) + .should.eventually.be.rejected.and.deep.equal(expected); + }); + }); + + describe('listModels', () => { + const LIST_RESPONSE = { + models: [MODEL_RESPONSE, MODEL_RESPONSE2], + nextPageToken: 'next', + }; + + const invalidListFilters: any[] = [null, 0, '', true, {}, []]; + invalidListFilters.forEach((invalidFilter) => { + it(`should reject when called with invalid pageToken: ${JSON.stringify(invalidFilter)}`, () => { + return apiClient.listModels({filter: invalidFilter}) + .should.eventually.be.rejected.and.have.property( + 'message', 'Invalid list filter.'); + }); + }); + + const invalidPageSizes: any[] = [null, '', '10', true, {}, []]; + invalidPageSizes.forEach((invalidPageSize) => { + it(`should reject when called with invalid page size: ${JSON.stringify(invalidPageSize)}`, () => { + return apiClient.listModels({pageSize: invalidPageSize}) + .should.eventually.be.rejected.and.have.property( + 'message', 'Invalid page size.'); + }); + }); + + const outOfRangePageSizes: number[] = [-1, 0, 101]; + outOfRangePageSizes.forEach((invalidPageSize) => { + it(`should reject when called with invalid page size: ${invalidPageSize}`, () => { + return apiClient.listModels({pageSize: invalidPageSize}) + .should.eventually.be.rejected.and.have.property( + 'message', 'Page size must be between 1 and 100.'); + }); + }); + + const invalidPageTokens: any[] = [null, 0, '', true, {}, []]; + invalidPageTokens.forEach((invalidToken) => { + it(`should reject when called with invalid pageToken: ${JSON.stringify(invalidToken)}`, () => { + return apiClient.listModels({pageToken: invalidToken}) + .should.eventually.be.rejected.and.have.property( + 'message', 'Next page token must be a non-empty string.'); + }); + }); + + it('should resolve on success when called without any arguments', () => { + const stub = sinon + .stub(HttpClient.prototype, 'send') + .resolves(utils.responseFrom(LIST_RESPONSE)); + stubs.push(stub); + return apiClient.listModels() + .then((resp) => { + expect(resp).to.deep.equal(LIST_RESPONSE); + expect(stub).to.have.been.calledOnce.and.calledWith({ + method: 'GET', + url: `${BASE_URL}/projects/test-project/models`, + headers: EXPECTED_HEADERS, + data: {}, + }); + }); + }); + + const validOptions: ListModelsOptions[] = [ + {pageSize: 5}, + {pageToken: 'next'}, + {filter: 'displayName=name1'}, + { + filter: 'displayName=name1', + pageSize: 5, + pageToken: 'next', + }, + ]; + validOptions.forEach((options) => { + it(`should resolve on success when called with options: ${JSON.stringify(options)}`, () => { + const stub = sinon + .stub(HttpClient.prototype, 'send') + .resolves(utils.responseFrom(LIST_RESPONSE)); + stubs.push(stub); + return apiClient.listModels(options) + .then((resp) => { + expect(resp.models).not.to.be.empty; + expect(resp.models!.length).to.equal(2); + expect(resp.models![0]).to.deep.equal(MODEL_RESPONSE); + expect(resp.models![1]).to.deep.equal(MODEL_RESPONSE2); + expect(stub).to.have.been.calledOnce.and.calledWith({ + method: 'GET', + url: `${BASE_URL}/projects/test-project/models`, + headers: EXPECTED_HEADERS, + data: options, + }); + }); + }); + }); + + it('should throw when a full platform error response is received', () => { + const stub = sinon + .stub(HttpClient.prototype, 'send') + .rejects(utils.errorFrom(ERROR_RESPONSE, 404)); + stubs.push(stub); + const expected = new FirebaseMachineLearningError('not-found', 'Requested entity not found'); + return apiClient.listModels() + .should.eventually.be.rejected.and.deep.equal(expected); + }); + + it('should throw unknown-error when error code is not present', () => { + const stub = sinon + .stub(HttpClient.prototype, 'send') + .rejects(utils.errorFrom({}, 404)); + stubs.push(stub); + const expected = new FirebaseMachineLearningError('unknown-error', 'Unknown server error: {}'); + return apiClient.listModels() + .should.eventually.be.rejected.and.deep.equal(expected); + }); + + it('should throw unknown-error for non-json response', () => { + const stub = sinon + .stub(HttpClient.prototype, 'send') + .rejects(utils.errorFrom('not json', 404)); + stubs.push(stub); + const expected = new FirebaseMachineLearningError( + 'unknown-error', 'Unexpected response with status: 404 and body: not json'); + return apiClient.listModels() + .should.eventually.be.rejected.and.deep.equal(expected); + }); + + it('should throw when rejected with a FirebaseAppError', () => { + const expected = new FirebaseAppError('network-error', 'socket hang up'); + const stub = sinon + .stub(HttpClient.prototype, 'send') + .rejects(expected); + stubs.push(stub); + return apiClient.listModels() + .should.eventually.be.rejected.and.deep.equal(expected); + }); + }); + + describe('deleteModel', () => { + const INVALID_NAMES: any[] = [null, undefined, '', 1, true, {}, []]; + INVALID_NAMES.forEach((invalidName) => { + it(`should reject when called with: ${JSON.stringify(invalidName)}`, () => { + return apiClient.deleteModel(invalidName) + .should.eventually.be.rejected.and.have.property( + 'message', 'Model ID must be a non-empty string.'); + }); + }); + + it(`should reject when called with prefixed name`, () => { + return apiClient.deleteModel('projects/foo/rulesets/bar') + .should.eventually.be.rejected.and.have.property( + 'message', 'Model ID must not contain any "/" characters.'); + }); + + it(`should reject when project id is not available`, () => { + return clientWithoutProjectId.deleteModel(MODEL_ID) + .should.eventually.be.rejectedWith(noProjectId); + }); + + it('should resolve on success', () => { + const stub = sinon + .stub(HttpClient.prototype, 'send') + .resolves(utils.responseFrom({})); + stubs.push(stub); + return apiClient.deleteModel(MODEL_ID) + .then(() => { + expect(stub).to.have.been.calledOnce.and.calledWith({ + method: 'DELETE', + url: `${BASE_URL}/projects/test-project/models/1234567`, + headers: EXPECTED_HEADERS, + }); + }); + }); + + it('should reject when a full platform error response is received', () => { + const stub = sinon + .stub(HttpClient.prototype, 'send') + .rejects(utils.errorFrom(ERROR_RESPONSE, 404)); + stubs.push(stub); + const expected = new FirebaseMachineLearningError('not-found', 'Requested entity not found'); + return apiClient.deleteModel(MODEL_ID) + .should.eventually.be.rejected.and.deep.equal(expected); + }); + + it('should reject with unknown-error when error code is not present', () => { + const stub = sinon + .stub(HttpClient.prototype, 'send') + .rejects(utils.errorFrom({}, 404)); + stubs.push(stub); + const expected = new FirebaseMachineLearningError('unknown-error', 'Unknown server error: {}'); + return apiClient.deleteModel(MODEL_ID) + .should.eventually.be.rejected.and.deep.equal(expected); + }); + + it('should reject with unknown-error for non-json response', () => { + const stub = sinon + .stub(HttpClient.prototype, 'send') + .rejects(utils.errorFrom('not json', 404)); + stubs.push(stub); + const expected = new FirebaseMachineLearningError( + 'unknown-error', 'Unexpected response with status: 404 and body: not json'); + return apiClient.deleteModel(MODEL_ID) + .should.eventually.be.rejected.and.deep.equal(expected); + }); + + it('should reject when failed with a FirebaseAppError', () => { + const expected = new FirebaseAppError('network-error', 'socket hang up'); + const stub = sinon + .stub(HttpClient.prototype, 'send') + .rejects(expected); + stubs.push(stub); + return apiClient.deleteModel(MODEL_ID) + .should.eventually.be.rejected.and.deep.equal(expected); + }); + }); +}); diff --git a/test/unit/machine-learning/machine-learning.spec.ts b/test/unit/machine-learning/machine-learning.spec.ts new file mode 100644 index 0000000000..1a49b2cab1 --- /dev/null +++ b/test/unit/machine-learning/machine-learning.spec.ts @@ -0,0 +1,848 @@ +/*! + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +import * as _ from 'lodash'; +import * as chai from 'chai'; +import * as sinon from 'sinon'; +import { MachineLearning, Model } from '../../../src/machine-learning/machine-learning'; +import { FirebaseApp } from '../../../src/firebase-app'; +import * as mocks from '../../resources/mocks'; +import { MachineLearningApiClient, StatusErrorResponse, + ModelOptions, ModelResponse } from '../../../src/machine-learning/machine-learning-api-client'; +import { FirebaseMachineLearningError } from '../../../src/machine-learning/machine-learning-utils'; +import { deepCopy } from '../../../src/utils/deep-copy'; + +const expect = chai.expect; + +describe('MachineLearning', () => { + + const MODEL_ID = '1234567'; + const EXPECTED_ERROR = new FirebaseMachineLearningError('internal-error', 'message'); + const CREATE_TIME_UTC = 'Fri, 07 Feb 2020 23:45:23 GMT'; + const UPDATE_TIME_UTC = 'Sat, 08 Feb 2020 23:45:23 GMT'; + const MODEL_RESPONSE: { + name: string; + createTime: string; + updateTime: string; + etag: string; + modelHash: string; + displayName?: string; + tags?: string[]; + state?: { + validationError?: { + code: number; + message: string; + }; + published?: boolean; + }; + tfliteModel?: { + gcsTfliteUri: string; + sizeBytes: number; + }; + } = { + name: 'projects/test-project/models/1234567', + createTime: '2020-02-07T23:45:23.288047Z', + updateTime: '2020-02-08T23:45:23.288047Z', + etag: 'etag123', + modelHash: 'modelHash123', + displayName: 'model_1', + tags: ['tag_1', 'tag_2'], + state: {published: true}, + tfliteModel: { + gcsTfliteUri: 'gs://test-project-bucket/Firebase/ML/Models/model1.tflite', + sizeBytes: 16900988, + }, + }; + const MODEL1 = new Model(MODEL_RESPONSE); + + const MODEL_RESPONSE2: { + name: string; + createTime: string; + updateTime: string; + etag: string; + modelHash: string; + displayName?: string; + tags?: string[]; + state?: { + validationError?: { + code: number; + message: string; + }; + published?: boolean; + }; + tfliteModel?: { + gcsTfliteUri: string; + sizeBytes: number; + }; + } = { + name: 'projects/test-project/models/2345678', + createTime: '2020-02-07T23:45:22.288047Z', + updateTime: '2020-02-08T23:45:22.288047Z', + etag: 'etag234', + modelHash: 'modelHash234', + displayName: 'model_2', + tags: ['tag_2', 'tag_3'], + state: {published: false}, + tfliteModel: { + gcsTfliteUri: 'gs://test-project-bucket/Firebase/ML/Models/model2.tflite', + sizeBytes: 22200222, + }, + }; + const MODEL2 = new Model(MODEL_RESPONSE2); + + const STATUS_ERROR_RESPONSE: { + code: number; + message: string; + } = { + code: 3, + message: 'Invalid Argument message', + }; + + const OPERATION_RESPONSE: { + name?: string; + done: boolean; + error?: StatusErrorResponse; + response?: { + name: string; + createTime: string; + updateTime: string; + etag: string; + modelHash: string; + displayName?: string; + tags?: string[]; + state?: { + validationError?: { + code: number; + message: string; + }; + published?: boolean; + }; + tfliteModel?: { + gcsTfliteUri: string; + sizeBytes: number; + }; + }; + } = { + done: true, + response: MODEL_RESPONSE, + }; + + const OPERATION_RESPONSE_ERROR: { + name?: string; + done: boolean; + error?: { + code: number; + message: string; + }; + response?: ModelResponse; + } = { + done: true, + error: STATUS_ERROR_RESPONSE, + }; + + + + let machineLearning: MachineLearning; + let mockApp: FirebaseApp; + let mockCredentialApp: FirebaseApp; + + const stubs: sinon.SinonStub[] = []; + + before(() => { + mockApp = mocks.app(); + mockCredentialApp = mocks.mockCredentialApp(); + machineLearning = new MachineLearning(mockApp); + }); + + after(() => { + return mockApp.delete(); + }); + + afterEach(() => { + _.forEach(stubs, (stub) => stub.restore()); + }); + + describe('Constructor', () => { + const invalidApps = [null, NaN, 0, 1, true, false, '', 'a', [], [1, 'a'], {}, { a: 1 }, _.noop]; + invalidApps.forEach((invalidApp) => { + it('should throw given invalid app: ' + JSON.stringify(invalidApp), () => { + expect(() => { + const machineLearningAny: any = MachineLearning; + return new machineLearningAny(invalidApp); + }).to.throw( + 'First argument passed to admin.machineLearning() must be a valid Firebase app ' + + 'instance.'); + }); + }); + + it('should throw given no app', () => { + expect(() => { + const machineLearningAny: any = MachineLearning; + return new machineLearningAny(); + }).to.throw( + 'First argument passed to admin.machineLearning() must be a valid Firebase app ' + + 'instance.'); + }); + + it('should throw given invalid credential', () => { + const expectedError = 'Failed to initialize Google Cloud Storage client with ' + + 'the available credential. Must initialize the SDK with a certificate credential ' + + 'or application default credentials to use Cloud Storage API.'; + expect(() => { + const machineLearningAny: any = MachineLearning; + return new machineLearningAny(mockCredentialApp).createModel({ + displayName: 'foo', + tfliteModel: { + gcsTfliteUri: 'gs://some-bucket/model.tflite', + }}); + }).to.throw(expectedError); + }); + + it('should not throw given a valid app', () => { + expect(() => { + return new MachineLearning(mockApp); + }).not.to.throw(); + }); + }); + + describe('app', () => { + it('returns the app from the constructor', () => { + // We expect referential equality here + expect(machineLearning.app).to.equal(mockApp); + }); + }); + + describe('Model', () => { + it('should successfully construct a model', () => { + const model = new Model(MODEL_RESPONSE); + expect(model.modelId).to.equal(MODEL_ID); + expect(model.displayName).to.equal('model_1'); + expect(model.tags).to.deep.equal(['tag_1', 'tag_2']); + expect(model.createTime).to.equal(CREATE_TIME_UTC); + expect(model.updateTime).to.equal(UPDATE_TIME_UTC); + expect(model.validationError).to.be.empty; + expect(model.published).to.be.true; + expect(model.etag).to.equal('etag123'); + expect(model.modelHash).to.equal('modelHash123'); + + const tflite = model.tfliteModel!; + expect(tflite.gcsTfliteUri).to.be.equal( + 'gs://test-project-bucket/Firebase/ML/Models/model1.tflite'); + expect(tflite.sizeBytes).to.be.equal(16900988); + }); + }); + + describe('getModel', () => { + it('should propagate API errors', () => { + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'getModel') + .rejects(EXPECTED_ERROR); + stubs.push(stub); + return machineLearning.getModel(MODEL_ID) + .should.eventually.be.rejected.and.deep.equal(EXPECTED_ERROR); + }); + + it('should reject when API response is invalid', () => { + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'getModel') + .resolves(null); + stubs.push(stub); + return machineLearning.getModel(MODEL_ID) + .should.eventually.be.rejected.and.have.property( + 'message', 'Invalid Model response: null'); + }); + + it('should reject when API response does not contain a name', () => { + const response = deepCopy(MODEL_RESPONSE); + response.name = ''; + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'getModel') + .resolves(response); + stubs.push(stub); + return machineLearning.getModel(MODEL_ID) + .should.eventually.be.rejected.and.have.property( + 'message', `Invalid Model response: ${JSON.stringify(response)}`); + }); + + it('should reject when API response does not contain a createTime', () => { + const response = deepCopy(MODEL_RESPONSE); + response.createTime = ''; + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'getModel') + .resolves(response); + stubs.push(stub); + return machineLearning.getModel(MODEL_ID) + .should.eventually.be.rejected.and.have.property( + 'message', `Invalid Model response: ${JSON.stringify(response)}`); + }); + + it('should reject when API response does not contain a updateTime', () => { + const response = deepCopy(MODEL_RESPONSE); + response.updateTime = ''; + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'getModel') + .resolves(response); + stubs.push(stub); + return machineLearning.getModel(MODEL_ID) + .should.eventually.be.rejected.and.have.property( + 'message', `Invalid Model response: ${JSON.stringify(response)}`); + }); + + it('should reject when API response does not contain a displayName', () => { + const response = deepCopy(MODEL_RESPONSE); + response.displayName = ''; + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'getModel') + .resolves(response); + stubs.push(stub); + return machineLearning.getModel(MODEL_ID) + .should.eventually.be.rejected.and.have.property( + 'message', `Invalid Model response: ${JSON.stringify(response)}`); + }); + + it('should reject when API response does not contain an etag', () => { + const response = deepCopy(MODEL_RESPONSE); + response.etag = ''; + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'getModel') + .resolves(response); + stubs.push(stub); + return machineLearning.getModel(MODEL_ID) + .should.eventually.be.rejected.and.have.property( + 'message', `Invalid Model response: ${JSON.stringify(response)}`); + }); + + it('should resolve with Model on success', () => { + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'getModel') + .resolves(MODEL_RESPONSE); + stubs.push(stub); + + return machineLearning.getModel(MODEL_ID) + .then((model) => { + expect(model).to.deep.equal(MODEL1); + }); + }); + }); + + describe('listModels', () => { + + const LIST_MODELS_RESPONSE = { + models: [ + MODEL_RESPONSE, + MODEL_RESPONSE2, + ], + nextPageToken: 'next', + }; + + it('should propagate API errors', () => { + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'listModels') + .rejects(EXPECTED_ERROR); + stubs.push(stub); + return machineLearning.listModels({}) + .should.eventually.be.rejected.and.deep.equal(EXPECTED_ERROR); + }); + + it('should reject when API response is invalid', () => { + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'listModels') + .resolves(null); + stubs.push(stub); + return machineLearning.listModels() + .should.eventually.be.rejected.and.have.property( + 'message', 'Invalid ListModels response: null'); + }); + + it('should resolve with Models on success', () => { + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'listModels') + .resolves(LIST_MODELS_RESPONSE); + stubs.push(stub); + return machineLearning.listModels() + .then((result) => { + expect(result.models.length).equals(2); + expect(result.models[0]).to.deep.equal(MODEL1); + expect(result.models[1]).to.deep.equal(MODEL2); + expect(result.pageToken).to.equal(LIST_MODELS_RESPONSE.nextPageToken); + }); + }); + }); + + describe('deleteModel', () => { + it('should propagate API errors', () => { + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'deleteModel') + .rejects(EXPECTED_ERROR); + stubs.push(stub); + return machineLearning.deleteModel(MODEL_ID) + .should.eventually.be.rejected.and.deep.equal(EXPECTED_ERROR); + }); + + it('should resolve on success', () => { + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'deleteModel') + .resolves({}); + stubs.push(stub); + + return machineLearning.deleteModel(MODEL_ID); + }); + }); + + describe('createModel', () => { + const GCS_TFLITE_URI = 'gs://test-bucket/Firebase/ML/Models/model1.tflite'; + const MODEL_OPTIONS_NO_GCS: ModelOptions = { + displayName: 'display_name', + tags: ['tag1', 'tag2'], + }; + const MODEL_OPTIONS_WITH_GCS: ModelOptions = { + displayName: 'display_name_2', + tags: ['tag3', 'tag4'], + tfliteModel: { + gcsTfliteUri: GCS_TFLITE_URI, + }, + }; + + it('should propagate API errors', () => { + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'createModel') + .rejects(EXPECTED_ERROR); + stubs.push(stub); + return machineLearning.createModel(MODEL_OPTIONS_NO_GCS) + .should.eventually.be.rejected.and.deep.equal(EXPECTED_ERROR); + }); + + it('should reject when API response is invalid', () => { + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'createModel') + .resolves(null); + stubs.push(stub); + return machineLearning.createModel(MODEL_OPTIONS_WITH_GCS) + .should.eventually.be.rejected.and.have.property( + 'message', 'Cannot read property \'done\' of null'); + }); + + it('should reject when API response does not contain a name', () => { + const op = deepCopy(OPERATION_RESPONSE); + op.response!.name = ''; + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'createModel') + .resolves(op); + stubs.push(stub); + return machineLearning.createModel(MODEL_OPTIONS_NO_GCS) + .should.eventually.be.rejected.and.have.property( + 'message', `Invalid Model response: ${JSON.stringify(op.response)}`); + }); + + it('should reject when API response does not contain a createTime', () => { + const op = deepCopy(OPERATION_RESPONSE); + op.response!.createTime = ''; + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'createModel') + .resolves(op); + stubs.push(stub); + return machineLearning.createModel(MODEL_OPTIONS_NO_GCS) + .should.eventually.be.rejected.and.have.property( + 'message', `Invalid Model response: ${JSON.stringify(op.response)}`); + }); + + it('should reject when API response does not contain a updateTime', () => { + const op = deepCopy(OPERATION_RESPONSE); + op.response!.updateTime = ''; + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'createModel') + .resolves(op); + stubs.push(stub); + return machineLearning.createModel(MODEL_OPTIONS_NO_GCS) + .should.eventually.be.rejected.and.have.property( + 'message', `Invalid Model response: ${JSON.stringify(op.response)}`); + }); + + it('should reject when API response does not contain a displayName', () => { + const op = deepCopy(OPERATION_RESPONSE); + op.response!.displayName = ''; + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'createModel') + .resolves(op); + stubs.push(stub); + return machineLearning.createModel(MODEL_OPTIONS_NO_GCS) + .should.eventually.be.rejected.and.have.property( + 'message', `Invalid Model response: ${JSON.stringify(op.response)}`); + }); + + it('should reject when API response does not contain an etag', () => { + const op = deepCopy(OPERATION_RESPONSE); + op.response!.etag = ''; + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'createModel') + .resolves(op); + stubs.push(stub); + return machineLearning.createModel(MODEL_OPTIONS_NO_GCS) + .should.eventually.be.rejected.and.have.property( + 'message', `Invalid Model response: ${JSON.stringify(op.response)}`); + }); + + it('should resolve with Model on success', () => { + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'createModel') + .resolves(OPERATION_RESPONSE); + stubs.push(stub); + + return machineLearning.createModel(MODEL_OPTIONS_WITH_GCS) + .then((model) => { + expect(model).to.deep.equal(MODEL1); + }); + }); + + it('should resolve with Error on operation error', () => { + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'createModel') + .resolves(OPERATION_RESPONSE_ERROR); + stubs.push(stub); + + return machineLearning.createModel(MODEL_OPTIONS_WITH_GCS) + .should.eventually.be.rejected.and.have.property( + 'message', 'Invalid Argument message'); + }); + }); + + describe('updateModel', () => { + const GCS_TFLITE_URI = 'gs://test-bucket/Firebase/ML/Models/model1.tflite'; + const MODEL_OPTIONS_NO_GCS: ModelOptions = { + displayName: 'display_name', + tags: ['tag1', 'tag2'], + }; + const MODEL_OPTIONS_WITH_GCS: ModelOptions = { + displayName: 'display_name_2', + tags: ['tag3', 'tag4'], + tfliteModel: { + gcsTfliteUri: GCS_TFLITE_URI, + }, + }; + + it('should propagate API errors', () => { + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'updateModel') + .rejects(EXPECTED_ERROR); + stubs.push(stub); + return machineLearning.updateModel(MODEL_ID, MODEL_OPTIONS_NO_GCS) + .should.eventually.be.rejected.and.deep.equal(EXPECTED_ERROR); + }); + + it('should reject when API response is invalid', () => { + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'updateModel') + .resolves(null); + stubs.push(stub); + return machineLearning.updateModel(MODEL_ID, MODEL_OPTIONS_WITH_GCS) + .should.eventually.be.rejected.and.have.property( + 'message', 'Cannot read property \'done\' of null'); + }); + + it('should reject when API response does not contain a name', () => { + const op = deepCopy(OPERATION_RESPONSE); + op.response!.name = ''; + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'updateModel') + .resolves(op); + stubs.push(stub); + return machineLearning.updateModel(MODEL_ID, MODEL_OPTIONS_NO_GCS) + .should.eventually.be.rejected.and.have.property( + 'message', `Invalid Model response: ${JSON.stringify(op.response)}`); + }); + + it('should reject when API response does not contain a createTime', () => { + const op = deepCopy(OPERATION_RESPONSE); + op.response!.createTime = ''; + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'updateModel') + .resolves(op); + stubs.push(stub); + return machineLearning.updateModel(MODEL_ID, MODEL_OPTIONS_NO_GCS) + .should.eventually.be.rejected.and.have.property( + 'message', `Invalid Model response: ${JSON.stringify(op.response)}`); + }); + + it('should reject when API response does not contain a updateTime', () => { + const op = deepCopy(OPERATION_RESPONSE); + op.response!.updateTime = ''; + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'updateModel') + .resolves(op); + stubs.push(stub); + return machineLearning.updateModel(MODEL_ID, MODEL_OPTIONS_NO_GCS) + .should.eventually.be.rejected.and.have.property( + 'message', `Invalid Model response: ${JSON.stringify(op.response)}`); + }); + + it('should reject when API response does not contain a displayName', () => { + const op = deepCopy(OPERATION_RESPONSE); + op.response!.displayName = ''; + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'updateModel') + .resolves(op); + stubs.push(stub); + return machineLearning.updateModel(MODEL_ID, MODEL_OPTIONS_NO_GCS) + .should.eventually.be.rejected.and.have.property( + 'message', `Invalid Model response: ${JSON.stringify(op.response)}`); + }); + + it('should reject when API response does not contain an etag', () => { + const op = deepCopy(OPERATION_RESPONSE); + op.response!.etag = ''; + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'updateModel') + .resolves(op); + stubs.push(stub); + return machineLearning.updateModel(MODEL_ID, MODEL_OPTIONS_NO_GCS) + .should.eventually.be.rejected.and.have.property( + 'message', `Invalid Model response: ${JSON.stringify(op.response)}`); + }); + + it('should resolve with Model on success', () => { + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'updateModel') + .resolves(OPERATION_RESPONSE); + stubs.push(stub); + + return machineLearning.updateModel(MODEL_ID, MODEL_OPTIONS_WITH_GCS) + .then((model) => { + expect(model).to.deep.equal(MODEL1); + }); + }); + + it('should resolve with Error on operation error', () => { + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'updateModel') + .resolves(OPERATION_RESPONSE_ERROR); + stubs.push(stub); + + return machineLearning.updateModel(MODEL_ID, MODEL_OPTIONS_WITH_GCS) + .should.eventually.be.rejected.and.have.property( + 'message', 'Invalid Argument message'); + }); + }); + + describe('publishModel', () => { + it('should propagate API errors', () => { + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'updateModel') + .rejects(EXPECTED_ERROR); + stubs.push(stub); + return machineLearning.publishModel(MODEL_ID) + .should.eventually.be.rejected.and.deep.equal(EXPECTED_ERROR); + }); + + it('should reject when API response is invalid', () => { + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'updateModel') + .resolves(null); + stubs.push(stub); + return machineLearning.publishModel(MODEL_ID) + .should.eventually.be.rejected.and.have.property( + 'message', 'Cannot read property \'done\' of null'); + }); + + it('should reject when API response does not contain a name', () => { + const op = deepCopy(OPERATION_RESPONSE); + op.response!.name = ''; + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'updateModel') + .resolves(op); + stubs.push(stub); + return machineLearning.publishModel(MODEL_ID) + .should.eventually.be.rejected.and.have.property( + 'message', `Invalid Model response: ${JSON.stringify(op.response)}`); + }); + + it('should reject when API response does not contain a createTime', () => { + const op = deepCopy(OPERATION_RESPONSE); + op.response!.createTime = ''; + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'updateModel') + .resolves(op); + stubs.push(stub); + return machineLearning.publishModel(MODEL_ID) + .should.eventually.be.rejected.and.have.property( + 'message', `Invalid Model response: ${JSON.stringify(op.response)}`); + }); + + it('should reject when API response does not contain a updateTime', () => { + const op = deepCopy(OPERATION_RESPONSE); + op.response!.updateTime = ''; + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'updateModel') + .resolves(op); + stubs.push(stub); + return machineLearning.publishModel(MODEL_ID) + .should.eventually.be.rejected.and.have.property( + 'message', `Invalid Model response: ${JSON.stringify(op.response)}`); + }); + + it('should reject when API response does not contain a displayName', () => { + const op = deepCopy(OPERATION_RESPONSE); + op.response!.displayName = ''; + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'updateModel') + .resolves(op); + stubs.push(stub); + return machineLearning.publishModel(MODEL_ID) + .should.eventually.be.rejected.and.have.property( + 'message', `Invalid Model response: ${JSON.stringify(op.response)}`); + }); + + it('should reject when API response does not contain an etag', () => { + const op = deepCopy(OPERATION_RESPONSE); + op.response!.etag = ''; + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'updateModel') + .resolves(op); + stubs.push(stub); + return machineLearning.publishModel(MODEL_ID) + .should.eventually.be.rejected.and.have.property( + 'message', `Invalid Model response: ${JSON.stringify(op.response)}`); + }); + + it('should resolve with Model on success', () => { + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'updateModel') + .resolves(OPERATION_RESPONSE); + stubs.push(stub); + + return machineLearning.publishModel(MODEL_ID) + .then((model) => { + expect(model).to.deep.equal(MODEL1); + }); + }); + + it('should resolve with Error on operation error', () => { + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'updateModel') + .resolves(OPERATION_RESPONSE_ERROR); + stubs.push(stub); + + return machineLearning.publishModel(MODEL_ID) + .should.eventually.be.rejected.and.have.property( + 'message', 'Invalid Argument message'); + }); + }); + + describe('unpublishModel', () => { + it('should propagate API errors', () => { + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'updateModel') + .rejects(EXPECTED_ERROR); + stubs.push(stub); + return machineLearning.unpublishModel(MODEL_ID) + .should.eventually.be.rejected.and.deep.equal(EXPECTED_ERROR); + }); + + it('should reject when API response is invalid', () => { + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'updateModel') + .resolves(null); + stubs.push(stub); + return machineLearning.unpublishModel(MODEL_ID) + .should.eventually.be.rejected.and.have.property( + 'message', 'Cannot read property \'done\' of null'); + }); + + it('should reject when API response does not contain a name', () => { + const op = deepCopy(OPERATION_RESPONSE); + op.response!.name = ''; + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'updateModel') + .resolves(op); + stubs.push(stub); + return machineLearning.unpublishModel(MODEL_ID) + .should.eventually.be.rejected.and.have.property( + 'message', `Invalid Model response: ${JSON.stringify(op.response)}`); + }); + + it('should reject when API response does not contain a createTime', () => { + const op = deepCopy(OPERATION_RESPONSE); + op.response!.createTime = ''; + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'updateModel') + .resolves(op); + stubs.push(stub); + return machineLearning.unpublishModel(MODEL_ID) + .should.eventually.be.rejected.and.have.property( + 'message', `Invalid Model response: ${JSON.stringify(op.response)}`); + }); + + it('should reject when API response does not contain a updateTime', () => { + const op = deepCopy(OPERATION_RESPONSE); + op.response!.updateTime = ''; + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'updateModel') + .resolves(op); + stubs.push(stub); + return machineLearning.unpublishModel(MODEL_ID) + .should.eventually.be.rejected.and.have.property( + 'message', `Invalid Model response: ${JSON.stringify(op.response)}`); + }); + + it('should reject when API response does not contain a displayName', () => { + const op = deepCopy(OPERATION_RESPONSE); + op.response!.displayName = ''; + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'updateModel') + .resolves(op); + stubs.push(stub); + return machineLearning.unpublishModel(MODEL_ID) + .should.eventually.be.rejected.and.have.property( + 'message', `Invalid Model response: ${JSON.stringify(op.response)}`); + }); + + it('should reject when API response does not contain an etag', () => { + const op = deepCopy(OPERATION_RESPONSE); + op.response!.etag = ''; + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'updateModel') + .resolves(op); + stubs.push(stub); + return machineLearning.unpublishModel(MODEL_ID) + .should.eventually.be.rejected.and.have.property( + 'message', `Invalid Model response: ${JSON.stringify(op.response)}`); + }); + + it('should resolve with Model on success', () => { + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'updateModel') + .resolves(OPERATION_RESPONSE); + stubs.push(stub); + + return machineLearning.unpublishModel(MODEL_ID) + .then((model) => { + expect(model).to.deep.equal(MODEL1); + }); + }); + + it('should resolve with Error on operation error', () => { + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'updateModel') + .resolves(OPERATION_RESPONSE_ERROR); + stubs.push(stub); + + return machineLearning.unpublishModel(MODEL_ID) + .should.eventually.be.rejected.and.have.property( + 'message', 'Invalid Argument message'); + }); + }); +}); diff --git a/test/unit/utils/api-request.spec.ts b/test/unit/utils/api-request.spec.ts index 3052d01e81..c2798de455 100644 --- a/test/unit/utils/api-request.spec.ts +++ b/test/unit/utils/api-request.spec.ts @@ -518,6 +518,30 @@ describe('HttpClient', () => { }); }); + it('should urlEncode query parameters in URL', () => { + const reqData = {key1: 'value 1!', key2: 'value 2!'}; + const mergedData = {...reqData, key3: 'value 3!'}; + const respData = {success: true}; + const scope = nock('https://' + mockHost) + .get(mockPath) + .query(mergedData) + .reply(200, respData, { + 'content-type': 'application/json', + }); + mockedRequests.push(scope); + const client = new HttpClient(); + return client.send({ + method: 'GET', + url: mockUrl + '?key3=value+3%21', + data: reqData, + }).then((resp) => { + expect(resp.status).to.equal(200); + expect(resp.headers['content-type']).to.equal('application/json'); + expect(resp.data).to.deep.equal(respData); + expect(resp.isJson()).to.be.true; + }); + }); + it('should default to https when protocol not specified', () => { const respData = {foo: 'bar'}; const scope = nock('https://' + mockHost)