aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorSam Anthony <sam@samanthony.xyz>2026-05-13 18:28:09 -0400
committerSam Anthony <sam@samanthony.xyz>2026-05-13 18:28:09 -0400
commit0865e2e03651dcc7be91846a6e52125e56bcde31 (patch)
treec5abe0a595e1ad26db0e4e7195ad963ca845e202
parent48f85453ffbe36f6c428a5d9a23b74122686b64c (diff)
downloadlulu-0865e2e03651dcc7be91846a6e52125e56bcde31.zip
poll for file validation status
-rw-r--r--cover.go4
-rw-r--r--cover_test.go65
-rw-r--r--debug.go17
-rw-r--r--interior.go4
-rw-r--r--interior_test.go74
-rw-r--r--lulu.go139
-rw-r--r--lulu_test.go36
7 files changed, 218 insertions, 121 deletions
diff --git a/cover.go b/cover.go
index a44ebab..415e22a 100644
--- a/cover.go
+++ b/cover.go
@@ -80,8 +80,8 @@ func (cd *CoverDimensions) UnmarshalJSON(data []byte) error {
return nil
}
-// CoverValidationRecord contains the validation status of a cover file.
-type CoverValidationRecord struct {
+// CoverValidation contains the validation status of a cover file.
+type CoverValidation struct {
Id uint
SrcUrl string `json:"source_url"`
NPages uint `json:"page_count"`
diff --git a/cover_test.go b/cover_test.go
index 140bf84..d94e2f0 100644
--- a/cover_test.go
+++ b/cover_test.go
@@ -1,6 +1,7 @@
package lulu
import (
+ "context"
"testing"
"github.com/stretchr/testify/require"
@@ -80,7 +81,7 @@ func TestMarshalValidateCoverReq(t *testing.T) {
requireMarshalJsonEq(t, want, req)
}
-func TestUnmarshalCoverValidationRecord(t *testing.T) {
+func TestUnmarshalCoverValidation(t *testing.T) {
t.Parallel()
data := `{
"id": 1,
@@ -89,7 +90,7 @@ func TestUnmarshalCoverValidationRecord(t *testing.T) {
"errors": null,
"status": "NORMALIZING"
}`
- want := CoverValidationRecord{
+ want := CoverValidation{
1,
"https://www.dropbox.com/sh/p3zh22vzsaegiri/AADP367j0bTWlt8fCu-_tm2ia/161025/139056_cover.pdf?dl=1",
210,
@@ -99,7 +100,7 @@ func TestUnmarshalCoverValidationRecord(t *testing.T) {
requireUnmarshalJsonEq(t, want, data)
}
-func TestValidateCover(t *testing.T) {
+func TestStartCoverValidation(t *testing.T) {
c := newClient(t)
mfg := PkgId{
UsTrade,
@@ -111,43 +112,47 @@ func TestValidateCover(t *testing.T) {
NoLinen,
NoFoil,
}
- id, err := c.ValidateCover(coverUrl, mfg, 210)
+ id, err := c.StartCoverValidation(coverUrl, mfg, 210)
require.NoError(t, err)
require.NotZero(t, id)
}
func TestGetCoverValidation(t *testing.T) {
c := newClient(t)
-
- mfg := PkgId{
- UsTrade,
- Mono,
- Standard,
- Perfect,
- P60UncoatedWhite,
- Gloss,
- NoLinen,
- NoFoil,
- }
- id, err := c.ValidateCover(coverUrl, mfg, 210)
+ mfg := PkgId{UsTrade, Mono, Standard, Perfect, P60UncoatedWhite, Gloss, NoLinen, NoFoil}
+ id, err := c.StartCoverValidation(coverUrl, mfg, 210)
require.NoError(t, err)
-
- poll(t, func() bool {
- rec, err := c.GetCoverValidation(id)
+ tpoll(t, func() bool {
+ val, err := c.GetCoverValidation(id)
require.NoError(t, err)
- if rec.Status.IsFinal() {
- require.Equal(t, CoverStatusNormalized, rec.Status)
- require.Equal(t, id, rec.Id)
- require.Equal(t, coverUrl, rec.SrcUrl)
-
- //require.Equal(t, uint(210), rec.NPages)
- // The server doesn't seem to set the page_count
- // field, but that's OK because we already know
- // the page count.
-
- require.Empty(t, rec.Errors)
+ if val.Status.IsFinal() {
+ require.Equal(t, id, val.Id)
+ validateCoverValidation(t, val)
return true
}
return false
})
}
+
+func TestValidateCover(t *testing.T) {
+ c := newClient(t)
+ ctx, cancel := context.WithTimeout(t.Context(), timeout)
+ defer cancel()
+ mfg := PkgId{UsTrade, Mono, Standard, Perfect, P60UncoatedWhite, Gloss, NoLinen, NoFoil}
+ val, err := c.ValidateCover(ctx, coverUrl, mfg, 210)
+ require.NoError(t, err)
+ validateCoverValidation(t, val)
+}
+
+func validateCoverValidation(t *testing.T, val CoverValidation) {
+ t.Helper()
+ require.Equal(t, CoverStatusNormalized, val.Status)
+ require.Equal(t, coverUrl, val.SrcUrl)
+
+ //require.Equal(t, uint(210), val.NPages)
+ // The server doesn't seem to set the page_count
+ // field, but that's OK because we already know
+ // the page count.
+
+ require.Empty(t, val.Errors)
+}
diff --git a/debug.go b/debug.go
new file mode 100644
index 0000000..18f4423
--- /dev/null
+++ b/debug.go
@@ -0,0 +1,17 @@
+package lulu
+
+import (
+ "log"
+ "os"
+)
+
+var (
+ Debug = false // print debug info to stdout
+ debugLog = log.New(os.Stderr, "DEBUG ", log.LstdFlags|log.Llongfile)
+)
+
+func debugf(format string, a ...any) {
+ if Debug {
+ debugLog.Printf(format, a...)
+ }
+}
diff --git a/interior.go b/interior.go
index b1cdce7..98ff4d2 100644
--- a/interior.go
+++ b/interior.go
@@ -34,8 +34,8 @@ type validateInteriorBasicReq struct {
SrcUrl string `json:"source_url"`
}
-// InteriorValidationRecord contains the validation status of an interior file.
-type InteriorValidationRecord struct {
+// InteriorValidation contains the validation status of an interior file.
+type InteriorValidation struct {
Id uint
SrcUrl string `json:"source_url"`
NPages uint `json:"page_count"`
diff --git a/interior_test.go b/interior_test.go
index 59707b8..110afb2 100644
--- a/interior_test.go
+++ b/interior_test.go
@@ -1,6 +1,7 @@
package lulu
import (
+ "context"
"testing"
"github.com/stretchr/testify/require"
@@ -34,7 +35,7 @@ func TestMarshalValidateInteriorBasicReq(t *testing.T) {
validateInteriorBasicReq{"https://example.com/interior.pdf"})
}
-func TestUnmarshalInteriorValidationRecord(t *testing.T) {
+func TestUnmarshalInteriorValidation(t *testing.T) {
t.Parallel()
data := `{
"id": 1,
@@ -44,7 +45,7 @@ func TestUnmarshalInteriorValidationRecord(t *testing.T) {
"status": "VALIDATING",
"valid_pod_package_ids": null
}`
- want := InteriorValidationRecord{
+ want := InteriorValidation{
1,
"https://www.dropbox.com/sh/p3zh22vzsaegiri/AACOUn3LFKsITDzylh13bQpsa/161025/thesis2.pdf?dl=1",
210,
@@ -55,51 +56,62 @@ func TestUnmarshalInteriorValidationRecord(t *testing.T) {
requireUnmarshalJsonEq(t, want, data)
}
-func TestValidateInterior(t *testing.T) {
+func TestStartInteriorValidation(t *testing.T) {
c := newClient(t)
- mfg := PkgId{
- UsTrade,
- Mono,
- Standard,
- Perfect,
- P60UncoatedWhite,
- Gloss,
- NoLinen,
- NoFoil,
- }
- id, err := c.ValidateInterior(interiorUrl, mfg)
+ mfg := PkgId{UsTrade, Mono, Standard, Perfect, P60UncoatedWhite, Gloss, NoLinen, NoFoil}
+ id, err := c.StartInteriorValidation(interiorUrl, mfg)
require.NoError(t, err)
require.NotZero(t, id)
- // It seems the server doesn't populate most of the response
- // fields, but we just need the ID anyway.
}
-func TestValidateInteriorBasic(t *testing.T) {
+func TestStartInteriorValidationBasic(t *testing.T) {
c := newClient(t)
- id, err := c.ValidateInteriorBasic(interiorUrl)
+ id, err := c.StartInteriorValidationBasic(interiorUrl)
require.NoError(t, err)
require.NotZero(t, id)
- // It seems the server doesn't populate most of the response
- // fields, but we just need the ID anyway.
}
func TestGetInteriorValidation(t *testing.T) {
c := newClient(t)
- id, err := c.ValidateInteriorBasic(interiorUrl)
+ mfg := PkgId{UsTrade, Mono, Standard, Perfect, P60UncoatedWhite, Gloss, NoLinen, NoFoil}
+ id, err := c.StartInteriorValidation(interiorUrl, mfg)
require.NoError(t, err)
- poll(t, func() bool {
- rec, err := c.GetInteriorValidation(id)
+ tpoll(t, func() bool {
+ val, err := c.GetInteriorValidation(id)
require.NoError(t, err)
- if rec.Status.IsFinal() {
- require.Equal(t, InteriorStatusValidated, rec.Status)
-
- require.Equal(t, id, rec.Id)
- require.Equal(t, interiorUrl, rec.SrcUrl)
- require.Equal(t, uint(210), rec.NPages)
- require.Empty(t, rec.Errors)
- require.NotEmpty(t, rec.ValidPkgIds)
+ if val.Status.IsFinal() {
+ require.Equal(t, id, val.Id)
+ validateInteriorValidation(t, val, InteriorStatusNormalized)
return true
}
return false
})
}
+
+func TestValidateInterior(t *testing.T) {
+ c := newClient(t)
+ ctx, cancel := context.WithTimeout(t.Context(), timeout)
+ defer cancel()
+ mfg := PkgId{UsTrade, Mono, Standard, Perfect, P60UncoatedWhite, Gloss, NoLinen, NoFoil}
+ val, err := c.ValidateInterior(ctx, interiorUrl, mfg)
+ require.NoError(t, err)
+ validateInteriorValidation(t, val, InteriorStatusNormalized)
+}
+
+func TestValidateInteriorBasic(t *testing.T) {
+ c := newClient(t)
+ ctx, cancel := context.WithTimeout(t.Context(), timeout)
+ defer cancel()
+ val, err := c.ValidateInteriorBasic(ctx, interiorUrl)
+ require.NoError(t, err)
+ validateInteriorValidation(t, val, InteriorStatusValidated)
+}
+
+func validateInteriorValidation(t *testing.T, val InteriorValidation, wantStatus InteriorValidationStatus) {
+ t.Helper()
+ require.Equal(t, wantStatus, val.Status)
+ require.Equal(t, interiorUrl, val.SrcUrl)
+ require.Equal(t, uint(210), val.NPages)
+ require.Empty(t, val.Errors)
+ require.NotEmpty(t, val.ValidPkgIds)
+}
diff --git a/lulu.go b/lulu.go
index 04e34ab..a4e12cb 100644
--- a/lulu.go
+++ b/lulu.go
@@ -7,10 +7,8 @@ import (
"encoding/json"
"fmt"
"io"
- "log"
"net/http"
"net/url"
- "os"
"time"
"golang.org/x/oauth2/clientcredentials"
@@ -20,6 +18,11 @@ 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"
@@ -33,10 +36,22 @@ const (
// you are ready to deploy.
var ApiUrl = SandboxUrl
-var (
- Debug = false // print debug info to stdout
- debugLog = log.New(os.Stderr, "DEBUG ", log.LstdFlags|log.Llongfile)
-)
+// 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 {
c *http.Client
@@ -58,34 +73,63 @@ func NewClient(ctx context.Context, key, secret string) (*Client, error) {
return &Client{cfg.Client(ctx)}, nil
}
-// ValidateInterior 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.
+// 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) ValidateInterior(srcUrl string, mfg PkgId) (uint, error) {
- id, err := c.validateInterior(validateInteriorReq{srcUrl, mfg})
+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
}
-// ValidateInteriorBasic is like ValidateInterior but without the
-// optional pod_package_id.
+// 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) ValidateInteriorBasic(srcUrl string) (uint, error) {
- id, err := c.validateInterior(validateInteriorBasicReq{srcUrl})
+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) validateInterior(payload any) (uint, error) {
- var rec InteriorValidationRecord
+func (c *Client) startInteriorValidation(payload any) (uint, error) {
+ var rec InteriorValidation
err := c.postDecode(validateInteriorPath, payload, http.StatusCreated, &rec)
return rec.Id, err
}
@@ -94,14 +138,14 @@ func (c *Client) validateInterior(payload any) (uint, error) {
// 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) (InteriorValidationRecord, error) {
+func (c *Client) GetInteriorValidation(id uint) (InteriorValidation, error) {
path, err := url.JoinPath(validateInteriorPath, fmt.Sprint(id))
if err != nil {
- return InteriorValidationRecord{}, pkgErr(err)
+ return InteriorValidation{}, pkgErr(err)
}
- var rec InteriorValidationRecord
+ var rec InteriorValidation
if err := c.getDecode(path, &rec); err != nil {
- return InteriorValidationRecord{}, pkgErr(err)
+ return InteriorValidation{}, pkgErr(err)
}
return rec, nil
}
@@ -121,15 +165,29 @@ func (c *Client) CoverDimensions(mfg PkgId, npages uint, unit Unit) (CoverDimens
return dims, nil
}
-// ValidateCover starts a server-side validation job for the cover file
-// located at srcUrl, returning the job ID. mfg is the manufacturing
+// 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) ValidateCover(srcUrl string, mfg PkgId, npages uint) (uint, error) {
+func (c *Client) StartCoverValidation(srcUrl string, mfg PkgId, npages uint) (uint, error) {
payload := validateCoverReq{srcUrl, mfg, npages}
- var rec CoverValidationRecord
+ var rec CoverValidation
err := c.postDecode(validateCoverPath, payload, http.StatusCreated, &rec)
if err != nil {
return 0, pkgErr(err)
@@ -138,17 +196,17 @@ func (c *Client) ValidateCover(srcUrl string, mfg PkgId, npages uint) (uint, err
}
// GetCoverValidiation retrieves information about a cover file
-// validation job that was started by ValidiateCover().
+// 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) (CoverValidationRecord, error) {
+func (c *Client) GetCoverValidation(id uint) (CoverValidation, error) {
path, err := url.JoinPath(validateCoverPath, fmt.Sprint(id))
if err != nil {
- return CoverValidationRecord{}, pkgErr(err)
+ return CoverValidation{}, pkgErr(err)
}
- var rec CoverValidationRecord
+ var rec CoverValidation
if err := c.getDecode(path, &rec); err != nil {
- return CoverValidationRecord{}, pkgErr(err)
+ return CoverValidation{}, pkgErr(err)
}
return rec, nil
}
@@ -339,8 +397,21 @@ func decodeResponse(resp *http.Response, v any) error {
return nil
}
-func debugf(format string, a ...any) {
- if Debug {
- debugLog.Printf(format, a...)
+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()
+ }
}
}
diff --git a/lulu_test.go b/lulu_test.go
index ceb0990..420033c 100644
--- a/lulu_test.go
+++ b/lulu_test.go
@@ -3,6 +3,7 @@ package lulu
import (
"context"
"encoding/json"
+ "flag"
"fmt"
"os"
"strings"
@@ -16,13 +17,10 @@ const (
clientKeyPath = "testdata/clientkey"
clientSecretPath = "testdata/clientsecret"
- apiKeyPage = "https://developers.sandbox.com/user-profile/api-keys"
-
interiorUrl = "https://www.dropbox.com/sh/p3zh22vzsaegiri/AACOUn3LFKsITDzylh13bQpsa/161025/thesis2.pdf?dl=1"
coverUrl = "https://www.dropbox.com/sh/p3zh22vzsaegiri/AADP367j0bTWlt8fCu-_tm2ia/161025/139056_cover.pdf?dl=1"
- pollTimeout = 15 * time.Second
- pollPeriod = time.Second
+ timeout = 15 * time.Second
)
var (
@@ -31,18 +29,21 @@ var (
)
func TestMain(m *testing.M) {
- Debug = true
+ flag.BoolVar(&Debug, "d", false, "Print debug info to stderr.")
+ flag.Parse()
+
+ Sandbox()
if b, err := os.ReadFile(clientKeyPath); err == nil {
clientKey = strings.TrimSpace(string(b))
} else {
- fmt.Fprintf(os.Stderr, "%v\nCopy and paste your \"client key\" from the API Keys page into %s\n%s\n", err, clientKeyPath, apiKeyPage)
+ fmt.Fprintf(os.Stderr, "%v\nCopy and paste your \"client key\" from the API Keys page into %s\n%s\n", err, clientKeyPath, SandboxApiKeyPage)
os.Exit(1)
}
if b, err := os.ReadFile(clientSecretPath); err == nil {
clientSecret = strings.TrimSpace(string(b))
} else {
- fmt.Fprintf(os.Stderr, "%v\nCopy and paste your \"client secret\" from the API Keys page into %s\n%s\n", err, clientSecretPath, apiKeyPage)
+ fmt.Fprintf(os.Stderr, "%v\nCopy and paste your \"client secret\" from the API Keys page into %s\n%s\n", err, clientSecretPath, SandboxApiKeyPage)
os.Exit(1)
}
@@ -86,23 +87,14 @@ func requireNotBeforeDate(t *testing.T, dt2, dt1 Date) {
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) {
+func tpoll(t *testing.T, f func() bool) {
t.Helper()
- ctx, cancel := context.WithTimeout(t.Context(), pollTimeout)
+ ctx, cancel := context.WithTimeout(t.Context(), timeout)
defer cancel()
- timer := time.NewTimer(pollPeriod)
- for {
- select {
- case <-timer.C:
- if f() {
- return
- }
- timer.Reset(pollPeriod)
- case <-ctx.Done():
- t.Errorf("timed out after %v", pollTimeout)
- }
- }
+ _, err := poll(ctx, func() (struct{}, bool, error) {
+ return struct{}{}, f(), nil
+ })
+ require.NoError(t, err)
}
func mustParseTime(layout, value string) time.Time {