diff options
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | Makefile | 14 | ||||
| -rw-r--r-- | go.mod | 5 | ||||
| -rw-r--r-- | go.sum | 10 | ||||
| -rw-r--r-- | lulu.go | 50 | ||||
| -rw-r--r-- | lulu_test.go | 58 | ||||
| -rw-r--r-- | pkgid.go | 109 | ||||
| -rw-r--r-- | pkgid_test.go | 112 |
8 files changed, 256 insertions, 103 deletions
@@ -1,2 +1,3 @@ +*_gen.go testdata todo diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8c3ea98 --- /dev/null +++ b/Makefile @@ -0,0 +1,14 @@ +GEN = pkgid_gen.go +TEST = $(wildcard *_test.go) +SRC = $(filter-out ${GEN} ${TEST}, $(wildcard *.go)) + +build: ${SRC} ${GEN} + go build + +${GEN}: ${SRC} + go generate + +clean: + rm -f ${GEN} + +.PHONY: build clean @@ -5,7 +5,12 @@ go 1.25.9 require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/spf13/pflag v1.0.6 // indirect github.com/stretchr/testify v1.11.1 // indirect + github.com/yawnak/string-enumer v0.0.0-20250330104602-f50db3525c45 // indirect + golang.org/x/mod v0.24.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.12.0 // indirect + golang.org/x/tools v0.31.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) @@ -2,10 +2,20 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/yawnak/string-enumer v0.0.0-20250330104602-f50db3525c45 h1:0iUqJCEe0kuryd8KPqhnnczbXrD6r66Qrlus4Q7xrb4= +github.com/yawnak/string-enumer v0.0.0-20250330104602-f50db3525c45/go.mod h1:14IQUPrpaeA3sucvDb4CoBSnKSaIrXhKZOfdy4ZIx1E= +golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= +golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU= +golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= @@ -41,11 +41,11 @@ type ValidationStatus string const ( StatusNull ValidationStatus = "NULL" // file validation is not started yet - StatusValidating = "VALIDATING" // file validation is still running - StatusValidated = "VALIDATED" // file validation finished without any errors - StatusNormalizing = "NORMALIZING" // file normalization (next step of validation, available only if pod_package_id is was passed in the payload) is still running - StatusNormalized = "NORMALIZED" // file normalization finished without any errors - StatusError = "ERROR" // file is invalid, list of errors is included in the response + StatusValidating ValidationStatus = "VALIDATING" // file validation is still running + StatusValidated ValidationStatus = "VALIDATED" // file validation finished without any errors + StatusNormalizing ValidationStatus = "NORMALIZING" // file normalization (next step of validation, available only if pod_package_id is was passed in the payload) is still running + StatusNormalized ValidationStatus = "NORMALIZED" // file normalization finished without any errors + StatusError ValidationStatus = "ERROR" // file is invalid, list of errors is included in the response ) func (s ValidationStatus) IsFinal() bool { @@ -136,16 +136,46 @@ func (c *Client) validateInterior(payload any) (uint, error) { return 0, fmt.Errorf("lulu: POST %s: %s: %s", url, resp.Status, body) } - dec := json.NewDecoder(resp.Body) + buf := new(bytes.Buffer) + if _, err := io.Copy(buf, resp.Body); err != nil { + return 0, fmt.Errorf("lulu: POST %s: error reading response body: %w", url, err) + } + dec := json.NewDecoder(buf) var rec InteriorValidationRecord - err = dec.Decode(&rec) - return rec.Id, err + if err := dec.Decode(&rec); err != nil { + return 0, fmt.Errorf("lulu: POST %s: error decoding response body %q: %w", url, buf, err) + } + return rec.Id, nil } // 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 int) (InteriorValidationRecord, error) { - return InteriorValidationRecord{}, fmt.Errorf("not implemented") // TODO +func (c *Client) GetInteriorValidation(id uint) (InteriorValidationRecord, error) { + url, err := url.JoinPath(ApiUrl, validateInteriorPath, fmt.Sprint(id)) + if err != nil { + return InteriorValidationRecord{}, fmt.Errorf("lulu: %w", err) + } + resp, err := c.c.Get(url) + if err != nil { + return InteriorValidationRecord{}, fmt.Errorf("lulu: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return InteriorValidationRecord{}, fmt.Errorf("lulu: GET %s: %s: %s", url, resp.Status, body) + } + + buf := new(bytes.Buffer) + if _, err := io.Copy(buf, resp.Body); err != nil { + return InteriorValidationRecord{}, fmt.Errorf("lulu: GET %s: error reading response body: %w", url, err) + } + dec := json.NewDecoder(buf) + var rec InteriorValidationRecord + if err := dec.Decode(&rec); err != nil { + return rec, fmt.Errorf("lulu: GET %s: error decoding response body %q: %w", url, buf, err) + } + return rec, nil } diff --git a/lulu_test.go b/lulu_test.go index b44427a..5f11e99 100644 --- a/lulu_test.go +++ b/lulu_test.go @@ -1,11 +1,13 @@ package lulu import ( + "context" "encoding/json" "fmt" "os" "strings" "testing" + "time" "github.com/stretchr/testify/require" ) @@ -63,12 +65,12 @@ func TestMarshalValidateInteriorReq(t *testing.T) { GoldFoil}, } - requireJsonEq(t, want, req) + requireMarshalJsonEq(t, want, req) } func TestMarshalValidateInteriorBasicReq(t *testing.T) { t.Parallel() - requireJsonEq(t, + requireMarshalJsonEq(t, `{"source_url": "https://example.com/interior.pdf"}`, validateInteriorBasicReq{"https://example.com/interior.pdf"}) } @@ -99,7 +101,7 @@ func TestUnmarshalInteriorValidationRecord(t *testing.T) { func TestValidateInterior(t *testing.T) { c := newClient(t) - pkg := PkgId{ + mfg := PkgId{ UsTrade, Mono, Standard, @@ -109,9 +111,11 @@ func TestValidateInterior(t *testing.T) { NoLinen, NoFoil, } - id, err := c.ValidateInterior(interiorUrl, pkg) + id, err := c.ValidateInterior(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) { @@ -119,10 +123,43 @@ func TestValidateInteriorBasic(t *testing.T) { id, err := c.ValidateInteriorBasic(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) { - t.Fail() // TODO + c := newClient(t) + + // Start validation job + id, err := c.ValidateInteriorBasic(interiorUrl) + require.NoError(t, err) + + // Poll until status is non-null + timeout := 15 * time.Second + period := time.Second + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + timer := time.NewTimer(period) + for { + select { + case <-timer.C: + rec, err := c.GetInteriorValidation(id) + require.NoError(t, err) + if rec.Status.IsFinal() { + 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.Equal(t, StatusValidated, rec.Status) + require.NotEmpty(t, rec.ValidPkgIds) + return + } + timer.Reset(period) + case <-ctx.Done(): + t.Errorf("status still not finalized after %v", timeout) + return + } + } } func newClient(t *testing.T) *Client { @@ -132,9 +169,16 @@ func newClient(t *testing.T) *Client { return c } -func requireJsonEq(t *testing.T, expected string, actual any) { +func requireMarshalJsonEq(t *testing.T, expected string, marshaler any) { t.Helper() - jactual, err := json.Marshal(actual) + jactual, err := json.Marshal(marshaler) require.NoError(t, err) require.JSONEq(t, expected, string(jactual)) } + +func requireUnmarshalJsonEq[T any](t *testing.T, expected T, j string) { + t.Helper() + var actual T + require.NoError(t, json.Unmarshal([]byte(j), &actual)) + require.Equal(t, expected, actual) +} @@ -1,10 +1,13 @@ package lulu import ( - "encoding/json" + "bytes" + "encoding" "fmt" ) +//go:generate go run github.com/yawnak/string-enumer -t TrimSize -t ColorType -t Quality -t Binding -t Paper -t Finish -t Linen -t Foil --text -o ./pkgid_gen.go . + // PkgId is a pod_package_id which represents the manufacturing options // of a printable. type PkgId struct { @@ -18,8 +21,32 @@ type PkgId struct { Foil } -func (p PkgId) MarshalJSON() ([]byte, error) { - return json.Marshal(fmt.Sprintf("%s.%s.%s.%s.%s.%s%s%s", p.TrimSize, p.ColorType, p.Quality, p.Binding, p.Paper, p.Finish, p.Linen, p.Foil)) +func (p PkgId) MarshalText() ([]byte, error) { + return []byte(fmt.Sprintf("%s.%s.%s.%s.%s.%s%s%s", p.TrimSize, p.ColorType, p.Quality, p.Binding, p.Paper, p.Finish, p.Linen, p.Foil)), nil +} + +func (p *PkgId) UnmarshalText(text []byte) error { + s := string(text) + parts := bytes.Split(text, []byte(".")) + if len(parts) != 6 { + return fmt.Errorf("malformed pod_package_id %q: has %d dot-separated fields; want 6", s, len(parts)) + } + cover := parts[5] + if len(cover) != 3 { + return fmt.Errorf("malformed pod_package_id %q: finish/linen/foil suffix must be 3 characters", s) + } + + for i, field := range []encoding.TextUnmarshaler{&p.TrimSize, &p.ColorType, &p.Quality, &p.Binding, &p.Paper} { + if err := field.UnmarshalText(parts[i]); err != nil { + return err + } + } + for i, field := range []encoding.TextUnmarshaler{&p.Finish, &p.Linen, &p.Foil} { + if err := field.UnmarshalText([]byte{cover[i]}); err != nil { + return err + } + } + return nil } // TrimSize is the final dimensions of the pages after the bleed margins @@ -28,21 +55,21 @@ type TrimSize string const ( Pocketbook TrimSize = "0425X0687" - Novella = "0500X0800" - Digest = "0550X0850" - A5 = "0583X0827" - UsTrade = "0600X0900" - Royal = "0614X0921" - Comic = "0663X1025" - SmallSquare = "0750X0750" - Executive = "0700X1000" - CrownQuatro = "0744X0968" - Square = "0850X0850" - A4 = "0827X1169" - UsLetter = "0850X1100" - Landscape = "0900X0700" - UsLetterLandscape = "1100X0850" - A4Landscape = "1169X0827" + Novella TrimSize = "0500X0800" + Digest TrimSize = "0550X0850" + A5 TrimSize = "0583X0827" + UsTrade TrimSize = "0600X0900" + Royal TrimSize = "0614X0921" + Comic TrimSize = "0663X1025" + SmallSquare TrimSize = "0750X0750" + Executive TrimSize = "0700X1000" + CrownQuatro TrimSize = "0744X0968" + Square TrimSize = "0850X0850" + A4 TrimSize = "0827X1169" + UsLetter TrimSize = "0850X1100" + Landscape TrimSize = "0900X0700" + UsLetterLandscape TrimSize = "1100X0850" + A4Landscape TrimSize = "1169X0827" ) // ColorType is the color mode of the printer. @@ -50,7 +77,7 @@ type ColorType string const ( Mono ColorType = "BW" - Color = "FC" + Color ColorType = "FC" ) // Quality is the print quality. @@ -58,18 +85,18 @@ type Quality string const ( Premium Quality = "PRE" - Standard = "STD" + Standard Quality = "STD" ) type Binding string const ( Perfect Binding = "PB" - Coil = "CO" - SaddleStitch = "SS" - CaseWrap = "CW" - LinenWrap = "LW" - WireO = "WO" + Coil Binding = "CO" + SaddleStitch Binding = "SS" + CaseWrap Binding = "CW" + LinenWrap Binding = "LW" + WireO Binding = "WO" ) // Paper is the weight/thickness and hue of the paper. @@ -77,10 +104,10 @@ type Paper string const ( P60UncoatedWhite Paper = "060UW444" - P60UncoatedCream = "060UC444" - P70CoatedWhite = "070CW460" - P80CoatedWhite = "080CW444" - P100CoatedWhite = "100CW" + P60UncoatedCream Paper = "060UC444" + P70CoatedWhite Paper = "070CW460" + P80CoatedWhite Paper = "080CW444" + P100CoatedWhite Paper = "100CW" ) // Finish is the surface finish of the cover. @@ -88,8 +115,8 @@ type Finish string const ( Gloss Finish = "G" - Matte = "M" - Unlaminated = "U" + Matte Finish = "M" + Unlaminated Finish = "U" ) // Linen is the color of the linen-wrapped cover, if applicable. @@ -97,13 +124,13 @@ type Linen string const ( RedLinen Linen = "R" - NavyLinen = "N" - BlackLinen = "B" - GrayLinen = "G" - TanLinen = "T" - ForestLinen = "F" - InteriorCoverPrint = "I" - NoLinen = "X" + NavyLinen Linen = "N" + BlackLinen Linen = "B" + GrayLinen Linen = "G" + TanLinen Linen = "T" + ForestLinen Linen = "F" + InteriorCoverPrint Linen = "I" + NoLinen Linen = "X" ) // Foil is the color of the foil stamping, if applicable. @@ -111,7 +138,7 @@ type Foil string const ( GoldFoil Foil = "G" - BlackFoil = "B" - WhiteFoil = "W" - NoFoil = "X" + BlackFoil Foil = "B" + WhiteFoil Foil = "W" + NoFoil Foil = "X" ) diff --git a/pkgid_test.go b/pkgid_test.go index 4a6120f..6a6865c 100644 --- a/pkgid_test.go +++ b/pkgid_test.go @@ -1,51 +1,73 @@ package lulu -import "testing" +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) func TestPkgId(t *testing.T) { t.Parallel() - requireJsonEq(t, - `"0850X1100.BW.STD.LW.060UW444.MNG"`, - PkgId{ - UsLetter, - Mono, - Standard, - LinenWrap, - P60UncoatedWhite, - Matte, - NavyLinen, - GoldFoil}) - requireJsonEq(t, - `"0600X0900.FC.STD.PB.080CW444.GXX"`, - PkgId{ - UsTrade, - Color, - Standard, - Perfect, - P80CoatedWhite, - Gloss, - NoLinen, - NoFoil}) - requireJsonEq(t, - `"0700X1000.FC.PRE.CO.060UC444.MXX"`, - PkgId{ - Executive, - Color, - Premium, - Coil, - P60UncoatedCream, - Matte, - NoLinen, - NoFoil}) - requireJsonEq(t, - `"0600X0900.BW.STD.PB.060UW444.MXX"`, - PkgId{ - UsTrade, - Mono, - Standard, - Perfect, - P60UncoatedWhite, - Matte, - NoLinen, - NoFoil}) + for _, pair := range []struct { + j string + pkg PkgId + }{ + { + `"0850X1100.BW.STD.LW.060UW444.MNG"`, + PkgId{ + UsLetter, + Mono, + Standard, + LinenWrap, + P60UncoatedWhite, + Matte, + NavyLinen, + GoldFoil}, + }, { + `"0600X0900.FC.STD.PB.080CW444.GXX"`, + PkgId{ + UsTrade, + Color, + Standard, + Perfect, + P80CoatedWhite, + Gloss, + NoLinen, + NoFoil}, + }, { + `"0700X1000.FC.PRE.CO.060UC444.MXX"`, + PkgId{ + Executive, + Color, + Premium, + Coil, + P60UncoatedCream, + Matte, + NoLinen, + NoFoil}, + }, { + `"0600X0900.BW.STD.PB.060UW444.MXX"`, + PkgId{ + UsTrade, + Mono, + Standard, + Perfect, + P60UncoatedWhite, + Matte, + NoLinen, + NoFoil}, + }, + } { + requireMarshalJsonEq(t, pair.j, pair.pkg) + requireUnmarshalJsonEq(t, pair.pkg, pair.j) + } +} + +func TestUnmarshalInvalidPkgId(t *testing.T) { + var pkg PkgId + require.Error(t, json.Unmarshal([]byte(``), &pkg)) + require.Error(t, json.Unmarshal([]byte(`""`), &pkg)) + require.Error(t, json.Unmarshal([]byte(`"abc"`), &pkg)) + require.Error(t, json.Unmarshal([]byte(`"9999X9999.XX.XXX.XX.999XX999.ZZZ"`), &pkg)) } |