├── .circleci └── config.yml ├── .github └── workflows │ └── clj-kondo.yml ├── .gitignore ├── README.md ├── build.clj ├── deps.edn ├── docs ├── examples.clj └── notes.md ├── src └── donut │ └── graphputer.cljc └── test └── donut └── graphputer_test.cljc /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | test-and-build: 4 | environment: 5 | - _JAVA_OPTIONS: "-Xms512m -Xmx1024m" 6 | docker: 7 | - image: cimg/clojure:1.11.1-openjdk-8.0-node 8 | steps: 9 | - checkout 10 | - run: 11 | name: test clj 12 | command: clojure -X:test 13 | - run: 14 | name: test cljs 15 | command: clojure -X:test-cljs 16 | - run: 17 | name: Build 18 | command: clojure -T:build jar 19 | - save-cache: 20 | paths: 21 | - ~/bin 22 | - ~/.m2 23 | key: system-{{ checksum "build.clj" }} 24 | - persist_to_workspace: 25 | root: ./ 26 | paths: 27 | - ./ 28 | deploy: 29 | docker: 30 | - image: cimg/clojure:1.11.1-openjdk-8.0-node 31 | steps: 32 | - checkout 33 | - restore_cache: 34 | key: system-{{ checksum "build.clj" }} 35 | - attach_workspace: 36 | at: ./ 37 | - run: 38 | name: Deploy to clojars 39 | command: | 40 | clojure -T:build deploy 41 | 42 | workflows: 43 | version: 2 44 | deploy: 45 | jobs: 46 | - test-and-build 47 | - deploy: 48 | filters: 49 | branches: 50 | only: 51 | - release 52 | requires: 53 | - test-and-build 54 | -------------------------------------------------------------------------------- /.github/workflows/clj-kondo.yml: -------------------------------------------------------------------------------- 1 | name: clj-kondo linting 2 | 3 | on: [push] 4 | 5 | jobs: 6 | self-lint: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - uses: DeLaGuardo/clojure-lint-action@master 11 | with: 12 | clj-kondo-args: --lint src test 13 | # secrets.GITHUB_TOKEN is needed here 14 | # to publish annotations back to github 15 | # this action is not storing or sending it anywhere 16 | github_token: ${{ secrets.GITHUB_TOKEN }} 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.DS_Store 2 | *.jar 3 | *.log 4 | .clj-kondo 5 | .cpcache 6 | .dir-locals.el 7 | .lein-env 8 | .lein-failures 9 | .lein-repl-history 10 | .lsp 11 | .nrepl* 12 | target 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Clojars Project](https://img.shields.io/clojars/v/party.donut/graphputer.svg)](https://clojars.org/party.donut/graphputer) 2 | 3 | # graphputer 4 | 5 | Ready to 'pute some graphs?? 6 | 7 | ## What is this? 8 | 9 | graphputer lets you decompose some computation into a directed graph of 10 | computations. Let's say you're writing a user signup API endpoint. Instead of 11 | writing something like this: 12 | 13 | ``` clojure 14 | ;; assume `validate` and `insert-user` are defined 15 | (defn user-signup 16 | [params] 17 | (if-let [validation-errors (validate params)] 18 | {:status 400 19 | :body validation-errors} 20 | (if-let [user (insert-user params)] 21 | {:status 200 22 | :body user} 23 | {:status 500}))) 24 | 25 | (user-signup {:username "newuser"}) 26 | ``` 27 | 28 | You can write something like this: 29 | 30 | ``` clojure 31 | (require '[donut.graphputer :as puter]) 32 | 33 | (def user-signup-graph 34 | {:id :user-signup 35 | :init :validate 36 | :nodes 37 | {:validate 38 | {:pute (fn [user-params] 39 | (if-let [validation-errors (validate user-params)] 40 | [::puter/goto :fail validation-errors] 41 | user-params)) 42 | ;; the vector [::puter/goto :fail new-parameter] tells graphputer to follow 43 | ;; the `:fail` edge (which points to the `:validate-failed` node here) 44 | ;; and to pass in `new-parameter` to that node's `:pute` 45 | :edges {:default :insert-user 46 | :fail :validate-failed}} 47 | 48 | :validate-failed 49 | {:pute (fn [validation-errors] 50 | {:status 400 51 | :body validation-errors})} 52 | 53 | :insert-user 54 | {:pute (fn [user-params] 55 | (if-let [inserted-user (insert-user user-params)] 56 | inserted-user 57 | [::puter/goto :fail])) 58 | :edges {:default :user-signup-success 59 | :fail :insert-user-failed}} 60 | 61 | :insert-user-failed 62 | {:pute (constantly {:status 500})} 63 | 64 | :user-signup-success 65 | {:pute (fn [inserted-user] 66 | {:status 200 67 | :body inserted-user})}}}) 68 | 69 | (puter/execute user-signup-graph {:username "newuser"}) 70 | ``` 71 | 72 | ```mermaid 73 | graph TB 74 | :validate -->|:default| :insert-user 75 | :validate -->|:fail| :validate-failed 76 | :insert-user -->|:default| :user-signup-success 77 | :insert-user -->|:fail| :insert-user-failed 78 | classDef default ry:5,rx:5 79 | ``` 80 | 81 | ## Uh, why? 82 | 83 | Why would anyone want to do this? Honestly, it might be a bad idea. You tell me! 84 | It's an experiment. 85 | 86 | The immediate motivating reason is to enable the creation of libraries that have 87 | nested control flow for coordinating multiple conditions and side-effecting 88 | behavior, while remaining extensible by the user. 89 | 90 | For example, I want it to be possible to create a lib for web app backends that 91 | can capture the core workflow for signing up a user, while allowing a dev to add 92 | their own custom behavior. A developer might want to email a user after 93 | successfully inserting their record in your db. You could do that like this: 94 | 95 | ``` clojure 96 | (def my-user-signup-graph 97 | (puter/splice-node 98 | user-signup-graph 99 | {:node-name :email-user-signup-success 100 | :node {:pute (fn [inserted-user] (email-user inserted-user))} 101 | :input-node-name :insert-user})) 102 | ``` 103 | 104 | This will insert a new computation node under the `:email-user-signup-success 105 | key`, and changes the `:insert-user` node so that its `:default` edge points to 106 | `:email-user-signup-success`. The `:email-user-signup-success` node's `:default` 107 | points to `:user-signup-success`. 108 | 109 | ```mermaid 110 | graph TB 111 | :validate -->|:default| :insert-user 112 | :validate -->|:fail| :validate-failed 113 | :insert-user -->|:default| :email-user-signup-success 114 | :insert-user -->|:fail| :insert-user-failed 115 | :email-user-signup-success -->|:default| :user-signup-success 116 | classDef default ry:5,rx:5 117 | ``` 118 | 119 | Another benefit of this approach is that it opens up possibilities for 120 | documenting your library. It's possible to visualize the compute graph and put 121 | it in your readme, making it easier for devs to understand what your lib is 122 | doing. 123 | 124 | ## How it works 125 | 126 | `donut.graphputer/execute` takes two arguments, a graph and the graph's initial 127 | execution parameter. When you call `donut.graphputer/execute`, it looks the node 128 | named by `:init` and calls its `:pute` function with one argument, the initial 129 | execution parameter. In the example above, `:validate`'s `:pute` gets called 130 | with the map `{:username "newuser"}`. 131 | 132 | If a `:pute` function returns a vector like `[:donut.graphputer/goto node-name 133 | new-execution-parameter]` then execution flow goes to the computation node named 134 | by `node-name` and that node gets passed `new-execution-parameter`. 135 | `new-execution-parameter` is optional; if it isn't supplied then the current 136 | execution parameter gets passed on. So with `:validate`, the `:fail` node would 137 | be `:validate-failed`. 138 | 139 | Otherwise, execution flow goes to the `:default` node and the next `:pute` 140 | function is called with the return value of the previous `:pute` function. In 141 | the example above, `:user-signup-success` gets called with the value returned by 142 | `(insert-user user-params)`. 143 | 144 | If there isn't another node to goto -- because `:default` isn't defined or 145 | `[:donut.graphputer/goto ...]` isn't returned, then execution stops and the last 146 | computed value is returned. 147 | 148 | ### Validation 149 | 150 | You can supply malli schemas for: 151 | 152 | - The argument passed to `:pute` 153 | - The values that a node passes to downstream nodes 154 | - The value that a node returns directly 155 | 156 | This example shows you'd handle the first to cases: 157 | 158 | ``` clojure 159 | {:pute (fn [user-params] 160 | (if-let [user (insert-user user-params)] 161 | user 162 | [::puter/goto :fail insert-user-failed])) 163 | :edges {:default :insert-user 164 | :fail :insert-user-failed} 165 | :schemas {::puter/input input-schema 166 | :default default-schema 167 | :fail fail-schema}} 168 | ``` 169 | 170 | Under `:schemas`, `::puter/input` is a schema that's used to validate the 171 | argument that will get passed in to the `:pute` function as `user-params`. 172 | `:default` and `:fail` both correspond to edge names, and validate the values 173 | that will be passed along those edges (before they actually get passed). 174 | 175 | To validate a value that's meant to be the return value for the entire 176 | execution, you use the `::puter/output` key under `:schemas`. 177 | 178 | If validation fails, an exception gets thrown. 179 | 180 | If you want to execute without using schemas for validation, include `:validate? 181 | false` in your graph definition: 182 | 183 | ``` clojure 184 | (def graph 185 | {:id :my-id 186 | :init :some-node-name 187 | :validate? false 188 | :nodes {}}) 189 | ``` 190 | 191 | ## Isn't this a state machine? 192 | 193 | Not really. Unlike a state machine, you don't send it events to advance states. 194 | -------------------------------------------------------------------------------- /build.clj: -------------------------------------------------------------------------------- 1 | (ns build 2 | "donut/graphputer's build script. Builds on https://github.com/seancorfield/honeysql/blob/develop/build.clj 3 | 4 | Run tests: 5 | clojure -X:test 6 | clojure -X:test:master 7 | For more information, run: 8 | clojure -A:deps -T:build help/doc" 9 | 10 | (:require [clojure.tools.build.api :as b] 11 | [org.corfield.build :as bb]) 12 | (:refer-clojure :exclude [test])) 13 | 14 | (def lib 'party.donut/graphputer) 15 | (def version (format "0.0.%s" (b/git-count-revs nil))) 16 | 17 | (defn deploy "Deploy the JAR to Clojars" 18 | [opts] 19 | (-> opts 20 | (assoc :lib lib :version version) 21 | (bb/deploy))) 22 | 23 | 24 | (defn jar "build a jar" 25 | [opts] 26 | (-> opts 27 | (assoc :lib lib :version version) 28 | (bb/clean) 29 | (bb/jar))) 30 | 31 | (defn install "Install the JAR locally." [opts] 32 | (-> opts 33 | (assoc :lib lib :version version) 34 | (bb/install))) 35 | 36 | (defn test "Run basic tests." [opts] 37 | (-> opts 38 | (bb/run-tests))) 39 | -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | {:paths ["src"] 2 | :deps {org.clojure/clojure {:mvn/version "1.10.3"} 3 | metosin/malli {:mvn/version "0.11.0"}} 4 | 5 | :aliases 6 | {:dev 7 | {:extra-deps {ring/ring {:mvn/version "1.9.4"}} 8 | :extra-paths ["dev" "test" "resources"]} 9 | 10 | :test 11 | {:extra-paths ["test" "resources"] 12 | :extra-deps {party.donut/system {:mvn/version "0.0.208"} 13 | io.github.cognitect-labs/test-runner 14 | {:git/tag "v0.5.0" :git/sha "48c3c67"}} 15 | :exec-fn cognitect.test-runner.api/test} 16 | 17 | :test-cljs 18 | {:extra-paths ["test"] 19 | :extra-deps {org.clojure/test.check {:mvn/version "0.9.0"} 20 | olical/cljs-test-runner {:mvn/version "3.8.0"}} 21 | :exec-fn cljs-test-runner.main/-main} 22 | 23 | :build 24 | {:deps {io.github.seancorfield/build-clj 25 | {:git/tag "v0.6.6" :git/sha "171d5f1"}} 26 | :ns-default build}}} 27 | -------------------------------------------------------------------------------- /docs/examples.clj: -------------------------------------------------------------------------------- 1 | (ns examples 2 | (:require 3 | [donut.graphputer :as puter])) 4 | 5 | (defn validate [_]) 6 | (defn insert-user [_]) 7 | 8 | (defn user-signup 9 | [params] 10 | (if-let [validation-errors (validate params)] 11 | {:status 400 12 | :body validation-errors} 13 | (if-let [user (insert-user params)] 14 | {:status 200 15 | :body user} 16 | {:status 500}))) 17 | 18 | (def user-signup-graph 19 | {:id :user-signup 20 | :init :validate 21 | :nodes 22 | {:validate 23 | {:pute (fn [user-params] 24 | (if-let [validation-errors (validate user-params)] 25 | [::puter/goto :fail validation-errors] 26 | user-params)) 27 | ;; the vector [::puter/goto :fail new-parameter] tells graphputer to follow 28 | ;; the `:fail` edge (which points to the `:validate-failed` node here) 29 | ;; and to pass in `new-parameter` to that node's `:pute` 30 | :edges {:default :insert-user 31 | :fail :validate-failed}} 32 | 33 | :validate-failed 34 | {:pute (fn [validation-errors] 35 | {:status 400 36 | :body validation-errors})} 37 | 38 | :insert-user 39 | {:pute (fn [user-params] 40 | (if-let [inserted-user (insert-user user-params)] 41 | inserted-user 42 | [::puter/goto :fail])) 43 | :edges {:default :user-signup-success 44 | :fail :insert-user-failed}} 45 | 46 | :insert-user-failed 47 | {:pute (constantly {:status 500})} 48 | 49 | :user-signup-success 50 | {:pute (fn [inserted-user] 51 | {:status 200 52 | :body inserted-user})}}}) 53 | 54 | (puter/execute user-signup-graph {:username "newuser"}) 55 | 56 | (def my-user-signup-graph 57 | (puter/splice-node 58 | user-signup-graph 59 | {:node-name :email-user-signup-success 60 | :node {:pute (fn [inserted-user] (email-user inserted-user))} 61 | :input-node-name :insert-user})) 62 | 63 | 64 | {:pute (fn [user-params] 65 | (if-let [user (insert-user user-params)] 66 | user 67 | [::puter/goto :fail insert-user-failed])) 68 | :edges {:default :insert-user 69 | :fail :insert-user-failed} 70 | :schemas {::puter/input input-schema 71 | :default default-schema 72 | :fail fail-schema}} 73 | 74 | (def graph 75 | {:id :my-id 76 | :init :some-node-name 77 | :validate? false 78 | :nodes {}}) 79 | -------------------------------------------------------------------------------- /docs/notes.md: -------------------------------------------------------------------------------- 1 | # Notes 2 | 3 | Possible visualizers: 4 | 5 | https://github.com/jerosoler/Drawflow 6 | https://github.com/projectstorm/react-diagrams 7 | https://github.com/alyssaxuu/flowy 8 | https://github.com/ishowta/DAG-ToDo 9 | https://reactflow.dev/ 10 | 11 | -------------------------------------------------------------------------------- /src/donut/graphputer.cljc: -------------------------------------------------------------------------------- 1 | (ns donut.graphputer 2 | (:require 3 | [malli.core :as m] 4 | [malli.error :as me] 5 | [clojure.data :as data])) 6 | 7 | ;; TODO splice-graph 8 | ;; - TODO handle name collisions? throw exception if there's a collision? 9 | 10 | (defn splice-node 11 | "inserts a node in the graph, re-wiring edges" 12 | [graph 13 | {:keys [node-name node input-node-name input-edge-name output-edge-name] 14 | :or {input-edge-name :default 15 | output-edge-name :default}}] 16 | (let [existing-edge (get-in graph [:nodes input-node-name :edges input-edge-name])] 17 | (-> graph 18 | (assoc-in [:nodes node-name] 19 | (update-in node [:edges output-edge-name] #(or % existing-edge))) 20 | (assoc-in [:nodes input-node-name :edges input-edge-name] 21 | node-name)))) 22 | 23 | (def NodesSchema 24 | [:map-of :keyword [:map 25 | [:pute fn?] 26 | [:edges {:optional true} [:map-of :keyword :keyword]] 27 | [:schemas {:optional true} :map]]]) 28 | 29 | (def GraphSchema 30 | [:map 31 | [:id :keyword] 32 | [:init :keyword] 33 | [:nodes NodesSchema]]) 34 | 35 | (defn validate-schema 36 | [node-name schemas schema-name value] 37 | (let [schema (schema-name schemas)] 38 | (when-let [explanation (and schema (m/explain schema value))] 39 | (throw (ex-info (str "Schema validation failed") 40 | {:node-name node-name 41 | :schema-name schema-name 42 | :spec-explain-human (me/humanize explanation) 43 | :spec-explain explanation}))))) 44 | 45 | (defn validate-graph 46 | [graph] 47 | (when-let [explanation (m/explain GraphSchema graph)] 48 | (throw (ex-info "graph definition invalid" {:spec-explain-human (me/humanize explanation) 49 | :spec-explain explanation}))) 50 | 51 | (when-not (get-in graph [:nodes (:init graph) :pute]) 52 | (throw (ex-info ":init does not refer to valid node" (select-keys graph [:init])))) 53 | 54 | (let [defined-node-names (set (keys (:nodes graph))) 55 | referenced-node-names (->> graph 56 | :nodes 57 | vals 58 | (mapcat (comp vals :edges)) 59 | (into #{(:init graph)})) 60 | [unreachable-nodes undefined-nodes] (data/diff defined-node-names referenced-node-names)] 61 | 62 | (when (seq undefined-nodes) 63 | (throw (ex-info "node edges refer to undefined nodes" {:undefined-nodes undefined-nodes}))) 64 | 65 | (when (seq unreachable-nodes) 66 | (throw (ex-info "nodes defined but no edges refer to them" {:unreachable-nodes unreachable-nodes}))))) 67 | 68 | (defn execute 69 | [{:keys [init nodes validate?] 70 | :or {validate? true} 71 | :as graph} 72 | ctx] 73 | (validate-graph graph) 74 | (loop [node-name init 75 | ctx ctx] 76 | (let [{:keys [pute edges schemas]} (node-name nodes)] 77 | (when validate? (validate-schema node-name schemas ::input ctx)) 78 | (let [result (pute ctx) 79 | [goto? edge-name new-ctx] (when (and (sequential? result) 80 | (= ::goto (first result))) 81 | result)] 82 | (cond 83 | goto? 84 | (do 85 | (when validate? (validate-schema node-name schemas edge-name new-ctx)) 86 | (recur (edge-name edges) new-ctx)) 87 | 88 | (:default edges) 89 | (do 90 | (when validate? (validate-schema node-name schemas :default result)) 91 | (recur (:default edges) result)) 92 | 93 | :else 94 | (do 95 | (when validate? (validate-schema node-name schemas ::output result)) 96 | result)))))) 97 | -------------------------------------------------------------------------------- /test/donut/graphputer_test.cljc: -------------------------------------------------------------------------------- 1 | (ns donut.graphputer-test 2 | (:require 3 | #?(:clj [clojure.test :refer [deftest is testing]] 4 | :cljs [cljs.test :refer [deftest is testing] :include-macros true]) 5 | [donut.graphputer :as puter])) 6 | 7 | (deftest test-execute-all-default 8 | (is (= {:status 200 9 | :body {:username "newuser"}} 10 | (puter/execute 11 | {:id :user-signup 12 | :init :validate 13 | 14 | :nodes 15 | {:validate 16 | {:pute (fn [ctx] ctx) 17 | :edges {:default :insert-user}} 18 | 19 | :insert-user 20 | {:pute (fn [ctx] ctx) 21 | :edges {:default :user-signup-default}} 22 | 23 | :user-signup-default 24 | {:pute (fn [ctx] 25 | {:status 200 26 | :body ctx})}}} 27 | {:username "newuser"})))) 28 | 29 | (deftest test-execute-goto-no-context 30 | (is (= :validation-failed 31 | (puter/execute 32 | {:id :user-signup 33 | :init :validate 34 | :nodes 35 | {:validate 36 | {:pute (fn [_] [::puter/goto :fail]) 37 | :edges {:fail :validate-failure}} 38 | 39 | :validate-failure 40 | {:pute (fn [_] :validation-failed)}}} 41 | {})))) 42 | 43 | (deftest test-execute-goto-with-new-context 44 | (is (= :new-context 45 | (puter/execute 46 | {:id :user-signup 47 | :init :validate 48 | :nodes 49 | {:validate 50 | {:pute (fn [_] [::puter/goto :fail :new-context]) 51 | :edges {:fail :validate-failure}} 52 | 53 | :validate-failure 54 | {:pute (fn [ctx] ctx)}}} 55 | {})))) 56 | 57 | (deftest test-splice-node 58 | (is (= {:id :user-signup 59 | :init :validate 60 | :nodes 61 | {:validate 62 | {:pute identity 63 | :edges {:default :validate-success}} 64 | 65 | ;; this is spliced in in between :validate and :insert-user 66 | :validate-success 67 | {:pute identity 68 | :edges {:default :insert-user}} 69 | 70 | :insert-user 71 | {:pute identity 72 | :edges {:default :user-signup-default}}}} 73 | (puter/splice-node 74 | {:id :user-signup 75 | :init :validate 76 | :nodes 77 | {:validate 78 | {:pute identity 79 | :edges {:default :insert-user}} 80 | 81 | :insert-user 82 | {:pute identity 83 | :edges {:default :user-signup-default}}}} 84 | {:node-name :validate-success 85 | :node {:pute identity} 86 | :input-node-name :validate})))) 87 | 88 | (deftest test-schemas 89 | (testing "validates pute input" 90 | (is (thrown? 91 | #?(:clj clojure.lang.ExceptionInfo 92 | :cljs cljs.core/ExceptionInfo) 93 | (puter/execute 94 | {:id :user-signup 95 | :init :validate 96 | 97 | :nodes 98 | {:validate 99 | {:pute (fn [ctx] ctx) 100 | :schemas {::puter/input [:map]}}}} 101 | :not-a-map))) 102 | 103 | (is (puter/execute 104 | {:id :user-signup 105 | :init :validate 106 | 107 | :nodes 108 | {:validate 109 | {:pute (fn [ctx] ctx) 110 | :schemas {::puter/input [:map]}}}} 111 | {:is :a-map}))) 112 | 113 | (testing "validates edge output" 114 | (is (thrown? 115 | #?(:clj clojure.lang.ExceptionInfo 116 | :cljs cljs.core/ExceptionInfo) 117 | (puter/execute 118 | {:id :user-signup 119 | :init :validate 120 | 121 | :nodes 122 | {:validate 123 | {:pute (fn [_] :not-a-map) 124 | :edges {:default :insert-user} 125 | :schemas {:default [:map]}} 126 | 127 | :insert-user 128 | {:pute identity}}} 129 | nil)))) 130 | 131 | (testing "validates output" 132 | (is (thrown? 133 | #?(:clj clojure.lang.ExceptionInfo 134 | :cljs cljs.core/ExceptionInfo) 135 | (puter/execute 136 | {:id :user-signup 137 | :init :validate 138 | 139 | :nodes 140 | {:validate 141 | {:pute (fn [_] :not-a-map) 142 | :schemas {::puter/output [:map]}}}} 143 | nil))))) 144 | 145 | (deftest validate-graph-:init 146 | (testing "throws when :init refers to invalid nodes" 147 | (is (thrown-with-msg? 148 | #?(:clj clojure.lang.ExceptionInfo 149 | :cljs cljs.core/ExceptionInfo) 150 | #":init does not refer to valid node" 151 | (puter/execute 152 | {:id :test 153 | :init :nonexistent-node 154 | :nodes {}} 155 | {})))) 156 | 157 | (testing "throws when node edges refer to undefined nodes" 158 | (is (thrown-with-msg? 159 | #?(:clj clojure.lang.ExceptionInfo 160 | :cljs cljs.core/ExceptionInfo) 161 | #"node edges refer to undefined nodes" 162 | (puter/execute 163 | {:id :test 164 | :init :start 165 | :nodes 166 | {:start 167 | {:pute (fn [_]) 168 | :edges {:default :undefined-node}}}} 169 | {}))))) 170 | --------------------------------------------------------------------------------