Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,6 @@ formatters:
- gofumpt
- goimports
- golines
settings:
golines:
max-len: 120
21 changes: 14 additions & 7 deletions cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,20 @@ type Store interface {
type Option func(*config)

type config struct {
credentialStore Store
tokenStore Store
httpClient *http.Client
fleetCredentialStore Store
vehicleSpecCredentialStore Store
tokenStore Store
httpClient *http.Client
}

// WithCredentialStore sets the credential store.
func WithCredentialStore(s Store) Option {
return func(c *config) { c.credentialStore = s }
// WithFleetCredentialStore sets the credential store for Mercedes-Benz Fleet API (OAuth2 + Kafka).
func WithFleetCredentialStore(s Store) Option {
return func(c *config) { c.fleetCredentialStore = s }
}

// WithVehicleSpecCredentialStore sets the credential store for Mercedes-Benz Vehicle Specification API.
func WithVehicleSpecCredentialStore(s Store) Option {
return func(c *config) { c.vehicleSpecCredentialStore = s }
}

// WithTokenStore sets the token store.
Expand All @@ -42,7 +48,8 @@ func WithHTTPClient(httpClient *http.Client) Option {
return func(c *config) { c.httpClient = httpClient }
}

// FileStore is a JSON file-backed store.
// FileStore is a file-backed store that uses protojson for proto messages
// and encoding/json for other types.
type FileStore struct {
path string
}
Expand Down
191 changes: 115 additions & 76 deletions cli/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ import (
"github.com/twmb/franz-go/pkg/sasl/oauth"
"github.com/way-platform/mbz-go"
"github.com/way-platform/mbz-go/api/vehiclesv1"
mbzpb "github.com/way-platform/mbz-go/proto/gen/go/wayplatform/connect/mbz/v1"
fleetcreds "github.com/way-platform/mbz-go/proto/gen/go/wayplatform/connect/mercedesbenz/fleet/v1"
vspeccreds "github.com/way-platform/mbz-go/proto/gen/go/wayplatform/connect/mercedesbenz/vehiclespec/v1"
"golang.org/x/oauth2"
"golang.org/x/term"
"google.golang.org/protobuf/encoding/protojson"
Expand Down Expand Up @@ -79,28 +80,27 @@ func newLoginCommand(cfg *config) *cobra.Command {
Use: "login",
Short: "Login to the Mercedes-Benz API",
}
apiKey := cmd.Flags().String("api-key", "", "API key for authentication")
region := cmd.Flags().
String("region", "", "region for authentication (OAuth2 only)")
clientID := cmd.Flags().
String("client-id", "", "client ID for authentication (OAuth2 only)")
clientSecret := cmd.Flags().
String("client-secret", "", "client secret for authentication (OAuth2 only)")
cmd.AddCommand(newLoginFleetCommand(cfg))
cmd.AddCommand(newLoginVehicleSpecCommand(cfg))
return cmd
}

func newLoginFleetCommand(cfg *config) *cobra.Command {
cmd := &cobra.Command{
Use: "fleet",
Short: "Login to the Mercedes-Benz Fleet API (OAuth2)",
}
region := cmd.Flags().String("region", "", "Region (e.g. ECE, AMAP/NA)")
clientID := cmd.Flags().String("client-id", "", "OAuth2 client ID")
clientSecret := cmd.Flags().String("client-secret", "", "OAuth2 client secret")
cmd.RunE = func(cmd *cobra.Command, _ []string) error {
// Try loading stored credentials first.
creds := &mbzpb.Credentials{}
if cfg.credentialStore != nil {
if err := cfg.credentialStore.Read(
creds,
); err != nil &&
!errors.Is(err, fs.ErrNotExist) {
creds := &fleetcreds.Credentials{}
if cfg.fleetCredentialStore != nil {
if err := cfg.fleetCredentialStore.Read(creds); err != nil && !errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("read credentials: %w", err)
}
}
// Override with flags.
if *apiKey != "" {
creds.SetApiKey(*apiKey)
}
if *region != "" {
creds.SetRegion(*region)
}
Expand All @@ -114,57 +114,80 @@ func newLoginCommand(cfg *config) *cobra.Command {
if creds.GetRegion() == "" {
creds.SetRegion(string(mbz.RegionECE))
}
// Prompt for API key if not provided.
if shouldPromptAPIKey(creds) {
val, err := promptSecret(cmd, "Enter API key (leave empty to skip): ")
if err != nil {
return err
}
creds.SetApiKey(val)
}
// Prompt for OAuth2 credentials if no API key was provided.
if creds.GetClientId() == "" && creds.GetApiKey() == "" {
// Prompt for missing fields.
if creds.GetClientId() == "" {
val, err := promptSecret(cmd, "Enter OAuth2 client ID: ")
if err != nil {
return err
}
creds.SetClientId(val)
}
if creds.GetClientSecret() == "" && creds.GetApiKey() == "" {
if creds.GetClientSecret() == "" {
val, err := promptSecret(cmd, "Enter OAuth2 client secret: ")
if err != nil {
return err
}
creds.SetClientSecret(val)
}
// Persist credentials.
if cfg.credentialStore != nil {
if err := cfg.credentialStore.Write(creds); err != nil {
if cfg.fleetCredentialStore != nil {
if err := cfg.fleetCredentialStore.Write(creds); err != nil {
return fmt.Errorf("write credentials: %w", err)
}
}
// Run OAuth2 flow if client credentials are provided.
if creds.GetClientId() != "" && creds.GetClientSecret() != "" {
oauth2Config, err := mbz.NewOAuth2Config(
mbz.Region(creds.GetRegion()),
creds.GetClientId(),
creds.GetClientSecret(),
)
if err != nil {
return err
// Run OAuth2 flow.
oauth2Config, err := mbz.NewOAuth2Config(
mbz.Region(creds.GetRegion()),
creds.GetClientId(),
creds.GetClientSecret(),
)
if err != nil {
return err
}
token, err := oauth2Config.Token(cmd.Context())
if err != nil {
return err
}
if cfg.tokenStore != nil {
if err := cfg.tokenStore.Write(token); err != nil {
return fmt.Errorf("write token: %w", err)
}
token, err := oauth2Config.Token(cmd.Context())
}
cmd.Printf("Logged in to %s.\n", creds.GetRegion())
return nil
}
return cmd
}

func newLoginVehicleSpecCommand(cfg *config) *cobra.Command {
cmd := &cobra.Command{
Use: "vehicle-spec",
Short: "Login to the Mercedes-Benz Vehicle Specification API (API key)",
}
apiKey := cmd.Flags().String("api-key", "", "API key")
cmd.RunE = func(cmd *cobra.Command, _ []string) error {
creds := &vspeccreds.Credentials{}
if cfg.vehicleSpecCredentialStore != nil {
if err := cfg.vehicleSpecCredentialStore.Read(creds); err != nil && !errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("read credentials: %w", err)
}
}
if *apiKey != "" {
creds.SetApiKey(*apiKey)
}
if creds.GetApiKey() == "" {
val, err := promptSecret(cmd, "Enter API key: ")
if err != nil {
return err
}
// Cache token.
if cfg.tokenStore != nil {
if err := cfg.tokenStore.Write(token); err != nil {
return fmt.Errorf("write token: %w", err)
}
creds.SetApiKey(val)
}
if cfg.vehicleSpecCredentialStore != nil {
if err := cfg.vehicleSpecCredentialStore.Write(creds); err != nil {
return fmt.Errorf("write credentials: %w", err)
}
}
cmd.Printf("Logged in to %s.\n", creds.GetRegion())
cmd.Println("Logged in to Vehicle Specification API.")
return nil
}
return cmd
Expand All @@ -180,9 +203,14 @@ func newLogoutCommand(cfg *config) *cobra.Command {
return fmt.Errorf("clear token: %w", err)
}
}
if cfg.credentialStore != nil {
if err := cfg.credentialStore.Clear(); err != nil {
return fmt.Errorf("clear credentials: %w", err)
if cfg.fleetCredentialStore != nil {
if err := cfg.fleetCredentialStore.Clear(); err != nil {
return fmt.Errorf("clear fleet credentials: %w", err)
}
}
if cfg.vehicleSpecCredentialStore != nil {
if err := cfg.vehicleSpecCredentialStore.Clear(); err != nil {
return fmt.Errorf("clear vehicle-spec credentials: %w", err)
}
}
cmd.Println("Logged out.")
Expand Down Expand Up @@ -578,18 +606,16 @@ func newConsumeVehicleSignalsCommand(cfg *config) *cobra.Command {
Short: "Consume vehicle signals from Kafka",
GroupID: "kafka",
}
topic := cmd.Flags().String("topic", "", "Topic")
_ = cmd.MarkFlagRequired("topic")
consumerGroup := cmd.Flags().String("consumer-group", "", "Consumer group")
_ = cmd.MarkFlagRequired("consumer-group")
topicFlag := cmd.Flags().String("topic", "", "Topic (overrides credential value)")
consumerGroupFlag := cmd.Flags().String("consumer-group", "", "Consumer group (overrides credential value)")
enableDebug := cmd.Flags().Bool("debug", false, "Enable debug logging")
format := cmd.Flags().String("format", "json", "Format to use for output")
cmd.RunE = func(cmd *cobra.Command, _ []string) error {
creds := &mbzpb.Credentials{}
if cfg.credentialStore != nil {
if err := cfg.credentialStore.Read(creds); err != nil {
creds := &fleetcreds.Credentials{}
if cfg.fleetCredentialStore != nil {
if err := cfg.fleetCredentialStore.Read(creds); err != nil {
if errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("no credentials found, please login using `mbz auth login`")
return fmt.Errorf("no credentials found, please login using `mbz auth login fleet`")
}
return fmt.Errorf("read credentials: %w", err)
}
Expand All @@ -598,7 +624,7 @@ func newConsumeVehicleSignalsCommand(cfg *config) *cobra.Command {
if cfg.tokenStore != nil {
if err := cfg.tokenStore.Read(&token); err != nil {
if errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("no credentials found, please login using `mbz auth login`")
return fmt.Errorf("no credentials found, please login using `mbz auth login fleet`")
}
return fmt.Errorf("read token: %w", err)
}
Expand All @@ -607,6 +633,23 @@ func newConsumeVehicleSignalsCommand(cfg *config) *cobra.Command {
if err != nil {
return err
}
// Resolve topic and consumer group from credentials, with flag overrides.
topic := creds.GetKafkaInputTopic()
if *topicFlag != "" {
topic = *topicFlag
}
if topic == "" {
return fmt.Errorf("topic is required: set via --topic or store in fleet credentials")
}
consumerGroup := creds.GetKafkaConsumerGroup()
if *consumerGroupFlag != "" {
consumerGroup = *consumerGroupFlag
}
if consumerGroup == "" {
return fmt.Errorf(
"consumer group is required: set via --consumer-group or store in fleet credentials",
)
}
var bootstrapServer string
switch region {
case mbz.RegionECE:
Expand All @@ -619,8 +662,8 @@ func newConsumeVehicleSignalsCommand(cfg *config) *cobra.Command {
opts := []kgo.Opt{
kgo.DialTLS(),
kgo.SeedBrokers(bootstrapServer),
kgo.ConsumerGroup(*consumerGroup),
kgo.ConsumeTopics(*topic),
kgo.ConsumerGroup(consumerGroup),
kgo.ConsumeTopics(topic),
kgo.SASL(oauth.Oauth(func(_ context.Context) (oauth.Auth, error) {
return oauth.Auth{
Token: token.AccessToken,
Expand Down Expand Up @@ -687,23 +730,23 @@ func newConsumeVehicleSignalsCommand(cfg *config) *cobra.Command {
// Client constructors.

func newOAuth2Client(cmd *cobra.Command, cfg *config) (*mbz.Client, error) {
creds := &mbzpb.Credentials{}
if cfg.credentialStore != nil {
if err := cfg.credentialStore.Read(creds); err != nil && !errors.Is(err, fs.ErrNotExist) {
creds := &fleetcreds.Credentials{}
if cfg.fleetCredentialStore != nil {
if err := cfg.fleetCredentialStore.Read(creds); err != nil && !errors.Is(err, fs.ErrNotExist) {
return nil, fmt.Errorf("read credentials: %w", err)
}
}
var token oauth2.Token
if cfg.tokenStore != nil {
if err := cfg.tokenStore.Read(&token); err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil, fmt.Errorf("no credentials found, please login using `mbz auth login`")
return nil, fmt.Errorf("no credentials found, please login using `mbz auth login fleet`")
}
return nil, fmt.Errorf("read token: %w", err)
}
}
if token.Expiry.Before(time.Now()) {
return nil, fmt.Errorf("invalid token, please login using `mbz auth login`")
return nil, fmt.Errorf("invalid token, please login using `mbz auth login fleet`")
}
region, err := resolveOAuth2Region(creds, token)
if err != nil {
Expand All @@ -720,20 +763,20 @@ func newOAuth2Client(cmd *cobra.Command, cfg *config) (*mbz.Client, error) {
}

func newClientWithAPIKey(cmd *cobra.Command, cfg *config) (*mbz.Client, error) {
creds := &mbzpb.Credentials{}
if cfg.credentialStore != nil {
if err := cfg.credentialStore.Read(creds); err != nil {
creds := &vspeccreds.Credentials{}
if cfg.vehicleSpecCredentialStore != nil {
if err := cfg.vehicleSpecCredentialStore.Read(creds); err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil, fmt.Errorf(
"no credentials found, please login using `mbz auth login --api-key <api-key>`",
"no credentials found, please login using `mbz auth login vehicle-spec --api-key <key>`",
)
}
return nil, fmt.Errorf("read credentials: %w", err)
}
}
if creds.GetApiKey() == "" {
return nil, fmt.Errorf(
"no API key found, please login using `mbz auth login --api-key <api-key>`",
"no API key found, please login using `mbz auth login vehicle-spec --api-key <key>`",
)
}
opts := []mbz.ClientOption{
Expand All @@ -757,11 +800,7 @@ func promptSecret(cmd *cobra.Command, prompt string) (string, error) {
return string(input), nil
}

func shouldPromptAPIKey(creds *mbzpb.Credentials) bool {
return creds.GetApiKey() == "" && creds.GetClientId() == "" && creds.GetClientSecret() == ""
}

func resolveOAuth2Region(creds *mbzpb.Credentials, token oauth2.Token) (mbz.Region, error) {
func resolveOAuth2Region(creds *fleetcreds.Credentials, token oauth2.Token) (mbz.Region, error) {
if creds.GetRegion() != "" {
return mbz.Region(creds.GetRegion()), nil
}
Expand Down
Loading
Loading