aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore9
-rw-r--r--Makefile9
-rw-r--r--cmd/lulu/creds.go41
-rw-r--r--cmd/lulu/flag.go138
-rw-r--r--cmd/lulu/main.go109
-rw-r--r--cmd/lulu/mfg.go74
-rw-r--r--cmd/lulu/validate_cover.go9
-rw-r--r--cmd/lulu/validate_interior.go59
-rw-r--r--go.mod2
-rw-r--r--go.sum4
-rw-r--r--lulu.go8
11 files changed, 453 insertions, 9 deletions
diff --git a/.gitignore b/.gitignore
index bf9c604..8f18729 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
-spec.yml
-testdata/clientkey
-testdata/clientsecret
-todo
+/lulu
+/spec.yml
+/testdata/clientkey
+/testdata/clientsecret
+/todo
diff --git a/Makefile b/Makefile
index 7914f12..c254292 100644
--- a/Makefile
+++ b/Makefile
@@ -2,16 +2,19 @@ GEN = $(addsuffix _gen.go, cover interior pkgid ship status)
TEST = $(wildcard *_test.go)
SRC = $(filter-out ${GEN} ${TEST}, $(wildcard *.go))
+all: build lulu
+
build: ${SRC} ${GEN}
go build
${GEN}: ${SRC}
go generate
+lulu: ${SRC} $(wildcard cmd/lulu/*.go)
+ go build ./cmd/$@
+
spec.yml:
curl -L -o $@ 'https://api.lulu.com/api-docs/openapi-specs/openapi_public.yml'
clean:
- rm -f ${GEN}
-
-.PHONY: build clean
+ rm -f ${GEN} ${BIN}
diff --git a/cmd/lulu/creds.go b/cmd/lulu/creds.go
new file mode 100644
index 0000000..5d80018
--- /dev/null
+++ b/cmd/lulu/creds.go
@@ -0,0 +1,41 @@
+package main
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/adrg/xdg"
+)
+
+var (
+ configDir = filepath.Join(xdg.ConfigHome, "lulu")
+ keyFile = filepath.Join(configDir, "key")
+ secretFile = filepath.Join(configDir, "secret")
+ sandboxKeyFile = filepath.Join(configDir, "sandbox", "key")
+ sandboxSecretFile = filepath.Join(configDir, "sandbox", "secret")
+)
+
+// Credentials contains the client-key and client-secret used to
+// authenticate the client (us) to the API.
+type Credentials struct {
+ key, secret string
+}
+
+func readCreds() (Credentials, error) {
+ key, err := readFile(keyFile)
+ if err != nil {
+ return Credentials{}, fmt.Errorf("error reading client-key file: %w", err)
+ }
+ secret, err := readFile(secretFile)
+ if err != nil {
+ return Credentials{}, fmt.Errorf("error reading client-secret file: %w", err)
+ }
+ return Credentials{key, secret}, nil
+}
+
+func readFile(path string) (string, error) {
+ data, err := os.ReadFile(path)
+ return strings.TrimSpace(string(data)), err
+}
diff --git a/cmd/lulu/flag.go b/cmd/lulu/flag.go
new file mode 100644
index 0000000..cd7773e
--- /dev/null
+++ b/cmd/lulu/flag.go
@@ -0,0 +1,138 @@
+package main
+
+import (
+ "encoding"
+ "flag"
+ "fmt"
+ "net/url"
+ "os"
+ "strconv"
+ "strings"
+ "unicode"
+)
+
+type Flag struct {
+ val flag.Value
+ name string
+ usage string
+}
+
+func (f Flag) Synopsis() string {
+ s := fmt.Sprintf("-%s", f.name)
+ if bf, ok := f.val.(boolFlag); !ok || !bf.IsBoolFlag() {
+ s += " " + strings.ToUpper(firstWord(f.usage))
+ }
+ return s
+}
+
+func (f Flag) AddTo(fs *flag.FlagSet) {
+ fs.Var(f.val, f.name, f.usage)
+}
+
+func (f Flag) IsSet() bool {
+ return f.val.String() != ""
+}
+
+func (f Flag) MustBeSet(fs *flag.FlagSet) {
+ if !f.IsSet() {
+ errMissingFlag(fs, f)
+ }
+}
+
+// boolValue implements flag.Value.
+type boolValue struct {
+ p *bool
+}
+
+func (bv boolValue) String() string {
+ if bv.p != nil {
+ return fmt.Sprint(*bv.p)
+ }
+ return fmt.Sprint(false)
+}
+
+func (bv boolValue) Set(s string) error {
+ b, err := strconv.ParseBool(s)
+ if err != nil {
+ return err
+ }
+ *bv.p = b
+ return nil
+}
+
+func (bv boolValue) IsBoolFlag() bool { return true }
+
+type boolFlag interface {
+ flag.Value
+ IsBoolFlag() bool
+}
+
+// textValue implements flag.Value for string types that implement
+// TextUnmarshaler.
+type textValue[T ~string, PT interface {
+ *T
+ encoding.TextUnmarshaler
+}] struct {
+ p PT
+}
+
+func newTextValue[T ~string, PT interface {
+ *T
+ encoding.TextUnmarshaler
+}](p PT) textValue[T, PT] {
+ return textValue[T, PT]{p}
+}
+
+func (tv textValue[T, PT]) String() string {
+ if tv.p != nil {
+ return string(*(*T)(tv.p))
+ }
+ return ""
+}
+
+func (tv textValue[T, PT]) Set(s string) error {
+ return tv.p.UnmarshalText([]byte(s))
+}
+
+type urlValue struct {
+ *url.URL
+}
+
+func (v urlValue) String() string {
+ if v.URL != nil {
+ return v.URL.String()
+ }
+ return ""
+}
+
+func (v urlValue) Set(s string) error {
+ u, err := url.Parse(s)
+ if err != nil {
+ return err
+ }
+ *v.URL = *u
+ return nil
+}
+
+func mutexFlags(fs *flag.FlagSet, a, b Flag) {
+ if a.IsSet() && b.IsSet() {
+ lg.Printf("lulu: %s: -%s is incompatible with -%s\n", fs.Name(), a.name, b.name)
+ fs.Usage()
+ os.Exit(1)
+ }
+}
+
+func errMissingFlag(fs *flag.FlagSet, f Flag) {
+ lg.Printf("lulu %s: missing -%s flag\n", fs.Name(), f.name)
+ fs.Usage()
+ os.Exit(1)
+}
+
+func firstWord(s string) string {
+ if i := strings.IndexFunc(s, func(r rune) bool {
+ return unicode.IsSpace(r) || unicode.IsPunct(r)
+ }); i >= 0 {
+ return s[:i]
+ }
+ return s
+}
diff --git a/cmd/lulu/main.go b/cmd/lulu/main.go
new file mode 100644
index 0000000..b664644
--- /dev/null
+++ b/cmd/lulu/main.go
@@ -0,0 +1,109 @@
+package main
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "log"
+ "os"
+ "time"
+
+ "git.samanthony.xyz/lulu"
+)
+
+const defaultTimeout = 15 * time.Second
+
+var lg = log.New(os.Stderr, "", 0)
+
+type command struct {
+ longhand, shorthand string
+ f func(clnt *lulu.Client, args []string)
+ usage string
+}
+
+var commands = []command{
+ {
+ "validate-interior", "vi",
+ validateInterior,
+ "Validate an interior file",
+ }, {
+ "validate-cover", "vc",
+ validateCover,
+ "Validate a cover file",
+ },
+}
+
+var (
+ sandbox = false
+ ask = true
+)
+
+func usage() {
+ progName := os.Args[0]
+ out := flag.CommandLine.Output()
+ fmt.Fprintf(out, "Usage of %s:\n", progName)
+ fmt.Fprintf(out, "%s [global flags] [command] [command flags]\n", progName)
+ fmt.Fprintf(out, "Global Flags:\n")
+ flag.PrintDefaults()
+ fmt.Fprintf(out, "Commands:\n")
+ for _, cmd := range commands {
+ fmt.Fprintf(out, " %s / %s\n", cmd.longhand, cmd.shorthand)
+ fmt.Fprintf(out, " \t%s\n", cmd.usage)
+ }
+}
+
+func main() {
+ flag.Usage = usage
+ flag.BoolVar(&sandbox, "s", false, "Use the sandbox API environment")
+ flagBoolFunc("y", "Do not ask for confirmation", func() { ask = false })
+ flag.BoolVar(&lulu.Debug, "d", false, "Print debug info to stdout")
+ flag.Parse()
+
+ if sandbox {
+ lg.Println("lulu: using sandbox environment")
+ lulu.Sandbox()
+ keyFile = sandboxKeyFile
+ secretFile = sandboxSecretFile
+ } else {
+ lulu.Production()
+ }
+
+ creds, err := readCreds()
+ if err != nil {
+ lg.Fatalf("%v\nCopy and paste your \"client key\" and \"client secret\" from the API Keys page into %s and %s\n%s\n",
+ err, keyFile, secretFile, lulu.ApiKeyPage())
+ }
+
+ clnt, err := lulu.NewClient(context.Background(), creds.key, creds.secret)
+ if err != nil {
+ lg.Fatal(err)
+ }
+
+ args := os.Args[1+flag.NFlag():] // drop program name and flags
+ if len(args) < 1 {
+ flag.Usage()
+ os.Exit(1)
+ }
+ cmdStr := args[0]
+ subArgs := args[1:]
+ for _, cmd := range commands {
+ switch cmdStr {
+ case cmd.longhand, cmd.shorthand:
+ cmd.f(clnt, subArgs)
+ return
+ }
+ }
+ lg.Printf("lulu: unknown command %q\n", cmdStr)
+ flag.Usage()
+ os.Exit(1)
+}
+
+func flagBoolFunc(name, usage string, f func()) {
+ flag.BoolFunc(name, usage, func(s string) error {
+ if s != "" {
+ return fmt.Errorf("no value allowed")
+ }
+ f()
+ return nil
+ })
+}
diff --git a/cmd/lulu/mfg.go b/cmd/lulu/mfg.go
new file mode 100644
index 0000000..c94c42a
--- /dev/null
+++ b/cmd/lulu/mfg.go
@@ -0,0 +1,74 @@
+package main
+
+import (
+ "flag"
+ "fmt"
+ "strings"
+
+ "git.samanthony.xyz/lulu"
+)
+
+// mfgFlags is a set of flags that specify a PkgId.
+type mfgFlagSet struct {
+ size lulu.TrimSize
+ color lulu.ColorType
+ quality lulu.Quality
+ binding lulu.Binding
+ paper lulu.Paper
+ finish lulu.Finish
+ linen lulu.Linen
+ foil lulu.Foil
+
+ flags []Flag
+}
+
+func newMfgFlagSet() *mfgFlagSet {
+ mfg := new(mfgFlagSet)
+ mfg.flags = []Flag{
+ {newTextValue(&mfg.size), "s", fmt.Sprintf("Trim size %v", lulu.TrimSizeValues())},
+ {newTextValue(&mfg.color), "c", fmt.Sprintf("Color mode %v", lulu.ColorTypeValues())},
+ {newTextValue(&mfg.quality), "q", fmt.Sprintf("Quality %v", lulu.QualityValues())},
+ {newTextValue(&mfg.binding), "b", fmt.Sprintf("Binding %v", lulu.BindingValues())},
+ {newTextValue(&mfg.paper), "p", fmt.Sprintf("Paper %v", lulu.PaperValues())},
+ {newTextValue(&mfg.finish), "f", fmt.Sprintf("Cover finish %v", lulu.FinishValues())},
+ {newTextValue(&mfg.linen), "l", fmt.Sprintf("Linen wrap color %v", lulu.LinenValues())},
+ {newTextValue(&mfg.foil), "o", fmt.Sprintf("Foil color %v", lulu.FoilValues())},
+ }
+ return mfg
+}
+
+func (m mfgFlagSet) PkgId() lulu.PkgId {
+ return lulu.PkgId{
+ m.size,
+ m.color,
+ m.quality,
+ m.binding,
+ m.paper,
+ m.finish,
+ m.linen,
+ m.foil,
+ }
+}
+
+func (m mfgFlagSet) Synopsis() string {
+ s := new(strings.Builder)
+ for i, f := range m.flags {
+ if i != 0 {
+ fmt.Fprint(s, " ")
+ }
+ fmt.Fprint(s, f.Synopsis())
+ }
+ return s.String()
+}
+
+func (m mfgFlagSet) AddTo(fs *flag.FlagSet) {
+ for _, f := range m.flags {
+ f.AddTo(fs)
+ }
+}
+
+func (m mfgFlagSet) MustBeSet(fs *flag.FlagSet) {
+ for _, f := range m.flags {
+ f.MustBeSet(fs)
+ }
+}
diff --git a/cmd/lulu/validate_cover.go b/cmd/lulu/validate_cover.go
new file mode 100644
index 0000000..25afbfd
--- /dev/null
+++ b/cmd/lulu/validate_cover.go
@@ -0,0 +1,9 @@
+package main
+
+import (
+ "git.samanthony.xyz/lulu"
+)
+
+func validateCover(clnt *lulu.Client, args []string) {
+ lg.Fatal("not implemented") // TODO
+}
diff --git a/cmd/lulu/validate_interior.go b/cmd/lulu/validate_interior.go
new file mode 100644
index 0000000..f7c4bdb
--- /dev/null
+++ b/cmd/lulu/validate_interior.go
@@ -0,0 +1,59 @@
+package main
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "net/url"
+ "time"
+
+ "git.samanthony.xyz/lulu"
+)
+
+func validateInterior(clnt *lulu.Client, args []string) {
+ var (
+ basic bool
+ url url.URL
+ timeout time.Duration
+ )
+ basicFlag := Flag{boolValue{&basic}, "basic", "Basic validation without pod_package_id manufacturing options"}
+ urlFlag := Flag{urlValue{&url}, "url", "URL of interior file for Lulu to download"}
+ mfgFlags := newMfgFlagSet()
+ fs := flag.NewFlagSet("validate-interior", flag.ExitOnError)
+ basicFlag.AddTo(fs)
+ urlFlag.AddTo(fs)
+ mfgFlags.AddTo(fs)
+ fs.DurationVar(&timeout, "t", defaultTimeout, "Timeout")
+ fs.Usage = func() {
+ out := fs.Output()
+ name := "validate-interior"
+ fmt.Fprintf(out, "Usage of %s:\n", name)
+ fmt.Fprintf(out, " %s %s %s [flags]\n", name, urlFlag.Synopsis(), mfgFlags.Synopsis())
+ fmt.Fprintf(out, " %s %s %s [flags]\n", name, basicFlag.Synopsis(), urlFlag.Synopsis())
+ fmt.Fprintf(out, "Flags:\n")
+ fs.PrintDefaults()
+ }
+ if err := fs.Parse(args); err != nil {
+ lg.Fatalf("lulu: %v", err)
+ }
+
+ var val lulu.InteriorValidation
+ var err error
+ ctx, cancel := context.WithTimeout(clnt.Context(), timeout)
+ defer cancel()
+ if basic {
+ urlFlag.MustBeSet(fs)
+ for _, f := range mfgFlags.flags {
+ mutexFlags(fs, f, basicFlag)
+ }
+ val, err = clnt.ValidateInteriorBasic(ctx, url.String())
+ } else {
+ urlFlag.MustBeSet(fs)
+ mfgFlags.MustBeSet(fs)
+ val, err = clnt.ValidateInterior(ctx, url.String(), mfgFlags.PkgId())
+ }
+ if err != nil {
+ lg.Fatal(err)
+ }
+ fmt.Println(val) // TODO: output format?
+}
diff --git a/go.mod b/go.mod
index 5f4d74d..9448402 100644
--- a/go.mod
+++ b/go.mod
@@ -10,12 +10,14 @@ require (
)
require (
+ github.com/adrg/xdg v0.5.3 // indirect
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/yawnak/string-enumer v0.0.0-20250330104602-f50db3525c45 // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/sync v0.20.0 // indirect
+ golang.org/x/sys v0.43.0 // indirect
golang.org/x/tools v0.44.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/go.sum b/go.sum
index e8912ba..d6274a7 100644
--- a/go.sum
+++ b/go.sum
@@ -1,3 +1,5 @@
+github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
+github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
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=
@@ -16,6 +18,8 @@ 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.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
+golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
diff --git a/lulu.go b/lulu.go
index a4e12cb..b6f5942 100644
--- a/lulu.go
+++ b/lulu.go
@@ -54,7 +54,8 @@ func ApiKeyPage() string {
}
type Client struct {
- c *http.Client
+ ctx context.Context
+ c *http.Client
}
// NewClient returns a client that will use the given client-key and
@@ -70,9 +71,12 @@ func NewClient(ctx context.Context, key, secret string) (*Client, error) {
ClientSecret: secret,
TokenURL: tokenUrl,
}
- return &Client{cfg.Client(ctx)}, nil
+ return &Client{ctx, cfg.Client(ctx)}, nil
}
+// Context returns the client's context.
+func (c *Client) Context() context.Context { return c.ctx }
+
// 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