@ssecd/jkn
Version:
JKN (BPJS) Bridging API untuk NodeJS
202 lines (201 loc) • 8.31 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/erekammedis'
}
};
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();
if (!option.path.startsWith('/'))
throw new Error(`path must be starts with "/"`);
let response = '';
try {
const baseUrl = this.config.baseUrls[type];
if (!baseUrl)
throw new Error(`base url of type "${type}" is invalid`);
const url = new URL(baseUrl[this.config.mode] + option.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();
response = await fetch(url, init).then((r) => r.text());
const json = JSON.parse(response);
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) {
this.onError?.(error);
if (this.config.throw) {
if (error instanceof Error) {
error.message += `. \nResponse: ${response}`;
}
throw error;
}
let message = error instanceof SyntaxError
? 'Received response from the JKN API appears to be in an unexpected format'
: 'An error occurred while requesting information from the JKN API';
if (error instanceof Error)
message += `. ` + error.message;
message += '. ' + response;
console.error(error);
// TODO: find better way to infer generic response type
const code = type === 'icare' ? 500 : '500';
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;
}
}