├── .credo.exs ├── .formatter.exs ├── .github └── workflows │ └── test-actions.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── codecov.yml ├── config └── config.exs ├── coveralls.json ├── lib ├── request_cache.ex └── request_cache │ ├── application.ex │ ├── con_cache_store.ex │ ├── config.ex │ ├── metrics.ex │ ├── middleware.ex │ ├── plug.ex │ └── util.ex ├── mix.exs ├── mix.lock └── test ├── request_cache ├── con_cache_store_test.exs ├── metrics_test.exs ├── middleware_test.exs ├── plug_test.exs └── util_test.exs ├── request_cache_absinthe_test.exs ├── request_cache_plug_test.exs ├── support ├── ensure_only_called_once.ex └── utils.ex └── test_helper.exs /.credo.exs: -------------------------------------------------------------------------------- 1 | allowed_imports = [ 2 | [:Absinthe], 3 | [:ChannelCase], 4 | [:DataCase], 5 | [:EctoEnum], 6 | [:Ecto], 7 | [:ExUnit, :CaptureLog], 8 | [:ExUnit], 9 | [:Mix], 10 | [:Phoenix], 11 | [:Plug], 12 | [:Router, :Helpers], 13 | [:SharedUtils, :Support, :HTTPSandbox], 14 | [:Swoosh, :TestAssertions], 15 | [:Telemetry, :Metrics] 16 | ] 17 | 18 | %{ 19 | configs: [ 20 | %{ 21 | name: "default", 22 | files: %{ 23 | included: [ 24 | "lib/", 25 | "src/", 26 | "test/", 27 | "web/", 28 | "apps/*/lib/", 29 | "apps/*/src/", 30 | "apps/*/test/", 31 | "apps/*/web/" 32 | ], 33 | excluded: [~r"_build/", ~r"deps/"] 34 | }, 35 | plugins: [], 36 | requires: ["deps/blitz_credo/lib/blitz_credo/"], 37 | strict: true, 38 | parse_timeout: 10000, 39 | color: true, 40 | checks: [ 41 | 42 | # BlitzCredoChecks 43 | {BlitzCredoChecks.SetWarningsAsErrorsInTest, false}, 44 | {BlitzCredoChecks.DocsBeforeSpecs, []}, 45 | {BlitzCredoChecks.DoctestIndent, []}, 46 | {BlitzCredoChecks.NoAsyncFalse, []}, 47 | {BlitzCredoChecks.NoDSLParentheses, []}, 48 | {BlitzCredoChecks.NoIsBitstring, []}, 49 | {BlitzCredoChecks.StrictComparison, []}, 50 | {BlitzCredoChecks.UseStream, []}, 51 | {BlitzCredoChecks.LowercaseTestNames, []}, 52 | {BlitzCredoChecks.ImproperImport, allowed_modules: allowed_imports}, 53 | 54 | # Consistency Checks 55 | {Credo.Check.Consistency.ExceptionNames, []}, 56 | {Credo.Check.Consistency.LineEndings, []}, 57 | {Credo.Check.Consistency.ParameterPatternMatching, []}, 58 | {Credo.Check.Consistency.SpaceAroundOperators, []}, 59 | {Credo.Check.Consistency.SpaceInParentheses, []}, 60 | {Credo.Check.Consistency.TabsOrSpaces, []}, 61 | 62 | # Design Checks 63 | {Credo.Check.Design.AliasUsage, false}, 64 | 65 | # No outstanding TODOs 66 | {Credo.Check.Design.TagTODO, false}, 67 | {Credo.Check.Design.TagFIXME, false}, 68 | 69 | # # Readability Checks 70 | {Credo.Check.Readability.AliasOrder, false}, 71 | {Credo.Check.Readability.FunctionNames, []}, 72 | {Credo.Check.Readability.LargeNumbers, []}, 73 | {Credo.Check.Readability.MaxLineLength, [max_length: 120]}, 74 | {Credo.Check.Readability.ModuleAttributeNames, []}, 75 | {Credo.Check.Readability.ModuleDoc, false}, 76 | {Credo.Check.Readability.ModuleNames, []}, 77 | {Credo.Check.Readability.ParenthesesInCondition, []}, 78 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, 79 | {Credo.Check.Readability.PredicateFunctionNames, []}, 80 | {Credo.Check.Readability.PreferImplicitTry, []}, 81 | {Credo.Check.Readability.RedundantBlankLines, false}, 82 | {Credo.Check.Readability.Semicolons, []}, 83 | {Credo.Check.Readability.SpaceAfterCommas, false}, 84 | {Credo.Check.Readability.StringSigils, []}, 85 | {Credo.Check.Readability.TrailingBlankLine, false}, 86 | {Credo.Check.Readability.TrailingWhiteSpace, false}, 87 | {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, 88 | {Credo.Check.Readability.VariableNames, []}, 89 | # 90 | # Refactoring Opportunities 91 | {Credo.Check.Refactor.CondStatements, []}, 92 | {Credo.Check.Refactor.CyclomaticComplexity, false}, 93 | {Credo.Check.Refactor.FunctionArity, []}, 94 | {Credo.Check.Refactor.LongQuoteBlocks, false}, 95 | {Credo.Check.Refactor.MapInto, false}, 96 | {Credo.Check.Refactor.MatchInCondition, []}, 97 | {Credo.Check.Refactor.NegatedConditionsInUnless, []}, 98 | {Credo.Check.Refactor.NegatedConditionsWithElse, []}, 99 | {Credo.Check.Refactor.Nesting, false}, 100 | {Credo.Check.Refactor.UnlessWithElse, []}, 101 | {Credo.Check.Refactor.WithClauses, []}, 102 | 103 | # Warnings 104 | {Credo.Check.Warning.BoolOperationOnSameValues, []}, 105 | {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, 106 | {Credo.Check.Warning.IExPry, []}, 107 | {Credo.Check.Warning.IoInspect, []}, 108 | {Credo.Check.Warning.LazyLogging, false}, 109 | {Credo.Check.Warning.MixEnv, false}, 110 | {Credo.Check.Warning.OperationOnSameValues, []}, 111 | {Credo.Check.Warning.OperationWithConstantResult, []}, 112 | {Credo.Check.Warning.RaiseInsideRescue, []}, 113 | {Credo.Check.Warning.UnusedEnumOperation, []}, 114 | {Credo.Check.Warning.UnusedFileOperation, []}, 115 | {Credo.Check.Warning.UnusedKeywordOperation, []}, 116 | {Credo.Check.Warning.UnusedListOperation, []}, 117 | {Credo.Check.Warning.UnusedPathOperation, []}, 118 | {Credo.Check.Warning.UnusedRegexOperation, []}, 119 | {Credo.Check.Warning.UnusedStringOperation, []}, 120 | {Credo.Check.Warning.UnusedTupleOperation, []}, 121 | {Credo.Check.Warning.UnsafeExec, []}, 122 | 123 | # Controversial and experimental checks 124 | {Credo.Check.Readability.StrictModuleLayout, false}, 125 | {Credo.Check.Consistency.MultiAliasImportRequireUse, false}, 126 | {Credo.Check.Consistency.UnusedVariableNames, false}, 127 | {Credo.Check.Design.DuplicatedCode, false}, 128 | {Credo.Check.Readability.AliasAs, false}, 129 | {Credo.Check.Readability.MultiAlias, false}, 130 | {Credo.Check.Readability.Specs, false}, 131 | {Credo.Check.Readability.SinglePipe, []}, 132 | {Credo.Check.Readability.WithCustomTaggedTuple, []}, 133 | {Credo.Check.Refactor.ABCSize, false}, 134 | {Credo.Check.Refactor.AppendSingleItem, false}, 135 | {Credo.Check.Refactor.DoubleBooleanNegation, false}, 136 | {Credo.Check.Refactor.ModuleDependencies, false}, 137 | {Credo.Check.Refactor.NegatedIsNil, false}, 138 | {Credo.Check.Refactor.PipeChainStart, []}, 139 | {Credo.Check.Refactor.VariableRebinding, false}, 140 | {Credo.Check.Warning.LeakyEnvironment, false}, 141 | {Credo.Check.Warning.MapGetUnsafePass, false}, 142 | {Credo.Check.Warning.UnsafeToAtom, false} 143 | ] 144 | } 145 | ] 146 | } 147 | 148 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/workflows/test-actions.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push] 4 | 5 | jobs: 6 | Build: 7 | runs-on: ubuntu-latest 8 | 9 | env: 10 | MIX_ENV: test 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: Cache Deps & Build 16 | uses: actions/cache@v4 17 | with: 18 | key: ${{github.ref}}-deps-build-cache-${{hashFiles('./mix.lock')}} 19 | path: | 20 | /github/home/.mix 21 | ./deps 22 | ./_build 23 | 24 | - name: Set up Elixir 25 | uses: erlef/setup-beam@v1 26 | with: 27 | elixir-version: '1.14.1' # Define the elixir version [required] 28 | otp-version: '25.1.2' # Define the OTP version [required] 29 | 30 | - name: Install Rebar & Hex 31 | run: mix local.hex --force --if-missing && mix local.rebar --force --if-missing 32 | 33 | - name: Install Dependencies 34 | run: mix deps.get 35 | 36 | - name: Compile Project 37 | run: mix compile --warnings-as-errors 38 | 39 | Test: 40 | runs-on: ubuntu-latest 41 | 42 | needs: [Build] 43 | 44 | env: 45 | MIX_ENV: test 46 | 47 | steps: 48 | - uses: actions/checkout@v4 49 | 50 | - name: Cache Deps & Build 51 | uses: actions/cache@v4 52 | with: 53 | key: ${{github.ref}}-deps-build-cache-${{hashFiles('./mix.lock')}} 54 | path: | 55 | /github/home/.mix 56 | ./deps 57 | ./_build 58 | 59 | - name: Set up Elixir 60 | uses: erlef/setup-beam@v1 61 | with: 62 | elixir-version: '1.14.1' # Define the elixir version [required] 63 | otp-version: '25.1.2' # Define the OTP version [required] 64 | 65 | - name: Install Dependencies 66 | run: mix deps.get 67 | 68 | - name: Run Tests 69 | run: mix test 70 | 71 | Credo: 72 | runs-on: ubuntu-latest 73 | 74 | needs: [Build] 75 | 76 | steps: 77 | - uses: actions/checkout@v4 78 | 79 | - name: Cache Deps & Build 80 | uses: actions/cache@v4 81 | with: 82 | key: ${{github.ref}}-deps-build-cache-${{hashFiles('./mix.lock')}} 83 | path: | 84 | /github/home/.mix 85 | ./deps 86 | ./_build 87 | 88 | - name: Set up Elixir 89 | uses: erlef/setup-beam@v1 90 | with: 91 | elixir-version: '1.14.1' # Define the elixir version [required] 92 | otp-version: '25.1.2' # Define the OTP version [required] 93 | 94 | - name: Install Dependencies 95 | run: mix deps.get 96 | 97 | - name: Run Credo 98 | run: mix credo 99 | 100 | Coverage: 101 | runs-on: ubuntu-latest 102 | 103 | needs: [Build] 104 | 105 | steps: 106 | - uses: actions/checkout@v4 107 | 108 | - name: Cache Deps & Build 109 | uses: actions/cache@v4 110 | with: 111 | key: ${{github.ref}}-deps-build-cache-${{hashFiles('./mix.lock')}} 112 | path: | 113 | /github/home/.mix 114 | ./deps 115 | ./_build 116 | 117 | - name: Set up Elixir 118 | uses: erlef/setup-beam@v1 119 | with: 120 | elixir-version: '1.14.1' # Define the elixir version [required] 121 | otp-version: '25.1.2' # Define the OTP version [required] 122 | 123 | - name: Install Dependencies 124 | run: mix deps.get 125 | 126 | - name: Run Coveralls 127 | run: mix coveralls.json && bash <(curl -s https://codecov.io/bash) 128 | 129 | - name: Upload to codecov.io 130 | uses: codecov/codecov-action@v4 131 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Elixir ### 2 | /_build 3 | /cover 4 | /deps 5 | /doc 6 | /.fetch 7 | erl_crash.dump 8 | *.ez 9 | *.beam 10 | /config/*.secret.exs 11 | .elixir_ls/ 12 | 13 | ### Elixir Patch ### 14 | 15 | ### OSX ### 16 | # General 17 | .DS_Store 18 | .AppleDouble 19 | .LSOverride 20 | 21 | # Icon must end with two \r 22 | Icon 23 | 24 | # Thumbnails 25 | ._* 26 | 27 | # Files that might appear in the root of a volume 28 | .DocumentRevisions-V100 29 | .fseventsd 30 | .Spotlight-V100 31 | .TemporaryItems 32 | .Trashes 33 | .VolumeIcon.icns 34 | .com.apple.timemachine.donotpresent 35 | 36 | # Directories potentially created on remote AFP share 37 | .AppleDB 38 | .AppleDesktop 39 | Network Trash Folder 40 | Temporary Items 41 | .apdisk 42 | 43 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 1.0.2 2 | - Add rc-cache-key header so you can invalidate specific cache items 3 | 4 | # 1.0.1 5 | - Disable middleware when enabled? false in global config 6 | 7 | # 1.0.0 8 | - turn off caching errors by default, you must tune errors to `:all` to retain prior behaviour 9 | 10 | # 0.4.0 11 | - add whitelist for specific query names for caching instead of caching all, default all still cached 12 | - add ability to tune caching errors, default all still cached 13 | 14 | # 0.3.0 15 | - Big fixes for caching not applying all the time 16 | - Content type fixes to respect different forms of responses 17 | 18 | # 0.2.4 19 | - Fix issue from defaults 20 | 21 | # 0.2.3 22 | - remove all the extra default options merges in order to rely on choice function in plug 23 | 24 | # 0.2.2 25 | - You can now provide `ttl` and `cache` settings to the plug directly 26 | 27 | # 0.2.1 28 | - Tags can now include labels for metrics 29 | 30 | # 0.2.0 31 | - Add header to signify request cache is running 32 | 33 | # 0.1.14 34 | - Ensure default ttl is applied properly 35 | 36 | # 0.1.13 37 | - Add custom labels per endpoint via telemetry metrics 38 | 39 | # 0.1.12 40 | - Add telemetry metrics 41 | 42 | # 0.1.11 43 | - Fix error messaging 44 | - Make sure cache module gets pulled out of config or the conn opts properly 45 | 46 | # 0.1.10 47 | - Add contenttype of application/json to response 48 | 49 | # 0.1.9 50 | - Swap to md5 hashing as phash doesn't contain enough range 51 | 52 | # 0.1.8 53 | - fix config app not matching app name 54 | 55 | # 0.1.7 56 | - fix issue with plug not pulling configured cache 57 | 58 | # 0.1.6 59 | - fix issue with plug not pulling ttls out when using absinthe plugs 60 | 61 | # 0.1.5 62 | - fix some bugs around resolver usage of store 63 | - add verbose logging when enabled 64 | 65 | # 0.1.4 66 | - add debug log when item returned from cache 67 | 68 | # 0.1.3 69 | - Stop raising exceptions and log messages in debug mode 70 | - add `enabled?` global config` 71 | 72 | # 0.1.2 73 | - Fix the child_spec inside of ConCacheStore 74 | 75 | # 0.1.1 76 | - Lower absinthe version requirement 77 | 78 | # 0.1.0 79 | - Initial Release 80 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2022-2023 Mika Kalathil 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## RequestCache 2 | 3 | [![Test](https://github.com/MikaAK/request_cache_plug/actions/workflows/test-actions.yml/badge.svg)](https://github.com/MikaAK/request_cache_plug/actions/workflows/test-actions.yml) 4 | [![codecov](https://codecov.io/gh/MikaAK/request_cache_plug/branch/main/graph/badge.svg?token=RF4ASVG5PV)](https://codecov.io/gh/MikaAK/request_cache_plug) 5 | [![Hex version badge](https://img.shields.io/hexpm/v/request_cache_plug.svg)](https://hex.pm/packages/request_cache_plug) 6 | 7 | This plug allows us to cache our graphql queries and phoenix controller requests declaritevly 8 | 9 | We call the cache inside either a resolver or a controller action and this will store it preventing further 10 | executions of our query on repeat requests. 11 | 12 | The goal of this plug is to short-circuit any processing phoenix would 13 | normally do upon request including json decoding/parsing, the only step that should run is telemetry 14 | 15 | ### Installation 16 | 17 | This package can be installed by adding `request_cache_plug` to your list of dependencies in `mix.exs`: 18 | 19 | ```elixir 20 | def deps do 21 | [ 22 | {:request_cache_plug, "~> 1.0"} 23 | ] 24 | end 25 | ``` 26 | 27 | Documentation can be found at . 28 | 29 | ### Config 30 | This is the default config, it can all be changed but without any configuration setup this will be used: 31 | ```elixir 32 | config :request_cache_plug, 33 | enabled?: true, 34 | verbose?: false, 35 | graphql_paths: ["/graphiql", "/graphql"], 36 | conn_priv_key: :__shared_request_cache__, 37 | request_cache_module: RequestCache.ConCacheStore, 38 | default_ttl: :timer.hours(1), 39 | default_concache_opts: [ 40 | ttl_check_interval: :timer.seconds(1), 41 | acquire_lock_timeout: :timer.seconds(1), 42 | ets_options: [write_concurrency: true, read_concurrency: true] 43 | ] 44 | ``` 45 | 46 | ### Usage 47 | This plug is intended to be inserted into the `endpoint.ex` fairly early in the pipeline, 48 | it should go after telemetry but before our parsers 49 | 50 | ```elixir 51 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 52 | 53 | plug RequestCache.Plug 54 | 55 | plug Plug.Parsers, 56 | parsers: [:urlencoded, :multipart, :json], 57 | pass: ["*/*"] 58 | ``` 59 | 60 | We also need to setup a before_send hook to our absinthe_plug (if not using absinthe you can skip this step) 61 | ```elixir 62 | plug Absinthe.Plug, before_send: {RequestCache, :connect_absinthe_context_to_conn} 63 | ``` 64 | What this does is allow us to see the results of items we put onto our request context from within plugs coming after absinthe 65 | 66 | After that we can utilize our cache in a few ways 67 | 68 | #### Utilization with Phoenix Controllers 69 | ```elixir 70 | def index(conn, params) do 71 | conn 72 | |> RequestCache.store(:timer.seconds(60)) 73 | |> put_status(200) 74 | |> json(%{...}) 75 | end 76 | ``` 77 | 78 | #### Utilization with Absinthe Resolvers 79 | ```elixir 80 | def all(params, _resolution) do 81 | # Instead of returning {:ok, value} we return this 82 | RequestCache.store(value, :timer.seconds(60)) 83 | end 84 | ``` 85 | 86 | #### Utilization with Absinthe Middleware 87 | ```elixir 88 | field :user, :user do 89 | arg :id, non_null(:id) 90 | 91 | middleware RequestCache.Middleware, ttl: :timer.seconds(60) 92 | 93 | resolve &Resolvers.User.find/2 94 | end 95 | ``` 96 | 97 | ### Specifying Specific Caching Locations 98 | We have a few ways to control the caching location of our RequestCache, by default if you have `con_cache` installed, 99 | we have access to `RequestCache.ConCacheStore` which is the default setting 100 | However we can override this by setting `config :request_cache_plug, :request_cache_module, MyCustomCache` 101 | 102 | Caching module will be expected to have the following API: 103 | ```elixir 104 | def get(key) do 105 | ... 106 | end 107 | 108 | def put(key, ttl, value) do 109 | ... 110 | end 111 | ``` 112 | 113 | You are responsible for starting the cache, including ConCacheStore, so if you're planning to use it make sure 114 | you add `RequestCache.ConCacheStore` to the application.ex list of children 115 | 116 | We can also override the module for a particular request by passing the option to our graphql middleware or 117 | our `&RequestCache.store/2` function as `[ttl: 123, cache: MyCacheModule]` 118 | 119 | ##### With Middleware 120 | 121 | ```elixir 122 | field :user, :user do 123 | arg :id, non_null(:id) 124 | 125 | middleware RequestCache.Middleware, ttl: :timer.seconds(60), cache: MyCacheModule 126 | 127 | resolve &Resolvers.User.find/2 128 | end 129 | ``` 130 | 131 | ##### In a Resolver 132 | 133 | ```elixir 134 | def all(params, resolution) do 135 | RequestCache.store(value, ttl: :timer.seconds(60), cache: MyCacheModule) 136 | end 137 | ``` 138 | 139 | ##### In a Controller 140 | 141 | ```elixir 142 | def index(conn, params) do 143 | RequestCache.store(conn, ttl: :timer.seconds(60), cache: MyCacheModule) 144 | end 145 | ``` 146 | 147 | ### telemetry 148 | 149 | Cache events are emitted via :telemetry. Events are: 150 | 151 | - `[:request_cache_plug, :graphql, :cache_hit]` 152 | - `[:request_cache_plug, :graphql, :cache_miss]` 153 | - `[:request_cache_plug, :rest, :cache_hit]` 154 | - `[:request_cache_plug, :rest, :cache_miss]` 155 | - `[:request_cache_plug, :cache_put]` 156 | 157 | For GraphQL endpoints it is possible to provide a list of atoms that will be passed through to the event metadata; e.g.: 158 | 159 | ##### With Middleware 160 | 161 | ```elixir 162 | field :user, :user do 163 | arg :id, non_null(:id) 164 | 165 | middleware RequestCache.Middleware, 166 | ttl: :timer.seconds(60), 167 | cache: MyCacheModule, 168 | labels: [:service, :endpoint], 169 | whitelisted_query_names: ["MyQueryName"] # By default all queries are cached, can also whitelist based off query name from GQL Document 170 | 171 | resolve &Resolvers.User.find/2 172 | end 173 | ``` 174 | 175 | ##### In a Resolver 176 | 177 | ```elixir 178 | def all(params, resolution) do 179 | RequestCache.store(value, ttl: :timer.seconds(60), cache: MyCacheModule, labels: [:service, :endpoint]) 180 | end 181 | ``` 182 | 183 | The events will look like this: 184 | 185 | ```elixir 186 | { 187 | [:request_cache_plug, :graphql, :cache_hit], 188 | %{count: 1}, 189 | %{ttl: 3600000, cache_key: "/graphql:NNNN", labels: [:service, :endpoint]} 190 | } 191 | ``` 192 | 193 | ##### Enable Error Caching 194 | In order to enable error caching we can either setup `cached_errors` in our config 195 | or as an option to `RequestCache.store` or `RequestCache.Middleware`. 196 | 197 | The value of `cached_errors` can be one of `[]`, `:all` or a list of reason_atoms as 198 | defined by `Plug.Conn.Status` such as `:not_found`, or `:internal_server_error`. 199 | 200 | In REST this works off the response codes returned. However, in order to use reason_atoms in GraphQL 201 | you will need to make sure your errors contain some sort of `%{code: "not_found"}` response in them 202 | 203 | Take a look at [error_message](https://github.com/MikaAK/elixir_error_message) for a compatible error system 204 | 205 | 206 | ### Notes/Gotchas 207 | - In order for this caching to work, we cannot be using POST requests as specced out by GraphQL, not for queries at least, fortunately this doesn't actually matter since we can use any http method we want (there will be a limit to query size), in a production app you may be doing this already due to the caching you gain from CloudFlare 208 | - Caches are stored via a MD5 hashed key that correlates to your query in GraphQL, or in REST your url path + query parameters 209 | - Absinthe and ConCache are optional dependencies, if you don't have them you won't have access to `RequestCache.Middleware` or `RequestCache.ConCacheStore` 210 | - If no ConCache is found, you must set `config :request_cache_module` to something else 211 | 212 | ### Caching Header 213 | When an item is served from the cache, we return a header `rc-cache-status` which has a value of `HIT`. Using this you can tell if the item was 214 | served out of cache, without it the item was fetched. 215 | We can also invalidate specific items out of the cache, by using the `rc-cache-key` header which returns the key being used for the cache 216 | 217 | ### Example Reduction 218 | In the case of a large (16mb) payload running through absinthe, this plug cuts down response times from 400+ms -> <400μs 219 | 220 | 221 | image 222 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | target: 90% 6 | threshold: 0% 7 | base: auto 8 | patch: 9 | default: 10 | target: 80% 11 | threshold: 2% 12 | base: auto 13 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :request_cache_plug, 4 | enabled?: true, 5 | verbose?: false, 6 | cached_errors: [], 7 | graphql_paths: ["/graphiql", "/graphql"], 8 | conn_priv_key: :__shared_request_cache__, 9 | request_cache_module: RequestCache.ConCacheStore, 10 | default_ttl: :timer.hours(1), 11 | default_concache_opts: [ 12 | name: :con_cache_request_plug_store, 13 | global_ttl: :timer.hours(24 * 14), 14 | ttl_check_interval: :timer.seconds(1), 15 | acquire_lock_timeout: :timer.seconds(1), 16 | ets_options: [write_concurrency: true, read_concurrency: true] 17 | ] 18 | -------------------------------------------------------------------------------- /coveralls.json: -------------------------------------------------------------------------------- 1 | { 2 | "skip_files": ["lib/request_cache/application.ex"], 3 | "custom_stop_words": ["defdelegate"], 4 | "terminal_options": {"file_column_width": 80}, 5 | "coverage_options": {"treat_no_relevant_lines_as_covered": true, "minimum_coverage": 90} 6 | } 7 | 8 | -------------------------------------------------------------------------------- /lib/request_cache.ex: -------------------------------------------------------------------------------- 1 | defmodule RequestCache do 2 | @moduledoc """ 3 | #{File.read!("./README.md")} 4 | """ 5 | 6 | @type opts :: [ 7 | ttl: pos_integer, 8 | cache: module, 9 | cached_errors: :all | list(atom) 10 | ] 11 | 12 | @spec store(conn :: Plug.Conn.t, opts_or_ttl :: opts | pos_integer) :: Plug.Conn.t 13 | def store(conn, opts_or_ttl \\ []) 14 | 15 | def store(%Plug.Conn{} = conn, opts_or_ttl) do 16 | if RequestCache.Config.enabled?() do 17 | RequestCache.Plug.store_request(conn, opts_or_ttl) 18 | else 19 | conn 20 | end 21 | end 22 | 23 | if RequestCache.Application.dependency_found?(:absinthe) and 24 | RequestCache.Application.dependency_found?(:absinthe_plug) do 25 | def store(result, opts_or_ttl) do 26 | if RequestCache.Config.enabled?() do 27 | RequestCache.Middleware.store_result(result, opts_or_ttl) 28 | else 29 | result 30 | end 31 | end 32 | 33 | def connect_absinthe_context_to_conn(conn, %Absinthe.Blueprint{} = blueprint) do 34 | Absinthe.Plug.put_options(conn, context: blueprint.execution.context) 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/request_cache/application.ex: -------------------------------------------------------------------------------- 1 | defmodule RequestCache.Application do 2 | # See https://hexdocs.pm/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | 6 | use Application 7 | 8 | @impl true 9 | def start(_type, _args) do 10 | children = [] 11 | 12 | # See https://hexdocs.pm/elixir/Supervisor.html 13 | # for other strategies and supported options 14 | opts = [strategy: :one_for_one, name: RequestCache.Supervisor] 15 | Supervisor.start_link(children, opts) 16 | end 17 | 18 | def dependency_found?(dependency_name) do 19 | Enum.any?(Application.loaded_applications(), fn 20 | {^dependency_name, _, _} -> true 21 | _ -> false 22 | end) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/request_cache/con_cache_store.ex: -------------------------------------------------------------------------------- 1 | cond do 2 | RequestCache.Application.dependency_found?(:con_cache) -> 3 | defmodule RequestCache.ConCacheStore do 4 | @moduledoc false 5 | @default_name RequestCache.Config.default_concache_opts()[:name] 6 | 7 | def start_link(opts \\ []) do 8 | opts = Keyword.merge(RequestCache.Config.default_concache_opts(), opts) 9 | 10 | ConCache.start_link(opts) 11 | end 12 | 13 | def child_spec(opts) do 14 | %{ 15 | id: opts[:name] || @default_name, 16 | start: {RequestCache.ConCacheStore, :start_link, [opts]} 17 | } 18 | end 19 | 20 | def get(pid \\ @default_name, key) do 21 | {:ok, ConCache.get( 22 | pid || RequestCache.Config.default_concache_opts()[:name], 23 | key 24 | )} 25 | end 26 | 27 | def put(pid \\ @default_name, key, ttl, value) do 28 | ConCache.put( 29 | pid || RequestCache.Config.default_concache_opts()[:name], 30 | key, 31 | %ConCache.Item{value: value, ttl: ttl} 32 | ) 33 | end 34 | end 35 | 36 | RequestCache.Config.request_cache_module() === RequestCache.ConCacheStore -> 37 | raise "Default cache is still set to RequestCache.ConCacheStore but ConCache isn't a dependency of this application\n\nEither configure a new :request_cache_module for :request_cache or add con_cache to your list of dependencies" 38 | 39 | true -> :another_module_used 40 | end 41 | -------------------------------------------------------------------------------- /lib/request_cache/config.ex: -------------------------------------------------------------------------------- 1 | defmodule RequestCache.Config do 2 | @moduledoc false 3 | 4 | @app :request_cache_plug 5 | 6 | def verbose? do 7 | !!Application.get_env(@app, :verbose?, false) 8 | end 9 | 10 | def graphql_paths do 11 | Application.get_env(@app, :graphql_paths) || ["/graphiql", "/graphql"] 12 | end 13 | 14 | def conn_private_key do 15 | Application.get_env(@app, :conn_priv_key) || :__shared_request_cache__ 16 | end 17 | 18 | def cached_errors do 19 | Application.get_env(@app, :cached_errors) || [] 20 | end 21 | 22 | def request_cache_module do 23 | Application.get_env(@app, :request_cache_module) || RequestCache.ConCacheStore 24 | end 25 | 26 | def default_ttl do 27 | Application.get_env(@app, :default_ttl) || :timer.hours(1) 28 | end 29 | 30 | def enabled? do 31 | !!Application.get_env(@app, :enabled?, true) 32 | end 33 | 34 | def default_concache_opts do 35 | Application.get_env(@app, :default_concache_opts) || [ 36 | name: :con_cache_request_cache_store, 37 | global_ttl: default_ttl(), 38 | acquire_lock_timeout: :timer.seconds(1), 39 | ttl_check_interval: :timer.seconds(1), 40 | ets_options: [write_concurrency: true, read_concurrency: true] 41 | ] 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/request_cache/metrics.ex: -------------------------------------------------------------------------------- 1 | defmodule RequestCache.Metrics do 2 | @moduledoc false 3 | 4 | import Telemetry.Metrics, only: [counter: 2] 5 | 6 | @app :request_cache_plug 7 | @graphql_cache_hit [@app, :graphql, :cache_hit] 8 | @graphql_cache_miss [@app, :graphql, :cache_miss] 9 | @rest_cache_hit [@app, :rest, :cache_hit] 10 | @rest_cache_miss [@app, :rest, :cache_miss] 11 | @cache_put [@app, :cache_put] 12 | 13 | @cache_count %{count: 1} 14 | 15 | @spec metrics() :: list(Counter.t()) 16 | def metrics do 17 | [ 18 | counter( 19 | counter_event_name(@graphql_cache_hit), 20 | event_name: @graphql_cache_hit, 21 | description: "Cache hits on GraphQL endpoints", 22 | measurement: :count, 23 | tags: [:labels] 24 | ), 25 | counter( 26 | counter_event_name(@graphql_cache_miss), 27 | event_name: @graphql_cache_miss, 28 | description: "Cache misses on GraphQL endpoints", 29 | measurement: :count, 30 | tags: [:labels] 31 | ), 32 | counter( 33 | counter_event_name(@rest_cache_hit), 34 | event_name: @rest_cache_hit, 35 | description: "Cache hits on REST endpoints", 36 | measurement: :count, 37 | tags: [:labels] 38 | ), 39 | counter( 40 | counter_event_name(@rest_cache_miss), 41 | event_name: @rest_cache_miss, 42 | description: "Cache misses on REST endpoints", 43 | measurement: :count, 44 | tags: [:labels] 45 | ), 46 | counter( 47 | counter_event_name(@cache_put), 48 | event_name: @cache_put, 49 | description: "Cache puts", 50 | measurement: :count, 51 | tags: [:labels] 52 | ) 53 | ] 54 | end 55 | 56 | @spec inc_graphql_cache_hit(map()) :: :ok 57 | def inc_graphql_cache_hit(metadata), do: execute(@graphql_cache_hit, @cache_count, metadata) 58 | 59 | @spec inc_graphql_cache_miss(map()) :: :ok 60 | def inc_graphql_cache_miss(metadata), do: execute(@graphql_cache_miss, @cache_count, metadata) 61 | 62 | @spec inc_rest_cache_hit(map()) :: :ok 63 | def inc_rest_cache_hit(metadata), do: execute(@rest_cache_hit, @cache_count, metadata) 64 | 65 | @spec inc_rest_cache_miss(map()) :: :ok 66 | def inc_rest_cache_miss(metadata), do: execute(@rest_cache_miss, @cache_count, metadata) 67 | 68 | @spec inc_cache_put(map()) :: :ok 69 | def inc_cache_put(metadata), do: execute(@cache_put, @cache_count, metadata) 70 | 71 | @spec execute(keyword(), map(), map()) :: :ok 72 | def execute(event_name, measurements, metadata \\ %{}) do 73 | :telemetry.execute( 74 | event_name, 75 | measurements, 76 | metadata 77 | ) 78 | end 79 | 80 | defp counter_event_name(event_name), do: "#{Enum.join(event_name, ".")}.total" 81 | end 82 | -------------------------------------------------------------------------------- /lib/request_cache/middleware.ex: -------------------------------------------------------------------------------- 1 | absinthe_loaded? = RequestCache.Application.dependency_found?(:absinthe) and 2 | RequestCache.Application.dependency_found?(:absinthe_plug) 3 | if absinthe_loaded? do 4 | defmodule RequestCache.Middleware do 5 | alias RequestCache.Util 6 | 7 | @behaviour Absinthe.Middleware 8 | 9 | @impl Absinthe.Middleware 10 | def call(%Absinthe.Resolution{} = resolution, opts) when is_list(opts) do 11 | opts = ensure_valid_ttl(opts) 12 | 13 | if RequestCache.Config.enabled?() do 14 | enable_cache_for_resolution(resolution, opts) 15 | else 16 | resolution 17 | end 18 | end 19 | 20 | @impl Absinthe.Middleware 21 | def call(%Absinthe.Resolution{} = resolution, ttl) when is_integer(ttl) do 22 | if RequestCache.Config.enabled?() do 23 | enable_cache_for_resolution(resolution, ttl: ttl) 24 | else 25 | resolution 26 | end 27 | end 28 | 29 | defp ensure_valid_ttl(opts) do 30 | ttl = opts[:ttl] || RequestCache.Config.default_ttl() 31 | 32 | Keyword.put(opts, :ttl, ttl) 33 | end 34 | 35 | defp enable_cache_for_resolution(%Absinthe.Resolution{} = resolution, opts) do 36 | resolution = resolve_resolver_func_middleware(resolution, opts) 37 | 38 | if resolution.context[RequestCache.Config.conn_private_key()][:enabled?] do 39 | Util.verbose_log("[RequestCache.Middleware] Enabling cache for resolution") 40 | 41 | root_resolution_path_item = List.last(resolution.path) 42 | 43 | cache_request? = !!root_resolution_path_item && 44 | root_resolution_path_item.schema_node.name === "RootQueryType" && 45 | query_name_whitelisted?(root_resolution_path_item.name, opts) 46 | 47 | %{resolution | 48 | value: resolution.value || opts[:value], 49 | context: Map.update!( 50 | resolution.context, 51 | RequestCache.Config.conn_private_key(), 52 | &Util.deep_merge(&1, 53 | request: opts, 54 | cache_request?: cache_request? 55 | ) 56 | ) 57 | } 58 | else 59 | Util.log_cache_disabled_message() 60 | 61 | resolution 62 | end 63 | end 64 | 65 | defp resolve_resolver_func_middleware(resolution, opts) do 66 | if resolver_middleware?(opts) do 67 | %{resolution | state: :resolved} 68 | else 69 | resolution 70 | end 71 | end 72 | 73 | defp resolver_middleware?(opts), do: opts[:value] 74 | 75 | defp query_name_whitelisted?(query_name, opts) do 76 | is_nil(opts[:whitelisted_query_names]) or query_name in opts[:whitelisted_query_names] 77 | end 78 | 79 | @spec store_result( 80 | result :: any, 81 | opts_or_ttl :: RequestCache.opts | pos_integer 82 | ) :: {:middleware, module, RequestCache.opts} 83 | def store_result(result, ttl) when is_integer(ttl) do 84 | store_result(result, [ttl: ttl]) 85 | end 86 | 87 | def store_result(result, opts) when is_list(opts) do 88 | {:middleware, RequestCache.Middleware, Keyword.put(opts, :value, result)} 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /lib/request_cache/plug.ex: -------------------------------------------------------------------------------- 1 | defmodule RequestCache.Plug do 2 | require Logger 3 | 4 | alias RequestCache.{Util, Metrics} 5 | 6 | @moduledoc """ 7 | This plug allows you to cache GraphQL requests based off their query name and 8 | variables. This should be placed right after telemetry and before parsers so that it can 9 | stop any processing of the requests and immediately return a response. 10 | 11 | Please see `RequestCache` for more details 12 | """ 13 | 14 | @behaviour Plug 15 | 16 | # This is compile time so we can check quicker 17 | @graphql_paths RequestCache.Config.graphql_paths() 18 | @request_cache_header "rc-cache-status" 19 | @request_cache_key_header "rc-cache-key" 20 | 21 | def request_cache_header, do: @request_cache_header 22 | def request_cache_key_header, do: @request_cache_key_header 23 | 24 | @impl Plug 25 | def init(opts), do: opts 26 | 27 | @impl Plug 28 | def call(conn, opts) do 29 | if RequestCache.Config.enabled?() do 30 | Util.verbose_log("[RequestCache.Plug] Hit request cache while enabled") 31 | call_for_api_type(conn, opts) 32 | else 33 | Util.verbose_log("[RequestCache.Plug] Hit request cache while disabled") 34 | 35 | conn 36 | end 37 | end 38 | 39 | defp call_for_api_type(%Plug.Conn{ 40 | request_path: path, 41 | method: "GET", 42 | query_string: query_string 43 | } = conn, opts) when path in @graphql_paths do 44 | Util.verbose_log("[RequestCache.Plug] GraphQL query detected") 45 | 46 | maybe_return_cached_result(conn, opts, path, query_string) 47 | end 48 | 49 | defp call_for_api_type(%Plug.Conn{ 50 | request_path: path, 51 | method: "GET" 52 | } = conn, opts) when path not in @graphql_paths do 53 | Util.verbose_log("[RequestCache.Plug] REST path detected") 54 | 55 | cache_key = rest_cache_key(conn) 56 | 57 | case request_cache_module(conn, opts).get(cache_key) do 58 | {:ok, nil} -> 59 | Metrics.inc_rest_cache_miss(%{cache_key: cache_key}) 60 | Util.verbose_log("[RequestCache.Plug] REST enabling cache for conn and will cache if set") 61 | 62 | conn 63 | |> enable_request_cache_for_conn 64 | |> cache_before_send_if_requested(cache_key, opts) 65 | 66 | {:ok, cached_result} -> 67 | Metrics.inc_rest_cache_hit(%{cache_key: cache_key}) 68 | Util.verbose_log("[RequestCache.Plug] Returning cached result for #{cache_key}") 69 | 70 | halt_and_return_result(conn, cache_key, cached_result) 71 | 72 | {:error, e} -> 73 | log_error(e, conn, opts) 74 | 75 | enable_request_cache_for_conn(conn) 76 | end 77 | end 78 | 79 | defp call_for_api_type(conn, _opts), do: conn 80 | 81 | defp maybe_return_cached_result(conn, opts, request_path, query_string) do 82 | cache_key = Util.create_key(request_path, query_string) 83 | 84 | case request_cache_module(conn, opts).get(cache_key) do 85 | {:ok, nil} -> 86 | Metrics.inc_graphql_cache_miss(event_metadata(conn, cache_key, opts)) 87 | 88 | conn 89 | |> enable_request_cache_for_conn 90 | |> cache_before_send_if_requested(cache_key, opts) 91 | 92 | {:ok, cached_result} -> 93 | Metrics.inc_graphql_cache_hit(event_metadata(conn, cache_key, opts)) 94 | Util.verbose_log("[RequestCache.Plug] Returning cached result for #{cache_key}") 95 | 96 | halt_and_return_result(conn, cache_key, cached_result) 97 | 98 | {:error, e} -> 99 | log_error(e, conn, opts) 100 | 101 | enable_request_cache_for_conn(conn) 102 | end 103 | end 104 | 105 | defp halt_and_return_result(conn, cache_key, result) do 106 | conn 107 | |> Plug.Conn.halt() 108 | |> Plug.Conn.put_resp_header(@request_cache_header, "HIT") 109 | |> Plug.Conn.put_resp_header(@request_cache_key_header, cache_key) 110 | |> maybe_put_content_type(result) 111 | |> Plug.Conn.send_resp(200, result) 112 | end 113 | 114 | defp maybe_put_content_type(conn, result) do 115 | case Plug.Conn.get_resp_header(conn, "content-type") do 116 | [_ | _] -> conn 117 | [] -> 118 | cond do 119 | String.starts_with?(result, ["{", "["]) -> Plug.Conn.put_resp_content_type(conn, "application/json") 120 | String.starts_with?(result, ["<"]) -> Plug.Conn.put_resp_content_type(conn, "text/html") 121 | 122 | true -> conn 123 | end 124 | end 125 | end 126 | 127 | defp rest_cache_key(%Plug.Conn{request_path: path, query_string: query_string}) do 128 | Util.create_key(path, query_string) 129 | end 130 | 131 | defp cache_before_send_if_requested(conn, cache_key, opts) do 132 | Plug.Conn.register_before_send(conn, fn new_conn -> 133 | if enabled_for_request?(new_conn) do 134 | Util.verbose_log("[RequestCache.Plug] Cache enabled before send, setting into cache...") 135 | ttl = request_cache_ttl(new_conn, opts) 136 | 137 | with :ok <- request_cache_module(new_conn, opts).put(cache_key, ttl, to_string(new_conn.resp_body)) do 138 | Metrics.inc_cache_put(event_metadata(conn, cache_key, opts)) 139 | 140 | Util.verbose_log("[RequestCache.Plug] Successfully put #{cache_key} into cache\n#{new_conn.resp_body}") 141 | end 142 | 143 | new_conn 144 | else 145 | Util.verbose_log("[RequestCache.Plug] Cache disabled in before_send callback") 146 | 147 | new_conn 148 | end 149 | end) 150 | end 151 | 152 | @spec event_metadata(Plug.Conn.t, String.t, Keyword.t) :: map() 153 | defp event_metadata(conn, cache_key, opts) do 154 | %{ 155 | cache_key: cache_key, 156 | labels: request_cache_labels(conn), 157 | ttl: request_cache_ttl(conn, opts) 158 | } 159 | end 160 | 161 | defp request_cache_module(conn, opts) do 162 | conn_request(conn)[:cache] || opts[:cache] || RequestCache.Config.request_cache_module() 163 | end 164 | 165 | defp request_cache_ttl(conn, opts) do 166 | conn_request(conn)[:ttl] || opts[:ttl] || RequestCache.Config.default_ttl() 167 | end 168 | 169 | defp request_cache_labels(conn) do 170 | conn_request(conn)[:labels] 171 | end 172 | 173 | defp request_cache_cached_errors(conn) do 174 | conn_request(conn)[:cached_errors] || RequestCache.Config.cached_errors() 175 | end 176 | 177 | defp enabled_for_request?(%Plug.Conn{} = conn) do 178 | plug_present? = !!conn_private_key_item(conn, :enabled?) 179 | marked_for_cache? = !!conn_private_key_item(conn, :cache_request?) 180 | 181 | if plug_present? do 182 | Util.verbose_log("[RequestCache.Plug] Plug enabled for request") 183 | end 184 | 185 | if marked_for_cache? do 186 | Util.verbose_log("[RequestCache.Plug] Plug has been marked for cache") 187 | end 188 | 189 | plug_present? && marked_for_cache? && response_error_and_cached?(conn) 190 | end 191 | 192 | defp response_error_and_cached?(%Plug.Conn{status: 200, request_path: path}) when path not in @graphql_paths do 193 | true 194 | end 195 | 196 | defp response_error_and_cached?(%Plug.Conn{status: 200, request_path: path} = conn) when path in @graphql_paths do 197 | empty_errors? = String.contains?(conn.resp_body, empty_errors_pattern()) 198 | no_errors? = !String.contains?(conn.resp_body, error_pattern()) 199 | 200 | empty_errors? or 201 | no_errors? or 202 | gql_resp_has_known_error?(request_cache_cached_errors(conn), conn.resp_body) 203 | end 204 | 205 | defp response_error_and_cached?(%Plug.Conn{status: status} = conn) do 206 | cached_error_codes = request_cache_cached_errors(conn) 207 | 208 | cached_error_codes !== [] and 209 | (cached_error_codes === :all or Plug.Conn.Status.reason_atom(status) in cached_error_codes) 210 | end 211 | 212 | defp gql_resp_has_known_error?([], _resp_body), do: false 213 | defp gql_resp_has_known_error?(:all, _resp_body), do: true 214 | 215 | defp gql_resp_has_known_error?(cached_errors, resp_body) do 216 | String.contains?(resp_body, error_codes_pattern(cached_errors)) 217 | end 218 | 219 | def empty_errors_pattern, do: :binary.compile_pattern("\"errors\": []") 220 | def error_pattern, do: :binary.compile_pattern("\"errors\":") 221 | 222 | def error_codes_pattern(cached_errors) do 223 | cached_errors 224 | |> Enum.flat_map(&["code\":\"#{&1}", "code\" :\"#{&1}", "code\": \"#{&1}", "code\" : \"#{&1}"]) 225 | |> :binary.compile_pattern 226 | end 227 | 228 | 229 | defp conn_request(%Plug.Conn{} = conn) do 230 | conn_private_key_item(conn, :request) || [] 231 | end 232 | 233 | defp conn_private_key_item(%Plug.Conn{private: private}, name) do 234 | get_in(private, [conn_private_key(), name]) 235 | || get_in(private, [:absinthe, :context, conn_private_key(), name]) 236 | end 237 | 238 | if RequestCache.Application.dependency_found?(:absinthe_plug) do 239 | defp enable_request_cache_for_conn(conn) do 240 | context = conn.private[:absinthe][:context] || %{} 241 | 242 | conn 243 | |> deep_merge_to_private(enabled?: true) 244 | |> Absinthe.Plug.put_options( 245 | context: Util.deep_merge(context, %{conn_private_key() => [enabled?: true]}) 246 | ) 247 | end 248 | else 249 | defp enable_request_cache_for_conn(conn) do 250 | deep_merge_to_private(conn, enabled?: true) 251 | end 252 | end 253 | 254 | def store_request(conn, opts) when is_list(opts) do 255 | if conn.private[conn_private_key()][:enabled?] do 256 | Util.verbose_log("[RequestCache.Plug] Storing REST request in #{conn_private_key()}") 257 | 258 | deep_merge_to_private(conn, 259 | cache_request?: true, 260 | request: opts 261 | ) 262 | else 263 | Util.log_cache_disabled_message() 264 | 265 | conn 266 | end 267 | end 268 | 269 | def store_request(conn, ttl) when is_integer(ttl) do 270 | store_request(conn, [ttl: ttl]) 271 | end 272 | 273 | defp conn_private_key do 274 | RequestCache.Config.conn_private_key() 275 | end 276 | 277 | defp deep_merge_to_private(conn, params) do 278 | (conn.private[conn_private_key()] || []) 279 | |> Util.deep_merge(params) 280 | |> then(&Plug.Conn.put_private(conn, conn_private_key(), &1)) 281 | end 282 | 283 | defp log_error(error, conn, opts) do 284 | {:current_stacktrace, stacktrace} = Process.info(self(), :current_stacktrace) 285 | 286 | # credo:disable-for-lines:3 287 | Logger.error( 288 | "[RequestCache.Plug] recieved an error from #{inspect(request_cache_module(conn, opts))}", 289 | [crash_reason: {error, stacktrace}] 290 | ) 291 | end 292 | end 293 | -------------------------------------------------------------------------------- /lib/request_cache/util.ex: -------------------------------------------------------------------------------- 1 | defmodule RequestCache.Util do 2 | require Logger 3 | 4 | @moduledoc false 5 | 6 | @whitelisted_modules [DateTime, NaiveDateTime, Date, Time, File.Stat, MapSet, Regex, URI, Version] 7 | 8 | def create_key(url_path, query_string) do 9 | "#{url_path}:#{hash_string(query_string)}" 10 | end 11 | 12 | defp hash_string(query_string) do 13 | :md5 |> :crypto.hash(query_string) |> Base.encode16(padding: false) 14 | end 15 | 16 | def log_cache_disabled_message do 17 | Logger.warning("RequestCache requested but hasn't been enabled, ensure RequestCache.Plug is part of your endpoint.ex file") 18 | end 19 | 20 | def verbose_log(message) do 21 | if RequestCache.Config.verbose?() do 22 | Logger.debug(message) 23 | end 24 | end 25 | 26 | def deep_merge(list_a, list_b) when is_list(list_a) and is_list(list_b) do 27 | Keyword.merge(list_a, list_b, fn 28 | _k, _, %struct{} = right when struct in @whitelisted_modules -> right 29 | _k, left, right when is_map(left) and is_map(right) -> deep_merge(left, right) 30 | _k, left, right when is_list(left) and is_list(right) -> deep_merge(left, right) 31 | _, _, right -> right 32 | end) 33 | end 34 | 35 | def deep_merge(map_a, map_b) do 36 | Map.merge(map_a, map_b, fn 37 | _k, _, %struct{} = right when struct in @whitelisted_modules -> right 38 | _k, left, right when is_map(left) and is_map(right) -> deep_merge(left, right) 39 | _k, left, right when is_list(left) and is_list(right) -> deep_merge(left, right) 40 | _, _, right -> right 41 | end) 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule RequestCache.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :request_cache_plug, 7 | version: "1.0.2", 8 | elixir: "~> 1.12", 9 | description: "Plug to cache requests declaratively for either GraphQL or Phoenix, this plug is intended to short circuit all json/decoding or parsing a server would normally do", 10 | start_permanent: Mix.env() == :prod, 11 | elixirc_paths: elixirc_paths(Mix.env()), 12 | deps: deps(), 13 | docs: docs(), 14 | package: package(), 15 | test_coverage: [tool: ExCoveralls], 16 | preferred_cli_env: [ 17 | coveralls: :test, 18 | "coveralls.json": :test, 19 | "coveralls.detail": :test, 20 | "coveralls.post": :test, 21 | "coveralls.html": :test 22 | ] 23 | ] 24 | end 25 | 26 | # Run "mix help compile.app" to learn about applications. 27 | def application do 28 | [ 29 | mod: {RequestCache.Application, []}, 30 | extra_applications: (if Mix.env() in [:test, :dev], do: [:con_cache, :logger], else: [:logger]) 31 | ] 32 | end 33 | 34 | # Run "mix help deps" to learn about dependencies. 35 | defp deps do 36 | [ 37 | {:absinthe, "~> 1.4", optional: true}, 38 | {:absinthe_plug, "~> 1.5", optional: true}, 39 | {:con_cache, "~> 1.0", optional: true}, 40 | {:plug, "~> 1.13"}, 41 | 42 | {:jason, "~> 1.0", only: [:test, :dev]}, 43 | {:ex_doc, ">= 0.0.0", only: :dev}, 44 | 45 | {:telemetry, "~> 1.1"}, 46 | {:telemetry_metrics, "~> 1.0"}, 47 | 48 | {:excoveralls, "~> 0.10", only: :test}, 49 | {:credo, "~> 1.6", only: [:test, :dev], runtime: false}, 50 | {:blitz_credo_checks, "~> 0.1", only: [:test, :dev], runtime: false} 51 | ] 52 | end 53 | 54 | # Specifies which paths to compile per environment. 55 | defp elixirc_paths(:test), do: ["lib", "test/support"] 56 | defp elixirc_paths(_), do: ["lib"] 57 | 58 | defp package do 59 | [ 60 | maintainers: ["Mika Kalathil"], 61 | licenses: ["MIT"], 62 | links: %{"GitHub" => "https://github.com/mikaak/request_cache_plug"}, 63 | files: ~w(mix.exs README.md CHANGELOG.md LICENSE lib config) 64 | ] 65 | end 66 | 67 | defp docs do 68 | [ 69 | main: "RequestCache", 70 | source_url: "https://github.com/mikaak/request_cache_plug", 71 | 72 | groups_for_modules: [ 73 | "Middleware/Plugs": [ 74 | RequestCache.Plug, 75 | RequestCache.Middleware 76 | ] 77 | ] 78 | ] 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "absinthe": {:hex, :absinthe, "1.7.8", "43443d12ad2b4fcce60e257ac71caf3081f3d5c4ddd5eac63a02628bcaf5b556", [:mix], [{:dataloader, "~> 1.0.0 or ~> 2.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:opentelemetry_process_propagator, "~> 0.2.1 or ~> 0.3", [hex: :opentelemetry_process_propagator, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c4085df201892a498384f997649aedb37a4ce8a726c170d5b5617ed3bf45d40b"}, 3 | "absinthe_plug": {:hex, :absinthe_plug, "1.5.8", "38d230641ba9dca8f72f1fed2dfc8abd53b3907d1996363da32434ab6ee5d6ab", [:mix], [{:absinthe, "~> 1.5", [hex: :absinthe, repo: "hexpm", optional: false]}, {:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "bbb04176647b735828861e7b2705465e53e2cf54ccf5a73ddd1ebd855f996e5a"}, 4 | "blitz_credo_checks": {:hex, :blitz_credo_checks, "0.1.10", "54ae0aa673101e3edd4f2ab0ee7860f90c3240e515e268546dd9f01109d2b917", [:mix], [{:credo, "~> 1.4", [hex: :credo, repo: "hexpm", optional: false]}], "hexpm", "b3248dd2c88a6fe907e84ed104e61b863c6451d8755aa609b36d3eb6c7bab9db"}, 5 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 6 | "certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"}, 7 | "con_cache": {:hex, :con_cache, "1.1.0", "45c7c6cd6dc216e47636232e8c683734b7fe293221fccd9454fa1757bc685044", [:mix], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8655f2ae13a1e56c8aef304d250814c7ed929c12810f126fc423ecc8e871593b"}, 8 | "credo": {:hex, :credo, "1.7.7", "771445037228f763f9b2afd612b6aa2fd8e28432a95dbbc60d8e03ce71ba4446", [: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", "8bc87496c9aaacdc3f90f01b7b0582467b69b4bd2441fe8aae3109d843cc2f2e"}, 9 | "earmark_parser": {:hex, :earmark_parser, "1.4.41", "ab34711c9dc6212dda44fcd20ecb87ac3f3fce6f0ca2f28d4a00e4154f8cd599", [:mix], [], "hexpm", "a81a04c7e34b6617c2792e291b5a2e57ab316365c2644ddc553bb9ed863ebefa"}, 10 | "ex_doc": {:hex, :ex_doc, "0.34.2", "13eedf3844ccdce25cfd837b99bea9ad92c4e511233199440488d217c92571e8", [:mix], [{:earmark_parser, "~> 1.4.39", [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", "5ce5f16b41208a50106afed3de6a2ed34f4acfd65715b82a0b84b49d995f95c1"}, 11 | "excoveralls": {:hex, :excoveralls, "0.18.2", "86efd87a0676a3198ff50b8c77620ea2f445e7d414afa9ec6c4ba84c9f8bdcc2", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "230262c418f0de64077626a498bd4fdf1126d5c2559bb0e6b43deac3005225a4"}, 12 | "file_system": {:hex, :file_system, "1.0.1", "79e8ceaddb0416f8b8cd02a0127bdbababe7bf4a23d2a395b983c1f8b3f73edd", [:mix], [], "hexpm", "4414d1f38863ddf9120720cd976fce5bdde8e91d8283353f0e31850fa89feb9e"}, 13 | "hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~>2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"}, 14 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, 15 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 16 | "makeup": {:hex, :makeup, "1.1.2", "9ba8837913bdf757787e71c1581c21f9d2455f4dd04cfca785c70bbfff1a76a3", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cce1566b81fbcbd21eca8ffe808f33b221f9eee2cbc7a1706fc3da9ff18e6cac"}, 17 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [: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", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, 18 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"}, 19 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 20 | "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, 21 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, 22 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, 23 | "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, 24 | "plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"}, 25 | "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, 26 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, 27 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 28 | "telemetry_metrics": {:hex, :telemetry_metrics, "1.0.0", "29f5f84991ca98b8eb02fc208b2e6de7c95f8bb2294ef244a176675adc7775df", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f23713b3847286a534e005126d4c959ebcca68ae9582118ce436b521d1d47d5d"}, 29 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, 30 | } 31 | -------------------------------------------------------------------------------- /test/request_cache/con_cache_store_test.exs: -------------------------------------------------------------------------------- 1 | defmodule RequestCache.ConCacheStoreTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias RequestCache.ConCacheStore 5 | 6 | describe "&get/1 and &put/3" do 7 | setup do 8 | key = "key_#{Enum.random(1..100_000_000_000)}" 9 | value = %{test: "key_#{Enum.random(1..100_000_000_000)}_key"} 10 | {:ok, pid} = ConCacheStore.start_link(name: :"#{key}_cache", ttl_check_interval: 10) 11 | 12 | %{ 13 | key: key, 14 | value: value, 15 | pid: pid 16 | } 17 | end 18 | 19 | test "can put into cache and pull items out", %{pid: pid, key: key, value: value} do 20 | assert {:ok, nil} = ConCacheStore.get(pid, key) 21 | 22 | assert :ok = ConCacheStore.put(pid, key, :timer.seconds(100), value) 23 | 24 | assert {:ok, ^value} = ConCacheStore.get(pid, key) 25 | end 26 | 27 | test "items expire via ttl", %{pid: pid, key: key, value: value} do 28 | assert {:ok, nil} = ConCacheStore.get(pid, key) 29 | 30 | assert :ok = ConCacheStore.put(pid, key, 25, value) 31 | 32 | Process.sleep(50) 33 | 34 | assert {:ok, nil} = ConCacheStore.get(pid, key) 35 | end 36 | end 37 | 38 | describe "&child_spec/1" do 39 | test "starts up properly" do 40 | pid_name = :"test_#{Enum.random(1..100_000_000)}" 41 | 42 | start_supervised!(RequestCache.ConCacheStore.child_spec(name: pid_name)) 43 | 44 | assert pid_name |> Process.whereis |> Process.alive? 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /test/request_cache/metrics_test.exs: -------------------------------------------------------------------------------- 1 | defmodule RequestCache.TelemetryMetricsTest do 2 | @moduledoc false 3 | 4 | use ExUnit.Case, async: true 5 | 6 | alias RequestCache.Support.Utils 7 | 8 | @expected_ttl 3_600_000 9 | @expected_measurements %{count: 1} 10 | @expected_rest_cache_hit_event_name [:request_cache_plug, :rest, :cache_hit] 11 | @expected_rest_cache_miss_event_name [:request_cache_plug, :rest, :cache_miss] 12 | @expected_graphql_cache_hit_event_name [:request_cache_plug, :graphql, :cache_hit] 13 | @expected_graphql_cache_miss_event_name [:request_cache_plug, :graphql, :cache_miss] 14 | # @expected_graphql_cache_put_event_name [:request_cache_plug, :graphql, :cache_put] 15 | @expected_cache_put_event_name [:request_cache_plug, :cache_put] 16 | 17 | @miss_cache_key "/graphql:BE1120D4C931B50910C1B8788FA21108" 18 | @hit_cache_key "/graphql:14BFE314D845C31342E288408A7DACE4" 19 | 20 | @expected_cache_miss_metadata %{ttl: @expected_ttl, cache_key: @miss_cache_key, labels: [:graphql, :test_endpoint]} 21 | @expected_cache_hit_metadata %{ttl: @expected_ttl, cache_key: @hit_cache_key, labels: [:graphql, :test_endpoint]} 22 | 23 | setup do: %{parent_pid: self()} 24 | 25 | describe "GraphQL RequestCache.Plug.call/2" do 26 | setup do 27 | conn = Map.put(Utils.graphql_conn(), :query_string, "?query=query any") 28 | 29 | %{conn: conn} 30 | end 31 | 32 | @tag capture_log: true 33 | test "cache miss", %{parent_pid: parent_pid, test: test, conn: conn} do 34 | start_telemetry_listener(parent_pid, test, @expected_graphql_cache_miss_event_name) 35 | 36 | RequestCache.Plug.call(conn, %{}) 37 | 38 | assert_receive {:telemetry_event, @expected_graphql_cache_miss_event_name, 39 | @expected_measurements, _metadata} 40 | end 41 | 42 | @tag capture_log: true 43 | test "cache miss with labels", %{parent_pid: parent_pid, test: test, conn: conn} do 44 | start_telemetry_listener(parent_pid, test, @expected_graphql_cache_miss_event_name) 45 | 46 | request = [labels: [:graphql, :test_endpoint]] 47 | 48 | conn 49 | |> Plug.Conn.put_private(RequestCache.Config.conn_private_key(), request: request) 50 | |> RequestCache.Plug.call(%{}) 51 | 52 | assert_receive {:telemetry_event, @expected_graphql_cache_miss_event_name, 53 | @expected_measurements, @expected_cache_miss_metadata} 54 | end 55 | end 56 | 57 | describe "GraphQL RequestCache.Plug.call/2 cache hit" do 58 | 59 | setup do 60 | conn = Map.put(Utils.graphql_conn(), :query_string, "?query=query all") 61 | 62 | RequestCache.ConCacheStore.put( 63 | nil, 64 | @hit_cache_key, 65 | 1_000, 66 | "TEST_VALUE" 67 | ) 68 | 69 | %{conn: conn} 70 | end 71 | 72 | @tag capture_log: true 73 | test "cache hit", %{parent_pid: parent_pid, test: test, conn: conn} do 74 | start_telemetry_listener(parent_pid, test, @expected_graphql_cache_hit_event_name) 75 | 76 | RequestCache.Plug.call(conn, %{}) 77 | 78 | assert_receive {:telemetry_event, @expected_graphql_cache_hit_event_name, 79 | @expected_measurements, _metadata} 80 | end 81 | 82 | @tag capture_log: true 83 | test "cache hit with labels", %{parent_pid: parent_pid, test: test, conn: conn} do 84 | start_telemetry_listener(parent_pid, test, @expected_graphql_cache_hit_event_name) 85 | 86 | request = [labels: [:graphql, :test_endpoint]] 87 | 88 | conn 89 | |> Plug.Conn.put_private(RequestCache.Config.conn_private_key(), request: request) 90 | |> RequestCache.Plug.call(%{}) 91 | 92 | assert_receive {:telemetry_event, @expected_graphql_cache_hit_event_name, 93 | @expected_measurements, @expected_cache_hit_metadata} 94 | end 95 | end 96 | 97 | describe "REST RequestCache.Plug.call/2" do 98 | @tag capture_log: true 99 | test "cache miss", %{parent_pid: parent_pid, test: test} do 100 | start_telemetry_listener(parent_pid, test, @expected_rest_cache_miss_event_name) 101 | 102 | Utils.rest_conn() 103 | |> Map.put(:query_string, "?page=1") 104 | |> RequestCache.Plug.call(%{}) 105 | 106 | assert_receive {:telemetry_event, @expected_rest_cache_miss_event_name, 107 | @expected_measurements, _metadata} 108 | end 109 | 110 | @tag capture_log: true 111 | test "cache hit", %{parent_pid: parent_pid, test: test} do 112 | RequestCache.ConCacheStore.put( 113 | nil, 114 | "/entity:17CE1C08EA497571A3B6BEB378C320B1", 115 | 10_000, 116 | "TEST_VALUE" 117 | ) 118 | 119 | start_telemetry_listener(parent_pid, test, @expected_rest_cache_hit_event_name) 120 | 121 | Utils.rest_conn() 122 | |> Map.put(:query_string, "?page=2") 123 | |> RequestCache.Plug.call(%{}) 124 | 125 | assert_receive {:telemetry_event, @expected_rest_cache_hit_event_name, 126 | @expected_measurements, _metadata} 127 | end 128 | end 129 | 130 | describe "metrics/0" do 131 | @tag capture_log: true 132 | test "metric definitions are correct" do 133 | assert [ 134 | %Telemetry.Metrics.Counter{ 135 | description: "Cache hits on GraphQL endpoints", 136 | event_name: @expected_graphql_cache_hit_event_name, 137 | measurement: :count, 138 | name: @expected_graphql_cache_hit_event_name ++ [:total], 139 | tags: [:labels] 140 | }, 141 | %Telemetry.Metrics.Counter{ 142 | description: "Cache misses on GraphQL endpoints", 143 | event_name: @expected_graphql_cache_miss_event_name, 144 | measurement: :count, 145 | name: @expected_graphql_cache_miss_event_name ++ [:total], 146 | tags: [:labels] 147 | }, 148 | %Telemetry.Metrics.Counter{ 149 | description: "Cache hits on REST endpoints", 150 | event_name: @expected_rest_cache_hit_event_name, 151 | measurement: :count, 152 | name: @expected_rest_cache_hit_event_name ++ [:total], 153 | tags: [:labels] 154 | }, 155 | %Telemetry.Metrics.Counter{ 156 | description: "Cache misses on REST endpoints", 157 | event_name: @expected_rest_cache_miss_event_name, 158 | measurement: :count, 159 | name: @expected_rest_cache_miss_event_name ++ [:total], 160 | tags: [:labels] 161 | }, 162 | %Telemetry.Metrics.Counter{ 163 | description: "Cache puts", 164 | event_name: @expected_cache_put_event_name, 165 | measurement: :count, 166 | name: @expected_cache_put_event_name ++ [:total], 167 | tags: [:labels] 168 | } 169 | ] = RequestCache.Metrics.metrics() 170 | end 171 | end 172 | 173 | defp start_telemetry_listener(parent_pid, handler_id, event_name, config \\ %{}) do 174 | :telemetry.attach( 175 | handler_id, 176 | event_name, 177 | event_handler(parent_pid), 178 | config 179 | ) 180 | end 181 | 182 | defp event_handler(parent_pid) do 183 | fn name, measurements, metadata, _config -> 184 | send(parent_pid, {:telemetry_event, name, measurements, metadata}) 185 | end 186 | end 187 | end 188 | -------------------------------------------------------------------------------- /test/request_cache/middleware_test.exs: -------------------------------------------------------------------------------- 1 | defmodule RequestCache.MiddlewareTest do 2 | use ExUnit.Case, async: true 3 | import ExUnit.CaptureLog 4 | 5 | describe "&call/2" do 6 | test "stores request configuration inside the context under configured conn_priv_key" do 7 | resolution = %Absinthe.Resolution{ 8 | context: %{RequestCache.Config.conn_private_key() => [enabled?: true]} 9 | } 10 | resolution = RequestCache.Middleware.call(resolution, ttl: :timer.seconds(10)) 11 | 12 | request_config = resolution.context[RequestCache.Config.conn_private_key()][:request] 13 | 14 | assert request_config[:ttl] === :timer.seconds(10) 15 | end 16 | 17 | test "throws exception when hasn't been enabled" do 18 | resolution = %Absinthe.Resolution{} 19 | 20 | assert capture_log(fn -> 21 | RequestCache.Middleware.call(resolution, ttl: :timer.seconds(10)) 22 | end) =~ "RequestCache requested" 23 | end 24 | 25 | test "default_ttl is applied when nil is given in map" do 26 | resolution = %Absinthe.Resolution{ 27 | context: %{RequestCache.Config.conn_private_key() => [enabled?: true]} 28 | } 29 | resolution = RequestCache.Middleware.call(resolution, ttl: nil) 30 | request_config = resolution.context[RequestCache.Config.conn_private_key()][:request] 31 | assert request_config[:ttl] === RequestCache.Config.default_ttl() 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /test/request_cache/plug_test.exs: -------------------------------------------------------------------------------- 1 | defmodule RequestCache.PlugTest do 2 | @moduledoc false 3 | 4 | use ExUnit.Case, async: true 5 | 6 | import ExUnit.CaptureLog 7 | 8 | defmodule FailingCacheModule do 9 | def get(_cache_key) do 10 | {:error, %{reason: :timeout}} 11 | end 12 | end 13 | 14 | describe "call/2" do 15 | @expected_log_content "recieved an error from RequestCache.PlugTest.FailingCacheModule" 16 | 17 | setup do 18 | config = [cache: FailingCacheModule] 19 | 20 | conn = Plug.Conn.put_private( 21 | %Plug.Conn{method: "GET"}, 22 | RequestCache.Config.conn_private_key(), 23 | request: config 24 | ) 25 | 26 | %{conn: conn} 27 | end 28 | 29 | test "it handles errors from the cache implementation on GraphQL endpoints", %{conn: conn} do 30 | conn = conn 31 | |> Map.put(:request_path, "/graphql") 32 | |> Map.put(:query_string, "?query=query MyQuery{myQuery{}}") 33 | 34 | error = capture_log(fn -> 35 | assert %Plug.Conn{} = RequestCache.Plug.call(conn, nil) 36 | end) 37 | 38 | assert error =~ @expected_log_content 39 | end 40 | 41 | test "it handles errors from the cache implementation on REST endpoints", %{conn: conn} do 42 | conn = conn 43 | |> Map.put(:request_path, "/my/object/1") 44 | |> Map.put(:query_string, "?page=1") 45 | 46 | error = capture_log(fn -> 47 | assert %Plug.Conn{} = RequestCache.Plug.call(conn, nil) 48 | end) 49 | 50 | assert error =~ @expected_log_content 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/request_cache/util_test.exs: -------------------------------------------------------------------------------- 1 | defmodule RequestCache.UtilTest do 2 | @moduledoc false 3 | 4 | use ExUnit.Case, async: true 5 | 6 | describe "&deep_merge/2" do 7 | test "deep merges keywords with nested maps and keywords properly" do 8 | date_time_a = DateTime.utc_now() 9 | date_time_b = DateTime.add(DateTime.utc_now(), 100) 10 | 11 | assert [ 12 | apple: %{ 13 | a: 2, 14 | b: 3, 15 | c: 4, 16 | date: date_time_b 17 | }, 18 | 19 | banana: [a: 1, b: 2], 20 | ] === RequestCache.Util.deep_merge( 21 | [apple: %{a: 1, b: 3, date: date_time_a}, banana: [a: 1]], 22 | [apple: %{a: 2, c: 4, date: date_time_b}, banana: [b: 2]] 23 | ) 24 | end 25 | 26 | test "deep merges maps with nested keywords and maps properly" do 27 | date_time_a = DateTime.utc_now() 28 | date_time_b = DateTime.add(DateTime.utc_now(), 100) 29 | 30 | assert %{ 31 | banana: %{a: 1, b: 2}, 32 | apple: [ 33 | b: 3, 34 | a: 2, 35 | c: 4, 36 | date: date_time_b 37 | ] 38 | } === RequestCache.Util.deep_merge( 39 | %{apple: [a: 1, b: 3, date: date_time_a], banana: %{a: 1}}, 40 | %{apple: [a: 2, c: 4, date: date_time_b], banana: %{b: 2}} 41 | ) 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /test/request_cache_absinthe_test.exs: -------------------------------------------------------------------------------- 1 | defmodule RequestCacheAbsintheTest do 2 | use ExUnit.Case, async: true 3 | import ExUnit.CaptureLog 4 | use Plug.Test 5 | 6 | alias RequestCache.Support.EnsureCalledOnlyOnce 7 | 8 | defmodule Schema do 9 | use Absinthe.Schema 10 | 11 | object :nested_item do 12 | field :world, :string 13 | end 14 | 15 | object :item do 16 | field :tester, :nested_item 17 | end 18 | 19 | query do 20 | field :hello, :string do 21 | resolve fn _, %{context: %{call_pid: pid}} -> 22 | EnsureCalledOnlyOnce.call(pid) 23 | RequestCache.store("Hello", :timer.seconds(100)) 24 | end 25 | end 26 | 27 | field :uncached_hello, :string do 28 | resolve fn _, %{context: %{call_pid: pid}} -> 29 | EnsureCalledOnlyOnce.call(pid) 30 | 31 | {:ok, "HelloUncached"} 32 | end 33 | end 34 | 35 | field :hello_world, :string do 36 | middleware RequestCache.Middleware, ttl: :timer.seconds(100) 37 | 38 | resolve fn _, %{context: %{call_pid: pid}} -> 39 | EnsureCalledOnlyOnce.call(pid) 40 | {:ok, "Hello2"} 41 | end 42 | end 43 | 44 | field :hello_error, :string do 45 | resolve fn _, %{context: %{call_pid: pid}} -> 46 | EnsureCalledOnlyOnce.call(pid) 47 | {:ok, "HelloError"} 48 | end 49 | end 50 | 51 | field :uncached_error, :string do 52 | middleware RequestCache.Middleware, cached_errors: [] 53 | 54 | resolve fn _, %{context: %{call_pid: pid}} -> 55 | EnsureCalledOnlyOnce.call(pid) 56 | 57 | {:error, %{code: :not_found, message: "TesT"}} 58 | end 59 | end 60 | 61 | field :cached_all_error, :string do 62 | arg :code, non_null(:string) 63 | 64 | middleware RequestCache.Middleware, cached_errors: :all 65 | 66 | resolve fn %{code: code}, %{context: %{call_pid: pid}} -> 67 | EnsureCalledOnlyOnce.call(pid) 68 | {:error, %{code: code, message: "TesT"}} 69 | end 70 | end 71 | 72 | field :cached_not_found_error, :string do 73 | middleware RequestCache.Middleware, cached_errors: [:not_found] 74 | 75 | resolve fn _, %{context: %{call_pid: pid}} -> 76 | EnsureCalledOnlyOnce.call(pid) 77 | {:error, %{code: :not_found, message: "TesT"}} 78 | end 79 | end 80 | 81 | field :whitelist_cached_hello, :item do 82 | middleware RequestCache.Middleware, whitelisted_query_names: ["SmallHello"] 83 | 84 | resolve fn _, _ -> 85 | {:ok, %{ 86 | tester: %{ 87 | world: "hello" 88 | } 89 | }} 90 | end 91 | end 92 | end 93 | end 94 | 95 | defmodule RouterWithoutPlug do 96 | use Plug.Router 97 | 98 | plug :match 99 | plug :dispatch 100 | 101 | forward "/graphql", 102 | to: Absinthe.Plug, 103 | init_opts: [ 104 | schema: RequestCacheAbsintheTest.Schema 105 | ] 106 | end 107 | 108 | defmodule Router do 109 | use Plug.Router 110 | 111 | plug RequestCache.Plug 112 | 113 | plug :match 114 | plug :dispatch 115 | 116 | forward "/graphql", 117 | to: Absinthe.Plug, 118 | init_opts: [ 119 | schema: RequestCacheAbsintheTest.Schema, 120 | before_send: {RequestCache, :connect_absinthe_context_to_conn} 121 | ] 122 | end 123 | 124 | @query "query Hello { hello }" 125 | @query_2 "query Hello2 { helloWorld }" 126 | @query_error "query HelloError { helloError }" 127 | @uncached_query "query HelloUncached { uncachedHello }" 128 | @uncached_error_query "query UncachedFound { uncachedError }" 129 | @cached_all_error_query "query CachedAllFound { cachedAllError(code: \"not_found\") }" 130 | @cached_not_found_error_query "query CachedNotFound { cachedNotFoundError }" 131 | @whitelist_named_query "query SmallHello { whitelistCachedHello { tester { world }} }" 132 | @whitelist_uncached_named_query "query SmallerHello { whitelistCachedHello { tester { world }} }" 133 | @whitelist_unnamed_query "query { whitelistCachedHello { tester { world }} }" 134 | 135 | setup do 136 | {:ok, pid} = EnsureCalledOnlyOnce.start_link() 137 | 138 | %{call_pid: pid} 139 | end 140 | 141 | @tag capture_log: true 142 | test "does not cache queries that don't ask for caching", %{call_pid: pid} do 143 | assert %Plug.Conn{} = :get 144 | |> conn(graphql_url(@uncached_query)) 145 | |> RequestCache.Support.Utils.ensure_default_opts() 146 | |> Absinthe.Plug.put_options(context: %{call_pid: pid}) 147 | |> Router.call([]) 148 | 149 | assert_raise Plug.Conn.WrapperError, fn -> 150 | conn = :get 151 | |> conn(graphql_url(@uncached_query)) 152 | |> RequestCache.Support.Utils.ensure_default_opts() 153 | |> Absinthe.Plug.put_options(context: %{call_pid: pid}) 154 | |> Router.call([]) 155 | 156 | assert [] === get_resp_header(conn, RequestCache.Plug.request_cache_header()) 157 | end 158 | end 159 | 160 | @tag capture_log: true 161 | test "does not cache errors when error caching not enabled", %{call_pid: pid} do 162 | assert %Plug.Conn{} = :get 163 | |> conn(graphql_url(@uncached_error_query)) 164 | |> RequestCache.Support.Utils.ensure_default_opts() 165 | |> Absinthe.Plug.put_options(context: %{call_pid: pid}) 166 | |> Router.call([]) 167 | 168 | assert_raise Plug.Conn.WrapperError, fn -> 169 | conn = :get 170 | |> conn(graphql_url(@uncached_error_query)) 171 | |> RequestCache.Support.Utils.ensure_default_opts() 172 | |> Absinthe.Plug.put_options(context: %{call_pid: pid}) 173 | |> Router.call([]) 174 | 175 | assert [] === get_resp_header(conn, RequestCache.Plug.request_cache_header()) 176 | end 177 | end 178 | 179 | @tag capture_log: true 180 | test "caches errors when error caching set to :all", %{call_pid: pid} do 181 | assert %Plug.Conn{} = :get 182 | |> conn(graphql_url(@cached_all_error_query)) 183 | |> RequestCache.Support.Utils.ensure_default_opts(request: [cached_errors: :all]) 184 | |> Absinthe.Plug.put_options(context: %{call_pid: pid}) 185 | |> Router.call([]) 186 | 187 | assert ["HIT"] = :get 188 | |> conn(graphql_url(@cached_all_error_query)) 189 | |> RequestCache.Support.Utils.ensure_default_opts(request: [cached_errors: :all]) 190 | |> Absinthe.Plug.put_options(context: %{call_pid: pid}) 191 | |> Router.call([]) 192 | |> get_resp_header(RequestCache.Plug.request_cache_header()) 193 | end 194 | 195 | @tag capture_log: true 196 | test "caches errors when error caching set to [:not_found]", %{call_pid: pid} do 197 | assert %Plug.Conn{} = :get 198 | |> conn(graphql_url(@cached_not_found_error_query)) 199 | |> RequestCache.Support.Utils.ensure_default_opts(request: [cached_errors: [:not_found]]) 200 | |> Absinthe.Plug.put_options(context: %{call_pid: pid}) 201 | |> Router.call([]) 202 | 203 | assert ["HIT"] = :get 204 | |> conn(graphql_url(@cached_not_found_error_query)) 205 | |> RequestCache.Support.Utils.ensure_default_opts(request: [cached_errors: [:not_found]]) 206 | |> Absinthe.Plug.put_options(context: %{call_pid: pid}) 207 | |> Router.call([]) 208 | |> get_resp_header(RequestCache.Plug.request_cache_header()) 209 | end 210 | 211 | @tag capture_log: true 212 | test "allows you to use middleware before a resolver to cache the results of the request", %{call_pid: pid} do 213 | conn = :get 214 | |> conn(graphql_url(@query_2)) 215 | |> RequestCache.Support.Utils.ensure_default_opts() 216 | |> Absinthe.Plug.put_options(context: %{call_pid: pid}) 217 | |> Router.call([]) 218 | 219 | assert conn.resp_body === :get 220 | |> conn(graphql_url(@query_2)) 221 | |> RequestCache.Support.Utils.ensure_default_opts() 222 | |> Absinthe.Plug.put_options(context: %{call_pid: pid}) 223 | |> Router.call([]) 224 | |> Map.get(:resp_body) 225 | end 226 | 227 | @tag capture_log: true 228 | test "allows you to use &store/2 in a resolver to cache the results of the request", %{call_pid: pid} do 229 | conn = :get 230 | |> conn(graphql_url(@query)) 231 | |> RequestCache.Support.Utils.ensure_default_opts() 232 | |> Absinthe.Plug.put_options(context: %{call_pid: pid}) 233 | |> Router.call([]) 234 | 235 | assert conn.resp_body === :get 236 | |> conn(graphql_url(@query)) 237 | |> RequestCache.Support.Utils.ensure_default_opts() 238 | |> Absinthe.Plug.put_options(context: %{call_pid: pid}) 239 | |> Router.call([]) 240 | |> Map.get(:resp_body) 241 | end 242 | 243 | @tag capture_log: true 244 | test "throws an error when called twice without cache", %{call_pid: pid} do 245 | conn = :get 246 | |> conn(graphql_url(@query_error)) 247 | |> RequestCache.Support.Utils.ensure_default_opts() 248 | |> Absinthe.Plug.put_options(context: %{call_pid: pid}) 249 | |> RouterWithoutPlug.call([]) 250 | 251 | assert_raise Plug.Conn.WrapperError, fn -> 252 | assert conn.resp_body === :get 253 | |> conn(graphql_url(@query_error)) 254 | |> RequestCache.Support.Utils.ensure_default_opts() 255 | |> Absinthe.Plug.put_options(context: %{call_pid: pid}) 256 | |> RouterWithoutPlug.call([]) 257 | |> Map.get(:resp_body) 258 | end 259 | end 260 | 261 | test "logs an error if router doesn't have RequestCache.Plug", %{call_pid: pid} do 262 | assert capture_log(fn -> 263 | :get 264 | |> conn(graphql_url(@query)) 265 | |> Absinthe.Plug.put_options(context: %{call_pid: pid}) 266 | |> RouterWithoutPlug.call([]) 267 | |> Map.get(:resp_body) 268 | end) =~ "RequestCache requested" 269 | end 270 | 271 | @tag capture_log: true 272 | test "whitelist doesn't cache unspecified queries" do 273 | assert [] = :get 274 | |> conn(graphql_url(@whitelist_uncached_named_query)) 275 | |> RequestCache.Support.Utils.ensure_default_opts(request: [whitelisted_query_names: ["SmallHello"]]) 276 | |> Router.call([]) 277 | |> get_resp_header(RequestCache.Plug.request_cache_header()) 278 | 279 | assert [] = :get 280 | |> conn(graphql_url(@whitelist_uncached_named_query)) 281 | |> RequestCache.Support.Utils.ensure_default_opts(request: [whitelisted_query_names: ["SmallHello"]]) 282 | |> Router.call([]) 283 | |> get_resp_header(RequestCache.Plug.request_cache_header()) 284 | 285 | assert [] = :get 286 | |> conn(graphql_url(@whitelist_unnamed_query)) 287 | |> RequestCache.Support.Utils.ensure_default_opts(request: [whitelisted_query_names: ["SmallHello"]]) 288 | |> Router.call([]) 289 | |> get_resp_header(RequestCache.Plug.request_cache_header()) 290 | 291 | assert [] = :get 292 | |> conn(graphql_url(@whitelist_unnamed_query)) 293 | |> RequestCache.Support.Utils.ensure_default_opts(request: [whitelisted_query_names: ["SmallHello"]]) 294 | |> Router.call([]) 295 | |> get_resp_header(RequestCache.Plug.request_cache_header()) 296 | end 297 | 298 | @tag capture_log: true 299 | test "whitelist caches specific named queries" do 300 | assert [] = :get 301 | |> conn(graphql_url(@whitelist_named_query)) 302 | |> RequestCache.Support.Utils.ensure_default_opts(request: [whitelisted_query_names: ["SmallHello"]]) 303 | |> Router.call([]) 304 | |> get_resp_header(RequestCache.Plug.request_cache_header()) 305 | 306 | assert ["HIT"] = :get 307 | |> conn(graphql_url(@whitelist_named_query)) 308 | |> RequestCache.Support.Utils.ensure_default_opts(request: [whitelisted_query_names: ["SmallHello"]]) 309 | |> Router.call([]) 310 | |> get_resp_header(RequestCache.Plug.request_cache_header()) 311 | end 312 | 313 | defp graphql_url(query) do 314 | "/graphql?#{URI.encode_query(%{query: query})}" 315 | end 316 | end 317 | -------------------------------------------------------------------------------- /test/request_cache_plug_test.exs: -------------------------------------------------------------------------------- 1 | defmodule RequestCachePlugTest do 2 | use ExUnit.Case, async: true 3 | use Plug.Test 4 | 5 | import ExUnit.CaptureLog 6 | 7 | alias RequestCache.Support.EnsureCalledOnlyOnce 8 | 9 | defmodule Router do 10 | use Plug.Router 11 | 12 | plug RequestCache.Plug 13 | 14 | plug :match 15 | plug :dispatch 16 | 17 | match "/my_uncached_route" do 18 | EnsureCalledOnlyOnce.call(conn.private[:call_pid]) 19 | 20 | send_resp(conn, 200, Jason.encode!(%{test: Enum.random(1..100_000_000)})) 21 | end 22 | 23 | match "/my_route" do 24 | EnsureCalledOnlyOnce.call(conn.private[:call_pid]) 25 | 26 | conn 27 | |> RequestCache.store(:timer.seconds(20)) 28 | |> send_resp(200, Jason.encode!(%{test: Enum.random(1..100_000_000)})) 29 | end 30 | 31 | match "/my_route/:param" do 32 | conn 33 | |> RequestCache.store(:timer.seconds(20)) 34 | |> send_resp(200, Jason.encode!(%{test: Enum.random(1..100_000_000)})) 35 | end 36 | 37 | match "/error-route/:param" do 38 | conn 39 | |> RequestCache.store(:timer.seconds(20)) 40 | |> send_resp(404, Jason.encode!(%{code: :not_found, message: "NOT WORKING #{Enum.random(1..100_000_000)}"})) 41 | end 42 | end 43 | 44 | defmodule EnsureCalledOnlyOncePlug do 45 | @behaviour Plug 46 | 47 | @impl Plug 48 | def init(opts) do 49 | opts 50 | end 51 | 52 | @impl Plug 53 | def call(conn, _opts) do 54 | EnsureCalledOnlyOnce.call(conn.private[:call_pid]) 55 | 56 | conn 57 | end 58 | end 59 | 60 | defmodule RouterWithBreakingPlug do 61 | use Plug.Router 62 | 63 | plug RequestCache.Plug 64 | plug RequestCachePlugTest.EnsureCalledOnlyOncePlug 65 | 66 | plug :match 67 | plug :dispatch 68 | 69 | match "/my_route" do 70 | conn 71 | |> RequestCache.store(:timer.seconds(20)) 72 | |> send_resp(200, Jason.encode!(%{test: Enum.random(1..100_000_000)})) 73 | end 74 | end 75 | 76 | defmodule RouterWithBreakingPlugDefaultTTL do 77 | use Plug.Router 78 | 79 | plug RequestCache.Plug 80 | plug RequestCachePlugTest.EnsureCalledOnlyOncePlug 81 | 82 | plug :match 83 | plug :dispatch 84 | 85 | match "/my_route_default_ttl" do 86 | conn 87 | |> RequestCache.store() 88 | |> send_resp(200, Jason.encode!(%{test: Enum.random(1..100_000_000)})) 89 | end 90 | end 91 | 92 | defmodule RouterWithoutPlug do 93 | use Plug.Router 94 | 95 | plug :match 96 | plug :dispatch 97 | 98 | match "/my_route" do 99 | conn 100 | |> RequestCache.store(:timer.seconds(20)) 101 | |> send_resp(200, Jason.encode!(%{test: Enum.random(1..100_000_000)})) 102 | end 103 | end 104 | 105 | setup do 106 | {:ok, pid} = EnsureCalledOnlyOnce.start_link() 107 | 108 | %{caller_pid: pid} 109 | end 110 | 111 | @tag capture_log: true 112 | test "does not cache routes that don't ask for caching", %{caller_pid: pid} do 113 | assert %Plug.Conn{} = :get 114 | |> conn("/my_uncached_route") 115 | |> RequestCache.Support.Utils.ensure_default_opts() 116 | |> put_private(:call_pid, pid) 117 | |> Router.call([]) 118 | 119 | assert_raise Plug.Conn.WrapperError, fn -> 120 | conn = %Plug.Conn{} = :get 121 | |> conn("/my_uncached_route") 122 | |> RequestCache.Support.Utils.ensure_default_opts() 123 | |> put_private(:call_pid, pid) 124 | |> Router.call([]) 125 | 126 | assert [] === get_resp_header(conn, RequestCache.Plug.request_cache_header()) 127 | end 128 | end 129 | 130 | @tag capture_log: true 131 | test "stops any plug from running if cache is found", %{caller_pid: pid} do 132 | assert %Plug.Conn{} = :get 133 | |> conn("/my_route") 134 | |> RequestCache.Support.Utils.ensure_default_opts() 135 | |> put_private(:call_pid, pid) 136 | |> RouterWithBreakingPlug.call([]) 137 | 138 | assert %Plug.Conn{} = :get 139 | |> conn("/my_route") 140 | |> RequestCache.Support.Utils.ensure_default_opts() 141 | |> put_private(:call_pid, pid) 142 | |> RouterWithBreakingPlug.call([]) 143 | end 144 | 145 | @tag capture_log: true 146 | test "stops any plug from running if cache using default ttl is found", %{caller_pid: pid} do 147 | assert %Plug.Conn{} = :get 148 | |> conn("/my_route_default_ttl") 149 | |> RequestCache.Support.Utils.ensure_default_opts() 150 | |> put_private(:call_pid, pid) 151 | |> RouterWithBreakingPlugDefaultTTL.call([]) 152 | 153 | assert %Plug.Conn{} = :get 154 | |> conn("/my_route_default_ttl") 155 | |> RequestCache.Support.Utils.ensure_default_opts() 156 | |> put_private(:call_pid, pid) 157 | |> RouterWithBreakingPlugDefaultTTL.call([]) 158 | end 159 | 160 | test "throws an error if router doesn't have RequestCache.Plug", %{caller_pid: pid} do 161 | assert capture_log(fn -> 162 | :get 163 | |> conn("/my_route") 164 | |> put_private(:call_pid, pid) 165 | |> RouterWithoutPlug.call([]) 166 | end) =~ "RequestCache requested" 167 | end 168 | 169 | @tag capture_log: true 170 | test "includes proper headers with when served from the cache", %{ 171 | caller_pid: pid 172 | } do 173 | route = "/my_route/html" 174 | assert %Plug.Conn{resp_headers: uncached_headers} = :get 175 | |> conn(route) 176 | |> RequestCache.Support.Utils.ensure_default_opts() 177 | |> put_private(:call_pid, pid) 178 | |> Router.call([]) 179 | 180 | assert uncached_headers === [ 181 | {"cache-control", "max-age=0, private, must-revalidate"} 182 | ] 183 | 184 | assert %Plug.Conn{resp_headers: resp_headers} = :get 185 | |> conn(route) 186 | |> RequestCache.Support.Utils.ensure_default_opts() 187 | |> put_private(:call_pid, pid) 188 | |> Router.call([]) 189 | 190 | assert resp_headers === [ 191 | {"cache-control", "max-age=0, private, must-revalidate"}, 192 | {"rc-cache-status", "HIT"}, 193 | {"rc-cache-key", "/my_route/html:D41D8CD98F00B204E9800998ECF8427E"}, 194 | {"content-type", "application/json; charset=utf-8"} 195 | ] 196 | end 197 | 198 | @tag capture_log: true 199 | test "allows for for custom content-type header and returns it when served from the cache", %{ 200 | caller_pid: pid 201 | } do 202 | route = "/my_route/cache" 203 | assert %Plug.Conn{resp_headers: uncached_headers} = :get 204 | |> conn(route) 205 | |> RequestCache.Support.Utils.ensure_default_opts() 206 | |> put_private(:call_pid, pid) 207 | |> Router.call([]) 208 | 209 | assert uncached_headers === [ 210 | {"cache-control", "max-age=0, private, must-revalidate"} 211 | ] 212 | 213 | assert %Plug.Conn{resp_headers: resp_headers} = :get 214 | |> conn(route) 215 | |> RequestCache.Support.Utils.ensure_default_opts() 216 | |> put_private(:call_pid, pid) 217 | |> put_resp_content_type("text/html") 218 | |> Router.call([]) 219 | 220 | assert resp_headers === [ 221 | {"cache-control", "max-age=0, private, must-revalidate"}, 222 | {"content-type", "text/html; charset=utf-8"}, 223 | {"rc-cache-status", "HIT"}, 224 | {"rc-cache-key", "/my_route/cache:D41D8CD98F00B204E9800998ECF8427E"} 225 | ] 226 | end 227 | 228 | test "doesn't cache errors if error caching not enabled", %{caller_pid: pid} do 229 | route = "/error-route/no-error-cache-enabled" 230 | 231 | assert %Plug.Conn{resp_headers: uncached_headers} = :get 232 | |> conn(route) 233 | |> RequestCache.Support.Utils.ensure_default_opts(request: [cached_errors: []]) 234 | |> put_private(:call_pid, pid) 235 | |> Router.call([]) 236 | 237 | assert uncached_headers === [ 238 | {"cache-control", "max-age=0, private, must-revalidate"} 239 | ] 240 | 241 | assert %Plug.Conn{resp_headers: expected_uncached_headers} = :get 242 | |> conn(route) 243 | |> RequestCache.Support.Utils.ensure_default_opts() 244 | |> put_private(:call_pid, pid) 245 | |> Router.call([]) 246 | 247 | assert expected_uncached_headers === [ 248 | {"cache-control", "max-age=0, private, must-revalidate"} 249 | ] 250 | end 251 | 252 | test "caches errors if error codes supplied and is error", %{caller_pid: pid} do 253 | route = "/error-route/caching-errors-enabled" 254 | 255 | assert %Plug.Conn{resp_headers: uncached_headers} = :get 256 | |> conn(route) 257 | |> RequestCache.Support.Utils.ensure_default_opts(request: [cached_errors: [:not_found]]) 258 | |> put_private(:call_pid, pid) 259 | |> Router.call([]) 260 | 261 | assert uncached_headers === [ 262 | {"cache-control", "max-age=0, private, must-revalidate"} 263 | ] 264 | 265 | assert %Plug.Conn{resp_headers: expected_cached_headers} = :get 266 | |> conn(route) 267 | |> RequestCache.Support.Utils.ensure_default_opts(request: [cached_errors: [:not_found]]) 268 | |> put_private(:call_pid, pid) 269 | |> put_resp_content_type("text/html") 270 | |> Router.call([]) 271 | 272 | assert expected_cached_headers === [ 273 | {"cache-control", "max-age=0, private, must-revalidate"}, 274 | {"content-type", "text/html; charset=utf-8"}, 275 | {"rc-cache-status", "HIT"}, 276 | {"rc-cache-key", "/error-route/caching-errors-enabled:D41D8CD98F00B204E9800998ECF8427E"} 277 | ] 278 | end 279 | 280 | test "caches errors if error caching enabled", %{caller_pid: pid} do 281 | route = "/error-route/all-errors-enabled" 282 | 283 | assert %Plug.Conn{resp_headers: uncached_headers} = :get 284 | |> conn(route) 285 | |> RequestCache.Support.Utils.ensure_default_opts(request: [cached_errors: :all]) 286 | |> put_private(:call_pid, pid) 287 | |> Router.call([]) 288 | 289 | assert uncached_headers === [ 290 | {"cache-control", "max-age=0, private, must-revalidate"} 291 | ] 292 | 293 | assert %Plug.Conn{resp_headers: expected_cached_headers} = :get 294 | |> conn(route) 295 | |> RequestCache.Support.Utils.ensure_default_opts(request: [cached_errors: :all]) 296 | |> put_private(:call_pid, pid) 297 | |> put_resp_content_type("text/html") 298 | |> Router.call([]) 299 | 300 | assert expected_cached_headers === [ 301 | {"cache-control", "max-age=0, private, must-revalidate"}, 302 | {"content-type", "text/html; charset=utf-8"}, 303 | {"rc-cache-status", "HIT"}, 304 | {"rc-cache-key", "/error-route/all-errors-enabled:D41D8CD98F00B204E9800998ECF8427E"} 305 | ] 306 | end 307 | end 308 | -------------------------------------------------------------------------------- /test/support/ensure_only_called_once.ex: -------------------------------------------------------------------------------- 1 | defmodule RequestCache.Support.EnsureCalledOnlyOnce do 2 | use Agent 3 | 4 | def start_link do 5 | Agent.start_link(fn -> 6 | false 7 | end) 8 | end 9 | 10 | def call(pid) do 11 | if Agent.get(pid, &(&1)) do 12 | raise "Cannot be called more than once" 13 | else 14 | Agent.update(pid, fn _ -> true end) 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/support/utils.ex: -------------------------------------------------------------------------------- 1 | defmodule RequestCache.Support.Utils do 2 | @moduledoc false 3 | 4 | alias Plug.Conn 5 | 6 | def ensure_default_opts(conn, extra_opts \\ []) do 7 | Conn.put_private( 8 | conn, 9 | RequestCache.Config.conn_private_key(), 10 | Keyword.merge([request: []], extra_opts) 11 | ) 12 | end 13 | 14 | def graphql_conn, do: "GET" |> build_conn("/graphql") |> ensure_default_opts 15 | 16 | def rest_conn, do: "GET" |> build_conn("/entity") |> ensure_default_opts 17 | 18 | @spec build_conn(atom | binary, binary, binary | list | map | nil) :: Conn.t 19 | def build_conn(method, path, params_or_body \\ nil) do 20 | %Conn{} 21 | |> Plug.Adapters.Test.Conn.conn(method, path, params_or_body) 22 | |> Conn.put_private(:plug_skip_csrf_protection, true) 23 | |> Conn.put_private(:phoenix_recycled, true) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | RequestCache.ConCacheStore.start_link() 2 | ExUnit.start() 3 | --------------------------------------------------------------------------------