packages feed

mig-0.2.0.0: src/Mig/Core/OpenApi.hs

-- | Renders mig-servers as OpenApi schemas
module Mig.Core.OpenApi (
  toOpenApi,
) where

import Control.Lens (at, (%~), (&), (.~), (?~))
import Data.Aeson (ToJSON (..))
import Data.HashMap.Strict.InsOrd qualified as InsOrdHashMap
import Data.HashSet.InsOrd qualified as Set
import Data.Monoid (Endo (..))
import Data.OpenApi hiding (Server (..))
import Data.Proxy
import Data.Text (Text)
import Data.Text qualified as Text
import Mig.Core.Api (Api)
import Mig.Core.Api qualified as Api
import Mig.Core.Class.Route (Route (..))
import Mig.Core.Server (Server (..), fillCaptures)
import Mig.Core.Types.Info (IsRequired (..), RouteInfo)
import Mig.Core.Types.Info qualified as Info
import Network.HTTP.Types.Method
import Network.HTTP.Types.Status (Status (..))

addCapture :: Text -> OpenApi -> OpenApi
addCapture captureName =
  prependPath ("{" <> Text.unpack captureName <> "}")

-- | Reads OpenApi schema for a server
toOpenApi :: Server m -> OpenApi
toOpenApi (Server x) = fromApiInfo (fmap (.info) $ fillCaptures x)

fromApiInfo :: Api RouteInfo -> OpenApi
fromApiInfo = \case
  Api.Empty -> mempty
  Api.Append a b -> fromApiInfo a <> fromApiInfo b
  Api.WithPath (Api.Path path) a -> foldr withPath (fromApiInfo a) path
  Api.HandleRoute route -> fromRoute route

withPath :: Api.PathItem -> OpenApi -> OpenApi
withPath = \case
  Api.StaticPath pathName -> prependPath (Text.unpack pathName)
  Api.CapturePath captureName -> addCapture captureName

fromRoute :: RouteInfo -> OpenApi
fromRoute routeInfo =
  appEndo
    (foldMap (Endo . fromRouteInput) routeInfo.inputs)
    (fromRouteOutput routeInfo)

fromRouteOutput :: RouteInfo -> OpenApi
fromRouteOutput routeInfo =
  mempty
    & components . schemas .~ defs
    & paths . at "/"
      ?~ ( mempty
            & method
              ?~ ( mempty
                    & at code
                      ?~ Inline
                        ( mempty
                            & content
                              .~ InsOrdHashMap.fromList
                                [(t, mempty & schema .~ mref) | t <- responseContentTypes]
                            & headers .~ responseHeaders
                        )
                    & tags .~ Set.fromList routeInfo.tags
                    & summary .~ nonEmptyText routeInfo.summary
                    & description .~ nonEmptyText routeInfo.description
                 )
         )
  where
    method = case routeInfo.method of
      Just m | m == methodGet -> get
      Just m | m == methodPost -> post
      Just m | m == methodPut -> put
      Just m | m == methodDelete -> delete
      Just m | m == methodOptions -> options
      Just m | m == methodHead -> head_
      Just m | m == methodPatch -> patch
      Just m | m == methodTrace -> trace
      _ -> mempty

    code = routeInfo.output.status.statusCode

    responseContentTypes = [routeInfo.output.media]

    -- TODO: is it always empty?
    responseHeaders = Inline <$> mempty

    Info.SchemaDefs defs mref = routeInfo.output.schema

fromRouteInput :: Info.Describe Info.RouteInput -> OpenApi -> OpenApi
fromRouteInput descInput base = case descInput.content of
  Info.ReqBodyInput inputType bodySchema -> onRequestBody inputType bodySchema
  Info.RawBodyInput -> base
  Info.CaptureInput captureName captureSchema -> onCapture captureName captureSchema
  Info.QueryInput isRequired queryName querySchema -> onQuery isRequired queryName querySchema
  Info.QueryFlagInput queryName -> onQueryFlag queryName
  Info.HeaderInput isRequired headerName headerSchema -> onHeader isRequired headerName headerSchema
  where
    onCapture = onParam addDefaultResponse404 ParamPath (IsRequired True)

    onQuery = onParam addDefaultResponse400 ParamQuery

    onHeader = onParam addDefaultResponse400 ParamHeader

    onParam defResponse paramType (IsRequired isRequired) paramName paramSchema =
      base
        & addParam param
        & defResponse paramName
      where
        param =
          mempty
            & name .~ paramName
            & description .~ (nonEmptyText =<< descInput.description)
            & required ?~ isRequired
            & in_ .~ paramType
            & schema ?~ (Inline paramSchema)

    onRequestBody bodyInputType (Info.SchemaDefs defs ref) =
      base
        & addRequestBody reqBody
        & addDefaultResponse400 "body"
        & components . schemas %~ (<> defs)
      where
        reqBody =
          (mempty :: RequestBody)
            & description .~ (nonEmptyText =<< descInput.description)
            & content .~ InsOrdHashMap.fromList [(t, mempty & schema .~ ref) | t <- [bodyContentType]]

        bodyContentType = bodyInputType

    onQueryFlag queryName =
      base
        & addParam param
        & addDefaultResponse400 queryName
      where
        param =
          mempty
            & name .~ queryName
            & in_ .~ ParamQuery
            & allowEmptyValue ?~ True
            & schema
              ?~ ( Inline $
                    (toParamSchema (Proxy :: Proxy Bool))
                      & default_ ?~ toJSON False
                 )

-------------------------------------------------------------------------------------
-- openapi utils

nonEmptyText :: Text -> Maybe Text
nonEmptyText txt
  | Text.null txt = Nothing
  | otherwise = Just txt

-- | Add RequestBody to every operations in the spec.
addRequestBody :: RequestBody -> OpenApi -> OpenApi
addRequestBody rb = allOperations . requestBody ?~ Inline rb

-- | Add parameter to every operation in the spec.
addParam :: Param -> OpenApi -> OpenApi
addParam param = allOperations . parameters %~ (Inline param :)

addDefaultResponse404 :: ParamName -> OpenApi -> OpenApi
addDefaultResponse404 pname = setResponseWith (\old _new -> alter404 old) 404 (return response404)
  where
    sname = markdownCode pname
    description404 = sname <> " not found"
    alter404 = description %~ ((sname <> " or ") <>)
    response404 = mempty & description .~ description404

addDefaultResponse400 :: ParamName -> OpenApi -> OpenApi
addDefaultResponse400 pname = setResponseWith (\old _new -> alter400 old) 400 (return response400)
  where
    sname = markdownCode pname
    description400 = "Invalid " <> sname
    alter400 = description %~ (<> (" or " <> sname))
    response400 = mempty & description .~ description400

-- | Format given text as inline code in Markdown.
markdownCode :: Text -> Text
markdownCode s = "`" <> s <> "`"