use std::collections::HashMap;
use chrono::{DateTime, Utc};
use reqwest::Method;
use serde::{Deserialize, Serialize};
use crate::client::Client;
use crate::error::Error;
use crate::http::{RequestSpec, encode_segment};
use crate::page::Page;
use crate::patch::Patch;
pub struct Links {
pub(crate) client: Client,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[non_exhaustive]
pub enum LinkStatus {
#[serde(rename = "ACTIVE")]
Active,
#[serde(rename = "INACTIVE")]
Inactive,
#[serde(rename = "EXPIRED")]
Expired,
#[serde(rename = "BLOCKED")]
Blocked,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub enum SettableStatus {
#[serde(rename = "ACTIVE")]
Active,
#[serde(rename = "INACTIVE")]
Inactive,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub enum AliasKind {
#[serde(rename = "alphanumeric")]
Alphanumeric,
#[serde(rename = "emoji")]
Emoji,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct MetaTags {
title: String,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
image: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
color: Option<String>,
}
impl MetaTags {
pub fn new(title: impl Into<String>) -> Self {
MetaTags {
title: title.into(),
description: None,
image: None,
color: None,
}
}
pub fn description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn image(mut self, image: impl Into<String>) -> Self {
self.image = Some(image.into());
self
}
pub fn color(mut self, color: impl Into<String>) -> Self {
self.color = Some(color.into());
self
}
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct MetaTagsInfo {
pub title: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub image: Option<String>,
#[serde(default)]
pub color: Option<String>,
#[serde(default)]
pub warnings: Option<Vec<String>>,
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct Link {
pub id: String,
pub alias: String,
pub short_url: String,
pub long_url: String,
#[serde(default)]
pub owner_id: Option<String>,
#[serde(with = "chrono::serde::ts_seconds")]
pub created_at: DateTime<Utc>,
pub status: LinkStatus,
#[serde(default)]
pub private_stats: Option<bool>,
#[serde(default)]
pub geo_rules: Option<HashMap<String, String>>,
#[serde(default)]
pub meta_tags: Option<MetaTagsInfo>,
#[serde(default)]
pub claim_token: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct LinkItem {
pub id: String,
#[serde(default)]
pub alias: Option<String>,
#[serde(default)]
pub long_url: Option<String>,
#[serde(default)]
pub status: Option<LinkStatus>,
#[serde(default)]
pub created_at: Option<DateTime<Utc>>,
#[serde(default, with = "chrono::serde::ts_seconds_option")]
pub expire_after: Option<DateTime<Utc>>,
#[serde(default)]
pub max_clicks: Option<u64>,
#[serde(default)]
pub private_stats: Option<bool>,
#[serde(default)]
pub block_bots: Option<bool>,
pub password_set: bool,
#[serde(default)]
pub total_clicks: Option<u64>,
#[serde(default)]
pub last_click: Option<DateTime<Utc>>,
#[serde(default)]
pub domain: Option<String>,
#[serde(default)]
pub geo_rules: Option<HashMap<String, String>>,
#[serde(default)]
pub meta_tags: Option<MetaTagsInfo>,
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct UpdatedLink {
pub id: String,
#[serde(default)]
pub alias: Option<String>,
#[serde(default)]
pub long_url: Option<String>,
#[serde(default)]
pub status: Option<LinkStatus>,
pub password_set: bool,
#[serde(default)]
pub max_clicks: Option<u64>,
#[serde(default, with = "chrono::serde::ts_seconds_option")]
pub expire_after: Option<DateTime<Utc>>,
#[serde(default)]
pub block_bots: Option<bool>,
#[serde(default)]
pub private_stats: Option<bool>,
#[serde(default)]
pub domain: Option<String>,
#[serde(default)]
pub geo_rules: Option<HashMap<String, String>>,
#[serde(with = "chrono::serde::ts_seconds")]
pub updated_at: DateTime<Utc>,
#[serde(default)]
pub meta_tags: Option<MetaTagsInfo>,
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct DeletedLink {
pub message: String,
pub id: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[non_exhaustive]
pub enum AliasIssue {
#[serde(rename = "length")]
Length,
#[serde(rename = "format")]
Format,
#[serde(rename = "reserved")]
Reserved,
#[serde(rename = "taken")]
Taken,
#[serde(rename = "emoji_policy")]
EmojiPolicy,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct AliasCheck {
pub available: bool,
#[serde(default)]
pub reason: Option<AliasIssue>,
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct BulkSummary {
pub total: u64,
pub succeeded: u64,
pub failed: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum BulkErrorCode {
NotFound,
Forbidden,
Conflict,
ValidationError,
Internal,
NotAttempted,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct BulkResult {
pub id: String,
#[serde(default)]
pub alias: Option<String>,
pub ok: bool,
#[serde(default)]
pub error_code: Option<BulkErrorCode>,
#[serde(default)]
pub error: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct BulkOutcome {
pub summary: BulkSummary,
pub results: Vec<BulkResult>,
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct DomainPurge {
pub message: String,
pub count: u64,
pub domain: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct ClaimRequest {
url_id: String,
token: String,
}
impl ClaimRequest {
pub fn new(url_id: impl Into<String>, token: impl Into<String>) -> Self {
ClaimRequest {
url_id: url_id.into(),
token: token.into(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ClaimStatus {
Claimed,
AlreadyYours,
Invalid,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct ClaimResult {
pub url_id: String,
pub status: ClaimStatus,
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct ClaimOutcome {
pub results: Vec<ClaimResult>,
pub claimed: u64,
}
impl Links {
pub fn create(&self, long_url: impl Into<String>) -> CreateLinkBuilder {
CreateLinkBuilder {
client: self.client.clone(),
body: CreateLinkBody {
long_url: long_url.into(),
..Default::default()
},
}
}
pub fn check_alias(&self, alias: impl Into<String>) -> CheckAliasBuilder {
CheckAliasBuilder {
client: self.client.clone(),
alias: alias.into(),
domain: None,
}
}
pub fn list(&self) -> ListLinksBuilder {
ListLinksBuilder {
client: self.client.clone(),
page: None,
page_size: None,
sort_by: None,
sort_order: None,
domain: None,
filter: serde_json::Map::new(),
}
}
pub async fn get(&self, id: &str) -> Result<LinkItem, Error> {
self.client
.transport
.execute(RequestSpec::new(
Method::GET,
format!("/api/v1/urls/{}", encode_segment(id)),
))
.await
}
pub async fn get_by_address(&self, domain: &str, alias: &str) -> Result<LinkItem, Error> {
self.client
.transport
.execute(RequestSpec::new(
Method::GET,
format!(
"/api/v1/urls/{}/{}",
encode_segment(domain),
encode_segment(alias)
),
))
.await
}
pub fn update(&self, id: impl Into<String>) -> UpdateLinkBuilder {
UpdateLinkBuilder {
client: self.client.clone(),
id: id.into(),
body: UpdateLinkBody::default(),
}
}
pub async fn set_status(&self, id: &str, status: SettableStatus) -> Result<UpdatedLink, Error> {
let spec = RequestSpec::new(
Method::PATCH,
format!("/api/v1/urls/{}/status", encode_segment(id)),
)
.json(&serde_json::json!({ "status": status }))?;
self.client.transport.execute(spec).await
}
pub async fn delete(&self, id: &str) -> Result<DeletedLink, Error> {
self.client
.transport
.execute(RequestSpec::new(
Method::DELETE,
format!("/api/v1/urls/{}", encode_segment(id)),
))
.await
}
pub async fn delete_all_on_domain(&self, domain: &str) -> Result<DomainPurge, Error> {
let spec =
RequestSpec::new(Method::DELETE, "/api/v1/urls").query("domain", Some(domain.into()));
self.client.transport.execute(spec).await
}
pub async fn bulk_delete<I, S>(&self, ids: I) -> Result<BulkOutcome, Error>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.bulk("/api/v1/urls/bulk/delete", ids, serde_json::Map::new())
.await
}
pub async fn bulk_set_status<I, S>(
&self,
ids: I,
status: SettableStatus,
) -> Result<BulkOutcome, Error>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let mut extra = serde_json::Map::new();
extra.insert(
"status".into(),
serde_json::to_value(status).map_err(Error::Decode)?,
);
self.bulk("/api/v1/urls/bulk/status", ids, extra).await
}
pub async fn bulk_set_expiry<I, S>(
&self,
ids: I,
expire_after: Option<DateTime<Utc>>,
) -> Result<BulkOutcome, Error>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let mut extra = serde_json::Map::new();
extra.insert(
"expire_after".into(),
match expire_after {
Some(when) => serde_json::Value::String(when.to_rfc3339()),
None => serde_json::Value::Null,
},
);
self.bulk("/api/v1/urls/bulk/expiry", ids, extra).await
}
pub async fn bulk_move_domain<I, S>(
&self,
ids: I,
domain: Option<&str>,
) -> Result<BulkOutcome, Error>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let mut extra = serde_json::Map::new();
extra.insert(
"domain".into(),
match domain {
Some(fqdn) => serde_json::Value::String(fqdn.to_owned()),
None => serde_json::Value::Null,
},
);
self.bulk("/api/v1/urls/bulk/domain", ids, extra).await
}
async fn bulk<I, S>(
&self,
path: &str,
ids: I,
mut extra: serde_json::Map<String, serde_json::Value>,
) -> Result<BulkOutcome, Error>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let ids: Vec<String> = ids.into_iter().map(Into::into).collect();
extra.insert(
"ids".into(),
serde_json::Value::Array(ids.into_iter().map(serde_json::Value::String).collect()),
);
let spec = RequestSpec::new(Method::POST, path).json(&serde_json::Value::Object(extra))?;
self.client.transport.execute(spec).await
}
pub async fn claim(
&self,
claims: impl IntoIterator<Item = ClaimRequest>,
) -> Result<ClaimOutcome, Error> {
let claims: Vec<ClaimRequest> = claims.into_iter().collect();
let spec = RequestSpec::new(Method::POST, "/api/v1/urls/claim")
.json(&serde_json::json!({ "claims": claims }))?;
self.client.transport.execute(spec).await
}
}
#[derive(Default, Serialize, Clone)]
struct CreateLinkBody {
long_url: String,
#[serde(skip_serializing_if = "Option::is_none")]
alias: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
alias_type: Option<AliasKind>,
#[serde(skip_serializing_if = "Option::is_none")]
password: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
block_bots: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
max_clicks: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
expire_after: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
private_stats: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
domain: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
geo_rules: Option<HashMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
meta_tags: Option<MetaTags>,
}
#[must_use = "builders do nothing until .send() is awaited"]
pub struct CreateLinkBuilder {
client: Client,
body: CreateLinkBody,
}
impl CreateLinkBuilder {
pub fn alias(mut self, alias: impl Into<String>) -> Self {
self.body.alias = Some(alias.into());
self
}
pub fn alias_kind(mut self, kind: AliasKind) -> Self {
self.body.alias_type = Some(kind);
self
}
pub fn password(mut self, password: impl Into<String>) -> Self {
self.body.password = Some(password.into());
self
}
pub fn block_bots(mut self, block: bool) -> Self {
self.body.block_bots = Some(block);
self
}
pub fn max_clicks(mut self, max: u64) -> Self {
self.body.max_clicks = Some(max);
self
}
pub fn expire_after(mut self, when: DateTime<Utc>) -> Self {
self.body.expire_after = Some(when.to_rfc3339());
self
}
pub fn private_stats(mut self, private: bool) -> Self {
self.body.private_stats = Some(private);
self
}
pub fn domain(mut self, fqdn: impl Into<String>) -> Self {
self.body.domain = Some(fqdn.into());
self
}
pub fn geo_rule(mut self, country: impl Into<String>, url: impl Into<String>) -> Self {
self.body
.geo_rules
.get_or_insert_with(HashMap::new)
.insert(country.into(), url.into());
self
}
pub fn geo_rules(mut self, rules: HashMap<String, String>) -> Self {
self.body.geo_rules = Some(rules);
self
}
pub fn meta_tags(mut self, tags: MetaTags) -> Self {
self.body.meta_tags = Some(tags);
self
}
pub async fn send(self) -> Result<Link, Error> {
let spec = RequestSpec::new(Method::POST, "/api/v1/shorten").json(&self.body)?;
self.client.transport.execute(spec).await
}
}
#[must_use = "builders do nothing until .send() is awaited"]
pub struct CheckAliasBuilder {
client: Client,
alias: String,
domain: Option<String>,
}
impl CheckAliasBuilder {
pub fn domain(mut self, fqdn: impl Into<String>) -> Self {
self.domain = Some(fqdn.into());
self
}
pub async fn send(self) -> Result<AliasCheck, Error> {
let spec = RequestSpec::new(Method::GET, "/api/v1/shorten/check-alias")
.query("alias", Some(self.alias))
.query("domain", self.domain);
self.client.transport.execute(spec).await
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum SortBy {
CreatedAt,
LastClick,
TotalClicks,
}
impl SortBy {
fn as_str(self) -> &'static str {
match self {
SortBy::CreatedAt => "created_at",
SortBy::LastClick => "last_click",
SortBy::TotalClicks => "total_clicks",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum SortOrder {
Ascending,
Descending,
}
impl SortOrder {
fn as_str(self) -> &'static str {
match self {
SortOrder::Ascending => "asc",
SortOrder::Descending => "desc",
}
}
}
#[must_use = "builders do nothing until .send() is awaited"]
#[derive(Clone)]
pub struct ListLinksBuilder {
client: Client,
page: Option<u64>,
page_size: Option<u64>,
sort_by: Option<SortBy>,
sort_order: Option<SortOrder>,
domain: Option<String>,
filter: serde_json::Map<String, serde_json::Value>,
}
#[derive(Deserialize)]
struct ListWire {
items: Vec<LinkItem>,
page: u64,
#[serde(rename = "pageSize")]
page_size: u64,
total: u64,
#[serde(rename = "hasNext")]
has_next: bool,
}
impl ListLinksBuilder {
pub fn page(mut self, page: u64) -> Self {
self.page = Some(page);
self
}
pub fn page_size(mut self, size: u64) -> Self {
self.page_size = Some(size);
self
}
pub fn sort_by(mut self, key: SortBy) -> Self {
self.sort_by = Some(key);
self
}
pub fn sort_order(mut self, order: SortOrder) -> Self {
self.sort_order = Some(order);
self
}
pub fn domain(mut self, fqdn: impl Into<String>) -> Self {
self.domain = Some(fqdn.into());
self
}
pub fn status(mut self, status: SettableStatus) -> Self {
self.filter.insert(
"status".into(),
match status {
SettableStatus::Active => "ACTIVE".into(),
SettableStatus::Inactive => "INACTIVE".into(),
},
);
self
}
pub fn created_after(mut self, when: DateTime<Utc>) -> Self {
self.filter
.insert("createdAfter".into(), when.to_rfc3339().into());
self
}
pub fn created_before(mut self, when: DateTime<Utc>) -> Self {
self.filter
.insert("createdBefore".into(), when.to_rfc3339().into());
self
}
pub fn password_set(mut self, set: bool) -> Self {
self.filter.insert("passwordSet".into(), set.into());
self
}
pub fn max_clicks_set(mut self, set: bool) -> Self {
self.filter.insert("maxClicksSet".into(), set.into());
self
}
pub fn search(mut self, term: impl Into<String>) -> Self {
self.filter.insert("search".into(), term.into().into());
self
}
pub async fn send(self) -> Result<Page<LinkItem>, Error> {
let next_template = self.clone();
let spec = self.into_spec()?;
let wire: ListWire = next_template.client.transport.execute(spec).await?;
Ok(build_page(wire, next_template))
}
fn into_spec(self) -> Result<RequestSpec, Error> {
let filter = if self.filter.is_empty() {
None
} else {
Some(serde_json::to_string(&self.filter).map_err(Error::Decode)?)
};
Ok(RequestSpec::new(Method::GET, "/api/v1/urls")
.query("page", self.page.map(|p| p.to_string()))
.query("pageSize", self.page_size.map(|s| s.to_string()))
.query("sortBy", self.sort_by.map(|s| s.as_str().to_owned()))
.query("sortOrder", self.sort_order.map(|s| s.as_str().to_owned()))
.query("domain", self.domain)
.query("filter", filter))
}
}
fn build_page(wire: ListWire, template: ListLinksBuilder) -> Page<LinkItem> {
let current = wire.page;
let has_next = wire.has_next;
let next = has_next.then(|| {
let fetch: crate::page::PageFetcher<LinkItem> = Box::new(move || {
let mut builder = template.clone();
builder.page = Some(current + 1);
Box::pin(async move { builder.send().await })
});
fetch
});
Page {
items: wire.items,
page: wire.page,
page_size: wire.page_size,
total: wire.total,
has_next,
next,
}
}
#[derive(Default, Serialize, Clone)]
struct UpdateLinkBody {
#[serde(skip_serializing_if = "Patch::is_keep")]
long_url: Patch<String>,
#[serde(skip_serializing_if = "Patch::is_keep")]
alias: Patch<String>,
#[serde(skip_serializing_if = "Patch::is_keep")]
password: Patch<String>,
#[serde(skip_serializing_if = "Patch::is_keep")]
block_bots: Patch<bool>,
#[serde(skip_serializing_if = "Patch::is_keep")]
max_clicks: Patch<u64>,
#[serde(skip_serializing_if = "Patch::is_keep")]
expire_after: Patch<String>,
#[serde(skip_serializing_if = "Patch::is_keep")]
private_stats: Patch<bool>,
#[serde(skip_serializing_if = "Patch::is_keep")]
status: Patch<SettableStatus>,
#[serde(skip_serializing_if = "Patch::is_keep")]
domain: Patch<String>,
#[serde(skip_serializing_if = "Patch::is_keep")]
geo_rules: Patch<HashMap<String, String>>,
#[serde(skip_serializing_if = "Patch::is_keep")]
meta_tags: Patch<MetaTags>,
}
#[must_use = "builders do nothing until .send() is awaited"]
pub struct UpdateLinkBuilder {
client: Client,
id: String,
body: UpdateLinkBody,
}
impl UpdateLinkBuilder {
pub fn long_url(mut self, url: impl Into<String>) -> Self {
self.body.long_url = Patch::Set(url.into());
self
}
pub fn alias(mut self, alias: impl Into<String>) -> Self {
self.body.alias = Patch::Set(alias.into());
self
}
pub fn password(mut self, password: impl Into<String>) -> Self {
self.body.password = Patch::Set(password.into());
self
}
pub fn remove_password(mut self) -> Self {
self.body.password = Patch::Null;
self
}
pub fn block_bots(mut self, block: bool) -> Self {
self.body.block_bots = Patch::Set(block);
self
}
pub fn max_clicks(mut self, max: u64) -> Self {
self.body.max_clicks = Patch::Set(max);
self
}
pub fn remove_max_clicks(mut self) -> Self {
self.body.max_clicks = Patch::Null;
self
}
pub fn expire_after(mut self, when: DateTime<Utc>) -> Self {
self.body.expire_after = Patch::Set(when.to_rfc3339());
self
}
pub fn remove_expiry(mut self) -> Self {
self.body.expire_after = Patch::Null;
self
}
pub fn private_stats(mut self, private: bool) -> Self {
self.body.private_stats = Patch::Set(private);
self
}
pub fn status(mut self, status: SettableStatus) -> Self {
self.body.status = Patch::Set(status);
self
}
pub fn domain(mut self, fqdn: impl Into<String>) -> Self {
self.body.domain = Patch::Set(fqdn.into());
self
}
pub fn system_domain(mut self) -> Self {
self.body.domain = Patch::Null;
self
}
pub fn geo_rules(mut self, rules: HashMap<String, String>) -> Self {
self.body.geo_rules = Patch::Set(rules);
self
}
pub fn clear_geo_rules(mut self) -> Self {
self.body.geo_rules = Patch::Null;
self
}
pub fn meta_tags(mut self, tags: MetaTags) -> Self {
self.body.meta_tags = Patch::Set(tags);
self
}
pub fn remove_meta_tags(mut self) -> Self {
self.body.meta_tags = Patch::Null;
self
}
pub async fn send(self) -> Result<UpdatedLink, Error> {
let spec = RequestSpec::new(
Method::PATCH,
format!("/api/v1/urls/{}", encode_segment(&self.id)),
)
.json(&self.body)?;
self.client.transport.execute(spec).await
}
}