Skip to content

Latest commit

 

History

History
670 lines (487 loc) · 28.3 KB

documentation.md

File metadata and controls

670 lines (487 loc) · 28.3 KB

Documentation

Documentation of the server side in Node.js.

Click here to check examples and introduction to Askless or here to access the Flutter Client.

Index

Starting Askless in the backend

init(...) → void

Initialize the server.

Example:

import { AsklessServer } from "askless";

const isProduction = false;

const server = new AsklessServer<number>(); // number is the type of the User ID
// which in this example is numeric, because we are using MySQL

server.init({
    wsOptions: { port: 3000, },
    debugLogs: !isProduction,
    sendInternalErrorsToClient: !isProduction,
    requestTimeoutInMs: 7 * 1000,
    // Add your authentication logic here
    authenticate: async (credential, accept, reject) => {
        if (credential && credential["accessToken"]) {
            const result = verifyJwtAccessToken(credential["accessToken"]);
            if (!result.valid) {
                // To reject the connection attempt
                reject({credentialErrorCode: "EXPIRED_ACCESS_TOKEN"});
                return;
            }
            // To accept the connection attempt as an authenticated user
            accept.asAuthenticatedUser({ userId: result.userId,  });
        } else {
            // To accept the connection attempt as an unauthenticated user
            accept.asUnauthenticatedUser();
        }
    },
});

Use any authentication method you prefer, for this example verifyJwtAccessToken(..) would be something like:

import * as jwt from "jsonwebtoken"; // npm install --save jsonwebtoken

export function verifyJwtAccessToken(jwtAccessToken:string) : { userId?:number, valid:boolean } {
    try {
        const res = jwt.verify(jwtAccessToken, privateKey);
        return { 
            valid: true, 
            userId: res.userId,
            // optionally set the user claims and locals here,
            // this can be useful inside the routes
            claims: [], 
            locals: {},
        };
    } catch (e) {
        return { valid: false };
    }
}

🔹 authenticate: (credential, accept, reject) → Promise<void>

Handles the client-side authentication request attempt (optional).
You can choose to either:

  • accept as an authenticated user:
    • accept.asAuthenticatedUser(userId: 1, claims: [], locals: {})
    • or accept.asAuthenticatedUser(userId: 1)
  • accept as an unauthenticated user:
    • accept.asUnauthenticatedUser()
  • or reject the authentication attempt:
    • reject()
    • or reject({credentialErrorCode: "MY_ERROR_CODE"})

credential is the value the client informed in the client side in the connection attempt.

🔹 sendInternalErrorsToClient?:boolean

If true: server internal errors can be sent to the client (optional). Keep as false when the server is running in production. Default: false.

🔹 requestTimeoutInMs?:number

Time in milliseconds that client can wait for a response after requesting it to the server (optional). If <= 0: Timeout error never will occur. Default: 7000 (7 seconds).

🔹 waitForAuthenticationTimeoutInMs?:number

If the logged user is not authenticated in the Flutter App, this is the timeout in milliseconds to wait for the authentication when performing a request to a route that requires authentication.

This delay is useful in cases where the user is performing a request while the access token is being refreshed at the same time.

No-op:

  • no-op when performing a request when the user is already authenticated
  • no-op when performing a request for a route that doesn't require authentication.
  • no-op when neverTimeout is set to true in the request attempt

🔹 wsOptions

Official documentation
The websocket configuration for ws (optional). Default: { port: 3000 }.

🔹 debugLogs?:boolean

Show Askless internal logs for debugging (optional). Keep as false when Askless is running in production. Default: false.

start() → void

Starts the server. This method must be called after the server has been fully configured with init(...).

server.start();

clearAuthentication(userId) → void

Makes a user as not authenticated anymore.

Routes

All routes are available as:

  • server.addRoute.forAuthenticatedUsers...: If you want to create routes to allow only authenticated users to perform operations
    • server.addRoute.forAuthenticatedUsers.read(..)
    • server.addRoute.forAuthenticatedUsers.create(..)
    • server.addRoute.forAuthenticatedUsers.update(..)
    • server.addRoute.forAuthenticatedUsers.delete(..)

and

  • server.addRoute.forAllUsers...: If you want to create routes to allow non-authenticated users and authenticated users to perform operations.
    • server.addRoute.forAllUsers.read(..)
    • server.addRoute.forAllUsers.create(..)
    • server.addRoute.forAllUsers.update(..)
    • server.addRoute.forAllUsers.delete(..)

⚠️ Is not recommended to send the output directly with the context.successCallback(..) parameter

Every route contains a context.successCallback(entity); and context.errorCallback(..) callback. Is not recommended to send the output (data the client will actually receive) directly with context.successCallback(..), instead, send the entity on context.successCallback(<entity here>) parameter, and convert the entity to output in the toOutput(..) function.

Not recommended:

server.addRoute.forAllUsers.read({
    route: "date",
    handleRead: async context => {
        context.successCallback({ "dateTimestamp": date.getTime(), "fullName": `${firstName} ${lastName}`, });
    },
});

✔️ Recommended:

server.addRoute.forAllUsers.read({
    route: "date",
    handleRead: async context => { 
        // e.g.: user is an entity in your server, it's in a DIFFERENT structure than what your App should receive
        // { date: date, firstName: firstName, lastName: lastName }
        context.successCallback(user); 
    },
    toOutput: (entity) => {
        // Here we are converting user to the structure your App SHOULD receive
        return { "dateTimestamp": entity.date.getTime(), "fullName": `${firstName} ${lastName}` };
    },
});

Great! Now, Askless is able to use toOutput(..) alone when needed, in this way, you have the flexibility to use handleReadOverride(..) and Askless still sends the output to the client, so you don't need to add extra code to convert the entity to output. It also gives you the possibility of accessing the entity on onReceived(entity, context) callback. You will get more details about these functions in the following sections.

read(..) → ReadRouteInstance

Adds a route to read and stream (listen) data.

Generic type parameter for TypeScript: read<ENTITY, LOCALS>(..)

Adding the route:

const readAllProductsRouteInstance = server.addRoute.forAllUsers.read<ProductEntity>({ // choose between "forAllUsers" and "forAuthenticatedUsers"
    route: "product/all",
    handleRead: async context => {
        const entityList = productsRepository.readList(context.params != null ? context.params['search'] : null);
        context.successCallback(entityList);
    },
    // convert the entity to the data the client will receive with toOutput(..)
    toOutput: (entityList) => ProductModel.fromEntityList(entityList).output(),
    onReceived: (entity, context) => { console.log("client received output successfully "); },
    onClientStartsListening: (context)  => { console.log("client started listening to [READ] \"product/all\""); },
    onClientStopsListening:  (context)  => { console.log("client stopped listening to [READ] \"product/all\""); }
});

🔸 route: string

The route name.

🔸 toOutput(entity): any

Convert the entity to the output the client will receive.

This function will also be called automatically by Askless every time you trigger notifyChanges(..).

🔸 onReceived (entity, context): void

A listener that is triggered every time the client receives output (optional).

This function will also be called automatically by Askless every time you trigger notifyChanges(..).

🔸 onClientStartsListening(context) : void

A callback that is triggered when a client starts listening to this route.

🔸 onClientStopsListening(context) : void

A callback that is triggered when a client stops listening to this route.

🔸 handleRead(context): void

Implement the handler to read and stream (listen) data.

This function will also be called automatically by Askless every time you trigger notifyChanges(..).

You should either context.successCallback(...) or context.errorCallback(...) to finish the request.

🔸 context fields for read / listen

🔸 🔹 params → object;

Additional data, it's useful in case you want to filter data.

🔸 🔹 locals → LOCALS,

An object where you can add custom data that is valid only for the context of the current request.

🔸 🔹 userId → string | number | undefined

The user ID is performing the request. Only in case the user is authenticated, otherwise is undefined.

🔸 🔹 claims → string[] | undefined

The claims the user is performing the request has. Only in case the user is authenticated, otherwise is undefined.

Example: ["admin"]

🔸 🔹 successCallback: (entity) → void;

Call successCallback(entity) when the request is handled successfully.

Do not pass the output as parameter, use the entity of your server instead.

🔸 🔹 🔸 entity

The response data BEFORE converting it to the output.

🔸 🔹 errorCallback: (errorParams?:AsklessErrorParams) → void;

Call errorCallback(..) to reject the request by sending an error.

context.errorCallback({
    code: "PERMISSION_DENIED",
    description: "Only authenticated users can read/listen to this route"
});
🔸 🔹 🔸 errorParams

Error details object

🔸 🔹 🔸 🔹 code: string

Code of the error and also set a custom error code.

🔸 🔹 🔸 🔹 description: string

Description of the error.

ReadRouteInstance

Read instance.

🔹 notifyChanges(...) → void

Call notifyChanges whenever you want to notify the clients the output have changed.

🔹 stopListening (userId) → void

Call stopListening when you want to make a user stop listening to a route

Letting the App know the route has changed (for stream):

Option 1: Making all users read again

If no parameters are added, your handleRead(..) implementation will be triggered for every user in the App:

readAllProductsRouteInstance.notifyChanges();

Option 2: Filtering who will receive changes by checking the context

If where(..) parameter is defined, handleRead(..) implementation will be triggered for only the user(s) in the App that fulfills the condition, e.g.:

readAllProductsRouteInstance.notifyChanges({
    where: (context) => {
        return context.userId == 1; // only user 1 will receive the latest changes from `handleRead(..)` 
    },
});

Option 3: Overriding handleRead(..) implementation

If handleReadOverride(context) parameter is defined, handleReadOverride(context) will take the place of handleRead(context), his is useful when you want to avoid making too many operations in the database.

⚠️ If params are specified, remember to handle those again

this.readAllProductsRouteInstance.notifyChanges({
    handleReadOverride: context => {
        if (context.params && context.params['search']) {
            const search = context.params['search'].toString().trim().toLowerCase();
            const matchedProductsEntiesCache = allProductsEntiesCache
                .filter((product => product.name.trim().toLowerCase().includes(search) || product.price.toString().trim().toLowerCase().includes(search)));
            context.successCallback(matchedProductsEntiesCache);
        } else {
            context.successCallback(allProductsEntiesCache);
        }
    },
    where: (context) => {
        return context.userId == 1;
    },
})

Please notice that we are still using entities rather than outputs as parameters of successCallback(entity).

In a real scenario it would be better to have a function that holds the logic of handling context.params for both handleRead and handleReadOverride.

create(..) → void

Adds a new route to create data.

Generic type parameter for TypeScript: create<ENTITY, LOCALS>(..)

Adding the route:

server.addRoute.forAuthenticatedUsers.create({ // choose between "forAllUsers" and "forAuthenticatedUsers"
    route: "product",
    handleCreate: async context => {
        try {
            if(context.userId == undefined){
                context.errorCallback({ code: AsklessErrorCode.PERMISSION_DENIED,  description: 'Only logged users can create', });
                return;
            }
            // converting the input from the App to an entity
            // if there's a date in milliseconds, here we convert it to Date
            let productEntity = {
                name: context.body["name"],
                id: context.body["id"],
                price: context.body["price"],
                expiresAt: new Date(context.body["expiresAtTimestamp"])
            };

            // saving the entity in the database
            productEntity = await productsRepository.save(productEntity);

            // in case of success, calling successCallback(..) by passing the entity as parameter
            context.successCallback(productEntity);
        } catch (e) {
            console.error("An unknown error occurred", e.toString());
            context.errorCallback({ description: "An unknown error occurred", code: AsklessErrorCode.INTERNAL_ERROR });
        }
    },
    // converting the entity to the data the client will receive with toOutput(..)
    toOutput: (productEntity) => {
        return {
            "id": productEntity.id,
            "name": productEntity.name,
            "price": productEntity.price,
            "expiresAtTimestamp": productEntity.expiresAt.getTime()
        }
    },

    // once the App receives the data, onReceived() callback will be called
    onReceived: (entity, context) => { console.log("client received output successfully "); }
})

🔸 route: string

The route name.

🔸 toOutput(entity): any

Convert the entity to the output the client will receive.

🔸 onReceived (entity, context): void

A listener that is triggered every time the client receives output (optional).

🔸 handleCreate(context): void

Implement the handler to create data.

You should either context.successCallback(...) or context.errorCallback(...) to finish the request.

🔸 🔹 context fields for create

🔸 🔹 🔸 params → object;

Additional data.

🔸 🔹 🔸 body → object;

The data input that will be created.

🔸 🔹 🔸 locals → LOCALS,

An object where you can add custom data that is valid only for the context of the current request.

🔸 🔹 🔸 userId → string | number | undefined

The user ID is performing the request. Only in case the user is authenticated, otherwise is undefined.

🔸 🔹 🔸 claims → string[] | undefined

The claims the user is performing the request has. Only in case the user is authenticated, otherwise is undefined.

Example: ["admin"]

🔸 🔹 🔸 successCallback: (entity) → void;

Call successCallback(entity) when the request is handled successfully.

Do not pass the output as parameter, use the entity of your server instead.

🔸 🔹 🔸 entity

The response data BEFORE converting it to the output.

🔸 🔹 errorCallback: (errorParams?:AsklessErrorParams) → void;

Call errorCallback(..) to reject the request by sending an error.

context.errorCallback({
    code: "PERMISSION_DENIED",
    description: "Only authenticated users can create on this route"
});
🔸 🔹 🔸 errorParams

Error details object

🔸 🔹 🔸 🔹 code: string

Code of the error and also set a custom error code.

🔸 🔹 🔸 🔹 description: string

Description of the error.

update(..) → void

Adds a new route to update data.

Generic type parameter for TypeScript: update<ENTITY, LOCALS>(..)

Adding the route:

server.addRoute.forAuthenticatedUsers.update({ // choose between "forAllUsers" and "forAuthenticatedUsers"
    route: "product",
    handleUpdate: async context => {
        try {
            if(context.userId == undefined){
                context.errorCallback({ code: AsklessErrorCode.PERMISSION_DENIED,  description: 'Only logged users can update', });
                return;
            }
            // converting the input from the App to an entity
            // if there's a date in milliseconds, here we convert it to Date
            let productPartialEntity:ProductEntity = {
                name: context.body["name"] ?? undefined,
                price: context.body["price"] ?? undefined,
                expiresAt: context.body["expiresAtTimestamp"] != null
                    ? new Date(context.body["expiresAtTimestamp"])
                    : undefined
            };

            // Removing undefined entries
            Object.keys(productPartialEntity).forEach(key => productPartialEntity[key] === undefined ? delete productPartialEntity[key] : {});

            // saving the entity in the database
            const productEntity = await productsRepository.update(context.body["id"], productPartialEntity);

            // in case of success, calling successCallback(..) by passing the entity as parameter
            context.successCallback(productEntity);
        } catch (e) {
            console.error("An unknown error occurred", e.toString());
            context.errorCallback({ description: "An unknown error occurred", code: AsklessErrorCode.INTERNAL_ERROR });
        }
    },
    // converting the entity to the data the client will receive with toOutput(..)
    toOutput: (productEntity) => {
        return {
            "id": productEntity.id,
            "name": productEntity.name,
            "price": productEntity.price,
            "expiresAtTimestamp": productEntity.expiresAt.getTime()
        }
    },

    // once the App receives the data, onReceived(entity, context) callback will be called
    onReceived: (entity, context) => { console.log("client received output successfully "); }
});

🔸 route: string

The route name.

🔸 toOutput(entity): any

Convert the entity to the output the client will receive.

🔸 onReceived (entity, context): void

A listener that is triggered every time the client receives output (optional).

🔸 handleUpdate(context): void

Implement the handler to update data.

You should either context.successCallback(...) or context.errorCallback(...) to finish the request.

🔸 🔹 context fields for update

🔸 🔹 params → object;

Additional data.

🔸 🔹 body → object;

The data input that will be updated.

🔸 🔹 locals → LOCALS,

An object where you can add custom data that is valid only for the context of the current request.

🔸 🔹 successCallback: (entity) → void;

Call successCallback(entity) when the request is handled successfully.

Do not pass the output as parameter, use the entity of your server instead.

🔸 🔹 🔸 entity

The response data BEFORE converting it to the output.

🔸 🔹 errorCallback: (errorParams?:AsklessErrorParams) → void;

Call errorCallback(..) to reject the request by sending an error.

context.errorCallback({
    code: "PERMISSION_DENIED",
    description: "Only authenticated users can update on this route"
});
🔸 🔹 🔸 errorParams

Error details object

🔸 🔹 🔸 🔹 code: string

Code of the error and also set a custom error code.

🔸 🔹 🔸 🔹 description: string

Description of the error.

🔸 🔹 userId → string | number | undefined

The user ID is performing the request. Only in case the user is authenticated, otherwise is undefined.

🔸 🔹 claims → string[] | undefined

The claims the user is performing the request has. Only in case the user is authenticated, otherwise is undefined.

Example: ["admin"]

delete(..) → void

Adds a new route to delete data.

Generic type parameter for TypeScript: delete<ENTITY, LOCALS>(..)

Adding the route:

server.addRoute.forAuthenticatedUsers.delete({ // choose between "forAllUsers" and "forAuthenticatedUsers"
    route: "product",
    handleDelete: async context => {
        try {
            if(context.userId == undefined){
                context.errorCallback({ code: AsklessErrorCode.PERMISSION_DENIED,  description: 'Only logged users can update', });
                return;
            }
            
            // deleting the entity in the database and getting the removed entity
            const productEntity = await productsRepository.delete(context.params["id"]);

            // in case of success, calling successCallback(..) by passing the removed entity as parameter
            context.successCallback(productEntity);
        } catch (e) {
            console.error("An unknown error occurred", e.toString());
            context.errorCallback({ description: "An unknown error occurred", code: AsklessErrorCode.INTERNAL_ERROR });
        }
    },
    // converting the entity to the data the client will receive with toOutput(..)
    toOutput: (productEntity) => {
        return {
            "id": productEntity.id,
            "name": productEntity.name,
            "price": productEntity.price,
            "expiresAtTimestamp": productEntity.expiresAt.getTime()
        }
    },

    // once the App receives the data, onReceived(entity, context) callback will be called
    onReceived: (entity, context) => { console.log("client received output successfully "); }
});

🔸 route: string

The route name.

🔸 toOutput(entity): any

Convert the entity to the output the client will receive.

🔸 onReceived (entity, context): void

A listener that is triggered every time the client receives output (optional).

🔸 handleDelete(context): void

Implement the handler to delete data.

You should either context.successCallback(...) or context.errorCallback(...) to finish the request.

🔸 🔹 context fields for delete

🔸 🔹 params → object;

An object to indicate the data that will be deleted

🔸 🔹 locals → LOCALS,

An object where you can add custom data that is valid only for the context of the current request.

🔸 🔹 successCallback: (entity) → void;

Call successCallback(entity) when the request is handled successfully.

Do not pass the output as parameter, use the entity of your server instead.

🔸 🔹 🔸 entity

The response data BEFORE converting it to the output.

🔸 🔹 errorCallback: (errorParams?:AsklessErrorParams) → void;

Call errorCallback(..) to reject the request by sending an error.

context.errorCallback({
    code: "PERMISSION_DENIED",
    description: "Only authenticated users can delete on this route"
});
🔸 🔹 🔸 errorParams

Error details object

🔸 🔹 🔸 🔹 code: string

Code of the error and also set a custom error code.

🔸 🔹 🔸 🔹 description: string

Description of the error.

🔸 🔹 userId → string | number | undefined

The user ID is performing the request. Only in case the user is authenticated, otherwise is undefined.

🔸 🔹 claims → string[] | undefined

The claims the user is performing the request has. Only in case the user is authenticated, otherwise is undefined.

Example: ["admin"]