packages feed

legion-discovery-0.3.0.1: src/Network/Legion/Discovery/Api.hs

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeOperators #-}
{- | The descovery api specification. -}
module Network.Legion.Discovery.Api (
  -- * Api types
  DiscoveryApi,
  V1Api,
  PingApi,
  InstancesApi,
  GraphApi,

  -- * "Real" data types.
  ClientList(..),
  InstanceList(..),
  Range(..),
  PingRequest(..),
  Graph(..),
) where

import Data.Aeson (FromJSON, parseJSON, Value(Object), (.:), eitherDecode,
  encode, object, (.=))
import Data.ByteString (hGetContents)
import Data.GraphViz (graphvizWithHandle, GraphvizCommand(Dot),
  GraphvizOutput(Svg))
import Data.GraphViz.Printing (renderDot, toDot)
import Data.GraphViz.Types.Canonical (DotGraph)
import Data.Map (Map)
import Data.Monoid ((<>))
import Data.Proxy (Proxy(Proxy))
import Data.Text.Encoding (encodeUtf8, decodeUtf8)
import Data.Version (showVersion)
import Distribution.Text (simpleParse)
import Distribution.Version (VersionRange)
import Network.Legion.Discovery.LegionApp (ServiceAddr(ServiceAddr),
  Client(Client), cName, cVersion, EntityName(EntityName), Client,
  unServiceAddr, version, InstanceInfo)
import Network.Legion.Discovery.UserAgent (parseUserAgent,
  UserAgent(UAProduct), Product(Product), productName, productVersion)
import Servant ((:>), (:<|>), Get, Accept, contentType, ReqBody, Capture,
  Header, MimeUnrender, mimeUnrender, MimeRender, mimeRender, NoContent,
  JSON)
import System.IO.Unsafe (unsafePerformIO)
import Web.HttpApiData (FromHttpApiData, parseUrlPiece, parseHeader)
import qualified Data.ByteString.Lazy as BSL
import qualified Data.Map as Map
import qualified Data.Text as T
import qualified Data.Text.Lazy as TL
import qualified Data.Text.Lazy.Encoding as TLE
import qualified Servant as S


type DiscoveryApi =
  "v1" :> V1Api


type V1Api =
  Header "User-Agent" ClientList :> (
    "ping" :> PingApi :<|>
    "services" :> (
      Capture "serviceName" EntityName :> (
        InstancesApi :<|>
        Capture "versionRange" Range :> InstancesApi
      )
    )
  ) :<|>
  "graph" :> GraphApi


{- | The "graph" endpoint. -}
type GraphApi = Get '[SvgCT, DotCT] Graph


{- | The "ping" endpoint. -}
type PingApi = ReqBody '[PingRequestCT] PingRequest :> PostNoContent


{- | Shorthand for a post returning no contents. -}
type PostNoContent = S.PostNoContent '[StupidArbitraryPlaceholder] NoContent


{- | see: https://github.com/haskell-servant/servant/issues/498 -}
type StupidArbitraryPlaceholder = JSON


{- | Api segment that returns a list of instances. -}
type InstancesApi = Get '[InstanceListCT] InstanceList


{- | The type of graph data. -}
newtype Graph = Graph (DotGraph TL.Text)
instance MimeRender DotCT Graph where
  mimeRender Proxy (Graph graph) = 
    TLE.encodeUtf8 . renderDot . toDot $ graph
instance MimeRender SvgCT Graph where
  mimeRender Proxy (Graph graph) = unsafePerformIO $ do
    bytes <- graphvizWithHandle Dot graph Svg hGetContents
    return (BSL.fromStrict bytes)


newtype Range = Range VersionRange
instance FromHttpApiData Range where
  parseUrlPiece r = case simpleParse (T.unpack r) of
    Nothing -> Left . T.pack $ ("Invalid version range: " ++ show r)
    Just vr -> Right (Range vr)


data InstanceListCT
instance Accept InstanceListCT where
  contentType Proxy = "application/vnd.legion-discovery.instance-list+json"


data PingRequestCT
instance Accept PingRequestCT where
  contentType Proxy = "application/vnd.legion-discovery.ping-request+json"


data DotCT
instance Accept DotCT where
  contentType Proxy = "text/vnd.graphviz"


data SvgCT
instance Accept SvgCT where
  contentType Proxy = "image/svg+xml"


newtype ClientList = ClientList [Client]
instance FromHttpApiData ClientList where
  parseUrlPiece = parseHeader . encodeUtf8
  parseHeader bytes = do
    agents <- parseUserAgent bytes
    return $ ClientList [
        Client {
            cName = EntityName (decodeUtf8 name),
            cVersion = version
          }
        | UAProduct Product {productName, productVersion} <- agents
        , let
            vstring = T.unpack . decodeUtf8 <$> productVersion
            (name, version) = case simpleParse =<< vstring of
              Nothing -> (
                  productName <> maybe "" ("/" <>) productVersion,
                  Nothing
                )
              Just v -> (productName, Just v)
      ]


newtype InstanceList = InstanceList (Map ServiceAddr InstanceInfo)
instance MimeRender InstanceListCT InstanceList where
  mimeRender Proxy (InstanceList instances) = 
    encode $ object [
        unServiceAddr addr .= object [
            "version" .= showVersion (version info)
          ]
        | (addr, info) <- Map.toList instances
      ]


{- | Decode a ping request entity. -}
newtype PingRequest = PingRequest {
    serviceAddress :: ServiceAddr
  }
instance FromJSON PingRequest where
  parseJSON (Object o) =
    PingRequest . ServiceAddr <$> o .: "serviceAddress"
  parseJSON v = fail
    $ "Can't parse PingRequest from: " ++ show v
instance MimeUnrender PingRequestCT PingRequest where
  mimeUnrender Proxy = eitherDecode