packages feed

acolyte-openapi-0.1.0.0: src/Acolyte/OpenApi/Spec.hs

{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE UndecidableInstances #-}
-- | OpenAPI 3.1 spec generation from API types.
module Acolyte.OpenApi.Spec
  ( -- * Spec generation
    OpenApiSpec (..)
  , generateSpec
    -- * Per-endpoint generation
  , EndpointToOperation (..)
  , Operation (..)
  , Parameter (..)
    -- * API walking
  , ApiToOperations (..)
    -- * Capture name reflection
  , KnownCaptureName (..)
    -- * Helpers
  , opMethodLower
  ) where

import Data.Aeson (Value (..), object, (.=), ToJSON (..))
import qualified Data.Aeson.Key as Key
import Data.Kind (Type)
import Data.Proxy (Proxy (..))
import Data.Text (Text)
import qualified Data.Text as T
import GHC.TypeLits (KnownSymbol, KnownNat, symbolVal, natVal)
import Network.HTTP.Types (Method)

import Acolyte.Core.Method (KnownMethod, methodVal)
import qualified Acolyte.Core.Method as Core
import Acolyte.Core.Path (PathSegment (..))
import Acolyte.Core.Endpoint (Endpoint, NoBody)
import Acolyte.Core.Effect (Requires)
import Data.Typeable (Typeable, typeRep)
import Acolyte.Core.Wrapper
  ( Describe, Description, Named, Versioned, ApiVersion (..)
  , WithParams, QP, WithHeaders, HH
  , ServerStream, ClientStream, BidiStream, RespondsWith
  )
import Acolyte.OpenApi.Schema (Schema (..), ToSchema (..), schemaToJson)


-- | A complete OpenAPI 3.1 spec.
data OpenApiSpec = OpenApiSpec
  { specTitle   :: !Text
  , specVersion :: !Text
  , specOps     :: ![Operation]
  } deriving (Show)

instance ToJSON OpenApiSpec where
  toJSON spec = object
    [ "openapi" .= ("3.1.0" :: Text)
    , "info" .= object
        [ "title"   .= specTitle spec
        , "version" .= specVersion spec
        ]
    , "paths" .= groupByPath (specOps spec)
    ]


groupByPath :: [Operation] -> Value
groupByPath ops = object
  [ Key.fromText (opPath op) .= object [Key.fromText (opMethodLower op) .= opToJson op]
  | op <- ops
  ]


opToJson :: Operation -> Value
opToJson op = object $ concat
  [ case opOperationId op of
      Nothing  -> []
      Just oid -> ["operationId" .= oid]
  , case opSummary op of
      Nothing -> []
      Just s  -> ["summary" .= s]
  , case opDescription op of
      Nothing -> []
      Just d  -> ["description" .= d]
  , [ "responses" .= object
        [ Key.fromText (T.pack (show (opStatusCode op))) .= responseObj ]
    ]
  , if null (opParameters op) then []
    else ["parameters" .= map paramToJson (opParameters op)]
  , case opRequestBody op of
      Nothing -> []
      Just schema ->
        [ "requestBody" .= object
            [ "required" .= True
            , "content" .= object
                [ "application/json" .= object
                    [ "schema" .= schemaToJson schema ]
                ]
            ]
        ]
  ]
  where
    responseObj = case opResponseSchema op of
      Nothing -> object ["description" .= ("Success" :: Text)]
      Just schema -> object
        [ "description" .= ("Success" :: Text)
        , "content" .= object
            [ "application/json" .= object
                [ "schema" .= schemaToJson schema ]
            ]
        ]


-- | A single API operation.
data Operation = Operation
  { opMethod         :: !Text
  , opPath           :: !Text
  , opOperationId    :: !(Maybe Text)
  , opSummary        :: !(Maybe Text)
  , opDescription    :: !(Maybe Text)
  , opParameters     :: ![Parameter]
  , opStatusCode     :: !Int
  , opResponseSchema :: !(Maybe Schema)
  , opRequestBody    :: !(Maybe Schema)
  } deriving (Show)

-- | Return the operation's HTTP method in lowercase (e.g. @"get"@, @"post"@).
opMethodLower :: Operation -> Text
opMethodLower = T.toLower . opMethod


-- | An API parameter.
data Parameter = Parameter
  { paramName     :: !Text
  , paramIn       :: !Text
  , paramRequired :: !Bool
  , paramSchema   :: !Schema
  } deriving (Show)

paramToJson :: Parameter -> Value
paramToJson p = object
  [ "name"     .= paramName p
  , "in"       .= paramIn p
  , "required" .= paramRequired p
  , "schema"   .= schemaToJson (paramSchema p)
  ]


-- ===================================================================
-- Per-endpoint operation generation
-- ===================================================================

-- | Convert an endpoint type into an OpenAPI 'Operation'.
class EndpointToOperation endpoint where
  toOperation :: Operation

-- GET (no request body)
instance (KnownMethod m, ReflectPathOA path, m ~ 'Core.GET, ToSchema resp)
  => EndpointToOperation (Endpoint m path NoBody resp) where
    toOperation = Operation
      { opMethod         = T.pack (show (methodVal @m))
      , opPath           = reflectOAPath @path
      , opOperationId    = Nothing
      , opSummary        = Nothing
      , opDescription    = Nothing
      , opParameters     = reflectOAParams @path
      , opStatusCode     = 200
      , opResponseSchema = Just (toSchema @resp)
      , opRequestBody    = Nothing
      }

-- DELETE (no request body)
instance (ReflectPathOA path, ToSchema resp)
  => EndpointToOperation (Endpoint 'Core.DELETE path NoBody resp) where
    toOperation = Operation
      { opMethod         = "DELETE"
      , opPath           = reflectOAPath @path
      , opOperationId    = Nothing
      , opSummary        = Nothing
      , opDescription    = Nothing
      , opParameters     = reflectOAParams @path
      , opStatusCode     = 200
      , opResponseSchema = Just (toSchema @resp)
      , opRequestBody    = Nothing
      }

-- POST (with request body)
instance (ReflectPathOA path, ToSchema req, ToSchema resp)
  => EndpointToOperation (Endpoint 'Core.POST path req resp) where
    toOperation = Operation
      { opMethod         = "POST"
      , opPath           = reflectOAPath @path
      , opOperationId    = Nothing
      , opSummary        = Nothing
      , opDescription    = Nothing
      , opParameters     = reflectOAParams @path
      , opStatusCode     = 201
      , opResponseSchema = Just (toSchema @resp)
      , opRequestBody    = Just (toSchema @req)
      }

-- PUT (with request body)
instance (ReflectPathOA path, ToSchema req, ToSchema resp)
  => EndpointToOperation (Endpoint 'Core.PUT path req resp) where
    toOperation = Operation
      { opMethod         = "PUT"
      , opPath           = reflectOAPath @path
      , opOperationId    = Nothing
      , opSummary        = Nothing
      , opDescription    = Nothing
      , opParameters     = reflectOAParams @path
      , opStatusCode     = 200
      , opResponseSchema = Just (toSchema @resp)
      , opRequestBody    = Just (toSchema @req)
      }

-- PATCH (with request body)
instance (ReflectPathOA path, ToSchema req, ToSchema resp)
  => EndpointToOperation (Endpoint 'Core.PATCH path req resp) where
    toOperation = Operation
      { opMethod         = "PATCH"
      , opPath           = reflectOAPath @path
      , opOperationId    = Nothing
      , opSummary        = Nothing
      , opDescription    = Nothing
      , opParameters     = reflectOAParams @path
      , opStatusCode     = 200
      , opResponseSchema = Just (toSchema @resp)
      , opRequestBody    = Just (toSchema @req)
      }

-- Requires delegates
instance EndpointToOperation inner
  => EndpointToOperation (Requires e inner) where
    toOperation = toOperation @inner

-- Describe extracts the endpoint summary from the type-level description
instance (KnownSymbol desc, EndpointToOperation inner)
  => EndpointToOperation (Describe desc inner) where
    toOperation =
      let op = toOperation @inner
      in op { opSummary = Just (T.pack (symbolVal (Proxy @desc))) }

-- Description sets the operation description (longer text, distinct from summary)
instance (KnownSymbol desc, EndpointToOperation inner)
  => EndpointToOperation (Description desc inner) where
    toOperation =
      let op = toOperation @inner
      in op { opDescription = Just (T.pack (symbolVal (Proxy @desc))) }

-- WithParams reflects query parameters into the operation
instance (ReflectParams ps, EndpointToOperation inner)
  => EndpointToOperation (WithParams ps inner) where
    toOperation =
      let op = toOperation @inner
      in op { opParameters = opParameters op ++ reflectParams @ps }

-- WithHeaders reflects header parameters into the operation
instance (ReflectHeaders hs, EndpointToOperation inner)
  => EndpointToOperation (WithHeaders hs inner) where
    toOperation =
      let op = toOperation @inner
      in op { opParameters = opParameters op ++ reflectHeaders @hs }

-- ServerStream delegates (streaming marker)
instance EndpointToOperation inner
  => EndpointToOperation (ServerStream inner) where
    toOperation = toOperation @inner

-- ClientStream delegates (streaming marker)
instance EndpointToOperation inner
  => EndpointToOperation (ClientStream inner) where
    toOperation = toOperation @inner

-- BidiStream delegates (streaming marker)
instance EndpointToOperation inner
  => EndpointToOperation (BidiStream inner) where
    toOperation = toOperation @inner

-- RespondsWith extracts the status code from the type-level Nat
instance (KnownNat s, EndpointToOperation inner)
  => EndpointToOperation (RespondsWith s inner) where
    toOperation =
      let op = toOperation @inner
      in op { opStatusCode = fromIntegral (natVal (Proxy @s)) }

-- Named sets operationId from the type-level name
instance (KnownSymbol name, EndpointToOperation inner)
  => EndpointToOperation (Named name inner) where
    toOperation =
      let op = toOperation @inner
      in op { opOperationId = Just (T.pack (symbolVal (Proxy @name))) }

-- Versioned prepends the version prefix to the path
instance (ApiVersion v, EndpointToOperation inner)
  => EndpointToOperation (Versioned v inner) where
    toOperation =
      let op = toOperation @inner
      in op { opPath = "/" <> versionPrefix @v <> opPath op }


-- ===================================================================
-- Query parameter reflection
-- ===================================================================

-- | Reflect type-level query parameter declarations into runtime Parameter values.
class ReflectParams (ps :: [Type]) where
  reflectParams :: [Parameter]

instance ReflectParams '[] where
  reflectParams = []

instance (KnownSymbol name, ReflectParams rest)
  => ReflectParams (QP name a ': rest) where
    reflectParams = Parameter
      { paramName     = T.pack (symbolVal (Proxy @name))
      , paramIn       = "query"
      , paramRequired = False
      , paramSchema   = Schema "string" [] Nothing Nothing Nothing Nothing False
      } : reflectParams @rest


-- ===================================================================
-- Header parameter reflection
-- ===================================================================

-- | Reflect type-level header declarations into runtime Parameter values.
class ReflectHeaders (hs :: [Type]) where
  reflectHeaders :: [Parameter]

instance ReflectHeaders '[] where
  reflectHeaders = []

instance (KnownSymbol name, ReflectHeaders rest)
  => ReflectHeaders (HH name a ': rest) where
    reflectHeaders = Parameter
      { paramName     = T.pack (symbolVal (Proxy @name))
      , paramIn       = "header"
      , paramRequired = True
      , paramSchema   = Schema "string" [] Nothing Nothing Nothing Nothing False
      } : reflectHeaders @rest


-- ===================================================================
-- Capture name reflection
-- ===================================================================

-- | Derive a parameter name for a capture type.
-- Override this for custom names; the default lowercases the type name.
class KnownCaptureName (t :: Type) where
  captureName :: Text

instance {-# OVERLAPPABLE #-} Typeable t => KnownCaptureName t where
  captureName = T.toLower (T.pack (show (typeRep (Proxy @t))))


-- ===================================================================
-- Path reflection for OpenAPI format
-- ===================================================================

class ReflectPathOA (path :: [PathSegment]) where
  reflectOAPath   :: Text
  reflectOAParams :: [Parameter]

instance ReflectPathOA '[] where
  reflectOAPath   = ""
  reflectOAParams = []

instance (KnownSymbol s, ReflectPathOA rest)
  => ReflectPathOA ('Lit s ': rest) where
    reflectOAPath = "/" <> T.pack (symbolVal (Proxy @s)) <> reflectOAPath @rest
    reflectOAParams = reflectOAParams @rest

instance (KnownCaptureName t, ToSchema t, ReflectPathOA rest)
  => ReflectPathOA ('Capture t ': rest) where
    reflectOAPath = "/{" <> captureName @t <> "}" <> reflectOAPath @rest
    reflectOAParams = Parameter
      { paramName     = captureName @t
      , paramIn       = "path"
      , paramRequired = True
      , paramSchema   = toSchema @t
      } : reflectOAParams @rest

instance (KnownSymbol name, ToSchema t, ReflectPathOA rest)
  => ReflectPathOA ('CaptureNamed name t ': rest) where
    reflectOAPath = "/{" <> T.pack (symbolVal (Proxy @name)) <> "}" <> reflectOAPath @rest
    reflectOAParams = Parameter
      { paramName     = T.pack (symbolVal (Proxy @name))
      , paramIn       = "path"
      , paramRequired = True
      , paramSchema   = toSchema @t
      } : reflectOAParams @rest


-- ===================================================================
-- Walk an API type list
-- ===================================================================

-- | Walk a promoted list of endpoint types, collecting their 'Operation's.
class ApiToOperations (api :: [Type]) where
  toOperations :: [Operation]

instance ApiToOperations '[] where
  toOperations = []

instance (EndpointToOperation e, ApiToOperations rest)
  => ApiToOperations (e ': rest) where
    toOperations = toOperation @e : toOperations @rest


-- ===================================================================
-- Top-level spec generation
-- ===================================================================

-- | Generate a complete OpenAPI 3.1 spec from an API type, title, and version.
generateSpec
  :: forall api. ApiToOperations api
  => Text -> Text -> OpenApiSpec
generateSpec title version = OpenApiSpec
  { specTitle   = title
  , specVersion = version
  , specOps     = toOperations @api
  }