hlquery-node-client 1.0.1 → 1.0.3
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/.github/workflows/ci.yml +1 -1
- package/README.md +67 -30
- package/lib/Client.js +142 -0
- package/lib/Documents.js +84 -6
- package/lib/Keys.js +3 -3
- package/lib/Request.js +17 -4
- package/lib/SAM.js +116 -13
- package/lib/conformance.js +10 -0
- package/package.json +1 -1
- package/test/offline-smoke.js +111 -3
- package/utils/CSVParser.js +13 -0
- package/utils/Validator.js +19 -21
- /package/{LICENSE → LICENSE.md} +0 -0
package/.github/workflows/ci.yml
CHANGED
package/README.md
CHANGED
|
@@ -6,35 +6,30 @@
|
|
|
6
6
|
|
|
7
7
|
**A modular Node.js client library for hlquery, designed with a familiar and intuitive API structure.**
|
|
8
8
|
|
|
9
|
-
[](https://x.com/hlquery)
|
|
10
|
-
[](https://github.com/hlquery/hlquery/stargazers)
|
|
13
|
-
[](https://x.com/hlquery)
|
|
10
|
+
[](https://github.com/hlquery/node-api/actions/workflows/ci.yml)
|
|
11
|
+
[](https://github.com/hlquery/node-api/stargazers)
|
|
12
|
+
[](https://github.com/hlquery/hlquery/stargazers)
|
|
13
|
+
[](https://opensource.org/licenses/BSD-3-Clause)
|
|
14
14
|
|
|
15
15
|
</div>
|
|
16
16
|
|
|
17
17
|
### What is the hlquery Node.js API?
|
|
18
18
|
|
|
19
|
-
The hlquery Node.js API is the official Node.js client for hlquery. It wraps the REST interface in a modular service-style client with helpers for collections, documents, search, SQL, and SAM.
|
|
19
|
+
The hlquery Node.js API is the official Node.js client for [hlquery](https://github.com/hlquery/hlquery). It wraps the REST interface in a modular service-style client with helpers for collections, documents, search, SQL, and SAM.
|
|
20
20
|
|
|
21
21
|
It is a good fit for backend services, scripts, dashboards, and apps that want hlquery integration without repeating request code.
|
|
22
22
|
|
|
23
23
|
### Why use it?
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
- Consistent auth, params, and parsed responses.
|
|
27
|
-
- Good default coverage for common hlquery workflows.
|
|
28
|
-
- Raw request access for custom routes.
|
|
25
|
+
Use the Node.js client when you want hlquery calls to read like regular application code. The client is organized around familiar modules such as `client.collections()`, `client.documents()`, and `client.sam()`, so collection management, document indexing, search, SQL, and SAM workflows stay easy to find.
|
|
29
26
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
Choose the Node.js client over raw HTTP when you want less repetitive `fetch` or `axios` boilerplate, cleaner handling for auth headers and endpoint paths, and search or indexing code that stays easier to read.
|
|
27
|
+
It also keeps the repetitive parts in one place: authentication, request parameters, endpoint paths, and parsed responses are handled consistently across the client. Common hlquery workflows are covered by default, while raw request access is still available when you need a custom route.
|
|
33
28
|
|
|
34
29
|
### Install
|
|
35
30
|
|
|
36
31
|
```bash
|
|
37
|
-
npm install hlquery-node-client
|
|
32
|
+
$ npm install hlquery-node-client
|
|
38
33
|
```
|
|
39
34
|
|
|
40
35
|
For local development inside this repository:
|
|
@@ -54,9 +49,11 @@ const client = new Client(process.env.HLQ_BASE_URL || process.env.HLQUERY_BASE_U
|
|
|
54
49
|
});
|
|
55
50
|
|
|
56
51
|
const health = await client.system().health();
|
|
52
|
+
/* Print the HTTP status code from the health response. */
|
|
57
53
|
console.log('status:', health.getStatusCode());
|
|
58
54
|
|
|
59
55
|
const collections = await client.collections().list(0, 10);
|
|
56
|
+
/* Print the collection list response body. */
|
|
60
57
|
console.log(collections.getBody());
|
|
61
58
|
```
|
|
62
59
|
|
|
@@ -72,6 +69,50 @@ client.setAuthToken('your_token_here', 'bearer');
|
|
|
72
69
|
client.setAuthToken('your_api_key_here', 'api-key');
|
|
73
70
|
```
|
|
74
71
|
|
|
72
|
+
### Operational routes
|
|
73
|
+
|
|
74
|
+
The client includes wrappers for the common server and cluster routes used by dashboards, scripts, and maintenance jobs:
|
|
75
|
+
|
|
76
|
+
```javascript
|
|
77
|
+
const status = await client.status();
|
|
78
|
+
const health = await client.health();
|
|
79
|
+
const etc = await client.etc();
|
|
80
|
+
|
|
81
|
+
const links = await client.links();
|
|
82
|
+
const ping = await client.linksPing();
|
|
83
|
+
const connect = await client.linksConnect('http://node-b:9200');
|
|
84
|
+
const disconnect = await client.linksDisconnect('http://node-b:9200');
|
|
85
|
+
|
|
86
|
+
const flush = await client.flush();
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Routes that do not have a dedicated wrapper can still be called through `executeRequest()`:
|
|
90
|
+
|
|
91
|
+
```javascript
|
|
92
|
+
const response = await client.executeRequest('GET', '/modules/<name>/<route>', null, {
|
|
93
|
+
q: 'example query'
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
/* Print the server status response body. */
|
|
97
|
+
console.log(status.getBody());
|
|
98
|
+
/* Print the health response body. */
|
|
99
|
+
console.log(health.getBody());
|
|
100
|
+
/* Print the runtime configuration response body. */
|
|
101
|
+
console.log(etc.getBody());
|
|
102
|
+
/* Print the configured cluster links response body. */
|
|
103
|
+
console.log(links.getBody());
|
|
104
|
+
/* Print the link ping response body. */
|
|
105
|
+
console.log(ping.getBody());
|
|
106
|
+
/* Print the link connect response body. */
|
|
107
|
+
console.log(connect.getBody());
|
|
108
|
+
/* Print the link disconnect response body. */
|
|
109
|
+
console.log(disconnect.getBody());
|
|
110
|
+
/* Print the flush response body. */
|
|
111
|
+
console.log(flush.getBody());
|
|
112
|
+
/* Print the custom route response body. */
|
|
113
|
+
console.log(response.getBody());
|
|
114
|
+
```
|
|
115
|
+
|
|
75
116
|
### SAM
|
|
76
117
|
|
|
77
118
|
SAM is separate from vector search. It performs term and intent-style lookup, not vector similarity search.
|
|
@@ -85,38 +126,34 @@ const results = await sam.search('books', 'distributed systems', {
|
|
|
85
126
|
limit: 10
|
|
86
127
|
});
|
|
87
128
|
|
|
129
|
+
/* Print the SAM status response body. */
|
|
88
130
|
console.log(status.getBody());
|
|
131
|
+
/* Print the SAM search history response body. */
|
|
89
132
|
console.log(history.getBody());
|
|
133
|
+
/* Print the SAM search results response body. */
|
|
90
134
|
console.log(results.getBody());
|
|
91
135
|
```
|
|
92
136
|
|
|
93
137
|
### SQL
|
|
94
138
|
|
|
95
139
|
```javascript
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
140
|
+
const rows = await client.sql('SHOW COLLECTIONS;');
|
|
141
|
+
const execResult = await client.execSql(
|
|
142
|
+
"INSERT INTO logs_archive (id, title) VALUES ('row-1', 'warm cache');"
|
|
143
|
+
);
|
|
144
|
+
const books = await client.sqlSearch(
|
|
100
145
|
'books',
|
|
101
146
|
'SELECT id, title FROM books ORDER BY title ASC LIMIT 3;'
|
|
102
147
|
);
|
|
103
148
|
|
|
149
|
+
/* Print the SQL query response body. */
|
|
104
150
|
console.log(rows.getBody());
|
|
151
|
+
/* Print the SQL execution response body. */
|
|
152
|
+
console.log(execResult.getBody());
|
|
153
|
+
/* Print the collection SQL search response body. */
|
|
105
154
|
console.log(books.getBody());
|
|
106
155
|
```
|
|
107
156
|
|
|
108
|
-
### Reduce Text Example
|
|
109
|
-
|
|
110
|
-
Use the raw request helper for custom module routes:
|
|
111
|
-
|
|
112
|
-
```javascript
|
|
113
|
-
const response = await client.executeRequest('GET', '/modules/<name>/<route>', null, {
|
|
114
|
-
q: 'example query'
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
console.log(response.getBody());
|
|
118
|
-
```
|
|
119
|
-
|
|
120
157
|
### Contributing
|
|
121
158
|
|
|
122
159
|
We welcome contributions from the community! All contributions must be released under the BSD 3-Clause license.
|
package/lib/Client.js
CHANGED
|
@@ -393,6 +393,80 @@ class Client {
|
|
|
393
393
|
async getCollectionFields(name) {
|
|
394
394
|
return await this._collections.getFields(name);
|
|
395
395
|
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Copy a collection into a new name (schema + documents).
|
|
399
|
+
*
|
|
400
|
+
* @param {string} sourceName
|
|
401
|
+
* @param {string} targetName
|
|
402
|
+
* @param {object} options
|
|
403
|
+
* @returns {Promise<Response>}
|
|
404
|
+
*/
|
|
405
|
+
async copyCollection(sourceName, targetName, options = {}) {
|
|
406
|
+
const batchSize = Number.isInteger(options.batch_size) ? options.batch_size
|
|
407
|
+
: (Number.isInteger(options.batchSize) ? options.batchSize : 500);
|
|
408
|
+
|
|
409
|
+
const sourceResponse = await this._collections.get(sourceName);
|
|
410
|
+
if (sourceResponse.getStatusCode() !== 200) {
|
|
411
|
+
return sourceResponse;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const source = sourceResponse.getBody();
|
|
415
|
+
const schema = {};
|
|
416
|
+
|
|
417
|
+
if (source && source.fields && typeof source.fields === 'object' && !Array.isArray(source.fields) && Object.keys(source.fields).length > 0) {
|
|
418
|
+
const fieldNames = Object.keys(source.fields).sort();
|
|
419
|
+
schema.fields = fieldNames.map(name => ({ name, type: typeof source.fields[name] === 'string' ? source.fields[name] : 'string' }));
|
|
420
|
+
} else if (source && Array.isArray(source.searchable_fields) && source.searchable_fields.length > 0) {
|
|
421
|
+
schema.searchable_fields = source.searchable_fields;
|
|
422
|
+
} else {
|
|
423
|
+
schema.searchable_fields = ['title', 'content'];
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (source && Array.isArray(source.filterable_fields)) {
|
|
427
|
+
schema.filterable_fields = source.filterable_fields;
|
|
428
|
+
}
|
|
429
|
+
if (source && Array.isArray(source.sortable_fields)) {
|
|
430
|
+
schema.sortable_fields = source.sortable_fields;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (source && source.metadata && typeof source.metadata === 'object' && !Array.isArray(source.metadata)) {
|
|
434
|
+
for (const [key, value] of Object.entries(source.metadata)) {
|
|
435
|
+
if (typeof key === 'string' && key.startsWith('_')) {
|
|
436
|
+
schema[key] = value;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const createResponse = await this._collections.create(targetName, schema);
|
|
442
|
+
if (createResponse.getStatusCode() !== 201) {
|
|
443
|
+
return createResponse;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
let offset = 0;
|
|
447
|
+
|
|
448
|
+
while (true) {
|
|
449
|
+
const listResponse = await this._documents.list(sourceName, { offset, limit: batchSize });
|
|
450
|
+
if (listResponse.getStatusCode() !== 200) {
|
|
451
|
+
return listResponse;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const body = listResponse.getBody() || {};
|
|
455
|
+
const documents = Array.isArray(body.documents) ? body.documents : [];
|
|
456
|
+
if (documents.length === 0) {
|
|
457
|
+
break;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const importResponse = await this._documents.import(targetName, documents);
|
|
461
|
+
if (!importResponse.isSuccess || !importResponse.isSuccess()) {
|
|
462
|
+
return importResponse;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
offset += documents.length;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return createResponse;
|
|
469
|
+
}
|
|
396
470
|
|
|
397
471
|
// ============================================================================
|
|
398
472
|
// DOCUMENTS API
|
|
@@ -429,6 +503,30 @@ class Client {
|
|
|
429
503
|
return await this._documents.get(collectionName, documentId);
|
|
430
504
|
}
|
|
431
505
|
|
|
506
|
+
/**
|
|
507
|
+
* Copy a document to a new ID within the same collection.
|
|
508
|
+
*
|
|
509
|
+
* @param {string} collectionName
|
|
510
|
+
* @param {string} sourceId
|
|
511
|
+
* @param {string} targetId
|
|
512
|
+
* @returns {Promise<Response>}
|
|
513
|
+
*/
|
|
514
|
+
async copyDocument(collectionName, sourceId, targetId) {
|
|
515
|
+
return await this._documents.copy(collectionName, sourceId, targetId);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Fetch most-recent documents (by timestamp desc).
|
|
520
|
+
*
|
|
521
|
+
* @param {string} collectionName
|
|
522
|
+
* @param {number} limit
|
|
523
|
+
* @param {number} offset
|
|
524
|
+
* @returns {Promise<Response>}
|
|
525
|
+
*/
|
|
526
|
+
async recentDocuments(collectionName, limit = 20, offset = 0) {
|
|
527
|
+
return await this._documents.recent(collectionName, limit, offset);
|
|
528
|
+
}
|
|
529
|
+
|
|
432
530
|
/**
|
|
433
531
|
* Export documents from a collection.
|
|
434
532
|
*
|
|
@@ -473,6 +571,50 @@ class Client {
|
|
|
473
571
|
return this._sam;
|
|
474
572
|
}
|
|
475
573
|
|
|
574
|
+
async samSearch(collectionName, query, params = {}) {
|
|
575
|
+
return await this._sam.search(collectionName, query, params);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
async samSearchAll(query, params = {}) {
|
|
579
|
+
return await this._sam.searchAll(query, params);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
async samRebuild(collectionName, params = {}) {
|
|
583
|
+
return await this._sam.rebuild(collectionName, params);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
async samStatus(collectionName = null, params = {}) {
|
|
587
|
+
return await this._sam.status(collectionName, params);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
async samDebug(collectionName = null, params = {}) {
|
|
591
|
+
return await this._sam.debug(collectionName, params);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
async samHistory(collectionName = null, limit = 100, params = {}) {
|
|
595
|
+
return await this._sam.history(collectionName, limit, params);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
async samPause(pauseUntilMs, params = {}) {
|
|
599
|
+
return await this._sam.pause(pauseUntilMs, params);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
async samClearPause(params = {}) {
|
|
603
|
+
return await this._sam.clearPause(params);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
async samDocuments(collectionName, offset = 0, limit = 20, params = {}) {
|
|
607
|
+
return await this._sam.listDocuments(collectionName, offset, limit, params);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
async samDocument(collectionName, documentId, params = {}) {
|
|
611
|
+
return await this._sam.getDocument(collectionName, documentId, params);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
async samOpenDocument(collectionName, documentId, interactionQuery = null, params = {}) {
|
|
615
|
+
return await this._sam.openDocument(collectionName, documentId, interactionQuery, params);
|
|
616
|
+
}
|
|
617
|
+
|
|
476
618
|
/**
|
|
477
619
|
* Get aliases API instance
|
|
478
620
|
*
|
package/lib/Documents.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
const Validator = require('../utils/Validator');
|
|
10
|
+
const Response = require('./Response');
|
|
10
11
|
const PDFParser = require('../utils/PDFParser');
|
|
11
12
|
const CSVParser = require('../utils/CSVParser');
|
|
12
13
|
|
|
@@ -167,16 +168,93 @@ class Documents {
|
|
|
167
168
|
Validator.validateCollectionName(collectionName);
|
|
168
169
|
|
|
169
170
|
// Validate each document in the array
|
|
170
|
-
if (Array.isArray(documents)) {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
171
|
+
if (!Array.isArray(documents)) {
|
|
172
|
+
throw new Error('Documents import payload must be an array');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
for (const doc of documents) {
|
|
176
|
+
Validator.validateDocumentFields(doc);
|
|
174
177
|
}
|
|
175
178
|
|
|
176
|
-
// Server expects {documents: [...]} format
|
|
177
|
-
|
|
179
|
+
// Server expects {documents: [...]} format; fall back to per-doc inserts if bulk route is unavailable.
|
|
180
|
+
const bulkResponse = await this.request.execute('POST', `/collections/${encodeURIComponent(collectionName)}/documents/import`, {
|
|
178
181
|
documents: documents
|
|
179
182
|
});
|
|
183
|
+
|
|
184
|
+
if (bulkResponse && bulkResponse.getStatusCode && bulkResponse.getStatusCode() === 404) {
|
|
185
|
+
let inserted = 0;
|
|
186
|
+
for (const doc of documents) {
|
|
187
|
+
const response = await this.request.execute('POST', `/collections/${encodeURIComponent(collectionName)}/documents`, doc);
|
|
188
|
+
const status = response.getStatusCode();
|
|
189
|
+
if (status !== 201) {
|
|
190
|
+
return response;
|
|
191
|
+
}
|
|
192
|
+
inserted += 1;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return new Response(201, { imported: inserted, fallback: true });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return bulkResponse;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Copy one document to a new ID within the same collection.
|
|
203
|
+
*
|
|
204
|
+
* @param {string} collectionName
|
|
205
|
+
* @param {string} sourceId
|
|
206
|
+
* @param {string} targetId
|
|
207
|
+
* @returns {Promise<Response>}
|
|
208
|
+
*/
|
|
209
|
+
async copy(collectionName, sourceId, targetId) {
|
|
210
|
+
Validator.validateCollectionName(collectionName);
|
|
211
|
+
Validator.validateDocumentId(sourceId);
|
|
212
|
+
Validator.validateDocumentId(targetId);
|
|
213
|
+
|
|
214
|
+
if (sourceId === targetId) {
|
|
215
|
+
throw new Error('Source and target document ids must differ');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const targetCheck = await this.get(collectionName, targetId);
|
|
219
|
+
if (targetCheck.getStatusCode() === 200) {
|
|
220
|
+
return targetCheck;
|
|
221
|
+
}
|
|
222
|
+
if (targetCheck.getStatusCode() !== 404) {
|
|
223
|
+
return targetCheck;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const sourceResponse = await this.get(collectionName, sourceId);
|
|
227
|
+
if (sourceResponse.getStatusCode() !== 200) {
|
|
228
|
+
return sourceResponse;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const doc = { ...sourceResponse.getBody(), id: targetId };
|
|
232
|
+
delete doc.collection_id;
|
|
233
|
+
delete doc.score;
|
|
234
|
+
|
|
235
|
+
const imported = await this.import(collectionName, [doc]);
|
|
236
|
+
return imported;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Convenience helper to fetch most-recent documents (by timestamp desc).
|
|
241
|
+
*
|
|
242
|
+
* @param {string} collectionName
|
|
243
|
+
* @param {number} limit
|
|
244
|
+
* @param {number} offset
|
|
245
|
+
* @returns {Promise<Response>}
|
|
246
|
+
*/
|
|
247
|
+
async recent(collectionName, limit = 20, offset = 0) {
|
|
248
|
+
Validator.validateCollectionName(collectionName);
|
|
249
|
+
Validator.validatePagination(offset, limit);
|
|
250
|
+
|
|
251
|
+
const sql = `select * from ${collectionName} order by timestamp desc limit ${limit} offset ${offset}`;
|
|
252
|
+
return await this.request.execute(
|
|
253
|
+
'GET',
|
|
254
|
+
`/collections/${encodeURIComponent(collectionName)}/documents/search`,
|
|
255
|
+
null,
|
|
256
|
+
{ sql }
|
|
257
|
+
);
|
|
180
258
|
}
|
|
181
259
|
|
|
182
260
|
/**
|
package/lib/Keys.js
CHANGED
|
@@ -35,7 +35,7 @@ class Keys {
|
|
|
35
35
|
if (!id) {
|
|
36
36
|
throw new Error('Key ID is required');
|
|
37
37
|
}
|
|
38
|
-
return await this.request.execute('GET', `/keys/${id}`);
|
|
38
|
+
return await this.request.execute('GET', `/keys/${encodeURIComponent(id)}`);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
/**
|
|
@@ -64,7 +64,7 @@ class Keys {
|
|
|
64
64
|
if (!id) {
|
|
65
65
|
throw new Error('Key ID is required');
|
|
66
66
|
}
|
|
67
|
-
return await this.request.execute('DELETE', `/keys/${id}`);
|
|
67
|
+
return await this.request.execute('DELETE', `/keys/${encodeURIComponent(id)}`);
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
/**
|
|
@@ -78,7 +78,7 @@ class Keys {
|
|
|
78
78
|
if (!id) {
|
|
79
79
|
throw new Error('Key ID is required');
|
|
80
80
|
}
|
|
81
|
-
return await this.request.execute('PUT', `/keys/${id}`, params);
|
|
81
|
+
return await this.request.execute('PUT', `/keys/${encodeURIComponent(id)}`, params);
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
84
|
|
package/lib/Request.js
CHANGED
|
@@ -47,20 +47,33 @@ class Request {
|
|
|
47
47
|
|
|
48
48
|
// Add query parameters
|
|
49
49
|
Object.keys(queryParams).forEach(key => {
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
const value = queryParams[key];
|
|
51
|
+
if (value === undefined || value === null) {
|
|
52
|
+
return;
|
|
52
53
|
}
|
|
54
|
+
|
|
55
|
+
if (Array.isArray(value)) {
|
|
56
|
+
url.searchParams.append(key, value.join(','));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (typeof value === 'object') {
|
|
61
|
+
url.searchParams.append(key, JSON.stringify(value));
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
url.searchParams.append(key, value);
|
|
53
66
|
});
|
|
54
67
|
|
|
55
68
|
const headers = {
|
|
56
|
-
'Content-Type': 'application/json',
|
|
57
69
|
'Accept': 'application/json'
|
|
58
70
|
};
|
|
59
71
|
|
|
60
72
|
// Prepare body string if present
|
|
61
73
|
let bodyStr = null;
|
|
62
|
-
if (body !== null) {
|
|
74
|
+
if (body !== null && body !== undefined) {
|
|
63
75
|
bodyStr = typeof body === 'string' ? body : JSON.stringify(body);
|
|
76
|
+
headers['Content-Type'] = 'application/json';
|
|
64
77
|
headers['Content-Length'] = Buffer.byteLength(bodyStr);
|
|
65
78
|
}
|
|
66
79
|
|
package/lib/SAM.js
CHANGED
|
@@ -16,28 +16,51 @@ class SAM {
|
|
|
16
16
|
this.request = request;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
_validateParams(params, label) {
|
|
20
|
+
if (params === null || typeof params !== 'object' || Array.isArray(params)) {
|
|
21
|
+
throw new Error(`${label} must be an object`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
21
24
|
|
|
25
|
+
async search(collectionName, query, params = {}) {
|
|
22
26
|
if (typeof query !== 'string' || query.trim() === '') {
|
|
23
27
|
throw new Error('SAM query must be a non-empty string');
|
|
24
28
|
}
|
|
25
29
|
|
|
26
|
-
|
|
27
|
-
throw new Error('SAM search params must be an object');
|
|
28
|
-
}
|
|
30
|
+
this._validateParams(params, 'SAM search params');
|
|
29
31
|
|
|
30
|
-
|
|
32
|
+
const queryParams = {
|
|
31
33
|
...params,
|
|
32
|
-
collection: collectionName,
|
|
33
34
|
q: query
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
if (collectionName !== null && collectionName !== undefined && collectionName !== '') {
|
|
38
|
+
Validator.validateCollectionName(collectionName);
|
|
39
|
+
queryParams.collection = collectionName;
|
|
40
|
+
} else if (!queryParams.all && !queryParams.collections) {
|
|
41
|
+
throw new Error('Collection name is required unless all=true or collections is provided');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return await this.request.execute('GET', '/sam/search', null, queryParams);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async searchAll(query, params = {}) {
|
|
48
|
+
this._validateParams(params, 'SAM search params');
|
|
49
|
+
return await this.search(null, query, { ...params, all: true });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async rebuild(collectionName, params = {}) {
|
|
53
|
+
Validator.validateCollectionName(collectionName);
|
|
54
|
+
this._validateParams(params, 'SAM rebuild params');
|
|
55
|
+
|
|
56
|
+
return await this.request.execute('POST', '/sam/rebuild', null, {
|
|
57
|
+
...params,
|
|
58
|
+
collection: collectionName
|
|
34
59
|
});
|
|
35
60
|
}
|
|
36
61
|
|
|
37
62
|
async status(collectionName = null, params = {}) {
|
|
38
|
-
|
|
39
|
-
throw new Error('SAM status params must be an object');
|
|
40
|
-
}
|
|
63
|
+
this._validateParams(params, 'SAM status params');
|
|
41
64
|
|
|
42
65
|
const queryParams = { ...params };
|
|
43
66
|
|
|
@@ -49,14 +72,29 @@ class SAM {
|
|
|
49
72
|
return await this.request.execute('GET', '/sam/status', null, queryParams);
|
|
50
73
|
}
|
|
51
74
|
|
|
75
|
+
async debug(collectionName = null, params = {}) {
|
|
76
|
+
this._validateParams(params, 'SAM debug params');
|
|
77
|
+
|
|
78
|
+
const queryParams = { ...params };
|
|
79
|
+
|
|
80
|
+
if (collectionName !== null && collectionName !== undefined && collectionName !== '') {
|
|
81
|
+
Validator.validateCollectionName(collectionName);
|
|
82
|
+
queryParams.collection = collectionName;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return await this.request.execute('GET', '/sam/debug', null, queryParams);
|
|
86
|
+
}
|
|
87
|
+
|
|
52
88
|
async history(collectionName = null, limit = 100, params = {}) {
|
|
53
|
-
|
|
54
|
-
|
|
89
|
+
this._validateParams(params, 'SAM history params');
|
|
90
|
+
const safeLimit = Number(limit);
|
|
91
|
+
if (!Number.isInteger(safeLimit) || safeLimit < 1) {
|
|
92
|
+
throw new Error('SAM history limit must be a positive integer');
|
|
55
93
|
}
|
|
56
94
|
|
|
57
95
|
const queryParams = {
|
|
58
96
|
...params,
|
|
59
|
-
limit:
|
|
97
|
+
limit: safeLimit
|
|
60
98
|
};
|
|
61
99
|
|
|
62
100
|
if (collectionName !== null && collectionName !== undefined && collectionName !== '') {
|
|
@@ -66,6 +104,71 @@ class SAM {
|
|
|
66
104
|
|
|
67
105
|
return await this.request.execute('GET', '/sam/history', null, queryParams);
|
|
68
106
|
}
|
|
107
|
+
|
|
108
|
+
async pause(pauseUntilMs, params = {}) {
|
|
109
|
+
this._validateParams(params, 'SAM pause params');
|
|
110
|
+
|
|
111
|
+
const pause = Number(pauseUntilMs);
|
|
112
|
+
if (!Number.isFinite(pause) || pause < 0) {
|
|
113
|
+
throw new Error('SAM pause value must be a non-negative unix millisecond timestamp, or 0 to clear');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return await this.request.execute('POST', '/sam/pause', null, {
|
|
117
|
+
...params,
|
|
118
|
+
pause
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async clearPause(params = {}) {
|
|
123
|
+
return await this.pause(0, params);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async listDocuments(collectionName, offset = 0, limit = 20, params = {}) {
|
|
127
|
+
Validator.validateCollectionName(collectionName);
|
|
128
|
+
this._validateParams(params, 'SAM document list params');
|
|
129
|
+
const safeOffset = Number(offset);
|
|
130
|
+
const safeLimit = Number(limit);
|
|
131
|
+
|
|
132
|
+
if (!Number.isInteger(safeOffset) || safeOffset < 0) {
|
|
133
|
+
throw new Error('SAM document offset must be a non-negative integer');
|
|
134
|
+
}
|
|
135
|
+
if (!Number.isInteger(safeLimit) || safeLimit < 1) {
|
|
136
|
+
throw new Error('SAM document limit must be a positive integer');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return await this.request.execute('GET', '/sam/documents', null, {
|
|
140
|
+
...params,
|
|
141
|
+
collection: collectionName,
|
|
142
|
+
offset: safeOffset,
|
|
143
|
+
limit: safeLimit
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async getDocument(collectionName, documentId, params = {}) {
|
|
148
|
+
Validator.validateCollectionName(collectionName);
|
|
149
|
+
Validator.validateDocumentId(documentId);
|
|
150
|
+
this._validateParams(params, 'SAM document params');
|
|
151
|
+
|
|
152
|
+
return await this.request.execute(
|
|
153
|
+
'GET',
|
|
154
|
+
`/sam/documents/${encodeURIComponent(collectionName)}/${encodeURIComponent(documentId)}`,
|
|
155
|
+
null,
|
|
156
|
+
params
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async openDocument(collectionName, documentId, interactionQuery = null, params = {}) {
|
|
161
|
+
const queryParams = { ...params };
|
|
162
|
+
|
|
163
|
+
if (interactionQuery !== null && interactionQuery !== undefined && interactionQuery !== '') {
|
|
164
|
+
if (typeof interactionQuery !== 'string') {
|
|
165
|
+
throw new Error('SAM interaction query must be a string');
|
|
166
|
+
}
|
|
167
|
+
queryParams.interaction_query = interactionQuery;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return await this.getDocument(collectionName, documentId, queryParams);
|
|
171
|
+
}
|
|
69
172
|
}
|
|
70
173
|
|
|
71
174
|
module.exports = SAM;
|
package/lib/conformance.js
CHANGED
|
@@ -44,9 +44,15 @@ const NODE_CLIENT_ROUTE_COVERAGE = [
|
|
|
44
44
|
{ path: '/collections/{name}/vector_search', methods: ['GET', 'POST'], status: 'supported', client: 'search.vectorSearch' },
|
|
45
45
|
{ path: '/multi_search', methods: ['GET', 'POST'], status: 'supported', client: 'search.multiSearch' },
|
|
46
46
|
{ path: '/search', methods: ['GET', 'POST'], status: 'supported', client: 'search.globalSearch' },
|
|
47
|
+
{ path: '/sql', methods: ['GET', 'POST'], status: 'supported', client: 'system.sql/execSql' },
|
|
48
|
+
{ path: '/sam/rebuild', methods: ['POST'], status: 'supported', client: 'sam.rebuild' },
|
|
47
49
|
{ path: '/sam/search', methods: ['GET'], status: 'supported', client: 'sam.search' },
|
|
48
50
|
{ path: '/sam/status', methods: ['GET'], status: 'supported', client: 'sam.status' },
|
|
51
|
+
{ path: '/sam/debug', methods: ['GET'], status: 'supported', client: 'sam.debug' },
|
|
49
52
|
{ path: '/sam/history', methods: ['GET'], status: 'supported', client: 'sam.history' },
|
|
53
|
+
{ path: '/sam/pause', methods: ['POST'], status: 'supported', client: 'sam.pause/clearPause' },
|
|
54
|
+
{ path: '/sam/documents', methods: ['GET'], status: 'supported', client: 'sam.listDocuments' },
|
|
55
|
+
{ path: '/sam/documents/{collection}/{id}', methods: ['GET'], status: 'supported', client: 'sam.getDocument/openDocument' },
|
|
50
56
|
{ path: '/collections/{name}/documents/facet_counts', methods: ['GET', 'POST'], status: 'supported', client: 'documents.facetCounts' },
|
|
51
57
|
{ path: '/collections/{name}/documents/export', methods: ['GET', 'POST'], status: 'supported', client: 'documents.export' },
|
|
52
58
|
{ path: '/collections/{name}/synonyms', methods: ['GET'], status: 'supported', client: 'synonyms.list' },
|
|
@@ -67,6 +73,10 @@ const NODE_CLIENT_ROUTE_COVERAGE = [
|
|
|
67
73
|
{ path: '/keys/{id}', methods: ['GET', 'PUT', 'DELETE'], status: 'supported', client: 'keys.get/update/delete' },
|
|
68
74
|
{ path: '/update-counters', methods: ['GET', 'POST'], status: 'omitted', reason: 'Internal maintenance endpoint; intentionally left off the public Node client.' },
|
|
69
75
|
{ path: '/query', methods: ['GET'], status: 'omitted', reason: 'Alias of /status; client exposes status() instead of duplicate naming.' },
|
|
76
|
+
{ path: '/search-config', methods: ['GET'], status: 'omitted', reason: 'Diagnostic configuration endpoint; stable client exposes raw executeRequest() for this route.' },
|
|
77
|
+
{ path: '/ready', methods: ['GET'], status: 'omitted', reason: 'Readiness alias; client exposes startup() and bootStatus() instead of duplicate naming.' },
|
|
78
|
+
{ path: '/llm', methods: ['GET', 'POST'], status: 'omitted', reason: 'LLM runtime endpoint is module/config dependent and not part of the stable SDK surface yet.' },
|
|
79
|
+
{ path: '/repair', methods: ['POST'], status: 'omitted', reason: 'Administrative repair endpoint; intentionally left off the public Node client.' },
|
|
70
80
|
{ path: '/modules', methods: ['GET'], status: 'omitted', reason: 'Module APIs are intentionally omitted until the module surface is documented and stabilized for SDKs.' },
|
|
71
81
|
{ path: '/modules/{name}', methods: ['ANY'], status: 'omitted', reason: 'Module-specific routes are dynamic and intentionally not wrapped in the stable Node client.' },
|
|
72
82
|
{ path: '/modules/{name}/syntax', methods: ['GET'], status: 'omitted', reason: 'Module-specific routes are dynamic and intentionally not wrapped in the stable Node client.' }
|
package/package.json
CHANGED
package/test/offline-smoke.js
CHANGED
|
@@ -9,6 +9,7 @@ const Request = require('../lib/Request');
|
|
|
9
9
|
const Response = require('../lib/Response');
|
|
10
10
|
const Config = require('../utils/Config');
|
|
11
11
|
const Validator = require('../utils/Validator');
|
|
12
|
+
const CSVParser = require('../utils/CSVParser');
|
|
12
13
|
|
|
13
14
|
async function main() {
|
|
14
15
|
assert.strictEqual(typeof Hlquery, 'function', 'default export should be the Client constructor');
|
|
@@ -23,7 +24,11 @@ async function main() {
|
|
|
23
24
|
Validator.validateSearchParams({ limit: 10, offset: 0 });
|
|
24
25
|
|
|
25
26
|
assert.throws(() => Validator.validateCollectionName('123bad'), /letter or underscore/);
|
|
26
|
-
assert.throws(() => Validator.
|
|
27
|
+
assert.throws(() => Validator.validateSearchParams(null), /object/);
|
|
28
|
+
assert.throws(() => Validator.validateSearchParams({ limit: NaN }), /positive integer/);
|
|
29
|
+
assert.throws(() => Validator.validateSearchParams({ offset: 1.5 }), /non-negative integer/);
|
|
30
|
+
assert.throws(() => Validator.validateDocumentFields({ title: 'bad,value' }), /comma \(\,\)/);
|
|
31
|
+
assert.throws(() => CSVParser.parseCSV('a||b', '||'), /single character/);
|
|
27
32
|
|
|
28
33
|
const response = new Response(200, { ok: true }, { 'content-type': 'application/json' });
|
|
29
34
|
assert.strictEqual(response.isSuccess(), true);
|
|
@@ -36,15 +41,65 @@ async function main() {
|
|
|
36
41
|
return;
|
|
37
42
|
}
|
|
38
43
|
|
|
39
|
-
if (req.url === '/
|
|
44
|
+
if (req.url === '/status') {
|
|
45
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
46
|
+
res.end(JSON.stringify({ status: 'ready' }));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (req.url === '/etc') {
|
|
51
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
52
|
+
res.end(JSON.stringify({ protocol: 'http' }));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (req.url === '/links') {
|
|
57
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
58
|
+
res.end(JSON.stringify({ links: [] }));
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (req.url === '/links/ping') {
|
|
63
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
64
|
+
res.end(JSON.stringify({ ok: true }));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (req.url === '/links/connect' && req.method === 'POST') {
|
|
69
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
70
|
+
res.end(JSON.stringify({ connected: true }));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (req.url === '/links/disconnect' && req.method === 'POST') {
|
|
75
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
76
|
+
res.end(JSON.stringify({ disconnected: true }));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (req.url === '/flush' && req.method === 'POST') {
|
|
81
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
82
|
+
res.end(JSON.stringify({ flushed: true }));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (req.url.startsWith('/echo-auth')) {
|
|
40
87
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
41
88
|
res.end(JSON.stringify({
|
|
42
89
|
authorization: req.headers.authorization || null,
|
|
43
|
-
apiKey: req.headers['x-api-key'] || null
|
|
90
|
+
apiKey: req.headers['x-api-key'] || null,
|
|
91
|
+
contentType: req.headers['content-type'] || null,
|
|
92
|
+
url: req.url
|
|
44
93
|
}));
|
|
45
94
|
return;
|
|
46
95
|
}
|
|
47
96
|
|
|
97
|
+
if (req.url === '/keys/key%2Fwith%20space') {
|
|
98
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
99
|
+
res.end(JSON.stringify({ id: 'key/with space' }));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
48
103
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
49
104
|
res.end(JSON.stringify({ error: 'not found' }));
|
|
50
105
|
});
|
|
@@ -69,10 +124,63 @@ async function main() {
|
|
|
69
124
|
const apiKeyEcho = await request.execute('GET', '/echo-auth');
|
|
70
125
|
assert.strictEqual(apiKeyEcho.getBody().apiKey, 'scoped-key');
|
|
71
126
|
|
|
127
|
+
const queryEcho = await request.execute('GET', '/echo-auth', undefined, {
|
|
128
|
+
fields: ['title', 'body'],
|
|
129
|
+
filter: { published: true },
|
|
130
|
+
skip: null
|
|
131
|
+
});
|
|
132
|
+
assert.strictEqual(queryEcho.getBody().contentType, null);
|
|
133
|
+
assert.match(queryEcho.getBody().url, /fields=title%2Cbody/);
|
|
134
|
+
assert.match(queryEcho.getBody().url, /filter=%7B%22published%22%3Atrue%7D/);
|
|
135
|
+
assert.doesNotMatch(queryEcho.getBody().url, /skip=/);
|
|
136
|
+
|
|
72
137
|
const client = new Client(`http://127.0.0.1:${port}`);
|
|
73
138
|
const clientHealth = await client.health();
|
|
74
139
|
assert.strictEqual(clientHealth.isSuccess(), true);
|
|
75
140
|
assert.deepStrictEqual(clientHealth.getBody(), { status: 'ok' });
|
|
141
|
+
|
|
142
|
+
const clientStatus = await client.status();
|
|
143
|
+
assert.deepStrictEqual(clientStatus.getBody(), { status: 'ready' });
|
|
144
|
+
|
|
145
|
+
const clientEtc = await client.etc();
|
|
146
|
+
assert.deepStrictEqual(clientEtc.getBody(), { protocol: 'http' });
|
|
147
|
+
|
|
148
|
+
const clientLinks = await client.links();
|
|
149
|
+
assert.deepStrictEqual(clientLinks.getBody(), { links: [] });
|
|
150
|
+
|
|
151
|
+
const clientLinksPing = await client.linksPing();
|
|
152
|
+
assert.deepStrictEqual(clientLinksPing.getBody(), { ok: true });
|
|
153
|
+
|
|
154
|
+
const clientLinksConnect = await client.linksConnect('http://node-b:9200');
|
|
155
|
+
assert.deepStrictEqual(clientLinksConnect.getBody(), { connected: true });
|
|
156
|
+
|
|
157
|
+
const clientLinksDisconnect = await client.linksDisconnect('http://node-b:9200');
|
|
158
|
+
assert.deepStrictEqual(clientLinksDisconnect.getBody(), { disconnected: true });
|
|
159
|
+
|
|
160
|
+
const clientFlush = await client.flush();
|
|
161
|
+
assert.deepStrictEqual(clientFlush.getBody(), { flushed: true });
|
|
162
|
+
|
|
163
|
+
assert.strictEqual(typeof client.executeRequest, 'function');
|
|
164
|
+
assert.strictEqual(typeof client.sql, 'function');
|
|
165
|
+
assert.strictEqual(typeof client.execSql, 'function');
|
|
166
|
+
assert.strictEqual(typeof client.sqlSearch, 'function');
|
|
167
|
+
assert.strictEqual(typeof client.sam().search, 'function');
|
|
168
|
+
assert.strictEqual(typeof client.sam().searchAll, 'function');
|
|
169
|
+
assert.strictEqual(typeof client.sam().rebuild, 'function');
|
|
170
|
+
assert.strictEqual(typeof client.sam().status, 'function');
|
|
171
|
+
assert.strictEqual(typeof client.sam().debug, 'function');
|
|
172
|
+
assert.strictEqual(typeof client.sam().history, 'function');
|
|
173
|
+
assert.strictEqual(typeof client.sam().pause, 'function');
|
|
174
|
+
assert.strictEqual(typeof client.sam().clearPause, 'function');
|
|
175
|
+
assert.strictEqual(typeof client.sam().listDocuments, 'function');
|
|
176
|
+
assert.strictEqual(typeof client.sam().getDocument, 'function');
|
|
177
|
+
assert.strictEqual(typeof client.sam().openDocument, 'function');
|
|
178
|
+
await assert.rejects(() => client.sam().history(null, 0), /positive integer/);
|
|
179
|
+
await assert.rejects(() => client.sam().listDocuments('books', NaN, 20), /non-negative integer/);
|
|
180
|
+
|
|
181
|
+
const keyResponse = await client.keys().get('key/with space');
|
|
182
|
+
assert.deepStrictEqual(keyResponse.getBody(), { id: 'key/with space' });
|
|
183
|
+
await assert.rejects(() => client.documents().import('books', { id: 'not-array' }), /array/);
|
|
76
184
|
} finally {
|
|
77
185
|
await new Promise((resolve) => server.close(resolve));
|
|
78
186
|
}
|
package/utils/CSVParser.js
CHANGED
|
@@ -14,6 +14,7 @@ const { ValidationException } = require('../lib/Exceptions');
|
|
|
14
14
|
class CSVParser {
|
|
15
15
|
static parseFile(filePath, options = {}) {
|
|
16
16
|
this.validateFilePath(filePath);
|
|
17
|
+
this.validateDelimiter(options.delimiter || ',');
|
|
17
18
|
|
|
18
19
|
const resolvedPath = path.resolve(filePath);
|
|
19
20
|
const stat = fs.statSync(resolvedPath);
|
|
@@ -62,6 +63,8 @@ class CSVParser {
|
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
static parseCSV(input, delimiter) {
|
|
66
|
+
this.validateDelimiter(delimiter);
|
|
67
|
+
|
|
65
68
|
const rows = [];
|
|
66
69
|
let row = [];
|
|
67
70
|
let value = '';
|
|
@@ -155,6 +158,16 @@ class CSVParser {
|
|
|
155
158
|
throw new ValidationException('CSV file path must be a non-empty string');
|
|
156
159
|
}
|
|
157
160
|
}
|
|
161
|
+
|
|
162
|
+
static validateDelimiter(delimiter) {
|
|
163
|
+
if (typeof delimiter !== 'string' || delimiter.length !== 1) {
|
|
164
|
+
throw new ValidationException('CSV delimiter must be a single character');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (delimiter === '"' || delimiter === '\r' || delimiter === '\n') {
|
|
168
|
+
throw new ValidationException('CSV delimiter cannot be a quote or newline');
|
|
169
|
+
}
|
|
170
|
+
}
|
|
158
171
|
}
|
|
159
172
|
|
|
160
173
|
module.exports = CSVParser;
|
package/utils/Validator.js
CHANGED
|
@@ -69,35 +69,33 @@ class Validator {
|
|
|
69
69
|
* Validate pagination parameters
|
|
70
70
|
*/
|
|
71
71
|
static validatePagination(offset, limit) {
|
|
72
|
-
if (offset !== undefined && (
|
|
73
|
-
throw new ValidationException('Offset must be a non-negative
|
|
72
|
+
if (offset !== undefined && (!Number.isInteger(offset) || offset < 0)) {
|
|
73
|
+
throw new ValidationException('Offset must be a non-negative integer');
|
|
74
74
|
}
|
|
75
|
-
if (limit !== undefined && (
|
|
76
|
-
throw new ValidationException('Limit must be a positive
|
|
75
|
+
if (limit !== undefined && (!Number.isInteger(limit) || limit < 1)) {
|
|
76
|
+
throw new ValidationException('Limit must be a positive integer');
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
/**
|
|
81
81
|
* Validate search parameters
|
|
82
82
|
*/
|
|
83
|
-
static validateSearchParams(params) {
|
|
84
|
-
if (params
|
|
83
|
+
static validateSearchParams(params = {}) {
|
|
84
|
+
if (params === null || typeof params !== 'object' || Array.isArray(params)) {
|
|
85
85
|
throw new ValidationException('Search parameters must be an object');
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
if (params) {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
throw new ValidationException('Size must be a positive number');
|
|
100
|
-
}
|
|
88
|
+
if (params.offset !== undefined && (!Number.isInteger(params.offset) || params.offset < 0)) {
|
|
89
|
+
throw new ValidationException('Offset must be a non-negative integer');
|
|
90
|
+
}
|
|
91
|
+
if (params.limit !== undefined && (!Number.isInteger(params.limit) || params.limit < 1)) {
|
|
92
|
+
throw new ValidationException('Limit must be a positive integer');
|
|
93
|
+
}
|
|
94
|
+
if (params.from !== undefined && (!Number.isInteger(params.from) || params.from < 0)) {
|
|
95
|
+
throw new ValidationException('From must be a non-negative integer');
|
|
96
|
+
}
|
|
97
|
+
if (params.size !== undefined && (!Number.isInteger(params.size) || params.size < 1)) {
|
|
98
|
+
throw new ValidationException('Size must be a positive integer');
|
|
101
99
|
}
|
|
102
100
|
}
|
|
103
101
|
|
|
@@ -120,7 +118,7 @@ class Validator {
|
|
|
120
118
|
// Check string values for commas
|
|
121
119
|
if (typeof value === 'string' && value.includes(',')) {
|
|
122
120
|
throw new ValidationException(
|
|
123
|
-
`Field '${key}' contains invalid character: comma (
|
|
121
|
+
`Field '${key}' contains invalid character: comma (,). ` +
|
|
124
122
|
`Commas are not allowed in field values. Use underscores (_) or spaces instead, or use arrays for multiple values.`
|
|
125
123
|
);
|
|
126
124
|
}
|
|
@@ -130,7 +128,7 @@ class Validator {
|
|
|
130
128
|
for (const item of value) {
|
|
131
129
|
if (typeof item === 'string' && item.includes(',')) {
|
|
132
130
|
throw new ValidationException(
|
|
133
|
-
`Field '${key}' contains invalid character: comma (
|
|
131
|
+
`Field '${key}' contains invalid character: comma (,). ` +
|
|
134
132
|
`Array items cannot contain commas. Use underscores (_) or spaces instead.`
|
|
135
133
|
);
|
|
136
134
|
}
|
/package/{LICENSE → LICENSE.md}
RENAMED
|
File without changes
|