github.com/letsencrypt/boulder@v0.20251208.0/sfe/zendesk/zendesk.go (about) 1 package zendesk 2 3 import ( 4 "bytes" 5 "encoding/json" 6 "fmt" 7 "io" 8 "net/http" 9 "net/url" 10 "slices" 11 "strings" 12 "time" 13 ) 14 15 const ( 16 apiPath = "api/v2/" 17 ticketsJSONPath = apiPath + "tickets.json" 18 searchJSONPath = apiPath + "search.json" 19 ) 20 21 // Note: This is client is NOT compatible with custom ticket statuses, it only 22 // supports the default Zendesk ticket statuses. For more information, see: 23 // https://developer.zendesk.com/api-reference/ticketing/tickets/custom_ticket_statuses 24 // https://developer.zendesk.com/api-reference/ticketing/tickets/tickets/#custom-ticket-statuses 25 var validStatuses = []string{"new", "open", "pending", "hold", "solved"} 26 27 // Client is a Zendesk client that allows you to create tickets, search for 28 // tickets, and add comments to tickets via the Zendesk REST API. It uses basic 29 // authentication with an API token. 30 type Client struct { 31 httpClient *http.Client 32 tokenEmail string 33 token string 34 35 ticketsURL string 36 searchURL string 37 updateURL string 38 39 nameToFieldID map[string]int64 40 fieldIDToName map[int64]string 41 } 42 43 // NewClient creates a new Zendesk client with the provided baseURL, token, and 44 // tokenEmail, and a name to field id mapping. baseURL is your Zendesk URL with 45 // the scheme included, e.g. "https://yourdomain.zendesk.com". Token is an API 46 // token generated from the Zendesk Admin UI. The tokenEmail is the email 47 // address of the Zendesk user that created the token. The nameToFieldID must 48 // contain the display names and corresponding IDs of the custom fields you want 49 // to use in your tickets, this allows you to refer to custom fields by string 50 // names instead of numeric IDs when working with tickets. 51 func NewClient(baseURL, tokenEmail, token string, nameToFieldID map[string]int64) (*Client, error) { 52 ticketsURL, err := url.JoinPath(baseURL, ticketsJSONPath) 53 if err != nil { 54 return nil, fmt.Errorf("failed to join tickets path: %w", err) 55 } 56 searchURL, err := url.JoinPath(baseURL, searchJSONPath) 57 if err != nil { 58 return nil, fmt.Errorf("failed to join search path: %w", err) 59 } 60 updateURL, err := url.JoinPath(baseURL, apiPath, "tickets") 61 if err != nil { 62 return nil, fmt.Errorf("failed to join comment path: %w", err) 63 } 64 fieldIDToName := make(map[int64]string, len(nameToFieldID)) 65 for name, id := range nameToFieldID { 66 _, ok := fieldIDToName[id] 67 if ok { 68 return nil, fmt.Errorf("duplicate field ID %d for field %q", id, name) 69 } 70 fieldIDToName[id] = name 71 } 72 return &Client{ 73 httpClient: &http.Client{Timeout: 15 * time.Second}, 74 tokenEmail: tokenEmail, 75 token: token, 76 ticketsURL: ticketsURL, 77 searchURL: searchURL, 78 updateURL: updateURL, 79 nameToFieldID: nameToFieldID, 80 fieldIDToName: fieldIDToName, 81 }, nil 82 } 83 84 // requester represents the requester of a Zendesk ticket. It contains the 85 // requester's name and email address. Both fields are required when creating a 86 // new ticket. 87 // 88 // For more information, see the Zendesk API documentation: 89 // https://developer.zendesk.com/documentation/ticketing/managing-tickets/creating-and-updating-tickets/#creating-a-ticket-with-a-new-requester 90 type requester struct { 91 // Name is the name of the requester, it is a required field. 92 Name string `json:"name"` 93 94 // Email is the email address of the requester, it is a required field. 95 Email string `json:"email"` 96 } 97 98 // comment represents a comment on a Zendesk ticket. It contains the body of the 99 // comment and whether the comment is public or private. The body is a required 100 // field when creating a new ticket or adding a comment to an existing ticket. 101 // 102 // For more information, see the Zendesk API documentation: 103 // https://developer.zendesk.com/api-reference/ticketing/tickets/ticket_comments/ 104 type comment struct { 105 // Body is the content of the comment, it is a required field. 106 Body string `json:"body"` 107 108 // Public indicates whether the comment is public or private. 109 Public bool `json:"public"` 110 } 111 112 // customField represents a custom field in a Zendesk ticket. It contains the ID 113 // of the custom field and its value. 114 // 115 // For more information, see the Zendesk API documentation: 116 // https://developer.zendesk.com/documentation/ticketing/managing-tickets/creating-and-updating-tickets/#setting-custom-field-values 117 type customField struct { 118 // ID is the ID of the custom field in Zendesk. It is a required field. 119 ID int64 `json:"id"` 120 121 // Value is the value of the custom field. 122 Value string `json:"value"` 123 } 124 125 // ticket represents a Zendesk ticket. It contains the requester, subject, 126 // initial comment, and optional custom fields. The requester and subject are 127 // required fields when creating a new ticket. 128 // 129 // For more information, see the Zendesk API documentation: 130 // https://developer.zendesk.com/api-reference/ticketing/tickets/ticket_fields/#json-format 131 // https://developer.zendesk.com/documentation/ticketing/managing-tickets/creating-and-updating-tickets/#creating-a-ticket-with-a-new-requester 132 type ticket struct { 133 // Requester is the requester of the ticket, it is a required field. 134 Requester requester `json:"requester"` 135 136 // Subject is the subject of the ticket, it is a required field. 137 Subject string `json:"subject"` 138 139 // Comment is the initial comment on the ticket. It is a required field. If 140 // you want to add additional comments later, use the AddComment method. 141 Comment comment `json:"comment"` 142 143 // CustomFields is a list of custom fields and their corresponding values. 144 // It is optional, but if you want to set custom fields you must provide 145 // them here. 146 // 147 // For more information, see the Zendesk API documentation: 148 // https://developer.zendesk.com/documentation/ticketing/managing-tickets/creating-and-updating-tickets/#setting-custom-field-values 149 CustomFields []customField `json:"custom_fields,omitempty"` 150 } 151 152 // doJSONRequest constructs and sends an HTTP request to the Zendesk API using 153 // the specified method and URL. It sets the appropriate headers for JSON 154 // content and basic authentication using the tokenEmail and token. The response 155 // body or an error is returned. 156 // 157 // https://developer.zendesk.com/api-reference/introduction/requests/#request-format 158 // https://developer.zendesk.com/api-reference/introduction/security-and-auth/#api-token 159 func (c *Client) doJSONRequest(method, reqURL string, body []byte) ([]byte, error) { 160 var reader io.Reader 161 if len(body) > 0 { 162 reader = bytes.NewReader(body) 163 } 164 req, err := http.NewRequest(method, reqURL, reader) 165 if err != nil { 166 return nil, fmt.Errorf("failed to create zendesk request: %w", err) 167 } 168 req.SetBasicAuth(c.tokenEmail+"/token", c.token) 169 req.Header.Set("Accept", "application/json") 170 if reader != nil { 171 req.Header.Set("Content-Type", "application/json") 172 } 173 174 resp, err := c.httpClient.Do(req) 175 if err != nil { 176 return nil, fmt.Errorf("zendesk request failed: %w", err) 177 } 178 defer resp.Body.Close() 179 180 respBody, err := io.ReadAll(resp.Body) 181 if err != nil { 182 return nil, fmt.Errorf("failed to read zendesk response body: %w", err) 183 } 184 185 if resp.StatusCode >= 300 { 186 return nil, fmt.Errorf("zendesk returned status %d: %s", resp.StatusCode, respBody) 187 } 188 return respBody, nil 189 } 190 191 // CreateTicket creates a new Zendesk ticket with the provided requester email, 192 // subject, initial comment body, and custom fields. It returns the ID of the 193 // created ticket or an error if the request fails. The custom fields should be 194 // provided as a map where the keys are the display names of the custom fields 195 // and the values are the desired values for those fields. The method will 196 // convert the display names to their corresponding field IDs using the 197 // nameToFieldID map provided when creating the Client. If a custom field name 198 // is unknown, an error will be returned. 199 func (c *Client) CreateTicket(requesterEmail, subject, commentBody string, fields map[string]string) (int64, error) { 200 ticketContents := ticket{ 201 Requester: requester{ 202 // Here we use the requesterEmail as both the email and name. This 203 // is done intentionally to keep PII to a minimum. 204 Email: requesterEmail, 205 Name: requesterEmail, 206 }, 207 Subject: subject, 208 Comment: comment{ 209 Body: commentBody, 210 Public: true, 211 }, 212 } 213 for name, value := range fields { 214 if value == "" { 215 // Zendesk will ignore empty custom fields, but we can avoid sending 216 // them across the wire at all. 217 continue 218 } 219 id, ok := c.nameToFieldID[name] 220 if !ok { 221 return 0, fmt.Errorf("unknown custom field %q", name) 222 } 223 ticketContents.CustomFields = append(ticketContents.CustomFields, customField{ 224 ID: id, 225 Value: value, 226 }) 227 } 228 229 // For more information on the ticket creation format, see: 230 // https://developer.zendesk.com/api-reference/introduction/requests/#request-format 231 body, err := json.Marshal(struct { 232 Ticket ticket `json:"ticket"` 233 }{Ticket: ticketContents}) 234 if err != nil { 235 return 0, fmt.Errorf("failed to marshal zendesk ticket: %w", err) 236 } 237 238 body, err = c.doJSONRequest(http.MethodPost, c.ticketsURL, body) 239 if err != nil { 240 return 0, fmt.Errorf("failed to create zendesk ticket: %w", err) 241 } 242 243 // For more information on the response format, see: 244 // https://developer.zendesk.com/api-reference/introduction/requests/#response-format 245 var result struct { 246 Ticket struct { 247 ID int64 `json:"id"` 248 } `json:"ticket"` 249 } 250 err = json.Unmarshal(body, &result) 251 if err != nil { 252 return 0, fmt.Errorf("failed to unmarshal zendesk response: %w", err) 253 } 254 if result.Ticket.ID == 0 { 255 return 0, fmt.Errorf("zendesk did not return a valid ticket ID") 256 } 257 return result.Ticket.ID, nil 258 } 259 260 // FindTickets returns all tickets whose custom fields match the required 261 // matchFields and optional status. The matchFields map should contain the 262 // display names of the custom fields as keys and the desired values as values. 263 // The method returns a map where the keys are ticket IDs and the values are 264 // maps of custom field names to their values. If no matchFields are supplied, 265 // an error is returned. If a custom field name is unknown, an error is returned. 266 func (c *Client) FindTickets(matchFields map[string]string, status string) (map[int64]map[string]string, error) { 267 if len(matchFields) == 0 { 268 return nil, fmt.Errorf("no match fields supplied") 269 } 270 271 // Below we're building a very basic search query using the Zendesk query 272 // syntax, for more information see: 273 // https://developer.zendesk.com/documentation/api-basics/working-with-data/searching-with-the-zendesk-api/#basic-query-syntax 274 275 query := []string{"type:ticket"} 276 277 if status != "" { 278 if !slices.Contains(validStatuses, status) { 279 return nil, fmt.Errorf("invalid status %q, must be one of %s", status, validStatuses) 280 } 281 query = append(query, fmt.Sprintf("status:%s", status)) 282 } 283 284 for name, want := range matchFields { 285 id, ok := c.nameToFieldID[name] 286 if !ok { 287 return nil, fmt.Errorf("unknown custom field %q", name) 288 } 289 290 // According to the Zendesk API documentation, if a value contains 291 // spaces, it must be quoted. If the value does not contain spaces, it 292 // must not be quoted. We have observed that the Zendesk API does reject 293 // queries with improper quoting. 294 val := want 295 if strings.Contains(val, " ") { 296 val = fmt.Sprintf("%q", val) 297 } 298 query = append(query, fmt.Sprintf("custom_field_%d:%s", id, val)) 299 } 300 301 searchURL := c.searchURL + "?query=" + url.QueryEscape(strings.Join(query, " ")) 302 out := make(map[int64]map[string]string) 303 304 for searchURL != "" { 305 body, err := c.doJSONRequest(http.MethodGet, searchURL, nil) 306 if err != nil { 307 return nil, fmt.Errorf("failed to search zendesk tickets: %w", err) 308 } 309 310 var results struct { 311 Results []struct { 312 ID int64 `json:"id"` 313 CustomFields []struct { 314 ID int64 `json:"id"` 315 Value any `json:"value"` 316 } `json:"custom_fields"` 317 } `json:"results"` 318 Next *string `json:"next_page"` 319 } 320 err = json.Unmarshal(body, &results) 321 if err != nil { 322 return nil, fmt.Errorf("failed to decode zendesk response: %w", err) 323 } 324 325 for _, result := range results.Results { 326 fieldMap := make(map[string]string) 327 for _, cf := range result.CustomFields { 328 name, ok := c.fieldIDToName[cf.ID] 329 if ok { 330 fieldMap[name] = fmt.Sprint(cf.Value) 331 } 332 } 333 out[result.ID] = fieldMap 334 } 335 if results.Next == nil { 336 break 337 } 338 searchURL = *results.Next 339 } 340 return out, nil 341 } 342 343 // AddComment posts the comment body to the specified ticket. The comment is 344 // added as a public or private comment based on the provided boolean value. An 345 // error is returned if the request fails. 346 func (c *Client) AddComment(ticketID int64, commentBody string, public bool) error { 347 endpoint, err := url.JoinPath(c.updateURL, fmt.Sprintf("%d.json", ticketID)) 348 if err != nil { 349 return fmt.Errorf("failed to join ticket path: %w", err) 350 } 351 352 // For more information on the comment format, see: 353 // https://developer.zendesk.com/api-reference/ticketing/tickets/ticket_comments/ 354 payload := struct { 355 Ticket struct { 356 Comment comment `json:"comment"` 357 } `json:"ticket"` 358 }{} 359 payload.Ticket.Comment = comment{Body: commentBody, Public: public} 360 361 body, err := json.Marshal(payload) 362 if err != nil { 363 return fmt.Errorf("failed to marshal zendesk comment: %w", err) 364 } 365 366 _, err = c.doJSONRequest(http.MethodPut, endpoint, body) 367 if err != nil { 368 return fmt.Errorf("failed to add comment to zendesk ticket %d: %w", ticketID, err) 369 } 370 return nil 371 } 372 373 // UpdateTicketStatus updates the status of the specified ticket to the provided 374 // status and adds a comment with the provided body. The comment is added as a 375 // public or private comment based on the provided boolean value. An error is 376 // returned if the request fails or if the provided status is invalid. 377 func (c *Client) UpdateTicketStatus(ticketID int64, status string, commentBody string, public bool) error { 378 if !slices.Contains(validStatuses, status) { 379 return fmt.Errorf("invalid status %q, must be one of %s", status, validStatuses) 380 } 381 382 endpoint, err := url.JoinPath(c.updateURL, fmt.Sprintf("%d.json", ticketID)) 383 if err != nil { 384 return fmt.Errorf("failed to join ticket path: %w", err) 385 } 386 387 // For more information on the status update format, see: 388 // https://developer.zendesk.com/api-reference/ticketing/tickets/tickets/#update-ticket 389 payload := struct { 390 Ticket struct { 391 Comment comment `json:"comment"` 392 Status string `json:"status"` 393 } `json:"ticket"` 394 }{} 395 payload.Ticket.Comment = comment{Body: commentBody, Public: public} 396 payload.Ticket.Status = status 397 398 body, err := json.Marshal(payload) 399 if err != nil { 400 return fmt.Errorf("failed to marshal zendesk status update: %w", err) 401 } 402 403 _, err = c.doJSONRequest(http.MethodPut, endpoint, body) 404 if err != nil { 405 return fmt.Errorf("failed to update zendesk ticket %d: %w", ticketID, err) 406 } 407 return nil 408 }