aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--Makefile14
-rw-r--r--go.mod5
-rw-r--r--go.sum10
-rw-r--r--lulu.go50
-rw-r--r--lulu_test.go58
-rw-r--r--pkgid.go109
-rw-r--r--pkgid_test.go112
8 files changed, 256 insertions, 103 deletions
diff --git a/.gitignore b/.gitignore
index ff42102..647f879 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/go.mod b/go.mod
index b33c728..8d3ada2 100644
--- a/go.mod
+++ b/go.mod
@@ -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
)
diff --git a/go.sum b/go.sum
index bf5faae..d678017 100644
--- a/go.sum
+++ b/go.sum
@@ -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=
diff --git a/lulu.go b/lulu.go
index 44220b8..2346749 100644
--- a/lulu.go
+++ b/lulu.go
@@ -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)
+}
diff --git a/pkgid.go b/pkgid.go
index 95b6303..1268568 100644
--- a/pkgid.go
+++ b/pkgid.go
@@ -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))
}