use std::sync::Mutex;
use log::*;
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use serde_json::json;
use simple_error::bail;
use ts_rs::TS;
pub struct UnifiClient {
client: reqwest::Client,
auth_token: String,
host: String,
}
#[derive(Debug, Serialize, Deserialize, Clone, TS)]
pub struct User {
pub id: String,
pub first_name: String,
pub last_name: String,
pub nfc_cards: Vec<NfcCard>,
pub employee_number: String,
pub user_email: String,
pub access_policies: Option<Vec<AccessPolicy>>,
}
#[derive(Debug, Serialize, Deserialize, Clone, TS)]
pub struct NfcCard {
pub id: String,
pub token: String,
}
#[derive(Debug, Deserialize)]
pub struct UsersResponse {
pub data: Vec<User>,
}
#[derive(Debug, Deserialize)]
struct GenericResponse {
pub data: Option<serde_json::Value>,
pub msg: String,
pub code: String,
}
#[derive(Debug, Deserialize, Serialize, Clone, TS)]
pub struct AccessPolicy {
pub id: String,
pub name: String,
}
#[derive(Debug, Deserialize)]
pub struct Device {
pub id: String,
pub name: String,
#[serde(rename = "type")]
pub device_type: String,
}
#[derive(Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "snake_case")]
pub enum SystemLogTopic {
All,
DoorOpenings,
Critical,
Updates,
DeviceEvents,
AdminActivity,
Visitor,
}
#[derive(Debug, Deserialize)]
pub struct SystemLogEvent {
pub actor: serde_json::Value,
pub authentication: serde_json::Value,
pub event: serde_json::Value,
pub target: serde_json::Value,
}
#[derive(Debug, Deserialize)]
pub struct SystemLogEventWrapper {
#[serde(rename = "@timestamp")]
pub timestamp: String,
#[serde(rename = "_id")]
pub id: String,
#[serde(rename = "_source")]
pub source: SystemLogEvent,
}
#[derive(Debug, Deserialize)]
pub struct SystemLogResponse {
hits: Vec<SystemLogEventWrapper>,
}
type UnifiError = Box<dyn std::error::Error + Send + Sync>;
type UnifiResult<T> = Result<T, UnifiError>;
impl UnifiClient {
pub fn new(hostname: &str, key: &str) -> UnifiClient {
let client = reqwest::Client::builder()
.danger_accept_invalid_certs(true)
.build()
.unwrap();
UnifiClient {
client,
auth_token: key.to_string(),
host: hostname.to_string(),
}
}
async fn generic_request_raw(
&self,
method: reqwest::Method,
api_path: String,
body: Option<serde_json::Value>,
) -> UnifiResult<String> {
let url = format!("https://{}:12445{}", self.host, api_path);
debug!("Sending request: {method} {url} {body:?}");
let mut request = self
.client
.request(method, url)
.bearer_auth(&self.auth_token);
if let Some(body) = body {
request = request
.header("content-type", "application/json")
.body(body.to_string());
}
let response = request.send().await?.text().await?;
trace!("Got raw response: {response}");
Ok(response)
}
async fn generic_request_no_parse(
&self,
method: reqwest::Method,
api_path: String,
body: Option<serde_json::Value>,
) -> UnifiResult<Option<serde_json::Value>> {
let response = self
.generic_request_raw(method, api_path.clone(), body)
.await?;
trace!("Got response from unifi: {response}");
let parsed: GenericResponse = serde_json::from_str(&response)?;
if parsed.code != "SUCCESS" {
bail!("Failed request to {api_path}: {}", parsed.msg);
}
Ok(parsed.data)
}
async fn generic_request<T: DeserializeOwned>(
&self,
method: reqwest::Method,
api_path: String,
body: Option<serde_json::Value>,
) -> UnifiResult<T> {
let raw = self
.generic_request_no_parse(method, api_path.clone(), body)
.await?;
Ok(serde_json::from_value(raw.ok_or(
simple_error::SimpleError::new(format!("No data found in response")),
)?)?)
}
pub async fn get_all_users(&self) -> UnifiResult<Vec<User>> {
self.generic_request(
reqwest::Method::GET,
"/api/v1/developer/users".to_string(),
None,
)
.await
}
pub async fn get_all_users_with_access_information(&self) -> UnifiResult<Vec<User>> {
let mut users = self.get_all_users().await?;
for user in users.iter_mut() {
user.access_policies = Some(self.get_access_policies_for_user(&user.id).await?);
}
Ok(users)
}
pub async fn register_user(
&self,
first_name: String,
last_name: String,
email: String,
employee_number: String,
) -> UnifiResult<String> {
debug!("Sending register_user_request: {first_name} {last_name} {email} {employee_number}");
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?;
let register_user_response: serde_json::Value = self
.generic_request(
reqwest::Method::POST,
"/api/v1/developer/users".to_string(),
Some(json!({
"first_name": first_name,
"last_name": last_name,
"user_email": email,
"employee_number": employee_number,
"onboard_time": now.as_secs(),
})),
)
.await?;
let id = register_user_response
.get("id")
.ok_or(simple_error::SimpleError::new("id not found in response"))?
.as_str()
.ok_or(simple_error::SimpleError::new("id not a string"))?;
Ok(id.to_string())
}
pub async fn get_all_access_policies(&self) -> UnifiResult<Vec<AccessPolicy>> {
debug!("Sending get_all_access_policies_request");
self.generic_request(
reqwest::Method::GET,
"/api/v1/developer/access_policies".to_string(),
None,
)
.await
}
pub async fn get_user_by_id(&self, user_id: &str) -> UnifiResult<User> {
debug!("Sending get_user_by_id_request: {user_id}");
self.generic_request(
reqwest::Method::GET,
format!("/api/v1/developer/users/{}", user_id),
None,
)
.await
}
pub async fn assign_access_policies(
&self,
user_id: &str,
policy_ids: Vec<String>,
) -> UnifiResult<()> {
let api = format!("/api/v1/developer/users/{}/access_policies", user_id);
debug!("Sending assign_access_policy_request: {user_id} {policy_ids:?} to {api}");
let _ = self
.generic_request_no_parse(
reqwest::Method::PUT,
api,
Some(json!({
"access_policy_ids": policy_ids,
})),
)
.await?;
Ok(())
}
pub async fn remove_all_access_policies_from_user(&self, user_id: &str) -> UnifiResult<()> {
let api = format!("/api/v1/developer/users/{}/access_policies", user_id);
debug!("Sending assign_access_policy_request to remove access: {user_id} to {api}");
let _ = self
.generic_request_no_parse(
reqwest::Method::PUT,
api,
Some(json!({
"access_policy_ids": [],
})),
)
.await?;
Ok(())
}
pub async fn get_access_policies_for_user(
&self,
user_id: &str,
) -> UnifiResult<Vec<AccessPolicy>> {
let api = format!("/api/v1/developer/users/{}/access_policies", user_id);
debug!("Sending get_access_policies_for_user_request: {user_id} to {api}");
let response = self
.generic_request(reqwest::Method::GET, api, None)
.await?;
Ok(response)
}
pub async fn get_devices(&self) -> UnifiResult<Vec<Device>> {
let response: Vec<Vec<Device>> = self
.generic_request(
reqwest::Method::GET,
"/api/v1/developer/devices".to_string(),
None,
)
.await?;
Ok(response.into_iter().flatten().collect())
}
pub async fn start_nfc_enrollment_session(&self, device_id: &str) -> UnifiResult<String> {
let enroll_response: serde_json::Value = self
.generic_request(
reqwest::Method::POST,
"/api/v1/developer/credentials/nfc_cards/sessions".to_string(),
Some(json!({
"device_id": device_id,
"reset_ua_card": true
})),
)
.await?;
let session_id = enroll_response
.get("session_id")
.ok_or(simple_error::SimpleError::new(
"session_id not found in response",
))?
.as_str()
.ok_or(simple_error::SimpleError::new("session_id not a string"))?;
Ok(session_id.to_string())
}
pub async fn get_nfc_enrollment_session_status(
&self,
session_id: &str,
) -> UnifiResult<Option<NfcCard>> {
let response = self
.generic_request_raw(
reqwest::Method::GET,
format!(
"/api/v1/developer/credentials/nfc_cards/sessions/{}",
session_id
),
None,
)
.await?;
if response.to_string().contains("SESSION_NOT_FOUND") {
return Err(Box::new(simple_error::SimpleError::new(
"Session has been canceled",
)));
}
if response.to_string().contains("TOKEN_EMPTY") {
return Ok(None);
}
let parsed: GenericResponse = serde_json::from_str(&response)?;
let body = parsed
.data
.ok_or(simple_error::SimpleError::new("data not found in response"))?;
let x: Option<NfcCard> = serde_json::from_value(body)?;
Ok(x)
}
pub async fn enroll_nfc_card(
&self,
device_id: &str,
session_state: &Mutex<Option<String>>,
) -> UnifiResult<NfcCard> {
let session = self.start_nfc_enrollment_session(device_id).await?;
*session_state.lock().unwrap() = Some(session.clone());
loop {
let result = self.get_nfc_enrollment_session_status(&session).await;
match result {
Ok(Some(card)) => return Ok(card),
Ok(None) => {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
}
Err(e) => {
return Err(e);
}
}
}
}
pub async fn assign_nfc_card(&self, user_id: &str, card: &NfcCard) -> UnifiResult<()> {
self.generic_request_no_parse(
reqwest::Method::PUT,
format!("/api/v1/developer/users/{}/nfc_cards", user_id),
Some(json!({
"token": card.token,
})),
)
.await?;
Ok(())
}
pub async fn fetch_nfc_card_user(&self, card: &NfcCard) -> UnifiResult<Option<String>> {
#[derive(Debug, Deserialize)]
struct CardUser {
user_id: Option<String>,
}
let x: CardUser = self
.generic_request(
reqwest::Method::GET,
format!(
"/api/v1/developer/credentials/nfc_cards/tokens/{}",
card.token
),
None,
)
.await?;
Ok(x.user_id)
}
pub async fn remove_nfc_card(&self, card: &NfcCard) -> UnifiResult<()> {
let user = self.fetch_nfc_card_user(card).await?;
if let Some(user_id) = user {
info!("Unassigning card {card:?} from user {user_id}");
self.generic_request_no_parse(
reqwest::Method::PUT,
format!("/api/v1/developer/users/{}/nfc_cards/delete", user_id),
Some(json!({
"token": card.token,
})),
)
.await?;
}
info!("Deleting card {card:?}");
let endpoint = format!(
"/api/v1/developer/credentials/nfc_cards/tokens/{}",
card.token
);
self.generic_request_no_parse(reqwest::Method::DELETE, endpoint, None)
.await?;
info!("Card deleted successfully");
Ok(())
}
pub async fn end_enrollment_session(&self, session_id: &str) -> UnifiResult<()> {
self.generic_request_no_parse(
reqwest::Method::DELETE,
format!(
"/api/v1/developer/credentials/nfc_cards/sessions/{}",
session_id
),
None,
)
.await?;
Ok(())
}
pub async fn fetch_system_log(
&self,
topic: SystemLogTopic,
start_time: Option<std::time::SystemTime>,
) -> UnifiResult<Vec<SystemLogEventWrapper>> {
let body = json!({
"topic": topic,
"since": start_time.map(|t| t.duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()),
});
let full_response: SystemLogResponse = self
.generic_request(
reqwest::Method::POST, "/api/v1/developer/system/logs".to_string(),
Some(body),
)
.await?;
Ok(full_response.hits)
}
}