diff --git a/.editorconfig b/.editorconfig
index 2925810..3218641 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -18,7 +18,11 @@ indent_size = 4
indent_style = space
indent_size = 2
+[*.json]
+indent_style = space
+indent_size = 2
+
# Matches the exact files either package.json or .travis.yml
[{pom.xml}]
indent_style = tab
-indent_size = 4
\ No newline at end of file
+indent_size = 4
diff --git a/.github/workflows/code-analysis.yaml b/.github/workflows/code-analysis.yaml
deleted file mode 100644
index 7211604..0000000
--- a/.github/workflows/code-analysis.yaml
+++ /dev/null
@@ -1,39 +0,0 @@
-name: SonarQube
-on:
- push:
- branches:
- - main
- pull_request:
- types: [opened, synchronize, reopened]
-jobs:
- build:
- name: Build and analyze
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- with:
- fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- - name: Set up JDK 17
- uses: actions/setup-java@v4
- with:
- java-version: 17
- distribution: 'temurin' # Alternative distribution options are available.
- - name: Cache SonarQube packages
- uses: actions/cache@v4
- with:
- path: ~/.sonar/cache
- key: ${{ runner.os }}-sonar
- restore-keys: ${{ runner.os }}-sonar
- - name: Cache Maven packages
- uses: actions/cache@v4
- with:
- path: ~/.m2/repository
- key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
- restore-keys: |
- ${{ runner.os }}-maven-
- - name: Build and analyze
- env:
- SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
- run: >
- mvn -B verify org.sonarsource.scanner.maven:sonar-maven-plugin:5.1.0.4751:sonar
- -Dsonar.projectKey=pgmarc_space-java-client -Pcoverage
\ No newline at end of file
diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
index 9b0b77a..2918079 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -1,7 +1,7 @@
name: Run tests
on: push
jobs:
- build:
+ tests:
runs-on: ubuntu-latest
steps:
- name: Checkout code
@@ -12,4 +12,4 @@ jobs:
java-version: '11'
distribution: 'temurin'
- name: Run tests
- run: mvn test
\ No newline at end of file
+ run: mvn test
diff --git a/pom.xml b/pom.xml
index fb34988..f0809ae 100644
--- a/pom.xml
+++ b/pom.xml
@@ -137,51 +137,28 @@
junit-jupiter
test
-
- org.mock-server
- mockserver-junit-jupiter-no-dependencies
- 5.14.0
- test
-
+
+ org.wiremock
+ wiremock
+ 3.13.1
+ test
+
+
+ org.assertj
+ assertj-core
+ 3.26.3
+ test
+
-
- org.apache.maven.plugins
- maven-checkstyle-plugin
- 3.6.0
-
-
- com.puppycrawl.tools
- checkstyle
- 10.26.1
-
-
-
org.jacoco
jacoco-maven-plugin
0.8.13
-
- com.github.spotbugs
- spotbugs-maven-plugin
- 4.9.3.2
-
-
- com.github.spotbugs
- spotbugs
- 4.9.4
-
-
-
-
- org.apache.maven.plugins
- maven-pmd-plugin
- 3.27.0
-
@@ -246,68 +223,6 @@
-
- org.apache.maven.plugins
- maven-checkstyle-plugin
-
- google_checks.xml
- true
- true
- false
-
-
-
- validate
- validate
-
- check
-
-
-
-
-
- com.github.spotbugs
- spotbugs-maven-plugin
-
-
-
- com.h3xstream.findsecbugs
- findsecbugs-plugin
- 1.12.0
-
-
-
-
-
- check
-
- check
-
-
-
-
-
- org.apache.maven.plugins
- maven-pmd-plugin
-
- true
- false
-
-
-
- check
-
- check
-
-
-
- cpd-check
-
- cpd-check
-
-
-
-
diff --git a/src/main/java/io/github/pgmarc/space/Config.java b/src/main/java/io/github/pgmarc/space/Config.java
new file mode 100644
index 0000000..0c046b8
--- /dev/null
+++ b/src/main/java/io/github/pgmarc/space/Config.java
@@ -0,0 +1,98 @@
+package io.github.pgmarc.space;
+
+import java.time.Duration;
+import java.util.Objects;
+
+import okhttp3.HttpUrl;
+
+public final class Config {
+
+ private static final String DEFAULT_SCHEME = "http";
+
+ private final String apiKey;
+ private final String host;
+ private final int port;
+ private final String prefixPath;
+ private final Duration readTimeout;
+ private final Duration writeTimeout;
+
+ private Config(Builder builder) {
+ this.host = builder.host;
+ this.apiKey = builder.apiKey;
+ this.port = builder.port;
+ this.prefixPath = builder.prefixPath;
+ this.readTimeout = builder.readTimeout;
+ this.writeTimeout = builder.writeTimeout;
+ }
+
+ public String getApiKey() {
+ return apiKey;
+ }
+
+ public Duration getReadTimeout() {
+ return readTimeout;
+ }
+
+ public Duration getWriteTimeout() {
+ return writeTimeout;
+ }
+
+ public HttpUrl getUrl() {
+ return new HttpUrl.Builder().scheme(DEFAULT_SCHEME)
+ .host(this.host).port(this.port)
+ .addPathSegments(prefixPath).build();
+ }
+
+ public static Builder builder(String host, String apiKey) {
+ return new Builder(host, apiKey);
+ }
+
+ public static final class Builder {
+
+ private final String host;
+ private final String apiKey;
+ private int port = 5403;
+ private String prefixPath = "api/v1";
+ private Duration readTimeout;
+ private Duration writeTimeout;
+
+ private Builder(String host, String apiKey) {
+ this.host = host;
+ this.apiKey = apiKey;
+ }
+
+ public Builder port(int port) {
+ this.port = port;
+ return this;
+ }
+
+ public Builder readTimeout(Duration duration) {
+ if (duration != null) {
+ this.readTimeout = duration;
+ }
+ return this;
+ }
+
+ public Builder writeTimeout(Duration duration) {
+ if (duration != null) {
+ this.writeTimeout = duration;
+ }
+ return this;
+ }
+
+ public Builder prefixPath(String prefixPath) {
+ if (prefixPath != null) {
+ this.prefixPath = prefixPath;
+ }
+ return this;
+ }
+
+ public Config build() {
+ Objects.requireNonNull(this.host, "host must not be null");
+ Objects.requireNonNull(this.apiKey, "api key must not be null");
+ return new Config(this);
+ }
+
+ }
+
+}
diff --git a/src/main/java/io/github/pgmarc/space/contracts/AddOn.java b/src/main/java/io/github/pgmarc/space/contracts/AddOn.java
new file mode 100644
index 0000000..07a4104
--- /dev/null
+++ b/src/main/java/io/github/pgmarc/space/contracts/AddOn.java
@@ -0,0 +1,46 @@
+package io.github.pgmarc.space.contracts;
+
+public final class AddOn {
+
+ private final String name;
+ private final long quantity;
+
+ AddOn(String name, long quantity) {
+ this.name = name;
+ this.quantity = quantity;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public long getQuantity() {
+ return quantity;
+ }
+
+ @Override
+ public int hashCode() {
+ final int prime = 31;
+ int result = 1;
+ result = prime * result + ((name == null) ? 0 : name.hashCode());
+ return result;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj)
+ return true;
+ if (obj == null)
+ return false;
+ if (getClass() != obj.getClass())
+ return false;
+ AddOn other = (AddOn) obj;
+ if (name == null) {
+ if (other.name != null)
+ return false;
+ } else if (!name.equals(other.name))
+ return false;
+ return true;
+ }
+
+}
diff --git a/src/main/java/io/github/pgmarc/space/contracts/BillingPeriod.java b/src/main/java/io/github/pgmarc/space/contracts/BillingPeriod.java
new file mode 100644
index 0000000..2c057de
--- /dev/null
+++ b/src/main/java/io/github/pgmarc/space/contracts/BillingPeriod.java
@@ -0,0 +1,131 @@
+package io.github.pgmarc.space.contracts;
+
+import java.time.Duration;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.util.Objects;
+import java.util.Optional;
+
+public final class BillingPeriod {
+
+ private final ZonedDateTime startDate;
+ private final ZonedDateTime endDate;
+ private Duration renewalDays;
+
+ private BillingPeriod(ZonedDateTime startDate, ZonedDateTime endDate, Duration renewalDays) {
+ this.startDate = startDate;
+ this.endDate = endDate;
+ this.renewalDays = renewalDays;
+ }
+
+ LocalDateTime getStartDate() {
+ return startDate.toLocalDateTime();
+ }
+
+ LocalDateTime getEndDate() {
+ return endDate.toLocalDateTime();
+ }
+
+ Duration getDuration() {
+ return renewalDays;
+ }
+
+ boolean isExpired(LocalDateTime dateTime) {
+ return endDate.isAfter(ZonedDateTime.of(dateTime, ZoneId.of("UTC")));
+ }
+
+ boolean isAutoRenewable() {
+ return renewalDays != null;
+ }
+
+ Optional getRenewalDate() {
+ return Optional.ofNullable(isAutoRenewable() ? endDate.plus(renewalDays).toLocalDateTime() : null);
+ }
+
+ public void setRenewalDays(Duration renewalDays) {
+ validateRenewalDays(renewalDays);
+ this.renewalDays = renewalDays;
+ }
+
+ private static void validateRenewalDays(Duration renewalDays) {
+ if (renewalDays != null && renewalDays.toDays() <= 0) {
+ throw new IllegalArgumentException("your subscription cannot expire in less than one day");
+ }
+ }
+
+ public static BillingPeriod of(ZonedDateTime startDate, ZonedDateTime endDate) {
+ return of(startDate, endDate, null);
+ }
+
+ public static BillingPeriod of(ZonedDateTime startDate, ZonedDateTime endDate, Duration renewalDays) {
+ Objects.requireNonNull(startDate, "startDate must not be null");
+ Objects.requireNonNull(endDate, "endDate must not be null");
+ if (startDate.isAfter(endDate)) {
+ throw new IllegalStateException("startDate is after endDate");
+ }
+ validateRenewalDays(renewalDays);
+ return new BillingPeriod(startDate, endDate, renewalDays);
+ }
+
+ public enum Keys {
+
+ START_DATE("startDate"),
+ END_DATE("endDate"),
+ AUTORENEW("autoRenew"),
+ RENEWAL_DAYS("renewalDays");
+
+ private final String name;
+
+ private Keys(String name) {
+ this.name = name;
+ }
+
+ @Override
+ public String toString() {
+ return name;
+ }
+ }
+
+ @Override
+ public int hashCode() {
+ final int prime = 31;
+ int result = 1;
+ result = prime * result + ((startDate == null) ? 0 : startDate.hashCode());
+ result = prime * result + ((endDate == null) ? 0 : endDate.hashCode());
+ result = prime * result + ((renewalDays == null) ? 0 : renewalDays.hashCode());
+ return result;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj)
+ return true;
+ if (obj == null)
+ return false;
+ if (getClass() != obj.getClass())
+ return false;
+ BillingPeriod other = (BillingPeriod) obj;
+ if (startDate == null) {
+ if (other.startDate != null)
+ return false;
+ } else if (!startDate.equals(other.startDate))
+ return false;
+ if (endDate == null) {
+ if (other.endDate != null)
+ return false;
+ } else if (!endDate.equals(other.endDate))
+ return false;
+ if (renewalDays == null) {
+ if (other.renewalDays != null)
+ return false;
+ } else if (!renewalDays.equals(other.renewalDays))
+ return false;
+ return true;
+ }
+
+ @Override
+ public String toString() {
+ return "From " + startDate + " to " + endDate + ", renews in " + renewalDays;
+ }
+}
diff --git a/src/main/java/io/github/pgmarc/space/contracts/ContractsEndpoint.java b/src/main/java/io/github/pgmarc/space/contracts/ContractsEndpoint.java
new file mode 100644
index 0000000..2014a99
--- /dev/null
+++ b/src/main/java/io/github/pgmarc/space/contracts/ContractsEndpoint.java
@@ -0,0 +1,103 @@
+package io.github.pgmarc.space.contracts;
+
+import java.io.IOException;
+import java.util.Objects;
+
+import org.json.JSONObject;
+
+import io.github.pgmarc.space.deserializers.ErrorDeserializer;
+import io.github.pgmarc.space.deserializers.SubscriptionDeserializer;
+import io.github.pgmarc.space.exceptions.SpaceApiException;
+import io.github.pgmarc.space.serializers.SubscriptionRequestSerializer;
+import io.github.pgmarc.space.serializers.SubscriptionUpdateRequestSerializer;
+import okhttp3.Headers;
+import okhttp3.HttpUrl;
+import okhttp3.MediaType;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.RequestBody;
+import okhttp3.Response;
+import okhttp3.ResponseBody;
+
+public final class ContractsEndpoint {
+
+ private static final MediaType JSON = MediaType.get("application/json");
+ private static final String ENDPOINT = "contracts";
+ private static final String STATUS_CODE = "statusCode";
+
+ private final OkHttpClient client;
+ private final HttpUrl baseUrl;
+ private final SubscriptionDeserializer subscriptionDeserializer = new SubscriptionDeserializer();
+ private final SubscriptionRequestSerializer subscriptionRequestSerializer = new SubscriptionRequestSerializer();
+ private final SubscriptionUpdateRequestSerializer subscriptionUpdateRequestSerializer = new SubscriptionUpdateRequestSerializer();
+ private final ErrorDeserializer errorDeserializer = new ErrorDeserializer();
+ private final Headers requiredHeaders;
+
+ public ContractsEndpoint(OkHttpClient client, HttpUrl baseUrl, String apiKey) {
+ this.client = client;
+ this.baseUrl = baseUrl;
+ this.requiredHeaders = new Headers.Builder().add("Accept", JSON.toString())
+ .add("x-api-key", apiKey).build();
+ }
+
+ public Subscription addContract(SubscriptionRequest subscriptionReq) throws IOException {
+ Objects.requireNonNull(subscriptionReq, "subscription request must not be null");
+
+ HttpUrl url = this.baseUrl.newBuilder().addPathSegment(ENDPOINT).build();
+ Subscription res = null;
+ Request request = new Request.Builder().url(url)
+ .post(RequestBody.create(subscriptionRequestSerializer.toJson(subscriptionReq).toString(), JSON))
+ .headers(requiredHeaders).build();
+ try (Response response = client.newCall(request).execute()) {
+ ResponseBody responseBody = response.body();
+ JSONObject jsonResponse = new JSONObject(responseBody.string());
+ if (!response.isSuccessful()) {
+ jsonResponse.put(STATUS_CODE, response.code());
+ throw new SpaceApiException(errorDeserializer.fromJson(jsonResponse));
+ }
+
+ res = subscriptionDeserializer.fromJson(jsonResponse);
+ }
+
+ return res;
+ }
+
+ public Subscription getContractByUserId(String userId) throws IOException {
+
+ HttpUrl url = this.baseUrl.newBuilder().addPathSegment(ENDPOINT).addEncodedPathSegment(userId).build();
+ Subscription res = null;
+ Request request = new Request.Builder().url(url).headers(requiredHeaders).build();
+ try (Response response = client.newCall(request).execute()) {
+ JSONObject jsonResponse = new JSONObject(response.body().string());
+ if (!response.isSuccessful()) {
+ jsonResponse.put(STATUS_CODE, response.code());
+ throw new SpaceApiException(errorDeserializer.fromJson(jsonResponse));
+ }
+ res = subscriptionDeserializer.fromJson(jsonResponse);
+ }
+
+ return res;
+ }
+
+ public Subscription updateContractByUserId(String userId, SubscriptionUpdateRequest subscription)
+ throws IOException {
+ HttpUrl url = this.baseUrl.newBuilder().addPathSegment(ENDPOINT).addEncodedPathSegment(userId).build();
+ Subscription res = null;
+ Request request = new Request.Builder().url(url)
+ .put(RequestBody.create(subscriptionUpdateRequestSerializer.toJson(subscription).toString(), JSON))
+ .headers(requiredHeaders)
+ .build();
+ try (Response response = client.newCall(request).execute()) {
+ ResponseBody responseBody = response.body();
+ JSONObject jsonResponse = new JSONObject(responseBody.string());
+ if (!response.isSuccessful()) {
+ jsonResponse.put(STATUS_CODE, response.code());
+ throw new SpaceApiException(errorDeserializer.fromJson(jsonResponse));
+ }
+ res = subscriptionDeserializer.fromJson(jsonResponse);
+ }
+
+ return res;
+ }
+
+}
diff --git a/src/main/java/io/github/pgmarc/space/contracts/Service.java b/src/main/java/io/github/pgmarc/space/contracts/Service.java
new file mode 100644
index 0000000..98cb1d9
--- /dev/null
+++ b/src/main/java/io/github/pgmarc/space/contracts/Service.java
@@ -0,0 +1,138 @@
+package io.github.pgmarc.space.contracts;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+
+public final class Service {
+
+ private final String name;
+ private final String version;
+ private final Map addOns;
+ private String plan;
+
+ private Service(Builder builder) {
+ this.name = builder.name;
+ this.version = builder.version;
+ this.addOns = Collections.unmodifiableMap(builder.addOns);
+ this.plan = builder.plan;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public String getVersion() {
+ return version;
+ }
+
+ public Optional getPlan() {
+ return Optional.ofNullable(plan);
+ }
+
+ public Optional getAddOn(String addOn) {
+ Objects.requireNonNull(addOn, "key must not be null");
+ return Optional.ofNullable(this.addOns.get(addOn));
+ }
+
+ public Set getAddOns() {
+ return Set.copyOf(this.addOns.values());
+ }
+
+ public static Builder builder(String name, String version) {
+ return new Builder(name, version);
+ }
+
+ @Override
+ public String toString() {
+ return name + ": " + version + " plan" + plan + " addOns " + addOns;
+ }
+
+ @Override
+ public int hashCode() {
+ final int prime = 31;
+ int result = 1;
+ result = prime * result + ((name == null) ? 0 : name.hashCode());
+ result = prime * result + ((version == null) ? 0 : version.hashCode());
+ result = prime * result + ((addOns == null) ? 0 : addOns.hashCode());
+ result = prime * result + ((plan == null) ? 0 : plan.hashCode());
+ return result;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj)
+ return true;
+ if (obj == null)
+ return false;
+ if (getClass() != obj.getClass())
+ return false;
+ Service other = (Service) obj;
+ if (name == null) {
+ if (other.name != null)
+ return false;
+ } else if (!name.equals(other.name))
+ return false;
+ if (version == null) {
+ if (other.version != null)
+ return false;
+ } else if (!version.equals(other.version))
+ return false;
+ if (addOns == null) {
+ if (other.addOns != null)
+ return false;
+ } else if (!addOns.equals(other.addOns))
+ return false;
+ if (plan == null) {
+ if (other.plan != null)
+ return false;
+ } else if (!plan.equals(other.plan))
+ return false;
+ return true;
+ }
+
+ public static final class Builder {
+
+ private final String name;
+ private final String version;
+ private final Map addOns = new HashMap<>();
+ private String plan;
+
+ private Builder(String name, String version) {
+ this.name = name;
+ this.version = version;
+ }
+
+ public Builder plan(String plan) {
+ if (plan.isBlank()) {
+ throw new IllegalArgumentException("plan must not be blank");
+ }
+ this.plan = plan;
+ return this;
+ }
+
+ public Builder addOn(String name, long quantity) {
+ Objects.requireNonNull(name, "add-on name must not be null");
+ if (name.isBlank()) {
+ throw new IllegalArgumentException("add-on name must no be blank");
+ }
+ if (quantity <= 0) {
+ throw new IllegalArgumentException(name + " quantity must be greater than 0");
+ }
+ this.addOns.put(name, new AddOn(name, quantity));
+ return this;
+ }
+
+ public Service build() {
+ Objects.requireNonNull(name, "name must not be null");
+ Objects.requireNonNull(version, "version must not be null");
+ if (plan == null && this.addOns.isEmpty()) {
+ throw new IllegalStateException("At least you have to be subscribed to a plan or add-on");
+ }
+ return new Service(this);
+ }
+ }
+}
diff --git a/src/main/java/io/github/pgmarc/space/contracts/Subscription.java b/src/main/java/io/github/pgmarc/space/contracts/Subscription.java
new file mode 100644
index 0000000..9c1f552
--- /dev/null
+++ b/src/main/java/io/github/pgmarc/space/contracts/Subscription.java
@@ -0,0 +1,246 @@
+package io.github.pgmarc.space.contracts;
+
+import java.time.Duration;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+public final class Subscription {
+
+ private final UserContact userContact;
+ private final Map services;
+ private final BillingPeriod billingPeriod;
+ private final List history;
+ private final Map> usageLevels;
+
+ private Subscription(Builder builder) {
+ this.userContact = builder.userContact;
+ this.billingPeriod = builder.billingPeriod;
+ this.services = Collections.unmodifiableMap(builder.services);
+ this.history = Collections.unmodifiableList(builder.history);
+ this.usageLevels = Collections.unmodifiableMap(builder.usageLevels);
+ }
+
+ public static Builder builder(UserContact userContact, BillingPeriod billingPeriod,
+ Service service) {
+ return new Builder(userContact, billingPeriod).subscribe(service);
+ }
+
+ public static Builder builder(UserContact usagerContact, BillingPeriod billingPeriod,
+ Collection services) {
+ return new Builder(usagerContact, billingPeriod).subscribeAll(services);
+ }
+
+ public LocalDateTime getStartDate() {
+ return billingPeriod.getStartDate();
+ }
+
+ public LocalDateTime getEndDate() {
+ return billingPeriod.getEndDate();
+ }
+
+ public Optional getRenewalDuration() {
+ return Optional.of(billingPeriod.getDuration());
+ }
+
+ public boolean isAutoRenewable() {
+ return billingPeriod.isAutoRenewable();
+ }
+
+ public boolean isExpired(LocalDateTime date) {
+ return billingPeriod.isExpired(date);
+ }
+
+ public Optional getRenewalDate() {
+ return billingPeriod.getRenewalDate();
+ }
+
+ public String getUserId() {
+ return userContact.getUserId();
+ }
+
+ public String getUsername() {
+ return userContact.getUsername();
+ }
+
+ public Optional getService(String serviceName) {
+ return Optional.ofNullable(this.services.get(serviceName));
+ }
+
+ public Map getServicesMap() {
+ return services;
+ }
+
+ public Set getServices() {
+ return Set.copyOf(services.values());
+ }
+
+ public List getHistory() {
+ return history;
+ }
+
+ public Map> getUsageLevels() {
+ return usageLevels;
+ }
+
+ public enum Keys {
+ USER_CONTACT("userContact"),
+ BILLING_PERIOD("billingPeriod"),
+ CONTRACTED_SERVICES("contractedServices"),
+ SUBSCRIPTION_PLANS("subscriptionPlans"),
+ SUBSCRIPTION_ADDONS("subscriptionAddOns"),
+ USAGE_LEVEL("usageLevel"),
+ HISTORY("history");
+
+ private final String name;
+
+ private Keys(String name) {
+ this.name = name;
+ }
+
+ @Override
+ public String toString() {
+ return name;
+ }
+ }
+
+ public static final class Builder {
+
+ private final BillingPeriod billingPeriod;
+ private final UserContact userContact;
+ private final Map services = new HashMap<>();
+ private final List history = new ArrayList<>();
+ private final Map> usageLevels = new HashMap<>();
+
+ private Builder(UserContact userContact, BillingPeriod billingPeriod) {
+ this.billingPeriod = billingPeriod;
+ this.userContact = userContact;
+ }
+
+ public Builder renewIn(Duration renewalDays) {
+ this.billingPeriod.setRenewalDays(renewalDays);
+ return this;
+ }
+
+ public Builder subscribe(Service service) {
+ this.services.put(service.getName(), Objects.requireNonNull(service, "service must not be null"));
+ return this;
+ }
+
+ private Map collectionToServiceMap(Collection services) {
+ return services.stream()
+ .collect(Collectors.toUnmodifiableMap(Service::getName, service -> service));
+ }
+
+ public Builder subscribeAll(Collection services) {
+ Objects.requireNonNull(services, "services must not be null");
+ this.services.putAll(collectionToServiceMap(services));
+ return this;
+ }
+
+ public Builder addSnapshots(Collection snaphsots) {
+ Objects.requireNonNull(snaphsots, "snapshots must not be null");
+ this.history.addAll(snaphsots);
+ return this;
+ }
+
+ public Builder addUsageLevels(Map> usageLevels) {
+ this.usageLevels.putAll(usageLevels);
+ return this;
+ }
+
+ public Subscription build() {
+ Objects.requireNonNull(billingPeriod, "billingPeriod must not be null");
+ Objects.requireNonNull(userContact, "userContact must not be null");
+ return new Subscription(this);
+ }
+ }
+
+ public static final class Snapshot {
+
+ private final LocalDateTime starDateTime;
+ private final LocalDateTime enDateTime;
+ private final Map services;
+
+ public Snapshot(LocalDateTime startDateTime, LocalDateTime endDateTime,
+ Map services) {
+ this.starDateTime = startDateTime;
+ this.enDateTime = endDateTime;
+ this.services = new HashMap<>(services);
+ }
+
+ private Snapshot(Subscription subscription) {
+ this.starDateTime = subscription.getStartDate();
+ this.enDateTime = subscription.getEndDate();
+ this.services = subscription.getServicesMap();
+ }
+
+ public LocalDateTime getStartDate() {
+ return starDateTime;
+ }
+
+ public LocalDateTime getEndDate() {
+ return enDateTime;
+ }
+
+ public Map getServices() {
+ return Collections.unmodifiableMap(services);
+ }
+
+ public Optional getService(String name) {
+ return Optional.ofNullable(services.get(name));
+ }
+
+ static Snapshot of(Subscription subscription) {
+ Objects.requireNonNull(subscription, "subscription must not be null");
+ return new Snapshot(subscription);
+ }
+
+ @Override
+ public int hashCode() {
+ final int prime = 31;
+ int result = 1;
+ result = prime * result + ((starDateTime == null) ? 0 : starDateTime.hashCode());
+ result = prime * result + ((enDateTime == null) ? 0 : enDateTime.hashCode());
+ result = prime * result + ((services == null) ? 0 : services.hashCode());
+ return result;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj)
+ return true;
+ if (obj == null)
+ return false;
+ if (getClass() != obj.getClass())
+ return false;
+ Snapshot other = (Snapshot) obj;
+ if (starDateTime == null) {
+ if (other.starDateTime != null)
+ return false;
+ } else if (!starDateTime.equals(other.starDateTime))
+ return false;
+ if (enDateTime == null) {
+ if (other.enDateTime != null)
+ return false;
+ } else if (!enDateTime.equals(other.enDateTime))
+ return false;
+ if (services == null) {
+ if (other.services != null)
+ return false;
+ } else if (!services.equals(other.services))
+ return false;
+ return true;
+ }
+
+ }
+
+}
diff --git a/src/main/java/io/github/pgmarc/space/contracts/SubscriptionRequest.java b/src/main/java/io/github/pgmarc/space/contracts/SubscriptionRequest.java
new file mode 100644
index 0000000..b4ad24a
--- /dev/null
+++ b/src/main/java/io/github/pgmarc/space/contracts/SubscriptionRequest.java
@@ -0,0 +1,117 @@
+package io.github.pgmarc.space.contracts;
+
+import java.time.Duration;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Objects;
+import java.util.Set;
+
+public final class SubscriptionRequest {
+
+ private final UserContact userContact;
+ private final Set services;
+ private final Duration renewalDays;
+
+ private SubscriptionRequest(Builder builder) {
+ this.userContact = builder.userContact;
+ this.renewalDays = builder.renewalDays;
+ this.services = Collections.unmodifiableSet(builder.services);
+ }
+
+ public static Builder builder(UserContact userContact) {
+ return new Builder(userContact);
+ }
+
+ public UserContact getUserContact() {
+ return userContact;
+ }
+
+ public Set getServices() {
+ return services;
+ }
+
+ public Duration getRenewalDays() {
+ return renewalDays;
+ }
+
+ public static final class Builder {
+
+ private final UserContact userContact;
+ private final Set services = new HashSet<>();
+ private Service.Builder serviceBuilder;
+ private Duration renewalDays;
+
+ private Builder(UserContact userContact) {
+ this.userContact = userContact;
+ }
+
+ public Builder subscribe(Service service) {
+ this.services.add(Objects.requireNonNull(service, "service must not be null"));
+ return this;
+ }
+
+ public Builder subscribeAll(Collection services) {
+ Objects.requireNonNull(services, "services must not be null");
+ this.services.addAll(services);
+ return this;
+ }
+
+ public Builder renewIn(Duration renewalDays) {
+ this.renewalDays = renewalDays;
+ return this;
+ }
+
+ public Builder startService(String name, String version) {
+ if (isServiceBuilderAlive()) {
+ throw new IllegalStateException("you must build a service before creating another");
+ }
+ this.serviceBuilder = Service.builder(name, version);
+ return this;
+ }
+
+ public Builder plan(String plan) {
+ validateServiceBuilderCalled("you must call 'newService' before setting a plan: " + plan);
+ serviceBuilder.plan(plan);
+ return this;
+ }
+
+ public Builder addOn(String addOnName, long quantity) {
+ validateServiceBuilderCalled("you must call 'newService' before setting an add-on: " + addOnName);
+ serviceBuilder.addOn(addOnName, quantity);
+ return this;
+ }
+
+ public Builder endService() {
+ validateServiceBuilderCalled("you must call 'newService' before adding a service");
+ services.add(serviceBuilder.build());
+ destroyServiceBuilder();
+ return this;
+ }
+
+ public SubscriptionRequest build() {
+ if (isServiceBuilderAlive()) {
+ throw new IllegalStateException("finish the creation of your service by calling endService");
+ }
+ Objects.requireNonNull(userContact, "userContact must not be null");
+ if (services.isEmpty()) {
+ throw new IllegalStateException("you have to be subscribed al least to one service");
+ }
+ return new SubscriptionRequest(this);
+ }
+
+ private boolean isServiceBuilderAlive() {
+ return serviceBuilder != null;
+ }
+
+ private void validateServiceBuilderCalled(String message) {
+ if (!isServiceBuilderAlive()) {
+ throw new IllegalStateException(message);
+ }
+ }
+
+ private void destroyServiceBuilder() {
+ this.serviceBuilder = null;
+ }
+ }
+}
diff --git a/src/main/java/io/github/pgmarc/space/contracts/SubscriptionUpdateRequest.java b/src/main/java/io/github/pgmarc/space/contracts/SubscriptionUpdateRequest.java
new file mode 100644
index 0000000..f117600
--- /dev/null
+++ b/src/main/java/io/github/pgmarc/space/contracts/SubscriptionUpdateRequest.java
@@ -0,0 +1,48 @@
+package io.github.pgmarc.space.contracts;
+
+import java.util.HashSet;
+import java.util.Objects;
+import java.util.Set;
+
+public class SubscriptionUpdateRequest {
+
+ private final Set services = new HashSet<>();
+ private Service.Builder serviceBuilder;
+
+ private SubscriptionUpdateRequest() {
+
+ }
+
+ public Set getServices() {
+ return Set.copyOf(services);
+ }
+
+ public static SubscriptionUpdateRequest builder() {
+ return new SubscriptionUpdateRequest();
+ }
+
+ public SubscriptionUpdateRequest service(String name, String version) {
+ this.serviceBuilder = Service.builder(name, version);
+ return this;
+ }
+
+ public SubscriptionUpdateRequest plan(String plan) {
+ Objects.requireNonNull(serviceBuilder, "you call service first");
+ serviceBuilder.plan(plan);
+ return this;
+ }
+
+ public SubscriptionUpdateRequest addOn(String name, long quantity) {
+ Objects.requireNonNull(serviceBuilder, "you call service first");
+ this.serviceBuilder.addOn(name, quantity);
+ return this;
+ }
+
+ public SubscriptionUpdateRequest add() {
+ Objects.requireNonNull(serviceBuilder, "you call service first");
+ this.services.add(serviceBuilder.build());
+ this.serviceBuilder = null;
+ return this;
+ }
+
+}
diff --git a/src/main/java/io/github/pgmarc/space/contracts/UsageLevel.java b/src/main/java/io/github/pgmarc/space/contracts/UsageLevel.java
new file mode 100644
index 0000000..b351d67
--- /dev/null
+++ b/src/main/java/io/github/pgmarc/space/contracts/UsageLevel.java
@@ -0,0 +1,67 @@
+package io.github.pgmarc.space.contracts;
+
+import java.time.LocalDateTime;
+import java.time.ZonedDateTime;
+import java.util.Objects;
+import java.util.Optional;
+
+public final class UsageLevel {
+
+ private final String name;
+ private final double consumed;
+ private ZonedDateTime resetTimestamp;
+
+ public enum Keys {
+ CONSUMED("consumed"),
+ RESET_TIMESTAMP("resetTimestamp");
+
+ private final String name;
+
+ private Keys(String name) {
+ this.name = name;
+ }
+
+ @Override
+ public String toString() {
+ return name;
+ }
+ }
+
+ private UsageLevel(String name, double consumed, ZonedDateTime resetTimestamp) {
+ this.name = name;
+ this.consumed = consumed;
+ this.resetTimestamp = resetTimestamp;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public Optional getResetTimestamp() {
+ return resetTimestamp != null ? Optional.of(resetTimestamp.toLocalDateTime()) : Optional.empty();
+ }
+
+ public boolean isRenewableUsageLimit() {
+ return resetTimestamp != null;
+ }
+
+ public double getConsumption() {
+ return consumed;
+ }
+
+ private static void validateUsageLevel(String name, double consumed) {
+ Objects.requireNonNull(name, "usage limit name must not be null");
+ if (consumed <= 0) {
+ throw new IllegalArgumentException("consumption must be greater than 0");
+ }
+ }
+
+ public static UsageLevel of(String name, double consumed) {
+ return of(name, consumed, null);
+ }
+
+ public static UsageLevel of(String name, double consumed, ZonedDateTime resetTimestamp) {
+ validateUsageLevel(name, consumed);
+ return new UsageLevel(name, consumed, resetTimestamp);
+ }
+}
diff --git a/src/main/java/io/github/pgmarc/space/contracts/UserContact.java b/src/main/java/io/github/pgmarc/space/contracts/UserContact.java
new file mode 100644
index 0000000..95f17b3
--- /dev/null
+++ b/src/main/java/io/github/pgmarc/space/contracts/UserContact.java
@@ -0,0 +1,157 @@
+package io.github.pgmarc.space.contracts;
+
+import java.util.Objects;
+import java.util.Optional;
+
+public final class UserContact {
+
+ private final String userId;
+ private final String username;
+ private final String firstName;
+ private final String lastName;
+ private final String email;
+ private final String phone;
+
+ private UserContact(Builder builder) {
+ this.userId = builder.userId;
+ this.username = builder.username;
+ this.firstName = builder.firstName;
+ this.lastName = builder.lastName;
+ this.email = builder.email;
+ this.phone = builder.phone;
+ }
+
+ public static Builder builder(String userId, String username) {
+ Objects.requireNonNull(userId, "userId must not be null");
+ Objects.requireNonNull(username, "username must not be null");
+ return new Builder(userId, username);
+ }
+
+ public String getUserId() {
+ return userId;
+ }
+
+ public String getUsername() {
+ return username;
+ }
+
+ public Optional getFirstName() {
+ return Optional.ofNullable(firstName);
+ }
+
+ public Optional getLastName() {
+ return Optional.ofNullable(lastName);
+ }
+
+ public Optional getEmail() {
+ return Optional.ofNullable(email);
+ }
+
+ public Optional getPhone() {
+ return Optional.ofNullable(phone);
+ }
+
+ @Override
+ public int hashCode() {
+ final int prime = 31;
+ int result = 1;
+ result = prime * result + ((userId == null) ? 0 : userId.hashCode());
+ result = prime * result + ((username == null) ? 0 : username.hashCode());
+ return result;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj)
+ return true;
+ if (obj == null)
+ return false;
+ if (getClass() != obj.getClass())
+ return false;
+ UserContact other = (UserContact) obj;
+ if (userId == null) {
+ if (other.userId != null)
+ return false;
+ } else if (!userId.equals(other.userId))
+ return false;
+ if (username == null) {
+ if (other.username != null)
+ return false;
+ } else if (!username.equals(other.username))
+ return false;
+ return true;
+ }
+
+ public static class Builder {
+
+ private final String userId;
+ private final String username;
+ private String firstName;
+ private String lastName;
+ private String email;
+ private String phone;
+
+ private Builder(String userId, String username) {
+ this.userId = userId;
+ this.username = username;
+ }
+
+ public Builder firstName(String firstName) {
+ this.firstName = firstName;
+ return this;
+ }
+
+ public Builder lastName(String lastName) {
+ this.lastName = lastName;
+ return this;
+ }
+
+ public Builder email(String email) {
+ this.email = email;
+ return this;
+ }
+
+ public Builder phone(String phone) {
+ this.phone = phone;
+ return this;
+ }
+
+ public UserContact build() {
+ if (username.isBlank()) {
+ throw new IllegalArgumentException("username is blank");
+ }
+ validateUserNameLength();
+ return new UserContact(this);
+ }
+
+ private void validateUserNameLength() {
+ int length = this.username.length();
+ boolean isValidLength = length >= 3 && length <= 30;
+ if (!isValidLength) {
+ throw new IllegalArgumentException("username must be between 3 and 30 characters. Current " + username
+ + " has " + length + "characters");
+ }
+ }
+ }
+
+ public enum Keys {
+
+ USER_ID("userId"),
+ USERNAME("username"),
+ FIRST_NAME("firstName"),
+ LAST_NAME("lastName"),
+ EMAIL("email"),
+ PHONE("phone");
+
+ private final String name;
+
+ private Keys(String name) {
+ this.name = name;
+ }
+
+ @Override
+ public String toString() {
+ return name;
+ }
+ }
+}
diff --git a/src/main/java/io/github/pgmarc/space/deserializers/BillingPeriodDeserializer.java b/src/main/java/io/github/pgmarc/space/deserializers/BillingPeriodDeserializer.java
new file mode 100644
index 0000000..457c4cd
--- /dev/null
+++ b/src/main/java/io/github/pgmarc/space/deserializers/BillingPeriodDeserializer.java
@@ -0,0 +1,28 @@
+package io.github.pgmarc.space.deserializers;
+
+import java.time.Duration;
+import java.time.ZonedDateTime;
+import java.util.Objects;
+
+import org.json.JSONObject;
+
+import io.github.pgmarc.space.contracts.BillingPeriod;
+
+class BillingPeriodDeserializer implements JsonDeserializable {
+
+ @Override
+ public BillingPeriod fromJson(JSONObject json) {
+
+ Objects.requireNonNull(json, "billing period json must not be null");
+ ZonedDateTime start = ZonedDateTime.parse(json.getString(BillingPeriod.Keys.START_DATE.toString()));
+ ZonedDateTime end = ZonedDateTime.parse(json.getString(BillingPeriod.Keys.END_DATE.toString()));
+ Duration renewalDays = null;
+ if (json.has(BillingPeriod.Keys.RENEWAL_DAYS.toString())) {
+ renewalDays = Duration.ofDays(json.getLong(BillingPeriod.Keys.RENEWAL_DAYS.toString()));
+ }
+
+ return BillingPeriod.of(start, end, renewalDays);
+
+ }
+
+}
diff --git a/src/main/java/io/github/pgmarc/space/deserializers/ErrorDeserializer.java b/src/main/java/io/github/pgmarc/space/deserializers/ErrorDeserializer.java
new file mode 100644
index 0000000..231e83a
--- /dev/null
+++ b/src/main/java/io/github/pgmarc/space/deserializers/ErrorDeserializer.java
@@ -0,0 +1,55 @@
+package io.github.pgmarc.space.deserializers;
+
+import java.util.HashSet;
+import java.util.Set;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import io.github.pgmarc.space.exceptions.SpaceApiError;
+
+public final class ErrorDeserializer implements JsonDeserializable {
+
+ private enum Keys {
+ ERROR("error"),
+ ERRORS("errors"),
+ CODE("statusCode"),
+ TYPE("field"),
+ MSG("msg"),
+ PATH("path"),
+ LOCATION("location"),
+ VALUE("value");
+
+ private final String name;
+
+ private Keys(String name) {
+ this.name = name;
+ }
+
+ @Override
+ public String toString() {
+ return name;
+ }
+ }
+
+ @Override
+ public SpaceApiError fromJson(JSONObject json) {
+
+ Set messages = new HashSet<>();
+ if (json.has(Keys.ERROR.toString())) {
+ messages.add(json.getString(Keys.ERROR.toString()));
+ }
+
+ if (json.has(Keys.ERRORS.toString())) {
+ JSONArray jsonErrors = json.getJSONArray(Keys.ERRORS.toString());
+ for (int i = 0; i < jsonErrors.length(); i++) {
+ messages.add(jsonErrors.getJSONObject(i).getString(Keys.MSG.toString()));
+ }
+ }
+
+ int statusCode = json.getInt(Keys.CODE.toString());
+
+ return new SpaceApiError(statusCode, messages);
+ }
+
+}
diff --git a/src/main/java/io/github/pgmarc/space/deserializers/JsonDeserializable.java b/src/main/java/io/github/pgmarc/space/deserializers/JsonDeserializable.java
new file mode 100644
index 0000000..529909b
--- /dev/null
+++ b/src/main/java/io/github/pgmarc/space/deserializers/JsonDeserializable.java
@@ -0,0 +1,8 @@
+package io.github.pgmarc.space.deserializers;
+
+import org.json.JSONObject;
+
+public interface JsonDeserializable {
+
+ U fromJson(JSONObject json);
+}
diff --git a/src/main/java/io/github/pgmarc/space/deserializers/ServicesDeserializer.java b/src/main/java/io/github/pgmarc/space/deserializers/ServicesDeserializer.java
new file mode 100644
index 0000000..a364a8f
--- /dev/null
+++ b/src/main/java/io/github/pgmarc/space/deserializers/ServicesDeserializer.java
@@ -0,0 +1,32 @@
+package io.github.pgmarc.space.deserializers;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import org.json.JSONObject;
+
+import io.github.pgmarc.space.contracts.Service;
+import io.github.pgmarc.space.contracts.Subscription;
+
+final class ServicesDeserializer implements JsonDeserializable