git.iko.soy/rowtype-yoga/purescript-yoga-http-api
on feat/custom-type-errors help sign in
feat: Record-based API type with named routes for client derivation

- Add record-based API composition: define APIs as records where labels become operationIds in OpenAPI output - Add CollectNamedOperationsRL to walk record RowList and inject operationId from record labels into OpenAPI JSON - Add CollectOperations (Record row) instance replacing Tuple-based composition - Add APIHandlers typeclass mapping API record to handler record - Add apiHandlers function with VTA support (apiHandlers @MyAPI) - Remove Tuple/(/\) route composition - Remove route smart constructor (unused phantom type) - Remove Warn from HasOperationMetadata default instance - Clean up Route.purs re-exports, remove internal Render*/Detect* classes and Data.Variant re-export

commitb7abb297bdbba168ee5f6ae2719194de2cd9b72e authorMark <mark.eibes@gmail.com> dateSat 7 Feb 2026, 12:50 (8 months ago) parenta1f9ddf
9 files changed+570−39
src/Yoga/HTTP/API/Route.purs+4−8 src/Yoga/HTTP/API/Route/OpenAPI.js+6 src/Yoga/HTTP/API/Route/OpenAPI.purs+36−9 src/Yoga/HTTP/API/Route/OpenAPIMetadata.purs+2−2 src/Yoga/HTTP/API/Route/Response.purs+378 src/Yoga/HTTP/API/Route/Route.purs+1−19 src/Yoga/HTTP/API/Route/RouteHandler.purs+33 test/APIRecordTest.Spec.purs+104 test/Test.Operators.purs+6−1
src/Yoga/HTTP/API/Route.purs+4−8
@@ -8,26 +8,22 @@ module Yoga.HTTP.API.Route
88 , module Yoga.HTTP.API.Route.BearerToken
99 , module Yoga.HTTP.API.Route.Response
1010 , module Yoga.HTTP.API.Route.Handler
11− , module Yoga.HTTP.API.Route.RenderMethod
1211 , module Yoga.HTTP.API.Route.OpenAPI
1312 , module Yoga.HTTP.API.Route.OpenAPIMetadata
1413 , module Yoga.HTTP.API.Route.Route
1514 , module Yoga.HTTP.API.Route.RouteHandler
16− , module Data.Variant
1715 ) where
1816
19−import Data.Variant (Variant)
2017import Yoga.HTTP.API.Path (Path, Root, Lit, Capture, PathCons, Param, QueryParams, Required, type (/), type (:), type (:?), class PathPattern, pathPattern, class ParseParam, parseParam, class ParsePath, parsePath)
2118import Yoga.HTTP.API.Route.BearerToken (BearerToken(..))
2219import Yoga.HTTP.API.Route.Encoding (JSON, FormData, NoBody)
23−import Yoga.HTTP.API.Route.Handler (HandlerFn, Request, class SegmentPathParams, class SegmentQueryParams, class SegmentQueryParamsRL, class EncodingBody, class CaptureParams, class RequestHeaders, class RequestBody)
20+import Yoga.HTTP.API.Route.Handler (HandlerFn, Request)
2421import Yoga.HTTP.API.Route.HeaderError (HeaderError(..))
2522import Yoga.HTTP.API.Route.HeaderValue (class HeaderValue, class HeaderValueType, headerValueType, parseHeader, printHeader)
2623import Yoga.HTTP.API.Route.Method (DELETE, GET, PATCH, POST, PUT)
27−import Yoga.HTTP.API.Route.OpenAPI (class CollectOperations, collectOperations, buildOpenAPISpec, buildOpenAPISpec', OpenAPISpec, ServerObject, class RenderHeadersSchema, class RenderHeadersSchemaRL, class RenderPathParamsSchema, class RenderPathParamsSchemaRL, class RenderQueryParamsSchema, class RenderQueryParamsSchemaRL, class RenderRequestBodySchema, class RenderResponseHeadersSchema, class RenderResponseHeadersSchemaRL, class RenderResponseSchema, class RenderVariantResponseSchemaRL, class RenderJSONSchema, class ToOpenAPI, renderHeadersSchema, renderHeadersSchemaRL, renderPathParamsSchema, renderPathParamsSchemaRL, renderQueryParamsSchema, renderQueryParamsSchemaRL, renderRequestBodySchema, renderResponseHeadersSchema, renderResponseHeadersSchemaRL, renderResponseSchema, renderVariantResponseSchemaRL, renderJSONSchema, toOpenAPI)
24+import Yoga.HTTP.API.Route.OpenAPI (class CollectOperations, collectOperations, buildOpenAPISpec, buildOpenAPISpec', OpenAPISpec, ServerObject, class ToOpenAPI, toOpenAPI)
2825import 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, OperationMetadata, Description, Example, Format, Minimum, Maximum, Pattern, MinLength, MaxLength, Title, Nullable, Default, Deprecated, Enum)
29−import Yoga.HTTP.API.Route.RenderMethod (class RenderMethod, renderMethod)
3026import Yoga.HTTP.API.Route.Response (Response(..), ResponseData, respondNoHeaders, respondWith)
31−import Yoga.HTTP.API.Route.Route (Route(..), route, class ConvertResponseVariant, class ConvertResponseVariantRL)
32−import Yoga.HTTP.API.Route.RouteHandler (Handler, class RouteHandler, mkHandler, runHandler)
27+import Yoga.HTTP.API.Route.Route (Route(..), class ConvertResponseVariant, class ConvertResponseVariantRL)
28+import Yoga.HTTP.API.Route.RouteHandler (Handler, class RouteHandler, mkHandler, runHandler, class APIHandlers, apiHandlers, class ApiRecord)
3329import Yoga.HTTP.API.Route.StatusCode (StatusCode(..), class StatusCodeMap, statusCodeFor, statusCodeToString)
src/Yoga/HTTP/API/Route/OpenAPI.js+6
@@ -7,3 +7,9 @@ export const stripKeys = (keys) => (obj) => {
77 }
88 return result;
99};
10+
11+export const setOperationId = (name) => (jsonStr) => {
12+ const obj = JSON.parse(jsonStr);
13+ obj.operationId = name;
14+ return JSON.stringify(obj);
15+};
src/Yoga/HTTP/API/Route/OpenAPI.purs+36−9
@@ -37,6 +37,8 @@ module Yoga.HTTP.API.Route.OpenAPI
3737 , toOpenAPI
3838 , class CollectOperations
3939 , collectOperations
40+ , class CollectNamedOperationsRL
41+ , collectNamedOperationsRL
4042 , OperationEntry
4143 , buildOpenAPISpec
4244 , buildOpenAPISpec'
@@ -54,7 +56,6 @@ import Data.String.Regex.Flags (global)
5456import Data.String.Regex.Unsafe (unsafeRegex)
5557import Data.Symbol (class IsSymbol, reflectSymbol)
5658import Data.Tuple (Tuple(..))
57−import Data.Tuple.Nested (type (/\), (/\))
5859import Foreign (Foreign)
5960import Foreign.Object as FObject
6061import Prim.Row as Row
@@ -873,17 +874,42 @@ type OperationEntry =
873874 }
874875
875876-- | Collect OpenAPI operations from a type-level structure of routes.
876−-- | Use Tuple (/\) to combine multiple routes:
877−-- | CollectOperations (RouteA /\ RouteB /\ RouteC)
877+-- | Use a Record to combine multiple named routes:
878+-- | CollectOperations { getUser :: RouteA, createUser :: RouteB }
878879class CollectOperations (routes :: Type) where
879880 collectOperations :: Proxy routes -> Array OperationEntry
880881
881−-- Tuple: recurse into both sides
882−instance (CollectOperations a, CollectOperations b) => CollectOperations (Tuple a b) where
883− collectOperations _ = collectOperations (Proxy :: Proxy a) <> collectOperations (Proxy :: Proxy b)
884−
885882-- Note: Route instance is defined in Route.purs to avoid import cycles
886883
884+--------------------------------------------------------------------------------
885+-- CollectNamedOperationsRL: Walk a record RowList and inject operationId
886+--------------------------------------------------------------------------------
887+
888+class CollectNamedOperationsRL (rl :: RowList Type) where
889+ collectNamedOperationsRL :: Proxy rl -> Array OperationEntry
890+
891+instance CollectNamedOperationsRL Nil where
892+ collectNamedOperationsRL _ = []
893+
894+instance
895+ ( IsSymbol label
896+ , CollectOperations routeType
897+ , CollectNamedOperationsRL tail
898+ ) =>
899+ CollectNamedOperationsRL (Cons label routeType tail) where
900+ collectNamedOperationsRL _ = do
901+ let
902+ name = reflectSymbol (Proxy :: Proxy label)
903+ entries = collectOperations (Proxy :: Proxy routeType)
904+ namedEntries = entries <#> \entry ->
905+ entry { operation = setOperationId name entry.operation }
906+ rest = collectNamedOperationsRL (Proxy :: Proxy tail)
907+ namedEntries <> rest
908+
909+-- Record instance: iterate record fields via RowList
910+instance (RowToList row rl, CollectNamedOperationsRL rl) => CollectOperations (Record row) where
911+ collectOperations _ = collectNamedOperationsRL (Proxy :: Proxy rl)
912+
887913--------------------------------------------------------------------------------
888914-- buildOpenAPISpec: Assemble a complete OpenAPI 3.0 document
889915--------------------------------------------------------------------------------
@@ -931,11 +957,11 @@ type ServerObject =
931957-- |
932958-- | Examples:
933959-- | -- Basic usage:
934−-- | buildOpenAPISpec @(HealthRoute /\ UserRoute)
960+-- | buildOpenAPISpec @MyAPI
935961-- | { title: "My API", version: "1.0.0" }
936962-- |
937963-- | -- With servers:
938−-- | buildOpenAPISpec' @(HealthRoute /\ UserRoute)
964+-- | buildOpenAPISpec' @MyAPI
939965-- | { title: "My API"
940966-- | , version: "1.0.0"
941967-- | , servers: Just
@@ -1003,3 +1029,4 @@ groupByPath = foldl insertOp FObject.empty
10031029-- FFI helpers
10041030foreign import unsafeParseJSON :: String -> Foreign
10051031foreign import stripKeys :: Array String -> Foreign -> Foreign
1032+foreign import setOperationId :: String -> String -> String
src/Yoga/HTTP/API/Route/OpenAPIMetadata.purs+2−2
@@ -57,7 +57,7 @@ import Data.String.Regex as Regex
5757import Data.String.Regex.Flags (noFlags)
5858import Data.Symbol (class IsSymbol, reflectSymbol)
5959import Foreign (Foreign)
60−import Prim.TypeError (class Warn, Text)
60+
6161import Type.Proxy (Proxy(..))
6262import Unsafe.Coerce (unsafeCoerce)
6363import Yoga.HTTP.API.Path (class ParseParam, parseParam)
@@ -611,7 +611,7 @@ class HasOperationMetadata :: Type -> Constraint
611611class HasOperationMetadata route where
612612 operationMetadata :: Proxy route -> OperationMetadata
613613
614−instance (Warn (Text "Using default operation metadata")) => HasOperationMetadata route where
614+instance HasOperationMetadata route where
615615 operationMetadata _ =
616616 { summary: Nothing
617617 , description: Nothing
src/Yoga/HTTP/API/Route/Response.purs+378
@@ -12,6 +12,131 @@ module Yoga.HTTP.API.Route.Response
1212 , respondNothing
1313 , respondNoContent
1414 , respondNotModified
15+ -- Status code helpers
16+, continue
17+, continueWith
18+, switchingProtocols
19+, switchingProtocolsWith
20+, processing
21+, processingWith
22+, earlyHints
23+, earlyHintsWith
24+, ok
25+, okWith
26+, created
27+, createdWith
28+, accepted
29+, acceptedWith
30+, nonAuthoritativeInformation
31+, nonAuthoritativeInformationWith
32+, noContent
33+, noContentWith
34+, resetContent
35+, resetContentWith
36+, partialContent
37+, partialContentWith
38+, multiStatus
39+, multiStatusWith
40+, alreadyReported
41+, alreadyReportedWith
42+, imUsed
43+, imUsedWith
44+, multipleChoices
45+, multipleChoicesWith
46+, movedPermanently
47+, movedPermanentlyWith
48+, found
49+, foundWith
50+, seeOther
51+, seeOtherWith
52+, notModified
53+, notModifiedWith
54+, useProxy
55+, useProxyWith
56+, temporaryRedirect
57+, temporaryRedirectWith
58+, permanentRedirect
59+, permanentRedirectWith
60+, badRequest
61+, badRequestWith
62+, unauthorized
63+, unauthorizedWith
64+, paymentRequired
65+, paymentRequiredWith
66+, forbidden
67+, forbiddenWith
68+, notFound
69+, notFoundWith
70+, methodNotAllowed
71+, methodNotAllowedWith
72+, notAcceptable
73+, notAcceptableWith
74+, proxyAuthenticationRequired
75+, proxyAuthenticationRequiredWith
76+, requestTimeout
77+, requestTimeoutWith
78+, conflict
79+, conflictWith
80+, gone
81+, goneWith
82+, lengthRequired
83+, lengthRequiredWith
84+, preconditionFailed
85+, preconditionFailedWith
86+, payloadTooLarge
87+, payloadTooLargeWith
88+, uriTooLong
89+, uriTooLongWith
90+, unsupportedMediaType
91+, unsupportedMediaTypeWith
92+, rangeNotSatisfiable
93+, rangeNotSatisfiableWith
94+, expectationFailed
95+, expectationFailedWith
96+, imATeapot
97+, imATeapotWith
98+, misdirectedRequest
99+, misdirectedRequestWith
100+, unprocessableEntity
101+, unprocessableEntityWith
102+, locked
103+, lockedWith
104+, failedDependency
105+, failedDependencyWith
106+, tooEarly
107+, tooEarlyWith
108+, upgradeRequired
109+, upgradeRequiredWith
110+, preconditionRequired
111+, preconditionRequiredWith
112+, tooManyRequests
113+, tooManyRequestsWith
114+, requestHeaderFieldsTooLarge
115+, requestHeaderFieldsTooLargeWith
116+, unavailableForLegalReasons
117+, unavailableForLegalReasonsWith
118+, internalServerError
119+, internalServerErrorWith
120+, notImplemented
121+, notImplementedWith
122+, badGateway
123+, badGatewayWith
124+, serviceUnavailable
125+, serviceUnavailableWith
126+, gatewayTimeout
127+, gatewayTimeoutWith
128+, httpVersionNotSupported
129+, httpVersionNotSupportedWith
130+, variantAlsoNegotiates
131+, variantAlsoNegotiatesWith
132+, insufficientStorage
133+, insufficientStorageWith
134+, loopDetected
135+, loopDetectedWith
136+, notExtended
137+, notExtendedWith
138+, networkAuthenticationRequired
139+, networkAuthenticationRequiredWith
15140 , module Data.Variant
16141 ) where
17142
@@ -240,3 +365,256 @@ respondNotModified
240365 => Variant r2
241366respondNotModified =
242367 unsafeCoerce (Variant.inj (Proxy :: Proxy "notModified") (Response { headers: {}, body: unit }))
368+
369+--------------------------------------------------------------------------------
370+-- Status Code Helper Functions
371+--------------------------------------------------------------------------------
372+
373+continue = respondNoHeaders @"continue"
374+
375+continueWith = respondWith (Proxy :: Proxy "continue")
376+
377+switchingProtocols = respondNoHeaders @"switchingProtocols"
378+
379+switchingProtocolsWith = respondWith (Proxy :: Proxy "switchingProtocols")
380+
381+processing = respondNoHeaders @"processing"
382+
383+processingWith = respondWith (Proxy :: Proxy "processing")
384+
385+earlyHints = respondNoHeaders @"earlyHints"
386+
387+earlyHintsWith = respondWith (Proxy :: Proxy "earlyHints")
388+
389+ok = respondNoHeaders @"ok"
390+
391+okWith = respondWith (Proxy :: Proxy "ok")
392+
393+created = respondNoHeaders @"created"
394+
395+createdWith = respondWith (Proxy :: Proxy "created")
396+
397+accepted = respondNoHeaders @"accepted"
398+
399+acceptedWith = respondWith (Proxy :: Proxy "accepted")
400+
401+nonAuthoritativeInformation = respondNoHeaders @"nonAuthoritativeInformation"
402+
403+nonAuthoritativeInformationWith = respondWith (Proxy :: Proxy "nonAuthoritativeInformation")
404+
405+noContent = respondNoHeaders @"noContent"
406+
407+noContentWith = respondWith (Proxy :: Proxy "noContent")
408+
409+resetContent = respondNoHeaders @"resetContent"
410+
411+resetContentWith = respondWith (Proxy :: Proxy "resetContent")
412+
413+partialContent = respondNoHeaders @"partialContent"
414+
415+partialContentWith = respondWith (Proxy :: Proxy "partialContent")
416+
417+multiStatus = respondNoHeaders @"multiStatus"
418+
419+multiStatusWith = respondWith (Proxy :: Proxy "multiStatus")
420+
421+alreadyReported = respondNoHeaders @"alreadyReported"
422+
423+alreadyReportedWith = respondWith (Proxy :: Proxy "alreadyReported")
424+
425+imUsed = respondNoHeaders @"imUsed"
426+
427+imUsedWith = respondWith (Proxy :: Proxy "imUsed")
428+
429+multipleChoices = respondNoHeaders @"multipleChoices"
430+
431+multipleChoicesWith = respondWith (Proxy :: Proxy "multipleChoices")
432+
433+movedPermanently = respondNoHeaders @"movedPermanently"
434+
435+movedPermanentlyWith = respondWith (Proxy :: Proxy "movedPermanently")
436+
437+found = respondNoHeaders @"found"
438+
439+foundWith = respondWith (Proxy :: Proxy "found")
440+
441+seeOther = respondNoHeaders @"seeOther"
442+
443+seeOtherWith = respondWith (Proxy :: Proxy "seeOther")
444+
445+notModified = respondNoHeaders @"notModified"
446+
447+notModifiedWith = respondWith (Proxy :: Proxy "notModified")
448+
449+useProxy = respondNoHeaders @"useProxy"
450+
451+useProxyWith = respondWith (Proxy :: Proxy "useProxy")
452+
453+temporaryRedirect = respondNoHeaders @"temporaryRedirect"
454+
455+temporaryRedirectWith = respondWith (Proxy :: Proxy "temporaryRedirect")
456+
457+permanentRedirect = respondNoHeaders @"permanentRedirect"
458+
459+permanentRedirectWith = respondWith (Proxy :: Proxy "permanentRedirect")
460+
461+badRequest = respondNoHeaders @"badRequest"
462+
463+badRequestWith = respondWith (Proxy :: Proxy "badRequest")
464+
465+unauthorized = respondNoHeaders @"unauthorized"
466+
467+unauthorizedWith = respondWith (Proxy :: Proxy "unauthorized")
468+
469+paymentRequired = respondNoHeaders @"paymentRequired"
470+
471+paymentRequiredWith = respondWith (Proxy :: Proxy "paymentRequired")
472+
473+forbidden = respondNoHeaders @"forbidden"
474+
475+forbiddenWith = respondWith (Proxy :: Proxy "forbidden")
476+
477+notFound = respondNoHeaders @"notFound"
478+
479+notFoundWith = respondWith (Proxy :: Proxy "notFound")
480+
481+methodNotAllowed = respondNoHeaders @"methodNotAllowed"
482+
483+methodNotAllowedWith = respondWith (Proxy :: Proxy "methodNotAllowed")
484+
485+notAcceptable = respondNoHeaders @"notAcceptable"
486+
487+notAcceptableWith = respondWith (Proxy :: Proxy "notAcceptable")
488+
489+proxyAuthenticationRequired = respondNoHeaders @"proxyAuthenticationRequired"
490+
491+proxyAuthenticationRequiredWith = respondWith (Proxy :: Proxy "proxyAuthenticationRequired")
492+
493+requestTimeout = respondNoHeaders @"requestTimeout"
494+
495+requestTimeoutWith = respondWith (Proxy :: Proxy "requestTimeout")
496+
497+conflict = respondNoHeaders @"conflict"
498+
499+conflictWith = respondWith (Proxy :: Proxy "conflict")
500+
501+gone = respondNoHeaders @"gone"
502+
503+goneWith = respondWith (Proxy :: Proxy "gone")
504+
505+lengthRequired = respondNoHeaders @"lengthRequired"
506+
507+lengthRequiredWith = respondWith (Proxy :: Proxy "lengthRequired")
508+
509+preconditionFailed = respondNoHeaders @"preconditionFailed"
510+
511+preconditionFailedWith = respondWith (Proxy :: Proxy "preconditionFailed")
512+
513+payloadTooLarge = respondNoHeaders @"payloadTooLarge"
514+
515+payloadTooLargeWith = respondWith (Proxy :: Proxy "payloadTooLarge")
516+
517+uriTooLong = respondNoHeaders @"uriTooLong"
518+
519+uriTooLongWith = respondWith (Proxy :: Proxy "uriTooLong")
520+
521+unsupportedMediaType = respondNoHeaders @"unsupportedMediaType"
522+
523+unsupportedMediaTypeWith = respondWith (Proxy :: Proxy "unsupportedMediaType")
524+
525+rangeNotSatisfiable = respondNoHeaders @"rangeNotSatisfiable"
526+
527+rangeNotSatisfiableWith = respondWith (Proxy :: Proxy "rangeNotSatisfiable")
528+
529+expectationFailed = respondNoHeaders @"expectationFailed"
530+
531+expectationFailedWith = respondWith (Proxy :: Proxy "expectationFailed")
532+
533+imATeapot = respondNoHeaders @"imATeapot"
534+
535+imATeapotWith = respondWith (Proxy :: Proxy "imATeapot")
536+
537+misdirectedRequest = respondNoHeaders @"misdirectedRequest"
538+
539+misdirectedRequestWith = respondWith (Proxy :: Proxy "misdirectedRequest")
540+
541+unprocessableEntity = respondNoHeaders @"unprocessableEntity"
542+
543+unprocessableEntityWith = respondWith (Proxy :: Proxy "unprocessableEntity")
544+
545+locked = respondNoHeaders @"locked"
546+
547+lockedWith = respondWith (Proxy :: Proxy "locked")
548+
549+failedDependency = respondNoHeaders @"failedDependency"
550+
551+failedDependencyWith = respondWith (Proxy :: Proxy "failedDependency")
552+
553+tooEarly = respondNoHeaders @"tooEarly"
554+
555+tooEarlyWith = respondWith (Proxy :: Proxy "tooEarly")
556+
557+upgradeRequired = respondNoHeaders @"upgradeRequired"
558+
559+upgradeRequiredWith = respondWith (Proxy :: Proxy "upgradeRequired")
560+
561+preconditionRequired = respondNoHeaders @"preconditionRequired"
562+
563+preconditionRequiredWith = respondWith (Proxy :: Proxy "preconditionRequired")
564+
565+tooManyRequests = respondNoHeaders @"tooManyRequests"
566+
567+tooManyRequestsWith = respondWith (Proxy :: Proxy "tooManyRequests")
568+
569+requestHeaderFieldsTooLarge = respondNoHeaders @"requestHeaderFieldsTooLarge"
570+
571+requestHeaderFieldsTooLargeWith = respondWith (Proxy :: Proxy "requestHeaderFieldsTooLarge")
572+
573+unavailableForLegalReasons = respondNoHeaders @"unavailableForLegalReasons"
574+
575+unavailableForLegalReasonsWith = respondWith (Proxy :: Proxy "unavailableForLegalReasons")
576+
577+internalServerError = respondNoHeaders @"internalServerError"
578+
579+internalServerErrorWith = respondWith (Proxy :: Proxy "internalServerError")
580+
581+notImplemented = respondNoHeaders @"notImplemented"
582+
583+notImplementedWith = respondWith (Proxy :: Proxy "notImplemented")
584+
585+badGateway = respondNoHeaders @"badGateway"
586+
587+badGatewayWith = respondWith (Proxy :: Proxy "badGateway")
588+
589+serviceUnavailable = respondNoHeaders @"serviceUnavailable"
590+
591+serviceUnavailableWith = respondWith (Proxy :: Proxy "serviceUnavailable")
592+
593+gatewayTimeout = respondNoHeaders @"gatewayTimeout"
594+
595+gatewayTimeoutWith = respondWith (Proxy :: Proxy "gatewayTimeout")
596+
597+httpVersionNotSupported = respondNoHeaders @"httpVersionNotSupported"
598+
599+httpVersionNotSupportedWith = respondWith (Proxy :: Proxy "httpVersionNotSupported")
600+
601+variantAlsoNegotiates = respondNoHeaders @"variantAlsoNegotiates"
602+
603+variantAlsoNegotiatesWith = respondWith (Proxy :: Proxy "variantAlsoNegotiates")
604+
605+insufficientStorage = respondNoHeaders @"insufficientStorage"
606+
607+insufficientStorageWith = respondWith (Proxy :: Proxy "insufficientStorage")
608+
609+loopDetected = respondNoHeaders @"loopDetected"
610+
611+loopDetectedWith = respondWith (Proxy :: Proxy "loopDetected")
612+
613+notExtended = respondNoHeaders @"notExtended"
614+
615+notExtendedWith = respondWith (Proxy :: Proxy "notExtended")
616+
617+networkAuthenticationRequired = respondNoHeaders @"networkAuthenticationRequired"
618+
619+networkAuthenticationRequiredWith = respondWith (Proxy :: Proxy "networkAuthenticationRequired")
620+
src/Yoga/HTTP/API/Route/Route.purs+1−19
@@ -1,6 +1,5 @@
11module Yoga.HTTP.API.Route.Route
22 ( Route(..)
3− , route
43 , class ConvertResponseVariant
54 , class ConvertResponseVariantRL
65 ) where
@@ -9,7 +8,7 @@ import Prelude
98
109import Data.Array as Array
1110import Data.Maybe (Maybe(..))
12−import Prim.Row (class Cons, class Lacks, class Union)
11+import Prim.Row (class Cons, class Lacks)
1312import Prim.RowList (class RowToList, RowList)
1413import Prim.RowList as RL
1514import Type.Proxy (Proxy(..))
@@ -51,23 +50,6 @@ instance convertResponseVariantRLCons ::
5150 ) =>
5251 ConvertResponseVariantRL (RL.Cons label recordType tail) acc1 out
5352
54−-- | Smart constructor for Route that allows partial request records.
55−-- |
56−-- | Users can specify only the fields they need:
57−-- | route (Proxy :: _ GET) (Proxy :: _ path) (Proxy :: _ (Request {})) -- no headers or body
58−-- | route (Proxy :: _ GET) (Proxy :: _ path) (Proxy :: _ (Request { body :: JSON User })) -- only body
59−-- | route (Proxy :: _ GET) (Proxy :: _ path) (Proxy :: _ (Request { headers :: { auth :: String } })) -- only headers
60−route
61− :: forall method segments partialRequest o_ fullHeaders fullBody userRespVariant internalRespVariant
62− . Union partialRequest o_ (headers :: fullHeaders, body :: fullBody)
63− => ConvertResponseVariant userRespVariant internalRespVariant
64− => Proxy method
65− -> Proxy segments
66− -> Proxy (Request (Record partialRequest))
67− -> Proxy userRespVariant
68− -> Route method segments (Request (Record partialRequest)) userRespVariant
69−route _ _ _ _ = Route
70−
7153instance
7254 ( RenderMethod method
7355 , PathPattern segments
src/Yoga/HTTP/API/Route/RouteHandler.purs+33
@@ -3,9 +3,14 @@ module Yoga.HTTP.API.Route.RouteHandler
33 , class RouteHandler
44 , mkHandler
55 , runHandler
6+ , class APIHandlers
7+ , apiHandlers
8+ , class ApiRecord
69 ) where
710
811import Prim.Row as Row
12+import Prim.RowList as RL
13+import Prim.RowList (class RowToList)
914import Unsafe.Coerce (unsafeCoerce)
1015import Yoga.HTTP.API.Route.Handler
1116 ( HandlerFn
@@ -66,3 +71,31 @@ runHandler
6671 => Handler route
6772 -> HandlerFn pathParams queryParams reqHeaders body respVariant
6873runHandler = unsafeCoerce
74+
75+--------------------------------------------------------------------------------
76+-- APIHandlers: Map an API record row to a handler record row
77+--------------------------------------------------------------------------------
78+
79+class APIHandlers (rl :: RL.RowList Type) (handlerRow :: Row Type) | rl -> handlerRow
80+
81+instance APIHandlers RL.Nil ()
82+
83+instance
84+ ( APIHandlers tail tailRow
85+ , Row.Cons label (Handler (Route method segments request resp)) tailRow handlerRow
86+ , Row.Lacks label tailRow
87+ ) =>
88+ APIHandlers (RL.Cons label (Route method segments request resp) tail) handlerRow
89+
90+class ApiRecord (api :: Type) (row :: Row Type) | api -> row
91+
92+instance ApiRecord (Record row) row
93+
94+apiHandlers
95+ :: forall @api apiRow rl handlerRow
96+ . ApiRecord api apiRow
97+ => RowToList apiRow rl
98+ => APIHandlers rl handlerRow
99+ => Record handlerRow
100+ -> Record handlerRow
101+apiHandlers handlers = handlers
test/APIRecordTest.Spec.pursnew file+104
@@ -0,0 +1,104 @@
1+module APIRecordTest.Spec where
2+
3+import Prelude
4+
5+import Data.Array as Array
6+import Data.String as String
7+import Effect (Effect)
8+import Effect.Aff (Aff)
9+import Type.Proxy (Proxy(..))
10+import Yoga.HTTP.API.Path (Path, Lit, type (/), type (:))
11+import Yoga.HTTP.API.Route (GET, POST, Route, Request, JSON, respondNoHeaders, buildOpenAPISpec, collectOperations, mkHandler, apiHandlers)
12+import Yoga.JSON (writeJSON)
13+import ViTest (ViTest, describe, test)
14+import ViTest.Expect (expectToBe)
15+
16+expectToEqual :: forall a. Eq a => a -> a -> Aff Unit
17+expectToEqual expected actual = expectToBe true (expected == actual)
18+
19+--------------------------------------------------------------------------------
20+-- Test API Definition
21+--------------------------------------------------------------------------------
22+
23+type User = { id :: Int, name :: String }
24+
25+type TestAPI =
26+ { getUser :: Route GET (Path ("users" / "id" : Int)) (Request {}) (ok :: { body :: User })
27+ , listUsers :: Route GET (Path (Lit "users")) (Request {}) (ok :: { body :: Array User })
28+ , createUser :: Route POST (Path (Lit "users")) (Request { body :: JSON User }) (created :: { body :: User })
29+ }
30+
31+--------------------------------------------------------------------------------
32+-- Tests
33+--------------------------------------------------------------------------------
34+
35+testCollectOperations :: Effect ViTest
36+testCollectOperations = describe "API Record - CollectOperations" do
37+ _ <- test "collects correct number of operations" do
38+ let ops = collectOperations (Proxy :: _ TestAPI)
39+ expectToEqual 3 (Array.length ops)
40+
41+ _ <- test "contains GET method" do
42+ let ops = collectOperations (Proxy :: _ TestAPI)
43+ let methods = ops <#> _.method
44+ expectToBe true (Array.elem "get" methods)
45+
46+ test "contains POST method" do
47+ let ops = collectOperations (Proxy :: _ TestAPI)
48+ let methods = ops <#> _.method
49+ expectToBe true (Array.elem "post" methods)
50+
51+testOperationIds :: Effect ViTest
52+testOperationIds = describe "API Record - OperationId" do
53+ _ <- test "injects getUser operationId" do
54+ let ops = collectOperations (Proxy :: _ TestAPI)
55+ let
56+ hasGetUser = ops # Array.any \op ->
57+ String.contains (String.Pattern "\"operationId\":\"getUser\"") op.operation
58+ expectToBe true hasGetUser
59+
60+ _ <- test "injects listUsers operationId" do
61+ let ops = collectOperations (Proxy :: _ TestAPI)
62+ let
63+ hasListUsers = ops # Array.any \op ->
64+ String.contains (String.Pattern "\"operationId\":\"listUsers\"") op.operation
65+ expectToBe true hasListUsers
66+
67+ test "injects createUser operationId" do
68+ let ops = collectOperations (Proxy :: _ TestAPI)
69+ let
70+ hasCreateUser = ops # Array.any \op ->
71+ String.contains (String.Pattern "\"operationId\":\"createUser\"") op.operation
72+ expectToBe true hasCreateUser
73+
74+testBuildOpenAPISpec :: Effect ViTest
75+testBuildOpenAPISpec = describe "API Record - buildOpenAPISpec" do
76+ _ <- test "generates valid OpenAPI spec with operationIds" do
77+ let spec = buildOpenAPISpec @TestAPI { title: "Test API", version: "1.0.0" }
78+ let json = writeJSON spec
79+ expectToBe true (String.contains (String.Pattern "\"operationId\":\"getUser\"") json)
80+ expectToBe true (String.contains (String.Pattern "\"operationId\":\"listUsers\"") json)
81+ expectToBe true (String.contains (String.Pattern "\"operationId\":\"createUser\"") json)
82+
83+ _ <- test "contains paths" do
84+ let spec = buildOpenAPISpec @TestAPI { title: "Test API", version: "1.0.0" }
85+ let json = writeJSON spec
86+ expectToBe true (String.contains (String.Pattern "/users/{id}") json)
87+ expectToBe true (String.contains (String.Pattern "/users") json)
88+
89+ test "contains API info" do
90+ let spec = buildOpenAPISpec @TestAPI { title: "Test API", version: "1.0.0" }
91+ let json = writeJSON spec
92+ expectToBe true (String.contains (String.Pattern "Test API") json)
93+ expectToBe true (String.contains (String.Pattern "1.0.0") json)
94+
95+testAPIHandlers :: Effect ViTest
96+testAPIHandlers = describe "API Record - apiHandlers" do
97+ test "compiles with correct handler record" do
98+ let
99+ _handlers = apiHandlers @TestAPI
100+ { getUser: mkHandler \{ path } -> pure $ respondNoHeaders @"ok" { id: path.id, name: "Alice" }
101+ , listUsers: mkHandler \_ -> pure $ respondNoHeaders @"ok" []
102+ , createUser: mkHandler \{ body } -> pure $ respondNoHeaders @"created" body
103+ }
104+ expectToBe true true
test/Test.Operators.purs+6−1
@@ -7,6 +7,7 @@ import OperatorTest.Spec as OperatorTest
77import ParserTest.Spec as ParserTest
88import VariantResponseTest.Spec as VariantResponseTest
99import MetadataValidationTest.Spec as MetadataValidationTest
10+import APIRecordTest.Spec as APIRecordTest
1011
1112spec :: Effect ViTest
1213spec = do
@@ -31,7 +32,11 @@ spec = do
3132 _ <- MetadataValidationTest.testMaxLengthValidation
3233 _ <- MetadataValidationTest.testMinimumValidation
3334 _ <- MetadataValidationTest.testMaximumValidation
34− MetadataValidationTest.testComposedValidation
35+ _ <- MetadataValidationTest.testComposedValidation
36+ _ <- APIRecordTest.testCollectOperations
37+ _ <- APIRecordTest.testOperationIds
38+ _ <- APIRecordTest.testBuildOpenAPISpec
39+ APIRecordTest.testAPIHandlers
3540
3641main :: ViTest
3742main = viTest spec