-
Notifications
You must be signed in to change notification settings - Fork 296
Expand file tree
/
Copy paths3.ts
More file actions
173 lines (155 loc) · 4.92 KB
/
s3.ts
File metadata and controls
173 lines (155 loc) · 4.92 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
// Modules -------------------------------------------------------------------->
import * as z from 'zod';
import {
APIOptions,
handleRestFailures,
isModArchResponse,
restCREATE,
restGET,
} from 'mod-arch-core';
import { BFF_API_VERSION, URL_PREFIX } from '~/app/utilities/const';
import type { S3ListObjectsResponse } from '~/app/types';
// Globals -------------------------------------------------------------------->
/* eslint-disable camelcase */
const S3ListObjectsResponseSchema = z.object({
common_prefixes: z.array(
z.object({
prefix: z.string(),
}),
),
contents: z.array(
z.object({
key: z.string(),
size: z.number(),
last_modified: z.string().optional(),
etag: z.string().optional(),
storage_class: z.string().optional(),
}),
),
is_truncated: z.boolean(),
key_count: z.number(),
max_keys: z.number(),
continuation_token: z.string().optional(),
delimiter: z.string().optional(),
name: z.string().optional(),
next_continuation_token: z.string().optional(),
prefix: z.string().optional(),
});
/* eslint-enable camelcase */
// Types ---------------------------------------------------------------------->
export type UploadFileToS3Params = {
namespace: string;
secretName: string;
bucket?: string;
key: string;
};
export type UploadFileToS3Response = {
uploaded: boolean;
key: string;
};
export type GetFilesOptions = {
namespace: string;
secretName?: string;
bucket?: string;
path?: string;
search?: string;
limit?: number;
next?: string;
};
// Public --------------------------------------------------------------------->
/**
* Uploads a file to S3 via the BFF POST /api/v1/s3/files/:key endpoint.
* Uses the given secret for credentials and the file's key (path) in the bucket.
*
* @param hostPath - Base path for API requests (e.g. '' for same-origin)
* @param params - namespace, secretName, key (required); bucket (optional, uses secret default if omitted)
* @param file - The file to upload (sent as multipart form field "file")
* @returns Promise that resolves when upload succeeds; throws on non-2xx response or malformed 2xx body
*/
export async function uploadFileToS3(
hostPath: string,
params: UploadFileToS3Params,
file: File,
): Promise<UploadFileToS3Response> {
const queryParams: Record<string, string> = {
namespace: params.namespace,
secretName: params.secretName,
};
if (params.bucket !== undefined && params.bucket !== '') {
queryParams.bucket = params.bucket;
}
const formData = new FormData();
formData.append('file', file, file.name);
const path = `${URL_PREFIX}/api/${BFF_API_VERSION}/s3/files/${encodeURIComponent(params.key)}`;
const response = await handleRestFailures(restCREATE(hostPath, path, formData, queryParams));
if (!isS3UploadSuccessPayload(response)) {
throw new Error(
'Invalid upload response: expected uploaded: true and a non-empty key from server',
);
}
return response;
}
/**
* getFiles: Fetch files from the S3 BFF endpoint `GET /api/v1/s3/files`
*
* @param {string} host - Passed into mod-arch-core's restGET. For typical BFF calls, passed in as ''
* @param {APIOptions} requestOptions - Passed into mod-arch-core's restGET. Allows the request behaviour to be configured
* @param {GetFilesOptions} options - Request parameters for S3 get files endpoint
*/
export async function getFiles(
host: string,
requestOptions: APIOptions,
options: GetFilesOptions,
): Promise<S3ListObjectsResponse> {
const query: Record<string, string> = {
namespace: options.namespace,
};
if (options.secretName) {
query.secretName = options.secretName;
}
if (options.bucket) {
query.bucket = options.bucket;
}
if (options.path) {
query.path = options.path;
}
if (options.search) {
query.search = options.search;
}
if (options.limit !== undefined) {
query.limit = String(options.limit);
}
if (options.next) {
query.next = options.next;
}
const response = await handleRestFailures(
restGET(host, `${URL_PREFIX}/api/${BFF_API_VERSION}/s3/files`, query, requestOptions),
);
if (isModArchResponse<S3ListObjectsResponse>(response)) {
try {
return S3ListObjectsResponseSchema.parse(response.data);
} catch (error) {
if (error instanceof z.ZodError) {
const issues = error.issues
.map((issue) => `${issue.path.join('.')}: ${issue.message}`)
.join(', ');
throw new Error(`Invalid S3ListObjectsResponse: ${issues}`);
}
throw error;
}
}
throw new Error('Invalid response format');
}
// Private -------------------------------------------------------------------->
function isS3UploadSuccessPayload(data: unknown): data is UploadFileToS3Response {
if (typeof data !== 'object' || data === null) {
return false;
}
return (
'uploaded' in data &&
data.uploaded === true &&
'key' in data &&
typeof data.key === 'string' &&
data.key.trim() !== ''
);
}