// Package lulu is a client library for the Lulu book printing API. package lulu import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "time" "golang.org/x/oauth2/clientcredentials" ) const ( sandbox = "https://api.sandbox.lulu.com/" production = "https://api.lulu.com/" productionKeyPage = "https://developers.lulu.com/user-profile/api-keys" sandboxKeyPage = "https://developers.sandbox.lulu.com/user-profile/api-keys" PollPeriod = time.Second tokenPath = "/auth/realms/glasstree/protocol/openid-connect/token" validateInteriorPath = "/validate-interior" coverDimensionsPath = "/cover-dimensions" validateCoverPath = "/validate-cover" printJobCostPath = "/print-job-cost-calculations" printJobsPath = "/print-jobs" ) // A Client connects to the Lulu API server. type Client interface { // StartInteriorValidation starts a server-side validation job for the // interior file located at srcUrl using manufacturing settings given // by mfg. It returns the ID of the job. Use // [Client.GetInteriorValidation] to poll the status of the job. // // https://api.lulu.com/docs/#tag/Files-validation/operation/Validate-Interior_create StartInteriorValidation(srcUrl string, mfg PkgId) (uint, error) // StartInteriorValidationBasic is like // [Client.StartInteriorValidation] but without the optional // pod_package_id. // // https://api.lulu.com/docs/#tag/Files-validation/operation/Validate-Interior_create StartInteriorValidationBasic(srcUrl string) (uint, error) // GetInteriorValidation retrieves information about an interior file // validation job that was started by ValidateInterior(). // // https://api.lulu.com/docs/#tag/Files-validation/operation/Validate-Interior_read GetInteriorValidation(id uint) (InteriorValidation, error) // CoverDimensions calculates the required dimensions of the cover for // a book with the given manufacturing settings and number of pages. // The returned dimensions are given in the specified units of // measurement. // // https://api.lulu.com/docs/#tag/Files-validation/operation/Cover-Dimensions_create CoverDimensions(mfg PkgId, npages uint, unit Unit) (CoverDimensions, error) // StartCoverValidation starts a server-side validation job for the // cover file located at srcUrl, returning the job ID. mfg is the // manufacturing settings of the book, and npages is the number of // interior pages. Use [Client.GetCoverValidation] to poll the status // of the job. // // https://api.lulu.com/docs/#tag/Files-validation/operation/Validate-Cover_create StartCoverValidation(srcUrl string, mfg PkgId, npages uint) (uint, error) // GetCoverValidiation retrieves information about a cover file // validation job that was started by ValidateCover(). // // https://api.lulu.com/docs/#tag/Files-validation/operation/Validate-Cover_read GetCoverValidation(id uint) (CoverValidation, error) // Cost calculates the cost of a hypothetical print order without // actually creating a print job. // // https://api.lulu.com/docs/#tag/Print-Job-Cost-Calculations/operation/Print-Job-cost-calculations_create Cost(items []CostLineItem, addr ShippingAddress, shipOpt ShippingLevel) (Cost, ShippingAddressValidation, 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. Must be // [[MinProductionDelay],[MaxProductionDelay]]. This gives // some time for the order to be cancelled before the books // are printed. 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 Print(contact EmailAddress, externalId string, productionDelay time.Duration, addr ShippingAddress, shipOpt ShippingLevel, items []Printable) (PrintJob, error) // Reprint creates a print job, printing books whose PrintableIds are // known from (one or more) prior calls to [Client.Print]. // // https://api.lulu.com/docs/#tag/Print-Jobs/operation/Print-Jobs_reprint Reprint(contact EmailAddress, externalId string, productionDelay time.Duration, addr ShippingAddress, shipOpt ShippingLevel, items []Reprintable) (PrintJob, error) // Job retrieves the print job with the given ID, which is a // [PrintJob.Id] returned by [Client.Print] or [Client.Reprint]. // // https://api.lulu.com/docs/#tag/Print-Jobs/operation/Print-Jobs_read Job(id uint64) (PrintJob, error) // Jobs 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 Jobs(queries ...PrintJobQuery) ([]PrintJob, error) // Cancel cancels the print job with the given ID. jobId is from a // [PrintJob] returned by [Client.Print] or [Client.Reprint]. // // https://api.lulu.com/docs/#tag/Print-Jobs/operation/Print-Jobs_status_cancel Cancel(jobId uint64) error } // ValidateInterior starts a server-side validation job for the given // interior file and polls its status until it finishes or the context // expires. See [Client.StartInteriorValidation], [Client.GetInteriorValidation], // and [PollPeriod]. func ValidateInterior(ctx context.Context, c Client, srcUrl string, mfg PkgId) (InteriorValidation, error) { id, err := c.StartInteriorValidation(srcUrl, mfg) if err != nil { return InteriorValidation{}, err } return pollInteriorValidation(ctx, c, id, InteriorStatusNormalized) } // ValidateInteriorBasic is like [Client.ValidateInterior] but without the // optional pod_package_id. func ValidateInteriorBasic(ctx context.Context, c Client, srcUrl string) (InteriorValidation, error) { id, err := c.StartInteriorValidationBasic(srcUrl) if err != nil { return InteriorValidation{}, err } return pollInteriorValidation(ctx, c, id, InteriorStatusValidated) } func pollInteriorValidation(ctx context.Context, c Client, id uint, wantStatus InteriorValidationStatus) (InteriorValidation, error) { return poll(ctx, func() (InteriorValidation, bool, error) { val, err := c.GetInteriorValidation(id) done := val.Status == wantStatus || val.Status == InteriorStatusError return val, done, err }) } // ValidateCover starts a server-side validation job for the given // cover file and polls its status until it finishes or the context // expires. See [Client.StartCoverValidation], // [Client.GetCoverValidation], and [PollPeriod]. func ValidateCover(ctx context.Context, c Client, srcUrl string, mfg PkgId, npages uint) (CoverValidation, error) { id, err := c.StartCoverValidation(srcUrl, mfg, npages) if err != nil { return CoverValidation{}, err } return poll(ctx, func() (CoverValidation, bool, error) { val, err := c.GetCoverValidation(id) return val, val.Status.IsFinal(), err }) } type client struct { ctx context.Context c *http.Client apiUrl string keyPageUrl string } // Credentials contains the client-key and client-secret used to // authenticate to the API. type Credentials struct { Key, Secret string } // NewClient creates a client that will use creds to connect to the // production API server. // // ctx is used by the OAuth2 client. If you don't care about this, use // [context.Background]. See [clientcredentials.Config.Client]. func NewClient(ctx context.Context, creds Credentials) (Client, error) { return newClient(ctx, creds, production, productionKeyPage) } // NewClient creates a client that will connect to the sandbox API // server. See [NewClient]. func NewSandboxClient(ctx context.Context, creds Credentials) (Client, error) { return newClient(ctx, creds, sandbox, sandboxKeyPage) } func newClient(ctx context.Context, creds Credentials, apiUrl, keyPageUrl string) (*client, error) { tokenUrl, err := url.JoinPath(apiUrl, tokenPath) if err != nil { return nil, pkgErrf(err, "error creating client") } cfg := &clientcredentials.Config{ ClientID: creds.Key, ClientSecret: creds.Secret, TokenURL: tokenUrl, } return &client{ctx, cfg.Client(ctx), apiUrl, keyPageUrl}, nil } func (c *client) StartInteriorValidation(srcUrl string, mfg PkgId) (uint, error) { id, err := c.startInteriorValidation(validateInteriorReq{srcUrl, mfg}) if err != nil { return 0, pkgErr(err) } return id, nil } func (c *client) StartInteriorValidationBasic(srcUrl string) (uint, error) { id, err := c.startInteriorValidation(validateInteriorBasicReq{srcUrl}) if err != nil { return 0, pkgErr(err) } return id, nil } func (c *client) startInteriorValidation(payload any) (uint, error) { var val InteriorValidation err := c.postDecode(validateInteriorPath, payload, http.StatusCreated, &val) return val.Id, err } func (c *client) GetInteriorValidation(id uint) (InteriorValidation, error) { path, err := url.JoinPath(validateInteriorPath, fmt.Sprint(id)) if err != nil { return InteriorValidation{}, pkgErr(err) } var val InteriorValidation if err := c.getDecode(path, &val); err != nil { return InteriorValidation{}, pkgErr(err) } return val, nil } func (c *client) CoverDimensions(mfg PkgId, npages uint, unit Unit) (CoverDimensions, error) { payload := coverDimensionsReq{mfg, npages, unit} var dims CoverDimensions err := c.postDecode(coverDimensionsPath, payload, http.StatusCreated, &dims) if err != nil { return CoverDimensions{}, pkgErr(err) } return dims, nil } func (c *client) StartCoverValidation(srcUrl string, mfg PkgId, npages uint) (uint, error) { payload := validateCoverReq{srcUrl, mfg, npages} var val CoverValidation err := c.postDecode(validateCoverPath, payload, http.StatusCreated, &val) if err != nil { return 0, pkgErr(err) } return val.Id, nil } func (c *client) GetCoverValidation(id uint) (CoverValidation, error) { path, err := url.JoinPath(validateCoverPath, fmt.Sprint(id)) if err != nil { return CoverValidation{}, pkgErr(err) } var val CoverValidation if err := c.getDecode(path, &val); err != nil { return CoverValidation{}, pkgErr(err) } return val, nil } func (c *client) Cost(items []CostLineItem, addr ShippingAddress, shipOpt ShippingLevel) (Cost, ShippingAddressValidation, error) { payload := costReq{items, addr, shipOpt} var resp costResp err := c.postDecode(printJobCostPath, payload, http.StatusCreated, &resp) if err != nil { return Cost{}, ShippingAddressValidation{}, pkgErr(err) } return resp.cost(), resp.AddressValidation, nil } func (c *client) Print(contact EmailAddress, externalId string, productionDelay time.Duration, addr ShippingAddress, shipOpt ShippingLevel, items []Printable) (PrintJob, error) { job, err := print(c, contact, externalId, productionDelay, addr, shipOpt, items) if err != nil { err = pkgErr(err) } return job, err } func (c *client) Reprint(contact EmailAddress, externalId string, productionDelay time.Duration, addr ShippingAddress, shipOpt ShippingLevel, items []Reprintable) (PrintJob, error) { job, err := print(c, contact, externalId, productionDelay, addr, shipOpt, items) if err != nil { err = pkgErr(err) } return job, err } func print[P Printable | Reprintable](c *client, contact EmailAddress, externalId string, productionDelay time.Duration, addr ShippingAddress, shipOpt ShippingLevel, items []P) (PrintJob, error) { if err := verifyProductionDelay(productionDelay); err != nil { return PrintJob{}, err } req := printReq[P]{ Contact: contact, ExternalId: externalId, Items: items, ProductionDelayMins: uint(productionDelay.Round(time.Minute).Minutes()), ShipAddr: addr, ShipOpt: shipOpt, } var job PrintJob err := c.postDecode(printJobsPath, req, http.StatusCreated, &job) return job, err } 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 } func (c *client) Job(id uint64) (PrintJob, error) { var job PrintJob path, err := url.JoinPath(printJobsPath, fmt.Sprint(id)) if err != nil { return job, pkgErr(err) } if err := c.getDecode(path, &job); err != nil { return job, pkgErr(err) } return job, nil } func (c *client) Jobs(queries ...PrintJobQuery) ([]PrintJob, error) { var q printJobQueries q.apply(queries...) qvals := q.vals() var jobs []PrintJob var cnt uint for page := 1; ; page++ { qvals.Set("page", fmt.Sprint(page)) var resp jobsResp if err := c.getQueryDecode(printJobsPath, qvals, &resp); err != nil { return jobs, pkgErr(err) } cnt = resp.Count if len(resp.Results) == 0 && resp.Next != "" { return jobs, pkgErr(fmt.Errorf("no results on this page, but server returned a next page: %s", resp.Next)) } if len(resp.Results) > 0 { jobs = append(jobs, resp.Results...) } if len(resp.Results) == 0 || len(resp.Next) == 0 { break } } if uint(len(jobs)) != cnt { return jobs, pkgErr(fmt.Errorf("expected %d jobs, got %d", cnt, len(jobs))) } return jobs, nil } func (c *client) Cancel(jobId uint64) error { path, err := url.JoinPath(printJobsPath, fmt.Sprint(jobId), "status") if err != nil { return pkgErr(err) } req := map[string]OrderStatus{"name": OrderCanceled} var status PrintJobStatus if err := c.putDecode(path, req, &status); err != nil { return pkgErr(err) } if status.Status != OrderCanceled { return pkgErr(fmt.Errorf("cancel job %d: expected %s, got %s", jobId, OrderCanceled, status.Status)) } return nil } // getDecode sends a GET request and unmarshals the response. func (c *client) getDecode(path string, v any) error { return c.getQueryDecode(path, url.Values{}, v) } // getQueryDecode sends a GET path?query request and unmarshals the response into v. func (c *client) getQueryDecode(path string, query url.Values, v any) error { url, err := c.url(path) if err != nil { return err } url += "?" + query.Encode() resp, err := c.c.Get(url) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return errRespStatus{resp} } return decodeResponse(resp, v) } // postDecode sends a POST request and unmarshals the response. func (c *client) postDecode(path string, payload any, wantStatus int, v any) error { resp, err := c.post(path, payload) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != wantStatus { return errRespStatus{resp} } return decodeResponse(resp, v) } func (c *client) post(path string, payload any) (*http.Response, error) { body, err := json.Marshal(payload) if err != nil { return nil, errEncReq{payload, path, err} } url, err := c.url(path) if err != nil { return nil, err } debugf("POST %s request: `%s`\n", url, body) return c.c.Post(url, "application/json", bytes.NewBuffer(body)) } // putDecode sends a PUT request and unmarshals the response. func (c *client) putDecode(path string, payload any, v any) error { body, err := json.Marshal(payload) if err != nil { return errEncReq{payload, path, err} } url, err := c.url(path) if err != nil { return err } debugf("PUT %s request: `%s`\n", url, body) req, err := http.NewRequest(http.MethodPut, url, bytes.NewBuffer(body)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") resp, err := c.c.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return errRespStatus{resp} } return decodeResponse(resp, v) } func (c *client) url(path string) (string, error) { return url.JoinPath(c.apiUrl, path) } func decodeResponse(resp *http.Response, v any) error { body, err := io.ReadAll(resp.Body) if err != nil { return errReadResp{resp, err} } debugf("%s %s response: `%s`\n", resp.Request.Method, resp.Request.URL, body) if err := json.Unmarshal(body, v); err != nil { return errDecResp{resp, body, err} } return nil } func poll[T any](ctx context.Context, f func() (v T, done bool, err error)) (T, error) { timer := time.NewTimer(0) for { select { case <-timer.C: v, done, err := f() if err != nil { return v, err } else if done { return v, nil } timer.Reset(PollPeriod) case <-ctx.Done(): var v T return v, ctx.Err() } } }