Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: client generators support experimental runtime response validat…
…ion (#112) Adds support for (optional) runtime response validation using `zod` for the `typescript-fetch` and `typescript-axios` client builders, enabled using the `--enable-runtime-response-validation` cli flag. Currently considered experimental and probably buggy. Additionally fixed some incorrect return types for the `typescript-axios` template, and made empty `schema` files stop being output. Partially implements #82 **TODO** Probably not to be completed in this PR, but these are roughly the outstanding actions before it would be considered stable - [ ] Support `joi` - [ ] Support `typescript-angular` - [ ] Documentation - [ ] Further testing **Example output** <details><summary>typescript-fetch</summary> <p> ```diff diff --git a/integration-tests/typescript-fetch/src/generated/todo-lists.yaml/client.ts b/integration-tests/typescript-fetch/src/generated/todo-lists.yaml/client.ts index e441863..4e07356 100644 --- a/integration-tests/typescript-fetch/src/generated/todo-lists.yaml/client.ts +++ b/integration-tests/typescript-fetch/src/generated/todo-lists.yaml/client.ts @@ -3,6 +3,7 @@ /* eslint-disable */ import { t_CreateUpdateTodoList, t_Error, t_TodoList } from "./models" +import { s_Error, s_TodoList } from "./schemas" import { AbstractFetchClient, AbstractFetchClientConfig, @@ -14,6 +15,7 @@ import { StatusCode5xx, TypedFetchResponse, } from "@nahkies/typescript-fetch-runtime/main" +import { responseValidationFactory } from "@nahkies/typescript-fetch-runtime/zod" import { z } from "zod" export interface ApiClientConfig extends AbstractFetchClientConfig {} @@ -34,7 +36,16 @@ export class ApiClient extends AbstractFetchClient { const url = this.basePath + `/list` const query = this._query({ created: p["created"], status: p["status"] }) - return this._fetch(url + query, { method: "GET", ...(opts ?? {}) }, timeout) + const res = this._fetch( + url + query, + { method: "GET", ...(opts ?? {}) }, + timeout, + ) + + return responseValidationFactory( + [["200", z.array(s_TodoList)]], + undefined, + )(res) } async getTodoListById( @@ -50,7 +61,15 @@ export class ApiClient extends AbstractFetchClient { > { const url = this.basePath + `/list/${p["listId"]}` - return this._fetch(url, { method: "GET", ...(opts ?? {}) }, timeout) + const res = this._fetch(url, { method: "GET", ...(opts ?? {}) }, timeout) + + return responseValidationFactory( + [ + ["200", s_TodoList], + ["4XX", s_Error], + ], + z.undefined(), + )(res) } async updateTodoListById( @@ -69,11 +88,19 @@ export class ApiClient extends AbstractFetchClient { const headers = this._headers({ "Content-Type": "application/json" }) const body = JSON.stringify(p.requestBody) - return this._fetch( + const res = this._fetch( url, { method: "PUT", headers, body, ...(opts ?? {}) }, timeout, ) + + return responseValidationFactory( + [ + ["200", s_TodoList], + ["4XX", s_Error], + ], + z.undefined(), + )(res) } async deleteTodoListById( @@ -89,7 +116,15 @@ export class ApiClient extends AbstractFetchClient { > { const url = this.basePath + `/list/${p["listId"]}` - return this._fetch(url, { method: "DELETE", ...(opts ?? {}) }, timeout) + const res = this._fetch(url, { method: "DELETE", ...(opts ?? {}) }, timeout) + + return responseValidationFactory( + [ + ["204", z.undefined()], + ["4XX", s_Error], + ], + z.undefined(), + )(res) } async getTodoListItems( @@ -120,7 +155,23 @@ export class ApiClient extends AbstractFetchClient { > { const url = this.basePath + `/list/${p["listId"]}/items` - return this._fetch(url, { method: "GET", ...(opts ?? {}) }, timeout) + const res = this._fetch(url, { method: "GET", ...(opts ?? {}) }, timeout) + + return responseValidationFactory( + [ + [ + "200", + z.object({ + id: z.string(), + content: z.string(), + createdAt: z.string().datetime({ offset: true }), + completedAt: z.string().datetime({ offset: true }).optional(), + }), + ], + ["5XX", z.object({ message: z.string(), code: z.string() })], + ], + undefined, + )(res) } async createTodoListItem( @@ -139,10 +190,12 @@ export class ApiClient extends AbstractFetchClient { const headers = this._headers({ "Content-Type": "application/json" }) const body = JSON.stringify(p.requestBody) - return this._fetch( + const res = this._fetch( url, { method: "POST", headers, body, ...(opts ?? {}) }, timeout, ) + + return responseValidationFactory([["204", z.undefined()]], undefined)(res) } } diff --git a/integration-tests/typescript-fetch/src/generated/todo-lists.yaml/schemas.ts b/integration-tests/typescript-fetch/src/generated/todo-lists.yaml/schemas.ts new file mode 100644 index 0000000..4a8cbea --- /dev/null +++ b/integration-tests/typescript-fetch/src/generated/todo-lists.yaml/schemas.ts @@ -0,0 +1,19 @@ +/** AUTOGENERATED - DO NOT EDIT **/ +/* tslint:disable */ +/* eslint-disable */ + +import { z } from "zod" + +export const s_TodoList = z.object({ + id: z.string(), + name: z.string(), + totalItemCount: z.coerce.number(), + incompleteItemCount: z.coerce.number(), + created: z.string().datetime({ offset: true }), + updated: z.string().datetime({ offset: true }), +}) + +export const s_Error = z.object({ + message: z.string().optional(), + code: z.coerce.number().optional(), +}) ``` </p> </details> <details><summary>typescript-axios</summary> <p> ```diff diff --git a/integration-tests/typescript-axios/src/generated/todo-lists.yaml/client.ts b/integration-tests/typescript-axios/src/generated/todo-lists.yaml/client.ts index 734179c..f5e4556 100644 --- a/integration-tests/typescript-axios/src/generated/todo-lists.yaml/client.ts +++ b/integration-tests/typescript-axios/src/generated/todo-lists.yaml/client.ts @@ -3,6 +3,7 @@ /* eslint-disable */ import { t_CreateUpdateTodoList, t_Error, t_TodoList } from "./models" +import { s_Error, s_TodoList } from "./schemas" import { AbstractAxiosClient, AbstractAxiosConfig, @@ -26,13 +27,15 @@ export class ApiClient extends AbstractAxiosClient { const url = `/list` const query = this._query({ created: p["created"], status: p["status"] }) - return this.axios.request({ + const res = await this.axios.request({ url: url + query, baseURL: this.basePath, method: "GET", timeout, ...(opts ?? {}), }) + + return { ...res, data: z.array(s_TodoList).parse(res.data) } } async getTodoListById( @@ -44,13 +47,15 @@ export class ApiClient extends AbstractAxiosClient { ): Promise<AxiosResponse<t_TodoList>> { const url = `/list/${p["listId"]}` - return this.axios.request({ + const res = await this.axios.request({ url: url, baseURL: this.basePath, method: "GET", timeout, ...(opts ?? {}), }) + + return { ...res, data: s_TodoList.parse(res.data) } } async updateTodoListById( @@ -65,7 +70,7 @@ export class ApiClient extends AbstractAxiosClient { const headers = this._headers({ "Content-Type": "application/json" }) const body = JSON.stringify(p.requestBody) - return this.axios.request({ + const res = await this.axios.request({ url: url, baseURL: this.basePath, method: "PUT", @@ -74,6 +79,8 @@ export class ApiClient extends AbstractAxiosClient { timeout, ...(opts ?? {}), }) + + return { ...res, data: s_TodoList.parse(res.data) } } async deleteTodoListById( @@ -85,13 +92,15 @@ export class ApiClient extends AbstractAxiosClient { ): Promise<AxiosResponse<void>> { const url = `/list/${p["listId"]}` - return this.axios.request({ + const res = await this.axios.request({ url: url, baseURL: this.basePath, method: "DELETE", timeout, ...(opts ?? {}), }) + + return { ...res, data: z.undefined().parse(res.data) } } async getTodoListItems( @@ -110,13 +119,25 @@ export class ApiClient extends AbstractAxiosClient { > { const url = `/list/${p["listId"]}/items` - return this.axios.request({ + const res = await this.axios.request({ url: url, baseURL: this.basePath, method: "GET", timeout, ...(opts ?? {}), }) + + return { + ...res, + data: z + .object({ + id: z.string(), + content: z.string(), + createdAt: z.string().datetime({ offset: true }), + completedAt: z.string().datetime({ offset: true }).optional(), + }) + .parse(res.data), + } } async createTodoListItem( @@ -135,7 +156,7 @@ export class ApiClient extends AbstractAxiosClient { const headers = this._headers({ "Content-Type": "application/json" }) const body = JSON.stringify(p.requestBody) - return this.axios.request({ + const res = await this.axios.request({ url: url, baseURL: this.basePath, method: "POST", @@ -144,5 +165,7 @@ export class ApiClient extends AbstractAxiosClient { timeout, ...(opts ?? {}), }) + + return { ...res, data: z.undefined().parse(res.data) } } } diff --git a/integration-tests/typescript-axios/src/generated/todo-lists.yaml/schemas.ts b/integration-tests/typescript-axios/src/generated/todo-lists.yaml/schemas.ts new file mode 100644 index 0000000..4a8cbea --- /dev/null +++ b/integration-tests/typescript-axios/src/generated/todo-lists.yaml/schemas.ts @@ -0,0 +1,19 @@ +/** AUTOGENERATED - DO NOT EDIT **/ +/* tslint:disable */ +/* eslint-disable */ + +import { z } from "zod" + +export const s_TodoList = z.object({ + id: z.string(), + name: z.string(), + totalItemCount: z.coerce.number(), + incompleteItemCount: z.coerce.number(), + created: z.string().datetime({ offset: true }), + updated: z.string().datetime({ offset: true }), +}) + +export const s_Error = z.object({ + message: z.string().optional(), + code: z.coerce.number().optional(), +}) ``` </p> </details>
- Loading branch information