Skip to content

Commit

Permalink
Add support for fetching events and event payloads. (#20)
Browse files Browse the repository at this point in the history
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
Show file tree
Hide file tree
Showing 10 changed files with 1,579 additions and 0 deletions.
147 changes: 147 additions & 0 deletions event.go
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
}
54 changes: 54 additions & 0 deletions examples/events/retrieve_all/retrieve_events.go
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)
}
}
171 changes: 171 additions & 0 deletions examples/webhooks/handler/webhook_handler.go
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)
}
}
Loading

0 comments on commit c75f665

Please sign in to comment.