Skip to content

Commit 9585da4

Browse files
authored
Merge pull request #2204 from digitallyinduced/extract-controller-context
Extract controller context
2 parents 2ee2d24 + 348e24a commit 9585da4

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

45 files changed

+663
-240
lines changed

.ghci

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
:set -i.
22
:set -idev
3+
:set -iihp-context
4+
:set -iihp-pagehead
5+
:set -iihp-log
6+
:set -iihp-modal
37
:set -iihp
8+
:set -iihp/Test
49
:set -iihp-ide
510
:set -iihp-migrate
611
:set -iihp-ssc

NixSupport/overlay.nix

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ final: prev: {
1515
in {
1616
ihp = localPackage "ihp";
1717
ihp-with-docs = localPackageWithHaddock "ihp";
18+
ihp-context = localPackage "ihp-context";
19+
ihp-pagehead = localPackage "ihp-pagehead";
20+
ihp-log = localPackage "ihp-log";
21+
ihp-modal = localPackage "ihp-modal";
1822
ihp-ide = localPackage "ihp-ide";
1923
ihp-migrate = (localPackage "ihp-migrate").overrideAttrs (old: { mainProgram = "migrate"; });
2024
ihp-openai = localPackage "ihp-openai";

UPGRADE.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,19 @@ After updating your project, please consult the segments from your current relea
44

55
# UNRELEASED
66

7+
## Internal packages extracted from `ihp`
8+
9+
Several internal modules have been extracted into separate packages. This should not affect most applications as they are re-exported through the standard preludes. However, if you import these modules directly, you may need to add the corresponding package to your dependencies:
10+
11+
| Module | New Package |
12+
|--------|-------------|
13+
| `IHP.ControllerContext` | `ihp-context` |
14+
| `IHP.PageHead.*` | `ihp-pagehead` |
15+
| `IHP.Log.*` | `ihp-log` |
16+
| `IHP.Modal.*` | `ihp-modal` |
17+
18+
Most applications don't need to change anything as these are still re-exported from `ihp`.
19+
720
## `IHP.Welcome.Controller` moved to separate `ihp-welcome` package
821

922
The welcome page controller has been moved to its own package `ihp-welcome`. If your project uses `IHP.Welcome.Controller` and `WelcomeAction`, you need to add the package to `flake.nix`:
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
{-|
2+
Module: IHP.ControllerContext
3+
Description: Typed key-value context container with minimal dependencies
4+
Copyright: (c) digitally induced GmbH, 2020
5+
6+
This module provides a typed key-value container where the key is the type of the value.
7+
It only depends on base and typerep-map, making it suitable for packages that need
8+
context storage without pulling in the full IHP dependency tree.
9+
10+
The main IHP framework has heavy transitive dependencies (database, mail, logging, etc.)
11+
through FrameworkConfig. By extracting ControllerContext into this minimal package,
12+
other IHP packages like ihp-pagehead can have a much smaller dependency footprint.
13+
-}
14+
module IHP.ControllerContext
15+
( ControllerContext(..)
16+
, newControllerContext
17+
, freeze
18+
, unfreeze
19+
, putContext
20+
, fromContext
21+
, maybeFromContext
22+
, fromFrozenContext
23+
, maybeFromFrozenContext
24+
) where
25+
26+
import Prelude
27+
import Data.IORef
28+
import qualified Data.TMap as TypeMap
29+
import qualified Data.Typeable as Typeable
30+
import Data.Typeable (Typeable)
31+
32+
-- | A container storing useful data along the request lifecycle, such as the request, the current user, set current view layout, flash messages, ...
33+
--
34+
-- The controller context is usually accessed via the @?context@ variable. It's available inside the action and the view. Think of it as a key-value-map where the key is the type of the value.
35+
--
36+
-- You can store information inside the context using 'putContext':
37+
--
38+
-- >>> newtype CurrentLayout = CurrentLayout Html
39+
-- >>>
40+
-- >>> ?context <- newControllerContext
41+
-- >>> putContext (CurrentLayout layout)
42+
--
43+
-- Inside an action you can access the values using 'fromContext':
44+
--
45+
-- >>> (CurrentLayout layout) <- fromContext
46+
--
47+
-- You can freeze the context and then access values without being inside an IO context (like inside views which are pure):
48+
--
49+
-- Call 'freeze' inside an IO part:
50+
--
51+
-- >>> ?context <- freeze ?context
52+
--
53+
-- ('freeze' is automatically called by IHP before rendering a view, so usually you don't need to call it manually)
54+
--
55+
-- Then use the frozen context from your pure code like this:
56+
--
57+
-- >>> let (CurrentLayout layout) = fromFrozenContext in ...
58+
--
59+
-- The context is initially created before a action is going to be executed. Its life cycle looks like this:
60+
--
61+
-- - @newControllerContext@: The new controller context is created
62+
-- - The 'IHP.ControllerSupport.runActionWithNewContext' fills in a few default values: The current @?application@ and also the Flash Messages to be rendered in the to-be-generated response.
63+
-- - @initContext@: The initContext function of the @InitControllerContext WebApplication@ (inside your FrontController.hs) is called. There application-specific context can be provided. Usually this is the current user and the default layout.
64+
-- - @beforeAction@: Here the context could also be modified. E.g. the layout could be overriden here for the whole controller.
65+
-- - @action ..@: The action itself.
66+
-- - Freezing: Before rendering the response, the container is frozen. Frozen means that all previously mutable fields become immutable.
67+
-- - View Rendering: The frozen container is now used inside the view and layout to display information such as the current user or flash messages
68+
data ControllerContext
69+
= ControllerContext { customFieldsRef :: IORef TypeMap.TMap }
70+
| FrozenControllerContext { customFields :: TypeMap.TMap }
71+
72+
-- | Creates a new empty controller context
73+
newControllerContext :: IO ControllerContext
74+
newControllerContext = do
75+
customFieldsRef <- newIORef TypeMap.empty
76+
pure ControllerContext { customFieldsRef }
77+
{-# INLINABLE newControllerContext #-}
78+
79+
-- | After freezing a container you can access its values from pure non-IO code by using 'fromFrozenContext'
80+
--
81+
-- Calls to 'putContext' will throw an exception after it's frozen.
82+
freeze :: ControllerContext -> IO ControllerContext
83+
freeze ControllerContext { customFieldsRef } = FrozenControllerContext <$> readIORef customFieldsRef
84+
freeze frozen = pure frozen
85+
{-# INLINABLE freeze #-}
86+
87+
-- | Returns an unfrozen version of the controller context that can be modified again
88+
--
89+
-- This is used together with 'freeze' by e.g. AutoRefresh to make an immutable copy of the current controller context state
90+
unfreeze :: ControllerContext -> IO ControllerContext
91+
unfreeze FrozenControllerContext { customFields } = do
92+
customFieldsRef <- newIORef customFields
93+
pure ControllerContext { customFieldsRef }
94+
unfreeze ControllerContext {} = error "Cannot call unfreeze on a controller context that is not frozen"
95+
{-# INLINABLE unfreeze #-}
96+
97+
-- | Returns a value from the current controller context
98+
--
99+
-- Throws an exception if there is no value with the type inside the context
100+
--
101+
-- __Example:__ Read the current user from the context
102+
--
103+
-- >>> user <- fromContext @User
104+
fromContext :: forall value. (?context :: ControllerContext, Typeable value) => IO value
105+
fromContext = maybeFromContext @value >>= \case
106+
Just value -> pure value
107+
Nothing -> do
108+
let ControllerContext { customFieldsRef } = ?context
109+
customFields <- readIORef customFieldsRef
110+
let notFoundMessage = "Unable to find " <> show (Typeable.typeRep (Typeable.Proxy @value)) <> " in controller context: " <> show customFields
111+
error notFoundMessage
112+
{-# INLINABLE fromContext #-}
113+
114+
-- | Returns a value from the current controller context. Requires the context to be frozen.
115+
--
116+
-- __Example:__ Read the current user from the context
117+
--
118+
-- >>> let user = fromFrozenContext @User
119+
fromFrozenContext :: forall value. (?context :: ControllerContext, Typeable value) => value
120+
fromFrozenContext = case maybeFromFrozenContext @value of
121+
Just value -> value
122+
Nothing -> do
123+
let FrozenControllerContext { customFields } = ?context
124+
let notFoundMessage = "Unable to find " <> show (Typeable.typeRep (Typeable.Proxy @value)) <> " in controller context: " <> show customFields
125+
error notFoundMessage
126+
{-# INLINABLE fromFrozenContext #-}
127+
128+
-- | Returns a value from the current controller context, or Nothing if not found
129+
maybeFromContext :: forall value. (?context :: ControllerContext, Typeable value) => IO (Maybe value)
130+
maybeFromContext = do
131+
frozen <- freeze ?context
132+
let ?context = frozen
133+
pure (maybeFromFrozenContext @value)
134+
{-# INLINABLE maybeFromContext #-}
135+
136+
-- | Returns a value from a frozen controller context, or Nothing if not found
137+
maybeFromFrozenContext :: forall value. (?context :: ControllerContext, Typeable value) => Maybe value
138+
maybeFromFrozenContext = case ?context of
139+
FrozenControllerContext { customFields } -> TypeMap.lookup @value customFields
140+
ControllerContext {} -> error ("maybeFromFrozenContext called on a non frozen context while trying to access " <> show (Typeable.typeRep (Typeable.Proxy @value)))
141+
{-# INLINABLE maybeFromFrozenContext #-}
142+
143+
-- | Puts a value into the context
144+
--
145+
-- Throws an exception if the context is already frozen.
146+
putContext :: forall value. (?context :: ControllerContext, Typeable value) => value -> IO ()
147+
putContext value = do
148+
let ControllerContext { customFieldsRef } = ?context
149+
modifyIORef customFieldsRef (TypeMap.insert value)
150+
{-# INLINABLE putContext #-}

ihp-context/LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
The MIT License (MIT)
2+
3+
Copyright (c) 2020 digitally induced GmbH
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

ihp-context/ihp-context.cabal

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
cabal-version: 2.2
2+
name: ihp-context
3+
version: 1.4.0
4+
synopsis: Minimal typed context container for IHP
5+
description:
6+
This package provides ControllerContext, a typed key-value container with minimal dependencies.
7+
.
8+
The main IHP framework has heavy transitive dependencies (database, mail, logging, etc.)
9+
through FrameworkConfig. By extracting ControllerContext into this lightweight package,
10+
other IHP packages like ihp-pagehead can depend only on ihp-context instead of the
11+
full ihp package, significantly reducing their dependency footprint.
12+
license: MIT
13+
license-file: LICENSE
14+
author: digitally induced GmbH
15+
maintainer: hello@digitallyinduced.com
16+
homepage: https://ihp.digitallyinduced.com/
17+
bug-reports: https://github.com/digitallyinduced/ihp/issues
18+
copyright: (c) digitally induced GmbH
19+
category: Web, IHP
20+
stability: Stable
21+
tested-with: GHC == 9.8.4
22+
build-type: Simple
23+
24+
source-repository head
25+
type: git
26+
location: https://github.com/digitallyinduced/ihp
27+
28+
common shared-properties
29+
default-language: GHC2021
30+
default-extensions:
31+
NoImplicitPrelude
32+
, ImplicitParams
33+
, BlockArguments
34+
, LambdaCase
35+
36+
library
37+
import: shared-properties
38+
hs-source-dirs: .
39+
build-depends:
40+
base >= 4.17.0 && < 4.22
41+
, typerep-map
42+
exposed-modules:
43+
IHP.ControllerContext

ihp-datasync/ihp-datasync.cabal

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ common shared-properties
4747
, unliftio
4848
, async
4949
, ihp
50+
, ihp-log
5051
, ihp-hsx
5152
, ihp-postgresql-simple-extra
5253
, deepseq

ihp-hspec/IHP/Hspec.hs

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import IHP.Controller.Session (sessionVaultKey)
2828

2929
import qualified System.Process as Process
3030
import IHP.Test.Mocking
31-
import IHP.RequestVault (modelContextMiddleware)
31+
import IHP.RequestVault (modelContextMiddleware, frameworkConfigMiddleware)
3232

3333
withConnection databaseUrl = Exception.bracket (PG.connectPostgreSQL databaseUrl) PG.close
3434

@@ -46,18 +46,17 @@ withIHPApp application configBuilder hspecAction = do
4646

4747
let sessionVault = Vault.insert sessionVaultKey mempty Vault.empty
4848

49-
-- Apply modelContextMiddleware to populate the vault
49+
-- Apply middlewares to populate the vault with modelContext and frameworkConfig
5050
requestRef <- newIORef (error "Internal test error: Request should have been captured by middleware")
5151
let captureApp req _ = writeIORef requestRef req >> pure ResponseReceived
52-
let transformedApp = modelContextMiddleware modelContext captureApp
52+
let transformedApp = frameworkConfigMiddleware frameworkConfig (modelContextMiddleware modelContext captureApp)
5353
_responseReceived <- transformedApp (defaultRequest {vault = sessionVault}) (\_ -> pure ResponseReceived)
54-
requestWithModelContext <- readIORef requestRef
54+
requestWithContext <- readIORef requestRef
5555

5656
let requestContext = RequestContext
57-
{ request = requestWithModelContext
57+
{ request = requestWithContext
5858
, requestBody = FormBody [] []
59-
, respond = const (pure ResponseReceived)
60-
, frameworkConfig = frameworkConfig }
59+
, respond = const (pure ResponseReceived) }
6160

6261
(hspecAction MockContext { .. })
6362

ihp-hspec/ihp-hspec.cabal

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,6 @@ library
2727
OverloadedStrings
2828
ImplicitParams
2929
RecordWildCards
30-
build-depends: base >= 4.17.0 && < 4.22, ihp, wai, process, text, ihp-ide, vault, uuid, postgresql-simple
30+
build-depends: base >= 4.17.0 && < 4.22, ihp, ihp-log, wai, process, text, ihp-ide, vault, uuid, postgresql-simple
3131
hs-source-dirs: .
3232
exposed-modules: IHP.Hspec

ihp-ide/IHP/IDE/Prelude.hs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ module IHP.IDE.Prelude
1414
, module IHP.FlashMessages
1515
, module IHP.Modal.Types
1616
, module IHP.Modal.ControllerFunctions
17+
, setModal
1718
, module IHP.ValidationSupport
1819
) where
1920

@@ -25,5 +26,15 @@ import IHP.Controller.Redirect
2526
import IHP.Controller.Layout
2627
import IHP.FlashMessages
2728
import IHP.Modal.Types
28-
import IHP.Modal.ControllerFunctions
29+
import IHP.Modal.ControllerFunctions hiding (setModal)
30+
import qualified IHP.Modal.ControllerFunctions as Modal
31+
import IHP.ViewSupport (View)
32+
import qualified IHP.ViewSupport as ViewSupport
2933
import IHP.ValidationSupport
34+
35+
-- | Renders a view and stores it as modal HTML in the context for later rendering.
36+
--
37+
-- > setModal MyModalView { .. }
38+
--
39+
setModal :: (?context :: ControllerContext, View view) => view -> IO ()
40+
setModal view = Modal.setModal (let ?view = view in ViewSupport.html view)

0 commit comments

Comments
 (0)