UNPKG

@ssecd/jkn

Version:

JKN (BPJS) Bridging API untuk NodeJS

202 lines (201 loc) 8.31 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/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; } }