use std::fmt::Debug;
use std::io::Cursor;
use std::io::Read;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use rand::RngCore as _;
use serde::Serialize;
use serde::de::DeserializeOwned;
use ureq::tls::{Certificate, ClientCert, PrivateKey, TlsConfig};
use crate::error::ApiError;
use crate::middleware;
pub struct KintoneClient {
base_url: url::Url,
auth: Auth,
guest_space_id: Option<u64>,
handler: Box<dyn middleware::Handler>,
}
impl KintoneClient {
pub fn new(base_url: &str, auth: Auth) -> Self {
KintoneClientBuilder::new(base_url, auth).build()
}
pub(crate) fn run(
&self,
req: http::Request<middleware::RequestBody>,
) -> Result<http::Response<middleware::ResponseBody>, ApiError> {
self.handler.handle(req)
}
}
pub struct RequestHandler {
http_client: ureq::Agent,
}
impl middleware::Handler for RequestHandler {
fn handle(
&self,
req: http::Request<middleware::RequestBody>,
) -> Result<http::Response<middleware::ResponseBody>, ApiError> {
let req = req.map(|body| body.into_ureq_body());
let resp = self.http_client.run(req)?;
if resp.status().as_u16() >= 400 {
return Err(ApiError::from(resp));
}
let (parts, body) = resp.into_parts();
let body = middleware::ResponseBody::from_ureq_body(body);
Ok(http::Response::from_parts(parts, body))
}
}
pub struct KintoneClientBuilder<L> {
base_url: url::Url,
auth: Auth,
user_agent: Option<String>,
guest_space_id: Option<u64>,
client_cert: Option<ClientCert>,
layer: L,
}
impl KintoneClientBuilder<middleware::NoLayer> {
pub fn new(base_url: &str, auth: Auth) -> Self {
let base_url = url::Url::parse(base_url).unwrap();
Self {
base_url,
auth,
user_agent: None,
guest_space_id: None,
client_cert: None,
layer: middleware::NoLayer,
}
}
}
impl<L> KintoneClientBuilder<L> {
pub fn layer<L2>(self, new_layer: L2) -> KintoneClientBuilder<middleware::Stack<L, L2>> {
let layer_stack = middleware::Stack::new(self.layer, new_layer);
KintoneClientBuilder {
base_url: self.base_url,
auth: self.auth,
user_agent: self.user_agent,
guest_space_id: self.guest_space_id,
client_cert: self.client_cert,
layer: layer_stack,
}
}
pub fn guest_space_id(mut self, guest_space_id: u64) -> Self {
self.guest_space_id = Some(guest_space_id);
self
}
pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
self.user_agent = Some(user_agent.into());
self
}
pub fn client_certificate_from_pem(
mut self,
cert_pem: &[u8],
key_pem: &[u8],
) -> Result<Self, std::io::Error> {
let cert = Certificate::from_pem(cert_pem).map_err(|e| e.into_io())?;
let key = PrivateKey::from_pem(key_pem).map_err(|e| e.into_io())?;
self.client_cert = Some(ClientCert::new_with_certs(&[cert], key));
Ok(self)
}
}
impl<L> KintoneClientBuilder<L>
where
L: middleware::Layer<RequestHandler>,
{
pub fn build(self) -> KintoneClient {
let user_agent = self.user_agent.unwrap_or_else(|| "kintone-rs".to_owned());
let http_client: ureq::Agent = ureq::Agent::config_builder()
.user_agent(&user_agent)
.http_status_as_error(false)
.tls_config(TlsConfig::builder().client_cert(self.client_cert).build())
.build()
.into();
let handler = self.layer.layer(RequestHandler { http_client });
KintoneClient {
base_url: self.base_url,
auth: self.auth,
guest_space_id: self.guest_space_id,
handler: Box::new(handler),
}
}
}
#[derive(Clone)]
pub enum Auth {
Password { username: String, password: String },
ApiToken { tokens: Vec<String> },
}
impl Auth {
pub fn password(username: String, password: String) -> Self {
Self::Password { username, password }
}
pub fn api_token(token: String) -> Self {
Self::ApiToken {
tokens: vec![token],
}
}
pub fn api_tokens(tokens: Vec<String>) -> Self {
Self::ApiToken { tokens }
}
}
impl Debug for Auth {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Auth::Password { username, .. } => f
.debug_struct("Password")
.field("username", username)
.field("password", &"<hidden>")
.finish(),
Auth::ApiToken { .. } => {
f.debug_struct("ApiToken").field("tokens", &"<hidden>").finish()
}
}
}
}
pub(crate) struct RequestBuilder {
method: http::Method,
api_path: String, headers: Vec<(String, String)>, query: Vec<(String, String)>, }
impl RequestBuilder {
pub fn new(method: http::Method, api_path: impl Into<String>) -> Self {
Self {
method,
api_path: api_path.into(),
headers: Vec::new(),
query: Vec::new(),
}
}
pub fn query<V: ToString>(mut self, key: &str, value: V) -> Self {
self.query.push((key.to_owned(), value.to_string()));
self
}
pub fn query_array<V: ToString>(mut self, key: &str, values: &[V]) -> Self {
for (i, v) in values.iter().enumerate() {
let name = format!("{key}[{i}]");
self.query.push((name, v.to_string()));
}
self
}
pub fn call<Resp: DeserializeOwned>(self, client: &KintoneClient) -> Result<Resp, ApiError> {
let req = make_request(client, self.method, &self.api_path, self.headers, self.query)?;
let resp = client.run(req)?;
resp.into_body().read_json()
}
pub fn send<Body: Serialize, Resp: DeserializeOwned>(
mut self,
client: &KintoneClient,
body: Body,
) -> Result<Resp, ApiError> {
let body = middleware::RequestBody::from_bytes(serde_json::to_vec_pretty(&body)?);
self.headers.push(("content-type".to_owned(), "application/json".to_owned()));
let req = make_request(client, self.method, &self.api_path, self.headers, self.query)?
.map(|_| body);
let resp = client.run(req)?;
resp.into_body().read_json()
}
}
pub(crate) struct UploadRequest {
method: http::Method,
api_path: String, name: String,
filename: String,
}
impl UploadRequest {
pub fn new(
method: http::Method,
api_path: impl Into<String>,
name: String,
filename: String,
) -> Self {
Self {
method,
api_path: api_path.into(),
name,
filename,
}
}
const CONTROLS_AND_QUOTES: &percent_encoding::AsciiSet =
&percent_encoding::CONTROLS.add(b'\'').add(b'"').add(b'\\');
pub fn send<Resp: DeserializeOwned>(
self,
client: &KintoneClient,
content_type: Option<String>,
content: impl Read + Send + Sync + 'static,
) -> Result<Resp, ApiError> {
let mut rng = rand::rng();
let boundary = format!("{:x}{:x}", rng.next_u64(), rng.next_u64());
let outer_content_type = format!("multipart/form-data; boundary={boundary}");
let headers = [("content-type".to_owned(), outer_content_type)];
let inner_content_type_header = match content_type {
Some(ref ct) => format!("Content-Type: {ct}\r\n"),
None => String::new(),
};
let header = format!(
"--{boundary}\r\n\
Content-Disposition: form-data; name=\"{}\"; filename*=utf8''{}\r\n\
{inner_content_type_header}\
\r\n",
percent_encoding::utf8_percent_encode(&self.name, Self::CONTROLS_AND_QUOTES),
percent_encoding::utf8_percent_encode(
&self.filename,
percent_encoding::NON_ALPHANUMERIC
),
);
let footer = format!("\r\n--{boundary}--\r\n");
let header_reader = Cursor::new(header.into_bytes());
let footer_reader = Cursor::new(footer.into_bytes());
let body_reader = header_reader.chain(content).chain(footer_reader);
let body = middleware::RequestBody::from_reader(body_reader);
let req = make_request(client, self.method, &self.api_path, headers, vec![])?.map(|_| body);
let resp = client.run(req)?;
resp.into_body().read_json()
}
}
pub(crate) struct DownloadRequest {
method: http::Method,
api_path: String, query: Vec<(String, String)>, }
pub(crate) struct DownloadResponse {
pub mime_type: Option<mime::Mime>,
pub content: Box<dyn Read + Send + Sync + 'static>,
}
impl DownloadRequest {
pub fn new(method: http::Method, api_path: impl Into<String>) -> Self {
Self {
method,
api_path: api_path.into(),
query: Vec::new(),
}
}
pub fn query<V: ToString>(mut self, key: &str, value: V) -> Self {
self.query.push((key.to_owned(), value.to_string()));
self
}
fn get_content_type<B>(resp: &http::Response<B>) -> Option<mime::Mime> {
let content_type = resp.headers().get(http::header::CONTENT_TYPE)?;
let content_type = content_type.to_str().ok()?;
content_type.parse().ok()
}
pub fn send(self, client: &KintoneClient) -> Result<DownloadResponse, ApiError> {
let req = make_request(client, self.method, &self.api_path, vec![], self.query)?;
let resp = client.run(req)?;
let mime_type = Self::get_content_type(&resp);
let content_reader = Box::new(resp.into_body().into_reader());
Ok(DownloadResponse {
mime_type,
content: content_reader,
})
}
}
fn make_request(
client: &KintoneClient,
method: http::Method,
api_path: &str,
headers: impl IntoIterator<Item = (String, String)>,
query: impl IntoIterator<Item = (String, String)>,
) -> Result<http::Request<middleware::RequestBody>, http::Error> {
let auth_headers = match client.auth {
Auth::Password {
ref username,
ref password,
} => {
let body = format!("{username}:{password}");
let header_value = BASE64.encode(body);
[("x-cybozu-authorization".to_owned(), header_value)]
}
Auth::ApiToken { ref tokens } => [("x-cybozu-api-token".to_owned(), tokens.join(","))],
};
let mut u = client.base_url.clone();
let mut path = if let Some(guest_space_id) = client.guest_space_id {
format!("/k/guest/{guest_space_id}")
} else {
"/k".to_owned()
};
path += api_path;
u.set_path(&path);
for (key, value) in query {
u.query_pairs_mut().append_pair(&key, &value);
}
let mut req = http::Request::builder().method(method).uri(u.as_str());
let all_headers = headers.into_iter().chain(auth_headers);
for (key, value) in all_headers {
req = req.header(&key, &value);
}
req.body(middleware::RequestBody::void())
}