aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorSam Anthony <sam@samanthony.xyz>2026-05-06 21:19:12 -0400
committerSam Anthony <sam@samanthony.xyz>2026-05-06 21:19:12 -0400
commit2990789d98405291bfbb3e0dce4efca1153120ab (patch)
treed7bdc19b28909bdc2d5abf24f2c3575d199463ae
parentb6b81ac135e77f7856efb07389a6ac42cc137a6e (diff)
downloadlulu-2990789d98405291bfbb3e0dce4efca1153120ab.zip
implement POST /validate-interior
-rw-r--r--.gitignore2
-rw-r--r--README.md23
-rw-r--r--go.mod1
-rw-r--r--go.sum2
-rw-r--r--lulu.go150
-rw-r--r--lulu_test.go140
-rw-r--r--pkgid.go (renamed from pod.go)10
-rw-r--r--pkgid_test.go51
-rw-r--r--pod_test.go67
9 files changed, 374 insertions, 72 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ff42102
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+testdata
+todo
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..83364a2
--- /dev/null
+++ b/README.md
@@ -0,0 +1,23 @@
+# Lulu Book Printing API Client
+
+[https://api.lulu.com/docs/](https://api.lulu.com/docs/)
+
+
+## Testing
+
+Some of the tests require a connection to the sandbox API.
+Before running the tests you must copy and paste your "client key" and "client secret" from the
+[Sandbox API Keys Page](https://developers.sandbox.lulu.com/user-profile/api-keys)
+into the files `testdata/clientkey` and `testdata/clientsecret`.
+
+For example, if your client-key and client-secret were `190f024a-fded-f508-7db8-a987b1159f40` and `dSdCXch8SCIcAksu6CXd1SzvfYUUdjPs`,
+
+```sh
+mkdir testdata
+echo 190f024a-fded-f508-7db8-a987b1159f40 >testdata/clientkey
+echo dSdCXch8SCIcAksu6CXd1SzvfYUUdjPs >testdata/clientsecret
+chmod 600 testdata/clientkey testdata/clientsecret
+```
+
+The tests make connections to Lulu's sandbox API server.
+Please be polite and don't run the tests too often to avoid spamming their server.
diff --git a/go.mod b/go.mod
index 50c17e0..b33c728 100644
--- a/go.mod
+++ b/go.mod
@@ -6,5 +6,6 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/testify v1.11.1 // indirect
+ golang.org/x/oauth2 v0.36.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/go.sum b/go.sum
index cc8b3f4..bf5faae 100644
--- a/go.sum
+++ b/go.sum
@@ -4,6 +4,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
+golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
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 3493991..44220b8 100644
--- a/lulu.go
+++ b/lulu.go
@@ -1 +1,151 @@
+// 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"
+
+ "golang.org/x/oauth2/clientcredentials"
+)
+
+const (
+ SandboxUrl = "https://api.sandbox.lulu.com/"
+ ProductionUrl = "https://api.lulu.com/"
+
+ tokenPath = "/auth/realms/glasstree/protocol/openid-connect/token"
+ validateInteriorPath = "/validate-interior"
+ coverDimensionsPath = "/cover-dimensions"
+ validateCoverPath = "/validate-cover"
+)
+
+// 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
+
+// Unit is a unit of length measurement.
+type Unit string
+
+const (
+ Points Unit = "pt"
+ Millimeters = "mm"
+ Inches = "inch"
+)
+
+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
+)
+
+func (s ValidationStatus) IsFinal() bool {
+ switch s {
+ case StatusValidated, StatusNormalized, StatusError:
+ return true
+ }
+ return false
+}
+
+// validateInteriorReq is the json body of a /validate-interior/ request.
+type validateInteriorReq struct {
+ SrcUrl string `json:"source_url"`
+ PkgId PkgId `json:"pod_package_id"`
+}
+
+// validateInteriorReq is the json body of a /validate-interior/ request without the optional pod_package_id.
+type validateInteriorBasicReq struct {
+ SrcUrl string `json:"source_url"`
+}
+
+// InteriorValidationRecord contains the validation status of an interior file.
+type InteriorValidationRecord struct {
+ Id uint
+ SrcUrl string `json:"source_url"`
+ NPages uint `json:"page_count"`
+ Errors string
+ Status ValidationStatus
+ ValidPkgIds []PkgId `json:"valid_pod_package_ids"`
+}
+
+type Client struct {
+ 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, err
+ }
+
+ cfg := &clientcredentials.Config{
+ ClientID: key,
+ ClientSecret: secret,
+ TokenURL: tokenUrl,
+ }
+ 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.
+//
+// https://api.lulu.com/docs/#tag/Files-validation/operation/Validate-Interior_create
+func (c *Client) ValidateInterior(srcUrl string, mfg PkgId) (uint, error) {
+ return c.validateInterior(validateInteriorReq{srcUrl, mfg})
+}
+
+// ValidateInteriorBasic is like ValidateInterior 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) {
+ return c.validateInterior(validateInteriorBasicReq{srcUrl})
+}
+
+func (c *Client) validateInterior(payload any) (uint, error) {
+ body, err := json.Marshal(payload)
+ if err != nil {
+ return 0, fmt.Errorf("lulu: error encoding request body %v for %s: %w", payload, validateInteriorPath, err)
+ }
+
+ url, err := url.JoinPath(ApiUrl, validateInteriorPath)
+ if err != nil {
+ return 0, fmt.Errorf("lulu: %w", err)
+ }
+ resp, err := c.c.Post(url, "application/json", bytes.NewBuffer(body))
+ if err != nil {
+ return 0, fmt.Errorf("lulu: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusCreated {
+ body, _ := io.ReadAll(resp.Body)
+ return 0, fmt.Errorf("lulu: POST %s: %s: %s", url, resp.Status, body)
+ }
+
+ dec := json.NewDecoder(resp.Body)
+ var rec InteriorValidationRecord
+ err = dec.Decode(&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 int) (InteriorValidationRecord, error) {
+ return InteriorValidationRecord{}, fmt.Errorf("not implemented") // TODO
+}
diff --git a/lulu_test.go b/lulu_test.go
new file mode 100644
index 0000000..b44427a
--- /dev/null
+++ b/lulu_test.go
@@ -0,0 +1,140 @@
+package lulu
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+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"
+)
+
+var (
+ clientKey string
+ clientSecret string
+)
+
+func TestMain(m *testing.M) {
+ 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)
+ 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)
+ os.Exit(1)
+ }
+
+ m.Run()
+}
+
+func TestMarshalValidateInteriorReq(t *testing.T) {
+ t.Parallel()
+
+ want := `{
+ "source_url": "https://example.com/interior.pdf",
+ "pod_package_id": "0850X1100.BW.STD.LW.060UW444.MNG"
+}`
+
+ req := validateInteriorReq{
+ "https://example.com/interior.pdf",
+ PkgId{
+ UsLetter,
+ Mono,
+ Standard,
+ LinenWrap,
+ P60UncoatedWhite,
+ Matte,
+ NavyLinen,
+ GoldFoil},
+ }
+
+ requireJsonEq(t, want, req)
+}
+
+func TestMarshalValidateInteriorBasicReq(t *testing.T) {
+ t.Parallel()
+ requireJsonEq(t,
+ `{"source_url": "https://example.com/interior.pdf"}`,
+ validateInteriorBasicReq{"https://example.com/interior.pdf"})
+}
+
+func TestUnmarshalInteriorValidationRecord(t *testing.T) {
+ t.Parallel()
+ data := []byte(`{
+ "id": 1,
+ "source_url": "https://www.dropbox.com/sh/p3zh22vzsaegiri/AACOUn3LFKsITDzylh13bQpsa/161025/thesis2.pdf?dl=1",
+ "page_count": 210,
+ "errors": null,
+ "status": "VALIDATING",
+ "valid_pod_package_ids": null
+}`)
+ var rec InteriorValidationRecord
+ err := json.Unmarshal(data, &rec)
+ require.NoError(t, err)
+ require.Equal(t,
+ InteriorValidationRecord{
+ 1,
+ "https://www.dropbox.com/sh/p3zh22vzsaegiri/AACOUn3LFKsITDzylh13bQpsa/161025/thesis2.pdf?dl=1",
+ 210,
+ "",
+ StatusValidating,
+ nil},
+ rec)
+}
+
+func TestValidateInterior(t *testing.T) {
+ c := newClient(t)
+ pkg := PkgId{
+ UsTrade,
+ Mono,
+ Standard,
+ Perfect,
+ P60UncoatedWhite,
+ Gloss,
+ NoLinen,
+ NoFoil,
+ }
+ id, err := c.ValidateInterior(interiorUrl, pkg)
+ require.NoError(t, err)
+ require.NotZero(t, id)
+}
+
+func TestValidateInteriorBasic(t *testing.T) {
+ c := newClient(t)
+ id, err := c.ValidateInteriorBasic(interiorUrl)
+ require.NoError(t, err)
+ require.NotZero(t, id)
+}
+
+func TestGetInteriorValidation(t *testing.T) {
+ t.Fail() // TODO
+}
+
+func newClient(t *testing.T) *Client {
+ t.Helper()
+ c, err := NewClient(t.Context(), clientKey, clientSecret)
+ require.NoError(t, err)
+ return c
+}
+
+func requireJsonEq(t *testing.T, expected string, actual any) {
+ t.Helper()
+ jactual, err := json.Marshal(actual)
+ require.NoError(t, err)
+ require.JSONEq(t, expected, string(jactual))
+}
diff --git a/pod.go b/pkgid.go
index 2796a80..95b6303 100644
--- a/pod.go
+++ b/pkgid.go
@@ -5,9 +5,9 @@ import (
"fmt"
)
-// PodPkgId is a pod_package_id which represents the manufacturing options.
-// https://api.lulu.com/docs/#section/Getting-Started/Select-a-Product
-type PodPkgId struct {
+// PkgId is a pod_package_id which represents the manufacturing options
+// of a printable.
+type PkgId struct {
TrimSize
ColorType
Quality
@@ -18,8 +18,8 @@ type PodPkgId struct {
Foil
}
-func (pod PodPkgId) MarshalJSON() ([]byte, error) {
- return json.Marshal(fmt.Sprintf("%s.%s.%s.%s.%s.%s%s%s", pod.TrimSize, pod.ColorType, pod.Quality, pod.Binding, pod.Paper, pod.Finish, pod.Linen, pod.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))
}
// TrimSize is the final dimensions of the pages after the bleed margins
diff --git a/pkgid_test.go b/pkgid_test.go
new file mode 100644
index 0000000..4a6120f
--- /dev/null
+++ b/pkgid_test.go
@@ -0,0 +1,51 @@
+package lulu
+
+import "testing"
+
+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})
+}
diff --git a/pod_test.go b/pod_test.go
deleted file mode 100644
index c54b8e5..0000000
--- a/pod_test.go
+++ /dev/null
@@ -1,67 +0,0 @@
-package lulu_test
-
-import (
- "encoding/json"
- "testing"
-
- "github.com/stretchr/testify/require"
-
- "git.samanthony.xyz/lulu"
-)
-
-func TestPod(t *testing.T) {
- t.Parallel()
- requireJsonEq(t,
- "0850X1100.BW.STD.LW.060UW444.MNG",
- lulu.PodPkgId{
- lulu.UsLetter,
- lulu.Mono,
- lulu.Standard,
- lulu.LinenWrap,
- lulu.P60UncoatedWhite,
- lulu.Matte,
- lulu.NavyLinen,
- lulu.GoldFoil})
- requireJsonEq(t,
- "0600X0900.FC.STD.PB.080CW444.GXX",
- lulu.PodPkgId{
- lulu.UsTrade,
- lulu.Color,
- lulu.Standard,
- lulu.Perfect,
- lulu.P80CoatedWhite,
- lulu.Gloss,
- lulu.NoLinen,
- lulu.NoFoil})
- requireJsonEq(t,
- "0700X1000.FC.PRE.CO.060UC444.MXX",
- lulu.PodPkgId{
- lulu.Executive,
- lulu.Color,
- lulu.Premium,
- lulu.Coil,
- lulu.P60UncoatedCream,
- lulu.Matte,
- lulu.NoLinen,
- lulu.NoFoil})
- requireJsonEq(t,
- "0600X0900.BW.STD.PB.060UW444.MXX",
- lulu.PodPkgId{
- lulu.UsTrade,
- lulu.Mono,
- lulu.Standard,
- lulu.Perfect,
- lulu.P60UncoatedWhite,
- lulu.Matte,
- lulu.NoLinen,
- lulu.NoFoil})
-}
-
-func requireJsonEq(t *testing.T, expected string, actual json.Marshaler) {
- t.Helper()
- a, err := json.Marshal(expected)
- require.NoError(t, err)
- b, err := json.Marshal(actual)
- require.NoError(t, err)
- require.JSONEq(t, string(a), string(b))
-}