Skip to content

Commit 3f2ec51

Browse files
authored
Added GetModel functionality and tests (#781)
* Added GetModel functionality and tests
1 parent 171b55e commit 3f2ec51

8 files changed

Lines changed: 723 additions & 9 deletions

File tree

src/index.d.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5252,11 +5252,11 @@ declare namespace admin.machineLearning {
52525252
readonly validationError?: string;
52535253
readonly published: boolean;
52545254
readonly etag: string;
5255-
readonly modelHash: string;
5255+
readonly modelHash?: string;
52565256
readonly locked: boolean;
52575257
waitForUnlocked(maxTimeSeconds?: number): Promise<void>;
52585258

5259-
readonly tfLiteModel?: TFLiteModel;
5259+
readonly tfliteModel?: TFLiteModel;
52605260
}
52615261

52625262
/**
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
/*!
2+
* Copyright 2020 Google Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { HttpRequestConfig, HttpClient, HttpError, AuthorizedHttpClient } from '../utils/api-request';
18+
import { PrefixedFirebaseError } from '../utils/error';
19+
import { FirebaseMachineLearningError, MachineLearningErrorCode } from './machine-learning-utils';
20+
import * as utils from '../utils/index';
21+
import * as validator from '../utils/validator';
22+
import { FirebaseApp } from '../firebase-app';
23+
24+
const ML_V1BETA1_API = 'https://mlkit.googleapis.com/v1beta1';
25+
const FIREBASE_VERSION_HEADER = {
26+
'X-Firebase-Client': 'fire-admin-node/<XXX_SDK_VERSION_XXX>',
27+
};
28+
29+
export interface StatusErrorResponse {
30+
readonly code: number;
31+
readonly message: string;
32+
}
33+
34+
export interface ModelContent {
35+
readonly displayName?: string;
36+
readonly tags?: string[];
37+
readonly state?: {
38+
readonly validationError?: StatusErrorResponse;
39+
readonly published?: boolean;
40+
};
41+
readonly tfliteModel?: {
42+
readonly gcsTfliteUri: string;
43+
readonly sizeBytes: number;
44+
};
45+
}
46+
47+
export interface ModelResponse extends ModelContent {
48+
readonly name: string;
49+
readonly createTime: string;
50+
readonly updateTime: string;
51+
readonly etag: string;
52+
readonly modelHash?: string;
53+
}
54+
55+
56+
/**
57+
* Class that facilitates sending requests to the Firebase ML backend API.
58+
*
59+
* @private
60+
*/
61+
export class MachineLearningApiClient {
62+
private readonly httpClient: HttpClient;
63+
private projectIdPrefix?: string;
64+
65+
constructor(private readonly app: FirebaseApp) {
66+
if (!validator.isNonNullObject(app) || !('options' in app)) {
67+
throw new FirebaseMachineLearningError(
68+
'invalid-argument',
69+
'First argument passed to admin.machineLearning() must be a valid '
70+
+ 'Firebase app instance.');
71+
}
72+
73+
this.httpClient = new AuthorizedHttpClient(app);
74+
}
75+
76+
public getModel(modelId: string): Promise<ModelResponse> {
77+
return Promise.resolve()
78+
.then(() => {
79+
return this.getModelName(modelId);
80+
})
81+
.then((modelName) => {
82+
return this.getResource<ModelResponse>(modelName);
83+
});
84+
}
85+
86+
/**
87+
* Gets the specified resource from the ML API. Resource names must be the short names without project
88+
* ID prefix (e.g. `models/123456789`).
89+
*
90+
* @param {string} name Full qualified name of the resource to get.
91+
* @returns {Promise<T>} A promise that fulfills with the resource.
92+
*/
93+
private getResource<T>(name: string): Promise<T> {
94+
return this.getUrl()
95+
.then((url) => {
96+
const request: HttpRequestConfig = {
97+
method: 'GET',
98+
url: `${url}/${name}`,
99+
};
100+
return this.sendRequest<T>(request);
101+
});
102+
}
103+
104+
private sendRequest<T>(request: HttpRequestConfig): Promise<T> {
105+
request.headers = FIREBASE_VERSION_HEADER;
106+
return this.httpClient.send(request)
107+
.then((resp) => {
108+
return resp.data as T;
109+
})
110+
.catch((err) => {
111+
throw this.toFirebaseError(err);
112+
});
113+
}
114+
115+
private toFirebaseError(err: HttpError): PrefixedFirebaseError {
116+
if (err instanceof PrefixedFirebaseError) {
117+
return err;
118+
}
119+
120+
const response = err.response;
121+
if (!response.isJson()) {
122+
return new FirebaseMachineLearningError(
123+
'unknown-error',
124+
`Unexpected response with status: ${response.status} and body: ${response.text}`);
125+
}
126+
127+
const error: Error = (response.data as ErrorResponse).error || {};
128+
let code: MachineLearningErrorCode = 'unknown-error';
129+
if (error.status && error.status in ERROR_CODE_MAPPING) {
130+
code = ERROR_CODE_MAPPING[error.status];
131+
}
132+
const message = error.message || `Unknown server error: ${response.text}`;
133+
return new FirebaseMachineLearningError(code, message);
134+
}
135+
136+
private getUrl(): Promise<string> {
137+
return this.getProjectIdPrefix()
138+
.then((projectIdPrefix) => {
139+
return `${ML_V1BETA1_API}/${this.projectIdPrefix}`;
140+
});
141+
}
142+
143+
private getProjectIdPrefix(): Promise<string> {
144+
if (this.projectIdPrefix) {
145+
return Promise.resolve(this.projectIdPrefix);
146+
}
147+
148+
return utils.findProjectId(this.app)
149+
.then((projectId) => {
150+
if (!validator.isNonEmptyString(projectId)) {
151+
throw new FirebaseMachineLearningError(
152+
'invalid-argument',
153+
'Failed to determine project ID. Initialize the SDK with service account credentials, or '
154+
+ 'set project ID as an app option. Alternatively, set the GOOGLE_CLOUD_PROJECT '
155+
+ 'environment variable.');
156+
}
157+
158+
this.projectIdPrefix = `projects/${projectId}`;
159+
return this.projectIdPrefix;
160+
});
161+
}
162+
163+
private getModelName(modelId: string): string {
164+
if (!validator.isNonEmptyString(modelId)) {
165+
throw new FirebaseMachineLearningError(
166+
'invalid-argument', 'Model ID must be a non-empty string.');
167+
}
168+
169+
if (modelId.indexOf('/') !== -1) {
170+
throw new FirebaseMachineLearningError(
171+
'invalid-argument', 'Model ID must not contain any "/" characters.');
172+
}
173+
174+
return `models/${modelId}`;
175+
}
176+
}
177+
178+
interface ErrorResponse {
179+
error?: Error;
180+
}
181+
182+
interface Error {
183+
code?: number;
184+
message?: string;
185+
status?: string;
186+
}
187+
188+
const ERROR_CODE_MAPPING: {[key: string]: MachineLearningErrorCode} = {
189+
INVALID_ARGUMENT: 'invalid-argument',
190+
NOT_FOUND: 'not-found',
191+
RESOURCE_EXHAUSTED: 'resource-exhausted',
192+
UNAUTHENTICATED: 'authentication-error',
193+
UNKNOWN: 'unknown-error',
194+
};
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/*!
2+
* Copyright 2020 Google Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { PrefixedFirebaseError } from '../utils/error';
18+
19+
export type MachineLearningErrorCode =
20+
'already-exists'
21+
| 'authentication-error'
22+
| 'internal-error'
23+
| 'invalid-argument'
24+
| 'invalid-server-response'
25+
| 'not-found'
26+
| 'resource-exhausted'
27+
| 'service-unavailable'
28+
| 'unknown-error';
29+
30+
export class FirebaseMachineLearningError extends PrefixedFirebaseError {
31+
constructor(code: MachineLearningErrorCode, message: string) {
32+
super('machine-learning', code, message);
33+
}
34+
}

src/machine-learning/machine-learning.ts

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,11 @@
1717

1818
import {FirebaseApp} from '../firebase-app';
1919
import {FirebaseServiceInterface, FirebaseServiceInternalsInterface} from '../firebase-service';
20+
import {MachineLearningApiClient, ModelResponse} from './machine-learning-api-client';
2021
import {FirebaseError} from '../utils/error';
2122

2223
import * as validator from '../utils/validator';
24+
import {FirebaseMachineLearningError} from './machine-learning-utils';
2325

2426
// const ML_HOST = 'mlkit.googleapis.com';
2527

@@ -58,6 +60,7 @@ export interface ListModelsResult {
5860
export class MachineLearning implements FirebaseServiceInterface {
5961
public readonly INTERNAL = new MachineLearningInternals();
6062

63+
private readonly client: MachineLearningApiClient;
6164
private readonly appInternal: FirebaseApp;
6265

6366
/**
@@ -68,12 +71,13 @@ export class MachineLearning implements FirebaseServiceInterface {
6871
if (!validator.isNonNullObject(app) || !('options' in app)) {
6972
throw new FirebaseError({
7073
code: 'machine-learning/invalid-argument',
71-
message: 'First argument passed to admin.MachineLearning() must be a ' +
74+
message: 'First argument passed to admin.machineLearning() must be a ' +
7275
'valid Firebase app instance.',
7376
});
7477
}
7578

7679
this.appInternal = app;
80+
this.client = new MachineLearningApiClient(app);
7781
}
7882

7983
/**
@@ -138,7 +142,10 @@ export class MachineLearning implements FirebaseServiceInterface {
138142
* @return {Promise<Model>} A Promise fulfilled with the unpublished model.
139143
*/
140144
public getModel(modelId: string): Promise<Model> {
141-
throw new Error('NotImplemented');
145+
return this.client.getModel(modelId)
146+
.then((modelResponse) => {
147+
return new Model(modelResponse);
148+
});
142149
}
143150

144151
/**
@@ -172,14 +179,48 @@ export class Model {
172179
public readonly modelId: string;
173180
public readonly displayName: string;
174181
public readonly tags?: string[];
175-
public readonly createTime: number;
176-
public readonly updateTime: number;
182+
public readonly createTime: string;
183+
public readonly updateTime: string;
177184
public readonly validationError?: string;
178185
public readonly published: boolean;
179186
public readonly etag: string;
180-
public readonly modelHash: string;
187+
public readonly modelHash?: string;
188+
189+
public readonly tfliteModel?: TFLiteModel;
190+
191+
constructor(model: ModelResponse) {
192+
if (!validator.isNonNullObject(model) ||
193+
!validator.isNonEmptyString(model.name) ||
194+
!validator.isNonEmptyString(model.createTime) ||
195+
!validator.isNonEmptyString(model.updateTime) ||
196+
!validator.isNonEmptyString(model.displayName) ||
197+
!validator.isNonEmptyString(model.etag)) {
198+
throw new FirebaseMachineLearningError(
199+
'invalid-argument',
200+
`Invalid Model response: ${JSON.stringify(model)}`);
201+
}
202+
203+
this.modelId = extractModelId(model.name);
204+
this.displayName = model.displayName;
205+
this.tags = model.tags || [];
206+
this.createTime = new Date(model.createTime).toUTCString();
207+
this.updateTime = new Date(model.updateTime).toUTCString();
208+
if (model.state?.validationError?.message) {
209+
this.validationError = model.state?.validationError?.message;
210+
}
211+
this.published = model.state?.published || false;
212+
this.etag = model.etag;
213+
if (model.modelHash) {
214+
this.modelHash = model.modelHash;
215+
}
216+
if (model.tfliteModel) {
217+
this.tfliteModel = {
218+
gcsTfliteUri: model.tfliteModel.gcsTfliteUri,
219+
sizeBytes: model.tfliteModel.sizeBytes,
220+
};
221+
}
181222

182-
public readonly tfLiteModel?: TFLiteModel;
223+
}
183224

184225
public get locked(): boolean {
185226
// Backend does not currently return locked models.
@@ -211,9 +252,13 @@ export class ModelOptions {
211252
public displayName?: string;
212253
public tags?: string[];
213254

214-
public tfLiteModel?: { gcsTFLiteUri: string; };
255+
public tfliteModel?: { gcsTFLiteUri: string; };
215256

216257
protected toJSON(forUpload?: boolean): object {
217258
throw new Error('NotImplemented');
218259
}
219260
}
261+
262+
function extractModelId(resourceName: string): string {
263+
return resourceName.split('/').pop()!;
264+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/*!
2+
* Copyright 2020 Google Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import * as admin from '../../lib/index';
18+
19+
describe('admin.machineLearning', () => {
20+
describe('getModel()', () => {
21+
it('rejects with not-found when the Model does not exist', () => {
22+
const nonExistingName = '00000000';
23+
return admin.machineLearning().getModel(nonExistingName)
24+
.should.eventually.be.rejected.and.have.property(
25+
'code', 'machine-learning/not-found');
26+
});
27+
28+
it('rejects with invalid-argument when the ModelId is invalid', () => {
29+
return admin.machineLearning().getModel('invalid-model-id')
30+
.should.eventually.be.rejected.and.have.property(
31+
'code', 'machine-learning/invalid-argument');
32+
});
33+
});
34+
});

0 commit comments

Comments
 (0)