diff options
| -rw-r--r-- | .gitignore | 2 | ||||
| -rw-r--r-- | README.md | 23 | ||||
| -rw-r--r-- | go.mod | 1 | ||||
| -rw-r--r-- | go.sum | 2 | ||||
| -rw-r--r-- | lulu.go | 150 | ||||
| -rw-r--r-- | lulu_test.go | 140 | ||||
| -rw-r--r-- | pkgid.go (renamed from pod.go) | 10 | ||||
| -rw-r--r-- | pkgid_test.go | 51 | ||||
| -rw-r--r-- | pod_test.go | 67 |
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. @@ -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 ) @@ -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= @@ -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)) +} @@ -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)) -} |