feat(server) Extend PUT /album/:id/assets endpoint (#857)

* Add new query parameter to API endpoint that allows adding assets to albums which potentially contain assets that are already part of this album.

* Change API endpoint

* Generate new APIs

* Fixed test

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
Matthias Rupp 2022-10-28 21:54:09 +02:00 committed by GitHub
parent 443c842723
commit ea99567805
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 314 additions and 30 deletions

4
.gitignore vendored
View File

@ -1,3 +1,5 @@
.DS_Store
.vscode
.idea
.idea
docker/upload

View File

@ -3,6 +3,7 @@
README.md
analysis_options.yaml
doc/AddAssetsDto.md
doc/AddAssetsResponseDto.md
doc/AddUsersDto.md
doc/AdminSignupResponseDto.md
doc/AlbumApi.md
@ -82,6 +83,7 @@ lib/auth/http_basic_auth.dart
lib/auth/http_bearer_auth.dart
lib/auth/oauth.dart
lib/model/add_assets_dto.dart
lib/model/add_assets_response_dto.dart
lib/model/add_users_dto.dart
lib/model/admin_signup_response_dto.dart
lib/model/album_count_response_dto.dart
@ -137,5 +139,3 @@ lib/model/user_count_response_dto.dart
lib/model/user_response_dto.dart
lib/model/validate_access_token_response_dto.dart
pubspec.yaml
test/check_existing_assets_dto_test.dart
test/check_existing_assets_response_dto_test.dart

View File

@ -118,6 +118,7 @@ Class | Method | HTTP request | Description
## Documentation For Models
- [AddAssetsDto](doc//AddAssetsDto.md)
- [AddAssetsResponseDto](doc//AddAssetsResponseDto.md)
- [AddUsersDto](doc//AddUsersDto.md)
- [AdminSignupResponseDto](doc//AdminSignupResponseDto.md)
- [AlbumCountResponseDto](doc//AlbumCountResponseDto.md)

View File

@ -0,0 +1,17 @@
# openapi.model.AddAssetsResponseDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**successfullyAdded** | **int** | |
**alreadyInAlbum** | **List<String>** | | [default to const []]
**album** | [**AlbumResponseDto**](AlbumResponseDto.md) | | [optional]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@ -22,7 +22,7 @@ Method | HTTP request | Description
# **addAssetsToAlbum**
> AlbumResponseDto addAssetsToAlbum(albumId, addAssetsDto)
> AddAssetsResponseDto addAssetsToAlbum(albumId, addAssetsDto)
@ -57,7 +57,7 @@ Name | Type | Description | Notes
### Return type
[**AlbumResponseDto**](AlbumResponseDto.md)
[**AddAssetsResponseDto**](AddAssetsResponseDto.md)
### Authorization

View File

@ -36,6 +36,7 @@ part 'api/server_info_api.dart';
part 'api/user_api.dart';
part 'model/add_assets_dto.dart';
part 'model/add_assets_response_dto.dart';
part 'model/add_users_dto.dart';
part 'model/admin_signup_response_dto.dart';
part 'model/album_count_response_dto.dart';

View File

@ -53,7 +53,7 @@ class AlbumApi {
/// * [String] albumId (required):
///
/// * [AddAssetsDto] addAssetsDto (required):
Future<AlbumResponseDto?> addAssetsToAlbum(String albumId, AddAssetsDto addAssetsDto,) async {
Future<AddAssetsResponseDto?> addAssetsToAlbum(String albumId, AddAssetsDto addAssetsDto,) async {
final response = await addAssetsToAlbumWithHttpInfo(albumId, addAssetsDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
@ -62,7 +62,7 @@ class AlbumApi {
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AlbumResponseDto',) as AlbumResponseDto;
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AddAssetsResponseDto',) as AddAssetsResponseDto;
}
return null;

View File

@ -194,6 +194,8 @@ class ApiClient {
return value is DateTime ? value : DateTime.tryParse(value);
case 'AddAssetsDto':
return AddAssetsDto.fromJson(value);
case 'AddAssetsResponseDto':
return AddAssetsResponseDto.fromJson(value);
case 'AddUsersDto':
return AddUsersDto.fromJson(value);
case 'AdminSignupResponseDto':

View File

@ -0,0 +1,138 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class AddAssetsResponseDto {
/// Returns a new [AddAssetsResponseDto] instance.
AddAssetsResponseDto({
required this.successfullyAdded,
this.alreadyInAlbum = const [],
this.album,
});
int successfullyAdded;
List<String> alreadyInAlbum;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
AlbumResponseDto? album;
@override
bool operator ==(Object other) => identical(this, other) || other is AddAssetsResponseDto &&
other.successfullyAdded == successfullyAdded &&
other.alreadyInAlbum == alreadyInAlbum &&
other.album == album;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(successfullyAdded.hashCode) +
(alreadyInAlbum.hashCode) +
(album == null ? 0 : album!.hashCode);
@override
String toString() => 'AddAssetsResponseDto[successfullyAdded=$successfullyAdded, alreadyInAlbum=$alreadyInAlbum, album=$album]';
Map<String, dynamic> toJson() {
final _json = <String, dynamic>{};
_json[r'successfullyAdded'] = successfullyAdded;
_json[r'alreadyInAlbum'] = alreadyInAlbum;
if (album != null) {
_json[r'album'] = album;
} else {
_json[r'album'] = null;
}
return _json;
}
/// Returns a new [AddAssetsResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AddAssetsResponseDto? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
// Ensure that the map contains the required keys.
// Note 1: the values aren't checked for validity beyond being non-null.
// Note 2: this code is stripped in release mode!
assert(() {
requiredKeys.forEach((key) {
assert(json.containsKey(key), 'Required key "AddAssetsResponseDto[$key]" is missing from JSON.');
assert(json[key] != null, 'Required key "AddAssetsResponseDto[$key]" has a null value in JSON.');
});
return true;
}());
return AddAssetsResponseDto(
successfullyAdded: mapValueOfType<int>(json, r'successfullyAdded')!,
alreadyInAlbum: json[r'alreadyInAlbum'] is List
? (json[r'alreadyInAlbum'] as List).cast<String>()
: const [],
album: AlbumResponseDto.fromJson(json[r'album']),
);
}
return null;
}
static List<AddAssetsResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
final result = <AddAssetsResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = AddAssetsResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, AddAssetsResponseDto> mapFromJson(dynamic json) {
final map = <String, AddAssetsResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AddAssetsResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of AddAssetsResponseDto-objects as value to a dart map
static Map<String, List<AddAssetsResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AddAssetsResponseDto>>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AddAssetsResponseDto.listFromJson(entry.value, growable: growable,);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'successfullyAdded',
'alreadyInAlbum',
};
}

View File

@ -0,0 +1,37 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
import 'package:openapi/api.dart';
import 'package:test/test.dart';
// tests for AddAssetsResponseDto
void main() {
// final instance = AddAssetsResponseDto();
group('test AddAssetsResponseDto', () {
// int successfullyAdded
test('to test the property `successfullyAdded`', () async {
// TODO
});
// List<String> alreadyInAlbum (default value: const [])
test('to test the property `alreadyInAlbum`', () async {
// TODO
});
// AlbumResponseDto album
test('to test the property `album`', () async {
// TODO
});
});
}

View File

@ -11,6 +11,7 @@ import { GetAlbumsDto } from './dto/get-albums.dto';
import { RemoveAssetsDto } from './dto/remove-assets.dto';
import { UpdateAlbumDto } from './dto/update-album.dto';
import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
import {AddAssetsResponseDto} from "./response-dto/add-assets-response.dto";
export interface IAlbumRepository {
create(ownerId: string, createAlbumDto: CreateAlbumDto): Promise<AlbumEntity>;
@ -20,7 +21,7 @@ export interface IAlbumRepository {
addSharedUsers(album: AlbumEntity, addUsersDto: AddUsersDto): Promise<AlbumEntity>;
removeUser(album: AlbumEntity, userId: string): Promise<void>;
removeAssets(album: AlbumEntity, removeAssets: RemoveAssetsDto): Promise<AlbumEntity>;
addAssets(album: AlbumEntity, addAssetsDto: AddAssetsDto): Promise<AlbumEntity>;
addAssets(album: AlbumEntity, addAssetsDto: AddAssetsDto): Promise<AddAssetsResponseDto>;
updateAlbum(album: AlbumEntity, updateAlbumDto: UpdateAlbumDto): Promise<AlbumEntity>;
getListByAssetId(userId: string, assetId: string): Promise<AlbumEntity[]>;
getCountByUserId(userId: string): Promise<AlbumCountResponseDto>;
@ -260,10 +261,16 @@ export class AlbumRepository implements IAlbumRepository {
}
}
async addAssets(album: AlbumEntity, addAssetsDto: AddAssetsDto): Promise<AlbumEntity> {
async addAssets(album: AlbumEntity, addAssetsDto: AddAssetsDto): Promise<AddAssetsResponseDto> {
const newRecords: AssetAlbumEntity[] = [];
const alreadyExisting: string[] = [];
for (const assetId of addAssetsDto.assetIds) {
// Album already contains that asset
if (album.assets?.some(a => a.assetId === assetId)) {
alreadyExisting.push(assetId);
continue;
}
const newAssetAlbum = new AssetAlbumEntity();
newAssetAlbum.assetId = assetId;
newAssetAlbum.albumId = album.id;
@ -278,7 +285,11 @@ export class AlbumRepository implements IAlbumRepository {
}
await this.assetAlbumRepository.save([...newRecords]);
return this.get(album.id) as Promise<AlbumEntity>; // There is an album for sure
return {
successfullyAdded: newRecords.length,
alreadyInAlbum: alreadyExisting
};
}
updateAlbum(album: AlbumEntity, updateAlbumDto: UpdateAlbumDto): Promise<AlbumEntity> {

View File

@ -24,6 +24,7 @@ import { GetAlbumsDto } from './dto/get-albums.dto';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { AlbumResponseDto } from './response-dto/album-response.dto';
import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
import {AddAssetsResponseDto} from "./response-dto/add-assets-response.dto";
// TODO might be worth creating a AlbumParamsDto that validates `albumId` instead of using the pipe.
@Authenticated()
@ -57,7 +58,7 @@ export class AlbumController {
@GetAuthUser() authUser: AuthUserDto,
@Body(ValidationPipe) addAssetsDto: AddAssetsDto,
@Param('albumId', new ParseUUIDPipe({ version: '4' })) albumId: string,
) {
) : Promise<AddAssetsResponseDto> {
return this.albumService.addAssetsToAlbum(authUser, addAssetsDto, albumId);
}

View File

@ -1,10 +1,11 @@
import { AlbumService } from './album.service';
import { IAlbumRepository } from './album-repository';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common';
import { AlbumEntity } from '@app/database/entities/album.entity';
import { AlbumResponseDto } from './response-dto/album-response.dto';
import { IAssetRepository } from '../asset/asset-repository';
import {AddAssetsResponseDto} from "./response-dto/add-assets-response.dto";
import {IAlbumRepository} from "./album-repository";
describe('Album service', () => {
let sut: AlbumService;
@ -329,10 +330,16 @@ describe('Album service', () => {
it('adds assets to owned album', async () => {
const albumEntity = _getOwnedAlbum();
const albumResponse: AddAssetsResponseDto = {
alreadyInAlbum: [],
successfullyAdded: 1
};
const albumId = albumEntity.id;
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve<AddAssetsResponseDto>(albumResponse));
const result = await sut.addAssetsToAlbum(
authUser,
@ -340,18 +347,24 @@ describe('Album service', () => {
assetIds: ['1'],
},
albumId,
);
) as AddAssetsResponseDto;
// TODO: stub and expect album rendered
expect(result.id).toEqual(albumId);
expect(result.album?.id).toEqual(albumId);
});
it('adds assets to shared album (shared with auth user)', async () => {
const albumEntity = _getSharedWithAuthUserAlbum();
const albumResponse: AddAssetsResponseDto = {
alreadyInAlbum: [],
successfullyAdded: 1
};
const albumId = albumEntity.id;
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve<AddAssetsResponseDto>(albumResponse));
const result = await sut.addAssetsToAlbum(
authUser,
@ -359,18 +372,24 @@ describe('Album service', () => {
assetIds: ['1'],
},
albumId,
);
) as AddAssetsResponseDto;
// TODO: stub and expect album rendered
expect(result.id).toEqual(albumId);
expect(result.album?.id).toEqual(albumId);
});
it('prevents adding assets to a not owned / shared album', async () => {
const albumEntity = _getNotOwnedNotSharedAlbum();
const albumResponse: AddAssetsResponseDto = {
alreadyInAlbum: [],
successfullyAdded: 1
};
const albumId = albumEntity.id;
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve<AddAssetsResponseDto>(albumResponse));
expect(
sut.addAssetsToAlbum(
@ -425,10 +444,16 @@ describe('Album service', () => {
it('prevents removing assets from a not owned / shared album', async () => {
const albumEntity = _getNotOwnedNotSharedAlbum();
const albumResponse: AddAssetsResponseDto = {
alreadyInAlbum: [],
successfullyAdded: 1
};
const albumId = albumEntity.id;
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve<AddAssetsResponseDto>(albumResponse));
expect(
sut.removeAssetsFromAlbum(

View File

@ -1,8 +1,7 @@
import { BadRequestException, Inject, Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { AddAssetsDto } from './dto/add-assets.dto';
import { CreateAlbumDto } from './dto/create-album.dto';
import { AlbumEntity } from '../../../../../libs/database/src/entities/album.entity';
import { AlbumEntity } from '@app/database/entities/album.entity';
import { AddUsersDto } from './dto/add-users.dto';
import { RemoveAssetsDto } from './dto/remove-assets.dto';
import { UpdateAlbumDto } from './dto/update-album.dto';
@ -11,6 +10,8 @@ import { AlbumResponseDto, mapAlbum, mapAlbumExcludeAssetInfo } from './response
import { ALBUM_REPOSITORY, IAlbumRepository } from './album-repository';
import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
import { ASSET_REPOSITORY, IAssetRepository } from '../asset/asset-repository';
import { AddAssetsResponseDto } from "./response-dto/add-assets-response.dto";
import {AddAssetsDto} from "./dto/add-assets.dto";
@Injectable()
export class AlbumService {
@ -108,10 +109,15 @@ export class AlbumService {
authUser: AuthUserDto,
addAssetsDto: AddAssetsDto,
albumId: string,
): Promise<AlbumResponseDto> {
): Promise<AddAssetsResponseDto> {
const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false });
const updatedAlbum = await this._albumRepository.addAssets(album, addAssetsDto);
return mapAlbum(updatedAlbum);
const result = await this._albumRepository.addAssets(album, addAssetsDto);
const newAlbum = await this._getAlbum({ authUser, albumId, validateIsOwner: false });
return {
...result,
album: mapAlbum(newAlbum)
};
}
async updateAlbumInfo(

View File

@ -0,0 +1,13 @@
import {ApiProperty} from "@nestjs/swagger";
import {AlbumResponseDto} from "./album-response.dto";
export class AddAssetsResponseDto {
@ApiProperty({ type: 'integer' })
successfullyAdded!: number;
@ApiProperty()
alreadyInAlbum!: string[];
@ApiProperty()
album?: AlbumResponseDto;
}

File diff suppressed because one or more lines are too long

View File

@ -34,6 +34,31 @@ export interface AddAssetsDto {
*/
'assetIds': Array<string>;
}
/**
*
* @export
* @interface AddAssetsResponseDto
*/
export interface AddAssetsResponseDto {
/**
*
* @type {number}
* @memberof AddAssetsResponseDto
*/
'successfullyAdded': number;
/**
*
* @type {Array<string>}
* @memberof AddAssetsResponseDto
*/
'alreadyInAlbum': Array<string>;
/**
*
* @type {AlbumResponseDto}
* @memberof AddAssetsResponseDto
*/
'album'?: AlbumResponseDto;
}
/**
*
* @export
@ -1990,7 +2015,7 @@ export const AlbumApiFp = function(configuration?: Configuration) {
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async addAssetsToAlbum(albumId: string, addAssetsDto: AddAssetsDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AlbumResponseDto>> {
async addAssetsToAlbum(albumId: string, addAssetsDto: AddAssetsDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AddAssetsResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.addAssetsToAlbum(albumId, addAssetsDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
@ -2105,7 +2130,7 @@ export const AlbumApiFactory = function (configuration?: Configuration, basePath
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
addAssetsToAlbum(albumId: string, addAssetsDto: AddAssetsDto, options?: any): AxiosPromise<AlbumResponseDto> {
addAssetsToAlbum(albumId: string, addAssetsDto: AddAssetsDto, options?: any): AxiosPromise<AddAssetsResponseDto> {
return localVarFp.addAssetsToAlbum(albumId, addAssetsDto, options).then((request) => request(axios, basePath));
},
/**

View File

@ -215,8 +215,10 @@
const { data } = await api.albumApi.addAssetsToAlbum(album.id, {
assetIds: assets.map((a) => a.id)
});
album = data;
if (data.album) {
album = data.album;
}
isShowAssetSelection = false;
} catch (e) {
console.error('Error [createAlbumHandler] ', e);
@ -233,7 +235,10 @@
const { data } = await api.albumApi.addAssetsToAlbum(album.id, {
assetIds: assetIds
});
album = data;
if (data.album) {
album = data.album;
}
} catch (e) {
console.error('Error [assetUploadedToAlbumHandler] ', e);
notificationController.show({