resource-watch/rw-api-microservice-node

View on GitHub
src/main.ts

Summary

Maintainability
F
3 days
Test Coverage
B
89%
// @ts-ignore
import Fastly from '@tiagojsag/fastly-promises';
import axios, { AxiosRequestConfig, AxiosResponse } from "axios";
import type { Context, Middleware, Next, Request } from "koa";
import cors from "@koa/cors";
import compose from "koa-compose";
import type corsType from "@koa/cors";
import type Logger from "bunyan";
import { ResponseError } from "./errors/response.error";
import { ApiKeyError } from "./errors/apiKey.error";
import CloudWatchService from "./cloudwatch.service";
import {
    BootstrapArguments,
    ConfigurationOptions,
    IRWAPIMicroservice, MicroserviceValidationResponse,
    RequestToMicroserviceOptions,
    RequestValidationResponse, UserValidationResponse
} from "./types";

class Microservice implements IRWAPIMicroservice {
    public options: ConfigurationOptions;

    private cloudWatchService: CloudWatchService;

    private static convertAndValidateBootstrapOptions(options: BootstrapArguments): ConfigurationOptions {
        if (
            options.fastlyEnabled !== true
            && options.fastlyEnabled !== false
            && options.fastlyEnabled !== "true"
            && options.fastlyEnabled !== "false"
        ) {
            throw new Error('RW API microservice - "fastlyEnabled" needs to be a boolean');
        }

        if (
            options.hasOwnProperty('requireAPIKey')
            && options.requireAPIKey !== true
            && options.requireAPIKey !== false
            && options.requireAPIKey !== "true"
            && options.requireAPIKey !== "false"
        ) {
            throw new Error('RW API microservice - "requireAPIKey" needs to be a boolean');
        }

        if (
            typeof options.awsCloudWatchLoggingEnabled !== 'undefined'
            && options.awsCloudWatchLoggingEnabled !== true
            && options.awsCloudWatchLoggingEnabled !== false
            && options.awsCloudWatchLoggingEnabled !== "true"
            && options.awsCloudWatchLoggingEnabled !== "false"
        ) {
            throw new Error('RW API microservice - "awsCloudWatchLoggingEnabled" needs to be a boolean');
        }

        const convertedOptions: ConfigurationOptions = {
            ...options,
            awsCloudWatchLoggingEnabled: ('awsCloudWatchLoggingEnabled' in options) ? (options.awsCloudWatchLoggingEnabled === true || options.awsCloudWatchLoggingEnabled === "true") : true,
            awsCloudWatchLogGroupName: (options.awsCloudWatchLogGroupName || 'api-keys-usage').replace(/ /g, "_"),
            awsCloudWatchLogStreamName: options.awsCloudWatchLogStreamName.replace(/ /g, "_"),
            fastlyEnabled: (options.fastlyEnabled === true || options.fastlyEnabled === "true"),
            requireAPIKey: !(options.requireAPIKey === false || options.requireAPIKey === "false"),
            skipAPIKeyRequirementEndpoints: (options.skipAPIKeyRequirementEndpoints || []).concat(
                [{
                    method: 'GET',
                    pathRegex: '/healthcheck'
                }, {
                    method: 'POST',
                    pathRegex: '/api/v1/request/validate'
                }]),
        };

        if (!convertedOptions.logger) {
            throw new Error('RW API microservice - "logger" cannot be empty');
        }
        if (!convertedOptions.gatewayURL) {
            throw new Error('RW API microservice - "gatewayURL" cannot be empty');
        }
        if (!convertedOptions.microserviceToken) {
            throw new Error('RW API microservice - "microserviceToken" cannot be empty');
        }
        if (convertedOptions.fastlyEnabled === true) {
            if (!options.fastlyServiceId) {
                throw new Error('RW API microservice - "fastlyServiceId" cannot be empty');
            }
            if (!convertedOptions.fastlyAPIKey) {
                throw new Error('RW API microservice - "fastlyAPIKey" cannot be empty');
            }
        }
        if (convertedOptions.awsCloudWatchLoggingEnabled === true) {
            if (!options.awsRegion) {
                throw new Error('RW API microservice - "awsRegion" cannot be empty');
            }
            if (!convertedOptions.awsCloudWatchLogGroupName) {
                throw new Error('RW API microservice - "awsCloudWatchLogGroupName" cannot be empty');
            }
            if (!convertedOptions.awsCloudWatchLogStreamName) {
                throw new Error('RW API microservice - "awsCloudWatchLogStreamName" cannot be empty');
            }
        }

        return convertedOptions;
    }

    private static async fastlyIntegrationHandler(ctx: Context, fastlyServiceId: string, fastlyAPIKey: string, logger: Logger): Promise<void> {
        const fastly: Fastly = Fastly(fastlyAPIKey, fastlyServiceId);

        if (ctx.status >= 200 && ctx.status < 400) {
            // Non-GET, anonymous requests with the `uncache` header can purge the cache
            if (ctx.request.method !== 'GET' && ctx.response.headers && ctx?.response?.headers?.uncache) {
                let tags: string[];
                if (typeof ctx.response.headers.uncache === "string" || typeof ctx.response.headers.uncache === "number") {
                    tags = ctx.response.headers.uncache.toString().split(' ').filter((part: string) => part !== '');
                } else {
                    tags = ctx.response.headers.uncache;
                }
                logger.info('[fastlyIntegrationHandler] Purging cache for tag(s): ', tags.join(' '));
                await fastly.purgeKeys(tags);
            }

            // GET anonymous requests with the `cache` header can be cached
            if (ctx.request.method === 'GET' && !ctx.request.headers?.authorization && ctx.response?.headers?.cache) {
                let keys: number | string | string[];
                if (Array.isArray(ctx.response.headers.cache)) {
                    keys = ctx.response.headers.cache.join(' ');
                } else {
                    keys = ctx.response.headers.cache.toString();
                }
                logger.info('[fastlyIntegrationHandler] Caching with key(s): ', keys);
                ctx.set('Surrogate-Key', keys);
            } else {
                ctx.set('Cache-Control', 'private');
            }
        }
    }

    private async getRequestValidationData(logger: Logger, baseURL: string, request: Request): Promise<RequestValidationResponse> {
        logger.debug('[getLoggedUser] Validating request');
        if (!request.header.authorization) {
            logger.debug('[getLoggedUser] No authorization header found');
        }

        try {
            const body: Record<string, any> = {};

            if (request.header.authorization) {
                body.userToken = request.header.authorization;
            }

            if (request.header["x-api-key"]) {
                body.apiKey = request.header["x-api-key"];
            }

            if (Object.keys(body).length > 0) {
                const getUserDetailsRequestConfig: AxiosRequestConfig = {
                    method: 'POST',
                    baseURL,
                    url: `/v1/request/validate`,
                    headers: {
                        'authorization': `Bearer ${this.options.microserviceToken}`
                    },
                    data: body
                };
                const response: AxiosResponse<Record<string, any>> = await axios(getUserDetailsRequestConfig);

                logger.debug('[getLoggedUser] Retrieved microserviceToken data, response status:', response.status);

                const validationResponse: RequestValidationResponse = response.data as RequestValidationResponse;

                if (this.options.requireAPIKey && !validationResponse.application && validationResponse.user?.data?.id !== 'microservice') {
                    throw new ApiKeyError(403, 'Required API key not found');
                }

                return validationResponse;
            } else {
                if (this.options.requireAPIKey) {
                    throw new ApiKeyError(403, 'Required API key not found');
                }
                return {};
            }


        } catch (err) {
            this.options.logger.error('Error getting user data', err);
            if (err?.response?.data) {
                throw new ResponseError(err.response.status, err.response.data, err.response);
            }
            throw err;
        }
    }

    private async injectRequestValidationData(logger: Logger, requestValidationData: RequestValidationResponse, ctx: Context): Promise<void> {
        logger.debug('[injectRequestValidationData] Obtaining loggedUser for microserviceToken');
        if (requestValidationData.application) {
            ctx.state.requestApplication = requestValidationData.application
        }
        if (['GET', 'DELETE'].includes(ctx.request.method.toUpperCase())) {
            if (requestValidationData.user && requestValidationData.user.data) {
                ctx.request.query = { ...ctx.request.query, loggedUser: JSON.stringify(requestValidationData.user.data) };
            }

        } else if (['POST', 'PATCH', 'PUT'].includes(ctx.request.method.toUpperCase())) {
            if (requestValidationData.user && requestValidationData.user.data) {
                // @ts-ignore
                ctx.request.body.loggedUser = requestValidationData.user.data;
            }
        }
    }

    private async logRequestToCloudWatch(logger: Logger, request: Request, requestValidationData: RequestValidationResponse): Promise<void> {
        logger.debug('[logRequestToCloudWatch] Logging request to CloudWatch');

        const logQuery: Record<string, any> = { ...request.query };
        delete logQuery.loggedUser;
        const logContent: Record<string, any> = {
            request: {
                method: request.method,
                path: request.path,
                query: logQuery,
            }
        };
        if (requestValidationData.user && requestValidationData.user.data) {
            if (requestValidationData.user.data.id === 'microservice') {
                logContent.loggedUser = {
                    id: (requestValidationData.user.data as MicroserviceValidationResponse).id,
                };
            } else {
                logContent.loggedUser = {
                    id: (requestValidationData.user.data as UserValidationResponse).id,
                    name: (requestValidationData.user.data as UserValidationResponse).name,
                    role: (requestValidationData.user.data as UserValidationResponse).role,
                    provider: (requestValidationData.user.data as UserValidationResponse).provider
                };
            }
        } else {
            logContent.loggedUser = {
                id: 'anonymous',
                name: 'anonymous',
                role: 'anonymous',
                provider: 'anonymous'
            };
        }
        if (requestValidationData.application) {
            logContent.requestApplication = {...requestValidationData.application.data, ...requestValidationData.application.data.attributes};
            delete logContent.requestApplication.attributes;
            delete logContent.requestApplication.type;
            delete logContent.requestApplication.createdAt;
            delete logContent.requestApplication.updatedAt;
        } else {
            logContent.requestApplication = {
                id: 'anonymous',
                name: 'anonymous',
                organization: null,
                user: null,
                apiKeyValue: null,
            };
        }

        await this.cloudWatchService.logToCloudWatch(JSON.stringify(logContent));
    }

    private shouldSkipAPIKeyValidation(ctx: Context): boolean {
        for (const skipAPIKeyRequirementEndpoint of this.options.skipAPIKeyRequirementEndpoints) {
            if (ctx.request.path.match(skipAPIKeyRequirementEndpoint.pathRegex) && ctx.request.method === skipAPIKeyRequirementEndpoint.method) {
                return true;
            }
        }
        return false;
    }

    public bootstrap(opts: BootstrapArguments): Middleware<{}, {}> {
        this.options = Microservice.convertAndValidateBootstrapOptions(opts);
        this.options.logger.info('RW API integration middleware registered');
        if (this.options.awsCloudWatchLoggingEnabled) {
            this.cloudWatchService = CloudWatchService.init(this.options.logger, this.options.awsRegion, this.options.awsCloudWatchLogGroupName, this.options.awsCloudWatchLogStreamName);
        }

        const corsOptions: corsType.Options = {
            credentials: true,
            allowHeaders: 'upgrade-insecure-requests'
        };

        const bootstrapMiddleware: Middleware = async (ctx: Context, next: Next) => {
            const { logger, gatewayURL } = this.options;

            if (!this.shouldSkipAPIKeyValidation(ctx)) {
                try {
                    const requestValidationData: RequestValidationResponse = await this.getRequestValidationData(logger, gatewayURL, ctx.request);
                    await this.injectRequestValidationData(logger, requestValidationData, ctx);
                    if (this.options.awsCloudWatchLoggingEnabled) {
                        await this.logRequestToCloudWatch(logger, ctx.request, requestValidationData);
                    }
                } catch (getLoggedUserError) {
                    if (getLoggedUserError instanceof ResponseError) {
                        ctx.response.status = (getLoggedUserError as ResponseError).statusCode;
                        ctx.response.body = (getLoggedUserError as ResponseError).error;
                        return;
                    } else if (getLoggedUserError instanceof ApiKeyError) {
                        ctx.response.status = (getLoggedUserError as ResponseError).statusCode;
                        ctx.response.body = {
                            errors: [{
                                status: 401,
                                detail: (getLoggedUserError as ResponseError).message
                            }]
                        };
                        return;
                    } else {
                        ctx.throw(500, `Error loading user info from token - ${getLoggedUserError.toString()}`);
                    }
                }
            }
            await next();

            if (this.options.fastlyEnabled) {
                await Microservice.fastlyIntegrationHandler(ctx, this.options.fastlyServiceId, this.options.fastlyAPIKey, logger);
            }

        };

        return compose([cors(corsOptions), bootstrapMiddleware]);
    }

    public async requestToMicroservice(requestConfig: RequestToMicroserviceOptions): Promise<Record<string, any>> {
        this.options.logger.info('Adding authorization header');
        const axiosRequestConfig: AxiosRequestConfig = {
            baseURL: this.options.gatewayURL,
            data: requestConfig.body,
            // @ts-ignore
            method: requestConfig.method,
            params: requestConfig.params
        };

        try {
            axiosRequestConfig.url = requestConfig.uri.toString();

            axiosRequestConfig.headers = Object.assign(requestConfig.headers || {}, { authorization: `Bearer ${this.options.microserviceToken}` });
            if (requestConfig.application) {
                axiosRequestConfig.headers.app_key = JSON.stringify({ application: requestConfig.application });
            }

            const response: AxiosResponse<Record<string, any>> = await axios(axiosRequestConfig);

            if (requestConfig.resolveWithFullResponse === true) {
                return {
                    statusCode: response.status,
                    body: response.data
                };
            }
            return response.data;
        } catch (err) {
            this.options.logger.info('Error doing request', err);
            if (requestConfig.simple === false && err.response.status < 500) {
                if (requestConfig.resolveWithFullResponse === true) {
                    return {
                        statusCode: err.response.status,
                        body: err.response.data
                    };
                }
                return err.response.data;
            }
            if (err?.response?.data) {
                if (requestConfig.resolveWithFullResponse === true) {
                    return {
                        statusCode: err.response.status,
                        body: err.response.data
                    };
                }
                throw new ResponseError(err.response.status, err.response.data, err.response);
            }
            return err;
        }

    }
}

const microservice: Microservice = new Microservice();

export { microservice as RWAPIMicroservice };