ebird-api-0.1.0.0: src/Data/EBird/API/Observations.hs
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE UndecidableInstances #-}
-- |
-- Module : Data.EBird.API.Observations
-- Copyright : (c) 2023 Finley McIlwaine
-- License : MIT (see LICENSE)
--
-- Maintainer : Finley McIlwaine <finleymcilwaine@gmail.com>
--
-- Types and functions related to eBird observation API values.
module Data.EBird.API.Observations where
import Control.Arrow
import Data.Aeson
import Data.Aeson.KeyMap
import Data.Attoparsec.Text
import Data.Function
import Data.Functor
import Data.Maybe
import Data.String
import Data.Text as Text
import Optics
import Servant.API (ToHttpApiData(..))
import Data.EBird.API.EBirdString
import Data.EBird.API.Regions
import Data.EBird.API.Util.Time
-------------------------------------------------------------------------------
-- * Observation types
-------------------------------------------------------------------------------
-- | An observation of a species submitted to eBird within a checklist. The
-- 'DetailLevel' index indicates whether the observation data includes "full"
-- details.
data Observation (detail :: DetailLevel) =
Observation
{ -- | Species code, e.g. "bohwax"
_observationSpeciesCode :: Text
-- | Common name, e.g. "Bohemian Waxwing"
, _observationCommonName :: Text
-- | Scientific name, e.g. "Bombycilla garrulus"
, _observationScientificName :: Text
-- | Location ID, e.g. \"L7884500\"
, _observationLocationId :: Text
-- | Location name, e.g. "Frog Pond"
, _observationLocationName :: Text
-- | Date and time of observation
, _observationDateTime :: EBirdDateTime
-- | How many were seen? Sometimes omitted.
, _observationHowMany :: Maybe Integer
-- | Observation latitude
, _observationLatitude :: Double
-- | Observation longitude
, _observationLongitude :: Double
-- | Is this observation valid?
, _observationValid :: Bool
-- | Has this observation been reviewed?
, _observationReviewed :: Bool
-- | Is the location of this observation private?
, _observationLocationPrivate :: Bool
-- | Submission ID
, _observationSubId :: Text
, _observationFullDetail :: ObservationDetails detail
}
deriving instance Show (Observation 'Simple)
deriving instance Show (Observation 'Full)
deriving instance Eq (Observation 'Simple)
deriving instance Eq (Observation 'Full)
-- | Extra details that may be attached to an observation. At the moment, it
-- only seems possible to get 'Full' detailed observations from the notable
-- observation endpoints (e.g. 'Data.EBird.API.RecentNotableObservationsAPI').
data ObservationDetails (detail :: DetailLevel) where
NoDetails :: ObservationDetails 'Simple
FullDetails ::
{ -- | The subnational2 region that this observation took place in
_observationDetailsSubnational2Code :: Region
-- | The name of the subnational2 region that this observation took
-- place in
, _observationDetailsSubnational2Name :: Text
-- | The subnational1 region that this observation took place in
, _observationDetailsSubnational1Code :: Region
-- | The name of the subnational1 region that this observation took
-- place in
, _observationDetailsSubnational1Name :: Text
-- | The country region that this observation took place in
, _observationDetailsCountryCode :: Region
-- | The name of the country region that this observation took place in
, _observationDetailsCountryName :: Text
-- | The display name of the user that submitted this observation
, _observationDetailsUserDisplayName :: Text
-- | The unique ID of this observation
, _observationDetailsObsId :: Text
-- | The ID of the checklist that this observation was submitted with,
-- e.g. \"CL24936\"
, _observationDetailsChecklistId :: Text
-- | Whether the count for the observation was provided as just \"X\"
, _observationDetailsPresenceNoted :: Bool
-- | Whether this observation was submitted with comments
, _observationDetailsHasComments :: Bool
-- | The last name of the user that submitted this observation
, _observationDetailsLastName :: Text
-- | The first name of the user that submitted this observation
, _observationDetailsFirstName :: Text
-- | Whether this observation has media such as photos, videos, or
-- audio attached
, _observationDetailsHasRichMedia :: Bool
} -> ObservationDetails 'Full
deriving instance Show (ObservationDetails 'Simple)
deriving instance Show (ObservationDetails 'Full)
deriving instance Eq (ObservationDetails 'Simple)
deriving instance Eq (ObservationDetails 'Full)
-- | 'Observation' values of existentially quantified detail.
data SomeObservation where
SomeObservation :: Observation detail -> SomeObservation
instance Show SomeObservation where
show (SomeObservation o) =
case _observationFullDetail o of
NoDetails -> show o
FullDetails{} -> show o
-------------------------------------------------------------------------------
-- * Auxiliary eBird observation API types
-------------------------------------------------------------------------------
-- | The promoted constructors of this type are used as type-level indices on
-- the 'Observation' type to determine whether an observation is 'Simple' detail
-- or 'Full' detail.
data DetailLevel = Simple | Full
deriving (Show, Read, Eq)
-- | Values representing the ways that observations may be sorted in responses
-- from the API.
data SortObservationsBy
= SortObservationsByDate
| SortObservationsBySpecies
deriving (Show, Read, Eq)
-- | Values representing how to pick which 'Observation's are returned from the
-- 'Data.EBird.API.HistoricalObservationsAPI' in the case that there are several
-- observations of the same species on the date.
data SelectObservation
= SelectFirstObservation
| SelectLastObservation
deriving (Show, Read, Eq)
-------------------------------------------------------------------------------
-- * Optics for observation types
-------------------------------------------------------------------------------
makeLenses ''Observation
makeFieldLabels ''Observation
observationDetailsSubnational2Code :: Lens' (ObservationDetails 'Full) Region
observationDetailsSubnational2Code =
lensVL $ \f d@FullDetails{..} ->
(\c -> d { _observationDetailsSubnational2Code = c })
<$> f _observationDetailsSubnational2Code
observationDetailsSubnational2Name :: Lens' (ObservationDetails 'Full) Text
observationDetailsSubnational2Name =
lensVL $ \f d@FullDetails{..} ->
(\c -> d { _observationDetailsSubnational2Name = c })
<$> f _observationDetailsSubnational2Name
observationDetailsSubnational1Code :: Lens' (ObservationDetails 'Full) Region
observationDetailsSubnational1Code =
lensVL $ \f d@FullDetails{..} ->
(\c -> d { _observationDetailsSubnational1Code = c })
<$> f _observationDetailsSubnational1Code
observationDetailsSubnational1Name :: Lens' (ObservationDetails 'Full) Text
observationDetailsSubnational1Name =
lensVL $ \f d@FullDetails{..} ->
(\c -> d { _observationDetailsSubnational1Name = c })
<$> f _observationDetailsSubnational1Name
observationDetailsCountryCode :: Lens' (ObservationDetails 'Full) Region
observationDetailsCountryCode =
lensVL $ \f d@FullDetails{..} ->
(\c -> d { _observationDetailsCountryCode = c })
<$> f _observationDetailsCountryCode
observationDetailsCountryName :: Lens' (ObservationDetails 'Full) Text
observationDetailsCountryName =
lensVL $ \f d@FullDetails{..} ->
(\c -> d { _observationDetailsCountryName = c })
<$> f _observationDetailsCountryName
observationDetailsUserDisplayName :: Lens' (ObservationDetails 'Full) Text
observationDetailsUserDisplayName =
lensVL $ \f d@FullDetails{..} ->
(\c -> d { _observationDetailsUserDisplayName = c })
<$> f _observationDetailsUserDisplayName
observationDetailsObsId :: Lens' (ObservationDetails 'Full) Text
observationDetailsObsId =
lensVL $ \f d@FullDetails{..} ->
(\c -> d { _observationDetailsObsId = c })
<$> f _observationDetailsObsId
observationDetailsChecklistId :: Lens' (ObservationDetails 'Full) Text
observationDetailsChecklistId =
lensVL $ \f d@FullDetails{..} ->
(\c -> d { _observationDetailsChecklistId = c })
<$> f _observationDetailsChecklistId
observationDetailsPresenceNoted :: Lens' (ObservationDetails 'Full) Bool
observationDetailsPresenceNoted =
lensVL $ \f d@FullDetails{..} ->
(\c -> d { _observationDetailsPresenceNoted = c })
<$> f _observationDetailsPresenceNoted
observationDetailsHasComments :: Lens' (ObservationDetails 'Full) Bool
observationDetailsHasComments =
lensVL $ \f d@FullDetails{..} ->
(\c -> d { _observationDetailsHasComments = c })
<$> f _observationDetailsHasComments
observationDetailsLastName :: Lens' (ObservationDetails 'Full) Text
observationDetailsLastName =
lensVL $ \f d@FullDetails{..} ->
(\c -> d { _observationDetailsLastName = c })
<$> f _observationDetailsLastName
observationDetailsFirstName :: Lens' (ObservationDetails 'Full) Text
observationDetailsFirstName =
lensVL $ \f d@FullDetails{..} ->
(\c -> d { _observationDetailsFirstName = c })
<$> f _observationDetailsFirstName
observationDetailsHasRichMedia :: Lens' (ObservationDetails 'Full) Bool
observationDetailsHasRichMedia =
lensVL $ \f d@FullDetails{..} ->
(\c -> d { _observationDetailsHasRichMedia = c })
<$> f _observationDetailsHasRichMedia
instance
k ~ A_Lens
=> LabelOptic
"subnational2Code" k
(ObservationDetails 'Full)
(ObservationDetails 'Full)
Region
Region
where
labelOptic = observationDetailsSubnational2Code
instance
k ~ A_Lens
=> LabelOptic
"subnational2Name" k
(ObservationDetails 'Full)
(ObservationDetails 'Full)
Text
Text
where
labelOptic = observationDetailsSubnational2Name
instance
k ~ A_Lens
=> LabelOptic
"subnational1Code" k
(ObservationDetails 'Full)
(ObservationDetails 'Full)
Region
Region
where
labelOptic = observationDetailsSubnational1Code
instance
k ~ A_Lens
=> LabelOptic
"subnational1Name" k
(ObservationDetails 'Full)
(ObservationDetails 'Full)
Text
Text
where
labelOptic = observationDetailsSubnational1Name
instance
k ~ A_Lens
=> LabelOptic
"countryCode" k
(ObservationDetails 'Full)
(ObservationDetails 'Full)
Region
Region
where
labelOptic = observationDetailsCountryCode
instance
k ~ A_Lens
=> LabelOptic
"countryName" k
(ObservationDetails 'Full)
(ObservationDetails 'Full)
Text
Text
where
labelOptic = observationDetailsCountryName
instance
k ~ A_Lens
=> LabelOptic
"userDisplayName" k
(ObservationDetails 'Full)
(ObservationDetails 'Full)
Text
Text
where
labelOptic = observationDetailsUserDisplayName
instance
k ~ A_Lens
=> LabelOptic
"obsId" k
(ObservationDetails 'Full)
(ObservationDetails 'Full)
Text
Text
where
labelOptic = observationDetailsObsId
instance
k ~ A_Lens
=> LabelOptic
"checklistId" k
(ObservationDetails 'Full)
(ObservationDetails 'Full)
Text
Text
where
labelOptic = observationDetailsChecklistId
instance
k ~ A_Lens
=> LabelOptic
"presenceNoted" k
(ObservationDetails 'Full)
(ObservationDetails 'Full)
Bool
Bool
where
labelOptic = observationDetailsPresenceNoted
instance
k ~ A_Lens
=> LabelOptic
"hasComments" k
(ObservationDetails 'Full)
(ObservationDetails 'Full)
Bool
Bool
where
labelOptic = observationDetailsHasComments
instance
k ~ A_Lens
=> LabelOptic
"lastName" k
(ObservationDetails 'Full)
(ObservationDetails 'Full)
Text
Text
where
labelOptic = observationDetailsLastName
instance
k ~ A_Lens
=> LabelOptic
"firstName" k
(ObservationDetails 'Full)
(ObservationDetails 'Full)
Text
Text
where
labelOptic = observationDetailsFirstName
instance
k ~ A_Lens
=> LabelOptic
"hasRichMedia" k
(ObservationDetails 'Full)
(ObservationDetails 'Full)
Bool
Bool
where
labelOptic = observationDetailsHasRichMedia
-------------------------------------------------------------------------------
-- aeson instances
-------------------------------------------------------------------------------
-- | Explicit instance for compatibility with their field names
instance FromJSON (Observation 'Simple) where
parseJSON = withObject "Observation 'Simple" $ \v ->
Observation
<$> v .: "speciesCode"
<*> v .: "comName"
<*> v .: "sciName"
<*> v .: "locId"
<*> v .: "locName"
<*> v .: "obsDt"
<*> v .:? "howMany"
<*> v .: "lat"
<*> v .: "lng"
<*> v .: "obsValid"
<*> v .: "obsReviewed"
<*> v .: "locationPrivate"
<*> v .: "subId"
<*> pure NoDetails
-- | Explicit instance for compatibility with their field names
instance ToJSON (Observation 'Simple) where
toJSON Observation{..} =
object $
[ "speciesCode" .= _observationSpeciesCode
, "comName" .= _observationCommonName
, "sciName" .= _observationScientificName
, "locId" .= _observationLocationId
, "locName" .= _observationLocationName
, "obsDt" .= _observationDateTime
, "lat" .= _observationLatitude
, "lng" .= _observationLongitude
, "obsValid" .= _observationValid
, "obsReviewed" .= _observationReviewed
, "locationPrivate" .= _observationLocationPrivate
, "subId" .= _observationSubId
]
-- Fields that may or may not be included, depending on the observation
-- data
<> ["howMany" .= howMany | Just howMany <- [_observationHowMany]]
-- | Explicit instance for compatibility with their field names
instance FromJSON (Observation 'Full) where
parseJSON = withObject "Observation 'Full" $ \v ->
Observation
<$> v .: "speciesCode"
<*> v .: "comName"
<*> v .: "sciName"
<*> v .: "locId"
<*> v .: "locName"
<*> v .: "obsDt"
<*> v .:? "howMany"
<*> v .: "lat"
<*> v .: "lng"
<*> v .: "obsValid"
<*> v .: "obsReviewed"
<*> v .: "locationPrivate"
<*> v .: "subId"
<*> ( FullDetails
<$> v .: "subnational2Code"
<*> v .: "subnational2Name"
<*> v .: "subnational1Code"
<*> v .: "subnational1Name"
<*> v .: "countryCode"
<*> v .: "countryName"
<*> v .: "userDisplayName"
<*> v .: "obsId"
<*> v .: "checklistId"
<*> v .: "presenceNoted"
<*> v .: "hasComments"
<*> v .: "lastName"
<*> v .: "firstName"
<*> v .: "hasRichMedia"
)
-- | Explicit instance for compatibility with their field names
instance ToJSON (Observation 'Full) where
toJSON Observation{..} =
object
[ "speciesCode" .= _observationSpeciesCode
, "comName" .= _observationCommonName
, "sciName" .= _observationScientificName
, "locId" .= _observationLocationId
, "locName" .= _observationLocationName
, "obsDt" .= _observationDateTime
, "howMany" .= _observationHowMany
, "lat" .= _observationLatitude
, "lng" .= _observationLongitude
, "obsValid" .= _observationValid
, "obsReviewed" .= _observationReviewed
, "locationPrivate" .= _observationLocationPrivate
, "subId" .= _observationSubId
, "subnational2Code" .=
_observationDetailsSubnational2Code _observationFullDetail
, "subnational2Name" .=
_observationDetailsSubnational2Name _observationFullDetail
, "subnational1Code" .=
_observationDetailsSubnational1Code _observationFullDetail
, "subnational1Name" .=
_observationDetailsSubnational1Name _observationFullDetail
, "countryCode" .=
_observationDetailsCountryCode _observationFullDetail
, "countryName" .=
_observationDetailsCountryName _observationFullDetail
, "userDisplayName" .=
_observationDetailsUserDisplayName _observationFullDetail
, "obsId" .=
_observationDetailsObsId _observationFullDetail
, "checklistId" .=
_observationDetailsChecklistId _observationFullDetail
, "presenceNoted" .=
_observationDetailsPresenceNoted _observationFullDetail
, "hasComments" .=
_observationDetailsHasComments _observationFullDetail
, "lastName" .=
_observationDetailsLastName _observationFullDetail
, "firstName" .=
_observationDetailsFirstName _observationFullDetail
, "hasRichMedia" .=
_observationDetailsHasRichMedia _observationFullDetail
]
-- | Switches between parsing a 'Simple' detail 'Observation' and a 'Full'
-- detail 'Observation' depending on whether the "firstName" key is present.
instance FromJSON SomeObservation where
parseJSON obj = withObject "SomeObservation"
( \v ->
if isJust (v !? "firstName") then
SomeObservation <$> parseJSON @(Observation 'Full) obj
else
SomeObservation <$> parseJSON @(Observation 'Simple) obj
) obj
-- | Switches between encoding a 'Simple' 'Observation' and a 'Full'
-- 'Observation' depending on the evidence introduced by pattern-matching on the
-- 'observationFullDetail' field.
instance ToJSON SomeObservation where
toJSON (SomeObservation obs) =
case _observationFullDetail obs of
NoDetails -> toJSON @(Observation 'Simple) obs
FullDetails {} -> toJSON @(Observation 'Full) obs
-------------------------------------------------------------------------------
-- 'EBirdString' instances
-------------------------------------------------------------------------------
-- | The eBird string for a 'DetailLevel' value is simply the lowercase
-- constructor name.
instance EBirdString DetailLevel where
toEBirdString =
\case
Simple -> "simple"
Full -> "full"
fromEBirdString str =
parseOnly parseDetailLevel str
& left (("Failed to parse DetailLevel: " <>) . Text.pack)
-- | The eBird string for a 'SortObservationsBy' value is either "date" or
-- "species".
instance EBirdString SortObservationsBy where
toEBirdString =
\case
SortObservationsByDate -> "date"
SortObservationsBySpecies -> "species"
fromEBirdString str =
parseOnly parseSortObservationsBy str
& left (("Failed to parse SortObservationsBy: " <>) . Text.pack)
-- | The eBird string for a 'SelectObservation' value is either "create" or
-- "mrec".
instance EBirdString SelectObservation where
toEBirdString =
\case
SelectFirstObservation -> "create"
SelectLastObservation -> "mrec"
fromEBirdString str =
parseOnly parseSelectObservation str
& left (("Failed to parse SelectObservation: " <>) . Text.pack)
-------------------------------------------------------------------------------
-- IsString instances
-------------------------------------------------------------------------------
-- | Use this instance carefully! It throws runtime exceptions if the string is
-- malformatted.
instance IsString DetailLevel where
fromString = unsafeFromEBirdString . Text.pack
-- | Use this instance carefully! It throws runtime exceptions if the string is
-- malformatted.
instance IsString SortObservationsBy where
fromString = unsafeFromEBirdString . Text.pack
-- | Use this instance carefully! It throws runtime exceptions if the string is
-- malformatted.
instance IsString SelectObservation where
fromString = unsafeFromEBirdString . Text.pack
-------------------------------------------------------------------------------
-- * attoparsec parsers
-------------------------------------------------------------------------------
-- | Parse a list of eBird API taxononomy categories. To avoid the partial
-- behavior of converting a 'sepBy1' result into a 'Data.List.NonEmpty', we
-- manually parse the first category followed by an optional tail.
parseDetailLevel :: Parser DetailLevel
parseDetailLevel =
choice
[ "simple" $> Simple
, "full" $> Full
]
where
_casesCovered :: DetailLevel -> ()
_casesCovered =
\case
Simple -> ()
Full -> ()
-- | Parse a 'SortObservationsBy' value
parseSortObservationsBy :: Parser SortObservationsBy
parseSortObservationsBy =
choice
[ "date" $> SortObservationsByDate
, "species" $> SortObservationsBySpecies
]
where
_casesCovered :: SortObservationsBy -> ()
_casesCovered =
\case
SortObservationsByDate -> ()
SortObservationsBySpecies -> ()
-- | Parse a 'SelectObservation' value
parseSelectObservation :: Parser SelectObservation
parseSelectObservation =
choice
[ "first" $> SelectFirstObservation
, "last" $> SelectLastObservation
]
where
_casesCovered :: SelectObservation -> ()
_casesCovered =
\case
SelectFirstObservation -> ()
SelectLastObservation -> ()
-------------------------------------------------------------------------------
-- 'ToHttpApiData' instances
-------------------------------------------------------------------------------
instance ToHttpApiData DetailLevel where
toUrlPiece = toEBirdString
instance ToHttpApiData SortObservationsBy where
toUrlPiece = toEBirdString
instance ToHttpApiData SelectObservation where
toUrlPiece = toEBirdString