Docs

Introduction

TypedAPI is set of libraries for creating client-server APIs for applications written in typescript. You describe API like ordinar class with only methods that return Promise. Then parser will create interface based on that class. Interface will be used by client`s application. Then you only need to configure connectors (HTTP and WebSocket available). Also API class can contain Events (HTTP connector will use HTTP polling) and child classes.

Installation

There is serveral libraries of TypedAPI and you install them depends on what result you need.

Target Server Client (browser)
Core typedapi-core
typedapi-server
typedapi-parser (in --dev mode)
typedapi-core
typedapi-client
WebSocket typedapi-server-ws typedapi-client-browser-ws
HTTP - typedapi-client-browser-http

If you want to install TypedAPI with Websocket support, you should run

# for server
npm install --save typedapi-core typedapi-server typedapi-server-ws
npm install --save-dev typedapi-parser
# for client
npm install --save typedapi-core typedapi-client typedapi-client-browser-ws

For installation with HTTP support, run:

# for server
npm install --save typedapi-core typedapi-server
npm install --save-dev typedapi-parser
# for client
npm install --save typedapi-core typedapi-client typedapi-client-browser-http

API Class

To describe API you only need to create TypeScript class with some conditions:

  • All methods should return Promises
  • Types of data, that can be received and returned by API:
    - Scalar types: number, string, boolean, Date, undefined, null;
    - Complex types: Array, Tuple, Enum, Union. Complex types can contain only types describerd here;
    - Object without methods;
    - Indexed object: { [key: string | number]: SomeOtherType };

    All types should be strongly typed: any|unknown not allowed.
  • Api class can contain child classes with same conditions.
  • Api class also can contain Events and Parametric Events objects.
  • Api class can use some injections for Authorization

Sample of abstract books API:

// Main api class
export class Api {
    books = new BooksApi
}

// Sample books api class that use some yours repository
export class BooksApi {

    get(id: number): Promise<BookResult> {
        return repository.get(id)
    }

    async create(createBookData: CreateBook): Promise<BookResult> {
        const bookResult = await repository.createBook(createBookData)
        this.onCreate.fire(bookResult)
    }

    async update(id: number, data: UpdateBook): Promise<void> {
        const bookResult = await repository.updateBook(id, data)
        this.onUpdate.fire(bookResult)
    }

    async remove(id: number): Promise<void> {
        await repository.deleteBook(id)
        this.onDelete.fire(id)
    }

    onCreate = new Event<BookResult>()
    onUpdate = new Event<BookResult>()
    onDelete = new Event<number>()
}

// Interfaces of data
export interface BookResult {
    id: number
    title: string
    description: string
}

export interface UpdateBook {
    title?: string
    description?: string
}

export interface CreateBook {
    title: string
    description: string
}

Generate interface

To use API on client you shoud generate api interface and reflections. It can be done using typedapi-parse binary.

To run generator, you should call typedapi-parse from server directory. It will be in ./node_modules/.bin directory. typedapi-parse command receive parameters:

typedapi-parse [sourceFilename] [sourceObjectName] [outFilename] [reflectionOutFileName]

Where:

  • sourceFilename: Path to file where your Api object
  • sourceObjectName: Class name in file (ex Api, Backend, MyCompanyApi etc)
  • outFilename: Ouput file name for client where will be interface, reflection, and Api factory
  • reflectionOutFileName: Ouput file name for server where will be stored Api reflection

For example, you can run:

./node_modules/.bin/typedapi-parse Api.ts Api ../client/apiReflection.ts apiReflection.ts

Also you can run generator from code:

import { build } from "typedapi-parser"

build({
    sourceFilename: "Api.ts",
    sourceObjectName: "Api",
    outFilename: "../client/apiReflection.ts",
    reflectionOutFileName: "apiReflection.ts",
})

Setup WebSocket connection

Sample for server:

import { WebSocketServer } from "typedapi-server-ws"
import { buildMap } from "typedapi-server"
// Reflection generated using typedapi-parse
import { reflection } from "./apiReflection"
// Your Api object
import { Api } from "./Api"

new WebSocketServer({
    apiMap: buildMap(reflection, new Api),
    port: 8090
})

Sample for client:

// Import TypedAPI libraries
import { WebSocketTransport } from "typedapi-client-browser-ws"
// Reflection generated using typedapi-parse
import { createClient } from "./apiReflection"
const api = createClient({ transport })

// And then you can run your API Methods
let result = await api.hello(name)

Setup HTTP connection

Sample for server:

import { HttpServer, buildMap } from "typedapi-server"
import { buildMap } from "typedapi-server"
import * as http from "http"
// Reflection generated using typedapi-parse
import { reflection } from "./apiReflection"
// Your Api object
import { Api } from "./Api"

// Create TypedAPI HTTP server
const apiHttpServer = new HttpServer({
    apiMap: buildMap(reflection, new Api)
})

// Handle HTTP request using stadart http module from NodeJS
const server = http.createServer(function (request, response) {
    apiHttpServer.handleRequest(request, response)
})

Sample for client:

// Import TypedAPI libraries
import { HttpTransport } from "typedapi-client-browser-http"
// Reflection generated using typedapi-parse
import { createClient } from "./apiReflection"
const api = createClient({ transport })

// And then you can run your API Methods
let result = await api.hello(name)

Events

Realisation of Events in TypedAPI is very simple. On server:

import { Event } from "typedapi-server"
// Describe event data
interface SomeEventData {
    someString: string,
    someNumber: number
}
// Include it to API class
export class Api {
    someEvent = new Event<SomeEventData>
}

// How to fire event
const data: SomeEventData = { someString: "string", someNumber: 123 }
// To all subscribers
api.someEvent.fire(data)
// To single user
api.someEvent.fireForUser(data, userId)
// To group
api.someEvent.fireForGroup(data, groupName)
// To user with specific session
api.someEvent.fireForSession(data, sessionId)
// To specific connection
api.someEvent.fireForConnection(data, connectionId)

On client:

// Subscribe to event
const subscription = await api.someEvent.subscribe(data => {
    // code to hadle event data
})
// Unsubscribe from event
await subscription.unsubscribe()

HTTP connection will use HTTP polling for handling events

Parametric events

Parametric event is more complex event, when subscription have some parameters, that will be used to check if we need to send specific event for specific connection.
ParametricEvent is generic class that recive 3 type variables:

  • DATA: type of data that will be sent to client
  • SUBSCRIPTION_PARAMETERS: type of parameters that client whould use to subscribe
  • EVENT_PARAMETERS: type of event parameters that will be passed to parametric event comparer

Also ParametricEvent have 2 constructor parameters

  • comparer: Comparer method to check if event should fire for current cubscription
  • validator (optional): Validator method to check if curren user can subscribe to event with such parameters

In result, public interface of ParametricEvent:

export class ParametricEvent<DATA, SUBSCRIPTION_PARAMETERS, EVENT_PARAMETERS> {
    constructor(
        public comparer: ParametricComparer<DATA, SUBSCRIPTION_PARAMETERS, EVENT_PARAMETERS>,
        public validator?: SubscriptionValidator<SUBSCRIPTION_PARAMETERS>,
    );

    fire(data: DATA, eventParameters: EVENT_PARAMETERS): void;
}

// Type of comparer method:
export type ParametricComparer<DATA, SUBSCRIPTION_PARAMETERS, EVENT_PARAMETERS> = {
    (subscriptionParameters: SUBSCRIPTION_PARAMETERS, data: DATA, eventParameters: EVENT_PARAMETERS): boolean
}

// Type of subscription validator method:
export type SubscriptionValidator<SUBSCRIPTION_PARAMETERS> = {
    (subscriptionParameters: SUBSCRIPTION_PARAMETERS, authData: AuthData): Promise<true | string>
}

For example, in our abstract books API we want subscribe to event when specific Book changed:

import { ParametricEvent } from "typedapi-server"

// Data of book
interface BookData {
    id: number
    title: string
    description: string
}

// Api class with event
export class Api {
    onBookChanged = new ParametricEvent<BookData,number,number>((data, subParams, eventParams) => {
        return subParams === eventParams
    })
}

// How to fire event on server
const eventData: BookData = {
    id: 10,
    title: "Book title",
    description: "Book description"
}
api.onBookChanged.fire(eventData, eventData.id)

When creating event object, we passing 3 type variables:
BookData - event data that will be passed to client.
number - subscription parameter, wi will subscribe to book by it id.
number - event parameter, when firing event, wi will pass book id here.

How to process event on client:

// subscribe to event
const subscription = await api.onBookChanged.subscribe(bookData => {
    // process bookData
}, 10) // <== here book id passed

//unsubscribe from event
await subscription.unsibscribe()

Authorization

To implement user`s authorization:
1. Realize SessionProvider object that implements SessionProviderInterface. By default server users MemorySessionProvider that working, but will be reset on application restart. Then add your provider to server configuration.
2. Add to API methods that return AuthDataResponse.

export type AuthDataResponse = {
    newAuthData: {
        id?: string | number
        groups?: string[]
        name?: string
        email?: string
        phone?: string
    }
    // response value will be return to user,
    // newAuthData will be removed and used only for internal usage
    response: boolean
}

Example of Api that implement users` authorization.

import { AuthDataResponse } from "typedapi-server"

export class ClientApi {

    /**
    * This method check user`s name and password, and, if success, it return user`s id and groupd.
    * That data will be stored in user`s connection data and will be passwed to methods when need.
    **/
    async login(username: string, password: string): Promise<AuthDataResponse> {
        let user = await usersRepository.login(username, password)
        if(!user) {
            return {
                response: false,
                newAuthData: {}
            }
        } else {
            return {
                response: true,
                newAuthData: {
                    id: user.id,
                    groups: user.groups
                }
            }
        }
    }

    /**
    * Logout and return empty auth data
    **/
    async logout(): Promise<AuthDataResponse> {
        return {
            response: true,
            newAuthData: {}
        }
    }

    /**
    * ApiUserID will be atumatically injected by user`s auth data.
    * If user not authorized, he will receive NotAuthorizedError
    **/
    async getUserData(apiUserId: number): Promise<SomeUserData> {
        let userData = await usersRespotory.getUserData(apiUserId)
        return userData
    }
}

For getting information about user you can use these injections. Injections will be removed from client`s interface

  • apiUserId - user id. If parameter not optional, for not authorized user request will be rejected with "NotAuthorizedError"
  • apiAuthData: AuthData - object with user authorization data:
    type AuthData = {
        id?: string | number
        groups?: string[]
        name?: string
        email?: string
        phone?: string
    }
    
  • apiConnectionData: ConnectionData - object with connection data
    type ConnectionData = {
        authData: AuthData
        ip: string
        sessionId?: string
        connectionId?: string
    }
    

Api map

Api map is obect that comress all methods and events to Maps for fast access. To create apiMap use buildMap method.
Example how to create Api map:

import { WebSocketServer } from "typedapi-server-ws"
import { buildMap } from "typedapi-server"
// Reflection generated using typedapi-parse
import { reflection } from "./apiReflection"
// Your Api object
import { Api } from "./Api"

// creating api map
const api = new Api
const apiMap = buildMap(reflection, api)

// Passing api map to websocket server
new WebSocketServer({
    apiMap: apiMap,
    port: 8090
})

Logging

By default TypedAPI use ConsoleLogger and log all data to console. You can implement own logger, pass it to server configuration and save logs anywhere you want.
Logger should implement interface:

export interface LoggerInterface {
    methodCall(method: string, ms: number, input: unknown, output: unknown, connectionData: ConnectionData): void
    clientError(method: string, input: unknown, error: string, connectionData: ConnectionData): void
    serverError(method: string, input: unknown, error: string, connectionData: ConnectionData): void
    event(event: string, data: unknown, connectionData?: ConnectionData): void
    status(data: LoggerServerStatusData): void
}

Microservices

The idea how to implement mocroservice architecture in TypedAPI project is to create Api instance on each node and configure each service for hadling specified methods, or to fire specified errors.
Instruments to achieve that:

  • HttpProxyClient - Class that proxy methods from entrance API to other service
  • HttpTrustServer - Class that receive request from HttpProxyClient and call requested method
  • RedisPublisher - Class that connect to api, listen for events, and send it to Redis server
  • RedisSubscriber - That class listen events from redis and send event to customer

To use RedisPublisher and RedisSubscriber you need to install another one library:

npm install --save typedapi-redis-signaling

For example, lets create sample API that will have two methods and one event.

import { Event } from "typedapi-server"
export class Api {
    async method1() {
        // do something
    }
    async method2() {
        // do something
    }
    event = new Event<string>()
}

Then we will create 3 services:
1. Entrance Api server, it will process method2
2. Service for handling method1
3. Service that will not handle methods, but sometime it will fire event.
Architecture will look like:

Then in project wee ned to craete 3 files for each service. Lets name them node{1-3}

node1.ts - entrance API server

import { WebSocketServer } from "typedapi-server-ws"
import { buildMap, HttpProxyClient } from "typedapi-server"
import { RedisSubscriber } from "typedapi-redis-signaling"
// Reflection generated using typedapi-parse
import { reflection } from "./apiReflection"
// Your Api object
import { Api } from "./Api"

// creating api map
const api = new Api
const apiMap = buildMap(reflection, api)

// Passing api map to websocket server
new WebSocketServer({
    apiMap: apiMap,
    port: 8090
})

// Server created, now wee ned to configure methods proxy
new HttpProxyClient({
    apiMap: apiMap, // api map, proxy client will replace methods with own controller
    host: "192.168.0.10", // host of remote node
    port: 0000, // port of remote node,
    methodsPattern: "method1" // we need to proxy only method1
})

// Then wee need to configure RedisSubscriber to receive events from other nodes
new RedisSubscriber({
    apiMap: apiMap,
    channel: "my-channel", // name of channel that will be listen to.
})

node2.ts - entrance API server

import { buildMap, HttpTrustServer } from "typedapi-server"
// Reflection generated using typedapi-parse
import { reflection } from "./apiReflection"
// Your Api object
import { Api } from "./Api"

// creating api map
const api = new Api
const apiMap = buildMap(reflection, api)

// then we need to configure HttpTrustServer
// that will receive requests from entrance API
new HttpTrustServer({
    apiMap: apiMap,
    port: 8080,
})

node3.ts - Service that will fire event

import { buildMap } from "typedapi-server"
import { RedisPublisher } from "typedapi-redis-signaling"
// Reflection generated using typedapi-parse
import { reflection } from "./apiReflection"
// Your Api object
import { Api } from "./Api"

// creating api map
const api = new Api
const apiMap = buildMap(reflection, api)

// Then wee need to configure RedisPublisher that will send events to redis
new RedisPublisher({
    apiMap: apiMap,
    channel: "my-channel", // name of channel that will be listen to.
})

// then we can fire event that will be fired also on intrance api
api.event.fire("hello, world!")

Errors handling

By default, if any error throwed by API method, on client will be throwed ServerError. Error data and stack will be passed to log, but client will receive only "Server error" message.

If you want to use your own errors and handle it by client, you should use ObjectProxy and add your error to ObjectProxy on server and client. Example:

import { ClientError } from "typedapi-core"
// this class should be included to server and client
export class MyCustomError extends ClientError {
    constructor(message = "Custom Error") {
        super(message)
        // we should add that, or instanceof for MyCustomError will be not working correct
        Object.setPrototypeOf(this, MyCustomError.prototype)
    }
}

Server:

import { ObjectProxy } from "typedapi-server"
import { MyCustomError } from "where it is"
// create object proxy
const objectProxy = new ObjectProxy()
// to add errors you should pass hash {[unique error name] => [error class]}.
// here we add { MyCustomError => MyCustomError }
objectProxy.setErrors({ MyCustomError })
// then add it to server constructor
new WebSocketServer({
    objectProxy: objectProxy,
    apiMap: apiMap,
    port: 8090
})

// then in your API code:
throw new MyCustomError()

Client:

import { ObjectProxy } from "typedapi-client"
import { MyCustomError } from "where it is"
// create object proxy and add error
const objectProxy = new ObjectProxy()
objectProxy.setErrors({ MyCustomError })

// then add it to createClient factory
const api = createClient({
    transport,
    objectProxy
})

// And then you can run your API Methods and catch that error
try {
    let result = await api.hello(name)
} catch(err) {
    if(err instanceof MyCustomError) {
        // do something with that error
    }
}

Decorators

There is several decorators that can be added to API methods:

  • LogConfig - logging configuration decorator. Use it if you method return many data and you dont want to store it in logs
    Receive one parameter:
    true - log all queries with input and output data [default]
    false - no log any queries
    "noData" - log queries but no put data to log
    "inputOnly" - log queries with only input data
    "outputOnly" - log queries with only output data
  • BroadCastEvent - add it decorator for often used events, and it will be sent to all users every time, and server will not store subscriptions list for that event. No parameters.
  • Access - decorator to set method access. you can pass group name or array of groups who can call that method.
  • NoFilter - No filter decorator is used to made more performance only if your return has no Date fields and you sure that you not send any columns not from response interface
  • FastFilter - used to made more performance if your return has no Data fields, but you not sure that you not send any columns not from response interface