Skip to content

donut-party/graphputer

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

18 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Clojars Project

graphputer

Ready to 'pute some graphs??

What is this?

graphputer lets you decompose some computation into a directed graph of computations. Let's say you're writing a user signup API endpoint. Instead of writing something like this:

;; assume `validate` and `insert-user` are defined
(defn user-signup
  [params]
  (if-let [validation-errors (validate params)] 
    {:status 400
     :body validation-errors}
    (if-let [user (insert-user params)]
      {:status 200
       :body   user}
      {:status 500})))
     
(user-signup {:username "newuser"})

You can write something like this:

(require '[donut.graphputer :as puter])

(def user-signup-graph
  {:id   :user-signup
   :init :validate
   :nodes
   {:validate
    {:pute  (fn [user-params]
              (if-let [validation-errors (validate user-params)]
                [::puter/goto :fail validation-errors]
                user-params))
     ;; the vector [::puter/goto :fail new-parameter] tells graphputer to follow
     ;; the `:fail` edge (which points to the `:validate-failed` node here)
     ;; and to pass in `new-parameter` to that node's `:pute`
     :edges {:default :insert-user
             :fail    :validate-failed}}

    :validate-failed
    {:pute (fn [validation-errors]
             {:status 400
              :body   validation-errors})}

    :insert-user
    {:pute  (fn [user-params]
              (if-let [inserted-user (insert-user user-params)]
                inserted-user
                [::puter/goto :fail]))
     :edges {:default :user-signup-success
             :fail    :insert-user-failed}}

    :insert-user-failed
    {:pute (constantly {:status 500})}

    :user-signup-success
    {:pute (fn [inserted-user]
             {:status 200
              :body   inserted-user})}}})

(puter/execute user-signup-graph {:username "newuser"})
graph TB
  :validate -->|:default| :insert-user
  :validate -->|:fail| :validate-failed
  :insert-user -->|:default| :user-signup-success
  :insert-user -->|:fail| :insert-user-failed
  classDef default ry:5,rx:5
Loading

Uh, why?

Why would anyone want to do this? Honestly, it might be a bad idea. You tell me! It's an experiment.

The immediate motivating reason is to enable the creation of libraries that have nested control flow for coordinating multiple conditions and side-effecting behavior, while remaining extensible by the user.

For example, I want it to be possible to create a lib for web app backends that can capture the core workflow for signing up a user, while allowing a dev to add their own custom behavior. A developer might want to email a user after successfully inserting their record in your db. You could do that like this:

(def my-user-signup-graph
  (puter/splice-node
   user-signup-graph
   {:node-name       :email-user-signup-success
    :node            {:pute (fn [inserted-user] (email-user inserted-user))}
    :input-node-name :insert-user}))

This will insert a new computation node under the :email-user-signup-success key, and changes the :insert-user node so that its :default edge points to :email-user-signup-success. The :email-user-signup-success node's :default points to :user-signup-success.

graph TB
  :validate -->|:default| :insert-user
  :validate -->|:fail| :validate-failed
  :insert-user -->|:default| :email-user-signup-success
  :insert-user -->|:fail| :insert-user-failed
  :email-user-signup-success -->|:default| :user-signup-success
  classDef default ry:5,rx:5
Loading

Another benefit of this approach is that it opens up possibilities for documenting your library. It's possible to visualize the compute graph and put it in your readme, making it easier for devs to understand what your lib is doing.

How it works

donut.graphputer/execute takes two arguments, a graph and the graph's initial execution parameter. When you call donut.graphputer/execute, it looks the node named by :init and calls its :pute function with one argument, the initial execution parameter. In the example above, :validate's :pute gets called with the map {:username "newuser"}.

If a :pute function returns a vector like [:donut.graphputer/goto node-name new-execution-parameter] then execution flow goes to the computation node named by node-name and that node gets passed new-execution-parameter. new-execution-parameter is optional; if it isn't supplied then the current execution parameter gets passed on. So with :validate, the :fail node would be :validate-failed.

Otherwise, execution flow goes to the :default node and the next :pute function is called with the return value of the previous :pute function. In the example above, :user-signup-success gets called with the value returned by (insert-user user-params).

If there isn't another node to goto -- because :default isn't defined or [:donut.graphputer/goto ...] isn't returned, then execution stops and the last computed value is returned.

Validation

You can supply malli schemas for:

  • The argument passed to :pute
  • The values that a node passes to downstream nodes
  • The value that a node returns directly

This example shows you'd handle the first to cases:

{:pute    (fn [user-params]
            (if-let [user (insert-user user-params)]
              user
              [::puter/goto :fail insert-user-failed]))
 :edges   {:default :insert-user
           :fail    :insert-user-failed}
 :schemas {::puter/input input-schema
           :default      default-schema
           :fail         fail-schema}}

Under :schemas, ::puter/input is a schema that's used to validate the argument that will get passed in to the :pute function as user-params. :default and :fail both correspond to edge names, and validate the values that will be passed along those edges (before they actually get passed).

To validate a value that's meant to be the return value for the entire execution, you use the ::puter/output key under :schemas.

If validation fails, an exception gets thrown.

If you want to execute without using schemas for validation, include :validate? false in your graph definition:

(def graph
  {:id :my-id
   :init :some-node-name
   :validate? false
   :nodes {}})

Isn't this a state machine?

Not really. Unlike a state machine, you don't send it events to advance states.

Releases

No releases published

Packages

No packages published