Skip to content

Commit

Permalink
Merge pull request #8 from ErikLysne/refactor/core-package-changes
Browse files Browse the repository at this point in the history
Refactor/core package changes
  • Loading branch information
ErikLysne authored Jul 22, 2023
2 parents 9f11ba3 + ec5a56f commit 76ebadf
Show file tree
Hide file tree
Showing 37 changed files with 2,293 additions and 2,050 deletions.
15 changes: 15 additions & 0 deletions .changeset/chilled-trains-move.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@acquirejs/mocks": minor
"@acquirejs/core": minor
---

Refactored core package (BREAKING CHANGE)

- Added AcquireBase class to deal with shared middleware.
- Renamed AcquireRequestExecutor class to AcquireRequestHandler.
- Reworked AcquireRequestHandlers call signature.
- Reworked Acquire class to generate request handlers through the createRequestHandler method.
- Added AcquireRequestHandlerFactory class to deal with request handler creation.
- Renamed AcquireRequestLogger middleware to RequestLogger.
- Added DelaySimulator middleware.
- Updated README.
167 changes: 79 additions & 88 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
AcquireJS is a TypeScript library designed to streamline the process of working with REST APIs. The library aims to solve three common pain-points in the data fetching/mutation process:

- 🔒 <b>Type safety</b> - Ensure that data going into and out of your application from external APIs is type safe.
- 🔒 <b>Type safety</b> - Ensure that data going into and out of your application from REST APIs is type safe.

- 🗺️ <b>Data mapping</b> - Decide how data going into and out of your application from external APIs should be mapped, using a declarative approach.
- 🗺️ <b>Data mapping</b> - Decide how data going into and out of your application from REST APIs should be mapped, using a declarative approach.

- 🎭 <b>Mocking and testing</b> - Easily mock data and API responses in order to test your code. Mock Data Transfer Objects (DTOs) to write unit tests at the function and component level, mock API calls to write integration tests at the page level or mock API calls with relational data to write End-to-End (E2E) tests at the application level.

Expand Down Expand Up @@ -55,6 +55,18 @@ yarn add @acquirejs/core reflect-metadata
```

> 💡 <b>Tip:</b> AcquireJS is built on [axios](https://axios-http.com/docs/intro). If you want to specify an axios config, you should also install axios.
>
> Using npm:
>
> ```bash
> npm install axios
> ```
>
> Using yarn:
>
> ```bash
> yarn add axios
> ```
---
Expand Down Expand Up @@ -122,7 +134,7 @@ This will allow multiple requests to share the same default settings, like base

A key concept in AcquireJS is to use two sets of classes for each endpoint: A <b>DTO</b> class and a <b>Model</b> class. The DTO (Data Transfer Object) is a class representing the data as delivered from or to the server. It should only contain JSON primitive values.

Imagine an example endpoint (`http://api.example.com/users/1`) that returns a user JSON response that looks like this:
Imagine an example endpoint (`http://api.example.com/users/1`) that returns a user JSON response in the following format:

```json
{
Expand Down Expand Up @@ -200,37 +212,31 @@ import acquire from "../../acquire.ts";
import UserDTO from "./dtos/UserDTO.ts";
import UserModel from "./models/UserModel.ts";

export const getUser = acquire({
request: {
export const getUser = acquire
.createRequestHandler()
.withResponseMapping(UserModel, UserDTO)
.get({
url: "http://api.example.com/users/1"
},
responseMapping: {
DTO: UserDTO,
Model: UserModel
}
});
});
```

> 💡 <b>Tip:</b> If you instantiated `Acquire` using an `axios` instance with a `baseURL` of `http://api.example.com/`, you could instead just set the `path`:
>
> ```typescript
> export const getUser = acquire({
> request: {
> export const getUser = acquire
> .createRequestHandler()
> .withResponseMapping(UserModel, UserDTO)
> .get({
> path: "/users/1"
> },
> responseMapping: {
> DTO: UserDTO,
> Model: UserModel
> }
> });
> });
> ```
### Calling the request executor
The request can now be executed:
```typescript
import { getUser } from "path/to/getUser";
import { getUser } from "path-to-getUser";
const user = await getUser();
Expand All @@ -247,44 +253,38 @@ Here, `user.model` is typed and mapped according to the `UserModel` class! 🎉

### Using dynamic request arguments

In the previous example, the ID of the user was hard-coded into the url, causing `getUser` to always return the user with ID 1. This was only shown as a simplistic example, but in general, the ID should be passed as an argument to the `getUser` method. This can be done by passing an object with a `userId` key to the `getUser` method. All properties on the `request` object can be set as values or callbacks that take `callArgs` as the argument. The type of `callArgs` can be set by binding `callArgs` to the method using the `withCallArgs` method on the `Acquire` instance:
In the previous example, the ID of the user was hard-coded into the url, causing `getUser` to always return the user with ID 1. This was only shown as a simplistic example, but in general, the ID should be passed as an argument to the `getUser` method.

Setting dynamic request parameters can be done by passing a generic type (`TCallArgs`) to the `get` method. The generic type must extend an object and will be required when calling the `getUser` function. All request configuration properties can be set as values or callbacks that take `TCallArgs` as the argument. In the following example, the `userId` is injected into the path of the url:

```typescript
export const getUser = acquire.withCallArgs<{ userId: number }>()({
request: {
url: (callArgs) => `http://api.example.com/users/${callArgs?.userId}`
},
responseMapping: {
DTO: UserDTO,
Model: UserModel
}
});
export const getUser = acquire
.createRequestHandler()
.withResponseMapping(UserModel, UserDTO)
.get<{ userId: number }>({
url: ({ userId }) => `http://api.example.com/users/${userId}`
});
```

And calling the method like so:
`getUser` is then called like so:

```typescript
const user = await getUser({ userId: 10 });
const { model: user } = await getUser({ userId: 10 });
```

> 🔍 <b>Caveat:</b> Note that when using `withCallArgs`, the function is curried. Don't forget the additional set of parenthesis!
---

### Requests that return arrays

Endpoints that return lists of items typically return a JSON array response. When working with endpoints that directly return arrays, the DTO and Model can be wrapped in an array:

```typescript
export const getUsers = acquire({
request: {
export const getUsers = acquire
.createRequestHandler()
.withResponseMapping([UserModel], [UserDTO]) // 👈 notice the square brackets!
.get({
url: "http://api.example.com/users"
},
responseMapping: {
DTO: [UserDTO],
Model: [UserModel]
}
});
});
```

Now, the return type of `getUsers` has `model` typed as a `UserModel[]` and `dto` as `UserDTO[]`.
Expand All @@ -293,7 +293,7 @@ Now, the return type of `getUsers` has `model` typed as a `UserModel[]` and `dto

### Mutations

AcquireJS can also perform mutations. In this case, the request `method` can be specified in the `request` argument. Additionally, a `requestMapping` can be provided, similar to the `responseMapping`. In general, the DTO used for queries and mutations may differ. For instance, the `UserDTO` in the previous example has information about the ID of the user, if the user is currently active, as well as when the user was created, last updated and last active. While this information is not included in the body of a `POST` request, it may appear in the response of the request. Hence, separate `CreateUserDTO` and `CreateUserModel` classes can be created to deal with the outgoing data:
AcquireJS can also perform mutations. In this case, a request method other than `get` (e.g., `put`, `post`, `delete`) can be used as the final function to end the chaining. Additionally, a `withRequestMapping` can be provided, similar to the `withResponseMapping`. In general, the DTO used for queries and mutations may differ. For instance, the `UserDTO` in the previous example has information about the ID of the user, if the user is currently active, as well as when the user was created, last updated and last active. While this information is not included in the body of a `post` request, it may appear in the response of the same request. Hence, separate `CreateUserDTO` and `CreateUserModel` classes can be created to deal with the outgoing data:

```typescript
// src/api/users/dtos/CreateUserDTO.ts
Expand Down Expand Up @@ -338,22 +338,22 @@ import CreateUserDTO from "./dtos/CreateUserDTO.ts";
import UserModel from "./models/UserModel.ts";
import CreateUserModel from "./models/CreateUserModel.ts";

export const getUser = acquire(/* From the previous example...*/);

export const createUser = acquire({
request: {
url: "http://api.example.com/users",
method: "POST"
},
requestMapping: {
DTO: CreateUserDTO,
Model: CreateUserModel
},
responseMapping: {
DTO: UserDTO,
Model: UserModel
}
});
// From the previous example...
export const getUser = acquire
.createRequestHandler()
.withResponseMapping(UserModel, UserDTO)
.get<{ userId: number }>({
url: ({ userId }) => `http://api.example.com/users/${userId}`
});

// Adding a mutation
export const createUser = acquire
.createRequestHandler()
.withRequestMapping(CreateUserModel, CreateUserDTO)
.withResponseMapping(UserModel, UserDTO)
.post({
url: "http://api.example.com/users"
});
```

To pass this data to the `createUser` method, `data` can be set in the argument:
Expand All @@ -366,7 +366,7 @@ const user = await createUser({
email: "[email protected]",
phoneNumber: "+1987654321",
role: "basic-user"
}
} // 👈 The type of `data` is dictated by `CreateUserModel`
});
```

Expand All @@ -385,9 +385,9 @@ In the examples above, the `UserDTO` class was always specified, but was not rea
> 💡 <b>Tip:</b> To see if a request is executed or mocked, and to see if the mocking is <i>on demand</i> or <i>from interceptors</i>, you can attach a logger to the `Acquire` instance:
>
> ```typescript
> import { Acquire, AcquireRequestLogger } from "@acquirejs/core";
> import { Acquire, RequestLogger } from "@acquirejs/core";
>
> const acquire = new Acquire().use(new AcquireRequestLogger());
> const acquire = new Acquire().use(new RequestLogger());
>
> export default acquire;
> ```
Expand Down Expand Up @@ -500,18 +500,18 @@ When mocking an AcquireJS request, no actual network request is executed. Instea

This is mostly useful when writing simple unit tests, but is not that suited for testing components that fetch data, as it requires us to modify how the function is called.

> 💡 <b>Tip:</b> When calling a request executor function using `.mock()`, you can (in addition to the `callArgs`) provide a `$count` argument, i.e.,
> 💡 <b>Tip:</b> When calling a request executor function using `.mock()`, the final argument passed to the function is the generated data count:
>
> ```typescript
> const users = await getUsers.mock({ $count: 100 });
> const users = await getUsers.mock(100);
> ```
>
> This will decide how many objects are returned for functions that return arrays (in this case, 100 mock users are generated). The default count is 10.
2. <b>Enable mocking globally:</b>
```typescript
import acquire from "path/to/acquire";
import acquire from "path-to-acquire";

acquire.enableMocking();
// or
Expand Down Expand Up @@ -640,28 +640,19 @@ import acquire from "../../acquire.ts";
import PostDTO from "./dtos/PostDTO.ts";
import PostModel from "./models/PostModel.ts";

export const getPosts = acquire.withCallArgs<{
createdByUserId?: number;
page?: number;
pageSize?: number;
sortBy?: keyof PostDTO;
sortByDescending?: boolean;
}>()({
request: {
export const getPosts = acquire
.createRequestHandler()
.withResponseMapping(UserModel, UserDTO)
.get<{
createdByUserId?: number;
page?: number;
pageSize?: number;
sortBy?: keyof PostDTO;
sortByDescending?: boolean;
}>({
url: "http://api.example.com/posts",
params: (callArgs) => ({
createdByUserId: callArgs?.createdByUserId,
page: callArgs?.page,
pageSize: callArgs?.pageSize,
sortBy: callArgs?.sortBy,
sortByDescending: callArgs?.sortByDescending
})
},
responseMapping: {
DTO: UserDTO,
Model: UserModel
}
});
params: (callArgs) => callArgs
});
```

Here, it is assumed that the `/posts` endpoint can accept additional parameters which can be used to filter the returned posts.
Expand All @@ -671,11 +662,11 @@ As the DTOs now have relations, it is necessary to store all the mocked data som
```typescript
// src/api/acquire.ts

import { Acquire, AcquireLogger, AcquireMockCache } from "@acquirejs/core";
import { Acquire, AcquireMockCache, RequestLogger } from "@acquirejs/core";

const acquire = new Acquire()
.useMockCache(new AcquireMockCache())
.use(new AcquireRequestLogger());
.use(new RequestLogger());

export default acquire;
```
Expand Down
18 changes: 7 additions & 11 deletions demos/vite-demo/src/api/acquire.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,14 @@
import {
Acquire,
AcquireMockCache,
AcquireRequestLogger
} from "@acquirejs/core";
import { Acquire, AcquireMockCache, RequestLogger } from "@acquirejs/core";
import axios from "axios";

const axiosInstance = axios.create({
baseURL: "https://jsonplaceholder.typicode.com"
});

export const mockCache = new AcquireMockCache();

const acquire = new Acquire(axiosInstance)
const acquire = new Acquire(
axios.create({
baseURL: "https://jsonplaceholder.typicode.com"
})
)
.useMockCache(mockCache)
.use(new AcquireRequestLogger());
.use(new RequestLogger());

export default acquire;
50 changes: 20 additions & 30 deletions demos/vite-demo/src/api/comment/commentApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,36 +4,26 @@ import { CreateCommentDTO } from "./dtos/CreateCommentDTO";
import { CommentModel } from "./models/CommentModel";
import { CreateCommentModel } from "./models/CreateCommentModel";

export const getComments = acquire.withCallArgs<{
postId?: number;
sort?: keyof CommentDTO;
order?: "asc" | "desc";
}>()({
request: {
export const getComments = acquire
.createRequestHandler()
.withResponseMapping([CommentModel], [CommentDTO])
.get<{
postId?: number;
sort?: keyof CommentDTO;
order?: "asc" | "desc";
}>({
path: "/comments",
params: (args) => ({
postId: args?.postId,
_sort: args?.sort,
_order: args?.order
params: ({ postId, sort, order }) => ({
postId: postId,
_sort: sort,
_order: order
})
},
responseMapping: {
DTO: [CommentDTO],
Model: [CommentModel]
}
});
});

export const createComment = acquire({
request: {
path: "/comments",
method: "POST"
},
requestMapping: {
DTO: CreateCommentDTO,
Model: CreateCommentModel
},
responseMapping: {
DTO: CommentDTO,
Model: CommentModel
}
});
export const createComment = acquire
.createRequestHandler()
.withRequestMapping(CreateCommentModel, CreateCommentDTO)
.withResponseMapping(CommentModel, CommentDTO)
.post({
path: "/comments"
});
Loading

0 comments on commit 76ebadf

Please sign in to comment.