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