@objectql/server 1.8.0 → 1.8.2
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.
- package/CHANGELOG.md +18 -0
- package/dist/adapters/node.d.ts +9 -1
- package/dist/adapters/node.js +30 -1
- package/dist/adapters/node.js.map +1 -1
- package/dist/file-handler.d.ts +50 -0
- package/dist/file-handler.js +354 -0
- package/dist/file-handler.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/storage.d.ts +35 -0
- package/dist/storage.js +181 -0
- package/dist/storage.js.map +1 -0
- package/dist/types.d.ts +72 -0
- package/package.json +16 -4
- package/src/adapters/node.ts +44 -2
- package/src/file-handler.ts +414 -0
- package/src/index.ts +2 -0
- package/src/storage.ts +171 -0
- package/src/types.ts +79 -0
- package/test/file-upload-integration.example.ts +201 -0
- package/test/file-validation.test.ts +131 -0
- package/test/storage.test.ts +104 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration Test Example for File Upload/Download
|
|
3
|
+
*
|
|
4
|
+
* This example demonstrates how to use the file upload/download API
|
|
5
|
+
* with a complete ObjectQL server setup.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { ObjectQL } from '@objectql/core';
|
|
9
|
+
import { SqlDriver } from '@objectql/driver-sql';
|
|
10
|
+
import { createNodeHandler, MemoryFileStorage } from '../src';
|
|
11
|
+
import * as http from 'http';
|
|
12
|
+
|
|
13
|
+
async function setupServer() {
|
|
14
|
+
// 1. Initialize Driver (In-Memory SQLite)
|
|
15
|
+
const driver = new SqlDriver({
|
|
16
|
+
client: 'sqlite3',
|
|
17
|
+
connection: { filename: ':memory:' },
|
|
18
|
+
useNullAsDefault: true
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// 2. Initialize Engine
|
|
22
|
+
const app = new ObjectQL({
|
|
23
|
+
datasources: { default: driver }
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// 3. Define Schema with File Attachment
|
|
27
|
+
app.registerObject({
|
|
28
|
+
name: 'expense',
|
|
29
|
+
label: 'Expense',
|
|
30
|
+
fields: {
|
|
31
|
+
expense_number: {
|
|
32
|
+
type: 'text',
|
|
33
|
+
required: true,
|
|
34
|
+
label: 'Expense Number'
|
|
35
|
+
},
|
|
36
|
+
amount: {
|
|
37
|
+
type: 'number',
|
|
38
|
+
required: true,
|
|
39
|
+
label: 'Amount'
|
|
40
|
+
},
|
|
41
|
+
description: {
|
|
42
|
+
type: 'textarea',
|
|
43
|
+
label: 'Description'
|
|
44
|
+
},
|
|
45
|
+
receipt: {
|
|
46
|
+
type: 'file',
|
|
47
|
+
label: 'Receipt',
|
|
48
|
+
accept: ['.pdf', '.jpg', '.jpeg', '.png'],
|
|
49
|
+
max_size: 5242880 // 5MB
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
await app.init();
|
|
55
|
+
|
|
56
|
+
// 4. Create HTTP Server with File Storage
|
|
57
|
+
const fileStorage = new MemoryFileStorage({
|
|
58
|
+
baseUrl: 'http://localhost:3000/api/files'
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const handler = createNodeHandler(app, { fileStorage });
|
|
62
|
+
const server = http.createServer(handler);
|
|
63
|
+
|
|
64
|
+
return { server, app, fileStorage };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function demonstrateUsage() {
|
|
68
|
+
console.log('=== ObjectQL File Upload/Download Integration Example ===\n');
|
|
69
|
+
|
|
70
|
+
const { server, app, fileStorage } = await setupServer();
|
|
71
|
+
|
|
72
|
+
// Start server
|
|
73
|
+
await new Promise<void>((resolve) => {
|
|
74
|
+
server.listen(3000, () => {
|
|
75
|
+
console.log('✓ Server started on http://localhost:3000');
|
|
76
|
+
resolve();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
// Example 1: Upload a file
|
|
82
|
+
console.log('\n1. Uploading a file...');
|
|
83
|
+
const fileContent = Buffer.from('Sample receipt content');
|
|
84
|
+
const uploadedFile = await fileStorage.save(
|
|
85
|
+
fileContent,
|
|
86
|
+
'receipt.pdf',
|
|
87
|
+
'application/pdf',
|
|
88
|
+
{ userId: 'user_123' }
|
|
89
|
+
);
|
|
90
|
+
console.log('✓ File uploaded:', uploadedFile);
|
|
91
|
+
|
|
92
|
+
// Example 2: Create an expense with the uploaded file
|
|
93
|
+
console.log('\n2. Creating expense record with attachment...');
|
|
94
|
+
const ctx = app.createContext({ isSystem: true });
|
|
95
|
+
const expenseRepo = ctx.object('expense');
|
|
96
|
+
|
|
97
|
+
const expense = await expenseRepo.create({
|
|
98
|
+
expense_number: 'EXP-2024-001',
|
|
99
|
+
amount: 125.50,
|
|
100
|
+
description: 'Office supplies',
|
|
101
|
+
receipt: uploadedFile
|
|
102
|
+
});
|
|
103
|
+
console.log('✓ Expense created:', {
|
|
104
|
+
id: expense.id,
|
|
105
|
+
expense_number: expense.expense_number,
|
|
106
|
+
receipt: expense.receipt
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Example 3: Retrieve the file
|
|
110
|
+
console.log('\n3. Downloading the file...');
|
|
111
|
+
const downloadedFile = await fileStorage.get(uploadedFile.id!);
|
|
112
|
+
console.log('✓ File downloaded:', {
|
|
113
|
+
size: downloadedFile?.length,
|
|
114
|
+
content: downloadedFile?.toString().substring(0, 50) + '...'
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Example 4: Query expenses with attachments
|
|
118
|
+
console.log('\n4. Querying expenses with attachments...');
|
|
119
|
+
const expenses = await expenseRepo.find({
|
|
120
|
+
filters: [['receipt', '!=', null]]
|
|
121
|
+
});
|
|
122
|
+
console.log('✓ Found expenses with receipts:', expenses.length);
|
|
123
|
+
expenses.forEach((exp: any) => {
|
|
124
|
+
console.log(` - ${exp.expense_number}: ${exp.receipt?.name}`);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Example 5: Update attachment
|
|
128
|
+
console.log('\n5. Uploading and updating receipt...');
|
|
129
|
+
const newFileContent = Buffer.from('Updated receipt content');
|
|
130
|
+
const newFile = await fileStorage.save(
|
|
131
|
+
newFileContent,
|
|
132
|
+
'updated_receipt.pdf',
|
|
133
|
+
'application/pdf',
|
|
134
|
+
{ userId: 'user_123' }
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
await expenseRepo.update(expense.id, {
|
|
138
|
+
receipt: newFile
|
|
139
|
+
});
|
|
140
|
+
console.log('✓ Receipt updated');
|
|
141
|
+
|
|
142
|
+
// Example 6: Multiple file upload (product gallery)
|
|
143
|
+
console.log('\n6. Multiple file upload example...');
|
|
144
|
+
app.registerObject({
|
|
145
|
+
name: 'product',
|
|
146
|
+
label: 'Product',
|
|
147
|
+
fields: {
|
|
148
|
+
name: { type: 'text', required: true },
|
|
149
|
+
price: { type: 'number' },
|
|
150
|
+
gallery: {
|
|
151
|
+
type: 'image',
|
|
152
|
+
label: 'Product Gallery',
|
|
153
|
+
multiple: true,
|
|
154
|
+
accept: ['.jpg', '.jpeg', '.png'],
|
|
155
|
+
max_size: 2097152 // 2MB per image
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const productRepo = ctx.object('product');
|
|
161
|
+
|
|
162
|
+
// Upload multiple images
|
|
163
|
+
const images = await Promise.all([
|
|
164
|
+
fileStorage.save(
|
|
165
|
+
Buffer.from('Image 1 content'),
|
|
166
|
+
'product_1.jpg',
|
|
167
|
+
'image/jpeg',
|
|
168
|
+
{ userId: 'user_123' }
|
|
169
|
+
),
|
|
170
|
+
fileStorage.save(
|
|
171
|
+
Buffer.from('Image 2 content'),
|
|
172
|
+
'product_2.jpg',
|
|
173
|
+
'image/jpeg',
|
|
174
|
+
{ userId: 'user_123' }
|
|
175
|
+
)
|
|
176
|
+
]);
|
|
177
|
+
|
|
178
|
+
const product = await productRepo.create({
|
|
179
|
+
name: 'Premium Laptop',
|
|
180
|
+
price: 1299.99,
|
|
181
|
+
gallery: images
|
|
182
|
+
});
|
|
183
|
+
console.log('✓ Product created with gallery:', {
|
|
184
|
+
id: product.id,
|
|
185
|
+
name: product.name,
|
|
186
|
+
images: product.gallery.length
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
} finally {
|
|
190
|
+
// Cleanup
|
|
191
|
+
server.close();
|
|
192
|
+
console.log('\n✓ Server stopped');
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Run the example
|
|
197
|
+
if (require.main === module) {
|
|
198
|
+
demonstrateUsage().catch(console.error);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export { setupServer, demonstrateUsage };
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { validateFile } from '../src/file-handler';
|
|
2
|
+
import { FieldConfig } from '@objectql/types';
|
|
3
|
+
|
|
4
|
+
describe('File Validation', () => {
|
|
5
|
+
describe('validateFile', () => {
|
|
6
|
+
it('should pass validation when no field config is provided', () => {
|
|
7
|
+
const file = {
|
|
8
|
+
filename: 'test.pdf',
|
|
9
|
+
mimeType: 'application/pdf',
|
|
10
|
+
buffer: Buffer.from('test content')
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const result = validateFile(file);
|
|
14
|
+
|
|
15
|
+
expect(result.valid).toBe(true);
|
|
16
|
+
expect(result.error).toBeUndefined();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('should validate file size limits', () => {
|
|
20
|
+
const file = {
|
|
21
|
+
filename: 'test.pdf',
|
|
22
|
+
mimeType: 'application/pdf',
|
|
23
|
+
buffer: Buffer.from('a'.repeat(1000)) // 1000 bytes
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const fieldConfig: FieldConfig = {
|
|
27
|
+
type: 'file',
|
|
28
|
+
max_size: 500 // 500 bytes max
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const result = validateFile(file, fieldConfig);
|
|
32
|
+
|
|
33
|
+
expect(result.valid).toBe(false);
|
|
34
|
+
expect(result.error?.code).toBe('FILE_TOO_LARGE');
|
|
35
|
+
expect(result.error?.message).toContain('exceeds maximum');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should validate minimum file size', () => {
|
|
39
|
+
const file = {
|
|
40
|
+
filename: 'test.pdf',
|
|
41
|
+
mimeType: 'application/pdf',
|
|
42
|
+
buffer: Buffer.from('small')
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const fieldConfig: FieldConfig = {
|
|
46
|
+
type: 'file',
|
|
47
|
+
min_size: 100 // 100 bytes minimum
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const result = validateFile(file, fieldConfig);
|
|
51
|
+
|
|
52
|
+
expect(result.valid).toBe(false);
|
|
53
|
+
expect(result.error?.code).toBe('FILE_TOO_SMALL');
|
|
54
|
+
expect(result.error?.message).toContain('below minimum');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should validate accepted file extensions', () => {
|
|
58
|
+
const file = {
|
|
59
|
+
filename: 'test.exe',
|
|
60
|
+
mimeType: 'application/x-msdownload',
|
|
61
|
+
buffer: Buffer.from('test content')
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const fieldConfig: FieldConfig = {
|
|
65
|
+
type: 'file',
|
|
66
|
+
accept: ['.pdf', '.jpg', '.png']
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const result = validateFile(file, fieldConfig);
|
|
70
|
+
|
|
71
|
+
expect(result.valid).toBe(false);
|
|
72
|
+
expect(result.error?.code).toBe('FILE_TYPE_NOT_ALLOWED');
|
|
73
|
+
expect(result.error?.message).toContain('not allowed');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should accept file when extension is in allowed list', () => {
|
|
77
|
+
const file = {
|
|
78
|
+
filename: 'document.pdf',
|
|
79
|
+
mimeType: 'application/pdf',
|
|
80
|
+
buffer: Buffer.from('test content')
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const fieldConfig: FieldConfig = {
|
|
84
|
+
type: 'file',
|
|
85
|
+
accept: ['.pdf', '.docx']
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const result = validateFile(file, fieldConfig);
|
|
89
|
+
|
|
90
|
+
expect(result.valid).toBe(true);
|
|
91
|
+
expect(result.error).toBeUndefined();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('should handle case-insensitive extension matching', () => {
|
|
95
|
+
const file = {
|
|
96
|
+
filename: 'document.PDF',
|
|
97
|
+
mimeType: 'application/pdf',
|
|
98
|
+
buffer: Buffer.from('test content')
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const fieldConfig: FieldConfig = {
|
|
102
|
+
type: 'file',
|
|
103
|
+
accept: ['.pdf']
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const result = validateFile(file, fieldConfig);
|
|
107
|
+
|
|
108
|
+
expect(result.valid).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should pass all validations for valid file', () => {
|
|
112
|
+
const file = {
|
|
113
|
+
filename: 'receipt.jpg',
|
|
114
|
+
mimeType: 'image/jpeg',
|
|
115
|
+
buffer: Buffer.from('a'.repeat(500))
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const fieldConfig: FieldConfig = {
|
|
119
|
+
type: 'image',
|
|
120
|
+
accept: ['.jpg', '.jpeg', '.png'],
|
|
121
|
+
max_size: 1000,
|
|
122
|
+
min_size: 100
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const result = validateFile(file, fieldConfig);
|
|
126
|
+
|
|
127
|
+
expect(result.valid).toBe(true);
|
|
128
|
+
expect(result.error).toBeUndefined();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { MemoryFileStorage } from '../src/storage';
|
|
2
|
+
import { AttachmentData } from '../src/types';
|
|
3
|
+
|
|
4
|
+
describe('MemoryFileStorage', () => {
|
|
5
|
+
let storage: MemoryFileStorage;
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
storage = new MemoryFileStorage({
|
|
9
|
+
baseUrl: 'http://localhost:3000/api/files'
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
storage.clear();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('save', () => {
|
|
18
|
+
it('should save a file and return attachment metadata', async () => {
|
|
19
|
+
const fileBuffer = Buffer.from('test file content');
|
|
20
|
+
const filename = 'test.txt';
|
|
21
|
+
const mimeType = 'text/plain';
|
|
22
|
+
|
|
23
|
+
const result = await storage.save(fileBuffer, filename, mimeType);
|
|
24
|
+
|
|
25
|
+
expect(result).toHaveProperty('id');
|
|
26
|
+
expect(result).toHaveProperty('name');
|
|
27
|
+
expect(result.url).toContain('http://localhost:3000/api/files');
|
|
28
|
+
expect(result.size).toBe(fileBuffer.length);
|
|
29
|
+
expect(result.type).toBe(mimeType);
|
|
30
|
+
expect(result.original_name).toBe(filename);
|
|
31
|
+
expect(result).toHaveProperty('uploaded_at');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should include user ID when provided', async () => {
|
|
35
|
+
const fileBuffer = Buffer.from('test file content');
|
|
36
|
+
const filename = 'test.txt';
|
|
37
|
+
const mimeType = 'text/plain';
|
|
38
|
+
const userId = 'user_123';
|
|
39
|
+
|
|
40
|
+
const result = await storage.save(fileBuffer, filename, mimeType, { userId });
|
|
41
|
+
|
|
42
|
+
expect(result.uploaded_by).toBe(userId);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should preserve file extension in stored filename', async () => {
|
|
46
|
+
const fileBuffer = Buffer.from('test file content');
|
|
47
|
+
const filename = 'document.pdf';
|
|
48
|
+
const mimeType = 'application/pdf';
|
|
49
|
+
|
|
50
|
+
const result = await storage.save(fileBuffer, filename, mimeType);
|
|
51
|
+
|
|
52
|
+
expect(result.name).toMatch(/\.pdf$/);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('get', () => {
|
|
57
|
+
it('should retrieve a saved file', async () => {
|
|
58
|
+
const fileBuffer = Buffer.from('test file content');
|
|
59
|
+
const filename = 'test.txt';
|
|
60
|
+
const mimeType = 'text/plain';
|
|
61
|
+
|
|
62
|
+
const attachment = await storage.save(fileBuffer, filename, mimeType);
|
|
63
|
+
const retrieved = await storage.get(attachment.id!);
|
|
64
|
+
|
|
65
|
+
expect(retrieved).not.toBeNull();
|
|
66
|
+
expect(retrieved!.toString()).toBe(fileBuffer.toString());
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should return null for non-existent file', async () => {
|
|
70
|
+
const result = await storage.get('non_existent_id');
|
|
71
|
+
expect(result).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('delete', () => {
|
|
76
|
+
it('should delete a file', async () => {
|
|
77
|
+
const fileBuffer = Buffer.from('test file content');
|
|
78
|
+
const filename = 'test.txt';
|
|
79
|
+
const mimeType = 'text/plain';
|
|
80
|
+
|
|
81
|
+
const attachment = await storage.save(fileBuffer, filename, mimeType);
|
|
82
|
+
const deleted = await storage.delete(attachment.id!);
|
|
83
|
+
|
|
84
|
+
expect(deleted).toBe(true);
|
|
85
|
+
|
|
86
|
+
const retrieved = await storage.get(attachment.id!);
|
|
87
|
+
expect(retrieved).toBeNull();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should return false when deleting non-existent file', async () => {
|
|
91
|
+
const result = await storage.delete('non_existent_id');
|
|
92
|
+
expect(result).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('getPublicUrl', () => {
|
|
97
|
+
it('should generate a public URL', () => {
|
|
98
|
+
const filePath = 'uploads/test.txt';
|
|
99
|
+
const url = storage.getPublicUrl(filePath);
|
|
100
|
+
|
|
101
|
+
expect(url).toBe('http://localhost:3000/api/files/uploads/test.txt');
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
});
|