Major refactor and clean up

Split out big lib.rs into separate modules for each endpoint.

Now using FFIResult in all functions requesting the API.

Added unit tests for all functions.

Abstracted file caching behavior into cache.rs. Uses temp files in tests
This commit is contained in:
Tyler Hallada 2020-10-18 20:51:37 -04:00
parent 0aa3e64dc8
commit 6513b2903a
13 changed files with 1895 additions and 1182 deletions

4
Cargo.lock generated
View File

@ -107,16 +107,16 @@ version = "0.1.0"
dependencies = [
"anyhow",
"base64 0.13.0",
"bytes",
"cbindgen",
"dirs",
"lazy_static",
"log",
"mockito",
"reqwest",
"serde",
"serde_json",
"simple-logging",
"url",
"tempfile",
"uuid",
]

View File

@ -12,16 +12,16 @@ cbindgen = "0.14.4"
[dependencies]
anyhow = "1.0"
base64 = "0.13"
bytes = "0.5"
mockito = "0.26.0"
reqwest = { version = "0.10", features = ["blocking", "json", "gzip"] }
lazy_static = "1.4"
log = "0.4"
simple-logging = "2.0"
dirs = "3.0"
uuid = { version = "0.8", features = ["v4"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
url = "2.1"
tempfile = "3.1"
[lib]
name = "BazaarRealmClient"

View File

@ -5,11 +5,10 @@ A Rust DLL that handles making requests to the
the [`BazaarRealmPlugin`](https://github.com/thallada/BazaarRealmPlugin),
part of the Bazaar Realm Skyrim mod.
This project is still a bit of a mess at the moment. But, essentially it uses
[`reqwest`](https://crates.io/crates/reqwest) to make requests to the API,
deserializes the data with [serde](https://crates.io/crates/serde), and saves
the responses to files in the Skyrim data directory to use as a local cache
when the API server is unavailable.
It uses [`reqwest`](https://crates.io/crates/reqwest) to make requests to the
API, deserializes the data with [serde](https://crates.io/crates/serde), and
saves the responses to files in the Skyrim data directory to use as a local
cache when the API server is unavailable.
[cbindgen](https://crates.io/crates/cbindgen) automatically generates the
header file needed for the `BazaarRealmPlugin` (written in C++) to call into
@ -17,10 +16,10 @@ this DLL.
Related projects:
* [`BazaarRealmAPI`](https://github.com/thallada/BazaarRealmAPI): API server
- [`BazaarRealmAPI`](https://github.com/thallada/BazaarRealmAPI): API server
for the mod that stores all shop data and what this client talks to
* [`BazaarRealmPlugin`](https://github.com/thallada/BazaarRealmPlugin): SKSE
- [`BazaarRealmPlugin`](https://github.com/thallada/BazaarRealmPlugin): SKSE
plugin for the mod that modifies data within the Skyrim game engine and calls
the methods in this client
* [`BazaarRealmMod`](https://github.com/thallada/BazaarRealmMod): Papyrus
- [`BazaarRealmMod`](https://github.com/thallada/BazaarRealmMod): Papyrus
scripts, ESP plugin, and all other resources for the mod

View File

@ -5,36 +5,6 @@
#include <cassert>
struct RefRecord {
const char *base_mod_name;
uint32_t base_local_form_id;
const char *ref_mod_name;
uint32_t ref_local_form_id;
float position_x;
float position_y;
float position_z;
float angle_x;
float angle_y;
float angle_z;
uint16_t scale;
};
struct MerchRecord {
const char *mod_name;
uint32_t local_form_id;
const char *name;
uint32_t quantity;
uint32_t form_type;
uint8_t is_food;
uint32_t price;
};
struct ShopRecord {
int32_t id;
const char *name;
const char *description;
};
template<typename T>
struct FFIResult {
enum class Tag : uint8_t {
@ -89,82 +59,121 @@ struct FFIResult {
}
};
struct RefRecordVec {
RefRecord *ptr;
struct RawInteriorRef {
const char *base_mod_name;
uint32_t base_local_form_id;
const char *ref_mod_name;
uint32_t ref_local_form_id;
float position_x;
float position_y;
float position_z;
float angle_x;
float angle_y;
float angle_z;
uint16_t scale;
};
struct RawMerchandise {
const char *mod_name;
uint32_t local_form_id;
const char *name;
uint32_t quantity;
uint32_t form_type;
uint8_t is_food;
uint32_t price;
};
struct RawOwner {
int32_t id;
const char *name;
uint32_t mod_version;
};
struct RawShop {
int32_t id;
const char *name;
const char *description;
};
struct RawInteriorRefVec {
RawInteriorRef *ptr;
uintptr_t len;
uintptr_t cap;
};
struct MerchRecordVec {
MerchRecord *ptr;
struct RawMerchandiseVec {
RawMerchandise *ptr;
uintptr_t len;
uintptr_t cap;
};
/* bad hack added by thallada. See: https://github.com/eqrion/cbindgen/issues/402 */
struct _Helper_0 {
FFIResult<RefRecordVec> ref_record_vec_result;
FFIResult<MerchRecordVec> merch_record_vec_result;
FFIResult<ShopRecord> shop_record_result;
FFIResult<bool> _bool_result;
FFIResult<int32_t> _int_result;
FFIResult<RawOwner> _raw_owner_result;
FFIResult<RawShop> _raw_shop_result;
FFIResult<RawInteriorRefVec> _raw_interior_ref_vec_result;
FFIResult<RawMerchandiseVec> _raw_merchandise_vec_result;
};
// dummy extern C block to close curly brace
// dummy extern C block to close curly brace (did I mention this is a bad hack?)
extern "C" {
};
extern "C" {
int32_t create_interior_ref_list(const char *api_url,
FFIResult<int32_t> create_interior_ref_list(const char *api_url,
const char *api_key,
int32_t shop_id,
const RawInteriorRef *raw_interior_ref_ptr,
uintptr_t raw_interior_ref_len);
FFIResult<int32_t> create_merchandise_list(const char *api_url,
const char *api_key,
int32_t shop_id,
const RawMerchandise *raw_merchandise_ptr,
uintptr_t raw_merchandise_len);
FFIResult<RawOwner> create_owner(const char *api_url,
const char *api_key,
int32_t shop_id,
const RefRecord *ref_records,
uintptr_t ref_records_len);
const char *name,
uint32_t mod_version);
int32_t create_merchandise_list(const char *api_url,
const char *api_key,
int32_t shop_id,
const MerchRecord *merch_records,
uintptr_t merch_records_len);
int32_t create_owner(const char *api_url,
const char *api_key,
const char *name,
uint32_t mod_version);
FFIResult<ShopRecord> create_shop(const char *api_url,
const char *api_key,
const char *name,
const char *description);
FFIResult<RawShop> create_shop(const char *api_url,
const char *api_key,
const char *name,
const char *description);
void free_string(char *ptr);
char *generate_api_key();
FFIResult<RefRecordVec> get_interior_ref_list(const char *api_url,
const char *api_key,
int32_t interior_ref_list_id);
FFIResult<RawInteriorRefVec> get_interior_ref_list(const char *api_url,
const char *api_key,
int32_t interior_ref_list_id);
FFIResult<MerchRecordVec> get_merchandise_list(const char *api_url,
const char *api_key,
int32_t merchandise_list_id);
FFIResult<RawMerchandiseVec> get_merchandise_list(const char *api_url,
const char *api_key,
int32_t merchandise_list_id);
FFIResult<ShopRecord> get_shop(const char *api_url, const char *api_key, int32_t shop_id);
FFIResult<RawShop> get_shop(const char *api_url, const char *api_key, int32_t shop_id);
bool init();
bool status_check(const char *api_url);
FFIResult<bool> status_check(const char *api_url);
int32_t update_owner(const char *api_url,
const char *api_key,
uint32_t id,
const char *name,
uint32_t mod_version);
FFIResult<RawOwner> update_owner(const char *api_url,
const char *api_key,
uint32_t id,
const char *name,
uint32_t mod_version);
FFIResult<ShopRecord> update_shop(const char *api_url,
const char *api_key,
uint32_t id,
const char *name,
const char *description);
FFIResult<RawShop> update_shop(const char *api_url,
const char *api_key,
uint32_t id,
const char *name,
const char *description);
} // extern "C"

View File

@ -65,17 +65,20 @@ renaming_overrides_prefixing = false
[export.body]
"MerchRecordVec" = """
"RawMerchandiseVec" = """
};
/* bad hack added by thallada. See: https://github.com/eqrion/cbindgen/issues/402 */
struct _Helper_0 {
FFIResult<RefRecordVec> ref_record_vec_result;
FFIResult<MerchRecordVec> merch_record_vec_result;
FFIResult<ShopRecord> shop_record_result;
FFIResult<bool> _bool_result;
FFIResult<int32_t> _int_result;
FFIResult<RawOwner> _raw_owner_result;
FFIResult<RawShop> _raw_shop_result;
FFIResult<RawInteriorRefVec> _raw_interior_ref_vec_result;
FFIResult<RawMerchandiseVec> _raw_merchandise_vec_result;
};
// dummy extern C block to close curly brace
// dummy extern C block to close curly brace (did I mention this is a bad hack?)
extern "C" {
"""

View File

@ -1,37 +1,47 @@
/// Thin wrapper around HashMap that automatically assigns new entries with an incrementing key (like a database)
use std::collections::HashMap;
use std::{fs::create_dir_all, fs::File, io::BufReader, io::Write, path::Path, path::PathBuf};
pub struct Cache<T> {
next_key: usize,
cache: HashMap<usize, T>,
use anyhow::Result;
use base64::{encode_config, URL_SAFE_NO_PAD};
use bytes::Bytes;
use serde::Deserialize;
#[cfg(test)]
use tempfile::tempfile;
#[cfg(not(test))]
use log::info;
#[cfg(test)]
use std::println as info;
use super::API_VERSION;
pub fn file_cache_dir(api_url: &str) -> Result<PathBuf> {
let encoded_url = encode_config(api_url, URL_SAFE_NO_PAD);
let path = Path::new("Data/SKSE/Plugins/BazaarRealmCache")
.join(encoded_url)
.join(API_VERSION);
#[cfg(not(test))]
create_dir_all(&path)?;
Ok(path)
}
impl<T> Default for Cache<T> {
fn default() -> Self {
Cache {
next_key: 0,
cache: HashMap::new(),
}
}
pub fn update_file_cache(cache_path: &Path, bytes: &Bytes) -> Result<()> {
#[cfg(not(test))]
let mut file = File::create(cache_path)?;
#[cfg(test)]
let mut file = tempfile()?;
file.write_all(&bytes.as_ref())?;
Ok(())
}
impl<T> Cache<T> {
pub fn new() -> Self {
Default::default()
}
pub fn from_file_cache<T: for<'de> Deserialize<'de>>(cache_path: &Path) -> Result<T> {
#[cfg(not(test))]
let file = File::open(cache_path)?;
#[cfg(test)]
let file = tempfile()?; // cache always reads from an empty temp file in cfg(test)
pub fn insert(&mut self, value: T) -> usize {
let new_key = self.next_key;
self.cache.insert(new_key, value);
self.next_key += 1;
new_key
}
pub fn get(&self, key: &usize) -> Option<&T> {
self.cache.get(key)
}
pub fn remove(&mut self, key: &usize) {
self.cache.remove(key);
}
let reader = BufReader::new(file);
info!("returning value from cache: {:?}", cache_path);
Ok(serde_json::from_reader(reader)?)
}

120
src/client.rs Normal file
View File

@ -0,0 +1,120 @@
use std::{ffi::CStr, ffi::CString, os::raw::c_char, path::Path};
use anyhow::Result;
use log::LevelFilter;
use reqwest::{blocking::Response, Url};
use uuid::Uuid;
#[cfg(not(test))]
use log::{error, info};
#[cfg(test)]
use std::{println as info, println as error};
use crate::{log_server_error, result::FFIResult};
#[no_mangle]
pub extern "C" fn init() -> bool {
match dirs::document_dir() {
Some(mut log_dir) => {
log_dir.push(Path::new(
r#"My Games\Skyrim Special Edition\SKSE\BazaarRealmClient.log"#,
));
match simple_logging::log_to_file(log_dir, LevelFilter::Info) {
Ok(_) => true,
Err(_) => false,
}
}
None => false,
}
}
#[no_mangle]
pub extern "C" fn status_check(api_url: *const c_char) -> FFIResult<bool> {
let api_url = unsafe { CStr::from_ptr(api_url) }.to_string_lossy();
info!("status_check api_url: {:?}", api_url);
fn inner(api_url: &str) -> Result<Response> {
#[cfg(not(test))]
let api_url = Url::parse(api_url)?.join("status")?;
#[cfg(test)]
let api_url = Url::parse(&mockito::server_url())?.join("status")?;
Ok(reqwest::blocking::get(api_url)?)
}
match inner(&api_url) {
Ok(resp) if resp.status() == 200 => {
info!("status_check ok");
FFIResult::Ok(true)
}
Ok(resp) => {
error!("status_check failed. Server error");
log_server_error(resp);
let err_string = CString::new("API returned a non-200 status code".to_string())
.expect("could not create CString")
.into_raw();
FFIResult::Err(err_string)
}
Err(err) => {
error!("status_check failed. {}", err);
let err_string = CString::new(err.to_string())
.expect("could not create CString")
.into_raw();
FFIResult::Err(err_string)
}
}
}
#[no_mangle]
pub unsafe extern "C" fn generate_api_key() -> *mut c_char {
// TODO: is leaking this CString bad?
let uuid = CString::new(format!("{}", Uuid::new_v4()))
.expect("could not create CString")
.into_raw();
info!("generate_api_key successful");
uuid
}
#[cfg(test)]
mod tests {
use super::*;
use mockito::mock;
#[test]
fn test_status_check() {
let mock = mock("GET", "/status").with_status(200).create();
let api_url = CString::new("url").unwrap().into_raw();
let result = status_check(api_url);
mock.assert();
match result {
FFIResult::Ok(success) => {
assert_eq!(success, true);
}
FFIResult::Err(error) => panic!("status_check returned error: {:?}", unsafe {
CStr::from_ptr(error).to_string_lossy()
}),
}
}
#[test]
fn test_status_check_server_error() {
let mock = mock("GET", "/status")
.with_status(500)
.with_body("Internal Server Error")
.create();
let api_url = CString::new("url").unwrap().into_raw();
let result = status_check(api_url);
mock.assert();
match result {
FFIResult::Ok(success) => panic!("status_check returned Ok result: {:?}", success),
FFIResult::Err(error) => {
assert_eq!(
unsafe { CStr::from_ptr(error).to_string_lossy() },
"API returned a non-200 status code"
);
}
}
}
}

450
src/interior_ref_list.rs Normal file
View File

@ -0,0 +1,450 @@
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 InteriorRefList {
pub id: Option<i32>,
pub shop_id: i32,
pub ref_list: Vec<InteriorRef>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct InteriorRef {
pub base_mod_name: String,
pub base_local_form_id: i32,
pub ref_mod_name: Option<String>,
pub ref_local_form_id: i32,
pub position_x: f32,
pub position_y: f32,
pub position_z: f32,
pub angle_x: f32,
pub angle_y: f32,
pub angle_z: f32,
pub scale: u16,
}
impl InteriorRefList {
pub fn from_game(shop_id: i32, raw_interior_ref_slice: &[RawInteriorRef]) -> Self {
Self {
id: None,
shop_id,
ref_list: raw_interior_ref_slice
.iter()
.map(|rec| InteriorRef {
base_mod_name: unsafe { CStr::from_ptr(rec.base_mod_name) }
.to_string_lossy()
.to_string(),
base_local_form_id: rec.base_local_form_id as i32,
ref_mod_name: match rec.ref_mod_name.is_null() {
true => None,
false => Some(
unsafe { CStr::from_ptr(rec.ref_mod_name) }
.to_string_lossy()
.to_string(),
),
},
ref_local_form_id: rec.ref_local_form_id as i32,
position_x: rec.position_x,
position_y: rec.position_y,
position_z: rec.position_z,
angle_x: rec.angle_x,
angle_y: rec.angle_y,
angle_z: rec.angle_z,
scale: rec.scale,
})
.collect(),
}
}
}
#[derive(Debug)]
#[repr(C)]
pub struct RawInteriorRef {
pub base_mod_name: *const c_char,
pub base_local_form_id: u32,
pub ref_mod_name: *const c_char,
pub ref_local_form_id: u32,
pub position_x: f32,
pub position_y: f32,
pub position_z: f32,
pub angle_x: f32,
pub angle_y: f32,
pub angle_z: f32,
pub scale: u16,
}
#[derive(Debug)]
#[repr(C)]
pub struct RawInteriorRefVec {
pub ptr: *mut RawInteriorRef,
pub len: usize,
pub cap: usize,
}
// Required in order to store results in a thread-safe static cache.
// Rust complains that the raw pointers cannot be Send + Sync. We only ever:
// a) read the values in C++/Papyrus land, and it's okay if multiple threads do that.
// b) from_raw() the pointers back into rust values and then drop them. This could be problematic if another script is still reading at the same time, but I'm pretty sure that won't happen.
// Besides, it's already unsafe to read from a raw pointer
unsafe impl Send for RawInteriorRefVec {}
unsafe impl Send for RawInteriorRef {}
unsafe impl Sync for RawInteriorRefVec {}
unsafe impl Sync for RawInteriorRef {}
// Because C++ does not have Result, -1 means that the request was unsuccessful
#[no_mangle]
pub extern "C" fn create_interior_ref_list(
api_url: *const c_char,
api_key: *const c_char,
shop_id: i32,
raw_interior_ref_ptr: *const RawInteriorRef,
raw_interior_ref_len: usize,
) -> FFIResult<i32> {
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_interior_ref_list api_url: {:?}, api_key: {:?}, shop_id: {:?}, raw_interior_ref_len: {:?}", api_url, api_key, shop_id, raw_interior_ref_len);
let raw_interior_ref_slice = unsafe {
assert!(!raw_interior_ref_ptr.is_null());
slice::from_raw_parts(raw_interior_ref_ptr, raw_interior_ref_len)
};
fn inner(
api_url: &str,
api_key: &str,
shop_id: i32,
raw_interior_ref_slice: &[RawInteriorRef],
) -> Result<InteriorRefList> {
#[cfg(not(test))]
let url = Url::parse(api_url)?.join("v1/interior_ref_lists")?;
#[cfg(test)]
let url = Url::parse(&mockito::server_url())?.join("v1/interior_ref_lists")?;
let interior_ref_list = InteriorRefList::from_game(shop_id, raw_interior_ref_slice);
info!(
"created interior_ref_list from game: shop_id: {}",
&interior_ref_list.shop_id
);
let client = reqwest::blocking::Client::new();
let resp = client
.post(url)
.header("Api-Key", api_key)
.json(&interior_ref_list)
.send()?;
info!("create interior_ref_list response from api: {:?}", &resp);
let bytes = resp.bytes()?;
let json: InteriorRefList = serde_json::from_slice(&bytes)?;
if let Some(id) = json.id {
update_file_cache(
&file_cache_dir(api_url)?.join(format!("interior_ref_list_{}.json", id)),
&bytes,
)?;
}
Ok(json)
}
match inner(&api_url, &api_key, shop_id, raw_interior_ref_slice) {
Ok(interior_ref_list) => {
if let Some(id) = interior_ref_list.id {
FFIResult::Ok(id)
} else {
error!("create_interior_ref_list 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_interior_ref_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)
}
}
}
// TODO: fetch by shop_id
#[no_mangle]
pub extern "C" fn get_interior_ref_list(
api_url: *const c_char,
api_key: *const c_char,
interior_ref_list_id: i32,
) -> FFIResult<RawInteriorRefVec> {
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_interior_ref_list api_url: {:?}, api_key: {:?}, interior_ref_list_id: {:?}",
api_url, api_key, interior_ref_list_id
);
fn inner(api_url: &str, api_key: &str, interior_ref_list_id: i32) -> Result<InteriorRefList> {
#[cfg(not(test))]
let url = Url::parse(api_url)?
.join(&format!("v1/interior_ref_lists/{}", interior_ref_list_id))?;
#[cfg(test)]
let url = Url::parse(&mockito::server_url())?
.join(&format!("v1/interior_ref_lists/{}", interior_ref_list_id))?;
info!("api_url: {:?}", url);
let client = reqwest::blocking::Client::new();
let cache_path = file_cache_dir(api_url)?
.join(format!("interior_ref_list_{}.json", interior_ref_list_id));
match client.get(url).header("Api-Key", api_key).send() {
Ok(resp) => {
info!("get_interior_ref_list response from api: {:?}", &resp);
if !resp.status().is_server_error() {
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_interior_ref_list api request error: {}", err);
from_file_cache(&cache_path)
}
}
}
match inner(&api_url, &api_key, interior_ref_list_id) {
Ok(interior_ref_list) => {
let (ptr, len, cap) = interior_ref_list
.ref_list
.into_iter()
.map(|interior_ref| RawInteriorRef {
base_mod_name: CString::new(interior_ref.base_mod_name)
.unwrap_or_default()
.into_raw(),
base_local_form_id: interior_ref.base_local_form_id as u32,
ref_mod_name: match interior_ref.ref_mod_name {
None => std::ptr::null(),
Some(ref_mod_name) => {
CString::new(ref_mod_name).unwrap_or_default().into_raw()
}
},
ref_local_form_id: interior_ref.ref_local_form_id as u32,
position_x: interior_ref.position_x,
position_y: interior_ref.position_y,
position_z: interior_ref.position_z,
angle_x: interior_ref.angle_x,
angle_y: interior_ref.angle_y,
angle_z: interior_ref.angle_z,
scale: interior_ref.scale,
})
.collect::<Vec<RawInteriorRef>>()
.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(RawInteriorRefVec { ptr, len, cap })
}
Err(err) => {
error!("interior_ref_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)
}
}
}
#[cfg(test)]
mod tests {
use std::ffi::CString;
use super::*;
use mockito::mock;
#[test]
fn test_create_interior_ref_list() {
let mock = mock("POST", "/v1/interior_ref_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, "ref_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![RawInteriorRef {
base_mod_name: CString::new("Skyrim.esm").unwrap().into_raw(),
base_local_form_id: 1,
ref_mod_name: CString::new("BazaarRealm.esp").unwrap().into_raw(),
ref_local_form_id: 1,
position_x: 100.,
position_y: 0.,
position_z: 100.,
angle_x: 0.,
angle_y: 0.,
angle_z: 0.,
scale: 1,
}]
.into_raw_parts();
let result = create_interior_ref_list(api_url, api_key, 1, ptr, len);
mock.assert();
match result {
FFIResult::Ok(interior_ref_list_id) => {
assert_eq!(interior_ref_list_id, 1);
}
FFIResult::Err(error) => {
panic!("create_interior_ref_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/interior_ref_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![RawInteriorRef {
base_mod_name: CString::new("Skyrim.esm").unwrap().into_raw(),
base_local_form_id: 1,
ref_mod_name: CString::new("BazaarRealm.esp").unwrap().into_raw(),
ref_local_form_id: 1,
position_x: 100.,
position_y: 0.,
position_z: 100.,
angle_x: 0.,
angle_y: 0.,
angle_z: 0.,
scale: 1,
}]
.into_raw_parts();
let result = create_interior_ref_list(api_url, api_key, 1, ptr, len);
mock.assert();
match result {
FFIResult::Ok(interior_ref_list_id) => panic!(
"create_interior_ref_list returned Ok result: {:?}",
interior_ref_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_interior_ref_list() {
let mock = mock("GET", "/v1/interior_ref_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,
"ref_list": [
{
"base_mod_name": "Skyrim.esm",
"base_local_form_id": 1,
"ref_mod_name": "BazaarRealm.esp",
"ref_local_form_id": 1,
"position_x": 100.0,
"position_y": 0.0,
"position_z": 100.0,
"angle_x": 0.0,
"angle_y": 0.0,
"angle_z": 0.0,
"scale": 1
}
],
"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_interior_ref_list(api_url, api_key, 1);
mock.assert();
match result {
FFIResult::Ok(raw_interior_ref_vec) => {
assert_eq!(raw_interior_ref_vec.len, 1);
let raw_interior_ref_slice = unsafe {
assert!(!raw_interior_ref_vec.ptr.is_null());
slice::from_raw_parts(raw_interior_ref_vec.ptr, raw_interior_ref_vec.len)
};
let raw_interior_ref = &raw_interior_ref_slice[0];
assert_eq!(
unsafe { CStr::from_ptr(raw_interior_ref.base_mod_name) }
.to_string_lossy()
.to_string(),
"Skyrim.esm".to_string(),
);
assert_eq!(raw_interior_ref.base_local_form_id, 1);
assert_eq!(
unsafe { CStr::from_ptr(raw_interior_ref.ref_mod_name) }
.to_string_lossy()
.to_string(),
"BazaarRealm.esp".to_string(),
);
assert_eq!(raw_interior_ref.ref_local_form_id, 1);
assert_eq!(raw_interior_ref.position_x, 100.);
assert_eq!(raw_interior_ref.position_y, 0.);
assert_eq!(raw_interior_ref.position_z, 100.);
assert_eq!(raw_interior_ref.angle_x, 0.);
assert_eq!(raw_interior_ref.angle_y, 0.);
assert_eq!(raw_interior_ref.angle_z, 0.);
assert_eq!(raw_interior_ref.scale, 1);
}
FFIResult::Err(error) => panic!("get_interior_ref_list returned error: {:?}", unsafe {
CStr::from_ptr(error).to_string_lossy()
}),
}
}
#[test]
fn test_get_interior_ref_list_server_error() {
let mock = mock("GET", "/v1/interior_ref_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_interior_ref_list(api_url, api_key, 1);
mock.assert();
match result {
FFIResult::Ok(raw_interior_ref_vec) => panic!(
"get_interior_ref_list returned Ok result: {:#x?}",
raw_interior_ref_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
);
}
}
}
}

1069
src/lib.rs

File diff suppressed because it is too large Load Diff

412
src/merchandise_list.rs Normal file
View File

@ -0,0 +1,412 @@
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<i32>,
pub shop_id: i32,
pub form_list: Vec<Merchandise>,
}
#[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,
}
// Required in order to store results in a thread-safe static cache.
// Rust complains that the raw pointers cannot be Send + Sync. We only ever:
// a) read the values in C++/Papyrus land, and it's okay if multiple threads do that.
// b) from_raw() the pointers back into rust values and then drop them. This could be problematic if another script is still reading at the same time, but I'm pretty sure that won't happen.
// Besides, it's already unsafe to read from a raw pointer
unsafe impl Send for RawMerchandiseVec {}
unsafe impl Send for RawMerchandise {}
unsafe impl Sync for RawMerchandiseVec {}
unsafe impl Sync for RawMerchandise {}
#[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<i32> {
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<MerchandiseList> {
#[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)
}
}
}
// TODO: fetch by shop_id
#[no_mangle]
pub extern "C" fn get_merchandise_list(
api_url: *const c_char,
api_key: *const c_char,
merchandise_list_id: i32,
) -> FFIResult<RawMerchandiseVec> {
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<MerchandiseList> {
#[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_server_error() {
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::<Vec<RawMerchandise>>()
.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)
}
}
}
#[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_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
);
}
}
}
}

314
src/owner.rs Normal file
View File

@ -0,0 +1,314 @@
use std::{ffi::CStr, ffi::CString, os::raw::c_char};
use anyhow::{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::update_file_cache, result::FFIResult};
#[derive(Serialize, Deserialize, Debug)]
pub struct Owner {
pub id: Option<i32>,
pub name: String,
pub api_key: Option<String>,
pub mod_version: u32,
}
impl Owner {
pub fn from_game(name: &str, api_key: &str, mod_version: u32) -> Self {
Self {
id: None,
name: name.to_string(),
api_key: Some(api_key.to_string()),
mod_version,
}
}
}
#[derive(Debug, PartialEq)]
#[repr(C)]
pub struct RawOwner {
pub id: i32,
pub name: *const c_char,
pub mod_version: u32,
}
// Required in order to store results in a thread-safe static cache.
// Rust complains that the raw pointers cannot be Send + Sync. We only ever:
// a) read the values in C++/Papyrus land, and it's okay if multiple threads do that.
// b) from_raw() the pointers back into rust values and then drop them. This could be problematic if another script is still reading at the same time, but I'm pretty sure that won't happen.
// Besides, it's already unsafe to read from a raw pointer
unsafe impl Send for RawOwner {}
unsafe impl Sync for RawOwner {}
#[no_mangle]
pub extern "C" fn create_owner(
api_url: *const c_char,
api_key: *const c_char,
name: *const c_char,
mod_version: u32,
) -> FFIResult<RawOwner> {
let api_url = unsafe { CStr::from_ptr(api_url) }.to_string_lossy();
let api_key = unsafe { CStr::from_ptr(api_key) }.to_string_lossy();
let name = unsafe { CStr::from_ptr(name) }.to_string_lossy();
info!(
"create_owner api_url: {:?}, api_key: {:?}, name: {:?}, mod_version: {:?}",
api_url, api_key, name, mod_version
);
fn inner(api_url: &str, api_key: &str, name: &str, mod_version: u32) -> Result<Owner> {
#[cfg(not(test))]
let url = Url::parse(api_url)?.join("v1/owners")?;
#[cfg(test)]
let url = Url::parse(&mockito::server_url())?.join("v1/owners")?;
let owner = Owner::from_game(name, api_key, mod_version);
info!("created owner from game: {:?}", &owner);
if let Some(api_key) = &owner.api_key {
let client = reqwest::blocking::Client::new();
let resp = client
.post(url)
.header("Api-Key", api_key.clone())
.json(&owner)
.send()?;
info!("create owner response from api: {:?}", &resp);
let bytes = resp.bytes()?;
let json: Owner = serde_json::from_slice(&bytes)?;
if let Some(id) = json.id {
update_file_cache(
&file_cache_dir(api_url)?.join(format!("owner_{}.json", id)),
&bytes,
)?;
}
Ok(json)
} else {
Err(anyhow!("api-key not defined"))
}
}
match inner(&api_url, &api_key, &name, mod_version) {
Ok(owner) => {
info!("create_owner successful");
if let Some(id) = owner.id {
FFIResult::Ok(RawOwner {
id,
name: CString::new(owner.name).unwrap_or_default().into_raw(),
mod_version: owner.mod_version,
})
} else {
error!("create_owner failed. API did not return an owner with an ID");
let err_string = CString::new("API did not return an owner 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_owner 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_owner(
api_url: *const c_char,
api_key: *const c_char,
id: u32,
name: *const c_char,
mod_version: u32,
) -> FFIResult<RawOwner> {
let api_url = unsafe { CStr::from_ptr(api_url) }.to_string_lossy();
let api_key = unsafe { CStr::from_ptr(api_key) }.to_string_lossy();
let name = unsafe { CStr::from_ptr(name) }.to_string_lossy();
info!(
"update_owner api_url: {:?}, api_key: {:?}, name: {:?}, mod_version: {:?}",
api_url, api_key, name, mod_version
);
fn inner(api_url: &str, api_key: &str, id: u32, name: &str, mod_version: u32) -> Result<Owner> {
#[cfg(not(test))]
let url = Url::parse(api_url)?.join(&format!("v1/owners/{}", id))?;
#[cfg(test)]
let url = Url::parse(&mockito::server_url())?.join(&format!("v1/owners/{}", id))?;
let owner = Owner::from_game(name, api_key, mod_version);
info!("created owner from game: {:?}", &owner);
if let Some(api_key) = &owner.api_key {
let client = reqwest::blocking::Client::new();
let resp = client
.patch(url)
.header("Api-Key", api_key.clone())
.json(&owner)
.send()?;
info!("update owner response from api: {:?}", &resp);
let bytes = resp.bytes()?;
let json: Owner = serde_json::from_slice(&bytes)?;
if let Some(id) = json.id {
update_file_cache(
&file_cache_dir(api_url)?.join(format!("owner_{}.json", id)),
&bytes,
)?;
}
Ok(json)
} else {
Err(anyhow!("api-key not defined"))
}
}
match inner(&api_url, &api_key, id, &name, mod_version) {
Ok(owner) => {
info!("update_owner successful");
if let Some(id) = owner.id {
FFIResult::Ok(RawOwner {
id,
name: CString::new(owner.name).unwrap_or_default().into_raw(),
mod_version: owner.mod_version,
})
} else {
error!("update_owner failed. API did not return an owner with an ID");
let err_string = CString::new("API did not return an owner 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_owner 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)
}
}
}
#[cfg(test)]
mod tests {
use std::ffi::CString;
use super::*;
use mockito::mock;
#[test]
fn test_create_owner() {
let mock = mock("POST", "/v1/owners")
.with_status(201)
.with_header("content-type", "application/json")
.with_body(r#"{ "created_at": "2020-08-18T00:00:00.000", "id": 1, "name": "name", "mod_version": 1, "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 name = CString::new("name").unwrap().into_raw();
let mod_version = 1;
let result = create_owner(api_url, api_key, name, mod_version);
mock.assert();
match result {
FFIResult::Ok(raw_owner) => {
assert_eq!(raw_owner.id, 1);
assert_eq!(
unsafe { CStr::from_ptr(raw_owner.name).to_string_lossy() },
"name"
);
assert_eq!(raw_owner.mod_version, 1);
}
FFIResult::Err(error) => panic!("create_owner returned error: {:?}", unsafe {
CStr::from_ptr(error).to_string_lossy()
}),
}
}
#[test]
fn test_create_owner_server_error() {
let mock = mock("POST", "/v1/owners")
.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 name = CString::new("name").unwrap().into_raw();
let mod_version = 1;
let result = create_owner(api_url, api_key, name, mod_version);
mock.assert();
match result {
FFIResult::Ok(raw_owner) => {
panic!("create_owner returned Ok result: {:#x?}", raw_owner)
}
FFIResult::Err(error) => {
assert_eq!(
unsafe { CStr::from_ptr(error).to_string_lossy() },
"expected value at line 1 column 1"
);
}
}
}
#[test]
fn test_update_owner() {
let mock = mock("PATCH", "/v1/owners/1")
.with_status(201)
.with_header("content-type", "application/json")
.with_body(r#"{ "created_at": "2020-08-18T00:00:00.000", "id": 1, "name": "name", "mod_version": 1, "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 name = CString::new("name").unwrap().into_raw();
let mod_version = 1;
let result = update_owner(api_url, api_key, 1, name, mod_version);
mock.assert();
match result {
FFIResult::Ok(raw_owner) => {
assert_eq!(raw_owner.id, 1);
assert_eq!(
unsafe { CStr::from_ptr(raw_owner.name).to_string_lossy() },
"name"
);
assert_eq!(raw_owner.mod_version, 1);
}
FFIResult::Err(error) => panic!("update_owner returned error: {:?}", unsafe {
CStr::from_ptr(error).to_string_lossy()
}),
}
}
#[test]
fn test_update_owner_server_error() {
let mock = mock("PATCH", "/v1/owners/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 name = CString::new("name").unwrap().into_raw();
let mod_version = 1;
let result = update_owner(api_url, api_key, 1, name, mod_version);
mock.assert();
match result {
FFIResult::Ok(raw_owner) => {
panic!("update_owner returned Ok result: {:#x?}", raw_owner)
}
FFIResult::Err(error) => {
assert_eq!(
unsafe { CStr::from_ptr(error).to_string_lossy() },
"expected value at line 1 column 1"
);
}
}
}
}

View File

@ -1,7 +1,16 @@
use std::os::raw::c_char;
#[derive(Debug, PartialEq)]
#[repr(C, u8)]
pub enum FFIResult<T> {
Ok(T),
Err(*const c_char),
}
// Required in order to store results in a thread-safe static cache.
// Rust complains that the raw pointers cannot be Send + Sync. We only ever:
// a) read the values in C++/Papyrus land, and it's okay if multiple threads do that.
// b) from_raw() the pointers back into rust values and then drop them. This could be problematic if another script is still reading at the same time, but I'm pretty sure that won't happen.
// Besides, it's already unsafe to read from a raw pointer
unsafe impl<T> Send for FFIResult<T> {}
unsafe impl<T> Sync for FFIResult<T> {}

434
src/shop.rs Normal file
View File

@ -0,0 +1,434 @@
use std::{ffi::CStr, ffi::CString, os::raw::c_char};
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 Shop {
pub id: Option<i32>,
pub name: String,
pub description: String,
}
impl Shop {
pub fn from_game(name: &str, description: &str) -> Self {
Self {
id: None,
name: name.to_string(),
description: description.to_string(),
}
}
}
#[derive(Debug)]
#[repr(C)]
pub struct RawShop {
pub id: i32,
pub name: *const c_char,
pub description: *const c_char,
}
// Required in order to store results in a thread-safe static cache.
// Rust complains that the raw pointers cannot be Send + Sync. We only ever:
// a) read the values in C++/Papyrus land, and it's okay if multiple threads do that.
// b) from_raw() the pointers back into rust values and then drop them. This could be problematic if another script is still reading at the same time, but I'm pretty sure that won't happen.
// Besides, it's already unsafe to read from a raw pointer
unsafe impl Send for RawShop {}
unsafe impl Sync for RawShop {}
#[no_mangle]
pub extern "C" fn create_shop(
api_url: *const c_char,
api_key: *const c_char,
name: *const c_char,
description: *const c_char,
) -> FFIResult<RawShop> {
let api_url = unsafe { CStr::from_ptr(api_url) }.to_string_lossy();
let api_key = unsafe { CStr::from_ptr(api_key) }.to_string_lossy();
let name = unsafe { CStr::from_ptr(name) }.to_string_lossy();
let description = unsafe { CStr::from_ptr(description) }.to_string_lossy();
info!(
"create_shop api_url: {:?}, api_key: {:?}, name: {:?}, description: {:?}",
api_url, api_key, name, description
);
fn inner(api_url: &str, api_key: &str, name: &str, description: &str) -> Result<Shop> {
#[cfg(not(test))]
let url = Url::parse(api_url)?.join("v1/shops")?;
#[cfg(test)]
let url = Url::parse(&mockito::server_url())?.join("v1/shops")?;
let shop = Shop::from_game(name, description);
info!("created shop from game: {:?}", &shop);
let client = reqwest::blocking::Client::new();
let resp = client
.post(url)
.header("Api-Key", api_key)
.json(&shop)
.send()?;
info!("create shop response from api: {:?}", &resp);
let bytes = resp.bytes()?;
let json: Shop = serde_json::from_slice(&bytes)?;
if let Some(id) = json.id {
update_file_cache(
&file_cache_dir(api_url)?.join(format!("shop_{}.json", id)),
&bytes,
)?;
}
Ok(json)
}
match inner(&api_url, &api_key, &name, &description) {
Ok(shop) => {
info!("create_shop successful");
if let Some(id) = shop.id {
FFIResult::Ok(RawShop {
id,
name: CString::new(shop.name).unwrap_or_default().into_raw(),
description: CString::new(shop.description)
.unwrap_or_default()
.into_raw(),
})
} else {
error!("create_shop failed. API did not return a shop with an ID");
let err_string = CString::new("API did not return a shop 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_shop 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_shop(
api_url: *const c_char,
api_key: *const c_char,
id: u32,
name: *const c_char,
description: *const c_char,
) -> FFIResult<RawShop> {
info!("update_shop 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();
let name = unsafe { CStr::from_ptr(name) }.to_string_lossy();
let description = unsafe { CStr::from_ptr(description) }.to_string_lossy();
info!(
"update_shop api_url: {:?}, api_key: {:?}, name: {:?}, description: {:?}",
api_url, api_key, name, description
);
fn inner(api_url: &str, api_key: &str, id: u32, name: &str, description: &str) -> Result<Shop> {
#[cfg(not(test))]
let url = Url::parse(api_url)?.join(&format!("v1/shops/{}", id))?;
#[cfg(test)]
let url = Url::parse(&mockito::server_url())?.join(&format!("v1/shops/{}", id))?;
let shop = Shop::from_game(name, description);
info!("created shop from game: {:?}", &shop);
let client = reqwest::blocking::Client::new();
let resp = client
.patch(url)
.header("Api-Key", api_key)
.json(&shop)
.send()?;
info!("update shop response from api: {:?}", &resp);
let bytes = resp.bytes()?;
let json: Shop = serde_json::from_slice(&bytes)?;
if let Some(id) = json.id {
update_file_cache(
&file_cache_dir(api_url)?.join(format!("shop_{}.json", id)),
&bytes,
)?;
}
Ok(json)
}
match inner(&api_url, &api_key, id, &name, &description) {
Ok(shop) => {
info!("update_shop successful");
if let Some(id) = shop.id {
FFIResult::Ok(RawShop {
id,
name: CString::new(shop.name).unwrap_or_default().into_raw(),
description: CString::new(shop.description)
.unwrap_or_default()
.into_raw(),
})
} else {
error!("create_shop failed. API did not return a shop with an ID");
let err_string = CString::new("API did not return a shop 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_shop 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_shop(
api_url: *const c_char,
api_key: *const c_char,
shop_id: i32,
) -> FFIResult<RawShop> {
info!("get_shop 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_shop api_url: {:?}, api_key: {:?}, shop_id: {:?}",
api_url, api_key, shop_id
);
fn inner(api_url: &str, api_key: &str, shop_id: i32) -> Result<Shop> {
#[cfg(not(test))]
let url = Url::parse(api_url)?.join(&format!("v1/shops/{}", shop_id))?;
#[cfg(test)]
let url = Url::parse(&mockito::server_url())?.join(&format!("v1/shops/{}", shop_id))?;
info!("api_url: {:?}", url);
let client = reqwest::blocking::Client::new();
let cache_path = file_cache_dir(api_url)?.join(format!("shop_{}.json", shop_id));
match client.get(url).header("Api-Key", api_key).send() {
Ok(resp) => {
info!("get_shop response from api: {:?}", &resp);
if !resp.status().is_server_error() {
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_shop api request error: {}", err);
from_file_cache(&cache_path)
}
}
}
match inner(&api_url, &api_key, shop_id) {
Ok(shop) => {
// 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(RawShop {
id: shop_id,
name: CString::new(shop.name).unwrap_or_default().into_raw(),
description: CString::new(shop.description)
.unwrap_or_default()
.into_raw(),
})
}
Err(err) => {
error!("get_shop_list failed. {}", err);
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_shop() {
let mock = mock("POST", "/v1/shops")
.with_status(201)
.with_header("content-type", "application/json")
.with_body(r#"{ "created_at": "2020-08-18T00:00:00.000", "id": 1, "name": "name", "description": "description", "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 name = CString::new("name").unwrap().into_raw();
let description = CString::new("description").unwrap().into_raw();
let result = create_shop(api_url, api_key, name, description);
mock.assert();
match result {
FFIResult::Ok(raw_shop) => {
assert_eq!(raw_shop.id, 1);
assert_eq!(
unsafe { CStr::from_ptr(raw_shop.name).to_string_lossy() },
"name"
);
assert_eq!(
unsafe { CStr::from_ptr(raw_shop.description).to_string_lossy() },
"description"
);
}
FFIResult::Err(error) => panic!("create_shop returned error: {:?}", unsafe {
CStr::from_ptr(error).to_string_lossy()
}),
}
}
#[test]
fn test_create_shop_server_error() {
let mock = mock("POST", "/v1/shops")
.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 name = CString::new("name").unwrap().into_raw();
let description = CString::new("description").unwrap().into_raw();
let result = create_shop(api_url, api_key, name, description);
mock.assert();
match result {
FFIResult::Ok(raw_shop) => panic!("create_shop returned Ok result: {:#x?}", raw_shop),
FFIResult::Err(error) => {
assert_eq!(
unsafe { CStr::from_ptr(error).to_string_lossy() },
"expected value at line 1 column 1"
);
}
}
}
#[test]
fn test_update_shop() {
let mock = mock("PATCH", "/v1/shops/1")
.with_status(201)
.with_header("content-type", "application/json")
.with_body(r#"{ "created_at": "2020-08-18T00:00:00.000", "id": 1, "name": "name", "description": "description", "updated_at": "2020-08-19T00:00:00.000" }"#)
.create();
let api_url = CString::new("url").unwrap().into_raw();
let api_key = CString::new("api-key").unwrap().into_raw();
let name = CString::new("name").unwrap().into_raw();
let description = CString::new("description").unwrap().into_raw();
let result = update_shop(api_url, api_key, 1, name, description);
mock.assert();
match result {
FFIResult::Ok(raw_shop) => {
assert_eq!(raw_shop.id, 1);
assert_eq!(
unsafe { CStr::from_ptr(raw_shop.name).to_string_lossy() },
"name"
);
assert_eq!(
unsafe { CStr::from_ptr(raw_shop.description).to_string_lossy() },
"description"
);
}
FFIResult::Err(error) => panic!("update_shop returned error: {:?}", unsafe {
CStr::from_ptr(error).to_string_lossy()
}),
}
}
#[test]
fn test_update_shop_server_error() {
let mock = mock("PATCH", "/v1/shops/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 name = CString::new("name").unwrap().into_raw();
let description = CString::new("description").unwrap().into_raw();
let result = update_shop(api_url, api_key, 1, name, description);
mock.assert();
match result {
FFIResult::Ok(raw_shop) => panic!("update_shop returned Ok result: {:#x?}", raw_shop),
FFIResult::Err(error) => {
assert_eq!(
unsafe { CStr::from_ptr(error).to_string_lossy() },
"expected value at line 1 column 1"
);
}
}
}
#[test]
fn test_get_shop() {
let mock = mock("GET", "/v1/shops/1")
.with_status(201)
.with_header("content-type", "application/json")
.with_body(r#"{ "created_at": "2020-08-18T00:00:00.000", "id": 1, "name": "name", "description": "description", "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_shop(api_url, api_key, 1);
mock.assert();
match result {
FFIResult::Ok(raw_shop) => {
assert_eq!(raw_shop.id, 1);
assert_eq!(
unsafe { CStr::from_ptr(raw_shop.name).to_string_lossy() },
"name"
);
assert_eq!(
unsafe { CStr::from_ptr(raw_shop.description).to_string_lossy() },
"description"
);
}
FFIResult::Err(error) => panic!("get_shop returned error: {:?}", unsafe {
CStr::from_ptr(error).to_string_lossy()
}),
}
}
#[test]
fn test_get_shop_server_error() {
let mock = mock("GET", "/v1/shops/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_shop(api_url, api_key, 1);
mock.assert();
match result {
FFIResult::Ok(raw_shop) => panic!("get_shop returned Ok result: {:#x?}", raw_shop),
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
);
}
}
}
}