UNPKG

@ssecd/jkn

Version:

JKN (BPJS) Bridging API untuk NodeJS

239 lines (238 loc) 9.51 kB
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); } }); }