Skip to content

Commit eabe9ba

Browse files
authored
Added ListModels functionality for Firebase ML (#795)
* Added ListModels functionality for Firebase ML
1 parent 550ac02 commit eabe9ba

File tree

7 files changed

+502
-147
lines changed

7 files changed

+502
-147
lines changed

src/index.d.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5207,8 +5207,8 @@ declare namespace admin.machineLearning {
52075207
/**
52085208
* Interface representing options for listing Models.
52095209
*/
5210-
interface ListModelOptions {
5211-
listFilter?: string;
5210+
interface ListModelsOptions {
5211+
filter?: string;
52125212
pageSize?: number;
52135213
pageToken?: string;
52145214
}
@@ -5322,7 +5322,7 @@ declare namespace admin.machineLearning {
53225322
* token. For the last page, an empty list of models and no page token
53235323
* are returned.
53245324
*/
5325-
listModels(options: ListModelOptions): Promise<ListModelsResult>;
5325+
listModels(options?: ListModelsOptions): Promise<ListModelsResult>;
53265326

53275327
/**
53285328
* Deletes a model from Firebase ML.

src/machine-learning/machine-learning-api-client.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,13 @@ export interface ModelUpdateOptions extends ModelOptions {
4545
state?: { published?: boolean; };
4646
}
4747

48+
/** Interface representing listModels options. */
49+
export interface ListModelsOptions {
50+
filter?: string;
51+
pageSize?: number;
52+
pageToken?: string;
53+
}
54+
4855
export interface ModelContent {
4956
readonly displayName?: string;
5057
readonly tags?: string[];
@@ -66,6 +73,11 @@ export interface ModelResponse extends ModelContent {
6673
readonly modelHash?: string;
6774
}
6875

76+
export interface ListModelsResponse {
77+
readonly models?: ModelResponse[];
78+
readonly nextPageToken?: string;
79+
}
80+
6981
export interface OperationResponse {
7082
readonly name?: string;
7183
readonly done: boolean;
@@ -140,6 +152,42 @@ export class MachineLearningApiClient {
140152
});
141153
}
142154

155+
public listModels(options: ListModelsOptions = {}): Promise<ListModelsResponse> {
156+
if (!validator.isNonNullObject(options)) {
157+
const err = new FirebaseMachineLearningError('invalid-argument', 'Invalid ListModelsOptions');
158+
return Promise.reject(err);
159+
}
160+
if (typeof options.filter !== 'undefined' && !validator.isNonEmptyString(options.filter)) {
161+
const err = new FirebaseMachineLearningError('invalid-argument', 'Invalid list filter.');
162+
return Promise.reject(err);
163+
}
164+
if (typeof options.pageSize !== 'undefined') {
165+
if (!validator.isNumber(options.pageSize)) {
166+
const err = new FirebaseMachineLearningError('invalid-argument', 'Invalid page size.');
167+
return Promise.reject(err);
168+
}
169+
if (options.pageSize < 1 || options.pageSize > 100) {
170+
const err = new FirebaseMachineLearningError(
171+
'invalid-argument', 'Page size must be between 1 and 100.');
172+
return Promise.reject(err);
173+
}
174+
}
175+
if (typeof options.pageToken !== 'undefined' && !validator.isNonEmptyString(options.pageToken)) {
176+
const err = new FirebaseMachineLearningError(
177+
'invalid-argument', 'Next page token must be a non-empty string.');
178+
return Promise.reject(err);
179+
}
180+
return this.getUrl()
181+
.then((url) => {
182+
const request: HttpRequestConfig = {
183+
method: 'GET',
184+
url: `${url}/models`,
185+
data: options,
186+
};
187+
return this.sendRequest<ListModelsResponse>(request);
188+
});
189+
}
190+
143191
public deleteModel(modelId: string): Promise<void> {
144192
return this.getUrl()
145193
.then((url) => {

src/machine-learning/machine-learning.ts

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import {FirebaseApp} from '../firebase-app';
1818
import {FirebaseServiceInterface, FirebaseServiceInternalsInterface} from '../firebase-service';
1919
import {MachineLearningApiClient, ModelResponse, OperationResponse,
20-
ModelOptions, ModelUpdateOptions} from './machine-learning-api-client';
20+
ModelOptions, ModelUpdateOptions, ListModelsOptions} from './machine-learning-api-client';
2121
import {FirebaseError} from '../utils/error';
2222

2323
import * as validator from '../utils/validator';
@@ -41,13 +41,6 @@ class MachineLearningInternals implements FirebaseServiceInternalsInterface {
4141
}
4242
}
4343

44-
/** Interface representing listModels options. */
45-
export interface ListModelsOptions {
46-
listFilter?: string;
47-
pageSize?: number;
48-
pageToken?: string;
49-
}
50-
5144
/** Response object for a listModels operation. */
5245
export interface ListModelsResult {
5346
models: Model[];
@@ -161,8 +154,24 @@ export class MachineLearning implements FirebaseServiceInterface {
161154
* token. For the last page, an empty list of models and no page token are
162155
* returned.
163156
*/
164-
public listModels(options: ListModelsOptions): Promise<ListModelsResult> {
165-
throw new Error('NotImplemented');
157+
public listModels(options: ListModelsOptions = {}): Promise<ListModelsResult> {
158+
return this.client.listModels(options)
159+
.then((resp) => {
160+
if (!validator.isNonNullObject(resp)) {
161+
throw new FirebaseMachineLearningError(
162+
'invalid-argument',
163+
`Invalid ListModels response: ${JSON.stringify(resp)}`);
164+
}
165+
let models: Model[] = [];
166+
if (resp.models) {
167+
models = resp.models.map((rs) => new Model(rs));
168+
}
169+
const result: ListModelsResult = {models};
170+
if (resp.nextPageToken) {
171+
result.pageToken = resp.nextPageToken;
172+
}
173+
return result;
174+
});
166175
}
167176

168177
/**
@@ -268,7 +277,6 @@ export class Model {
268277
sizeBytes: model.tfliteModel.sizeBytes,
269278
};
270279
}
271-
272280
}
273281

274282
public get locked(): boolean {

test/integration/machine-learning.spec.ts

Lines changed: 116 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -171,9 +171,7 @@ describe('admin.machineLearning', () => {
171171
});
172172

173173
it('sets tags for a model', () => {
174-
// TODO(ifielker): Uncomment & replace when BE change lands.
175-
// const ORIGINAL_TAGS = ['tag-node-update-1'];
176-
const ORIGINAL_TAGS: string[] = [];
174+
const ORIGINAL_TAGS = ['tag-node-update-1'];
177175
const NEW_TAGS = ['tag-node-update-2', 'tag-node-update-3'];
178176

179177
return createTemporaryModel({
@@ -192,7 +190,7 @@ describe('admin.machineLearning', () => {
192190
});
193191

194192
it('updates the tflite file', () => {
195-
Promise.all([
193+
return Promise.all([
196194
createTemporaryModel(),
197195
uploadModelToGcs('model1.tflite', 'valid_model.tflite')])
198196
.then(([model, fileName]) => {
@@ -324,6 +322,120 @@ describe('admin.machineLearning', () => {
324322
});
325323
});
326324

325+
describe('listModels()', () => {
326+
let model1: admin.machineLearning.Model;
327+
let model2: admin.machineLearning.Model;
328+
let model3: admin.machineLearning.Model;
329+
330+
before(() => {
331+
return Promise.all([
332+
admin.machineLearning().createModel({
333+
displayName: 'node-integration-list1',
334+
tags: ['node-integration-tag-1'],
335+
}),
336+
admin.machineLearning().createModel({
337+
displayName: 'node-integration-list2',
338+
tags: ['node-integration-tag-1'],
339+
}),
340+
admin.machineLearning().createModel({
341+
displayName: 'node-integration-list3',
342+
tags: ['node-integration-tag-1'],
343+
})])
344+
.then(([m1, m2, m3]: admin.machineLearning.Model[]) => {
345+
model1 = m1;
346+
model2 = m2;
347+
model3 = m3;
348+
});
349+
});
350+
351+
after(() => {
352+
return Promise.all([
353+
admin.machineLearning().deleteModel(model1.modelId),
354+
admin.machineLearning().deleteModel(model2.modelId),
355+
admin.machineLearning().deleteModel(model3.modelId),
356+
]);
357+
});
358+
359+
it('resolves with a list of models', () => {
360+
return admin.machineLearning().listModels({pageSize: 100})
361+
.then((modelList) => {
362+
expect(modelList.models.length).to.be.at.least(2);
363+
expect(modelList.models).to.deep.include(model1);
364+
expect(modelList.models).to.deep.include(model2);
365+
expect(modelList.pageToken).to.be.empty;
366+
});
367+
});
368+
369+
it('respects page size', () => {
370+
return admin.machineLearning().listModels({pageSize: 2})
371+
.then((modelList) => {
372+
expect(modelList.models.length).to.equal(2);
373+
expect(modelList.pageToken).not.to.be.empty;
374+
});
375+
});
376+
377+
it('filters by exact displayName', () => {
378+
return admin.machineLearning().listModels({filter: 'displayName=node-integration-list1'})
379+
.then((modelList) => {
380+
expect(modelList.models.length).to.equal(1);
381+
expect(modelList.models[0]).to.deep.equal(model1);
382+
expect(modelList.pageToken).to.be.empty;
383+
});
384+
});
385+
386+
it('filters by displayName prefix', () => {
387+
return admin.machineLearning().listModels({filter: 'displayName:node-integration-list*', pageSize: 100})
388+
.then((modelList) => {
389+
expect(modelList.models.length).to.be.at.least(3);
390+
expect(modelList.models).to.deep.include(model1);
391+
expect(modelList.models).to.deep.include(model2);
392+
expect(modelList.models).to.deep.include(model3);
393+
expect(modelList.pageToken).to.be.empty;
394+
});
395+
});
396+
397+
it('filters by tag', () => {
398+
return admin.machineLearning().listModels({filter: 'tags:node-integration-tag-1', pageSize: 100})
399+
.then((modelList) => {
400+
expect(modelList.models.length).to.be.at.least(3);
401+
expect(modelList.models).to.deep.include(model1);
402+
expect(modelList.models).to.deep.include(model2);
403+
expect(modelList.models).to.deep.include(model3);
404+
expect(modelList.pageToken).to.be.empty;
405+
});
406+
});
407+
408+
it('handles pageTokens properly', () => {
409+
return admin.machineLearning().listModels({filter: 'displayName:node-integration-list*', pageSize: 2})
410+
.then((modelList) => {
411+
expect(modelList.models.length).to.equal(2);
412+
expect(modelList.pageToken).not.to.be.empty;
413+
return admin.machineLearning().listModels({
414+
filter: 'displayName:node-integration-list*',
415+
pageSize: 2,
416+
pageToken: modelList.pageToken})
417+
.then((modelList2) => {
418+
expect(modelList2.models.length).to.be.at.least(1);
419+
expect(modelList2.pageToken).to.be.empty;
420+
});
421+
});
422+
});
423+
424+
it('successfully returns an empty list of models', () => {
425+
return admin.machineLearning().listModels({filter: 'displayName=non-existing-model'})
426+
.then((modelList) => {
427+
expect(modelList.models.length).to.equal(0);
428+
expect(modelList.pageToken).to.be.empty;
429+
});
430+
});
431+
432+
it('rejects with invalid argument if the filter is invalid', () => {
433+
return admin.machineLearning().listModels({filter: 'invalidFilterItem=foo'})
434+
.should.eventually.be.rejected.and.have.property(
435+
'code', 'machine-learning/invalid-argument');
436+
});
437+
});
438+
327439
describe('deleteModel()', () => {
328440
it('rejects with not-found when the Model does not exist', () => {
329441
const nonExistingName = '00000000';

0 commit comments

Comments
 (0)