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

chore(server): easy js to ts migrations #2 - objectStorage.ts #3396

Merged
merged 2 commits into from
Oct 25, 2024
Merged
Changes from 1 commit
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
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) {
Expand All @@ -36,7 +46,7 @@ const getS3Config = () => {
return s3Config
}

let storageBucket = null
let storageBucket: Nullable<string> = null

const getStorageBucket = () => {
if (!storageBucket) {
Expand All @@ -51,32 +61,45 @@ const getObjectStorage = () => ({
createBucket: createS3Bucket()
})

const sendCommand = async (command) => {
const sendCommand = async <CommandOutput>(
Copy link
Contributor

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!

Copy link
Contributor

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?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

yes

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
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not a fan of as CommandOutput. Is there a safer way of doing this?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's already as CommandOutput in practice, the type of CommandOutput is auto inferred from the actual command you pass in. The only reason I need to do this is because of all of the abstraction and generics behind the scene which break something - I dunno, it wasn't obvious to me how to avoid it.

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')
Copy link
Contributor Author

Choose a reason for hiding this comment

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

get(err, Code)

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,
Expand All @@ -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 }
Expand All @@ -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 }
}
)
Expand All @@ -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
}