Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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 keyWithoutPrefix = filename;
if (typeof this._generateKey === 'function') {
const candidate = this._generateKey(filename, contentType, options);
keyWithoutPrefix =
candidate && typeof candidate.then === 'function' ? await candidate : candidate;
if (typeof keyWithoutPrefix !== 'string' || keyWithoutPrefix.trim().length === 0) {
throw new Error('generateKey must return a non-empty string');
}
keyWithoutPrefix = keyWithoutPrefix.trim();
}

const params = {
Bucket: this._bucket,
Key: this._bucketPrefix + filename,
Key: this._bucketPrefix + keyWithoutPrefix,
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);

return Object.assign(response || {}, { Location: location });
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}.`) ||
Comment on lines +193 to +197
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: i can suggestion to add comments with an url example to show at each line what you replace and why, it will help for the maintenance in the long term

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sure

basePath.split('/').includes(this._bucket);
const pathWithBucket = hasBucketInHostOrPath ? basePath : `${basePath}/${this._bucket}`;
locationBase = `${origin}${pathWithBucket}`;
Comment on lines +199 to +200
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: is it valid to redirect a "domain" bucket to a "path" bucket ? not sure all S3 providers support this switch

We need to be sure that current system works with all S3 providers like DO Spaces, Google Storage with S3, Minio S3 ...

Did you test locally with this kind of providers to ensure S3 protocol compatibility ?

Copy link
Member

@mtrezza mtrezza Sep 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we only need to test with S3, which is the spec others should follow when claiming S3 compatibility. However, it must be explicitly documented in the S3 specs, otherwise we may use a behavior that is not properly defined even for S3. If in doubt, ask AWS support.

} 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}`;

let url;
if (config?.mount && config?.applicationId) { // if config has required properties for getFileLocation
url = await this.getFileLocation(config, keyWithoutPrefix);
}

return {
location: location, // actual upload location, used for tests
name: keyWithoutPrefix, // 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