1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
use flate2::write::GzEncoder;
use flate2::Compression;
use hex;
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION};
use reqwest::multipart;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::io::prelude::*;
use std::str;
use uuid::Uuid;

const URL: &str = "https://www.paprikaapp.com/api/v2";

pub enum QueryType {
    GET,
    POST,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct ApiResponse {
    pub result: ApiResult,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(untagged)] // this is what lets serde guess at how to deserialize ApiResponse properly
pub enum ApiResult {
    Token(Token),
    Bool(bool),
    Recipes(Vec<RecipeEntry>),
    Categories(Vec<Category>),
    Recipe(Recipe),
}

#[derive(Debug, Serialize, Deserialize)]
pub struct Token {
    pub token: String,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct RecipeEntry {
    pub uid: String,
    pub hash: String,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct Category {
    pub uid: String,
    pub order_flag: i32,
    pub name: String,
    pub parent_uid: Option<String>,
}

#[derive(Debug, Serialize, Deserialize, Hash, Default, Clone)]
pub struct Recipe {
    pub uid: String,
    pub name: String,
    pub ingredients: String,
    pub directions: String,
    pub description: String,
    pub notes: String,
    pub nutritional_info: String,
    pub servings: String,
    pub difficulty: String,
    pub prep_time: String,
    pub cook_time: String,
    pub total_time: String,
    pub source: String,
    pub source_url: Option<String>,
    pub image_url: Option<String>,
    pub photo: Option<String>,
    pub photo_hash: Option<String>,
    pub photo_large: Option<String>,
    pub scale: Option<String>,
    pub hash: String,
    pub categories: Vec<String>,
    pub rating: i32,
    pub in_trash: bool,
    pub is_pinned: bool,
    pub on_favorites: bool,
    pub on_grocery_list: bool,
    pub created: String,
    pub photo_url: Option<String>,
}

impl Recipe {
    fn update_hash(&mut self) {
        let mut hasher = Sha256::new();

        let serialized = serde_json::to_string(&self).unwrap();
        hasher.update(serialized);

        self.hash = hex::encode(hasher.finalize());
    }

    fn generate_uuid(&mut self) {
        self.uid = Uuid::new_v4().to_string();
    }
}

fn get_headers(token: &str) -> HeaderMap {
    let mut headers = HeaderMap::new();

    headers.append(
        AUTHORIZATION,
        HeaderValue::from_str(&(String::from("Bearer ") + token)).unwrap(),
    );

    headers
}

pub async fn simple_query(
    token: &str,
    endpoint: &str,
    query_type: QueryType,
    form_args: Option<Box<[(&str, &str)]>>,
) -> Result<ApiResult, serde_json::Error> {
    let client = reqwest::Client::new();
    let mut builder: reqwest::RequestBuilder;

    match query_type {
        QueryType::GET => builder = client.get(format!("{}/{}/", URL, endpoint)),
        QueryType::POST => builder = client.post(format!("{}/{}/", URL, endpoint)),
    }

    if let Some(t) = form_args {
        builder = builder.form(&*t);
    }

    let resp_text = builder
        .headers(get_headers(token))
        .send()
        .await
        .expect("Request failed")
        .text()
        .await
        .expect("Failed to decode response as text");

    let response: Result<ApiResponse, serde_json::Error> = serde_json::from_str(&resp_text);

    match response {
        Ok(r) => Ok(r.result),
        Err(e) => Err(e),
    }
}

pub async fn login(email: &str, password: &str) -> Result<Token, Box<dyn std::error::Error>> {
    let params = [("email", email), ("password", password)];

    let token = simple_query("", "account/login", QueryType::POST, Some(Box::new(params))).await;

    match token {
        Ok(r) => match r {
            ApiResult::Token(r) => Ok(r),
            _ => Err("Invalid API response".into()),
        },
        Err(e) => Err(Box::new(e)),
    }
}

pub async fn get_recipes(token: &str) -> Result<Vec<RecipeEntry>, Box<dyn std::error::Error>> {
    let recipes = simple_query(token, "sync/recipes", QueryType::GET, None).await;

    match recipes {
        Ok(r) => match r {
            ApiResult::Recipes(r) => Ok(r),
            _ => Err("Invalid API response".into()),
        },
        Err(e) => Err(Box::new(e)),
    }
}

pub async fn get_categories(token: &str) -> Result<Vec<Category>, Box<dyn std::error::Error>> {
    let categories = simple_query(token, "sync/categories", QueryType::GET, None).await;

    match categories {
        Ok(r) => match r {
            ApiResult::Categories(r) => Ok(r),
            _ => Err("Invalid API response".into()),
        },
        Err(e) => Err(Box::new(e)),
    }
}

pub async fn get_recipe_by_id(token: &str, id: &str) -> Result<Recipe, Box<dyn std::error::Error>> {
    let endpoint = format!("{}/{}", "sync/recipe", &id);
    let recipe = simple_query(token, &&endpoint, QueryType::GET, None).await;
    
    match recipe {
        Ok(r) => match r {
            ApiResult::Recipe(r) => Ok(r),
            _ => Err("Invalid API response".into()),
        },
        Err(e) => Err(Box::new(e)),
    }
}

/// Uploads a new recipe (when uid == "") or updates an existing one (when uid exists in database already)
/// #arguments
/// * `token` a login token from `login()`
/// * `recipe` a populated Recipe, with or without a `uid` (for updating or creating a new recipe)
pub async fn upload_recipe(
    token: &str,
    recipe: &mut Recipe,
) -> Result<bool, Box<dyn std::error::Error>> {
    let client = reqwest::Client::new();

    // new recipes won't have UID
    if recipe.uid.is_empty() {
        recipe.generate_uuid();
    }

    recipe.update_hash();

    // updating/creating recipes seems to have very weird HTTP requirements
    // first, convert to JSON
    let body_json = serde_json::to_vec(&recipe).unwrap();

    // then, GZip-encode that json with no compression
    let mut encoder = GzEncoder::new(Vec::new(), Compression::none());
    encoder.write_all(body_json.as_slice()).unwrap();
    let gzip_body = encoder.finish().unwrap();

    // send that GZip-encoded data as a multi-part file field named "data"
    let part = reqwest::multipart::Part::bytes(gzip_body).file_name("data");
    let form = multipart::Form::new().part("data", part);

    let resp_text = client
        .post(format!("{}/sync/recipe/{}/", URL, &recipe.uid))
        .multipart(form)
        .header("accept", "*/*")
        .header("accept-encoding", "utf-8")
        .header("authorization", "Bearer ".to_string() + token)
        .send()
        .await
        .expect("Request failed")
        .text()
        .await
        .expect("Failed to decode response as text");

    let recipe_post_resp: Result<ApiResponse, serde_json::Error> = serde_json::from_str(&resp_text);

    match recipe_post_resp {
        Ok(r) => match r.result {
            ApiResult::Bool(b) => Ok(b),
            _ => Err("Recipe POST failed".into()),
        },
        Err(e) => {
            Err(Box::new(e))
        }
    }
}