@onlineapps/content-resolver 1.1.15 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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.15",
3
+ "version": "2.0.0",
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": "2.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
@@ -110,7 +183,10 @@ class ContentResolver {
110
183
  constructor(options = {}) {
111
184
  const resolved = runtimeCfg.resolve(options);
112
185
  this.threshold = resolved.threshold;
113
- this.logger = options.logger || console;
186
+ if (!options.logger || typeof options.logger.warn !== 'function') {
187
+ throw new Error('[ContentResolver] Logger is required — Expected object with warn() method');
188
+ }
189
+ this.logger = options.logger;
114
190
 
115
191
  // Storage can be passed as instance or config (no fallbacks - fail-fast)
116
192
  if (options.storage instanceof StorageConnector) {
@@ -152,12 +228,20 @@ class ContentResolver {
152
228
 
153
229
  /**
154
230
  * Resolve a value - if it's a reference, download content; if it's text, return as-is
231
+ *
232
+ * Accepts a string and nothing else. The method promises to RETURN a string,
233
+ * so a non-string input has no correct answer; handing it back unchanged made
234
+ * that promise false and pushed the type error into whichever caller did
235
+ * string work on the result next. Both real call sites already reject a
236
+ * non-string themselves (api_biz/pdfgen/src/handlers/pdf.js:221 and :297).
237
+ *
155
238
  * @param {string} value - Text content or storage reference
156
239
  * @returns {Promise<string>} Resolved text content
240
+ * @throws {Error} If the value is not a string
157
241
  */
158
242
  async resolve(value) {
159
- if (!value || typeof value !== 'string') {
160
- return value;
243
+ if (typeof value !== 'string') {
244
+ throw unsupportedValueError('resolve', ACCEPTS.reference, value, FIXES.readDescriptor);
161
245
  }
162
246
 
163
247
  // Check if it's a reference
@@ -165,12 +249,17 @@ class ContentResolver {
165
249
  return value; // Already text content
166
250
  }
167
251
 
168
- // Parse reference
252
+ // Parse reference.
253
+ //
254
+ // No null check: `isReference()` above and `parseReference()` read the SAME
255
+ // two patterns, neither with the `g` flag, so a value that passed the first
256
+ // cannot fail the second. The `if (!parsed)` branch that used to sit here
257
+ // warned and returned the raw reference as if it were content — a fallback
258
+ // that could never fire, and would have handed back a URI instead of a
259
+ // document if it had (`architecture-principles.md` §3). The invariant is
260
+ // asserted in tests/unit/content-resolver.test.js
261
+ // ('invariant — everything isReference() accepts, parseReference() parses').
169
262
  const parsed = parseReference(value);
170
- if (!parsed) {
171
- this.logger.warn(`[ContentResolver] Invalid reference format: ${value}`);
172
- return value;
173
- }
174
263
 
175
264
  // Download content
176
265
  try {
@@ -203,7 +292,14 @@ class ContentResolver {
203
292
  * @returns {Promise<Object>} Content Descriptor
204
293
  */
205
294
  async store(content, context = {}, filename = null, content_type = null) {
206
- if (!content) {
295
+ // The EMPTY STRING is content: the caller said "store nothing", and gets the
296
+ // documented empty inline descriptor for it. Everything else falsy — `null`,
297
+ // `undefined`, `0`, `false`, `NaN` — is a MISSING value, i.e. a defect at the
298
+ // call site, and falls through to the one input contract this package owns
299
+ // (`createDescriptor()` / `ACCEPTS.rawContent`). The old `if (!content)` test
300
+ // could not tell the two apart and answered a mistake with a valid-looking
301
+ // descriptor (`architecture-principles.md` §3, §4).
302
+ if (content === '') {
207
303
  return {
208
304
  _descriptor: true,
209
305
  type: 'inline',
@@ -311,30 +407,63 @@ class ContentResolver {
311
407
 
312
408
  /**
313
409
  * Get content as Buffer - unified API for string, reference, or Descriptor
410
+ *
411
+ * Accepts exactly two input types, and nothing else:
412
+ * - a string — either inline content or a storage reference. This is an
413
+ * input type, not a leniency path: `api_monitoring` passes a bare
414
+ * `minio://…` string (api/infra/api_monitoring/src/consumer/index.js:2815).
415
+ * - a Content Descriptor — stamped or type-only (see `isDescriptor`).
416
+ *
417
+ * Anything else throws. It used to be coerced with `Buffer.from(String(value))`,
418
+ * which turned an object into the literal bytes `[object Object]` and shipped
419
+ * them as an email attachment or an HTTP download body — a silent fallback
420
+ * banned by `architecture-principles.md` §3.
421
+ *
314
422
  * @param {string|Object} value - String, reference, or Content Descriptor
315
423
  * @returns {Promise<Buffer>} Content as Buffer
424
+ * @throws {Error} If the value is not a string or a well-formed Descriptor
316
425
  */
317
426
  async getAsBuffer(value) {
318
- // Plain string (backward compatibility)
319
427
  if (typeof value === 'string') {
320
428
  if (isReference(value)) {
321
429
  return await this.downloadAsBuffer(value);
322
430
  }
323
431
  return Buffer.from(value, 'utf-8');
324
432
  }
325
-
326
- // Content Descriptor
433
+
327
434
  if (isDescriptor(value)) {
328
435
  if (value.type === 'file') {
436
+ if (typeof value.storage_ref !== 'string' || value.storage_ref.length === 0) {
437
+ throw new Error(
438
+ `[ContentResolver] File descriptor without storage_ref - a descriptor of type 'file' requires ` +
439
+ `a non-empty storage_ref string (minio://<bucket>/<path> or internal://storage/<path>), got ` +
440
+ `${describeValue(value.storage_ref)}. ` +
441
+ `Fix: build the descriptor with store() or createDescriptor() instead of by hand.`
442
+ );
443
+ }
329
444
  return await this.downloadAsBuffer(value.storage_ref);
330
445
  }
331
- // type === 'inline'
332
- const encoding = value.encoding || 'utf-8';
333
- return Buffer.from(value.content, encoding);
446
+
447
+ if (value.type === 'inline') {
448
+ if (typeof value.content !== 'string') {
449
+ throw new Error(
450
+ `[ContentResolver] Inline descriptor without content - a descriptor of type 'inline' requires ` +
451
+ `a content string, got ${describeValue(value.content)}. ` +
452
+ `Fix: build the descriptor with store() or createDescriptor() instead of by hand.`
453
+ );
454
+ }
455
+ const encoding = value.encoding || 'utf-8';
456
+ return Buffer.from(value.content, encoding);
457
+ }
458
+
459
+ throw new Error(
460
+ `[ContentResolver] Descriptor with unsupported type - expected type 'inline' or 'file', got ` +
461
+ `${describeValue(value.type)}. ` +
462
+ `Fix: build the descriptor with store() or createDescriptor() instead of by hand.`
463
+ );
334
464
  }
335
-
336
- // Fallback: try to convert to string
337
- return Buffer.from(String(value), 'utf-8');
465
+
466
+ throw unsupportedValueError('getAsBuffer', ACCEPTS.contentValue, value, FIXES.wrap);
338
467
  }
339
468
 
340
469
  /**
@@ -349,8 +478,16 @@ class ContentResolver {
349
478
 
350
479
  /**
351
480
  * Get metadata from value - unified API
481
+ *
482
+ * Accepts exactly what `getAsBuffer()` accepts, because it is called on the
483
+ * SAME value one line later (api_biz/emailer/src/services/email.service.js:431
484
+ * → :434). While this method coerced with `String(value)`, the pair disagreed:
485
+ * a value `getAsBuffer()` refused was still described here as a 15-byte
486
+ * `content.txt` — the metadata of the text `[object Object]`.
487
+ *
352
488
  * @param {string|Object} value - String, reference, or Content Descriptor
353
489
  * @returns {Object} Metadata object with filename, content_type, size, fingerprint
490
+ * @throws {Error} If the value is not a string or a Descriptor
354
491
  */
355
492
  getMetadata(value) {
356
493
  // Plain string
@@ -372,13 +509,7 @@ class ContentResolver {
372
509
  };
373
510
  }
374
511
 
375
- // Fallback
376
- const str = String(value);
377
- return {
378
- filename: 'content.txt',
379
- content_type: 'text/plain',
380
- size: Buffer.byteLength(str, 'utf-8')
381
- };
512
+ throw unsupportedValueError('getMetadata', ACCEPTS.contentValue, value, FIXES.wrap);
382
513
  }
383
514
 
384
515
  /**
@@ -390,11 +521,17 @@ class ContentResolver {
390
521
  * @param {Object} [options.context] - Workflow context
391
522
  * @param {boolean} [options.forceFile=false] - Force storage as file even if small
392
523
  * @returns {Promise<Object>} Content Descriptor
524
+ * @throws {Error} If the content is neither a string nor a Buffer
393
525
  */
394
526
  async createDescriptor(content, options = {}) {
395
527
  const { filename, content_type, context = {}, forceFile = false } = options;
396
-
397
- // Convert Buffer to string if needed
528
+
529
+ // A string or a Buffer, and nothing else. Buffer is a MEASURED input type,
530
+ // not a leniency path: `storeOutput()` forwards Buffers by name (:289) and
531
+ // `normalizeToDescriptor()` hands over the bytes it just downloaded (:577).
532
+ // Anything else used to be coerced with `String(content)`, which stored the
533
+ // eight bytes of `[object Object]` under a sha256 of themselves and reported
534
+ // it back as a valid descriptor.
398
535
  let contentString;
399
536
  let isBinary = false;
400
537
  if (Buffer.isBuffer(content)) {
@@ -403,7 +540,7 @@ class ContentResolver {
403
540
  } else if (typeof content === 'string') {
404
541
  contentString = content;
405
542
  } else {
406
- contentString = String(content);
543
+ throw unsupportedValueError('createDescriptor', ACCEPTS.rawContent, content, FIXES.serialize);
407
544
  }
408
545
 
409
546
  // For binary content, always store as file
@@ -489,9 +626,18 @@ class ContentResolver {
489
626
 
490
627
  /**
491
628
  * Normalize value to Content Descriptor if needed
629
+ *
630
+ * Accepts the same set as `getAsBuffer()`: a string or a Descriptor. A Buffer
631
+ * is NOT in that set even though `createDescriptor()` takes one — this method
632
+ * only RECOGNISES a value, and a bare Buffer carries none of the
633
+ * filename/content-type context `createDescriptor()` takes as arguments. It
634
+ * used to be swallowed by `String(value)`, which quietly decoded the bytes
635
+ * into an inline text descriptor via `Buffer.prototype.toString`.
636
+ *
492
637
  * @param {string|Object} value - String, reference, or Descriptor
493
638
  * @param {Object} options - Options for descriptor creation
494
639
  * @returns {Promise<Object>} Content Descriptor
640
+ * @throws {Error} If the value is not a string or a Descriptor
495
641
  */
496
642
  async normalizeToDescriptor(value, options = {}) {
497
643
  // Already a Descriptor - ensure it has _descriptor flag
@@ -515,9 +661,8 @@ class ContentResolver {
515
661
  // Plain string - create descriptor
516
662
  return await this.createDescriptor(value, options);
517
663
  }
518
-
519
- // Fallback
520
- return await this.createDescriptor(String(value), options);
664
+
665
+ throw unsupportedValueError('normalizeToDescriptor', ACCEPTS.contentValue, value, FIXES.wrap);
521
666
  }
522
667
 
523
668
  /**