+++ /dev/null
-{- git-lfs API
- -
- - https://github.com/git-lfs/git-lfs/blob/master/docs/api
- -
- - Copyright 2019 Joey Hess <id@joeyh.name>
- -
- - Licensed under the GNU AGPL version 3 or higher.
- -}
-
--- | This implementation of the git-lfs API uses http Request and Response,
--- but leaves actually connecting up the http client to the user.
---
--- You'll want to use a Manager that supports https, since the protocol
--- uses http basic auth.
---
--- Some LFS servers, notably Github's, may require a User-Agent header
--- in some of the requests, in order to allow eg, uploads. No such header
--- is added by default, so be sure to add your own.
-
-{-# LANGUAGE DeriveGeneric, FlexibleInstances, FlexibleContexts #-}
-{-# LANGUAGE OverloadedStrings #-}
-{-# LANGUAGE LambdaCase #-}
-
--- This is a vendored copy of Network.GitLFS from the git-lfs package,
--- and will be removed once that package is available in all build
--- environments.
-module Utility.GitLFS (
- -- * Transfer requests
- TransferRequest(..),
- TransferRequestOperation(..),
- TransferAdapter(..),
- TransferRequestObject(..),
- startTransferRequest,
-
- -- * Responses to transfer requests
- TransferResponse(..),
- TransferResponseOperation(..),
- IsTransferResponseOperation,
- DownloadOperation(..),
- UploadOperation(..),
- OperationParams(..),
- ParsedTransferResponse(..),
- parseTransferResponse,
-
- -- * Making transfers
- downloadOperationRequest,
- uploadOperationRequests,
- ServerSupportsChunks(..),
-
- -- * Endpoint discovery
- Endpoint,
- guessEndpoint,
- modifyEndpointRequest,
- sshDiscoverEndpointCommand,
- parseSshDiscoverEndpointResponse,
-
- -- * Errors
- TransferResponseError(..),
- TransferResponseObjectError(..),
-
- -- * Additional data types
- Url,
- SHA256,
- GitRef(..),
- NumSeconds,
- HTTPHeader,
- HTTPHeaderValue,
-) where
-
-import Data.Aeson
-import Data.Aeson.Types
-import GHC.Generics
-import Network.HTTP.Client
-import Data.List
-import qualified Data.Map as M
-import qualified Data.Text as T
-import qualified Data.Text.Encoding as E
-import qualified Data.ByteString.Lazy as L
-import qualified Data.CaseInsensitive as CI
-import qualified Network.URI as URI
-
-data TransferRequest = TransferRequest
- { req_operation :: TransferRequestOperation
- , req_transfers :: [TransferAdapter]
- , req_ref :: Maybe GitRef
- , req_objects :: [TransferRequestObject]
- }
- deriving (Generic, Show)
-
-instance ToJSON TransferRequest where
- toJSON = genericToJSON transferRequestOptions
- toEncoding = genericToEncoding transferRequestOptions
-
-instance FromJSON TransferRequest where
- parseJSON = genericParseJSON transferRequestOptions
-
-transferRequestOptions :: Options
-transferRequestOptions = stripFieldPrefix nonNullOptions
-
-data TransferRequestObject = TransferRequestObject
- { req_oid :: SHA256
- , req_size :: Integer
- }
- deriving (Generic, Show)
-
-instance ToJSON TransferRequestObject where
- toJSON = genericToJSON transferRequestObjectOptions
- toEncoding = genericToEncoding transferRequestObjectOptions
-
-instance FromJSON TransferRequestObject where
- parseJSON = genericParseJSON transferRequestObjectOptions
-
-transferRequestObjectOptions :: Options
-transferRequestObjectOptions = stripFieldPrefix defaultOptions
-
-data TransferRequestOperation = RequestDownload | RequestUpload
- deriving (Show)
-
-instance ToJSON TransferRequestOperation where
- toJSON RequestDownload = "download"
- toJSON RequestUpload = "upload"
-
-instance FromJSON TransferRequestOperation where
- parseJSON (String "download") = pure RequestDownload
- parseJSON (String "upload") = pure RequestUpload
- parseJSON invalid = typeMismatch "TransferRequestOperation" invalid
-
-data TransferResponse op = TransferResponse
- { transfer :: Maybe TransferAdapter
- , objects :: [TransferResponseOperation op]
- }
- deriving (Generic, Show)
-
-instance IsTransferResponseOperation op => ToJSON (TransferResponse op) where
- toJSON = genericToJSON nonNullOptions
- toEncoding = genericToEncoding nonNullOptions
-
-instance IsTransferResponseOperation op => FromJSON (TransferResponse op)
-
--- | This is an error with a TransferRequest as a whole. It's also possible
--- for a TransferRequest to overall succeed, but fail for some
--- objects; such failures use TransferResponseObjectError.
-data TransferResponseError = TransferResponseError
- { resperr_message :: T.Text
- , resperr_request_id :: Maybe T.Text
- , resperr_documentation_url :: Maybe Url
- }
- deriving (Generic, Show)
-
-instance ToJSON TransferResponseError where
- toJSON = genericToJSON transferResponseErrorOptions
- toEncoding = genericToEncoding transferResponseErrorOptions
-
-instance FromJSON TransferResponseError where
- parseJSON = genericParseJSON transferResponseErrorOptions
-
-transferResponseErrorOptions :: Options
-transferResponseErrorOptions = stripFieldPrefix nonNullOptions
-
--- | An error with a single object within a TransferRequest.
-data TransferResponseObjectError = TransferResponseObjectError
- { respobjerr_code :: Int
- , respobjerr_message :: T.Text
- }
- deriving (Generic, Show)
-
-instance ToJSON TransferResponseObjectError where
- toJSON = genericToJSON transferResponseObjectErrorOptions
- toEncoding = genericToEncoding transferResponseObjectErrorOptions
-
-instance FromJSON TransferResponseObjectError where
- parseJSON = genericParseJSON transferResponseObjectErrorOptions
-
-transferResponseObjectErrorOptions :: Options
-transferResponseObjectErrorOptions = stripFieldPrefix nonNullOptions
-
-data TransferAdapter = Basic
- deriving (Show)
-
-instance ToJSON TransferAdapter where
- toJSON Basic = "basic"
-
-instance FromJSON TransferAdapter where
- parseJSON (String "basic") = pure Basic
- parseJSON invalid = typeMismatch "basic" invalid
-
-data TransferResponseOperation op = TransferResponseOperation
- { resp_oid :: SHA256
- , resp_size :: Integer
- , resp_authenticated :: Maybe Bool
- , resp_actions :: Maybe op
- , resp_error :: Maybe TransferResponseObjectError
- }
- deriving (Generic, Show)
-
-instance ToJSON op => ToJSON (TransferResponseOperation op) where
- toJSON = genericToJSON transferResponseOperationOptions
- toEncoding = genericToEncoding transferResponseOperationOptions
-
-instance FromJSON op => FromJSON (TransferResponseOperation op) where
- parseJSON = genericParseJSON transferResponseOperationOptions
-
-transferResponseOperationOptions :: Options
-transferResponseOperationOptions = stripFieldPrefix nonNullOptions
-
--- | Class of types that can be responses to a transfer request,
--- that contain an operation to use to make the transfer.
-class (FromJSON op, ToJSON op) => IsTransferResponseOperation op
-
-data DownloadOperation = DownloadOperation
- { download :: OperationParams }
- deriving (Generic, Show)
-
-instance IsTransferResponseOperation DownloadOperation
-instance ToJSON DownloadOperation
-instance FromJSON DownloadOperation
-
-data UploadOperation = UploadOperation
- { upload :: OperationParams
- , verify :: Maybe OperationParams
- }
- deriving (Generic, Show)
-
-instance IsTransferResponseOperation UploadOperation
-
-instance ToJSON UploadOperation where
- toJSON = genericToJSON nonNullOptions
- toEncoding = genericToEncoding nonNullOptions
-
-instance FromJSON UploadOperation
-
-data OperationParams = OperationParams
- { href :: Url
- , header :: Maybe (M.Map HTTPHeader HTTPHeaderValue)
- , expires_in :: Maybe NumSeconds
- , expires_at :: Maybe T.Text
- }
- deriving (Generic, Show)
-
-instance ToJSON OperationParams where
- toJSON = genericToJSON nonNullOptions
- toEncoding = genericToEncoding nonNullOptions
-
-instance FromJSON OperationParams
-
-data Verification = Verification
- { verification_oid :: SHA256
- , verification_size :: Integer
- }
- deriving (Generic, Show)
-
-instance ToJSON Verification where
- toJSON = genericToJSON verificationOptions
- toEncoding = genericToEncoding verificationOptions
-
-instance FromJSON Verification where
- parseJSON = genericParseJSON verificationOptions
-
-verificationOptions :: Options
-verificationOptions = stripFieldPrefix defaultOptions
-
--- | Sent over ssh connection when using that to find the endpoint.
-data SshDiscoveryResponse = SshDiscoveryResponse
- { endpoint_href :: Url
- , endpoint_header :: Maybe (M.Map HTTPHeader HTTPHeaderValue)
- , endpoint_expires_in :: Maybe NumSeconds
- , endpoint_expires_at :: Maybe T.Text
- } deriving (Generic, Show)
-
-instance ToJSON SshDiscoveryResponse where
- toJSON = genericToJSON sshDiscoveryResponseOptions
- toEncoding = genericToEncoding sshDiscoveryResponseOptions
-
-instance FromJSON SshDiscoveryResponse where
- parseJSON = genericParseJSON sshDiscoveryResponseOptions
-
-sshDiscoveryResponseOptions :: Options
-sshDiscoveryResponseOptions = stripFieldPrefix nonNullOptions
-
-data GitRef = GitRef
- { name :: T.Text }
- deriving (Generic, Show)
-
-instance FromJSON GitRef
-instance ToJSON GitRef
-
-type SHA256 = T.Text
-
--- | The endpoint of a git-lfs server.
-data Endpoint = Endpoint Request
- deriving (Show)
-
--- | Command to run via ssh with to discover an endpoint. The FilePath is
--- the location of the git repository on the ssh server.
---
--- Note that, when sshing to the server, you should take care that the
--- hostname you pass to ssh is really a hostname and not something that ssh
--- will parse an an option, such as -oProxyCommand=".
-sshDiscoverEndpointCommand :: FilePath -> TransferRequestOperation -> [String]
-sshDiscoverEndpointCommand remotepath tro =
- [ "git-lfs-authenticate"
- , remotepath
- , case tro of
- RequestDownload -> "download"
- RequestUpload -> "upload"
- ]
-
--- Internal smart constructor for an Endpoint.
---
--- Since this uses the LFS batch API, it adds /objects/batch
--- to the endpoint url. It also adds the necessary headers to use JSON.
-mkEndpoint :: URI.URI -> Maybe Endpoint
-mkEndpoint uri = do
- r <- requestFromURI uri
- let r' = addLfsJsonHeaders $ r { path = path r <> "/objects/batch" }
- return (Endpoint r')
-
--- | Parse the json output when doing ssh endpoint discovery.
-parseSshDiscoverEndpointResponse :: L.ByteString -> Maybe Endpoint
-parseSshDiscoverEndpointResponse resp = do
- sr <- decode resp
- uri <- URI.parseURI (T.unpack (endpoint_href sr))
- endpoint <- mkEndpoint uri
- return $ modifyEndpointRequest endpoint $ case endpoint_header sr of
- Nothing -> id
- Just headers ->
- let headers' = map convheader (M.toList headers)
- in \req -> req
- { requestHeaders = requestHeaders req ++ headers' }
- where
- convheader (k, v) = (CI.mk (E.encodeUtf8 k), E.encodeUtf8 v)
-
--- | Guesses the LFS endpoint from the http url of a git remote.
---
--- https://github.com/git-lfs/git-lfs/blob/master/docs/api/server-discovery.md
-guessEndpoint :: URI.URI -> Maybe Endpoint
-guessEndpoint uri = case URI.uriScheme uri of
- "https:" -> endpoint
- "http:" -> endpoint
- _ -> Nothing
- where
- endpoint = mkEndpoint $ uri
- -- force https because the git-lfs protocol uses http
- -- basic auth tokens, which should not be exposed
- { URI.uriScheme = "https:"
- , URI.uriPath = guessedpath
- }
-
- guessedpath
- | ".git" `isSuffixOf` URI.uriPath uri =
- URI.uriPath uri ++ "/info/lfs"
- | ".git/" `isSuffixOf` URI.uriPath uri =
- URI.uriPath uri ++ "info/lfs"
- | otherwise = (droptrailing '/' (URI.uriPath uri)) ++ ".git/info/lfs"
-
- droptrailing c = reverse . dropWhile (== c) . reverse
-
--- | When an Endpoint is used to generate a Request, this allows adjusting
--- that Request.
---
--- This can be used to add http basic authentication to an Endpoint:
---
--- > modifyEndpointRequest (guessEndpoint u) (applyBasicAuth "user" "pass")
-modifyEndpointRequest :: Endpoint -> (Request -> Request) -> Endpoint
-modifyEndpointRequest (Endpoint r) f = Endpoint (f r)
-
--- | Makes a Request that will start the process of making a transfer to or
--- from the LFS endpoint.
-startTransferRequest :: Endpoint -> TransferRequest -> Request
-startTransferRequest (Endpoint r) tr = r
- { method = "POST"
- , requestBody = RequestBodyLBS (encode tr)
- }
-
-addLfsJsonHeaders :: Request -> Request
-addLfsJsonHeaders r = r
- { requestHeaders = requestHeaders r ++
- [ ("Accept", lfsjson)
- , ("Content-Type", lfsjson)
- ]
- }
- where
- lfsjson = "application/vnd.git-lfs+json"
-
-data ParsedTransferResponse op
- = ParsedTransferResponse (TransferResponse op)
- | ParsedTransferResponseError TransferResponseError
- | ParseFailed String
-
--- | Parse the body of a response to a transfer request.
-parseTransferResponse
- :: IsTransferResponseOperation op
- => L.ByteString
- -> ParsedTransferResponse op
-parseTransferResponse resp = case eitherDecode resp of
- Right tr -> ParsedTransferResponse tr
- -- If unable to decode as a TransferResponse, try to decode
- -- as a TransferResponseError instead, in case the LFS server
- -- sent an error message.
- Left err ->
- either (const $ ParseFailed err) ParsedTransferResponseError $
- eitherDecode resp
-
--- | Builds a http request to perform a download.
-downloadOperationRequest :: DownloadOperation -> Maybe Request
-downloadOperationRequest = fmap fst . operationParamsRequest . download
-
--- | Builds http request to perform an upload. The content to upload is
--- provided, along with its SHA256 and size.
---
--- When the LFS server requested verification, there will be a second
--- Request that does that; it should be run only after the upload has
--- succeeded.
---
--- When the LFS server already contains the object, an empty list may be
--- returned.
-uploadOperationRequests :: UploadOperation -> (ServerSupportsChunks -> RequestBody) -> SHA256 -> Integer -> Maybe [Request]
-uploadOperationRequests op mkcontent oid size =
- case (mkdlreq, mkverifyreq) of
- (Nothing, _) -> Nothing
- (Just dlreq, Nothing) -> Just [dlreq]
- (Just dlreq, Just verifyreq) -> Just [dlreq, verifyreq]
- where
- mkdlreq = mkdlreq'
- <$> operationParamsRequest (upload op)
- mkdlreq' (r, ssc) = r
- { method = "PUT"
- , requestBody = mkcontent ssc
- }
- mkverifyreq = mkverifyreq'
- <$> (operationParamsRequest =<< verify op)
- mkverifyreq' (r, _ssc) = addLfsJsonHeaders $ r
- { method = "POST"
- , requestBody = RequestBodyLBS $ encode $
- Verification oid size
- }
-
--- | When the LFS server indicates that it supports Transfer-Encoding chunked,
--- this will contain a true value, and the RequestBody provided to
--- uploadOperationRequests may be created using RequestBodyStreamChunked.
--- Otherwise, that should be avoided as the server may not support the
--- chunked encoding.
-newtype ServerSupportsChunks = ServerSupportsChunks Bool
-
-operationParamsRequest :: OperationParams -> Maybe (Request, ServerSupportsChunks)
-operationParamsRequest ps = do
- r <- parseRequest (T.unpack (href ps))
- let headers = map convheader $ maybe [] M.toList (header ps)
- let headers' = filter allowedheader headers
- let ssc = ServerSupportsChunks $
- any (== ("Transfer-Encoding", "chunked")) headers
- return (r { requestHeaders = headers' }, ssc)
- where
- convheader (k, v) = (CI.mk (E.encodeUtf8 k), E.encodeUtf8 v)
- -- requestHeaders is not allowed to set Transfer-Encoding or
- -- Content-Length; copying those over blindly could request in a
- -- malformed request.
- allowedheader (k, _) = k /= "Transfer-Encoding"
- && k /= "Content-Length"
-
-type Url = T.Text
-
-type NumSeconds = Integer
-
-type HTTPHeader = T.Text
-
-type HTTPHeaderValue = T.Text
-
--- Prevent Nothing from serializing to null.
-nonNullOptions :: Options
-nonNullOptions = defaultOptions { omitNothingFields = True }
-
--- Remove prefix from field names.
-stripFieldPrefix :: Options -> Options
-stripFieldPrefix o =
- o { fieldLabelModifier = drop 1 . dropWhile (/= '_') }