1package internal
2
3import (
4	"bytes"
5	"encoding/json"
6	"errors"
7	"fmt"
8	"io"
9	"net/http"
10	"net/url"
11	"path"
12	"time"
13)
14
15const defaultBaseURL = "https://api.hyperone.com/v2"
16
17const defaultLocationID = "pl-waw-1"
18
19type signer interface {
20	GetJWT() (string, error)
21}
22
23// Client the HyperOne client.
24type Client struct {
25	HTTPClient *http.Client
26
27	apiEndpoint string
28	locationID  string
29	projectID   string
30
31	passport *Passport
32	signer   signer
33}
34
35// NewClient Creates a new HyperOne client.
36func NewClient(apiEndpoint, locationID string, passport *Passport) (*Client, error) {
37	if passport == nil {
38		return nil, errors.New("the passport is missing")
39	}
40
41	projectID, err := passport.ExtractProjectID()
42	if err != nil {
43		return nil, err
44	}
45
46	baseURL := defaultBaseURL
47	if apiEndpoint != "" {
48		baseURL = apiEndpoint
49	}
50
51	tokenSigner := &TokenSigner{
52		PrivateKey: passport.PrivateKey,
53		KeyID:      passport.CertificateID,
54		Audience:   baseURL,
55		Issuer:     passport.Issuer,
56		Subject:    passport.SubjectID,
57	}
58
59	client := &Client{
60		HTTPClient:  &http.Client{Timeout: 5 * time.Second},
61		apiEndpoint: baseURL,
62		locationID:  locationID,
63		passport:    passport,
64		projectID:   projectID,
65		signer:      tokenSigner,
66	}
67
68	if client.locationID == "" {
69		client.locationID = defaultLocationID
70	}
71
72	return client, nil
73}
74
75// FindRecordset looks for recordset with given recordType and name and returns it.
76// In case if recordset is not found returns nil.
77// https://api.hyperone.com/v2/docs#operation/dns_project_zone_recordset_list
78func (c *Client) FindRecordset(zoneID, recordType, name string) (*Recordset, error) {
79	// https://api.hyperone.com/v2/dns/{locationId}/project/{projectId}/zone/{zoneId}/recordset
80	resourceURL := path.Join("dns", c.locationID, "project", c.projectID, "zone", zoneID, "recordset")
81
82	req, err := c.createRequest(http.MethodGet, resourceURL, nil)
83	if err != nil {
84		return nil, err
85	}
86
87	var recordSets []Recordset
88
89	err = c.do(req, &recordSets)
90	if err != nil {
91		return nil, fmt.Errorf("failed to get recordsets from server: %w", err)
92	}
93
94	for _, v := range recordSets {
95		if v.RecordType == recordType && v.Name == name {
96			return &v, nil
97		}
98	}
99
100	// when recordset is not present returns nil, but error is not thrown
101	return nil, nil
102}
103
104// CreateRecordset creates recordset and record with given value within one request.
105// https://api.hyperone.com/v2/docs#operation/dns_project_zone_recordset_create
106func (c *Client) CreateRecordset(zoneID, recordType, name, recordValue string, ttl int) (*Recordset, error) {
107	recordsetInput := Recordset{
108		RecordType: recordType,
109		Name:       name,
110		TTL:        ttl,
111		Record:     &Record{Content: recordValue},
112	}
113
114	requestBody, err := json.Marshal(recordsetInput)
115	if err != nil {
116		return nil, fmt.Errorf("failed to marshal recordset: %w", err)
117	}
118
119	// https://api.hyperone.com/v2/dns/{locationId}/project/{projectId}/zone/{zoneId}/recordset
120	resourceURL := path.Join("dns", c.locationID, "project", c.projectID, "zone", zoneID, "recordset")
121
122	req, err := c.createRequest(http.MethodPost, resourceURL, bytes.NewBuffer(requestBody))
123	if err != nil {
124		return nil, err
125	}
126
127	var recordsetResponse Recordset
128
129	err = c.do(req, &recordsetResponse)
130	if err != nil {
131		return nil, fmt.Errorf("failed to create recordset: %w", err)
132	}
133
134	return &recordsetResponse, nil
135}
136
137// DeleteRecordset deletes a recordset.
138// https://api.hyperone.com/v2/docs#operation/dns_project_zone_recordset_delete
139func (c *Client) DeleteRecordset(zoneID string, recordsetID string) error {
140	// https://api.hyperone.com/v2/dns/{locationId}/project/{projectId}/zone/{zoneId}/recordset/{recordsetId}
141	resourceURL := path.Join("dns", c.locationID, "project", c.projectID, "zone", zoneID, "recordset", recordsetID)
142
143	req, err := c.createRequest(http.MethodDelete, resourceURL, nil)
144	if err != nil {
145		return err
146	}
147
148	return c.do(req, nil)
149}
150
151// GetRecords gets all records within specified recordset.
152// https://api.hyperone.com/v2/docs#operation/dns_project_zone_recordset_record_list
153func (c *Client) GetRecords(zoneID string, recordsetID string) ([]Record, error) {
154	// https://api.hyperone.com/v2/dns/{locationId}/project/{projectId}/zone/{zoneId}/recordset/{recordsetId}/record
155	resourceURL := path.Join("dns", c.locationID, "project", c.projectID, "zone", zoneID, "recordset", recordsetID, "record")
156
157	req, err := c.createRequest(http.MethodGet, resourceURL, nil)
158	if err != nil {
159		return nil, err
160	}
161
162	var records []Record
163
164	err = c.do(req, &records)
165	if err != nil {
166		return nil, fmt.Errorf("failed to get records from server: %w", err)
167	}
168
169	return records, err
170}
171
172// CreateRecord creates a record.
173// https://api.hyperone.com/v2/docs#operation/dns_project_zone_recordset_record_create
174func (c *Client) CreateRecord(zoneID, recordsetID, recordContent string) (*Record, error) {
175	// https://api.hyperone.com/v2/dns/{locationId}/project/{projectId}/zone/{zoneId}/recordset/{recordsetId}/record
176	resourceURL := path.Join("dns", c.locationID, "project", c.projectID, "zone", zoneID, "recordset", recordsetID, "record")
177
178	requestBody, err := json.Marshal(Record{Content: recordContent})
179	if err != nil {
180		return nil, fmt.Errorf("failed to marshal record: %w", err)
181	}
182
183	req, err := c.createRequest(http.MethodPost, resourceURL, bytes.NewBuffer(requestBody))
184	if err != nil {
185		return nil, err
186	}
187
188	var recordResponse Record
189
190	err = c.do(req, &recordResponse)
191	if err != nil {
192		return nil, fmt.Errorf("failed to set record: %w", err)
193	}
194
195	return &recordResponse, nil
196}
197
198// DeleteRecord deletes a record.
199// https://api.hyperone.com/v2/docs#operation/dns_project_zone_recordset_record_delete
200func (c *Client) DeleteRecord(zoneID, recordsetID, recordID string) error {
201	// https://api.hyperone.com/v2/dns/{locationId}/project/{projectId}/zone/{zoneId}/recordset/{recordsetId}/record/{recordId}
202	resourceURL := path.Join("dns", c.locationID, "project", c.projectID, "zone", zoneID, "recordset", recordsetID, "record", recordID)
203
204	req, err := c.createRequest(http.MethodDelete, resourceURL, nil)
205	if err != nil {
206		return err
207	}
208
209	return c.do(req, nil)
210}
211
212// FindZone looks for DNS Zone and returns nil if it does not exist.
213func (c *Client) FindZone(name string) (*Zone, error) {
214	zones, err := c.GetZones()
215	if err != nil {
216		return nil, err
217	}
218
219	for _, zone := range zones {
220		if zone.DNSName == name {
221			return &zone, nil
222		}
223	}
224
225	return nil, fmt.Errorf("failed to find zone for %s", name)
226}
227
228// GetZones gets all user's zones.
229// https://api.hyperone.com/v2/docs#operation/dns_project_zone_list
230func (c *Client) GetZones() ([]Zone, error) {
231	// https://api.hyperone.com/v2/dns/{locationId}/project/{projectId}/zone
232	resourceURL := path.Join("dns", c.locationID, "project", c.projectID, "zone")
233
234	req, err := c.createRequest(http.MethodGet, resourceURL, nil)
235	if err != nil {
236		return nil, err
237	}
238
239	var zones []Zone
240
241	err = c.do(req, &zones)
242	if err != nil {
243		return nil, fmt.Errorf("failed to fetch available zones: %w", err)
244	}
245
246	return zones, nil
247}
248
249func (c *Client) createRequest(method, uri string, body io.Reader) (*http.Request, error) {
250	baseURL, err := url.Parse(c.apiEndpoint)
251	if err != nil {
252		return nil, err
253	}
254
255	endpoint, err := baseURL.Parse(path.Join(baseURL.Path, uri))
256	if err != nil {
257		return nil, err
258	}
259
260	req, err := http.NewRequest(method, endpoint.String(), body)
261	if err != nil {
262		return nil, err
263	}
264
265	jwt, err := c.signer.GetJWT()
266	if err != nil {
267		return nil, fmt.Errorf("failed to sign the request: %w", err)
268	}
269
270	req.Header.Set("Authorization", "Bearer "+jwt)
271	req.Header.Set("Content-Type", "application/json")
272
273	return req, nil
274}
275
276func (c *Client) do(req *http.Request, v interface{}) error {
277	resp, err := c.HTTPClient.Do(req)
278	if err != nil {
279		return err
280	}
281
282	defer func() { _ = resp.Body.Close() }()
283
284	err = checkResponse(resp)
285	if err != nil {
286		return err
287	}
288
289	if v == nil {
290		return nil
291	}
292
293	raw, err := io.ReadAll(resp.Body)
294	if err != nil {
295		return fmt.Errorf("failed to read body: %w", err)
296	}
297
298	if err = json.Unmarshal(raw, v); err != nil {
299		return fmt.Errorf("unmarshaling %T error: %w: %s", v, err, string(raw))
300	}
301
302	return nil
303}
304
305func checkResponse(resp *http.Response) error {
306	if resp.StatusCode/100 == 2 {
307		return nil
308	}
309
310	var msg string
311	if resp.StatusCode == http.StatusForbidden {
312		msg = "forbidden: check if service account you are trying to use has permissions required for managing DNS"
313	} else {
314		msg = fmt.Sprintf("%d: unknown error", resp.StatusCode)
315	}
316
317	// add response body to error message if not empty
318	responseBody, _ := io.ReadAll(resp.Body)
319	if len(responseBody) > 0 {
320		msg = fmt.Sprintf("%s: %s", msg, string(responseBody))
321	}
322
323	return errors.New(msg)
324}
325