feat: SocketAddress, serve entrypoint

This commit is contained in:
Orion Kindel 2025-03-01 15:50:41 -06:00
parent cc0e3e8d02
commit 4ff431acbd
Signed by untrusted user who does not match committer: orion
GPG Key ID: 6D4165AE4C928719
12 changed files with 293 additions and 67 deletions

View File

@ -86,6 +86,7 @@
{
"typelevel-prelude": ">=7.0.0 <8.0.0"
},
"unlift",
{
"url-immutable": ">=1.0.0 <2.0.0"
},
@ -102,6 +103,7 @@
"arrays",
"b64",
"bifunctors",
"catenable-lists",
"console",
"const",
"contravariant",
@ -118,6 +120,8 @@
"foldable-traversable",
"foreign",
"foreign-object",
"free",
"freet",
"functions",
"functors",
"gen",
@ -129,6 +133,7 @@
"lazy",
"lists",
"maybe",
"monad-control",
"newtype",
"node-buffer",
"node-event-emitter",
@ -162,6 +167,7 @@
"typelevel-prelude",
"unfoldable",
"unicode",
"unlift",
"unsafe-coerce",
"url-immutable",
"variant",
@ -1177,6 +1183,23 @@
"unsafe-coerce"
]
},
"freet": {
"type": "registry",
"version": "7.0.0",
"integrity": "sha256-zkL6wU4ZPq8xz1kGFxoliWqyhBksepMJTyA68VEBaJo=",
"dependencies": [
"aff",
"bifunctors",
"effect",
"either",
"exists",
"free",
"prelude",
"tailrec",
"transformers",
"tuples"
]
},
"functions": {
"type": "registry",
"version": "6.0.0",
@ -1329,6 +1352,17 @@
"transformers"
]
},
"monad-control": {
"type": "registry",
"version": "5.0.0",
"integrity": "sha256-bgfDW30wbIm70NR1Tvvh9P+VFQMDh1wK2sSJXCj/dZc=",
"dependencies": [
"aff",
"freet",
"identity",
"lists"
]
},
"newtype": {
"type": "registry",
"version": "5.0.0",
@ -1920,6 +1954,25 @@
"strings"
]
},
"unlift": {
"type": "registry",
"version": "1.0.1",
"integrity": "sha256-nbBCVV0fZz/3UHKoW11dcTwBYmQOIgK31ht2BN47RPw=",
"dependencies": [
"aff",
"effect",
"either",
"freet",
"identity",
"lists",
"maybe",
"monad-control",
"prelude",
"st",
"transformers",
"tuples"
]
},
"unsafe-coerce": {
"type": "registry",
"version": "6.0.0",

View File

@ -4,6 +4,7 @@ package:
- aff-promise
- b64
- parsing
- unlift
- aff: '>=8.0.0 <9.0.0'
- argonaut-codecs: '>=9.1.0 <10.0.0'
- argonaut-core: '>=7.0.0 <8.0.0'

View File

@ -39,6 +39,7 @@ import Data.MIME as MIME
import Data.Map (Map)
import Data.Map as Map
import Data.Maybe (Maybe)
import Data.Net.SocketAddress (SocketAddress)
import Data.Show.Generic (genericShow)
import Data.String.Lower (StringLower)
import Data.String.Lower as String.Lower
@ -54,7 +55,6 @@ import Effect.Ref as Ref
import Node.Buffer (Buffer)
import Node.Buffer as Buffer
import Node.Encoding (Encoding(..))
import Node.Net.Types (IPv4, IPv6, SocketAddress)
import Node.Stream as Stream
import Node.Stream.Aff as Stream.Aff
@ -110,7 +110,7 @@ data Body
data Request =
Request
{ headers :: Map StringLower String
, address :: Either (SocketAddress IPv4) (SocketAddress IPv6)
, address :: SocketAddress
, url :: URL
, method :: Method
, bodyRef :: Effect.Ref Body
@ -118,7 +118,7 @@ data Request =
make ::
{ headers :: Map String String
, address :: Either (SocketAddress IPv4) (SocketAddress IPv6)
, address :: SocketAddress
, url :: URL
, method :: Method
, body :: Body
@ -155,7 +155,7 @@ contentLength = lookupHeader "content-length" >=> Int.fromString
method :: Request -> Method
method (Request a) = a.method
address :: Request -> Either (SocketAddress IPv4) (SocketAddress IPv6)
address :: Request -> SocketAddress
address (Request a) = a.address
url :: Request -> URL

View File

@ -23,9 +23,7 @@ foreign import requestAddr ::
/** @typedef {{port: number | null, hostname: string | null, idleTimeout: number | null, fetch: (req: Request) => (bun: Bun.Server) => () => Promise<Response>}} ServeOptions */
/**
* @template A
* @template B
* @typedef {unknown} Either
* @typedef {(addr: string) => (port: number) => unknown} FFISocketAddress
*/
/** @type {(s: ServeOptions) => () => Bun.Server} */
@ -47,13 +45,13 @@ export const unref = s => () => s.unref()
/** @type {(s: Bun.Server) => () => Promise<void>} */
export const stop = s => () => s.stop()
/** @type {(_: {left: <A, B>(a: A) => Either<A, B>, right: <A, B>(b: B) => Either<A, B>}) => (req: Request) => (s: Bun.Server) => () => Either<Net.SocketAddress, Net.SocketAddress>} */
/** @type {(_: {ipv4: FFISocketAddress, ipv6: FFISocketAddress}) => (req: Request) => (s: Bun.Server) => () => unknown} */
export const requestAddr =
({ left, right }) =>
({ ipv4, ipv6 }) =>
req =>
s =>
() => {
const ip = s.requestIP(req)
if (!ip) throw new Error('Request closed')
return ip.family === 'IPv4' ? left(ip) : right(ip)
return (ip.family === 'IPv4' ? ipv4 : ipv6)(ip.address)(ip.port)
}

View File

@ -1,6 +1,6 @@
module Axon.Runtime.Bun where
import Prelude
import Prelude hiding (join)
import Axon.Request (Request)
import Axon.Response (Response)
@ -10,18 +10,22 @@ import Axon.Web.Request as WebRequest
import Axon.Web.Response (WebResponse)
import Axon.Web.Response as WebResponse
import Control.Monad.Error.Class (try)
import Control.Monad.Fork.Class (fork, join, never)
import Control.Promise (Promise)
import Control.Promise as Promise
import Data.Either (Either(..))
import Data.Net.SocketAddress (SocketAddress)
import Data.Net.SocketAddress as SocketAddress
import Data.Newtype (unwrap)
import Data.Nullable (Nullable)
import Data.Nullable as Null
import Effect (Effect)
import Effect.Aff (Aff)
import Effect.Aff as Aff
import Effect.Aff.Class (liftAff)
import Effect.Aff.Unlift (class MonadUnliftAff, UnliftAff(..), askUnliftAff)
import Effect.Class (liftEffect)
import Effect.Exception (error)
import Node.Net.Types (IPv4, IPv6, SocketAddress)
foreign import data Bun :: Type
@ -37,31 +41,49 @@ foreign import stop :: Bun -> Promise Unit
foreign import ref :: Bun -> Effect Unit
foreign import unref :: Bun -> Effect Unit
foreign import requestAddr ::
{ left :: forall a b. a -> Either a b, right :: forall a b. b -> Either a b } ->
{ ipv4 :: String -> Int -> SocketAddress
, ipv6 :: String -> Int -> SocketAddress
} ->
WebRequest ->
Bun ->
Effect (Either (SocketAddress IPv4) (SocketAddress IPv6))
Effect SocketAddress
fetchImpl ::
(Request -> Aff Response) -> WebRequest -> Bun -> Effect (Promise WebResponse)
fetchImpl f req bun =
Promise.fromAff do
addr <- liftEffect $ requestAddr { left: Left, right: Right } req bun
req' <- liftEffect $ WebRequest.toRequest addr req
f req' >>= (liftEffect <<< WebResponse.fromResponse)
forall m.
MonadUnliftAff m =>
(Request -> m Response) ->
m
( WebRequest ->
Bun ->
Effect (Promise WebResponse)
)
fetchImpl f = do
UnliftAff toAff <- askUnliftAff
pure \req bun ->
Promise.fromAff do
addr <- liftEffect $ requestAddr
{ ipv4: SocketAddress.IPv4, ipv6: SocketAddress.IPv6 }
req
bun
req' <- liftEffect $ WebRequest.toRequest addr req
toAff $ f req' >>= (liftEffect <<< WebResponse.fromResponse)
instance Runtime Bun where
serve o = do
-- Killing `stopSignal` causes `stopFiber` to complete
stopSignal <- Aff.forkAff Aff.never
stopFiber <- Aff.forkAff $ void $ try $ Aff.joinFiber stopSignal
-- `stopSignal` will never resolve, but it can be killed.
stopSignal <- liftAff $ Aff.forkAff Aff.never
-- blocks on `stopSignal`, resolving when it's killed.
stopFiber <- fork $ liftAff $ void $ try $ Aff.joinFiber stopSignal
fetch <- fetchImpl o.fetch
let
o' =
{ port: Null.toNullable o.port
, hostname: Null.toNullable o.hostname
, idleTimeout: Null.toNullable $ unwrap <$> o.idleTimeout
, fetch: fetchImpl o.fetch
, fetch
}
bun <- liftEffect $ serve o'
@ -70,7 +92,7 @@ instance Runtime Bun where
pure
{ server: bun
, join: stopFiber
, stop: do
, stop: liftAff do
Promise.toAff $ stop bun
Aff.killFiber (error "") stopSignal
}

View File

@ -4,24 +4,27 @@ import Prelude
import Axon.Request (Request)
import Axon.Response (Response)
import Control.Monad.Fork.Class (class MonadFork)
import Data.Maybe (Maybe)
import Data.Time.Duration (Seconds)
import Effect (Effect)
import Effect.Aff (Aff, Fiber)
import Effect.Aff.Class (class MonadAff)
import Effect.Aff.Unlift (class MonadUnliftAff)
type Init =
{ fetch :: Request -> Aff Response
type Init m =
{ fetch :: Request -> m Response
, port :: Maybe Int
, hostname :: Maybe String
, idleTimeout :: Maybe Seconds
}
type Handle a =
type Handle m f a =
{ server :: a
, join :: Fiber Unit
, stop :: Aff Unit
, join :: f Unit
, stop :: m Unit
}
class Runtime :: Type -> Constraint
class Runtime a where
serve :: Init -> Aff (Handle a)
serve ::
forall m f. MonadFork f m => MonadUnliftAff m => Init m -> m (Handle m f a)

View File

@ -13,12 +13,12 @@ import Control.Monad.Trans.Class (lift)
import Data.ArrayBuffer.Types (Uint8Array)
import Data.Either (Either)
import Data.Maybe (fromMaybe)
import Data.Net.SocketAddress (SocketAddress)
import Data.Nullable (Nullable)
import Data.Nullable as Null
import Data.URL as URL
import Effect (Effect)
import Effect.Exception (error)
import Node.Net.Types (IPv4, IPv6, IpFamily(..), SocketAddress)
import Node.Stream as Stream
import Web.Streams.ReadableStream (ReadableStream)
@ -37,7 +37,7 @@ foreign import readableFromWeb ::
ReadableStream Uint8Array -> Effect (Stream.Readable ())
toRequest ::
Either (SocketAddress IPv4) (SocketAddress IPv6) ->
SocketAddress ->
WebRequest ->
Effect Request
toRequest address req =

View File

@ -1,2 +1,142 @@
module Axon where
import Prelude
import Axon.Request (Request)
import Axon.Request.Handler (Handler(..))
import Axon.Request.Handler.Default as Handler.Default
import Axon.Request.Parts.Class (ExtractError(..))
import Axon.Response (Response)
import Axon.Response as Rep
import Axon.Response.Status as Status
import Axon.Runtime (class Runtime)
import Axon.Runtime as Runtime
import Axon.Runtime.Bun as Runtime.Bun
import Control.Monad.Error.Class (throwError)
import Control.Monad.Fork.Class (class MonadFork)
import Data.Either (Either(..))
import Data.Map as Map
import Data.Maybe (Maybe(..))
import Data.Time.Duration (Milliseconds(..), convertDuration)
import Effect.Aff (Aff, Fiber)
import Effect.Aff.Class (class MonadAff, liftAff)
import Effect.Aff.Unlift (class MonadUnliftAff)
import Effect.Class (liftEffect)
import Effect.Console as Console
import Effect.Exception (Error)
import Effect.Exception as Error
import Prim.Row (class Nub, class Union)
import Record as Record
type Serve m =
( port :: Int
, hostname :: String
, idleTimeout :: Milliseconds
, handleError :: Request -> Error -> m Response
, handleBadRequest :: Request -> String -> m Response
, handleUnmatched :: Handler m Response
)
serveDefaults :: forall m. MonadAff m => Record (Serve m)
serveDefaults =
{ port: 8000
, hostname: "127.0.0.1"
, idleTimeout: Milliseconds 30000.0
, handleError: \_ e ->
let
rep = Rep.response Status.internalServerError (Rep.BodyString $ show e)
Map.empty
in
liftEffect $ Console.error (show e) $> rep
, handleBadRequest: \_ e ->
let
rep = Rep.response Status.badRequest (Rep.BodyString $ show e) Map.empty
in
pure rep
, handleUnmatched: Handler.Default.notFound
}
serveToRuntime ::
forall m.
MonadUnliftAff m =>
Handler m Response ->
Record (Serve m) ->
Runtime.Init m
serveToRuntime h o =
let
fetch :: Boolean -> Handler m Response -> Request -> m Response
fetch recursed (Handler f) req =
f req
>>= case _ of
Left (ExtractError m) -> o.handleError req (Error.error m)
Left (ExtractBadRequest m) -> o.handleBadRequest req m
Left ExtractNext
| recursed -> liftAff $ throwError $ Error.error
"Fatal: `serve.handleUnmatched` didn't match request."
| otherwise -> fetch true o.handleUnmatched req
Right rep -> pure rep
in
{ fetch: fetch false h
, port: Just o.port
, hostname: Just o.hostname
, idleTimeout: Just $ convertDuration o.idleTimeout
}
-- | `serve` using `Bun` in `Aff`
serveBun ::
forall opts optsMissing optsMerged.
Union opts optsMissing (Serve Aff) =>
Union opts (Serve Aff) optsMerged =>
Nub optsMerged (Serve Aff) =>
Record opts ->
Handler Aff Response ->
Aff (Runtime.Handle Aff Fiber Runtime.Bun.Bun)
serveBun = serve
-- | `serve` in `Aff`
serveAff ::
forall @rt opts optsMissing optsMerged.
Runtime rt =>
Union opts optsMissing (Serve Aff) =>
Union opts (Serve Aff) optsMerged =>
Nub optsMerged (Serve Aff) =>
Record opts ->
Handler Aff Response ->
Aff (Runtime.Handle Aff Fiber rt)
serveAff = serve
-- | Runs the server using the given `runtime`.
-- |
-- | First argument (`Record opts`) must be a partial record of `Serve m`.
-- | Omitted fields are set using `serveDefaults`.
-- |
-- | Second argument is your application's `Handler` entrypoint.
serve ::
forall @runtime f m opts optsMissing optsMerged.
MonadUnliftAff m =>
MonadFork f m =>
Runtime runtime =>
Union opts optsMissing (Serve m) =>
Union opts (Serve m) optsMerged =>
Nub optsMerged (Serve m) =>
Record opts ->
Handler m Response ->
m (Runtime.Handle m f runtime)
serve opts' handle =
let
-- Add visible type application to Record.merge
-- so we can explicitly set intermediate row type
merge' ::
forall @r1 @r2 @r3 @r4.
Union r1 r2 r3 =>
Nub r3 r4 =>
Record r1 ->
Record r2 ->
Record r4
merge' = Record.merge
-- Merge input options with defaults
opts :: Record (Serve m)
opts = merge' @_ @_ @optsMerged @_ opts' serveDefaults
in
Runtime.serve $ serveToRuntime handle opts

View File

@ -0,0 +1,5 @@
/** @type {(s: import('node:net').SocketAddress) => string} */
export const nodeAddr = s => s.address
/** @type {(s: import('node:net').SocketAddress) => number} */
export const nodePort = s => s.port

View File

@ -0,0 +1,19 @@
module Data.Net.SocketAddress where
import Data.Either (Either(..))
import Node.Net.Types as Node.Net
data SocketAddress = IPv4 String Int | IPv6 String Int
foreign import nodeAddr ::
forall (f :: Node.Net.IpFamily). Node.Net.SocketAddress f -> String
foreign import nodePort ::
forall (f :: Node.Net.IpFamily). Node.Net.SocketAddress f -> Int
fromNode ::
Either (Node.Net.SocketAddress Node.Net.IPv4)
(Node.Net.SocketAddress Node.Net.IPv6) ->
SocketAddress
fromNode (Left a) = IPv4 (nodeAddr a) (nodePort a)
fromNode (Right a) = IPv6 (nodeAddr a) (nodePort a)

View File

@ -1,6 +1,6 @@
module Test.Axon.Request.Handler where
import Axon.Request.Parts.Class
import Axon.Request.Parts.Class (Get, Path(..))
import Prelude
import Axon.Request (Body)
@ -11,30 +11,27 @@ import Axon.Request.Method (Method(..))
import Axon.Request.Parts.Path (type (/))
import Axon.Response (Response)
import Axon.Response as Response
import Axon.Response.Body as Response.Body
import Axon.Response.Construct (toResponse)
import Axon.Response.Construct as Response.Construct
import Axon.Response.Status as Status
import Control.Monad.Error.Class (liftEither)
import Data.Bifunctor (lmap)
import Data.Either (Either(..))
import Data.Map (Map)
import Data.Map as Map
import Data.Maybe (fromJust)
import Data.Net.SocketAddress (SocketAddress)
import Data.Net.SocketAddress (SocketAddress(..)) as SocketAddress
import Data.URL (URL)
import Data.URL as URL
import Effect.Aff (Aff, error)
import Effect.Class (liftEffect)
import Effect.Unsafe (unsafePerformEffect)
import Node.Net.SocketAddress as SocketAddress
import Node.Net.Types (IPv4, IPv6, SocketAddress)
import Partial.Unsafe (unsafePartial)
import Test.Spec (Spec, describe, it)
import Test.Spec.Assertions (shouldEqual)
defaultRequest ::
{ headers :: Map String String
, address :: Either (SocketAddress IPv4) (SocketAddress IPv6)
, address :: SocketAddress
, url :: URL
, method :: Method
, body :: Body
@ -43,8 +40,7 @@ defaultRequest =
{ body: Request.BodyEmpty
, url: URL.fromString "http://localhost:80/" # unsafePartial fromJust
, headers: Map.singleton "content-type" "application/json"
, address: Left $ unsafePerformEffect $ SocketAddress.newIpv4
{ address: "127.0.0.1", port: 81 }
, address: SocketAddress.IPv4 "127.0.0.1" 81
, method: GET
}

View File

@ -23,6 +23,7 @@ import Data.Bifunctor (lmap)
import Data.Either (Either(..))
import Data.Map as Map
import Data.Maybe (fromJust)
import Data.Net.SocketAddress as SocketAddress
import Data.Tuple.Nested (type (/\), (/\))
import Data.URL as URL
import Effect.Aff (Aff)
@ -45,8 +46,7 @@ spec = describe "Parts" do
{ body: Request.BodyEmpty
, url: URL.fromString "http://localhost:80/foo" # unsafePartial fromJust
, headers: Map.singleton "content-type" "application/json"
, address: Left $ unsafePerformEffect $ SocketAddress.newIpv4
{ address: "127.0.0.1", port: 81 }
, address: SocketAddress.IPv4 "127.0.0.1" 81
, method: GET
}
_ :: Request <- invokeHandler (pure @Aff) req <#> lmap (error <<< show) >>=
@ -62,8 +62,7 @@ spec = describe "Parts" do
, url: URL.fromString "http://localhost:80/users/12" # unsafePartial
fromJust
, headers: Map.singleton "content-type" "application/json"
, address: Left $ unsafePerformEffect $ SocketAddress.newIpv4
{ address: "127.0.0.1", port: 81 }
, address: SocketAddress.IPv4 "127.0.0.1" 81
, method: PATCH
}
@ -91,8 +90,7 @@ spec = describe "Parts" do
{ body: Request.BodyCachedString "foo"
, url: URL.fromString "http://localhost:80/foo" # unsafePartial fromJust
, headers: Map.empty
, address: Left $ unsafePerformEffect $ SocketAddress.newIpv4
{ address: "127.0.0.1", port: 81 }
, address: SocketAddress.IPv4 "127.0.0.1" 81
, method: GET
}
a <- extractRequestParts @(Path "foo" _) req <#> lmap (error <<< show)
@ -105,8 +103,7 @@ spec = describe "Parts" do
, url: URL.fromString "http://localhost:80/foo/bar/baz" # unsafePartial
fromJust
, headers: Map.empty
, address: Left $ unsafePerformEffect $ SocketAddress.newIpv4
{ address: "127.0.0.1", port: 81 }
, address: SocketAddress.IPv4 "127.0.0.1" 81
, method: GET
}
a <- extractRequestParts @(Path ("foo" / "bar" / "baz") _) req
@ -120,8 +117,7 @@ spec = describe "Parts" do
, url: URL.fromString "http://localhost:80/foo/bar/baz" # unsafePartial
fromJust
, headers: Map.empty
, address: Left $ unsafePerformEffect $ SocketAddress.newIpv4
{ address: "127.0.0.1", port: 81 }
, address: SocketAddress.IPv4 "127.0.0.1" 81
, method: GET
}
a <- extractRequestParts @(Path ("foo" / "bar") _) req
@ -133,8 +129,7 @@ spec = describe "Parts" do
, url: URL.fromString "http://localhost:80/foo/bar/baz" # unsafePartial
fromJust
, headers: Map.empty
, address: Left $ unsafePerformEffect $ SocketAddress.newIpv4
{ address: "127.0.0.1", port: 81 }
, address: SocketAddress.IPv4 "127.0.0.1" 81
, method: GET
}
a <- extractRequestParts @(Path ("foo" / "bar" / IgnoreRest) _) req
@ -148,8 +143,7 @@ spec = describe "Parts" do
, url: URL.fromString "http://localhost:80/foo/123/bar" # unsafePartial
fromJust
, headers: Map.empty
, address: Left $ unsafePerformEffect $ SocketAddress.newIpv4
{ address: "127.0.0.1", port: 81 }
, address: SocketAddress.IPv4 "127.0.0.1" 81
, method: GET
}
a <- extractRequestParts @(Path ("foo" / Int / "bar") _) req
@ -163,8 +157,7 @@ spec = describe "Parts" do
, url: URL.fromString "http://localhost:80/foo/123/bar/baz" #
unsafePartial fromJust
, headers: Map.empty
, address: Left $ unsafePerformEffect $ SocketAddress.newIpv4
{ address: "127.0.0.1", port: 81 }
, address: SocketAddress.IPv4 "127.0.0.1" 81
, method: GET
}
a <- extractRequestParts @(Path ("foo" / Int / "bar" / String) _) req
@ -178,8 +171,7 @@ spec = describe "Parts" do
{ body: Request.BodyCachedString "foo"
, url: URL.fromString "http://localhost:80/foo" # unsafePartial fromJust
, headers: Map.empty
, address: Left $ unsafePerformEffect $ SocketAddress.newIpv4
{ address: "127.0.0.1", port: 81 }
, address: SocketAddress.IPv4 "127.0.0.1" 81
, method: GET
}
a <- extractRequestParts @(Try Request.BodyStringError String) req
@ -194,8 +186,7 @@ spec = describe "Parts" do
{ body: Request.BodyReadable stream
, url: URL.fromString "http://localhost:80/foo" # unsafePartial fromJust
, headers: Map.empty
, address: Left $ unsafePerformEffect $ SocketAddress.newIpv4
{ address: "127.0.0.1", port: 81 }
, address: SocketAddress.IPv4 "127.0.0.1" 81
, method: GET
}
a <- extractRequestParts @(Try Request.BodyStringError String) req
@ -213,8 +204,7 @@ spec = describe "Parts" do
{ body: Request.BodyCached buf
, url: URL.fromString "http://localhost:80/foo" # unsafePartial fromJust
, headers: Map.empty
, address: Left $ unsafePerformEffect $ SocketAddress.newIpv4
{ address: "127.0.0.1", port: 81 }
, address: SocketAddress.IPv4 "127.0.0.1" 81
, method: GET
}
a <- extractRequestParts @(Try Request.BodyStringError String) req
@ -234,8 +224,7 @@ spec = describe "Parts" do
{ body: Request.BodyReadable stream
, url: URL.fromString "http://localhost:80/foo" # unsafePartial fromJust
, headers: Map.empty
, address: Left $ unsafePerformEffect $ SocketAddress.newIpv4
{ address: "127.0.0.1", port: 81 }
, address: SocketAddress.IPv4 "127.0.0.1" 81
, method: POST
}
a <- extractRequestParts @(Post /\ Json { foo :: Int, bar :: String }) req