From 0865e2e03651dcc7be91846a6e52125e56bcde31 Mon Sep 17 00:00:00 2001 From: Sam Anthony Date: Wed, 13 May 2026 18:28:09 -0400 Subject: poll for file validation status --- cover.go | 4 +- cover_test.go | 65 ++++++++++++++------------ debug.go | 17 +++++++ interior.go | 4 +- interior_test.go | 74 ++++++++++++++++------------- lulu.go | 139 +++++++++++++++++++++++++++++++++++++++++-------------- lulu_test.go | 36 ++++++-------- 7 files changed, 218 insertions(+), 121 deletions(-) create mode 100644 debug.go 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 { -- cgit v1.2.3