Skip to content

Commit

Permalink
Move blocking API to dedicated module
Browse files Browse the repository at this point in the history
  • Loading branch information
Sharparam committed Aug 16, 2023
1 parent 91580d1 commit a4364d3
Show file tree
Hide file tree
Showing 16 changed files with 383 additions and 355 deletions.
5 changes: 4 additions & 1 deletion crates/api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,16 @@ license = "MPL-2.0"
keywords = ["factorio"]
categories = ["api-bindings"]

[features]
blocking = ["reqwest/blocking"]

[dependencies]
chrono = { version = "0.4.26", default-features = false, features = [
"std",
"serde"
] }
facti-lib = { version = "0.1.0", path = "../lib" }
reqwest = { version = "0.11.18", features = ["blocking", "json", "multipart"] }
reqwest = { version = "0.11.18", features = ["json", "multipart"] }
serde = { version = "1.0.183", features = ["derive"] }
serde_json = "1.0.104"
strum = { version = "0.25.0", features = ["derive"] }
Expand Down
11 changes: 11 additions & 0 deletions crates/api/src/blocking.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
//! Blocking client API.
//!
//! # Optional
//!
//! This requires the optional `blocking` feature to be enabled.

mod client;
mod error;
mod reqwest;

pub use client::{ApiClient, ApiClientBuilder};
221 changes: 221 additions & 0 deletions crates/api/src/blocking/client.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
use std::path::Path;

use reqwest::{blocking::RequestBuilder, header};
use serde::de::DeserializeOwned;
use url::Url;

use crate::{
detail::{ModDetailsRequest, ModDetailsResponse},
error::{ApiError, ApiErrorKind},
image::{ImageAddResponse, ImageEditRequest, ImageEditResponse, ImageUploadResponse},
portal::{SearchQuery, SearchResponse, SearchResult},
publish::{InitPublishResponse, PublishRequest, PublishResponse},
reqwest::FormContainer,
upload::{InitUploadResponse, UploadResponse},
DEFAULT_BASE_URL,
};

pub struct ApiClient {
client: reqwest::blocking::Client,
base_url: Url,
api_key: Option<String>,
}

type Result<T> = core::result::Result<T, ApiError>;

impl ApiClient {
pub fn new<T: Into<String>>(api_key: Option<T>) -> Self {
Self::builder().api_key(api_key).build()
}

pub fn builder() -> ApiClientBuilder {
ApiClientBuilder::new()
}

pub fn search(&self, query: &SearchQuery) -> Result<SearchResponse> {
self.get("mods", false, |r| r.query(query))
}

pub fn info_short(&self, name: &str) -> Result<SearchResult> {
self.get(&format!("mods/{}", name), false, |r| r)
}

pub fn info_full(&self, name: &str) -> Result<SearchResult> {
self.get(&format!("mods/{}/full", name), false, |r| r)
}

pub fn init_upload<T: Into<String>>(&self, name: T) -> Result<InitUploadResponse> {
let form = reqwest::blocking::multipart::Form::new().text("mod", name.into());
self.post("v2/mods/upload", true, |r| r.multipart(form))
}

pub fn upload(&self, url: Url, path: &Path) -> Result<UploadResponse> {
let form = reqwest::blocking::multipart::Form::new()
.file("file", path)
.map_err(|e| {
ApiError::new(
ApiErrorKind::ImageIo,
format!("Could not read mod file {:?}", e),
None,
)
})?;

self.send(self.client.post(url).multipart(form), false)
}

pub fn edit_details(&self, data: ModDetailsRequest) -> Result<ModDetailsResponse> {
let container: FormContainer<reqwest::blocking::multipart::Form> = data.into();
let form = container.into_inner();
self.post("v2/mods/edit_details", true, |r| r.multipart(form))
}

pub fn add_image<T: Into<String>>(&self, name: T) -> Result<ImageAddResponse> {
self.post("v2/mods/images/add", true, |r| {
r.multipart(reqwest::blocking::multipart::Form::new().text("mod", name.into()))
})
}

pub fn upload_image(&self, url: Url, path: &Path) -> Result<ImageUploadResponse> {
let form = reqwest::blocking::multipart::Form::new()
.file("image", path)
.map_err(|e| {
ApiError::new(
ApiErrorKind::ImageIo,
format!("Could not read image file: {:?}", e),
None,
)
})?;

self.send(self.client.post(url).multipart(form), false)
}

pub fn edit_images(&self, data: ImageEditRequest) -> Result<ImageEditResponse> {
let container: FormContainer<reqwest::blocking::multipart::Form> = data.into();
let form = container.into_inner();
self.post("v2/mods/images/edit", true, |r| r.multipart(form))
}

pub fn init_publish<T: Into<String>>(&self, name: T) -> Result<InitPublishResponse> {
let form = reqwest::blocking::multipart::Form::new().text("mod", name.into());
self.post("v2/mods/init_publish", true, |r| r.multipart(form))
}

pub fn publish(&self, url: Url, data: PublishRequest, path: &Path) -> Result<PublishResponse> {
let container: FormContainer<reqwest::blocking::multipart::Form> = data.into();
let mut form = container.into_inner();
form = form.file("file", path).map_err(|e| {
ApiError::new(
ApiErrorKind::ImageIo,
format!("Could not read mod file {:?}", e),
None,
)
})?;

self.send(self.client.post(url).multipart(form), false)
}

fn url(&self, path: &str) -> Result<Url> {
self.base_url.join(path).map_err(|_| {
ApiError::new(
ApiErrorKind::UrlParseFailed,
format!("Failed to join base URL with path {}", path),
None,
)
})
}

fn send<T>(&self, request: reqwest::blocking::RequestBuilder, auth: bool) -> Result<T>
where
T: DeserializeOwned,
{
let mut request = request.header(header::USER_AGENT, "facti");
if auth {
if let Some(api_key) = &self.api_key {
request = request.bearer_auth(api_key)
} else {
return Err(ApiError::new(
ApiErrorKind::MissingApiKey,
"Missing API key",
None,
));
}
}

let response = request.send()?;

if response.status().is_success() {
Ok(response.json::<T>()?)
} else {
Err(response.into())
}
}

fn get<T, F>(&self, path: &str, auth: bool, f: F) -> Result<T>
where
T: serde::de::DeserializeOwned,
F: FnOnce(RequestBuilder) -> RequestBuilder,
{
let url = self.url(path)?;
let request = f(self.client.get(url));

self.send::<T>(request, auth)
}

fn post<T, F>(&self, path: &str, auth: bool, f: F) -> Result<T>
where
T: serde::de::DeserializeOwned,
F: FnOnce(RequestBuilder) -> RequestBuilder,
{
let url = self.url(path)?;
let request = f(self.client.post(url));

self.send::<T>(request, auth)
}
}

impl Default for ApiClient {
fn default() -> Self {
Self::new::<String>(None)
}
}

#[derive(Default)]
pub struct ApiClientBuilder {
client: Option<reqwest::blocking::Client>,
base_url: Option<Url>,
api_key: Option<String>,
}

impl ApiClientBuilder {
pub fn new() -> Self {
Default::default()
}

pub fn client(mut self, client: Option<reqwest::blocking::Client>) -> Self {
self.client = client;
self
}

pub fn base_url(mut self, base_url: Option<Url>) -> Self {
self.base_url = base_url;
self
}

pub fn api_key<T: Into<String>>(mut self, api_key: Option<T>) -> Self {
self.api_key = api_key.map(Into::into);
self
}

pub fn build(self) -> ApiClient {
let client = self.client.unwrap_or_default();
let base_url = self
.base_url
.unwrap_or(Url::parse(DEFAULT_BASE_URL).unwrap());

ApiClient {
client,
base_url,
api_key: self.api_key,
}
}
}
32 changes: 32 additions & 0 deletions crates/api/src/blocking/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
use serde::Deserialize;

use crate::error::{ApiError, ApiErrorKind};

impl From<reqwest::blocking::Response> for ApiError {
fn from(response: reqwest::blocking::Response) -> Self {
#[derive(Debug, Deserialize)]
struct ApiErrorResponse {
error: String,
message: String,
}

let source = match response.error_for_status_ref() {
Ok(_) => None,
Err(e) => Some(e),
};

if let Ok(error_response) = response.json::<ApiErrorResponse>() {
Self::new(
ApiErrorKind::parse(error_response.error),
error_response.message,
source,
)
} else {
Self::new(
ApiErrorKind::Unknown,
"Failed to parse error response",
source,
)
}
}
}
Empty file.
17 changes: 17 additions & 0 deletions crates/api/src/blocking/reqwest.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
use std::borrow::Cow;

use crate::reqwest::FormLike;

impl FormLike for reqwest::blocking::multipart::Form {
fn new() -> Self {
Self::new()
}

fn text<T, U>(self, name: T, value: U) -> Self
where
T: Into<Cow<'static, str>>,
U: Into<Cow<'static, str>>,
{
self.text(name, value)
}
}
30 changes: 16 additions & 14 deletions crates/api/src/detail.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ use serde::{Deserialize, Serialize, Serializer};
use strum::Display;
use url::Url;

use crate::reqwest::{FormContainer, FormLike};

/// Describes a request to modify details for a mod.
///
/// The `name` field is required to identify the mod to change,
Expand Down Expand Up @@ -85,53 +87,53 @@ impl ModDetailsRequest {
}
}

impl From<ModDetailsRequest> for reqwest::blocking::multipart::Form {
fn from(data: ModDetailsRequest) -> Self {
let mut form = Self::new().text("mod", data.name);
impl<T: FormLike> From<ModDetailsRequest> for FormContainer<T> {
fn from(value: ModDetailsRequest) -> Self {
let mut form = T::new().text("mod", value.name);

if let Some(title) = data.title {
if let Some(title) = value.title {
form = form.text("title", title);
}

if let Some(summary) = data.summary {
if let Some(summary) = value.summary {
form = form.text("summary", summary);
}

if let Some(description) = data.description {
if let Some(description) = value.description {
form = form.text("description", description);
}

if let Some(category) = data.category {
if let Some(category) = value.category {
form = form.text("category", category.to_string());
}

if let Some(tags) = data.tags {
if let Some(tags) = value.tags {
for tag in tags {
form = form.text("tags", tag.to_string());
}
}

if let Some(license) = data.license {
if let Some(license) = value.license {
form = form.text("license", license.to_string());
}

if let Some(homepage) = data.homepage {
if let Some(homepage) = value.homepage {
form = form.text("homepage", homepage.to_string());
}

if let Some(deprecated) = data.deprecated {
if let Some(deprecated) = value.deprecated {
form = form.text("deprecated", deprecated.to_string());
}

if let Some(source_url) = data.source_url {
if let Some(source_url) = value.source_url {
form = form.text("source_url", source_url.to_string());
}

if let Some(faq) = data.faq {
if let Some(faq) = value.faq {
form = form.text("faq", faq);
}

form
FormContainer(form)
}
}

Expand Down
Loading

0 comments on commit a4364d3

Please sign in to comment.