diff --git a/charts/device-definitions-api/templates/secret.yaml b/charts/device-definitions-api/templates/secret.yaml index 56c1a3ef..13847314 100644 --- a/charts/device-definitions-api/templates/secret.yaml +++ b/charts/device-definitions-api/templates/secret.yaml @@ -95,6 +95,12 @@ spec: - remoteRef: key: {{ .Release.Namespace }}/definitions/carvx/apikey secretKey: CAR_VX_API_KEY + - remoteRef: + key: {{ .Release.Namespace }}/definitions/kaufmann/username + secretKey: ELEVA_USERNAME + - remoteRef: + key: {{ .Release.Namespace }}/definitions/kaufmann/password + secretKey: ELEVA_PASSWORD secretStoreRef: kind: ClusterSecretStore name: aws-secretsmanager-secret-store diff --git a/cmd/device-definitions-api/decode_vin.go b/cmd/device-definitions-api/decode_vin.go index 6ab73832..dd4057ba 100644 --- a/cmd/device-definitions-api/decode_vin.go +++ b/cmd/device-definitions-api/decode_vin.go @@ -37,6 +37,7 @@ type decodeVINCmd struct { fromFile bool persistToDB bool carvx bool + eleva bool } func (*decodeVINCmd) Name() string { return "decodevin" } @@ -44,7 +45,7 @@ func (*decodeVINCmd) Synopsis() string { return "tries decoding a vin with chosen provider - does not insert in our db" } func (*decodeVINCmd) Usage() string { - return `decodevin [-dat|-drivly|-vincario|-japan17vin|carvx|-from-file] ` + return `decodevin [-dat|-drivly|-vincario|-japan17vin|-carvx|-eleva|-from-file] ` } func (p *decodeVINCmd) SetFlags(f *flag.FlagSet) { @@ -53,6 +54,7 @@ func (p *decodeVINCmd) SetFlags(f *flag.FlagSet) { f.BoolVar(&p.vincario, "vincario", false, "use vincario vin decoder") f.BoolVar(&p.japan17vin, "japan17vin", false, "use japan17vin vin decoder") f.BoolVar(&p.carvx, "carvx", false, "use carvx vin decoder") + f.BoolVar(&p.eleva, "eleva", false, "use eleva kaufmann vin decoder") f.BoolVar(&p.fromFile, "from-file", false, "read vin from file in /tmp directory") f.BoolVar(&p.persistToDB, "persist-to-db", false, "persist successful vin decodings to db, table vin_numbers") } @@ -160,6 +162,14 @@ func (p *decodeVINCmd) Execute(ctx context.Context, f *flag.FlagSet, _ ...interf fmt.Println("VIN Info:") fmt.Println(string(jsonBytes)) } + if p.eleva { + vinInfo, _, err = vinDecodingService.GetVIN(ctx, vin, coremodels.ElevaKaufmannProvider, country) + if err != nil { + fmt.Println(err.Error()) + continue + } + fmt.Printf("VIN Response: \n %+v\n", vinInfo) + } fmt.Println() if p.persistToDB { if vinInfo == nil || vinInfo.Model == "" { @@ -250,6 +260,10 @@ func instantiateVINDecodingSvc(ctx context.Context, settings *config.Settings, l vincarioAPI := gateways.NewVincarioAPIService(settings, logger) jp17vinAPI := gateways.NewJapan17VINAPI(logger, settings) carvxAPI := gateways.NewCarVxVINAPI(logger, settings) + elevaAPI := gateways.NewElevaAPI(settings) + if settings.Environment == "local" { + return services.NewVINDecodingService(drivlyAPI, vincarioAPI, nil, logger, nil, datAPI, pdb.DBS, jp17vinAPI, carvxAPI, elevaAPI) + } send, err := createSender(ctx, settings, logger) if err != nil { @@ -267,7 +281,5 @@ func instantiateVINDecodingSvc(ctx context.Context, settings *config.Settings, l } deviceDefinitionOnChainService := gateways.NewDeviceDefinitionOnChainService(settings, logger, ethClient, chainID, send, pdb.DBS) - vinDecodingService := services.NewVINDecodingService(drivlyAPI, vincarioAPI, nil, logger, deviceDefinitionOnChainService, datAPI, pdb.DBS, jp17vinAPI, carvxAPI) - - return vinDecodingService + return services.NewVINDecodingService(drivlyAPI, vincarioAPI, nil, logger, deviceDefinitionOnChainService, datAPI, pdb.DBS, jp17vinAPI, carvxAPI, elevaAPI) } diff --git a/internal/api/api.go b/internal/api/api.go index 24146591..93c65b8d 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -62,6 +62,7 @@ func Run(ctx context.Context, logger zerolog.Logger, settings *config.Settings, autoIsoAPIService := gateways.NewAutoIsoAPIService(settings.AutoIsoAPIUid, settings.AutoIsoAPIKey) japan17VINAPI := gateways.NewJapan17VINAPI(&logger, settings) carvxAPI := gateways.NewCarVxVINAPI(&logger, settings) + elevaAPI := gateways.NewElevaAPI(settings) registryInstance, err := contracts.NewRegistry(settings.EthereumRegistryAddress, ethClient) if err != nil { logger.Fatal().Err(err).Msg("Failed to create registry query instance.") @@ -75,7 +76,7 @@ func Run(ctx context.Context, logger zerolog.Logger, settings *config.Settings, vinRepository := repositories.NewVINRepository(pdb.DBS, registryInstance, identityAPI) //cache services - vincDecodingService := services.NewVINDecodingService(drivlyAPIService, vincarioAPIService, autoIsoAPIService, &logger, ddOnChainService, datGroupWSService, pdb.DBS, japan17VINAPI, carvxAPI) + vincDecodingService := services.NewVINDecodingService(drivlyAPIService, vincarioAPIService, autoIsoAPIService, &logger, ddOnChainService, datGroupWSService, pdb.DBS, japan17VINAPI, carvxAPI, elevaAPI) powerTrainTypeService, err := services.NewPowerTrainTypeService("powertrain_type_rule.yaml", &logger, ddOnChainService) searchService := search.NewTypesenseAPIService(settings, &logger) if err != nil { diff --git a/internal/config/settings.go b/internal/config/settings.go index 16f5af54..5a2baed0 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -55,6 +55,9 @@ type Settings struct { Japan17VINPassword string `yaml:"JAPAN17_VIN_PASSWORD"` CarVxUserID string `yaml:"CAR_VX_USER_ID"` CarVxAPIKey string `yaml:"CAR_VX_API_KEY"` + // used for Kaufmann API + ElevaUsername string `yaml:"ELEVA_USERNAME"` + ElevaPassword string `yaml:"ELEVA_PASSWORD"` } func (s *Settings) IsProd() bool { diff --git a/internal/core/models/vin_decoding_models.go b/internal/core/models/vin_decoding_models.go index 41e67ba2..e0e03b82 100644 --- a/internal/core/models/vin_decoding_models.go +++ b/internal/core/models/vin_decoding_models.go @@ -12,14 +12,15 @@ import ( type DecodeProviderEnum string const ( - DrivlyProvider DecodeProviderEnum = "drivly" - VincarioProvider DecodeProviderEnum = "vincario" - AutoIsoProvider DecodeProviderEnum = "autoiso" - DATGroupProvider DecodeProviderEnum = "dat" - AllProviders DecodeProviderEnum = "" - TeslaProvider DecodeProviderEnum = "tesla" - Japan17VIN DecodeProviderEnum = "japan17vin" - CarVXVIN DecodeProviderEnum = "carvxvin" + DrivlyProvider DecodeProviderEnum = "drivly" + VincarioProvider DecodeProviderEnum = "vincario" + AutoIsoProvider DecodeProviderEnum = "autoiso" + DATGroupProvider DecodeProviderEnum = "dat" + AllProviders DecodeProviderEnum = "" + TeslaProvider DecodeProviderEnum = "tesla" + Japan17VIN DecodeProviderEnum = "japan17vin" + CarVXVIN DecodeProviderEnum = "carvxvin" + ElevaKaufmannProvider DecodeProviderEnum = "eleva" ) type VINDecodingInfoData struct { @@ -444,3 +445,28 @@ type CarVxResponse struct { } `json:"data"` Error string `json:"error"` } + +// nolint +type ElevaVINResponse struct { + Error int `json:"error"` + Message string `json:"message"` + Data struct { + Client struct { + Rut string `json:"rut"` + ClientId int `json:"clientId"` + Name string `json:"name"` + Lastname string `json:"lastname"` + Email string `json:"email"` + Phone string `json:"phone"` + BusinessName string `json:"businessName"` + } `json:"client"` + Vehicle struct { + Plate string `json:"plate"` + Chassis string `json:"chassis"` + Model string `json:"model"` + Kms int `json:"kms"` + Baumuster string `json:"baumuster"` + Brand string `json:"brand"` + } `json:"vehicle"` + } `json:"data"` +} diff --git a/internal/core/services/eleva_resp.json b/internal/core/services/eleva_resp.json new file mode 100644 index 00000000..7983c78e --- /dev/null +++ b/internal/core/services/eleva_resp.json @@ -0,0 +1,23 @@ +{ + "error": 0, + "message": "Success", + "data": { + "client": { + "rut": "12866851-9", + "clientId": 13, + "name": "MACARENA", + "lastname": "RAMOS ALERCE", + "email": "maxhoja@gmail.com", + "phone": "995339163", + "businessName": "MACARENA RAMOS ALERCE" + }, + "vehicle": { + "plate": "SC-SS29", + "chassis": "W1K3F4GB9NN286196", + "model": "A 250", + "kms": 43879, + "baumuster": "177046", + "brand": "MERCEDES-BENZ" + } + } +} \ No newline at end of file diff --git a/internal/core/services/vin_decoding_service.go b/internal/core/services/vin_decoding_service.go index 6cd9e4e3..e736d1f5 100644 --- a/internal/core/services/vin_decoding_service.go +++ b/internal/core/services/vin_decoding_service.go @@ -10,6 +10,7 @@ import ( "strconv" "strings" "time" + "unicode" "github.com/DIMO-Network/shared/pkg/logfields" @@ -37,15 +38,17 @@ type vinDecodingService struct { DATGroupAPIService gateways.DATGroupAPIService japan17VINAPI gateways.Japan17VINAPI carvxAPI gateways.CarVxVINAPI + elevaAPI gateways.ElevaAPI onChainSvc gateways.DeviceDefinitionOnChainService dbs func() *db.ReaderWriter } func NewVINDecodingService(drivlyAPISvc gateways.DrivlyAPIService, vincarioAPISvc gateways.VincarioAPIService, autoIso gateways.AutoIsoAPIService, logger *zerolog.Logger, onChainSvc gateways.DeviceDefinitionOnChainService, datGroupAPIService gateways.DATGroupAPIService, dbs func() *db.ReaderWriter, - japan17VINAPI gateways.Japan17VINAPI, carvxAPI gateways.CarVxVINAPI) VINDecodingService { + japan17VINAPI gateways.Japan17VINAPI, carvxAPI gateways.CarVxVINAPI, elevaAPI gateways.ElevaAPI) VINDecodingService { return &vinDecodingService{drivlyAPISvc: drivlyAPISvc, vincarioAPISvc: vincarioAPISvc, autoIsoAPIService: autoIso, - japan17VINAPI: japan17VINAPI, carvxAPI: carvxAPI, logger: logger, onChainSvc: onChainSvc, DATGroupAPIService: datGroupAPIService, dbs: dbs} + japan17VINAPI: japan17VINAPI, carvxAPI: carvxAPI, logger: logger, onChainSvc: onChainSvc, + DATGroupAPIService: datGroupAPIService, dbs: dbs, elevaAPI: elevaAPI} } func (c vinDecodingService) GetVIN(ctx context.Context, vin string, provider coremodels.DecodeProviderEnum, country string) (*coremodels.VINDecodingInfoData, *coremodels.VINDecodingVendorExtra, error) { @@ -56,7 +59,10 @@ func (c vinDecodingService) GetVIN(ctx context.Context, vin string, provider cor vin = strings.ToUpper(strings.TrimSpace(vin)) providersToTry := make([]coremodels.DecodeProviderEnum, 0) // check for japan chasis if all providers - if provider == coremodels.AllProviders && ((len(vin) < 17 && len(vin) > 10) || country == "JPN") { + if country == "CHL" { + providersToTry = append(providersToTry, coremodels.ElevaKaufmannProvider) + providersToTry = append(providersToTry, coremodels.VincarioProvider) // sometimes works in latam as backup + } else if provider == coremodels.AllProviders && ((len(vin) < 17 && len(vin) > 10) || country == "JPN") { providersToTry = append(providersToTry, coremodels.CarVXVIN) providersToTry = append(providersToTry, coremodels.Japan17VIN) } else if !ValidateVIN(vin) { @@ -227,6 +233,33 @@ func (c vinDecodingService) GetVIN(ctx context.Context, vin string, provider cor continue } return result, resultVendorExtra, nil + case coremodels.ElevaKaufmannProvider: + resultVendorExtra.VendorsTried = append(resultVendorExtra.VendorsTried, string(coremodels.ElevaKaufmannProvider)) + localLog.Info().Msgf("trying to decode VIN: %s with eleva", vin) + + vinInfo, err := c.elevaAPI.GetVINInfo(vin) + if err != nil { + errFinal = errors.Wrapf(err, "unable to decode vin: %s with eleva", vin) + continue + } + vinObj := vinutil.VIN(vin) + yr := vinObj.Year() + if yr < 2018 { + yr = time.Now().Year() - 1 + localLog.Info().Msgf("vin year is less than 2019 for eleva kaufmann, using %d instead", yr) + } + + result = &coremodels.VINDecodingInfoData{ + VIN: vin, + Make: titleCase(vinInfo.Data.Vehicle.Brand), // MERCEDES-BENZ to Mercedes-Benz + Model: vinInfo.Data.Vehicle.Model, + SubModel: "", + Year: int32(yr), + StyleName: "", + Source: coremodels.ElevaKaufmannProvider, + Raw: nil, + } + return result, resultVendorExtra, nil case coremodels.AllProviders: // this should never hit errFinal = fmt.Errorf("all providers - invalid option reached") @@ -395,3 +428,20 @@ func validateVinDecoding(vdi *coremodels.VINDecodingInfoData) error { return nil } + +func titleCase(s string) string { + parts := strings.Split(s, "-") + + for i, part := range parts { + if len(part) == 0 { + continue + } + part = strings.ToLower(part) + + runes := []rune(part) + runes[0] = unicode.ToUpper(runes[0]) + + parts[i] = string(runes) + } + return strings.Join(parts, "-") +} diff --git a/internal/core/services/vin_decoding_service_test.go b/internal/core/services/vin_decoding_service_test.go index 4d01dee1..543be6ec 100644 --- a/internal/core/services/vin_decoding_service_test.go +++ b/internal/core/services/vin_decoding_service_test.go @@ -37,6 +37,7 @@ type VINDecodingServiceSuite struct { mockOnChainSvc *mock_gateways.MockDeviceDefinitionOnChainService vinDecodingService VINDecodingService + mockElevaAPI *mock_gateways.MockElevaAPI } func TestVINDecodingService(t *testing.T) { @@ -62,10 +63,11 @@ func (s *VINDecodingServiceSuite) SetupTest() { s.mockDATGroupAPIService = mock_gateways.NewMockDATGroupAPIService(s.ctrl) s.mockJapan17VINAPI = mock_gateways.NewMockJapan17VINAPI(s.ctrl) s.mockCarvxAPI = mock_gateways.NewMockCarVxVINAPI(s.ctrl) + s.mockElevaAPI = mock_gateways.NewMockElevaAPI(s.ctrl) s.mockOnChainSvc = mock_gateways.NewMockDeviceDefinitionOnChainService(s.ctrl) s.vinDecodingService = NewVINDecodingService(s.mockDrivlyAPISvc, s.mockVincarioAPISvc, s.mockAutoIsoAPISvc, dbtesthelper.Logger(), - s.mockOnChainSvc, s.mockDATGroupAPIService, s.pdb.DBS, s.mockJapan17VINAPI, s.mockCarvxAPI) + s.mockOnChainSvc, s.mockDATGroupAPIService, s.pdb.DBS, s.mockJapan17VINAPI, s.mockCarvxAPI, s.mockElevaAPI) } func (s *VINDecodingServiceSuite) TearDownTest() { @@ -108,6 +110,31 @@ func (s *VINDecodingServiceSuite) Test_VINDecodingService_Japan17VIN_Success() { assert.Equal(s.T(), result.Year, int32(2022)) } +//go:embed eleva_resp.json +var elevaAPIResponse []byte + +func (s *VINDecodingServiceSuite) Test_VINDecodingService_KaufmannEleva_Success() { + ctx := context.Background() + const vin = "W1K3F4GB9NN286196" + const country = "CHL" // chile only + + vinInfoResp := &coremodels.ElevaVINResponse{} + err := json.Unmarshal(elevaAPIResponse, vinInfoResp) + require.NoError(s.T(), err) + s.mockElevaAPI.EXPECT().GetVINInfo(vin).Times(1).Return(vinInfoResp, nil) + + _ = dbtesthelper.SetupCreateDeviceType(s.T(), s.pdb) + + result, _, err := s.vinDecodingService.GetVIN(ctx, vin, coremodels.AllProviders, country) + + s.NoError(err) + assert.Equal(s.T(), result.VIN, vin) + assert.Equal(s.T(), result.Source, coremodels.ElevaKaufmannProvider) + assert.Equal(s.T(), result.Make, "Mercedes-Benz") + assert.Equal(s.T(), result.Model, "A 250") + assert.Equal(s.T(), result.Year, int32(2022)) +} + func (s *VINDecodingServiceSuite) Test_VINDecodingService_Drivly_Success() { ctx := context.Background() const vin = "1FMCU0G61MUA52727" // ford escape 2021 diff --git a/internal/infrastructure/gateways/kaufmann_api.go b/internal/infrastructure/gateways/kaufmann_api.go new file mode 100644 index 00000000..70b572df --- /dev/null +++ b/internal/infrastructure/gateways/kaufmann_api.go @@ -0,0 +1,142 @@ +package gateways + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/DIMO-Network/device-definitions-api/internal/config" + coremodels "github.com/DIMO-Network/device-definitions-api/internal/core/models" +) + +//go:generate mockgen -source kaufmann_api.go -destination mocks/kaufmann_api_mock.go -package mocks + +type ElevaConfig struct { + Username string + Password string +} + +type ElevaAPI interface { + GetVINInfo(vin string) (*coremodels.ElevaVINResponse, error) +} + +// ElevaAPI is used to call Kaufmann for VIN decoding in Chile +type elevaAPI struct { + client *http.Client + config ElevaConfig + accessToken string + tokenExpires time.Time +} + +func NewElevaAPI(settings *config.Settings) ElevaAPI { + return &elevaAPI{ + client: &http.Client{ + Timeout: 10 * time.Second, + }, + config: ElevaConfig{ + Username: settings.ElevaUsername, + Password: settings.ElevaPassword, + }, + } +} + +// how long does the access token last +const tokenExpiration = 15 * time.Minute // assumed + +func (e *elevaAPI) getAccessToken() error { + if e.accessToken != "" && time.Now().Before(e.tokenExpires) { + return nil + } + + loginURL := "https://api-prd-js.eleva-services.com/v4/auth/login" + + payload := map[string]string{ + "username": e.config.Username, + "password": e.config.Password, + } + body, _ := json.Marshal(payload) + + req, err := http.NewRequest("POST", loginURL, bytes.NewBuffer(body)) + if err != nil { + return fmt.Errorf("creating login request: %w", err) + } + req.Header.Set("accept", "application/json") + req.Header.Set("content-type", "application/json") + req.Header.Set("origin", "https://portalprepago.elevapp.io") + req.Header.Set("referer", "https://portalprepago.elevapp.io/") + + resp, err := e.client.Do(req) + if err != nil { + return fmt.Errorf("performing login request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode > 299 { + return fmt.Errorf("auth failed with status %d", resp.StatusCode) + } + + var result struct { + Data struct { + AccessToken string `json:"accessToken"` + } `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return fmt.Errorf("decoding auth response: %w", err) + } + + if result.Data.AccessToken == "" { + return fmt.Errorf("no access token returned") + } + + e.accessToken = result.Data.AccessToken + e.tokenExpires = time.Now().Add(tokenExpiration) + return nil +} + +func (e *elevaAPI) GetVINInfo(plateOrVIN string) (*coremodels.ElevaVINResponse, error) { + if err := e.getAccessToken(); err != nil { + return nil, err + } + + url := "https://api-prd-js.eleva-services.com/v4/showcase-vehicle/client-summary" + payload := map[string]string{ + "plateOrChassis": plateOrVIN, + } + body, _ := json.Marshal(payload) + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(body)) + if err != nil { + return nil, fmt.Errorf("creating vin info request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+e.accessToken) + + resp, err := e.client.Do(req) + if err != nil { + return nil, fmt.Errorf("performing vin info request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode > 299 { + bodyBytes, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("vin info failed (%d): %s", resp.StatusCode, string(bodyBytes)) + } + + jsonResp, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed reading vin info response: %w", err) + } + v := &coremodels.ElevaVINResponse{} + err = json.Unmarshal(jsonResp, &v) + if err != nil { + return nil, fmt.Errorf("failed decoding vin info response: %w", err) + } + if v.Error > 0 { + return nil, fmt.Errorf("eleva api returned error: %s", v.Message) + } + + return v, nil +} diff --git a/internal/infrastructure/gateways/mocks/kaufmann_api_mock.go b/internal/infrastructure/gateways/mocks/kaufmann_api_mock.go new file mode 100644 index 00000000..c635c0bd --- /dev/null +++ b/internal/infrastructure/gateways/mocks/kaufmann_api_mock.go @@ -0,0 +1,56 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: kaufmann_api.go +// +// Generated by this command: +// +// mockgen -source kaufmann_api.go -destination mocks/kaufmann_api_mock.go -package mocks +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + reflect "reflect" + + models "github.com/DIMO-Network/device-definitions-api/internal/core/models" + gomock "go.uber.org/mock/gomock" +) + +// MockElevaAPI is a mock of ElevaAPI interface. +type MockElevaAPI struct { + ctrl *gomock.Controller + recorder *MockElevaAPIMockRecorder + isgomock struct{} +} + +// MockElevaAPIMockRecorder is the mock recorder for MockElevaAPI. +type MockElevaAPIMockRecorder struct { + mock *MockElevaAPI +} + +// NewMockElevaAPI creates a new mock instance. +func NewMockElevaAPI(ctrl *gomock.Controller) *MockElevaAPI { + mock := &MockElevaAPI{ctrl: ctrl} + mock.recorder = &MockElevaAPIMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockElevaAPI) EXPECT() *MockElevaAPIMockRecorder { + return m.recorder +} + +// GetVINInfo mocks base method. +func (m *MockElevaAPI) GetVINInfo(vin string) (*models.ElevaVINResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetVINInfo", vin) + ret0, _ := ret[0].(*models.ElevaVINResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetVINInfo indicates an expected call of GetVINInfo. +func (mr *MockElevaAPIMockRecorder) GetVINInfo(vin any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetVINInfo", reflect.TypeOf((*MockElevaAPI)(nil).GetVINInfo), vin) +}