From 79f7823bda056a1399d05eee0c09e0eaf17fc2b5 Mon Sep 17 00:00:00 2001 From: Edward Kelly Date: Mon, 12 Jan 2026 14:44:31 +0000 Subject: [PATCH 1/4] chore: Add support for standalone Selenium Server --- .../com/smartcar/sdk/helpers/AuthHelpers.java | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/src/integration/java/com/smartcar/sdk/helpers/AuthHelpers.java b/src/integration/java/com/smartcar/sdk/helpers/AuthHelpers.java index b31daec5..2ab19071 100644 --- a/src/integration/java/com/smartcar/sdk/helpers/AuthHelpers.java +++ b/src/integration/java/com/smartcar/sdk/helpers/AuthHelpers.java @@ -1,7 +1,9 @@ package com.smartcar.sdk.helpers; import com.smartcar.sdk.AuthClient; +import org.openqa.selenium.remote.RemoteWebDriver; import org.openqa.selenium.By; +import org.openqa.selenium.Capabilities; import org.openqa.selenium.WebDriver; import org.openqa.selenium.WebElement; import org.openqa.selenium.chrome.ChromeDriver; @@ -38,6 +40,10 @@ public class AuthHelpers { private static final boolean HEADLESS = System.getenv("CI") != null || System.getenv("HEADLESS") != null; private static final HashMap ENV_VAR_CACHE = new HashMap<>(); + public static String getSeleniumRemoteUrl() { + return safeGetEnv("SELENIUM_REMOTE_URL"); + } + public static String getClientId() { return safeGetEnv("E2E_SMARTCAR_CLIENT_ID"); } @@ -62,6 +68,21 @@ public static WebDriver setupDriver() { String browser = getBrowser(); WebDriver driver; + if (getSeleniumRemoteUrl() != null) { + Capabilities capabilities = "chrome".equalsIgnoreCase(browser) + ? new ChromeOptions() + : new FirefoxOptions(); + try { + driver = new RemoteWebDriver( + java.net.URI.create(getSeleniumRemoteUrl()).toURL(), + capabilities + ); + } catch (MalformedURLException e) { + throw new RuntimeException("Invalid SELENIUM_REMOTE_URL", e); + } + return driver; + } + if ("chrome".equalsIgnoreCase(browser)) { ChromeOptions options = new ChromeOptions(); if (HEADLESS) { @@ -74,7 +95,7 @@ public static WebDriver setupDriver() { if (HEADLESS) { options.addArguments("--headless"); } - + // Set Firefox binary path if available (for CI environments) String firefoxPath = System.getenv("FIREFOX_BINARY_PATH"); if (firefoxPath == null) { @@ -85,7 +106,7 @@ public static WebDriver setupDriver() { "/usr/bin/firefox", "/usr/lib/firefox/firefox" }; - + for (String path : candidatePaths) { if (new File(path).exists()) { firefoxPath = path; @@ -97,7 +118,7 @@ public static WebDriver setupDriver() { System.out.println("Using Firefox binary: " + firefoxPath); options.setBinary(firefoxPath); } - + driver = new FirefoxDriver(options); } From d67ea56aee4e9999451509c56e75eb9a550897b0 Mon Sep 17 00:00:00 2001 From: Edward Kelly Date: Mon, 12 Jan 2026 14:45:43 +0000 Subject: [PATCH 2/4] feat: Make redirectUri and scope optional --- README.md | 5 +- README.mdt | 5 +- .../java/com/smartcar/sdk/AuthClient.java | 63 ++++++++++++------- .../java/com/smartcar/sdk/AuthClientTest.java | 53 +++++++++------- 4 files changed, 75 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 00143397..63a69fea 100644 --- a/README.md +++ b/README.md @@ -51,19 +51,16 @@ a valid access token for the target vehicle. // Setup String clientId = ""; String clientSecret = ""; - String redirectUri = ""; - String[] scope = {}; String mode = "test"; // Initialize a new AuthClient with your credentials. AuthClient authClient = new AuthClient.Builder .clientId(clientId) .clientSecret(clientSecret) - .redirectUri(redirectUri) .mode(mode); // Retrieve the auth URL to start the OAuth flow. - String authUrl = authClient.authUrlBuilder(scope) + String authUrl = authClient.authUrlBuilder() .setApprovalPrompt(true) .setState("some state") .build(); diff --git a/README.mdt b/README.mdt index 38856513..341b854a 100644 --- a/README.mdt +++ b/README.mdt @@ -51,19 +51,16 @@ a valid access token for the target vehicle. // Setup String clientId = ""; String clientSecret = ""; - String redirectUri = ""; - String[] scope = {}; String mode = "test"; // Initialize a new AuthClient with your credentials. AuthClient authClient = new AuthClient.Builder .clientId(clientId) .clientSecret(clientSecret) - .redirectUri(redirectUri) .mode(mode); // Retrieve the auth URL to start the OAuth flow. - String authUrl = authClient.authUrlBuilder(scope) + String authUrl = authClient.authUrlBuilder() .setApprovalPrompt(true) .setState("some state") .build(); diff --git a/src/main/java/com/smartcar/sdk/AuthClient.java b/src/main/java/com/smartcar/sdk/AuthClient.java index dc685c0d..2048f00d 100644 --- a/src/main/java/com/smartcar/sdk/AuthClient.java +++ b/src/main/java/com/smartcar/sdk/AuthClient.java @@ -4,8 +4,6 @@ import okhttp3.*; import java.util.*; -import java.util.stream.Collectors; -import java.util.stream.Stream; /** Smartcar OAuth 2.0 Authentication Client */ public class AuthClient { @@ -24,12 +22,16 @@ public static class Builder { private String clientSecret; private String redirectUri; private String mode; - private final Set validModes = Stream.of("test", "live", "simulated").collect(Collectors.toSet()); public Builder() { this.clientId = System.getenv("SMARTCAR_CLIENT_ID"); this.clientSecret = System.getenv("SMARTCAR_CLIENT_SECRET"); - this.redirectUri = System.getenv("SMARTCAR_REDIRECT_URI"); + String redirectUri = System.getenv("SMARTCAR_REDIRECT_URI"); + if (redirectUri != null && !redirectUri.equals("")) { + this.redirectUri = redirectUri; + } else { + this.redirectUri = null; + } this.mode = "live"; } @@ -58,11 +60,7 @@ public Builder testMode(boolean testMode) { } public Builder mode(String mode) throws Exception { - if (!this.validModes.contains(mode)) { - throw new Exception( - "The \"mode\" parameter MUST be one of the following: \"test\", \"live\", \"simulated\"" - ); - } + Utils.validateMode(mode); this.mode = mode; return this; @@ -76,9 +74,6 @@ public AuthClient build() throws Exception { if (this.clientSecret == null) { throw new Exception("clientSecret must be defined"); } - if (this.redirectUri == null) { - throw new Exception("redirectUri must be defined"); - } return new AuthClient(this); } } @@ -90,6 +85,10 @@ private AuthClient(Builder builder) { this.mode = builder.mode; } + public AuthUrlBuilder authUrlBuilder() { + return new AuthUrlBuilder(); + } + /** * Creates an AuthUrlBuilder * @@ -105,22 +104,41 @@ public AuthUrlBuilder authUrlBuilder(String[] scope) { */ public class AuthUrlBuilder { private HttpUrl.Builder urlBuilder; - private String mode = AuthClient.this.mode; private List flags = new ArrayList<>(); + public AuthUrlBuilder() { + String origin = System.getenv("SMARTCAR_CONNECT_ORIGIN"); + if (origin == null) { + origin = "https://connect.smartcar.com"; + } + urlBuilder = createUrlBuilder(); + } + public AuthUrlBuilder(String[] scope) { String origin = System.getenv("SMARTCAR_CONNECT_ORIGIN"); if (origin == null) { origin = "https://connect.smartcar.com"; } - urlBuilder = + urlBuilder = createUrlBuilder() + .addQueryParameter("scope", Utils.join(scope, " ")); + } + + private HttpUrl.Builder createUrlBuilder() { + String origin = System.getenv("SMARTCAR_CONNECT_ORIGIN"); + if (origin == null) { + origin = "https://connect.smartcar.com"; + } + HttpUrl.Builder urlBuilder = HttpUrl.parse(origin + "/oauth/authorize") .newBuilder() .addQueryParameter("response_type", "code") .addQueryParameter("client_id", AuthClient.this.clientId) - .addQueryParameter("redirect_uri", AuthClient.this.redirectUri) - .addQueryParameter("mode", AuthClient.this.mode) - .addQueryParameter("scope", Utils.join(scope, " ")); + .addQueryParameter("mode", AuthClient.this.mode); + + Optional.ofNullable(AuthClient.this.redirectUri) + .ifPresent(uri -> urlBuilder.addQueryParameter("redirect_uri", uri)); + + return urlBuilder; } public AuthUrlBuilder state(String state) { @@ -230,12 +248,15 @@ public Auth exchangeCode(String code) throws SmartcarException { * @throws SmartcarException when the request is unsuccessful */ public Auth exchangeCode(String code, SmartcarAuthOptions options) throws SmartcarException { - RequestBody requestBody = + FormBody.Builder requestBodyBuilder = new FormBody.Builder() .add("grant_type", "authorization_code") - .add("code", code) - .add("redirect_uri", this.redirectUri) - .build(); + .add("code", code); + + Optional.ofNullable(this.redirectUri) + .ifPresent(uri -> requestBodyBuilder.add("redirect_uri", this.redirectUri)); + + RequestBody requestBody = requestBodyBuilder.build(); return this.getTokens(requestBody, options); } diff --git a/src/test/java/com/smartcar/sdk/AuthClientTest.java b/src/test/java/com/smartcar/sdk/AuthClientTest.java index 2e9f4fc7..8c56b970 100644 --- a/src/test/java/com/smartcar/sdk/AuthClientTest.java +++ b/src/test/java/com/smartcar/sdk/AuthClientTest.java @@ -38,13 +38,7 @@ public class AuthClientTest extends PowerMockTestCase { private final String sampleClientSecret = "24d55382-843f-4ce9-a7a7-cl13nts3cr3t"; private final String sampleRedirectUri = "https://example.com/"; private final String expectedRequestId = "67127d3a-a08a-41f0-8211-f96da36b2d6e"; - private final String sampleRedirectUriEncoded = "https%3A%2F%2Fexample.com%2F"; private final String[] sampleScope = {"read_vehicle_info", "read_location", "read_odometer"}; - private final boolean sampleTestMode = true; - - // Sample AuthClient.getAuthUrl Args - private final String sampleState = "s4mpl3st4t3"; - private final boolean sampleForcePrompt = true; // Sample AuthClient.exchangeCode Arg private final String sampleCode = ""; @@ -52,16 +46,6 @@ public class AuthClientTest extends PowerMockTestCase { // Sample AuthClient.exchangeRefreshToken Arg private final String sampleRefreshToken = ""; - // Fake Auth Data - private String fakeAccessToken = "F4K3_4CC355_T0K3N"; - private String fakeRefreshToken = "F4K3_R3FR35H_T0K3N"; - private Date fakeExpiration = new Date(); - private Date fakeRefreshExpiration = new Date(); - - // Subject Under Test - private AuthClient subject; - - private JsonElement loadJsonResource(String resourceName) throws FileNotFoundException { String fileName = String.format("src/test/resources/%s.json", resourceName); return JsonParser.parseReader(new FileReader(fileName)); @@ -189,7 +173,7 @@ public void testAuthUrlBuilderDefault() throws Exception { AuthClient client = new AuthClient.Builder().build(); String authUrl = client.authUrlBuilder(this.sampleScope).build(); - Assert.assertEquals(authUrl, "https://connect.smartcar.com/oauth/authorize?response_type=code&client_id=cl13nt1d-t35t-46dc-aa25-bdd042f54e7d&redirect_uri=https%3A%2F%2Fexample.com%2F&mode=live&scope=read_vehicle_info%20read_location%20read_odometer"); + Assert.assertEquals(authUrl, "https://connect.smartcar.com/oauth/authorize?response_type=code&client_id=cl13nt1d-t35t-46dc-aa25-bdd042f54e7d&mode=live&redirect_uri=https%3A%2F%2Fexample.com%2F&scope=read_vehicle_info%20read_location%20read_odometer"); } @Test @@ -210,7 +194,7 @@ public void testAuthUrlBuilderWithOptions() throws Exception { .addFlag("test", true) .addUser("709ed6e4-cc69-4ae0-a385-6e0cf8efba8a") .build(); - Assert.assertEquals(authUrl, "https://connect.smartcar.com/oauth/authorize?response_type=code&client_id=cl13nt1d-t35t-46dc-aa25-bdd042f54e7d&redirect_uri=https%3A%2F%2Fexample.com%2F&mode=live&scope=read_vehicle_info%20read_location%20read_odometer&state=sampleState&approval_prompt=force&make=TESLA&single_select=true&single_select=true&single_select_vin=sampleVin&user=709ed6e4-cc69-4ae0-a385-6e0cf8efba8a&flags=foo%3Abar%20test%3Atrue"); + Assert.assertEquals(authUrl, "https://connect.smartcar.com/oauth/authorize?response_type=code&client_id=cl13nt1d-t35t-46dc-aa25-bdd042f54e7d&mode=live&redirect_uri=https%3A%2F%2Fexample.com%2F&scope=read_vehicle_info%20read_location%20read_odometer&state=sampleState&approval_prompt=force&make=TESLA&single_select=true&single_select=true&single_select_vin=sampleVin&user=709ed6e4-cc69-4ae0-a385-6e0cf8efba8a&flags=foo%3Abar%20test%3Atrue"); } @Test @@ -222,8 +206,7 @@ public void testAuthUrlBuilderWithSimulatedMode() throws Exception { AuthClient client = new AuthClient.Builder().mode("simulated").build(); String authUrl = client.authUrlBuilder(this.sampleScope).build(); - - Assert.assertEquals(authUrl, "https://connect.smartcar.com/oauth/authorize?response_type=code&client_id=cl13nt1d-t35t-46dc-aa25-bdd042f54e7d&redirect_uri=https%3A%2F%2Fexample.com%2F&mode=simulated&scope=read_vehicle_info%20read_location%20read_odometer"); + Assert.assertEquals(authUrl, "https://connect.smartcar.com/oauth/authorize?response_type=code&client_id=cl13nt1d-t35t-46dc-aa25-bdd042f54e7d&mode=simulated&redirect_uri=https%3A%2F%2Fexample.com%2F&scope=read_vehicle_info%20read_location%20read_odometer"); } @Test @@ -236,7 +219,33 @@ public void testAuthUrlBuilderWithTestModeParameter() throws Exception { String authUrl = client.authUrlBuilder(this.sampleScope).build(); - Assert.assertEquals(authUrl, "https://connect.smartcar.com/oauth/authorize?response_type=code&client_id=cl13nt1d-t35t-46dc-aa25-bdd042f54e7d&redirect_uri=https%3A%2F%2Fexample.com%2F&mode=test&scope=read_vehicle_info%20read_location%20read_odometer"); + // "https://connect.smartcar.com/oauth/authorize?response_type=code&client_id=cl13nt1d-t35t-46dc-aa25-bdd042f54e7d&mode=test&redirect_uri=https%3A%2F%2Fexample.com%2F&scope=read_vehicle_info%20read_location%20read_odometer" + // "https://connect.smartcar.com/oauth/authorize?response_type=code&client_id=cl13nt1d-t35t-46dc-aa25-bdd042f54e7d&redirect_uri=https%3A%2F%2Fexample.com%2F&mode=test&scope=read_vehicle_info%20read_location%20read_odometer" + Assert.assertEquals(authUrl, "https://connect.smartcar.com/oauth/authorize?response_type=code&client_id=cl13nt1d-t35t-46dc-aa25-bdd042f54e7d&mode=test&redirect_uri=https%3A%2F%2Fexample.com%2F&scope=read_vehicle_info%20read_location%20read_odometer"); + } + + @Test + public void testAuthUrlBuilderWithoutRedirectUri() throws Exception { + PowerMockito.mockStatic(System.class); + PowerMockito.when(System.getenv("SMARTCAR_CLIENT_ID")).thenReturn(this.sampleClientId); + PowerMockito.when(System.getenv("SMARTCAR_CLIENT_SECRET")).thenReturn(this.sampleClientSecret); + + AuthClient client = new AuthClient.Builder().build(); + String authUrl = client.authUrlBuilder(this.sampleScope).build(); + + Assert.assertEquals(authUrl, "https://connect.smartcar.com/oauth/authorize?response_type=code&client_id=cl13nt1d-t35t-46dc-aa25-bdd042f54e7d&mode=live&scope=read_vehicle_info%20read_location%20read_odometer"); + } + + @Test + public void testAuthUrlBuilderWithoutScope() throws Exception { + PowerMockito.mockStatic(System.class); + PowerMockito.when(System.getenv("SMARTCAR_CLIENT_ID")).thenReturn(this.sampleClientId); + PowerMockito.when(System.getenv("SMARTCAR_CLIENT_SECRET")).thenReturn(this.sampleClientSecret); + + AuthClient client = new AuthClient.Builder().build(); + String authUrl = client.authUrlBuilder().build(); + + Assert.assertEquals(authUrl, "https://connect.smartcar.com/oauth/authorize?response_type=code&client_id=cl13nt1d-t35t-46dc-aa25-bdd042f54e7d&mode=live"); } @Test @@ -248,7 +257,7 @@ public void testAuthUrlBuilderWithInvaildMode() { boolean thrown = false; try{ - AuthClient client = new AuthClient.Builder().mode("invalid").build(); + new AuthClient.Builder().mode("invalid").build(); } catch (Exception e) { thrown = true; Assert.assertEquals(e.getMessage(), "The \"mode\" parameter MUST be one of the following: \"test\", \"live\", \"simulated\""); From a372027b6f9213eb7d747e52ea8b25de31bf339e Mon Sep 17 00:00:00 2001 From: Edward Kelly Date: Mon, 12 Jan 2026 15:02:52 +0000 Subject: [PATCH 3/4] feat: Add support for CompatibilityMatrix --- .../java/com/smartcar/sdk/SmartcarTest.java | 30 ++++ src/main/java/com/smartcar/sdk/ApiClient.java | 2 + src/main/java/com/smartcar/sdk/Smartcar.java | 50 +++++++ .../SmartcarCompatibilityMatrixRequest.java | 135 ++++++++++++++++++ .../sdk/SmartcarCompatibilityRequest.java | 10 +- .../smartcar/sdk/SmartcarVehicleRequest.java | 4 +- src/main/java/com/smartcar/sdk/Utils.java | 14 ++ .../java/com/smartcar/sdk/data/ApiData.java | 3 - .../sdk/data/CompatibilityMatrix.java | 115 +++++++++++++++ .../CompatibilityMatrixDeserializer.java | 37 +++++ .../java/com/smartcar/sdk/SmartcarTest.java | 34 +++++ 11 files changed, 420 insertions(+), 14 deletions(-) create mode 100644 src/main/java/com/smartcar/sdk/SmartcarCompatibilityMatrixRequest.java create mode 100644 src/main/java/com/smartcar/sdk/data/CompatibilityMatrix.java create mode 100644 src/main/java/com/smartcar/sdk/deserializer/CompatibilityMatrixDeserializer.java diff --git a/src/integration/java/com/smartcar/sdk/SmartcarTest.java b/src/integration/java/com/smartcar/sdk/SmartcarTest.java index 33025c09..bf9f60ab 100644 --- a/src/integration/java/com/smartcar/sdk/SmartcarTest.java +++ b/src/integration/java/com/smartcar/sdk/SmartcarTest.java @@ -85,6 +85,36 @@ public void testGetCompatibility() throws Exception { Assert.assertFalse((capable)); } + @Test + public void testGetCompatibilityMatrix() throws Exception { + String[] scope = {"read_battery", "read_charge"}; + + SmartcarCompatibilityMatrixRequest request = new SmartcarCompatibilityMatrixRequest.Builder() + .clientId(this.clientId) + .clientSecret(this.clientSecret) + .make("NISSAN") + .type("BEV") + .scope(scope) + .build(); + CompatibilityMatrix matrix = Smartcar.getCompatibilityMatrix(request); + Map> results = matrix.getResults(); + Assert.assertTrue(results.size() > 0); + for (Map.Entry> entry : results.entrySet()) { + for (CompatibilityMatrix.CompatibilityEntry result : entry.getValue()) { + Assert.assertNotNull(result.getModel()); + Assert.assertNotNull(result.getStartYear()); + Assert.assertNotNull(result.getEndYear()); + Assert.assertNotNull(result.getType()); + Assert.assertNotNull(result.getEndpoints()); + Assert.assertNotNull(result.getPermissions()); + + Assert.assertEquals(result.getType(), "BEV"); + List permissions = Arrays.asList(result.getPermissions()); + Assert.assertTrue(permissions.containsAll(Arrays.asList(scope))); + } + } + } + // TODO uncomment when test mode connections are returned // @Test // public void testGetConnections() throws SmartcarException { diff --git a/src/main/java/com/smartcar/sdk/ApiClient.java b/src/main/java/com/smartcar/sdk/ApiClient.java index 2143b609..0bb80f72 100644 --- a/src/main/java/com/smartcar/sdk/ApiClient.java +++ b/src/main/java/com/smartcar/sdk/ApiClient.java @@ -10,6 +10,7 @@ import com.smartcar.sdk.data.*; import com.smartcar.sdk.deserializer.AuthDeserializer; import com.smartcar.sdk.deserializer.BatchDeserializer; +import com.smartcar.sdk.deserializer.CompatibilityMatrixDeserializer; import com.smartcar.sdk.deserializer.VehicleResponseDeserializer; import okhttp3.*; @@ -55,6 +56,7 @@ private static String getSdkVersion() { .setFieldNamingStrategy(field -> Utils.toCamelCase(field.getName())) .registerTypeAdapter(Auth.class, new AuthDeserializer()) .registerTypeAdapter(BatchResponse.class, new BatchDeserializer()) + .registerTypeAdapter(CompatibilityMatrix.class, new CompatibilityMatrixDeserializer()) .registerTypeAdapter(VehicleResponse.class, new VehicleResponseDeserializer()) .create(); diff --git a/src/main/java/com/smartcar/sdk/Smartcar.java b/src/main/java/com/smartcar/sdk/Smartcar.java index dc6f4eef..923c4fdf 100644 --- a/src/main/java/com/smartcar/sdk/Smartcar.java +++ b/src/main/java/com/smartcar/sdk/Smartcar.java @@ -17,6 +17,7 @@ import java.util.Date; import java.util.HashMap; import java.util.Map; +import java.util.Optional; public class Smartcar { public static String API_VERSION = "2.0"; @@ -177,6 +178,55 @@ public static Compatibility getCompatibility(SmartcarCompatibilityRequest compat return ApiClient.execute(request, Compatibility.class); } + /** + * Retrieves the compatibility matrix for a given region. Provides the ability to filter by + * scope, make and type. + * + * A compatible vehicle is a vehicle that: + *
    + *
  1. has the hardware required for internet connectivity, + *
  2. belongs to the makes and models Smartcar supports, and + *
  3. supports the permissions. + *
+ * + * To use this function, please contact us! + * + * @param compatibilityMatrixRequest with options for this request. See Smartcar.SmartcarCompatibilityMatrixRequest + * @return A CompatibilityMatrix object with the compatibility information. + * @throws SmartcarException when the request is unsuccessful + */ + public static CompatibilityMatrix getCompatibilityMatrix(SmartcarCompatibilityMatrixRequest compatibilityMatrixRequest) throws SmartcarException { + String apiUrl = Smartcar.getApiOrigin(); + HttpUrl.Builder urlBuilder = + HttpUrl.parse(apiUrl) + .newBuilder() + .addPathSegment("v" + compatibilityMatrixRequest.getVersion()) + .addPathSegment("compatibility") + .addPathSegment("matrix"); + + Optional.ofNullable(compatibilityMatrixRequest.getMake()) + .ifPresent(make -> urlBuilder.addQueryParameter("make", make)); + Optional.ofNullable(compatibilityMatrixRequest.getMode()) + .ifPresent(mode -> urlBuilder.addQueryParameter("mode", mode)); + Optional.ofNullable(compatibilityMatrixRequest.getRegion()) + .ifPresent(region -> urlBuilder.addQueryParameter("region", region)); + Optional.ofNullable(compatibilityMatrixRequest.getScope()) + .ifPresent(scope -> urlBuilder.addQueryParameter("scope", String.join(" ", scope))); + Optional.ofNullable(compatibilityMatrixRequest.getType()) + .ifPresent(type -> urlBuilder.addQueryParameter("type", type)); + + HttpUrl url = urlBuilder.build(); + + Map headers = new HashMap<>(); + headers.put("Authorization", Credentials.basic( + compatibilityMatrixRequest.getClientId(), + compatibilityMatrixRequest.getClientSecret() + )); + Request request = ApiClient.buildRequest(url, "GET", null, headers); + + return ApiClient.execute(request, CompatibilityMatrix.class); + } + /** * Performs a HmacSHA256 hash on a challenge string using the key provided * diff --git a/src/main/java/com/smartcar/sdk/SmartcarCompatibilityMatrixRequest.java b/src/main/java/com/smartcar/sdk/SmartcarCompatibilityMatrixRequest.java new file mode 100644 index 00000000..c51d117f --- /dev/null +++ b/src/main/java/com/smartcar/sdk/SmartcarCompatibilityMatrixRequest.java @@ -0,0 +1,135 @@ +package com.smartcar.sdk; + +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Class encompassing optional arguments for Smartcar compatibility matrix requests + */ +public final class SmartcarCompatibilityMatrixRequest { + private final String clientId; + private final String clientSecret; + private final String make; + private final String mode; + private final String region; + private final String[] scope; + private final String type; + private final String version; + + public static class Builder { + private String clientId; + private String clientSecret; + private String make; + private String mode; + private String region; + private List scope; + private String type; + private String version; + + public Builder() { + this.clientId = System.getenv("SMARTCAR_CLIENT_ID"); + this.clientSecret = System.getenv("SMARTCAR_CLIENT_SECRET"); + this.mode = null; + this.make = null; + this.region = "US"; + this.scope = null; + this.type = null; + this.version = Smartcar.API_VERSION; + } + + public Builder clientId(String clientId) { + this.clientId = clientId; + return this; + } + + public Builder clientSecret(String clientSecret) { + this.clientSecret = clientSecret; + return this; + } + + public Builder make(String make) { + this.make = make; + return this; + } + + public Builder mode(String mode) throws Exception { + Utils.validateMode(mode); + this.mode = mode; + return this; + } + + public Builder region(String region) { + this.region = region; + return this; + } + + public Builder scope(String[] scope) { + this.scope = Arrays.asList(scope); + return this; + } + + public Builder scope(Collection scope) { + this.scope = scope.stream().collect(Collectors.toList()); + return this; + } + + public Builder type(String type) { + this.type = type; + return this; + } + + public Builder version(String version) { + this.version = version; + return this; + } + + public SmartcarCompatibilityMatrixRequest build() { + return new SmartcarCompatibilityMatrixRequest(this); + } + } + + private SmartcarCompatibilityMatrixRequest(Builder builder) { + this.clientId = builder.clientId; + this.clientSecret = builder.clientSecret; + this.make = builder.make; + this.mode = builder.mode; + this.region = builder.region; + this.scope = builder.scope != null ? builder.scope.toArray(new String[0]) : null; + this.type = builder.type; + this.version = builder.version; + } + + public String getClientId() { + return this.clientId; + } + + public String getClientSecret() { + return this.clientSecret; + } + + public String getMake() { + return this.make; + } + + public String getMode() { + return this.mode; + } + + public String getRegion() { + return this.region; + } + + public String[] getScope() { + return this.scope; + } + + public String getType() { + return this.type; + } + + public String getVersion() { + return this.version; + } +} diff --git a/src/main/java/com/smartcar/sdk/SmartcarCompatibilityRequest.java b/src/main/java/com/smartcar/sdk/SmartcarCompatibilityRequest.java index b6b7f340..c9028def 100644 --- a/src/main/java/com/smartcar/sdk/SmartcarCompatibilityRequest.java +++ b/src/main/java/com/smartcar/sdk/SmartcarCompatibilityRequest.java @@ -2,9 +2,6 @@ import java.util.ArrayList; import java.util.List; -import java.util.Set; -import java.util.stream.Collectors; -import java.util.stream.Stream; /** * Class encompassing optional arguments for Smartcar compatibility requests @@ -30,7 +27,6 @@ public static class Builder { private String clientSecret; private String mode; private String testModeCompatibilityLevel; - private final Set validModes = Stream.of("test", "live", "simulated").collect(Collectors.toSet()); public Builder() { this.vin = ""; @@ -94,11 +90,7 @@ public Builder testMode(boolean testMode) { } public Builder mode(String mode) throws Exception { - if (!this.validModes.contains(mode)) { - throw new Exception( - "The \"mode\" parameter MUST be one of the following: \"test\", \"live\", \"simulated\"" - ); - } + Utils.validateMode(mode); this.mode = mode; return this; diff --git a/src/main/java/com/smartcar/sdk/SmartcarVehicleRequest.java b/src/main/java/com/smartcar/sdk/SmartcarVehicleRequest.java index 3cb08edf..13da806a 100644 --- a/src/main/java/com/smartcar/sdk/SmartcarVehicleRequest.java +++ b/src/main/java/com/smartcar/sdk/SmartcarVehicleRequest.java @@ -131,8 +131,8 @@ private SmartcarVehicleRequest(Builder builder) { this.body = jsonBody.isEmpty() ? null : RequestBody.create(ApiClient.JSON, jsonBody.toString()); - // Shallow clone of headers Map - this.headers = (HashMap) ((HashMap) builder.headers).clone(); + // Shallow copy of headers Map + this.headers = new HashMap<>(builder.headers); if (builder.flags.size() > 0) { String[] flagStrings = builder.flags.toArray(new String[0]); diff --git a/src/main/java/com/smartcar/sdk/Utils.java b/src/main/java/com/smartcar/sdk/Utils.java index 8286ac8c..3deb2425 100644 --- a/src/main/java/com/smartcar/sdk/Utils.java +++ b/src/main/java/com/smartcar/sdk/Utils.java @@ -1,9 +1,15 @@ package com.smartcar.sdk; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + import org.apache.commons.text.CaseUtils; /** General package utilities. */ public class Utils { + private static final Set VALID_MODES = Stream.of("test", "live", "simulated") + .collect(Collectors.toSet()); /** * Joins the elements of a string array together, delimited by a separator. * @@ -31,4 +37,12 @@ public static String toCamelCase(String fieldName) { } return fieldName; } + + public static void validateMode(String mode) throws Exception{ + if (!VALID_MODES.contains(mode)) { + throw new Exception( + "The \"mode\" parameter MUST be one of the following: \"test\", \"live\", \"simulated\"" + ); + } + } } diff --git a/src/main/java/com/smartcar/sdk/data/ApiData.java b/src/main/java/com/smartcar/sdk/data/ApiData.java index 44073fb6..f52502c0 100644 --- a/src/main/java/com/smartcar/sdk/data/ApiData.java +++ b/src/main/java/com/smartcar/sdk/data/ApiData.java @@ -4,9 +4,6 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; import java.io.Serializable; -import java.lang.reflect.Type; - -import okhttp3.Response; /** The base object representing parsed API response data. */ public class ApiData implements Serializable { diff --git a/src/main/java/com/smartcar/sdk/data/CompatibilityMatrix.java b/src/main/java/com/smartcar/sdk/data/CompatibilityMatrix.java new file mode 100644 index 00000000..5b3d0506 --- /dev/null +++ b/src/main/java/com/smartcar/sdk/data/CompatibilityMatrix.java @@ -0,0 +1,115 @@ +package com.smartcar.sdk.data; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** A container for the compatibility matrix endpoint */ +public class CompatibilityMatrix extends ApiData { + private Map> makeCompatibilityMap; + + public class CompatibilityEntry extends ApiData { + private String model; + private String startYear; + private String endYear; + private String type; + private String[] endpoints; + private String[] permissions; + + /** + * Returns the model + * @return model + */ + public String getModel() { + return model; + } + + /** + * Returns the start year + * @return startYear + */ + public String getStartYear() { + return startYear; + } + + /** + * Returns the end year + * @return endYear + */ + public String getEndYear() { + return endYear; + } + + /** + * Returns the type + * @return type + */ + public String getType() { + return type; + } + + /** + * Returns the endpoints + * @return endpoints + */ + public String[] getEndpoints() { + return endpoints; + } + + /** + * Returns the permissions + * @return permissions + */ + public String[] getPermissions() { + return permissions; + } + + /** + * Return the string representation + * + * @return String representation + */ + @Override + public String toString() { + return "CompatibilityEntry{" + + "model='" + model + '\'' + + ", startYear='" + startYear + '\'' + + ", endYear='" + endYear + '\'' + + ", type='" + type + '\'' + + ", endpoints=" + String.join(",", endpoints) + + ", permissions=" + String.join(",", permissions) + + '}'; + } + } + + /** + * Returns the make compatibility map + * @return makeCompatibilityMap + */ + public Map> getResults() { + if (makeCompatibilityMap == null) { + return Collections.emptyMap(); + } + return makeCompatibilityMap; + } + + /** + * Sets the make compatibility map + * @param makeCompatibilityMap makeCompatibilityMap + */ + public void setMakeCompatibilityMap(Map> makeCompatibilityMap) { + this.makeCompatibilityMap = makeCompatibilityMap; + } + + /** + * Return the string representation + * + * @return String representation + */ + @Override + public String toString() { + return "CompatibilityMatrix{" + + "makeCompatibilityMap=" + makeCompatibilityMap + + '}'; + } +} diff --git a/src/main/java/com/smartcar/sdk/deserializer/CompatibilityMatrixDeserializer.java b/src/main/java/com/smartcar/sdk/deserializer/CompatibilityMatrixDeserializer.java new file mode 100644 index 00000000..6c96396a --- /dev/null +++ b/src/main/java/com/smartcar/sdk/deserializer/CompatibilityMatrixDeserializer.java @@ -0,0 +1,37 @@ +package com.smartcar.sdk.deserializer; + +import java.lang.reflect.Type; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.reflect.TypeToken; +import com.smartcar.sdk.data.CompatibilityMatrix; + +public class CompatibilityMatrixDeserializer implements JsonDeserializer { + @Override + public CompatibilityMatrix deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) + throws JsonParseException { + JsonObject jsonObject = json.getAsJsonObject(); + CompatibilityMatrix compatibilityMatrix = new CompatibilityMatrix(); + Set makes = jsonObject.keySet(); + Map> makeCompatibilityMap = new HashMap<>(makes.size()); + makes.forEach(make -> { + JsonElement makeElement = jsonObject.get(make); + Type compatibilityEntriesType = new TypeToken>() {}.getType(); + List compatibilityEntries = + context.deserialize(makeElement, compatibilityEntriesType); + makeCompatibilityMap.put(make, compatibilityEntries); + }); + + compatibilityMatrix.setMakeCompatibilityMap(makeCompatibilityMap); + return compatibilityMatrix; + } + +} diff --git a/src/test/java/com/smartcar/sdk/SmartcarTest.java b/src/test/java/com/smartcar/sdk/SmartcarTest.java index c8d0bec7..9d5d4724 100644 --- a/src/test/java/com/smartcar/sdk/SmartcarTest.java +++ b/src/test/java/com/smartcar/sdk/SmartcarTest.java @@ -1,6 +1,7 @@ package com.smartcar.sdk; import com.smartcar.sdk.data.Compatibility; +import com.smartcar.sdk.data.CompatibilityMatrix; import com.smartcar.sdk.data.User; import com.smartcar.sdk.data.VehicleIds; import okhttp3.mockwebserver.MockResponse; @@ -194,6 +195,39 @@ public void testGetCompatibilityWithoutRequiredOptions() { Assert.assertTrue(thrown); } + @Test + @PrepareForTest(System.class) + public void testGetCompatibilityMatrix() { + PowerMockito.mockStatic(System.class); + PowerMockito.when(System.getenv("SMARTCAR_API_ORIGIN")).thenReturn( + "http://localhost:" + TestExecutionListener.mockWebServer.getPort() + ); + + MockResponse response = new MockResponse() + .setBody("{}") + .addHeader("sc-request-id", this.sampleRequestId); + TestExecutionListener.mockWebServer.enqueue(response); + + SmartcarCompatibilityMatrixRequest compatibilityMatrixRequest = new SmartcarCompatibilityMatrixRequest.Builder() + .clientId(this.sampleClientId) + .clientSecret(this.sampleClientSecret) + .build(); + + try { + CompatibilityMatrix matrix = Smartcar.getCompatibilityMatrix(compatibilityMatrixRequest); + Assert.assertNotNull(matrix); + Assert.assertEquals(matrix.getResults().size(), 0); + Assert.assertEquals(matrix.getMeta().getRequestId(), this.sampleRequestId); + } catch (SmartcarException e) { + Assert.fail("Exception thrown during getCompatibilityMatrix: " + e.getMessage()); + } + try { + TestExecutionListener.mockWebServer.takeRequest(); + } catch (InterruptedException e) { + Assert.fail("Request was not made to mock server"); + } + } + /** * Tests setting the api version to 2.0 and getting the api url that is used for subsequent * requests From f10b56d670e5b7a89100a3a49375edf4994153de Mon Sep 17 00:00:00 2001 From: Edward Kelly Date: Mon, 12 Jan 2026 15:39:17 +0000 Subject: [PATCH 4/4] feat: V3 Support --- LICENSE.md | 2 +- ...Test.java => SmartcarIntegrationTest.java} | 19 ++- .../smartcar/sdk/VehicleIntegrationTest.java | 49 ++++++ src/main/java/com/smartcar/sdk/ApiClient.java | 38 +++-- src/main/java/com/smartcar/sdk/Smartcar.java | 21 +++ .../smartcar/sdk/SmartcarVehicleOptions.java | 13 ++ src/main/java/com/smartcar/sdk/Vehicle.java | 80 +++++++--- src/main/java/com/smartcar/sdk/data/Meta.java | 27 +++- src/main/java/com/smartcar/sdk/data/User.java | 7 - .../smartcar/sdk/data/VehicleChargeLimit.java | 1 - .../com/smartcar/sdk/data/v3/JsonApiData.java | 36 +++++ .../java/com/smartcar/sdk/data/v3/Signal.java | 40 +++++ .../com/smartcar/sdk/data/v3/Signals.java | 47 ++++++ .../sdk/data/v3/VehicleAttributes.java | 45 ++++++ .../deserializer/v3/JsonApiDeserializer.java | 74 ++++++++++ .../deserializer/v3/SignalsDeserializer.java | 37 +++++ .../java/com/smartcar/sdk/SmartcarTest.java | 31 +++- .../smartcar/sdk/TestExecutionListener.java | 1 + .../java/com/smartcar/sdk/VehicleTest.java | 69 ++++++++- src/test/resources/GetVehicle.json | 12 ++ src/test/resources/SignalV3.json | 23 +++ src/test/resources/SignalsV3.json | 139 ++++++++++++++++++ 22 files changed, 760 insertions(+), 51 deletions(-) rename src/integration/java/com/smartcar/sdk/{SmartcarTest.java => SmartcarIntegrationTest.java} (89%) create mode 100644 src/main/java/com/smartcar/sdk/data/v3/JsonApiData.java create mode 100644 src/main/java/com/smartcar/sdk/data/v3/Signal.java create mode 100644 src/main/java/com/smartcar/sdk/data/v3/Signals.java create mode 100644 src/main/java/com/smartcar/sdk/data/v3/VehicleAttributes.java create mode 100644 src/main/java/com/smartcar/sdk/deserializer/v3/JsonApiDeserializer.java create mode 100644 src/main/java/com/smartcar/sdk/deserializer/v3/SignalsDeserializer.java create mode 100644 src/test/resources/GetVehicle.json create mode 100644 src/test/resources/SignalV3.json create mode 100644 src/test/resources/SignalsV3.json diff --git a/LICENSE.md b/LICENSE.md index 18e74892..63689957 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 Smartcar, Inc. +Copyright (c) 2026 Smartcar, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/src/integration/java/com/smartcar/sdk/SmartcarTest.java b/src/integration/java/com/smartcar/sdk/SmartcarIntegrationTest.java similarity index 89% rename from src/integration/java/com/smartcar/sdk/SmartcarTest.java rename to src/integration/java/com/smartcar/sdk/SmartcarIntegrationTest.java index bf9f60ab..595f90c0 100644 --- a/src/integration/java/com/smartcar/sdk/SmartcarTest.java +++ b/src/integration/java/com/smartcar/sdk/SmartcarIntegrationTest.java @@ -1,6 +1,7 @@ package com.smartcar.sdk; import com.smartcar.sdk.data.*; +import com.smartcar.sdk.data.v3.VehicleAttributes; import com.smartcar.sdk.helpers.AuthHelpers; import org.testng.Assert; import org.testng.annotations.BeforeSuite; @@ -8,8 +9,13 @@ import java.util.ArrayList; import java.util.Arrays; +import java.util.List; +import java.util.Map; + +public class SmartcarIntegrationTest { + private static final String V3_VEHICLE_ID = "tst2e255-d3c8-4f90-9fec-e6e68b98e9cb"; + private static final String V3_TEST_TOKEN = "test-data-token"; -public class SmartcarTest { private String accessToken; private VehicleIds vehicleIds; private String clientId; @@ -55,6 +61,17 @@ public void testGetVehiclesPaging() throws SmartcarException { Assert.assertEquals(vehicleIds.getPaging().getOffset(), 0); } + @Test + public void testGetVehicle() throws SmartcarException { + VehicleAttributes vehicle = Smartcar.getVehicle(V3_TEST_TOKEN, V3_VEHICLE_ID); + + Assert.assertNotNull(vehicle); + Assert.assertEquals(vehicle.getId(), V3_VEHICLE_ID); + Assert.assertEquals(vehicle.getMake(), "TESLA"); + Assert.assertEquals(vehicle.getModel(), "Model Y"); + Assert.assertEquals(vehicle.getYear().intValue(), 2021); + } + @Test public void testGetCompatibility() throws Exception { String vin = "5YJSA1E29LF403082"; diff --git a/src/integration/java/com/smartcar/sdk/VehicleIntegrationTest.java b/src/integration/java/com/smartcar/sdk/VehicleIntegrationTest.java index c33f7eaa..e8655720 100644 --- a/src/integration/java/com/smartcar/sdk/VehicleIntegrationTest.java +++ b/src/integration/java/com/smartcar/sdk/VehicleIntegrationTest.java @@ -8,6 +8,9 @@ import javax.json.JsonArrayBuilder; import com.smartcar.sdk.data.*; +import com.smartcar.sdk.data.v3.Signal; +import com.smartcar.sdk.data.v3.Signals; +import com.smartcar.sdk.data.v3.Signals.SignalsMeta; import com.smartcar.sdk.helpers.AuthHelpers; import org.powermock.core.classloader.annotations.PowerMockIgnore; import org.testng.Assert; @@ -20,6 +23,9 @@ */ @PowerMockIgnore("javax.net.ssl.*") public class VehicleIntegrationTest { + private static final String V3_VEHICLE_ID = "tst2e255-d3c8-4f90-9fec-e6e68b98e9cb"; + private static final String V3_TEST_TOKEN = "test-data-token"; + private Vehicle vehicle; private Vehicle eVehicle; @@ -371,4 +377,47 @@ public void testSubscribeUnsubscribe() throws SmartcarException { this.vehicle.unsubscribe( AuthHelpers.getApplicationManagementToken(), AuthHelpers.getWebhookId()); } + + @Test + public void testVehicleSignals() throws SmartcarException { + Vehicle vehicle = new Vehicle(V3_VEHICLE_ID, V3_TEST_TOKEN); + Signals signals = vehicle.getSignals(); + Assert.assertNotNull(signals); + Assert.assertTrue(signals.getSignals().size() > 0); + + Meta meta = signals.getMeta(); + Assert.assertTrue(meta instanceof SignalsMeta); + Assert.assertEquals(((SignalsMeta) meta).getTotalCount().intValue(), signals.getSignals().size()); + Assert.assertEquals(((SignalsMeta) meta).getPageSize().intValue(), signals.getSignals().size()); + Assert.assertEquals(((SignalsMeta) meta).getPage().intValue(), 1); + + Assert.assertNotNull(signals.getLinks()); + Assert.assertNotNull(signals.getIncluded()); + + Signal signal = signals.getSignals().stream() + .filter(s -> s.getCode().equals("odometer-traveleddistance")) + .findFirst() + .orElse(null); + Assert.assertNotNull(signal); + Assert.assertNotNull(signal); + Assert.assertEquals(signal.getCode(), "odometer-traveleddistance"); + Assert.assertNotNull(signal.getBody()); + Assert.assertNotNull(signal.getStatus()); + + Meta signalMeta = signal.getMeta(); + Assert.assertNotNull(signalMeta); + Assert.assertNotNull(signalMeta.getRetrievedAt()); + Assert.assertNotNull(signalMeta.getOemUpdatedAt()); + } + + @Test + public void testVehicleSignal() throws SmartcarException { + Vehicle vehicle = new Vehicle(V3_VEHICLE_ID, V3_TEST_TOKEN); + Signal signal = vehicle.getSignal("odometer-traveleddistance"); + Assert.assertNotNull(signal); + Assert.assertEquals(signal.getCode(), "odometer-traveleddistance"); + Assert.assertNotNull(signal.getBody()); + Assert.assertNotNull(signal.getStatus()); + Assert.assertNotNull(signal.getMeta()); + } } diff --git a/src/main/java/com/smartcar/sdk/ApiClient.java b/src/main/java/com/smartcar/sdk/ApiClient.java index 0bb80f72..e956bf5e 100644 --- a/src/main/java/com/smartcar/sdk/ApiClient.java +++ b/src/main/java/com/smartcar/sdk/ApiClient.java @@ -8,10 +8,15 @@ import com.google.gson.JsonParser; import com.google.gson.reflect.TypeToken; import com.smartcar.sdk.data.*; +import com.smartcar.sdk.data.v3.Signal; +import com.smartcar.sdk.data.v3.Signals; import com.smartcar.sdk.deserializer.AuthDeserializer; import com.smartcar.sdk.deserializer.BatchDeserializer; import com.smartcar.sdk.deserializer.CompatibilityMatrixDeserializer; import com.smartcar.sdk.deserializer.VehicleResponseDeserializer; +import com.smartcar.sdk.deserializer.v3.JsonApiDeserializer; +import com.smartcar.sdk.deserializer.v3.SignalsDeserializer; + import okhttp3.*; import java.io.IOException; @@ -58,6 +63,9 @@ private static String getSdkVersion() { .registerTypeAdapter(BatchResponse.class, new BatchDeserializer()) .registerTypeAdapter(CompatibilityMatrix.class, new CompatibilityMatrixDeserializer()) .registerTypeAdapter(VehicleResponse.class, new VehicleResponseDeserializer()) + .registerTypeAdapter(Signal.class, new JsonApiDeserializer()) + .registerTypeAdapter(Signals.class, new SignalsDeserializer()) + .registerTypeAdapter(com.smartcar.sdk.data.v3.VehicleAttributes.class, new JsonApiDeserializer()) .create(); private static final Gson GSON_LOWER_CASE_WITH_UNDERSCORES = new GsonBuilder() @@ -67,7 +75,7 @@ private static String getSdkVersion() { /** * Builds a request object with common headers, using provided request * parameters - * + * * @param url url for the request, including the query parameters * @param method http method * @param body request body @@ -118,7 +126,7 @@ protected static Response execute(Request request) throws SmartcarException { * @throws SmartcarException if the request is unsuccessful */ protected static T execute( - Request request, Class dataType) throws SmartcarException { + Request request, Class dataType, String version) throws SmartcarException { Response response = ApiClient.execute(request); T data = null; Meta meta; @@ -130,6 +138,10 @@ protected static T execute( JsonElement jsonElement = JsonParser.parseString(bodyString); if (jsonElement.isJsonArray()) { + // This block is for handling the service history api + // This should be refactored to a specific deserializer if more + // endpoints return arrays at the top level. I can foresee this + // being an issue down the line. Field itemsField = dataType.getDeclaredField("items"); itemsField.setAccessible(true); @@ -152,14 +164,17 @@ protected static T execute( data = GSON_CAMEL_CASE.fromJson(bodyString, dataType); } - Headers headers = response.headers(); - JsonObject headerJson = new JsonObject(); - for (String header : headers.names()) { - headerJson.addProperty(header.toLowerCase(), headers.get(header)); + if (!version.equals("3")) { + Headers headers = response.headers(); + JsonObject headerJson = new JsonObject(); + for (String header : headers.names()) { + headerJson.addProperty(header.toLowerCase(), headers.get(header)); + } + String headerJsonString = headerJson.toString(); + meta = GSON_CAMEL_CASE.fromJson(headerJsonString, Meta.class); + data.setMeta(meta); } - String headerJsonString = headerJson.toString(); - meta = GSON_CAMEL_CASE.fromJson(headerJsonString, Meta.class); - data.setMeta(meta); + return data; } catch (Exception ex) { if (bodyString.equals("")) { @@ -174,4 +189,9 @@ protected static T execute( .build(); } } + + protected static T execute( + Request request, Class dataType) throws SmartcarException { + return ApiClient.execute(request, dataType, "2"); + } } diff --git a/src/main/java/com/smartcar/sdk/Smartcar.java b/src/main/java/com/smartcar/sdk/Smartcar.java index 923c4fdf..a11e0bd7 100644 --- a/src/main/java/com/smartcar/sdk/Smartcar.java +++ b/src/main/java/com/smartcar/sdk/Smartcar.java @@ -4,6 +4,7 @@ import com.smartcar.sdk.data.RequestPaging; import com.smartcar.sdk.data.User; import com.smartcar.sdk.data.VehicleIds; +import com.smartcar.sdk.data.v3.VehicleAttributes; import com.smartcar.sdk.data.*; import okhttp3.Credentials; import okhttp3.HttpUrl; @@ -22,6 +23,7 @@ public class Smartcar { public static String API_VERSION = "2.0"; public static String API_ORIGIN = "https://api.smartcar.com"; + public static String API_V3_ORIGIN = "https://vehicle.api.smartcar.com"; public static String MANAGEMENT_API_ORIGIN = "https://management.smartcar.com"; /** @@ -53,6 +55,14 @@ static String getApiOrigin() { return apiOrigin; } + static String getApiOrigin(String version) { + if (version.equals("3")) { + String apiV3Origin = System.getenv("SMARTCAR_API_V3_ORIGIN"); + return Optional.ofNullable(apiV3Origin).orElse(API_V3_ORIGIN); + } + return getApiOrigin(); + } + /** * @return Smartcar Management API origin */ @@ -120,6 +130,17 @@ public static VehicleIds getVehicles(String accessToken) return Smartcar.getVehicles(accessToken, null); } + public static VehicleAttributes getVehicle(String accessToken, String id) throws SmartcarException { + String apiUrl = Smartcar.getApiOrigin("3"); + HttpUrl url = HttpUrl.parse(apiUrl + "/v3/vehicles/" + id); + + Map headers = new HashMap<>(); + headers.put("Authorization", "Bearer " + accessToken); + Request request = ApiClient.buildRequest(url, "GET", null, headers); + + return ApiClient.execute(request, VehicleAttributes.class); + } + /** * Convenience method for determining if an auth token expiration has passed. * diff --git a/src/main/java/com/smartcar/sdk/SmartcarVehicleOptions.java b/src/main/java/com/smartcar/sdk/SmartcarVehicleOptions.java index 363cf1f6..25771b59 100644 --- a/src/main/java/com/smartcar/sdk/SmartcarVehicleOptions.java +++ b/src/main/java/com/smartcar/sdk/SmartcarVehicleOptions.java @@ -8,18 +8,21 @@ public final class SmartcarVehicleOptions { private final String version; private final Vehicle.UnitSystem unitSystem; private final String origin; + private final String v3Origin; private final String flags; public static class Builder { private String version; private Vehicle.UnitSystem unitSystem; private String origin; + private String v3Origin; private final List flags; public Builder() { this.version = "2.0"; this.unitSystem = Vehicle.UnitSystem.METRIC; this.origin = Smartcar.getApiOrigin(); + this.v3Origin = Smartcar.getApiOrigin("3"); this.flags = new ArrayList<>(); } @@ -48,6 +51,11 @@ public Builder origin(String origin) { return this; } + public Builder v3Origin(String v3Origin) { + this.v3Origin = v3Origin; + return this; + } + public SmartcarVehicleOptions build() { return new SmartcarVehicleOptions(this); } @@ -57,6 +65,7 @@ private SmartcarVehicleOptions(Builder builder) { this.version = builder.version; this.unitSystem = builder.unitSystem; this.origin = builder.origin; + this.v3Origin = builder.v3Origin; if (!builder.flags.isEmpty()) { this.flags = String.join(" ", builder.flags); } else { @@ -79,4 +88,8 @@ public String getFlags() { public String getOrigin() { return this.origin; } + + public String getV3Origin() { + return this.v3Origin; + } } diff --git a/src/main/java/com/smartcar/sdk/Vehicle.java b/src/main/java/com/smartcar/sdk/Vehicle.java index f5027ce0..8d1956a5 100644 --- a/src/main/java/com/smartcar/sdk/Vehicle.java +++ b/src/main/java/com/smartcar/sdk/Vehicle.java @@ -1,6 +1,9 @@ package com.smartcar.sdk; import com.smartcar.sdk.data.*; +import com.smartcar.sdk.data.v3.Signal; +import com.smartcar.sdk.data.v3.Signals; + import okhttp3.HttpUrl; import okhttp3.Request; import okhttp3.RequestBody; @@ -36,6 +39,7 @@ public enum UnitSystem { private Vehicle.UnitSystem unitSystem; private final String version; private final String origin; + private final String v3Origin; private final String flags; private ApplicationPermissions permissions; @@ -51,7 +55,7 @@ public Vehicle(String vehicleId, String accessToken) { /** * Initializes a new Vehicle with provided options - * + * * @param vehicleId vehicleId the vehicle ID * @param accessToken accessToken the OAuth 2.0 access token * @param options optional arguments provided with a SmartcarVehicleOptions @@ -63,12 +67,13 @@ public Vehicle(String vehicleId, String accessToken, SmartcarVehicleOptions opti this.version = options.getVersion(); this.unitSystem = options.getUnitSystem(); this.origin = options.getOrigin(); + this.v3Origin = options.getV3Origin(); this.flags = options.getFlags(); } /** * Gets the version of Smartcar API that this vehicle is using - * + * * @return String representing version */ public String getVersion() { @@ -77,13 +82,20 @@ public String getVersion() { /** * Gets the flags that are passed to the vehicle object as a serialized string - * + * * @return serialized string of the flags */ public String getFlags() { return this.flags; } + protected String getOrigin(String version) { + if (version.equals("3")) { + return this.v3Origin; + } + return this.origin; + } + /** * Executes an API request under the VehicleIds endpoint. * @@ -95,10 +107,10 @@ public String getFlags() { * @throws SmartcarException if the request is unsuccessful */ protected T call( - String path, String method, RequestBody body, String accessToken, Class type) throws SmartcarException { - HttpUrl.Builder urlBuilder = HttpUrl.parse(this.origin) + String path, String version, String method, RequestBody body, String accessToken, Class type) throws SmartcarException { + HttpUrl.Builder urlBuilder = HttpUrl.parse(this.getOrigin(version)) .newBuilder() - .addPathSegments("v" + this.version) + .addPathSegments("v" + version) .addPathSegments("vehicles") .addPathSegments(this.vehicleId) .addPathSegments(path); @@ -113,12 +125,17 @@ protected T call( headers.put("sc-unit-system", this.unitSystem.name().toLowerCase()); Request request = ApiClient.buildRequest(url, method, body, headers); - return ApiClient.execute(request, type); + return ApiClient.execute(request, type, version); + } + + protected T call( + String path, String method, RequestBody body, String accessToken, Class type) throws SmartcarException { + return this.call(path, this.version, method, body, accessToken, type); } protected T call(String path, String method, RequestBody body, Class type) throws SmartcarException { - return this.call(path, method, body, this.accessToken, type); + return this.call(path, this.version, method, body, this.accessToken, type); } protected T call(String path, String method, RequestBody body, Map query, @@ -147,6 +164,10 @@ protected T call(String path, String method, RequestBody bod return ApiClient.execute(request, type); } + protected T callGet(String path, String version, Class type) throws SmartcarException { + return this.call(path, version, "GET", null, this.accessToken, type); + } + /** * Send request to the / endpoint * @@ -255,7 +276,7 @@ public ServiceHistory serviceHistory(OffsetDateTime startDate, OffsetDateTime en /** * Overload without parameters to handle no input case, calling the full method * with nulls - * + * * @return service history records * @throws SmartcarException if the request is unsuccessful * @throws UnsupportedEncodingException @@ -333,7 +354,7 @@ public VehicleBattery battery() throws SmartcarException { public VehicleBatteryCapacity batteryCapacity() throws SmartcarException { return this.call("battery/capacity", "GET", null, VehicleBatteryCapacity.class); } - + /** * Send request to the /battery/nominal_capacity endpoint * @@ -364,7 +385,7 @@ public VehicleChargeLimit getChargeLimit() throws SmartcarException { public ActionResponse setChargeLimit(double limit) throws SmartcarException { JsonObject json = Json.createObjectBuilder().add("limit", limit).build(); - RequestBody body = RequestBody.create(ApiClient.JSON, json.toString()); + RequestBody body = RequestBody.create(json.toString(), ApiClient.JSON); return this.call("charge/limit", "POST", body, ActionResponse.class); } @@ -398,7 +419,7 @@ public VehicleLocation location() throws SmartcarException { public ActionResponse unlock() throws SmartcarException { JsonObject json = Json.createObjectBuilder().add("action", "UNLOCK").build(); - RequestBody body = RequestBody.create(ApiClient.JSON, json.toString()); + RequestBody body = RequestBody.create(json.toString(), ApiClient.JSON); return this.call("security", "POST", body, ActionResponse.class); } @@ -412,7 +433,7 @@ public ActionResponse unlock() throws SmartcarException { public ActionResponse lock() throws SmartcarException { JsonObject json = Json.createObjectBuilder().add("action", "LOCK").build(); - RequestBody body = RequestBody.create(ApiClient.JSON, json.toString()); + RequestBody body = RequestBody.create(json.toString(), ApiClient.JSON); return this.call("security", "POST", body, ActionResponse.class); } @@ -426,7 +447,7 @@ public ActionResponse lock() throws SmartcarException { public ActionResponse startCharge() throws SmartcarException { JsonObject json = Json.createObjectBuilder().add("action", "START").build(); - RequestBody body = RequestBody.create(ApiClient.JSON, json.toString()); + RequestBody body = RequestBody.create(json.toString(), ApiClient.JSON); return this.call("charge", "POST", body, ActionResponse.class); } @@ -440,7 +461,7 @@ public ActionResponse startCharge() throws SmartcarException { public ActionResponse stopCharge() throws SmartcarException { JsonObject json = Json.createObjectBuilder().add("action", "STOP").build(); - RequestBody body = RequestBody.create(ApiClient.JSON, json.toString()); + RequestBody body = RequestBody.create(json.toString(), ApiClient.JSON); return this.call("charge", "POST", body, ActionResponse.class); } @@ -483,7 +504,7 @@ public ActionResponse sendDestination(double latitude, double longitude) throws .add("longitude", longitude) .build(); - RequestBody requestBody = RequestBody.create(ApiClient.JSON, json.toString()); + RequestBody requestBody = RequestBody.create(json.toString(), ApiClient.JSON); return this.call("navigation/destination", "POST", requestBody, ActionResponse.class); } @@ -495,7 +516,7 @@ public ActionResponse sendDestination(double latitude, double longitude) throws * @throws SmartcarException if the request is unsuccessful */ public WebhookSubscription subscribe(String webhookId) throws SmartcarException { - RequestBody body = RequestBody.create(null, new byte[] {}); + RequestBody body = RequestBody.create(new byte[] {}); return this.call("webhooks/" + webhookId, "POST", body, WebhookSubscription.class); } @@ -528,7 +549,7 @@ public BatchResponse batch(String[] paths) throws SmartcarException { JsonObject json = Json.createObjectBuilder().add("requests", requests).build(); - RequestBody body = RequestBody.create(ApiClient.JSON, json.toString()); + RequestBody body = RequestBody.create(json.toString(), ApiClient.JSON); BatchResponse response = this.call("batch", "POST", body, BatchResponse.class); BatchResponse batchResponse = response; batchResponse.setRequestId(response.getMeta().getRequestId()); @@ -591,4 +612,27 @@ public VehicleResponse request(SmartcarVehicleRequest vehicleRequest) throws Sma public void setUnitSystem(Vehicle.UnitSystem unitSystem) { this.unitSystem = unitSystem; } + + /** + * Send request to the V3 /signals endpoint for retrieving + * all of the available signals for the vehicle + * + * @return the signals of the vehicle + * @throws SmartcarException if the request is unsuccessful + */ + public Signals getSignals() throws SmartcarException { + return this.callGet("signals", "3", Signals.class); + } + + /** + * Send request to the V3 /signals/{signal_code} endpoint for retrieving + * a specific signal for the vehicle + * + * @param signalCode the code of the signal to retrieve + * @return the signal of the vehicle + * @throws SmartcarException if the request is unsuccessful + */ + public Signal getSignal(String signalCode) throws SmartcarException { + return this.callGet("signals/" + signalCode, "3", Signal.class); + } } diff --git a/src/main/java/com/smartcar/sdk/data/Meta.java b/src/main/java/com/smartcar/sdk/data/Meta.java index 75f7e5bf..658f6988 100644 --- a/src/main/java/com/smartcar/sdk/data/Meta.java +++ b/src/main/java/com/smartcar/sdk/data/Meta.java @@ -6,25 +6,26 @@ import java.text.DateFormat; import java.text.ParseException; import java.text.SimpleDateFormat; -import java.time.Instant; import java.util.Date; -import java.util.TimeZone; public class Meta { @SerializedName("sc-request-id") private String requestId; - + // Timestamp of when the data was originally created/reported by the vehicle @SerializedName("sc-data-age") private String dataAge = null; - + @SerializedName("sc-unit-system") private String unitSystem; - + // Timestamp of when Smartcar's system last processed/fetched the data @SerializedName("sc-fetched-at") private String fetchedAt = null; + private Long retrievedAt; + private Long oemUpdatedAt; + public String getRequestId() { return this.requestId; } public Date getDataAge() throws SmartcarException { @@ -41,7 +42,7 @@ public Date getDataAge() throws SmartcarException { } public String getUnitSystem() { return this.unitSystem; } - + public Date getFetchedAt() throws SmartcarException { if (this.fetchedAt == null) { return null; @@ -54,4 +55,18 @@ public Date getFetchedAt() throws SmartcarException { throw new SmartcarException.Builder().type("SDK_ERROR").description(ex.getMessage()).build(); } } + + public Date getRetrievedAt() { + if (this.retrievedAt == null) { + return null; + } + return new Date(this.retrievedAt); + } + + public Date getOemUpdatedAt() { + if (this.oemUpdatedAt == null) { + return null; + } + return new Date(this.oemUpdatedAt); + } } diff --git a/src/main/java/com/smartcar/sdk/data/User.java b/src/main/java/com/smartcar/sdk/data/User.java index 0dfbebf0..e0628f55 100644 --- a/src/main/java/com/smartcar/sdk/data/User.java +++ b/src/main/java/com/smartcar/sdk/data/User.java @@ -1,12 +1,5 @@ package com.smartcar.sdk.data; -import com.google.gson.annotations.SerializedName; -import com.smartcar.sdk.data.Meta; -import com.google.gson.JsonObject; -import okhttp3.Response; - -import javax.json.Json; - public class User extends ApiData { private String id; diff --git a/src/main/java/com/smartcar/sdk/data/VehicleChargeLimit.java b/src/main/java/com/smartcar/sdk/data/VehicleChargeLimit.java index cb3bd66d..404a99da 100644 --- a/src/main/java/com/smartcar/sdk/data/VehicleChargeLimit.java +++ b/src/main/java/com/smartcar/sdk/data/VehicleChargeLimit.java @@ -12,7 +12,6 @@ public double getChargeLimit() { return this.limit; } - // TODO: add .toString() /** @return a stringified representation of VehicleFuel */ @Override public String toString() { diff --git a/src/main/java/com/smartcar/sdk/data/v3/JsonApiData.java b/src/main/java/com/smartcar/sdk/data/v3/JsonApiData.java new file mode 100644 index 00000000..ecbd1bb6 --- /dev/null +++ b/src/main/java/com/smartcar/sdk/data/v3/JsonApiData.java @@ -0,0 +1,36 @@ +package com.smartcar.sdk.data.v3; + +import java.util.Collections; +import java.util.Map; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.smartcar.sdk.data.ApiData; + +public class JsonApiData extends ApiData { + JsonObject response; + Map links; + + public JsonObject getResponse() { + return response; + } + + public void setResponse(JsonObject response) { + this.response = response; + } + + public JsonElement getIncluded() { + return response.get("included"); + } + + public Map getLinks() { + if (this.links == null) { + return Collections.emptyMap(); + } + return links; + } + + public void setLinks(Map links) { + this.links = links; + } +} diff --git a/src/main/java/com/smartcar/sdk/data/v3/Signal.java b/src/main/java/com/smartcar/sdk/data/v3/Signal.java new file mode 100644 index 00000000..87f6399e --- /dev/null +++ b/src/main/java/com/smartcar/sdk/data/v3/Signal.java @@ -0,0 +1,40 @@ +package com.smartcar.sdk.data.v3; + +import com.google.gson.JsonObject; + +public class Signal extends JsonApiData { + private String id; + private String code; + private String name; + private String group; + private JsonObject status; + private JsonObject body; + + public String getCode() { + return this.code; + } + + public String getName() { + return this.name; + } + + public String getGroup() { + return this.group; + } + + public JsonObject getStatus() { + return this.status; + } + + public JsonObject getBody() { + return this.body; + } + + public String getId() { + return this.id; + } + + public void setId(String id) { + this.id = id; + } +} \ No newline at end of file diff --git a/src/main/java/com/smartcar/sdk/data/v3/Signals.java b/src/main/java/com/smartcar/sdk/data/v3/Signals.java new file mode 100644 index 00000000..6a1ab49a --- /dev/null +++ b/src/main/java/com/smartcar/sdk/data/v3/Signals.java @@ -0,0 +1,47 @@ +package com.smartcar.sdk.data.v3; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import com.smartcar.sdk.data.Meta; + +import java.util.Collections; + +public class Signals extends JsonApiData { + public static class SignalsMeta extends Meta { + private Integer totalCount; + private Integer pageSize; + private Integer page; + + public Integer getCount() { + return this.totalCount; + } + + public Integer getTotalCount() { + return this.totalCount; + } + + public Integer getPageSize() { + return this.pageSize; + } + + public Integer getPage() { + return this.page; + } + } + + private List signals; + + public List getSignals() { + if (this.signals == null) { + return Collections.emptyList(); + } + return Collections.unmodifiableList(this.signals); + } + + public void setSignals(Collection signals) { + this.signals = signals.stream().collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/smartcar/sdk/data/v3/VehicleAttributes.java b/src/main/java/com/smartcar/sdk/data/v3/VehicleAttributes.java new file mode 100644 index 00000000..ca64a22a --- /dev/null +++ b/src/main/java/com/smartcar/sdk/data/v3/VehicleAttributes.java @@ -0,0 +1,45 @@ +package com.smartcar.sdk.data.v3; + +public class VehicleAttributes extends JsonApiData { + private String id; + private String make; + private String model; + private Integer year; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getMake() { + return make; + } + + public String getModel() { + return model; + } + + public Integer getYear() { + return year; + } + + @Override + public String toString() { + return this.getClass().getName() + + "{" + + "id=" + + id + + ", make='" + + make + + '\'' + + ", model='" + + model + + '\'' + + ", year=" + + year + + '}'; + } +} diff --git a/src/main/java/com/smartcar/sdk/deserializer/v3/JsonApiDeserializer.java b/src/main/java/com/smartcar/sdk/deserializer/v3/JsonApiDeserializer.java new file mode 100644 index 00000000..bf866e31 --- /dev/null +++ b/src/main/java/com/smartcar/sdk/deserializer/v3/JsonApiDeserializer.java @@ -0,0 +1,74 @@ +package com.smartcar.sdk.deserializer.v3; + +import java.lang.reflect.Method; +import java.lang.reflect.Type; +import java.util.Map; + +import com.google.gson.Gson; +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.smartcar.sdk.data.Meta; +import com.smartcar.sdk.data.v3.JsonApiData; + +public class JsonApiDeserializer implements JsonDeserializer { + private static final Gson DEFAULT_GSON = new Gson(); + + @Override + public T deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { + JsonObject root = json.getAsJsonObject(); + // JsonObject dataElement = root.getAsJsonObject("data"); + JsonObject attributesElement = root.getAsJsonObject("attributes"); + + // Use default Gson deserialization for the attributes to avoid + // looping back into this deserializer + T result = DEFAULT_GSON.fromJson(attributesElement, typeOfT); + + if (result == null) { + return null; + } + + result.setResponse(root); + + JsonElement idEl = root.get("id"); + if (idEl != null) { + try { + Method setIdMethod = result.getClass() + .getMethod("setId", String.class); + if (setIdMethod != null) { + setIdMethod.invoke(result, idEl.getAsString()); + } + } catch (Exception e) { + throw new JsonParseException("Failed to set id on deserialized object", e); + } + } + + JsonElement linksEl = root.get("links"); + if (linksEl != null) { + try { + Map links = context.deserialize(linksEl, java.util.Map.class); + result.getClass() + .getMethod("setLinks", Map.class) + .invoke(result, links); + } catch (Exception e) { + throw new JsonParseException("Failed to set links on deserialized object", e); + } + } + + JsonElement metaEl = root.get("meta"); + if (metaEl != null) { + try { + Meta meta = context.deserialize(metaEl, Meta.class); + result.getClass() + .getMethod("setMeta", Meta.class) + .invoke(result, meta); + } catch (Exception e) { + throw new JsonParseException("Failed to set meta on deserialized object", e); + } + } + + return result; + } +} diff --git a/src/main/java/com/smartcar/sdk/deserializer/v3/SignalsDeserializer.java b/src/main/java/com/smartcar/sdk/deserializer/v3/SignalsDeserializer.java new file mode 100644 index 00000000..52e06574 --- /dev/null +++ b/src/main/java/com/smartcar/sdk/deserializer/v3/SignalsDeserializer.java @@ -0,0 +1,37 @@ +package com.smartcar.sdk.deserializer.v3; + +import java.lang.reflect.Type; +import java.util.Arrays; +import java.util.Map; + +import com.google.gson.JsonArray; +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.smartcar.sdk.data.v3.Signal; +import com.smartcar.sdk.data.v3.Signals; + +public class SignalsDeserializer implements com.google.gson.JsonDeserializer { + @Override + public Signals deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) + throws JsonParseException { + JsonObject root = json.getAsJsonObject(); + JsonElement signalsElement = root.get("data"); + root.add("data", new JsonArray()); // Temporarily remove data + + Signals result = new Signals(); + result.setResponse(root); + + Signal[] signals = context.deserialize(signalsElement, Signal[].class); + result.setSignals(Arrays.asList(signals)); + result.setMeta( + context.deserialize(json.getAsJsonObject().get("meta"), Signals.SignalsMeta.class)); + Map links = context.deserialize( + json.getAsJsonObject().get("links"), java.util.Map.class); + result.setLinks(links); + JsonElement includedEl = root.get("included"); + + return result; + } +} diff --git a/src/test/java/com/smartcar/sdk/SmartcarTest.java b/src/test/java/com/smartcar/sdk/SmartcarTest.java index 9d5d4724..eb6bd9b6 100644 --- a/src/test/java/com/smartcar/sdk/SmartcarTest.java +++ b/src/test/java/com/smartcar/sdk/SmartcarTest.java @@ -4,6 +4,8 @@ import com.smartcar.sdk.data.CompatibilityMatrix; import com.smartcar.sdk.data.User; import com.smartcar.sdk.data.VehicleIds; +import com.smartcar.sdk.data.v3.VehicleAttributes; + import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.RecordedRequest; import org.powermock.api.mockito.PowerMockito; @@ -13,7 +15,8 @@ import org.testng.Assert; import org.testng.annotations.Test; -import javax.json.JsonObject; +import java.nio.file.Files; +import java.nio.file.Paths; @PowerMockIgnore({"javax.net.ssl.*", "javax.crypto.*"}) @PrepareForTest({ @@ -64,6 +67,32 @@ public void testVehicles() throws Exception { TestExecutionListener.mockWebServer.takeRequest(); } + @Test + @PrepareForTest(System.class) + public void testGetVehicle() throws Exception { + PowerMockito.mockStatic(System.class); + PowerMockito.when(System.getenv("SMARTCAR_API_V3_ORIGIN")).thenReturn( + "http://localhost:" + TestExecutionListener.mockWebServer.getPort() + ); + + String fileName = "src/test/resources/GetVehicle.json"; + String body = new String(Files.readAllBytes(Paths.get(fileName))); + + MockResponse response = new MockResponse() + .setBody(body) + .addHeader("sc-request-id", this.sampleRequestId); + TestExecutionListener.mockWebServer.enqueue(response); + + String vehicleId = "36ab27d0-fd9d-4455-823a-ce30af709ffc"; + + VehicleAttributes vehicle = Smartcar.getVehicle(this.fakeAccessToken, vehicleId); + Assert.assertNotNull(vehicle); + Assert.assertEquals(vehicle.getId(), vehicleId); + Assert.assertEquals(vehicle.getMake(), "TESLA"); + Assert.assertEquals(vehicle.getModel(), "Model 3"); + Assert.assertEquals(vehicle.getYear().intValue(), 2019); + } + @Test @PrepareForTest(System.class) public void testGetCompatibility() throws Exception { diff --git a/src/test/java/com/smartcar/sdk/TestExecutionListener.java b/src/test/java/com/smartcar/sdk/TestExecutionListener.java index 3c0c2787..31ea9f45 100644 --- a/src/test/java/com/smartcar/sdk/TestExecutionListener.java +++ b/src/test/java/com/smartcar/sdk/TestExecutionListener.java @@ -17,6 +17,7 @@ public void onExecutionStart() { e.printStackTrace(); } Smartcar.API_ORIGIN = "http://" + mockWebServer.getHostName() + ":" + mockWebServer.getPort(); + Smartcar.API_V3_ORIGIN = "http://" + mockWebServer.getHostName() + ":" + mockWebServer.getPort(); } @Override diff --git a/src/test/java/com/smartcar/sdk/VehicleTest.java b/src/test/java/com/smartcar/sdk/VehicleTest.java index ded456a7..d3243527 100644 --- a/src/test/java/com/smartcar/sdk/VehicleTest.java +++ b/src/test/java/com/smartcar/sdk/VehicleTest.java @@ -2,6 +2,10 @@ import com.google.gson.*; import com.smartcar.sdk.data.*; +import com.smartcar.sdk.data.v3.Signal; +import com.smartcar.sdk.data.v3.Signals; +import com.smartcar.sdk.data.v3.Signals.SignalsMeta; + import okhttp3.mockwebserver.MockResponse; import org.powermock.core.classloader.annotations.PowerMockIgnore; import org.testng.Assert; @@ -83,6 +87,7 @@ private void beforeMethod() throws IOException { .addFlag("foo", "bar") .addFlag("test", true) .origin("http://localhost:" + TestExecutionListener.mockWebServer.getPort()) + .v3Origin("http://localhost:" + TestExecutionListener.mockWebServer.getPort()) .build(); this.subject = new Vehicle(this.vehicleId, this.accessToken, options); } @@ -276,7 +281,7 @@ public void testBatteryCapacity() throws Exception { VehicleBatteryCapacity batteryCapacity = this.subject.batteryCapacity(); Assert.assertEquals(batteryCapacity.getCapacity(), 28.0); - } + } @Test public void testNominalCapacity() throws Exception { @@ -289,19 +294,19 @@ public void testNominalCapacity() throws Exception { Assert.assertEquals(nominalCapacity.getCapacity().getNominal(), 80.9); Assert.assertEquals(nominalCapacity.getCapacity().getSource(), "USER_SELECTED"); Assert.assertEquals(nominalCapacity.getAvailableCapacities().size(), 3); - + AvailableCapacity capacity1 = nominalCapacity.getAvailableCapacities().get(0); Assert.assertEquals(capacity1.getCapacity(), 70.9); Assert.assertNull(capacity1.getDescription()); - + AvailableCapacity capacity2 = nominalCapacity.getAvailableCapacities().get(1); Assert.assertEquals(capacity2.getCapacity(), 80.9); Assert.assertNull(capacity2.getDescription()); - + AvailableCapacity capacity3 = nominalCapacity.getAvailableCapacities().get(2); Assert.assertEquals(capacity3.getCapacity(), 90.9); Assert.assertEquals(capacity3.getDescription(), "BEV:Extended Range"); - + Assert.assertNotNull(nominalCapacity.getUrl()); } @@ -948,12 +953,12 @@ public void testBatchMixedErrorsSuccess() throws FileNotFoundException, Smartcar public void testMetaInvalidFetchedAt() throws Exception { // Create a Meta object directly with an invalid date format Meta meta = new Meta(); - + // Use reflection to set the private field with invalid date Field fetchedAtField = Meta.class.getDeclaredField("fetchedAt"); fetchedAtField.setAccessible(true); fetchedAtField.set(meta, "invalid-date-format"); - + try { // Attempt to get the parsed date meta.getFetchedAt(); @@ -964,4 +969,54 @@ public void testMetaInvalidFetchedAt() throws Exception { Assert.assertTrue(e.getMessage().contains("SDK_ERROR")); } } + + @Test + public void testGetSignals() throws Exception { + loadAndEnqueueResponse("SignalsV3"); + + Signals signals = this.subject.getSignals(); + Assert.assertNotNull(signals); + Assert.assertTrue(signals.getSignals().size() > 0); + + Meta meta = signals.getMeta(); + Assert.assertTrue(meta instanceof SignalsMeta); + Assert.assertEquals(((SignalsMeta) meta).getTotalCount().intValue(), signals.getSignals().size()); + Assert.assertEquals(((SignalsMeta) meta).getPageSize().intValue(), signals.getSignals().size()); + Assert.assertEquals(((SignalsMeta) meta).getPage().intValue(), 1); + + Assert.assertNotNull(signals.getLinks()); + Assert.assertNotNull(signals.getIncluded()); + + Signal signal = signals.getSignals().stream() + .filter(s -> s.getCode().equals("odometer-traveleddistance")) + .findFirst() + .orElse(null); + Assert.assertNotNull(signal); + Assert.assertNotNull(signal); + Assert.assertEquals(signal.getCode(), "odometer-traveleddistance"); + Assert.assertNotNull(signal.getBody()); + Assert.assertNotNull(signal.getStatus()); + + Meta signalMeta = signal.getMeta(); + Assert.assertNotNull(signalMeta); + Assert.assertNotNull(signalMeta.getRetrievedAt()); + Assert.assertNotNull(signalMeta.getOemUpdatedAt()); + } + + @Test + public void testGetSignal() throws Exception { + loadAndEnqueueResponse("SignalV3"); + + Signal signal = this.subject.getSignal("odometer-traveleddistance"); + Assert.assertNotNull(signal); + Assert.assertEquals(signal.getCode(), "odometer-traveleddistance"); + Assert.assertNotNull(signal.getBody()); + Assert.assertNotNull(signal.getStatus()); + + Assert.assertEquals(signal.getBody().get("value").getAsDouble(), 115071.50046584333, 0.0001); + Meta signalMeta = signal.getMeta(); + Assert.assertNotNull(signalMeta); + Assert.assertNotNull(signalMeta.getRetrievedAt()); + Assert.assertNotNull(signalMeta.getOemUpdatedAt()); + } } diff --git a/src/test/resources/GetVehicle.json b/src/test/resources/GetVehicle.json new file mode 100644 index 00000000..c8200bfb --- /dev/null +++ b/src/test/resources/GetVehicle.json @@ -0,0 +1,12 @@ +{ + "id": "36ab27d0-fd9d-4455-823a-ce30af709ffc", + "type": "vehicle", + "attributes": { + "make": "TESLA", + "model": "Model 3", + "year": 2019 + }, + "links": { + "self": "/vehicles/36ab27d0-fd9d-4455-823a-ce30af709ffc" + } +} \ No newline at end of file diff --git a/src/test/resources/SignalV3.json b/src/test/resources/SignalV3.json new file mode 100644 index 00000000..10026cd0 --- /dev/null +++ b/src/test/resources/SignalV3.json @@ -0,0 +1,23 @@ +{ + "id": "odometer-traveleddistance", + "type": "signal", + "attributes": { + "code": "odometer-traveleddistance", + "name": "TraveledDistance", + "group": "Odometer", + "status": { + "value": "SUCCESS" + }, + "body": { + "unit": "kilometers", + "value": 115071.50046584333 + } + }, + "meta": { + "retrievedAt": 1752104218549, + "oemUpdatedAt": 1752104118549 + }, + "links": { + "self": "/vehicles/tst2e255-d3c8-4f90-9fec-e6e68b98e9cb/signals/odometer-traveleddistance" + } +} \ No newline at end of file diff --git a/src/test/resources/SignalsV3.json b/src/test/resources/SignalsV3.json new file mode 100644 index 00000000..65b69a67 --- /dev/null +++ b/src/test/resources/SignalsV3.json @@ -0,0 +1,139 @@ +{ + "data": [ + { + "id": "charge-chargetimers", + "type": "signal", + "attributes": { + "code": "charge-chargetimers", + "name": "ChargeTimers", + "group": "Charge", + "status": { + "value": "SUCCESS" + }, + "body": { + "values": [ + { + "type": "location", + "condition": { + "name": "Home", + "address": "123 Main St, Anytown, USA", + "latitude": 37.7749, + "longitude": -122.4194, + "connectorType": "J1772" + }, + "schedules": [ + { + "start": "22:00:00", + "end": "06:00:00", + "days": [ + "MONDAY", + "TUESDAY", + "WEDNESDAY", + "THURSDAY", + "FRIDAY" + ] + } + ], + "departureTimers": [ + { + "days": [ + "MONDAY", + "TUESDAY", + "WEDNESDAY" + ], + "time": "07:30:00" + } + ], + "isOEMOptimizationEnabled": true + }, + { + "type": "global", + "condition": { + "name": "Work", + "address": "456 Office Park, Bigcity, USA", + "latitude": 40.7128, + "longitude": -74.006, + "connectorType": "CCS" + }, + "schedules": [ + { + "start": "08:00:00", + "end": "17:00:00", + "days": [ + "MONDAY", + "TUESDAY", + "WEDNESDAY", + "THURSDAY", + "FRIDAY" + ] + } + ], + "departureTimers": [ + { + "days": [ + "FRIDAY" + ], + "time": "17:30:00" + } + ], + "isOEMOptimizationEnabled": false + } + ] + } + }, + "meta": { + "retrievedAt": 1752104218549, + "oemUpdatedAt": 1752104118549 + }, + "links": { + "self": "/vehicles/tst2e255-d3c8-4f90-9fec-e6e68b98e9cb/signals/charge-chargetimers", + "values": "/vehicles/tst2e255-d3c8-4f90-9fec-e6e68b98e9cb/signals/charge-chargetimers/values" + } + }, + { + "id": "odometer-traveleddistance", + "type": "signal", + "attributes": { + "code": "odometer-traveleddistance", + "name": "TraveledDistance", + "group": "Odometer", + "status": { + "value": "SUCCESS" + }, + "body": { + "unit": "kilometers", + "value": 115071.50046584333 + } + }, + "meta": { + "retrievedAt": 1752104218549, + "oemUpdatedAt": 1752104118549 + }, + "links": { + "self": "/vehicles/tst2e255-d3c8-4f90-9fec-e6e68b98e9cb/signals/odometer-traveleddistance" + } + } + ], + "meta": { + "totalCount": 2, + "pageSize": 2, + "page": 1 + }, + "links": { + "self": "/vehicles/tst2e255-d3c8-4f90-9fec-e6e68b98e9cb/signals" + }, + "included": { + "vehicle": { + "id": "tst2e255-d3c8-4f90-9fec-e6e68b98e9cb", + "type": "vehicle", + "attributes": { + "make": "TESLA", + "model": "Model Y", + "year": "2021" + }, + "links": { + "self": "/vehicles/tst2e255-d3c8-4f90-9fec-e6e68b98e9cb" + } + } + } +} \ No newline at end of file