-
Notifications
You must be signed in to change notification settings - Fork 181
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
chore(server): easy js to ts migrations #2 - objectStorage.ts #3396
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,23 +1,33 @@ | ||
const { NotFoundError, EnvironmentResourceError } = require('@/modules/shared/errors') | ||
const { | ||
/* eslint-disable @typescript-eslint/no-explicit-any */ | ||
import { | ||
NotFoundError, | ||
EnvironmentResourceError, | ||
BadRequestError | ||
} from '@/modules/shared/errors' | ||
import { | ||
S3Client, | ||
GetObjectCommand, | ||
HeadBucketCommand, | ||
DeleteObjectCommand, | ||
CreateBucketCommand, | ||
S3ServiceException | ||
} = require('@aws-sdk/client-s3') | ||
const { Upload } = require('@aws-sdk/lib-storage') | ||
const { | ||
S3ServiceException, | ||
S3ClientConfig | ||
} from '@aws-sdk/client-s3' | ||
import { Upload, Options as UploadOptions } from '@aws-sdk/lib-storage' | ||
import { | ||
getS3AccessKey, | ||
getS3SecretKey, | ||
getS3Endpoint, | ||
getS3Region, | ||
getS3BucketName, | ||
createS3Bucket | ||
} = require('@/modules/shared/helpers/envHelper') | ||
} from '@/modules/shared/helpers/envHelper' | ||
import { ensureError, Nullable } from '@speckle/shared' | ||
import { get } from 'lodash' | ||
import type { Command } from '@aws-sdk/smithy-client' | ||
import type stream from 'stream' | ||
|
||
let s3Config = null | ||
let s3Config: Nullable<S3ClientConfig> = null | ||
|
||
const getS3Config = () => { | ||
if (!s3Config) { | ||
|
@@ -36,7 +46,7 @@ const getS3Config = () => { | |
return s3Config | ||
} | ||
|
||
let storageBucket = null | ||
let storageBucket: Nullable<string> = null | ||
|
||
const getStorageBucket = () => { | ||
if (!storageBucket) { | ||
|
@@ -51,32 +61,45 @@ const getObjectStorage = () => ({ | |
createBucket: createS3Bucket() | ||
}) | ||
|
||
const sendCommand = async (command) => { | ||
const sendCommand = async <CommandOutput>( | ||
command: (Bucket: string) => Command<any, CommandOutput, any, any, any> | ||
) => { | ||
const { client, Bucket } = getObjectStorage() | ||
try { | ||
return await client.send(command(Bucket)) | ||
const ret = (await client.send( | ||
command(Bucket) as Command<any, any, any, any, any> | ||
)) as CommandOutput | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not a fan of There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's already |
||
return ret | ||
iainsproat marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} catch (err) { | ||
if (err instanceof S3ServiceException && err.Code === 'NoSuchKey') | ||
if (err instanceof S3ServiceException && get(err, 'Code') === 'NoSuchKey') | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
no such prop according to TS, hence the dynamic access |
||
throw new NotFoundError(err.message) | ||
throw err | ||
} | ||
} | ||
|
||
const getObjectStream = async ({ objectKey }) => { | ||
export const getObjectStream = async ({ objectKey }: { objectKey: string }) => { | ||
const data = await sendCommand( | ||
(Bucket) => new GetObjectCommand({ Bucket, Key: objectKey }) | ||
) | ||
return data.Body | ||
|
||
// TODO: Apparently not always stream.Readable according to types, but in practice this works | ||
return data.Body as stream.Readable | ||
} | ||
|
||
const getObjectAttributes = async ({ objectKey }) => { | ||
export const getObjectAttributes = async ({ objectKey }: { objectKey: string }) => { | ||
const data = await sendCommand( | ||
(Bucket) => new GetObjectCommand({ Bucket, Key: objectKey }) | ||
) | ||
return { fileSize: data.ContentLength } | ||
return { fileSize: data.ContentLength || 0 } | ||
} | ||
|
||
const storeFileStream = async ({ objectKey, fileStream }) => { | ||
export const storeFileStream = async ({ | ||
objectKey, | ||
fileStream | ||
}: { | ||
objectKey: string | ||
fileStream: UploadOptions['params']['Body'] | ||
}) => { | ||
const { client, Bucket } = getObjectStorage() | ||
const parallelUploads3 = new Upload({ | ||
client, | ||
|
@@ -95,20 +118,40 @@ const storeFileStream = async ({ objectKey, fileStream }) => { | |
|
||
const data = await parallelUploads3.done() | ||
// the ETag is a hash of the object. Could be used to dedupe stuff... | ||
|
||
if (!data || !('ETag' in data) || !data.ETag) { | ||
throw new BadRequestError('No ETag in response') | ||
} | ||
|
||
const fileHash = data.ETag.replaceAll('"', '') | ||
return { fileHash } | ||
} | ||
|
||
const deleteObject = async ({ objectKey }) => { | ||
export const deleteObject = async ({ objectKey }: { objectKey: string }) => { | ||
await sendCommand((Bucket) => new DeleteObjectCommand({ Bucket, Key: objectKey })) | ||
} | ||
const ensureStorageAccess = async () => { | ||
|
||
// No idea what the actual error type is, too difficult to figure out | ||
type EnsureStorageAccessError = Error & { | ||
statusCode?: number | ||
$metadata?: { httpStatusCode?: number } | ||
} | ||
|
||
const isExpectedEnsureStorageAccessError = ( | ||
err: unknown | ||
): err is EnsureStorageAccessError => | ||
err instanceof Error && ('statusCode' in err || '$metadata' in err) | ||
|
||
export const ensureStorageAccess = async () => { | ||
const { client, Bucket, createBucket } = getObjectStorage() | ||
try { | ||
await client.send(new HeadBucketCommand({ Bucket })) | ||
return | ||
} catch (err) { | ||
if (err.statusCode === 403 || err['$metadata']?.httpStatusCode === 403) { | ||
if ( | ||
isExpectedEnsureStorageAccessError(err) && | ||
(err.statusCode === 403 || err['$metadata']?.httpStatusCode === 403) | ||
) { | ||
throw new EnvironmentResourceError("Access denied to S3 bucket '{bucket}'", { | ||
cause: err, | ||
info: { bucket: Bucket } | ||
|
@@ -121,7 +164,7 @@ const ensureStorageAccess = async () => { | |
throw new EnvironmentResourceError( | ||
"Can't open S3 bucket '{bucket}', and have failed to create it.", | ||
{ | ||
cause: err, | ||
cause: ensureError(err), | ||
info: { bucket: Bucket } | ||
} | ||
) | ||
|
@@ -130,18 +173,10 @@ const ensureStorageAccess = async () => { | |
throw new EnvironmentResourceError( | ||
"Can't open S3 bucket '{bucket}', and the Speckle server configuration has disabled creation of the bucket.", | ||
{ | ||
cause: err, | ||
cause: ensureError(err), | ||
info: { bucket: Bucket } | ||
} | ||
) | ||
} | ||
} | ||
} | ||
|
||
module.exports = { | ||
ensureStorageAccess, | ||
deleteObject, | ||
getObjectAttributes, | ||
storeFileStream, | ||
getObjectStream | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ok, nice use of generics. I think this is where I got stuck!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I notice that when
sendCommand
is called we don't specify what CommandOutput is. Is it inferred from the usage?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yes