Initial AS213905 SDK release
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
.DS_Store
|
||||
.env
|
||||
target/
|
||||
dist/
|
||||
node_modules/
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
*.egg-info/
|
||||
coverage/
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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>
|
||||
Generated
+28
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"private": true,
|
||||
"dependencies": {"swagger-ui-dist": "5.27.1"}
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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"}
|
||||
}
|
||||
Vendored
+34
@@ -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>;
|
||||
}
|
||||
@@ -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}); }
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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}
|
||||
@@ -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.
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,4 @@
|
||||
from .client import AS213905, APIError
|
||||
|
||||
__all__ = ["AS213905", "APIError"]
|
||||
__version__ = "1.1.0"
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
Generated
+1277
File diff suppressed because it is too large
Load Diff
@@ -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"] }
|
||||
@@ -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.
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user