├── gradle.properties ├── settings.gradle ├── src ├── test │ ├── resources │ │ ├── mockito-extensions │ │ │ └── org.mockito.plugins.MockMaker │ │ └── template │ │ │ ├── test_template.xml │ │ │ └── readme-template.mjml │ └── java │ │ └── es │ │ └── atrujillo │ │ └── mjml │ │ ├── util │ │ └── TestUtils.java │ │ ├── service │ │ └── auth │ │ │ ├── EnvironmentMjmlAuthTest.java │ │ │ ├── MemoryMjmlAuthTest.java │ │ │ └── PropertiesMjmlAuthTest.java │ │ ├── model │ │ └── ModelPojoTest.kt │ │ ├── rest │ │ └── BasicAuthRestClientTest.kt │ │ ├── integration │ │ └── service │ │ │ ├── MjmlRestServiceJavaTest.java │ │ │ └── MjmlRestServiceKtTest.kt │ │ └── config │ │ └── template │ │ └── TemplateFactoryTest.kt └── main │ └── kotlin │ └── es │ └── atrujillo │ └── mjml │ ├── model │ └── mjml │ │ ├── MjmlRequest.kt │ │ ├── MjmlError.kt │ │ ├── MjmlApiError.kt │ │ └── MjmlResponse.kt │ ├── exception │ ├── InvalidMjmlApiUrlException.kt │ ├── MjmlApiErrorException.kt │ └── MjmlApiUnsupportedVersionException.kt │ ├── rest │ ├── Jacksonable.kt │ ├── BasicAuthRestClient.kt │ ├── RestClient.kt │ └── HttpRestClient.kt │ ├── util │ ├── Constants.kt │ └── LogExtensions.kt │ ├── service │ ├── auth │ │ ├── MjmlAuth.kt │ │ ├── MemoryMjmlAuth.kt │ │ ├── SystemEnvironmentMjmlAuth.kt │ │ ├── PropertiesMjmlAuth.kt │ │ └── MjmlAuthFactory.kt │ ├── definition │ │ └── MjmlService.kt │ └── impl │ │ └── MjmlRestService.kt │ └── config │ ├── http │ └── basicauth │ │ └── HttpComponentsClientHttpRequestFactoryBasicAuth.kt │ └── template │ └── TemplateFactory.kt ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── .gitignore ├── .github └── dependabot.yml ├── .circleci └── config.yml ├── gradlew.bat ├── gradlew ├── README.md └── LICENSE.md /gradle.properties: -------------------------------------------------------------------------------- 1 | version=2.0.2 -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'mjml-rest-client' 2 | -------------------------------------------------------------------------------- /src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker: -------------------------------------------------------------------------------- 1 | mock-maker-inline -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atrujillofalcon/mjml-rest-client/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /src/main/kotlin/es/atrujillo/mjml/model/mjml/MjmlRequest.kt: -------------------------------------------------------------------------------- 1 | package es.atrujillo.mjml.model.mjml 2 | 3 | internal data class MjmlRequest(val mjml: String) -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/*.iml 2 | **/.ivy 3 | **/.classpath 4 | **/.project 5 | **/.settings 6 | **/bin 7 | **/build 8 | **/build_gradle 9 | .gradle 10 | .idea 11 | 12 | **/out -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-4.9-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /src/main/kotlin/es/atrujillo/mjml/model/mjml/MjmlError.kt: -------------------------------------------------------------------------------- 1 | package es.atrujillo.mjml.model.mjml 2 | 3 | internal data class MjmlError(val message: String, val tagName: String, 4 | val formattedMessage: String, val lineNumber: String?) -------------------------------------------------------------------------------- /src/main/kotlin/es/atrujillo/mjml/exception/InvalidMjmlApiUrlException.kt: -------------------------------------------------------------------------------- 1 | package es.atrujillo.mjml.exception 2 | 3 | /** 4 | * Exception throwed when entered URL don't match with a URL regex pattern 5 | * @see es.atrujillo.mjml.util.RegexConstants.URL_REGEX 6 | */ 7 | class InvalidMjmlApiUrlException : IllegalArgumentException("Invalid MJML API url entered") -------------------------------------------------------------------------------- /src/main/kotlin/es/atrujillo/mjml/model/mjml/MjmlApiError.kt: -------------------------------------------------------------------------------- 1 | package es.atrujillo.mjml.model.mjml 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty 4 | import es.atrujillo.mjml.util.StringConstants 5 | 6 | /** 7 | * API Errors to not 200 responses 8 | */ 9 | data class MjmlApiError(val message: String, @JsonProperty("request_id") val requestId: String = StringConstants.EMPTY) -------------------------------------------------------------------------------- /src/main/kotlin/es/atrujillo/mjml/exception/MjmlApiErrorException.kt: -------------------------------------------------------------------------------- 1 | package es.atrujillo.mjml.exception 2 | 3 | import es.atrujillo.mjml.model.mjml.MjmlApiError 4 | import org.springframework.http.HttpStatus 5 | 6 | /** 7 | * Exception throwed when API response is not successfully 8 | */ 9 | open class MjmlApiErrorException(mjmlApiError: MjmlApiError, statusCode: HttpStatus) 10 | : RuntimeException("API Error ${statusCode.value()} - ${mjmlApiError.message}") -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gradle 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "04:00" 8 | open-pull-requests-limit: 10 9 | target-branch: develop 10 | ignore: 11 | - dependency-name: org.springframework:spring-web 12 | versions: 13 | - 5.3.3 14 | - 5.3.4 15 | - 5.3.5 16 | - dependency-name: org.mockito:mockito-junit-jupiter 17 | versions: 18 | - 3.7.7 19 | - 3.8.0 20 | - dependency-name: org.junit.jupiter:junit-jupiter 21 | versions: 22 | - 5.7.0 23 | -------------------------------------------------------------------------------- /src/main/kotlin/es/atrujillo/mjml/exception/MjmlApiUnsupportedVersionException.kt: -------------------------------------------------------------------------------- 1 | package es.atrujillo.mjml.exception 2 | 3 | import es.atrujillo.mjml.model.mjml.MjmlApiError 4 | import org.springframework.http.HttpStatus 5 | 6 | /** 7 | * Exception throwed when mjml template is unsupported by API mjml version 8 | */ 9 | class MjmlApiUnsupportedVersionException(mjmlVersion: String) 10 | : MjmlApiErrorException(MjmlApiError("Sended MJML template is invalid with current API version ($mjmlVersion). " + 11 | "Please, use a valid $mjmlVersion supported template."), HttpStatus.BAD_REQUEST) -------------------------------------------------------------------------------- /src/main/kotlin/es/atrujillo/mjml/rest/Jacksonable.kt: -------------------------------------------------------------------------------- 1 | package es.atrujillo.mjml.rest 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper 4 | 5 | /** 6 | * Classes that implements this interface should be capables of obtain a {@link ObjectMapper} from its context. 7 | * @see ObjectMapper 8 | * 9 | * @author Arnaldo Trujillo 10 | */ 11 | internal interface Jacksonable { 12 | 13 | /** 14 | * Return the previusly configured ObjectMapper of library context 15 | * 16 | * @see ObjectMapper 17 | * @return ObjectMapper 18 | */ 19 | fun getObjectMapper(): ObjectMapper 20 | } -------------------------------------------------------------------------------- /src/main/kotlin/es/atrujillo/mjml/util/Constants.kt: -------------------------------------------------------------------------------- 1 | package es.atrujillo.mjml.util 2 | 3 | /** 4 | * String common constants 5 | * 6 | * @author Arnaldo Trujillo 7 | */ 8 | internal class StringConstants { 9 | 10 | companion object { 11 | const val EMPTY = "" 12 | } 13 | 14 | } 15 | 16 | /** 17 | * Regex common expressions 18 | * 19 | * @author Arnaldo Trujillo 20 | */ 21 | internal class RegexConstants { 22 | 23 | companion object { 24 | @JvmStatic 25 | val URL_REGEX = Regex("^(https?)://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%=~_|]") 26 | } 27 | 28 | } -------------------------------------------------------------------------------- /src/test/resources/template/test_template.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Positive Column 10 | 11 | 12 | Negative Column 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/main/kotlin/es/atrujillo/mjml/service/auth/MjmlAuth.kt: -------------------------------------------------------------------------------- 1 | package es.atrujillo.mjml.service.auth 2 | 3 | import java.net.URI 4 | 5 | /** 6 | * Interface get information about mjml api credential for future calls 7 | * 8 | * @author Arnaldo Trujillo 9 | */ 10 | interface MjmlAuth { 11 | 12 | /** 13 | * Returns mjml api applicationId 14 | */ 15 | fun getMjmlApplicationId(): String 16 | 17 | /** 18 | * Returns mjml api secret key token 19 | */ 20 | fun getMjmlApplicationSecretKey(): String 21 | 22 | /** 23 | * Return API url 24 | */ 25 | fun getMjmlApiEndpoint(): URI 26 | 27 | } 28 | -------------------------------------------------------------------------------- /src/main/kotlin/es/atrujillo/mjml/service/definition/MjmlService.kt: -------------------------------------------------------------------------------- 1 | package es.atrujillo.mjml.service.definition 2 | 3 | /** 4 | * Service to convert a template in MJML format to the final HTML that we will use in our emails. 5 | * @see es.atrujillo.mjml.service.impl.MjmlRestService 6 | * 7 | * 8 | * @throws es.atrujillo.mjml.exception.MjmlApiErrorException 9 | * @author Arnaldo Trujillo 10 | */ 11 | interface MjmlService { 12 | 13 | /** 14 | * Convert the mjml template to html 15 | * @param mjmlBody Mjml template body 16 | * @return Transpiled final HTML 17 | */ 18 | fun transpileMjmlToHtml(mjmlBody: String): String 19 | } 20 | -------------------------------------------------------------------------------- /src/main/kotlin/es/atrujillo/mjml/util/LogExtensions.kt: -------------------------------------------------------------------------------- 1 | package es.atrujillo.mjml.util 2 | 3 | import org.slf4j.LoggerFactory 4 | 5 | internal fun Any.logDebug(msg: String) { 6 | LoggerFactory.getLogger(this::class.java).debug(msg) 7 | } 8 | 9 | internal fun Any.logInfo(msg: String) { 10 | LoggerFactory.getLogger(this::class.java).info(msg) 11 | } 12 | 13 | internal fun Any.logWarn(msg: String) { 14 | LoggerFactory.getLogger(this::class.java).warn(msg) 15 | } 16 | 17 | internal fun Any.logError(msg: String) { 18 | LoggerFactory.getLogger(this::class.java).error(msg) 19 | } 20 | 21 | internal fun Any.logError(msg: String, e: Throwable) { 22 | LoggerFactory.getLogger(this::class.java).error(msg, e) 23 | } -------------------------------------------------------------------------------- /src/main/kotlin/es/atrujillo/mjml/service/auth/MemoryMjmlAuth.kt: -------------------------------------------------------------------------------- 1 | package es.atrujillo.mjml.service.auth 2 | 3 | import java.net.URI 4 | 5 | /** 6 | * @author Arnaldo Trujillo 7 | */ 8 | class MemoryMjmlAuth private constructor(private val mjmlAppId: String, private val mjmlSecretKey: String) : MjmlAuth { 9 | 10 | private var mjmlApiEndpoint: URI = URI.create("https://api.mjml.io/v1") 11 | 12 | constructor(mjmlAppId: String, mjmlSecretKey: String, mjmlApiEndpoint: URI) : this(mjmlAppId, mjmlSecretKey) { 13 | this.mjmlApiEndpoint = mjmlApiEndpoint 14 | } 15 | 16 | override fun getMjmlApplicationId(): String = mjmlAppId 17 | override fun getMjmlApplicationSecretKey(): String = mjmlSecretKey 18 | override fun getMjmlApiEndpoint(): URI = mjmlApiEndpoint 19 | 20 | } 21 | -------------------------------------------------------------------------------- /src/main/kotlin/es/atrujillo/mjml/model/mjml/MjmlResponse.kt: -------------------------------------------------------------------------------- 1 | package es.atrujillo.mjml.model.mjml 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty 4 | import es.atrujillo.mjml.util.StringConstants 5 | 6 | internal data class MjmlResponse(val html: String = StringConstants.EMPTY, 7 | val mjml: String = StringConstants.EMPTY, 8 | @JsonProperty("mjml_version") 9 | val mjmlVersion: String = StringConstants.EMPTY, 10 | val errors: List = emptyList()) { 11 | 12 | fun getMajorVersion(): Double { 13 | return if (mjmlVersion.contains(".")) mjmlVersion 14 | .substring(0..mjmlVersion.indexOfFirst { c -> c.equals('.', true) }) 15 | .toDouble() else 0.0 16 | } 17 | } -------------------------------------------------------------------------------- /src/test/resources/template/readme-template.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |

7 |

8 | 9 | 10 | 11 | 12 | 13 |
14 |
15 |
16 |
17 |
-------------------------------------------------------------------------------- /src/test/java/es/atrujillo/mjml/util/TestUtils.java: -------------------------------------------------------------------------------- 1 | package es.atrujillo.mjml.util; 2 | 3 | public class TestUtils { 4 | 5 | public static final String HELLO_WORLD_MJML = "Hello World"; 6 | public static final String MALFORMED_TEMPLATE = "Hello World"; 7 | public static final String MJML_VERSION4_TEMPLATE = " Hello World "; 8 | public static final String INVALID_TEMPLATE = ""; 9 | public static final String MJML_APP_ID = "MJML_APP_ID"; 10 | public static final String MJML_SECRET_KEY = "MJML_SECRET_KEY"; 11 | 12 | } 13 | -------------------------------------------------------------------------------- /src/test/java/es/atrujillo/mjml/service/auth/EnvironmentMjmlAuthTest.java: -------------------------------------------------------------------------------- 1 | package es.atrujillo.mjml.service.auth; 2 | 3 | import org.junit.jupiter.api.Assertions; 4 | import org.junit.jupiter.api.DisplayName; 5 | import org.junit.jupiter.api.Test; 6 | 7 | import java.util.UUID; 8 | 9 | /** 10 | * Test Environment authentication 11 | */ 12 | class EnvironmentMjmlAuthTest { 13 | 14 | 15 | /** 16 | * Test invalid arguments 17 | */ 18 | @Test 19 | @DisplayName("Test Build Properties Auth") 20 | void testNotFoundEnvironmentVariables() { 21 | Assertions.assertThrows(IllegalArgumentException.class, () -> { 22 | MjmlAuthFactory.builder() 23 | .withEnvironmentCredentials() 24 | .mjmlKeyNames(null, null) 25 | .build(); 26 | }); 27 | Assertions.assertThrows(IllegalArgumentException.class, () -> { 28 | MjmlAuthFactory.builder() 29 | .withEnvironmentCredentials() 30 | .mjmlKeyNames(UUID.randomUUID().toString(), UUID.randomUUID().toString()) 31 | .build() 32 | .getMjmlApplicationId(); 33 | }); 34 | } 35 | 36 | } 37 | 38 | -------------------------------------------------------------------------------- /src/main/kotlin/es/atrujillo/mjml/config/http/basicauth/HttpComponentsClientHttpRequestFactoryBasicAuth.kt: -------------------------------------------------------------------------------- 1 | package es.atrujillo.mjml.config.http.basicauth 2 | 3 | import org.apache.http.HttpHost 4 | import org.apache.http.client.protocol.HttpClientContext 5 | import org.apache.http.impl.auth.BasicScheme 6 | import org.apache.http.impl.client.BasicAuthCache 7 | import org.apache.http.protocol.BasicHttpContext 8 | import org.apache.http.protocol.HttpContext 9 | import org.springframework.http.HttpMethod 10 | import org.springframework.http.client.HttpComponentsClientHttpRequestFactory 11 | import java.net.URI 12 | 13 | 14 | internal class HttpComponentsClientHttpRequestFactoryBasicAuth(private val host: HttpHost) : HttpComponentsClientHttpRequestFactory() { 15 | 16 | override fun createHttpContext(httpMethod: HttpMethod?, uri: URI?) = createHttpContext() 17 | 18 | private fun createHttpContext(): HttpContext { 19 | val authCache = BasicAuthCache() 20 | val basicAuth = BasicScheme() 21 | authCache.put(host, basicAuth) 22 | 23 | val localcontext = BasicHttpContext() 24 | localcontext.setAttribute(HttpClientContext.AUTH_CACHE, authCache) 25 | return localcontext 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /src/main/kotlin/es/atrujillo/mjml/service/auth/SystemEnvironmentMjmlAuth.kt: -------------------------------------------------------------------------------- 1 | package es.atrujillo.mjml.service.auth 2 | 3 | import java.net.URI 4 | 5 | /** 6 | * @author Arnaldo Trujillo 7 | */ 8 | class SystemEnvironmentMjmlAuth private constructor(private val mjmlAppIdKeyName: String, 9 | private val mjmlSecretKeyName: String) : MjmlAuth { 10 | 11 | private var mjmlApiEndpoint: URI = URI.create("https://api.mjml.io/v1") 12 | 13 | constructor(mjmlAppIdKeyName: String, mjmlSecretKeyName: String, mjmlApiEndpoint: URI) : this(mjmlAppIdKeyName, mjmlSecretKeyName) { 14 | this.mjmlApiEndpoint = mjmlApiEndpoint 15 | } 16 | 17 | override fun getMjmlApplicationId(): String = validateThatSystemVariableExistsAndGetValue(mjmlAppIdKeyName) 18 | 19 | override fun getMjmlApplicationSecretKey(): String = validateThatSystemVariableExistsAndGetValue(mjmlSecretKeyName) 20 | 21 | override fun getMjmlApiEndpoint(): URI = mjmlApiEndpoint 22 | 23 | private fun validateThatSystemVariableExistsAndGetValue(key: String): String { 24 | if (System.getenv().containsKey(key)) 25 | return System.getenv(key) 26 | 27 | throw IllegalArgumentException("System environment don't contains a variable with $key key") 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /src/main/kotlin/es/atrujillo/mjml/service/auth/PropertiesMjmlAuth.kt: -------------------------------------------------------------------------------- 1 | package es.atrujillo.mjml.service.auth 2 | 3 | import java.net.URI 4 | import java.util.* 5 | 6 | /** 7 | * @author Arnaldo Trujillo 8 | */ 9 | class PropertiesMjmlAuth private constructor(private val properties: Properties, private val mjmlAppIdKeyName: String, 10 | private val mjmlSecretKeyName: String) : MjmlAuth { 11 | 12 | private var mjmlApiEndpoint: URI = URI.create("https://api.mjml.io/v1") 13 | 14 | constructor(properties: Properties, mjmlAppIdKeyName: String, mjmlSecretKeyName: String, mjmlApiEndpoint: URI) 15 | : this(properties, mjmlAppIdKeyName, mjmlSecretKeyName) { 16 | 17 | this.mjmlApiEndpoint = mjmlApiEndpoint 18 | } 19 | 20 | override fun getMjmlApplicationId(): String = validateThatPropertyExistsAndGetValue(properties, mjmlAppIdKeyName) 21 | 22 | override fun getMjmlApplicationSecretKey(): String = validateThatPropertyExistsAndGetValue(properties, mjmlSecretKeyName) 23 | 24 | override fun getMjmlApiEndpoint(): URI = mjmlApiEndpoint 25 | 26 | private fun validateThatPropertyExistsAndGetValue(properties: Properties, key: String) :String{ 27 | if (properties.stringPropertyNames().contains(key)) 28 | return properties.getProperty(key) 29 | 30 | throw IllegalArgumentException("Properties file don't contains $key key") 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /src/test/java/es/atrujillo/mjml/model/ModelPojoTest.kt: -------------------------------------------------------------------------------- 1 | package es.atrujillo.mjml.model 2 | 3 | import es.atrujillo.mjml.model.mjml.MjmlApiError 4 | import es.atrujillo.mjml.model.mjml.MjmlError 5 | import es.atrujillo.mjml.model.mjml.MjmlRequest 6 | import es.atrujillo.mjml.model.mjml.MjmlResponse 7 | import org.junit.jupiter.api.DisplayName 8 | import org.junit.jupiter.api.Test 9 | import pl.pojo.tester.api.assertion.Assertions 10 | import pl.pojo.tester.api.assertion.Method 11 | import kotlin.reflect.KClass 12 | 13 | internal class ModelPojoTest { 14 | 15 | @Test 16 | @DisplayName("Test MjmlApiError POJO") 17 | internal fun testPojoMjmlApiError() = testPojoObject(MjmlApiError::class) 18 | 19 | @Test 20 | @DisplayName("Test MjmlError POJO") 21 | internal fun testPojoMjmlError() = testPojoObject(MjmlError::class) 22 | 23 | @Test 24 | @DisplayName("Test MjmlRequest POJO") 25 | internal fun testPojoMjmlRequest() = testPojoObject(MjmlRequest::class) 26 | 27 | @Test 28 | @DisplayName("Test MjmlResponse POJO") 29 | internal fun testPojoMjmlResponse() = testPojoObject(MjmlResponse::class) 30 | 31 | 32 | private fun testPojoObject(pojoClass: KClass<*>) { 33 | Assertions.assertPojoMethodsFor(pojoClass.java) 34 | .testing(Method.GETTER) 35 | .testing(Method.TO_STRING) 36 | .testing(Method.EQUALS) 37 | .testing(Method.HASH_CODE) 38 | .testing(Method.CONSTRUCTOR) 39 | .areWellImplemented() 40 | } 41 | } -------------------------------------------------------------------------------- /src/test/java/es/atrujillo/mjml/rest/BasicAuthRestClientTest.kt: -------------------------------------------------------------------------------- 1 | package es.atrujillo.mjml.rest 2 | 3 | import es.atrujillo.mjml.exception.InvalidMjmlApiUrlException 4 | import org.junit.jupiter.api.Assertions.assertNotNull 5 | import org.junit.jupiter.api.Assertions.assertTrue 6 | import org.junit.jupiter.api.BeforeEach 7 | import org.junit.jupiter.api.DisplayName 8 | import org.junit.jupiter.api.Test 9 | import org.junit.jupiter.api.assertThrows 10 | import org.springframework.http.client.support.BasicAuthorizationInterceptor 11 | 12 | internal class BasicAuthRestClientTest { 13 | 14 | private lateinit var restClient: BasicAuthRestClient 15 | 16 | @BeforeEach 17 | internal fun initialize() { 18 | restClient = BasicAuthRestClient("https://api.mjml.io/v1", "appID", "secretKey") 19 | } 20 | 21 | @Test 22 | @DisplayName("Test RestTemplate Initialization") 23 | internal fun testRestTemplateInitialization() { 24 | assertNotNull(restClient) 25 | assertNotNull(restClient.restTemplate) 26 | assertNotNull(restClient.restTemplate.requestFactory) 27 | assertNotNull(restClient.restTemplate.messageConverters) 28 | assertTrue(restClient.restTemplate.interceptors.stream().anyMatch { it is BasicAuthorizationInterceptor }) 29 | } 30 | 31 | @Test 32 | @DisplayName("When Invalid URL throw InvalidMjmlApiUrl Exception") 33 | internal fun testInitializationWithInvalidUrl() { 34 | assertThrows { 35 | restClient = BasicAuthRestClient("invalid_url", "appID", "secretKey") 36 | } 37 | } 38 | 39 | } -------------------------------------------------------------------------------- /src/main/kotlin/es/atrujillo/mjml/rest/BasicAuthRestClient.kt: -------------------------------------------------------------------------------- 1 | package es.atrujillo.mjml.rest 2 | 3 | import es.atrujillo.mjml.config.http.basicauth.HttpComponentsClientHttpRequestFactoryBasicAuth 4 | import es.atrujillo.mjml.exception.InvalidMjmlApiUrlException 5 | import es.atrujillo.mjml.util.RegexConstants 6 | import org.apache.http.HttpHost 7 | import org.springframework.http.client.support.BasicAuthorizationInterceptor 8 | import org.springframework.web.client.RestTemplate 9 | 10 | import java.net.URI 11 | 12 | /** 13 | * Rest client with BasicAuth integrated 14 | * Each request going to have configured the basic auth token 15 | * {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication} 16 | * 17 | * @author Arnaldo Trujillo 18 | * @param 19 | */ 20 | internal class BasicAuthRestClient(apiEndpoint: String, private val applicationID: String, private val secretKey: String) : HttpRestClient(apiEndpoint) { 21 | 22 | init { 23 | restTemplate = configureRestTemplateWithBasicAuth() 24 | } 25 | 26 | private fun configureRestTemplateWithBasicAuth(): RestTemplate { 27 | if (!RegexConstants.URL_REGEX.matches(apiEndpoint)) 28 | throw InvalidMjmlApiUrlException() 29 | 30 | val uriApiEndpoint = URI.create(apiEndpoint) 31 | val httpHost = HttpHost(uriApiEndpoint.host, uriApiEndpoint.port, uriApiEndpoint.scheme) 32 | 33 | val restTemplate = RestTemplate(HttpComponentsClientHttpRequestFactoryBasicAuth(httpHost)) 34 | restTemplate.interceptors.add(BasicAuthorizationInterceptor(applicationID, secretKey)) 35 | 36 | return restTemplate 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Java Gradle CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-java/ for more details 4 | # 5 | version: 2 6 | jobs: 7 | build: 8 | docker: 9 | # specify the version you desire here 10 | - image: circleci/openjdk:8-jdk 11 | 12 | # Specify service dependencies here if necessary 13 | # CircleCI maintains a library of pre-built images 14 | # documented at https://circleci.com/docs/2.0/circleci-images/ 15 | # - image: circleci/postgres:9.4 16 | 17 | working_directory: ~/repo 18 | 19 | environment: 20 | # Customize the JVM maximum heap limit 21 | JVM_OPTS: -Xmx516m 22 | TERM: dumb 23 | 24 | steps: 25 | - checkout 26 | 27 | # Download and cache dependencies 28 | - restore_cache: 29 | keys: 30 | - v1-dependencies-{{ checksum "build.gradle" }} 31 | # fallback to using the latest cache if no exact match is found 32 | - v1-dependencies- 33 | 34 | - run: gradle dependencies 35 | 36 | - save_cache: 37 | paths: 38 | - ~/.gradle 39 | key: v1-dependencies-{{ checksum "build.gradle" }} 40 | 41 | # run tests! 42 | - run: gradle test jacocoTestReport 43 | - run: bash <(curl -s https://codecov.io/bash) -t 28d5c82d-b6ff-4503-8387-9f3548e5d92c 44 | 45 | - run: 46 | name: Save test results 47 | command: | 48 | mkdir -p ~/test-results/junit/ 49 | find . -type f -regex ".*/build/test-results/.*xml" -exec cp {} ~/test-results/junit/ \; 50 | when: always 51 | - store_test_results: 52 | path: ~/test-results 53 | - store_artifacts: 54 | path: ~/test-results/junit -------------------------------------------------------------------------------- /src/main/kotlin/es/atrujillo/mjml/rest/RestClient.kt: -------------------------------------------------------------------------------- 1 | package es.atrujillo.mjml.rest 2 | 3 | import org.springframework.core.ParameterizedTypeReference 4 | import org.springframework.http.HttpHeaders 5 | import org.springframework.http.ResponseEntity 6 | import org.springframework.util.MultiValueMap 7 | 8 | /** 9 | * RestClient Interface to execute HTTP call 10 | * 11 | * @author Arnaldo Trujillo 12 | */ 13 | internal interface RestClient { 14 | 15 | /** 16 | * GET HTTP method abstraction 17 | * 18 | * @param path URL path 19 | * @param type Return object type expected 20 | * @param params URL query params (Optional) 21 | * @param headers HTTP header (Optional) 22 | */ 23 | operator fun get(path: String, type: ParameterizedTypeReference, params: MultiValueMap? = null, 24 | headers: HttpHeaders? = null): ResponseEntity 25 | 26 | /** 27 | * POST HTTP method abstraction 28 | * 29 | * @param request HTTP body 30 | * @param path URL path 31 | * @param type Return object type expected 32 | * @param params URL query params (Optional) 33 | * @param headers HTTP header (Optional) 34 | */ 35 | fun post(request: R, path: String, type: ParameterizedTypeReference, params: MultiValueMap? = null, 36 | headers: HttpHeaders? = null): ResponseEntity 37 | 38 | /** 39 | * PATCH HTTP method abstraction 40 | * 41 | * @param request HTTP body 42 | * @param path URL path 43 | * @param type Return object type expected 44 | * @param params URL query params (Optional) 45 | * @param headers HTTP header (Optional) 46 | */ 47 | fun patch(request: R, path: String, type: ParameterizedTypeReference, params: MultiValueMap? = null, 48 | headers: HttpHeaders? = null): ResponseEntity 49 | 50 | } 51 | -------------------------------------------------------------------------------- /src/test/java/es/atrujillo/mjml/integration/service/MjmlRestServiceJavaTest.java: -------------------------------------------------------------------------------- 1 | package es.atrujillo.mjml.integration.service; 2 | 3 | import es.atrujillo.mjml.config.template.TemplateFactory; 4 | import es.atrujillo.mjml.service.auth.MjmlAuth; 5 | import es.atrujillo.mjml.service.auth.MjmlAuthFactory; 6 | import es.atrujillo.mjml.service.definition.MjmlService; 7 | import es.atrujillo.mjml.service.impl.MjmlRestService; 8 | import org.junit.jupiter.api.DisplayName; 9 | import org.junit.jupiter.api.Test; 10 | 11 | import static es.atrujillo.mjml.util.TestUtils.HELLO_WORLD_MJML; 12 | import static es.atrujillo.mjml.util.TestUtils.MJML_APP_ID; 13 | import static es.atrujillo.mjml.util.TestUtils.MJML_SECRET_KEY; 14 | import static org.junit.jupiter.api.Assertions.assertFalse; 15 | import static org.junit.jupiter.api.Assertions.assertNotNull; 16 | 17 | /** 18 | * @author Arnaldo Trujillo 19 | */ 20 | class MjmlRestServiceJavaTest { 21 | 22 | 23 | /** 24 | * Test that valid mjml template is converted to html using MjmlService 25 | */ 26 | @Test 27 | @DisplayName("Integration API test") 28 | void testThatMjmlApiRespondCorrectly() { 29 | 30 | assertNotNull(MJML_APP_ID, "You have to configure environment variable MJML_APP_ID"); 31 | assertNotNull(MJML_SECRET_KEY, "You have to configure environment variable MJML_SECRET_KEY"); 32 | 33 | MjmlAuth authConf = MjmlAuthFactory.builder() 34 | .withEnvironmentCredentials() 35 | .mjmlKeyNames(MJML_APP_ID, MJML_SECRET_KEY) 36 | .build(); 37 | 38 | String template = TemplateFactory.builder() 39 | .withStringTemplate() 40 | .template(HELLO_WORLD_MJML) 41 | .buildTemplate(); 42 | 43 | MjmlService mjmlService = new MjmlRestService(authConf); 44 | String response = mjmlService.transpileMjmlToHtml(template); 45 | 46 | assertNotNull(response); 47 | assertFalse(response.isEmpty()); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /src/test/java/es/atrujillo/mjml/service/auth/MemoryMjmlAuthTest.java: -------------------------------------------------------------------------------- 1 | package es.atrujillo.mjml.service.auth; 2 | 3 | import org.junit.jupiter.api.Assertions; 4 | import org.junit.jupiter.api.DisplayName; 5 | import org.junit.jupiter.api.Test; 6 | import org.mockito.Mockito; 7 | 8 | import java.net.URI; 9 | import java.util.UUID; 10 | 11 | class MemoryMjmlAuthTest { 12 | 13 | private final static String DUMMY_URI_STRING = "http://localhost/mjml"; 14 | private MjmlAuth mjmlAuth; 15 | private String appID; 16 | private String secretKey; 17 | 18 | @Test 19 | @DisplayName("Test Build Memory Auth") 20 | void testAuthInstance() { 21 | appID = UUID.randomUUID().toString(); 22 | secretKey = UUID.randomUUID().toString(); 23 | mjmlAuth = MjmlAuthFactory.builder() 24 | .withMemoryCredentials() 25 | .mjmlCredentials(appID, secretKey) 26 | .build(); 27 | 28 | Assertions.assertNotNull(mjmlAuth); 29 | Assertions.assertNotNull(mjmlAuth.getMjmlApplicationId()); 30 | Assertions.assertNotNull(mjmlAuth.getMjmlApplicationSecretKey()); 31 | Assertions.assertNotNull(mjmlAuth.getMjmlApiEndpoint()); 32 | Assertions.assertEquals(appID, mjmlAuth.getMjmlApplicationId()); 33 | Assertions.assertEquals(secretKey, mjmlAuth.getMjmlApplicationSecretKey()); 34 | 35 | URI mockURI = Mockito.mock(URI.class); 36 | Mockito.when(mockURI.toString()).thenReturn(DUMMY_URI_STRING); 37 | mjmlAuth = MjmlAuthFactory.builder() 38 | .withMemoryCredentials() 39 | .mjmlCredentials(appID, secretKey) 40 | .changeEndpoint(mockURI) 41 | .build(); 42 | 43 | Assertions.assertEquals(DUMMY_URI_STRING, mjmlAuth.getMjmlApiEndpoint().toString()); 44 | 45 | } 46 | 47 | @Test 48 | @DisplayName("Test That Fail When Null values") 49 | void testAuthInstanceWithNullValues() { 50 | Assertions.assertThrows(IllegalArgumentException.class, () -> { 51 | mjmlAuth = MjmlAuthFactory.builder() 52 | .withMemoryCredentials() 53 | .mjmlCredentials(null, null) 54 | .build(); 55 | }); 56 | Assertions.assertThrows(IllegalArgumentException.class, () -> { 57 | mjmlAuth = MjmlAuthFactory.builder() 58 | .withMemoryCredentials() 59 | .mjmlCredentials(UUID.randomUUID().toString(), null) 60 | .build(); 61 | }); 62 | Assertions.assertThrows(IllegalArgumentException.class, () -> { 63 | mjmlAuth = MjmlAuthFactory.builder() 64 | .withMemoryCredentials() 65 | .mjmlCredentials(null, UUID.randomUUID().toString()) 66 | .build(); 67 | }); 68 | Assertions.assertThrows(IllegalArgumentException.class, () -> { 69 | MjmlAuthFactory.builder() 70 | .withMemoryCredentials() 71 | .mjmlCredentials(UUID.randomUUID().toString(), UUID.randomUUID().toString()) 72 | .changeEndpoint(null) 73 | .build(); 74 | }); 75 | 76 | } 77 | 78 | } 79 | 80 | -------------------------------------------------------------------------------- /src/main/kotlin/es/atrujillo/mjml/rest/HttpRestClient.kt: -------------------------------------------------------------------------------- 1 | package es.atrujillo.mjml.rest 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper 4 | import org.slf4j.LoggerFactory 5 | import org.springframework.core.ParameterizedTypeReference 6 | import org.springframework.http.HttpEntity 7 | import org.springframework.http.HttpHeaders 8 | import org.springframework.http.HttpMethod 9 | import org.springframework.http.ResponseEntity 10 | import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter 11 | import org.springframework.util.MultiValueMap 12 | import org.springframework.web.client.RestTemplate 13 | import org.springframework.web.util.UriComponentsBuilder 14 | import java.net.URI 15 | import java.util.* 16 | 17 | /** 18 | * 19 | * @param Request type 20 | * @author Arnaldo Trujillo 21 | */ 22 | internal abstract class HttpRestClient protected constructor(protected var apiEndpoint: String) : RestClient, Jacksonable { 23 | 24 | lateinit var restTemplate: RestTemplate 25 | 26 | private fun httpRequest(request: R?, path: String, httpMethod: HttpMethod, type: ParameterizedTypeReference, 27 | params: MultiValueMap? = null, headers: HttpHeaders? = null): ResponseEntity { 28 | 29 | LOG.debug(String.format("%s HttpRequest to %s", httpMethod.toString(), apiEndpoint)) 30 | 31 | var url = URI.create(apiEndpoint + path) 32 | if (Objects.nonNull(params)) { 33 | url = UriComponentsBuilder.fromUri(url).queryParams(params).build().toUri() 34 | } 35 | 36 | //request puede ser null en el caso de que la petición no tenga body 37 | val entityRequest = HttpEntity(request, headers) 38 | 39 | return restTemplate.exchange(url, httpMethod, entityRequest, type) 40 | } 41 | 42 | override fun get(path: String, type: ParameterizedTypeReference, params: MultiValueMap?, 43 | headers: HttpHeaders?): ResponseEntity = httpRequest(null, path, HttpMethod.GET, type, params, headers) 44 | 45 | 46 | override fun post(request: R, path: String, type: ParameterizedTypeReference, 47 | params: MultiValueMap?, headers: HttpHeaders?): ResponseEntity = httpRequest(request, path, HttpMethod.POST, type, params, headers) 48 | 49 | override fun patch(request: R, path: String, type: ParameterizedTypeReference, 50 | params: MultiValueMap?, headers: HttpHeaders?): ResponseEntity = httpRequest(request, path, HttpMethod.PATCH, type, params, headers) 51 | 52 | override fun getObjectMapper(): ObjectMapper { 53 | return restTemplate.messageConverters.stream() 54 | .filter { converter -> converter is MappingJackson2HttpMessageConverter } 55 | .findFirst() 56 | .map { jacksonConverter -> 57 | (jacksonConverter as? MappingJackson2HttpMessageConverter)?.objectMapper ?: ObjectMapper() 58 | } 59 | .orElse(ObjectMapper()) 60 | } 61 | 62 | 63 | companion object { 64 | private val LOG = LoggerFactory.getLogger(this::class.java) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/test/java/es/atrujillo/mjml/config/template/TemplateFactoryTest.kt: -------------------------------------------------------------------------------- 1 | package es.atrujillo.mjml.config.template 2 | 3 | import org.junit.jupiter.api.Assertions 4 | import org.junit.jupiter.api.DisplayName 5 | import org.junit.jupiter.api.Test 6 | import org.thymeleaf.context.Context 7 | import java.io.File 8 | import java.net.URL 9 | import java.util.* 10 | 11 | internal class TemplateFactoryTest { 12 | 13 | @Test 14 | @DisplayName("Test Build String Template") 15 | internal fun testBuildStringTemplate() { 16 | val template = TemplateFactory.builder() 17 | .withStringTemplate() 18 | .template(HELLO_WORLD_MJML) 19 | .buildTemplate() 20 | 21 | Assertions.assertEquals(HELLO_WORLD_MJML, template, "The built template has to be the same") 22 | } 23 | 24 | @Test 25 | @DisplayName("Test that build fails if template is not set") 26 | internal fun whenBuildStringTemplateWithoutSetTemplate() { 27 | Assertions.assertThrows(IllegalArgumentException::class.java) { 28 | TemplateFactory.builder() 29 | .withStringTemplate() 30 | .template("") 31 | .buildTemplate() 32 | } 33 | Assertions.assertThrows(IllegalArgumentException::class.java) { 34 | TemplateFactory.builder() 35 | .withFileTemplate() 36 | .template(File("/invalid/path/file")) 37 | .buildTemplate() 38 | } 39 | } 40 | 41 | @Test 42 | @DisplayName("Test Build File Template With Context") 43 | internal fun testBuildFileTemplate() { 44 | val fileTemplate = File( 45 | Objects.requireNonNull(javaClass.classLoader.getResource("template/test_template.xml")) 46 | .file) 47 | 48 | val context = Context(Locale.getDefault()) 49 | val messageVal = "Hello World" 50 | context.setVariable("message", messageVal) 51 | context.setVariable("number", 1) 52 | 53 | val template = TemplateFactory.builder() 54 | .withFileTemplate() 55 | .template(fileTemplate) 56 | .templateContext(context) 57 | .buildTemplate() 58 | 59 | Assertions.assertNotNull(template) 60 | Assertions.assertTrue(template.isNotEmpty(), "Templace can't be empty") 61 | Assertions.assertTrue(template.contains(messageVal), "Template has to include message variable") 62 | Assertions.assertTrue(template.contains("Positive Column"), "Template has to include positive column") 63 | Assertions.assertFalse(template.contains("Negative Column"), "Template can't include negative column") 64 | } 65 | 66 | @Test 67 | @DisplayName("Test Build File Template With Context") 68 | internal fun whenBuildFileTemplateWithIvalidFilePath() { 69 | Assertions.assertThrows(IllegalArgumentException::class.java) { 70 | val invalidFile = File("/invalid/path/template.xml") 71 | 72 | TemplateFactory.builder() 73 | .withFileTemplate() 74 | .template(invalidFile) 75 | .buildTemplate() 76 | } 77 | } 78 | 79 | companion object { 80 | private const val HELLO_WORLD_MJML = "Hello World" 81 | } 82 | 83 | } 84 | -------------------------------------------------------------------------------- /src/main/kotlin/es/atrujillo/mjml/service/impl/MjmlRestService.kt: -------------------------------------------------------------------------------- 1 | package es.atrujillo.mjml.service.impl 2 | 3 | import com.fasterxml.jackson.module.kotlin.readValue 4 | import es.atrujillo.mjml.exception.MjmlApiErrorException 5 | import es.atrujillo.mjml.exception.MjmlApiUnsupportedVersionException 6 | import es.atrujillo.mjml.model.mjml.MjmlApiError 7 | import es.atrujillo.mjml.model.mjml.MjmlRequest 8 | import es.atrujillo.mjml.model.mjml.MjmlResponse 9 | import es.atrujillo.mjml.rest.BasicAuthRestClient 10 | import es.atrujillo.mjml.service.auth.MjmlAuth 11 | import es.atrujillo.mjml.service.definition.MjmlService 12 | import es.atrujillo.mjml.util.logError 13 | import es.atrujillo.mjml.util.logWarn 14 | import org.springframework.core.ParameterizedTypeReference 15 | import org.springframework.http.HttpStatus 16 | import org.springframework.http.ResponseEntity 17 | import org.springframework.web.client.HttpStatusCodeException 18 | 19 | /** 20 | * Service implementation to convert a MJML template to HTML through the API. 21 | * To instantiate this service we need a MjmlAuth instance. 22 | * @see MjmlAuth 23 | * 24 | * @throws es.atrujillo.mjml.exception.MjmlApiErrorException 25 | * @author Arnaldo Trujillo 26 | */ 27 | class MjmlRestService(private val authConf: MjmlAuth) : MjmlService { 28 | 29 | override fun transpileMjmlToHtml(mjmlBody: String): String { 30 | 31 | val restClient = BasicAuthRestClient(authConf.getMjmlApiEndpoint().toString(), 32 | authConf.getMjmlApplicationId(), authConf.getMjmlApplicationSecretKey()) 33 | 34 | val request = MjmlRequest(mjmlBody) 35 | 36 | try { 37 | val responseEntity = restClient.post(request, TRANSPILE_RENDER_MJML_PATH, object : ParameterizedTypeReference() {}) 38 | if (responseEntity.statusCode.is2xxSuccessful) { 39 | val response: MjmlResponse? = responseEntity.let { response: ResponseEntity -> response.body } 40 | if (response != null) { 41 | validateMjmlVersion(mjmlBody, response) 42 | return response.html 43 | } 44 | throw MjmlApiErrorException(MjmlApiError(EMPTY_RESPONSE_ERROR_MESSAGE), HttpStatus.NOT_FOUND) 45 | } 46 | 47 | throw MjmlApiErrorException(MjmlApiError(responseEntity.statusCode.reasonPhrase), responseEntity.statusCode) 48 | 49 | } catch (httpError: HttpStatusCodeException) { 50 | logError(httpError.localizedMessage, httpError) 51 | val mjmlApiError = restClient.getObjectMapper().readValue(httpError.responseBodyAsString) 52 | throw MjmlApiErrorException(mjmlApiError, httpError.statusCode) 53 | } 54 | } 55 | 56 | private fun validateMjmlVersion(requestBody: String, response: MjmlResponse) { 57 | if (response.getMajorVersion() < 4.0 && !requestBody.contains(DEPRECATED_MJML_ELEMENT)) 58 | throw MjmlApiUnsupportedVersionException(response.mjmlVersion) 59 | 60 | if(response.errors.isNotEmpty()){ 61 | response.errors.forEach { 62 | logWarn(it.formattedMessage) 63 | } 64 | } 65 | } 66 | 67 | companion object { 68 | 69 | private const val TRANSPILE_RENDER_MJML_PATH = "/render" 70 | private const val DEPRECATED_MJML_ELEMENT = "mj-container" 71 | private const val EMPTY_RESPONSE_ERROR_MESSAGE = "Not response body found" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/test/java/es/atrujillo/mjml/service/auth/PropertiesMjmlAuthTest.java: -------------------------------------------------------------------------------- 1 | package es.atrujillo.mjml.service.auth; 2 | 3 | import org.junit.jupiter.api.Assertions; 4 | import org.junit.jupiter.api.BeforeEach; 5 | import org.junit.jupiter.api.DisplayName; 6 | import org.junit.jupiter.api.Test; 7 | import org.mockito.ArgumentMatchers; 8 | import org.mockito.Mockito; 9 | 10 | import java.net.URI; 11 | import java.util.*; 12 | 13 | class PropertiesMjmlAuthTest { 14 | 15 | private final static String DUMMY_URI_STRING = "http://localhost/mjml"; 16 | private MjmlAuth mjmlAuth; 17 | private String appIDPropKey; 18 | private String secretKeyPropKey; 19 | private String appIdVal; 20 | private String secretKeyVal; 21 | private Properties properties; 22 | 23 | @BeforeEach 24 | void initialize() { 25 | appIDPropKey = UUID.randomUUID().toString(); 26 | secretKeyPropKey = UUID.randomUUID().toString(); 27 | appIdVal = UUID.randomUUID().toString(); 28 | secretKeyVal = UUID.randomUUID().toString(); 29 | 30 | properties = Mockito.mock(Properties.class); 31 | Set propKeys = new HashSet<>(Arrays.asList(appIDPropKey, secretKeyPropKey)); 32 | 33 | Mockito.when(properties.stringPropertyNames()).thenReturn(propKeys); 34 | Mockito.when(properties.getProperty(ArgumentMatchers.eq(appIDPropKey))).thenReturn(appIdVal); 35 | Mockito.when(properties.getProperty(ArgumentMatchers.eq(secretKeyPropKey))).thenReturn(secretKeyVal); 36 | 37 | URI mockURI = Mockito.mock(URI.class); 38 | Mockito.when(mockURI.toString()).thenReturn(DUMMY_URI_STRING); 39 | 40 | mjmlAuth = MjmlAuthFactory.builder() 41 | .withPropertiesCredential() 42 | .properties(properties) 43 | .mjmlKeyNames(appIDPropKey, secretKeyPropKey) 44 | .changeEndpoint(mockURI) 45 | .build(); 46 | 47 | } 48 | 49 | @Test 50 | @DisplayName("Test Build Properties Auth") 51 | void testAuthInstance() { 52 | Assertions.assertNotNull(mjmlAuth); 53 | Assertions.assertNotNull(mjmlAuth.getMjmlApplicationId()); 54 | Assertions.assertNotNull(mjmlAuth.getMjmlApplicationSecretKey()); 55 | Assertions.assertNotNull(mjmlAuth.getMjmlApiEndpoint()); 56 | Assertions.assertEquals(appIdVal, mjmlAuth.getMjmlApplicationId()); 57 | Assertions.assertEquals(secretKeyVal, mjmlAuth.getMjmlApplicationSecretKey()); 58 | Assertions.assertEquals(DUMMY_URI_STRING, mjmlAuth.getMjmlApiEndpoint().toString()); 59 | } 60 | 61 | @Test 62 | void testAuthInstanceWithNullValues() { 63 | Assertions.assertThrows(IllegalArgumentException.class, () -> { 64 | MjmlAuthFactory.builder() 65 | .withPropertiesCredential() 66 | .properties(properties) 67 | .mjmlKeyNames(null, null) 68 | .build(); 69 | }); 70 | Assertions.assertThrows(IllegalArgumentException.class, () -> { 71 | MjmlAuthFactory.builder() 72 | .withPropertiesCredential() 73 | .properties(properties) 74 | .mjmlKeyNames(UUID.randomUUID().toString(), null) 75 | .build(); 76 | }); 77 | Assertions.assertThrows(IllegalArgumentException.class, () -> { 78 | MjmlAuthFactory.builder() 79 | .withPropertiesCredential() 80 | .properties(properties) 81 | .mjmlKeyNames(null, UUID.randomUUID().toString()) 82 | .build(); 83 | }); 84 | Assertions.assertThrows(IllegalArgumentException.class, () -> { 85 | MjmlAuthFactory.builder() 86 | .withPropertiesCredential() 87 | .properties(properties) 88 | .mjmlKeyNames(UUID.randomUUID().toString(), UUID.randomUUID().toString()) 89 | .changeEndpoint(null) 90 | .build(); 91 | }); 92 | Assertions.assertThrows(IllegalArgumentException.class, () -> { 93 | MjmlAuth testMjmlAuth = MjmlAuthFactory.builder() 94 | .withPropertiesCredential() 95 | .properties(properties) 96 | .mjmlKeyNames("invalid", secretKeyPropKey) 97 | .build(); 98 | testMjmlAuth.getMjmlApplicationId(); 99 | }); 100 | Assertions.assertThrows(IllegalArgumentException.class, () -> { 101 | MjmlAuth testMjmlAuth = MjmlAuthFactory.builder() 102 | .withPropertiesCredential() 103 | .properties(properties) 104 | .mjmlKeyNames(appIDPropKey,"invalid") 105 | .build(); 106 | testMjmlAuth.getMjmlApplicationSecretKey(); 107 | }); 108 | 109 | } 110 | 111 | } 112 | 113 | -------------------------------------------------------------------------------- /src/main/kotlin/es/atrujillo/mjml/config/template/TemplateFactory.kt: -------------------------------------------------------------------------------- 1 | package es.atrujillo.mjml.config.template 2 | 3 | import es.atrujillo.mjml.util.logDebug 4 | import org.thymeleaf.TemplateEngine 5 | import org.thymeleaf.context.Context 6 | import org.thymeleaf.context.IContext 7 | import org.thymeleaf.templatemode.TemplateMode 8 | import org.thymeleaf.templateresolver.FileTemplateResolver 9 | import org.thymeleaf.templateresolver.StringTemplateResolver 10 | import java.io.File 11 | 12 | /** 13 | * Factory class that allow renderize Thymleaf template dinamically. 14 | * The origin template can be a File or a String 15 | * 16 | * This creational class use the Step Pattern. 17 | * 18 | * 19 | * @author Arnaldo Trujillo 20 | */ 21 | class TemplateFactory private constructor(templateEngine: TemplateEngine) { 22 | 23 | /** 24 | * Initial build step 25 | */ 26 | interface ChooseTypeStep { 27 | 28 | /** 29 | * Choose File template mode. 30 | * @return TemplateFileStep 31 | */ 32 | fun withFileTemplate(): TemplateFileStep 33 | 34 | /** 35 | * Choose String template mode. 36 | * @return TemplateStringStep 37 | */ 38 | fun withStringTemplate(): TemplateStringStep 39 | } 40 | 41 | /** 42 | * Set File template step. 43 | */ 44 | interface TemplateFileStep { 45 | 46 | /** 47 | * Should be a valid existing File 48 | */ 49 | fun template(fileTemplate: File): BuildStep 50 | } 51 | 52 | /** 53 | * Set String template step 54 | */ 55 | interface TemplateStringStep { 56 | 57 | /** 58 | * Can't be empty nor null. 59 | */ 60 | fun template(stringTemplate: String): BuildStep 61 | 62 | } 63 | 64 | /** 65 | * Final build step 66 | */ 67 | interface BuildStep { 68 | 69 | /** 70 | * Return the final renderized template 71 | */ 72 | fun buildTemplate(): String 73 | 74 | /** 75 | * Set context variables to process in template 76 | */ 77 | fun templateContext(templateContext: IContext): BuildStep 78 | 79 | } 80 | 81 | companion object { 82 | 83 | /** 84 | * Builder static method 85 | */ 86 | @JvmStatic 87 | fun builder(): ChooseTypeStep = Builder() 88 | 89 | class Builder : ChooseTypeStep, TemplateStringStep, TemplateFileStep, BuildStep { 90 | 91 | private lateinit var templateType: TemplateType 92 | private var context: IContext = Context() 93 | private lateinit var fileTemplate: File 94 | private lateinit var stringTemplate: String 95 | 96 | override fun withFileTemplate(): TemplateFileStep { 97 | templateType = TemplateType.FILE 98 | return this 99 | } 100 | 101 | override fun withStringTemplate(): TemplateStringStep { 102 | templateType = TemplateType.STRING 103 | return this 104 | } 105 | 106 | override fun buildTemplate(): String { 107 | val engine = TemplateEngine() 108 | val template: String 109 | val resolver = when (templateType) { 110 | TemplateType.STRING -> { 111 | if (stringTemplate.isEmpty()) 112 | throw IllegalArgumentException("Enter a valid String template") 113 | 114 | template = stringTemplate 115 | val resolver = StringTemplateResolver() 116 | resolver.templateMode = TemplateMode.XML 117 | resolver 118 | } 119 | TemplateType.FILE -> { 120 | if (fileTemplate.exists() && fileTemplate.isFile) { 121 | val resolver = FileTemplateResolver() 122 | resolver.templateMode = TemplateMode.XML 123 | resolver.prefix = "${fileTemplate.parent}/" 124 | resolver.suffix = ".${fileTemplate.extension}" 125 | template = fileTemplate.nameWithoutExtension 126 | 127 | resolver 128 | } else { 129 | throw IllegalArgumentException("Enter a valid File template") 130 | } 131 | } 132 | } 133 | 134 | engine.setTemplateResolver(resolver) 135 | val finalTemplate = engine.process(template, context) 136 | 137 | logDebug("Final template: \n $finalTemplate") 138 | return finalTemplate 139 | } 140 | 141 | override fun templateContext(templateContext: IContext): BuildStep { 142 | this.context = templateContext 143 | return this 144 | } 145 | 146 | override fun template(fileTemplate: File): BuildStep { 147 | this.fileTemplate = fileTemplate 148 | return this 149 | } 150 | 151 | override fun template(stringTemplate: String): BuildStep { 152 | this.stringTemplate = stringTemplate 153 | return this 154 | } 155 | } 156 | } 157 | 158 | private enum class TemplateType { STRING, FILE } 159 | 160 | } -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS="" 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /src/test/java/es/atrujillo/mjml/integration/service/MjmlRestServiceKtTest.kt: -------------------------------------------------------------------------------- 1 | package es.atrujillo.mjml.integration.service 2 | 3 | import es.atrujillo.mjml.config.template.TemplateFactory 4 | import es.atrujillo.mjml.exception.MjmlApiErrorException 5 | import es.atrujillo.mjml.service.auth.MjmlAuthFactory 6 | import es.atrujillo.mjml.service.impl.MjmlRestService 7 | import es.atrujillo.mjml.util.TestUtils.* 8 | import org.junit.jupiter.api.Assertions.* 9 | import org.junit.jupiter.api.BeforeEach 10 | import org.junit.jupiter.api.DisplayName 11 | import org.junit.jupiter.api.Test 12 | import org.thymeleaf.context.Context 13 | import org.thymeleaf.exceptions.TemplateInputException 14 | import java.io.File 15 | import java.net.URL 16 | import java.util.* 17 | 18 | /** 19 | * @author Arnaldo Trujillo 20 | */ 21 | internal class MjmlRestServiceKtTest { 22 | 23 | private lateinit var template: String 24 | 25 | @BeforeEach 26 | private fun setUpTests() { 27 | template = TemplateFactory.builder() 28 | .withStringTemplate() 29 | .template(HELLO_WORLD_MJML) 30 | .buildTemplate() 31 | } 32 | 33 | 34 | /** 35 | * Test that valid mjml template is converted to html using MjmlService 36 | */ 37 | @Test 38 | @DisplayName("Integration API test") 39 | internal fun testThatMjmlApiRespondCorrectly() { 40 | 41 | assertNotNull(MJML_APP_ID, "You have to configure environment variable MJML_APP_ID") 42 | assertNotNull(MJML_SECRET_KEY, "You have to configure environment variable MJML_SECRET_KEY") 43 | 44 | val authConf = MjmlAuthFactory.builder() 45 | .withEnvironmentCredentials() 46 | .mjmlKeyNames(MJML_APP_ID, MJML_SECRET_KEY) 47 | .build() 48 | 49 | val mjmlService = MjmlRestService(authConf) 50 | val response = mjmlService.transpileMjmlToHtml(template) 51 | 52 | assertNotNull(response) 53 | assertFalse(response.isEmpty()) 54 | } 55 | 56 | /** 57 | * Test that valid mjml template is converted to html using MjmlService 58 | */ 59 | @Test 60 | @DisplayName("Readme Template API test") 61 | internal fun testThatReadmeTemplateExampleIsCorrect() { 62 | 63 | assertNotNull(MJML_APP_ID, "You have to configure environment variable MJML_APP_ID") 64 | assertNotNull(MJML_SECRET_KEY, "You have to configure environment variable MJML_SECRET_KEY") 65 | 66 | val readmeTemplate = File(Objects.requireNonNull(javaClass.classLoader.getResource("template/readme-template.mjml")).file) 67 | 68 | val title = "Dog Gallery" 69 | val description = "This is my dog Bilbo, modeling for the camera" 70 | 71 | val context = Context(Locale.getDefault()) 72 | context.setVariable("myTitle", title) 73 | context.setVariable("myDescription", description) 74 | 75 | template = TemplateFactory.builder() 76 | .withFileTemplate() 77 | .template(readmeTemplate) 78 | .templateContext(context) 79 | .buildTemplate() 80 | 81 | val authConf = MjmlAuthFactory.builder() 82 | .withEnvironmentCredentials() 83 | .mjmlKeyNames(MJML_APP_ID, MJML_SECRET_KEY) 84 | .build() 85 | 86 | val mjmlService = MjmlRestService(authConf) 87 | val response = mjmlService.transpileMjmlToHtml(template) 88 | 89 | assertNotNull(response) 90 | assertFalse(response.isEmpty()) 91 | assertTrue(response.contains(title)) 92 | assertTrue(response.contains(description)) 93 | } 94 | 95 | /** 96 | * Test that valid mjml template is converted to html using MjmlService 97 | */ 98 | @Test 99 | @DisplayName("Test Error Handling with Invalid Credentials") 100 | internal fun testThatApiReturnErrorWithoutValidCredentials() { 101 | 102 | val invalidAppId = UUID.randomUUID().toString() 103 | val invalidSecretKey = UUID.randomUUID().toString() 104 | 105 | val authConf = MjmlAuthFactory.builder() 106 | .withMemoryCredentials() 107 | .mjmlCredentials(invalidAppId, invalidSecretKey) 108 | .build() 109 | 110 | val mjmlService = MjmlRestService(authConf) 111 | 112 | assertThrows(MjmlApiErrorException::class.java) { mjmlService.transpileMjmlToHtml(template) } 113 | 114 | } 115 | 116 | /** 117 | * Test that a malformed template return a Thymeleaf error 118 | */ 119 | @Test 120 | @DisplayName("Test Thymeleaf Error When Malformed Template") 121 | internal fun testThatTemplateBuildingReturnErrorWithMalformedTemplate() { 122 | 123 | assertThrows(TemplateInputException::class.java) { 124 | TemplateFactory.builder() 125 | .withStringTemplate() 126 | .template(MALFORMED_TEMPLATE) 127 | .buildTemplate() 128 | } 129 | } 130 | 131 | /** 132 | * Test that unsupported version mjml template return error 133 | */ 134 | @Test 135 | @DisplayName("Test Error Handling with Invalid Supported Version") 136 | internal fun testThatVersion4TemplateReturnOk() { 137 | 138 | assertNotNull(MJML_APP_ID, "You have to configure environment variable MJML_APP_ID") 139 | assertNotNull(MJML_SECRET_KEY, "You have to configure environment variable MJML_SECRET_KEY") 140 | 141 | val unsupportedVersionTemplate = TemplateFactory.builder() 142 | .withStringTemplate() 143 | .template(MJML_VERSION4_TEMPLATE) 144 | .buildTemplate() 145 | 146 | val authConf = MjmlAuthFactory.builder() 147 | .withEnvironmentCredentials() 148 | .mjmlKeyNames(MJML_APP_ID, MJML_SECRET_KEY) 149 | .build() 150 | 151 | val mjmlService = MjmlRestService(authConf) 152 | 153 | val response = mjmlService.transpileMjmlToHtml(unsupportedVersionTemplate) 154 | 155 | assertNotNull(response) 156 | assertFalse(response.isEmpty()) 157 | } 158 | 159 | } 160 | -------------------------------------------------------------------------------- /src/main/kotlin/es/atrujillo/mjml/service/auth/MjmlAuthFactory.kt: -------------------------------------------------------------------------------- 1 | package es.atrujillo.mjml.service.auth 2 | 3 | import java.net.URI 4 | import java.util.* 5 | 6 | /** 7 | * Factory class that returns a instance of MjmlAuth. 8 | * This factory implements the Step Pattern that allows us to create 9 | * all types subclasses of MjmlAuth in an orderly and clear manner. 10 | * 11 | * MjmlAuth types can be: 12 | * @see SystemEnvironmentMjmlAuth 13 | * @see PropertiesMjmlAuth 14 | * @see MemoryMjmlAuth 15 | * 16 | * @example 17 | * 18 | * MjmlAuth propertyAuthConf = MjmlAuthFactory.builder() 19 | * .withPropertiesCredential() 20 | * .properties(propertiesFile) 21 | * .mjmlKeyNames(appPropKey, secretPropKey) 22 | * .build(); 23 | * 24 | * @return MjmlAuth 25 | * @author Arnaldo Trujillo 26 | */ 27 | class MjmlAuthFactory private constructor() { 28 | 29 | /** 30 | * Initial build step 31 | */ 32 | interface ChooseTypeStep { 33 | 34 | /** 35 | * First step to configure a {@link SystemEnvironmentMjmlAuth} 36 | * @return Next environment auth step 37 | */ 38 | fun withEnvironmentCredentials(): EnvAuthStep 39 | 40 | /** 41 | * First step to configure a {@link MemoryMjmlAuth} 42 | * @return Next in memory auth step 43 | */ 44 | fun withMemoryCredentials(): MemoryAuthStep 45 | 46 | /** 47 | * First step to configure a {@link PropertiesMjmlAuth} 48 | * @return Next file properties auth step 49 | */ 50 | fun withPropertiesCredential(): PropertiesAuthStep 51 | } 52 | 53 | /** 54 | * Step where we have to set mjml credentials directly in memory 55 | */ 56 | interface MemoryAuthStep { 57 | 58 | /** 59 | * Set mjml credentials in memory 60 | * @param mjmlAppId Mjml Api applicationId 61 | * @param mjmlSecretKey Mjml Api secret key 62 | */ 63 | fun mjmlCredentials(mjmlAppId: String, mjmlSecretKey: String): BuildStep 64 | } 65 | 66 | /** 67 | * Step where we have to set the properties files where 68 | * framework going to find mjml api credentials 69 | * 70 | * @see Properties 71 | */ 72 | interface PropertiesAuthStep { 73 | 74 | /** 75 | * Set properties file where search mjml credentials * 76 | */ 77 | fun properties(properties: Properties): EnvAuthStep 78 | } 79 | 80 | /** 81 | * Step where we have to set the key names to obtains credentials from System environments. 82 | * Apply too to properties file. 83 | */ 84 | interface EnvAuthStep { 85 | /** 86 | * Set credentials key names 87 | */ 88 | fun mjmlKeyNames(mjmlAppIdKeyName: String, mjmlSecretKeyName: String): BuildStep 89 | } 90 | 91 | /** 92 | * Final step to get the {@link MjmlAuth} instance 93 | */ 94 | interface BuildStep { 95 | /** 96 | * Return the MjmlAuth instance when configuration is finished 97 | */ 98 | fun build(): MjmlAuth 99 | 100 | /** 101 | * Change the default MJML Api endpoint 102 | */ 103 | fun changeEndpoint(endpoint: URI): BuildStep 104 | } 105 | 106 | companion object { 107 | 108 | private enum class AuthType { MEMORY, PROPERTIES, ENV } 109 | 110 | /** 111 | * Init method to create the MjmlAuth instance 112 | * @return Builder 113 | */ 114 | @JvmStatic 115 | fun builder(): ChooseTypeStep = Builder() 116 | 117 | class Builder : ChooseTypeStep, MemoryAuthStep, PropertiesAuthStep, EnvAuthStep, BuildStep { 118 | 119 | private lateinit var authType: AuthType 120 | private lateinit var mjmlAppId: String 121 | private lateinit var mjmlSecretKey: String 122 | private lateinit var appIdKeyName: String 123 | private lateinit var secretKeyName: String 124 | private lateinit var properties: Properties 125 | private var endpoint: URI = URI.create("https://api.mjml.io/v1") 126 | 127 | override fun withEnvironmentCredentials(): EnvAuthStep { 128 | authType = AuthType.ENV 129 | return this 130 | } 131 | 132 | override fun withMemoryCredentials(): MemoryAuthStep { 133 | authType = AuthType.MEMORY 134 | return this 135 | } 136 | 137 | override fun withPropertiesCredential(): PropertiesAuthStep { 138 | authType = AuthType.PROPERTIES 139 | return this 140 | } 141 | 142 | override fun mjmlCredentials(mjmlAppId: String, mjmlSecretKey: String): BuildStep { 143 | this.mjmlAppId = mjmlAppId 144 | this.mjmlSecretKey = mjmlSecretKey 145 | return this 146 | } 147 | 148 | override fun properties(properties: Properties): EnvAuthStep { 149 | this.properties = properties 150 | return this 151 | } 152 | 153 | override fun mjmlKeyNames(mjmlAppIdKeyName: String, mjmlSecretKeyName: String): BuildStep { 154 | this.appIdKeyName = mjmlAppIdKeyName 155 | this.secretKeyName = mjmlSecretKeyName 156 | return this 157 | } 158 | 159 | override fun build(): MjmlAuth { 160 | return when (authType) { 161 | AuthType.MEMORY -> MemoryMjmlAuth(mjmlAppId, mjmlSecretKey, endpoint) 162 | AuthType.PROPERTIES -> PropertiesMjmlAuth(properties, appIdKeyName, secretKeyName, endpoint) 163 | AuthType.ENV -> SystemEnvironmentMjmlAuth(appIdKeyName, secretKeyName, endpoint) 164 | } 165 | } 166 | 167 | override fun changeEndpoint(endpoint: URI): BuildStep { 168 | this.endpoint = endpoint 169 | return this 170 | } 171 | 172 | } 173 | } 174 | 175 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mjml Rest Client 2 | 3 | [![ko-fi](https://www.ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/W7W1UXQE) 4 | 5 | [![CircleCI](https://circleci.com/gh/atrujillofalcon/mjml-rest-client.svg?style=svg)](https://circleci.com/gh/atrujillofalcon/mjml-rest-client) 6 | [![Codacy Badge](https://api.codacy.com/project/badge/Grade/38e786b08ee544ec81e4dffc1fc3e5dd)](https://app.codacy.com/app/atrujillo92work/mjml-rest-client?utm_source=github.com&utm_medium=referral&utm_content=atrujillofalcon/mjml-rest-client&utm_campaign=badger) 7 | [![codecov](https://codecov.io/gh/atrujillofalcon/mjml-rest-client/branch/develop/graph/badge.svg)](https://codecov.io/gh/atrujillofalcon/mjml-rest-client) 8 | 9 | 10 | 11 | [Mjml](https://mjml.io/) is the best responsive mail framework, I love it :heart:. I created this project to have a Java library that use the 12 | **mjml API** to convert a mjml template into valid html ready to use. 13 | 14 | 15 | ## Built With 16 | 17 | * [Kotlin](https://kotlinlang.org/) - The future of JVM languages 18 | * [Gradle](https://gradle.org/) - Dependency Management 19 | * [Spring Rest Template](https://docs.spring.io/spring/docs/current/javadoc-api/org/springframework/web/client/RestTemplate.html) - Used to communicate with API 20 | * [Jackson](https://github.com/FasterXML/jackson-databind) - Best databind Java library 21 | * [Thymeleaf](https://www.thymeleaf.org/) - Template Engine 22 | 23 | 24 | ### Installing 25 | 26 | To include this library into your project your only need to add the dependency. 27 | 28 | **Maven**: 29 | ```xml 30 | 31 | es.atrujillo.mjml 32 | mjml-rest-client 33 | 2.0.1 34 | 35 | ``` 36 | 37 | **Gradle**: 38 | ```groovy 39 | compile "es.atrujillo.mjml:mjml-rest-client:2.0.1" 40 | ``` 41 | 42 | ## Usage 43 | 44 | 45 | ### Creating templates 46 | 47 | This library includes Thymleaf engine to allow to create dynamic templates before to send to Mjml API. 48 | 49 | We have two options for templating mjml mails. In-memory String or File. 50 | 51 | #### File templates 52 | 53 | Now we're going to see how create the template from a file source to create a fun mail. Let's imagine that we have a Thymeleaf template in a file called readme-template.mjml with the following content: 54 | 55 | ```xml 56 | 57 | 58 | 59 | 60 | 61 |

62 |

63 | 64 | 65 | 66 | 67 | 68 |
69 |
70 |
71 |
72 |
73 | ``` 74 | 75 | If we look, we have two variables: **myTitle** and **myDescription** that we're going to replace dynamically. Let's see how use the File Template mode: 76 | 77 | ```java 78 | File fileTemplate = new File("/path/to/mjml/readme-template.mjml"); 79 | Context contextVars = new Context(); 80 | contextVars.setVariable("myTitle","Dog Gallery"); 81 | contextVars.setVariable("message","This is my dog Bilbo, modeling for the camera"); 82 | 83 | String mjmlTemplate = TemplateFactory.builder() 84 | .withFileTemplate() 85 | .template(fileTemplate) 86 | .templateContext(contextVars) 87 | .buildTemplate(); 88 | ``` 89 | 90 | **Final Result of Template** 91 | 92 | ![Mjml Screenshoot](https://unblogdecode.es/gallery/mjm-screenshoot.png) 93 | 94 | #### In-Memory String templates 95 | 96 | ```java 97 | private static final String DUMMY_TEMPLATE = ""; 98 | ... 99 | ... 100 | ... 101 | Context contextVars = new Context(); 102 | contextVars.setVariable("message","Hello MJML"); 103 | 104 | String mjmlTemplate = TemplateFactory.builder() 105 | .withStringTemplate() 106 | .template(DUMMY_TEMPLATE) 107 | .templateContext(contextVars) 108 | .buildTemplate(); 109 | ``` 110 | 111 | ### API Credentials 112 | 113 | We already have the template, but before to call to API we need the API credentials to initialize our service client. 114 | 115 | You can obtain the credentials [**here**](https://mjml.io/api). 116 | 117 | Before calling our service we have to create an instance of MjmlAuth through the MjmlAuthFactory class. 118 | We have three options: 119 | 120 | ```java 121 | //Get credentials from environments variables 122 | MjmlAuth systemEnvAuthConf = MjmlAuthFactory.builder() 123 | .withEnvironmentCredentials() 124 | .mjmlKeyNames(MJML_APP_ID, MJML_SECRET_KEY) 125 | .build(); 126 | 127 | //Enter in-memory string credentials directly into auth factory 128 | MjmlAuth memoryAuthConf = MjmlAuthFactory.builder() 129 | .withMemoryCredentials() 130 | .mjmlCredentials(mjmlAppId, mjmlSecretKey) 131 | .build(); 132 | 133 | //Get credentials from properties file 134 | MjmlAuth propertyAuthConf = MjmlAuthFactory.builder() 135 | .withPropertiesCredential() 136 | .properties(propertiesFile) 137 | .mjmlKeyNames(appPropKey, secretPropKey) 138 | .build(); 139 | ``` 140 | 141 | ### Obtaining final HTML 142 | 143 | Finally, we just need to instantiate our client with the credentials obtained 144 | and use it to convert the template into the final HTML to send it to whoever we want. 145 | 146 | ```java 147 | MjmlService mjmlService = new MjmlRestService(authConfInstance); 148 | 149 | String resultHtmlMail = mjmlService.transpileMjmlToHtml(mjmlTemplate); 150 | //after obtain the html you can send it using your email service implementation. 151 | ``` 152 | 153 | 154 | ## Running the tests 155 | 156 | First you have to set **MJML_APP_ID** and **MJML_SECRET_KEY** environment variables. 157 | 158 | Execute from root folder: 159 | 160 | ```groovy 161 | gradle test 162 | ``` 163 | 164 | ## Author 165 | 166 | [**Arnaldo Trujillo**](https://github.com/atrujillofalcon) 167 | 168 | See also the list of [contributors](https://github.com/atrujillofalcon/mjml-rest-client/graphs/contributors) who participated in this project. 169 | 170 | ## License 171 | 172 | This project is licensed under the Apache License - see the [LICENSE.md](LICENSE.md) file for details 173 | 174 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2017 Arnaldo Trujillo Falcón 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express 12 | or implied. See the License for the specific language governing 13 | permissions and limitations under the License. 14 | 15 | 16 | Apache License 17 | Version 2.0, January 2004 18 | http://www.apache.org/licenses/ 19 | 20 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 21 | 22 | 1. Definitions. 23 | 24 | "License" shall mean the terms and conditions for use, reproduction, and 25 | distribution as defined by Sections 1 through 9 of this document. 26 | 27 | "Licensor" shall mean the copyright owner or entity authorized by the 28 | copyright owner that is granting the License. 29 | 30 | "Legal Entity" shall mean the union of the acting entity and all other 31 | entities that control, are controlled by, or are under common control with 32 | that entity. For the purposes of this definition, "control" means (i) the 33 | power, direct or indirect, to cause the direction or management of such 34 | entity, whether by contract or otherwise, or (ii) ownership of 35 | fifty percent (50%) or more of the outstanding shares, or (iii) beneficial 36 | ownership of such entity. 37 | 38 | "You" (or "Your") shall mean an individual or Legal Entity exercising 39 | permissions granted by this License. 40 | 41 | "Source" form shall mean the preferred form for making modifications, 42 | including but not limited to software source code, documentation source, 43 | and configuration files. 44 | 45 | "Object" form shall mean any form resulting from mechanical transformation 46 | or translation of a Source form, including but not limited to compiled 47 | object code, generated documentation, and conversions to 48 | other media types. 49 | 50 | "Work" shall mean the work of authorship, whether in Source or Object 51 | form, made available under the License, as indicated by a copyright notice 52 | that is included in or attached to the work (an example is provided in the 53 | Appendix below). 54 | 55 | "Derivative Works" shall mean any work, whether in Source or Object form, 56 | that is based on (or derived from) the Work and for which the editorial 57 | revisions, annotations, elaborations, or other modifications represent, 58 | as a whole, an original work of authorship. For the purposes of this 59 | License, Derivative Works shall not include works that remain separable 60 | from, or merely link (or bind by name) to the interfaces of, the Work and 61 | Derivative Works thereof. 62 | 63 | "Contribution" shall mean any work of authorship, including the original 64 | version of the Work and any modifications or additions to that Work or 65 | Derivative Works thereof, that is intentionally submitted to Licensor for 66 | inclusion in the Work by the copyright owner or by an individual or 67 | Legal Entity authorized to submit on behalf of the copyright owner. 68 | For the purposes of this definition, "submitted" means any form of 69 | electronic, verbal, or written communication sent to the Licensor or its 70 | representatives, including but not limited to communication on electronic 71 | mailing lists, source code control systems, and issue tracking systems 72 | that are managed by, or on behalf of, the Licensor for the purpose of 73 | discussing and improving the Work, but excluding communication that is 74 | conspicuously marked or otherwise designated in writing by the copyright 75 | owner as "Not a Contribution." 76 | 77 | "Contributor" shall mean Licensor and any individual or Legal Entity on 78 | behalf of whom a Contribution has been received by Licensor and 79 | subsequently incorporated within the Work. 80 | 81 | 2. Grant of Copyright License. 82 | 83 | Subject to the terms and conditions of this License, each Contributor 84 | hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, 85 | royalty-free, irrevocable copyright license to reproduce, prepare 86 | Derivative Works of, publicly display, publicly perform, sublicense, 87 | and distribute the Work and such Derivative Works in 88 | Source or Object form. 89 | 90 | 3. Grant of Patent License. 91 | 92 | Subject to the terms and conditions of this License, each Contributor 93 | hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, 94 | royalty-free, irrevocable (except as stated in this section) patent 95 | license to make, have made, use, offer to sell, sell, import, and 96 | otherwise transfer the Work, where such license applies only to those 97 | patent claims licensable by such Contributor that are necessarily 98 | infringed by their Contribution(s) alone or by combination of their 99 | Contribution(s) with the Work to which such Contribution(s) was submitted. 100 | If You institute patent litigation against any entity (including a 101 | cross-claim or counterclaim in a lawsuit) alleging that the Work or a 102 | Contribution incorporated within the Work constitutes direct or 103 | contributory patent infringement, then any patent licenses granted to 104 | You under this License for that Work shall terminate as of the date such 105 | litigation is filed. 106 | 107 | 4. Redistribution. 108 | 109 | You may reproduce and distribute copies of the Work or Derivative Works 110 | thereof in any medium, with or without modifications, and in Source or 111 | Object form, provided that You meet the following conditions: 112 | 113 | 1. You must give any other recipients of the Work or Derivative Works a 114 | copy of this License; and 115 | 116 | 2. You must cause any modified files to carry prominent notices stating 117 | that You changed the files; and 118 | 119 | 3. You must retain, in the Source form of any Derivative Works that You 120 | distribute, all copyright, patent, trademark, and attribution notices from 121 | the Source form of the Work, excluding those notices that do not pertain 122 | to any part of the Derivative Works; and 123 | 124 | 4. If the Work includes a "NOTICE" text file as part of its distribution, 125 | then any Derivative Works that You distribute must include a readable copy 126 | of the attribution notices contained within such NOTICE file, excluding 127 | those notices that do not pertain to any part of the Derivative Works, 128 | in at least one of the following places: within a NOTICE text file 129 | distributed as part of the Derivative Works; within the Source form or 130 | documentation, if provided along with the Derivative Works; or, within a 131 | display generated by the Derivative Works, if and wherever such 132 | third-party notices normally appear. The contents of the NOTICE file are 133 | for informational purposes only and do not modify the License. 134 | You may add Your own attribution notices within Derivative Works that You 135 | distribute, alongside or as an addendum to the NOTICE text from the Work, 136 | provided that such additional attribution notices cannot be construed 137 | as modifying the License. 138 | 139 | You may add Your own copyright statement to Your modifications and may 140 | provide additional or different license terms and conditions for use, 141 | reproduction, or distribution of Your modifications, or for any such 142 | Derivative Works as a whole, provided Your use, reproduction, and 143 | distribution of the Work otherwise complies with the conditions 144 | stated in this License. 145 | 146 | 5. Submission of Contributions. 147 | 148 | Unless You explicitly state otherwise, any Contribution intentionally 149 | submitted for inclusion in the Work by You to the Licensor shall be under 150 | the terms and conditions of this License, without any additional 151 | terms or conditions. Notwithstanding the above, nothing herein shall 152 | supersede or modify the terms of any separate license agreement you may 153 | have executed with Licensor regarding such Contributions. 154 | 155 | 6. Trademarks. 156 | 157 | This License does not grant permission to use the trade names, trademarks, 158 | service marks, or product names of the Licensor, except as required for 159 | reasonable and customary use in describing the origin of the Work and 160 | reproducing the content of the NOTICE file. 161 | 162 | 7. Disclaimer of Warranty. 163 | 164 | Unless required by applicable law or agreed to in writing, Licensor 165 | provides the Work (and each Contributor provides its Contributions) 166 | on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, 167 | either express or implied, including, without limitation, any warranties 168 | or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS 169 | FOR A PARTICULAR PURPOSE. You are solely responsible for determining the 170 | appropriateness of using or redistributing the Work and assume any risks 171 | associated with Your exercise of permissions under this License. 172 | 173 | 8. Limitation of Liability. 174 | 175 | In no event and under no legal theory, whether in tort 176 | (including negligence), contract, or otherwise, unless required by 177 | applicable law (such as deliberate and grossly negligent acts) or agreed 178 | to in writing, shall any Contributor be liable to You for damages, 179 | including any direct, indirect, special, incidental, or consequential 180 | damages of any character arising as a result of this License or out of 181 | the use or inability to use the Work (including but not limited to damages 182 | for loss of goodwill, work stoppage, computer failure or malfunction, 183 | or any and all other commercial damages or losses), even if such 184 | Contributor has been advised of the possibility of such damages. 185 | 186 | 9. Accepting Warranty or Additional Liability. 187 | 188 | While redistributing the Work or Derivative Works thereof, You may choose 189 | to offer, and charge a fee for, acceptance of support, warranty, 190 | indemnity, or other liability obligations and/or rights consistent with 191 | this License. However, in accepting such obligations, You may act only 192 | on Your own behalf and on Your sole responsibility, not on behalf of any 193 | other Contributor, and only if You agree to indemnify, defend, and hold 194 | each Contributor harmless for any liability incurred by, or claims 195 | asserted against, such Contributor by reason of your accepting any such 196 | warranty or additional liability. 197 | 198 | END OF TERMS AND CONDITIONS 199 | 200 | --------------------------------------------------------------------------------