diff options
| -rw-r--r-- | Makefile | 2 | ||||
| -rw-r--r-- | cost.go | 1 | ||||
| -rw-r--r-- | email.go | 15 | ||||
| -rw-r--r-- | lulu.go | 66 | ||||
| -rw-r--r-- | lulu_test.go | 16 | ||||
| -rw-r--r-- | print.go | 68 | ||||
| -rw-r--r-- | print_test.go | 85 | ||||
| -rw-r--r-- | ship.go | 29 | ||||
| -rw-r--r-- | ship_test.go | 94 | ||||
| -rw-r--r-- | status.go (renamed from order.go) | 13 | ||||
| -rw-r--r-- | status_gen.go (renamed from order_gen.go) | 39 |
11 files changed, 335 insertions, 93 deletions
@@ -1,4 +1,4 @@ -GEN = $(addsuffix _gen.go, cover interior order pkgid ship) +GEN = $(addsuffix _gen.go, cover interior pkgid ship status) TEST = $(wildcard *_test.go) SRC = $(filter-out ${GEN} ${TEST}, $(wildcard *.go)) @@ -60,6 +60,7 @@ type printJobCostReq struct { ShipOpt ShippingLevel `json:"shipping_option"` } +// TODO: just use ShippingAddress? type printJobCostReqShipAddr struct { City string `json:"city"` Country string `json:"country_code"` @@ -5,8 +5,10 @@ import ( "net/mail" ) -type EmailAddress struct { - *mail.Address +type EmailAddress mail.Address + +func (a EmailAddress) MarshalText() ([]byte, error) { + return []byte(a.Address), nil } func (a *EmailAddress) UnmarshalText(text []byte) error { @@ -14,13 +16,16 @@ func (a *EmailAddress) UnmarshalText(text []byte) error { if err != nil { return err } - a.Address = addr + *a = EmailAddress(*addr) return nil } func ParseEmailAddress(address string) (EmailAddress, error) { addr, err := mail.ParseAddress(address) - return EmailAddress{addr}, err + if err != nil { + return EmailAddress{}, err + } + return EmailAddress(*addr), nil } func MustParseEmailAddress(s string) EmailAddress { @@ -28,5 +33,5 @@ func MustParseEmailAddress(s string) EmailAddress { if err != nil { panic(fmt.Sprintf("lulu: %v", err)) } - return EmailAddress{addr} + return EmailAddress(*addr) } @@ -11,6 +11,7 @@ import ( "net/http" "net/url" "os" + "time" "golang.org/x/oauth2/clientcredentials" ) @@ -175,11 +176,11 @@ func (c *Client) PrintJobCost(items []PrintJobCostLineItem, addr ShippingAddress 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. +// GetPrintJobs 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) { +func (c *Client) GetPrintJobs(queries ...PrintJobQuery) ([]PrintJob, error) { var q printJobQueries q.apply(queries...) qvals := q.vals() @@ -207,6 +208,62 @@ func (c *Client) PrintJobs(queries ...PrintJobQuery) ([]PrintJob, error) { } } +// Print creates a new print job. +// +// contact: Email address that should be contacted if questions +// regarding the Print-Job arise. Lulu recommends to use the +// email of a person who is responsible for placing the Print-Job +// like a developer or business owner. +// +// externalId: 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. +// +// productionDelay: 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. +// +// addr: The postal address of the customer that the order will +// be sent to. +// +// shipOpt: Shipping method. +// +// items: List of books to print. +// +// https://api.lulu.com/docs/#tag/Print-Jobs/operation/Print-Jobs_create +func (c *Client) Print(contact EmailAddress, externalId string, productionDelay time.Duration, addr ShippingAddress, shipOpt ShippingLevel, items []Printable) (PrintJob, error) { + if err := verifyProductionDelay(productionDelay); err != nil { + return PrintJob{}, pkgErr(err) + } + + req := printReq{ + Contact: contact, + ExternalId: externalId, + LineItems: items, + ProductionDelayMins: uint(productionDelay.Round(time.Minute).Minutes()), + ShipAddr: addr, + ShipOpt: shipOpt, + } + + var job PrintJob + err := c.postDecode(printJobsPath, req, http.StatusCreated, &job) + if err != nil { + return job, pkgErr(err) + } + return job, nil +} + +func verifyProductionDelay(delay time.Duration) error { + if delay < MinProductionDelay || delay > MaxProductionDelay { + return fmt.Errorf("production delay %s out of range: must be [%s, %s]", + delay, MinProductionDelay, MaxProductionDelay) + } + return nil +} + // getDecode sends a GET request and unmarshals the response. func (c *Client) getDecode(path string, v any) error { verify := func(v any) error { return nil } @@ -261,6 +318,9 @@ func (c *Client) post(path string, payload any) (*http.Response, error) { if err != nil { return nil, err } + + debugf("POST %s request: `%s`\n", url, body) + return c.c.Post(url, "application/json", bytes.NewBuffer(body)) } diff --git a/lulu_test.go b/lulu_test.go index 613cc4b..ceb0990 100644 --- a/lulu_test.go +++ b/lulu_test.go @@ -70,6 +70,22 @@ func requireUnmarshalJsonEq[T any](t *testing.T, expected T, j string) { require.Equal(t, expected, actual) } +// assert that t2 is after t1 +func requireAfter(t *testing.T, t2, t1 time.Time) { + t.Helper() + require.Truef(t, t2.After(t1), "%s not after %s", t2, t1) +} + +// assert that dt2 is not before tt1 +func requireNotBeforeDate(t *testing.T, dt2, dt1 Date) { + t.Helper() + y2, m2, d2 := time.Time(dt2).UTC().Date() + y1, m1, d1 := time.Time(dt1).UTC().Date() + require.Truef(t, y2 >= y1 && m2 >= m1 && d2 >= d1, "%s is before %s", + time.Date(y2, m2, d2, 0, 0, 0, 0, time.UTC).Format(time.DateOnly), + time.Date(y1, m1, d1, 0, 0, 0, 0, time.UTC).Format(time.DateOnly)) +} + // poll periodically calls f() until it returns true or the deadline is exceeded. func poll(t *testing.T, f func() bool) { t.Helper() @@ -11,6 +11,47 @@ const ( MaxProductionDelay = 48 * time.Hour ) +type printReq struct { + Contact EmailAddress `json:"contact_email"` + ExternalId string `json:"external_id"` + LineItems []Printable `json:"line_items"` + ProductionDelayMins uint `json:"production_delay"` + ShipAddr ShippingAddress `json:"shipping_address"` + ShipOpt ShippingLevel `json:"shipping_level"` +} + +// PrintableId uniquely identifies a book to be printed: a "printable". A +// printable consists of a cover and an interior file as well as a +// PodPkgId which specifies the manufacturing options. +type PrintableId string + +// Printable is a book to be printed. +type Printable 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"` + + // Interior and cover files have to be specified with a URL from + // which Lulu can download the files. Using encoded basic + // authentication in the URL is ok. All files processed by Lulu + // will be validated and normalized before sending them to + // production. If problems with the file occur, the PrintJob will + // be rejected or cancelled and an error message will be + // displayed. + CoverUrl string `json:"cover"` + InteriorUrl string `json:"interior"` + + // Manufacturing options. + Mfg PkgId `json:"pod_package_id"` + + // The number of copies to print. + Quantity uint `json:"quantity"` + + // The title of the book. Must not exceed 255 bytes. + Title string `json:"title"` +} + type getPrintJobsResp struct { Count uint `json:"count"` Next string `json:"next"` @@ -60,6 +101,8 @@ type PrintJob struct { // ISO 3166-1 alpha-2 country code of the tax country determined for this job TaxCountry string `json:"tax_country"` + + Status PrintJobStatus `json:"status"` } func (pj *PrintJob) UnmarshalJSON(data []byte) error { @@ -77,13 +120,7 @@ func (pj *PrintJob) UnmarshalJSON(data []byte) error { 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"` -} - +// LineItem represents a book that should be printed: a "printable" for short. 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 @@ -98,7 +135,7 @@ type LineItem struct { Mfg PkgId `json:"pod_package_id"` // Id of the printable. It can be used instead of PrintableNormalization. - PrintableId string `json:"printable_id"` + PrintableId PrintableId `json:"printable_id"` // Normalization process of the cover and interior source files. PrintableNormalization PrintableNormalization `json:"printable_normalization"` @@ -121,6 +158,13 @@ type LineItem struct { Carrier string `json:"carrier_name"` } +type EstimatedShippingDates struct { + ArrivalMax Date `json:"arrival_max"` + ArrivalMin Date `json:"arrival_min"` + DispatchMax Date `json:"dispatch_max"` + DispatchMin Date `json:"dispatch_min"` +} + // PrintableNormalization represents the normalization processes of the // interior and cover source files. type PrintableNormalization struct { @@ -146,7 +190,7 @@ type NormalizedFile struct { type LineItemStatus struct { Messages LineItemStatusMessages `json:"messages"` - Status OrderStatus `json:"name"` + Status ItemStatus `json:"name"` } type LineItemStatusMessages struct { @@ -191,3 +235,9 @@ func (msgs *LineItemStatusMessages) UnmarshalJSON(data []byte) error { return nil } + +type PrintJobStatus struct { + Changed time.Time `json:"changed"` // time of the last status change + Msg string `json:"message"` + Status OrderStatus `json:"name"` +} diff --git a/print_test.go b/print_test.go index d14c529..ec4e455 100644 --- a/print_test.go +++ b/print_test.go @@ -75,49 +75,70 @@ func TestUnmarshalGetPrintJobsResp(t *testing.T) { Messages: LineItemStatusMessages{ Info: "Line-item is currently being validated", }, - Status: OrderCreated, + Status: ItemCreated, }, 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", - }}, + ProductionDelay: 120 * time.Minute, + ProductionDue: time.Time{}, + AddressValidation: shipAddrValidationSample, + ShipOpt: Mail, + Status: PrintJobStatus{ + Changed: mustParseTime(time.RFC3339, "2017-08-07T08:47:26.480493Z"), + Msg: "Print-job is currently being validated", + Status: OrderCreated, }, - ShipOpt: Mail, }}, } requireUnmarshalJsonEq(t, want, getPrintJobsRespJson) } -func TestPrintJobs(t *testing.T) { +func TestGetPrintJobs(t *testing.T) { t.Fail() // TODO: create a few print jobs and retrieve them c := newClient(t) - _, err := c.PrintJobs() + _, err := c.GetPrintJobs() + require.NoError(t, err) +} + +func TestPrint(t *testing.T) { + contact := MustParseEmailAddress("test@test.com") + jobEid := "demo-time" + productionDelay := 120 * time.Minute + addr := shipAddrSample + shipOpt := Mail + item := Printable{ + ExternalId: "item-reference-1", + CoverUrl: "https://www.dropbox.com/s/7bv6mg2tj0h3l0r/lulu_trade_perfect_template.pdf?dl=1&raw=1", + InteriorUrl: "https://www.dropbox.com/s/r20orb8umqjzav9/lulu_trade_interior_template-32.pdf?dl=1&raw=1", + Mfg: PkgId{UsTrade, Mono, Standard, Perfect, P60UncoatedWhite, Matte, NoLinen, NoFoil}, + Quantity: 30, + Title: "My Book", + } + + c := newClient(t) + startTime := time.Now() + job, err := c.Print(contact, jobEid, productionDelay, addr, shipOpt, []Printable{item}) require.NoError(t, err) + require.Equal(t, contact, job.Contact) + requireAfter(t, job.Created, startTime) + requireAfter(t, job.Modified, startTime) + require.Equal(t, jobEid, job.ExternalId) + require.NotZero(t, job.Id) + require.Len(t, job.LineItems, 1) + jobItem := job.LineItems[0] + require.Equal(t, item.ExternalId, jobItem.ExternalId) + require.NotZero(t, jobItem.Id) + require.Equal(t, item.CoverUrl, jobItem.PrintableNormalization.Cover.SrcUrl) + require.Equal(t, item.InteriorUrl, jobItem.PrintableNormalization.Interior.SrcUrl) + require.Equal(t, item.Mfg, jobItem.Mfg) + require.Equal(t, item.Quantity, jobItem.Quantity) + require.NotEmpty(t, jobItem.Status.Messages.Info) + require.NotEmpty(t, jobItem.Status.Status) + require.Equal(t, productionDelay, job.ProductionDelay) + requireMatchShipAddrValidationSample(t, job.AddressValidation) + require.Equal(t, shipOpt, job.ShipOpt) + requireAfter(t, job.Status.Changed, startTime) + require.NotEmpty(t, job.Status.Msg) + require.NotEmpty(t, job.Status.Status) } @@ -29,42 +29,43 @@ const ( ) type ShippingAddress struct { - Country string // ISO 3166-2 country code + // ISO 3166-2 country code + Country string `json:"country_code"` - // 2 or 3 letter state codes (officially called ISO-3166-2 + // 2 or 3 letter state code (officially called ISO-3166-2 // subdivision codes). They are required for some countries (e.g. // US, MX, CA, AU). - State string + State string `json:"state"` - City string - Street1 string // First address line - Street2 string // Second address line - PostCode string // Required for most countries + City string `json:"city"` + Street1 string `json:"street1"` // First address line + Street2 string `json:"street2"` // Second address line + PostCode string `json:"postcode"` // Required for most countries // Only relevant for US addresses. Some US carriers don't deliver // to business-addresses on Saturday. - IsBusiness bool + IsBusiness bool `json:"is_business"` // Full name of the person, including first and last name. - Name string - Title Title + Name string `json:"name"` + Title Title `json:"title"` // Name of an organization. Required if no person name is given. - Organization string + Organization string `json:"organization"` // 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 + Email EmailAddress `json:"email"` // 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 + Phone PhoneNumber `json:"phone_number"` // The recipient's tax identification number. Required for // shipping addresses to Brazil, Chile, and Mexico. - TaxId string + TaxId string `json:"recipient_tax_id"` } func (a *ShippingAddress) UnmarshalJSON(data []byte) error { diff --git a/ship_test.go b/ship_test.go index 7643cb3..bd5432d 100644 --- a/ship_test.go +++ b/ship_test.go @@ -3,37 +3,77 @@ package lulu import ( _ "embed" "testing" + + "github.com/stretchr/testify/require" ) //go:embed testdata/shipaddrresp.json var shipAddrRespJson string +var shipAddrSample = ShippingAddress{ + City: "Lübeck", + Country: "DE", + IsBusiness: false, + Name: "Hans Dampf", + Phone: MustParsePhoneNumber("844-212-0689"), + PostCode: "23552", + State: "", + Street1: "Holstenstr. 40", + Street2: "", +} +var shipWarningsSample = []ShippingAddressWarning{{ + Type: "validation_warning", + Path: "external", + Code: "REPLACED", + Msg: "street1: Holstenstr. 40 -> Holstenstraße 40", +}} +var suggestedShipAddrSample = ShippingAddress{ + Country: "DE", + State: "", + PostCode: "23552", + City: "Lübeck", + Street1: "Holstenstraße 40", +} +var shipAddrValidationSample = ShippingAddressValidation{ + Address: shipAddrSample, + Warnings: shipWarningsSample, + Suggested: suggestedShipAddrSample, +} + 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) + requireUnmarshalJsonEq(t, shipAddrValidationSample, shipAddrRespJson) +} + +func requireMatchShipAddrValidationSample(t *testing.T, av ShippingAddressValidation) { + t.Helper() + requireMatchShipAddrSample(t, av.Address) + requireMatchShipWarningsSample(t, av.Warnings) + requireMatchSuggestedShipAddrSample(t, av.Suggested) +} + +func requireMatchShipAddrSample(t *testing.T, addr ShippingAddress) { + t.Helper() + require.Equal(t, shipAddrSample.City, addr.City) + require.Equal(t, shipAddrSample.Country, addr.Country) + require.Equal(t, shipAddrSample.IsBusiness, addr.IsBusiness) + require.Equal(t, shipAddrSample.Name, addr.Name) + require.Equal(t, shipAddrSample.Phone, addr.Phone) + require.Equal(t, shipAddrSample.PostCode, addr.PostCode) + require.Equal(t, shipAddrSample.State, addr.State) + require.Equal(t, shipAddrSample.Street1, addr.Street1) + require.Equal(t, shipAddrSample.Street2, addr.Street2) +} + +func requireMatchShipWarningsSample(t *testing.T, warns []ShippingAddressWarning) { + t.Helper() + require.Equal(t, shipWarningsSample, warns) +} + +func requireMatchSuggestedShipAddrSample(t *testing.T, sug ShippingAddress) { + t.Helper() + require.Equal(t, suggestedShipAddrSample.Country, sug.Country) + require.Equal(t, suggestedShipAddrSample.State, sug.State) + require.Equal(t, suggestedShipAddrSample.PostCode, sug.PostCode) + require.Equal(t, suggestedShipAddrSample.City, sug.City) + require.Equal(t, suggestedShipAddrSample.Street1, sug.Street1) } @@ -1,6 +1,6 @@ package lulu -//go:generate go run github.com/yawnak/string-enumer -t OrderStatus --text -o ./order_gen.go . +//go:generate go run github.com/yawnak/string-enumer -t OrderStatus -t ItemStatus --text -o ./status_gen.go . type OrderStatus string @@ -16,3 +16,14 @@ const ( 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. ) + +type ItemStatus string + +const ( + ItemCreated ItemStatus = "CREATED" + ItemAccepted ItemStatus = "ACCEPTED" + ItemRejected ItemStatus = "REJECTED" + ItemInProduction ItemStatus = "IN_PRODUCTION" + ItemError ItemStatus = "ERROR" + ItemShipped ItemStatus = "SHIPPED" +) diff --git a/order_gen.go b/status_gen.go index 5b17f05..2b2bb84 100644 --- a/order_gen.go +++ b/status_gen.go @@ -1,10 +1,47 @@ -// Code generated by "string-enumer -t OrderStatus --text -o ./order_gen.go ."; DO NOT EDIT. +// Code generated by "string-enumer -t OrderStatus -t ItemStatus --text -o ./status_gen.go ."; DO NOT EDIT. package lulu import ( "fmt" ) +// validItemStatusValues contains a map of all valid ItemStatus values for easy lookup +var validItemStatusValues = map[ItemStatus]struct{}{ + ItemCreated: {}, + ItemAccepted: {}, + ItemRejected: {}, + ItemInProduction: {}, + ItemError: {}, + ItemShipped: {}, +} + +// Valid validates if a value is a valid ItemStatus +func (v ItemStatus) Valid() bool { + _, ok := validItemStatusValues[v] + return ok +} + +// ItemStatusValues returns a list of all (valid) ItemStatus values +func ItemStatusValues() []ItemStatus { + return []ItemStatus{ + ItemCreated, + ItemAccepted, + ItemRejected, + ItemInProduction, + ItemError, + ItemShipped, + } +} + +// UnmarshalText takes a text, verifies that it is a correct ItemStatus and unmarshals it +func (v *ItemStatus) UnmarshalText(text []byte) error { + if valid := ItemStatus(text).Valid(); !valid { + return fmt.Errorf("not valid value for ItemStatus: %s", text) + } + *v = ItemStatus(text) + return nil +} + // validOrderStatusValues contains a map of all valid OrderStatus values for easy lookup var validOrderStatusValues = map[OrderStatus]struct{}{ OrderCreated: {}, |