packages feed

hetzner 0.1.2.0 → 0.2.0.0

raw patch · 5 files changed

+324/−7 lines, 5 filesPVP ok

version bump matches the API change (PVP)

API changes (from Hackage documentation)

+ Hetzner.Cloud: [newServerNetworks] :: NewServer -> [NetworkID]
+ Hetzner.DNS: A :: RecordType
+ Hetzner.DNS: AAAA :: RecordType
+ Hetzner.DNS: CAA :: RecordType
+ Hetzner.DNS: CNAME :: RecordType
+ Hetzner.DNS: DANE :: RecordType
+ Hetzner.DNS: DNSError :: Response ByteString -> DNSException
+ Hetzner.DNS: DS :: RecordType
+ Hetzner.DNS: Failed :: ZoneStatus
+ Hetzner.DNS: HINFO :: RecordType
+ Hetzner.DNS: JSONError :: Response ByteString -> String -> DNSException
+ Hetzner.DNS: MX :: RecordType
+ Hetzner.DNS: NS :: RecordType
+ Hetzner.DNS: PTR :: RecordType
+ Hetzner.DNS: Pagination :: Int -> Int -> Maybe Int -> Maybe Int -> Maybe Int -> Maybe Int -> Pagination
+ Hetzner.DNS: Pending :: ZoneStatus
+ Hetzner.DNS: RP :: RecordType
+ Hetzner.DNS: Record :: ZonedTime -> ZonedTime -> RecordID -> Text -> RecordType -> Text -> Int -> ZoneID -> Record
+ Hetzner.DNS: RecordID :: Text -> RecordID
+ Hetzner.DNS: ResponseMeta :: Pagination -> ResponseMeta
+ Hetzner.DNS: SOA :: RecordType
+ Hetzner.DNS: SRV :: RecordType
+ Hetzner.DNS: TLS :: RecordType
+ Hetzner.DNS: TXT :: RecordType
+ Hetzner.DNS: Token :: ByteString -> Token
+ Hetzner.DNS: Verified :: ZoneStatus
+ Hetzner.DNS: WithKey :: a -> WithKey (key :: Symbol) a
+ Hetzner.DNS: WithMeta :: ResponseMeta -> a -> WithMeta (key :: Symbol) a
+ Hetzner.DNS: Zone :: ZonedTime -> ZonedTime -> ZoneID -> Text -> Bool -> ZoneStatus -> Int -> Int -> Zone
+ Hetzner.DNS: ZoneID :: Text -> ZoneID
+ Hetzner.DNS: [currentPage] :: Pagination -> Int
+ Hetzner.DNS: [itemsPerPage] :: Pagination -> Int
+ Hetzner.DNS: [lastPage] :: Pagination -> Maybe Int
+ Hetzner.DNS: [nextPage] :: Pagination -> Maybe Int
+ Hetzner.DNS: [pagination] :: ResponseMeta -> Pagination
+ Hetzner.DNS: [previousPage] :: Pagination -> Maybe Int
+ Hetzner.DNS: [recordCreated] :: Record -> ZonedTime
+ Hetzner.DNS: [recordID] :: Record -> RecordID
+ Hetzner.DNS: [recordModified] :: Record -> ZonedTime
+ Hetzner.DNS: [recordName] :: Record -> Text
+ Hetzner.DNS: [recordTTL] :: Record -> Int
+ Hetzner.DNS: [recordType] :: Record -> RecordType
+ Hetzner.DNS: [recordValue] :: Record -> Text
+ Hetzner.DNS: [recordZone] :: Record -> ZoneID
+ Hetzner.DNS: [responseMeta] :: WithMeta (key :: Symbol) a -> ResponseMeta
+ Hetzner.DNS: [totalEntries] :: Pagination -> Maybe Int
+ Hetzner.DNS: [withoutKey] :: WithKey (key :: Symbol) a -> a
+ Hetzner.DNS: [withoutMeta] :: WithMeta (key :: Symbol) a -> a
+ Hetzner.DNS: [zoneCreated] :: Zone -> ZonedTime
+ Hetzner.DNS: [zoneID] :: Zone -> ZoneID
+ Hetzner.DNS: [zoneIsSecondary] :: Zone -> Bool
+ Hetzner.DNS: [zoneModified] :: Zone -> ZonedTime
+ Hetzner.DNS: [zoneName] :: Zone -> Text
+ Hetzner.DNS: [zoneRecordCount] :: Zone -> Int
+ Hetzner.DNS: [zoneStatus] :: Zone -> ZoneStatus
+ Hetzner.DNS: [zoneTTL] :: Zone -> Int
+ Hetzner.DNS: allRecordTypes :: [RecordType]
+ Hetzner.DNS: createRecord :: Token -> ZoneID -> Text -> RecordType -> Text -> Maybe Int -> IO Record
+ Hetzner.DNS: data DNSException
+ Hetzner.DNS: data Pagination
+ Hetzner.DNS: data Record
+ Hetzner.DNS: data RecordType
+ Hetzner.DNS: data ResponseMeta
+ Hetzner.DNS: data WithKey (key :: Symbol) a
+ Hetzner.DNS: data WithMeta (key :: Symbol) a
+ Hetzner.DNS: data Zone
+ Hetzner.DNS: data ZoneStatus
+ Hetzner.DNS: deleteRecord :: Token -> RecordID -> IO ()
+ Hetzner.DNS: deleteZone :: Token -> ZoneID -> IO ()
+ Hetzner.DNS: dnsQuery :: (ToJSON body, FromJSON a) => ByteString -> ByteString -> Maybe body -> Query -> Token -> Maybe Int -> IO a
+ Hetzner.DNS: getRecord :: Token -> RecordID -> IO Record
+ Hetzner.DNS: getRecords :: Token -> Maybe ZoneID -> IO [Record]
+ Hetzner.DNS: getTokenFromEnv :: IO (Maybe Token)
+ Hetzner.DNS: getZone :: Token -> ZoneID -> IO Zone
+ Hetzner.DNS: getZones :: Token -> Maybe Int -> IO (WithMeta "zones" [Zone])
+ Hetzner.DNS: instance Data.Aeson.Types.FromJSON.FromJSON Hetzner.DNS.DNSTime
+ Hetzner.DNS: instance Data.Aeson.Types.FromJSON.FromJSON Hetzner.DNS.Record
+ Hetzner.DNS: instance Data.Aeson.Types.FromJSON.FromJSON Hetzner.DNS.RecordID
+ Hetzner.DNS: instance Data.Aeson.Types.FromJSON.FromJSON Hetzner.DNS.RecordType
+ Hetzner.DNS: instance Data.Aeson.Types.FromJSON.FromJSON Hetzner.DNS.Zone
+ Hetzner.DNS: instance Data.Aeson.Types.FromJSON.FromJSON Hetzner.DNS.ZoneID
+ Hetzner.DNS: instance Data.Aeson.Types.FromJSON.FromJSON Hetzner.DNS.ZoneStatus
+ Hetzner.DNS: instance Data.Aeson.Types.ToJSON.ToJSON Hetzner.DNS.RecordID
+ Hetzner.DNS: instance Data.Aeson.Types.ToJSON.ToJSON Hetzner.DNS.RecordType
+ Hetzner.DNS: instance Data.Aeson.Types.ToJSON.ToJSON Hetzner.DNS.ZoneID
+ Hetzner.DNS: instance Data.String.IsString Hetzner.DNS.Token
+ Hetzner.DNS: instance GHC.Classes.Eq Hetzner.DNS.RecordID
+ Hetzner.DNS: instance GHC.Classes.Eq Hetzner.DNS.RecordType
+ Hetzner.DNS: instance GHC.Classes.Eq Hetzner.DNS.Token
+ Hetzner.DNS: instance GHC.Classes.Eq Hetzner.DNS.ZoneID
+ Hetzner.DNS: instance GHC.Classes.Ord Hetzner.DNS.RecordID
+ Hetzner.DNS: instance GHC.Classes.Ord Hetzner.DNS.Token
+ Hetzner.DNS: instance GHC.Classes.Ord Hetzner.DNS.ZoneID
+ Hetzner.DNS: instance GHC.Enum.Enum Hetzner.DNS.RecordType
+ Hetzner.DNS: instance GHC.Exception.Type.Exception Hetzner.DNS.DNSException
+ Hetzner.DNS: instance GHC.Show.Show Hetzner.DNS.DNSException
+ Hetzner.DNS: instance GHC.Show.Show Hetzner.DNS.Record
+ Hetzner.DNS: instance GHC.Show.Show Hetzner.DNS.RecordID
+ Hetzner.DNS: instance GHC.Show.Show Hetzner.DNS.RecordType
+ Hetzner.DNS: instance GHC.Show.Show Hetzner.DNS.Token
+ Hetzner.DNS: instance GHC.Show.Show Hetzner.DNS.Zone
+ Hetzner.DNS: instance GHC.Show.Show Hetzner.DNS.ZoneID
+ Hetzner.DNS: instance GHC.Show.Show Hetzner.DNS.ZoneStatus
+ Hetzner.DNS: newtype RecordID
+ Hetzner.DNS: newtype Token
+ Hetzner.DNS: newtype ZoneID
+ Hetzner.DNS: noBody :: Maybe Void
+ Hetzner.DNS: streamPages :: forall key f a i m. (Foldable f, MonadIO m) => (Maybe Int -> IO (WithMeta key (f a))) -> ConduitT i a m ()
+ Hetzner.DNS: streamToList :: Monad m => ConduitT () a m () -> m [a]
+ Hetzner.DNS: updateRecord :: Token -> RecordID -> ZoneID -> Text -> RecordType -> Text -> Maybe Int -> IO Record
+ Hetzner.DNS: updateZone :: Token -> ZoneID -> Text -> Maybe Int -> IO Zone
- Hetzner.Cloud: NewServer :: Bool -> Maybe (Either DatacenterID LocationID) -> [FirewallID] -> ImageID -> [Label] -> Text -> Bool -> Bool -> ServerTypeID -> [SSHKeyID] -> Bool -> [VolumeID] -> NewServer
+ Hetzner.Cloud: NewServer :: Bool -> Maybe (Either DatacenterID LocationID) -> [FirewallID] -> ImageID -> [Label] -> Text -> [NetworkID] -> Bool -> Bool -> ServerTypeID -> [SSHKeyID] -> Bool -> [VolumeID] -> NewServer

Files

changelog.md view
@@ -1,3 +1,8 @@+## 0.2.0.0+* Allow to attach servers to networks on creation.+* Modify `streamPages` to cover more cases.+* Add support for DNS operations on zones and records.+ ## 0.1.2.0 * New function: `getTokenFromEnv`. This function allows the user   to obtain a token from the `HETZNER_API_TOKEN` environment variable.
hetzner.cabal view
@@ -1,8 +1,8 @@ name: hetzner category: Cloud-synopsis: Hetzner Cloud client library.-description: Hetzner Cloud client library. Check the readme and documentation for more details.-version: 0.1.2.0+synopsis: Hetzner Cloud and DNS library.+description: Hetzner Cloud and DNS library. Check the readme and documentation for more details.+version: 0.2.0.0 cabal-version: >= 1.10 build-type: Simple author: Daniel Casanueva (daniel.casanueva `at` proton.me)@@ -18,6 +18,7 @@   exposed-modules:     Hetzner.Cloud     Hetzner.Cloud.Fingerprint+    Hetzner.DNS   default-extensions:       ImportQualifiedPost     , OverloadedStrings
readme.md view
@@ -1,6 +1,6 @@-# Hetzner Cloud Haskell Library+# Hetzner Haskell Library -[![Build Status](https://img.shields.io/github/actions/workflow/status/Daniel-Diaz/hetzner/build.yml?branch=dev&style=for-the-badge)](https://github.com/Daniel-Diaz/hetzner/actions/workflows/build.yml)+[![Build Status](https://img.shields.io/github/actions/workflow/status/Daniel-Diaz/hetzner/build.yml?branch=main&style=for-the-badge)](https://github.com/Daniel-Diaz/hetzner/actions/workflows/build.yml)  ## Distributions @@ -10,7 +10,9 @@  ## Description -Client interface for the [Hetzner Cloud API](https://docs.hetzner.cloud/) written in the Haskell programming language.+Client interface for the [Hetzner Cloud API](https://docs.hetzner.cloud)+and the [Hetzner DNS API](https://dns.hetzner.com/api-docs),+written in the [Haskell](https://www.haskell.org) programming language.  ## Status 
src/Hetzner/Cloud.hs view
@@ -538,7 +538,13 @@       -- Yield results from response       forM_ resp $ mapM_ Conduit.yield       -- Continue if not in last page-      let next = nextPage $ pagination $ responseMeta resp+      let pag = pagination $ responseMeta resp+          cur = currentPage pag+      let next = case lastPage pag of+                   Just l -> if l == cur+                                then Nothing+                                else Just $ cur + 1+                   _ -> nextPage pag       if isNothing next then pure () else go next  -- | Convenient function to turn streams into lists.@@ -1189,6 +1195,8 @@     -- | Name of the server. Must be unique per project and a valid     --   hostname as per RFC 1123.   , newServerName :: Text+    -- | List of networks the server will be attached to.+  , newServerNetworks :: [NetworkID]   , newServerEnableIPv4 :: Bool   , newServerEnableIPv6 :: Bool   , newServerType :: ServerTypeID@@ -1207,6 +1215,7 @@     , pure $ "image" .= newServerImage nserver     , pure $ "labels" .= toLabelMap (newServerLabels nserver)     , pure $ "name" .= newServerName nserver+    , pure $ "networks" .= newServerNetworks nserver     , pure $ "public_net" .= JSON.object         [ "enable_ipv4" .= newServerEnableIPv4 nserver         , "enable_ipv6" .= newServerEnableIPv6 nserver@@ -1234,6 +1243,7 @@   , newServerImage = ImageID 67794396   , newServerLabels = []   , newServerName = name+  , newServerNetworks = []   , newServerEnableIPv4 = True   , newServerEnableIPv6 = True   , newServerType = ServerTypeID 1
+ src/Hetzner/DNS.hs view
@@ -0,0 +1,299 @@+----------------------------------------------------------------------------------------------------++-- | Client for the Hetzner DNS API.+module Hetzner.DNS (+    -- * Tokens+    Token (..)+  , getTokenFromEnv+    -- * Hetzner DNS+    -- ** Zones+  , ZoneID (..)+  , ZoneStatus (..)+  , Zone (..)+  , getZones+  , getZone+  , updateZone+  , deleteZone+    -- ** Records+  , RecordID (..)+  , RecordType (..)+  , allRecordTypes+  , Record (..)+  , getRecords+  , getRecord+  , createRecord+  , updateRecord+  , deleteRecord+    -- * Exceptions+  , DNSException (..)+    -- * Streaming+  , streamPages+  , streamToList+    -- * Generic interface+    -- ** Generic queries+  , dnsQuery+  , noBody+    -- ** JSON Wrappers+  , WithKey (..)+  , WithMeta (..)+    -- ** Response metadata+  , ResponseMeta (..)+  , Pagination (..)+  ) where++import Hetzner.Cloud+  ( WithKey (..), WithMeta (..)+  , ResponseMeta (..), Pagination (..), noBody+  , streamPages, streamToList+    )+-- base+import Data.String (IsString, fromString)+import Data.Maybe (maybeToList)+import System.Environment qualified as System+import Control.Exception (Exception, throwIO)+import Data.Foldable (find)+-- bytestring+import Data.ByteString (ByteString)+-- aeson+import Data.Aeson (FromJSON, ToJSON, (.:), (.:?), (.!=), (.=))+import Data.Aeson qualified as JSON+-- http-conduit+import Network.HTTP.Simple as HTTP+-- time+import Data.Time (ZonedTime, parseTimeM, defaultTimeLocale)+-- text+import Data.Text (Text)+import Data.Text qualified as Text+import Data.Text.Encoding (encodeUtf8)++-- | A token used to authenticate requests.+--+--   You can create one in the [Hetzner DNS Console](https://dns.hetzner.com/settings/api-token).+newtype Token = Token ByteString deriving (Show, Eq, Ord)++instance IsString Token where+  fromString = Token . fromString++-- | Lookup 'Token' from the environment variable @HETZNER_DNS_TOKEN@.+getTokenFromEnv :: IO (Maybe Token)+getTokenFromEnv = fmap fromString <$> System.lookupEnv "HETZNER_DNS_TOKEN"++-- | Exception produced while performing a request to Hetzner DNS.+data DNSException =+    DNSError (HTTP.Response ByteString)+  | JSONError (HTTP.Response ByteString) String+    deriving Show++instance Exception DNSException++-- | Generic Hetzner DNS query.+dnsQuery+  :: (ToJSON body, FromJSON a)+  => ByteString -- ^ Method.+  -> ByteString -- ^ Path.+  -> Maybe body -- ^ Request body. You may use 'noBody' to skip.+  -> HTTP.Query -- ^ Additional query options.+  -> Token -- ^ Authorization token.+  -> Maybe Int -- ^ Page.+  -> IO a+dnsQuery method path mbody query (Token token) mpage = do+  let req = HTTP.setRequestMethod method+          $ HTTP.setRequestSecure True+          $ HTTP.setRequestHost "dns.hetzner.com"+          $ HTTP.setRequestPort 443+          $ HTTP.setRequestPath ("/api/v1" <> path)+          $ maybe id HTTP.setRequestBodyJSON mbody+          $ HTTP.addRequestHeader "Auth-API-Token" token+          $ HTTP.addToRequestQueryString query+          $ maybe id (\page -> HTTP.addToRequestQueryString+                                 [("page", Just $ fromString $ show page)]) mpage+          $ HTTP.defaultRequest+  resp <- HTTP.httpBS req+  let body = HTTP.getResponseBody resp+  case divMod (HTTP.getResponseStatusCode resp) 100 of+    (2,m) ->+      let body' = if m == 4 then "{}" else body+      in  case JSON.eitherDecodeStrict body' of+            Left err -> throwIO $ JSONError resp err+            Right x -> pure x+    _ -> throwIO $ DNSError resp++----------------------------------------------------------------------------------------------------+-- Time parser+----------------------------------------------------------------------------------------------------++newtype DNSTime = DNSTime { dnsTime :: ZonedTime }++instance FromJSON DNSTime where+  parseJSON = JSON.withText "DNSTime" $+    let format = "%F %T%Q %z %Z"+    in  fmap DNSTime . parseTimeM False defaultTimeLocale format . Text.unpack++----------------------------------------------------------------------------------------------------+-- Zones+----------------------------------------------------------------------------------------------------++-- | Zone identifier.+newtype ZoneID = ZoneID Text deriving (Eq, Ord, Show, FromJSON, ToJSON)++-- | Status of a 'Zone'.+data ZoneStatus = Verified | Failed | Pending deriving Show++instance FromJSON ZoneStatus where+  parseJSON = JSON.withText "ZoneStatus" $ \t -> case t of+    "verified" -> pure Verified+    "failed" -> pure Failed+    "pending" -> pure Pending+    _ -> fail $ "Invalid zone status: " ++ Text.unpack t++-- | DNS zone.+data Zone = Zone+  { zoneCreated :: ZonedTime+  , zoneModified :: ZonedTime+  , zoneID :: ZoneID+  , zoneName :: Text+  , zoneIsSecondary :: Bool+  , zoneStatus :: ZoneStatus+  , zoneRecordCount :: Int+  , zoneTTL :: Int+    } deriving Show++instance FromJSON Zone where+  parseJSON = JSON.withObject "Zone" $ \o -> Zone+    <$> (dnsTime <$> o .: "created")+    <*> (dnsTime <$> o .: "modified")+    <*> o .: "id"+    <*> o .: "name"+    <*> o .: "is_secondary_dns"+    <*> o .: "status"+    <*> o .: "records_count"+    <*> o .: "ttl"++-- | Get zones.+getZones :: Token -> Maybe Int -> IO (WithMeta "zones" [Zone])+getZones = dnsQuery "GET" "/zones" noBody []++-- | Get a single zone.+getZone :: Token -> ZoneID -> IO Zone+getZone token (ZoneID i) = withoutKey @"zone" <$>+  dnsQuery "GET" ("/zones/" <> encodeUtf8 i) noBody [] token Nothing++-- | Update a zone's name and TTL.+updateZone+  :: Token+  -> ZoneID -- ^ ID of zone to update.+  -> Text -- ^ New zone name.+  -> Maybe Int -- ^ New TTL. If not provided, it won't change.+  -> IO Zone+updateZone token (ZoneID i) name mttl = withoutKey @"zone" <$>+  let body = JSON.object $ ("name" .= name) : maybeToList (fmap ("ttl" .=) mttl)+  in  dnsQuery "PUT" ("/zones/" <> encodeUtf8 i) (Just body) [] token Nothing++-- | Delete a zone.+deleteZone :: Token -> ZoneID -> IO ()+deleteZone token (ZoneID i) = dnsQuery "DELETE" ("/zones/" <> encodeUtf8 i) noBody [] token Nothing++----------------------------------------------------------------------------------------------------+-- Records+----------------------------------------------------------------------------------------------------++-- | A record identifier.+newtype RecordID = RecordID Text deriving (Eq, Ord, Show, FromJSON, ToJSON)++-- | Record type.+data RecordType =+  A | AAAA | CAA | CNAME | DANE | DS | HINFO | MX | NS | PTR | RP | SOA | SRV | TLS | TXT+  deriving (Eq, Show, Enum)++-- | List with all supported record types.+allRecordTypes :: [RecordType]+allRecordTypes = [A ..]++instance FromJSON RecordType where+  parseJSON = JSON.withText "RecordType" $ \t ->+    case find ((==) t . Text.pack . show) allRecordTypes of+      Just rtype -> pure rtype+      _ -> fail $ "Invalid record type: " ++ Text.unpack t++instance ToJSON RecordType where+  toJSON = JSON.String . Text.pack . show++-- | A DNS record.+data Record = Record+  { recordCreated :: ZonedTime+  , recordModified :: ZonedTime+  , recordID :: RecordID+  , recordName :: Text+  , recordType :: RecordType+  , recordValue :: Text+  , recordTTL :: Int+    -- | ID of the zone this record is associated with.+  , recordZone :: ZoneID+    } deriving Show++instance FromJSON Record where+  parseJSON = JSON.withObject "Record" $ \o -> Record+    <$> (dnsTime <$> o .: "created")+    <*> (dnsTime <$> o .: "modified")+    <*> o .: "id"+    <*> o .: "name"+    <*> o .: "type"+    <*> o .: "value"+    <*> o .:? "ttl" .!= 86400+    <*> o .: "zone_id"++-- | Get DNS records.+getRecords+  :: Token+  -> Maybe ZoneID -- ^ Optionally filter by zone.+  -> IO [Record]+getRecords token mzone = withoutKey @"records" <$>+  let query = maybe [] (\(ZoneID zone) -> [("zone_id", Just $ encodeUtf8 zone)]) mzone+  in  dnsQuery "GET" "/records" noBody query token Nothing++-- | Get a single DNS record.+getRecord :: Token -> RecordID -> IO Record+getRecord token (RecordID i) = withoutKey @"record" <$>+  dnsQuery "GET" ("/records/" <> encodeUtf8 i) noBody [] token Nothing++-- | Create a DNS record.+createRecord+  :: Token+  -> ZoneID -- ^ Zone to add the record to.+  -> Text -- ^ Record name.+  -> RecordType -- ^ Record type.+  -> Text -- ^ Record value.+  -> Maybe Int -- ^ Optional TTL.+  -> IO Record+createRecord token zone name rtype value mttl = withoutKey @"record" <$>+  let body = JSON.object $+        [ "zone_id" .= zone+        , "name" .= name+        , "type" .= rtype+        , "value" .= value+          ] ++ maybe [] (pure . ("ttl" .=)) mttl+  in  dnsQuery "POST" "/records" (Just body) [] token Nothing++-- | Update a DNS record.+updateRecord+  :: Token+  -> RecordID -- ^ Record to update.+  -> ZoneID -- ^ Zone for the record.+  -> Text -- ^ New record name.+  -> RecordType -- ^ New recored type.+  -> Text -- ^ New record value.+  -> Maybe Int -- ^ Optinally, a new TTL.+  -> IO Record+updateRecord token (RecordID i) zone name rtype value mttl = withoutKey @"record" <$>+  let body = JSON.object $+        [ "zone_id" .= zone+        , "name" .= name+        , "type" .= rtype+        , "value" .= value+          ] ++ maybe [] (pure . ("ttl" .=)) mttl+  in  dnsQuery "PUT" ("/records/" <> encodeUtf8 i) (Just body) [] token Nothing++-- | Delete a DNS record.+deleteRecord :: Token -> RecordID -> IO ()+deleteRecord token (RecordID i) =+  dnsQuery "DELETE" ("/records/" <> encodeUtf8 i) noBody [] token Nothing