// 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 ( SandboxUrl = "https://api.sandbox.lulu.com/" ProductionUrl = "https://api.lulu.com/" ProductionApiKeyPage = "https://developers.lulu.com/user-profile/api-keys" SandboxApiKeyPage = "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" ) // ApiUrl is the location of the API server. It is set to the sandbox // environment by default; change it to the production environment when // you are ready to deploy. var ApiUrl = SandboxUrl // Sandbox sets ApiUrl to SandboxApiUrl so that subsequent requests will // be sent to the sandbox API server. func Sandbox() { ApiUrl = SandboxUrl } // Production sets ApiUrl to ProductionApiUrl so that subsequent requests // will be sent to the production API server. func Production() { ApiUrl = ProductionUrl } // ApiKeyPage returns the URL of the page where you can generate a // client-key and client-secret to use for authentication. func ApiKeyPage() string { if ApiUrl == SandboxUrl { return SandboxApiKeyPage } return ProductionApiKeyPage } type Client struct { ctx context.Context c *http.Client } // NewClient returns a client that will use the given client-key and // client-secret to connect to the API server. func NewClient(ctx context.Context, key, secret string) (*Client, error) { tokenUrl, err := url.JoinPath(ApiUrl, tokenPath) if err != nil { return nil, pkgErrf(err, "error creating client") } cfg := &clientcredentials.Config{ ClientID: key, ClientSecret: secret, TokenURL: tokenUrl, } return &Client{ctx, cfg.Client(ctx)}, nil } // Context returns the client's context. func (c *Client) Context() context.Context { return c.ctx } // ValidateInterior starts a server-side validation job for the given // interior file and polls its status until it finishes or the context // expires. See also: StartInteriorValidation() and // GetInteriorValidation(). func (c *Client) ValidateInterior(ctx context.Context, srcUrl string, mfg PkgId) (InteriorValidation, error) { id, err := c.StartInteriorValidation(srcUrl, mfg) if err != nil { return InteriorValidation{}, err } return c.pollInteriorValidation(ctx, id) } // ValidateInteriorBasic is like ValidateInterior but without the // optional pod_package_id. func (c *Client) ValidateInteriorBasic(ctx context.Context, srcUrl string) (InteriorValidation, error) { id, err := c.StartInteriorValidationBasic(srcUrl) if err != nil { return InteriorValidation{}, err } return c.pollInteriorValidation(ctx, id) } func (c *Client) pollInteriorValidation(ctx context.Context, id uint) (InteriorValidation, error) { return poll(ctx, func() (InteriorValidation, bool, error) { val, err := c.GetInteriorValidation(id) return val, val.Status.IsFinal(), err }) } // 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 GetInteriorValidation() to poll // the status of the job. // // https://api.lulu.com/docs/#tag/Files-validation/operation/Validate-Interior_create 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 } // StartInteriorValidationBasic is like StartInteriorValidation but // without the optional pod_package_id. // // https://api.lulu.com/docs/#tag/Files-validation/operation/Validate-Interior_create 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 rec InteriorValidation err := c.postDecode(validateInteriorPath, payload, http.StatusCreated, &rec) return rec.Id, err } // 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 func (c *Client) GetInteriorValidation(id uint) (InteriorValidation, error) { path, err := url.JoinPath(validateInteriorPath, fmt.Sprint(id)) if err != nil { return InteriorValidation{}, pkgErr(err) } var rec InteriorValidation if err := c.getDecode(path, &rec); err != nil { return InteriorValidation{}, pkgErr(err) } return rec, nil } // 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 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 } // ValidateCover starts a server-side validation job for the given cover // file and polls its status until it finishes or the context expires. // See also: StartCoverValidation() and GetCoverValidation(). func (c *Client) ValidateCover(ctx context.Context, 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 }) } // 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 // GetCoverValidation() to poll the status of the job. // // https://api.lulu.com/docs/#tag/Files-validation/operation/Validate-Cover_create func (c *Client) StartCoverValidation(srcUrl string, mfg PkgId, npages uint) (uint, error) { payload := validateCoverReq{srcUrl, mfg, npages} var rec CoverValidation err := c.postDecode(validateCoverPath, payload, http.StatusCreated, &rec) if err != nil { return 0, pkgErr(err) } return rec.Id, nil } // 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 func (c *Client) GetCoverValidation(id uint) (CoverValidation, error) { path, err := url.JoinPath(validateCoverPath, fmt.Sprint(id)) if err != nil { return CoverValidation{}, pkgErr(err) } var rec CoverValidation if err := c.getDecode(path, &rec); err != nil { return CoverValidation{}, pkgErr(err) } return rec, nil } // PrintJobCost 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 func (c *Client) PrintJobCost(items []PrintJobCostLineItem, addr ShippingAddress, shipOpt ShippingLevel) (PrintJobCost, ShippingAddressValidation, error) { reqAddr := printJobCostReqShipAddr{ City: addr.City, Country: addr.Country, PostCode: addr.PostCode, State: addr.State, Street1: addr.Street1, Phone: addr.Phone, } payload := printJobCostReq{items, reqAddr, shipOpt} var resp printJobCostResp err := c.postDecode(printJobCostPath, payload, http.StatusCreated, &resp) if err != nil { return PrintJobCost{}, ShippingAddressValidation{}, pkgErr(err) } return resp.cost(), resp.AddressValidation, nil } // 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) GetPrintJobs(queries ...PrintJobQuery) ([]PrintJob, error) { var q printJobQueries q.apply(queries...) qvals := q.vals() verify := func(v any) error { resp := v.(*getPrintJobsResp) if int(resp.Count) != len(resp.Results) { return fmt.Errorf("count (%d) != len(results) (%d)", resp.Count, len(resp.Results)) } else if len(resp.Results) == 0 && resp.Next != "" { return fmt.Errorf("no results on this page, but server returned a next page: %s", resp.Next) } return nil } var jobs []PrintJob for page := 1; ; page++ { qvals.Set("page", fmt.Sprint(page)) var resp getPrintJobsResp if err := c.getQueryDecodeVerify(printJobsPath, qvals, &resp, verify); err != nil { return jobs, pkgErr(err) } if len(resp.Results) > 0 { jobs = append(jobs, resp.Results...) } if len(resp.Results) == 0 || len(resp.Next) == 0 { return jobs, nil } } } // GetPrintJob retrieves the print job with the given ID. // // https://api.lulu.com/docs/#tag/Print-Jobs/operation/Print-Jobs_read func (c *Client) GetPrintJob(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 } // 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) { job, err := print(c, contact, externalId, productionDelay, addr, shipOpt, items) if err != nil { err = pkgErr(err) } return job, err } // Reprint creates a print job, printing books whose PrintableIds are // known from a prior order. See also: Print(). // // https://api.lulu.com/docs/#tag/Print-Jobs/operation/Print-Jobs_reprint 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, LineItems: 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 } // 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 } return c.getQueryDecodeVerify(path, nil, v, verify) } // getDecodeVerify sends a GET /path/?query request and unmarshals and // verifies the response. func (c *Client) getQueryDecodeVerify(path string, query url.Values, v any, verify func(any) error) error { url, err := url.JoinPath(ApiUrl, 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} } if err := decodeResponse(resp, v); err != nil { return err } if err := verify(v); err != nil { return errResp{resp, err} } return nil } // 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 := url.JoinPath(ApiUrl, 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)) } 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() } } }