eip-cloud-services 1.3.0 → 1.5.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/CHANGELOG.md +5 -0
- package/README.md +48 -5
- package/index.js +1 -0
- package/package.json +1 -1
- package/src/cdn.js +37 -90
- package/src/ondemand.js +339 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
|
|
4
4
|
All notable changes to this project will be documented in this file.
|
|
5
5
|
|
|
6
|
+
## Unreleased
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
- Added staging-safe OnDemand Content Ingest, Content Discovery, and Azure Blob upload clients with injectable fetch, timeouts, production-host guards, and signed-query redaction.
|
|
10
|
+
|
|
6
11
|
## [1.2.5] - 2026-03-02
|
|
7
12
|
|
|
8
13
|
### Fixed
|
package/README.md
CHANGED
|
@@ -82,6 +82,51 @@ module.exports = {
|
|
|
82
82
|
5. [AWS Lambda Module](#aws-lambda-module)
|
|
83
83
|
6. [AWS SQS Module](#aws-sqs-module)
|
|
84
84
|
7. [AWS Secrets Manager Module](#aws-secrets-manager-module)
|
|
85
|
+
8. [OnDemand Module](#ondemand-module)
|
|
86
|
+
|
|
87
|
+
# OnDemand Module
|
|
88
|
+
|
|
89
|
+
## Overview
|
|
90
|
+
|
|
91
|
+
The OnDemand module provides dependency-free clients for Axel Springer's Content Ingest and Content Discovery APIs, plus streaming uploads to their Azure Blob Storage target. Staging endpoints are the defaults. Production Content Discovery/Ingest hosts are rejected unless a future caller explicitly supplies `allowProduction: true`.
|
|
92
|
+
|
|
93
|
+
Secrets are caller-provided and are never stored by this package. Provider errors and returned metadata are sanitized so signed Azure query parameters are not included in thrown errors.
|
|
94
|
+
|
|
95
|
+
## Usage
|
|
96
|
+
|
|
97
|
+
```javascript
|
|
98
|
+
const { ondemand } = require ( 'eip-cloud-services' );
|
|
99
|
+
|
|
100
|
+
const ingest = ondemand.createIngestClient ( {
|
|
101
|
+
apiKey: process.env.ONDEMAND_INGEST_API_KEY
|
|
102
|
+
} );
|
|
103
|
+
|
|
104
|
+
const discovery = ondemand.createDiscoveryClient ( {
|
|
105
|
+
apiKey: process.env.ONDEMAND_DISCOVERY_API_KEY
|
|
106
|
+
} );
|
|
107
|
+
|
|
108
|
+
const assets = await discovery.search ( {
|
|
109
|
+
q: 'climate change',
|
|
110
|
+
assetType: 'video',
|
|
111
|
+
suppliers: [ 'https://cv.content-discovery.video/party/telegraph' ]
|
|
112
|
+
} );
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Available Ingest operations are `createAsset`, `getAsset`, `updateAsset`, `startIngest`, `getIngest`, `createRendition`, and `updateRendition`. Discovery operations are `getVersion`, `getAsset`, `getAssets`, `search`, `searchByExpression`, and `getMasterDownload`.
|
|
116
|
+
|
|
117
|
+
Use `uploadAzureBlob` with a clean blob URL and a separate SAS token. Pass the clean URL—not the signed upload URL—to `startIngest`:
|
|
118
|
+
|
|
119
|
+
```javascript
|
|
120
|
+
await ondemand.uploadAzureBlob ( {
|
|
121
|
+
blobUrl: cleanBlobUrl,
|
|
122
|
+
sasToken: process.env.ONDEMAND_AZURE_SAS_TOKEN,
|
|
123
|
+
body: videoStream,
|
|
124
|
+
contentLength: videoSize,
|
|
125
|
+
contentType: 'video/mp4'
|
|
126
|
+
} );
|
|
127
|
+
|
|
128
|
+
await ingest.startIngest ( assetId, { url: cleanBlobUrl } );
|
|
129
|
+
```
|
|
85
130
|
|
|
86
131
|
# AWS Secrets Manager Module
|
|
87
132
|
|
|
@@ -273,7 +318,7 @@ The module is designed to handle errors gracefully, providing clear error messag
|
|
|
273
318
|
|
|
274
319
|
## Overview
|
|
275
320
|
|
|
276
|
-
This module provides
|
|
321
|
+
This module provides a shared client for CDN invalidation requests. CDN invalidation requests are sent through the shared Tools API invalidate endpoint, so callers use one centralized route while the backend implementation remains independently updateable inside the invalidation service.
|
|
277
322
|
|
|
278
323
|
## Installation
|
|
279
324
|
|
|
@@ -287,7 +332,7 @@ const cdn = require('eip-cloud-services/cdn');
|
|
|
287
332
|
|
|
288
333
|
### Create a CDN Invalidation
|
|
289
334
|
|
|
290
|
-
To invalidate cached content in a CDN, use the `createInvalidation` method. This
|
|
335
|
+
To invalidate cached content in a CDN, use the `createInvalidation` method. This sends the request to the shared Tools API invalidate endpoint, which executes the configured provider-specific invalidation in `eip-cdn-invalidator`.
|
|
291
336
|
|
|
292
337
|
#### Invalidate in Amazon CloudFront
|
|
293
338
|
|
|
@@ -317,11 +362,9 @@ The module is equipped to handle errors gracefully, including validation of inpu
|
|
|
317
362
|
|
|
318
363
|
## Advanced Features
|
|
319
364
|
|
|
320
|
-
- Supports both Amazon CloudFront and Google Cloud CDN.
|
|
321
365
|
- Validates the key argument to ensure proper formatting.
|
|
322
366
|
- Constructs invalidation paths based on the provided keys.
|
|
323
|
-
-
|
|
324
|
-
- Sends invalidation commands to the CDN client based on the type of CDN and environment.
|
|
367
|
+
- Centralizes invalidation routing through the shared Tools API endpoint.
|
|
325
368
|
|
|
326
369
|
|
|
327
370
|
# AWS S3 Module
|
package/index.js
CHANGED
package/package.json
CHANGED
package/src/cdn.js
CHANGED
|
@@ -1,114 +1,61 @@
|
|
|
1
|
-
const { CloudFrontClient, CreateInvalidationCommand } = require ( '@aws-sdk/client-cloudfront' );
|
|
2
|
-
const { GoogleAuth } = require ( 'google-auth-library' );
|
|
3
|
-
const { initialiseGoogleAuth } = require ( './gcp' );
|
|
4
1
|
const fs = require ( 'fs' );
|
|
5
2
|
let config = {};
|
|
6
3
|
const configDirPath = `${ process.cwd ()}/config`;
|
|
7
4
|
if ( fs.existsSync ( configDirPath ) && fs.statSync ( configDirPath ).isDirectory () ) {
|
|
8
5
|
config = require ( 'config' ); // require the config directory if it exists
|
|
9
6
|
}
|
|
10
|
-
const packageJson = require ( '../package.json' );
|
|
11
7
|
const { cwd } = require ( 'process' );
|
|
12
8
|
const { log } = config?.s3?.logsFunction ? require ( `${ cwd ()}/${config.s3.logsFunction}` ) : console;
|
|
13
9
|
|
|
14
|
-
const
|
|
10
|
+
const INVALIDATION_ENDPOINT = process.env.EIP_CDN_INVALIDATE_ENDPOINT || config?.cdn?.invalidateEndpoint || 'https://tools.eip.telegraph.co.uk/v1/invalidate';
|
|
11
|
+
const shouldLog = Boolean ( config?.cdn?.log || config?.cdn?.logs );
|
|
15
12
|
|
|
16
|
-
/**
|
|
17
|
-
* Create a CDN invalidation for the specified key(s) and environment.
|
|
18
|
-
*
|
|
19
|
-
* @param {string} cdn - The CDN provider to be used (e.g., 'google', 'amazon').
|
|
20
|
-
* @param {string|string[]} key - The key(s) representing the file(s) to invalidate.
|
|
21
|
-
* @param {string} environment - The environment (e.g., 'staging', 'production').
|
|
22
|
-
* @returns {Promise<void>} A promise that resolves when the invalidation is created.
|
|
23
|
-
* @description Creates a CDN invalidation for the specified key(s) in the specified environment.
|
|
24
|
-
* - The `cdn` parameter specifies the CDN provider (e.g., 'google', 'amazon').
|
|
25
|
-
* - The `key` parameter can be a single string or an array of strings representing the file(s) to invalidate.
|
|
26
|
-
* - The `environment` parameter specifies the environment (e.g., 'staging', 'production').
|
|
27
|
-
* - The function validates the `key` argument and throws an error if it is not a string or an array of strings.
|
|
28
|
-
* - The function constructs the invalidation paths based on the provided keys.
|
|
29
|
-
* - The CDN client (either Google or Amazon CloudFront) is used to send the invalidation command.
|
|
30
|
-
* - Returns a promise that resolves when the invalidation is created.
|
|
31
|
-
* - The function initializes Google Auth if Google CDN is used.
|
|
32
|
-
* - If Google CDN is used, the function makes a POST request to Google's 'invalidateCache' endpoint for each provided path.
|
|
33
|
-
* - If Amazon CloudFront is used, the function sends an invalidation command to CloudFront.
|
|
34
|
-
* - If the CDN type is not 'google' or 'amazon', the function throws an error.
|
|
35
|
-
* - The function uses the 'config' module to access the CDN settings based on the CDN provider and environment.
|
|
36
|
-
*/
|
|
37
13
|
exports.createInvalidation = async ( cdn, key, environment = 'production' ) => {
|
|
38
|
-
const
|
|
14
|
+
const invalidation = normalizeInvalidation ( cdn, key, environment );
|
|
39
15
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
.filter ( item => typeof item === 'string' )
|
|
43
|
-
.map ( item => item.charAt ( 0 ) !== '/' ? '/' + item : item );
|
|
44
|
-
|
|
45
|
-
if ( paths.length === 0 ) {
|
|
46
|
-
throw new Error ( 'Invalid key argument. Expected a string or an array of strings.' );
|
|
16
|
+
if ( shouldLog ) {
|
|
17
|
+
log ( `CDN [INVALIDATE][REQUEST]: ${invalidation.cdn} (${invalidation.environment}) ${invalidation.paths.join ( ', ' )}\n` );
|
|
47
18
|
}
|
|
48
19
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
20
|
+
const response = await fetch ( INVALIDATION_ENDPOINT, {
|
|
21
|
+
method: 'POST',
|
|
22
|
+
headers: {
|
|
23
|
+
'Content-Type': 'application/json'
|
|
24
|
+
},
|
|
25
|
+
body: JSON.stringify ( {
|
|
26
|
+
cdn: invalidation.cdn,
|
|
27
|
+
key: invalidation.key,
|
|
28
|
+
environment: invalidation.environment
|
|
29
|
+
} )
|
|
30
|
+
} );
|
|
52
31
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
log ( `CDN [INVALIDATE]: Invalidation already in progress - skipping: ${paths.map ( path => `https://${cdn}${environment !== 'production' ? '-test' : ''}.eip.telegraph.co.uk${path}` ).join ( ', ' )}\n` );
|
|
56
|
-
await redis.increment(redisKey);
|
|
32
|
+
if ( !response.ok ) {
|
|
33
|
+
const responseText = await response.text ();
|
|
57
34
|
|
|
58
|
-
|
|
59
|
-
}
|
|
60
|
-
else{
|
|
61
|
-
await redis.set(redisKey, 1, cdnSettings.type === 'google' ? 300 : 120); // 5 minutes for google, 2 minutes for amazon
|
|
62
|
-
}
|
|
35
|
+
throw new Error ( `CDN invalidation request failed (${response.status}): ${responseText}` );
|
|
63
36
|
}
|
|
37
|
+
};
|
|
64
38
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
switch ( cdnSettings.type ) {
|
|
69
|
-
case 'google':
|
|
70
|
-
await invalidateGoogleCDN ( cdnSettings, paths );
|
|
71
|
-
break;
|
|
72
|
-
|
|
73
|
-
case 'amazon':
|
|
74
|
-
await invalidateAmazonCDN ( cdnSettings, paths );
|
|
75
|
-
break;
|
|
39
|
+
function normalizeInvalidation ( cdn, key, environment ) {
|
|
40
|
+
const cdnSettings = config?.cdn?.[ cdn ]?.[ environment ];
|
|
76
41
|
|
|
77
|
-
|
|
78
|
-
|
|
42
|
+
if ( !cdnSettings ) {
|
|
43
|
+
throw new Error ( `Missing CDN configuration for ${cdn} (${environment})` );
|
|
79
44
|
}
|
|
80
|
-
};
|
|
81
45
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const auth = new GoogleAuth ( {
|
|
86
|
-
scopes: 'https://www.googleapis.com/auth/cloud-platform'
|
|
87
|
-
} );
|
|
46
|
+
const paths = ( Array.isArray ( key ) ? key : [ key ] )
|
|
47
|
+
.filter ( item => typeof item === 'string' )
|
|
48
|
+
.map ( item => item.charAt ( 0 ) !== '/' ? '/' + item : item );
|
|
88
49
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
50
|
+
if ( paths.length === 0 ) {
|
|
51
|
+
throw new Error ( 'Invalid key argument. Expected a string or an array of strings.' );
|
|
52
|
+
}
|
|
92
53
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
54
|
+
return {
|
|
55
|
+
cdn,
|
|
56
|
+
key,
|
|
57
|
+
environment,
|
|
58
|
+
paths,
|
|
59
|
+
cdnSettings
|
|
60
|
+
};
|
|
98
61
|
}
|
|
99
|
-
|
|
100
|
-
async function invalidateAmazonCDN ( cdnSettings, paths ) {
|
|
101
|
-
const client = new CloudFrontClient ();
|
|
102
|
-
const command = new CreateInvalidationCommand ( {
|
|
103
|
-
DistributionId: cdnSettings.distributionId,
|
|
104
|
-
InvalidationBatch: {
|
|
105
|
-
CallerReference: `${packageJson.name}-${Date.now ()}`,
|
|
106
|
-
Paths: {
|
|
107
|
-
Quantity: paths.length,
|
|
108
|
-
Items: paths,
|
|
109
|
-
},
|
|
110
|
-
},
|
|
111
|
-
} );
|
|
112
|
-
|
|
113
|
-
await client.send ( command );
|
|
114
|
-
}
|
package/src/ondemand.js
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
const DEFAULT_INGEST_BASE_URL = 'https://ingest.staging.content-discovery.video/v1';
|
|
2
|
+
const DEFAULT_DISCOVERY_BASE_URL = 'https://api.staging.content-discovery.video/v1';
|
|
3
|
+
const PRODUCTION_HOSTS = new Set ( [
|
|
4
|
+
'ingest.content-discovery.video',
|
|
5
|
+
'api.content-discovery.video'
|
|
6
|
+
] );
|
|
7
|
+
const DEFAULT_TIMEOUT_MS = 15000;
|
|
8
|
+
|
|
9
|
+
class OnDemandRequestError extends Error {
|
|
10
|
+
constructor ( message, { status = null, details = null, cause = null } = {} ) {
|
|
11
|
+
super ( message, cause ? { cause } : undefined );
|
|
12
|
+
this.name = 'OnDemandRequestError';
|
|
13
|
+
this.status = status;
|
|
14
|
+
this.details = details;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const normalizeText = value => typeof value === 'string' ? value.trim () : '';
|
|
19
|
+
|
|
20
|
+
const sanitizeUrl = value => {
|
|
21
|
+
try {
|
|
22
|
+
const url = new URL ( value );
|
|
23
|
+
url.search = '';
|
|
24
|
+
url.hash = '';
|
|
25
|
+
return url.toString ();
|
|
26
|
+
}
|
|
27
|
+
catch ( error ) {
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const sanitizeText = value => normalizeText ( value )
|
|
33
|
+
.replace ( /https?:\/\/[^\s"'<>]+/gi, match => sanitizeUrl ( match ) )
|
|
34
|
+
.replace ( /[?&](?:sig|sv|se|sp|spr|srt|ss|st)=[^&\s"']*/gi, '' );
|
|
35
|
+
|
|
36
|
+
const sanitizeDetails = value => {
|
|
37
|
+
if ( typeof value === 'string' ) return sanitizeText ( value );
|
|
38
|
+
if ( Array.isArray ( value ) ) return value.map ( sanitizeDetails );
|
|
39
|
+
if ( !value || typeof value !== 'object' ) return value;
|
|
40
|
+
|
|
41
|
+
return Object.fromEntries ( Object.entries ( value ).map ( ( [ key, item ] ) => [ key, sanitizeDetails ( item ) ] ) );
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const resolveFetch = fetchImpl => {
|
|
45
|
+
const resolved = fetchImpl || global.fetch;
|
|
46
|
+
if ( typeof resolved !== 'function' ) {
|
|
47
|
+
throw new Error ( 'OnDemand requires a fetch implementation' );
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return resolved;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const normalizeBaseUrl = ( value, defaultValue, allowProduction ) => {
|
|
54
|
+
const normalized = normalizeText ( value ) || defaultValue;
|
|
55
|
+
const url = new URL ( normalized );
|
|
56
|
+
|
|
57
|
+
if ( url.protocol !== 'https:' ) {
|
|
58
|
+
throw new Error ( 'OnDemand endpoint must use HTTPS' );
|
|
59
|
+
}
|
|
60
|
+
if ( PRODUCTION_HOSTS.has ( url.hostname ) && !allowProduction ) {
|
|
61
|
+
throw new Error ( 'OnDemand production endpoint is disabled' );
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
url.search = '';
|
|
65
|
+
url.hash = '';
|
|
66
|
+
return url.toString ().replace ( /\/$/, '' );
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const requireApiKey = value => {
|
|
70
|
+
const apiKey = normalizeText ( value );
|
|
71
|
+
if ( !apiKey ) throw new Error ( 'OnDemand API key is required' );
|
|
72
|
+
return apiKey;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const parseResponse = async response => {
|
|
76
|
+
if ( response.status === 204 ) return null;
|
|
77
|
+
|
|
78
|
+
const contentType = normalizeText ( response?.headers?.get?.( 'content-type' ) ).toLowerCase ();
|
|
79
|
+
if ( contentType.includes ( 'json' ) && typeof response.json === 'function' ) {
|
|
80
|
+
return response.json ();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const text = typeof response.text === 'function' ? await response.text () : '';
|
|
84
|
+
if ( !text ) return null;
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
return JSON.parse ( text );
|
|
88
|
+
}
|
|
89
|
+
catch ( error ) {
|
|
90
|
+
return text;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const buildErrorMessage = ( status, details ) => {
|
|
95
|
+
const detail = typeof details === 'object' && details
|
|
96
|
+
? details.detail || details.title || details.message
|
|
97
|
+
: details;
|
|
98
|
+
const suffix = sanitizeText ( detail );
|
|
99
|
+
return `OnDemand request failed (${status})${suffix ? `: ${suffix}` : ''}`;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const executeRequest = async ( {
|
|
103
|
+
fetchImpl,
|
|
104
|
+
url,
|
|
105
|
+
options,
|
|
106
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
107
|
+
includeResponse = false
|
|
108
|
+
} ) => {
|
|
109
|
+
const controller = new AbortController ();
|
|
110
|
+
const timer = setTimeout ( () => controller.abort (), timeoutMs );
|
|
111
|
+
timer.unref?.();
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
const response = await fetchImpl ( url, {
|
|
115
|
+
...options,
|
|
116
|
+
signal: controller.signal
|
|
117
|
+
} );
|
|
118
|
+
const payload = await parseResponse ( response );
|
|
119
|
+
|
|
120
|
+
if ( !response.ok ) {
|
|
121
|
+
const sanitizedDetails = sanitizeDetails ( payload );
|
|
122
|
+
throw new OnDemandRequestError ( buildErrorMessage ( response.status, sanitizedDetails ), {
|
|
123
|
+
status: response.status,
|
|
124
|
+
details: sanitizedDetails
|
|
125
|
+
} );
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return includeResponse ? { payload, response } : payload;
|
|
129
|
+
}
|
|
130
|
+
catch ( error ) {
|
|
131
|
+
if ( error instanceof OnDemandRequestError ) throw error;
|
|
132
|
+
|
|
133
|
+
const timedOut = controller.signal.aborted || error?.name === 'AbortError';
|
|
134
|
+
throw new OnDemandRequestError ( timedOut ? 'OnDemand request timed out' : 'OnDemand request failed', {
|
|
135
|
+
cause: error
|
|
136
|
+
} );
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
clearTimeout ( timer );
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const createApiClient = ( {
|
|
144
|
+
apiKey,
|
|
145
|
+
baseUrl,
|
|
146
|
+
defaultBaseUrl,
|
|
147
|
+
allowProduction = false,
|
|
148
|
+
fetch: fetchOption,
|
|
149
|
+
timeoutMs = DEFAULT_TIMEOUT_MS
|
|
150
|
+
} ) => {
|
|
151
|
+
const resolvedApiKey = requireApiKey ( apiKey );
|
|
152
|
+
const resolvedBaseUrl = normalizeBaseUrl ( baseUrl, defaultBaseUrl, allowProduction );
|
|
153
|
+
const fetchImpl = resolveFetch ( fetchOption );
|
|
154
|
+
|
|
155
|
+
return async ( path, { method = 'GET', body, headers = {} } = {} ) => executeRequest ( {
|
|
156
|
+
fetchImpl,
|
|
157
|
+
url: `${resolvedBaseUrl}${path}`,
|
|
158
|
+
timeoutMs,
|
|
159
|
+
options: {
|
|
160
|
+
method,
|
|
161
|
+
headers: {
|
|
162
|
+
...( body === undefined ? {} : { 'Content-Type': 'application/json' } ),
|
|
163
|
+
'X-API-KEY': resolvedApiKey,
|
|
164
|
+
...headers
|
|
165
|
+
},
|
|
166
|
+
...( body === undefined ? {} : { body: JSON.stringify ( body ) } )
|
|
167
|
+
}
|
|
168
|
+
} );
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const encodePathSegment = ( value, label ) => {
|
|
172
|
+
const normalized = normalizeText ( value );
|
|
173
|
+
if ( !normalized ) throw new Error ( `${label} is required` );
|
|
174
|
+
return encodeURIComponent ( normalized );
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const assertCleanIngestUrl = value => {
|
|
178
|
+
const normalized = normalizeText ( value );
|
|
179
|
+
if ( !normalized ) throw new Error ( 'OnDemand ingest URL is required' );
|
|
180
|
+
|
|
181
|
+
const url = new URL ( normalized );
|
|
182
|
+
if ( url.search ) throw new Error ( 'OnDemand ingest URL must not contain query parameters' );
|
|
183
|
+
return url.toString ();
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Create a Content Ingest API client. Staging is the default and production hosts
|
|
188
|
+
* are rejected unless a future caller explicitly opts in with allowProduction.
|
|
189
|
+
*
|
|
190
|
+
* @param {object} options
|
|
191
|
+
* @param {string} options.apiKey Content Ingest X-API-KEY value.
|
|
192
|
+
* @param {string} [options.baseUrl] Content Ingest base URL.
|
|
193
|
+
* @param {boolean} [options.allowProduction=false] Explicit production safety override.
|
|
194
|
+
* @param {Function} [options.fetch] Injectable fetch implementation.
|
|
195
|
+
* @param {number} [options.timeoutMs=15000] Per-request timeout.
|
|
196
|
+
* @returns {object} Content Ingest operations.
|
|
197
|
+
*/
|
|
198
|
+
exports.createIngestClient = options => {
|
|
199
|
+
const request = createApiClient ( {
|
|
200
|
+
...options,
|
|
201
|
+
defaultBaseUrl: DEFAULT_INGEST_BASE_URL
|
|
202
|
+
} );
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
createAsset: input => request ( '/assets', { method: 'POST', body: input } ),
|
|
206
|
+
getAsset: assetId => request ( `/assets/${encodePathSegment ( assetId, 'OnDemand asset id' )}` ),
|
|
207
|
+
updateAsset: ( assetId, input ) => request ( `/assets/${encodePathSegment ( assetId, 'OnDemand asset id' )}`, {
|
|
208
|
+
method: 'PATCH',
|
|
209
|
+
body: input
|
|
210
|
+
} ),
|
|
211
|
+
startIngest: ( assetId, input ) => request ( `/assets/${encodePathSegment ( assetId, 'OnDemand asset id' )}/ingest`, {
|
|
212
|
+
method: 'POST',
|
|
213
|
+
body: {
|
|
214
|
+
...input,
|
|
215
|
+
url: assertCleanIngestUrl ( input?.url )
|
|
216
|
+
}
|
|
217
|
+
} ),
|
|
218
|
+
getIngest: ingestId => request ( `/ingests/${encodePathSegment ( ingestId, 'OnDemand ingest id' )}` ),
|
|
219
|
+
createRendition: ( assetId, input ) => request ( `/assets/${encodePathSegment ( assetId, 'OnDemand asset id' )}/rendition`, {
|
|
220
|
+
method: 'POST',
|
|
221
|
+
body: input
|
|
222
|
+
} ),
|
|
223
|
+
updateRendition: ( assetId, rendition, input ) => request ( `/assets/${encodePathSegment ( assetId, 'OnDemand asset id' )}/rendition/${encodePathSegment ( rendition, 'OnDemand rendition' )}`, {
|
|
224
|
+
method: 'PUT',
|
|
225
|
+
body: input
|
|
226
|
+
} )
|
|
227
|
+
};
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const buildQueryString = params => {
|
|
231
|
+
const searchParams = new URLSearchParams ();
|
|
232
|
+
|
|
233
|
+
Object.entries ( params || {} ).forEach ( ( [ key, value ] ) => {
|
|
234
|
+
if ( value === undefined || value === null || value === '' ) return;
|
|
235
|
+
searchParams.set ( key, Array.isArray ( value ) ? value.join ( ',' ) : String ( value ) );
|
|
236
|
+
} );
|
|
237
|
+
|
|
238
|
+
const query = searchParams.toString ();
|
|
239
|
+
return query ? `?${query}` : '';
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Create a Content Discovery API client. Staging is the default and production
|
|
244
|
+
* hosts are rejected unless a future caller explicitly opts in.
|
|
245
|
+
*
|
|
246
|
+
* @param {object} options
|
|
247
|
+
* @param {string} options.apiKey Content Discovery X-API-KEY value.
|
|
248
|
+
* @param {string} [options.baseUrl] Content Discovery base URL.
|
|
249
|
+
* @param {boolean} [options.allowProduction=false] Explicit production safety override.
|
|
250
|
+
* @param {Function} [options.fetch] Injectable fetch implementation.
|
|
251
|
+
* @param {number} [options.timeoutMs=15000] Per-request timeout.
|
|
252
|
+
* @returns {object} Content Discovery operations.
|
|
253
|
+
*/
|
|
254
|
+
exports.createDiscoveryClient = options => {
|
|
255
|
+
const request = createApiClient ( {
|
|
256
|
+
...options,
|
|
257
|
+
defaultBaseUrl: DEFAULT_DISCOVERY_BASE_URL
|
|
258
|
+
} );
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
getVersion: () => request ( '/' ),
|
|
262
|
+
getAsset: assetId => request ( `/assets/${encodePathSegment ( assetId, 'OnDemand asset id' )}` ),
|
|
263
|
+
getAssets: assetIds => request ( `/assets${buildQueryString ( {
|
|
264
|
+
id: ( Array.isArray ( assetIds ) ? assetIds : [ assetIds ] ).join ( ',' )
|
|
265
|
+
} )}` ),
|
|
266
|
+
search: params => request ( `/search${buildQueryString ( params )}` ),
|
|
267
|
+
searchByExpression: expression => request ( '/search', { method: 'POST', body: expression } ),
|
|
268
|
+
getMasterDownload: assetId => request ( `/assets/${encodePathSegment ( assetId, 'OnDemand asset id' )}/renditions/master/download` )
|
|
269
|
+
};
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const buildAzureUploadUrl = ( blobUrl, sasToken ) => {
|
|
273
|
+
const url = new URL ( normalizeText ( blobUrl ) );
|
|
274
|
+
if ( url.protocol !== 'https:' || !url.hostname.endsWith ( '.blob.core.windows.net' ) ) {
|
|
275
|
+
throw new Error ( 'OnDemand Azure blob URL must be an HTTPS Azure Blob Storage URL' );
|
|
276
|
+
}
|
|
277
|
+
if ( url.search ) {
|
|
278
|
+
throw new Error ( 'OnDemand Azure blob URL must not already contain query parameters' );
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const normalizedSas = normalizeText ( sasToken ).replace ( /^\?/, '' );
|
|
282
|
+
if ( !normalizedSas ) throw new Error ( 'OnDemand Azure SAS token is required' );
|
|
283
|
+
url.search = normalizedSas;
|
|
284
|
+
return url.toString ();
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Stream a source video to an Azure block blob using a SAS token. The returned
|
|
289
|
+
* result never includes the signed request URL.
|
|
290
|
+
*
|
|
291
|
+
* @param {object} options
|
|
292
|
+
* @param {string} options.blobUrl Clean Azure blob URL without a query string.
|
|
293
|
+
* @param {string} options.sasToken Azure SAS query string.
|
|
294
|
+
* @param {import('stream').Readable|Buffer|Uint8Array|Blob} options.body Upload body.
|
|
295
|
+
* @param {number} options.contentLength Exact byte length.
|
|
296
|
+
* @param {string} [options.contentType='video/mp4'] MIME type.
|
|
297
|
+
* @param {Function} [options.fetch] Injectable fetch implementation.
|
|
298
|
+
* @param {number} [options.timeoutMs=300000] Upload timeout.
|
|
299
|
+
* @returns {Promise<{status: number, etag: string|null}>} Sanitized upload result.
|
|
300
|
+
*/
|
|
301
|
+
exports.uploadAzureBlob = async ( {
|
|
302
|
+
blobUrl,
|
|
303
|
+
sasToken,
|
|
304
|
+
body,
|
|
305
|
+
contentLength,
|
|
306
|
+
contentType = 'video/mp4',
|
|
307
|
+
fetch: fetchOption,
|
|
308
|
+
timeoutMs = 300000
|
|
309
|
+
} ) => {
|
|
310
|
+
if ( !body ) throw new Error ( 'OnDemand Azure upload body is required' );
|
|
311
|
+
if ( !Number.isFinite ( Number ( contentLength ) ) || Number ( contentLength ) < 0 ) {
|
|
312
|
+
throw new Error ( 'OnDemand Azure upload content length is required' );
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const fetchImpl = resolveFetch ( fetchOption );
|
|
316
|
+
const { response } = await executeRequest ( {
|
|
317
|
+
fetchImpl,
|
|
318
|
+
url: buildAzureUploadUrl ( blobUrl, sasToken ),
|
|
319
|
+
timeoutMs,
|
|
320
|
+
includeResponse: true,
|
|
321
|
+
options: {
|
|
322
|
+
method: 'PUT',
|
|
323
|
+
headers: {
|
|
324
|
+
'Content-Type': contentType,
|
|
325
|
+
'Content-Length': String ( contentLength ),
|
|
326
|
+
'x-ms-blob-type': 'BlockBlob'
|
|
327
|
+
},
|
|
328
|
+
body,
|
|
329
|
+
...( typeof body?.pipe === 'function' ? { duplex: 'half' } : {} )
|
|
330
|
+
}
|
|
331
|
+
} );
|
|
332
|
+
|
|
333
|
+
return {
|
|
334
|
+
status: response.status,
|
|
335
|
+
etag: response.headers?.get?.( 'etag' ) || null
|
|
336
|
+
};
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
exports.OnDemandRequestError = OnDemandRequestError;
|