Skip to content

Commit 707b100

Browse files
committed
feat: custom media types for Accept
* test text/html and drop HtmlRawOutputSpec.hs * all tests passing, removed all pendingWith * make functions compatible with pg <= 12 * move custom media types tests to own spec * anyelement aggregate * apply aggregates without a final function * overriding works * overriding anyelement with particular agg * cannot override vendored media types * plan spec works with custom aggregate * renamed media types to make clear which ones are overridable * correct content negotiation with same weight * text/tab-separated-values media type * text/csv with BOM plus content-disposition header
1 parent e7df9d1 commit 707b100

26 files changed

+794
-375
lines changed

postgrest.cabal

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,11 +204,11 @@ test-suite spec
204204
Feature.OptionsSpec
205205
Feature.Query.AndOrParamsSpec
206206
Feature.Query.ComputedRelsSpec
207+
Feature.Query.CustomMediaSpec
207208
Feature.Query.DeleteSpec
208209
Feature.Query.EmbedDisambiguationSpec
209210
Feature.Query.EmbedInnerJoinSpec
210211
Feature.Query.ErrorSpec
211-
Feature.Query.HtmlRawOutputSpec
212212
Feature.Query.InsertSpec
213213
Feature.Query.JsonOperatorSpec
214214
Feature.Query.MultipleSchemaSpec

src/PostgREST/App.hs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,7 @@ handleRequest AuthResult{..} conf appState authenticated prepared pgVer apiReq@A
220220
return $ pgrstResponse metrics pgrst
221221

222222
(ActionInspect headersOnly, TargetDefaultSpec tSchema) -> do
223-
(planTime', iPlan) <- withTiming $ liftEither $ Plan.inspectPlan conf apiReq
223+
(planTime', iPlan) <- withTiming $ liftEither $ Plan.inspectPlan apiReq
224224
(rsTime', oaiResult) <- withTiming $ runQuery roleIsoLvl (Plan.ipTxmode iPlan) $ Query.openApiQuery sCache pgVer conf tSchema
225225
(renderTime', pgrst) <- withTiming $ liftEither $ Response.openApiResponse (T.decodeUtf8 prettyVersion, docsVersion) headersOnly oaiResult conf sCache iSchema iNegotiatedByProfile
226226
let metrics = Map.fromList [(SMPlan, planTime'), (SMQuery, rsTime'), (SMRender, renderTime'), jwtTime]

src/PostgREST/Error.hs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ instance PgrstError ApiRequestError where
9191
status SingularityError{} = HTTP.status406
9292
status PGRSTParseError = HTTP.status500
9393

94-
headers SingularityError{} = [MediaType.toContentType $ MTSingularJSON False]
94+
headers SingularityError{} = [MediaType.toContentType $ MTVndSingularJSON False]
9595
headers _ = mempty
9696

9797
toJsonPgrstError :: ErrorCode -> Text -> Maybe JSON.Value -> Maybe JSON.Value -> JSON.Value

src/PostgREST/MediaType.hs

Lines changed: 32 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1+
{-# LANGUAGE DeriveGeneric #-}
12
{-# LANGUAGE DuplicateRecordFields #-}
23

34
module PostgREST.MediaType
45
( MediaType(..)
5-
, MTPlanOption (..)
6-
, MTPlanFormat (..)
6+
, MTVndPlanOption (..)
7+
, MTVndPlanFormat (..)
78
, toContentType
89
, toMime
910
, decodeMediaType
@@ -19,8 +20,6 @@ import Protolude
1920
-- | Enumeration of currently supported media types
2021
data MediaType
2122
= MTApplicationJSON
22-
| MTArrayJSONStrip
23-
| MTSingularJSON Bool
2423
| MTGeoJSON
2524
| MTTextCSV
2625
| MTTextPlain
@@ -30,32 +29,23 @@ data MediaType
3029
| MTOctetStream
3130
| MTAny
3231
| MTOther ByteString
33-
-- TODO MTPlan should only have its options as [Text]. Its ResultAggregate should have the typed attributes.
34-
| MTPlan MediaType MTPlanFormat [MTPlanOption]
35-
deriving Show
36-
instance Eq MediaType where
37-
MTApplicationJSON == MTApplicationJSON = True
38-
MTArrayJSONStrip == MTArrayJSONStrip = True
39-
MTSingularJSON x == MTSingularJSON y = x == y
40-
MTGeoJSON == MTGeoJSON = True
41-
MTTextCSV == MTTextCSV = True
42-
MTTextPlain == MTTextPlain = True
43-
MTTextXML == MTTextXML = True
44-
MTOpenAPI == MTOpenAPI = True
45-
MTUrlEncoded == MTUrlEncoded = True
46-
MTOctetStream == MTOctetStream = True
47-
MTAny == MTAny = True
48-
MTOther x == MTOther y = x == y
49-
MTPlan{} == MTPlan{} = True
50-
_ == _ = False
32+
-- vendored media types
33+
| MTVndArrayJSONStrip
34+
| MTVndSingularJSON Bool
35+
-- TODO MTVndPlan should only have its options as [Text]. Its ResultAggregate should have the typed attributes.
36+
| MTVndPlan MediaType MTVndPlanFormat [MTVndPlanOption]
37+
deriving (Eq, Show, Generic)
38+
instance Hashable MediaType
5139

52-
data MTPlanOption
40+
data MTVndPlanOption
5341
= PlanAnalyze | PlanVerbose | PlanSettings | PlanBuffers | PlanWAL
54-
deriving (Eq, Show)
42+
deriving (Eq, Show, Generic)
43+
instance Hashable MTVndPlanOption
5544

56-
data MTPlanFormat
45+
data MTVndPlanFormat
5746
= PlanJSON | PlanText
58-
deriving (Eq, Show)
47+
deriving (Eq, Show, Generic)
48+
instance Hashable MTVndPlanFormat
5949

6050
-- | Convert MediaType to a Content-Type HTTP Header
6151
toContentType :: MediaType -> Header
@@ -69,31 +59,31 @@ toContentType ct = (hContentType, toMime ct <> charset)
6959
-- | Convert from MediaType to a ByteString representing the mime type
7060
toMime :: MediaType -> ByteString
7161
toMime MTApplicationJSON = "application/json"
72-
toMime MTArrayJSONStrip = "application/vnd.pgrst.array+json;nulls=stripped"
62+
toMime MTVndArrayJSONStrip = "application/vnd.pgrst.array+json;nulls=stripped"
7363
toMime MTGeoJSON = "application/geo+json"
7464
toMime MTTextCSV = "text/csv"
7565
toMime MTTextPlain = "text/plain"
7666
toMime MTTextXML = "text/xml"
7767
toMime MTOpenAPI = "application/openapi+json"
78-
toMime (MTSingularJSON True) = "application/vnd.pgrst.object+json;nulls=stripped"
79-
toMime (MTSingularJSON False) = "application/vnd.pgrst.object+json"
68+
toMime (MTVndSingularJSON True) = "application/vnd.pgrst.object+json;nulls=stripped"
69+
toMime (MTVndSingularJSON False) = "application/vnd.pgrst.object+json"
8070
toMime MTUrlEncoded = "application/x-www-form-urlencoded"
8171
toMime MTOctetStream = "application/octet-stream"
8272
toMime MTAny = "*/*"
8373
toMime (MTOther ct) = ct
84-
toMime (MTPlan mt fmt opts) =
74+
toMime (MTVndPlan mt fmt opts) =
8575
"application/vnd.pgrst.plan+" <> toMimePlanFormat fmt <>
8676
("; for=\"" <> toMime mt <> "\"") <>
8777
(if null opts then mempty else "; options=" <> BS.intercalate "|" (toMimePlanOption <$> opts))
8878

89-
toMimePlanOption :: MTPlanOption -> ByteString
79+
toMimePlanOption :: MTVndPlanOption -> ByteString
9080
toMimePlanOption PlanAnalyze = "analyze"
9181
toMimePlanOption PlanVerbose = "verbose"
9282
toMimePlanOption PlanSettings = "settings"
9383
toMimePlanOption PlanBuffers = "buffers"
9484
toMimePlanOption PlanWAL = "wal"
9585

96-
toMimePlanFormat :: MTPlanFormat -> ByteString
86+
toMimePlanFormat :: MTVndPlanFormat -> ByteString
9787
toMimePlanFormat PlanJSON = "json"
9888
toMimePlanFormat PlanText = "text"
9989

@@ -103,25 +93,25 @@ toMimePlanFormat PlanText = "text"
10393
-- MTApplicationJSON
10494
--
10595
-- >>> decodeMediaType "application/vnd.pgrst.plan;"
106-
-- MTPlan MTApplicationJSON PlanText []
96+
-- MTVndPlan MTApplicationJSON PlanText []
10797
--
10898
-- >>> decodeMediaType "application/vnd.pgrst.plan;for=\"application/json\""
109-
-- MTPlan MTApplicationJSON PlanText []
99+
-- MTVndPlan MTApplicationJSON PlanText []
110100
--
111101
-- >>> decodeMediaType "application/vnd.pgrst.plan+json;for=\"text/csv\""
112-
-- MTPlan MTTextCSV PlanJSON []
102+
-- MTVndPlan MTTextCSV PlanJSON []
113103
--
114104
-- >>> decodeMediaType "application/vnd.pgrst.array+json;nulls=stripped"
115-
-- MTArrayJSONStrip
105+
-- MTVndArrayJSONStrip
116106
--
117107
-- >>> decodeMediaType "application/vnd.pgrst.array+json"
118108
-- MTApplicationJSON
119109
--
120110
-- >>> decodeMediaType "application/vnd.pgrst.object+json;nulls=stripped"
121-
-- MTSingularJSON True
111+
-- MTVndSingularJSON True
122112
--
123113
-- >>> decodeMediaType "application/vnd.pgrst.object+json"
124-
-- MTSingularJSON False
114+
-- MTVndSingularJSON False
125115

126116
decodeMediaType :: BS.ByteString -> MediaType
127117
decodeMediaType mt =
@@ -145,11 +135,11 @@ decodeMediaType mt =
145135
other:_ -> MTOther other
146136
_ -> MTAny
147137
where
148-
checkArrayNullStrip ["nulls=stripped"] = MTArrayJSONStrip
138+
checkArrayNullStrip ["nulls=stripped"] = MTVndArrayJSONStrip
149139
checkArrayNullStrip _ = MTApplicationJSON
150140

151-
checkSingularNullStrip ["nulls=stripped"] = MTSingularJSON True
152-
checkSingularNullStrip _ = MTSingularJSON False
141+
checkSingularNullStrip ["nulls=stripped"] = MTVndSingularJSON True
142+
checkSingularNullStrip _ = MTVndSingularJSON False
153143

154144
getPlan fmt rest =
155145
let
@@ -161,7 +151,7 @@ decodeMediaType mt =
161151
strippedFor <- BS.stripPrefix "for=" foundFor
162152
pure . decodeMediaType $ dropAround (== BS.c2w '"') strippedFor
163153
in
164-
MTPlan mtFor fmt $
154+
MTVndPlan mtFor fmt $
165155
[PlanAnalyze | inOpts "analyze" ] ++
166156
[PlanVerbose | inOpts "verbose" ] ++
167157
[PlanSettings | inOpts "settings"] ++

src/PostgREST/Plan.hs

Lines changed: 50 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -44,15 +44,15 @@ import PostgREST.ApiRequest (Action (..),
4444
Payload (..))
4545
import PostgREST.Config (AppConfig (..))
4646
import PostgREST.Error (Error (..))
47-
import PostgREST.MediaType (MTPlanFormat (..),
48-
MediaType (..))
47+
import PostgREST.MediaType (MediaType (..))
4948
import PostgREST.Query.SqlFragment (sourceCTEName)
5049
import PostgREST.RangeQuery (NonnegRange, allRange,
5150
convertToLimitZeroRange,
5251
restrictRange)
5352
import PostgREST.SchemaCache (SchemaCache (..))
5453
import PostgREST.SchemaCache.Identifiers (FieldName,
5554
QualifiedIdentifier (..),
55+
RelIdentifier (..),
5656
Schema)
5757
import PostgREST.SchemaCache.Relationship (Cardinality (..),
5858
Junction (..),
@@ -61,7 +61,7 @@ import PostgREST.SchemaCache.Relationship (Cardinality (..),
6161
relIsToOne)
6262
import PostgREST.SchemaCache.Representations (DataRepresentation (..),
6363
RepresentationsMap)
64-
import PostgREST.SchemaCache.Routine (ResultAggregate (..),
64+
import PostgREST.SchemaCache.Routine (MediaHandler (..),
6565
Routine (..),
6666
RoutineMap,
6767
RoutineParam (..),
@@ -93,25 +93,28 @@ import Protolude hiding (from)
9393
data WrappedReadPlan = WrappedReadPlan {
9494
wrReadPlan :: ReadPlanTree
9595
, wrTxMode :: SQL.Mode
96-
, wrResAgg :: ResultAggregate
96+
, wrHandler :: MediaHandler
9797
, wrMedia :: MediaType
98+
, wrIdent :: QualifiedIdentifier
9899
}
99100

100101
data MutateReadPlan = MutateReadPlan {
101102
mrReadPlan :: ReadPlanTree
102103
, mrMutatePlan :: MutatePlan
103104
, mrTxMode :: SQL.Mode
104-
, mrResAgg :: ResultAggregate
105+
, mrHandler :: MediaHandler
105106
, mrMedia :: MediaType
107+
, mrIdent :: QualifiedIdentifier
106108
}
107109

108110
data CallReadPlan = CallReadPlan {
109111
crReadPlan :: ReadPlanTree
110112
, crCallPlan :: CallPlan
111113
, crTxMode :: SQL.Mode
112114
, crProc :: Routine
113-
, crResAgg :: ResultAggregate
115+
, crHandler :: MediaHandler
114116
, crMedia :: MediaType
117+
, crIdent :: QualifiedIdentifier
115118
}
116119

117120
data InspectPlan = InspectPlan {
@@ -122,17 +125,17 @@ data InspectPlan = InspectPlan {
122125
wrappedReadPlan :: QualifiedIdentifier -> AppConfig -> SchemaCache -> ApiRequest -> Either Error WrappedReadPlan
123126
wrappedReadPlan identifier conf sCache apiRequest@ApiRequest{iPreferences=Preferences{..},..} = do
124127
rPlan <- readPlan identifier conf sCache apiRequest
125-
mediaType <- mapLeft ApiRequestError $ negotiateContent conf iAction iAcceptMediaType
128+
(hdler, mediaType) <- mapLeft ApiRequestError $ negotiateContent conf apiRequest identifier iAcceptMediaType (dbMediaHandlers sCache)
126129
if not (null invalidPrefs) && preferHandling == Just Strict then Left $ ApiRequestError $ InvalidPreferences invalidPrefs else Right ()
127-
return $ WrappedReadPlan rPlan SQL.Read (mediaToAggregate mediaType apiRequest) mediaType
130+
return $ WrappedReadPlan rPlan SQL.Read hdler mediaType identifier
128131

129132
mutateReadPlan :: Mutation -> ApiRequest -> QualifiedIdentifier -> AppConfig -> SchemaCache -> Either Error MutateReadPlan
130133
mutateReadPlan mutation apiRequest@ApiRequest{iPreferences=Preferences{..},..} identifier conf sCache = do
131134
rPlan <- readPlan identifier conf sCache apiRequest
132135
mPlan <- mutatePlan mutation identifier apiRequest sCache rPlan
133-
mediaType <- mapLeft ApiRequestError $ negotiateContent conf iAction iAcceptMediaType
134136
if not (null invalidPrefs) && preferHandling == Just Strict then Left $ ApiRequestError $ InvalidPreferences invalidPrefs else Right ()
135-
return $ MutateReadPlan rPlan mPlan SQL.Write (mediaToAggregate mediaType apiRequest) mediaType
137+
(hdler, mediaType) <- mapLeft ApiRequestError $ negotiateContent conf apiRequest identifier iAcceptMediaType (dbMediaHandlers sCache)
138+
return $ MutateReadPlan rPlan mPlan SQL.Write hdler mediaType identifier
136139

137140
callReadPlan :: QualifiedIdentifier -> AppConfig -> SchemaCache -> ApiRequest -> InvokeMethod -> Either Error CallReadPlan
138141
callReadPlan identifier conf sCache apiRequest@ApiRequest{iPreferences=Preferences{..},..} invMethod = do
@@ -156,15 +159,19 @@ callReadPlan identifier conf sCache apiRequest@ApiRequest{iPreferences=Preferenc
156159
(InvPost, Routine.Immutable) -> SQL.Read
157160
(InvPost, Routine.Volatile) -> SQL.Write
158161
cPlan = callPlan proc apiRequest paramKeys args rPlan
159-
mediaType <- mapLeft ApiRequestError $ negotiateContent conf iAction iAcceptMediaType
162+
(hdler, mediaType) <- mapLeft ApiRequestError $ negotiateContent conf apiRequest relIdentifier iAcceptMediaType (dbMediaHandlers sCache)
160163
if not (null invalidPrefs) && preferHandling == Just Strict then Left $ ApiRequestError $ InvalidPreferences invalidPrefs else Right ()
161-
return $ CallReadPlan rPlan cPlan txMode proc (mediaToAggregate mediaType apiRequest) mediaType
164+
return $ CallReadPlan rPlan cPlan txMode proc hdler mediaType relIdentifier
162165
where
163166
qsParams' = QueryParams.qsParams iQueryParams
164167

165-
inspectPlan :: AppConfig -> ApiRequest -> Either Error InspectPlan
166-
inspectPlan conf apiRequest = do
167-
mediaType <- mapLeft ApiRequestError $ negotiateContent conf (iAction apiRequest) (iAcceptMediaType apiRequest)
168+
inspectPlan :: ApiRequest -> Either Error InspectPlan
169+
inspectPlan apiRequest = do
170+
let producedMTs = [MTOpenAPI, MTApplicationJSON, MTAny]
171+
accepts = iAcceptMediaType apiRequest
172+
mediaType <- if not . null $ L.intersect accepts producedMTs
173+
then Right MTOpenAPI
174+
else Left . ApiRequestError . MediaTypeError $ MediaType.toMime <$> accepts
168175
return $ InspectPlan mediaType SQL.Read
169176

170177
{-|
@@ -824,52 +831,34 @@ inferColsEmbedNeeds (Node ReadPlan{select} forest) pkCols
824831
addFilterToLogicForest :: CoercibleFilter -> [CoercibleLogicTree] -> [CoercibleLogicTree]
825832
addFilterToLogicForest flt lf = CoercibleStmnt flt : lf
826833

827-
mediaToAggregate :: MediaType -> ApiRequest -> ResultAggregate
828-
mediaToAggregate mt apiReq@ApiRequest{iAction=act, iPreferences=Preferences{preferRepresentation=rep}} =
829-
if noAgg then NoAgg
830-
else case mt of
831-
MTApplicationJSON -> BuiltinAggJson
832-
MTSingularJSON strip -> BuiltinAggSingleJson strip
833-
MTArrayJSONStrip -> BuiltinAggArrayJsonStrip
834-
MTGeoJSON -> BuiltinAggGeoJson
835-
MTTextCSV -> BuiltinAggCsv
836-
MTAny -> BuiltinAggJson
837-
MTOpenAPI -> BuiltinAggJson
838-
MTUrlEncoded -> NoAgg -- TODO: unreachable since a previous step (producedMediaTypes) whitelists the media types that can become aggregates.
839-
840-
-- Doing `Accept: application/vnd.pgrst.plan; for="application/vnd.pgrst.plan"` doesn't make sense, so we just empty the body.
841-
-- TODO: fail instead to be more strict
842-
MTPlan (MTPlan{}) _ _ -> NoAgg
843-
MTPlan media _ _ -> mediaToAggregate media apiReq
844-
_ -> NoAgg
845-
where
846-
noAgg = case act of
847-
ActionMutate _ -> rep == Just HeadersOnly || rep == Just None || isNothing rep
848-
ActionRead _isHead -> _isHead -- no need for an aggregate on HEAD https://github.com/PostgREST/postgrest/issues/2849
849-
ActionInvoke invMethod -> invMethod == InvHead
850-
_ -> False
851-
852834
-- | Do content negotiation. i.e. choose a media type based on the intersection of accepted/produced media types.
853-
negotiateContent :: AppConfig -> Action -> [MediaType] -> Either ApiRequestError MediaType
854-
negotiateContent conf action accepts =
855-
case firstAcceptedPick of
856-
Just MTAny -> Right MTApplicationJSON -- by default(for */*) we respond with json
857-
Just mt -> Right mt
858-
Nothing -> Left . MediaTypeError $ map MediaType.toMime accepts
835+
negotiateContent :: AppConfig -> ApiRequest -> QualifiedIdentifier -> [MediaType] ->
836+
HM.HashMap (RelIdentifier, MediaType) MediaHandler -> Either ApiRequestError (MediaHandler, MediaType)
837+
negotiateContent conf ApiRequest{iAction=act, iPreferences=Preferences{preferRepresentation=rep}} identifier accepts produces =
838+
mtAnyToJSON $ case (act, firstAcceptedPick) of
839+
(_, Nothing) -> Left . MediaTypeError $ map MediaType.toMime accepts
840+
(ActionMutate _, Just (x, mt)) -> Right (if rep == Just Full then x else NoAgg, mt)
841+
-- no need for an aggregate on HEAD https://github.com/PostgREST/postgrest/issues/2849
842+
-- TODO: despite no aggregate, these are responding with a Content-Type, which is not correct.
843+
(ActionRead True, Just (_, mt)) -> Right (NoAgg, mt)
844+
(ActionInvoke InvHead, Just (_, mt)) -> Right (NoAgg, mt)
845+
(_, Just (x, mt)) -> Right (x, mt)
859846
where
847+
-- TODO initial */* is not overridable
848+
-- initial handlers in the schema cache have a */* to BuiltinAggJson but they don't preserve the media type (application/json)
849+
-- for now we just convert the resultant */* to application/json here
850+
mtAnyToJSON = mapRight (\(x, y) -> (x, if y == MTAny then MTApplicationJSON else y))
860851
-- if there are multiple accepted media types, pick the first
861-
firstAcceptedPick = listToMaybe $ L.intersect accepts $ producedMediaTypes conf action
862-
863-
producedMediaTypes :: AppConfig -> Action -> [MediaType]
864-
producedMediaTypes conf action =
865-
case action of
866-
ActionRead _ -> defaultMediaTypes
867-
ActionInvoke _ -> defaultMediaTypes
868-
ActionInfo -> defaultMediaTypes
869-
ActionMutate _ -> defaultMediaTypes
870-
ActionInspect _ -> inspectMediaTypes
871-
where
872-
inspectMediaTypes = [MTOpenAPI, MTApplicationJSON, MTArrayJSONStrip, MTAny]
873-
defaultMediaTypes =
874-
[MTApplicationJSON, MTArrayJSONStrip, MTSingularJSON True, MTSingularJSON False, MTGeoJSON, MTTextCSV] ++
875-
[MTPlan MTApplicationJSON PlanText mempty | configDbPlanEnabled conf] ++ [MTAny]
852+
firstAcceptedPick = listToMaybe $ mapMaybe searchMT accepts
853+
lookupIdent mt = -- first search for an aggregate that applies to the particular relation, then for one that applies to anyelement
854+
HM.lookup (RelId identifier, mt) produces <|> HM.lookup (RelAnyElement, mt) produces
855+
searchMT mt = case mt of
856+
-- all the vendored media types have special handling as they have media type parameters, they cannot be overridden
857+
m@(MTVndSingularJSON strip) -> Just (BuiltinAggSingleJson strip, m)
858+
m@MTVndArrayJSONStrip -> Just (BuiltinAggArrayJsonStrip, m)
859+
m@(MTVndPlan (MTVndSingularJSON strip) _ _) -> mtPlanToNothing $ Just (BuiltinAggSingleJson strip, m)
860+
m@(MTVndPlan MTVndArrayJSONStrip _ _) -> mtPlanToNothing $ Just (BuiltinAggArrayJsonStrip, m)
861+
-- all the other media types can be overridden
862+
m@(MTVndPlan mType _ _) -> mtPlanToNothing $ (,) <$> lookupIdent mType <*> pure m
863+
x -> (,) <$> lookupIdent x <*> pure x
864+
mtPlanToNothing x = if configDbPlanEnabled conf then x else Nothing -- don't find anything if the plan media type is not allowed

0 commit comments

Comments
 (0)