VII Patterns

A Gateway to an External Service

The shop takes money through a payment provider: an outside company with an HTTP API, a secret key, and rules of its own. Three components need it — the one that starts a payment, the one that receives the provider’s notifications, the one that tracks memberships.

The naive way is to let each of them call the provider with context.fetch. Then the secret key is configured in three places, each component learns the provider’s statuses and quirks on its own, each test of each of them needs the key and the network, and the day the provider changes its API you edit all three.

§One component speaks to the provider

payments.gateway is the only component that knows the provider exists. It has no entity. Its operations are the questions the shop asks, in the shop’s words, and everybody else calls them like any other operation.

billing.topups ──────┐
payments.webhooks ───┼── call ──> payments.gateway ── HTTPS ──> the payment provider
billing.memberships ─┘            (key, signing secret)

The three callers are built elsewhere: payments.webhooks in Receiving Webhooks, billing.topups there and in Taking a Payment, End to End, billing.memberships in Mirroring a Provider’s Object.

# components/payments.gateway/manifest.toa.yaml
name: gateway
namespace: payments

operations:
  checkout:                 # a payment page for the customer
    type: effect
    errors: [INVALID_ORIGIN, UNAVAILABLE]
    input:
      type: object
      properties:
        customer: { type: string }
        amount: { type: integer, minimum: 100 }
        success: { type: string }
        cancel: { type: string }
      required: [customer, amount, success, cancel]
    output:
      type: object
      properties:
        url: { type: string }
  retrieve:                 # a checkout as the provider holds it now
    type: effect
    errors: [UNKNOWN_CHECKOUT, UNAVAILABLE]
    input:
      type: object
      properties:
        checkout: { type: string }
      required: [checkout]
  decode:                   # a notification, once its signature is verified
    type: computation
    errors: [DECODE]
    input:
      type: object
      properties:
        payload: { type: string }
        signature: { type: string }
      required: [payload, signature]

configuration:
  schema:
    properties:
      url: { type: string, format: uri }
      key: { type: string, format: secret }
      signing: { type: string, format: secret }
      origins:
        type: array
        items: { type: string }
    required: [url, origins]

The key and the signing secret are secrets: the Context, which gains these values under its configuration, holds only their names.

# context.toa.yaml
configuration:
  payments.gateway:
    url: https://sandbox.payments.example.com
    url@production: https://payments.example.com
    key: $PAYMENTS_KEY
    signing: $PAYMENTS_SIGNING_SECRET
    origins: [https://store.example.com]

Each operation is a few lines, because the work is behind one function that picks who answers:

// components/payments.gateway/operations/checkout.ts
import type { CheckoutInput, Context } from '../types/index.d.ts'
import { connect } from '../lib/connect.ts'

export async function effect (input: CheckoutInput, context: Context) {
  const { origins } = context.configuration
  const allowed = (url: string) => origins.some((origin) => url.startsWith(`${origin}/`))

  if (!allowed(input.success) || !allowed(input.cancel)) return new Error('INVALID_ORIGIN')

  return await connect(context).checkout(input)
}
// components/payments.gateway/operations/retrieve.ts
import type { Context, RetrieveInput } from '../types/index.d.ts'
import { connect } from '../lib/connect.ts'

export async function effect (input: RetrieveInput, context: Context) {
  return await connect(context).retrieve(input.checkout)
}
// components/payments.gateway/operations/decode.ts
import type { Context, DecodeInput } from '../types/index.d.ts'
import { connect } from '../lib/connect.ts'

export async function computation (input: DecodeInput, context: Context) {
  return connect(context).decode(input.payload, input.signature)
}

checkout refuses a return address outside the shop’s own sites. Without the check, anyone could make the shop’s payment page send a customer wherever they like afterwards.

§The real client

// components/payments.gateway/lib/client.ts
import type { CheckoutInput, Context } from '../types/index.d.ts'
import { createHmac, timingSafeEqual } from 'node:crypto'

export function client (context: Context, key: string) {
  const { url, signing } = context.configuration

  async function request (method: string, path: string, body?: unknown) {
    let response

    try {
      response = await context.fetch(url + path, {
        method,
        headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
        body: body === undefined ? undefined : JSON.stringify(body),
        signal: AbortSignal.timeout(10_000)
      })
    } catch {
      return new Error('UNAVAILABLE') // not reached, or no answer in ten seconds
    }

    if (response.status === 404) {
      await response.body?.cancel()

      return null
    }

    if (response.status === 429 || response.status >= 500) {
      await response.body?.cancel()

      return new Error('UNAVAILABLE')
    }

    if (!response.ok) throw new Error(`The payment provider answered ${response.status}`)

    return await response.json()
  }

  return {
    async checkout ({ customer, amount, success, cancel }: CheckoutInput) {
      const created = await request('POST', '/checkouts', {
        amount,
        currency: 'USD',
        success_url: success,
        cancel_url: cancel,
        metadata: { customer }
      })

      return created instanceof Error ? created : { url: created.url }
    },

    async retrieve (id: string) {
      const found = await request('GET', `/checkouts/${encodeURIComponent(id)}`)

      return found ?? new Error('UNKNOWN_CHECKOUT')
    },

    decode (payload: string, signature: string) {
      const secret = signing?.unwrap()

      if (secret === undefined || secret === '')
        throw new Error('The signing secret of the payment provider is not configured')

      const expected = createHmac('sha256', secret).update(payload).digest('hex')
      const given = Buffer.from(signature)

      if (given.length !== expected.length || !timingSafeEqual(given, Buffer.from(expected)))
        return new Error('DECODE')

      return JSON.parse(payload)
    }
  }
}

The provider here is an invented one; a real provider documents its own paths, fields and signature scheme, and usually ships a library that implements them. What carries over is the shape:

  • Every answer of the provider becomes one of three things. A value in the shop’s words ({ url }, not the provider’s forty fields). A returned error for an answer the callers are built for: UNKNOWN_CHECKOUT, and UNAVAILABLE for “not now, ask again”. An exception for what nobody expected — a 400 that means the request itself is wrong, which is a bug to fix.
  • The customer’s id travels through the provider. metadata.customer is set when the checkout is created, and comes back in every later answer and notification about it. That is how a notification that arrives on its own, days later, is matched to a customer.
  • The timeout is set here, once, and so would be retries of requests that are safe to repeat.

§A stub for an empty key

// components/payments.gateway/lib/connect.ts
import type { Context } from '../types/index.d.ts'
import { client } from './client.ts'
import { stub } from './stub.ts'

export function connect (context: Context) {
  const key = context.configuration.key?.unwrap()

  if (key !== undefined && key !== '') return client(context, key)

  if (context.env !== 'local') throw new Error('The key of the payment provider is not configured')

  return stub
}
// components/payments.gateway/lib/stub.ts
import type { CheckoutInput } from '../types/index.d.ts'

export const stub = {
  async checkout ({ customer }: CheckoutInput) {
    if (customer.startsWith('dead')) return new Error('UNAVAILABLE')

    return { url: 'https://pay.test/checkout' }
  },

  // a checkout is the JSON its id is
  async retrieve (id: string) {
    try {
      return JSON.parse(id)
    } catch {
      return new Error('UNKNOWN_CHECKOUT')
    }
  },

  // a notification is its body, unsigned
  decode (payload: string) {
    try {
      return JSON.parse(payload)
    } catch {
      return new Error('DECODE')
    }
  }
}

A freshly generated .env holds an empty key, so a developer’s machine and a test run get the stub with no setup. context.env is the environment the application runs as. The stub keeps nothing and asks nobody: what it answers is decided by what it is given. A test that needs a paid checkout passes one as the id; a test that needs the provider to be down uses a customer whose id starts with dead:

$ npm run restart
$ npx toa call payments.gateway.retrieve 
    "{ input: { checkout: '{"id":"co_1","status":"paid","amount":5000}' } }"
{ id: 'co_1', status: 'paid', amount: 5000 }
$ npx toa call payments.gateway.retrieve "{ input: { checkout: 'nonsense' } }"

answers the error UNKNOWN_CHECKOUT.

Anywhere but the local environment a missing key stops the call with an exception. A deployment that forgot its secret must fail loudly, not accept payments into a stub. The same arrangement for a mail provider is walked through in Call an External Service.

§Keys reach one process

Secrets are handed to the processes that run the components declaring them. Put the gateway and the components that exist only for the provider into a composition of their own:

# context.toa.yaml
compositions:
  - name: billing
    components:
      - payments.gateway
      - payments.webhooks
      - billing.topups
      - billing.memberships

The provider’s key now reaches these pods and no others, and a change to billing restarts nothing else. Decide What Runs Together has the rest; Supply Secrets is how PAYMENTS_KEY gets its value in a deployment.

§Why a component and not a shared library

A shared function would remove the duplicated code, and nothing else: every component that imports it still needs the key, still talks to the network in its tests, and still has to be released when the provider changes.

Whatever is outside the application gets exactly one component that speaks to it; the rest of the application speaks to that component.

The price is one more call between components, which is small next to a request that leaves the building.

§What can still go wrong

The provider is down. The gateway answers UNAVAILABLE, and each caller decides what that means for it. An operation serving a customer returns it, and the customer is told to try again. An operation reached by an event or a task has nobody to tell: there a returned error ends the work for good, so it throws when the work must be tried again, or leaves it to another route it knows will come. Which of the two is right for a notification from the provider is in Receiving Webhooks.

The operation runs twice. A gateway operation is an effect: nothing remembers that it ran, so a caller that runs again makes the request again. Reads do no harm. A second checkout creates a second payment page that nobody opens, which costs nothing. A request that moves money — a refund, a charge of a saved card — must carry a key the provider recognizes the repeat by, taken from something that is the same on every run, such as the id of the order being refunded: Keys for external services.

The provider is slow or limits requests. Whatever softens that — a timeout, a short cache of a price list that every page asks for — lives in the gateway, where one place decides it for all callers.

The stub drifts from the provider. The stub answers in the shape the client does, refusals included, and nothing checks that but you. Keep both behind the same small set of methods, as above, and run the scenarios against the provider’s sandbox before a release that touches the client.

§Rules of thumb

  • One external service, one component, no entity. Other components never import its client and never see its key.
  • Name operations and errors in the application’s language; turn the provider’s statuses into values, declared errors and exceptions inside the gateway.
  • Have an error that means “ask again later”, distinct from “no”.
  • Put your own id into the provider’s metadata when you create something there.
  • With an empty key, a stub answers locally and the component fails anywhere else.
  • Let a test choose the stub’s answer through the input.
  • Give money-moving requests a key the provider deduplicates by.
  • Keep the provider’s components in one composition, so its secrets reach one process.
Search
Search the documentation