108 lines
3.3 KiB
Go
108 lines
3.3 KiB
Go
package configread
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
)
|
|
|
|
const dispatcherIDHeader = "X-DISPATCHER-id"
|
|
const dispatcherSecretHeader = "X-DISPATCHER-SECRET-KEY"
|
|
const configReadPath = "/internal/v1/dispatcher"
|
|
|
|
type Client struct {
|
|
baseURL string
|
|
dispatcherID string
|
|
secret string
|
|
httpClient *http.Client
|
|
}
|
|
|
|
type HTTPError struct {
|
|
StatusCode int
|
|
Code string
|
|
}
|
|
|
|
func (e *HTTPError) Error() string {
|
|
if e.Code == "" {
|
|
return fmt.Sprintf("configuration service returned HTTP %d", e.StatusCode)
|
|
}
|
|
return fmt.Sprintf("configuration service returned HTTP %d (%s)", e.StatusCode, e.Code)
|
|
}
|
|
|
|
func NewClient(baseURL, dispatcherID, secret string, httpClient *http.Client) (*Client, error) {
|
|
parsed, err := url.ParseRequestURI(baseURL)
|
|
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
|
|
return nil, errors.New("configuration service URL must be an absolute HTTP(S) URL without credentials, query, or fragment")
|
|
}
|
|
if strings.TrimSpace(dispatcherID) == "" || strings.TrimSpace(secret) == "" {
|
|
return nil, errors.New("dispatcher ID and secret are required")
|
|
}
|
|
if strings.ContainsAny(dispatcherID+secret, "\r\n") {
|
|
return nil, errors.New("dispatcher credentials contain invalid header characters")
|
|
}
|
|
if httpClient == nil {
|
|
httpClient = http.DefaultClient
|
|
}
|
|
return &Client{baseURL: strings.TrimRight(baseURL, "/"), dispatcherID: dispatcherID, secret: secret, httpClient: httpClient}, nil
|
|
}
|
|
|
|
func (c *Client) DispatcherID() string { return c.dispatcherID }
|
|
|
|
func (c *Client) get(ctx context.Context, path string) (json.RawMessage, error) {
|
|
relative, err := url.Parse(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse configuration path: %w", err)
|
|
}
|
|
base, err := url.Parse(c.baseURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse configuration base URL: %w", err)
|
|
}
|
|
base = base.JoinPath(relative.Path)
|
|
base.RawQuery = relative.RawQuery
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, base.String(), nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("build configuration request: %w", err)
|
|
}
|
|
request.Header.Set("Accept", "application/json")
|
|
request.Header.Set(dispatcherIDHeader, c.dispatcherID)
|
|
request.Header.Set(dispatcherSecretHeader, c.secret)
|
|
response, err := c.httpClient.Do(request)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("request configuration service: %w", err)
|
|
}
|
|
defer response.Body.Close()
|
|
body, err := io.ReadAll(response.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read configuration response: %w", err)
|
|
}
|
|
if response.StatusCode != http.StatusOK {
|
|
return nil, &HTTPError{StatusCode: response.StatusCode, Code: responseErrorCode(body)}
|
|
}
|
|
mediaType, _, err := mime.ParseMediaType(response.Header.Get("Content-Type"))
|
|
if err != nil || mediaType != "application/json" {
|
|
return nil, errors.New("configuration service response must use application/json")
|
|
}
|
|
if !json.Valid(body) {
|
|
return nil, errors.New("configuration service returned invalid JSON")
|
|
}
|
|
return json.RawMessage(body), nil
|
|
}
|
|
|
|
func responseErrorCode(body []byte) string {
|
|
var response struct {
|
|
Error struct {
|
|
Code string `json:"code"`
|
|
} `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(body, &response); err != nil {
|
|
return ""
|
|
}
|
|
return response.Error.Code
|
|
}
|