Skip to content

Commit

Permalink
ON-41612 # Added OneBlinkDownloader
Browse files Browse the repository at this point in the history
  • Loading branch information
mymattcarroll committed Jul 1, 2024
1 parent 81d84b3 commit 6faab7a
Show file tree
Hide file tree
Showing 12 changed files with 305 additions and 74 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.

## [Unreleased]

### Added

- `OneBlinkDownloader` to download form submissions, drafts and pre-fill data

## [2.0.0] - 2024-05-20

### Added
Expand Down
130 changes: 130 additions & 0 deletions src/OneBlinkDownloader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { DownloadOptions, StorageConstructorOptions } from './types'
import downloadJsonFromS3 from './downloadJsonFromS3'
import { SubmissionTypes } from '@oneblink/types'
/**
* Used to create an instance of the OneBlinkDownloader, exposing methods to
* download submissions and other types of files
*/
export default class OneBlinkDownloader {
apiOrigin: StorageConstructorOptions['apiOrigin']
region: StorageConstructorOptions['region']
getBearerToken: StorageConstructorOptions['getBearerToken']

/**
* #### Example
*
* ```typescript
* import { OneBlinkDownloader } from '@oneblink/storage'
*
* const downloader = new OneBlinkDownloader({
* apiOrigin: 'https://auth-api.blinkm.io',
* region: 'ap-southeast-2',
* getBearerToken: () => getAccessToken(),
* })
* ```
*/
constructor(props: StorageConstructorOptions) {
this.apiOrigin = props.apiOrigin
this.region = props.region
this.getBearerToken = props.getBearerToken
}

/**
* Download a form submission.
*
* #### Example
*
* ```ts
* const result = await downloader.downloadSubmission({
* submissionId: '5ad46e62-f466-451c-8cd6-29ba23ac50b7',
* formId: 1,
* abortSignal: new AbortController().signal,
* })
* ```
*
* @param data The submission upload data and options
* @returns The submission
*/
async downloadSubmission({
submissionId,
formId,
abortSignal,
}: DownloadOptions & {
/** The identifier of the submission. */
submissionId: string
/** The identifier of the form associated with the submission. */
formId: number
}) {
return await downloadJsonFromS3<SubmissionTypes.S3SubmissionData>({
...this,
key: `forms/${formId}/submissions/${submissionId}`,
abortSignal,
})
}

/**
* Download a draft form submission.
*
* #### Example
*
* ```ts
* const result = await downloader.downloadDraftSubmission({
* formSubmissionDraftVersionId: '5ad46e62-f466-451c-8cd6-29ba23ac50b7',
* formId: 1,
* abortSignal: new AbortController().signal,
* })
* ```
*
* @param data The submission upload data and options
* @returns The submission
*/
async downloadDraftSubmission({
formSubmissionDraftVersionId,
formId,
abortSignal,
}: DownloadOptions & {
/** The identifier of the draft form submission version. */
formSubmissionDraftVersionId: string
/** The identifier of the form associated with the draft submission. */
formId: number
}) {
return await downloadJsonFromS3<SubmissionTypes.S3SubmissionData>({
...this,
key: `forms/${formId}/drafts/${formSubmissionDraftVersionId}`,
abortSignal,
})
}

/**
* Download pre-fill form submission data.
*
* #### Example
*
* ```ts
* const result = await downloader.downloadPrefillData({
* preFillFormDataId: '5ad46e62-f466-451c-8cd6-29ba23ac50b7',
* formId: 1,
* abortSignal: new AbortController().signal,
* })
* ```
*
* @param data The submission upload data and options
* @returns The submission
*/
async downloadPrefillData<T extends SubmissionTypes.S3SubmissionData>({
preFillFormDataId,
formId,
abortSignal,
}: DownloadOptions & {
/** The identifier of the pre-fill data. */
preFillFormDataId: string
/** The identifier of the form associated with the pre-fill data. */
formId: number
}) {
return await downloadJsonFromS3<T>({
...this,
key: `forms/${formId}/pre-fill/${preFillFormDataId}`,
abortSignal,
})
}
}
19 changes: 17 additions & 2 deletions src/OneBlinkRequestHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
OneBlinkResponse,
FailResponse,
} from './http-handlers/types'
import OneBlinkStorageError from './OneBlinkStorageError'

/**
* Our own custom request handler to allow the response header which includes
Expand Down Expand Up @@ -40,9 +41,9 @@ export class OneBlinkRequestHandler<T>
}

const requestUrl = `${request.method} ${request.protocol}//${request.hostname}${request.path}?${new URLSearchParams(request.query as Record<string, string>).toString()}`
console.log('Starting storage upload request', requestUrl)
console.log('Starting storage request', requestUrl)
const response = await this.oneBlinkHttpHandler.handleRequest(request)
console.log('Finished storage upload request', requestUrl)
console.log('Finished storage request', requestUrl)

const oneblinkResponse = response.headers['x-oneblink-response']
if (typeof oneblinkResponse === 'string') {
Expand All @@ -58,4 +59,18 @@ export class OneBlinkRequestHandler<T>
response,
}
}

async sendS3Command<O>(sender: () => Promise<O>): Promise<O> {
try {
return await sender()
} catch (error) {
if (this.failResponse) {
throw new OneBlinkStorageError(this.failResponse.message, {
httpStatusCode: this.failResponse.statusCode,
originalError: error instanceof Error ? error : undefined,
})
}
throw error
}
}
}
44 changes: 44 additions & 0 deletions src/downloadJsonFromS3.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { GetObjectCommand, GetObjectCommandOutput } from '@aws-sdk/client-s3'
import { DownloadOptions, StorageConstructorOptions } from './types'
import { generateS3Client } from './generateS3Client'
import OneBlinkStorageError from './OneBlinkStorageError'

export default async function downloadJsonFromS3<T>({
key,
abortSignal,
...storageConstructorOptions
}: DownloadOptions &
StorageConstructorOptions & {
key: string
}): Promise<T | undefined> {
const { s3Client, bucket, oneBlinkRequestHandler } = generateS3Client({
...storageConstructorOptions,
requestBodyHeader: undefined,
})

try {
const getObjectCommandOutput =
await oneBlinkRequestHandler.sendS3Command<GetObjectCommandOutput>(
async () =>
await s3Client.send(
new GetObjectCommand({
Bucket: bucket,
Key: key,
}),
{
abortSignal,
},
),
)

return oneBlinkRequestHandler.oneBlinkHttpHandler.parseGetObjectCommandOutputAsJson<T>(
getObjectCommandOutput,
)
} catch (error) {
if (error instanceof OneBlinkStorageError && error.httpStatusCode === 403) {
return
} else {
throw error
}
}
}
57 changes: 57 additions & 0 deletions src/generateS3Client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { S3Client } from '@aws-sdk/client-s3'
import { StorageConstructorOptions } from './types'
import { getOneBlinkHttpHandler } from './http-handlers'
import { OneBlinkRequestHandler } from './OneBlinkRequestHandler'
import { RequestBodyHeader } from './http-handlers/types'

const RETRY_ATTEMPTS = 3

export function generateS3Client<T>({
region,
apiOrigin,
getBearerToken,
requestBodyHeader,
}: StorageConstructorOptions & {
requestBodyHeader?: RequestBodyHeader
}) {
const oneBlinkHttpHandler = getOneBlinkHttpHandler()
const oneBlinkRequestHandler = new OneBlinkRequestHandler<T>(
oneBlinkHttpHandler,
requestBodyHeader,
)

// The endpoint we use instead of the the AWS S3 endpoint is
// formatted internally by the AWS S3 SDK. It will add the Bucket
// parameter below as the subdomain to the URL (as long as the
// bucket does not contain a `.`). The logic below allows the final
// URL used to upload the object to be the origin that is passed in.
// The suffix on the end is important as it will allow us to route
// traffic to S3 via lambda at edge instead of going to our API.
const url = new URL(apiOrigin)
url.pathname = '/storage'
const [bucket, ...domainParts] = url.hostname.split('.')
url.hostname = domainParts.join('.')

return {
bucket,
oneBlinkRequestHandler,
s3Client: new S3Client({
endpoint: url.href,
region,
maxAttempts: RETRY_ATTEMPTS,
requestHandler: oneBlinkRequestHandler,
// Sign requests with our own Authorization header instead
// of letting AWS SDK attempt to generate credentials
signer: {
sign: async (request) => {
const token = await getBearerToken()
if (token) {
request.headers['authorization'] = 'Bearer ' + token
}

return request
},
},
}),
}
}
15 changes: 14 additions & 1 deletion src/http-handlers/FetchHandler.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { HttpRequest, HttpResponse } from '@smithy/protocol-http'
import { HttpHandlerOptions } from '@smithy/types'
import { IOneBlinkHttpHandler } from './types'
import { GetObjectCommandOutput } from '@aws-sdk/client-s3'

export class OneBlinkFetchHandler implements IOneBlinkHttpHandler {
async handleRequest(request: HttpRequest, options?: HttpHandlerOptions) {
Expand Down Expand Up @@ -30,7 +31,19 @@ export class OneBlinkFetchHandler implements IOneBlinkHttpHandler {
}
}

determineQueueSize() {
async parseGetObjectCommandOutputAsJson<T>(
getObjectCommandOutput: GetObjectCommandOutput,
): Promise<T | undefined> {
if (
getObjectCommandOutput.Body instanceof Blob ||
(window.ReadableStream &&
getObjectCommandOutput.Body instanceof window.ReadableStream)
) {
return (await new Response(getObjectCommandOutput.Body).json()) as T
}
}

determineUploadQueueSize() {
const effectiveType =
window.navigator &&
'connection' in window.navigator &&
Expand Down
18 changes: 15 additions & 3 deletions src/http-handlers/NodeJsHandler.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { HttpRequest, HttpResponse } from '@smithy/protocol-http'
import { HttpHandlerOptions } from '@smithy/types'
import { IOneBlinkHttpHandler, FailResponse } from './types'
import { GetObjectCommandOutput } from '@aws-sdk/client-s3'

export class OneBlinkNodeJsHandler implements IOneBlinkHttpHandler {
async handleRequest(
Expand Down Expand Up @@ -35,9 +36,10 @@ export class OneBlinkNodeJsHandler implements IOneBlinkHttpHandler {
}
break
}
case 'text/html': {
const { Readable, consumers } = await import('stream')
default: {
const { Readable } = await import('stream')
if (response.body instanceof Readable) {
const consumers = await import('stream/consumers')
return {
statusCode: response.statusCode,
message: await consumers.text(response.body),
Expand All @@ -55,7 +57,17 @@ export class OneBlinkNodeJsHandler implements IOneBlinkHttpHandler {
}
}

determineQueueSize() {
async parseGetObjectCommandOutputAsJson<T>(
getObjectCommandOutput: GetObjectCommandOutput,
): Promise<T | undefined> {
const { Readable } = await import('stream')
if (getObjectCommandOutput.Body instanceof Readable) {
const consumers = await import('stream/consumers')
return (await consumers.json(getObjectCommandOutput.Body)) as T
}
}

determineUploadQueueSize() {
return 10
}
}
6 changes: 5 additions & 1 deletion src/http-handlers/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { GetObjectCommandOutput } from '@aws-sdk/client-s3'
import { AWSTypes } from '@oneblink/types'
import { HttpRequest, HttpResponse } from '@smithy/protocol-http'
import { HttpHandlerOptions } from '@smithy/types'
Expand All @@ -17,8 +18,11 @@ export interface IOneBlinkHttpHandler {
request: HttpRequest,
options?: HttpHandlerOptions,
) => Promise<HttpResponse>
parseGetObjectCommandOutputAsJson: <T>(
getObjectCommandOutput: GetObjectCommandOutput,
) => Promise<T | undefined>
handleFailResponse: (
response: HttpResponse,
) => Promise<FailResponse | undefined>
determineQueueSize: () => number
determineUploadQueueSize: () => number
}
9 changes: 6 additions & 3 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,16 @@ export type StorageConstructorOptions = {
getBearerToken: () => Promise<string | undefined>
}

export type UploadOptions = {
/** An optional progress listener for tracking the progress of the upload */
onProgress?: ProgressListener
export type DownloadOptions = {
/** An optional AbortSignal that can be used to abort the upload */
abortSignal?: AbortSignal
}

export type UploadOptions = DownloadOptions & {
/** An optional progress listener for tracking the progress of the upload */
onProgress?: ProgressListener
}

export type AttachmentUploadData = NonNullable<PutObjectCommandInput['Body']>

export type UploadFormSubmissionOptions = UploadOptions & {
Expand Down
Loading

0 comments on commit 6faab7a

Please sign in to comment.