diff --git a/main.lisp b/main.lisp index bf116b5..da65227 100644 --- a/main.lisp +++ b/main.lisp @@ -3,6 +3,7 @@ (defparameter *api-host* "http://127.0.0.1:5001") (defparameter *api-root* "/api/v0/") +;; ————————————————————————————————————— ;; STRING LIST [:LIST :BOOLEAN :SYMBOL] → STRING | HASHTABLE | (NIL STRING) (defun ipfs-call (call arguments &key (parameters nil) (want-stream nil) @@ -24,7 +25,7 @@ ((stringp result) result) ((vectorp result) (let ((json (yason:parse (flexi-streams:octets-to-string result)))) - (cond ((equal "error" (gethash "Type" json)) + (cond ((equal "error" (ignore-errors (gethash "Type" json))) (values nil (gethash "Message" json))) ('T json))))))) @@ -67,22 +68,21 @@ (values nil message) ,form))) +(defmacro bind-api-alist (call form) + `(bind-api-result ,call (re-hash-table-alist ,form))) -;; ------------------------------------- +;; ————————————————————————————————————— ;; ROOT CALLS ;; PATHNAME → (HASH-STRING SIZE-NUMBER) || (NIL STRING) (defun add (pathname &key (pin 't) (only-hash nil)) "Add a file to IPFS, return it's hash. - http://127.0.0.1:8080/ipns/docs.ipfs.io/reference/api/http/#api-v0-add" + /ipns/docs.ipfs.io/reference/api/http/#api-v0-add" (bind-api-result (ipfs-call "add" `(("pin" ,pin) ("only-hash" ,only-hash)) :method :post :parameters `(("file" . ,pathname))) - - (values (gethash "Hash" result) - (parse-integer (gethash "Size" result)) - (gethash "Name" result)))) + (re-hash-table-alist result))) ;; STRING :NUMBER :NUMBER → STRING || (NIL STRING) (defun cat (ipfs-path &key (offset nil) (length nil)) @@ -95,23 +95,15 @@ ,(if length `("length" ,length)))) result)) -;; STRING [:BOOLEAN :BOOLEAN] → (LIST LIST LIST LIST) || (NIL STRING) +;; STRING [:BOOLEAN :BOOLEAN] → ALIST || (NIL STRING) (defun ls (ipfs-path &key (resolve-type 't) (size 't)) "Returns all sub-objects (IPFS hashes) under a given IPFS/IPNS directory - path. Returns four lists; hashes, sizes, names, types. They are related - positionally-- aka, item #2 in the hash-list, will have a size of #2 in the - size-list, etc. + path. Returns as an associative list. /ipns/docs.ipfs.io/reference/api/http/#api-v0-ls" (bind-api-result (ipfs-call "ls" `(("arg" ,ipfs-path) ("resolve-type" ,resolve-type) ("size" ,size))) - - (let ((links (gethash "Links" - (car (gethash "Objects" result))))) - (values (mapcar (lambda (link) (gethash "Hash" link)) links) - (mapcar (lambda (link) (gethash "Size" link)) links) - (mapcar (lambda (link) (gethash "Name" link)) links) - (mapcar (lambda (link) (gethash "Type" link)) links))))) + (cdr (caadar (re-hash-table-alist result))))) ;; STRING PATHNAME → NIL (defun dl (ipfs-path out-file) @@ -133,25 +125,20 @@ (write-byte it out-stream)) (close in-stream)))) -;; ----------------- +;; —————————————————— -;; [STRING] → (STRING LIST STRING STRING STRING) -(defun id (&key (peer-id nil)) - "Return info on a node by ID. Has multiple return-values, as follows: - public-key, address-list, agent-version, protocol-version, and peer ID. - The last might be redundant if it was specified as an argument; otherwise, - it tells you your own node's ID. +;; [STRING] → ALIST +(defun id (&optional peer-id) + "Return info on a node by ID. Returns as an associative list, + the public key, agent version, etc. If no node ID is specified, + then your own is assumed. /ipns/docs.ipfs.io/reference/api/http/#api-v0-id" (bind-api-result (ipfs-call "id" `(,(if peer-id (list "arg" peer-id)))) - (values (gethash "PublicKey" result) - (gethash "Addresses" result) - (gethash "AgentVersion" result) - (gethash "ProtocolVersion" result) - (gethash "ID" result)))) + (re-hash-table-alist result))) -;; ----------------- +;; —————————————————— ;; STRING → (STRING || (NIL STRING) (defun dns (domain &key (recursive 't)) @@ -172,7 +159,7 @@ ("dht-timeout" ,(string+ dht-timeout "s")))) (gethash "Path" result))) -;; ----------------- +;; —————————————————— ;; NIL → NIL (defun shutdown () @@ -182,7 +169,7 @@ -;; ------------------------------------- +;; ————————————————————————————————————— ;; BLOCK CALLS ;; STRING → STRING @@ -226,7 +213,7 @@ -;; ------------------------------------- +;; ————————————————————————————————————— ;; BOOTSTRAP CALLS ;; NIL → LIST @@ -277,7 +264,34 @@ -;; ------------------------------------- +;; ————————————————————————————————————— +;; CID CALLS + +;; STRING → STRING || (NIL STRING) +(defun cid/base32 (cid) + "Convert a CID into Base32 CIDv1 + /ipns/docs.ipfs.io/reference/api/http/#api-v0-cid-base32" + (bind-api-result + (ipfs-call "cid/base32" `(("arg" ,cid))) + (if (zerop (length (gethash "ErrorMsg" result))) + (gethash "Formatted" result) + (values nil (gethash "ErrorMsg" result))))) + +;; NIL → ALIST || (NIL STRING) +(defun cid/bases () + "Return a associative list of available bases in plist format; + each base's name is a assigned a given code-number. + ((CODE-A . NAME-A) (CODE-B . NAME-B) … (CODE-N . NAME-N)) + /ipns/docs.ipfs.io/reference/api/http/#api-v0-cid-bases" + (bind-api-result + (ipfs-call "cid/bases" '()) + (mapcan (lambda (base) + `((,(gethash "Code" base) . ,(gethash "Name" base)))) + result))) + + + +;; ————————————————————————————————————— ;; CONFIG CALLS ;; STRING [:STRING :BOOLEAN :BOOLEAN] → STRING || (NIL STRING) @@ -287,51 +301,102 @@ (bind-api-result (ipfs-call "config" `(("arg" ,key) ,(if value (list "value" value)) ("bool" ,bool) ("json" ,json))) - `(,(gethash "Key" result) ,(gethash "Value" result)))) + (cons (gethash "Key" result) (gethash "Value" result)))) -;; NIL → HASH-TABLE +;; NIL → ALIST (defun config/show () - "Return the config file's contents, in a Yason, JSON-parsed hashtable. - Doesn't quite line up with #api-v0-config-show" - (bind-api-result + "Return the config file's contents, in alist-format… + y'know, with several sub-alists. + Doesn't quite line up with #api-v0-config-show + /ipns/docs.ipfs.io/reference/api/http/#api-v0-config-show" + (bind-api-alist (ipfs-call "config/show" '()) result)) +;; STRING → STRING || (NIL STRING) +(defun config/get (key) + "Get a config key's value. + Doesn't map with any existant API call; it's just a + convenience wrapper around #'config" + (config key)) + +;; STRING → STRING || (NIL STRING) +(defun config/set (key value &key (bool nil) (json nil)) + "Set a config key's value. + Doesn't map with any existant API call; it's just a + convenience wrapper around #'config" + (config key :value value :bool bool :json json)) -;; ------------------------------------- + +;; ————————————————————————————————————— ;; VERSION CALLS -;; NIL → (STRING STRING STRING STRING STRING) +;; NIL → STRING (defun version () - "Return versioning information on this IPFS node + "Return the current IPFS version. /ipns/docs.ipfs.io/reference/api/http/#api-v0-version" (bind-api-result (ipfs-call "version" '()) - (values (gethash "Version" result) - (gethash "Commit" result) - (gethash "Repo" result) - (gethash "System" result) - (gethash "Golang" result)))) + (gethash "Version" result))) -;; NIL → (STRING STRING STRING STRING) +;; NIL → ALIST (defun version/deps () - "Return info about dependencies used for build. + "Return info about dependencies used for build; + I.E., Go version, OS, etc. /ipns/docs.ipfs.io/reference/api/http/#api-v0-version" (bind-api-result (ipfs-call "version/deps" '()) - (values (gethash "Version" result) - (gethash "Path" result) - (gethash "ReplacedBy" result) - (gethash "Sum" result)))) + (re-hash-table-alist result))) -;; ------------------------------------- +;; ————————————————————————————————————— ;; UTIL +;; FORM → BOOLEAN +(defmacro error-p (form) + "Return whether or not a given form errors out." + `(multiple-value-bind (return error) (ignore-errors ,form) + (when error 'T))) + +;; VARYING → BOOLEAN +(defun pure-cons-p (item) + "Return whether or not an item is 'purely' a cons-pair; + that is, it doesn't make up a larger list. In these cases, + #'consp passes, but #'length errors out." + (and (consp item) + (error-p (length item)))) + +;; FUNCTION FUNCTION LIST/VARYING → LIST +(defun test-apply (test function data) + "Apply a given function to all items within a list that + pass the given test, recursively. AKA, if the given function + returns another list, the process is applied to that list as + well. So on and so forth." + (cond ((pure-cons-p data) + (test-apply test function + `(,(car data) ,(cdr data)))) + ((listp data) + (mapcar + (lambda (item) (test-apply test function item)) + data)) + ((funcall test data) + (test-apply test function + (funcall function data))) + ('T data))) + ;; STRING-A STRING-B … STRING-N → STRING (defun string+ (&rest strings) "Combine an arbitrary amount of strings into a single string." (reduce (lambda (a b) (format nil "~A~A" a b)) strings)) + +;; HASH-TABLE → ALIST +(defun re-hash-table-alist (hash-table) + "Turn a hash-table into an associative list, recursively-- + if any of the hash-table's values are ther hash-tables, + they too are turned into alists." + (test-apply #'hash-table-p + #'alexandria:hash-table-alist + (alexandria:hash-table-alist hash-table))) diff --git a/package.lisp b/package.lisp index 85348ce..efa88ec 100644 --- a/package.lisp +++ b/package.lisp @@ -29,6 +29,9 @@ :bootstrap/rm :bootstrap/rm/all + ;; / cid calls + :cid/base32 + :cid/bases ;; / config calls :config :config/show @@ -38,3 +41,400 @@ :version/deps)) (in-package :cl-ipfs-api²) +(in-package :cl-ipfs-api2) + +(defparameter *api-host* "http://127.0.0.1:5001") +(defparameter *api-root* "/api/v0/") + + +;; STRING LIST [:LIST :BOOLEAN :SYMBOL] → STRING | HASHTABLE | (NIL STRING) +(defun ipfs-call (call arguments &key (parameters nil) (want-stream nil) + (method :GET)) + "Make an IPFS HTTP API call. Quite commonly used. + Some calls return strings/raw data, and others return JSON. + When strings/arbitrary data are recieved, they're returned verbatim. + But, when JSON is returned, it is parsed into a hashtable. + If the JSON is 'error JSON', I.E., it signals that an error has been + recieved, two values are returned: NIL and the string-error-message." + (let ((result + (drakma:http-request + (make-call-url *api-host* *api-root* call arguments) + :method method + :parameters parameters + :want-stream want-stream))) + + (cond (want-stream result) + ((stringp result) result) + ((vectorp result) + (let ((json (yason:parse (flexi-streams:octets-to-string result)))) + (cond ((equal "error" (ignore-errors (gethash "Type" json))) + (values nil (gethash "Message" json))) + ('T json))))))) + + +;; STRING STRING LIST → STRING +(defun make-call-url (host root call arguments) + "Create the URL of an API call, as per the given arguments. + Symbols are assumed to be something like 'T (so boolean), nil likewise. + Arguments should look like this: + (('recursive' nil)('name' 'xabbu'))" + (let ((call-url (string+ host root call)) + (first-arg 'T)) + (mapcar (lambda (arg-pair) + (when arg-pair + (setq call-url + (string+ + call-url + (if first-arg "?" "&") + (first arg-pair) "=" + (cond ((not (second arg-pair)) + "false") + ((symbolp (second arg-pair)) + "true") + ('T (second arg-pair))))) + (setq first-arg nil))) + arguments) + call-url)) + + +;; FORM FORM → FORM +(defmacro bind-api-result (call form) + "Wrap around an #'ipfs-call form; if #'call returns an error, then + return NIL and the error message-- (NIL STRING)-- otherwise, execute + #'form. + Binds the result of the API call to… you guessed it, the variable 'result'. + The error message is assigned to 'message', if such a thing exists." + `(multiple-value-bind (result message) + ,call + (if message + (values nil message) + ,form))) + + + +;; ------------------------------------- +;; ROOT CALLS + +;; PATHNAME → (HASH-STRING SIZE-NUMBER) || (NIL STRING) +(defun add (pathname &key (pin 't) (only-hash nil)) + "Add a file to IPFS, return it's hash. + http://127.0.0.1:8080/ipns/docs.ipfs.io/reference/api/http/#api-v0-add" + (bind-api-result + (ipfs-call "add" `(("pin" ,pin) ("only-hash" ,only-hash)) + :method :post :parameters `(("file" . ,pathname))) + + (values (gethash "Hash" result) + (parse-integer (gethash "Size" result)) + (gethash "Name" result)))) + +;; STRING :NUMBER :NUMBER → STRING || (NIL STRING) +(defun cat (ipfs-path &key (offset nil) (length nil)) + "Return a string of the data at the given IPFS path. + /ipns/docs.ipfs.io/reference/api/http/#api-v0-cat" + (bind-api-result + (ipfs-call "cat" + `(("arg" ,ipfs-path) + ,(if offset `("offset" ,offset)) + ,(if length `("length" ,length)))) + result)) + +;; STRING [:BOOLEAN :BOOLEAN] → (LIST LIST LIST LIST) || (NIL STRING) +(defun ls (ipfs-path &key (resolve-type 't) (size 't)) + "Returns all sub-objects (IPFS hashes) under a given IPFS/IPNS directory + path. Returns four lists; hashes, sizes, names, types. They are related + positionally-- aka, item #2 in the hash-list, will have a size of #2 in the + size-list, etc. + /ipns/docs.ipfs.io/reference/api/http/#api-v0-ls" + (bind-api-result + (ipfs-call "ls" `(("arg" ,ipfs-path) + ("resolve-type" ,resolve-type) ("size" ,size))) + + (let ((links (gethash "Links" + (car (gethash "Objects" result))))) + (values (mapcar (lambda (link) (gethash "Hash" link)) links) + (mapcar (lambda (link) (gethash "Size" link)) links) + (mapcar (lambda (link) (gethash "Name" link)) links) + (mapcar (lambda (link) (gethash "Type" link)) links))))) + +;; STRING PATHNAME → NIL +(defun dl (ipfs-path out-file) + "Write an IPFS file directly to a file on the local file-system. + Non-recursive, in the case of directories… for now. + (Thanks to this thread ♥: https://stackoverflow.com/a/12607423) + Is a general replacement for the 'get' API call, but actually just uses + the 'cat' call, due to some issues with using 'get'. + Will not actually return NIL when an error is reached (like other functions) + with an error-message, it'lll just write the error JSON to the file. + Whoops." + (with-open-file (out-stream out-file :direction :output + :element-type '(unsigned-byte 8) + :if-exists :overwrite :if-does-not-exist :create) + (let ((in-stream + (ipfs-call "cat" `(("arg" ,ipfs-path)) :want-stream 'T))) + + (awhile (read-byte in-stream nil nil) + (write-byte it out-stream)) + (close in-stream)))) + +;; ----------------- + +;; [:STRING] → (STRING LIST STRING STRING STRING) +(defun id (&key (peer-id nil)) + "Return info on a node by ID. Has multiple return-values, as follows: + public-key, address-list, agent-version, protocol-version, and peer ID. + The last might be redundant if it was specified as an argument; otherwise, + it tells you your own node's ID. + /ipns/docs.ipfs.io/reference/api/http/#api-v0-id" + (bind-api-result + (ipfs-call "id" `(,(if peer-id (list "arg" peer-id)))) + + (values (gethash "PublicKey" result) + (gethash "Addresses" result) + (gethash "AgentVersion" result) + (gethash "ProtocolVersion" result) + (gethash "ID" result)))) + +;; ----------------- + +;; STRING → (STRING || (NIL STRING) +(defun dns (domain &key (recursive 't)) + "Resolve a domain into a path (usually /ipfs/). + /ipns/docs.ipfs.io/reference/api/http/#api-v0-dns" + (bind-api-result + (ipfs-call "dns" `(("arg" ,domain) ("recursive" ,recursive))) + (gethash "Path" result))) + +;; STRING [:BOOLEAN :NUMBER :NUMBER] → STRING || (NIL STRING) +(defun resolve (ipfs-path &key (recursive 't) (dht-record-count nil) + (dht-timeout 30)) + "Resolve a given name to an IPFS path." + (bind-api-result + (ipfs-call "resolve" `(("arg" ,ipfs-path) ("recursive" ,recursive) + ,(if dht-record-count + (list "dht-record-count" dht-record-count)) + ("dht-timeout" ,(string+ dht-timeout "s")))) + (gethash "Path" result))) + +;; ----------------- + +;; NIL → NIL +(defun shutdown () + "Shut down the connected IPFS node. + /ipns/docs.ipfs.io/reference/api/http/#api-v0-shutdown" + (ipfs-call "shutdown" '())) + + + +;; ------------------------------------- +;; BLOCK CALLS + +;; STRING → STRING +(defun block/get (hash) + "Get a raw IPFS block. + /ipns/docs.ipfs.io/reference/api/http/#api-v0-block-get" + (bind-api-result + (ipfs-call "block/get" `(("arg" ,hash))) + result)) + +;; PATHNAME [:STRING :STRING :NUMBER :BOOLEAN] → (STRING NUMBER) +(defun block/put (pathname &key (format nil) (mhtype "sha2-256") (mhlen -1) + (pin nil)) + "Store input as an IPFS block. + /ipns/docs.ipfs.io/reference/api/http/#api-v0-block-put" + (bind-api-result + (ipfs-call "block/put" `(,(if format (list "format" format)) + ("mhtype" ,mhtype) + ("mhlen" ,mhlen) + ("pin" ,pin)) + :method :POST :parameters `(("data" . ,pathname))) + (values (gethash "Key" result) + (gethash "Size" result)))) + +;; STRING → BOOLEAN +(defun block/rm (hash &key (force nil)) + "Delete an IPFS block(s). + /ipns/docs.ipfs.io/reference/api/http/#api-v0-block-rm" + (bind-api-result + (ipfs-call "block/rm" `(("arg" ,hash) ,(if force (list "force" force)))) + 't)) + +;; STRING → (STRING NUMBER) +(defun block/stat (hash) + "Print info about a raw IPFS block + /ipns/docs.ipfs.io/reference/api/http/#api-v0-block-stat" + (bind-api-result + (ipfs-call "block/stat" `(("arg" ,hash))) + (values (gethash "Key" result) + (gethash "Size" result)))) + + + +;; ------------------------------------- +;; BOOTSTRAP CALLS + +;; NIL → LIST +(defun bootstrap () + "Return a list of bootstrap peers + /ipns/docs.ipfs.io/reference/api/http/#api-v0-bootstrap" + (bind-api-result + (ipfs-call "bootstrap" '()) + (gethash "Peers" result))) + +;; NIL → LIST +(defun bootstrap/list () + "Return a list of bootstrap peers + /ipns/docs.ipfs.io/reference/api/http/#api-v0-bootstrap-list" + (bootstrap)) + +;; STRING → LIST +(defun bootstrap/add (peer) + "Add a peer to the bootstrap list + /ipns/docs.ipfs.io/reference/api/http/#api-v0-bootstrap-add" + (bind-api-result + (ipfs-call "bootstrap/add" `(("arg" ,peer))) + (gethash "Peers" result))) + +;; NIL → LIST +(defun bootstrap/add/default () + "Add default peers to the bootstrap list + /ipns/docs.ipfs.io/reference/api/http/#api-v0-bootstrap-add-default" + (bind-api-result + (ipfs-call "bootstrap/add/default" '()) + (gethash "Peers" result))) + +;; STRING → LIST +(defun bootstrap/rm (peer) + "Remove a peer from the bootstrap list + /ipns/docs.ipfs.io/reference/api/http/#api-v0-bootstrap-rm" + (bind-api-result + (ipfs-call "bootstrap/rm" `(("arg" ,peer))) + (gethash "Peers" result))) + +;; NIL → LIST +(defun bootstrap/rm/all (peer) + "Remove a peer from the bootstrap list + /ipns/docs.ipfs.io/reference/api/http/#api-v0-bootstrap-rm" + (bind-api-result + (ipfs-call "bootstrap/rm/all" '()) + (gethash "Peers" result))) + + + +;; ------------------------------------- +;; CID CALLS + +;; STRING → STRING || (NIL STRING) +(defun cid/base32 (cid) + "Convert a CID into Base32 CIDv1 + /ipns/docs.ipfs.io/reference/api/http/#api-v0-cid-base32" + (bind-api-result + (ipfs-call "cid/base32" `(("arg" ,cid))) + (if (zerop (length (gethash "ErrorMsg" result))) + (gethash "Formatted" result) + (values nil (gethash "ErrorMsg" result))))) + +;; NIL → LIST || (NIL STRING) +(defun cid/bases () + "Return a list of available bases in plist format; each base's name is a + assigned a given code-number. + (CODE-A NAME-A CODE-B NAME-B … CODE-N NAME-N) + /ipns/docs.ipfs.io/reference/api/http/#api-v0-cid-bases" + (bind-api-result + (ipfs-call "cid/bases" '()) + (mapcan (lambda (base) + (list (gethash "Code" base) + (gethash "Name" base))) + result))) + +;; NIL → LIST || (NIL STRING) +(defun cid/bases () + "Convert a CID into Base32 CIDv1 + /ipns/docs.ipfs.io/reference/api/http/#api-v0-cid-base32" + (bind-api-result + (ipfs-call "cid/bases" '()) + (mapcan (lambda (base) + (list (gethash "Code" base) + (gethash "Name" base))) + result))) + + +;; ------------------------------------- +;; CONFIG CALLS + +;; STRING [:STRING :BOOLEAN :BOOLEAN] → STRING || (NIL STRING) +(defun config (key &key (value nil) (bool nil) (json nil)) + "Get/set a config key's value. + /ipns/docs.ipfs.io/reference/api/http/#api-v0-config" + (bind-api-result + (ipfs-call "config" `(("arg" ,key) ,(if value (list "value" value)) + ("bool" ,bool) ("json" ,json))) + (cons (gethash "Key" result) (gethash "Value" result)))) + +;; NIL → ALIST +(defun config/show () + "Return the config file's contents, in plist-format. + Doesn't quite line up with #api-v0-config-show" + (bind-api-result + (ipfs-call "config/show" '()) + (hash-table-alist-recursive result))) +;; (mapcar (lambda (key) +;; (list key (gethash key result))) +;; (hash-table-keys result)))) + + + +;; ------------------------------------- +;; VERSION CALLS + +;; NIL → (STRING ALIST) +(defun version () + "Return versioning information on this IPFS node + /ipns/docs.ipfs.io/reference/api/http/#api-v0-version" + (bind-api-result + (ipfs-call "version" '()) + (values (gethash "Version" result) + `(("Commit" ,(gethash "Commit" result)) + ("Repo" ,(gethash "Repo" result)) + ("System" ,(gethash "System" result)) + ("Golang" ,(gethash "Golang" result)))))A) + +;; NIL → PLIST +(defun version/deps () + "Return info about dependencies used for build. + /ipns/docs.ipfs.io/reference/api/http/#api-v0-version" + (bind-api-result + (ipfs-call "version/deps" '()) + `(("Path" ,(gethash "Path" result)) + ("ReplacedBy" ,(gethash "ReplacedBy" result)) + ("Sum" ,(gethash "Sum" result))))) + + + + +;; ------------------------------------- +;; UTIL + +(defun test-apply (test function data) + (format t "DATA: ~A ~A~%" data (not (hash-table-p data))) + (if (hash-table-p data) (format t "HELL NO") + (cond ((and (listp data) (not (hash-table-p data))) + (mapcar (lambda (item) + (test-apply test function item)) + data)) + + ((apply test `(,data)) + (let ((new-data (apply function `(,data)))) + (cond + ((or (listp new-data) + (ignore-errors (apply test `(,new-data)))) + (test-apply test function new-data)) + ('T new-data)))) + ('T data)))) + +;; STRING-A STRING-B … STRING-N → STRING +(defun string+ (&rest strings) + "Combine an arbitrary amount of strings into a single string." + (reduce (lambda (a b) (format nil "~A~A" a b)) strings)) + +(defun hash-table-alist-recursive (hash-table) + (test-apply #'hash-table-p #'alexandria:hash-table-alist (alexandria:hash-table-alist hash-table)))