Skip to content

Commit

Permalink
feat(plugin): add a plugin mecanism to zodios
Browse files Browse the repository at this point in the history
  • Loading branch information
ecyrbe committed Feb 16, 2022
1 parent fa6bf39 commit fd7c8fc
Show file tree
Hide file tree
Showing 6 changed files with 163 additions and 113 deletions.
17 changes: 11 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,19 +64,24 @@ const apiClient = new Zodios(
}),
},
] as const,
// Optional Token provider
{
tokenProvider: {
getToken: () => Promise.resolve("token"),
}
}
);
// typed auto-complete url auto-complete params
// ▼ ▼ ▼
const user = await apiClient.get("/users/:id", { params: { id: 7 } });
console.log(user);
// Output: { id: 7, name: 'Kurtis Weissnat' }
```
## Use token provider plugin

Zodios comes with a plugin to inject and renew your tokens :
```typescript
import { pluginToken } from 'zodios/plugins/token';

apiClient.use(pluginToken({
getToken: async () => "token"
}));
```

## Get underlying axios instance

you can get back the underlying axios instance to customize it.
Expand Down
36 changes: 36 additions & 0 deletions src/plugins/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { AxiosRequestConfig } from "axios";
import { Zodios } from "../zodios";
import { ZodiosEnpointDescriptions } from "../zodios.types";

function createRequestInterceptor() {
return async (config: AxiosRequestConfig) => {
config.withCredentials = true;
// istanbul ignore next
if (!config.headers) {
config.headers = {};
}
if (config.method !== "get") {
config.headers = {
...config.headers,
"Content-Type": "application/json",
Accept: "application/json",
};
} else {
config.headers = {
...config.headers,
Accept: "application/json",
};
}
return config;
};
}

/**
* plugin that add application/json header to all requests
* @param zodios
*/
export function pluginApi<Api extends ZodiosEnpointDescriptions>() {
return (zodios: Zodios<Api>) => {
zodios.axios.interceptors.request.use(createRequestInterceptor());
};
}
53 changes: 53 additions & 0 deletions src/plugins/token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import axios, { AxiosInstance, AxiosRequestConfig } from "axios";
import { ReadonlyDeep } from "../utils.types";
import { Zodios } from "../zodios";
import {
AxiosRetryRequestConfig,
TokenProvider,
ZodiosEndpointDescription,
ZodiosEnpointDescriptions,
} from "../zodios.types";

function createRequestInterceptor(provider: TokenProvider) {
return async (config: AxiosRequestConfig) => {
const token = await provider.getToken();
if (token) {
config.headers = {
...config.headers,
Authorization: `Bearer ${token}`,
};
}
return config;
};
}

function createResponseInterceptor(
instance: AxiosInstance,
provider: TokenProvider
) {
return async (error: Error) => {
if (axios.isAxiosError(error) && provider.renewToken) {
const retryConfig = error.config as AxiosRetryRequestConfig;
if (error.response?.status === 401 && !retryConfig.retried) {
retryConfig.retried = true;
provider.renewToken();
return instance.request(retryConfig);
}
}
throw error;
};
}

export function pluginToken<Api extends ZodiosEnpointDescriptions>(
provider: TokenProvider
) {
return (zodios: Zodios<Api>) => {
zodios.axios.interceptors.request.use(createRequestInterceptor(provider));
if (provider?.renewToken) {
zodios.axios.interceptors.response.use(
undefined,
createResponseInterceptor(zodios.axios, provider)
);
}
};
}
95 changes: 44 additions & 51 deletions src/zodios.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import express, { Application } from "express";
import { AddressInfo } from "net";
import { z, ZodError } from "zod";
import { Zodios } from "./zodios";
import { pluginToken } from "./plugins/token";

describe("Zodios", () => {
let app: express.Express;
Expand Down Expand Up @@ -77,12 +78,12 @@ describe("Zodios", () => {
expect(zodios).toBeDefined();
});
it("should create a new instance when providing a token provider", () => {
const zodios = new Zodios(`http://localhost:${port}`, [], {
tokenProvider: {
const zodios = new Zodios(`http://localhost:${port}`, []);
zodios.use(
pluginToken({
getToken: async () => "token",
renewToken: async () => {},
},
});
})
);
expect(zodios).toBeDefined();
});
it("should make an http request", async () => {
Expand Down Expand Up @@ -236,67 +237,59 @@ describe("Zodios", () => {
expect(response).toEqual({ id: 6 });
});
it("should make an http request with a token", async () => {
const zodios = new Zodios(
`http://localhost:${port}`,
[
{
method: "get",
path: "/token",
response: z.object({
token: z.string(),
}),
},
] as const,
const zodios = new Zodios(`http://localhost:${port}`, [
{
tokenProvider: {
getToken: async () => "token",
renewToken: async () => {},
},
}
method: "get",
path: "/token",
response: z.object({
token: z.string(),
}),
},
] as const);
zodios.use(
pluginToken({
getToken: async () => "token",
})
);
const response = await zodios.get("/token");
expect(response).toEqual({ token: "Bearer token" });
});
it("should make an http post with a token", async () => {
const zodios = new Zodios(
`http://localhost:${port}`,
[
{
method: "post",
path: "/token",
response: z.object({
token: z.string(),
}),
},
] as const,
const zodios = new Zodios(`http://localhost:${port}`, [
{
tokenProvider: {
getToken: async () => "token",
renewToken: async () => {},
method: "post",
path: "/token",
response: z.object({
token: z.string(),
}),
},
] as const);
zodios.use(
pluginToken({
getToken: async () => {
console.log("get token");
return "token";
},
}
})
);
const response = await zodios.post("/token");
expect(response).toEqual({ token: "Bearer token" });
});
it("should handle 401 error with a token", async () => {
const zodios = new Zodios(
`http://localhost:${port}`,
[
{
method: "get",
path: "/error401",
response: z.object({}),
},
] as const,
it("should handle 401 error with a renew token", async () => {
const zodios = new Zodios(`http://localhost:${port}`, [
{
tokenProvider: {
method: "get",
path: "/error401",
response: z.object({}),
},
] as const);
try {
zodios.use(
pluginToken({
getToken: async () => "token",
renewToken: async () => {},
},
}
);
try {
})
);
await zodios.get("/error401");
} catch (e) {
expect((e as AxiosError).response?.status).toBe(401);
Expand Down
66 changes: 12 additions & 54 deletions src/zodios.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,25 @@
import axios, { AxiosInstance, AxiosRequestConfig } from "axios";
import { z } from "zod";
import { pluginApi } from "./plugins/api";
import {
AnyZodiosRequestOptions,
ZodiosEndpointDescription,
ZodiosRequestOptions,
AxiosRetryRequestConfig,
Body,
Method,
Paths,
Response,
TokenProvider,
ZodiosOptions,
ZodiosEnpointDescriptions,
} from "./zodios.types";
import { ReadonlyDeep } from "./utils.types";
import { omit } from "./utils";

const paramsRegExp = /:([a-zA-Z_][a-zA-Z0-9_]*)/g;

/**
* zodios api client based on axios
*/
export class Zodios<
Api extends ReadonlyDeep<ZodiosEndpointDescription<any>[]>
> {
export class Zodios<Api extends ZodiosEnpointDescriptions> {
axiosInstance: AxiosInstance;
options: ZodiosOptions;

Expand All @@ -35,6 +32,7 @@ export class Zodios<
constructor(baseURL: string, private api: Api, options?: ZodiosOptions) {
this.options = {
validateResponse: true,
usePluginApi: true,
...options,
};

Expand All @@ -47,16 +45,8 @@ export class Zodios<
});
}

if (this.options.tokenProvider) {
this.axiosInstance.interceptors.request.use(
this.createRequestInterceptor()
);
if (this.options.tokenProvider?.renewToken) {
this.axiosInstance.interceptors.response.use(
undefined,
this.createResponseInterceptor()
);
}
if (this.options.usePluginApi) {
this.use(pluginApi());
}
}

Expand All @@ -67,44 +57,12 @@ export class Zodios<
return this.axiosInstance;
}

private createRequestInterceptor() {
return async (config: AxiosRequestConfig) => {
config.withCredentials = true;
// istanbul ignore next
if (!config.headers) {
config.headers = {};
}
const token = await this.options.tokenProvider?.getToken();
if (token && config.method !== "get") {
config.headers = {
...config.headers,
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
Accept: "application/json",
};
} else if (token) {
config.headers = {
...config.headers,
Authorization: `Bearer ${token}`,
Accept: "application/json",
};
}
return config;
};
}

private createResponseInterceptor() {
return async (error: Error) => {
if (axios.isAxiosError(error) && this.options.tokenProvider?.renewToken) {
const retryConfig = error.config as AxiosRetryRequestConfig;
if (error.response?.status === 401 && !retryConfig.retried) {
retryConfig.retried = true;
this.options.tokenProvider.renewToken();
return this.axiosInstance.request(retryConfig);
}
}
throw error;
};
/**
* use a plugin to cusomize the client
* @param plugin - the plugin to use
*/
use(plugin: (zodios: Zodios<Api>) => void) {
plugin(this);
}

private findEndpoint<M extends Method, Path extends Paths<Api, M>>(
Expand Down
9 changes: 7 additions & 2 deletions src/zodios.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
GetParamsKeys,
ParamsToObject,
SetPropsOptionalIfChildrenAreOptional,
ReadonlyDeep,
} from "./utils.types";
import { z } from "zod";

Expand Down Expand Up @@ -119,9 +120,9 @@ export interface TokenProvider {
*/
export type ZodiosOptions = {
/**
* Token provider to allow zodios to inject a token into the request or renew it
* use the header api interceptor? Default: true
*/
tokenProvider?: TokenProvider;
usePluginApi?: boolean;
/**
* Should zodios validate the response? Default: true
*/
Expand All @@ -146,3 +147,7 @@ export type ZodiosEndpointDescription<R> = {
}>;
response: z.ZodType<R>;
};

export type ZodiosEnpointDescriptions = ReadonlyDeep<
ZodiosEndpointDescription<any>[]
>;

0 comments on commit fd7c8fc

Please sign in to comment.