Skip to content

Commit a9e5b1f

Browse files
alexbiehlclaude
andcommitted
Replace optics-based JSON traversal with lightweight custom helpers
Drop the aeson-optics and optics-core dependencies in favor of a small TestContainers.Docker.JSON module that provides lookupKey, asText, asBool, asInteger, eachMember, and eachValue for navigating aeson Value trees. Adds scientific and vector as direct dependencies instead. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d66246f commit a9e5b1f

4 files changed

Lines changed: 123 additions & 97 deletions

File tree

src/TestContainers/Docker.hs

Lines changed: 58 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -180,11 +180,11 @@ import Control.Monad.Trans.Resource
180180
runResourceT,
181181
)
182182
import Data.Aeson (decode')
183-
import qualified Data.Aeson.Optics as Optics
184183
import qualified Data.ByteString.Lazy.Char8 as LazyByteString
185184
import Data.Function ((&))
185+
import Data.Functor ((<&>))
186186
import Data.List (find, stripPrefix)
187-
import Data.Maybe (fromMaybe)
187+
import Data.Maybe (fromMaybe, listToMaybe)
188188
import Data.String (IsString (..))
189189
import Data.Text (Text, pack, splitOn, strip, unpack)
190190
import Data.Text.Encoding (encodeUtf8)
@@ -205,9 +205,6 @@ import Network.HTTP.Client
205205
)
206206
import Network.HTTP.Types (statusCode)
207207
import qualified Network.Socket as Socket
208-
import Optics.Fold (pre)
209-
import Optics.Operators ((^?))
210-
import Optics.Optic ((%), (<&>))
211208
import System.Directory (doesFileExist)
212209
import System.Environment (lookupEnv)
213210
import System.IO (Handle, hClose)
@@ -233,6 +230,7 @@ import TestContainers.Docker.Internal
233230
dockerWithStdin,
234231
prefixedLogConsumer,
235232
)
233+
import TestContainers.Docker.JSON (asText, eachMember, eachValue, lookupKey)
236234
import TestContainers.Docker.Network
237235
( Network,
238236
NetworkId,
@@ -923,7 +921,7 @@ waitForState isReady = WaitReady $ \Container {id, image} -> do
923921
internalInspect configTracer id
924922

925923
let state = containerState inspectOutput
926-
containerName = inspectOutput ^? Optics.key "Name" % Optics._String
924+
containerName = lookupKey "Name" inspectOutput >>= asText
927925
exception =
928926
InvalidStateException
929927
{ id = id,
@@ -1123,7 +1121,7 @@ waitUntilReady container@Container {id} input = do
11231121
case result of
11241122
Nothing -> do
11251123
let Container {image, inspectOutput} = container
1126-
containerName = inspectOutput ^? Optics.key "Name" % Optics._String
1124+
containerName = lookupKey "Name" inspectOutput >>= asText
11271125
throwM $
11281126
TimeoutException
11291127
{ id,
@@ -1200,12 +1198,9 @@ containerIp =
12001198
-- | Get the IP address of a running Docker container using @docker inspect@.
12011199
internalContainerIp :: Container -> Text
12021200
internalContainerIp Container {id, inspectOutput, image} =
1203-
case inspectOutput
1204-
^? Optics.key "NetworkSettings"
1205-
% Optics.key "IPAddress"
1206-
% Optics._String of
1201+
case lookupKey "NetworkSettings" inspectOutput >>= lookupKey "IPAddress" >>= asText of
12071202
Nothing -> do
1208-
let containerName = inspectOutput ^? Optics.key "Name" % Optics._String
1203+
let containerName = lookupKey "Name" inspectOutput >>= asText
12091204
throw $
12101205
InspectOutputUnexpected
12111206
{ id,
@@ -1221,50 +1216,42 @@ internalContainerIp Container {id, inspectOutput, image} =
12211216
-- @since 0.5.0.0
12221217
containerAlias :: Container -> Text
12231218
containerAlias Container {id, inspectOutput, image} =
1224-
case inspectOutput
1225-
^? pre
1226-
( Optics.key "NetworkSettings"
1227-
% Optics.key "Networks"
1228-
% Optics.members
1229-
% Optics.key "Aliases"
1230-
% Optics.values
1231-
% Optics._String
1232-
) of
1233-
Nothing -> do
1234-
let containerName = inspectOutput ^? Optics.key "Name" % Optics._String
1235-
throw $
1236-
InspectOutputMissingNetwork
1237-
{ id,
1238-
imageName = Just (imageTag image),
1239-
containerName = containerName
1240-
}
1241-
Just alias ->
1242-
alias
1219+
let aliases = do
1220+
network <- maybe [] eachMember (lookupKey "NetworkSettings" inspectOutput >>= lookupKey "Networks")
1221+
aliasValue <- maybe [] eachValue (lookupKey "Aliases" network)
1222+
maybe [] pure (asText aliasValue)
1223+
in case listToMaybe aliases of
1224+
Nothing -> do
1225+
let containerName = lookupKey "Name" inspectOutput >>= asText
1226+
throw $
1227+
InspectOutputMissingNetwork
1228+
{ id,
1229+
imageName = Just (imageTag image),
1230+
containerName = containerName
1231+
}
1232+
Just alias ->
1233+
alias
12431234

12441235
-- | Get the IP address for the container's gateway, i.e. the host.
12451236
-- Takes the first gateway address found.
12461237
--
12471238
-- @since 0.5.0.0
12481239
containerGateway :: Container -> Text
12491240
containerGateway Container {id, inspectOutput, image} =
1250-
case inspectOutput
1251-
^? pre
1252-
( Optics.key "NetworkSettings"
1253-
% Optics.key "Networks"
1254-
% Optics.members
1255-
% Optics.key "Gateway"
1256-
% Optics._String
1257-
) of
1258-
Nothing -> do
1259-
let containerName = inspectOutput ^? Optics.key "Name" % Optics._String
1260-
throw $
1261-
InspectOutputMissingNetwork
1262-
{ id,
1263-
imageName = Just (imageTag image),
1264-
containerName = containerName
1265-
}
1266-
Just gatewayIp ->
1267-
gatewayIp
1241+
let gateways = do
1242+
network <- maybe [] eachMember (lookupKey "NetworkSettings" inspectOutput >>= lookupKey "Networks")
1243+
maybe [] pure (lookupKey "Gateway" network >>= asText)
1244+
in case listToMaybe gateways of
1245+
Nothing -> do
1246+
let containerName = lookupKey "Name" inspectOutput >>= asText
1247+
throw $
1248+
InspectOutputMissingNetwork
1249+
{ id,
1250+
imageName = Just (imageTag image),
1251+
containerName = containerName
1252+
}
1253+
Just gatewayIp ->
1254+
gatewayIp
12681255

12691256
-- | Looks up an exposed port on the host.
12701257
--
@@ -1278,26 +1265,28 @@ containerPort Container {id, inspectOutput, image} Port {port, protocol} =
12781265
in -- TODO be more mindful, make sure to grab the
12791266
-- port from the right host address
12801267

1281-
case inspectOutput
1282-
^? pre
1283-
( Optics.key "NetworkSettings"
1284-
% Optics.key "Ports"
1285-
% Optics.key textPort
1286-
% Optics.values
1287-
% Optics.key "HostPort"
1288-
% Optics._String
1289-
) of
1290-
Nothing ->
1291-
let containerName = inspectOutput ^? Optics.key "Name" % Optics._String
1292-
in throw $
1293-
UnknownPortMapping
1294-
{ id,
1295-
port = textPort,
1296-
imageName = Just (imageTag image),
1297-
containerName = containerName
1298-
}
1299-
Just hostPort ->
1300-
read (unpack hostPort)
1268+
let hostPorts = do
1269+
entry <-
1270+
maybe
1271+
[]
1272+
eachValue
1273+
( lookupKey "NetworkSettings" inspectOutput
1274+
>>= lookupKey "Ports"
1275+
>>= lookupKey (show port <> "/" <> unpack protocol)
1276+
)
1277+
maybe [] pure (lookupKey "HostPort" entry >>= asText)
1278+
in case listToMaybe hostPorts of
1279+
Nothing ->
1280+
let containerName = lookupKey "Name" inspectOutput >>= asText
1281+
in throw $
1282+
UnknownPortMapping
1283+
{ id,
1284+
port = textPort,
1285+
imageName = Just (imageTag image),
1286+
containerName = containerName
1287+
}
1288+
Just hostPort ->
1289+
read (unpack hostPort)
13011290

13021291
-- | Returns the domain and port exposing the given container's port. Differs
13031292
-- from 'containerPort' in that 'containerAddress' will return the container's

src/TestContainers/Docker/JSON.hs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
{-# LANGUAGE OverloadedStrings #-}
2+
3+
-- | Helpers for navigating aeson 'Value' trees without
4+
-- an optics dependency.
5+
module TestContainers.Docker.JSON
6+
( lookupKey,
7+
asText,
8+
asBool,
9+
asInteger,
10+
eachMember,
11+
eachValue,
12+
)
13+
where
14+
15+
import Data.Aeson (Value (..), withObject)
16+
import Data.Aeson.Types (parseMaybe, (.:))
17+
import Data.Scientific (floatingOrInteger)
18+
import Data.String (IsString (..))
19+
import Data.Text (Text)
20+
import qualified Data.Vector as Vector
21+
22+
-- | Look up a key in a JSON object. Works across aeson 1.x and 2.x
23+
-- because '(.:)' accepts both 'Text' and 'Key' via 'IsString'.
24+
lookupKey :: String -> Value -> Maybe Value
25+
lookupKey k = parseMaybe (withObject "object" (\obj -> obj .: fromString k))
26+
27+
-- | Extract a 'Text' from a JSON 'String' value.
28+
asText :: Value -> Maybe Text
29+
asText (String t) = Just t
30+
asText _ = Nothing
31+
32+
-- | Extract a 'Bool' from a JSON boolean value.
33+
asBool :: Value -> Maybe Bool
34+
asBool (Bool b) = Just b
35+
asBool _ = Nothing
36+
37+
-- | Extract an 'Integer' from a JSON number value.
38+
asInteger :: Value -> Maybe Integer
39+
asInteger (Number n) =
40+
case floatingOrInteger n of
41+
Right i -> Just i
42+
Left _floatingPoint -> Nothing
43+
asInteger _ = Nothing
44+
45+
-- | Collect all member values from a JSON object.
46+
eachMember :: Value -> [Value]
47+
eachMember (Object obj) = foldMap (: []) obj
48+
eachMember _ = []
49+
50+
-- | Collect all elements from a JSON array.
51+
eachValue :: Value -> [Value]
52+
eachValue (Array arr) = Vector.toList arr
53+
eachValue _ = []

src/TestContainers/Docker/State.hs

Lines changed: 9 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,9 @@ where
1919

2020
import Control.Exception (Exception, throw)
2121
import Data.Aeson (Value)
22-
import qualified Data.Aeson.Optics as Optics
2322
import Data.Text (Text)
24-
import Optics.Operators ((^?))
25-
import Optics.Optic ((%))
2623
import TestContainers.Docker.Internal (InspectOutput)
24+
import TestContainers.Docker.JSON (asBool, asInteger, asText, lookupKey)
2725

2826
-- | An exception thrown in case the State object is invalid and couldn't be parsed.
2927
--
@@ -57,7 +55,7 @@ newtype State = State Value
5755
-- @since 0.5.0.0
5856
containerState :: InspectOutput -> State
5957
containerState inspectOutput =
60-
case inspectOutput ^? Optics.key "State" of
58+
case lookupKey "State" inspectOutput of
6159
Just state -> State state
6260
Nothing -> State "dummy"
6361

@@ -66,9 +64,7 @@ containerState inspectOutput =
6664
-- @since 0.5.0.0
6765
stateStatus :: State -> Status
6866
stateStatus (State value) =
69-
case value
70-
^? Optics.key "Status"
71-
% Optics._String of
67+
case lookupKey "Status" value >>= asText of
7268
Just "created" -> Created
7369
Just "running" -> Running
7470
Just "paused" -> Paused
@@ -84,9 +80,7 @@ stateStatus (State value) =
8480
-- @since 0.5.0.0
8581
stateOOMKilled :: State -> Bool
8682
stateOOMKilled (State value) =
87-
case value
88-
^? Optics.key "OOMKilled"
89-
% Optics._Bool of
83+
case lookupKey "OOMKilled" value >>= asBool of
9084
Just True -> True
9185
_ -> False
9286

@@ -95,9 +89,7 @@ stateOOMKilled (State value) =
9589
-- @since 0.5.0.0
9690
statePid :: State -> Maybe Int
9791
statePid (State value) =
98-
case value
99-
^? Optics.key "Pid"
100-
% Optics._Integer of
92+
case lookupKey "Pid" value >>= asInteger of
10193
Just pid -> Just (fromIntegral pid)
10294
_ -> Nothing
10395

@@ -106,9 +98,7 @@ statePid (State value) =
10698
-- @since 0.5.0.0
10799
stateExitCode :: State -> Maybe Int
108100
stateExitCode (State value) =
109-
case value
110-
^? Optics.key "ExitCode"
111-
% Optics._Integer of
101+
case lookupKey "ExitCode" value >>= asInteger of
112102
Just exitCode -> Just (fromIntegral exitCode)
113103
_ -> Nothing
114104

@@ -117,9 +107,7 @@ stateExitCode (State value) =
117107
-- @since 0.5.0.0
118108
stateError :: State -> Maybe Text
119109
stateError (State value) =
120-
case value
121-
^? Optics.key "Error"
122-
% Optics._String of
110+
case lookupKey "Error" value >>= asText of
123111
Just err -> Just err
124112
_ -> Nothing
125113

@@ -128,9 +116,7 @@ stateError (State value) =
128116
-- @since 0.5.0.0
129117
stateStartedAt :: State -> Maybe Text
130118
stateStartedAt (State value) =
131-
case value
132-
^? Optics.key "StartedAt"
133-
% Optics._String of
119+
case lookupKey "StartedAt" value >>= asText of
134120
Just err -> Just err
135121
_ -> Nothing
136122

@@ -139,8 +125,6 @@ stateStartedAt (State value) =
139125
-- @since 0.5.0.0
140126
stateFinishedAt :: State -> Maybe Text
141127
stateFinishedAt (State value) =
142-
case value
143-
^? Optics.key "FinishedAt"
144-
% Optics._String of
128+
case lookupKey "FinishedAt" value >>= asText of
145129
Just err -> Just err
146130
_ -> Nothing

testcontainers.cabal

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,10 @@ library
4242
TestContainers.Tasty
4343
TestContainers.Trace
4444

45-
-- other-modules:
45+
other-modules: TestContainers.Docker.JSON
4646
-- other-extensions:
4747
build-depends:
4848
aeson >=1.4.6 && <3
49-
, aeson-optics >=1.1 && <2
5049
, async
5150
, base >=4.12 && <5
5251
, bytestring >=0.10.8 && <0.13
@@ -56,13 +55,14 @@ library
5655
, http-types >=0.12.3 && <1
5756
, mtl >=2.2.2 && <3
5857
, network >=2.8.0 && <3.3
59-
, optics-core >=0.1 && <0.5
6058
, process >=1.6.5 && <1.7
6159
, random >=1.2 && <2
6260
, resourcet >=1.2.4 && <1.4
61+
, scientific >=0.3 && <0.4
6362
, tasty >=1.0 && <1.6
6463
, text >=1.2.3 && <3
6564
, unliftio-core >=0.1.0 && <0.3
65+
, vector >=0.12 && <0.14
6666

6767
hs-source-dirs: src
6868
default-language: Haskell2010

0 commit comments

Comments
 (0)