Initial AS213905 SDK release

This commit is contained in:
AS213905 Engineering
2026-08-14 11:06:03 +00:00
commit ff4aa1b64e
29 changed files with 3313 additions and 0 deletions
+1277
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "as213905-sdk"
version = "1.1.0"
edition = "2021"
license = "MIT"
description = "Official AS213905 customer API client"
repository = "https://git.datacoria.com/Phylex/as213905-sdk"
[dependencies]
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_urlencoded = "0.7"
thiserror = "2"
[dev-dependencies]
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
+10
View File
@@ -0,0 +1,10 @@
# Rust SDK
```toml
[dependencies]
as213905-sdk = { git = "https://git.datacoria.com/Phylex/as213905-sdk.git" }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
```
The async client uses `reqwest` with rustls and exposes typed models for every
documented operation.
+12
View File
@@ -0,0 +1,12 @@
use as213905_sdk::Client;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = Client::new(std::env::var("AS213905_API_KEY")?);
let traffic = client.traffic(Some(7), None, None).await?;
println!(
"p95: {} kbit/s, volume: {:.2} GB",
traffic.p95_kbps, traffic.total_gb
);
Ok(())
}
+478
View File
@@ -0,0 +1,478 @@
use reqwest::{Method, StatusCode};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use std::time::Duration;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("AS213905 API error {status}: {code}: {message}")]
Api {
status: StatusCode,
code: String,
message: String,
required_scope: Option<String>,
},
#[error("HTTP client error: {0}")]
Http(#[from] reqwest::Error),
#[error("invalid base URL: {0}")]
InvalidBaseUrl(String),
}
#[derive(Clone)]
pub struct Client {
api_key: String,
base_url: String,
http: reqwest::Client,
}
impl Client {
pub fn new(api_key: impl Into<String>) -> Self {
Self::with_base_url(api_key, "https://as213905.com").expect("built-in URL is valid")
}
pub fn with_base_url(
api_key: impl Into<String>,
base_url: impl AsRef<str>,
) -> Result<Self, Error> {
let parsed = reqwest::Url::parse(base_url.as_ref())
.map_err(|e| Error::InvalidBaseUrl(e.to_string()))?;
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.user_agent("as213905-rust/1.1.0")
.build()?;
Ok(Self {
api_key: api_key.into(),
base_url: parsed.as_str().trim_end_matches('/').to_owned(),
http,
})
}
async fn request<T: DeserializeOwned, B: Serialize + ?Sized>(
&self,
method: Method,
path: &str,
org: Option<&str>,
body: Option<&B>,
) -> Result<T, Error> {
let mut request = self
.http
.request(method, format!("{}{}", self.base_url, path))
.bearer_auth(&self.api_key)
.header("Accept", "application/json");
if let Some(value) = org {
request = request.header("X-Organization-ID", value);
}
if let Some(value) = body {
request = request.json(value);
}
let response = request.send().await?;
let status = response.status();
if !status.is_success() {
let value: APIErrorBody = response.json().await.unwrap_or_default();
return Err(Error::Api {
status,
code: value.error.unwrap_or_else(|| "http_error".into()),
message: value.message.unwrap_or_default(),
required_scope: value.required_scope,
});
}
Ok(response.json().await?)
}
async fn empty<B: Serialize + ?Sized>(
&self,
method: Method,
path: &str,
org: Option<&str>,
body: Option<&B>,
) -> Result<(), Error> {
let _: serde_json::Value = self.request(method, path, org, body).await?;
Ok(())
}
pub async fn organization(&self) -> Result<Organization, Error> {
Ok(self
.request::<OrganizationResponse, serde_json::Value>(
Method::GET,
"/api/v1/organization",
None,
None,
)
.await?
.organization)
}
pub async fn invoices(&self) -> Result<Vec<Invoice>, Error> {
Ok(self
.request::<InvoiceList, serde_json::Value>(Method::GET, "/api/v1/invoices", None, None)
.await?
.invoices)
}
pub async fn services(&self) -> Result<Vec<Service>, Error> {
Ok(self
.request::<ServiceList, serde_json::Value>(Method::GET, "/api/v1/services", None, None)
.await?
.services)
}
pub async fn service(&self, id: &str) -> Result<Service, Error> {
Ok(self
.request::<ServiceResponse, serde_json::Value>(
Method::GET,
&format!("/api/v1/services/{id}"),
None,
None,
)
.await?
.service)
}
pub async fn service_metrics(
&self,
id: &str,
target: Option<&str>,
range: Option<&str>,
) -> Result<MetricSeries, Error> {
let mut q = vec![];
if let Some(v) = target {
q.push(("target", v))
};
if let Some(v) = range {
q.push(("range", v))
};
let s = serde_urlencoded::to_string(q).unwrap_or_default();
self.request::<MetricSeries, serde_json::Value>(
Method::GET,
&format!(
"/api/v1/services/{id}/metrics{}{}",
if s.is_empty() { "" } else { "?" },
s
),
None,
None,
)
.await
}
pub async fn power_service(
&self,
id: &str,
action: PowerAction,
) -> Result<StatusAction, Error> {
self.request(
Method::POST,
&format!("/api/v1/services/{id}/power"),
None,
Some(&PowerRequest { action }),
)
.await
}
pub async fn traffic(
&self,
days: Option<u16>,
from: Option<&str>,
to: Option<&str>,
) -> Result<Traffic, Error> {
self.request::<Traffic, serde_json::Value>(
Method::GET,
&traffic_path("/api/v1/traffic", days, from, to),
None,
None,
)
.await
}
pub async fn tunnels(&self) -> Result<Vec<Tunnel>, Error> {
Ok(self
.request::<TunnelList, serde_json::Value>(Method::GET, "/api/v1/tunnels", None, None)
.await?
.tunnels)
}
pub async fn create_tunnel(&self, input: &CreateTunnel) -> Result<Tunnel, Error> {
Ok(self
.request::<TunnelResponse, CreateTunnel>(
Method::POST,
"/api/v1/tunnels",
None,
Some(input),
)
.await?
.tunnel)
}
pub async fn delete_tunnel(&self, id: &str) -> Result<(), Error> {
self.empty::<serde_json::Value>(
Method::DELETE,
&format!("/api/v1/tunnels/{id}"),
None,
None,
)
.await
}
pub async fn tunnel_traffic(
&self,
id: &str,
days: Option<u16>,
from: Option<&str>,
to: Option<&str>,
) -> Result<TunnelTraffic, Error> {
self.request::<TunnelTraffic, serde_json::Value>(
Method::GET,
&traffic_path(&format!("/api/v1/tunnels/{id}/traffic"), days, from, to),
None,
None,
)
.await
}
pub async fn geofeeds(&self, org: &str) -> Result<GeofeedList, Error> {
self.request::<GeofeedList, serde_json::Value>(
Method::GET,
"/api/v1/geofeeds",
Some(org),
None,
)
.await
}
pub async fn create_geofeed(&self, org: &str, name: &str) -> Result<GeofeedList, Error> {
self.request(
Method::POST,
"/api/v1/geofeeds",
Some(org),
Some(&NameRequest { name }),
)
.await
}
pub async fn geofeed(&self, org: &str, id: &str) -> Result<Geofeed, Error> {
Ok(self
.request::<GeofeedResponse, serde_json::Value>(
Method::GET,
&format!("/api/v1/geofeeds/{id}"),
Some(org),
None,
)
.await?
.geofeed)
}
pub async fn rename_geofeed(&self, org: &str, id: &str, name: &str) -> Result<(), Error> {
self.empty(
Method::PATCH,
&format!("/api/v1/geofeeds/{id}"),
Some(org),
Some(&NameRequest { name }),
)
.await
}
pub async fn delete_geofeed(&self, org: &str, id: &str) -> Result<(), Error> {
self.empty::<serde_json::Value>(
Method::DELETE,
&format!("/api/v1/geofeeds/{id}"),
Some(org),
None,
)
.await
}
pub async fn replace_geofeed_records(
&self,
org: &str,
id: &str,
records: &[GeofeedRecord],
) -> Result<(), Error> {
self.empty(
Method::PUT,
&format!("/api/v1/geofeeds/{id}/records"),
Some(org),
Some(&RecordsRequest { records }),
)
.await
}
pub async fn rotate_geofeed_url(&self, org: &str, id: &str) -> Result<GeofeedList, Error> {
self.request::<GeofeedList, serde_json::Value>(
Method::POST,
&format!("/api/v1/geofeeds/{id}/rotate"),
Some(org),
None,
)
.await
}
}
fn traffic_path(base: &str, days: Option<u16>, from: Option<&str>, to: Option<&str>) -> String {
let mut q = vec![];
if let Some(v) = days {
q.push(("days", v.to_string()))
};
if let Some(v) = from {
q.push(("from", v.to_owned()))
};
if let Some(v) = to {
q.push(("to", v.to_owned()))
};
let s = serde_urlencoded::to_string(q).unwrap_or_default();
format!("{base}{}{}", if s.is_empty() { "" } else { "?" }, s)
}
#[derive(Default, Deserialize)]
struct APIErrorBody {
error: Option<String>,
message: Option<String>,
required_scope: Option<String>,
}
#[derive(Deserialize)]
struct OrganizationResponse {
organization: Organization,
}
#[derive(Deserialize)]
struct InvoiceList {
invoices: Vec<Invoice>,
}
#[derive(Deserialize)]
struct ServiceList {
services: Vec<Service>,
}
#[derive(Deserialize)]
struct ServiceResponse {
service: Service,
}
#[derive(Deserialize)]
struct TunnelList {
tunnels: Vec<Tunnel>,
}
#[derive(Deserialize)]
struct TunnelResponse {
tunnel: Tunnel,
}
#[derive(Deserialize)]
struct GeofeedResponse {
geofeed: Geofeed,
}
#[derive(Serialize)]
struct PowerRequest {
action: PowerAction,
}
#[derive(Serialize)]
struct NameRequest<'a> {
name: &'a str,
}
#[derive(Serialize)]
struct RecordsRequest<'a> {
records: &'a [GeofeedRecord],
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Organization {
pub id: String,
pub asn: u32,
pub name: String,
pub status: String,
pub commit_kbps: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Invoice {
pub id: String,
pub number: String,
pub status: String,
pub currency: String,
pub total_cents: i64,
pub period_from: Option<String>,
pub period_to: Option<String>,
pub issued_at: Option<String>,
pub due_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Service {
pub id: String,
pub kind: String,
pub label: String,
pub status: String,
pub monthly_cents: i64,
pub next_due_on: Option<String>,
#[serde(default)]
pub controllable: bool,
pub location: Option<String>,
pub configuration: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MetricPoint {
pub t: String,
pub v: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MetricSeries {
pub metric: String,
pub target: String,
pub range: String,
pub points: Vec<MetricPoint>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Period {
pub from: String,
pub to: String,
pub label: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Traffic {
pub p95_kbps: i64,
#[serde(default)]
pub commit_kbps: i64,
#[serde(default)]
pub over_commit: bool,
pub total_bytes: i64,
pub total_gb: f64,
pub period: Period,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Tunnel {
pub id: String,
pub status: String,
pub label: Option<String>,
pub location: String,
pub peer_endpoint: String,
pub ifname: Option<String>,
pub inner_v4: Option<String>,
pub inner_v6: Option<String>,
#[serde(default)]
pub announce: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TunnelTraffic {
#[serde(flatten)]
pub traffic: Traffic,
pub tunnel: Tunnel,
}
#[derive(Debug, Clone, Serialize)]
pub struct CreateTunnel {
pub peer_endpoint: String,
pub location: String,
pub label: Option<String>,
pub announce: Option<bool>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PowerAction {
Start,
Stop,
Restart,
}
#[derive(Debug, Clone, Deserialize)]
pub struct StatusAction {
pub status: String,
pub action: PowerAction,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GeofeedRecord {
#[serde(default, skip_serializing_if = "String::is_empty")]
pub id: String,
pub prefix: String,
pub country_code: String,
#[serde(default)]
pub region_code: String,
#[serde(default)]
pub city: String,
#[serde(default)]
pub postal_code: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Geofeed {
pub id: String,
pub name: String,
pub slug: String,
#[serde(default)]
pub record_count: u32,
pub created_at: String,
pub updated_at: String,
#[serde(default)]
pub records: Vec<GeofeedRecord>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct GeofeedList {
pub geofeeds: Vec<Geofeed>,
pub limit: u8,
}