Skip to content

Commit

Permalink
Backend: Implement sample API
Browse files Browse the repository at this point in the history
  • Loading branch information
winwiz1 committed Jan 18, 2020
1 parent 86c373a commit 8fde86d
Show file tree
Hide file tree
Showing 17 changed files with 928 additions and 55 deletions.
5 changes: 4 additions & 1 deletion server/.vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,10 @@
],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"port": 9229
"port": 9229,
"env": {
"NODE_ENV": "test"
}
},
]
}
17 changes: 10 additions & 7 deletions server/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "crisp-react-server",
"version": "1.1.2",
"description": "Server for the crisp-react project. Please see ../package.json for full description.",
"name": "crisp-react-backend",
"version": "1.2.0",
"description": "Backend for the Crisp React project",
"author": "winwiz1 <[email protected]> (https://github.com/winwiz1/)",
"contributors": [
"winwiz1 <[email protected]> (https://github.com/winwiz1/)"
Expand Down Expand Up @@ -45,27 +45,30 @@
"express-static-gzip": "^2.0.5",
"helmet": "^3.21.2",
"http-proxy-middleware": "^0.20.0",
"node-cache": "^5.1.0",
"node-fetch": "^2.6.0",
"winston": "3.2.1"
},
"devDependencies": {
"@types/express": "4.17.2",
"@types/helmet": "^0.0.45",
"@types/http-proxy-middleware": "^0.19.3",
"@types/jest": "24.0.23",
"@types/node": "12.12.17",
"@types/jest": "24.0.25",
"@types/node": "13.1.6",
"@types/node-cache": "^4.2.5",
"@types/node-fetch": "2.5.4",
"@types/supertest": "^2.0.8",
"@types/winston": "^2.4.4",
"copyfiles": "^2.1.1",
"cross-env": "^6.0.3",
"echo-cli": "^1.0.8",
"jest": "24.9.0",
"mkdirp": "^0.5.1",
"rimraf": "^3.0.0",
"supertest": "^4.0.2",
"ts-jest": "24.2.0",
"ts-jest": "24.3.0",
"tslib": "1.10.0",
"tslint": "5.20.1",
"typescript": "3.7.3"
"typescript": "3.7.4"
}
}
88 changes: 88 additions & 0 deletions server/src/api/controllers/SampleController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/*
The class SampleController adds and handles Express API route.
*/
import * as express from "express";
import { SampleModel, SampleModelConfig } from "../models/SampleModel";
import {
SampleRequest,
SampleRetrievalResult, JsonParsingError
} from "../types/SampleTypes";
import {
CustomError,
isError,
isCustomError,
} from "../../utils/error";

const jsonParser = express.json({
inflate: true,
limit: "1kb",
strict: true,
type: "application/json"
});

/*
API route handler
*/
export class SampleController {
static readonly addRoute = (app: express.Application): void => {
app.post(SampleRequest.Path,
jsonParser,
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
try {
// Once-off configuration
if (!SampleController.s_configSet) {
const config = new SampleModelConfig();
SampleModel.Config = config;
SampleController.s_configSet = true;
}

// Request related error handling
const errInfo: JsonParsingError = { message: undefined };
const sampleRequest = SampleRequest.fromJson(req.body, req.ip, errInfo);
if (!sampleRequest) {
const err = new CustomError(400, SampleController.s_ErrMsgParams, true);
err.unobscuredMessage = `Invalid request from ${req.ip} with hostname ${req.hostname} using path ${req.originalUrl}. `;
!!errInfo.message && (err.unobscuredMessage += errInfo.message);
return next(err);
}

// Ask the static factory to create an instance of the model
// and use it to get the data
const model = SampleModel.Factory;
await model.fetch(sampleRequest);
const data = model.Data;

// Response related error handling
if (data instanceof Error) {
if (isCustomError(data)) {
return next(data as CustomError);
}
const error = new CustomError(500, SampleController.s_ErrMsgSample, true, true);
// Can only be set to "<no data>" if code is incorrectly modified
error.unobscuredMessage = (data as Error).message ?? "<no data>";
return next(error);
} else {
res.status(200).json(data as SampleRetrievalResult);
}
} catch (err) {
if (isCustomError(err)) {
return next(err);
}
const error = new CustomError(500, SampleController.s_ErrMsgSample, true, true);
const errMsg: string = isError(err) ? err.message : (
"Exception: <" +
Object.keys(err).map((key) => `${key}: ${err[key] ?? "no data"}`).join("\n") +
">"
);
error.unobscuredMessage = errMsg;
return next(error);
}
});
}

/********************** private data ************************/

private static s_configSet = false;
private static readonly s_ErrMsgSample = "Could not query Sample API. Please retry later. If the problem persists contact Support";
private static readonly s_ErrMsgParams = "Invalid data retrieval parameter(s). Please notify Support";
}
234 changes: 234 additions & 0 deletions server/src/api/models/SampleModel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
/*
The Model.
Communicates with cloud service.
Performs asynchronous API requests.
*/
import nodeFetch from "node-fetch";
import * as NodeCache from "node-cache";
import { logger } from "../../utils/logger";
import { CustomError } from "../../utils/error";
import {
SampleRetrievalData,
ISampleData,
SampleRequest,
SampleRetrieval,
SampleRetrievalResult
} from "../types/SampleTypes";

/*
Model configuration.
*/
export class SampleModelConfig {
constructor(
// Daily limit on API requests per client address
private limitDailyClient = SampleModelConfig.s_limitDailyClient,
// Daily limit on API requests per backend instance
private limitDailyInstance = SampleModelConfig.s_limitDailyInstance
) {

if (limitDailyClient <= 0 || limitDailyClient > SampleModelConfig.s_limitDailyClient) {
throw new RangeError("Client API call limit is invalid");
}

if (limitDailyInstance <= 0 || limitDailyInstance > SampleModelConfig.s_limitDailyInstance) {
throw new RangeError("Instance API call limit is invalid");
}
}

public readonly setClientDailyLiImit = (limit: number) => {
if (typeof limit !== "number" || !Number.isInteger(limit)) {
throw new TypeError("Client API call limit is not an integer");
}

if (limit <= 0 || limit > SampleModelConfig.s_limitDailyClient) {
throw new RangeError("Client API call limit is invalid");
}

this.limitDailyClient = limit;
}

public readonly getClientDailyLimit = (): number => {
return this.limitDailyClient;
}

public readonly getInstanceDailyLimit = (): number => {
return this.limitDailyInstance;
}

// Default daily API call limit per client address
private static readonly s_limitDailyClient = 10;
// Default daily API call limit per backend instance
private static readonly s_limitDailyInstance = 1000;

}

/*
Model interface.
Extends the data storage interface by adding data fetching capability.
*/
export interface ISampleFetcher extends ISampleData {
readonly fetch: (param: SampleRequest) => Promise<void>;
}

/*
Model implementation.
Usage:
1. Use .Config setter once to set the configuration.
2. Use .Factory getter one or many times to get an instance of the class.
3. Use the instance of the class to await .fetch().
4. Use .Data getter to get either the data fetched or an Error object.
See SampleModel.test.ts for an example.
*/
export class SampleModel implements ISampleFetcher {

static set Config(config: SampleModelConfig) {
SampleModel.s_config = config;
SampleModel.s_instance = undefined;
}

static get Factory(): SampleModel {
if (!SampleModel.s_instance) {
SampleModel.s_instance = new SampleModel();
}
return SampleModel.s_instance;
}

public async fetch(quoteRequest: SampleRequest): Promise<void> {
this.m_request = quoteRequest;

const dataUsage = this.getDataUsage(quoteRequest.ClientAddress);
// Check data usage per client
if (dataUsage.client_data >= SampleModel.s_config!.getClientDailyLimit()) {
const custErr = new CustomError(509, SampleModel.s_errLimitClient, false, false);
custErr.unobscuredMessage = `Client ${quoteRequest.ClientAddress} has reached daily limit`;
this.m_result = custErr;
return;
}
// Check data usage by the backend instance
if (dataUsage.instance_data >= SampleModel.s_config!.getInstanceDailyLimit()) {
const custErr = new CustomError(509, SampleModel.s_errLimitInstance, false, false);
custErr.unobscuredMessage = `Client ${quoteRequest.ClientAddress} request denied due to backend reaching its daily limit`;
this.m_result = custErr;
return;
}

await this.fetchData();
}

get Data(): SampleRetrieval {
return this.m_result;
}

public getData(): SampleRetrieval {
return this.m_result;
}

/********************** private methods and data ************************/

private constructor() {
if (!SampleModel.s_config) {
throw new Error("SampleModelConfig is undefined");
}
this.m_cache.on("expired", this.handleCacheExpiry);
}

private async fetchData(): Promise<void> {
const request = this.m_request as SampleRequest;

try {
const url = "https://api.genderize.io/?name=" + request.Name;
const response = await nodeFetch(url);
const data: SampleRetrievalData = await response.json();

this.m_result = new SampleRetrievalResult(data);
this.adjustDataUsage(request.ClientAddress);
} catch (err) {
const errorMsg = err instanceof Error ? err.message : (
"Exception: <" +
Object.keys(err).map((key) => `${key}: ${err[key] ?? "no data"}`).join("\n") +
">"
);
logger.error({ message: `API server call failed, error: ${errorMsg}` });
this.m_result = new Error(SampleModel.s_errMsg);
}
}

private handleCacheExpiry = (cache_key: string, _value: any) => {
if (!cache_key) {
return;
}

logger.info({ msg: `Cache key ${cache_key} has expired`});
}

// TODO Use durable cache
private getDataUsage(clientAddress: string):
{
// Client address
client_key: string,
// Count of API calls made by the client
client_data: number,
// Count of API calls made by the backend instanc
instance_data: number
} {
if (!clientAddress) {
const errMsg = "SampleModel.getDataUsage - missing clientAddress";
logger.error({ message: errMsg });
throw new Error(errMsg);
}

const clientKey = SampleModel.s_limitPrefix + clientAddress;
const cacheData = this.m_cache.mget([clientKey, SampleModel.s_limitInstance]);
const clientData = typeof cacheData[clientKey] === "number" ?
cacheData[clientKey] as number : 0;
const instanceData = typeof cacheData[SampleModel.s_limitInstance] === "number" ?
cacheData[SampleModel.s_limitInstance] as number : 0;
return { client_key: clientKey, client_data: clientData, instance_data: instanceData };
}

// TODO Use durable cache
private adjustDataUsage(clientAddress: string, usageCount: number = 1) {
if (usageCount === 0) {
return;
}

const { client_key, client_data, instance_data } = this.getDataUsage(clientAddress);

const ret = this.m_cache.mset([
{ key: client_key,
ttl: SampleModel.s_limitCleanupInterval,
val: client_data + usageCount,
},
{ key: SampleModel.s_limitInstance,
ttl: SampleModel.s_limitCleanupInterval,
val: instance_data + usageCount,
}
]);

if (!ret) {
const errMsg = "Failed to store API call counts in the cache";
logger.error({ message: errMsg });
throw new Error(errMsg);
}
}

private m_request?: SampleRequest = undefined;
private m_result: SampleRetrieval = new Error("API server call not attempted");

private readonly m_cache = new NodeCache({
checkperiod: 900,
deleteOnExpire: true,
stdTTL: SampleModel.s_limitCleanupInterval,
useClones: false
});

private static s_instance?: SampleModel = undefined;
private static s_config?: SampleModelConfig = undefined;
private static readonly s_errMsg = "Failed to query the API server. Please retry later. If the problem persists contact Support";
private static readonly s_errLimitClient = "The daily API call limit has been reached. Please contact Support if you feel this limit is inadequate.";
private static readonly s_errLimitInstance = "Temporary unable to call the API server. Please contact Support.";
private static readonly s_limitPrefix = "apilimit_";
private static readonly s_limitInstance = "apilimit_instance";
private static readonly s_limitCleanupInterval = 3600 * 24;
}
Loading

0 comments on commit 8fde86d

Please sign in to comment.