Skip to content

AnyhowStep/route-client

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

24 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

route-client

Use route-declaration and type-mapping to make compile-time, and run-time type-safe API calls.

Maps/validates outgoing request param, query, body, header data during compile-time and run-time.

Maps/validates incoming response data during compile-time and run-time.


Goals

  • Compile-time type safety

    Using route-declaration, you may build your requests and have TypeScript check it during compile-time and give helpful error messages.

(TODO: More goals)


Installation

npm install --save route-client

Basic Usage

import * as tm from "type-mapping/fluent";
import {route} from "route-declaration";
import {toAxiosApi} from "route-client";

const fields = tm.fields({
    flowerId : tm.mysql.bigIntUnsigned(),
    name : tm.mysql.varChar(),
    wateredAt : tm.mysql.dateTime(3),
});

/**
    A mapper that returns,

    {
        flowerId : bigint,
        name : string,
        wateredAt : Date,
    }
*/
const flower = tm.object(fields);

const all = route()
    .append("/flower");
const one = route()
    .append("/flower")
    .appendParam(fields.flowerId, /\d+/);

//`FlowerApi` is a class constructor
const FlowerApi = toAxiosApi({
    //POST /flower
    //Creates a new flower entry
    create : all
        .setBody(tm.object(
            fields.name,
            fields.wateredAt,
        ))
        .setResponse(flower),
    //GET /flower/:flowerId
    //Fetches a single flower
    fetch : one
        .setResponse(flower),
    //PUT /flower/:flowerId
    //Updates a single flower
    update : one
        .setMethod("PUT")
        .setBody(tm.object(
            fields.name.optional(),
            fields.wateredAt.optional(),
        ))
        .setResponse(flower),
    //DELETE /flower/:flowerId
    //Deletes a single flower
    delete : one
        .setMethod("DELETE")
    //GET /flower?page=:number&rowsPerPage=:number
    //Fetches all flowers, paginated
    paginate : all
        .setQuery(tm.object({
            page : tm.mysql.intUnsigned().optional(),
            rowsPerPage : tm.mysql.intUnsigned().optional(),
        }))
        .setResponse(tm.object({
            rows : flower.array(),
            info : tm.object({
                page : tm.mysql.intUnsigned(),
                rowsPerPage : tm.mysql.intUnsigned(),
                rowsFound : tm.mysql.intUnsigned(),
                pagesFound : tm.mysql.intUnsigned(),
            }),
        })),
});

//`flowerApi` is an instance of `FlowerApi`, with all the routes
const flowerApi = new FlowerApi({
    //e.g. http://example.com:9999
    domain : `http://localhost`,
    //The root path the is prepended to all paths of the API
    root : `/api`,
});

//POST http://localhost/api/flower
//Throws an error if a non-2xx status code is returned (like 400, 401, 422, 500, etc.)
flowerApi.create()
    .setBody({
        name : "Orchid by the Kitchen Window",
        wateredAt : new Date(),
    })
    .send()
    .then((sendResult) => {
        /*
            {
                flowerId : bigint,
                name : "Orchid by the Kitchen Window",
                wateredAt : Date,
            }
        */
        console.log(sendResult.responseBody);
    });
//GET http://localhost/api/flower/4
//Throws an error if a non-2xx status code is returned (like 404)
flowerApi.fetch()
    .setParam({
        flowerId : 4n,
    })
    .send()
    .then((sendResult) => {
        /*
            {
                flowerId : bigint,
                name : string,
                wateredAt : Date,
            }
        */
        console.log(sendResult.responseBody);
    });
//GET http://localhost/api/flower/4
//Throws an error if a non-2xx, and non-404 status code is returned (like 500)
flowerApi.fetch()
    .setParam({
        flowerId : 4n,
    })
    //You can pass non-2xx status codes here.
    //to handle them without throwing an error.
    .on(404, () => {
        return undefined;
    })
    .send()
    .then((sendResult) => {
        if (sendResult.status == 404) {
            /*
                Type of `sendResult.responseBody` narrowed to `undefined`
            */
            console.log(sendResult.responseBody);
            //Error; property flowerId does not exist on `undefined`
            console.log(sendResult.responseBody.flowerId);
        } else {
            /*
                Type of `sendResult.responseBody` narrowed to flower object,
                {
                    flowerId : bigint,
                    name : string,
                    wateredAt : Date,
                }
            */
            console.log(sendResult.responseBody);
            //OK!
            console.log(sendResult.responseBody.flowerId);
        }
    });

//PUT http://localhost/api/flower/4
//Throws an error if a non-2xx, non-404, non-304 status code is returned (like 500)
flowerApi.update()
    .setParam({
        flowerId : 4n,
    })
    .setBody({
        //`name` is optional, so we can leave it undefined.
        //We set the `name` to update it.
        name : "Lily by the Pond",
        //`wateredAt` is optional, so we can leave it undefined.
        //We leave it unset to not update it.
    })
    //You can pass non-2xx status codes here.
    //to handle them without throwing an error.
    .on(404, () => {
        return {
            notFound : true,
        } as const;
    })
    //You can pass non-2xx status codes here.
    //to handle them without throwing an error.
    .on(304, () => {
        return {
            notModified : true,
        } as const;
    })
    .send()
    .then((sendResult) => {
        if (sendResult.status == 404) {
            /*
                Type of `sendResult.responseBody` narrowed to `{ notFound : true }`
            */
            console.log(sendResult.responseBody);
            //OK; true
            console.log(sendResult.responseBody.notFound);
        } else if (sendResult.status == 304) {
            /*
                Type of `sendResult.responseBody` narrowed to `{ notModified : true }`
            */
            console.log(sendResult.responseBody);
            //OK; true
            console.log(sendResult.responseBody.notModified);
        } else {
            /*
                Type of `sendResult.responseBody` narrowed to flower object,
                {
                    flowerId : bigint,
                    name : string,
                    wateredAt : Date,
                }
            */
            console.log(sendResult.responseBody);
            //OK!
            console.log(sendResult.responseBody.flowerId);
        }
    });

//DELETE http://localhost/api/flower/4
//Throws an error if a non-2xx, and non-404 status code is returned (like 500)
flowerApi.delete()
    .setParam({
        flowerId : 4n,
    })
    //You can pass non-2xx status codes here.
    //to handle them without throwing an error.
    .on(404, () => {
        return {
            notFound : true,
        } as const;
    })
    .send()
    .then((sendResult) => {
        if (sendResult.status == 404) {
            /*
                Type of `sendResult.responseBody` narrowed to `{ notFound : true }`
            */
            console.log(sendResult.responseBody);
            //OK; true
            console.log(sendResult.responseBody.notFound);
        } else {
            /*
                The route has no response mapper specified.
                Type of `sendResult.responseBody` narrowed to `undefined`
            */
            console.log(sendResult.responseBody);
        }
    });

//GET http://localhost/api/flower
//Throws an error if a non-2xx status code is returned (like 500)
flowerApi.paginate()
    .setQuery({
        page : 11,
    })
    .send()
    .then((sendResult) => {
        /*
            {
                rows : {
                    flowerId : bigint,
                    name : string,
                    wateredAt : Date,
                }[],
                info : {
                    page : number,
                    rowsPerPage : number,
                    rowsFound : number,
                    pagesFound : number,
                }
            }
        */
        console.log(sendResult.responseBody);
    });

AxiosSender, ISender

An ISender performs the actual network request and receives the response.

This package comes with a single implementation of ISender that uses axios and is called AxiosSender.

You may create your own senders by implementing ISender.


Request, request()

The function request(route, sender) takes a route declared with the route-declaration package and a sender implementing ISender.

It returns a Request class.

The Request class is a request builder that lets you set all the necessary param, query, body, header values before calling .send() to perform the network request.

The Request class also lets you specify error handling for specific http status codes with the .on() method.

See these tests for more details.


HttpStatusCode, HttpStatusCode2xx, HttpStatusCodeNon2xx

Enums that contain the varieous HTTP status codes.

See http-status-code.ts for more details.


toAxiosApi(), toApi()

Instead of calling request(route, sender) repeatedly, toApi() and toAxiosApi() may be used to aggregate route declarations, and an ISender into one class.

declare const axiosSenderArgs : AxiosSenderArgs;
const sender = new AxiosSender(axiosSenderArgs);
//This is tiresome
request(createRoute, sender);
request(fetchRoute, sender);
request(updateRoute, sender);
request(deleteRoute, sender);

//More set-up is required here.
const FlowerApi = toAxiosApi({
    create : createRoute,
    fetch : fetchRoute,
    update : updateRoute,
    delete : deleteRoute,
});
const flowerApi = new FlowerApi(axiosSenderArgs);
//However, each API call is now shorter.
flowerApi.create();
flowerApi.fetch();
flowerApi.update();
flowerApi.delete();

TransformBodyDelegate

You may wish to use JS objects when setting the body of your network request.

However, the server implementing the API endpoint may be expecting application/x-www-form-urlencoded.

With a TransformBodyDelegate, you can still use JS objects when building your request.

The TransformBodyDelegate will be invoked to transform the JS object into the application/x-www-form-urlencoded format before sending the request.

See Request.setOnTransformBody() for more details. Calling this will apply the TransformBodyDelegate for that one network request.

Also see AxiosApiArgs.onTransformBody. Setting this property will apply the TransformBodyDelegate to all requests made with the AxiosApi instance.

Using AxiosApiArgs.onTransformBody is the recommended way to use this feature as it requires less boilerplate, overall.

See this test for more details.


InjectHeaderDelegate

Your API may require an api-key header for all network requests. Having to set the header value on all requests might be too much effort.

Using AxiosApiArgs.onInjectHeader, you can apply a default set of header keys and values to all requests.

See this test for more details.


TransformResponseDelegate

You may wish to work with JS objects when receiving a network response.

However, the server implementing the API endpoint might only send responses in XML, or binary format.

With a TransformResponseDelegate, you can still use JS objects after receiving a response.

The TransformResponseDelegate will be invoked to transform the response into a JS object before applying the response mapper to it.

AxiosApiArgs.onTransformResponse is the recommended way to use this feature.

See this test for more details.


Contributing

(TODO)


Tests

npm run sanity-check

The above command rebuilds this package and runs the compile-time and run-time tests.


Cookbook

(TODO)

About

Send requests using route-declaration

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published