├── .github ├── FUNDING.yml ├── dependabot.yaml └── workflows │ ├── continuous-integration-workflow.yml │ └── continuous-deployment-workflow.yml ├── .gitignore ├── src ├── deps.cljs └── day8 │ └── re_frame │ └── http_fx_alpha.cljs ├── docs └── DEVELOP.adoc ├── karma.conf.js ├── LICENSE ├── project.clj ├── test └── day8 │ └── re_frame │ └── http_fx_alpha_test.cljs └── README.adoc /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: mike-thompson-day8 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | /*.iml 3 | /pom.xml 4 | /.nrepl-port 5 | /run/ 6 | /target/ 7 | /node_modules/ 8 | /.shadow-cljs/ 9 | /package.json 10 | /shadow-cljs.edn 11 | /resources/public/js/test/ 12 | -------------------------------------------------------------------------------- /src/deps.cljs: -------------------------------------------------------------------------------- 1 | {:npm-dev-deps {"shadow-cljs" "2.8.83" 2 | "karma" "4.4.1" 3 | "karma-chrome-launcher" "3.1.0" 4 | "karma-cljs-test" "0.1.0" 5 | "karma-junit-reporter" "2.0.1"}} 6 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | # Set update schedule for GitHub Actions 2 | 3 | version: 2 4 | updates: 5 | 6 | - package-ecosystem: "github-actions" 7 | directory: "/" 8 | schedule: 9 | # Check for updates to GitHub Actions every week 10 | interval: "weekly" 11 | -------------------------------------------------------------------------------- /docs/DEVELOP.adoc: -------------------------------------------------------------------------------- 1 | # `re-frame-http-fx-alpha` Development 2 | 3 | ## Requirements 4 | 5 | - link:https://nodejs.org[node.js] (v6.0.0+, most recent version preferred) 6 | - link:https://www.npmjs.com[npm] (comes bundled with `node.js`) or 7 | link:https://yarnpkg.com[yarn] 8 | - link:https://adoptopenjdk.net[Java SDK] (Version 8+, Hotspot) 9 | 10 | ## Quick Start 11 | 12 | In a terminal: 13 | 14 | ```text 15 | $ git clone git@github.com:day8/re-frame-http-fx-alpha.git 16 | $ cd re-frame-http-fx-alpha 17 | $ npm install 18 | $ lein run -m shadow.cljs.devtools.cli watch browser-test 19 | ``` 20 | 21 | Then open these links in different tabs or windows: 22 | 23 | * link:http://localhost:8290[Tests] 24 | * link:http://localhost:9630[Builds & REPL] 25 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = function (config) { 2 | var junitOutputDir = process.env.CIRCLE_TEST_REPORTS || "target/junit" 3 | 4 | config.set({ 5 | browsers: ['ChromeHeadless'], 6 | basePath: 'target', 7 | files: ['karma-test.js'], 8 | frameworks: ['cljs-test'], 9 | plugins: [ 10 | 'karma-cljs-test', 11 | 'karma-chrome-launcher', 12 | 'karma-junit-reporter' 13 | ], 14 | colors: true, 15 | logLevel: config.LOG_INFO, 16 | client: { 17 | args: ['shadow.test.karma.init'], 18 | singleRun: true 19 | }, 20 | 21 | // the default configuration 22 | junitReporter: { 23 | outputDir: junitOutputDir + '/karma', // results will be saved as outputDir/browserName.xml 24 | outputFile: undefined, // if included, results will be saved as outputDir/browserName/outputFile 25 | suite: '' // suite will become the package name attribute in xml testsuite element 26 | } 27 | }) 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Michael Thompson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/continuous-integration-workflow.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: [push] 3 | 4 | jobs: 5 | test: 6 | name: Test 7 | runs-on: ubuntu-latest 8 | container: 9 | # Source: https://github.com/day8/dockerfile-for-dev-ci-image 10 | image: ghcr.io/day8/dockerfile-for-dev-ci-image/chrome-latest:2 11 | steps: 12 | - uses: actions/checkout@v1 13 | - name: Maven cache 14 | uses: actions/cache@v4 15 | with: 16 | path: /root/.m2/repository 17 | key: ${{ runner.os }}-maven-${{ hashFiles('project.clj') }} 18 | restore-keys: | 19 | ${{ runner.os }}-maven- 20 | - name: npm cache 21 | uses: actions/cache@v4 22 | with: 23 | path: ~/.npm 24 | key: ${{ runner.os }}-npm-${{ hashFiles('project.clj') }}-${{ hashFiles('**/deps.cljs') }} 25 | restore-keys: | 26 | ${{ runner.os }}-npm- 27 | - name: shadow-cljs compiler cache 28 | uses: actions/cache@v4 29 | with: 30 | path: .shadow-cljs 31 | key: ${{ runner.os }}-shadow-cljs-${{ github.sha }} 32 | restore-keys: | 33 | ${{ runner.os }}-shadow-cljs- 34 | - run: lein karma-once 35 | - name: Slack notification 36 | uses: homoluctus/slatify@v2.0.1 37 | if: failure() || cancelled() 38 | with: 39 | type: ${{ job.status }} 40 | job_name: re-frame-http-fx-alpha Tests 41 | channel: '#oss-robots' 42 | url: ${{ secrets.SLACK_WEBHOOK }} 43 | commit: true 44 | token: ${{ secrets.GITHUB_TOKEN }} 45 | -------------------------------------------------------------------------------- /.github/workflows/continuous-deployment-workflow.yml: -------------------------------------------------------------------------------- 1 | name: cd 2 | on: 3 | push: 4 | tags: 5 | - "v[0-9]+.[0-9]+.[0-9]+*" 6 | 7 | jobs: 8 | test: 9 | name: Test 10 | runs-on: ubuntu-20.04 11 | container: 12 | # Source: https://github.com/day8/dockerfile-for-dev-ci-image 13 | image: ghcr.io/day8/dockerfile-for-dev-ci-image/chrome-latest:2 14 | steps: 15 | - uses: actions/checkout@v1 16 | - name: Maven cache 17 | uses: actions/cache@v4 18 | with: 19 | path: /root/.m2/repository 20 | key: ${{ runner.os }}-maven-${{ hashFiles('project.clj') }} 21 | restore-keys: | 22 | ${{ runner.os }}-maven- 23 | - name: npm cache 24 | uses: actions/cache@v4 25 | with: 26 | path: ~/.npm 27 | key: ${{ runner.os }}-npm-${{ hashFiles('project.clj') }}-${{ hashFiles('**/deps.cljs') }} 28 | restore-keys: | 29 | ${{ runner.os }}-npm- 30 | - name: shadow-cljs compiler cache 31 | uses: actions/cache@v4 32 | with: 33 | path: .shadow-cljs 34 | key: ${{ runner.os }}-shadow-cljs-${{ github.sha }} 35 | restore-keys: | 36 | ${{ runner.os }}-shadow-cljs- 37 | - run: lein karma-once 38 | - name: Slack notification 39 | uses: homoluctus/slatify@v2.0.1 40 | if: failure() || cancelled() 41 | with: 42 | type: ${{ job.status }} 43 | job_name: re-frame-http-fx-alpha Tests 44 | channel: '#oss-robots' 45 | url: ${{ secrets.SLACK_WEBHOOK }} 46 | commit: true 47 | token: ${{ secrets.GITHUB_TOKEN }} 48 | release: 49 | name: Release 50 | needs: test 51 | runs-on: ubuntu-20.04 52 | container: 53 | # Source: https://github.com/day8/dockerfile-for-dev-ci-image 54 | image: ghcr.io/day8/dockerfile-for-dev-ci-image/chrome-latest:2 55 | steps: 56 | - uses: actions/checkout@v1 57 | - name: Maven cache 58 | uses: actions/cache@v4 59 | with: 60 | path: /root/.m2/repository 61 | key: ${{ runner.os }}-maven-${{ hashFiles('project.clj') }} 62 | restore-keys: | 63 | ${{ runner.os }}-maven- 64 | - name: Run lein release 65 | run: | 66 | CLOJARS_USERNAME=${{ secrets.CLOJARS_USERNAME }} CLOJARS_PASSWORD=${{ secrets.CLOJARS_PASSWORD }} GITHUB_USERNAME=${{ github.actor }} GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }} lein release 67 | - name: Slack notification 68 | uses: homoluctus/slatify@v2.0.1 69 | if: always() 70 | with: 71 | type: ${{ job.status }} 72 | job_name: re-frame-http-fx-alpha Deployment 73 | channel: '#oss-robots' 74 | url: ${{ secrets.SLACK_WEBHOOK }} 75 | commit: true 76 | token: ${{ secrets.GITHUB_TOKEN }} 77 | -------------------------------------------------------------------------------- /project.clj: -------------------------------------------------------------------------------- 1 | (defproject day8.re-frame/http-fx-alpha "lein-git-inject/version" 2 | :description "A re-frame effects handler for fetching resources (including across the network)." 3 | :url "https://github.com/day8/re-frame-http-fx-alpha.git" 4 | :license {:name "MIT"} 5 | 6 | :dependencies [[org.clojure/clojure "1.10.1" :scope "provided"] 7 | [org.clojure/clojurescript "1.10.597" :scope "provided" 8 | :exclusions [com.google.javascript/closure-compiler-unshaded 9 | org.clojure/google-closure-library 10 | org.clojure/google-closure-library-third-party]] 11 | [thheller/shadow-cljs "2.8.83" :scope "provided"] 12 | [re-frame "0.10.9" :scope "provided"]] 13 | 14 | :profiles {:dev {:dependencies [[binaryage/devtools "0.9.11"] 15 | [karma-reporter "3.1.0"]]}} 16 | 17 | :plugins [[day8/lein-git-inject "0.0.11"] 18 | [lein-shadow "0.1.7"] 19 | [lein-shell "0.5.0"]] 20 | 21 | :middleware [leiningen.git-inject/middleware] 22 | 23 | :clean-targets [:target-path 24 | "shadow-cljs.edn" 25 | "package.json" 26 | "package-lock.json" 27 | "resources/public/js/test"] 28 | 29 | :jvm-opts ["-Xmx1g"] 30 | 31 | :source-paths ["src"] 32 | :test-paths ["test"] 33 | :resource-paths ["run/resources"] 34 | 35 | :shadow-cljs {:nrepl {:port 8777} 36 | 37 | :builds {:browser-test 38 | {:target :browser-test 39 | :ns-regexp "-test$" 40 | :test-dir "resources/public/js/test" 41 | :devtools {:http-root "resources/public/js/test" 42 | :http-port 8290}} 43 | 44 | :karma-test 45 | {:target :karma 46 | :ns-regexp "-test$" 47 | :output-to "target/karma-test.js"}}} 48 | 49 | :aliases {"dev-auto" ["with-profile" "dev" "do" 50 | ["clean"] 51 | ["shadow" "watch" "browser-test"]] 52 | "karma-once" ["do" 53 | ["clean"] 54 | ["shadow" "compile" "karma-test"] 55 | ["shell" "karma" "start" "--single-run" "--reporters" "junit,dots"]]} 56 | 57 | :deploy-repositories [["clojars" {:sign-releases false 58 | :url "https://clojars.org/repo" 59 | :username :env/CLOJARS_USERNAME 60 | :password :env/CLOJARS_PASSWORD}]] 61 | 62 | :release-tasks [["deploy" "clojars"]]) 63 | -------------------------------------------------------------------------------- /test/day8/re_frame/http_fx_alpha_test.cljs: -------------------------------------------------------------------------------- 1 | (ns day8.re-frame.http-fx-alpha-test 2 | (:require 3 | [clojure.test :refer [deftest is testing async use-fixtures]] 4 | [clojure.spec.alpha :as s] 5 | [goog.object :as obj] 6 | [re-frame.core :as re-frame] 7 | [day8.re-frame.http-fx-alpha :as http-fx-alpha])) 8 | 9 | ;; Utilities 10 | ;; ============================================================================= 11 | 12 | (deftest ->seq-test 13 | (is (= [{}] 14 | (http-fx-alpha/->seq {}))) 15 | (is (= [{}] 16 | (http-fx-alpha/->seq [{}]))) 17 | (is (= [nil] 18 | (http-fx-alpha/->seq nil)))) 19 | 20 | (deftest ->str-test 21 | (is (= "" 22 | (http-fx-alpha/->str nil))) 23 | (is (= "42" 24 | (http-fx-alpha/->str 42))) 25 | (is (= "salient" 26 | (http-fx-alpha/->str :salient))) 27 | (is (= "symbolic" 28 | (http-fx-alpha/->str 'symbolic)))) 29 | 30 | (deftest ->params->str-test 31 | (is (= "" 32 | (http-fx-alpha/params->str nil))) 33 | (is (= "" 34 | (http-fx-alpha/params->str {}))) 35 | (is (= "?sort=desc&start=0" 36 | (http-fx-alpha/params->str {:sort :desc :start 0}))) 37 | (is (= "?fq=Expect%20nothing%2C%20%5Ba-z%26%26%5B%5Eaeiou%5D%5D&debug=timing" 38 | (http-fx-alpha/params->str {:fq "Expect nothing, [a-z&&[^aeiou]]" 39 | :debug 'timing})))) 40 | 41 | (deftest headers->js-test 42 | (let [js-headers (http-fx-alpha/headers->js {:content-type "application/json"})] 43 | (is (instance? js/Headers js-headers)) 44 | (is (= "application/json" 45 | (.get js-headers "content-type"))))) 46 | 47 | (deftest request->js-init-test 48 | (let [controller (js/AbortController.) 49 | js-init (http-fx-alpha/request->js-init 50 | {:method "GET"} 51 | controller)] 52 | (is (= "{\"signal\":{},\"method\":\"GET\",\"mode\":\"same-origin\",\"credentials\":\"include\",\"redirect\":\"follow\"}" 53 | (js/JSON.stringify js-init))) 54 | (is (= (.-signal controller) 55 | (.-signal js-init))))) 56 | 57 | (deftest js-headers->clj-test 58 | (let [headers {:content-type "application/json" 59 | :server "nginx"}] 60 | (is (= headers 61 | (http-fx-alpha/js-headers->clj (http-fx-alpha/headers->js headers)))))) 62 | 63 | (deftest js-response->clj 64 | (is (= {:url "" 65 | :ok? true 66 | :redirected? false 67 | :status 200 68 | :status-text "" 69 | :type "default" 70 | :final-uri? nil 71 | :headers {}} 72 | (http-fx-alpha/js-response->clj (js/Response.))))) 73 | 74 | (deftest response->reader-test 75 | (is (= :text 76 | (http-fx-alpha/response->reader 77 | {} 78 | {:headers {:content-type "application/json"}}))) 79 | (is (= :blob 80 | (http-fx-alpha/response->reader 81 | {:content-types {"text/plain" :blob}} 82 | {:headers {}}))) 83 | (is (= :json 84 | (http-fx-alpha/response->reader 85 | {:content-types {#"(?i)application/.*json" :json}} 86 | {:headers {:content-type "application/json"}})))) 87 | 88 | (deftest timeout-race-test 89 | (async done 90 | (-> (http-fx-alpha/timeout-race 91 | (js/Promise. 92 | (fn [_ reject] 93 | (js/setTimeout #(reject :winner) 16))) 94 | 32) 95 | (.catch (fn [value] 96 | (is (= :winner value)) 97 | (done))))) 98 | (async done 99 | (-> (http-fx-alpha/timeout-race 100 | (js/Promise. 101 | (fn [_ reject] 102 | (js/setTimeout #(reject :winner) 32))) 103 | 16) 104 | (.catch (fn [value] 105 | (is (= :timeout value)) 106 | (done)))))) 107 | 108 | ;; Profiles 109 | ;; ============================================================================= 110 | 111 | ; {:params {:sort :desc, :jwt "eyJhbGc..."}, :headers {:accept "application/json", :authorization "Bearer eyJhbGc..."}, :path [:a :b :c :d :e], :fsm {:in-process [:in-process-a], :in-problem [:in-problem-b]}, :timeout 5000, :profiles [:xyz :jwt], :action :GET, :url "http://api.example.com/articles"} 112 | ; {:params {:sort :desc, :jwt "eyJhbGc..."}, :headers {:accept "application/json", :authorization "Bearer eyJhbGc..."}, :path [:d :e], :fsm {:in-process [:in-process-a], :in-problem [:in-problem-b]}, :timeout 5000 :profiles [:xyz :jwt], :action :GET, :url "http://api.example.com/articles",})) 113 | 114 | (deftest conj-profiles-test 115 | (is (= {:params {:sort :desc 116 | :jwt "eyJhbGc..."} 117 | :headers {:accept "application/json" 118 | :authorization "Bearer eyJhbGc..."} 119 | :path [:a :b :c :d :e] 120 | :fsm {:in-process [:in-process-a] 121 | :in-problem [:in-problem-b]} 122 | :timeout 5000 123 | :profiles [:xyz :jwt] 124 | :action :GET 125 | :url "http://api.example.com/articles"} 126 | (http-fx-alpha/conj-profiles 127 | {:profiles [:xyz :jwt] 128 | :action :GET 129 | :url "http://api.example.com/articles" 130 | :fsm {:in-process [:in-process-a] 131 | :in-problem [:in-problem-a]} 132 | :params {:sort :desc} 133 | :headers {:accept "application/json"} 134 | :path [:a :b :c]} 135 | [{:reg-profile :xyz 136 | :values {:fsm {:in-problem [:in-problem-b]} 137 | :timeout 5000}} 138 | {:reg-profile :jwt 139 | :values {:params {:jwt "eyJhbGc..."} 140 | :headers {:authorization "Bearer eyJhbGc..."} 141 | :path [:d :e]}}])))) 142 | 143 | ;; Requests 144 | ;; ============================================================================= 145 | 146 | (deftest fsm-swap-fn 147 | (is (= {:http-xyz {::http-fx-alpha/request {:state :problem} 148 | ::http-fx-alpha/js-controller {}}} 149 | (http-fx-alpha/fsm-swap-fn 150 | {:http-xyz {::http-fx-alpha/request {:state :waiting} 151 | ::http-fx-alpha/js-controller {}}} 152 | :http-xyz 153 | :problem 154 | nil))) 155 | (is (= {:http-xyz {::http-fx-alpha/request {:state :cancelled} 156 | ::http-fx-alpha/js-controller nil}} 157 | (http-fx-alpha/fsm-swap-fn 158 | {:http-xyz {::http-fx-alpha/request {:state :waiting} 159 | ::http-fx-alpha/js-controller {}}} 160 | :http-xyz 161 | :cancelled 162 | nil))) 163 | (is (= {:http-xyz {::http-fx-alpha/request {:state :failed 164 | :problem :fsm 165 | :problem-from-state :waiting 166 | :problem-to-state :done} 167 | ::http-fx-alpha/js-controller {}}} 168 | (http-fx-alpha/fsm-swap-fn 169 | {:http-xyz {::http-fx-alpha/request {:state :waiting} 170 | ::http-fx-alpha/js-controller {}}} 171 | :http-xyz 172 | :done 173 | nil)))) 174 | -------------------------------------------------------------------------------- /src/day8/re_frame/http_fx_alpha.cljs: -------------------------------------------------------------------------------- 1 | (ns day8.re-frame.http-fx-alpha 2 | (:require 3 | [goog.object :as obj] 4 | [re-frame.core :refer [reg-fx reg-event-fx dispatch console]] 5 | [clojure.string :as string] 6 | [clojure.set])) 7 | 8 | ;; Utilities 9 | ;; ============================================================================= 10 | 11 | (defn ->seq 12 | "Returns x if x satisfies ISequential, otherwise vector of x." 13 | [x] 14 | (if (sequential? x) x [x])) 15 | 16 | (defn ->str 17 | "Returns the name String of x if x is a symbol or keyword, otherwise 18 | x.toString()." 19 | [x] 20 | (if (or (symbol? x) 21 | (keyword? x)) 22 | (name x) 23 | (str x))) 24 | 25 | (defn params->str 26 | "Returns a URI-encoded string of the params." 27 | [params] 28 | (if (zero? (count params)) 29 | "" 30 | (let [pairs (reduce-kv 31 | (fn [ret k v] 32 | (conj ret (str (js/encodeURIComponent (->str k)) "=" 33 | (js/encodeURIComponent (->str v))))) 34 | [] 35 | params)] 36 | (str "?" (string/join "&" pairs))))) 37 | 38 | (defn headers->js 39 | "Returns a new js/Headers JavaScript object of the ClojureScript map of headers." 40 | [headers] 41 | (reduce-kv 42 | (fn [js-headers header-name header-value] 43 | (doto js-headers 44 | (.append (->str header-name) 45 | (->str header-value)))) 46 | (js/Headers.) 47 | headers)) 48 | 49 | (defn request->js-init 50 | "Returns an init options js/Object to use as the second argument to js/fetch." 51 | [{:keys [method headers body mode credentials cache redirect referrer integrity] :as request} 52 | js-controller] 53 | (let [mode (or mode "same-origin") 54 | credentials (or credentials "include") 55 | redirect (or redirect "follow")] 56 | (doto 57 | #js {;; There is always a controller, as in our impl all requests can be 58 | ;; aborted. 59 | :signal (.-signal js-controller) 60 | 61 | ;; There is always a method, as dispatch is via sub-effects like :get. 62 | :method method 63 | 64 | ;; Although the below keys are usually optional, the default between 65 | ;; different browsers is inconsistent so we always set our own default. 66 | 67 | ;; Possible: cors no-cors same-origin navigate 68 | :mode mode 69 | 70 | ;; Possible: omit same-origin include 71 | :credentials credentials 72 | 73 | ;; Possible: follow error manual 74 | :redirect redirect} 75 | 76 | ;; Everything else is optional... 77 | (cond-> headers (obj/set "headers" (headers->js headers))) 78 | 79 | (cond-> body (obj/set "body" body)) 80 | 81 | ;; Possible: default no-store reload no-cache force-cache only-if-cached 82 | (cond-> cache (obj/set "cache" cache)) 83 | 84 | ;; Possible: no-referrer client 85 | (cond-> referrer (obj/set "referrer" referrer)) 86 | 87 | ;; Sub-resource integrity string 88 | (cond-> integrity (obj/set "integrity" integrity))))) 89 | 90 | (defn js-headers->clj 91 | "Returns a new ClojureScript map of the js/Headers JavaScript object." 92 | [js-headers] 93 | (reduce 94 | (fn [headers [header-name header-value]] 95 | (assoc headers (keyword header-name) header-value)) 96 | {} 97 | (es6-iterator-seq (.entries js-headers)))) 98 | 99 | (defn js-response->clj 100 | "Returns a new ClojureScript map of the js/Response JavaScript object." 101 | [js-response] 102 | {:url (.-url js-response) 103 | :ok? (.-ok js-response) 104 | :redirected? (.-redirected js-response) 105 | :status (.-status js-response) 106 | :status-text (.-statusText js-response) 107 | :type (.-type js-response) 108 | :final-uri? (.-useFinalURL js-response) 109 | :headers (js-headers->clj (.-headers js-response))}) 110 | 111 | (defn response->reader 112 | "Returns a keyword of the type of reader to use for the body of the 113 | response according to the Content-Type header." 114 | [{:keys [content-types]} response] 115 | (let [content-type (get-in response [:headers :content-type] "text/plain")] 116 | (reduce-kv 117 | (fn [ret pattern reader] 118 | (if (or (and (string? pattern) (= content-type pattern)) 119 | (and (regexp? pattern) (re-matches pattern content-type))) 120 | (reduced reader) 121 | ret)) 122 | :text 123 | content-types))) 124 | 125 | (defn timeout-race 126 | "Returns a js/Promise JavaScript object that is a race between another 127 | js/Promise JavaScript object and timeout in ms if timeout is not nil, 128 | otherwise js-promise." 129 | [js-promise timeout] 130 | (if timeout 131 | (.race js/Promise 132 | #js [js-promise 133 | (js/Promise. 134 | (fn [_ reject] 135 | (js/setTimeout #(reject :timeout) timeout)))]) 136 | js-promise)) 137 | 138 | ;; Effect Dispatch to Actions 139 | ;; ============================================================================= 140 | 141 | (defmulti action :action) 142 | 143 | (defn http-fx 144 | "Executes the HTTP effect via value-based dispatch to sub-effects." 145 | [effect] 146 | (let [seq-of-sub-effects (->seq effect)] 147 | (doseq [effect seq-of-sub-effects] 148 | (action effect)))) 149 | 150 | (reg-fx :http http-fx) 151 | 152 | (defmethod action :default 153 | [m] 154 | (console :error "http fx: no matching :action for " m)) 155 | 156 | ;; Profiles 157 | ;; ============================================================================= 158 | 159 | (def profile-id->profile 160 | "An Atom that contains a mapping of profile-ids to profile maps." 161 | (atom {})) 162 | 163 | (defn profile-swap-fn 164 | [current {profile-id :id 165 | default? :default? 166 | :as profile}] 167 | (cond-> current 168 | ;; Store default? profile-id as 169 | ;; 1) we need to know the 'latest' default; and 170 | ;; 2) avoid walking all profiles to find default; and 171 | ;; 3) we do not want to overwrite access to earlier default 172 | ;; profile(s) that may still be available via profile-id. 173 | default? 174 | (assoc ::default-profile-id profile-id) 175 | 176 | :always 177 | (assoc profile-id profile))) 178 | 179 | (defmethod action :reg-profile 180 | [profile] 181 | (swap! profile-id->profile profile-swap-fn profile)) 182 | 183 | (defmethod action :unreg-profile 184 | [{profile-id :id}] 185 | (swap! profile-id->profile #(dissoc %1 %2) profile-id)) 186 | 187 | (defn get-profile 188 | "Returns a profile map for the profile-id if one exists in profiles, otherwise 189 | nil." 190 | [profiles profile-id] 191 | (case profile-id 192 | ;; Special case :none means do not get any profile! 193 | :none nil 194 | 195 | ;; nil effectively means no profile was requested, thus get default. 196 | nil (get profiles (get profiles ::default-profile-id)) 197 | 198 | ;; Otherwise, just get the profile with the profile-id. 199 | (get profiles profile-id))) 200 | 201 | (defn get-profiles 202 | "Returns a lazy sequence of profile maps for the profile-id(s)." 203 | [profile-id] 204 | (let [seq-of-profile-ids (if (sequential? profile-id) profile-id [profile-id]) 205 | profiles @profile-id->profile] 206 | (->> seq-of-profile-ids 207 | (map (partial get-profile profiles)) 208 | (filter identity)))) 209 | 210 | (defn conj-profiles 211 | "Returns a new request-state map with the seq-of-profile-maps 'added'." 212 | [request-state seq-of-profile-maps] 213 | (reduce 214 | (fn [ret {:keys [values]}] 215 | (reduce-kv 216 | (fn [ret k profile-value] 217 | (let [existing-value (get ret k) 218 | v' (cond 219 | (and (map? profile-value) 220 | (map? existing-value)) 221 | (merge existing-value profile-value) 222 | 223 | (and (set? profile-value) 224 | (set? existing-value)) 225 | (clojure.set/union existing-value profile-value) 226 | 227 | (and (string? profile-value) 228 | (string? existing-value)) 229 | (str profile-value existing-value) 230 | 231 | (and (coll? profile-value) 232 | (coll? existing-value)) 233 | (concat existing-value profile-value) 234 | 235 | :default 236 | profile-value)] 237 | (assoc ret k v'))) 238 | ret 239 | values)) 240 | request-state 241 | seq-of-profile-maps)) 242 | 243 | (defn +profiles 244 | "" 245 | [{:keys [profiles] :as request-state}] 246 | (->> profiles 247 | (get-profiles) 248 | (conj-profiles request-state))) 249 | 250 | ;; Requests 251 | ;; ============================================================================= 252 | 253 | (def request-id->request-and-controller 254 | "An Atom that contains a mapping of request-ids to requests and their 255 | associated js/AbortController; i.e., 256 | {:http-123 {::request {:state :waiting :method... } 257 | ::js-controller js/AbortController}}" 258 | (atom {})) 259 | 260 | (def fsm 261 | "A mapping of states to valid transitions out of that state." 262 | {:requested #{:setup :failed} 263 | :setup #{:waiting} 264 | :waiting #{:problem :processing :cancelled} 265 | :problem #{:waiting :failed} 266 | :processing #{:failed :succeeded} 267 | :failed #{:teardown} 268 | :succeeded #{:teardown} 269 | :cancelled #{:teardown} 270 | :teardown #{}}) 271 | 272 | (def trigger->to-state 273 | "A mapping of triggers to states." 274 | {:request :setup 275 | :send :waiting 276 | :retry :waiting 277 | :problem :problem 278 | :success :processing 279 | :fail :failed 280 | :processed :succeeded 281 | :abort :cancelled 282 | :done :teardown}) 283 | 284 | (def fsm->event-keys 285 | "A mapping of states to the event handler to dispatch in that state." 286 | {:setup :in-setup 287 | :waiting :in-wait 288 | :problem :in-problem 289 | :processing :in-process 290 | :cancelled :in-cancelled 291 | :failed :in-failed 292 | :succeeded :in-succeeded 293 | :teardown :in-teardown}) 294 | 295 | (defn fsm-swap-fn 296 | "In current value of request-id->request-and-controller moves state of request 297 | with request-id to-state if it is a valid state transition, otherwise moves 298 | state to :failed." 299 | [current request-id to-state merge-request-state] 300 | (let [{::keys [request]} (get current request-id) 301 | {from-state :state} request 302 | valid-to-state? (get fsm from-state #{})] 303 | (if (valid-to-state? to-state) 304 | (cond-> current 305 | (= to-state :cancelled) 306 | (assoc-in [request-id ::js-controller] nil) 307 | true 308 | (assoc-in [request-id ::request :state] to-state) 309 | true 310 | (update-in [request-id ::request] merge merge-request-state)) 311 | (update-in current [request-id ::request] assoc 312 | :state :failed 313 | :problem :fsm 314 | :problem-from-state from-state 315 | :problem-to-state to-state)))) 316 | 317 | ;; TODO handle undefined event handler(s); default event handlers 318 | 319 | (defn fsm->! 320 | "Moves state of request with request-id to-state if it is a valid state 321 | transition, otherwise to :failed. Dispatches to the appropriate event handler. 322 | Returns nil." 323 | ([request-id to-state] 324 | (fsm->! request-id to-state nil)) 325 | ([request-id to-state merge-request-state] 326 | (let [[_ {{{:keys [state] :as request-state} ::request 327 | js-controller ::js-controller} request-id}] 328 | (swap-vals! request-id->request-and-controller 329 | fsm-swap-fn request-id to-state merge-request-state) 330 | event-key (get fsm->event-keys state) 331 | event (get-in request-state [:fsm event-key] [:no-handler]) 332 | event' (conj event request-state)] 333 | (when (= :cancelled state) 334 | (.abort js-controller)) 335 | (when (not= :waiting state) 336 | (dispatch event'))))) 337 | 338 | (defn body-handler 339 | "Dispatches the request with request-id and the associated response with a 340 | body to the appropriate event handler. Returns nil." 341 | [request-id response reader js-body] 342 | (let [body (if (= :json reader) (js->clj js-body :keywordize-keys true) js-body) 343 | response' (assoc response :body body)] 344 | (if (:ok? response') 345 | (fsm->! request-id :processing {:response response'}) 346 | (fsm->! request-id :problem {:response response' 347 | :problem :server})))) 348 | 349 | (defn body-failed-handler 350 | "Dispatches the request with request-id and the associated response to the 351 | in-failed event handler due to a failure reading the body. Returns nil." 352 | [request-id response reader js-error] 353 | ;(console :error js-error) 354 | (fsm->! request-id :failed {:response response 355 | :problem :body})) 356 | 357 | (defn response-handler 358 | "Reads the js/Response JavaScript object stream, that is associated with the 359 | request with request-id, to completion. Returns nil." 360 | [request-id js-response] 361 | (let [{{request ::request} request-id} @request-id->request-and-controller 362 | response (js-response->clj js-response) 363 | reader (response->reader request response)] 364 | (-> (case reader 365 | :json (.json js-response) 366 | :form-data (.formData js-response) 367 | :blob (.blob js-response) 368 | :array-buffer (.arrayBuffer js-response) 369 | :text (.text js-response)) 370 | (.then (partial body-handler request-id response reader)) 371 | (.catch (partial body-failed-handler request-id response reader))))) 372 | 373 | (defn problem-handler 374 | [request-id js-error] 375 | ;;(console :error js-error) 376 | (fsm->! request-id :problem {:problem js-error})) 377 | 378 | (defn fetch 379 | "Initiate the request. Returns nil." 380 | [{:keys [request-id]}] 381 | (let [{::keys [request js-controller]} (get @request-id->request-and-controller request-id) 382 | {:keys [url timeout]} request] 383 | (-> (timeout-race (js/fetch url (request->js-init request js-controller)) timeout) 384 | (.then (partial response-handler request-id)) 385 | (.catch (partial problem-handler request-id))) 386 | nil)) 387 | 388 | (defn setup 389 | "Initialise the request. Returns nil." 390 | [{:keys [url params] :as request}] 391 | (let [request-id (keyword (gensym "http-")) 392 | url' (str url (params->str params)) 393 | request' (-> request 394 | (merge {:request-id request-id 395 | :url url' 396 | :state :requested}) 397 | (+profiles)) 398 | js-controller (js/AbortController.)] 399 | (swap! request-id->request-and-controller 400 | #(assoc %1 %2 %3) 401 | request-id 402 | {::request request' 403 | ::js-controller js-controller}) 404 | (fsm->! request-id :setup) 405 | nil)) 406 | 407 | (defmethod action :GET 408 | [m] 409 | (setup (merge m {:method "GET"}))) 410 | 411 | (defmethod action :HEAD 412 | [m] 413 | (setup (merge m {:method "HEAD"}))) 414 | 415 | (defmethod action :POST 416 | [m] 417 | (setup (merge m {:method "POST"}))) 418 | 419 | (defmethod action :PUT 420 | [m] 421 | (setup (merge m {:method "PUT"}))) 422 | 423 | (defmethod action :DELETE 424 | [m] 425 | (setup (merge m {:method "DELETE"}))) 426 | 427 | (defmethod action :OPTIONS 428 | [m] 429 | (setup (merge m {:method "OPTIONS"}))) 430 | 431 | ;; FSM Trigger 432 | ;; ================================================================================= 433 | 434 | (defmethod action :trigger 435 | [{:keys [request-id trigger]}] 436 | (let [request (get-in @request-id->request-and-controller [request-id ::request]) 437 | to-state (get trigger->to-state trigger)] 438 | (when (#{:send :retry} trigger) 439 | (fetch request)) 440 | (fsm->! request-id to-state))) 441 | 442 | ;; Abort 443 | ;; ================================================================================= 444 | 445 | (defmethod action :abort 446 | [{:keys [request-id]}] 447 | (fsm->! request-id :cancelled)) 448 | 449 | (defn abort-event-handler 450 | "Generic HTTP abort event handler." 451 | [_ [_ request-id]] 452 | {:http {:action :abort 453 | :request-id request-id}}) 454 | 455 | (reg-event-fx :abort abort-event-handler) -------------------------------------------------------------------------------- /README.adoc: -------------------------------------------------------------------------------- 1 | :source-highlighter: coderay 2 | :source-language: clojure 3 | :toc: 4 | :toc-placement: preamble 5 | :sectlinks: 6 | :sectanchors: 7 | :toc: 8 | :icons: font 9 | 10 | > Don't use this library - the code is not ready. + 11 | 12 | 13 | image:https://github.com/day8/re-frame-http-fx-alpha/workflows/ci/badge.svg["CI", link="https://github.com/day8/re-frame-http-fx-alpha/actions?workflow=ci"] 14 | image:https://img.shields.io/github/v/tag/day8/re-frame-http-fx-alpha?style=flat["GitHub tag (latest by date)", link="https://github.com/day8/re-frame-http-fx-alpha/tags"] 15 | image:https://img.shields.io/clojars/v/day8.re-frame/http-fx-alpha.svg["Clojars Project", link="https://clojars.org/day8.re-frame/http-fx-alpha"] 16 | image:https://img.shields.io/github/issues-raw/day8/re-frame-http-fx-alpha?style=flat["GitHub issues", link="https://github.com/day8/re-frame-http-fx-alpha/issues"] 17 | image:https://img.shields.io/github/issues-pr/day8/re-frame-http-fx-alpha?style=flat["GitHub pull requests", link="https://github.com/day8/re-frame-http-fx-alpha/pulls"] 18 | image:https://img.shields.io/github/license/day8/re-frame-http-fx-alpha?style=flat["License", link="https://github.com/day8/re-frame-http-fx-alpha/blob/master/LICENSE"] 19 | 20 | == Easy and Robust HTTP Requests 21 | 22 | A re-frame library for performing HTTP requests using an effect with key `:http` 23 | 24 | It is an improved version of the original https://github.com/day8/re-frame-http-fx[re-frame-http-fx library] 25 | 26 | == Overview 27 | 28 | HTTP requests are simple, right? 29 | 30 | You send off a request, you get back a response, and you store it in `app-db`. 31 | Done. 32 | 33 | Except _**requests are anything but simple**_. There is a happy 34 | path, yes, but it winds through a deceptively dense briar thicket of fiddly 35 | issues. Accessing unreliable servers across unreliable networks using an async 36 | flow of control is hard enough, what with the multiple failure paths 37 | and possible retries but, on top of that, there's also the 38 | *_cross-cutting issues_* of managing UI updates, user-initiated cancellations, 39 | error recovery, logging, statistics gathering, etc. 40 | 41 | Many programmers instinctively shy away from the tentacles of this complexity, 42 | pragmatically preferring naive but simple solutions. And yet, even after accepting 43 | that trade-off, they can find themselves with an uncomfortable amount of 44 | repetition and boilerplate. 45 | 46 | This library has two goals: 47 | 48 | 1. proper treatment for failure paths (robustness and a better user experience) 49 | 2. you write less code for each request (no repetition or fragile boilerplate) 50 | 51 | These two goals might typically pull in opposite directions, but this library 52 | saves you from the horns of this dilemma and seeks to let you have your 53 | cake and eat it too. In fact, maybe even eat it twice. But no chocolate 54 | sprinkles. We're not monsters. 55 | 56 | == An Indicative Code Fragment 57 | 58 | Here's a re-frame event handler returning an effect keyed `:http` (see the last three lines): 59 | [source, Clojure] 60 | ---- 61 | (ref-event-fx 62 | :switch-to-articles-panel 63 | (fn [{:keys [db]} _] 64 | ;; Note the following :http effect 65 | {:http {:action :GET 66 | :url "http://api.something.com/articles/" 67 | :path [:a :path :within :app-db]}})) 68 | ---- 69 | 70 | 71 | That `:http` effect will do an HTTP GET and place any response data into 72 | `app-db` at the given `:path`. 73 | 74 | But wait, there's more. 75 | 76 | This request will be retried on timeouts, and 503 77 | failure, etc. Logs will be written, errors will be reported, and interesting 78 | statistics will be captured. The user will see a busy-twirly and be able to 79 | cancel the request by hitting a button. The response data can be processed from 80 | JSON and transit into EDN, before being put into right place within `app-db`. Etc. 81 | 82 | So, by using just three lines of code - simple ones at that - you'll get a robust HTTP 83 | request process. However, as you'll soon see, there's no magic happening to 84 | achieve the outcome. You'll need to *_compose the right defaults_*. 85 | 86 | The cost? Some upfront learning so you can fashion these 87 | *_right defaults_* for your application. Let us begin that learning now ... 88 | 89 | == An Overview 90 | 91 | I have some good news and some bad news — first, the good news. 92 | 93 | This library models an HTTP request using a Finite State Machine (hereafter FSM) 94 | - one which captures the various failure paths and retry paths, etc. That 95 | leads to a robust and understandable request process. 96 | 97 | Also, this library wraps `fetch`, making it central to the FSM, and that delivers a 98 | browser-modern way to do the network layer. 99 | 100 | But, that's the end of the good news. The bad news is that you'll be 101 | doing most of the work. 102 | 103 | Most parts of the FSM are left unimplemented, and you will need to fill in 104 | these blanks by writing a number of *_Logical State Handlers_*. 105 | 106 | A `Logical State Handler` provides the behaviour for a single 107 | *_Logical State_* in the FSM. To help you, this library will provide "a recipe" 108 | for each of these handlers, describing what your implementation might reasonably 109 | do. So, for example, in the `Failed` state, the recipe might 110 | say that you "perform error logging and error reporting to the user" and "recover the application 111 | into a stable state, in light of the failure". 112 | 113 | Of course, only 114 | you, the application programmer, know how to implement "a recipe" correctly for your 115 | application. And that's precisely why the FSM's implementation is left incomplete 116 | and why you are asked to fill in the blanks. 117 | 118 | NOTE: With a bit of squinting and head tilting, you can see a 119 | *_Logical State Handler_* as something of a *_callback_*. For example, writing a 120 | `Logical State Handler` for the `Failed State` is very similar to supplying an 121 | `on-failure` callback. Except, of course, because we're in 122 | re-frame land, the mechanism will be more `dispatch-back` than `callback`. 123 | 124 | Each `Logical State Handler` you write has to be something of a "good citizen" 125 | within the overall composition (follow the task recipe). If your 126 | `Logical State handler` fails to do the right thing, any FSM you compose using 127 | it will be, to some extent, dysfunctional. I have every confidence in you. 128 | 129 | Later, once you have written the necessary set of `Logical State Handlers`, you 130 | *_compose them to form a functioning FSM_*. And, because this is a ClojureScript 131 | library, this composition happens in a *_data oriented_* way. 132 | 133 | If your application's needs are sufficiently complicated, you can create 134 | multiple FSM compositions within the one app, with each configuration designed 135 | for a different kind of request. 136 | 137 | And, if you use one regularly, you can nominate it to be 138 | *_the default FSM_*. That three-line example I presented earlier owes its brevity to 139 | (implicitly) using a `default` FSM composition. 140 | 141 | Finally, let's talk about state. 142 | 143 | XXX logical state 144 | 145 | XXX extended state 146 | 147 | Each FSM instance has some 148 | working state, which we call `request-state`. In addition, there will be 149 | some state stored within `app-db` which is also associated with a 150 | request - we call that `path-state`. Your 151 | `Logical State handlers`are responsible for pushing/projecting parts of 152 | `request-state` through into `path-state`, so that your UI can render the state of 153 | the request. Again, only you, the application programmer, know how you want this 154 | done, although we will certainly be suggesting some patterns. For 155 | example, you might write a `:retrying?` value of `true` into `path-state` which then 156 | causes the UI to render "Sorry about the delay. There was a problem with the 157 | network connection, so we're trying again". 158 | 159 | So, in summary: 160 | 161 | * you should get to know the FSM topology (in the next section) 162 | * you will implement the blank parts of the FSM but writing a set of 163 | `Logical State handlers`, following the recipe for each. 164 | * you will *_compose an ensemble_* of these `Logical State handlers` to form a FSM 165 | * your FSM will likely push/project aspects of `request-state` into `path-state` 166 | * you will write views in terms of what's in `path-state`, to show the 167 | user what's happening 168 | * when you make an HTTP request, you'll be 169 | nominating which FSM to use (or you will implicitly be using your default FSM) 170 | 171 | == The FSM 172 | 173 | An HTTP request is a multi-step process which plays out over time. This library models this process as a *_Machine_*. 174 | 175 | By a *_Machine_*, I’m referring to the abstract concept of something which does something. 176 | This library uses a specific kind of Machine called a _*Finte State Machine (FSM)_*, which 177 | is one that has a fixed, finite number of *_States_*. 178 | 179 | In each State, a *_Machine_* has discrete responsibilities, concerns and behaviours. And a 180 | FSM can only be in one State at a time. 181 | 182 | This library formalises that process as *_a Machine_*. And, specifically, it 183 | uses a Finte State Machine (FSM), which has a fixed, finite number of *_States_*. 184 | 185 | States allow us to reason about what a Machine 186 | is doing because: 187 | * 188 | * in each State the *_Machine_* has discrete responsibilities, 189 | concerns and behaviours 190 | 191 | So, when an FSM changes *_State_*, it goes from doing one thing to doing another thing. 192 | 193 | Over time, events occur and they can cause the Machine to changes from one State to another. 194 | Such events are called *_Triggers_* and a change in State is called a *_Transition_*. 195 | Sometimes there are certain *_Actions_* (behaviour/computation) associated with 196 | a *_Transition_*. But, to repeat, the significant thing about a State change is that the Machine goes from 197 | doing one thing to doing another thing. 198 | 199 | .What does a Machine do in a State?: 200 | * it can do nothing (waiting for a Trigger) 201 | * it can undertake "an activity" which takes finite time and comes to an end (this ending might causes a Trigger) 202 | * it can undertake "an activity" which does not naturally come to an end (until there is a Trigger) 203 | * it can compute the next Trigger. These are sometimes called https://www.uml-diagrams.org/state-machine-diagrams.html#pseudostate[PseudoStates]. 204 | 205 | This library's FSM contains examples of all these kinds of State. 206 | 207 | The `Logical State Handlers` you will be asked to write are about "doing a thing" when the Machine is in a 208 | particular State. And, as a result, they implement the behaviour for one part of this library's FSM. 209 | 210 | The FSM at the core of this library: 211 | 212 | image::http://www.plantuml.com/plantuml/png/ZLDDJnjD3BxFhx32vULLKL4lI564W4YeXnvGgTG3os5sno4ZTksjnmDQLVtldVreEbcQjBxPypoFF-ov2cf5OrCRvHQKeMHLRi1vmKez4vYjTmZOjDg1mr29R_kuCU7PKsl5DX2srl6hfoVOs3fWzbBQwlb9E99RSyq29xV9SgPQHVXk0E26nQ5CiElhQmFmbhvUhmViVdorWe-sRRixgzlBI_ZadxPwKqUSoSvWdxcpzG3xOOmPArdSeyPs0OFP08CBewrM6ViN_glrcXfVJFZ9FOo_4wumX86IyB_T0_ZxM5M83jrYqD-vX_I_e9Mq2rh0WDV9XJTuOxBSIsX71tIB81XQfe1GiklU5MJ9GLlR2i4hU8AaSkPAa_MwX0qBM23KLvPdg9XeF9-HRI6WlA3if8qn3_y_mcHd3oUxPJoUNSAjzJKw69KzlTZQku84lqKCUeoOhMi9Cvh97kUylLO2aeFti46jjiEKoXgRYNLnST7ZHzjZ2QfqEzeHrbvpc-GKL69bOq1GPcWiTGNrQXu3i02Ai80F1SKKhZYDqIPjayz_dYDBlmJr3NBKVyP72lsLXR29gRR__hHJbccXOtMdkVPyjdjdDYadsVvUOu0Fv-UdnofRMwgm7WQs15koQEBsHne3Ia6AqdYoYgwWFZej-zG0vFTzT0Vj3bVGq4xEd7Up-u0P4vqnMNnEoVxW4XmJcYpzlBAtu6m2VmURB3Il8_it2Or1XJjpXUHYK_y4[FSM,600] 213 | 214 | 215 | Notes: 216 | 217 | * to use this library, you'll need to understand this FSM 218 | * the boxes in the diagram represent the FSM's *_Logical States_* 219 | * the lines between the boxes show what *_Transitions_* are allowed between *_Logical States_* 220 | * the names on those lines are the *_Triggers_* (the event which causes the 221 | Transition to happen) 222 | * when you write a `Logical State Handler` you are implementing the behaviour 223 | for one of the boxes 224 | * the "happy path" for a request is shown in blue (both boxes and lines) 225 | * and, yes, there are variations on this FSM model of a request - this one is 226 | ours. We could, for example, have teased the "Problem" Logical State out into 227 | four distinct states: "Timed Out", "Connection Problem", "Recoverable Server 228 | Problem" and "Unrecoverable Server Problem". We decided not to do that because of, well, reasons. My point is that there isn't a "right" model, just one that is fit for purpose. 229 | 230 | NOTE: 231 | 232 | == Requesting 233 | 234 | Earlier, we saw this code which uses an effect `:http` to initiate an HTTP GET request: 235 | 236 | [source, Clojure] 237 | ---- 238 | (ref-event-fx 239 | :switch-to-articles-panel 240 | (fn [{:keys [db]} _] 241 | ;; Note the following :http effect 242 | {:http {:action :GET 243 | :url "http://api.something.com/articles/" 244 | :path [:put :response :data :at :this :path :in :app-db]}})) 245 | ---- 246 | 247 | Who doesn't love terse? But, as a learning exercise, 248 | let's now pendulum to the opposite extreme 249 | and show you *_the most verbose_* use of the 250 | `:http` effect: 251 | [source, Clojure] 252 | ---- 253 | (reg-event-fx 254 | :request-articles 255 | (fn [_ _] 256 | {:http {:action :GET ;; can be :PUT :POST :HEAD etc 257 | 258 | :url "http://api.something.com/articles/" 259 | 260 | ;; Optional. The path within `app-db` to which request related data should be written 261 | ;; The map at this location is known as `path-state` 262 | :path [:a :path :within :app-db] 263 | 264 | ;; Compose the FSM by nominating the `Logical State handlers`. 265 | ;; Look back at the FSM diagram and at the boxes which represent 266 | ;; Logical States. 267 | ;; When the FSM transitions to a new Logical State, it will `dispatch` 268 | ;; the event you nominate below, and the associated event handler is expected 269 | ;; to perform "the behaviour" required of that Logical State. 270 | :fsm {:in-setup [:my-setup] 271 | :in-process [:my-processor] 272 | :in-problem [:deep-think :where-did-I-go-wrong] 273 | :in-failed [:call-mum] 274 | :in-cancelled [:generic-cancelled] 275 | :in-succeeded [:yah! "fist-pump" :twice] 276 | :in-teardown [:so-tired-now]} 277 | 278 | ;; a map of query params 279 | :params {:user "Fred" 280 | :customer "big one"} 281 | 282 | ;; a map of HTTP headers 283 | :headers {"Authorization" "Bearer QWxhZGRpbjpvcGVuIHNlc2FtZQ==" 284 | "Cache-Control" "no-cache"} 285 | 286 | ;; Where there is a body to the response, fetch will automatically 287 | ;; process that body according to mime type provided. 288 | ;; XXX Isaac we have to explain this 289 | ;; XXX are there sensible defaults? What if I forget to provide? 290 | :content-type {#"application/.*json" :json 291 | #"application/edn" :text} 292 | 293 | ;; Optional - by default a request will run as long as the browser implementation allows 294 | :timeout 5000 295 | 296 | ;; Note: GET or HEAD cannot have body. 297 | ;; Can be one of: String | js/ArrayBuffer | js/Blob | js/FormData | js/BufferSource | js/ReadableStream 298 | :body "a string" 299 | 300 | ;; how many times should occurances like timeouts or HTTP status 503 be retried before failing 301 | :max-retries 5 302 | 303 | ;; Optional: an area to put application-specific data 304 | ;; If data is supplied here, it will probably be used later within the 305 | ;; implementation of a "Logical State Handler". For example "description" 306 | ;; might be a useful string for displaying to the users in the UI or 307 | ;; to put in errors or logs. 308 | :context {:description "Loading articles" 309 | :dispatch-on-success [:another event] 310 | :recover-to {[:where :I :store :the :panel :id] old-value}} 311 | 312 | ;; The following are optional and more obscure. 313 | ;; See https://developer.mozilla.org/en-US/docs/Web/API/Request#Properties 314 | :credentials "omit" 315 | :redirect "manual" 316 | :mode "cors" 317 | :cache "no-store" 318 | :referrer "no-referrer" 319 | 320 | ;; See https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity 321 | :integrity "sha256-BpfBw7ivV8q2jLiT13fxDYAe2tJllusRSZ273h2nFSE="})) 322 | ---- 323 | 324 | 325 | While all this specification offers useful flexibility, we clearly don't want to repeat 326 | this much every time. Particularly because 327 | we'll often want the same headers, params and `Logical State handers`. 328 | 329 | How do we avoid boilerplate and repertition? 330 | 331 | == Profiles 332 | 333 | A *_Profile_* associates an `id` with a fragment of `:http` specification. 334 | 335 | You "register" one or more *_Profiles_*, typically on application 336 | startup. 337 | 338 | Because an `:http` specification is just data (a map), a fragment is also 339 | just data (again, a map). And if you think that sounds pretty simple, you'd be right. 340 | 341 | 342 | == Registering A Profile 343 | 344 | The code below shows how to register a profile with id `:xyz`, and associate 345 | it with certain specification values: 346 | [source, Clojure] 347 | ---- 348 | (reg-event-fx 349 | :register-my-http-profile 350 | (fn [_ _] 351 | 352 | {:http {;; The `:action` is no longer a verb 353 | ;; Instead it indicates we are registering a profile 354 | :action :reg-profile 355 | 356 | ;; This identifier will be used later 357 | :id :xyz 358 | 359 | ;; Optional. Set this profile as the 'default' one? 360 | :default? true 361 | 362 | ;; This is the important bit 363 | ;; This map captures the values associated with this profile. 364 | :values {:url "http:/api.some.com/v2" 365 | :fsm {:in-process [:my-processor] 366 | :in-problem [:generic-problem :extra "whatever"] 367 | :in-failed [:my-special-failed] 368 | :in-cancelled [:generic-cancelled] 369 | :in-teardown [:generic-teardown]} 370 | :timeout 3000 371 | :max-retries 2 372 | :context {...}}}})) 373 | ---- 374 | 375 | == Using A Profile 376 | 377 | Here's an example of using the *_Profile_* with id `:xyz` which we registered above: 378 | [source, Clojure] 379 | ---- 380 | {:http {:action :GET 381 | :url "http://api.endpoint.com/articles/" 382 | :path [:somewhere :in :app-db]} 383 | ---- 384 | Wait! Is this a trick? That's the same three lines as before! 385 | 386 | Indeed. If you look back, you'll see the *_Profile_* `:xyz` was registered with `:default? true` 387 | which means it will be used by default, but only if no Profile is explicitly provided. 388 | 389 | Here's how to explicitly nominate a *_Profile_*: 390 | [source, Clojure] 391 | ---- 392 | {:http {:action :GET 393 | :url "http://api.endpoint.com/articles/" 394 | :path [:somewhere :in :app-db] 395 | :profiles [:xyz]}} ;; <--- NEW: THIS IS HOW WE SAY WHAT PROFILE(S) TO USE 396 | ---- 397 | 398 | That new key `:profiles` allows you to nominate a vector of previously registered *_Profile_* `ids`. The map 399 | of `:values` from those *_Profiles_* will be added into the `:http` specification. 400 | 401 | Here's another example, but this time with multiple profile ids: 402 | [source, Clojure] 403 | ---- 404 | {:http {:action :GET 405 | :url "http://api.endpoint.com/articles/" 406 | :path [:somewhere :in :app-db] 407 | :profiles [:jwt-token :standard-parms :xyz]}} ;; <---- MULTIPLE 408 | ---- 409 | 410 | The map of `:values` in all nominated profiles will be composed into the 411 | the `:http` specification. 412 | 413 | NOTE: explicitly using `:profiles []` would mean no profiles. This is the way to NOT even use the default. 414 | 415 | === Composing Profiles 416 | 417 | How are multiple profiles combined? 418 | 419 | As a first approximation, imagine the process as a `clojure.core/reduce` across a collection of maps, using `clojure.core/merge`: 420 | [source, Clojure] 421 | ---- 422 | (reduce merge [map1, map2, map3]) 423 | ---- 424 | This will accumulate the key/value pairs in the maps, into one final map. 425 | 426 | An example: 427 | [source, Clojure] 428 | ---- 429 | (def map1 {:a 1}) 430 | (def map2 {:b 11}) 431 | 432 | (reduce merge [map1, map2]) 433 | ---- 434 | the result is `{:a 1 :b 11}`. 435 | 436 | Instead of `map1`, `map2`, imagine that we 437 | combine `profile1`, `profile2`, like this: 438 | [source, Clojure] 439 | ---- 440 | (def profile1 {:action :GET}) 441 | (def profile2 {:url "http://some.com/"}) 442 | 443 | (reduce merge [profile1, profile2]) 444 | ---- 445 | with the result: 446 | ``` 447 | { 448 | :action :GET 449 | :url "http://some.com/" 450 | } 451 | ``` 452 | 453 | While ever the profiles have disjoint keys, this is straightforward. But, when there are duplicate keys, 454 | we need a strategy to "combine" the coresponding values. 455 | 456 | .Here are the rules: 457 | * if both values satisfy `str?`, then they will be combined with `str` 458 | * if both values satisfy `set?`, then they will be combined with `clojure.set/union` 459 | * if both values satisfy `map?`, then they will be combined with `merge` (remember merge is shallow). 460 | * if both values satisfy `sequential?`, then `conj` is used 461 | * otherwise, last value wins (no combining) 462 | 463 | Imagine we have a special version of `merge` which implements these rules, called say `special-merge`. 464 | [source, Clojure] 465 | ---- 466 | (def profile1 {:url "http://some.com/"}) 467 | (def profile2 {:url "blah"}) 468 | 469 | (reduce special-merge [profile1, profile2]) 470 | ---- 471 | the result would be: 472 | ``` 473 | {:url "http://some.com/blah"} 474 | ``` 475 | because the values for the duplicate `:url` keys are strings, they will be combined with `str` to form one string. 476 | 477 | Similarly: 478 | [source, Clojure] 479 | ---- 480 | (def profile1 {:params {:Cache-Control "no-cach"}}) 481 | (def profile2 {:params {:Authorization "Basic YWxhZGRpbjpvcGVuc2VzYW1l"}}) 482 | 483 | (reduce special-merge [profile1, profile2]) 484 | ---- 485 | the result would be: 486 | ``` 487 | {:params {:Cache-Control "no-cach" 488 | :Authorization "Basic YWxhZGRpbjpvcGVuc2VzYW1l"}} 489 | ``` 490 | because the values for the duplicate `:params` keys are maps and will be combined with `merge`. 491 | 492 | So, when you nominate multiple profiles: 493 | [source, Clojure] 494 | ---- 495 | {:http {:action :GET 496 | ... 497 | :profiles [:jwt-token :standard-parms :xyz]}} ;; <---- MULTIPLE PROFILES 498 | ---- 499 | the final `:http` spec will be a map. And it will be as if it was formed 500 | using `special-merge` on all the `:values` maps from all the nominated profiles, 501 | plus the map supplied for the `:http` itself as the last one. 502 | 503 | === Advanced Profile Combining 504 | 505 | Where you need to take detailed control of the "combining" process you 506 | can use this library's API function `merge-profiles` 507 | ``` 508 | {:http (-> (merge-profiles [:xyz :another]) ;; combines these two profiles and returns a map 509 | (assoc-in [:fsm :in-setup] [:special]) ;; now manipulate the map in the way you want 510 | (update-in [:url] str "/path")) 511 | ``` 512 | 513 | The function call `(http/merge-profiles [:xyz])` would just return 514 | `:values` map for that one profile. 515 | 516 | = About State 517 | 518 | There are two kinds of State: 519 | 520 | * `request-state` is data for a single request and it maintained by this library. 521 | It only exists for the lifetime of a request. 522 | This state is stored internally in the library and, although it is 523 | provided in the event vector of *_Logical State Handlers_*, it is effectively 524 | read-only. 525 | It includes the 526 | request id, the current logical state of the FSM, the original request, 527 | a trace history through the FSM including timings, etc. 528 | 529 | 530 | * `path-state` - this state is a map of values which exists at a particular 531 | path within `app-db`. It is the application's "materialised 532 | view" of the `request-state`. 533 | The contents of this map is up to you, 534 | the writer of the application. It will be created and maintained by the 535 | *_Logical State Handlers_* you write. 536 | 537 | 538 | Typically, the `in-setup` LogicalStateHandler initialises `path-state`, and it is 539 | then maintained across the request handling process by the various FSM handlers. Ultimately, it 540 | will contain the response data or an error. Your views will be subscribed to this map and will 541 | render it appropriately for the user to view. 542 | 543 | An example of the `path-state` map. 544 | [source, Clojure] 545 | ---- 546 | { 547 | :request-id 123456 548 | :loading? true 549 | 550 | :result nil 551 | :retries 0 552 | :cancelled? false 553 | :description "Loading filtered thingos" 554 | 555 | :error { 556 | :title "Error loading thingos" 557 | :what-happened "Couldn't load thingos from the server because it returned a status 500 error" 558 | :consequence "This application can't display your thingos" 559 | :users-next-action "Please report this error to your help desk via email, with a screenshot. Perhaps try again later"} 560 | } 561 | ---- 562 | 563 | Remember, you design this map. You initialise it in `in-setup`. You update it to reflect the state of the ongoing request. You create the subscriptions which deliver it to a view, and that view will render it. 564 | 565 | XXX :context is put where? 566 | 567 | Note: none of this precludes you, for example, writing errors to a different place within app-db. You write the LogicalStatehandlers. Your choice about how data flows into `app-db`. The proposal above is just one way to do it. 568 | 569 | XXX To avoid race conditions, should the booleans be false in absence via subscriptions? Eg: use `completed?` instead of `loading?` because "absence" (a nil) correctly matches the predicate's negative value. 570 | 571 | XXX consider what else needs to happen to work well with `re-frame-async-flow` 572 | 573 | So, I'd like to stress two points already made: 574 | - lifetime: `path-state` exists for as long as your application code says it should - it persists. Whereas 575 | `request-state` is created and destroyed by this library - it is a means to an ends - it is transitory. 576 | - during the request process, `request-state` tends to be authoritative. : `path-state` is something 577 | of a projection or materialised view of `request-state`. (Not entirely true but a useful mental model at 578 | this early stage in explanation) 579 | 580 | While `path-state` .... there might need to be a `:loading?` value set to true to indicate that the busy twirly should be kept up. Or perhaps a `:retrying?` flag might need to be "projected" from the `reguest-state` so that, again, the UI can show the user what is happening. 581 | 582 | Ultimately, the most important part of this `path-state` is the (processed) response data itself. But there will be other information alongside it. For this reason, `presentation-state` is normally a map of values with a key for `response`, but it has other values. 583 | 584 | The `path-state` is managed by your `Logical State Handlers`. You control what data is projected from the `request-state` across into the `presentation-state`. Because you, the application programmer, knows what you want to set within `app-db`. You know how you want the UI to render the state of the request process. 585 | 586 | For example: 587 | - it is the job of the `in-setup` to initially create the `XXX-state` assumed to be a map. 588 | And it might initially establish within this map a `:loading?` flag as `true`. 589 | - it is then the job of the `in-teardown` handler to set the `:loading?` flag back to `false` 590 | (thus taking down the twirly). 591 | 592 | 593 | = Logical State Handler Recipes 594 | 595 | 596 | .To use this library, you'll: 597 | * design `path-state` and the views which render it (or simply use the default design suggested) 598 | * implement your Logical State Handlers (or simply use the default Handlers provided) 599 | 600 | The Logical State Handlers you write are about "executing the behaviour" associated with being *_in_* a particular state within the FSM. They implement behaviour for one part of "the machine". 601 | 602 | Recipes for each of the Logical State Handlers ... 603 | 604 | === in-setup 605 | 606 | Overview: prepare the application for the pending HTTP request. 607 | 608 | .Recipe: 609 | * establish initial `path-state` at the nominated `:path` 610 | * optionally, if the application is to allow the user to cancel the request 611 | (e.g., via a button) then capture the `:request-id` of the request and assoc it 612 | into `path-state` for access within the view (which will dispatch a cancel request event with this id supplied). 613 | * optionally, put up a twirly-busy-thing, perhaps with a description of the 614 | request: "Loading all the blah things", perhaps with a cancel button 615 | * optionally, cause the application to change panel or view to be ready for the 616 | incoming response data. 617 | * trigger `:send` to cause the transition to `waiting` state. The transition will cause the `fetch` action which actually initiates the request. 618 | 619 | 620 | Views subscribed to this `path-state` will then render the UI, probably locking 621 | it up and allowing the user to see that a request is in-flight. 622 | 623 | XXX a panel might change .... perhaps the user clicked a button to "View Inappropriate", so the application will change panels to the inappropriate one (via a change in `app-db` state), AND also kickoff a server request to get the "inappropriates". 624 | 625 | Example implementation: 626 | [source, Clojure] 627 | ---- 628 | (fn [{:keys [db] :as cofx} [_ {:keys [request-id context] :as request-state}]] 629 | (let [path (:path context)] 630 | ;; trigger for state transition 631 | {:http {:trigger :send 632 | :request-id request-id} 633 | ;; Initialise app-db to reflect that a request is now inflight 634 | ;; This might mean updating some "global" place in app-db to get a twirly-busy-thing up 635 | ;; This might mean putting an "map" at the path provided in the request 636 | :db (-> db 637 | (assoc-in (conj path :request-id) request-id) 638 | (assoc-in [:global :loading?] true) 639 | (assoc-in [:global :loading-text] (:loading-text context)))})) 640 | ---- 641 | 642 | XXX once preparation is complete, notice that your code is expected to `trigger` the transition. 643 | 644 | === in-waiting 645 | 646 | This State Handler is unique because it is the only one you can't write. It is 647 | provided by this library. 648 | 649 | In this state, we are waiting for an HTTP response (after the `fetch` is 650 | launched) and then doing the first round of processing of the response body. 651 | 652 | === in-processing 653 | 654 | .Recipe: 655 | * Process the response: turn transit JSON into transit or XXX 656 | * store in `app-db` 657 | * FSM trigger `:processed` or `:processing-error` 658 | 659 | Example implementation 660 | [source, Clojure] 661 | ---- 662 | (fn [{:keys [db] :as cofx} [_ {:keys [request-id response context] :as request-state}]] 663 | (let [path (:path context) 664 | reader (transit/reader :json)] 665 | (try 666 | (let [data (transit/read reader (:body response))] 667 | {:db (assoc-in db (conj path :data) data) 668 | :http {:trigger :processed 669 | :request-id request-id}})) 670 | (catch js/Error e 671 | {:db (-> db 672 | (assoc-in (conj path :error) (str e))) 673 | :http {:trigger :processing-error 674 | :request-id request-id}}))) 675 | ---- 676 | 677 | XXX `:processing-error` causes a transition to `failed`. How and where does this state obtain the error details? 678 | 679 | === in-succeeded 680 | 681 | The processing of the response has succeeded. 682 | 683 | .Recipe: 684 | * FSM trigger `:done` 685 | 686 | Example implementation 687 | [source, Clojure] 688 | ---- 689 | (fn [{:keys [db] :as cofx} [_ {:keys [request-id] :as request-state}]] 690 | {:http {:trigger :done 691 | :request-id request-id}}) 692 | ---- 693 | 694 | === in-problem 695 | 696 | .Recipe: 697 | * decide what to do about the problem - retry or give up? 698 | * FSM trigger `:fail` or `:retry` 699 | 700 | Example implementation: 701 | [source, Clojure] 702 | ---- 703 | (fn [{:keys [db] :as cofx} [_ {:keys [request-id context problem response] :as request-state}]] 704 | (let [path (:path context) 705 | temporary? (= :timeout problem) 706 | max-retries (:max-retries context) 707 | num-retries (get-in db (conj path :num-retries request-id) 0) 708 | try-again? (and (< num-retries max-retries) temporary?)] 709 | (if try-again? 710 | {:http {:trigger :retry 711 | :request-id request-id} 712 | :db (update-in db (conj path :num-retries request-id) inc)} 713 | {:http {:trigger :fail 714 | :request-id request-id}}))) 715 | ---- 716 | 717 | .Full taxonomy of problems: 718 | * network connection error - no response - retry-able (except that DNS issues take a long time, so retires are annoying) 719 | ** cross-site scripting whereby access is denied; or 720 | ** requesting a URI that is unreachable (typo, DNS issues, invalid hostname etc); or 721 | ** request is interrupted after being sent (browser refresh or navigates away from the page); or 722 | ** request is otherwise intercepted (check your ad blocker). 723 | * `fetch` API body processing error; e.g. JSON parse error. 724 | * timeout - no response - retry-able 725 | * non 200 HTTP status - returned from the server - MAY have a response 726 | ** may have a response :body returned from server which will need to be processed. See https://tools.ietf.org/html/rfc7807 Imagine a 403 Forbidden response. XXX talk about how it might be EDN or a Blob etc. 727 | * some HTTP status are retry-able and some are not 728 | 729 | === in-failed 730 | 731 | The request has failed and we must now adjust for that. 732 | 733 | Ultimately, it doesn't actually matter why we are in the failed state, but to help give context, here's the sort of reasons we end up in this state: 734 | * no outright failure, but too many retries (see `:history` XXX for what happened) 735 | * some kind of networking error happened which means the request never even got to the target server (CORS, DNS error?) 736 | * the server failed in some way (didn't return a 200) 737 | * a 200 response was received but an error occurred when processing that response 738 | 739 | 740 | .Recipe: 741 | * log the error 742 | * show the error to the user 743 | * put the application back into a sane state 744 | * FSM trigger `:teardown` 745 | 746 | Example implementation: 747 | [source, Clojure] 748 | ---- 749 | (fn [{:keys [db] :as cofx} [_ {:keys [request-id context problem response] :as request-state}]] 750 | (let [path (:path context)] 751 | {:http {:trigger :teardown 752 | :request-id request-id} 753 | :db (-> db 754 | ...)})) 755 | ---- 756 | 757 | === in-cancelled 758 | 759 | This state follows user cancellation. 760 | 761 | .Recipe: 762 | * put the application into a state consistent with the cancellation. What does 763 | the user see? What can they do next? 764 | * update `path-state`, maybe. 765 | * FSM trigger `:teardown` 766 | 767 | Example implementation: 768 | [source, Clojure] 769 | ---- 770 | (fn [{:keys [db] :as cofx} [_ {:keys [request-id context problem response] :as request-state}]] 771 | (let [path (:path context)] 772 | {:http {:trigger :teardown 773 | :request-id request-id} 774 | :db (-> db 775 | ...)})) 776 | ---- 777 | 778 | === in-teardown 779 | 780 | Irrespective of the outcome of the request (success, cancellation or failure), this state occurs immediately before it completes. 781 | 782 | As a result, in this state we handle any actions which have to happen irrespective of the outcome. 783 | 784 | .Recipe: 785 | * take down the twirly 786 | * accumulate and log final stats 787 | * possible updates to `path-state` 788 | * change `:loading?` to false 789 | * possible updates to `app-db` 790 | * busy twirly removal 791 | * FSM trigger `:destroy` 792 | 793 | Example implementation: 794 | [source, Clojure] 795 | ---- 796 | (fn [{:keys [db]} [_ {:keys [request-id context] :as request-state}]] 797 | (let [path (:path context)] 798 | {:http {:trigger :destroy 799 | :request-id request-id} 800 | :db (-> db 801 | (assoc-in [:global :loading?] false))})) 802 | ---- 803 | 804 | 805 | === Notes 806 | 807 | .XXX: 808 | * split the recipies into their own docs in /docs 809 | * FAQ for file upload - reference example application 810 | * Talk about the two approaches to switching tabs 811 | * Nine states of UI 812 | * note somewhere you can supply multiple requests ... a vector 813 | * Add note that `fetch` doesn't work on IE. So you'll need to provide a polyfil if you target IE. 814 | * add optional `:cancel` event handler ?? 815 | * ??? add an interceptor to assert the correctness of the Transitions - Logical State Handlers 816 | * anything we should be doing around stubbing and testing? 817 | * add trace to FSM 818 | 819 | === FAQ 820 | 821 | 1. Your FSM is wrong 822 | 2. Why don't you use gards? 823 | 824 | === Explaining State Machines 825 | 826 | A *_control system_* determines its outputs depending on its inputs. 827 | 828 | If the present input values are sufficient to determine the outputs the control system 829 | is a *_combinatorial system_*. 830 | 831 | For example, a traffic light control system could be constructed like this: 832 | * the only input is the current time (the input changes every second) 833 | * strip the input time down to just the seconds part (a number between 0 and 59) 834 | * Apply the following rules: 835 | ** if the seconds is between 0 and 27 seconds then the output is "green" 836 | ** if the seconds is between 28 and 33 seconds, the output is "orange" 837 | ** if the seconds is between 34 and 59 seconds, the output is "red" 838 | 839 | At any point in this system's fucntioning, knowing the input (time) is 840 | sufficient to know the output (green,orange,red). 841 | 842 | If, on the other hand, the control system needs to know the history of its inputs 843 | to determine its output the system is a *_sequential system_*. 844 | 845 | To function, a *_sequential system_* must store a representation of its 846 | input history. And this representation is known as *_State_*. 847 | 848 | Imagine a traffic lights control system which XXX 849 | 850 | If the State was a 16 bit integer, the number of States would be 65536. 851 | 852 | 853 | 854 | 855 | 856 | 857 | 858 | 859 | 860 | A state machine is the oldest known formal model for sequential behaviour i.e. behaviour that cannot 861 | be defined by the knowledge of inputs only, but depends on the history of the inputs. 862 | 863 | --------------------------------------------------------------------------------