-
Notifications
You must be signed in to change notification settings - Fork 17
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add support for fetching events and event payloads. (#20)
Adds support for the /v2/events API, including fetching event payloads. The code is implemented in such a way that it can be re-used for implementing an HTTP handler for receiving EasyPost event notifications (webhook).
- Loading branch information
Richard Shaffer
authored
Jun 10, 2020
1 parent
e2ae694
commit c75f665
Showing
10 changed files
with
1,579 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,147 @@ | ||
package easypost | ||
|
||
import ( | ||
"context" | ||
"encoding/base64" | ||
"encoding/json" | ||
"net/http" | ||
"time" | ||
) | ||
|
||
// Event objects contain details about changes to EasyPost objects | ||
type Event struct { | ||
ID string `json:"id,omitempty"` | ||
UserID string `json:"user_id,omitempty"` | ||
Object string `json:"object,omitempty"` | ||
Mode string `json:"mode,omitempty"` | ||
CreatedAt *time.Time `json:"created_at,omitempty"` | ||
UpdatedAt *time.Time `json:"updated_at,omitempty"` | ||
Description string `json:"description,omitempty"` | ||
PreviousAttributes map[string]interface{} `json:"previous_attributes,omitempty"` | ||
// Result will be populated with the relevant object type, i.e. | ||
// *Batch, *Insurance, *PaymentLog, *Refund, *Report, *Tracker or *ScanForm. | ||
// It will be nil if no 'result' field is present, which is the case for | ||
// the ListEvents and GetEvents methods. The RequestBody field of the | ||
// EventPayload type will generally be an instance of *Event with this field | ||
// present. Having the field here also enables re-using this type to | ||
// implement a webhook handler. | ||
Result interface{} `json:"result,omitempty"` | ||
Status string `json:"status,omitempty"` | ||
PendingURLs []string `json:"pending_urls,omitempty"` | ||
CompletedURLs []string `json:"completed_urls,omitempty"` | ||
} | ||
|
||
func (e *Event) UnmarshalJSON(data []byte) (err error) { | ||
var buf json.RawMessage | ||
event := Event{Result: &buf} | ||
|
||
type nonUnmarshaler *Event | ||
if err = json.Unmarshal(data, nonUnmarshaler(&event)); err != nil { | ||
return err | ||
} | ||
|
||
if event.Result, err = UnmarshalJSONObject(buf); err == nil { | ||
*e = event | ||
} | ||
|
||
return err | ||
} | ||
|
||
// EventPayload represents the result of a webhook call. | ||
type EventPayload struct { | ||
ID string `json:"id,omitempty"` | ||
Object string `json:"object,omitempty"` | ||
CreatedAt *time.Time `json:"created_at,omitempty"` | ||
UpdatedAt *time.Time `json:"updated_at,omitempty"` | ||
RequestURL string `json:"request_url,omitempty"` | ||
RequestHeaders map[string]string `json:"request_headers,omitempty"` | ||
// RequestBody is the raw request body that was sent to the webhook. This is | ||
// expected to be an Event object. It may either be encoded in the API | ||
// response as a string (with JSON delimiters escaped) or as base64. The | ||
// UnmarshalJSON method will attempt to convert it to an *Event type, but it | ||
// may be set to a default type if decoding to an object fails. | ||
RequestBody interface{} `json:"request_body,omitempty"` | ||
ResponseHeaders map[string]string `json:"response_headers,omitempty"` | ||
ResponseBody string `json:"response_body,omitempty"` | ||
ResponseCode int `json:"response_code,omitempty"` | ||
TotalTime int `json:"total_time,omitempty"` | ||
} | ||
|
||
func (e *EventPayload) UnmarshalJSON(data []byte) (err error) { | ||
var s string | ||
payload := EventPayload{RequestBody: &s} | ||
|
||
type nonUnmarshaler *EventPayload | ||
if err = json.Unmarshal(data, nonUnmarshaler(&payload)); err != nil { | ||
return err | ||
} | ||
|
||
// Attempt to base64 decode the body. Ignore errors. | ||
if buf, err := base64.StdEncoding.DecodeString(s); err == nil { | ||
s = string(buf) | ||
} | ||
|
||
// try to decode RequestBody to an object, but if we can't, then just | ||
// set it to the string. | ||
if payload.RequestBody, err = UnmarshalJSONObject([]byte(s)); err != nil { | ||
payload.RequestBody = s | ||
} | ||
|
||
*e = payload | ||
return nil | ||
} | ||
|
||
// ListEventsResult holds the results from the list events API. | ||
type ListEventsResult struct { | ||
Events []*Event `json:"events,omitempty"` | ||
// HasMore indicates if there are more responses to be fetched. If True, | ||
// additional responses can be fetched by updating the ListEventsOptions | ||
// parameter's AfterID field with the ID of the last item in this object's | ||
// Events field. | ||
HasMore bool `json:"has_more,omitempty"` | ||
} | ||
|
||
// ListEvents provides a paginated result of Event objects. | ||
func (c *Client) ListEvents(opts *ListOptions) (out *ListEventsResult, err error) { | ||
return c.ListEventsWithContext(nil, opts) | ||
} | ||
|
||
// ListEventsWithContext performs the same operation as ListEventes, but | ||
// allows specifying a context that can interrupt the request. | ||
func (c *Client) ListEventsWithContext(ctx context.Context, opts *ListOptions) (out *ListEventsResult, err error) { | ||
err = c.do(ctx, http.MethodGet, "events", c.convertOptsToURLValues(opts), &out) | ||
return | ||
} | ||
|
||
// GetEvent retrieves a previously-created event by its ID. | ||
func (c *Client) GetEvent(eventID string) (out *Event, err error) { | ||
err = c.get(nil, "events/"+eventID, &out) | ||
return | ||
} | ||
|
||
// GetEventWithContext performs the same operation as GetEvent, but allows | ||
// specifying a context that can interrupt the request. | ||
func (c *Client) GetEventWithContext(ctx context.Context, eventID string) (out *Event, err error) { | ||
err = c.get(ctx, "events/"+eventID, &out) | ||
return | ||
} | ||
|
||
type listEventPayloadsResult struct { | ||
Payloads *[]*EventPayload `json:"payloads,omitempty"` | ||
} | ||
|
||
// GetEventPayload retrieves the payload results of a previous webhook call. | ||
func (c *Client) ListEventPayloads(eventID string) (out []*EventPayload, err error) { | ||
return c.ListEventPayloadsWithContext(nil, eventID) | ||
} | ||
|
||
// GetEventPayloadWithContext performs the same operation as GetEventPaylod, but | ||
// allows specifying a context that can interrupt the request. | ||
func (c *Client) ListEventPayloadsWithContext(ctx context.Context, eventID string) (out []*EventPayload, err error) { | ||
err = c.get( | ||
ctx, | ||
"events/"+eventID+"/payloads", | ||
&listEventPayloadsResult{Payloads: &out}, | ||
) | ||
return | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,54 @@ | ||
package main | ||
|
||
import ( | ||
"encoding/json" | ||
"fmt" | ||
"os" | ||
"time" | ||
|
||
"github.com/EasyPost/easypost-go" | ||
) | ||
|
||
func main() { | ||
apiKey := os.Getenv("EASYPOST_API_KEY") | ||
if apiKey == "" { | ||
fmt.Fprintln(os.Stderr, "missing API key") | ||
os.Exit(1) | ||
return | ||
} | ||
client := easypost.New(apiKey) | ||
|
||
enc := json.NewEncoder(os.Stdout) | ||
enc.SetIndent("", " ") | ||
|
||
// Retrieve events from the past day. | ||
yesterday := time.Now().Add(-24 * time.Hour) | ||
opts := &easypost.ListOptions{StartDateTime: &yesterday} | ||
|
||
results, err := &easypost.ListEventsResult{HasMore: true}, error(nil) | ||
for results.HasMore && err == nil { | ||
if results, err = client.ListEvents(opts); err == nil { | ||
for i := range results.Events { | ||
enc.Encode(results.Events[i]) | ||
// If a webhook is registered, payloads can be examined to | ||
// obtain the event result. | ||
payloads, _ := client.ListEventPayloads(results.Events[i].ID) | ||
if len(payloads) != 0 { | ||
event, _ := payloads[0].RequestBody.(*easypost.Event) | ||
if event != nil { | ||
enc.Encode(event.Result) | ||
} | ||
} | ||
} | ||
if results.HasMore { | ||
// Update BeforeID in order to fetch additional pages. | ||
opts.BeforeID = results.Events[len(results.Events)-1].ID | ||
} | ||
} | ||
} | ||
|
||
if err != nil { | ||
fmt.Fprintln(os.Stderr, "error retrieving events:", err) | ||
os.Exit(1) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,171 @@ | ||
package main | ||
|
||
import ( | ||
"encoding/json" | ||
"flag" | ||
"fmt" | ||
"log" | ||
"net/http" | ||
"strings" | ||
|
||
"github.com/EasyPost/easypost-go" | ||
) | ||
|
||
type Handler struct { | ||
Username string | ||
Password string | ||
} | ||
|
||
func (h *Handler) ServeHTTP(w http.ResponseWriter, req *http.Request) { | ||
if req.Method != http.MethodPost { | ||
w.WriteHeader(http.StatusMethodNotAllowed) | ||
return | ||
} | ||
|
||
if h.Username != "" { | ||
user, pass, ok := req.BasicAuth() | ||
if !ok || user != h.Username || pass != h.Password { | ||
w.WriteHeader(http.StatusUnauthorized) | ||
return | ||
} | ||
} | ||
|
||
var event easypost.Event | ||
if err := json.NewDecoder(req.Body).Decode(&event); err != nil { | ||
w.WriteHeader(http.StatusBadRequest) | ||
log.Println("failed decoding request:", err) | ||
return | ||
} | ||
|
||
switch event.Description { | ||
case "batch.created", "batch.updated": | ||
h.HandleBatchEvent(&event) | ||
case "insurance.cancelled", "insurance.purchased": | ||
h.HandleInsuranceEvent(&event) | ||
case "payment.completed", "payment.created", "payment.failed": | ||
h.HandlePaymentEvent(&event) | ||
case "refund.successful": | ||
h.HandleRefundEvent(&event) | ||
case "report.available", "report.failed", "report.new": | ||
h.HandleReportEvent(&event) | ||
case "scan_form.created", "scan_form.updated": | ||
h.HandleScanFormEvent(&event) | ||
case "tracker.created", "tracker.updated": | ||
h.HandleTrackerEvent(&event) | ||
default: | ||
log.Println("unrecognized event type:", event.Description) | ||
} | ||
} | ||
|
||
func (h *Handler) HandleBatchEvent(event *easypost.Event) { | ||
batch, ok := event.Result.(*easypost.Batch) | ||
if !ok { | ||
log.Printf("unexpected result type for batch event: %T\n", event.Result) | ||
return | ||
} | ||
verb := strings.TrimPrefix(event.Description, "batch.") | ||
log.Printf( | ||
"batch %s %s with %d shipments and status "+ | ||
"(postage_purchased: %d, postage_purchase_failed: %d, "+ | ||
"queued_for_purchase: %d, creation_failed: %d)\n", | ||
batch.ID, verb, batch.NumShipments, batch.Status.PostagePurchased, | ||
batch.Status.PostagePurchaseFailed, batch.Status.QueuedForPurchase, | ||
batch.Status.CreationFailed, | ||
) | ||
} | ||
|
||
func (h *Handler) HandleInsuranceEvent(event *easypost.Event) { | ||
insurance, ok := event.Result.(*easypost.Insurance) | ||
if !ok { | ||
log.Printf( | ||
"unexpected result type for insurance event: %T\n", event.Result, | ||
) | ||
return | ||
} | ||
verb := strings.TrimPrefix(event.Description, "insurance.") | ||
log.Printf( | ||
"insurance %s %s of %s for shipment %s\n", | ||
insurance.ID, verb, insurance.Amount, insurance.ShipmentID, | ||
) | ||
} | ||
|
||
func (h *Handler) HandlePaymentEvent(event *easypost.Event) { | ||
payment, ok := event.Result.(*easypost.PaymentLog) | ||
if !ok { | ||
log.Printf("unexpected result type for payment event: %T\n", event.Result) | ||
return | ||
} | ||
verb := strings.TrimPrefix(event.Description, "payment.") | ||
log.Printf( | ||
"payment %s %s with amount %s and status %d\n", | ||
payment.ID, verb, payment.Amount, payment.Status, | ||
) | ||
} | ||
|
||
func (h *Handler) HandleRefundEvent(event *easypost.Event) { | ||
refund, ok := event.Result.(*easypost.Refund) | ||
if !ok { | ||
log.Printf("unexpected result type for refund event: %T\n", event.Result) | ||
return | ||
} | ||
verb := strings.TrimPrefix(event.Description, "refund.") | ||
log.Printf( | ||
"refund %s %s with status %s for shipment %s\n", | ||
refund.ID, verb, refund.Status, refund.ShipmentID, | ||
) | ||
} | ||
|
||
func (h *Handler) HandleReportEvent(event *easypost.Event) { | ||
report, ok := event.Result.(*easypost.Report) | ||
if !ok { | ||
log.Printf("unexpected result type for report event: %T\n", event.Result) | ||
return | ||
} | ||
verb := strings.TrimPrefix(event.Description, "report.") | ||
log.Printf( | ||
"report %s %s with status %s and URL %s\n", | ||
report.ID, verb, report.Status, report.URL, | ||
) | ||
} | ||
|
||
func (h *Handler) HandleScanFormEvent(event *easypost.Event) { | ||
scanForm, ok := event.Result.(*easypost.ScanForm) | ||
if !ok { | ||
log.Printf("unexpected result type for batch event: %T\n", event.Result) | ||
return | ||
} | ||
verb := strings.TrimPrefix(event.Description, "scan_form.") | ||
log.Printf( | ||
"scan form %s %s with status %s and tracking codes %s\n", | ||
scanForm.ID, verb, scanForm.Status, | ||
strings.Join(scanForm.TrackingCodes, ", "), | ||
) | ||
} | ||
|
||
func (h *Handler) HandleTrackerEvent(event *easypost.Event) { | ||
tracker, ok := event.Result.(*easypost.Tracker) | ||
if !ok { | ||
log.Printf("unexpected result type for tracker event: %T\n", event.Result) | ||
return | ||
} | ||
verb := strings.TrimPrefix(event.Description, "tracker.") | ||
log.Printf( | ||
"tracker %s %s with status %s and tracking code %s\n", | ||
tracker.ID, verb, tracker.Status, tracker.TrackingCode, | ||
) | ||
} | ||
|
||
func main() { | ||
var addr, user, pass, path string | ||
flag.StringVar(&addr, "addr", ":8080", "Local HTTP listener address") | ||
flag.StringVar(&user, "user", "", "HTTP user name required in requests") | ||
flag.StringVar(&pass, "pass", "", "HTTP user password required in requests") | ||
flag.StringVar(&path, "path", "/easypost/events", "HTTP webhook handler URI path") | ||
flag.Parse() | ||
handler := &Handler{Username: user, Password: pass} | ||
mux := http.NewServeMux() | ||
mux.Handle(path, handler) | ||
if err := http.ListenAndServe(addr, mux); err != http.ErrServerClosed { | ||
fmt.Println("error:", err) | ||
} | ||
} |
Oops, something went wrong.