@@ -45,6 +45,23 @@ module Yoga.HTTP.API.Route.OpenAPI
4545 , ServerObject
4646 , OpenAPISpec
4747 , ResponseHeaderObject
48+ , class CollectSchemas
49+ , collectSchemas
50+ , class CollectSchemasRL
51+ , collectSchemasRL
52+ , class CollectVariantSchemasRL
53+ , collectVariantSchemasRL
54+ , class CollectRouteSchemas
55+ , collectRouteSchemas
56+ , class CollectRouteSchemasRL
57+ , collectRouteSchemasRL
58+ , class CollectSchemaNames
59+ , class CollectSchemaNamesRL
60+ , class CollectVariantSchemaNames
61+ , class CollectRouteSchemaNames
62+ , class CollectRouteSchemaNameRL
63+ , class ValidateNoDuplicatesRL
64+ , class ValidateSchemaNames
4865 ) where
4966
5067import Prelude
@@ -66,7 +83,7 @@ import Unsafe.Coerce (unsafeCoerce)
6683import Yoga.HTTP.API.Route.BearerToken (BearerToken)
6784import Yoga.HTTP.API.Route.Encoding (JSON, FormData, NoBody)
6885import Yoga.HTTP.API.Route.HeaderValue (class HeaderValueType, headerValueType)
69−import Yoga.HTTP.API.Route.OpenAPIMetadata (Description, Example, Format, Minimum, Maximum, Pattern, MinLength, MaxLength, Title, Nullable, Default, Deprecated, Enum, 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 HasOperationMetadata, operationMetadata)
86+import Yoga.HTTP.API.Route.OpenAPIMetadata (Description, Example, Format, Minimum, Maximum, Pattern, MinLength, MaxLength, Title, Nullable, Default, Deprecated, Enum, Schema, 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 HasOperationMetadata, operationMetadata)
7087import Yoga.HTTP.API.Route.Response (class ToResponse)
7188import Yoga.HTTP.API.Route.StatusCode (class StatusCodeMap, statusCodeFor, statusCodeToString)
7289import Yoga.JSON (class WriteForeign, writeImpl)
@@ -604,6 +621,14 @@ instance (RenderJSONSchema a, HasEnum (Enum a)) => RenderJSONSchema (Enum a) whe
604621 in
605622 unsafeCoerce withEnum
606623
624+-- Schema wrapper: generates $ref instead of inline schema
625+instance (IsSymbol name, RenderJSONSchema a) => RenderJSONSchema (Schema name a) where
626+ renderJSONSchema _ =
627+ let
628+ schemaName = reflectSymbol (Proxy :: Proxy name)
629+ in
630+ unsafeCoerce $ FObject.singleton "$ref" (unsafeCoerce $ "#/components/schemas/" <> schemaName)
631+
607632-- Other metadata wrappers (Minimum, Maximum, Pattern, MinLength, MaxLength, Title, Default)
608633-- are similar but require more complex constraints, so we'll skip them for now in body schemas
609634
@@ -863,6 +888,282 @@ class ToOpenAPI (route :: Type) where
863888toOpenAPI :: forall @a. ToOpenAPI a => String
864889toOpenAPI = toOpenAPIImpl (Proxy :: Proxy a)
865890
891+--------------------------------------------------------------------------------
892+-- CollectSchemas: Collect all Schema wrappers for component extraction
893+--------------------------------------------------------------------------------
894+
895+-- | Collects all Schema wrappers found in a type and returns their definitions
896+class CollectSchemas (ty :: Type) where
897+ collectSchemas :: Proxy ty -> FObject.Object Foreign
898+
899+-- Base cases: primitives don't contain schemas
900+instance CollectSchemas String where
901+ collectSchemas _ = FObject.empty
902+
903+instance CollectSchemas Int where
904+ collectSchemas _ = FObject.empty
905+
906+instance CollectSchemas Number where
907+ collectSchemas _ = FObject.empty
908+
909+instance CollectSchemas Boolean where
910+ collectSchemas _ = FObject.empty
911+
912+instance CollectSchemas Unit where
913+ collectSchemas _ = FObject.empty
914+
915+-- Array: recurse into item type
916+instance CollectSchemas a => CollectSchemas (Array a) where
917+ collectSchemas _ = collectSchemas (Proxy :: Proxy a)
918+
919+-- Maybe: recurse into inner type
920+instance CollectSchemas a => CollectSchemas (Maybe a) where
921+ collectSchemas _ = collectSchemas (Proxy :: Proxy a)
922+
923+-- Record: recurse through all fields
924+instance (RowToList row rl, CollectSchemasRL rl) => CollectSchemas (Record row) where
925+ collectSchemas _ = collectSchemasRL (Proxy :: Proxy rl)
926+
927+-- JSON wrapper: unwrap and recurse
928+instance CollectSchemas a => CollectSchemas (JSON a) where
929+ collectSchemas _ = collectSchemas (Proxy :: Proxy a)
930+
931+-- FormData wrapper: unwrap and recurse
932+instance CollectSchemas a => CollectSchemas (FormData a) where
933+ collectSchemas _ = collectSchemas (Proxy :: Proxy a)
934+
935+-- NoBody: no schemas
936+instance CollectSchemas NoBody where
937+ collectSchemas _ = FObject.empty
938+
939+-- Schema wrapper: COLLECT THIS + recurse into inner type
940+instance (IsSymbol name, RenderJSONSchema inner, CollectSchemas inner) => CollectSchemas (Schema name inner) where
941+ collectSchemas _ =
942+ let
943+ schemaName = reflectSymbol (Proxy :: Proxy name)
944+ -- Render the INNER type (not the Schema wrapper) for the component definition
945+ innerSchema = renderJSONSchema (Proxy :: Proxy inner)
946+ -- Also collect any nested schemas
947+ nestedSchemas = collectSchemas (Proxy :: Proxy inner)
948+ in
949+ FObject.insert schemaName innerSchema nestedSchemas
950+
951+-- Metadata wrappers: recurse through to find schemas
952+instance CollectSchemas a => CollectSchemas (Description desc a) where
953+ collectSchemas _ = collectSchemas (Proxy :: Proxy a)
954+
955+instance CollectSchemas a => CollectSchemas (Example ex a) where
956+ collectSchemas _ = collectSchemas (Proxy :: Proxy a)
957+
958+instance CollectSchemas a => CollectSchemas (Format fmt a) where
959+ collectSchemas _ = collectSchemas (Proxy :: Proxy a)
960+
961+instance CollectSchemas a => CollectSchemas (Minimum v a) where
962+ collectSchemas _ = collectSchemas (Proxy :: Proxy a)
963+
964+instance CollectSchemas a => CollectSchemas (Maximum v a) where
965+ collectSchemas _ = collectSchemas (Proxy :: Proxy a)
966+
967+instance CollectSchemas a => CollectSchemas (Pattern pat a) where
968+ collectSchemas _ = collectSchemas (Proxy :: Proxy a)
969+
970+instance CollectSchemas a => CollectSchemas (MinLength v a) where
971+ collectSchemas _ = collectSchemas (Proxy :: Proxy a)
972+
973+instance CollectSchemas a => CollectSchemas (MaxLength v a) where
974+ collectSchemas _ = collectSchemas (Proxy :: Proxy a)
975+
976+instance CollectSchemas a => CollectSchemas (Title t a) where
977+ collectSchemas _ = collectSchemas (Proxy :: Proxy a)
978+
979+instance CollectSchemas a => CollectSchemas (Nullable a) where
980+ collectSchemas _ = collectSchemas (Proxy :: Proxy a)
981+
982+instance CollectSchemas a => CollectSchemas (Default val a) where
983+ collectSchemas _ = collectSchemas (Proxy :: Proxy a)
984+
985+instance CollectSchemas a => CollectSchemas (Deprecated a) where
986+ collectSchemas _ = collectSchemas (Proxy :: Proxy a)
987+
988+instance CollectSchemas a => CollectSchemas (Enum a) where
989+ collectSchemas _ = collectSchemas (Proxy :: Proxy a)
990+
991+-- | Helper class to collect schemas from record fields
992+class CollectSchemasRL (rl :: RowList Type) where
993+ collectSchemasRL :: Proxy rl -> FObject.Object Foreign
994+
995+instance CollectSchemasRL Nil where
996+ collectSchemasRL _ = FObject.empty
997+
998+instance (IsSymbol name, CollectSchemas ty, CollectSchemasRL tail) => CollectSchemasRL (Cons name ty tail) where
999+ collectSchemasRL _ =
1000+ let
1001+ fieldSchemas = collectSchemas (Proxy :: Proxy ty)
1002+ restSchemas = collectSchemasRL (Proxy :: Proxy tail)
1003+ in
1004+ FObject.union fieldSchemas restSchemas
1005+
1006+-- | Collects schemas from all response bodies in a variant
1007+class CollectVariantSchemasRL (rl :: RowList Type) where
1008+ collectVariantSchemasRL :: Proxy rl -> FObject.Object Foreign
1009+
1010+instance CollectVariantSchemasRL Nil where
1011+ collectVariantSchemasRL _ = FObject.empty
1012+
1013+instance
1014+ ( ToResponse recordType headers body
1015+ , CollectSchemas body
1016+ , CollectVariantSchemasRL tail
1017+ ) =>
1018+ CollectVariantSchemasRL (Cons label recordType tail) where
1019+ collectVariantSchemasRL _ =
1020+ let
1021+ bodySchemas = collectSchemas (Proxy :: Proxy body)
1022+ restSchemas = collectVariantSchemasRL (Proxy :: Proxy tail)
1023+ in
1024+ FObject.union bodySchemas restSchemas
1025+
1026+-- | Collects schemas from a route's request and response types
1027+class CollectRouteSchemas (route :: Type) where
1028+ collectRouteSchemas :: Proxy route -> FObject.Object Foreign
1029+
1030+-- | Helper class for collecting schemas from a record of routes
1031+class CollectRouteSchemasRL (rl :: RowList Type) where
1032+ collectRouteSchemasRL :: Proxy rl -> FObject.Object Foreign
1033+
1034+instance CollectRouteSchemasRL Nil where
1035+ collectRouteSchemasRL _ = FObject.empty
1036+
1037+instance
1038+ ( CollectRouteSchemas routeType
1039+ , CollectRouteSchemasRL tail
1040+ ) =>
1041+ CollectRouteSchemasRL (Cons label routeType tail) where
1042+ collectRouteSchemasRL _ =
1043+ let
1044+ routeSchemas = collectRouteSchemas (Proxy :: Proxy routeType)
1045+ restSchemas = collectRouteSchemasRL (Proxy :: Proxy tail)
1046+ in
1047+ FObject.union routeSchemas restSchemas
1048+
1049+-- Record instance: iterate record fields via RowList
1050+instance (RowToList row rl, CollectRouteSchemasRL rl) => CollectRouteSchemas (Record row) where
1051+ collectRouteSchemas _ = collectRouteSchemasRL (Proxy :: Proxy rl)
1052+
1053+--------------------------------------------------------------------------------
1054+-- CollectSchemaNames: Type-level collision detection
1055+--------------------------------------------------------------------------------
1056+
1057+-- | Collects schema names at the type level as a Row.
1058+-- | Used with Row.Union + Row.Nub to detect name collisions at compile time.
1059+-- | Two Schema wrappers with the same name but different types cause a compile error.
1060+class CollectSchemaNames (ty :: Type) (names :: Row Type) | ty -> names
1061+
1062+instance CollectSchemaNames String ()
1063+instance CollectSchemaNames Int ()
1064+instance CollectSchemaNames Number ()
1065+instance CollectSchemaNames Boolean ()
1066+instance CollectSchemaNames Unit ()
1067+
1068+instance CollectSchemaNames a names => CollectSchemaNames (Array a) names
1069+instance CollectSchemaNames a names => CollectSchemaNames (Maybe a) names
1070+
1071+instance (RowToList row rl, CollectSchemaNamesRL rl names) => CollectSchemaNames (Record row) names
1072+
1073+instance CollectSchemaNames a names => CollectSchemaNames (JSON a) names
1074+instance CollectSchemaNames a names => CollectSchemaNames (FormData a) names
1075+instance CollectSchemaNames NoBody ()
1076+
1077+instance
1078+ ( CollectSchemaNames inner innerNames
1079+ , Row.Cons name inner innerNames names
1080+ , Row.Lacks name innerNames
1081+ ) =>
1082+ CollectSchemaNames (Schema name inner) names
1083+
1084+-- Metadata wrappers: recurse through
1085+instance CollectSchemaNames a names => CollectSchemaNames (Description desc a) names
1086+instance CollectSchemaNames a names => CollectSchemaNames (Example ex a) names
1087+instance CollectSchemaNames a names => CollectSchemaNames (Format fmt a) names
1088+instance CollectSchemaNames a names => CollectSchemaNames (Minimum v a) names
1089+instance CollectSchemaNames a names => CollectSchemaNames (Maximum v a) names
1090+instance CollectSchemaNames a names => CollectSchemaNames (Pattern pat a) names
1091+instance CollectSchemaNames a names => CollectSchemaNames (MinLength v a) names
1092+instance CollectSchemaNames a names => CollectSchemaNames (MaxLength v a) names
1093+instance CollectSchemaNames a names => CollectSchemaNames (Title t a) names
1094+instance CollectSchemaNames a names => CollectSchemaNames (Nullable a) names
1095+instance CollectSchemaNames a names => CollectSchemaNames (Default val a) names
1096+instance CollectSchemaNames a names => CollectSchemaNames (Deprecated a) names
1097+instance CollectSchemaNames a names => CollectSchemaNames (Enum a) names
1098+
1099+-- | Helper class to collect schema names from record fields
1100+class CollectSchemaNamesRL (rl :: RowList Type) (names :: Row Type) | rl -> names
1101+
1102+instance CollectSchemaNamesRL Nil ()
1103+
1104+instance
1105+ ( CollectSchemaNames ty fieldNames
1106+ , CollectSchemaNamesRL tail tailNames
1107+ , Row.Union fieldNames tailNames names
1108+ ) =>
1109+ CollectSchemaNamesRL (Cons name ty tail) names
1110+
1111+-- | Collects schema names from all response bodies in a variant
1112+class CollectVariantSchemaNames (rl :: RowList Type) (names :: Row Type) | rl -> names
1113+
1114+instance CollectVariantSchemaNames Nil ()
1115+
1116+instance
1117+ ( ToResponse recordType headers body
1118+ , CollectSchemaNames body bodyNames
1119+ , CollectVariantSchemaNames tail tailNames
1120+ , Row.Union bodyNames tailNames names
1121+ ) =>
1122+ CollectVariantSchemaNames (Cons label recordType tail) names
1123+
1124+-- | Collects schema names from a route's request and response types
1125+class CollectRouteSchemaNames (route :: Type) (names :: Row Type) | route -> names
1126+
1127+-- | Helper for collecting schema names from a record of routes
1128+class CollectRouteSchemaNameRL (rl :: RowList Type) (names :: Row Type) | rl -> names
1129+
1130+instance CollectRouteSchemaNameRL Nil ()
1131+
1132+instance
1133+ ( CollectRouteSchemaNames routeType routeNames
1134+ , CollectRouteSchemaNameRL tail tailNames
1135+ , Row.Union routeNames tailNames names
1136+ ) =>
1137+ CollectRouteSchemaNameRL (Cons label routeType tail) names
1138+
1139+-- Record instance
1140+instance (RowToList row rl, CollectRouteSchemaNameRL rl names) => CollectRouteSchemaNames (Record row) names
1141+
1142+-- | Walks a RowList and checks each entry against the full row.
1143+-- | If the RowList has duplicate labels from Union, Cons will unify their types.
1144+-- | Same name + same type: Cons succeeds. Same name + different type: Cons fails.
1145+class ValidateNoDuplicatesRL (rl :: RowList Type) (full :: Row Type)
1146+
1147+instance ValidateNoDuplicatesRL Nil full
1148+
1149+instance
1150+ ( Row.Cons name ty _rest full
1151+ , ValidateNoDuplicatesRL tail full
1152+ ) =>
1153+ ValidateNoDuplicatesRL (Cons name ty tail) full
1154+
1155+-- | Validates that all Schema wrappers in a route collection have unique names.
1156+-- | Same name with same type is allowed (deduplication).
1157+-- | Same name with different types is a compile error.
1158+class ValidateSchemaNames (routes :: Type)
1159+
1160+instance
1161+ ( CollectRouteSchemaNames routes names
1162+ , RowToList names rl
1163+ , ValidateNoDuplicatesRL rl names
1164+ ) =>
1165+ ValidateSchemaNames routes
1166+
8661167--------------------------------------------------------------------------------
8671168-- CollectOperations: Walk a type-level structure of routes
8681169--------------------------------------------------------------------------------
@@ -972,6 +1273,8 @@ type ServerObject =
9721273buildOpenAPISpec
9731274 :: forall @routes
9741275 . CollectOperations routes
1276+ => CollectRouteSchemas routes
1277+ => ValidateSchemaNames routes
9751278 => { title :: String, version :: String }
9761279 -> OpenAPISpec
9771280buildOpenAPISpec info = buildOpenAPISpec' @routes info { servers: Nothing }
@@ -980,6 +1283,8 @@ buildOpenAPISpec info = buildOpenAPISpec' @routes info { servers: Nothing }
9801283buildOpenAPISpec'
9811284 :: forall @routes
9821285 . CollectOperations routes
1286+ => CollectRouteSchemas routes
1287+ => ValidateSchemaNames routes
9831288 => { title :: String, version :: String }
9841289 -> { servers :: Maybe (Array ServerObject) }
9851290 -> OpenAPISpec
@@ -988,9 +1293,15 @@ buildOpenAPISpec' info config =
9881293 ops = collectOperations (Proxy :: Proxy routes)
9891294 paths = groupByPath ops
9901295 securitySchemes = buildSecuritySchemes ops
1296+ schemas = collectRouteSchemas (Proxy :: Proxy routes)
1297+
1298+ -- Build components object with both schemas and securitySchemes
9911299 components =
992− if FObject.isEmpty securitySchemes then Nothing
993− else Just { securitySchemes }
1300+ if FObject.isEmpty schemas && FObject.isEmpty securitySchemes then Nothing
1301+ else Just $ unsafeCoerce $ FObject.fromFoldable $
1302+ (if FObject.isEmpty schemas then [] else [ Tuple "schemas" (unsafeCoerce schemas) ])
1303+ <> (if FObject.isEmpty securitySchemes then [] else [ Tuple "securitySchemes" (unsafeCoerce securitySchemes) ])
1304+
9941305 baseSpec = FObject.fromFoldable
9951306 [ Tuple "openapi" (unsafeCoerce "3.0.0")
9961307 , Tuple "info" (unsafeCoerce { title: info.title, version: info.version })
@@ -998,7 +1309,7 @@ buildOpenAPISpec' info config =
9981309 ]
9991310 withComponents = case components of
10001311 Nothing -> baseSpec
1001− Just c -> FObject.insert "components" (unsafeCoerce c) baseSpec
1312+ Just c -> FObject.insert "components" c baseSpec
10021313 withServers = case config.servers of
10031314 Nothing -> withComponents
10041315 Just servers -> FObject.insert "servers" (writeImpl servers) withComponents