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  }