├── 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 = "Return Errors";
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 | [](https://ko-fi.com/W7W1UXQE)
4 |
5 | [](https://circleci.com/gh/atrujillofalcon/mjml-rest-client)
6 | [](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 | [](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 | 
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 |
--------------------------------------------------------------------------------