diff --git a/src/main/java/com/impactupgrade/nucleus/client/MinistryByTextClient.java b/src/main/java/com/impactupgrade/nucleus/client/MinistryByTextClient.java index af76dc19d..acb9b03c2 100644 --- a/src/main/java/com/impactupgrade/nucleus/client/MinistryByTextClient.java +++ b/src/main/java/com/impactupgrade/nucleus/client/MinistryByTextClient.java @@ -1,24 +1,20 @@ package com.impactupgrade.nucleus.client; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.google.common.base.Strings; import com.impactupgrade.nucleus.environment.Environment; import com.impactupgrade.nucleus.environment.EnvironmentConfig; import com.impactupgrade.nucleus.model.CrmContact; import com.impactupgrade.nucleus.util.HttpClient; +import com.impactupgrade.nucleus.util.OAuth2Util; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import javax.ws.rs.core.Form; import javax.ws.rs.core.GenericType; import java.util.ArrayList; -import java.util.Calendar; import java.util.List; -import static com.impactupgrade.nucleus.util.HttpClient.TokenResponse; import static com.impactupgrade.nucleus.util.HttpClient.get; import static com.impactupgrade.nucleus.util.HttpClient.post; -import static javax.ws.rs.core.MediaType.APPLICATION_FORM_URLENCODED; import static javax.ws.rs.core.MediaType.APPLICATION_JSON; // TODO: To eventually become a mbt-java-client open source lib? @@ -31,11 +27,15 @@ public class MinistryByTextClient { protected final Environment env; - protected String accessToken; - protected Calendar accessTokenExpiration; + protected static OAuth2Util.Tokens tokens; + + private String clientId; + private String clientSecret; public MinistryByTextClient(Environment env) { this.env = env; + this.clientId = env.getConfig().mbt.clientId; + this.clientSecret = env.getConfig().mbt.clientSecret; } public List getGroups(String campusId) { @@ -102,37 +102,15 @@ public static class SubscriberRelation { } protected HttpClient.HeaderBuilder headers() { - if (isAccessTokenInvalid()) { - log.info("Getting new access token..."); - TokenResponse tokenResponse = getAccessToken(); - accessToken = tokenResponse.accessToken; - Calendar onehour = Calendar.getInstance(); - onehour.add(Calendar.SECOND, tokenResponse.expiresIn); - accessTokenExpiration = onehour; + tokens = OAuth2Util.refreshTokens(tokens, AUTH_ENDPOINT); + if (tokens == null) { + tokens = OAuth2Util.getTokensForClientCredentials(clientId, clientSecret, AUTH_ENDPOINT); } - System.out.println(accessToken); + String accessToken = tokens != null ? tokens.accessToken() : null; return HttpClient.HeaderBuilder.builder().authBearerToken(accessToken); } - protected boolean isAccessTokenInvalid() { - Calendar now = Calendar.getInstance(); - return Strings.isNullOrEmpty(accessToken) || now.after(accessTokenExpiration); - } - - protected TokenResponse getAccessToken() { - // TODO: Map.of should be able to be used instead of Form (see VirtuousClient), but getting errors about no writer - return post( - AUTH_ENDPOINT, - new Form() - .param("client_id", env.getConfig().mbt.clientId) - .param("client_secret", env.getConfig().mbt.clientSecret) - .param("grant_type", "client_credentials"), - APPLICATION_FORM_URLENCODED, - HttpClient.HeaderBuilder.builder(), - TokenResponse.class - ); - } - + //TODO: remove once done with testing public static void main(String[] args) { Environment env = new Environment() { @Override @@ -153,5 +131,8 @@ public EnvironmentConfig getConfig() { crmContact.mobilePhone = "260-349-5732"; Subscriber subscriber = mbtClient.upsertSubscriber(crmContact, "c64ecadf-bbfa-4cd4-8f19-a64e5d661b2b"); System.out.println(subscriber); + + // To check same access token is used + subscriber = mbtClient.upsertSubscriber(crmContact, "c64ecadf-bbfa-4cd4-8f19-a64e5d661b2b"); } } diff --git a/src/main/java/com/impactupgrade/nucleus/client/RaiselyClient.java b/src/main/java/com/impactupgrade/nucleus/client/RaiselyClient.java index 4cb8d2ede..53e77e5f4 100644 --- a/src/main/java/com/impactupgrade/nucleus/client/RaiselyClient.java +++ b/src/main/java/com/impactupgrade/nucleus/client/RaiselyClient.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.impactupgrade.nucleus.environment.Environment; import com.impactupgrade.nucleus.util.HttpClient; +import com.impactupgrade.nucleus.util.OAuth2Util; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -11,19 +12,24 @@ import java.util.Map; import static com.impactupgrade.nucleus.util.HttpClient.get; -import static com.impactupgrade.nucleus.util.HttpClient.post; -public class RaiselyClient{ +public class RaiselyClient { + private static final Logger log = LogManager.getLogger(RaiselyClient.class); private static final String RAISELY_API_URL = "https://api.raisely.com/v3/"; - private static final String APPLICATION_JSON = "application/json"; + private static final String AUTH_URL = RAISELY_API_URL + "login"; protected final Environment env; - private String _accessToken = null; + protected static OAuth2Util.Tokens tokens; + + private String username; + private String password; - public RaiselyClient(Environment env){ + public RaiselyClient(Environment env) { this.env = env; + this.username = env.getConfig().raisely.username; + this.password = env.getConfig().raisely.password; } //*Note this uses the donation ID from the Stripe metadata. Different from the donation UUID @@ -34,7 +40,7 @@ public RaiselyClient(Environment env){ * like this: https://api.raisely.com/v3/donations?idGTE=14619704&idLTE=14619704&private=true */ - public RaiselyClient.Donation getDonation(String donationId){ + public RaiselyClient.Donation getDonation(String donationId) { DonationResponse response = get( RAISELY_API_URL + "donations?idGTE=" + donationId + "&idLTE=" + donationId + "&private=true", HttpClient.HeaderBuilder.builder().authBearerToken(getAccessToken()), @@ -49,27 +55,14 @@ public RaiselyClient.Donation getDonation(String donationId){ } protected String getAccessToken() { - if (_accessToken == null) { - String username = env.getConfig().raisely.username; - String password = env.getConfig().raisely.password; - - log.info("Getting token..."); - HttpClient.HeaderBuilder headers = HttpClient.HeaderBuilder.builder(); - TokenResponse response = post(RAISELY_API_URL + "login", Map.of("requestAdminToken", "true", "username", username, "password", password), APPLICATION_JSON, headers, TokenResponse.class); - log.info("Token: {}", response.token); - this._accessToken = response.token; + tokens = OAuth2Util.refreshTokens(tokens, AUTH_URL); + if (tokens == null) { + tokens = OAuth2Util.getTokensForUsernameAndPassword(username, password, AUTH_URL); } - - return this._accessToken; + return tokens != null ? tokens.accessToken() : null; } //Response Objects - @JsonIgnoreProperties(ignoreUnknown = true) - public static class TokenResponse { - @JsonProperty("token") - public String token; - } - @JsonIgnoreProperties(ignoreUnknown = true) public static class DonationResponse { public List data; @@ -94,12 +87,12 @@ public static class Donation { @Override public String toString() { return "Donation{" + - "amount=" + amount + - ", fee=" + fee + - ", feeOptIn = " + feeCovered + - ", total='" + total + '\'' + - ", items='" + items + '\'' + - '}'; + "amount=" + amount + + ", fee=" + fee + + ", feeOptIn = " + feeCovered + + ", total='" + total + '\'' + + ", items='" + items + '\'' + + '}'; } } @@ -113,13 +106,25 @@ public static class DonationItem { @Override public String toString() { return "Item{" + - "amount=" + amount + - ", amountRefunded=" + amountRefunded + - ", type='" + type + '\'' + - ", quantity='" + quantity + '\'' + - '}'; + "amount=" + amount + + ", amountRefunded=" + amountRefunded + + ", type='" + type + '\'' + + ", quantity='" + quantity + '\'' + + '}'; } } + //TODO test with real creds + public static void main(String[] args) { + + String username = "username"; + String password = "password"; + String tokenServerUrl = AUTH_URL; + + tokens = OAuth2Util.refreshTokens(tokens, tokenServerUrl); + if (tokens == null) { + tokens = OAuth2Util.getTokensForUsernameAndPassword(username, password, Map.of("requestAdminToken", "true"), tokenServerUrl); + } + } } diff --git a/src/main/java/com/impactupgrade/nucleus/client/SpokeClient.java b/src/main/java/com/impactupgrade/nucleus/client/SpokeClient.java index 647705427..5207ffc18 100644 --- a/src/main/java/com/impactupgrade/nucleus/client/SpokeClient.java +++ b/src/main/java/com/impactupgrade/nucleus/client/SpokeClient.java @@ -6,20 +6,17 @@ import com.impactupgrade.nucleus.environment.EnvironmentConfig; import com.impactupgrade.nucleus.model.CrmContact; import com.impactupgrade.nucleus.util.HttpClient; +import com.impactupgrade.nucleus.util.OAuth2Util; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import javax.ws.rs.core.Form; import javax.ws.rs.core.GenericType; import java.util.ArrayList; -import java.util.Calendar; import java.util.List; -import static com.impactupgrade.nucleus.util.HttpClient.TokenResponse; import static com.impactupgrade.nucleus.util.HttpClient.get; import static com.impactupgrade.nucleus.util.HttpClient.post; import static com.impactupgrade.nucleus.util.HttpClient.put; -import static javax.ws.rs.core.MediaType.APPLICATION_FORM_URLENCODED; import static javax.ws.rs.core.MediaType.APPLICATION_JSON; // TODO: To eventually become a spoke-phone-java-client open source lib? @@ -32,11 +29,15 @@ public class SpokeClient { protected final Environment env; - protected String accessToken; - protected Calendar accessTokenExpiration; + protected static OAuth2Util.Tokens tokens; + private String clientId; + private String clientSecret; + public SpokeClient(Environment env) { this.env = env; + this.clientId = env.getConfig().spoke.clientId; + this.clientSecret = env.getConfig().spoke.clientSecret; } public List getPhonebooks() { @@ -117,37 +118,15 @@ public static class ContactRequest { } protected HttpClient.HeaderBuilder headers() { - if (isAccessTokenInvalid()) { - log.info("Getting new access token..."); - TokenResponse tokenResponse = getAccessToken(); - accessToken = tokenResponse.accessToken; - Calendar onehour = Calendar.getInstance(); - onehour.add(Calendar.SECOND, tokenResponse.expiresIn); - accessTokenExpiration = onehour; + tokens = OAuth2Util.refreshTokens(tokens, AUTH_ENDPOINT); + if (tokens == null) { + tokens = OAuth2Util.getTokensForClientCredentials(clientId, clientSecret, AUTH_ENDPOINT); } - System.out.println(accessToken); + String accessToken = tokens != null ? tokens.accessToken() : null; return HttpClient.HeaderBuilder.builder().authBearerToken(accessToken); } - protected boolean isAccessTokenInvalid() { - Calendar now = Calendar.getInstance(); - return Strings.isNullOrEmpty(accessToken) || now.after(accessTokenExpiration); - } - - protected TokenResponse getAccessToken() { - // TODO: Map.of should be ablet o be used instead of Form (see VirtuousClient), but getting errors about no writer - return post( - AUTH_ENDPOINT, - new Form() - .param("client_id", env.getConfig().spoke.clientId) - .param("client_secret", env.getConfig().spoke.clientSecret) - .param("grant_type", "client_credentials"), - APPLICATION_FORM_URLENCODED, - HttpClient.HeaderBuilder.builder(), - TokenResponse.class - ); - } - + //TODO: remove once done with testing public static void main(String[] args) { Environment env = new Environment() { @Override @@ -168,6 +147,9 @@ public EnvironmentConfig getConfig() { // Phonebook phonebook = spokeClient.createPhonebook("Salesforce US", "Salesforce contacts in the US", "US"); List phonebooks = spokeClient.getPhonebooks(); // Contact contact = spokeClient.upsertContact(crmContact, "Salesforce", phonebooks.get(0).id); - System.out.println(phonebooks); + + // To check same access token is used + phonebooks = spokeClient.getPhonebooks(); } + } diff --git a/src/main/java/com/impactupgrade/nucleus/client/VirtuousClient.java b/src/main/java/com/impactupgrade/nucleus/client/VirtuousClient.java index bca10e8d1..71a5caa84 100644 --- a/src/main/java/com/impactupgrade/nucleus/client/VirtuousClient.java +++ b/src/main/java/com/impactupgrade/nucleus/client/VirtuousClient.java @@ -5,6 +5,7 @@ import com.google.common.base.Strings; import com.impactupgrade.nucleus.environment.Environment; import com.impactupgrade.nucleus.util.HttpClient; +import com.impactupgrade.nucleus.util.OAuth2Util; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -14,315 +15,290 @@ import java.util.ArrayList; import java.util.Calendar; import java.util.Collections; -import java.util.Date; import java.util.List; -import java.util.Map; -import java.util.Objects; import static com.impactupgrade.nucleus.util.HttpClient.delete; import static com.impactupgrade.nucleus.util.HttpClient.get; import static com.impactupgrade.nucleus.util.HttpClient.post; import static com.impactupgrade.nucleus.util.HttpClient.put; -import static com.impactupgrade.nucleus.util.HttpClient.TokenResponse; -import static javax.ws.rs.core.MediaType.APPLICATION_FORM_URLENCODED; import static javax.ws.rs.core.MediaType.APPLICATION_JSON; public class VirtuousClient { - private static final Logger log = LogManager.getLogger(VirtuousClient.class); + private static final Logger log = LogManager.getLogger(VirtuousClient.class); - private static final String VIRTUOUS_API_URL = "https://api.virtuoussoftware.com/api"; - private static final int DEFAULT_OFFSET = 0; - private static final int DEFAULT_LIMIT = 100; - - private static TokenResponse tokenResponse; - - private String apiKey; - private String username; - private String password; - private String tokenServerUrl; - - private String accessToken; - private String refreshToken; - - protected Environment env; - - public VirtuousClient(Environment env) { - this.env = env; - - this.apiKey = env.getConfig().virtuous.secretKey; - - this.username = env.getConfig().virtuous.username; - this.password = env.getConfig().virtuous.password; - this.tokenServerUrl = env.getConfig().virtuous.tokenServerUrl; - - this.accessToken = env.getConfig().virtuous.accessToken; - this.refreshToken = env.getConfig().virtuous.refreshToken; - } - - // Contact - public Contact createContact(Contact contact) { - contact = post(VIRTUOUS_API_URL + "/Contact", contact, APPLICATION_JSON, headers(), Contact.class); - if (contact != null) { - log.info("Created contact: {}", contact); - } - return contact; - } + private static final String VIRTUOUS_API_URL = "https://api.virtuoussoftware.com/api"; + private static final int DEFAULT_OFFSET = 0; + private static final int DEFAULT_LIMIT = 100; - public Contact getContactById(Integer id) { - return getContact(VIRTUOUS_API_URL + "/Contact/" + id); - } - - public Contact getContactByEmail(String email) throws Exception { - return getContact(VIRTUOUS_API_URL + "/Contact/Find?email=" + email); - } + private static OAuth2Util.Tokens tokens; - private Contact getContact(String contactUrl) { - return get(contactUrl, headers(), Contact.class); + private String apiKey; + + private String username; + private String password; + private String tokenServerUrl; + + private String accessToken; + private String refreshToken; + + protected Environment env; + + public VirtuousClient(Environment env) { + this.env = env; + + this.apiKey = env.getConfig().virtuous.secretKey; + + this.username = env.getConfig().virtuous.username; + this.password = env.getConfig().virtuous.password; + this.tokenServerUrl = env.getConfig().virtuous.tokenServerUrl; + + this.accessToken = env.getConfig().virtuous.accessToken; + this.refreshToken = env.getConfig().virtuous.refreshToken; + } + + // Contact + public Contact createContact(Contact contact) { + contact = post(VIRTUOUS_API_URL + "/Contact", contact, APPLICATION_JSON, headers(), Contact.class); + if (contact != null) { + log.info("Created contact: {}", contact); } + return contact; + } - public List getContactsModifiedAfter(Calendar modifiedAfter) { - QueryCondition queryCondition = new QueryCondition(); - queryCondition.parameter = "Last Modified Date"; - queryCondition.operator = "After"; - queryCondition.value = getLastModifiedDateValue(modifiedAfter); + public Contact getContactById(Integer id) { + return getContact(VIRTUOUS_API_URL + "/Contact/" + id); + } - QueryConditionGroup group = new QueryConditionGroup(); - group.conditions = List.of(queryCondition); + public Contact getContactByEmail(String email) throws Exception { + return getContact(VIRTUOUS_API_URL + "/Contact/Find?email=" + email); + } + + private Contact getContact(String contactUrl) { + return get(contactUrl, headers(), Contact.class); + } - ContactQuery query = new ContactQuery(); - //query.queryLocation = null; // TODO: decide if we need this param - query.groups = List.of(group); - query.sortBy = "Last Name"; - query.descending = false; + public List getContactsModifiedAfter(Calendar modifiedAfter) { + QueryCondition queryCondition = new QueryCondition(); + queryCondition.parameter = "Last Modified Date"; + queryCondition.operator = "After"; + queryCondition.value = getLastModifiedDateValue(modifiedAfter); + + QueryConditionGroup group = new QueryConditionGroup(); + group.conditions = List.of(queryCondition); + + ContactQuery query = new ContactQuery(); + //query.queryLocation = null; // TODO: decide if we need this param + query.groups = List.of(group); + query.sortBy = "Last Name"; + query.descending = false; + + return queryContacts(query); + } - return queryContacts(query); + private String getLastModifiedDateValue(Calendar calendar) { + //"valueOptions": [ + // "180 Days Ago", + // "270 Days Ago", + // "30 Days Ago", + // "60 Days Ago", + // "90 Days Ago", + // "Last Sunday", + // "One week from now", + // "One Year Ago", + // "Start Of This Month", + // "This Calendar Year", + // "Today", + // "Tomorrow", + // "Two Years Ago", + // "Yesterday" + // ] + LocalDateTime then = LocalDateTime.ofInstant(calendar.toInstant(), ZoneId.of("UTC")); + long daysAgo = Duration.between(then, LocalDateTime.now()).toDays(); + String lastModifiedDate; + if (daysAgo < 1) { + lastModifiedDate = "Today"; + // TODO: daysAgo >= 1 is always true. What was intended here? + } else if (daysAgo >= 1) { + lastModifiedDate = "Yesterday"; + } else if (daysAgo >= 30 && daysAgo < 60) { + lastModifiedDate = "30 Days Ago"; + } else if (daysAgo >= 60 && daysAgo < 90) { + lastModifiedDate = "60 Days Ago"; + } else if (daysAgo >= 90 && daysAgo < 180) { + lastModifiedDate = "90 Days Ago"; + } else if (daysAgo >= 180 && daysAgo < 270) { + lastModifiedDate = "180 Days Ago"; + } else if (daysAgo >= 270 && daysAgo < 365) { + lastModifiedDate = "270 Days Ago"; + } else { + lastModifiedDate = "One Year Ago"; + } + return lastModifiedDate; + } + + public Contact updateContact(Contact contact) { + contact = put(VIRTUOUS_API_URL + "/Contact/" + contact.id, contact, APPLICATION_JSON, headers(), Contact.class); + if (contact != null) { + log.info("Updated contact: {}", contact); } + return contact; + } - private String getLastModifiedDateValue(Calendar calendar) { - //"valueOptions": [ - // "180 Days Ago", - // "270 Days Ago", - // "30 Days Ago", - // "60 Days Ago", - // "90 Days Ago", - // "Last Sunday", - // "One week from now", - // "One Year Ago", - // "Start Of This Month", - // "This Calendar Year", - // "Today", - // "Tomorrow", - // "Two Years Ago", - // "Yesterday" - // ] - LocalDateTime then = LocalDateTime.ofInstant(calendar.toInstant(), ZoneId.of("UTC")); - long daysAgo = Duration.between(then, LocalDateTime.now()).toDays(); - String lastModifiedDate; - if (daysAgo < 1) { - lastModifiedDate = "Today"; - // TODO: daysAgo >= 1 is always true. What was intended here? - } else if (daysAgo >= 1) { - lastModifiedDate = "Yesterday"; - } else if (daysAgo >= 30 && daysAgo < 60) { - lastModifiedDate = "30 Days Ago"; - } else if (daysAgo >= 60 && daysAgo < 90) { - lastModifiedDate = "60 Days Ago"; - } else if (daysAgo >= 90 && daysAgo < 180) { - lastModifiedDate = "90 Days Ago"; - } else if (daysAgo >= 180 && daysAgo < 270) { - lastModifiedDate = "180 Days Ago"; - } else if (daysAgo >= 270 && daysAgo < 365) { - lastModifiedDate = "270 Days Ago"; - } else { - lastModifiedDate = "One Year Ago"; - } - return lastModifiedDate; - } - - public Contact updateContact(Contact contact) { - contact = put(VIRTUOUS_API_URL + "/Contact/" + contact.id, contact, APPLICATION_JSON, headers(), Contact.class); - if (contact != null) { - log.info("Updated contact: {}", contact); - } - return contact; - } - - public ContactMethod createContactMethod(ContactMethod contactMethod) { - contactMethod = post(VIRTUOUS_API_URL + "/ContactMethod", contactMethod, APPLICATION_JSON, headers(), ContactMethod.class); - if (contactMethod != null) { - log.info("Created contactMethod: {}", contactMethod); - } - return contactMethod; - } - - public ContactMethod updateContactMethod(ContactMethod contactMethod) { - contactMethod = put(VIRTUOUS_API_URL + "/ContactMethod/" + contactMethod.id, contactMethod, APPLICATION_JSON, headers(), ContactMethod.class); - if (contactMethod != null) { - log.info("Updated contactMethod: {}", contactMethod); - } - return contactMethod; - } - - public void deleteContactMethod(ContactMethod contactMethod) { - delete(VIRTUOUS_API_URL + "/ContactMethod/" + contactMethod.id, headers()); - log.info("Deleted contactMethod: {}", contactMethod.id); - } - - public List queryContacts(ContactQuery query) { - ContactQueryResponse response = post(VIRTUOUS_API_URL + "/Contact/Query/FullContact?skip=" + DEFAULT_OFFSET + "&take=" + DEFAULT_LIMIT, query, APPLICATION_JSON, headers(), ContactQueryResponse.class); - if (response == null) { - return Collections.emptyList(); - } - return response.contacts; - } - - public List getContactIndividuals(String searchString) { - return getContactIndividuals(searchString, DEFAULT_OFFSET, DEFAULT_LIMIT); - } - - public List getContactIndividuals(String searchString, int offset, int limit) { - ContactsSearchCriteria criteria = new ContactsSearchCriteria(); - criteria.search = searchString; - ContactSearchResponse response = post(VIRTUOUS_API_URL + "/Contact/Search?skip=" + offset + "&take=" + limit, criteria, APPLICATION_JSON, headers(), ContactSearchResponse.class); - if (response == null) { - return Collections.emptyList(); - } - return response.contactIndividualShorts; - } - - // Gift - public Gifts getGiftsByContact(String contactId) { - String giftUrl = VIRTUOUS_API_URL + "/Gift/ByContact/" + contactId + "?take=1000"; - return get(giftUrl, headers(), Gifts.class); - } - - public Gift getGiftByTransactionSourceAndId(String transactionSource, String transactionId) { - String giftUrl = VIRTUOUS_API_URL + "/Gift/" + transactionSource + "/" + transactionId; - return getGift(giftUrl); - } - - private Gift getGift(String giftUrl) { - return get(giftUrl, headers(), Gift.class); - } - - // This endpoint creates a gift directly onto a contact record. - // Using this endpoint assumes you know the precise contact the gift is matched to. - // Virtuous does not support cleaning up data that is caused by - // creating the gifts incorrectly through this endpoint. - // Please use the Gift Transaction endpoint as a better alternative. - // https://docs.virtuoussoftware.com/#5cbc35dc-6b1e-41da-b1a5-477043a9a66d - public Gift createGift(Gift gift) { - gift = post(VIRTUOUS_API_URL + "/Gift", gift, APPLICATION_JSON, headers(), Gift.class); - if (gift != null) { - log.info("Created gift: {}", gift); - } - return gift; - } - - // This is the recommended way to create a gift. - // This ensures the gift is matched using the Virtuous matching algorithms - // for Contacts, Recurring gifts, Designations, etc. - // https://docs.virtuoussoftware.com/#e4a6a1e3-71a4-44f9-bd7c-9466996befac - public void createGiftAsync(GiftTransaction giftTransaction) { - post(VIRTUOUS_API_URL + "/v2/Gift/Transaction", giftTransaction, APPLICATION_JSON, headers()); - } - - public Gift updateGift(Gift gift) { - gift = put(VIRTUOUS_API_URL + "/Gift" + "/" + gift.id, gift, APPLICATION_JSON, headers(), Gift.class); - if (gift != null) { - log.info("Updated gift: {}", gift); - } - return gift; + public ContactMethod createContactMethod(ContactMethod contactMethod) { + contactMethod = post(VIRTUOUS_API_URL + "/ContactMethod", contactMethod, APPLICATION_JSON, headers(), ContactMethod.class); + if (contactMethod != null) { + log.info("Created contactMethod: {}", contactMethod); } + return contactMethod; + } - // TODO: Should this return ReversingTransaction? Does the API respond with ReversingTransaction or the Gift? - public Gift createReversingTransaction(Gift gift) throws Exception { - gift = post(VIRTUOUS_API_URL + "/Gift/ReversingTransaction", reversingTransaction(gift), APPLICATION_JSON, headers(), Gift.class); - if (gift != null) { - log.info("Created reversing transaction: {}", gift); - } - return gift; + public ContactMethod updateContactMethod(ContactMethod contactMethod) { + contactMethod = put(VIRTUOUS_API_URL + "/ContactMethod/" + contactMethod.id, contactMethod, APPLICATION_JSON, headers(), ContactMethod.class); + if (contactMethod != null) { + log.info("Updated contactMethod: {}", contactMethod); } + return contactMethod; + } - private ReversingTransaction reversingTransaction(Gift gift) { - ReversingTransaction reversingTransaction = new ReversingTransaction(); - reversingTransaction.reversedGiftId = gift.id; - reversingTransaction.giftDate = gift.giftDate; - reversingTransaction.notes = "Reverting transaction: " + - gift.transactionSource + "/" + gift.transactionId; - return reversingTransaction; + public void deleteContactMethod(ContactMethod contactMethod) { + delete(VIRTUOUS_API_URL + "/ContactMethod/" + contactMethod.id, headers()); + log.info("Deleted contactMethod: {}", contactMethod.id); + } + + public List queryContacts(ContactQuery query) { + ContactQueryResponse response = post(VIRTUOUS_API_URL + "/Contact/Query/FullContact?skip=" + DEFAULT_OFFSET + "&take=" + DEFAULT_LIMIT, query, APPLICATION_JSON, headers(), ContactQueryResponse.class); + if (response == null) { + return Collections.emptyList(); } + return response.contacts; + } - public Task createTask(Task task) throws Exception { - task = post(VIRTUOUS_API_URL + "/Task", task, APPLICATION_JSON, headers(), Task.class); - if (task != null) { - log.info("Created task: {}", task); - } - return task; + public List getContactIndividuals(String searchString) { + return getContactIndividuals(searchString, DEFAULT_OFFSET, DEFAULT_LIMIT); + } + + public List getContactIndividuals(String searchString, int offset, int limit) { + ContactsSearchCriteria criteria = new ContactsSearchCriteria(); + criteria.search = searchString; + ContactSearchResponse response = post(VIRTUOUS_API_URL + "/Contact/Search?skip=" + offset + "&take=" + limit, criteria, APPLICATION_JSON, headers(), ContactSearchResponse.class); + if (response == null) { + return Collections.emptyList(); } + return response.contactIndividualShorts; + } + + // Gift + public Gifts getGiftsByContact(String contactId) { + String giftUrl = VIRTUOUS_API_URL + "/Gift/ByContact/" + contactId + "?take=1000"; + return get(giftUrl, headers(), Gifts.class); + } - private HttpClient.HeaderBuilder headers() { - // First, use the simple API key, if available. - - if (!Strings.isNullOrEmpty(apiKey)) { - return HttpClient.HeaderBuilder.builder().authBearerToken(apiKey); - } + public Gift getGiftByTransactionSourceAndId(String transactionSource, String transactionId) { + String giftUrl = VIRTUOUS_API_URL + "/Gift/" + transactionSource + "/" + transactionId; + return getGift(giftUrl); + } + + private Gift getGift(String giftUrl) { + return get(giftUrl, headers(), Gift.class); + } - // Otherwise, assume oauth. - - if (!containsValidAccessToken(tokenResponse)) { - log.info("Getting new access token..."); - if (!Strings.isNullOrEmpty(refreshToken)) { - // Refresh access token if possible - log.info("Refreshing token..."); - tokenResponse = refreshAccessToken(); - } else { - // Get new token pair otherwise - log.info("Getting new pair of tokens..."); - tokenResponse = getTokenResponse(); - log.info("TR: {}", tokenResponse.accessToken); - } - - // ! - // When fetching a token for a user with Two-Factor Authentication, you will receive a 202 (Accepted) response stating that a verification code is required. - //The user will then need to enter the verification code that was sent to their phone. You will then request the token again but this time you will pass in an OTP (one-time-password) header with the verification code received - //If the verification code and user credentials are correct, you will receive a token as seen in the Token authentication above. - //To request a new Token after the user enters the verification code, add an OTP header: - //curl -d "grant_type=password&username=YOUR_EMAIL&password=YOUR_PASSWORD&otp=YOUR_OTP" -X POST https://api.virtuoussoftware.com/Token - } - return HttpClient.HeaderBuilder.builder().authBearerToken(tokenResponse.accessToken); - } - - private boolean containsValidAccessToken(TokenResponse tokenResponse) { - return Objects.nonNull(tokenResponse) && new Date().before(tokenResponse.expiresAt); - } - - private TokenResponse refreshAccessToken() { - // To refresh access token: - // curl -d "grant_type=refresh_token&refresh_token=REFRESH_TOKEN" - // -X POST https://api.virtuoussoftware.com/Token - return post(tokenServerUrl, Map.of("grant_type", "refresh_token", "refresh_token", refreshToken), APPLICATION_FORM_URLENCODED, headers(), TokenResponse.class); - } - - private TokenResponse getTokenResponse() { - return post(tokenServerUrl, Map.of("grant_type", "password", "username", username, "password", password), APPLICATION_FORM_URLENCODED, headers(), TokenResponse.class); - } - - public static class ContactSearchResponse { - @JsonProperty("list") - public List contactIndividualShorts = new ArrayList<>(); - public Integer total; - - @Override - public String toString() { - return "ContactSearchResponse{" + - "contactIndividualShorts=" + contactIndividualShorts + - ", total=" + total + - '}'; - } + // This endpoint creates a gift directly onto a contact record. + // Using this endpoint assumes you know the precise contact the gift is matched to. + // Virtuous does not support cleaning up data that is caused by + // creating the gifts incorrectly through this endpoint. + // Please use the Gift Transaction endpoint as a better alternative. + // https://docs.virtuoussoftware.com/#5cbc35dc-6b1e-41da-b1a5-477043a9a66d + public Gift createGift(Gift gift) { + gift = post(VIRTUOUS_API_URL + "/Gift", gift, APPLICATION_JSON, headers(), Gift.class); + if (gift != null) { + log.info("Created gift: {}", gift); + } + return gift; + } + + // This is the recommended way to create a gift. + // This ensures the gift is matched using the Virtuous matching algorithms + // for Contacts, Recurring gifts, Designations, etc. + // https://docs.virtuoussoftware.com/#e4a6a1e3-71a4-44f9-bd7c-9466996befac + public void createGiftAsync(GiftTransaction giftTransaction) { + post(VIRTUOUS_API_URL + "/v2/Gift/Transaction", giftTransaction, APPLICATION_JSON, headers()); + } + + public Gift updateGift(Gift gift) { + gift = put(VIRTUOUS_API_URL + "/Gift" + "/" + gift.id, gift, APPLICATION_JSON, headers(), Gift.class); + if (gift != null) { + log.info("Updated gift: {}", gift); + } + return gift; + } + + // TODO: Should this return ReversingTransaction? Does the API respond with ReversingTransaction or the Gift? + public Gift createReversingTransaction(Gift gift) throws Exception { + gift = post(VIRTUOUS_API_URL + "/Gift/ReversingTransaction", reversingTransaction(gift), APPLICATION_JSON, headers(), Gift.class); + if (gift != null) { + log.info("Created reversing transaction: {}", gift); + } + return gift; + } + + private ReversingTransaction reversingTransaction(Gift gift) { + ReversingTransaction reversingTransaction = new ReversingTransaction(); + reversingTransaction.reversedGiftId = gift.id; + reversingTransaction.giftDate = gift.giftDate; + reversingTransaction.notes = "Reverting transaction: " + + gift.transactionSource + "/" + gift.transactionId; + return reversingTransaction; + } + + public Task createTask(Task task) throws Exception { + task = post(VIRTUOUS_API_URL + "/Task", task, APPLICATION_JSON, headers(), Task.class); + if (task != null) { + log.info("Created task: {}", task); + } + return task; + } + + private HttpClient.HeaderBuilder headers() { + // First, use the simple API key, if available. + if (!Strings.isNullOrEmpty(apiKey)) { + return HttpClient.HeaderBuilder.builder().authBearerToken(apiKey); + } + + // Otherwise, assume oauth. + if (tokens == null) { + tokens = new OAuth2Util.Tokens(accessToken, null, refreshToken); + } + tokens = OAuth2Util.refreshTokens(tokens, tokenServerUrl); + if (tokens == null) { + tokens = OAuth2Util.getTokensForUsernameAndPassword(username, password, tokenServerUrl); } + // ! +// // When fetching a token for a user with Two-Factor Authentication, you will receive a 202 (Accepted) response stating that a verification code is required. +// //The user will then need to enter the verification code that was sent to their phone. You will then request the token again but this time you will pass in an OTP (one-time-password) header with the verification code received +// //If the verification code and user credentials are correct, you will receive a token as seen in the Token authentication above. +// //To request a new Token after the user enters the verification code, add an OTP header: +// //curl -d "grant_type=password&username=YOUR_EMAIL&password=YOUR_PASSWORD&otp=YOUR_OTP" -X POST https://api.virtuoussoftware.com/Token + + String accessToken = tokens != null ? tokens.accessToken() : null; + return HttpClient.HeaderBuilder.builder().authBearerToken(accessToken); + } + + public static class ContactSearchResponse { + @JsonProperty("list") + public List contactIndividualShorts = new ArrayList<>(); + public Integer total; + + @Override + public String toString() { + return "ContactSearchResponse{" + + "contactIndividualShorts=" + contactIndividualShorts + + ", total=" + total + + '}'; + } + } + @JsonIgnoreProperties(ignoreUnknown = true) public static class Contact { // public Boolean isCurrentUserFollowing; @@ -891,4 +867,23 @@ public enum Type { MEETING; } } + + //TODO: remove once done with testing + public static void main(String[] args) { + String username = "brett@impactupgrade.com"; + String password = "69nWJnsceQmUw88rH32z"; + String tokenServerUrl = "https://api.virtuoussoftware.com/Token"; + + String accessToken = "MYEp3SVJliMwm3JceQG0l4nA92A71bVXRgqdpiYMy0Cb9ZeS6SCepkNXDfMupXXHBYKGKY40G_L_UGZZNaWx94eWTOFH0BFw1kqZZyUQMtGO2dSoLfGXA_zgm64iZNeB-8o9JugeVHzDL6FwbpyMAxAD68iYk_PQkf6FCDxJ_VKD_6z-baOQ49LrnxIfxKx_np9mSzHto5J-lhPuwPdlHjYjAaFHmj7EpvcBBXUnCYnf310eV5pqyH6WPE6FqUVPUgKlZ1LfRo-Dc5-edSMU9Jvao4ayjMuNcPYmE1LUOoW_B9arAS3f5rnCVU_R2qanJ2_1isRY6sK3YIfA9gEPsxm7_B3qG42z4VZsrJT4WulQxAOSE8i3o4GEEwFTjXBnFeX0DtrkDnuELcKntkKzUN0JGHQNQb_M4ezkkmHlD5RowS9IVJCaQH3L8I6y9Ima3KyYrfhjE-dkgTbtkfdG9CnPplvZezo0IyQcWW9707O5tF39XTwJs3puUV_yg3z1wzpy54nx1KrROUSrku1N5fAYZ6hzFzCOHS_yYSxTqd5ODZlWgiByFfPYOpwKt53rqLXPHA5gNfRllU0XDQiK-2pDsoI"; + //String refreshToken = "XGFBTq-Bfog67KYMwSOfV2IgQ3gyu_V1PKprFTc41ULqPsoxc1IBK-pMip2HLc7r3RbTeyuP4M49CdSzIo43uqASkdyb8JRRBu57vFxw7HcWWLx8RXoBSY04V3od8djbYB49PKqO4bjHjf10aLHLlN0p0d6HiI6NhDjLUfdrvAFKryra7Tg2W_aUWuEFmSvoI92XongqUh5Uf3arbxTNfqcz_XWLWFaM547CL718-IpJ9ie5OLGybvYuglZDsXw6DK7pMu2jfW2akN2sn1PXxCi4aeQpQEckbhvUIF3vaHvCdEFEXLVF7JbPPWckrWj1pG4xY_bpp86MG1wqvbn3XSrQiuBkSsWZKYWGyBZWHYrVwfkiTJz1zjOevhOTXnM3D2VR1SK5coCSGcTzcWANoAk0-Du6eHE5u7pUc-FQpjbDW0uphcj6TT-VADE2uvB3tcKn_4tjY64vWYf0s09SPFlZd9CErhZKri1XX5P5jsGoi7wdCG0QbDcI7r8ywBZbVzERKH-hG8LUwJA7tUV_eX_MnHNm9hEkSnX_rq60Dl2yknPByGXRGZgR9L7SPph90nd_9s4K0Wf6Wzp0-OGjmvsiyVk"; + String refreshToken = "abc"; + + if (tokens == null) { + tokens = new OAuth2Util.Tokens(accessToken, null, refreshToken); + } + tokens = OAuth2Util.refreshTokens(tokens, tokenServerUrl); + if (tokens == null) { + tokens = OAuth2Util.getTokensForUsernameAndPassword(username, password, tokenServerUrl); + } + } } diff --git a/src/main/java/com/impactupgrade/nucleus/util/OAuth2Util.java b/src/main/java/com/impactupgrade/nucleus/util/OAuth2Util.java new file mode 100644 index 000000000..8e859b346 --- /dev/null +++ b/src/main/java/com/impactupgrade/nucleus/util/OAuth2Util.java @@ -0,0 +1,129 @@ +package com.impactupgrade.nucleus.util; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.common.base.Strings; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import javax.ws.rs.core.Form; +import java.time.Instant; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +import static com.impactupgrade.nucleus.util.HttpClient.post; +import static javax.ws.rs.core.MediaType.APPLICATION_FORM_URLENCODED; + +public class OAuth2Util { + + private static final Logger log = LogManager.getLogger(OAuth2Util.class); + + public static Tokens refreshTokens(Tokens tokens, String tokenServerUrl) { + if (tokens == null) { + log.warn("can't refresh null!"); + return null; + } + if (tokens != null + && !Strings.isNullOrEmpty(tokens.accessToken) + && tokens.expiresAt != null + //TODO: try to decode given access token (jwt decode) to parse expiration date + && tokens.expiresAt.after(new Date())) { + // No need to refresh + log.info("access token is still valid - returning as-is..."); + return tokens; + } + + log.info("refreshing access token..."); + + Map params = new HashMap<>(); + params.put("refresh_token", tokens.refreshToken); + params.put("grant_type", "refresh_token"); + + TokenResponse tokenResponse = getTokenResponse(tokenServerUrl, params, Collections.emptyMap()); + if (tokenResponse == null) { + log.warn("failed to refresh tokens!"); + } + + return toTokens(tokenResponse); + } + + public static Tokens getTokensForUsernameAndPassword(String username, String password, String tokenServerUrl) { + return getTokensForUsernameAndPassword(username, password, Collections.emptyMap(), tokenServerUrl); + } + + public static Tokens getTokensForUsernameAndPassword(String username, String password, Map additionalParams, String tokenServerUrl) { + log.info("getting new tokens for username and password..."); + + Map params = new HashMap<>(); + params.put("username", username); + params.put("password", password); + params.put("grant_type", "password"); + + TokenResponse tokenResponse = getTokenResponse(tokenServerUrl, params, additionalParams); + if (tokenResponse == null) { + log.warn("failed to get new tokens for username and password!"); + } + return toTokens(tokenResponse); + } + + public static Tokens getTokensForClientCredentials(String clientId, String clientSecret, String tokenServerUrl) { + return getTokensForClientCredentials(clientId, clientSecret, Collections.emptyMap(), tokenServerUrl); + } + + public static Tokens getTokensForClientCredentials(String clientId, String clientSecret, Map additionalParams, String tokenServerUrl) { + log.info("getting new tokens for client id and client secret..."); + + Map params = new HashMap<>(); + params.put("client_id", clientId); + params.put("client_secret", clientSecret); + params.put("grant_type", "client_credentials"); + + TokenResponse tokenResponse = getTokenResponse(tokenServerUrl, params, additionalParams); + if (tokenResponse == null) { + log.warn("failed to get new tokens for username and password!"); + } + return toTokens(tokenResponse); + } + + // Utils + private static Tokens toTokens(TokenResponse tokenResponse) { + if (tokenResponse == null) { + return null; + } + Instant expiresAt = Instant.now().plusSeconds(tokenResponse.expiresInSeconds); + return new Tokens(tokenResponse.accessToken, Date.from(expiresAt), tokenResponse.refreshToken); + } + + private static TokenResponse getTokenResponse(String url, Map params, Map additionalParams) { + Form form = new Form(); + params.forEach((k, v) -> form.param(k, v)); + + additionalParams.entrySet().stream() + .filter(e -> params.containsKey(e.getKey())) + .forEach(e -> form.param(e.getKey(), e.getValue())); + + TokenResponse tokenResponse = post( + url, + form, + APPLICATION_FORM_URLENCODED, + HttpClient.HeaderBuilder.builder(), + TokenResponse.class + ); + + return tokenResponse; + } + + public record Tokens(String accessToken, Date expiresAt, String refreshToken) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + private static final class TokenResponse { + @JsonProperty("access_token") + public String accessToken; + @JsonProperty("expires_in") + public Integer expiresInSeconds; + @JsonProperty("refresh_token") + public String refreshToken; + } +}