use std::{ffi::CStr, ffi::CString, os::raw::c_char, slice}; use anyhow::Result; use reqwest::Url; use serde::{Deserialize, Serialize}; #[cfg(not(test))] use log::{error, info}; #[cfg(test)] use std::{println as info, println as error}; use crate::{ cache::file_cache_dir, cache::from_file_cache, cache::update_file_cache, log_server_error, result::FFIResult, }; #[derive(Serialize, Deserialize, Debug)] pub struct MerchandiseList { pub id: Option, pub shop_id: i32, pub form_list: Vec, } #[derive(Serialize, Deserialize, Debug)] pub struct Merchandise { pub mod_name: String, pub local_form_id: u32, pub name: String, pub quantity: u32, pub form_type: u32, pub is_food: bool, pub price: u32, } impl MerchandiseList { pub fn from_game(shop_id: i32, merch_records: &[RawMerchandise]) -> Self { Self { id: None, shop_id, form_list: merch_records .iter() .map(|rec| Merchandise { mod_name: unsafe { CStr::from_ptr(rec.mod_name) } .to_string_lossy() .to_string(), local_form_id: rec.local_form_id, name: unsafe { CStr::from_ptr(rec.name) } .to_string_lossy() .to_string(), quantity: rec.quantity, form_type: rec.form_type, is_food: rec.is_food == 1, price: rec.price, }) .collect(), } } } #[derive(Debug)] #[repr(C)] pub struct RawMerchandise { pub mod_name: *const c_char, pub local_form_id: u32, pub name: *const c_char, pub quantity: u32, pub form_type: u32, pub is_food: u8, pub price: u32, } #[derive(Debug)] #[repr(C)] pub struct RawMerchandiseVec { pub ptr: *mut RawMerchandise, pub len: usize, pub cap: usize, } #[no_mangle] pub extern "C" fn create_merchandise_list( api_url: *const c_char, api_key: *const c_char, shop_id: i32, raw_merchandise_ptr: *const RawMerchandise, raw_merchandise_len: usize, ) -> FFIResult { let api_url = unsafe { CStr::from_ptr(api_url) }.to_string_lossy(); let api_key = unsafe { CStr::from_ptr(api_key) }.to_string_lossy(); info!("create_merchandise_list api_url: {:?}, api_key: {:?}, shop_id: {:?}, raw_merchandise_len: {:?}", api_url, api_key, shop_id, raw_merchandise_len); let raw_merchandise_slice = unsafe { assert!(!raw_merchandise_ptr.is_null()); slice::from_raw_parts(raw_merchandise_ptr, raw_merchandise_len) }; fn inner( api_url: &str, api_key: &str, shop_id: i32, raw_merchandise_slice: &[RawMerchandise], ) -> Result { #[cfg(not(test))] let url = Url::parse(api_url)?.join("v1/merchandise_lists")?; #[cfg(test)] let url = Url::parse(&mockito::server_url())?.join("v1/merchandise_lists")?; let merchandise_list = MerchandiseList::from_game(shop_id, raw_merchandise_slice); info!( "created merchandise_list from game: shop_id: {}", &merchandise_list.shop_id ); let client = reqwest::blocking::Client::new(); let resp = client .post(url) .header("Api-Key", api_key) .json(&merchandise_list) .send()?; info!("create merchandise_list response from api: {:?}", &resp); let bytes = resp.bytes()?; let json: MerchandiseList = serde_json::from_slice(&bytes)?; if let Some(id) = json.id { update_file_cache( &file_cache_dir(api_url)?.join(format!("merchandise_list_{}.json", id)), &bytes, )?; } Ok(json) } match inner(&api_url, &api_key, shop_id, raw_merchandise_slice) { Ok(merchandise_list) => { if let Some(id) = merchandise_list.id { FFIResult::Ok(id) } else { error!( "create_merchandise failed. API did not return an interior ref list with an ID" ); let err_string = CString::new("API did not return an interior ref list with an ID".to_string()) .expect("could not create CString") .into_raw(); // TODO: also need to drop this CString once C++ is done reading it FFIResult::Err(err_string) } } Err(err) => { error!("create_merchandise_list failed. {}", err); // TODO: also need to drop this CString once C++ is done reading it let err_string = CString::new(err.to_string()) .expect("could not create CString") .into_raw(); FFIResult::Err(err_string) } } } #[no_mangle] pub extern "C" fn update_merchandise_list( api_url: *const c_char, api_key: *const c_char, shop_id: i32, raw_merchandise_ptr: *const RawMerchandise, raw_merchandise_len: usize, ) -> FFIResult { let api_url = unsafe { CStr::from_ptr(api_url) }.to_string_lossy(); let api_key = unsafe { CStr::from_ptr(api_key) }.to_string_lossy(); info!("create_merchandise_list api_url: {:?}, api_key: {:?}, shop_id: {:?}, raw_merchandise_len: {:?}", api_url, api_key, shop_id, raw_merchandise_len); let raw_merchandise_slice = unsafe { assert!(!raw_merchandise_ptr.is_null()); slice::from_raw_parts(raw_merchandise_ptr, raw_merchandise_len) }; fn inner( api_url: &str, api_key: &str, shop_id: i32, raw_merchandise_slice: &[RawMerchandise], ) -> Result { #[cfg(not(test))] let url = Url::parse(api_url)?.join(&format!("v1/shops/{}/merchandise_list", shop_id))?; #[cfg(test)] let url = Url::parse(&mockito::server_url())? .join(&format!("v1/shops/{}/merchandise_list", shop_id))?; let merchandise_list = MerchandiseList::from_game(shop_id, raw_merchandise_slice); info!( "created merchandise_list from game: shop_id: {}", &merchandise_list.shop_id ); let client = reqwest::blocking::Client::new(); let resp = client .patch(url) .header("Api-Key", api_key) .json(&merchandise_list) .send()?; info!("update merchandise_list response from api: {:?}", &resp); let bytes = resp.bytes()?; let json: MerchandiseList = serde_json::from_slice(&bytes)?; if let Some(id) = json.id { update_file_cache( &file_cache_dir(api_url)?.join(format!("shops_{}_merchandise_list.json", id)), &bytes, )?; } Ok(json) } match inner(&api_url, &api_key, shop_id, raw_merchandise_slice) { Ok(merchandise_list) => { if let Some(id) = merchandise_list.id { FFIResult::Ok(id) } else { error!( "update_merchandise failed. API did not return an interior ref list with an ID" ); let err_string = CString::new("API did not return an interior ref list with an ID".to_string()) .expect("could not create CString") .into_raw(); // TODO: also need to drop this CString once C++ is done reading it FFIResult::Err(err_string) } } Err(err) => { error!("update_merchandise_list failed. {}", err); // TODO: also need to drop this CString once C++ is done reading it let err_string = CString::new(err.to_string()) .expect("could not create CString") .into_raw(); FFIResult::Err(err_string) } } } #[no_mangle] pub extern "C" fn get_merchandise_list( api_url: *const c_char, api_key: *const c_char, merchandise_list_id: i32, ) -> FFIResult { info!("get_merchandise_list begin"); let api_url = unsafe { CStr::from_ptr(api_url) }.to_string_lossy(); let api_key = unsafe { CStr::from_ptr(api_key) }.to_string_lossy(); info!( "get_merchandise_list api_url: {:?}, api_key: {:?}, merchandise_list_id: {:?}", api_url, api_key, merchandise_list_id ); fn inner(api_url: &str, api_key: &str, merchandise_list_id: i32) -> Result { #[cfg(not(test))] let url = Url::parse(api_url)?.join(&format!("v1/merchandise_lists/{}", merchandise_list_id))?; #[cfg(test)] let url = Url::parse(&mockito::server_url())? .join(&format!("v1/merchandise_lists/{}", merchandise_list_id))?; info!("api_url: {:?}", url); let client = reqwest::blocking::Client::new(); let cache_path = file_cache_dir(api_url)?.join(format!("merchandise_list_{}.json", merchandise_list_id)); match client.get(url).header("Api-Key", api_key).send() { Ok(resp) => { info!("get_merchandise_list response from api: {:?}", &resp); if resp.status().is_success() { let bytes = resp.bytes()?; update_file_cache(&cache_path, &bytes)?; let json = serde_json::from_slice(&bytes)?; Ok(json) } else { log_server_error(resp); from_file_cache(&cache_path) } } Err(err) => { error!("get_merchandise_list api request error: {}", err); from_file_cache(&cache_path) } } } match inner(&api_url, &api_key, merchandise_list_id) { Ok(merchandise_list) => { let (ptr, len, cap) = merchandise_list .form_list .into_iter() .map(|merchandise| RawMerchandise { mod_name: CString::new(merchandise.mod_name) .unwrap_or_default() .into_raw(), local_form_id: merchandise.local_form_id, name: CString::new(merchandise.name) .unwrap_or_default() .into_raw(), quantity: merchandise.quantity, form_type: merchandise.form_type, is_food: merchandise.is_food as u8, price: merchandise.price, }) .collect::>() .into_raw_parts(); // TODO: need to pass this back into Rust once C++ is done with it so it can be manually dropped and the CStrings dropped from raw pointers. FFIResult::Ok(RawMerchandiseVec { ptr, len, cap }) } Err(err) => { error!("merchandise_list failed. {}", err); // TODO: how to do error handling? let err_string = CString::new(err.to_string()) .expect("could not create CString") .into_raw(); // TODO: also need to drop this CString once C++ is done reading it FFIResult::Err(err_string) } } } #[no_mangle] pub extern "C" fn get_merchandise_list_by_shop_id( api_url: *const c_char, api_key: *const c_char, shop_id: i32, ) -> FFIResult { let api_url = unsafe { CStr::from_ptr(api_url) }.to_string_lossy(); let api_key = unsafe { CStr::from_ptr(api_key) }.to_string_lossy(); info!( "get_merchandise_list_by_shop_id api_url: {:?}, api_key: {:?}, shop_id: {:?}", api_url, api_key, shop_id ); fn inner(api_url: &str, api_key: &str, shop_id: i32) -> Result { #[cfg(not(test))] let url = Url::parse(api_url)?.join(&format!("v1/shops/{}/merchandise_list", shop_id))?; #[cfg(test)] let url = Url::parse(&mockito::server_url())? .join(&format!("v1/shops/{}/merchandise_list", shop_id))?; info!("api_url: {:?}", url); let client = reqwest::blocking::Client::new(); let cache_path = file_cache_dir(api_url)?.join(format!("shops_{}_merchandise_list.json", shop_id)); match client.get(url).header("Api-Key", api_key).send() { Ok(resp) => { info!( "get_merchandise_list_by_shop_id response from api: {:?}", &resp ); if resp.status().is_success() { let bytes = resp.bytes()?; update_file_cache(&cache_path, &bytes)?; let json = serde_json::from_slice(&bytes)?; Ok(json) } else { log_server_error(resp); from_file_cache(&cache_path) } } Err(err) => { error!("get_merchandise_list_by_shop_id api request error: {}", err); from_file_cache(&cache_path) } } } match inner(&api_url, &api_key, shop_id) { Ok(merchandise_list) => { let (ptr, len, cap) = merchandise_list .form_list .into_iter() .map(|merchandise| RawMerchandise { mod_name: CString::new(merchandise.mod_name) .unwrap_or_default() .into_raw(), local_form_id: merchandise.local_form_id, name: CString::new(merchandise.name) .unwrap_or_default() .into_raw(), quantity: merchandise.quantity, form_type: merchandise.form_type, is_food: merchandise.is_food as u8, price: merchandise.price, }) .collect::>() .into_raw_parts(); // TODO: need to pass this back into Rust once C++ is done with it so it can be manually dropped and the CStrings dropped from raw pointers. FFIResult::Ok(RawMerchandiseVec { ptr, len, cap }) } Err(err) => { error!("get_merchandise_list_by_shop_id failed. {}", err); // TODO: how to do error handling? let err_string = CString::new(err.to_string()) .expect("could not create CString") .into_raw(); // TODO: also need to drop this CString once C++ is done reading it FFIResult::Err(err_string) } } } #[cfg(test)] mod tests { use std::ffi::CString; use super::*; use mockito::mock; #[test] fn test_create_merchandise_list() { let mock = mock("POST", "/v1/merchandise_lists") .with_status(201) .with_header("content-type", "application/json") .with_body(r#"{ "created_at": "2020-08-18T00:00:00.000", "id": 1, "shop_id": 1, "form_list": [], "updated_at": "2020-08-18T00:00:00.000" }"#) .create(); let api_url = CString::new("url").unwrap().into_raw(); let api_key = CString::new("api-key").unwrap().into_raw(); let (ptr, len, _cap) = vec![RawMerchandise { mod_name: CString::new("Skyrim.esm").unwrap().into_raw(), local_form_id: 1, name: CString::new("Iron Sword").unwrap().into_raw(), quantity: 1, form_type: 1, is_food: 0, price: 100, }] .into_raw_parts(); let result = create_merchandise_list(api_url, api_key, 1, ptr, len); mock.assert(); match result { FFIResult::Ok(merchandise_list_id) => { assert_eq!(merchandise_list_id, 1); } FFIResult::Err(error) => { panic!("create_merchandise_list returned error: {:?}", unsafe { CStr::from_ptr(error).to_string_lossy() }) } } } #[test] fn test_create_interior_ref_list_server_error() { let mock = mock("POST", "/v1/merchandise_lists") .with_status(500) .with_body("Internal Server Error") .create(); let api_url = CString::new("url").unwrap().into_raw(); let api_key = CString::new("api-key").unwrap().into_raw(); let (ptr, len, _cap) = vec![RawMerchandise { mod_name: CString::new("Skyrim.esm").unwrap().into_raw(), local_form_id: 1, name: CString::new("Iron Sword").unwrap().into_raw(), quantity: 1, form_type: 1, is_food: 0, price: 100, }] .into_raw_parts(); let result = create_merchandise_list(api_url, api_key, 1, ptr, len); mock.assert(); match result { FFIResult::Ok(merchandise_list_id) => panic!( "create_merchandise_list returned Ok result: {:?}", merchandise_list_id ), FFIResult::Err(error) => { assert_eq!( unsafe { CStr::from_ptr(error).to_string_lossy() }, "expected value at line 1 column 1" ); } } } #[test] fn test_update_merchandise_list() { let mock = mock("PATCH", "/v1/shops/1/merchandise_list") .with_status(201) .with_header("content-type", "application/json") .with_body(r#"{ "created_at": "2020-08-18T00:00:00.000", "id": 1, "shop_id": 1, "form_list": [], "updated_at": "2020-08-18T00:00:00.000" }"#) .create(); let api_url = CString::new("url").unwrap().into_raw(); let api_key = CString::new("api-key").unwrap().into_raw(); let (ptr, len, _cap) = vec![RawMerchandise { mod_name: CString::new("Skyrim.esm").unwrap().into_raw(), local_form_id: 1, name: CString::new("Iron Sword").unwrap().into_raw(), quantity: 1, form_type: 1, is_food: 0, price: 100, }] .into_raw_parts(); let result = update_merchandise_list(api_url, api_key, 1, ptr, len); mock.assert(); match result { FFIResult::Ok(merchandise_list_id) => { assert_eq!(merchandise_list_id, 1); } FFIResult::Err(error) => { panic!("update_merchandise_list returned error: {:?}", unsafe { CStr::from_ptr(error).to_string_lossy() }) } } } #[test] fn test_update_interior_ref_list_server_error() { let mock = mock("PATCH", "/v1/shops/1/merchandise_list") .with_status(500) .with_body("Internal Server Error") .create(); let api_url = CString::new("url").unwrap().into_raw(); let api_key = CString::new("api-key").unwrap().into_raw(); let (ptr, len, _cap) = vec![RawMerchandise { mod_name: CString::new("Skyrim.esm").unwrap().into_raw(), local_form_id: 1, name: CString::new("Iron Sword").unwrap().into_raw(), quantity: 1, form_type: 1, is_food: 0, price: 100, }] .into_raw_parts(); let result = update_merchandise_list(api_url, api_key, 1, ptr, len); mock.assert(); match result { FFIResult::Ok(merchandise_list_id) => panic!( "update_merchandise_list returned Ok result: {:?}", merchandise_list_id ), FFIResult::Err(error) => { assert_eq!( unsafe { CStr::from_ptr(error).to_string_lossy() }, "expected value at line 1 column 1" ); } } } #[test] fn test_get_merchandise_list() { let mock = mock("GET", "/v1/merchandise_lists/1") .with_status(201) .with_header("content-type", "application/json") .with_body( r#"{ "created_at": "2020-08-18T00:00:00.000", "id": 1, "shop_id": 1, "form_list": [ { "mod_name": "Skyrim.esm", "local_form_id": 1, "name": "Iron Sword", "quantity": 1, "form_type": 1, "is_food": false, "price": 100 } ], "updated_at": "2020-08-18T00:00:00.000" }"#, ) .create(); let api_url = CString::new("url").unwrap().into_raw(); let api_key = CString::new("api-key").unwrap().into_raw(); let result = get_merchandise_list(api_url, api_key, 1); mock.assert(); match result { FFIResult::Ok(raw_merchandise_vec) => { assert_eq!(raw_merchandise_vec.len, 1); let raw_merchandise_slice = unsafe { assert!(!raw_merchandise_vec.ptr.is_null()); slice::from_raw_parts(raw_merchandise_vec.ptr, raw_merchandise_vec.len) }; let raw_merchandise = &raw_merchandise_slice[0]; assert_eq!( unsafe { CStr::from_ptr(raw_merchandise.mod_name) } .to_string_lossy() .to_string(), "Skyrim.esm".to_string(), ); assert_eq!(raw_merchandise.local_form_id, 1); assert_eq!( unsafe { CStr::from_ptr(raw_merchandise.name) } .to_string_lossy() .to_string(), "Iron Sword".to_string(), ); assert_eq!(raw_merchandise.quantity, 1); assert_eq!(raw_merchandise.form_type, 1); assert_eq!(raw_merchandise.is_food, 0); assert_eq!(raw_merchandise.price, 100); } FFIResult::Err(error) => panic!("get_merchandise_list returned error: {:?}", unsafe { CStr::from_ptr(error).to_string_lossy() }), } } #[test] fn test_get_merchandise_list_server_error() { let mock = mock("GET", "/v1/merchandise_lists/1") .with_status(500) .with_body("Internal Server Error") .create(); let api_url = CString::new("url").unwrap().into_raw(); let api_key = CString::new("api-key").unwrap().into_raw(); let result = get_merchandise_list(api_url, api_key, 1); mock.assert(); match result { FFIResult::Ok(raw_merchandise_vec) => panic!( "get_merchandise_list returned Ok result: {:#x?}", raw_merchandise_vec ), FFIResult::Err(error) => { assert_eq!( unsafe { CStr::from_ptr(error).to_string_lossy() }, "EOF while parsing a value at line 1 column 0" // empty tempfile ); } } } #[test] fn test_get_merchandise_list_by_shop_id() { let mock = mock("GET", "/v1/shops/1/merchandise_list") .with_status(201) .with_header("content-type", "application/json") .with_body( r#"{ "created_at": "2020-08-18T00:00:00.000", "id": 1, "shop_id": 1, "form_list": [ { "mod_name": "Skyrim.esm", "local_form_id": 1, "name": "Iron Sword", "quantity": 1, "form_type": 1, "is_food": false, "price": 100 } ], "updated_at": "2020-08-18T00:00:00.000" }"#, ) .create(); let api_url = CString::new("url").unwrap().into_raw(); let api_key = CString::new("api-key").unwrap().into_raw(); let result = get_merchandise_list_by_shop_id(api_url, api_key, 1); mock.assert(); match result { FFIResult::Ok(raw_merchandise_vec) => { assert_eq!(raw_merchandise_vec.len, 1); let raw_merchandise_slice = unsafe { assert!(!raw_merchandise_vec.ptr.is_null()); slice::from_raw_parts(raw_merchandise_vec.ptr, raw_merchandise_vec.len) }; let raw_merchandise = &raw_merchandise_slice[0]; assert_eq!( unsafe { CStr::from_ptr(raw_merchandise.mod_name) } .to_string_lossy() .to_string(), "Skyrim.esm".to_string(), ); assert_eq!(raw_merchandise.local_form_id, 1); assert_eq!( unsafe { CStr::from_ptr(raw_merchandise.name) } .to_string_lossy() .to_string(), "Iron Sword".to_string(), ); assert_eq!(raw_merchandise.quantity, 1); assert_eq!(raw_merchandise.form_type, 1); assert_eq!(raw_merchandise.is_food, 0); assert_eq!(raw_merchandise.price, 100); } FFIResult::Err(error) => panic!( "get_merchandise_list_by_shop_id returned error: {:?}", unsafe { CStr::from_ptr(error).to_string_lossy() } ), } } #[test] fn test_get_merchandise_list_server_error_by_shop_id() { let mock = mock("GET", "/v1/shops/1/merchandise_list") .with_status(500) .with_body("Internal Server Error") .create(); let api_url = CString::new("url").unwrap().into_raw(); let api_key = CString::new("api-key").unwrap().into_raw(); let result = get_merchandise_list_by_shop_id(api_url, api_key, 1); mock.assert(); match result { FFIResult::Ok(raw_merchandise_vec) => panic!( "get_merchandise_list_by_shop_id returned Ok result: {:#x?}", raw_merchandise_vec ), FFIResult::Err(error) => { assert_eq!( unsafe { CStr::from_ptr(error).to_string_lossy() }, "EOF while parsing a value at line 1 column 0" // empty tempfile ); } } } }