Skip to content

Commit

Permalink
feat: client generators support experimental runtime response validat…
Browse files Browse the repository at this point in the history
…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
mnahkies authored Feb 10, 2024
1 parent d564530 commit f3c3610
Show file tree
Hide file tree
Showing 39 changed files with 667 additions and 609 deletions.

This file was deleted.

This file was deleted.

This file was deleted.

This file was deleted.

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -5052,7 +5052,7 @@ export class ApiClient extends AbstractAxiosClient {
},
timeout?: number,
opts?: AxiosRequestConfig,
): Promise<never> {
): Promise<AxiosResponse<void>> {
const url = `/orgs/${p["org"]}/migrations/${p["migrationId"]}/archive`

return this.axios.request({
Expand Down Expand Up @@ -7702,7 +7702,7 @@ export class ApiClient extends AbstractAxiosClient {
},
timeout?: number,
opts?: AxiosRequestConfig,
): Promise<never> {
): Promise<AxiosResponse<void>> {
const url = `/repos/${p["owner"]}/${p["repo"]}/actions/artifacts/${p["artifactId"]}/${p["archiveFormat"]}`

return this.axios.request({
Expand Down Expand Up @@ -7836,7 +7836,7 @@ export class ApiClient extends AbstractAxiosClient {
},
timeout?: number,
opts?: AxiosRequestConfig,
): Promise<never> {
): Promise<AxiosResponse<void>> {
const url = `/repos/${p["owner"]}/${p["repo"]}/actions/jobs/${p["jobId"]}/logs`

return this.axios.request({
Expand Down Expand Up @@ -8693,7 +8693,7 @@ export class ApiClient extends AbstractAxiosClient {
},
timeout?: number,
opts?: AxiosRequestConfig,
): Promise<never> {
): Promise<AxiosResponse<void>> {
const url = `/repos/${p["owner"]}/${p["repo"]}/actions/runs/${p["runId"]}/attempts/${p["attemptNumber"]}/logs`

return this.axios.request({
Expand Down Expand Up @@ -8793,7 +8793,7 @@ export class ApiClient extends AbstractAxiosClient {
},
timeout?: number,
opts?: AxiosRequestConfig,
): Promise<never> {
): Promise<AxiosResponse<void>> {
const url = `/repos/${p["owner"]}/${p["repo"]}/actions/runs/${p["runId"]}/logs`

return this.axios.request({
Expand Down Expand Up @@ -17832,7 +17832,7 @@ export class ApiClient extends AbstractAxiosClient {
},
timeout?: number,
opts?: AxiosRequestConfig,
): Promise<never> {
): Promise<AxiosResponse<void>> {
const url = `/repos/${p["owner"]}/${p["repo"]}/tarball/${p["ref"]}`

return this.axios.request({
Expand Down Expand Up @@ -18087,7 +18087,7 @@ export class ApiClient extends AbstractAxiosClient {
},
timeout?: number,
opts?: AxiosRequestConfig,
): Promise<never> {
): Promise<AxiosResponse<void>> {
const url = `/repos/${p["owner"]}/${p["repo"]}/zipball/${p["ref"]}`

return this.axios.request({
Expand Down Expand Up @@ -20759,7 +20759,7 @@ export class ApiClient extends AbstractAxiosClient {
},
timeout?: number,
opts?: AxiosRequestConfig,
): Promise<never> {
): Promise<AxiosResponse<void>> {
const url = `/user/migrations/${p["migrationId"]}/archive`

return this.axios.request({
Expand Down

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ export class ApiClient extends AbstractAxiosClient {
} = {},
timeout?: number,
opts?: AxiosRequestConfig,
): Promise<never> {
): Promise<AxiosResponse<void>> {
const url = `/oauth2/v1/authorize`
const query = this._query({
acr_values: p["acrValues"],
Expand Down Expand Up @@ -356,7 +356,7 @@ export class ApiClient extends AbstractAxiosClient {
},
timeout?: number,
opts?: AxiosRequestConfig,
): Promise<never> {
): Promise<AxiosResponse<void>> {
const url = `/oauth2/v1/logout`
const query = this._query({
id_token_hint: p["idTokenHint"],
Expand Down Expand Up @@ -522,7 +522,7 @@ export class ApiClient extends AbstractAxiosClient {
},
timeout?: number,
opts?: AxiosRequestConfig,
): Promise<never> {
): Promise<AxiosResponse<void>> {
const url = `/oauth2/${p["authorizationServerId"]}/v1/authorize`
const query = this._query({
acr_values: p["acrValues"],
Expand Down Expand Up @@ -656,7 +656,7 @@ export class ApiClient extends AbstractAxiosClient {
},
timeout?: number,
opts?: AxiosRequestConfig,
): Promise<never> {
): Promise<AxiosResponse<void>> {
const url = `/oauth2/${p["authorizationServerId"]}/v1/logout`
const query = this._query({
id_token_hint: p["idTokenHint"],
Expand Down

This file was deleted.

This file was deleted.

This file was deleted.

This file was deleted.

This file was deleted.

This file was deleted.

This file was deleted.

This file was deleted.

This file was deleted.

This file was deleted.

Loading

0 comments on commit f3c3610

Please sign in to comment.