diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 037c9e3..1403ce1 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.3.4" + ".": "1.3.5" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dbb16e..76243ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [1.3.5](https://github.com/gumlet/nodejs-sdk/compare/v1.3.4...v1.3.5) (2026-09-25) + + +### Features + +* **api:** add operation multipartUpload.abort (+1 more change) ([066c9bf](https://github.com/gumlet/nodejs-sdk/commit/066c9bf897a9c46c084cc34cc1b1da8662e20440)) + + +### Chores + +* **api:** regenerate SDK ([cf6c3ad](https://github.com/gumlet/nodejs-sdk/commit/cf6c3adb0f1c6216536ba2e19300a938a2e62ec2)) +* release 1.3.5 ([4f43a21](https://github.com/gumlet/nodejs-sdk/commit/4f43a21b30dd4b88ec490344fdf06dc214844b7b)) +* release 1.3.5 ([5e81fed](https://github.com/gumlet/nodejs-sdk/commit/5e81fed88a94db71ec652d1b8edb91124da1000c)) + ## [1.3.4](https://github.com/gumlet/nodejs-sdk/compare/v1.3.3...v1.3.4) (2026-09-21) diff --git a/api.md b/api.md index db602f5..a156f41 100644 --- a/api.md +++ b/api.md @@ -30,6 +30,8 @@ Complete reference of every operation, grouped by resource. See [the README](./R - [`MultipartUpload`](#multipartupload) - [Get Part Upload URL](#get-part-upload-url) - [Complete Multipart Upload](#complete-multipart-upload) + - [Abort Upload](#abort-upload) + - [List Uploads](#list-uploads) - [`VideoProfiles`](#videoprofiles) - [Create Profile](#create-profile) - [List Profiles](#list-profiles) @@ -488,6 +490,32 @@ Once you upload all parts to S3 bucket via pre-signed URL, use this endpoint to const multipartUpload = await client.multipartUpload.complete('assetId'); ``` +### Abort Upload + +This call aborts multi-part upload and deletes the already uploaded parts from the storage. + +| Direction | Type | +| --- | --- | +| Request | [`MultipartUploadAbortParams`](./src/resources/multipart-upload.ts) | +| Response | [`MultipartUploadAbortResponse`](./src/resources/multipart-upload.ts) | + +```ts +const multipartUpload = await client.multipartUpload.abort('assetId'); +``` + +### List Uploads + +Lists all parts uploaded so far. + +| Direction | Type | +| --- | --- | +| Request | [`MultipartUploadListParams`](./src/resources/multipart-upload.ts) | +| Response | [`MultipartUploadListResponse`](./src/resources/multipart-upload.ts) | + +```ts +const multipartUpload = await client.multipartUpload.list('assetId'); +``` + ## `VideoProfiles` Create and manage encoding/output profiles for video assets. diff --git a/package.json b/package.json index 38e6f03..d764905 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@gumlet/nodejs-sdk", - "version": "1.3.4", + "version": "1.3.5", "description": "Gumlet helps developers deliver online video and images. This API encompasses Gumlet Video, Image and Video Analytics functionality to help you build your products better and faster than ever before.", "author": "Gumlet", "repository": { diff --git a/scalar-sdk.manifest.json b/scalar-sdk.manifest.json index ec94d2a..9a2b70a 100644 --- a/scalar-sdk.manifest.json +++ b/scalar-sdk.manifest.json @@ -1,7 +1,7 @@ { "name": "Gumlet", "slug": "gumlet", - "generatorVersion": "0.33.2", + "generatorVersion": "0.33.3", "servers": [ "https://api.gumlet.com/v1" ], @@ -3498,6 +3498,136 @@ "responseLinks": [], "transport": "http" }, + { + "resource": "multipartUpload", + "publicResource": "multipartUpload", + "operation": "abort", + "publicOperation": "abort", + "deprecated": false, + "method": "POST", + "path": "/video/assets/{asset_id}/multipartupload/abort", + "pathParams": [ + "asset_id" + ], + "publicPathParams": [ + "asset_id" + ], + "queryParams": [], + "publicQueryParams": [], + "headerParams": [], + "publicHeaderParams": [], + "bodyParams": [], + "publicBodyParams": [], + "bodyParamDetails": [], + "pathParamDetails": [ + { + "name": "asset_id", + "required": true, + "style": "simple", + "explode": false + } + ], + "queryParamDetails": [], + "headerParamDetails": [], + "cookieParams": [], + "publicCookieParams": [], + "cookieParamDetails": [], + "paramsModel": { + "publicName": "MultipartUploadAbortParams" + }, + "requestBody": { + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ], + "required": false, + "publicName": "body", + "publicIdentifier": "body" + }, + "response": { + "status": "200", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + "errorResponses": [], + "responseLinks": [], + "transport": "http" + }, + { + "resource": "multipartUpload", + "publicResource": "multipartUpload", + "operation": "list", + "publicOperation": "list", + "deprecated": false, + "method": "POST", + "path": "/video/assets/{asset_id}/multipartupload/list", + "pathParams": [ + "asset_id" + ], + "publicPathParams": [ + "asset_id" + ], + "queryParams": [], + "publicQueryParams": [], + "headerParams": [], + "publicHeaderParams": [], + "bodyParams": [], + "publicBodyParams": [], + "bodyParamDetails": [], + "pathParamDetails": [ + { + "name": "asset_id", + "required": true, + "style": "simple", + "explode": false + } + ], + "queryParamDetails": [], + "headerParamDetails": [], + "cookieParams": [], + "publicCookieParams": [], + "cookieParamDetails": [], + "paramsModel": { + "publicName": "MultipartUploadListParams" + }, + "requestBody": { + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ], + "required": false, + "publicName": "body", + "publicIdentifier": "body" + }, + "response": { + "status": "200", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + "errorResponses": [], + "responseLinks": [], + "transport": "http" + }, { "resource": "videoProfiles", "publicResource": "videoProfiles", diff --git a/src/client.ts b/src/client.ts index a92aaa8..4d8e424 100644 --- a/src/client.ts +++ b/src/client.ts @@ -14,6 +14,7 @@ import { formatRequestDetails, loggerFor, parseLogLevel, + redactUrl, type LogLevel, type Logger, } from './internal/utils/log'; @@ -75,8 +76,12 @@ import { MultipartUpload, type MultipartUploadRetrievePartURLResponse, type MultipartUploadCompleteResponse, + type MultipartUploadAbortResponse, + type MultipartUploadListResponse, type MultipartUploadRetrievePartURLParams, type MultipartUploadCompleteParams, + type MultipartUploadAbortParams, + type MultipartUploadListParams, } from './resources/multipart-upload'; import { VideoProfiles, @@ -613,7 +618,7 @@ export class Gumlet { throw new Errors.APIConnectionError({ cause: response }); } - const responseInfo = `[${requestLogID}${retryLogStr}] ${req.method} ${url} ${ + const responseInfo = `[${requestLogID}${retryLogStr}] ${req.method} ${redactUrl(url)} ${ response.ok ? 'succeeded' : 'failed' } with status ${response.status} in ${headersTime - startTime}ms`; @@ -1127,8 +1132,12 @@ export declare namespace Gumlet { MultipartUpload as MultipartUpload, type MultipartUploadRetrievePartURLResponse as MultipartUploadRetrievePartURLResponse, type MultipartUploadCompleteResponse as MultipartUploadCompleteResponse, + type MultipartUploadAbortResponse as MultipartUploadAbortResponse, + type MultipartUploadListResponse as MultipartUploadListResponse, type MultipartUploadRetrievePartURLParams as MultipartUploadRetrievePartURLParams, type MultipartUploadCompleteParams as MultipartUploadCompleteParams, + type MultipartUploadAbortParams as MultipartUploadAbortParams, + type MultipartUploadListParams as MultipartUploadListParams, }; export { diff --git a/src/internal/utils/log.ts b/src/internal/utils/log.ts index d865d7f..f44e12f 100644 --- a/src/internal/utils/log.ts +++ b/src/internal/utils/log.ts @@ -84,6 +84,160 @@ export function loggerFor(client: Gumlet): Logger { return levelLogger; } +/** Value a credential is replaced with before it reaches a log line. */ +const REDACTED = '***'; + +/** + * Header names whose value is replaced before a request or response is logged. + * + * The fixed entries are the conventional credential headers, which is all a runtime shared by every + * SDK could know on its own. The generated ones are the headers *this* SDK's auth schemes actually + * send: an OpenAPI `apiKey` scheme names its own header, and `X-Auth-Token` or `PRIVATE-TOKEN` is no + * less a credential for being spelled differently. Without them a `logLevel: "debug"` run prints the + * credential in clear text, and the generated CLI turns that level on with `--debug`, so it reaches + * terminals and CI logs alike. + */ +const REDACTED_HEADERS: ReadonlySet = new Set([ + 'authorization', + 'api-key', + 'x-api-key', + 'cookie', + 'set-cookie', +]); + +/** + * Query parameters this SDK sends a credential in, from its `apiKey` schemes with `in: query`. + * + * A credential in the query string is worse off than one in a header: the request URL is logged at + * `info` as well as at `debug`, so it leaks a level below the one a reader would think of as + * verbose. + */ +const REDACTED_QUERY_PARAMS: readonly string[] = []; + +/** + * Blanks the userinfo of a URL, which is a credential wherever it appears. + * + * A `--base-url` carries whatever the caller typed, and this repo already treats userinfo as secret + * where it handles a base URL elsewhere (`profileKey` blanks it before a URL becomes a keychain + * account). Both halves go rather than the password alone: a token is as often the username + * (`https://@host`) as the password, so keeping either back still prints one shape in full. + */ +const redactUserinfo = (url: string): string => { + const scheme = url.indexOf('://'); + if (scheme === -1) return url; + const start = scheme + 3; + // The authority ends at the first of these; an `@` past one of them belongs to a path or a query. + let end = url.length; + for (const delimiter of ['/', '?', '#']) { + const at = url.indexOf(delimiter, start); + if (at !== -1 && at < end) end = at; + } + const at = url.lastIndexOf('@', end); + return at === -1 || at < start ? url : url.slice(0, start) + REDACTED + url.slice(at); +}; + +/** + * Decodes one `application/x-www-form-urlencoded` parameter name, for comparison against the list. + * + * A name reaches the URL percent-encoded, so `api%5Fkey` has to match `api_key`. A malformed escape + * comes back as written rather than throwing: a log line is no place to fail over one, and a name + * that cannot be decoded cannot match the list anyway. + */ +const decodeQueryName = (name: string): string => { + try { + return decodeURIComponent(name.replace(/\+/gu, ' ')); + } catch { + return name; + } +}; + +/** + * Replaces the value of every credential-bearing query parameter in a URL about to be logged. + * + * Rewritten on the query substring rather than through `new URL`, so a relative URL is handled the + * same way an absolute one is and no parse can throw on the logging path. + */ +export const redactUrl = (url: string): string => { + const url_ = redactUserinfo(url); + if (REDACTED_QUERY_PARAMS.length === 0) return url_; + const mark = url_.indexOf('?'); + if (mark === -1) return url_; + // A fragment is not part of the query, so splitting it off keeps it out of the rewrite. + const fragment = url_.indexOf('#', mark); + const end = fragment === -1 ? url_.length : fragment; + // Each pair is rewritten where it stands rather than round-tripped through `URLSearchParams`, + // whose serializer re-spells every *other* parameter in its own safe set -- a space comes back as + // `+`, a `~` as `%7E` -- and a logged URL that is not the one the request used is a URL nobody can + // paste back. Splitting and rejoining on `&` is lossless, so a URL carrying no credential comes + // out of this exactly as it went in. + const query = url_ + .slice(mark + 1, end) + .split('&') + .map((pair) => { + const equals = pair.indexOf('='); + // A parameter with no `=` carries no value, so there is nothing in it to redact. + if (equals === -1) return pair; + const name = pair.slice(0, equals); + return REDACTED_QUERY_PARAMS.includes(decodeQueryName(name)) ? name + '=' + REDACTED : pair; + }) + .join('&'); + return url_.slice(0, mark + 1) + query + url_.slice(end); +}; + +/** + * Replaces credential values in a caller-supplied query object. + * + * The client merges its own auth query into the URL, but a caller may pass the same parameter here + * and `buildURL` lets that value win — so it is as live a credential as the one in the URL. + */ +const redactQuery = (query: object): object => { + const redacted: Record = { ...(query as Record) }; + for (const name of REDACTED_QUERY_PARAMS) { + if (hasOwn(redacted, name)) redacted[name] = REDACTED; + } + return redacted; +}; + +/** Replaces the value of every credential-bearing header, for a `Headers` or a plain record alike. */ +export const redactHeaders = (headers: Headers | Record): Record => + Object.fromEntries( + (headers instanceof Headers ? [...headers] : Object.entries(headers)).map(([name, value]) => [ + name, + REDACTED_HEADERS.has(name.toLowerCase()) ? REDACTED : value, + ]), + ); + +/** URL-shaped runs inside free text. Whitespace and quotes end one; see {@link TRAILING_PUNCTUATION}. */ +const URL_IN_TEXT = /\bhttps?:\/\/[^\s"'<>]+/gu; + +/** + * Punctuation that ends a sentence or closes a bracket rather than belonging to the URL. + * + * Trimmed back after the match instead of being excluded from it, because an IPv6 host carries its + * own `]` (`http://[::1]:8080/p?api_key=…`). Excluding the character cut the match short at the + * host and left the query — credential included — in the text. + */ +const TRAILING_PUNCTUATION = /[.,;:!?)\]}]+$/u; + +/** + * Redacts every URL quoted inside free text, leaving the rest of the text alone. + * + * Runs for every SDK, not only those with a query credential: Node's `fetch` rejects a URL carrying + * userinfo with a message that quotes the whole URL back (`Request cannot be constructed from a URL + * that includes credentials: …`), and that message is logged on the connection-failure path. Gating + * this on the query list left the password in the log for every SDK whose document happens not to + * put a credential in the query. + * + * Only the matched URL is rewritten, never the whole string: a message is not a query string, and + * handing one to `URLSearchParams` wholesale would swallow everything after the first parameter and + * take the diagnostic with it. + */ +const redactUrlsInText = (text: string): string => + text.replace(URL_IN_TEXT, (match) => { + const trailing = match.match(TRAILING_PUNCTUATION)?.[0] ?? ''; + return redactUrl(match.slice(0, match.length - trailing.length)) + trailing; + }); + export const formatRequestDetails = (details: { options?: RequestOptions | undefined; headers?: Headers | Record | undefined; @@ -97,24 +251,48 @@ export const formatRequestDetails = (details: { body?: unknown; }) => { if (details.options) { - details.options = { ...details.options }; - delete details.options['headers']; // redundant + leaks internals + // Swept through a record view rather than field by field: not every field below is declared on + // `RequestOptions` in every profile (`serverURL` belongs to the Speakeasy compatibility one), + // and the copy is what keeps the caller's own options object untouched. + const options: Record = { ...details.options }; + delete options['headers']; // redundant + leaks internals + // The Speakeasy profile re-adds a header bag under `fetchOptions`, and the client reads it as + // the request's headers whenever the flattened one is absent -- so it holds the credential a + // migrated call site passes. Dropped for the same reason as the field above rather than + // redacted, because `HeadersLike` also takes shapes (an array of pairs, `null`) that the header + // redaction does not, and the sent headers are already reported beside this, redacted. + const fetchOptions = options['fetchOptions']; + if (fetchOptions && typeof fetchOptions === 'object') { + const copy: Record = { ...(fetchOptions as Record) }; + delete copy['headers']; + options['fetchOptions'] = copy; + } + const query = options['query']; + if (query && typeof query === 'object') options['query'] = redactQuery(query); + // These three are URL strings, not route fragments: `buildURL` takes an absolute `path` whole -- + // userinfo, query string and all -- while `defaultBaseURL` and the Speakeasy profile's + // `serverURL` each replace the base URL outright. The caller-supplied escape hatches therefore + // carry a credential exactly as readily as the resolved URL beside them does. `serverURL` may + // be a `URL`, whose `username` and `password` print as fields of their own, so the redacted + // string goes back rather than the object. + for (const field of ['path', 'defaultBaseURL', 'serverURL']) { + const value = options[field]; + if (typeof value === 'string') options[field] = redactUrl(value); + else if (value instanceof URL) options[field] = redactUrl(value.toString()); + } + details.options = options as RequestOptions; + } + if (details.url) { + details.url = redactUrl(details.url); } if (details.headers) { - details.headers = Object.fromEntries( - (details.headers instanceof Headers ? [...details.headers] : Object.entries(details.headers)).map( - ([name, value]) => [ - name, - name.toLowerCase() === 'authorization' || - name.toLowerCase() === 'api-key' || - name.toLowerCase() === 'x-api-key' || - name.toLowerCase() === 'cookie' || - name.toLowerCase() === 'set-cookie' - ? '***' - : value, - ], - ), - ); + details.headers = redactHeaders(details.headers); + } + // The message is whatever the fetch implementation threw, and Deno's connection errors quote the + // request URL inside their text (see the note at the `connection failed` call sites), so a query + // credential rides along in a field none of the checks above look at. + if (typeof details.message === 'string') { + details.message = redactUrlsInText(details.message); } if ('retryOfRequestLogID' in details) { if (details.retryOfRequestLogID) { diff --git a/src/resources.ts b/src/resources.ts index 1297bf0..1be02f9 100644 --- a/src/resources.ts +++ b/src/resources.ts @@ -64,6 +64,10 @@ export type { MultipartUploadRetrievePartURLResponse, MultipartUploadCompleteParams, MultipartUploadCompleteResponse, + MultipartUploadAbortParams, + MultipartUploadAbortResponse, + MultipartUploadListParams, + MultipartUploadListResponse, VideoProfileCreateParams, VideoProfileCreateResponse, VideoProfileListParams, diff --git a/src/resources/index.ts b/src/resources/index.ts index 19455d7..7f7cccc 100644 --- a/src/resources/index.ts +++ b/src/resources/index.ts @@ -52,6 +52,10 @@ export type { MultipartUploadRetrievePartURLResponse, MultipartUploadCompleteParams, MultipartUploadCompleteResponse, + MultipartUploadAbortParams, + MultipartUploadAbortResponse, + MultipartUploadListParams, + MultipartUploadListResponse, } from './multipart-upload'; export { VideoProfiles } from './video-profiles'; export type { diff --git a/src/resources/multipart-upload.ts b/src/resources/multipart-upload.ts index 64908a6..6d25557 100644 --- a/src/resources/multipart-upload.ts +++ b/src/resources/multipart-upload.ts @@ -56,6 +56,54 @@ export class MultipartUpload extends APIResource { ...options, }); } + + /** + * This call aborts multi-part upload and deletes the already uploaded parts from the storage. + * + * @param {string} assetID - An asset id for the asset. + * @param {MultipartUploadAbortParams} [body] - The request body to send. + * @param {RequestOptions} [options] - Options to apply to the request, such as headers and an abort signal. + * @returns {APIPromise} Successful response + * + * @example + * ```ts + * const multipartUpload = await client.multipartUpload.abort('assetId'); + * ``` + */ + abort( + assetID: string, + body: MultipartUploadAbortParams | null | undefined = {}, + options?: RequestOptions, + ): APIPromise { + return this._client.post(__scalarPath`/video/assets/${assetID}/multipartupload/abort`, { + body, + ...options, + }); + } + + /** + * Lists all parts uploaded so far. + * + * @param {string} assetID - An asset id for the asset. + * @param {MultipartUploadListParams} [body] - The request body to send. + * @param {RequestOptions} [options] - Options to apply to the request, such as headers and an abort signal. + * @returns {APIPromise} Successful response + * + * @example + * ```ts + * const multipartUpload = await client.multipartUpload.list('assetId'); + * ``` + */ + list( + assetID: string, + body: MultipartUploadListParams | null | undefined = {}, + options?: RequestOptions, + ): APIPromise { + return this._client.post(__scalarPath`/video/assets/${assetID}/multipartupload/list`, { + body, + ...options, + }); + } } export interface MultipartUploadRetrievePartURLParams { @@ -92,11 +140,43 @@ export namespace MultipartUploadCompleteParams { } export type MultipartUploadCompleteResponse = Record; + +export type MultipartUploadAbortParams = Record; + +export type MultipartUploadAbortResponse = Record; + +export type MultipartUploadListParams = Record; + +export interface MultipartUploadListResponse { + parts: Array; +} + +export namespace MultipartUploadListResponse { + export interface Part { + /** + * Part number + */ + PartNumber: number; + /** + * Size of the uploaded part + * @format uint64 + */ + Size?: number; + /** + * ETag of the uploaded part + */ + ETag?: string; + } +} export declare namespace MultipartUpload { export { type MultipartUploadRetrievePartURLResponse as MultipartUploadRetrievePartURLResponse, type MultipartUploadCompleteResponse as MultipartUploadCompleteResponse, + type MultipartUploadAbortResponse as MultipartUploadAbortResponse, + type MultipartUploadListResponse as MultipartUploadListResponse, type MultipartUploadRetrievePartURLParams as MultipartUploadRetrievePartURLParams, type MultipartUploadCompleteParams as MultipartUploadCompleteParams, + type MultipartUploadAbortParams as MultipartUploadAbortParams, + type MultipartUploadListParams as MultipartUploadListParams, }; } diff --git a/src/version.ts b/src/version.ts index d43bf17..882d611 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,3 +1,3 @@ // File generated from our OpenAPI spec by Scalar. See README.md for details. -export const VERSION = '1.3.4'; // x-release-please-version +export const VERSION = '1.3.5'; // x-release-please-version diff --git a/tests/smoke-test.ts b/tests/smoke-test.ts index 1f8ff24..a9a4ffd 100644 --- a/tests/smoke-test.ts +++ b/tests/smoke-test.ts @@ -446,6 +446,46 @@ const cases: { }, }, + { + operation: 'abort', + method: 'POST', + path: '/video/assets/{asset_id}/multipartupload/abort', + label: 'required params', + run: async () => { + const multipartUpload = await client.multipartUpload.abort('assetId'); + }, + }, + + { + operation: 'abort', + method: 'POST', + path: '/video/assets/{asset_id}/multipartupload/abort', + label: 'all params', + run: async () => { + const multipartUpload = await client.multipartUpload.abort('assetId', {}); + }, + }, + + { + operation: 'list', + method: 'POST', + path: '/video/assets/{asset_id}/multipartupload/list', + label: 'required params', + run: async () => { + const multipartUpload = await client.multipartUpload.list('assetId'); + }, + }, + + { + operation: 'list', + method: 'POST', + path: '/video/assets/{asset_id}/multipartupload/list', + label: 'all params', + run: async () => { + const multipartUpload = await client.multipartUpload.list('assetId', {}); + }, + }, + { operation: 'create', method: 'POST',