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 }