From 8604bfdc5684f1f0678686f62363b7530d7e9b59 Mon Sep 17 00:00:00 2001 From: Erik Lysne Date: Tue, 11 Jul 2023 12:44:38 +0200 Subject: [PATCH] Added support for middleware --- .changeset/fast-impalas-thank.md | 8 + README.md | 167 ++++---- demos/vite-demo/package.json | 1 + demos/vite-demo/src/App.tsx | 2 + demos/vite-demo/src/api/acquire.ts | 8 +- .../src/api/{mockInit.ts => acquireInit.ts} | 2 +- .../src/api/comment/commentApiMocking.ts | 32 +- .../vite-demo/src/api/post/postApiMocking.ts | 45 +- .../vite-demo/src/api/user/userApiMocking.ts | 18 +- demos/vite-demo/src/main.tsx | 4 +- motivation.md | 151 ++++++- packages/core/src/classes/Acquire.class.ts | 85 ++-- .../core/src/classes/AcquireLogger.class.ts | 65 ++- .../classes/AcquireRequestExecutor.class.ts | 339 ++++++++------- .../src/functions/generateMock.function.ts | 20 +- .../src/guards/isAcquireMiddleware.guard.ts | 7 + packages/core/src/index.ts | 26 +- .../src/interfaces/AcquireArgs.interface.ts | 3 +- ...terface.ts => AcquireContext.interface.ts} | 21 +- .../interfaces/AcquireMiddleware.interface.ts | 15 + .../AcquireMockGenerator.interface.ts | 4 +- .../AcquireMockInterceptor.interface.ts | 20 - .../src/interfaces/AcquireResult.interface.ts | 12 - .../src/middleware/AcquireRequestLogger.ts | 90 ++++ packages/core/tests/classes/Acquire.test.ts | 60 +-- .../core/tests/classes/AcquireLogger.test.ts | 36 +- .../classes/AcquireRequestExecutor.test.ts | 385 ++++++++++++++++-- .../integration/mock-interceptor.test.ts | 13 +- .../tests/integration/mock-mutation.test.ts | 28 +- .../tests/integration/mock-relations.test.ts | 16 +- .../middleware/AcquireRequestLogger.test.ts | 112 +++++ yarn.lock | 40 ++ 32 files changed, 1338 insertions(+), 497 deletions(-) create mode 100644 .changeset/fast-impalas-thank.md rename demos/vite-demo/src/api/{mockInit.ts => acquireInit.ts} (82%) create mode 100644 packages/core/src/guards/isAcquireMiddleware.guard.ts rename packages/core/src/interfaces/{AcquireMockContext.interface.ts => AcquireContext.interface.ts} (55%) create mode 100644 packages/core/src/interfaces/AcquireMiddleware.interface.ts delete mode 100644 packages/core/src/interfaces/AcquireMockInterceptor.interface.ts delete mode 100644 packages/core/src/interfaces/AcquireResult.interface.ts create mode 100644 packages/core/src/middleware/AcquireRequestLogger.ts create mode 100644 packages/core/tests/middleware/AcquireRequestLogger.test.ts diff --git a/.changeset/fast-impalas-thank.md b/.changeset/fast-impalas-thank.md new file mode 100644 index 0000000..79eea69 --- /dev/null +++ b/.changeset/fast-impalas-thank.md @@ -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. diff --git a/README.md b/README.md index e56034a..c8fcd30 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ yarn add @acquirejs/core reflect-metadata ## ⚙️ Configuring TypeScript -To use AcquireJS, we need to tweak some TypeScript settings. In `tsconfig.json`: +To use AcquireJS, you must tweak some TypeScript settings. In `tsconfig.json`: ```json { @@ -71,7 +71,13 @@ To use AcquireJS, we need to tweak some TypeScript settings. In `tsconfig.json`: } ``` -We also need to import `reflect-metadata` at the entry-point of our application. +You also need to import `reflect-metadata` at the entry-point of your application: + +```typescript +import "reflect-metadata"; + +// Application entry-point... +``` --- @@ -90,7 +96,7 @@ const acquire = new Acquire(); export default acquire; ``` -We can also pass in an axios instance as the first argument: +You can also pass in an axios instance as the first argument: ```typescript // src/api/acquire.ts @@ -108,13 +114,13 @@ export default acquire; This will allow multiple requests to share the same default settings, like base url and headers. -> 💡 Tip: If you are working with multiple APIs, you typically want to create one `Acquire` instance for each domain. +> 💡 Tip: If you are working with multiple APIs, you typically want to create one `Acquire` instance for each domain, so properties like `baseURL` and `headers` can be configured separately. ### Defining DTO and Model classes A key concept in AcquireJS is to use two sets of classes for each endpoint: A DTO class and a Model 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. -If we have an imaginary 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 that looks like this: ```json { @@ -131,7 +137,7 @@ If we have an imaginary endpoint (`http://api.example.com/users/1`) that returns } ``` -The DTO class should look like this: +The DTO class should then look like this: ```typescript // src/api/users/dtos/UserDTO.ts @@ -152,7 +158,7 @@ export default class UserDTO { > 🔍 Caveat: Notice how we are preserving values in their JSON primitive representation at this stage, so even though `lastActive`, `createdAt` and `updatedAt` represent dates, we keep them as strings. -Next, we create the Model class which represents the desired format of the data (i.e., how we wish the data looks like when it enters our application). It may look like this: +A Model class should also be created, which represents the desired format of the data. It may look like this: ```typescript // src/api/users/models/UserModel.ts @@ -173,11 +179,11 @@ export default class UserModel { } ``` -Here, there are a few things to note: First, we use the `Expose` decorator on each field of the class. This is because all values on the response object not defined on the `UserModel` will be stripped. If we wanted to omit some fields from the response object to tidy up our interfaces, we could simply drop the value from the Model class. This is only possible by explicitly annotating the values we wish to expose. +Here, there are a few things to note: First, the `Expose` decorator has been applied to each field of the class. This is because all values on the response object not defined on the `UserModel` with `Expose` will be stripped. This allows fields on the DTO to be omitted from the model (e.g., to clean up the interface) and is only possible by explicitly annotating the values to expose. > 💡 Tip: The `Expose` decorator comes straight from the [class-transformer](https://github.com/typestack/class-transformer) library, which is bundled with `@acquirejs/core`. All decorators and functions from `class-transformer` can be imported directly from `@acquirejs/core`. -The second thing to note is that we have configured the `UserModel` to automatically transform the date strings to `Date` objects. This is done by both specifying the type of the value as `Date` (for type safety) and adding a `ToDate` transformer decorator (for data mapping). This allows data mapping to be done in a declarative manner. +The second thing to note is that the `UserModel` has been configured to automatically transform the date strings to `Date` objects. This is done by both specifying the type of the value as `Date` (for type safety) and adding a `ToDate` transformer decorator (for data mapping). This allows data mapping to be done in a declarative manner. > 💡 Tip: See the full list of available transformer decorators, as well as how to write your own. @@ -219,7 +225,7 @@ export const getUser = acquire({ ### Calling the request executor -We can now execute the request at the desired place in our application: +The request can now be executed: ```typescript import { getUser } from "path/to/getUser"; @@ -231,7 +237,7 @@ user.dto;, // type: UserDTO (before mapping) user.response; // type: AxiosResponse ``` -Now we can use `user.model` which is correctly typed and mapped! 🎉 +Here, `user.model` is typed and mapped according to the `UserModel` class! 🎉 --- @@ -239,7 +245,7 @@ Now we can use `user.model` which is correctly typed and mapped! 🎉 ### 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, we would like to pass the ID as an argument to the `getUser` method. We can do this 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. 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: ```typescript export const getUser = acquire.withCallArgs<{ userId: number }>()({ @@ -253,7 +259,7 @@ export const getUser = acquire.withCallArgs<{ userId: number }>()({ }); ``` -And call the method like so: +And calling the method like so: ```typescript const user = await getUser({ userId: 10 }); @@ -265,7 +271,7 @@ const user = await getUser({ userId: 10 }); ### Requests that return arrays -Endpoints that return lists of items typically return a JSON array response. We can inform AcquireJS that we are fetching an array by wrapping the Model and DTO classes in 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({ @@ -281,13 +287,11 @@ export const getUsers = acquire({ Now, the return type of `getUsers` has `model` typed as a `UserModel[]` and `dto` as `UserDTO[]`. -> 🔍 Caveat: This syntax may at first seem peculiar, but it provides a simple and compact way of differentiating between objects and arrays at the type level as well as in run-time. - --- ### Mutations -We can also use AcquireJS to perform mutations. In this case, we can specify the request `method` as something other then `GET` (the default) and optionally provide a `requestMapping`, 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 body we receive. Hence, we create separate `CreateUserDTO` and `CreateUserModel` classes to deal with the outgoing data: +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: ```typescript // src/api/users/dtos/CreateUserDTO.ts @@ -317,11 +321,11 @@ export default class CreateUserModel { } ``` -Here, like before, the `CreateUserModel` represents the state of the data within our application (typically originating from a form), while `CreateUserDTO` is the object we send to the server. +Here, like before, the `CreateUserModel` represents the state of the data within the application, while `CreateUserDTO` is the object sent to the server. > 🔍 Caveat: Note that since we are doing the mapping process in reverse for outgoing data, we need to put the `Expose` decorators on the DTO class, not the Model class! -We can then create another request executor function for the mutation: +Another request executor function can be created for the mutation: ```typescript // src/api/users/userApi.ts @@ -350,7 +354,7 @@ export const createUser = acquire({ }); ``` -To pass this data to the `createUser` method, we set the `data` in the argument: +To pass this data to the `createUser` method, `data` can be set in the argument: ```typescript const user = await createUser({ @@ -370,27 +374,27 @@ By specifying the `CreateUserModel` in the `responseMapping`, the `data` argumen ## 🎭 Mocking and testing -In the examples above, we specified the `UserDTO` class, but did not really use it for anything. The DTO classes come into play when we are writing tests. Instead of tediously writing our own mock data generation code, AcquireJS can handle this process for us through use of Mock decorators. When mocking requests, mock data can be generated in one of two ways: +In the examples above, the `UserDTO` class was always specified, but was not really used for anything. The DTO classes come into play when writing tests. Instead of tediously writing your own mock data generation code, AcquireJS can handle this process for you through use of Mock decorators. When mocking requests, mock data can be generated in one of two ways: -1. ON DEMAND - When mock data is generated on demand, it is created at the time when the request executor function is called and then discarded. This can be useful for simpler test cases, where the mocked data does not have any relation to other data. However, when mock data is generated on demand, calling the same request executor function multiple times will not yield the same response, unless the random generator is reset in between each call. +1. ON DEMAND - When mock data is generated on demand, it is created at the time when the request executor function is called and then discarded. This can be useful for simpler test cases, where the mocked data does not have any relation to other data. Mocking data on demand is not idempotent unless the random generator is reset in between each call, so calling the same request executor function multiple times will not yield the same response. -2. FROM INTERCEPTORS - In more complex situations, we may need to mock requests that rely on other existing data. For example, we may need to mock DTOs that include properties or IDs from other objects that must be pre-defined. In these cases, we can configure a mock cache and pre-fill it with data. In addition, we may provide mock interceptor functions that intercept the request and allow us to fetch or mutate data in the cache before sending a response. This is useful if we for instance wish to mock an entire application and we need consistent IDs to make the application behave in a meaningful way. Thankfully, this can usually be achieved with minimal extra code. +2. FROM MIDDLEWARE - In more complex situations, it may be necessary to mock requests that rely on other existing data. For example, when mocking DTOs that include properties or IDs from other objects that must be pre-defined. In these cases, a mock cache can be configured and pre-filled with data. In addition, middleware can be applied that intercept the request and perform side effects or mutate the response. This is useful when mocking an entire application and consistent IDs are required to make the application behave in a meaningful way. Thankfully, this can usually be achieved with minimal extra code. > 💡 Tip: To see if a request is executed or mocked, and to see if the mocking is on demand or from interceptors, you can attach a logger to the `Acquire` instance: > > ```typescript -> import { Acquire, AcquireLogger } from "@acquirejs/core"; +> import { Acquire, AcquireRequestLogger } from "@acquirejs/core"; > -> const acquire = new Acquire().useLogger(new AcquireLogger()); +> const acquire = new Acquire().use(new AcquireRequestLogger()); > > export default acquire; > ``` -In order to mock requests, we first need to annotate the DTO classes, in order to specify what type of data we should mock. +To get started with mocking, the DTO classes must be updated to include Mock decorators. ### Using the Mock decorator -We can use the `Mock` decorator to hard-code JSON primitive values for the DTO class: +The `Mock` decorator can be used to hard-code JSON primitive values for the DTO class: ```typescript import { Mock } from "@acquirejs/core"; @@ -404,7 +408,7 @@ export default class UserDTO { } ``` -This would pass those values onto the generated mock data. However, all mocked requests would then end up getting the exact same data, which might not be what we want. A more meaningful approach is to pass a callback to `Mock` that returns a JSON primitive value: +This will pass those values onto the generated mock data. However, all mocked requests would then end up getting the exact same data. It is generally more meaningful to pass a callback to `Mock` that returns a JSON primitive value: ```typescript import { Mock } from "@acquirejs/core"; @@ -422,13 +426,13 @@ Here, you can use functions that generate randomly generated data and even execu > 🔍 Caveat: When are the Mock callbacks actually called? > -> When annotating the class with Mock decorators that take callbacks, the functions are not invoked until the moment when data is generated. Keep this in mind when using Mock decorators to generate random data. +> When annotating the class with Mock decorators with callbacks, the functions are not invoked until the moment when data is generated. Keep this in mind when using Mock decorators to generate random data. AcquireJS comes with an additional package `@acquirejs/mocks` that exports a large set of decorators that can be used to annotate DTO classes with mock data. ## Using the @acquirejs/mocks package -To get started, we must first install the mocks package: +To get started, install the mocks package: ```bash npm install @acquirejs/mocks @@ -440,7 +444,7 @@ Or yarn add @acquirejs/mocks ``` -Then, somewhere early in our code (before any mocks are invoked), we need to call `initAcquireMocks`: +Then, somewhere near the entry point of your application (before any mocks are invoked), call `initAcquireMocks`: ```typescript import { initAcquireMocks } from "@acquirejs/mocks"; @@ -448,7 +452,7 @@ import { initAcquireMocks } from "@acquirejs/mocks"; initAcquireMocks(); ``` -We can then import the decorators we need: +You can then import the required decorators: ```typescript // src/api/users/dtos/UserDTO.ts @@ -480,7 +484,7 @@ export default class UserDTO { > 💡 Tip: `@acquirejs/mocks` is a wrapper around the [Chance](https://chancejs.com/) library. All Mock decorators can be passed the arguments from their Chance counterpart. Some modification has been made to ensure that the decorators return JSON primitive values. For more info about the mock decorators, please refer to the Chance documentation. -> 💡 Tip: You don't need to worry about omitting this code in your production build if you are using a build tool that supports tree-shaking. Instead, you should conditionally call `initAcquireMocks` based on environment variables. If the `initAcquireMocks` call is never reached, the Chance library is not imported and all Mock decorators from `@acquirejs/mocks` are replaced with empty function calls. +> 💡 Tip: You don't need to worry about omitting this code in your production build if you are using a build tool that supports tree-shaking. Instead, you should conditionally call `initAcquireMocks` based on environment variables. If `initAcquireMocks` is never invoked, the Chance library is not imported and all Mock decorators from `@acquirejs/mocks` are replaced with empty function calls. ### Mocking requests @@ -512,13 +516,13 @@ When mocking an AcquireJS request, no actual network request is executed. Instea acquire.setMockingEnabled(true); ``` - This enables mocking for all request executor functions created by the `Acquire` instance. This allows us to enable mocking without modifying the code where it runs, e.g., within a page or a component. + This enables mocking for all request executor functions created by the `Acquire` instance. This allows mocking to be enabled without modifying the code where it runs, e.g., within a page or a component. ### Mocking data with relations using mock interceptors and the mock cache -Although generating mock data on demand is the simplest approach, it is not always a viable option. For instance, we may wish to test an entire application where multiple endpoints are involved and the data returned from the endpoints have relations. As we will see, we can achieve this using some special Mock decorators: `MockID`, `MockRelationID` and `MockRelationProperty`. +Although generating mock data on demand is the simplest approach, it is not always a viable option. For instance, when testing an entire application where multiple endpoints are involved and the data returned from the endpoints have relations. This can be achieved using some special Mock decorators: `MockID`, `MockRelationID` and `MockRelationProperty`. -Continuing with our example, lets imagine we have another endpoint at `http://api.example.com/posts` that returns blog posts JSON response that looks like this: +Continuing with the previous example, imagine an another endpoint at `http://api.example.com/posts` that returns blog posts JSON response that looks like this: ```json { @@ -532,9 +536,9 @@ Continuing with our example, lets imagine we have another endpoint at `http://ap } ``` -Here, `createdByUserId`, `createdByUserFirstName` and `createdByUserLastName` are related to the `/users` endpoint. If we generated both the user and blog post on demand, these values would not be consistent, which could be essential for more sophisticated tests. +Here, `createdByUserId`, `createdByUserFirstName` and `createdByUserLastName` are related to the `/users` endpoint. If both the user and blog post were generated on demand, these values would not be in sync, which could be essential for more sophisticated tests. -To solve this, we need to add some additional steps. Firstly, we are going to make a slight modification to the `UserDTO` class: +To solve this, some additional steps are required. Firstly, the `UserDTO` class must be modified to use the `MockID` decorator: ```typescript // src/api/users/dtos/UserDTO.ts @@ -551,7 +555,7 @@ import { } from "@acquirejs/mocks"; export default class UserDTO { - @MockID() id: number; // <- update this + @MockID() id: number; // 👈 update this @MockFirstName() firstName: string; @MockLastName() lastName: string; @MockEmail() email: string; @@ -564,7 +568,7 @@ export default class UserDTO { } ``` -Previously, we used the `MockNatural` decorator to mock a natural number (positive integer) for the `id` field. By replacing this with `MockID`, we inform AcquireJS that the `id` field represents the database ID. +Previously, the `MockNatural` decorator was used to mock a natural number (positive integer) for the `id` field. By replacing this with `MockID`, AcquireJS is informed that that the `id` field represents the database ID. > 💡 Tip: If you are working with an API that is using non-numerical IDs and this is important for your testing, you can provide your own ID generator to `MockID`. You may do this in the following way: @@ -581,7 +585,7 @@ Previously, we used the `MockNatural` decorator to mock a natural number (positi > > However, it might not actually matter what format the IDs are in if they are only used to reference other data. -Next, we need to create a `PostDTO` class: +Next, create a `PostDTO` class: ```typescript // src/api/posts/dto/PostDTO.ts @@ -603,11 +607,11 @@ export default class PostDTO { } ``` -Notice how we are using `MockRelationID` to annotate that the `createdByUserID` represents the ID of the `UserDTO` object. We are also specifying that `createdByUserFirstName` should be taken from the `firstName` field on the `UserDTO`, and similarly for `createdByUserLastName` and `lastName`. +Notice how `MockRelationID` is used to indicate that the `createdByUserID` represents the ID of the `UserDTO` object. Additionally, `createdByUserFirstName` is configured to be taken from the `firstName` field on the `UserDTO` (and similarly for `createdByUserLastName` and `lastName`). -Now, when a `PostDTO` is mocked, it is guaranteed to have a `createdByUserId` from an existing `UserDTO` and the `createdByUserFirstName` and `createdByUserLastName` will be taken from the same object. By adding these links, we are enforcing the relationship between the `UserDTO` and `PostDTO` classes, which gives us better consistency when testing. +Now, when a `PostDTO` is mocked, it is guaranteed to have a `createdByUserId` from an existing `UserDTO` and the `createdByUserFirstName` and `createdByUserLastName` will be taken from the same object. By adding these links, the relationship between the `UserDTO` and `PostDTO` classes are enforced, which leads to better consistency when testing. -We are also going to create a `PostModel` class: +Next, create a `PostModel` class: ```typescript // src/api/posts/models/PostModel.ts @@ -658,9 +662,9 @@ export const getPosts = acquire.withCallArgs<{ }); ``` -Here, we are imagining that the `/posts` endpoint can accept additional parameters which can be used to filter the returned posts. +Here, it is assumed that the `/posts` endpoint can accept additional parameters which can be used to filter the returned posts. -Because we are now mocking DTOs with relationships, we need somewhere to store all the mocked data so we can reference it. We can add a mock cache to the `Acquire` instance: +As the DTOs now have relations, it is necessary to store all the mocked data somewhere so it can be referenced. This is done by adding a mock cache to the `Acquire` instance: ```typescript // src/api/acquire.ts @@ -669,12 +673,12 @@ import { Acquire, AcquireLogger, AcquireMockCache } from "@acquirejs/core"; const acquire = new Acquire() .useMockCache(new AcquireMockCache()) - .useLogger(new AcquireLogger()); + .use(new AcquireRequestLogger()); export default acquire; ``` -Now we can pre-fill the mock cache with data. Because the blog post is referencing users, we need some users to already be present in the mock cache. We can create a function to populate the cache which should be called before any mocks are invoked, but after calling `initAcquireMocks`: +Now the mock cache can be pre-filled with data. Because the blog post is referencing users, some users must already be present in the mock cache. You can create a function to populate the cache which should be called before any mocks are invoked, but after calling `initAcquireMocks`: ```typescript // src/api/populateMockCache.ts @@ -695,7 +699,7 @@ This will populate the mock cache with 20 randomly generated `UserDTOs` and 100 > 🔍 Caveat: Note that the `mockCache.fill` function is async and needs to be awaited. Under the hood, the generation process is most likely going to be synchronous, so you don't need to worry about parallelizing multiple `mockCache.fill` calls. -The final step is to set up a mock interceptor function for the `getPosts` method that can enforce the query parameters we set up in the `callArgs`. We can do that in a separate file and import it before making any mock requests: +The final step is to set up middleware to interceptor the request for the `getPosts` method, which can enforce the query parameters set up in the `callArgs`. This can be done in a separate file: ```typescript // src/api/posts/postApiMocking.ts @@ -703,43 +707,38 @@ The final step is to set up a mock interceptor function for the `getPosts` metho import { getPosts } from "./getPosts.ts"; import PostDTO from "./dtos/PostDTO.ts"; -getPosts.setMockInterceptor( - async ({ mockResponse, mockCache, callArgs, delay }) => { - const { createdByUserId, page, pageSize, sortBy, sortByDescending } = - callArgs ?? {}; - - const dbSimulator = mockCache?.createDatabaseSimulator(PostDTO); - - // Filter data from the cache based on `callArgs` - const data = dbSimulator - ?.filter( - createdByUserId - ? (post) => post.createdByUserId === createdByUserId - : undefined - ) - .sort(sortBy, sortByDescending ? "desc" : "asc") - .paginate(page, pageSize, 1) - .get(); - - // Add the data to the response - mockResponse.data = data; - - // Update the header with the total number of posts - mockResponse.headers = { - ...mockResponse.headers, - ["x-total-count"]: dbSimulator?.count() - }; - - // Delay 100-300ms - await delay(100, 300); - - return Promise.resolve(mockResponse); - } -); +getPosts.useOnMocking(({ response, mockCache, callArgs }) => { + const { createdByUserId, page, pageSize, sortBy, sortByDescending } = + callArgs ?? {}; + + const dbSimulator = mockCache!.createDatabaseSimulator(PostDTO); + + // Filter data from the cache based on `callArgs` + const data = dbSimulator + .filter( + createdByUserId + ? (post) => post.createdByUserId === createdByUserId + : undefined + ) + .sort(sortBy, sortByDescending ? "desc" : "asc") + .paginate(page, pageSize, 1) + .get(); + + // Add the data to the response + response.data = data; + + // Update the header with the total number of posts + response.headers = { + ...response.headers, + ["x-total-count"]: dbSimulator.count() + }; +}); ``` -> 💡 Tip: Setting the mockInterceptor in a separate file and conditionally importing it dynamically based on environment variables is a good way to omit the code from the production build. +> 🔍 Caveat: Here, `useOnMocking` is used to apply this middleware only when `getPosts` is mocked. + +> 💡 Tip: Applying the middleware in a separate file and conditionally importing it dynamically based on environment variables is a good way to omit the code from the production build. -As shown above, the `setMockInterceptor` method accepts a callback which takes a `MockContext` argument. The `MockContext` contains helpful properties for simulating an API response, such as the `mockResponse`, `mockCache`, `callArgs` and a `delay` function to simulate server and network delay. +As shown above, the `useOnMocking` method accepts a callback which takes an `AcquireContext` argument. The `AcquireContext` contains helpful properties for simulating an API response, such as the `response`, `mockCache`, `callArgs` and others. The `createDatabaseSimulator` method on `mockCache` returns an `AcquireDatabaseSimulator` with various helper methods to quickly build a query for data in the mock cache or perform CRUD operations. diff --git a/demos/vite-demo/package.json b/demos/vite-demo/package.json index 29826e5..9e06c29 100644 --- a/demos/vite-demo/package.json +++ b/demos/vite-demo/package.json @@ -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", diff --git a/demos/vite-demo/src/App.tsx b/demos/vite-demo/src/App.tsx index 4d9042c..88b2f76 100644 --- a/demos/vite-demo/src/App.tsx +++ b/demos/vite-demo/src/App.tsx @@ -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"; @@ -15,6 +16,7 @@ import theme from "./theme/theme"; function App(): React.ReactElement { return ( + diff --git a/demos/vite-demo/src/api/acquire.ts b/demos/vite-demo/src/api/acquire.ts index 2533c51..d35c82c 100644 --- a/demos/vite-demo/src/api/acquire.ts +++ b/demos/vite-demo/src/api/acquire.ts @@ -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({ @@ -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; diff --git a/demos/vite-demo/src/api/mockInit.ts b/demos/vite-demo/src/api/acquireInit.ts similarity index 82% rename from demos/vite-demo/src/api/mockInit.ts rename to demos/vite-demo/src/api/acquireInit.ts index 53542d6..97f2fe4 100644 --- a/demos/vite-demo/src/api/mockInit.ts +++ b/demos/vite-demo/src/api/acquireInit.ts @@ -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"), diff --git a/demos/vite-demo/src/api/comment/commentApiMocking.ts b/demos/vite-demo/src/api/comment/commentApiMocking.ts index 8b396c6..3bd4935 100644 --- a/demos/vite-demo/src/api/comment/commentApiMocking.ts +++ b/demos/vite-demo/src/api/comment/commentApiMocking.ts @@ -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; }); diff --git a/demos/vite-demo/src/api/post/postApiMocking.ts b/demos/vite-demo/src/api/post/postApiMocking.ts index e2079ef..6952bb3 100644 --- a/demos/vite-demo/src/api/post/postApiMocking.ts +++ b/demos/vite-demo/src/api/post/postApiMocking.ts @@ -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; }); diff --git a/demos/vite-demo/src/api/user/userApiMocking.ts b/demos/vite-demo/src/api/user/userApiMocking.ts index 3d9619f..884ea93 100644 --- a/demos/vite-demo/src/api/user/userApiMocking.ts +++ b/demos/vite-demo/src/api/user/userApiMocking.ts @@ -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; +}); diff --git a/demos/vite-demo/src/main.tsx b/demos/vite-demo/src/main.tsx index 9e1c5b2..517effb 100644 --- a/demos/vite-demo/src/main.tsx +++ b/demos/vite-demo/src/main.tsx @@ -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( diff --git a/motivation.md b/motivation.md index e63efce..7ad8f91 100644 --- a/motivation.md +++ b/motivation.md @@ -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 type safety 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": "johndoe@example.com", + "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 date strings. 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 desired format of the data with the actual format of the data. This leads us to the second problem: data mapping. + +### 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 nullable 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. Fixing the return type + + ```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. Mapping the data: + + ```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 DTO (data transfer object) to indicate that this is the format of the transferred data, i.e., the raw data. In the second example (`UserModel`), we have used the term model 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 testing. + +### 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 diff --git a/packages/core/src/classes/Acquire.class.ts b/packages/core/src/classes/Acquire.class.ts index 7b41cc4..6ae5887 100644 --- a/packages/core/src/classes/Acquire.class.ts +++ b/packages/core/src/classes/Acquire.class.ts @@ -1,7 +1,10 @@ import { AcquireArgs } from "@/interfaces/AcquireArgs.interface"; import { AcquireCallArgs } from "@/interfaces/AcquireCallArgs.interface"; +import { + AcquireMiddleware, + AcquireMiddlewareWithOrder +} from "@/interfaces/AcquireMiddleware.interface"; import { ClassOrClassArray } from "@/interfaces/ClassOrClassArray.interface"; -import { Logger } from "@/interfaces/Logger.interface"; import { OmitFirstArg } from "@/interfaces/OmitFirstArg.interface"; import axios, { AxiosInstance } from "axios"; import AcquireMockCache from "./AcquireMockCache.class"; @@ -62,20 +65,34 @@ interface BoundAcquireRequestExecutor< TRequestModel >["mock"] >; - setMockInterceptor: AcquireRequestExecutor< + use: AcquireRequestExecutor< + TCallArgs, + TResponseDTO, + TResponseModel, + TRequestDTO, + TRequestModel + >["use"]; + useOnExecution: AcquireRequestExecutor< + TCallArgs, + TResponseDTO, + TResponseModel, + TRequestDTO, + TRequestModel + >["useOnExecution"]; + useOnMocking: AcquireRequestExecutor< TCallArgs, TResponseDTO, TResponseModel, TRequestDTO, TRequestModel - >["setMockInterceptor"]; - clearMockInterceptor: AcquireRequestExecutor< + >["useOnMocking"]; + clearMiddlewares: AcquireRequestExecutor< TCallArgs, TResponseDTO, TResponseModel, TRequestDTO, TRequestModel - >["clearMockInterceptor"]; + >["clearMiddlewares"]; } export interface Acquire { @@ -86,7 +103,7 @@ export interface Acquire { TRequestDTO extends ClassOrClassArray = any, TRequestModel extends ClassOrClassArray = any >( - args?: AcquireArgs< + acquireArgs: AcquireArgs< TCallArgs, TResponseDTO, TResponseModel, @@ -105,10 +122,11 @@ export interface Acquire { export class Acquire extends CallableInstance { private __isAcquireInstance = true; + private executionMiddlewares: AcquireMiddlewareWithOrder[] = []; + private mockingMiddlewares: AcquireMiddlewareWithOrder[] = []; constructor( private axiosInstance: AxiosInstance = axios, - private logger?: Logger, private mockCache?: AcquireMockCache, private isMockingEnabled = false ) { @@ -119,22 +137,35 @@ export class Acquire extends CallableInstance { return !!(instance as Acquire)?.__isAcquireInstance; } - public setAxiosInstance(axiosInstance: AxiosInstance): this { - this.axiosInstance = axiosInstance; + public use(middleware: AcquireMiddleware, order = 0): this { + this.executionMiddlewares.push([middleware, order]); + this.mockingMiddlewares.push([middleware, order]); return this; } - public getAxiosInstance(): AxiosInstance { - return this.axiosInstance; + public useOnExecution(middleware: AcquireMiddleware, order = 0): this { + this.executionMiddlewares.push([middleware, order]); + return this; + } + + public useOnMocking(middleware: AcquireMiddleware, order = 0): this { + this.mockingMiddlewares.push([middleware, order]); + return this; + } + + public clearMiddlewares(): this { + this.executionMiddlewares = []; + this.mockingMiddlewares = []; + return this; } - public useLogger(logger: Logger): this { - this.logger = logger; + public setAxiosInstance(axiosInstance: AxiosInstance): this { + this.axiosInstance = axiosInstance; return this; } - public getLogger(): Logger | undefined { - return this.logger; + public getAxiosInstance(): AxiosInstance { + return this.axiosInstance; } public useMockCache(mockCache: AcquireMockCache): this { @@ -172,7 +203,7 @@ export class Acquire extends CallableInstance { TRequestDTO extends ClassOrClassArray = any, TRequestModel extends ClassOrClassArray = any >( - args: AcquireArgs< + acquireArgs: AcquireArgs< TCallArgs, TResponseDTO, TResponseModel, @@ -187,15 +218,16 @@ export class Acquire extends CallableInstance { TRequestDTO, TRequestModel > { - const { request } = args; + const { request } = acquireArgs; - if (!request?.path && !request?.url && !request?.baseURL) { - this.logger?.warn("'baseURL', 'url' or 'path' is missing."); + if (!request.path && !request.url && !request.baseURL) { + console.warn("'baseURL', 'url' or 'path' is missing."); } const getConfig: AcquireRequestExecutorGetConfig = () => ({ axiosInstance: this.axiosInstance, - logger: this.logger, + executionMiddlewares: this.executionMiddlewares, + mockingMiddlewares: this.mockingMiddlewares, mockCache: this.mockCache, isMockingEnabled: this.isMockingEnabled }); @@ -210,12 +242,15 @@ export class Acquire extends CallableInstance { requestExecutor.execute = requestExecutor.execute.bind( requestExecutor, - args + acquireArgs ); - requestExecutor.mock = requestExecutor.mock.bind(requestExecutor, args); + requestExecutor.mock = requestExecutor.mock.bind( + requestExecutor, + acquireArgs + ); - return requestExecutor as BoundAcquireRequestExecutor< + return requestExecutor as unknown as BoundAcquireRequestExecutor< TCallArgs, TResponseDTO, TResponseModel, @@ -230,7 +265,7 @@ export class Acquire extends CallableInstance { TRequestDTO extends ClassOrClassArray = any, TRequestModel extends ClassOrClassArray = any >( - args: AcquireArgs< + acquireArgs: AcquireArgs< TCallArgs, TResponseDTO, TResponseModel, @@ -245,6 +280,6 @@ export class Acquire extends CallableInstance { TRequestDTO, TRequestModel > { - return (args) => this.acquire(args); + return (acquireArgs) => this.acquire(acquireArgs); } } diff --git a/packages/core/src/classes/AcquireLogger.class.ts b/packages/core/src/classes/AcquireLogger.class.ts index d466532..43150d6 100644 --- a/packages/core/src/classes/AcquireLogger.class.ts +++ b/packages/core/src/classes/AcquireLogger.class.ts @@ -1,6 +1,6 @@ import { LogLevel, Logger, LoggerFn } from "@/interfaces/Logger.interface"; -export type AcquireLogColor = keyof typeof AcquireLogger.colors; +export type AcquireLogColor = keyof typeof AcquireLogger.color; export default class AcquireLogger implements Logger { constructor( @@ -11,24 +11,45 @@ export default class AcquireLogger implements Logger { this.logLevels = logLevels; } - static colors = { + static color = { reset: "\x1b[0m", - gray: "\x1b[90m", - blue: "\x1b[34m", + black: "\x1b[30m", + red: "\x1b[31m", green: "\x1b[32m", yellow: "\x1b[33m", - red: "\x1b[31m", + blue: "\x1b[34m", magenta: "\x1b[35m", cyan: "\x1b[36m", white: "\x1b[37m", - black: "\x1b[30m", + brightBlack: "\x1b[90m", brightRed: "\x1b[91m", brightGreen: "\x1b[92m", brightYellow: "\x1b[93m", brightBlue: "\x1b[94m", brightMagenta: "\x1b[95m", brightCyan: "\x1b[96m", - brightWhite: "\x1b[97m" + brightWhite: "\x1b[97m", + bgBlack: "\x1b[40m", + bgRed: "\x1b[41m", + bgGreen: "\x1b[42m", + bgYellow: "\x1b[43m", + bgBlue: "\x1b[44m", + bgMagenta: "\x1b[45m", + bgCyan: "\x1b[46m", + bgWhite: "\x1b[47m", + bgBrightBlack: "\x1b[100m", + bgBrightRed: "\x1b[101m", + bgBrightGreen: "\x1b[102m", + bgBrightYellow: "\x1b[103m", + bgBrightBlue: "\x1b[104m", + bgBrightMagenta: "\x1b[105m", + bgBrightCyan: "\x1b[106m", + bgBrightWhite: "\x1b[107m" + }; + + static style = { + bold: "\x1b[1m", + reset: "\x1b[0m" }; static colorize( @@ -36,7 +57,11 @@ export default class AcquireLogger implements Logger { color: AcquireLogColor, reset: AcquireLogColor = "reset" ): string { - return `${AcquireLogger.colors[color]}${text}${AcquireLogger.colors[reset]}`; + return `${AcquireLogger.color[color]}${text}${AcquireLogger.color[reset]}`; + } + + static emphasize(text: string): string { + return `${AcquireLogger.style.bold}${text}${AcquireLogger.style.reset}`; } private makeLog( @@ -47,10 +72,22 @@ export default class AcquireLogger implements Logger { if (this.logLevels !== "all" && !this.logLevels.includes(level)) { return; } - const levelPrefix = `[${AcquireLogger.colorize( - `acquire:${level.toUpperCase()}`, - color - )}]`; + const levelPrefix = AcquireLogger.colorize("[Acquire]", color); + + function zeroPad(value: number): string { + return value.toString().padStart(2, "0"); + } + + const timestamp = new Date(); + const timestampPrefix = AcquireLogger.colorize( + `${zeroPad(timestamp.getDate())}/${zeroPad( + timestamp.getMonth() + 1 + )}/${zeroPad(timestamp.getFullYear())} ${zeroPad( + timestamp.getHours() + )}:${zeroPad(timestamp.getMinutes())}:${zeroPad(timestamp.getSeconds())}`, + "brightBlack" + ); + const message = args.reduce((acc, curr, index) => { let text = curr; if (typeof curr === "object") { @@ -63,7 +100,9 @@ export default class AcquireLogger implements Logger { return `${acc} ${curr}`; }, ""); - this.logFn(`${levelPrefix} ${message}`); + this.logFn( + `${AcquireLogger.color.reset}${levelPrefix} - ${timestampPrefix} - ${message}` + ); } public setLogLevels(logLevels: LogLevel[] | "all"): this { diff --git a/packages/core/src/classes/AcquireRequestExecutor.class.ts b/packages/core/src/classes/AcquireRequestExecutor.class.ts index 70ffb26..d86b97d 100644 --- a/packages/core/src/classes/AcquireRequestExecutor.class.ts +++ b/packages/core/src/classes/AcquireRequestExecutor.class.ts @@ -1,22 +1,22 @@ -import RequestMethod, { - RequestMethodType -} from "@/constants/RequestMethod.const"; +import { RequestMethodType } from "@/constants/RequestMethod.const"; import generateMock from "@/functions/generateMock.function"; import resolveValueOrCallback from "@/functions/resolveValueOrCallback.function"; import transform from "@/functions/transform.function"; import unwrapClassOrClassArray from "@/functions/unwrapClassArray.function"; +import isAcquireMiddlewareClass from "@/guards/isAcquireMiddleware.guard"; import { AcquireArgs } from "@/interfaces/AcquireArgs.interface"; import { AcquireCallArgs } from "@/interfaces/AcquireCallArgs.interface"; -import { AcquireMockContext } from "@/interfaces/AcquireMockContext.interface"; -import { AcquireMockInterceptor } from "@/interfaces/AcquireMockInterceptor.interface"; +import { AcquireContext } from "@/interfaces/AcquireContext.interface"; +import { + AcquireMiddleware, + AcquireMiddlewareFn, + AcquireMiddlewareWithOrder +} from "@/interfaces/AcquireMiddleware.interface"; import { AcquireRequestOptions } from "@/interfaces/AcquireRequestOptions.interface"; -import { AcquireResult } from "@/interfaces/AcquireResult.interface"; import { ClassOrClassArray } from "@/interfaces/ClassOrClassArray.interface"; import { InstanceOrInstanceArray } from "@/interfaces/InstanceOrInstanceArray.interface"; -import { Logger } from "@/interfaces/Logger.interface"; import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios"; import { instanceToPlain } from "class-transformer"; -import AcquireLogger, { AcquireLogColor } from "./AcquireLogger.class"; import AcquireMockCache from "./AcquireMockCache.class"; import { CallableInstance } from "./CallableInstance.class"; @@ -42,11 +42,21 @@ export interface AcquireRequestExecutor< export type AcquireRequestExecutorGetConfig = () => { axiosInstance: AxiosInstance; - logger?: Logger; + executionMiddlewares?: AcquireMiddlewareWithOrder[]; + mockingMiddlewares?: AcquireMiddlewareWithOrder[]; mockCache?: AcquireMockCache; isMockingEnabled?: boolean; }; +export interface AcquireResult< + TResponseDTO extends ClassOrClassArray | undefined, + TResponseModel extends ClassOrClassArray | undefined +> { + response: AxiosResponse; + dto: InstanceOrInstanceArray; + model: InstanceOrInstanceArray; +} + export class AcquireRequestExecutor< TCallArgs extends AcquireCallArgs = object, TResponseDTO extends ClassOrClassArray = any, @@ -55,15 +65,8 @@ export class AcquireRequestExecutor< TRequestModel extends ClassOrClassArray = any > extends CallableInstance { private __isAcquireRequestExecutorInstance = true; - private mockInterceptor: - | AcquireMockInterceptor< - TCallArgs, - TResponseDTO, - TResponseModel, - TRequestDTO, - TRequestModel - > - | undefined; + private executionMiddlewares: AcquireMiddlewareWithOrder[] = []; + private mockingMiddlewares: AcquireMiddlewareWithOrder[] = []; constructor(private getConfig: AcquireRequestExecutorGetConfig) { super((...args: any[]) => { @@ -82,6 +85,28 @@ export class AcquireRequestExecutor< ?.__isAcquireRequestExecutorInstance; } + public use(middleware: AcquireMiddleware, order = 0): this { + this.executionMiddlewares.push([middleware, order]); + this.mockingMiddlewares.push([middleware, order]); + return this; + } + + public useOnExecution(middleware: AcquireMiddleware, order = 0): this { + this.executionMiddlewares.push([middleware, order]); + return this; + } + + public useOnMocking(middleware: AcquireMiddleware, order = 0): this { + this.mockingMiddlewares.push([middleware, order]); + return this; + } + + public clearMiddlewares(): this { + this.executionMiddlewares = []; + this.mockingMiddlewares = []; + return this; + } + public async execute( acquireArgs: AcquireArgs< TCallArgs, @@ -93,16 +118,20 @@ export class AcquireRequestExecutor< >, callArgs?: TCallArgs & { data?: InstanceOrInstanceArray } ): Promise> { - const { request, responseMapping, requestMapping, axiosConfig } = - acquireArgs; - const { axiosInstance, logger, isMockingEnabled } = this.getConfig(); + const { + request, + responseMapping = {}, + requestMapping = {}, + axiosConfig = {} + } = acquireArgs; + const { axiosInstance, mockCache, isMockingEnabled } = this.getConfig(); if (isMockingEnabled) { return this.mock(acquireArgs, callArgs); } let requestData = callArgs?.data; - if (requestMapping && requestMapping.DTO && callArgs?.data) { + if (requestMapping.DTO && callArgs?.data) { const plainData = instanceToPlain(callArgs.data); requestData = transform(plainData, requestMapping.DTO, { @@ -110,36 +139,61 @@ export class AcquireRequestExecutor< }); } - const requestConfig = request - ? this.resolveRequestConfig(request, callArgs, axiosConfig) - : {}; + const { request: _request, ...rest } = acquireArgs ?? {}; + const requestConfig = ( + request ? this.resolveRequestConfig(request, callArgs, axiosConfig) : {} + ) as AxiosRequestConfig & { method: RequestMethodType }; + + let response = null; + + function preventMockDataGeneration(): void {} + function mockDataGenerationPrevented(): boolean { + return false; + } - let response: AxiosResponse; + const context: AcquireContext< + TCallArgs, + TResponseDTO, + TResponseModel, + TRequestDTO, + TRequestModel + > = { + acquireArgs: { + ...rest, + request: requestConfig + }, + type: "execution", + method: requestConfig.method, + callArgs, + mockCache, + preventMockDataGeneration, + mockDataGenerationPrevented, + response: {} as AxiosResponse // The response context is handled below + }; try { response = await axiosInstance.request({ ...requestConfig, data: requestData }); - this.logAcquireCall( - logger, - response, - requestConfig.method as RequestMethodType - ); + + context.response = response; } catch (error) { if (axios.isAxiosError(error)) { - this.logAcquireCall( - logger, - error.response ?? {}, - requestConfig.method as RequestMethodType - ); + context.error = error; + if (error.response) { + context.response = error.response; + } } - throw error; + } finally { + for (const middleware of this.getMiddlewares("execution")) { + middleware(context); + } } - const dto = transform(response.data, responseMapping?.DTO); - const model = transform(response.data, responseMapping?.Model, { + const dto = transform(response.data, responseMapping.DTO); + const model = transform(response.data, responseMapping.Model, { excludeExtraneousValues: true }); @@ -164,20 +218,20 @@ export class AcquireRequestExecutor< $count?: number; } ): Promise> { - const { request, responseMapping, requestMapping, axiosConfig } = - acquireArgs; - const { axiosInstance, logger, mockCache } = this.getConfig(); - - let mockDto: InstanceOrInstanceArray | undefined = undefined; - let mockModel: InstanceOrInstanceArray | undefined = - undefined; + const { + request, + responseMapping = {}, + requestMapping = {}, + axiosConfig = {} + } = acquireArgs; + const { axiosInstance, mockCache } = this.getConfig(); const requestConfig = ( request ? this.resolveRequestConfig(request, callArgs, axiosConfig) : {} ) as AxiosRequestConfig & { method: RequestMethodType }; let requestData = callArgs?.data; - if (requestMapping && requestMapping.DTO && callArgs?.data) { + if (requestMapping.DTO && callArgs?.data) { const plainData = instanceToPlain(callArgs.data); requestData = transform(plainData, requestMapping.DTO, { @@ -202,7 +256,7 @@ export class AcquireRequestExecutor< return config; } - let mockResponse = { + const mockResponse = { config: mergeConfigObjects(axiosInstance.defaults, requestConfig, { data: requestData }), @@ -212,86 +266,80 @@ export class AcquireRequestExecutor< } as Partial; const { request: _request, ...rest } = acquireArgs ?? {}; - const mockContext: AcquireMockContext< + + let isMockDataGenerationPrevented = false; + + function preventMockDataGeneration(): void { + isMockDataGenerationPrevented = true; + } + function mockDataGenerationPrevented(): boolean { + return isMockDataGenerationPrevented; + } + + const context: AcquireContext< TCallArgs, TResponseDTO, TResponseModel, TRequestDTO, TRequestModel > = { - args: { + acquireArgs: { ...rest, request: requestConfig }, + type: "mocking", + method: requestConfig.method, callArgs, - mockResponse, mockCache, - delay( - minDuration: number, - maxDuration: number = minDuration - ): Promise { - const duration = - Math.random() * (maxDuration - minDuration) + minDuration; - return new Promise((resolve) => setTimeout(resolve, duration)); - } + mockDataGenerationPrevented, + preventMockDataGeneration, + response: mockResponse as AxiosResponse }; - if (this.mockInterceptor) { - mockResponse = await this.mockInterceptor(mockContext); - } else if (responseMapping?.DTO) { + for (const middleware of this.getMiddlewares("mocking")) { + try { + middleware(context); + } catch (error) { + if (axios.isAxiosError(error)) { + context.error = error; + if (error.response) { + context.response = error.response; + } + } + } + } + + if (context.error) { + throw context.error; + } + + if ( + !isMockDataGenerationPrevented && + !context.response.data && + responseMapping.DTO + ) { const { ClassUnwrapped: DTOUnwrapped, isClassArray: isDTOArray } = - unwrapClassOrClassArray(responseMapping?.DTO); + unwrapClassOrClassArray(responseMapping.DTO); const mockResult = await (isDTOArray - ? generateMock( - DTOUnwrapped, - callArgs?.$count ?? 10, - mockCache, - mockContext as any - ) - : generateMock(DTOUnwrapped, undefined, mockCache, mockContext as any)); + ? generateMock(DTOUnwrapped, callArgs?.$count ?? 10, mockCache, context) + : generateMock(DTOUnwrapped, undefined, mockCache, context)); mockResponse.data = instanceToPlain(mockResult); } - mockDto = transform(mockResponse?.data, responseMapping?.DTO); - mockModel = transform(mockResponse?.data, responseMapping?.Model, { + const mockDto = transform(mockResponse?.data, responseMapping.DTO); + const mockModel = transform(mockResponse?.data, responseMapping.Model, { excludeExtraneousValues: true }); - this.logAcquireCall( - logger, - mockResponse, - requestConfig.method, - true, - typeof this.mockInterceptor === "function" - ); - return { response: mockResponse as AxiosResponse, - dto: mockDto!, - model: mockModel! + dto: mockDto, + model: mockModel }; } - public setMockInterceptor( - mockInterceptor: AcquireMockInterceptor< - TCallArgs, - TResponseDTO, - TResponseModel, - TRequestDTO, - TRequestModel - > - ): this { - this.mockInterceptor = mockInterceptor; - return this; - } - - public clearMockInterceptor(): this { - delete this.mockInterceptor; - return this; - } - private resolveRequestConfig( request: AcquireRequestOptions, callArgs: any, @@ -341,67 +389,42 @@ export class AcquireRequestExecutor< }; } - private logAcquireCall( - logger?: Logger, - response?: Partial, - method?: RequestMethodType, - isMocked?: boolean, - hasMockInterceptor?: boolean - ): void { - if (!logger || !response) { - return; + private resolveMiddlewares( + orderedMiddlewares: AcquireMiddlewareWithOrder[] + ): AcquireMiddlewareFn[] { + const orderedMiddlewareFns: [ + middlewareFn: AcquireMiddlewareFn, + order: number + ][] = []; + + for (const orderedMiddleware of orderedMiddlewares) { + const [middleware, order] = orderedMiddleware; + if (isAcquireMiddlewareClass(middleware)) { + orderedMiddlewareFns.push([ + middleware.handle, + middleware.order ?? order + ]); + } else { + orderedMiddlewareFns.push([middleware, order]); + } } - const colorize = - logger instanceof AcquireLogger - ? AcquireLogger.colorize - : (message: string, ..._args: any[]): string => message; - - function colorizeStatusCode(statusCode: number): AcquireLogColor { - const statusClass = Math.floor(statusCode / 100); - - switch (statusClass) { - case 1: // Informational - return "blue"; - case 2: // Success - return "green"; - case 3: // Redirection - return "yellow"; - case 4: // Client error - return "brightRed"; - case 5: // Server error - return "red"; - default: // Unknown status code - return "gray"; - } + return orderedMiddlewareFns + .sort((a, b) => a[1] - b[1]) + .map(([middlewareFn]) => middlewareFn); + } + + private getMiddlewares(mode: AcquireContext["type"]): AcquireMiddlewareFn[] { + if (mode === "execution") { + return this.resolveMiddlewares([ + ...(this.getConfig().executionMiddlewares ?? []), + ...this.executionMiddlewares + ]); } - const uri = axios.getUri(response.config); - const urlObj = new URL(uri); - - const executedOrMocked = `[${ - isMocked ? colorize("MOCKED", "yellow") : colorize("EXECUTED", "green") - }]`; - const requestMethod = method ? `[${RequestMethod[method]}]` : ""; - const atPath = `@ ${urlObj.pathname}`; - const status = colorize( - `[${response.status}]`, - colorizeStatusCode(response.status ?? 200) - ); - const mockSource = hasMockInterceptor - ? colorize("-> [FROM INTERCEPTOR]", "yellow") - : colorize("-> [ON DEMAND]", "yellow"); - - const log = [ - executedOrMocked, - requestMethod, - atPath, - status, - isMocked ? mockSource : "" - ] as const; - - response.status && response.status >= 400 - ? logger.error(...log) - : logger.info(...log); + return this.resolveMiddlewares([ + ...(this.getConfig().mockingMiddlewares ?? []), + ...this.mockingMiddlewares + ]); } } diff --git a/packages/core/src/functions/generateMock.function.ts b/packages/core/src/functions/generateMock.function.ts index 1f09762..7f27d25 100644 --- a/packages/core/src/functions/generateMock.function.ts +++ b/packages/core/src/functions/generateMock.function.ts @@ -1,7 +1,7 @@ import AcquireMockCache from "@/classes/AcquireMockCache.class"; import acquireMockDataStorage from "@/classes/AcquireMockDataStorage.class"; import AcquireError from "@/errors/AcquireError.error"; -import { AcquireMockContext } from "@/interfaces/AcquireMockContext.interface"; +import { AcquireContext } from "@/interfaces/AcquireContext.interface"; import { AcquireMockGenerator } from "@/interfaces/AcquireMockGenerator.interface"; import { ClassConstructor } from "@/interfaces/ClassConstructor.interface"; import { plainToInstance } from "class-transformer"; @@ -47,7 +47,7 @@ export async function createMockObject< >( cls: TClassConstructor, mockCache?: AcquireMockCache, - mockContext?: AcquireMockContext + context?: AcquireContext ): Promise> { const mockDecorators = acquireMockDataStorage.mockDecoratorMap.get(cls); @@ -74,9 +74,7 @@ export async function createMockObject< if (!mockCache || !(MockRelationClass || mockRelationProperty)) { // No mockCache or no MockRelationID or MockRelationProperty decorator -> create the value directly from the generator value = - typeof generator === "function" - ? await generator(mockContext) - : generator; + typeof generator === "function" ? await generator(context) : generator; } else { if (MockRelationClass) { let relationID = relationIDMap.get(MockRelationClass); @@ -142,7 +140,7 @@ export default async function generateMock< classConstructor: TClassConstructor, count?: undefined, mockCache?: AcquireMockCache, - mockContext?: AcquireMockContext + context?: AcquireContext ): Promise>; export default async function generateMock< TClassConstructor extends ClassConstructor @@ -150,7 +148,7 @@ export default async function generateMock< classConstructor: TClassConstructor, count: number, mockCache?: AcquireMockCache, - mockContext?: AcquireMockContext + context?: AcquireContext ): Promise[]>; export default async function generateMock< TClassConstructor extends ClassConstructor @@ -158,18 +156,16 @@ export default async function generateMock< classConstructor: TClassConstructor, count?: number, mockCache?: AcquireMockCache, - mockContext?: AcquireMockContext + context?: AcquireContext ): Promise | InstanceType[]> { if (typeof count === "number") { const mockCalls = []; for (let i = 0; i < count; i++) { - mockCalls.push( - createMockObject(classConstructor, mockCache, mockContext) - ); + mockCalls.push(createMockObject(classConstructor, mockCache, context)); } return Promise.all(mockCalls); } - return createMockObject(classConstructor, mockCache, mockContext); + return createMockObject(classConstructor, mockCache, context); } diff --git a/packages/core/src/guards/isAcquireMiddleware.guard.ts b/packages/core/src/guards/isAcquireMiddleware.guard.ts new file mode 100644 index 0000000..e94c33e --- /dev/null +++ b/packages/core/src/guards/isAcquireMiddleware.guard.ts @@ -0,0 +1,7 @@ +import { AcquireMiddlewareClass } from "@/interfaces/AcquireMiddleware.interface"; + +export default function isAcquireMiddlewareClass( + obj: any +): obj is AcquireMiddlewareClass { + return "handle" in obj; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a3ad4f8..134fcb6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -13,6 +13,14 @@ export { default as AcquireMockCache } from "./classes/AcquireMockCache.class"; export { default as acquireMockDataStorage } from "./classes/AcquireMockDataStorage.class"; /* -------------------------------------------------------------------------- */ /*/ +/* -------------------------------- Constants ------------------------------- */ +export { + default as RequestMethod, + type RequestMethodType +} from "./constants/RequestMethod.const"; +/*/ +/* -------------------------------------------------------------------------- */ +/*/ /* ------------------------------- Decorators ------------------------------- */ export { default as Mock } from "./decorators/mocks/Mock.decorator"; export { default as MockDTO } from "./decorators/mocks/MockDTO.decorator"; @@ -67,11 +75,19 @@ export { default as transform } from "./functions/transform.function"; /*/ /* ------------------------------- Interfaces ------------------------------- */ export type { AcquireArgs } from "./interfaces/AcquireArgs.interface"; -export type { AcquireMockContext } from "./interfaces/AcquireMockContext.interface"; +export type { AcquireCallArgs } from "./interfaces/AcquireCallArgs.interface"; +export type { AcquireContext } from "./interfaces/AcquireContext.interface"; +export type { + AcquireMiddleware, + AcquireMiddlewareClass, + AcquireMiddlewareFn, + AcquireMiddlewareWithOrder +} from "./interfaces/AcquireMiddleware.interface"; export type { AcquireMockGenerator } from "./interfaces/AcquireMockGenerator.interface"; export type { AcquireRequestOptions } from "./interfaces/AcquireRequestOptions.interface"; -export type { AcquireResult } from "./interfaces/AcquireResult.interface"; +export type { AcquireTransformerOptions } from "./interfaces/AcquireTransformerOptions.interface"; export type { ClassConstructor } from "./interfaces/ClassConstructor.interface"; +export type { ClassOrClassArray } from "./interfaces/ClassOrClassArray.interface"; export type { InstanceOrInstanceArray } from "./interfaces/InstanceOrInstanceArray.interface"; export type { JSONArray, @@ -79,4 +95,10 @@ export type { JSONValue } from "./interfaces/JSON.interface"; export type { LogLevel, Logger, LoggerFn } from "./interfaces/Logger.interface"; +export type { OmitFirstArg } from "./interfaces/OmitFirstArg.interface"; export type { ValueOrCallback } from "./interfaces/ValueOrCallback.interface"; +/* -------------------------------------------------------------------------- */ +/*/ +/* ------------------------------- Middleware ------------------------------- */ +export { default as AcquireRequestLogger } from "./middleware/AcquireRequestLogger"; +/* -------------------------------------------------------------------------- */ diff --git a/packages/core/src/interfaces/AcquireArgs.interface.ts b/packages/core/src/interfaces/AcquireArgs.interface.ts index 245ac82..d265976 100644 --- a/packages/core/src/interfaces/AcquireArgs.interface.ts +++ b/packages/core/src/interfaces/AcquireArgs.interface.ts @@ -1,5 +1,4 @@ import { AxiosRequestConfig } from "axios"; - import { AcquireCallArgs } from "./AcquireCallArgs.interface"; import { AcquireRequestOptions } from "./AcquireRequestOptions.interface"; import { ClassOrClassArray } from "./ClassOrClassArray.interface"; @@ -12,8 +11,8 @@ export interface AcquireArgs< TRequestModel extends ClassOrClassArray = never, TRequestOptionsValueOnly extends boolean = false > { + request: AcquireRequestOptions; callArgs?: TCallArgs; - request?: AcquireRequestOptions; requestMapping?: { DTO?: TRequestDTO; Model?: TRequestModel; diff --git a/packages/core/src/interfaces/AcquireMockContext.interface.ts b/packages/core/src/interfaces/AcquireContext.interface.ts similarity index 55% rename from packages/core/src/interfaces/AcquireMockContext.interface.ts rename to packages/core/src/interfaces/AcquireContext.interface.ts index 1f69e6b..6c7a060 100644 --- a/packages/core/src/interfaces/AcquireMockContext.interface.ts +++ b/packages/core/src/interfaces/AcquireContext.interface.ts @@ -1,20 +1,20 @@ import AcquireMockCache from "@/classes/AcquireMockCache.class"; +import { RequestMethodType } from "@/constants/RequestMethod.const"; +import AcquireError from "@/errors/AcquireError.error"; import { AxiosResponse } from "axios"; import { AcquireArgs } from "./AcquireArgs.interface"; import { AcquireCallArgs } from "./AcquireCallArgs.interface"; import { ClassOrClassArray } from "./ClassOrClassArray.interface"; import { InstanceOrInstanceArray } from "./InstanceOrInstanceArray.interface"; -export interface AcquireMockContext< +export interface AcquireContext< TCallArgs extends AcquireCallArgs = any, TResponseDTO extends ClassOrClassArray = any, TResponseModel extends ClassOrClassArray = any, TRequestDTO extends ClassOrClassArray = any, TRequestModel extends ClassOrClassArray = any > { - mockResponse: Partial; - callArgs?: TCallArgs & { data?: InstanceOrInstanceArray }; - args?: AcquireArgs< + readonly acquireArgs: AcquireArgs< TCallArgs, TResponseDTO, TResponseModel, @@ -22,6 +22,15 @@ export interface AcquireMockContext< TRequestModel, true >; - mockCache?: AcquireMockCache; - delay: (minDuration: number, maxDuration?: number) => Promise; + readonly type: "mocking" | "execution"; + readonly method: RequestMethodType; + readonly callArgs?: TCallArgs & { + data?: InstanceOrInstanceArray; + }; + readonly mockCache?: AcquireMockCache; + readonly mockDataGenerationPrevented: () => boolean; + readonly preventMockDataGeneration: () => void; + + response: AxiosResponse; + error?: AcquireError; } diff --git a/packages/core/src/interfaces/AcquireMiddleware.interface.ts b/packages/core/src/interfaces/AcquireMiddleware.interface.ts new file mode 100644 index 0000000..b53e926 --- /dev/null +++ b/packages/core/src/interfaces/AcquireMiddleware.interface.ts @@ -0,0 +1,15 @@ +import { AcquireContext } from "./AcquireContext.interface"; + +export type AcquireMiddlewareFn = (context: AcquireContext) => void; + +export interface AcquireMiddlewareClass { + order?: number; + handle: AcquireMiddlewareFn; +} + +export type AcquireMiddleware = AcquireMiddlewareFn | AcquireMiddlewareClass; + +export type AcquireMiddlewareWithOrder = [ + middleware: AcquireMiddleware, + order: number +]; diff --git a/packages/core/src/interfaces/AcquireMockGenerator.interface.ts b/packages/core/src/interfaces/AcquireMockGenerator.interface.ts index 81f9d82..c68f968 100644 --- a/packages/core/src/interfaces/AcquireMockGenerator.interface.ts +++ b/packages/core/src/interfaces/AcquireMockGenerator.interface.ts @@ -1,7 +1,7 @@ -import { AcquireMockContext } from "./AcquireMockContext.interface"; +import { AcquireContext } from "./AcquireContext.interface"; import { JSONValue } from "./JSON.interface"; export type AcquireMockGenerator = | JSONValue - | ((context?: AcquireMockContext, ...args: any) => JSONValue) + | ((context?: AcquireContext, ...args: any) => JSONValue) | (() => Promise); diff --git a/packages/core/src/interfaces/AcquireMockInterceptor.interface.ts b/packages/core/src/interfaces/AcquireMockInterceptor.interface.ts deleted file mode 100644 index f8b6fa8..0000000 --- a/packages/core/src/interfaces/AcquireMockInterceptor.interface.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { AxiosResponse } from "axios"; -import { AcquireCallArgs } from "./AcquireCallArgs.interface"; -import { AcquireMockContext } from "./AcquireMockContext.interface"; -import { ClassOrClassArray } from "./ClassOrClassArray.interface"; - -export type AcquireMockInterceptor< - TCallArgs extends AcquireCallArgs = any, - TResponseDTO extends ClassOrClassArray = ClassOrClassArray, - TResponseModel extends ClassOrClassArray = ClassOrClassArray, - TRequestDTO extends ClassOrClassArray = ClassOrClassArray, - TRequestModel extends ClassOrClassArray = ClassOrClassArray -> = ( - context: AcquireMockContext< - TCallArgs, - TResponseDTO, - TResponseModel, - TRequestDTO, - TRequestModel - > -) => Promise>; diff --git a/packages/core/src/interfaces/AcquireResult.interface.ts b/packages/core/src/interfaces/AcquireResult.interface.ts deleted file mode 100644 index f7c29ee..0000000 --- a/packages/core/src/interfaces/AcquireResult.interface.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { AxiosResponse } from "axios"; -import { ClassOrClassArray } from "./ClassOrClassArray.interface"; -import { InstanceOrInstanceArray } from "./InstanceOrInstanceArray.interface"; - -export interface AcquireResult< - TResponseDTO extends ClassOrClassArray | undefined, - TResponseModel extends ClassOrClassArray | undefined -> { - response: AxiosResponse; - dto: InstanceOrInstanceArray; - model: InstanceOrInstanceArray; -} diff --git a/packages/core/src/middleware/AcquireRequestLogger.ts b/packages/core/src/middleware/AcquireRequestLogger.ts new file mode 100644 index 0000000..8813231 --- /dev/null +++ b/packages/core/src/middleware/AcquireRequestLogger.ts @@ -0,0 +1,90 @@ +import AcquireLogger, { AcquireLogColor } from "@/classes/AcquireLogger.class"; +import RequestMethod from "@/constants/RequestMethod.const"; +import { AcquireContext } from "@/interfaces/AcquireContext.interface"; +import { + AcquireMiddlewareClass, + AcquireMiddlewareFn +} from "@/interfaces/AcquireMiddleware.interface"; +import { LogLevel, LoggerFn } from "@/interfaces/Logger.interface"; +import axios from "axios"; + +export interface AcquireRequestLoggerOptions { + logFn?: LoggerFn; + logLevels?: LogLevel[] | "all"; +} + +export default class AcquireRequestLogger implements AcquireMiddlewareClass { + public order = 1000; + + private logger: AcquireLogger; + + constructor(options?: AcquireRequestLoggerOptions) { + const { logFn, logLevels } = options ?? {}; + this.logger = new AcquireLogger(logFn, logLevels); + } + + getStatusCodeColor(statusCode: number): AcquireLogColor { + const statusClass = Math.floor(statusCode / 100); + + switch (statusClass) { + case 1: // Informational + return "blue"; + case 2: // Success + return "green"; + case 3: // Redirection + return "yellow"; + case 4: // Client error + return "red"; + case 5: // Server error + return "red"; + default: // Unknown status code + return "white"; + } + } + + logAcquireCall(logger: AcquireLogger, context: AcquireContext): void { + const { response, type, method, error } = context; + const isMocked = type === "mocking"; + const isOnDemand = response.data == null && error == null; + + const uri = axios.getUri(response.config); + const urlObj = new URL(uri); + + const mockedOrExecutedLabel = AcquireLogger.colorize( + `[${ + isMocked + ? `Mocked ->${ + isOnDemand + ? AcquireLogger.colorize("on demand", "brightBlue", "yellow") + : AcquireLogger.colorize("intercepted", "green", "yellow") + }` + : "Executed" + }]`.padStart(22, " "), + isMocked ? "yellow" : "green" + ); + + const requestDetailsLabel = AcquireLogger.colorize( + `[${RequestMethod[method ?? "GET"]}: ${urlObj.pathname}]:`, + "cyan" + ); + + const statusLabel = AcquireLogger.colorize( + `${response.status}${ + response.statusText || error?.message + ? ` - ${error?.message ?? response.statusText}` + : "" + }`, + this.getStatusCodeColor(response.status ?? 200) + ); + + const log = [mockedOrExecutedLabel, requestDetailsLabel, statusLabel]; + + response.status && response.status >= 400 + ? logger.error(...log) + : logger.info(...log); + } + + handle: AcquireMiddlewareFn = (context) => { + return this.logAcquireCall(this.logger, context); + }; +} diff --git a/packages/core/tests/classes/Acquire.test.ts b/packages/core/tests/classes/Acquire.test.ts index d6f4d91..6c4a543 100644 --- a/packages/core/tests/classes/Acquire.test.ts +++ b/packages/core/tests/classes/Acquire.test.ts @@ -1,17 +1,17 @@ import { Acquire } from "@/classes/Acquire.class"; import { AcquireRequestExecutor } from "@/classes/AcquireRequestExecutor.class"; -const acquire = new Acquire(); +describe("class: Acquire", () => { + const acquire = new Acquire(); -const requestExecutorExecuteSpy = jest - .spyOn(AcquireRequestExecutor.prototype, "execute") - .mockImplementation(() => Promise.resolve() as any); + const requestExecutorExecuteSpy = jest + .spyOn(AcquireRequestExecutor.prototype, "execute") + .mockImplementation(() => Promise.resolve() as any); -const requestExecutorMockSpy = jest - .spyOn(AcquireRequestExecutor.prototype, "mock") - .mockImplementation(() => Promise.resolve() as any); + const requestExecutorMockSpy = jest + .spyOn(AcquireRequestExecutor.prototype, "mock") + .mockImplementation(() => Promise.resolve() as any); -describe("class: Acquire", () => { beforeEach(() => { acquire.disableMocking(); }); @@ -60,37 +60,37 @@ describe("class: Acquire", () => { }); }); }); -}); -describe("function: withCallArgs", () => { - it("should call execute on AcquireRequestExecutor with the acquireArgs and callArgs", async () => { - const getUrl = (args?: { userId: number }): string => - `https://example.com/user/${args?.userId}`; - - class UserModel {} - - const getUser = acquire.withCallArgs<{ userId: number }>()({ - request: { - url: getUrl - }, - responseMapping: { - Model: UserModel - } - }); + describe("function: withCallArgs", () => { + it("should call execute on AcquireRequestExecutor with the acquireArgs and callArgs", async () => { + const getUrl = (args?: { userId: number }): string => + `https://example.com/user/${args?.userId}`; - await getUser({ userId: 10 }); + class UserModel {} - expect(requestExecutorExecuteSpy).toHaveBeenCalledWith( - { + const getUser = acquire.withCallArgs<{ userId: number }>()({ request: { url: getUrl }, responseMapping: { Model: UserModel } - }, - { userId: 10 } - ); + }); + + await getUser({ userId: 10 }); + + expect(requestExecutorExecuteSpy).toHaveBeenCalledWith( + { + request: { + url: getUrl + }, + responseMapping: { + Model: UserModel + } + }, + { userId: 10 } + ); + }); }); describe("function: isAcquireInstance", () => { diff --git a/packages/core/tests/classes/AcquireLogger.test.ts b/packages/core/tests/classes/AcquireLogger.test.ts index 1b01634..147835d 100644 --- a/packages/core/tests/classes/AcquireLogger.test.ts +++ b/packages/core/tests/classes/AcquireLogger.test.ts @@ -1,11 +1,21 @@ import AcquireLogger from "@/classes/AcquireLogger.class"; describe("class: AcquireLogger", () => { - let logger: AcquireLogger; + const { blue, red, brightBlack, reset } = AcquireLogger.color; const consoleSpy = jest.spyOn(console, "log").mockImplementation(() => {}); - beforeEach(() => { - logger = new AcquireLogger(); + const logger = new AcquireLogger(); + + const OriginalDate = global.Date; + + beforeAll(() => { + jest + .spyOn(global, "Date") + .mockImplementation(() => new OriginalDate("2023-07-03T11:22:33Z")); + }); + + afterAll(() => { + jest.restoreAllMocks(); }); afterEach(() => { @@ -14,10 +24,26 @@ describe("class: AcquireLogger", () => { describe("function: log", () => { it("should log to console by default", () => { - logger.log("Test message"); + logger.log("Hello world"); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("Hello world") + ); + }); + + it("should have the correct formatting for logging info", () => { + logger.info("Hello world"); + + expect(consoleSpy).toHaveBeenCalledWith( + `${reset}${blue}[Acquire]${reset} - ${brightBlack}03/07/2023 13:22:33${reset} - Hello world` + ); + }); + + it("should have the correct formatting for logging error", () => { + logger.error("Hello world"); expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining("Test message") + `${reset}${red}[Acquire]${reset} - ${brightBlack}03/07/2023 13:22:33${reset} - Hello world` ); }); }); diff --git a/packages/core/tests/classes/AcquireRequestExecutor.test.ts b/packages/core/tests/classes/AcquireRequestExecutor.test.ts index 3e75da7..f77292c 100644 --- a/packages/core/tests/classes/AcquireRequestExecutor.test.ts +++ b/packages/core/tests/classes/AcquireRequestExecutor.test.ts @@ -1,16 +1,17 @@ +import AcquireMockCache from "@/classes/AcquireMockCache.class"; import { AcquireRequestExecutor } from "@/classes/AcquireRequestExecutor.class"; import Mock from "@/decorators/mocks/Mock.decorator"; -import { AcquireMockCache } from "@/index"; import isPlainObject from "@tests/testing-utils/isPlainObject.function"; -import axios, { AxiosResponse } from "axios"; +import axios, { AxiosError, AxiosResponse } from "axios"; import MockAdapter from "axios-mock-adapter"; import { Chance } from "chance"; import { Expose } from "class-transformer"; -const mockAxios = new MockAdapter(axios); -const chance = new Chance(); - describe("class: AcquireRequestExecutor", () => { + const axiosInstance = axios.create(); + const mockAxios = new MockAdapter(axiosInstance); + const chance = new Chance(); + afterEach(() => { mockAxios.reset(); }); @@ -40,7 +41,7 @@ describe("class: AcquireRequestExecutor", () => { it("should call axios with the correct value arguments", async () => { const getUser = new AcquireRequestExecutor(() => ({ - axiosInstance: axios + axiosInstance })); const mockResponse: Partial = { @@ -73,7 +74,7 @@ describe("class: AcquireRequestExecutor", () => { it("should call axios with the correct function arguments", async () => { const getUser = new AcquireRequestExecutor<{ userId: number }>(() => ({ - axiosInstance: axios + axiosInstance })); const mockResponse: Partial = { @@ -113,7 +114,7 @@ describe("class: AcquireRequestExecutor", () => { typeof UserDTO, typeof UserModel >(() => ({ - axiosInstance: axios + axiosInstance })); const mockResponse: Partial = { @@ -148,7 +149,7 @@ describe("class: AcquireRequestExecutor", () => { [typeof UserDTO], [typeof UserModel] >(() => ({ - axiosInstance: axios + axiosInstance })); const mockResponse: Partial = { @@ -185,9 +186,7 @@ describe("class: AcquireRequestExecutor", () => { }); it("should forward data if no request DTO is provided", async () => { - const axiosInstance = axios.create(); - - const postSpy = jest + const requestSpy = jest .spyOn(axiosInstance, "request") .mockImplementationOnce(() => Promise.resolve({ data: {} })); @@ -211,7 +210,7 @@ describe("class: AcquireRequestExecutor", () => { } ); - expect(postSpy).toHaveBeenCalledWith( + expect(requestSpy).toHaveBeenCalledWith( expect.objectContaining({ data: { age: userFormData.age, @@ -220,13 +219,11 @@ describe("class: AcquireRequestExecutor", () => { }) ); - postSpy.mockRestore(); + requestSpy.mockRestore(); }); it("should transform the request Model instance into a DTO class instance", async () => { - const axiosInstance = axios.create(); - - const postSpy = jest + const requestSpy = jest .spyOn(axiosInstance, "request") .mockImplementationOnce(() => Promise.resolve({ data: {} })); @@ -260,7 +257,7 @@ describe("class: AcquireRequestExecutor", () => { } ); - expect(postSpy).toHaveBeenCalledWith( + expect(requestSpy).toHaveBeenCalledWith( expect.objectContaining({ data: { age: userFormData.age, @@ -269,7 +266,225 @@ describe("class: AcquireRequestExecutor", () => { }) ); - postSpy.mockRestore(); + requestSpy.mockRestore(); + }); + + it("should execute the right middlewares from the instance", async () => { + axiosInstance.request = jest + .fn() + .mockImplementationOnce(() => Promise.resolve({ data: {} })); + + const getUser = new AcquireRequestExecutor(() => ({ + axiosInstance + })); + + const middlewareExecution = jest.fn(); + const middlewareMocking = jest.fn(); + const middlewareBoth = jest.fn(); + + getUser + .useOnExecution(middlewareExecution) + .useOnMocking(middlewareMocking) + .use(middlewareBoth); + + await getUser({ + request: { + url: "https://example.com/user/10" + } + }); + + expect(middlewareExecution).toHaveBeenCalled(); + expect(middlewareMocking).not.toHaveBeenCalled(); + expect(middlewareBoth).toHaveBeenCalled(); + }); + + it("should execute the right middlewares from the config", async () => { + axiosInstance.request = jest + .fn() + .mockImplementationOnce(() => Promise.resolve({ data: {} })); + + const middlewareExecution = jest.fn(); + const middlewareMocking = jest.fn(); + const middlewareBoth = jest.fn(); + + const getUser = new AcquireRequestExecutor(() => ({ + axiosInstance, + executionMiddlewares: [ + [middlewareExecution, 0], + [middlewareBoth, 0] + ], + mockingMiddlewares: [ + [middlewareMocking, 0], + [middlewareBoth, 0] + ] + })); + + await getUser({ + request: { + url: "https://example.com/user/10" + } + }); + + expect(middlewareExecution).toHaveBeenCalled(); + expect(middlewareMocking).not.toHaveBeenCalled(); + expect(middlewareBoth).toHaveBeenCalled(); + }); + + it("should execute the right middlewares from both the instance and config", async () => { + axiosInstance.request = jest + .fn() + .mockImplementationOnce(() => Promise.resolve({ data: {} })); + + const configMiddlewareExecution = jest.fn(); + const configMiddlewareMocking = jest.fn(); + const configMiddlewareBoth = jest.fn(); + + const getUser = new AcquireRequestExecutor(() => ({ + axiosInstance, + executionMiddlewares: [ + [configMiddlewareExecution, 0], + [configMiddlewareBoth, 0] + ], + mockingMiddlewares: [ + [configMiddlewareMocking, 0], + [configMiddlewareBoth, 0] + ] + })); + + const instanceMiddlewareExecution = jest.fn(); + const instanceMiddlewareMocking = jest.fn(); + const instanceMiddlewareBoth = jest.fn(); + + getUser + .useOnExecution(instanceMiddlewareExecution) + .useOnMocking(instanceMiddlewareMocking) + .use(instanceMiddlewareBoth); + + await getUser({ + request: { + url: "https://example.com/user/10" + } + }); + + expect(configMiddlewareExecution).toHaveBeenCalled(); + expect(configMiddlewareMocking).not.toHaveBeenCalled(); + expect(configMiddlewareBoth).toHaveBeenCalled(); + expect(instanceMiddlewareExecution).toHaveBeenCalled(); + expect(instanceMiddlewareMocking).not.toHaveBeenCalled(); + expect(instanceMiddlewareBoth).toHaveBeenCalled(); + }); + + it("should call middleware with the right context arguments", async () => { + axiosInstance.request = jest.fn().mockImplementationOnce(() => + Promise.resolve({ + data: { + id: 10, + name: "Christian Bale", + age: 49 + } + }) + ); + + const mockInterceptor = jest.fn(); + const mockCache = new AcquireMockCache(); + + const getUser = new AcquireRequestExecutor< + { userId: number }, + typeof UserDTO, + typeof UserModel + >(() => ({ + axiosInstance, + mockCache + })); + + getUser.useOnExecution(mockInterceptor); + + const url = (args: { userId?: number } | undefined): string => + `https://example.com/user/${args?.userId}`; + + await getUser( + { + request: { + url, + headers: { + Accept: "application/json" + } + }, + responseMapping: { + DTO: UserDTO, + Model: UserModel + } + }, + { userId: 10 } + ); + + expect(mockInterceptor).toHaveBeenCalledWith( + expect.objectContaining({ + response: expect.objectContaining({ + data: { + id: 10, + name: "Christian Bale", + age: 49 + } + }), + type: "execution", + method: "GET", + acquireArgs: expect.objectContaining({ + responseMapping: { + DTO: UserDTO, + Model: UserModel + }, + request: expect.objectContaining({ + headers: { + Accept: "application/json" + }, + url: "https://example.com/user/10" + }) + }), + callArgs: { + userId: 10 + }, + mockCache + }) + ); + }); + + it.todo("should execute middleware in the correct order"); + + it("should allow errors to be handled by middleware", async () => { + axiosInstance.request = jest + .fn() + .mockImplementationOnce(() => + Promise.reject(new AxiosError("Not found")) + ); + + const mockInterceptor = jest.fn(); + + const getUser = new AcquireRequestExecutor(() => ({ + axiosInstance + })); + + getUser.useOnExecution(mockInterceptor); + + await expect( + getUser({ + request: { + url: "http://example.com/user/10" + }, + responseMapping: { + DTO: UserDTO, + Model: UserModel + } + }) + ).rejects.toThrow(); + + expect(mockInterceptor).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ + message: "Not found" + }) + }) + ); }); }); @@ -288,7 +503,7 @@ describe("class: AcquireRequestExecutor", () => { it("should call mock and not execute when the callable instance is called with mock", async () => { const getUsers = new AcquireRequestExecutor(() => ({ - axiosInstance: axios + axiosInstance })); const executeSpy = jest @@ -306,11 +521,14 @@ describe("class: AcquireRequestExecutor", () => { expect(executeSpy).not.toHaveBeenCalled(); expect(mockSpy).toHaveBeenCalled(); + + executeSpy.mockRestore(); + mockSpy.mockRestore(); }); it("should call mock and not execute when mocking is enabled", async () => { const getUsers = new AcquireRequestExecutor(() => ({ - axiosInstance: axios, + axiosInstance, isMockingEnabled: true })); @@ -329,6 +547,9 @@ describe("class: AcquireRequestExecutor", () => { expect(executeSpy).not.toHaveBeenCalled(); expect(mockSpy).toHaveBeenCalled(); + + executeSpy.mockRestore(); + mockSpy.mockRestore(); }); it("should mock DTO and Model when a DTO class is provided", async () => { @@ -337,7 +558,7 @@ describe("class: AcquireRequestExecutor", () => { typeof UserDTO, typeof UserModel >(() => ({ - axiosInstance: axios + axiosInstance })); const response = await getUser.mock({ @@ -371,7 +592,7 @@ describe("class: AcquireRequestExecutor", () => { [typeof UserDTO], [typeof UserModel] >(() => ({ - axiosInstance: axios + axiosInstance })); const response = await getUsers.mock({ @@ -418,19 +639,102 @@ describe("class: AcquireRequestExecutor", () => { }); }); - it("should allow the mockInterceptor to be set", () => { - const mockInterceptor = jest.fn(); + it("should execute the right middlewares from the instance", async () => { + const getUser = new AcquireRequestExecutor(() => ({ + axiosInstance + })); - const requestExecutor = new AcquireRequestExecutor(() => ({ - axiosInstance: axios + const middlewareExecution = jest.fn(); + const middlewareMocking = jest.fn(); + const middlewareBoth = jest.fn(); + + getUser + .useOnExecution(middlewareExecution) + .useOnMocking(middlewareMocking) + .use(middlewareBoth); + + await getUser.mock({ + request: { + url: "https://example.com/user/10" + } + }); + + expect(middlewareExecution).not.toHaveBeenCalled(); + expect(middlewareMocking).toHaveBeenCalled(); + expect(middlewareBoth).toHaveBeenCalled(); + }); + + it("should execute the right middlewares from the config", async () => { + const middlewareExecution = jest.fn(); + const middlewareMocking = jest.fn(); + const middlewareBoth = jest.fn(); + + const getUser = new AcquireRequestExecutor(() => ({ + axiosInstance: axios, + executionMiddlewares: [ + [middlewareExecution, 0], + [middlewareBoth, 0] + ], + mockingMiddlewares: [ + [middlewareMocking, 0], + [middlewareBoth, 0] + ] })); - requestExecutor.setMockInterceptor(mockInterceptor); + await getUser.mock({ + request: { + url: "https://example.com/user/10" + } + }); + + expect(middlewareExecution).not.toHaveBeenCalled(); + expect(middlewareMocking).toHaveBeenCalled(); + expect(middlewareBoth).toHaveBeenCalled(); + }); + + it("should execute the right middlewares from both the instance and config", async () => { + const configMiddlewareExecution = jest.fn(); + const configMiddlewareMocking = jest.fn(); + const configMiddlewareBoth = jest.fn(); + + const getUser = new AcquireRequestExecutor(() => ({ + axiosInstance, + executionMiddlewares: [ + [configMiddlewareExecution, 0], + [configMiddlewareBoth, 0] + ], + mockingMiddlewares: [ + [configMiddlewareMocking, 0], + [configMiddlewareBoth, 0] + ] + })); + + const instanceMiddlewareExecution = jest.fn(); + const instanceMiddlewareMocking = jest.fn(); + const instanceMiddlewareBoth = jest.fn(); - expect(requestExecutor["mockInterceptor"]).toBeDefined(); + getUser + .useOnExecution(instanceMiddlewareExecution) + .useOnMocking(instanceMiddlewareMocking) + .use(instanceMiddlewareBoth); + + await getUser.mock({ + request: { + url: "https://example.com/user/10" + } + }); + + expect(configMiddlewareExecution).not.toHaveBeenCalled(); + expect(configMiddlewareMocking).toHaveBeenCalled(); + expect(configMiddlewareBoth).toHaveBeenCalled(); + expect(instanceMiddlewareExecution).not.toHaveBeenCalled(); + expect(instanceMiddlewareMocking).toHaveBeenCalled(); + expect(instanceMiddlewareBoth).toHaveBeenCalled(); }); - it("should call the mockInterceptor with the right context arguments", async () => { + it.todo("should execute middleware in the correct order"); + + it("should call middleware with the right context arguments", async () => { const mockInterceptor = jest.fn(); const mockCache = new AcquireMockCache(); @@ -439,11 +743,11 @@ describe("class: AcquireRequestExecutor", () => { typeof UserDTO, typeof UserModel >(() => ({ - axiosInstance: axios, + axiosInstance, mockCache })); - getUser.setMockInterceptor(mockInterceptor); + getUser.useOnMocking(mockInterceptor); const url = (args: { userId?: number } | undefined): string => `https://example.com/user/${args?.userId}`; @@ -466,7 +770,7 @@ describe("class: AcquireRequestExecutor", () => { expect(mockInterceptor).toHaveBeenCalledWith( expect.objectContaining({ - mockResponse: expect.objectContaining({ + response: expect.objectContaining({ config: expect.objectContaining({ headers: { Accept: "application/json" @@ -474,9 +778,16 @@ describe("class: AcquireRequestExecutor", () => { url: "https://example.com/user/10" }), status: 200, - statusText: "OK" + statusText: "OK", + data: { + id: 10, + name: "Christian Bale", + age: 49 + } }), - args: expect.objectContaining({ + type: "mocking", + method: "GET", + acquireArgs: expect.objectContaining({ responseMapping: { DTO: UserDTO, Model: UserModel @@ -505,7 +816,7 @@ describe("class: AcquireRequestExecutor", () => { { id: number }, typeof MockTestDTO >(() => ({ - axiosInstance: axios + axiosInstance })); const response = await getUser.mock( @@ -530,7 +841,7 @@ describe("class: AcquireRequestExecutor", () => { describe("function: isAcquireRequestExecutorInstance", () => { it("should return true when called with an instance of AcquireRequestExecutor", () => { const requestExecutor = new AcquireRequestExecutor(() => ({ - axiosInstance: axios + axiosInstance })); expect( diff --git a/packages/core/tests/integration/mock-interceptor.test.ts b/packages/core/tests/integration/mock-interceptor.test.ts index dc6ac8d..e18a2e5 100644 --- a/packages/core/tests/integration/mock-interceptor.test.ts +++ b/packages/core/tests/integration/mock-interceptor.test.ts @@ -3,8 +3,8 @@ import AcquireMockCache from "@/classes/AcquireMockCache.class"; import axios from "axios"; import { Expose } from "class-transformer"; -describe("Setup of endpoint with mock interceptor", () => { - it("should correctly mock the request, using the mock interceptor to customize the returned data", async () => { +describe("Setup of endpoint with intercepting middleware", () => { + it("should correctly mock the request, using the middleware to customize the returned data", async () => { /* ---------------------------------- Setup --------------------------------- */ class UserDTO { @@ -63,20 +63,19 @@ describe("Setup of endpoint with mock interceptor", () => { /* ---------------------------- Setup mock logic ---------------------------- */ - getUsers.setMockInterceptor(({ mockResponse, mockCache, callArgs }) => { + getUsers.use(({ response, mockCache, callArgs }) => { const { sortBy, sortByDescending, search, minAge, maxAge } = callArgs ?? {}; - const dbSimulator = mockCache?.createDatabaseSimulator(UserDTO); + const dbSimulator = mockCache!.createDatabaseSimulator(UserDTO); const data = dbSimulator - ?.sort(sortBy, sortByDescending ? "desc" : "asc") + .sort(sortBy, sortByDescending ? "desc" : "asc") .search(search, ["name"]) .filter(minAge ? (user): boolean => user.age >= minAge : undefined) .filter(maxAge ? (user): boolean => user.age <= maxAge : undefined) .get(); - mockResponse.data = data; - return Promise.resolve(mockResponse); + response.data = data; }); /* -------------------------------------------------------------------------- */ diff --git a/packages/core/tests/integration/mock-mutation.test.ts b/packages/core/tests/integration/mock-mutation.test.ts index df4f302..eecec92 100644 --- a/packages/core/tests/integration/mock-mutation.test.ts +++ b/packages/core/tests/integration/mock-mutation.test.ts @@ -2,11 +2,13 @@ import { Acquire } from "@/classes/Acquire.class"; import AcquireMockCache from "@/classes/AcquireMockCache.class"; import Mock from "@/decorators/mocks/Mock.decorator"; import MockID from "@/decorators/mocks/MockID.decorator"; +import isPlainObject from "@tests/testing-utils/isPlainObject.function"; import { Chance } from "chance"; import { Expose } from "class-transformer"; describe("setup of mutation endpoint", () => { it("should correctly allow mutations to be mocked", async () => { + /* ---------------------------------- Setup --------------------------------- */ const chance = new Chance(); class PostDTO { @@ -35,6 +37,9 @@ describe("setup of mutation endpoint", () => { const acquire = new Acquire().useMockCache(mockCache).enableMocking(); await mockCache.fill(PostDTO, 15); + /* -------------------------------------------------------------------------- */ + + /* ---------------------------- Define API method --------------------------- */ const createPost = acquire({ request: { @@ -51,19 +56,25 @@ describe("setup of mutation endpoint", () => { } }); - createPost.setMockInterceptor(async ({ mockResponse, mockCache }) => { - const data = mockResponse.config?.data; + /* -------------------------------------------------------------------------- */ - const dbSimulator = mockCache?.createDatabaseSimulator(PostDTO); - const id = dbSimulator?.generateNextID(); - const newPost = { ...data, id }; - dbSimulator?.create(newPost); + /* ---------------------------- Setup mock logic ---------------------------- */ + + createPost.use(({ response, mockCache }) => { + const data = response.config?.data; - mockResponse.data = newPost; + const dbSimulator = mockCache!.createDatabaseSimulator(PostDTO); + const id = dbSimulator.generateNextID(); + const newPost = { ...data, id }; + dbSimulator.create(newPost); - return Promise.resolve(mockResponse); + response.data = newPost; }); + /* -------------------------------------------------------------------------- */ + + /* ------------------------------ Test function ----------------------------- */ + const response = await createPost({ data: { title: "My title", @@ -71,6 +82,7 @@ describe("setup of mutation endpoint", () => { } }); + expect(isPlainObject(response.response.data)); expect(response.model).toBeInstanceOf(PostModel); expect(response.model.title).toEqual("My title"); expect(response.model.text).toEqual("My text"); diff --git a/packages/core/tests/integration/mock-relations.test.ts b/packages/core/tests/integration/mock-relations.test.ts index 2470c8d..09f5845 100644 --- a/packages/core/tests/integration/mock-relations.test.ts +++ b/packages/core/tests/integration/mock-relations.test.ts @@ -38,7 +38,7 @@ describe("setup of endpoint with mocked relations", () => { beforeEach(() => { mockCache.clear(); - getPosts.clearMockInterceptor(); + getPosts.clearMiddlewares(); }); it("should throw an error if no data exists in the cache", () => { @@ -62,26 +62,24 @@ describe("setup of endpoint with mocked relations", () => { }); }); - it("should intercept the mocking and return a custom response when mockInterceptor is set", async () => { + it("should intercept the mocking and return a custom response when middleware is set", async () => { await mockCache.fill(UserDTO, 10); await mockCache.fill(PostDTO, 50); - getPosts.setMockInterceptor(({ callArgs, mockResponse, mockCache }) => { + getPosts.use(({ callArgs, response, mockCache }) => { const { createdByUserId } = callArgs ?? {}; - const dbSimulator = mockCache?.createDatabaseSimulator(PostDTO); + const dbSimulator = mockCache!.createDatabaseSimulator(PostDTO); const data = dbSimulator - ?.filter((post) => post.userId === createdByUserId) + .filter((post) => post.userId === createdByUserId) .get(); - mockResponse.data = data; - - return Promise.resolve(mockResponse); + response.data = data; }); const posts = await getPosts({ createdByUserId: 5 }); - expect(posts.dto.every((post) => post.userId === 5)).toBe(true); + expect(posts.dto.every((post) => post.userId === 5)); }); }); diff --git a/packages/core/tests/middleware/AcquireRequestLogger.test.ts b/packages/core/tests/middleware/AcquireRequestLogger.test.ts new file mode 100644 index 0000000..69ba0f1 --- /dev/null +++ b/packages/core/tests/middleware/AcquireRequestLogger.test.ts @@ -0,0 +1,112 @@ +import { Acquire, AcquireLogger } from "@/index"; +import AcquireRequestLogger from "@/middleware/AcquireRequestLogger"; +import axios, { AxiosError } from "axios"; +import MockAdapter from "axios-mock-adapter"; + +describe("middleware: AcquireRequestLogger", () => { + const { + blue, + green, + red, + cyan, + yellow, + brightBlack, + brightBlue, + brightGreen, + reset + } = AcquireLogger.color; + const axiosInstance = axios.create(); + const mockAxios = new MockAdapter(axiosInstance); + + const consoleSpy = jest.spyOn(console, "log").mockImplementation(() => {}); + const acquire = new Acquire(axiosInstance); + acquire.use(new AcquireRequestLogger()); + + const OriginalDate = global.Date; + + beforeAll(() => { + jest + .spyOn(global, "Date") + .mockImplementation(() => new OriginalDate("2023-07-03T11:22:33Z")); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + mockAxios.reset(); + }); + + it("should log executed requests", async () => { + mockAxios.onGet("http://example.com/users").reply(200, {}); + + const getUsers = acquire({ + request: { + url: "http://example.com/users" + } + }); + + await getUsers(); + + expect(consoleSpy).toHaveBeenCalledWith( + `${reset}${blue}[Acquire]${reset} - ${brightBlack}03/07/2023 13:22:33${reset} - ${green} [Executed]${reset} ${cyan}[GET: /users]:${reset} ${green}200${reset}` + ); + }); + + it("should log executed requests with errors", async () => { + mockAxios.onGet("http://example.com/users").reply(404, {}); + + const getUsers = acquire({ + request: { + url: "http://example.com/users" + } + }); + + await expect(getUsers()).rejects.toThrow(); + + expect(consoleSpy).toHaveBeenCalledWith( + `${reset}${red}[Acquire]${reset} - ${brightBlack}03/07/2023 13:22:33${reset} - ${green} [Executed]${reset} ${cyan}[GET: /users]:${reset} ${red}404 - Request failed with status code 404${reset}` + ); + }); + + it("should log mocked requests", async () => { + const getUsers = acquire({ + request: { + url: "http://example.com/users" + } + }); + + await getUsers.mock(); + + expect(consoleSpy).toHaveBeenCalledWith( + `${reset}${blue}[Acquire]${reset} - ${brightBlack}03/07/2023 13:22:33${reset} - ${yellow}[Mocked ->${brightBlue}on demand${yellow}]${reset} ${cyan}[GET: /users]:${reset} ${green}200 - OK${reset}` + ); + }); + + it("should log mocked requests with errors", async () => { + const getUsers = acquire({ + request: { + url: "http://example.com/users" + } + }); + + getUsers.use((context) => { + const error = new AxiosError("Not found"); + error.response = { + ...context.response, + statusText: "Not found", + status: 404 + }; + + throw error; + }); + + await expect(getUsers.mock()).rejects.toThrow(); + + expect(consoleSpy).toHaveBeenCalledWith( + `${reset}${red}[Acquire]${reset} - ${brightBlack}03/07/2023 13:22:33${reset} - ${yellow}[Mocked ->${green}intercepted${yellow}]${reset} ${cyan}[GET: /users]:${reset} ${red}404 - Not found${reset}` + ); + }); +}); diff --git a/yarn.lock b/yarn.lock index 2f021a4..20d60ec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1333,11 +1333,27 @@ "@swc/core-win32-ia32-msvc" "1.3.60" "@swc/core-win32-x64-msvc" "1.3.60" +"@tanstack/match-sorter-utils@^8.7.0": + version "8.8.4" + resolved "https://registry.yarnpkg.com/@tanstack/match-sorter-utils/-/match-sorter-utils-8.8.4.tgz#0b2864d8b7bac06a9f84cb903d405852cc40a457" + integrity sha512-rKH8LjZiszWEvmi01NR72QWZ8m4xmXre0OOwlRGnjU01Eqz/QnN+cqpty2PJ0efHblq09+KilvyR7lsbzmXVEw== + dependencies: + remove-accents "0.4.2" + "@tanstack/query-core@4.29.5": version "4.29.5" resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-4.29.5.tgz#a0273e88bf2fc102c4c893dc7c034127b67fd5d9" integrity sha512-xXIiyQ/4r9KfaJ3k6kejqcaqFXXBTzN2aOJ5H1J6aTJE9hl/nbgAdfF6oiIu0CD5xowejJEJ6bBg8TO7BN4NuQ== +"@tanstack/react-query-devtools@^4.29.19": + version "4.29.19" + resolved "https://registry.yarnpkg.com/@tanstack/react-query-devtools/-/react-query-devtools-4.29.19.tgz#b6f337c91313388d3f04c890449005d7bb355322" + integrity sha512-rL2xqTPr+7gJvVGwyq8E8CWqqw950N4lZ6ffJeNX0qqymKHxHW1FM6nZaYt7Aufs/bXH0m1L9Sj3kDGQbp0rwg== + dependencies: + "@tanstack/match-sorter-utils" "^8.7.0" + superjson "^1.10.0" + use-sync-external-store "^1.2.0" + "@tanstack/react-query@^4.29.5": version "4.29.5" resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-4.29.5.tgz#3890741291f9f925933243d78bd74dfc59d64208" @@ -2159,6 +2175,13 @@ convert-source-map@^2.0.0: resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== +copy-anything@^3.0.2: + version "3.0.5" + resolved "https://registry.yarnpkg.com/copy-anything/-/copy-anything-3.0.5.tgz#2d92dce8c498f790fa7ad16b01a1ae5a45b020a0" + integrity sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w== + dependencies: + is-what "^4.1.8" + cosmiconfig@^7.0.0: version "7.1.0" resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-7.1.0.tgz#1443b9afa596b670082ea46cbd8f6a62b84635f6" @@ -3243,6 +3266,11 @@ is-weakref@^1.0.2: dependencies: call-bind "^1.0.2" +is-what@^4.1.8: + version "4.1.15" + resolved "https://registry.yarnpkg.com/is-what/-/is-what-4.1.15.tgz#de43a81090417a425942d67b1ae86e7fae2eee0e" + integrity sha512-uKua1wfy3Yt+YqsD6mTUEa2zSi3G1oPlqTflgaPJ7z63vUGN5pxFpnQfeSLMFnJDEsdvOtkp1rUWkYjB4YfhgA== + is-windows@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d" @@ -4401,6 +4429,11 @@ regexpp@^3.1.0: resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.2.0.tgz#0425a2768d8f23bad70ca4b90461fa2f1213e1b2" integrity sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg== +remove-accents@0.4.2: + version "0.4.2" + resolved "https://registry.yarnpkg.com/remove-accents/-/remove-accents-0.4.2.tgz#0a43d3aaae1e80db919e07ae254b285d9e1c7bb5" + integrity sha512-7pXIJqJOq5tFgG1A2Zxti3Ht8jJF337m4sowbuHsW30ZnkQFnDzy9qBNhgzX8ZLW4+UBcXiiR7SwR6pokHsxiA== + require-directory@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" @@ -4780,6 +4813,13 @@ sucrase@^3.20.3: pirates "^4.0.1" ts-interface-checker "^0.1.9" +superjson@^1.10.0: + version "1.12.4" + resolved "https://registry.yarnpkg.com/superjson/-/superjson-1.12.4.tgz#cfea35b0d1eb0f12d8b185f1d871272555f5a61f" + integrity sha512-vkpPQAxdCg9SLfPv5GPC5fnGrui/WryktoN9O5+Zif/14QIMjw+RITf/5LbBh+9QpBFb3KNvJth+puz2H8o6GQ== + dependencies: + copy-anything "^3.0.2" + supports-color@^5.3.0: version "5.5.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"