Enhance MySubaru API integration with improved error handling and new utility functions
All checks were successful
Golan Testing / testing (1.24.x, ubuntu-latest) (push) Successful in 25s

- Refactor Response struct's parse method to return detailed error messages based on API error codes.
- Introduce UnixTime type for handling Unix timestamps in JSON marshaling and unmarshaling.
- Add email masking utility function to obfuscate email addresses for privacy.
- Implement containsValueInStruct function to check for substring presence in struct fields.
- Create comprehensive unit tests for UnixTime, email masking, and struct value checking.
- Update vehicle service request method documentation for clarity.
This commit is contained in:
2025-07-08 11:26:45 -04:00
parent 1d8d175be0
commit aec4b8435b
7 changed files with 747 additions and 220 deletions

175
client.go
View File

@ -5,6 +5,7 @@ import (
"errors" "errors"
"io" "io"
"log/slog" "log/slog"
"regexp"
"slices" "slices"
"sync" "sync"
"time" "time"
@ -18,13 +19,14 @@ type Client struct {
credentials config.Credentials credentials config.Credentials
httpClient *resty.Client httpClient *resty.Client
country string // USA | CA country string // USA | CA
updateInterval int // 7200 contactMethods dataMap // List of contact methods for 2FA
fetchInterval int // 360
currentVin string currentVin string
listOfVins []string listOfVins []string
isAuthenticated bool isAuthenticated bool
isRegistered bool isRegistered bool
isAlive bool isAlive bool
updateInterval int // 7200
fetchInterval int // 360
logger *slog.Logger logger *slog.Logger
sync.RWMutex sync.RWMutex
} }
@ -49,45 +51,73 @@ func New(config *config.Config) (*Client, error) {
"X-Requested-With": MOBILE_APP[client.country], "X-Requested-With": MOBILE_APP[client.country],
"Accept-Language": "en-US,en;q=0.9", "Accept-Language": "en-US,en;q=0.9",
"Accept-Encoding": "gzip, deflate", "Accept-Encoding": "gzip, deflate",
"Accept": "*/*"}) "Accept": "*/*"},
)
client.httpClient = httpClient client.httpClient = httpClient
resp, err := client.auth()
if err != nil { if ok, err := client.auth(); !ok {
client.logger.Error("error while executing auth request", "request", "auth", "error", err.Error()) client.logger.Error("error while executing auth request", "request", "auth", "error", err.Error())
return nil, errors.New("error while executing auth request: " + err.Error()) return nil, errors.New("error while executing auth request: " + err.Error())
} }
return client, nil
}
// auth authenticates the client with the MySubaru API using the provided credentials.
func (c *Client) auth() (bool, error) {
params := map[string]string{
"env": "cloudprod",
"deviceType": "android",
"loginUsername": c.credentials.Username,
"password": c.credentials.Password,
"deviceId": c.credentials.DeviceID,
"passwordToken": "",
"selectedVin": "",
"pushToken": ""}
reqURL := MOBILE_API_VERSION + apiURLs["API_LOGIN"]
resp, err := c.execute(POST, reqURL, params, false)
if err != nil {
c.logger.Error("error while executing auth request", "request", "auth", "error", err.Error())
return false, errors.New("error while executing auth request: " + err.Error())
}
c.logger.Debug("http request output", "request", "auth", "body", resp)
var sd SessionData var sd SessionData
err = json.Unmarshal(resp.Data, &sd) err = json.Unmarshal(resp.Data, &sd)
if err != nil { if err != nil {
client.logger.Error("error while parsing json", "request", "auth", "error", err.Error()) c.logger.Error("error while parsing json", "request", "auth", "error", err.Error())
} }
// client.logger.Debug("unmarshaled json data", "request", "auth", "type", "sessionData", "body", sd) // client.logger.Debug("unmarshaled json data", "request", "auth", "type", "sessionData", "body", sd)
if sd.DeviceRegistered && sd.RegisteredDevicePermanent { if !sd.DeviceRegistered {
// client.logger.Debug("client authentication successful") err := c.getContactMethods()
client.isAuthenticated = true if err != nil {
client.isRegistered = true c.logger.Error("error while getting contact methods", "request", "auth", "error", err.Error())
return false, errors.New("error while getting contact methods: " + err.Error())
} }
// TODO: Work on registerDevice()
// } else {
// // client.logger.Debug("client authentication successful, but devices is not registered")
// client.registerDevice()
// }
// client.logger.Debug("parsing cars assigned to the account", "quantity", len(sd.Vehicles)) c.logger.Error("device is not registered", "request", "auth", "deviceId", c.credentials.DeviceID)
return false, errors.New("device is not registered: " + c.credentials.DeviceID)
}
if sd.DeviceRegistered && sd.RegisteredDevicePermanent {
c.isAuthenticated = true
c.isRegistered = true
c.isAlive = true
}
c.logger.Debug("MySubaru API client authenticated")
if len(sd.Vehicles) > 0 { if len(sd.Vehicles) > 0 {
for _, vehicle := range sd.Vehicles { for _, vehicle := range sd.Vehicles {
// client.logger.Debug("parsing car", "vin", vehicle.Vin) c.listOfVins = append(c.listOfVins, vehicle.Vin)
client.listOfVins = append(client.listOfVins, vehicle.Vin)
} }
client.currentVin = client.listOfVins[0] c.currentVin = c.listOfVins[0]
} else { } else {
client.logger.Error("there are no cars assigned to the account") c.logger.Error("there are no vehicles associated with the account", "request", "auth", "error", "no vehicles found")
return nil, err return false, err
} }
return client, nil return true, nil
} }
// SelectVehicle selects a vehicle by its VIN. If no VIN is provided, it uses the current VIN. // SelectVehicle selects a vehicle by its VIN. If no VIN is provided, it uses the current VIN.
@ -142,17 +172,17 @@ func (c *Client) GetVehicleByVin(vin string) (*Vehicle, error) {
reqURL := MOBILE_API_VERSION + apiURLs["API_SELECT_VEHICLE"] reqURL := MOBILE_API_VERSION + apiURLs["API_SELECT_VEHICLE"]
resp, err := c.execute(GET, reqURL, params, false) resp, err := c.execute(GET, reqURL, params, false)
if err != nil { if err != nil {
c.logger.Error("error while executing GetVehicleByVIN request", "request", "GetVehicleByVIN", "error", err.Error()) c.logger.Error("error while executing GetVehicleByVin request", "request", "GetVehicleByVin", "error", err.Error())
return nil, errors.New("error while executing GetVehicleByVIN request: " + err.Error()) return nil, errors.New("error while executing GetVehicleByVin request: " + err.Error())
} }
// c.logger.Debug("http request output", "request", "GetVehicleByVIN", "body", resp) // c.logger.Debug("http request output", "request", "GetVehicleByVin", "body", resp)
var vd VehicleData var vd VehicleData
err = json.Unmarshal(resp.Data, &vd) err = json.Unmarshal(resp.Data, &vd)
if err != nil { if err != nil {
c.logger.Error("error while parsing json", "request", "GetVehicleByVIN", "error", err.Error()) c.logger.Error("error while parsing json", "request", "GetVehicleByVin", "error", err.Error())
} }
// c.logger.Debug("http request output", "request", "GetVehicleByVIN", "body", resp) // c.logger.Debug("http request output", "request", "GetVehicleByVin", "body", resp)
vehicle = &Vehicle{ vehicle = &Vehicle{
Vin: vin, Vin: vin,
@ -198,6 +228,7 @@ func (c *Client) GetVehicleByVin(vin string) (*Vehicle, error) {
} }
// RefreshVehicles refreshes the list of vehicles associated with the client's account. // RefreshVehicles refreshes the list of vehicles associated with the client's account.
// {"success":true,"dataName":"sessionData","data":{"sessionChanged":true,"vehicleInactivated":false,"account":{"marketId":1,"createdDate":1476984644000,"firstName":"Tatiana","lastName":"Savin","zipCode":"07974","accountKey":765268,"lastLoginDate":1751835464000,"zipCode5":"07974"},"resetPassword":false,"deviceId":"JddMBQXvAkgutSmEP6uFsThbq4QgEBBQ","sessionId":"0E154A99FA3D014D866840123E5E666A","deviceRegistered":true,"passwordToken":null,"vehicles":[{"customer":{"sessionCustomer":{"firstName":"Tatiana","lastName":"Savin","title":"","suffix":"","email":"tanya@savin.nyc","address":"29a Marion Ave","address2":"","city":"New Providence","state":"NJ","zip":"07974-1906","cellularPhone":"","workPhone":"","homePhone":"4013050505","countryCode":"USA","relationshipType":null,"gender":"","dealerCode":null,"oemCustId":"CRM-41PLM-5TYE","createMysAccount":null,"sourceSystemCode":"mys","vehicles":[{"vin":"4S4BSETC4H3265676","siebelVehicleRelationship":"Previous Owner","primary":false,"oemCustId":"1-8K7OBOJ","status":""},{"vin":"4S4BSETC4H3265676","siebelVehicleRelationship":"Previous TM Subscriber","primary":false,"oemCustId":"1-8JY3UVS","status":"Inactive"},{"vin":"4S4BTGND8L3137058","siebelVehicleRelationship":"Previous TM Subscriber","primary":false,"oemCustId":"CRM-44UFUA14-V","status":"Draft"},{"vin":"4S4BTGPD0P3199198","siebelVehicleRelationship":"TM Subscriber","primary":true,"oemCustId":"CRM-41PLM-5TYE","status":"Active"}],"phone":"","zip5Digits":"07974","primaryPersonalCountry":"USA"},"email":"","firstName":"Tatiana","lastName":"Savin","zip":"07974-1906","oemCustId":"CRM-41PLM-5TYE","phone":""},"vehicleName":"Subaru Outback TXT","stolenVehicle":false,"vin":"4S4BTGPD0P3199198","modelYear":null,"modelCode":null,"engineSize":null,"nickname":"Subaru Outback TXT","vehicleKey":8211380,"active":true,"licensePlate":"","licensePlateState":"","email":"tanya@savin.nyc","firstName":"Tatiana","lastName":"Savin","subscriptionFeatures":["REMOTE","SAFETY","Retail3"],"accessLevel":-1,"zip":"07974-1906","oemCustId":"CRM-41PLM-5TYE","vehicleMileage":null,"phone":"","timeZone":"America/New_York","features":["ABS_MIL","ACCS","AHBL_MIL","ATF_MIL","AWD_MIL","BSD","BSDRCT_MIL","CEL_MIL","CP1_5HHU","EBD_MIL","EOL_MIL","EPAS_MIL","EPB_MIL","ESS_MIL","EYESIGHT","ISS_MIL","MOONSTAT","OPL_MIL","PANPM-TUIRWAOC","PWAAADWWAP","RAB_MIL","RCC","REARBRK","RES","RESCC","RES_HVAC_HFS","RES_HVAC_VFS","RHSF","RPOI","RPOIA","RTGU","RVFS","SRH_MIL","SRS_MIL","SXM360L","T23DCM","TEL_MIL","TIF_35","TIR_33","TLD","TPMS_MIL","VALET","VDC_MIL","WASH_MIL","WDWSTAT","g3"],"userOemCustId":"CRM-41PLM-5TYE","subscriptionStatus":"ACTIVE","authorizedVehicle":false,"preferredDealer":null,"cachedStateCode":"NJ","modelName":null,"subscriptionPlans":[],"crmRightToRepair":false,"needMileagePrompt":false,"phev":null,"extDescrip":null,"sunsetUpgraded":true,"intDescrip":null,"transCode":null,"provisioned":true,"remoteServicePinExist":true,"needEmergencyContactPrompt":false,"vehicleGeoPosition":null,"show3gSunsetBanner":false,"vehicleBranded":false}],"rightToRepairEnabled":true,"rightToRepairStartYear":2022,"rightToRepairStates":"MA","enableXtime":true,"termsAndConditionsAccepted":true,"digitalGlobeConnectId":"0572e32b-2fcf-4bc8-abe0-1e3da8767132","digitalGlobeImageTileService":"https://earthwatch.digitalglobe.com/earthservice/tmsaccess/tms/1.0.0/DigitalGlobe:ImageryTileService@EPSG:3857@png/{z}/{x}/{y}.png?connectId=0572e32b-2fcf-4bc8-abe0-1e3da8767132","digitalGlobeTransparentTileService":"https://earthwatch.digitalglobe.com/earthservice/tmsaccess/tms/1.0.0/Digitalglobe:OSMTransparentTMSTileService@EPSG:3857@png/{z}/{x}/{-y}.png/?connectId=0572e32b-2fcf-4bc8-abe0-1e3da8767132","tomtomKey":"DHH9SwEQ4MW55Hj2TfqMeldbsDjTdgAs","currentVehicleIndex":0,"handoffToken":"$2a$08$EvONydxMhrhVgFsv4OgX3O8QaOu3naCFYex2Crqhl27cPQwJYXera$1751837730624","satelliteViewEnabled":true,"registeredDevicePermanent":true}}
func (c *Client) RefreshVehicles() error { func (c *Client) RefreshVehicles() error {
params := map[string]string{} params := map[string]string{}
reqURL := MOBILE_API_VERSION + apiURLs["API_REFRESH_VEHICLES"] reqURL := MOBILE_API_VERSION + apiURLs["API_REFRESH_VEHICLES"]
@ -219,9 +250,24 @@ func (c *Client) RefreshVehicles() error {
} }
// RequestAuthCode requests an authentication code for two-factor authentication (2FA). // RequestAuthCode requests an authentication code for two-factor authentication (2FA).
func (c *Client) RequestAuthCode() error { // (?!^).(?=.*@)
// (?!^): This is a negative lookbehind assertion. It ensures that the matched character is not at the beginning of the string.
// .: This matches any single character (except newline, by default).
// (?=.*@): This is a positive lookahead assertion. It ensures that the matched character is followed by any characters (.*) and then an "@" symbol. This targets the username part of the email address.
func (c *Client) RequestAuthCode(email string) error {
email, err := emailMasking(email)
if err != nil {
c.logger.Error("error while hiding email", "request", "RequestAuthCode", "error", err.Error())
return errors.New("error while hiding email: " + err.Error())
}
if !containsValueInStruct(c.contactMethods, email) {
c.logger.Error("email is not in the list of contact methods", "request", "RequestAuthCode", "email", email)
return errors.New("email is not in the list of contact methods: " + email)
}
params := map[string]string{ params := map[string]string{
"contactMethod": "0", "contactMethod": email,
"languagePreference": "EN"} "languagePreference": "EN"}
reqUrl := MOBILE_API_VERSION + apiURLs["API_2FA_SEND_VERIFICATION"] reqUrl := MOBILE_API_VERSION + apiURLs["API_2FA_SEND_VERIFICATION"]
resp, err := c.execute(POST, reqUrl, params, false) resp, err := c.execute(POST, reqUrl, params, false)
@ -236,6 +282,12 @@ func (c *Client) RequestAuthCode() error {
// SubmitAuthCode submits the authentication code received from the RequestAuthCode method. // SubmitAuthCode submits the authentication code received from the RequestAuthCode method.
func (c *Client) SubmitAuthCode(code string, permanent bool) error { func (c *Client) SubmitAuthCode(code string, permanent bool) error {
regex := regexp.MustCompile(`^\d{6}$`)
if !regex.MatchString(code) {
c.logger.Error("invalid verification code format", "request", "SubmitAuthCode", "code", code)
return errors.New("invalid verification code format, must be 6 digits")
}
params := map[string]string{ params := map[string]string{
"deviceId": c.credentials.DeviceID, "deviceId": c.credentials.DeviceID,
"deviceName": c.credentials.DeviceName, "deviceName": c.credentials.DeviceName,
@ -250,34 +302,44 @@ func (c *Client) SubmitAuthCode(code string, permanent bool) error {
c.logger.Error("error while executing SubmitAuthCode request", "request", "SubmitAuthCode", "error", err.Error()) c.logger.Error("error while executing SubmitAuthCode request", "request", "SubmitAuthCode", "error", err.Error())
return errors.New("error while executing SubmitAuthCode request: " + err.Error()) return errors.New("error while executing SubmitAuthCode request: " + err.Error())
} }
c.logger.Debug("http request output", "request", "SubmitAuthCode", "body", resp)
// Device registration does not always immediately take effect // Device registration does not always immediately take effect
time.Sleep(time.Second * 3) time.Sleep(time.Second * 3)
c.logger.Debug("http request output", "request", "SubmitAuthCode", "body", resp)
// Reauthenticate after submitting the code
if ok, err := c.auth(); !ok {
c.logger.Error("error while executing auth request", "request", "auth", "error", err.Error())
return errors.New("error while executing auth request: " + err.Error())
}
return nil return nil
} }
// getContactMethods retrieves the available contact methods for two-factor authentication (2FA). // getContactMethods retrieves the available contact methods for two-factor authentication (2FA).
func (c *Client) GetContactMethods() error { // {"success":true,"dataName":"dataMap","data":{"userName":"a**x@savin.nyc","email":"t***a@savin.nyc"}}
func (c *Client) getContactMethods() error {
params := map[string]string{} params := map[string]string{}
reqUrl := MOBILE_API_VERSION + apiURLs["API_2FA_CONTACT"] reqUrl := MOBILE_API_VERSION + apiURLs["API_2FA_CONTACT"]
resp, err := c.execute(POST, reqUrl, params, false) resp, err := c.execute(POST, reqUrl, params, false)
if err != nil { if err != nil {
c.logger.Error("error while executing GetContactMethods request", "request", "GetContactMethods", "error", err.Error()) c.logger.Error("error while executing getContactMethods request", "request", "getContactMethods", "error", err.Error())
return errors.New("error while executing GetContactMethods request: " + err.Error()) return errors.New("error while executing getContactMethods request: " + err.Error())
} }
c.logger.Debug("http request output", "request", "GetContactMethods", "body", resp) c.logger.Debug("http request output", "request", "getContactMethods", "body", resp)
var dm dataMap
err = json.Unmarshal(resp.Data, &dm)
if err != nil {
c.logger.Error("error while parsing json", "request", "getContactMethods", "error", err.Error())
return errors.New("error while parsing json while getting contact methods: " + err.Error())
}
c.contactMethods = dm
c.logger.Debug("contact methods successfully retrieved", "request", "getContactMethods", "methods", dm)
return nil return nil
} }
// func isPINRequired() {}
// func getEVStatus() {}
// func getRemoteOptionsStatus() {}
// func getRemoteStartStatus() {}
// func getSafetyStatus() {}
// func getSubscriptionStatus() {}
// IsAlive checks if the Client instance is alive // IsAlive checks if the Client instance is alive
func (c *Client) IsAlive() bool { func (c *Client) IsAlive() bool {
return c.isAlive return c.isAlive
@ -335,6 +397,8 @@ func (c *Client) execute(method string, url string, params map[string]string, j
} }
c.logger.Debug("parsed http request output", "data", string(resBytes)) c.logger.Debug("parsed http request output", "data", string(resBytes))
c.httpClient.SetCookies(resp.Cookies())
if r, ok := c.parseResponse(resBytes); ok { if r, ok := c.parseResponse(resBytes); ok {
c.isAlive = true c.isAlive = true
c.Unlock() c.Unlock()
@ -361,28 +425,6 @@ func (c *Client) execute(method string, url string, params map[string]string, j
return nil, errors.New("request is not successfull, HTTP code: " + resp.Status()) return nil, errors.New("request is not successfull, HTTP code: " + resp.Status())
} }
// auth authenticates the client with the MySubaru API using the provided credentials.
func (c *Client) auth() (*Response, error) {
params := map[string]string{
"env": "cloudprod",
"deviceType": "android",
"loginUsername": c.credentials.Username,
"password": c.credentials.Password,
"deviceId": c.credentials.DeviceID,
"passwordToken": "",
"selectedVin": "",
"pushToken": ""}
reqURL := MOBILE_API_VERSION + apiURLs["API_LOGIN"]
resp, err := c.execute(POST, reqURL, params, false)
c.logger.Debug("AUTH HTTP OUTPUT", "body", resp)
if err != nil {
c.logger.Error("error while executing auth request", "request", "auth", "error", err.Error())
return nil, errors.New("error while executing auth request: " + err.Error())
}
c.logger.Debug("AUTH HTTP OUTPUT", "body", resp)
return resp, nil
}
// parseResponse parses the JSON response from the MySubaru API into a Response struct. // parseResponse parses the JSON response from the MySubaru API into a Response struct.
func (c *Client) parseResponse(b []byte) (Response, bool) { func (c *Client) parseResponse(b []byte) (Response, bool) {
var r Response var r Response
@ -407,6 +449,13 @@ func (c *Client) ValidateSession() bool {
return true return true
} }
// func isPINRequired() {}
// func getEVStatus() {}
// func getRemoteOptionsStatus() {}
// func getRemoteStartStatus() {}
// func getSafetyStatus() {}
// func getSubscriptionStatus() {}
// validateSession . // validateSession .
// TODO: add session validation process and add it to the proper functions // TODO: add session validation process and add it to the proper functions
// func (c *Client) validateSession() bool { // func (c *Client) validateSession() bool {

View File

@ -95,23 +95,23 @@ var apiURLs = map[string]string{
// } // }
var API_ERRORS = map[string]string{ var API_ERRORS = map[string]string{
"403-soa-unableToParseResponseBody": "ERROR_SOA_403", // G2 Error Codes "API_ERROR_SOA_403": "403-soa-unableToParseResponseBody", // G2
"InvalidCredentials": "ERROR_INVALID_CREDENTIALS", "API_ERROR_NO_ACCOUNT": "accountNotFound", // G2
"ServiceAlreadyStarted": "ERROR_SERVICE_ALREADY_STARTED", "API_ERROR_INVALID_ACCOUNT": "invalidAccount", // G2
"invalidAccount": "ERROR_INVALID_ACCOUNT", "API_ERROR_INVALID_CREDENTIALS": "InvalidCredentials", // G2
"passwordWarning": "ERROR_PASSWORD_WARNING", "API_ERROR_INVALID_TOKEN": "InvalidToken", // G2
"accountLocked": "ERROR_ACCOUNT_LOCKED", "API_ERROR_PASSWORD_WARNING": "passwordWarning", // G2
"noVehiclesOnAccount": "ERROR_NO_VEHICLES", "API_ERROR_TOO_MANY_ATTEMPTS": "tooManyAttempts", // G2
"noVehiclesAvailable": "ERROR_NO_VEHICLE_AVAILABLE", "API_ERROR_ACCOUNT_LOCKED": "accountLocked", // G2
"VEHICLESETUPERROR": "ERROR_VEHICLE_SETUP_ERROR", // Vehicle Select "API_ERROR_NO_VEHICLES": "noVehiclesOnAccount", // G2
"accountNotFound": "ERROR_NO_ACCOUNT", "API_ERROR_VEHICLE_SETUP": "VEHICLESETUPERROR", // G2
"tooManyAttempts": "ERROR_TOO_MANY_ATTEMPTS", "API_ERROR_VEHICLE_NOT_IN_ACCOUNT": "vehicleNotInAccount", // G2
"vehicleNotInAccount": "ERROR_VEHICLE_NOT_IN_ACCOUNT", "API_ERROR_SERVICE_ALREADY_STARTED": "ServiceAlreadyStarted", // G2
"SXM40004": "ERROR_G1_NO_SUBSCRIPTION", // G1 Error Codes "API_ERROR_G1_NO_SUBSCRIPTION": "SXM40004", // G1
"SXM40005": "ERROR_G1_STOLEN_VEHICLE", "API_ERROR_G1_STOLEN_VEHICLE": "SXM40005", // G1
"SXM40006": "ERROR_G1_INVALID_PIN", "API_ERROR_G1_INVALID_PIN": "SXM40006", // G1
"SXM40009": "ERROR_G1_SERVICE_ALREADY_STARTED", "API_ERROR_G1_SERVICE_ALREADY_STARTED": "SXM40009", // G1
"SXM40017": "ERROR_G1_PIN_LOCKED", "API_ERROR_G1_PIN_LOCKED": "SXM40017", // G1
} }
var APP_ERRORS = map[string]string{ var APP_ERRORS = map[string]string{

View File

@ -2,6 +2,10 @@ package mysubaru
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt"
"log/slog"
"time"
) )
// Response represents the structure of a response from the MySubaru API. // Response represents the structure of a response from the MySubaru API.
@ -12,18 +16,59 @@ type Response struct {
Data json.RawMessage `json:"data"` // Data struct Data json.RawMessage `json:"data"` // Data struct
} }
// parse . // parse parses the JSON response from the MySubaru API into a Response struct.
// func (r *Response) parse(b []byte, logger *slog.Logger) bool { func (r *Response) parse(b []byte, logger *slog.Logger) (*Response, error) {
// err := json.Unmarshal(b, &r) err := json.Unmarshal(b, &r)
// if err != nil { if err != nil {
// logger.Error("error while parsing json", "error", err.Error()) logger.Error("error while parsing json", "error", err.Error())
// return false return nil, errors.New("error while parsing json: " + err.Error())
// } }
// return true
// }
// Unmarshal . if !r.Success && r.ErrorCode != "" {
// func (r *Response) Unmarshal(b []byte) {} logger.Error("error in response", "errorCode", r.ErrorCode, "dataName", r.DataName)
switch r.ErrorCode {
// G2 API errors
case API_ERRORS["API_ERROR_NO_ACCOUNT"]:
return r, errors.New("error in response: Account not found")
case API_ERRORS["API_ERROR_INVALID_ACCOUNT"]:
return r, errors.New("error in response: Invalid Account")
case API_ERRORS["API_ERROR_INVALID_CREDENTIALS"]:
return r, errors.New("error in response: Invalid Credentials")
case API_ERRORS["API_ERROR_INVALID_TOKEN"]:
return r, errors.New("error in response: Invalid Token")
case API_ERRORS["API_ERROR_PASSWORD_WARNING"]:
return r, errors.New("error in response: Mutiple failed login attempts, password warning")
case API_ERRORS["API_ERROR_TOO_MANY_ATTEMPTS"]:
return r, errors.New("error in response: Too many attempts, please try again later")
case API_ERRORS["API_ERROR_ACCOUNT_LOCKED"]:
return r, errors.New("error in response: Account Locked")
case API_ERRORS["API_ERROR_NO_VEHICLES"]:
return r, errors.New("error in response: No vehicles found for the account")
case API_ERRORS["API_ERROR_VEHICLE_SETUP"]:
return r, errors.New("error in response: Vehicle setup is not complete")
case API_ERRORS["API_ERROR_VEHICLE_NOT_IN_ACCOUNT"]:
return r, errors.New("error in response: Vehicle not in account")
case API_ERRORS["API_ERROR_SERVICE_ALREADY_STARTED"]:
return r, errors.New("error in response: Service already started")
case API_ERRORS["API_ERROR_SOA_403"]:
return r, errors.New("error in response: Unable to parse response body, SOA 403 error")
// G1 API errors
case API_ERRORS["API_ERROR_G1_NO_SUBSCRIPTION"]:
return r, errors.New("error in response: No subscription found for the vehicle")
case API_ERRORS["API_ERROR_G1_STOLEN_VEHICLE"]:
return r, errors.New("error in response: Car is reported as stolen")
case API_ERRORS["API_ERROR_G1_INVALID_PIN"]:
return r, errors.New("error in response: Invalid PIN")
case API_ERRORS["API_ERROR_G1_PIN_LOCKED"]:
return r, errors.New("error in response: PIN is locked")
case API_ERRORS["API_ERROR_G1_SERVICE_ALREADY_STARTED"]:
return r, errors.New("error in response: Service already started")
}
return r, errors.New("error in response: " + r.ErrorCode)
}
return r, nil
}
// Request represents the structure of a request to the MySubaru API. // Request represents the structure of a request to the MySubaru API.
type Request struct { type Request struct {
@ -46,21 +91,21 @@ type Request struct {
StartConfiguration *string `json:"startConfiguration,omitempty"` // StartConfiguration *string `json:"startConfiguration,omitempty"` //
} }
// Account . // account .
type Account struct { type account struct {
MarketID int `json:"marketId"` MarketID int `json:"marketId"`
AccountKey int `json:"accountKey"` AccountKey int `json:"accountKey"`
FirstName string `json:"firstName"` FirstName string `json:"firstName"`
LastName string `json:"lastName"` LastName string `json:"lastName"`
ZipCode string `json:"zipCode"` ZipCode string `json:"zipCode"`
ZipCode5 string `json:"zipCode5"` ZipCode5 string `json:"zipCode5"`
LastLoginDate int64 `json:"lastLoginDate"` LastLoginDate UnixTime `json:"lastLoginDate"`
CreatedDate int64 `json:"createdDate"` CreatedDate UnixTime `json:"createdDate"`
} }
// Customer . // Customer .
type Customer struct { type Customer struct {
SessionCustomer string `json:"sessionCustomer"` SessionCustomer SessionCustomer `json:"sessionCustomer,omitempty"` // struct | Only by performing a RefreshVehicles request
Email string `json:"email"` Email string `json:"email"`
FirstName string `json:"firstName"` FirstName string `json:"firstName"`
LastName string `json:"lastName"` LastName string `json:"lastName"`
@ -69,18 +114,60 @@ type Customer struct {
Phone string `json:"phone"` Phone string `json:"phone"`
} }
// SessionCustomer .
type SessionCustomer struct {
FirstName string `json:"firstName,omitempty"`
LastName string `json:"lastName,omitempty"`
Title string `json:"title,omitempty"`
Suffix string `json:"suffix,omitempty"`
Email string `json:"email"`
Address string `json:"address"`
Address2 string `json:"address2,omitempty"`
City string `json:"city"`
State string `json:"state"`
Zip string `json:"zip"`
CellularPhone string `json:"cellularPhone,omitempty"`
WorkPhone string `json:"workPhone,omitempty"`
HomePhone string `json:"homePhone,omitempty"`
CountryCode string `json:"countryCode"`
RelationshipType any `json:"relationshipType,omitempty"`
Gender string `json:"gender,omitempty"`
DealerCode any `json:"dealerCode,omitempty"`
OemCustID string `json:"oemCustId"`
CreateMysAccount any `json:"createMysAccount,omitempty"`
SourceSystemCode string `json:"sourceSystemCode"`
Vehicles []struct {
Vin string `json:"vin"`
SiebelVehicleRelationship string `json:"siebelVehicleRelationship"` // TM Subscriber | Previous TM Subscriber | Previous Owner
Primary bool `json:"primary"` // true | false
OemCustID string `json:"oemCustId"` // CRM-41PLM-5TYE | 1-8K7OBOJ | 1-8JY3UVS | CRM-44UFUA14-V
Status string `json:"status,omitempty"` // "Active" | "Draft" | "Inactive"
} `json:"vehicles"`
Phone string `json:"phone,omitempty"`
Zip5Digits string `json:"zip5Digits"`
PrimaryPersonalCountry string `json:"primaryPersonalCountry"`
}
// DataMap .
// "dataName": "dataMap"
type dataMap struct {
Username string `json:"userName"`
Email string `json:"email"`
}
// SessionData . // SessionData .
// "dataName": "sessionData" // "dataName": "sessionData"
type SessionData struct { type SessionData struct {
SessionChanged bool `json:"sessionChanged"` Account account `json:"account"`
VehicleInactivated bool `json:"vehicleInactivated"`
Account Account `json:"account"`
ResetPassword bool `json:"resetPassword"`
DeviceID string `json:"deviceId"`
SessionID string `json:"sessionId"`
DeviceRegistered bool `json:"deviceRegistered"`
PasswordToken string `json:"passwordToken"` PasswordToken string `json:"passwordToken"`
ResetPassword bool `json:"resetPassword"`
SessionID string `json:"sessionId"`
SessionChanged bool `json:"sessionChanged"`
DeviceID string `json:"deviceId"`
DeviceRegistered bool `json:"deviceRegistered"`
RegisteredDevicePermanent bool `json:"registeredDevicePermanent"`
Vehicles []VehicleData `json:"vehicles"` Vehicles []VehicleData `json:"vehicles"`
VehicleInactivated bool `json:"vehicleInactivated"`
RightToRepairEnabled bool `json:"rightToRepairEnabled"` RightToRepairEnabled bool `json:"rightToRepairEnabled"`
RightToRepairStates string `json:"rightToRepairStates"` RightToRepairStates string `json:"rightToRepairStates"`
CurrentVehicleIndex int `json:"currentVehicleIndex"` CurrentVehicleIndex int `json:"currentVehicleIndex"`
@ -93,21 +180,20 @@ type SessionData struct {
DigitalGlobeTransparentTileService string `json:"digitalGlobeTransparentTileService"` DigitalGlobeTransparentTileService string `json:"digitalGlobeTransparentTileService"`
TomtomKey string `json:"tomtomKey"` TomtomKey string `json:"tomtomKey"`
SatelliteViewEnabled bool `json:"satelliteViewEnabled"` SatelliteViewEnabled bool `json:"satelliteViewEnabled"`
RegisteredDevicePermanent bool `json:"registeredDevicePermanent"`
} }
// Vehicle . // Vehicle .
// "dataName": "vehicle" // "dataName": "vehicle"
type VehicleData struct { type VehicleData struct {
Customer Customer `json:"customer"` // Customer struct Customer Customer `json:"customer"` // Customer struct
UserOemCustID string `json:"userOemCustId"` // CRM-631-HQN48K
OemCustID string `json:"oemCustId"` // CRM-631-HQN48K OemCustID string `json:"oemCustId"` // CRM-631-HQN48K
UserOemCustID string `json:"userOemCustId"` // CRM-631-HQN48K
Active bool `json:"active"` // true | false Active bool `json:"active"` // true | false
Email string `json:"email"` // null | email@address.com Email string `json:"email"` // null | email@address.com
FirstName string `json:"firstName"` // null | First Name FirstName string `json:"firstName,omitempty"` // null | First Name
LastName string `json:"lastName"` // null | Last Name LastName string `json:"lastName,omitempty"` // null | Last Name
Zip string `json:"zip"` // 12345 Zip string `json:"zip"` // 12345
Phone string `json:"phone"` // null | 123-456-7890 Phone string `json:"phone,omitempty"` // null | 123-456-7890
StolenVehicle bool `json:"stolenVehicle"` // true | false StolenVehicle bool `json:"stolenVehicle"` // true | false
VehicleName string `json:"vehicleName"` // Subaru Outback LXT VehicleName string `json:"vehicleName"` // Subaru Outback LXT
Features []string `json:"features"` // "11.6MMAN", "ABS_MIL", "ACCS", "AHBL_MIL", "ATF_MIL", "AWD_MIL", "BSD", "BSDRCT_MIL", "CEL_MIL", "EBD_MIL", "EOL_MIL", "EPAS_MIL", "EPB_MIL", "ESS_MIL", "EYESIGHT", "ISS_MIL", "NAV_TOMTOM", "OPL_MIL", "RAB_MIL", "RCC", "REARBRK", "RES", "RESCC", "RHSF", "RPOI", "RPOIA", "SRH_MIL", "SRS_MIL", "TEL_MIL", "TPMS_MIL", "VDC_MIL", "WASH_MIL", "g2" Features []string `json:"features"` // "11.6MMAN", "ABS_MIL", "ACCS", "AHBL_MIL", "ATF_MIL", "AWD_MIL", "BSD", "BSDRCT_MIL", "CEL_MIL", "EBD_MIL", "EOL_MIL", "EPAS_MIL", "EPB_MIL", "ESS_MIL", "EYESIGHT", "ISS_MIL", "NAV_TOMTOM", "OPL_MIL", "RAB_MIL", "RCC", "REARBRK", "RES", "RESCC", "RHSF", "RPOI", "RPOIA", "SRH_MIL", "SRS_MIL", "TEL_MIL", "TPMS_MIL", "VDC_MIL", "WASH_MIL", "g2"
@ -127,10 +213,10 @@ type VehicleData struct {
LicensePlateState string `json:"licensePlateState"` // ABCDEF LicensePlateState string `json:"licensePlateState"` // ABCDEF
SubscriptionStatus string `json:"subscriptionStatus"` // ACTIVE SubscriptionStatus string `json:"subscriptionStatus"` // ACTIVE
SubscriptionFeatures []string `json:"subscriptionFeatures"` // "[ REMOTE ], [ SAFETY ], [ Retail | Finance3 | RetailPHEV ]"" SubscriptionFeatures []string `json:"subscriptionFeatures"` // "[ REMOTE ], [ SAFETY ], [ Retail | Finance3 | RetailPHEV ]""
SubscriptionPlans []string `json:"subscriptionPlans"` // [] SubscriptionPlans []string `json:"subscriptionPlans,omitempty"` // []
VehicleGeoPosition GeoPosition `json:"vehicleGeoPosition"` // GeoPosition struct VehicleGeoPosition GeoPosition `json:"vehicleGeoPosition"` // GeoPosition struct
AccessLevel int `json:"accessLevel"` // -1 AccessLevel int `json:"accessLevel"` // -1
VehicleMileage int `json:"vehicleMileage"` // null VehicleMileage int `json:"vehicleMileage,omitempty"` // null
CrmRightToRepair bool `json:"crmRightToRepair"` // true | false CrmRightToRepair bool `json:"crmRightToRepair"` // true | false
AuthorizedVehicle bool `json:"authorizedVehicle"` // false | true AuthorizedVehicle bool `json:"authorizedVehicle"` // false | true
NeedMileagePrompt bool `json:"needMileagePrompt"` // false | true NeedMileagePrompt bool `json:"needMileagePrompt"` // false | true
@ -140,7 +226,7 @@ type VehicleData struct {
Provisioned bool `json:"provisioned"` // true | false Provisioned bool `json:"provisioned"` // true | false
TimeZone string `json:"timeZone"` // America/New_York TimeZone string `json:"timeZone"` // America/New_York
SunsetUpgraded bool `json:"sunsetUpgraded"` // true | false SunsetUpgraded bool `json:"sunsetUpgraded"` // true | false
PreferredDealer string `json:"preferredDealer"` // null | PreferredDealer string `json:"preferredDealer,omitempty"` // null |
} }
// GeoPosition . // GeoPosition .
@ -166,20 +252,13 @@ type GeoPosition struct {
// } // }
// VehicleStatus . // VehicleStatus .
// "dataName":null
// parts = []{"door", "tire", "tyre", "window"}
// prefix = []{"pressure", "status"}
// suffix = []{"status", "position", "unit", "psi"}
// positions = []{"front", "rear", "sunroof", "boot", "enginehood"}
// subPositions []{"left", "right"}
type VehicleStatus struct { type VehicleStatus struct {
VehicleId int64 `json:"vhsId"` // + 9969776690 5198812434 VehicleId int64 `json:"vhsId"` // + 9969776690 5198812434
OdometerValue int `json:"odometerValue"` // + 23787 OdometerValue int `json:"odometerValue"` // + 23787
OdometerValueKm int `json:"odometerValueKilometers"` // + 38273 OdometerValueKm int `json:"odometerValueKilometers"` // + 38273
EventDate int64 `json:"eventDate"` // + 1701896993000 EventDate UnixTime `json:"eventDate"` // + 1701896993000
EventDateStr string `json:"eventDateStr"` // + 2023-12-06T21:09+0000 EventDateStr string `json:"eventDateStr"` // + 2023-12-06T21:09+0000
EventDateCarUser int64 `json:"eventDateCarUser"` // + 1701896993000 EventDateCarUser UnixTime `json:"eventDateCarUser"` // + 1701896993000
EventDateStrCarUser string `json:"eventDateStrCarUser"` // + 2023-12-06T21:09+0000 EventDateStrCarUser string `json:"eventDateStrCarUser"` // + 2023-12-06T21:09+0000
Latitude float64 `json:"latitude"` // + 40.700183 Latitude float64 `json:"latitude"` // + 40.700183
Longitude float64 `json:"longitude"` // + -74.401372 Longitude float64 `json:"longitude"` // + -74.401372
@ -287,6 +366,7 @@ type VehicleCondition struct {
// "dataName": "remoteServiceStatus" // "dataName": "remoteServiceStatus"
type ServiceRequest struct { type ServiceRequest struct {
ServiceRequestID string `json:"serviceRequestId,omitempty"` // 4S4BTGND8L3137058_1640294426029_19_@NGTP ServiceRequestID string `json:"serviceRequestId,omitempty"` // 4S4BTGND8L3137058_1640294426029_19_@NGTP
Vin string `json:"vin"` // 4S4BTGND8L3137058
Success bool `json:"success"` // false | true // Could be in the false state while the executed request in the progress Success bool `json:"success"` // false | true // Could be in the false state while the executed request in the progress
Cancelled bool `json:"cancelled"` // false | true Cancelled bool `json:"cancelled"` // false | true
RemoteServiceType string `json:"remoteServiceType"` // vehicleStatus | condition | locate | unlock | lock | lightsOnly | engineStart | engineStop | phevChargeNow RemoteServiceType string `json:"remoteServiceType"` // vehicleStatus | condition | locate | unlock | lock | lightsOnly | engineStart | engineStop | phevChargeNow
@ -294,8 +374,7 @@ type ServiceRequest struct {
SubState string `json:"subState,omitempty"` // null SubState string `json:"subState,omitempty"` // null
ErrorCode string `json:"errorCode,omitempty"` // null:null ErrorCode string `json:"errorCode,omitempty"` // null:null
Result json.RawMessage `json:"result,omitempty"` // struct Result json.RawMessage `json:"result,omitempty"` // struct
UpdateTime int64 `json:"updateTime,omitempty"` // timestamp UpdateTime UnixTime `json:"updateTime,omitempty"` // timestamp // is empty if the request is started
Vin string `json:"vin"` // 4S4BTGND8L3137058
} }
// climateSettings: [ climateSettings ] // climateSettings: [ climateSettings ]
@ -334,3 +413,49 @@ type ErrorResponse struct {
ErrorLabel string `json:"errorLabel"` // "404-soa-unableToParseResponseBody" ErrorLabel string `json:"errorLabel"` // "404-soa-unableToParseResponseBody"
ErrorDescription string `json:"errorDescription,omitempty"` // null ErrorDescription string `json:"errorDescription,omitempty"` // null
} }
// UnixTime is a wrapper around time.Time that allows us to marshal and unmarshal Unix timestamps
type UnixTime struct {
time.Time
}
// UnmarshalJSON is the method that satisfies the Unmarshaller interface
// Note that it uses a pointer receiver. It needs this because it will be modifying the embedded time.Time instance
func (u *UnixTime) UnmarshalJSON(b []byte) error {
var timestamp int64
err := json.Unmarshal(b, &timestamp)
if err != nil {
return err
}
u.Time = time.Unix(timestamp, 0)
return nil
}
// MarshalJSON turns our time.Time back into an int
func (u UnixTime) MarshalJSON() ([]byte, error) {
return []byte(fmt.Sprintf("%d", (u.Time.Unix()))), nil
}
// CustomTime1 "2021-12-22T13:14:47" is a custom type for unmarshalling time strings
type CustomTime1 struct {
time.Time
}
// UnmarshalJSON implements the json.Unmarshaler interface
func (ct *CustomTime1) UnmarshalJSON(b []byte) (err error) {
// Use the correct layout string for the desired format
const layout = "2006-01-02T15:04:05"
ct.Time, err = time.Parse(layout, string(b))
return
}
// CustomTime2 "2023-04-10T17:50:54+0000" is a custom type for unmarshalling time strings
type CustomTime2 struct {
time.Time
}
func (ct *CustomTime2) UnmarshalJSON(b []byte) (err error) {
const layout = "2006-01-02T15:04:05-0700"
ct.Time, err = time.Parse(layout, string(b)) // Parse the string using the custom layout
return
}

204
mysubaru_test.go Normal file
View File

@ -0,0 +1,204 @@
package mysubaru
import (
"encoding/json"
"testing"
"time"
)
func TestUnixTime_UnmarshalJSON(t *testing.T) {
tests := []struct {
name string
input string
wantTime time.Time
wantError bool
}{
{
name: "valid unix timestamp",
input: "1700000000",
wantTime: time.Unix(1700000000, 0),
},
{
name: "invalid string",
input: "\"notanumber\"",
wantError: true,
},
{
name: "empty input",
input: "",
wantError: true,
},
{
name: "float value",
input: "1700000000.123",
wantError: true,
},
{
name: "zero timestamp",
input: "0",
wantTime: time.Unix(0, 0),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var ut UnixTime
err := ut.UnmarshalJSON([]byte(tt.input))
if (err != nil) != tt.wantError {
t.Errorf("UnmarshalJSON() error = %v, wantError %v", err, tt.wantError)
return
}
if !tt.wantError && !ut.Time.Equal(tt.wantTime) {
t.Errorf("UnmarshalJSON() got = %v, want %v", ut.Time, tt.wantTime)
}
})
}
}
func TestUnixTime_UnmarshalJSON_withJSONUnmarshal(t *testing.T) {
type testStruct struct {
Time UnixTime `json:"time"`
}
input := `{"time":1700000000}`
var ts testStruct
err := json.Unmarshal([]byte(input), &ts)
if err != nil {
t.Fatalf("json.Unmarshal failed: %v", err)
}
want := time.Unix(1700000000, 0)
if !ts.Time.Time.Equal(want) {
t.Errorf("UnmarshalJSON() got = %v, want %v", ts.Time.Time, want)
}
}
func TestUnixTime_MarshalJSON(t *testing.T) {
tests := []struct {
name string
input time.Time
want string
}{
{
name: "epoch",
input: time.Unix(0, 0),
want: "0",
},
{
name: "positive unix time",
input: time.Unix(1700000000, 0),
want: "1700000000",
},
{
name: "negative unix time",
input: time.Unix(-100, 0),
want: "-100",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ut := UnixTime{Time: tt.input}
got, err := ut.MarshalJSON()
if err != nil {
t.Fatalf("MarshalJSON() error = %v", err)
}
if string(got) != tt.want {
t.Errorf("MarshalJSON() = %s, want %s", string(got), tt.want)
}
})
}
}
func TestUnixTime_MarshalJSON_withJSONMarshal(t *testing.T) {
type testStruct struct {
Time UnixTime `json:"time"`
}
ts := testStruct{Time: UnixTime{Time: time.Unix(1700000000, 0)}}
b, err := json.Marshal(ts)
if err != nil {
t.Fatalf("json.Marshal failed: %v", err)
}
want := `{"time":1700000000}`
if string(b) != want {
t.Errorf("json.Marshal() = %s, want %s", string(b), want)
}
}
// func TestResponse_parse(t *testing.T) {
// tests := []struct {
// name string
// input string
// wantErr error
// wantCode string
// wantLog string
// }{
// {
// name: "success response",
// input: `{"success":true,"dataName":"foo","data":{}}`,
// wantErr: nil,
// },
// {
// name: "invalid json",
// input: `{"success":tru`,
// wantErr: errors.New("error while parsing json:"),
// wantLog: "error while parsing json",
// },
// {
// name: "API_ERROR_NO_ACCOUNT",
// input: `{"success":false,"errorCode":"noAccount","dataName":"errorResponse","data":{}}`,
// wantErr: errors.New("error in response: Account not found"),
// wantCode: "noAccount",
// wantLog: "error in response",
// },
// {
// name: "API_ERROR_INVALID_CREDENTIALS",
// input: `{"success":false,"errorCode":"invalidCredentials","dataName":"errorResponse","data":{}}`,
// wantErr: errors.New("error in response: Invalid Credentials"),
// wantCode: "invalidCredentials",
// wantLog: "error in response",
// },
// {
// name: "API_ERROR_SOA_403",
// input: `{"success":false,"errorCode":"404-soa-unableToParseResponseBody","dataName":"errorResponse","data":{}}`,
// wantErr: errors.New("error in response: Unable to parse response body, SOA 403 error"),
// wantCode: "404-soa-unableToParseResponseBody",
// wantLog: "error in response",
// },
// {
// name: "unknown error code",
// input: `{"success":false,"errorCode":"somethingElse","dataName":"errorResponse","data":{}}`,
// wantErr: errors.New("error in response: somethingElse"),
// wantCode: "somethingElse",
// wantLog: "error in response",
// },
// {
// name: "no errorCode but not success",
// input: `{"success":false,"dataName":"errorResponse","data":{}}`,
// wantErr: nil,
// },
// }
// for _, tt := range tests {
// t.Run(tt.name, func(t *testing.T) {
// var resp Response
// logger := slog.New(slog.NewTextHandler(nil, nil))
// got, err := resp.parse([]byte(tt.input), logger)
// if tt.wantErr != nil {
// if err == nil {
// t.Fatalf("expected error, got nil")
// }
// if !contains(err.Error(), tt.wantErr.Error()) {
// t.Errorf("parse() error = %v, want %v", err, tt.wantErr)
// }
// } else if err != nil {
// t.Errorf("parse() unexpected error: %v", err)
// }
// if tt.wantCode != "" && got != nil && got.ErrorCode != tt.wantCode {
// t.Errorf("parse() got.ErrorCode = %v, want %v", got.ErrorCode, tt.wantCode)
// }
// })
// }
// }
// // contains is a helper for substring matching.
// func contains(s, substr string) bool {
// return bytes.Contains([]byte(s), []byte(substr))
// }

View File

@ -1,7 +1,10 @@
package mysubaru package mysubaru
import ( import (
"fmt"
"math" "math"
"net/mail"
"reflect"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
@ -114,6 +117,65 @@ func transcodeDigits(vin string) int {
return digitSum return digitSum
} }
// emailMasking takes an email address as input and returns a version of the email
// with the username part partially hidden for privacy. Only the first and last
// characters of the username are visible, with the middle characters replaced by asterisks.
// The function validates the email format before processing.
// Returns the obfuscated email or an error if the input is not a valid email address.
func emailMasking(email string) (string, error) {
_, err := mail.ParseAddress(email)
if err != nil {
return "", fmt.Errorf("invalid email address: %s", email)
}
re1 := regexp.MustCompile(`^(.*?)@(.*)$`)
matches := re1.FindStringSubmatch(email)
var username, domain string
if len(matches) == 3 { // Expecting the full match, username, and domain
username = matches[1]
domain = matches[2]
} else {
return "", fmt.Errorf("invalid email format: %s", email)
}
re2 := regexp.MustCompile(`(.)(.*)(.)`)
replacedString := re2.ReplaceAllStringFunc(username, func(s string) string {
firstChar := string(s[0])
lastChar := string(s[len(s)-1])
middleCharsCount := len(s) - 2
if middleCharsCount < 0 { // Should not happen with the length check above, but for robustness
return s
}
return firstChar + strings.Repeat("*", middleCharsCount) + lastChar
})
return replacedString + "@" + domain, nil
}
// containsValueInStruct checks if any string field in the given struct 's' contains the specified 'search' substring (case-insensitive).
// It returns true if at least one string field contains the substring, and false otherwise.
// If 's' is not a struct, it returns false.
func containsValueInStruct(s any, search string) bool {
val := reflect.ValueOf(s)
if val.Kind() != reflect.Struct {
return false // Not a struct
}
for i := 0; i < val.NumField(); i++ {
field := val.Field(i)
if field.Kind() == reflect.String {
if strings.Contains(strings.ToLower(field.String()), strings.ToLower(search)) {
return true
}
}
}
return false
}
// timeTrack . // timeTrack .
// func timeTrack(name string) { // func timeTrack(name string) {
// start := time.Now() // start := time.Now()

View File

@ -128,3 +128,89 @@ func TestUrlToGen_NoApiGen(t *testing.T) {
t.Errorf("urlToGen(%q, %q) = %q, want %q", url, gen, got, url) t.Errorf("urlToGen(%q, %q) = %q, want %q", url, gen, got, url)
} }
} }
func TestEmailHidder(t *testing.T) {
tests := []struct {
email string
expected string
wantErr bool
}{
{"alex@example.com", "a**x@example.com", false},
{"a@example.com", "a@example.com", false},
{"ab@example.com", "ab@example.com", false},
{"", "", true},
{"notanemail", "", true},
}
for _, tt := range tests {
got, err := emailMasking(tt.email)
if (err != nil) != tt.wantErr {
t.Errorf("emailHidder(%q) error = %v, wantErr %v", tt.email, err, tt.wantErr)
continue
}
if got != tt.expected {
t.Errorf("emailHidder(%q) = %q, want %q", tt.email, got, tt.expected)
}
}
}
func TestContainsValueInStruct(t *testing.T) {
type TestStruct struct {
Name string
Address string
Age int
Note string
}
tests := []struct {
s any
search string
want bool
}{
{
s: TestStruct{Name: "Alice", Address: "123 Main St", Age: 30, Note: "VIP customer"},
search: "alice",
want: true,
},
{
s: TestStruct{Name: "Bob", Address: "456 Elm St", Age: 25, Note: "Regular"},
search: "elm",
want: true,
},
{
s: TestStruct{Name: "Charlie", Address: "789 Oak St", Age: 40, Note: "VIP"},
search: "vip",
want: true,
},
{
s: TestStruct{Name: "Diana", Address: "101 Pine St", Age: 22, Note: ""},
search: "xyz",
want: false,
},
{
s: TestStruct{Name: "", Address: "", Age: 0, Note: ""},
search: "",
want: true, // empty string is contained in all strings
},
{
s: struct{ Foo int }{Foo: 42},
search: "42",
want: false,
},
{
s: "not a struct",
search: "struct",
want: false,
},
{
s: struct{ S string }{S: "CaseInsensitive"},
search: "caseinsensitive",
want: true,
},
}
for i, tt := range tests {
got := containsValueInStruct(tt.s, tt.search)
if got != tt.want {
t.Errorf("Test %d: containsStringInStruct(%#v, %q) = %v, want %v", i, tt.s, tt.search, got, tt.want)
}
}
}

View File

@ -792,6 +792,7 @@ func (v *Vehicle) GetFeaturesList() {
} }
// executeServiceRequest // executeServiceRequest
// Executes a service request to the Subaru API and handles the response.
func (v *Vehicle) executeServiceRequest(params map[string]string, reqUrl, pollingUrl string, ch chan string, attempt int) error { func (v *Vehicle) executeServiceRequest(params map[string]string, reqUrl, pollingUrl string, ch chan string, attempt int) error {
var maxAttempts = 15 var maxAttempts = 15