git.iko.soy/rowtype-yoga/purescript-yoga-http-api
on feat/custom-type-errors help sign in
Harden HTTP API contracts and streaming
commit183c10e4fa15e4d366fb1fae2f3a9ed3998f5f8b authorMark <mark.eibes@gmail.com> dateTue 14 Jul 2026, 23:43 (3 months ago) parent7f57dc7
23 files changed+767−62
.github/workflows/ci.yml+16 spago.lock+22−4 spago.yaml+7−1 src/Yoga/HTTP/API/Path.purs+7−5 src/Yoga/HTTP/API/Route.purs+3−3 src/Yoga/HTTP/API/Route/Encoding.js+19 src/Yoga/HTTP/API/Route/Encoding.purs+59−7 src/Yoga/HTTP/API/Route/Handler.purs+16−1 src/Yoga/HTTP/API/Route/HeaderValue.purs+3−4 src/Yoga/HTTP/API/Route/OpenAPI.purs+45−21 src/Yoga/HTTP/API/Route/OpenAPIMetadata.purs+7−5 src/Yoga/HTTP/API/Route/Route.purs+2−2 src/Yoga/HTTP/API/Route/RouteHandler.purs+48−5 test-compile-fail/cases/CookieHandlerWrongRunner.purs+29 test-compile-fail/cases/InvalidNumericStatusCode.purs+9 test-compile-fail/cases/UnsupportedStreamChunk.purs+13 test-compile-fail/cases/WrongCaptureType.purs+1−1 test/Test/BearerAuthTest.js+7 test/Test/BearerAuthTest.purs+20−3 test/Test/Operators.purs+4 test/Test/PublicSurfaceTest.purs+305 test/Test/StreamingTest.js+43 test/Test/StreamingTest.purs+82
.github/workflows/ci.ymlnew file+16
@@ -0,0 +1,16 @@
1+name: CI
2+
3+on:
4+ push:
5+ branches: [main]
6+ pull_request:
7+ branches: [main]
8+
9+jobs:
10+ test:
11+ runs-on: ubuntu-latest
12+ steps:
13+ - uses: actions/checkout@v4
14+ - uses: rowtype-yoga/purescript-yoga-actions/.github/actions/setup@main
15+ - run: bun install --frozen-lockfile
16+ - run: bun run test
spago.lock+22−4
@@ -8,9 +8,15 @@
88 {
99 "aff": ">=8.0.0 <9.0.0"
1010 },
11+ {
12+ "arraybuffer-types": ">=3.0.2 <4.0.0"
13+ },
1114 {
1215 "arrays": ">=7.3.0 <8.0.0"
1316 },
17+ {
18+ "effect": ">=4.0.0 <5.0.0"
19+ },
1420 {
1521 "either": ">=6.1.0 <7.0.0"
1622 },
@@ -29,12 +35,24 @@
2935 {
3036 "integers": ">=6.0.0 <7.0.0"
3137 },
38+ {
39+ "js-promise": ">=1.0.0 <2.0.0"
40+ },
41+ {
42+ "js-promise-aff": ">=1.0.0 <2.0.0"
43+ },
3244 {
3345 "maybe": ">=6.0.0 <7.0.0"
3446 },
3547 {
3648 "newtype": ">=5.0.0 <6.0.0"
3749 },
50+ {
51+ "nullable": ">=6.0.0 <7.0.0"
52+ },
53+ {
54+ "numbers": ">=9.0.1 <10.0.0"
55+ },
3856 {
3957 "prelude": ">=6.0.2 <7.0.0"
4058 },
@@ -197,8 +215,8 @@
197215 },
198216 "compile-fail": {
199217 "type": "registry",
200− "version": "0.3.1",
201− "integrity": "sha256-nd5BrFIr3ppJb1LwCZPQPyLaImm4OGmycqUwg123XqU=",
218+ "version": "0.4.0",
219+ "integrity": "sha256-q0svAx4ZfokQcyCRotvhcnbaPCNLwxkjRVNUN6+Ufgg=",
202220 "dependencies": [
203221 "aff",
204222 "arrays",
@@ -1084,8 +1102,8 @@
10841102 },
10851103 "spec": {
10861104 "type": "registry",
1087− "version": "8.1.1",
1088− "integrity": "sha256-b6qjDb7lUJGLV+WEoywLu6BCXC4zGpfbN43mTL+vBeI=",
1105+ "version": "8.1.2",
1106+ "integrity": "sha256-klPPJ6lBQI1CGOAdLJ+ZoGRt8XZippkTKjn53BrSkL0=",
10891107 "dependencies": [
10901108 "aff",
10911109 "ansi",
spago.yaml+7−1
@@ -2,22 +2,28 @@ workspace: {}
22package:
33 name: yoga-http-api
44 publish:
5− version: 0.3.2
5+ version: 0.3.3
66 license: MIT
77 location:
88 githubOwner: rowtype-yoga
99 githubRepo: purescript-yoga-http-api
1010 dependencies:
1111 - aff: ">=8.0.0 <9.0.0"
12+ - arraybuffer-types: ">=3.0.2 <4.0.0"
1213 - arrays: ">=7.3.0 <8.0.0"
14+ - effect: ">=4.0.0 <5.0.0"
1315 - either: ">=6.1.0 <7.0.0"
1416 - foldable-traversable: ">=6.0.0 <7.0.0"
1517 - foreign: ">=7.0.0 <8.0.0"
1618 - foreign-object: ">=4.1.0 <5.0.0"
1719 - identity: ">=6.0.0 <7.0.0"
1820 - integers: ">=6.0.0 <7.0.0"
21+ - js-promise: ">=1.0.0 <2.0.0"
22+ - js-promise-aff: ">=1.0.0 <2.0.0"
1923 - maybe: ">=6.0.0 <7.0.0"
2024 - newtype: ">=5.0.0 <6.0.0"
25+ - nullable: ">=6.0.0 <7.0.0"
26+ - numbers: ">=9.0.1 <10.0.0"
2127 - prelude: ">=6.0.2 <7.0.0"
2228 - record: ">=4.0.0 <5.0.0"
2329 - strings: ">=6.0.1 <7.0.0"
src/Yoga/HTTP/API/Path.purs+7−5
@@ -28,12 +28,12 @@ import Data.Array as Array
2828import Data.Either (Either(..))
2929import Data.Int as Int
3030import Data.Maybe (Maybe(..))
31+import Data.Number as Number
3132import Data.String (Pattern(..), split)
3233import Data.Symbol (class IsSymbol, reflectSymbol)
3334import Prim.Row as Row
3435import Record as Record
3536import Type.Proxy (Proxy(..))
36−import Unsafe.Coerce (unsafeCoerce)
3737--------------------------------------------------------------------------------
3838-- Type-Level Path DSL
3939--------------------------------------------------------------------------------
@@ -237,9 +237,9 @@ instance parseParamInt :: ParseParam Int where
237237 Just n -> Right n
238238
239239instance parseParamNumber :: ParseParam Number where
240− parseParam s = case Int.fromString s of
240+ parseParam s = case Number.fromString s of
241241 Nothing -> Left $ "Expected a number but got: " <> s
242− Just n -> Right (Int.toNumber n) -- Simple version, doesn't handle floats
242+ Just n -> Right n
243243
244244
245245-- | Parse a URL string into a record of typed path parameters
@@ -267,15 +267,17 @@ instance parsePathLit :: IsSymbol s => ParsePath (Path (Lit s)) () where
267267instance parsePathCapture ::
268268 ( IsSymbol name
269269 , ParseParam ty
270+ , Row.Cons name ty () params
271+ , Row.Lacks name ()
270272 ) =>
271− ParsePath (Path (Capture name ty)) (name :: ty) where
273+ ParsePath (Path (Capture name ty)) params where
272274 parsePath _ url = do
273275 -- Remove leading "/"
274276 let segments = Array.filter (_ /= "") $ split (Pattern "/") url
275277 case segments of
276278 [ segment ] -> case (parseParam segment :: Either String ty) of
277279 Left _ -> Nothing
278− Right value -> pure $ unsafeCoerce { value } -- Simplified: we'd need proper record construction
280+ Right value -> pure $ Record.insert (Proxy :: Proxy name) value {}
279281 _ -> Nothing
280282
281283-- Recursive case: Lit segment followed by more
src/Yoga/HTTP/API/Route.purs+3−3
@@ -19,8 +19,8 @@ module Yoga.HTTP.API.Route
1919import Yoga.HTTP.API.Path (Path, Root, Lit, Capture, PathCons, Param, QueryParams, Required, type (/), type (:), type (:?), class PathPattern, pathPattern, class ParseParam, parseParam, class ParsePath, parsePath)
2020import Yoga.HTTP.API.Route.Auth (BearerToken(..), BasicAuth(..), ApiKeyHeader(..), ApiKeyCookie(..), DigestAuth(..))
2121import Yoga.HTTP.API.Route.BearerToken (BearerToken(..))
22−import Yoga.HTTP.API.Route.Encoding (JSON, FormData, MultipartFormData, PlainText, Streaming, XML, CustomContentType, NoBody)
23−import Yoga.HTTP.API.Route.Handler (HandlerFn, NoRequest)
22+import Yoga.HTTP.API.Route.Encoding (JSON, FormData, MultipartFormData, MultipartFile, multipartFileBytes, PlainText, Streaming, StreamChunk, textChunk, binaryChunk, streaming, XML, CustomContentType, NoBody)
23+import Yoga.HTTP.API.Route.Handler (HandlerFn, HandlerFnWithCookies, Request(..), NoRequest)
2424import Yoga.HTTP.API.Route.HeaderError (HeaderError(..))
2525import Yoga.HTTP.API.Route.HeaderValue (class HeaderValue, class HeaderValueType, headerValueType, parseHeader, printHeader)
2626import Yoga.HTTP.API.Route.Method (DELETE, GET, PATCH, POST, PUT, QUERY)
@@ -28,6 +28,6 @@ import Yoga.HTTP.API.Route.OpenAPI (class CollectOperations, collectOperations,
2828import Yoga.HTTP.API.Route.OpenAPIMetadata (class HasDescription, description, class HasExample, example, class HasFormat, format, class HasMinimum, minimum, class HasMaximum, maximum, class HasPattern, pattern, class HasMinLength, minLength, class HasMaxLength, maxLength, class HasTitle, title, class HasNullable, nullable, class HasDefault, default, class HasDeprecated, deprecated, class HasEnum, enum, class GenericEnumValues, genericEnumValues, class HasOperationMetadata, operationMetadata, class HasExamples, examples, OperationMetadata, Description, Example, Format, Minimum, Maximum, Pattern, MinLength, MaxLength, Title, Nullable, Default, Deprecated, Enum, Schema, Callback, Link, Examples, ExampleValue, ExampleWithSummary, ExampleObject)
2929import Yoga.HTTP.API.Route.Response (Response(..), ResponseData, respondNoHeaders, respondWith)
3030import Yoga.HTTP.API.Route.Route (Route(..), class ConvertResponseVariant, class ConvertResponseVariantRL)
31−import Yoga.HTTP.API.Route.RouteHandler (Handler, class RouteHandler, mkHandler, runHandler, class APIHandlers, apiHandlers, class ApiRecord)
31+import Yoga.HTTP.API.Route.RouteHandler (Handler, class RouteHandler, class RouteCookies, mkHandler, mkHandlerWithCookies, runHandler, runHandlerWithCookies, class APIHandlers, apiHandlers, class ApiRecord)
3232import Yoga.HTTP.API.Route.StatusCode (StatusCode(..), class StatusCodeMap, statusCodeFor, statusCodeToString)
3333import Yoga.Options (class Options, options, uorToMaybe, UndefinedOr, nullishCoalesce, (??), fromUndefinedOr, defined, withUor)
src/Yoga/HTTP/API/Route/Encoding.jsnew file+19
@@ -0,0 +1,19 @@
1+export const multipartFileBytes = (value) => value;
2+
3+export const textChunk = (value) => value;
4+export const binaryChunk = (value) => value;
5+
6+export const streamingImpl = (pull, cancel) =>
7+ new ReadableStream({
8+ async pull(controller) {
9+ const chunk = await pull();
10+ if (chunk == null) {
11+ controller.close();
12+ } else {
13+ controller.enqueue(chunk);
14+ }
15+ },
16+ cancel() {
17+ cancel();
18+ },
19+ });
src/Yoga/HTTP/API/Route/Encoding.purs+59−7
@@ -2,13 +2,32 @@ module Yoga.HTTP.API.Route.Encoding
22 ( JSON
33 , FormData
44 , MultipartFormData
5+ , MultipartFile
6+ , multipartFileBytes
57 , PlainText
68 , XML
79 , CustomContentType
810 , Streaming
11+ , StreamChunk
12+ , textChunk
13+ , binaryChunk
14+ , streaming
915 , NoBody
1016 ) where
1117
18+import Prelude
19+
20+import Data.ArrayBuffer.Types (Uint8Array)
21+import Data.Maybe (Maybe)
22+import Data.Nullable (Nullable, toNullable)
23+import Effect (Effect)
24+import Effect.Aff (Aff)
25+import Effect.Uncurried (EffectFn2, runEffectFn2)
26+import Foreign (unsafeToForeign, unsafeFromForeign)
27+import Promise (Promise)
28+import Promise.Aff as Promise
29+import Yoga.JSON (class ReadForeign, class WriteForeign)
30+
1231-- | JSON-encoded request body (application/json)
1332-- |
1433-- | Example:
@@ -30,6 +49,16 @@ data FormData a
3049data MultipartFormData :: Type -> Type
3150data MultipartFormData a
3251
52+-- | Opaque bytes for a file field produced by a multipart parser.
53+-- | The type prevents ordinary text fields and uploaded bytes being confused.
54+foreign import data MultipartFile :: Type
55+
56+-- | Access uploaded bytes without copying.
57+foreign import multipartFileBytes :: MultipartFile -> Uint8Array
58+
59+instance ReadForeign MultipartFile where
60+ readImpl value = pure (unsafeFromForeign value)
61+
3362-- | Plain text request/response body (text/plain)
3463-- |
3564-- | Example:
@@ -51,13 +80,36 @@ data XML a
5180data CustomContentType :: Symbol -> Type -> Type
5281data CustomContentType mime a
5382
54−-- | Streaming response body (returns a Strom of decoded chunks)
55−-- |
56−-- | Example:
57−-- | { body :: Streaming Uint8Array } -- raw binary
58−-- | { body :: Streaming String } -- text
59−data Streaming :: Type -> Type
60−data Streaming a
83+-- | A chunk representation accepted by Node and Web byte streams.
84+foreign import data StreamChunk :: Type
85+
86+-- | Construct a textual stream chunk.
87+foreign import textChunk :: String -> StreamChunk
88+
89+-- | Construct a binary stream chunk without copying its Uint8Array.
90+foreign import binaryChunk :: Uint8Array -> StreamChunk
91+
92+-- | A standards-based Web ReadableStream used as an HTTP response body.
93+-- | `pull` is called only when the consumer requests another chunk. Returning
94+-- | `Nothing` closes the stream; disconnecting the consumer runs `cancel`.
95+foreign import data Streaming :: Type -> Type
96+
97+foreign import streamingImpl
98+ :: EffectFn2
99+ (Effect (Promise (Nullable StreamChunk)))
100+ (Effect Unit)
101+ (Streaming StreamChunk)
102+
103+streaming
104+ :: { pull :: Aff (Maybe StreamChunk), cancel :: Effect Unit }
105+ -> Effect (Streaming StreamChunk)
106+streaming source =
107+ runEffectFn2 streamingImpl
108+ (Promise.fromAff (toNullable <$> source.pull))
109+ source.cancel
110+
111+instance WriteForeign (Streaming a) where
112+ writeImpl = unsafeToForeign
61113
62114-- | No request body (for GET, DELETE, etc.)
63115-- |
src/Yoga/HTTP/API/Route/Handler.purs+16−1
@@ -1,5 +1,6 @@
11module Yoga.HTTP.API.Route.Handler
22 ( HandlerFn
3+ , HandlerFnWithCookies
34 , Request(..)
45 , NoRequest
56 , class DefaultRequestFields
@@ -21,7 +22,7 @@ import Effect.Aff (Aff)
2122import Prim.Row as Row
2223import Prim.RowList as RL
2324import Yoga.HTTP.API.Path (Path, PathCons, Capture, Param, QueryParams, Required)
24−import Yoga.HTTP.API.Route.Encoding (JSON, NoBody)
25+import Yoga.HTTP.API.Route.Encoding (CustomContentType, FormData, JSON, MultipartFormData, NoBody, PlainText, XML)
2526
2627--------------------------------------------------------------------------------
2728-- NoRequest: Type alias for empty request (no headers, cookies, or body)
@@ -61,6 +62,15 @@ type HandlerFn pathParams queryParams reqHeaders body respVariant =
6162 }
6263 -> Aff (Variant respVariant)
6364
65+type HandlerFnWithCookies pathParams queryParams reqHeaders reqCookies body respVariant =
66+ { path :: Record pathParams
67+ , query :: Record queryParams
68+ , headers :: Record reqHeaders
69+ , cookies :: Record reqCookies
70+ , body :: body
71+ }
72+ -> Aff (Variant respVariant)
73+
6474--------------------------------------------------------------------------------
6575-- SegmentPathParams: Extract path capture row from segments
6676--------------------------------------------------------------------------------
@@ -158,6 +168,11 @@ else instance captureParamsDefault :: CaptureParams s ()
158168class EncodingBody (encoding :: Type) (body :: Type) | encoding -> body
159169
160170instance encodingBodyJSON :: EncodingBody (JSON a) a
171+instance encodingBodyFormData :: EncodingBody (FormData a) a
172+instance encodingBodyMultipartFormData :: EncodingBody (MultipartFormData a) a
173+instance encodingBodyPlainText :: EncodingBody PlainText String
174+instance encodingBodyXML :: EncodingBody (XML a) a
175+instance encodingBodyCustomContentType :: EncodingBody (CustomContentType mime a) a
161176instance encodingBodyNoBody :: EncodingBody NoBody Unit
162177instance EncodingBody (Record row) (Record row)
163178
src/Yoga/HTTP/API/Route/HeaderValue.purs+3−4
@@ -50,10 +50,9 @@ instance HeaderValue Int where
5050-- | Note: parseHeader always succeeds for Maybe types (returns Just or Nothing)
5151instance HeaderValue a => HeaderValue (Maybe a) where
5252 parseHeader s = Right
53− ( case parseHeader s of
54− Right val -> Just val
55− Left _ -> Nothing
56− )
53+ case parseHeader s of
54+ Right val -> Just val
55+ Left _ -> Nothing
5756 printHeader Nothing = ""
5857 printHeader (Just a) = printHeader a
5958
src/Yoga/HTTP/API/Route/OpenAPI.purs+45−21
@@ -44,6 +44,7 @@ module Yoga.HTTP.API.Route.OpenAPI
4444 , detectCookieSecurity
4545 , class DetectCookieSecurityRL
4646 , detectCookieSecurityRL
47+ , mergeSecurityRequirements
4748 , class ToOpenAPI
4849 , toOpenAPIImpl
4950 , toOpenAPI
@@ -101,7 +102,7 @@ import Type.Proxy (Proxy(..))
101102import Data.Reflectable (class Reflectable, reflectType)
102103import Unsafe.Coerce (unsafeCoerce)
103104import Yoga.HTTP.API.Route.Auth (BearerToken, BasicAuth, ApiKeyHeader, ApiKeyCookie, DigestAuth)
104−import Yoga.HTTP.API.Route.Encoding (JSON, FormData, MultipartFormData, PlainText, XML, CustomContentType, NoBody)
105+import Yoga.HTTP.API.Route.Encoding (JSON, FormData, MultipartFormData, PlainText, Streaming, XML, CustomContentType, NoBody)
105106import Yoga.HTTP.API.Route.HeaderValue (class HeaderValueType, headerValueType)
106107import Yoga.HTTP.API.Route.OpenAPIMetadata (Description, Example, Format, Minimum, Maximum, Pattern, MinLength, MaxLength, Title, Nullable, Default, Deprecated, Enum, Schema, Callback, Examples, class HasDescription, description, class HasExample, example, class HasFormat, format, class HasDeprecated, deprecated, class HasMinimum, minimum, class HasMaximum, maximum, class HasPattern, pattern, class HasMinLength, minLength, class HasMaxLength, maxLength, class HasTitle, title, class HasNullable, nullable, class HasDefault, default, class HasEnum, enum, class HasExamples, examples)
107108import Yoga.HTTP.API.Route.RenderMethod (class RenderMethod, renderMethod)
@@ -325,6 +326,18 @@ else instance
325326--------------------------------------------------------------------------------
326327-- Security Detection
327328--------------------------------------------------------------------------------
329+-- | Combine required schemes into one OpenAPI Security Requirement Object.
330+-- | Keys within one object are AND requirements; separate objects would mean OR.
331+mergeSecurityRequirements :: Array (Array Foreign) -> Array Foreign
332+mergeSecurityRequirements groups =
333+ let
334+ merged = foldl
335+ (\acc requirement -> FObject.union acc (unsafeCoerce requirement))
336+ FObject.empty
337+ (Array.concat groups)
338+ in
339+ if FObject.size merged == 0 then [] else [ unsafeCoerce merged ]
340+
328341
329342-- | Detect security requirements in request headers
330343-- | Returns an array of security requirement objects (e.g., [{ bearerAuth: [] }])
@@ -348,7 +361,7 @@ instance DetectSecurityRL tail => DetectSecurityRL (RL.Cons name BearerToken tai
348361 let
349362 bearerAuth = unsafeCoerce $ FObject.singleton "bearerAuth" ([] :: Array String)
350363 in
351− [ bearerAuth ]
364+ mergeSecurityRequirements [ [ bearerAuth ], detectSecurityRL (Proxy :: Proxy tail) ]
352365
353366-- Case: BearerToken wrapped in Description
354367else instance DetectSecurityRL tail => DetectSecurityRL (RL.Cons name (Description desc BearerToken) tail) where
@@ -356,7 +369,7 @@ else instance DetectSecurityRL tail => DetectSecurityRL (RL.Cons name (Descripti
356369 let
357370 bearerAuth = unsafeCoerce $ FObject.singleton "bearerAuth" ([] :: Array String)
358371 in
359− [ bearerAuth ]
372+ mergeSecurityRequirements [ [ bearerAuth ], detectSecurityRL (Proxy :: Proxy tail) ]
360373
361374-- Case: BearerToken wrapped in Example
362375else instance DetectSecurityRL tail => DetectSecurityRL (RL.Cons name (Example ex BearerToken) tail) where
@@ -364,7 +377,7 @@ else instance DetectSecurityRL tail => DetectSecurityRL (RL.Cons name (Example e
364377 let
365378 bearerAuth = unsafeCoerce $ FObject.singleton "bearerAuth" ([] :: Array String)
366379 in
367− [ bearerAuth ]
380+ mergeSecurityRequirements [ [ bearerAuth ], detectSecurityRL (Proxy :: Proxy tail) ]
368381
369382-- Case: BearerToken wrapped in Deprecated
370383else instance DetectSecurityRL tail => DetectSecurityRL (RL.Cons name (Deprecated BearerToken) tail) where
@@ -372,7 +385,7 @@ else instance DetectSecurityRL tail => DetectSecurityRL (RL.Cons name (Deprecate
372385 let
373386 bearerAuth = unsafeCoerce $ FObject.singleton "bearerAuth" ([] :: Array String)
374387 in
375− [ bearerAuth ]
388+ mergeSecurityRequirements [ [ bearerAuth ], detectSecurityRL (Proxy :: Proxy tail) ]
376389
377390-- Case: BasicAuth found
378391else instance DetectSecurityRL tail => DetectSecurityRL (RL.Cons name BasicAuth tail) where
@@ -380,7 +393,7 @@ else instance DetectSecurityRL tail => DetectSecurityRL (RL.Cons name BasicAuth
380393 let
381394 basicAuth = unsafeCoerce $ FObject.singleton "basicAuth" ([] :: Array String)
382395 in
383− [ basicAuth ]
396+ mergeSecurityRequirements [ [ basicAuth ], detectSecurityRL (Proxy :: Proxy tail) ]
384397
385398-- Case: BasicAuth wrapped in Description
386399else instance DetectSecurityRL tail => DetectSecurityRL (RL.Cons name (Description desc BasicAuth) tail) where
@@ -388,7 +401,7 @@ else instance DetectSecurityRL tail => DetectSecurityRL (RL.Cons name (Descripti
388401 let
389402 basicAuth = unsafeCoerce $ FObject.singleton "basicAuth" ([] :: Array String)
390403 in
391− [ basicAuth ]
404+ mergeSecurityRequirements [ [ basicAuth ], detectSecurityRL (Proxy :: Proxy tail) ]
392405
393406-- Case: BasicAuth wrapped in Example
394407else instance DetectSecurityRL tail => DetectSecurityRL (RL.Cons name (Example ex BasicAuth) tail) where
@@ -396,7 +409,7 @@ else instance DetectSecurityRL tail => DetectSecurityRL (RL.Cons name (Example e
396409 let
397410 basicAuth = unsafeCoerce $ FObject.singleton "basicAuth" ([] :: Array String)
398411 in
399− [ basicAuth ]
412+ mergeSecurityRequirements [ [ basicAuth ], detectSecurityRL (Proxy :: Proxy tail) ]
400413
401414-- Case: BasicAuth wrapped in Deprecated
402415else instance DetectSecurityRL tail => DetectSecurityRL (RL.Cons name (Deprecated BasicAuth) tail) where
@@ -404,7 +417,7 @@ else instance DetectSecurityRL tail => DetectSecurityRL (RL.Cons name (Deprecate
404417 let
405418 basicAuth = unsafeCoerce $ FObject.singleton "basicAuth" ([] :: Array String)
406419 in
407− [ basicAuth ]
420+ mergeSecurityRequirements [ [ basicAuth ], detectSecurityRL (Proxy :: Proxy tail) ]
408421
409422-- Case: ApiKeyHeader found
410423else instance (DetectSecurityRL tail, IsSymbol name) => DetectSecurityRL (RL.Cons name ApiKeyHeader tail) where
@@ -413,7 +426,7 @@ else instance (DetectSecurityRL tail, IsSymbol name) => DetectSecurityRL (RL.Con
413426 keyName = reflectSymbol (Proxy :: Proxy name)
414427 apiKeyAuth = unsafeCoerce $ FObject.singleton (keyName <> "ApiKey") ([] :: Array String)
415428 in
416− [ apiKeyAuth ]
429+ mergeSecurityRequirements [ [ apiKeyAuth ], detectSecurityRL (Proxy :: Proxy tail) ]
417430
418431-- Case: ApiKeyHeader wrapped in Description
419432else instance (DetectSecurityRL tail, IsSymbol name) => DetectSecurityRL (RL.Cons name (Description desc ApiKeyHeader) tail) where
@@ -422,7 +435,7 @@ else instance (DetectSecurityRL tail, IsSymbol name) => DetectSecurityRL (RL.Con
422435 keyName = reflectSymbol (Proxy :: Proxy name)
423436 apiKeyAuth = unsafeCoerce $ FObject.singleton (keyName <> "ApiKey") ([] :: Array String)
424437 in
425− [ apiKeyAuth ]
438+ mergeSecurityRequirements [ [ apiKeyAuth ], detectSecurityRL (Proxy :: Proxy tail) ]
426439
427440-- Case: ApiKeyHeader wrapped in Example
428441else instance (DetectSecurityRL tail, IsSymbol name) => DetectSecurityRL (RL.Cons name (Example ex ApiKeyHeader) tail) where
@@ -431,7 +444,7 @@ else instance (DetectSecurityRL tail, IsSymbol name) => DetectSecurityRL (RL.Con
431444 keyName = reflectSymbol (Proxy :: Proxy name)
432445 apiKeyAuth = unsafeCoerce $ FObject.singleton (keyName <> "ApiKey") ([] :: Array String)
433446 in
434− [ apiKeyAuth ]
447+ mergeSecurityRequirements [ [ apiKeyAuth ], detectSecurityRL (Proxy :: Proxy tail) ]
435448
436449-- Case: ApiKeyHeader wrapped in Deprecated
437450else instance (DetectSecurityRL tail, IsSymbol name) => DetectSecurityRL (RL.Cons name (Deprecated ApiKeyHeader) tail) where
@@ -440,7 +453,7 @@ else instance (DetectSecurityRL tail, IsSymbol name) => DetectSecurityRL (RL.Con
440453 keyName = reflectSymbol (Proxy :: Proxy name)
441454 apiKeyAuth = unsafeCoerce $ FObject.singleton (keyName <> "ApiKey") ([] :: Array String)
442455 in
443− [ apiKeyAuth ]
456+ mergeSecurityRequirements [ [ apiKeyAuth ], detectSecurityRL (Proxy :: Proxy tail) ]
444457
445458-- Case: DigestAuth found
446459else instance DetectSecurityRL tail => DetectSecurityRL (RL.Cons name DigestAuth tail) where
@@ -448,7 +461,7 @@ else instance DetectSecurityRL tail => DetectSecurityRL (RL.Cons name DigestAuth
448461 let
449462 digestAuth = unsafeCoerce $ FObject.singleton "digestAuth" ([] :: Array String)
450463 in
451− [ digestAuth ]
464+ mergeSecurityRequirements [ [ digestAuth ], detectSecurityRL (Proxy :: Proxy tail) ]
452465
453466-- Case: DigestAuth wrapped in Description
454467else instance DetectSecurityRL tail => DetectSecurityRL (RL.Cons name (Description desc DigestAuth) tail) where
@@ -456,7 +469,7 @@ else instance DetectSecurityRL tail => DetectSecurityRL (RL.Cons name (Descripti
456469 let
457470 digestAuth = unsafeCoerce $ FObject.singleton "digestAuth" ([] :: Array String)
458471 in
459− [ digestAuth ]
472+ mergeSecurityRequirements [ [ digestAuth ], detectSecurityRL (Proxy :: Proxy tail) ]
460473
461474-- Case: DigestAuth wrapped in Example
462475else instance DetectSecurityRL tail => DetectSecurityRL (RL.Cons name (Example ex DigestAuth) tail) where
@@ -464,7 +477,7 @@ else instance DetectSecurityRL tail => DetectSecurityRL (RL.Cons name (Example e
464477 let
465478 digestAuth = unsafeCoerce $ FObject.singleton "digestAuth" ([] :: Array String)
466479 in
467− [ digestAuth ]
480+ mergeSecurityRequirements [ [ digestAuth ], detectSecurityRL (Proxy :: Proxy tail) ]
468481
469482-- Case: DigestAuth wrapped in Deprecated
470483else instance DetectSecurityRL tail => DetectSecurityRL (RL.Cons name (Deprecated DigestAuth) tail) where
@@ -472,7 +485,7 @@ else instance DetectSecurityRL tail => DetectSecurityRL (RL.Cons name (Deprecate
472485 let
473486 digestAuth = unsafeCoerce $ FObject.singleton "digestAuth" ([] :: Array String)
474487 in
475− [ digestAuth ]
488+ mergeSecurityRequirements [ [ digestAuth ], detectSecurityRL (Proxy :: Proxy tail) ]
476489
477490-- Case: Non-security header, recurse
478491else instance (DetectSecurityRL tail, HeaderValueType ty) => DetectSecurityRL (RL.Cons name ty tail) where
@@ -505,7 +518,7 @@ instance (DetectCookieSecurityRL tail, IsSymbol name) => DetectCookieSecurityRL
505518 cookieName = reflectSymbol (Proxy :: Proxy name)
506519 apiKeyCookie = unsafeCoerce $ FObject.singleton (cookieName <> "Cookie") ([] :: Array String)
507520 in
508− [ apiKeyCookie ]
521+ mergeSecurityRequirements [ [ apiKeyCookie ], detectCookieSecurityRL (Proxy :: Proxy tail) ]
509522
510523-- Case: ApiKeyCookie wrapped in Description
511524else instance (DetectCookieSecurityRL tail, IsSymbol name) => DetectCookieSecurityRL (RL.Cons name (Description desc ApiKeyCookie) tail) where
@@ -514,7 +527,7 @@ else instance (DetectCookieSecurityRL tail, IsSymbol name) => DetectCookieSecuri
514527 cookieName = reflectSymbol (Proxy :: Proxy name)
515528 apiKeyCookie = unsafeCoerce $ FObject.singleton (cookieName <> "Cookie") ([] :: Array String)
516529 in
517− [ apiKeyCookie ]
530+ mergeSecurityRequirements [ [ apiKeyCookie ], detectCookieSecurityRL (Proxy :: Proxy tail) ]
518531
519532-- Case: ApiKeyCookie wrapped in Example
520533else instance (DetectCookieSecurityRL tail, IsSymbol name) => DetectCookieSecurityRL (RL.Cons name (Example ex ApiKeyCookie) tail) where
@@ -523,7 +536,7 @@ else instance (DetectCookieSecurityRL tail, IsSymbol name) => DetectCookieSecuri
523536 cookieName = reflectSymbol (Proxy :: Proxy name)
524537 apiKeyCookie = unsafeCoerce $ FObject.singleton (cookieName <> "Cookie") ([] :: Array String)
525538 in
526− [ apiKeyCookie ]
539+ mergeSecurityRequirements [ [ apiKeyCookie ], detectCookieSecurityRL (Proxy :: Proxy tail) ]
527540
528541-- Case: ApiKeyCookie wrapped in Deprecated
529542else instance (DetectCookieSecurityRL tail, IsSymbol name) => DetectCookieSecurityRL (RL.Cons name (Deprecated ApiKeyCookie) tail) where
@@ -532,7 +545,7 @@ else instance (DetectCookieSecurityRL tail, IsSymbol name) => DetectCookieSecuri
532545 cookieName = reflectSymbol (Proxy :: Proxy name)
533546 apiKeyCookie = unsafeCoerce $ FObject.singleton (cookieName <> "Cookie") ([] :: Array String)
534547 in
535− [ apiKeyCookie ]
548+ mergeSecurityRequirements [ [ apiKeyCookie ], detectCookieSecurityRL (Proxy :: Proxy tail) ]
536549
537550-- Case: Non-security cookie, recurse
538551else instance (DetectCookieSecurityRL tail, HeaderValueType ty) => DetectCookieSecurityRL (RL.Cons name ty tail) where
@@ -929,6 +942,10 @@ instance RenderJSONSchema a => RenderJSONSchema (XML a) where
929942instance RenderJSONSchema a => RenderJSONSchema (CustomContentType mime a) where
930943 renderJSONSchema _ = renderJSONSchema (Proxy :: Proxy a)
931944
945+-- Streaming responses are represented as binary payloads in OpenAPI.
946+instance RenderJSONSchema (Streaming a) where
947+ renderJSONSchema _ = unsafeCoerce { type: "string", format: "binary" }
948+
932949-- NoBody encoding (no schema)
933950instance RenderJSONSchema NoBody where
934951 renderJSONSchema _ = unsafeCoerce { type: "null" }
@@ -1114,6 +1131,9 @@ instance GetContentType (XML a) where
11141131instance IsSymbol mime => GetContentType (CustomContentType mime a) where
11151132 getContentType _ = reflectSymbol (Proxy :: Proxy mime)
11161133
1134+instance GetContentType (Streaming a) where
1135+ getContentType _ = "application/octet-stream"
1136+
11171137-- For unwrapped types (backward compatibility), default to JSON
11181138instance GetContentType String where
11191139 getContentType _ = "application/json"
@@ -1576,6 +1596,9 @@ instance CollectSchemas a => CollectSchemas (MultipartFormData a) where
15761596instance CollectSchemas (PlainText) where
15771597 collectSchemas _ = FObject.empty
15781598
1599+instance CollectSchemas (Streaming a) where
1600+ collectSchemas _ = FObject.empty
1601+
15791602-- XML wrapper: unwrap and recurse
15801603instance CollectSchemas a => CollectSchemas (XML a) where
15811604 collectSchemas _ = collectSchemas (Proxy :: Proxy a)
@@ -1730,6 +1753,7 @@ instance CollectSchemaNames a names => CollectSchemaNames (JSON a) names
17301753instance CollectSchemaNames a names => CollectSchemaNames (FormData a) names
17311754instance CollectSchemaNames a names => CollectSchemaNames (MultipartFormData a) names
17321755instance CollectSchemaNames (PlainText) ()
1756+instance CollectSchemaNames (Streaming a) ()
17331757instance CollectSchemaNames a names => CollectSchemaNames (XML a) names
17341758instance CollectSchemaNames a names => CollectSchemaNames (CustomContentType mime a) names
17351759instance CollectSchemaNames NoBody ()
src/Yoga/HTTP/API/Route/OpenAPIMetadata.purs+7−5
@@ -54,6 +54,8 @@ module Yoga.HTTP.API.Route.OpenAPIMetadata
5454 , ExampleObject
5555 , class HasExamples
5656 , examples
57+ , class RenderLinkParametersRL
58+ , renderLinkParametersRL
5759 , class HasLinks
5860 , links
5961 , LinkMetadata
@@ -201,7 +203,7 @@ infixl 5 type Callback as #
201203-- | Example:
202204-- | ok :: { body :: User }
203205-- | # Link "deleteUser" "deleteUserById" ( userId :: "$response.body#/id" )
204−data Link :: Type -> Symbol -> Symbol -> Row Type -> Type
206+data Link :: Type -> Symbol -> Symbol -> Row Symbol -> Type
205207data Link inner linkName operationId parametersRow
206208
207209infixl 5 type Link as :#
@@ -767,19 +769,19 @@ type LinkMetadata =
767769
768770-- | Helper class to render link parameters row as a Foreign object
769771-- | Each field in the row should have a Symbol literal type as its value
770−class RenderLinkParametersRL (rl :: RowList Type) where
772+class RenderLinkParametersRL (rl :: RowList Symbol) where
771773 renderLinkParametersRL :: Proxy rl -> FObject.Object Foreign
772774
773775instance RenderLinkParametersRL Nil where
774776 renderLinkParametersRL _ = FObject.empty
775777
776778-- For each parameter, the type is a Symbol literal representing the runtime expression
777−-- We use Reflectable to extract the Symbol value from the literal type
778−instance (IsSymbol paramName, Reflectable expression String, RenderLinkParametersRL tail) => RenderLinkParametersRL (Cons paramName expression tail) where
779+-- Reflect the Symbol directly into the OpenAPI runtime expression.
780+instance (IsSymbol paramName, IsSymbol expression, RenderLinkParametersRL tail) => RenderLinkParametersRL (Cons paramName expression tail) where
779781 renderLinkParametersRL _ =
780782 let
781783 name = reflectSymbol (Proxy :: Proxy paramName)
782− expr = reflectType (Proxy :: Proxy expression)
784+ expr = reflectSymbol (Proxy :: Proxy expression)
783785 rest = renderLinkParametersRL (Proxy :: Proxy tail)
784786 in
785787 FObject.insert name (unsafeCoerce expr) rest
src/Yoga/HTTP/API/Route/Route.purs+2−2
@@ -14,7 +14,7 @@ import Prim.RowList as RL
1414import Type.Proxy (Proxy(..))
1515import Yoga.HTTP.API.Path (class PathPattern, pathPattern)
1616import Yoga.HTTP.API.Route.Handler (Request, class DefaultRequestFields, class SegmentPathParams, class SegmentQueryParams)
17−import Yoga.HTTP.API.Route.OpenAPI (class CollectOperations, class RenderHeadersSchema, renderHeadersSchema, class RenderCookieParamsSchema, renderCookieParamsSchema, class RenderPathParamsSchema, renderPathParamsSchema, class RenderQueryParamsSchema, renderQueryParamsSchema, class RenderRequestBodySchema, renderRequestBodySchema, class RenderVariantResponseSchemaRL, renderVariantResponseSchemaRL, class DetectSecurity, detectSecurity, class DetectCookieSecurity, detectCookieSecurity, class ToOpenAPI, toOpenAPIImpl, class CollectSchemas, collectSchemas, class CollectVariantSchemasRL, collectVariantSchemasRL, class CollectRouteSchemas, collectRouteSchemas, class CollectSchemaNames, class CollectVariantSchemaNames, class CollectRouteSchemaNames)
17+import Yoga.HTTP.API.Route.OpenAPI (class CollectOperations, class RenderHeadersSchema, renderHeadersSchema, class RenderCookieParamsSchema, renderCookieParamsSchema, class RenderPathParamsSchema, renderPathParamsSchema, class RenderQueryParamsSchema, renderQueryParamsSchema, class RenderRequestBodySchema, renderRequestBodySchema, class RenderVariantResponseSchemaRL, renderVariantResponseSchemaRL, class DetectSecurity, detectSecurity, class DetectCookieSecurity, detectCookieSecurity, mergeSecurityRequirements, class ToOpenAPI, toOpenAPIImpl, class CollectSchemas, collectSchemas, class CollectVariantSchemasRL, collectVariantSchemasRL, class CollectRouteSchemas, collectRouteSchemas, class CollectSchemaNames, class CollectVariantSchemaNames, class CollectRouteSchemaNames)
1818import Yoga.HTTP.API.Route.OpenAPIMetadata (class HasOperationMetadata, operationMetadata)
1919import Yoga.HTTP.API.Route.RenderMethod (class RenderMethod, renderMethod)
2020import Yoga.HTTP.API.Route.Response (Response, class ToResponse)
@@ -82,7 +82,7 @@ instance
8282 responses = renderVariantResponseSchemaRL (Proxy :: Proxy rl)
8383 headerSecurity = detectSecurity (Proxy :: Proxy reqHeaders)
8484 cookieSecurity = detectCookieSecurity (Proxy :: Proxy reqCookies)
85− security = headerSecurity <> cookieSecurity
85+ security = mergeSecurityRequirements [ headerSecurity, cookieSecurity ]
8686 metadata = operationMetadata proxy
8787 operation =
8888 { method: methodStr
src/Yoga/HTTP/API/Route/RouteHandler.purs+48−5
@@ -1,8 +1,11 @@
11module Yoga.HTTP.API.Route.RouteHandler
22 ( Handler
33 , class RouteHandler
4+ , class RouteCookies
45 , mkHandler
6+ , mkHandlerWithCookies
57 , runHandler
8+ , runHandlerWithCookies
69 , class APIHandlers
710 , apiHandlers
811 , class ApiRecord
@@ -15,6 +18,7 @@ import Unsafe.Coerce (unsafeCoerce)
1518import Yoga.HTTP.API.Route.Handler
1619 ( HandlerFn
1720 , Request
21+ , HandlerFnWithCookies
1822 , class DefaultRequestFields
1923 , class EncodingBody
2024 , class SegmentPathParams
@@ -65,6 +69,21 @@ else instance
6569 body
6670 respVariant
6771
72+-- | Compute the cookie row declared by a route request.
73+class RouteCookies (route :: Type) (cookies :: Row Type) | route -> cookies
74+
75+instance
76+ DefaultRequestFields partialRequest headers cookies encoding =>
77+ RouteCookies
78+ (Route method segments (Record partialRequest) response)
79+ cookies
80+
81+else instance
82+ DefaultRequestFields partialRequest headers cookies encoding =>
83+ RouteCookies
84+ (Route method segments (Request (Record partialRequest)) response)
85+ cookies
86+
6887-- | A handler tied to a specific route type.
6988-- |
7089-- | Usage:
@@ -72,21 +91,45 @@ else instance
7291-- | userHandler = mkHandler \{ path } -> ...
7392foreign import data Handler :: Type -> Type
7493
75−-- | Create a Handler from a function matching the route's type.
94+-- | Create a handler from the route's complete typed request.
95+-- | The cookie row is inferred from the route, so cookie-aware routes need no
96+-- | alternate constructor.
7697mkHandler
77− :: forall route pathParams queryParams reqHeaders body respVariant
98+ :: forall route pathParams queryParams reqHeaders reqCookies body respVariant
7899 . RouteHandler route pathParams queryParams reqHeaders body respVariant
79− => HandlerFn pathParams queryParams reqHeaders body respVariant
100+ => RouteCookies route reqCookies
101+ => HandlerFnWithCookies pathParams queryParams reqHeaders reqCookies body respVariant
80102 -> Handler route
81103mkHandler = unsafeCoerce
82104
83−-- | Extract the handler function from a Handler.
105+-- | Compatibility name for cookie-aware handlers. Prefer `mkHandler`.
106+mkHandlerWithCookies
107+ :: forall route pathParams queryParams reqHeaders reqCookies body respVariant
108+ . RouteHandler route pathParams queryParams reqHeaders body respVariant
109+ => RouteCookies route reqCookies
110+ => HandlerFnWithCookies pathParams queryParams reqHeaders reqCookies body respVariant
111+ -> Handler route
112+mkHandlerWithCookies = mkHandler
113+
114+-- | Run a handler for a route whose cookie row is empty.
115+-- | Cookie-bearing routes must use `runHandlerWithCookies`.
84116runHandler
85117 :: forall route pathParams queryParams reqHeaders body respVariant
86118 . RouteHandler route pathParams queryParams reqHeaders body respVariant
119+ => RouteCookies route ()
87120 => Handler route
88121 -> HandlerFn pathParams queryParams reqHeaders body respVariant
89−runHandler = unsafeCoerce
122+runHandler handler { path, query, headers, body } =
123+ (unsafeCoerce handler) { path, query, headers, cookies: {}, body }
124+
125+-- | Run the canonical cookie-aware handler representation.
126+runHandlerWithCookies
127+ :: forall route pathParams queryParams reqHeaders reqCookies body respVariant
128+ . RouteHandler route pathParams queryParams reqHeaders body respVariant
129+ => RouteCookies route reqCookies
130+ => Handler route
131+ -> HandlerFnWithCookies pathParams queryParams reqHeaders reqCookies body respVariant
132+runHandlerWithCookies = unsafeCoerce
90133
91134--------------------------------------------------------------------------------
92135-- APIHandlers: Map an API record row to a handler record row
test-compile-fail/cases/CookieHandlerWrongRunner.pursnew file+29
@@ -0,0 +1,29 @@
1+module Test.CompileFail.CookieHandlerWrongRunner where
2+
3+import Prelude
4+
5+import Effect.Aff (Aff)
6+import Yoga.HTTP.API.Route
7+ ( ApiKeyCookie
8+ , GET
9+ , Handler
10+ , Route
11+ , mkHandler
12+ , respondNoHeaders
13+ , runHandler
14+ )
15+
16+type CookieRoute =
17+ Route GET "cookie" { cookies :: { session :: ApiKeyCookie } } (ok :: { body :: String })
18+
19+handler :: Handler CookieRoute
20+handler = mkHandler \{ cookies } ->
21+ pure $ respondNoHeaders @"ok" cookies.session
22+
23+wrong :: Aff _
24+wrong = runHandler handler
25+ { path: {}
26+ , query: {}
27+ , headers: {}
28+ , body: unit
29+ }
test-compile-fail/cases/InvalidNumericStatusCode.pursnew file+9
@@ -0,0 +1,9 @@
1+-- EXPECT: StatusCodeToLabel
2+module Test.CompileFail.InvalidNumericStatusCode where
3+
4+import Data.Variant (Variant)
5+import Yoga.HTTP.API.Route.Response (Response, respondStatus)
6+
7+-- Numeric response constructors accept only registered HTTP status codes.
8+test :: Variant (ok :: Response () String)
9+test = respondStatus @299 "not registered"
test-compile-fail/cases/UnsupportedStreamChunk.pursnew file+13
@@ -0,0 +1,13 @@
1+-- EXPECT: Could not match type
2+module Test.CompileFail.UnsupportedStreamChunk where
3+
4+import Prelude
5+import Data.Maybe (Maybe(..))
6+import Effect (Effect)
7+import Yoga.HTTP.API.Route.Encoding (Streaming, streaming)
8+
9+bad :: Effect (Streaming Int)
10+bad = streaming
11+ { pull: pure (Just 1)
12+ , cancel: pure unit
13+ }
test-compile-fail/cases/WrongCaptureType.purs+1−1
@@ -1,4 +1,4 @@
1−-- EXPECT: Could not match type
1+-- EXPECT: No type class instance was found
22module Test.CompileFail.WrongCaptureType where
33
44import Data.Maybe (Maybe)
test/Test/BearerAuthTest.jsnew file+7
@@ -0,0 +1,7 @@
1+export const securityShape = (source) => {
2+ const security = JSON.parse(source).paths["/combined"].get.security;
3+ return {
4+ requirementCount: security.length,
5+ schemeCount: Object.keys(security[0] ?? {}).length,
6+ };
7+};
test/Test/BearerAuthTest.purs+20−3
@@ -3,11 +3,10 @@ module Test.BearerAuthTest where
33import Prelude
44
55import Data.Array as Array
6−import Data.Maybe (Maybe(..))
76import Data.String as String
87import Effect (Effect)
98import Yoga.HTTP.API.Route (GET, Route, buildOpenAPISpec)
10−import Yoga.HTTP.API.Route.Auth (BearerToken, BasicAuth, ApiKeyHeader, DigestAuth)
9+import Yoga.HTTP.API.Route.Auth (BearerToken, BasicAuth, ApiKeyHeader, ApiKeyCookie, DigestAuth)
1110import Yoga.JSON (writeJSON)
1211import Test.OpenAPIValidation (validate)
1312import ViTest (ViTest, describe, test)
@@ -39,6 +38,18 @@ type MultiAuthAPI =
3938 , apiKeyEndpoint :: Route GET "apikey" { headers :: { xApiKey :: ApiKeyHeader } } (ok :: { body :: { message :: String } })
4039 }
4140
41+type ThreeFactorAPI =
42+ { combined :: Route GET "combined"
43+ { headers :: { authorization :: BearerToken, apiKey :: ApiKeyHeader }
44+ , cookies :: { session :: ApiKeyCookie }
45+ }
46+ (ok :: { body :: { message :: String } })
47+ }
48+
49+foreign import securityShape
50+ :: String
51+ -> { requirementCount :: Int, schemeCount :: Int }
52+
4253--------------------------------------------------------------------------------
4354-- Tests
4455--------------------------------------------------------------------------------
@@ -130,7 +141,7 @@ testDigestAuth = describe "DigestAuth Authentication" do
130141 expectToBe true (String.contains (String.Pattern "\"scheme\":\"digest\"") json)
131142
132143testMultipleAuthTypes :: Effect ViTest
133−testMultipleAuthTypes = describe "Multiple Authentication Types" do
144+testMultipleAuthTypes = describe "Declare authentication requirements in route types" do
134145 _ <- test "All auth types appear in security schemes" do
135146 let spec = buildOpenAPISpec @MultiAuthAPI { title: "Auth API", version: "1.0.0" }
136147 let json = writeJSON spec
@@ -144,3 +155,9 @@ testMultipleAuthTypes = describe "Multiple Authentication Types" do
144155 -- Check that all endpoints have empty parameters arrays
145156 let hasEmptyParams = String.contains (String.Pattern "\"parameters\":[]") json
146157 expectToBe true hasEmptyParams
158+
159+ test "one request record → one OpenAPI AND requirement" do
160+ let spec = buildOpenAPISpec @ThreeFactorAPI { title: "Auth API", version: "1.0.0" }
161+ let shape = securityShape (writeJSON spec)
162+ expectToBe 1 shape.requirementCount
163+ expectToBe 3 shape.schemeCount
test/Test/Operators.purs+4
@@ -16,6 +16,8 @@ import Test.ContentTypeTest as ContentTypeTest
1616import Test.ExamplesTest as ExamplesTest
1717import Test.ComprehensiveExample as ComprehensiveExample
1818import CompileFailTest.Spec as CompileFailTest
19+import Test.PublicSurfaceTest as PublicSurfaceTest
20+import Test.StreamingTest as StreamingTest
1921
2022spec :: Effect ViTest
2123spec = do
@@ -69,6 +71,8 @@ spec = do
6971 _ <- ExamplesTest.testComplexExampleObject
7072 _ <- ComprehensiveExample.testComprehensiveExample
7173 _ <- SchemaComponentTest.testSchemaComponents
74+ _ <- PublicSurfaceTest.spec
75+ _ <- StreamingTest.spec
7276 CompileFailTest.testCompileFail
7377
7478main :: ViTest
test/Test/PublicSurfaceTest.pursnew file+305
@@ -0,0 +1,305 @@
1+module Test.PublicSurfaceTest where
2+
3+import Prelude
4+
5+import Data.Array (length)
6+import Data.Either (Either(..))
7+import Data.Generic.Rep (class Generic)
8+import Data.Maybe (Maybe(..))
9+import Data.Undefined.NoProblem (opt)
10+import Data.String as String
11+import Data.Variant (Variant)
12+import Data.Variant as Variant
13+import Effect (Effect)
14+import Effect.Aff (Aff)
15+import Foreign.Object as FObject
16+import Type.Function (type (#))
17+import Type.Proxy (Proxy(..))
18+import ViTest (ViTest, describe, test)
19+import ViTest.Expect (expectToBe)
20+import Yoga.HTTP.API.Path (Capture, Lit, Path, Required, Root, parseParam, parsePath, pathPattern, type (/), type (:), type (:?))
21+import Yoga.HTTP.API.Route (ApiKeyCookie(..), ApiKeyHeader(..), BasicAuth(..), BearerToken(..), CustomContentType, DELETE, Deprecated, DigestAuth(..), Enum, Example, Format, FormData, GET, JSON, MaxLength, Maximum, MinLength, Minimum, MultipartFormData, NoBody, NoRequest, Nullable, PATCH, POST, PUT, PlainText, QUERY, Response(..), Route, Schema, Title, XML, apiHandlers, buildOpenAPISpec', default, deprecated, description, enum, example, format, headerValueType, maxLength, maximum, minLength, minimum, mkHandler, nullable, options, parseHeader, pattern, printHeader, runHandler, statusCodeFor, statusCodeToString, title)
22+import Yoga.HTTP.API.Route.Handler (Request, class EncodingBody)
23+import Yoga.HTTP.API.Route.HeaderError (HeaderError(..))
24+import Yoga.HTTP.API.Route.OpenAPI (detectCookieSecurity, detectSecurity, getContentType, renderCookieParamsSchema, renderHeadersSchema, renderJSONSchema, renderPathParamsSchema, renderQueryParamsSchema, renderRequestBodySchema, renderResponseHeadersSchema)
25+import Yoga.HTTP.API.Route.OpenAPIMetadata (Description, Default, Pattern, links, type (:#))
26+import Yoga.HTTP.API.Route.RenderMethod (renderMethod)
27+import Yoga.HTTP.API.Route.Response (ok, respondNoBody, respondNoContent, respondNotModified, respondNothing, respondStatus, respondStatusWith)
28+import Yoga.HTTP.API.Route.RouteHandler (Handler)
29+import Yoga.HTTP.API.Route.StatusCode (class StatusCodeMap)
30+import Yoga.JSON (writeJSON)
31+
32+data Access = Guest | Member | Admin
33+
34+derive instance Generic Access _
35+
36+expectEqual :: forall a. Eq a => Show a => a -> a -> Aff Unit
37+expectEqual expected actual = expectToBe (show expected) (show actual)
38+
39+code :: forall @label. StatusCodeMap label => String
40+code = statusCodeToString (statusCodeFor (Proxy :: Proxy label))
41+
42+decodeBody :: forall encoding body. EncodingBody encoding body => Proxy encoding -> body -> body
43+decodeBody _ = identity
44+
45+type HandlerRoute =
46+ Route GET
47+ (Path ("items" / "id" : Int) :? { limit :: Required Int, search :: String })
48+ { headers :: { requestId :: String }, body :: JSON { suffix :: String } }
49+ (ok :: { body :: String })
50+
51+handler :: Handler HandlerRoute
52+handler = mkHandler \{ path, query, headers, body } ->
53+ pure $ ok (headers.requestId <> ":" <> show path.id <> ":" <> show query.limit <> ":" <> show query.search <> ":" <> body.suffix)
54+
55+type WrappedHandlerRoute =
56+ Route POST "wrapped" (Request { body :: FormData String }) (ok :: { body :: String })
57+
58+wrappedHandler :: Handler WrappedHandlerRoute
59+wrappedHandler = mkHandler \{ body } -> pure $ ok body
60+
61+type NoRequestRoute =
62+ Route GET Root NoRequest (ok :: { body :: String })
63+
64+noRequestHandler :: Handler NoRequestRoute
65+noRequestHandler = mkHandler \_ -> pure $ ok "healthy"
66+
67+type ServerAPI =
68+ { health :: NoRequestRoute }
69+
70+testMethodsAndPaths :: Effect ViTest
71+testMethodsAndPaths = describe "public methods and paths" do
72+ _ <- test "renders every method token" do
73+ expectEqual
74+ [ "get", "post", "put", "delete", "patch", "query" ]
75+ [ renderMethod (Proxy :: Proxy GET)
76+ , renderMethod (Proxy :: Proxy POST)
77+ , renderMethod (Proxy :: Proxy PUT)
78+ , renderMethod (Proxy :: Proxy DELETE)
79+ , renderMethod (Proxy :: Proxy PATCH)
80+ , renderMethod (Proxy :: Proxy QUERY)
81+ ]
82+
83+ _ <- test "renders root, literal, capture, sugar, wrapped, and query paths" do
84+ expectEqual
85+ [ "/", "/", "/users", "/:id", "/users/:id/posts", "/users/:id", "/users/:id" ]
86+ [ pathPattern (Proxy :: Proxy Root)
87+ , pathPattern (Proxy :: Proxy "/")
88+ , pathPattern (Proxy :: Proxy (Lit "users"))
89+ , pathPattern (Proxy :: Proxy (Capture "id" Int))
90+ , pathPattern (Proxy :: Proxy (Path (Lit "users" / Capture "id" Int / Lit "posts")))
91+ , pathPattern (Proxy :: Proxy (Path ("users" / "id" : Int)))
92+ , pathPattern (Proxy :: Proxy (Path ("users" / "id" : Int) :? { q :: String }))
93+ ]
94+
95+ _ <- test "parses primitive parameters and rejects malformed values" do
96+ expectEqual (Right "") (parseParam "" :: Either String String)
97+ expectEqual (Right 42) (parseParam "42" :: Either String Int)
98+ expectEqual (Left "Expected an integer but got: 4.2") (parseParam "4.2" :: Either String Int)
99+ expectEqual (Right 4.2) (parseParam "4.2" :: Either String Number)
100+ expectEqual (Left "Expected a number but got: nope") (parseParam "nope" :: Either String Number)
101+
102+ test "parses exact paths into correctly named captures" do
103+ expectEqual (Just {}) (parsePath (Proxy :: Proxy (Path Root)) "/")
104+ expectEqual Nothing (parsePath (Proxy :: Proxy (Path Root)) "")
105+ expectEqual (Just {}) (parsePath (Proxy :: Proxy (Path (Lit "users"))) "/users")
106+ expectEqual Nothing (parsePath (Proxy :: Proxy (Path (Lit "users"))) "/posts")
107+ expectEqual (Just { id: 7 }) (parsePath (Proxy :: Proxy (Path (Capture "id" Int))) "/7")
108+ expectEqual Nothing (parsePath (Proxy :: Proxy (Path (Capture "id" Int))) "/bad")
109+ expectEqual (Just { id: 7 }) (parsePath (Proxy :: Proxy (Path (Lit "users" / Capture "id" Int))) "/users/7")
110+ expectEqual Nothing (parsePath (Proxy :: Proxy (Path (Lit "users" / Capture "id" Int))) "/users/7/extra")
111+
112+testHeadersAndAuth :: Effect ViTest
113+testHeadersAndAuth = describe "header values, errors, and authentication" do
114+ _ <- test "roundtrips strings, integers, and optional headers" do
115+ expectEqual (Right "alpha") (parseHeader "alpha" :: Either String String)
116+ expectEqual "alpha" (printHeader "alpha")
117+ expectEqual (Right 12) (parseHeader "12" :: Either String Int)
118+ expectEqual (Left "not a valid integer (got: twelve)") (parseHeader "twelve" :: Either String Int)
119+ expectEqual "12" (printHeader 12)
120+ expectEqual (Right (Just 12)) (parseHeader "12" :: Either String (Maybe Int))
121+ expectEqual (Right Nothing) (parseHeader "twelve" :: Either String (Maybe Int))
122+ expectEqual "" (printHeader (Nothing :: Maybe Int))
123+
124+ _ <- test "validates and roundtrips every authentication header type" do
125+ expectEqual (Right (BearerToken "token")) (parseHeader "Bearer token" :: Either String BearerToken)
126+ expectEqual (Left "missing 'Bearer ' prefix") (parseHeader "bearer token" :: Either String BearerToken)
127+ expectEqual "Bearer token" (printHeader (BearerToken "token"))
128+ expectEqual (Right (BasicAuth "dXNlcjpwYXNz")) (parseHeader "Basic dXNlcjpwYXNz" :: Either String BasicAuth)
129+ expectEqual (Left "missing 'Basic ' prefix") (parseHeader "dXNlcjpwYXNz" :: Either String BasicAuth)
130+ expectEqual "Basic dXNlcjpwYXNz" (printHeader (BasicAuth "dXNlcjpwYXNz"))
131+ expectEqual (Right (ApiKeyHeader "key")) (parseHeader "key" :: Either String ApiKeyHeader)
132+ expectEqual "key" (printHeader (ApiKeyHeader "key"))
133+ expectEqual (Right (ApiKeyCookie "session")) (parseHeader "session" :: Either String ApiKeyCookie)
134+ expectEqual "session" (printHeader (ApiKeyCookie "session"))
135+ expectEqual (Right (DigestAuth "Digest realm=api")) (parseHeader "Digest realm=api" :: Either String DigestAuth)
136+ expectEqual (Left "missing 'Digest ' prefix") (parseHeader "realm=api" :: Either String DigestAuth)
137+ expectEqual "Digest realm=api" (printHeader (DigestAuth "Digest realm=api"))
138+
139+ test "distinguishes missing headers from invalid values" do
140+ expectEqual "Missing required header: x-request-id" (show (MissingHeader "x-request-id"))
141+ expectEqual "Invalid header 'content-length': not an integer" (show (InvalidHeaderValue "content-length" "not an integer"))
142+ expectToBe true (MissingHeader "x" /= InvalidHeaderValue "x" "missing")
143+
144+testStatusesAndResponses :: Effect ViTest
145+testStatusesAndResponses = describe "status and response constructors" do
146+ _ <- test "maps every public status label" do
147+ expectEqual
148+ [ "100", "101", "102", "103"
149+ , "200", "201", "202", "203", "204", "205", "206", "207", "208", "226"
150+ , "300", "301", "302", "303", "304", "305", "307", "308"
151+ , "400", "401", "402", "403", "404", "405", "406", "407", "408", "409", "410", "411", "412", "413", "414", "415", "416", "417", "418", "421", "422", "423", "424", "425", "426", "428", "429", "431", "451"
152+ , "500", "501", "502", "503", "504", "505", "506", "507", "508", "510", "511"
153+ ]
154+ [ code @"continue", code @"switchingProtocols", code @"processing", code @"earlyHints"
155+ , code @"ok", code @"created", code @"accepted", code @"nonAuthoritativeInformation", code @"noContent", code @"resetContent", code @"partialContent", code @"multiStatus", code @"alreadyReported", code @"imUsed"
156+ , code @"multipleChoices", code @"movedPermanently", code @"found", code @"seeOther", code @"notModified", code @"useProxy", code @"temporaryRedirect", code @"permanentRedirect"
157+ , code @"badRequest", code @"unauthorized", code @"paymentRequired", code @"forbidden", code @"notFound", code @"methodNotAllowed", code @"notAcceptable", code @"proxyAuthenticationRequired", code @"requestTimeout", code @"conflict", code @"gone", code @"lengthRequired", code @"preconditionFailed", code @"payloadTooLarge", code @"uriTooLong", code @"unsupportedMediaType", code @"rangeNotSatisfiable", code @"expectationFailed", code @"imATeapot", code @"misdirectedRequest", code @"unprocessableEntity", code @"locked", code @"failedDependency", code @"tooEarly", code @"upgradeRequired", code @"preconditionRequired", code @"tooManyRequests", code @"requestHeaderFieldsTooLarge", code @"unavailableForLegalReasons"
158+ , code @"internalServerError", code @"notImplemented", code @"badGateway", code @"serviceUnavailable", code @"gatewayTimeout", code @"httpVersionNotSupported", code @"variantAlsoNegotiates", code @"insufficientStorage", code @"loopDetected", code @"notExtended", code @"networkAuthenticationRequired"
159+ ]
160+
161+ _ <- test "constructs numeric responses with status-derived labels" do
162+ let
163+ plain :: Variant (ok :: Response () String)
164+ plain = respondStatus @200 "body"
165+ withHeaders :: Variant (created :: Response (location :: String) String)
166+ withHeaders = respondStatusWith @201 { location: "/items/1" } "created"
167+ expectEqual "body" (Variant.match { ok: \(Response r) -> r.body } plain)
168+ expectEqual "/items/1:created" (Variant.match { created: \(Response r) -> r.headers.location <> ":" <> r.body } withHeaders)
169+
170+ test "constructs bodyless and empty standard responses" do
171+ let
172+ bodyless :: Variant (noContent :: Response (etag :: String) Unit)
173+ bodyless = respondNoBody (Proxy :: Proxy "noContent") { etag: "v1" }
174+ nothing :: Variant (accepted :: Response () Unit)
175+ nothing = respondNothing @"accepted"
176+ noContentResponse :: Variant (noContent :: Response () Unit)
177+ noContentResponse = respondNoContent
178+ notModifiedResponse :: Variant (notModified :: Response () Unit)
179+ notModifiedResponse = respondNotModified
180+ expectEqual "v1" (Variant.match { noContent: \(Response r) -> r.headers.etag } bodyless)
181+ expectEqual unit (Variant.match { accepted: \(Response r) -> r.body } nothing)
182+ expectEqual unit (Variant.match { noContent: \(Response r) -> r.body } noContentResponse)
183+ expectEqual unit (Variant.match { notModified: \(Response r) -> r.body } notModifiedResponse)
184+
185+testMetadataAndOpenAPI :: Effect ViTest
186+testMetadataAndOpenAPI = describe "metadata and OpenAPI primitives" do
187+ _ <- test "extracts every composable schema metadata value" do
188+ let p = Proxy :: Proxy (String # Description "identifier" # Example "abc" # Format "uuid" # Minimum 1 # Maximum 9 # Pattern "^[a-z]+$" # MinLength 2 # MaxLength 8 # Title "Identifier" # Nullable # Default "abc" # Deprecated)
189+ expectEqual (Just "identifier") (description p)
190+ expectEqual (Just "abc") (example p)
191+ expectEqual (Just "uuid") (format p)
192+ expectEqual (Just 1) (minimum p)
193+ expectEqual (Just 9) (maximum p)
194+ expectEqual (Just "^[a-z]+$") (pattern p)
195+ expectEqual (Just 2) (minLength p)
196+ expectEqual (Just 8) (maxLength p)
197+ expectEqual (Just "Identifier") (title p)
198+ expectEqual true (nullable p)
199+ expectEqual (Just "abc") (default p)
200+ expectEqual true (deprecated p)
201+
202+ _ <- test "extracts generic enums and chained links" do
203+ expectEqual (Just [ "Guest", "Member", "Admin" ]) (enum (Proxy :: Proxy (Enum Access)))
204+ let
205+ metadata = links (Proxy :: Proxy (((((String :# "getItem") "getItemById" (id :: "$response.body#/id")) :# "getOwner") "getOwnerById" (id :: "$response.body#/ownerId"))))
206+ expectEqual 2 (length metadata)
207+ expectEqual [ "getOwner", "getItem" ] (map _.name metadata)
208+ expectEqual [ "getOwnerById", "getItemById" ] (map _.operationId metadata)
209+ expectToBe true (String.contains (String.Pattern "$response.body#/ownerId") (writeJSON metadata))
210+
211+ _ <- test "maps every request content encoding" do
212+ expectEqual
213+ [ "", "application/json", "application/x-www-form-urlencoded", "multipart/form-data", "text/plain", "application/xml", "application/vnd.test+json", "application/json" ]
214+ [ getContentType (Proxy :: Proxy NoBody)
215+ , getContentType (Proxy :: Proxy (JSON String))
216+ , getContentType (Proxy :: Proxy (FormData String))
217+ , getContentType (Proxy :: Proxy (MultipartFormData String))
218+ , getContentType (Proxy :: Proxy PlainText)
219+ , getContentType (Proxy :: Proxy (XML String))
220+ , getContentType (Proxy :: Proxy (CustomContentType "application/vnd.test+json" String))
221+ , getContentType (Proxy :: Proxy (Schema "Text" String))
222+ ]
223+
224+ _ <- test "unwraps every request encoding to its handler body" do
225+ expectEqual "json" (decodeBody (Proxy :: Proxy (JSON String)) "json")
226+ expectEqual "form" (decodeBody (Proxy :: Proxy (FormData String)) "form")
227+ expectEqual "multipart" (decodeBody (Proxy :: Proxy (MultipartFormData String)) "multipart")
228+ expectEqual "text" (decodeBody (Proxy :: Proxy PlainText) "text")
229+ expectEqual "xml" (decodeBody (Proxy :: Proxy (XML String)) "xml")
230+ expectEqual "custom" (decodeBody (Proxy :: Proxy (CustomContentType "application/vnd.test+json" String)) "custom")
231+
232+ _ <- test "reports header schema types through metadata wrappers" do
233+ expectEqual [ "string", "integer", "integer", "string" ]
234+ [ headerValueType (Proxy :: Proxy String)
235+ , headerValueType (Proxy :: Proxy Int)
236+ , headerValueType (Proxy :: Proxy (Maybe Int))
237+ , headerValueType (Proxy :: Proxy (Description "token" BearerToken))
238+ ]
239+
240+ _ <- test "builds explicit server metadata with buildOpenAPISpec'" do
241+ let
242+ document = buildOpenAPISpec' @ServerAPI
243+ (options { title: "Server API", version: "1.0.0" })
244+ { servers: opt [ { url: "https://api.example.test", description: Just "production" } ] }
245+ json = writeJSON document
246+ expectToBe true (String.contains (String.Pattern "\"servers\"") json)
247+ expectToBe true (String.contains (String.Pattern "https://api.example.test") json)
248+
249+ test "renders public OpenAPI schema and security seams" do
250+ expectEqual
251+ [ 0, 1, 1, 1, 2, 1, 0, 1, 1, 1 ]
252+ [ length (renderHeadersSchema (Proxy :: Proxy ()))
253+ , length (renderHeadersSchema (Proxy :: Proxy (requestId :: String)))
254+ , length (renderCookieParamsSchema (Proxy :: Proxy (theme :: String)))
255+ , length (renderPathParamsSchema (Proxy :: Proxy (id :: Int)))
256+ , length (renderQueryParamsSchema (Proxy :: Proxy (limit :: Int, search :: Maybe String)))
257+ , FObject.size (renderResponseHeadersSchema (Proxy :: Proxy (etag :: String)))
258+ , length (detectSecurity (Proxy :: Proxy (requestId :: String)))
259+ , length (detectSecurity (Proxy :: Proxy (authorization :: BearerToken, apiKey :: ApiKeyHeader)))
260+ , length (detectCookieSecurity (Proxy :: Proxy (session :: ApiKeyCookie)))
261+ , length (detectCookieSecurity (Proxy :: Proxy (primary :: ApiKeyCookie, secondary :: ApiKeyCookie)))
262+ ]
263+ case renderRequestBodySchema (Proxy :: Proxy NoBody) of
264+ Nothing -> expectToBe true true
265+ Just _ -> expectToBe "no request body" "unexpected request body"
266+ case renderRequestBodySchema (Proxy :: Proxy (JSON { name :: String })) of
267+ Nothing -> expectToBe "request body" "missing"
268+ Just body -> expectEqual true body.required
269+ expectToBe true (String.contains (String.Pattern "nullable") (writeJSON (renderJSONSchema (Proxy :: Proxy (Maybe String)))))
270+
271+testHandlers :: Effect ViTest
272+testHandlers = describe "route and API handler combinators" do
273+ _ <- test "mkHandler/runHandler preserve typed path, query, header, and body inputs" do
274+ response <- runHandler handler
275+ { path: { id: 7 }
276+ , query: { limit: 5, search: Just "red" }
277+ , headers: { requestId: "req" }
278+ , body: { suffix: "done" }
279+ }
280+ expectEqual "req:7:5:(Just \"red\"):done" (Variant.match { ok: \(Response r) -> r.body } response)
281+
282+ _ <- test "apiHandlers preserves a complete named handler record" do
283+ let handlers = apiHandlers @{ getItem :: HandlerRoute } { getItem: handler }
284+ response <- runHandler handlers.getItem
285+ { path: { id: 1 }
286+ , query: { limit: 2, search: Nothing }
287+ , headers: { requestId: "r" }
288+ , body: { suffix: "x" }
289+ }
290+ expectEqual "r:1:2:Nothing:x" (Variant.match { ok: \(Response r) -> r.body } response)
291+
292+ test "Request and NoRequest wrappers compute executable handler inputs" do
293+ wrapped <- runHandler wrappedHandler { path: {}, query: {}, headers: {}, body: "form body" }
294+ healthy <- runHandler noRequestHandler { path: {}, query: {}, headers: {}, body: unit }
295+ expectEqual "form body" (Variant.match { ok: \(Response r) -> r.body } wrapped)
296+ expectEqual "healthy" (Variant.match { ok: \(Response r) -> r.body } healthy)
297+
298+spec :: Effect ViTest
299+spec = do
300+ _ <- testMethodsAndPaths
301+ _ <- testHeadersAndAuth
302+ _ <- testStatusesAndResponses
303+ _ <- testMetadataAndOpenAPI
304+ testHandlers
305+
test/Test/StreamingTest.jsnew file+43
@@ -0,0 +1,43 @@
1+const canceler = (_cancelError, _onError, onSuccess) => onSuccess();
2+
3+export const collectImpl = (stream) => (onError, onSuccess) => {
4+ const reader = stream.getReader();
5+ const values = [];
6+ const pump = () => {
7+ reader.read().then(
8+ ({ done, value }) => {
9+ if (done) {
10+ reader.releaseLock();
11+ onSuccess(values);
12+ } else {
13+ values.push(value);
14+ pump();
15+ }
16+ },
17+ onError,
18+ );
19+ };
20+ pump();
21+ return canceler;
22+};
23+
24+export const readOneAndCancelImpl = (stream) => (onError, onSuccess) => {
25+ const reader = stream.getReader();
26+ reader.read().then(
27+ ({ done, value }) => {
28+ if (done) {
29+ onError(new Error("stream closed before its first chunk"));
30+ return;
31+ }
32+ reader.cancel("test complete").then(
33+ () => {
34+ reader.releaseLock();
35+ onSuccess(value);
36+ },
37+ onError,
38+ );
39+ },
40+ onError,
41+ );
42+ return canceler;
43+};
test/Test/StreamingTest.pursnew file+82
@@ -0,0 +1,82 @@
1+module Test.StreamingTest where
2+
3+import Prelude
4+
5+import Data.Array as Array
6+import Data.Variant as Variant
7+import Data.Maybe (Maybe(..))
8+import Effect (Effect)
9+import Effect.Aff.Compat (EffectFnAff, fromEffectFnAff)
10+import Effect.Class (liftEffect)
11+import Effect.Ref as Ref
12+import Type.Proxy (Proxy(..))
13+import Yoga.HTTP.API.Route
14+ ( GET
15+ , Handler
16+ , Request
17+ , Response(..)
18+ , Route
19+ , Streaming
20+ , StreamChunk
21+ , mkHandler
22+ , respondNoHeaders
23+ , streaming
24+ , textChunk
25+ , runHandlerWithCookies
26+ )
27+import ViTest (ViTest, describe, test)
28+import ViTest.Expect (expectToBe)
29+import Yoga.HTTP.API.Route.OpenAPI (getContentType)
30+
31+foreign import collectImpl :: Streaming StreamChunk -> EffectFnAff (Array String)
32+
33+type CookieRoute = Route GET "cookie"
34+ (Request { cookies :: { session :: String } })
35+ (ok :: { body :: String })
36+
37+cookieHandler :: Handler CookieRoute
38+cookieHandler = mkHandler \{ cookies } ->
39+ pure $ respondNoHeaders @"ok" cookies.session
40+
41+foreign import readOneAndCancelImpl :: Streaming StreamChunk -> EffectFnAff String
42+
43+spec :: Effect ViTest
44+spec = describe "Define streaming responses and typed handlers" do
45+ test "build a stream whose pull returns chunks and Nothing closes it" do
46+ index <- liftEffect $ Ref.new 0
47+ stream <- liftEffect $ streaming
48+ { pull: do
49+ current <- liftEffect $ Ref.read index
50+ liftEffect $ Ref.modify_ (_ + 1) index
51+ pure $ textChunk <$> Array.index [ "first", "second" ] current
52+ , cancel: pure unit
53+ }
54+ chunks <- fromEffectFnAff (collectImpl stream)
55+ expectToBe true (chunks == [ "first", "second" ])
56+
57+ test "put cleanup in cancel so consumers can release resources" do
58+ cancelled <- liftEffect $ Ref.new false
59+ stream <- liftEffect $ streaming
60+ { pull: pure (Just (textChunk "chunk"))
61+ , cancel: Ref.write true cancelled
62+ }
63+ chunk <- fromEffectFnAff (readOneAndCancelImpl stream)
64+ didCancel <- liftEffect $ Ref.read cancelled
65+ expectToBe "chunk" chunk
66+ expectToBe true didCancel
67+
68+ test "publishes an explicit binary response content type" do
69+ expectToBe "application/octet-stream"
70+ (getContentType (Proxy :: Proxy (Streaming StreamChunk)))
71+
72+ test "Request cookies → mkHandler { cookies }" do
73+ result <- runHandlerWithCookies cookieHandler
74+ { path: {}
75+ , query: {}
76+ , headers: {}
77+ , cookies: { session: "abc123" }
78+ , body: unit
79+ }
80+ case Variant.prj (Proxy :: Proxy "ok") result of
81+ Just (Response response) -> expectToBe "abc123" response.body
82+ Nothing -> expectToBe true false