apodclient 0.1.0

Download NASA APOD images
Documentation
// Copyright 2019 Sebastian Wiesner <sebastian@swsnr.de>

// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at

// 	http://www.apache.org/licenses/LICENSE-2.0

// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

#![deny(warnings, clippy::all)]

use chrono::NaiveDate;
use reqwest::{Client, Url};
use serde::Deserialize;
use std::error::Error;
use std::fmt::Display;
use std::io::Write;

#[derive(Deserialize, Debug, Clone)]
pub struct APODError {
    pub code: u16,
    pub msg: String,
    pub service_version: String,
}

impl Display for APODError {
    fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(fmt, "{} (code {})", self.msg, self.code)
    }
}

pub fn apod_url(date: NaiveDate) -> Url {
    Url::parse(&format!(
        "https://apod.nasa.gov/apod/ap{}.html",
        date.format("%y%m%d")
    ))
    .unwrap()
}

#[derive(Deserialize, Debug, Clone)]
pub struct APOD {
    pub date: NaiveDate,
    pub title: String,
    pub explanation: String,
    #[serde(default)]
    pub copyright: Option<String>,
    pub media_type: String,
    #[serde(default, with = "url_serde")]
    pub hdurl: Option<Url>,
    #[serde(with = "url_serde")]
    pub url: Url,
    pub service_version: String,
}

impl APOD {
    pub fn download_url(&self) -> &Url {
        self.hdurl.as_ref().unwrap_or(&self.url)
    }

    pub fn url(&self) -> Url {
        apod_url(self.date)
    }
}

#[derive(Debug)]
pub enum APODClientError {
    APOD(APODError),
    HTTP(reqwest::Error),
}

impl Display for APODClientError {
    fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
        use APODClientError::*;
        match self {
            APOD(error) => write!(fmt, "APOD error: {}", error),
            HTTP(error) => write!(fmt, "HTTP error: {}", error),
        }
    }
}

impl Error for APODClientError {}

impl From<reqwest::Error> for APODClientError {
    fn from(error: reqwest::Error) -> Self {
        APODClientError::HTTP(error)
    }
}

impl From<APODError> for APODClientError {
    fn from(error: APODError) -> Self {
        APODClientError::APOD(error)
    }
}

type Result<T> = std::result::Result<T, APODClientError>;

trait APODResponse
where
    Self: std::marker::Sized,
{
    fn apod_response(self) -> Result<Self>;
}

impl APODResponse for reqwest::Response {
    fn apod_response(mut self) -> Result<Self> {
        if self.status().is_success() {
            Ok(self)
        } else {
            Err(self.json::<APODError>()?.into())
        }
    }
}

#[derive(Debug)]
pub struct APODClient {
    api_key: String,
    client: Client,
}

impl APODClient {
    pub fn new(api_key: String) -> APODClient {
        let proxy = reqwest::Proxy::custom(|url| env_proxy::for_url(&url).to_url());
        APODClient {
            api_key,
            client: Client::builder().proxy(proxy).build().unwrap(),
        }
    }

    pub fn api_key(&self) -> &str {
        &self.api_key
    }

    pub fn get(&self, date: NaiveDate) -> Result<APOD> {
        let mut url = Url::parse("https://api.nasa.gov/planetary/apod").unwrap();
        url.query_pairs_mut()
            .append_pair("api_key", &self.api_key)
            .append_pair("date", &date.format("%Y-%m-%d").to_string());
        self.client
            .get(url)
            .send()?
            .apod_response()?
            .json()
            .map_err(From::from)
    }

    pub fn copy_to<W: Write>(&self, apod: &APOD, sink: &mut W) -> Result<u64> {
        self.client
            .get(apod.download_url().clone())
            .send()?
            .apod_response()?
            .copy_to(sink)
            .map_err(From::from)
    }
}

#[cfg(test)]
mod test {
    use super::*;
    // use super::{APODClient, APODClientError};
    use chrono::NaiveDate;
    use pretty_assertions::assert_eq;

    fn client() -> APODClient {
        APODClient::new(
            std::env::var("NASA_API_KEY").expect("NASA API KEY in $NASA_API_KEY missing"),
        )
    }

    #[test]
    fn apod_get_image() -> Result<()> {
        let date = NaiveDate::from_ymd(2019, 4, 11);
        let apod = client().get(date)?;
        assert_eq!(apod.date, date);
        assert_eq!(apod.media_type, "image");
        assert_eq!(apod.title, "First Horizon-Scale Image of a Black Hole");
        assert!(apod.copyright.is_none());
        assert!(apod.hdurl.is_some());
        Ok(())
    }

    #[test]
    fn apod_get_video() -> Result<()> {
        let date = NaiveDate::from_ymd(2019, 4, 10);
        let apod = client().get(date)?;
        assert_eq!(apod.date, date);
        assert_eq!(apod.media_type, "video");
        assert!(apod.copyright.is_none());
        assert!(apod.hdurl.is_none());
        Ok(())
    }

    #[test]
    fn apod_get_image_with_copyright() -> Result<()> {
        let date = NaiveDate::from_ymd(2019, 4, 8);
        let apod = client().get(date)?;
        assert_eq!(apod.date, date);
        assert_eq!(apod.media_type, "image");
        assert_eq!(apod.title, "AZURE Vapor Tracers over Norway");
        assert_eq!(apod.copyright, Some("Yang Sutie".to_string()));
        assert!(apod.hdurl.is_some());
        Ok(())
    }

    #[test]
    fn apod_get_image_does_not_exist() {
        // APOD didn't exist back in those days.
        let error = match client().get(NaiveDate::from_ymd(1994, 8, 23)).unwrap_err() {
            APODClientError::APOD(error) => error,
            APODClientError::HTTP(error) => panic!("Unexpected HTTP error: {}", error),
        };
        assert_eq!(error.code, 400);
        assert!(
            error.msg.contains("Date must be between Jun 16, 1995"),
            "Message: {}",
            error.msg
        );
    }

    #[test]
    fn apod_copy_to_buffer() -> Result<()> {
        let c = client();
        let apod = c.get(NaiveDate::from_ymd(2019, 4, 11)).unwrap();
        let mut buffer = Vec::new();
        let no_bytes = c.copy_to(&apod, &mut buffer)?;
        assert!(127_000 < no_bytes);
        Ok(())
    }
}