git.iko.soy/typeable/openapi3
on master help sign in
Merge pull request #10 from GetShopTV/generic-schema

ToSchema class with Generic deriving and other helpers

commit2600c209c01356c3fef459c29ac88695b196a05d authorNickolay Kudasov <fizruk@users.noreply.github.com> dateSat 28 Nov 2015, 01:50 (11 years ago) parent9723dd156c4c1c
12 files changed+961−30
CHANGELOG.md+11 src/Data/Swagger.hs+4 src/Data/Swagger/Internal.hs+4−3 src/Data/Swagger/Internal/Utils.hs+1 src/Data/Swagger/Lens.hs+5−4 src/Data/Swagger/Schema.hs+28 src/Data/Swagger/Schema/Internal.hs+447 swagger2.cabal+14 test/Data/Swagger/SchemaSpec.hs+400 test/Data/SwaggerSpec.hs+1−23 test/DocTest.hs+7 test/SpecCommon.hs+39
CHANGELOG.md+11
@@ -1,3 +1,14 @@
1+Unreleased changes
2+---
3+* Remove `Swagger`/`swagger` prefixes;
4+* Add `ToSchema` type class with default generic implementation;
5+* Add configurable generic `ToSchema` helpers;
6+* Add `doctest` test suite;
7+* Fixes:
8+ * Fix `HasSchemaCommon` instance for `Schema`;
9+ * Change `minimum`, `maximum` and `multipleOf` properties to be any number,
10+ not necessarily an integer.
11+
1120.3
213---
314* Fixes:
src/Data/Swagger.hs+4
@@ -6,6 +6,8 @@
66-- and Swagger-Codegen to generate clients in various languages.
77-- Additional utilities can also take advantage of the resulting files, such as testing tools.
88module Data.Swagger (
9+ module Data.Swagger.Schema,
10+
911 -- * Swagger specification
1012 Swagger(..),
1113 Host(..),
@@ -79,5 +81,7 @@ module Data.Swagger (
7981 URL(..),
8082) where
8183
84+import Data.Swagger.Schema
85+
8286import Data.Swagger.Internal
8387
src/Data/Swagger/Internal.hs+4−3
@@ -19,6 +19,7 @@ import qualified Data.HashMap.Strict as HashMap
1919import Data.Map (Map)
2020import qualified Data.Map as Map
2121import Data.Monoid
22+import Data.Scientific (Scientific)
2223import Data.String (fromString)
2324import Data.Text (Text)
2425import qualified Data.Text as Text
@@ -420,9 +421,9 @@ data SchemaCommon = SchemaCommon
420421 -- Unlike JSON Schema this value MUST conform to the defined type for this parameter.
421422 _schemaCommonDefault :: Maybe Value
422423
423− , _schemaCommonMaximum :: Maybe Integer
424+ , _schemaCommonMaximum :: Maybe Scientific
424425 , _schemaCommonExclusiveMaximum :: Maybe Bool
425− , _schemaCommonMinimum :: Maybe Integer
426+ , _schemaCommonMinimum :: Maybe Scientific
426427 , _schemaCommonExclusiveMinimum :: Maybe Bool
427428 , _schemaCommonMaxLength :: Maybe Integer
428429 , _schemaCommonMinLength :: Maybe Integer
@@ -431,7 +432,7 @@ data SchemaCommon = SchemaCommon
431432 , _schemaCommonMinItems :: Maybe Integer
432433 , _schemaCommonUniqueItems :: Maybe Bool
433434 , _schemaCommonEnum :: Maybe [Value]
434− , _schemaCommonMultipleOf :: Maybe Integer
435+ , _schemaCommonMultipleOf :: Maybe Scientific
435436 } deriving (Eq, Show, Generic)
436437
437438data Xml = Xml
src/Data/Swagger/Internal/Utils.hs+1
@@ -36,6 +36,7 @@ jsonPrefix prefix = defaultOptions
3636 { fieldLabelModifier = modifier . drop 1
3737 , constructorTagModifier = modifier
3838 , sumEncoding = ObjectWithSingleField
39+ , omitNothingFields = True
3940 }
4041 where
4142 modifier = lowerFirstUppers . drop (length prefix)
src/Data/Swagger/Lens.hs+5−4
@@ -7,6 +7,7 @@ module Data.Swagger.Lens where
77import Control.Lens
88import Control.Lens.TH
99import Data.Aeson (Value)
10+import Data.Scientific (Scientific)
1011import Data.Swagger.Internal
1112import Data.Text (Text)
1213
@@ -60,7 +61,7 @@ instance HasDescription ExternalDocs (Maybe Text) where description = external
6061class HasSchemaCommon s where
6162 schemaCommon :: Lens' s SchemaCommon
6263
63−instance HasSchemaCommon Schema where schemaCommon = schemaCommon
64+instance HasSchemaCommon Schema where schemaCommon = schemaSchemaCommon
6465instance HasSchemaCommon ParameterOtherSchema where schemaCommon = parameterOtherSchemaCommon
6566instance HasSchemaCommon Items where schemaCommon = itemsCommon
6667instance HasSchemaCommon Header where schemaCommon = headerCommon
@@ -69,13 +70,13 @@ instance HasSchemaCommon SchemaCommon where schemaCommon = id
6970schemaDefault :: HasSchemaCommon s => Lens' s (Maybe Value)
7071schemaDefault = schemaCommon.schemaCommonDefault
7172
72−schemaMaximum :: HasSchemaCommon s => Lens' s (Maybe Integer)
73+schemaMaximum :: HasSchemaCommon s => Lens' s (Maybe Scientific)
7374schemaMaximum = schemaCommon.schemaCommonMaximum
7475
7576schemaExclusiveMaximum :: HasSchemaCommon s => Lens' s (Maybe Bool)
7677schemaExclusiveMaximum = schemaCommon.schemaCommonExclusiveMaximum
7778
78−schemaMinimum :: HasSchemaCommon s => Lens' s (Maybe Integer)
79+schemaMinimum :: HasSchemaCommon s => Lens' s (Maybe Scientific)
7980schemaMinimum = schemaCommon.schemaCommonMinimum
8081
8182schemaExclusiveMinimum :: HasSchemaCommon s => Lens' s (Maybe Bool)
@@ -102,6 +103,6 @@ schemaUniqueItems = schemaCommon.schemaCommonUniqueItems
102103schemaEnum :: HasSchemaCommon s => Lens' s (Maybe [Value])
103104schemaEnum = schemaCommon.schemaCommonEnum
104105
105−schemaMultipleOf :: HasSchemaCommon s => Lens' s (Maybe Integer)
106+schemaMultipleOf :: HasSchemaCommon s => Lens' s (Maybe Scientific)
106107schemaMultipleOf = schemaCommon.schemaCommonMultipleOf
107108
src/Data/Swagger/Schema.hsnew file+28
@@ -0,0 +1,28 @@
1+-- |
2+-- Module: Data.Swagger.Schema
3+-- Copyright: (c) 2015 GetShopTV
4+-- License: BSD3
5+-- Maintainer: Nickolay Kudasov <nickolay@getshoptv.com>
6+-- Stability: experimental
7+--
8+-- Types and functions for working with Swagger schema.
9+module Data.Swagger.Schema (
10+ -- * Encoding
11+ ToSchema(..),
12+ NamedSchema,
13+ toSchema,
14+ toSchemaRef,
15+ schemaName,
16+
17+ -- * Generic schema encoding
18+ genericToSchema,
19+ genericToNamedSchema,
20+ genericToNamedSchemaBoundedIntegral,
21+ toSchemaBoundedIntegral,
22+
23+ -- * Generic encoding configuration
24+ SchemaOptions(..),
25+ defaultSchemaOptions,
26+) where
27+
28+import Data.Swagger.Schema.Internal
src/Data/Swagger/Schema/Internal.hsnew file+447
@@ -0,0 +1,447 @@
1+{-# LANGUAGE DefaultSignatures #-}
2+{-# LANGUAGE FlexibleContexts #-}
3+{-# LANGUAGE FlexibleInstances #-}
4+{-# LANGUAGE KindSignatures #-}
5+{-# LANGUAGE OverloadedStrings #-}
6+{-# LANGUAGE PolyKinds #-}
7+{-# LANGUAGE ScopedTypeVariables #-}
8+{-# LANGUAGE TupleSections #-}
9+{-# LANGUAGE TypeFamilies #-}
10+{-# LANGUAGE TypeOperators #-}
11+{-# LANGUAGE TypeSynonymInstances #-}
12+module Data.Swagger.Schema.Internal where
13+
14+import Control.Lens
15+import Data.Aeson
16+import Data.Char
17+import Data.HashMap.Strict (HashMap)
18+import Data.HashSet (HashSet)
19+import Data.Int
20+import Data.IntSet (IntSet)
21+import Data.IntMap (IntMap)
22+import Data.Map (Map)
23+import Data.Monoid
24+import Data.Proxy
25+import Data.Scientific (Scientific)
26+import Data.Set (Set)
27+import qualified Data.Text as T
28+import qualified Data.Text.Lazy as TL
29+import Data.Time
30+import Data.Word
31+import GHC.Generics
32+
33+import Data.Swagger.Internal
34+import Data.Swagger.Lens
35+
36+-- | A @'Schema'@ with an optional name.
37+-- This name can be used in references.
38+type NamedSchema = (Maybe String, Schema)
39+
40+unnamed :: Schema -> NamedSchema
41+unnamed schema = (Nothing, schema)
42+
43+named :: String -> Schema -> NamedSchema
44+named name schema = (Just name, schema)
45+
46+-- | Convert a type into @'Schema'@.
47+--
48+-- An example type and instance:
49+--
50+-- @
51+-- {-\# LANGUAGE OverloadedStrings \#-} -- allows to write 'T.Text' literals
52+-- {-\# LANGUAGE OverloadedLists \#-} -- allows to write 'Map' as list
53+--
54+-- import Control.Lens
55+--
56+-- data Coord = Coord { x :: Double, y :: Double }
57+--
58+-- instance ToSchema Coord where
59+-- toNamedSchema = (Just \"Coord\", mempty
60+-- & schemaType .~ SchemaObject
61+-- & schemaProperties .~
62+-- [ ("x", toSchemaRef (Proxy :: Proxy Double))
63+-- , ("y", toSchemaRef (Proxy :: Proxy Double))
64+-- ]
65+-- & schemaRequired .~ [ "x", "y" ]
66+-- @
67+--
68+-- Instead of manually writing your @'ToSchema'@ instance you can
69+-- use a default generic implementation of @'toNamedSchema'@.
70+--
71+-- To do that, simply add @deriving 'Generic'@ clause to your datatype
72+-- and declare a @'ToSchema'@ instance for your datatype without
73+-- giving definition for @'toNamedSchema'@.
74+--
75+-- For instance, the previous example can be simplified into this:
76+--
77+-- @
78+-- {-\# LANGUAGE DeriveGeneric \#-}
79+--
80+-- import GHC.Generics (Generic)
81+--
82+-- data Coord = Coord { x :: Double, y :: Double } deriving Generic
83+--
84+-- instance ToSchema Coord
85+-- @
86+class ToSchema a where
87+ -- | Convert a type into an optionally named schema.
88+ toNamedSchema :: proxy a -> NamedSchema
89+ default toNamedSchema :: (Generic a, GToSchema (Rep a)) => proxy a -> NamedSchema
90+ toNamedSchema = genericToNamedSchema defaultSchemaOptions
91+
92+-- | Get type's schema name according to its @'ToSchema'@ instance.
93+schemaName :: ToSchema a => proxy a -> Maybe String
94+schemaName = fst . toNamedSchema
95+
96+-- | Convert a type into a schema.
97+toSchema :: ToSchema a => proxy a -> Schema
98+toSchema = snd . toNamedSchema
99+
100+-- | Convert a type into a referenced schema if possible.
101+-- Only named schemas can be references, nameless schemas are inlined.
102+toSchemaRef :: ToSchema a => proxy a -> Referenced Schema
103+toSchemaRef proxy = case toNamedSchema proxy of
104+ (Just name, _) -> Ref (Reference ("#/definitions/" <> T.pack name))
105+ (_, schema) -> Inline schema
106+
107+class GToSchema (f :: * -> *) where
108+ gtoNamedSchema :: SchemaOptions -> proxy f -> Schema -> NamedSchema
109+
110+gtoSchema :: GToSchema f => SchemaOptions -> proxy f -> Schema -> Schema
111+gtoSchema opts proxy = snd . gtoNamedSchema opts proxy
112+
113+instance {-# OVERLAPPABLE #-} ToSchema a => ToSchema [a] where
114+ toNamedSchema _ = unnamed $ mempty
115+ & schemaType .~ SchemaArray
116+ & schemaItems ?~ SchemaItemsObject (toSchemaRef (Proxy :: Proxy a))
117+
118+instance {-# OVERLAPPING #-} ToSchema String where
119+ toNamedSchema _ = unnamed $ mempty & schemaType .~ SchemaString
120+
121+instance ToSchema Bool where
122+ toNamedSchema _ = unnamed $ mempty & schemaType .~ SchemaBoolean
123+
124+instance ToSchema Integer where
125+ toNamedSchema _ = unnamed $ mempty & schemaType .~ SchemaInteger
126+
127+instance ToSchema Int where toNamedSchema = unnamed . toSchemaBoundedIntegral
128+instance ToSchema Int8 where toNamedSchema = unnamed . toSchemaBoundedIntegral
129+instance ToSchema Int16 where toNamedSchema = unnamed . toSchemaBoundedIntegral
130+instance ToSchema Int32 where toNamedSchema = unnamed . toSchemaBoundedIntegral
131+instance ToSchema Int64 where toNamedSchema = unnamed . toSchemaBoundedIntegral
132+
133+instance ToSchema Word where toNamedSchema = unnamed . toSchemaBoundedIntegral
134+instance ToSchema Word8 where toNamedSchema = unnamed . toSchemaBoundedIntegral
135+instance ToSchema Word16 where toNamedSchema = unnamed . toSchemaBoundedIntegral
136+instance ToSchema Word32 where toNamedSchema = unnamed . toSchemaBoundedIntegral
137+instance ToSchema Word64 where toNamedSchema = unnamed . toSchemaBoundedIntegral
138+
139+instance ToSchema Char where
140+ toNamedSchema _ = unnamed $ mempty
141+ & schemaType .~ SchemaString
142+ & schemaMaxLength ?~ 1
143+ & schemaMinLength ?~ 1
144+
145+instance ToSchema Scientific where
146+ toNamedSchema _ = unnamed $ mempty & schemaType .~ SchemaNumber
147+
148+instance ToSchema Double where
149+ toNamedSchema _ = unnamed $ mempty & schemaType .~ SchemaNumber
150+
151+instance ToSchema Float where
152+ toNamedSchema _ = unnamed $ mempty & schemaType .~ SchemaNumber
153+
154+instance ToSchema a => ToSchema (Maybe a) where
155+ toNamedSchema _ = unnamed $ toSchema (Proxy :: Proxy a)
156+
157+instance (ToSchema a, ToSchema b) => ToSchema (Either a b)
158+
159+instance ToSchema ()
160+instance (ToSchema a, ToSchema b) => ToSchema (a, b)
161+instance (ToSchema a, ToSchema b, ToSchema c) => ToSchema (a, b, c)
162+instance (ToSchema a, ToSchema b, ToSchema c, ToSchema d) => ToSchema (a, b, c, d)
163+instance (ToSchema a, ToSchema b, ToSchema c, ToSchema d, ToSchema e) => ToSchema (a, b, c, d, e)
164+instance (ToSchema a, ToSchema b, ToSchema c, ToSchema d, ToSchema e, ToSchema f) => ToSchema (a, b, c, d, e, f)
165+instance (ToSchema a, ToSchema b, ToSchema c, ToSchema d, ToSchema e, ToSchema f, ToSchema g) => ToSchema (a, b, c, d, e, f, g)
166+
167+timeNamedSchema :: String -> String -> NamedSchema
168+timeNamedSchema name format = (Just name, mempty
169+ & schemaType .~ SchemaString
170+ & schemaFormat ?~ T.pack format
171+ & schemaMinLength ?~ toInteger (length format))
172+
173+-- |
174+-- >>> toSchema (Proxy :: Proxy Day) ^. schemaFormat
175+-- Just "yyyy-mm-dd"
176+instance ToSchema Day where
177+ toNamedSchema _ = timeNamedSchema "Day" "yyyy-mm-dd"
178+
179+-- |
180+-- >>> toSchema (Proxy :: Proxy LocalTime) ^. schemaFormat
181+-- Just "yyyy-mm-ddThh:MM:ss"
182+instance ToSchema LocalTime where
183+ toNamedSchema _ = timeNamedSchema "LocalTime" "yyyy-mm-ddThh:MM:ss"
184+
185+-- |
186+-- >>> toSchema (Proxy :: Proxy ZonedTime) ^. schemaFormat
187+-- Just "yyyy-mm-ddThh:MM:ss(Z|+hh:MM)"
188+instance ToSchema ZonedTime where
189+ toNamedSchema _ = (Just "ZonedTime", mempty
190+ & schemaType .~ SchemaString
191+ & schemaFormat ?~ "yyyy-mm-ddThh:MM:ss(Z|+hh:MM)"
192+ & schemaMinLength ?~ toInteger (length ("yyyy-mm-ddThh:MM:ssZ" :: String)))
193+
194+instance ToSchema NominalDiffTime where
195+ toNamedSchema _ = toNamedSchema (Proxy :: Proxy Integer)
196+
197+-- |
198+-- >>> toSchema (Proxy :: Proxy UTCTime) ^. schemaFormat
199+-- Just "yyyy-mm-ddThh:MM:ssZ"
200+instance ToSchema UTCTime where
201+ toNamedSchema _ = timeNamedSchema "UTCTime" "yyyy-mm-ddThh:MM:ssZ"
202+
203+instance ToSchema T.Text where
204+ toNamedSchema _ = unnamed $ toSchema (Proxy :: Proxy String)
205+
206+instance ToSchema TL.Text where
207+ toNamedSchema _ = unnamed $ toSchema (Proxy :: Proxy String)
208+
209+instance ToSchema IntSet where toNamedSchema _ = toNamedSchema (Proxy :: Proxy (Set Int))
210+
211+-- | NOTE: This schema does not account for the uniqueness of keys.
212+instance ToSchema a => ToSchema (IntMap a) where
213+ toNamedSchema _ = toNamedSchema (Proxy :: Proxy [(Int, a)])
214+
215+instance ToSchema a => ToSchema (Map String a) where
216+ toNamedSchema _ = unnamed $ mempty
217+ & schemaType .~ SchemaObject
218+ & schemaAdditionalProperties ?~ toSchema (Proxy :: Proxy a)
219+
220+instance ToSchema a => ToSchema (Map T.Text a) where toNamedSchema _ = unnamed $ toSchema (Proxy :: Proxy (Map String a))
221+instance ToSchema a => ToSchema (Map TL.Text a) where toNamedSchema _ = unnamed $ toSchema (Proxy :: Proxy (Map String a))
222+
223+instance ToSchema a => ToSchema (HashMap String a) where toNamedSchema _ = unnamed $ toSchema (Proxy :: Proxy (Map String a))
224+instance ToSchema a => ToSchema (HashMap T.Text a) where toNamedSchema _ = unnamed $ toSchema (Proxy :: Proxy (Map String a))
225+instance ToSchema a => ToSchema (HashMap TL.Text a) where toNamedSchema _ = unnamed $ toSchema (Proxy :: Proxy (Map String a))
226+
227+instance ToSchema a => ToSchema (Set a) where
228+ toNamedSchema _ = unnamed $ toSchema (Proxy :: Proxy [a])
229+ & schemaUniqueItems ?~ True
230+
231+instance ToSchema a => ToSchema (HashSet a) where toNamedSchema _ = unnamed $ toSchema (Proxy :: Proxy (Set a))
232+
233+instance ToSchema All where toNamedSchema _ = unnamed $ toSchema (Proxy :: Proxy Bool)
234+instance ToSchema Any where toNamedSchema _ = unnamed $ toSchema (Proxy :: Proxy Bool)
235+instance ToSchema a => ToSchema (Sum a) where toNamedSchema _ = unnamed $ toSchema (Proxy :: Proxy a)
236+instance ToSchema a => ToSchema (Product a) where toNamedSchema _ = unnamed $ toSchema (Proxy :: Proxy a)
237+instance ToSchema a => ToSchema (First a) where toNamedSchema _ = unnamed $ toSchema (Proxy :: Proxy a)
238+instance ToSchema a => ToSchema (Last a) where toNamedSchema _ = unnamed $ toSchema (Proxy :: Proxy a)
239+instance ToSchema a => ToSchema (Dual a) where toNamedSchema _ = unnamed $ toSchema (Proxy :: Proxy a)
240+
241+-- | Options that specify how to encode your type to Swagger schema.
242+data SchemaOptions = SchemaOptions
243+ { -- | Function applied to field labels. Handy for removing common record prefixes for example.
244+ fieldLabelModifier :: String -> String
245+ -- | Function applied to constructor tags which could be handy for lower-casing them for example.
246+ , constructorTagModifier :: String -> String
247+ -- | Function applied to datatype name.
248+ , datatypeNameModifier :: String -> String
249+ -- | If @'True'@ the constructors of a datatype, with all nullary constructors,
250+ -- will be encoded to a string enumeration schema with the constructor tags as possible values.
251+ , allNullaryToStringTag :: Bool
252+ -- | If @'True'@ direct subschemas will be referenced if possible (rather than inlined).
253+ -- Note that this option does not influence nested schemas, e.g. for these types
254+ --
255+ -- @
256+ -- data Object = Object String deriving Generic
257+ -- instance ToSchema Object
258+ --
259+ -- newtype Objects = Objects [Object] deriving Generic
260+ -- instance ToSchema Objects where
261+ -- toNamedSchema = genericToNamedSchema defaultSchemaOptions
262+ -- { useReferences = False }
263+ -- @
264+ --
265+ -- Schema for @Objects@ __will not__ inline @Object@ schema because
266+ -- it is nested in a @[]@ schema.
267+ , useReferences :: Bool
268+ -- | Hide the field name when a record constructor has only one field, like a newtype.
269+ , unwrapUnaryRecords :: Bool
270+ }
271+
272+-- | Default encoding @'SchemaOptions'@.
273+--
274+-- @
275+-- 'SchemaOptions'
276+-- { 'fieldLabelModifier' = id
277+-- , 'constructorTagModifier' = id
278+-- , 'datatypeNameModifier' = id
279+-- , 'allNullaryToStringTag' = True
280+-- , 'useReferences' = True
281+-- , 'unwrapUnaryRecords' = False
282+-- }
283+-- @
284+defaultSchemaOptions :: SchemaOptions
285+defaultSchemaOptions = SchemaOptions
286+ { fieldLabelModifier = id
287+ , constructorTagModifier = id
288+ , datatypeNameModifier = id
289+ , allNullaryToStringTag = True
290+ , useReferences = True
291+ , unwrapUnaryRecords = False
292+ }
293+
294+-- | Default schema for @'Bounded'@, @'Integral'@ types.
295+toSchemaBoundedIntegral :: forall a proxy. (Bounded a, Integral a) => proxy a -> Schema
296+toSchemaBoundedIntegral _ = mempty
297+ & schemaType .~ SchemaInteger
298+ & schemaMinimum ?~ fromInteger (toInteger (minBound :: a))
299+ & schemaMaximum ?~ fromInteger (toInteger (maxBound :: a))
300+
301+-- | Default generic named schema for @'Bounded'@, @'Integral'@ types.
302+genericToNamedSchemaBoundedIntegral :: forall a d f proxy.
303+ ( Bounded a, Integral a
304+ , Generic a, Rep a ~ D1 d f, Datatype d)
305+ => SchemaOptions -> proxy a -> NamedSchema
306+genericToNamedSchemaBoundedIntegral opts proxy
307+ = (gdatatypeSchemaName opts (Proxy :: Proxy d), toSchemaBoundedIntegral proxy)
308+
309+-- | A configurable generic @'Schema'@ creator.
310+genericToSchema :: (Generic a, GToSchema (Rep a)) => SchemaOptions -> proxy a -> Schema
311+genericToSchema opts = snd . genericToNamedSchema opts
312+
313+-- | A configurable generic @'NamedSchema'@ creator.
314+-- This function applied to @'defaultSchemaOptions'@
315+-- is used as the default for @'toNamedSchema'@
316+-- when the type is an instance of @'Generic'@.
317+genericToNamedSchema :: forall a proxy. (Generic a, GToSchema (Rep a)) => SchemaOptions -> proxy a -> NamedSchema
318+genericToNamedSchema opts _ = gtoNamedSchema opts (Proxy :: Proxy (Rep a)) mempty
319+
320+gdatatypeSchemaName :: forall proxy d. Datatype d => SchemaOptions -> proxy d -> Maybe String
321+gdatatypeSchemaName opts _ = case name of
322+ (c:_) | isAlpha c && isUpper c -> Just name
323+ _ -> Nothing
324+ where
325+ name = datatypeNameModifier opts (datatypeName (Proxy3 :: Proxy3 d f a))
326+
327+nullarySchema :: Schema
328+nullarySchema = mempty
329+ & schemaType .~ SchemaArray
330+ & schemaEnum ?~ [ toJSON () ]
331+
332+instance GToSchema U1 where
333+ gtoNamedSchema _ _ _ = unnamed nullarySchema
334+
335+instance (GToSchema f, GToSchema g) => GToSchema (f :*: g) where
336+ gtoNamedSchema opts _ = unnamed . gtoSchema opts (Proxy :: Proxy f) . gtoSchema opts (Proxy :: Proxy g)
337+
338+instance (Datatype d, GToSchema f) => GToSchema (D1 d f) where
339+ gtoNamedSchema opts _ s = (schemaName, gtoSchema opts (Proxy :: Proxy f) s)
340+ where
341+ schemaName = gdatatypeSchemaName opts (Proxy :: Proxy d)
342+
343+instance {-# OVERLAPPABLE #-} GToSchema f => GToSchema (C1 c f) where
344+ gtoNamedSchema opts _ = unnamed . gtoSchema opts (Proxy :: Proxy f)
345+
346+-- | Single field constructor.
347+instance (Selector s, GToSchema f) => GToSchema (C1 c (S1 s f)) where
348+ gtoNamedSchema opts _ s
349+ | unwrapUnaryRecords opts = fieldSchema
350+ | otherwise =
351+ case schema ^. schemaItems of
352+ Just (SchemaItemsArray [_]) -> fieldSchema
353+ _ -> unnamed schema
354+ where
355+ schema = gtoSchema opts (Proxy :: Proxy (S1 s f)) s
356+ fieldSchema = gtoNamedSchema opts (Proxy :: Proxy f) s
357+
358+gtoSchemaRef :: GToSchema f => SchemaOptions -> proxy f -> Referenced Schema
359+gtoSchemaRef opts proxy = case gtoNamedSchema opts proxy mempty of
360+ (Just name, _)
361+ | useReferences opts -> Ref (Reference ("#/definitions/" <> T.pack name))
362+ (_, schema) -> Inline schema
363+
364+appendItem :: Referenced Schema -> Maybe SchemaItems -> Maybe SchemaItems
365+appendItem x Nothing = Just (SchemaItemsArray [x])
366+appendItem x (Just (SchemaItemsArray xs)) = Just (SchemaItemsArray (x:xs))
367+appendItem _ _ = error "GToSchema.appendItem: cannot append to SchemaItemsObject"
368+
369+withFieldSchema :: forall proxy s f. (Selector s, GToSchema f) => SchemaOptions -> proxy s f -> Bool -> Schema -> Schema
370+withFieldSchema opts _ isRequiredField schema
371+ | T.null fieldName = schema
372+ & schemaType .~ SchemaArray
373+ & schemaItems %~ appendItem fieldSchemaRef
374+ | otherwise = schema
375+ & schemaType .~ SchemaObject
376+ & schemaProperties . at fieldName ?~ fieldSchemaRef
377+ & if isRequiredField
378+ then schemaRequired %~ (fieldName :)
379+ else id
380+ where
381+ fieldName = T.pack (fieldLabelModifier opts (selName (Proxy3 :: Proxy3 s f p)))
382+ fieldSchemaRef = gtoSchemaRef opts (Proxy :: Proxy f)
383+
384+-- | Optional record fields.
385+instance {-# OVERLAPPING #-} (Selector s, ToSchema c) => GToSchema (S1 s (K1 i (Maybe c))) where
386+ gtoNamedSchema opts _ = unnamed . withFieldSchema opts (Proxy2 :: Proxy2 s (K1 i (Maybe c))) False
387+
388+-- | Record fields.
389+instance {-# OVERLAPPABLE #-} (Selector s, GToSchema f) => GToSchema (S1 s f) where
390+ gtoNamedSchema opts _ = unnamed . withFieldSchema opts (Proxy2 :: Proxy2 s f) True
391+
392+instance ToSchema c => GToSchema (K1 i c) where
393+ gtoNamedSchema _ _ _ = toNamedSchema (Proxy :: Proxy c)
394+
395+instance (GSumToSchema f, GSumToSchema g) => GToSchema (f :+: g) where
396+ gtoNamedSchema opts _ s
397+ | allNullaryToStringTag opts && allNullary = unnamed (toStringTag sumSchema)
398+ | otherwise = unnamed sumSchema
399+ where
400+ (All allNullary, sumSchema) = gsumToSchema opts (Proxy :: Proxy (f :+: g)) s
401+
402+ toStringTag schema = mempty
403+ & schemaType .~ SchemaString
404+ & schemaEnum ?~ map toJSON (schema ^.. schemaProperties.ifolded.asIndex)
405+
406+type AllNullary = All
407+
408+class GSumToSchema f where
409+ gsumToSchema :: SchemaOptions -> proxy f -> Schema -> (AllNullary, Schema)
410+
411+instance (GSumToSchema f, GSumToSchema g) => GSumToSchema (f :+: g) where
412+ gsumToSchema opts _ = gsumToSchema opts (Proxy :: Proxy f) <.> gsumToSchema opts (Proxy :: Proxy g)
413+ where
414+ (f <.> g) s = (a <> b, s'')
415+ where
416+ (a, s') = f s
417+ (b, s'') = g s'
418+
419+gsumConToSchema :: forall c f proxy. Constructor c =>
420+ Bool -> Referenced Schema -> SchemaOptions -> proxy (C1 c f) -> Schema -> (AllNullary, Schema)
421+gsumConToSchema isNullary tagSchemaRef opts _ schema = (All isNullary, schema
422+ & schemaType .~ SchemaObject
423+ & schemaProperties . at tag ?~ tagSchemaRef
424+ & schemaMaxProperties ?~ 1
425+ & schemaMinProperties ?~ 1)
426+ where
427+ tag = T.pack (constructorTagModifier opts (conName (Proxy3 :: Proxy3 c f p)))
428+
429+instance {-# OVERLAPPABLE #-} (Constructor c, GToSchema f) => GSumToSchema (C1 c f) where
430+ gsumToSchema opts = gsumConToSchema False tagSchemaRef opts
431+ where
432+ tagSchemaRef = gtoSchemaRef opts (Proxy :: Proxy (C1 c f))
433+
434+instance Constructor c => GSumToSchema (C1 c U1) where
435+ gsumToSchema opts = gsumConToSchema True tagSchemaRef opts
436+ where
437+ tagSchemaRef = gtoSchemaRef opts (Proxy :: Proxy (C1 c U1))
438+
439+instance (Constructor c, Selector s, GToSchema f) => GSumToSchema (C1 c (S1 s f)) where
440+ gsumToSchema opts = gsumConToSchema False tagSchemaRef opts
441+ where
442+ tagSchemaRef = gtoSchemaRef opts (Proxy :: Proxy (C1 c (S1 s f)))
443+
444+data Proxy2 a b = Proxy2
445+
446+data Proxy3 a b c = Proxy3
447+
swagger2.cabal+14
@@ -21,10 +21,12 @@ library
2121 exposed-modules:
2222 Data.Swagger
2323 Data.Swagger.Lens
24+ Data.Swagger.Schema
2425
2526 -- internal modules
2627 Data.Swagger.Internal
2728 Data.Swagger.Internal.Utils
29+ Data.Swagger.Schema.Internal
2830 build-depends: base == 4.*
2931 , aeson
3032 , containers
@@ -33,8 +35,10 @@ library
3335 , network
3436 , template-haskell
3537 , text
38+ , time
3639 , unordered-containers
3740 , lens
41+ , scientific
3842 default-language: Haskell2010
3943
4044test-suite spec
@@ -49,12 +53,22 @@ test-suite spec
4953 , text
5054 , aeson
5155 , aeson-qq
56+ , containers
5257 , unordered-containers
5358 , vector
5459 other-modules:
60+ SpecCommon
5561 Data.SwaggerSpec
62+ Data.Swagger.SchemaSpec
5663 default-language: Haskell2010
5764
65+test-suite doctest
66+ build-depends: base, doctest, Glob
67+ default-language: Haskell2010
68+ hs-source-dirs: test
69+ main-is: DocTest.hs
70+ type: exitcode-stdio-1.0
71+
5872source-repository head
5973 type: git
6074 location: https://github.com/GetShopTV/swagger2.git
test/Data/Swagger/SchemaSpec.hsnew file+400
@@ -0,0 +1,400 @@
1+{-# LANGUAGE DeriveGeneric #-}
2+{-# LANGUAGE DeriveAnyClass #-}
3+{-# LANGUAGE ScopedTypeVariables #-}
4+{-# LANGUAGE TemplateHaskell #-}
5+{-# LANGUAGE QuasiQuotes #-}
6+module Data.Swagger.SchemaSpec (main, spec) where
7+
8+import Data.Aeson
9+import Data.Aeson.QQ
10+import Data.Char
11+import Data.Proxy
12+import Data.Set (Set)
13+import GHC.Generics
14+
15+import Data.Swagger
16+import Data.Swagger.Schema
17+
18+import SpecCommon
19+import Test.Hspec
20+
21+checkToSchema :: ToSchema a => Proxy a -> Value -> Spec
22+checkToSchema proxy js = toSchema proxy <~> js
23+
24+checkSchemaName :: ToSchema a => Maybe String -> Proxy a -> Spec
25+checkSchemaName name proxy =
26+ it ("schema name is " ++ show name) $
27+ schemaName proxy `shouldBe` name
28+
29+spec :: Spec
30+spec = do
31+ describe "Generic ToSchema" $ do
32+ context "Unit" $ checkToSchema (Proxy :: Proxy Unit) unitSchemaJSON
33+ context "Person" $ checkToSchema (Proxy :: Proxy Person) personSchemaJSON
34+ context "ISPair" $ checkToSchema (Proxy :: Proxy ISPair) ispairSchemaJSON
35+ context "Point (fieldLabelModifier)" $ checkToSchema (Proxy :: Proxy Point) pointSchemaJSON
36+ context "Color (bounded enum)" $ checkToSchema (Proxy :: Proxy Color) colorSchemaJSON
37+ context "Paint (record with bounded enum field)" $ checkToSchema (Proxy :: Proxy Paint) paintSchemaJSON
38+ context "UserGroup (set newtype)" $ checkToSchema (Proxy :: Proxy UserGroup) userGroupSchemaJSON
39+ context "Unary records" $ do
40+ context "Email (unwrapUnaryRecords)" $ checkToSchema (Proxy :: Proxy Email) emailSchemaJSON
41+ context "UserId (non-record newtype)" $ checkToSchema (Proxy :: Proxy UserId) userIdSchemaJSON
42+ context "Player (unary record)" $ checkToSchema (Proxy :: Proxy Player) playerSchemaJSON
43+ context "Players (inlining schema)" $ checkToSchema (Proxy :: Proxy Players) playersSchemaJSON
44+ context "MyRoseTree (datatypeNameModifier)" $ checkToSchema (Proxy :: Proxy MyRoseTree) myRoseTreeSchemaJSON
45+ context "Sum types" $ do
46+ context "Status (sum of unary constructors)" $ checkToSchema (Proxy :: Proxy Status) statusSchemaJSON
47+ context "Character (ref and record sum)" $ checkToSchema (Proxy :: Proxy Character) characterSchemaJSON
48+ context "Light (sum with unwrapUnaryRecords)" $ checkToSchema (Proxy :: Proxy Light) lightSchemaJSON
49+ context "Schema name" $ do
50+ context "String" $ checkSchemaName Nothing (Proxy :: Proxy String)
51+ context "(Int, Float)" $ checkSchemaName Nothing (Proxy :: Proxy (Int, Float))
52+ context "Person" $ checkSchemaName (Just "Person") (Proxy :: Proxy Person)
53+
54+main :: IO ()
55+main = hspec spec
56+
57+-- ========================================================================
58+-- Person (simple record with optional fields)
59+-- ========================================================================
60+data Person = Person
61+ { name :: String
62+ , phone :: Integer
63+ , email :: Maybe String
64+ } deriving (Generic, ToSchema)
65+
66+personSchemaJSON :: Value
67+personSchemaJSON = [aesonQQ|
68+{
69+ "type": "object",
70+ "properties":
71+ {
72+ "name": { "type": "string" },
73+ "phone": { "type": "integer" },
74+ "email": { "type": "string" }
75+ },
76+ "required": ["name", "phone"]
77+}
78+|]
79+
80+-- ========================================================================
81+-- ISPair (non-record product data type)
82+-- ========================================================================
83+data ISPair = ISPair Integer String
84+ deriving (Generic, ToSchema)
85+
86+ispairSchemaJSON :: Value
87+ispairSchemaJSON = [aesonQQ|
88+{
89+ "type": "array",
90+ "items":
91+ [
92+ { "type": "integer" },
93+ { "type": "string" }
94+ ]
95+}
96+|]
97+
98+-- ========================================================================
99+-- Point (record data type with custom fieldLabelModifier)
100+-- ========================================================================
101+data Point = Point
102+ { pointX :: Double
103+ , pointY :: Double
104+ } deriving (Generic)
105+
106+instance ToSchema Point where
107+ toNamedSchema = genericToNamedSchema defaultSchemaOptions
108+ { fieldLabelModifier = map toLower . drop (length "point") }
109+
110+pointSchemaJSON :: Value
111+pointSchemaJSON = [aesonQQ|
112+{
113+ "type": "object",
114+ "properties":
115+ {
116+ "x": { "type": "number" },
117+ "y": { "type": "number" }
118+ },
119+ "required": ["x", "y"]
120+}
121+|]
122+
123+
124+-- ========================================================================
125+-- Color (enum)
126+-- ========================================================================
127+data Color
128+ = Red
129+ | Green
130+ | Blue
131+ deriving (Generic, ToSchema)
132+
133+colorSchemaJSON :: Value
134+colorSchemaJSON = [aesonQQ|
135+{
136+ "type": "string",
137+ "enum": ["Red", "Green", "Blue"]
138+}
139+|]
140+
141+-- ========================================================================
142+-- Paint (record with bounded enum property)
143+-- ========================================================================
144+
145+newtype Paint = Paint { color :: Color }
146+ deriving (Generic, ToSchema)
147+
148+paintSchemaJSON :: Value
149+paintSchemaJSON = [aesonQQ|
150+{
151+ "type": "object",
152+ "properties":
153+ {
154+ "color":
155+ {
156+ "$ref": "#/definitions/Color"
157+ }
158+ },
159+ "required": ["color"]
160+}
161+|]
162+
163+-- ========================================================================
164+-- Email (newtype with unwrapUnaryRecords set to True)
165+-- ========================================================================
166+
167+newtype Email = Email { getEmail :: String }
168+ deriving (Generic)
169+
170+instance ToSchema Email where
171+ toNamedSchema = genericToNamedSchema defaultSchemaOptions
172+ { unwrapUnaryRecords = True }
173+
174+emailSchemaJSON :: Value
175+emailSchemaJSON = [aesonQQ|
176+{
177+ "type": "string"
178+}
179+|]
180+
181+-- ========================================================================
182+-- UserId (non-record newtype)
183+-- ========================================================================
184+
185+newtype UserId = UserId Integer
186+ deriving (Eq, Ord, Generic, ToSchema)
187+
188+userIdSchemaJSON :: Value
189+userIdSchemaJSON = [aesonQQ|
190+{
191+ "type": "integer"
192+}
193+|]
194+
195+-- ========================================================================
196+-- UserGroup (set newtype)
197+-- ========================================================================
198+
199+newtype UserGroup = UserGroup (Set UserId)
200+ deriving (Generic, ToSchema)
201+
202+userGroupSchemaJSON :: Value
203+userGroupSchemaJSON = [aesonQQ|
204+{
205+ "type": "array",
206+ "items": { "$ref": "#/definitions/UserId" },
207+ "uniqueItems": true
208+}
209+|]
210+
211+-- ========================================================================
212+-- Player (record newtype)
213+-- ========================================================================
214+
215+newtype Player = Player
216+ { position :: Point
217+ } deriving (Generic, ToSchema)
218+
219+playerSchemaJSON :: Value
220+playerSchemaJSON = [aesonQQ|
221+{
222+ "type": "object",
223+ "properties":
224+ {
225+ "position":
226+ {
227+ "$ref": "#/definitions/Point"
228+ }
229+ },
230+ "required": ["position"]
231+}
232+|]
233+
234+-- ========================================================================
235+-- MyRoseTree (custom datatypeNameModifier)
236+-- ========================================================================
237+
238+data MyRoseTree = MyRoseTree
239+ { root :: String
240+ , trees :: [MyRoseTree]
241+ } deriving (Generic)
242+
243+instance ToSchema MyRoseTree where
244+ toNamedSchema = genericToNamedSchema defaultSchemaOptions
245+ { datatypeNameModifier = drop (length "My") }
246+
247+myRoseTreeSchemaJSON :: Value
248+myRoseTreeSchemaJSON = [aesonQQ|
249+{
250+ "type": "object",
251+ "properties":
252+ {
253+ "root": { "type": "string" },
254+ "trees":
255+ {
256+ "type": "array",
257+ "items":
258+ {
259+ "$ref": "#/definitions/RoseTree"
260+ }
261+ }
262+ },
263+ "required": ["root", "trees"]
264+}
265+|]
266+
267+-- ========================================================================
268+-- Inlined (newtype for inlining schemas)
269+-- ========================================================================
270+
271+newtype Inlined a = Inlined { getInlined :: a }
272+
273+instance ToSchema a => ToSchema (Inlined a) where
274+ toNamedSchema _ = (Nothing, toSchema (Proxy :: Proxy a))
275+
276+newtype Players = Players [Inlined Player]
277+ deriving (Generic, ToSchema)
278+
279+playersSchemaJSON :: Value
280+playersSchemaJSON = [aesonQQ|
281+{
282+ "type": "array",
283+ "items":
284+ {
285+ "type": "object",
286+ "properties":
287+ {
288+ "position":
289+ {
290+ "$ref": "#/definitions/Point"
291+ }
292+ },
293+ "required": ["position"]
294+ }
295+}
296+|]
297+
298+-- ========================================================================
299+-- Status (sum type with unary constructors)
300+-- ========================================================================
301+
302+data Status
303+ = StatusOk String
304+ | StatusError String
305+ deriving (Generic)
306+
307+instance ToSchema Status where
308+ toNamedSchema = genericToNamedSchema defaultSchemaOptions
309+ { constructorTagModifier = map toLower . drop (length "Status") }
310+
311+statusSchemaJSON :: Value
312+statusSchemaJSON = [aesonQQ|
313+{
314+ "type": "object",
315+ "properties":
316+ {
317+ "ok": { "type": "string" },
318+ "error": { "type": "string" }
319+ },
320+ "maxProperties": 1,
321+ "minProperties": 1
322+}
323+|]
324+
325+-- ========================================================================
326+-- Unit type
327+-- ========================================================================
328+
329+data Unit = Unit deriving (Generic, ToSchema)
330+
331+unitSchemaJSON :: Value
332+unitSchemaJSON = [aesonQQ|
333+{
334+ "type": "array",
335+ "enum": [[]]
336+}
337+|]
338+
339+
340+-- ========================================================================
341+-- Character (sum type with ref and record in alternative)
342+-- ========================================================================
343+
344+data Character
345+ = PC Player
346+ | NPC { npcName :: String, npcPosition :: Point }
347+ deriving (Generic, ToSchema)
348+
349+characterSchemaJSON :: Value
350+characterSchemaJSON = [aesonQQ|
351+{
352+ "type": "object",
353+ "properties":
354+ {
355+ "PC": { "$ref": "#/definitions/Player" },
356+ "NPC":
357+ {
358+ "type": "object",
359+ "properties":
360+ {
361+ "npcName": { "type": "string" },
362+ "npcPosition": { "$ref": "#/definitions/Point" }
363+ },
364+ "required": ["npcName", "npcPosition"]
365+ }
366+ },
367+ "maxProperties": 1,
368+ "minProperties": 1
369+}
370+|]
371+
372+-- ========================================================================
373+-- Light (sum type with unwrapUnaryRecords)
374+-- ========================================================================
375+
376+data Light
377+ = LightFreq Double
378+ | LightColor Color
379+ | LightWaveLength { waveLength :: Double }
380+ deriving (Generic)
381+
382+instance ToSchema Light where
383+ toNamedSchema = genericToNamedSchema defaultSchemaOptions
384+ { unwrapUnaryRecords = True }
385+
386+lightSchemaJSON :: Value
387+lightSchemaJSON = [aesonQQ|
388+{
389+ "type": "object",
390+ "properties":
391+ {
392+ "LightFreq": { "type": "number" },
393+ "LightColor": { "$ref": "#/definitions/Color" },
394+ "LightWaveLength": { "type": "number" }
395+ },
396+ "maxProperties": 1,
397+ "minProperties": 1
398+}
399+|]
400+
test/Data/SwaggerSpec.hs+1−23
@@ -15,31 +15,9 @@ import qualified Data.Vector as Vector
1515import Data.Text (Text)
1616
1717import Data.Swagger
18−
18+import SpecCommon
1919import Test.Hspec
2020
21−isSubJSON :: Value -> Value -> Bool
22−isSubJSON Null _ = True
23−isSubJSON (Object x) (Object y) = HashMap.keys x == HashMap.keys i && F.and i
24− where
25− i = HashMap.intersectionWith isSubJSON x y
26−isSubJSON (Array xs) (Array ys) = Vector.length xs == Vector.length ys && F.and (Vector.zipWith isSubJSON xs ys)
27−isSubJSON x y = x == y
28−
29−(<~>) :: (Eq a, Show a, ToJSON a, FromJSON a) => a -> Value -> Spec
30−x <~> json = do
31− it "encodes correctly (probably with extra properties)" $ do
32− toJSON x `shouldSatisfy` (json `isSubJSON`)
33− it "decodes correctly" $ do
34− fromJSON json `shouldBe` Success x
35−
36−(<=>) :: (Eq a, Show a, ToJSON a, FromJSON a) => a -> Value -> Spec
37−x <=> json = do
38− it "encodes correctly" $ do
39− toJSON x `shouldBe` json
40− it "decodes correctly" $ do
41− fromJSON json `shouldBe` Success x
42−
4321spec :: Spec
4422spec = do
4523 describe "License Object" $ licenseExample <=> licenseExampleJSON
test/DocTest.hsnew file+7
@@ -0,0 +1,7 @@
1+module Main (main) where
2+
3+import System.FilePath.Glob (glob)
4+import Test.DocTest (doctest)
5+
6+main :: IO ()
7+main = glob "src/**/*.hs" >>= doctest
test/SpecCommon.hsnew file+39
@@ -0,0 +1,39 @@
1+module SpecCommon where
2+
3+import Data.Aeson
4+import Data.Aeson.QQ
5+import qualified Data.Foldable as F
6+import Data.HashMap.Strict (HashMap)
7+import qualified Data.HashMap.Strict as HashMap
8+import Data.Maybe
9+import Data.Monoid
10+import qualified Data.Vector as Vector
11+import Data.Text (Text)
12+
13+import Data.Swagger
14+
15+import Test.Hspec
16+
17+isSubJSON :: Value -> Value -> Bool
18+isSubJSON Null _ = True
19+isSubJSON (Object x) (Object y) = HashMap.keys x == HashMap.keys i && F.and i
20+ where
21+ i = HashMap.intersectionWith isSubJSON x y
22+isSubJSON (Array xs) (Array ys) = Vector.length xs == Vector.length ys && F.and (Vector.zipWith isSubJSON xs ys)
23+isSubJSON x y = x == y
24+
25+(<~>) :: (Eq a, Show a, ToJSON a, FromJSON a) => a -> Value -> Spec
26+x <~> json = do
27+ it "encodes correctly (probably with extra properties)" $ do
28+ toJSON x `shouldSatisfy` (json `isSubJSON`)
29+ it "decodes correctly" $ do
30+ fromJSON json `shouldBe` Success x
31+
32+(<=>) :: (Eq a, Show a, ToJSON a, FromJSON a) => a -> Value -> Spec
33+x <=> json = do
34+ it "encodes correctly" $ do
35+ toJSON x `shouldBe` json
36+ it "decodes correctly" $ do
37+ fromJSON json `shouldBe` Success x
38+
39+