From 014d164d9997f9c6663da615ca6be2f99a337909 Mon Sep 17 00:00:00 2001 From: Daniele Ricci Date: Sat, 23 Sep 2023 17:28:55 +0200 Subject: [PATCH] feat(server): random assets API (#4184) * feat(server): get random assets API * Fix tests * Use correct validation annotation * Fix offset use in query * Update API specs * Fix typo * Random assets e2e tests * Improve e2e tests --- cli/src/api/open-api/api.ts | 87 ++++++++++++++++++ mobile/openapi/README.md | Bin 20530 -> 20614 bytes mobile/openapi/doc/AssetApi.md | Bin 58977 -> 60949 bytes mobile/openapi/lib/api/asset_api.dart | Bin 54274 -> 55948 bytes mobile/openapi/test/asset_api_test.dart | Bin 5499 -> 5628 bytes server/immich-openapi-specs.json | 44 +++++++++ server/src/domain/asset/asset.repository.ts | 1 + server/src/domain/asset/asset.service.ts | 5 + server/src/domain/asset/dto/asset.dto.ts | 11 ++- .../immich/controllers/asset.controller.ts | 6 ++ .../infra/repositories/asset.repository.ts | 11 +++ server/test/e2e/asset.e2e-spec.ts | 63 ++++++++++++- .../repositories/asset.repository.mock.ts | 1 + web/src/api/open-api/api.ts | 87 ++++++++++++++++++ 14 files changed, 313 insertions(+), 3 deletions(-) diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index 1d4df828f9..66085a52f0 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -6303,6 +6303,49 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {number} [count] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getRandom: async (count?: number, options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/asset/random`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + if (count !== undefined) { + localVarQueryParameter['count'] = count; + } + + + setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; @@ -7043,6 +7086,16 @@ export const AssetApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.getMemoryLane(timestamp, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {number} [count] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getRandom(count?: number, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getRandom(count, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {TimeBucketSize} size @@ -7318,6 +7371,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath getMemoryLane(requestParameters: AssetApiGetMemoryLaneRequest, options?: AxiosRequestConfig): AxiosPromise> { return localVarFp.getMemoryLane(requestParameters.timestamp, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {AssetApiGetRandomRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getRandom(requestParameters: AssetApiGetRandomRequest = {}, options?: AxiosRequestConfig): AxiosPromise> { + return localVarFp.getRandom(requestParameters.count, options).then((request) => request(axios, basePath)); + }, /** * * @param {AssetApiGetTimeBucketsRequest} requestParameters Request parameters. @@ -7752,6 +7814,20 @@ export interface AssetApiGetMemoryLaneRequest { readonly timestamp: string } +/** + * Request parameters for getRandom operation in AssetApi. + * @export + * @interface AssetApiGetRandomRequest + */ +export interface AssetApiGetRandomRequest { + /** + * + * @type {number} + * @memberof AssetApiGetRandom + */ + readonly count?: number +} + /** * Request parameters for getTimeBuckets operation in AssetApi. * @export @@ -8244,6 +8320,17 @@ export class AssetApi extends BaseAPI { return AssetApiFp(this.configuration).getMemoryLane(requestParameters.timestamp, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {AssetApiGetRandomRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AssetApi + */ + public getRandom(requestParameters: AssetApiGetRandomRequest = {}, options?: AxiosRequestConfig) { + return AssetApiFp(this.configuration).getRandom(requestParameters.count, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {AssetApiGetTimeBucketsRequest} requestParameters Request parameters. diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index e83d475be39ee79b33f0cd4bea30eab01d2af2b8..889f20658d889d9bbedf5ac5f1a78db9a49711fc 100644 GIT binary patch delta 47 rcmdnAfU#{MAkO7lE0Y3N;E^TJEkPT3QPFiN(dKCHfGF&Eag8 z6JY93S;_N2v}z>hm*$mhHi%JX5dkw5Y!xi@3{9*R^z{|;N^>V~T&jnnbn?fnV3;wD qiLLx#O+d>))`HYK9uuA%Cm}j{K@aQXD^`M=U2>N)Zk{mhqCWt#(LtyH delta 19 bcmbPwhxy?d<_(E#n-{a`PuT1-@1j2dT%`#N diff --git a/mobile/openapi/lib/api/asset_api.dart b/mobile/openapi/lib/api/asset_api.dart index ac828f730b910433d8e5e12afa78e79ac196527b..c2c5fd021c5fb4f095735f608321d913bb36f7e5 100644 GIT binary patch delta 200 zcmZo#!Q8Wzc|&R|TTxE98~t+ADw+O`ez+0Mor;zV&1SjUHBr#C{EPUNB2t2cmTI1`kO_knvCj*0Jis i5YtgjY^>5&z+x-J5G$xz2jbo^O_mSk-dxZ+$qxV(WlVDb delta 14 VcmeC#%G|Vqc|&UJ=J(xm{Qxr&2P6Oh diff --git a/mobile/openapi/test/asset_api_test.dart b/mobile/openapi/test/asset_api_test.dart index d374f212ef24a6d60cb74dda3bbbe7e487cb19db..c30050198605ee1e832de8acccb5f98c7da9a687 100644 GIT binary patch delta 54 zcmeyZ^+$U{Ki}jyE-|5?#JrUJT#aglywY5S; getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated; getWith(pagination: PaginationOptions, property: WithProperty, libraryId?: string): Paginated; + getRandom(userId: string, count: number): Promise; getFirstAssetForAlbumId(albumId: string): Promise; getLastUpdatedAssetForAlbumId(albumId: string): Promise; getByLibraryId(libraryIds: string[]): Promise; diff --git a/server/src/domain/asset/asset.service.ts b/server/src/domain/asset/asset.service.ts index 90f8196c9c..c70cbe006b 100644 --- a/server/src/domain/asset/asset.service.ts +++ b/server/src/domain/asset/asset.service.ts @@ -284,6 +284,11 @@ export class AssetService { return mapStats(stats); } + async getRandom(authUser: AuthUserDto, count: number): Promise { + const assets = await this.assetRepository.getRandom(authUser.id, count); + return assets.map((a) => mapAsset(a)); + } + async update(authUser: AuthUserDto, id: string, dto: UpdateAssetDto): Promise { await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, id); diff --git a/server/src/domain/asset/dto/asset.dto.ts b/server/src/domain/asset/dto/asset.dto.ts index f6ba61b20b..c10924ff6c 100644 --- a/server/src/domain/asset/dto/asset.dto.ts +++ b/server/src/domain/asset/dto/asset.dto.ts @@ -1,4 +1,5 @@ -import { IsBoolean, IsString } from 'class-validator'; +import { Type } from 'class-transformer'; +import { IsBoolean, IsInt, IsPositive, IsString } from 'class-validator'; import { Optional } from '../../domain.util'; import { BulkIdsDto } from '../response-dto'; @@ -25,3 +26,11 @@ export class UpdateAssetDto { @IsString() description?: string; } + +export class RandomAssetsDto { + @Optional() + @IsInt() + @IsPositive() + @Type(() => Number) + count?: number; +} diff --git a/server/src/immich/controllers/asset.controller.ts b/server/src/immich/controllers/asset.controller.ts index 2e6bb47742..f3bd9747ed 100644 --- a/server/src/immich/controllers/asset.controller.ts +++ b/server/src/immich/controllers/asset.controller.ts @@ -13,6 +13,7 @@ import { MapMarkerResponseDto, MemoryLaneDto, MemoryLaneResponseDto, + RandomAssetsDto, TimeBucketAssetDto, TimeBucketDto, TimeBucketResponseDto, @@ -41,6 +42,11 @@ export class AssetController { return this.service.getMemoryLane(authUser, dto); } + @Get('random') + getRandom(@AuthUser() authUser: AuthUserDto, @Query() dto: RandomAssetsDto): Promise { + return this.service.getRandom(authUser, dto.count ?? 1); + } + @SharedLinkRoute() @Post('download/info') getDownloadInfo(@AuthUser() authUser: AuthUserDto, @Body() dto: DownloadInfoDto): Promise { diff --git a/server/src/infra/repositories/asset.repository.ts b/server/src/infra/repositories/asset.repository.ts index 1d0044fa62..2d2ea6563f 100644 --- a/server/src/infra/repositories/asset.repository.ts +++ b/server/src/infra/repositories/asset.repository.ts @@ -429,6 +429,17 @@ export class AssetRepository implements IAssetRepository { return result; } + getRandom(ownerId: string, count: number): Promise { + // can't use queryBuilder because of custom OFFSET clause + return this.repository.query( + `SELECT * + FROM assets + WHERE "ownerId" = $1 + OFFSET FLOOR(RANDOM() * (SELECT GREATEST(COUNT(*) - 1, 0) FROM ASSETS)) LIMIT $2`, + [ownerId, count], + ); + } + getTimeBuckets(options: TimeBucketOptions): Promise { const truncateValue = truncateMap[options.size]; diff --git a/server/test/e2e/asset.e2e-spec.ts b/server/test/e2e/asset.e2e-spec.ts index 24939d27b9..8fe2a62e19 100644 --- a/server/test/e2e/asset.e2e-spec.ts +++ b/server/test/e2e/asset.e2e-spec.ts @@ -1,4 +1,11 @@ -import { IAssetRepository, IFaceRepository, IPersonRepository, LoginResponseDto, TimeBucketSize } from '@app/domain'; +import { + AssetResponseDto, + IAssetRepository, + IFaceRepository, + IPersonRepository, + LoginResponseDto, + TimeBucketSize, +} from '@app/domain'; import { AppModule, AssetController } from '@app/immich'; import { AssetEntity, AssetType } from '@app/infra/entities'; import { INestApplication } from '@nestjs/common'; @@ -322,7 +329,7 @@ describe(`${AssetController.name} (e2e)`, () => { }); it('should require authentication', async () => { - const { status, body } = await request(server).get('/album/statistics'); + const { status, body } = await request(server).get('/asset/statistics'); expect(status).toBe(401); expect(body).toEqual(errorStub.unauthorized); @@ -378,6 +385,58 @@ describe(`${AssetController.name} (e2e)`, () => { }); }); + describe('GET /asset/random', () => { + it('should require authentication', async () => { + const { status, body } = await request(server).get('/asset/random'); + + expect(status).toBe(401); + expect(body).toEqual(errorStub.unauthorized); + }); + + it('should return 1 random assets', async () => { + const { status, body } = await request(server) + .get('/asset/random') + .set('Authorization', `Bearer ${user1.accessToken}`); + + expect(status).toBe(200); + + const assets: AssetResponseDto[] = body; + expect(assets.length).toBe(1); + expect(assets[0].ownerId).toBe(user1.userId); + // assets owned by user1 + expect([asset1.id, asset2.id, asset3.id]).toContain(assets[0].id); + // assets owned by user2 + expect(assets[0].id).not.toBe(asset4.id); + }); + + it('should return 2 random assets', async () => { + const { status, body } = await request(server) + .get('/asset/random?count=2') + .set('Authorization', `Bearer ${user1.accessToken}`); + + expect(status).toBe(200); + + const assets: AssetResponseDto[] = body; + expect(assets.length).toBe(2); + + for (const asset of assets) { + expect(asset.ownerId).toBe(user1.userId); + // assets owned by user1 + expect([asset1.id, asset2.id, asset3.id]).toContain(asset.id); + // assets owned by user2 + expect(asset.id).not.toBe(asset4.id); + } + }); + + it('should return error', async () => { + const { status } = await request(server) + .get('/asset/random?count=ABC') + .set('Authorization', `Bearer ${user1.accessToken}`); + + expect(status).toBe(400); + }); + }); + describe('GET /asset/time-buckets', () => { it('should require authentication', async () => { const { status, body } = await request(server).get('/asset/time-buckets').query({ size: TimeBucketSize.MONTH }); diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index b3fde02a54..1a13ef2c57 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -11,6 +11,7 @@ export const newAssetRepositoryMock = (): jest.Mocked => { getWithout: jest.fn(), getByChecksum: jest.fn(), getWith: jest.fn(), + getRandom: jest.fn(), getFirstAssetForAlbumId: jest.fn(), getLastUpdatedAssetForAlbumId: jest.fn(), getAll: jest.fn().mockResolvedValue({ items: [], hasNextPage: false }), diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 1d4df828f9..66085a52f0 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -6303,6 +6303,49 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {number} [count] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getRandom: async (count?: number, options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/asset/random`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + if (count !== undefined) { + localVarQueryParameter['count'] = count; + } + + + setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; @@ -7043,6 +7086,16 @@ export const AssetApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.getMemoryLane(timestamp, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {number} [count] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getRandom(count?: number, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getRandom(count, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {TimeBucketSize} size @@ -7318,6 +7371,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath getMemoryLane(requestParameters: AssetApiGetMemoryLaneRequest, options?: AxiosRequestConfig): AxiosPromise> { return localVarFp.getMemoryLane(requestParameters.timestamp, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {AssetApiGetRandomRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getRandom(requestParameters: AssetApiGetRandomRequest = {}, options?: AxiosRequestConfig): AxiosPromise> { + return localVarFp.getRandom(requestParameters.count, options).then((request) => request(axios, basePath)); + }, /** * * @param {AssetApiGetTimeBucketsRequest} requestParameters Request parameters. @@ -7752,6 +7814,20 @@ export interface AssetApiGetMemoryLaneRequest { readonly timestamp: string } +/** + * Request parameters for getRandom operation in AssetApi. + * @export + * @interface AssetApiGetRandomRequest + */ +export interface AssetApiGetRandomRequest { + /** + * + * @type {number} + * @memberof AssetApiGetRandom + */ + readonly count?: number +} + /** * Request parameters for getTimeBuckets operation in AssetApi. * @export @@ -8244,6 +8320,17 @@ export class AssetApi extends BaseAPI { return AssetApiFp(this.configuration).getMemoryLane(requestParameters.timestamp, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {AssetApiGetRandomRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AssetApi + */ + public getRandom(requestParameters: AssetApiGetRandomRequest = {}, options?: AxiosRequestConfig) { + return AssetApiFp(this.configuration).getRandom(requestParameters.count, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {AssetApiGetTimeBucketsRequest} requestParameters Request parameters.