@giveitsmaller/sdk 0.19.0 → 0.21.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.
- package/LICENSE +202 -0
- package/README.md +8 -15
- package/dist/_audit.js +14 -4
- package/dist/builder.d.ts +5 -4
- package/dist/builder.js +16 -16
- package/dist/client.d.ts +17 -7
- package/dist/client.js +116 -62
- package/dist/ergonomic/image_output_routes.d.ts +30 -0
- package/dist/ergonomic/image_output_routes.js +82 -11
- package/dist/ergonomic/option_types.d.ts +34 -1
- package/dist/ergonomic/option_types.js +5 -1
- package/dist/ergonomic/option_validation.d.ts +19 -1
- package/dist/ergonomic/option_validation.js +34 -1
- package/dist/ergonomic/preset_resolver.d.ts +2 -2
- package/dist/ergonomic/preset_resolver.js +6 -10
- package/dist/ergonomic/presets/index.d.ts +9 -8
- package/dist/ergonomic/presets/index.js +1 -9
- package/dist/errors.d.ts +105 -3
- package/dist/errors.js +136 -2
- package/dist/file-first.d.ts +234 -16
- package/dist/file-first.js +692 -175
- package/dist/generated/sdk_spec/enums.d.ts +0 -26
- package/dist/generated/sdk_spec/enums.js +0 -16
- package/dist/generated/sdk_spec/errors.d.ts +1 -1
- package/dist/generated/sdk_spec/errors.js +12 -0
- package/dist/generated/sdk_spec/presets.js +0 -14
- package/dist/generated/sdk_spec/version.d.ts +2 -2
- package/dist/generated/sdk_spec/version.js +2 -2
- package/dist/gisl.d.ts +93 -5
- package/dist/gisl.js +72 -2
- package/dist/handle.d.ts +6 -1
- package/dist/handle.js +42 -13
- package/dist/index.core.d.ts +10 -8
- package/dist/index.core.js +11 -3
- package/dist/merge.d.ts +12 -0
- package/dist/merge.js +14 -2
- package/dist/retry-metadata.d.ts +37 -0
- package/dist/retry-metadata.js +86 -0
- package/dist/sse.d.ts +2 -1
- package/dist/sse.js +26 -6
- package/dist/types.d.ts +54 -4
- package/dist/types.js +1 -0
- package/package.json +3 -3
- package/dist/ergonomic/presets/document_pdf_compress.d.ts +0 -12
- package/dist/ergonomic/presets/document_pdf_compress.js +0 -33
package/dist/client.js
CHANGED
|
@@ -4,8 +4,12 @@
|
|
|
4
4
|
// blobByteSource, which never touches these). Kept as a STATIC import (not a
|
|
5
5
|
// dynamic one) so `vi.mock('node:fs/promises')` still intercepts it in tests.
|
|
6
6
|
import { open, stat, basename } from './node-fs.js';
|
|
7
|
-
import { AudioWatermarkDecodeRequestToJSON, AudioWatermarkDecodeResponseFromJSON, ExternalImportCreatedResponseFromJSON, ExternalImportRequestToJSON, LoginUser200ResponseDataFromJSON, AccountLimitsFromJSON, CreditsBalanceResponseFromJSON, CreditsUsageResponseFromJSON, UploadResponseFromJSON, UploadProbeResponseFromJSON, MultipartInitiateResponseFromJSON, MultipartInitiateRequestMetadataHintToJSON, MultipartCompleteResponseFromJSON, MultipartCompleteRequestToJSON, WorkflowCancelResponseFromJSON, WorkflowCreateResponseFromJSON, WorkflowResumeResponseFromJSON, WorkflowStatusResponseFromJSON, WorkflowListResponseFromJSON, WorkflowDownloadResponseFromJSON, MetadataResponseFromJSON, OperationsSchemaResponseFromJSON, RetryResponseFromJSON, WorkflowStatus, AuthErrorResponseFromJSON, AuthErrorType, AuthRejectionEnvelopeFromJSON, AuthRejectionEnvelopeErrorTypeEnum, BalanceExhaustedResponseFromJSON, BalanceExhaustedResponseRequiredActionEnum, FeatureNotAvailableResponseFromJSON, FeatureTierRestrictedResponseFromJSON, TierRestrictionKind, TierRestrictionResponseFromJSON, UserTier, WorkflowExpiredResponseFromJSON, ProbePendingResponseFromJSON, UploadSizeExceedsTierResponseFromJSON, UploadDurationExceedsTierResponseFromJSON, UploadConstraintsAppliedProcessingClassPreAssignmentEnum, UploadThresholdsSingleShotMaxBytesEnum, UploadThresholdsMultipartChunkSizeEnum, UploadThresholdsMultipartConcurrencyDefaultEnum, } from '@giveitsmaller/contracts/openapi';
|
|
8
|
-
import { GislAbortError, GislApiError, GislAuthError, GislAuthRejectionError, GislBalanceExhaustedError, GislError, GislFeatureNotAvailableError, GislFeatureTierRestrictedError, GislMultipartPartCountError, GislMultipartPartError, GislMultipartSessionNotFoundError, GislMultipartSessionOwnershipError, GislMultipartSessionAuthRequiredError, GislTierRestrictedError, GislTimeoutError, GislProbePendingError, GislUploadCapExceededError, GislValidationError, GislWorkflowExpiredError, } from './errors.js';
|
|
7
|
+
import { AudioWatermarkDecodeRequestToJSON, AudioWatermarkDecodeResponseFromJSON, ExternalImportCreatedResponseFromJSON, ExternalImportRequestToJSON, LoginUser200ResponseDataFromJSON, AccountLimitsFromJSON, CreditsBalanceResponseFromJSON, CreditsUsageResponseFromJSON, UploadResponseFromJSON, UploadProbeResponseFromJSON, MultipartInitiateResponseFromJSON, MultipartInitiateRequestMetadataHintToJSON, MultipartCompleteResponseFromJSON, MultipartCompleteRequestToJSON, WorkflowCancelResponseFromJSON, WorkflowCreateResponseFromJSON, WorkflowResumeResponseFromJSON, WorkflowStatusResponseFromJSON, WorkflowListResponseFromJSON, WorkflowDownloadResponseFromJSON, MetadataResponseFromJSON, OperationsSchemaResponseFromJSON, RetryResponseFromJSON, WorkflowStatus, AuthErrorResponseFromJSON, AuthErrorType, AuthRejectionEnvelopeFromJSON, AuthRejectionEnvelopeErrorTypeEnum, BalanceExhaustedResponseFromJSON, BalanceExhaustedResponseRequiredActionEnum, FeatureNotAvailableResponseFromJSON, FeatureTierRestrictedResponseFromJSON, LongFormConcurrencyLimitResponseFromJSON, TierRestrictionKind, TierRestrictionResponseFromJSON, UserTier, WorkflowExpiredResponseFromJSON, ProbePendingResponseFromJSON, UploadSizeExceedsTierResponseFromJSON, UploadDurationExceedsTierResponseFromJSON, UploadConstraintsAppliedProcessingClassPreAssignmentEnum, UploadThresholdsSingleShotMaxBytesEnum, UploadThresholdsMultipartChunkSizeEnum, UploadThresholdsMultipartConcurrencyDefaultEnum, } from '@giveitsmaller/contracts/openapi';
|
|
8
|
+
import { GislAbortError, GislApiError, GislAuthError, GislAuthRejectionError, GislBalanceExhaustedError, GislError, GislFeatureNotAvailableError, GislFeatureTierRestrictedError, GislLongFormConcurrencyError, GislMultipartPartCountError, GislMultipartPartError, GislMultipartSessionNotFoundError, GislMultipartSessionOwnershipError, GislMultipartSessionAuthRequiredError, GislTierRestrictedError, GislTimeoutError, GislProbePendingError, GislUploadCapExceededError, GislValidationError, GislWorkflowExpiredError, } from './errors.js';
|
|
9
|
+
// The `Retry-After` millisecond parser lives in the shared retry-metadata
|
|
10
|
+
// module (extracted to break the client ↔ errors circular import); re-imported
|
|
11
|
+
// here so the retry-loop timing stays byte-identical.
|
|
12
|
+
import { parseRetryAfterMs } from './retry-metadata.js';
|
|
9
13
|
import { parseSseStream } from './sse.js';
|
|
10
14
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
11
15
|
// SDK-internal aliases derived from the contract-pinned UploadThresholds enums
|
|
@@ -55,7 +59,25 @@ const S3_MAX_MULTIPART_PARTS = 10_000;
|
|
|
55
59
|
// exists to prevent). codex review (high).
|
|
56
60
|
const RECOMMENDED_CHUNK_SIZE_MAX_BYTES = 104_857_600; // 100 MiB
|
|
57
61
|
const DEFAULT_POLL_INTERVAL_MS = 2_000;
|
|
58
|
-
const DEFAULT_POLL_TIMEOUT_MS =
|
|
62
|
+
const DEFAULT_POLL_TIMEOUT_MS = 600_000; // 10 min
|
|
63
|
+
/**
|
|
64
|
+
* Re-tag a bare per-request transport {@link GislTimeoutError} with the workflow
|
|
65
|
+
* it was scoped to, so a timed-out workflow read (status / downloads) stays
|
|
66
|
+
* recoverable. No-op for any other error and for a timeout that already carries
|
|
67
|
+
* an id. See `oYumKo6y`. (PHP transport failures surface as `GislNetworkError`,
|
|
68
|
+
* so this enrichment is TS-only.)
|
|
69
|
+
*/
|
|
70
|
+
function withWorkflowIdOnTimeout(err, workflowId) {
|
|
71
|
+
if (!(err instanceof GislTimeoutError) || err.workflowId !== undefined)
|
|
72
|
+
return err;
|
|
73
|
+
const enriched = new GislTimeoutError(err.message, workflowId);
|
|
74
|
+
// Preserve the original throw site + chain the cause — the enriched error is
|
|
75
|
+
// a re-tag, not a new failure, so diagnostics should still point at the
|
|
76
|
+
// transport timeout.
|
|
77
|
+
enriched.stack = err.stack;
|
|
78
|
+
enriched.cause = err;
|
|
79
|
+
return enriched;
|
|
80
|
+
}
|
|
59
81
|
// Anonymous-read capability header. An anonymous (null-owner) workflow create
|
|
60
82
|
// returns a one-time `cap` token (WorkflowCreateResponse.cap); the session-less
|
|
61
83
|
// caller passes it back on status/downloads/events reads via this header so the
|
|
@@ -94,6 +116,18 @@ function headersToRecord(headers) {
|
|
|
94
116
|
});
|
|
95
117
|
return r;
|
|
96
118
|
}
|
|
119
|
+
// Canonical human-readable fallback when a failure envelope carries NO `message`
|
|
120
|
+
// field. Kept IDENTICAL to the PHP SDK (`GislClient::fallbackErrorMessage`) so a
|
|
121
|
+
// message-absent error surfaces byte-identical `.message` text across SDKs
|
|
122
|
+
// (card U7MACpOj). Used by BOTH error-construction paths (`handleResponse` and
|
|
123
|
+
// the `getSchema` raw-response path) so they cannot drift. The machine code
|
|
124
|
+
// stays on `.errorCode` — this string is human DISPLAY text only; never parse
|
|
125
|
+
// it. `error` absent → `unknown_error` (PHP parity). Historically TS leaked the
|
|
126
|
+
// SCREAMING_SNAKE `error` code as `.message`, diverging from PHP's synthetic
|
|
127
|
+
// sentence; this unifies them on the human-shaped form.
|
|
128
|
+
function fallbackErrorMessage(status, errorCode) {
|
|
129
|
+
return `Request failed with status ${status} (${errorCode ?? 'unknown_error'}).`;
|
|
130
|
+
}
|
|
97
131
|
function isValidationDetails(value) {
|
|
98
132
|
return (Array.isArray(value) &&
|
|
99
133
|
value.length > 0 &&
|
|
@@ -109,12 +143,14 @@ function isAbortError(err) {
|
|
|
109
143
|
typeof err === 'object' &&
|
|
110
144
|
err.name === 'AbortError');
|
|
111
145
|
}
|
|
112
|
-
// Retryable S3 PUT response statuses: 429 throttling, 503
|
|
113
|
-
// other 5xx (502/504 are common transients behind
|
|
114
|
-
//
|
|
115
|
-
//
|
|
146
|
+
// Retryable S3 PUT response statuses: 408 request timeout, 429 throttling, 503
|
|
147
|
+
// slow-down, and any other 5xx (502/504 are common transients behind
|
|
148
|
+
// CloudFront/S3). 408 is a transient timeout on the PUT itself, so it is retried
|
|
149
|
+
// (matching the PHP SDK's S3-PUT predicate — qz7MjNTy cross-SDK alignment). Other
|
|
150
|
+
// 4xx (403 signed-URL expiry, 400 SignatureDoesNotMatch, etc.) are configuration
|
|
151
|
+
// / authority issues — retrying just delays the real failure.
|
|
116
152
|
function isRetryableStatus(status) {
|
|
117
|
-
return status === 429 || (status >= 500 && status <= 599);
|
|
153
|
+
return status === 408 || status === 429 || (status >= 500 && status <= 599);
|
|
118
154
|
}
|
|
119
155
|
// fetch surfaces network failures (DNS, TLS, TCP reset, mid-body disconnect)
|
|
120
156
|
// as TypeError. Abort surfaces as a DOMException with name='AbortError', not
|
|
@@ -123,31 +159,6 @@ function isRetryableStatus(status) {
|
|
|
123
159
|
function isRetryableNetworkError(err) {
|
|
124
160
|
return err instanceof TypeError;
|
|
125
161
|
}
|
|
126
|
-
// Parse an HTTP `Retry-After` header into milliseconds. Accepts the two RFC
|
|
127
|
-
// 9110 forms: delta-seconds (e.g. "5") or an HTTP-date. Returns `undefined`
|
|
128
|
-
// for an absent / unparseable / negative value (caller falls back to its own
|
|
129
|
-
// backoff). A past HTTP-date clamps to 0.
|
|
130
|
-
function parseRetryAfterMs(headerValue) {
|
|
131
|
-
if (headerValue === undefined)
|
|
132
|
-
return undefined;
|
|
133
|
-
const trimmed = headerValue.trim();
|
|
134
|
-
if (trimmed === '')
|
|
135
|
-
return undefined;
|
|
136
|
-
let ms;
|
|
137
|
-
if (/^\d+$/.test(trimmed)) {
|
|
138
|
-
ms = Number(trimmed) * 1000;
|
|
139
|
-
}
|
|
140
|
-
else {
|
|
141
|
-
const when = Date.parse(trimmed);
|
|
142
|
-
if (Number.isNaN(when))
|
|
143
|
-
return undefined;
|
|
144
|
-
ms = when - Date.now();
|
|
145
|
-
}
|
|
146
|
-
// A non-positive Retry-After (e.g. "0" or a past HTTP-date) must NOT short-
|
|
147
|
-
// circuit the backoff to zero — treat it as absent so the caller falls back
|
|
148
|
-
// to jitter and the loop can't busy-poll until timeout.
|
|
149
|
-
return ms > 0 ? ms : undefined;
|
|
150
|
-
}
|
|
151
162
|
// Cancellable sleep for poll loops. Resolves after `ms`, or rejects with
|
|
152
163
|
// `GislAbortError` if `signal` aborts. Resolves immediately for ms <= 0.
|
|
153
164
|
function cancellableSleep(ms, signal) {
|
|
@@ -493,18 +504,26 @@ export class GislClient {
|
|
|
493
504
|
messageKey: json.message_key,
|
|
494
505
|
locale: json.locale,
|
|
495
506
|
messageParams: json.message_params,
|
|
507
|
+
// The wire-stable machine code (SCREAMING_SNAKE `error`), surfaced as
|
|
508
|
+
// `error.errorCode` on every dispatched error (PHP parity). Threaded via
|
|
509
|
+
// this single options object → base GislApiError, GislValidationError,
|
|
510
|
+
// and every structured subclass (passed through as `extra`).
|
|
511
|
+
errorCode: typeof json.error === 'string' ? json.error : undefined,
|
|
496
512
|
responseHeaders,
|
|
497
513
|
contentLanguage,
|
|
498
514
|
};
|
|
499
515
|
// Human-readable text comes from `message` (the I26 localised field).
|
|
500
516
|
// `error` is the stable, never-localised SCREAMING_SNAKE machine code —
|
|
501
|
-
// NOT display text
|
|
502
|
-
//
|
|
503
|
-
//
|
|
504
|
-
//
|
|
505
|
-
//
|
|
517
|
+
// NOT display text (surfacing it as `.message` regressed consumers that
|
|
518
|
+
// render the human string, x9Lbf6uy). When `message` is absent (the
|
|
519
|
+
// deployed contract guarantees it on conforming error envelopes), fall
|
|
520
|
+
// back to the canonical synthetic sentence — byte-identical to the PHP
|
|
521
|
+
// SDK — instead of leaking the machine code (card U7MACpOj). The code is
|
|
522
|
+
// still carried on `.errorCode`. Machine dispatch keys off `error_type`
|
|
523
|
+
// (below), unchanged.
|
|
506
524
|
const status = response.status;
|
|
507
|
-
const errorMessage = json.message ??
|
|
525
|
+
const errorMessage = json.message ??
|
|
526
|
+
fallbackErrorMessage(status, typeof json.error === 'string' ? json.error : undefined);
|
|
508
527
|
// Validation-details branch first — preserve existing shape so callers
|
|
509
528
|
// matching on `instanceof GislValidationError` keep working.
|
|
510
529
|
if (isValidationDetails(json.details)) {
|
|
@@ -609,6 +628,15 @@ export class GislClient {
|
|
|
609
628
|
tryThrowCap(UploadDurationExceedsTierResponseFromJSON, 'duration_tier', (p) => isInEnum(p.currentTier, UserTier) &&
|
|
610
629
|
typeof p.maxDurationSeconds === 'number');
|
|
611
630
|
}
|
|
631
|
+
// Long-form concurrency limit (429) — a TIER quota, DISTINCT from a
|
|
632
|
+
// generic infra rate-limit 429. Dispatched on the machine `error` CODE
|
|
633
|
+
// (this envelope carries NO `error_type`); a generic rate-limit 429 has a
|
|
634
|
+
// different/absent code, so it falls through to the base GislApiError
|
|
635
|
+
// where `retryAfterSeconds` applies. The validator re-asserts the code so
|
|
636
|
+
// a malformed envelope falls through rather than mis-typing.
|
|
637
|
+
if (status === 429 && json.error === 'LONG_FORM_CONCURRENCY_LIMIT_EXCEEDED') {
|
|
638
|
+
tryThrowStructured(LongFormConcurrencyLimitResponseFromJSON, GislLongFormConcurrencyError, (p) => p.error === 'LONG_FORM_CONCURRENCY_LIMIT_EXCEEDED');
|
|
639
|
+
}
|
|
612
640
|
// 413 = the absolute across-tier cap. The contract models 413 as a
|
|
613
641
|
// plain `ErrorEnvelope` (no `error_type` discriminator, no typed
|
|
614
642
|
// payload — api.yaml), so dispatch purely on status with no FromJSON
|
|
@@ -1617,10 +1645,15 @@ export class GislClient {
|
|
|
1617
1645
|
* authenticated reads. A wrong/missing cap on a null-owner workflow is a 404.
|
|
1618
1646
|
*/
|
|
1619
1647
|
async getWorkflowStatus(workflowId, opts = {}) {
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1648
|
+
try {
|
|
1649
|
+
return await this.request('GET', `/api/workflows/${encodeURIComponent(workflowId)}/status`, {
|
|
1650
|
+
deserialize: WorkflowStatusResponseFromJSON,
|
|
1651
|
+
headers: workflowCapabilityHeaders(opts.capability),
|
|
1652
|
+
});
|
|
1653
|
+
}
|
|
1654
|
+
catch (err) {
|
|
1655
|
+
throw withWorkflowIdOnTimeout(err, workflowId);
|
|
1656
|
+
}
|
|
1624
1657
|
}
|
|
1625
1658
|
/**
|
|
1626
1659
|
* Poll until the workflow reaches a terminal status.
|
|
@@ -1638,7 +1671,7 @@ export class GislClient {
|
|
|
1638
1671
|
return status;
|
|
1639
1672
|
}
|
|
1640
1673
|
if (Date.now() + intervalMs > deadline) {
|
|
1641
|
-
throw new GislTimeoutError(`Workflow ${workflowId} did not complete within ${timeoutMs}ms
|
|
1674
|
+
throw new GislTimeoutError(`Workflow ${workflowId} did not complete within ${timeoutMs}ms`, workflowId);
|
|
1642
1675
|
}
|
|
1643
1676
|
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
1644
1677
|
}
|
|
@@ -1695,10 +1728,15 @@ export class GislClient {
|
|
|
1695
1728
|
* authenticated reads. A wrong/missing cap on a null-owner workflow is a 404.
|
|
1696
1729
|
*/
|
|
1697
1730
|
async getWorkflowDownloads(workflowId, opts = {}) {
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1731
|
+
try {
|
|
1732
|
+
return await this.request('GET', `/api/workflows/${encodeURIComponent(workflowId)}/downloads`, {
|
|
1733
|
+
deserialize: WorkflowDownloadResponseFromJSON,
|
|
1734
|
+
headers: workflowCapabilityHeaders(opts.capability),
|
|
1735
|
+
});
|
|
1736
|
+
}
|
|
1737
|
+
catch (err) {
|
|
1738
|
+
throw withWorkflowIdOnTimeout(err, workflowId);
|
|
1739
|
+
}
|
|
1702
1740
|
}
|
|
1703
1741
|
/**
|
|
1704
1742
|
* Stream SSE events for a workflow. Returns an async iterable.
|
|
@@ -1747,7 +1785,10 @@ export class GislClient {
|
|
|
1747
1785
|
releaseConsumerSignal();
|
|
1748
1786
|
}
|
|
1749
1787
|
}
|
|
1750
|
-
const inner = parseSseStream(response, {
|
|
1788
|
+
const inner = parseSseStream(response, {
|
|
1789
|
+
signal: controller.signal,
|
|
1790
|
+
...(opts.onParseError !== undefined ? { onParseError: opts.onParseError } : {}),
|
|
1791
|
+
});
|
|
1751
1792
|
let started = false;
|
|
1752
1793
|
let settled = false;
|
|
1753
1794
|
// Idempotent teardown. `abort` only on consumer-driven early
|
|
@@ -1873,21 +1914,31 @@ export class GislClient {
|
|
|
1873
1914
|
return { notModified: true, etag, lastModified };
|
|
1874
1915
|
}
|
|
1875
1916
|
if (!response.ok) {
|
|
1876
|
-
let
|
|
1917
|
+
let errorCode;
|
|
1918
|
+
// Seed with the canonical synthetic sentence (unknown_error) so the
|
|
1919
|
+
// message-absent JSON branch below reuses the SAME helper as
|
|
1920
|
+
// handleResponse + PHP — that message-absent JSON envelope is the
|
|
1921
|
+
// U7MACpOj parity target. A non-JSON body is a rare edge that keeps
|
|
1922
|
+
// this seed (it does NOT claim byte-parity with PHP's non-JSON path,
|
|
1923
|
+
// which throws a distinct GislError).
|
|
1924
|
+
let errorMessage = fallbackErrorMessage(response.status, undefined);
|
|
1877
1925
|
try {
|
|
1878
1926
|
const errJson = (await response.json());
|
|
1879
|
-
//
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1927
|
+
// Surface the machine code as errorCode too (parity with handleResponse
|
|
1928
|
+
// + PHP), even when `message` supplied the human text.
|
|
1929
|
+
if (typeof errJson.error === 'string')
|
|
1930
|
+
errorCode = errJson.error;
|
|
1931
|
+
// Prefer the human `message`; else the canonical synthetic sentence —
|
|
1932
|
+
// NOT the raw machine code (x9Lbf6uy / U7MACpOj).
|
|
1933
|
+
errorMessage = errJson.message ?? fallbackErrorMessage(response.status, errorCode);
|
|
1884
1934
|
}
|
|
1885
1935
|
catch {
|
|
1886
|
-
// Non-JSON body — keep generic message.
|
|
1936
|
+
// Non-JSON body — keep the generic synthetic message, no machine code.
|
|
1887
1937
|
}
|
|
1888
1938
|
// This throw is OUTSIDE handleResponse (rawResponse:true / 304 path), so
|
|
1889
1939
|
// build the response-header surface from the in-scope `response` here.
|
|
1890
1940
|
throw new GislApiError(response.status, errorMessage, path, undefined, {
|
|
1941
|
+
errorCode,
|
|
1891
1942
|
responseHeaders: headersToRecord(response.headers),
|
|
1892
1943
|
contentLanguage: response.headers.get('content-language') ?? undefined,
|
|
1893
1944
|
});
|
|
@@ -1954,15 +2005,18 @@ export class GislClient {
|
|
|
1954
2005
|
* via that cookie when the client is configured with
|
|
1955
2006
|
* `useSessionCookie: true`.
|
|
1956
2007
|
*
|
|
1957
|
-
* Failure modes per ticket FX6mbTJD
|
|
1958
|
-
*
|
|
1959
|
-
*
|
|
1960
|
-
*
|
|
1961
|
-
* `
|
|
1962
|
-
* `account_deletion_expired`) → `GislAuthError`.
|
|
2008
|
+
* Failure modes per ticket FX6mbTJD (login narrowed at contracts
|
|
2009
|
+
* v2.166.0 authsec — no 403 account-state branch on login):
|
|
2010
|
+
* - **401** `invalid_credentials` (wrong password, unverified, OR
|
|
2011
|
+
* unknown account — all collapsed for anti-enumeration) →
|
|
2012
|
+
* `GislAuthError`.
|
|
1963
2013
|
* - **429** infrastructure rate-limit → `GislApiError` with
|
|
1964
2014
|
* the `Retry-After` header echoed on the response.
|
|
1965
2015
|
*
|
|
2016
|
+
* The account-status error types (`account_locked` / `account_disabled`
|
|
2017
|
+
* / `account_deleted` / `account_deletion_expired`) still exist but are
|
|
2018
|
+
* emitted on the API-key path + live-session enforcement, not on login.
|
|
2019
|
+
*
|
|
1966
2020
|
* Node session persistence (cookie-jar across processes) is out of
|
|
1967
2021
|
* scope — this method only touches the request side.
|
|
1968
2022
|
*/
|
|
@@ -87,4 +87,34 @@ export declare function resolveOutputRoute(inputToken: string, outputFormat: str
|
|
|
87
87
|
* option / value / group is unknown (no gate).
|
|
88
88
|
*/
|
|
89
89
|
export declare function isPlannedValue(inputToken: string, optionKey: string, value: unknown): boolean;
|
|
90
|
+
/**
|
|
91
|
+
* Compress-route enum members per image mime-group, mirroring the shipped
|
|
92
|
+
* `availability/availability.json` `operations.compress.mime_groups.<group>.
|
|
93
|
+
* options.<opt>.values`. Kept as a hand table (NOT a runtime read of the ~238KB
|
|
94
|
+
* availability sidecar) so the enum-membership gate stays browser-safe, exactly
|
|
95
|
+
* like {@link IMAGE_OUTPUT_ROUTES} — and, crucially, so the gate has NO
|
|
96
|
+
* dependency on a contracts version that carries the enum in a compact form (a
|
|
97
|
+
* generated-metadata `values` field would fail open on an older published
|
|
98
|
+
* `@giveitsmaller/contracts`). PINNED to `availability.json` by
|
|
99
|
+
* `output-route-conformance.test.ts`; a contract regen that adds/changes an
|
|
100
|
+
* enum member fails there. Mirrored by PHP `ImageOutputRoutes::COMPRESS_OPTION_VALUES`.
|
|
101
|
+
*
|
|
102
|
+
* `image_svg`/`image_avif` carry the NARROW `metadata: ['strip','all']` (no
|
|
103
|
+
* `keep`) — the reason a value gate that consulted only the generic `image`
|
|
104
|
+
* group (`['strip','keep','all']`) let `metadata: 'keep'` reach a server 422 on
|
|
105
|
+
* those bases (rtkzl9gr). `output_format` is listed for a faithful projection
|
|
106
|
+
* mirror but is never gated here (the Output lowering owns it positionally).
|
|
107
|
+
*/
|
|
108
|
+
export declare const COMPRESS_OPTION_VALUES: Readonly<Record<string, Readonly<Record<string, readonly string[]>>>>;
|
|
109
|
+
/**
|
|
110
|
+
* Whether a VALUE lies OUTSIDE the option's compress-route enum for the given
|
|
111
|
+
* input format — the pre-upload enum-membership gate (rtkzl9gr). Reads the hand
|
|
112
|
+
* {@link COMPRESS_OPTION_VALUES} table. Returns false when the option is not an
|
|
113
|
+
* enum on this group (no entry), so a non-enum option (e.g. integer `quality`)
|
|
114
|
+
* is never gated. Meaningful only on the same_format (compress) route, where
|
|
115
|
+
* the compress option enums definitionally apply. Membership is STRICT: a value
|
|
116
|
+
* whose type differs from the string enum members (e.g. numeric `420`) is
|
|
117
|
+
* treated as unknown rather than coerced to a match.
|
|
118
|
+
*/
|
|
119
|
+
export declare function isUnknownEnumValue(inputToken: string, optionKey: string, value: unknown): boolean;
|
|
90
120
|
export {};
|
|
@@ -29,8 +29,18 @@
|
|
|
29
29
|
import { compressMetadata } from '@giveitsmaller/contracts/operations';
|
|
30
30
|
/** The resize option keys — input-keyed, raster-only (see module doc). */
|
|
31
31
|
export const RESIZE_KEYS = ['width', 'height', 'fit'];
|
|
32
|
-
/**
|
|
33
|
-
|
|
32
|
+
/**
|
|
33
|
+
* Options whose availability follows the INPUT format's raster capability, not
|
|
34
|
+
* the output format — resize (`width`/`height`/`fit`) plus `auto_orient`. The
|
|
35
|
+
* projection lists them on every `format_change` cell (keyed by OUTPUT), so on
|
|
36
|
+
* a format change they must be re-gated against the INPUT's `same_format` cell:
|
|
37
|
+
* a raster input carries them, an SVG (vector) input does not. Before rtkzl9gr
|
|
38
|
+
* only the resize keys were input-gated, so `auto_orient` leaked onto the
|
|
39
|
+
* `svg → raster` route and was rejected server-side.
|
|
40
|
+
*/
|
|
41
|
+
const INPUT_GATED_KEYS = [...RESIZE_KEYS, 'auto_orient'];
|
|
42
|
+
/** Set form of {@link INPUT_GATED_KEYS} for `string`-keyed membership tests. */
|
|
43
|
+
const INPUT_GATED_KEY_SET = new Set(INPUT_GATED_KEYS);
|
|
34
44
|
/** Image area cap shared by every resizable route (projection `max_output_pixels`). */
|
|
35
45
|
export const MAX_OUTPUT_PIXELS = 16_000_000;
|
|
36
46
|
/**
|
|
@@ -117,21 +127,22 @@ export function resolveOutputRoute(inputToken, outputFormat) {
|
|
|
117
127
|
const cell = IMAGE_OUTPUT_ROUTES.format_change[outToken];
|
|
118
128
|
if (cell === undefined)
|
|
119
129
|
return undefined;
|
|
120
|
-
// Resize
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
|
|
130
|
+
// Resize + auto_orient are INPUT-gated (see {@link INPUT_GATED_KEYS}). Since
|
|
131
|
+
// v2.103.0 convert is the resize engine, so the projection lists width/height/
|
|
132
|
+
// fit AND auto_orient on EVERY format_change cell — but an SVG INPUT cannot be
|
|
133
|
+
// raster-resized or auto-oriented (the convert worker rejects it). So strip
|
|
134
|
+
// the cell's input-gated keys and re-add only those the INPUT's same_format
|
|
135
|
+
// cell honors: raster inputs carry them, svg does not. The transcoder options
|
|
136
|
+
// (output_format/quality/background/color_profile) ride the cell directly.
|
|
137
|
+
const transcoderHonored = cell.honored.filter((k) => !INPUT_GATED_KEY_SET.has(k));
|
|
127
138
|
const inCell = IMAGE_OUTPUT_ROUTES.same_format[inputToken];
|
|
128
|
-
const
|
|
139
|
+
const inputGated = inCell ? INPUT_GATED_KEYS.filter((k) => inCell.honored.includes(k)) : [];
|
|
129
140
|
return {
|
|
130
141
|
route: 'format_change',
|
|
131
142
|
sourceOp: 'convert',
|
|
132
143
|
outputFormatWire: outToken,
|
|
133
144
|
inputToken,
|
|
134
|
-
honored: new Set([...transcoderHonored, ...
|
|
145
|
+
honored: new Set([...transcoderHonored, ...inputGated]),
|
|
135
146
|
planned: new Set(cell.planned),
|
|
136
147
|
};
|
|
137
148
|
}
|
|
@@ -161,3 +172,63 @@ export function isPlannedValue(inputToken, optionKey, value) {
|
|
|
161
172
|
const entry = opt.per_value_availability[String(value)];
|
|
162
173
|
return entry?.availability === 'planned';
|
|
163
174
|
}
|
|
175
|
+
/**
|
|
176
|
+
* Compress-route enum members per image mime-group, mirroring the shipped
|
|
177
|
+
* `availability/availability.json` `operations.compress.mime_groups.<group>.
|
|
178
|
+
* options.<opt>.values`. Kept as a hand table (NOT a runtime read of the ~238KB
|
|
179
|
+
* availability sidecar) so the enum-membership gate stays browser-safe, exactly
|
|
180
|
+
* like {@link IMAGE_OUTPUT_ROUTES} — and, crucially, so the gate has NO
|
|
181
|
+
* dependency on a contracts version that carries the enum in a compact form (a
|
|
182
|
+
* generated-metadata `values` field would fail open on an older published
|
|
183
|
+
* `@giveitsmaller/contracts`). PINNED to `availability.json` by
|
|
184
|
+
* `output-route-conformance.test.ts`; a contract regen that adds/changes an
|
|
185
|
+
* enum member fails there. Mirrored by PHP `ImageOutputRoutes::COMPRESS_OPTION_VALUES`.
|
|
186
|
+
*
|
|
187
|
+
* `image_svg`/`image_avif` carry the NARROW `metadata: ['strip','all']` (no
|
|
188
|
+
* `keep`) — the reason a value gate that consulted only the generic `image`
|
|
189
|
+
* group (`['strip','keep','all']`) let `metadata: 'keep'` reach a server 422 on
|
|
190
|
+
* those bases (rtkzl9gr). `output_format` is listed for a faithful projection
|
|
191
|
+
* mirror but is never gated here (the Output lowering owns it positionally).
|
|
192
|
+
*/
|
|
193
|
+
export const COMPRESS_OPTION_VALUES = {
|
|
194
|
+
image: { color_profile: ['keep', 'srgb', 'strip'], fit: ['max', 'crop', 'scale'], metadata: ['strip', 'keep', 'all'], output_format: ['original', 'webp', 'auto', 'smallest'] },
|
|
195
|
+
image_jpeg: { chroma_subsampling: ['420', '422', '444'], color_profile: ['keep', 'srgb', 'strip'], encoding_mode: ['quality', 'target_size', 'auto_quality'], fit: ['max', 'crop', 'scale'], metadata: ['strip', 'keep', 'all'], output_format: ['original', 'webp', 'auto', 'smallest'], quality_preset: ['best', 'good', 'fair', 'low'] },
|
|
196
|
+
image_png: { color_profile: ['keep', 'srgb', 'strip'], fit: ['max', 'crop', 'scale'], metadata: ['strip', 'keep', 'all'], output_format: ['original', 'webp', 'auto', 'smallest'] },
|
|
197
|
+
image_avif: { color_profile: ['keep', 'srgb', 'strip'], encoding_mode: ['quality', 'target_size', 'auto_quality'], fit: ['max', 'crop', 'scale'], metadata: ['strip', 'all'], output_format: ['original', 'webp', 'auto', 'smallest'], quality_preset: ['best', 'good', 'fair', 'low'] },
|
|
198
|
+
image_svg: { metadata: ['strip', 'all'], output_format: ['original', 'webp', 'auto', 'smallest'] },
|
|
199
|
+
image_webp: { color_profile: ['keep', 'srgb', 'strip'], encoding_mode: ['quality', 'target_size', 'auto_quality'], fit: ['max', 'crop', 'scale'], metadata: ['strip', 'keep', 'all'], output_format: ['original', 'webp', 'auto', 'smallest'], quality_preset: ['best', 'good', 'fair', 'low'] },
|
|
200
|
+
};
|
|
201
|
+
/**
|
|
202
|
+
* The compress mime-group whose enum members are authoritative for an image
|
|
203
|
+
* token's SAME_FORMAT route — the exact `image_<token>` group when
|
|
204
|
+
* {@link COMPRESS_OPTION_VALUES} carries one, else the generic `image` group
|
|
205
|
+
* (gif/tiff).
|
|
206
|
+
*
|
|
207
|
+
* Deliberately DISTINCT from {@link compressGroupForToken} (which the planned
|
|
208
|
+
* gate uses). The planned gate routes webp/gif/svg/tiff through the generic
|
|
209
|
+
* `image` group, where cross-format `planned` markers live (e.g. `srgb`).
|
|
210
|
+
* Enum MEMBERSHIP is the opposite: it needs the format-specific enum, because
|
|
211
|
+
* `image_svg`'s `metadata` enum is the narrow `[strip, all]` while the generic
|
|
212
|
+
* group's is `[strip, keep, all]` — so only the specific group rejects
|
|
213
|
+
* `metadata: 'keep'` on SVG (and AVIF, which already maps specifically).
|
|
214
|
+
*/
|
|
215
|
+
function enumGroupForToken(token) {
|
|
216
|
+
const specific = `image_${token}`;
|
|
217
|
+
return COMPRESS_OPTION_VALUES[specific] !== undefined ? specific : 'image';
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Whether a VALUE lies OUTSIDE the option's compress-route enum for the given
|
|
221
|
+
* input format — the pre-upload enum-membership gate (rtkzl9gr). Reads the hand
|
|
222
|
+
* {@link COMPRESS_OPTION_VALUES} table. Returns false when the option is not an
|
|
223
|
+
* enum on this group (no entry), so a non-enum option (e.g. integer `quality`)
|
|
224
|
+
* is never gated. Meaningful only on the same_format (compress) route, where
|
|
225
|
+
* the compress option enums definitionally apply. Membership is STRICT: a value
|
|
226
|
+
* whose type differs from the string enum members (e.g. numeric `420`) is
|
|
227
|
+
* treated as unknown rather than coerced to a match.
|
|
228
|
+
*/
|
|
229
|
+
export function isUnknownEnumValue(inputToken, optionKey, value) {
|
|
230
|
+
const members = COMPRESS_OPTION_VALUES[enumGroupForToken(inputToken)]?.[optionKey];
|
|
231
|
+
if (members === undefined)
|
|
232
|
+
return false;
|
|
233
|
+
return !(typeof value === 'string' && members.includes(value));
|
|
234
|
+
}
|
|
@@ -74,6 +74,12 @@ export interface ThumbnailOptions {
|
|
|
74
74
|
/** 1-based page index for document input. */
|
|
75
75
|
page?: number;
|
|
76
76
|
}
|
|
77
|
+
export interface TransformOptions {
|
|
78
|
+
/** Clockwise rotation in degrees. document_pdf honors `rotate` only. */
|
|
79
|
+
rotate?: 0 | 90 | 180 | 270;
|
|
80
|
+
/** Mirror axis (applied after `rotate`). Not honored on document_pdf input. */
|
|
81
|
+
flip?: 'none' | 'horizontal' | 'vertical' | 'both';
|
|
82
|
+
}
|
|
77
83
|
export interface TextWatermarkOptions {
|
|
78
84
|
/** Font size in pixels (8-512). */
|
|
79
85
|
font_size?: number;
|
|
@@ -96,6 +102,24 @@ export interface TextWatermarkOptions {
|
|
|
96
102
|
/** Overlay opacity (0-1). */
|
|
97
103
|
opacity?: number;
|
|
98
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* One entry in the multi-overlay stack (contract `overlays[]` items, v2.152.0).
|
|
107
|
+
* Index-aligned to the overlay-role sources — `overlays[i]` places overlay
|
|
108
|
+
* source `i` — and mirrors the flat single-overlay option shape. Matches the
|
|
109
|
+
* generated `ImageWatermarkImageOverlaysItem`.
|
|
110
|
+
*/
|
|
111
|
+
export interface WatermarkOverlay {
|
|
112
|
+
/** 9-grid anchor position for this overlay. */
|
|
113
|
+
anchor?: WatermarkAnchor;
|
|
114
|
+
/** Horizontal offset from the anchor (e.g. '40px' or '5%'). */
|
|
115
|
+
margin_x?: string;
|
|
116
|
+
/** Vertical offset from the anchor. */
|
|
117
|
+
margin_y?: string;
|
|
118
|
+
/** Overlay opacity (0-1). */
|
|
119
|
+
opacity?: number;
|
|
120
|
+
/** Overlay width (e.g. '120px' or '20%'). */
|
|
121
|
+
overlay_width?: string;
|
|
122
|
+
}
|
|
99
123
|
export interface WatermarkOptions {
|
|
100
124
|
/** 9-grid anchor position. */
|
|
101
125
|
anchor?: WatermarkAnchor;
|
|
@@ -107,6 +131,14 @@ export interface WatermarkOptions {
|
|
|
107
131
|
opacity?: number;
|
|
108
132
|
/** Overlay width (e.g. '120px' or '20%'). */
|
|
109
133
|
overlay_width?: string;
|
|
134
|
+
/**
|
|
135
|
+
* Per-overlay placement for the multi-overlay stack (contract `overlays[]`,
|
|
136
|
+
* v2.152.0) — one entry per overlay source, index-aligned; stacks up to 8
|
|
137
|
+
* overlays on one base image (z-order = array index). MUTUALLY EXCLUSIVE with
|
|
138
|
+
* the flat single-overlay options above; the server rejects mixing the two as
|
|
139
|
+
* `invalid_options`. image_watermark jpeg/png/webp bases only.
|
|
140
|
+
*/
|
|
141
|
+
overlays?: WatermarkOverlay[];
|
|
110
142
|
}
|
|
111
143
|
/** Resize mode (contract `fit` enum, v2.97.0). */
|
|
112
144
|
export type OutputFit = 'max' | 'crop' | 'scale';
|
|
@@ -182,7 +214,8 @@ export interface OutputOptions {
|
|
|
182
214
|
export declare const VERB_OPTION_KEYS: {
|
|
183
215
|
readonly convert: readonly ["quality", "background", "crf", "trim_start", "trim_end", "fps", "width", "height", "fit", "metadata", "color_profile", "auto_orient", "max_colors", "loop", "dither", "bitrate", "pages", "dpi"];
|
|
184
216
|
readonly thumbnail: readonly ["width", "height", "fit", "format", "quality", "background", "timestamp", "source", "page"];
|
|
217
|
+
readonly transform: readonly ["rotate", "flip"];
|
|
185
218
|
readonly textWatermark: readonly ["font_size", "color", "font_family", "rotation", "watermark_mode", "tile_spacing", "anchor", "margin_x", "margin_y", "opacity"];
|
|
186
|
-
readonly watermark: readonly ["anchor", "margin_x", "margin_y", "opacity", "overlay_width"];
|
|
219
|
+
readonly watermark: readonly ["anchor", "margin_x", "margin_y", "opacity", "overlay_width", "overlays"];
|
|
187
220
|
readonly output: readonly ["quality", "quality_preset", "encoding_mode", "target_size_bytes", "chroma_subsampling", "width", "height", "fit", "background", "progressive", "optimization_level", "avif_speed", "metadata", "color_profile", "auto_orient", "lossless"];
|
|
188
221
|
};
|
|
@@ -21,12 +21,13 @@ const CONVERT_OPTION_KEYS = [
|
|
|
21
21
|
const THUMBNAIL_OPTION_KEYS = [
|
|
22
22
|
'width', 'height', 'fit', 'format', 'quality', 'background', 'timestamp', 'source', 'page',
|
|
23
23
|
];
|
|
24
|
+
const TRANSFORM_OPTION_KEYS = ['rotate', 'flip'];
|
|
24
25
|
const TEXT_WATERMARK_OPTION_KEYS = [
|
|
25
26
|
'font_size', 'color', 'font_family', 'rotation', 'watermark_mode',
|
|
26
27
|
'tile_spacing', 'anchor', 'margin_x', 'margin_y', 'opacity',
|
|
27
28
|
];
|
|
28
29
|
const WATERMARK_OPTION_KEYS = [
|
|
29
|
-
'anchor', 'margin_x', 'margin_y', 'opacity', 'overlay_width',
|
|
30
|
+
'anchor', 'margin_x', 'margin_y', 'opacity', 'overlay_width', 'overlays',
|
|
30
31
|
];
|
|
31
32
|
const OUTPUT_OPTION_KEYS = [
|
|
32
33
|
'quality', 'quality_preset', 'encoding_mode', 'target_size_bytes', 'chroma_subsampling', 'width', 'height', 'fit',
|
|
@@ -35,12 +36,14 @@ const OUTPUT_OPTION_KEYS = [
|
|
|
35
36
|
];
|
|
36
37
|
const _convertKeysMatch = true;
|
|
37
38
|
const _thumbnailKeysMatch = true;
|
|
39
|
+
const _transformKeysMatch = true;
|
|
38
40
|
const _textWatermarkKeysMatch = true;
|
|
39
41
|
const _watermarkKeysMatch = true;
|
|
40
42
|
const _outputKeysMatch = true;
|
|
41
43
|
// Reference the assertions so `noUnusedLocals` doesn't strip them.
|
|
42
44
|
void _convertKeysMatch;
|
|
43
45
|
void _thumbnailKeysMatch;
|
|
46
|
+
void _transformKeysMatch;
|
|
44
47
|
void _textWatermarkKeysMatch;
|
|
45
48
|
void _watermarkKeysMatch;
|
|
46
49
|
void _outputKeysMatch;
|
|
@@ -52,6 +55,7 @@ void _outputKeysMatch;
|
|
|
52
55
|
export const VERB_OPTION_KEYS = {
|
|
53
56
|
convert: CONVERT_OPTION_KEYS,
|
|
54
57
|
thumbnail: THUMBNAIL_OPTION_KEYS,
|
|
58
|
+
transform: TRANSFORM_OPTION_KEYS,
|
|
55
59
|
textWatermark: TEXT_WATERMARK_OPTION_KEYS,
|
|
56
60
|
watermark: WATERMARK_OPTION_KEYS,
|
|
57
61
|
output: OUTPUT_OPTION_KEYS,
|
|
@@ -25,7 +25,7 @@ import { type OperationMetadata } from '@giveitsmaller/contracts/operations';
|
|
|
25
25
|
*/
|
|
26
26
|
export declare function operationOptionKeys(metadata: OperationMetadata): ReadonlySet<string>;
|
|
27
27
|
/** The ergonomic verbs whose option bags this module key-validates. */
|
|
28
|
-
export type ValidatedVerb = 'convert' | 'thumbnail' | 'textWatermark' | 'watermark' | 'output';
|
|
28
|
+
export type ValidatedVerb = 'convert' | 'thumbnail' | 'transform' | 'textWatermark' | 'watermark' | 'output';
|
|
29
29
|
/** Accessor for the conformance guard (pins these sets to the contract metadata). */
|
|
30
30
|
export declare function allowedKeysFor(verb: ValidatedVerb): ReadonlySet<string>;
|
|
31
31
|
/**
|
|
@@ -39,6 +39,24 @@ export declare function allowedKeysFor(verb: ValidatedVerb): ReadonlySet<string>
|
|
|
39
39
|
* option set.
|
|
40
40
|
*/
|
|
41
41
|
export declare function validateVerbOptions(verb: ValidatedVerb, options: object | null | undefined): void;
|
|
42
|
+
/**
|
|
43
|
+
* Validate the option bag for the SINGLE-OP builder `gisl().convert(input, options)`
|
|
44
|
+
* (ExVcchMz). DISTINCT from `validateVerbOptions('convert', ...)`, which is for the
|
|
45
|
+
* file-first `Recipe.convert(format, options)` where `output_format` is set by the
|
|
46
|
+
* positional `format` arg and is therefore positional-owned (rejected in the bag).
|
|
47
|
+
* The single-op builder has NO positional format — its target is carried in the bag
|
|
48
|
+
* as the wire key `output_format` — so this guard ALLOWS `output_format` (and only
|
|
49
|
+
* that; the SDK alias `format` is NOT accepted, the single-op bag lowers verbatim to
|
|
50
|
+
* the wire) while still rejecting any other unknown key, AND requires `output_format`
|
|
51
|
+
* to be present (a convert with no target is a guaranteed server 422). `format`, the
|
|
52
|
+
* SDK alias, is intentionally excluded so a caller using it gets a clear unknown-key
|
|
53
|
+
* error rather than a silent wire `format` the server 422s.
|
|
54
|
+
*
|
|
55
|
+
* @throws {GislConfigError} reason `unknown_field` for a key outside the convert
|
|
56
|
+
* contract set ∪ {output_format}; reason `missing_required_field` when
|
|
57
|
+
* `output_format` is absent/nullish.
|
|
58
|
+
*/
|
|
59
|
+
export declare function validateSingleOpConvertOptions(options: object | null | undefined): void;
|
|
42
60
|
/**
|
|
43
61
|
* Assert thumbnail `width` AND `height` are both present and non-nullish (the
|
|
44
62
|
* contract marks both `required` for image/video/document). The typed signature
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { convertMetadata, thumbnailMetadata, textWatermarkMetadata, imageWatermarkMetadata, videoWatermarkMetadata, } from '@giveitsmaller/contracts/operations';
|
|
1
|
+
import { convertMetadata, thumbnailMetadata, transformMetadata, textWatermarkMetadata, imageWatermarkMetadata, videoWatermarkMetadata, } from '@giveitsmaller/contracts/operations';
|
|
2
2
|
import { GislConfigError } from '../errors.js';
|
|
3
3
|
import { VERB_OPTION_KEYS } from './option_types.js';
|
|
4
4
|
/**
|
|
@@ -51,6 +51,9 @@ function union(...sets) {
|
|
|
51
51
|
const ALLOWED_KEYS = {
|
|
52
52
|
convert: operationOptionKeys(convertMetadata),
|
|
53
53
|
thumbnail: operationOptionKeys(thumbnailMetadata),
|
|
54
|
+
// transform is a passthrough verb (rotate/flip). The generic allowed set is
|
|
55
|
+
// the op-wide union {rotate, flip}; `flip`-on-PDF is narrowed server-side.
|
|
56
|
+
transform: operationOptionKeys(transformMetadata),
|
|
54
57
|
textWatermark: operationOptionKeys(textWatermarkMetadata),
|
|
55
58
|
watermark: union(operationOptionKeys(imageWatermarkMetadata), operationOptionKeys(videoWatermarkMetadata)),
|
|
56
59
|
// `output` is the image Output facade — its allowed keys are the UNION of every
|
|
@@ -115,6 +118,36 @@ export function validateVerbOptions(verb, options) {
|
|
|
115
118
|
}
|
|
116
119
|
}
|
|
117
120
|
}
|
|
121
|
+
/**
|
|
122
|
+
* Validate the option bag for the SINGLE-OP builder `gisl().convert(input, options)`
|
|
123
|
+
* (ExVcchMz). DISTINCT from `validateVerbOptions('convert', ...)`, which is for the
|
|
124
|
+
* file-first `Recipe.convert(format, options)` where `output_format` is set by the
|
|
125
|
+
* positional `format` arg and is therefore positional-owned (rejected in the bag).
|
|
126
|
+
* The single-op builder has NO positional format — its target is carried in the bag
|
|
127
|
+
* as the wire key `output_format` — so this guard ALLOWS `output_format` (and only
|
|
128
|
+
* that; the SDK alias `format` is NOT accepted, the single-op bag lowers verbatim to
|
|
129
|
+
* the wire) while still rejecting any other unknown key, AND requires `output_format`
|
|
130
|
+
* to be present (a convert with no target is a guaranteed server 422). `format`, the
|
|
131
|
+
* SDK alias, is intentionally excluded so a caller using it gets a clear unknown-key
|
|
132
|
+
* error rather than a silent wire `format` the server 422s.
|
|
133
|
+
*
|
|
134
|
+
* @throws {GislConfigError} reason `unknown_field` for a key outside the convert
|
|
135
|
+
* contract set ∪ {output_format}; reason `missing_required_field` when
|
|
136
|
+
* `output_format` is absent/nullish.
|
|
137
|
+
*/
|
|
138
|
+
export function validateSingleOpConvertOptions(options) {
|
|
139
|
+
const o = (options ?? {});
|
|
140
|
+
const allowed = new Set([...ALLOWED_KEYS.convert, 'output_format']);
|
|
141
|
+
for (const key of Object.keys(o)) {
|
|
142
|
+
if (!allowed.has(key)) {
|
|
143
|
+
throw new GislConfigError(`convert: unknown option '${key}'. Valid options: ${[...allowed].sort().join(', ')}.`, { reason: 'unknown_field', conflictingFields: [key] });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (o.output_format === undefined || o.output_format === null) {
|
|
147
|
+
throw new GislConfigError(`convert requires 'output_format' (the target format) in the options bag; ` +
|
|
148
|
+
`e.g. gisl().convert(input, { output_format: 'webp' }).`, { reason: 'missing_required_field', conflictingFields: ['output_format'] });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
118
151
|
/**
|
|
119
152
|
* Assert thumbnail `width` AND `height` are both present and non-nullish (the
|
|
120
153
|
* contract marks both `required` for image/video/document). The typed signature
|