├── .config └── medic.toml ├── .formatter.exs ├── .github └── workflows │ └── tests.yml ├── .gitignore ├── .tool-versions ├── Brewfile ├── CHANGELOG.md ├── LICENSE ├── README.md ├── bin └── dev │ ├── audit │ ├── docs │ ├── doctor │ ├── format │ ├── shipit │ ├── test │ └── update ├── config ├── config.exs ├── dev.exs └── test.exs ├── lib ├── gestalt.ex ├── gestalt │ ├── macros.ex │ └── util │ │ └── map.ex └── mix │ └── tasks │ └── gestalt │ └── tags │ ├── create.ex │ └── push.ex ├── mix.exs ├── mix.lock ├── pages └── overview.md └── test ├── gestalt_test.exs ├── test_helper.exs └── util └── map_test.exs /.config/medic.toml: -------------------------------------------------------------------------------- 1 | [doctor] 2 | checks = [ 3 | { check = "homebrew" }, 4 | { check = "tool-versions", command = "plugin-installed", args = { plugin = "erlang" } }, 5 | { check = "tool-versions", command = "plugin-installed", args = { plugin = "elixir" } }, 6 | { check = "tool-versions", command = "package-installed", args = { plugin = "erlang" } }, 7 | { check = "tool-versions", command = "package-installed", args = { plugin = "elixir" } }, 8 | { check = "elixir", command = "local-hex" }, 9 | { check = "elixir", command = "local-rebar" }, 10 | { check = "elixir", command = "packages-installed" }, 11 | ] 12 | 13 | [test] 14 | checks = [ 15 | { name = "Check for warnings", shell = "mix compile --force --warnings-as-errors" }, 16 | { name = "Elixir tests", shell = "mix test --color --warnings-as-errors", verbose = true }, 17 | ] 18 | 19 | [audit] 20 | checks = [ 21 | { name = "Check formatting", shell = "mix format --check-formatted", remedy = "mix format" }, 22 | { step = "elixir", command = "audit-deps" }, 23 | { step = "elixir", command = "credo" }, 24 | { step = "elixir", command = "dialyzer" }, 25 | { check = "elixir", command = "unused-deps" }, 26 | ] 27 | 28 | [outdated] 29 | checks = [ 30 | { check = "elixir" }, 31 | ] 32 | 33 | [update] 34 | steps = [ 35 | { step = "git", command = "pull" }, 36 | { step = "elixir", command = "get-deps" }, 37 | { step = "elixir", command = "compile-deps", args = { mix-env = "dev" } }, 38 | { step = "elixir", command = "compile-deps", args = { mix-env = "test" } }, 39 | { doctor = {} }, 40 | { name = "Build docs", shell = "mix docs" }, 41 | ] 42 | 43 | [shipit] 44 | steps = [ 45 | { audit = {} }, 46 | { update = {} }, 47 | { test = {} }, 48 | { step = "git", command = "push" }, 49 | { step = "github", command = "link-to-actions", verbose = true }, 50 | ] 51 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"], 3 | line_length: 200, 4 | locals_without_parens: [ 5 | # Ecto 6 | ## schema 7 | field: :*, 8 | belongs_to: :*, 9 | has_one: :*, 10 | has_many: :*, 11 | many_to_many: :*, 12 | embeds_one: :*, 13 | embeds_many: :*, 14 | 15 | ## migration 16 | create: :*, 17 | create_if_not_exists: :*, 18 | alter: :*, 19 | drop: :*, 20 | drop_if_exists: :*, 21 | rename: :*, 22 | add: :*, 23 | remove: :*, 24 | modify: :*, 25 | execute: :*, 26 | 27 | ## query 28 | from: :* 29 | ] 30 | ] 31 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Test & Audit 2 | on: 3 | push: 4 | branches: 5 | - main 6 | - ci 7 | - ci-* 8 | pull_request: 9 | branches: 10 | - main 11 | jobs: 12 | build_test: 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | include: 17 | - pair: 18 | elixir: 1.16 19 | otp: 25 20 | - pair: 21 | elixir: 1.18 22 | otp: 27 23 | name: Build Test 24 | runs-on: ubuntu-24.04 25 | env: 26 | MIX_ENV: test 27 | steps: 28 | - uses: actions/checkout@v4 29 | - name: Set up Elixir 30 | id: beam 31 | uses: erlef/setup-beam@v1 32 | with: 33 | elixir-version: ${{ matrix.pair.elixir }} 34 | otp-version: ${{ matrix.pair.otp }} 35 | - name: Cache deps 36 | uses: actions/cache@v4 37 | with: 38 | path: deps 39 | key: ${{ runner.os }}-test-deps-v1-${{ hashFiles('**/mix.lock') }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }} 40 | - name: Cache _build 41 | uses: actions/cache@v4 42 | with: 43 | path: _build 44 | key: ${{ runner.os }}-test-build-v1-${{ hashFiles('**/mix.lock') }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }} 45 | - name: Install dependencies 46 | run: mix deps.get 47 | working-directory: . 48 | - name: Compile for test 49 | run: mix compile --force --warnings-as-errors 50 | working-directory: . 51 | build_dev: 52 | strategy: 53 | fail-fast: false 54 | matrix: 55 | include: 56 | - pair: 57 | elixir: 1.16 58 | otp: 25 59 | - pair: 60 | elixir: 1.18 61 | otp: 27 62 | name: Build Dev 63 | runs-on: ubuntu-24.04 64 | env: 65 | MIX_ENV: dev 66 | steps: 67 | - uses: actions/checkout@v4 68 | - name: Set up Elixir 69 | id: beam 70 | uses: erlef/setup-beam@v1 71 | with: 72 | elixir-version: ${{ matrix.pair.elixir }} 73 | otp-version: ${{ matrix.pair.otp }} 74 | - name: Cache deps 75 | uses: actions/cache@v4 76 | with: 77 | path: deps 78 | key: ${{ runner.os }}-dev-deps-v1-${{ hashFiles('**/mix.lock') }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }} 79 | - name: Cache _build 80 | uses: actions/cache@v4 81 | with: 82 | path: _build 83 | key: ${{ runner.os }}-dev-build-v1-${{ hashFiles('**/mix.lock') }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }} 84 | - name: Install dependencies 85 | run: mix deps.get 86 | working-directory: . 87 | - name: Compile for dev 88 | run: mix compile --force --warnings-as-errors 89 | working-directory: . 90 | test: 91 | strategy: 92 | fail-fast: false 93 | matrix: 94 | include: 95 | - pair: 96 | elixir: 1.16 97 | otp: 25 98 | - pair: 99 | elixir: 1.18 100 | otp: 27 101 | name: Test 102 | needs: build_test 103 | runs-on: ubuntu-24.04 104 | env: 105 | MIX_ENV: test 106 | steps: 107 | - uses: actions/checkout@v4 108 | - name: Set up Elixir 109 | id: beam 110 | uses: erlef/setup-beam@v1 111 | with: 112 | elixir-version: ${{ matrix.pair.elixir }} 113 | otp-version: ${{ matrix.pair.otp }} 114 | - name: Cache deps 115 | uses: actions/cache@v4 116 | with: 117 | path: deps 118 | key: ${{ runner.os }}-test-deps-v1-${{ hashFiles('**/mix.lock') }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }} 119 | - name: Cache _build 120 | uses: actions/cache@v4 121 | with: 122 | path: _build 123 | key: ${{ runner.os }}-test-build-v1-${{ hashFiles('**/mix.lock') }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }} 124 | - name: Run tests 125 | run: mix test --color --warnings-as-errors 126 | working-directory: . 127 | credo_and_dialyxir: 128 | strategy: 129 | fail-fast: false 130 | matrix: 131 | include: 132 | - pair: 133 | elixir: 1.18 134 | otp: 27 135 | name: Credo + Dialyxir 136 | needs: build_test 137 | runs-on: ubuntu-24.04 138 | env: 139 | MIX_ENV: test 140 | steps: 141 | - uses: actions/checkout@v4 142 | - name: Set up Elixir 143 | id: beam 144 | uses: erlef/setup-beam@v1 145 | with: 146 | elixir-version: ${{ matrix.pair.elixir }} 147 | otp-version: ${{ matrix.pair.otp }} 148 | - name: Cache deps 149 | uses: actions/cache@v4 150 | with: 151 | path: deps 152 | key: ${{ runner.os }}-test-deps-v1-${{ hashFiles('**/mix.lock') }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }} 153 | - name: Cache _build 154 | uses: actions/cache@v4 155 | with: 156 | path: _build 157 | key: ${{ runner.os }}-test-build-v1-${{ hashFiles('**/mix.lock') }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }} 158 | - name: Cache PLTs 159 | uses: actions/cache@v4 160 | with: 161 | path: _build/plts 162 | key: ${{ runner.os }}-test-dialyxir-v2-${{ steps.beam.outputs.elixir-version }}-${{ steps.beam.outputs.otp-version }}-${{ hashFiles('**/mix.lock', '.tool-versions') }} 163 | - name: Credo 164 | run: mix credo --strict 165 | working-directory: . 166 | - name: Run dialyzer 167 | run: mix dialyzer 168 | working-directory: . 169 | audit: 170 | strategy: 171 | fail-fast: false 172 | matrix: 173 | include: 174 | - pair: 175 | elixir: 1.18 176 | otp: 27 177 | name: Audit 178 | needs: build_dev 179 | runs-on: ubuntu-24.04 180 | env: 181 | MIX_ENV: dev 182 | steps: 183 | - uses: actions/checkout@v4 184 | - name: Set up Elixir 185 | id: beam 186 | uses: erlef/setup-beam@v1 187 | with: 188 | elixir-version: ${{ matrix.pair.elixir }} 189 | otp-version: ${{ matrix.pair.otp }} 190 | - name: Cache deps 191 | uses: actions/cache@v4 192 | with: 193 | path: deps 194 | key: ${{ runner.os }}-dev-deps-v1-${{ hashFiles('**/mix.lock') }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }} 195 | - name: Cache _build 196 | uses: actions/cache@v4 197 | with: 198 | path: _build 199 | key: ${{ runner.os }}-dev-build-v1-${{ hashFiles('**/mix.lock') }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }} 200 | - name: Check Elixir formatting 201 | run: mix format --check-formatted 202 | working-directory: . 203 | - name: Check for unused dependencies 204 | run: mix deps.unlock --check-unused 205 | working-directory: . 206 | - name: Audit deps 207 | run: mix deps.audit 208 | working-directory: . 209 | publish: 210 | strategy: 211 | fail-fast: false 212 | matrix: 213 | include: 214 | - pair: 215 | elixir: 1.18 216 | otp: 27 217 | name: Publish to Hex 218 | if: github.ref == 'refs/heads/main' 219 | needs: 220 | - test 221 | - credo_and_dialyxir 222 | - audit 223 | runs-on: ubuntu-24.04 224 | steps: 225 | - uses: actions/checkout@v4 226 | - name: Set up Elixir 227 | id: beam 228 | uses: erlef/setup-beam@v1 229 | with: 230 | elixir-version: ${{ matrix.pair.elixir }} 231 | otp-version: ${{ matrix.pair.otp }} 232 | - name: Cache deps 233 | uses: actions/cache@v4 234 | with: 235 | path: deps 236 | key: ${{ runner.os }}-dev-deps-v1-${{ hashFiles('**/mix.lock') }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }} 237 | - name: Cache _build 238 | uses: actions/cache@v4 239 | with: 240 | path: _build 241 | key: ${{ runner.os }}-dev-build-v1-${{ hashFiles('**/mix.lock') }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }} 242 | - name: Publish to Hex 243 | uses: synchronal/hex-publish-action@v3 244 | with: 245 | name: gestalt 246 | key: ${{ secrets.HEX_PM_KEY }} 247 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.ez 2 | *.iml 3 | .medic/.doctor.out 4 | .medic/skipped 5 | /.fetch 6 | /_build/ 7 | /cover/ 8 | /deps/ 9 | /doc/ 10 | /priv/plts/ 11 | Brewfile.lock.json 12 | erl_crash.dump 13 | gestalt-*.tar 14 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | elixir 1.18.3-otp-27 2 | erlang 27.3 3 | -------------------------------------------------------------------------------- /Brewfile: -------------------------------------------------------------------------------- 1 | tap 'synchronal/tap' 2 | 3 | brew 'synchronal/tap/medic' 4 | brew 'synchronal/tap/medic-bash' 5 | brew 'synchronal/tap/medic-ext-elixir' 6 | brew 'synchronal/tap/medic-ext-tool-versions' 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Change Log 2 | ========== 3 | 4 | ## Unreleased 5 | 6 | ## 2.0.0 7 | 8 | - Verify support for Elixir 1.17.0. 9 | - *Breaking*: Drop support for Elixir older than 1.15.0. 10 | 11 | ## 1.0.3 12 | 13 | - Bump required Elixir version to `~> 1.9` 14 | - Use `Config` in favor of deprecated `Mix.Config` 15 | - Add LiveView section to guides 16 | 17 | ## 1.0.2 18 | 19 | - Fix dialyzer error when analyzing an application using Gestalt in 20 | non-test environments. 21 | 22 | ## 1.0.1 23 | 24 | - Add credo, dialyzer; fix issues 25 | - Add `@spec`s 26 | 27 | ## 1.0 28 | 29 | - Return existing env/config for values not explicitly overridden 30 | - Fix license in mix.exs 31 | 32 | ## 0.3.1 33 | 34 | - Add Apache license 35 | - Internal reorganization of private mix tasks 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Gestalt 2 | ======= 3 | 4 | [![CI](https://github.com/livinginthepast/elixir-gestalt/actions/workflows/tests.yml/badge.svg)](https://github.com/livinginthepast/elixir-gestalt/actions) 5 | [![Hex pm](http://img.shields.io/hexpm/v/gestalt.svg?style=flat)](https://hex.pm/packages/gestalt) 6 | [![License](http://img.shields.io/github/license/livinginthepast/elixir-gestalt.svg?style=flat)](https://github.com/livinginthepast/elixir-gestalt/blob/main/LICENSE.md) 7 | 8 | `Configuration` → `Form` → `Gestalt` 9 | 10 | A wrapper for `Application.get_config` and `System.get_env` that makes it easy 11 | to swap in process-specific overrides. Among other things, this allows tests 12 | to provide async-safe overrides. 13 | 14 | Documentation can be found at [https://hexdocs.pm/gestalt](https://hexdocs.pm/gestalt). 15 | 16 | ## Sponsorship 💕 17 | 18 | This library is part of the [Synchronal suite of libraries and tools](https://github.com/synchronal) 19 | which includes more than 15 open source Elixir libraries as well as some Rust libraries and tools. 20 | 21 | You can support our open source work by [sponsoring us](https://github.com/sponsors/reflective-dev). 22 | If you have specific features in mind, bugs you'd like fixed, or new libraries you'd like to see, 23 | file an issue or contact us at [contact@reflective.dev](mailto:contact@reflective.dev). 24 | 25 | ## Installation 26 | 27 | This package can be installed by adding `gestalt` to your list of dependencies in `mix.exs`: 28 | 29 | ```elixir 30 | def deps do 31 | [ 32 | {:gestalt, "~> 1.0"} 33 | ] 34 | end 35 | ``` 36 | -------------------------------------------------------------------------------- /bin/dev/audit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -exo pipefail 4 | 5 | medic audit 6 | -------------------------------------------------------------------------------- /bin/dev/docs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | source "$(brew --prefix)/share/medic-bash/cecho.bash" 6 | source "$(brew --prefix)/share/medic-bash/step.bash" 7 | 8 | step_with_output "Generating docs" "mix docs" 9 | open doc/index.html 10 | -------------------------------------------------------------------------------- /bin/dev/doctor: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | set -u 5 | set -o pipefail 6 | 7 | # run doctor in home directory if present 8 | if [[ -f "${HOME}/bin/dev/doctor" ]]; then 9 | if ! step "Found a doctor script in home directory" "pushd ${HOME} > /dev/null && ./bin/dev/doctor && popd > /dev/null"; then 10 | exit 1 11 | fi 12 | fi 13 | 14 | medic doctor 15 | -------------------------------------------------------------------------------- /bin/dev/format: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # FORMATTER SCRIPT 4 | # 5 | # Formats Elixir files via `mix format`. This should be considered the canonical 6 | # formatter for the project. 7 | # 8 | # VS Code users in particular should be careful with its Elixir formatter (since 9 | # it routinely ignores the formatting settings) and should consider using this 10 | # script instead via the `emeraldwalk.runonsave` extension with the following 11 | # configuration: 12 | # 13 | # ``` 14 | # "emeraldwalk.runonsave": { 15 | # "commands": [ 16 | # { 17 | # "match": "\\.ex$|\\.exs$|\\.js$|\\.ts$", 18 | # "cmd": "bin/dev/format ${file}" 19 | # } 20 | # ] 21 | # } 22 | # ``` 23 | # 24 | # Options: 25 | # or : formats the file or all files in the path 26 | # --all: formats all files in the current directory 27 | # --cd: formats all files in the given directory 28 | # --check: fails if some files are not formatted 29 | 30 | 31 | set -e 32 | 33 | file=$1 34 | 35 | assert_file() { 36 | if [ ! -f "$1" ]; then 37 | echo "error: file '${1}' does not exist" 38 | exit 1 39 | fi 40 | } 41 | 42 | case $1 in 43 | --all) 44 | mix format 45 | ;; 46 | 47 | --cd) 48 | cd $2 49 | shift 50 | shift 51 | ;; 52 | 53 | --check) 54 | mix format --check-formatted 55 | ;; 56 | 57 | *.ex | *.exs) 58 | assert_file $1 59 | mix format "$1" 60 | ;; 61 | 62 | *) 63 | echo "unknown flag or file type: '$1'" 64 | echo "" 65 | echo "USAGE" 66 | echo " $0 or " 67 | echo " $0 --all" 68 | echo " $0 --cd " 69 | echo " $0 --check" 70 | exit 1 71 | ;; 72 | esac 73 | -------------------------------------------------------------------------------- /bin/dev/shipit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eo pipefail 4 | 5 | medic shipit 6 | -------------------------------------------------------------------------------- /bin/dev/test: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | medic test 4 | -------------------------------------------------------------------------------- /bin/dev/update: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | medic update 4 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | import Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for 9 | # 3rd-party users, it should be done in your "mix.exs" file. 10 | 11 | # You can configure your application as: 12 | # 13 | # config :gestalt, key: :value 14 | # 15 | # and access this configuration in your application as: 16 | # 17 | # Application.get_env(:gestalt, :key) 18 | # 19 | # You can also configure a 3rd-party app: 20 | # 21 | # config :logger, level: :info 22 | # 23 | 24 | # It is also possible to import configuration files, relative to this 25 | # directory. For example, you can emulate configuration per environment 26 | # by uncommenting the line below and defining dev.exs, test.exs and such. 27 | # Configuration from the imported file will override the ones defined 28 | # here (which is why it is important to import them last). 29 | # 30 | import_config "#{Mix.env()}.exs" 31 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :gestalt, :boolean_true, true 4 | config :gestalt, :boolean_false, false 5 | config :gestalt, :nil_value, nil 6 | config :gestalt, :keyword_list, some: "thing", with: "multiple", set: "values" 7 | -------------------------------------------------------------------------------- /lib/gestalt.ex: -------------------------------------------------------------------------------- 1 | defmodule Gestalt do 2 | @moduledoc """ 3 | Provides a wrapper for `Application.get_env/3` and `System.get_env/1`, where configuration 4 | can be overridden on a per-process basis. This allows asynchronous tests to change 5 | configuration on the fly without altering global state for other tests. 6 | 7 | 8 | ## Usage 9 | 10 | In `test_helper.exs`, add the following: 11 | 12 | {:ok, _} = Gestalt.start() 13 | 14 | In runtime code, where one would use `Application.get_env/3`, 15 | 16 | value = Application.get_env(:my_module, :my_config) 17 | 18 | instead the following could be used: 19 | 20 | value = Gestalt.get_config(:my_module, :my_config, self()) 21 | 22 | In runtime code, where one would use `System.get_env/1`, 23 | 24 | value = System.get_env("VARIABLE_NAME") 25 | 26 | instead the following could be used: 27 | 28 | value = Gestalt.get_env("VARIABLE_NAME", self()) 29 | 30 | 31 | ## Overriding values in tests 32 | 33 | The value of Gestalt comes from its ability to change configuration and/or environment 34 | in a way that only effects the current process. For instance, if code behaves differently 35 | depending on configuration, then a test that uses `Application.put_env/4` to verify its 36 | effect will change global state for other asynchronously-running tests. 37 | 38 | To change Application configuration, use the following: 39 | 40 | Gestalt.replace_config(:my_module, :my_value, "some value", self()) 41 | 42 | To change System environment, use the following: 43 | 44 | Gestalt.replace_env("VARIABLE_NAME", "some value", self()) 45 | 46 | 47 | ## Caveats 48 | 49 | Gestalt does not try to be too smart about merging overrides with existing configuration. 50 | If an override is set for the current pid, then all config and env values required by the 51 | runtime code must be specifically set. 52 | 53 | Also, note that Gestalt is a runtime configuration library. Values used by module variables 54 | are evaluated at compile time, not at runtime. 55 | 56 | """ 57 | 58 | alias Gestalt.Util 59 | 60 | @type agent :: pid() | atom() 61 | 62 | @spec __using__(any()) :: Macro.t() 63 | defmacro __using__(_) do 64 | quote do 65 | import Gestalt.Macros 66 | end 67 | end 68 | 69 | @doc ~S""" 70 | Starts an agent for storing override values. If an agent is already running, it 71 | is returned. 72 | 73 | ## Examples 74 | 75 | iex> {:ok, pid} = Gestalt.start() 76 | iex> is_pid(pid) 77 | true 78 | iex> {:ok, other_pid} = Gestalt.start() 79 | iex> pid == other_pid 80 | true 81 | 82 | """ 83 | @spec start(agent()) :: {:ok, pid()} 84 | def start(agent \\ __MODULE__) do 85 | case GenServer.whereis(agent) do 86 | nil -> Agent.start_link(fn -> %{} end, name: agent) 87 | server -> {:ok, server} 88 | end 89 | end 90 | 91 | @doc ~S""" 92 | Copies Gestalt overrides from one pid to another. If no overrides have been defined, 93 | returns `nil`. 94 | """ 95 | @spec copy(pid(), pid(), agent()) :: nil | map() 96 | def copy(from_pid, to_pid, agent \\ __MODULE__) 97 | 98 | def copy(from_pid, to_pid, agent) when is_pid(from_pid) and is_pid(to_pid) do 99 | unless GenServer.whereis(agent), 100 | do: raise("agent not started, please call start() before changing state") 101 | 102 | Agent.get_and_update(agent, fn state -> 103 | get_in(state, [from_pid]) 104 | |> case do 105 | nil -> {nil, state} 106 | overrides -> {overrides, state |> Map.put(to_pid, overrides)} 107 | end 108 | end) 109 | end 110 | 111 | @doc ~S""" 112 | Copies Gestalt overrides from one pid to another. If no overrides have been defined, 113 | raises a RuntimeError. 114 | """ 115 | @spec copy!(pid(), pid(), agent()) :: :ok 116 | def copy!(from_pid, to_pid, agent \\ __MODULE__) 117 | 118 | def copy!(from_pid, to_pid, agent) when is_pid(from_pid) and is_pid(to_pid) do 119 | copy(from_pid, to_pid, agent) 120 | |> case do 121 | nil -> raise("copy!/2 expected overrides for pid: #{inspect(from_pid)}, but none found") 122 | _overrides -> :ok 123 | end 124 | end 125 | 126 | @doc ~S""" 127 | Gets configuration either from Application, or from the running agent. 128 | 129 | ## Examples 130 | 131 | iex> {:ok, _pid} = Gestalt.start() 132 | iex> Application.put_env(:module_name, :key_name, true) 133 | iex> Gestalt.get_config(:module_name, :key_name, self()) 134 | true 135 | iex> Gestalt.replace_config(:module_name, :key_name, false, self()) 136 | :ok 137 | iex> Gestalt.get_config(:module_name, :key_name, self()) 138 | false 139 | 140 | """ 141 | @spec get_config(atom(), any(), pid()) :: any() 142 | @spec get_config(atom(), any(), pid(), agent()) :: any() 143 | @spec get_config(atom(), any(), atom(), agent()) :: none() 144 | def get_config(_module, _key, _pid, _agent \\ __MODULE__) 145 | 146 | def get_config(module, key, pid, agent) when is_pid(pid) do 147 | case GenServer.whereis(agent) do 148 | nil -> Application.get_env(module, key) 149 | _ -> get_agent_config(agent, pid, module, key) 150 | end 151 | end 152 | 153 | def get_config(_module, _key, _pid, _agent), do: raise("get_config/3 must receive a pid") 154 | 155 | @doc ~S""" 156 | Gets environment variables either from System, or from the running agent. 157 | 158 | ## Examples 159 | 160 | iex> {:ok, _pid} = Gestalt.start() 161 | iex> System.put_env("VARIABLE_FROM_ENV", "value set from env") 162 | iex> Gestalt.get_env("VARIABLE_FROM_ENV", self()) 163 | "value set from env" 164 | iex> Gestalt.replace_env("VARIABLE_FROM_ENV", "no longer from env", self()) 165 | :ok 166 | iex> Gestalt.get_env("VARIABLE_FROM_ENV", self()) 167 | "no longer from env" 168 | 169 | """ 170 | @spec get_env(String.t(), pid()) :: any() 171 | @spec get_env(String.t(), pid(), module()) :: any() 172 | def get_env(_variable, _pid, _agent \\ __MODULE__) 173 | 174 | def get_env(variable, pid, agent) when is_pid(pid) do 175 | case GenServer.whereis(agent) do 176 | nil -> System.get_env(variable) 177 | _ -> get_agent_env(agent, pid, variable) 178 | end 179 | end 180 | 181 | def get_env(_variable, _pid, _agent), do: raise("get_env/2 must receive a pid") 182 | 183 | ############################## 184 | ## Modify state 185 | ############################## 186 | 187 | @doc ~S""" 188 | Sets an override for the provided pid, effecting the behavior of `get_config/4`. 189 | """ 190 | @spec replace_config(atom(), any(), any(), pid()) :: :ok 191 | @spec replace_config(atom(), any(), any(), pid(), module()) :: :ok 192 | def replace_config(_module, _key, _value, _pid, _agent \\ __MODULE__) 193 | 194 | def replace_config(module, key, value, pid, agent) when is_pid(pid) do 195 | case GenServer.whereis(agent) do 196 | nil -> 197 | raise "agent not started, please call start() before changing state" 198 | 199 | _ -> 200 | Agent.update(agent, fn state -> 201 | update_map = %{module => %{key => value}} 202 | 203 | overrides = 204 | (get_in(state, [pid]) || [configuration: %{}]) 205 | |> Keyword.update(:configuration, update_map, &Util.Map.deep_merge(&1, update_map)) 206 | 207 | Map.put(state, pid, overrides) 208 | end) 209 | end 210 | end 211 | 212 | def replace_config(_module, _key, _value, _pid, _agent), do: raise("replace_config/4 must receive a pid") 213 | 214 | @doc ~S""" 215 | Sets an override for the provided pid, effecting the behavior of `get_env/3`. 216 | """ 217 | @spec replace_env(String.t(), any(), pid()) :: :ok 218 | @spec replace_env(String.t(), any(), pid(), module()) :: :ok 219 | def replace_env(_variable, _value, _pid, _agent \\ __MODULE__) 220 | 221 | def replace_env(variable, value, pid, agent) when is_pid(pid) do 222 | case GenServer.whereis(agent) do 223 | nil -> 224 | raise "agent not started, please call start() before changing state" 225 | 226 | _ -> 227 | Agent.update(agent, fn state -> 228 | overrides = 229 | (get_in(state, [pid]) || [env: %{}]) 230 | |> Keyword.update(:env, %{variable => value}, &Map.put(&1, variable, value)) 231 | 232 | Map.put(state, pid, overrides) 233 | end) 234 | end 235 | end 236 | 237 | def replace_env(_variable, _value, _pid, _agent), do: raise("replace_env/3 must receive a pid") 238 | 239 | ############################## 240 | ## Private 241 | ############################## 242 | 243 | defp get_agent_config(agent, caller_pid, module, key) do 244 | Agent.get(agent, fn state -> 245 | get_in(state, [caller_pid, :configuration]) 246 | end) 247 | |> case do 248 | nil -> 249 | Application.get_env(module, key) 250 | 251 | override -> 252 | case Map.has_key?(override, module) && Map.has_key?(override[module], key) do 253 | false -> Application.get_env(module, key) 254 | true -> get_in(override, [module, key]) 255 | end 256 | end 257 | end 258 | 259 | defp get_agent_env(agent, caller_pid, variable) when is_binary(variable) do 260 | Agent.get(agent, fn state -> 261 | get_in(state, [caller_pid, :env]) 262 | end) 263 | |> case do 264 | nil -> 265 | System.get_env(variable) 266 | 267 | override -> 268 | case Map.has_key?(override, variable) do 269 | false -> System.get_env(variable) 270 | true -> override[variable] 271 | end 272 | end 273 | end 274 | end 275 | -------------------------------------------------------------------------------- /lib/gestalt/macros.ex: -------------------------------------------------------------------------------- 1 | defmodule Gestalt.Macros do 2 | @moduledoc """ 3 | Provides macros that execute Gestalt code only in the :test Mix environment. 4 | In other environments, Gestalt is compiled out and either Application or 5 | System is used. 6 | 7 | ## Usage 8 | 9 | defmodule MyApp.Config do 10 | use Gestalt 11 | 12 | def config_value(), 13 | do: gestalt_config(:my_app, :config, self()) 14 | 15 | def env_value(), 16 | do: gestalt_env("ENVIRONMENT_VARIABLE", self()) 17 | end 18 | """ 19 | 20 | @spec gestalt_config(module(), atom(), pid()) :: Macro.t() 21 | defmacro gestalt_config(module, key, pid) do 22 | if Mix.env() == :test do 23 | quote location: :keep do 24 | Gestalt.get_config(unquote(module), unquote(key), unquote(pid)) 25 | end 26 | else 27 | quote location: :keep do 28 | _pid = unquote(pid) 29 | Application.get_env(unquote(module), unquote(key)) 30 | end 31 | end 32 | end 33 | 34 | @spec gestalt_env(binary(), pid()) :: Macro.t() 35 | defmacro gestalt_env(variable, pid) do 36 | if Mix.env() == :test do 37 | quote location: :keep do 38 | Gestalt.get_env(unquote(variable), unquote(pid)) 39 | end 40 | else 41 | quote location: :keep do 42 | _pid = unquote(pid) 43 | System.get_env(unquote(variable)) 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/gestalt/util/map.ex: -------------------------------------------------------------------------------- 1 | defmodule Gestalt.Util.Map do 2 | @moduledoc false 3 | 4 | @spec deep_merge(map(), map()) :: map() 5 | def deep_merge(left, right) do 6 | Elixir.Map.merge(left, right, &deep_resolve/3) 7 | end 8 | 9 | defp deep_resolve(_key, left = %{}, right = %{}) do 10 | deep_merge(left, right) 11 | end 12 | 13 | defp deep_resolve(_key, _left, right) do 14 | right 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/mix/tasks/gestalt/tags/create.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Gestalt.Tags.Create do 2 | @moduledoc false 3 | 4 | use Mix.Task 5 | 6 | @shortdoc "Creates a git tag" 7 | @impl Mix.Task 8 | def run([]) do 9 | start_app!() 10 | 11 | Mix.Shell.IO.cmd( 12 | command() 13 | |> Enum.join(" ") 14 | ) 15 | end 16 | 17 | defp command do 18 | [ 19 | "git", 20 | "tag", 21 | "-a", 22 | tag(), 23 | "-m", 24 | "'#{description()}'" 25 | ] 26 | end 27 | 28 | defp description do 29 | Mix.Shell.IO.prompt("Please enter a tag message:") 30 | end 31 | 32 | defp tag do 33 | {:ok, version} = :application.get_key(:gestalt, :vsn) 34 | "v#{version}" 35 | end 36 | 37 | defp start_app!, do: Mix.Task.run("app.start", []) 38 | end 39 | -------------------------------------------------------------------------------- /lib/mix/tasks/gestalt/tags/push.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Gestalt.Tags.Push do 2 | @moduledoc false 3 | 4 | use Mix.Task 5 | 6 | @shortdoc "Pushes all git tags" 7 | @impl Mix.Task 8 | def run([]) do 9 | Mix.Shell.IO.cmd(command() |> Enum.join(" ")) 10 | end 11 | 12 | defp command do 13 | [ 14 | "git", 15 | "push", 16 | "origin", 17 | "--tags" 18 | ] 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Gestalt.MixProject do 2 | use Mix.Project 3 | 4 | @version "2.0.0" 5 | 6 | def project do 7 | [ 8 | aliases: aliases(), 9 | app: :gestalt, 10 | deps: deps(), 11 | description: description(), 12 | dialyzer: dialyzer(), 13 | docs: docs(), 14 | elixir: "~> 1.15", 15 | package: package(), 16 | source_url: "https://github.com/synchronal/elixir-gestalt", 17 | start_permanent: Mix.env() == :prod, 18 | version: @version 19 | ] 20 | end 21 | 22 | # Run "mix help compile.app" to learn about applications. 23 | def application do 24 | [extra_applications: [:logger]] 25 | end 26 | 27 | def cli, 28 | do: [ 29 | preferred_envs: [credo: :test, dialyzer: :test] 30 | ] 31 | 32 | # # # 33 | 34 | defp aliases, 35 | do: [] 36 | 37 | defp deps, 38 | do: [ 39 | {:credo, ">= 0.0.0", only: [:dev, :test], runtime: false}, 40 | {:dialyxir, ">= 0.0.0", only: [:dev, :test], runtime: false}, 41 | {:ex_doc, ">= 0.0.0", only: :dev}, 42 | {:mix_audit, "~> 2.0", only: :dev, runtime: false} 43 | ] 44 | 45 | defp description() do 46 | """ 47 | A wrapper for `Application.get_config/3` and `System.get_env/1` that makes it easy 48 | to swap in process-specific overrides. Among other things, this allows tests 49 | to provide async-safe overrides. 50 | """ 51 | end 52 | 53 | defp dialyzer, 54 | do: [ 55 | plt_add_apps: [:ex_unit, :mix], 56 | plt_add_deps: :app_tree, 57 | plt_core_path: "_build/plts/#{Mix.env()}", 58 | plt_local_path: "_build/plts/#{Mix.env()}" 59 | ] 60 | 61 | defp docs, 62 | do: [ 63 | extras: extras(), 64 | source_ref: "v#{@version}", 65 | main: "overview" 66 | ] 67 | 68 | defp extras() do 69 | [ 70 | "pages/overview.md" 71 | ] 72 | end 73 | 74 | defp package(), 75 | do: [ 76 | files: ~w(lib .formatter.exs mix.exs README* LICENSE* CHANGELOG* pages), 77 | licenses: ["Apache"], 78 | links: %{ 79 | "GitHub" => "https://github.com/synchronal/elixir-gestalt", 80 | "Sponsor" => "https://github.com/sponsors/reflective-dev" 81 | }, 82 | maintainers: ["synchronal.dev", "Eric Saxby"] 83 | ] 84 | end 85 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 3 | "credo": {:hex, :credo, "1.7.11", "d3e805f7ddf6c9c854fd36f089649d7cf6ba74c42bc3795d587814e3c9847102", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "56826b4306843253a66e47ae45e98e7d284ee1f95d53d1612bb483f88a8cf219"}, 4 | "dialyxir": {:hex, :dialyxir, "1.4.5", "ca1571ac18e0f88d4ab245f0b60fa31ff1b12cbae2b11bd25d207f865e8ae78a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b0fb08bb8107c750db5c0b324fa2df5ceaa0f9307690ee3c1f6ba5b9eb5d35c3"}, 5 | "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, 6 | "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, 7 | "ex_doc": {:hex, :ex_doc, "0.37.3", "f7816881a443cd77872b7d6118e8a55f547f49903aef8747dbcb345a75b462f9", [:mix], [{:earmark_parser, "~> 1.4.42", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "e6aebca7156e7c29b5da4daa17f6361205b2ae5f26e5c7d8ca0d3f7e18972233"}, 8 | "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, 9 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 10 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 11 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 12 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 13 | "mix_audit": {:hex, :mix_audit, "2.1.4", "0a23d5b07350cdd69001c13882a4f5fb9f90fbd4cbf2ebc190a2ee0d187ea3e9", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "fd807653cc8c1cada2911129c7eb9e985e3cc76ebf26f4dd628bb25bbcaa7099"}, 14 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 15 | "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, 16 | "yaml_elixir": {:hex, :yaml_elixir, "2.11.0", "9e9ccd134e861c66b84825a3542a1c22ba33f338d82c07282f4f1f52d847bd50", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "53cc28357ee7eb952344995787f4bb8cc3cecbf189652236e9b163e8ce1bc242"}, 17 | } 18 | -------------------------------------------------------------------------------- /pages/overview.md: -------------------------------------------------------------------------------- 1 | Overview 2 | ======== 3 | 4 | Gestalt serves as a wrapper for `Application.get_env/3` and `System.get_env/1`. It provides a mechanism for setting 5 | process-specific overrides to application configuration and system variables, primarily to ease asynchronous testing of 6 | behaviors dependent on specific values. 7 | 8 | Gestalt should be used for getting runtime configuration, and not in places where configuration is compiled into 9 | modules. 10 | 11 | 12 | ## Asynchronous Testing 13 | 14 | Assuming your project sets configuration in `config.exs`, some elixir code will use `Application.get_env/3` to access 15 | its values. 16 | 17 | ```elixir 18 | use Mix.Config 19 | 20 | config :my_project, :enable_new_feature, true 21 | ``` 22 | 23 | This could be accessed at runtime using a config module: 24 | 25 | ```elixir 26 | defmodule Project.Config do 27 | def enable_new_feature? do 28 | Application.get_env(:my_project, :enable_new_feature) 29 | end 30 | end 31 | ``` 32 | 33 | And tests could be written: 34 | 35 | ```elixir 36 | defmodule Project.ConfigTest do 37 | use ExUnit.Case, async: true 38 | 39 | alias Project.Config 40 | 41 | describe "enable_new_feature?/0" do 42 | test "when :enable_new_feature is true, it is true" do 43 | :ok = Application.put_env(:my_project, :enable_new_feature, true) 44 | assert Config.enable_new_feature?() 45 | end 46 | 47 | test "when :enable_new_feature is false, it is false" do 48 | :ok = Application.put_env(:my_project, :enable_new_feature, false) 49 | refute Config.enable_new_feature?() 50 | end 51 | end 52 | end 53 | ``` 54 | 55 | Now there is a problem. Because these tests are marked `async: true`, there will be times that they will run 56 | concurrently. Since application env is global, `Application.put_env/4` will effect everything else in the runtime. If 57 | controller tests or acceptance tests assert on user-facing behavior related to the configuration, then the two tests 58 | shown above may cause those to fail randomly and non-deterministically. 59 | 60 | The same problem occurs with `System.get_env/1`. Code may behave differently in the presence of a specific environment 61 | variable. For instance, optionally initializing a library depending on whether or not an authentication token is 62 | present. `System.put_env/2` can be used to update values for tests, leading to more non-deterministic test failures. 63 | 64 | ```elixir 65 | defmodule Project.Config do 66 | #... 67 | 68 | def enable_monitoring_lib? do 69 | case monitoring_auth_token do 70 | nil -> false 71 | _ -> true 72 | end 73 | end 74 | 75 | def monitoring_auth_token do 76 | System.get_env("AUTH_TOKEN") 77 | end 78 | end 79 | ``` 80 | 81 | ```elixir 82 | defmodule Project.ConfigTest do 83 | #... 84 | 85 | describe "enable_monitoring_lib?/0" do 86 | test "when AUTH_TOKEN is present, it is true" do 87 | :ok = System.put_env("AUTH_TOKEN", "abc123") 88 | assert Config.enable_monitoring_lib?() 89 | end 90 | 91 | test "when AUTH_TOKEN is not present, it is false" do 92 | :ok = System.delete_env("AUTH_TOKEN") 93 | refute Config.enable_monitoring_lib?() 94 | end 95 | end 96 | end 97 | ``` 98 | 99 | One solution would be to set `async: false` for all tests that depend upon configuration. Another would be to rewrite 100 | the `Config` functions such that `Application` or `System` could be injected. The former would work for testing at the 101 | acceptance level. The second would not, without jumping through many more hoops of dependency injection. 102 | 103 | 104 | ## Pid-specific Overrides 105 | 106 | Gestalt solves this problem in a different fashion, by starting an `Agent` to store override values for specific pids. 107 | Initialization of the agent can be done in `test/test_helpers.exs`, for instance: 108 | 109 | ```elixir 110 | {:ok, _agent} = Gestalt.start() 111 | ``` 112 | 113 | Now the config module can be rewritten as follows: 114 | 115 | ```elixir 116 | defmodule Project.Config do 117 | def enable_new_feature? do 118 | Gestalt.get_config(:my_project, :enable_new_feature, self()) 119 | end 120 | 121 | def enable_monitoring_lib? do 122 | case monitoring_auth_token do 123 | nil -> false 124 | _ -> true 125 | end 126 | end 127 | 128 | def monitoring_auth_token do 129 | Gestalt.get_env("AUTH_TOKEN", self()) 130 | end 131 | end 132 | ``` 133 | 134 | By default, when there is no agent running or when there are no overrides for the current pid, `Gestalt.get_config/4` 135 | falls back to `Application.get_env/3` and `Gestalt.get_env/2` falls back to `System.get_env/1`. For purposes of clarity 136 | and to remind us that Gestalt overrides are pid-specific, the pid arguments are not optional. Gestalt functions do take 137 | an extra optional argument, which is the agent name. 138 | 139 | Now our tests can be rewritten as follows to use `Gestalt.replace_config/5` and `Gestalt.replace_env/4`: 140 | 141 | ```elixir 142 | defmodule Project.ConfigTest do 143 | use ExUnit.Case, async: true 144 | 145 | alias Project.Config 146 | 147 | describe "enable_new_feature?/0" do 148 | test "when :enable_new_feature is true, it is true" do 149 | :ok = Gestalt.replace_config(:my_project, :enable_new_feature, true, self()) 150 | assert Config.enable_new_feature?() 151 | end 152 | 153 | test "when :enable_new_feature is false, it is false" do 154 | :ok = Gestalt.replace_config(:my_project, :enable_new_feature, false, self()) 155 | refute Config.enable_new_feature?() 156 | end 157 | end 158 | 159 | describe "enable_monitoring_lib?/0" do 160 | test "when AUTH_TOKEN is present, it is true" do 161 | :ok = Gestalt.replace_env("AUTH_TOKEN", "abc123", self()) 162 | assert Config.enable_monitoring_lib?() 163 | end 164 | 165 | test "when AUTH_TOKEN is not present, it is false" do 166 | :ok = Gestalt.replace_env("AUTH_TOKEN", nil, self()) 167 | refute Config.enable_monitoring_lib?() 168 | end 169 | end 170 | end 171 | ``` 172 | 173 | Note that `self()` can be used in both the code and the tests, because the code is running in the same pid as the test. 174 | In most cases, this will be safe. In some few cases, the code might be running in a separate process from the test, in 175 | which case `replace_config` and `replace_env` should use the pid of the running code. 176 | 177 | 178 | ## Runtime vs. Compile-time 179 | 180 | Gestalt can be used to override values in the runtime. A common pattern in Elixir dependency injection is to use 181 | application config to set module variables. This happens at compile time, making it impossible for Gestalt to 182 | provide overrides. 183 | 184 | 185 | ## Gestalt macros for testing 186 | 187 | For cases where runtime configuration should only be overridden during testing, Gestalt provides macros that compile 188 | to `Application` and `System` in non-test environments. 189 | 190 | ```elixir 191 | defmodule Project.Config do 192 | use Gestalt 193 | 194 | def enable_new_feature? do 195 | gestalt_config(:my_project, :enable_new_feature, self()) 196 | end 197 | 198 | def enable_monitoring_lib? do 199 | case monitoring_auth_token do 200 | nil -> false 201 | _ -> true 202 | end 203 | end 204 | 205 | def monitoring_auth_token do 206 | gestalt_env("AUTH_TOKEN", self()) 207 | end 208 | end 209 | ``` 210 | 211 | 212 | ## Acceptance tests 213 | 214 | Acceptance tests often involve multiple processes. Wallaby, for instance, starts a Phoenix server, which processes 215 | requests in separate processes. In this case, `self()` in the context of the test process will be different from 216 | `self()` in the server process. 217 | 218 | This can by solved with the `Gestalt.copy/2` function, in a test plug. 219 | 220 | If the `test` Mix env includes `test/support` in its elixir paths, then a plug can be written in 221 | `test/support/test/plug/gestalt.ex` 222 | 223 | ```elixir 224 | defmodule ProjectWeb.Test.Plug.Gestalt do 225 | alias Plug.Conn 226 | 227 | def init(_), do: nil 228 | 229 | def call(conn, _opts), 230 | do: 231 | conn 232 | |> extract_metadata() 233 | |> copy_overrides(conn) 234 | 235 | defp extract_metadata(%Conn{} = conn), 236 | do: 237 | conn 238 | |> Conn.get_req_header("user-agent") 239 | |> List.first() 240 | |> Phoenix.Ecto.SQL.Sandbox.decode_metadata() 241 | 242 | defp copy_overrides(%{gestalt_pid: gestalt_pid}, conn) do 243 | Gestalt.copy(gestalt_pid, self()) 244 | conn 245 | end 246 | 247 | defp copy_overrides(_metadata, conn), do: conn 248 | end 249 | ``` 250 | 251 | This can then be added to the Phoenix Endpoint: 252 | 253 | ```elixir 254 | defmodule ProjectWeb.Endpoint do 255 | use Phoenix.Endpoint, otp_app: :my_project 256 | 257 | if Application.get_env(:my_project, :sql_sandbox) do 258 | plug(Phoenix.Ecto.SQL.Sandbox) 259 | plug(ProjectWeb.Test.Plug.Gestalt) 260 | end 261 | 262 | # .... 263 | end 264 | ``` 265 | 266 | In the `CaseTemplate` used for your acceptance tests, the following can then be configured (if using Wallaby): 267 | 268 | ```elixir 269 | setup tags do 270 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Project.Repo) 271 | unless tags[:async], do: Ecto.Adapters.SQL.Sandbox.mode(Project.Repo, {:shared, self()}) 272 | 273 | # Add the :gestalt_pid test process pid to the metadata being passed through each acceptance request header 274 | metadata = Phoenix.Ecto.SQL.Sandbox.metadata_for(Project.Repo, self()) |> Map.put(:gestalt_pid, self()) 275 | {:ok, session} = Wallaby.start_session(metadata: metadata) 276 | {:ok, session: session} 277 | end 278 | ``` 279 | 280 | Now, any overrides configured for the test process will be copied onto the server process pid, and 281 | `Gestalt.get_config/3` or `Gestalt.get_env/2` will have your overrides available. 282 | 283 | 284 | ## LiveView tests 285 | 286 | LiveView tests can be configured to share the Gestalt configuration via `Phoenix.LiveView.on_mount/1`. 287 | In the test, the current pid should be added to the `conn`'s session. 288 | 289 | ```elixir 290 | Phoenix.ConnTest.init_test_session(conn, %{gestalt_pid: self()}) 291 | {:ok, _live, _html} = live(conn, "/") 292 | ``` 293 | 294 | The LiveView can run an `on_mount` handler to copy configuration for the socket's pid. 295 | 296 | ```elixir 297 | defmodule Web.Gestalt do 298 | def on_mount(:copy_gestalt_config, _params, %{"gestalt_pid" => gestalt_pid}, socket) do 299 | Gestalt.copy(gestalt_pid, self()) 300 | {:cont, socket} 301 | end 302 | 303 | def on_mount(:copy_gestalt_config, _params, _session, socket) do 304 | {:cont, socket} 305 | end 306 | end 307 | ``` 308 | 309 | ```elixir 310 | module Web.MyLive do 311 | use Web, :live_view 312 | 313 | on_mount {Web.Gestalt, :copy_gestalt_config} 314 | 315 | def render(assigns) do 316 | ~H"
" 317 | end 318 | 319 | def mount(_params, _session, socket), do: {:ok, socket} 320 | end 321 | ``` 322 | -------------------------------------------------------------------------------- /test/gestalt_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GestaltTest do 2 | use ExUnit.Case, async: true 3 | doctest Gestalt 4 | 5 | describe "start/1" do 6 | test "starts a new agent" do 7 | refute GenServer.whereis(:start_agent) 8 | assert {:ok, agent} = Gestalt.start(:start_agent) 9 | assert ^agent = GenServer.whereis(:start_agent) 10 | Agent.stop(agent) 11 | end 12 | 13 | test "finds a running agent if it exists" do 14 | {:ok, agent} = Agent.start_link(fn -> %{} end, name: :running_agent) 15 | assert ^agent = GenServer.whereis(:running_agent) 16 | assert {:ok, ^agent} = Gestalt.start(:running_agent) 17 | Agent.stop(agent) 18 | end 19 | end 20 | 21 | describe "get_config/3" do 22 | test "verify Application env" do 23 | assert Application.get_env(:gestalt, :boolean_true) == true 24 | assert Application.get_env(:gestalt, :boolean_false) == false 25 | assert Application.get_env(:gestalt, :nil_value) == nil 26 | 27 | assert Application.get_env(:gestalt, :keyword_list) == [ 28 | some: "thing", 29 | with: "multiple", 30 | set: "values" 31 | ] 32 | end 33 | 34 | test "raises when pid is not passed" do 35 | assert_raise RuntimeError, "get_config/3 must receive a pid", fn -> 36 | Gestalt.get_config(:thing, :blah, nil) 37 | end 38 | 39 | assert_raise RuntimeError, "get_config/3 must receive a pid", fn -> 40 | Gestalt.get_config(:thing, :blah, "pid") 41 | end 42 | end 43 | 44 | test "uses Application if the agent is not running" do 45 | refute GenServer.whereis(:not_running) 46 | 47 | assert Gestalt.get_config(:gestalt, :boolean_true, self(), :not_running) == true 48 | assert Gestalt.get_config(:gestalt, :boolean_false, self(), :not_running) == false 49 | assert Gestalt.get_config(:gestalt, :nil_value, self(), :not_running) == nil 50 | 51 | assert Gestalt.get_config(:gestalt, :keyword_list, self(), :not_running) == 52 | [ 53 | some: "thing", 54 | with: "multiple", 55 | set: "values" 56 | ] 57 | end 58 | 59 | test "uses Application if the agent is running but there is no pid override" do 60 | {:ok, agent} = Gestalt.start(:get_config_value_no_override) 61 | 62 | assert Gestalt.get_config(:gestalt, :boolean_true, self(), :get_config_value_no_override) == true 63 | assert Gestalt.get_config(:gestalt, :boolean_false, self(), :get_config_value_no_override) == false 64 | assert Gestalt.get_config(:gestalt, :nil_value, self(), :get_config_value_no_override) == nil 65 | 66 | assert Gestalt.get_config(:gestalt, :keyword_list, self(), :get_config_value_no_override) == 67 | [ 68 | some: "thing", 69 | with: "multiple", 70 | set: "values" 71 | ] 72 | 73 | Agent.stop(agent) 74 | end 75 | 76 | test "uses the running agent if there is a pid override and the key exists" do 77 | pid = self() 78 | {:ok, agent} = Gestalt.start(:get_config_value_with_override) 79 | 80 | Agent.update(:get_config_value_with_override, fn state -> 81 | Map.put(state, pid, configuration: %{module: %{key: "value"}}) 82 | end) 83 | 84 | assert Gestalt.get_config(:module, :key, pid, :get_config_value_with_override) == "value" 85 | 86 | Agent.stop(agent) 87 | end 88 | 89 | test "uses the running agent if there is a pid override and the key exists and the value is nil" do 90 | pid = self() 91 | {:ok, agent} = Gestalt.start(:get_config_value_with_override) 92 | 93 | Agent.update(:get_config_value_with_override, fn state -> 94 | Map.put(state, pid, configuration: %{gestalt: %{boolean_true: nil}}) 95 | end) 96 | 97 | assert Gestalt.get_config(:gestalt, :boolean_true, pid, :get_config_value_with_override) == nil 98 | 99 | Agent.stop(agent) 100 | end 101 | 102 | test "uses Application if there is a pid override but the key is not present" do 103 | pid = self() 104 | {:ok, agent} = Gestalt.start(:get_config_value_with_override) 105 | 106 | Agent.update(:get_config_value_with_override, fn state -> 107 | Map.put(state, pid, configuration: %{module: %{key: "value"}}) 108 | end) 109 | 110 | assert Gestalt.get_config(:gestalt, :boolean_true, pid, :get_config_value_with_override) == true 111 | 112 | Agent.stop(agent) 113 | end 114 | end 115 | 116 | describe "get_env/2" do 117 | test "raises when pid is not passed" do 118 | assert_raise RuntimeError, "get_env/2 must receive a pid", fn -> 119 | Gestalt.get_env("VARIABLE", nil) 120 | end 121 | 122 | assert_raise RuntimeError, "get_env/2 must receive a pid", fn -> 123 | Gestalt.get_env("VARIABLE", "self()") 124 | end 125 | end 126 | 127 | test "uses System if the agent is not running" do 128 | refute GenServer.whereis(:not_running) 129 | 130 | System.put_env("VARIABLE_AGENT_NOT_RUNNING", "exists") 131 | assert Gestalt.get_env("VARIABLE_AGENT_NOT_RUNNING", self(), :not_running) == "exists" 132 | end 133 | 134 | test "uses System if the agent is running but there is no pid override" do 135 | {:ok, agent} = Gestalt.start(:get_env_value_no_override) 136 | 137 | System.put_env("VARIABLE_AGENT_RUNNING", "definitely exists") 138 | assert Gestalt.get_env("VARIABLE_AGENT_RUNNING", self(), :not_running) == "definitely exists" 139 | 140 | Agent.stop(agent) 141 | end 142 | 143 | test "uses the running agent if there is a pid override and the key exists" do 144 | pid = self() 145 | {:ok, agent} = Gestalt.start(:get_env_value_with_override) 146 | 147 | System.put_env("VARIABLE_OVERRIDE", "i am from the system") 148 | 149 | Agent.update(:get_env_value_with_override, fn state -> 150 | Map.put(state, pid, env: %{"VARIABLE_OVERRIDE" => "i am overridden"}) 151 | end) 152 | 153 | assert Gestalt.get_env("VARIABLE_OVERRIDE", pid, :get_env_value_with_override) == "i am overridden" 154 | 155 | Agent.stop(agent) 156 | end 157 | 158 | test "uses the running agent if there is a pid override and the key exists and the value is an empty string" do 159 | pid = self() 160 | {:ok, agent} = Gestalt.start(:get_env_value_with_override) 161 | 162 | System.put_env("VARIABLE_OVERRIDE", "i am from the system") 163 | 164 | Agent.update(:get_env_value_with_override, fn state -> 165 | Map.put(state, pid, env: %{"VARIABLE_OVERRIDE" => ""}) 166 | end) 167 | 168 | assert Gestalt.get_env("VARIABLE_OVERRIDE", pid, :get_env_value_with_override) == "" 169 | 170 | Agent.stop(agent) 171 | end 172 | 173 | test "uses System if there is a pid override but the key is not present" do 174 | pid = self() 175 | {:ok, agent} = Gestalt.start(:get_env_value_with_override) 176 | 177 | System.put_env("VARIABLE_OVERRIDE", "i am from the system") 178 | 179 | Agent.update(:get_env_value_with_override, fn state -> 180 | Map.put(state, pid, env: %{"OTHER_VARIABLE_OVERRIDE" => "i am overridden"}) 181 | end) 182 | 183 | assert Gestalt.get_env("VARIABLE_OVERRIDE", pid, :get_env_value_with_override) == "i am from the system" 184 | 185 | Agent.stop(agent) 186 | end 187 | end 188 | 189 | describe "replace_config/4" do 190 | test "raises when pid is not passed" do 191 | assert_raise RuntimeError, "replace_config/4 must receive a pid", fn -> 192 | Gestalt.replace_config(:gestalt, :key, true, "self()") 193 | end 194 | end 195 | 196 | test "raises when no agent has been started" do 197 | refute GenServer.whereis(:replace_config_no_agent) 198 | 199 | assert_raise RuntimeError, "agent not started, please call start() before changing state", fn -> 200 | Gestalt.replace_config(:gestalt, :key, true, self(), :replace_config_no_agent) 201 | end 202 | end 203 | 204 | test "adds to a running agent" do 205 | pid = self() 206 | {:ok, agent} = Gestalt.start(:replace_config) 207 | 208 | assert Agent.get(:replace_config, fn state -> state end) == %{} 209 | Gestalt.replace_config(:gestalt, :key, true, pid, :replace_config) 210 | assert Agent.get(:replace_config, fn state -> state end) == %{pid => [configuration: %{gestalt: %{key: true}}]} 211 | 212 | Agent.stop(agent) 213 | end 214 | 215 | test "handles multiple overrides for the same module" do 216 | pid = self() 217 | {:ok, agent} = Gestalt.start(:multiple_overrides) 218 | 219 | Gestalt.replace_config(:some, :stuff, [host: "here"], pid, :multiple_overrides) 220 | Gestalt.replace_config(:some, :thing, "yay", pid, :multiple_overrides) 221 | Gestalt.replace_config(:some, :other_thing, "nay", pid, :multiple_overrides) 222 | 223 | assert Gestalt.get_config(:some, :other_thing, pid, :multiple_overrides) == "nay" 224 | assert Gestalt.get_config(:some, :thing, pid, :multiple_overrides) == "yay" 225 | assert Gestalt.get_config(:some, :stuff, pid, :multiple_overrides) == [host: "here"] 226 | 227 | Agent.stop(agent) 228 | end 229 | 230 | test "merges into a running agent with overrides" do 231 | pid = self() 232 | 233 | {:ok, agent} = 234 | Agent.start_link( 235 | fn -> 236 | %{pid => [configuration: %{gestalt: %{key: true}}]} 237 | end, 238 | name: :merge_config 239 | ) 240 | 241 | assert Agent.get(:merge_config, fn state -> state end) == %{pid => [configuration: %{gestalt: %{key: true}}]} 242 | Gestalt.replace_config(:gestalt, :override, "yay", pid, :merge_config) 243 | Gestalt.replace_config(:other, :thing, "nay", pid, :merge_config) 244 | 245 | assert Agent.get(:merge_config, fn state -> state end) == %{ 246 | pid => [ 247 | configuration: %{ 248 | gestalt: %{key: true, override: "yay"}, 249 | other: %{thing: "nay"} 250 | } 251 | ] 252 | } 253 | 254 | Agent.stop(agent) 255 | end 256 | 257 | test "merges into a running agent with env overrides" do 258 | pid = self() 259 | 260 | {:ok, agent} = 261 | Agent.start_link( 262 | fn -> 263 | %{pid => [env: %{"SOME" => "override"}]} 264 | end, 265 | name: :merge_config 266 | ) 267 | 268 | assert Agent.get(:merge_config, fn state -> state end) == %{pid => [env: %{"SOME" => "override"}]} 269 | Gestalt.replace_config(:gestalt, :key, "yay", pid, :merge_config) 270 | 271 | assert Agent.get(:merge_config, fn state -> state end) == %{ 272 | pid => [ 273 | env: %{ 274 | "SOME" => "override" 275 | }, 276 | configuration: %{ 277 | gestalt: %{key: "yay"} 278 | } 279 | ] 280 | } 281 | 282 | Agent.stop(agent) 283 | end 284 | 285 | test "does not merge keyword lists" do 286 | pid = self() 287 | 288 | {:ok, agent} = 289 | Agent.start_link( 290 | fn -> 291 | %{pid => [configuration: %{gestalt: %{key: [with: "some", value: "list"]}}]} 292 | end, 293 | name: :merge_config_lists 294 | ) 295 | 296 | assert Agent.get(:merge_config_lists, fn state -> state end) == %{pid => [configuration: %{gestalt: %{key: [with: "some", value: "list"]}}]} 297 | 298 | Gestalt.replace_config(:gestalt, :key, [a: "different", keyword: "list"], pid, :merge_config_lists) 299 | 300 | assert Agent.get(:merge_config_lists, fn state -> state end) == %{ 301 | pid => [ 302 | configuration: %{ 303 | gestalt: %{key: [a: "different", keyword: "list"]} 304 | } 305 | ] 306 | } 307 | 308 | Agent.stop(agent) 309 | end 310 | end 311 | 312 | describe "replace_env/3" do 313 | test "raises when pid is not passed" do 314 | assert_raise RuntimeError, "replace_env/3 must receive a pid", fn -> 315 | Gestalt.replace_env("SOME_VARIABLE", true, "self()") 316 | end 317 | end 318 | 319 | test "raises when no agent has been started" do 320 | refute GenServer.whereis(:replace_env_no_agent) 321 | 322 | assert_raise RuntimeError, "agent not started, please call start() before changing state", fn -> 323 | Gestalt.replace_env("SOME_VARIABLE", true, self(), :replace_env_no_agent) 324 | end 325 | end 326 | 327 | test "adds to a running agent" do 328 | pid = self() 329 | {:ok, agent} = Gestalt.start(:replace_env) 330 | 331 | assert Agent.get(:replace_env, fn state -> state end) == %{} 332 | Gestalt.replace_env("REPLACING_VARIABLE", "overridden value", pid, :replace_env) 333 | assert Agent.get(:replace_env, fn state -> state end) == %{pid => [env: %{"REPLACING_VARIABLE" => "overridden value"}]} 334 | 335 | Agent.stop(agent) 336 | end 337 | 338 | test "merges into a running agent with overrides" do 339 | pid = self() 340 | 341 | {:ok, agent} = 342 | Agent.start_link( 343 | fn -> 344 | %{pid => [env: %{"EXISTING_OVERRIDE" => "something"}]} 345 | end, 346 | name: :merge_env 347 | ) 348 | 349 | assert Agent.get(:merge_env, fn state -> state end) == %{pid => [env: %{"EXISTING_OVERRIDE" => "something"}]} 350 | Gestalt.replace_env("REPLACING_VARIABLE", "overridden value", pid, :merge_env) 351 | 352 | assert Agent.get(:merge_env, fn state -> state end) == %{ 353 | pid => [ 354 | env: %{ 355 | "EXISTING_OVERRIDE" => "something", 356 | "REPLACING_VARIABLE" => "overridden value" 357 | } 358 | ] 359 | } 360 | 361 | Agent.stop(agent) 362 | end 363 | 364 | test "merges into a running agent with configuration overrides" do 365 | pid = self() 366 | 367 | {:ok, agent} = 368 | Agent.start_link( 369 | fn -> 370 | %{pid => [configuration: %{module: %{key: "value"}}]} 371 | end, 372 | name: :merge_env_into_config 373 | ) 374 | 375 | assert Agent.get(:merge_env_into_config, fn state -> state end) == %{pid => [configuration: %{module: %{key: "value"}}]} 376 | Gestalt.replace_env("ENV_VAR", "value", pid, :merge_env_into_config) 377 | 378 | assert Agent.get(:merge_env_into_config, fn state -> state end) == %{ 379 | pid => [ 380 | configuration: %{module: %{key: "value"}}, 381 | env: %{ 382 | "ENV_VAR" => "value" 383 | } 384 | ] 385 | } 386 | 387 | Agent.stop(agent) 388 | end 389 | end 390 | 391 | describe "copy/2" do 392 | test "raises when no agent has been started" do 393 | refute GenServer.whereis(:copy_env_no_agent) 394 | 395 | assert_raise RuntimeError, "agent not started, please call start() before changing state", fn -> 396 | Gestalt.copy(self(), :erlang.list_to_pid(~c"<0.1.0>"), :copy_env_no_agent) 397 | end 398 | end 399 | 400 | test "does nothing when no overrides exist for the source pid" do 401 | {:ok, agent} = Agent.start_link(fn -> %{} end, name: :copy_env_without_overrides) 402 | 403 | Gestalt.copy(self(), :erlang.list_to_pid(~c"<0.1.0>"), :copy_env_without_overrides) 404 | assert Agent.get(:copy_env_without_overrides, fn state -> state end) == %{} 405 | 406 | Agent.stop(agent) 407 | end 408 | 409 | test "copies overrides to another pid" do 410 | pid = self() 411 | {:ok, agent} = Agent.start_link(fn -> %{} end, name: :copy_env) 412 | 413 | Gestalt.replace_env("ENV_VAR", "overridden value", self(), :copy_env) 414 | Gestalt.replace_config(:something, :key, true, self(), :copy_env) 415 | 416 | other_pid = :erlang.list_to_pid(~c"<0.1.0>") 417 | Gestalt.copy(pid, other_pid, :copy_env) 418 | 419 | assert Agent.get(:copy_env, fn state -> state end) == %{ 420 | pid => [ 421 | env: %{"ENV_VAR" => "overridden value"}, 422 | configuration: %{something: %{key: true}} 423 | ], 424 | other_pid => [ 425 | env: %{"ENV_VAR" => "overridden value"}, 426 | configuration: %{something: %{key: true}} 427 | ] 428 | } 429 | 430 | Agent.stop(agent) 431 | end 432 | end 433 | 434 | describe "copy!/2" do 435 | test "raises when no agent has been started" do 436 | refute GenServer.whereis(:copy_env_no_agent) 437 | 438 | assert_raise RuntimeError, "agent not started, please call start() before changing state", fn -> 439 | Gestalt.copy!(self(), :erlang.list_to_pid(~c"<0.1.0>"), :copy_env_no_agent) 440 | end 441 | end 442 | 443 | test "raises when no overrides exist for the source pid" do 444 | pid = self() 445 | {:ok, agent} = Agent.start_link(fn -> %{} end, name: :copy_env_without_overrides) 446 | 447 | assert_raise RuntimeError, "copy!/2 expected overrides for pid: #{inspect(pid)}, but none found", fn -> 448 | Gestalt.copy!(pid, :erlang.list_to_pid(~c"<0.1.0>"), :copy_env_without_overrides) 449 | end 450 | 451 | Agent.stop(agent) 452 | end 453 | 454 | test "copies overrides to another pid" do 455 | pid = self() 456 | {:ok, agent} = Agent.start_link(fn -> %{} end, name: :copy_env) 457 | 458 | Gestalt.replace_env("ENV_VAR", "overridden value", self(), :copy_env) 459 | Gestalt.replace_config(:something, :key, true, self(), :copy_env) 460 | 461 | other_pid = :erlang.list_to_pid(~c"<0.1.0>") 462 | Gestalt.copy!(pid, other_pid, :copy_env) 463 | 464 | assert Agent.get(:copy_env, fn state -> state end) == %{ 465 | pid => [ 466 | env: %{"ENV_VAR" => "overridden value"}, 467 | configuration: %{something: %{key: true}} 468 | ], 469 | other_pid => [ 470 | env: %{"ENV_VAR" => "overridden value"}, 471 | configuration: %{something: %{key: true}} 472 | ] 473 | } 474 | 475 | Agent.stop(agent) 476 | end 477 | end 478 | end 479 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /test/util/map_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Gestalt.Util.MapTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Gestalt.Util 5 | 6 | describe "deep_merge/2" do 7 | test "merges keys from the second map into the first" do 8 | assert Util.Map.deep_merge(%{a: 1, b: 2}, %{c: 3}) == %{a: 1, b: 2, c: 3} 9 | end 10 | 11 | test "merges nested maps" do 12 | assert Util.Map.deep_merge(%{a: %{c: 1}, b: 2}, %{a: %{d: 3}}) == %{a: %{c: 1, d: 3}, b: 2} 13 | end 14 | 15 | test "prefers right-most values" do 16 | assert Util.Map.deep_merge(%{a: 1}, %{a: 2}) == %{a: 2} 17 | end 18 | 19 | test "does not merge lists" do 20 | assert Util.Map.deep_merge(%{a: [b: 2]}, %{a: [c: 3]}) == %{a: [c: 3]} 21 | end 22 | end 23 | end 24 | --------------------------------------------------------------------------------