diff --git a/lib/util/http/bitbucket-server.ts b/lib/util/http/bitbucket-server.ts index e458d9a63a26831716307f016be03a3597b6420a..ddb676f2da6e0ea34238eaba1acf551a9764782d 100644 --- a/lib/util/http/bitbucket-server.ts +++ b/lib/util/http/bitbucket-server.ts @@ -1,7 +1,6 @@ import is from '@sindresorhus/is'; -import type { InternalJsonUnsafeOptions } from './http'; +import { HttpBase, type InternalJsonUnsafeOptions } from './http'; import type { HttpMethod, HttpOptions, HttpResponse } from './types'; -import { Http } from '.'; const MAX_LIMIT = 100; @@ -20,7 +19,7 @@ interface PagedResult<T = unknown> { values: T[]; } -export class BitbucketServerHttp extends Http<BitbucketServerHttpOptions> { +export class BitbucketServerHttp extends HttpBase<BitbucketServerHttpOptions> { protected override get baseUrl(): string | undefined { return baseUrl; } diff --git a/lib/util/http/bitbucket.ts b/lib/util/http/bitbucket.ts index bee3ca1c84c2d9d51d3468ac565e52319a74a0d4..6a989bf41b8105cce0bfb17632c109eafae066fc 100644 --- a/lib/util/http/bitbucket.ts +++ b/lib/util/http/bitbucket.ts @@ -1,8 +1,7 @@ import is from '@sindresorhus/is'; import type { PagedResult } from '../../modules/platform/bitbucket/types'; -import type { InternalJsonUnsafeOptions } from './http'; +import { HttpBase, type InternalJsonUnsafeOptions } from './http'; import type { HttpMethod, HttpOptions, HttpResponse } from './types'; -import { Http } from '.'; const MAX_PAGES = 100; const MAX_PAGELEN = 100; @@ -18,7 +17,7 @@ export interface BitbucketHttpOptions extends HttpOptions { pagelen?: number; } -export class BitbucketHttp extends Http<BitbucketHttpOptions> { +export class BitbucketHttp extends HttpBase<BitbucketHttpOptions> { protected override get baseUrl(): string | undefined { return baseUrl; } @@ -63,12 +62,11 @@ export class BitbucketHttp extends Http<BitbucketHttpOptions> { // Override other page-related attributes resultBody.pagelen = resultBody.values.length; + /* v8 ignore start: hard to test all branches */ resultBody.size = - page <= MAX_PAGES - ? resultBody.values.length - : /* v8 ignore next */ undefined; - resultBody.next = - page <= MAX_PAGES ? nextURL : /* v8 ignore next */ undefined; + page <= MAX_PAGES ? resultBody.values.length : undefined; + resultBody.next = page <= MAX_PAGES ? nextURL : undefined; + /* v8 ignore stop */ } return result as HttpResponse<T>; diff --git a/lib/util/http/errors.ts b/lib/util/http/errors.ts new file mode 100644 index 0000000000000000000000000000000000000000..b9a20c28c40699087900fea1d4ec32357c973749 --- /dev/null +++ b/lib/util/http/errors.ts @@ -0,0 +1,2 @@ +// required for zod type safety with `Result.wrap` +export class EmptyResultError extends Error {} diff --git a/lib/util/http/gerrit.ts b/lib/util/http/gerrit.ts index babd02bf43a99d719a45516071283803217abd70..d999d017c28d2d159d6b000e8a3dcca5ecd47257 100644 --- a/lib/util/http/gerrit.ts +++ b/lib/util/http/gerrit.ts @@ -1,9 +1,8 @@ import { parseJson } from '../common'; import { regEx } from '../regex'; import { isHttpUrl } from '../url'; -import type { InternalHttpOptions } from './http'; +import { HttpBase, type InternalHttpOptions } from './http'; import type { HttpOptions } from './types'; -import { Http } from '.'; let baseUrl: string; export function setBaseUrl(url: string): void { @@ -14,7 +13,7 @@ export function setBaseUrl(url: string): void { * Access Gerrit REST-API and strip-of the "magic prefix" from responses. * @see https://gerrit-review.googlesource.com/Documentation/rest-api.html */ -export class GerritHttp extends Http { +export class GerritHttp extends HttpBase { private static magicPrefix = regEx(/^\)]}'\n/g); protected override get baseUrl(): string | undefined { diff --git a/lib/util/http/gitea.ts b/lib/util/http/gitea.ts index 51fb0cba281446e38dd379e6ac189ec6ef9db7f6..1e52806b04ee5fa1199c9d9cea2c91e3de0cd428 100644 --- a/lib/util/http/gitea.ts +++ b/lib/util/http/gitea.ts @@ -1,7 +1,6 @@ import is from '@sindresorhus/is'; -import type { InternalJsonUnsafeOptions } from './http'; +import { HttpBase, type InternalJsonUnsafeOptions } from './http'; import type { HttpMethod, HttpOptions, HttpResponse } from './types'; -import { Http } from '.'; let baseUrl: string; export const setBaseUrl = (newBaseUrl: string): void => { @@ -24,7 +23,7 @@ function getPaginationContainer<T = unknown>(body: unknown): T[] | null { return null; } -export class GiteaHttp extends Http<GiteaHttpOptions> { +export class GiteaHttp extends HttpBase<GiteaHttpOptions> { protected override get baseUrl(): string | undefined { return baseUrl; } diff --git a/lib/util/http/github.ts b/lib/util/http/github.ts index b1f45271cd0f061a9685c497790f7c4e71eec900..9958543494bbf0b46146b199499efe39bb7ea961 100644 --- a/lib/util/http/github.ts +++ b/lib/util/http/github.ts @@ -15,7 +15,11 @@ import { range } from '../range'; import { regEx } from '../regex'; import { joinUrlParts, parseLinkHeader } from '../url'; import { findMatchingRule } from './host-rules'; -import type { InternalHttpOptions, InternalJsonUnsafeOptions } from './http'; +import { + HttpBase, + type InternalHttpOptions, + type InternalJsonUnsafeOptions, +} from './http'; import type { GotLegacyError } from './legacy'; import type { GraphqlOptions, @@ -23,7 +27,6 @@ import type { HttpOptions, HttpResponse, } from './types'; -import { Http } from '.'; const githubBaseUrl = 'https://api.github.com/'; let baseUrl = githubBaseUrl; @@ -31,11 +34,14 @@ export const setBaseUrl = (url: string): void => { baseUrl = url; }; -export interface GithubHttpOptions extends HttpOptions { +export interface GithubBaseHttpOptions extends HttpOptions { + repository?: string; +} + +export interface GithubHttpOptions extends GithubBaseHttpOptions { paginate?: boolean | string; paginationField?: string; pageLimit?: number; - repository?: string; } interface GithubGraphqlRepoData<T = unknown> { @@ -58,7 +64,7 @@ export type GithubGraphqlResponse<T = unknown> = function handleGotError( err: GotLegacyError, url: string | URL, - opts: GithubHttpOptions, + opts: GithubBaseHttpOptions, ): Error { const path = url.toString(); let message = err.message || ''; @@ -267,18 +273,18 @@ function replaceUrlBase(url: URL, baseUrl: string): URL { return new URL(relativeUrl, baseUrl); } -export class GithubHttp extends Http<GithubHttpOptions> { +export class GithubHttp extends HttpBase<GithubHttpOptions> { protected override get baseUrl(): string | undefined { return baseUrl; } - constructor(hostType = 'github', options?: GithubHttpOptions) { + constructor(hostType = 'github', options?: HttpOptions) { super(hostType, options); } protected override processOptions( url: URL, - opts: InternalHttpOptions & GithubHttpOptions, + opts: InternalHttpOptions & GithubBaseHttpOptions, ): void { if (!opts.token) { const authUrl = new URL(url); @@ -415,7 +421,7 @@ export class GithubHttp extends Http<GithubHttpOptions> { } const body = variables ? { query, variables } : { query }; - const opts: GithubHttpOptions = { + const opts: GithubBaseHttpOptions = { baseUrl: baseUrl.replace('/v3/', '/'), // GHE uses unversioned graphql path body, headers: { accept: options?.acceptHeader }, @@ -524,9 +530,9 @@ export class GithubHttp extends Http<GithubHttpOptions> { */ public async getRawTextFile( url: string, - options: InternalHttpOptions & GithubHttpOptions = {}, + options: InternalHttpOptions & GithubBaseHttpOptions = {}, ): Promise<HttpResponse> { - const newOptions: InternalHttpOptions & GithubHttpOptions = { + const newOptions: InternalHttpOptions & GithubBaseHttpOptions = { ...options, headers: { accept: 'application/vnd.github.raw+json', diff --git a/lib/util/http/gitlab.ts b/lib/util/http/gitlab.ts index 4bdda9dd7ee21dc4dbf55969726d390ede098a92..8f9826c44402cf02b795e10b2a7d1a2137420406 100644 --- a/lib/util/http/gitlab.ts +++ b/lib/util/http/gitlab.ts @@ -3,9 +3,8 @@ import { RequestError, type RetryObject } from 'got'; import { logger } from '../../logger'; import { ExternalHostError } from '../../types/errors/external-host-error'; import { parseLinkHeader, parseUrl } from '../url'; -import type { InternalJsonUnsafeOptions } from './http'; +import { HttpBase, type InternalJsonUnsafeOptions } from './http'; import type { HttpMethod, HttpOptions, HttpResponse } from './types'; -import { Http } from '.'; let baseUrl = 'https://gitlab.com/api/v4/'; export const setBaseUrl = (url: string): void => { @@ -16,7 +15,7 @@ export interface GitlabHttpOptions extends HttpOptions { paginate?: boolean; } -export class GitlabHttp extends Http<GitlabHttpOptions> { +export class GitlabHttp extends HttpBase<GitlabHttpOptions> { protected override get baseUrl(): string | undefined { return baseUrl; } diff --git a/lib/util/http/got.ts b/lib/util/http/got.ts new file mode 100644 index 0000000000000000000000000000000000000000..34a6cf3c86c30974c2dd2b21bb41164ab969ffd7 --- /dev/null +++ b/lib/util/http/got.ts @@ -0,0 +1,64 @@ +// TODO: refactor code to remove this (#9651) +import './legacy'; + +import type { Options } from 'got'; +import got, { RequestError } from 'got'; +import type { SetRequired } from 'type-fest'; +import { logger } from '../../logger'; +import { coerceNumber } from '../number'; +import { type HttpRequestStatsDataPoint, HttpStats } from '../stats'; +import { coerceString } from '../string'; +import { hooks } from './hooks'; +import type { GotBufferOptions, GotOptions, HttpResponse } from './types'; + +export { RequestError } from 'got'; + +type QueueStatsData = Pick<HttpRequestStatsDataPoint, 'queueMs'>; + +export async function fetch( + url: string, + options: SetRequired<GotOptions, 'method'>, + queueStats: QueueStatsData, +): Promise<HttpResponse<unknown>> { + logger.trace({ url, options }, 'got request'); + + let duration = 0; + let statusCode = 0; + try { + // Cheat the TS compiler using `as` to pick a specific overload. + // Otherwise it doesn't typecheck. + const resp = await got(url, { ...options, hooks } as GotBufferOptions); + statusCode = resp.statusCode; + duration = coerceNumber(resp.timings.phases.total, 0); + return resp; + } catch (error) { + if (error instanceof RequestError) { + statusCode = coerceNumber(error.response?.statusCode, -1); + duration = coerceNumber(error.timings?.phases.total, -1); + const method = options.method.toUpperCase(); + const code = coerceString(error.code, 'UNKNOWN'); + const retryCount = coerceNumber(error.request?.retryCount, -1); + logger.debug( + `${method} ${url} = (code=${code}, statusCode=${statusCode} retryCount=${retryCount}, duration=${duration})`, + ); + } + + throw error; + /* v8 ignore next: 🐛 https://github.com/bcoe/c8/issues/229 */ + } finally { + HttpStats.write({ + method: options.method, + url, + reqMs: duration, + queueMs: queueStats.queueMs, + status: statusCode, + }); + } +} + +export function stream( + url: string, + options: Omit<Options, 'isStream'>, +): NodeJS.ReadableStream { + return got.stream(url, options); +} diff --git a/lib/util/http/http.ts b/lib/util/http/http.ts index 589bce68527dbedcfbe0a727e8078d371e32408b..c9c32d03f1853e97706f8703a651345c671d6d48 100644 --- a/lib/util/http/http.ts +++ b/lib/util/http/http.ts @@ -1,7 +1,36 @@ -import type { Options } from 'got'; -import type { SetRequired } from 'type-fest'; -import type { ZodType } from 'zod'; -import type { GotOptions, HttpMethod, HttpOptions } from './types'; +import is from '@sindresorhus/is'; +import merge from 'deepmerge'; +import type { Options, RetryObject } from 'got'; +import type { Merge, SetRequired } from 'type-fest'; +import type { infer as Infer } from 'zod'; +import { ZodType } from 'zod'; +import { GlobalConfig } from '../../config/global'; +import { HOST_DISABLED } from '../../constants/error-messages'; +import { pkg } from '../../expose.cjs'; +import { logger } from '../../logger'; +import { ExternalHostError } from '../../types/errors/external-host-error'; +import * as memCache from '../cache/memory'; +import { hash } from '../hash'; +import { type AsyncResult, Result } from '../result'; +import { isHttpUrl, parseUrl, resolveBaseUrl } from '../url'; +import { parseSingleYaml } from '../yaml'; +import { applyAuthorization, removeAuthorization } from './auth'; +import { fetch, stream } from './got'; +import { applyHostRule, findMatchingRule } from './host-rules'; + +import { getQueue } from './queue'; +import { getRetryAfter, wrapWithRetry } from './retry-after'; +import { getThrottle } from './throttle'; +import type { + GotOptions, + GotStreamOptions, + GotTask, + HttpMethod, + HttpOptions, + HttpResponse, + SafeJsonError, +} from './types'; +import { copyResponse } from './util'; export interface InternalJsonUnsafeOptions< Opts extends HttpOptions = HttpOptions, @@ -27,3 +56,591 @@ export interface InternalHttpOptions extends HttpOptions { parseJson?: Options['parseJson']; } + +export function applyDefaultHeaders(options: Options): void { + const renovateVersion = pkg.version; + options.headers = { + ...options.headers, + 'user-agent': + GlobalConfig.get('userAgent') ?? + `RenovateBot/${renovateVersion} (https://github.com/renovatebot/renovate)`, + }; +} + +export abstract class HttpBase< + JSONOpts extends HttpOptions = HttpOptions, + Opts extends HttpOptions = HttpOptions, +> { + private readonly options: InternalGotOptions; + + protected get baseUrl(): string | undefined { + return undefined; + } + + constructor( + protected hostType: string, + options: HttpOptions = {}, + ) { + const retryLimit = process.env.NODE_ENV === 'test' ? 0 : 2; + this.options = merge<InternalGotOptions>( + options, + { + method: 'get', + context: { hostType }, + retry: { + calculateDelay: (retryObject) => + this.calculateRetryDelay(retryObject), + limit: retryLimit, + maxRetryAfter: 0, // Don't rely on `got` retry-after handling, just let it fail and then we'll handle it + }, + }, + { isMergeableObject: is.plainObject }, + ); + } + private async request( + requestUrl: string | URL, + httpOptions: InternalHttpOptions, + ): Promise<HttpResponse<string>>; + private async request( + requestUrl: string | URL, + httpOptions: InternalHttpOptions & { responseType: 'text' }, + ): Promise<HttpResponse<string>>; + private async request( + requestUrl: string | URL, + httpOptions: InternalHttpOptions & { responseType: 'buffer' }, + ): Promise<HttpResponse<Buffer>>; + private async request<T = unknown>( + requestUrl: string | URL, + httpOptions: InternalHttpOptions & { responseType: 'json' }, + ): Promise<HttpResponse<T>>; + + private async request( + requestUrl: string | URL, + httpOptions: InternalHttpOptions, + ): Promise<HttpResponse<unknown>> { + const resolvedUrl = this.resolveUrl(requestUrl, httpOptions); + const url = resolvedUrl.toString(); + + this.processOptions(resolvedUrl, httpOptions); + + let options = merge<InternalGotOptions, InternalHttpOptions>( + { + ...this.options, + hostType: this.hostType, + }, + httpOptions, + { isMergeableObject: is.plainObject }, + ); + + logger.trace(`HTTP request: ${options.method.toUpperCase()} ${url}`); + + options.hooks = { + beforeRedirect: [removeAuthorization], + }; + + applyDefaultHeaders(options); + + if ( + is.undefined(options.readOnly) && + ['head', 'get'].includes(options.method) + ) { + options.readOnly = true; + } + + const hostRule = findMatchingRule(url, options); + options = applyHostRule(url, options, hostRule); + if (options.enabled === false) { + logger.debug(`Host is disabled - rejecting request. HostUrl: ${url}`); + throw new Error(HOST_DISABLED); + } + options = applyAuthorization(options); + options.timeout ??= 60000; + + const { cacheProvider } = options; + const cachedResponse = await cacheProvider?.bypassServer<string | Buffer>( + url, + ); + if (cachedResponse) { + return cachedResponse; + } + + const memCacheKey = + options.memCache !== false && + (options.method === 'get' || options.method === 'head') + ? hash( + `got-${JSON.stringify({ + url, + headers: options.headers, + method: options.method, + })}`, + ) + : null; + + let resPromise: Promise<HttpResponse<unknown>> | null = null; + + // Cache GET requests unless memCache=false + if (memCacheKey) { + resPromise = memCache.get(memCacheKey); + } + + if (!resPromise) { + if (cacheProvider) { + await cacheProvider.setCacheHeaders(url, options); + } + + const startTime = Date.now(); + const httpTask: GotTask = () => { + const queueMs = Date.now() - startTime; + return fetch(url, options, { queueMs }); + }; + + const throttle = getThrottle(url); + const throttledTask = throttle ? () => throttle.add(httpTask) : httpTask; + + const queue = getQueue(url); + const queuedTask = queue ? () => queue.add(throttledTask) : throttledTask; + + const { maxRetryAfter = 60 } = hostRule; + resPromise = wrapWithRetry(queuedTask, url, getRetryAfter, maxRetryAfter); + + if (memCacheKey) { + memCache.set(memCacheKey, resPromise); + } + } + + try { + const res = await resPromise; + const deepCopyNeeded = !!memCacheKey && res.statusCode !== 304; + const resCopy = copyResponse(res, deepCopyNeeded); + resCopy.authorization = !!options?.headers?.authorization; + + if (cacheProvider) { + return await cacheProvider.wrapServerResponse(url, resCopy); + } + + return resCopy; + } catch (err) { + const { abortOnError, abortIgnoreStatusCodes } = options; + if (abortOnError && !abortIgnoreStatusCodes?.includes(err.statusCode)) { + throw new ExternalHostError(err); + } + + const staleResponse = await cacheProvider?.bypassServer<string | Buffer>( + url, + true, + ); + if (staleResponse) { + logger.debug( + { err }, + `Request error: returning stale cache instead for ${url}`, + ); + return staleResponse; + } + + this.handleError(requestUrl, httpOptions, err); + } + } + + protected processOptions(_url: URL, _options: InternalHttpOptions): void { + // noop + } + + protected handleError( + _url: string | URL, + _httpOptions: HttpOptions, + err: Error, + ): never { + throw err; + } + + protected resolveUrl( + requestUrl: string | URL, + options: HttpOptions | undefined, + ): URL { + let url = requestUrl; + + if (url instanceof URL) { + // already a aboslute URL + return url; + } + + const baseUrl = options?.baseUrl ?? this.baseUrl; + if (baseUrl) { + url = resolveBaseUrl(baseUrl, url); + } + + const parsedUrl = parseUrl(url); + if (!parsedUrl || !isHttpUrl(parsedUrl)) { + logger.error({ url: requestUrl }, 'Request Error: cannot parse url'); + throw new Error('Invalid URL'); + } + return parsedUrl; + } + + protected calculateRetryDelay({ computedValue }: RetryObject): number { + return computedValue; + } + + get( + url: string, + options: HttpOptions = {}, + ): Promise<HttpResponse<string | Buffer>> { + return this.request(url, options); + } + + head(url: string, options: HttpOptions = {}): Promise<HttpResponse<never>> { + // to complex to validate + return this.request(url, { + ...options, + responseType: 'text', + method: 'head', + }) as Promise<HttpResponse<never>>; + } + + getText( + url: string | URL, + options: HttpOptions = {}, + ): Promise<HttpResponse<string>> { + return this.request(url, { ...options, responseType: 'text' }); + } + + getBuffer( + url: string | URL, + options: HttpOptions = {}, + ): Promise<HttpResponse<Buffer>> { + return this.request(url, { ...options, responseType: 'buffer' }); + } + + protected requestJsonUnsafe<ResT>( + method: HttpMethod, + { url, httpOptions: requestOptions }: InternalJsonUnsafeOptions<JSONOpts>, + ): Promise<HttpResponse<ResT>> { + const { body: json, ...httpOptions } = { ...requestOptions }; + const opts: InternalHttpOptions = { + ...httpOptions, + method, + }; + // signal that we expect a json response + opts.headers = { + accept: 'application/json', + ...opts.headers, + }; + if (json) { + opts.json = json; + } + return this.request<ResT>(url, { ...opts, responseType: 'json' }); + } + + private async requestJson<ResT, Schema extends ZodType<ResT> = ZodType<ResT>>( + method: HttpMethod, + options: InternalJsonOptions<JSONOpts, ResT, Schema>, + ): Promise<HttpResponse<ResT>> { + const res = await this.requestJsonUnsafe<ResT>(method, options); + + if (options.schema) { + res.body = await options.schema.parseAsync(res.body); + } + + return res; + } + + private resolveArgs<ResT = unknown>( + arg1: string, + arg2: JSONOpts | ZodType<ResT> | undefined, + arg3: ZodType<ResT> | undefined, + ): InternalJsonOptions<JSONOpts, ResT> { + const res: InternalJsonOptions<JSONOpts, ResT> = { url: arg1 }; + + if (arg2 instanceof ZodType) { + res.schema = arg2; + } else if (arg2) { + res.httpOptions = arg2; + } + + if (arg3) { + res.schema = arg3; + } + + return res; + } + + async getPlain(url: string, options?: Opts): Promise<HttpResponse> { + const opt = options ?? {}; + return await this.getText(url, { + headers: { + Accept: 'text/plain', + }, + ...opt, + }); + } + + /** + * @deprecated use `getYaml` instead + */ + async getYamlUnchecked<ResT>( + url: string, + options?: Opts, + ): Promise<HttpResponse<ResT>> { + const res = await this.getText(url, options); + const body = parseSingleYaml<ResT>(res.body); + return { ...res, body }; + } + + async getYaml<Schema extends ZodType<any, any, any>>( + url: string, + schema: Schema, + ): Promise<HttpResponse<Infer<Schema>>>; + async getYaml<Schema extends ZodType<any, any, any>>( + url: string, + options: Opts, + schema: Schema, + ): Promise<HttpResponse<Infer<Schema>>>; + async getYaml<Schema extends ZodType<any, any, any>>( + arg1: string, + arg2?: Opts | Schema, + arg3?: Schema, + ): Promise<HttpResponse<Infer<Schema>>> { + const url = arg1; + let schema: Schema; + let httpOptions: Opts | undefined; + if (arg3) { + schema = arg3; + httpOptions = arg2 as Opts; + } else { + schema = arg2 as Schema; + } + + const opts: InternalHttpOptions = { + ...httpOptions, + method: 'get', + }; + + const res = await this.getText(url, opts); + const body = await schema.parseAsync(parseSingleYaml(res.body)); + return { ...res, body }; + } + + getYamlSafe< + ResT extends NonNullable<unknown>, + Schema extends ZodType<ResT> = ZodType<ResT>, + >(url: string, schema: Schema): AsyncResult<Infer<Schema>, SafeJsonError>; + getYamlSafe< + ResT extends NonNullable<unknown>, + Schema extends ZodType<ResT> = ZodType<ResT>, + >( + url: string, + options: Opts, + schema: Schema, + ): AsyncResult<Infer<Schema>, SafeJsonError>; + getYamlSafe< + ResT extends NonNullable<unknown>, + Schema extends ZodType<ResT> = ZodType<ResT>, + >( + arg1: string, + arg2: Opts | Schema, + arg3?: Schema, + ): AsyncResult<ResT, SafeJsonError> { + const url = arg1; + let schema: Schema; + let httpOptions: Opts | undefined; + if (arg3) { + schema = arg3; + httpOptions = arg2 as Opts; + } else { + schema = arg2 as Schema; + } + + let res: AsyncResult<HttpResponse<ResT>, SafeJsonError>; + if (httpOptions) { + res = Result.wrap(this.getYaml(url, httpOptions, schema)); + } else { + res = Result.wrap(this.getYaml(url, schema)); + } + + return res.transform((response) => Result.ok(response.body)); + } + + /** + * Request JSON and return the response without any validation. + * + * The usage of this method is discouraged, please use `getJson` instead. + * + * If you're new to Zod schema validation library: + * - consult the [documentation of Zod library](https://github.com/colinhacks/zod?tab=readme-ov-file#basic-usage) + * - search the Renovate codebase for 'zod' module usage + * - take a look at the `schema-utils.ts` file for Renovate-specific schemas and utilities + */ + getJsonUnchecked<ResT = unknown>( + url: string, + options?: JSONOpts, + ): Promise<HttpResponse<ResT>> { + return this.requestJson<ResT>('get', { url, httpOptions: options }); + } + + /** + * Request JSON with a Zod schema for the response, + * throwing an error if the response is not valid. + * + * @param url + * @param schema Zod schema for the response + */ + getJson<Schema extends ZodType<any, any, any>>( + url: string, + schema: Schema, + ): Promise<HttpResponse<Infer<Schema>>>; + getJson<Schema extends ZodType<any, any, any>>( + url: string, + options: JSONOpts, + schema: Schema, + ): Promise<HttpResponse<Infer<Schema>>>; + getJson<Schema extends ZodType<any, any, any>>( + arg1: string, + arg2?: JSONOpts | Schema, + arg3?: Schema, + ): Promise<HttpResponse<Infer<Schema>>> { + const args = this.resolveArgs<Infer<Schema>>(arg1, arg2, arg3); + return this.requestJson<Infer<Schema>>('get', args); + } + + /** + * Request JSON with a Zod schema for the response, + * wrapping response data in a `Result` class. + * + * @param url + * @param schema Zod schema for the response + */ + getJsonSafe<ResT extends NonNullable<unknown>, Schema extends ZodType<ResT>>( + url: string, + schema: Schema, + ): AsyncResult<Infer<Schema>, SafeJsonError>; + getJsonSafe<ResT extends NonNullable<unknown>, Schema extends ZodType<ResT>>( + url: string, + options: JSONOpts, + schema: Schema, + ): AsyncResult<Infer<Schema>, SafeJsonError>; + getJsonSafe<ResT extends NonNullable<unknown>, Schema extends ZodType<ResT>>( + arg1: string, + arg2?: JSONOpts | Schema, + arg3?: Schema, + ): AsyncResult<ResT, SafeJsonError> { + const args = this.resolveArgs<ResT>(arg1, arg2, arg3); + return Result.wrap(this.requestJson<ResT>('get', args)).transform( + (response) => Result.ok(response.body), + ); + } + + /** + * @deprecated use `head` instead + */ + headJson(url: string, httpOptions?: JSONOpts): Promise<HttpResponse<never>> { + return this.requestJson<never>('head', { url, httpOptions }); + } + + postJson<T>(url: string, options?: JSONOpts): Promise<HttpResponse<T>>; + postJson<T, Schema extends ZodType<T> = ZodType<T>>( + url: string, + schema: Schema, + ): Promise<HttpResponse<Infer<Schema>>>; + postJson<T, Schema extends ZodType<T> = ZodType<T>>( + url: string, + options: JSONOpts, + schema: Schema, + ): Promise<HttpResponse<Infer<Schema>>>; + postJson<T = unknown, Schema extends ZodType<T> = ZodType<T>>( + arg1: string, + arg2?: JSONOpts | Schema, + arg3?: Schema, + ): Promise<HttpResponse<T>> { + const args = this.resolveArgs(arg1, arg2, arg3); + return this.requestJson<T>('post', args); + } + + putJson<T>(url: string, options?: JSONOpts): Promise<HttpResponse<T>>; + putJson<T, Schema extends ZodType<T> = ZodType<T>>( + url: string, + schema: Schema, + ): Promise<HttpResponse<Infer<Schema>>>; + putJson<T, Schema extends ZodType<T> = ZodType<T>>( + url: string, + options: JSONOpts, + schema: Schema, + ): Promise<HttpResponse<Infer<Schema>>>; + putJson<T = unknown, Schema extends ZodType<T> = ZodType<T>>( + arg1: string, + arg2?: JSONOpts | Schema, + arg3?: ZodType, + ): Promise<HttpResponse<T>> { + const args = this.resolveArgs(arg1, arg2, arg3); + return this.requestJson<T>('put', args); + } + + patchJson<T>(url: string, options?: JSONOpts): Promise<HttpResponse<T>>; + patchJson<T, Schema extends ZodType<T> = ZodType<T>>( + url: string, + schema: Schema, + ): Promise<HttpResponse<Infer<Schema>>>; + patchJson<T, Schema extends ZodType<T> = ZodType<T>>( + url: string, + options: JSONOpts, + schema: Schema, + ): Promise<HttpResponse<Infer<Schema>>>; + patchJson<T = unknown, Schema extends ZodType<T> = ZodType<T>>( + arg1: string, + arg2?: JSONOpts | Schema, + arg3?: Schema, + ): Promise<HttpResponse<T>> { + const args = this.resolveArgs(arg1, arg2, arg3); + return this.requestJson<T>('patch', args); + } + + deleteJson<T>(url: string, options?: JSONOpts): Promise<HttpResponse<T>>; + deleteJson<T, Schema extends ZodType<T> = ZodType<T>>( + url: string, + schema: Schema, + ): Promise<HttpResponse<Infer<Schema>>>; + deleteJson<T, Schema extends ZodType<T> = ZodType<T>>( + url: string, + options: JSONOpts, + schema: Schema, + ): Promise<HttpResponse<Infer<Schema>>>; + deleteJson<T = unknown, Schema extends ZodType<T> = ZodType<T>>( + arg1: string, + arg2?: JSONOpts | Schema, + arg3?: Schema, + ): Promise<HttpResponse<T>> { + const args = this.resolveArgs(arg1, arg2, arg3); + return this.requestJson<T>('delete', args); + } + + stream(url: string, options?: HttpOptions): NodeJS.ReadableStream { + let combinedOptions: Merge< + GotStreamOptions, + SetRequired<InternalHttpOptions, 'method'> + > = { + ...this.options, + hostType: this.hostType, + ...options, + method: 'get', + }; + + const resolvedUrl = this.resolveUrl(url, options).toString(); + + applyDefaultHeaders(combinedOptions); + + if ( + is.undefined(combinedOptions.readOnly) && + ['head', 'get'].includes(combinedOptions.method) + ) { + combinedOptions.readOnly = true; + } + + const hostRule = findMatchingRule(url, combinedOptions); + combinedOptions = applyHostRule(resolvedUrl, combinedOptions, hostRule); + if (combinedOptions.enabled === false) { + throw new Error(HOST_DISABLED); + } + combinedOptions = applyAuthorization(combinedOptions); + + return stream(resolvedUrl, combinedOptions); + } +} diff --git a/lib/util/http/index.ts b/lib/util/http/index.ts index 6cd54196705b703388f95732f0816c929d7c6e0e..ab06e48a38ecc2996144e907c66bf177182647bd 100644 --- a/lib/util/http/index.ts +++ b/lib/util/http/index.ts @@ -1,671 +1,9 @@ -// TODO: refactor code to remove this (#9651) -import './legacy'; +import { EmptyResultError } from './errors'; +import { RequestError } from './got'; +import { HttpBase } from './http'; -import is from '@sindresorhus/is'; -import merge from 'deepmerge'; -import type { Options, RetryObject } from 'got'; -import got, { RequestError } from 'got'; -import type { SetRequired } from 'type-fest'; -import type { infer as Infer, ZodError } from 'zod'; -import { ZodType } from 'zod'; -import { GlobalConfig } from '../../config/global'; -import { HOST_DISABLED } from '../../constants/error-messages'; -import { pkg } from '../../expose.cjs'; -import { logger } from '../../logger'; -import { ExternalHostError } from '../../types/errors/external-host-error'; -import * as memCache from '../cache/memory'; -import { hash } from '../hash'; -import { type AsyncResult, Result } from '../result'; -import { type HttpRequestStatsDataPoint, HttpStats } from '../stats'; -import { isHttpUrl, parseUrl, resolveBaseUrl } from '../url'; -import { parseSingleYaml } from '../yaml'; -import { applyAuthorization, removeAuthorization } from './auth'; -import { hooks } from './hooks'; -import { applyHostRule, findMatchingRule } from './host-rules'; -import type { - InternalGotOptions, - InternalHttpOptions, - InternalJsonOptions, - InternalJsonUnsafeOptions, -} from './http'; -import { getQueue } from './queue'; -import { getRetryAfter, wrapWithRetry } from './retry-after'; -import { getThrottle } from './throttle'; -import type { - GotBufferOptions, - GotOptions, - GotTask, - HttpMethod, - HttpOptions, - HttpResponse, -} from './types'; -import { copyResponse } from './util'; +export { RequestError as HttpError, EmptyResultError }; -export { RequestError as HttpError }; +export type * from './types'; -export class EmptyResultError extends Error {} -export type SafeJsonError = RequestError | ZodError | EmptyResultError; - -function applyDefaultHeaders(options: Options): void { - const renovateVersion = pkg.version; - options.headers = { - ...options.headers, - 'user-agent': - GlobalConfig.get('userAgent') ?? - `RenovateBot/${renovateVersion} (https://github.com/renovatebot/renovate)`, - }; -} - -type QueueStatsData = Pick<HttpRequestStatsDataPoint, 'queueMs'>; - -async function gotTask( - url: string, - options: SetRequired<GotOptions, 'method'>, - queueStats: QueueStatsData, -): Promise<HttpResponse<unknown>> { - logger.trace({ url, options }, 'got request'); - - let duration = 0; - let statusCode = 0; - try { - // Cheat the TS compiler using `as` to pick a specific overload. - // Otherwise it doesn't typecheck. - const resp = await got(url, { ...options, hooks } as GotBufferOptions); - statusCode = resp.statusCode; - duration = - resp.timings.phases.total ?? /* v8 ignore next: can't be tested */ 0; - return resp; - } catch (error) { - if (error instanceof RequestError) { - statusCode = error.response?.statusCode ?? -1; - duration = - error.timings?.phases.total ?? /* v8 ignore next: can't be tested */ -1; - const method = options.method.toUpperCase(); - const code = error.code ?? /* v8 ignore next */ 'UNKNOWN'; - const retryCount = error.request?.retryCount ?? /* v8 ignore next */ -1; - logger.debug( - `${method} ${url} = (code=${code}, statusCode=${statusCode} retryCount=${retryCount}, duration=${duration})`, - ); - } - - throw error; - /* v8 ignore next: 🐛 https://github.com/bcoe/c8/issues/229 */ - } finally { - HttpStats.write({ - method: options.method, - url, - reqMs: duration, - queueMs: queueStats.queueMs, - status: statusCode, - }); - } -} - -export class Http<Opts extends HttpOptions = HttpOptions> { - private readonly options: InternalGotOptions; - - protected get baseUrl(): string | undefined { - return undefined; - } - - constructor( - protected hostType: string, - options: HttpOptions = {}, - ) { - const retryLimit = process.env.NODE_ENV === 'test' ? 0 : 2; - this.options = merge<InternalGotOptions>( - options, - { - method: 'get', - context: { hostType }, - retry: { - calculateDelay: (retryObject) => - this.calculateRetryDelay(retryObject), - limit: retryLimit, - maxRetryAfter: 0, // Don't rely on `got` retry-after handling, just let it fail and then we'll handle it - }, - }, - { isMergeableObject: is.plainObject }, - ); - } - private async request( - requestUrl: string | URL, - httpOptions: InternalHttpOptions, - ): Promise<HttpResponse<string>>; - private async request( - requestUrl: string | URL, - httpOptions: InternalHttpOptions & { responseType: 'text' }, - ): Promise<HttpResponse<string>>; - private async request( - requestUrl: string | URL, - httpOptions: InternalHttpOptions & { responseType: 'buffer' }, - ): Promise<HttpResponse<Buffer>>; - private async request<T = unknown>( - requestUrl: string | URL, - httpOptions: InternalHttpOptions & { responseType: 'json' }, - ): Promise<HttpResponse<T>>; - - private async request( - requestUrl: string | URL, - httpOptions: InternalHttpOptions, - ): Promise<HttpResponse<unknown>> { - const resolvedUrl = this.resolveUrl(requestUrl, httpOptions); - const url = resolvedUrl.toString(); - - this.processOptions(resolvedUrl, httpOptions); - - let options = merge<InternalGotOptions, InternalHttpOptions>( - { - ...this.options, - hostType: this.hostType, - }, - httpOptions, - { isMergeableObject: is.plainObject }, - ); - - logger.trace(`HTTP request: ${options.method.toUpperCase()} ${url}`); - - options.hooks = { - beforeRedirect: [removeAuthorization], - }; - - applyDefaultHeaders(options); - - if ( - is.undefined(options.readOnly) && - ['head', 'get'].includes(options.method) - ) { - options.readOnly = true; - } - - const hostRule = findMatchingRule(url, options); - options = applyHostRule(url, options, hostRule); - if (options.enabled === false) { - logger.debug(`Host is disabled - rejecting request. HostUrl: ${url}`); - throw new Error(HOST_DISABLED); - } - options = applyAuthorization(options); - options.timeout ??= 60000; - - const { cacheProvider } = options; - const cachedResponse = await cacheProvider?.bypassServer<string | Buffer>( - url, - ); - if (cachedResponse) { - return cachedResponse; - } - - const memCacheKey = - options.memCache !== false && - (options.method === 'get' || options.method === 'head') - ? hash( - `got-${JSON.stringify({ - url, - headers: options.headers, - method: options.method, - })}`, - ) - : null; - - let resPromise: Promise<HttpResponse<unknown>> | null = null; - - // Cache GET requests unless memCache=false - if (memCacheKey) { - resPromise = memCache.get(memCacheKey); - } - - if (!resPromise) { - if (cacheProvider) { - await cacheProvider.setCacheHeaders(url, options); - } - - const startTime = Date.now(); - const httpTask: GotTask = () => { - const queueMs = Date.now() - startTime; - return gotTask(url, options, { queueMs }); - }; - - const throttle = getThrottle(url); - const throttledTask = throttle ? () => throttle.add(httpTask) : httpTask; - - const queue = getQueue(url); - const queuedTask = queue ? () => queue.add(throttledTask) : throttledTask; - - const { maxRetryAfter = 60 } = hostRule; - resPromise = wrapWithRetry(queuedTask, url, getRetryAfter, maxRetryAfter); - - if (memCacheKey) { - memCache.set(memCacheKey, resPromise); - } - } - - try { - const res = await resPromise; - const deepCopyNeeded = !!memCacheKey && res.statusCode !== 304; - const resCopy = copyResponse(res, deepCopyNeeded); - resCopy.authorization = !!options?.headers?.authorization; - - if (cacheProvider) { - return await cacheProvider.wrapServerResponse(url, resCopy); - } - - return resCopy; - } catch (err) { - const { abortOnError, abortIgnoreStatusCodes } = options; - if (abortOnError && !abortIgnoreStatusCodes?.includes(err.statusCode)) { - throw new ExternalHostError(err); - } - - const staleResponse = await cacheProvider?.bypassServer<string | Buffer>( - url, - true, - ); - if (staleResponse) { - logger.debug( - { err }, - `Request error: returning stale cache instead for ${url}`, - ); - return staleResponse; - } - - this.handleError(requestUrl, httpOptions, err); - } - } - - protected processOptions(_url: URL, _options: InternalHttpOptions): void { - // noop - } - - protected handleError( - _url: string | URL, - _httpOptions: HttpOptions, - err: Error, - ): never { - throw err; - } - - protected resolveUrl( - requestUrl: string | URL, - options: HttpOptions | undefined, - ): URL { - let url = requestUrl; - - if (url instanceof URL) { - // already a aboslute URL - return url; - } - - const baseUrl = options?.baseUrl ?? this.baseUrl; - if (baseUrl) { - url = resolveBaseUrl(baseUrl, url); - } - - const parsedUrl = parseUrl(url); - if (!parsedUrl || !isHttpUrl(parsedUrl)) { - logger.error({ url: requestUrl }, 'Request Error: cannot parse url'); - throw new Error('Invalid URL'); - } - return parsedUrl; - } - - protected calculateRetryDelay({ computedValue }: RetryObject): number { - return computedValue; - } - - get( - url: string, - options: HttpOptions = {}, - ): Promise<HttpResponse<string | Buffer>> { - return this.request(url, options); - } - - head(url: string, options: HttpOptions = {}): Promise<HttpResponse<never>> { - // to complex to validate - return this.request(url, { - ...options, - responseType: 'text', - method: 'head', - }) as Promise<HttpResponse<never>>; - } - - getText( - url: string | URL, - options: HttpOptions = {}, - ): Promise<HttpResponse<string>> { - return this.request(url, { ...options, responseType: 'text' }); - } - - getBuffer( - url: string | URL, - options: HttpOptions = {}, - ): Promise<HttpResponse<Buffer>> { - return this.request(url, { ...options, responseType: 'buffer' }); - } - - protected requestJsonUnsafe<ResT>( - method: HttpMethod, - { url, httpOptions: requestOptions }: InternalJsonUnsafeOptions<Opts>, - ): Promise<HttpResponse<ResT>> { - const { body: json, ...httpOptions } = { ...requestOptions }; - const opts: InternalHttpOptions = { - ...httpOptions, - method, - }; - // signal that we expect a json response - opts.headers = { - accept: 'application/json', - ...opts.headers, - }; - if (json) { - opts.json = json; - } - return this.request<ResT>(url, { ...opts, responseType: 'json' }); - } - - private async requestJson<ResT, Schema extends ZodType<ResT> = ZodType<ResT>>( - method: HttpMethod, - options: InternalJsonOptions<Opts, ResT, Schema>, - ): Promise<HttpResponse<ResT>> { - const res = await this.requestJsonUnsafe<ResT>(method, options); - - if (options.schema) { - res.body = await options.schema.parseAsync(res.body); - } - - return res; - } - - private resolveArgs<ResT = unknown>( - arg1: string, - arg2: Opts | ZodType<ResT> | undefined, - arg3: ZodType<ResT> | undefined, - ): InternalJsonOptions<Opts, ResT> { - const res: InternalJsonOptions<Opts, ResT> = { url: arg1 }; - - if (arg2 instanceof ZodType) { - res.schema = arg2; - } else if (arg2) { - res.httpOptions = arg2; - } - - if (arg3) { - res.schema = arg3; - } - - return res; - } - - async getPlain(url: string, options?: Opts): Promise<HttpResponse> { - const opt = options ?? {}; - return await this.getText(url, { - headers: { - Accept: 'text/plain', - }, - ...opt, - }); - } - - /** - * @deprecated use `getYaml` instead - */ - async getYamlUnchecked<ResT>( - url: string, - options?: Opts, - ): Promise<HttpResponse<ResT>> { - const res = await this.getText(url, options); - const body = parseSingleYaml<ResT>(res.body); - return { ...res, body }; - } - - async getYaml<Schema extends ZodType<any, any, any>>( - url: string, - schema: Schema, - ): Promise<HttpResponse<Infer<Schema>>>; - async getYaml<Schema extends ZodType<any, any, any>>( - url: string, - options: Opts, - schema: Schema, - ): Promise<HttpResponse<Infer<Schema>>>; - async getYaml<Schema extends ZodType<any, any, any>>( - arg1: string, - arg2?: Opts | Schema, - arg3?: Schema, - ): Promise<HttpResponse<Infer<Schema>>> { - const url = arg1; - let schema: Schema; - let httpOptions: Opts | undefined; - if (arg3) { - schema = arg3; - httpOptions = arg2 as Opts; - } else { - schema = arg2 as Schema; - } - - const opts: InternalHttpOptions = { - ...httpOptions, - method: 'get', - }; - - const res = await this.getText(url, opts); - const body = await schema.parseAsync(parseSingleYaml(res.body)); - return { ...res, body }; - } - - getYamlSafe< - ResT extends NonNullable<unknown>, - Schema extends ZodType<ResT> = ZodType<ResT>, - >(url: string, schema: Schema): AsyncResult<Infer<Schema>, SafeJsonError>; - getYamlSafe< - ResT extends NonNullable<unknown>, - Schema extends ZodType<ResT> = ZodType<ResT>, - >( - url: string, - options: Opts, - schema: Schema, - ): AsyncResult<Infer<Schema>, SafeJsonError>; - getYamlSafe< - ResT extends NonNullable<unknown>, - Schema extends ZodType<ResT> = ZodType<ResT>, - >( - arg1: string, - arg2: Opts | Schema, - arg3?: Schema, - ): AsyncResult<ResT, SafeJsonError> { - const url = arg1; - let schema: Schema; - let httpOptions: Opts | undefined; - if (arg3) { - schema = arg3; - httpOptions = arg2 as Opts; - } else { - schema = arg2 as Schema; - } - - let res: AsyncResult<HttpResponse<ResT>, SafeJsonError>; - if (httpOptions) { - res = Result.wrap(this.getYaml(url, httpOptions, schema)); - } else { - res = Result.wrap(this.getYaml(url, schema)); - } - - return res.transform((response) => Result.ok(response.body)); - } - - /** - * Request JSON and return the response without any validation. - * - * The usage of this method is discouraged, please use `getJson` instead. - * - * If you're new to Zod schema validation library: - * - consult the [documentation of Zod library](https://github.com/colinhacks/zod?tab=readme-ov-file#basic-usage) - * - search the Renovate codebase for 'zod' module usage - * - take a look at the `schema-utils.ts` file for Renovate-specific schemas and utilities - */ - getJsonUnchecked<ResT = unknown>( - url: string, - options?: Opts, - ): Promise<HttpResponse<ResT>> { - return this.requestJson<ResT>('get', { url, httpOptions: options }); - } - - /** - * Request JSON with a Zod schema for the response, - * throwing an error if the response is not valid. - * - * @param url - * @param schema Zod schema for the response - */ - getJson<Schema extends ZodType<any, any, any>>( - url: string, - schema: Schema, - ): Promise<HttpResponse<Infer<Schema>>>; - getJson<Schema extends ZodType<any, any, any>>( - url: string, - options: Opts, - schema: Schema, - ): Promise<HttpResponse<Infer<Schema>>>; - getJson<Schema extends ZodType<any, any, any>>( - arg1: string, - arg2?: Opts | Schema, - arg3?: Schema, - ): Promise<HttpResponse<Infer<Schema>>> { - const args = this.resolveArgs<Infer<Schema>>(arg1, arg2, arg3); - return this.requestJson<Infer<Schema>>('get', args); - } - - /** - * Request JSON with a Zod schema for the response, - * wrapping response data in a `Result` class. - * - * @param url - * @param schema Zod schema for the response - */ - getJsonSafe<ResT extends NonNullable<unknown>, Schema extends ZodType<ResT>>( - url: string, - schema: Schema, - ): AsyncResult<Infer<Schema>, SafeJsonError>; - getJsonSafe<ResT extends NonNullable<unknown>, Schema extends ZodType<ResT>>( - url: string, - options: Opts, - schema: Schema, - ): AsyncResult<Infer<Schema>, SafeJsonError>; - getJsonSafe<ResT extends NonNullable<unknown>, Schema extends ZodType<ResT>>( - arg1: string, - arg2?: Opts | Schema, - arg3?: Schema, - ): AsyncResult<ResT, SafeJsonError> { - const args = this.resolveArgs<ResT>(arg1, arg2, arg3); - return Result.wrap(this.requestJson<ResT>('get', args)).transform( - (response) => Result.ok(response.body), - ); - } - - headJson(url: string, httpOptions?: Opts): Promise<HttpResponse<never>> { - return this.requestJson<never>('head', { url, httpOptions }); - } - - postJson<T>(url: string, options?: Opts): Promise<HttpResponse<T>>; - postJson<T, Schema extends ZodType<T> = ZodType<T>>( - url: string, - schema: Schema, - ): Promise<HttpResponse<Infer<Schema>>>; - postJson<T, Schema extends ZodType<T> = ZodType<T>>( - url: string, - options: Opts, - schema: Schema, - ): Promise<HttpResponse<Infer<Schema>>>; - postJson<T = unknown, Schema extends ZodType<T> = ZodType<T>>( - arg1: string, - arg2?: Opts | Schema, - arg3?: Schema, - ): Promise<HttpResponse<T>> { - const args = this.resolveArgs(arg1, arg2, arg3); - return this.requestJson<T>('post', args); - } - - putJson<T>(url: string, options?: Opts): Promise<HttpResponse<T>>; - putJson<T, Schema extends ZodType<T> = ZodType<T>>( - url: string, - schema: Schema, - ): Promise<HttpResponse<Infer<Schema>>>; - putJson<T, Schema extends ZodType<T> = ZodType<T>>( - url: string, - options: Opts, - schema: Schema, - ): Promise<HttpResponse<Infer<Schema>>>; - putJson<T = unknown, Schema extends ZodType<T> = ZodType<T>>( - arg1: string, - arg2?: Opts | Schema, - arg3?: ZodType, - ): Promise<HttpResponse<T>> { - const args = this.resolveArgs(arg1, arg2, arg3); - return this.requestJson<T>('put', args); - } - - patchJson<T>(url: string, options?: Opts): Promise<HttpResponse<T>>; - patchJson<T, Schema extends ZodType<T> = ZodType<T>>( - url: string, - schema: Schema, - ): Promise<HttpResponse<Infer<Schema>>>; - patchJson<T, Schema extends ZodType<T> = ZodType<T>>( - url: string, - options: Opts, - schema: Schema, - ): Promise<HttpResponse<Infer<Schema>>>; - patchJson<T = unknown, Schema extends ZodType<T> = ZodType<T>>( - arg1: string, - arg2?: Opts | Schema, - arg3?: Schema, - ): Promise<HttpResponse<T>> { - const args = this.resolveArgs(arg1, arg2, arg3); - return this.requestJson<T>('patch', args); - } - - deleteJson<T>(url: string, options?: Opts): Promise<HttpResponse<T>>; - deleteJson<T, Schema extends ZodType<T> = ZodType<T>>( - url: string, - schema: Schema, - ): Promise<HttpResponse<Infer<Schema>>>; - deleteJson<T, Schema extends ZodType<T> = ZodType<T>>( - url: string, - options: Opts, - schema: Schema, - ): Promise<HttpResponse<Infer<Schema>>>; - deleteJson<T = unknown, Schema extends ZodType<T> = ZodType<T>>( - arg1: string, - arg2?: Opts | Schema, - arg3?: Schema, - ): Promise<HttpResponse<T>> { - const args = this.resolveArgs(arg1, arg2, arg3); - return this.requestJson<T>('delete', args); - } - - stream(url: string, options?: HttpOptions): NodeJS.ReadableStream { - // TODO: fix types (#22198) - let combinedOptions: any = { - ...this.options, - hostType: this.hostType, - ...options, - }; - - const resolvedUrl = this.resolveUrl(url, options).toString(); - - applyDefaultHeaders(combinedOptions); - - if ( - is.undefined(combinedOptions.readOnly) && - ['head', 'get'].includes(combinedOptions.method) - ) { - combinedOptions.readOnly = true; - } - - const hostRule = findMatchingRule(url, combinedOptions); - combinedOptions = applyHostRule(resolvedUrl, combinedOptions, hostRule); - if (combinedOptions.enabled === false) { - throw new Error(HOST_DISABLED); - } - combinedOptions = applyAuthorization(combinedOptions); - - return got.stream(resolvedUrl, combinedOptions); - } -} +export class Http extends HttpBase {} diff --git a/lib/util/http/jira.ts b/lib/util/http/jira.ts index adfe8aa3d0322e12db5bc87e587e1023f6796d2f..4895540ad4d0674bf83ee9500845eee51d7ce398 100644 --- a/lib/util/http/jira.ts +++ b/lib/util/http/jira.ts @@ -1,5 +1,5 @@ +import { HttpBase } from './http'; import type { HttpOptions } from './types'; -import { Http } from '.'; let baseUrl: string; @@ -7,7 +7,7 @@ export function setBaseUrl(url: string): void { baseUrl = url; } -export class JiraHttp extends Http { +export class JiraHttp extends HttpBase { protected override get baseUrl(): string | undefined { return baseUrl; } diff --git a/lib/util/http/types.ts b/lib/util/http/types.ts index 2b6a31b4e574a95c15e47cc48fb05229a9a3d43c..ec673701b26df3ce4017bf0b7eab0dab3a9d0706 100644 --- a/lib/util/http/types.ts +++ b/lib/util/http/types.ts @@ -1,10 +1,14 @@ import type { IncomingHttpHeaders } from 'node:http'; import type { + Options, OptionsOfBufferResponseBody, OptionsOfJSONResponseBody, OptionsOfTextResponseBody, + RequestError, } from 'got'; +import type { ZodError } from 'zod'; import type { HttpCacheProvider } from './cache/types'; +import type { EmptyResultError } from './errors'; export type GotContextOptions = { authType?: string; @@ -16,6 +20,8 @@ export type GotBufferOptions = OptionsOfBufferResponseBody & GotExtraOptions; export type GotTextOptions = OptionsOfTextResponseBody & GotExtraOptions; export type GotJSONOptions = OptionsOfJSONResponseBody & GotExtraOptions; +export type GotStreamOptions = Options & GotExtraOptions; + export interface GotExtraOptions { abortOnError?: boolean; abortIgnoreStatusCodes?: number[]; @@ -27,14 +33,6 @@ export interface GotExtraOptions { context?: GotContextOptions; } -export interface RequestStats { - method: string; - url: string; - duration: number; - queueDuration: number; - statusCode: number; -} - export type OutgoingHttpHeaders = Record<string, string | string[] | undefined>; export type GraphqlVariables = Record<string, unknown>; @@ -95,3 +93,5 @@ export interface ConcurrencyLimitRule { matchHost: string; concurrency: number; } + +export type SafeJsonError = RequestError | ZodError | EmptyResultError; diff --git a/vitest.config.ts b/vitest.config.ts index 05bacaccd0179176fdc7098c84370693d2b6059a..237d98eca691bb86feb35473227ebf89e25ae340 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -109,7 +109,6 @@ export default defineConfig(() => 'lib/modules/datasource/hex/v2/package.ts', 'lib/modules/datasource/hex/v2/signed.ts', 'lib/util/cache/package/redis.ts', - 'lib/util/http/http.ts', // TODO: remove when code is moved from index 'lib/util/http/legacy.ts', 'lib/workers/repository/cache.ts', ],