TIL: Caching http-easy responses is... easy

Hi, Racket Discourse.

I realized last night that I could serialize the responses provided by http-easy with minimal effort.

This is awesome, because it allows one to essentially cache-and-replay the responses from your requests. Of course, this isn't super novel, but I had never gotten to the point where the realization kicked in, until now.

I have a suspicion I've seen a blog post about this before, but for the life of me, I cannot recall the specifics, if I did.

#lang racket/base

(require
  net/http-easy
  (only-in
   racket/list remf)
  (only-in
   json read-json jsexpr->string jsexpr?)
  (only-in
   json/pretty pretty-print-json))

(provide
  cache
  cached define-cached
  reload
  persist)

(define cache (make-parameter #false))

(define (response->jsexpr r)
  (hasheq
   'status  (bytes->string/utf-8     (response-status-line r))
   'headers (map bytes->string/utf-8 (response-headers r))
   'output  (bytes->string/utf-8     (response-body r))
   'history (map response->jsexpr    (response-history r))))

(define (jsexpr->response js)
  (make-response
   (string->bytes/utf-8                   (hash-ref js 'status))
   (map string->bytes/utf-8               (hash-ref js 'headers))
   (open-input-bytes (string->bytes/utf-8 (hash-ref js 'output)))
   (map jsexpr->response                  (hash-ref js 'history))
   void))

(define ((app/rsp f) r)
  (hash-set r 'rsp (f (hash-ref r 'rsp))))

(define (search key section)
  (define (key? rsp) (equal? key (hash-ref rsp 'key)))
  (values
   (findf key? section) (remf key? section)))

(define (cached name request . key)
  (unless (jsexpr? key)
    (error 'cached "expected a jsexpr?, found: ~a" key))
  
  (define sec (hash-ref {cache} name null))
  (define-values (val rst) (search key sec))
  (cond
    [(not val)
     (define response (request))
     {cache
      (hash-set
       {cache} name (cons (hasheq 'key key 'rsp response) rst))}
     response]
    
    [else
     (hash-ref val 'rsp)]))

(define-syntax-rule
  (define-cached (name args ...)
    #:key [key ...] body ...)
  #;becomes
  (define (name args ...)
    (cached 'name (lambda () body ...) key ...)))

(define (reload)
  (cond
    [(not (file-exists? "cache.json"))
     (hasheq)]
    [else
     (with-input-from-file "cache.json"
       (lambda ()
         (for/hasheq ([(key val) (in-immutable-hash (read-json))])
           (values key (map (app/rsp jsexpr->response) val)))))]))

(define (persist)
  (with-output-to-file "cache.json"
    #:exists 'truncate/replace
    (lambda ()
      (pretty-print-json
       (jsexpr->string
        (for/hasheq ([(key val) (in-immutable-hash {cache})])
          (values key (map (app/rsp response->jsexpr) val))))))))

Defining a cached procedure looks like this, for example:

(define-cached (get-user-details [user #false])
  #:key [user]
  (get "https://gitea-server.co.za/api/v1/user"
       #:auth (bearer-auth gitea-token)
       #:headers
       (if (not user)
           (hasheq)
           (hasheq 'sudo user))))

Now, my scribble documents only generate slowly on cache-reload, and so much faster when not loading fresh data from the API.

4 Likes