Created
April 11, 2015 10:57
-
-
Save adusak/b91fcd885342c4a53a2e to your computer and use it in GitHub Desktop.
Commandline utility to get weather from openweathermap.org written in haskell
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| {- | | |
| This is the fourth assignment for IB016, semester spring 2015. | |
| Name: Adam Melkus | |
| UID: 374010 | |
| == Obtaining weather information from http://openweathermap.org | |
| This time your task is to implement download and processing of weather data | |
| from <http://openweathermap.org>. Following it partially filled program, which | |
| already contains some data type definitions, 'main', argument parsing, and | |
| dispatch functions to run your implementation. You should not change any of | |
| already defined data types and functions, unless it is specifically allowed. | |
| OpenWeatherMap provides JSON API for weather forecast (it also provides XML, | |
| but we believe JSON is simpler to process), your task is: | |
| * Download JSON data from serwer based on value of 'Query' (which was parsed | |
| from commandline arguments). That is implement 'createUrl' and | |
| 'downloadResults'. | |
| * Process JSON data in functions 'weatherNow', 'weatherDetailed', | |
| 'weatherDaily' and 'warmestDay'. | |
| * Implement helper function 'prettyPrint'. | |
| You will have to use some library for HTTP and for JSON, we recommend following | |
| packages and modules @http@ (module @Network.HTTP@) and @hjson@ (module | |
| @Text.HJson@) which provide simple and easy to use interface. Both of these | |
| modules can be installed with @cabal@ (they are not part of standard | |
| distribution). If you use @http@ module you will also need to decode UTF-8 | |
| manually, you can use @utf-string@ (module @Codec.Binary.UTF8.String@, function | |
| 'decodeString') for that. You can also use any other module which implements | |
| given functionality, in that case you might need to replace 'Json' type in all | |
| functions using it with appropriate type (this change is allowed). Furthermore, | |
| 'Rational' data type is used to represent numeric values in weather forecast, | |
| this is to simplify parsing from Json, as @hjson@ uses it to represent numbers, | |
| if you use another library you are allowed to replace Rational with another | |
| type cabable of representing fractional values. | |
| Documentation of OpenWeatherMap's current and forecast API can be found at | |
| <http://openweathermap.org/current> and <http://openweathermap.org/forecast> | |
| respectively, JSON reply format is also linked from appropriate function | |
| documentation. Beware that JSON examples on OpenWeatherMap are not always | |
| properly indented. Furthermore, as a simplification, you can expect that | |
| weather field (which is JSON array in response) contains at least one entry and | |
| you can use the data from the first entry and ignore all other entries. | |
| You can expect that you will obtain a valid JSON from OpenWeatherMap. | |
| If you detect an invalid JSON, you can kill the program using 'exitFailure' | |
| from @System.Exit@ . | |
| However, you should expect that the obtained JSON does not contain all required | |
| information (which might happen if you query an invalid city). For this reason all | |
| JSON-parsing functions you should implement are returning type wrapped with | |
| 'WithError a' which is an alias to 'Either String String'. You should emit an | |
| appropriate error message into 'Left' if any JSON field is missing. However, if | |
| you don't feel like it, you can just omit error handling at all which will be | |
| penalised with 5 points (in this case you should just wrap the result into | |
| 'Right' to match type declaration). | |
| === Examples | |
| @ | |
| $ ./Weather now --city=Brno | |
| city: Brno (lat = 49.2, lon = 16.61) | |
| weather: few clouds | |
| temperature: 2.1 øC | |
| pressure: 993.8 hPa | |
| $ ./Weather detailed --city=Brno | |
| city: Brno (lat = 49.195, lon = 16.608) | |
| date: 05-04-2015 15:00 | |
| weather: scattered clouds | |
| temperature: 2.1 øC | |
| pressure: 992.4 hPa | |
| date: 05-04-2015 18:00 | |
| weather: few clouds | |
| temperature: -0.2 øC | |
| pressure: 993.8 hPa | |
| date: 05-04-2015 21:00 | |
| weather: light rain | |
| temperature: -2.3 øC | |
| pressure: 994.1 hPa | |
| date: 06-04-2015 00:00 | |
| weather: sky is clear | |
| temperature: -3.5 øC | |
| pressure: 993.7 hPa | |
| date: 06-04-2015 03:00 | |
| weather: sky is clear | |
| temperature: -3.9 øC | |
| pressure: 993.7 hPa | |
| # ... | |
| ./Weather daily --city=Brno --count=2 | |
| city: Brno (lat = 49.195, lon = 16.608) | |
| date: 05-04-2015 10:00 | |
| weather: scattered clouds | |
| temperature: 2.1 øC | |
| pressure: 992.4 hPa | |
| date: 06-04-2015 10:00 | |
| weather: light snow | |
| temperature: 3.2 øC | |
| pressure: 995.0 hPa | |
| $ ./Weather warmest-day --city=Brno | |
| city: Brno (lat = 49.195, lon = 16.608) | |
| date: 11-04-2015 10:00 | |
| weather: sky is clear | |
| temperature: 16.3 øC | |
| pressure: 1006.6 hPa | |
| $ ./Weather warmest-day --city=Brno --count=16 | |
| city: Brno (lat = 49.195, lon = 16.608) | |
| date: 20-04-2015 10:00 | |
| weather: light rain | |
| temperature: 19.8 øC | |
| pressure: 988.2 hPa | |
| $ ./Weather now --city="¦Ô r nad S zavou" | |
| city: ¦Ô r nad S zavou (lat = 49.56, lon = 15.94) | |
| weather: scattered clouds | |
| temperature: 0.0 øC | |
| pressure: 977.4 hPa | |
| $ ./Weather now --coord=49.56,15.94 | |
| city: Zdar nad Sazavou (lat = 49.56, lon = 15.94) | |
| weather: scattered clouds | |
| temperature: 0.0 øC | |
| pressure: 977.4 hPa | |
| @ | |
| -} | |
| module Main ( | |
| -- * Executable entry | |
| main | |
| -- * Pre-defined types and functions | |
| , URL | |
| , Query (..) | |
| , QueryType (..) | |
| , Location (..) | |
| , parseQuery | |
| , PrettyPrint (..) | |
| , disp, disp' | |
| , City (..) | |
| , Weather (..) | |
| , Date (..) | |
| , valid | |
| , usage | |
| , WithError | |
| , processData | |
| -- * Required functions and types | |
| , createUrl | |
| , downloadResults | |
| , prettyPrint | |
| , weatherNow | |
| , weatherDetailed | |
| , weatherDaily | |
| , warmestDay | |
| ) where | |
| -- for timestamp conversion | |
| import Data.Time.Clock.POSIX | |
| import Data.Time.Format | |
| import System.Locale | |
| import Control.Applicative | |
| import Control.Monad | |
| import Data.Map (Map) | |
| import Data.Maybe | |
| import Data.Monoid | |
| import qualified Data.Map as M | |
| import Numeric | |
| import System.Environment | |
| import System.Exit | |
| import System.IO | |
| import Network.HTTP | |
| import Text.HJson | |
| import Text.Read (readMaybe) | |
| import Codec.Binary.UTF8.String (decodeString) | |
| type URL = String | |
| -- | City location specification | |
| data Location = Name { locName :: String } | |
| | Coord { lat :: Double, lon :: Double } | |
| | NoLocation | |
| deriving ( Eq, Show, Read ) | |
| instance Monoid Location where | |
| mempty = NoLocation | |
| x `mappend` NoLocation = x | |
| _ `mappend` y = y | |
| -- | Type of query | |
| data QueryType = Now | Detailed | Daily | WarmestDay | NotSet | |
| deriving ( Eq, Show, Read ) | |
| instance Monoid QueryType where | |
| mempty = NotSet | |
| x `mappend` NotSet = x | |
| _ `mappend` y = y | |
| -- | Type representing commandline parameters | |
| data Query = Query { queryType :: QueryType | |
| , city :: Location | |
| , count :: Last Int | |
| } | |
| deriving ( Eq, Show, Read ) | |
| instance Monoid Query where | |
| mempty = Query mempty mempty mempty | |
| x `mappend` y = Query { queryType = queryType x `mappend` queryType y | |
| , city = city x `mappend` city y | |
| , count = count x `mappend` count y | |
| } | |
| -- | Parses commandline arguments into 'Query' type | |
| parseQuery :: [String] -> Query | |
| parseQuery [] = mempty | |
| parseQuery (qt:args) = mempty { queryType = qType } `mappend` mconcat (map fromArg args) | |
| where | |
| qType = case qt of | |
| "now" -> Now | |
| "daily" -> Daily | |
| "detailed" -> Detailed | |
| "warmest-day" -> WarmestDay | |
| _ -> NotSet | |
| fromArg :: String -> Query | |
| fromArg arg = fromMaybe mempty $ do | |
| (k, '=':v) <- Just $ span (/= '=') arg -- if pattern fail we get Nothing from this do block | |
| case k of | |
| "--city" -> Just $ mempty { city = Name v } | |
| "--coord" -> do | |
| (slat, ',':slon) <- Just $ span (/= ',') v | |
| lat <- readMaybe slat | |
| lon <- readMaybe slon | |
| Just $ mempty { city = Coord { lat = lat, lon = lon } } | |
| "--count" -> do | |
| cnt <- readMaybe v | |
| return $ mempty { count = Last (Just cnt) } | |
| _ -> Nothing | |
| -- | A type class to facilitate pretty printing of tablular information. | |
| class PrettyPrint a where | |
| -- | Format object into list of key-value pairs of string representation. | |
| -- This is later used by 'prettyPrint' to format data for output. | |
| ppKeyVal :: a -> [(String, String)] | |
| instance (PrettyPrint a, PrettyPrint b) => PrettyPrint (a, b) where | |
| ppKeyVal (x, y) = ppKeyVal x ++ ppKeyVal y | |
| instance (PrettyPrint a, PrettyPrint b, PrettyPrint c) => PrettyPrint (a, b, c) where | |
| ppKeyVal (x, y, z) = ppKeyVal x ++ ppKeyVal y ++ ppKeyVal z | |
| instance PrettyPrint a => PrettyPrint [a] where | |
| ppKeyVal = concatMap ppKeyVal | |
| -- | Should format data which can have key-value representation into well | |
| -- readable tabular form. That is all the form key: value, with value indented | |
| -- such that all values start at same offset. Key-value pairs should be obtained by | |
| -- 'ppKeyVal' function from 'PrettyPrint' class. | |
| -- | |
| -- >>> putStrLn $ prettyPrint (Date 0) | |
| -- date: 01-01-1970 00:00 | |
| -- | |
| -- >>> putStrLn $ prettyPrint (Date 0, Weather 0 0 "test") | |
| -- date: 01-01-1970 00:00 | |
| -- weather: test | |
| -- temperature: -273.2 øC | |
| -- pressure: 0.0 hPa | |
| -- | |
| prettyPrint :: PrettyPrint a => a -> String | |
| prettyPrint v = process (ppKeyVal v) | |
| where | |
| tabs x | length x <= 4 = ":\t\t" | |
| | otherwise = ":\t" | |
| process [] = "" | |
| process (x:xs) = fst x ++ tabs (fst x) ++ snd x ++ "\n" ++ process xs | |
| -- | Show rational number rounded with given precision | |
| -- | |
| -- >>> disp 1 1.007 | |
| -- "1.0" | |
| -- | |
| -- >>> disp 2 1.007 | |
| -- "1.01" | |
| -- | |
| -- >>> disp 3 2.2 | |
| -- "2.2" | |
| disp :: Int -> Rational -> String | |
| disp n = show . (/ 10^n) . fromIntegral . round . (* 10^n) | |
| -- | Shortcut for @'disp' 1@. | |
| disp' :: Rational -> String | |
| disp' = disp 1 | |
| -- | Information about city and its location. | |
| data City = City { cityName :: String | |
| , cityLat :: Rational | |
| , cityLon :: Rational | |
| } deriving ( Eq, Show, Read ) | |
| instance PrettyPrint City where | |
| ppKeyVal c = [ ("city", cityName c ++ " (lat = " ++ | |
| disp 3 (cityLat c) ++ ", lon = " ++ | |
| disp 3 (cityLon c) ++ ")") ] | |
| -- | Information about weather. | |
| data Weather = Weather { temperature :: Rational | |
| , pressure :: Rational | |
| , description :: String | |
| } deriving ( Eq, Show, Read ) | |
| instance PrettyPrint Weather where | |
| ppKeyVal w = [ ("weather", description w) | |
| , ("temperature", disp' (temperature w - 273.15) ++ " °C") | |
| , ("pressure", disp' (pressure w) ++ " hPa") | |
| ] | |
| -- | Unix time wrapped so that it can be made instance of 'PrettyPrint'. | |
| newtype Date = Date { timestamp :: Rational } | |
| deriving ( Eq, Show, Read ) | |
| instance PrettyPrint Date where | |
| ppKeyVal (Date d) = [ ("date", formatTime defaultTimeLocale "%d-%m-%Y %R" unixTime) ] | |
| where | |
| unixTime = posixSecondsToUTCTime (realToFrac d) | |
| -- | Create URL from given query, that is add in all parameters necessary to | |
| -- obtain weather data. | |
| -- | |
| -- It must properly encode all parameters for example using functions from | |
| -- @Network.HTTP.Base@. | |
| -- | |
| -- >>> createUrl $ mempty {queryType = Now, city = Name "Brno" } | |
| -- "http://api.openweathermap.org/data/2.5/weather?q=Brno" | |
| -- | |
| -- >>> createUrl $ mempty {queryType = Detailed, city = Name "Brno" } | |
| -- "http://api.openweathermap.org/data/2.5/forecast?q=Brno" | |
| -- | |
| -- >>> createUrl $ mempty {queryType = Daily, city = Name "Brno" } | |
| -- "http://api.openweathermap.org/data/2.5/forecast/daily?q=Brno" | |
| -- | |
| -- >>> createUrl $ mempty {queryType = WarmestDay, city = Name "Brno", count = Last (Just 5) } | |
| -- "http://api.openweathermap.org/data/2.5/forecast/daily?q=Brno&cnt=5" | |
| -- | |
| -- >>> createUrl $ mempty {queryType = Now, city = Name "¦Ô r nad S zavou" } | |
| -- "http://api.openweathermap.org/data/2.5/weather?q=%C5%BD%C4%8F%C3%A1r%20nad%20S%C3%A1zavou" | |
| createUrl :: Query -> URL | |
| createUrl query = baseUrl ++ typeP ++ dailyP ++ urlEncode cityP ++ countP | |
| where | |
| baseUrl = "http://api.openweathermap.org/data/2.5/" | |
| typeP = if queryType query == Now then "weather" else "forecast" | |
| cityP = case city query of Name a -> "?q=" ++ a | |
| Coord lat lon -> "?lat=" ++ show lat ++ "&lon=" ++ show lon | |
| dailyP = if queryType query == Daily || queryType query == WarmestDay then "/daily" else "" | |
| countP = case (getLast . count) query of Just v -> "&cnt=" ++ show v | |
| Nothing -> "" | |
| -- | Download requested URL and parse JSON out of it. | |
| -- | |
| -- It is recommended to use functionality of @Network.HTTP@ for download | |
| -- and @Text.HJson@ for JSON representation and parsing. If you use HTTP | |
| -- library which does not handle unicode (such as @Network.HTTP@), you should | |
| -- decode response manualy using 'decodeString' from @Codec.Binary.UTF8.String@. | |
| downloadResults :: URL -> IO Json | |
| downloadResults url = do | |
| response <- (simpleHTTP . getRequest) url >>= getResponseBody | |
| let decodeResult = (fromString . decodeString) response | |
| case decodeResult of Left s -> (error .show) s | |
| Right jsonr -> return jsonr | |
| -- | Check validity of 'Query'. | |
| valid :: Query -> Bool | |
| valid q = queryType q /= NotSet && city q /= NoLocation | |
| -- | Program usage. | |
| usage :: String | |
| usage = unlines [ | |
| "Usage: Weather {now|detailed|daily|warmest-day}", | |
| " {--city=CITY | --coord=LATITUDE,LONGITUDE} [--count=CNT]", | |
| "", | |
| " --count applies only to daily and warmest-day and specifies number of days" | |
| ] | |
| -- | Alias to 'Either' to simplify types. | |
| type WithError a = Either String a | |
| -- | Parse current weather from JSON, | |
| -- see <http://openweathermap.org/weather-data#current> for format description. | |
| weatherNow :: Json -> WithError (City, Weather) | |
| weatherNow json = handleError json jsonIncomplete (cityRes, weatherRes) | |
| where | |
| jsonIncomplete = not . jsonContains ["name", "weather description", "main pressure", "main temp", "coord lat", "coord lon"] | |
| cityRes = extractCity json | |
| weatherRes = extractWeather json | |
| -- | Parse detailed (5-day/3 hour) forecast from JSON, | |
| -- see <http://openweathermap.org/weather-data#5days> for format specification. | |
| weatherDetailed :: Json -> WithError (City, [(Date, Weather)]) | |
| weatherDetailed json = weatherCommon json ["city coord lat", "city coord lon", "city name", "list dt", "list weather description", "list main temp", "list main pressure"] | |
| -- | Parse daily (16-day) forecast from JSON, | |
| -- see <http://openweathermap.org/weather-data#16days> for format specification. | |
| -- Note: you should use temperature from @day@ temperature entry. | |
| weatherDaily :: Json -> WithError (City, [(Date, Weather)]) | |
| weatherDaily json = weatherCommon json ["city coord lat", "city coord lon", "city name", "list dt", "list weather description", "list temp day", "list pressure"] | |
| weatherCommon :: Json -> [String] -> WithError (City, [(Date, Weather)]) | |
| weatherCommon json jsonFields = handleError json jsonIncomplete (cityRes, dwList) | |
| where | |
| jsonIncomplete = not . jsonContains jsonFields | |
| cityRes = extractCity $ jsonGetElem "city" json | |
| generateDateWeather = map extractDateWeather | |
| dwList = case jsonGetElem "list" json of (JArray a) -> generateDateWeather a | |
| -- | Parse daily forecast and get warmest day from it. | |
| warmestDay :: Json -> WithError (City, Date, Weather) | |
| warmestDay json = handleError json jsonIncomplete (cityRes, fst dwList, snd dwList) | |
| where | |
| jsonIncomplete = not . jsonContains ["city coord lat", "city coord lon", "city name", "list dt", "list weather description", "list temp day", "list pressure"] | |
| cityRes = extractCity $ jsonGetElem "city" json | |
| max' (d1, w1) (d2, w2) = if temperature w1 > temperature w2 then (d1, w1) else (d2, w2) | |
| generateDateWeather [x] = extractDateWeather x | |
| generateDateWeather (w:ws) = max' (extractDateWeather w) (generateDateWeather ws) | |
| dwList = case jsonGetElem "list" json of (JArray a) -> generateDateWeather a | |
| -- | Composes tuple of date and weather information from Json | |
| -- Doesn't do any kind of error checking, valid Json on input is assumed | |
| extractDateWeather :: Json -> (Date, Weather) | |
| extractDateWeather j = (extractDate, extractWeather j) | |
| where extractDate = Date {timestamp = toNumber $ jsonGetElem "dt" j} | |
| -- | Extracts city information from given Json | |
| -- Doesn't do any kind of error checking, valid Json on input is assumed | |
| extractCity :: Json -> City | |
| extractCity json = City {cityName = cName, cityLat = cLat, cityLon = cLon} | |
| where | |
| cName = toString' $ jsonGetElem "name" json | |
| cLat = toNumber $ jsonGetElem "coord lat" json | |
| cLon = toNumber $ jsonGetElem "coord lon" json | |
| -- | Extracts wather information from given Json | |
| -- Doesn't do any kind of error checking, valid Json on input is assumed. | |
| -- It can handle 2 different configurations of weather information provided by forecast and daily forecast | |
| extractWeather :: Json -> Weather | |
| extractWeather json = Weather {temperature=wTemp, pressure=wPressure, description=wDescription} | |
| where | |
| wDescription = case jsonGetElem "weather" json of (JArray a) -> toString' $ jsonGetElem "description" (head a) | |
| _ -> "No description" | |
| cont = if jsonContains ["main"] json then "main " else "" | |
| temp = if jsonContains ["temp day"] json then "temp day" else "temp" | |
| wPressure = toNumber $ jsonGetElem (cont++"pressure") json | |
| wTemp = toNumber $ jsonGetElem (cont++temp) json | |
| -- | Extracts value from Json on given path | |
| -- Doesn't do any kind of error checking, valid Json on input is assumed. | |
| -- It can extract values only from map. Can't do extraction from array | |
| -- Path is given in format "root_element nested_element final_elemet" this would extract final_element from the JSON | |
| jsonGetElem :: String -> Json -> Json | |
| jsonGetElem path = extractElem (words path) | |
| where | |
| extractElem [x] json = case json of (JObject map) -> map M.! x | |
| a -> a | |
| extractElem (x:xs) json = case json of (JObject map) -> extractElem xs (map M.! x) | |
| a -> a | |
| -- | Checks if specified elements are in the JSON | |
| -- Path is given in format "root_element nested_element final_elemet" this would check if final_element is in the json JSON | |
| -- Checks even inside of arrays | |
| jsonContains :: [String] -> Json -> Bool | |
| jsonContains [] _ = True | |
| jsonContains (x:xs) json = checkPath (words x) json && jsonContains xs json | |
| where | |
| checkPath (y:ys) json = case json of (JObject map) -> M.member y map && (null ys || checkPath ys (map M.! y)) | |
| (JArray (j:js)) -> checkPath (y:ys) j || (not (null js) && checkPath (y:ys) (JArray js)) | |
| _ -> False | |
| -- | Converts some JSON values to number | |
| toNumber :: Json -> Rational | |
| toNumber (JNumber num) = num | |
| toNumber (JString num) = fst . head $ readSigned readFloat num | |
| -- | Converts some JSON values to string | |
| toString' :: Json -> String | |
| toString' (JString str) = str | |
| toString' (JNumber num) = show num | |
| -- | Checks for valid JSON and error and outputs either error message or given result | |
| handleError :: Json -> (Json -> Bool) -> a -> Either String a | |
| handleError json f result = case handleInvalidResponse json of | |
| Just a -> Left a | |
| Nothing -> if f json then Left "Error parsing JSON, some element was missing" | |
| else Right result | |
| -- | Checks if the response code from web service returns error message from server if code wasn't OK | |
| handleInvalidResponse :: Json -> Maybe String | |
| handleInvalidResponse json = if resultCode /= 200 then Just errorMessage else Nothing | |
| where | |
| resultCode = toNumber $ jsonGetElem "cod" json | |
| errorMessage = toString' $ jsonGetElem "message" json | |
| -- | Dispatch parsing functionas based on 'QueryType' and handle errors. | |
| processData :: QueryType -> Json -> String | |
| processData qtype json = either handler id $ case qtype of | |
| Now -> prettyPrint <$> weatherNow json | |
| Detailed -> prettyPrint <$> weatherDetailed json | |
| Daily -> prettyPrint <$> weatherDaily json | |
| WarmestDay -> prettyPrint <$> warmestDay json | |
| _ -> Left "invalid query" | |
| where | |
| handler msg = unlines [ "Error processing data, sorry", msg] | |
| main :: IO () | |
| main = do | |
| query <- parseQuery <$> getArgs | |
| unless (valid query) $ do | |
| hPutStrLn stderr "Invalid options" | |
| hPutStrLn stderr usage | |
| exitFailure | |
| --(error . createUrl) query | |
| weather <- downloadResults (createUrl query) | |
| putStrLn $ processData (queryType query) weather |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment