Skip to content

Commit

Permalink
Search records when id is not provided (#3)
Browse files Browse the repository at this point in the history
* update example and add example to _example/
* update libdns
* major rewrite:
- clear separation of provider and ionos-client part
- delete, update now also work when no record id is provided
- restructured tests
* delete all found records when no id is provided
* add and test delete safety check
  • Loading branch information
jandelgado authored Aug 18, 2024
1 parent b9bdfa3 commit f9501ae
Show file tree
Hide file tree
Showing 8 changed files with 532 additions and 356 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
.envrc
_gitignore/
*~
coverage.out
30 changes: 21 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,21 +60,33 @@ tests:
$ export LIBDNS_IONOS_TEST_ZONE=mydomain.org
$ export LIBDNS_IONOS_TEST_TOKEN=aaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
$ go test -v
go test -v
=== RUN Test_AppendRecords
--- PASS: Test_AppendRecords (43.01s)
=== RUN Test_AppendRecords/testcase_0
=== RUN Test_AppendRecords/testcase_1
=== RUN Test_AppendRecords/testcase_2
--- PASS: Test_AppendRecords (6.71s)
--- PASS: Test_AppendRecords/testcase_0 (2.51s)
--- PASS: Test_AppendRecords/testcase_1 (2.15s)
--- PASS: Test_AppendRecords/testcase_2 (2.05s)
=== RUN Test_DeleteRecords
--- PASS: Test_DeleteRecords (23.91s)
=== RUN Test_DeleteRecords/clear_record.ID=true
=== RUN Test_DeleteRecords/clear_record.ID=false
--- PASS: Test_DeleteRecords (9.62s)
--- PASS: Test_DeleteRecords/clear_record.ID=true (4.81s)
--- PASS: Test_DeleteRecords/clear_record.ID=false (4.80s)
=== RUN Test_GetRecords
--- PASS: Test_GetRecords (30.96s)
=== RUN Test_SetRecords
--- PASS: Test_SetRecords (51.39s)
--- PASS: Test_GetRecords (4.41s)
=== RUN Test_UpdateRecords
=== RUN Test_UpdateRecords/clear_record.ID=true
=== RUN Test_UpdateRecords/clear_record.ID=false
--- PASS: Test_UpdateRecords (10.14s)
--- PASS: Test_UpdateRecords/clear_record.ID=true (5.84s)
--- PASS: Test_UpdateRecords/clear_record.ID=false (4.30s)
PASS
ok github.com/libdns/ionos 149.277s
ok github.com/libdns/ionos 30.884s
```

The tests were taken from the [Hetzner libdns
module](https://github.com/libdns/hetzner) and are not modified.

## Author

Original Work (C) Copyright 2020 by matthiasng (based on https://github.com/libdns/hetzner),
Expand Down
34 changes: 34 additions & 0 deletions _example/example.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package main

import (
"context"
"encoding/json"
"fmt"
"os"

"github.com/libdns/ionos"
)

func main() {
token := os.Getenv("LIBDNS_IONOS_TOKEN")
if token == "" {
panic("LIBDNS_IONOS_TOKEN not set")
}

zone := os.Getenv("LIBDNS_IONOS_ZONE")
if zone == "" {
panic("LIBDNS_IONOS_ZONE not set")
}

p := &ionos.Provider{
AuthAPIToken: token,
}

records, err := p.GetRecords(context.TODO(), zone)
if err != nil {
panic(err)
}

out, _ := json.MarshalIndent(records, "", " ")
fmt.Println(string(out))
}
212 changes: 74 additions & 138 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,9 @@ import (
"context"
"encoding/json"
"fmt"
"io/ioutil"
"io"
"net/http"
"time"

"github.com/libdns/libdns"
"net/url"
)

const (
Expand Down Expand Up @@ -57,7 +55,7 @@ type record struct {

// IONOS does not accept TTL values < 60, and returns status 400. If the
// TTL is 0, we leave the field empty, by setting the struct value to nil.
func optTTL(ttl float64) *int {
func ionosTTL(ttl float64) *int {
var intTTL *int
if ttl > 0 {
tmp := int(ttl)
Expand All @@ -71,7 +69,10 @@ func doRequest(token string, request *http.Request) ([]byte, error) {
request.Header.Add("Content-Type", "application/json")

client := &http.Client{} // no timeout set because request is w/ context
// fmt.Printf(">>> HTTP req: %+v\n\n", request)
response, err := client.Do(request)

// fmt.Printf("<<< HTTP res: %+v\n\n", response)
if err != nil {
return nil, err
}
Expand All @@ -80,11 +81,12 @@ func doRequest(token string, request *http.Request) ([]byte, error) {
if response.StatusCode < 200 || response.StatusCode >= 300 {
return nil, fmt.Errorf("%s (%d)", http.StatusText(response.StatusCode), response.StatusCode)
}
return ioutil.ReadAll(response.Body)
return io.ReadAll(response.Body)

}

// GET /v1/zones
func getAllZones(ctx context.Context, token string) (getAllZonesResponse, error) {
func ionosGetAllZones(ctx context.Context, token string) (getAllZonesResponse, error) {
uri := fmt.Sprintf("%s/zones", APIEndpoint)
req, err := http.NewRequestWithContext(ctx, "GET", uri, nil)
data, err := doRequest(token, req)
Expand All @@ -99,25 +101,25 @@ func getAllZones(ctx context.Context, token string) (getAllZonesResponse, error)
return getAllZonesResponse{zones}, err
}

// findZoneDescriptor finds the zoneDescriptor for the named zoned in all zones
func findZoneDescriptor(ctx context.Context, token string, zoneName string) (zoneDescriptor, error) {
allZones, err := getAllZones(ctx, token)
// ionosGetZone reads the contents of zone by it's IONOS zoneID, optionally filtering for
// a specific recordType and recordName.
// /v1/zones/{zoneId}
func ionosGetZone(ctx context.Context, token string, zoneID string, recordType, recordName string) (getZoneResponse, error) {
u, err := url.Parse(APIEndpoint)
if err != nil {
return zoneDescriptor{}, err
return getZoneResponse{}, err
}
for _, zone := range allZones.Zones {
if zone.Name == zoneName {
return zone, nil
}
u = u.JoinPath("zones", zoneID)
queryString := u.Query()
if recordType != "" {
queryString.Set("recordType", recordType)
}
return zoneDescriptor{}, fmt.Errorf("zone not found")
}
if recordName != "" {
queryString.Set("recordName", recordName)
}
u.RawQuery = queryString.Encode()

// getZone reads a zone by it's IONOS zoneID
// /v1/zones/{zoneId}
func getZone(ctx context.Context, token string, zoneID string) (getZoneResponse, error) {
uri := fmt.Sprintf("%s/zones/%s", APIEndpoint, zoneID)
req, err := http.NewRequestWithContext(ctx, "GET", uri, nil)
req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
data, err := doRequest(token, req)
var result getZoneResponse
if err != nil {
Expand All @@ -128,159 +130,93 @@ func getZone(ctx context.Context, token string, zoneID string) (getZoneResponse,
return result, err
}

// findRecordInZone searches all records in the given zone for a record with
// the given name and type and returns this record on success
func findRecordInZone(ctx context.Context, token, zoneName, name, typ string) (zoneRecord, error) {
zoneResp, err := getZoneByName(ctx, token, zoneName)
// ionosFindRecordsInZone is a convenience function to search all records in the
// given zone for a record with the given name and type and returns this record
// on success
func ionosFindRecordsInZone(ctx context.Context, token string, zoneID, typ, name string) ([]zoneRecord, error) {
resp, err := ionosGetZone(ctx, token, zoneID, typ, name)
if err != nil {
return zoneRecord{}, err
return nil, err
}

for _, r := range zoneResp.Records {
if r.Name == name && r.Type == typ {
return r, nil
}
if len(resp.Records) < 1 {
return nil, fmt.Errorf("record not found in zone")
}
return zoneRecord{}, fmt.Errorf("record not found")
return resp.Records, nil
}

// getZoneByName reads a zone by it's zone name, requiring 2 REST calls to
// the IONOS API
func getZoneByName(ctx context.Context, token, zoneName string) (getZoneResponse, error) {
zoneDes, err := findZoneDescriptor(ctx, token, zoneName)
if err != nil {
return getZoneResponse{}, err
// ionosDeleteRecord deletes the given record
// DELETE /v1/zones/{zoneId}/records/{recordId}
func ionosDeleteRecord(ctx context.Context, token string, zoneID, id string) error {

if id == "" {
return fmt.Errorf("no record id provided")
}
return getZone(ctx, token, zoneDes.ID)
}

// getAllRecords returns all records from the given zone
func getAllRecords(ctx context.Context, token string, zoneName string) ([]libdns.Record, error) {
zoneResp, err := getZoneByName(ctx, token, zoneName)
req, err := http.NewRequestWithContext(ctx, "DELETE",
fmt.Sprintf("%s/zones/%s/records/%s", APIEndpoint, zoneID, id), nil)

if err != nil {
return nil, err
}
records := []libdns.Record{}
for _, r := range zoneResp.Records {
records = append(records, libdns.Record{
ID: r.ID,
Type: r.Type,
// libdns Name is partially qualified, relative to zone
Name: libdns.RelativeName(r.Name, zoneResp.Name),
Value: r.Content,
TTL: time.Duration(r.TTL) * time.Second,
})
return err
}
return records, nil
_, err = doRequest(token, req)
return err
}

// createRecord creates a DNS record in the given zone
// ionosCreateRecord creates a batch of DNS record in the given zone
// POST /v1/zones/{zoneId}/records
func createRecord(ctx context.Context, token string, zoneName string, r libdns.Record) (libdns.Record, error) {
zoneResp, err := getZoneByName(ctx, token, zoneName)
if err != nil {
return libdns.Record{}, err
}

reqData := []record{
{Type: r.Type,
// IONOS: Name is fully qualified
Name: libdns.AbsoluteName(r.Name, zoneName),
Content: r.Value,
TTL: optTTL(r.TTL.Seconds()),
}}
func ionosCreateRecords(
ctx context.Context,
token string,
zoneID string,
records []record) ([]zoneRecord, error) {

reqBuffer, err := json.Marshal(reqData)
reqBuffer, err := json.Marshal(records)
if err != nil {
return libdns.Record{}, err
return nil, err
}

uri := fmt.Sprintf("%s/zones/%s/records", APIEndpoint, zoneResp.ID)
uri := fmt.Sprintf("%s/zones/%s/records", APIEndpoint, zoneID)
req, err := http.NewRequestWithContext(ctx, "POST", uri, bytes.NewBuffer(reqBuffer))
if err != nil {
return libdns.Record{}, err
return nil, err
}

// as result of the POST, a zoneDescriptor array is returned
data, err := doRequest(token, req)
// as result of the POST, a zoneRecord array is returned
res, err := doRequest(token, req)
if err != nil {
return libdns.Record{}, err
}
zones := make([]zoneDescriptor, 0)
if err = json.Unmarshal(data, &zones); err != nil {
return libdns.Record{}, err
return nil, err
}

if len(zones) != 1 {
return libdns.Record{}, fmt.Errorf("unexpected response from create record (size mismatch)")
var zoneRecords []zoneRecord
if err = json.Unmarshal(res, &zoneRecords); err != nil {
return nil, err
}
return zoneRecords, nil

return libdns.Record{
ID: zones[0].ID,
Type: r.Type,
// always return partially qualified name, relative to zone for libdns
Name: libdns.RelativeName(unFQDN(r.Name), zoneName),
Value: r.Value,
TTL: r.TTL,
}, nil
}

// DELETE /v1/zones/{zoneId}/records/{recordId}
func deleteRecord(ctx context.Context, token, zoneName string, record libdns.Record) error {
zoneResp, err := getZoneByName(ctx, token, zoneName)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, "DELETE",
fmt.Sprintf("%s/zones/%s/records/%s", APIEndpoint, zoneResp.ID, record.ID), nil)
if err != nil {
return err
}
_, err = doRequest(token, req)
return err
}

// /v1/zones/{zoneId}/records/{recordId}
func updateRecord(ctx context.Context, token string, zone string, r libdns.Record) (libdns.Record, error) {
zoneDes, err := getZoneByName(ctx, token, zone)
if err != nil {
return libdns.Record{}, err
}

reqData := record{
Type: r.Type,
Name: libdns.AbsoluteName(r.Name, zone),
Content: r.Value,
TTL: optTTL(r.TTL.Seconds()),
// ionosUpdateRecord updates the record with id `id` in the given zone
// TODO check TTL
// PUT /v1/zones/{zoneId}/records/{recordId}
func ionosUpdateRecord(ctx context.Context, token string, zoneID, id string, r record) error {
if id == "" {
return fmt.Errorf("no record id provided")
}

reqBuffer, err := json.Marshal(reqData)
reqBuffer, err := json.Marshal(r)
if err != nil {
return libdns.Record{}, err
return fmt.Errorf("marshal record for update: %w", err)
}

req, err := http.NewRequestWithContext(ctx, "PUT",
fmt.Sprintf("%s/zones/%s/records/%s", APIEndpoint, zoneDes.ID, r.ID),
fmt.Sprintf("%s/zones/%s/records/%s", APIEndpoint, zoneID, id),
bytes.NewBuffer(reqBuffer))

if err != nil {
return libdns.Record{}, err
return err
}

// according to API doc, no response returned here
_, err = doRequest(token, req)

return libdns.Record{
ID: r.ID,
Type: r.Type,
Name: r.Name,
Value: r.Value,
TTL: time.Duration(r.TTL) * time.Second,
}, err
}

func createOrUpdateRecord(ctx context.Context, token string, zone string, r libdns.Record) (libdns.Record, error) {
if r.ID == "" {
return createRecord(ctx, token, zone, r)
}
return updateRecord(ctx, token, zone, r)
return err
}
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
module github.com/libdns/ionos

go 1.16
go 1.18

require github.com/libdns/libdns v0.2.1
require github.com/libdns/libdns v0.2.2
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
github.com/libdns/libdns v0.2.1 h1:Wu59T7wSHRgtA0cfxC+n1c/e+O3upJGWytknkmFEDis=
github.com/libdns/libdns v0.2.1/go.mod h1:yQCXzk1lEZmmCPa857bnk4TsOiqYasqpyOEeSObbb40=
github.com/libdns/libdns v0.2.2 h1:O6ws7bAfRPaBsgAYt8MDe2HcNBGC29hkZ9MX2eUSX3s=
github.com/libdns/libdns v0.2.2/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
Loading

0 comments on commit f9501ae

Please sign in to comment.