Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

## [Unreleased]

### Added

- `client` package: `ResolveVirtualNameServer` resolves a zone's `virtualNameServer` from its origin via zone search, returning `ErrZoneNotFound` when no zone matches and `ErrAmbiguousZone` when more than one zone shares the origin. Search results are filtered to an exact origin match before the count checks, so a server whose `name EQUAL` search matched loosely cannot make resolution wrongly ambiguous or resolve to the wrong zone.
- `client` package: `UpdateRecords(ctx, origin, virtualNameServer, adds, rems)` applies record additions and removals to one specific zone via `PATCH /zone/{origin}/{virtualNameServer}`, so an origin with more than one zone can be managed. `models.ZonePatchRequest` is its request body.

### Removed

- `client` package: `StreamRecords` and `models.RecordStreamRequest` (breaking). The origin-only `POST /zone/{origin}/_stream` endpoint cannot target one of several zones sharing an origin; `UpdateRecords` over `PATCH /zone/{origin}/{virtualNameServer}` replaces it. `fakeautodns` drops the `_stream` route accordingly.

## [0.0.2] - 2026-07-20

### Fixed

- `StreamRecords` now posts to `/zone/{origin}/_stream`: the real API keys the stream endpoint by origin alone and returns 404 for the previously used `/zone/{origin}/{virtualNameServer}/_stream` path (verified against api.autodns.com on 2026-07-20). The unused `virtualNameServer` parameter is dropped from the signature (breaking), and `fakeautodns` serves the corrected route.

## [0.0.1] - 2026-07-17

### Added

- `client` package: `New` with functional options (`WithEndpoint`, `WithHTTPClient`, `WithMaxRetries`, `WithRetryWait`), HTTP Basic Auth plus `X-Domainrobot-Context` header, and retries for 429/500/502/503/504 with exponential backoff, jitter, and `Retry-After` support (seconds or HTTP-date form, honored on any retried response).
Expand Down
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,16 @@ func main() {
}
fmt.Println(zone.Origin, len(zone.ResourceRecords))

// Atomically add and remove records. The AutoDNS API has no
// per-record endpoints; _stream is the record mutation primitive,
// keyed by origin alone.
err = apiClient.StreamRecords(
// Atomically add and remove records on one zone. The AutoDNS API has
// no per-record endpoints; UpdateRecords is the record mutation
// primitive, keyed by origin and virtualNameServer together, since
// an origin can have more than one zone. Pass a literal
// virtualNameServer if you already know it, or resolve it first
// with ResolveVirtualNameServer(ctx, origin).
err = apiClient.UpdateRecords(
ctx,
"example.com",
"a.ns.example.net",
[]models.ResourceRecord{
{
Name: "www",
Expand Down Expand Up @@ -90,6 +94,7 @@ Construction and lookup misses use sentinels, matched with `errors.Is`:

- `client.ErrInvalidConfig` - `New` called with an empty username, password, or context.
- `client.ErrZoneNotFound` - the API reported the requested zone does not exist.
- `client.ErrAmbiguousZone` - `ResolveVirtualNameServer` found more than one zone sharing the given origin.

API-level failures use typed pointer errors, matched with `errors.As`:

Expand Down
6 changes: 6 additions & 0 deletions client/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ var (
// ErrZoneNotFound is returned by zone operations when the API
// reports that the requested zone does not exist.
ErrZoneNotFound = errors.New("autodns: zone not found")

// ErrAmbiguousZone is returned by ResolveVirtualNameServer when more than
// one zone shares the given origin, so the origin alone does not identify
// a single zone. Callers that can accept a virtual name server from their
// own configuration should ask for one instead of resolving.
ErrAmbiguousZone = errors.New("zone origin is ambiguous")
)

// APIError is returned when the AutoDNS response envelope reports
Expand Down
3 changes: 2 additions & 1 deletion client/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,10 @@ func TestIntegrationZoneLifecycle_Happy(t *testing.T) {
t.Cleanup(func() { _ = apiClient.DeleteZone(ctx, origin, virtualNameServer) })
require.Equal(t, origin, created.Origin)

err = apiClient.StreamRecords(
err = apiClient.UpdateRecords(
ctx,
origin,
virtualNameServer,
[]models.ResourceRecord{
{
Name: "www",
Expand Down
22 changes: 10 additions & 12 deletions client/record.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,20 @@ import (
"context"
"fmt"
"net/http"
"net/url"

"github.com/alserda-nl/go-autodns/models"
)

// StreamRecords atomically applies record additions and removals to a
// zone via the _stream endpoint - the AutoDNS API has no per-record
// endpoints. Unlike the other zone routes, the endpoint is keyed by
// origin alone (POST /zone/{origin}/_stream): the real API returns 404
// for a path carrying a virtualNameServer segment. It returns an error
// matching ErrZoneNotFound when the zone does not exist.
func (c *Client) StreamRecords(ctx context.Context, origin string, adds, rems []models.ResourceRecord) error {
path := "/zone/" + url.PathEscape(origin) + "/_stream"
requestBody := models.RecordStreamRequest{Adds: adds, Rems: rems}
if _, err := c.doRequest(ctx, http.MethodPost, path, requestBody); err != nil {
return fmt.Errorf("streaming records: %w", err)
// UpdateRecords atomically applies record additions and removals to one
// zone via PATCH /zone/{origin}/{virtualNameServer}. AutoDNS has no
// per-record endpoints; this is the vns-targeted incremental route,
// letting callers address one of several zones sharing an origin by
// passing the specific virtualNameServer. It returns an error matching
// ErrZoneNotFound when the zone does not exist.
func (c *Client) UpdateRecords(ctx context.Context, origin, virtualNameServer string, adds, rems []models.ResourceRecord) error {
requestBody := models.ZonePatchRequest{ResourceRecordsAdd: adds, ResourceRecordsRem: rems}
if _, err := c.doRequest(ctx, http.MethodPatch, zonePath(origin, virtualNameServer), requestBody); err != nil {
return fmt.Errorf("updating records: %w", err)
}

return nil
Expand Down
19 changes: 10 additions & 9 deletions client/record_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ import (
"github.com/alserda-nl/go-autodns/models"
)

// TestStreamRecords_Happy_PostsOriginOnlyPath pins the production route:
// the real API's stream endpoint is POST /zone/{origin}/_stream, without
// the virtualNameServer segment the other zone routes carry. Verified
// against api.autodns.com on 2026-07-20; the vns-carrying variant
// returns 404 there.
func TestStreamRecords_Happy_PostsOriginOnlyPath(t *testing.T) {
// TestUpdateRecords_Happy_PatchesVNSPath pins the production route: the
// real API applies record adds and removals to one specific zone via
// PATCH /zone/{origin}/{virtualNameServer} - the vns segment is
// load-bearing here, since it is what lets the call target one of
// several zones sharing an origin.
func TestUpdateRecords_Happy_PatchesVNSPath(t *testing.T) {
var gotMethod, gotPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMethod, gotPath = r.Method, r.URL.Path
Expand All @@ -28,9 +28,10 @@ func TestStreamRecords_Happy_PostsOriginOnlyPath(t *testing.T) {
apiClient, err := client.New("user", "secret", "4", client.WithEndpoint(server.URL))
require.NoError(t, err)

err = apiClient.StreamRecords(
err = apiClient.UpdateRecords(
context.Background(),
"example.com",
"a.ns.example.net",
[]models.ResourceRecord{
{
Name: "www",
Expand All @@ -43,6 +44,6 @@ func TestStreamRecords_Happy_PostsOriginOnlyPath(t *testing.T) {
)

require.NoError(t, err)
assert.Equal(t, http.MethodPost, gotMethod)
assert.Equal(t, "/zone/example.com/_stream", gotPath)
assert.Equal(t, http.MethodPatch, gotMethod)
assert.Equal(t, "/zone/example.com/a.ns.example.net", gotPath)
}
50 changes: 50 additions & 0 deletions client/zone.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"fmt"
"net/http"
"net/url"
"slices"
"strings"

"github.com/alserda-nl/go-autodns/models"
)
Expand Down Expand Up @@ -102,6 +104,54 @@ func (c *Client) DeleteZone(ctx context.Context, origin, virtualNameServer strin
return nil
}

// ResolveVirtualNameServer returns the virtualNameServer of the zone
// with the given origin, found via zone search. It returns an error
// matching ErrZoneNotFound when no zone with that origin exists, and an
// error matching ErrAmbiguousZone, naming the candidate virtual name
// servers, when more than one zone matches: zone identity is
// origin+virtualNameServer, so more than one zone sharing an origin is
// genuinely ambiguous, not a pick-first guess.
//
// Search results are filtered to an exact origin match before the count
// checks, which makes resolution independent of how the server
// interprets the name search: whether it matches loosely (e.g.
// returning subdomains of origin) or only exactly, only exact-origin
// results ever reach the count checks.
func (c *Client) ResolveVirtualNameServer(ctx context.Context, origin string) (string, error) {
zones, err := c.SearchZones(ctx, []models.Filter{
{
Key: "name",
Value: origin,
Operator: "EQUAL",
},
})
if err != nil {
return "", fmt.Errorf("resolving virtual name server for %s: %w", origin, err)
}

matched := make([]models.Zone, 0, len(zones))
for _, zone := range zones {
if zone.Origin == origin {
matched = append(matched, zone)
}
}

if len(matched) == 0 {
return "", fmt.Errorf("resolving virtual name server for %s: %w", origin, ErrZoneNotFound)
}
if len(matched) > 1 {
servers := make([]string, 0, len(matched))
for _, zone := range matched {
servers = append(servers, zone.VirtualNameServer)
}
slices.Sort(servers)

return "", fmt.Errorf("resolving virtual name server for %s: %w (%d zones share this origin: %s)", origin, ErrAmbiguousZone, len(matched), strings.Join(servers, ", "))
}

return matched[0].VirtualNameServer, nil
}

// zonePath builds the /zone/{origin}/{virtualNameServer} path with both
// segments escaped.
func zonePath(origin, virtualNameServer string) string {
Expand Down
83 changes: 83 additions & 0 deletions client/zone_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package client_test

import (
"context"
"net/http"
"net/http/httptest"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/alserda-nl/go-autodns/client"
)

// TestResolveVirtualNameServer_FiltersToExactOrigin drives the defensive
// origin filter that fakeautodns (which matches EQUAL exactly) cannot
// exercise: a search server that matched loosely could return a
// near-miss zone whose origin is not the searched origin. The filter
// must drop those before the count checks, so a lone exact match still
// resolves, a near miss alongside it does not make resolution
// ambiguous, and two zones that genuinely share the searched origin
// (distinguished only by virtualNameServer) are reported as ambiguous
// rather than resolved to either one.
func TestResolveVirtualNameServer_FiltersToExactOrigin(t *testing.T) {
cases := []struct {
name string
origin string
searchData string
expectedVNS string
expectedErrorSubstring string // "" means no error expected
expectedSentinel error // nil means no sentinel to check
}{
{
name: "exact match alongside a near miss resolves to the exact match",
origin: "example.com",
searchData: `[{"origin":"example.com","virtualNameServer":"a.ns.example.net"},{"origin":"www.example.com","virtualNameServer":"b.ns.example.net"}]`,
expectedVNS: "a.ns.example.net",
expectedErrorSubstring: "",
expectedSentinel: nil,
},
{
name: "only a near miss returned resolves to not found",
origin: "example.com",
searchData: `[{"origin":"www.example.com","virtualNameServer":"b.ns.example.net"}]`,
expectedVNS: "",
expectedErrorSubstring: "zone not found",
expectedSentinel: client.ErrZoneNotFound,
},
{
name: "two zones share the same origin is ambiguous",
origin: "example.com",
searchData: `[{"origin":"example.com","virtualNameServer":"a.ns.example.net"},{"origin":"example.com","virtualNameServer":"b.ns.example.net"}]`,
expectedVNS: "",
expectedErrorSubstring: "b.ns.example.net",
expectedSentinel: client.ErrAmbiguousZone,
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"status":{"type":"SUCCESS"},"data":` + tc.searchData + `}`))
}))
t.Cleanup(server.Close)
apiClient, err := client.New("user", "secret", "4", client.WithEndpoint(server.URL))
require.NoError(t, err)

actualVNS, err := apiClient.ResolveVirtualNameServer(context.Background(), tc.origin)

if tc.expectedErrorSubstring != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tc.expectedErrorSubstring)
if tc.expectedSentinel != nil {
require.ErrorIs(t, err, tc.expectedSentinel)
}
return
}

require.NoError(t, err)
assert.Equal(t, tc.expectedVNS, actualVNS)
})
}
}
2 changes: 1 addition & 1 deletion fakeautodns/fakeautodns.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ func New(t *testing.T) *Server {
mux.HandleFunc("GET /zone/{origin}/{virtualNameServer}", server.handleGetZone)
mux.HandleFunc("PUT /zone/{origin}/{virtualNameServer}", server.handleUpdateZone)
mux.HandleFunc("DELETE /zone/{origin}/{virtualNameServer}", server.handleDeleteZone)
mux.HandleFunc("POST /zone/{origin}/_stream", server.handleStreamRecords)
mux.HandleFunc("PATCH /zone/{origin}/{virtualNameServer}", server.handleUpdateRecords)

server.Server = httptest.NewServer(server.intercept(mux))
t.Cleanup(server.Close)
Expand Down
31 changes: 12 additions & 19 deletions fakeautodns/fakeautodns_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,31 +145,24 @@ func TestReset_Happy(t *testing.T) {
assert.Equal(t, http.StatusNotFound, statusCode)
}

func TestStreamEndpoint_Happy_MutatesState(t *testing.T) {
func TestUpdateRecordsEndpoint_Happy_TargetsOneZoneOfSharedOrigin(t *testing.T) {
fake := fakeautodns.New(t)
fake.SeedZone(models.Zone{
Origin: "example.com",
VirtualNameServer: "a.ns.example.net",
ResourceRecords: []models.ResourceRecord{
{
Name: "old",
Type: "A",
Value: "192.0.2.99",
TTL: 600,
},
},
})
fake.SeedZone(models.Zone{Origin: "example.com", VirtualNameServer: "a.ns.example.net"})
fake.SeedZone(models.Zone{Origin: "example.com", VirtualNameServer: "b.ns.example.net"})

statusCode, _ := doJSON(t, http.MethodPost, fake.URL+"/zone/example.com/_stream", `{
"adds": [{"name": "www", "type": "A", "value": "192.0.2.10", "ttl": 600}],
"rems": [{"name": "old", "type": "A", "value": "192.0.2.99", "ttl": 600}]
statusCode, _ := doJSON(t, http.MethodPatch, fake.URL+"/zone/example.com/a.ns.example.net", `{
"resourceRecordsAdd": [{"name": "www", "type": "A", "value": "192.0.2.10", "ttl": 600}]
}`)

assert.Equal(t, http.StatusOK, statusCode)
zone, ok := fake.Zone("example.com", "a.ns.example.net")
targeted, ok := fake.Zone("example.com", "a.ns.example.net")
require.True(t, ok)
require.Len(t, targeted.ResourceRecords, 1)
assert.Equal(t, "www", targeted.ResourceRecords[0].Name)

other, ok := fake.Zone("example.com", "b.ns.example.net")
require.True(t, ok)
require.Len(t, zone.ResourceRecords, 1)
assert.Equal(t, "www", zone.ResourceRecords[0].Name)
assert.Empty(t, other.ResourceRecords)
}

func TestSearchEndpoint_Happy_FiltersByName(t *testing.T) {
Expand Down
Loading
Loading