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
+9
View File
@@ -0,0 +1,9 @@
.DS_Store
.env
target/
dist/
node_modules/
__pycache__/
.pytest_cache/
*.egg-info/
coverage/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 AS213905
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+67
View File
@@ -0,0 +1,67 @@
# AS213905 SDK
Official API clients and the OpenAPI contract for the AS213905 customer API.
The clients cover organisations, invoices, services, VM metrics and power
actions, transit traffic, tunnels, and RFC 8805 geofeeds. API keys are created
in **Panel → Settings → API keys** and are always scoped to one organisation.
## Packages
| Language | Package | Install |
| --- | --- | --- |
| Go | `as213905` | `go get git.datacoria.com/Phylex/as213905-sdk/go` |
| Rust | `as213905-sdk` | Git dependency shown in `rust/README.md` |
| Python | `as213905` | `pip install "as213905 @ git+https://git.datacoria.com/Phylex/as213905-sdk.git#subdirectory=python"` |
| JavaScript | `@as213905/sdk` | `npm install git+https://git.datacoria.com/Phylex/as213905-sdk.git#javascript` |
All clients use `https://as213905.com` by default and accept a different base
URL for tests. Never put an API key in a query string or commit it to source.
## Quick start
```go
client := as213905.New(os.Getenv("AS213905_API_KEY"))
traffic, err := client.Traffic(ctx, &as213905.TrafficQuery{Days: 7})
```
```rust
let client = as213905_sdk::Client::new(std::env::var("AS213905_API_KEY")?);
let traffic = client.traffic(Some(7), None, None).await?;
```
```python
client = AS213905(os.environ["AS213905_API_KEY"])
traffic = client.traffic(days=7)
```
```js
const client = new AS213905({apiKey: process.env.AS213905_API_KEY});
const traffic = await client.traffic({days: 7});
```
Geofeed methods additionally take the organisation UUID because the geofeed
service also supports administrator sessions. An API key can only use its own
organisation UUID and needs `geofeeds:read` or `geofeeds:write`.
## API contract
- [`openapi/openapi.yaml`](openapi/openapi.yaml) is the source of truth.
- Interactive reference: <https://as213905.com/docs/api/reference>
- Machine-readable production contract: <https://as213905.com/api/v1/openapi.json>
## Development
Each language directory is independently buildable and tested. See its README
for language-specific commands. Behaviour shared by every client:
- bearer authentication in the `Authorization` header;
- stable `APIError`/`Error` values containing HTTP status, API error code and
response message;
- request timeouts;
- no automatic retries for mutation requests;
- typed request and response models for the documented operations.
## License
MIT. See [LICENSE](LICENSE).
+30
View File
@@ -0,0 +1,30 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark light" />
<title>AS213905 API reference</title>
<link rel="stylesheet" href="/docs/api/reference/assets/swagger-ui.css" />
<style>
html { box-sizing: border-box; overflow-y: scroll; }
*, *::before, *::after { box-sizing: inherit; }
body { margin: 0; background: #201d21; }
.swagger-ui .topbar { background: #171518; border-bottom: 1px solid #4a454b; }
.swagger-ui .topbar-wrapper img { display: none; }
.swagger-ui .topbar-wrapper::before { content: "AS213905 API"; color: #ffbe5c; font: 600 18px system-ui; }
.swagger-ui .scheme-container { background: #29252a; box-shadow: none; }
@media (max-width: 640px) {
.swagger-ui .wrapper { padding: 0 12px; }
.swagger-ui .opblock .opblock-summary { flex-wrap: wrap; }
.swagger-ui .opblock-summary-path { max-width: 100%; font-size: 13px; }
}
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="/docs/api/reference/assets/swagger-ui-bundle.js"></script>
<script src="/docs/api/reference/assets/swagger-ui-standalone-preset.js"></script>
<script src="/docs/api/reference/swagger-initializer.js"></script>
</body>
</html>
+28
View File
@@ -0,0 +1,28 @@
{
"name": "docs",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"swagger-ui-dist": "5.27.1"
}
},
"node_modules/@scarf/scarf": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
"integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==",
"hasInstallScript": true,
"license": "Apache-2.0"
},
"node_modules/swagger-ui-dist": {
"version": "5.27.1",
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.27.1.tgz",
"integrity": "sha512-oGtpYO3lnoaqyGtlJalvryl7TwzgRuxpOVWqEHx8af0YXI+Kt+4jMpLdgMtMcmWmuQ0QTCHLKExwrBFMSxvAUA==",
"license": "Apache-2.0",
"dependencies": {
"@scarf/scarf": "=1.4.0"
}
}
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"private": true,
"dependencies": {"swagger-ui-dist": "5.27.1"}
}
+13
View File
@@ -0,0 +1,13 @@
window.addEventListener('load', () => {
window.ui = SwaggerUIBundle({
url: '/api/v1/openapi.json',
dom_id: '#swagger-ui',
deepLinking: true,
displayRequestDuration: true,
filter: true,
persistAuthorization: false,
tryItOutEnabled: false,
presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
layout: 'StandaloneLayout',
});
});
+13
View File
@@ -0,0 +1,13 @@
# Go SDK
```bash
go get git.datacoria.com/Phylex/as213905-sdk/go
```
```go
client := as213905.New(os.Getenv("AS213905_API_KEY"))
org, err := client.Organization(context.Background())
```
The client is safe for concurrent use. Use `NewWithOptions` to supply a custom
base URL, HTTP client or user agent.
+258
View File
@@ -0,0 +1,258 @@
package as213905
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
const defaultBaseURL = "https://as213905.com"
type Options struct {
BaseURL string
HTTPClient *http.Client
UserAgent string
}
type Client struct {
apiKey string
base *url.URL
http *http.Client
userAgent string
}
type APIError struct {
Status int
Code string `json:"error"`
Message string `json:"message"`
RequiredScope string `json:"required_scope"`
RetryAfter int `json:"retry_after"`
}
func (e *APIError) Error() string {
if e.Message != "" {
return fmt.Sprintf("as213905: %s (%d): %s", e.Code, e.Status, e.Message)
}
return fmt.Sprintf("as213905: %s (%d)", e.Code, e.Status)
}
func New(apiKey string) *Client {
c, err := NewWithOptions(apiKey, Options{})
if err != nil {
panic(err)
}
return c
}
func NewWithOptions(apiKey string, opts Options) (*Client, error) {
if strings.TrimSpace(apiKey) == "" {
return nil, errors.New("as213905: api key is required")
}
base := opts.BaseURL
if base == "" {
base = defaultBaseURL
}
u, err := url.Parse(strings.TrimRight(base, "/"))
if err != nil || u.Scheme == "" || u.Host == "" {
return nil, errors.New("as213905: invalid base URL")
}
hc := opts.HTTPClient
if hc == nil {
hc = &http.Client{Timeout: 30 * time.Second}
}
ua := opts.UserAgent
if ua == "" {
ua = "as213905-go/1.1.0"
}
return &Client{apiKey: apiKey, base: u, http: hc, userAgent: ua}, nil
}
func (c *Client) do(ctx context.Context, method, path, organizationID string, body, out any) error {
var reader io.Reader
if body != nil {
payload, err := json.Marshal(body)
if err != nil {
return err
}
reader = bytes.NewReader(payload)
}
rel, err := url.Parse(path)
if err != nil {
return err
}
u := c.base.ResolveReference(rel)
req, err := http.NewRequestWithContext(ctx, method, u.String(), reader)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", c.userAgent)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if organizationID != "" {
req.Header.Set("X-Organization-ID", organizationID)
}
res, err := c.http.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
data, err := io.ReadAll(io.LimitReader(res.Body, 4<<20))
if err != nil {
return err
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
e := &APIError{Status: res.StatusCode, Code: "http_error"}
_ = json.Unmarshal(data, e)
if e.Code == "" {
e.Code = "http_error"
}
if e.RetryAfter == 0 {
e.RetryAfter, _ = strconv.Atoi(res.Header.Get("Retry-After"))
}
return e
}
if out == nil || len(data) == 0 {
return nil
}
if err := json.Unmarshal(data, out); err != nil {
return fmt.Errorf("as213905: decode response: %w", err)
}
return nil
}
func withTrafficQuery(path string, q *TrafficQuery) string {
if q == nil {
return path
}
v := url.Values{}
if q.Days > 0 {
v.Set("days", strconv.Itoa(q.Days))
}
if q.From != "" {
v.Set("from", q.From)
}
if q.To != "" {
v.Set("to", q.To)
}
if len(v) > 0 {
return path + "?" + v.Encode()
}
return path
}
func (c *Client) Organization(ctx context.Context) (Organization, error) {
var r struct {
Organization Organization `json:"organization"`
}
err := c.do(ctx, http.MethodGet, "/api/v1/organization", "", nil, &r)
return r.Organization, err
}
func (c *Client) Invoices(ctx context.Context) ([]Invoice, error) {
var r struct {
Invoices []Invoice `json:"invoices"`
}
err := c.do(ctx, http.MethodGet, "/api/v1/invoices", "", nil, &r)
return r.Invoices, err
}
func (c *Client) Services(ctx context.Context) ([]Service, error) {
var r struct {
Services []Service `json:"services"`
}
err := c.do(ctx, http.MethodGet, "/api/v1/services", "", nil, &r)
return r.Services, err
}
func (c *Client) Service(ctx context.Context, id string) (Service, error) {
var r struct {
Service Service `json:"service"`
}
err := c.do(ctx, http.MethodGet, "/api/v1/services/"+url.PathEscape(id), "", nil, &r)
return r.Service, err
}
func (c *Client) ServiceMetrics(ctx context.Context, id, target, metricRange string) (MetricSeries, error) {
q := url.Values{}
if target != "" {
q.Set("target", target)
}
if metricRange != "" {
q.Set("range", metricRange)
}
path := "/api/v1/services/" + url.PathEscape(id) + "/metrics"
if len(q) > 0 {
path += "?" + q.Encode()
}
var r MetricSeries
err := c.do(ctx, http.MethodGet, path, "", nil, &r)
return r, err
}
func (c *Client) PowerService(ctx context.Context, id, action string) error {
return c.do(ctx, http.MethodPost, "/api/v1/services/"+url.PathEscape(id)+"/power", "", map[string]string{"action": action}, nil)
}
func (c *Client) Traffic(ctx context.Context, q *TrafficQuery) (Traffic, error) {
var r Traffic
err := c.do(ctx, http.MethodGet, withTrafficQuery("/api/v1/traffic", q), "", nil, &r)
return r, err
}
func (c *Client) Tunnels(ctx context.Context) ([]Tunnel, error) {
var r struct {
Tunnels []Tunnel `json:"tunnels"`
}
err := c.do(ctx, http.MethodGet, "/api/v1/tunnels", "", nil, &r)
return r.Tunnels, err
}
func (c *Client) CreateTunnel(ctx context.Context, input CreateTunnelRequest) (Tunnel, error) {
var r struct {
Tunnel Tunnel `json:"tunnel"`
}
err := c.do(ctx, http.MethodPost, "/api/v1/tunnels", "", input, &r)
return r.Tunnel, err
}
func (c *Client) DeleteTunnel(ctx context.Context, id string) error {
return c.do(ctx, http.MethodDelete, "/api/v1/tunnels/"+url.PathEscape(id), "", nil, nil)
}
func (c *Client) TunnelTraffic(ctx context.Context, id string, q *TrafficQuery) (Traffic, error) {
var r Traffic
err := c.do(ctx, http.MethodGet, withTrafficQuery("/api/v1/tunnels/"+url.PathEscape(id)+"/traffic", q), "", nil, &r)
return r, err
}
func (c *Client) Geofeeds(ctx context.Context, org string) (GeofeedList, error) {
var r GeofeedList
err := c.do(ctx, http.MethodGet, "/api/v1/geofeeds", org, nil, &r)
return r, err
}
func (c *Client) CreateGeofeed(ctx context.Context, org, name string) (GeofeedList, error) {
var r GeofeedList
err := c.do(ctx, http.MethodPost, "/api/v1/geofeeds", org, map[string]string{"name": name}, &r)
return r, err
}
func (c *Client) Geofeed(ctx context.Context, org, id string) (Geofeed, error) {
var r struct {
Geofeed Geofeed `json:"geofeed"`
}
err := c.do(ctx, http.MethodGet, "/api/v1/geofeeds/"+url.PathEscape(id), org, nil, &r)
return r.Geofeed, err
}
func (c *Client) RenameGeofeed(ctx context.Context, org, id, name string) error {
return c.do(ctx, http.MethodPatch, "/api/v1/geofeeds/"+url.PathEscape(id), org, map[string]string{"name": name}, nil)
}
func (c *Client) DeleteGeofeed(ctx context.Context, org, id string) error {
return c.do(ctx, http.MethodDelete, "/api/v1/geofeeds/"+url.PathEscape(id), org, nil, nil)
}
func (c *Client) ReplaceGeofeedRecords(ctx context.Context, org, id string, records []GeofeedRecord) error {
return c.do(ctx, http.MethodPut, "/api/v1/geofeeds/"+url.PathEscape(id)+"/records", org, map[string]any{"records": records}, nil)
}
func (c *Client) RotateGeofeedURL(ctx context.Context, org, id string) (GeofeedList, error) {
var r GeofeedList
err := c.do(ctx, http.MethodPost, "/api/v1/geofeeds/"+url.PathEscape(id)+"/rotate", org, nil, &r)
return r, err
}
+44
View File
@@ -0,0 +1,44 @@
package as213905
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
func TestOrganizationAndHeaders(t *testing.T) {
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer secret" {
t.Errorf("authorization = %q", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"organization":{"id":"o","asn":213905,"name":"ISPLABS","status":"active","commit_kbps":50000}}`))
}))
defer s.Close()
c, err := NewWithOptions("secret", Options{BaseURL: s.URL})
if err != nil {
t.Fatal(err)
}
org, err := c.Organization(context.Background())
if err != nil {
t.Fatal(err)
}
if org.ASN != 213905 {
t.Fatalf("asn = %d", org.ASN)
}
}
func TestAPIError(t *testing.T) {
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(403)
_, _ = w.Write([]byte(`{"error":"scope_required","message":"need traffic:read"}`))
}))
defer s.Close()
c, _ := NewWithOptions("secret", Options{BaseURL: s.URL})
_, err := c.Traffic(context.Background(), nil)
e, ok := err.(*APIError)
if !ok || e.Code != "scope_required" || e.Status != 403 {
t.Fatalf("error = %#v", err)
}
}
+111
View File
@@ -0,0 +1,111 @@
package as213905
import "time"
type Organization struct {
ID string `json:"id"`
ASN int64 `json:"asn"`
Name string `json:"name"`
Status string `json:"status"`
CommitKbps int64 `json:"commit_kbps"`
}
type Invoice struct {
ID string `json:"id"`
Number string `json:"number"`
Status string `json:"status"`
Currency string `json:"currency"`
TotalCents int64 `json:"total_cents"`
PeriodFrom *string `json:"period_from,omitempty"`
PeriodTo *string `json:"period_to,omitempty"`
IssuedAt *time.Time `json:"issued_at,omitempty"`
DueAt *time.Time `json:"due_at,omitempty"`
}
type Service struct {
ID string `json:"id"`
Kind string `json:"kind"`
Label string `json:"label"`
Status string `json:"status"`
MonthlyCents int64 `json:"monthly_cents"`
NextDueOn *string `json:"next_due_on,omitempty"`
Controllable bool `json:"controllable,omitempty"`
Location string `json:"location,omitempty"`
Configuration map[string]any `json:"configuration,omitempty"`
}
type MetricPoint struct {
Time time.Time `json:"t"`
Value *float64 `json:"v,omitempty"`
}
type MetricSeries struct {
Metric string `json:"metric"`
Target string `json:"target"`
Range string `json:"range"`
Points []MetricPoint `json:"points"`
}
type Period struct {
From time.Time `json:"from"`
To time.Time `json:"to"`
Label string `json:"label"`
}
type Traffic struct {
P95Kbps int64 `json:"p95_kbps"`
CommitKbps int64 `json:"commit_kbps,omitempty"`
OverCommit bool `json:"over_commit,omitempty"`
TotalBytes int64 `json:"total_bytes"`
TotalGB float64 `json:"total_gb"`
Period Period `json:"period"`
}
type Tunnel struct {
ID string `json:"id"`
Status string `json:"status"`
Label string `json:"label,omitempty"`
Location string `json:"location"`
PeerEndpoint string `json:"peer_endpoint"`
IfName string `json:"ifname,omitempty"`
InnerV4 string `json:"inner_v4,omitempty"`
InnerV6 string `json:"inner_v6,omitempty"`
Announce bool `json:"announce,omitempty"`
}
type TrafficQuery struct {
Days int
From string
To string
}
type CreateTunnelRequest struct {
PeerEndpoint string `json:"peer_endpoint"`
Location string `json:"location"`
Label string `json:"label,omitempty"`
Announce bool `json:"announce,omitempty"`
}
type Geofeed struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
RecordCount int `json:"record_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Records []GeofeedRecord `json:"records,omitempty"`
}
type GeofeedRecord struct {
ID string `json:"id,omitempty"`
Prefix string `json:"prefix"`
CountryCode string `json:"country_code"`
RegionCode string `json:"region_code,omitempty"`
City string `json:"city,omitempty"`
PostalCode string `json:"postal_code,omitempty"`
}
type GeofeedList struct {
Geofeeds []Geofeed `json:"geofeeds"`
Limit int `json:"limit"`
}
+19
View File
@@ -0,0 +1,19 @@
package main
import (
"context"
"fmt"
"log"
"os"
as213905 "git.datacoria.com/Phylex/as213905-sdk/go/as213905"
)
func main() {
client := as213905.New(os.Getenv("AS213905_API_KEY"))
traffic, err := client.Traffic(context.Background(), &as213905.TrafficQuery{Days: 7})
if err != nil {
log.Fatal(err)
}
fmt.Printf("p95: %d kbit/s, volume: %.2f GB\n", traffic.P95Kbps, traffic.TotalGB)
}
+3
View File
@@ -0,0 +1,3 @@
module git.datacoria.com/Phylex/as213905-sdk/go
go 1.22
+14
View File
@@ -0,0 +1,14 @@
# JavaScript / TypeScript SDK
```bash
npm install git+https://git.datacoria.com/Phylex/as213905-sdk.git#javascript
```
```js
import {AS213905} from '@as213905/sdk';
const client = new AS213905({apiKey: process.env.AS213905_API_KEY});
console.log(await client.traffic({days: 7}));
```
The package has no dependencies, works in Node 18+ and modern browsers, and
ships TypeScript declarations.
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@as213905/sdk",
"version": "1.1.0",
"description": "Official AS213905 customer API client",
"type": "module",
"main": "./src/index.js",
"types": "./src/index.d.ts",
"exports": {".": {"types": "./src/index.d.ts", "import": "./src/index.js"}},
"files": ["src", "README.md"],
"scripts": {"test": "node --test", "check": "node --check src/index.js"},
"engines": {"node": ">=18"},
"license": "MIT",
"repository": {"type": "git", "url": "https://git.datacoria.com/Phylex/as213905-sdk.git", "directory": "javascript"}
}
+34
View File
@@ -0,0 +1,34 @@
export interface ClientOptions { apiKey: string; baseUrl?: string; timeout?: number; fetch?: typeof fetch }
export interface RequestOptions { signal?: AbortSignal }
export interface Organization { id: string; asn: number; name: string; status: string; commit_kbps: number }
export interface Invoice { id: string; number: string; status: string; currency: string; total_cents: number; period_from?: string|null; period_to?: string|null; issued_at?: string|null; due_at?: string|null }
export interface Service { id: string; kind: string; label: string; status: string; monthly_cents: number; next_due_on?: string|null; controllable?: boolean; location?: string; configuration?: Record<string, unknown> }
export interface Period { from: string; to: string; label: string }
export interface Traffic { p95_kbps: number; commit_kbps?: number; over_commit?: boolean; total_bytes: number; total_gb: number; period: Period }
export interface Tunnel { id: string; status: string; label?: string; location: string; peer_endpoint: string; ifname?: string; inner_v4?: string; inner_v6?: string; announce?: boolean }
export interface CreateTunnel { peer_endpoint: string; location: string; label?: string; announce?: boolean }
export interface GeofeedRecord { id?: string; prefix: string; country_code: string; region_code?: string; city?: string; postal_code?: string }
export interface Geofeed { id: string; name: string; slug: string; record_count: number; created_at: string; updated_at: string; records?: GeofeedRecord[] }
export interface GeofeedList { geofeeds: Geofeed[]; limit: number }
export class APIError extends Error { status: number; code: string; requiredScope?: string; retryAfter?: number }
export class AS213905 {
constructor(options: ClientOptions);
organization(options?: RequestOptions): Promise<Organization>;
invoices(options?: RequestOptions): Promise<Invoice[]>;
services(options?: RequestOptions): Promise<Service[]>;
service(id: string, options?: RequestOptions): Promise<Service>;
serviceMetrics(id: string, options?: RequestOptions & {target?: string; range?: string}): Promise<{metric:string;target:string;range:string;points:Array<{t:string;v?:number}>}>;
powerService(id: string, action: 'start'|'stop'|'restart', options?: RequestOptions): Promise<{status:string;action:string}>;
traffic(options?: RequestOptions & {days?:number;from?:string;to?:string}): Promise<Traffic>;
tunnels(options?: RequestOptions): Promise<Tunnel[]>;
createTunnel(input: CreateTunnel, options?: RequestOptions): Promise<Tunnel>;
deleteTunnel(id: string, options?: RequestOptions): Promise<unknown>;
tunnelTraffic(id:string, options?:RequestOptions & {days?:number;from?:string;to?:string}):Promise<Traffic & {tunnel:Tunnel}>;
geofeeds(organizationId:string,options?:RequestOptions):Promise<GeofeedList>;
createGeofeed(organizationId:string,name:string,options?:RequestOptions):Promise<GeofeedList>;
geofeed(organizationId:string,id:string,options?:RequestOptions):Promise<Geofeed>;
renameGeofeed(organizationId:string,id:string,name:string,options?:RequestOptions):Promise<unknown>;
deleteGeofeed(organizationId:string,id:string,options?:RequestOptions):Promise<unknown>;
replaceGeofeedRecords(organizationId:string,id:string,records:GeofeedRecord[],options?:RequestOptions):Promise<unknown>;
rotateGeofeedUrl(organizationId:string,id:string,options?:RequestOptions):Promise<GeofeedList>;
}
+49
View File
@@ -0,0 +1,49 @@
export class APIError extends Error {
constructor(status, code, message = '', details = {}) {
super(`AS213905 API error ${status} (${code})${message ? `: ${message}` : ''}`);
this.name = 'APIError'; this.status = status; this.code = code;
this.requiredScope = details.required_scope; this.retryAfter = details.retry_after;
}
}
export class AS213905 {
constructor({apiKey, baseUrl = 'https://as213905.com', timeout = 30000, fetch: fetchImpl = globalThis.fetch} = {}) {
if (!apiKey) throw new TypeError('apiKey is required');
if (typeof fetchImpl !== 'function') throw new TypeError('fetch implementation is required');
this.apiKey = apiKey; this.baseUrl = baseUrl.replace(/\/$/, ''); this.timeout = timeout; this.fetch = fetchImpl;
}
async request(method, path, {body, organizationId, signal} = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(new Error('request timed out')), this.timeout);
if (signal) signal.addEventListener('abort', () => controller.abort(signal.reason), {once: true});
try {
const response = await this.fetch(this.baseUrl + path, {
method, signal: controller.signal,
headers: {Authorization: `Bearer ${this.apiKey}`, Accept: 'application/json', ...(body === undefined ? {} : {'Content-Type': 'application/json'}), ...(organizationId ? {'X-Organization-ID': organizationId} : {})},
body: body === undefined ? undefined : JSON.stringify(body),
});
const value = await response.json().catch(() => ({}));
if (!response.ok) throw new APIError(response.status, value.error || 'http_error', value.message || '', {...value, retry_after: value.retry_after || Number(response.headers.get('Retry-After')) || undefined});
return value;
} finally { clearTimeout(timer); }
}
static query(values) { const q = new URLSearchParams(Object.entries(values).filter(([,v]) => v !== undefined && v !== null)); return q.size ? `?${q}` : ''; }
organization(options) { return this.request('GET', '/api/v1/organization', options).then(v => v.organization); }
invoices(options) { return this.request('GET', '/api/v1/invoices', options).then(v => v.invoices); }
services(options) { return this.request('GET', '/api/v1/services', options).then(v => v.services); }
service(id, options) { return this.request('GET', `/api/v1/services/${encodeURIComponent(id)}`, options).then(v => v.service); }
serviceMetrics(id, {target, range, ...options} = {}) { return this.request('GET', `/api/v1/services/${encodeURIComponent(id)}/metrics${AS213905.query({target, range})}`, options); }
powerService(id, action, options = {}) { return this.request('POST', `/api/v1/services/${encodeURIComponent(id)}/power`, {...options, body: {action}}); }
traffic({days, from, to, ...options} = {}) { return this.request('GET', `/api/v1/traffic${AS213905.query({days, from, to})}`, options); }
tunnels(options) { return this.request('GET', '/api/v1/tunnels', options).then(v => v.tunnels); }
createTunnel(input, options = {}) { return this.request('POST', '/api/v1/tunnels', {...options, body: input}).then(v => v.tunnel); }
deleteTunnel(id, options) { return this.request('DELETE', `/api/v1/tunnels/${encodeURIComponent(id)}`, options); }
tunnelTraffic(id, {days, from, to, ...options} = {}) { return this.request('GET', `/api/v1/tunnels/${encodeURIComponent(id)}/traffic${AS213905.query({days, from, to})}`, options); }
geofeeds(organizationId, options = {}) { return this.request('GET', '/api/v1/geofeeds', {...options, organizationId}); }
createGeofeed(organizationId, name, options = {}) { return this.request('POST', '/api/v1/geofeeds', {...options, organizationId, body: {name}}); }
geofeed(organizationId, id, options = {}) { return this.request('GET', `/api/v1/geofeeds/${encodeURIComponent(id)}`, {...options, organizationId}).then(v => v.geofeed); }
renameGeofeed(organizationId, id, name, options = {}) { return this.request('PATCH', `/api/v1/geofeeds/${encodeURIComponent(id)}`, {...options, organizationId, body: {name}}); }
deleteGeofeed(organizationId, id, options = {}) { return this.request('DELETE', `/api/v1/geofeeds/${encodeURIComponent(id)}`, {...options, organizationId}); }
replaceGeofeedRecords(organizationId, id, records, options = {}) { return this.request('PUT', `/api/v1/geofeeds/${encodeURIComponent(id)}/records`, {...options, organizationId, body: {records}}); }
rotateGeofeedUrl(organizationId, id, options = {}) { return this.request('POST', `/api/v1/geofeeds/${encodeURIComponent(id)}/rotate`, {...options, organizationId}); }
}
+13
View File
@@ -0,0 +1,13 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {AS213905, APIError} from '../src/index.js';
test('sets bearer authentication and parses organization', async () => {
const client = new AS213905({apiKey:'secret',fetch:async (url,init)=>{assert.equal(init.headers.Authorization,'Bearer secret');return new Response(JSON.stringify({organization:{asn:213905}}),{status:200,headers:{'content-type':'application/json'}})}});
assert.equal((await client.organization()).asn,213905);
});
test('throws a structured APIError', async () => {
const client = new AS213905({apiKey:'secret',fetch:async()=>new Response(JSON.stringify({error:'scope_required',message:'need traffic:read'}),{status:403})});
await assert.rejects(client.traffic(),error=>error instanceof APIError&&error.status===403&&error.code==='scope_required');
});
+611
View File
@@ -0,0 +1,611 @@
openapi: 3.1.0
info:
title: AS213905 API
version: 1.1.0
description: |
Customer automation API for organisations, invoices, services, traffic,
tunnels and RFC 8805 geofeeds. Create a scoped key in Panel → Settings.
contact:
name: AS213905 NOC
email: noc@as213905.com
url: https://as213905.com/docs/api
license:
name: Proprietary API; SDKs licensed under MIT
url: https://git.datacoria.com/Phylex/as213905-sdk/src/branch/main/LICENSE
servers:
- url: https://as213905.com
tags:
- name: Organisation
description: The organisation bound to the API key.
- name: Invoices
description: Billing documents and payment state.
- name: Services
description: Customer services, BGP VM metrics and power actions.
- name: Traffic
description: Organisation and per-tunnel traffic accounting.
- name: Tunnels
description: Transit tunnel inventory and lifecycle.
- name: Geofeeds
description: RFC 8805 public geofeed management.
security:
- apiKey: []
paths:
/api/v1/organization:
get:
tags: [Organisation]
summary: Get the API key's organisation
operationId: getOrganization
x-required-scope: organizations:read
responses:
'200':
description: Organisation
content:
application/json:
schema: {$ref: '#/components/schemas/OrganizationResponse'}
'401': {$ref: '#/components/responses/Unauthenticated'}
'403': {$ref: '#/components/responses/Forbidden'}
/api/v1/invoices:
get:
tags: [Invoices]
summary: List invoices, newest first
operationId: listInvoices
x-required-scope: invoices:read
responses:
'200':
description: Invoice list
content:
application/json:
schema:
type: object
required: [invoices]
properties:
invoices: {type: array, items: {$ref: '#/components/schemas/Invoice'}}
'401': {$ref: '#/components/responses/Unauthenticated'}
'403': {$ref: '#/components/responses/Forbidden'}
/api/v1/services:
get:
tags: [Services]
summary: List services
operationId: listServices
x-required-scope: services:read
responses:
'200':
description: Service list
content:
application/json:
schema:
type: object
required: [services]
properties:
services: {type: array, items: {$ref: '#/components/schemas/Service'}}
'401': {$ref: '#/components/responses/Unauthenticated'}
'403': {$ref: '#/components/responses/Forbidden'}
/api/v1/services/{id}:
parameters:
- {$ref: '#/components/parameters/ResourceId'}
get:
tags: [Services]
summary: Get one service
operationId: getService
x-required-scope: services:read
responses:
'200':
description: Service
content:
application/json:
schema:
type: object
required: [service]
properties:
service: {$ref: '#/components/schemas/Service'}
'401': {$ref: '#/components/responses/Unauthenticated'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
/api/v1/services/{id}/metrics:
parameters:
- {$ref: '#/components/parameters/ResourceId'}
get:
tags: [Services]
summary: Read a BGP VM metric series
operationId: getServiceMetrics
x-required-scope: services:read
parameters:
- name: target
in: query
schema:
type: string
enum: [cpu_load, mem_usage_percent, net_rx, net_tx, iops, df.root.used]
default: cpu_load
- name: range
in: query
schema: {type: string, enum: [1h, 6h, 24h, 7d], default: 24h}
responses:
'200':
description: Metric series
content:
application/json:
schema: {$ref: '#/components/schemas/MetricSeries'}
'401': {$ref: '#/components/responses/Unauthenticated'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
/api/v1/services/{id}/power:
parameters:
- {$ref: '#/components/parameters/ResourceId'}
post:
tags: [Services]
summary: Start, stop or restart a BGP VM
operationId: powerService
x-required-scope: services:write
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [action]
properties:
action: {type: string, enum: [start, stop, restart]}
responses:
'202':
description: Command accepted
content:
application/json:
schema:
type: object
required: [status, action]
properties:
status: {type: string, const: accepted}
action: {type: string, enum: [start, stop, restart]}
'401': {$ref: '#/components/responses/Unauthenticated'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
'429': {$ref: '#/components/responses/RateLimited'}
/api/v1/traffic:
get:
tags: [Traffic]
summary: Get organisation transit usage
operationId: getTraffic
x-required-scope: traffic:read
parameters:
- {$ref: '#/components/parameters/Days'}
- {$ref: '#/components/parameters/From'}
- {$ref: '#/components/parameters/To'}
responses:
'200':
description: Traffic summary
content:
application/json:
schema: {$ref: '#/components/schemas/Traffic'}
'400': {$ref: '#/components/responses/BadRequest'}
'401': {$ref: '#/components/responses/Unauthenticated'}
'403': {$ref: '#/components/responses/Forbidden'}
/api/v1/tunnels:
get:
tags: [Tunnels]
summary: List tunnels
operationId: listTunnels
x-required-scope: tunnels:read
responses:
'200':
description: Tunnel list
content:
application/json:
schema:
type: object
required: [tunnels]
properties:
tunnels: {type: array, items: {$ref: '#/components/schemas/Tunnel'}}
'401': {$ref: '#/components/responses/Unauthenticated'}
'403': {$ref: '#/components/responses/Forbidden'}
post:
tags: [Tunnels]
summary: Request a transit tunnel
operationId: createTunnel
x-required-scope: tunnels:write
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/CreateTunnelRequest'}
responses:
'202':
description: Tunnel recorded for provisioning
content:
application/json:
schema:
type: object
required: [tunnel]
properties:
tunnel: {$ref: '#/components/schemas/Tunnel'}
'400': {$ref: '#/components/responses/BadRequest'}
'401': {$ref: '#/components/responses/Unauthenticated'}
'403': {$ref: '#/components/responses/Forbidden'}
/api/v1/tunnels/{id}:
parameters:
- {$ref: '#/components/parameters/ResourceId'}
delete:
tags: [Tunnels]
summary: Remove a tunnel asynchronously
operationId: deleteTunnel
x-required-scope: tunnels:write
responses:
'202':
description: Removal accepted
content:
application/json:
schema: {$ref: '#/components/schemas/StatusResponse'}
'401': {$ref: '#/components/responses/Unauthenticated'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
/api/v1/tunnels/{id}/traffic:
parameters:
- {$ref: '#/components/parameters/ResourceId'}
get:
tags: [Traffic, Tunnels]
summary: Get one tunnel's usage
operationId: getTunnelTraffic
x-required-scope: traffic:read
parameters:
- {$ref: '#/components/parameters/Days'}
- {$ref: '#/components/parameters/From'}
- {$ref: '#/components/parameters/To'}
responses:
'200':
description: Tunnel traffic
content:
application/json:
schema: {$ref: '#/components/schemas/TunnelTraffic'}
'400': {$ref: '#/components/responses/BadRequest'}
'401': {$ref: '#/components/responses/Unauthenticated'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
/api/v1/geofeeds:
parameters:
- {$ref: '#/components/parameters/OrganizationId'}
get:
tags: [Geofeeds]
summary: List geofeeds
operationId: listGeofeeds
x-required-scope: geofeeds:read
responses:
'200':
description: Geofeed list
content:
application/json:
schema: {$ref: '#/components/schemas/GeofeedList'}
'401': {$ref: '#/components/responses/Unauthenticated'}
'403': {$ref: '#/components/responses/Forbidden'}
post:
tags: [Geofeeds]
summary: Create a geofeed (maximum two per organisation)
operationId: createGeofeed
x-required-scope: geofeeds:write
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name]
properties:
name: {type: string, minLength: 1, maxLength: 100}
responses:
'201':
description: Created
content:
application/json:
schema: {$ref: '#/components/schemas/GeofeedList'}
'400': {$ref: '#/components/responses/BadRequest'}
'401': {$ref: '#/components/responses/Unauthenticated'}
'403': {$ref: '#/components/responses/Forbidden'}
'409': {$ref: '#/components/responses/Conflict'}
/api/v1/geofeeds/{id}:
parameters:
- {$ref: '#/components/parameters/OrganizationId'}
- {$ref: '#/components/parameters/ResourceId'}
get:
tags: [Geofeeds]
summary: Get a geofeed and its records
operationId: getGeofeed
x-required-scope: geofeeds:read
responses:
'200':
description: Geofeed
content:
application/json:
schema:
type: object
required: [geofeed]
properties:
geofeed: {$ref: '#/components/schemas/GeofeedWithRecords'}
'401': {$ref: '#/components/responses/Unauthenticated'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
patch:
tags: [Geofeeds]
summary: Rename a geofeed
operationId: updateGeofeed
x-required-scope: geofeeds:write
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name]
properties:
name: {type: string, minLength: 1, maxLength: 100}
responses:
'200':
description: Updated
content:
application/json:
schema: {$ref: '#/components/schemas/StatusResponse'}
'400': {$ref: '#/components/responses/BadRequest'}
'401': {$ref: '#/components/responses/Unauthenticated'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
delete:
tags: [Geofeeds]
summary: Delete a geofeed
operationId: deleteGeofeed
x-required-scope: geofeeds:write
responses:
'200':
description: Deleted
content:
application/json:
schema: {$ref: '#/components/schemas/StatusResponse'}
'401': {$ref: '#/components/responses/Unauthenticated'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
/api/v1/geofeeds/{id}/records:
parameters:
- {$ref: '#/components/parameters/OrganizationId'}
- {$ref: '#/components/parameters/ResourceId'}
put:
tags: [Geofeeds]
summary: Atomically replace all RFC 8805 records
operationId: replaceGeofeedRecords
x-required-scope: geofeeds:write
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [records]
properties:
records:
type: array
maxItems: 1000
items: {$ref: '#/components/schemas/GeofeedRecordInput'}
responses:
'200':
description: Saved
content:
application/json:
schema: {$ref: '#/components/schemas/StatusResponse'}
'400': {$ref: '#/components/responses/BadRequest'}
'401': {$ref: '#/components/responses/Unauthenticated'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
/api/v1/geofeeds/{id}/rotate:
parameters:
- {$ref: '#/components/parameters/OrganizationId'}
- {$ref: '#/components/parameters/ResourceId'}
post:
tags: [Geofeeds]
summary: Rotate the unguessable public CSV URL
operationId: rotateGeofeedUrl
x-required-scope: geofeeds:write
responses:
'200':
description: Rotated
content:
application/json:
schema: {$ref: '#/components/schemas/GeofeedList'}
'401': {$ref: '#/components/responses/Unauthenticated'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
components:
securitySchemes:
apiKey:
type: http
scheme: bearer
bearerFormat: as213905_…
description: API key from Panel → Settings. Query-string keys are rejected.
parameters:
ResourceId:
name: id
in: path
required: true
schema: {type: string, format: uuid}
OrganizationId:
name: X-Organization-ID
in: header
required: true
description: Organisation UUID. An API key can only address its own organisation.
schema: {type: string, format: uuid}
Days:
name: days
in: query
description: Last N days; mutually exclusive with from/to.
schema: {type: integer, minimum: 1, maximum: 366}
From:
name: from
in: query
schema: {type: string, format: date}
To:
name: to
in: query
description: Inclusive whole-day end.
schema: {type: string, format: date}
responses:
BadRequest:
description: Invalid request
content: {application/json: {schema: {$ref: '#/components/schemas/APIError'}}}
Unauthenticated:
description: Missing, malformed, expired, revoked or unknown API key
content: {application/json: {schema: {$ref: '#/components/schemas/APIError'}}}
Forbidden:
description: Missing scope or disallowed source address
content: {application/json: {schema: {$ref: '#/components/schemas/APIError'}}}
NotFound:
description: Resource not found
content: {application/json: {schema: {$ref: '#/components/schemas/APIError'}}}
Conflict:
description: Resource limit or state conflict
content: {application/json: {schema: {$ref: '#/components/schemas/APIError'}}}
RateLimited:
description: Rate limit exceeded
headers:
Retry-After: {schema: {type: integer}}
content: {application/json: {schema: {$ref: '#/components/schemas/APIError'}}}
schemas:
APIError:
type: object
required: [error]
properties:
error: {type: string, examples: [scope_required]}
message: {type: string}
required_scope: {type: string}
retry_after: {type: integer}
StatusResponse:
type: object
required: [status]
properties: {status: {type: string}}
OrganizationResponse:
type: object
required: [organization]
properties: {organization: {$ref: '#/components/schemas/Organization'}}
Organization:
type: object
required: [id, asn, name, status, commit_kbps]
properties:
id: {type: string, format: uuid}
asn: {type: integer, minimum: 1, maximum: 4294967295}
name: {type: string}
status: {type: string}
commit_kbps: {type: integer, format: int64}
Invoice:
type: object
required: [id, number, status, currency, total_cents]
properties:
id: {type: string, format: uuid}
number: {type: string}
status: {type: string, enum: [draft, issued, paid, overdue, cancelled]}
currency: {type: string, minLength: 3, maxLength: 3}
total_cents: {type: integer, format: int64}
period_from: {type: [string, 'null'], format: date}
period_to: {type: [string, 'null'], format: date}
issued_at: {type: [string, 'null'], format: date-time}
due_at: {type: [string, 'null'], format: date-time}
Service:
type: object
required: [id, kind, label, status, monthly_cents]
properties:
id: {type: string, format: uuid}
kind: {type: string}
label: {type: string}
status: {type: string, enum: [awaiting_payment, provisioning, active, suspended, terminating, terminated]}
monthly_cents: {type: integer, format: int64}
next_due_on: {type: [string, 'null'], format: date}
controllable: {type: boolean}
location: {type: string}
configuration: {type: object, additionalProperties: true}
MetricPoint:
type: object
required: [t]
properties:
t: {type: string, format: date-time}
v: {type: number}
MetricSeries:
type: object
required: [metric, target, range, points]
properties:
metric: {type: string}
target: {type: string}
range: {type: string}
points: {type: array, items: {$ref: '#/components/schemas/MetricPoint'}}
Period:
type: object
required: [from, to, label]
properties:
from: {type: string, format: date-time}
to: {type: string, format: date-time}
label: {type: string}
Traffic:
type: object
required: [p95_kbps, total_bytes, total_gb, period]
properties:
p95_kbps: {type: integer, format: int64}
commit_kbps: {type: integer, format: int64}
over_commit: {type: boolean}
total_bytes: {type: integer, format: int64}
total_gb: {type: number}
period: {$ref: '#/components/schemas/Period'}
Tunnel:
type: object
required: [id, status, location, peer_endpoint]
properties:
id: {type: string, format: uuid}
status: {type: string, enum: [provisioning, configuring, awaiting_peer, active, error, suspended, removed]}
label: {type: string}
location: {type: string, examples: [fra]}
peer_endpoint: {type: string, format: ip}
ifname: {type: string}
inner_v4: {type: string}
inner_v6: {type: string}
announce: {type: boolean}
CreateTunnelRequest:
type: object
required: [peer_endpoint, location]
properties:
peer_endpoint: {type: string, format: ip}
location: {type: string, examples: [fra]}
label: {type: string, maxLength: 100}
announce: {type: boolean, default: false}
TunnelTraffic:
allOf:
- {$ref: '#/components/schemas/Traffic'}
- type: object
required: [tunnel]
properties: {tunnel: {$ref: '#/components/schemas/Tunnel'}}
Geofeed:
type: object
required: [id, name, slug, record_count, created_at, updated_at]
properties:
id: {type: string, format: uuid}
name: {type: string}
slug: {type: string}
record_count: {type: integer, minimum: 0}
created_at: {type: string, format: date-time}
updated_at: {type: string, format: date-time}
GeofeedRecordInput:
type: object
required: [prefix, country_code]
properties:
prefix: {type: string, examples: [203.0.113.0/24]}
country_code: {type: string, pattern: '^[A-Z]{2}$', examples: [DE]}
region_code: {type: string, maxLength: 128, examples: [DE-HE]}
city: {type: string, maxLength: 128, examples: [Frankfurt am Main]}
postal_code: {type: string, maxLength: 128}
GeofeedRecord:
allOf:
- {$ref: '#/components/schemas/GeofeedRecordInput'}
- type: object
properties: {id: {type: string, format: uuid}}
GeofeedWithRecords:
allOf:
- {$ref: '#/components/schemas/Geofeed'}
- type: object
required: [records]
properties:
records: {type: array, items: {$ref: '#/components/schemas/GeofeedRecord'}}
GeofeedList:
type: object
required: [geofeeds, limit]
properties:
geofeeds: {type: array, items: {$ref: '#/components/schemas/Geofeed'}}
limit: {type: integer, const: 2}
+13
View File
@@ -0,0 +1,13 @@
# Python SDK
```bash
pip install "as213905 @ git+https://git.datacoria.com/Phylex/as213905-sdk.git#subdirectory=python"
```
```python
from as213905 import AS213905
client = AS213905("as213905_…")
print(client.traffic(days=7))
```
The package has no runtime dependencies and uses the Python standard library.
+20
View File
@@ -0,0 +1,20 @@
[build-system]
requires = ["hatchling>=1.24"]
build-backend = "hatchling.build"
[project]
name = "as213905"
version = "1.1.0"
description = "Official AS213905 customer API client"
readme = "README.md"
requires-python = ">=3.9"
license = {text = "MIT"}
authors = [{name = "AS213905", email = "noc@as213905.com"}]
keywords = ["as213905", "bgp", "transit", "geofeed"]
[project.urls]
Documentation = "https://as213905.com/docs/api/reference"
Repository = "https://git.datacoria.com/Phylex/as213905-sdk"
[tool.pytest.ini_options]
testpaths = ["tests"]
+4
View File
@@ -0,0 +1,4 @@
from .client import AS213905, APIError
__all__ = ["AS213905", "APIError"]
__version__ = "1.1.0"
+99
View File
@@ -0,0 +1,99 @@
"""Typed, dependency-free AS213905 API client."""
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any, Iterable, Mapping, Optional
from urllib.error import HTTPError, URLError
from urllib.parse import quote, urlencode
from urllib.request import Request, urlopen
@dataclass
class APIError(Exception):
status: int
code: str
message: str = ""
required_scope: Optional[str] = None
retry_after: Optional[int] = None
def __str__(self) -> str:
detail = f": {self.message}" if self.message else ""
return f"AS213905 API error {self.status} ({self.code}){detail}"
class AS213905:
def __init__(self, api_key: str, *, base_url: str = "https://as213905.com", timeout: float = 30.0) -> None:
if not api_key.strip():
raise ValueError("api_key is required")
self.api_key = api_key
self.base_url = base_url.rstrip("/")
self.timeout = timeout
def _request(self, method: str, path: str, *, organization_id: Optional[str] = None, body: Any = None) -> Any:
payload = None if body is None else json.dumps(body).encode()
headers = {"Authorization": f"Bearer {self.api_key}", "Accept": "application/json", "User-Agent": "as213905-python/1.1.0"}
if payload is not None:
headers["Content-Type"] = "application/json"
if organization_id:
headers["X-Organization-ID"] = organization_id
request = Request(self.base_url + path, data=payload, headers=headers, method=method)
try:
with urlopen(request, timeout=self.timeout) as response:
raw = response.read()
except HTTPError as exc:
try:
value = json.loads(exc.read())
except (ValueError, UnicodeDecodeError):
value = {}
retry = value.get("retry_after") or exc.headers.get("Retry-After")
raise APIError(exc.code, value.get("error", "http_error"), value.get("message", ""), value.get("required_scope"), int(retry) if retry else None) from None
except URLError as exc:
raise APIError(0, "network_error", str(exc.reason)) from exc
return json.loads(raw) if raw else None
@staticmethod
def _traffic_query(days: Optional[int], from_date: Optional[str], to_date: Optional[str]) -> str:
query = {key: value for key, value in (("days", days), ("from", from_date), ("to", to_date)) if value is not None}
return "?" + urlencode(query) if query else ""
def organization(self) -> Mapping[str, Any]:
return self._request("GET", "/api/v1/organization")["organization"]
def invoices(self) -> list[Mapping[str, Any]]:
return self._request("GET", "/api/v1/invoices")["invoices"]
def services(self) -> list[Mapping[str, Any]]:
return self._request("GET", "/api/v1/services")["services"]
def service(self, service_id: str) -> Mapping[str, Any]:
return self._request("GET", f"/api/v1/services/{quote(service_id, safe='')}" )["service"]
def service_metrics(self, service_id: str, *, target: Optional[str] = None, range: Optional[str] = None) -> Mapping[str, Any]:
query = urlencode({k: v for k, v in (("target", target), ("range", range)) if v is not None})
return self._request("GET", f"/api/v1/services/{quote(service_id, safe='')}/metrics" + ("?" + query if query else ""))
def power_service(self, service_id: str, action: str) -> Mapping[str, Any]:
return self._request("POST", f"/api/v1/services/{quote(service_id, safe='')}/power", body={"action": action})
def traffic(self, *, days: Optional[int] = None, from_date: Optional[str] = None, to_date: Optional[str] = None) -> Mapping[str, Any]:
return self._request("GET", "/api/v1/traffic" + self._traffic_query(days, from_date, to_date))
def tunnels(self) -> list[Mapping[str, Any]]:
return self._request("GET", "/api/v1/tunnels")["tunnels"]
def create_tunnel(self, peer_endpoint: str, location: str, *, label: Optional[str] = None, announce: bool = False) -> Mapping[str, Any]:
body = {"peer_endpoint": peer_endpoint, "location": location, "announce": announce}
if label is not None: body["label"] = label
return self._request("POST", "/api/v1/tunnels", body=body)["tunnel"]
def delete_tunnel(self, tunnel_id: str) -> None:
self._request("DELETE", f"/api/v1/tunnels/{quote(tunnel_id, safe='')}")
def tunnel_traffic(self, tunnel_id: str, *, days: Optional[int] = None, from_date: Optional[str] = None, to_date: Optional[str] = None) -> Mapping[str, Any]:
return self._request("GET", f"/api/v1/tunnels/{quote(tunnel_id, safe='')}/traffic" + self._traffic_query(days, from_date, to_date))
def geofeeds(self, organization_id: str) -> Mapping[str, Any]:
return self._request("GET", "/api/v1/geofeeds", organization_id=organization_id)
def create_geofeed(self, organization_id: str, name: str) -> Mapping[str, Any]:
return self._request("POST", "/api/v1/geofeeds", organization_id=organization_id, body={"name": name})
def geofeed(self, organization_id: str, geofeed_id: str) -> Mapping[str, Any]:
return self._request("GET", f"/api/v1/geofeeds/{quote(geofeed_id, safe='')}", organization_id=organization_id)["geofeed"]
def rename_geofeed(self, organization_id: str, geofeed_id: str, name: str) -> None:
self._request("PATCH", f"/api/v1/geofeeds/{quote(geofeed_id, safe='')}", organization_id=organization_id, body={"name": name})
def delete_geofeed(self, organization_id: str, geofeed_id: str) -> None:
self._request("DELETE", f"/api/v1/geofeeds/{quote(geofeed_id, safe='')}", organization_id=organization_id)
def replace_geofeed_records(self, organization_id: str, geofeed_id: str, records: Iterable[Mapping[str, Any]]) -> None:
self._request("PUT", f"/api/v1/geofeeds/{quote(geofeed_id, safe='')}/records", organization_id=organization_id, body={"records": list(records)})
def rotate_geofeed_url(self, organization_id: str, geofeed_id: str) -> Mapping[str, Any]:
return self._request("POST", f"/api/v1/geofeeds/{quote(geofeed_id, safe='')}/rotate", organization_id=organization_id)
+28
View File
@@ -0,0 +1,28 @@
import io
import json
import unittest
from unittest.mock import patch
from as213905 import AS213905, APIError
class Response(io.BytesIO):
def __enter__(self): return self
def __exit__(self, *args): return False
class ClientTests(unittest.TestCase):
def test_organization_sets_bearer_header(self):
def open_(request, timeout):
self.assertEqual(request.get_header("Authorization"), "Bearer secret")
return Response(json.dumps({"organization": {"asn": 213905}}).encode())
with patch("as213905.client.urlopen", open_):
self.assertEqual(AS213905("secret").organization()["asn"], 213905)
def test_traffic_query(self):
client = AS213905("secret")
self.assertEqual(client._traffic_query(7, None, None), "?days=7")
if __name__ == "__main__":
unittest.main()
+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,
}