From bc9e353af48b33a1fcd1d93f505f98320ae15900 Mon Sep 17 00:00:00 2001 From: Sergei Zharinov <zharinov@users.noreply.github.com> Date: Tue, 4 Oct 2022 09:40:48 +0300 Subject: [PATCH] feat(http): Schemas and type inference for JSON requests (#18096) --- lib/util/http/index.spec.ts | 133 +++++++++++++++++++++++++++++++ lib/util/http/index.ts | 154 +++++++++++++++++++++++++++++++----- lib/util/http/types.ts | 2 + 3 files changed, 270 insertions(+), 19 deletions(-) diff --git a/lib/util/http/index.spec.ts b/lib/util/http/index.spec.ts index d665e4b269..dcd4f8165d 100644 --- a/lib/util/http/index.spec.ts +++ b/lib/util/http/index.spec.ts @@ -1,10 +1,15 @@ +import * as z from 'zod'; import * as httpMock from '../../../test/http-mock'; +import { logger } from '../../../test/util'; import { EXTERNAL_HOST_ERROR, HOST_DISABLED, } from '../../constants/error-messages'; +import * as memCache from '../cache/memory'; import * as hostRules from '../host-rules'; +import { reportErrors } from '../schema'; import * as queue from './queue'; +import type { HttpResponse } from './types'; import { Http } from '.'; const baseUrl = 'http://renovate.com'; @@ -300,4 +305,132 @@ describe('util/http/index', () => { expect(res?.body).toBeInstanceOf(Buffer); expect(res?.body.toString('utf-8')).toBe('test'); }); + + describe('Schema support', () => { + const testSchema = z.object({ test: z.boolean() }); + type TestType = z.infer<typeof testSchema>; + + beforeEach(() => { + jest.resetAllMocks(); + memCache.init(); + }); + + afterEach(() => { + memCache.reset(); + }); + + describe('getJson', () => { + it('infers body type', async () => { + httpMock + .scope(baseUrl) + .get('/') + .reply(200, JSON.stringify({ test: true })); + + const { body }: HttpResponse<TestType> = await http.getJson( + 'http://renovate.com', + testSchema + ); + + expect(body).toEqual({ test: true }); + + reportErrors(); + expect(logger.logger.warn).not.toHaveBeenCalled(); + }); + + it('reports warnings', async () => { + memCache.init(); + httpMock + .scope(baseUrl) + .get('/') + .reply(200, JSON.stringify({ test: 'foobar' })); + + const res = await http.getJson( + 'http://renovate.com', + { onSchemaError: 'warn' }, + testSchema + ); + + expect(res.body).toEqual({ test: 'foobar' }); + + expect(logger.logger.warn).not.toHaveBeenCalled(); + reportErrors(); + expect(logger.logger.warn).toHaveBeenCalled(); + }); + + it('throws', async () => { + httpMock + .scope(baseUrl) + .get('/') + .reply(200, JSON.stringify({ test: 'foobar' })); + + await expect( + http.getJson( + 'http://renovate.com', + { onSchemaError: 'throw' }, + testSchema + ) + ).rejects.toThrow(); + + reportErrors(); + expect(logger.logger.warn).not.toHaveBeenCalled(); + }); + }); + + describe('postJson', () => { + it('infers body type', async () => { + httpMock + .scope(baseUrl) + .post('/') + .reply(200, JSON.stringify({ test: true })); + + const { body }: HttpResponse<TestType> = await http.postJson( + 'http://renovate.com', + testSchema + ); + + expect(body).toEqual({ test: true }); + + reportErrors(); + expect(logger.logger.warn).not.toHaveBeenCalled(); + }); + + it('reports warnings', async () => { + memCache.init(); + httpMock + .scope(baseUrl) + .post('/') + .reply(200, JSON.stringify({ test: 'foobar' })); + + const res = await http.postJson( + 'http://renovate.com', + { onSchemaError: 'warn' }, + testSchema + ); + + expect(res.body).toEqual({ test: 'foobar' }); + + expect(logger.logger.warn).not.toHaveBeenCalled(); + reportErrors(); + expect(logger.logger.warn).toHaveBeenCalled(); + }); + + it('throws', async () => { + httpMock + .scope(baseUrl) + .post('/') + .reply(200, JSON.stringify({ test: 'foobar' })); + + await expect( + http.postJson( + 'http://renovate.com', + { onSchemaError: 'throw' }, + testSchema + ) + ).rejects.toThrow(); + + reportErrors(); + expect(logger.logger.warn).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/lib/util/http/index.ts b/lib/util/http/index.ts index 7aef53c600..4132b3cd4c 100644 --- a/lib/util/http/index.ts +++ b/lib/util/http/index.ts @@ -1,12 +1,14 @@ import merge from 'deepmerge'; import got, { Options, RequestError, Response } from 'got'; import hasha from 'hasha'; +import { infer as Infer, ZodSchema } from 'zod'; 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 { clone } from '../clone'; +import { match } from '../schema'; import { resolveBaseUrl } from '../url'; import { applyAuthorization, removeAuthorization } from './auth'; import { hooks } from './hooks'; @@ -25,6 +27,12 @@ import './legacy'; export { RequestError as HttpError }; +type JsonArgs<T extends HttpOptions> = { + url: string; + httpOptions?: T; + schema?: ZodSchema | undefined; +}; + function cloneResponse<T extends Buffer | string | any>( response: HttpResponse<T> ): HttpResponse<T> { @@ -208,51 +216,159 @@ export class Http<Opts extends HttpOptions = HttpOptions> { } private async requestJson<T = unknown>( - url: string, - requestOptions?: Opts, - internalOptions?: InternalHttpOptions + method: InternalHttpOptions['method'], + { url, httpOptions: requestOptions, schema }: JsonArgs<Opts> ): Promise<HttpResponse<T>> { - const { body, ...httpOptions } = { ...requestOptions }; + const { body, onSchemaError, ...httpOptions } = { ...requestOptions }; const opts: InternalHttpOptions = { ...httpOptions, - ...internalOptions, + method, responseType: 'json', }; if (body) { opts.json = body; } const res = await this.request<T>(url, opts); + + if (schema) { + match(schema, res.body, onSchemaError); + } + return { ...res, body: res.body }; } - getJson<T = unknown>(url: string, options?: Opts): Promise<HttpResponse<T>> { - return this.requestJson<T>(url, options); + private resolveArgs( + arg1: string, + arg2: Opts | ZodSchema | undefined, + arg3: ZodSchema | undefined + ): JsonArgs<Opts> { + const res: JsonArgs<Opts> = { url: arg1 }; + + if (arg2 instanceof ZodSchema) { + res.schema = arg2; + } else if (arg2) { + res.httpOptions = arg2; + } + + if (arg3) { + res.schema = arg3; + } + + return res; } - headJson<T = unknown>(url: string, options?: Opts): Promise<HttpResponse<T>> { - return this.requestJson<T>(url, options, { method: 'head' }); + getJson<T>(url: string, options?: Opts): Promise<HttpResponse<T>>; + getJson<T>( + url: string, + schema: ZodSchema<T> + ): Promise<HttpResponse<Infer<typeof schema>>>; + getJson<T>( + url: string, + options: Opts, + schema: ZodSchema<T> + ): Promise<HttpResponse<Infer<typeof schema>>>; + getJson<T = unknown>( + arg1: string, + arg2?: Opts | ZodSchema, + arg3?: ZodSchema + ): Promise<HttpResponse<T>> { + const args = this.resolveArgs(arg1, arg2, arg3); + return this.requestJson<T>('get', args); } - postJson<T = unknown>(url: string, options?: Opts): Promise<HttpResponse<T>> { - return this.requestJson<T>(url, options, { method: 'post' }); + headJson<T>(url: string, options?: Opts): Promise<HttpResponse<T>>; + headJson<T>( + url: string, + schema: ZodSchema<T> + ): Promise<HttpResponse<Infer<typeof schema>>>; + headJson<T>( + url: string, + options: Opts, + schema: ZodSchema<T> + ): Promise<HttpResponse<Infer<typeof schema>>>; + headJson<T = unknown>( + arg1: string, + arg2?: Opts | ZodSchema, + arg3?: ZodSchema + ): Promise<HttpResponse<T>> { + const args = this.resolveArgs(arg1, arg2, arg3); + return this.requestJson<T>('head', args); } - putJson<T = unknown>(url: string, options?: Opts): Promise<HttpResponse<T>> { - return this.requestJson<T>(url, options, { method: 'put' }); + postJson<T>(url: string, options?: Opts): Promise<HttpResponse<T>>; + postJson<T>( + url: string, + schema: ZodSchema<T> + ): Promise<HttpResponse<Infer<typeof schema>>>; + postJson<T>( + url: string, + options: Opts, + schema: ZodSchema<T> + ): Promise<HttpResponse<Infer<typeof schema>>>; + postJson<T = unknown>( + arg1: string, + arg2?: Opts | ZodSchema, + arg3?: ZodSchema + ): Promise<HttpResponse<T>> { + const args = this.resolveArgs(arg1, arg2, arg3); + return this.requestJson<T>('post', args); } - patchJson<T = unknown>( + putJson<T>(url: string, options?: Opts): Promise<HttpResponse<T>>; + putJson<T>( url: string, - options?: Opts + schema: ZodSchema<T> + ): Promise<HttpResponse<Infer<typeof schema>>>; + putJson<T>( + url: string, + options: Opts, + schema: ZodSchema<T> + ): Promise<HttpResponse<Infer<typeof schema>>>; + putJson<T = unknown>( + arg1: string, + arg2?: Opts | ZodSchema, + arg3?: ZodSchema ): Promise<HttpResponse<T>> { - return this.requestJson<T>(url, options, { method: 'patch' }); + const args = this.resolveArgs(arg1, arg2, arg3); + return this.requestJson<T>('put', args); } - deleteJson<T = unknown>( + patchJson<T>(url: string, options?: Opts): Promise<HttpResponse<T>>; + patchJson<T>( + url: string, + schema: ZodSchema<T> + ): Promise<HttpResponse<Infer<typeof schema>>>; + patchJson<T>( url: string, - options?: Opts + options: Opts, + schema: ZodSchema<T> + ): Promise<HttpResponse<Infer<typeof schema>>>; + patchJson<T = unknown>( + arg1: string, + arg2?: Opts | ZodSchema, + arg3?: ZodSchema + ): 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>( + url: string, + schema: ZodSchema<T> + ): Promise<HttpResponse<Infer<typeof schema>>>; + deleteJson<T>( + url: string, + options: Opts, + schema: ZodSchema<T> + ): Promise<HttpResponse<Infer<typeof schema>>>; + deleteJson<T = unknown>( + arg1: string, + arg2?: Opts | ZodSchema, + arg3?: ZodSchema ): Promise<HttpResponse<T>> { - return this.requestJson<T>(url, options, { method: 'delete' }); + const args = this.resolveArgs(arg1, arg2, arg3); + return this.requestJson<T>('delete', args); } stream(url: string, options?: HttpOptions): NodeJS.ReadableStream { diff --git a/lib/util/http/types.ts b/lib/util/http/types.ts index 32f873f6bc..5b961721aa 100644 --- a/lib/util/http/types.ts +++ b/lib/util/http/types.ts @@ -63,6 +63,8 @@ export interface HttpOptions { token?: string; useCache?: boolean; + + onSchemaError?: 'warn' | 'throw'; } export interface InternalHttpOptions extends HttpOptions { -- GitLab