ebird-api-0.1.0.0: src/Data/EBird/API/Regions.hs
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE UndecidableInstances #-}
-- |
-- Module : Data.EBird.API.Regions
-- Copyright : (c) 2023 Finley McIlwaine
-- License : MIT (see LICENSE)
--
-- Maintainer : Finley McIlwaine <finleymcilwaine@gmail.com>
--
-- Types related to eBird region API values.
module Data.EBird.API.Regions where
import Control.Arrow
import Data.Aeson
import Data.Attoparsec.Text
import Data.Function
import Data.Functor
import Data.List.NonEmpty (NonEmpty(..))
import Data.String
import Data.Text (Text)
import Data.Text qualified as Text
import Optics
import Servant.API (ToHttpApiData(..))
import Data.EBird.API.EBirdString
-------------------------------------------------------------------------------
-- * Region-related API types
-------------------------------------------------------------------------------
-- | eBird divides the world into countries, subnational1 regions (states) or
-- subnational2 regions (counties). 'Location' regions are eBird-specific
-- location identifiers.
data Region =
-- | Regions may be specified as location IDs, e.g. @L227544@
Location Integer
-- | The world is a region
| World
-- | At the top level, the world is divided into countries
| Country Text
-- | Subnational1 regions are states within countries
| Subnational1
Text -- ^ The country
Text -- ^ The state
-- | Subnational2 regions are counties within states
| Subnational2
Text -- ^ The country
Text -- ^ The state
Text -- ^ The county
deriving (Show, Read, Eq)
-- | One constructor per eBird "region type" (countries, subnational1 (states),
-- or subnational2 (counties)).
data RegionType =
CountryType
| Subnational1Type
| Subnational2Type
deriving (Show, Read, Eq)
-- | A 'RegionCode' is a list of one or more 'Region's.
newtype RegionCode = RegionCode { regionCodeRegions :: NonEmpty Region }
deriving (Show, Read, Eq)
-- | 'RegionNameFormat' values specify what format the API should return region
-- names in. See the constructor docs for examples.
data RegionNameFormat =
-- | 'DetailedNameFormat' region name values are fully qualified with only
-- the country abbreviated, e.g. "Madison County, New York, US"
DetailedNameFormat
-- | 'DetailedNoQualNameFormat' region name values are like
-- 'DetailedNameFormat' but without the country qualifier and no "county"
-- annotation, e.g. "Madison, New York"
| DetailedNoQualNameFormat
-- | 'FullNameFormat' region name values are fully qualified with no
-- abbreviated country name and no "county" annotation, e.g. "Madison,
-- New York, United States"
| FullNameFormat
-- | 'NameQualNameFormat' region name values are just the annotated name,
-- e.g. "Madison County"
| NameQualNameFormat
-- | 'NameOnlyNameFormat' region name values are just the name, e.g.
-- \"Madison\"
| NameOnlyNameFormat
-- | 'RevDetailedNameFormat' region name values are like
-- 'DetailedNameFormat' but with reverse qualifiers, e.g. "US, New York,
-- Madison County"
| RevDetailedNameFormat
deriving (Show, Read, Eq)
-- | 'RegionInfo' specifies the name of a region (in some 'RegionNameFormat')
-- and the bounds of that region as 'RegionBounds'.
data RegionInfo =
RegionInfo
{ _regionInfoName :: Text
, _regionInfoBounds :: Maybe RegionBounds
}
deriving (Show, Read, Eq)
-- | 'RegionBounds' specify the corners of a bounding box around a region.
data RegionBounds =
RegionBounds
{ _regionBoundsMinX :: Double
, _regionBoundsMaxX :: Double
, _regionBoundsMinY :: Double
, _regionBoundsMaxY :: Double
}
deriving (Show, Read, Eq)
-- | The data structure returned by the eBird 'Data.EBird.API.SubRegionListAPI' and
-- 'Data.EBird.API.AdjacentRegionsAPI'.
data RegionListEntry =
RegionListEntry
{ _regionListEntryRegion :: Region
, _regionListEntryName :: Text
}
deriving (Show, Read, Eq)
-- ** Optics for region-related API types
makeLenses ''RegionInfo
makeFieldLabels ''RegionInfo
makeLenses ''RegionBounds
makeFieldLabels ''RegionBounds
makeLenses ''RegionListEntry
makeFieldLabels ''RegionListEntry
-------------------------------------------------------------------------------
-- aeson instances
-------------------------------------------------------------------------------
instance FromJSON RegionCode where
parseJSON = withText "RegionCode" $ \t ->
case parseOnly parseRegionCode t of
Left _ -> fail "failed to parse region code"
Right c -> return c
instance ToJSON RegionCode where
toJSON = String . toEBirdString
instance FromJSON Region where
parseJSON = withText "RegionCode" $ \t ->
case parseOnly parseRegion t of
Left _ -> fail "failed to parse region"
Right r -> return r
instance ToJSON Region where
toJSON = String . toEBirdString
-- | Explicit instance for compatibility with their field names
instance FromJSON RegionInfo where
parseJSON = withObject "RegionInfo" $ \v ->
RegionInfo
<$> v .: "result"
<*> v .:? "bounds"
-- | Explicit instance for compatibility with their field names
instance ToJSON RegionInfo where
toJSON RegionInfo{..} =
object $
[ "result" .= _regionInfoName
]
<> ["bounds" .= bs | Just bs <- [_regionInfoBounds]]
-- | Explicit instance for compatibility with their field names
instance FromJSON RegionBounds where
parseJSON = withObject "RegionBounds" $ \v ->
RegionBounds
<$> v .: "minX"
<*> v .: "maxX"
<*> v .: "minY"
<*> v .: "maxY"
-- | Explicit instance for compatibility with their field names
instance ToJSON RegionBounds where
toJSON RegionBounds{..} =
object
[ "minX" .= _regionBoundsMinX
, "maxX" .= _regionBoundsMaxX
, "minY" .= _regionBoundsMinY
, "maxY" .= _regionBoundsMaxY
]
-- | Explicit instance for compatibility with their field names
instance FromJSON RegionListEntry where
parseJSON = withObject "RegionListEntry" $ \v ->
RegionListEntry
<$> v .: "code"
<*> v .: "name"
-- | Explicit instance for compatibility with their field names
instance ToJSON RegionListEntry where
toJSON RegionListEntry{..} =
object
[ "code" .= _regionListEntryRegion
, "name" .= _regionListEntryName
]
-------------------------------------------------------------------------------
-- 'EBirdString' instances
-------------------------------------------------------------------------------
-- | A 'Region' eBird string is either:
--
-- * \"L227544\" for location regions, where L227544 is the location ID.
-- * "world" for 'World' regions.
-- * The country identifier (e.g. \"US\" for the United States) for 'Country'
-- regions.
-- * The country identifier and the state identifier separated by a hyphen
-- for 'Subnational1' regions (e.g. "US-WY" for Wyoming in the United
-- States).
-- * The county identifier, the state identifier, and the country identifier
-- separated by hyphens for 'Subnational2' regions (e.g. US-WY-013)
instance EBirdString Region where
toEBirdString =
\case
Location n -> "L" <> Text.pack (show n)
World -> "world"
Country cr -> cr
Subnational1 cr st -> cr <> "-" <> st
Subnational2 cr st cy -> cr <> "-" <> st <> "-" <> cy
fromEBirdString str =
parseOnly parseRegion str
& left (("Failed to parse Region: " <>) . Text.pack)
-- | Results in
-- [eBird region type format](https://documenter.getpostman.com/view/664302/S1ENwy59#382da1c8-8bff-4926-936a-a1f8b065e7d5)
instance EBirdString RegionType where
toEBirdString =
\case
CountryType -> "country"
Subnational1Type -> "subnational1"
Subnational2Type -> "subnational2"
fromEBirdString str =
parseOnly parseRegionType str
& left (("Failed to parse RegionType: " <>) . Text.pack)
-- | A 'RegionCode' eBird string is a comma-separated list of regions.
instance EBirdString RegionCode where
toEBirdString (RegionCode (r :| rs)) =
Text.intercalate "," $ map toEBirdString (r : rs)
fromEBirdString str =
parseOnly parseRegionCode str
& left (("Failed to parse RegionCode: " <>) . Text.pack)
-- | A 'RegionNameFormat' is shown as the constructor name without the
-- @NameFormat@ suffix, in all lower-case.
instance EBirdString RegionNameFormat where
toEBirdString =
\case
DetailedNameFormat -> "detailed"
DetailedNoQualNameFormat -> "detailednoqual"
FullNameFormat -> "full"
NameQualNameFormat -> "namequal"
NameOnlyNameFormat -> "nameonly"
RevDetailedNameFormat -> "revdetailed"
fromEBirdString str =
parseOnly parseRegionNameFormat str
& left (("Failed to parse RegionNameFormat: " <>) . Text.pack)
-------------------------------------------------------------------------------
-- IsString instances
-------------------------------------------------------------------------------
-- | Use this instance carefully! It throws runtime exceptions if the string is
-- malformatted.
instance IsString Region where
fromString = unsafeFromEBirdString . Text.pack
-- | Use this instance carefully! It throws runtime exceptions if the string is
-- malformatted.
instance IsString RegionType where
fromString = unsafeFromEBirdString . Text.pack
-- | Use this instance carefully! It throws runtime exceptions if the string is
-- malformatted.
instance IsString RegionCode where
fromString = unsafeFromEBirdString . Text.pack
-- | Use this instance carefully! It throws runtime exceptions if the string is
-- malformatted.
instance IsString RegionNameFormat where
fromString = unsafeFromEBirdString . Text.pack
-------------------------------------------------------------------------------
-- * attoparsec parsers
-------------------------------------------------------------------------------
-- | Parse an eBird API region code, which is a comma-separated list of one or
-- more regions. To avoid the partial behavior of converting a 'sepBy1' result
-- into a 'NonEmpty', we manually parse the first region followed by an optional
-- tail.
parseRegionCode :: Parser RegionCode
parseRegionCode = do
r <- parseRegion
rs <- atEnd >>= \case
True -> return []
False -> do
skip (==',')
parseRegion `sepBy` char ','
return $ RegionCode (r :| rs)
-- | Parse an eBird API region. This parser only ensures that the input is
-- somewhat well-formed, in that it is either:
--
-- * A 'Location' region (an \'L\' followed by an integral number)
-- * The 'World' region (just the string "world")
-- * A 'Subnational2' region (formatted as "LETTERS-LETTERS-NUMBER" where
-- "LETTERS" is one or more letters in any case, and "NUMBERS" is an
-- integral number)
-- * A 'Subnational1' region (formatterd as "LETTERS-LETTERS")
-- * A 'Country' region (just \"LETTERS\")
parseRegion :: Parser Region
parseRegion =
choice
[ parseLocationId
, parseWorld
, parseSubnational2
, parseSubnational1
, parseCountry
]
where
parseLocationId :: Parser Region
parseLocationId = do
"L" *> (Location <$> decimal)
parseWorld :: Parser Region
parseWorld = do
"world" $> World
parseCountry :: Parser Region
parseCountry = Country <$> letters
parseSubnational1 :: Parser Region
parseSubnational1 = do
cr <- letters
skipHyphen
st <- letters
return $ Subnational1 cr st
parseSubnational2 :: Parser Region
parseSubnational2 = do
cr <- letters
skipHyphen
st <- letters
skipHyphen
cy <- choice [letters, digits]
return $ Subnational2 cr st cy
letters :: Parser Text
letters = Text.pack <$> many1 letter
digits :: Parser Text
digits = Text.pack <$> many1 digit
skipHyphen :: Parser ()
skipHyphen = skip (=='-')
-- | Parse an eBird API 'RegionNameFormat'.
parseRegionNameFormat :: Parser RegionNameFormat
parseRegionNameFormat =
choice
[ "detailednoqual" $> DetailedNoQualNameFormat
, "detailed" $> DetailedNameFormat
, "full" $> FullNameFormat
, "namequal" $> NameQualNameFormat
, "nameonly" $> NameOnlyNameFormat
, "revdetailed" $> RevDetailedNameFormat
]
where
_casesCovered :: RegionNameFormat -> ()
_casesCovered =
\case
DetailedNoQualNameFormat -> ()
DetailedNameFormat -> ()
FullNameFormat -> ()
NameQualNameFormat -> ()
NameOnlyNameFormat -> ()
RevDetailedNameFormat -> ()
-- | Parse an eBird API 'RegionType'.
parseRegionType :: Parser RegionType
parseRegionType =
choice
[ "country" $> CountryType
, "subnational1" $> Subnational1Type
, "subnational2" $> Subnational2Type
]
where
_casesCovered :: RegionType -> ()
_casesCovered =
\case
CountryType -> ()
Subnational1Type -> ()
Subnational2Type -> ()
-------------------------------------------------------------------------------
-- 'ToHttpApiData' instances
-------------------------------------------------------------------------------
instance ToHttpApiData Region where
toUrlPiece = toEBirdString
instance ToHttpApiData RegionType where
toUrlPiece = toEBirdString
instance ToHttpApiData RegionCode where
toUrlPiece = toEBirdString
instance ToHttpApiData RegionNameFormat where
toUrlPiece = toEBirdString