@ssecd/jkn
Version:
JKN (BPJS) Bridging API untuk NodeJS
239 lines (238 loc) • 9.51 kB
JavaScript
import lz from 'lz-string';
import { createDecipheriv, createHash, createHmac } from 'node:crypto';
const defaultBaseUrls = {
aplicares: {
development: 'https://dvlp.bpjs-kesehatan.go.id:8888/aplicaresws',
production: 'https://new-api.bpjs-kesehatan.go.id/aplicaresws'
},
vclaim: {
development: 'https://apijkn-dev.bpjs-kesehatan.go.id/vclaim-rest-dev',
production: 'https://apijkn.bpjs-kesehatan.go.id/vclaim-rest'
},
antrean: {
development: 'https://apijkn-dev.bpjs-kesehatan.go.id/antreanrs_dev',
production: 'https://apijkn.bpjs-kesehatan.go.id/antreanrs'
},
apotek: {
development: 'https://apijkn-dev.bpjs-kesehatan.go.id/apotek-rest-dev',
production: 'https://apijkn.bpjs-kesehatan.go.id/apotek-rest'
},
pcare: {
development: 'https://apijkn-dev.bpjs-kesehatan.go.id/pcare-rest-dev',
production: 'https://apijkn.bpjs-kesehatan.go.id/pcare-rest'
},
icare: {
development: 'https://apijkn-dev.bpjs-kesehatan.go.id/ihs_dev',
production: 'https://apijkn.bpjs-kesehatan.go.id/wsihs'
},
rekamMedis: {
development: 'https://apijkn-dev.bpjs-kesehatan.go.id/erekammedis_dev',
production: 'https://apijkn.bpjs-kesehatan.go.id/medicalrecord'
}
};
export class Fetcher {
userConfig;
// simply using custom event function instead of node:EventEmitter
onRequest = undefined;
onResponse = undefined;
onError = undefined;
configured = false;
config = {
mode: process.env.NODE_ENV !== 'production' ? 'development' : process.env.NODE_ENV,
ppkCode: process.env.JKN_PPK_CODE ?? '',
consId: process.env.JKN_CONS_ID ?? '',
consSecret: process.env.JKN_CONS_SECRET ?? '',
aplicaresUserKey: process.env.JKN_APLICARES_USER_KEY,
vclaimUserKey: process.env.JKN_VCLAIM_USER_KEY ?? '',
antreanUserKey: process.env.JKN_ANTREAN_USER_KEY ?? '',
apotekUserKey: process.env.JKN_APOTEK_USER_KEY,
pcareUserKey: process.env.JKN_PCARE_USER_KEY ?? '',
icareUserKey: process.env.JKN_ICARE_USER_KEY,
rekamMedisUserKey: process.env.JKN_REKAM_MEDIS_USER_KEY,
throw: false,
baseUrls: defaultBaseUrls
};
constructor(userConfig) {
this.userConfig = userConfig;
}
async applyConfig() {
if (this.configured)
return;
if (typeof this.userConfig === 'object') {
this.config = this.mergeConfig(this.config, this.userConfig);
}
else if (typeof this.userConfig === 'function') {
const userConfig = await this.userConfig();
this.config = this.mergeConfig(this.config, userConfig);
}
if (!this.config.consId || !this.config.consSecret) {
throw new Error(`cons id and secret are not defined`);
}
// fallback to vclaimUserKey
for (const key of [
'aplicaresUserKey',
'apotekUserKey',
'icareUserKey',
'rekamMedisUserKey'
]) {
if (this.config[key])
continue;
this.config[key] = this.config.vclaimUserKey;
}
this.configured = true;
}
mergeConfig(target, source) {
// simple object merge strategy because only baseUrls is typeof object for now
const baseUrls = { ...target.baseUrls, ...source.baseUrls };
return { ...target, ...source, baseUrls };
}
get userKeyMap() {
return {
aplicares: this.config.aplicaresUserKey,
vclaim: this.config.vclaimUserKey,
antrean: this.config.antreanUserKey,
apotek: this.config.apotekUserKey,
pcare: this.config.pcareUserKey,
icare: this.config.icareUserKey,
rekamMedis: this.config.rekamMedisUserKey
};
}
getDefaultHeaders(type) {
const userKey = this.userKeyMap[type];
if (!userKey)
throw new Error(`failed to get user key of type "${type}"`);
const { consId, consSecret } = this.config;
const timestamp = Math.round(Date.now() / 1000);
const message = `${consId}&${timestamp}`;
const signature = createHmac('SHA256', consSecret).update(message).digest('base64');
return {
'X-cons-id': consId,
'X-timestamp': String(timestamp),
'X-signature': encodeURI(signature),
user_key: userKey
};
}
decrypt(responseText, requestTimestamp) {
const { consId, consSecret } = this.config;
const keyPlain = `${consId}${consSecret}${requestTimestamp}`;
const key = createHash('sha256').update(keyPlain, 'utf8').digest();
const iv = Uint8Array.from(key.subarray(0, 16));
const decipher = createDecipheriv('aes-256-cbc', key, iv);
let dec = decipher.update(responseText, 'base64', 'utf8');
dec += decipher.final('utf8');
return dec;
}
decompress(text) {
return lz.decompressFromEncodedURIComponent(text);
}
async send(type, option) {
await this.applyConfig();
const path = normalizePath(option.path);
const baseUrl = this.config.baseUrls[type];
if (!baseUrl)
throw new Error(`Invalid base URL for type "${type}"`);
let result = '';
try {
const url = new URL(baseUrl[this.config.mode] + path);
const init = { method: option.method ?? 'GET' };
const headers = { ...this.getDefaultHeaders(type), ...(option.headers ?? {}) };
init.headers = headers;
if (option.data) {
if (option.method === 'GET')
throw new Error(`Can not pass data with "GET" method`);
init.body = JSON.stringify(option.data);
// default fetch content type in request header is json
if (!option.skipContentTypeHack) {
init.headers = {
...init.headers,
/**
* The "Content-Type" is actually invalid because the body is in json format,
* but it simply adheres to the JKN doc or TrustMark. What a weird API.
*/
'Content-Type': 'Application/x-www-form-urlencoded'
};
}
}
this.onRequest?.({ ...option, type });
const startedAt = performance.now();
const response = await fetch(url, init);
result = await response.text();
if (!result)
throw new Error(`The response body is empty (${response.status})`);
const json = JSON.parse(result);
if (json.response && !option.skipDecrypt) {
const decrypted = this.decrypt(String(json.response), headers['X-timestamp']);
json.response = JSON.parse(this.decompress(decrypted));
}
const duration = performance.now() - startedAt;
this.onResponse?.({ ...option, duration, type }, json);
return json;
}
catch (error) {
const customError = new Error(error instanceof SyntaxError
? `The response is not JSON (${parseHtml(result)})`
: error instanceof Error
? error.message
: JSON.stringify(error), { cause: error });
this.onError?.(customError);
if (this.config.throw)
throw customError;
console.error(customError);
// TODO: find better way to infer generic response type
const code = type === 'icare' ? 500 : '500';
const message = `An error occurred: "${customError.message}"`;
return {
metadata: { code: +code, message },
metaData: { code, message },
response: undefined
};
}
}
async invalidateConfig() {
this.configured = false;
await this.applyConfig();
}
async getConfig() {
await this.applyConfig();
return this.config;
}
}
/**
* A simple HTML parser so ugly HTML error messages
* don't hurt your eyes anymore.
*/
function parseHtml(html) {
if (!html)
return '[empty]';
return html
.replace(/<head\b[^>]*>[\s\S]*?<\/head>/gi, '')
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<[^>]*>/g, '')
.trim()
.replace(/\r?\n+/g, ' - ') // newlines to dash
.replace(/\s+/g, ' '); // normalize whitespace
}
/** @internal */
export function normalizePath(path) {
const [pathname, params] = typeof path == 'string' ? [path] : path;
if (!pathname.startsWith('/'))
throw new Error(`Path must start with "/"`);
if (!params)
return pathname;
return pathname.replace(/\/:([A-Za-z0-9_]+)(\?)?/g, (_, key, optional) => {
const value = params[key];
if (value == null) {
if (optional)
return ''; // remove entire `/:param?`
throw new Error(`Missing params: ${key}`);
}
const component = String(value);
try {
return '/' + encodeURIComponent(decodeURIComponent(component));
}
catch {
return '/' + encodeURIComponent(component);
}
});
}