Skip to content

Commit d047917

Browse files
committed
feat(cli): add WithHTTPClient injection, separate cli module
- export DebugTransport with lazy Enabled *bool for embedding - add WithHTTPClient option, remove WithDebug from client - move --debug flag from cli package to standalone main.go - create separate cli/go.mod to prevent cobra leaking into SDK - update cmd/rfms/go.mod to depend on cli submodule - document embedding pattern in AGENTS.md
1 parent db5743b commit d047917

File tree

12 files changed

+141
-61
lines changed

12 files changed

+141
-61
lines changed

AGENTS.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,41 @@ The rFMS standard is implemented by multiple OEMs, each with their own auth:
3737

3838
The `Credentials` struct stores the active provider alongside the provider-specific fields.
3939

40+
### Embedding in a Parent CLI
41+
42+
The CLI can be embedded as a subcommand in a larger tool (e.g. a unified `way` CLI). Key design rules:
43+
44+
- **Never use `cmd.Root()`** — resolves to the parent CLI's root when embedded, breaking flag lookups. Use `cmd.Flags()` instead (works for both persistent and local flags).
45+
- **`WithHTTPClient`** — the parent injects an `*http.Client` via `cli.WithHTTPClient()`. The SDK layers (auth, retry) stack on top of the injected client's transport.
46+
- **`DebugTransport`** — exported in `debug.go` with a lazy `Enabled *bool` field. The parent owns the `--debug` flag and points `Enabled` at the flag variable. The transport checks the pointer at request time, solving the chicken-and-egg problem (transport constructed before flag parsing).
47+
48+
```go
49+
var debug bool
50+
cmd := cli.NewCommand(
51+
cli.WithCredentialStore(store),
52+
cli.WithTokenStore(tokenStore),
53+
cli.WithHTTPClient(&http.Client{
54+
Transport: &rfms.DebugTransport{
55+
Enabled: &debug,
56+
Next: http.DefaultTransport,
57+
},
58+
}),
59+
)
60+
cmd.PersistentFlags().BoolVar(&debug, "debug", false, "Enable debug logging")
61+
```
62+
63+
### Module Structure
64+
65+
Three separate Go modules prevent Cobra/CLI dependencies from leaking into the SDK library:
66+
67+
```
68+
go.mod # SDK client library (no cobra, no CLI deps)
69+
cli/go.mod # CLI commands (depends on root SDK + cobra)
70+
cmd/rfms/go.mod # Standalone binary (depends on cli module)
71+
```
72+
73+
Consumers who only need the Go client import the root module without pulling in CLI dependencies.
74+
4075
### Conventions
4176

4277
- Subcommands are organized by entity using `cobra.Group`

auth_scania.go

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,9 @@ type ScaniaAuthConfig struct {
3737
// Timeout is the timeout for a request.
3838
Timeout time.Duration
3939

40-
// Debug is whether to enable debug logging.
41-
Debug bool
40+
// HTTPClient is the base HTTP client whose transport is used as the
41+
// innermost layer. When nil, [http.DefaultTransport] is used.
42+
HTTPClient *http.Client
4243
}
4344

4445
// TokenSource returns an oauth2.TokenSource that retrieves tokens using
@@ -169,10 +170,8 @@ func (s *scaniaTokenSource) newRequest(method, path string, body io.Reader) (*ht
169170
// httpClient returns the HTTP client to use, with retry transport if none specified.
170171
func (s *scaniaTokenSource) httpClient() *http.Client {
171172
transport := http.DefaultTransport
172-
if s.config.Debug {
173-
transport = &debugTransport{
174-
next: transport,
175-
}
173+
if s.config.HTTPClient != nil && s.config.HTTPClient.Transport != nil {
174+
transport = s.config.HTTPClient.Transport
176175
}
177176
if s.config.MaxRetries > 0 {
178177
transport = &retryTransport{

cli/cli.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package cli
33
import (
44
"encoding/json"
55
"fmt"
6+
"net/http"
67
"os"
78
"path/filepath"
89
)
@@ -32,6 +33,7 @@ type Option func(*config)
3233
type config struct {
3334
credentialStore Store
3435
tokenStore Store
36+
httpClient *http.Client
3537
}
3638

3739
// WithCredentialStore sets the credential store.
@@ -44,6 +46,11 @@ func WithTokenStore(s Store) Option {
4446
return func(c *config) { c.tokenStore = s }
4547
}
4648

49+
// WithHTTPClient sets the base [http.Client] passed to the SDK client.
50+
func WithHTTPClient(httpClient *http.Client) Option {
51+
return func(c *config) { c.httpClient = httpClient }
52+
}
53+
4754
// FileStore is a JSON file-backed store.
4855
type FileStore struct {
4956
path string

cli/command.go

Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import (
55
"errors"
66
"fmt"
77
"io/fs"
8-
"log/slog"
98
"os"
109
"time"
1110

@@ -26,17 +25,6 @@ func NewCommand(opts ...Option) *cobra.Command {
2625
Use: "rfms",
2726
Short: "rFMS CLI",
2827
}
29-
cmd.PersistentFlags().Bool("debug", false, "Enable debug logging")
30-
cmd.PersistentPreRunE = func(cmd *cobra.Command, _ []string) error {
31-
level := slog.LevelInfo
32-
if cmd.Root().PersistentFlags().Changed("debug") {
33-
level = slog.LevelDebug
34-
}
35-
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
36-
Level: level,
37-
})))
38-
return nil
39-
}
4028
cmd.AddGroup(&cobra.Group{ID: "rfms", Title: "rFMS Commands"})
4129
cmd.AddCommand(newVehiclesCommand(&cfg))
4230
cmd.AddCommand(newVehiclePositionsCommand(&cfg))
@@ -352,8 +340,7 @@ func newVehicleStatusesCommand(cfg *config) *cobra.Command {
352340
return cmd
353341
}
354342

355-
func newClient(cmd *cobra.Command, cfg *config) (*rfms.Client, error) {
356-
debug, _ := cmd.Root().PersistentFlags().GetBool("debug")
343+
func newClient(_ *cobra.Command, cfg *config) (*rfms.Client, error) {
357344
var creds Credentials
358345
if cfg.credentialStore != nil {
359346
if err := cfg.credentialStore.Read(&creds); err != nil {
@@ -363,6 +350,10 @@ func newClient(cmd *cobra.Command, cfg *config) (*rfms.Client, error) {
363350
return nil, fmt.Errorf("read credentials: %w", err)
364351
}
365352
}
353+
var opts []rfms.ClientOption
354+
if cfg.httpClient != nil {
355+
opts = append(opts, rfms.WithHTTPClient(cfg.httpClient))
356+
}
366357
switch creds.Provider {
367358
case rfms.BrandScania:
368359
var token oauth2.Token
@@ -381,17 +372,17 @@ func newClient(cmd *cobra.Command, cfg *config) (*rfms.Client, error) {
381372
"session expired, please login again using `rfms auth login scania`",
382373
)
383374
}
384-
return rfms.NewClient(
385-
rfms.WithDebug(debug),
375+
opts = append(opts,
386376
rfms.WithBaseURL(rfms.ScaniaBaseURL),
387377
rfms.WithVersion(rfms.V4),
388378
rfms.WithTokenSource(oauth2.StaticTokenSource(&token)),
389379
)
380+
return rfms.NewClient(opts...)
390381
case rfms.BrandVolvoTrucks:
391-
return rfms.NewClient(
392-
rfms.WithDebug(debug),
382+
opts = append(opts,
393383
rfms.WithVolvoTrucks(creds.Username, creds.Password),
394384
)
385+
return rfms.NewClient(opts...)
395386
default:
396387
return nil, fmt.Errorf("unknown provider: %s", creds.Provider)
397388
}

cli/go.mod

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
module github.com/way-platform/rfms-go/cli
2+
3+
go 1.25.0
4+
5+
toolchain go1.26.0
6+
7+
require (
8+
github.com/spf13/cobra v1.10.2
9+
github.com/way-platform/rfms-go v0.14.0
10+
golang.org/x/oauth2 v0.31.0
11+
golang.org/x/term v0.41.0
12+
google.golang.org/protobuf v1.36.6
13+
)
14+
15+
require (
16+
github.com/inconshreveable/mousetrap v1.1.0 // indirect
17+
github.com/spf13/pflag v1.0.9 // indirect
18+
golang.org/x/sys v0.42.0 // indirect
19+
)
20+
21+
replace github.com/way-platform/rfms-go => ../

cli/go.sum

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
2+
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
3+
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
4+
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
5+
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
6+
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
7+
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
8+
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
9+
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
10+
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
11+
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
12+
golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo=
13+
golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
14+
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
15+
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
16+
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
17+
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
18+
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
19+
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
20+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

client.go

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ type ClientConfig struct {
2020
baseURL string
2121
apiVersion Version
2222
retryCount int
23-
debug bool
23+
httpClient *http.Client
2424
timeout time.Duration
2525
tokenSource oauth2.TokenSource
2626
username string
@@ -67,12 +67,10 @@ func NewClient(opts ...ClientOption) (*Client, error) {
6767

6868
// httpClient creates a new HTTP client with the given configuration.
6969
func (c *Client) httpClient(cfg ClientConfig) *http.Client {
70+
// Use injected client's transport as base, or fall back to default.
7071
transport := http.DefaultTransport
71-
// Add debug transport if debug is enabled.
72-
if cfg.debug {
73-
transport = &debugTransport{
74-
next: transport,
75-
}
72+
if cfg.httpClient != nil && cfg.httpClient.Transport != nil {
73+
transport = cfg.httpClient.Transport
7674
}
7775
// Add basic auth transport if username/password are configured
7876
if cfg.username != "" && cfg.password != "" {
@@ -109,10 +107,12 @@ func (c *Client) httpClient(cfg ClientConfig) *http.Client {
109107
}
110108
}
111109

112-
// WithDebug sets the debug flag for the [Client].
113-
func WithDebug(debug bool) ClientOption {
110+
// WithHTTPClient sets the base [http.Client] whose transport is used as the
111+
// innermost layer of the transport chain. This is useful for injecting a
112+
// [DebugTransport] or custom TLS configuration.
113+
func WithHTTPClient(httpClient *http.Client) ClientOption {
114114
return func(cc *ClientConfig) {
115-
cc.debug = debug
115+
cc.httpClient = httpClient
116116
}
117117
}
118118

@@ -168,7 +168,7 @@ func WithScania(clientID, clientSecret string) ClientOption {
168168
WithTokenSource(ScaniaAuthConfig{
169169
ClientID: clientID,
170170
ClientSecret: clientSecret,
171-
Debug: cc.debug,
171+
HTTPClient: cc.httpClient,
172172
MaxRetries: cc.retryCount,
173173
Timeout: cc.timeout,
174174
}.TokenSource(context.Background())),

cmd/rfms/go.mod

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ require (
99
charm.land/lipgloss/v2 v2.0.2
1010
github.com/adrg/xdg v0.5.3
1111
github.com/way-platform/rfms-go v0.14.0
12+
github.com/way-platform/rfms-go/cli v0.0.0
1213
)
1314

1415
require (
@@ -42,4 +43,7 @@ require (
4243
google.golang.org/protobuf v1.36.9 // indirect
4344
)
4445

45-
replace github.com/way-platform/rfms-go => ../../
46+
replace (
47+
github.com/way-platform/rfms-go => ../../
48+
github.com/way-platform/rfms-go/cli => ../../cli/
49+
)

cmd/rfms/main.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,27 @@ package main
33
import (
44
"context"
55
"image/color"
6+
"net/http"
67
"os"
78

89
"charm.land/fang/v2"
910
"charm.land/lipgloss/v2"
1011
"github.com/adrg/xdg"
12+
rfms "github.com/way-platform/rfms-go"
1113
"github.com/way-platform/rfms-go/cli"
1214
)
1315

1416
func main() {
1517
credPath, _ := xdg.ConfigFile("rfms-go/credentials.json")
1618
tokenPath, _ := xdg.ConfigFile("rfms-go/token.json")
19+
var debug bool
20+
debugTransport := &rfms.DebugTransport{Enabled: &debug}
1721
cmd := cli.NewCommand(
1822
cli.WithCredentialStore(cli.NewFileStore(credPath)),
1923
cli.WithTokenStore(cli.NewFileStore(tokenPath)),
24+
cli.WithHTTPClient(&http.Client{Transport: debugTransport}),
2025
)
26+
cmd.PersistentFlags().BoolVar(&debug, "debug", false, "Enable debug logging of HTTP requests")
2127
if err := fang.Execute(
2228
context.Background(),
2329
cmd,

debug.go

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,35 @@ import (
99
"os"
1010
)
1111

12-
type debugTransport struct {
13-
next http.RoundTripper
12+
// DebugTransport is an [http.RoundTripper] that dumps HTTP requests and
13+
// responses to stderr. When Enabled is non-nil, logging is gated on the
14+
// pointed-to bool, allowing a CLI flag to be bound before the value is known.
15+
type DebugTransport struct {
16+
// Enabled gates debug output. When nil, output is always enabled.
17+
Enabled *bool
18+
// Next is the underlying transport. Defaults to [http.DefaultTransport].
19+
Next http.RoundTripper
1420
}
1521

16-
func (t *debugTransport) RoundTrip(request *http.Request) (*http.Response, error) {
22+
var _ http.RoundTripper = (*DebugTransport)(nil)
23+
24+
func (t *DebugTransport) next() http.RoundTripper {
25+
if t.Next != nil {
26+
return t.Next
27+
}
28+
return http.DefaultTransport
29+
}
30+
31+
func (t *DebugTransport) RoundTrip(request *http.Request) (*http.Response, error) {
32+
if t.Enabled != nil && !*t.Enabled {
33+
return t.next().RoundTrip(request)
34+
}
1735
requestDump, err := httputil.DumpRequestOut(request, true)
1836
if err != nil {
1937
return nil, fmt.Errorf("failed to dump request for debug: %w", err)
2038
}
2139
prettyPrintDump(os.Stderr, requestDump, "> ")
22-
response, err := t.next.RoundTrip(request)
40+
response, err := t.next().RoundTrip(request)
2341
if err != nil {
2442
return nil, err
2543
}

0 commit comments

Comments
 (0)