diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 02b0b4f..ce35b53 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -37,9 +37,10 @@ jobs: with: java-version: ${{ matrix.java-version }} architecture: x64 + distribution: "adopt" - name: Lint the code - run: mvn checkstyle:checkstyle + run: mvn -X checkstyle:checkstyle - name: Run test suite run: mvn test diff --git a/pom.xml b/pom.xml index 93b8715..ed67987 100644 --- a/pom.xml +++ b/pom.xml @@ -36,12 +36,34 @@ + + org.mock-server + mockserver-netty + 3.10.8 + + + org.mock-server + mockserver-client-java + 3.10.8 + junit junit 4.11 test + + com.google.code.gson + gson + 2.8.8 + compile + + + org.junit.jupiter + junit-jupiter + 5.7.2 + test + @@ -107,7 +129,44 @@ + + org.mock-server + mockserver-maven-plugin + 3.10.8 + + 1080 + 1090 + OFF + org.mockserver.maven.ExampleInitializationClass + + + + process-test-classes + process-test-classes + + start + + + + verify + verify + + stop + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + 11 + 11 + + + diff --git a/src/main/java/com/shipengine/Config.java b/src/main/java/com/shipengine/Config.java index d144450..c724ebe 100644 --- a/src/main/java/com/shipengine/Config.java +++ b/src/main/java/com/shipengine/Config.java @@ -1,26 +1,127 @@ package com.shipengine; +import com.shipengine.exception.InvalidFieldValueException; +import com.shipengine.exception.ValidationException; +import com.shipengine.util.Constants; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + public class Config { + private String apiKey; + + public void setBaseUrl(String baseUrl) { + this.baseUrl = baseUrl; + } + + private String baseUrl = Constants.BASE_URL; + private int pageSize = 5000; + private int retries = 1; + private int timeout = 50; + + public Config(Map config) { + if (config.containsKey("apiKey")) { + setApiKey(config.get("apiKey").toString()); + } else { + setApiKey(""); + } + + if (config.containsKey("baseUrl")) { + setBaseUrl(config.get("baseUrl").toString()); + } + + if (config.containsKey("timeout")) { + setTimeout(Integer.parseInt(config.get("timeout").toString())); + } + + if (config.containsKey("retries")) { + setRetries(Integer.parseInt(config.get("retries").toString())); + } + + if (config.containsKey("pageSize")) { + setPageSize(Integer.parseInt(config.get("pageSize").toString())); + } + } + + public Config(String apiKey) { + setApiKey(apiKey); + } + + public Config(String apiKey, int timeout, int retries, int pageSize) { + setApiKey(apiKey); + setTimeout(timeout); + setRetries(retries); + setPageSize(pageSize); + } + + public Config(String apiKey, String baseUrl, int timeout, int retries, int pageSize) { + setApiKey(apiKey); + setBaseUrl(baseUrl); + setTimeout(timeout); + setRetries(retries); + setPageSize(pageSize); + } + + /* + * The URL of the ShipEngine API. You can usually leave this unset and it will + * default to our public API. + */ + public String getBaseUrl() { + return baseUrl; + } + /* * Your ShipEngine API key. This can be a production or sandbox key. Sandbox * keys start with "TEST_". */ - String apiKey; + public String getApiKey() { + return apiKey; + } /* - * The URL of the ShipEngine API. You can usually leave this unset and it will - * default to our public API. + * Set the ShipEngine API key. */ - String baseUrl = "https://api.shipengine.com/"; + public void setApiKey(String apiKey) throws InvalidFieldValueException { + String apiKeyStr = "apiKey"; + Pattern regexPattern = Pattern.compile("[\\s]"); + Matcher matcher = regexPattern.matcher(apiKey); + if (apiKey.length() == 0) { + throw new InvalidFieldValueException(apiKeyStr, apiKey); + } else if (matcher.matches()) { + throw new InvalidFieldValueException(apiKeyStr, apiKey); + } else { + this.apiKey = apiKey; + } + } /* - * Some ShipEngine API endpoints return paged data. This lets you control the - * number of items returned per request. Larger numbers will use more memory but - * will require fewer HTTP requests. + * The maximum amount of time (in milliseconds) to wait for a response from the + * ShipEngine server. * - * Defaults to 50. + * Defaults to 5000 (5 seconds). */ - int pageSize; + public int getTimeout() { + return timeout; + } + + /* + * Set the timeout (in milliseconds). + */ + public void setTimeout(int timeout) { + if (timeout == 0) { + throw new ValidationException( + "The timeout value cannot be zero.", + "shipengine", + "validation", + "invalid_field_value" + ); + } + this.timeout = timeout; + } /* * If the ShipEngine client receives a rate limit error it can automatically @@ -30,40 +131,95 @@ public class Config { * Defaults to 1, which means up to 2 attempts will be made (the original * attempt, plus one retry). */ - int retries; + public int getRetries() { + return retries; + } /* - * The maximum amount of time (in milliseconds) to wait for a response from the - * ShipEngine server. - * - * Defaults to 5000 (5 seconds). + * Set the retries. */ - int timeout; - - public Config(String apiKey) { - this.apiKey = apiKey; - this.timeout = 5000; - this.retries = 1; - this.pageSize = 50; + public void setRetries(int retries) { + if (retries == 0) { + throw new ValidationException( + "The retries value cannot be zero.", + "shipengine", + "validation", + "invalid_field_value" + ); + } + this.retries = retries; } - public String getBaseUrl() { - return baseUrl; + /* + * Some ShipEngine API endpoints return paged data. This lets you control the + * number of items returned per request. Larger numbers will use more memory but + * will require fewer HTTP requests. + * + * Defaults to 50. + */ + public int getPageSize() { + return pageSize; } - public String getApiKey() { - return apiKey; + /* + * Set the page size. + */ + public void setPageSize(int pageSize) { + if (pageSize == 0) { + throw new ValidationException( + "The pageSize value cannot be zero.", + "shipengine", + "validation", + "invalid_field_value" + ); + } + this.pageSize = pageSize; } - public int getTimeout() { - return timeout; + public Config merge() { + return this; } - public int getRetries() { - return retries; + public Config merge(String apiKey) { + return new Config(apiKey); } - public int getPageSize() { - return pageSize; + public Config merge(Map newConfig) { + Map config = new HashMap<>(); + List configKeys = Arrays.asList("apiKey", "timeout", "retries", "pageSize"); + + if (newConfig.isEmpty()) { + return this; + } else { + if (newConfig.containsKey(configKeys.get(0))) { + config.put(configKeys.get(0), newConfig.get(configKeys.get(0))); + } else { + config.put(configKeys.get(0), getApiKey()); + } + + if (newConfig.containsKey(configKeys.get(1))) { + config.put(configKeys.get(1), newConfig.get(configKeys.get(1))); + } else { + config.put(configKeys.get(1), getTimeout()); + } + + if (newConfig.containsKey(configKeys.get(2))) { + config.put(configKeys.get(2), newConfig.get(configKeys.get(2))); + } else { + config.put(configKeys.get(2), getRetries()); + } + + if (newConfig.containsKey(configKeys.get(3))) { + config.put(configKeys.get(3), newConfig.get(configKeys.get(3))); + } else { + config.put(configKeys.get(3), getPageSize()); + } + } + return new Config( + config.get(configKeys.get(0)).toString(), + Integer.parseInt(config.get(configKeys.get(1)).toString()), + Integer.parseInt(config.get(configKeys.get(2)).toString()), + Integer.parseInt(config.get(configKeys.get(3)).toString()) + ); } } diff --git a/src/main/java/com/shipengine/InternalClient.java b/src/main/java/com/shipengine/InternalClient.java new file mode 100644 index 0000000..93a2164 --- /dev/null +++ b/src/main/java/com/shipengine/InternalClient.java @@ -0,0 +1,475 @@ +package com.shipengine; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.shipengine.exception.ClientTimeoutError; +import com.shipengine.exception.RateLimitExceededException; +import com.shipengine.exception.ShipEngineException; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class InternalClient { + private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + + /** + * Enumeration of frequently used HTTP verbs to be used when making requests with the client. + */ + private enum HttpVerbs { + GET, + POST, + PUT, + DELETE + } + + /** + * This is the request loop that manages the clients retry logic when interacting with ShipEngine API. + * This method takes in the request body as a Map or HashMap. + * + * @param httpMethod The HTTP Verb to set as the HTTP Method in the request. + * @param endpoint A string representation of the target API endpoint for the request. + * @param body A Map or HashMap that contains the request body contents. + * @param config The global Config object for the ShipEngine SDK. + * @return Map The response from ShipEngine API serialized into a Map/HashMap. + */ + private Map requestLoop( + String httpMethod, + String endpoint, + Map body, + Config config + ) throws InterruptedException { + int retry = 0; + Map apiResponse = Map.of(); + while (retry <= config.getRetries()) { + try { + apiResponse = sendHttpRequest( + httpMethod, + endpoint, + body, + config + ); + } catch (Exception err) { + if ((retry < config.getRetries()) && + (err instanceof RateLimitExceededException) && + (config.getTimeout() > ((RateLimitExceededException) err).getRetryAfter())) { + try { + java.util.concurrent.TimeUnit.SECONDS.sleep(((RateLimitExceededException) err).getRetryAfter()); + retry++; + // continue; + } catch (RuntimeException e) { + e.printStackTrace(); + } + } else { + throw err; + } + } + retry++; + } + return apiResponse; + } + + /** + * This is the request loop that manages the clients retry logic when interacting with ShipEngine API. + * This method override takes in the request body as a List or Array. + * + * @param httpMethod The HTTP Verb to set as the HTTP Method in the request. + * @param endpoint A string representation of the target API endpoint for the request. + * @param body A Map or HashMap that contains the request body contents. + * @param config The global Config object for the ShipEngine SDK. + * @return List The response from ShipEngine API serialized into a List/Array. + */ + private List> requestLoop( + String httpMethod, + String endpoint, + List> body, + Config config + ) throws InterruptedException { + int retry = 0; + List apiResponse = List.of(); + while (retry <= config.getRetries()) { + try { + apiResponse = sendHttpRequest( + httpMethod, + endpoint, + body, + config + ); + } catch (Exception err) { + if ((retry < config.getRetries()) && + (err instanceof RateLimitExceededException) && + (config.getTimeout() > ((RateLimitExceededException) err).getRetryAfter())) { + try { + java.util.concurrent.TimeUnit.SECONDS.sleep(((RateLimitExceededException) err).getRetryAfter()); + retry++; + // continue; + } catch (RuntimeException e) { + e.printStackTrace(); + } + } else { + throw err; + } + } + retry++; + } + return apiResponse; + } + + /** + * This is the request loop that manages the clients retry logic when interacting with ShipEngine API. + * This method override does not take in a *body* argument (e.g. Servicing a GET request). + * + * @param httpMethod The HTTP Verb to set as the HTTP Method in the request. + * @param endpoint A string representation of the target API endpoint for the request. + * @param config The global Config object for the ShipEngine SDK. + * @return Map The response from ShipEngine API serialized into a List/Array. + */ + private Map requestLoop( + String httpMethod, + String endpoint, + Config config + ) throws InterruptedException { + int retry = 0; + Map apiResponse = Map.of(); + while (retry <= config.getRetries()) { + try { + apiResponse = sendHttpRequest( + httpMethod, + endpoint, + config + ); + } catch (Exception err) { + if ((retry < config.getRetries()) && + (err instanceof RateLimitExceededException) && + (config.getTimeout() > ((RateLimitExceededException) err).getRetryAfter())) { + try { + java.util.concurrent.TimeUnit.SECONDS.sleep(((RateLimitExceededException) err).getRetryAfter()); + retry++; + // continue; + } catch (InterruptedException e) { + e.printStackTrace(); + } + } else { + throw err; + } + } + retry++; + } + return apiResponse; + } + + private Map sendHttpRequest( + String httpMethod, + String endpoint, + Map requestBody, + Config config + ) { + Map apiResponse = Map.of(); + if (httpMethod.equals(HttpVerbs.POST.name())) { + apiResponse = internalPost(endpoint, requestBody, config); + } else if (httpMethod.equals(HttpVerbs.GET.name())) { + apiResponse = internalGet(endpoint, config); + } else if (httpMethod.equals(HttpVerbs.PUT.name())) { + apiResponse = internalPut(endpoint, requestBody, config); + } else if (httpMethod.equals(HttpVerbs.DELETE.name())) { + apiResponse = internalDelete(endpoint, config); + } + return apiResponse; + } + + private Map sendHttpRequest( + String httpMethod, + String endpoint, + Config config + ) { + Map apiResponse = Map.of(); + if (httpMethod.equals(HttpVerbs.GET.name())) { + apiResponse = internalGet(endpoint, config); + } else if (httpMethod.equals(HttpVerbs.DELETE.name())) { + apiResponse = internalDelete(endpoint, config); + } + return apiResponse; + } + + private List> sendHttpRequest( + String httpMethod, + String endpoint, + List> requestBody, + Config config + ) { + List> apiResponse = List.of(); + if (httpMethod.equals(HttpVerbs.POST.name())) { + apiResponse = internalPost(endpoint, requestBody, config); + } + return apiResponse; + } + + public List> post( + String endpoint, + List> body, + Config config + ) throws InterruptedException { + return requestLoop( + HttpVerbs.POST.name(), + endpoint, + body, + config + ); + } + + public Map post( + String endpoint, + Map body, + Config config + ) throws InterruptedException { + return requestLoop( + HttpVerbs.POST.name(), + endpoint, + body, + config + ); + } + + public Map put( + String endpoint, + Map body, + Config config + ) throws InterruptedException { + return requestLoop( + HttpVerbs.PUT.name(), + endpoint, + body, + config + ); + } + + public Map get( + String endpoint, + Config config + ) throws InterruptedException { + return requestLoop( + HttpVerbs.GET.name(), + endpoint, + config + ); + } + + public Map delete( + String endpoint, + Config config + ) throws InterruptedException { + return requestLoop( + HttpVerbs.DELETE.name(), + endpoint, + config + ); + } + + private HttpRequest.Builder prepareRequest( + String endpoint, + Config config + ) { + Pattern pattern = Pattern.compile("/"); + String baseUri; + if (endpoint.length() > 0) { + Matcher matcher = pattern.matcher(endpoint); + String result = matcher.replaceFirst(""); + baseUri = String.format("%s%s", config.getBaseUrl(), result); + + } else { + baseUri = config.getBaseUrl(); + + } + + URI clientUri = null; + try { + clientUri = new URI(baseUri); + } catch (URISyntaxException err) { + err.printStackTrace(); + } + + return HttpRequest.newBuilder() + .uri(clientUri) + .headers("Content-Type", "application/json") + .headers("Accepts", "application/json") + .headers("Api-Key", config.getApiKey()) + .timeout(Duration.of(config.getTimeout(), ChronoUnit.SECONDS)); + + } + + private String sendPreparedRequest( + HttpRequest preparedRequest, + Config config + ) { + String responseBody = null; + + try { + HttpResponse response = HttpClient + .newBuilder() + .build() + .send(preparedRequest, HttpResponse.BodyHandlers.ofString()); + responseBody = response.body(); + String retryAfterHeaderValue = response.headers().firstValue("retry-after").toString(); + + checkResponseForErrors( + response.statusCode(), + responseBody, + retryAfterHeaderValue, + config + ); + + String debugg = "Check above values in debug console!"; + } catch (IOException | InterruptedException err) { + err.printStackTrace(); + } + + return responseBody; + } + + private Map internalDelete( + String endpoint, + Config config + ) { + HttpRequest request = prepareRequest(endpoint, config) + .DELETE() + .build(); + + String apiResponse = sendPreparedRequest(request, config); + return apiResponseToMap(apiResponse); + } + + private Map internalGet( + String endpoint, + Config config + ) { + HttpRequest request = prepareRequest(endpoint, config) + .GET() + .build(); + + String apiResponse = sendPreparedRequest(request, config); + return apiResponseToMap(apiResponse); + } + + private List> internalPost( + String endpoint, + List> requestBody, + Config config + ) { + String preppedRequest = gson.toJson(requestBody); + HttpRequest request = prepareRequest(endpoint, config) + .POST(HttpRequest.BodyPublishers.ofString(preppedRequest)) + .build(); + + String apiResponse = sendPreparedRequest(request, config); + return apiResponseToList(apiResponse); + } + + private Map internalPost( + String endpoint, + Map requestBody, + Config config + ) { + HttpRequest request = prepareRequest(endpoint, config) + .POST(HttpRequest.BodyPublishers.ofString(hashMapToJson(requestBody))) + .build(); + + String apiResponse = sendPreparedRequest(request, config); + return apiResponseToMap(apiResponse); + } + + private Map internalPut( + String endpoint, + Map requestBody, + Config config + ) { + HttpRequest request = prepareRequest(endpoint, config) + .PUT(HttpRequest.BodyPublishers.ofString(hashMapToJson(requestBody))) + .build(); + + String apiResponse = sendPreparedRequest(request, config); + return apiResponseToMap(apiResponse); + } + + private String hashMapToJson(Map hash) { + return gson.toJson(hash); + } + + private String objectToJson(Object obj) { + return gson.toJson(obj); + } + + private String listToJson(List list) { + return gson.toJson(list); + } + + private static Map apiResponseToMap(String apiResponse) { + return gson.fromJson(apiResponse, HashMap.class); + } + + private static List> apiResponseToList(String apiResponse) { + List> newList = new ArrayList<>(); + List apiResponseAsList = gson.fromJson(apiResponse, List.class); + for (Object k : apiResponseAsList) { + String temp = gson.toJson(k); + newList.add(gson.fromJson(temp, HashMap.class)); + } + return newList; + } + + private void checkResponseForErrors( + int statusCode, + String httpResponseBody, + String retryAfterHeader, + Config config + ) { + Map>> responseBody = apiResponseToMap(httpResponseBody); + Map error = responseBody.get("errors").get(0); + switch (statusCode) { + case 400: + case 500: + throw new ShipEngineException( + error.get("message"), + responseBody.get("request_id").toString(), + error.get("error_source"), + error.get("error_type"), + error.get("error_code") + ); + case 404: + throw new ShipEngineException( + error.get("message"), + responseBody.get("request_id").toString(), + "shipengine", + error.get("error_type"), + error.get("error_code") + ); + case 429: + int retry = Integer.parseInt(retryAfterHeader); + if (retry > config.getTimeout()) { + throw new ClientTimeoutError( + responseBody.get("request_id").toString(), + "shipengine", + retry + ); + } else { + throw new RateLimitExceededException( + responseBody.get("request_id").toString(), + "shipengine", + retry + ); + } + default: + return; + } + } +} diff --git a/src/main/java/com/shipengine/ShipEngine.java b/src/main/java/com/shipengine/ShipEngine.java index 46eda09..5abff3e 100644 --- a/src/main/java/com/shipengine/ShipEngine.java +++ b/src/main/java/com/shipengine/ShipEngine.java @@ -1,45 +1,329 @@ package com.shipengine; +import com.shipengine.exception.ShipEngineException; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + public class ShipEngine { - Config config; + private InternalClient client = new InternalClient(); + + private Config config; public ShipEngine(String apiKey) { this.config = new Config(apiKey); } + public ShipEngine(String apiKey, int timeout, int retries, int pageSize) { + this.config = new Config(apiKey, timeout, retries, pageSize); + } + + public ShipEngine(Map config) { + this.config = new Config(config); + } + public Config getConfig() { return config; } - public String validateAddresses() { - return config.getBaseUrl(); + /** + * Address validation ensures accurate addresses and can lead to reduced shipping costs by preventing address + * correction surcharges. ShipEngine cross-references multiple databases to validate addresses and identify + * potential deliverability issues. + * See: https://shipengine.github.io/shipengine-openapi/#operation/validate_address + * + * @param address A list of HashMaps where each HashMap contains the address data to be validated. + * @return The response from ShipEngine API including the validated and normalized address. + */ + public List> validateAddresses(List> address) { + List> apiResponse = new ArrayList<>(); + try { + apiResponse = client.post( + "/v1/addresses/validate", + address, + this.getConfig() + ); + return apiResponse; + } catch (ShipEngineException | InterruptedException e) { + e.printStackTrace(); + } + return apiResponse; + } + + /** + * Address validation ensures accurate addresses and can lead to reduced shipping costs by preventing address + * correction surcharges. ShipEngine cross-references multiple databases to validate addresses and identify + * potential deliverability issues. + * See: https://shipengine.github.io/shipengine-openapi/#operation/validate_address + * + * @param address A list of HashMaps where each HashMap contains the address data to be validated. + * @param config Method level configuration to set new values for properties of the + * global ShipEngineConfig object that will only affect the current request, not all requests. + * @return The response from ShipEngine API including the validated and normalized address. + */ + public List> validateAddresses(List> address, Map config) { + Config mergedConfig = this.config.merge(config); + List> apiResponse = new ArrayList<>(); + try { + apiResponse = client.post( + "/v1/addresses/validate", + address, + mergedConfig + ); + return apiResponse; + } catch (ShipEngineException | InterruptedException e) { + e.printStackTrace(); + } + return apiResponse; + } + + public Map listCarriers() { + Map apiResponse = new HashMap<>(); + try { + apiResponse = client.get( + "/v1/carriers", + this.getConfig() + ); + return apiResponse; + } catch (ShipEngineException | InterruptedException e) { + e.printStackTrace(); + } + return apiResponse; + } + + public Map listCarriers(Map config) { + Config mergedConfig = this.config.merge(config); + Map apiResponse = new HashMap<>(); + try { + apiResponse = client.get( + "/v1/carriers", + mergedConfig + ); + return apiResponse; + } catch (ShipEngineException | InterruptedException e) { + e.printStackTrace(); + } + return apiResponse; + } + + public Map createLabelFromShipmentDetails(Map shipment) { + Map apiResponse = new HashMap<>(); + try { + apiResponse = client.post( + "/v1/labels", + shipment, + this.getConfig() + ); + return apiResponse; + } catch (ShipEngineException | InterruptedException e) { + e.printStackTrace(); + } + return apiResponse; + } + + public Map createLabelFromShipmentDetails(Map shipment, Map config) { + Config mergedConfig = this.config.merge(config); + Map apiResponse = new HashMap<>(); + try { + apiResponse = client.post( + "/v1/labels", + shipment, + mergedConfig + ); + return apiResponse; + } catch (ShipEngineException | InterruptedException e) { + e.printStackTrace(); + } + return apiResponse; + } + + public Map createLabelFromRateId(String rateId, Map params) { + Map apiResponse = new HashMap<>(); + try { + apiResponse = client.post( + String.format("/v1/labels/rates/%s", rateId), + params, + this.getConfig() + ); + return apiResponse; + } catch (ShipEngineException | InterruptedException e) { + e.printStackTrace(); + } + return apiResponse; + } + + public Map createLabelFromRateId( + String rateId, + Map params, + Map config + ) { + Config mergedConfig = this.config.merge(config); + Map apiResponse = new HashMap<>(); + try { + apiResponse = client.post( + String.format("/v1/labels/rates/%s", rateId), + params, + mergedConfig + ); + return apiResponse; + } catch (ShipEngineException | InterruptedException e) { + e.printStackTrace(); + } + return apiResponse; + } + + public Map getRatesWithShipmentDetails(Map shipment) { + Map apiResponse = new HashMap<>(); + try { + apiResponse = client.post( + "/v1/rates", + shipment, + this.getConfig() + ); + return apiResponse; + } catch (ShipEngineException | InterruptedException e) { + e.printStackTrace(); + } + return apiResponse; } - public String listCarriers() { - return config.getBaseUrl(); + public Map getRatesWithShipmentDetails(Map shipment, Map config) { + Config mergedConfig = this.config.merge(config); + Map apiResponse = new HashMap<>(); + try { + apiResponse = client.post( + "/v1/rates", + shipment, + mergedConfig + ); + return apiResponse; + } catch (ShipEngineException | InterruptedException e) { + e.printStackTrace(); + } + return apiResponse; } - public String trackUsingCarrierCodeAndTrackingNumber() { - return config.getBaseUrl(); + public Map trackUsingCarrierCodeAndTrackingNumber( + Map trackingData + ) { + Map apiResponse = new HashMap<>(); + try { + apiResponse = client.get( + String.format( + "/v1/tracking?carrier_code=%s&tracking_number=%s", + trackingData.get("carrierCode"), + trackingData.get("trackingNumber") + ), + this.getConfig() + ); + return apiResponse; + } catch (ShipEngineException | InterruptedException e) { + e.printStackTrace(); + } + return apiResponse; } - public String trackUsingLabelId() { - return config.getBaseUrl(); + public Map trackUsingCarrierCodeAndTrackingNumber( + Map trackingData, + Map config + ) { + Config mergedConfig = this.config.merge(config); + Map apiResponse = new HashMap<>(); + try { + apiResponse = client.get( + String.format( + "/v1/tracking?carrier_code=%s&tracking_number=%s", + trackingData.get("carrierCode"), + trackingData.get("trackingNumber") + ), + mergedConfig + ); + return apiResponse; + } catch (ShipEngineException | InterruptedException e) { + e.printStackTrace(); + } + return apiResponse; } - public String createLabelFromShipmentDetails() { - return config.getBaseUrl(); + public Map trackUsingLabelId(String labelId) { + Map apiResponse = new HashMap<>(); + try { + apiResponse = client.get( + String.format( + "/v1/labels/%s/track", + labelId + ), + this.getConfig() + ); + return apiResponse; + } catch (ShipEngineException | InterruptedException e) { + e.printStackTrace(); + } + return apiResponse; } - public String createLabelFromRate() { - return config.getBaseUrl(); + public Map trackUsingLabelId(String labelId, Map config) { + Config mergedConfig = this.config.merge(config); + Map apiResponse = new HashMap<>(); + try { + apiResponse = client.get( + String.format( + "/v1/labels/%s/track", + labelId + ), + mergedConfig + ); + return apiResponse; + } catch (ShipEngineException | InterruptedException e) { + e.printStackTrace(); + } + return apiResponse; } - public String voidLabelWithLabelId() { - return config.getBaseUrl(); + /** + * Void label with a Label ID. + * See: https://shipengine.github.io/shipengine-openapi/#operation/void_label + * + * @param labelId The label_id of the label you wish to void. + * @return The response from ShipEngine API confirming the label was successfully voided or unable to be voided. + */ + public Map voidLabelWithLabelId(String labelId) { + Map apiResponse = new HashMap<>(); + try { + apiResponse = client.get( + String.format("/v1/labels/%s/void", labelId), + this.getConfig() + ); + return apiResponse; + } catch (ShipEngineException | InterruptedException e) { + e.printStackTrace(); + } + return apiResponse; } - public String getRatesWithShipmentDetails() { - return config.getBaseUrl(); + /** + * Void label with a Label ID. + * See: https://shipengine.github.io/shipengine-openapi/#operation/void_label + * + * @param labelId The label_id of the label you wish to void. + * @param config Method level configuration to set new values for properties of the + * global ShipEngineConfig object that will only affect the current request, not all requests. + * @return The response from ShipEngine API confirming the label was successfully voided or unable to be voided. + */ + public Map voidLabelWithLabelId(String labelId, Map config) { + Config mergedConfig = this.config.merge(config); + Map apiResponse = new HashMap<>(); + try { + apiResponse = client.get( + String.format("/v1/labels/%s/void", labelId), + mergedConfig + ); + return apiResponse; + } catch (ShipEngineException | InterruptedException e) { + e.printStackTrace(); + } + return apiResponse; } } diff --git a/src/main/java/com/shipengine/exception/ClientTimeoutError.java b/src/main/java/com/shipengine/exception/ClientTimeoutError.java new file mode 100644 index 0000000..8e92269 --- /dev/null +++ b/src/main/java/com/shipengine/exception/ClientTimeoutError.java @@ -0,0 +1,49 @@ +package com.shipengine.exception; + +public class ClientTimeoutError extends ShipEngineException { + + private int retryAfter; + + private ErrorSource source; + + private String requestID; + + public int getRetryAfter() { + return retryAfter; + } + + public void setRetryAfter(int retryAfter) { + this.retryAfter = retryAfter; + } + + public ErrorSource getSource() { + return source; + } + + public void setSource(ErrorSource source) { + this.source = source; + } + + public String getRequestID() { + return requestID; + } + + public void setRequestID(String requestID) { + this.requestID = requestID; + } + + public ClientTimeoutError( + String requestID, + String source, + int retryAfter + ) { + super( + String.format("The request took longer than the %s seconds allowed.", retryAfter), + requestID, + ErrorSource.valueOf(source.toUpperCase()), + ErrorType.SYSTEM, + ErrorCode.TIMEOUT, + "https://www.shipengine.com/docs/rate-limits" + ); + } +} diff --git a/src/main/java/com/shipengine/exception/FieldValueRequiredException.java b/src/main/java/com/shipengine/exception/FieldValueRequiredException.java new file mode 100644 index 0000000..fd809c5 --- /dev/null +++ b/src/main/java/com/shipengine/exception/FieldValueRequiredException.java @@ -0,0 +1,26 @@ +package com.shipengine.exception; + +/** + * This error occurs when a required field has not been set. This includes fields + * that are conditionally required. + */ +public class FieldValueRequiredException extends RuntimeException { + /** + * The name of the invalid field. + */ + private String fieldName; + + public String getFieldName() { + return fieldName; + } + + public void setFieldName(String fieldName) { + this.fieldName = fieldName; + } + + public FieldValueRequiredException( + String fieldName + ) { + super(String.format("%s is a required field.", fieldName)); + } +} diff --git a/src/main/java/com/shipengine/exception/InvalidFieldValueException.java b/src/main/java/com/shipengine/exception/InvalidFieldValueException.java new file mode 100644 index 0000000..2f25b98 --- /dev/null +++ b/src/main/java/com/shipengine/exception/InvalidFieldValueException.java @@ -0,0 +1,48 @@ +package com.shipengine.exception; + +/** + * This error occurs when a field has been set to an invalid value. + */ +public class InvalidFieldValueException extends ShipEngineException { + /** + * The name of the invalid field. + */ + private String fieldName; + + /** + * The value of the invalid field. + */ + private String fieldValue; + + public String getFieldName() { + return fieldName; + } + + private void setFieldName(String fieldName) { + this.fieldName = fieldName; + } + + public String getFieldValue() { + return fieldValue; + } + + private void setFieldValue(String fieldValue) { + this.fieldValue = fieldValue; + } + + public InvalidFieldValueException( + String fieldName, + String fieldValue + ) { + super( + String.format("%s - \"%s\" was provided.", fieldName, fieldValue), + "", + ErrorSource.SHIPENGINE, + ErrorType.VALIDATION, + ErrorCode.INVALID_FIELD_VALUE, + "https://www.shipengine.com/docs/" + ); + this.setFieldName(fieldName); + this.setFieldValue(fieldValue); + } +} diff --git a/src/main/java/com/shipengine/exception/RateLimitExceededException.java b/src/main/java/com/shipengine/exception/RateLimitExceededException.java new file mode 100644 index 0000000..276887a --- /dev/null +++ b/src/main/java/com/shipengine/exception/RateLimitExceededException.java @@ -0,0 +1,34 @@ +package com.shipengine.exception; + +/** + * This error occurs when a request to ShipEngine API is blocked due to the rate + * limit being exceeded. + */ +public class RateLimitExceededException extends ShipEngineException { + /** + * The amount of time (in milliseconds) to wait before retrying the request. + */ + private int retryAfter; + + public int getRetryAfter() { + return retryAfter; + } + + private void setRetryAfter(int retryAfter) { + this.retryAfter = retryAfter; + } + + public RateLimitExceededException( + String requestID, String source, int retryAfter + ) { + super( + "You have exceeded the rate limit.", + requestID, + ErrorSource.valueOf(source.toUpperCase()), + ErrorType.SYSTEM, + ErrorCode.RATE_LIMIT_EXCEEDED, + "https://www.shipengine.com/docs/rate-limits" + ); + this.setRetryAfter(retryAfter); + } +} diff --git a/src/main/java/com/shipengine/exception/ShipEngineException.java b/src/main/java/com/shipengine/exception/ShipEngineException.java new file mode 100644 index 0000000..b2ca0ef --- /dev/null +++ b/src/main/java/com/shipengine/exception/ShipEngineException.java @@ -0,0 +1,224 @@ +package com.shipengine.exception; + +import java.net.MalformedURLException; +import java.net.URL; +import java.util.HashSet; + +/** + * An error thrown by the ShipEngine SDK. All other SDK errors inherit from this + * class. + */ +public class ShipEngineException extends RuntimeException { + public static HashSet getEnums() { + HashSet values = new HashSet<>(); + + for (ErrorSource k : ErrorSource.values()) { + values.add(k.name()); + } + + for (ErrorType p : ErrorType.values()) { + values.add(p.name()); + } + + for (ErrorCode c : ErrorCode.values()) { + values.add(c.name()); + } + + return values; + } + + enum ErrorSource { + CARRIER, + ORDER_SOURCE, + SHIPENGINE + } + + enum ErrorType { + ACCOUNT_STATUS, + AUTHORIZATION, + BUSINESS_RULES, + ERROR, + SECURITY, + SYSTEM, + VALIDATION + } + + enum ErrorCode { + ADDRESS_NOT_FOUND, + AUTO_FUND_NOT_SUPPORTED, + BATCH_CANNOT_BE_MODIFIED, + CARRIER_CONFLICT, + CARRIER_NOT_CONNECTED, + CARRIER_NOT_SUPPORTED, + CONFIRMATION_NOT_SUPPORTED, + FIELD_CONFLICT, + FIELD_VALUE_REQUIRED, + FORBIDDEN, + IDENTIFIER_CONFLICT, + IDENTIFIER_MUST_MATCH, + INCOMPATIBLE_PAIRED_LABELS, + INVALID_ADDRESS, + INVALID_BILLING_PLAN, + INVALID_CHARGE_EVENT, + INVALID_FIELD_VALUE, + INVALID_IDENTIFIER, + INVALID_STATUS, + INVALID_STRING_LENGTH, + LABEL_IMAGES_NOT_SUPPORTED, + METER_FAILURE, + MINIMUM_POSTAL_CODE_VERIFICATION_FAILED, + NOT_FOUND, + PARTIALLY_VERIFIED_TO_PREMISE_LEVEL, + RATE_LIMIT_EXCEEDED, + REQUEST_BODY_REQUIRED, + RETURN_LABEL_NOT_SUPPORTED, + SUBSCRIPTION_INACTIVE, + TERMS_NOT_ACCEPTED, + TIMEOUT, + TRACKING_NOT_SUPPORTED, + TRIAL_EXPIRED, + UNAUTHORIZED, + UNSPECIFIED, + VERIFICATION_CONFLICT, + WAREHOUSE_CONFLICT, + WEBHOOK_EVENT_TYPE_CONFLICT + } + + /** + * If the error came from the ShipEngine server (as opposed to a client-side + * error) then this is the unique ID of the HTTP request that returned the + * error. You can use this ID when contacting ShipEngine support for help. + */ + private String requestID; + + /** + * Indicates where the error originated. This lets you know whether you should + * contact ShipEngine for support or if you should contact the carrier or + * marketplace instead. + * + * @see ... + */ + private ErrorSource source; + + /** + * Indicates the type of error that occurred, such as a validation error, a + * security error, etc. + * + * @see ... + */ + private ErrorType type; + + /** + * A code that indicates the specific error that occurred, such as missing a + * required field, an invalid address, a timeout, etc. + * + * @see ... + */ + private ErrorCode code; + + /** + * Some errors include a URL that you can visit to learn more about the error, + * find out how to resolve it, or get support. + */ + private URL url; + + public String getRequestID() { + return requestID; + } + + public ErrorSource getSource() { + return source; + } + + public ErrorType getType() { + return type; + } + + public ErrorCode getCode() { + return code; + } + + public URL getUrl() { + return url; + } + + public void setRequestID(String requestID) { + this.requestID = requestID; + } + + public void setSource(ErrorSource source) { + this.source = source; + } + + public void setType(ErrorType type) { + this.type = type; + } + + public void setCode(ErrorCode code) { + this.code = code; + } + + public void setUrl(String url) { + try { + this.url = new URL(url); + } catch (MalformedURLException err) { + err.printStackTrace(); + } + } + + public ShipEngineException( + String message, + String requestID, + ErrorSource source, + ErrorType type, + ErrorCode code, + String url + ) { + super(message); + this.setRequestID(requestID); + this.setSource(source); + this.setType(type); + this.setCode(code); + this.setUrl(url); + } + + public ShipEngineException( + String message, + ErrorSource source, + ErrorType type, + ErrorCode code, + String url + ) { + super(message); + this.setSource(source); + this.setType(type); + this.setCode(code); + this.setUrl(url); + } + + public ShipEngineException( + String message, + String requestID, + String source, + String type, + String code + ) { + super(message); + this.setRequestID(requestID); + this.setSource(ErrorSource.valueOf(source.toUpperCase())); + this.setType(ErrorType.valueOf(type.toUpperCase())); + this.setCode(ErrorCode.valueOf(code)); + } + + public ShipEngineException( + String message, + String source, + String type, + String code + ) { + super(message); + this.setSource(ErrorSource.valueOf(source.toUpperCase())); + this.setType(ErrorType.valueOf(type.toUpperCase())); + this.setCode(ErrorCode.valueOf(code)); + } +} diff --git a/src/main/java/com/shipengine/exception/ValidationException.java b/src/main/java/com/shipengine/exception/ValidationException.java new file mode 100644 index 0000000..f3c50e6 --- /dev/null +++ b/src/main/java/com/shipengine/exception/ValidationException.java @@ -0,0 +1,24 @@ +package com.shipengine.exception; + +public class ValidationException extends ShipEngineException { + + public ValidationException( + String message, + String requestID, + ErrorSource source, + ErrorType type, + ErrorCode code, + String url + ) { + super(message, requestID, source, type, code, url); + } + + public ValidationException( + String message, + String source, + String type, + String code + ) { + super(message, source, type, code); + } +} diff --git a/src/main/java/com/shipengine/util/Constants.java b/src/main/java/com/shipengine/util/Constants.java new file mode 100644 index 0000000..ac1124d --- /dev/null +++ b/src/main/java/com/shipengine/util/Constants.java @@ -0,0 +1,7 @@ +package com.shipengine.util; + +public interface Constants { + String API_KEY = "TEST_vMiVbICUjBz4BZjq0TRBLC/9MrxY4+yjvb1G1RMxlJs"; + String BASE_URL = "https://api.shipengine.com/"; + String TEST_URL = "http://127.0.0.1:1080/"; +} diff --git a/src/test/java/com/shipengine/ConfigTest.java b/src/test/java/com/shipengine/ConfigTest.java index 805fdff..d70d5ac 100644 --- a/src/test/java/com/shipengine/ConfigTest.java +++ b/src/test/java/com/shipengine/ConfigTest.java @@ -1,20 +1,48 @@ package com.shipengine; -import static org.junit.Assert.assertEquals; - +import com.shipengine.util.Constants; import org.junit.Test; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.assertEquals; + /** * Unit test for Config. */ public class ConfigTest { - /** - * Should allow the config to just be the API key - */ - @Test - public void shouldAllowApiKeyOnly() { - ShipEngine client = new ShipEngine("test"); - - assertEquals(client.getConfig().getApiKey(), "test"); - } + /** + * Should allow the config to just be the API key + */ + @Test + public void shouldAllowApiKeyOnlyConstructor() { + ShipEngine client = new ShipEngine(Constants.API_KEY); + + assertEquals(Constants.API_KEY, client.getConfig().getApiKey()); + } + + /** + * Should return the Global Config object when calling the merge() method + * with no arguments. + */ + @Test + public void shouldReturnGlobalConfigFromEmptyMergeCall() { + ShipEngine client = new ShipEngine(Constants.API_KEY); + + assertEquals(Config.class, client.getConfig().merge().getClass()); + } + + /** + * Should allow method level configuration. + */ + @Test + public void shouldAllowMethodLevelConfig() { + ShipEngine client = new ShipEngine(Constants.API_KEY); + + Map newConfig = new HashMap<>(); + newConfig.put("retries", 3); + Map result = client.listCarriers(newConfig); + assertEquals(result.getClass(), HashMap.class); + } } diff --git a/src/test/java/com/shipengine/ShipEngineTest.java b/src/test/java/com/shipengine/ShipEngineTest.java index 5b3d0a9..0177a44 100644 --- a/src/test/java/com/shipengine/ShipEngineTest.java +++ b/src/test/java/com/shipengine/ShipEngineTest.java @@ -1,18 +1,1431 @@ package com.shipengine; -import static org.junit.Assert.assertEquals; - +import com.shipengine.util.Constants; +import org.junit.After; +import org.junit.Before; import org.junit.Test; +import org.mockserver.client.server.MockServerClient; +import org.mockserver.integration.ClientAndServer; +import org.mockserver.matchers.Times; +import org.mockserver.model.Header; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertEquals; +import static org.mockserver.integration.ClientAndServer.startClientAndServer; +import static org.mockserver.model.HttpRequest.request; +import static org.mockserver.model.HttpResponse.response; /** * Unit test for simple App. */ public class ShipEngineTest { + private ClientAndServer mockServer; + + private final HashMap customConfig = new HashMap<>() {{ + put("apiKey", Constants.API_KEY); + put("baseUrl", Constants.TEST_URL); + put("retries", 3); + }}; + + @Before + public void startServer() { + mockServer = startClientAndServer(1080); + } + + @After + public void stopMockServer() { + mockServer.stop(); + } + /** - * Rigorous Test :-) + * Testing Address Validation with a valid address. */ @Test - public void shouldAnswerWithTrue() { - assertEquals(new ShipEngine("test").validateAddresses(), "https://api.shipengine.com/"); + public void successfulAddressValidation() { + new MockServerClient("127.0.0.1", 1080) + .when(request() + .withMethod("POST") + .withPath("/v1/addresses/validate"), + Times.exactly(1)) + .respond(response() + .withStatusCode(200) + .withBody("[\n" + + " {\n" + + " \"status\": \"verified\",\n" + + " \"original_address\": {\n" + + " \"name\": \"ShipEngine\",\n" + + " \"phone\": \"1-123-456-7891\",\n" + + " \"company_name\": null,\n" + + " \"address_line1\": \"3800 N Lamar Blvd\",\n" + + " \"address_line2\": \"ste 220\",\n" + + " \"address_line3\": null,\n" + + " \"city_locality\": \"Austin\",\n" + + " \"state_province\": \"TX\",\n" + + " \"postal_code\": \"78756\",\n" + + " \"country_code\": \"US\",\n" + + " \"address_residential_indicator\": \"unknown\"\n" + + " },\n" + + " \"matched_address\": {\n" + + " \"name\": \"SHIPENGINE\",\n" + + " \"phone\": \"1-123-456-7891\",\n" + + " \"company_name\": null,\n" + + " \"address_line1\": \"3800 N LAMAR BLVD STE 220\",\n" + + " \"address_line2\": \"\",\n" + + " \"address_line3\": null,\n" + + " \"city_locality\": \"AUSTIN\",\n" + + " \"state_province\": \"TX\",\n" + + " \"postal_code\": \"78756-0003\",\n" + + " \"country_code\": \"US\",\n" + + " \"address_residential_indicator\": \"no\"\n" + + " },\n" + + " \"messages\": []\n" + + " }\n" + + "]") + .withDelay(TimeUnit.SECONDS, 1)); + + HashMap stubAddress = new HashMap<>() {{ + put("name", "ShipEngine"); + put("company", "Auctane"); + put("phone", "1-123-456-7891"); + put("address_line1", "3800 N Lamar Blvd"); + put("address_line2", "ste 220"); + put("city_locality", "Austin"); + put("state_province", "TX"); + put("postal_code", "78756"); + put("country_code", "US"); + put("address_residential_indicator", "unknown"); + }}; + + List> unvalidatedAddress = List.of(stubAddress); + List> validatedAddress = new ShipEngine(customConfig).validateAddresses(unvalidatedAddress); + assertEquals("verified", validatedAddress.get(0).get("status")); + } + + /** + * Testing successful call to listCarriers which fetches all + * carrier accounts connected o given ShipEngine Account. + */ + @Test + public void successfulListCarriers() { + new MockServerClient("127.0.0.1", 1080) + .when(request() + .withMethod("GET") + .withPath("/v1/carriers"), + Times.exactly(1)) + .respond(response() + .withStatusCode(200) + .withBody("{\n" + + " \"carriers\": [\n" + + " {\n" + + " \"carrier_id\": \"se-656171\",\n" + + " \"carrier_code\": \"stamps_com\",\n" + + " \"account_number\": \"test_account_656171\",\n" + + " \"requires_funded_amount\": true,\n" + + " \"balance\": 8948.3400,\n" + + " \"nickname\": \"ShipEngine Test Account - Stamps.com\",\n" + + " \"friendly_name\": \"Stamps.com\",\n" + + " \"primary\": false,\n" + + " \"has_multi_package_supporting_services\": false,\n" + + " \"supports_label_messages\": true,\n" + + " \"services\": [\n" + + " {\n" + + " \"carrier_id\": \"se-656171\",\n" + + " \"carrier_code\": \"stamps_com\",\n" + + " \"service_code\": \"usps_first_class_mail\",\n" + + " \"name\": \"USPS First Class Mail\",\n" + + " \"domestic\": true,\n" + + " \"international\": false,\n" + + " \"is_multi_package_supported\": false\n" + + " },\n" + + " {\n" + + " \"carrier_id\": \"se-656171\",\n" + + " \"carrier_code\": \"stamps_com\",\n" + + " \"service_code\": \"usps_media_mail\",\n" + + " \"name\": \"USPS Media Mail\",\n" + + " \"domestic\": true,\n" + + " \"international\": false,\n" + + " \"is_multi_package_supported\": false\n" + + " },\n" + + " {\n" + + " \"carrier_id\": \"se-656171\",\n" + + " \"carrier_code\": \"stamps_com\",\n" + + " \"service_code\": \"usps_parcel_select\",\n" + + " \"name\": \"USPS Parcel Select Ground\",\n" + + " \"domestic\": true,\n" + + " \"international\": false,\n" + + " \"is_multi_package_supported\": false\n" + + " },\n" + + " {\n" + + " \"carrier_id\": \"se-656171\",\n" + + " \"carrier_code\": \"stamps_com\",\n" + + " \"service_code\": \"usps_priority_mail\",\n" + + " \"name\": \"USPS Priority Mail\",\n" + + " \"domestic\": true,\n" + + " \"international\": false,\n" + + " \"is_multi_package_supported\": false\n" + + " },\n" + + " {\n" + + " \"carrier_id\": \"se-656171\",\n" + + " \"carrier_code\": \"stamps_com\",\n" + + " \"service_code\": \"usps_priority_mail_express\",\n" + + " \"name\": \"USPS Priority Mail Express\",\n" + + " \"domestic\": true,\n" + + " \"international\": false,\n" + + " \"is_multi_package_supported\": false\n" + + " },\n" + + " {\n" + + " \"carrier_id\": \"se-656171\",\n" + + " \"carrier_code\": \"stamps_com\",\n" + + " \"service_code\": \"usps_first_class_mail_international\",\n" + + " \"name\": \"USPS First Class Mail Intl\",\n" + + " \"domestic\": false,\n" + + " \"international\": true,\n" + + " \"is_multi_package_supported\": false\n" + + " },\n" + + " {\n" + + " \"carrier_id\": \"se-656171\",\n" + + " \"carrier_code\": \"stamps_com\",\n" + + " \"service_code\": \"usps_priority_mail_international\",\n" + + " \"name\": \"USPS Priority Mail Intl\",\n" + + " \"domestic\": false,\n" + + " \"international\": true,\n" + + " \"is_multi_package_supported\": false\n" + + " },\n" + + " {\n" + + " \"carrier_id\": \"se-656171\",\n" + + " \"carrier_code\": \"stamps_com\",\n" + + " \"service_code\": \"usps_priority_mail_express_international\",\n" + + " \"name\": \"USPS Priority Mail Express Intl\",\n" + + " \"domestic\": false,\n" + + " \"international\": true,\n" + + " \"is_multi_package_supported\": false\n" + + " }\n" + + " ],\n" + + " \"packages\": [\n" + + " {\n" + + " \"package_id\": null,\n" + + " \"package_code\": \"cubic\",\n" + + " \"name\": \"Cubic\",\n" + + " \"description\": \"Cubic\"\n" + + " },\n" + + " {\n" + + " \"package_id\": null,\n" + + " \"package_code\": \"flat_rate_envelope\",\n" + + " \"name\": \"Flat Rate Envelope\",\n" + + " \"description\": \"USPS flat rate envelope. A special cardboard envelope provided by the USPS that clearly indicates \\\"Flat Rate\\\".\"\n" + + " },\n" + + " {\n" + + " \"package_id\": null,\n" + + " \"package_code\": \"flat_rate_legal_envelope\",\n" + + " \"name\": \"Flat Rate Legal Envelope\",\n" + + " \"description\": \"Flat Rate Legal Envelope\"\n" + + " },\n" + + " {\n" + + " \"package_id\": null,\n" + + " \"package_code\": \"flat_rate_padded_envelope\",\n" + + " \"name\": \"Flat Rate Padded Envelope\",\n" + + " \"description\": \"Flat Rate Padded Envelope\"\n" + + " },\n" + + " {\n" + + " \"package_id\": null,\n" + + " \"package_code\": \"large_envelope_or_flat\",\n" + + " \"name\": \"Large Envelope or Flat\",\n" + + " \"description\": \"Large envelope or flat. Has one dimension that is between 11 1/2\\\" and 15\\\" long, 6 1/18\\\" and 12\\\" high, or 1/4\\\" and 3/4\\\" thick.\"\n" + + " },\n" + + " {\n" + + " \"package_id\": null,\n" + + " \"package_code\": \"large_flat_rate_box\",\n" + + " \"name\": \"Large Flat Rate Box\",\n" + + " \"description\": \"Large Flat Rate Box\"\n" + + " },\n" + + " {\n" + + " \"package_id\": null,\n" + + " \"package_code\": \"large_package\",\n" + + " \"name\": \"Large Package (any side \\u003e 12\\\")\",\n" + + " \"description\": \"Large package. Longest side plus the distance around the thickest part is over 84\\\" and less than or equal to 108\\\".\"\n" + + " },\n" + + " {\n" + + " \"package_id\": null,\n" + + " \"package_code\": \"letter\",\n" + + " \"name\": \"Letter\",\n" + + " \"description\": \"Letter\"\n" + + " },\n" + + " {\n" + + " \"package_id\": null,\n" + + " \"package_code\": \"medium_flat_rate_box\",\n" + + " \"name\": \"Medium Flat Rate Box\",\n" + + " \"description\": \"USPS flat rate box. A special 11\\\" x 8 1/2\\\" x 5 1/2\\\" or 14\\\" x 3.5\\\" x 12\\\" USPS box that clearly indicates \\\"Flat Rate Box\\\"\"\n" + + " },\n" + + " {\n" + + " \"package_id\": null,\n" + + " \"package_code\": \"non_rectangular\",\n" + + " \"name\": \"Non Rectangular Package\",\n" + + " \"description\": \"Non-Rectangular package type that is cylindrical in shape.\"\n" + + " },\n" + + " {\n" + + " \"package_id\": null,\n" + + " \"package_code\": \"package\",\n" + + " \"name\": \"Package\",\n" + + " \"description\": \"Package. Longest side plus the distance around the thickest part is less than or equal to 84\\\"\"\n" + + " },\n" + + " {\n" + + " \"package_id\": null,\n" + + " \"package_code\": \"regional_rate_box_a\",\n" + + " \"name\": \"Regional Rate Box A\",\n" + + " \"description\": \"Regional Rate Box A\"\n" + + " },\n" + + " {\n" + + " \"package_id\": null,\n" + + " \"package_code\": \"regional_rate_box_b\",\n" + + " \"name\": \"Regional Rate Box B\",\n" + + " \"description\": \"Regional Rate Box B\"\n" + + " },\n" + + " {\n" + + " \"package_id\": null,\n" + + " \"package_code\": \"small_flat_rate_box\",\n" + + " \"name\": \"Small Flat Rate Box\",\n" + + " \"description\": \"Small Flat Rate Box\"\n" + + " },\n" + + " {\n" + + " \"package_id\": null,\n" + + " \"package_code\": \"thick_envelope\",\n" + + " \"name\": \"Thick Envelope\",\n" + + " \"description\": \"Thick envelope. Envelopes or flats greater than 3/4\\\" at the thickest point.\"\n" + + " }\n" + + " ],\n" + + " \"options\": [\n" + + " {\n" + + " \"name\": \"non_machinable\",\n" + + " \"default_value\": \"false\",\n" + + " \"description\": \"\"\n" + + " },\n" + + " {\n" + + " \"name\": \"bill_to_account\",\n" + + " \"default_value\": null,\n" + + " \"description\": \"Bill To Account\"\n" + + " },\n" + + " {\n" + + " \"name\": \"bill_to_party\",\n" + + " \"default_value\": null,\n" + + " \"description\": \"Bill To Party\"\n" + + " },\n" + + " {\n" + + " \"name\": \"bill_to_postal_code\",\n" + + " \"default_value\": null,\n" + + " \"description\": \"Bill To Postal Code\"\n" + + " },\n" + + " {\n" + + " \"name\": \"bill_to_country_code\",\n" + + " \"default_value\": null,\n" + + " \"description\": \"Bill To Country Code\"\n" + + " }\n" + + " ]\n" + + " },\n" + + " {\n" + + " \"carrier_id\": \"se-656172\",\n" + + " \"carrier_code\": \"ups\",\n" + + " \"account_number\": \"test_account_656172\",\n" + + " \"requires_funded_amount\": false,\n" + + " \"balance\": 0.0,\n" + + " \"nickname\": \"ShipEngine Test Account - UPS\",\n" + + " \"friendly_name\": \"UPS\",\n" + + " \"primary\": false,\n" + + " \"has_multi_package_supporting_services\": true,\n" + + " \"supports_label_messages\": true,\n" + + " \"services\": [\n" + + " {\n" + + " \"carrier_id\": \"se-656172\",\n" + + " \"carrier_code\": \"ups\",\n" + + " \"service_code\": \"ups_standard_international\",\n" + + " \"name\": \"UPS Standard®\",\n" + + " \"domestic\": false,\n" + + " \"international\": true,\n" + + " \"is_multi_package_supported\": true\n" + + " },\n" + + " {\n" + + " \"carrier_id\": \"se-656172\",\n" + + " \"carrier_code\": \"ups\",\n" + + " \"service_code\": \"ups_next_day_air_early_am\",\n" + + " \"name\": \"UPS Next Day Air® Early\",\n" + + " \"domestic\": true,\n" + + " \"international\": false,\n" + + " \"is_multi_package_supported\": true\n" + + " },\n" + + " {\n" + + " \"carrier_id\": \"se-656172\",\n" + + " \"carrier_code\": \"ups\",\n" + + " \"service_code\": \"ups_worldwide_express\",\n" + + " \"name\": \"UPS Worldwide Express®\",\n" + + " \"domestic\": false,\n" + + " \"international\": true,\n" + + " \"is_multi_package_supported\": true\n" + + " },\n" + + " {\n" + + " \"carrier_id\": \"se-656172\",\n" + + " \"carrier_code\": \"ups\",\n" + + " \"service_code\": \"ups_next_day_air\",\n" + + " \"name\": \"UPS Next Day Air®\",\n" + + " \"domestic\": true,\n" + + " \"international\": false,\n" + + " \"is_multi_package_supported\": true\n" + + " },\n" + + " {\n" + + " \"carrier_id\": \"se-656172\",\n" + + " \"carrier_code\": \"ups\",\n" + + " \"service_code\": \"ups_ground_international\",\n" + + " \"name\": \"UPS Ground® (International)\",\n" + + " \"domestic\": false,\n" + + " \"international\": true,\n" + + " \"is_multi_package_supported\": true\n" + + " },\n" + + " {\n" + + " \"carrier_id\": \"se-656172\",\n" + + " \"carrier_code\": \"ups\",\n" + + " \"service_code\": \"ups_worldwide_express_plus\",\n" + + " \"name\": \"UPS Worldwide Express Plus®\",\n" + + " \"domestic\": false,\n" + + " \"international\": true,\n" + + " \"is_multi_package_supported\": true\n" + + " },\n" + + " {\n" + + " \"carrier_id\": \"se-656172\",\n" + + " \"carrier_code\": \"ups\",\n" + + " \"service_code\": \"ups_next_day_air_saver\",\n" + + " \"name\": \"UPS Next Day Air Saver®\",\n" + + " \"domestic\": true,\n" + + " \"international\": false,\n" + + " \"is_multi_package_supported\": true\n" + + " },\n" + + " {\n" + + " \"carrier_id\": \"se-656172\",\n" + + " \"carrier_code\": \"ups\",\n" + + " \"service_code\": \"ups_worldwide_expedited\",\n" + + " \"name\": \"UPS Worldwide Expedited®\",\n" + + " \"domestic\": false,\n" + + " \"international\": true,\n" + + " \"is_multi_package_supported\": true\n" + + " },\n" + + " {\n" + + " \"carrier_id\": \"se-656172\",\n" + + " \"carrier_code\": \"ups\",\n" + + " \"service_code\": \"ups_2nd_day_air_am\",\n" + + " \"name\": \"UPS 2nd Day Air AM®\",\n" + + " \"domestic\": true,\n" + + " \"international\": false,\n" + + " \"is_multi_package_supported\": true\n" + + " },\n" + + " {\n" + + " \"carrier_id\": \"se-656172\",\n" + + " \"carrier_code\": \"ups\",\n" + + " \"service_code\": \"ups_2nd_day_air\",\n" + + " \"name\": \"UPS 2nd Day Air®\",\n" + + " \"domestic\": true,\n" + + " \"international\": false,\n" + + " \"is_multi_package_supported\": true\n" + + " },\n" + + " {\n" + + " \"carrier_id\": \"se-656172\",\n" + + " \"carrier_code\": \"ups\",\n" + + " \"service_code\": \"ups_worldwide_saver\",\n" + + " \"name\": \"UPS Worldwide Saver®\",\n" + + " \"domestic\": false,\n" + + " \"international\": true,\n" + + " \"is_multi_package_supported\": true\n" + + " },\n" + + " {\n" + + " \"carrier_id\": \"se-656172\",\n" + + " \"carrier_code\": \"ups\",\n" + + " \"service_code\": \"ups_2nd_day_air_international\",\n" + + " \"name\": \"UPS 2nd Day Air® (International)\",\n" + + " \"domestic\": false,\n" + + " \"international\": true,\n" + + " \"is_multi_package_supported\": true\n" + + " },\n" + + " {\n" + + " \"carrier_id\": \"se-656172\",\n" + + " \"carrier_code\": \"ups\",\n" + + " \"service_code\": \"ups_3_day_select\",\n" + + " \"name\": \"UPS 3 Day Select®\",\n" + + " \"domestic\": true,\n" + + " \"international\": false,\n" + + " \"is_multi_package_supported\": true\n" + + " },\n" + + " {\n" + + " \"carrier_id\": \"se-656172\",\n" + + " \"carrier_code\": \"ups\",\n" + + " \"service_code\": \"ups_ground\",\n" + + " \"name\": \"UPS® Ground\",\n" + + " \"domestic\": true,\n" + + " \"international\": false,\n" + + " \"is_multi_package_supported\": true\n" + + " },\n" + + " {\n" + + " \"carrier_id\": \"se-656172\",\n" + + " \"carrier_code\": \"ups\",\n" + + " \"service_code\": \"ups_next_day_air_international\",\n" + + " \"name\": \"UPS Next Day Air® (International)\",\n" + + " \"domestic\": false,\n" + + " \"international\": true,\n" + + " \"is_multi_package_supported\": true\n" + + " }\n" + + " ],\n" + + " \"packages\": [\n" + + " {\n" + + " \"package_id\": null,\n" + + " \"package_code\": \"package\",\n" + + " \"name\": \"Package\",\n" + + " \"description\": \"Package. Longest side plus the distance around the thickest part is less than or equal to 84\\\"\"\n" + + " },\n" + + " {\n" + + " \"package_id\": null,\n" + + " \"package_code\": \"ups__express_box_large\",\n" + + " \"name\": \"UPS Express® Box - Large\",\n" + + " \"description\": \"Express Box - Large\"\n" + + " },\n" + + " {\n" + + " \"package_id\": null,\n" + + " \"package_code\": \"ups_10_kg_box\",\n" + + " \"name\": \"UPS 10 KG Box®\",\n" + + " \"description\": \"10 KG Box\"\n" + + " },\n" + + " {\n" + + " \"package_id\": null,\n" + + " \"package_code\": \"ups_25_kg_box\",\n" + + " \"name\": \"UPS 25 KG Box®\",\n" + + " \"description\": \"25 KG Box\"\n" + + " },\n" + + " {\n" + + " \"package_id\": null,\n" + + " \"package_code\": \"ups_express_box\",\n" + + " \"name\": \"UPS Express® Box\",\n" + + " \"description\": \"Express Box\"\n" + + " },\n" + + " {\n" + + " \"package_id\": null,\n" + + " \"package_code\": \"ups_express_box_medium\",\n" + + " \"name\": \"UPS Express® Box - Medium\",\n" + + " \"description\": \"Express Box - Medium\"\n" + + " },\n" + + " {\n" + + " \"package_id\": null,\n" + + " \"package_code\": \"ups_express_box_small\",\n" + + " \"name\": \"UPS Express® Box - Small\",\n" + + " \"description\": \"Express Box - Small\"\n" + + " },\n" + + " {\n" + + " \"package_id\": null,\n" + + " \"package_code\": \"ups_express_pak\",\n" + + " \"name\": \"UPS Express® Pak\",\n" + + " \"description\": \"Pak\"\n" + + " },\n" + + " {\n" + + " \"package_id\": null,\n" + + " \"package_code\": \"ups_letter\",\n" + + " \"name\": \"UPS Letter\",\n" + + " \"description\": \"Letter\"\n" + + " },\n" + + " {\n" + + " \"package_id\": null,\n" + + " \"package_code\": \"ups_tube\",\n" + + " \"name\": \"UPS Tube\",\n" + + " \"description\": \"Tube\"\n" + + " }\n" + + " ],\n" + + " \"options\": [\n" + + " {\n" + + " \"name\": \"bill_to_account\",\n" + + " \"default_value\": \"\",\n" + + " \"description\": \"\"\n" + + " },\n" + + " {\n" + + " \"name\": \"bill_to_country_code\",\n" + + " \"default_value\": \"\",\n" + + " \"description\": \"\"\n" + + " },\n" + + " {\n" + + " \"name\": \"bill_to_party\",\n" + + " \"default_value\": \"\",\n" + + " \"description\": \"\"\n" + + " },\n" + + " {\n" + + " \"name\": \"bill_to_postal_code\",\n" + + " \"default_value\": \"\",\n" + + " \"description\": \"\"\n" + + " },\n" + + " {\n" + + " \"name\": \"collect_on_delivery\",\n" + + " \"default_value\": \"\",\n" + + " \"description\": \"\"\n" + + " },\n" + + " {\n" + + " \"name\": \"contains_alcohol\",\n" + + " \"default_value\": \"false\",\n" + + " \"description\": \"\"\n" + + " },\n" + + " {\n" + + " \"name\": \"delivered_duty_paid\",\n" + + " \"default_value\": \"false\",\n" + + " \"description\": \"\"\n" + + " },\n" + + " {\n" + + " \"name\": \"dry_ice\",\n" + + " \"default_value\": \"false\",\n" + + " \"description\": \"\"\n" + + " },\n" + + " {\n" + + " \"name\": \"dry_ice_weight\",\n" + + " \"default_value\": \"0\",\n" + + " \"description\": \"\"\n" + + " },\n" + + " {\n" + + " \"name\": \"freight_class\",\n" + + " \"default_value\": \"\",\n" + + " \"description\": \"\"\n" + + " },\n" + + " {\n" + + " \"name\": \"non_machinable\",\n" + + " \"default_value\": \"false\",\n" + + " \"description\": \"\"\n" + + " },\n" + + " {\n" + + " \"name\": \"saturday_delivery\",\n" + + " \"default_value\": \"false\",\n" + + " \"description\": \"\"\n" + + " },\n" + + " {\n" + + " \"name\": \"shipper_release\",\n" + + " \"default_value\": \"false\",\n" + + " \"description\": \"Driver may release package without signature\"\n" + + " }\n" + + " ]\n" + + " },\n" + + " {\n" + + " \"carrier_id\": \"se-656173\",\n" + + " \"carrier_code\": \"fedex\",\n" + + " \"account_number\": \"test_account_656173\",\n" + + " \"requires_funded_amount\": false,\n" + + " \"balance\": 0.0,\n" + + " \"nickname\": \"ShipEngine Test Account - FedEx\",\n" + + " \"friendly_name\": \"FedEx\",\n" + + " \"primary\": false,\n" + + " \"has_multi_package_supporting_services\": true,\n" + + " \"supports_label_messages\": true,\n" + + " \"services\": [\n" + + " {\n" + + " \"carrier_id\": \"se-656173\",\n" + + " \"carrier_code\": \"fedex\",\n" + + " \"service_code\": \"fedex_ground\",\n" + + " \"name\": \"FedEx Ground®\",\n" + + " \"domestic\": true,\n" + + " \"international\": false,\n" + + " \"is_multi_package_supported\": true\n" + + " },\n" + + " {\n" + + " \"carrier_id\": \"se-656173\",\n" + + " \"carrier_code\": \"fedex\",\n" + + " \"service_code\": \"fedex_home_delivery\",\n" + + " \"name\": \"FedEx Home Delivery®\",\n" + + " \"domestic\": true,\n" + + " \"international\": false,\n" + + " \"is_multi_package_supported\": true\n" + + " },\n" + + " {\n" + + " \"carrier_id\": \"se-656173\",\n" + + " \"carrier_code\": \"fedex\",\n" + + " \"service_code\": \"fedex_2day\",\n" + + " \"name\": \"FedEx 2Day®\",\n" + + " \"domestic\": true,\n" + + " \"international\": false,\n" + + " \"is_multi_package_supported\": true\n" + + " },\n" + + " {\n" + + " \"carrier_id\": \"se-656173\",\n" + + " \"carrier_code\": \"fedex\",\n" + + " \"service_code\": \"fedex_2day_am\",\n" + + " \"name\": \"FedEx 2Day® A.M.\",\n" + + " \"domestic\": true,\n" + + " \"international\": false,\n" + + " \"is_multi_package_supported\": true\n" + + " },\n" + + " {\n" + + " \"carrier_id\": \"se-656173\",\n" + + " \"carrier_code\": \"fedex\",\n" + + " \"service_code\": \"fedex_express_saver\",\n" + + " \"name\": \"FedEx Express Saver®\",\n" + + " \"domestic\": true,\n" + + " \"international\": false,\n" + + " \"is_multi_package_supported\": true\n" + + " },\n" + + " {\n" + + " \"carrier_id\": \"se-656173\",\n" + + " \"carrier_code\": \"fedex\",\n" + + " \"service_code\": \"fedex_standard_overnight\",\n" + + " \"name\": \"FedEx Standard Overnight®\",\n" + + " \"domestic\": true,\n" + + " \"international\": false,\n" + + " \"is_multi_package_supported\": true\n" + + " },\n" + + " {\n" + + " \"carrier_id\": \"se-656173\",\n" + + " \"carrier_code\": \"fedex\",\n" + + " \"service_code\": \"fedex_priority_overnight\",\n" + + " \"name\": \"FedEx Priority Overnight®\",\n" + + " \"domestic\": true,\n" + + " \"international\": false,\n" + + " \"is_multi_package_supported\": true\n" + + " },\n" + + " {\n" + + " \"carrier_id\": \"se-656173\",\n" + + " \"carrier_code\": \"fedex\",\n" + + " \"service_code\": \"fedex_first_overnight\",\n" + + " \"name\": \"FedEx First Overnight®\",\n" + + " \"domestic\": true,\n" + + " \"international\": false,\n" + + " \"is_multi_package_supported\": true\n" + + " },\n" + + " {\n" + + " \"carrier_id\": \"se-656173\",\n" + + " \"carrier_code\": \"fedex\",\n" + + " \"service_code\": \"fedex_1_day_freight\",\n" + + " \"name\": \"FedEx 1Day® Freight\",\n" + + " \"domestic\": true,\n" + + " \"international\": false,\n" + + " \"is_multi_package_supported\": true\n" + + " },\n" + + " {\n" + + " \"carrier_id\": \"se-656173\",\n" + + " \"carrier_code\": \"fedex\",\n" + + " \"service_code\": \"fedex_2_day_freight\",\n" + + " \"name\": \"FedEx 2Day® Freight\",\n" + + " \"domestic\": true,\n" + + " \"international\": false,\n" + + " \"is_multi_package_supported\": true\n" + + " },\n" + + " {\n" + + " \"carrier_id\": \"se-656173\",\n" + + " \"carrier_code\": \"fedex\",\n" + + " \"service_code\": \"fedex_3_day_freight\",\n" + + " \"name\": \"FedEx 3Day® Freight\",\n" + + " \"domestic\": true,\n" + + " \"international\": false,\n" + + " \"is_multi_package_supported\": true\n" + + " },\n" + + " {\n" + + " \"carrier_id\": \"se-656173\",\n" + + " \"carrier_code\": \"fedex\",\n" + + " \"service_code\": \"fedex_first_overnight_freight\",\n" + + " \"name\": \"FedEx First Overnight® Freight\",\n" + + " \"domestic\": true,\n" + + " \"international\": false,\n" + + " \"is_multi_package_supported\": true\n" + + " },\n" + + " {\n" + + " \"carrier_id\": \"se-656173\",\n" + + " \"carrier_code\": \"fedex\",\n" + + " \"service_code\": \"fedex_ground_international\",\n" + + " \"name\": \"FedEx International Ground®\",\n" + + " \"domestic\": false,\n" + + " \"international\": true,\n" + + " \"is_multi_package_supported\": true\n" + + " },\n" + + " {\n" + + " \"carrier_id\": \"se-656173\",\n" + + " \"carrier_code\": \"fedex\",\n" + + " \"service_code\": \"fedex_international_economy\",\n" + + " \"name\": \"FedEx International Economy®\",\n" + + " \"domestic\": false,\n" + + " \"international\": true,\n" + + " \"is_multi_package_supported\": true\n" + + " },\n" + + " {\n" + + " \"carrier_id\": \"se-656173\",\n" + + " \"carrier_code\": \"fedex\",\n" + + " \"service_code\": \"fedex_international_priority\",\n" + + " \"name\": \"FedEx International Priority®\",\n" + + " \"domestic\": false,\n" + + " \"international\": true,\n" + + " \"is_multi_package_supported\": true\n" + + " },\n" + + " {\n" + + " \"carrier_id\": \"se-656173\",\n" + + " \"carrier_code\": \"fedex\",\n" + + " \"service_code\": \"fedex_international_first\",\n" + + " \"name\": \"FedEx International First®\",\n" + + " \"domestic\": false,\n" + + " \"international\": true,\n" + + " \"is_multi_package_supported\": true\n" + + " },\n" + + " {\n" + + " \"carrier_id\": \"se-656173\",\n" + + " \"carrier_code\": \"fedex\",\n" + + " \"service_code\": \"fedex_international_economy_freight\",\n" + + " \"name\": \"FedEx International Economy® Freight\",\n" + + " \"domestic\": false,\n" + + " \"international\": true,\n" + + " \"is_multi_package_supported\": true\n" + + " },\n" + + " {\n" + + " \"carrier_id\": \"se-656173\",\n" + + " \"carrier_code\": \"fedex\",\n" + + " \"service_code\": \"fedex_international_priority_freight\",\n" + + " \"name\": \"FedEx International Priority® Freight\",\n" + + " \"domestic\": false,\n" + + " \"international\": true,\n" + + " \"is_multi_package_supported\": true\n" + + " },\n" + + " {\n" + + " \"carrier_id\": \"se-656173\",\n" + + " \"carrier_code\": \"fedex\",\n" + + " \"service_code\": \"fedex_international_connect_plus\",\n" + + " \"name\": \"FedEx International Connect Plus®\",\n" + + " \"domestic\": false,\n" + + " \"international\": true,\n" + + " \"is_multi_package_supported\": false\n" + + " }\n" + + " ],\n" + + " \"packages\": [\n" + + " {\n" + + " \"package_id\": null,\n" + + " \"package_code\": \"fedex_envelope_onerate\",\n" + + " \"name\": \"FedEx One Rate® Envelope\",\n" + + " \"description\": \"FedEx® Envelope\"\n" + + " },\n" + + " {\n" + + " \"package_id\": null,\n" + + " \"package_code\": \"fedex_extra_large_box_onerate\",\n" + + " \"name\": \"FedEx One Rate® Extra Large Box\",\n" + + " \"description\": \"FedEx® Extra Large Box\"\n" + + " },\n" + + " {\n" + + " \"package_id\": null,\n" + + " \"package_code\": \"fedex_large_box_onerate\",\n" + + " \"name\": \"FedEx One Rate® Large Box\",\n" + + " \"description\": \"FedEx® Large Box\"\n" + + " },\n" + + " {\n" + + " \"package_id\": null,\n" + + " \"package_code\": \"fedex_medium_box_onerate\",\n" + + " \"name\": \"FedEx One Rate® Medium Box\",\n" + + " \"description\": \"FedEx® Medium Box\"\n" + + " },\n" + + " {\n" + + " \"package_id\": null,\n" + + " \"package_code\": \"fedex_pak_onerate\",\n" + + " \"name\": \"FedEx One Rate® Pak\",\n" + + " \"description\": \"FedEx® Pak\"\n" + + " },\n" + + " {\n" + + " \"package_id\": null,\n" + + " \"package_code\": \"fedex_small_box_onerate\",\n" + + " \"name\": \"FedEx One Rate® Small Box\",\n" + + " \"description\": \"FedEx® Small Box\"\n" + + " },\n" + + " {\n" + + " \"package_id\": null,\n" + + " \"package_code\": \"fedex_tube_onerate\",\n" + + " \"name\": \"FedEx One Rate® Tube\",\n" + + " \"description\": \"FedEx® Tube\"\n" + + " },\n" + + " {\n" + + " \"package_id\": null,\n" + + " \"package_code\": \"fedex_10kg_box\",\n" + + " \"name\": \"FedEx® 10kg Box\",\n" + + " \"description\": \"FedEx® 10kg Box\"\n" + + " },\n" + + " {\n" + + " \"package_id\": null,\n" + + " \"package_code\": \"fedex_25kg_box\",\n" + + " \"name\": \"FedEx® 25kg Box\",\n" + + " \"description\": \"FedEx® 25kg Box\"\n" + + " },\n" + + " {\n" + + " \"package_id\": null,\n" + + " \"package_code\": \"fedex_box\",\n" + + " \"name\": \"FedEx® Box\",\n" + + " \"description\": \"FedEx® Box\"\n" + + " },\n" + + " {\n" + + " \"package_id\": null,\n" + + " \"package_code\": \"fedex_envelope\",\n" + + " \"name\": \"FedEx® Envelope\",\n" + + " \"description\": \"FedEx® Envelope\"\n" + + " },\n" + + " {\n" + + " \"package_id\": null,\n" + + " \"package_code\": \"fedex_extra_large_box\",\n" + + " \"name\": \"FedEx® Extra Large Box\",\n" + + " \"description\": \"FedEx® Extra Large Box\"\n" + + " },\n" + + " {\n" + + " \"package_id\": null,\n" + + " \"package_code\": \"fedex_large_box\",\n" + + " \"name\": \"FedEx® Large Box\",\n" + + " \"description\": \"FedEx® Large Box\"\n" + + " },\n" + + " {\n" + + " \"package_id\": null,\n" + + " \"package_code\": \"fedex_medium_box\",\n" + + " \"name\": \"FedEx® Medium Box\",\n" + + " \"description\": \"FedEx® Medium Box\"\n" + + " },\n" + + " {\n" + + " \"package_id\": null,\n" + + " \"package_code\": \"fedex_pak\",\n" + + " \"name\": \"FedEx® Pak\",\n" + + " \"description\": \"FedEx® Pak\"\n" + + " },\n" + + " {\n" + + " \"package_id\": null,\n" + + " \"package_code\": \"fedex_small_box\",\n" + + " \"name\": \"FedEx® Small Box\",\n" + + " \"description\": \"FedEx® Small Box\"\n" + + " },\n" + + " {\n" + + " \"package_id\": null,\n" + + " \"package_code\": \"fedex_tube\",\n" + + " \"name\": \"FedEx® Tube\",\n" + + " \"description\": \"FedEx® Tube\"\n" + + " },\n" + + " {\n" + + " \"package_id\": null,\n" + + " \"package_code\": \"package\",\n" + + " \"name\": \"Package\",\n" + + " \"description\": \"Package. Longest side plus the distance around the thickest part is less than or equal to 84\\\"\"\n" + + " }\n" + + " ],\n" + + " \"options\": [\n" + + " {\n" + + " \"name\": \"bill_to_account\",\n" + + " \"default_value\": \"\",\n" + + " \"description\": \"\"\n" + + " },\n" + + " {\n" + + " \"name\": \"bill_to_country_code\",\n" + + " \"default_value\": \"\",\n" + + " \"description\": \"\"\n" + + " },\n" + + " {\n" + + " \"name\": \"bill_to_party\",\n" + + " \"default_value\": \"\",\n" + + " \"description\": \"\"\n" + + " },\n" + + " {\n" + + " \"name\": \"bill_to_postal_code\",\n" + + " \"default_value\": \"\",\n" + + " \"description\": \"\"\n" + + " },\n" + + " {\n" + + " \"name\": \"collect_on_delivery\",\n" + + " \"default_value\": \"\",\n" + + " \"description\": \"\"\n" + + " },\n" + + " {\n" + + " \"name\": \"contains_alcohol\",\n" + + " \"default_value\": \"false\",\n" + + " \"description\": \"\"\n" + + " },\n" + + " {\n" + + " \"name\": \"delivered_duty_paid\",\n" + + " \"default_value\": \"false\",\n" + + " \"description\": \"\"\n" + + " },\n" + + " {\n" + + " \"name\": \"dry_ice\",\n" + + " \"default_value\": \"false\",\n" + + " \"description\": \"\"\n" + + " },\n" + + " {\n" + + " \"name\": \"dry_ice_weight\",\n" + + " \"default_value\": \"0\",\n" + + " \"description\": \"\"\n" + + " },\n" + + " {\n" + + " \"name\": \"non_machinable\",\n" + + " \"default_value\": \"false\",\n" + + " \"description\": \"\"\n" + + " },\n" + + " {\n" + + " \"name\": \"saturday_delivery\",\n" + + " \"default_value\": \"false\",\n" + + " \"description\": \"\"\n" + + " }\n" + + " ]\n" + + " }\n" + + " ],\n" + + " \"request_id\": \"81a0e0f0-4fed-4b6b-8965-56e1b82baad2\",\n" + + " \"errors\": []\n" + + "}") + .withDelay(TimeUnit.SECONDS, 1)); + + Map listOfCarriers = new ShipEngine(customConfig).listCarriers(); +// assertEquals(List.class, listOfCarriers.getClass()); + assertEquals(HashMap.class, listOfCarriers.getClass()); + } + + @Test + public void successfulCreateLabelUsingShipmentDetails() { + new MockServerClient("127.0.0.1", 1080) + .when(request() + .withMethod("POST") + .withPath("/v1/labels"), + Times.exactly(1)) + .respond(response() + .withStatusCode(200) + .withBody("{\n" + + " \"batch_id\": \"\",\n" + + " \"carrier_code\": \"stamps_com\",\n" + + " \"carrier_id\": \"se-656171\",\n" + + " \"charge_event\": \"carrier_default\",\n" + + " \"created_at\": \"2021-08-05T16:47:47.8768838Z\",\n" + + " \"display_scheme\": \"label\",\n" + + " \"form_download\": null,\n" + + " \"insurance_claim\": null,\n" + + " \"insurance_cost\": {\n" + + " \"amount\": 0.0,\n" + + " \"currency\": \"usd\"\n" + + " },\n" + + " \"is_international\": false,\n" + + " \"is_return_label\": false,\n" + + " \"label_download\": {\n" + + " \"href\": \"https://api.shipengine.com/v1/downloads/10/_EKGeA4yuEuLzLq81iOzew/label-75693596.pdf\",\n" + + " \"pdf\": \"https://api.shipengine.com/v1/downloads/10/_EKGeA4yuEuLzLq81iOzew/label-75693596.pdf\",\n" + + " \"png\": \"https://api.shipengine.com/v1/downloads/10/_EKGeA4yuEuLzLq81iOzew/label-75693596.png\",\n" + + " \"zpl\": \"https://api.shipengine.com/v1/downloads/10/_EKGeA4yuEuLzLq81iOzew/label-75693596.zpl\"\n" + + " },\n" + + " \"label_format\": \"pdf\",\n" + + " \"label_id\": \"se-799373193\",\n" + + " \"label_image_id\": null,\n" + + " \"label_layout\": \"4x6\",\n" + + " \"package_code\": \"package\",\n" + + " \"packages\": [\n" + + " {\n" + + " \"dimensions\": {\n" + + " \"height\": 0.0,\n" + + " \"length\": 0.0,\n" + + " \"unit\": \"inch\",\n" + + " \"width\": 0.0\n" + + " },\n" + + " \"external_package_id\": null,\n" + + " \"insured_value\": {\n" + + " \"amount\": 0.0,\n" + + " \"currency\": \"usd\"\n" + + " },\n" + + " \"label_messages\": {\n" + + " \"reference1\": null,\n" + + " \"reference2\": null,\n" + + " \"reference3\": null\n" + + " },\n" + + " \"package_code\": \"package\",\n" + + " \"package_id\": 80328023,\n" + + " \"sequence\": 1,\n" + + " \"tracking_number\": \"9400111899560334651289\",\n" + + " \"weight\": {\n" + + " \"unit\": \"ounce\",\n" + + " \"value\": 1.0\n" + + " }\n" + + " }\n" + + " ],\n" + + " \"rma_number\": null,\n" + + " \"service_code\": \"usps_first_class_mail\",\n" + + " \"ship_date\": \"2021-08-05T00:00:00Z\",\n" + + " \"shipment_cost\": {\n" + + " \"amount\": 3.35,\n" + + " \"currency\": \"usd\"\n" + + " },\n" + + " \"shipment_id\": \"se-144794216\",\n" + + " \"status\": \"completed\",\n" + + " \"trackable\": true,\n" + + " \"tracking_number\": \"9400111899560334651289\",\n" + + " \"tracking_status\": \"in_transit\",\n" + + " \"voided\": false,\n" + + " \"voided_at\": null\n" + + "}") + .withDelay(TimeUnit.SECONDS, 1)); + + Map shipmentDetails = new HashMap<>() {{ + put("shipment", new HashMap<>() {{ + put("carrier_id", "se-1234"); + put("service_code", "usps_first_class_mail"); + put("external_order_id", "string"); + put("items", new ArrayList<>()); + put("tax_identifiers", new ArrayList<>() {{ + add(new HashMap<>() {{ + put("taxable_entity_type", "shipper"); + put("identifier_type", "vat"); + put("issuing_authority", "string"); + put("value", "string"); + }}); + }}); + put("external_shipment_id", "string"); + put("ship_date", "2018-09-23T00:00:00.000Z"); + put("ship_to", new HashMap<>() {{ + put("name", "John Doe"); + put("phone", "1-123-456-7894"); + put("company_name", "The Home Depot"); + put("address_line1", "1999 Bishop Grandin Blvd."); + put("address_line2", "Unit 408"); + put("address_line3", "Building #7"); + put("city_locality", "Winnipeg"); + put("state_province", "Manitoba"); + put("postal_code", "78756"); + put("country_code", "CA"); + put("address_residential_indicator", "no"); + }}); + put("ship_from", new HashMap<>() {{ + put("name", "John Doe"); + put("phone", "1-123-456-7894"); + put("company_name", "The Home Depot"); + put("address_line1", "1999 Bishop Grandin Blvd."); + put("address_line2", "Unit 408"); + put("address_line3", "Building #7"); + put("city_locality", "Winnipeg"); + put("state_province", "Manitoba"); + put("postal_code", "78756"); + put("country_code", "CA"); + put("address_residential_indicator", "no"); + }}); + }}); + }}; + + Map labelData = new ShipEngine(customConfig).createLabelFromShipmentDetails(shipmentDetails); + assertEquals("stamps_com", labelData.get("carrier_code")); + } + + @Test + public void successfulCreateLabelUsingLabelId() { + new MockServerClient("127.0.0.1", 1080) + .when(request() + .withMethod("POST") + .withPath("/v1/labels/rates/se-1234"), + Times.exactly(1)) + .respond(response() + .withStatusCode(200) + .withBody("{\n" + + " \"batch_id\": \"\",\n" + + " \"carrier_code\": \"stamps_com\",\n" + + " \"carrier_id\": \"se-656171\",\n" + + " \"charge_event\": \"carrier_default\",\n" + + " \"created_at\": \"2021-08-05T16:47:47.8768838Z\",\n" + + " \"display_scheme\": \"label\",\n" + + " \"form_download\": null,\n" + + " \"insurance_claim\": null,\n" + + " \"insurance_cost\": {\n" + + " \"amount\": 0.0,\n" + + " \"currency\": \"usd\"\n" + + " },\n" + + " \"is_international\": false,\n" + + " \"is_return_label\": false,\n" + + " \"label_download\": {\n" + + " \"href\": \"https://api.shipengine.com/v1/downloads/10/_EKGeA4yuEuLzLq81iOzew/label-75693596.pdf\",\n" + + " \"pdf\": \"https://api.shipengine.com/v1/downloads/10/_EKGeA4yuEuLzLq81iOzew/label-75693596.pdf\",\n" + + " \"png\": \"https://api.shipengine.com/v1/downloads/10/_EKGeA4yuEuLzLq81iOzew/label-75693596.png\",\n" + + " \"zpl\": \"https://api.shipengine.com/v1/downloads/10/_EKGeA4yuEuLzLq81iOzew/label-75693596.zpl\"\n" + + " },\n" + + " \"label_format\": \"pdf\",\n" + + " \"label_id\": \"se-799373193\",\n" + + " \"label_image_id\": null,\n" + + " \"label_layout\": \"4x6\",\n" + + " \"package_code\": \"package\",\n" + + " \"packages\": [\n" + + " {\n" + + " \"dimensions\": {\n" + + " \"height\": 0.0,\n" + + " \"length\": 0.0,\n" + + " \"unit\": \"inch\",\n" + + " \"width\": 0.0\n" + + " },\n" + + " \"external_package_id\": null,\n" + + " \"insured_value\": {\n" + + " \"amount\": 0.0,\n" + + " \"currency\": \"usd\"\n" + + " },\n" + + " \"label_messages\": {\n" + + " \"reference1\": null,\n" + + " \"reference2\": null,\n" + + " \"reference3\": null\n" + + " },\n" + + " \"package_code\": \"package\",\n" + + " \"package_id\": 80328023,\n" + + " \"sequence\": 1,\n" + + " \"tracking_number\": \"9400111899560334651289\",\n" + + " \"weight\": {\n" + + " \"unit\": \"ounce\",\n" + + " \"value\": 1.0\n" + + " }\n" + + " }\n" + + " ],\n" + + " \"rma_number\": null,\n" + + " \"service_code\": \"usps_first_class_mail\",\n" + + " \"ship_date\": \"2021-08-05T00:00:00Z\",\n" + + " \"shipment_cost\": {\n" + + " \"amount\": 3.35,\n" + + " \"currency\": \"usd\"\n" + + " },\n" + + " \"shipment_id\": \"se-144794216\",\n" + + " \"status\": \"completed\",\n" + + " \"trackable\": true,\n" + + " \"tracking_number\": \"9400111899560334651289\",\n" + + " \"tracking_status\": \"in_transit\",\n" + + " \"voided\": false,\n" + + " \"voided_at\": null\n" + + "}") + .withDelay(TimeUnit.SECONDS, 1)); + + String labelId = "se-1234"; + Map labelParams = new HashMap<>() {{ + put("label_layout", "4x6"); + put("label_format", "pdf"); + put("label_download_type", "url"); + }}; + + Map labelData = new ShipEngine(customConfig).createLabelFromRateId(labelId, labelParams); + assertEquals("se-799373193", labelData.get("label_id")); + } + + @Test + public void successfulVoidLabelWithLabelId() { + String labelId = "se-799373193"; + new MockServerClient("127.0.0.1", 1080) + .when(request() + .withMethod("GET") + .withPath("/v1/labels/se-799373193/void"), + Times.exactly(1)) + .respond(response() + .withStatusCode(200) + .withBody("{\n" + + " \"approved\": true,\n" + + " \"message\": \"This label has been voided.\"\n" + + "}") + .withDelay(TimeUnit.SECONDS, 1)); + + Map voidLabelResult = new ShipEngine(customConfig).voidLabelWithLabelId(labelId); + assertEquals("This label has been voided.", voidLabelResult.get("message")); + } + + @Test + public void successfulGetRateFromShipmentDetails() { + new MockServerClient("127.0.0.1", 1080) + .when(request() + .withMethod("POST") + .withPath("/v1/rates"), + Times.exactly(1)) + .respond(response() + .withStatusCode(200) + .withBody("{\n" + + " \"shipmentId\": \"se-141694059\",\n" + + " \"carrierId\": \"se-161650\",\n" + + " \"serviceCode\": \"usps_first_class_mail\",\n" + + " \"externalOrderId\": null,\n" + + " \"items\": [],\n" + + " \"taxIdentifiers\": null,\n" + + " \"externalShipmentId\": null,\n" + + " \"shipDate\": \"2021-07-28T00:00:00Z\",\n" + + " \"createdAt\": \"2021-07-28T16:56:40.257Z\",\n" + + " \"modifiedAt\": \"2021-07-28T16:56:40.223Z\",\n" + + " \"shipmentStatus\": \"pending\",\n" + + " \"shipTo\": {\n" + + " \"name\": \"James Atkinson\",\n" + + " \"phone\": null,\n" + + " \"companyName\": null,\n" + + " \"addressLine1\": \"28793 Fox Fire Lane\",\n" + + " \"addressLine2\": null,\n" + + " \"addressLine3\": null,\n" + + " \"cityLocality\": \"Shell Knob\",\n" + + " \"stateProvince\": \"MO\",\n" + + " \"postalCode\": \"65747\",\n" + + " \"countryCode\": \"US\",\n" + + " \"addressResidentialIndicator\": \"yes\"\n" + + " },\n" + + " \"shipFrom\": {\n" + + " \"name\": \"Medals of America\",\n" + + " \"phone\": \"800-308-0849\",\n" + + " \"companyName\": null,\n" + + " \"addressLine1\": \"114 Southchase Blvd\",\n" + + " \"addressLine2\": null,\n" + + " \"addressLine3\": null,\n" + + " \"cityLocality\": \"Fountain Inn\",\n" + + " \"stateProvince\": \"SC\",\n" + + " \"postalCode\": \"29644\",\n" + + " \"countryCode\": \"US\",\n" + + " \"addressResidentialIndicator\": \"unknown\"\n" + + " },\n" + + " \"warehouseId\": null,\n" + + " \"returnTo\": {\n" + + " \"name\": \"Medals of America\",\n" + + " \"phone\": \"800-308-0849\",\n" + + " \"companyName\": null,\n" + + " \"addressLine1\": \"114 Southchase Blvd\",\n" + + " \"addressLine2\": null,\n" + + " \"addressLine3\": null,\n" + + " \"cityLocality\": \"Fountain Inn\",\n" + + " \"stateProvince\": \"SC\",\n" + + " \"postalCode\": \"29644\",\n" + + " \"countryCode\": \"US\",\n" + + " \"addressResidentialIndicator\": \"unknown\"\n" + + " },\n" + + " \"confirmation\": \"none\",\n" + + " \"customs\": {\n" + + " \"contents\": \"merchandise\",\n" + + " \"nonDelivery\": \"return_to_sender\",\n" + + " \"customsItems\": []\n" + + " },\n" + + " \"advancedOptions\": {\n" + + " \"billToAccount\": null,\n" + + " \"billToCountryCode\": null,\n" + + " \"billToParty\": null,\n" + + " \"billToPostalCode\": null,\n" + + " \"containsAlcohol\": null,\n" + + " \"deliveryDutyPaid\": null,\n" + + " \"dryIce\": null,\n" + + " \"dryIceWeight\": null,\n" + + " \"nonMachinable\": null,\n" + + " \"saturdayDelivery\": null,\n" + + " \"useUPSGroundFreightPricing\": null,\n" + + " \"freightClass\": null,\n" + + " \"customField1\": null,\n" + + " \"customField2\": null,\n" + + " \"customField3\": null,\n" + + " \"originType\": null,\n" + + " \"shipperRelease\": null,\n" + + " \"collectOnDelivery\": null\n" + + " },\n" + + " \"originType\": null,\n" + + " \"insuranceProvider\": \"none\",\n" + + " \"tags\": [],\n" + + " \"orderSourceCode\": null,\n" + + " \"packages\": [\n" + + " {\n" + + " \"packageCode\": \"package\",\n" + + " \"weight\": {\n" + + " \"value\": 2.9,\n" + + " \"unit\": \"ounce\"\n" + + " },\n" + + " \"dimensions\": {\n" + + " \"unit\": \"inch\",\n" + + " \"length\": 0,\n" + + " \"width\": 0,\n" + + " \"height\": 0\n" + + " },\n" + + " \"insuredValue\": {\n" + + " \"currency\": \"usd\",\n" + + " \"amount\": 0\n" + + " },\n" + + " \"trackingNumber\": null,\n" + + " \"labelMessages\": {\n" + + " \"reference1\": \"4051492\",\n" + + " \"reference2\": null,\n" + + " \"reference3\": null\n" + + " },\n" + + " \"externalPackageId\": null\n" + + " }\n" + + " ],\n" + + " \"totalWeight\": {\n" + + " \"value\": 2.9,\n" + + " \"unit\": \"ounce\"\n" + + " },\n" + + " \"rateResponse\": {\n" + + " \"rates\": [\n" + + " {\n" + + " \"rateId\": \"se-784001113\",\n" + + " \"rateType\": \"shipment\",\n" + + " \"carrierId\": \"se-161650\",\n" + + " \"shippingAmount\": {\n" + + " \"currency\": \"usd\",\n" + + " \"amount\": 3.12\n" + + " },\n" + + " \"insuranceAmount\": {\n" + + " \"currency\": \"usd\",\n" + + " \"amount\": 0\n" + + " },\n" + + " \"confirmationAmount\": {\n" + + " \"currency\": \"usd\",\n" + + " \"amount\": 0\n" + + " },\n" + + " \"otherAmount\": {\n" + + " \"currency\": \"usd\",\n" + + " \"amount\": 0\n" + + " },\n" + + " \"taxAmount\": null,\n" + + " \"zone\": 5,\n" + + " \"packageType\": \"package\",\n" + + " \"deliveryDays\": 3,\n" + + " \"guaranteedService\": false,\n" + + " \"estimatedDeliveryDate\": \"2021-07-31T00:00:00Z\",\n" + + " \"carrierDeliveryDays\": \"3\",\n" + + " \"shipDate\": \"2021-07-28T00:00:00Z\",\n" + + " \"negotiatedRate\": false,\n" + + " \"serviceType\": \"USPS First Class Mail\",\n" + + " \"serviceCode\": \"usps_first_class_mail\",\n" + + " \"trackable\": true,\n" + + " \"carrierCode\": \"usps\",\n" + + " \"carrierNickname\": \"USPS\",\n" + + " \"carrierFriendlyName\": \"USPS\",\n" + + " \"validationStatus\": \"valid\",\n" + + " \"warningMessages\": [],\n" + + " \"errorMessages\": []\n" + + " }\n" + + " ],\n" + + " \"invalidRates\": [],\n" + + " \"rateRequestId\": \"se-85117731\",\n" + + " \"shipmentId\": \"se-141694059\",\n" + + " \"createdAt\": \"2021-07-28T16:56:40.6148892Z\",\n" + + " \"status\": \"completed\",\n" + + " \"errors\": []\n" + + " }\n" + + "}") + .withDelay(TimeUnit.SECONDS, 1)); + + Map shipmentDetails = new HashMap<>() {{ + put("shipment", new HashMap<>() {{ + put("carrier_id", "se-1234"); + put("service_code", "usps_first_class_mail"); + put("external_order_id", "string"); + put("items", new ArrayList<>()); + put("tax_identifiers", new ArrayList<>() {{ + add(new HashMap<>() {{ + put("taxable_entity_type", "shipper"); + put("identifier_type", "vat"); + put("issuing_authority", "string"); + put("value", "string"); + }}); + }}); + put("external_shipment_id", "string"); + put("ship_date", "2018-09-23T00:00:00.000Z"); + put("ship_to", new HashMap<>() {{ + put("name", "John Doe"); + put("phone", "1-123-456-7894"); + put("company_name", "The Home Depot"); + put("address_line1", "1999 Bishop Grandin Blvd."); + put("address_line2", "Unit 408"); + put("address_line3", "Building #7"); + put("city_locality", "Winnipeg"); + put("state_province", "Manitoba"); + put("postal_code", "78756"); + put("country_code", "CA"); + put("address_residential_indicator", "no"); + }}); + put("ship_from", new HashMap<>() {{ + put("name", "John Doe"); + put("phone", "1-123-456-7894"); + put("company_name", "The Home Depot"); + put("address_line1", "1999 Bishop Grandin Blvd."); + put("address_line2", "Unit 408"); + put("address_line3", "Building #7"); + put("city_locality", "Winnipeg"); + put("state_province", "Manitoba"); + put("postal_code", "78756"); + put("country_code", "CA"); + put("address_residential_indicator", "no"); + }}); + }}); + }}; + + Map rateData = new ShipEngine(customConfig).getRatesWithShipmentDetails(shipmentDetails); + assertEquals("se-141694059", rateData.get("shipmentId")); } -} +} \ No newline at end of file