@jordanalec/dtk 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +730 -0
  3. package/dist/add.js +89 -0
  4. package/dist/cli.js +12 -0
  5. package/dist/init.js +20 -0
  6. package/dist/utils/patch.js +8 -0
  7. package/package.json +52 -0
  8. package/templates/init/.env.template +2 -0
  9. package/templates/init/GUIDE.md +543 -0
  10. package/templates/init/README.md +59 -0
  11. package/templates/init/jest.config.ts +19 -0
  12. package/templates/init/package.json +22 -0
  13. package/templates/init/src/lib/auth.test.ts +48 -0
  14. package/templates/init/src/lib/basic-auth.ts +6 -0
  15. package/templates/init/src/lib/bearer-token.ts +5 -0
  16. package/templates/init/src/lib/http.test.ts +197 -0
  17. package/templates/init/src/lib/http.ts +81 -0
  18. package/templates/init/src/lib/oauth.test.ts +61 -0
  19. package/templates/init/src/lib/oauth.ts +15 -0
  20. package/templates/init/src/lib/token.ts +5 -0
  21. package/templates/init/src/load-env.ts +4 -0
  22. package/templates/init/src/runbooks/example.ts +33 -0
  23. package/templates/init/src/suite.test.ts +94 -0
  24. package/templates/init/src/suite.ts +70 -0
  25. package/templates/init/src/types/http.ts +12 -0
  26. package/templates/init/src/types/oauth.ts +13 -0
  27. package/templates/init/src/types/suite.ts +37 -0
  28. package/templates/init/tsconfig.json +14 -0
  29. package/templates/init/tsconfig.test.json +8 -0
  30. package/templates/plugins/aws-dynamo/env.txt +2 -0
  31. package/templates/plugins/aws-dynamo/example.ts +75 -0
  32. package/templates/plugins/aws-dynamo/plugin.json +38 -0
  33. package/templates/plugins/aws-dynamo/service.test.ts +180 -0
  34. package/templates/plugins/aws-dynamo/service.ts +73 -0
  35. package/templates/plugins/aws-dynamo/types.ts +29 -0
  36. package/templates/plugins/aws-s3/env.txt +2 -0
  37. package/templates/plugins/aws-s3/example.ts +41 -0
  38. package/templates/plugins/aws-s3/plugin.json +38 -0
  39. package/templates/plugins/aws-s3/service.test.ts +150 -0
  40. package/templates/plugins/aws-s3/service.ts +43 -0
  41. package/templates/plugins/aws-s3/types.ts +28 -0
  42. package/templates/plugins/aws-sns/env.txt +2 -0
  43. package/templates/plugins/aws-sns/example.ts +18 -0
  44. package/templates/plugins/aws-sns/plugin.json +37 -0
  45. package/templates/plugins/aws-sns/service.test.ts +79 -0
  46. package/templates/plugins/aws-sns/service.ts +28 -0
  47. package/templates/plugins/aws-sns/types.ts +8 -0
  48. package/templates/plugins/aws-sqs/env.txt +2 -0
  49. package/templates/plugins/aws-sqs/example.ts +16 -0
  50. package/templates/plugins/aws-sqs/plugin.json +37 -0
  51. package/templates/plugins/aws-sqs/service.test.ts +63 -0
  52. package/templates/plugins/aws-sqs/service.ts +27 -0
  53. package/templates/plugins/aws-sqs/types.ts +8 -0
  54. package/templates/plugins/open-ai/env.txt +1 -0
  55. package/templates/plugins/open-ai/example.ts +27 -0
  56. package/templates/plugins/open-ai/plugin.json +36 -0
  57. package/templates/plugins/open-ai/service.test.ts +55 -0
  58. package/templates/plugins/open-ai/service.ts +26 -0
  59. package/templates/plugins/open-ai/types.ts +61 -0
  60. package/templates/plugins/tsconfig.json +11 -0
@@ -0,0 +1,75 @@
1
+ import "../load-env.js";
2
+ import { suite } from "../suite.js";
3
+
4
+ await suite()
5
+ .dynamo({
6
+ region: process.env.AWS_REGION!,
7
+ })
8
+ .step("create-item", async (ctx) => {
9
+ const result = await ctx.services.dynamo.putItem(
10
+ process.env.DYNAMO_TABLE_NAME!,
11
+ {
12
+ id: "user-123",
13
+ name: "John Doe",
14
+ email: "john@example.com",
15
+ timestamp: new Date().toISOString(),
16
+ }
17
+ );
18
+ console.log("Item created:", result);
19
+ return result;
20
+ })
21
+ .step("retrieve-item", async (ctx) => {
22
+ const result = await ctx.services.dynamo.getItem(
23
+ process.env.DYNAMO_TABLE_NAME!,
24
+ { id: "user-123" }
25
+ );
26
+ console.log("Item retrieved:", result);
27
+ return result;
28
+ })
29
+ .step("update-item", async (ctx) => {
30
+ const result = await ctx.services.dynamo.updateItem(
31
+ process.env.DYNAMO_TABLE_NAME!,
32
+ { id: "user-123" },
33
+ {
34
+ UpdateExpression: "SET #n = :n, #e = :e",
35
+ ExpressionAttributeNames: { "#n": "name", "#e": "email" },
36
+ ExpressionAttributeValues: {
37
+ ":n": { S: "Jane Doe" },
38
+ ":e": { S: "jane@example.com" },
39
+ },
40
+ }
41
+ );
42
+ console.log("Item updated:", result);
43
+ return result;
44
+ })
45
+ .step("query-items", async (ctx) => {
46
+ const result = await ctx.services.dynamo.queryItems(
47
+ process.env.DYNAMO_TABLE_NAME!,
48
+ {
49
+ KeyConditionExpression: "#pk = :pk",
50
+ ExpressionAttributeNames: { "#pk": "id" },
51
+ ExpressionAttributeValues: { ":pk": { S: "user-123" } },
52
+ }
53
+ );
54
+ console.log("Items found:", result.count);
55
+ console.log("Items:", result.items);
56
+ return result;
57
+ })
58
+ .step("scan-items", async (ctx) => {
59
+ const result = await ctx.services.dynamo.scanItems(
60
+ process.env.DYNAMO_TABLE_NAME!,
61
+ { Limit: 10 }
62
+ );
63
+ console.log("Items scanned:", result.count);
64
+ console.log("Items:", result.items);
65
+ return result;
66
+ })
67
+ .step("delete-item", async (ctx) => {
68
+ const result = await ctx.services.dynamo.deleteItem(
69
+ process.env.DYNAMO_TABLE_NAME!,
70
+ { id: "user-123" }
71
+ );
72
+ console.log("Item deleted:", result);
73
+ return result;
74
+ })
75
+ .run("throwOnError");
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "aws-dynamo",
3
+ "description": "AWS DynamoDB -- create and query items in DynamoDB tables",
4
+ "dependencies": {
5
+ "@aws-sdk/client-dynamodb": "^3.300.0",
6
+ "@aws-sdk/util-dynamodb": "^3.300.0"
7
+ },
8
+ "files": [
9
+ { "src": "service.ts", "dest": "src/services/dynamo.ts" },
10
+ { "src": "types.ts", "dest": "src/types/aws-dynamo.ts" },
11
+ { "src": "service.test.ts", "dest": "src/services/dynamo.test.ts" }
12
+ ],
13
+ "env": "env.txt",
14
+ "example": "example.ts",
15
+ "transforms": {
16
+ "service.ts": [
17
+ { "from": "./types.js", "to": "../types/aws-dynamo.js" }
18
+ ],
19
+ "service.test.ts": [
20
+ { "from": "./service.js", "to": "./dynamo.js" }
21
+ ]
22
+ },
23
+ "patches": {
24
+ "src/suite.ts": {
25
+ "imports": [
26
+ "import { createDynamoService } from \"./services/dynamo.js\";",
27
+ "import type { DynamoConfig } from \"./types/aws-dynamo.js\";"
28
+ ],
29
+ "configs": " private dynamoConfig?: DynamoConfig;",
30
+ "methods": " dynamo(config: DynamoConfig): this { this.dynamoConfig = config; return this; }",
31
+ "services": " dynamo: createDynamoService(this.dynamoConfig),"
32
+ },
33
+ "src/types/suite.ts": {
34
+ "type-imports": "import type { DynamoConfig, PutItemResult, GetItemResult, QueryResult, DeleteItemResult, UpdateItemResult } from \"./aws-dynamo.js\";",
35
+ "service-types": " dynamo: { putItem(tableName: string, item: Record<string, any>): Promise<PutItemResult>; getItem(tableName: string, key: Record<string, any>): Promise<GetItemResult>; queryItems(tableName: string, params: Record<string, any>): Promise<QueryResult>; updateItem(tableName: string, key: Record<string, any>, params: Record<string, any>): Promise<UpdateItemResult>; deleteItem(tableName: string, key: Record<string, any>): Promise<DeleteItemResult>; scanItems(tableName: string, params?: Record<string, any>): Promise<QueryResult>; };"
36
+ }
37
+ }
38
+ }
@@ -0,0 +1,180 @@
1
+ import { createDynamoService } from './service.js';
2
+
3
+ jest.mock('@aws-sdk/client-dynamodb');
4
+ jest.mock('@aws-sdk/util-dynamodb');
5
+ import { DynamoDBClient, PutItemCommand, GetItemCommand, QueryCommand, DeleteItemCommand, ScanCommand, UpdateItemCommand } from '@aws-sdk/client-dynamodb';
6
+ import type { QueryCommandInput, ScanCommandInput, UpdateItemCommandInput } from '@aws-sdk/client-dynamodb';
7
+ import { marshall, unmarshall } from '@aws-sdk/util-dynamodb';
8
+
9
+ const mockSend = jest.fn();
10
+
11
+ beforeEach(() => {
12
+ jest.clearAllMocks();
13
+ (DynamoDBClient as jest.Mock).mockImplementation(() => ({ send: mockSend }));
14
+ (marshall as jest.Mock).mockImplementation((obj) => obj);
15
+ (unmarshall as jest.Mock).mockImplementation((obj) => obj);
16
+ });
17
+
18
+ describe('createDynamoService', () => {
19
+ const config = { region: 'us-east-1' };
20
+
21
+ describe('putItem', () => {
22
+ it('sends a PutItemCommand with the table name and marshalled item', async () => {
23
+ mockSend.mockResolvedValue({});
24
+ const dynamo = createDynamoService(config);
25
+ const item = { id: 'abc', name: 'test' };
26
+ await dynamo.putItem('my-table', item);
27
+ const commandArg = (PutItemCommand as unknown as jest.Mock).mock.calls[0][0];
28
+ expect(commandArg.TableName).toBe('my-table');
29
+ expect(commandArg.Item).toEqual(item);
30
+ });
31
+
32
+ it('returns success with the table name and item field count', async () => {
33
+ mockSend.mockResolvedValue({});
34
+ const dynamo = createDynamoService(config);
35
+ const result = await dynamo.putItem('my-table', { id: '1', name: 'test' });
36
+ expect(result).toEqual({ success: true, tableName: 'my-table', itemCount: 2 });
37
+ });
38
+
39
+ });
40
+
41
+ describe('getItem', () => {
42
+ it('sends a GetItemCommand with the table name and marshalled key', async () => {
43
+ mockSend.mockResolvedValue({ Item: { id: 'abc' } });
44
+ const dynamo = createDynamoService(config);
45
+ await dynamo.getItem('my-table', { id: 'abc' });
46
+ const commandArg = (GetItemCommand as unknown as jest.Mock).mock.calls[0][0];
47
+ expect(commandArg.TableName).toBe('my-table');
48
+ expect(commandArg.Key).toEqual({ id: 'abc' });
49
+ });
50
+
51
+ it('returns the unmarshalled item and found: true when item exists', async () => {
52
+ const rawItem = { id: 'abc', name: 'test' };
53
+ mockSend.mockResolvedValue({ Item: rawItem });
54
+ const dynamo = createDynamoService(config);
55
+ const result = await dynamo.getItem('my-table', { id: 'abc' });
56
+ expect(result).toEqual({ item: rawItem, found: true });
57
+ });
58
+
59
+ it('returns null and found: false when item does not exist', async () => {
60
+ mockSend.mockResolvedValue({});
61
+ const dynamo = createDynamoService(config);
62
+ const result = await dynamo.getItem('my-table', { id: 'missing' });
63
+ expect(result).toEqual({ item: null, found: false });
64
+ });
65
+
66
+ });
67
+
68
+ describe('queryItems', () => {
69
+ const params: Omit<QueryCommandInput, 'TableName'> = {
70
+ KeyConditionExpression: '#pk = :pk',
71
+ ExpressionAttributeNames: { '#pk': 'userId' },
72
+ ExpressionAttributeValues: { ':pk': { S: 'user-123' } },
73
+ };
74
+
75
+ it('sends a QueryCommand with the table name and provided params', async () => {
76
+ mockSend.mockResolvedValue({ Items: [] });
77
+ const dynamo = createDynamoService(config);
78
+ await dynamo.queryItems('my-table', params);
79
+ const commandArg = (QueryCommand as unknown as jest.Mock).mock.calls[0][0];
80
+ expect(commandArg.TableName).toBe('my-table');
81
+ expect(commandArg.KeyConditionExpression).toBe('#pk = :pk');
82
+ });
83
+
84
+ it('returns unmarshalled items and count', async () => {
85
+ const raw = [{ userId: 'user-123', sk: 'order-1' }];
86
+ mockSend.mockResolvedValue({ Items: raw });
87
+ const dynamo = createDynamoService(config);
88
+ const result = await dynamo.queryItems('my-table', params);
89
+ expect(result).toEqual({ items: raw, count: 1 });
90
+ });
91
+
92
+ it('returns empty items when response has no Items', async () => {
93
+ mockSend.mockResolvedValue({});
94
+ const dynamo = createDynamoService(config);
95
+ const result = await dynamo.queryItems('my-table', params);
96
+ expect(result).toEqual({ items: [], count: 0 });
97
+ });
98
+
99
+ });
100
+
101
+ describe('updateItem', () => {
102
+ const key = { id: 'abc' };
103
+ const params: Omit<UpdateItemCommandInput, 'TableName' | 'Key'> = {
104
+ UpdateExpression: 'SET #n = :n',
105
+ ExpressionAttributeNames: { '#n': 'name' },
106
+ ExpressionAttributeValues: { ':n': { S: 'Jane Doe' } },
107
+ };
108
+
109
+ it('sends an UpdateItemCommand with the table name, marshalled key, and params', async () => {
110
+ mockSend.mockResolvedValue({});
111
+ const dynamo = createDynamoService(config);
112
+ await dynamo.updateItem('my-table', key, params);
113
+ const commandArg = (UpdateItemCommand as unknown as jest.Mock).mock.calls[0][0];
114
+ expect(commandArg.TableName).toBe('my-table');
115
+ expect(commandArg.Key).toEqual(key);
116
+ expect(commandArg.UpdateExpression).toBe('SET #n = :n');
117
+ });
118
+
119
+ it('returns success and the table name', async () => {
120
+ mockSend.mockResolvedValue({});
121
+ const dynamo = createDynamoService(config);
122
+ const result = await dynamo.updateItem('my-table', key, params);
123
+ expect(result).toEqual({ success: true, tableName: 'my-table' });
124
+ });
125
+
126
+ });
127
+
128
+ describe('deleteItem', () => {
129
+ it('sends a DeleteItemCommand with the table name and marshalled key', async () => {
130
+ mockSend.mockResolvedValue({});
131
+ const dynamo = createDynamoService(config);
132
+ await dynamo.deleteItem('my-table', { id: 'abc' });
133
+ const commandArg = (DeleteItemCommand as unknown as jest.Mock).mock.calls[0][0];
134
+ expect(commandArg.TableName).toBe('my-table');
135
+ expect(commandArg.Key).toEqual({ id: 'abc' });
136
+ });
137
+
138
+ it('returns success and the table name', async () => {
139
+ mockSend.mockResolvedValue({});
140
+ const dynamo = createDynamoService(config);
141
+ const result = await dynamo.deleteItem('my-table', { id: 'abc' });
142
+ expect(result).toEqual({ success: true, tableName: 'my-table' });
143
+ });
144
+
145
+ });
146
+
147
+ describe('scanItems', () => {
148
+ it('sends a ScanCommand with the table name and provided params', async () => {
149
+ mockSend.mockResolvedValue({ Items: [] });
150
+ const dynamo = createDynamoService(config);
151
+ const scanParams: Omit<ScanCommandInput, 'TableName'> = { Limit: 10 };
152
+ await dynamo.scanItems('my-table', scanParams);
153
+ const commandArg = (ScanCommand as unknown as jest.Mock).mock.calls[0][0];
154
+ expect(commandArg.TableName).toBe('my-table');
155
+ expect(commandArg.Limit).toBe(10);
156
+ });
157
+
158
+ it('returns unmarshalled items and count', async () => {
159
+ const raw = [{ id: 'a' }, { id: 'b' }];
160
+ mockSend.mockResolvedValue({ Items: raw });
161
+ const dynamo = createDynamoService(config);
162
+ const result = await dynamo.scanItems('my-table');
163
+ expect(result).toEqual({ items: raw, count: 2 });
164
+ });
165
+
166
+ it('returns empty items when response has no Items', async () => {
167
+ mockSend.mockResolvedValue({});
168
+ const dynamo = createDynamoService(config);
169
+ const result = await dynamo.scanItems('my-table');
170
+ expect(result).toEqual({ items: [], count: 0 });
171
+ });
172
+
173
+ });
174
+
175
+ it('throws error when a method is called without config', async () => {
176
+ const dynamo = createDynamoService();
177
+ await expect(dynamo.putItem('my-table', { id: '1' })).rejects.toThrow('dynamo service is not configured');
178
+ });
179
+
180
+ });
@@ -0,0 +1,73 @@
1
+ import { DynamoDBClient, PutItemCommand, GetItemCommand, QueryCommand, DeleteItemCommand, ScanCommand, UpdateItemCommand } from "@aws-sdk/client-dynamodb";
2
+ import type { QueryCommandInput, ScanCommandInput, UpdateItemCommandInput } from "@aws-sdk/client-dynamodb";
3
+ import { marshall, unmarshall } from "@aws-sdk/util-dynamodb";
4
+ import type { DynamoConfig, PutItemResult, GetItemResult, QueryResult, DeleteItemResult, UpdateItemResult } from "./types.js";
5
+
6
+ export function createDynamoService(config?: DynamoConfig) {
7
+ const ensureConfig = () => {
8
+ if (!config) throw new Error("dynamo service is not configured -- call .dynamo(config) on the suite");
9
+ };
10
+ const client = config ? new DynamoDBClient({ region: config.region }) : null;
11
+
12
+ return {
13
+ putItem: async (tableName: string, item: Record<string, any>): Promise<PutItemResult> => {
14
+ ensureConfig();
15
+ const marshalledItem = marshall(item);
16
+ await client!.send(
17
+ new PutItemCommand({
18
+ TableName: tableName,
19
+ Item: marshalledItem,
20
+ })
21
+ );
22
+ return {
23
+ success: true,
24
+ tableName,
25
+ itemCount: Object.keys(item).length,
26
+ };
27
+ },
28
+
29
+ getItem: async (tableName: string, key: Record<string, any>): Promise<GetItemResult> => {
30
+ ensureConfig();
31
+ const marshalledKey = marshall(key);
32
+ const response = await client!.send(
33
+ new GetItemCommand({
34
+ TableName: tableName,
35
+ Key: marshalledKey,
36
+ })
37
+ );
38
+
39
+ if (!response.Item) {
40
+ return { item: null, found: false };
41
+ }
42
+
43
+ const unmarshalledItem = unmarshall(response.Item);
44
+ return { item: unmarshalledItem, found: true };
45
+ },
46
+
47
+ queryItems: async (tableName: string, params: Omit<QueryCommandInput, 'TableName'>): Promise<QueryResult> => {
48
+ ensureConfig();
49
+ const response = await client!.send(new QueryCommand({ TableName: tableName, ...params }));
50
+ const items = (response.Items ?? []).map((item) => unmarshall(item));
51
+ return { items, count: items.length };
52
+ },
53
+
54
+ deleteItem: async (tableName: string, key: Record<string, any>): Promise<DeleteItemResult> => {
55
+ ensureConfig();
56
+ await client!.send(new DeleteItemCommand({ TableName: tableName, Key: marshall(key) }));
57
+ return { success: true, tableName };
58
+ },
59
+
60
+ updateItem: async (tableName: string, key: Record<string, any>, params: Omit<UpdateItemCommandInput, 'TableName' | 'Key'>): Promise<UpdateItemResult> => {
61
+ ensureConfig();
62
+ await client!.send(new UpdateItemCommand({ TableName: tableName, Key: marshall(key), ...params }));
63
+ return { success: true, tableName };
64
+ },
65
+
66
+ scanItems: async (tableName: string, params: Omit<ScanCommandInput, 'TableName'> = {}): Promise<QueryResult> => {
67
+ ensureConfig();
68
+ const response = await client!.send(new ScanCommand({ TableName: tableName, ...params }));
69
+ const items = (response.Items ?? []).map((item) => unmarshall(item));
70
+ return { items, count: items.length };
71
+ },
72
+ };
73
+ }
@@ -0,0 +1,29 @@
1
+ export interface DynamoConfig {
2
+ region: string;
3
+ }
4
+
5
+ export interface PutItemResult {
6
+ success: boolean;
7
+ tableName: string;
8
+ itemCount: number;
9
+ }
10
+
11
+ export interface GetItemResult {
12
+ item: Record<string, any> | null;
13
+ found: boolean;
14
+ }
15
+
16
+ export interface QueryResult {
17
+ items: Record<string, any>[];
18
+ count: number;
19
+ }
20
+
21
+ export interface DeleteItemResult {
22
+ success: boolean;
23
+ tableName: string;
24
+ }
25
+
26
+ export interface UpdateItemResult {
27
+ success: boolean;
28
+ tableName: string;
29
+ }
@@ -0,0 +1,2 @@
1
+ S3_BUCKET_NAME=
2
+ AWS_REGION=
@@ -0,0 +1,41 @@
1
+ import "../load-env.js";
2
+ import { suite } from "../suite.js";
3
+
4
+ await suite()
5
+ .s3({
6
+ region: process.env.AWS_REGION!,
7
+ })
8
+ .step("upload-file", async (ctx) => {
9
+ const result = await ctx.services.s3.uploadFile(
10
+ process.env.S3_BUCKET_NAME!,
11
+ "uploads/example.txt",
12
+ "./example.txt",
13
+ {
14
+ contentType: "text/plain",
15
+ metadata: { source: "dtk-example" },
16
+ }
17
+ );
18
+ console.log("Uploaded:", result);
19
+ return result;
20
+ })
21
+ .step("get-presigned-url", async (ctx) => {
22
+ const result = await ctx.services.s3.getPresignedUrl(
23
+ process.env.S3_BUCKET_NAME!,
24
+ "uploads/example.txt",
25
+ 300
26
+ );
27
+ console.log("Presigned URL:", result.url);
28
+ console.log("Expires in:", result.expiresIn, "seconds");
29
+ return result;
30
+ })
31
+ .step("download-file", async (ctx) => {
32
+ const result = await ctx.services.s3.downloadFile(
33
+ process.env.S3_BUCKET_NAME!,
34
+ "uploads/example.txt",
35
+ "./downloaded-example.txt"
36
+ );
37
+ console.log("Downloaded to:", result.localPath);
38
+ console.log("Content type:", result.contentType);
39
+ return result;
40
+ })
41
+ .run("throwOnError");
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "aws-s3",
3
+ "description": "AWS S3 -- upload files, download files, and generate presigned URLs",
4
+ "dependencies": {
5
+ "@aws-sdk/client-s3": "^3.300.0",
6
+ "@aws-sdk/s3-request-presigner": "^3.300.0"
7
+ },
8
+ "files": [
9
+ { "src": "service.ts", "dest": "src/services/s3.ts" },
10
+ { "src": "types.ts", "dest": "src/types/aws-s3.ts" },
11
+ { "src": "service.test.ts", "dest": "src/services/s3.test.ts" }
12
+ ],
13
+ "env": "env.txt",
14
+ "example": "example.ts",
15
+ "transforms": {
16
+ "service.ts": [
17
+ { "from": "./types.js", "to": "../types/aws-s3.js" }
18
+ ],
19
+ "service.test.ts": [
20
+ { "from": "./service.js", "to": "./s3.js" }
21
+ ]
22
+ },
23
+ "patches": {
24
+ "src/suite.ts": {
25
+ "imports": [
26
+ "import { createS3Service } from \"./services/s3.js\";",
27
+ "import type { S3Config } from \"./types/aws-s3.js\";"
28
+ ],
29
+ "configs": " private s3Config?: S3Config;",
30
+ "methods": " s3(config: S3Config): this { this.s3Config = config; return this; }",
31
+ "services": " s3: createS3Service(this.s3Config),"
32
+ },
33
+ "src/types/suite.ts": {
34
+ "type-imports": "import type { S3Config, UploadOptions, UploadFileResult, DownloadFileResult, PresignedUrlResult } from \"./aws-s3.js\";",
35
+ "service-types": " s3: { uploadFile(bucket: string, key: string, filePath: string, options?: UploadOptions): Promise<UploadFileResult>; downloadFile(bucket: string, key: string, localPath: string): Promise<DownloadFileResult>; getPresignedUrl(bucket: string, key: string, expiresIn?: number): Promise<PresignedUrlResult>; };"
36
+ }
37
+ }
38
+ }
@@ -0,0 +1,150 @@
1
+ import { createS3Service } from './service.js';
2
+
3
+ jest.mock('@aws-sdk/client-s3');
4
+ jest.mock('@aws-sdk/s3-request-presigner');
5
+ jest.mock('fs/promises');
6
+
7
+ import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
8
+ import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
9
+ import { readFile, writeFile } from 'fs/promises';
10
+
11
+ const mockSend = jest.fn();
12
+
13
+ beforeEach(() => {
14
+ jest.clearAllMocks();
15
+ (S3Client as jest.Mock).mockImplementation(() => ({ send: mockSend }));
16
+ });
17
+
18
+ describe('createS3Service', () => {
19
+ const config = { region: 'us-east-1' };
20
+
21
+ describe('uploadFile', () => {
22
+ it('reads the local file and sends a PutObjectCommand with bucket, key, and body', async () => {
23
+ const fileContent = Buffer.from('hello world');
24
+ (readFile as jest.Mock).mockResolvedValue(fileContent);
25
+ mockSend.mockResolvedValue({ ETag: '"abc123"' });
26
+ const s3 = createS3Service(config);
27
+ await s3.uploadFile('my-bucket', 'uploads/file.txt', './file.txt');
28
+ const commandArg = (PutObjectCommand as unknown as jest.Mock).mock.calls[0][0];
29
+ expect(commandArg.Bucket).toBe('my-bucket');
30
+ expect(commandArg.Key).toBe('uploads/file.txt');
31
+ expect(commandArg.Body).toBe(fileContent);
32
+ });
33
+
34
+ it('includes ContentType when provided in options', async () => {
35
+ (readFile as jest.Mock).mockResolvedValue(Buffer.from(''));
36
+ mockSend.mockResolvedValue({ ETag: '"abc123"' });
37
+ const s3 = createS3Service(config);
38
+ await s3.uploadFile('my-bucket', 'key', './file.txt', { contentType: 'text/plain' });
39
+ const commandArg = (PutObjectCommand as unknown as jest.Mock).mock.calls[0][0];
40
+ expect(commandArg.ContentType).toBe('text/plain');
41
+ });
42
+
43
+ it('includes Metadata when provided in options', async () => {
44
+ (readFile as jest.Mock).mockResolvedValue(Buffer.from(''));
45
+ mockSend.mockResolvedValue({ ETag: '"abc123"' });
46
+ const s3 = createS3Service(config);
47
+ await s3.uploadFile('my-bucket', 'key', './file.txt', { metadata: { source: 'dtk' } });
48
+ const commandArg = (PutObjectCommand as unknown as jest.Mock).mock.calls[0][0];
49
+ expect(commandArg.Metadata).toEqual({ source: 'dtk' });
50
+ });
51
+
52
+ it('omits ContentType and Metadata when no options are provided', async () => {
53
+ (readFile as jest.Mock).mockResolvedValue(Buffer.from(''));
54
+ mockSend.mockResolvedValue({ ETag: '"abc123"' });
55
+ const s3 = createS3Service(config);
56
+ await s3.uploadFile('my-bucket', 'key', './file.txt');
57
+ const commandArg = (PutObjectCommand as unknown as jest.Mock).mock.calls[0][0];
58
+ expect(commandArg.ContentType).toBeUndefined();
59
+ expect(commandArg.Metadata).toBeUndefined();
60
+ });
61
+
62
+ it('returns the bucket, key, and etag from the response', async () => {
63
+ (readFile as jest.Mock).mockResolvedValue(Buffer.from(''));
64
+ mockSend.mockResolvedValue({ ETag: '"abc123"' });
65
+ const s3 = createS3Service(config);
66
+ const result = await s3.uploadFile('my-bucket', 'uploads/file.txt', './file.txt');
67
+ expect(result).toEqual({ bucket: 'my-bucket', key: 'uploads/file.txt', etag: '"abc123"' });
68
+ });
69
+
70
+ it('returns null etag when not present in the response', async () => {
71
+ (readFile as jest.Mock).mockResolvedValue(Buffer.from(''));
72
+ mockSend.mockResolvedValue({});
73
+ const s3 = createS3Service(config);
74
+ const result = await s3.uploadFile('my-bucket', 'key', './file.txt');
75
+ expect(result.etag).toBeNull();
76
+ });
77
+ });
78
+
79
+ describe('downloadFile', () => {
80
+ it('sends a GetObjectCommand with the bucket and key', async () => {
81
+ const mockBody = { transformToByteArray: jest.fn().mockResolvedValue(new Uint8Array()) };
82
+ mockSend.mockResolvedValue({ Body: mockBody, ContentType: 'text/plain' });
83
+ (writeFile as jest.Mock).mockResolvedValue(undefined);
84
+ const s3 = createS3Service(config);
85
+ await s3.downloadFile('my-bucket', 'uploads/file.txt', './local.txt');
86
+ const commandArg = (GetObjectCommand as unknown as jest.Mock).mock.calls[0][0];
87
+ expect(commandArg.Bucket).toBe('my-bucket');
88
+ expect(commandArg.Key).toBe('uploads/file.txt');
89
+ });
90
+
91
+ it('writes the response body to the local path', async () => {
92
+ const bytes = new Uint8Array([104, 101, 108, 108, 111]);
93
+ const mockBody = { transformToByteArray: jest.fn().mockResolvedValue(bytes) };
94
+ mockSend.mockResolvedValue({ Body: mockBody, ContentType: 'text/plain' });
95
+ (writeFile as jest.Mock).mockResolvedValue(undefined);
96
+ const s3 = createS3Service(config);
97
+ await s3.downloadFile('my-bucket', 'uploads/file.txt', './local.txt');
98
+ expect(writeFile).toHaveBeenCalledWith('./local.txt', bytes);
99
+ });
100
+
101
+ it('returns bucket, key, localPath, and contentType', async () => {
102
+ const mockBody = { transformToByteArray: jest.fn().mockResolvedValue(new Uint8Array()) };
103
+ mockSend.mockResolvedValue({ Body: mockBody, ContentType: 'application/json' });
104
+ (writeFile as jest.Mock).mockResolvedValue(undefined);
105
+ const s3 = createS3Service(config);
106
+ const result = await s3.downloadFile('my-bucket', 'uploads/file.txt', './local.txt');
107
+ expect(result).toEqual({ bucket: 'my-bucket', key: 'uploads/file.txt', localPath: './local.txt', contentType: 'application/json' });
108
+ });
109
+
110
+ it('returns undefined contentType when not present in the response', async () => {
111
+ const mockBody = { transformToByteArray: jest.fn().mockResolvedValue(new Uint8Array()) };
112
+ mockSend.mockResolvedValue({ Body: mockBody });
113
+ (writeFile as jest.Mock).mockResolvedValue(undefined);
114
+ const s3 = createS3Service(config);
115
+ const result = await s3.downloadFile('my-bucket', 'key', './local.txt');
116
+ expect(result.contentType).toBeUndefined();
117
+ });
118
+ });
119
+
120
+ describe('getPresignedUrl', () => {
121
+ it('defaults expiresIn to 3600 when not provided', async () => {
122
+ (getSignedUrl as jest.Mock).mockResolvedValue('https://s3.example.com/presigned');
123
+ const s3 = createS3Service(config);
124
+ const result = await s3.getPresignedUrl('my-bucket', 'uploads/file.txt');
125
+ expect(getSignedUrl).toHaveBeenCalledWith(expect.anything(), expect.anything(), { expiresIn: 3600 });
126
+ expect(result.expiresIn).toBe(3600);
127
+ });
128
+
129
+ it('uses the provided expiresIn value', async () => {
130
+ (getSignedUrl as jest.Mock).mockResolvedValue('https://s3.example.com/presigned');
131
+ const s3 = createS3Service(config);
132
+ const result = await s3.getPresignedUrl('my-bucket', 'key', 300);
133
+ expect(getSignedUrl).toHaveBeenCalledWith(expect.anything(), expect.anything(), { expiresIn: 300 });
134
+ expect(result.expiresIn).toBe(300);
135
+ });
136
+
137
+ it('returns bucket and key alongside url and expiresIn', async () => {
138
+ (getSignedUrl as jest.Mock).mockResolvedValue('https://s3.example.com/presigned');
139
+ const s3 = createS3Service(config);
140
+ const result = await s3.getPresignedUrl('my-bucket', 'uploads/file.txt', 300);
141
+ expect(result).toEqual({ url: 'https://s3.example.com/presigned', expiresIn: 300, bucket: 'my-bucket', key: 'uploads/file.txt' });
142
+ });
143
+ });
144
+
145
+ it('throws error when a method is called without config', async () => {
146
+ const s3 = createS3Service();
147
+ await expect(s3.uploadFile('bucket', 'key', './file.txt')).rejects.toThrow('s3 service is not configured');
148
+ });
149
+
150
+ });
@@ -0,0 +1,43 @@
1
+ import { S3Client, PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3";
2
+ import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
3
+ import { readFile, writeFile } from "fs/promises";
4
+ import type { S3Config, UploadOptions, UploadFileResult, DownloadFileResult, PresignedUrlResult } from "./types.js";
5
+
6
+ export function createS3Service(config?: S3Config) {
7
+ const ensureConfig = () => {
8
+ if (!config) throw new Error("s3 service is not configured -- call .s3(config) on the suite");
9
+ };
10
+ const client = config ? new S3Client({ region: config.region }) : null;
11
+
12
+ return {
13
+ uploadFile: async (bucket: string, key: string, filePath: string, options: UploadOptions = {}): Promise<UploadFileResult> => {
14
+ ensureConfig();
15
+ const body = await readFile(filePath);
16
+ const response = await client!.send(
17
+ new PutObjectCommand({
18
+ Bucket: bucket,
19
+ Key: key,
20
+ Body: body,
21
+ ...(options.contentType && { ContentType: options.contentType }),
22
+ ...(options.metadata && { Metadata: options.metadata }),
23
+ })
24
+ );
25
+ return { bucket, key, etag: response.ETag ?? null };
26
+ },
27
+
28
+ downloadFile: async (bucket: string, key: string, localPath: string): Promise<DownloadFileResult> => {
29
+ ensureConfig();
30
+ const response = await client!.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
31
+ const body = await response.Body!.transformToByteArray();
32
+ await writeFile(localPath, body);
33
+ return { bucket, key, localPath, contentType: response.ContentType };
34
+ },
35
+
36
+ getPresignedUrl: async (bucket: string, key: string, expiresIn: number = 3600): Promise<PresignedUrlResult> => {
37
+ ensureConfig();
38
+ const command = new GetObjectCommand({ Bucket: bucket, Key: key });
39
+ const url = await getSignedUrl(client!, command, { expiresIn });
40
+ return { url, expiresIn, bucket, key };
41
+ },
42
+ };
43
+ }