OvhSmsSender.java

package fr.sii.ogham.sms.sender.impl;

import static fr.sii.ogham.sms.OvhSmsConstants.DEFAULT_OVHSMS_HTTP2SMS_IMPLEMENTATION_PRIORITY;

import java.io.IOException;
import java.net.URL;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Pattern;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

import fr.sii.ogham.core.builder.priority.Priority;
import fr.sii.ogham.core.exception.MessageException;
import fr.sii.ogham.core.exception.MessageNotSentException;
import fr.sii.ogham.core.exception.util.HttpException;
import fr.sii.ogham.core.exception.util.PhoneNumberException;
import fr.sii.ogham.core.sender.AbstractSpecializedSender;
import fr.sii.ogham.core.util.StringUtils;
import fr.sii.ogham.sms.message.PhoneNumber;
import fr.sii.ogham.sms.message.Recipient;
import fr.sii.ogham.sms.message.Sms;
import fr.sii.ogham.sms.sender.impl.ovh.OvhAuthParams;
import fr.sii.ogham.sms.sender.impl.ovh.OvhOptions;
import fr.sii.ogham.sms.sender.impl.ovh.SmsCoding;
import fr.sii.ogham.sms.sender.impl.ovh.SmsCodingDetector;
import fr.sii.ogham.sms.util.HttpUtils;
import fr.sii.ogham.sms.util.http.Parameter;
import fr.sii.ogham.sms.util.http.Response;

/**
 * Implementation that is able to send SMS through <a href=
 * "https://docs.ovh.com/fr/sms/envoyer_des_sms_depuis_une_url_-_http2sms/#objectif">OVH
 * web service</a>. This sender requires that phone numbers are provided using
 * either:
 * <ul>
 * <li><a href="https://en.wikipedia.org/wiki/E.164">E.164 international
 * format</a> (prefixed with '+' followed by country code)</li>
 * <li><a href="https://en.wikipedia.org/wiki/E.123">E.123 international
 * format</a> (prefixed with '+' followed by country code and can contain
 * spaces)</li>
 * <li>Using 13 digits: country code on 4 digits followed by number. For
 * example, 0033 6 01 02 03 04 is a valid French number (country code is 33,
 * additional '0' are added to reach the 4 digits)</li>
 * </ul>
 * 
 * @author Aurélien Baudet
 *
 */
@Priority(properties = "${ogham.sms.implementation-priority.ovh-http2sms}", defaultValue = DEFAULT_OVHSMS_HTTP2SMS_IMPLEMENTATION_PRIORITY)
public class OvhSmsSender extends AbstractSpecializedSender<Sms> {
	private static final Logger LOG = LoggerFactory.getLogger(OvhSmsSender.class);
	private static final String CONTENT_TYPE = "application/json";
	private static final String RESPONSE_TYPE = "contentType";
	private static final String MESSAGE = "message";
	private static final String SMS_CODING = "smsCoding";
	private static final String TO = "to";
	private static final String FROM = "from";
	private static final String RECIPIENTS_SEPARATOR = ",";
	private static final int OK_STATUS = 200;
	private static final int INTERNATIONAL_FORMAT_LENGTH = 13;
	private static final Pattern SPACES = Pattern.compile("\\s+");

	/**
	 * The authentication parameters
	 */
	private final OvhAuthParams authParams;

	/**
	 * The OVH options
	 */
	private final OvhOptions options;

	/**
	 * This is used to parse JSON response
	 */
	private final ObjectMapper mapper;

	/**
	 * The URL to OVH web service
	 */
	private final URL url;

	/**
	 * If {@link SmsCoding} not set, detects which {@link SmsCoding} can be used
	 * to encode the message
	 */
	private final SmsCodingDetector smsCodingDetector;

	public OvhSmsSender(URL url, OvhAuthParams authParams, OvhOptions options, SmsCodingDetector smsCodingDetector) {
		super();
		this.url = url;
		this.authParams = authParams;
		this.options = options;
		this.smsCodingDetector = smsCodingDetector;
		this.mapper = new ObjectMapper();
	}

	@Override
	public void send(Sms message) throws MessageException {
		try {
			String text = getContent(message);
			// @formatter:off
			Response response = HttpUtils.get(url.toString(), authParams, options,
									new Parameter(SMS_CODING, getSmsCodingValue(text)),
									new Parameter(RESPONSE_TYPE, CONTENT_TYPE),
									// convert phone number to international format
									new Parameter(FROM, toInternational(message.getFrom().getPhoneNumber())),
									new Parameter(TO, StringUtils.join(convert(message.getRecipients()), RECIPIENTS_SEPARATOR)),
									// TODO: manage long messages: how to do ??
									new Parameter(MESSAGE, text));
			// @formatter:on
			handleResponse(message, response);
		} catch (IOException e) {
			throw new MessageException("Failed to read response when sending SMS through OVH", message, e);
		} catch (HttpException e) {
			throw new MessageException("Failed to send SMS through OVH", message, e);
		} catch (PhoneNumberException e) {
			throw new MessageException("Failed to send SMS through OVH (invalid phone number)", message, e);
		}
	}

	private Integer getSmsCodingValue(String message) {
		SmsCoding smsCoding = getSmsCoding(message);
		return smsCoding == null ? null : smsCoding.getValue();
	}

	private SmsCoding getSmsCoding(String message) {
		if (options.getSmsCoding() != null) {
			return options.getSmsCoding();
		}
		return smsCodingDetector.detect(message);
	}

	/**
	 * Handle OVH response. If status provided in response is less than 200,
	 * then the message has been sent. Otherwise, the message has not been sent.
	 * 
	 * @param message
	 *            the SMS to send
	 * @param response
	 *            the received response from OVH API
	 * @throws IOException
	 *             when the response couldn't be read
	 * @throws JsonProcessingException
	 *             when the response format is not valid JSON
	 * @throws MessageNotSentException
	 *             generated exception to indicate that the message couldn't be
	 *             sent
	 */
	private void handleResponse(Sms message, Response response) throws IOException, MessageNotSentException {
		if (response.getStatus().isSuccess()) {
			JsonNode json = mapper.readTree(response.getBody());
			int ovhStatus = json.get("status").asInt();
			// 100 <= ovh status < 200 ====> OK -> just log response
			// 200 <= ovh status ====> KO -> throw an exception
			if (ovhStatus >= OK_STATUS) {
				LOG.error("SMS failed to be sent through OVH");
				LOG.debug("Sent SMS: {}", message);
				LOG.debug("Response status {}", response.getStatus());
				LOG.debug("Response body {}", response.getBody());
				throw new MessageNotSentException("SMS couldn't be sent through OVH: " + json.get(MESSAGE).asText(), message);
			} else {
				LOG.info("SMS successfully sent through OVH");
				LOG.debug("Sent SMS: {}", message);
				LOG.debug("Response: {}", response.getBody());
			}
		} else {
			LOG.error("Response status {}", response.getStatus());
			LOG.error("Response body {}", response.getBody());
			throw new MessageNotSentException("SMS couldn't be sent. Response status is " + response.getStatus(), message);
		}
	}

	/**
	 * Get the content of the SMS and apply some transformations on it to be
	 * usable by OVH.
	 * 
	 * @param message
	 *            the message that contains the content to extract
	 * @return the content formatted for OVH
	 */
	private static String getContent(Sms message) {
		// if a string contains \r\n, only \r is kept
		// if there are \n without \r, those \n are converted to \r
		return message.getContent().toString().replaceAll("(\r)?\n", "\r");
	}

	/**
	 * Convert the list of SMS recipients to international phone numbers usable
	 * by OVH.
	 * 
	 * @param recipients
	 *            the list of recipients
	 * @return the list of international phone numbers
	 * @throws PhoneNumberException
	 *             when phone number can't be handled by OVH
	 */
	private static List<String> convert(List<Recipient> recipients) throws PhoneNumberException {
		List<String> tos = new ArrayList<>(recipients.size());
		// convert phone numbers to international format
		for (Recipient recipient : recipients) {
			tos.add(toInternational(recipient.getPhoneNumber()));
		}
		return tos;
	}

	/**
	 * Convert a raw phone number to its international form usable by OVH (13
	 * digits with no space).
	 * 
	 * @param phoneNumber
	 *            the phone number to transform
	 * @return the international phone number
	 * @throws PhoneNumberException
	 *             when phone number can't be handled by OVH
	 */
	private static String toInternational(PhoneNumber phoneNumber) throws PhoneNumberException {
		String number = phoneNumber.getNumber();
		if (number.startsWith("+") || number.length() == INTERNATIONAL_FORMAT_LENGTH) {
			String withoutPlus = number.replace("+", "");
			String withoutSpaces = SPACES.matcher(withoutPlus).replaceAll("");
			return StringUtils.leftPad(withoutSpaces, INTERNATIONAL_FORMAT_LENGTH, '0');
		} else {
			throw new PhoneNumberException("Invalid phone number. OVH only accepts international phone numbers. Please write the phone number with the country prefix. "
					+ "For example, if the number is 0601020304 and it is a French number, then the international number is +33601020304", phoneNumber);
		}
	}

	public OvhAuthParams getAuthParams() {
		return authParams;
	}

	public OvhOptions getOptions() {
		return options;
	}

	public URL getUrl() {
		return url;
	}
}