Repository: hetznercloud/hcloud-go
Pull request #625 (2025-03-17 08:39:20)

refactor: use generic request for remaining get calls
- Also split the metrics query parameter building into a `Validate() error` and a `values() url.Values` functions. 

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/hetznercloud/hcloud-go/pull/625?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hetznercloud) Report
Attention: Patch coverage is `95.23810%` with `2 lines` in your changes missing coverage. Please review.
> Project coverage is 71.01%. Comparing base [(`fc99d70`)](https://app.codecov.io/gh/hetznercloud/hcloud-go/commit/fc99d70cebc5f369dd60c979d67dbf61baab39b1?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hetznercloud) to head [(`e4b893b`)](https://app.codecov.io/gh/hetznercloud/hcloud-go/commit/e4b893ba2c5c28f7d340c4c6a5d8e242f521e784?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hetznercloud).

| [Files with missing lines](https://app.codecov.io/gh/hetznercloud/hcloud-go/pull/625?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hetznercloud) | Patch % | Lines |
|---|---|---|
| [hcloud/load\_balancer.go](https://app.codecov.io/gh/hetznercloud/hcloud-go/pull/625?src=pr&el=tree&filepath=hcloud%2Fload_balancer.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hetznercloud#diff-aGNsb3VkL2xvYWRfYmFsYW5jZXIuZ28=) | 95.00% | [1 Missing :warning: ](https://app.codecov.io/gh/hetznercloud/hcloud-go/pull/625?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hetznercloud) |
| [hcloud/server.go](https://app.codecov.io/gh/hetznercloud/hcloud-go/pull/625?src=pr&el=tree&filepath=hcloud%2Fserver.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hetznercloud#diff-aGNsb3VkL3NlcnZlci5nbw==) | 94.73% | [1 Missing :warning: ](https://app.codecov.io/gh/hetznercloud/hcloud-go/pull/625?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hetznercloud) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #625      +/-   ##
==========================================
+ Coverage   70.95%   71.01%   +0.05%     
==========================================
  Files          49       49              
  Lines        4431     4416      -15     
==========================================
- Hits         3144     3136       -8     
+ Misses        955      951       -4     
+ Partials      332      329       -3     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/hetznercloud/hcloud-go/pull/625?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hetznercloud).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hetznercloud).

<details><summary>🚀 New features to boost your workflow: </summary>

- ❄ [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>

Patch:
diff --git a/hcloud/certificate.go b/hcloud/certificate.go
index c079cac..b04cdb5 100644
--- a/hcloud/certificate.go
+++ b/hcloud/certificate.go
@@ -1,9 +1,7 @@
 package hcloud
 
 import (
-	"bytes"
 	"context"
-	"encoding/json"
 	"errors"
 	"fmt"
 	"net/url"
@@ -229,16 +227,15 @@ func (c *CertificateClient) Create(ctx context.Context, opts CertificateCreateOp
 func (c *CertificateClient) CreateCertificate(
 	ctx context.Context, opts CertificateCreateOpts,
 ) (CertificateCreateResult, *Response, error) {
-	var (
-		action  *Action
-		reqBody schema.CertificateCreateRequest
-	)
+	result := CertificateCreateResult{}
 
 	if err := opts.Validate(); err != nil {
-		return CertificateCreateResult{}, nil, err
+		return result, nil, err
 	}
 
-	reqBody.Name = opts.Name
+	reqBody := schema.CertificateCreateRequest{
+		Name: opts.Name,
+	}
 
 	switch opts.Type {
 	case "", CertificateTypeUploaded:
@@ -255,26 +252,20 @@ func (c *CertificateClient) CreateCertificate(
 	if opts.Labels != nil {
 		reqBody.Labels = &opts.Labels
 	}
-	reqBodyData, err := json.Marshal(reqBody)
-	if err != nil {
-		return CertificateCreateResult{}, nil, err
-	}
-	req, err := c.client.NewRequest(ctx, "POST", "/certificates", bytes.NewReader(reqBodyData))
-	if err != nil {
-		return CertificateCreateResult{}, nil, err
-	}
 
-	respBody := schema.CertificateCreateResponse{}
-	resp, err := c.client.Do(req, &respBody)
+	reqPath := "/certificates"
+
+	respBody, resp, err := postRequest[schema.CertificateCreateResponse](ctx, c.client, reqPath, reqBody)
 	if err != nil {
-		return CertificateCreateResult{}, resp, err
+		return result, resp, err
 	}
-	cert := CertificateFromSchema(respBody.Certificate)
+
+	result.Certificate = CertificateFromSchema(respBody.Certificate)
 	if respBody.Action != nil {
-		action = ActionFromSchema(*respBody.Action)
+		result.Action = ActionFromSchema(*respBody.Action)
 	}
 
-	return CertificateCreateResult{Certificate: cert, Action: action}, resp, nil
+	return result, resp, nil
 }
 
 // CertificateUpdateOpts specifies options for updating a Certificate.
diff --git a/hcloud/firewall.go b/hcloud/firewall.go
index 1ea312d..6fe8100 100644
--- a/hcloud/firewall.go
+++ b/hcloud/firewall.go
@@ -191,28 +191,24 @@ type FirewallCreateResult struct {
 
 // Create creates a new Firewall.
 func (c *FirewallClient) Create(ctx context.Context, opts FirewallCreateOpts) (FirewallCreateResult, *Response, error) {
+	result := FirewallCreateResult{}
+
 	if err := opts.Validate(); err != nil {
-		return FirewallCreateResult{}, nil, err
+		return result, nil, err
 	}
+
 	reqBody := firewallCreateOptsToSchema(opts)
-	reqBodyData, err := json.Marshal(reqBody)
-	if err != nil {
-		return FirewallCreateResult{}, nil, err
-	}
-	req, err := c.client.NewRequest(ctx, "POST", "/firewalls", bytes.NewReader(reqBodyData))
-	if err != nil {
-		return FirewallCreateResult{}, nil, err
-	}
 
-	respBody := schema.FirewallCreateResponse{}
-	resp, err := c.client.Do(req, &respBody)
+	reqPath := "/firewalls"
+
+	respBody, resp, err := postRequest[schema.FirewallCreateResponse](ctx, c.client, reqPath, reqBody)
 	if err != nil {
-		return FirewallCreateResult{}, resp, err
-	}
-	result := FirewallCreateResult{
-		Firewall: FirewallFromSchema(respBody.Firewall),
-		Actions:  ActionsFromSchema(respBody.Actions),
+		return result, resp, err
 	}
+
+	result.Firewall = FirewallFromSchema(respBody.Firewall)
+	result.Actions = ActionsFromSchema(respBody.Actions)
+
 	return result, resp, nil
 }
 
diff --git a/hcloud/floating_ip.go b/hcloud/floating_ip.go
index 2a1f967..7d2893b 100644
--- a/hcloud/floating_ip.go
+++ b/hcloud/floating_ip.go
@@ -200,8 +200,10 @@ type FloatingIPCreateResult struct {
 
 // Create creates a Floating IP.
 func (c *FloatingIPClient) Create(ctx context.Context, opts FloatingIPCreateOpts) (FloatingIPCreateResult, *Response, error) {
+	result := FloatingIPCreateResult{}
+
 	if err := opts.Validate(); err != nil {
-		return FloatingIPCreateResult{}, nil, err
+		return result, nil, err
 	}
 
 	reqBody := schema.FloatingIPCreateRequest{
@@ -218,29 +220,20 @@ func (c *FloatingIPClient) Create(ctx context.Context, opts FloatingIPCreateOpts
 	if opts.Labels != nil {
 		reqBody.Labels = &opts.Labels
 	}
-	reqBodyData, err := json.Marshal(reqBody)
-	if err != nil {
-		return FloatingIPCreateResult{}, nil, err
-	}
 
-	req, err := c.client.NewRequest(ctx, "POST", "/floating_ips", bytes.NewReader(reqBodyData))
-	if err != nil {
-		return FloatingIPCreateResult{}, nil, err
-	}
+	reqPath := "/floating_ips"
 
-	var respBody schema.FloatingIPCreateResponse
-	resp, err := c.client.Do(req, &respBody)
+	respBody, resp, err := postRequest[schema.FloatingIPCreateResponse](ctx, c.client, reqPath, reqBody)
 	if err != nil {
-		return FloatingIPCreateResult{}, resp, err
+		return result, resp, err
 	}
-	var action *Action
+
+	result.FloatingIP = FloatingIPFromSchema(respBody.FloatingIP)
 	if respBody.Action != nil {
-		action = ActionFromSchema(*respBody.Action)
+		result.Action = ActionFromSchema(*respBody.Action)
 	}
-	return FloatingIPCreateResult{
-		FloatingIP: FloatingIPFromSchema(respBody.FloatingIP),
-		Action:     action,
-	}, resp, nil
+
+	return result, resp, nil
 }
 
 // Delete deletes a Floating IP.
diff --git a/hcloud/load_balancer.go b/hcloud/load_balancer.go
index 3e70d92..cf38bba 100644
--- a/hcloud/load_balancer.go
+++ b/hcloud/load_balancer.go
@@ -6,7 +6,6 @@ import (
 	"encoding/json"
 	"fmt"
 	"net"
-	"net/http"
 	"net/url"
 	"strconv"
 	"time"
@@ -433,25 +432,21 @@ type LoadBalancerCreateResult struct {
 
 // Create creates a new Load Balancer.
 func (c *LoadBalancerClient) Create(ctx context.Context, opts LoadBalancerCreateOpts) (LoadBalancerCreateResult, *Response, error) {
+	result := LoadBalancerCreateResult{}
+
 	reqBody := loadBalancerCreateOptsToSchema(opts)
-	reqBodyData, err := json.Marshal(reqBody)
-	if err != nil {
-		return LoadBalancerCreateResult{}, nil, err
-	}
-	req, err := c.client.NewRequest(ctx, "POST", "/load_balancers", bytes.NewReader(reqBodyData))
-	if err != nil {
-		return LoadBalancerCreateResult{}, nil, err
-	}
 
-	respBody := schema.LoadBalancerCreateResponse{}
-	resp, err := c.client.Do(req, &respBody)
+	reqPath := "/load_balancers"
+
+	respBody, resp, err := postRequest[schema.LoadBalancerCreateResponse](ctx, c.client, reqPath, reqBody)
 	if err != nil {
-		return LoadBalancerCreateResult{}, resp, err
+		return result, resp, err
 	}
-	return LoadBalancerCreateResult{
-		LoadBalancer: LoadBalancerFromSchema(respBody.LoadBalancer),
-		Action:       ActionFromSchema(respBody.Action),
-	}, resp, nil
+
+	result.LoadBalancer = LoadBalancerFromSchema(respBody.LoadBalancer)
+	result.Action = ActionFromSchema(respBody.Action)
+
+	return result, resp, nil
 }
 
 // Delete deletes a Load Balancer.
@@ -937,32 +932,34 @@ type LoadBalancerGetMetricsOpts struct {
 	Step  int
 }
 
-func (o *LoadBalancerGetMetricsOpts) addQueryParams(req *http.Request) error {
-	query := req.URL.Query()
-
+func (o LoadBalancerGetMetricsOpts) Validate() error {
 	if len(o.Types) == 0 {
 		return fmt.Errorf("no metric types specified")
 	}
-	for _, typ := range o.Types {
-		query.Add("type", string(typ))
-	}
-
 	if o.Start.IsZero() {
 		return fmt.Errorf("no start time specified")
 	}
-	query.Add("start", o.Start.Format(time.RFC3339))
-
 	if o.End.IsZero() {
 		return fmt.Errorf("no end time specified")
 	}
+	return nil
+}
+
+func (o LoadBalancerGetMetricsOpts) values() url.Values {
+	query := url.Values{}
+
+	for _, typ := range o.Types {
+		query.Add("type", string(typ))
+	}
+
+	query.Add("start", o.Start.Format(time.RFC3339))
 	query.Add("end", o.End.Format(time.RFC3339))
 
 	if o.Step > 0 {
 		query.Add("step", strconv.Itoa(o.Step))
 	}
-	req.URL.RawQuery = query.Encode()
 
-	return nil
+	return query
 }
 
 // LoadBalancerMetrics contains the metrics requested for a Load Balancer.
@@ -981,31 +978,29 @@ type LoadBalancerMetricsValue struct {
 
 // GetMetrics obtains metrics for a Load Balancer.
 func (c *LoadBalancerClient) GetMetrics(
-	ctx context.Context, lb *LoadBalancer, opts LoadBalancerGetMetricsOpts,
+	ctx context.Context, loadBalancer *LoadBalancer, opts LoadBalancerGetMetricsOpts,
 ) (*LoadBalancerMetrics, *Response, error) {
-	var respBody schema.LoadBalancerGetMetricsResponse
-
-	if lb == nil {
+	if loadBalancer == nil {
 		return nil, nil, fmt.Errorf("illegal argument: load balancer is nil")
 	}
 
-	path := fmt.Sprintf("/load_balancers/%d/metrics", lb.ID)
-	req, err := c.client.NewRequest(ctx, "GET", path, nil)
-	if err != nil {
-		return nil, nil, fmt.Errorf("new request: %v", err)
-	}
-	if err := opts.addQueryParams(req); err != nil {
-		return nil, nil, fmt.Errorf("add query params: %v", err)
+	if err := opts.Validate(); err != nil {
+		return nil, nil, err
 	}
-	resp, err := c.client.Do(req, &respBody)
+
+	reqPath := fmt.Sprintf("/load_balancers/%d/metrics?%s", loadBalancer.ID, opts.values().Encode())
+
+	respBody, resp, err := getRequest[schema.LoadBalancerGetMetricsResponse](ctx, c.client, reqPath)
 	if err != nil {
-		return nil, resp, fmt.Errorf("get metrics: %v", err)
+		return nil, resp, err
 	}
-	ms, err := loadBalancerMetricsFromSchema(&respBody)
+
+	metrics, err := loadBalancerMetricsFromSchema(&respBody)
 	if err != nil {
-		return nil, nil, fmt.Errorf("convert response body: %v", err)
+		return nil, nil, fmt.Errorf("convert response body: %w", err)
 	}
-	return ms, resp, nil
+
+	return metrics, resp, nil
 }
 
 // ChangeDNSPtr changes or resets the reverse DNS pointer for a Load Balancer.
diff --git a/hcloud/network.go b/hcloud/network.go
index 58bab2d..c05b08d 100644
--- a/hcloud/network.go
+++ b/hcloud/network.go
@@ -249,20 +249,14 @@ func (c *NetworkClient) Create(ctx context.Context, opts NetworkCreateOpts) (*Ne
 	if opts.Labels != nil {
 		reqBody.Labels = &opts.Labels
 	}
-	reqBodyData, err := json.Marshal(reqBody)
-	if err != nil {
-		return nil, nil, err
-	}
-	req, err := c.client.NewRequest(ctx, "POST", "/networks", bytes.NewReader(reqBodyData))
-	if err != nil {
-		return nil, nil, err
-	}
 
-	respBody := schema.NetworkCreateResponse{}
-	resp, err := c.client.Do(req, &respBody)
+	reqPath := "/networks"
+
+	respBody, resp, err := postRequest[schema.NetworkCreateResponse](ctx, c.client, reqPath, reqBody)
 	if err != nil {
 		return nil, resp, err
 	}
+
 	return NetworkFromSchema(respBody.Network), resp, nil
 }
 
diff --git a/hcloud/placement_group.go b/hcloud/placement_group.go
index 524df45..6ca74dd 100644
--- a/hcloud/placement_group.go
+++ b/hcloud/placement_group.go
@@ -1,9 +1,7 @@
 package hcloud
 
 import (
-	"bytes"
 	"context"
-	"encoding/json"
 	"errors"
 	"fmt"
 	"net/url"
@@ -142,27 +140,22 @@ type PlacementGroupCreateResult struct {
 
 // Create creates a new PlacementGroup.
 func (c *PlacementGroupClient) Create(ctx context.Context, opts PlacementGroupCreateOpts) (PlacementGroupCreateResult, *Response, error) {
+	result := PlacementGroupCreateResult{}
+
 	if err := opts.Validate(); err != nil {
-		return PlacementGroupCreateResult{}, nil, err
+		return result, nil, err
 	}
+
 	reqBody := placementGroupCreateOptsToSchema(opts)
-	reqBodyData, err := json.Marshal(reqBody)
-	if err != nil {
-		return PlacementGroupCreateResult{}, nil, err
-	}
-	req, err := c.client.NewRequest(ctx, "POST", "/placement_groups", bytes.NewReader(reqBodyData))
-	if err != nil {
-		return PlacementGroupCreateResult{}, nil, err
-	}
 
-	respBody := schema.PlacementGroupCreateResponse{}
-	resp, err := c.client.Do(req, &respBody)
+	reqPath := "/placement_groups"
+
+	respBody, resp, err := postRequest[schema.PlacementGroupCreateResponse](ctx, c.client, reqPath, reqBody)
 	if err != nil {
-		return PlacementGroupCreateResult{}, resp, err
-	}
-	result := PlacementGroupCreateResult{
-		PlacementGroup: PlacementGroupFromSchema(respBody.PlacementGroup),
+		return result, resp, err
 	}
+
+	result.PlacementGroup = PlacementGroupFromSchema(respBody.PlacementGroup)
 	if respBody.Action != nil {
 		result.Action = ActionFromSchema(*respBody.Action)
 	}
diff --git a/hcloud/pricing.go b/hcloud/pricing.go
index 3bf6863..983c7b7 100644
--- a/hcloud/pricing.go
+++ b/hcloud/pricing.go
@@ -136,15 +136,12 @@ type PricingClient struct {
 
 // Get retrieves pricing information.
 func (c *PricingClient) Get(ctx context.Context) (Pricing, *Response, error) {
-	req, err := c.client.NewRequest(ctx, "GET", "/pricing", nil)
-	if err != nil {
-		return Pricing{}, nil, err
-	}
+	reqPath := "/pricing"
 
-	var body schema.PricingGetResponse
-	resp, err := c.client.Do(req, &body)
+	respBody, resp, err := getRequest[schema.PricingGetResponse](ctx, c.client, reqPath)
 	if err != nil {
 		return Pricing{}, resp, err
 	}
-	return PricingFromSchema(body.Pricing), resp, nil
+
+	return PricingFromSchema(respBody.Pricing), resp, nil
 }
diff --git a/hcloud/primary_ip.go b/hcloud/primary_ip.go
index 2859780..9bea708 100644
--- a/hcloud/primary_ip.go
+++ b/hcloud/primary_ip.go
@@ -254,30 +254,21 @@ func (c *PrimaryIPClient) AllWithOpts(ctx context.Context, opts PrimaryIPListOpt
 
 // Create creates a Primary IP.
 func (c *PrimaryIPClient) Create(ctx context.Context, reqBody PrimaryIPCreateOpts) (*PrimaryIPCreateResult, *Response, error) {
-	reqBodyData, err := json.Marshal(reqBody)
-	if err != nil {
-		return &PrimaryIPCreateResult{}, nil, err
-	}
+	result := &PrimaryIPCreateResult{}
 
-	req, err := c.client.NewRequest(ctx, "POST", "/primary_ips", bytes.NewReader(reqBodyData))
-	if err != nil {
-		return &PrimaryIPCreateResult{}, nil, err
-	}
+	reqPath := "/primary_ips"
 
-	var respBody schema.PrimaryIPCreateResponse
-	resp, err := c.client.Do(req, &respBody)
+	respBody, resp, err := postRequest[schema.PrimaryIPCreateResponse](ctx, c.client, reqPath, reqBody)
 	if err != nil {
-		return &PrimaryIPCreateResult{}, resp, err
+		return result, resp, err
 	}
-	var action *Action
+
+	result.PrimaryIP = PrimaryIPFromSchema(respBody.PrimaryIP)
 	if respBody.Action != nil {
-		action = ActionFromSchema(*respBody.Action)
+		result.Action = ActionFromSchema(*respBody.Action)
 	}
-	primaryIP := PrimaryIPFromSchema(respBody.PrimaryIP)
-	return &PrimaryIPCreateResult{
-		PrimaryIP: primaryIP,
-		Action:    action,
-	}, resp, nil
+
+	return result, resp, nil
 }
 
 // Delete deletes a Primary IP.
diff --git a/hcloud/server.go b/hcloud/server.go
index d4c23c9..c4992c4 100644
--- a/hcloud/server.go
+++ b/hcloud/server.go
@@ -7,7 +7,6 @@ import (
 	"errors"
 	"fmt"
 	"net"
-	"net/http"
 	"net/url"
 	"strconv"
 	"time"
@@ -338,8 +337,10 @@ type ServerCreateResult struct {
 
 // Create creates a new server.
 func (c *ServerClient) Create(ctx context.Context, opts ServerCreateOpts) (ServerCreateResult, *Response, error) {
+	result := ServerCreateResult{}
+
 	if err := opts.Validate(); err != nil {
-		return ServerCreateResult{}, nil, err
+		return result, nil, err
 	}
 
 	var reqBody schema.ServerCreateRequest
@@ -400,29 +401,21 @@ func (c *ServerClient) Create(ctx context.Context, opts ServerCreateOpts) (Serve
 	if opts.PlacementGroup != nil {
 		reqBody.PlacementGroup = opts.PlacementGroup.ID
 	}
-	reqBodyData, err := json.Marshal(reqBody)
-	if err != nil {
-		return ServerCreateResult{}, nil, err
-	}
 
-	req, err := c.client.NewRequest(ctx, "POST", "/servers", bytes.NewReader(reqBodyData))
-	if err != nil {
-		return ServerCreateResult{}, nil, err
-	}
+	reqPath := "/servers"
 
-	var respBody schema.ServerCreateResponse
-	resp, err := c.client.Do(req, &respBody)
+	respBody, resp, err := postRequest[schema.ServerCreateResponse](ctx, c.client, reqPath, reqBody)
 	if err != nil {
-		return ServerCreateResult{}, resp, err
-	}
-	result := ServerCreateResult{
-		Server:      ServerFromSchema(respBody.Server),
-		Action:      ActionFromSchema(respBody.Action),
-		NextActions: ActionsFromSchema(respBody.NextActions),
+		return result, resp, err
 	}
+
+	result.Server = ServerFromSchema(respBody.Server)
+	result.Action = ActionFromSchema(respBody.Action)
+	result.NextActions = ActionsFromSchema(respBody.NextActions)
 	if respBody.RootPassword != nil {
 		result.RootPassword = *respBody.RootPassword
 	}
+
 	return result, resp, nil
 }
 
@@ -1058,32 +1051,34 @@ type ServerGetMetricsOpts struct {
 	Step  int
 }
 
-func (o *ServerGetMetricsOpts) addQueryParams(req *http.Request) error {
-	query := req.URL.Query()
-
+func (o ServerGetMetricsOpts) Validate() error {
 	if len(o.Types) == 0 {
 		return fmt.Errorf("no metric types specified")
 	}
-	for _, typ := range o.Types {
-		query.Add("type", string(typ))
-	}
-
 	if o.Start.IsZero() {
 		return fmt.Errorf("no start time specified")
 	}
-	query.Add("start", o.Start.Format(time.RFC3339))
-
 	if o.End.IsZero() {
 		return fmt.Errorf("no end time specified")
 	}
+	return nil
+}
+
+func (o ServerGetMetricsOpts) values() url.Values {
+	query := url.Values{}
+
+	for _, typ := range o.Types {
+		query.Add("type", string(typ))
+	}
+
+	query.Add("start", o.Start.Format(time.RFC3339))
 	query.Add("end", o.End.Format(time.RFC3339))
 
 	if o.Step > 0 {
 		query.Add("step", strconv.Itoa(o.Step))
 	}
-	req.URL.RawQuery = query.Encode()
 
-	return nil
+	return query
 }
 
 // ServerMetrics contains the metrics requested for a Server.
@@ -1102,29 +1097,27 @@ type ServerMetricsValue struct {
 
 // GetMetrics obtains metrics for Server.
 func (c *ServerClient) GetMetrics(ctx context.Context, server *Server, opts ServerGetMetricsOpts) (*ServerMetrics, *Response, error) {
-	var respBody schema.ServerGetMetricsResponse
-
 	if server == nil {
 		return nil, nil, fmt.Errorf("illegal argument: server is nil")
 	}
 
-	path := fmt.Sprintf("/servers/%d/metrics", server.ID)
-	req, err := c.client.NewRequest(ctx, "GET", path, nil)
-	if err != nil {
-		return nil, nil, fmt.Errorf("new request: %v", err)
-	}
-	if err := opts.addQueryParams(req); err != nil {
-		return nil, nil, fmt.Errorf("add query params: %v", err)
+	if err := opts.Validate(); err != nil {
+		return nil, nil, err
 	}
-	resp, err := c.client.Do(req, &respBody)
+
+	reqPath := fmt.Sprintf("/servers/%d/metrics?%s", server.ID, opts.values().Encode())
+
+	respBody, resp, err := getRequest[schema.ServerGetMetricsResponse](ctx, c.client, reqPath)
 	if err != nil {
-		return nil, resp, fmt.Errorf("get metrics: %v", err)
+		return nil, resp, err
 	}
-	ms, err := serverMetricsFromSchema(&respBody)
+
+	metrics, err := serverMetricsFromSchema(&respBody)
 	if err != nil {
-		return nil, nil, fmt.Errorf("convert response body: %v", err)
+		return nil, nil, fmt.Errorf("convert response body: %w", err)
 	}
-	return ms, resp, nil
+
+	return metrics, resp, nil
 }
 
 func (c *ServerClient) AddToPlacementGroup(ctx context.Context, server *Server, placementGroup *PlacementGroup) (*Action, *Response, error) {
diff --git a/hcloud/ssh_key.go b/hcloud/ssh_key.go
index ab1d5b0..2d5fb79 100644
--- a/hcloud/ssh_key.go
+++ b/hcloud/ssh_key.go
@@ -1,9 +1,7 @@
 package hcloud
 
 import (
-	"bytes"
 	"context"
-	"encoding/json"
 	"errors"
 	"fmt"
 	"net/url"
@@ -142,21 +140,14 @@ func (c *SSHKeyClient) Create(ctx context.Context, opts SSHKeyCreateOpts) (*SSHK
 	if opts.Labels != nil {
 		reqBody.Labels = &opts.Labels
 	}
-	reqBodyData, err := json.Marshal(reqBody)
-	if err != nil {
-		return nil, nil, err
-	}
 
-	req, err := c.client.NewRequest(ctx, "POST", "/ssh_keys", bytes.NewReader(reqBodyData))
-	if err != nil {
-		return nil, nil, err
-	}
+	reqPath := "/ssh_keys"
 
-	var respBody schema.SSHKeyCreateResponse
-	resp, err := c.client.Do(req, &respBody)
+	respBody, resp, err := postRequest[schema.SSHKeyCreateResponse](ctx, c.client, reqPath, reqBody)
 	if err != nil {
 		return nil, resp, err
 	}
+
 	return SSHKeyFromSchema(respBody.SSHKey), resp, nil
 }
 
diff --git a/hcloud/volume.go b/hcloud/volume.go
index 9caf35d..80e9340 100644
--- a/hcloud/volume.go
+++ b/hcloud/volume.go
@@ -172,8 +172,10 @@ type VolumeCreateResult struct {
 
 // Create creates a new volume with the given options.
 func (c *VolumeClient) Create(ctx context.Context, opts VolumeCreateOpts) (VolumeCreateResult, *Response, error) {
+	result := VolumeCreateResult{}
+
 	if err := opts.Validate(); err != nil {
-		return VolumeCreateResult{}, nil, err
+		return result, nil, err
 	}
 	reqBody := schema.VolumeCreateRequest{
 		Name:      opts.Name,
@@ -193,32 +195,20 @@ func (c *VolumeClient) Create(ctx context.Context, opts VolumeCreateOpts) (Volum
 		}
 	}
 
-	reqBodyData, err := json.Marshal(reqBody)
-	if err != nil {
-		return VolumeCreateResult{}, nil, err
-	}
-
-	req, err := c.client.NewRequest(ctx, "POST", "/volumes", bytes.NewReader(reqBodyData))
-	if err != nil {
-		return VolumeCreateResult{}, nil, err
-	}
+	reqPath := "/volumes"
 
-	var respBody schema.VolumeCreateResponse
-	resp, err := c.client.Do(req, &respBody)
+	respBody, resp, err := postRequest[schema.VolumeCreateResponse](ctx, c.client, reqPath, reqBody)
 	if err != nil {
-		return VolumeCreateResult{}, resp, err
+		return result, resp, err
 	}
 
-	var action *Action
+	result.Volume = VolumeFromSchema(respBody.Volume)
 	if respBody.Action != nil {
-		action = ActionFromSchema(*respBody.Action)
+		result.Action = ActionFromSchema(*respBody.Action)
 	}
+	result.NextActions = ActionsFromSchema(respBody.NextActions)
 
-	return VolumeCreateResult{
-		Volume:      VolumeFromSchema(respBody.Volume),
-		Action:      action,
-		NextActions: ActionsFromSchema(respBody.NextActions),
-	}, resp, nil
+	return result, resp, nil
 }
 
 // Delete deletes a volume.
diff --git a/hcloud/zz_load_balancer_client_iface.go b/hcloud/zz_load_balancer_client_iface.go
index 2665f22..16a6359 100644
--- a/hcloud/zz_load_balancer_client_iface.go
+++ b/hcloud/zz_load_balancer_client_iface.go
@@ -64,7 +64,7 @@ type ILoadBalancerClient interface {
 	// ChangeType changes a Load Balancer's type.
 	ChangeType(ctx context.Context, loadBalancer *LoadBalancer, opts LoadBalancerChangeTypeOpts) (*Action, *Response, error)
 	// GetMetrics obtains metrics for a Load Balancer.
-	GetMetrics(ctx context.Context, lb *LoadBalancer, opts LoadBalancerGetMetricsOpts) (*LoadBalancerMetrics, *Response, error)
+	GetMetrics(ctx context.Context, loadBalancer *LoadBalancer, opts LoadBalancerGetMetricsOpts) (*LoadBalancerMetrics, *Response, error)
 	// ChangeDNSPtr changes or resets the reverse DNS pointer for a Load Balancer.
 	// Pass a nil ptr to reset the reverse DNS pointer to its default value.
 	ChangeDNSPtr(ctx context.Context, lb *LoadBalancer, ip string, ptr *string) (*Action, *Response, error)


Test patch:
diff --git a/hcloud/load_balancer_test.go b/hcloud/load_balancer_test.go
index ff17b2a..26176d4 100644
--- a/hcloud/load_balancer_test.go
+++ b/hcloud/load_balancer_test.go
@@ -1155,7 +1155,7 @@ func TestLoadBalancerGetMetrics(t *testing.T) {
 				Start: mustParseTime(t, "2017-01-01T00:00:00Z"),
 				End:   mustParseTime(t, "2017-01-01T23:00:00Z"),
 			},
-			expectedErr: "add query params: no metric types specified",
+			expectedErr: "no metric types specified",
 		},
 		{
 			name: "no start time",
@@ -1164,7 +1164,7 @@ func TestLoadBalancerGetMetrics(t *testing.T) {
 				Types: []LoadBalancerMetricType{LoadBalancerMetricBandwidth},
 				End:   mustParseTime(t, "2017-01-01T23:00:00Z"),
 			},
-			expectedErr: "add query params: no start time specified",
+			expectedErr: "no start time specified",
 		},
 		{
 			name: "no end time",
@@ -1173,7 +1173,7 @@ func TestLoadBalancerGetMetrics(t *testing.T) {
 				Types: []LoadBalancerMetricType{LoadBalancerMetricBandwidth},
 				Start: mustParseTime(t, "2017-01-01T00:00:00Z"),
 			},
-			expectedErr: "add query params: no end time specified",
+			expectedErr: "no end time specified",
 		},
 		{
 			name: "call to backend API fails",
@@ -1184,7 +1184,7 @@ func TestLoadBalancerGetMetrics(t *testing.T) {
 				End:   mustParseTime(t, "2017-01-01T23:00:00Z"),
 			},
 			respStatus:  http.StatusInternalServerError,
-			expectedErr: "get metrics: hcloud: server responded with status code 500",
+			expectedErr: "hcloud: server responded with status code 500",
 		},
 		{
 			name: "no load balancer passed",
diff --git a/hcloud/server_test.go b/hcloud/server_test.go
index b170ac2..5ccbd6e 100644
--- a/hcloud/server_test.go
+++ b/hcloud/server_test.go
@@ -2242,7 +2242,7 @@ func TestServerGetMetrics(t *testing.T) {
 				Start: mustParseTime(t, "2017-01-01T00:00:00Z"),
 				End:   mustParseTime(t, "2017-01-01T23:00:00Z"),
 			},
-			expectedErr: "add query params: no metric types specified",
+			expectedErr: "no metric types specified",
 		},
 		{
 			name:   "no start time",
@@ -2251,7 +2251,7 @@ func TestServerGetMetrics(t *testing.T) {
 				Types: []ServerMetricType{ServerMetricCPU},
 				End:   mustParseTime(t, "2017-01-01T23:00:00Z"),
 			},
-			expectedErr: "add query params: no start time specified",
+			expectedErr: "no start time specified",
 		},
 		{
 			name:   "no end time",
@@ -2260,7 +2260,7 @@ func TestServerGetMetrics(t *testing.T) {
 				Types: []ServerMetricType{ServerMetricCPU},
 				Start: mustParseTime(t, "2017-01-01T00:00:00Z"),
 			},
-			expectedErr: "add query params: no end time specified",
+			expectedErr: "no end time specified",
 		},
 		{
 			name:   "call to backend API fails",
@@ -2271,7 +2271,7 @@ func TestServerGetMetrics(t *testing.T) {
 				End:   mustParseTime(t, "2017-01-01T23:00:00Z"),
 			},
 			respStatus:  http.StatusInternalServerError,
-			expectedErr: "get metrics: hcloud: server responded with status code 500",
+			expectedErr: "hcloud: server responded with status code 500",
 		},
 		{
 			name: "no server passed",