rickety
v4.1.0
Published
minimal typescript rpc framework
Downloads
182
Readme
:scroll: rickety
minimal typescript rpc library
- Strongly typed endpoints
- Groupable queries
- Convenient testing
- Optional type checks
- Extensible clients
- No runtime dependencies
Try out the example project to experiment with a working setup (including tests)
Install
$ npm install rickety
Usage
import {DefaultClient, Endpoint} from "rickety";
const myAPI = new DefaultClient();
const userByID = new Endpoint<number, User>({
client: myAPI,
path: "/api/v1/...",
});
app.use(
userByID.handler(async (id) => {
// ...
return user;
});
);
const user = await userByID.call(id);
Endpoint
An endpoint's call function sends requests using the configured options. It returns a promise which may be rejected if there is an issue with the request process or if the status is unexpected.
const response = await endpoint.call(request);
Request handlers contain the server code that transforms requests into responses. Both express' req
and res
objects are passed to the function which makes it possible to implement custom behavior like accessing and writing headers.
app.use(
endpoint.handler(async (request, req, res) => {
// ...
return response;
});
);
Endpoints expose their configuration through readonly public values which can be accessed from the instance.
const method = endpoint.method; // POST
The endpoint's request and response types can also be accessed using typeof
on two special members. Using them by value with produce an error.
type Request = typeof endpoint.$req;
type Response = typeof endpoint.$res;
Config
const endpoint = new Endpoint({
client: Client;
path: string;
method?: string;
expect?: number | number[];
isRequest?: (req: any) => boolean;
isResponse?: (res: any) => boolean;
strict?: boolean;
});
| | |
| -- | -- |
| client
| Client is used to send the requests and can be shared by multiple endpoints. More info here. |
| method
| HTTP method used when handling and making requests. Defaults to POST
if not configured. |
| path
| Required URL path at which the handler will be registered and the requests will be sent. |
| expect
| Expected returned status code(s). By default, anything but 200
is considered an error. This value is only used for making requests and has no influence on the handler (which will return 200
by default). |
| isRequest
isResponse
| Type checking functions run before and after serializing the objects in both client and server. By default any value will be considered correct. |
| strict
| Flag to enable strict JSON marshalling/un-marshalling. By default "raw" strings are detected and handled correctly. In strict mode, they would cause a parsing error. This issue comes up if a server is returning a plain message str
. Since it is not valid JSON it cannot be parsed without extra steps. The correct format for a JSON string surrounds it with double quotes "str"
. |
Client
Clients are responsible for sending requests and receiving responses.
Rickety is released with a few included clients which can be imported using the rickety/client/...
path pattern.
| fetch
| xhr
| node
| request
| axios
|
| -- | -- | -- | -- | -- |
The fetch
client is used as DefaultClient
.
Clients can be extended or re-implemented to better address project requirements. For example, a client can enable caching, modify headers or append a path prefix or a domain to endpoint URLs. The only requirement for a client is that it satisfies the Client
interface available in rickety/client
.
The supertest
client also enables easy integration tests, as detailed in the testing section.
Group
Groups allow multiple endpoints to be treated as a single construct while preserving type information.
const userByID = new Endpoint<string, User>( ... );
const promotedByUserID = new Endpoint<string, Product[]>( ... );
const allProducts = new Endpoint<Query, Product[]>( ... );
const listingPage = new Group({
user: userByID,
listing: {
promoted: promotedByUserID,
all: allProducts,
},
});
Groups can be used inside other groups.
Groups are called using a request object with the same "shape" as its definition, but with the correct request data type in the place of the endpoints. Similarly, the response is also strictly typed and shares the same "shape" as the definition, but with response data in the place of the endpoints.
Here is an example request and response objects for the above group.
const pageData = await listingPage.call({
user: "abc-123-xyz",
listing: {
promoted: "abc-123-xyz",
all: {
page: 3
},
},
});
// pageData {
// user: {...}
// listing: {
// promoted: [...],
// all: [...],
// },
// }
The group's dynamic request and response types can also be accessed using typeof
on two special members. Using them by value with produce an error.
type Request = typeof group.$req;
type Response = typeof group.$res;
Testing
An endpoint/group's call
function can be spied on to test behavior with mocked return values or assert on how it is being called.
import {getUserData} from "../endpoints";
import {Homepage} from "../frontend/components";
test("homepage fetches correct user data", () => {
const spy = jest.spyOn(getUserData, "call");
spy.mockReturnValue({ ... });
mount(<Homepage ... />);
expect(spy).toHaveBeenCalledWith( ... );
});
The express app instance can be "linked" to test handler behavior.
import {SupertestClient} from "rickety/client/supertest";
import {app} from "../backend/app";
import {database} from "../backend/database";
import {client} from "../client";
import {createUserByEmail} from "../endpoints";
SupertestClient.override(client, app);
test("new user is created in the database", async () => {
const spy = jest.spyOn(database, "createUser");
spy.mockReturnValue({ ... });
await createUserByEmail( ... );
expect(spy).toHaveBeenCalledWith( ... );
});
This pattern also enables integration tests which involve both client and server code.
import {SupertestClient} from "rickety/client/supertest";
import {mount} from "enzyme";
import {app} from "../backend/app";
import {database} from "../backend/database";
import {client} from "../client";
import {SignUp} from "../frontend/components";
SupertestClient.override(client, app);
test("should refuse duplicate email addresses", async () => {
const spy = jest.spyOn(database, "createUser");
spy.mockReturnValue({ ... });
const wrapper = mount(<SignUp ... />);
const submit = wrapper.find('button');
submit.simulate('click');
expect(wrapper.find(".error")).toContain("...");
});