chore: tree shake unused API methods from CLI (#6973)

This commit is contained in:
Ben McCann
2024-02-09 12:53:37 -08:00
committed by GitHub
parent 954c1c2ef4
commit aff71a10e5
200 changed files with 3337 additions and 22416 deletions

4
cli/package-lock.json generated
View File

@@ -33,7 +33,7 @@
"mock-fs": "^5.2.0",
"typescript": "^5.3.3",
"vite": "^5.0.12",
"vitest": "^1.2.1",
"vitest": "^1.2.2",
"yaml": "^2.3.1"
},
"engines": {
@@ -47,6 +47,7 @@
"license": "MIT",
"devDependencies": {
"@types/node": "^20.11.0",
"oazapfts": "^5.1.4",
"typescript": "^5.3.3"
},
"peerDependencies": {
@@ -6112,6 +6113,7 @@
"version": "file:../open-api/typescript-sdk",
"requires": {
"@types/node": "^20.11.0",
"oazapfts": "^5.1.4",
"typescript": "^5.3.3"
}
},

View File

@@ -34,7 +34,7 @@
"mock-fs": "^5.2.0",
"typescript": "^5.3.3",
"vite": "^5.0.12",
"vitest": "^1.2.1",
"vitest": "^1.2.2",
"yaml": "^2.3.1"
},
"scripts": {

View File

@@ -1,10 +1,9 @@
import { ServerVersionResponseDto, UserResponseDto } from '@immich/sdk';
import { ImmichApi } from '../services/api.service';
import { SessionService } from '../services/session.service';
import { ImmichApi } from 'src/services/api.service';
export abstract class BaseCommand {
protected sessionService!: SessionService;
protected immichApi!: ImmichApi;
protected user!: UserResponseDto;
protected serverVersion!: ServerVersionResponseDto;
@@ -15,7 +14,7 @@ export abstract class BaseCommand {
this.sessionService = new SessionService(options.configDirectory);
}
public async connect(): Promise<void> {
this.immichApi = await this.sessionService.connect();
public async connect(): Promise<ImmichApi> {
return await this.sessionService.connect();
}
}

View File

@@ -2,10 +2,10 @@ import { BaseCommand } from './base-command';
export class ServerInfoCommand extends BaseCommand {
public async run() {
await this.connect();
const versionInfo = await this.immichApi.serverInfoApi.getServerVersion();
const mediaTypes = await this.immichApi.serverInfoApi.getSupportedMediaTypes();
const statistics = await this.immichApi.assetApi.getAssetStatistics();
const api = await this.connect();
const versionInfo = await api.getServerVersion();
const mediaTypes = await api.getSupportedMediaTypes();
const statistics = await api.getAssetStatistics();
console.log(`Server Version: ${versionInfo.major}.${versionInfo.minor}.${versionInfo.patch}`);
console.log(`Image Types: ${mediaTypes.image.map((extension) => extension.replace('.', ''))}`);

View File

@@ -7,7 +7,7 @@ import { basename } from 'node:path';
import { access, constants, stat, unlink } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import os from 'node:os';
import { UploadFileRequest } from '@immich/sdk';
import { ImmichApi } from 'src/services/api.service';
class Asset {
readonly path: string;
@@ -33,7 +33,7 @@ class Asset {
this.albumName = this.extractAlbumName();
}
async getUploadFileRequest(): Promise<UploadFileRequest> {
async getUploadFormData(): Promise<FormData> {
if (!this.deviceAssetId) {
throw new Error('Device asset id not set');
}
@@ -52,15 +52,25 @@ class Asset {
sidecarData = new File([await fs.openAsBlob(sideCarPath)], basename(sideCarPath));
} catch {}
return {
const data: any = {
assetData: new File([await fs.openAsBlob(this.path)], basename(this.path)),
deviceAssetId: this.deviceAssetId,
deviceId: 'CLI',
fileCreatedAt: this.fileCreatedAt,
fileModifiedAt: this.fileModifiedAt,
isFavorite: false,
sidecarData,
isFavorite: String(false),
};
const formData = new FormData();
for (const property in data) {
formData.append(property, data[property]);
}
if (sidecarData) {
formData.append('sidecarData', sidecarData);
}
return formData;
}
async delete(): Promise<void> {
@@ -101,9 +111,9 @@ export class UploadCommand extends BaseCommand {
uploadLength!: number;
public async run(paths: string[], options: UploadOptionsDto): Promise<void> {
await this.connect();
const api = await this.connect();
const formatResponse = await this.immichApi.serverInfoApi.getSupportedMediaTypes();
const formatResponse = await api.getSupportedMediaTypes();
const crawlService = new CrawlService(formatResponse.image, formatResponse.video);
const inputFiles: string[] = [];
@@ -153,7 +163,7 @@ export class UploadCommand extends BaseCommand {
}
}
const existingAlbums = await this.immichApi.albumApi.getAllAlbums();
const existingAlbums = await api.getAllAlbums();
uploadProgress.start(totalSize, 0);
uploadProgress.update({ value_formatted: 0, total_formatted: byteSize(totalSize) });
@@ -172,9 +182,7 @@ export class UploadCommand extends BaseCommand {
if (!options.skipHash) {
const assetBulkUploadCheckDto = { assets: [{ id: asset.path, checksum: await asset.hash() }] };
const checkResponse = await this.immichApi.assetApi.checkBulkUpload({
assetBulkUploadCheckDto,
});
const checkResponse = await api.checkBulkUpload(assetBulkUploadCheckDto);
skipUpload = checkResponse.results[0].action === 'reject';
@@ -188,9 +196,10 @@ export class UploadCommand extends BaseCommand {
if (!skipAsset && !options.dryRun) {
if (!skipUpload) {
const fileRequest = await asset.getUploadFileRequest();
const response = await this.immichApi.assetApi.uploadFile(fileRequest);
existingAssetId = response.id;
const formData = await asset.getUploadFormData();
const response = await this.uploadAsset(api, formData);
const json = await response.json();
existingAssetId = json.id;
uploadCounter++;
totalSizeUploaded += asset.fileSize;
}
@@ -198,17 +207,14 @@ export class UploadCommand extends BaseCommand {
if ((options.album || options.albumName) && asset.albumName !== undefined) {
let album = existingAlbums.find((album) => album.albumName === asset.albumName);
if (!album) {
const response = await this.immichApi.albumApi.createAlbum({
createAlbumDto: { albumName: asset.albumName },
});
const response = await api.createAlbum({ albumName: asset.albumName });
album = response;
existingAlbums.push(album);
}
if (existingAssetId) {
await this.immichApi.albumApi.addAssetsToAlbum({
id: album.id,
bulkIdsDto: { ids: [existingAssetId] },
await api.addAssetsToAlbum(album.id, {
ids: [existingAssetId],
});
}
}
@@ -248,4 +254,21 @@ export class UploadCommand extends BaseCommand {
}
}
}
private async uploadAsset(api: ImmichApi, data: FormData): Promise<Response> {
const url = api.instanceUrl + '/asset/upload';
const response = await fetch(url, {
method: 'post',
redirect: 'error',
headers: {
'x-api-key': api.apiKey,
},
body: data,
});
if (response.status !== 200 && response.status !== 201) {
throw new Error(await response.text());
}
return response;
}
}

View File

@@ -1,56 +1,106 @@
import {
AlbumApi,
APIKeyApi,
AssetApi,
AuthenticationApi,
Configuration,
JobApi,
OAuthApi,
ServerInfoApi,
SystemConfigApi,
UserApi,
addAssetsToAlbum,
checkBulkUpload,
createAlbum,
createApiKey,
getAllAlbums,
getAllAssets,
getAssetStatistics,
getMyUserInfo,
getServerVersion,
getSupportedMediaTypes,
login,
pingServer,
signUpAdmin,
uploadFile,
ApiKeyCreateDto,
AssetBulkUploadCheckDto,
BulkIdsDto,
CreateAlbumDto,
CreateAssetDto,
LoginCredentialDto,
SignUpDto,
} from '@immich/sdk';
/**
* Wraps the underlying API to abstract away the options and make API calls mockable for testing.
*/
export class ImmichApi {
public userApi: UserApi;
public albumApi: AlbumApi;
public assetApi: AssetApi;
public authenticationApi: AuthenticationApi;
public oauthApi: OAuthApi;
public serverInfoApi: ServerInfoApi;
public jobApi: JobApi;
public keyApi: APIKeyApi;
public systemConfigApi: SystemConfigApi;
private readonly config;
private readonly options;
constructor(
public instanceUrl: string,
public apiKey: string,
) {
this.config = new Configuration({
basePath: instanceUrl,
this.options = {
baseUrl: instanceUrl,
headers: {
'x-api-key': apiKey,
},
});
this.userApi = new UserApi(this.config);
this.albumApi = new AlbumApi(this.config);
this.assetApi = new AssetApi(this.config);
this.authenticationApi = new AuthenticationApi(this.config);
this.oauthApi = new OAuthApi(this.config);
this.serverInfoApi = new ServerInfoApi(this.config);
this.jobApi = new JobApi(this.config);
this.keyApi = new APIKeyApi(this.config);
this.systemConfigApi = new SystemConfigApi(this.config);
};
}
setApiKey(apiKey: string) {
this.apiKey = apiKey;
if (!this.config.headers) {
if (!this.options.headers) {
throw new Error('missing headers');
}
this.config.headers['x-api-key'] = apiKey;
this.options.headers['x-api-key'] = apiKey;
}
async addAssetsToAlbum(id: string, bulkIdsDto: BulkIdsDto) {
return await addAssetsToAlbum({ id, bulkIdsDto }, this.options);
}
async checkBulkUpload(assetBulkUploadCheckDto: AssetBulkUploadCheckDto) {
return await checkBulkUpload({ assetBulkUploadCheckDto }, this.options);
}
async createAlbum(createAlbumDto: CreateAlbumDto) {
return await createAlbum({ createAlbumDto }, this.options);
}
async createApiKey(apiKeyCreateDto: ApiKeyCreateDto, options: { headers: { Authorization: string } }) {
return await createApiKey({ apiKeyCreateDto }, { ...this.options, ...options });
}
async getAllAlbums() {
return await getAllAlbums({}, this.options);
}
async getAllAssets() {
return await getAllAssets({}, this.options);
}
async getAssetStatistics() {
return await getAssetStatistics({}, this.options);
}
async getMyUserInfo() {
return await getMyUserInfo(this.options);
}
async getServerVersion() {
return await getServerVersion(this.options);
}
async getSupportedMediaTypes() {
return await getSupportedMediaTypes(this.options);
}
async login(loginCredentialDto: LoginCredentialDto) {
return await login({ loginCredentialDto }, this.options);
}
async pingServer() {
return await pingServer(this.options);
}
async signUpAdmin(signUpDto: SignUpDto) {
return await signUpAdmin({ signUpDto }, this.options);
}
async uploadFile(createAssetDto: CreateAssetDto) {
return await uploadFile({ createAssetDto }, this.options);
}
}

View File

@@ -12,18 +12,20 @@ import {
spyOnConsole,
} from '../../test/cli-test-utils';
const mockPingServer = vi.fn(() => Promise.resolve({ res: 'pong' }));
const mockUserInfo = vi.fn(() => Promise.resolve({ email: 'admin@example.com' }));
const mocks = vi.hoisted(() => {
return {
getMyUserInfo: vi.fn(() => Promise.resolve({ email: 'admin@example.com' })),
pingServer: vi.fn(() => Promise.resolve({ res: 'pong' })),
};
});
vi.mock('@immich/sdk', async () => ({
...(await vi.importActual('@immich/sdk')),
UserApi: vi.fn().mockImplementation(() => {
return { getMyUserInfo: mockUserInfo };
}),
ServerInfoApi: vi.fn().mockImplementation(() => {
return { pingServer: mockPingServer };
}),
}));
vi.mock('./api.service', async (importOriginal) => {
const module = await importOriginal<typeof import('./api.service')>();
// @ts-expect-error this is only a partial implementation of the return value
module.ImmichApi.prototype.getMyUserInfo = mocks.getMyUserInfo;
module.ImmichApi.prototype.pingServer = mocks.pingServer;
return module;
});
describe('SessionService', () => {
let sessionService: SessionService;
@@ -46,7 +48,7 @@ describe('SessionService', () => {
);
await sessionService.connect();
expect(mockPingServer).toHaveBeenCalledTimes(1);
expect(mocks.pingServer).toHaveBeenCalledTimes(1);
});
it('should error if no auth file exists', async () => {

View File

@@ -3,7 +3,6 @@ import { access, constants, mkdir, readFile, unlink, writeFile } from 'node:fs/p
import path from 'node:path';
import yaml from 'yaml';
import { ImmichApi } from './api.service';
class LoginError extends Error {
constructor(message: string) {
super(message);
@@ -51,12 +50,12 @@ export class SessionService {
const api = new ImmichApi(instanceUrl, apiKey);
const pingResponse = await api.serverInfoApi.pingServer().catch((error) => {
throw new Error(`Failed to connect to server ${api.instanceUrl}: ${error.message}`);
const pingResponse = await api.pingServer().catch((error) => {
throw new Error(`Failed to connect to server ${instanceUrl}: ${error.message}`, error);
});
if (pingResponse.res !== 'pong') {
throw new Error(`Could not parse response. Is Immich listening on ${api.instanceUrl}?`);
throw new Error(`Could not parse response. Is Immich listening on ${instanceUrl}?`);
}
return api;
@@ -68,7 +67,7 @@ export class SessionService {
const api = new ImmichApi(instanceUrl, apiKey);
// Check if server and api key are valid
const userInfo = await api.userApi.getMyUserInfo().catch((error) => {
const userInfo = await api.getMyUserInfo().catch((error) => {
throw new LoginError(`Failed to connect to server ${instanceUrl}: ${error.message}`);
});

View File

@@ -1,6 +1,6 @@
import fs from 'node:fs';
import path from 'node:path';
import { ImmichApi } from '../src/services/api.service';
import { ImmichApi } from 'src/services/api.service';
export const TEST_CONFIG_DIR = '/tmp/immich/';
export const TEST_AUTH_FILE = path.join(TEST_CONFIG_DIR, 'auth.yml');
@@ -11,14 +11,10 @@ export const CLI_BASE_OPTIONS = { configDirectory: TEST_CONFIG_DIR };
export const setup = async () => {
const api = new ImmichApi(process.env.IMMICH_INSTANCE_URL as string, '');
await api.authenticationApi.signUpAdmin({
signUpDto: { email: 'cli@immich.app', password: 'password', name: 'Administrator' },
});
const admin = await api.authenticationApi.login({
loginCredentialDto: { email: 'cli@immich.app', password: 'password' },
});
const apiKey = await api.keyApi.createApiKey(
{ aPIKeyCreateDto: { name: 'CLI Test' } },
await api.signUpAdmin({ email: 'cli@immich.app', password: 'password', name: 'Administrator' });
const admin = await api.login({ email: 'cli@immich.app', password: 'password' });
const apiKey = await api.createApiKey(
{ name: 'CLI Test' },
{ headers: { Authorization: `Bearer ${admin.accessToken}` } },
);

View File

@@ -37,7 +37,7 @@ describe(`login-key (e2e)`, () => {
it('should error when providing an invalid API key', async () => {
await expect(new LoginCommand(CLI_BASE_OPTIONS).run(instanceUrl, 'invalid')).rejects.toThrow(
`Failed to connect to server ${instanceUrl}: Response returned an error code`,
`Failed to connect to server ${instanceUrl}: Error: 401`,
);
});

View File

@@ -1,7 +1,7 @@
import { IMMICH_TEST_ASSET_PATH, restoreTempFolder, testApp } from '@test-utils';
import { CLI_BASE_OPTIONS, setup, spyOnConsole } from 'test/cli-test-utils';
import { UploadCommand } from '../../src/commands/upload.command';
import { ImmichApi } from '../../src/services/api.service';
import { ImmichApi } from 'src/services/api.service';
describe(`upload (e2e)`, () => {
let api: ImmichApi;
@@ -26,13 +26,13 @@ describe(`upload (e2e)`, () => {
it('should upload a folder recursively', async () => {
await new UploadCommand(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], { recursive: true });
const assets = await api.assetApi.getAllAssets({}, { headers: { 'x-api-key': api.apiKey } });
const assets = await api.getAllAssets();
expect(assets.length).toBeGreaterThan(4);
});
it('should not create a new album', async () => {
await new UploadCommand(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], { recursive: true });
const albums = await api.albumApi.getAllAlbums({}, { headers: { 'x-api-key': api.apiKey } });
const albums = await api.getAllAlbums();
expect(albums.length).toEqual(0);
});
@@ -42,7 +42,7 @@ describe(`upload (e2e)`, () => {
album: true,
});
const albums = await api.albumApi.getAllAlbums({}, { headers: { 'x-api-key': api.apiKey } });
const albums = await api.getAllAlbums();
expect(albums.length).toEqual(1);
const natureAlbum = albums[0];
expect(natureAlbum.albumName).toEqual('nature');
@@ -59,7 +59,7 @@ describe(`upload (e2e)`, () => {
album: true,
});
const albums = await api.albumApi.getAllAlbums({}, { headers: { 'x-api-key': api.apiKey } });
const albums = await api.getAllAlbums();
expect(albums.length).toEqual(1);
const natureAlbum = albums[0];
expect(natureAlbum.albumName).toEqual('nature');
@@ -71,7 +71,7 @@ describe(`upload (e2e)`, () => {
albumName: 'testAlbum',
});
const albums = await api.albumApi.getAllAlbums({}, { headers: { 'x-api-key': api.apiKey } });
const albums = await api.getAllAlbums();
expect(albums.length).toEqual(1);
const testAlbum = albums[0];
expect(testAlbum.albumName).toEqual('testAlbum');