2 Module : Gargantext.API
3 Description : REST API declaration
4 Copyright : (c) CNRS, 2017-Present
5 License : AGPL + CECILL v3
6 Maintainer : team@gargantext.org
7 Stability : experimental
10 Main (RESTful) API of the instance Gargantext.
12 The Garg-API is typed to derive the documentation, the mock and tests.
14 This API is indeed typed in order to be able to derive both the server
17 The Garg-API-Monad enables:
20 - Database connection (long term)
21 - In Memory stack management (short term)
24 Thanks to Yann Esposito for our discussions at the start and to Nicolas
25 Pouillard (who mainly made it).
29 {-# OPTIONS_GHC -fno-warn-name-shadowing #-}
31 {-# LANGUAGE ConstraintKinds #-}
32 {-# LANGUAGE NoImplicitPrelude #-}
33 {-# LANGUAGE DataKinds #-}
34 {-# LANGUAGE DeriveGeneric #-}
35 {-# LANGUAGE FlexibleContexts #-}
36 {-# LANGUAGE FlexibleInstances #-}
37 {-# LANGUAGE OverloadedStrings #-}
38 {-# LANGUAGE TemplateHaskell #-}
39 {-# LANGUAGE TypeOperators #-}
40 {-# LANGUAGE KindSignatures #-}
41 {-# LANGUAGE RankNTypes #-}
42 {-# LANGUAGE ScopedTypeVariables #-}
43 {-# LANGUAGE TypeFamilies #-}
44 {-# LANGUAGE UndecidableInstances #-}
46 ---------------------------------------------------------------------
49 ---------------------------------------------------------------------
50 import Control.Concurrent (threadDelay)
51 import Control.Exception (finally)
53 import Control.Monad.Except (withExceptT, ExceptT)
54 import Control.Monad.IO.Class (liftIO)
55 import Control.Monad.Reader (ReaderT, runReaderT)
56 import Data.Aeson.Encode.Pretty (encodePretty)
58 import Data.Text (Text)
60 import GHC.Generics (D1, Meta (..), Rep)
61 import GHC.TypeLits (AppendSymbol, Symbol)
63 import Network.Wai.Handler.Warp hiding (defaultSettings)
65 import Servant.Auth as SA
66 import Servant.Auth.Server (AuthResult(..))
67 import Servant.Auth.Swagger ()
68 import Servant.Job.Async
69 import Servant.Swagger
70 import Servant.Swagger.UI
71 import System.IO (FilePath)
72 import Data.List (lookup)
73 import Data.Text.Encoding (encodeUtf8)
74 import GHC.Base (Applicative)
75 import Gargantext.API.Auth (AuthRequest, AuthResponse, AuthenticatedUser(..), AuthContext, auth, withAccess, PathId(..))
76 import Gargantext.API.Count ( CountAPI, count, Query)
77 import Gargantext.API.FrontEnd (FrontEndAPI, frontEndServer)
78 import Gargantext.API.Ngrams (HasRepo(..), HasRepoSaver(..), saveRepo, TableNgramsApi, apiNgramsTableDoc)
79 import Gargantext.API.Node
80 import Gargantext.API.Orchestrator.Types
81 import Gargantext.API.Search (SearchPairsAPI, searchPairs)
82 import Gargantext.API.Settings
83 import Gargantext.API.Types
84 import Gargantext.Database.Node.Contact (HyperdataContact)
85 import Gargantext.Database.Types.Node
86 import Gargantext.Database.Types.Node (NodeId, CorpusId, AnnuaireId)
87 import Gargantext.Database.Utils (HasConnection)
88 import Gargantext.Prelude
89 import Gargantext.Viz.Graph.API
90 import Network.HTTP.Types hiding (Query)
91 import Network.Wai (Request, requestHeaders)
92 import Network.Wai.Middleware.Cors
93 import Network.Wai.Middleware.RequestLogger
94 import qualified Data.ByteString.Lazy.Char8 as BL8
95 import qualified Data.Text.IO as T
96 import qualified Gargantext.API.Annuaire as Annuaire
97 import qualified Gargantext.API.Corpus.New as New
98 import qualified Gargantext.API.Export as Export
99 import qualified Gargantext.API.Ngrams.List as List
101 showAsServantErr :: GargError -> ServerError
102 showAsServantErr (GargServerError err) = err
103 showAsServantErr a = err500 { errBody = BL8.pack $ show a }
105 fireWall :: Applicative f => Request -> FireWall -> f Bool
107 let origin = lookup "Origin" (requestHeaders req)
108 let host = lookup "Host" (requestHeaders req)
110 let hostOk = Just (encodeUtf8 "localhost:3000")
111 let originOk = Just (encodeUtf8 "http://localhost:8008")
113 if origin == originOk
115 || (not $ unFireWall fw)
121 -- makeMockApp :: Env -> IO (Warp.Settings, Application)
122 makeMockApp :: MockEnv -> IO Application
124 let serverApp = appMock
126 -- logWare <- mkRequestLogger def { destination = RequestLogger.Logger $ env^.logger }
127 --logWare <- mkRequestLogger def { destination = RequestLogger.Logger "/tmp/logs.txt" }
128 let checkOriginAndHost app req resp = do
129 blocking <- fireWall req (env ^. menv_firewall)
132 False -> resp ( responseLBS status401 []
133 "Invalid Origin or Host header")
135 let corsMiddleware = cors $ \_ -> Just CorsResourcePolicy
136 -- { corsOrigins = Just ([env^.settings.allowedOrigin], False)
137 { corsOrigins = Nothing -- == /*
138 , corsMethods = [ methodGet , methodPost , methodPut
139 , methodDelete, methodOptions, methodHead]
140 , corsRequestHeaders = ["authorization", "content-type"]
141 , corsExposedHeaders = Nothing
142 , corsMaxAge = Just ( 60*60*24 ) -- one day
143 , corsVaryOrigin = False
144 , corsRequireOrigin = False
145 , corsIgnoreFailures = False
148 --let warpS = Warp.setPort (8008 :: Int) -- (env^.settings.appPort)
149 -- $ Warp.defaultSettings
151 --pure (warpS, logWare $ checkOriginAndHost $ corsMiddleware $ serverApp)
152 pure $ logStdoutDev $ checkOriginAndHost $ corsMiddleware $ serverApp
156 makeDevMiddleware :: IO Middleware
157 makeDevMiddleware = do
159 -- logWare <- mkRequestLogger def { destination = RequestLogger.Logger $ env^.logger }
160 --logWare <- mkRequestLogger def { destination = RequestLogger.Logger "/tmp/logs.txt" }
161 -- let checkOriginAndHost app req resp = do
162 -- blocking <- fireWall req (env ^. menv_firewall)
164 -- True -> app req resp
165 -- False -> resp ( responseLBS status401 []
166 -- "Invalid Origin or Host header")
168 let corsMiddleware = cors $ \_ -> Just CorsResourcePolicy
169 -- { corsOrigins = Just ([env^.settings.allowedOrigin], False)
170 { corsOrigins = Nothing -- == /*
171 , corsMethods = [ methodGet , methodPost , methodPut
172 , methodDelete, methodOptions, methodHead]
173 , corsRequestHeaders = ["authorization", "content-type"]
174 , corsExposedHeaders = Nothing
175 , corsMaxAge = Just ( 60*60*24 ) -- one day
176 , corsVaryOrigin = False
177 , corsRequireOrigin = False
178 , corsIgnoreFailures = False
181 --let warpS = Warp.setPort (8008 :: Int) -- (env^.settings.appPort)
182 -- $ Warp.defaultSettings
184 --pure (warpS, logWare . checkOriginAndHost . corsMiddleware)
185 pure $ logStdoutDev . corsMiddleware
187 ---------------------------------------------------------------------
190 -- | API for serving @swagger.json@
191 type SwaggerAPI = SwaggerSchemaUI "swagger-ui" "swagger.json"
193 -- | API for serving main operational routes of @gargantext.org@
196 type GargAPI = "api" :> Summary "API " :> GargAPIVersion
197 -- | TODO :<|> Summary "Latest API" :> GargAPI'
200 type GargAPIVersion = "v1.0" :> Summary "v1.0: " :> GargAPI'
204 "auth" :> Summary "AUTH API"
205 :> ReqBody '[JSON] AuthRequest
206 :> Post '[JSON] AuthResponse
207 -- TODO-ACCESS here we want to request a particular header for
208 -- auth and capabilities.
211 type GargPrivateAPI = SA.Auth '[SA.JWT, SA.Cookie] AuthenticatedUser :> GargPrivateAPI'
215 = "user" :> Summary "First user endpoint"
217 :<|> "nodes" :> Summary "Nodes endpoint"
218 :> ReqBody '[JSON] [NodeId] :> NodesAPI
220 ----------------------------------------
222 type WaitAPI = Get '[JSON] Text
224 waitAPI :: Int -> GargServer WaitAPI
227 m = (10 :: Int) ^ (6 :: Int)
228 _ <- liftIO $ threadDelay ( m * n)
229 pure $ "Waited: " <> (cs $ show n)
230 ----------------------------------------
233 type GargPrivateAPI' =
237 :<|> "node" :> Summary "Node endpoint"
238 :> Capture "node_id" NodeId
239 :> NodeAPI HyperdataAny
242 :<|> "corpus" :> Summary "Corpus endpoint"
243 :> Capture "corpus_id" CorpusId
244 :> NodeAPI HyperdataCorpus
246 :<|> "corpus" :> Summary "Corpus endpoint"
247 :> Capture "node1_id" NodeId
249 :> Capture "node2_id" NodeId
250 :> NodeNodeAPI HyperdataAny
252 :<|> "corpus" :> Capture "node_id" CorpusId
256 :<|> "annuaire" :> Summary "Annuaire endpoint"
257 :> Capture "annuaire_id" AnnuaireId
258 :> NodeAPI HyperdataAnnuaire
260 :<|> "annuaire" :> Summary "Contact endpoint"
261 :> Capture "annuaire_id" NodeId
263 :> Capture "contact_id" NodeId
264 :> NodeNodeAPI HyperdataContact
267 :<|> "document" :> Summary "Document endpoint"
268 :> Capture "doc_id" DocId
269 :> "ngrams" :> TableNgramsApi
271 -- :<|> "counts" :> Stream GET NewLineFraming '[JSON] Count :> CountAPI
273 :<|> "count" :> Summary "Count endpoint"
274 :> ReqBody '[JSON] Query
277 -- Corpus endpoint --> TODO rename s/search/filter/g
278 :<|> "search" :> Capture "corpus" NodeId
281 -- TODO move to NodeAPI?
282 :<|> "graph" :> Summary "Graph endpoint"
283 :> Capture "graph_id" NodeId
286 -- TODO move to NodeAPI?
288 :<|> "tree" :> Summary "Tree endpoint"
289 :> Capture "tree_id" NodeId
294 :<|> New.AddWithQuery
296 :<|> Annuaire.AddWithForm
297 -- :<|> New.AddWithFile
298 -- :<|> "scraper" :> WithCallbacks ScraperAPI
299 -- :<|> "new" :> New.Api
301 :<|> "lists" :> Summary "List export API"
302 :> Capture "listId" ListId
305 :<|> "wait" :> Summary "Wait test"
307 :> WaitAPI -- Get '[JSON] Int
313 -- :<|> "list" :> Capture "node_id" Int :> NodeAPI
314 -- :<|> "ngrams" :> Capture "node_id" Int :> NodeAPI
315 -- :<|> "auth" :> Capture "node_id" Int :> NodeAPI
316 ---------------------------------------------------------------------
318 type API = SwaggerAPI
322 -- This is the concrete monad. It needs to be used as little as possible,
323 -- instead, prefer GargServer, GargServerT, GargServerC.
324 type GargServerM env err = ReaderT env (ExceptT err IO)
330 , HasJobEnv env ScraperStatus ScraperStatus
333 ---------------------------------------------------------------------
334 -- | Server declarations
336 server :: forall env. EnvC env => env -> IO (Server API)
338 -- orchestrator <- scrapyOrchestrator env
339 pure $ schemaUiServer swaggerDoc
340 :<|> hoistServerWithContext (Proxy :: Proxy GargAPI) (Proxy :: Proxy AuthContext) transform serverGargAPI
343 transform :: forall a. GargServerM env GargError a -> Handler a
344 transform = Handler . withExceptT showAsServantErr . (`runReaderT` env)
346 serverGargAPI :: GargServerT env err (GargServerM env err) GargAPI
347 serverGargAPI -- orchestrator
348 = auth :<|> serverPrivateGargAPI
351 serverPrivateGargAPI :: GargServerT env err (GargServerM env err) GargPrivateAPI
352 serverPrivateGargAPI (Authenticated auser) = serverPrivateGargAPI' auser
353 serverPrivateGargAPI _ = throwAll' (_ServerError # err401)
354 -- Here throwAll' requires a concrete type for the monad.
356 -- TODO-SECURITY admin only: withAdmin
357 -- Question: How do we mark admins?
358 serverGargAdminAPI :: GargServer GargAdminAPI
359 serverGargAdminAPI = roots
363 serverPrivateGargAPI' :: AuthenticatedUser -> GargServer GargPrivateAPI'
364 serverPrivateGargAPI' (AuthenticatedUser (NodeId uid))
366 :<|> nodeAPI (Proxy :: Proxy HyperdataAny) uid
367 :<|> nodeAPI (Proxy :: Proxy HyperdataCorpus) uid
368 :<|> nodeNodeAPI (Proxy :: Proxy HyperdataAny) uid
369 :<|> Export.getCorpus -- uid
370 :<|> nodeAPI (Proxy :: Proxy HyperdataAnnuaire) uid
371 :<|> nodeNodeAPI (Proxy :: Proxy HyperdataContact) uid
373 :<|> withAccess (Proxy :: Proxy TableNgramsApi) Proxy uid
374 <$> PathNode <*> apiNgramsTableDoc
376 :<|> count -- TODO: undefined
378 :<|> withAccess (Proxy :: Proxy SearchPairsAPI) Proxy uid
379 <$> PathNode <*> searchPairs -- TODO: move elsewhere
381 :<|> withAccess (Proxy :: Proxy GraphAPI) Proxy uid
382 <$> PathNode <*> graphAPI uid -- TODO: mock
384 :<|> withAccess (Proxy :: Proxy TreeAPI) Proxy uid
385 <$> PathNode <*> treeAPI
388 -- :<|> (\corpus -> addWithQuery corpus :<|> addWithFile corpus)
389 :<|> addCorpusWithForm
390 :<|> addCorpusWithQuery
392 :<|> addAnnuaireWithForm
393 -- :<|> New.api uid -- TODO-SECURITY
394 -- :<|> New.info uid -- TODO-SECURITY
400 addUpload :: GargServer New.Upload
401 addUpload cId = (serveJobsAPI $ JobFunction (\i log -> New.addToCorpusJobFunction cid i (liftIO . log)))
402 :<|> (serveJobsAPI $ JobFunction (\i log -> New.addToCorpusWithForm cid i (liftIO . log)))
405 addCorpusWithQuery :: GargServer New.AddWithQuery
406 addCorpusWithQuery cid =
408 JobFunction (\i log -> New.addToCorpusJobFunction cid i (liftIO . log))
410 addWithFile :: GargServer New.AddWithFile
411 addWithFile cid i f =
413 JobFunction (\_i log -> New.addToCorpusWithFile cid i f (liftIO . log))
415 addCorpusWithForm :: GargServer New.AddWithForm
416 addCorpusWithForm cid =
418 JobFunction (\i log -> New.addToCorpusWithForm cid i (liftIO . log))
420 addAnnuaireWithForm :: GargServer Annuaire.AddWithForm
421 addAnnuaireWithForm cid =
423 JobFunction (\i log -> Annuaire.addToAnnuaireWithForm cid i (liftIO . log))
426 serverStatic :: Server (Get '[HTML] Html)
428 let path = "purescript-gargantext/dist/index.html"
429 Just s <- liftIO (fileTypeToFileTree (FileTypeFile path))
433 ---------------------------------------------------------------------
434 --gargMock :: Server GargAPI
435 --gargMock = mock apiGarg Proxy
436 ---------------------------------------------------------------------
437 makeApp :: EnvC env => env -> IO Application
438 makeApp env = serveWithContext api cfg <$> server env
440 cfg :: Servant.Context AuthContext
441 cfg = env ^. settings . jwtSettings
442 :. env ^. settings . cookieSettings
446 --appMock :: Application
447 --appMock = serve api (swaggerFront :<|> gargMock :<|> serverStatic)
448 ---------------------------------------------------------------------
452 apiGarg :: Proxy GargAPI
454 ---------------------------------------------------------------------
455 schemaUiServer :: (Server api ~ Handler Swagger)
456 => Swagger -> Server (SwaggerSchemaUI' dir api)
457 schemaUiServer = swaggerSchemaUIServer
459 -- Type Family for the Documentation
460 type family TypeName (x :: *) :: Symbol where
462 TypeName Text = "Text"
463 TypeName x = GenericTypeName x (Rep x ())
465 type family GenericTypeName t (r :: *) :: Symbol where
466 GenericTypeName t (D1 ('MetaData name mod pkg nt) f x) = name
468 type Desc t n = Description (AppendSymbol (TypeName t) (AppendSymbol " | " n))
471 -- | Swagger Specifications
472 swaggerDoc :: Swagger
473 swaggerDoc = toSwagger (Proxy :: Proxy GargAPI)
474 & info.title .~ "Gargantext"
475 & info.version .~ "0.0.1.3.1" -- TODO same version as Gargantext
476 -- & info.base_url ?~ (URL "http://gargantext.org/")
477 & info.description ?~ "REST API specifications"
478 -- & tags .~ Set.fromList [Tag "Garg" (Just "Main perations") Nothing]
479 & applyTagsFor (subOperations (Proxy :: Proxy GargAPI)(Proxy :: Proxy GargAPI))
480 ["Gargantext" & description ?~ "Main operations"]
481 & info.license ?~ ("AGPLV3 (English) and CECILL (French)" & url ?~ URL urlLicence )
483 urlLicence = "https://gitlab.iscpif.fr/gargantext/haskell-gargantext/blob/master/LICENSE"
485 -- | Output generated @swagger.json@ file for the @'TodoAPI'@.
486 swaggerWriteJSON :: IO ()
487 swaggerWriteJSON = BL8.writeFile "swagger.json" (encodePretty swaggerDoc)
489 portRouteInfo :: PortNumber -> IO ()
490 portRouteInfo port = do
491 T.putStrLn " ----Main Routes----- "
492 T.putStrLn $ "http://localhost:" <> toUrlPiece port <> "/index.html"
493 T.putStrLn $ "http://localhost:" <> toUrlPiece port <> "/swagger-ui"
495 stopGargantext :: HasRepoSaver env => env -> IO ()
496 stopGargantext env = do
497 T.putStrLn "----- Stopping gargantext -----"
498 runReaderT saveRepo env
500 -- | startGargantext takes as parameters port number and Ini file.
501 startGargantext :: PortNumber -> FilePath -> IO ()
502 startGargantext port file = do
503 env <- newEnv port file
506 mid <- makeDevMiddleware
507 run port (mid app) `finally` stopGargantext env
510 startGargantextMock :: PortNumber -> IO ()
511 startGargantextMock port = do
513 application <- makeMockApp . MockEnv $ FireWall False