2023-05-21 11:46:23 +02:00
|
|
|
// SPDX-FileCopyrightText: 2023 Winni Neessen <wn@neessen.dev>
|
2023-05-12 12:44:27 +02:00
|
|
|
//
|
|
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
|
|
|
|
package meteologix
|
|
|
|
|
|
|
|
import (
|
|
|
|
"bufio"
|
|
|
|
"bytes"
|
|
|
|
"context"
|
|
|
|
"crypto/tls"
|
2023-05-14 15:55:03 +02:00
|
|
|
"encoding/json"
|
2023-05-12 12:44:27 +02:00
|
|
|
"errors"
|
|
|
|
"fmt"
|
|
|
|
"io"
|
2023-06-27 18:31:13 +02:00
|
|
|
"log"
|
2023-05-12 12:44:27 +02:00
|
|
|
"net/http"
|
2023-05-15 15:27:09 +02:00
|
|
|
"net/url"
|
2023-05-12 12:44:27 +02:00
|
|
|
"strings"
|
|
|
|
"time"
|
|
|
|
)
|
|
|
|
|
|
|
|
const (
|
|
|
|
// HTTPClientTimeout is the default timeout value for the HTTPClient
|
|
|
|
HTTPClientTimeout = time.Second * 10
|
|
|
|
// MIMETypeJSON is a string constant for application/json MIME type
|
|
|
|
MIMETypeJSON = "application/json"
|
|
|
|
)
|
|
|
|
|
|
|
|
// ErrNonJSONResponse is returned when a HTTPClient request did not return the
|
|
|
|
// expected application/json content type
|
|
|
|
var ErrNonJSONResponse = errors.New("HTTP response is of non-JSON content type")
|
|
|
|
|
|
|
|
// HTTPClient is a type wrapper for the Go stdlib http.Client and the Config
|
|
|
|
type HTTPClient struct {
|
|
|
|
*Config
|
|
|
|
*http.Client
|
|
|
|
}
|
|
|
|
|
2023-05-14 15:55:03 +02:00
|
|
|
// APIError wraps the error interface for the API
|
|
|
|
type APIError struct {
|
|
|
|
Code int `json:"status"`
|
|
|
|
Details string `json:"detail"`
|
|
|
|
Message string `json:"message"`
|
|
|
|
Title string `json:"title"`
|
|
|
|
Type string `json:"type"`
|
|
|
|
}
|
|
|
|
|
2023-05-12 12:44:27 +02:00
|
|
|
// NewHTTPClient returns a new HTTP client
|
|
|
|
func NewHTTPClient(c *Config) *HTTPClient {
|
|
|
|
tc := &tls.Config{
|
|
|
|
MinVersion: tls.VersionTLS12,
|
|
|
|
}
|
2023-06-27 18:31:13 +02:00
|
|
|
ht := &http.Transport{TLSClientConfig: tc}
|
2023-06-27 15:20:16 +02:00
|
|
|
hc := &http.Client{
|
|
|
|
Timeout: HTTPClientTimeout,
|
2023-06-27 18:31:13 +02:00
|
|
|
Transport: ht,
|
2023-06-27 15:20:16 +02:00
|
|
|
}
|
2023-05-12 12:44:27 +02:00
|
|
|
return &HTTPClient{c, hc}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Get performs a HTTP GET request for the given URL with the default HTTP timeout
|
|
|
|
func (hc *HTTPClient) Get(u string) ([]byte, error) {
|
|
|
|
return hc.GetWithTimeout(u, HTTPClientTimeout)
|
|
|
|
}
|
|
|
|
|
|
|
|
// GetWithTimeout performs a HTTP GET request for the given URL and sets a timeout
|
|
|
|
// context with the given timeout duration
|
|
|
|
func (hc *HTTPClient) GetWithTimeout(u string, t time.Duration) ([]byte, error) {
|
|
|
|
ctx, cfn := context.WithTimeout(context.Background(), t)
|
|
|
|
defer cfn()
|
|
|
|
hr, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
2023-05-13 13:18:47 +02:00
|
|
|
hr.Header.Set("User-Agent", hc.userAgent)
|
2023-05-12 12:44:27 +02:00
|
|
|
hr.Header.Set("Content-Type", MIMETypeJSON)
|
|
|
|
hr.Header.Set("Accept", MIMETypeJSON)
|
2023-05-13 13:18:47 +02:00
|
|
|
hr.Header.Set("Accept-Language", hc.acceptLang)
|
2023-05-12 12:44:27 +02:00
|
|
|
|
|
|
|
// User authentication (only required for Meteologix API calls)
|
|
|
|
if strings.HasPrefix(u, APIBaseURL) {
|
2023-06-27 18:31:13 +02:00
|
|
|
hc.setAuthentication(hr)
|
2023-05-12 12:44:27 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
sr, err := hc.Do(hr)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
2023-06-27 18:31:13 +02:00
|
|
|
if sr == nil {
|
|
|
|
return nil, errors.New("nil response received")
|
|
|
|
}
|
|
|
|
defer func(b io.ReadCloser) {
|
|
|
|
if err = b.Close(); err != nil {
|
|
|
|
log.Printf("failed to close HTTP request body: %s", err)
|
2023-05-12 12:44:27 +02:00
|
|
|
}
|
2023-06-27 18:31:13 +02:00
|
|
|
}(sr.Body)
|
2023-05-12 12:44:27 +02:00
|
|
|
|
|
|
|
if !strings.HasPrefix(sr.Header.Get("Content-Type"), MIMETypeJSON) {
|
|
|
|
return nil, ErrNonJSONResponse
|
|
|
|
}
|
2023-06-27 18:31:13 +02:00
|
|
|
if sr.StatusCode >= http.StatusBadRequest {
|
|
|
|
ae := new(APIError)
|
|
|
|
if err = json.NewDecoder(sr.Body).Decode(ae); err != nil {
|
2023-05-14 15:55:03 +02:00
|
|
|
return nil, fmt.Errorf("failed to unmarshal error JSON: %w", err)
|
|
|
|
}
|
|
|
|
if ae.Code < 1 {
|
|
|
|
ae.Code = sr.StatusCode
|
|
|
|
}
|
|
|
|
if ae.Details == "" {
|
|
|
|
ae.Details = sr.Status
|
|
|
|
}
|
2023-06-27 18:31:13 +02:00
|
|
|
return nil, *ae
|
|
|
|
}
|
|
|
|
|
|
|
|
buf := &bytes.Buffer{}
|
|
|
|
bw := bufio.NewWriter(buf)
|
|
|
|
_, err = io.Copy(bw, sr.Body)
|
|
|
|
if err != nil {
|
|
|
|
return nil, fmt.Errorf("failed to copy HTTP response body to buffer: %w", err)
|
|
|
|
}
|
|
|
|
if err = bw.Flush(); err != nil {
|
|
|
|
return nil, fmt.Errorf("failed to flush buffer: %w", err)
|
2023-05-14 15:55:03 +02:00
|
|
|
}
|
2023-05-12 12:44:27 +02:00
|
|
|
return buf.Bytes(), nil
|
|
|
|
}
|
|
|
|
|
2023-06-27 18:31:13 +02:00
|
|
|
// setAuthentication sets the corresponding user authentication header. If an API Key is set, this
|
2023-05-13 13:18:47 +02:00
|
|
|
// will be preferred, alternatively a username/authPass combination for HTTP Basic auth can
|
2023-05-12 12:44:27 +02:00
|
|
|
// be used
|
2023-06-27 18:31:13 +02:00
|
|
|
func (hc *HTTPClient) setAuthentication(hr *http.Request) {
|
2023-05-13 13:18:47 +02:00
|
|
|
if hc.apiKey != "" {
|
|
|
|
hr.Header.Set("X-API-Key", hc.Config.apiKey)
|
2023-05-12 12:44:27 +02:00
|
|
|
return
|
|
|
|
}
|
2023-08-02 16:08:38 +02:00
|
|
|
if hc.bearerToken != "" {
|
|
|
|
hr.Header.Set("Authorization", "Bearer"+hc.bearerToken)
|
|
|
|
return
|
|
|
|
}
|
2023-05-13 13:18:47 +02:00
|
|
|
if hc.authUser != "" && hc.authPass != "" {
|
2023-05-15 15:27:09 +02:00
|
|
|
hr.SetBasicAuth(url.QueryEscape(hc.authUser), url.QueryEscape(hc.authPass))
|
2023-05-12 12:44:27 +02:00
|
|
|
}
|
|
|
|
}
|
2023-05-14 15:55:03 +02:00
|
|
|
|
|
|
|
// Error satisfies the error interface for the APIError type
|
|
|
|
func (e APIError) Error() string {
|
|
|
|
var em strings.Builder
|
|
|
|
em.WriteString("API request failed with status HTTP ")
|
|
|
|
em.WriteString(fmt.Sprintf("%d: ", e.Code))
|
|
|
|
if e.Details != "" {
|
|
|
|
em.WriteString(e.Details)
|
|
|
|
}
|
|
|
|
if e.Message != "" {
|
|
|
|
em.WriteString(" (Optional message: ")
|
|
|
|
em.WriteString(e.Message)
|
|
|
|
em.WriteString(")")
|
|
|
|
}
|
|
|
|
return em.String()
|
|
|
|
}
|