<?xml version="1.0" encoding="UTF-8"?>
<!--
  Integration API code template library for Mirth Connect 4.x and Open Integration Engine (OIE).
  api-platform §5.12 and §17: tokens are fetched and requests are sent at DISPATCH time, inside the
  JavaScript Writer destination, never in a transformer, so queued retries are re-authenticated.

  Configuration map keys (Settings > Configuration Map; secrets in the engine's secret store):
    ingest.api.baseUrl        https://api.<domain> or the sandbox base URL from the portal
    ingest.api.clientId       the app's client id (edf_test_cli_… in the sandbox)
    ingest.api.clientSecret   the client secret (or use ingest.api.privateKeyJwt below)
    ingest.api.scope          space-separated scopes, e.g. "hl7v2:write events:write"
    ingest.api.site           optional site code (?site=); otherwise MSH-4 maps through the profile
    ingest.api.userAgent      e.g. "Mirth-4.5.0 ADT-sender/1.0"
-->
<codeTemplateLibrary version="4.5.0">
  <id>7f3c2b1a-0e4d-4c5b-9a8f-1d2e3f4a5b60</id>
  <name>Integration API</name>
  <revision>1</revision>
  <lastModified>
    <time>1789387331000</time>
    <timezone>America/Toronto</timezone>
  </lastModified>
  <description>Token cache, dispatch-time send, Retry-After pause and ACK classification for the integration API.</description>
  <includeNewChannels>false</includeNewChannels>
  <enabledChannelIds/>
  <disabledChannelIds/>
  <codeTemplates>
    <codeTemplate version="4.5.0">
      <id>7f3c2b1a-0e4d-4c5b-9a8f-1d2e3f4a5b61</id>
      <name>ingestApi</name>
      <revision>1</revision>
      <lastModified>
        <time>1789387331000</time>
        <timezone>America/Toronto</timezone>
      </lastModified>
      <contextSet>
        <delegate>
          <contextType>DESTINATION_DISPATCHER</contextType>
          <contextType>CHANNEL_DEPLOY</contextType>
        </delegate>
      </contextSet>
      <properties class="com.mirth.connect.model.codetemplates.BasicCodeTemplateProperties">
        <type>FUNCTION</type>
        <code><![CDATA[
/**
 * Reads a configuration map value; throws when a required one is missing.
 */
function ingestCfg(key, optional) {
  var value = configurationMap.get(key);
  if ((value === null || value === undefined || String(value) === '') && !optional) {
    throw 'Missing configuration map key ' + key;
  }
  return value === null || value === undefined ? '' : String(value);
}

/**
 * Reads a whole HTTP response body as UTF-8 (bounded to 1 MiB).
 */
function ingestReadBody(conn) {
  var stream = null;
  try {
    stream = conn.getResponseCode() >= 400 ? conn.getErrorStream() : conn.getInputStream();
  } catch (e) {
    stream = conn.getErrorStream();
  }
  if (stream === null) {
    return '';
  }
  var out = new java.io.ByteArrayOutputStream();
  var buffer = java.lang.reflect.Array.newInstance(java.lang.Byte.TYPE, 8192);
  var total = 0;
  var n;
  while ((n = stream.read(buffer)) !== -1 && total < 1048576) {
    out.write(buffer, 0, n);
    total += n;
  }
  stream.close();
  return new java.lang.String(out.toByteArray(), 'UTF-8');
}

/**
 * An OAuth 2.0 client-credentials token, cached in the global map until 30 s before expiry.
 * `forceRefresh` after a 401 token_expired / token_revoked.
 */
function ingestAccessToken(forceRefresh) {
  var now = java.lang.System.currentTimeMillis();
  var cached = globalMap.get('ingest.api.token');
  if (!forceRefresh && cached !== null && cached !== undefined && cached.expiresAt - 30000 > now) {
    return cached.value;
  }
  var url = new java.net.URL(ingestCfg('ingest.api.baseUrl') + '/oauth/token');
  var conn = url.openConnection();
  conn.setRequestMethod('POST');
  conn.setDoOutput(true);
  conn.setInstanceFollowRedirects(false);
  conn.setConnectTimeout(10000);
  conn.setReadTimeout(15000);
  conn.setRequestProperty('Content-Type', 'application/x-www-form-urlencoded');
  var basic = java.util.Base64.getEncoder().encodeToString(
    new java.lang.String(
      java.net.URLEncoder.encode(ingestCfg('ingest.api.clientId'), 'UTF-8') + ':' +
      java.net.URLEncoder.encode(ingestCfg('ingest.api.clientSecret'), 'UTF-8')
    ).getBytes('UTF-8')
  );
  conn.setRequestProperty('Authorization', 'Basic ' + basic);
  var form = 'grant_type=client_credentials&scope=' +
    java.net.URLEncoder.encode(ingestCfg('ingest.api.scope'), 'UTF-8');
  var writer = conn.getOutputStream();
  writer.write(new java.lang.String(form).getBytes('UTF-8'));
  writer.close();
  var status = conn.getResponseCode();
  var body = ingestReadBody(conn);
  if (status !== 200) {
    // Never log the body of a token response; the status and error code are enough.
    var code = '';
    try { code = JSON.parse(body).error; } catch (e) { code = 'unparseable'; }
    throw 'Token endpoint answered ' + status + ' ' + code;
  }
  var token = JSON.parse(body);
  globalMap.put('ingest.api.token', {
    expiresAt: now + (token.expires_in * 1000),
    value: token.access_token
  });
  return token.access_token;
}

/**
 * Whether the channel is inside a Retry-After pause (a 503 or 429 answered earlier).
 */
function ingestPaused() {
  var until = globalChannelMap.get('ingest.api.pauseUntil');
  return until !== null && until !== undefined && java.lang.System.currentTimeMillis() < until;
}

function ingestPause(retryAfterHeader) {
  var seconds = parseInt(retryAfterHeader, 10);
  if (isNaN(seconds) || seconds < 1) {
    seconds = 30;
  }
  globalChannelMap.put('ingest.api.pauseUntil', java.lang.System.currentTimeMillis() + Math.min(seconds, 3600) * 1000);
}

/**
 * Sends one request at dispatch time. Returns { status, body, contentType, retryAfter }.
 * A 401 token_expired or token_revoked refreshes the token once and resends the same request.
 */
function ingestSend(path, contentType, payload, idempotencyKey) {
  var attempt = function (token) {
    var url = new java.net.URL(ingestCfg('ingest.api.baseUrl') + path);
    var conn = url.openConnection();
    conn.setRequestMethod('POST');
    conn.setDoOutput(true);
    conn.setInstanceFollowRedirects(false);
    conn.setConnectTimeout(10000);
    conn.setReadTimeout(30000);
    conn.setRequestProperty('Authorization', 'Bearer ' + token);
    conn.setRequestProperty('Content-Type', contentType);
    conn.setRequestProperty('User-Agent', ingestCfg('ingest.api.userAgent', true) || 'Mirth-Connect');
    if (idempotencyKey) {
      conn.setRequestProperty('Idempotency-Key', idempotencyKey);
    }
    var writer = conn.getOutputStream();
    writer.write(new java.lang.String(payload).getBytes('UTF-8'));
    writer.close();
    var status = conn.getResponseCode();
    return {
      body: ingestReadBody(conn),
      contentType: String(conn.getHeaderField('Content-Type') || ''),
      retryAfter: conn.getHeaderField('Retry-After'),
      status: status
    };
  };
  var result = attempt(ingestAccessToken(false));
  if (result.status === 401) {
    var code = '';
    try { code = JSON.parse(result.body).code; } catch (e) { code = ''; }
    if (code === 'token_expired' || code === 'token_revoked' || code === 'invalid_token') {
      result = attempt(ingestAccessToken(true));
    }
  }
  return result;
}

/**
 * MSA-1 of an ACK (AA, AE, AR, CA, CE, CR) or '' when there is none.
 */
function ingestAckCode(ack) {
  var segments = String(ack).split(/\r\n|\r|\n/);
  for (var i = 0; i < segments.length; i++) {
    if (segments[i].indexOf('MSA') === 0) {
      return segments[i].substring(4, 6);
    }
  }
  return '';
}

/**
 * Maps an API outcome to a Mirth Response. 503/429 pause and queue (never an error); AE/AR go
 * to the error channel and never hold the queue; 2xx is SENT.
 */
function ingestResponse(result, errorChannelName) {
  if (result.status === 503 || result.status === 429) {
    ingestPause(result.retryAfter);
    return new Response(QUEUED, '', 'Transient ' + result.status + '; retry after ' + result.retryAfter + ' s', '');
  }
  if (result.status >= 500) {
    return new Response(QUEUED, '', 'Server error ' + result.status + '; queued for retry', '');
  }
  if (result.status >= 200 && result.status < 300) {
    var ack = ingestAckCode(result.body);
    if (ack === 'AE' || ack === 'AR') {
      if (errorChannelName) {
        router.routeMessage(errorChannelName, result.body);
      }
      return new Response(SENT, result.body, 'ACK ' + ack + ' routed to ' + errorChannelName, '');
    }
    return new Response(SENT, result.body, 'Accepted', '');
  }
  if (errorChannelName) {
    router.routeMessage(errorChannelName, result.body);
  }
  return new Response(ERROR, result.body, 'Refused ' + result.status, '');
}
]]></code>
      </properties>
    </codeTemplate>
  </codeTemplates>
</codeTemplateLibrary>
