client.go 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. /*
  2. Copyright © 2025 ESO Maintainer Team
  3. Licensed under the Apache License, Version 2.0 (the "License");
  4. you may not use this file except in compliance with the License.
  5. You may obtain a copy of the License at
  6. https://www.apache.org/licenses/LICENSE-2.0
  7. Unless required by applicable law or agreed to in writing, software
  8. distributed under the License is distributed on an "AS IS" BASIS,
  9. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  10. See the License for the specific language governing permissions and
  11. limitations under the License.
  12. */
  13. // Package client provides the client implementation for interacting with Doppler's API.
  14. package client
  15. import (
  16. "bytes"
  17. "crypto/tls"
  18. "encoding/json"
  19. "fmt"
  20. "io"
  21. "net/http"
  22. "net/url"
  23. "strings"
  24. "time"
  25. )
  26. // DopplerClient represents a client for interacting with Doppler's API.
  27. type DopplerClient struct {
  28. baseURL *url.URL
  29. DopplerToken string
  30. VerifyTLS bool
  31. UserAgent string
  32. }
  33. type queryParams map[string]string
  34. type headers map[string]string
  35. type httpRequestBody []byte
  36. // Secrets represents a map of secret names to their values.
  37. type Secrets map[string]string
  38. // Change represents a request to modify a secret in Doppler.
  39. type Change struct {
  40. Name string `json:"name"`
  41. OriginalName string `json:"originalName"`
  42. Value *string `json:"value"`
  43. ShouldDelete bool `json:"shouldDelete,omitempty"`
  44. }
  45. // APIError represents an error returned by the Doppler API.
  46. type APIError struct {
  47. Err error
  48. Message string
  49. Data string
  50. }
  51. type apiResponse struct {
  52. HTTPResponse *http.Response
  53. Body []byte
  54. }
  55. type apiErrorResponse struct {
  56. Messages []string
  57. Success bool
  58. }
  59. // SecretRequest represents a request to retrieve a single secret.
  60. type SecretRequest struct {
  61. Name string
  62. Project string
  63. Config string
  64. }
  65. // SecretsRequest represents a request to retrieve multiple secrets.
  66. type SecretsRequest struct {
  67. Project string
  68. Config string
  69. NameTransformer string
  70. Format string
  71. ETag string // Specifying an ETag implies that the caller has implemented response caching
  72. }
  73. // UpdateSecretsRequest represents a request to update secrets in Doppler.
  74. type UpdateSecretsRequest struct {
  75. Secrets Secrets `json:"secrets,omitempty"`
  76. ChangeRequests []Change `json:"change_requests,omitempty"`
  77. Project string `json:"project,omitempty"`
  78. Config string `json:"config,omitempty"`
  79. }
  80. type secretResponseBody struct {
  81. Name string `json:"name,omitempty"`
  82. Value struct {
  83. Raw *string `json:"raw"`
  84. Computed *string `json:"computed"`
  85. } `json:"value,omitempty"`
  86. Messages *[]string `json:"messages,omitempty"`
  87. Success bool `json:"success"`
  88. }
  89. // SecretResponse represents the response from retrieving a secret.
  90. type SecretResponse struct {
  91. Name string
  92. Value string
  93. }
  94. // SecretsResponse represents the response from retrieving multiple secrets.
  95. type SecretsResponse struct {
  96. Secrets Secrets
  97. Body []byte
  98. Modified bool
  99. ETag string
  100. }
  101. // NewDopplerClient creates a new Doppler API client.
  102. func NewDopplerClient(dopplerToken string) (*DopplerClient, error) {
  103. client := &DopplerClient{
  104. DopplerToken: dopplerToken,
  105. VerifyTLS: true,
  106. UserAgent: "doppler-external-secrets",
  107. }
  108. if err := client.SetBaseURL("https://api.doppler.com"); err != nil {
  109. return nil, &APIError{Err: err, Message: "setting base URL failed"}
  110. }
  111. return client, nil
  112. }
  113. // BaseURL returns the base URL of the Doppler API.
  114. func (c *DopplerClient) BaseURL() *url.URL {
  115. u := *c.baseURL
  116. return &u
  117. }
  118. // SetBaseURL sets the base URL for the Doppler API.
  119. func (c *DopplerClient) SetBaseURL(urlStr string) error {
  120. baseURL, err := url.Parse(strings.TrimSuffix(urlStr, "/"))
  121. if err != nil {
  122. return err
  123. }
  124. if baseURL.Scheme == "" {
  125. baseURL.Scheme = "https"
  126. }
  127. c.baseURL = baseURL
  128. return nil
  129. }
  130. // Authenticate validates the authentication credentials.
  131. func (c *DopplerClient) Authenticate() error {
  132. // Choose projects as a lightweight endpoint for testing authentication
  133. if _, err := c.performRequest("/v3/projects", "GET", headers{}, queryParams{}, httpRequestBody{}); err != nil {
  134. return err
  135. }
  136. return nil
  137. }
  138. // GetSecret retrieves a secret from Doppler.
  139. func (c *DopplerClient) GetSecret(request SecretRequest) (*SecretResponse, error) {
  140. params := request.buildQueryParams(request.Name)
  141. response, err := c.performRequest("/v3/configs/config/secret", "GET", headers{}, params, httpRequestBody{})
  142. if err != nil {
  143. return nil, err
  144. }
  145. var data secretResponseBody
  146. if err := json.Unmarshal(response.Body, &data); err != nil {
  147. return nil, &APIError{Err: err, Message: "unable to unmarshal secret payload", Data: string(response.Body)}
  148. }
  149. if data.Value.Computed == nil {
  150. return nil, &APIError{Message: fmt.Sprintf("secret '%s' not found", request.Name)}
  151. }
  152. return &SecretResponse{Name: data.Name, Value: *data.Value.Computed}, nil
  153. }
  154. // GetSecrets should only have an ETag supplied if Secrets are cached as SecretsResponse.Secrets will be nil if 304 (not modified) returned.
  155. func (c *DopplerClient) GetSecrets(request SecretsRequest) (*SecretsResponse, error) {
  156. headers := headers{}
  157. if request.ETag != "" {
  158. headers["if-none-match"] = request.ETag
  159. }
  160. if request.Format != "" && request.Format != "json" {
  161. headers["accept"] = "text/plain"
  162. }
  163. params := request.buildQueryParams()
  164. response, apiErr := c.performRequest("/v3/configs/config/secrets/download", "GET", headers, params, httpRequestBody{})
  165. if apiErr != nil {
  166. return nil, apiErr
  167. }
  168. if response.HTTPResponse.StatusCode == 304 {
  169. return &SecretsResponse{Modified: false, Secrets: nil, ETag: request.ETag}, nil
  170. }
  171. eTag := response.HTTPResponse.Header.Get("etag")
  172. // Format defeats JSON parsing
  173. if request.Format != "" {
  174. return &SecretsResponse{Modified: true, Body: response.Body, ETag: eTag}, nil
  175. }
  176. var secrets Secrets
  177. if err := json.Unmarshal(response.Body, &secrets); err != nil {
  178. return nil, &APIError{Err: err, Message: "unable to unmarshal secrets payload"}
  179. }
  180. return &SecretsResponse{Modified: true, Secrets: secrets, Body: response.Body, ETag: eTag}, nil
  181. }
  182. // UpdateSecrets updates secrets in Doppler.
  183. func (c *DopplerClient) UpdateSecrets(request UpdateSecretsRequest) error {
  184. body, jsonErr := json.Marshal(request)
  185. if jsonErr != nil {
  186. return &APIError{Err: jsonErr, Message: "unable to unmarshal update secrets payload"}
  187. }
  188. _, err := c.performRequest("/v3/configs/config/secrets", "POST", headers{}, queryParams{}, body)
  189. if err != nil {
  190. return err
  191. }
  192. return nil
  193. }
  194. func (r *SecretRequest) buildQueryParams(name string) queryParams {
  195. params := queryParams{}
  196. params["name"] = name
  197. if r.Project != "" {
  198. params["project"] = r.Project
  199. }
  200. if r.Config != "" {
  201. params["config"] = r.Config
  202. }
  203. return params
  204. }
  205. func (r *SecretsRequest) buildQueryParams() queryParams {
  206. params := queryParams{}
  207. if r.Project != "" {
  208. params["project"] = r.Project
  209. }
  210. if r.Config != "" {
  211. params["config"] = r.Config
  212. }
  213. if r.NameTransformer != "" {
  214. params["name_transformer"] = r.NameTransformer
  215. }
  216. if r.Format != "" {
  217. params["format"] = r.Format
  218. }
  219. return params
  220. }
  221. func (c *DopplerClient) performRequest(path, method string, headers headers, params queryParams, body httpRequestBody) (*apiResponse, error) {
  222. urlStr := c.BaseURL().String() + path
  223. reqURL, err := url.Parse(urlStr)
  224. if err != nil {
  225. return nil, &APIError{Err: err, Message: fmt.Sprintf("invalid API URL: %s", urlStr)}
  226. }
  227. var bodyReader io.Reader
  228. if body != nil {
  229. bodyReader = bytes.NewReader(body)
  230. } else {
  231. bodyReader = http.NoBody
  232. }
  233. req, err := http.NewRequest(method, reqURL.String(), bodyReader)
  234. if err != nil {
  235. return nil, &APIError{Err: err, Message: "unable to form HTTP request"}
  236. }
  237. if method == "POST" && req.Header.Get("content-type") == "" {
  238. req.Header.Set("content-type", "application/json")
  239. }
  240. if req.Header.Get("accept") == "" {
  241. req.Header.Set("accept", "application/json")
  242. }
  243. req.Header.Set("user-agent", c.UserAgent)
  244. req.SetBasicAuth(c.DopplerToken, "")
  245. for key, value := range headers {
  246. req.Header.Set(key, value)
  247. }
  248. query := req.URL.Query()
  249. for key, value := range params {
  250. query.Add(key, value)
  251. }
  252. req.URL.RawQuery = query.Encode()
  253. httpClient := &http.Client{Timeout: 10 * time.Second}
  254. tlsConfig := &tls.Config{
  255. MinVersion: tls.VersionTLS12,
  256. }
  257. if !c.VerifyTLS {
  258. tlsConfig.InsecureSkipVerify = true
  259. }
  260. httpClient.Transport = &http.Transport{
  261. DisableKeepAlives: true,
  262. TLSClientConfig: tlsConfig,
  263. }
  264. r, err := httpClient.Do(req)
  265. if err != nil {
  266. return nil, &APIError{Err: err, Message: "unable to load response"}
  267. }
  268. defer func() {
  269. _ = r.Body.Close()
  270. }()
  271. bodyResponse, err := io.ReadAll(r.Body)
  272. if err != nil {
  273. return &apiResponse{HTTPResponse: r, Body: nil}, &APIError{Err: err, Message: "unable to read entire response body"}
  274. }
  275. response := &apiResponse{HTTPResponse: r, Body: bodyResponse}
  276. success := isSuccess(r.StatusCode)
  277. if !success {
  278. if contentType := r.Header.Get("content-type"); strings.HasPrefix(contentType, "application/json") {
  279. var errResponse apiErrorResponse
  280. err := json.Unmarshal(bodyResponse, &errResponse)
  281. if err != nil {
  282. return response, &APIError{Err: err, Message: "unable to unmarshal error JSON payload"}
  283. }
  284. return response, &APIError{Err: nil, Message: strings.Join(errResponse.Messages, "\n")}
  285. }
  286. return nil, &APIError{Err: fmt.Errorf("%d status code; %d bytes", r.StatusCode, len(bodyResponse)), Message: "unable to load response"}
  287. }
  288. if success && err != nil {
  289. return nil, &APIError{Err: err, Message: "unable to load data from successful response"}
  290. }
  291. return response, nil
  292. }
  293. func isSuccess(statusCode int) bool {
  294. return (statusCode >= 200 && statusCode <= 299) || (statusCode >= 300 && statusCode <= 399)
  295. }
  296. func (e *APIError) Error() string {
  297. message := fmt.Sprintf("Doppler API Client Error: %s", e.Message)
  298. if underlyingError := e.Err; underlyingError != nil {
  299. message = fmt.Sprintf("%s\n%s", message, underlyingError.Error())
  300. }
  301. if e.Data != "" {
  302. message = fmt.Sprintf("%s\nData: %s", message, e.Data)
  303. }
  304. return message
  305. }