diff options
| author | Sam Anthony <sam@samanthony.xyz> | 2026-05-12 15:35:06 -0400 |
|---|---|---|
| committer | Sam Anthony <sam@samanthony.xyz> | 2026-05-12 15:37:29 -0400 |
| commit | 9010ebe8a581fb9db7bc6e97d40ff062fb18495f (patch) | |
| tree | 4301a455762a59d4951507c8a8781e99c6f91c6d | |
| parent | 329257be8d9fb05d3dcea49823acea0f878ed52c (diff) | |
| download | lulu-9010ebe8a581fb9db7bc6e97d40ff062fb18495f.zip | |
unmarshal GET /print-jobs response
| -rw-r--r-- | Makefile | 2 | ||||
| -rw-r--r-- | cost.go | 118 | ||||
| -rw-r--r-- | cost_test.go | 66 | ||||
| -rw-r--r-- | date.go | 39 | ||||
| -rw-r--r-- | email.go | 32 | ||||
| -rw-r--r-- | json.go | 15 | ||||
| -rw-r--r-- | lulu.go | 24 | ||||
| -rw-r--r-- | lulu_test.go | 8 | ||||
| -rw-r--r-- | notes | 9 | ||||
| -rw-r--r-- | order.go | 18 | ||||
| -rw-r--r-- | order_gen.go | 51 | ||||
| -rw-r--r-- | phone.go | 36 | ||||
| -rw-r--r-- | print.go | 193 | ||||
| -rw-r--r-- | print_test.go | 115 | ||||
| -rw-r--r-- | query.go | 76 | ||||
| -rw-r--r-- | ship.go | 263 | ||||
| -rw-r--r-- | ship_test.go | 39 | ||||
| -rw-r--r-- | testdata/getprintjobsresp.json | 119 | ||||
| -rw-r--r-- | testdata/shipaddrresp.json | 27 |
19 files changed, 1021 insertions, 229 deletions
@@ -1,4 +1,4 @@ -GEN = $(addsuffix _gen.go, cover interior pkgid ship) +GEN = $(addsuffix _gen.go, cover interior order pkgid ship) TEST = $(wildcard *_test.go) SRC = $(filter-out ${GEN} ${TEST}, $(wildcard *.go)) @@ -1,16 +1,6 @@ package lulu -import ( - "encoding/json" - - "github.com/shopspring/decimal" -) - -type printJobCostReq struct { - LineItems []PrintJobCostLineItem `json:"line_items"` - ShipAddr printJobCostReqShipAddr `json:"shipping_address"` - ShipOpt ShippingLevel `json:"shipping_option"` -} +import "github.com/shopspring/decimal" type PrintJobCostLineItem struct { NPages uint `json:"page_count"` @@ -18,32 +8,16 @@ type PrintJobCostLineItem struct { Quantity uint `json:"quantity"` } -type printJobCostReqShipAddr struct { - City string `json:"city"` - Country string `json:"country_code"` - PostCode string `json:"postcode"` - State string `json:"state_code"` - Street1 string `json:"street1"` - Phone PhoneNumber `json:"phone_number"` -} - -// PrintJobCost is the response from /print-job-cost-calculations/. type PrintJobCost struct { - Addr, SuggestedAddr ShippingAddress - AddrWarnings []ShippingAddressWarning - Fees []Fee - LineItemCosts []LineItemCost - ShipCost FulfillmentCost - FulfillmentCost FulfillmentCost - TotalTax, TotalCostExclTax, TotalCostInclTax, TotalDiscount decimal.Decimal - Currency string -} - -type ShippingAddressWarning struct { - Type string `json:"type"` // eg "validation_warning" - Path string `json:"path"` // eg "external" - Code string `json:"code"` // eg "REPLACED" - Msg string `json:"message"` // eg "street1: Holstenstr. 40 -> Holstenstraße 40" + Fees []Fee `json:"fees"` + LineItemCosts []LineItemCost `json:"line_item_costs"` + ShipCost FulfillmentCost `json:"shipping_cost"` + FulfillmentCost FulfillmentCost `json:"fulfillment_cost"` + TotalTax decimal.Decimal `json:"total_tax"` + TotalCostExclTax decimal.Decimal `json:"total_cost_excl_tax"` + TotalCostInclTax decimal.Decimal `json:"total_cost_incl_tax"` + TotalDiscount decimal.Decimal `json:"total_discount"` + Currency string `json:"currency"` } type Fee struct { @@ -80,44 +54,44 @@ type Discount struct { Description string `json:"description"` } +type printJobCostReq struct { + LineItems []PrintJobCostLineItem `json:"line_items"` + ShipAddr printJobCostReqShipAddr `json:"shipping_address"` + ShipOpt ShippingLevel `json:"shipping_option"` +} + +type printJobCostReqShipAddr struct { + City string `json:"city"` + Country string `json:"country_code"` + PostCode string `json:"postcode"` + State string `json:"state_code"` + Street1 string `json:"street1"` + Phone PhoneNumber `json:"phone_number"` +} + type printJobCostResp struct { - Addr json.RawMessage `json:"shipping_address"` - Fees []Fee `json:"fees"` - LineItemCosts []LineItemCost `json:"line_item_costs"` - ShipCost FulfillmentCost `json:"shipping_cost"` - FulfillmentCost FulfillmentCost `json:"fulfillment_cost"` - TotalTax decimal.Decimal `json:"total_tax"` - TotalCostExclTax decimal.Decimal `json:"total_cost_excl_tax"` - TotalCostInclTax decimal.Decimal `json:"total_cost_incl_tax"` - TotalDiscount decimal.Decimal `json:"total_discount_amount"` - Currency string `json:"currency"` + AddressValidation ShippingAddressValidation `json:"shipping_address"` + Fees []Fee `json:"fees"` + LineItemCosts []LineItemCost `json:"line_item_costs"` + ShipCost FulfillmentCost `json:"shipping_cost"` + FulfillmentCost FulfillmentCost `json:"fulfillment_cost"` + TotalTax decimal.Decimal `json:"total_tax"` + TotalCostExclTax decimal.Decimal `json:"total_cost_excl_tax"` + TotalCostInclTax decimal.Decimal `json:"total_cost_incl_tax"` + TotalDiscount decimal.Decimal `json:"total_discount_amount"` + Currency string `json:"currency"` } -func (c *PrintJobCost) UnmarshalJSON(data []byte) error { - var resp printJobCostResp - if err := json.Unmarshal(data, &resp); err != nil { - return err - } - if err := json.Unmarshal(resp.Addr, &c.Addr); err != nil { - return err - } - var warnsAndSugg struct { - Warnings []ShippingAddressWarning `json:"warnings"` - Suggested ShippingAddress `json:"suggested_address"` - } - if err := json.Unmarshal(resp.Addr, &warnsAndSugg); err != nil { - return err +func (resp printJobCostResp) cost() PrintJobCost { + return PrintJobCost{ + Fees: resp.Fees, + LineItemCosts: resp.LineItemCosts, + ShipCost: resp.ShipCost, + FulfillmentCost: resp.FulfillmentCost, + TotalTax: resp.TotalTax, + TotalCostExclTax: resp.TotalCostExclTax, + TotalCostInclTax: resp.TotalCostInclTax, + TotalDiscount: resp.TotalDiscount, + Currency: resp.Currency, } - c.SuggestedAddr = warnsAndSugg.Suggested - c.AddrWarnings = warnsAndSugg.Warnings - c.Fees = resp.Fees - c.LineItemCosts = resp.LineItemCosts - c.ShipCost = resp.ShipCost - c.FulfillmentCost = resp.FulfillmentCost - c.TotalTax = resp.TotalTax - c.TotalCostExclTax = resp.TotalCostExclTax - c.TotalCostInclTax = resp.TotalCostInclTax - c.TotalDiscount = resp.TotalDiscount - c.Currency = resp.Currency - return nil } diff --git a/cost_test.go b/cost_test.go index 15b2fe1..446440d 100644 --- a/cost_test.go +++ b/cost_test.go @@ -40,31 +40,33 @@ var printJobCostReqSample = printJobCostReq{ Express, } -var printJobCostSample = PrintJobCost{ - Addr: ShippingAddress{ - City: "Lübeck", - PostCode: "23552", - Street1: "Holstenstr. 40", - Phone: MustParsePhoneNumber("844-212-0689"), - State: "", - Country: "DE", - IsBusiness: false, - Name: ". .", - }, - SuggestedAddr: ShippingAddress{ - Country: "DE", - State: "", - PostCode: "23552", - City: "Lübeck", - Street1: "Holstenstraße 40", - Street2: "", +var printJobCostRespSample = printJobCostResp{ + AddressValidation: ShippingAddressValidation{ + Address: ShippingAddress{ + City: "Lübeck", + PostCode: "23552", + Street1: "Holstenstr. 40", + Phone: MustParsePhoneNumber("844-212-0689"), + State: "", + Country: "DE", + IsBusiness: false, + Name: ". .", + }, + Suggested: ShippingAddress{ + Country: "DE", + State: "", + PostCode: "23552", + City: "Lübeck", + Street1: "Holstenstraße 40", + Street2: "", + }, + Warnings: []ShippingAddressWarning{{ + "validation_warning", + "external", + "REPLACED", + "street1: Holstenstr. 40 -> Holstenstraße 40", + }}, }, - AddrWarnings: []ShippingAddressWarning{{ - "validation_warning", - "external", - "REPLACED", - "street1: Holstenstr. 40 -> Holstenstraße 40", - }}, Fees: []Fee{ { Currency: "USD", @@ -130,8 +132,8 @@ func TestMarshalPrintJobCostReq(t *testing.T) { requireMarshalJsonEq(t, printJobCostReqJson, printJobCostReqSample) } -func TestUnmarshalPrintJobCost(t *testing.T) { - requireUnmarshalJsonEq(t, printJobCostSample, printJobCostRespJson) +func TestUnmarshalPrintJobCostResp(t *testing.T) { + requireUnmarshalJsonEq(t, printJobCostRespSample, printJobCostRespJson) } func TestPrintJobCost(t *testing.T) { @@ -146,17 +148,9 @@ func TestPrintJobCost(t *testing.T) { Phone: MustParsePhoneNumber("844-212-0689"), } shiplvl := printJobCostReqSample.ShipOpt - cost, err := c.PrintJobCost(items, addr, shiplvl) + cost, av, err := c.PrintJobCost(items, addr, shiplvl) require.NoError(t, err) - require.Equal(t, "Lübeck", cost.Addr.City) - require.Equal(t, "23552", cost.Addr.PostCode) - require.Equal(t, "Holstenstr. 40", cost.Addr.Street1) - require.Equal(t, MustParsePhoneNumber("844-212-0689"), cost.Addr.Phone) - require.Empty(t, cost.Addr.State) - require.Equal(t, "DE", cost.Addr.Country) - require.False(t, cost.Addr.IsBusiness) - require.Equal(t, printJobCostSample.SuggestedAddr, cost.SuggestedAddr) - require.Equal(t, printJobCostSample.AddrWarnings, cost.AddrWarnings) + require.Equal(t, printJobCostRespSample.AddressValidation, av) requireCurrency(t, cost.Currency) for _, fee := range cost.Fees { requireCurrency(t, fee.Currency) @@ -0,0 +1,39 @@ +package lulu + +import ( + "fmt" + "time" +) + +type Date time.Time + +func ParseDate(s string) (Date, error) { + t, err := time.Parse(time.DateOnly, s) + return Date(t), err +} + +func MustParseDate(s string) Date { + d, err := ParseDate(s) + if err != nil { + panic(fmt.Sprintf("lulu.ParseDate(%q): %v", s, err)) + } + return d +} + +func (d Date) String() string { + return time.Time(d).Format(time.DateOnly) +} + +func (d *Date) UnmarshalText(text []byte) error { + nd, err := ParseDate(string(text)) + if err != nil { + return err + } + *d = nd + return nil +} + +func (d Date) MarshalText() ([]byte, error) { + s := time.Time(d).Format(time.DateOnly) + return []byte(s), nil +} diff --git a/email.go b/email.go new file mode 100644 index 0000000..bdf4dce --- /dev/null +++ b/email.go @@ -0,0 +1,32 @@ +package lulu + +import ( + "fmt" + "net/mail" +) + +type EmailAddress struct { + *mail.Address +} + +func (a *EmailAddress) UnmarshalText(text []byte) error { + addr, err := mail.ParseAddress(string(text)) + if err != nil { + return err + } + a.Address = addr + return nil +} + +func ParseEmailAddress(address string) (EmailAddress, error) { + addr, err := mail.ParseAddress(address) + return EmailAddress{addr}, err +} + +func MustParseEmailAddress(s string) EmailAddress { + addr, err := mail.ParseAddress(s) + if err != nil { + panic(fmt.Sprintf("lulu: %v", err)) + } + return EmailAddress{addr} +} @@ -0,0 +1,15 @@ +package lulu + +import "encoding/json" + +func unmarshalSliceOrVal[T any](data []byte) ([]T, error) { + var vs []T + if err := json.Unmarshal(data, &vs); err != nil { + var v T + if err := json.Unmarshal(data, &v); err != nil { + return nil, err + } + return []T{v}, nil + } + return vs, nil +} @@ -24,6 +24,7 @@ const ( coverDimensionsPath = "/cover-dimensions" validateCoverPath = "/validate-cover" printJobCostPath = "/print-job-cost-calculations" + printJobPath = " /print-jobs" ) // ApiUrl is the location of the API server. It is set to the sandbox @@ -155,7 +156,7 @@ func (c *Client) GetCoverValidation(id uint) (CoverValidationRecord, error) { // actually creating a print job. // // https://api.lulu.com/docs/#tag/Print-Job-Cost-Calculations/operation/Print-Job-cost-calculations_create -func (c *Client) PrintJobCost(items []PrintJobCostLineItem, addr ShippingAddress, shipOpt ShippingLevel) (PrintJobCost, error) { +func (c *Client) PrintJobCost(items []PrintJobCostLineItem, addr ShippingAddress, shipOpt ShippingLevel) (PrintJobCost, ShippingAddressValidation, error) { reqAddr := printJobCostReqShipAddr{ City: addr.City, Country: addr.Country, @@ -165,12 +166,25 @@ func (c *Client) PrintJobCost(items []PrintJobCostLineItem, addr ShippingAddress Phone: addr.Phone, } payload := printJobCostReq{items, reqAddr, shipOpt} - var cost PrintJobCost - err := c.postDecode(printJobCostPath, payload, http.StatusCreated, &cost) + + var resp printJobCostResp + err := c.postDecode(printJobCostPath, payload, http.StatusCreated, &resp) if err != nil { - return cost, pkgErr(err) + return PrintJobCost{}, ShippingAddressValidation{}, pkgErr(err) } - return cost, nil + return resp.cost(), resp.AddressValidation, nil +} + +// PrintJobs retrieves a list of all print jobs that have been created, +// filtered by a set of optional query parameters. +// +// https://api.lulu.com/docs/#tag/Print-Jobs/operation/Print-Jobs_list +func (c *Client) PrintJobs(queries ...PrintJobQuery) ([]PrintJob, error) { + //q := parsePrintJobQueries(queries) + //page := 1 + + // TODO + return nil, fmt.Errorf("not implemented") } // getDecode sends a GET request and unmarshals the response. diff --git a/lulu_test.go b/lulu_test.go index 648ede7..613cc4b 100644 --- a/lulu_test.go +++ b/lulu_test.go @@ -88,3 +88,11 @@ func poll(t *testing.T, f func() bool) { } } } + +func mustParseTime(layout, value string) time.Time { + t, err := time.Parse(layout, value) + if err != nil { + panic(err) + } + return t +} @@ -9,3 +9,12 @@ real response did not; thus "fees" is included in the testdata. The real response had "first_name" and "last_name" rather than just "name", so ShippingAddress has a custom UnmarshalJSON() function to account for both. And so on... + + +-- Print Jobs -- + +I made some corrections to testdata/getprintjobsresp.json. These +include changing "source_md5sum" to "source_md5_sum" and changing the +postcode field from integer to string, etc., as per the schema. It +remains to be seen if the errors are just in the documentation example +or if they (sometimes) appear in real server responses too... diff --git a/order.go b/order.go new file mode 100644 index 0000000..ec53071 --- /dev/null +++ b/order.go @@ -0,0 +1,18 @@ +package lulu + +//go:generate go run github.com/yawnak/string-enumer -t OrderStatus --text -o ./order_gen.go . + +type OrderStatus string + +const ( + OrderCreated OrderStatus = "CREATED" // Print-Job created. + OrderUnpaid OrderStatus = "UNPAID" // Print-Job can be paid. + OrderPaymentInProgress OrderStatus = "PAYMENT_IN_PROGRESS" // Payment is in Progress. + OrderProductionDelayed OrderStatus = "PRODUCTION_DELAYED" // Print-Job is paid and will move to production after the mandatory production delay. + OrderProductionReady OrderStatus = "PRODUCTION_READY" // Production delay has ended and the Print-Job will move to "in production" shortly. + OrderInProduction OrderStatus = "IN_PRODUCTION" // Print-Job submitted to printer. + OrderShipped OrderStatus = "SHIPPED" // Print-Job is fully shipped. + OrderDelivered OrderStatus = "DELIVERED" // Print-Job has been delivered by the carrier. This is supported by USPS, FedEx, and UPS. + OrderRejected OrderStatus = "REJECTED" // When there is a problem with the input data or the file, Lulu will reject a Print-Job with a detailed error message. Please contact our experts if you need help in resolving this issue. + OrderCanceled OrderStatus = "CANCELED" // You can cancel a Print-Job as long as it is “unpaid” using an API request to the status endpoint. In rare cases, Lulu might also cancel a Print-Job if a problem has surfaced in production and the order cannot be fulfilled. +) diff --git a/order_gen.go b/order_gen.go new file mode 100644 index 0000000..5b17f05 --- /dev/null +++ b/order_gen.go @@ -0,0 +1,51 @@ +// Code generated by "string-enumer -t OrderStatus --text -o ./order_gen.go ."; DO NOT EDIT. +package lulu + +import ( + "fmt" +) + +// validOrderStatusValues contains a map of all valid OrderStatus values for easy lookup +var validOrderStatusValues = map[OrderStatus]struct{}{ + OrderCreated: {}, + OrderUnpaid: {}, + OrderPaymentInProgress: {}, + OrderProductionDelayed: {}, + OrderProductionReady: {}, + OrderInProduction: {}, + OrderShipped: {}, + OrderDelivered: {}, + OrderRejected: {}, + OrderCanceled: {}, +} + +// Valid validates if a value is a valid OrderStatus +func (v OrderStatus) Valid() bool { + _, ok := validOrderStatusValues[v] + return ok +} + +// OrderStatusValues returns a list of all (valid) OrderStatus values +func OrderStatusValues() []OrderStatus { + return []OrderStatus{ + OrderCreated, + OrderUnpaid, + OrderPaymentInProgress, + OrderProductionDelayed, + OrderProductionReady, + OrderInProduction, + OrderShipped, + OrderDelivered, + OrderRejected, + OrderCanceled, + } +} + +// UnmarshalText takes a text, verifies that it is a correct OrderStatus and unmarshals it +func (v *OrderStatus) UnmarshalText(text []byte) error { + if valid := OrderStatus(text).Valid(); !valid { + return fmt.Errorf("not valid value for OrderStatus: %s", text) + } + *v = OrderStatus(text) + return nil +} diff --git a/phone.go b/phone.go new file mode 100644 index 0000000..ff92cef --- /dev/null +++ b/phone.go @@ -0,0 +1,36 @@ +package lulu + +import ( + "fmt" + "regexp" +) + +var phoneExpr = regexp.MustCompile(`^\+?[\d\s\-.\/()]{8,20}$`) + +type PhoneNumber string + +func ParsePhoneNumber(s string) (PhoneNumber, error) { + if phoneExpr.MatchString(s) { + return PhoneNumber(s), nil + } + return "", fmt.Errorf("malformed phone number %q; must fit pattern `%s`", s, phoneExpr.String()) +} + +// MustParsePhoneNumber is like ParsePhoneNumber but panics if the phone +// number cannot be parsed. +func MustParsePhoneNumber(s string) PhoneNumber { + n, err := ParsePhoneNumber(s) + if err != nil { + panic(fmt.Sprintf("lulu: ParsePhoneNumber(%q): %v", s, err)) + } + return n +} + +func (n *PhoneNumber) UnmarshalText(text []byte) error { + pn, err := ParsePhoneNumber(string(text)) + if err != nil { + return err + } + *n = pn + return nil +} diff --git a/print.go b/print.go new file mode 100644 index 0000000..4302e6e --- /dev/null +++ b/print.go @@ -0,0 +1,193 @@ +package lulu + +import ( + "encoding/json" + "fmt" + "time" +) + +const ( + MinProductionDelay = 60 * time.Minute + MaxProductionDelay = 48 * time.Hour +) + +type getPrintJobsResp struct { + Count uint `json:"count"` + Next string `json:"next"` + Prev string `json:"previous"` + Results []PrintJob `json:"results"` +} + +type PrintJob struct { + // An email address for questions regarding the print + // job—normally, you want to use the email address of a + // developer or shop owner, not the end customer. + Contact EmailAddress `json:"contact_email"` + + Cost PrintJobCost `json:"costs"` + + Created time.Time `json:"date_created"` + Modified time.Time `json:"date_modified"` + + EstimatedShippingDates EstimatedShippingDates `json:"estimated_shipping_dates"` + + // Arbitrary string to identify and connect a print job to your + // systems. Set it to an order number, a purchase order or + // whatever else works for your particular use case. + ExternalId string `json:"external_id"` + + Id uint64 `json:"id"` + + LineItems []LineItem `json:"line_items"` + + OrderId string `json:"order_id"` + + // Delay before a newly created Print-Job is sent to production. + // Minimum is 60 minutes, maximum is 2880 minutes (=48 hours). As + // most cancellation requests occur right after an order has been + // placed, it makes sense to wait for some time before sending an + // order to production. Once production has started, orders + // cannot be canceled anymore. + ProductionDelay time.Duration + + // Target timestamp of when this job will move into production. + ProductionDue time.Time `json:"production_due_time"` + + AddressValidation ShippingAddressValidation `json:"shipping_address"` + + // The shipping level that this Print-Job is shipped with. + ShipOpt ShippingLevel `json:"shipping_level"` + + // ISO 3166-1 alpha-2 country code of the tax country determined for this job + TaxCountry string `json:"tax_country"` +} + +func (pj *PrintJob) UnmarshalJSON(data []byte) error { + type alias PrintJob // prevent infinite recursion + if err := json.Unmarshal(data, (*alias)(pj)); err != nil { + return err + } + var extra struct { + ProductionDelay uint `json:"production_delay"` + } + if err := json.Unmarshal(data, &extra); err != nil { + return fmt.Errorf("error unmarshaling %T.ProductionDelay: %w", *pj, err) + } + pj.ProductionDelay = time.Minute * time.Duration(extra.ProductionDelay) + return nil +} + +type EstimatedShippingDates struct { + ArrivalMax Date `json:"arrival_max"` + ArrivalMin Date `json:"arrival_min"` + DispatchMax Date `json:"dispatch_max"` + DispatchMin Date `json:"dispatch_min"` +} + +type LineItem struct { + // Arbitrary string to identify and connect a print job to your + // systems. Set it to an order number, a purchase order or + // whatever else works for your particular use case. + ExternalId string `json:"external_id"` + + Id uint64 `json:"id"` + + NPages uint `json:"page_count"` + + // Manufacturing options. + Mfg PkgId `json:"pod_package_id"` + + // Id of the printable. It can be used instead of PrintableNormalization. + PrintableId string `json:"printable_id"` + + // Normalization process of the cover and interior source files. + PrintableNormalization PrintableNormalization `json:"printable_normalization"` + + // Quantity of printed books. + Quantity uint `json:"quantity"` + + Status LineItemStatus `json:"status"` + + // Title of the line item. Should be on the cover. + Title string `json:"title"` + + // Tracking id for this line item's shipment. + TrackingId string `json:"tracking_id"` + + // A list of tracking urls for this line item's shipment. + TrackingUrls []string `json:"tracking_urls"` + + // Name of the carrier handling the shipment. + Carrier string `json:"carrier_name"` +} + +// PrintableNormalization represents the normalization processes of the +// interior and cover source files. +type PrintableNormalization struct { + Cover NormalizationJob `json:"cover"` + Interior NormalizationJob `json:"interior"` +} + +// NormalizationJob represents the normalization process of an interior +// or cover source file. +type NormalizationJob struct { + JobId uint `json:"job_id"` + NormalizedFile NormalizedFile `json:"normalized_file"` + SrcMd5Sum string `json:"source_md5_sum"` // md5 hash of the source file. + SrcUrl string `json:"source_url"` // URL of the source file. +} + +// NormalizedFile represents a file on the server that was created by +// normalizing a cover or interior source file. +type NormalizedFile struct { + Id uint64 `json:"file_id"` + Name string `json:"filename"` +} + +type LineItemStatus struct { + Messages LineItemStatusMessages `json:"messages"` + Status OrderStatus `json:"name"` +} + +type LineItemStatusMessages struct { + Delay time.Duration // Expected delay due to the error, if present. + Error string `json:"error"` + Info string `json:"info"` + PrintableNormalization PrintableNormalization `json:"printable_normalization"` + Timestamp time.Time `json:"timestamp"` // Timestamp of the last status change. + TrackingUrls []string + TrackingId string `json:"tracking_id"` // Tracking ID for this line item's shipment. + Carrier string `json:"carrier_name"` // Name of the carrier handling the shipment. +} + +func (msgs *LineItemStatusMessages) UnmarshalJSON(data []byte) error { + type alias LineItemStatusMessages // prevent infinite recursion + if err := json.Unmarshal(data, (*alias)(msgs)); err != nil { + return err + } + + var extra struct { + Delay string `json:"delay"` + TrackingUrls json.RawMessage `json:"tracking_urls"` + } + if err := json.Unmarshal(data, &extra); err != nil { + return err + } + + var err error + if len(extra.Delay) > 0 { + msgs.Delay, err = time.ParseDuration(fmt.Sprintf("%sh", extra.Delay)) + if err != nil { + return fmt.Errorf("error unmarshaling %T.Delay: %w", *msgs, err) + } + } + + if len(extra.TrackingUrls) > 0 { + msgs.TrackingUrls, err = unmarshalSliceOrVal[string](extra.TrackingUrls) + if err != nil { + return fmt.Errorf("error unmarshaling %T.TrackingUrls: %w", *msgs, err) + } + } + + return nil +} diff --git a/print_test.go b/print_test.go new file mode 100644 index 0000000..9e51095 --- /dev/null +++ b/print_test.go @@ -0,0 +1,115 @@ +package lulu + +import ( + _ "embed" + "testing" + "time" + + "github.com/shopspring/decimal" +) + +//go:embed testdata/getprintjobsresp.json +var getPrintJobsRespJson string + +func TestUnmarshalGetPrintJobsResp(t *testing.T) { + want := getPrintJobsResp{ + Count: 1, + Next: "https://api.lulu.com/resources/?page=1&page_size=1", + Prev: "https://api.lulu.com/resources/?page=1&page_size=1", + Results: []PrintJob{{ + Contact: MustParseEmailAddress("test@test.com"), + Cost: PrintJobCost{ + LineItemCosts: nil, + ShipCost: FulfillmentCost{ + TotalCostExclTax: decimal.RequireFromString("132.74"), + TotalCostInclTax: decimal.RequireFromString("132.74"), + TotalTax: decimal.RequireFromString("0.00"), + TaxRate: decimal.RequireFromString("0.000000"), + }, + FulfillmentCost: FulfillmentCost{ + TotalCostExclTax: decimal.RequireFromString("0.75"), + TotalCostInclTax: decimal.RequireFromString("0.81"), + TotalTax: decimal.RequireFromString("0.06"), + TaxRate: decimal.RequireFromString("0.080000"), + }, + Fees: []Fee{{ + Currency: "USD", + Type: "HANDLING_FEE", + Sku: "HANDLING_FEE_6", + TaxRate: decimal.RequireFromString("0.088750"), + TotalCostExclTax: decimal.RequireFromString("4.00"), + TotalCostInclTax: decimal.RequireFromString("4.36"), + TotalTax: decimal.RequireFromString("0.36"), + }}, + TotalCostExclTax: decimal.RequireFromString("123.45"), + TotalCostInclTax: decimal.RequireFromString("678.00"), + TotalTax: decimal.RequireFromString("0.123400"), + }, + Created: mustParseTime(time.RFC3339, "2017-08-07T08:47:26.485456Z"), + Modified: mustParseTime(time.RFC3339, "2017-08-07T08:47:26.485490Z"), + EstimatedShippingDates: EstimatedShippingDates{ + ArrivalMax: MustParseDate("2017-08-12"), + ArrivalMin: MustParseDate("2017-08-10"), + DispatchMax: MustParseDate("2017-08-09"), + DispatchMin: MustParseDate("2017-08-07"), + }, + ExternalId: "demo-time", + Id: 1, + LineItems: []LineItem{{ + ExternalId: "item-reference-1", + Id: 1, + PrintableId: "", + PrintableNormalization: PrintableNormalization{ + Cover: NormalizationJob{ + SrcMd5Sum: "e78512c777e7f5841fe8f1992cefb898", + SrcUrl: "https://www.dropbox.com/sh/p3zh22vzsaegiri/AADP367j0bTWlt8fCu-_tm2ia/161025/139056_cover.pdf?dl=1", + }, + Interior: NormalizationJob{ + SrcMd5Sum: "7f8af20c296747689756f8e310135d79", + SrcUrl: "https://www.dropbox.com/sh/p3zh22vzsaegiri/AACOUn3LFKsITDzylh13bQpsa/161025/thesis2.pdf?dl=1", + }, + }, + Quantity: 20, + Status: LineItemStatus{ + Messages: LineItemStatusMessages{ + Info: "Line-item is currently being validated", + }, + Status: OrderCreated, + }, + Title: "My Book", + }}, + ProductionDelay: 120 * time.Minute, + ProductionDue: time.Time{}, + AddressValidation: ShippingAddressValidation{ + Address: ShippingAddress{ + City: "Lübeck", + Country: "DE", + IsBusiness: false, + Name: "Hans Dampf", + Phone: "844-212-0689", + PostCode: "23552", + State: "", + Street1: "Holstenstr. 40", + Street2: "", + }, + Suggested: ShippingAddress{ + Country: "DE", + State: "", + PostCode: "23552", + City: "Lübeck", + Street1: "Holstenstraße 40", + Street2: "", + }, + Warnings: []ShippingAddressWarning{{ + Type: "validation_warning", + Path: "external", + Code: "REPLACED", + Msg: "street1: Holstenstr. 40 -> Holstenstraße 40", + }}, + }, + ShipOpt: Mail, + }}, + } + + requireUnmarshalJsonEq(t, want, getPrintJobsRespJson) +} diff --git a/query.go b/query.go new file mode 100644 index 0000000..cf56bdd --- /dev/null +++ b/query.go @@ -0,0 +1,76 @@ +package lulu + +import "time" + +type printJobQueries struct { + creatAfter, creatBefore, modAfter, modBefore *time.Time + status *OrderStatus + id, orderId *uint + excludeLineItems bool +} + +func parsePrintJobQueries(qs []PrintJobQuery) printJobQueries { + var q printJobQueries + for i := range qs { + qs[i](&q) + } + return q +} + +type PrintJobQuery func(*printJobQueries) + +// Include only print jobs created after t. +func FilterCreatedAfter(t time.Time) PrintJobQuery { + return func(q *printJobQueries) { + q.creatAfter = &t + } +} + +// Include only print jobs created before t. +func FilterCreatedBefore(t time.Time) PrintJobQuery { + return func(q *printJobQueries) { + q.creatBefore = &t + } +} + +// Include only print jobs modified after t. +func FilterModifiedAfter(t time.Time) PrintJobQuery { + return func(q *printJobQueries) { + q.modAfter = &t + } +} + +// Include only print jobs modified before t. +func FilterModifiedBefore(t time.Time) PrintJobQuery { + return func(q *printJobQueries) { + q.modBefore = &t + } +} + +// Include only print jobs with status s. +func FilterStatus(s OrderStatus) PrintJobQuery { + return func(q *printJobQueries) { + q.status = &s + } +} + +// Include only print jobs with the given id. +func FilterId(id uint) PrintJobQuery { + return func(q *printJobQueries) { + q.id = &id + } +} + +// Include only print jobs with the given order_id. +func FilterOrderId(orderId uint) PrintJobQuery { + return func(q *printJobQueries) { + q.orderId = &orderId + } +} + +// Leave the list of line items out of the print jobs response. +func ExcludeLineItems() PrintJobQuery { + return func(q *printJobQueries) { + q.excludeLineItems = true + } +} @@ -3,14 +3,10 @@ package lulu import ( "encoding/json" "fmt" - email "net/mail" - "regexp" ) //go:generate go run github.com/yawnak/string-enumer -t ShippingLevel -t Title --text -o ./ship_gen.go . -var phoneExpr = regexp.MustCompile(`^\+?[\d\s\-.\/()]{8,20}$`) - // ShippingLevel is the quality/speed with which a package is shipped. type ShippingLevel string @@ -22,99 +18,174 @@ const ( Express ShippingLevel = "EXPRESS" // Overnight delivery. Fastest shipping available. ) -type ShippingAddress struct { - Country string // ISO 3166-2 country code - State string // 2 or 3 letter state codes (officially called ISO-3166-2 subdivision codes). They are required for some countries (e.g. US, MX, CA, AU) - City string - Street1 string // First address line - Street2 string // Second address line - PostCode string // Required for most countries - IsBusiness bool // Only relevant for US addresses. Some US carriers don't deliver to business-addresses on Saturday. - Name string // Full name of the person, including first and last name. - Title Title - Organization string // Name of an organization. Required if no person name is given. - Email *email.Address // Shipping carriers require an email address for notifications or handling delivery issues. If no email is given, the default email in the user profile will be used. - Phone PhoneNumber // Shipping carriers require a phone number for handling delivery issues. If no phone number is given, the default in the API user profile will be used. - TaxId string // The recipient’s tax identification number. Required for shipping addresses to Brazil, Chile, and Mexico. -} +type Title string + +const ( + Mr Title = "MR" + Miss Title = "MISS" + Mrs Title = "MRS" + Ms Title = "MS" + Dr Title = "DR" +) -// They use "country" in some places, "country_code" in others, etc. -// This is the union of all of them. -type shippingAddress struct { - Country string `json:"country"` - CountryCode string `json:"country_code"` - State string `json:"state"` - StateCode string `json:"state_code"` - City string `json:"city"` - Street1 string `json:"street1"` - Street2 string `json:"street2"` - PostCode string `json:"postcode"` - IsBusiness bool `json:"is_business"` - Name string `json:"name"` - FirstName string `json:"first_name"` - LastName string `json:"last_name"` - Title Title `json:"title"` - Organization string `json:"organization"` - Email string `json:"email"` - Phone PhoneNumber `json:"phone_number"` - TaxId string `json:"recipient_tax_id"` +type ShippingAddress struct { + Country string // ISO 3166-2 country code + + // 2 or 3 letter state codes (officially called ISO-3166-2 + // subdivision codes). They are required for some countries (e.g. + // US, MX, CA, AU). + State string + + City string + Street1 string // First address line + Street2 string // Second address line + PostCode string // Required for most countries + + // Only relevant for US addresses. Some US carriers don't deliver + // to business-addresses on Saturday. + IsBusiness bool + + // Full name of the person, including first and last name. + Name string + Title Title + + // Name of an organization. Required if no person name is given. + Organization string + + // Shipping carriers require an email address for notifications + // or handling delivery issues. If no email is given, the default + // email in the user profile will be used. + Email EmailAddress + + // Shipping carriers require a phone number for handling delivery + // issues. If no phone number is given, the default in the API + // user profile will be used. + Phone PhoneNumber + + // The recipient's tax identification number. Required for + // shipping addresses to Brazil, Chile, and Mexico. + TaxId string } func (a *ShippingAddress) UnmarshalJSON(data []byte) error { - s := string(data) - var alias shippingAddress + var alias extShippingAddress if err := json.Unmarshal(data, &alias); err != nil { return err } + addr, err := alias.addr() + if err != nil { + return err + } + *a = addr + return nil +} + +type ShippingAddressValidation struct { + Address ShippingAddress // The original address that was given to the server. + Suggested ShippingAddress // The address that the server recommends. + Warnings []ShippingAddressWarning +} - if country, both := either(alias.Country, alias.CountryCode); !both { - a.Country = country - } else { - return fmt.Errorf(`address contains both "country" and "country_code": %q`, s) +func (av *ShippingAddressValidation) UnmarshalJSON(data []byte) error { + var alias extShippingAddress + if err := json.Unmarshal(data, &alias); err != nil { + return err } + addr, err := alias.addr() + if err != nil { + return err + } + av.Address = addr + av.Suggested = alias.Suggested + av.Warnings = alias.Warnings + return nil +} + +type ShippingAddressWarning struct { + Type string `json:"type"` // eg "validation_warning" + Path string `json:"path"` // eg "external" + Code string `json:"code"` // eg "REPLACED" + Msg string `json:"message"` // eg "street1: Holstenstr. 40 -> Holstenstraße 40" +} + +// extShippingAddress is the extended shipping address that is seen in +// some responses. It includes "warnings" and "suggested_address". It +// also unmarshals either "country" or "country_code" etc. to account for +// the irregularities in the API. +type extShippingAddress struct { + Country string `json:"country"` + CountryCode string `json:"country_code"` + + State string `json:"state"` + StateCode string `json:"state_code"` + + City string `json:"city"` + Street1 string `json:"street1"` + Street2 string `json:"street2"` + PostCode string `json:"postcode"` + IsBusiness bool `json:"is_business"` + + Name string `json:"name"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + + Title Title `json:"title"` + Organization string `json:"organization"` + Email EmailAddress `json:"email"` + Phone PhoneNumber `json:"phone_number"` + TaxId string `json:"recipient_tax_id"` + + Warnings []ShippingAddressWarning `json:"warnings"` + Suggested ShippingAddress `json:"suggested_address"` +} - if state, both := either(alias.State, alias.StateCode); !both { - a.State = state - } else { - return fmt.Errorf(`address contains both "state" and "state_code": %q`, s) +// addr takes the disjunctive union of the API's irregular fields +// ("country"/"country_code", etc.) and returns only those fields of the +// actual shipping address: excluding the suggested address and warnings. +// It returns error if two or more of the "same field" are present, eg. +// both "country" and "country_code". +func (ext extShippingAddress) addr() (ShippingAddress, error) { + country, both := either(ext.Country, ext.CountryCode) + if both { + return ShippingAddress{}, fmt.Errorf(`address contains both "country" and "country_code"`) } - a.City = alias.City - a.Street1 = alias.Street1 - a.Street2 = alias.Street2 - a.PostCode = alias.PostCode - a.IsBusiness = alias.IsBusiness + state, both := either(ext.State, ext.StateCode) + if both { + return ShippingAddress{}, fmt.Errorf(`address contains both "state" and "state_code"`) + } - hasName := alias.Name != "" - hasFirst := alias.FirstName != "" - hasLast := alias.LastName != "" + var name string + hasName := ext.Name != "" + hasFirst := ext.FirstName != "" + hasLast := ext.LastName != "" if hasName && (hasFirst || hasLast) { - return fmt.Errorf(`address contains both "name" and {"first_name", "last_name"}: %q`, s) + return ShippingAddress{}, fmt.Errorf(`address contains both "name" and {"first_name", "last_name"}`) } else if hasName { - a.Name = alias.Name + name = ext.Name } else if hasFirst && hasLast { - a.Name = fmt.Sprintf("%s %s", alias.FirstName, alias.LastName) + name = fmt.Sprintf("%s %s", ext.FirstName, ext.LastName) } else if hasLast { - a.Name = alias.LastName + name = ext.LastName } else if hasFirst { - a.Name = alias.FirstName - } - - a.Title = alias.Title - a.Organization = alias.Organization - - if alias.Email != "" { - addr, err := email.ParseAddress(alias.Email) - if err != nil { - return err - } - a.Email = addr + name = ext.FirstName } - a.Phone = alias.Phone - a.TaxId = alias.TaxId - - return nil + return ShippingAddress{ + Country: country, + State: state, + City: ext.City, + Street1: ext.Street1, + Street2: ext.Street2, + PostCode: ext.PostCode, + IsBusiness: ext.IsBusiness, + Name: name, + Title: ext.Title, + Organization: ext.Organization, + Email: ext.Email, + Phone: ext.Phone, + TaxId: ext.TaxId, + }, nil } func either(a, b string) (string, bool) { @@ -125,41 +196,3 @@ func either(a, b string) (string, bool) { } return b, false } - -type Title string - -const ( - Mr Title = "MR" - Miss Title = "MISS" - Mrs Title = "MRS" - Ms Title = "MS" - Dr Title = "DR" -) - -type PhoneNumber string - -func ParsePhoneNumber(s string) (PhoneNumber, error) { - if phoneExpr.MatchString(s) { - return PhoneNumber(s), nil - } - return "", fmt.Errorf("malformed phone number %q; must fit pattern `%s`", s, phoneExpr.String()) -} - -// MustParsePhoneNumber is like ParsePhoneNumber but panics if the phone -// number cannot be parsed. -func MustParsePhoneNumber(s string) PhoneNumber { - n, err := ParsePhoneNumber(s) - if err != nil { - panic(fmt.Sprintf("lulu: ParsePhoneNumber(%q): %v", s, err)) - } - return n -} - -func (n *PhoneNumber) UnmarshalText(text []byte) error { - pn, err := ParsePhoneNumber(string(text)) - if err != nil { - return err - } - *n = pn - return nil -} diff --git a/ship_test.go b/ship_test.go new file mode 100644 index 0000000..7643cb3 --- /dev/null +++ b/ship_test.go @@ -0,0 +1,39 @@ +package lulu + +import ( + _ "embed" + "testing" +) + +//go:embed testdata/shipaddrresp.json +var shipAddrRespJson string + +func TestUnmarshalShippingAddressValidation(t *testing.T) { + want := ShippingAddressValidation{ + Address: ShippingAddress{ + City: "Lübeck", + Country: "DE", + IsBusiness: false, + Name: "Hans Dampf", + Phone: MustParsePhoneNumber("844-212-0689"), + PostCode: "23552", + State: "", + Street1: "Holstenstr. 40", + Street2: "", + }, + Warnings: []ShippingAddressWarning{{ + Type: "validation_warning", + Path: "external", + Code: "REPLACED", + Msg: "street1: Holstenstr. 40 -> Holstenstraße 40", + }}, + Suggested: ShippingAddress{ + Country: "DE", + State: "", + PostCode: "23552", + City: "Lübeck", + Street1: "Holstenstraße 40", + }, + } + requireUnmarshalJsonEq(t, want, shipAddrRespJson) +} diff --git a/testdata/getprintjobsresp.json b/testdata/getprintjobsresp.json new file mode 100644 index 0000000..3f43d2c --- /dev/null +++ b/testdata/getprintjobsresp.json @@ -0,0 +1,119 @@ +{ + "count": 1, + "next": "https://api.lulu.com/resources/?page=1&page_size=1", + "previous": "https://api.lulu.com/resources/?page=1&page_size=1", + "results": [ + { + "contact_email": "test@test.com", + "costs": { + "line_item_costs": null, + "shipping_cost": { + "total_cost_excl_tax": "132.74", + "total_cost_incl_tax": "132.74", + "total_tax": "0.00", + "tax_rate": "0.000000" + }, + "fulfillment_cost": { + "total_cost_excl_tax": "0.75", + "total_cost_incl_tax": "0.81", + "total_tax": "0.06", + "tax_rate": "0.080000" + }, + "fees": [ + { + "currency": "USD", + "fee_type": "HANDLING_FEE", + "sku": "HANDLING_FEE_6", + "tax_rate": "0.088750", + "total_cost_excl_tax": "4.00", + "total_cost_incl_tax": "4.36", + "total_tax": "0.36" + } + ], + "total_cost_excl_tax": "123.45", + "total_cost_incl_tax": "678.00", + "total_tax": "0.123400" + }, + "date_created": "2017-08-07T08:47:26.485456Z", + "date_modified": "2017-08-07T08:47:26.485490Z", + "estimated_shipping_dates": { + "arrival_max": "2017-08-12", + "arrival_min": "2017-08-10", + "dispatch_max": "2017-08-09", + "dispatch_min": "2017-08-07" + }, + "external_id": "demo-time", + "id": 1, + "line_items": [ + { + "external_id": "item-reference-1", + "id": 1, + "printable_id": null, + "printable_normalization": { + "cover": { + "job_id": null, + "normalized_file": null, + "page_count": null, + "source_file": null, + "source_md5_sum": "e78512c777e7f5841fe8f1992cefb898", + "source_url": "https://www.dropbox.com/sh/p3zh22vzsaegiri/AADP367j0bTWlt8fCu-_tm2ia/161025/139056_cover.pdf?dl=1" + }, + "interior": { + "job_id": null, + "normalized_file": null, + "page_count": null, + "source_file": null, + "source_md5_sum": "7f8af20c296747689756f8e310135d79", + "source_url": "https://www.dropbox.com/sh/p3zh22vzsaegiri/AACOUn3LFKsITDzylh13bQpsa/161025/thesis2.pdf?dl=1" + }, + "pod_package_id": "0600X0900.BW.STD.PB.060UW444.MXX" + }, + "quantity": 20, + "status": { + "messages": { + "info": "Line-item is currently being validated" + }, + "name": "CREATED" + }, + "title": "My Book" + } + ], + "production_delay": 120, + "production_due_time": null, + "shipping_address": { + "city": "Lübeck", + "country_code": "DE", + "is_business": false, + "name": "Hans Dampf", + "phone_number": "844-212-0689", + "postcode": "23552", + "state_code": "", + "street1": "Holstenstr. 40", + "street2": "", + "warnings": [ + { + "type": "validation_warning", + "path": "external", + "code": "REPLACED", + "message": "street1: Holstenstr. 40 -> Holstenstraße 40" + } + ], + "suggested_address": { + "country_code": "DE", + "state_code": null, + "postcode": "23552", + "city": "Lübeck", + "street1": "Holstenstraße 40", + "street2": null + } + }, + "shipping_level": "MAIL", + "shipping_option_level": "MAIL", + "status": { + "changed": "2017-08-07T08:47:26.480493Z", + "message": "Print-job is currently being validated", + "name": "CREATED" + } + } + ] +} diff --git a/testdata/shipaddrresp.json b/testdata/shipaddrresp.json new file mode 100644 index 0000000..510831d --- /dev/null +++ b/testdata/shipaddrresp.json @@ -0,0 +1,27 @@ +{ + "city": "Lübeck", + "country_code": "DE", + "is_business": false, + "name": "Hans Dampf", + "phone_number": "844-212-0689", + "postcode": "23552", + "state_code": "", + "street1": "Holstenstr. 40", + "street2": "", + "warnings": [ + { + "type": "validation_warning", + "path": "external", + "code": "REPLACED", + "message": "street1: Holstenstr. 40 -> Holstenstraße 40" + } + ], + "suggested_address": { + "country_code": "DE", + "state_code": null, + "postcode": "23552", + "city": "Lübeck", + "street1": "Holstenstraße 40", + "street2": null + } +} |