Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

THR-7 ContentStorage: store data on S3 via S3ClientAdapter #4198

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
c12a99f
store data on s3 (contentStorage)
SteKrause Jun 13, 2023
60dc5ce
add s3Client and s3ClientAdapter to module and config
SteKrause Jun 19, 2023
aa4ebb0
add bug fixes to contentStorage
SteKrause Jun 19, 2023
0c744e0
Revert "add s3Client and s3ClientAdapter to module and config"
SteKrause Jun 20, 2023
78910f0
add contentStorage tests and bug fixes
SteKrause Jun 20, 2023
38998ea
Revert "Revert "add s3Client and s3ClientAdapter to module and config""
SteKrause Jun 20, 2023
7714333
refactor contentStorage and tests
SteKrause Jun 21, 2023
2760599
Fix h5p uc tests delete
SteKrause Jun 21, 2023
e40fa27
fix h5p delete api test
SteKrause Jun 21, 2023
0f46ed5
fix h5p editor api tests
SteKrause Jun 22, 2023
0988496
fix uc test get and save-create
SteKrause Jun 22, 2023
24b9623
fix uc test get player
SteKrause Jun 22, 2023
39485ee
fix uc test ajax
SteKrause Jun 22, 2023
585d533
WIP UC test h5p-files
SteKrause Jun 22, 2023
98d6c8a
Cleanup test fixtures
marode-cap Jun 22, 2023
e640b4a
delete rimraf from contentstorage test
SteKrause Jun 22, 2023
842fb68
remove unused import
SteKrause Jun 22, 2023
4205968
WIP contentStorage
SteKrause Jun 22, 2023
bd7b92e
bugfix contentStorage
SteKrause Jun 22, 2023
3ea1be5
bugfix save and delete content
SteKrause Jun 22, 2023
ae29b13
delete deprecated code
SteKrause Jun 23, 2023
45c49a3
delete unused comments
SteKrause Jun 23, 2023
ff23e3f
refactor contentStorage
SteKrause Jun 23, 2023
cf3fb4d
add contentMetadata entity
SteKrause Jun 23, 2023
86d9770
add file-stats to contentStorage
SteKrause Jun 26, 2023
f377e26
store metadata in mongodb
SteKrause Jun 27, 2023
c8c9869
delete ToDo
SteKrause Jun 27, 2023
0f50ffe
add bytesRange to getFilesStream
SteKrause Jun 27, 2023
2f83d8a
add contentMetadataRepo to test module
SteKrause Jun 27, 2023
50a257a
adjust uc tests
SteKrause Jun 27, 2023
1335953
Fix PR comments
marode-cap Jun 28, 2023
56cb49e
Fix unit tests
marode-cap Jun 28, 2023
3d28844
Fix last unit test
marode-cap Jun 28, 2023
39b2896
Implemented repo integration tests
marode-cap Jun 28, 2023
d00da4f
Rework database and S3Adapter
marode-cap Jul 3, 2023
3ab39f2
Small fix
marode-cap Jul 3, 2023
70b335d
Extends h5pcontent factory to test content
marode-cap Jul 3, 2023
8fc21e6
Add head command
marode-cap Jul 3, 2023
aecb832
Update S3 client adapter based on PR comments
marode-cap Jul 11, 2023
7c577f2
Implement requested changes for content storage
marode-cap Jul 11, 2023
6e47df0
Implement requested changes
marode-cap Jul 11, 2023
2ac438e
rewrote tests for content storage
marode-cap Jul 13, 2023
62cb685
formatting
marode-cap Jul 13, 2023
c2c02a6
change params (again)
marode-cap Jul 13, 2023
2314dfe
Fix small error
marode-cap Jul 13, 2023
084bd42
Clarify ts-expect-error comment
marode-cap Jul 13, 2023
1c7a9a1
Last changes
marode-cap Jul 13, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions apps/server/src/modules/files-storage/client/s3-client.adapter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,4 +430,152 @@ describe('S3ClientAdapter', () => {
await expect(service.copy(undefined)).rejects.toThrowError(InternalServerErrorException);
});
});

describe('head', () => {
const setup = () => {
const { pathToFile } = createParameter();

return { pathToFile };
};

describe('when file exists', () => {
it('should call send() of client with head object', async () => {
const { pathToFile } = setup();

await service.head(pathToFile);

expect(client.send).toBeCalledWith(
expect.objectContaining({
input: { Bucket: 'test-bucket', Key: pathToFile },
})
);
});
});

describe('when file does not exist', () => {
it('should throw NotFoundException', async () => {
const { pathToFile } = setup();
// @ts-expect-error ignore parameter type of mock function
client.send.mockRejectedValueOnce(new Error('NoSuchKey'));

const headPromise = service.head(pathToFile);

await expect(headPromise).rejects.toBeInstanceOf(NotFoundException);
});
});
});

describe('list', () => {
const setup = () => {
const prefix = 'test/';

const keys = Array.from(Array(2500).keys()).map((n) => `KEY-${n}`);
const responseContents = keys.map((key) => {
return {
Key: `${prefix}${key}`,
};
});

return { prefix, keys, responseContents };
};

afterEach(() => {
client.send.mockClear();
});

it('should truncate result when max is given', async () => {
const { prefix, keys, responseContents } = setup();

// @ts-expect-error ignore parameter type of mock function
client.send.mockResolvedValue({
IsTruncated: false,
Contents: responseContents.slice(0, 500),
});

const resultKeys = await service.list(prefix, 500);

expect(resultKeys).toEqual(keys.slice(0, 500));

expect(client.send).toBeCalledWith(
expect.objectContaining({
input: {
Bucket: 'test-bucket',
Prefix: prefix,
ContinuationToken: undefined,
MaxKeys: 500,
},
})
);
});

it('should call send() multiple times if bucket contains more than 1000 keys', async () => {
const { prefix, responseContents, keys } = setup();

client.send
// @ts-expect-error ignore parameter type of mock function
.mockResolvedValueOnce({
IsTruncated: true,
NextContinuationToken: '1',
Contents: responseContents.slice(0, 1000),
})
// @ts-expect-error ignore parameter type of mock function
.mockResolvedValueOnce({
IsTruncated: true,
NextContinuationToken: '2',
Contents: responseContents.slice(1000, 2000),
})
// @ts-expect-error ignore parameter type of mock function
.mockResolvedValueOnce({
Contents: responseContents.slice(2000),
});

const resultKeys = await service.list(prefix);

expect(resultKeys).toEqual(keys);

expect(client.send).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
input: {
Bucket: 'test-bucket',
Prefix: prefix,
ContinuationToken: undefined,
},
})
);

expect(client.send).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
input: {
Bucket: 'test-bucket',
Prefix: prefix,
ContinuationToken: '1',
},
})
);

expect(client.send).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
input: {
Bucket: 'test-bucket',
Prefix: prefix,
ContinuationToken: '2',
},
})
);
});

it('should throw error if client rejects with an error', async () => {
const { prefix } = setup();

// @ts-expect-error ignore parameter type of mock function
client.send.mockRejectedValue(new Error());

const listPromise = service.list(prefix);

await expect(listPromise).rejects.toThrow();
});
});
});
58 changes: 58 additions & 0 deletions apps/server/src/modules/files-storage/client/s3-client.adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import {
CreateBucketCommand,
DeleteObjectsCommand,
GetObjectCommand,
HeadObjectCommand,
ListObjectsV2Command,
ListObjectsV2CommandOutput,
S3Client,
ServiceOutputTypes,
} from '@aws-sdk/client-s3';
Expand Down Expand Up @@ -183,6 +186,61 @@ export class S3ClientAdapter implements IStorageClient {
return this.client.send(req);
}

public async list(prefix: string, maxKeys?: number) {
dyedwiper marked this conversation as resolved.
Show resolved Hide resolved
this.logger.log({ action: 'list', params: { prefix, bucket: this.config.bucket } });

try {
let files: string[] = [];
let ret: ListObjectsV2CommandOutput | undefined;

do {
const req = new ListObjectsV2Command({
Bucket: this.config.bucket,
Prefix: prefix,
ContinuationToken: ret?.NextContinuationToken,
MaxKeys: maxKeys && maxKeys - files.length,
});

// Iterations are dependent on each other
// eslint-disable-next-line no-await-in-loop
ret = await this.client.send(req);

const returnedFiles =
ret?.Contents?.filter((o) => o.Key)
.map((o) => o.Key as string) // Can not be undefined because of filter above
.map((key) => key.substring(prefix.length)) ?? [];

files = files.concat(returnedFiles);
} while (ret?.IsTruncated && (!maxKeys || files.length < maxKeys));

return files;
} catch (err) {
throw new InternalServerErrorException(err, 'S3ClientAdapter:list');
}
}

public async head(path: string) {
try {
this.logger.log({ action: 'head', params: { path, bucket: this.config.bucket } });

const req = new HeadObjectCommand({
Bucket: this.config.bucket,
Key: path,
});

const headResponse = await this.client.send(req);

return headResponse;
} catch (err) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (err.message && err.message === 'NoSuchKey') {
this.logger.log(`could not find the file for head with id ${path}`);
throw new NotFoundException('NoSuchKey');
}
throw new InternalServerErrorException(err, 'S3ClientAdapter:head');
}
}

/* istanbul ignore next */
private checkStreamResponsive(stream: Readable, context: string) {
let timer: NodeJS.Timeout;
Expand Down
Loading