packages feed

ihp-1.4.0: IHP/DataSync/REST/Controller.hs

{-# LANGUAGE UndecidableInstances #-}
module IHP.DataSync.REST.Controller where

import IHP.ControllerPrelude hiding (OrderByClause)
import IHP.DataSync.REST.Types
import Data.Aeson
import qualified Database.PostgreSQL.Simple.ToField as PG
import qualified Database.PostgreSQL.Simple.Types as PG
import qualified Database.PostgreSQL.Simple as PG
import qualified Data.Vector as Vector
import qualified Data.ByteString.Char8 as ByteString
import qualified Control.Exception.Safe as Exception
import IHP.DataSync.RowLevelSecurity
import IHP.DataSync.DynamicQuery
import IHP.DataSync.Types
import Network.HTTP.Types (status400)
import IHP.DataSync.DynamicQueryCompiler
import qualified Data.Text as Text
import qualified Data.Scientific as Scientific

import qualified Data.ByteString.Builder as ByteString
import qualified Data.Aeson.Encoding.Internal as Aeson
import qualified Data.Aeson.KeyMap as Aeson
import qualified Data.Aeson.Key as Aeson


instance (
    PG.ToField (PrimaryKey (GetTableName CurrentUserRecord))
    , Show (PrimaryKey (GetTableName CurrentUserRecord))
    , HasNewSessionUrl CurrentUserRecord
    , Typeable CurrentUserRecord
    , HasField "id" CurrentUserRecord (Id' (GetTableName CurrentUserRecord))
    ) => Controller ApiController where
    action CreateRecordAction { table } = do
        ensureRLSEnabled table

        let payload = requestBodyJSON

        case payload of
            Object hashMap -> do
                let query = "INSERT INTO ? ? VALUES ? RETURNING *"
                let columns = hashMap
                        |> Aeson.keys
                        |> map (fieldNameToColumnName . Aeson.toText)

                let values = hashMap
                        |> Aeson.elems
                        |> map aesonValueToPostgresValue

                let params = (PG.Identifier table, PG.In (map PG.Identifier columns), PG.In values)

                result :: Either EnhancedSqlError [[Field]] <- Exception.try do
                    sqlQueryWithRLS query params

                case result of
                    Left error -> renderErrorJson error
                    Right result -> renderJson result

            Array objects -> do
                let query = "INSERT INTO ? ? ? RETURNING *"
                let columns = objects
                        |> Vector.toList
                        |> head
                        |> \case
                            Just value -> value
                            Nothing -> error "Atleast one record is required"
                        |> \case
                            Object hashMap -> hashMap
                            otherwise -> error "Expected object"
                        |> Aeson.keys
                        |> map (fieldNameToColumnName . Aeson.toText)

                let values = objects
                        |> Vector.toList
                        |> map (\object ->
                                object
                                |> \case
                                    Object hashMap -> hashMap
                                    otherwise -> error "Expected object"
                                |> Aeson.elems
                                |> map aesonValueToPostgresValue
                            )


                let params = (PG.Identifier table, PG.In (map PG.Identifier columns), PG.Values [] values)

                result :: [[Field]] <- sqlQueryWithRLS query params
                renderJson result



    action UpdateRecordAction { table, id } = do
        ensureRLSEnabled table

        let payload = requestBodyJSON
                |> \case
                    Object hashMap -> hashMap

        let columns = payload
                |> Aeson.keys
                |> map (fieldNameToColumnName . Aeson.toText)
                |> map PG.Identifier

        let values = payload
                |> Aeson.elems
                |> map aesonValueToPostgresValue

        let keyValues = zip columns values

        let setCalls = keyValues
                |> map (\_ -> "? = ?")
                |> ByteString.intercalate ", "
        let query = "UPDATE ? SET " <> setCalls <> " WHERE id = ? RETURNING *"

        let params = [PG.toField (PG.Identifier table)]
                <> (join (map (\(key, value) -> [PG.toField key, value]) keyValues))
                <> [PG.toField id]

        result :: [[Field]] <- sqlQueryWithRLS (PG.Query query) params

        renderJson (head result)

    -- DELETE /api/:table/:id
    action DeleteRecordAction { table, id } = do
        ensureRLSEnabled table

        sqlExecWithRLS "DELETE FROM ? WHERE id = ?" (PG.Identifier table, id)

        renderJson True

    -- GET /api/:table/:id
    action ShowRecordAction { table, id } = do
        ensureRLSEnabled table

        result :: [[Field]] <- sqlQueryWithRLS "SELECT * FROM ? WHERE id = ?" (PG.Identifier table, id)

        renderJson (head result)

    -- GET /api/:table
    -- GET /api/:table?orderBy=createdAt
    -- GET /api/:table?fields=id,title
    action ListRecordsAction { table } = do
        ensureRLSEnabled table

        let (theQuery, theParams) = compileQuery (buildDynamicQueryFromRequest table)
        result :: [[Field]] <- sqlQueryWithRLS theQuery theParams

        renderJson result

buildDynamicQueryFromRequest table = DynamicSQLQuery
    { table
    , selectedColumns = paramOrDefault SelectAll "fields"
    , whereCondition = Nothing
    , orderByClause = paramList "orderBy"
    , distinctOnColumn = paramOrNothing "distinctOnColumn"
    , limit = paramOrNothing "limit"
    , offset = paramOrNothing "offset"
    }

instance ParamReader SelectedColumns where
    readParameter byteString = pure $
        byteString
            |> cs
            |> Text.split (\char -> char == ',')
            |> SelectSpecific

instance ParamReader OrderByClause where
    readParameter byteString = case ByteString.split ',' byteString of
            [orderByColumn, order] -> do
                orderByDirection <- parseOrder order
                pure OrderByClause { orderByColumn, orderByDirection }
            [orderByColumn] -> pure OrderByClause { orderByColumn, orderByDirection = Asc }
        where
            parseOrder "asc" = Right Asc
            parseOrder "desc" = Right Desc
            parseOrder otherwise = Left ("Invalid order " <> cs otherwise)

instance ToJSON PG.SqlError where
    toJSON PG.SqlError { sqlState, sqlErrorMsg, sqlErrorDetail, sqlErrorHint } = object
                [ "state" .= ((cs sqlState) :: Text)
                , "errorMsg" .= ((cs sqlErrorMsg) :: Text)
                , "errorDetail" .= ((cs sqlErrorDetail) :: Text)
                , "errorHint" .= ((cs sqlErrorHint) :: Text)
                ]
        where
            fieldValueToJSON (IntValue value) = toJSON value
            fieldValueToJSON (TextValue value) = toJSON value
            fieldValueToJSON (BoolValue value) = toJSON value
            fieldValueToJSON (UUIDValue value) = toJSON value
            fieldValueToJSON (DateTimeValue value) = toJSON value

instance ToJSON EnhancedSqlError where
    toJSON EnhancedSqlError { sqlError } = toJSON sqlError

renderErrorJson :: (?context :: ControllerContext) => Data.Aeson.ToJSON json => json -> IO ()
renderErrorJson json = renderJsonWithStatusCode status400 json
{-# INLINABLE renderErrorJson #-}

aesonValueToPostgresValue :: Value -> PG.Action
aesonValueToPostgresValue (String text) = PG.toField text
aesonValueToPostgresValue (Bool value) = PG.toField value
aesonValueToPostgresValue (Number value) = case Scientific.floatingOrInteger value of -- Hacky, we should make this function "Schema.sql"-aware in the future
    Left (floating :: Double) -> PG.toField floating
    Right (integer :: Integer) -> PG.toField integer
aesonValueToPostgresValue Data.Aeson.Null = PG.toField PG.Null
aesonValueToPostgresValue (Data.Aeson.Array values) = PG.toField (PG.PGArray (map aesonValueToPostgresValue (Vector.toList values)))
aesonValueToPostgresValue object@(Object values) =
    let
        tryDecodeAsPoint :: Maybe Point
        tryDecodeAsPoint = do
                xValue <- Aeson.lookup "x" values
                yValue <- Aeson.lookup "y" values
                x <- case xValue of
                        Number number -> pure (Scientific.toRealFloat number)
                        otherwise -> Nothing
                y <- case yValue of
                        Number number -> pure (Scientific.toRealFloat number)
                        otherwise -> Nothing
                pure Point { x, y }
    in
        -- This is really hacky and is mostly duck typing. We should refactor this in the future to
        -- become more type aware by passing the DDL of the table to 'aesonValueToPostgresValue'.
        if Aeson.size values == 2
            then fromMaybe (PG.toField $ toJSON object) (PG.toField <$> tryDecodeAsPoint)
            else PG.toField (toJSON object)


instance ToJSON GraphQLResult where
    toJSON GraphQLResult { requestId, graphQLResult } = object [ "tag" .= ("GraphQLResult" :: Text), "requestId" .= requestId, "graphQLResult" .= ("" :: Text) ]
    toEncoding GraphQLResult { requestId, graphQLResult } = Aeson.econcat
        [ Aeson.unsafeToEncoding "{\"tag\":\"GraphQLResult\",\"requestId\":"
        , Aeson.int requestId
        , Aeson.unsafeToEncoding ",\"graphQLResult\":"
        , toEncoding graphQLResult
        , Aeson.unsafeToEncoding "}"
        ]
instance ToJSON UndecodedJSON where
    toJSON (UndecodedJSON _) = error "Not implemented"
    toEncoding (UndecodedJSON json) = Aeson.unsafeToEncoding (ByteString.byteString json)