Nanyx

From a class to a record

Translate a caching manager class into a record, attached functions and contexts.

module attestation

import nanyx/time

---
There are no classes in Nanyx. This example takes a class from another
language, a manager that caches a key attestation and refreshes it when it
expires, and models it the Nanyx way:

  - the object's fields become a record type
  - its methods become definitions attached to that type
  - mutation becomes a function from the old state to the new state
  - its dependencies become contexts
---

type Attestation = (
  certificateChain: list(string)
  generatedAt: Instant
)

type Settings = (
  tokenExpiry: Duration
  maxRetryAttempts: int
  retryDelay: Duration
)

export type AttestationCache = (
  settings: Settings
  current: #some(Attestation) | #none
)

export def AttestationCache.new: Settings -> AttestationCache = { settings ->
  AttestationCache(settings = settings, current = #none)
}

def AttestationCache.needsRefresh: [Clock] AttestationCache -> bool = { cache ->
  match cache.current
    | #none -> true
    | #some(attestation) -> Clock.now - attestation.generatedAt >= cache.settings.tokenExpiry
}

-- The service that produces attestations is a dependency, so it is a context
context Attester = (
  getChallenge: () -> string
  generateCertificateChain: string -> #some(list(string)) | #error(#attestationFailed)
)

-- Retry an operation, waiting between attempts
def retry: [Clock] (times: int, delay: int -> Duration, operation: () -> #some(a) | #error(e)) -> #some(a) | #error(e)
  = { times, delay, operation ->
    rec attempt = { n ->
      match operation()
        | #some(value) -> #some(value)
        | #error(e) ->
          if n >= times then #error(e)
          else
            Clock.sleep(delay(n))
            attempt(n + 1)
    }
    attempt(1)
  }

-- Refresh the attestation if it is missing or expired. Returns the new cache;
-- the old one is unchanged.
export def AttestationCache.refresh
  : [Clock & Attester] AttestationCache -> #some(AttestationCache) | #error(#attestationFailed)
  = { cache ->
    if not cache.needsRefresh then #some(cache)
    else proc {
      def chain = try retry(
        times = cache.settings.maxRetryAttempts
        delay = { n -> cache.settings.retryDelay * n }
        operation = { generateCertificateChain(getChallenge()) }
      )

      return cache with current = #some(Attestation(certificateChain = chain, generatedAt = Clock.now))
    }
  }

export def AttestationCache.certificateChain: AttestationCache -> #some(list(string)) | #none = { cache ->
  cache.current \Option.map { .certificateChain }
}

This program lives in the repository as examples/oo-class.nyx.