-
Notifications
You must be signed in to change notification settings - Fork 28
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
17 changed files
with
928 additions
and
55 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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/)" | ||
|
@@ -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" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
Oops, something went wrong.