Skip to content

feat: Revise createFile logic to return modified filenames and location #242

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

Open
wants to merge 16 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
54 changes: 45 additions & 9 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -140,16 +140,24 @@ class S3Adapter {

// For a given config object, filename, and data, store a file in S3
// Returns a promise containing the S3 object creation response
async createFile(filename, data, contentType, options = {}) {
async createFile(filename, data, contentType, options = {}, config = {}) {
let key_without_prefix = filename;
if (typeof this._generateKey === 'function') {
const candidate = this._generateKey(filename, contentType, options);
key_without_prefix =
candidate && typeof candidate.then === 'function' ? await candidate : candidate;
if (typeof key_without_prefix !== 'string' || key_without_prefix.trim().length === 0) {
throw new Error('generateKey must return a non-empty string');
}
key_without_prefix = key_without_prefix.trim();
}

const params = {
Bucket: this._bucket,
Key: this._bucketPrefix + filename,
Key: this._bucketPrefix + key_without_prefix,
Body: data,
};

if (this._generateKey instanceof Function) {
params.Key = this._bucketPrefix + this._generateKey(filename);
}
if (this._fileAcl) {
if (this._fileAcl === 'none') {
delete params.ACL;
Expand Down Expand Up @@ -177,11 +185,39 @@ class S3Adapter {
}
await this.createBucket();
const command = new PutObjectCommand(params);
const response = await this._s3Client.send(command);
const endpoint = this._endpoint || `https://${this._bucket}.s3.${this._region}.amazonaws.com`;
const location = `${endpoint}/${params.Key}`;
await this._s3Client.send(command);

let locationBase;
if (this._endpoint) {
try {
const u = new URL(this._endpoint);
const origin = `${u.protocol}//${u.host}`;
const basePath = (u.pathname || '').replace(/\/$/, '');
const hasBucketInHostOrPath =
u.hostname.startsWith(`${this._bucket}.`) ||
basePath.split('/').includes(this._bucket);
const pathWithBucket = hasBucketInHostOrPath ? basePath : `${basePath}/${this._bucket}`;
locationBase = `${origin}${pathWithBucket}`;
} catch {
// Fallback for non-URL endpoints (assume hostname)
locationBase = `https://${String(this._endpoint).replace(/\/$/, '')}/${this._bucket}`;
}
} else {
const regionPart = this._region ? `.s3.${this._region}` : '.s3';
locationBase = `https://${this._bucket}${regionPart}.amazonaws.com`;
}
const location = `${locationBase}/${params.Key}`;

return Object.assign(response || {}, { Location: location });
let url;
if (config?.mount && config?.applicationId) { // if config has required properties for getFileLocation
url = await this.getFileLocation(config, key_without_prefix);
}

return {
location: location, // actual upload location, used for tests
name: key_without_prefix, // filename in storage, consistent with other adapters
...url ? { url: url } : {} // url (optionally presigned) or non-direct access url
};
}

async deleteFile(filename) {
Expand Down
236 changes: 233 additions & 3 deletions spec/test.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -729,7 +729,7 @@ describe('S3Adapter tests', () => {
const s3 = getMockS3Adapter(options);
const fileName = 'randomFileName.txt';
const response = s3.createFile(fileName, 'hello world', 'text/utf8').then(value => {
const url = new URL(value.Location);
const url = new URL(value.location);
expect(url.pathname.indexOf(fileName) > 13).toBe(true);
});
promises.push(response);
Expand All @@ -740,7 +740,7 @@ describe('S3Adapter tests', () => {
const s3 = getMockS3Adapter(options);
const fileName = 'foo/randomFileName.txt';
const response = s3.createFile(fileName, 'hello world', 'text/utf8').then(value => {
const url = new URL(value.Location);
const url = new URL(value.location);
expect(url.pathname.substring(1)).toEqual(options.bucketPrefix + fileName);
});
promises.push(response);
Expand All @@ -750,7 +750,7 @@ describe('S3Adapter tests', () => {
const s3 = getMockS3Adapter(options);
const fileName = 'foo/randomFileName.txt';
const response = s3.createFile(fileName, 'hello world', 'text/utf8').then(value => {
const url = new URL(value.Location);
const url = new URL(value.location);
expect(url.pathname.indexOf('foo/')).toEqual(6);
expect(url.pathname.indexOf('random') > 13).toBe(true);
});
Expand Down Expand Up @@ -867,6 +867,236 @@ describe('S3Adapter tests', () => {
expect(commandArg).toBeInstanceOf(PutObjectCommand);
expect(commandArg.input.ACL).toBeUndefined();
});

it('should return url when config is provided', async () => {
const options = {
bucket: 'bucket-1',
presignedUrl: true
};
const s3 = new S3Adapter(options);

const mockS3Response = {
ETag: '"mock-etag"',
VersionId: 'mock-version',
Location: 'mock-location'
};
s3ClientMock.send.and.returnValue(Promise.resolve(mockS3Response));
s3._s3Client = s3ClientMock;

// Mock getFileLocation to return a presigned URL
spyOn(s3, 'getFileLocation').and.returnValue(Promise.resolve('https://presigned-url.com/file.txt'));

const result = await s3.createFile(
'file.txt',
'hello world',
'text/utf8',
{},
{ mount: 'http://example.com', applicationId: 'test123' }
);

expect(s3.getFileLocation).toHaveBeenCalledWith(
jasmine.objectContaining({ mount: 'http://example.com', applicationId: 'test123' }),
'file.txt'
);
expect(result).toEqual({
location: jasmine.any(String),
name: 'file.txt',
url: 'https://presigned-url.com/file.txt'
});
});

it('should handle generateKey function errors', async () => {
const options = {
bucket: 'bucket-1',
generateKey: () => {
throw new Error('Generate key failed');
}
};
const s3 = new S3Adapter(options);
s3._s3Client = s3ClientMock;

await expectAsync(
s3.createFile('file.txt', 'hello world', 'text/utf8', {})
).toBeRejectedWithError('Generate key failed');
});

it('should handle async generateKey function', async () => {
const options = {
bucket: 'bucket-1',
generateKey: async (filename) => {
// Simulate async operation
await new Promise(resolve => setTimeout(resolve, 10));
return `async-${filename}`;
}
};
const s3 = new S3Adapter(options);
s3ClientMock.send.and.returnValue(Promise.resolve({}));
s3._s3Client = s3ClientMock;

const out = await s3.createFile('file.txt', 'hello world', 'text/utf8', {});

expect(s3ClientMock.send).toHaveBeenCalledTimes(2);
const commands = s3ClientMock.send.calls.all();
const commandArg = commands[1].args[0];
expect(commandArg).toBeInstanceOf(PutObjectCommand);
expect(commandArg.input.Key).toBe('async-file.txt');
expect(out.name).toBe('async-file.txt');
});

it('should handle generateKey that returns a Promise', async () => {
const options = {
bucket: 'bucket-1',
generateKey: (filename) => {
return Promise.resolve(`promise-${filename}`);
}
};
const s3 = new S3Adapter(options);
s3ClientMock.send.and.returnValue(Promise.resolve({}));
s3._s3Client = s3ClientMock;

const out = await s3.createFile('file.txt', 'hello world', 'text/utf8', {});

expect(s3ClientMock.send).toHaveBeenCalledTimes(2);
const commands = s3ClientMock.send.calls.all();
const commandArg = commands[1].args[0];
expect(commandArg).toBeInstanceOf(PutObjectCommand);
expect(commandArg.input.Key).toBe('promise-file.txt');
expect(out.name).toBe('promise-file.txt');
});

it('should validate generateKey returns a non-empty string', async () => {
const options = {
bucket: 'bucket-1',
generateKey: () => ''
};
const s3 = new S3Adapter(options);
s3._s3Client = s3ClientMock;

await expectAsync(
s3.createFile('file.txt', 'hello world', 'text/utf8', {})
).toBeRejectedWithError('generateKey must return a non-empty string');
});

it('should validate generateKey returns a string (not number)', async () => {
const options = {
bucket: 'bucket-1',
generateKey: () => 12345
};
const s3 = new S3Adapter(options);
s3._s3Client = s3ClientMock;

await expectAsync(
s3.createFile('file.txt', 'hello world', 'text/utf8', {})
).toBeRejectedWithError('generateKey must return a non-empty string');
});

it('should reject when generateKey returns only whitespace', async () => {
const options = {
bucket: 'bucket-1',
generateKey: () => ' '
};
const s3 = new S3Adapter(options);
s3._s3Client = s3ClientMock;

await expectAsync(
s3.createFile('file.txt', 'hello world', 'text/utf8', {})
).toBeRejectedWithError('generateKey must return a non-empty string');
});

it('should validate async generateKey returns a string', async () => {
const options = {
bucket: 'bucket-1',
generateKey: async () => null
};
const s3 = new S3Adapter(options);
s3._s3Client = s3ClientMock;

await expectAsync(
s3.createFile('file.txt', 'hello world', 'text/utf8', {})
).toBeRejectedWithError('generateKey must return a non-empty string');
});
});

describe('URL construction with custom endpoints', () => {
let s3ClientMock;

beforeEach(() => {
s3ClientMock = jasmine.createSpyObj('S3Client', ['send']);
s3ClientMock.send.and.returnValue(Promise.resolve({}));
});

it('should handle endpoint with path and query correctly', async () => {
const s3 = new S3Adapter({
bucket: 'test-bucket',
s3overrides: {
endpoint: 'https://example.com:8080/path?foo=bar'
}
});
s3._s3Client = s3ClientMock;

const result = await s3.createFile('test.txt', 'hello world', 'text/utf8');

// Should construct proper URL without breaking query parameters
expect(result.location).toBe('https://example.com:8080/path/test-bucket/test.txt');
});

it('should handle path-style endpoint without bucket in hostname', async () => {
const s3 = new S3Adapter({
bucket: 'test-bucket',
s3overrides: {
endpoint: 'https://minio.example.com'
}
});
s3._s3Client = s3ClientMock;

const result = await s3.createFile('test.txt', 'hello world', 'text/utf8');

// Should add bucket to path for path-style
expect(result.location).toBe('https://minio.example.com/test-bucket/test.txt');
});

it('should handle virtual-hosted-style endpoint with bucket in hostname', async () => {
const s3 = new S3Adapter({
bucket: 'test-bucket',
s3overrides: {
endpoint: 'https://test-bucket.s3.example.com'
}
});
s3._s3Client = s3ClientMock;

const result = await s3.createFile('test.txt', 'hello world', 'text/utf8');

// Should not duplicate bucket when it's already in hostname
expect(result.location).toBe('https://test-bucket.s3.example.com/test.txt');
});

it('should fallback for malformed endpoint', async () => {
const s3 = new S3Adapter({
bucket: 'test-bucket',
s3overrides: {
endpoint: 'not-a-valid-url'
}
});
s3._s3Client = s3ClientMock;

const result = await s3.createFile('test.txt', 'hello world', 'text/utf8');

// Should fallback to safe construction
expect(result.location).toBe('https://not-a-valid-url/test-bucket/test.txt');
});

it('should use default AWS endpoint when no custom endpoint', async () => {
const s3 = new S3Adapter({
bucket: 'test-bucket',
region: 'us-west-2'
});
s3._s3Client = s3ClientMock;

const result = await s3.createFile('test.txt', 'hello world', 'text/utf8');

// Should use standard AWS S3 URL
expect(result.location).toBe('https://test-bucket.s3.us-west-2.amazonaws.com/test.txt');
});
});

describe('handleFileStream', () => {
Expand Down