1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

feat(server): improve API specification (#1853)

This commit is contained in:
Michel Heusschen 2023-02-24 17:01:10 +01:00 committed by GitHub
parent da9b9c8c69
commit 9323cc76d9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
41 changed files with 965 additions and 223 deletions

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -22,7 +22,7 @@ import { AddUsersDto } from './dto/add-users.dto';
import { RemoveAssetsDto } from './dto/remove-assets.dto';
import { UpdateAlbumDto } from './dto/update-album.dto';
import { GetAlbumsDto } from './dto/get-albums.dto';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { AlbumResponseDto } from '@app/domain';
import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
@ -37,7 +37,6 @@ import { CreateAlbumShareLinkDto as CreateAlbumSharedLinkDto } from './dto/creat
// TODO might be worth creating a AlbumParamsDto that validates `albumId` instead of using the pipe.
@ApiBearerAuth()
@ApiTags('Album')
@Controller('album')
export class AlbumController {
@ -134,12 +133,13 @@ export class AlbumController {
@Authenticated({ isShared: true })
@Get('/:albumId/download')
@ApiOkResponse({ content: { 'application/zip': { schema: { type: 'string', format: 'binary' } } } })
async downloadArchive(
@GetAuthUser() authUser: AuthUserDto,
@Param('albumId', new ParseUUIDPipe({ version: '4' })) albumId: string,
@Query(new ValidationPipe({ transform: true })) dto: DownloadDto,
@Response({ passthrough: true }) res: Res,
): Promise<any> {
) {
this.albumService.checkDownloadAccess(authUser);
const { stream, fileName, fileSize, fileCount, complete } = await this.albumService.downloadArchive(

View File

@ -28,7 +28,7 @@ import { Response as Res } from 'express';
import { DeleteAssetDto } from './dto/delete-asset.dto';
import { SearchAssetDto } from './dto/search-asset.dto';
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiHeader, ApiTags } from '@nestjs/swagger';
import { ApiBody, ApiConsumes, ApiHeader, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
import { AssetResponseDto, ImmichReadStream } from '@app/domain';
@ -62,7 +62,6 @@ function asStreamableFile({ stream, type, length }: ImmichReadStream) {
return new StreamableFile(stream, { type, length });
}
@ApiBearerAuth()
@ApiTags('Asset')
@Controller('asset')
export class AssetController {
@ -108,21 +107,23 @@ export class AssetController {
@Authenticated({ isShared: true })
@Get('/download/:assetId')
@ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } })
async downloadFile(
@GetAuthUser() authUser: AuthUserDto,
@Response({ passthrough: true }) res: Res,
@Param('assetId') assetId: string,
): Promise<any> {
) {
return this.assetService.downloadFile(authUser, assetId).then(asStreamableFile);
}
@Authenticated({ isShared: true })
@Post('/download-files')
@ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } })
async downloadFiles(
@GetAuthUser() authUser: AuthUserDto,
@Response({ passthrough: true }) res: Res,
@Body(new ValidationPipe()) dto: DownloadFilesDto,
): Promise<any> {
) {
this.assetService.checkDownloadAccess(authUser);
await this.assetService.checkAssetsAccess(authUser, [...dto.assetIds]);
const { stream, fileName, fileSize, fileCount, complete } = await this.assetService.downloadFiles(dto);
@ -138,11 +139,12 @@ export class AssetController {
*/
@Authenticated({ isShared: true })
@Get('/download-library')
@ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } })
async downloadLibrary(
@GetAuthUser() authUser: AuthUserDto,
@Query(new ValidationPipe({ transform: true })) dto: DownloadDto,
@Response({ passthrough: true }) res: Res,
): Promise<any> {
) {
this.assetService.checkDownloadAccess(authUser);
const { stream, fileName, fileSize, fileCount, complete } = await this.assetService.downloadLibrary(authUser, dto);
res.attachment(fileName);
@ -155,13 +157,14 @@ export class AssetController {
@Authenticated({ isShared: true })
@Get('/file/:assetId')
@Header('Cache-Control', 'max-age=31536000')
@ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } })
async serveFile(
@GetAuthUser() authUser: AuthUserDto,
@Headers() headers: Record<string, string>,
@Response({ passthrough: true }) res: Res,
@Query(new ValidationPipe({ transform: true })) query: ServeFileDto,
@Param('assetId') assetId: string,
): Promise<any> {
) {
await this.assetService.checkAssetsAccess(authUser, [assetId]);
return this.assetService.serveFile(authUser, assetId, query, res, headers);
}
@ -169,13 +172,14 @@ export class AssetController {
@Authenticated({ isShared: true })
@Get('/thumbnail/:assetId')
@Header('Cache-Control', 'max-age=31536000')
@ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } })
async getAssetThumbnail(
@GetAuthUser() authUser: AuthUserDto,
@Headers() headers: Record<string, string>,
@Response({ passthrough: true }) res: Res,
@Param('assetId') assetId: string,
@Query(new ValidationPipe({ transform: true })) query: GetAssetThumbnailDto,
): Promise<any> {
) {
await this.assetService.checkAssetsAccess(authUser, [assetId]);
return this.assetService.getAssetThumbnail(assetId, query, res, headers);
}

View File

@ -1,5 +1,5 @@
import { Body, Controller, Get, Param, Put, ValidationPipe } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { ApiTags } from '@nestjs/swagger';
import { Authenticated } from '../../decorators/authenticated.decorator';
import { AllJobStatusResponseDto } from './response-dto/all-job-status-response.dto';
import { GetJobDto } from './dto/get-job.dto';
@ -8,7 +8,6 @@ import { JobCommandDto } from './dto/job-command.dto';
@Authenticated({ admin: true })
@ApiTags('Job')
@ApiBearerAuth()
@Controller('jobs')
export class JobController {
constructor(private readonly jobService: JobService) {}

View File

@ -14,7 +14,7 @@ import {
ValidateAccessTokenResponseDto,
} from '@app/domain';
import { Body, Controller, Ip, Post, Req, Res, ValidationPipe } from '@nestjs/common';
import { ApiBadRequestResponse, ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { ApiBadRequestResponse, ApiTags } from '@nestjs/swagger';
import { Request, Response } from 'express';
import { GetAuthUser } from '../decorators/auth-user.decorator';
import { Authenticated } from '../decorators/authenticated.decorator';
@ -45,7 +45,6 @@ export class AuthController {
}
@Authenticated()
@ApiBearerAuth()
@Post('validateToken')
// eslint-disable-next-line @typescript-eslint/no-unused-vars
validateAccessToken(@GetAuthUser() authUser: AuthUserDto): ValidateAccessTokenResponseDto {
@ -53,7 +52,6 @@ export class AuthController {
}
@Authenticated()
@ApiBearerAuth()
@Post('change-password')
async changePassword(@GetAuthUser() authUser: AuthUserDto, @Body() dto: ChangePasswordDto): Promise<UserResponseDto> {
return this.authService.changePassword(authUser, dto);

View File

@ -5,12 +5,11 @@ import {
UpsertDeviceInfoDto as UpsertDto,
} from '@app/domain';
import { Body, Controller, Put, ValidationPipe } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { ApiTags } from '@nestjs/swagger';
import { GetAuthUser } from '../decorators/auth-user.decorator';
import { Authenticated } from '../decorators/authenticated.decorator';
@Authenticated()
@ApiBearerAuth()
@ApiTags('Device Info')
@Controller('device-info')
export class DeviceInfoController {

View File

@ -1,10 +1,9 @@
import { SystemConfigDto, SystemConfigService, SystemConfigTemplateStorageOptionDto } from '@app/domain';
import { Body, Controller, Get, Put, ValidationPipe } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { ApiTags } from '@nestjs/swagger';
import { Authenticated } from '../decorators/authenticated.decorator';
@ApiTags('System Config')
@ApiBearerAuth()
@Authenticated({ admin: true })
@Controller('system-config')
export class SystemConfigController {

View File

@ -23,7 +23,7 @@ import { UpdateUserDto } from '@app/domain';
import { FileInterceptor } from '@nestjs/platform-express';
import { profileImageUploadOption } from '../config/profile-image-upload.config';
import { Response as Res } from 'express';
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { UserResponseDto } from '@app/domain';
import { UserCountResponseDto } from '@app/domain';
import { CreateProfileImageDto } from '@app/domain';
@ -36,7 +36,6 @@ export class UserController {
constructor(private readonly userService: UserService) {}
@Authenticated()
@ApiBearerAuth()
@Get()
async getAllUsers(
@GetAuthUser() authUser: AuthUserDto,
@ -51,14 +50,12 @@ export class UserController {
}
@Authenticated()
@ApiBearerAuth()
@Get('me')
async getMyUserInfo(@GetAuthUser() authUser: AuthUserDto): Promise<UserResponseDto> {
return await this.userService.getUserInfo(authUser);
}
@Authenticated({ admin: true })
@ApiBearerAuth()
@Post()
async createUser(
@Body(new ValidationPipe({ transform: true })) createUserDto: CreateUserDto,
@ -72,21 +69,18 @@ export class UserController {
}
@Authenticated({ admin: true })
@ApiBearerAuth()
@Delete('/:userId')
async deleteUser(@GetAuthUser() authUser: AuthUserDto, @Param('userId') userId: string): Promise<UserResponseDto> {
return await this.userService.deleteUser(authUser, userId);
}
@Authenticated({ admin: true })
@ApiBearerAuth()
@Post('/:userId/restore')
async restoreUser(@GetAuthUser() authUser: AuthUserDto, @Param('userId') userId: string): Promise<UserResponseDto> {
return await this.userService.restoreUser(authUser, userId);
}
@Authenticated()
@ApiBearerAuth()
@Put()
async updateUser(
@GetAuthUser() authUser: AuthUserDto,
@ -97,7 +91,6 @@ export class UserController {
@UseInterceptors(FileInterceptor('file', profileImageUploadOption))
@Authenticated()
@ApiBearerAuth()
@ApiConsumes('multipart/form-data')
@ApiBody({
description: 'A new avatar for the user',

View File

@ -1,4 +1,5 @@
import { applyDecorators, SetMetadata } from '@nestjs/common';
import { ApiBearerAuth, ApiCookieAuth, ApiQuery } from '@nestjs/swagger';
interface AuthenticatedOptions {
admin?: boolean;
@ -12,7 +13,7 @@ export enum Metadata {
}
export const Authenticated = (options?: AuthenticatedOptions) => {
const decorators = [SetMetadata(Metadata.AUTH_ROUTE, true)];
const decorators: MethodDecorator[] = [ApiBearerAuth(), ApiCookieAuth(), SetMetadata(Metadata.AUTH_ROUTE, true)];
options = options || {};
@ -22,6 +23,7 @@ export const Authenticated = (options?: AuthenticatedOptions) => {
if (options.isShared) {
decorators.push(SetMetadata(Metadata.SHARED_ROUTE, true));
decorators.push(ApiQuery({ name: 'key', type: String, required: false }));
}
return applyDecorators(...decorators);

View File

@ -11,6 +11,7 @@ import { RedisIoAdapter } from './middlewares/redis-io.adapter.middleware';
import { json } from 'body-parser';
import { patchOpenAPI } from './utils/patch-open-api.util';
import { getLogLevels, MACHINE_LEARNING_ENABLED } from '@app/common';
import { IMMICH_ACCESS_COOKIE } from '@app/domain';
const logger = new Logger('ImmichServer');
@ -42,6 +43,7 @@ async function bootstrap() {
scheme: 'Bearer',
in: 'header',
})
.addCookieAuth(IMMICH_ACCESS_COOKIE)
.addServer('/api')
.build();

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -93,6 +93,7 @@ describe('AlbumCard component', () => {
expect(apiMock.assetApi.getAssetThumbnail).toHaveBeenCalledWith(
'thumbnailIdOne',
ThumbnailFormat.Jpeg,
undefined,
{ responseType: 'blob' }
);
expect(createObjectURLMock).toHaveBeenCalledWith(thumbnailBlob);

View File

@ -34,9 +34,14 @@
return;
}
const { data } = await api.assetApi.getAssetThumbnail(thubmnailId, ThumbnailFormat.Jpeg, {
responseType: 'blob'
});
const { data } = await api.assetApi.getAssetThumbnail(
thubmnailId,
ThumbnailFormat.Jpeg,
undefined,
{
responseType: 'blob'
}
);
if (data instanceof Blob) {
return URL.createObjectURL(data);

View File

@ -170,11 +170,7 @@
{
assetIds: assets.map((a) => a.id)
},
{
params: {
key: sharedLink?.key
}
}
sharedLink?.key
);
if (data.album) {
@ -269,10 +265,8 @@
const { data, status, headers } = await api.albumApi.downloadArchive(
album.id,
skip || undefined,
sharedLink?.key,
{
params: {
key: sharedLink?.key
},
responseType: 'blob',
onDownloadProgress: function (progressEvent) {
const request = this as XMLHttpRequest;

View File

@ -145,8 +145,7 @@
$downloadAssets[imageFileName] = 0;
const { data, status } = await api.assetApi.downloadFile(assetId, {
params: { key },
const { data, status } = await api.assetApi.downloadFile(assetId, key, {
responseType: 'blob',
onDownloadProgress: (progressEvent) => {
if (progressEvent.lengthComputable) {

View File

@ -26,10 +26,7 @@
const loadAssetData = async () => {
try {
const { data } = await api.assetApi.serveFile(asset.id, false, true, {
params: {
key: publicSharedKey
},
const { data } = await api.assetApi.serveFile(asset.id, false, true, publicSharedKey, {
responseType: 'blob'
});

View File

@ -54,11 +54,7 @@
{
assetIds
},
{
params: {
key: sharedLink?.key
}
}
sharedLink?.key
);
notificationController.show({
@ -76,11 +72,7 @@
{
assetIds: assets.filter((a) => !selectedAssets.has(a)).map((a) => a.id)
},
{
params: {
key: sharedLink?.key
}
}
sharedLink?.key
);
assets = assets.filter((a) => !selectedAssets.has(a));

View File

@ -11,9 +11,14 @@
return noThumbnailUrl;
}
const { data } = await api.assetApi.getAssetThumbnail(thubmnailId, ThumbnailFormat.Webp, {
responseType: 'blob'
});
const { data } = await api.assetApi.getAssetThumbnail(
thubmnailId,
ThumbnailFormat.Webp,
undefined,
{
responseType: 'blob'
}
);
if (data instanceof Blob) {
return URL.createObjectURL(data);
}

View File

@ -18,19 +18,17 @@ export const addAssetsToAlbum = async (
assetIds: Array<string>,
key: string | undefined = undefined
): Promise<AddAssetsResponseDto> =>
api.albumApi
.addAssetsToAlbum(albumId, { assetIds }, { params: { key } })
.then(({ data: dto }) => {
if (dto.successfullyAdded > 0) {
// This might be 0 if the user tries to add an asset that is already in the album
notificationController.show({
message: `Added ${dto.successfullyAdded} to ${dto.album?.albumName}`,
type: NotificationType.Info
});
}
api.albumApi.addAssetsToAlbum(albumId, { assetIds }, key).then(({ data: dto }) => {
if (dto.successfullyAdded > 0) {
// This might be 0 if the user tries to add an asset that is already in the album
notificationController.show({
message: `Added ${dto.successfullyAdded} to ${dto.album?.albumName}`,
type: NotificationType.Info
});
}
return dto;
});
return dto;
});
export async function bulkDownload(
fileName: string,
@ -53,24 +51,20 @@ export async function bulkDownload(
let total = 0;
const { data, status, headers } = await api.assetApi.downloadFiles(
{ assetIds },
{
params: { key },
responseType: 'blob',
onDownloadProgress: function (progressEvent) {
const request = this as XMLHttpRequest;
if (!total) {
total = Number(request.getResponseHeader('X-Immich-Content-Length-Hint')) || 0;
}
const { data, status, headers } = await api.assetApi.downloadFiles({ assetIds }, key, {
responseType: 'blob',
onDownloadProgress: function (progressEvent) {
const request = this as XMLHttpRequest;
if (!total) {
total = Number(request.getResponseHeader('X-Immich-Content-Length-Hint')) || 0;
}
if (total) {
const current = progressEvent.loaded;
downloadAssets.set({ [downloadFileName]: Math.floor((current / total) * 100) });
}
if (total) {
const current = progressEvent.loaded;
downloadAssets.set({ [downloadFileName]: Math.floor((current / total) * 100) });
}
}
);
});
const isNotComplete = headers['x-immich-archive-complete'] === 'false';
const fileCount = Number(headers['x-immich-archive-file-count']) || 0;

View File

@ -108,11 +108,7 @@ async function fileUploader(
deviceAssetId: String(deviceAssetId),
deviceId: 'WEB'
},
{
params: {
key: sharedKey
}
}
sharedKey
);
if (status === 200 && data.isExist && data.id) {

View File

@ -12,7 +12,7 @@ export const load: PageServerLoad = async ({ params, parent }) => {
const { key } = params;
try {
const { data: sharedLink } = await api.shareApi.getMySharedLink({ params: { key } });
const { data: sharedLink } = await api.shareApi.getMySharedLink(key);
const assetCount = sharedLink.assets.length;
const assetId = sharedLink.album?.albumThumbnailAssetId || sharedLink.assets[0]?.id;

View File

@ -7,9 +7,7 @@ import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params }) => {
try {
const { key, assetId } = params;
const { data: asset } = await api.assetApi.getAssetById(assetId, {
params: { key }
});
const { data: asset } = await api.assetApi.getAssetById(assetId, key);
if (!asset) {
return error(404, 'Asset not found');