diff --git a/src/app/api-management/api-import.util.ts b/src/app/api-management/api-import.util.ts new file mode 100644 index 000000000..13ab86be7 --- /dev/null +++ b/src/app/api-management/api-import.util.ts @@ -0,0 +1,778 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { ApiMappingRow, KeyValue, kv } from './api-management.service'; + +/** + * Reads what a vendor's documentation or a Postman / browser / cURL capture gives - in any of these shapes - and turns + * it into the pieces of an API: method, URL, path, query parameters, headers, content type, payload, response status, + * response headers, response body, and a first guess at the CBS field mapping. Nothing is sent anywhere. + * + * Understood: + * - a cURL command + * - a Postman collection or a single Postman request / item (with its saved responses) + * - a HAR capture + * - an "API definition" object: { method, url | path, query, headers, body, status, ... } + * - a bare JSON payload (treated as the request body, or the response body on the response side) + * - form text (a=1&b=2) and other raw text + * + * Credentials found in headers or query strings are never kept: the value is replaced by {{secret}} and the row is + * flagged as secret, so a key pasted from a sample cannot end up stored. + */ + +export interface ImportedRequest { + source: string; + method?: string; + /** The full URL when the file had one. */ + url?: string; + /** Scheme and host of the URL, when present. */ + baseUrl?: string; + path?: string; + queryParams: KeyValue[]; + headers: KeyValue[]; + contentType?: string; + body?: string; + /** A response found in the same file (Postman saved response, HAR entry, definition object). */ + response?: ImportedResponse; + notes: string[]; +} + +export interface ImportedResponse { + source: string; + status?: number; + headers: KeyValue[]; + body?: string; + errorBody?: string; + notes: string[]; +} + +const SECRET_HEADERS = + /^(authorization|proxy-authorization|x-api-key|api-key|apikey|authkey|x-auth-token|auth-token|token|x-access-token|cookie|set-cookie|x-hub-signature(-256)?)$/i; +const SECRET_QUERY = /^(key|api[-_]?key|apikey|token|access[-_]?token|auth[-_]?key|secret|password|signature)$/i; +const SECRET = '{{secret}}'; + +// ── small helpers ─────────────────────────────────────────────────────────── + +function isObject(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + +function tryJson(text: string): any { + try { + return JSON.parse(text); + } catch { + return undefined; + } +} + +function prettyJsonOrText(text: string): string { + const parsed = tryJson(text); + return parsed !== undefined && typeof parsed === 'object' ? JSON.stringify(parsed, null, 2) : text; +} + +function bodyToText(body: unknown): string | undefined { + if (body === undefined || body === null) return undefined; + if (typeof body === 'string') return prettyJsonOrText(body); + return JSON.stringify(body, null, 2); +} + +function toKeyValues(source: unknown, kind: 'header' | 'query' | 'plain'): KeyValue[] { + const out: KeyValue[] = []; + const add = (key: unknown, value: unknown, description = '', disabled = false) => { + const k = String(key ?? '').trim(); + if (!k) return; + const secret = (kind === 'header' && SECRET_HEADERS.test(k)) || (kind === 'query' && SECRET_QUERY.test(k)); + out.push({ + key: k, + value: secret ? SECRET : value === undefined || value === null ? '' : String(value), + description, + enabled: !disabled, + secret + }); + }; + if (Array.isArray(source)) { + for (const item of source) { + if (isObject(item)) + add(item.key ?? item.name, item.value, String(item.description ?? ''), item.disabled === true); + } + } else if (isObject(source)) { + for (const [ + k, + v + ] of Object.entries(source)) add(k, v); + } + return out; +} + +/** Splits a URL into its base, path and query without failing on {{placeholders}} or {path} parameters. */ +function splitUrl(raw: string): { baseUrl?: string; path: string; query: KeyValue[] } { + let rest = raw.trim(); + let baseUrl: string | undefined; + const m = /^([a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/?#]+)(.*)$/.exec(rest); + if (m) { + baseUrl = m[1]; + rest = m[2]; + } + const hash = rest.indexOf('#'); + if (hash >= 0) rest = rest.slice(0, hash); + let queryText = ''; + const q = rest.indexOf('?'); + if (q >= 0) { + queryText = rest.slice(q + 1); + rest = rest.slice(0, q); + } + const query: KeyValue[] = []; + for (const part of queryText.split('&')) { + if (!part) continue; + const eq = part.indexOf('='); + const key = safeDecode(eq >= 0 ? part.slice(0, eq) : part); + const value = eq >= 0 ? safeDecode(part.slice(eq + 1)) : ''; + query.push(...toKeyValues([{ key, value }], 'query')); + } + return { baseUrl, path: rest || '/', query }; +} + +function safeDecode(s: string): string { + try { + return decodeURIComponent(s.replace(/\+/g, ' ')); + } catch { + return s; + } +} + +function detectContentType( + headers: KeyValue[], + body: string | undefined +): { contentType?: string; headers: KeyValue[] } { + const ct = headers.find((h) => h.key.toLowerCase() === 'content-type'); + const rest = headers.filter((h) => h.key.toLowerCase() !== 'content-type'); + if (ct) return { contentType: ct.value.split(';')[0].trim(), headers: rest }; + if (!body) return { headers: rest }; + if (tryJson(body) !== undefined) return { contentType: 'application/json', headers: rest }; + if (/^\s* q.secret)) + notes.push('A credential in the query string was replaced by {{secret}} - set it on the Connection tab.'); + if (headers.some((h) => h.secret)) + notes.push('Credentials in the headers were replaced by {{secret}} - set them on the Connection tab.'); + return { + source, + method: method ? method.toUpperCase() : body ? 'POST' : undefined, + url, + baseUrl: parts?.baseUrl, + path: parts?.path, + queryParams, + headers: detected.headers, + contentType: contentTypeHint ?? detected.contentType, + body, + notes + }; +} + +// ── cURL ──────────────────────────────────────────────────────────────────── + +/** Splits a shell command into words, honouring '…' "…" quotes and \ line continuations. */ +function shellWords(command: string): string[] { + const words: string[] = []; + let cur = ''; + let quote: '"' | "'" | null = null; + let started = false; + const text = command.replace(/\\\r?\n/g, ' ').trim(); + for (let i = 0; i < text.length; i++) { + const c = text[i]; + if (quote) { + if (c === quote) quote = null; + else if (c === '\\' && quote === '"' && i + 1 < text.length) cur += text[++i]; + else cur += c; + } else if (c === '"' || c === "'") { + quote = c; + started = true; + } else if (/\s/.test(c)) { + if (started || cur) words.push(cur); + cur = ''; + started = false; + } else if (c === '\\' && i + 1 < text.length) { + cur += text[++i]; + } else { + cur += c; + } + } + if (started || cur) words.push(cur); + return words; +} + +function parseCurl(text: string): ImportedRequest { + const words = shellWords(text); + const notes: string[] = []; + let method: string | undefined; + let url: string | undefined; + const headers: KeyValue[] = []; + const data: string[] = []; + let form = false; + let getMode = false; + for (let i = 0; i < words.length; i++) { + const w = words[i]; + const next = () => words[++i] ?? ''; + if (w === 'curl') continue; + if (w === '-X' || w === '--request') method = next(); + else if (w === '-H' || w === '--header') { + const h = next(); + const c = h.indexOf(':'); + if (c > 0) headers.push(...toKeyValues([{ key: h.slice(0, c), value: h.slice(c + 1).trim() }], 'header')); + } else if ([ + '-d', + '--data', + '--data-raw', + '--data-binary', + '--data-ascii' + ].includes(w)) data.push(next()); + else if (w === '--data-urlencode') { + data.push(next()); + form = true; + } else if (w === '-F' || w === '--form') { + data.push(next()); + form = true; + notes.push('Multipart form fields were read as name=value pairs.'); + } else if (w === '--url') url = next(); + else if (w === '-G' || w === '--get') getMode = true; + else if (w === '-u' || w === '--user') { + next(); + headers.push({ + key: 'Authorization', + value: SECRET, + description: 'Basic authentication from the cURL -u option', + enabled: true, + secret: true + }); + } else if (w.startsWith('-')) { + // Flags that take a value we do not need. + if ([ + '-o', + '--output', + '-A', + '--user-agent', + '-e', + '--referer', + '--connect-timeout', + '-m', + '--max-time', + '--proxy', + '-x' + ].includes(w)) next(); + } else if (!url && /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(w)) url = w; + else if (!url && /^[\w.-]+(:\d+)?\//.test(w)) url = w; + } + let body = data.length ? data.join('&') : undefined; + if (body && getMode) { + url = url + (url?.includes('?') ? '&' : '?') + body; + body = undefined; + } + return finishRequest( + 'cURL command', + method ?? (getMode ? 'GET' : body ? 'POST' : 'GET'), + url, + undefined, + undefined, + headers, + body ? prettyJsonOrText(body) : undefined, + form && !headers.some((h) => h.key.toLowerCase() === 'content-type') + ? 'application/x-www-form-urlencoded' + : undefined, + notes + ); +} + +// ── Postman / HAR / definition object ─────────────────────────────────────── + +function findPostmanItem(node: any): any { + if (!node) return undefined; + if (node.request) return node; + if (Array.isArray(node.item)) { + for (const child of node.item) { + const found = findPostmanItem(child); + if (found) return found; + } + } + return undefined; +} + +function postmanUrl(url: any): string | undefined { + if (!url) return undefined; + if (typeof url === 'string') return url; + if (typeof url.raw === 'string') return url.raw; + const host = Array.isArray(url.host) ? url.host.join('.') : url.host; + const path = Array.isArray(url.path) ? url.path.join('/') : url.path; + return `${url.protocol ? url.protocol + '://' : ''}${host ?? ''}/${path ?? ''}`; +} + +function postmanResponse(r: any): ImportedResponse { + return { + source: 'Postman saved response', + status: typeof r?.code === 'number' ? r.code : undefined, + headers: toKeyValues(r?.header, 'header'), + body: bodyToText(r?.body), + notes: [] + }; +} + +function fromPostman(item: any): ImportedRequest { + const req = item.request; + const notes: string[] = []; + let body: string | undefined; + let contentType: string | undefined; + const b = req?.body; + if (b?.mode === 'raw') { + body = bodyToText(b.raw); + const lang = b.options?.raw?.language; + if (lang === 'json') contentType = 'application/json'; + } else if (b?.mode === 'urlencoded') { + body = (b.urlencoded ?? []) + .filter((p: any) => !p.disabled) + .map((p: any) => `${encodeURIComponent(p.key)}=${encodeURIComponent(p.value ?? '')}`) + .join('&'); + contentType = 'application/x-www-form-urlencoded'; + } else if (b?.mode === 'formdata') { + body = (b.formdata ?? []).map((p: any) => `${p.key}=${p.value ?? ''}`).join('&'); + contentType = 'multipart/form-data'; + } + const request = finishRequest( + 'Postman request', + typeof req?.method === 'string' ? req.method : undefined, + postmanUrl(req?.url), + undefined, + undefined, + toKeyValues(req?.header, 'header'), + body, + contentType, + notes + ); + if (Array.isArray(item.response) && item.response.length) request.response = postmanResponse(item.response[0]); + if (req?.auth) + notes.push('The request had an auth section; it was not read. Set authentication on the Connection tab.'); + return request; +} + +function fromHar(har: any): ImportedRequest { + const entry = har.log.entries[0]; + const r = entry.request ?? {}; + const body = r.postData?.text ? prettyJsonOrText(r.postData.text) : undefined; + const request = finishRequest( + 'HAR capture', + r.method, + r.url, + undefined, + undefined, + toKeyValues(r.headers, 'header'), + body, + typeof r.postData?.mimeType === 'string' ? r.postData.mimeType.split(';')[0] : undefined, + [] + ); + const res = entry.response; + if (res) { + request.response = { + source: 'HAR capture', + status: typeof res.status === 'number' ? res.status : undefined, + headers: toKeyValues(res.headers, 'header'), + body: res.content?.text ? prettyJsonOrText(res.content.text) : undefined, + notes: [] + }; + } + return request; +} + +function definitionResponse(def: any): ImportedResponse | undefined { + const r = def.response ?? def.responseDefinition; + const status = def.status ?? def.statusCode ?? def.successCode ?? r?.status ?? r?.statusCode ?? r?.code; + const headers = def.responseHeaders ?? r?.headers; + const body = def.responseBody ?? (isObject(r) ? (r.body ?? r.data) : r); + const errorBody = def.errorBody ?? def.errorResponse ?? r?.errorBody; + if (status === undefined && headers === undefined && body === undefined && errorBody === undefined) return undefined; + return { + source: 'API definition', + status: typeof status === 'number' ? status : status ? Number(status) || undefined : undefined, + headers: toKeyValues(headers, 'header'), + body: bodyToText(body), + errorBody: bodyToText(errorBody), + notes: [] + }; +} + +function fromDefinition(def: any): ImportedRequest { + const body = def.body ?? def.payload ?? def.data ?? def.requestBody ?? def.request; + const bodyText = isObject(body) && ('method' in body || 'url' in body) ? undefined : bodyToText(body); + const url = def.url ?? def.endpoint ?? def.uri; + const request = finishRequest( + 'API definition', + def.method ?? def.httpMethod, + typeof url === 'string' ? url : undefined, + typeof def.path === 'string' ? def.path : undefined, + def.query ?? def.queryParams ?? def.params, + toKeyValues(def.headers, 'header'), + bodyText, + typeof def.contentType === 'string' ? def.contentType : undefined, + [] + ); + request.response = definitionResponse(def); + return request; +} + +// ── public: request side ──────────────────────────────────────────────────── + +export function parseRequestText(text: string): ImportedRequest { + const t = (text ?? '').trim(); + if (!t) throw new Error('Nothing to read - paste the request or choose a file.'); + if (/^curl\b/i.test(t)) return parseCurl(t); + + const parsed = tryJson(t); + if (parsed === undefined) { + // Not JSON: a cURL command in the middle of text, form data, or other raw text. + const curl = /curl\b[\s\S]*/i.exec(t); + if (curl && /https?:\/\//.test(curl[0])) return parseCurl(curl[0]); + const detected = detectContentType([], t); + return finishRequest('Raw text', undefined, undefined, undefined, undefined, [], t, detected.contentType, [ + 'The text is not JSON; it was kept as the request payload.' + ]); + } + if (isObject(parsed) && isObject(parsed.log) && Array.isArray(parsed.log.entries) && parsed.log.entries.length) + return fromHar(parsed); + if (isObject(parsed) && (parsed.item || parsed.info?.schema)) { + const item = findPostmanItem(parsed); + if (!item) throw new Error('The Postman collection has no request in it.'); + return fromPostman(item); + } + if ( + isObject(parsed) && + isObject(parsed.request) && + (parsed.request.url || parsed.request.method || parsed.request.header) + ) + return fromPostman(parsed); + if (isObject(parsed) && [ + 'method', + 'httpMethod', + 'url', + 'endpoint', + 'uri', + 'headers', + 'queryParams', + 'requestBody', + 'payload' + ].some((k) => k in parsed) && !('statuses' in parsed)) { + return fromDefinition(parsed); + } + // A bare JSON payload. + return finishRequest( + 'JSON payload', + undefined, + undefined, + undefined, + undefined, + [], + JSON.stringify(parsed, null, 2), + 'application/json', + [] + ); +} + +// ── public: response side ─────────────────────────────────────────────────── + +export function parseResponseText(text: string): ImportedResponse { + const t = (text ?? '').trim(); + if (!t) throw new Error('Nothing to read - paste the response or choose a file.'); + const parsed = tryJson(t); + if (parsed === undefined) { + return { + source: 'Raw text', + headers: [], + body: t, + notes: ['The text is not JSON; it was kept as the response body.'] + }; + } + if (isObject(parsed) && isObject(parsed.log) && Array.isArray(parsed.log.entries) && parsed.log.entries.length) { + return ( + fromHar(parsed).response ?? { source: 'HAR capture', headers: [], notes: ['The HAR entry has no response.'] } + ); + } + if (isObject(parsed) && (parsed.item || parsed.info?.schema)) { + const item = findPostmanItem(parsed); + const res = item ? fromPostman(item).response : undefined; + if (!res) throw new Error('The Postman collection has no saved response.'); + return res; + } + if (isObject(parsed) && Array.isArray(parsed.response) && parsed.response.length && (parsed.request || parsed.name)) { + return postmanResponse(parsed.response[0]); + } + if (isObject(parsed) && [ + 'status', + 'statusCode', + 'headers', + 'responseHeaders', + 'responseBody', + 'errorBody' + ].some( + (k) => k in parsed + ) && ('body' in parsed || 'responseBody' in parsed || 'headers' in parsed || 'responseHeaders' in parsed || 'errorBody' in parsed || 'data' in parsed)) { + const r = definitionResponse({ + status: parsed.status ?? parsed.statusCode, + responseHeaders: parsed.responseHeaders ?? parsed.headers, + responseBody: parsed.responseBody ?? parsed.body ?? parsed.data, + errorBody: parsed.errorBody + }); + if (r) return r; + } + return { source: 'JSON body', headers: [], body: JSON.stringify(parsed, null, 2), notes: [] }; +} + +// ── JSON → mapping rows ───────────────────────────────────────────────────── + +export interface JsonLeaf { + path: string; + value: unknown; +} + +/** Every value in a JSON document with its path: `a.b[0].c`. Only the first item of an array is walked. */ +export function flatten(json: unknown, prefix = ''): JsonLeaf[] { + const out: JsonLeaf[] = []; + if (Array.isArray(json)) { + if (json.length) out.push(...flatten(json[0], `${prefix}[0]`)); + } else if (isObject(json)) { + for (const [ + k, + v + ] of Object.entries(json)) { + out.push(...flatten(v, prefix ? `${prefix}.${k}` : k)); + } + } else if (prefix) { + out.push({ path: prefix, value: json }); + } + return out; +} + +function lastKey(path: string): string { + const m = /([A-Za-z0-9_$-]+)(\[\d+\])*$/.exec(path); + return m ? m[1] : path; +} + +const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, ''); + +const REQUEST_GUESS: [RegExp, string, boolean][] = [ + [ + /^(mobile|mobiles|mobileno|mobilenumber|msisdn|phone|phoneno|phonenumber|to|destination|recipient|recipients|number|contact)$/, + 'customer.mobile', + true + ], + [ + /^(templateid|template|templatename|dlttemplateid|flowid)$/, + 'message.templateId', + true + ], + [ + /^(message|text|body|content|sms|msg|smstext|var1|messagetext)$/, + 'message.text', + true + ], + [ + /^(sender|senderid|from|source|shortcode)$/, + 'bank.senderId', + false + ], + [ + /^(email|emailid|mail|emailaddress)$/, + 'customer.email', + false + ], + [ + /^(customername|fullname|firstname|name)$/, + 'customer.name', + false + ], + [ + /^(otp|code|pin)$/, + 'message.otp', + false + ], + [ + /^(pan|pannumber)$/, + 'customer.pan', + false + ], + [ + /^(aadhaar|aadhar|aadhaarnumber)$/, + 'customer.aadhaar', + false + ], + [ + /^(dob|dateofbirth|birthdate)$/, + 'customer.dob', + false + ], + [ + /^(membercode|memberid|memberreference)$/, + 'bank.memberCode', + false + ], + [ + /^(accountno|accountnumber|acno|account)$/, + 'account.number', + false + ], + [ + /^(cif|customerid|clientid|cifno)$/, + 'customer.cif', + false + ], + [ + /^(amount|amt|txnamount|transactionamount)$/, + 'txn.amount', + false + ], + [ + /^(txnid|transactionid|transactionreference|txnref)$/, + 'txn.reference', + false + ], + [ + /^(txndate|transactiondate)$/, + 'txn.date', + false + ], + [ + /^(fromdate|startdate|datefrom)$/, + 'request.fromDate', + false + ], + [ + /^(todate|enddate|dateto)$/, + 'request.toDate', + false + ], + [ + /^(reference|referenceno|referencenumber|ref|requestid|clientreference|correlationid)$/, + 'request.reference', + false + ], + [ + /^(tenantidentifier|tenantid)$/, + 'tenant.identifier', + false + ], + [ + /^(providerid)$/, + 'sms.providerId', + false + ] +]; + +function guessTransform(cbsField: string, value: unknown): string { + if (cbsField === 'customer.mobile') return 'DIGITS_ONLY'; + if (cbsField === 'customer.email') return 'LOWER'; + if (cbsField === 'bank.senderId') return 'UPPER'; + if (cbsField === 'txn.amount') return 'NUMBER'; + if (typeof value === 'boolean') return 'BOOLEAN'; + if (typeof value === 'number') return 'NUMBER'; + if (typeof value === 'string') { + if (/^\d{8}$/.test(value) && cbsField.includes('Date')) return 'DATE_DDMMYYYY'; + if (/^\d{4}-\d{2}-\d{2}/.test(value)) return 'DATE_YYYY_MM_DD'; + } + return 'NONE'; +} + +/** + * A first mapping for a request payload: each value that looks like a known CBS field (mobile, message, template, + * sender ...) becomes "CBS field → path"; every other value becomes a fixed value at its path, so the whole payload is + * reproduced. Review and adjust. + */ +export function guessRequestRows(bodyText: string): ApiMappingRow[] { + const parsed = tryJson(bodyText); + if (parsed === undefined || typeof parsed !== 'object' || parsed === null) return []; + const rows: ApiMappingRow[] = []; + const used = new Set(); + for (const leaf of flatten(parsed)) { + const key = norm(lastKey(leaf.path)); + const hit = REQUEST_GUESS.find(([re]) => re.test(key)); + if (hit && !used.has(hit[1])) { + used.add(hit[1]); + rows.push({ + from: hit[1], + to: leaf.path, + transform: guessTransform(hit[1], leaf.value), + defaultValue: '', + mandatory: hit[2] + }); + } else { + rows.push({ + from: '', + to: leaf.path, + transform: guessTransform('', leaf.value), + defaultValue: leaf.value === null ? '' : String(leaf.value), + mandatory: false + }); + } + } + return rows.filter((r) => r.from || r.defaultValue); +} + +/** + * A first mapping for a response body: every value at its path → a CBS field named after the vendor and the key + * (`sms.messageId`). Ids / references are marked required. + */ +export function guessResponseRows(bodyText: string, vendorCode: string): ApiMappingRow[] { + const parsed = tryJson(bodyText); + if (parsed === undefined || typeof parsed !== 'object' || parsed === null) return []; + const prefix = vendorCode.toLowerCase().split('_')[0] || 'vendor'; + const rows: ApiMappingRow[] = []; + const used = new Set(); + for (const leaf of flatten(parsed)) { + const key = lastKey(leaf.path); + const n = norm(key); + let target: string; + if (/^(messageid|msgid|id|reference|requestid|referenceid|messageid)$/.test(n)) target = 'reference'; + else if (/^(status|state|result|type)$/.test(n)) target = 'status'; + else if (/^(error|errormessage|errormsg|reason|detail|description)$/.test(n)) target = 'error'; + else target = key.replace(/[^A-Za-z0-9]+(.)?/g, (_m, c: string) => (c ? c.toUpperCase() : '')); + let to = `${prefix}.${target}`; + for (let i = 2; used.has(to); i++) to = `${prefix}.${target}${i}`; + used.add(to); + rows.push({ + from: leaf.path, + to, + transform: target === 'status' ? 'UPPER' : guessTransform('', leaf.value), + defaultValue: '', + mandatory: target === 'reference' + }); + } + return rows; +} + +export function secretNote(rows: KeyValue[]): string | null { + const n = rows.filter((r) => r.secret).length; + return n ? `${n} credential value(s) were hidden.` : null; +} + +export { kv }; diff --git a/src/app/api-management/api-key-value-editor/api-key-value-editor.component.html b/src/app/api-management/api-key-value-editor/api-key-value-editor.component.html new file mode 100644 index 000000000..c13a8f1e4 --- /dev/null +++ b/src/app/api-management/api-key-value-editor/api-key-value-editor.component.html @@ -0,0 +1,61 @@ + + +@if (!rows.length) { +
{{ emptyText }}
+} @else { +
+ + + + + + + + + @if (!fixedKeys) { + + } + + + + @for (r of rows; track $index; let i = $index) { + + + + + + + @if (!fixedKeys) { + + } + + } + +
On{{ keyLabel }}{{ valueLabel }}NoteSecret
+ + + + + + + + + +
+
+} +@if (!fixedKeys) { + +} diff --git a/src/app/api-management/api-key-value-editor/api-key-value-editor.component.ts b/src/app/api-management/api-key-value-editor/api-key-value-editor.component.ts new file mode 100644 index 000000000..e3b3b0910 --- /dev/null +++ b/src/app/api-management/api-key-value-editor/api-key-value-editor.component.ts @@ -0,0 +1,44 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { CommonModule } from '@angular/common'; +import { Component, Input } from '@angular/core'; +import { FormsModule } from '@angular/forms'; + +import { KeyValue } from '../api-management.service'; + +/** An editable table of name / value pairs: headers, query parameters, path parameters. Edits the array in place. */ +@Component({ + selector: 'mifosx-api-key-value-editor', + standalone: true, + imports: [ + CommonModule, + FormsModule + ], + templateUrl: './api-key-value-editor.component.html', + styleUrls: ['../../credit-bureau/cic-shared.scss'] +}) +export class ApiKeyValueEditorComponent { + @Input() rows: KeyValue[] = []; + @Input() keyLabel = 'Name'; + @Input() valueLabel = 'Value'; + @Input() keyPlaceholder = ''; + @Input() valuePlaceholder = ''; + @Input() addLabel = '+ Add'; + /** Path parameters come from the path itself; the names cannot be edited or removed. */ + @Input() fixedKeys = false; + @Input() emptyText = 'None.'; + + add(): void { + this.rows.push({ key: '', value: '', description: '', enabled: true, secret: false }); + } + + remove(i: number): void { + this.rows.splice(i, 1); + } +} diff --git a/src/app/api-management/api-log-detail/api-log-detail.component.html b/src/app/api-management/api-log-detail/api-log-detail.component.html new file mode 100644 index 000000000..fa7262a55 --- /dev/null +++ b/src/app/api-management/api-log-detail/api-log-detail.component.html @@ -0,0 +1,55 @@ + + +@if (log) { +
+
+

+ {{ log.vendorName }} · {{ log.apiName }} + {{ + log.direction + }} + {{ + log.result + }} +

+
+
Reference
+
{{ log.reference }}
+
Call
+
{{ log.method }} {{ log.url }}
+
Result
+
HTTP {{ log.httpStatus }} in {{ log.durationMs }} ms · {{ log.time | slice: 0 : 16 }}
+ @if (log.error) { +
Error
+
{{ log.error }}
+ } +
+
+
+

+ {{ log.direction === 'OUT' ? 'Request headers' : 'Headers received / sent' }} + (credentials masked) +

+
{{ log.requestHeaders }}
+

Request payload

+
{{ log.request || '(empty)' }}
+
+
+

Response headers

+
{{ log.responseHeaders }}
+

Response body

+
{{ log.response || '(empty)' }}
+
+
+
+ +
+
+
+} diff --git a/src/app/api-management/api-log-detail/api-log-detail.component.ts b/src/app/api-management/api-log-detail/api-log-detail.component.ts new file mode 100644 index 000000000..bfffa3eea --- /dev/null +++ b/src/app/api-management/api-log-detail/api-log-detail.component.ts @@ -0,0 +1,25 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { CommonModule } from '@angular/common'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; + +import { ApiLog } from '../api-management.service'; + +/** One API call: what was sent and what came back. */ +@Component({ + selector: 'mifosx-api-log-detail', + standalone: true, + imports: [CommonModule], + templateUrl: './api-log-detail.component.html', + styleUrls: ['../../credit-bureau/cic-shared.scss'] +}) +export class ApiLogDetailComponent { + @Input() log: ApiLog | null = null; + @Output() closed = new EventEmitter(); +} diff --git a/src/app/api-management/api-logs/api-logs.component.html b/src/app/api-management/api-logs/api-logs.component.html new file mode 100644 index 000000000..339081a13 --- /dev/null +++ b/src/app/api-management/api-logs/api-logs.component.html @@ -0,0 +1,122 @@ + + +
+
+
+

API Call Logs

+

Every API call the CBS made (OUT) or received (IN), across all vendors, with its request and response.

+
+ +
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+

+ {{ total }} call(s) + + {{ success }} success · + {{ failed }} failed + +

+ @if (loading && !items.length) { +
Loading…
+ } @else if (!items.length) { +
No call matches the filter.
+ } @else { +
+ + + + + + + + + + + + + + + + @for (l of items; track l.id) { + + + + + + + + + + + + } + +
TimeVendorAPIDirectionCallHTTPmsResultReference
{{ l.time | slice: 0 : 16 }}{{ l.vendorName }}{{ l.apiName }} + {{ + l.direction + }} + {{ l.method }} {{ l.path }}{{ l.httpStatus }}{{ l.durationMs }} + {{ + l.result + }} + {{ l.reference }}
+
+
+ {{ offset + 1 }}–{{ offset + items.length }} of {{ total }} + + + + +
+ } +
+
+ + diff --git a/src/app/api-management/api-logs/api-logs.component.ts b/src/app/api-management/api-logs/api-logs.component.ts new file mode 100644 index 000000000..447bd8e82 --- /dev/null +++ b/src/app/api-management/api-logs/api-logs.component.ts @@ -0,0 +1,77 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { CommonModule } from '@angular/common'; +import { Component, OnInit, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute, RouterModule } from '@angular/router'; + +import { ApiLogDetailComponent } from '../api-log-detail/api-log-detail.component'; +import { ApiLog, ApiManagementService, ApiVendor } from '../api-management.service'; + +/** Every API call, IN and OUT, across all vendors, with its request and response. */ +@Component({ + selector: 'mifosx-api-logs', + standalone: true, + imports: [ + CommonModule, + FormsModule, + RouterModule, + ApiLogDetailComponent + ], + templateUrl: './api-logs.component.html', + styleUrls: ['../../credit-bureau/cic-shared.scss'] +}) +export class ApiLogsComponent implements OnInit { + private service = inject(ApiManagementService); + private route = inject(ActivatedRoute); + + vendors: ApiVendor[] = []; + filter = { vendorId: '', direction: '', result: '', search: '' }; + items: ApiLog[] = []; + total = 0; + success = 0; + failed = 0; + offset = 0; + readonly limit = 15; + loading = false; + openLog: ApiLog | null = null; + + ngOnInit(): void { + const vendorId = this.route.snapshot.queryParamMap.get('vendorId'); + if (vendorId) this.filter.vendorId = vendorId; + this.service.vendors().subscribe((v) => (this.vendors = v)); + this.load(); + } + + load(): void { + this.loading = true; + this.service.logs({ ...this.filter, offset: this.offset, limit: this.limit }).subscribe((r) => { + this.items = r.items; + this.total = r.total; + this.success = r.success; + this.failed = r.failed; + this.loading = false; + }); + } + + apply(): void { + this.offset = 0; + this.load(); + } + + reset(): void { + this.filter = { vendorId: '', direction: '', result: '', search: '' }; + this.apply(); + } + + page(delta: number): void { + this.offset = Math.max(0, this.offset + delta * this.limit); + this.load(); + } +} diff --git a/src/app/api-management/api-management-routing.module.ts b/src/app/api-management/api-management-routing.module.ts new file mode 100644 index 000000000..1cf94c6c6 --- /dev/null +++ b/src/app/api-management/api-management-routing.module.ts @@ -0,0 +1,58 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; + +import { Route } from '../core/route/route.service'; +import { ApiVendorsComponent } from './api-vendors/api-vendors.component'; +import { ApiVendorSetupComponent } from './api-vendor-setup/api-vendor-setup.component'; +import { ApiLogsComponent } from './api-logs/api-logs.component'; + +const routes: Routes = [ + Route.withShell([ + { + path: 'api-management', + data: { title: 'API Management', breadcrumb: 'API Management' }, + children: [ + { + path: '', + redirectTo: 'vendors', + pathMatch: 'full' + }, + { + path: 'vendors', + data: { breadcrumb: 'Vendors' }, + children: [ + { + path: '', + component: ApiVendorsComponent, + data: { title: 'API Vendors & Setup' } + }, + { + path: ':id', + component: ApiVendorSetupComponent, + data: { title: 'Vendor Setup', breadcrumb: 'Setup' } + } + ] + }, + { + path: 'logs', + component: ApiLogsComponent, + data: { title: 'API Call Logs', breadcrumb: 'Call Logs' } + } + ] + } + ]) +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class ApiManagementRoutingModule {} diff --git a/src/app/api-management/api-management.module.ts b/src/app/api-management/api-management.module.ts new file mode 100644 index 000000000..6ee7ef247 --- /dev/null +++ b/src/app/api-management/api-management.module.ts @@ -0,0 +1,29 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { NgModule } from '@angular/core'; + +import { SharedModule } from '../shared/shared.module'; +import { ApiManagementRoutingModule } from './api-management-routing.module'; +import { ApiVendorsComponent } from './api-vendors/api-vendors.component'; +import { ApiVendorSetupComponent } from './api-vendor-setup/api-vendor-setup.component'; +import { ApiLogsComponent } from './api-logs/api-logs.component'; +import { ApiLogDetailComponent } from './api-log-detail/api-log-detail.component'; + +/** API Management: connect any outside API service (CIC, SMS, WhatsApp ...) - vendor, work, API set, IN / OUT. */ +@NgModule({ + imports: [ + SharedModule, + ApiManagementRoutingModule, + ApiVendorsComponent, + ApiVendorSetupComponent, + ApiLogsComponent, + ApiLogDetailComponent + ] +}) +export class ApiManagementModule {} diff --git a/src/app/api-management/api-management.service.ts b/src/app/api-management/api-management.service.ts new file mode 100644 index 000000000..84ff6f4c6 --- /dev/null +++ b/src/app/api-management/api-management.service.ts @@ -0,0 +1,1042 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Injectable } from '@angular/core'; +import { Observable, delay, of, throwError } from 'rxjs'; + +/** + * API Management: every outside API service the CBS talks to, set up the same way - Vendor → Work → API set - with a + * direction for each API. + * + * OUT - the CBS calls the vendor (send an SMS, send a WhatsApp message) + * IN pull - data comes INTO the CBS because the CBS asks the vendor on a schedule (CIC alerts, delivery reports) + * IN push - data comes INTO the CBS because the vendor calls a CBS path (webhook) + * + * Every API carries the whole HTTP picture - method, path, path / query parameters, headers, content type, payload, + * success codes, response headers, success and error bodies - plus the CBS field mapping. It can be typed in, or read + * from a JSON / cURL / Postman / HAR file (see api-import.util.ts). + * + * This is the STATIC version: everything is kept in memory for the session, no backend call is made, and it resets on + * reload. When the backend is built, only this service changes. + */ + +export type ApiDirection = 'IN' | 'OUT'; +export type ApiMode = 'PUSH' | 'PULL'; + +/** One header, query parameter or path parameter. */ +export interface KeyValue { + key: string; + value: string; + description: string; + enabled: boolean; + /** Secret values (tokens, keys) are shown masked and never read from an imported file. */ + secret: boolean; +} + +export interface ApiMappingRow { + /** Request mapping: the CBS field. Response mapping: the vendor's JSON path. Empty = a fixed value (see defaultValue). */ + from: string; + /** Request mapping: the vendor's JSON path. Response mapping: the CBS field. */ + to: string; + transform: string; + defaultValue: string; + mandatory: boolean; +} + +export interface VendorApi { + id: number; + name: string; + direction: ApiDirection; + /** IN only: PULL = the CBS asks the vendor on a schedule; PUSH = the vendor calls a CBS path (webhook). */ + mode?: ApiMode; + method: 'POST' | 'PUT' | 'GET' | 'PATCH' | 'DELETE'; + /** Calls the vendor (OUT, IN pull): the vendor's path after its base URL. IN push: the CBS path the vendor calls. */ + path: string; + /** When the CBS uses this API. */ + usedFor: string; + active: boolean; + contentType: string; + pathParams: KeyValue[]; + queryParams: KeyValue[]; + headers: KeyValue[]; + /** The request payload (for IN push: what the vendor sends us). Mapping rows put CBS values into / read values out of it. */ + requestBody: string; + /** Status codes that mean success, e.g. "200, 202". */ + successCodes: string; + responseHeaders: KeyValue[]; + responseBody: string; + errorBody: string; + /** Overrides of the vendor's timeout / retries for this API only. */ + timeoutMs: number | null; + retries: number | null; + /** OUT / IN pull: CBS value → the vendor's request path. IN push: the vendor's incoming path → CBS field. */ + requestRows: ApiMappingRow[]; + /** OUT / IN pull: the vendor's response path → CBS field. IN push: CBS value (or fixed) → our answer path. */ + responseRows: ApiMappingRow[]; +} + +export interface ApiVendor { + id: number; + code: string; + name: string; + /** What this vendor is used for - the "work". */ + work: string; + /** Where this vendor is configured in the CBS today. */ + configNote?: string; + environment: 'SIMULATOR' | 'UAT' | 'PRODUCTION'; + baseUrl: string; + authType: string; + username: string; + apiKeyHeader: string; + hasSecret: boolean; + senderId: string; + timeoutMs: number; + retries: number; + /** Headers sent on every API of this vendor. */ + defaultHeaders: KeyValue[]; + active: boolean; + apis: VendorApi[]; + lastCallOn?: string; + /** Screens that already exist for this vendor (e.g. CIC whitelist and mapping). */ + links: { label: string; route: string }[]; +} + +export interface ApiLog { + id: number; + time: string; + vendorId: number; + vendorName: string; + apiName: string; + direction: ApiDirection; + method: string; + path: string; + url: string; + httpStatus: number; + durationMs: number; + result: 'SUCCESS' | 'FAILED'; + reference: string; + error?: string; + requestHeaders: string; + request: string; + responseHeaders: string; + response: string; +} + +export interface ApiLogFilter { + vendorId?: string; + direction?: string; + result?: string; + search?: string; + offset?: number; + limit?: number; +} + +export const API_AUTH_TYPES: Record = { + NONE: 'None (IP whitelisting / mutual TLS)', + BASIC: 'Basic (user id + password)', + API_KEY: 'API key in a header', + BEARER_TOKEN: 'Static bearer token', + OAUTH2_CLIENT_CREDENTIALS: 'OAuth2 client credentials' +}; + +export const API_METHODS = [ + 'POST', + 'GET', + 'PUT', + 'PATCH', + 'DELETE' +]; + +export const API_CONTENT_TYPES: Record = { + 'application/json': 'JSON (application/json)', + 'application/x-www-form-urlencoded': 'Form (application/x-www-form-urlencoded)', + 'multipart/form-data': 'Multipart form', + 'application/xml': 'XML (application/xml)', + 'text/xml': 'XML (text/xml)', + 'text/plain': 'Plain text' +}; + +export const API_TRANSFORMS: Record = { + NONE: 'As is', + UPPER: 'UPPER CASE', + LOWER: 'lower case', + DIGITS_ONLY: 'Digits only', + DATE_DDMMYYYY: 'Date ddMMyyyy', + DATE_YYYY_MM_DD: 'Date yyyy-MM-dd', + AMOUNT_INTEGER: 'Amount, whole rupees', + NUMBER: 'Number', + BOOLEAN: 'true / false' +}; + +/** CBS values an OUT request (or an IN answer) can take. */ +export const API_CBS_FIELDS: Record = { + 'customer.name': 'Customer name', + 'customer.mobile': 'Customer mobile number', + 'customer.email': 'Customer e-mail', + 'customer.cif': 'Customer CIF / client number', + 'customer.pan': 'Customer PAN', + 'customer.aadhaar': 'Customer Aadhaar (masked)', + 'customer.dob': 'Customer date of birth', + 'account.number': 'Account number', + 'txn.amount': 'Transaction amount', + 'txn.reference': 'Transaction reference', + 'txn.date': 'Transaction date', + 'message.text': 'Message text', + 'message.templateId': 'Message template id', + 'message.otp': 'One-time password', + 'bank.senderId': 'Bank sender id', + 'bank.memberCode': 'Bank member code', + 'request.reference': 'Request reference number', + 'request.fromDate': 'From date', + 'request.toDate': 'To date', + 'sms.queueId': 'SMS queue id', + 'sms.providerId': 'SMS provider id', + 'tenant.identifier': 'Tenant identifier' +}; + +const DAY = 86_400_000; + +function ago(minutes: number): string { + return new Date(Date.now() - minutes * 60_000).toISOString().slice(0, 19); +} + +function row(from: string, to: string, transform = 'NONE', mandatory = false, defaultValue = ''): ApiMappingRow { + return { from, to, transform, defaultValue, mandatory }; +} + +export function kv(key: string, value: string, description = '', secret = false, enabled = true): KeyValue { + return { key, value, description, enabled, secret }; +} + +function api( + p: Partial & Pick +): VendorApi { + return { + active: true, + contentType: 'application/json', + pathParams: [], + queryParams: [], + headers: [], + requestBody: '{}', + successCodes: '200', + responseHeaders: [], + responseBody: '{}', + errorBody: '{}', + timeoutMs: null, + retries: null, + requestRows: [], + responseRows: [], + ...p + }; +} + +const pretty = (v: unknown) => JSON.stringify(v, null, 2); + +export function blankApi(direction: ApiDirection): VendorApi { + return api({ + id: 0, + name: '', + direction, + mode: direction === 'IN' ? 'PUSH' : undefined, + method: 'POST', + path: '', + usedFor: '', + headers: [kv('Accept', 'application/json')] + }); +} + +/** Names of the {param} placeholders in a path (not the {{double}} placeholders). */ +export function pathParamNames(path: string): string[] { + const names: string[] = []; + const re = /(?): boolean { + return a.direction === 'OUT' || a.mode === 'PULL'; +} + +export interface RequestPreview { + method: string; + url: string; + headers: { key: string; value: string }[]; + body: string; +} + +/** The request exactly as it would go out (credentials masked), assembled from the vendor and the API. */ +export function buildRequestPreview(vendor: ApiVendor, a: VendorApi): RequestPreview { + const outbound = callsVendor(a); + let path = a.path; + for (const p of a.pathParams) { + if (p.key) path = path.split(`{${p.key}}`).join(p.value || `{${p.key}}`); + } + const query = a.queryParams + .filter((q) => q.enabled && q.key) + .map((q) => `${encodeURIComponent(q.key)}=${q.secret ? '••••••' : encodeURIComponent(q.value)}`) + .join('&'); + const base = outbound ? vendor.baseUrl || '(vendor base URL not set)' : 'https://'; + const url = + base.replace(/\/+$/, '') + (path.startsWith('/') || !path ? path : '/' + path) + (query ? '?' + query : ''); + + const headers = new Map(); + const put = (k: string, v: string) => headers.set(k.toLowerCase(), { key: k, value: v }); + vendor.defaultHeaders.filter((h) => h.enabled && h.key).forEach((h) => put(h.key, h.secret ? '••••••' : h.value)); + a.headers.filter((h) => h.enabled && h.key).forEach((h) => put(h.key, h.secret ? '••••••' : h.value)); + if (a.method !== 'GET' && a.method !== 'DELETE' && a.contentType) put('Content-Type', a.contentType); + if (outbound) { + if (vendor.authType === 'API_KEY') put(vendor.apiKeyHeader || 'X-API-KEY', '••••••'); + else if (vendor.authType === 'BEARER_TOKEN') put('Authorization', 'Bearer ••••••'); + else if (vendor.authType === 'BASIC') put('Authorization', 'Basic ••••••'); + else if (vendor.authType === 'OAUTH2_CLIENT_CREDENTIALS') put('Authorization', 'Bearer '); + } + return { + method: a.method, + url, + headers: [...headers.values()], + body: a.method === 'GET' || a.method === 'DELETE' ? '' : a.requestBody + }; +} + +function buildVendors(): ApiVendor[] { + return [ + { + id: 1, + code: 'CIC', + name: 'CIC — Credit Information Companies', + work: 'Enquiry alerts: tells us when another lender enquires on one of our customers (CIBIL, Experian, Equifax, CRIF)', + configNote: + 'Each bureau is whitelisted and connected on the CIC Whitelist & Configuration screen; this API set is its alert call.', + environment: 'SIMULATOR', + baseUrl: '', + authType: 'NONE', + username: '', + apiKeyHeader: 'X-API-KEY', + hasSecret: false, + senderId: 'MH00123456', + timeoutMs: 30000, + retries: 1, + defaultHeaders: [kv('Accept', 'application/json', 'Every call asks for JSON')], + active: true, + lastCallOn: ago(55), + links: [ + { label: 'Whitelist & configuration', route: '/credit-bureau/bureaus' }, + { label: 'API mapping', route: '/credit-bureau/api-mapping' }, + { label: 'Credit Bureau dashboard', route: '/credit-bureau/dashboard' } + ], + apis: [ + api({ + id: 101, + name: 'Enquiry alerts', + direction: 'IN', + mode: 'PULL', + method: 'POST', + path: '/monitoring/enquiry-alerts', + usedFor: 'Every 60 minutes the CBS asks each whitelisted bureau for new enquiries on our customers', + queryParams: [kv('version', '1.0', 'API version of the bureau')], + headers: [ + kv('X-Member-Code', '{{bank.memberCode}}', "The bank's member code at the bureau"), + kv('X-Request-Id', '{{request.reference}}', 'Our reference for this fetch') + ], + requestBody: pretty({ + requestHeader: { memberCode: 'MH00123456', referenceNumber: 'CICF000001' }, + criteria: { fromDate: '01102026', toDate: '08102026', scope: 'OTHER_MEMBERS' } + }), + successCodes: '200', + responseHeaders: [ + kv('Content-Type', 'application/json'), + kv('X-RateLimit-Remaining', '98', 'Calls left in this window') + ], + responseBody: pretty({ + responseHeader: { status: 'SUCCESS' }, + data: { + totalAlerts: 1, + alerts: [ + { + alertId: 'CIBIL-20261008-1', + consumer: { pan: 'ABXPS4421K' }, + enquiry: { productType: 'PERSONAL LOAN', amount: 450000, date: '08102026' } + } + ] + } + }), + errorBody: pretty({ + responseHeader: { status: 'ERROR', errorCode: 'E102', errorMessage: 'Invalid member code' } + }), + requestRows: [ + row('bank.memberCode', 'requestHeader.memberCode'), + row('request.reference', 'requestHeader.referenceNumber', 'NONE', true), + row('request.fromDate', 'criteria.fromDate', 'DATE_DDMMYYYY', true), + row('request.toDate', 'criteria.toDate', 'DATE_DDMMYYYY', true), + row('', 'criteria.scope', 'NONE', false, 'OTHER_MEMBERS') + ], + responseRows: [ + row('data.alerts', 'alerts', 'NONE', true), + row('consumer.pan', 'customer.pan', 'UPPER'), + row('enquiry.productType', 'alert.loanType'), + row('enquiry.amount', 'alert.amount', 'NUMBER'), + row('enquiry.date', 'alert.enquiryDate', 'DATE_DDMMYYYY') + ] + }) + ] + }, + { + id: 2, + code: 'SMS_NOTICE', + name: 'SMS Gateway — Customer Notices (MSG91)', + work: 'SMS to customers from the CBS: KYC exception outreach (Log Customer Contact), alerts and reminders — sent on a DLT-registered sender id and template', + configNote: + 'Today this is configured in application.properties as fineract.notification.sms.* (provider, base-url, path, auth-header, body-template, sender-id, template-id). Setting it up here replaces that.', + environment: 'UAT', + baseUrl: 'https://control.msg91.com', + authType: 'API_KEY', + username: '', + apiKeyHeader: 'authkey', + hasSecret: true, + senderId: 'LNCPAY', + timeoutMs: 10000, + retries: 2, + defaultHeaders: [kv('Accept', 'application/json')], + active: true, + lastCallOn: ago(4), + links: [], + apis: [ + api({ + id: 201, + name: 'Customer notice SMS', + direction: 'OUT', + method: 'POST', + path: '/api/v5/flow/', + usedFor: 'Log Customer Contact → SMS on the KYC Exceptions screen, and other one-to-one customer notices', + requestBody: pretty({ + template_id: '1107169123456789012', + sender: 'LNCPAY', + short_url: '0', + recipients: [{ mobiles: '919876543210', VAR1: 'Please submit your PAN to complete KYC. - LincPay' }] + }), + successCodes: '200', + responseHeaders: [kv('Content-Type', 'application/json')], + responseBody: pretty({ type: 'success', data: { messageId: '3166707a4f36323138343631' } }), + errorBody: pretty({ type: 'error', message: 'Authentication failure', code: '401' }), + requestRows: [ + row('message.templateId', 'template_id', 'NONE', true), + row('bank.senderId', 'sender', 'UPPER', true), + row('customer.mobile', 'recipients[0].mobiles', 'DIGITS_ONLY', true), + row('message.text', 'recipients[0].VAR1', 'NONE', true), + row('', 'short_url', 'NONE', false, '0') + ], + responseRows: [ + row('data.messageId', 'sms.reference', 'NONE'), + row('type', 'sms.status', 'UPPER') + ] + }) + ] + }, + { + id: 3, + code: 'SMS_GATEWAY', + name: 'SMS Message Gateway — Campaigns & OTP', + work: 'Queued SMS sent by the CBS scheduler: SMS campaigns, two-factor login OTP and self-service registration OTP — with delivery reports', + configNote: + 'Today this is configured under System → External Services → SMS (host name, port, end point, tenant app key). Jobs: "Send messages to SMS gateway" and "Get delivery reports from SMS gateway".', + environment: 'UAT', + baseUrl: 'http://sms-gateway.internal:9191', + authType: 'API_KEY', + username: '', + apiKeyHeader: 'Fineract-Tenant-App-Key', + hasSecret: true, + senderId: '', + timeoutMs: 20000, + retries: 1, + defaultHeaders: [kv('Fineract-Platform-TenantId', '{{tenant.identifier}}', 'Tenant the messages belong to')], + active: true, + lastCallOn: ago(11), + links: [{ label: 'External services → SMS', route: '/system/external-services/sms' }], + apis: [ + api({ + id: 301, + name: 'Send queued messages', + direction: 'OUT', + method: 'POST', + path: '/sms', + usedFor: + 'Job "Send messages to SMS gateway": campaign SMS, two-factor OTP and registration OTP. The gateway must answer 202 Accepted', + requestBody: pretty([ + { + id: 5821, + tenantIdentifier: 'lincpay', + mobileNo: '9876543210', + message: 'Your LincPay OTP is 482913. Valid for 5 minutes.', + providerId: 1 + } + ]), + successCodes: '202', + responseHeaders: [kv('Content-Length', '0')], + responseBody: '', + errorBody: pretty({ error: 'Unknown SMS provider', providerId: 9 }), + requestRows: [ + row('sms.queueId', '[0].id', 'NUMBER', true), + row('tenant.identifier', '[0].tenantIdentifier', 'NONE', true), + row('customer.mobile', '[0].mobileNo', 'DIGITS_ONLY', true), + row('message.text', '[0].message', 'NONE', true), + row('sms.providerId', '[0].providerId', 'NUMBER') + ] + }), + api({ + id: 302, + name: 'Get delivery reports', + direction: 'IN', + mode: 'PULL', + method: 'POST', + path: '/sms/report', + usedFor: 'Job "Get delivery reports from SMS gateway": the CBS asks for the status of messages it sent', + requestBody: pretty([ + 5821, + 5822 + ]), + successCodes: '200', + responseHeaders: [kv('Content-Type', 'application/json')], + responseBody: pretty([ + { id: 5821, deliveryStatus: 300, hasError: false }, + { id: 5822, deliveryStatus: 400, hasError: true, errorMessage: 'Number is on the DND list' } + ]), + errorBody: pretty({ error: 'Report not available yet' }), + requestRows: [row('sms.queueId', '[0]', 'NUMBER', true)], + responseRows: [ + row('[0].id', 'sms.queueId', 'NUMBER', true), + row('[0].deliveryStatus', 'sms.deliveryStatus', 'NUMBER', true), + row('[0].hasError', 'sms.hasError', 'BOOLEAN'), + row('[0].errorMessage', 'sms.error') + ] + }) + ] + }, + { + id: 4, + code: 'WHATSAPP', + name: 'WhatsApp — Customer Notices (Meta Cloud API)', + work: 'WhatsApp messages to customers from the CBS: KYC exception outreach and notices', + configNote: + 'Today this is configured in application.properties as fineract.notification.whatsapp.* (base-url, path with the phone-number id, bearer token, body-template, country code 91).', + environment: 'SIMULATOR', + baseUrl: 'https://graph.facebook.com', + authType: 'BEARER_TOKEN', + username: '', + apiKeyHeader: 'Authorization', + hasSecret: false, + senderId: '109876543210987', + timeoutMs: 15000, + retries: 1, + defaultHeaders: [kv('Accept', 'application/json')], + active: true, + lastCallOn: ago(190), + links: [], + apis: [ + api({ + id: 401, + name: 'Send customer notice', + direction: 'OUT', + method: 'POST', + path: '/v21.0/{senderId}/messages', + usedFor: 'Log Customer Contact → WhatsApp on the KYC Exceptions screen', + pathParams: [kv('senderId', '{{bank.senderId}}', 'The WhatsApp phone-number id')], + requestBody: pretty({ + messaging_product: 'whatsapp', + to: '919876543210', + type: 'text', + text: { body: 'Please submit your PAN to complete KYC. - LincPay' } + }), + successCodes: '200', + responseHeaders: [ + kv('Content-Type', 'application/json; charset=UTF-8'), + kv('x-fb-trace-id', 'AbCdEf123', 'For Meta support') + ], + responseBody: pretty({ + messaging_product: 'whatsapp', + contacts: [{ input: '919876543210', wa_id: '919876543210' }], + messages: [{ id: 'wamid.HBgMOTE5ODc2NTQzMjEw' }] + }), + errorBody: pretty({ + error: { + message: 'Invalid OAuth access token.', + type: 'OAuthException', + code: 190, + fbtrace_id: 'AbCdEf123' + } + }), + requestRows: [ + row('customer.mobile', 'to', 'DIGITS_ONLY', true), + row('message.text', 'text.body', 'NONE', true), + row('', 'messaging_product', 'NONE', true, 'whatsapp'), + row('', 'type', 'NONE', false, 'text') + ], + responseRows: [row('messages[0].id', 'whatsapp.reference', 'NONE')] + }), + api({ + id: 402, + name: 'Message status', + direction: 'IN', + mode: 'PUSH', + method: 'POST', + path: '/webhooks/whatsapp/status', + usedFor: 'Planned — WhatsApp tells us a message was sent, delivered or read (not used by the CBS yet)', + active: false, + headers: [kv('X-Hub-Signature-256', 'sha256=…', 'Signature we check on every call', true)], + requestBody: pretty({ + statuses: [ + { + id: 'wamid.HBgMOTE5ODc2NTQzMjEw', + status: 'delivered', + timestamp: '1760000000', + recipient_id: '919876543210' + } + ] + }), + successCodes: '200', + responseBody: pretty({ ack: 'OK' }), + errorBody: pretty({ error: 'Invalid signature' }), + requestRows: [ + row('statuses[0].id', 'whatsapp.reference', 'NONE', true), + row('statuses[0].status', 'whatsapp.status', 'UPPER', true) + ], + responseRows: [row('', 'ack', 'NONE', false, 'OK')] + }) + ] + } + ]; +} + +function logFor( + id: number, + time: string, + v: ApiVendor, + a: VendorApi, + status: number, + durationMs: number, + reference: string +): ApiLog { + const p = buildRequestPreview(v, a); + const failed = !a.successCodes + .split(',') + .map((s) => Number(s.trim())) + .includes(status); + return { + id, + time, + vendorId: v.id, + vendorName: v.name, + apiName: a.name, + direction: a.direction, + method: a.method, + path: a.path, + url: p.url, + httpStatus: status, + durationMs, + result: failed ? 'FAILED' : 'SUCCESS', + reference, + error: failed + ? status === 401 + ? 'Vendor refused the credentials' + : status === 429 + ? 'Rate limit exceeded' + : status === 504 + ? 'Vendor timed out' + : 'Vendor returned an internal error' + : undefined, + requestHeaders: pretty( + Object.fromEntries( + p.headers.map((h) => [ + h.key, + h.value + ]) + ) + ), + request: p.body, + responseHeaders: pretty( + Object.fromEntries( + a.responseHeaders + .filter((h) => h.enabled) + .map((h) => [ + h.key, + h.value + ]) + ) + ), + response: failed ? a.errorBody || pretty({ error: 'failed', status }) : a.responseBody + }; +} + +function buildLogs(vendors: ApiVendor[]): ApiLog[] { + const logs: ApiLog[] = []; + let id = 1; + let seed = 7; + const next = () => { + seed = (seed * 1664525 + 1013904223) % 4294967296; + return seed / 4294967296; + }; + for (let i = 0; i < 64; i++) { + const v = vendors[Math.floor(next() * vendors.length)]; + const apis = v.apis.filter((a) => a.active); + const a = apis[Math.floor(next() * apis.length)]; + const failed = next() < 0.14; + const okCode = Number(a.successCodes.split(',')[0]); + const status = failed ? [ + 500, + 504, + 401, + 429 + ][Math.floor(next() * 4)] : okCode; + logs.push( + logFor( + id++, + ago(i * 38 + Math.floor(next() * 30)), + v, + a, + status, + 90 + Math.floor(next() * (failed ? 9000 : 900)), + `${v.code}-${String(2026100000 + i * 17)}` + ) + ); + } + return logs; +} + +function fail(message: string): Observable { + return throwError(() => ({ error: { defaultUserMessage: message } })); +} + +function reply(value: T): Observable { + // A copy, so a screen editing what it got never changes the in-memory "database". + return of(JSON.parse(JSON.stringify(value)) as T).pipe(delay(200)); +} + +const HEADER_NAME = /^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/; + +/** Checks the key / value tables; returns a message or null. */ +function checkPairs(label: string, pairs: KeyValue[], unique: boolean): string | null { + const seen = new Set(); + for (const p of pairs) { + if (!p.key.trim()) return `${label}: a row has no name (fill it in or remove the row).`; + if (label.startsWith('Header') && !HEADER_NAME.test(p.key.trim())) + return `${label}: "${p.key}" is not a valid header name.`; + if (unique && p.enabled) { + const k = p.key.trim().toLowerCase(); + if (seen.has(k)) return `${label}: "${p.key}" is listed twice.`; + seen.add(k); + } + } + return null; +} + +function looksJson(text: string): boolean { + const t = text.trim(); + return t.startsWith('{') || t.startsWith('['); +} + +function jsonError(text: string): string | null { + try { + JSON.parse(text); + return null; + } catch (e: any) { + return String(e?.message ?? e); + } +} + +@Injectable({ providedIn: 'root' }) +export class ApiManagementService { + private vendorList = buildVendors(); + private logList = buildLogs(this.vendorList); + private nextVendorId = 10; + private nextApiId = 1000; + private nextLogId = 1000; + + // ── vendors ── + + vendors(): Observable { + return reply(this.vendorList); + } + + vendor(id: number): Observable { + const v = this.vendorList.find((x) => x.id === id); + return v ? reply(v) : fail('Vendor was not found.'); + } + + addVendor(input: { name: string; work: string; environment: ApiVendor['environment'] }): Observable { + const name = input.name.trim(); + if (!name) return fail('Give the vendor a name.'); + if (!input.work.trim()) return fail('Say what this vendor is used for (its work).'); + if (this.vendorList.some((v) => v.name.toLowerCase() === name.toLowerCase())) { + return fail(`A vendor called "${name}" already exists.`); + } + const vendor: ApiVendor = { + id: this.nextVendorId++, + code: name + .toUpperCase() + .replace(/[^A-Z0-9]+/g, '_') + .replace(/^_|_$/g, '') + .slice(0, 20), + name, + work: input.work.trim(), + environment: input.environment, + baseUrl: '', + authType: 'NONE', + username: '', + apiKeyHeader: 'X-API-KEY', + hasSecret: false, + senderId: '', + timeoutMs: 30000, + retries: 1, + defaultHeaders: [kv('Accept', 'application/json')], + active: false, + links: [], + apis: [] + }; + this.vendorList.push(vendor); + return reply(vendor); + } + + saveVendor(id: number, body: Partial & { secret?: string; clearSecret?: boolean }): Observable { + const v = this.vendorList.find((x) => x.id === id); + if (!v) return fail('Vendor was not found.'); + const name = (body.name ?? v.name).trim(); + if (!name) return fail('Give the vendor a name.'); + const baseUrl = (body.baseUrl ?? v.baseUrl).trim(); + const environment = body.environment ?? v.environment; + if (baseUrl && !/^https?:\/\//.test(baseUrl)) + return fail('Base URL must start with https:// (or http:// for a test server).'); + if (environment === 'PRODUCTION' && baseUrl && !baseUrl.startsWith('https://')) { + return fail('A PRODUCTION vendor must be reached over https://.'); + } + const timeout = Number(body.timeoutMs ?? v.timeoutMs); + if (timeout < 1000 || timeout > 300000) return fail('Timeout must be between 1,000 and 300,000 milliseconds.'); + const retries = Number(body.retries ?? v.retries); + if (retries < 0 || retries > 5) return fail('Retries must be between 0 and 5.'); + const defaultHeaders = body.defaultHeaders ?? v.defaultHeaders; + const headerProblem = checkPairs('Header', defaultHeaders, true); + if (headerProblem) return fail(`Default headers — ${headerProblem}`); + const authType = body.authType ?? v.authType; + const hasSecret = body.clearSecret ? false : body.secret ? true : v.hasSecret; + if (body.active ?? v.active) { + if (environment !== 'SIMULATOR' && !baseUrl) + return fail(`${name} needs a base URL before it can be active in ${environment}.`); + if (environment !== 'SIMULATOR' && authType !== 'NONE' && !hasSecret) { + return fail(`${name} uses ${authType} but no password / key / token is stored.`); + } + } + Object.assign(v, { + name, + work: (body.work ?? v.work).trim(), + environment, + baseUrl, + authType, + username: (body.username ?? v.username).trim(), + apiKeyHeader: (body.apiKeyHeader ?? v.apiKeyHeader).trim() || 'X-API-KEY', + hasSecret, + senderId: (body.senderId ?? v.senderId).trim(), + timeoutMs: timeout, + retries, + defaultHeaders, + active: body.active ?? v.active + }); + return reply(v); + } + + // ── API set ── + + saveApi(vendorId: number, input: VendorApi): Observable { + const v = this.vendorList.find((x) => x.id === vendorId); + if (!v) return fail('Vendor was not found.'); + const a = input; + if (!a.name.trim()) return fail('Give the API a name.'); + const inbound = !callsVendor(a); + if (!a.path.trim()) + return fail(inbound ? 'Enter the CBS path the vendor will call.' : "Enter the vendor's API path."); + if (inbound && !a.path.trim().startsWith('/')) return fail('The CBS path must start with /.'); + if (!a.usedFor.trim()) return fail('Say when the CBS uses this API.'); + + for (const [ + label, + pairs, + unique + ] of [ + [ + 'Header', + a.headers, + true + ], + [ + 'Query parameter', + a.queryParams, + false + ], + [ + 'Path parameter', + a.pathParams, + true + ], + [ + 'Header (response)', + a.responseHeaders, + true + ] + ] as [string, KeyValue[], boolean][]) { + const problem = checkPairs(label, pairs, unique); + if (problem) return fail(problem); + } + for (const name of pathParamNames(a.path)) { + const p = a.pathParams.find((x) => x.key === name); + if (!p || !p.value.trim()) return fail(`The path has {${name}} - give it a value under Path parameters.`); + } + if (a.contentType === 'application/json' && a.requestBody.trim() && looksJson(a.requestBody)) { + const err = jsonError(a.requestBody); + if (err) return fail(`Request payload is not valid JSON: ${err}`); + } + for (const [ + label, + text + ] of [ + [ + 'Success response body', + a.responseBody + ], + [ + 'Error response body', + a.errorBody + ] + ]) { + if (text.trim() && looksJson(text)) { + const err = jsonError(text); + if (err) return fail(`${label} is not valid JSON: ${err}`); + } + } + const codes = a.successCodes + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + if (!codes.length || codes.some((c) => !/^[1-5]\d\d$/.test(c))) { + return fail('Success codes must be HTTP status codes like "200" or "200, 202".'); + } + if (a.timeoutMs !== null && (a.timeoutMs < 1000 || a.timeoutMs > 300000)) { + return fail("The API timeout must be between 1,000 and 300,000 ms (or empty to use the vendor's)."); + } + if (a.retries !== null && (a.retries < 0 || a.retries > 5)) + return fail('The API retries must be between 0 and 5 (or empty).'); + for (const r of [ + ...a.requestRows, + ...a.responseRows + ]) { + if ((!r.from.trim() && !r.defaultValue.trim()) || !r.to.trim()) { + return fail('Every mapping row needs both sides filled in, or a fixed value (or remove the row).'); + } + } + if (a.active && a.direction === 'IN' && !(inbound ? a.requestRows : a.responseRows).length) { + return fail('An active IN API needs at least one incoming-data mapping, or nothing can be read from it.'); + } + if (v.apis.some((x) => x.id !== a.id && x.name.toLowerCase() === a.name.trim().toLowerCase())) { + return fail(`${v.name} already has an API called "${a.name.trim()}".`); + } + const clean: VendorApi = { + ...a, + name: a.name.trim(), + path: a.path.trim(), + usedFor: a.usedFor.trim(), + successCodes: codes.join(', ') + }; + const i = v.apis.findIndex((x) => x.id === a.id); + if (i >= 0) { + v.apis[i] = clean; + } else { + clean.id = this.nextApiId++; + v.apis.push(clean); + } + return reply(clean); + } + + deleteApi(vendorId: number, apiId: number): Observable<{ deleted: boolean }> { + const v = this.vendorList.find((x) => x.id === vendorId); + if (!v) return fail('Vendor was not found.'); + v.apis = v.apis.filter((a) => a.id !== apiId); + return reply({ deleted: true }); + } + + /** Simulated call: nothing leaves the browser; the result is added to the log. */ + testApi(vendorId: number, apiId: number, requestBody: string): Observable { + const v = this.vendorList.find((x) => x.id === vendorId); + const a = v?.apis.find((x) => x.id === apiId); + if (!v || !a) return fail('API was not found.'); + if (a.contentType === 'application/json' && requestBody.trim() && looksJson(requestBody)) { + const err = jsonError(requestBody); + if (err) return fail(`The test request is not valid JSON: ${err}`); + } + if (callsVendor(a) && v.environment !== 'SIMULATOR' && !v.baseUrl) { + return fail(`${v.name} has no base URL for ${v.environment}.`); + } + const log = logFor( + this.nextLogId++, + new Date().toISOString().slice(0, 19), + v, + { ...a, requestBody: requestBody || a.requestBody }, + Number(a.successCodes.split(',')[0]), + 120 + Math.floor(Math.random() * 300), + `${v.code}-TEST-${this.nextLogId}` + ); + this.logList = [ + log, + ...this.logList + ]; + v.lastCallOn = log.time; + return reply(log); + } + + // ── logs ── + + logs(filter: ApiLogFilter): Observable<{ total: number; success: number; failed: number; items: ApiLog[] }> { + const q = (filter.search ?? '').trim().toLowerCase(); + const rows = this.logList.filter( + (l) => + (!filter.vendorId || String(l.vendorId) === String(filter.vendorId)) && + (!filter.direction || l.direction === filter.direction) && + (!filter.result || l.result === filter.result) && + (!q || `${l.reference} ${l.apiName} ${l.vendorName} ${l.path}`.toLowerCase().includes(q)) + ); + const offset = filter.offset ?? 0; + const limit = filter.limit ?? 20; + return reply({ + total: rows.length, + success: rows.filter((l) => l.result === 'SUCCESS').length, + failed: rows.filter((l) => l.result === 'FAILED').length, + items: rows.slice(offset, offset + limit) + }); + } + + /** Numbers for the vendors page: calls in the last 24 hours. */ + summary(): Observable<{ + callsToday: number; + failedToday: number; + perVendor: Record; + }> { + const since = Date.now() - DAY; + const recent = this.logList.filter((l) => new Date(l.time).getTime() >= since); + const perVendor: Record = {}; + for (const l of recent) { + const e = perVendor[l.vendorId] ?? { calls: 0, failed: 0 }; + e.calls++; + if (l.result === 'FAILED') e.failed++; + perVendor[l.vendorId] = e; + } + return reply({ + callsToday: recent.length, + failedToday: recent.filter((l) => l.result === 'FAILED').length, + perVendor + }); + } +} diff --git a/src/app/api-management/api-vendor-setup/api-vendor-setup.component.html b/src/app/api-management/api-vendor-setup/api-vendor-setup.component.html new file mode 100644 index 000000000..83a17dd44 --- /dev/null +++ b/src/app/api-management/api-vendor-setup/api-vendor-setup.component.html @@ -0,0 +1,747 @@ + + +
+
+
+

{{ vendor?.name || 'Vendor setup' }}

+

{{ vendor?.work }}

+
+
+ @if (vendor) { + {{ vendor.active ? 'Active' : 'Switched off' }} + {{ vendor.environment }} + } + ← All vendors +
+
+ + @if (error && !editing && !importOpen) { +
{{ error }}
+ } + @if (info && !editing) { +
{{ info }}
+ } + @if (loading && !vendor) { +
Loading…
+ } + + @if (vendor) { + @if (vendor.configNote) { +
{{ vendor.configNote }}
+ } + @if (vendor.links.length) { +
+ {{ vendor.code }} also has its own screens: + @for (l of vendor.links; track l.route) { + {{ l.label }} + } +
+ } + +
+ + + + +
+ + + @if (tab === 'connection') { +
+

Vendor and connection

+
+
+ + +
+
+ + +
+
+ + +
+
+ + + Every OUT / pull API path is added to this. PRODUCTION needs https://. +
+
+ + +
+ @if (form.authType === 'BASIC') { +
+ + +
+ } + @if (form.authType === 'API_KEY') { +
+ + +
+ } + @if (needsSecret()) { +
+ + + @if (vendor.hasSecret) { + + } +
+ } +
+ + + The bank's id at this vendor (SMS sender id, WhatsApp number id, CIC member code …). +
+
+ + +
+
+ + +
+ +
+ +

+ Default headers + — sent on every API of this vendor (an API can override one) +

+ + +
+ + +
+
+ } + + + @if (tab === 'apis') { +
+

+ API set + + + + +

+ @if (!vendor.apis.length) { +
+ No API yet. Add one, then fill it in by hand or read it from a JSON / cURL / Postman file. +
+ } @else { +
+ + + + + + + + + + + + + + + @for (a of vendor.apis; track a.id) { + + + + + + + + + + + } + +
APIDirectionCallUsed forHeaders / queryMappingActive
+ {{ a.name }} + + {{ + a.direction + }} + @if (a.mode) { + {{ a.mode === 'PULL' ? 'pull' : 'push' }} + } + {{ a.method }} {{ a.path }}{{ a.usedFor }}{{ a.headers.length }} / {{ a.queryParams.length }}{{ a.requestRows.length }} / {{ a.responseRows.length }} + {{ a.active ? 'Yes' : 'Off' }} + + + +
+
+ } +
+ } + + + @if (tab === 'test') { +
+

+ Test an API + simulated — nothing is sent to the vendor; the result is added to the logs +

+ @if (!vendor.apis.length) { +
Add an API first.
+ } @else { +
+ + +
+ @if (testPreview; as p) { +

The request

+
{{ p.method }} {{ p.url }}
+@for (h of p.headers; track h.key) {
+{{ h.key }}: {{ h.value }}
+}
+ } +
+ + +
+ + } +
+ @if (testResult) { +
+ {{ testResult.reference }} — HTTP {{ testResult.httpStatus }} in {{ testResult.durationMs }} ms +
+
+
+

Response headers

+
{{ testResult.responseHeaders }}
+
+
+

Response body

+
{{ testResult.response || '(empty)' }}
+
+
+ } + } + + + @if (tab === 'calls') { +
+

+ Recent calls + All logs +

+ @if (!calls.length) { +
No call yet.
+ } @else { +
+ + + + + + + + + + + + + + @for (l of calls; track l.id) { + + + + + + + + + + } + +
TimeAPIDirectionHTTPmsResultReference
{{ l.time | slice: 0 : 16 }}{{ l.apiName }} + {{ + l.direction + }} + {{ l.httpStatus }}{{ l.durationMs }} + {{ l.result }} + {{ l.reference }}
+
+ } +
+ } + } +
+ + +@if (editing) { +
+
+
+

+ {{ isNewApi ? 'New' : 'Edit' }} API + {{ + editing.direction + }} + @if (editing.mode) { + + {{ editing.mode === 'PULL' ? 'pull' : 'push' }} + } +

+ +
+

+ @if (editing.direction === 'OUT') { + OUT — the CBS calls the vendor: fill the request it sends and the response it expects. + } @else if (editing.mode === 'PULL') { + IN (pull) — the CBS asks the vendor for data on a schedule: fill the request it sends and the response it + reads. + } @else { + IN (push) — the vendor calls a CBS path: fill what the vendor sends us and what we answer. + } + Type everything in, or read it from a file with the import button — then check each tab. +

+ + @if (info) { +
{{ info }}
+ } + @if (error) { +
{{ error }}
+ } + +
+ + + +
+ + + @if (editorTab === 'basics') { +
+
+ + +
+
+ + +
+ @if (editing.direction === 'IN') { +
+ + +
+ } @else { +
+ } +
+ + +
+
+ + + Use {{ '{' }}name{{ '}' }} for a value that changes, e.g. /v21.0/{{ '{' }}senderId{{ + '}' + }}/messages. +
+
+ + +
+
+ + +
+
+ + +
+
+ + + @if (preview(editing); as p) { +

+ {{ outbound ? 'The call as it will go out' : 'The call the vendor will make to us' }} + (credentials masked) +

+
{{ p.method }} {{ p.url }}
+@for (h of p.headers; track h.key) {
+{{ h.key }}: {{ h.value }}
+}
+{{ p.body }}
+ } + } + + + @if (editorTab === 'request') { +

Path parameters

+ + +

Query parameters

+ + +

+ Headers + — this API's own; the vendor's default headers and its authentication are added automatically +

+ + +
+
+ + +
+
+ +
+ + + + {{ + outbound + ? 'The constant parts of the payload; the mapping below puts CBS values into it.' + : 'A sample of what arrives; the mapping reads values out of it.' + }} + +
+ +

+ {{ mappingTitle('request') }} + +

+
+ + + + + + + + + + + + + @for (r of editing.requestRows; track $index; let i = $index) { + + + + + + + + + } + +
{{ fromLabel('request') }}{{ toLabel('request') }}TransformFixed / default valueRequired
+ @if (fromIsCbs('request')) { + + } @else { + + } + + + + +
+
+ + } + + + @if (editorTab === 'response') { +
+
+ + + {{ + outbound ? 'Codes the vendor answers with when it worked.' : 'Codes the CBS answers with.' + }} +
+
+ +

+ {{ outbound ? 'Response headers' : 'Headers of our answer' }} +

+ + +
+
+ + +
+
+ + + What comes back when it fails (wrong key, rate limit …) — shown in the logs for a failed call. +
+
+ +

+ {{ mappingTitle('response') }} + +

+
+ + + + + + + + + + + + + @for (r of editing.responseRows; track $index; let i = $index) { + + + + + + + + + } + +
{{ fromLabel('response') }}{{ toLabel('response') }}TransformFixed / default valueRequired
+ @if (fromIsCbs('response')) { + + } @else { + + } + + + + +
+
+ + } + +
+ + +
+
+
+} + + +@if (importOpen && editing) { +
+
+

Import from JSON / cURL / Postman

+

+ Paste it or choose a file — + {{ outbound ? 'the request and the response' : 'what the vendor sends us and what we answer' }}. Any of these + works: a cURL command, a Postman collection or request, a HAR capture, an API definition + ({{ '{' }} method, url, headers, query, body, status … {{ '}' }}), or just the JSON payload. The method, URL / path, query parameters, headers, content type, payload, + status code, response headers, bodies and a first CBS mapping are filled in for you. Passwords, keys and tokens + are never kept. +

+ @if (importError) { +
{{ importError }}
+ } +
+
+ + +
+ + @if (importRequestFile) { + {{ importRequestFile }} + } +
+
+
+ + +
+ + @if (importResponseFile) { + {{ importResponseFile }} + } +
+
+
+
+ + +
+
+ + +
+
+
+} + + diff --git a/src/app/api-management/api-vendor-setup/api-vendor-setup.component.ts b/src/app/api-management/api-vendor-setup/api-vendor-setup.component.ts new file mode 100644 index 000000000..3171a7521 --- /dev/null +++ b/src/app/api-management/api-vendor-setup/api-vendor-setup.component.ts @@ -0,0 +1,546 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { CommonModule } from '@angular/common'; +import { Component, OnInit, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute, RouterModule } from '@angular/router'; + +import { ApiKeyValueEditorComponent } from '../api-key-value-editor/api-key-value-editor.component'; +import { ApiLogDetailComponent } from '../api-log-detail/api-log-detail.component'; +import { + guessRequestRows, + guessResponseRows, + ImportedRequest, + ImportedResponse, + parseRequestText, + parseResponseText +} from '../api-import.util'; +import { + API_AUTH_TYPES, + API_CBS_FIELDS, + API_CONTENT_TYPES, + API_METHODS, + API_TRANSFORMS, + ApiLog, + ApiManagementService, + ApiMappingRow, + ApiVendor, + blankApi, + buildRequestPreview, + callsVendor, + KeyValue, + pathParamNames, + RequestPreview, + VendorApi +} from '../api-management.service'; + +/** + * One vendor's setup: its connection (URL, authentication, default headers, credentials), its API set - each API IN or + * OUT with the full HTTP detail and the field mapping, typed in or read from a JSON / cURL / Postman / HAR file - a + * test call, and its recent calls. + */ +@Component({ + selector: 'mifosx-api-vendor-setup', + standalone: true, + imports: [ + CommonModule, + FormsModule, + RouterModule, + ApiLogDetailComponent, + ApiKeyValueEditorComponent + ], + templateUrl: './api-vendor-setup.component.html', + styleUrls: ['../../credit-bureau/cic-shared.scss'] +}) +export class ApiVendorSetupComponent implements OnInit { + private service = inject(ApiManagementService); + private route = inject(ActivatedRoute); + + vendor: ApiVendor | null = null; + tab: 'connection' | 'apis' | 'test' | 'calls' = 'connection'; + loading = false; + error: string | null = null; + info: string | null = null; + + // connection + form: any = {}; + saving = false; + + // API set + editing: VendorApi | null = null; + isNewApi = false; + savingApi = false; + editorTab: 'basics' | 'request' | 'response' = 'basics'; + + // import from JSON / cURL / Postman / HAR + importOpen = false; + importRequestText = ''; + importResponseText = ''; + importRequestFile = ''; + importResponseFile = ''; + importReplace = true; + importGenerate = true; + importError: string | null = null; + + // test + testApiId: number | null = null; + testBody = ''; + testing = false; + testResult: ApiLog | null = null; + + // calls + calls: ApiLog[] = []; + openLog: ApiLog | null = null; + + readonly authTypes = Object.entries(API_AUTH_TYPES).map( + ([ + key, + label + ]) => ({ key, label }) + ); + readonly contentTypes = Object.entries(API_CONTENT_TYPES).map( + ([ + key, + label + ]) => ({ key, label }) + ); + readonly transforms = Object.entries(API_TRANSFORMS).map( + ([ + key, + label + ]) => ({ key, label }) + ); + readonly cbsFields = Object.entries(API_CBS_FIELDS).map( + ([ + key, + label + ]) => ({ key, label }) + ); + readonly methods = API_METHODS; + + ngOnInit(): void { + this.load(Number(this.route.snapshot.paramMap.get('id'))); + } + + load(id: number): void { + this.loading = true; + this.service.vendor(id).subscribe({ + next: (v) => { + this.vendor = v; + this.loading = false; + this.resetForm(v); + if (!this.testApiId || !v.apis.some((a) => a.id === this.testApiId)) this.testApiId = v.apis[0]?.id ?? null; + this.fillTestBody(); + this.loadCalls(); + }, + error: (e) => { + this.loading = false; + this.error = e?.error?.defaultUserMessage ?? 'Could not load the vendor.'; + } + }); + } + + private resetForm(v: ApiVendor): void { + this.form = { + name: v.name, + work: v.work, + environment: v.environment, + baseUrl: v.baseUrl, + authType: v.authType, + username: v.username, + apiKeyHeader: v.apiKeyHeader, + secret: '', + clearSecret: false, + senderId: v.senderId, + timeoutMs: v.timeoutMs, + retries: v.retries, + defaultHeaders: JSON.parse(JSON.stringify(v.defaultHeaders)), + active: v.active + }; + } + + loadCalls(): void { + if (!this.vendor) return; + this.service.logs({ vendorId: String(this.vendor.id), limit: 12 }).subscribe((r) => (this.calls = r.items)); + } + + // ── connection ── + + needsSecret(): boolean { + return this.form.authType !== 'NONE'; + } + + secretLabel(): string { + const a = this.form.authType; + return a === 'BASIC' + ? 'Password' + : a === 'API_KEY' + ? 'API key' + : a === 'BEARER_TOKEN' + ? 'Bearer token' + : 'Client secret'; + } + + saveConnection(): void { + if (!this.vendor) return; + this.saving = true; + this.error = null; + this.info = null; + const body = { ...this.form }; + if (!body.secret) delete body.secret; + this.service.saveVendor(this.vendor.id, body).subscribe({ + next: (v) => { + this.saving = false; + this.vendor = v; + this.resetForm(v); + this.info = `${v.name} connection saved.`; + }, + error: (e) => { + this.saving = false; + this.error = e?.error?.defaultUserMessage ?? 'Could not save the connection.'; + } + }); + } + + // ── API set ── + + newApi(direction: 'IN' | 'OUT'): void { + this.isNewApi = true; + this.error = null; + this.info = null; + this.editorTab = 'basics'; + this.editing = blankApi(direction); + } + + editApi(a: VendorApi): void { + this.isNewApi = false; + this.error = null; + this.info = null; + this.editorTab = 'basics'; + this.editing = JSON.parse(JSON.stringify(a)); + } + + /** True when the CBS makes the HTTP call (OUT, or IN pull); false for an IN push webhook. */ + get outbound(): boolean { + return !!this.editing && callsVendor(this.editing); + } + + directionChanged(): void { + if (!this.editing) return; + this.editing.mode = this.editing.direction === 'IN' ? (this.editing.mode ?? 'PUSH') : undefined; + } + + /** Keeps the path-parameter table in step with the {names} typed in the path. */ + pathChanged(): void { + if (!this.editing) return; + const names = pathParamNames(this.editing.path); + const kept = this.editing.pathParams.filter((p) => names.includes(p.key)); + for (const n of names) { + if (!kept.some((p) => p.key === n)) + kept.push({ key: n, value: '', description: '', enabled: true, secret: false }); + } + this.editing.pathParams = kept; + } + + rowsOf(side: 'request' | 'response'): ApiMappingRow[] { + if (!this.editing) return []; + return side === 'request' ? this.editing.requestRows : this.editing.responseRows; + } + + /** Whether the left column of a mapping table holds a CBS value (a pick list) or a path in the vendor's JSON. */ + fromIsCbs(side: 'request' | 'response'): boolean { + return this.outbound ? side === 'request' : side === 'response'; + } + + mappingTitle(side: 'request' | 'response'): string { + if (this.outbound) return side === 'request' ? 'Request mapping (CBS → vendor)' : 'Response mapping (vendor → CBS)'; + return side === 'request' ? 'Incoming data mapping (vendor → CBS)' : 'Answer mapping (CBS → vendor)'; + } + + fromLabel(side: 'request' | 'response'): string { + return this.fromIsCbs(side) ? 'CBS value' : "Vendor's JSON path"; + } + + toLabel(side: 'request' | 'response'): string { + if (this.fromIsCbs(side)) return side === 'request' ? "Vendor's request path" : 'Answer path'; + return 'CBS field'; + } + + addRow(side: 'request' | 'response'): void { + this.rowsOf(side).push({ from: '', to: '', transform: 'NONE', defaultValue: '', mandatory: false }); + } + + removeRow(side: 'request' | 'response', i: number): void { + this.rowsOf(side).splice(i, 1); + } + + /** Builds mapping rows from the payload typed or imported for that side. */ + generateRows(side: 'request' | 'response'): void { + if (!this.editing || !this.vendor) return; + const body = side === 'request' ? this.editing.requestBody : this.editing.responseBody; + // Reading values OUT of a payload (vendor → CBS) vs putting values INTO one (CBS → vendor). + const reading = !this.fromIsCbs(side); + const rows = reading ? guessResponseRows(body, this.vendor.code) : guessRequestRows(body); + if (!rows.length) { + this.error = 'That payload has no JSON values to build a mapping from.'; + return; + } + this.error = null; + const target = this.rowsOf(side); + const have = new Set(target.map((r) => r.to)); + target.push(...rows.filter((r) => !have.has(r.to))); + this.info = `${rows.length} mapping row(s) read from the payload. Review the CBS fields and adjust.`; + } + + saveApi(): void { + if (!this.vendor || !this.editing) return; + this.savingApi = true; + this.error = null; + this.service.saveApi(this.vendor.id, this.editing).subscribe({ + next: (a) => { + this.savingApi = false; + this.info = `${a.name} saved.`; + this.editing = null; + this.load(this.vendor!.id); + }, + error: (e) => { + this.savingApi = false; + this.error = e?.error?.defaultUserMessage ?? 'Could not save the API.'; + } + }); + } + + deleteApi(a: VendorApi): void { + if (!this.vendor || !confirm(`Delete the API "${a.name}"?`)) return; + this.service.deleteApi(this.vendor.id, a.id).subscribe(() => { + this.editing = null; + this.info = `${a.name} deleted.`; + this.load(this.vendor!.id); + }); + } + + /** The request this API would send (or receive), as the Test tab and the editor show it. */ + preview(a: VendorApi | null): RequestPreview | null { + return this.vendor && a ? buildRequestPreview(this.vendor, a) : null; + } + + // ── import ── + + openImport(): void { + this.importOpen = true; + this.importError = null; + this.importReplace = this.isNewApi; + this.importRequestText = ''; + this.importResponseText = ''; + this.importRequestFile = ''; + this.importResponseFile = ''; + } + + onFile(event: Event, which: 'request' | 'response'): void { + const input = event.target as HTMLInputElement; + const file = input.files?.[0]; + if (!file) return; + if (file.size > 5 * 1024 * 1024) { + this.importError = 'The file is larger than 5 MB.'; + return; + } + const reader = new FileReader(); + reader.onload = () => { + const text = String(reader.result ?? ''); + if (which === 'request') { + this.importRequestText = text; + this.importRequestFile = file.name; + } else { + this.importResponseText = text; + this.importResponseFile = file.name; + } + }; + reader.readAsText(file); + input.value = ''; + } + + applyImport(): void { + if (!this.editing || !this.vendor) return; + this.importError = null; + let req: ImportedRequest | null = null; + let res: ImportedResponse | null = null; + try { + if (this.importRequestText.trim()) req = parseRequestText(this.importRequestText); + if (this.importResponseText.trim()) res = parseResponseText(this.importResponseText); + else if (req?.response) res = req.response; + } catch (e: any) { + this.importError = e?.message ?? 'Could not read the file.'; + return; + } + if (!req && !res) { + this.importError = 'Paste or choose a request, a response, or both.'; + return; + } + + const a = this.editing; + const done: string[] = []; + const notes: string[] = [ + ...(req?.notes ?? []), + ...(res?.notes ?? []) + ]; + const replace = this.importReplace; + + const mergePairs = (target: KeyValue[], incoming: KeyValue[]): KeyValue[] => { + if (replace) return incoming; + const out = [...target]; + for (const p of incoming) { + const i = out.findIndex((x) => x.key.toLowerCase() === p.key.toLowerCase()); + if (i >= 0) out[i] = p; + else out.push(p); + } + return out; + }; + + if (req) { + if (req.method && this.methods.includes(req.method)) { + a.method = req.method as VendorApi['method']; + done.push(`method ${req.method}`); + } + if (req.path && req.path !== '/') { + a.path = this.stripVendorBasePath(req.path); + done.push(`path ${a.path}`); + } + if (req.baseUrl) { + if (!this.form.baseUrl) { + this.form.baseUrl = req.baseUrl; + notes.push(`Base URL ${req.baseUrl} was put on the Connection tab - save it there.`); + } else if (this.form.baseUrl.replace(/\/+$/, '') !== req.baseUrl) { + notes.push( + `The file's host (${req.baseUrl}) is not this vendor's base URL (${this.form.baseUrl}); only the path was used.` + ); + } + } + if (req.queryParams.length) { + a.queryParams = mergePairs(a.queryParams, req.queryParams); + done.push(`${req.queryParams.length} query parameter(s)`); + } + if (req.headers.length) { + a.headers = mergePairs(a.headers, req.headers); + done.push(`${req.headers.length} header(s)`); + } + if (req.contentType) { + a.contentType = Object.keys(API_CONTENT_TYPES).includes(req.contentType) ? req.contentType : a.contentType; + done.push(`content type ${a.contentType}`); + } + if (req.body !== undefined) { + a.requestBody = req.body; + done.push('request payload'); + } + if (!a.name.trim() && a.path) { + const last = + a.path + .split('/') + .filter((s) => s && !s.includes('{')) + .pop() ?? 'API'; + a.name = last.charAt(0).toUpperCase() + last.slice(1).replace(/[-_]+/g, ' '); + } + this.pathChanged(); + } + + if (res) { + const failure = res.status !== undefined && (res.status < 200 || res.status > 299); + if (res.status !== undefined && !failure) { + a.successCodes = String(res.status); + done.push(`success code ${res.status}`); + } + if (res.headers.length) { + a.responseHeaders = mergePairs(a.responseHeaders, res.headers); + done.push(`${res.headers.length} response header(s)`); + } + if (res.body !== undefined) { + if (failure) { + a.errorBody = res.body; + done.push(`error body (HTTP ${res.status})`); + } else { + a.responseBody = res.body; + done.push('response body'); + } + } + if (res.errorBody !== undefined) { + a.errorBody = res.errorBody; + done.push('error body'); + } + } + + if (this.importGenerate) { + const addRows = (side: 'request' | 'response', body: string) => { + const reading = !this.fromIsCbs(side); + const rows = reading ? guessResponseRows(body, this.vendor!.code) : guessRequestRows(body); + if (!rows.length) return; + const target = replace ? [] : this.rowsOf(side); + const have = new Set(target.map((r) => r.to)); + const merged = [ + ...target, + ...rows.filter((r) => !have.has(r.to)) + ]; + if (side === 'request') a.requestRows = merged; + else a.responseRows = merged; + done.push(`${rows.length} ${side === 'request' ? 'request' : 'response'} mapping row(s)`); + }; + if (req?.body !== undefined) addRows('request', a.requestBody); + if (res?.body !== undefined && !(res.status !== undefined && (res.status < 200 || res.status > 299))) + addRows('response', a.responseBody); + } + + this.importOpen = false; + this.error = null; + this.info = `Read from ${[ + req?.source, + res?.source + ] + .filter(Boolean) + .join(' + ')}: ${done.join(', ') || 'nothing new'}. ${notes.join(' ')} Review each tab, then Save API.`; + } + + /** A path read from a full URL may repeat the path already in the vendor's base URL. */ + private stripVendorBasePath(path: string): string { + const base = String(this.form.baseUrl ?? ''); + const m = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/]+(\/.+)$/.exec(base.replace(/\/+$/, '')); + if (m && path.startsWith(m[1] + '/')) return path.slice(m[1].length); + return path; + } + + // ── test ── + + get testApi(): VendorApi | null { + return this.vendor?.apis.find((x) => x.id === this.testApiId) ?? null; + } + + fillTestBody(): void { + this.testBody = this.testApi?.requestBody ?? '{}'; + this.testResult = null; + } + + get testPreview(): RequestPreview | null { + const a = this.testApi; + return this.vendor && a ? buildRequestPreview(this.vendor, { ...a, requestBody: this.testBody }) : null; + } + + runTest(): void { + if (!this.vendor || !this.testApiId) return; + this.testing = true; + this.error = null; + this.service.testApi(this.vendor.id, this.testApiId, this.testBody).subscribe({ + next: (log) => { + this.testing = false; + this.testResult = log; + this.loadCalls(); + }, + error: (e) => { + this.testing = false; + this.error = e?.error?.defaultUserMessage ?? 'Test failed.'; + } + }); + } +} diff --git a/src/app/api-management/api-vendors/api-vendors.component.html b/src/app/api-management/api-vendors/api-vendors.component.html new file mode 100644 index 000000000..dbc0209e3 --- /dev/null +++ b/src/app/api-management/api-vendors/api-vendors.component.html @@ -0,0 +1,172 @@ + + +
+
+
+

API Management

+

+ Connect any API service to the CBS the same way: Vendor → Work → API set. IN = data comes into the + CBS from the vendor (CIC alerts, delivery reports, replies). OUT = the CBS calls the vendor (send an SMS + or WhatsApp message). +

+
+
+ API call logs + +
+
+ + @if (error && !adding) { +
{{ error }}
+ } + @if (info) { +
{{ info }}
+ } + +
+
+
Vendors
+
{{ vendors.length }}
+
{{ activeVendors }} active
+
+
+
APIs
+
{{ totalIn + totalOut }}
+
{{ totalIn }} IN · {{ totalOut }} OUT
+
+
+
Calls in 24 hours
+
{{ summary.callsToday }}
+
both directions
+
+
+
Failed in 24 hours
+
{{ summary.failedToday }}
+
see the call logs
+
+
+ +
+

Vendors

+ @if (loading && !vendors.length) { +
Loading…
+ } @else { +
+ + + + + + + + + + + + + + + @for (v of vendors; track v.id) { + + + + + + + + + + + @if (v.links.length) { + + + + } + } + +
VendorWorkAPIsEnvironment24 h callsLast callActive
+ {{ v.name }} +
{{ v.code }}
+
{{ v.work }} + @if (count(v, 'IN')) { + IN × {{ count(v, 'IN') }} + } + @if (count(v, 'OUT')) { + OUT × {{ count(v, 'OUT') }} + } + @if (!v.apis.length) { + none yet + } + + {{ v.environment }} + + {{ calls(v).calls }} + @if (calls(v).failed) { + · {{ calls(v).failed }} failed + } + {{ v.lastCallOn ? (v.lastCallOn | slice: 0 : 16) : '—' }} + + + + + + Set up +
+ {{ v.code }} also has: + @for (l of v.links; track l.route) { + {{ l.label }} + } +
+
+ } +
+ + @if (adding) { +
+
+

Add an API vendor

+ @if (error) { +
{{ error }}
+ } +
+
+ + +
+
+ + +
+
+ + +
+
+

+ Next you set up the connection and add its APIs, each marked IN or OUT. +

+
+ + +
+
+
+ } +
diff --git a/src/app/api-management/api-vendors/api-vendors.component.ts b/src/app/api-management/api-vendors/api-vendors.component.ts new file mode 100644 index 000000000..bb4bda497 --- /dev/null +++ b/src/app/api-management/api-vendors/api-vendors.component.ts @@ -0,0 +1,121 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { CommonModule } from '@angular/common'; +import { Component, OnInit, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Router, RouterModule } from '@angular/router'; + +import { ApiManagementService, ApiVendor } from '../api-management.service'; + +/** Every outside API service the CBS uses: vendor, its work, its APIs (IN / OUT) and whether it is switched on. */ +@Component({ + selector: 'mifosx-api-vendors', + standalone: true, + imports: [ + CommonModule, + FormsModule, + RouterModule + ], + templateUrl: './api-vendors.component.html', + styleUrls: ['../../credit-bureau/cic-shared.scss'] +}) +export class ApiVendorsComponent implements OnInit { + private service = inject(ApiManagementService); + private router = inject(Router); + + vendors: ApiVendor[] = []; + summary: { callsToday: number; failedToday: number; perVendor: Record } = { + callsToday: 0, + failedToday: 0, + perVendor: {} + }; + loading = false; + error: string | null = null; + info: string | null = null; + + adding = false; + saving = false; + form: { name: string; work: string; environment: ApiVendor['environment'] } = this.blankForm(); + + ngOnInit(): void { + this.load(); + } + + private blankForm() { + return { name: '', work: '', environment: 'SIMULATOR' as ApiVendor['environment'] }; + } + + load(): void { + this.loading = true; + this.service.vendors().subscribe((v) => { + this.vendors = v; + this.loading = false; + }); + this.service.summary().subscribe((s) => (this.summary = s)); + } + + count(v: ApiVendor, direction: 'IN' | 'OUT'): number { + return v.apis.filter((a) => a.direction === direction).length; + } + + get totalIn(): number { + return this.vendors.reduce((s, v) => s + this.count(v, 'IN'), 0); + } + + get totalOut(): number { + return this.vendors.reduce((s, v) => s + this.count(v, 'OUT'), 0); + } + + get activeVendors(): number { + return this.vendors.filter((v) => v.active).length; + } + + calls(v: ApiVendor): { calls: number; failed: number } { + return this.summary.perVendor[v.id] ?? { calls: 0, failed: 0 }; + } + + toggle(v: ApiVendor, event: Event): void { + const want = (event.target as HTMLInputElement).checked; + (event.target as HTMLInputElement).checked = v.active; + this.error = null; + this.info = null; + this.service.saveVendor(v.id, { active: want }).subscribe({ + next: (u) => { + v.active = u.active; + this.info = `${u.name} is now ${u.active ? 'active' : 'switched off'}.`; + }, + error: (e) => (this.error = e?.error?.defaultUserMessage ?? 'Could not change the vendor.') + }); + } + + startAdd(): void { + this.adding = true; + this.form = this.blankForm(); + this.error = null; + } + + saveNew(): void { + this.saving = true; + this.error = null; + this.service.addVendor(this.form).subscribe({ + next: (v) => { + this.saving = false; + this.adding = false; + this.router.navigate([ + '/api-management/vendors', + v.id + ]); + }, + error: (e) => { + this.saving = false; + this.error = e?.error?.defaultUserMessage ?? 'Could not add the vendor.'; + } + }); + } +} diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 00ebb2abc..1f9b2ba1d 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -74,10 +74,12 @@ import { NpaModule } from './npa/npa.module'; import { ReconciliationModule } from './reconciliation/reconciliation.module'; import { LoanOriginationModule } from './loan-origination/loan-origination.module'; import { CreditBureauModule } from './credit-bureau/credit-bureau.module'; +import { ApiManagementModule } from './api-management/api-management.module'; import { DigitalModule } from './digital/digital.module'; import { LetterMgmtModule } from './letter-mgmt/letter-mgmt.module'; import { AccountsModule } from './accounts/accounts.module'; import { ElectionModule } from './election/election.module'; +import { LoanEligibilityModule } from './loan-eligibility/loan-eligibility.module'; import { SponsorDDModule } from './sponsor-dd/sponsor-dd.module'; import { SponsorDDReconciliationModule } from './sponsor-dd-reconciliation/sponsor-dd-reconciliation.module'; import { PortalModule } from '@angular/cdk/portal'; @@ -191,10 +193,12 @@ export function HttpLoaderFactory(http: HttpClient) { ReconciliationModule, LoanOriginationModule, CreditBureauModule, + ApiManagementModule, DigitalModule, LetterMgmtModule, AccountsModule, ElectionModule, + LoanEligibilityModule, SponsorDDModule, SponsorDDReconciliationModule, AppRoutingModule, diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index 3223ce52d..dfa7bfad3 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -407,9 +407,14 @@ Loan Origination - + Credit Bureau - Advances @@ -512,10 +517,15 @@ >Loans - + Credit BureauCIC - - -
- -
-
Bureau Enquiry
- - - - -
- -
-
CIC Setup
- - -
-
-
-
diff --git a/src/app/core/shell/toolbar/toolbar.component.html b/src/app/core/shell/toolbar/toolbar.component.html index 450624b69..fbb86e720 100644 --- a/src/app/core/shell/toolbar/toolbar.component.html +++ b/src/app/core/shell/toolbar/toolbar.component.html @@ -154,7 +154,7 @@ Lending & Risk
- + +
+
+ + API Management +
+ + + + +