coreagile/defenv

2.0.1-SNAPSHOT


A library for managing environment variables in Clojure

dependencies

org.clojure/clojure
1.10.1
slingshot
0.12.2



(this space intentionally left almost blank)
 

Welcome to defenv

This library attempts to simplify the management of environment variables. Put simply, it's System/getenv on steroids.

Features include: documentation generation, default values, and custom parsing. Also critical is the use of delayed binding so your application doesn't throw exceptions when loading namespaces (a huge pet peeve!).

Take a look at defenv.usage for examples of how to use the library.

(ns defenv.core
  (:require [clojure.pprint :refer :all]
            [clojure.repl :as repl]
            [clojure.set :refer :all]
            [slingshot.slingshot :refer [throw+]]
            [clojure.string :as str]))
(defn- env
  [env-name default-value]
  (let [val (System/getenv env-name)] (if-not val default-value val)))

Transformation convenience functions

(for use wherever you see :tfn)

DEPRECATED in favor of parse-bool

(defn parse-bool [s] (Boolean/parseBoolean s))
(defn parse-int [s] (Integer/parseInt s))
(defn parse-long [s] (Long/parseLong s))
(defn parse-float [s] (Float/parseFloat s))
(defn parse-double [s] (Double/parseDouble s))
(defn parse-boolean
  {:deprecated "0.0.6"} [s] (parse-bool s))

Parses a string containing key-value pairs into a map. Each pair is separated by a comma, and the key and value by an equal sign. Whitespace surrounding keys and values is ignored. Example: a=b,c=d parses to {"a"="b", "c"="d"}.

You can pass two configuration options into the opts map:

:default-key specifies what happens when either you don't follow the syntax (you get a map with the value of :default-key as its key, and the variable value as its value) or when you have a key in the map that is blank.

:key-fn specifies a function to execute on each key. This function is not executed on the :default-key

See defenv.tfn-spec for examples.

(defn parse-map
  ([s] (parse-map {} s))
  ([{:keys [default-key key-fn] :or {default-key "default"
                                     key-fn      identity}} s]
   (if (re-find #"=" s)
     (let [kvs (str/split s #",")]
       (reduce (fn [m s]
                 (let [[k v] (map str/trim (str/split s #"="))
                       k (if (str/blank? k) default-key (key-fn k))]
                   (when (m k)
                     (binding [*out* *err*]
                       (println "**WARNING** Duplicate key found in map:" k)))
                   (assoc m k (or v "")))) {} kvs))
     {default-key s})))

On-the-fly documentation

Generation

(def ^:private displays {::missing     "*REQUIRED*"
                         ::masked      "--MASKED--"
                         ::parse-error "*PARSE ERROR*"})
(defn- get-var-status [{:keys [env-name v default masked? optional?]
                        :as   doc-map}]
  (let [e-val (env env-name default)]
    (-> doc-map
        (assoc :value
               (if e-val (if masked? ::masked v)
                         (if optional? "nil" ::missing)))
        (rename-keys {:env-name :name}))))
(defn- get-var-statuses [display-spec] (map get-var-status (vals display-spec)))
(defn- convert [v]
  (let [parsed? (vector? v)
        parse-difference? (and parsed? (not= (first v) (second v)))
        displayable (if parsed? (second v) v)
        displayable
        (if-let [replacement (displays displayable)] replacement displayable)]
    (if parse-difference? (format "\"%s\" -> %s" (first v) displayable)
                          displayable)))

Convert a display spec into a collection of objects suitable for displaying documentation

(defn display-spec->docs
  [display-spec]
  (map #(update % :value convert) (get-var-statuses display-spec)))

DEPRECATED in favor of parse-bool

(defn spec->docs
  {:deprecated "1.0.11"} [display-spec]
  (display-spec->docs display-spec))

Convert a connection of spec docs into a nice table

(defn doc-table
  [env-docs]
  (with-out-str (print-table [:name :value :doc] env-docs)))
(defn- make-usage-string [display-spec prefix]
  (let [env-docs (display-spec->docs display-spec)]
    (if (empty? env-docs)
      "No environment variables defined!"
      (str prefix "\n" (doc-table env-docs)))))

Display

(def ^:dynamic ^:private print-usage? (atom false))

Enable user-friendly usage printing globally.

(defn set-error-print-enabled! [enabled?] (reset! print-usage? enabled?))

Enable user-friendly usage printing in the current thread.

(defmacro with-usage-print-enabled [& body]
  `(binding [print-usage? (delay true)] ~@body))
(defn- print-to-stderr [msg] (binding [*out* *err*] (println msg)))
(def ^:dynamic ^:private epfn (atom print-to-stderr))
(def ^:private missing "Environment variable(s) missing")
(defn print-usage [pfn message display-spec]
  (pfn (make-usage-string display-spec (str message ":"))))

Send user-friendly error messages somewhere else universally.

(defn set-err-print-fn! [f] (reset! epfn f))

Send user-friendly error messages somewhere else in the current thread.

(defmacro with-print-fn [f & body] `(binding [epfn (delay ~f)] ~@body))
(defn- throw-usage-if-missing [display-spec]
  (let [missing-vals
        (->> (get-var-statuses display-spec)
             (filter #(let [{:keys [value]} %
                            [_ parsed-v] (when (vector? value) value)]
                        (case (or parsed-v value)
                          ::missing true
                          ::parse-error true
                          false)))
             (map :name))]
    (when (seq missing-vals)
      (let [missing-msg
            (format "%s: %s" missing (str/join ", " missing-vals))]
        (when @print-usage? (print-usage @epfn missing display-spec))
        (throw+ {:type ::missing-env :missing missing-vals} missing-msg)))))

Core functionality

Retrieving environment variable values

(defn- parse-env [env-name {:keys [default tfn optional?] :as params}]
  (let [has-param? (partial contains? params)
        env-args [env-name]
        base-value (env env-name default)]
    {:tfn               (if (and (not (nil? base-value))
                                 (has-param? :tfn)) tfn
                                                    identity)
     :env-args          (if (or optional? (has-param? :default))
                          (conj env-args default) env-args)
     :params-to-display (assoc params :env-name env-name)
     :base-value        base-value}))
(defn- pretty-demunge
  [fn-object]
  (let [demunged (repl/demunge (str fn-object))
        pretty (second (re-find #"(.*?\/.*?)@.*" demunged))]
    (or pretty demunged)))
(defn- try-tfn [env-name masked? tfn base-value]
  (try (tfn base-value)
       (catch Exception e
         (@epfn (format "Unable to parse %s='%s' using %s (%s)."
                        env-name
                        (if masked? (::masked displays) base-value)
                        (-> tfn str pretty-demunge)
                        (if masked? "--ERROR MASKED--" (.getMessage e))))
         ::parse-error)))
(defn- overlay-env [m k raw-params]
  (let [{:keys [optional? masked? env-name env] :as params}
        (merge {:masked? true} raw-params)
        env-name (or env-name env)
        {:keys [tfn params-to-display base-value]} (parse-env env-name params)
        v (try-tfn env-name masked? tfn base-value)]
    (-> m
        (update :env-map #(if (and (not optional?) (nil? v)) % (assoc % k v)))
        (update :display-spec assoc env-name
                (assoc params-to-display :v [base-value v])))))
(defn- get-env-info [env-spec]
  (reduce-kv overlay-env
             {:env-map (sorted-map), :display-spec (sorted-map)}
             (into (sorted-map) env-spec)))

Operates much like defenv except you can define multiple bindings at once, and receive a map of all values that have been found in the environment. Unlike defenv, however, this function will throw an exception if any required variable is missing, not just the one you ask for.

The map should look something like this:

{:ev1 {:env-name "MY_ENV_VAR"
       :tfn my-optional-parse-function
       :default "MY OPTIONAL DEFAULT VALUE"
       :masked? true
       :doc "Nice documentation"
       :optional? true}
 :other {:env-name "OTHER_VAR"}}

In this case, the :ev1 key will be filled in with the value of MY_ENV_VAR, unless it isn't present, in which case, it will receive the value MY OPTIONAL DEFAULT VALUE. Every key except for :env-name is optional.

In the case of :other, if there is no value for OTHER_VAR, there will be an exception thrown much like when attempting to deref a binding generated by defenvthat is required but missing.

(defn env->map
  [env-spec]
  (let [{:keys [env-map display-spec]} (get-env-info env-spec)]
    (throw-usage-if-missing display-spec)
    env-map))

Get a single environment variable value. You can use any of the params that you would use in env->map except :env-name, which is the env-name.

(defn one
  [env-name & params]
  (:e (env->map {:e (into {:env-name env-name} (apply hash-map params))})))

Defining a global environment variable binding

(def ^:private global-defined-spec (ref (sorted-map)))
(def ^:private global-parsed-env (ref (sorted-map)))
(def ^:private global-display-spec (ref (sorted-map)))
(def ^:private on-load-handlers (atom []))
(defmacro ^:private with-new-parsed-env! [& body]
  `(dosync ~@body (alter global-parsed-env empty)))
(defn- initialize-global! []
  (when (empty? @global-parsed-env)
    (let [{:keys [env-map display-spec]} (get-env-info @global-defined-spec)]
      (dosync
        (ref-set global-parsed-env env-map)
        (ref-set global-display-spec display-spec))
      (doall (map #(% @global-display-spec) @on-load-handlers)))))
(defn on-load! [f] (swap! on-load-handlers conj f))
(defmacro ^:private guarantee-global! [& body]
  `(do (initialize-global!) ~@body))
(defn- add-doc [doc-present? doc-or-env params]
  (if doc-present? (assoc params :doc doc-or-env) params))

Used primarily by defenv to retrieve the global environment state.

(defn get-global-env
  [env-name]
  (guarantee-global!
    (if ((set (keys @global-parsed-env)) env-name)
      (get @global-parsed-env env-name)
      (throw-usage-if-missing @global-display-spec))))

Used primarily by defenv to add to the global environment state.

(defn add-to-global-defined-spec!
  [env-name params]
  (with-new-parsed-env! (alter global-defined-spec assoc env-name params)))

Define a binding b to an environment variable. Creates a delayed object that, when dereferenced, will load an environment variable of the given key.

If doc-or-env and env-or-fk are both strings, we assume that doc-or-env is a docstring and env-or-fk is the environment variable to be pulled. Then, remaining become the params.

Else, we assume doc-or-env is the environment variable and we use env-or-fk as the first key of the params. This convention also allows for your documentation generator (like ) to detect docstrings in their conventional position.

If :default {value} shows up in the params, will send back the given value if the variable isn't defined.

If :tfn {function} shows up in the params, runs the given function against the result of the environment variable load, which is by default a string. Best not to use a lambda, as you won't get any helpful context on parse errors.

If you add :masked? and set it to true, the value won't be displayed in usage documentation.

If you add :optional? and set it to true, then there need not be a default value set, and the tfn will not get invoked if the value is missing.

(defmacro defenv
  [b & [doc-or-env env-or-fk & remaining]]
  (let [doc-present? (every? string? [doc-or-env env-or-fk])
        env-name (if doc-present? env-or-fk doc-or-env)
        params (->> (if doc-present?
                      remaining
                      (when env-or-fk (concat [env-or-fk] remaining)))
                    (apply hash-map)
                    (add-doc doc-present? doc-or-env)
                    (merge {:env-name env-name}))]
    `(do (add-to-global-defined-spec! ~env-name ~params)
         (def ~(vary-meta b assoc :dynamic true)
           (delay (get-global-env ~env-name))))))

Displaying environment information to your users

From an environment specification (or nil, if using the global), construct a specification for how to display the environment configuration.

(defn display-spec
  ([] (display-spec nil))
  ([env-spec]
   (if env-spec
     (:display-spec (get-env-info env-spec))
     (guarantee-global! @global-display-spec))))
(defn- display-env-internal [display-spec display-fn]
  (print-usage display-fn "Environment" display-spec))

A recipe for doing your own display of environment information

(comment
  (let [test-spec {:stuff {:env-name "STUFF" :default "bits" :doc "fun"}

                   :answer
                          {:env-name "ANSWER"
                           :default  "42"
                           :tfn      parse-int
                           :doc      "ultimate"}

                   :path  {:env-name "PATH"
                           :tfn      (fn [v] (str/split v #"[:;]"))
                           :masked?  true
                           :doc      "System path!"}}]

    (println "\nInternal use:")
    (->> test-spec env->map clojure.pprint/pprint)
    (println "\nExternal view:")
    (-> test-spec
        display-spec
        display-spec->docs
        doc-table
        println))
  )

Display the current environment to users in a friendly manner. If you call this function without an env-spec, we will print the result of documentation from all the defenv calls that have been executed in all the referred namespaces. Otherwise, it will be based on the spec given. This is the same format as you would send to env->map.

Also, in case you want to send your own function (instead of println), you can call the 2-argument alternative. out-fn will be executed with a string representation of the given environment. If env-spec is nil, we will use the global environment (modified using defenv).

(defn display-env
  ([]
   (display-env nil println))
  ([env-spec]
   (display-env env-spec println))
  ([env-spec out-fn]
   (-> env-spec display-spec (display-env-internal out-fn))))

Extract the global environment config as a list of maps

(defn extract-global-spec
  [f]
  (guarantee-global! (map (comp f second) @global-defined-spec)))

Test fixtures

(defn reset-defined-env!
  []
  (with-new-parsed-env!
    (alter global-display-spec empty)
    (alter global-defined-spec empty)))
 
(ns defenv.docs
  (:require [defenv.core :as env]
            [hiccup.core :as h]))

Documentation Generation

(defn- env-var->html [{:keys [env-name default optional? masked? doc]}]
  [[:dd env-name]
   [:dt
    (when (or default optional? masked?)
      [:ul
       (when default
         [:li {:class "default-value"} "Default: "
          [:span default]])
       (when optional? [:li {:class "optional"} "optional"])
       (when masked? [:li {:class "masked"} "masked"])])
    [:span {:class "doc"} (or doc [:i "No documentation found"])]]])
(defn- env->html [header]
  (h/html
    [:html
     [:head
      [:link {:type "text/css" :href "defenv.css" :rel "stylesheet"}]]
     [:body
      [:h1 header]
      (->> env-var->html
           env/extract-global-spec
           (reduce concat)
           (concat [:dl])
           (into []))]]))

Save the current global environment as an HTML file. The header will be emitted as an h1 element at the top of the file. The file-name is where the contents will be saved.

(defn save-html
  [header file-name]
  (spit file-name (env->html header)))
(defn- example-usage []
  (load-file "src/defenv/usage.clj")
  (save-html "defenv environment specification" "test.html"))
(comment
  (example-usage)
  )
 

Usage example

(ns defenv.usage
  (:require [clojure.string :as str]
            [defenv.core :as env]
            [slingshot.slingshot :refer [try+]]
            [taoensso.timbre :as log])
  (:gen-class)
  (:import (clojure.lang ExceptionInfo)))

Environment variables

(def sensible-default "A Sensible Defaultâ„¢")

Global Bindings

An environment variable with a default value.

(env/defenv testing
  "DEFENV_TESTING"
  :default sensible-default
  :masked? false)

Shows you what happens when something is missing.

(env/defenv missing
  "DEFENV_MISSING"
  :masked? false)

DEFENVMISSINGNO_DOCS

(env/defenv missing-no-docs 
  :masked? false)

Shows how values can be converted to keywords.

(env/defenv some-keyword
  "DEFENV_KEYWORD"
  :default "test"
  :tfn keyword
  :masked? false)

Shows what happens when you don't unmask a var.

(env/defenv secret-thing
  "DEFENV_SECRET"
  :default "oops")

A truly optional value.

(env/defenv truly-optional
  "DEFENV_OPT" :tfn env/parse-int :optional? true :masked? false)
(defn parse-broken [x]
  (throw (ExceptionInfo. (str "I refuse to parse: " x) {})))

Something secret that can't be parsed.

(env/defenv secret-parse-error
  "DEFENV_UNPARSEABLE_SECRET"
  :tfn parse-broken :default "secret" :masked? true)

Something that can't be parsed.

(env/defenv parse-error  "DEFENV_UNPARSEABLE"
  :tfn parse-broken :default "broken" :masked? false)

Local Map

(def env-map-spec {:testing {:env-name "DEFENV_TESTING"
                             :default sensible-default
                             :masked? false}
                   :log-level {:env-name "LOG_LEVEL" :doc "Global log level."
                               :tfn keyword
                               :default "info"
                               :masked? false}
                   :should-log? {:env-name "SHOULD_LOG"
                                 :doc "Should I log? A boolean."
                                 :tfn env/parse-bool
                                 :default "false"
                                 :masked? false}
                   :optional {:env-name "DEFENV_OPT"
                              :optional? true
                              :doc "A truly optional value."
                              :masked? false}})
(def env-map (env/env->map env-map-spec))
(defmacro handle-exception [& body]
  `(try+
     (printf "this should die: %s%n" ~@body)
     (catch [:type :defenv.core/missing-env] {missing# :missing}
       (println "Exception msg:" (.getMessage (:throwable ~'&throw-context)))
       (println "Variables missing:" (str/join ", " missing#)))))

A very simple example of how to use the library. Just run lein usage.

(defn -main
  [& _]
  (println "* Display usage without trying to access a variable *")
  (env/on-load! (fn [_] (println "** Global Bindings On-Load **")))
  (env/on-load! (partial env/print-usage println "Environment On-Load"))
  (env/on-load! (fn [_] (println "*****************************")))
  (println "We should see 2 dumps of the same global environment:")
  (env/display-env nil println)
  (println "\n** Local Map **")
  (println env-map)
  (env/display-env env-map-spec)
  (println "\n** Single values **")
  (println (env/one "PATH"))
  (handle-exception (env/one "MISSING_SECRET" :masked? true :tfn env/parse-int))
  (println "\n* Error Handling *")
  (env/set-error-print-enabled! true)
  (env/set-err-print-fn! #(log/error %))
  (printf "Exception Printing: %s%n" @testing)
  (handle-exception @missing))