@onlineapps/content-resolver 1.1.16 → 2.0.1

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 (3) hide show
  1. package/README.md +36 -8
  2. package/package.json +9 -8
  3. package/src/index.js +177 -32
package/README.md CHANGED
@@ -31,7 +31,8 @@ const resolver = new ContentResolver({
31
31
  port: 9000,
32
32
  accessKey: 'minioadmin',
33
33
  secretKey: 'minioadmin'
34
- }
34
+ },
35
+ logger // required - the constructor throws without a logger exposing warn()
35
36
  });
36
37
 
37
38
  // Resolve reference to content
@@ -57,7 +58,7 @@ const descriptor = await resolver.store(largeText, { workflow_id: 'wf-123' }, 'd
57
58
  const ContentResolver = require('@onlineapps/content-resolver');
58
59
 
59
60
  exports.processDocument = async (input, context = {}) => {
60
- const resolver = new ContentResolver();
61
+ const resolver = new ContentResolver({ logger }); // logger is required
61
62
 
62
63
  // Input can be either text or reference - resolve transparently
63
64
  const resolvedInput = await resolver.resolveInput(input, ['content', 'markdown']);
@@ -80,17 +81,37 @@ exports.processDocument = async (input, context = {}) => {
80
81
  |--------|------|---------|-------------|
81
82
  | `threshold` | number | 16384 | Size threshold in bytes |
82
83
  | `storage` | Object | env-based | Storage connector config |
83
- | `logger` | Object | console | Logger instance |
84
+ | `logger` | Object | **required** | Logger instance — must expose `warn()`. The constructor throws without it (`src/index.js`); there is no `console` default. |
85
+
86
+ ### Input contract
87
+
88
+ Every method below takes one of two input sets, and **throws on anything else** —
89
+ there is no `String(value)` coercion anywhere in the API. An object used to be
90
+ stored and shipped as the literal bytes `[object Object]`; that path is gone.
91
+
92
+ | Set | Accepted | Methods |
93
+ |---|---|---|
94
+ | **Content value** | a string (inline content **or** a `minio://<bucket>/<path>` / `internal://storage/<path>` reference), or a Content Descriptor (`{ _descriptor: true, type: 'inline' \| 'file' }`, or type-only) | `getAsBuffer`, `getAsString`, `getMetadata`, `normalizeToDescriptor` |
95
+ | **Raw content** | a string or a `Buffer` | `createDescriptor`, `store` |
96
+
97
+ `resolve()` is narrower still: a string only, because it promises to return one.
98
+
99
+ Rejection message format: `[ContentResolver] Unsupported content value - <method>() accepts <shapes>, got <kind>. Fix: …`
84
100
 
85
101
  ### Methods
86
102
 
87
103
  #### `resolve(value): Promise<string>`
88
104
  If value is a reference (`minio://...`), downloads and returns content.
89
- Otherwise returns value unchanged.
105
+ A non-reference string is returned unchanged. **Anything that is not a string throws** —
106
+ use `getAsString()` to read a Descriptor.
90
107
 
91
108
  #### `store(content, context, filename?, content_type?): Promise<Object>`
92
109
  Stores content and returns **Content Descriptor**. If size > threshold, stores in MinIO.
93
110
  Returns Descriptor with `type: 'inline'` or `type: 'file'`.
111
+ Content must be a string or a `Buffer`. The **empty string** `''` is content: it returns
112
+ the documented empty inline descriptor (`filename: 'empty.txt'`, `size: 0`, no
113
+ fingerprint). Every other falsy value — `null`, `undefined`, `0`, `false` — is a MISSING
114
+ value and **throws** the same contract error `createDescriptor()` throws.
94
115
 
95
116
  #### `getAsBuffer(value): Promise<Buffer>`
96
117
  Unified API to get content as Buffer. Accepts:
@@ -98,17 +119,24 @@ Unified API to get content as Buffer. Accepts:
98
119
  - Storage reference (`minio://...`) → downloads and returns Buffer
99
120
  - Content Descriptor → extracts content as Buffer
100
121
 
122
+ A raw `Buffer` is **not** accepted here — this method reads content, it does not wrap it.
123
+
101
124
  #### `getAsString(value): Promise<string>`
102
125
  Unified API to get content as string. Works with string, reference, or Descriptor.
103
126
 
104
127
  #### `getMetadata(value): Object`
105
- Get metadata (filename, content_type, size, fingerprint) from any value type.
128
+ Get metadata (filename, content_type, size, fingerprint) from a string or a Descriptor —
129
+ **exactly** the values `getAsBuffer()` accepts, so the two never disagree about what a
130
+ value is. Throws on anything else.
106
131
 
107
132
  #### `createDescriptor(content, options): Promise<Object>`
108
- Create Content Descriptor from raw content. Automatically decides inline vs file storage.
133
+ Create Content Descriptor from raw content a **string or a `Buffer`**.
134
+ Automatically decides inline vs file storage; a Buffer is always stored as a file.
109
135
 
110
136
  #### `normalizeToDescriptor(value, options): Promise<Object>`
111
- Normalize any value (string, reference, Buffer) to Content Descriptor.
137
+ Normalize a string, a reference or a Descriptor to a Content Descriptor.
138
+ A raw `Buffer` is **not** accepted (it carries no filename/content-type context) —
139
+ pass it to `createDescriptor()` or `store()` instead.
112
140
 
113
141
  #### `createDescriptorFromFile(tempPath, options): Promise<Object>`
114
142
  Create Content Descriptor from a temp file. **Used by ApiMapper for file outputs.**
@@ -176,7 +204,7 @@ Stores large content fields as Descriptors (returns Descriptors, not plain strin
176
204
  ### Usage Example
177
205
 
178
206
  ```javascript
179
- const resolver = new ContentResolver();
207
+ const resolver = new ContentResolver({ logger }); // logger is required
180
208
 
181
209
  // Work with attachments - unified API
182
210
  async function processAttachment(attachment) {
package/package.json CHANGED
@@ -1,10 +1,12 @@
1
1
  {
2
2
  "name": "@onlineapps/content-resolver",
3
- "version": "1.1.16",
3
+ "version": "2.0.1",
4
4
  "description": "Automatic conversion between text content and storage references with Content Descriptor pattern",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
7
- "test": "jest --passWithNoTests"
7
+ "test": "npm run test:unit && npm run test:integration",
8
+ "test:unit": "jest",
9
+ "test:integration": "jest --config=jest.integration.config.js"
8
10
  },
9
11
  "keywords": [
10
12
  "content",
@@ -16,11 +18,10 @@
16
18
  "author": "OnlineApps",
17
19
  "license": "ISC",
18
20
  "dependencies": {
19
- "@onlineapps/conn-base-storage": "1.0.9",
20
- "@onlineapps/runtime-config": "1.0.2"
21
+ "@onlineapps/conn-base-storage": "3.0.0",
22
+ "@onlineapps/runtime-config": "1.0.3"
21
23
  },
22
- "peerDependencies": {
23
- "@onlineapps/conn-base-storage": "^1.0.0"
24
- },
25
- "devDependencies": {}
24
+ "devDependencies": {
25
+ "jest": "^29.7.0"
26
+ }
26
27
  }
package/src/index.js CHANGED
@@ -23,6 +23,16 @@ const INTERNAL_REF_PATTERN = /^internal:\/\/storage\/(.+)$/;
23
23
 
24
24
  /**
25
25
  * Check if a value is a Content Descriptor
26
+ *
27
+ * A descriptor is recognised by the explicit `_descriptor` flag OR by its `type`
28
+ * alone. The second form is part of the contract, not a backward-compatibility
29
+ * shim: `emailer` treats an externally supplied `{ type: 'inline' | 'file' }`
30
+ * payload as canonical and forwards it to the resolver WITHOUT stamping
31
+ * `_descriptor` (api_biz/emailer/src/services/email.service.js:332 → :431), and
32
+ * its operation schema declares `attachments[].value` as an untyped object
33
+ * (api_biz/emailer/config/service/operations.json), so such payloads arrive from
34
+ * outside this codebase. Dropping type-only recognition breaks that consumer.
35
+ *
26
36
  * @param {*} value - Value to check
27
37
  * @returns {boolean} True if value is a Descriptor object
28
38
  */
@@ -30,10 +40,73 @@ function isDescriptor(value) {
30
40
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
31
41
  return false;
32
42
  }
33
- // Check explicit identifier first, then fallback to type check for backward compatibility
34
43
  return value._descriptor === true || (value.type === 'inline' || value.type === 'file');
35
44
  }
36
45
 
46
+ /**
47
+ * Name a value's kind for an error message — `typeof` alone cannot tell null,
48
+ * an array and an object apart, and those are the three shapes that actually
49
+ * reach the resolver by mistake.
50
+ * @param {*} value
51
+ * @returns {string}
52
+ */
53
+ function describeValue(value) {
54
+ if (value === null) return 'null';
55
+ if (Array.isArray(value)) return 'array';
56
+ return typeof value;
57
+ }
58
+
59
+ /**
60
+ * The input contract, written down ONCE.
61
+ *
62
+ * Every public method that takes a content value takes one of these sets and
63
+ * throws on anything else. Before 2026-08-28 four of them silently coerced with
64
+ * `String(value)` instead, so the same object was a hard error in
65
+ * `getAsBuffer()` and the literal text `[object Object]` two lines later in
66
+ * `getMetadata()` (api_biz/emailer/src/services/email.service.js:431 → :434).
67
+ *
68
+ * @see ../../../../.claude/rules/architecture-principles.md §3 (No Fallbacks), §4 (Fail-Fast)
69
+ */
70
+ const ACCEPTS = {
71
+ /** A string (inline content or a reference). Used where the return type is a string. */
72
+ reference:
73
+ `a string (inline content or a minio://<bucket>/<path> / internal://storage/<path> reference)`,
74
+
75
+ /** A string or a Content Descriptor — the set `getAsBuffer()` established. */
76
+ contentValue:
77
+ `a string (inline content or a minio://<bucket>/<path> / internal://storage/<path> reference) ` +
78
+ `or a Content Descriptor ({ _descriptor: true, type: 'inline' | 'file' })`,
79
+
80
+ /** Raw bytes on the way IN to storage: a string or a Buffer. */
81
+ rawContent: 'a string or a Buffer'
82
+ };
83
+
84
+ /** The actionable half of the message, per `architecture-principles.md` §5. */
85
+ const FIXES = {
86
+ wrap: 'Fix: wrap raw data with store() or createDescriptor() before passing it.',
87
+ serialize: 'Fix: serialize the value yourself (e.g. JSON.stringify) or pass a Buffer.',
88
+ readDescriptor:
89
+ 'Fix: read a Content Descriptor with getAsString() instead, or pass the string itself.'
90
+ };
91
+
92
+ /**
93
+ * Build the one rejection message this package uses — one owner, not one copy
94
+ * per method.
95
+ *
96
+ * @param {string} method - The public method that received the value.
97
+ * @param {string} accepted - One of `ACCEPTS`.
98
+ * @param {*} value - The rejected value.
99
+ * @param {string} fix - One of `FIXES`.
100
+ * @returns {Error}
101
+ */
102
+ function unsupportedValueError(method, accepted, value, fix) {
103
+ return new Error(
104
+ `[ContentResolver] Unsupported content value - ${method}() accepts ${accepted}, got ` +
105
+ `${describeValue(value)}. ` +
106
+ `${fix}`
107
+ );
108
+ }
109
+
37
110
  /**
38
111
  * Get content type from filename or content
39
112
  * @param {string} filename - Filename with extension
@@ -147,7 +220,10 @@ class ContentResolver {
147
220
  if (config.endPoint) {
148
221
  config.endPoint = config.endPoint.replace(/^https?:\/\//, '');
149
222
  }
150
- this.storage = new StorageConnector(config);
223
+ // The connector requires an injected logger (conn-base-storage 3.0.0,
224
+ // architecture-principles.md §1). ContentResolver already fail-fasts on
225
+ // its own logger, so it has one to give.
226
+ this.storage = new StorageConnector({ ...config, logger: this.logger });
151
227
  await this.storage.initialize();
152
228
  }
153
229
  return this.storage;
@@ -155,12 +231,20 @@ class ContentResolver {
155
231
 
156
232
  /**
157
233
  * Resolve a value - if it's a reference, download content; if it's text, return as-is
234
+ *
235
+ * Accepts a string and nothing else. The method promises to RETURN a string,
236
+ * so a non-string input has no correct answer; handing it back unchanged made
237
+ * that promise false and pushed the type error into whichever caller did
238
+ * string work on the result next. Both real call sites already reject a
239
+ * non-string themselves (api_biz/pdfgen/src/handlers/pdf.js:221 and :297).
240
+ *
158
241
  * @param {string} value - Text content or storage reference
159
242
  * @returns {Promise<string>} Resolved text content
243
+ * @throws {Error} If the value is not a string
160
244
  */
161
245
  async resolve(value) {
162
- if (!value || typeof value !== 'string') {
163
- return value;
246
+ if (typeof value !== 'string') {
247
+ throw unsupportedValueError('resolve', ACCEPTS.reference, value, FIXES.readDescriptor);
164
248
  }
165
249
 
166
250
  // Check if it's a reference
@@ -168,12 +252,17 @@ class ContentResolver {
168
252
  return value; // Already text content
169
253
  }
170
254
 
171
- // Parse reference
255
+ // Parse reference.
256
+ //
257
+ // No null check: `isReference()` above and `parseReference()` read the SAME
258
+ // two patterns, neither with the `g` flag, so a value that passed the first
259
+ // cannot fail the second. The `if (!parsed)` branch that used to sit here
260
+ // warned and returned the raw reference as if it were content — a fallback
261
+ // that could never fire, and would have handed back a URI instead of a
262
+ // document if it had (`architecture-principles.md` §3). The invariant is
263
+ // asserted in tests/unit/content-resolver.test.js
264
+ // ('invariant — everything isReference() accepts, parseReference() parses').
172
265
  const parsed = parseReference(value);
173
- if (!parsed) {
174
- this.logger.warn(`[ContentResolver] Invalid reference format: ${value}`);
175
- return value;
176
- }
177
266
 
178
267
  // Download content
179
268
  try {
@@ -206,7 +295,14 @@ class ContentResolver {
206
295
  * @returns {Promise<Object>} Content Descriptor
207
296
  */
208
297
  async store(content, context = {}, filename = null, content_type = null) {
209
- if (!content) {
298
+ // The EMPTY STRING is content: the caller said "store nothing", and gets the
299
+ // documented empty inline descriptor for it. Everything else falsy — `null`,
300
+ // `undefined`, `0`, `false`, `NaN` — is a MISSING value, i.e. a defect at the
301
+ // call site, and falls through to the one input contract this package owns
302
+ // (`createDescriptor()` / `ACCEPTS.rawContent`). The old `if (!content)` test
303
+ // could not tell the two apart and answered a mistake with a valid-looking
304
+ // descriptor (`architecture-principles.md` §3, §4).
305
+ if (content === '') {
210
306
  return {
211
307
  _descriptor: true,
212
308
  type: 'inline',
@@ -314,30 +410,63 @@ class ContentResolver {
314
410
 
315
411
  /**
316
412
  * Get content as Buffer - unified API for string, reference, or Descriptor
413
+ *
414
+ * Accepts exactly two input types, and nothing else:
415
+ * - a string — either inline content or a storage reference. This is an
416
+ * input type, not a leniency path: `api_monitoring` passes a bare
417
+ * `minio://…` string (api/infra/api_monitoring/src/consumer/index.js:2815).
418
+ * - a Content Descriptor — stamped or type-only (see `isDescriptor`).
419
+ *
420
+ * Anything else throws. It used to be coerced with `Buffer.from(String(value))`,
421
+ * which turned an object into the literal bytes `[object Object]` and shipped
422
+ * them as an email attachment or an HTTP download body — a silent fallback
423
+ * banned by `architecture-principles.md` §3.
424
+ *
317
425
  * @param {string|Object} value - String, reference, or Content Descriptor
318
426
  * @returns {Promise<Buffer>} Content as Buffer
427
+ * @throws {Error} If the value is not a string or a well-formed Descriptor
319
428
  */
320
429
  async getAsBuffer(value) {
321
- // Plain string (backward compatibility)
322
430
  if (typeof value === 'string') {
323
431
  if (isReference(value)) {
324
432
  return await this.downloadAsBuffer(value);
325
433
  }
326
434
  return Buffer.from(value, 'utf-8');
327
435
  }
328
-
329
- // Content Descriptor
436
+
330
437
  if (isDescriptor(value)) {
331
438
  if (value.type === 'file') {
439
+ if (typeof value.storage_ref !== 'string' || value.storage_ref.length === 0) {
440
+ throw new Error(
441
+ `[ContentResolver] File descriptor without storage_ref - a descriptor of type 'file' requires ` +
442
+ `a non-empty storage_ref string (minio://<bucket>/<path> or internal://storage/<path>), got ` +
443
+ `${describeValue(value.storage_ref)}. ` +
444
+ `Fix: build the descriptor with store() or createDescriptor() instead of by hand.`
445
+ );
446
+ }
332
447
  return await this.downloadAsBuffer(value.storage_ref);
333
448
  }
334
- // type === 'inline'
335
- const encoding = value.encoding || 'utf-8';
336
- return Buffer.from(value.content, encoding);
449
+
450
+ if (value.type === 'inline') {
451
+ if (typeof value.content !== 'string') {
452
+ throw new Error(
453
+ `[ContentResolver] Inline descriptor without content - a descriptor of type 'inline' requires ` +
454
+ `a content string, got ${describeValue(value.content)}. ` +
455
+ `Fix: build the descriptor with store() or createDescriptor() instead of by hand.`
456
+ );
457
+ }
458
+ const encoding = value.encoding || 'utf-8';
459
+ return Buffer.from(value.content, encoding);
460
+ }
461
+
462
+ throw new Error(
463
+ `[ContentResolver] Descriptor with unsupported type - expected type 'inline' or 'file', got ` +
464
+ `${describeValue(value.type)}. ` +
465
+ `Fix: build the descriptor with store() or createDescriptor() instead of by hand.`
466
+ );
337
467
  }
338
-
339
- // Fallback: try to convert to string
340
- return Buffer.from(String(value), 'utf-8');
468
+
469
+ throw unsupportedValueError('getAsBuffer', ACCEPTS.contentValue, value, FIXES.wrap);
341
470
  }
342
471
 
343
472
  /**
@@ -352,8 +481,16 @@ class ContentResolver {
352
481
 
353
482
  /**
354
483
  * Get metadata from value - unified API
484
+ *
485
+ * Accepts exactly what `getAsBuffer()` accepts, because it is called on the
486
+ * SAME value one line later (api_biz/emailer/src/services/email.service.js:431
487
+ * → :434). While this method coerced with `String(value)`, the pair disagreed:
488
+ * a value `getAsBuffer()` refused was still described here as a 15-byte
489
+ * `content.txt` — the metadata of the text `[object Object]`.
490
+ *
355
491
  * @param {string|Object} value - String, reference, or Content Descriptor
356
492
  * @returns {Object} Metadata object with filename, content_type, size, fingerprint
493
+ * @throws {Error} If the value is not a string or a Descriptor
357
494
  */
358
495
  getMetadata(value) {
359
496
  // Plain string
@@ -375,13 +512,7 @@ class ContentResolver {
375
512
  };
376
513
  }
377
514
 
378
- // Fallback
379
- const str = String(value);
380
- return {
381
- filename: 'content.txt',
382
- content_type: 'text/plain',
383
- size: Buffer.byteLength(str, 'utf-8')
384
- };
515
+ throw unsupportedValueError('getMetadata', ACCEPTS.contentValue, value, FIXES.wrap);
385
516
  }
386
517
 
387
518
  /**
@@ -393,11 +524,17 @@ class ContentResolver {
393
524
  * @param {Object} [options.context] - Workflow context
394
525
  * @param {boolean} [options.forceFile=false] - Force storage as file even if small
395
526
  * @returns {Promise<Object>} Content Descriptor
527
+ * @throws {Error} If the content is neither a string nor a Buffer
396
528
  */
397
529
  async createDescriptor(content, options = {}) {
398
530
  const { filename, content_type, context = {}, forceFile = false } = options;
399
-
400
- // Convert Buffer to string if needed
531
+
532
+ // A string or a Buffer, and nothing else. Buffer is a MEASURED input type,
533
+ // not a leniency path: `storeOutput()` forwards Buffers by name (:289) and
534
+ // `normalizeToDescriptor()` hands over the bytes it just downloaded (:577).
535
+ // Anything else used to be coerced with `String(content)`, which stored the
536
+ // eight bytes of `[object Object]` under a sha256 of themselves and reported
537
+ // it back as a valid descriptor.
401
538
  let contentString;
402
539
  let isBinary = false;
403
540
  if (Buffer.isBuffer(content)) {
@@ -406,7 +543,7 @@ class ContentResolver {
406
543
  } else if (typeof content === 'string') {
407
544
  contentString = content;
408
545
  } else {
409
- contentString = String(content);
546
+ throw unsupportedValueError('createDescriptor', ACCEPTS.rawContent, content, FIXES.serialize);
410
547
  }
411
548
 
412
549
  // For binary content, always store as file
@@ -492,9 +629,18 @@ class ContentResolver {
492
629
 
493
630
  /**
494
631
  * Normalize value to Content Descriptor if needed
632
+ *
633
+ * Accepts the same set as `getAsBuffer()`: a string or a Descriptor. A Buffer
634
+ * is NOT in that set even though `createDescriptor()` takes one — this method
635
+ * only RECOGNISES a value, and a bare Buffer carries none of the
636
+ * filename/content-type context `createDescriptor()` takes as arguments. It
637
+ * used to be swallowed by `String(value)`, which quietly decoded the bytes
638
+ * into an inline text descriptor via `Buffer.prototype.toString`.
639
+ *
495
640
  * @param {string|Object} value - String, reference, or Descriptor
496
641
  * @param {Object} options - Options for descriptor creation
497
642
  * @returns {Promise<Object>} Content Descriptor
643
+ * @throws {Error} If the value is not a string or a Descriptor
498
644
  */
499
645
  async normalizeToDescriptor(value, options = {}) {
500
646
  // Already a Descriptor - ensure it has _descriptor flag
@@ -518,9 +664,8 @@ class ContentResolver {
518
664
  // Plain string - create descriptor
519
665
  return await this.createDescriptor(value, options);
520
666
  }
521
-
522
- // Fallback
523
- return await this.createDescriptor(String(value), options);
667
+
668
+ throw unsupportedValueError('normalizeToDescriptor', ACCEPTS.contentValue, value, FIXES.wrap);
524
669
  }
525
670
 
526
671
  /**