β WARNING: This package is currently under development and might be subject to breaking changes until version 1.0.0 is released.
AcquireJS is a TypeScript library designed to streamline the process of working with REST APIs. The library aims to solve three common pain-points in the data fetching/mutation process:
-
π Type safety - Ensure that data going into and out of your application from REST APIs is type safe.
-
πΊοΈ Data mapping - Decide how data going into and out of your application from REST APIs should be mapped, using a declarative approach.
-
π Mocking and testing - Easily mock data and API responses in order to test your code. Mock Data Transfer Objects (DTOs) to write unit tests at the function and component level, mock API calls to write integration tests at the page level or mock API calls with relational data to write End-to-End (E2E) tests at the application level.
π‘ Tip: Wondering if AcquireJS is right for your project? Read the motivation!
To get started, install the @acquirejs/core
package and reflect-metadata
:
Using npm:
npm install @acquirejs/core reflect-metadata
Using yarn:
yarn add @acquirejs/core reflect-metadata
π‘ Tip: AcquireJS is built on axios. If you want to specify an axios config, you should also install axios.
Using npm:
npm install axiosUsing yarn:
yarn add axios
To use AcquireJS, you must tweak some TypeScript settings. In tsconfig.json
:
{
"compilerOptions": {
// Other settings...
"strictPropertyInitialization": false,
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
You also need to import reflect-metadata
at the entry-point of your application:
import "reflect-metadata";
// Application entry-point...
First, create an instance of Acquire
:
// src/api/acquire.ts
import { Acquire } from "@acquirejs/core";
const acquire = new Acquire();
export default acquire;
You can also pass in an axios instance as the first argument:
// src/api/acquire.ts
import axios from "axios";
import { Acquire } from "@acquirejs/core";
const exampleAxios = axios.create({
baseURL: "http://api.example.com"
});
const acquire = new Acquire(exampleAxios);
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, so properties likebaseURL
andheaders
can be configured separately.
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.
Imagine an example endpoint (http://api.example.com/users/1
) that returns a user JSON response in the following format:
{
"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"
}
The DTO class should then look like this:
// src/api/users/dtos/UserDTO.ts
export default class UserDTO {
id: number;
firstName: string;
lastName: string;
email: string;
phoneNumber: string;
role: string;
isActive: boolean;
lastActiveAt: string;
createdAt: string;
updatedAt: string;
}
π Caveat: Notice how we are preserving values in their JSON primitive representation at this stage, so even though
lastActive
,createdAt
andupdatedAt
represent dates, we keep them as strings.
A Model class should also be created, which represents the desired format of the data. It may look like this:
// src/api/users/models/UserModel.ts
import { Expose, ToDate } from "@acquirejs/core";
export default class UserModel {
@Expose() id: number;
@Expose() firstName: string;
@Expose() lastName: string;
@Expose() email: string;
@Expose() phoneNumber: string;
@Expose() role: "basic-user" | "admin";
@Expose() isActive: boolean;
@Expose() @ToDate() lastActiveAt: Date;
@Expose() @ToDate() createdAt: Date;
@Expose() @ToDate() updatedAt: Date;
}
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 library, which is bundled with@acquirejs/core
. All decorators and functions fromclass-transformer
can be imported directly from@acquirejs/core
.
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.
The Acquire
instance is used with the DTO and Model classes to create a request executor function:
// src/api/users/userApi.ts
import acquire from "../../acquire.ts";
import UserDTO from "./dtos/UserDTO.ts";
import UserModel from "./models/UserModel.ts";
export const getUser = acquire
.createRequestHandler()
.withResponseMapping(UserModel, UserDTO)
.get({
url: "http://api.example.com/users/1"
});
π‘ Tip: If you instantiated
Acquire
using anaxios
instance with abaseURL
ofhttp://api.example.com/
, you could instead just set thepath
:export const getUser = acquire .createRequestHandler() .withResponseMapping(UserModel, UserDTO) .get({ path: "/users/1" });
The request can now be executed:
import { getUser } from "path-to-getUser";
const user = await getUser();
user.model; // type: UserModel (after mapping)
user.dto;, // type: UserDTO (before mapping)
user.response; // type: AxiosResponse
Here, user.model
is typed and mapped according to the UserModel
class! π
In the previous example, the ID of the user was hard-coded into the url, causing getUser
to always return the user with ID 1. This was only shown as a simplistic example, but in general, the ID should be passed as an argument to the getUser
method.
Setting dynamic request parameters can be done by passing a generic type (TCallArgs
) to the get
method. The generic type must extend an object and will be required when calling the getUser
function. All request configuration properties can be set as values or callbacks that take TCallArgs
as the argument. In the following example, the userId
is injected into the path of the url:
export const getUser = acquire
.createRequestHandler()
.withResponseMapping(UserModel, UserDTO)
.get<{ userId: number }>({
url: ({ userId }) => `http://api.example.com/users/${userId}`
});
getUser
is then called like so:
const { model: user } = await getUser({ userId: 10 });
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:
export const getUsers = acquire
.createRequestHandler()
.withResponseMapping([UserModel], [UserDTO]) // π notice the square brackets!
.get({
url: "http://api.example.com/users"
});
Now, the return type of getUsers
has model
typed as a UserModel[]
and dto
as UserDTO[]
.
AcquireJS can also perform mutations. In this case, a request method other than get
(e.g., put
, post
, delete
) can be used as the final function to end the chaining. Additionally, a withRequestMapping
can be provided, similar to the withResponseMapping
. In general, the DTO used for queries and mutations may differ. For instance, the UserDTO
in the previous example has information about the ID of the user, if the user is currently active, as well as when the user was created, last updated and last active. While this information is not included in the body of a post
request, it may appear in the response of the same request. Hence, separate CreateUserDTO
and CreateUserModel
classes can be created to deal with the outgoing data:
// src/api/users/dtos/CreateUserDTO.ts
import { Expose } from "@acquirejs/core";
export default class CreateUserDTO {
@Expose() firstName: string;
@Expose() lastName: string;
@Expose() email: string;
@Expose() phoneNumber: string;
@Expose() role: string;
}
And:
// src/api/users/models/CreateUserModel.ts
export default class CreateUserModel {
firstName: string;
lastName: string;
email: string;
phoneNumber: string;
role: "basic-user" | "admin";
}
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!
Another request executor function can be created for the mutation:
// src/api/users/userApi.ts
import acquire from "../../acquire.ts";
import UserDTO from "./dtos/UserDTO.ts";
import CreateUserDTO from "./dtos/CreateUserDTO.ts";
import UserModel from "./models/UserModel.ts";
import CreateUserModel from "./models/CreateUserModel.ts";
// From the previous example...
export const getUser = acquire
.createRequestHandler()
.withResponseMapping(UserModel, UserDTO)
.get<{ userId: number }>({
url: ({ userId }) => `http://api.example.com/users/${userId}`
});
// Adding a mutation
export const createUser = acquire
.createRequestHandler()
.withRequestMapping(CreateUserModel, CreateUserDTO)
.withResponseMapping(UserModel, UserDTO)
.post({
url: "http://api.example.com/users"
});
To pass this data to the createUser
method, data
can be set in the argument:
const user = await createUser({
data: {
firstName: "Jane",
lastName: "Doe",
email: "janedoe@example.com",
phoneNumber: "+1987654321",
role: "basic-user"
} // π The type of `data` is dictated by `CreateUserModel`
});
By specifying the CreateUserModel
in the responseMapping
, the data
argument automatically gets the type of the CreateUserModel
class.
π‘ Tip: Thanks to TypeScript's structural type system (commonly referred to as duck typing), you can pass a plain object that conforms to the
CreateUserModel
class interface to thecreateUser
method, instead of creating an actual instance of the class.
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:
-
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.
-
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:import { Acquire, RequestLogger } from "@acquirejs/core"; const acquire = new Acquire().use(new RequestLogger()); export default acquire;
To get started with mocking, the DTO classes must be updated to include Mock decorators.
The Mock
decorator can be used to hard-code JSON primitive values for the DTO class:
import { Mock } from "@acquirejs/core";
export default class UserDTO {
@Mock(10) id: number;
@Mock("Jane") firstName: string;
@Mock("Doe") lastName: string;
@Mock("janedoe@example.com") email: string;
// other properties...
}
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:
import { Mock } from "@acquirejs/core";
export default class UserDTO {
@Mock(() => 10) id: number;
@Mock(() => "Jane") firstName: string;
@Mock(() => "Doe") lastName: string;
@Mock(() => "janedoe@example.com") email: string;
// other properties...
}
Here, you can use functions that generate randomly generated data and even execute async functions.
π Caveat: When are the Mock callbacks actually called?
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.
To get started, install the mocks package:
npm install @acquirejs/mocks
Or
yarn add @acquirejs/mocks
Then, somewhere near the entry point of your application (before any mocks are invoked), call initAcquireMocks
:
import { initAcquireMocks } from "@acquirejs/mocks";
initAcquireMocks();
You can then import the required decorators:
// src/api/users/dtos/UserDTO.ts
import {
MockNatural,
MockFirstName,
MockLastName,
MockEmail,
MockPhone,
MockPick,
MockBool,
MockDate
} from "@acquirejs/mocks";
export default class UserDTO {
@MockNatural() id: number;
@MockFirstName() firstName: string;
@MockLastName() lastName: string;
@MockEmail() email: string;
@MockPhone() phoneNumber: string;
@MockPick(["basic-user", "admin"]) role: string;
@MockBool() isActive: boolean;
@MockDate() lastActiveAt: string;
@MockDate() createdAt: string;
@MockDate() updatedAt: string;
}
π‘ Tip:
@acquirejs/mocks
is a wrapper around the Chance 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. IfinitAcquireMocks
is never invoked, the Chance library is not imported and all Mock decorators from@acquirejs/mocks
are replaced with empty function calls.
When mocking an AcquireJS request, no actual network request is executed. Instead, a mock function is called to simulate a network request, which is used for testing. When mocking requests, the data is generated by the Mock
decorators on the DTO class. There are two ways to mock a request:
-
Calling
.mock()
instead of calling the request executor directly:const users = await getUsers.mock();
This is mostly useful when writing simple unit tests, but is not that suited for testing components that fetch data, as it requires us to modify how the function is called.
π‘ Tip: When calling a request executor function using
.mock()
, the final argument passed to the function is the generated data count:const users = await getUsers.mock(100);
This will decide how many objects are returned for functions that return arrays (in this case, 100 mock users are generated). The default count is 10.
-
Enable mocking globally:
import acquire from "path-to-acquire"; acquire.enableMocking(); // or acquire.setMockingEnabled(true);
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.
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 the previous example, imagine an another endpoint at http://api.example.com/posts
that returns blog posts JSON response that looks like this:
{
"id": 1,
"title": "Lorem ipsum",
"body": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus mollis.",
"createdByUserId": 1,
"createdByUserFirstName": "John",
"createdByUserLastName": "Doe",
"createdAt": "2023-05-25T12:00:00Z"
}
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, some additional steps are required. Firstly, the UserDTO
class must be modified to use the MockID
decorator:
// src/api/users/dtos/UserDTO.ts
import { MockID } from "@acquirejs/core";
import {
MockFirstName,
MockLastName,
MockEmail,
MockPhone,
MockPick,
MockBool,
MockDate
} from "@acquirejs/mocks";
export default class UserDTO {
@MockID() id: number; // π update this
@MockFirstName() firstName: string;
@MockLastName() lastName: string;
@MockEmail() email: string;
@MockPhone() phoneNumber: string;
@MockPick(["basic-user", "admin"]) role: string;
@MockBool() isActive: boolean;
@MockDate() lastActiveAt: string;
@MockDate() createdAt: string;
@MockDate() updatedAt: string;
}
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:
let userIdHead = 1; function generateUserId(): string { return (userIdHead++).toString(); } class UserDTO { @MockID(generateUserId) id: string; }However, it might not actually matter what format the IDs are in if they are only used to reference other data.
Next, create a PostDTO
class:
// src/api/posts/dto/PostDTO.ts
import { MockID, MockRelationID, MockRelationProperty } from "@acquirejs/core";
import { MockSentence, MockParagraph, MockDate } from "@acquirejs/mocks";
import UserDTO from "../../../users/dto/UserDTO.ts";
export default class PostDTO {
@MockID() id: number;
@MockSentence() title: string;
@MockParagraph() body: string;
@MockRelationID(() => UserDTO) createdByUserId: number;
@MockRelationProperty(() => UserDTO, "firstName")
createdByUserFirstName: string;
@MockRelationProperty(() => UserDTO, "lastName")
createdByUserLastName: string;
@MockDate() createdAt: string;
}
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, the relationship between the UserDTO
and PostDTO
classes are enforced, which leads to better consistency when testing.
Next, create a PostModel
class:
// src/api/posts/models/PostModel.ts
import { Expose, ToDate } from "@acquirejs/core";
export default class PostModel {
@Expose() id: number;
@Expose() title: string;
@Expose() body: string;
@Expose() createdByUserId: number;
@Expose() createdByUserFirstName: string;
@Expose() createdByUserLastName: string;
@Expose() @ToDate() createdAt: Date;
}
And finally set up the request executor function:
// src/api/posts/postApi.ts
import acquire from "../../acquire.ts";
import PostDTO from "./dtos/PostDTO.ts";
import PostModel from "./models/PostModel.ts";
export const getPosts = acquire
.createRequestHandler()
.withResponseMapping(UserModel, UserDTO)
.get<{
createdByUserId?: number;
page?: number;
pageSize?: number;
sortBy?: keyof PostDTO;
sortByDescending?: boolean;
}>({
url: "http://api.example.com/posts",
params: (callArgs) => callArgs
});
Here, it is assumed that the /posts
endpoint can accept additional parameters which can be used to filter the returned posts.
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:
// src/api/acquire.ts
import { Acquire, AcquireMockCache, RequestLogger } from "@acquirejs/core";
const acquire = new Acquire()
.useMockCache(new AcquireMockCache())
.use(new RequestLogger());
export default acquire;
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
:
// src/api/populateMockCache.ts
import { mockCache } from "./acquire";
import UserDTO from "./users/dtos/UserDTO";
import PostDTO from "./users/posts/PostDTO";
export default async function populateMockCache() {
await mockCache.fill(UserDTO, 20);
await mockCache.fill(PostDTO, 100);
}
This will populate the mock cache with 20 randomly generated UserDTOs
and 100 PostDTOs
.
π Caveat: The order matters! Because
PostDTO
relies onUserDTO
, the users must be added first.
π 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 multiplemockCache.fill
calls.
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:
// src/api/posts/postApiMocking.ts
import { getPosts } from "./getPosts.ts";
import PostDTO from "./dtos/PostDTO.ts";
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()
};
});
π Caveat: Here,
useOnMocking
is used to apply this middleware only whengetPosts
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 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.