diff --git a/.gitignore b/.gitignore index e14f969..241173f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ /.cabal-sandbox/ /cabal.sandbox.config /.stack-work/ -.DS_Store \ No newline at end of file +.DS_Store +.env \ No newline at end of file diff --git a/ms-auth/CHANGELOG.md b/ms-auth/CHANGELOG.md index 403f1bc..b8791ee 100644 --- a/ms-auth/CHANGELOG.md +++ b/ms-auth/CHANGELOG.md @@ -8,13 +8,18 @@ and this project adheres to the ## Unreleased -defaultAzureCredential - to mimic the behaviour of the Microsoft Identity SDK +## 0.3.0.0 -Breaking change: +defaultAzureCredential - simplified version of the Microsoft Identity SDK -module Network.OAuth2.JWT is not exposed anymore +introduced MSAuth module that re-exports internal functions -## 0.3.0.0 +Breaking changes: + +- module Network.OAuth2.JWT is not exposed anymore +- OAuthCfg does not contain fields for client ID and secret anymore +- client ID and client secret can only be loaded from environment variables +- Network.OAuth2.Provider.AzureAD azureADApp and azureOAuthADApp return in MonadIO since they look up client ID and secret from the environment ## 0.1.0.0 diff --git a/ms-auth/ms-auth.cabal b/ms-auth/ms-auth.cabal index bbe71af..11c7102 100644 --- a/ms-auth/ms-auth.cabal +++ b/ms-auth/ms-auth.cabal @@ -18,14 +18,17 @@ tested-with: GHC == 9.2.8 library default-language: Haskell2010 hs-source-dirs: src - exposed-modules: + exposed-modules: MSAuth Network.OAuth2.Session Network.OAuth2.Provider.AzureAD other-modules: Network.OAuth2.JWT - build-depends: base >= 4.7 && < 5 - , aeson + DotEnv + build-depends: aeson + , base >= 4.7 && < 5 , bytestring , containers + , directory + , directory >= 1.3.6.2 , hoauth2 == 2.6.0 , http-client , http-types @@ -35,9 +38,9 @@ library , text , time , transformers + , unliftio , uri-bytestring , validation-micro - , unliftio ghc-options: -Wall -Wcompat -Widentities diff --git a/ms-auth/src/DotEnv.hs b/ms-auth/src/DotEnv.hs new file mode 100644 index 0000000..e410552 --- /dev/null +++ b/ms-auth/src/DotEnv.hs @@ -0,0 +1,67 @@ +module DotEnv (applyDotEnv) where + +import Control.Monad.IO.Class (MonadIO(..)) +import Data.Foldable (traverse_) +import Data.Functor (void) +import Data.List (sortOn) +import Data.Maybe (listToMaybe, fromMaybe) +import Data.Ord (Down(..)) +import System.Environment (setEnv) +import qualified Text.ParserCombinators.ReadP as P (ReadP, readP_to_S, char, munch, sepBy1) + +-- directory +import System.Directory (doesFileExist) + +-- | Load, parse and apply a @.env@ file +-- +-- NB : overwrites any preexisting env vars +-- +-- NB2 : if the @.env@ file is not found the program continues (i.e. this function is a no-op in that case) +applyDotEnv :: MonadIO m => + Maybe FilePath -- ^ defaults to @.env@ if Nothing + -> m () +applyDotEnv mfp = liftIO $ do + let + fpath = fromMaybe ".env" mfp + ok <- doesFileExist fpath + if ok + then + do + mp <- parseDotEnv <$> readFile fpath + case mp of + Just es -> setEnvs es + Nothing -> putStrLn $ unwords ["dotenv: cannot parse", fpath] + else + do + putStrLn $ unwords ["dotenv:", fpath, "not found"] + +setEnvs :: MonadIO m => [(String, String)] -> m () +setEnvs = traverse_ insf + where + insf (k, v) = liftIO $ do + setEnv k v + putStrLn $ unwords ["dotenv: set", k] -- DEBUG + +parseDotEnv :: String -- ^ contents of the @.env@ file + -> Maybe [(String, String)] +parseDotEnv = parse1 keyValues + +keyValues :: P.ReadP [(String, String)] +keyValues = P.sepBy1 keyValue (P.char '\n') <* P.char '\n' + +keyValue :: P.ReadP (String, String) +keyValue = do + k <- keyP + void $ P.char '=' + v <- valueP + pure (k, v) + +keyP, valueP :: P.ReadP String +keyP = P.munch (/= '=') +valueP = P.munch (/= '\n') + +-- parse :: P.ReadP b -> String -> Maybe b +-- parse p str = fst <$> (listToMaybe $ P.readP_to_S p str) + +parse1 :: Foldable t => P.ReadP (t a) -> String -> Maybe (t a) +parse1 p str = fmap fst $ listToMaybe $ sortOn (Down . length . fst) (P.readP_to_S p str) diff --git a/ms-auth/src/Network/OAuth2/Provider/AzureAD.hs b/ms-auth/src/Network/OAuth2/Provider/AzureAD.hs index 16850de..b2e7f42 100644 --- a/ms-auth/src/Network/OAuth2/Provider/AzureAD.hs +++ b/ms-auth/src/Network/OAuth2/Provider/AzureAD.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE LambdaCase #-} {-# language DeriveGeneric, GeneralizedNewtypeDeriving, DerivingStrategies #-} {-# LANGUAGE QuasiQuotes, RecordWildCards #-} {-# language OverloadedStrings #-} @@ -6,20 +7,30 @@ {-# options_ghc -Wno-ambiguous-fields #-} -- | Settings for using Azure Active Directory as OAuth identity provider -- --- Both @Delegated@ (On-Behalf-Of) and @App-only@ (i.e. Client Credentials) authentication flows are supported. The former is useful when a user needs to login and delegate some permissions to the application (i.e. accessing personal data), whereas the second is for server processes and automation accounts. +-- Both @Auth Code Grant@ (i.e. with browser client interaction) and @App-only@ (i.e. Client Credentials) authentication flows are supported. The former is useful when a user needs to login and delegate some permissions to the application (i.e. accessing personal data), whereas the second is for server processes and automation accounts. module Network.OAuth2.Provider.AzureAD ( AzureAD + -- * Environment variables + , envClientId + , envClientSecret + , envTenantId -- * App flow , azureADApp -- * Delegated permissions OAuth2 flow , OAuthCfg(..) , AzureADUser , azureOAuthADApp + -- * Exceptions + , AzureADException(..) ) where -- import Data.String (IsString(..)) -- import GHC.Generics +import Control.Monad.IO.Class (MonadIO(..)) +import Control.Exception (Exception(..)) +import System.Environment (lookupEnv) + -- aeson import Data.Aeson -- containers @@ -27,10 +38,12 @@ import qualified Data.Map.Strict as Map import qualified Data.Set as Set -- hoauth2 import Network.OAuth.OAuth2 (ClientAuthenticationMethod(..), authGetJSON) -import Network.OAuth2.Experiment (IdpApplication(..), Idp(..), IdpUserInfo, GrantTypeFlow(..), ClientId(..), ClientSecret, Scope, AuthorizeState) +import Network.OAuth2.Experiment (IdpApplication(..), Idp(..), IdpUserInfo, GrantTypeFlow(..), ClientId(..), ClientSecret(..), Scope, AuthorizeState) -- text import qualified Data.Text as T (Text) -import qualified Data.Text.Lazy as TL (Text) +import qualified Data.Text.Lazy as TL (Text, pack) +-- unliftio +import UnliftIO.Exception (throwIO, Typeable) -- uri-bytestring import URI.ByteString (URI) import URI.ByteString.QQ (uri) @@ -38,6 +51,29 @@ import URI.ByteString.QQ (uri) data AzureAD = AzureAD deriving (Eq, Show) +-- | @AZURE_CLIENT_ID@ +envClientId :: MonadIO f => f ClientId +envClientId = env ClientId "AZURE_CLIENT_ID" +-- | @AZURE_TENANT_ID@ +envTenantId :: MonadIO f => f TL.Text +envTenantId = env id "AZURE_TENANT_ID" +-- | @AZURE_CLIENT_SECRET@ +envClientSecret :: MonadIO f => f ClientSecret +envClientSecret = env ClientSecret "AZURE_CLIENT_SECRET" + + +env :: MonadIO m => (TL.Text -> b) -> String -> m b +env mk e = do + me <- liftIO $ lookupEnv e + case me of + Nothing -> throwIO $ AADNoEnvVar e + Just x -> pure $ (mk . TL.pack) x + +data AzureADException = AADNoEnvVar String deriving (Typeable) +instance Exception AzureADException +instance Show AzureADException where + show = \case + AADNoEnvVar e -> unwords ["Env var", e, "not found"] -- * App-only flow @@ -49,16 +85,22 @@ data AzureAD = AzureAD deriving (Eq, Show) -- -- also be aware to find the right client id. -- see https://stackoverflow.com/a/70670961 -azureADApp :: TL.Text -- ^ application name - -> ClientId -> ClientSecret +-- +-- +-- Throws 'AzureADException' if @AZURE_CLIENT_ID@ and/or @AZURE_CLIENT_SECRET@ credentials are not found in the environment +azureADApp :: MonadIO m => + TL.Text -- ^ application name -> [Scope] -- ^ scopes - -> IdpApplication 'ClientCredentials AzureAD -azureADApp appname clid sec scopes = defaultAzureADApp{ - idpAppName = appname - , idpAppClientId = clid - , idpAppClientSecret = sec - , idpAppScope = Set.fromList (scopes <> ["offline_access"]) - } + -> m (IdpApplication 'ClientCredentials AzureAD) +azureADApp appname scopes = do + clid <- envClientId + sec <- envClientSecret + pure $ defaultAzureADApp{ + idpAppName = appname + , idpAppClientId = clid + , idpAppClientSecret = sec + , idpAppScope = Set.fromList (scopes <> ["offline_access"]) + } defaultAzureADApp :: IdpApplication 'ClientCredentials AzureAD defaultAzureADApp = @@ -79,8 +121,6 @@ type instance IdpUserInfo AzureAD = AzureADUser -- | Configuration object of the OAuth2 application data OAuthCfg = OAuthCfg { oacAppName :: TL.Text -- ^ application name - , oacClientId :: ClientId -- ^ app client ID : see https://stackoverflow.com/a/70670961 - , oacClientSecret :: ClientSecret -- ^ app client secret " , oacScopes :: [Scope] -- ^ OAuth2 and OIDC scopes , oacAuthState :: AuthorizeState -- ^ OAuth2 'state' (a random string, https://www.rfc-editor.org/rfc/rfc6749#section-10.12 ) , oacRedirectURI :: URI -- ^ OAuth2 redirect URI @@ -96,16 +136,23 @@ data OAuthCfg = OAuthCfg { -- -- also be aware to find the right client id. -- see https://stackoverflow.com/a/70670961 -azureOAuthADApp :: OAuthCfg -- ^ OAuth configuration - -> IdpApplication 'AuthorizationCode AzureAD -azureOAuthADApp (OAuthCfg appname clid sec scopes authstate reduri) = defaultAzureOAuthADApp{ - idpAppName = appname - , idpAppClientId = clid - , idpAppClientSecret = sec - , idpAppScope = Set.fromList (scopes <> ["openid", "offline_access"]) - , idpAppAuthorizeState = authstate - , idpAppRedirectUri = reduri - } +-- +-- +-- Throws 'AzureADException' if @AZURE_CLIENT_ID@ and/or @AZURE_CLIENT_SECRET@ credentials are not found in the environment +azureOAuthADApp :: MonadIO m => + OAuthCfg -- ^ OAuth configuration + -> m (IdpApplication 'AuthorizationCode AzureAD) +azureOAuthADApp (OAuthCfg appname scopes authstate reduri) = do + clid <- envClientId + sec <- envClientSecret + pure $ defaultAzureOAuthADApp{ + idpAppName = appname + , idpAppClientId = clid + , idpAppClientSecret = sec + , idpAppScope = Set.fromList (scopes <> ["openid", "offline_access"]) + , idpAppAuthorizeState = authstate + , idpAppRedirectUri = reduri + } defaultAzureOAuthADApp :: IdpApplication 'AuthorizationCode AzureAD defaultAzureOAuthADApp = diff --git a/ms-auth/src/Network/OAuth2/Session.hs b/ms-auth/src/Network/OAuth2/Session.hs index 9ac35b0..0d6edb1 100644 --- a/ms-auth/src/Network/OAuth2/Session.hs +++ b/ms-auth/src/Network/OAuth2/Session.hs @@ -21,7 +21,7 @@ module Network.OAuth2.Session ( , fetchUpdateToken -- ** Default Azure Credential , defaultAzureCredential - -- * B Auth code grant flow (with user in the loop) + -- * B Auth code grant flow (interactive) -- ** OAuth endpoints , loginEndpoint , replyEndpoint @@ -37,7 +37,7 @@ module Network.OAuth2.Session ( , withAADUser , Scotty , Action - ) where + ) where import Control.Applicative (Alternative(..)) import Control.Exception (Exception(..), SomeException(..)) @@ -152,10 +152,13 @@ withAADUser ts loginURI act = aadHeaderIdToken $ \usub -> do -- | App has (at most) one token at a time type Token t = TVar (Maybe t) +-- | Create an empty 'Token' store newNoToken :: MonadIO m => m (Token t) newNoToken = newTVarIO Nothing +-- | Delete the current token expireToken :: MonadIO m => Token t -> m () expireToken ts = atomically $ modifyTVar ts (const Nothing) +-- | Read the current value of the token readToken :: MonadIO m => Token t -> m (Maybe t) readToken ts = atomically $ readTVar ts @@ -178,17 +181,23 @@ fetchUpdateTokenWith f idpApp ts mgr = liftIO $ void $ forkFinally loop cleanup threadDelay (dtSecs * 1000000) -- pause thread loop --- | DefaultUserCredential mechanism as in the Python SDK https://pypi.org/project/azure-identity/ +-- | DefaultAzureCredential mechanism as in the Python SDK https://pypi.org/project/azure-identity/ +-- +-- Order of authentication attempts: +-- +-- 1) token request with client secret +-- +-- 2) token request via managed identity (App Service and Azure Functions) https://learn.microsoft.com/en-us/azure/app-service/overview-managed-identity?tabs=portal%2Chttp#rest-endpoint-reference defaultAzureCredential :: MonadIO m => - String - -> String + String -- ^ Client ID + -> String -- ^ Azure Resource URI (for @managed identity@ auth flow) -> IdpApplication 'ClientCredentials AzureAD -> Token OAuth2Token -> Manager -> m () defaultAzureCredential clid resuri = fetchUpdateTokenWith ( - \idp mgr -> - tokenRequestNoExchange idp mgr <|> + \ip mgr -> + tokenRequestNoExchange ip mgr <|> managedIdentity mgr clid resuri ) @@ -196,7 +205,7 @@ tokenRequestNoExchange :: (MonadIO m) => IdpApplication 'ClientCredentials AzureAD -> Manager -> ExceptT [String] m OAuth2Token -tokenRequestNoExchange idp mgr = withExceptT (pure . show) (conduitTokenRequest idp mgr) +tokenRequestNoExchange ip mgr = withExceptT (pure . show) (conduitTokenRequest ip mgr) -- | Fetch an OAuth token and keep it updated. Should be called as a first thing in the app -- diff --git a/ms-graph-api-test/app/Main.hs b/ms-graph-api-test/app/Main.hs index 489e939..65bf696 100644 --- a/ms-graph-api-test/app/Main.hs +++ b/ms-graph-api-test/app/Main.hs @@ -6,6 +6,10 @@ module Main (main) where import Control.Monad.IO.Class (MonadIO(..)) +-- aeson-pretty +import qualified Data.Aeson.Encode.Pretty as A (encodePretty) +-- bytestring +import qualified Data.ByteString.Lazy.Char8 as LBS (putStrLn) -- hoauth2 import Network.OAuth.OAuth2 (OAuth2Token(..)) import Network.OAuth2.Experiment (IdpApplication, GrantTypeFlow(..)) @@ -16,9 +20,10 @@ import Network.HTTP.Client.TLS (tlsManagerSettings) -- req import Network.HTTP.Req (runReq, defaultHttpConfig, httpConfigAltManager) -- scotty -import Web.Scotty.Trans (ScottyT, scottyT, get, html, RoutePattern, middleware) +import Web.Scotty.Trans (ScottyT, scottyT, get, text, html, RoutePattern, middleware) -- text import qualified Data.Text as T (unpack) +import qualified Data.Text.Lazy.Encoding as TL (decodeUtf8) import qualified Data.Text.Lazy as TL (Text, pack) -- transformers import Control.Monad.Trans.Reader (runReaderT) @@ -29,9 +34,11 @@ import URI.ByteString.QQ (uri) -- wai-extra import Network.Wai.Middleware.RequestLogger (logStdoutDev) +import qualified MSGraphAPI.Files.DriveItem as MSDI (listRootChildrenMe) +import qualified MSGraphAPI.Users.Group as MSGU (getMeJoinedTeams) import qualified MSGraphAPI.Users.User as MSG (getMe, User(..)) import Network.OAuth2.Provider.AzureAD (OAuthCfg(..), azureOAuthADApp, AzureAD) -import Network.OAuth2.Session (Tokens, newTokens, tokensToList, withAADUser, loginEndpoint, replyEndpoint, UserSub, Scotty, Action) +import MSAuth (applyDotEnv, Tokens, newTokens, tokensToList, withAADUser, loginEndpoint, replyEndpoint, UserSub, Scotty, Action) main :: IO () @@ -40,15 +47,19 @@ main = server server :: MonadIO m => m () server = do ts <- newTokens + applyDotEnv (Just ".env") mgr <- liftIO $ newManager tlsManagerSettings + ip <- idpApp let runR r = runReaderT r ts scottyT 3000 runR $ do middleware logStdoutDev - loginEndpoint idpApp "/oauth/login" - replyEndpoint idpApp ts mgr "/oauth/reply" + loginEndpoint ip "/oauth/login" + replyEndpoint ip ts mgr "/oauth/reply" allTokensEndpoint ts "/tokens" currentUsersEndpoint ts (Just mgr) "/me" + meFilesEndpoint ts (Just mgr) "/me/files" + meTeamsEndpoint ts (Just mgr) "/me/teams" -- currentUserEndpoint :: MonadIO m => -- Tokens UserSub OAuth2Token @@ -62,6 +73,38 @@ server = do -- h = TL.pack $ unwords ["", "

", T.unpack uname, "

",""] -- html h +meTeamsEndpoint :: (MonadIO m) => + Tokens a OAuth2Token + -> Maybe Manager -> RoutePattern -> Scotty m () +meTeamsEndpoint ts mmgr pth = get pth $ do + tsl <- tokensToList ts + let + f (_, oat) = do + let + t = accessToken oat + item <- runReq defaultHttpConfig{ httpConfigAltManager = mmgr } $ MSGU.getMeJoinedTeams t + let + js = A.encodePretty item + pure js + rows <- traverse f tsl + text $ TL.decodeUtf8 $ mconcat rows + +meFilesEndpoint :: (MonadIO m) => + Tokens a OAuth2Token + -> Maybe Manager -> RoutePattern -> Scotty m () +meFilesEndpoint ts mmgr pth = get pth $ do + tsl <- tokensToList ts + let + f (_, oat) = do + let + t = accessToken oat + item <- runReq defaultHttpConfig{ httpConfigAltManager = mmgr } $ MSDI.listRootChildrenMe t + let + js = A.encodePretty item + pure js + rows <- traverse f tsl + text $ TL.decodeUtf8 $ mconcat rows + currentUsersEndpoint :: (MonadIO m) => Tokens a OAuth2Token -> Maybe Manager -- ^ if Nothing it uses the default implicit connection manager @@ -95,14 +138,12 @@ table mm = TL.pack ("" <> foldMap insf mm <> "
") -- also double check https://stackoverflow.com/a/63929994/2890063 in the AAD app manifest -idpApp :: IdpApplication 'AuthorizationCode AzureAD +idpApp :: MonadIO m => m (IdpApplication 'AuthorizationCode AzureAD) idpApp = azureOAuthADApp (OAuthCfg "ms-graph-api-test" - "53647139-affd-4ec6-b83a-e41323f33240" - "4C68Q~sGVNAqdr_jGERbi68oSE4kjNtmt1Ilmbxx" - ["profile", "email", "User.Read"] + ["profile", "email", "User.Read", "Files.Read", "Team.ReadBasic.All"] "abcd1234" - [uri|https://66b3-213-89-187-253.ngrok-free.app/oauth/reply|] + [uri|https://66e7-213-89-187-253.ngrok-free.app/oauth/reply|] ) diff --git a/ms-graph-api-test/ms-graph-api-test.cabal b/ms-graph-api-test/ms-graph-api-test.cabal index 6b3a21a..b5f7f70 100644 --- a/ms-graph-api-test/ms-graph-api-test.cabal +++ b/ms-graph-api-test/ms-graph-api-test.cabal @@ -27,6 +27,9 @@ executable ms-graph-api-test hs-source-dirs: app main-is: Main.hs build-depends: base + , aeson + , aeson-pretty + , bytestring , hoauth2 == 2.6.0 , http-client , http-client-tls >= 0.3 diff --git a/ms-graph-api/CHANGELOG.md b/ms-graph-api/CHANGELOG.md index e397277..c89a277 100644 --- a/ms-graph-api/CHANGELOG.md +++ b/ms-graph-api/CHANGELOG.md @@ -10,6 +10,12 @@ and this project adheres to the +## 0.8.0.0 + +MSGraphAPI.Files.DriveItem + + + ## 0.7.0.0 diff --git a/ms-graph-api/ms-graph-api.cabal b/ms-graph-api/ms-graph-api.cabal index 87032a7..be56972 100644 --- a/ms-graph-api/ms-graph-api.cabal +++ b/ms-graph-api/ms-graph-api.cabal @@ -18,7 +18,8 @@ tested-with: GHC == 9.2.8 library default-language: Haskell2010 hs-source-dirs: src - exposed-modules: MSGraphAPI.ChangeNotifications.Subscription + exposed-modules: MSGraphAPI + MSGraphAPI.ChangeNotifications.Subscription MSGraphAPI.User MSGraphAPI.Users.User MSGraphAPI.Users.Group @@ -30,6 +31,7 @@ library , bytestring , containers , hoauth2 == 2.6.0 + , http-client-tls >= 0.3 , http-types , modern-uri , req @@ -47,27 +49,6 @@ library DerivingStrategies LambdaCase - --- test-suite spec --- default-language: Haskell2010 --- type: exitcode-stdio-1.0 --- hs-source-dirs: test --- main-is: Spec.hs --- other-modules: LibSpec --- build-depends: base --- , ms-graph-api --- , hspec --- , QuickCheck --- ghc-options: -Wall --- -Wcompat --- -Widentities --- -Wincomplete-record-updates --- -Wincomplete-uni-patterns --- -Wmissing-export-lists --- -Wmissing-home-modules --- -Wpartial-fields --- -Wredundant-constraints - source-repository head type: git location: https://github.com/unfoldml/ms-graph-api diff --git a/ms-graph-api/src/MSGraphAPI.hs b/ms-graph-api/src/MSGraphAPI.hs new file mode 100644 index 0000000..69e3665 --- /dev/null +++ b/ms-graph-api/src/MSGraphAPI.hs @@ -0,0 +1,5 @@ +module MSGraphAPI ( + Collection(..), run, tryReq, withTLS + ) where + +import MSGraphAPI.Internal.Common (Collection(..), run, tryReq, withTLS) diff --git a/ms-graph-api/src/MSGraphAPI/Files/DriveItem.hs b/ms-graph-api/src/MSGraphAPI/Files/DriveItem.hs index 1c48894..b39b619 100644 --- a/ms-graph-api/src/MSGraphAPI/Files/DriveItem.hs +++ b/ms-graph-api/src/MSGraphAPI/Files/DriveItem.hs @@ -1,9 +1,24 @@ -module MSGraphAPI.Files.DriveItem where +module MSGraphAPI.Files.DriveItem ( + -- * list items + listRootChildrenMe + , listGroupItemChildren + -- , listGroupRootChildren + -- * download items + , downloadFile + , downloadFileMe + -- * types + , DriveItem(..) + , DIItem(..) + , File(..), Folder(..), Package(..) + ) where +import Control.Applicative (Alternative(..)) +import Data.Int (Int32) import GHC.Generics (Generic(..)) -- aeson -import qualified Data.Aeson as A (ToJSON(..), FromJSON(..), genericParseJSON) +import qualified Data.Aeson as A (ToJSON(..), FromJSON(..), Value, genericParseJSON, (.:), (.:?), Object, withObject, Key) +import qualified Data.Aeson.Types as A (Parser) -- bytestring import qualified Data.ByteString.Lazy as LBS (ByteString) -- hoauth @@ -13,18 +28,88 @@ import Network.HTTP.Req (Req) -- text import Data.Text (Text, pack, unpack) -- time -import Data.Time (LocalTime) +import Data.Time (LocalTime, ZonedTime) import qualified MSGraphAPI.Internal.Common as MSG (get, getLbs, post, Collection, aesonOptions) +-- | The 'DriveItem' resource represents a file, folder, or other item stored in a drive. +-- +-- All file system objects in OneDrive and SharePoint are returned as driveItem resources. +-- +-- https://learn.microsoft.com/en-us/graph/api/resources/driveitem?view=graph-rest-1.0 data DriveItem = DriveItem { diId :: Text , diName :: Text - , diLastModifiedDateTime :: LocalTime - } deriving (Eq, Ord, Show, Generic) + , diLastModifiedDateTime :: ZonedTime -- 2022-11-28T09:18:45Z + , diItem :: DIItem + } deriving (Show, Generic) +instance A.ToJSON DriveItem + instance A.FromJSON DriveItem where - parseJSON = A.genericParseJSON (MSG.aesonOptions "di") + parseJSON = A.withObject "DriveItem" $ \o -> DriveItem <$> + o A..: "id" <*> + o A..: "name" <*> + o A..: "lastModifiedDateTime" <*> + diItemP o + +diItemP :: A.Object -> A.Parser DIItem +diItemP o = + (DIIFile <$> o A..: "file") <|> + (DIIFolder <$> o A..: "folder") <|> + (DIIRemoteItem <$ o .: "remoteItem") <|> + (DIIPhoto <$ o .: "photo") <|> + (DIIVideo <$ o .: "video") <|> + (DIIBundle <$ o .: "bundle") <|> + (DIIPackage <$> o A..: "package") + + +(.:) :: A.Object -> A.Key -> A.Parser () +(.:) = (A..:) + +-- | A sum type for the various drive item types +-- +-- This is a departure from the original API but makes it convenient to pattern match on constructors +data DIItem = DIIFile File + | DIIFolder Folder + | DIIRemoteItem + | DIIPhoto + | DIIVideo + | DIIBundle + | DIIPackage Package + deriving (Eq, Ord, Show, Generic) +instance A.ToJSON DIItem where + toJSON = \case + DIIFile f -> A.toJSON f + DIIFolder f -> A.toJSON f + DIIPackage f -> A.toJSON f + e -> A.toJSON $ drop 3 (show e) -- FIXME hack + +-- | The Folder resource groups folder-related data on an item into a single structure. DriveItems with a non-null folder facet are containers for other DriveItems. +-- +-- https://learn.microsoft.com/en-us/graph/api/resources/folder?view=graph-rest-1.0 +data Folder = Folder { + difoChildCount :: Int32 + } deriving (Eq, Ord, Show, Generic) +instance A.FromJSON Folder where + parseJSON = A.genericParseJSON (MSG.aesonOptions "difo") +instance A.ToJSON Folder +-- | The File resource groups file-related data items into a single structure. +-- +-- https://learn.microsoft.com/en-us/graph/api/resources/file?view=graph-rest-1.0 +data File = File { + difiMimeType :: Text + } deriving (Eq, Ord, Show, Generic) +instance A.FromJSON File where + parseJSON = A.genericParseJSON (MSG.aesonOptions "difi") +instance A.ToJSON File + +data Package = Package { + dipType :: Text + } deriving (Eq, Ord, Show, Generic) +instance A.FromJSON Package where + parseJSON = A.genericParseJSON (MSG.aesonOptions "dip") +instance A.ToJSON Package -- | download a complete file from user's directory -- @@ -44,3 +129,31 @@ downloadFile :: Text -- ^ drive ID -> Text -- ^ file ID -> AccessToken -> Req LBS.ByteString downloadFile did itemId = MSG.getLbs ["drives", did, "items", itemId, "content"] mempty + +-- | List children in the root of the current user's drive +-- +-- @GET \/me\/drive\/root\/children@ +-- +-- https://learn.microsoft.com/en-us/graph/api/driveitem-list-children?view=graph-rest-1.0&tabs=http#list-children-in-the-root-of-the-current-users-drive +listRootChildrenMe :: AccessToken -> Req (MSG.Collection DriveItem) +listRootChildrenMe = MSG.get ["me", "drive", "root", "children"] mempty + + +-- | List children of an item of a group drive +-- +-- @GET \/groups\/{group-id}\/drive\/items\/{item-id}\/children@ +-- +-- https://learn.microsoft.com/en-us/graph/api/driveitem-list-children?view=graph-rest-1.0&tabs=http +listGroupItemChildren :: Text -- ^ group ID + -> Text -- ^ item ID + -> AccessToken -> Req (MSG.Collection DriveItem) +listGroupItemChildren gid iid = + MSG.get ["groups", gid, "drive", "items", iid, "children"] mempty + +-- -- | List children of the root item of a group drive +-- -- +-- -- @GET \/groups\/{group-id}\/drive\/root\/children@ +-- listGroupRootChildren :: Text -- ^ group ID +-- -> AccessToken -> Req (MSG.Collection DriveItem) +-- listGroupRootChildren gid = +-- MSG.get ["groups", gid, "drive", "root", "children"] mempty -- TODO DOUBLE CHECK PATH diff --git a/ms-graph-api/src/MSGraphAPI/Internal/Common.hs b/ms-graph-api/src/MSGraphAPI/Internal/Common.hs index 07bfa11..4fbb624 100644 --- a/ms-graph-api/src/MSGraphAPI/Internal/Common.hs +++ b/ms-graph-api/src/MSGraphAPI/Internal/Common.hs @@ -16,8 +16,11 @@ module MSGraphAPI.Internal.Common ( -- -- ** catch HTTP exceptions -- , postE -- * running requests + , run , runReq , tryReq + -- * HTTP(S) connections + , withTLS -- * JSON : aeson helpers , Collection(..) , aesonOptions @@ -42,10 +45,12 @@ import qualified Data.ByteString.Lazy.Char8 as LBS8 (pack, unpack, putStrLn) -- hoauth2 import Network.OAuth.OAuth2 (OAuth2Token(..)) import Network.OAuth.OAuth2.Internal (AccessToken(..), ExchangeToken(..), RefreshToken(..), OAuth2Error, IdToken(..)) +-- http-client-tls +import Network.HTTP.Client.TLS (newTlsManager) -- modern-uri import Text.URI (URI, mkURI) -- req -import Network.HTTP.Req (Req, runReq, HttpException(..), defaultHttpConfig, req, Option, (=:), GET(..), POST(..), PUT(..), Url, Scheme(..), useHttpsURI, https, (/:), ReqBodyJson(..), NoReqBody(..), oAuth2Bearer, HttpResponse(..), jsonResponse, JsonResponse, lbsResponse, LbsResponse, bsResponse, BsResponse, responseBody) +import Network.HTTP.Req (Req, runReq, HttpException(..), HttpConfig(..), defaultHttpConfig, req, Option, (=:), GET(..), POST(..), PUT(..), Url, Scheme(..), useHttpsURI, https, (/:), ReqBodyJson(..), NoReqBody(..), oAuth2Bearer, HttpResponse(..), jsonResponse, JsonResponse, lbsResponse, LbsResponse, bsResponse, BsResponse, responseBody) -- text import Data.Text (Text, pack, unpack) -- unliftio @@ -61,7 +66,20 @@ tryReq :: Req a -> Req (Either HttpException a) tryReq = try +-- | Create a new TLS manager, which should be reused throughout the program +withTLS :: MonadIO m => + (HttpConfig -> m b) -- ^ user program + -> m b +withTLS act = do + mgr <- newTlsManager + let + hc = defaultHttpConfig { httpConfigAltManager = Just mgr } + act hc +-- | Run a 'Req' computation +run :: MonadIO m => + HttpConfig -> Req a -> m (Either HttpException a) +run hc = runReq hc . tryReq -- * REST verbs @@ -135,9 +153,13 @@ msGraphReqConfig (AccessToken ttok) uriRest = (url, os) -- | a collection of items with key @value@ data Collection a = Collection { cValue :: [a] + , cNextLink :: Maybe Text } deriving (Eq, Show, Generic) +instance A.ToJSON a => A.ToJSON (Collection a) instance A.FromJSON a => A.FromJSON (Collection a) where - parseJSON = A.genericParseJSON (aesonOptions "c") + parseJSON = A.withObject "Collection" $ \o -> Collection <$> + o A..: "value" <*> + o A..:? "@odata.nextLink" -- | drop the prefix and lowercase first character -- diff --git a/ms-graph-api/src/MSGraphAPI/Users/Group.hs b/ms-graph-api/src/MSGraphAPI/Users/Group.hs index d4b4bfc..fb0b0e9 100644 --- a/ms-graph-api/src/MSGraphAPI/Users/Group.hs +++ b/ms-graph-api/src/MSGraphAPI/Users/Group.hs @@ -1,5 +1,13 @@ --- | User -module MSGraphAPI.Users.Group where +-- | Users.Group +module MSGraphAPI.Users.Group ( + -- * Teams + getUserJoinedTeams + , getMeJoinedTeams + -- * Drive items + , getGroupsDriveItems + -- * types + , Group(..) + )where import GHC.Generics (Generic(..)) @@ -25,8 +33,9 @@ data Group = Group { } deriving (Eq, Ord, Show, Generic) instance A.FromJSON Group where parseJSON = A.genericParseJSON (MSG.aesonOptions "g") +instance A.ToJSON Group --- | Get the teams in Microsoft Teams that the user is a direct member of. +-- | Get the teams in Microsoft Teams that the given user is a direct member of. -- -- @GET \/users\/{id | user-principal-name}\/joinedTeams@ -- @@ -35,6 +44,14 @@ getUserJoinedTeams :: Text -- ^ User ID -> AccessToken -> Req (MSG.Collection Group) getUserJoinedTeams uid = MSG.get ["users", uid, "joinedTeams"] mempty +-- | Get the teams in Microsoft Teams that the current user is a direct member of. +-- +-- @GET \/me\/joinedTeams@ +-- +-- https://learn.microsoft.com/en-us/graph/api/user-list-joinedteams?view=graph-rest-1.0&tabs=http +getMeJoinedTeams :: AccessToken -> Req (MSG.Collection Group) +getMeJoinedTeams = MSG.get ["me", "joinedTeams"] mempty + -- | Get the 'DriveItem's in the 'Group' storage, starting from the root item -- -- @GET \/groups\/{group-id}\/drive\/root\/children@ diff --git a/ms-graph-api/src/MSGraphAPI/Users/User.hs b/ms-graph-api/src/MSGraphAPI/Users/User.hs index 4518895..9929a56 100644 --- a/ms-graph-api/src/MSGraphAPI/Users/User.hs +++ b/ms-graph-api/src/MSGraphAPI/Users/User.hs @@ -1,4 +1,4 @@ --- | User +-- | Users.User module MSGraphAPI.Users.User where import GHC.Generics (Generic(..))