hlquery-node-client 1.0.4 → 1.0.6
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/README.md +35 -26
- package/index.js +4 -0
- package/lib/Aliases.js +5 -0
- package/lib/Analytics.js +30 -0
- package/lib/Client.js +122 -3
- package/lib/Collections.js +36 -3
- package/lib/Documents.js +98 -6
- package/lib/Exceptions.js +11 -3
- package/lib/Modules.js +64 -0
- package/lib/Request.js +150 -15
- package/lib/Response.js +28 -2
- package/lib/Route.js +111 -0
- package/lib/SAM.js +25 -0
- package/lib/Search.js +13 -7
- package/lib/System.js +32 -0
- package/lib/Users.js +51 -0
- package/lib/conformance.js +29 -8
- package/package.json +1 -1
- package/test/offline-smoke.js +263 -2
- package/utils/Config.js +17 -2
- package/utils/Validator.js +12 -29
package/lib/conformance.js
CHANGED
|
@@ -14,6 +14,8 @@ const NODE_CLIENT_ROUTE_COVERAGE = [
|
|
|
14
14
|
{ path: '/stats', methods: ['GET'], status: 'supported', client: 'system.stats' },
|
|
15
15
|
{ path: '/metrics', methods: ['GET'], status: 'supported', client: 'system.metrics' },
|
|
16
16
|
{ path: '/metrics.json', methods: ['GET'], status: 'supported', client: 'system.metricsJson' },
|
|
17
|
+
{ path: '/metrics/history', methods: ['GET'], status: 'supported', client: 'system.metricsHistory' },
|
|
18
|
+
{ path: '/metrics-history', methods: ['GET'], status: 'supported', client: 'system.metricsHistoryAlias' },
|
|
17
19
|
{ path: '/connections', methods: ['GET'], status: 'supported', client: 'system.connections' },
|
|
18
20
|
{ path: '/rocksdb', methods: ['GET'], status: 'supported', client: 'system.rocksdb' },
|
|
19
21
|
{ path: '/_rocksdb', methods: ['GET'], status: 'supported', client: 'system.rocksdbInternal' },
|
|
@@ -35,10 +37,16 @@ const NODE_CLIENT_ROUTE_COVERAGE = [
|
|
|
35
37
|
{ path: '/collections/distributed', methods: ['GET'], status: 'supported', client: 'client.listCollectionsDistributed' },
|
|
36
38
|
{ path: '/collections', methods: ['GET', 'POST'], status: 'supported', client: 'collections.list/create' },
|
|
37
39
|
{ path: '/collections/{name}', methods: ['GET', 'DELETE'], status: 'supported', client: 'collections.get/delete' },
|
|
40
|
+
{ path: '/collections/{name}/lang', methods: ['GET'], status: 'supported', client: 'collections.language' },
|
|
41
|
+
{ path: '/collections/{name}/aliases', methods: ['GET'], status: 'supported', client: 'aliases.listCollection' },
|
|
38
42
|
{ path: '/collections/{name}/update', methods: ['POST'], status: 'supported', client: 'collections.update' },
|
|
39
43
|
{ path: '/collections/{name}/documents', methods: ['GET', 'POST', 'DELETE'], status: 'supported', client: 'documents.list/add/deleteByFilter' },
|
|
40
44
|
{ path: '/collections/{name}/documents/import', methods: ['POST'], status: 'supported', client: 'documents.import' },
|
|
41
45
|
{ path: '/collections/{name}/documents/{id}', methods: ['GET', 'PUT', 'DELETE'], status: 'supported', client: 'documents.get/update/delete' },
|
|
46
|
+
{ path: '/collections/{name}/documents/{id}/context', methods: ['GET'], status: 'supported', client: 'documents.context' },
|
|
47
|
+
{ path: '/collections/{name}/documents/maybe', methods: ['GET', 'POST'], status: 'supported', client: 'documents.maybe' },
|
|
48
|
+
{ path: '/collections/{name}/documents/_update_by_query', methods: ['POST'], status: 'supported', client: 'documents.updateByQuery' },
|
|
49
|
+
{ path: '/collections/{name}/documents/_delete_by_query', methods: ['POST'], status: 'supported', client: 'documents.deleteByQuery' },
|
|
42
50
|
{ path: '/collections/{name}/search', methods: ['GET', 'POST'], status: 'supported', client: 'search.search' },
|
|
43
51
|
{ path: '/collections/{name}/documents/search', methods: ['GET', 'POST'], status: 'supported', client: 'search.searchLegacy' },
|
|
44
52
|
{ path: '/collections/{name}/vector_search', methods: ['GET', 'POST'], status: 'supported', client: 'search.vectorSearch' },
|
|
@@ -51,8 +59,11 @@ const NODE_CLIENT_ROUTE_COVERAGE = [
|
|
|
51
59
|
{ path: '/sam/debug', methods: ['GET'], status: 'supported', client: 'sam.debug' },
|
|
52
60
|
{ path: '/sam/history', methods: ['GET'], status: 'supported', client: 'sam.history' },
|
|
53
61
|
{ path: '/sam/pause', methods: ['POST'], status: 'supported', client: 'sam.pause/clearPause' },
|
|
62
|
+
{ path: '/sam/improve', methods: ['POST'], status: 'supported', client: 'sam.improve' },
|
|
63
|
+
{ path: '/sam/flush_actor_metadata', methods: ['POST'], status: 'supported', client: 'sam.flushActorMetadata' },
|
|
54
64
|
{ path: '/sam/documents', methods: ['GET'], status: 'supported', client: 'sam.listDocuments' },
|
|
55
65
|
{ path: '/sam/documents/{collection}/{id}', methods: ['GET'], status: 'supported', client: 'sam.getDocument/openDocument' },
|
|
66
|
+
{ path: '/sam/label/add/{collection}/{id}', methods: ['POST'], status: 'supported', client: 'sam.addDocumentLabel' },
|
|
56
67
|
{ path: '/collections/{name}/documents/facet_counts', methods: ['GET', 'POST'], status: 'supported', client: 'documents.facetCounts' },
|
|
57
68
|
{ path: '/collections/{name}/documents/export', methods: ['GET', 'POST'], status: 'supported', client: 'documents.export' },
|
|
58
69
|
{ path: '/collections/{name}/synonyms', methods: ['GET'], status: 'supported', client: 'synonyms.list' },
|
|
@@ -71,15 +82,25 @@ const NODE_CLIENT_ROUTE_COVERAGE = [
|
|
|
71
82
|
{ path: '/aliases/{name}', methods: ['GET', 'POST', 'PUT', 'DELETE'], status: 'supported', client: 'aliases.get/create/update/delete' },
|
|
72
83
|
{ path: '/keys', methods: ['GET', 'POST'], status: 'supported', client: 'keys.list/create' },
|
|
73
84
|
{ path: '/keys/{id}', methods: ['GET', 'PUT', 'DELETE'], status: 'supported', client: 'keys.get/update/delete' },
|
|
74
|
-
{ path: '/
|
|
85
|
+
{ path: '/users', methods: ['GET', 'POST'], status: 'supported', client: 'users.list/create' },
|
|
86
|
+
{ path: '/users/{name}', methods: ['GET', 'PUT', 'DELETE'], status: 'supported', client: 'users.get/update/delete' },
|
|
87
|
+
{ path: '/analytics/click', methods: ['POST'], status: 'supported', client: 'analytics.click' },
|
|
88
|
+
{ path: '/update-counters', methods: ['GET', 'POST'], status: 'supported', client: 'system.updateCounters' },
|
|
89
|
+
{ path: '/debug/counters', methods: ['GET'], status: 'supported', client: 'system.debugCounters' },
|
|
75
90
|
{ path: '/query', methods: ['GET'], status: 'omitted', reason: 'Alias of /status; client exposes status() instead of duplicate naming.' },
|
|
76
|
-
{ path: '/search-config', methods: ['GET'], status: '
|
|
77
|
-
{ path: '/ready', methods: ['GET'], status: '
|
|
78
|
-
{ path: '/llm', methods: ['GET'
|
|
79
|
-
{ path: '/repair', methods: ['POST'], status: '
|
|
80
|
-
{ path: '/modules', methods: ['GET'], status: '
|
|
81
|
-
{ path: '/modules/
|
|
82
|
-
{ path: '/modules/
|
|
91
|
+
{ path: '/search-config', methods: ['GET'], status: 'supported', client: 'system.searchConfig' },
|
|
92
|
+
{ path: '/ready', methods: ['GET'], status: 'supported', client: 'system.ready' },
|
|
93
|
+
{ path: '/llm', methods: ['GET'], status: 'supported', client: 'system.llm' },
|
|
94
|
+
{ path: '/repair', methods: ['GET', 'POST'], status: 'supported', client: 'system.repair' },
|
|
95
|
+
{ path: '/modules', methods: ['GET'], status: 'supported', client: 'modules.list' },
|
|
96
|
+
{ path: '/modules/load', methods: ['POST'], status: 'omitted', reason: 'Prefix alias; modules.load(name) uses the explicit /loadmodule/{name} form.' },
|
|
97
|
+
{ path: '/modules/unload', methods: ['POST'], status: 'omitted', reason: 'Prefix alias; modules.unload(name) uses the explicit /unloadmodule/{name} form.' },
|
|
98
|
+
{ path: '/loadmodule', methods: ['POST'], status: 'omitted', reason: 'Body-based alias; modules.load(name) uses the explicit /loadmodule/{name} form.' },
|
|
99
|
+
{ path: '/loadmodule/{name}', methods: ['POST'], status: 'supported', client: 'modules.load' },
|
|
100
|
+
{ path: '/unloadmodule', methods: ['POST'], status: 'omitted', reason: 'Body-based alias; modules.unload(name) uses the explicit /unloadmodule/{name} form.' },
|
|
101
|
+
{ path: '/unloadmodule/{name}', methods: ['POST'], status: 'supported', client: 'modules.unload' },
|
|
102
|
+
{ path: '/modules/{name}', methods: ['ANY'], status: 'supported', client: 'modules.call' },
|
|
103
|
+
{ path: '/modules/{name}/syntax', methods: ['GET'], status: 'supported', client: 'modules.syntax' }
|
|
83
104
|
];
|
|
84
105
|
|
|
85
106
|
module.exports = {
|
package/package.json
CHANGED
package/test/offline-smoke.js
CHANGED
|
@@ -5,8 +5,17 @@ const http = require('http');
|
|
|
5
5
|
|
|
6
6
|
const Hlquery = require('..');
|
|
7
7
|
const Client = require('../lib/Client');
|
|
8
|
+
const Collections = require('../lib/Collections');
|
|
9
|
+
const Documents = require('../lib/Documents');
|
|
10
|
+
const Aliases = require('../lib/Aliases');
|
|
8
11
|
const Request = require('../lib/Request');
|
|
9
12
|
const Response = require('../lib/Response');
|
|
13
|
+
const SAM = require('../lib/SAM');
|
|
14
|
+
const Search = require('../lib/Search');
|
|
15
|
+
const System = require('../lib/System');
|
|
16
|
+
const Modules = require('../lib/Modules');
|
|
17
|
+
const Users = require('../lib/Users');
|
|
18
|
+
const Analytics = require('../lib/Analytics');
|
|
10
19
|
const Config = require('../utils/Config');
|
|
11
20
|
const Validator = require('../utils/Validator');
|
|
12
21
|
const CSVParser = require('../utils/CSVParser');
|
|
@@ -16,23 +25,35 @@ async function main() {
|
|
|
16
25
|
assert.strictEqual(Hlquery.Client, Client, 'named Client export should match default export');
|
|
17
26
|
|
|
18
27
|
assert.strictEqual(Config.normalizeUrl('localhost:9200/'), 'http://localhost:9200');
|
|
28
|
+
assert.strictEqual(Config.normalizeUrl(new URL('http://localhost:9200///')), 'http://localhost:9200');
|
|
19
29
|
assert.strictEqual(Config.isValidUrl('http://localhost:9200'), true);
|
|
30
|
+
assert.strictEqual(Config.isValidUrl('http://'), false);
|
|
20
31
|
assert.strictEqual(Config.isValidUrl('notaurl'), false);
|
|
21
32
|
|
|
22
33
|
Validator.validateCollectionName('books');
|
|
23
34
|
Validator.validateDocumentId('doc_123');
|
|
35
|
+
Validator.validateDocumentId('doc/with space+plus');
|
|
24
36
|
Validator.validateSearchParams({ limit: 10, offset: 0 });
|
|
37
|
+
Validator.validateDocumentFields({ title: 'good,value' });
|
|
25
38
|
|
|
26
39
|
assert.throws(() => Validator.validateCollectionName('123bad'), /letter or underscore/);
|
|
27
40
|
assert.throws(() => Validator.validateSearchParams(null), /object/);
|
|
28
41
|
assert.throws(() => Validator.validateSearchParams({ limit: NaN }), /positive integer/);
|
|
29
42
|
assert.throws(() => Validator.validateSearchParams({ offset: 1.5 }), /non-negative integer/);
|
|
30
|
-
assert.throws(() => Validator.
|
|
43
|
+
assert.throws(() => Validator.validateDocumentId('bad\nid'), /control characters/);
|
|
44
|
+
assert.throws(() => Validator.validateDocumentFields({ '': 'bad field' }), /field names/);
|
|
31
45
|
assert.throws(() => CSVParser.parseCSV('a||b', '||'), /single character/);
|
|
32
46
|
|
|
33
|
-
const response = new Response(200, { ok: true }, { 'content-type': 'application/json' }
|
|
47
|
+
const response = new Response(200, { ok: true }, { 'content-type': 'application/json' }, {
|
|
48
|
+
statusMessage: 'OK',
|
|
49
|
+
rawBody: Buffer.from('{"ok":true}'),
|
|
50
|
+
request: { method: 'GET', url: 'http://localhost/health' }
|
|
51
|
+
});
|
|
34
52
|
assert.strictEqual(response.isSuccess(), true);
|
|
35
53
|
assert.deepStrictEqual(response.getBody(), { ok: true });
|
|
54
|
+
assert.strictEqual(response.getStatusMessage(), 'OK');
|
|
55
|
+
assert.strictEqual(response.getContentType(), 'application/json');
|
|
56
|
+
assert.strictEqual(response.getRawBody().toString(), '{"ok":true}');
|
|
36
57
|
|
|
37
58
|
const server = http.createServer((req, res) => {
|
|
38
59
|
if (req.url === '/health') {
|
|
@@ -53,6 +74,36 @@ async function main() {
|
|
|
53
74
|
return;
|
|
54
75
|
}
|
|
55
76
|
|
|
77
|
+
if (req.url === '/modules') {
|
|
78
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
79
|
+
res.end(JSON.stringify({ modules: ['demo'] }));
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (req.url === '/modules/demo/syntax') {
|
|
84
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
85
|
+
res.end(JSON.stringify({ name: 'demo', syntax: 'demo <q>' }));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (req.url.startsWith('/modules/demo/search') && req.method === 'GET') {
|
|
90
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
91
|
+
res.end(JSON.stringify({ module: 'demo', route: 'search', url: req.url }));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (req.url === '/modules/demo/index' && req.method === 'POST') {
|
|
96
|
+
let body = '';
|
|
97
|
+
req.on('data', chunk => {
|
|
98
|
+
body += chunk.toString();
|
|
99
|
+
});
|
|
100
|
+
req.on('end', () => {
|
|
101
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
102
|
+
res.end(JSON.stringify({ indexed: JSON.parse(body) }));
|
|
103
|
+
});
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
56
107
|
if (req.url === '/links') {
|
|
57
108
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
58
109
|
res.end(JSON.stringify({ links: [] }));
|
|
@@ -77,6 +128,26 @@ async function main() {
|
|
|
77
128
|
return;
|
|
78
129
|
}
|
|
79
130
|
|
|
131
|
+
if (req.url === '/slow') {
|
|
132
|
+
setTimeout(() => {
|
|
133
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
134
|
+
res.end(JSON.stringify({ done: true }));
|
|
135
|
+
}, 100);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (req.url === '/large') {
|
|
140
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
141
|
+
res.end(JSON.stringify({ data: 'x'.repeat(128) }));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (req.url === '/plain') {
|
|
146
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
147
|
+
res.end('plain response');
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
80
151
|
if (req.url === '/flush' && req.method === 'POST') {
|
|
81
152
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
82
153
|
res.end(JSON.stringify({ flushed: true }));
|
|
@@ -134,6 +205,45 @@ async function main() {
|
|
|
134
205
|
assert.match(queryEcho.getBody().url, /filter=%7B%22published%22%3Atrue%7D/);
|
|
135
206
|
assert.doesNotMatch(queryEcho.getBody().url, /skip=/);
|
|
136
207
|
|
|
208
|
+
const repeatedQueryEcho = await request.execute('GET', '/echo-auth', undefined, {
|
|
209
|
+
fields: ['title', 'body']
|
|
210
|
+
}, {
|
|
211
|
+
query_array_format: 'repeat'
|
|
212
|
+
});
|
|
213
|
+
assert.match(repeatedQueryEcho.getBody().url, /fields=title&fields=body/);
|
|
214
|
+
|
|
215
|
+
const plain = await request.execute('GET', '/plain');
|
|
216
|
+
assert.strictEqual(plain.isParsed(), false);
|
|
217
|
+
assert.strictEqual(plain.getBody(), 'plain response');
|
|
218
|
+
assert.strictEqual(plain.getRawBody().toString(), 'plain response');
|
|
219
|
+
assert.match(plain.getRequest().url, /\/plain$/);
|
|
220
|
+
|
|
221
|
+
await assert.rejects(
|
|
222
|
+
() => request.execute('GET', '/large', null, {}, { max_response_bytes: 32 }),
|
|
223
|
+
(error) => error.code === 'MAX_RESPONSE_BYTES' && error.statusCode === 200
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
await assert.rejects(
|
|
227
|
+
() => request.execute('GET', '/missing', null, {}, { throw_on_error: true }),
|
|
228
|
+
(error) => error.statusCode === 404 && error.response && error.response.getError() === 'not found'
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
const controller = typeof AbortController === 'function' ? new AbortController() : null;
|
|
232
|
+
if (controller) {
|
|
233
|
+
const aborted = request.execute('GET', '/slow', null, {}, { signal: controller.signal });
|
|
234
|
+
controller.abort();
|
|
235
|
+
await assert.rejects(aborted, (error) => error.code === 'ABORT_ERR');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const keepAliveAgent = new http.Agent({ keepAlive: true });
|
|
239
|
+
const agentRequest = new Request(`http://127.0.0.1:${port}`, {
|
|
240
|
+
http_agent: keepAliveAgent,
|
|
241
|
+
headers: { 'X-Test-Default': '1' }
|
|
242
|
+
});
|
|
243
|
+
const agentEcho = await agentRequest.execute('GET', '/echo-auth');
|
|
244
|
+
assert.strictEqual(agentEcho.getStatusCode(), 200);
|
|
245
|
+
keepAliveAgent.destroy();
|
|
246
|
+
|
|
137
247
|
const client = new Client(`http://127.0.0.1:${port}`);
|
|
138
248
|
const clientHealth = await client.health();
|
|
139
249
|
assert.strictEqual(clientHealth.isSuccess(), true);
|
|
@@ -145,6 +255,32 @@ async function main() {
|
|
|
145
255
|
const clientEtc = await client.etc();
|
|
146
256
|
assert.deepStrictEqual(clientEtc.getBody(), { protocol: 'http' });
|
|
147
257
|
|
|
258
|
+
const routeEtc = await client.route('etc').get();
|
|
259
|
+
assert.deepStrictEqual(routeEtc.body, { protocol: 'http' });
|
|
260
|
+
|
|
261
|
+
const modulesList = await client.modules().list();
|
|
262
|
+
assert.deepStrictEqual(modulesList.body, { modules: ['demo'] });
|
|
263
|
+
|
|
264
|
+
const moduleSyntax = await client.modules().syntax('demo');
|
|
265
|
+
assert.deepStrictEqual(moduleSyntax.body, { name: 'demo', syntax: 'demo <q>' });
|
|
266
|
+
|
|
267
|
+
const moduleSearch = await client.module('demo').route('search').get({
|
|
268
|
+
q: 'example query'
|
|
269
|
+
});
|
|
270
|
+
assert.strictEqual(moduleSearch.body.module, 'demo');
|
|
271
|
+
assert.strictEqual(moduleSearch.body.route, 'search');
|
|
272
|
+
assert.match(moduleSearch.body.url, /q=example\+query|q=example%20query/);
|
|
273
|
+
|
|
274
|
+
const modulePost = await client.module('demo').route('index').post({
|
|
275
|
+
id: 'doc_1'
|
|
276
|
+
});
|
|
277
|
+
assert.deepStrictEqual(modulePost.body.indexed, { id: 'doc_1' });
|
|
278
|
+
|
|
279
|
+
const moduleCall = await client.modules().call('demo', 'search', 'GET', null, {
|
|
280
|
+
q: 'from call'
|
|
281
|
+
});
|
|
282
|
+
assert.match(moduleCall.body.url, /q=from\+call|q=from%20call/);
|
|
283
|
+
|
|
148
284
|
const clientLinks = await client.links();
|
|
149
285
|
assert.deepStrictEqual(clientLinks.getBody(), { links: [] });
|
|
150
286
|
|
|
@@ -172,15 +308,140 @@ async function main() {
|
|
|
172
308
|
assert.strictEqual(typeof client.sam().history, 'function');
|
|
173
309
|
assert.strictEqual(typeof client.sam().pause, 'function');
|
|
174
310
|
assert.strictEqual(typeof client.sam().clearPause, 'function');
|
|
311
|
+
assert.strictEqual(typeof client.sam().improve, 'function');
|
|
312
|
+
assert.strictEqual(typeof client.sam().flushActorMetadata, 'function');
|
|
175
313
|
assert.strictEqual(typeof client.sam().listDocuments, 'function');
|
|
176
314
|
assert.strictEqual(typeof client.sam().getDocument, 'function');
|
|
177
315
|
assert.strictEqual(typeof client.sam().openDocument, 'function');
|
|
316
|
+
assert.strictEqual(typeof client.sam().addDocumentLabel, 'function');
|
|
317
|
+
assert.strictEqual(typeof client.users().list, 'function');
|
|
318
|
+
assert.strictEqual(typeof client.analytics().click, 'function');
|
|
178
319
|
await assert.rejects(() => client.sam().history(null, 0), /positive integer/);
|
|
179
320
|
await assert.rejects(() => client.sam().listDocuments('books', NaN, 20), /non-negative integer/);
|
|
180
321
|
|
|
181
322
|
const keyResponse = await client.keys().get('key/with space');
|
|
182
323
|
assert.deepStrictEqual(keyResponse.getBody(), { id: 'key/with space' });
|
|
183
324
|
await assert.rejects(() => client.documents().import('books', { id: 'not-array' }), /array/);
|
|
325
|
+
|
|
326
|
+
const strictClient = new Client(`http://127.0.0.1:${port}`, { throw_on_error: true });
|
|
327
|
+
await assert.rejects(
|
|
328
|
+
() => strictClient.executeRequest('GET', '/missing'),
|
|
329
|
+
(error) => error.statusCode === 404 && error.response.getStatusCode() === 404
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
const fakeCollections = new Collections({
|
|
333
|
+
async execute(method, path, body) {
|
|
334
|
+
assert.strictEqual(body.name, 'books');
|
|
335
|
+
assert.strictEqual(body.extra, true);
|
|
336
|
+
return new Response(201, body);
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
const created = await fakeCollections.create('books', { name: 'books', extra: true });
|
|
340
|
+
assert.strictEqual(created.getBody().name, 'books');
|
|
341
|
+
await assert.rejects(() => fakeCollections.create('books', { name: 'other' }), /must match/);
|
|
342
|
+
|
|
343
|
+
const fieldsApi = new Collections({
|
|
344
|
+
async execute() {
|
|
345
|
+
return new Response(200, {
|
|
346
|
+
fields: [{ name: 'title', type: 'string' }],
|
|
347
|
+
searchable_fields: ['body'],
|
|
348
|
+
filterable_fields: ['author']
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
const fields = await fieldsApi.getFields('books');
|
|
353
|
+
assert.deepStrictEqual(fields.getBody().fields.map(field => field.name), ['title', 'body', 'author']);
|
|
354
|
+
|
|
355
|
+
const capturedRequests = [];
|
|
356
|
+
const captureRequest = {
|
|
357
|
+
async execute(method, path, body = null, queryParams = {}) {
|
|
358
|
+
capturedRequests.push({ method, path, body, queryParams });
|
|
359
|
+
return new Response(200, { ok: true });
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
const capturedCollections = new Collections(captureRequest);
|
|
363
|
+
const capturedDocuments = new Documents(captureRequest);
|
|
364
|
+
const capturedAliases = new Aliases(captureRequest);
|
|
365
|
+
const capturedSam = new SAM(captureRequest);
|
|
366
|
+
const capturedSystem = new System(captureRequest);
|
|
367
|
+
const capturedModules = new Modules(captureRequest);
|
|
368
|
+
const capturedUsers = new Users(captureRequest);
|
|
369
|
+
const capturedAnalytics = new Analytics(captureRequest);
|
|
370
|
+
|
|
371
|
+
await capturedCollections.language('books');
|
|
372
|
+
await capturedAliases.listCollection('books');
|
|
373
|
+
await capturedDocuments.context('books', 'doc/one');
|
|
374
|
+
await capturedDocuments.maybe('books', { q: 'bok' });
|
|
375
|
+
await capturedDocuments.updateByQuery('books', { filter_by: 'author:alice', update: { featured: true } });
|
|
376
|
+
await capturedDocuments.deleteByQuery('books', { filter_by: 'author:alice' });
|
|
377
|
+
await capturedSam.improve({ limit: 2, force: true });
|
|
378
|
+
await capturedSam.flushActorMetadata();
|
|
379
|
+
await capturedSam.addDocumentLabel('books', 'doc/one', ['featured']);
|
|
380
|
+
await capturedUsers.list();
|
|
381
|
+
await capturedUsers.get('alice smith');
|
|
382
|
+
await capturedUsers.create({ name: 'alice smith', flags: ['user'] });
|
|
383
|
+
await capturedUsers.update('alice smith', { description: 'Updated' });
|
|
384
|
+
await capturedUsers.delete('alice smith');
|
|
385
|
+
await capturedAnalytics.click({ collection: 'books', doc_id: 'doc/one', query: 'book', rank: 1 });
|
|
386
|
+
await capturedSystem.ready();
|
|
387
|
+
await capturedSystem.metricsHistory();
|
|
388
|
+
await capturedSystem.metricsHistoryAlias();
|
|
389
|
+
await capturedSystem.searchConfig();
|
|
390
|
+
await capturedSystem.llm();
|
|
391
|
+
await capturedSystem.updateCounters({ prefix: 'bench_' });
|
|
392
|
+
await capturedSystem.debugCounters();
|
|
393
|
+
await capturedSystem.repair({ collection: 'books', rebuild_index: true });
|
|
394
|
+
await capturedModules.load('demo');
|
|
395
|
+
await capturedModules.unload('demo');
|
|
396
|
+
|
|
397
|
+
assert.deepStrictEqual(capturedRequests, [
|
|
398
|
+
{ method: 'GET', path: '/collections/books/lang', body: null, queryParams: {} },
|
|
399
|
+
{ method: 'GET', path: '/collections/books/aliases', body: null, queryParams: {} },
|
|
400
|
+
{ method: 'GET', path: '/collections/books/documents/doc%2Fone/context', body: null, queryParams: {} },
|
|
401
|
+
{ method: 'GET', path: '/collections/books/documents/maybe', body: null, queryParams: { q: 'bok' } },
|
|
402
|
+
{ method: 'POST', path: '/collections/books/documents/_update_by_query', body: { filter_by: 'author:alice', update: { featured: true } }, queryParams: {} },
|
|
403
|
+
{ method: 'POST', path: '/collections/books/documents/_delete_by_query', body: { filter_by: 'author:alice' }, queryParams: {} },
|
|
404
|
+
{ method: 'POST', path: '/sam/improve', body: null, queryParams: { limit: 2, force: true } },
|
|
405
|
+
{ method: 'POST', path: '/sam/flush_actor_metadata', body: null, queryParams: {} },
|
|
406
|
+
{ method: 'POST', path: '/sam/label/add/books/doc%2Fone', body: { labels: ['featured'] }, queryParams: {} },
|
|
407
|
+
{ method: 'GET', path: '/users', body: null, queryParams: {} },
|
|
408
|
+
{ method: 'GET', path: '/users/alice%20smith', body: null, queryParams: {} },
|
|
409
|
+
{ method: 'POST', path: '/users', body: { name: 'alice smith', flags: ['user'] }, queryParams: {} },
|
|
410
|
+
{ method: 'PUT', path: '/users/alice%20smith', body: { description: 'Updated' }, queryParams: {} },
|
|
411
|
+
{ method: 'DELETE', path: '/users/alice%20smith', body: null, queryParams: {} },
|
|
412
|
+
{ method: 'POST', path: '/analytics/click', body: { collection: 'books', doc_id: 'doc/one', query: 'book', rank: 1 }, queryParams: {} },
|
|
413
|
+
{ method: 'GET', path: '/ready', body: null, queryParams: {} },
|
|
414
|
+
{ method: 'GET', path: '/metrics/history', body: null, queryParams: {} },
|
|
415
|
+
{ method: 'GET', path: '/metrics-history', body: null, queryParams: {} },
|
|
416
|
+
{ method: 'GET', path: '/search-config', body: null, queryParams: {} },
|
|
417
|
+
{ method: 'GET', path: '/llm', body: null, queryParams: {} },
|
|
418
|
+
{ method: 'POST', path: '/update-counters', body: null, queryParams: { prefix: 'bench_' } },
|
|
419
|
+
{ method: 'GET', path: '/debug/counters', body: null, queryParams: {} },
|
|
420
|
+
{ method: 'POST', path: '/repair', body: null, queryParams: { collection: 'books', rebuild_index: true } },
|
|
421
|
+
{ method: 'POST', path: '/loadmodule/demo', body: null, queryParams: {} },
|
|
422
|
+
{ method: 'POST', path: '/unloadmodule/demo', body: null, queryParams: {} }
|
|
423
|
+
]);
|
|
424
|
+
|
|
425
|
+
let collectionFetches = 0;
|
|
426
|
+
const searchApi = new Search({
|
|
427
|
+
async execute(method, path, body, queryParams) {
|
|
428
|
+
return new Response(200, { queryParams });
|
|
429
|
+
}
|
|
430
|
+
}, {
|
|
431
|
+
async get() {
|
|
432
|
+
collectionFetches += 1;
|
|
433
|
+
return new Response(200, { searchable_fields: ['title', 'body'] });
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
await searchApi.search('books', { q: 'one' });
|
|
437
|
+
await searchApi.search('books', { q: 'two' });
|
|
438
|
+
assert.strictEqual(collectionFetches, 1);
|
|
439
|
+
|
|
440
|
+
const noAutoDetect = await searchApi.search('books', {
|
|
441
|
+
q: 'three',
|
|
442
|
+
auto_detect_query_by: false
|
|
443
|
+
});
|
|
444
|
+
assert.strictEqual(noAutoDetect.getBody().queryParams.query_by, undefined);
|
|
184
445
|
} finally {
|
|
185
446
|
await new Promise((resolve) => server.close(resolve));
|
|
186
447
|
}
|
package/utils/Config.js
CHANGED
|
@@ -23,6 +23,14 @@ class Config {
|
|
|
23
23
|
timeout: options.timeout || 30000,
|
|
24
24
|
token: options.token || null,
|
|
25
25
|
auth_method: options.auth_method || 'bearer',
|
|
26
|
+
agent: options.agent || null,
|
|
27
|
+
http_agent: options.http_agent || null,
|
|
28
|
+
https_agent: options.https_agent || null,
|
|
29
|
+
headers: options.headers || {},
|
|
30
|
+
max_response_bytes: options.max_response_bytes || 0,
|
|
31
|
+
throw_on_error: options.throw_on_error || false,
|
|
32
|
+
query_array_format: options.query_array_format || 'comma',
|
|
33
|
+
signal: options.signal || null,
|
|
26
34
|
...options
|
|
27
35
|
};
|
|
28
36
|
}
|
|
@@ -34,11 +42,17 @@ class Config {
|
|
|
34
42
|
if (!url) {
|
|
35
43
|
return Config.getDefaultBaseUrl();
|
|
36
44
|
}
|
|
45
|
+
if (url instanceof URL) {
|
|
46
|
+
url = url.toString();
|
|
47
|
+
}
|
|
48
|
+
if (typeof url !== 'string') {
|
|
49
|
+
url = String(url);
|
|
50
|
+
}
|
|
37
51
|
url = url.trim();
|
|
38
52
|
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
|
39
53
|
url = 'http://' + url;
|
|
40
54
|
}
|
|
41
|
-
return url.replace(
|
|
55
|
+
return url.replace(/\/+$/, '');
|
|
42
56
|
}
|
|
43
57
|
|
|
44
58
|
/**
|
|
@@ -47,7 +61,8 @@ class Config {
|
|
|
47
61
|
static isValidUrl(url) {
|
|
48
62
|
try {
|
|
49
63
|
const urlObj = new URL(url);
|
|
50
|
-
return urlObj.protocol === 'http:' || urlObj.protocol === 'https:'
|
|
64
|
+
return (urlObj.protocol === 'http:' || urlObj.protocol === 'https:') &&
|
|
65
|
+
Boolean(urlObj.hostname);
|
|
51
66
|
} catch (e) {
|
|
52
67
|
return false;
|
|
53
68
|
}
|
package/utils/Validator.js
CHANGED
|
@@ -56,12 +56,12 @@ class Validator {
|
|
|
56
56
|
throw new ValidationException('Document ID must be between 1 and 64 characters');
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
);
|
|
59
|
+
if (idStr.trim() === '') {
|
|
60
|
+
throw new ValidationException('Document ID must be a non-empty string or number');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (/[\x00-\x1F\x7F]/.test(idStr)) {
|
|
64
|
+
throw new ValidationException('Document ID contains control characters');
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
67
|
|
|
@@ -100,8 +100,7 @@ class Validator {
|
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
/**
|
|
103
|
-
* Validate document field values
|
|
104
|
-
* Commas are not allowed in string field values as they're reserved for internal parsing
|
|
103
|
+
* Validate document field values.
|
|
105
104
|
*
|
|
106
105
|
* @param {object} document - Document object to validate
|
|
107
106
|
* @throws {ValidationException} If document contains invalid characters
|
|
@@ -111,28 +110,12 @@ class Validator {
|
|
|
111
110
|
return; // Skip validation for non-objects or arrays (will be validated per-item)
|
|
112
111
|
}
|
|
113
112
|
|
|
114
|
-
for (const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
// Check string values for commas
|
|
119
|
-
if (typeof value === 'string' && value.includes(',')) {
|
|
120
|
-
throw new ValidationException(
|
|
121
|
-
`Field '${key}' contains invalid character: comma (,). ` +
|
|
122
|
-
`Commas are not allowed in field values. Use underscores (_) or spaces instead, or use arrays for multiple values.`
|
|
123
|
-
);
|
|
113
|
+
for (const key of Object.keys(document)) {
|
|
114
|
+
if (key === '') {
|
|
115
|
+
throw new ValidationException('Document field names must be non-empty strings');
|
|
124
116
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if (Array.isArray(value)) {
|
|
128
|
-
for (const item of value) {
|
|
129
|
-
if (typeof item === 'string' && item.includes(',')) {
|
|
130
|
-
throw new ValidationException(
|
|
131
|
-
`Field '${key}' contains invalid character: comma (,). ` +
|
|
132
|
-
`Array items cannot contain commas. Use underscores (_) or spaces instead.`
|
|
133
|
-
);
|
|
134
|
-
}
|
|
135
|
-
}
|
|
117
|
+
if (/[\x00-\x1F\x7F]/.test(key)) {
|
|
118
|
+
throw new ValidationException(`Field '${key}' contains control characters`);
|
|
136
119
|
}
|
|
137
120
|
}
|
|
138
121
|
}
|