Skip to content

Commit

Permalink
Added support for middleware
Browse files Browse the repository at this point in the history
  • Loading branch information
Erik Lysne committed Jul 11, 2023
1 parent 19c0020 commit 8604bfd
Show file tree
Hide file tree
Showing 32 changed files with 1,338 additions and 497 deletions.
8 changes: 8 additions & 0 deletions .changeset/fast-impalas-thank.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@acquirejs/core": minor
"@acquirejs/vite-demo": minor
---

- Added support for middleware on the `Acquire` and `AcquireRequestExecutor` classes. Middleware can be applied using the `use`, `useOnExecution` and `useOnMocking` methods.
- Removed `setMockInterceptor` and `clearMockInterceptor` methods from the `AcquireRequestExecutor` class. Intercepting mock calls should now be done using middleware.
- Removed the `useLogger` method from the `Acquire` class. Logging can now be done using the `AcquireRequestLogger` middleware.
167 changes: 83 additions & 84 deletions README.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions demos/vite-demo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"reflect-metadata": "^0.1.13"
},
"devDependencies": {
"@tanstack/react-query-devtools": "^4.29.19",
"@types/react": "^18.0.28",
"@types/react-dom": "^18.0.11",
"@vitejs/plugin-react-swc": "^3.0.0",
Expand Down
2 changes: 2 additions & 0 deletions demos/vite-demo/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Toolbar from "@mui/material/Toolbar";
import Typography from "@mui/material/Typography";
import { ThemeProvider } from "@mui/material/styles";
import { QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import React from "react";
import { BrowserRouter, Link } from "react-router-dom";
import MockSwitch from "./components/MockSwitch";
Expand All @@ -15,6 +16,7 @@ import theme from "./theme/theme";
function App(): React.ReactElement {
return (
<QueryClientProvider client={queryClient}>
<ReactQueryDevtools />
<ThemeProvider theme={theme}>
<BrowserRouter>
<AcquireMockProvider>
Expand Down
8 changes: 6 additions & 2 deletions demos/vite-demo/src/api/acquire.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { Acquire, AcquireLogger, AcquireMockCache } from "@acquirejs/core";
import {
Acquire,
AcquireMockCache,
AcquireRequestLogger
} from "@acquirejs/core";
import axios from "axios";

const axiosInstance = axios.create({
Expand All @@ -9,6 +13,6 @@ export const mockCache = new AcquireMockCache();

const acquire = new Acquire(axiosInstance)
.useMockCache(mockCache)
.useLogger(new AcquireLogger());
.use(new AcquireRequestLogger());

export default acquire;
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { initAcquireMocks } from "@acquirejs/mocks";

export default async function mockInit() {
export default async function acquireInit() {
initAcquireMocks();
return Promise.all([
import("./comment/commentApiMocking"),
Expand Down
32 changes: 12 additions & 20 deletions demos/vite-demo/src/api/comment/commentApiMocking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,37 +2,29 @@ import { demoUser } from "../populateMockCache";
import { createComment, getComments } from "./commentApi";
import { CommentDTO } from "./dtos/CommentDTO";

getComments.setMockInterceptor(
async ({ mockResponse, mockCache, callArgs, delay }) => {
const { postId, order, sort } = callArgs ?? {};
getComments.useOnMocking(({ response, mockCache, callArgs }) => {
const { postId, order, sort } = callArgs ?? {};

const dbSimulator = mockCache?.createDatabaseSimulator(CommentDTO);
const data = dbSimulator
?.sort(sort, order)
.filter((comment) => comment.postId === postId)
.get();

mockResponse.data = data;
const dbSimulator = mockCache?.createDatabaseSimulator(CommentDTO);
const data = dbSimulator
?.sort(sort, order)
.filter((comment) => comment.postId === postId)
.get();

await delay(100, 350);
return Promise.resolve(mockResponse);
}
);
response.data = data;
});

createComment.setMockInterceptor(async ({ mockResponse, mockCache, delay }) => {
createComment.useOnMocking(({ response, mockCache }) => {
const dbSimulator = mockCache?.createDatabaseSimulator(CommentDTO);
const id = dbSimulator?.generateNextID();

const newComment: CommentDTO = {
...mockResponse.config?.data,
...response.config?.data,
id,
name: demoUser.name
};

dbSimulator?.create(newComment);

mockResponse.data = newComment;

await delay(100, 200);
return Promise.resolve(mockResponse);
response.data = newComment;
});
45 changes: 18 additions & 27 deletions demos/vite-demo/src/api/post/postApiMocking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,43 +2,34 @@ import { demoUser } from "../populateMockCache";
import { PostDTO } from "./dtos/PostDTO";
import { createPost, getPosts } from "./postApi";

getPosts.setMockInterceptor(
async ({ mockResponse, mockCache, callArgs, delay }) => {
const { userId, page, limit, order, sort } = callArgs ?? {};
getPosts.useOnMocking(({ response, mockCache, callArgs }) => {
const { userId, page, limit, order, sort } = callArgs ?? {};

const dbSimulator = mockCache?.createDatabaseSimulator(PostDTO);
const data = dbSimulator
?.filter(userId ? (post) => post.userId === userId : undefined)
.sort(sort, order)
.paginate(page, limit, 1)
.get();

mockResponse.data = data;
mockResponse.headers = {
...mockResponse.headers,
["x-total-count"]: dbSimulator?.count()
};

await delay(100, 500);
return Promise.resolve(mockResponse);
}
);
const dbSimulator = mockCache?.createDatabaseSimulator(PostDTO);
const data = dbSimulator
?.filter(userId ? (post) => post.userId === userId : undefined)
.sort(sort, order)
.paginate(page, limit, 1)
.get();

response.data = data;
response.headers = {
...response.headers,
["x-total-count"]: dbSimulator?.count()
};
});

createPost.setMockInterceptor(async ({ mockResponse, mockCache, delay }) => {
createPost.useOnMocking(({ response, mockCache }) => {
const dbSimulator = mockCache?.createDatabaseSimulator(PostDTO);
const id = dbSimulator?.generateNextID();

const newPost: PostDTO = {
...mockResponse.config?.data,
...response.config?.data,
id,
userId: demoUser.id
};

dbSimulator?.create(newPost);

mockResponse.data = newPost;

await delay(100, 200);

return Promise.resolve(mockResponse);
response.data = newPost;
});
18 changes: 6 additions & 12 deletions demos/vite-demo/src/api/user/userApiMocking.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,11 @@
import { UserDTO } from "./dtos/UserDTO";
import { getUser } from "./userApi";

getUser.setMockInterceptor(
async ({ mockResponse, mockCache, callArgs, delay }) => {
const { userId } = callArgs ?? {};
getUser.useOnMocking(({ response, mockCache, callArgs }) => {
const { userId } = callArgs ?? {};

const dbSimulator = mockCache?.createDatabaseSimulator(UserDTO);
const user = dbSimulator?.find((user) => user.id === userId);
const dbSimulator = mockCache?.createDatabaseSimulator(UserDTO);
const user = dbSimulator?.find((user) => user.id === userId);

mockResponse.data = user;

await delay(300, 500);

return Promise.resolve(mockResponse);
}
);
response.data = user;
});
4 changes: 2 additions & 2 deletions demos/vite-demo/src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import mockInit from "./api/mockInit";
import acquireInit from "./api/acquireInit";

async function bootstrap() {
await mockInit();
await acquireInit();

ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
Expand Down
151 changes: 150 additions & 1 deletion motivation.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,152 @@
# Motivation

Coming soon!
When working with REST API in TypeScript, some common problems usually arise that AcquireJS aims to solve.

## Problems

### 1. Type safety

The first issue typically encountered when working with REST APIs in TypeScript is <i>type safety</i> of the returned data. Let's imagine we are working with an API that returns an array of user objects, where a user object has the following structure:

```json
{
"id": 1,
"firstName": "John",
"lastName": "Doe",
"email": "[email protected]",
"phoneNumber": "+1234567890",
"role": "admin",
"isActive": false,
"lastActiveAt": "2023-05-30T12:00:00Z",
"createdAt": "2023-01-01T00:00:00Z",
"updatedAt": "2023-05-20T12:00:00Z"
}
```

To fetch a list of users, we can implement a `GET` request like this, using the built-in `fetch` method in JavaScript:

```typescript
async function getUsers() {
const response = await fetch("http://api.example.com");
const users = await response.json();
return users;
}
```

The problem with this implementation is that the data returned from `getUsers` lacks a return type. The return type therefore defaults to `any`:

```typescript
const users = await getUsers();
// ^ type: any
```

We can easily solve this by adding a type to the `users` variable:

```typescript
interface User {
id: number;
firstName: string;
lastName: string;
email: string;
phoneNumber: string;
role: "basic-user" | "admin";
isActive: boolean;
lastActiveAt: Date;
createdAt: Date;
updatedAt: Date;
}

async function getUsers() {
const response = await fetch("http://api.example.com");
const users: User[] = await response.json();
return users;
}
```

Now `getUsers` has a return type of `User[]`:

```typescript
const users = await getUsers();
// ^ type: User[]
```

However we have now introduced another problem. We typed `lastActiveAt`, `createdAt` and `updatedAt` as `Date` objects, when in reality they are <i>date strings</i>. We have therefore lied to TypeScript about the nature of the data we expect from the `json` method. The real problem is that we have mixed up the <i>desired</i> format of the data with the <i>actual</i> format of the data. This leads us to the second problem: <i>data mapping</i>.

### 2. Data mapping

There is no implicit conversion between date strings and `Date` objects when calling the `json` method; the `fetch` method leaves it up to us to get our data into the desired format. This is actually a good thing, as we might not wish to work with native `Date` objects, but rather use a third party library like [Luxon](https://moment.github.io/luxon/#/) or [Moment](https://momentjs.com/) for handling dates.

While the date example illustrates the problem well, it just one of many issues of such nature that arise when working with REST APIs. Here are some other examples:

- Some APIs return numbers as strings, sometimes with thousand or decimal separators not directly parsable by JavaScript. Ideally, these should be mapped to plain numbers.
- Some APIs contain enum values, which are represented as strings or numbers. Ideally, those should be mapped to something similar to an enum, like a TypeScript enum or an `as const` object.
- Some APIs represent boolean values as something other than a JSON boolean, e.g., as `"true"`/`"false"` string values. Ideally, those should be mapped to proper boolean values.
- Some values may represent a measurable quantity, such as a volume, mass or pressure. In these cases, it would be convenient to map the data to some kind of measurement class, so we could have support for selecting a unit system to display all values in (e.g., SI or imperial).
- Some values may be <i>nullable</i> and we need to decide how to handle that. We may wish to assign a default value to `null` values, such as `0`, `false` or `""`, or something like a default enum value.

We have two fundamental choices when fixing the issue outlined above. The first option is to fix it at the type-level, by typing the return type exactly as we receive it (the "raw" data). The second option is to fix it in run-time by mapping the data to our desired format:

1. <b>Fixing the return type</b>

```typescript
interface UserDTO {
id: number;
firstName: string;
lastName: string;
email: string;
phoneNumber: string;
role: string;
isActive: boolean;
lastActiveAt: string;
createdAt: string;
updatedAt: string;
}

async function getUsers() {
const response = await fetch("http://api.example.com");
const users: UserDTO[] = await response.json();
return users;
}
```

2. <b>Mapping the data</b>:

```typescript
interface UserModel {
id: number;
firstName: string;
lastName: string;
email: string;
phoneNumber: string;
role: "basic-user" | "admin";
isActive: boolean;
lastActiveAt: Date;
createdAt: Date;
updatedAt: Date;
}
async function getUsers() {
const response = await fetch("http://api.example.com");
const userDTOs: any[] = await response.json();
const userModels: UserModel[] = userDTOs.map((user) => ({
...user,
lastActiveAt: Date.parse(user.lastActiveAt),
createdAt: Date.parse(user.createdAt),
updatedAt: Date.parse(upser.updatedAt)
}));
return userModels;
}
```

Notice that we have introduced two new terms for the user type. In the first example (`UserDTO`), we use the term <i>DTO</i> (data transfer object) to indicate that this is the format of the <i>transferred</i> data, i.e., the raw data. In the second example (`UserModel`), we have used the term <i>model</i> to indicate that this a model of a user object in our application. This terminology is used extensively in AcquireJS.

The first solution is straightforward, but adds a lot of mental strain on the developer. Elsewhere in the application, we would have the burden of having to know that `lastActiveAt` (and the other date values) is a date represented in string format. In order to do any sort of date logic on it, we would likely need to transform it into some kind of Date object first. Essentially, this just moves the problem further into our application.

The second option is more appealing, as the request method provides a natural location in our code base to map raw data to a more pleasent format to work with. However, it has other limitations when it comes to <i>testing</i>.

### 3. Testing

Testing our code is an important part of the development process. However, if we have opted to go for the second option outlined above, we will have a harder time testing code that is tied to the users API. We may have some function or component that requires a `UserModel` as an input. In order to test this code, we could write a `mockUserModel` function that generates a random user object for us and that would work fine. However, later down the line, we might wish to write more elaborate tests that intercept the `getUsers` method at the network level (e.g., using [Mock Service Worker](https://mswjs.io/)), at which point we don't have any typing for the raw data (the `UserDTO` type), so we would have a harder time making sure the mocked data is in the right format. Additionally, we would have a harder time testing the mapping portion of the `getUsers` method for the same reason.

## Solutions
Loading

0 comments on commit 8604bfd

Please sign in to comment.