From e3020fa2f74ebb2e5ac7252121f08bcda67abc93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Thu, 29 Oct 2026 16:08:35 +0100 Subject: [PATCH 01/35] test: add a recording HTTP client for tests Add a fake HttpClient that keeps each request with its raw body and returns queued responses. New tests use it, so they send no request to the network and need no recorded tapes. Co-Authored-By: Claude Opus 5.5 --- .../com/mailerlite/sdk/support/Fixtures.java | 47 ++ .../sdk/support/RecordingHttpClient.java | 585 ++++++++++++++++++ .../sdk/tests/RecordingHttpClientTest.java | 113 ++++ 3 files changed, 745 insertions(+) create mode 100644 src/test/java/com/mailerlite/sdk/support/Fixtures.java create mode 100644 src/test/java/com/mailerlite/sdk/support/RecordingHttpClient.java create mode 100644 src/test/java/com/mailerlite/sdk/tests/RecordingHttpClientTest.java diff --git a/src/test/java/com/mailerlite/sdk/support/Fixtures.java b/src/test/java/com/mailerlite/sdk/support/Fixtures.java new file mode 100644 index 0000000..721a741 --- /dev/null +++ b/src/test/java/com/mailerlite/sdk/support/Fixtures.java @@ -0,0 +1,47 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.support; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; + +/** + * Reads the JSON response fixtures in src/test/resources/responses. + */ +public final class Fixtures { + + /** + * Do not make instances of this class. + */ + private Fixtures() + { + } + + /** + * Reads a fixture. + * + * @param name the file name, for example "email.json" + * @return the file contents + */ + public static String read(String name) + { + try (InputStream stream = Fixtures.class.getResourceAsStream("/responses/" + name)) { + + if (stream == null) { + throw new IllegalArgumentException("No fixture with the name " + name); + } + + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + + throw new UncheckedIOException(e); + } + } +} diff --git a/src/test/java/com/mailerlite/sdk/support/RecordingHttpClient.java b/src/test/java/com/mailerlite/sdk/support/RecordingHttpClient.java new file mode 100644 index 0000000..1dd393a --- /dev/null +++ b/src/test/java/com/mailerlite/sdk/support/RecordingHttpClient.java @@ -0,0 +1,585 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.support; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.Authenticator; +import java.net.CookieHandler; +import java.net.ProxySelector; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpResponse.BodyHandler; +import java.net.http.HttpResponse.BodySubscriber; +import java.net.http.HttpResponse.PushPromiseHandler; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Deque; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.Flow; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLSession; + +import com.mailerlite.sdk.util.MailerLiteHttpClientFactory; + +/** + * A fake HttpClient for tests. It keeps each request that it gets, and it returns the responses + * from a queue. It sends no request to the network. + */ +public class RecordingHttpClient extends HttpClient { + + /** The requests that the client got, in sequence. */ + private final List requests = new ArrayList(); + + /** The responses and the errors to return, in sequence. */ + private final Deque responses = new ArrayDeque(); + + /** + * Makes a new client and sets it in the MailerLiteHttpClientFactory. + * + * @return the new client + */ + public static RecordingHttpClient install() + { + RecordingHttpClient client = new RecordingHttpClient(); + MailerLiteHttpClientFactory.getInstance().setClient(client); + + return client; + } + + /** + * Removes the client from the MailerLiteHttpClientFactory. + */ + public static void uninstall() + { + MailerLiteHttpClientFactory.getInstance().setClient(null); + } + + /** + * Adds a response to the queue. + * + * @param status the status code + * @param body the response body + * @return this client + */ + public RecordingHttpClient respond(int status, String body) + { + return respond(status, body, Collections.emptyMap()); + } + + /** + * Adds a response with headers to the queue. + * + * @param status the status code + * @param body the response body + * @param headers the response headers + * @return this client + */ + public RecordingHttpClient respond(int status, String body, Map headers) + { + Map> headerMap = new LinkedHashMap>(); + + for (Map.Entry header : headers.entrySet()) { + headerMap.put(header.getKey(), Collections.singletonList(header.getValue())); + } + + responses.add(new QueuedResponse(status, body, HttpHeaders.of(headerMap, (name, value) -> true))); + + return this; + } + + /** + * Adds an error to the queue. The client throws it for the next request. + * + * @param error an IOException or an InterruptedException + * @return this client + */ + public RecordingHttpClient fail(Exception error) + { + responses.add(error); + + return this; + } + + /** + * Gets the requests that the client got. + * + * @return the requests + */ + public List requests() + { + return requests; + } + + /** + * Gets the last request that the client got. + * + * @return the last request + */ + public RecordedRequest lastRequest() + { + if (requests.isEmpty()) { + throw new AssertionError("The client got no request"); + } + + return requests.get(requests.size() - 1); + } + + /** + * Gets the number of queued responses that no request used. + * + * @return the number of queued responses + */ + public int pendingResponses() + { + return responses.size(); + } + + /** + * Keeps the request and returns the next queued response. + * + * @param the body type + * @param request the request + * @param responseBodyHandler the body handler + * @return the response + * @throws IOException if the queue has an IOException, or if the queue is empty + * @throws InterruptedException if the queue has an InterruptedException + */ + @Override + public HttpResponse send(HttpRequest request, BodyHandler responseBodyHandler) + throws IOException, InterruptedException + { + requests.add(new RecordedRequest(request)); + + Object next = responses.poll(); + + if (next == null) { + throw new IOException("RecordingHttpClient has no queued response"); + } + + if (next instanceof IOException) { + throw (IOException) next; + } + + if (next instanceof InterruptedException) { + throw (InterruptedException) next; + } + + QueuedResponse queued = (QueuedResponse) next; + + return new FakeResponse(request, queued, readBody(queued, responseBodyHandler)); + } + + /** + * Converts the queued body with the body handler of the caller. + * + * @param the body type + * @param queued the queued response + * @param handler the body handler + * @return the body + */ + private T readBody(QueuedResponse queued, BodyHandler handler) + { + BodySubscriber subscriber = handler.apply(new HttpResponse.ResponseInfo() { + + @Override + public int statusCode() + { + return queued.status; + } + + @Override + public HttpHeaders headers() + { + return queued.headers; + } + + @Override + public Version version() + { + return Version.HTTP_1_1; + } + }); + + subscriber.onSubscribe(new Flow.Subscription() { + + @Override + public void request(long n) + { + // the body is given at once below + } + + @Override + public void cancel() + { + // nothing to cancel + } + }); + + subscriber.onNext(Collections.singletonList(ByteBuffer.wrap(queued.body.getBytes(StandardCharsets.UTF_8)))); + subscriber.onComplete(); + + return subscriber.getBody().toCompletableFuture().join(); + } + + /** + * Not supported. + * + * @param the body type + * @param request the request + * @param responseBodyHandler the body handler + * @return nothing + */ + @Override + public CompletableFuture> sendAsync(HttpRequest request, BodyHandler responseBodyHandler) + { + throw new UnsupportedOperationException("RecordingHttpClient supports only send()"); + } + + /** + * Not supported. + * + * @param the body type + * @param request the request + * @param responseBodyHandler the body handler + * @param pushPromiseHandler the push promise handler + * @return nothing + */ + @Override + public CompletableFuture> sendAsync(HttpRequest request, BodyHandler responseBodyHandler, + PushPromiseHandler pushPromiseHandler) + { + throw new UnsupportedOperationException("RecordingHttpClient supports only send()"); + } + + /** + * Cookie handler. + * + * @return an empty value + */ + @Override + public Optional cookieHandler() + { + return Optional.empty(); + } + + /** + * Connect timeout. + * + * @return an empty value + */ + @Override + public Optional connectTimeout() + { + return Optional.empty(); + } + + /** + * Follow redirects. + * + * @return NEVER + */ + @Override + public Redirect followRedirects() + { + return Redirect.NEVER; + } + + /** + * Proxy. + * + * @return an empty value + */ + @Override + public Optional proxy() + { + return Optional.empty(); + } + + /** + * Ssl context. + * + * @return null + */ + @Override + public SSLContext sslContext() + { + return null; + } + + /** + * Ssl parameters. + * + * @return null + */ + @Override + public SSLParameters sslParameters() + { + return null; + } + + /** + * Authenticator. + * + * @return an empty value + */ + @Override + public Optional authenticator() + { + return Optional.empty(); + } + + /** + * Version. + * + * @return HTTP_1_1 + */ + @Override + public Version version() + { + return Version.HTTP_1_1; + } + + /** + * Executor. + * + * @return an empty value + */ + @Override + public Optional executor() + { + return Optional.empty(); + } + + /** + * A request that the client got, with the raw bytes of its body. + */ + public static class RecordedRequest { + + /** The method. */ + public final String method; + + /** The full URI. */ + public final URI uri; + + /** The headers. */ + public final HttpHeaders headers; + + /** True if the request has a body publisher. GET() and DELETE() give no publisher. */ + public final boolean hasBodyPublisher; + + /** The content length of the body publisher, or -2 if there is no publisher. */ + public final long contentLength; + + /** The raw bytes of the body. The array is empty if there is no body. */ + public final byte[] body; + + /** + * Reads the request and its body. + * + * @param request the request + */ + RecordedRequest(HttpRequest request) + { + this.method = request.method(); + this.uri = request.uri(); + this.headers = request.headers(); + this.hasBodyPublisher = request.bodyPublisher().isPresent(); + this.contentLength = request.bodyPublisher().map(p -> p.contentLength()).orElse(-2L); + this.body = request.bodyPublisher().map(RecordedRequest::readAll).orElse(new byte[0]); + } + + /** + * Gets the body as a UTF-8 string. + * + * @return the body + */ + public String bodyString() + { + return new String(body, StandardCharsets.UTF_8); + } + + /** + * Gets the first value of a header, or null. + * + * @param name the header name + * @return the value or null + */ + public String header(String name) + { + return headers.firstValue(name).orElse(null); + } + + /** + * Reads all the bytes of a body publisher. + * + * @param publisher the body publisher + * @return the bytes + */ + private static byte[] readAll(HttpRequest.BodyPublisher publisher) + { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + CompletableFuture done = new CompletableFuture(); + + publisher.subscribe(new Flow.Subscriber() { + + @Override + public void onSubscribe(Flow.Subscription subscription) + { + subscription.request(Long.MAX_VALUE); + } + + @Override + public void onNext(ByteBuffer item) + { + byte[] chunk = new byte[item.remaining()]; + item.get(chunk); + bytes.write(chunk, 0, chunk.length); + } + + @Override + public void onError(Throwable throwable) + { + done.completeExceptionally(throwable); + } + + @Override + public void onComplete() + { + done.complete(null); + } + }); + + done.join(); + + return bytes.toByteArray(); + } + } + + /** + * A queued response. + */ + private static class QueuedResponse { + + /** The status. */ + final int status; + + /** The body. */ + final String body; + + /** The headers. */ + final HttpHeaders headers; + + /** + * Instantiates a new queued response. + * + * @param status the status + * @param body the body + * @param headers the headers + */ + QueuedResponse(int status, String body, HttpHeaders headers) + { + this.status = status; + this.body = body; + this.headers = headers; + } + } + + /** + * The response that the client returns. + * + * @param the body type + */ + private static class FakeResponse implements HttpResponse { + + /** The request. */ + private final HttpRequest request; + + /** The queued response. */ + private final QueuedResponse queued; + + /** The body. */ + private final T body; + + /** + * Instantiates a new fake response. + * + * @param request the request + * @param queued the queued response + * @param body the body + */ + FakeResponse(HttpRequest request, QueuedResponse queued, T body) + { + this.request = request; + this.queued = queued; + this.body = body; + } + + @Override + public int statusCode() + { + return queued.status; + } + + @Override + public HttpRequest request() + { + return request; + } + + @Override + public Optional> previousResponse() + { + return Optional.empty(); + } + + @Override + public HttpHeaders headers() + { + return queued.headers; + } + + @Override + public T body() + { + return body; + } + + @Override + public Optional sslSession() + { + return Optional.empty(); + } + + @Override + public URI uri() + { + return request.uri(); + } + + @Override + public Version version() + { + return Version.HTTP_1_1; + } + } +} diff --git a/src/test/java/com/mailerlite/sdk/tests/RecordingHttpClientTest.java b/src/test/java/com/mailerlite/sdk/tests/RecordingHttpClientTest.java new file mode 100644 index 0000000..fc823df --- /dev/null +++ b/src/test/java/com/mailerlite/sdk/tests/RecordingHttpClientTest.java @@ -0,0 +1,113 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.tests; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpRequest; +import java.net.http.HttpRequest.BodyPublishers; +import java.net.http.HttpResponse; +import java.net.http.HttpResponse.BodyHandlers; +import java.util.Collections; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import com.mailerlite.sdk.support.RecordingHttpClient; +import com.mailerlite.sdk.support.RecordingHttpClient.RecordedRequest; + +/** + * Tests the fake HttpClient that the other tests use. + */ +public class RecordingHttpClientTest { + + /** The test URI. */ + private static final URI TEST_URI = URI.create("https://connect.mailerlite.com/api/test"); + + /** + * Removes the client from the factory. + */ + @AfterEach + public void afterEach() + { + RecordingHttpClient.uninstall(); + } + + /** + * The client keeps the raw body and returns the queued response. + * + * @throws Exception if the request fails + */ + @Test + public void keepsTheRawBodyAndReturnsTheQueuedResponse() throws Exception + { + RecordingHttpClient client = new RecordingHttpClient() + .respond(201, "{\"ok\":true}", Collections.singletonMap("Idempotent-Replayed", "true")); + + HttpRequest request = HttpRequest.newBuilder(TEST_URI) + .method("PATCH", BodyPublishers.ofString("{\"a\":1}")) + .build(); + + HttpResponse response = client.send(request, BodyHandlers.ofString()); + + assertEquals(201, response.statusCode()); + assertEquals("{\"ok\":true}", response.body()); + assertEquals("true", response.headers().firstValue("idempotent-replayed").get()); + + RecordedRequest recorded = client.lastRequest(); + assertEquals("PATCH", recorded.method); + assertEquals(TEST_URI, recorded.uri); + assertEquals("{\"a\":1}", recorded.bodyString()); + assertEquals(0, client.pendingResponses()); + } + + /** + * A request without a body publisher and a request with noBody() are different. + * + * @throws Exception if the request fails + */ + @Test + public void tellsNoPublisherFromAnEmptyBody() throws Exception + { + RecordingHttpClient client = new RecordingHttpClient().respond(200, "").respond(200, ""); + + client.send(HttpRequest.newBuilder(TEST_URI).GET().build(), BodyHandlers.ofString()); + client.send(HttpRequest.newBuilder(TEST_URI).method("POST", BodyPublishers.noBody()).build(), BodyHandlers.ofString()); + + RecordedRequest get = client.requests().get(0); + assertFalse(get.hasBodyPublisher); + assertArrayEquals(new byte[0], get.body); + + RecordedRequest post = client.requests().get(1); + assertTrue(post.hasBodyPublisher); + assertEquals(0, post.contentLength); + assertArrayEquals(new byte[0], post.body); + } + + /** + * The client throws a queued error, and an IOException when the queue is empty. + */ + @Test + public void throwsQueuedErrorsAndFailsWhenTheQueueIsEmpty() + { + RecordingHttpClient client = new RecordingHttpClient().fail(new IOException("connection reset")); + HttpRequest request = HttpRequest.newBuilder(TEST_URI).GET().build(); + + IOException queued = assertThrows(IOException.class, () -> client.send(request, BodyHandlers.ofString())); + assertEquals("connection reset", queued.getMessage()); + + assertThrows(IOException.class, () -> client.send(request, BodyHandlers.ofString())); + assertEquals(2, client.requests().size()); + } +} From b4a354ecd4c2bb365aa4cf065876d3167fd7215d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Thu, 29 Oct 2026 16:09:26 +0100 Subject: [PATCH 02/35] fix: throw MailerLiteException for network errors The request methods cast an IOException or an InterruptedException to MailerLiteException. The cast failed, so callers got a ClassCastException. Now the methods throw a MailerLiteException with the network error as the cause. For an InterruptedException, the methods also set the interrupt flag of the thread again. Co-Authored-By: Claude Opus 5.5 --- .../com/mailerlite/sdk/MailerLiteApi.java | 40 +++-- .../sdk/exceptions/MailerLiteException.java | 11 ++ .../sdk/tests/MailerLiteApiTest.java | 149 ++++++++++++++++++ 3 files changed, 184 insertions(+), 16 deletions(-) create mode 100644 src/test/java/com/mailerlite/sdk/tests/MailerLiteApiTest.java diff --git a/src/main/java/com/mailerlite/sdk/MailerLiteApi.java b/src/main/java/com/mailerlite/sdk/MailerLiteApi.java index 9ebf90e..ac1bc19 100644 --- a/src/main/java/com/mailerlite/sdk/MailerLiteApi.java +++ b/src/main/java/com/mailerlite/sdk/MailerLiteApi.java @@ -100,9 +100,7 @@ public T getRequest(String endpoint, Class res } catch (IOException | InterruptedException e) { - MailerLiteException ex = (MailerLiteException) e; - - throw ex; + throw this.networkError(e); } if (responseClass == MailerLiteStringResponse.class) { @@ -161,9 +159,7 @@ public T postRequest(String endpoint, String requ } catch (IOException | InterruptedException e) { - MailerLiteException ex = (MailerLiteException) e; - - throw ex; + throw this.networkError(e); } if (responseClass == MailerLiteStringResponse.class) { @@ -218,9 +214,7 @@ public T deleteRequest(String endpoint, Class } catch (IOException | InterruptedException e) { - MailerLiteException ex = (MailerLiteException) e; - - throw ex; + throw this.networkError(e); } return this.handleApiResponse(responseObject, responseClass, customDeserializer); @@ -274,9 +268,7 @@ public T putRequest(String endpoint, String reque } catch (IOException | InterruptedException e) { - MailerLiteException ex = (MailerLiteException) e; - - throw ex; + throw this.networkError(e); } if (responseClass == MailerLiteStringResponse.class) { @@ -291,6 +283,24 @@ public T putRequest(String endpoint, String reque } + /** + * Converts a network error to a MailerLiteException. + * For an InterruptedException, it also sets the interrupt flag of the thread again. + * + * @param e the network error + * @return the exception to throw + */ + private MailerLiteException networkError(Exception e) + { + if (e instanceof InterruptedException) { + + Thread.currentThread().interrupt(); + } + + return new MailerLiteException(e.getMessage(), e); + } + + /** * Handles the response from the MailerLite API. It deserializes the JSON response into an object with the given type * @@ -366,11 +376,9 @@ private T handleApiResponse(HttpResponse try { response = responseClass.getDeclaredConstructor().newInstance(); - } catch (Exception e) { - - MailerLiteException ex = (MailerLiteException) e; + } catch (ReflectiveOperationException e) { - throw ex; + throw new MailerLiteException(e.getMessage(), e); } } diff --git a/src/main/java/com/mailerlite/sdk/exceptions/MailerLiteException.java b/src/main/java/com/mailerlite/sdk/exceptions/MailerLiteException.java index 69310df..228a15d 100644 --- a/src/main/java/com/mailerlite/sdk/exceptions/MailerLiteException.java +++ b/src/main/java/com/mailerlite/sdk/exceptions/MailerLiteException.java @@ -35,4 +35,15 @@ public MailerLiteException(String message) { super(message); } + + /** + * Makes an exception with a message and a cause, for example a network error. + * + * @param message the message + * @param cause the error that caused this exception + */ + public MailerLiteException(String message, Throwable cause) { + + super(message, cause); + } } diff --git a/src/test/java/com/mailerlite/sdk/tests/MailerLiteApiTest.java b/src/test/java/com/mailerlite/sdk/tests/MailerLiteApiTest.java new file mode 100644 index 0000000..0330270 --- /dev/null +++ b/src/test/java/com/mailerlite/sdk/tests/MailerLiteApiTest.java @@ -0,0 +1,149 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.function.Executable; + +import com.mailerlite.sdk.MailerLiteApi; +import com.mailerlite.sdk.MailerLiteResponse; +import com.mailerlite.sdk.exceptions.MailerLiteException; +import com.mailerlite.sdk.support.RecordingHttpClient; + +/** + * Tests the HTTP layer of the SDK with the recording HTTP client. + */ +public class MailerLiteApiTest { + + /** The fake HTTP client. */ + private RecordingHttpClient client; + + /** + * Sets the fake HTTP client. + */ + @BeforeEach + public void setupEach() + { + client = RecordingHttpClient.install(); + } + + /** + * Removes the fake HTTP client. + */ + @AfterEach + public void afterEach() + { + RecordingHttpClient.uninstall(); + Thread.interrupted(); + } + + /** + * Makes an API object with a test token. + * + * @return the API object + */ + private MailerLiteApi api() + { + MailerLiteApi api = new MailerLiteApi(); + api.setToken("test-token"); + + return api; + } + + /** + * The old request methods, each with a name. + * + * @return the request methods + */ + private Map oldRequestMethods() + { + Map methods = new LinkedHashMap(); + methods.put("GET", () -> api().getRequest("/groups", MailerLiteResponse.class)); + methods.put("POST", () -> api().postRequest("/groups", "{}", MailerLiteResponse.class)); + methods.put("PUT", () -> api().putRequest("/groups/1", "{}", MailerLiteResponse.class)); + methods.put("DELETE", () -> api().deleteRequest("/groups/1", MailerLiteResponse.class)); + + return methods; + } + + /** + * A network error becomes a MailerLiteException with the error as the cause. + */ + @Test + public void networkErrorBecomesMailerLiteException() + { + for (Map.Entry method : oldRequestMethods().entrySet()) { + + IOException error = new IOException("connection reset"); + client.fail(error); + + MailerLiteException e = assertThrows(MailerLiteException.class, method.getValue(), method.getKey()); + + assertSame(error, e.getCause(), method.getKey()); + assertEquals("connection reset", e.getMessage(), method.getKey()); + } + } + + /** + * An interrupt becomes a MailerLiteException, and the thread keeps its interrupt flag. + */ + @Test + public void interruptBecomesMailerLiteExceptionAndKeepsTheFlag() + { + for (Map.Entry method : oldRequestMethods().entrySet()) { + + InterruptedException error = new InterruptedException("interrupted"); + client.fail(error); + + MailerLiteException e = assertThrows(MailerLiteException.class, method.getValue(), method.getKey()); + + assertSame(error, e.getCause(), method.getKey()); + assertTrue(Thread.interrupted(), method.getKey()); + } + } + + /** + * An empty success body for a response class without a no-argument constructor gives a MailerLiteException. + */ + @Test + public void emptyBodyForResponseWithoutDefaultConstructorBecomesMailerLiteException() + { + client.respond(200, ""); + + MailerLiteException e = assertThrows(MailerLiteException.class, + () -> api().getRequest("/test", NoDefaultConstructorResponse.class)); + + assertTrue(e.getCause() instanceof ReflectiveOperationException); + } + + /** + * A response class without a no-argument constructor. + */ + public static class NoDefaultConstructorResponse extends MailerLiteResponse { + + /** + * Instantiates a new response. + * + * @param value any value + */ + public NoDefaultConstructorResponse(int value) + { + } + } +} From 2f665109bf6eefb105718111170a55c3837f5519 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Thu, 29 Oct 2026 16:10:10 +0100 Subject: [PATCH 03/35] feat: add sendRequest for any HTTP method Add MailerLiteApi.sendRequest. It sends any HTTP method, for example PATCH, with an optional body and extra headers. It sends a DELETE body only when the caller gives one. When the body is null, a POST or PATCH has an empty body, not the JSON null. Each request has the headers Content-Type and Accept with the value application/json. The old request methods do not change. Co-Authored-By: Claude Opus 5.5 --- .../com/mailerlite/sdk/MailerLiteApi.java | 62 ++++++++++ .../sdk/tests/MailerLiteApiTest.java | 117 ++++++++++++++++++ 2 files changed, 179 insertions(+) diff --git a/src/main/java/com/mailerlite/sdk/MailerLiteApi.java b/src/main/java/com/mailerlite/sdk/MailerLiteApi.java index ac1bc19..6be98ac 100644 --- a/src/main/java/com/mailerlite/sdk/MailerLiteApi.java +++ b/src/main/java/com/mailerlite/sdk/MailerLiteApi.java @@ -14,6 +14,7 @@ import java.net.http.HttpResponse; import java.net.http.HttpRequest.BodyPublishers; import java.net.http.HttpResponse.BodyHandlers; +import java.util.Map; import java.util.NoSuchElementException; import com.google.gson.Gson; @@ -283,6 +284,67 @@ public T putRequest(String endpoint, String reque } + /** + * Sends a request with any HTTP method, for example PATCH, to the given endpoint of the MailerLite API. + *

+ * The request has the headers {@code Content-Type: application/json} and {@code Accept: application/json}, + * and then the extra headers. + *

+ * If the body is null, GET and DELETE requests have no body, and the other requests have an empty body. + * The SDK never sends the JSON {@code null}. + * + * @param the generic type + * @param method the HTTP method, for example "PATCH" + * @param endpoint the MailerLite API endpoint, with the query string + * @param body the JSON body, or null for no body + * @param headers the extra headers, or null + * @param responseClass the class of the response object + * @return T + * @throws MailerLiteException if the API returns an error or the request fails + */ + public T sendRequest(String method, String endpoint, String body, Map headers, Class responseClass) throws MailerLiteException + { + HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(this.endpointBase.concat(endpoint))) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .header("Authorization", "Bearer ".concat(this.apiToken)); + + if (headers != null) { + + for (Map.Entry header : headers.entrySet()) { + + builder.header(header.getKey(), header.getValue()); + } + } + + if (body != null) { + + builder.method(method, BodyPublishers.ofString(body)); + } else if ("GET".equals(method)) { + + builder.GET(); + } else if ("DELETE".equals(method)) { + + builder.DELETE(); + } else { + + builder.method(method, BodyPublishers.noBody()); + } + + HttpResponse responseObject = null; + + try { + + responseObject = this.client.send(builder.build(), BodyHandlers.ofString()); + } catch (IOException | InterruptedException e) { + + throw this.networkError(e); + } + + return this.handleApiResponse(responseObject, responseClass, null); + } + + /** * Converts a network error to a MailerLiteException. * For an InterruptedException, it also sets the interrupt flag of the thread again. diff --git a/src/test/java/com/mailerlite/sdk/tests/MailerLiteApiTest.java b/src/test/java/com/mailerlite/sdk/tests/MailerLiteApiTest.java index 0330270..f2a0248 100644 --- a/src/test/java/com/mailerlite/sdk/tests/MailerLiteApiTest.java +++ b/src/test/java/com/mailerlite/sdk/tests/MailerLiteApiTest.java @@ -7,12 +7,16 @@ **************************************************/ package com.mailerlite.sdk.tests; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.IOException; +import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; @@ -25,6 +29,7 @@ import com.mailerlite.sdk.MailerLiteResponse; import com.mailerlite.sdk.exceptions.MailerLiteException; import com.mailerlite.sdk.support.RecordingHttpClient; +import com.mailerlite.sdk.support.RecordingHttpClient.RecordedRequest; /** * Tests the HTTP layer of the SDK with the recording HTTP client. @@ -132,6 +137,118 @@ public void emptyBodyForResponseWithoutDefaultConstructorBecomesMailerLiteExcept assertTrue(e.getCause() instanceof ReflectiveOperationException); } + /** + * Asserts the headers that sendRequest sends on every request. + * + * @param request the request + */ + private void assertDefaultHeaders(RecordedRequest request) + { + assertEquals("application/json", request.header("Content-Type")); + assertEquals("application/json", request.header("Accept")); + assertEquals("Bearer test-token", request.header("Authorization")); + } + + /** + * sendRequest sends a PATCH request with the body and the extra headers. + * + * @throws MailerLiteException if the request fails + */ + @Test + public void sendRequestSendsPatchWithBody() throws MailerLiteException + { + client.respond(200, "{}"); + + MailerLiteResponse response = api().sendRequest("PATCH", "/emails/123/blocks/57", "{\"etag\":\"42\"}", + Collections.singletonMap("Idempotency-Key", "key-1"), MailerLiteResponse.class); + + RecordedRequest request = client.lastRequest(); + assertEquals("PATCH", request.method); + assertEquals("https://connect.mailerlite.com/api/emails/123/blocks/57", request.uri.toString()); + assertEquals("{\"etag\":\"42\"}", request.bodyString()); + assertDefaultHeaders(request); + assertEquals("key-1", request.header("Idempotency-Key")); + assertEquals(200, response.responseStatusCode); + } + + /** + * sendRequest sends a DELETE request with a body when the caller gives one. + * + * @throws MailerLiteException if the request fails + */ + @Test + public void sendRequestSendsDeleteWithBody() throws MailerLiteException + { + client.respond(200, "{\"etag\":\"45\"}"); + + api().sendRequest("DELETE", "/emails/123/blocks/57", "{\"etag\":\"44\"}", null, MailerLiteResponse.class); + + RecordedRequest request = client.lastRequest(); + assertEquals("DELETE", request.method); + assertEquals("{\"etag\":\"44\"}", request.bodyString()); + assertDefaultHeaders(request); + assertNull(request.header("Idempotency-Key")); + } + + /** + * sendRequest sends GET and DELETE without a body publisher when the body is null. + * + * @throws MailerLiteException if the request fails + */ + @Test + public void sendRequestSendsGetAndDeleteWithoutBody() throws MailerLiteException + { + client.respond(200, "{}").respond(204, ""); + + api().sendRequest("GET", "/emails/123", null, null, MailerLiteResponse.class); + MailerLiteResponse deleted = api().sendRequest("DELETE", "/campaigns/1", null, null, MailerLiteResponse.class); + + for (RecordedRequest request : client.requests()) { + assertFalse(request.hasBodyPublisher, request.method); + assertDefaultHeaders(request); + } + + assertEquals("GET", client.requests().get(0).method); + assertEquals("DELETE", client.requests().get(1).method); + assertEquals(204, deleted.responseStatusCode); + } + + /** + * sendRequest sends a POST with an empty body, not the JSON null, when the body is null. + * + * @throws MailerLiteException if the request fails + */ + @Test + public void sendRequestSendsPostWithEmptyBody() throws MailerLiteException + { + client.respond(201, "{}"); + + MailerLiteResponse response = api().sendRequest("POST", "/emails/123/versions", null, null, MailerLiteResponse.class); + + RecordedRequest request = client.lastRequest(); + assertEquals("POST", request.method); + assertTrue(request.hasBodyPublisher); + assertEquals(0, request.contentLength); + assertArrayEquals(new byte[0], request.body); + assertDefaultHeaders(request); + assertEquals(201, response.responseStatusCode); + } + + /** + * sendRequest converts a network error to a MailerLiteException. + */ + @Test + public void sendRequestNetworkErrorBecomesMailerLiteException() + { + IOException error = new IOException("timeout"); + client.fail(error); + + MailerLiteException e = assertThrows(MailerLiteException.class, + () -> api().sendRequest("POST", "/emails/123/versions", null, null, MailerLiteResponse.class)); + + assertSame(error, e.getCause()); + } + /** * A response class without a no-argument constructor. */ From ef982b24af89bc2a7a8973fbf652735fe98c1834 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Thu, 29 Oct 2026 16:11:00 +0100 Subject: [PATCH 04/35] feat: expose the API error code and etag on MailerLiteException Add the fields errorCode and etag, and the method isVersionConflict(), to MailerLiteException. The field code stays the HTTP status. When the API sends a code, the message starts with the code, for example "version_conflict: ...". A body that is not JSON keeps the old message. Add EmailErrorCodes.VERSION_CONFLICT. Co-Authored-By: Claude Opus 5.5 --- .../com/mailerlite/sdk/MailerLiteApi.java | 11 ++- .../sdk/emails/EmailErrorCodes.java | 27 ++++++ .../sdk/exceptions/JsonResponseError.java | 6 ++ .../sdk/exceptions/MailerLiteException.java | 25 +++++ .../sdk/tests/MailerLiteApiTest.java | 97 +++++++++++++++++++ 5 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/mailerlite/sdk/emails/EmailErrorCodes.java diff --git a/src/main/java/com/mailerlite/sdk/MailerLiteApi.java b/src/main/java/com/mailerlite/sdk/MailerLiteApi.java index 6be98ac..67698da 100644 --- a/src/main/java/com/mailerlite/sdk/MailerLiteApi.java +++ b/src/main/java/com/mailerlite/sdk/MailerLiteApi.java @@ -412,9 +412,18 @@ private T handleApiResponse(HttpResponse JsonResponseError error = gson.fromJson(stringResponse, JsonResponseError.class); - responseError = new MailerLiteException(error.message); + String message = error.message; + + if (error.code != null && !error.code.isEmpty()) { + + message = error.code.concat(": ").concat(String.valueOf(error.message)); + } + + responseError = new MailerLiteException(message); responseError.errors = error.errors; + responseError.errorCode = error.code; + responseError.etag = error.etag; } catch (Exception ex) { responseError = new MailerLiteException("Error parsing API response"); diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailErrorCodes.java b/src/main/java/com/mailerlite/sdk/emails/EmailErrorCodes.java new file mode 100644 index 0000000..f0ef593 --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/EmailErrorCodes.java @@ -0,0 +1,27 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.emails; + +/** + * The values of {@code MailerLiteException.errorCode} that the email endpoints send. + */ +public final class EmailErrorCodes { + + /** + * The email changed after you read the etag that the write sent. The exception has the current etag. + * Get the email again, and then send the write again with the current etag. + */ + public static final String VERSION_CONFLICT = "version_conflict"; + + /** + * Do not make instances of this class. + */ + private EmailErrorCodes() + { + } +} diff --git a/src/main/java/com/mailerlite/sdk/exceptions/JsonResponseError.java b/src/main/java/com/mailerlite/sdk/exceptions/JsonResponseError.java index 7bdb03e..efa9527 100644 --- a/src/main/java/com/mailerlite/sdk/exceptions/JsonResponseError.java +++ b/src/main/java/com/mailerlite/sdk/exceptions/JsonResponseError.java @@ -19,4 +19,10 @@ public class JsonResponseError { /** The errors. */ public HashMap errors = new HashMap(); + + /** The error code, for example "version_conflict", or null. */ + public String code; + + /** The current etag of the email, or null. */ + public String etag; } diff --git a/src/main/java/com/mailerlite/sdk/exceptions/MailerLiteException.java b/src/main/java/com/mailerlite/sdk/exceptions/MailerLiteException.java index 228a15d..ba86be3 100644 --- a/src/main/java/com/mailerlite/sdk/exceptions/MailerLiteException.java +++ b/src/main/java/com/mailerlite/sdk/exceptions/MailerLiteException.java @@ -9,6 +9,8 @@ import java.util.HashMap; +import com.mailerlite.sdk.emails.EmailErrorCodes; + /** * The Class MailerLiteException. */ @@ -26,6 +28,18 @@ public class MailerLiteException extends Exception { /** The errors. */ public HashMap errors = new HashMap(); + /** + * The {@code code} string of the API error body, for example "version_conflict", or null if the body has none. + * The field {@code code} is the HTTP status. + */ + public String errorCode = null; + + /** + * The {@code etag} of the API error body, or null if the body has none. + * A version conflict error has the current etag of the email. + */ + public String etag = null; + /** *

Constructor for MailerListException.

* @@ -46,4 +60,15 @@ public MailerLiteException(String message, Throwable cause) { super(message, cause); } + + /** + * Tells if the error is a version conflict: the email changed after you read the etag that the write sent. + * Then {@link #etag} is the current etag of the email. + * + * @return true if {@link #errorCode} is {@link EmailErrorCodes#VERSION_CONFLICT} + */ + public boolean isVersionConflict() { + + return EmailErrorCodes.VERSION_CONFLICT.equals(this.errorCode); + } } diff --git a/src/test/java/com/mailerlite/sdk/tests/MailerLiteApiTest.java b/src/test/java/com/mailerlite/sdk/tests/MailerLiteApiTest.java index f2a0248..a931f17 100644 --- a/src/test/java/com/mailerlite/sdk/tests/MailerLiteApiTest.java +++ b/src/test/java/com/mailerlite/sdk/tests/MailerLiteApiTest.java @@ -249,6 +249,103 @@ public void sendRequestNetworkErrorBecomesMailerLiteException() assertSame(error, e.getCause()); } + /** + * A 409 version conflict exposes the API code and the current etag. + */ + @Test + public void versionConflictExposesCodeAndEtag() + { + String body = "{\"message\":\"The email changed since you read it. Fetch it again, then retry with its new etag.\"," + + "\"code\":\"version_conflict\",\"etag\":\"43\"}"; + client.respond(409, body); + + MailerLiteException e = assertThrows(MailerLiteException.class, + () -> api().sendRequest("PATCH", "/emails/123/blocks/57", "{}", null, MailerLiteResponse.class)); + + assertEquals(409, e.code); + assertEquals("version_conflict", e.errorCode); + assertEquals("43", e.etag); + assertTrue(e.isVersionConflict()); + assertEquals("version_conflict: The email changed since you read it. Fetch it again, then retry with its new etag.", + e.getMessage()); + assertEquals(body, e.responseBody); + assertTrue(e.errors.isEmpty()); + } + + /** + * A 409 without a code is not a version conflict, and the message has no prefix. + */ + @Test + public void conflictWithoutCodeIsNotVersionConflict() + { + client.respond(409, "{\"message\":\"The campaign is not a draft.\"}"); + + MailerLiteException e = assertThrows(MailerLiteException.class, + () -> api().postRequest("/emails/123/versions", "{}", MailerLiteResponse.class)); + + assertEquals(409, e.code); + assertNull(e.errorCode); + assertNull(e.etag); + assertFalse(e.isVersionConflict()); + assertEquals("The campaign is not a draft.", e.getMessage()); + } + + /** + * A 422 with errors exposes the errors for each field. + */ + @Test + public void validationErrorExposesErrors() + { + client.respond(422, "{\"message\":\"The given data was invalid.\"," + + "\"errors\":{\"editor_content\":[\"The editor content field must be present.\"]}}"); + + MailerLiteException e = assertThrows(MailerLiteException.class, + () -> api().sendRequest("POST", "/emails/123/blocks", "{}", null, MailerLiteResponse.class)); + + assertEquals(422, e.code); + assertNull(e.errorCode); + assertFalse(e.isVersionConflict()); + assertEquals("The given data was invalid.", e.getMessage()); + assertArrayEquals(new String[] {"The editor content field must be present."}, e.errors.get("editor_content")); + } + + /** + * A 422 with only a message has no errors. + */ + @Test + public void validationErrorWithOnlyMessage() + { + client.respond(422, "{\"message\":\"This email was not built with the simple editor.\"}"); + + MailerLiteException e = assertThrows(MailerLiteException.class, + () -> api().sendRequest("GET", "/emails/123", null, null, MailerLiteResponse.class)); + + assertEquals(422, e.code); + assertEquals("This email was not built with the simple editor.", e.getMessage()); + assertTrue(e.errors.isEmpty()); + assertNull(e.errorCode); + } + + /** + * A 503 with an HTML body keeps the status and the raw body. + */ + @Test + public void serviceUnavailableWithHtmlBody() + { + String body = "

503 Service Unavailable

"; + client.respond(503, body); + + MailerLiteException e = assertThrows(MailerLiteException.class, + () -> api().sendRequest("GET", "/emails/123/html", null, null, MailerLiteResponse.class)); + + assertEquals(503, e.code); + assertEquals("Error parsing API response", e.getMessage()); + assertEquals(body, e.responseBody); + assertNull(e.errorCode); + assertNull(e.etag); + assertFalse(e.isVersionConflict()); + } + /** * A response class without a no-argument constructor. */ From cdedfdd0ca83330c367bdbd256b95b9985c18ac0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Thu, 29 Oct 2026 16:11:47 +0100 Subject: [PATCH 05/35] fix: encode query values and skip null values ApiRequest sent query values as they were. A space made the URI invalid, and a null value caused a NullPointerException. Now the SDK encodes each value as UTF-8, so a space is sent as "+". A null value is not sent, and it removes a value that was set before. An empty value is sent as "name=". The keys do not change, so "filter[name]" keeps its brackets. Co-Authored-By: Claude Opus 5.5 --- .../com/mailerlite/sdk/util/ApiRequest.java | 16 +++- .../mailerlite/sdk/tests/ApiRequestTest.java | 94 +++++++++++++++++++ 2 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 src/test/java/com/mailerlite/sdk/tests/ApiRequestTest.java diff --git a/src/main/java/com/mailerlite/sdk/util/ApiRequest.java b/src/main/java/com/mailerlite/sdk/util/ApiRequest.java index 32ab1c1..5e3f2bc 100644 --- a/src/main/java/com/mailerlite/sdk/util/ApiRequest.java +++ b/src/main/java/com/mailerlite/sdk/util/ApiRequest.java @@ -7,6 +7,8 @@ **************************************************/ package com.mailerlite.sdk.util; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.util.HashMap; /** @@ -20,12 +22,20 @@ public class ApiRequest { /** * Adds the query parameter. + *

+ * A null value is not sent, and it removes a value that you set before. An empty value is sent as "name=". * - * @param name the name - * @param value the value + * @param name the name, for example "filter[name]". The SDK sends it as it is. + * @param value the value. The SDK encodes it, so a space is sent as "+". */ public void addQueryParameter(String name, String value) { + if (value == null) { + + queryParameters.remove(name); + return; + } + queryParameters.put(name, value); } @@ -44,7 +54,7 @@ public String getQueryParameters() query = query.concat("&"); } - query = query.concat(name).concat("=").concat(queryParameters.get(name)); + query = query.concat(name).concat("=").concat(URLEncoder.encode(queryParameters.get(name), StandardCharsets.UTF_8)); } return "?".concat(query); diff --git a/src/test/java/com/mailerlite/sdk/tests/ApiRequestTest.java b/src/test/java/com/mailerlite/sdk/tests/ApiRequestTest.java new file mode 100644 index 0000000..7a76cec --- /dev/null +++ b/src/test/java/com/mailerlite/sdk/tests/ApiRequestTest.java @@ -0,0 +1,94 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +import com.mailerlite.sdk.util.ApiRequest; + +/** + * Tests the query string of ApiRequest. + */ +public class ApiRequestTest { + + /** + * The SDK does not store a null value. + */ + @Test + public void skipsNullValues() + { + ApiRequest request = new ApiRequest(); + request.addQueryParameter("filter[type]", null); + request.addQueryParameter("page", "2"); + + assertEquals("?page=2", request.getQueryParameters()); + } + + /** + * A null value removes a value that the caller set before. + */ + @Test + public void nullValueRemovesTheEarlierValue() + { + ApiRequest request = new ApiRequest(); + request.addQueryParameter("filter[type]", "image"); + request.addQueryParameter("filter[type]", null); + + assertEquals("?", request.getQueryParameters()); + } + + /** + * The SDK encodes a space as a plus sign and keeps the brackets of the key. + */ + @Test + public void encodesSpacesInValues() + { + ApiRequest request = new ApiRequest(); + request.addQueryParameter("filter[name]", "summer sale banner"); + + assertEquals("?filter[name]=summer+sale+banner", request.getQueryParameters()); + } + + /** + * The SDK encodes the characters that have a meaning in a query string, and characters that are not ASCII. + */ + @Test + public void encodesReservedAndNonAsciiCharacters() + { + ApiRequest request = new ApiRequest(); + request.addQueryParameter("filter[name]", "a&b=c#d/é"); + + assertEquals("?filter[name]=a%26b%3Dc%23d%2F%C3%A9", request.getQueryParameters()); + } + + /** + * The SDK sends an empty value as "key=". + */ + @Test + public void sendsEmptyValue() + { + ApiRequest request = new ApiRequest(); + request.addQueryParameter("filter[name]", ""); + + assertEquals("?filter[name]=", request.getQueryParameters()); + } + + /** + * A value with only safe characters does not change, so the recorded tapes still match. + */ + @Test + public void keepsSafeValues() + { + ApiRequest request = new ApiRequest(); + request.addQueryParameter("filter[status]", "active"); + + assertEquals("?filter[status]=active", request.getQueryParameters()); + } +} From dc0b5f029642cd99e9424a4107581808eaa96f58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Thu, 29 Oct 2026 16:13:11 +0100 Subject: [PATCH 06/35] feat: add shared input checks and the editor content adapter Add RequestChecks with checks for IDs, etags and idempotency keys. An ID must contain only the ASCII digits 0 to 9. The checks throw EmptyIdException, InvalidIdException, EmptyEtagException or InvalidIdempotencyKeyException. All four extend IllegalArgumentException. Add EditorContentAdapter for editor_content maps. It reads whole numbers as Long, so 7 is not sent back as 7.0. It also keeps the null values in the map. Co-Authored-By: Claude Opus 5.5 --- .../sdk/emails/EditorContentAdapter.java | 86 +++++++ .../sdk/exceptions/EmptyEtagException.java | 28 +++ .../sdk/exceptions/EmptyIdException.java | 28 +++ .../sdk/exceptions/InvalidIdException.java | 28 +++ .../InvalidIdempotencyKeyException.java | 28 +++ .../mailerlite/sdk/util/RequestChecks.java | 117 ++++++++++ .../sdk/tests/RequestChecksTest.java | 217 ++++++++++++++++++ 7 files changed, 532 insertions(+) create mode 100644 src/main/java/com/mailerlite/sdk/emails/EditorContentAdapter.java create mode 100644 src/main/java/com/mailerlite/sdk/exceptions/EmptyEtagException.java create mode 100644 src/main/java/com/mailerlite/sdk/exceptions/EmptyIdException.java create mode 100644 src/main/java/com/mailerlite/sdk/exceptions/InvalidIdException.java create mode 100644 src/main/java/com/mailerlite/sdk/exceptions/InvalidIdempotencyKeyException.java create mode 100644 src/main/java/com/mailerlite/sdk/util/RequestChecks.java create mode 100644 src/test/java/com/mailerlite/sdk/tests/RequestChecksTest.java diff --git a/src/main/java/com/mailerlite/sdk/emails/EditorContentAdapter.java b/src/main/java/com/mailerlite/sdk/emails/EditorContentAdapter.java new file mode 100644 index 0000000..72129e8 --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/EditorContentAdapter.java @@ -0,0 +1,86 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.emails; + +import java.io.IOException; +import java.util.Map; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.ToNumberPolicy; +import com.google.gson.TypeAdapter; +import com.google.gson.reflect.TypeToken; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.google.gson.stream.JsonWriter; + +/** + * Reads and writes the {@code editor_content} document of a block or a footer text. + *

+ * The adapter does two things that the Gson default does not do: + *

    + *
  1. It reads a whole number as a {@code Long} and other numbers as a {@code Double}. So {@code "linkId": 7} + * is sent back as {@code 7}, not as {@code 7.0}.
  2. + *
  3. It writes the null values in the map, so the SDK sends the document exactly as you give it.
  4. + *
+ */ +public class EditorContentAdapter extends TypeAdapter> { + + /** The Gson object that reads and writes the map. */ + private static final Gson GSON = new GsonBuilder() + .setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE) + .serializeNulls() + .create(); + + /** The adapter for the map. */ + private static final TypeAdapter> DELEGATE = + GSON.getAdapter(new TypeToken>() { }); + + /** + * Writes the map, with its null values. + * + * @param out the writer + * @param value the map + * @throws IOException if the writer fails + */ + @Override + public void write(JsonWriter out, Map value) throws IOException + { + if (value == null) { + out.nullValue(); + return; + } + + boolean serializeNulls = out.getSerializeNulls(); + out.setSerializeNulls(true); + + try { + DELEGATE.write(out, value); + } finally { + out.setSerializeNulls(serializeNulls); + } + } + + /** + * Reads the map. Whole numbers become Long values, and other numbers become Double values. + * + * @param in the reader + * @return the map, or null + * @throws IOException if the reader fails + */ + @Override + public Map read(JsonReader in) throws IOException + { + if (in.peek() == JsonToken.NULL) { + in.nextNull(); + return null; + } + + return DELEGATE.read(in); + } +} diff --git a/src/main/java/com/mailerlite/sdk/exceptions/EmptyEtagException.java b/src/main/java/com/mailerlite/sdk/exceptions/EmptyEtagException.java new file mode 100644 index 0000000..8f8aea2 --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/exceptions/EmptyEtagException.java @@ -0,0 +1,28 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.exceptions; + +/** + * A write has an empty or null etag. + * The SDK sends no request when it throws this exception. + */ +public class EmptyEtagException extends IllegalArgumentException { + + /** The serial version UID. */ + private static final long serialVersionUID = 1L; + + /** + * Makes the exception. + * + * @param message the message + */ + public EmptyEtagException(String message) + { + super(message); + } +} diff --git a/src/main/java/com/mailerlite/sdk/exceptions/EmptyIdException.java b/src/main/java/com/mailerlite/sdk/exceptions/EmptyIdException.java new file mode 100644 index 0000000..abf2550 --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/exceptions/EmptyIdException.java @@ -0,0 +1,28 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.exceptions; + +/** + * An email, block or version ID is empty or null. + * The SDK sends no request when it throws this exception. + */ +public class EmptyIdException extends IllegalArgumentException { + + /** The serial version UID. */ + private static final long serialVersionUID = 1L; + + /** + * Makes the exception. + * + * @param message the message + */ + public EmptyIdException(String message) + { + super(message); + } +} diff --git a/src/main/java/com/mailerlite/sdk/exceptions/InvalidIdException.java b/src/main/java/com/mailerlite/sdk/exceptions/InvalidIdException.java new file mode 100644 index 0000000..40d906e --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/exceptions/InvalidIdException.java @@ -0,0 +1,28 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.exceptions; + +/** + * An email, block or version ID contains a character that is not an ASCII digit (0 to 9). + * The SDK sends no request when it throws this exception. + */ +public class InvalidIdException extends IllegalArgumentException { + + /** The serial version UID. */ + private static final long serialVersionUID = 1L; + + /** + * Makes the exception. + * + * @param message the message + */ + public InvalidIdException(String message) + { + super(message); + } +} diff --git a/src/main/java/com/mailerlite/sdk/exceptions/InvalidIdempotencyKeyException.java b/src/main/java/com/mailerlite/sdk/exceptions/InvalidIdempotencyKeyException.java new file mode 100644 index 0000000..9812bfa --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/exceptions/InvalidIdempotencyKeyException.java @@ -0,0 +1,28 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.exceptions; + +/** + * An idempotency key is not 1 to 255 printable ASCII characters, or it has a space at the start or the end. + * The SDK sends no request when it throws this exception. + */ +public class InvalidIdempotencyKeyException extends IllegalArgumentException { + + /** The serial version UID. */ + private static final long serialVersionUID = 1L; + + /** + * Makes the exception. + * + * @param message the message + */ + public InvalidIdempotencyKeyException(String message) + { + super(message); + } +} diff --git a/src/main/java/com/mailerlite/sdk/util/RequestChecks.java b/src/main/java/com/mailerlite/sdk/util/RequestChecks.java new file mode 100644 index 0000000..a8733cb --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/util/RequestChecks.java @@ -0,0 +1,117 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.util; + +import com.mailerlite.sdk.exceptions.EmptyEtagException; +import com.mailerlite.sdk.exceptions.EmptyIdException; +import com.mailerlite.sdk.exceptions.InvalidIdException; +import com.mailerlite.sdk.exceptions.InvalidIdempotencyKeyException; + +/** + * Checks the input of a request before the SDK sends it. + * Each check throws an exception that extends IllegalArgumentException, and the SDK then sends no request. + */ +public final class RequestChecks { + + /** The maximum length of an idempotency key. */ + private static final int MAX_IDEMPOTENCY_KEY_LENGTH = 255; + + /** + * Do not make instances of this class. + */ + private RequestChecks() + { + } + + /** + * Checks an ID that goes into the path or the body of a request. + *

+ * The ID must contain only the ASCII digits 0 to 9. URL encoding is not sufficient, because the server + * decodes "%2F" to "/". Other Unicode digits, such as "²", are not accepted. + * + * @param value the ID + * @param name the name of the parameter, for the error message + * @return the ID + * @throws EmptyIdException if the ID is null or empty + * @throws InvalidIdException if the ID contains a character that is not an ASCII digit + */ + public static String pathId(String value, String name) + { + if (value == null || value.isEmpty()) { + throw new EmptyIdException(name + " must not be empty"); + } + + for (int i = 0; i < value.length(); i++) { + + char c = value.charAt(i); + + if (c < '0' || c > '9') { + throw new InvalidIdException(name + " must contain only the digits 0 to 9"); + } + } + + return value; + } + + /** + * Checks the etag of a write. + * + * @param value the etag from the last response for the email + * @return the etag + * @throws EmptyEtagException if the etag is null or empty + */ + public static String etag(String value) + { + if (value == null || value.isEmpty()) { + throw new EmptyEtagException("etag must not be empty"); + } + + return value; + } + + /** + * Checks an idempotency key. + *

+ * The key must have 1 to 255 characters. Each character must be printable ASCII (0x20 to 0x7E). + * The key must not start or end with a space, because HTTP removes these spaces and the API then gets + * a different key. + * + * @param key the key + * @return the key + * @throws InvalidIdempotencyKeyException if the key is null or does not obey these rules + */ + public static String idempotencyKey(String key) + { + if (key == null || key.isEmpty() || key.length() > MAX_IDEMPOTENCY_KEY_LENGTH + || key.charAt(0) == ' ' || key.charAt(key.length() - 1) == ' ') { + throw invalidKey(); + } + + for (int i = 0; i < key.length(); i++) { + + char c = key.charAt(i); + + if (c < 0x20 || c > 0x7E) { + throw invalidKey(); + } + } + + return key; + } + + /** + * Makes the exception for an invalid idempotency key. + * + * @return the exception + */ + private static InvalidIdempotencyKeyException invalidKey() + { + return new InvalidIdempotencyKeyException( + "the idempotency key must be 1 to 255 printable ASCII characters, with no space at the start or the end"); + } +} diff --git a/src/test/java/com/mailerlite/sdk/tests/RequestChecksTest.java b/src/test/java/com/mailerlite/sdk/tests/RequestChecksTest.java new file mode 100644 index 0000000..476fe3c --- /dev/null +++ b/src/test/java/com/mailerlite/sdk/tests/RequestChecksTest.java @@ -0,0 +1,217 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.DynamicTest.dynamicTest; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; + +import com.google.gson.Gson; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.annotations.SerializedName; +import com.mailerlite.sdk.emails.EditorContentAdapter; +import com.mailerlite.sdk.exceptions.EmptyEtagException; +import com.mailerlite.sdk.exceptions.EmptyIdException; +import com.mailerlite.sdk.exceptions.InvalidIdException; +import com.mailerlite.sdk.exceptions.InvalidIdempotencyKeyException; +import com.mailerlite.sdk.util.RequestChecks; + +/** + * Tests the shared input checks and the editor content adapter. + */ +public class RequestChecksTest { + + /** IDs that are not only ASCII digits. */ + public static final List INVALID_IDS = Collections.unmodifiableList(Arrays.asList( + "123/html", "123%2Fhtml", "123\n", "\u00b2", "\u0661\u0662\u0663", "12a", " 123", "-1", "1.5")); + + /** Idempotency keys that the API rejects, or that HTTP changes. */ + public static final List INVALID_KEYS = Collections.unmodifiableList(Arrays.asList( + "", repeat('k', 256), "key\n", "key\t", "cl\u00e9", "key\u007f", " key", "key ")); + + /** + * Makes a string of one character. + * + * @param c the character + * @param count the length + * @return the string + */ + public static String repeat(char c, int count) + { + char[] chars = new char[count]; + Arrays.fill(chars, c); + + return new String(chars); + } + + /** + * A valid ID is returned as it is. + */ + @Test + public void pathIdAcceptsAsciiDigits() + { + assertEquals("123", RequestChecks.pathId("123", "emailId")); + assertEquals("0", RequestChecks.pathId("0", "emailId")); + } + + /** + * An empty or null ID throws EmptyIdException with the parameter name. + */ + @Test + public void pathIdRejectsEmptyIds() + { + EmptyIdException empty = assertThrows(EmptyIdException.class, () -> RequestChecks.pathId("", "emailId")); + assertTrue(empty.getMessage().contains("emailId"), empty.getMessage()); + + assertThrows(EmptyIdException.class, () -> RequestChecks.pathId(null, "blockId")); + assertTrue(empty instanceof IllegalArgumentException); + } + + /** + * An ID that is not only ASCII digits throws InvalidIdException. + * + * @return a test for each ID + */ + @TestFactory + public List pathIdRejectsInvalidIds() + { + List tests = new ArrayList(); + + for (String id : INVALID_IDS) { + tests.add(dynamicTest("rejects " + id.replace("\n", "\\n"), () -> { + InvalidIdException e = assertThrows(InvalidIdException.class, () -> RequestChecks.pathId(id, "versionId")); + assertTrue(e.getMessage().contains("versionId"), e.getMessage()); + assertTrue(e instanceof IllegalArgumentException); + })); + } + + return tests; + } + + /** + * An empty or null etag throws EmptyEtagException. + */ + @Test + public void etagRejectsEmptyValues() + { + assertEquals("42", RequestChecks.etag("42")); + assertThrows(EmptyEtagException.class, () -> RequestChecks.etag("")); + EmptyEtagException e = assertThrows(EmptyEtagException.class, () -> RequestChecks.etag(null)); + assertTrue(e instanceof IllegalArgumentException); + } + + /** + * A key of 1 to 255 printable ASCII characters is accepted. + */ + @Test + public void idempotencyKeyAcceptsPrintableAscii() + { + String longest = repeat('k', 255); + + assertEquals(longest, RequestChecks.idempotencyKey(longest)); + assertEquals("a", RequestChecks.idempotencyKey("a")); + assertEquals("retry 1 ~!@#$%^&*()", RequestChecks.idempotencyKey("retry 1 ~!@#$%^&*()")); + } + + /** + * An invalid key throws InvalidIdempotencyKeyException. + * + * @return a test for each key + */ + @TestFactory + public List idempotencyKeyRejectsInvalidKeys() + { + List keys = new ArrayList(INVALID_KEYS); + keys.add(null); + + List tests = new ArrayList(); + + for (String key : keys) { + String name = key == null ? "null" : key.length() > 20 ? "length " + key.length() : key; + tests.add(dynamicTest("rejects " + name.replace("\n", "\\n").replace("\t", "\\t"), () -> { + InvalidIdempotencyKeyException e = assertThrows(InvalidIdempotencyKeyException.class, + () -> RequestChecks.idempotencyKey(key)); + assertTrue(e instanceof IllegalArgumentException); + })); + } + + return tests; + } + + /** + * A class with an editor content field, like the SDK models. + */ + public static class Holder { + + /** The editor content. */ + @SerializedName("editor_content") + @JsonAdapter(EditorContentAdapter.class) + public Map editorContent; + + /** A field that stays absent when it is null. */ + @SerializedName("other") + public String other; + } + + /** + * The adapter reads whole numbers as Long, so the SDK sends them back without ".0". + */ + @Test + public void editorContentKeepsWholeNumbers() + { + String json = "{\"editor_content\":{\"attrs\":{\"linkId\":7,\"size\":24,\"ratio\":1.5}," + + "\"content\":[{\"text\":\"Hi\",\"level\":2}]}}"; + + Holder holder = new Gson().fromJson(json, Holder.class); + + @SuppressWarnings("unchecked") + Map attrs = (Map) holder.editorContent.get("attrs"); + assertEquals(Long.valueOf(7), attrs.get("linkId")); + assertEquals(Long.valueOf(24), attrs.get("size")); + assertEquals(Double.valueOf(1.5), attrs.get("ratio")); + assertEquals(json, new Gson().toJson(holder)); + } + + /** + * The adapter keeps null values in the map, and the other null fields stay absent. + */ + @Test + public void editorContentKeepsNullValues() + { + Map attrs = new LinkedHashMap(); + attrs.put("href", null); + attrs.put("textAlign", "left"); + + Map content = new LinkedHashMap(); + content.put("attrs", attrs); + content.put("title", null); + + Holder holder = new Holder(); + holder.editorContent = content; + + String json = new Gson().toJson(holder); + assertEquals("{\"editor_content\":{\"attrs\":{\"href\":null,\"textAlign\":\"left\"},\"title\":null}}", json); + + Holder read = new Gson().fromJson(json, Holder.class); + assertTrue(read.editorContent.containsKey("title")); + assertNull(read.editorContent.get("title")); + } +} From 7c2d1783424e86db23f37a7a11a8c995467a356f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Thu, 29 Oct 2026 16:17:31 +0100 Subject: [PATCH 07/35] feat: add Emails.get Add the Emails service for emails built with the simple editor, and the accessor MailerLite.emails(). Emails.get returns the email with its design settings, its blocks and its etag. Add the models of the email, the blocks and the design settings. The editor_content maps use EditorContentAdapter. Add a test that compiles the new README examples and runs them against the recording HTTP client. Co-Authored-By: Claude Opus 5.5 --- README.md | 46 +++ .../java/com/mailerlite/sdk/MailerLite.java | 19 + .../com/mailerlite/sdk/emails/EmailBlock.java | 45 +++ .../sdk/emails/EmailBlockPlacement.java | 24 ++ .../sdk/emails/EmailBrandModes.java | 30 ++ .../sdk/emails/EmailBrandSettings.java | 46 +++ .../sdk/emails/EmailButtonSettings.java | 43 +++ .../mailerlite/sdk/emails/EmailDocument.java | 42 +++ .../sdk/emails/EmailEditorText.java | 25 ++ .../sdk/emails/EmailFooterSettings.java | 53 +++ .../sdk/emails/EmailHeadingStyle.java | 47 +++ .../sdk/emails/EmailLayoutSettings.java | 27 ++ .../mailerlite/sdk/emails/EmailLinkStyle.java | 23 ++ .../sdk/emails/EmailParagraphStyle.java | 39 ++ .../mailerlite/sdk/emails/EmailSettings.java | 43 +++ .../sdk/emails/EmailSocialLink.java | 36 ++ .../mailerlite/sdk/emails/EmailTextStyle.java | 27 ++ .../sdk/emails/EmailTypographySettings.java | 28 ++ .../com/mailerlite/sdk/emails/Emails.java | 102 +++++ .../sdk/emails/SingleEmailDocument.java | 25 ++ .../sdk/support/ReadmeExampleBase.java | 30 ++ .../com/mailerlite/sdk/tests/EmailsTest.java | 250 +++++++++++++ .../sdk/tests/ReadmeExamplesTest.java | 348 ++++++++++++++++++ src/test/resources/responses/email.json | 81 ++++ 24 files changed, 1479 insertions(+) create mode 100644 src/main/java/com/mailerlite/sdk/emails/EmailBlock.java create mode 100644 src/main/java/com/mailerlite/sdk/emails/EmailBlockPlacement.java create mode 100644 src/main/java/com/mailerlite/sdk/emails/EmailBrandModes.java create mode 100644 src/main/java/com/mailerlite/sdk/emails/EmailBrandSettings.java create mode 100644 src/main/java/com/mailerlite/sdk/emails/EmailButtonSettings.java create mode 100644 src/main/java/com/mailerlite/sdk/emails/EmailDocument.java create mode 100644 src/main/java/com/mailerlite/sdk/emails/EmailEditorText.java create mode 100644 src/main/java/com/mailerlite/sdk/emails/EmailFooterSettings.java create mode 100644 src/main/java/com/mailerlite/sdk/emails/EmailHeadingStyle.java create mode 100644 src/main/java/com/mailerlite/sdk/emails/EmailLayoutSettings.java create mode 100644 src/main/java/com/mailerlite/sdk/emails/EmailLinkStyle.java create mode 100644 src/main/java/com/mailerlite/sdk/emails/EmailParagraphStyle.java create mode 100644 src/main/java/com/mailerlite/sdk/emails/EmailSettings.java create mode 100644 src/main/java/com/mailerlite/sdk/emails/EmailSocialLink.java create mode 100644 src/main/java/com/mailerlite/sdk/emails/EmailTextStyle.java create mode 100644 src/main/java/com/mailerlite/sdk/emails/EmailTypographySettings.java create mode 100644 src/main/java/com/mailerlite/sdk/emails/Emails.java create mode 100644 src/main/java/com/mailerlite/sdk/emails/SingleEmailDocument.java create mode 100644 src/test/java/com/mailerlite/sdk/support/ReadmeExampleBase.java create mode 100644 src/test/java/com/mailerlite/sdk/tests/EmailsTest.java create mode 100644 src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java create mode 100644 src/test/resources/responses/email.json diff --git a/README.md b/README.md index c3d2123..a97b819 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,8 @@ MailerLite Java SDK - [Cancel a ready campaign](#cancel-a-ready-campaign) - [Delete a campaign](#delete-a-campaign) - [Get subscribers activity for a campaign](#get-subscribers-activity-for-an-campaign) + - [Emails](#emails) + - [Get an email](#get-an-email) - [Forms](#forms) - [Get a list of forms](#get-a-list-of-forms) - [Get a form](#get-a-form) @@ -960,6 +962,50 @@ class Example { } ``` +## Emails + +These methods read and change emails built with the simple editor. The ID of the email of a campaign is in `campaign.emails[0].id`. + +- IDs are strings of the digits 0 to 9. An empty or `null` ID throws `EmptyIdException`. An ID with other characters throws `InvalidIdException`. Both extend `IllegalArgumentException`, and the SDK sends no request. +- Each write needs the etag from the last response for the email, and each write returns a new etag. The etag covers the whole email, so send the writes to one email one after another. An empty or `null` etag throws `EmptyEtagException`, and the SDK sends no request. +- If the email changed after you read it, the write throws a `MailerLiteException` with `code` `409`, and `e.isVersionConflict()` is `true`. Then `e.etag` is the current etag. Get the email again, and then send the write again with the current etag. +- The API error code, for example `version_conflict`, is in `e.errorCode`. The field `e.code` is the HTTP status. +- The keys of `editorContent` are camelCase, for example `textAlign`. The SDK sends them as you give them. In a response, whole numbers are `Long` values. +- Each response has the status code in `responseStatusCode` and the headers in `headers`. + +### Get an email + +Returns the email with its design settings and its blocks, and the etag of the email. + +```java + +// import .... + +class Example { + + public void example() { + try { + + SingleEmailDocument response = this.getMailerLite().emails().get("123456"); + + EmailDocument email = response.email; + String etag = response.etag; + + for (EmailBlock block : email.blocks) { + + System.out.println(block.id + " " + block.type); + } + + System.out.println(etag); + + } catch (MailerLiteException e) { + + e.printStackTrace(); + } + } +} +``` + ## Forms ### Get a list of forms diff --git a/src/main/java/com/mailerlite/sdk/MailerLite.java b/src/main/java/com/mailerlite/sdk/MailerLite.java index 0d98a94..87987c4 100644 --- a/src/main/java/com/mailerlite/sdk/MailerLite.java +++ b/src/main/java/com/mailerlite/sdk/MailerLite.java @@ -9,6 +9,7 @@ import com.mailerlite.sdk.automations.Automations; import com.mailerlite.sdk.campaigns.Campaigns; +import com.mailerlite.sdk.emails.Emails; import com.mailerlite.sdk.fields.Fields; import com.mailerlite.sdk.forms.Forms; import com.mailerlite.sdk.groups.Groups; @@ -49,6 +50,9 @@ public class MailerLite /** The forms. */ protected Forms forms; + /** The emails. */ + protected Emails emails; + /** * Campaigns. * @@ -173,6 +177,21 @@ public Forms forms() } + /** + * Emails built with the simple editor. + * + * @return the emails + */ + public Emails emails() + { + if (emails == null) { + emails = new Emails(this); + } + + return emails; + } + + /** * Sets the MailerLite API token. * diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailBlock.java b/src/main/java/com/mailerlite/sdk/emails/EmailBlock.java new file mode 100644 index 0000000..5ba8df8 --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/EmailBlock.java @@ -0,0 +1,45 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.emails; + +import java.util.Map; + +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.annotations.SerializedName; + +/** + * A content block of an email. + */ +public class EmailBlock { + + /** The ID of the block. */ + @SerializedName("id") + public String id; + + /** The block type. See EmailBlockTypes. The API can add new types, so the SDK accepts any value. */ + @SerializedName("type") + public String type; + + /** + * The editor document of the block. + * The keys are camelCase, for example "textAlign" and "linkId". Whole numbers are Long values. + */ + @SerializedName("editor_content") + @JsonAdapter(EditorContentAdapter.class) + public Map editorContent; + + /** The blocks in this block, or null if the block has none. */ + @SerializedName("children") + public EmailBlock[] children; + + /** + * The position of the block. Only the responses for one block have it. In the blocks of an email, it is null. + */ + @SerializedName("placement") + public EmailBlockPlacement placement; +} diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailBlockPlacement.java b/src/main/java/com/mailerlite/sdk/emails/EmailBlockPlacement.java new file mode 100644 index 0000000..35ef751 --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/EmailBlockPlacement.java @@ -0,0 +1,24 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.emails; + +import com.google.gson.annotations.SerializedName; + +/** + * The position of a block among the blocks at the same level. + */ +public class EmailBlockPlacement { + + /** The ID of the block before this block, or null if this block is the first block. */ + @SerializedName("after") + public String after; + + /** The ID of the block after this block, or null if this block is the last block. */ + @SerializedName("before") + public String before; +} diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailBrandModes.java b/src/main/java/com/mailerlite/sdk/emails/EmailBrandModes.java new file mode 100644 index 0000000..8821e49 --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/EmailBrandModes.java @@ -0,0 +1,30 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.emails; + +/** + * The values of {@code EmailBrandSettings.mode}: where the palette of an email comes from. + */ +public final class EmailBrandModes { + + /** Follow the brand styles of your account. The API clears the colors of the email. */ + public static final String ACCOUNT = "account"; + + /** Use the palette of the template of the email. */ + public static final String TEMPLATE = "template"; + + /** Use the colors of the email. */ + public static final String CUSTOM = "custom"; + + /** + * Do not make instances of this class. + */ + private EmailBrandModes() + { + } +} diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailBrandSettings.java b/src/main/java/com/mailerlite/sdk/emails/EmailBrandSettings.java new file mode 100644 index 0000000..efe434e --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/EmailBrandSettings.java @@ -0,0 +1,46 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.emails; + +import com.google.gson.annotations.SerializedName; + +/** + * The palette and the font of an email. + * + * In an update, null is not sent and does not change the value. + * An empty string unsets the value, and the email then uses the value of your account. + */ +public class EmailBrandSettings { + + /** Where the palette comes from: one of the values in EmailBrandModes. */ + @SerializedName("mode") + public String mode; + + /** + * Write-only. The identifier of a preset from the brand styles, for example "preset-retro". + * The API applies the palette of the preset, and the mode then reads back as "custom". + */ + @SerializedName("style") + public String style; + + /** The font family, for example "Inter, sans-serif". */ + @SerializedName("font_family") + public String fontFamily; + + /** The background color, for example "#ffffff". */ + @SerializedName("background_color") + public String backgroundColor; + + /** The text color. */ + @SerializedName("text_color") + public String textColor; + + /** The border color. */ + @SerializedName("border_color") + public String borderColor; +} diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailButtonSettings.java b/src/main/java/com/mailerlite/sdk/emails/EmailButtonSettings.java new file mode 100644 index 0000000..99768d1 --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/EmailButtonSettings.java @@ -0,0 +1,43 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.emails; + +import com.google.gson.annotations.SerializedName; + +/** + * The button style of an email. Send all values as strings, for example "4" and not 4. + * + * In an update, null is not sent and does not change the value. + * An empty string unsets the value, and the email then uses the value of your account. + */ +public class EmailButtonSettings { + + /** The font family. */ + @SerializedName("font_family") + public String fontFamily; + + /** The font weight, for example "600". */ + @SerializedName("font_weight") + public String fontWeight; + + /** The letter spacing, for example "0". */ + @SerializedName("letter_spacing") + public String letterSpacing; + + /** The background color. */ + @SerializedName("background_color") + public String backgroundColor; + + /** The text color. */ + @SerializedName("text_color") + public String textColor; + + /** The border radius in pixels, without a unit, for example "4". */ + @SerializedName("border_radius") + public String borderRadius; +} diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailDocument.java b/src/main/java/com/mailerlite/sdk/emails/EmailDocument.java new file mode 100644 index 0000000..b9b86e1 --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/EmailDocument.java @@ -0,0 +1,42 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.emails; + +import com.google.gson.annotations.SerializedName; + +/** + * An email built with the simple editor: its design settings and its content blocks. + */ +public class EmailDocument { + + /** The ID of the email. */ + @SerializedName("id") + public String id; + + /** The type of the email, for example "builder_inline_email". */ + @SerializedName("type") + public String type; + + /** The type of the owner of the email, for example "campaigns". */ + @SerializedName("emailable_type") + public String emailableType; + + /** The ID of the owner of the email, for example the campaign ID. */ + @SerializedName("emailable_id") + public String emailableId; + + /** + * The design settings, as they apply to the email. They include the values that the email inherits from your account. + */ + @SerializedName("settings") + public EmailSettings settings; + + /** The content blocks, in the sequence in which they show in the email. */ + @SerializedName("blocks") + public EmailBlock[] blocks; +} diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailEditorText.java b/src/main/java/com/mailerlite/sdk/emails/EmailEditorText.java new file mode 100644 index 0000000..1adb19f --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/EmailEditorText.java @@ -0,0 +1,25 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.emails; + +import java.util.Map; + +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.annotations.SerializedName; + +/** + * A footer text as an editor document. In an update, an empty editorContent map unsets the text. + * The email then uses the text of your account. + */ +public class EmailEditorText { + + /** The editor document. The keys are camelCase. */ + @SerializedName("editor_content") + @JsonAdapter(EditorContentAdapter.class) + public Map editorContent; +} diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailFooterSettings.java b/src/main/java/com/mailerlite/sdk/emails/EmailFooterSettings.java new file mode 100644 index 0000000..fce9f46 --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/EmailFooterSettings.java @@ -0,0 +1,53 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.emails; + +import java.util.List; + +import com.google.gson.annotations.SerializedName; + +/** + * The footer of an email. + * + * In an update, a null field is not sent and does not change. + */ +public class EmailFooterSettings { + + /** True to show the preferences link. */ + @SerializedName("show_preferences") + public Boolean showPreferences; + + /** The company name, as an editor document. */ + @SerializedName("company_name") + public EmailEditorText companyName; + + /** The address, as an editor document. */ + @SerializedName("address") + public EmailEditorText address; + + /** The information text, as an editor document. */ + @SerializedName("info_text") + public EmailEditorText infoText; + + /** The text of the unsubscribe link. It is plain text. */ + @SerializedName("unsubscribe_text") + public String unsubscribeText; + + /** The text of the preferences link. It is plain text. */ + @SerializedName("preferences_text") + public String preferencesText; + + /** + * The social links. + * + * An update replaces the whole list. Send all the links that you want to keep, in the sequence that you want. + * An empty list removes all the links. Keep the linkId of each link to keep its click history. + */ + @SerializedName("social_links") + public List socialLinks; +} diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailHeadingStyle.java b/src/main/java/com/mailerlite/sdk/emails/EmailHeadingStyle.java new file mode 100644 index 0000000..e40e3f9 --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/EmailHeadingStyle.java @@ -0,0 +1,47 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.emails; + +import com.google.gson.annotations.SerializedName; + +/** + * The heading styles of an email. Send all values as strings, for example "700" and not 700. + * + * In an update, null is not sent and does not change the value. + * An empty string unsets the value, and the email then uses the value of your account. + */ +public class EmailHeadingStyle { + + /** The font family. */ + @SerializedName("font_family") + public String fontFamily; + + /** The font weight, for example "700". */ + @SerializedName("font_weight") + public String fontWeight; + + /** The line height, for example "150%". */ + @SerializedName("line_height") + public String lineHeight; + + /** The letter spacing, for example "0". */ + @SerializedName("letter_spacing") + public String letterSpacing; + + /** The style of level 1 headings. */ + @SerializedName("h1") + public EmailTextStyle h1; + + /** The style of level 2 headings. */ + @SerializedName("h2") + public EmailTextStyle h2; + + /** The style of level 3 headings. */ + @SerializedName("h3") + public EmailTextStyle h3; +} diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailLayoutSettings.java b/src/main/java/com/mailerlite/sdk/emails/EmailLayoutSettings.java new file mode 100644 index 0000000..269b5c5 --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/EmailLayoutSettings.java @@ -0,0 +1,27 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.emails; + +import com.google.gson.annotations.SerializedName; + +/** + * The layout of an email. + * + * In an update, null is not sent and does not change the value. + * An empty string unsets the value, and the email then uses the value of your account. + */ +public class EmailLayoutSettings { + + /** The width mode, for example "default". */ + @SerializedName("width_mode") + public String widthMode; + + /** The alignment of the email, for example "center". */ + @SerializedName("alignment") + public String alignment; +} diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailLinkStyle.java b/src/main/java/com/mailerlite/sdk/emails/EmailLinkStyle.java new file mode 100644 index 0000000..4b47f1c --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/EmailLinkStyle.java @@ -0,0 +1,23 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.emails; + +import com.google.gson.annotations.SerializedName; + +/** + * The link style of an email. + * + * In an update, null is not sent and does not change the value. + * An empty string unsets the value, and the email then uses the value of your account. + */ +public class EmailLinkStyle { + + /** The link color, for example "#2cb191". */ + @SerializedName("color") + public String color; +} diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailParagraphStyle.java b/src/main/java/com/mailerlite/sdk/emails/EmailParagraphStyle.java new file mode 100644 index 0000000..3d5276b --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/EmailParagraphStyle.java @@ -0,0 +1,39 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.emails; + +import com.google.gson.annotations.SerializedName; + +/** + * The paragraph styles of an email. Send all values as strings, for example "400" and not 400. + * + * In an update, null is not sent and does not change the value. + * An empty string unsets the value, and the email then uses the value of your account. + */ +public class EmailParagraphStyle { + + /** The font family. */ + @SerializedName("font_family") + public String fontFamily; + + /** The font weight, for example "400". */ + @SerializedName("font_weight") + public String fontWeight; + + /** The line height, for example "150%". */ + @SerializedName("line_height") + public String lineHeight; + + /** The letter spacing, for example "0". */ + @SerializedName("letter_spacing") + public String letterSpacing; + + /** The style of paragraphs. */ + @SerializedName("p1") + public EmailTextStyle p1; +} diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailSettings.java b/src/main/java/com/mailerlite/sdk/emails/EmailSettings.java new file mode 100644 index 0000000..5d3f1b0 --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/EmailSettings.java @@ -0,0 +1,43 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.emails; + +import com.google.gson.annotations.SerializedName; + +/** + * The design settings of an email. + * + * In a response, the settings show the values that apply to the email, also the values that the email + * inherits from your account. In an update, set only the fields that you want to change. A null field is not sent. + * An empty string unsets a value. + * + * Do not send back all the settings that you read. The API stores each value that you send, also an inherited + * value. The email then no longer follows your account for that value. + */ +public class EmailSettings { + + /** The palette and the font of the email. */ + @SerializedName("brand") + public EmailBrandSettings brand; + + /** The layout of the email. */ + @SerializedName("layout") + public EmailLayoutSettings layout; + + /** The text styles of the email. */ + @SerializedName("typography") + public EmailTypographySettings typography; + + /** The button style of the email. */ + @SerializedName("button") + public EmailButtonSettings button; + + /** The footer of the email. */ + @SerializedName("footer") + public EmailFooterSettings footer; +} diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailSocialLink.java b/src/main/java/com/mailerlite/sdk/emails/EmailSocialLink.java new file mode 100644 index 0000000..d11ac23 --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/EmailSocialLink.java @@ -0,0 +1,36 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.emails; + +import com.google.gson.annotations.SerializedName; + +/** + * A social link in the footer of an email. + */ +public class EmailSocialLink { + + /** The URL of the link. */ + @SerializedName("url") + public String url; + + /** The network of the link, for example "website". */ + @SerializedName("type") + public String type; + + /** The label of the link. */ + @SerializedName("label") + public String label; + + /** + * The ID that the click reports use. + * + * Send it back without a change. Without it, the API makes a new link, and the click history of the old link is lost. + */ + @SerializedName("link_id") + public Long linkId; +} diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailTextStyle.java b/src/main/java/com/mailerlite/sdk/emails/EmailTextStyle.java new file mode 100644 index 0000000..2c16dcc --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/EmailTextStyle.java @@ -0,0 +1,27 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.emails; + +import com.google.gson.annotations.SerializedName; + +/** + * The color and the size of a text style. Send the size as a string, for example "16" and not 16. + * + * In an update, null is not sent and does not change the value. + * An empty string unsets the value, and the email then uses the value of your account. + */ +public class EmailTextStyle { + + /** The text color, for example "#000000". */ + @SerializedName("color") + public String color; + + /** The text size in pixels, without a unit, for example "16". */ + @SerializedName("size") + public String size; +} diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailTypographySettings.java b/src/main/java/com/mailerlite/sdk/emails/EmailTypographySettings.java new file mode 100644 index 0000000..e33facb --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/EmailTypographySettings.java @@ -0,0 +1,28 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.emails; + +import com.google.gson.annotations.SerializedName; + +/** + * The text styles of an email. + */ +public class EmailTypographySettings { + + /** The link style. */ + @SerializedName("link") + public EmailLinkStyle link; + + /** The heading styles. */ + @SerializedName("heading") + public EmailHeadingStyle heading; + + /** The paragraph styles. */ + @SerializedName("paragraph") + public EmailParagraphStyle paragraph; +} diff --git a/src/main/java/com/mailerlite/sdk/emails/Emails.java b/src/main/java/com/mailerlite/sdk/emails/Emails.java new file mode 100644 index 0000000..6e2cc0c --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/Emails.java @@ -0,0 +1,102 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.emails; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.mailerlite.sdk.MailerLite; +import com.mailerlite.sdk.MailerLiteApi; +import com.mailerlite.sdk.MailerLiteResponse; +import com.mailerlite.sdk.exceptions.MailerLiteException; +import com.mailerlite.sdk.util.RequestChecks; + +/** + * The endpoints for emails built with the simple editor. + *

+ * Each write must send the etag from the last response for the email, and each write returns a new etag. + * The etag covers the whole email, so send the writes to one email one after another. If the email changed + * after you read it, the write throws a {@link MailerLiteException} with + * {@link MailerLiteException#isVersionConflict()} set to true and the current etag in + * {@link MailerLiteException#etag}. + *

+ * The methods check the IDs and the etag before they send a request. An empty or null ID throws + * {@link com.mailerlite.sdk.exceptions.EmptyIdException}. An ID that contains a character other than the + * digits 0 to 9 throws {@link com.mailerlite.sdk.exceptions.InvalidIdException}. + */ +public class Emails { + + /** The Gson object for the request bodies. */ + private static final Gson GSON = new GsonBuilder().disableHtmlEscaping().create(); + + /** The api object reference. */ + private MailerLite apiObjectReference; + + /** + * Instantiates a new emails service. + * + * @param apiReference the api reference + */ + public Emails(MailerLite apiReference) + { + apiObjectReference = apiReference; + } + + /** + * Gets an email with its design settings and its blocks. + * + * @param emailId the email ID + * @return the email and its etag + * @throws MailerLiteException if the API returns an error or the request fails + */ + public SingleEmailDocument get(String emailId) throws MailerLiteException + { + return send("GET", emailPath(emailId), null, SingleEmailDocument.class); + } + + /** + * Checks the email ID and makes the path of the email. + * + * @param emailId the email ID + * @return the path + */ + private static String emailPath(String emailId) + { + return "/emails/".concat(RequestChecks.pathId(emailId, "emailId")); + } + + /** + * Converts a request body to JSON. + * + * @param body the body + * @return the JSON + */ + private static String toJson(Object body) + { + return GSON.toJson(body); + } + + /** + * Sends a request. + * + * @param the response type + * @param method the HTTP method + * @param endpoint the endpoint + * @param body the JSON body, or null + * @param responseClass the response class + * @return the response + * @throws MailerLiteException if the API returns an error or the request fails + */ + private T send(String method, String endpoint, String body, Class responseClass) + throws MailerLiteException + { + MailerLiteApi api = new MailerLiteApi(); + api.setToken(apiObjectReference.getToken()); + + return api.sendRequest(method, endpoint, body, null, responseClass); + } +} diff --git a/src/main/java/com/mailerlite/sdk/emails/SingleEmailDocument.java b/src/main/java/com/mailerlite/sdk/emails/SingleEmailDocument.java new file mode 100644 index 0000000..d6cab76 --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/SingleEmailDocument.java @@ -0,0 +1,25 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.emails; + +import com.google.gson.annotations.SerializedName; +import com.mailerlite.sdk.MailerLiteResponse; + +/** + * The response of the endpoints that return a full email. + */ +public class SingleEmailDocument extends MailerLiteResponse { + + /** The email. */ + @SerializedName("data") + public EmailDocument email; + + /** The etag of the email. Send it with the next write to the email. */ + @SerializedName("etag") + public String etag; +} diff --git a/src/test/java/com/mailerlite/sdk/support/ReadmeExampleBase.java b/src/test/java/com/mailerlite/sdk/support/ReadmeExampleBase.java new file mode 100644 index 0000000..391502a --- /dev/null +++ b/src/test/java/com/mailerlite/sdk/support/ReadmeExampleBase.java @@ -0,0 +1,30 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.support; + +import com.mailerlite.sdk.MailerLite; + +/** + * The base class of the README examples in ReadmeExamplesTest. It gives getMailerLite(), as the + * Usage section of the README shows. + */ +public class ReadmeExampleBase { + + /** + * Gets a MailerLite object with a test token. + * + * @return the MailerLite object + */ + public MailerLite getMailerLite() + { + MailerLite ml = new MailerLite(); + ml.setToken("test-token"); + + return ml; + } +} diff --git a/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java b/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java new file mode 100644 index 0000000..fa0f85a --- /dev/null +++ b/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java @@ -0,0 +1,250 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.function.Function; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.function.Executable; + +import com.mailerlite.sdk.MailerLite; +import com.mailerlite.sdk.emails.EmailBlock; +import com.mailerlite.sdk.emails.EmailDocument; +import com.mailerlite.sdk.emails.EmailSettings; +import com.mailerlite.sdk.emails.EmailSocialLink; +import com.mailerlite.sdk.emails.Emails; +import com.mailerlite.sdk.emails.SingleEmailDocument; +import com.mailerlite.sdk.exceptions.EmptyIdException; +import com.mailerlite.sdk.exceptions.InvalidIdException; +import com.mailerlite.sdk.support.Fixtures; +import com.mailerlite.sdk.support.RecordingHttpClient; +import com.mailerlite.sdk.support.RecordingHttpClient.RecordedRequest; + +/** + * Tests the email endpoints with the recording HTTP client. + */ +public class EmailsTest { + + /** The base URL of the API. */ + static final String BASE = "https://connect.mailerlite.com/api"; + + /** The fake HTTP client. */ + private RecordingHttpClient client; + + /** The email service. */ + private Emails emails; + + /** + * Sets the fake HTTP client. + */ + @BeforeEach + public void setupEach() + { + client = RecordingHttpClient.install(); + + MailerLite ml = new MailerLite(); + ml.setToken("test-token"); + emails = ml.emails(); + } + + /** + * Removes the fake HTTP client. + */ + @AfterEach + public void afterEach() + { + RecordingHttpClient.uninstall(); + } + + /** + * Asserts the one request that the client got: the method, the full URL, all the headers and the raw body. + * + * @param client the fake HTTP client + * @param method the HTTP method + * @param path the path and the query string, after the base URL + * @param body the exact raw body, or null for a request without a body publisher + * @param idempotencyKey the expected Idempotency-Key header, or null for no header + * @return the request + */ + static RecordedRequest assertRequest(RecordingHttpClient client, String method, String path, String body, + String idempotencyKey) + { + assertEquals(1, client.requests().size(), "number of requests"); + + RecordedRequest request = client.lastRequest(); + assertEquals(method, request.method); + assertEquals(BASE + path, request.uri.toString()); + + Set expectedHeaders = new TreeSet(String.CASE_INSENSITIVE_ORDER); + expectedHeaders.addAll(Arrays.asList("Content-Type", "Accept", "Authorization")); + + if (idempotencyKey != null) { + expectedHeaders.add("Idempotency-Key"); + } + + Set actualHeaders = new TreeSet(String.CASE_INSENSITIVE_ORDER); + actualHeaders.addAll(request.headers.map().keySet()); + assertEquals(expectedHeaders, actualHeaders); + + assertEquals("application/json", request.header("Content-Type")); + assertEquals("application/json", request.header("Accept")); + assertEquals("Bearer test-token", request.header("Authorization")); + assertEquals(idempotencyKey, request.header("Idempotency-Key")); + + if (body == null) { + assertFalse(request.hasBodyPublisher, "the request must have no body"); + } else { + assertEquals(body, request.bodyString()); + } + + return request; + } + + /** + * Asserts that each empty and each invalid ID throws the correct exception, and that no request is sent. + * + * @param client the fake HTTP client + * @param call makes the call for an ID + */ + static void assertRejectsIds(RecordingHttpClient client, Function call) + { + for (String id : Arrays.asList("", null)) { + assertThrows(EmptyIdException.class, call.apply(id), "ID " + id); + } + + for (String id : RequestChecksTest.INVALID_IDS) { + assertThrows(InvalidIdException.class, call.apply(id), "ID " + id); + } + + assertTrue(client.requests().isEmpty(), "the SDK must send no request"); + } + + /** + * Gets a map value as a map. + * + * @param map the map + * @param key the key + * @return the value + */ + @SuppressWarnings("unchecked") + static Map map(Map map, String key) + { + return (Map) map.get(key); + } + + /** + * get sends GET /emails/{email} and decodes the email, the settings and the blocks. + * + * @throws Exception if the request fails + */ + @Test + public void getDecodesTheEmail() throws Exception + { + client.respond(200, Fixtures.read("email.json")); + + SingleEmailDocument response = emails.get("123"); + + assertRequest(client, "GET", "/emails/123", null, null); + assertEquals(200, response.responseStatusCode); + assertEquals("42", response.etag); + + EmailDocument email = response.email; + assertEquals("123", email.id); + assertEquals("builder_inline_email", email.type); + assertEquals("campaigns", email.emailableType); + assertEquals("456", email.emailableId); + + EmailSettings settings = email.settings; + assertEquals("account", settings.brand.mode); + assertNull(settings.brand.style); + assertEquals("Inter, sans-serif", settings.brand.fontFamily); + assertEquals("#ffffff", settings.brand.backgroundColor); + assertEquals("#000000", settings.brand.textColor); + assertEquals("#e5e7eb", settings.brand.borderColor); + assertEquals("default", settings.layout.widthMode); + assertEquals("center", settings.layout.alignment); + assertEquals("#2cb191", settings.typography.link.color); + assertEquals("Inter, sans-serif", settings.typography.heading.fontFamily); + assertEquals("700", settings.typography.heading.fontWeight); + assertEquals("150%", settings.typography.heading.lineHeight); + assertEquals("0", settings.typography.heading.letterSpacing); + assertEquals("32", settings.typography.heading.h1.size); + assertEquals("24", settings.typography.heading.h2.size); + assertEquals("20", settings.typography.heading.h3.size); + assertEquals("#000000", settings.typography.heading.h3.color); + assertEquals("400", settings.typography.paragraph.fontWeight); + assertEquals("16", settings.typography.paragraph.p1.size); + assertEquals("Inter, sans-serif", settings.button.fontFamily); + assertEquals("600", settings.button.fontWeight); + assertEquals("0", settings.button.letterSpacing); + assertEquals("#2cb191", settings.button.backgroundColor); + assertEquals("#ffffff", settings.button.textColor); + assertEquals("4", settings.button.borderRadius); + assertEquals(Boolean.FALSE, settings.footer.showPreferences); + assertEquals("Unsubscribe", settings.footer.unsubscribeText); + assertEquals("Update your preferences", settings.footer.preferencesText); + + @SuppressWarnings("unchecked") + List> companyName = (List>) settings.footer.companyName.editorContent.get("content"); + assertEquals("Acme", companyName.get(0).get("text")); + + @SuppressWarnings("unchecked") + List> address = (List>) settings.footer.address.editorContent.get("content"); + assertEquals("1 Main Street", address.get(0).get("text")); + + @SuppressWarnings("unchecked") + List> infoText = (List>) settings.footer.infoText.editorContent.get("content"); + assertEquals("You signed up on our website.", infoText.get(0).get("text")); + + assertEquals(1, settings.footer.socialLinks.size()); + EmailSocialLink link = settings.footer.socialLinks.get(0); + assertEquals("https://example.com", link.url); + assertEquals("website", link.type); + assertEquals("Website", link.label); + assertEquals(Long.valueOf(7), link.linkId); + + assertEquals(2, email.blocks.length); + EmailBlock paragraph = email.blocks[0]; + assertEquals("1", paragraph.id); + assertEquals("paragraph", paragraph.type); + assertEquals("left", map(paragraph.editorContent, "attrs").get("textAlign")); + assertNull(paragraph.children); + assertNull(paragraph.placement); + + EmailBlock list = email.blocks[1]; + assertEquals("2", list.id); + assertEquals("bulleted_list", list.type); + assertEquals(Collections.emptyMap(), list.editorContent); + assertEquals(1, list.children.length); + assertEquals("3", list.children[0].id); + assertEquals("paragraph", list.children[0].type); + } + + /** + * get checks the email ID and sends no request for an invalid ID. + */ + @Test + public void getRejectsInvalidIds() + { + assertRejectsIds(client, id -> () -> emails.get(id)); + } +} diff --git a/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java b/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java new file mode 100644 index 0000000..a917b54 --- /dev/null +++ b/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java @@ -0,0 +1,348 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.DynamicTest.dynamicTest; + +import java.io.File; +import java.lang.reflect.InvocationTargetException; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import javax.tools.Diagnostic; +import javax.tools.DiagnosticCollector; +import javax.tools.JavaCompiler; +import javax.tools.JavaFileObject; +import javax.tools.StandardJavaFileManager; +import javax.tools.ToolProvider; + +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.TestFactory; + +import com.google.gson.Gson; +import com.mailerlite.sdk.MailerLite; +import com.mailerlite.sdk.support.Fixtures; +import com.mailerlite.sdk.support.ReadmeExampleBase; +import com.mailerlite.sdk.support.RecordingHttpClient; +import com.mailerlite.sdk.support.RecordingHttpClient.RecordedRequest; + +/** + * Compiles the examples of the new README sections and runs each one against the recording HTTP client. + *

+ * Each example must send the expected requests and use all the queued responses. A MailerLiteException + * in an example fails the test, because the test changes "e.printStackTrace();" to a throw. + */ +public class ReadmeExamplesTest { + + /** The base URL of the API. */ + private static final String BASE = "https://connect.mailerlite.com/api"; + + /** The imports that the README examples get. The README shows them as "// import ....". */ + private static final List IMPORTS = Arrays.asList( + "com.mailerlite.sdk.*", + "com.mailerlite.sdk.campaigns.*", + "com.mailerlite.sdk.emails.*", + "com.mailerlite.sdk.exceptions.*", + "java.util.*"); + + /** + * A README example: the heading of its section, the responses to queue and the expected requests. + */ + private static class Example { + + /** The heading of the README section, without "### ". */ + final String heading; + + /** The responses to queue. */ + final List responses = new ArrayList(); + + /** The expected requests, as "METHOD /path?query". */ + final List requests = new ArrayList(); + + /** + * Instantiates a new example. + * + * @param heading the heading + */ + Example(String heading) + { + this.heading = heading; + } + + /** + * Queues a response. + * + * @param status the status + * @param body the body + * @return this example + */ + Example respond(int status, String body) + { + return respond(status, body, Collections.emptyMap()); + } + + /** + * Queues a response with headers. + * + * @param status the status + * @param body the body + * @param headers the headers + * @return this example + */ + Example respond(int status, String body, Map headers) + { + responses.add(new Object[] {status, body, headers}); + return this; + } + + /** + * Queues a network error. + * + * @param error the error + * @return this example + */ + Example fail(Exception error) + { + responses.add(new Object[] {error}); + return this; + } + + /** + * Adds an expected request. + * + * @param request the request, as "METHOD /path?query" + * @return this example + */ + Example expect(String request) + { + requests.add(request); + return this; + } + } + + /** + * The examples to run, one for each new README section. + * + * @return the examples + */ + private static List examples() + { + List list = new ArrayList(); + + list.add(new Example("Get an email") + .respond(200, Fixtures.read("email.json")) + .expect("GET /emails/123456")); + + return list; + } + + /** + * Compiles and runs the README examples. + * + * @return a test for each example + * @throws Exception if the README cannot be read or the examples do not compile + */ + @TestFactory + public List readmeExamplesRun() throws Exception + { + String readme = new String(Files.readAllBytes(Paths.get("README.md")), StandardCharsets.UTF_8); + List examples = examples(); + + Path dir = Files.createTempDirectory("readme-examples"); + Path packageDir = Files.createDirectories(dir.resolve("readme")); + List sources = new ArrayList(); + + for (int i = 0; i < examples.size(); i++) { + + String code = extractCode(readme, examples.get(i).heading); + Path source = packageDir.resolve("Example" + i + ".java"); + Files.write(source, toSource(code, "Example" + i).getBytes(StandardCharsets.UTF_8)); + sources.add(source.toFile()); + } + + compile(dir, sources); + + URLClassLoader loader = new URLClassLoader(new URL[] {dir.toUri().toURL()}, getClass().getClassLoader()); + List tests = new ArrayList(); + + for (int i = 0; i < examples.size(); i++) { + + Example example = examples.get(i); + String className = "readme.Example" + i; + + tests.add(dynamicTest(example.heading, () -> run(loader, className, example))); + } + + return tests; + } + + /** + * Runs one compiled example and checks its requests. + * + * @param loader the class loader of the compiled examples + * @param className the class name + * @param example the example + * @throws Throwable if the example fails + */ + private void run(ClassLoader loader, String className, Example example) throws Throwable + { + RecordingHttpClient client = RecordingHttpClient.install(); + + try { + for (Object[] response : example.responses) { + + if (response.length == 1) { + client.fail((Exception) response[0]); + } else { + @SuppressWarnings("unchecked") + Map headers = (Map) response[2]; + client.respond((Integer) response[0], (String) response[1], headers); + } + } + + Object instance = loader.loadClass(className).getDeclaredConstructor().newInstance(); + + try { + instance.getClass().getMethod("example").invoke(instance); + } catch (InvocationTargetException e) { + throw e.getCause(); + } + + List sent = new ArrayList(); + + for (RecordedRequest request : client.requests()) { + sent.add(request.method + " " + request.uri.toString().substring(BASE.length())); + } + + assertEquals(example.requests, sent, "requests of the example"); + assertEquals(0, client.pendingResponses(), "the example must use all the queued responses"); + } finally { + RecordingHttpClient.uninstall(); + } + } + + /** + * Finds the first Java code block of a README section. + * + * @param readme the README + * @param heading the heading of the section, without "### " + * @return the code + */ + private static String extractCode(String readme, String heading) + { + List lines = Arrays.asList(readme.split("\n", -1)); + int start = lines.indexOf("### " + heading); + + if (start < 0) { + throw new AssertionError("The README has no section \"### " + heading + "\""); + } + + StringBuilder code = new StringBuilder(); + boolean inCode = false; + + for (int i = start + 1; i < lines.size(); i++) { + + String line = lines.get(i); + + if (!inCode && line.startsWith("#")) { + break; + } + + if (!inCode && line.equals("```java")) { + inCode = true; + } else if (inCode && line.equals("```")) { + return code.toString(); + } else if (inCode) { + code.append(line).append("\n"); + } + } + + throw new AssertionError("The README section \"### " + heading + "\" has no Java code block"); + } + + /** + * Makes a compilable class from a README example. + * + * @param code the example code + * @param className the class name + * @return the source + */ + private static String toSource(String code, String className) + { + if (!code.contains("class Example {") || !code.contains("public void example()")) { + throw new AssertionError("A README example must have \"class Example {\" and \"public void example()\":\n" + code); + } + + StringBuilder source = new StringBuilder("package readme;\n\n"); + + for (String name : IMPORTS) { + source.append("import ").append(name).append(";\n"); + } + + source.append("\n"); + source.append(code + .replace("class Example {", "public class " + className + " extends " + ReadmeExampleBase.class.getName() + " {") + .replace("e.printStackTrace();", "throw new RuntimeException(e);")); + + return source.toString(); + } + + /** + * Compiles the examples for Java 11. + * + * @param dir the output folder + * @param sources the source files + * @throws Exception if the compiler fails + */ + private static void compile(Path dir, List sources) throws Exception + { + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + DiagnosticCollector diagnostics = new DiagnosticCollector(); + + String classPath = String.join(File.pathSeparator, + location(MailerLite.class), location(ReadmeExampleBase.class), location(Gson.class)); + + try (StandardJavaFileManager files = compiler.getStandardFileManager(diagnostics, null, StandardCharsets.UTF_8)) { + + List options = Arrays.asList("--release", "11", "-classpath", classPath, "-d", dir.toString()); + boolean ok = compiler.getTask(null, files, diagnostics, options, null, files.getJavaFileObjectsFromFiles(sources)).call(); + + if (!ok) { + StringBuilder message = new StringBuilder("The README examples do not compile:\n"); + + for (Diagnostic diagnostic : diagnostics.getDiagnostics()) { + message.append(diagnostic.toString()).append("\n"); + } + + throw new AssertionError(message.toString()); + } + } + } + + /** + * Gets the class path entry of a class. + * + * @param type the class + * @return the path of its folder or JAR file + * @throws Exception if the location is not a file + */ + private static String location(Class type) throws Exception + { + return Paths.get(type.getProtectionDomain().getCodeSource().getLocation().toURI()).toString(); + } +} diff --git a/src/test/resources/responses/email.json b/src/test/resources/responses/email.json new file mode 100644 index 0000000..2762d5b --- /dev/null +++ b/src/test/resources/responses/email.json @@ -0,0 +1,81 @@ +{ + "data": { + "id": "123", + "type": "builder_inline_email", + "emailable_type": "campaigns", + "emailable_id": "456", + "settings": { + "brand": { + "mode": "account", + "font_family": "Inter, sans-serif", + "background_color": "#ffffff", + "text_color": "#000000", + "border_color": "#e5e7eb" + }, + "layout": {"width_mode": "default", "alignment": "center"}, + "typography": { + "link": {"color": "#2cb191"}, + "heading": { + "font_family": "Inter, sans-serif", + "font_weight": "700", + "line_height": "150%", + "letter_spacing": "0", + "h1": {"color": "#000000", "size": "32"}, + "h2": {"color": "#000000", "size": "24"}, + "h3": {"color": "#000000", "size": "20"} + }, + "paragraph": { + "font_family": "Inter, sans-serif", + "font_weight": "400", + "line_height": "150%", + "letter_spacing": "0", + "p1": {"color": "#000000", "size": "16"} + } + }, + "button": { + "font_family": "Inter, sans-serif", + "font_weight": "600", + "letter_spacing": "0", + "background_color": "#2cb191", + "text_color": "#ffffff", + "border_radius": "4" + }, + "footer": { + "show_preferences": false, + "company_name": { + "editor_content": { + "content": [{"type": "text", "text": "Acme", "marks": [{"type": "bold"}]}] + } + }, + "address": { + "editor_content": {"content": [{"text": "1 Main Street", "type": "text"}]} + }, + "info_text": { + "editor_content": {"content": [{"text": "You signed up on our website.", "type": "text"}]} + }, + "unsubscribe_text": "Unsubscribe", + "preferences_text": "Update your preferences", + "social_links": [ + {"url": "https://example.com", "type": "website", "label": "Website", "link_id": 7} + ] + } + }, + "blocks": [ + { + "id": "1", + "type": "paragraph", + "editor_content": { + "attrs": {"textAlign": "left"}, + "content": [{"text": "Hello", "type": "text"}] + } + }, + { + "id": "2", + "type": "bulleted_list", + "editor_content": {}, + "children": [{"id": "3", "type": "paragraph", "editor_content": {}}] + } + ] + }, + "etag": "42" +} From cec395b75370281738dfbddf0e5df0f1b4cabbcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Thu, 29 Oct 2026 16:18:42 +0100 Subject: [PATCH 08/35] feat: add Emails.getHtml Emails.getHtml returns the HTML preview of an email and its etag. The HTML is not ready to send. The API can return a temporary 503 error with a body that is not JSON. Co-Authored-By: Claude Opus 5.5 --- README.md | 31 +++++++++++++ .../com/mailerlite/sdk/emails/EmailHtml.java | 23 ++++++++++ .../com/mailerlite/sdk/emails/Emails.java | 14 ++++++ .../sdk/emails/SingleEmailHtml.java | 25 +++++++++++ .../com/mailerlite/sdk/tests/EmailsTest.java | 43 +++++++++++++++++++ .../sdk/tests/ReadmeExamplesTest.java | 4 ++ src/test/resources/responses/email_html.json | 1 + 7 files changed, 141 insertions(+) create mode 100644 src/main/java/com/mailerlite/sdk/emails/EmailHtml.java create mode 100644 src/main/java/com/mailerlite/sdk/emails/SingleEmailHtml.java create mode 100644 src/test/resources/responses/email_html.json diff --git a/README.md b/README.md index a97b819..7acd462 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ MailerLite Java SDK - [Get subscribers activity for a campaign](#get-subscribers-activity-for-an-campaign) - [Emails](#emails) - [Get an email](#get-an-email) + - [Get the HTML of an email](#get-the-html-of-an-email) - [Forms](#forms) - [Get a list of forms](#get-a-list-of-forms) - [Get a form](#get-a-form) @@ -1006,6 +1007,36 @@ class Example { } ``` +### Get the HTML of an email + +Returns the HTML of the email as a preview. The HTML is not ready to send: personalization shows as tokens such as `{$name}`, and the links have markers instead of tracking URLs. The API can return a temporary `503` error. If it does, try again later. + +```java + +// import .... + +class Example { + + public void example() { + try { + + SingleEmailHtml response = this.getMailerLite().emails().getHtml("123456"); + + System.out.println(response.preview.html); + + } catch (MailerLiteException e) { + + if (e.code == 503) { + + System.out.println("Try again later"); + } + + e.printStackTrace(); + } + } +} +``` + ## Forms ### Get a list of forms diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailHtml.java b/src/main/java/com/mailerlite/sdk/emails/EmailHtml.java new file mode 100644 index 0000000..cf80352 --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/EmailHtml.java @@ -0,0 +1,23 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.emails; + +import com.google.gson.annotations.SerializedName; + +/** + * The HTML of an email. + * + * The HTML is a preview, not an email that is ready to send. Personalization shows as tokens such as {$name}, + * and the links have markers instead of tracking URLs. Do not send this HTML yourself. + */ +public class EmailHtml { + + /** The HTML of the email, as one string. */ + @SerializedName("html") + public String html; +} diff --git a/src/main/java/com/mailerlite/sdk/emails/Emails.java b/src/main/java/com/mailerlite/sdk/emails/Emails.java index 6e2cc0c..ccbc9b7 100644 --- a/src/main/java/com/mailerlite/sdk/emails/Emails.java +++ b/src/main/java/com/mailerlite/sdk/emails/Emails.java @@ -58,6 +58,20 @@ public SingleEmailDocument get(String emailId) throws MailerLiteException return send("GET", emailPath(emailId), null, SingleEmailDocument.class); } + /** + * Gets the HTML of an email, as a preview. The HTML is not ready to send. + *

+ * The API can return a temporary 503 error with a body that is not JSON. If it does, try again later. + * + * @param emailId the email ID + * @return the HTML and the etag of the email + * @throws MailerLiteException if the API returns an error or the request fails + */ + public SingleEmailHtml getHtml(String emailId) throws MailerLiteException + { + return send("GET", emailPath(emailId).concat("/html"), null, SingleEmailHtml.class); + } + /** * Checks the email ID and makes the path of the email. * diff --git a/src/main/java/com/mailerlite/sdk/emails/SingleEmailHtml.java b/src/main/java/com/mailerlite/sdk/emails/SingleEmailHtml.java new file mode 100644 index 0000000..a0da351 --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/SingleEmailHtml.java @@ -0,0 +1,25 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.emails; + +import com.google.gson.annotations.SerializedName; +import com.mailerlite.sdk.MailerLiteResponse; + +/** + * The response of Emails.getHtml. + */ +public class SingleEmailHtml extends MailerLiteResponse { + + /** The HTML preview of the email. */ + @SerializedName("data") + public EmailHtml preview; + + /** The etag of the email. Send it with the next write to the email. */ + @SerializedName("etag") + public String etag; +} diff --git a/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java b/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java index fa0f85a..706e816 100644 --- a/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java +++ b/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java @@ -33,8 +33,10 @@ import com.mailerlite.sdk.emails.EmailSocialLink; import com.mailerlite.sdk.emails.Emails; import com.mailerlite.sdk.emails.SingleEmailDocument; +import com.mailerlite.sdk.emails.SingleEmailHtml; import com.mailerlite.sdk.exceptions.EmptyIdException; import com.mailerlite.sdk.exceptions.InvalidIdException; +import com.mailerlite.sdk.exceptions.MailerLiteException; import com.mailerlite.sdk.support.Fixtures; import com.mailerlite.sdk.support.RecordingHttpClient; import com.mailerlite.sdk.support.RecordingHttpClient.RecordedRequest; @@ -247,4 +249,45 @@ public void getRejectsInvalidIds() { assertRejectsIds(client, id -> () -> emails.get(id)); } + + /** + * getHtml sends GET /emails/{email}/html and decodes the HTML. + * + * @throws Exception if the request fails + */ + @Test + public void getHtmlDecodesTheHtml() throws Exception + { + client.respond(200, Fixtures.read("email_html.json")); + + SingleEmailHtml response = emails.getHtml("123"); + + assertRequest(client, "GET", "/emails/123/html", null, null); + assertEquals("

Hello

", response.preview.html); + assertEquals("42", response.etag); + } + + /** + * getHtml throws a MailerLiteException with the status for a temporary 503 with a body that is not JSON. + */ + @Test + public void getHtmlServiceUnavailable() + { + client.respond(503, "Service Unavailable"); + + MailerLiteException e = assertThrows(MailerLiteException.class, () -> emails.getHtml("123")); + + assertEquals(503, e.code); + assertEquals("Service Unavailable", e.responseBody); + } + + /** + * getHtml checks the email ID and sends no request for an invalid ID. + */ + @Test + public void getHtmlRejectsInvalidIds() + { + assertRejectsIds(client, id -> () -> emails.getHtml(id)); + } + } diff --git a/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java b/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java index a917b54..a118649 100644 --- a/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java +++ b/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java @@ -148,6 +148,10 @@ private static List examples() .respond(200, Fixtures.read("email.json")) .expect("GET /emails/123456")); + list.add(new Example("Get the HTML of an email") + .respond(200, Fixtures.read("email_html.json")) + .expect("GET /emails/123456/html")); + return list; } diff --git a/src/test/resources/responses/email_html.json b/src/test/resources/responses/email_html.json new file mode 100644 index 0000000..d680c41 --- /dev/null +++ b/src/test/resources/responses/email_html.json @@ -0,0 +1 @@ +{"data": {"html": "

Hello

"}, "etag": "42"} From 6a194eb00b24d136d3d9ce84c6a368e2cb0b40ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Thu, 29 Oct 2026 16:19:17 +0100 Subject: [PATCH 09/35] feat: add Emails.getBlock Emails.getBlock returns one block of an email with its placement, and the etag of the email. The method checks the email ID and the block ID before it sends the request. Co-Authored-By: Claude Opus 5.5 --- README.md | 30 ++++++++++++++++ .../com/mailerlite/sdk/emails/Emails.java | 25 ++++++++++++++ .../sdk/emails/SingleEmailBlock.java | 25 ++++++++++++++ .../com/mailerlite/sdk/tests/EmailsTest.java | 34 +++++++++++++++++++ .../sdk/tests/ReadmeExamplesTest.java | 4 +++ src/test/resources/responses/email_block.json | 12 +++++++ 6 files changed, 130 insertions(+) create mode 100644 src/main/java/com/mailerlite/sdk/emails/SingleEmailBlock.java create mode 100644 src/test/resources/responses/email_block.json diff --git a/README.md b/README.md index 7acd462..eb3a647 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ MailerLite Java SDK - [Emails](#emails) - [Get an email](#get-an-email) - [Get the HTML of an email](#get-the-html-of-an-email) + - [Get a block](#get-a-block) - [Forms](#forms) - [Get a list of forms](#get-a-list-of-forms) - [Get a form](#get-a-form) @@ -1037,6 +1038,35 @@ class Example { } ``` +### Get a block + +Returns the block and its position. `placement.after` is the ID of the block before it, and `placement.before` is the ID of the block after it. Each one is `null` if there is no such block. + +```java + +// import .... + +class Example { + + public void example() { + try { + + SingleEmailBlock response = this.getMailerLite().emails().getBlock("123456", "57"); + + EmailBlock block = response.block; + + System.out.println(block.type); + System.out.println(block.editorContent); + System.out.println(block.placement.after); + + } catch (MailerLiteException e) { + + e.printStackTrace(); + } + } +} +``` + ## Forms ### Get a list of forms diff --git a/src/main/java/com/mailerlite/sdk/emails/Emails.java b/src/main/java/com/mailerlite/sdk/emails/Emails.java index ccbc9b7..2a08922 100644 --- a/src/main/java/com/mailerlite/sdk/emails/Emails.java +++ b/src/main/java/com/mailerlite/sdk/emails/Emails.java @@ -72,6 +72,19 @@ public SingleEmailHtml getHtml(String emailId) throws MailerLiteException return send("GET", emailPath(emailId).concat("/html"), null, SingleEmailHtml.class); } + /** + * Gets one block of an email, with its placement. + * + * @param emailId the email ID + * @param blockId the block ID + * @return the block and the etag of the email + * @throws MailerLiteException if the API returns an error or the request fails + */ + public SingleEmailBlock getBlock(String emailId, String blockId) throws MailerLiteException + { + return send("GET", blockPath(emailId, blockId), null, SingleEmailBlock.class); + } + /** * Checks the email ID and makes the path of the email. * @@ -83,6 +96,18 @@ private static String emailPath(String emailId) return "/emails/".concat(RequestChecks.pathId(emailId, "emailId")); } + /** + * Checks the email ID and the block ID, and makes the path of the block. + * + * @param emailId the email ID + * @param blockId the block ID + * @return the path + */ + private static String blockPath(String emailId, String blockId) + { + return emailPath(emailId).concat("/blocks/").concat(RequestChecks.pathId(blockId, "blockId")); + } + /** * Converts a request body to JSON. * diff --git a/src/main/java/com/mailerlite/sdk/emails/SingleEmailBlock.java b/src/main/java/com/mailerlite/sdk/emails/SingleEmailBlock.java new file mode 100644 index 0000000..d9431fc --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/SingleEmailBlock.java @@ -0,0 +1,25 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.emails; + +import com.google.gson.annotations.SerializedName; +import com.mailerlite.sdk.MailerLiteResponse; + +/** + * The response of the endpoints that return one block. + */ +public class SingleEmailBlock extends MailerLiteResponse { + + /** The block, with its placement. */ + @SerializedName("data") + public EmailBlock block; + + /** The etag of the email. Send it with the next write to the email. */ + @SerializedName("etag") + public String etag; +} diff --git a/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java b/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java index 706e816..b01ea7a 100644 --- a/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java +++ b/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java @@ -32,6 +32,7 @@ import com.mailerlite.sdk.emails.EmailSettings; import com.mailerlite.sdk.emails.EmailSocialLink; import com.mailerlite.sdk.emails.Emails; +import com.mailerlite.sdk.emails.SingleEmailBlock; import com.mailerlite.sdk.emails.SingleEmailDocument; import com.mailerlite.sdk.emails.SingleEmailHtml; import com.mailerlite.sdk.exceptions.EmptyIdException; @@ -290,4 +291,37 @@ public void getHtmlRejectsInvalidIds() assertRejectsIds(client, id -> () -> emails.getHtml(id)); } + + /** + * getBlock sends GET /emails/{email}/blocks/{block} and decodes the block and its placement. + * + * @throws Exception if the request fails + */ + @Test + public void getBlockDecodesTheBlock() throws Exception + { + client.respond(200, Fixtures.read("email_block.json")); + + SingleEmailBlock response = emails.getBlock("123", "57"); + + assertRequest(client, "GET", "/emails/123/blocks/57", null, null); + assertEquals("43", response.etag); + assertEquals("57", response.block.id); + assertEquals("paragraph", response.block.type); + assertEquals("left", map(response.block.editorContent, "attrs").get("textAlign")); + assertEquals("56", response.block.placement.after); + assertNull(response.block.placement.before); + assertNull(response.block.children); + } + + /** + * getBlock checks both IDs and sends no request for an invalid ID. + */ + @Test + public void getBlockRejectsInvalidIds() + { + assertRejectsIds(client, id -> () -> emails.getBlock(id, "57")); + assertRejectsIds(client, id -> () -> emails.getBlock("123", id)); + } + } diff --git a/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java b/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java index a118649..3df12ce 100644 --- a/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java +++ b/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java @@ -152,6 +152,10 @@ private static List examples() .respond(200, Fixtures.read("email_html.json")) .expect("GET /emails/123456/html")); + list.add(new Example("Get a block") + .respond(200, Fixtures.read("email_block.json")) + .expect("GET /emails/123456/blocks/57")); + return list; } diff --git a/src/test/resources/responses/email_block.json b/src/test/resources/responses/email_block.json new file mode 100644 index 0000000..df7d248 --- /dev/null +++ b/src/test/resources/responses/email_block.json @@ -0,0 +1,12 @@ +{ + "data": { + "id": "57", + "type": "paragraph", + "editor_content": { + "attrs": {"textAlign": "left"}, + "content": [{"text": "Hello", "type": "text"}] + }, + "placement": {"after": "56", "before": null} + }, + "etag": "43" +} From 52d14a3d07785515b3ae62d88a31b6d1a74b34b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Thu, 29 Oct 2026 16:21:18 +0100 Subject: [PATCH 10/35] feat: add Emails.createBlock Emails.createBlock inserts a block into an email and returns the new block with its placement and the new etag. Add EmailBlockInput, EmailBlockPosition, EmailBlockTypes and EmailBlockPositions. The API uses placement.block_id only for the positions "before" and "after". So the SDK checks the block ID only for these positions. For "start" and "end", the SDK does not check the block ID and does not send it. The SDK does not change the placement of the caller. Co-Authored-By: Claude Opus 5.5 --- README.md | 45 ++++ .../sdk/emails/EmailBlockInput.java | 54 +++++ .../sdk/emails/EmailBlockPosition.java | 59 ++++++ .../sdk/emails/EmailBlockPositions.java | 33 +++ .../sdk/emails/EmailBlockTypes.java | 60 ++++++ .../com/mailerlite/sdk/emails/Emails.java | 129 ++++++++++++ .../com/mailerlite/sdk/tests/EmailsTest.java | 197 ++++++++++++++++++ .../sdk/tests/ReadmeExamplesTest.java | 6 + .../responses/email_block_created.json | 12 ++ 9 files changed, 595 insertions(+) create mode 100644 src/main/java/com/mailerlite/sdk/emails/EmailBlockInput.java create mode 100644 src/main/java/com/mailerlite/sdk/emails/EmailBlockPosition.java create mode 100644 src/main/java/com/mailerlite/sdk/emails/EmailBlockPositions.java create mode 100644 src/main/java/com/mailerlite/sdk/emails/EmailBlockTypes.java create mode 100644 src/test/resources/responses/email_block_created.json diff --git a/README.md b/README.md index eb3a647..b9c5443 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ MailerLite Java SDK - [Get an email](#get-an-email) - [Get the HTML of an email](#get-the-html-of-an-email) - [Get a block](#get-a-block) + - [Insert a block](#insert-a-block) - [Forms](#forms) - [Get a list of forms](#get-a-list-of-forms) - [Get a form](#get-a-form) @@ -1067,6 +1068,50 @@ class Example { } ``` +### Insert a block + +Use the block types in `EmailBlockTypes`. You cannot insert the `TITLE` type. `editorContent` is required, but it can be an empty map. The SDK sends the map as it is, also its `null` values. + +Without `placement`, the API puts the block at the end of the email. The positions are in `EmailBlockPositions`. The `BEFORE` and `AFTER` positions also need a `blockId`. For `START` and `END`, the API does not use a `blockId`, so the SDK does not send it. + +A `null` block or `editorContent` throws `NullPointerException`. An empty block type throws `IllegalArgumentException`. + +```java + +// import .... + +class Example { + + public void example() { + try { + + Emails emails = this.getMailerLite().emails(); + + String emailId = "123456"; + String etag = emails.get(emailId).etag; + + Map content = Map.of( + "attrs", Map.of("textAlign", "left"), + "content", List.of(Map.of("type", "text", "text", "Hello"))); + + EmailBlockInput block = new EmailBlockInput(EmailBlockTypes.PARAGRAPH, content); + block.placement = new EmailBlockPosition(EmailBlockPositions.START); + + SingleEmailBlock response = emails.createBlock(emailId, etag, block); + + String blockId = response.block.id; + etag = response.etag; + + System.out.println(blockId + " " + etag); + + } catch (MailerLiteException e) { + + e.printStackTrace(); + } + } +} +``` + ## Forms ### Get a list of forms diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailBlockInput.java b/src/main/java/com/mailerlite/sdk/emails/EmailBlockInput.java new file mode 100644 index 0000000..b003610 --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/EmailBlockInput.java @@ -0,0 +1,54 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.emails; + +import java.util.Map; + +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.annotations.SerializedName; + +/** + * A block to insert into an email or to validate. + */ +public class EmailBlockInput { + + /** The block type: one of the values in {@link EmailBlockTypes}, except TITLE. Required. */ + @SerializedName("type") + public String type; + + /** + * The editor document of the block. Required, but it can be an empty map, for example for a divider. + * The keys are camelCase, for example "textAlign". The SDK sends the map as it is, also its null values. + */ + @SerializedName("editor_content") + @JsonAdapter(EditorContentAdapter.class) + public Map editorContent; + + /** The position of the new block. If it is null, the API puts the block at the end of the email. */ + @SerializedName("placement") + public EmailBlockPosition placement; + + /** + * Instantiates an empty block input. + */ + public EmailBlockInput() + { + } + + /** + * Instantiates a block input with a type and an editor document. + * + * @param type the block type + * @param editorContent the editor document + */ + public EmailBlockInput(String type, Map editorContent) + { + this.type = type; + this.editorContent = editorContent; + } +} diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailBlockPosition.java b/src/main/java/com/mailerlite/sdk/emails/EmailBlockPosition.java new file mode 100644 index 0000000..2e6f60d --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/EmailBlockPosition.java @@ -0,0 +1,59 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.emails; + +import com.google.gson.annotations.SerializedName; + +/** + * Where to put a block: at the start or the end of its level, or before or after a block at the same level. + */ +public class EmailBlockPosition { + + /** The position: one of the values in {@link EmailBlockPositions}. Required. */ + @SerializedName("position") + public String position; + + /** + * The ID of the block at the same level to put the block before or after. + *

+ * It is required for {@link EmailBlockPositions#BEFORE} and {@link EmailBlockPositions#AFTER}, and it must + * contain only the digits 0 to 9. For {@link EmailBlockPositions#START} and {@link EmailBlockPositions#END}, + * the API does not use it, so the SDK does not check it and does not send it. + */ + @SerializedName("block_id") + public String blockId; + + /** + * Instantiates an empty position. + */ + public EmailBlockPosition() + { + } + + /** + * Instantiates a position without a block, for example {@link EmailBlockPositions#START}. + * + * @param position the position + */ + public EmailBlockPosition(String position) + { + this.position = position; + } + + /** + * Instantiates a position before or after a block. + * + * @param position the position, for example {@link EmailBlockPositions#AFTER} + * @param blockId the ID of the block at the same level + */ + public EmailBlockPosition(String position, String blockId) + { + this.position = position; + this.blockId = blockId; + } +} diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailBlockPositions.java b/src/main/java/com/mailerlite/sdk/emails/EmailBlockPositions.java new file mode 100644 index 0000000..9f369b5 --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/EmailBlockPositions.java @@ -0,0 +1,33 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.emails; + +/** + * The values of {@link EmailBlockPosition#position}. + */ +public final class EmailBlockPositions { + + /** At the start of the level. The API does not use a block ID. */ + public static final String START = "start"; + + /** At the end of the level. The API does not use a block ID. */ + public static final String END = "end"; + + /** Before the block with the block ID. */ + public static final String BEFORE = "before"; + + /** After the block with the block ID. */ + public static final String AFTER = "after"; + + /** + * Do not make instances of this class. + */ + private EmailBlockPositions() + { + } +} diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailBlockTypes.java b/src/main/java/com/mailerlite/sdk/emails/EmailBlockTypes.java new file mode 100644 index 0000000..67d38eb --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/EmailBlockTypes.java @@ -0,0 +1,60 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.emails; + +/** + * The block types of the simple editor. + *

+ * The API can add new types. The SDK accepts any type string when it reads a block, and it sends any type string + * that you give. + */ +public final class EmailBlockTypes { + + /** A quotation. */ + public static final String BLOCKQUOTE = "blockquote"; + + /** A list with bullets. */ + public static final String BULLETED_LIST = "bulleted_list"; + + /** A button. */ + public static final String BUTTON = "button"; + + /** A code block. */ + public static final String CODE = "code"; + + /** A divider line. */ + public static final String DIVIDER = "divider"; + + /** A heading. */ + public static final String HEADING = "heading"; + + /** An image. */ + public static final String IMAGE = "image"; + + /** A list with numbers. */ + public static final String NUMBERED_LIST = "numbered_list"; + + /** A paragraph. */ + public static final String PARAGRAPH = "paragraph"; + + /** A row of social icons. */ + public static final String SOCIAL_ICON = "social_icon"; + + /** The title of the email. It is read-only: you cannot insert it. */ + public static final String TITLE = "title"; + + /** A video. */ + public static final String VIDEO = "video"; + + /** + * Do not make instances of this class. + */ + private EmailBlockTypes() + { + } +} diff --git a/src/main/java/com/mailerlite/sdk/emails/Emails.java b/src/main/java/com/mailerlite/sdk/emails/Emails.java index 2a08922..db2340f 100644 --- a/src/main/java/com/mailerlite/sdk/emails/Emails.java +++ b/src/main/java/com/mailerlite/sdk/emails/Emails.java @@ -7,8 +7,13 @@ **************************************************/ package com.mailerlite.sdk.emails; +import java.util.Map; +import java.util.Objects; + import com.google.gson.Gson; import com.google.gson.GsonBuilder; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.annotations.SerializedName; import com.mailerlite.sdk.MailerLite; import com.mailerlite.sdk.MailerLiteApi; import com.mailerlite.sdk.MailerLiteResponse; @@ -85,6 +90,28 @@ public SingleEmailBlock getBlock(String emailId, String blockId) throws MailerLi return send("GET", blockPath(emailId, blockId), null, SingleEmailBlock.class); } + /** + * Inserts a block into an email. + *

+ * Without a placement, the API puts the block at the end of the email. The response has the new block with + * its placement and the new etag. + * + * @param emailId the email ID + * @param etag the etag from the last response for the email + * @param block the block to insert + * @return the new block and the new etag + * @throws MailerLiteException if the API returns an error or the request fails + * @throws NullPointerException if the block or its editor content is null + * @throws IllegalArgumentException if the block type or the placement position is empty + */ + public SingleEmailBlock createBlock(String emailId, String etag, EmailBlockInput block) throws MailerLiteException + { + String endpoint = emailPath(emailId).concat("/blocks"); + BlockBody body = blockBody(block, RequestChecks.etag(etag)); + + return send("POST", endpoint, toJson(body), SingleEmailBlock.class); + } + /** * Checks the email ID and makes the path of the email. * @@ -108,6 +135,59 @@ private static String blockPath(String emailId, String blockId) return emailPath(emailId).concat("/blocks/").concat(RequestChecks.pathId(blockId, "blockId")); } + /** + * Checks a block input and makes the request body. + * + * @param block the block input + * @param etag the etag, or null for no etag + * @return the request body + */ + private static BlockBody blockBody(EmailBlockInput block, String etag) + { + Objects.requireNonNull(block, "block"); + + if (block.type == null || block.type.isEmpty()) { + throw new IllegalArgumentException("block.type must not be empty"); + } + + BlockBody body = new BlockBody(); + body.type = block.type; + body.editorContent = Objects.requireNonNull(block.editorContent, "block.editorContent"); + body.placement = block.placement == null ? null : placementBody(block.placement); + body.etag = etag; + + return body; + } + + /** + * Checks a placement and makes a copy to send. + *

+ * The API uses the block ID only for "before" and "after". So for these positions the block ID must be valid. + * For "start" and "end", the copy has no block ID. For another position, the block ID is checked if it is set. + * + * @param placement the placement of the caller + * @return the copy to send + */ + private static PlacementBody placementBody(EmailBlockPosition placement) + { + String position = placement.position; + + if (position == null || position.isEmpty()) { + throw new IllegalArgumentException("placement.position must not be empty"); + } + + if (EmailBlockPositions.START.equals(position) || EmailBlockPositions.END.equals(position)) { + return new PlacementBody(position, null); + } + + if (EmailBlockPositions.BEFORE.equals(position) || EmailBlockPositions.AFTER.equals(position) + || placement.blockId != null) { + RequestChecks.pathId(placement.blockId, "placement.blockId"); + } + + return new PlacementBody(position, placement.blockId); + } + /** * Converts a request body to JSON. * @@ -138,4 +218,53 @@ private T send(String method, String endpoint, St return api.sendRequest(method, endpoint, body, null, responseClass); } + + /** + * The request body of a block insert or a block validation. + */ + private static class BlockBody { + + /** The block type. */ + @SerializedName("type") + String type; + + /** The editor document. */ + @SerializedName("editor_content") + @JsonAdapter(EditorContentAdapter.class) + Map editorContent; + + /** The placement, or null. */ + @SerializedName("placement") + PlacementBody placement; + + /** The etag, or null. */ + @SerializedName("etag") + String etag; + } + + /** + * The placement in a request body. + */ + private static class PlacementBody { + + /** The position. */ + @SerializedName("position") + final String position; + + /** The block ID, or null. */ + @SerializedName("block_id") + final String blockId; + + /** + * Instantiates a new placement body. + * + * @param position the position + * @param blockId the block ID, or null + */ + PlacementBody(String position, String blockId) + { + this.position = position; + this.blockId = blockId; + } + } } diff --git a/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java b/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java index b01ea7a..4d6ed35 100644 --- a/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java +++ b/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java @@ -15,6 +15,7 @@ import java.util.Arrays; import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; @@ -28,6 +29,10 @@ import com.mailerlite.sdk.MailerLite; import com.mailerlite.sdk.emails.EmailBlock; +import com.mailerlite.sdk.emails.EmailBlockInput; +import com.mailerlite.sdk.emails.EmailBlockPosition; +import com.mailerlite.sdk.emails.EmailBlockPositions; +import com.mailerlite.sdk.emails.EmailBlockTypes; import com.mailerlite.sdk.emails.EmailDocument; import com.mailerlite.sdk.emails.EmailSettings; import com.mailerlite.sdk.emails.EmailSocialLink; @@ -35,6 +40,7 @@ import com.mailerlite.sdk.emails.SingleEmailBlock; import com.mailerlite.sdk.emails.SingleEmailDocument; import com.mailerlite.sdk.emails.SingleEmailHtml; +import com.mailerlite.sdk.exceptions.EmptyEtagException; import com.mailerlite.sdk.exceptions.EmptyIdException; import com.mailerlite.sdk.exceptions.InvalidIdException; import com.mailerlite.sdk.exceptions.MailerLiteException; @@ -324,4 +330,195 @@ public void getBlockRejectsInvalidIds() assertRejectsIds(client, id -> () -> emails.getBlock("123", id)); } + + /** + * Makes the editor content of a paragraph: {"attrs":{"textAlign":align},"content":[{"type":"text","text":text}]}. + * + * @param align the text alignment + * @param text the text + * @return the editor content + */ + static Map paragraph(String align, String text) + { + Map attrs = new LinkedHashMap(); + attrs.put("textAlign", align); + + Map node = new LinkedHashMap(); + node.put("type", "text"); + node.put("text", text); + + Map content = new LinkedHashMap(); + content.put("attrs", attrs); + content.put("content", Collections.singletonList(node)); + + return content; + } + + /** + * Asserts that each write rejects an empty or null etag, and sends no request. + * + * @param client the fake HTTP client + * @param call makes the call for an etag + */ + static void assertRejectsEtags(RecordingHttpClient client, Function call) + { + assertThrows(EmptyEtagException.class, call.apply("")); + assertThrows(EmptyEtagException.class, call.apply(null)); + assertTrue(client.requests().isEmpty(), "the SDK must send no request"); + } + + /** + * Asserts the placement rules of a method that sends a placement. + *

+ * The SDK checks blockId only for "before" and "after". For "start" and "end", it does not check blockId and + * does not send it. For another position, it checks blockId if it is set, and sends it. + * The SDK does not change the object of the caller. + * + * @param client the fake HTTP client + * @param response the response body to queue for each request + * @param call makes the call for a placement + * @param expectedBody makes the expected raw body from the JSON of the placement + * @throws Throwable if a call fails + */ + static void assertPlacementRules(RecordingHttpClient client, String response, Function call, + Function expectedBody) throws Throwable + { + Object[][] sent = { + {EmailBlockPositions.END, "", "{\"position\":\"end\"}"}, + {EmailBlockPositions.START, "1/2", "{\"position\":\"start\"}"}, + {EmailBlockPositions.AFTER, "57", "{\"position\":\"after\",\"block_id\":\"57\"}"}, + {EmailBlockPositions.BEFORE, "1", "{\"position\":\"before\",\"block_id\":\"1\"}"}, + {"middle", "5", "{\"position\":\"middle\",\"block_id\":\"5\"}"}, + {"middle", null, "{\"position\":\"middle\"}"}, + }; + + for (Object[] item : sent) { + + client.requests().clear(); + client.respond(200, response); + + EmailBlockPosition placement = new EmailBlockPosition((String) item[0], (String) item[1]); + call.apply(placement).execute(); + + assertEquals(expectedBody.apply((String) item[2]), client.lastRequest().bodyString(), item[0] + " " + item[1]); + assertEquals(item[1], placement.blockId, "the SDK must not change the placement of the caller"); + } + + client.requests().clear(); + + Object[][] rejected = { + {EmailBlockPositions.AFTER, null, EmptyIdException.class}, + {EmailBlockPositions.AFTER, "", EmptyIdException.class}, + {EmailBlockPositions.BEFORE, null, EmptyIdException.class}, + {EmailBlockPositions.BEFORE, "1/2", InvalidIdException.class}, + {EmailBlockPositions.AFTER, "\u00b2", InvalidIdException.class}, + {"middle", "1/2", InvalidIdException.class}, + {"", "5", IllegalArgumentException.class}, + {null, "5", IllegalArgumentException.class}, + }; + + for (Object[] item : rejected) { + + @SuppressWarnings("unchecked") + Class type = (Class) item[2]; + assertThrows(type, call.apply(new EmailBlockPosition((String) item[0], (String) item[1])), item[0] + " " + item[1]); + } + + assertTrue(client.requests().isEmpty(), "the SDK must send no request"); + } + + /** + * createBlock sends POST /emails/{email}/blocks with the block, the placement and the etag. + * + * @throws Exception if the request fails + */ + @Test + public void createBlockSendsTheBlock() throws Exception + { + client.respond(201, Fixtures.read("email_block_created.json")); + + EmailBlockInput block = new EmailBlockInput(EmailBlockTypes.PARAGRAPH, paragraph("center", "Hi")); + block.placement = new EmailBlockPosition(EmailBlockPositions.AFTER, "57"); + + SingleEmailBlock response = emails.createBlock("123", "42", block); + + assertRequest(client, "POST", "/emails/123/blocks", + "{\"type\":\"paragraph\",\"editor_content\":{\"attrs\":{\"textAlign\":\"center\"}," + + "\"content\":[{\"type\":\"text\",\"text\":\"Hi\"}]}," + + "\"placement\":{\"position\":\"after\",\"block_id\":\"57\"},\"etag\":\"42\"}", null); + assertEquals(201, response.responseStatusCode); + assertEquals("43", response.etag); + assertEquals("58", response.block.id); + assertEquals("center", map(response.block.editorContent, "attrs").get("textAlign")); + assertEquals("57", response.block.placement.after); + assertNull(response.block.placement.before); + } + + /** + * createBlock sends an empty editor content and no placement when the caller sets none. + * A null value inside the editor content is sent as null. + * + * @throws Exception if the request fails + */ + @Test + public void createBlockWithoutPlacement() throws Exception + { + client.respond(201, Fixtures.read("email_block_created.json")).respond(201, Fixtures.read("email_block_created.json")); + + emails.createBlock("123", "42", new EmailBlockInput(EmailBlockTypes.DIVIDER, new LinkedHashMap())); + + assertEquals("{\"type\":\"divider\",\"editor_content\":{},\"etag\":\"42\"}", client.lastRequest().bodyString()); + + Map attrs = new LinkedHashMap(); + attrs.put("url", null); + Map content = new LinkedHashMap(); + content.put("attrs", attrs); + + emails.createBlock("123", "42", new EmailBlockInput(EmailBlockTypes.BUTTON, content)); + + assertEquals("{\"type\":\"button\",\"editor_content\":{\"attrs\":{\"url\":null}},\"etag\":\"42\"}", + client.lastRequest().bodyString()); + } + + /** + * createBlock checks the placement: blockId only for "before" and "after". + * + * @throws Throwable if a call fails + */ + @Test + public void createBlockPlacementRules() throws Throwable + { + assertPlacementRules(client, Fixtures.read("email_block_created.json"), placement -> () -> { + EmailBlockInput block = new EmailBlockInput(EmailBlockTypes.DIVIDER, new LinkedHashMap()); + block.placement = placement; + emails.createBlock("123", "42", block); + }, json -> "{\"type\":\"divider\",\"editor_content\":{},\"placement\":" + json + ",\"etag\":\"42\"}"); + } + + /** + * createBlock checks the IDs, the etag and the required values, and sends no request when a check fails. + */ + @Test + public void createBlockRejectsInvalidInput() + { + EmailBlockInput divider = new EmailBlockInput(EmailBlockTypes.DIVIDER, new LinkedHashMap()); + + assertRejectsIds(client, id -> () -> emails.createBlock(id, "42", divider)); + assertRejectsEtags(client, etag -> () -> emails.createBlock("123", etag, divider)); + + NullPointerException noBlock = assertThrows(NullPointerException.class, () -> emails.createBlock("123", "42", null)); + assertTrue(noBlock.getMessage().contains("block"), noBlock.getMessage()); + + assertThrows(IllegalArgumentException.class, + () -> emails.createBlock("123", "42", new EmailBlockInput("", new LinkedHashMap()))); + assertThrows(IllegalArgumentException.class, + () -> emails.createBlock("123", "42", new EmailBlockInput(null, new LinkedHashMap()))); + + NullPointerException noContent = assertThrows(NullPointerException.class, + () -> emails.createBlock("123", "42", new EmailBlockInput(EmailBlockTypes.DIVIDER, null))); + assertTrue(noContent.getMessage().contains("editorContent"), noContent.getMessage()); + + assertTrue(client.requests().isEmpty(), "the SDK must send no request"); + } + } diff --git a/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java b/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java index 3df12ce..eb52408 100644 --- a/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java +++ b/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java @@ -156,6 +156,12 @@ private static List examples() .respond(200, Fixtures.read("email_block.json")) .expect("GET /emails/123456/blocks/57")); + list.add(new Example("Insert a block") + .respond(200, Fixtures.read("email.json")) + .respond(201, Fixtures.read("email_block_created.json")) + .expect("GET /emails/123456") + .expect("POST /emails/123456/blocks")); + return list; } diff --git a/src/test/resources/responses/email_block_created.json b/src/test/resources/responses/email_block_created.json new file mode 100644 index 0000000..7b52e03 --- /dev/null +++ b/src/test/resources/responses/email_block_created.json @@ -0,0 +1,12 @@ +{ + "data": { + "id": "58", + "type": "paragraph", + "editor_content": { + "attrs": {"textAlign": "center"}, + "content": [{"text": "Hi", "type": "text"}] + }, + "placement": {"after": "57", "before": null} + }, + "etag": "43" +} From 8b21926672b9b82d2d72bca1678f446ab34d5eab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Thu, 29 Oct 2026 16:21:56 +0100 Subject: [PATCH 11/35] feat: add Emails.updateBlock Emails.updateBlock sends PATCH with the new editor content and the etag, and returns the block and the new etag. The new editor content replaces the whole content of the block. A block that the SDK read is sent back with the same whole numbers and the same null values. Co-Authored-By: Claude Opus 5.5 --- README.md | 33 +++++++++++ .../com/mailerlite/sdk/emails/Emails.java | 41 +++++++++++++ .../com/mailerlite/sdk/tests/EmailsTest.java | 59 +++++++++++++++++++ .../sdk/tests/ReadmeExamplesTest.java | 6 ++ .../responses/email_block_updated.json | 9 +++ 5 files changed, 148 insertions(+) create mode 100644 src/test/resources/responses/email_block_updated.json diff --git a/README.md b/README.md index b9c5443..b6c3b6b 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ MailerLite Java SDK - [Get the HTML of an email](#get-the-html-of-an-email) - [Get a block](#get-a-block) - [Insert a block](#insert-a-block) + - [Update a block](#update-a-block) - [Forms](#forms) - [Get a list of forms](#get-a-list-of-forms) - [Get a form](#get-a-form) @@ -1112,6 +1113,38 @@ class Example { } ``` +### Update a block + +The new `editorContent` replaces the whole content of the block. The API removes the keys that you do not send. So get the block, change its `editorContent`, and send all of it. The type and the position of the block do not change. + +```java + +// import .... + +class Example { + + public void example() { + try { + + Emails emails = this.getMailerLite().emails(); + + SingleEmailBlock read = emails.getBlock("123456", "57"); + + Map content = new HashMap(read.block.editorContent); + content.put("attrs", Map.of("textAlign", "center")); + + SingleEmailBlock response = emails.updateBlock("123456", "57", read.etag, content); + + System.out.println(response.etag); + + } catch (MailerLiteException e) { + + e.printStackTrace(); + } + } +} +``` + ## Forms ### Get a list of forms diff --git a/src/main/java/com/mailerlite/sdk/emails/Emails.java b/src/main/java/com/mailerlite/sdk/emails/Emails.java index db2340f..a6c547e 100644 --- a/src/main/java/com/mailerlite/sdk/emails/Emails.java +++ b/src/main/java/com/mailerlite/sdk/emails/Emails.java @@ -112,6 +112,32 @@ public SingleEmailBlock createBlock(String emailId, String etag, EmailBlockInput return send("POST", endpoint, toJson(body), SingleEmailBlock.class); } + /** + * Replaces the content of a block. The type and the position of the block do not change. + *

+ * The new editor content replaces the whole content of the block. The API removes the keys that you do not + * send. Read the block, change it, and send the full editor content. + * + * @param emailId the email ID + * @param blockId the block ID + * @param etag the etag from the last response for the email + * @param editorContent the new editor document of the block. The keys are camelCase. + * @return the block and the new etag + * @throws MailerLiteException if the API returns an error or the request fails + * @throws NullPointerException if the editor content is null + */ + public SingleEmailBlock updateBlock(String emailId, String blockId, String etag, Map editorContent) + throws MailerLiteException + { + String endpoint = blockPath(emailId, blockId); + + UpdateBlockBody body = new UpdateBlockBody(); + body.etag = RequestChecks.etag(etag); + body.editorContent = Objects.requireNonNull(editorContent, "editorContent"); + + return send("PATCH", endpoint, toJson(body), SingleEmailBlock.class); + } + /** * Checks the email ID and makes the path of the email. * @@ -242,6 +268,21 @@ private static class BlockBody { String etag; } + /** + * The request body of a block update. + */ + private static class UpdateBlockBody { + + /** The editor document. */ + @SerializedName("editor_content") + @JsonAdapter(EditorContentAdapter.class) + Map editorContent; + + /** The etag. */ + @SerializedName("etag") + String etag; + } + /** * The placement in a request body. */ diff --git a/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java b/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java index 4d6ed35..0d60d8b 100644 --- a/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java +++ b/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java @@ -521,4 +521,63 @@ public void createBlockRejectsInvalidInput() assertTrue(client.requests().isEmpty(), "the SDK must send no request"); } + + /** + * updateBlock sends PATCH /emails/{email}/blocks/{block} with the editor content and the etag. + * + * @throws Exception if the request fails + */ + @Test + public void updateBlockSendsTheEditorContent() throws Exception + { + client.respond(200, Fixtures.read("email_block_updated.json")); + + SingleEmailBlock response = emails.updateBlock("123", "57", "43", paragraph("center", "Hello again")); + + assertRequest(client, "PATCH", "/emails/123/blocks/57", + "{\"editor_content\":{\"attrs\":{\"textAlign\":\"center\"}," + + "\"content\":[{\"type\":\"text\",\"text\":\"Hello again\"}]},\"etag\":\"43\"}", null); + assertEquals("44", response.etag); + assertEquals("57", response.block.id); + assertEquals("56", response.block.placement.after); + assertEquals("58", response.block.placement.before); + } + + /** + * A block that the SDK read is sent back with the same numbers and the same null values. + * + * @throws Exception if the request fails + */ + @Test + public void updateBlockSendsBackTheEditorContentExactly() throws Exception + { + String editorContent = "{\"attrs\":{\"url\":\"https://example.com\",\"linkId\":7,\"size\":24,\"ratio\":1.5,\"title\":null}}"; + client.respond(200, "{\"data\":{\"id\":\"57\",\"type\":\"button\",\"editor_content\":" + editorContent + "},\"etag\":\"43\"}") + .respond(200, Fixtures.read("email_block_updated.json")); + + SingleEmailBlock read = emails.getBlock("123", "57"); + assertEquals(Long.valueOf(7), map(read.block.editorContent, "attrs").get("linkId")); + + emails.updateBlock("123", "57", read.etag, read.block.editorContent); + + assertEquals("{\"editor_content\":" + editorContent + ",\"etag\":\"43\"}", client.lastRequest().bodyString()); + } + + /** + * updateBlock checks the IDs, the etag and the editor content, and sends no request when a check fails. + */ + @Test + public void updateBlockRejectsInvalidInput() + { + Map content = paragraph("left", "Hi"); + + assertRejectsIds(client, id -> () -> emails.updateBlock(id, "57", "43", content)); + assertRejectsIds(client, id -> () -> emails.updateBlock("123", id, "43", content)); + assertRejectsEtags(client, etag -> () -> emails.updateBlock("123", "57", etag, content)); + + NullPointerException e = assertThrows(NullPointerException.class, () -> emails.updateBlock("123", "57", "43", null)); + assertTrue(e.getMessage().contains("editorContent"), e.getMessage()); + assertTrue(client.requests().isEmpty(), "the SDK must send no request"); + } + } diff --git a/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java b/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java index eb52408..1af07c3 100644 --- a/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java +++ b/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java @@ -162,6 +162,12 @@ private static List examples() .expect("GET /emails/123456") .expect("POST /emails/123456/blocks")); + list.add(new Example("Update a block") + .respond(200, Fixtures.read("email_block.json")) + .respond(200, Fixtures.read("email_block_updated.json")) + .expect("GET /emails/123456/blocks/57") + .expect("PATCH /emails/123456/blocks/57")); + return list; } diff --git a/src/test/resources/responses/email_block_updated.json b/src/test/resources/responses/email_block_updated.json new file mode 100644 index 0000000..cb87a43 --- /dev/null +++ b/src/test/resources/responses/email_block_updated.json @@ -0,0 +1,9 @@ +{ + "data": { + "id": "57", + "type": "paragraph", + "editor_content": {"content": [{"text": "Hello again", "type": "text"}]}, + "placement": {"after": "56", "before": "58"} + }, + "etag": "44" +} From 9f4385c94eca1e2cfe3156fb0c7d80e827989ce2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Thu, 29 Oct 2026 16:22:32 +0100 Subject: [PATCH 12/35] feat: add Emails.deleteBlock Emails.deleteBlock sends DELETE with the etag in the body, and returns the new etag of the email. The API also deletes the blocks in the block. The old delete methods of the SDK still send no body. Co-Authored-By: Claude Opus 5.5 --- README.md | 26 ++++++++++++ .../sdk/emails/EmailEtagResponse.java | 21 ++++++++++ .../com/mailerlite/sdk/emails/Emails.java | 40 +++++++++++++++++++ .../com/mailerlite/sdk/tests/EmailsTest.java | 30 ++++++++++++++ .../sdk/tests/ReadmeExamplesTest.java | 4 ++ src/test/resources/responses/email_etag.json | 1 + 6 files changed, 122 insertions(+) create mode 100644 src/main/java/com/mailerlite/sdk/emails/EmailEtagResponse.java create mode 100644 src/test/resources/responses/email_etag.json diff --git a/README.md b/README.md index b6c3b6b..060fab8 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ MailerLite Java SDK - [Get a block](#get-a-block) - [Insert a block](#insert-a-block) - [Update a block](#update-a-block) + - [Delete a block](#delete-a-block) - [Forms](#forms) - [Get a list of forms](#get-a-list-of-forms) - [Get a form](#get-a-form) @@ -1145,6 +1146,31 @@ class Example { } ``` +### Delete a block + +Deletes the block and the blocks in it. The response has only the new etag. + +```java + +// import .... + +class Example { + + public void example() { + try { + + EmailEtagResponse response = this.getMailerLite().emails().deleteBlock("123456", "57", "etag-from-the-last-response"); + + System.out.println(response.etag); + + } catch (MailerLiteException e) { + + e.printStackTrace(); + } + } +} +``` + ## Forms ### Get a list of forms diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailEtagResponse.java b/src/main/java/com/mailerlite/sdk/emails/EmailEtagResponse.java new file mode 100644 index 0000000..61e1792 --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/EmailEtagResponse.java @@ -0,0 +1,21 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.emails; + +import com.google.gson.annotations.SerializedName; +import com.mailerlite.sdk.MailerLiteResponse; + +/** + * The response of Emails.deleteBlock. It has only the new etag of the email. + */ +public class EmailEtagResponse extends MailerLiteResponse { + + /** The new etag of the email. Send it with the next write to the email. */ + @SerializedName("etag") + public String etag; +} diff --git a/src/main/java/com/mailerlite/sdk/emails/Emails.java b/src/main/java/com/mailerlite/sdk/emails/Emails.java index a6c547e..5ad2c4c 100644 --- a/src/main/java/com/mailerlite/sdk/emails/Emails.java +++ b/src/main/java/com/mailerlite/sdk/emails/Emails.java @@ -138,6 +138,22 @@ public SingleEmailBlock updateBlock(String emailId, String blockId, String etag, return send("PATCH", endpoint, toJson(body), SingleEmailBlock.class); } + /** + * Deletes a block of an email. The API also deletes the blocks in it. + * + * @param emailId the email ID + * @param blockId the block ID + * @param etag the etag from the last response for the email + * @return the new etag + * @throws MailerLiteException if the API returns an error or the request fails + */ + public EmailEtagResponse deleteBlock(String emailId, String blockId, String etag) throws MailerLiteException + { + String endpoint = blockPath(emailId, blockId); + + return send("DELETE", endpoint, etagBody(etag), EmailEtagResponse.class); + } + /** * Checks the email ID and makes the path of the email. * @@ -214,6 +230,20 @@ private static PlacementBody placementBody(EmailBlockPosition placement) return new PlacementBody(position, placement.blockId); } + /** + * Checks the etag and makes a request body that has only the etag. + * + * @param etag the etag + * @return the JSON body + */ + private static String etagBody(String etag) + { + EtagBody body = new EtagBody(); + body.etag = RequestChecks.etag(etag); + + return toJson(body); + } + /** * Converts a request body to JSON. * @@ -283,6 +313,16 @@ private static class UpdateBlockBody { String etag; } + /** + * A request body that has only the etag. + */ + private static class EtagBody { + + /** The etag. */ + @SerializedName("etag") + String etag; + } + /** * The placement in a request body. */ diff --git a/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java b/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java index 0d60d8b..8ecee21 100644 --- a/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java +++ b/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java @@ -34,6 +34,7 @@ import com.mailerlite.sdk.emails.EmailBlockPositions; import com.mailerlite.sdk.emails.EmailBlockTypes; import com.mailerlite.sdk.emails.EmailDocument; +import com.mailerlite.sdk.emails.EmailEtagResponse; import com.mailerlite.sdk.emails.EmailSettings; import com.mailerlite.sdk.emails.EmailSocialLink; import com.mailerlite.sdk.emails.Emails; @@ -580,4 +581,33 @@ public void updateBlockRejectsInvalidInput() assertTrue(client.requests().isEmpty(), "the SDK must send no request"); } + + /** + * deleteBlock sends DELETE /emails/{email}/blocks/{block} with the etag in the body, and returns the new etag. + * + * @throws Exception if the request fails + */ + @Test + public void deleteBlockSendsTheEtagInTheBody() throws Exception + { + client.respond(200, Fixtures.read("email_etag.json")); + + EmailEtagResponse response = emails.deleteBlock("123", "57", "44"); + + assertRequest(client, "DELETE", "/emails/123/blocks/57", "{\"etag\":\"44\"}", null); + assertEquals(200, response.responseStatusCode); + assertEquals("45", response.etag); + } + + /** + * deleteBlock checks the IDs and the etag, and sends no request when a check fails. + */ + @Test + public void deleteBlockRejectsInvalidInput() + { + assertRejectsIds(client, id -> () -> emails.deleteBlock(id, "57", "44")); + assertRejectsIds(client, id -> () -> emails.deleteBlock("123", id, "44")); + assertRejectsEtags(client, etag -> () -> emails.deleteBlock("123", "57", etag)); + } + } diff --git a/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java b/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java index 1af07c3..b32d0e7 100644 --- a/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java +++ b/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java @@ -168,6 +168,10 @@ private static List examples() .expect("GET /emails/123456/blocks/57") .expect("PATCH /emails/123456/blocks/57")); + list.add(new Example("Delete a block") + .respond(200, Fixtures.read("email_etag.json")) + .expect("DELETE /emails/123456/blocks/57")); + return list; } diff --git a/src/test/resources/responses/email_etag.json b/src/test/resources/responses/email_etag.json new file mode 100644 index 0000000..730eaff --- /dev/null +++ b/src/test/resources/responses/email_etag.json @@ -0,0 +1 @@ +{"etag": "45"} From b29ebdfbb4faaf03828137a1d0727f6533ab4e45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Thu, 29 Oct 2026 16:23:07 +0100 Subject: [PATCH 13/35] feat: add Emails.moveBlock Emails.moveBlock sends the new placement and the etag, and returns the block with its new placement and the new etag. The placement checks are the same as for an insert. Co-Authored-By: Claude Opus 5.5 --- README.md | 28 ++++++++++ .../com/mailerlite/sdk/emails/Emails.java | 40 +++++++++++++++ .../com/mailerlite/sdk/tests/EmailsTest.java | 51 +++++++++++++++++++ .../sdk/tests/ReadmeExamplesTest.java | 4 ++ .../responses/email_block_moved.json | 12 +++++ 5 files changed, 135 insertions(+) create mode 100644 src/test/resources/responses/email_block_moved.json diff --git a/README.md b/README.md index 060fab8..08f40c6 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ MailerLite Java SDK - [Insert a block](#insert-a-block) - [Update a block](#update-a-block) - [Delete a block](#delete-a-block) + - [Move a block](#move-a-block) - [Forms](#forms) - [Get a list of forms](#get-a-list-of-forms) - [Get a form](#get-a-form) @@ -1171,6 +1172,33 @@ class Example { } ``` +### Move a block + +Moves the block to a new position at the same level. The `BEFORE` and `AFTER` positions also need a `blockId`. The response has the block with its new `placement`. + +```java + +// import .... + +class Example { + + public void example() { + try { + + EmailBlockPosition placement = new EmailBlockPosition(EmailBlockPositions.AFTER, "12"); + + SingleEmailBlock response = this.getMailerLite().emails().moveBlock("123456", "57", "etag-from-the-last-response", placement); + + System.out.println(response.etag); + + } catch (MailerLiteException e) { + + e.printStackTrace(); + } + } +} +``` + ## Forms ### Get a list of forms diff --git a/src/main/java/com/mailerlite/sdk/emails/Emails.java b/src/main/java/com/mailerlite/sdk/emails/Emails.java index 5ad2c4c..66e5d19 100644 --- a/src/main/java/com/mailerlite/sdk/emails/Emails.java +++ b/src/main/java/com/mailerlite/sdk/emails/Emails.java @@ -154,6 +154,32 @@ public EmailEtagResponse deleteBlock(String emailId, String blockId, String etag return send("DELETE", endpoint, etagBody(etag), EmailEtagResponse.class); } + /** + * Moves a block to a new position at the same level. The nesting of the block does not change. + *

+ * The positions {@link EmailBlockPositions#BEFORE} and {@link EmailBlockPositions#AFTER} need a block ID. + * + * @param emailId the email ID + * @param blockId the ID of the block to move + * @param etag the etag from the last response for the email + * @param placement the new position + * @return the block with its new placement, and the new etag + * @throws MailerLiteException if the API returns an error or the request fails + * @throws NullPointerException if the placement is null + * @throws IllegalArgumentException if the placement position is empty + */ + public SingleEmailBlock moveBlock(String emailId, String blockId, String etag, EmailBlockPosition placement) + throws MailerLiteException + { + String endpoint = blockPath(emailId, blockId).concat("/move"); + + MoveBlockBody body = new MoveBlockBody(); + body.etag = RequestChecks.etag(etag); + body.placement = placementBody(Objects.requireNonNull(placement, "placement")); + + return send("POST", endpoint, toJson(body), SingleEmailBlock.class); + } + /** * Checks the email ID and makes the path of the email. * @@ -323,6 +349,20 @@ private static class EtagBody { String etag; } + /** + * The request body of a block move. + */ + private static class MoveBlockBody { + + /** The placement. */ + @SerializedName("placement") + PlacementBody placement; + + /** The etag. */ + @SerializedName("etag") + String etag; + } + /** * The placement in a request body. */ diff --git a/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java b/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java index 8ecee21..12c62dd 100644 --- a/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java +++ b/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java @@ -610,4 +610,55 @@ public void deleteBlockRejectsInvalidInput() assertRejectsEtags(client, etag -> () -> emails.deleteBlock("123", "57", etag)); } + + /** + * moveBlock sends POST /emails/{email}/blocks/{block}/move with the placement and the etag. + * + * @throws Exception if the request fails + */ + @Test + public void moveBlockSendsThePlacement() throws Exception + { + client.respond(200, Fixtures.read("email_block_moved.json")); + + SingleEmailBlock response = emails.moveBlock("123", "57", "45", new EmailBlockPosition(EmailBlockPositions.BEFORE, "1")); + + assertRequest(client, "POST", "/emails/123/blocks/57/move", + "{\"placement\":{\"position\":\"before\",\"block_id\":\"1\"},\"etag\":\"45\"}", null); + assertEquals("46", response.etag); + assertEquals("57", response.block.id); + assertNull(response.block.placement.after); + assertEquals("1", response.block.placement.before); + } + + /** + * moveBlock checks the placement: blockId only for "before" and "after". + * + * @throws Throwable if a call fails + */ + @Test + public void moveBlockPlacementRules() throws Throwable + { + assertPlacementRules(client, Fixtures.read("email_block_moved.json"), + placement -> () -> emails.moveBlock("123", "57", "45", placement), + json -> "{\"placement\":" + json + ",\"etag\":\"45\"}"); + } + + /** + * moveBlock checks the IDs, the etag and the placement, and sends no request when a check fails. + */ + @Test + public void moveBlockRejectsInvalidInput() + { + EmailBlockPosition start = new EmailBlockPosition(EmailBlockPositions.START); + + assertRejectsIds(client, id -> () -> emails.moveBlock(id, "57", "45", start)); + assertRejectsIds(client, id -> () -> emails.moveBlock("123", id, "45", start)); + assertRejectsEtags(client, etag -> () -> emails.moveBlock("123", "57", etag, start)); + + NullPointerException e = assertThrows(NullPointerException.class, () -> emails.moveBlock("123", "57", "45", null)); + assertTrue(e.getMessage().contains("placement"), e.getMessage()); + assertTrue(client.requests().isEmpty(), "the SDK must send no request"); + } + } diff --git a/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java b/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java index b32d0e7..9bb664b 100644 --- a/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java +++ b/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java @@ -172,6 +172,10 @@ private static List examples() .respond(200, Fixtures.read("email_etag.json")) .expect("DELETE /emails/123456/blocks/57")); + list.add(new Example("Move a block") + .respond(200, Fixtures.read("email_block_moved.json")) + .expect("POST /emails/123456/blocks/57/move")); + return list; } diff --git a/src/test/resources/responses/email_block_moved.json b/src/test/resources/responses/email_block_moved.json new file mode 100644 index 0000000..3041e81 --- /dev/null +++ b/src/test/resources/responses/email_block_moved.json @@ -0,0 +1,12 @@ +{ + "data": { + "id": "57", + "type": "paragraph", + "editor_content": { + "attrs": {"textAlign": "left"}, + "content": [{"text": "Hello", "type": "text"}] + }, + "placement": {"after": null, "before": "1"} + }, + "etag": "46" +} From 62166e4c196e738b839b09e6364727e6a0fb5285 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Thu, 29 Oct 2026 16:24:07 +0100 Subject: [PATCH 14/35] feat: add Emails.validateBlock Emails.validateBlock checks a block with the rules of an insert and saves nothing. It sends no etag. An invalid block is not an error: the response has valid set to false and the errors for each field. Add EmailValidation, EmailSchema and EmailRejected. Co-Authored-By: Claude Opus 5.5 --- README.md | 34 +++++++++ .../mailerlite/sdk/emails/EmailRejected.java | 24 ++++++ .../mailerlite/sdk/emails/EmailSchema.java | 24 ++++++ .../sdk/emails/EmailValidation.java | 40 ++++++++++ .../com/mailerlite/sdk/emails/Emails.java | 16 ++++ .../com/mailerlite/sdk/tests/EmailsTest.java | 74 +++++++++++++++++++ .../sdk/tests/ReadmeExamplesTest.java | 4 + .../email_block_validation_invalid.json | 5 ++ .../email_block_validation_valid.json | 1 + 9 files changed, 222 insertions(+) create mode 100644 src/main/java/com/mailerlite/sdk/emails/EmailRejected.java create mode 100644 src/main/java/com/mailerlite/sdk/emails/EmailSchema.java create mode 100644 src/main/java/com/mailerlite/sdk/emails/EmailValidation.java create mode 100644 src/test/resources/responses/email_block_validation_invalid.json create mode 100644 src/test/resources/responses/email_block_validation_valid.json diff --git a/README.md b/README.md index 08f40c6..cbd2b56 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ MailerLite Java SDK - [Update a block](#update-a-block) - [Delete a block](#delete-a-block) - [Move a block](#move-a-block) + - [Validate a block](#validate-a-block) - [Forms](#forms) - [Get a list of forms](#get-a-list-of-forms) - [Get a form](#get-a-form) @@ -1199,6 +1200,39 @@ class Example { } ``` +### Validate a block + +Checks a block before you insert it, and saves nothing. It needs no email and no etag. An invalid block is not an error: the response has `valid` set to `false` and the `errors` for each field. + +```java + +// import .... + +class Example { + + public void example() { + try { + + EmailBlockInput block = new EmailBlockInput("table", Map.of()); + + EmailValidation response = this.getMailerLite().emails().validateBlock(block); + + if (!response.valid) { + + for (Map.Entry error : response.errors.entrySet()) { + + System.out.println(error.getKey() + ": " + String.join(" ", error.getValue())); + } + } + + } catch (MailerLiteException e) { + + e.printStackTrace(); + } + } +} +``` + ## Forms ### Get a list of forms diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailRejected.java b/src/main/java/com/mailerlite/sdk/emails/EmailRejected.java new file mode 100644 index 0000000..bd7569e --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/EmailRejected.java @@ -0,0 +1,24 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.emails; + +import com.google.gson.annotations.SerializedName; + +/** + * The unsafe values that the API removed or replaced. The request is still a success. + */ +public class EmailRejected { + + /** The message. */ + @SerializedName("message") + public String message; + + /** The paths of the values, for example "footer.social_links[0].url". */ + @SerializedName("keys") + public String[] keys; +} diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailSchema.java b/src/main/java/com/mailerlite/sdk/emails/EmailSchema.java new file mode 100644 index 0000000..dcfba3c --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/EmailSchema.java @@ -0,0 +1,24 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.emails; + +import com.google.gson.annotations.SerializedName; + +/** + * The version of the rules that a validation used. + */ +public class EmailSchema { + + /** The version of the rules, for example "2.73.2". */ + @SerializedName("version") + public String version; + + /** The hash of the rules. */ + @SerializedName("hash") + public String hash; +} diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailValidation.java b/src/main/java/com/mailerlite/sdk/emails/EmailValidation.java new file mode 100644 index 0000000..ba1c939 --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/EmailValidation.java @@ -0,0 +1,40 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.emails; + +import java.util.HashMap; +import java.util.Map; + +import com.google.gson.annotations.SerializedName; +import com.mailerlite.sdk.MailerLiteResponse; + +/** + * The result of a validation. + * + * An invalid input is not an error: the status is 200, valid is false, and errors has the messages for each field. + */ +public class EmailValidation extends MailerLiteResponse { + + /** True if the input is valid. */ + @SerializedName("valid") + public boolean valid; + + /** The error messages for each field path. It is empty if the input is valid. */ + @SerializedName("errors") + public HashMap errors; + + /** The version of the rules that the validation used. */ + @SerializedName("schema") + public EmailSchema schema; + + /** + * The unsafe values that an update would remove, or null if there are none. Only the validation of settings has it. + */ + @SerializedName("rejected") + public EmailRejected rejected; +} diff --git a/src/main/java/com/mailerlite/sdk/emails/Emails.java b/src/main/java/com/mailerlite/sdk/emails/Emails.java index 66e5d19..b819028 100644 --- a/src/main/java/com/mailerlite/sdk/emails/Emails.java +++ b/src/main/java/com/mailerlite/sdk/emails/Emails.java @@ -180,6 +180,22 @@ public SingleEmailBlock moveBlock(String emailId, String blockId, String etag, E return send("POST", endpoint, toJson(body), SingleEmailBlock.class); } + /** + * Checks a block with the rules of an insert, and saves nothing. It needs no email and no etag. + *

+ * An invalid block is not an error: the response has valid set to false and the errors for each field. + * + * @param block the block to check + * @return the result of the validation + * @throws MailerLiteException if the API returns an error or the request fails + * @throws NullPointerException if the block or its editor content is null + * @throws IllegalArgumentException if the block type or the placement position is empty + */ + public EmailValidation validateBlock(EmailBlockInput block) throws MailerLiteException + { + return send("POST", "/emails/blocks/validate", toJson(blockBody(block, null)), EmailValidation.class); + } + /** * Checks the email ID and makes the path of the email. * diff --git a/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java b/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java index 12c62dd..ac84182 100644 --- a/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java +++ b/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java @@ -7,6 +7,7 @@ **************************************************/ package com.mailerlite.sdk.tests; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; @@ -37,6 +38,7 @@ import com.mailerlite.sdk.emails.EmailEtagResponse; import com.mailerlite.sdk.emails.EmailSettings; import com.mailerlite.sdk.emails.EmailSocialLink; +import com.mailerlite.sdk.emails.EmailValidation; import com.mailerlite.sdk.emails.Emails; import com.mailerlite.sdk.emails.SingleEmailBlock; import com.mailerlite.sdk.emails.SingleEmailDocument; @@ -661,4 +663,76 @@ public void moveBlockRejectsInvalidInput() assertTrue(client.requests().isEmpty(), "the SDK must send no request"); } + + /** + * validateBlock sends POST /emails/blocks/validate with the block and no etag, and decodes a valid result. + * + * @throws Exception if the request fails + */ + @Test + public void validateBlockValid() throws Exception + { + client.respond(200, Fixtures.read("email_block_validation_valid.json")); + + EmailBlockInput block = new EmailBlockInput(EmailBlockTypes.PARAGRAPH, paragraph("left", "Hi")); + block.placement = new EmailBlockPosition(EmailBlockPositions.END); + + EmailValidation response = emails.validateBlock(block); + + assertRequest(client, "POST", "/emails/blocks/validate", + "{\"type\":\"paragraph\",\"editor_content\":{\"attrs\":{\"textAlign\":\"left\"}," + + "\"content\":[{\"type\":\"text\",\"text\":\"Hi\"}]},\"placement\":{\"position\":\"end\"}}", null); + assertTrue(response.valid); + assertTrue(response.errors.isEmpty()); + assertEquals("2.73.2", response.schema.version); + assertEquals("0f3a9c", response.schema.hash); + assertNull(response.rejected); + } + + /** + * validateBlock returns an invalid result as a success, with the errors for each field. + * + * @throws Exception if the request fails + */ + @Test + public void validateBlockInvalid() throws Exception + { + client.respond(200, Fixtures.read("email_block_validation_invalid.json")); + + EmailValidation response = emails.validateBlock(new EmailBlockInput("table", new LinkedHashMap())); + + assertEquals("{\"type\":\"table\",\"editor_content\":{}}", client.lastRequest().bodyString()); + assertEquals(200, response.responseStatusCode); + assertFalse(response.valid); + assertArrayEquals(new String[] {"The selected type is invalid."}, response.errors.get("type")); + } + + /** + * validateBlock checks the placement: blockId only for "before" and "after". + * + * @throws Throwable if a call fails + */ + @Test + public void validateBlockPlacementRules() throws Throwable + { + assertPlacementRules(client, Fixtures.read("email_block_validation_valid.json"), placement -> () -> { + EmailBlockInput block = new EmailBlockInput(EmailBlockTypes.DIVIDER, new LinkedHashMap()); + block.placement = placement; + emails.validateBlock(block); + }, json -> "{\"type\":\"divider\",\"editor_content\":{},\"placement\":" + json + "}"); + } + + /** + * validateBlock checks the required values, and sends no request when a check fails. + */ + @Test + public void validateBlockRejectsInvalidInput() + { + assertThrows(NullPointerException.class, () -> emails.validateBlock(null)); + assertThrows(IllegalArgumentException.class, + () -> emails.validateBlock(new EmailBlockInput("", new LinkedHashMap()))); + assertThrows(NullPointerException.class, () -> emails.validateBlock(new EmailBlockInput(EmailBlockTypes.DIVIDER, null))); + assertTrue(client.requests().isEmpty(), "the SDK must send no request"); + } + } diff --git a/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java b/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java index 9bb664b..b1a9d6f 100644 --- a/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java +++ b/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java @@ -176,6 +176,10 @@ private static List examples() .respond(200, Fixtures.read("email_block_moved.json")) .expect("POST /emails/123456/blocks/57/move")); + list.add(new Example("Validate a block") + .respond(200, Fixtures.read("email_block_validation_invalid.json")) + .expect("POST /emails/blocks/validate")); + return list; } diff --git a/src/test/resources/responses/email_block_validation_invalid.json b/src/test/resources/responses/email_block_validation_invalid.json new file mode 100644 index 0000000..c5c04fb --- /dev/null +++ b/src/test/resources/responses/email_block_validation_invalid.json @@ -0,0 +1,5 @@ +{ + "valid": false, + "errors": {"type": ["The selected type is invalid."]}, + "schema": {"version": "2.73.2", "hash": "0f3a9c"} +} diff --git a/src/test/resources/responses/email_block_validation_valid.json b/src/test/resources/responses/email_block_validation_valid.json new file mode 100644 index 0000000..b73c417 --- /dev/null +++ b/src/test/resources/responses/email_block_validation_valid.json @@ -0,0 +1 @@ +{"valid": true, "errors": {}, "schema": {"version": "2.73.2", "hash": "0f3a9c"}} From 4672cd827a8f343564fb29f6e98ba863ae890db4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Thu, 29 Oct 2026 16:25:18 +0100 Subject: [PATCH 15/35] feat: add Emails.updateSettings Emails.updateSettings sends PATCH with only the settings fields that the caller set, and the etag at the top level of the body. A null field is not sent. An empty string unsets a value, and an empty list of social links removes all the links. The response has the settings, the new etag and the unsafe values that the API removed. Co-Authored-By: Claude Opus 5.5 --- README.md | 48 +++++++ .../com/mailerlite/sdk/emails/Emails.java | 47 ++++++- .../sdk/emails/SingleEmailSettings.java | 29 ++++ .../com/mailerlite/sdk/tests/EmailsTest.java | 125 ++++++++++++++++++ .../sdk/tests/ReadmeExamplesTest.java | 4 + .../resources/responses/email_settings.json | 111 ++++++++++++++++ 6 files changed, 363 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/mailerlite/sdk/emails/SingleEmailSettings.java create mode 100644 src/test/resources/responses/email_settings.json diff --git a/README.md b/README.md index cbd2b56..2e9194f 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ MailerLite Java SDK - [Delete a block](#delete-a-block) - [Move a block](#move-a-block) - [Validate a block](#validate-a-block) + - [Update the design settings](#update-the-design-settings) - [Forms](#forms) - [Get a list of forms](#get-a-list-of-forms) - [Get a form](#get-a-form) @@ -1233,6 +1234,53 @@ class Example { } ``` +### Update the design settings + +Set only the fields that you want to change. The groups are `brand`, `layout`, `typography`, `button` and `footer`. + +- A `null` field is not sent and does not change. An empty string unsets the value, and the email then uses the value of your account. +- All style values are strings, for example `"16"` and not `16`. +- The API stores each value that you send, also a value that the email inherits from your account. Then the email no longer follows your account for that value. So do not send back all the settings that you read. +- `footer.socialLinks` replaces the whole list. An empty list removes all the links. Keep the `linkId` of each link to keep its click history. +- `brand.mode` is one of `EmailBrandModes`. To apply a preset palette, set `brand.style` to the `identifier` of the preset from the brand styles. +- The SDK sends the etag at the top level of the body. Do not put it in the settings. +- If the API removed unsafe values, the response has `rejected` with the paths of these values. + +```java + +// import .... + +class Example { + + public void example() { + try { + + EmailSettings settings = new EmailSettings(); + settings.brand = new EmailBrandSettings(); + settings.brand.mode = EmailBrandModes.CUSTOM; + settings.brand.backgroundColor = "#f4f4f4"; + settings.typography = new EmailTypographySettings(); + settings.typography.paragraph = new EmailParagraphStyle(); + settings.typography.paragraph.p1 = new EmailTextStyle(); + settings.typography.paragraph.p1.size = "18"; + + SingleEmailSettings response = this.getMailerLite().emails().updateSettings("123456", "etag-from-the-last-response", settings); + + System.out.println(response.etag); + + if (response.rejected != null) { + + System.out.println(String.join(", ", response.rejected.keys)); + } + + } catch (MailerLiteException e) { + + e.printStackTrace(); + } + } +} +``` + ## Forms ### Get a list of forms diff --git a/src/main/java/com/mailerlite/sdk/emails/Emails.java b/src/main/java/com/mailerlite/sdk/emails/Emails.java index b819028..fc96101 100644 --- a/src/main/java/com/mailerlite/sdk/emails/Emails.java +++ b/src/main/java/com/mailerlite/sdk/emails/Emails.java @@ -12,6 +12,7 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; import com.google.gson.annotations.JsonAdapter; import com.google.gson.annotations.SerializedName; import com.mailerlite.sdk.MailerLite; @@ -35,9 +36,15 @@ */ public class Emails { - /** The Gson object for the request bodies. */ + /** The Gson object for the request bodies. It does not send null fields. */ private static final Gson GSON = new GsonBuilder().disableHtmlEscaping().create(); + /** + * The Gson object that writes a JSON tree that GSON made. The tree has no null fields, except the null values + * in the editor content maps, so this object writes them. + */ + private static final Gson TREE_GSON = new GsonBuilder().disableHtmlEscaping().serializeNulls().create(); + /** The api object reference. */ private MailerLite apiObjectReference; @@ -196,6 +203,33 @@ public EmailValidation validateBlock(EmailBlockInput block) throws MailerLiteExc return send("POST", "/emails/blocks/validate", toJson(blockBody(block, null)), EmailValidation.class); } + /** + * Changes the design settings of an email. + *

+ * Set only the fields that you want to change. A null field is not sent and does not change. An empty string + * unsets the value. The API stores each value that you send, also a value that the email inherits from your + * account. So do not send back all the settings that you read. + *

+ * The SDK sends the etag at the top level of the body, next to the settings groups. + * + * @param emailId the email ID + * @param etag the etag from the last response for the email + * @param settings the settings to change + * @return the settings after the update, the new etag, and the unsafe values that the API removed + * @throws MailerLiteException if the API returns an error or the request fails + * @throws NullPointerException if the settings are null + */ + public SingleEmailSettings updateSettings(String emailId, String etag, EmailSettings settings) throws MailerLiteException + { + String endpoint = emailPath(emailId).concat("/settings"); + String checkedEtag = RequestChecks.etag(etag); + + JsonObject body = settingsBody(settings); + body.addProperty("etag", checkedEtag); + + return send("PATCH", endpoint, TREE_GSON.toJson(body), SingleEmailSettings.class); + } + /** * Checks the email ID and makes the path of the email. * @@ -286,6 +320,17 @@ private static String etagBody(String etag) return toJson(body); } + /** + * Converts the settings to a JSON tree without the null fields. + * + * @param settings the settings + * @return the JSON tree + */ + private static JsonObject settingsBody(EmailSettings settings) + { + return GSON.toJsonTree(Objects.requireNonNull(settings, "settings")).getAsJsonObject(); + } + /** * Converts a request body to JSON. * diff --git a/src/main/java/com/mailerlite/sdk/emails/SingleEmailSettings.java b/src/main/java/com/mailerlite/sdk/emails/SingleEmailSettings.java new file mode 100644 index 0000000..26db987 --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/SingleEmailSettings.java @@ -0,0 +1,29 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.emails; + +import com.google.gson.annotations.SerializedName; +import com.mailerlite.sdk.MailerLiteResponse; + +/** + * The response of Emails.updateSettings. + */ +public class SingleEmailSettings extends MailerLiteResponse { + + /** The design settings after the update, as they apply to the email. */ + @SerializedName("data") + public EmailSettings settings; + + /** The new etag of the email. Send it with the next write to the email. */ + @SerializedName("etag") + public String etag; + + /** The unsafe values that the API removed or replaced, or null if there are none. */ + @SerializedName("rejected") + public EmailRejected rejected; +} diff --git a/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java b/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java index ac84182..5dc0076 100644 --- a/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java +++ b/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java @@ -14,6 +14,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.LinkedHashMap; @@ -36,7 +37,15 @@ import com.mailerlite.sdk.emails.EmailBlockTypes; import com.mailerlite.sdk.emails.EmailDocument; import com.mailerlite.sdk.emails.EmailEtagResponse; +import com.mailerlite.sdk.emails.EmailBrandModes; +import com.mailerlite.sdk.emails.EmailBrandSettings; +import com.mailerlite.sdk.emails.EmailEditorText; +import com.mailerlite.sdk.emails.EmailFooterSettings; +import com.mailerlite.sdk.emails.EmailParagraphStyle; import com.mailerlite.sdk.emails.EmailSettings; +import com.mailerlite.sdk.emails.EmailTextStyle; +import com.mailerlite.sdk.emails.EmailTypographySettings; +import com.mailerlite.sdk.emails.SingleEmailSettings; import com.mailerlite.sdk.emails.EmailSocialLink; import com.mailerlite.sdk.emails.EmailValidation; import com.mailerlite.sdk.emails.Emails; @@ -735,4 +744,120 @@ public void validateBlockRejectsInvalidInput() assertTrue(client.requests().isEmpty(), "the SDK must send no request"); } + + /** + * Makes settings that change the brand mode, the background color and the paragraph size. + * + * @return the settings + */ + static EmailSettings brandAndParagraphSettings() + { + EmailSettings settings = new EmailSettings(); + settings.brand = new EmailBrandSettings(); + settings.brand.mode = EmailBrandModes.CUSTOM; + settings.brand.backgroundColor = "#f4f4f4"; + settings.typography = new EmailTypographySettings(); + settings.typography.paragraph = new EmailParagraphStyle(); + settings.typography.paragraph.p1 = new EmailTextStyle(); + settings.typography.paragraph.p1.size = "18"; + + return settings; + } + + /** + * updateSettings sends PATCH /emails/{email}/settings with only the set fields, and the etag at the top level. + * + * @throws Exception if the request fails + */ + @Test + public void updateSettingsSendsOnlyTheSetFields() throws Exception + { + client.respond(200, Fixtures.read("email_settings.json")); + + SingleEmailSettings response = emails.updateSettings("123", "46", brandAndParagraphSettings()); + + assertRequest(client, "PATCH", "/emails/123/settings", + "{\"brand\":{\"mode\":\"custom\",\"background_color\":\"#f4f4f4\"}," + + "\"typography\":{\"paragraph\":{\"p1\":{\"size\":\"18\"}}},\"etag\":\"46\"}", null); + assertEquals("47", response.etag); + assertEquals("custom", response.settings.brand.mode); + assertEquals("#f4f4f4", response.settings.brand.backgroundColor); + assertEquals("16", response.settings.typography.paragraph.p1.size); + assertEquals(Long.valueOf(7), response.settings.footer.socialLinks.get(0).linkId); + assertEquals("Some attributes were rejected.", response.rejected.message); + assertArrayEquals(new String[] {"footer.social_links[0].url"}, response.rejected.keys); + } + + /** + * An empty string unsets a value, an empty list removes all the social links, an empty editor content unsets a + * footer text, and a null inside an editor content is kept. + * + * @throws Exception if the request fails + */ + @Test + public void updateSettingsSendsEmptyValues() throws Exception + { + client.respond(200, Fixtures.read("email_settings.json")); + + Map companyName = new LinkedHashMap(); + companyName.put("content", Collections.emptyList()); + companyName.put("attrs", null); + + EmailSettings settings = new EmailSettings(); + settings.brand = new EmailBrandSettings(); + settings.brand.backgroundColor = ""; + settings.footer = new EmailFooterSettings(); + settings.footer.showPreferences = false; + settings.footer.infoText = new EmailEditorText(); + settings.footer.infoText.editorContent = new LinkedHashMap(); + settings.footer.companyName = new EmailEditorText(); + settings.footer.companyName.editorContent = companyName; + settings.footer.socialLinks = new ArrayList(); + + emails.updateSettings("123", "46", settings); + + assertEquals("{\"brand\":{\"background_color\":\"\"},\"footer\":{\"show_preferences\":false," + + "\"company_name\":{\"editor_content\":{\"content\":[],\"attrs\":null}}," + + "\"info_text\":{\"editor_content\":{}},\"social_links\":[]},\"etag\":\"46\"}", + client.lastRequest().bodyString()); + } + + /** + * A social link keeps its link ID as a whole number. + * + * @throws Exception if the request fails + */ + @Test + public void updateSettingsKeepsTheLinkId() throws Exception + { + client.respond(200, Fixtures.read("email.json")).respond(200, Fixtures.read("email_settings.json")); + + SingleEmailDocument email = emails.get("123"); + + EmailSettings settings = new EmailSettings(); + settings.footer = new EmailFooterSettings(); + settings.footer.socialLinks = email.email.settings.footer.socialLinks; + + emails.updateSettings("123", email.etag, settings); + + assertEquals("{\"footer\":{\"social_links\":[{\"url\":\"https://example.com\",\"type\":\"website\"," + + "\"label\":\"Website\",\"link_id\":7}]},\"etag\":\"42\"}", client.lastRequest().bodyString()); + } + + /** + * updateSettings checks the ID, the etag and the settings, and sends no request when a check fails. + */ + @Test + public void updateSettingsRejectsInvalidInput() + { + EmailSettings settings = brandAndParagraphSettings(); + + assertRejectsIds(client, id -> () -> emails.updateSettings(id, "46", settings)); + assertRejectsEtags(client, etag -> () -> emails.updateSettings("123", etag, settings)); + + NullPointerException e = assertThrows(NullPointerException.class, () -> emails.updateSettings("123", "46", null)); + assertTrue(e.getMessage().contains("settings"), e.getMessage()); + assertTrue(client.requests().isEmpty(), "the SDK must send no request"); + } + } diff --git a/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java b/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java index b1a9d6f..d33d0f0 100644 --- a/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java +++ b/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java @@ -180,6 +180,10 @@ private static List examples() .respond(200, Fixtures.read("email_block_validation_invalid.json")) .expect("POST /emails/blocks/validate")); + list.add(new Example("Update the design settings") + .respond(200, Fixtures.read("email_settings.json")) + .expect("PATCH /emails/123456/settings")); + return list; } diff --git a/src/test/resources/responses/email_settings.json b/src/test/resources/responses/email_settings.json new file mode 100644 index 0000000..b6193ec --- /dev/null +++ b/src/test/resources/responses/email_settings.json @@ -0,0 +1,111 @@ +{ + "data": { + "brand": { + "mode": "custom", + "font_family": "Inter, sans-serif", + "background_color": "#f4f4f4", + "text_color": "#000000", + "border_color": "#e5e7eb" + }, + "layout": { + "width_mode": "default", + "alignment": "center" + }, + "typography": { + "link": { + "color": "#2cb191" + }, + "heading": { + "font_family": "Inter, sans-serif", + "font_weight": "700", + "line_height": "150%", + "letter_spacing": "0", + "h1": { + "color": "#000000", + "size": "32" + }, + "h2": { + "color": "#000000", + "size": "24" + }, + "h3": { + "color": "#000000", + "size": "20" + } + }, + "paragraph": { + "font_family": "Inter, sans-serif", + "font_weight": "400", + "line_height": "150%", + "letter_spacing": "0", + "p1": { + "color": "#000000", + "size": "16" + } + } + }, + "button": { + "font_family": "Inter, sans-serif", + "font_weight": "600", + "letter_spacing": "0", + "background_color": "#2cb191", + "text_color": "#ffffff", + "border_radius": "4" + }, + "footer": { + "show_preferences": false, + "company_name": { + "editor_content": { + "content": [ + { + "type": "text", + "text": "Acme", + "marks": [ + { + "type": "bold" + } + ] + } + ] + } + }, + "address": { + "editor_content": { + "content": [ + { + "text": "1 Main Street", + "type": "text" + } + ] + } + }, + "info_text": { + "editor_content": { + "content": [ + { + "text": "You signed up on our website.", + "type": "text" + } + ] + } + }, + "unsubscribe_text": "Unsubscribe", + "preferences_text": "Update your preferences", + "social_links": [ + { + "url": "https://example.com", + "type": "website", + "label": "Website", + "link_id": 7 + } + ] + } + }, + "etag": "47", + "rejected": { + "message": "Some attributes were rejected.", + "keys": [ + "footer.social_links[0].url" + ] + } +} From 58f153fb4ff8985412e6d485a923c999e78e8990 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Thu, 29 Oct 2026 16:25:50 +0100 Subject: [PATCH 16/35] feat: add Emails.validateSettings Emails.validateSettings checks a change to the design settings with the rules of an update and saves nothing. It sends no etag. The response has valid, the errors for each field and the unsafe values that an update would remove. Co-Authored-By: Claude Opus 5.5 --- README.md | 33 +++++++++++++++++ .../com/mailerlite/sdk/emails/Emails.java | 19 ++++++++++ .../com/mailerlite/sdk/tests/EmailsTest.java | 35 +++++++++++++++++++ .../sdk/tests/ReadmeExamplesTest.java | 4 +++ .../responses/email_settings_validation.json | 9 +++++ 5 files changed, 100 insertions(+) create mode 100644 src/test/resources/responses/email_settings_validation.json diff --git a/README.md b/README.md index 2e9194f..e1507dd 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ MailerLite Java SDK - [Move a block](#move-a-block) - [Validate a block](#validate-a-block) - [Update the design settings](#update-the-design-settings) + - [Validate the design settings](#validate-the-design-settings) - [Forms](#forms) - [Get a list of forms](#get-a-list-of-forms) - [Get a form](#get-a-form) @@ -1281,6 +1282,38 @@ class Example { } ``` +### Validate the design settings + +Checks a change with the rules of the update, and saves nothing. It needs no etag. Invalid settings are not an error: the response has `valid` set to `false` and the `errors` for each field. If the update would remove unsafe values, `rejected` has their paths. + +```java + +// import .... + +class Example { + + public void example() { + try { + + EmailSettings settings = new EmailSettings(); + settings.button = new EmailButtonSettings(); + settings.button.borderRadius = "8"; + + EmailValidation response = this.getMailerLite().emails().validateSettings("123456", settings); + + if (!response.valid) { + + System.out.println(response.errors.keySet()); + } + + } catch (MailerLiteException e) { + + e.printStackTrace(); + } + } +} +``` + ## Forms ### Get a list of forms diff --git a/src/main/java/com/mailerlite/sdk/emails/Emails.java b/src/main/java/com/mailerlite/sdk/emails/Emails.java index fc96101..bd3aa81 100644 --- a/src/main/java/com/mailerlite/sdk/emails/Emails.java +++ b/src/main/java/com/mailerlite/sdk/emails/Emails.java @@ -230,6 +230,25 @@ public SingleEmailSettings updateSettings(String emailId, String etag, EmailSett return send("PATCH", endpoint, TREE_GSON.toJson(body), SingleEmailSettings.class); } + /** + * Checks a change to the design settings with the rules of an update, and saves nothing. It needs no etag. + *

+ * Invalid settings are not an error: the response has valid set to false and the errors for each field. If the + * update would remove unsafe values, the response has their paths in rejected. + * + * @param emailId the email ID + * @param settings the settings to check + * @return the result of the validation + * @throws MailerLiteException if the API returns an error or the request fails + * @throws NullPointerException if the settings are null + */ + public EmailValidation validateSettings(String emailId, EmailSettings settings) throws MailerLiteException + { + String endpoint = emailPath(emailId).concat("/settings/validate"); + + return send("POST", endpoint, TREE_GSON.toJson(settingsBody(settings)), EmailValidation.class); + } + /** * Checks the email ID and makes the path of the email. * diff --git a/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java b/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java index 5dc0076..658477f 100644 --- a/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java +++ b/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java @@ -860,4 +860,39 @@ public void updateSettingsRejectsInvalidInput() assertTrue(client.requests().isEmpty(), "the SDK must send no request"); } + + /** + * validateSettings sends POST /emails/{email}/settings/validate with the settings and no etag. + * + * @throws Exception if the request fails + */ + @Test + public void validateSettingsSendsTheSettingsWithoutEtag() throws Exception + { + client.respond(200, Fixtures.read("email_settings_validation.json")); + + EmailValidation response = emails.validateSettings("123", brandAndParagraphSettings()); + + assertRequest(client, "POST", "/emails/123/settings/validate", + "{\"brand\":{\"mode\":\"custom\",\"background_color\":\"#f4f4f4\"}," + + "\"typography\":{\"paragraph\":{\"p1\":{\"size\":\"18\"}}}}", null); + assertFalse(response.valid); + assertArrayEquals(new String[] {"The layout.width field cannot be changed."}, response.errors.get("layout.width")); + assertEquals("2.73.2", response.schema.version); + assertEquals("Some attributes were rejected.", response.rejected.message); + assertArrayEquals(new String[] {"footer.social_links[0].url"}, response.rejected.keys); + } + + /** + * validateSettings checks the ID and the settings, and sends no request when a check fails. + */ + @Test + public void validateSettingsRejectsInvalidInput() + { + assertRejectsIds(client, id -> () -> emails.validateSettings(id, brandAndParagraphSettings())); + + assertThrows(NullPointerException.class, () -> emails.validateSettings("123", null)); + assertTrue(client.requests().isEmpty(), "the SDK must send no request"); + } + } diff --git a/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java b/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java index d33d0f0..327a3ca 100644 --- a/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java +++ b/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java @@ -184,6 +184,10 @@ private static List examples() .respond(200, Fixtures.read("email_settings.json")) .expect("PATCH /emails/123456/settings")); + list.add(new Example("Validate the design settings") + .respond(200, Fixtures.read("email_settings_validation.json")) + .expect("POST /emails/123456/settings/validate")); + return list; } diff --git a/src/test/resources/responses/email_settings_validation.json b/src/test/resources/responses/email_settings_validation.json new file mode 100644 index 0000000..d1a571a --- /dev/null +++ b/src/test/resources/responses/email_settings_validation.json @@ -0,0 +1,9 @@ +{ + "valid": false, + "errors": {"layout.width": ["The layout.width field cannot be changed."]}, + "schema": {"version": "2.73.2", "hash": "0f3a9c"}, + "rejected": { + "message": "Some attributes were rejected.", + "keys": ["footer.social_links[0].url"] + } +} From 64192ff81c7f43e87bdc5295ef37b360ff3f30e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Thu, 29 Oct 2026 16:26:30 +0100 Subject: [PATCH 17/35] feat: add Emails.createTemplate Emails.createTemplate copies an email to a new template with the given name, and returns the template. An empty or null name throws IllegalArgumentException, and the SDK sends no request. Co-Authored-By: Claude Opus 5.5 --- README.md | 26 ++++++++++++++ .../mailerlite/sdk/emails/EmailTemplate.java | 32 +++++++++++++++++ .../com/mailerlite/sdk/emails/Emails.java | 33 +++++++++++++++++ .../sdk/emails/SingleEmailTemplate.java | 21 +++++++++++ .../com/mailerlite/sdk/tests/EmailsTest.java | 35 +++++++++++++++++++ .../sdk/tests/ReadmeExamplesTest.java | 4 +++ .../resources/responses/email_template.json | 1 + 7 files changed, 152 insertions(+) create mode 100644 src/main/java/com/mailerlite/sdk/emails/EmailTemplate.java create mode 100644 src/main/java/com/mailerlite/sdk/emails/SingleEmailTemplate.java create mode 100644 src/test/resources/responses/email_template.json diff --git a/README.md b/README.md index e1507dd..f9fe116 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ MailerLite Java SDK - [Validate a block](#validate-a-block) - [Update the design settings](#update-the-design-settings) - [Validate the design settings](#validate-the-design-settings) + - [Save an email as a template](#save-an-email-as-a-template) - [Forms](#forms) - [Get a list of forms](#get-a-list-of-forms) - [Get a form](#get-a-form) @@ -1314,6 +1315,31 @@ class Example { } ``` +### Save an email as a template + +Copies the email to a new template. The API cannot delete templates. + +```java + +// import .... + +class Example { + + public void example() { + try { + + SingleEmailTemplate response = this.getMailerLite().emails().createTemplate("123456", "Monthly newsletter"); + + System.out.println(response.template.id); + + } catch (MailerLiteException e) { + + e.printStackTrace(); + } + } +} +``` + ## Forms ### Get a list of forms diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailTemplate.java b/src/main/java/com/mailerlite/sdk/emails/EmailTemplate.java new file mode 100644 index 0000000..1622542 --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/EmailTemplate.java @@ -0,0 +1,32 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.emails; + +import com.google.gson.annotations.SerializedName; + +/** + * A template that the API made from an email. + */ +public class EmailTemplate { + + /** The ID of the template. */ + @SerializedName("id") + public String id; + + /** The name of the template. */ + @SerializedName("name") + public String name; + + /** The type of the template, for example "email". */ + @SerializedName("type") + public String type; + + /** The date and time when the API made the template, for example "2026-10-28 12:00:00". */ + @SerializedName("created_at") + public String createdAt; +} diff --git a/src/main/java/com/mailerlite/sdk/emails/Emails.java b/src/main/java/com/mailerlite/sdk/emails/Emails.java index bd3aa81..e82fdc2 100644 --- a/src/main/java/com/mailerlite/sdk/emails/Emails.java +++ b/src/main/java/com/mailerlite/sdk/emails/Emails.java @@ -249,6 +249,29 @@ public EmailValidation validateSettings(String emailId, EmailSettings settings) return send("POST", endpoint, TREE_GSON.toJson(settingsBody(settings)), EmailValidation.class); } + /** + * Copies an email to a new template. The API cannot delete templates. + * + * @param emailId the email ID + * @param name the name of the new template + * @return the new template + * @throws MailerLiteException if the API returns an error or the request fails + * @throws IllegalArgumentException if the name is null or empty + */ + public SingleEmailTemplate createTemplate(String emailId, String name) throws MailerLiteException + { + String endpoint = emailPath(emailId).concat("/templates"); + + if (name == null || name.isEmpty()) { + throw new IllegalArgumentException("name must not be empty"); + } + + TemplateBody body = new TemplateBody(); + body.name = name; + + return send("POST", endpoint, toJson(body), SingleEmailTemplate.class); + } + /** * Checks the email ID and makes the path of the email. * @@ -443,6 +466,16 @@ private static class MoveBlockBody { String etag; } + /** + * The request body of a template create. + */ + private static class TemplateBody { + + /** The name. */ + @SerializedName("name") + String name; + } + /** * The placement in a request body. */ diff --git a/src/main/java/com/mailerlite/sdk/emails/SingleEmailTemplate.java b/src/main/java/com/mailerlite/sdk/emails/SingleEmailTemplate.java new file mode 100644 index 0000000..05fb98c --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/SingleEmailTemplate.java @@ -0,0 +1,21 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.emails; + +import com.google.gson.annotations.SerializedName; +import com.mailerlite.sdk.MailerLiteResponse; + +/** + * The response of Emails.createTemplate. + */ +public class SingleEmailTemplate extends MailerLiteResponse { + + /** The new template. */ + @SerializedName("data") + public EmailTemplate template; +} diff --git a/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java b/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java index 658477f..21fd418 100644 --- a/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java +++ b/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java @@ -46,6 +46,7 @@ import com.mailerlite.sdk.emails.EmailTextStyle; import com.mailerlite.sdk.emails.EmailTypographySettings; import com.mailerlite.sdk.emails.SingleEmailSettings; +import com.mailerlite.sdk.emails.SingleEmailTemplate; import com.mailerlite.sdk.emails.EmailSocialLink; import com.mailerlite.sdk.emails.EmailValidation; import com.mailerlite.sdk.emails.Emails; @@ -895,4 +896,38 @@ public void validateSettingsRejectsInvalidInput() assertTrue(client.requests().isEmpty(), "the SDK must send no request"); } + + /** + * createTemplate sends POST /emails/{email}/templates with the name, and decodes the template. + * + * @throws Exception if the request fails + */ + @Test + public void createTemplateSendsTheName() throws Exception + { + client.respond(201, Fixtures.read("email_template.json")); + + SingleEmailTemplate response = emails.createTemplate("123", "Newsletter"); + + assertRequest(client, "POST", "/emails/123/templates", "{\"name\":\"Newsletter\"}", null); + assertEquals(201, response.responseStatusCode); + assertEquals("789", response.template.id); + assertEquals("Newsletter", response.template.name); + assertEquals("email", response.template.type); + assertEquals("2026-10-28 12:00:00", response.template.createdAt); + } + + /** + * createTemplate checks the ID and the name, and sends no request when a check fails. + */ + @Test + public void createTemplateRejectsInvalidInput() + { + assertRejectsIds(client, id -> () -> emails.createTemplate(id, "Newsletter")); + + assertThrows(IllegalArgumentException.class, () -> emails.createTemplate("123", "")); + assertThrows(IllegalArgumentException.class, () -> emails.createTemplate("123", null)); + assertTrue(client.requests().isEmpty(), "the SDK must send no request"); + } + } diff --git a/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java b/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java index 327a3ca..dedbcd4 100644 --- a/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java +++ b/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java @@ -188,6 +188,10 @@ private static List examples() .respond(200, Fixtures.read("email_settings_validation.json")) .expect("POST /emails/123456/settings/validate")); + list.add(new Example("Save an email as a template") + .respond(201, Fixtures.read("email_template.json")) + .expect("POST /emails/123456/templates")); + return list; } diff --git a/src/test/resources/responses/email_template.json b/src/test/resources/responses/email_template.json new file mode 100644 index 0000000..8f44cd4 --- /dev/null +++ b/src/test/resources/responses/email_template.json @@ -0,0 +1 @@ +{"data": {"id": "789", "name": "Newsletter", "type": "email", "created_at": "2026-10-28 12:00:00"}} From 9c855f635b82e29a4e35f52b260b8f12e838758a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Thu, 29 Oct 2026 16:27:24 +0100 Subject: [PATCH 18/35] feat: add Emails.versions Emails.versions returns a retriever with page() and limit(). Its get() returns the versions of an email, newest first. Without options, the request has no query string. The pagination meta can have null for from and to, for example for an empty page. The SDK reads null as 0. The Javadoc of ResponseMeta now says this. Co-Authored-By: Claude Opus 5.5 --- README.md | 29 ++++++++ .../mailerlite/sdk/emails/EmailVersion.java | 28 ++++++++ .../sdk/emails/EmailVersionsList.java | 21 ++++++ .../sdk/emails/EmailVersionsRetriever.java | 68 ++++++++++++++++++ .../com/mailerlite/sdk/emails/Emails.java | 12 ++++ .../com/mailerlite/sdk/util/ResponseMeta.java | 4 +- .../com/mailerlite/sdk/tests/EmailsTest.java | 71 +++++++++++++++++++ .../sdk/tests/ReadmeExamplesTest.java | 4 ++ .../resources/responses/email_versions.json | 26 +++++++ 9 files changed, 261 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/mailerlite/sdk/emails/EmailVersion.java create mode 100644 src/main/java/com/mailerlite/sdk/emails/EmailVersionsList.java create mode 100644 src/main/java/com/mailerlite/sdk/emails/EmailVersionsRetriever.java create mode 100644 src/test/resources/responses/email_versions.json diff --git a/README.md b/README.md index f9fe116..b14dd48 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ MailerLite Java SDK - [Update the design settings](#update-the-design-settings) - [Validate the design settings](#validate-the-design-settings) - [Save an email as a template](#save-an-email-as-a-template) + - [List the versions of an email](#list-the-versions-of-an-email) - [Forms](#forms) - [Get a list of forms](#get-a-list-of-forms) - [Get a form](#get-a-form) @@ -1340,6 +1341,34 @@ class Example { } ``` +### List the versions of an email + +A version is a saved copy of the content and the design settings of an email. The list starts with the newest version. `current` is `true` for the version that the email has now. Writes through the API do not save versions. + +```java + +// import .... + +class Example { + + public void example() { + try { + + EmailVersionsList response = this.getMailerLite().emails().versions("123456").page(1).limit(10).get(); + + for (EmailVersion version : response.versions) { + + System.out.println(version.id + " " + version.createdAt + " " + version.current); + } + + } catch (MailerLiteException e) { + + e.printStackTrace(); + } + } +} +``` + ## Forms ### Get a list of forms diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailVersion.java b/src/main/java/com/mailerlite/sdk/emails/EmailVersion.java new file mode 100644 index 0000000..4dac399 --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/EmailVersion.java @@ -0,0 +1,28 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.emails; + +import com.google.gson.annotations.SerializedName; + +/** + * A version of an email: a saved copy of its content and its design settings. + */ +public class EmailVersion { + + /** The ID of the version. */ + @SerializedName("id") + public String id; + + /** The date and time when the API saved the version, for example "2026-10-28 12:00:00". */ + @SerializedName("created_at") + public String createdAt; + + /** True if the email has the content of this version now. */ + @SerializedName("current") + public boolean current; +} diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailVersionsList.java b/src/main/java/com/mailerlite/sdk/emails/EmailVersionsList.java new file mode 100644 index 0000000..c06540b --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/EmailVersionsList.java @@ -0,0 +1,21 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.emails; + +import com.google.gson.annotations.SerializedName; +import com.mailerlite.sdk.util.PaginatedResponse; + +/** + * The response of EmailVersionsRetriever.get. The list starts with the newest version. + */ +public class EmailVersionsList extends PaginatedResponse { + + /** The versions of the email. */ + @SerializedName("data") + public EmailVersion[] versions; +} diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailVersionsRetriever.java b/src/main/java/com/mailerlite/sdk/emails/EmailVersionsRetriever.java new file mode 100644 index 0000000..93c04a8 --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/EmailVersionsRetriever.java @@ -0,0 +1,68 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.emails; + +import com.mailerlite.sdk.MailerLite; +import com.mailerlite.sdk.MailerLiteApi; +import com.mailerlite.sdk.exceptions.MailerLiteException; +import com.mailerlite.sdk.util.CursorPaginatedRequest; + +/** + * Lists the versions of an email. Use page() and limit() to select a page, and then call get(). + */ +public class EmailVersionsRetriever extends CursorPaginatedRequest { + + /** The api object reference. */ + private MailerLite apiObjectReference; + + /** The path of the versions, with the checked email ID. */ + private String endpoint; + + /** + * Instantiates a new retriever. Use {@link Emails#versions(String)}. + * + * @param apiReference the api reference + * @param endpoint the path of the versions + */ + EmailVersionsRetriever(MailerLite apiReference, String endpoint) + { + this.apiObjectReference = apiReference; + this.endpoint = endpoint; + } + + /** + * Gets a page of versions, newest first. + * + * @return the versions + * @throws MailerLiteException if the API returns an error or the request fails + */ + public EmailVersionsList get() throws MailerLiteException + { + String query = this.getQueryParameters(); + + if (query.equals("?")) { + query = ""; + } + + MailerLiteApi api = new MailerLiteApi(); + api.setToken(apiObjectReference.getToken()); + + return api.sendRequest("GET", endpoint.concat(query), null, null, EmailVersionsList.class); + } + + /** + * Gets this instance. + * + * @return this retriever + */ + @Override + protected EmailVersionsRetriever getInstance() + { + return this; + } +} diff --git a/src/main/java/com/mailerlite/sdk/emails/Emails.java b/src/main/java/com/mailerlite/sdk/emails/Emails.java index e82fdc2..ab25458 100644 --- a/src/main/java/com/mailerlite/sdk/emails/Emails.java +++ b/src/main/java/com/mailerlite/sdk/emails/Emails.java @@ -272,6 +272,18 @@ public SingleEmailTemplate createTemplate(String emailId, String name) throws Ma return send("POST", endpoint, toJson(body), SingleEmailTemplate.class); } + /** + * Lists the versions of an email, newest first. Call get() on the result, after page() and limit() if you + * want them. + * + * @param emailId the email ID + * @return the retriever of the versions + */ + public EmailVersionsRetriever versions(String emailId) + { + return new EmailVersionsRetriever(apiObjectReference, emailPath(emailId).concat("/versions")); + } + /** * Checks the email ID and makes the path of the email. * diff --git a/src/main/java/com/mailerlite/sdk/util/ResponseMeta.java b/src/main/java/com/mailerlite/sdk/util/ResponseMeta.java index 7bead1f..d3414ae 100644 --- a/src/main/java/com/mailerlite/sdk/util/ResponseMeta.java +++ b/src/main/java/com/mailerlite/sdk/util/ResponseMeta.java @@ -20,7 +20,7 @@ public class ResponseMeta { @SerializedName("current_page") public int currentPage; - /** The from. */ + /** The number of the first item on the page. It is 0 when the API sends null, for example for an empty page. */ @SerializedName("from") public int from; @@ -40,7 +40,7 @@ public class ResponseMeta { @SerializedName("per_page") public int perPage; - /** The to. */ + /** The number of the last item on the page. It is 0 when the API sends null, for example for an empty page. */ @SerializedName("to") public int to; diff --git a/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java b/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java index 21fd418..1b8f9c5 100644 --- a/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java +++ b/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java @@ -49,6 +49,7 @@ import com.mailerlite.sdk.emails.SingleEmailTemplate; import com.mailerlite.sdk.emails.EmailSocialLink; import com.mailerlite.sdk.emails.EmailValidation; +import com.mailerlite.sdk.emails.EmailVersionsList; import com.mailerlite.sdk.emails.Emails; import com.mailerlite.sdk.emails.SingleEmailBlock; import com.mailerlite.sdk.emails.SingleEmailDocument; @@ -930,4 +931,74 @@ public void createTemplateRejectsInvalidInput() assertTrue(client.requests().isEmpty(), "the SDK must send no request"); } + + /** + * versions().get() sends GET /emails/{email}/versions without a query string, and decodes the list. + * + * @throws Exception if the request fails + */ + @Test + public void versionsWithoutOptions() throws Exception + { + client.respond(200, Fixtures.read("email_versions.json")); + + EmailVersionsList response = emails.versions("123").get(); + + assertRequest(client, "GET", "/emails/123/versions", null, null); + assertEquals(2, response.versions.length); + assertEquals("902", response.versions[0].id); + assertEquals("2026-10-28 12:05:00", response.versions[0].createdAt); + assertTrue(response.versions[0].current); + assertEquals("901", response.versions[1].id); + assertFalse(response.versions[1].current); + assertNull(response.links.previous); + assertNull(response.links.next); + assertEquals(1, response.meta.from); + assertEquals(2, response.meta.to); + assertEquals(2, response.meta.total); + } + + /** + * versions() sends the page and the limit. + * + * @throws Exception if the request fails + */ + @Test + public void versionsWithPageAndLimit() throws Exception + { + client.respond(200, Fixtures.read("email_versions.json")); + + emails.versions("123").page(2).limit(10).get(); + + assertRequest(client, "GET", "/emails/123/versions?limit=10&page=2", null, null); + } + + /** + * An empty page has null for meta.from and meta.to. The SDK reads them as 0. + * + * @throws Exception if the request fails + */ + @Test + public void versionsEmptyPageReadsNullFromAndToAsZero() throws Exception + { + client.respond(200, "{\"data\":[],\"links\":{\"first\":null,\"last\":null,\"prev\":null,\"next\":null}," + + "\"meta\":{\"current_page\":1,\"from\":null,\"last_page\":1,\"per_page\":25,\"to\":null,\"total\":0}}"); + + EmailVersionsList response = emails.versions("123").get(); + + assertEquals(0, response.versions.length); + assertEquals(0, response.meta.from); + assertEquals(0, response.meta.to); + assertEquals(0, response.meta.total); + } + + /** + * versions() checks the ID, and sends no request for an invalid ID. + */ + @Test + public void versionsRejectsInvalidIds() + { + assertRejectsIds(client, id -> () -> emails.versions(id).get()); + } + } diff --git a/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java b/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java index dedbcd4..69bccf0 100644 --- a/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java +++ b/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java @@ -192,6 +192,10 @@ private static List examples() .respond(201, Fixtures.read("email_template.json")) .expect("POST /emails/123456/templates")); + list.add(new Example("List the versions of an email") + .respond(200, Fixtures.read("email_versions.json")) + .expect("GET /emails/123456/versions?limit=10&page=1")); + return list; } diff --git a/src/test/resources/responses/email_versions.json b/src/test/resources/responses/email_versions.json new file mode 100644 index 0000000..d1881d8 --- /dev/null +++ b/src/test/resources/responses/email_versions.json @@ -0,0 +1,26 @@ +{ + "data": [ + {"id": "902", "created_at": "2026-10-28 12:05:00", "current": true}, + {"id": "901", "created_at": "2026-10-28 12:00:00", "current": false} + ], + "links": { + "first": "https://connect.mailerlite.com/api/emails/123/versions?page=1", + "last": "https://connect.mailerlite.com/api/emails/123/versions?page=1", + "prev": null, + "next": null + }, + "meta": { + "current_page": 1, + "from": 1, + "last_page": 1, + "links": [ + {"url": null, "label": "« Previous", "page": null, "active": false}, + {"url": "https://connect.mailerlite.com/api/emails/123/versions?page=1", "label": "1", "page": 1, "active": true}, + {"url": null, "label": "Next »", "page": null, "active": false} + ], + "path": "https://connect.mailerlite.com/api/emails/123/versions", + "per_page": 25, + "to": 2, + "total": 2 + } +} From 3f26d7f59c00d1a1990f5f74b9b9589d7ed6fbba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Thu, 29 Oct 2026 16:27:59 +0100 Subject: [PATCH 19/35] feat: add Emails.createVersion Emails.createVersion saves the email as a new version. The request has an empty body, not the JSON null. SingleEmailVersion.isCreated() is true for status 201 (a new version) and false for status 200 (nothing changed, and the response has the last version). Co-Authored-By: Claude Opus 5.5 --- README.md | 26 ++++++++++ .../com/mailerlite/sdk/emails/Emails.java | 15 ++++++ .../sdk/emails/SingleEmailVersion.java | 32 ++++++++++++ .../com/mailerlite/sdk/tests/EmailsTest.java | 52 +++++++++++++++++++ .../sdk/tests/ReadmeExamplesTest.java | 4 ++ .../resources/responses/email_version.json | 1 + 6 files changed, 130 insertions(+) create mode 100644 src/main/java/com/mailerlite/sdk/emails/SingleEmailVersion.java create mode 100644 src/test/resources/responses/email_version.json diff --git a/README.md b/README.md index b14dd48..353254d 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ MailerLite Java SDK - [Validate the design settings](#validate-the-design-settings) - [Save an email as a template](#save-an-email-as-a-template) - [List the versions of an email](#list-the-versions-of-an-email) + - [Save a version of an email](#save-a-version-of-an-email) - [Forms](#forms) - [Get a list of forms](#get-a-list-of-forms) - [Get a form](#get-a-form) @@ -1369,6 +1370,31 @@ class Example { } ``` +### Save a version of an email + +Writes through the API do not save versions. If nothing changed after the last version, the API saves no new version and returns the last version. `isCreated()` is `true` for a new version (status `201`), and `false` for the last version (status `200`). + +```java + +// import .... + +class Example { + + public void example() { + try { + + SingleEmailVersion response = this.getMailerLite().emails().createVersion("123456"); + + System.out.println(response.version.id + " " + response.isCreated()); + + } catch (MailerLiteException e) { + + e.printStackTrace(); + } + } +} +``` + ## Forms ### Get a list of forms diff --git a/src/main/java/com/mailerlite/sdk/emails/Emails.java b/src/main/java/com/mailerlite/sdk/emails/Emails.java index ab25458..043fa48 100644 --- a/src/main/java/com/mailerlite/sdk/emails/Emails.java +++ b/src/main/java/com/mailerlite/sdk/emails/Emails.java @@ -284,6 +284,21 @@ public EmailVersionsRetriever versions(String emailId) return new EmailVersionsRetriever(apiObjectReference, emailPath(emailId).concat("/versions")); } + /** + * Saves the email as a new version. The request has no body. + *

+ * If nothing changed after the last version, the API saves no new version and returns the last version. + * {@link SingleEmailVersion#isCreated()} tells the two cases apart. + * + * @param emailId the email ID + * @return the new version, or the last version + * @throws MailerLiteException if the API returns an error or the request fails + */ + public SingleEmailVersion createVersion(String emailId) throws MailerLiteException + { + return send("POST", emailPath(emailId).concat("/versions"), null, SingleEmailVersion.class); + } + /** * Checks the email ID and makes the path of the email. * diff --git a/src/main/java/com/mailerlite/sdk/emails/SingleEmailVersion.java b/src/main/java/com/mailerlite/sdk/emails/SingleEmailVersion.java new file mode 100644 index 0000000..513e324 --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/SingleEmailVersion.java @@ -0,0 +1,32 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.emails; + +import com.google.gson.annotations.SerializedName; +import com.mailerlite.sdk.MailerLiteResponse; + +/** + * The response of Emails.createVersion. + */ +public class SingleEmailVersion extends MailerLiteResponse { + + /** The new version, or the last version if nothing changed. */ + @SerializedName("data") + public EmailVersion version; + + /** + * Tells if the API saved a new version. If nothing changed after the last version, the API saves no new + * version, returns the last version, and the status is 200. + * + * @return true if the status is 201 + */ + public boolean isCreated() + { + return responseStatusCode == 201; + } +} diff --git a/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java b/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java index 1b8f9c5..1e6fc09 100644 --- a/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java +++ b/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java @@ -47,6 +47,7 @@ import com.mailerlite.sdk.emails.EmailTypographySettings; import com.mailerlite.sdk.emails.SingleEmailSettings; import com.mailerlite.sdk.emails.SingleEmailTemplate; +import com.mailerlite.sdk.emails.SingleEmailVersion; import com.mailerlite.sdk.emails.EmailSocialLink; import com.mailerlite.sdk.emails.EmailValidation; import com.mailerlite.sdk.emails.EmailVersionsList; @@ -1001,4 +1002,55 @@ public void versionsRejectsInvalidIds() assertRejectsIds(client, id -> () -> emails.versions(id).get()); } + + /** + * createVersion sends POST /emails/{email}/versions with an empty body, not the JSON null. + * Status 201 means a new version. + * + * @throws Exception if the request fails + */ + @Test + public void createVersionNew() throws Exception + { + client.respond(201, Fixtures.read("email_version.json")); + + SingleEmailVersion response = emails.createVersion("123"); + + RecordedRequest request = assertRequest(client, "POST", "/emails/123/versions", "", null); + assertTrue(request.hasBodyPublisher); + assertEquals(0, request.contentLength); + assertEquals(0, request.body.length); + assertTrue(response.isCreated()); + assertEquals(201, response.responseStatusCode); + assertEquals("903", response.version.id); + assertEquals("2026-10-28 12:10:00", response.version.createdAt); + assertTrue(response.version.current); + } + + /** + * Status 200 means that nothing changed, and the response has the last version. + * + * @throws Exception if the request fails + */ + @Test + public void createVersionUnchanged() throws Exception + { + client.respond(200, Fixtures.read("email_version.json")); + + SingleEmailVersion response = emails.createVersion("123"); + + assertFalse(response.isCreated()); + assertEquals(200, response.responseStatusCode); + assertEquals("903", response.version.id); + } + + /** + * createVersion checks the ID, and sends no request for an invalid ID. + */ + @Test + public void createVersionRejectsInvalidIds() + { + assertRejectsIds(client, id -> () -> emails.createVersion(id)); + } + } diff --git a/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java b/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java index 69bccf0..a57e32e 100644 --- a/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java +++ b/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java @@ -196,6 +196,10 @@ private static List examples() .respond(200, Fixtures.read("email_versions.json")) .expect("GET /emails/123456/versions?limit=10&page=1")); + list.add(new Example("Save a version of an email") + .respond(201, Fixtures.read("email_version.json")) + .expect("POST /emails/123456/versions")); + return list; } diff --git a/src/test/resources/responses/email_version.json b/src/test/resources/responses/email_version.json new file mode 100644 index 0000000..8e0ef77 --- /dev/null +++ b/src/test/resources/responses/email_version.json @@ -0,0 +1 @@ +{"data": {"id": "903", "created_at": "2026-10-28 12:10:00", "current": true}} From e3d197fc7bd1e9a011a3883ab54b1a64f8430f31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Thu, 29 Oct 2026 16:28:27 +0100 Subject: [PATCH 20/35] feat: add Emails.restoreVersion Emails.restoreVersion puts an email back to a version. It sends the etag in the body, and returns the full email with the new etag. Co-Authored-By: Claude Opus 5.5 --- README.md | 26 ++++++++++++++++ .../com/mailerlite/sdk/emails/Emails.java | 19 ++++++++++++ .../com/mailerlite/sdk/tests/EmailsTest.java | 30 +++++++++++++++++++ .../sdk/tests/ReadmeExamplesTest.java | 4 +++ 4 files changed, 79 insertions(+) diff --git a/README.md b/README.md index 353254d..d6dff8a 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ MailerLite Java SDK - [Save an email as a template](#save-an-email-as-a-template) - [List the versions of an email](#list-the-versions-of-an-email) - [Save a version of an email](#save-a-version-of-an-email) + - [Restore a version of an email](#restore-a-version-of-an-email) - [Forms](#forms) - [Get a list of forms](#get-a-list-of-forms) - [Get a form](#get-a-form) @@ -1395,6 +1396,31 @@ class Example { } ``` +### Restore a version of an email + +Puts the email back to the version, and returns the full email with the new etag. Before the restore, the API saves the current state as a version, so you can undo the restore. + +```java + +// import .... + +class Example { + + public void example() { + try { + + SingleEmailDocument response = this.getMailerLite().emails().restoreVersion("123456", "901", "etag-from-the-last-response"); + + System.out.println(response.etag); + + } catch (MailerLiteException e) { + + e.printStackTrace(); + } + } +} +``` + ## Forms ### Get a list of forms diff --git a/src/main/java/com/mailerlite/sdk/emails/Emails.java b/src/main/java/com/mailerlite/sdk/emails/Emails.java index 043fa48..6f0aa06 100644 --- a/src/main/java/com/mailerlite/sdk/emails/Emails.java +++ b/src/main/java/com/mailerlite/sdk/emails/Emails.java @@ -299,6 +299,25 @@ public SingleEmailVersion createVersion(String emailId) throws MailerLiteExcepti return send("POST", emailPath(emailId).concat("/versions"), null, SingleEmailVersion.class); } + /** + * Puts an email back to a version, and returns the full email with the new etag. + *

+ * Before the restore, the API saves the current state as a version, so you can undo the restore. + * + * @param emailId the email ID + * @param versionId the version ID + * @param etag the etag from the last response for the email + * @return the email and the new etag + * @throws MailerLiteException if the API returns an error or the request fails + */ + public SingleEmailDocument restoreVersion(String emailId, String versionId, String etag) throws MailerLiteException + { + String endpoint = emailPath(emailId).concat("/versions/") + .concat(RequestChecks.pathId(versionId, "versionId")).concat("/restore"); + + return send("POST", endpoint, etagBody(etag), SingleEmailDocument.class); + } + /** * Checks the email ID and makes the path of the email. * diff --git a/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java b/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java index 1e6fc09..a66adaa 100644 --- a/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java +++ b/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java @@ -1053,4 +1053,34 @@ public void createVersionRejectsInvalidIds() assertRejectsIds(client, id -> () -> emails.createVersion(id)); } + + /** + * restoreVersion sends POST /emails/{email}/versions/{version}/restore with the etag, and decodes the email. + * + * @throws Exception if the request fails + */ + @Test + public void restoreVersionSendsTheEtag() throws Exception + { + client.respond(200, Fixtures.read("email.json")); + + SingleEmailDocument response = emails.restoreVersion("123", "901", "47"); + + assertRequest(client, "POST", "/emails/123/versions/901/restore", "{\"etag\":\"47\"}", null); + assertEquals("42", response.etag); + assertEquals("123", response.email.id); + assertEquals(2, response.email.blocks.length); + } + + /** + * restoreVersion checks the IDs and the etag, and sends no request when a check fails. + */ + @Test + public void restoreVersionRejectsInvalidInput() + { + assertRejectsIds(client, id -> () -> emails.restoreVersion(id, "901", "47")); + assertRejectsIds(client, id -> () -> emails.restoreVersion("123", id, "47")); + assertRejectsEtags(client, etag -> () -> emails.restoreVersion("123", "901", etag)); + } + } diff --git a/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java b/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java index a57e32e..7dd9c26 100644 --- a/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java +++ b/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java @@ -200,6 +200,10 @@ private static List examples() .respond(201, Fixtures.read("email_version.json")) .expect("POST /emails/123456/versions")); + list.add(new Example("Restore a version of an email") + .respond(200, Fixtures.read("email.json")) + .expect("POST /emails/123456/versions/901/restore")); + return list; } From d5cf12da6fc02f9bdcf4f1ca9a2233604d7e937a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Thu, 29 Oct 2026 16:29:18 +0100 Subject: [PATCH 21/35] feat: add BrandStyles.get Add the BrandStyles service and the accessor MailerLite.brandStyles(). BrandStyles.get returns the brand styles of the account, the default palette and the presets. The brand is null when the account has none. The JSON key "default" is a Java keyword, so the field is defaultStyle. Co-Authored-By: Claude Opus 5.5 --- README.md | 34 +++++- .../java/com/mailerlite/sdk/MailerLite.java | 19 +++ .../sdk/brandstyles/BrandStyle.java | 57 +++++++++ .../sdk/brandstyles/BrandStyles.java | 45 ++++++++ .../sdk/brandstyles/BrandStylesData.java | 28 +++++ .../sdk/brandstyles/SingleBrandStyles.java | 21 ++++ .../mailerlite/sdk/tests/BrandStylesTest.java | 108 ++++++++++++++++++ .../sdk/tests/ReadmeExamplesTest.java | 5 + .../resources/responses/brand_styles.json | 50 ++++++++ 9 files changed, 366 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/mailerlite/sdk/brandstyles/BrandStyle.java create mode 100644 src/main/java/com/mailerlite/sdk/brandstyles/BrandStyles.java create mode 100644 src/main/java/com/mailerlite/sdk/brandstyles/BrandStylesData.java create mode 100644 src/main/java/com/mailerlite/sdk/brandstyles/SingleBrandStyles.java create mode 100644 src/test/java/com/mailerlite/sdk/tests/BrandStylesTest.java create mode 100644 src/test/resources/responses/brand_styles.json diff --git a/README.md b/README.md index d6dff8a..09dd99b 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,8 @@ MailerLite Java SDK - [List the versions of an email](#list-the-versions-of-an-email) - [Save a version of an email](#save-a-version-of-an-email) - [Restore a version of an email](#restore-a-version-of-an-email) + - [Brand styles](#brand-styles) + - [Get the brand styles](#get-the-brand-styles) - [Forms](#forms) - [Get a list of forms](#get-a-list-of-forms) - [Get a form](#get-a-form) @@ -1247,7 +1249,7 @@ Set only the fields that you want to change. The groups are `brand`, `layout`, ` - All style values are strings, for example `"16"` and not `16`. - The API stores each value that you send, also a value that the email inherits from your account. Then the email no longer follows your account for that value. So do not send back all the settings that you read. - `footer.socialLinks` replaces the whole list. An empty list removes all the links. Keep the `linkId` of each link to keep its click history. -- `brand.mode` is one of `EmailBrandModes`. To apply a preset palette, set `brand.style` to the `identifier` of the preset from the brand styles. +- `brand.mode` is one of `EmailBrandModes`. To apply a preset palette, set `brand.style` to the `identifier` of the preset from [the brand styles](#get-the-brand-styles). - The SDK sends the etag at the top level of the body. Do not put it in the settings. - If the API removed unsafe values, the response has `rejected` with the paths of these values. @@ -1421,6 +1423,36 @@ class Example { } ``` +## Brand styles + +### Get the brand styles + +Returns the palettes of your account: `brand` (your brand styles, or `null` if your account has none), `defaultStyle` (the JSON key `default`) and the `presets`. Match a palette on its `identifier`, not on its `name`. To apply a preset to an email, [update the design settings](#update-the-design-settings) with `brand.style` set to the `identifier`. + +```java + +// import .... + +class Example { + + public void example() { + try { + + SingleBrandStyles response = this.getMailerLite().brandStyles().get(); + + for (BrandStyle preset : response.brandStyles.presets) { + + System.out.println(preset.identifier + " " + preset.name); + } + + } catch (MailerLiteException e) { + + e.printStackTrace(); + } + } +} +``` + ## Forms ### Get a list of forms diff --git a/src/main/java/com/mailerlite/sdk/MailerLite.java b/src/main/java/com/mailerlite/sdk/MailerLite.java index 87987c4..109ad3a 100644 --- a/src/main/java/com/mailerlite/sdk/MailerLite.java +++ b/src/main/java/com/mailerlite/sdk/MailerLite.java @@ -8,6 +8,7 @@ package com.mailerlite.sdk; import com.mailerlite.sdk.automations.Automations; +import com.mailerlite.sdk.brandstyles.BrandStyles; import com.mailerlite.sdk.campaigns.Campaigns; import com.mailerlite.sdk.emails.Emails; import com.mailerlite.sdk.fields.Fields; @@ -53,6 +54,9 @@ public class MailerLite /** The emails. */ protected Emails emails; + /** The brand styles. */ + protected BrandStyles brandStyles; + /** * Campaigns. * @@ -192,6 +196,21 @@ public Emails emails() } + /** + * Brand styles. + * + * @return the brand styles + */ + public BrandStyles brandStyles() + { + if (brandStyles == null) { + brandStyles = new BrandStyles(this); + } + + return brandStyles; + } + + /** * Sets the MailerLite API token. * diff --git a/src/main/java/com/mailerlite/sdk/brandstyles/BrandStyle.java b/src/main/java/com/mailerlite/sdk/brandstyles/BrandStyle.java new file mode 100644 index 0000000..f625151 --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/brandstyles/BrandStyle.java @@ -0,0 +1,57 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.brandstyles; + +import com.google.gson.annotations.SerializedName; + +/** + * A palette: one font and six colors. + * + * The identifier does not change. The name is only for display, and it can change. So match a palette on its + * identifier, not on its name. + */ +public class BrandStyle { + + /** + * The identifier of the palette, for example "preset-retro". To apply a preset to an email, set it in EmailBrandSettings.style. + */ + @SerializedName("identifier") + public String identifier; + + /** The display name of the palette. */ + @SerializedName("name") + public String name; + + /** The font family. */ + @SerializedName("font_family") + public String fontFamily; + + /** The primary color. */ + @SerializedName("primary_color") + public String primaryColor; + + /** The secondary color. */ + @SerializedName("secondary_color") + public String secondaryColor; + + /** The heading color. */ + @SerializedName("heading_color") + public String headingColor; + + /** The text color. */ + @SerializedName("text_color") + public String textColor; + + /** The border color. */ + @SerializedName("border_color") + public String borderColor; + + /** The background color. */ + @SerializedName("background_color") + public String backgroundColor; +} diff --git a/src/main/java/com/mailerlite/sdk/brandstyles/BrandStyles.java b/src/main/java/com/mailerlite/sdk/brandstyles/BrandStyles.java new file mode 100644 index 0000000..042cd7b --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/brandstyles/BrandStyles.java @@ -0,0 +1,45 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.brandstyles; + +import com.mailerlite.sdk.MailerLite; +import com.mailerlite.sdk.MailerLiteApi; +import com.mailerlite.sdk.exceptions.MailerLiteException; + +/** + * The brand styles endpoint: the palettes that you can apply to an email built with the simple editor. + */ +public class BrandStyles { + + /** The api object reference. */ + private MailerLite apiObjectReference; + + /** + * Instantiates a new brand styles service. + * + * @param apiReference the api reference + */ + public BrandStyles(MailerLite apiReference) + { + apiObjectReference = apiReference; + } + + /** + * Gets the brand styles of the account, the default palette and the presets. + * + * @return the palettes + * @throws MailerLiteException if the API returns an error or the request fails + */ + public SingleBrandStyles get() throws MailerLiteException + { + MailerLiteApi api = new MailerLiteApi(); + api.setToken(apiObjectReference.getToken()); + + return api.sendRequest("GET", "/brand-styles", null, null, SingleBrandStyles.class); + } +} diff --git a/src/main/java/com/mailerlite/sdk/brandstyles/BrandStylesData.java b/src/main/java/com/mailerlite/sdk/brandstyles/BrandStylesData.java new file mode 100644 index 0000000..1531668 --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/brandstyles/BrandStylesData.java @@ -0,0 +1,28 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.brandstyles; + +import com.google.gson.annotations.SerializedName; + +/** + * The palettes of the account. + */ +public class BrandStylesData { + + /** The brand styles of the account, or null if the account has none. */ + @SerializedName("brand") + public BrandStyle brand; + + /** The default palette of the editor. The JSON key is "default", which is a Java keyword. */ + @SerializedName("default") + public BrandStyle defaultStyle; + + /** The preset palettes. */ + @SerializedName("presets") + public BrandStyle[] presets; +} diff --git a/src/main/java/com/mailerlite/sdk/brandstyles/SingleBrandStyles.java b/src/main/java/com/mailerlite/sdk/brandstyles/SingleBrandStyles.java new file mode 100644 index 0000000..a1ddcff --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/brandstyles/SingleBrandStyles.java @@ -0,0 +1,21 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.brandstyles; + +import com.google.gson.annotations.SerializedName; +import com.mailerlite.sdk.MailerLiteResponse; + +/** + * The response of BrandStyles.get. + */ +public class SingleBrandStyles extends MailerLiteResponse { + + /** The palettes of the account. */ + @SerializedName("data") + public BrandStylesData brandStyles; +} diff --git a/src/test/java/com/mailerlite/sdk/tests/BrandStylesTest.java b/src/test/java/com/mailerlite/sdk/tests/BrandStylesTest.java new file mode 100644 index 0000000..e1db429 --- /dev/null +++ b/src/test/java/com/mailerlite/sdk/tests/BrandStylesTest.java @@ -0,0 +1,108 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.mailerlite.sdk.MailerLite; +import com.mailerlite.sdk.brandstyles.BrandStyle; +import com.mailerlite.sdk.brandstyles.BrandStylesData; +import com.mailerlite.sdk.brandstyles.SingleBrandStyles; +import com.mailerlite.sdk.support.Fixtures; +import com.mailerlite.sdk.support.RecordingHttpClient; + +/** + * Tests the brand styles endpoint with the recording HTTP client. + */ +public class BrandStylesTest { + + /** The fake HTTP client. */ + private RecordingHttpClient client; + + /** The MailerLite object. */ + private MailerLite ml; + + /** + * Sets the fake HTTP client. + */ + @BeforeEach + public void setupEach() + { + client = RecordingHttpClient.install(); + + ml = new MailerLite(); + ml.setToken("test-token"); + } + + /** + * Removes the fake HTTP client. + */ + @AfterEach + public void afterEach() + { + RecordingHttpClient.uninstall(); + } + + /** + * get sends GET /brand-styles and decodes the brand, the default palette and the presets. + * + * @throws Exception if the request fails + */ + @Test + public void getDecodesThePalettes() throws Exception + { + client.respond(200, Fixtures.read("brand_styles.json")); + + SingleBrandStyles response = ml.brandStyles().get(); + + EmailsTest.assertRequest(client, "GET", "/brand-styles", null, null); + + BrandStylesData data = response.brandStyles; + assertEquals("account", data.brand.identifier); + assertEquals("Brand styles", data.brand.name); + assertEquals("#2cb191", data.brand.primaryColor); + + BrandStyle defaultStyle = data.defaultStyle; + assertEquals("default-brand", defaultStyle.identifier); + assertEquals("Default", defaultStyle.name); + assertEquals("Inter, sans-serif", defaultStyle.fontFamily); + assertEquals("#09c269", defaultStyle.primaryColor); + assertEquals("#000000", defaultStyle.secondaryColor); + assertEquals("#000000", defaultStyle.headingColor); + assertEquals("#000000", defaultStyle.textColor); + assertEquals("#e5e7eb", defaultStyle.borderColor); + assertEquals("#ffffff", defaultStyle.backgroundColor); + + assertEquals(2, data.presets.length); + assertEquals("preset-business-casual", data.presets[0].identifier); + assertEquals("preset-retro", data.presets[1].identifier); + assertEquals("Courier New, monospace", data.presets[1].fontFamily); + } + + /** + * brand is null when the account has no brand styles. + * + * @throws Exception if the request fails + */ + @Test + public void getWithoutBrand() throws Exception + { + client.respond(200, "{\"data\":{\"brand\":null,\"default\":{\"identifier\":\"default-brand\",\"name\":\"Default\"},\"presets\":[]}}"); + + SingleBrandStyles response = ml.brandStyles().get(); + + assertNull(response.brandStyles.brand); + assertEquals("default-brand", response.brandStyles.defaultStyle.identifier); + assertEquals(0, response.brandStyles.presets.length); + } +} diff --git a/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java b/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java index 7dd9c26..2a87c26 100644 --- a/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java +++ b/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java @@ -55,6 +55,7 @@ public class ReadmeExamplesTest { /** The imports that the README examples get. The README shows them as "// import ....". */ private static final List IMPORTS = Arrays.asList( "com.mailerlite.sdk.*", + "com.mailerlite.sdk.brandstyles.*", "com.mailerlite.sdk.campaigns.*", "com.mailerlite.sdk.emails.*", "com.mailerlite.sdk.exceptions.*", @@ -204,6 +205,10 @@ private static List examples() .respond(200, Fixtures.read("email.json")) .expect("POST /emails/123456/versions/901/restore")); + list.add(new Example("Get the brand styles") + .respond(200, Fixtures.read("brand_styles.json")) + .expect("GET /brand-styles")); + return list; } diff --git a/src/test/resources/responses/brand_styles.json b/src/test/resources/responses/brand_styles.json new file mode 100644 index 0000000..dab5ce3 --- /dev/null +++ b/src/test/resources/responses/brand_styles.json @@ -0,0 +1,50 @@ +{ + "data": { + "brand": { + "identifier": "account", + "name": "Brand styles", + "font_family": "Inter, sans-serif", + "primary_color": "#2cb191", + "secondary_color": "#1f2937", + "heading_color": "#111827", + "text_color": "#374151", + "border_color": "#e5e7eb", + "background_color": "#ffffff" + }, + "default": { + "identifier": "default-brand", + "name": "Default", + "font_family": "Inter, sans-serif", + "primary_color": "#09c269", + "secondary_color": "#000000", + "heading_color": "#000000", + "text_color": "#000000", + "border_color": "#e5e7eb", + "background_color": "#ffffff" + }, + "presets": [ + { + "identifier": "preset-business-casual", + "name": "Business casual", + "font_family": "Georgia, serif", + "primary_color": "#1d4ed8", + "secondary_color": "#1e3a8a", + "heading_color": "#0f172a", + "text_color": "#334155", + "border_color": "#cbd5e1", + "background_color": "#f8fafc" + }, + { + "identifier": "preset-retro", + "name": "Retro", + "font_family": "Courier New, monospace", + "primary_color": "#d97706", + "secondary_color": "#92400e", + "heading_color": "#451a03", + "text_color": "#78350f", + "border_color": "#fde68a", + "background_color": "#fffbeb" + } + ] + } +} From 8ef1fe140e4d70d4be5cdd8ab5338956be238160 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Thu, 29 Oct 2026 16:30:40 +0100 Subject: [PATCH 22/35] feat: add the files list Add the MediaFiles service and the accessor MailerLite.files(). Its retriever lists the files of the media library, with a name filter, a type filter, any other filter, a sort, a page and a limit. Add MediaFileTypes and MediaFileSorts. The names File and Files are not used, because they clash with java.io.File and java.nio.file.Files. Co-Authored-By: Claude Opus 5.5 --- README.md | 42 ++++++ .../java/com/mailerlite/sdk/MailerLite.java | 19 +++ .../com/mailerlite/sdk/files/MediaFile.java | 48 +++++++ .../sdk/files/MediaFileRetriever.java | 115 +++++++++++++++ .../mailerlite/sdk/files/MediaFileSorts.java | 39 +++++ .../mailerlite/sdk/files/MediaFileTypes.java | 31 ++++ .../com/mailerlite/sdk/files/MediaFiles.java | 39 +++++ .../mailerlite/sdk/files/MediaFilesList.java | 21 +++ .../mailerlite/sdk/tests/MediaFilesTest.java | 135 ++++++++++++++++++ .../sdk/tests/ReadmeExamplesTest.java | 5 + src/test/resources/responses/files.json | 44 ++++++ 11 files changed, 538 insertions(+) create mode 100644 src/main/java/com/mailerlite/sdk/files/MediaFile.java create mode 100644 src/main/java/com/mailerlite/sdk/files/MediaFileRetriever.java create mode 100644 src/main/java/com/mailerlite/sdk/files/MediaFileSorts.java create mode 100644 src/main/java/com/mailerlite/sdk/files/MediaFileTypes.java create mode 100644 src/main/java/com/mailerlite/sdk/files/MediaFiles.java create mode 100644 src/main/java/com/mailerlite/sdk/files/MediaFilesList.java create mode 100644 src/test/java/com/mailerlite/sdk/tests/MediaFilesTest.java create mode 100644 src/test/resources/responses/files.json diff --git a/README.md b/README.md index 09dd99b..07de65d 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,8 @@ MailerLite Java SDK - [Restore a version of an email](#restore-a-version-of-an-email) - [Brand styles](#brand-styles) - [Get the brand styles](#get-the-brand-styles) + - [Files](#files) + - [Get a list of files](#get-a-list-of-files) - [Forms](#forms) - [Get a list of forms](#get-a-list-of-forms) - [Get a form](#get-a-form) @@ -1453,6 +1455,46 @@ class Example { } ``` +## Files + +### Get a list of files + +Returns the files of your media library. Use their `url` in image blocks. `width` and `height` are `null` for files that are not images. + +- `nameFilter` matches a part of the file name. `typeFilter` takes one of `MediaFileTypes`. `filter(name, value)` adds any other filter as `filter[name]`. +- `sort` takes one of `MediaFileSorts`. The default is the newest files first. +- The SDK encodes the values, so a space is sent as `+`. A `null` value is not sent. + +```java + +// import .... + +class Example { + + public void example() { + try { + + MediaFilesList response = this.getMailerLite().files().retriever() + .nameFilter("summer sale") + .typeFilter(MediaFileTypes.IMAGE) + .sort(MediaFileSorts.SIZE_DESC) + .page(1) + .limit(25) + .get(); + + for (MediaFile file : response.files) { + + System.out.println(file.name + " " + file.url); + } + + } catch (MailerLiteException e) { + + e.printStackTrace(); + } + } +} +``` + ## Forms ### Get a list of forms diff --git a/src/main/java/com/mailerlite/sdk/MailerLite.java b/src/main/java/com/mailerlite/sdk/MailerLite.java index 109ad3a..8939902 100644 --- a/src/main/java/com/mailerlite/sdk/MailerLite.java +++ b/src/main/java/com/mailerlite/sdk/MailerLite.java @@ -12,6 +12,7 @@ import com.mailerlite.sdk.campaigns.Campaigns; import com.mailerlite.sdk.emails.Emails; import com.mailerlite.sdk.fields.Fields; +import com.mailerlite.sdk.files.MediaFiles; import com.mailerlite.sdk.forms.Forms; import com.mailerlite.sdk.groups.Groups; import com.mailerlite.sdk.segments.Segments; @@ -57,6 +58,9 @@ public class MailerLite /** The brand styles. */ protected BrandStyles brandStyles; + /** The files of the media library. */ + protected MediaFiles files; + /** * Campaigns. * @@ -211,6 +215,21 @@ public BrandStyles brandStyles() } + /** + * Files of the media library. + * + * @return the files + */ + public MediaFiles files() + { + if (files == null) { + files = new MediaFiles(this); + } + + return files; + } + + /** * Sets the MailerLite API token. * diff --git a/src/main/java/com/mailerlite/sdk/files/MediaFile.java b/src/main/java/com/mailerlite/sdk/files/MediaFile.java new file mode 100644 index 0000000..8ddea6e --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/files/MediaFile.java @@ -0,0 +1,48 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.files; + +import com.google.gson.annotations.SerializedName; + +/** + * A file in the media library of the account. Use its URL in image blocks. + */ +public class MediaFile { + + /** The ID of the file. */ + @SerializedName("id") + public String id; + + /** The file type, for example "image". See MediaFileTypes. The SDK accepts any value. */ + @SerializedName("type") + public String type; + + /** The file name. */ + @SerializedName("name") + public String name; + + /** The public URL of the file. */ + @SerializedName("url") + public String url; + + /** The size of the file in bytes. */ + @SerializedName("size") + public long size; + + /** The width in pixels, or null if the file is not an image. */ + @SerializedName("width") + public Integer width; + + /** The height in pixels, or null if the file is not an image. */ + @SerializedName("height") + public Integer height; + + /** The date and time when the file was added, for example "2026-10-20 09:30:00". */ + @SerializedName("created_at") + public String createdAt; +} diff --git a/src/main/java/com/mailerlite/sdk/files/MediaFileRetriever.java b/src/main/java/com/mailerlite/sdk/files/MediaFileRetriever.java new file mode 100644 index 0000000..a230517 --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/files/MediaFileRetriever.java @@ -0,0 +1,115 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.files; + +import com.mailerlite.sdk.MailerLite; +import com.mailerlite.sdk.MailerLiteApi; +import com.mailerlite.sdk.exceptions.MailerLiteException; +import com.mailerlite.sdk.util.CursorPaginatedRequest; + +/** + * Lists the files of the media library. Set the filters, the sort, the page and the limit, and then call get(). + *

+ * The SDK encodes each value, so a space is sent as "+". A null value is not sent. An empty value is sent as + * "name=", and the API then ignores it. + */ +public class MediaFileRetriever extends CursorPaginatedRequest { + + /** The api object reference. */ + private MailerLite apiObjectReference; + + /** + * Instantiates a new retriever. Use {@link MediaFiles#retriever()}. + * + * @param apiReference the api reference + */ + public MediaFileRetriever(MailerLite apiReference) + { + apiObjectReference = apiReference; + } + + /** + * Filters the files on a part of the name. + * + * @param name a part of the file name + * @return this retriever + */ + public MediaFileRetriever nameFilter(String name) + { + return filter("name", name); + } + + /** + * Filters the files on the type. + * + * @param type the file type, for example {@link MediaFileTypes#IMAGE} + * @return this retriever + */ + public MediaFileRetriever typeFilter(String type) + { + return filter("type", type); + } + + /** + * Adds a filter. The SDK sends it as "filter[name]=value". + * + * @param name the filter name, for example "name" or "type" + * @param value the value + * @return this retriever + */ + public MediaFileRetriever filter(String name, String value) + { + this.addQueryParameter("filter[".concat(name).concat("]"), value); + + return this; + } + + /** + * Sorts the files. + * + * @param sort one of the values in {@link MediaFileSorts} + * @return this retriever + */ + public MediaFileRetriever sort(String sort) + { + this.addQueryParameter("sort", sort); + + return this; + } + + /** + * Gets a page of files. + * + * @return the files + * @throws MailerLiteException if the API returns an error or the request fails + */ + public MediaFilesList get() throws MailerLiteException + { + String query = this.getQueryParameters(); + + if (query.equals("?")) { + query = ""; + } + + MailerLiteApi api = new MailerLiteApi(); + api.setToken(apiObjectReference.getToken()); + + return api.sendRequest("GET", "/files".concat(query), null, null, MediaFilesList.class); + } + + /** + * Gets this instance. + * + * @return this retriever + */ + @Override + protected MediaFileRetriever getInstance() + { + return this; + } +} diff --git a/src/main/java/com/mailerlite/sdk/files/MediaFileSorts.java b/src/main/java/com/mailerlite/sdk/files/MediaFileSorts.java new file mode 100644 index 0000000..247a82f --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/files/MediaFileSorts.java @@ -0,0 +1,39 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.files; + +/** + * The sort values of the file list. The default is {@link #CREATED_AT_DESC}: the newest files first. + */ +public final class MediaFileSorts { + + /** The oldest files first. */ + public static final String CREATED_AT = "created_at"; + + /** The newest files first. */ + public static final String CREATED_AT_DESC = "-created_at"; + + /** By name, from A to Z. */ + public static final String NAME = "name"; + + /** By name, from Z to A. */ + public static final String NAME_DESC = "-name"; + + /** The smallest files first. */ + public static final String SIZE = "size"; + + /** The largest files first. */ + public static final String SIZE_DESC = "-size"; + + /** + * Do not make instances of this class. + */ + private MediaFileSorts() + { + } +} diff --git a/src/main/java/com/mailerlite/sdk/files/MediaFileTypes.java b/src/main/java/com/mailerlite/sdk/files/MediaFileTypes.java new file mode 100644 index 0000000..6cfaefc --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/files/MediaFileTypes.java @@ -0,0 +1,31 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.files; + +/** + * The file types of the media library, for the type filter. The API can add new types, so the filter accepts any + * string. + */ +public final class MediaFileTypes { + + /** An image. */ + public static final String IMAGE = "image"; + + /** A PDF document. */ + public static final String PDF = "pdf"; + + /** An EPUB book. */ + public static final String EPUB = "epub"; + + /** + * Do not make instances of this class. + */ + private MediaFileTypes() + { + } +} diff --git a/src/main/java/com/mailerlite/sdk/files/MediaFiles.java b/src/main/java/com/mailerlite/sdk/files/MediaFiles.java new file mode 100644 index 0000000..75e1fac --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/files/MediaFiles.java @@ -0,0 +1,39 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.files; + +import com.mailerlite.sdk.MailerLite; + +/** + * The files endpoint: the files of the media library of the account. + */ +public class MediaFiles { + + /** The api object reference. */ + private MailerLite apiObjectReference; + + /** + * Instantiates a new files service. + * + * @param apiReference the api reference + */ + public MediaFiles(MailerLite apiReference) + { + apiObjectReference = apiReference; + } + + /** + * Lists the files. + * + * @return the retriever + */ + public MediaFileRetriever retriever() + { + return new MediaFileRetriever(apiObjectReference); + } +} diff --git a/src/main/java/com/mailerlite/sdk/files/MediaFilesList.java b/src/main/java/com/mailerlite/sdk/files/MediaFilesList.java new file mode 100644 index 0000000..ce8e31a --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/files/MediaFilesList.java @@ -0,0 +1,21 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.files; + +import com.google.gson.annotations.SerializedName; +import com.mailerlite.sdk.util.PaginatedResponse; + +/** + * The response of MediaFileRetriever.get. + */ +public class MediaFilesList extends PaginatedResponse { + + /** The files. */ + @SerializedName("data") + public MediaFile[] files; +} diff --git a/src/test/java/com/mailerlite/sdk/tests/MediaFilesTest.java b/src/test/java/com/mailerlite/sdk/tests/MediaFilesTest.java new file mode 100644 index 0000000..b52009a --- /dev/null +++ b/src/test/java/com/mailerlite/sdk/tests/MediaFilesTest.java @@ -0,0 +1,135 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.mailerlite.sdk.MailerLite; +import com.mailerlite.sdk.files.MediaFile; +import com.mailerlite.sdk.files.MediaFileSorts; +import com.mailerlite.sdk.files.MediaFileTypes; +import com.mailerlite.sdk.files.MediaFilesList; +import com.mailerlite.sdk.support.Fixtures; +import com.mailerlite.sdk.support.RecordingHttpClient; + +/** + * Tests the files endpoint with the recording HTTP client. + */ +public class MediaFilesTest { + + /** The fake HTTP client. */ + private RecordingHttpClient client; + + /** The MailerLite object. */ + private MailerLite ml; + + /** + * Sets the fake HTTP client. + */ + @BeforeEach + public void setupEach() + { + client = RecordingHttpClient.install(); + client.respond(200, Fixtures.read("files.json")); + + ml = new MailerLite(); + ml.setToken("test-token"); + } + + /** + * Removes the fake HTTP client. + */ + @AfterEach + public void afterEach() + { + RecordingHttpClient.uninstall(); + } + + /** + * get() without options sends GET /files without a query string, and decodes the files. + * + * @throws Exception if the request fails + */ + @Test + public void listWithoutOptions() throws Exception + { + MediaFilesList response = ml.files().retriever().get(); + + EmailsTest.assertRequest(client, "GET", "/files", null, null); + assertEquals(2, response.files.length); + + MediaFile image = response.files[0]; + assertEquals("301", image.id); + assertEquals("image", image.type); + assertEquals("summer sale banner.png", image.name); + assertEquals("https://storage.example.com/files/summer-sale-banner.png", image.url); + assertEquals(48213L, image.size); + assertEquals(Integer.valueOf(1200), image.width); + assertEquals(Integer.valueOf(600), image.height); + assertEquals("2026-10-20 09:30:00", image.createdAt); + + MediaFile pdf = response.files[1]; + assertEquals("pdf", pdf.type); + assertNull(pdf.width); + assertNull(pdf.height); + + assertNull(response.links.next); + assertEquals(2, response.meta.total); + } + + /** + * A name filter with spaces keeps the key filter[name] and sends the spaces as "+". + * + * @throws Exception if the request fails + */ + @Test + public void nameFilterWithSpaces() throws Exception + { + ml.files().retriever().nameFilter("summer sale banner").get(); + + EmailsTest.assertRequest(client, "GET", "/files?filter[name]=summer+sale+banner", null, null); + } + + /** + * An empty filter value is sent as "key=", and a null value is not sent. + * + * @throws Exception if the request fails + */ + @Test + public void emptyAndNullFilterValues() throws Exception + { + ml.files().retriever().nameFilter("").typeFilter(null).get(); + + EmailsTest.assertRequest(client, "GET", "/files?filter[name]=", null, null); + } + + /** + * The type filter, the sort, the page and the limit are sent. The filter accepts types that the SDK does not know. + * + * @throws Exception if the request fails + */ + @Test + public void allOptions() throws Exception + { + ml.files().retriever() + .typeFilter(MediaFileTypes.IMAGE) + .filter("name", "banner") + .sort(MediaFileSorts.SIZE_DESC) + .page(2) + .limit(25) + .get(); + + EmailsTest.assertRequest(client, "GET", + "/files?filter[type]=image&limit=25&filter[name]=banner&sort=-size&page=2", null, null); + } +} diff --git a/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java b/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java index 2a87c26..073cb2f 100644 --- a/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java +++ b/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java @@ -59,6 +59,7 @@ public class ReadmeExamplesTest { "com.mailerlite.sdk.campaigns.*", "com.mailerlite.sdk.emails.*", "com.mailerlite.sdk.exceptions.*", + "com.mailerlite.sdk.files.*", "java.util.*"); /** @@ -209,6 +210,10 @@ private static List examples() .respond(200, Fixtures.read("brand_styles.json")) .expect("GET /brand-styles")); + list.add(new Example("Get a list of files") + .respond(200, Fixtures.read("files.json")) + .expect("GET /files?filter[type]=image&limit=25&filter[name]=summer+sale&sort=-size&page=1")); + return list; } diff --git a/src/test/resources/responses/files.json b/src/test/resources/responses/files.json new file mode 100644 index 0000000..4bf0846 --- /dev/null +++ b/src/test/resources/responses/files.json @@ -0,0 +1,44 @@ +{ + "data": [ + { + "id": "301", + "type": "image", + "name": "summer sale banner.png", + "url": "https://storage.example.com/files/summer-sale-banner.png", + "size": 48213, + "width": 1200, + "height": 600, + "created_at": "2026-10-20 09:30:00" + }, + { + "id": "302", + "type": "pdf", + "name": "summer sale terms.pdf", + "url": "https://storage.example.com/files/summer-sale-terms.pdf", + "size": 10240, + "width": null, + "height": null, + "created_at": "2026-10-21 10:00:00" + } + ], + "links": { + "first": "https://connect.mailerlite.com/api/files?page=1", + "last": "https://connect.mailerlite.com/api/files?page=1", + "prev": null, + "next": null + }, + "meta": { + "current_page": 1, + "from": 1, + "last_page": 1, + "links": [ + {"url": null, "label": "« Previous", "page": null, "active": false}, + {"url": "https://connect.mailerlite.com/api/files?page=1", "label": "1", "page": 1, "active": true}, + {"url": null, "label": "Next »", "page": null, "active": false} + ], + "path": "https://connect.mailerlite.com/api/files", + "per_page": 25, + "to": 2, + "total": 2 + } +} From b3ddbce63f4657816d12c17f549878d39df627e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Thu, 29 Oct 2026 16:31:59 +0100 Subject: [PATCH 23/35] feat: create campaigns with the simple editor Add the fields editor, templateId and replyTo to EmailBase, and the constant EmailEditors.SIMPLE. The SDK does not send a field that is null, so a create without these fields sends the same body as before. The ID of the template is a String, like the other IDs of the SDK. Only the campaign create accepts editor and templateId. The Javadoc of the fields and of CampaignBuilder.update() says that an update with one of them gets a 422 error. Co-Authored-By: Claude Opus 5.5 --- README.md | 45 ++++++- .../sdk/campaigns/CampaignBuilder.java | 6 + .../com/mailerlite/sdk/emails/EmailBase.java | 25 ++++ .../mailerlite/sdk/emails/EmailEditors.java | 24 ++++ .../sdk/tests/CampaignSimpleEditorTest.java | 119 ++++++++++++++++++ .../sdk/tests/ReadmeExamplesTest.java | 4 + .../responses/campaign_simple_editor.json | 24 ++++ 7 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/mailerlite/sdk/emails/EmailEditors.java create mode 100644 src/test/java/com/mailerlite/sdk/tests/CampaignSimpleEditorTest.java create mode 100644 src/test/resources/responses/campaign_simple_editor.json diff --git a/README.md b/README.md index 07de65d..467b428 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ MailerLite Java SDK - [Get a list of campaigns](#get-a-list-of-campaigns) - [Get a campaign](#get-a-campaign) - [Create a campaign](#create-a-campaign) + - [Create a campaign with the simple editor](#create-a-campaign-with-the-simple-editor) - [Update a campaign](#update-a-campaign) - [Schedule a campaign](#schedule-a-campaign) - [Cancel a ready campaign](#cancel-a-ready-campaign) @@ -815,6 +816,48 @@ class Example { } ``` +### Create a campaign with the simple editor + +To build the email with the simple editor, set `editor` to `EmailEditors.SIMPLE`, and do not set `content`. Then change the email with [the email methods](#emails). The ID of the email is in `campaign.emails[0].id`. + +- `editor`: the only accepted value is `simple`, which selects the simple editor instead of the drag-and-drop builder. It has to be the same across every email in the payload; a mixed array is rejected. Setting it prohibits `emails.*.content`. +- `templateId` (`template_id`): the template the new email starts from. Allowed only when `editor` is `simple`, accepts only a template from the shared gallery, and also has to be the same across every email. +- Only `create()` accepts `editor` and `templateId`. If an email has one of them set, `update()` gets a `422` error. +- The SDK does not send `editor`, `templateId`, `replyTo` or `content` when they are `null`. + +```java + +// import .... + +class Example { + + public void example() { + EmailBase email = new EmailBase(); + email.from = "from email"; + email.fromName = "test from name"; + email.subject = "test email subject"; + email.editor = EmailEditors.SIMPLE; + + try { + + SingleCampaign newCampaign = this.getMailerLite().campaigns().builder() + .name("test campaign name") + .email(email) + .type("regular") + .create(); + + String emailId = newCampaign.campaign.emails[0].id; + + System.out.println(emailId); + + } catch (MailerLiteException e) { + + e.printStackTrace(); + } + } +} +``` + ### Update a campaign ```java @@ -981,7 +1024,7 @@ class Example { ## Emails -These methods read and change emails built with the simple editor. The ID of the email of a campaign is in `campaign.emails[0].id`. +These methods read and change emails built with the simple editor. To make such an email, [create a campaign with the simple editor](#create-a-campaign-with-the-simple-editor). The ID of the email is in `campaign.emails[0].id`. - IDs are strings of the digits 0 to 9. An empty or `null` ID throws `EmptyIdException`. An ID with other characters throws `InvalidIdException`. Both extend `IllegalArgumentException`, and the SDK sends no request. - Each write needs the etag from the last response for the email, and each write returns a new etag. The etag covers the whole email, so send the writes to one email one after another. An empty or `null` etag throws `EmptyEtagException`, and the SDK sends no request. diff --git a/src/main/java/com/mailerlite/sdk/campaigns/CampaignBuilder.java b/src/main/java/com/mailerlite/sdk/campaigns/CampaignBuilder.java index 2fee6e0..e15f8fb 100644 --- a/src/main/java/com/mailerlite/sdk/campaigns/CampaignBuilder.java +++ b/src/main/java/com/mailerlite/sdk/campaigns/CampaignBuilder.java @@ -161,6 +161,9 @@ public CampaignBuilder segmentIds(String[] segmentIds) /** * Creates the. + *

+ * To build the emails with the simple editor, set {@code editor} to + * {@link com.mailerlite.sdk.emails.EmailEditors#SIMPLE} on each email, and do not set {@code content}. * * @return the single campaign * @throws MailerLiteException the mailer lite exception @@ -188,6 +191,9 @@ public SingleCampaign create() throws MailerLiteException /** * Update. + *

+ * Only {@link #create()} accepts the email fields {@code editor} and {@code templateId}. If an email of the + * builder has one of them set, the API rejects the update with a 422 error. * * @param campaignId the campaign id * @return the single campaign diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailBase.java b/src/main/java/com/mailerlite/sdk/emails/EmailBase.java index 35890a8..880d609 100644 --- a/src/main/java/com/mailerlite/sdk/emails/EmailBase.java +++ b/src/main/java/com/mailerlite/sdk/emails/EmailBase.java @@ -30,4 +30,29 @@ public class EmailBase extends ApiResource { /** The subject. */ @SerializedName("subject") public String subject; + + /** The reply-to address. If it is null, the SDK does not send it. */ + @SerializedName("reply_to") + public String replyTo; + + /** + * The editor of the email: the only accepted value is {@code simple}, which selects the simple editor instead of + * the drag-and-drop builder. It has to be the same across every email in the payload; a mixed array is rejected. + * Setting it prohibits {@code emails.*.content}. + *

+ * Use {@link EmailEditors#SIMPLE}. If it is null, the SDK does not send it. Only + * {@code CampaignBuilder.create()} accepts it. If it is set, {@code CampaignBuilder.update()} gets a 422 error. + */ + @SerializedName("editor") + public String editor; + + /** + * The template ID: the template the new email starts from. Allowed only when {@code editor} is {@code simple}, + * accepts only a template from the shared gallery, and also has to be the same across every email. + *

+ * If it is null, the SDK does not send it. Only {@code CampaignBuilder.create()} accepts it. If it is set, + * {@code CampaignBuilder.update()} gets a 422 error. + */ + @SerializedName("template_id") + public String templateId; } diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailEditors.java b/src/main/java/com/mailerlite/sdk/emails/EmailEditors.java new file mode 100644 index 0000000..4af8e33 --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/EmailEditors.java @@ -0,0 +1,24 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.emails; + +/** + * The values of {@link EmailBase#editor}. + */ +public final class EmailEditors { + + /** The simple editor. Change the email with the methods of {@link Emails}. */ + public static final String SIMPLE = "simple"; + + /** + * Do not make instances of this class. + */ + private EmailEditors() + { + } +} diff --git a/src/test/java/com/mailerlite/sdk/tests/CampaignSimpleEditorTest.java b/src/test/java/com/mailerlite/sdk/tests/CampaignSimpleEditorTest.java new file mode 100644 index 0000000..c8aff6b --- /dev/null +++ b/src/test/java/com/mailerlite/sdk/tests/CampaignSimpleEditorTest.java @@ -0,0 +1,119 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.mailerlite.sdk.MailerLite; +import com.mailerlite.sdk.campaigns.SingleCampaign; +import com.mailerlite.sdk.emails.Email; +import com.mailerlite.sdk.emails.EmailBase; +import com.mailerlite.sdk.emails.EmailEditors; +import com.mailerlite.sdk.support.Fixtures; +import com.mailerlite.sdk.support.RecordingHttpClient; + +/** + * Tests the campaign create with the simple editor, with the recording HTTP client. + */ +public class CampaignSimpleEditorTest { + + /** The fake HTTP client. */ + private RecordingHttpClient client; + + /** The MailerLite object. */ + private MailerLite ml; + + /** + * Sets the fake HTTP client. + */ + @BeforeEach + public void setupEach() + { + client = RecordingHttpClient.install(); + client.respond(201, Fixtures.read("campaign_simple_editor.json")); + + ml = new MailerLite(); + ml.setToken("test-token"); + } + + /** + * Removes the fake HTTP client. + */ + @AfterEach + public void afterEach() + { + RecordingHttpClient.uninstall(); + } + + /** + * Makes an email with a sender and a subject. + * + * @return the email + */ + private EmailBase email() + { + EmailBase email = new EmailBase(); + email.from = "sender@example.com"; + email.fromName = "Sender"; + email.subject = "Hello"; + + return email; + } + + /** + * A create without the new fields sends the same body as before. + * + * @throws Exception if the request fails + */ + @Test + public void createWithoutEditorSendsTheSameBodyAsBefore() throws Exception + { + EmailBase email = email(); + email.content = "

Hello
"; + + ml.campaigns().builder().name("Newsletter").type("regular").email(email).create(); + + assertEquals("POST", client.lastRequest().method); + assertEquals("https://connect.mailerlite.com/api/campaigns", client.lastRequest().uri.toString()); + assertEquals("{\"name\":\"Newsletter\",\"language_id\":4,\"type\":\"regular\",\"emails\":[{\"from\":\"sender@example.com\"," + + "\"from_name\":\"Sender\",\"content\":\"\\u003cdiv\\u003eHello\\u003c/div\\u003e\",\"subject\":\"Hello\"}]," + + "\"groups\":[],\"segments\":[],\"ab_settings\":{},\"resend_settings\":{}}", client.lastRequest().bodyString()); + } + + /** + * A create with the simple editor sends editor, template_id and reply_to, and no content. + * The response has the email of the simple editor. + * + * @throws Exception if the request fails + */ + @Test + public void createWithSimpleEditor() throws Exception + { + EmailBase email = email(); + email.replyTo = "reply@example.com"; + email.editor = EmailEditors.SIMPLE; + email.templateId = "77"; + + SingleCampaign response = ml.campaigns().builder().name("Simple editor campaign").type("regular").email(email).create(); + + assertEquals("{\"name\":\"Simple editor campaign\",\"language_id\":4,\"type\":\"regular\",\"emails\":[{" + + "\"from\":\"sender@example.com\",\"from_name\":\"Sender\",\"subject\":\"Hello\"," + + "\"reply_to\":\"reply@example.com\",\"editor\":\"simple\",\"template_id\":\"77\"}]," + + "\"groups\":[],\"segments\":[],\"ab_settings\":{},\"resend_settings\":{}}", client.lastRequest().bodyString()); + + Email created = response.campaign.emails[0]; + assertEquals("123", created.id); + assertEquals("builder_inline_email", created.type); + assertEquals("reply@example.com", created.replyTo); + assertEquals("456", response.campaign.id); + } +} diff --git a/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java b/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java index 073cb2f..107baee 100644 --- a/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java +++ b/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java @@ -214,6 +214,10 @@ private static List examples() .respond(200, Fixtures.read("files.json")) .expect("GET /files?filter[type]=image&limit=25&filter[name]=summer+sale&sort=-size&page=1")); + list.add(new Example("Create a campaign with the simple editor") + .respond(201, Fixtures.read("campaign_simple_editor.json")) + .expect("POST /campaigns")); + return list; } diff --git a/src/test/resources/responses/campaign_simple_editor.json b/src/test/resources/responses/campaign_simple_editor.json new file mode 100644 index 0000000..d54f882 --- /dev/null +++ b/src/test/resources/responses/campaign_simple_editor.json @@ -0,0 +1,24 @@ +{ + "data": { + "id": "456", + "account_id": "1", + "name": "Simple editor campaign", + "type": "regular", + "status": "draft", + "default_email_id": "123", + "emails": [ + { + "id": "123", + "account_id": "1", + "emailable_id": "456", + "emailable_type": "campaigns", + "type": "builder_inline_email", + "from": "sender@example.com", + "from_name": "Sender", + "reply_to": "reply@example.com", + "subject": "Hello", + "is_designed": true + } + ] + } +} From 0a7e9f6866d4a0b7c9b5cbabdf362e9aa72ccb2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Thu, 29 Oct 2026 16:34:12 +0100 Subject: [PATCH 24/35] feat: add idempotency keys to email writes Add RequestOptions.idempotencyKey(key). It checks the key at once. Add overloads with RequestOptions as the last argument to createBlock, updateBlock, deleteBlock, moveBlock, updateSettings, createTemplate, createVersion and restoreVersion. With a key, the request has the Idempotency-Key header. Without options or with a null key, the request has no header. The validate methods never send it. Add MailerLiteResponse.isIdempotentReplay(). It finds the header Idempotent-Replayed without case and compares its value with "true" without case. Add a README section that shows a retry after a network error. Co-Authored-By: Claude Opus 5.5 --- README.md | 53 +++++ .../mailerlite/sdk/MailerLiteResponse.java | 30 +++ .../com/mailerlite/sdk/RequestOptions.java | 66 ++++++ .../com/mailerlite/sdk/emails/Emails.java | 206 ++++++++++++++++- .../mailerlite/sdk/tests/IdempotencyTest.java | 216 ++++++++++++++++++ .../sdk/tests/ReadmeExamplesTest.java | 8 + 6 files changed, 568 insertions(+), 11 deletions(-) create mode 100644 src/main/java/com/mailerlite/sdk/RequestOptions.java create mode 100644 src/test/java/com/mailerlite/sdk/tests/IdempotencyTest.java diff --git a/README.md b/README.md index 467b428..5f33a85 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ MailerLite Java SDK - [List the versions of an email](#list-the-versions-of-an-email) - [Save a version of an email](#save-a-version-of-an-email) - [Restore a version of an email](#restore-a-version-of-an-email) + - [Retry a write with an idempotency key](#retry-a-write-with-an-idempotency-key) - [Brand styles](#brand-styles) - [Get the brand styles](#get-the-brand-styles) - [Files](#files) @@ -1468,6 +1469,58 @@ class Example { } ``` +### Retry a write with an idempotency key + +If a write fails with a network error, you do not know if the API did the write. Give the write an idempotency key with `RequestOptions.idempotencyKey(key)`. Then send the write again with the same key, the same etag and the same arguments. If the first request was a success, the API returns the stored response and does not do the write two times. Then `response.isIdempotentReplay()` is `true`. + +- A network error is a `MailerLiteException` with the network error as the cause. +- Use a new key for each write. The API keeps a key for 24 hours. +- The key must be 1 to 255 printable ASCII characters, with no space at the start or the end. Otherwise `RequestOptions.idempotencyKey` throws `InvalidIdempotencyKeyException`, and the SDK sends no request. +- The same key with different arguments gets a `422` error. If the first request is still running, the API returns `409` without an error code. +- Without a key, a retry of a write that was a success gets `409` with `version_conflict`, because the etag is old. +- These methods accept `RequestOptions` as the last argument: `createBlock`, `updateBlock`, `deleteBlock`, `moveBlock`, `updateSettings`, `createTemplate`, `createVersion` and `restoreVersion`. + +```java + +// import .... + +class Example { + + public void example() { + Emails emails = this.getMailerLite().emails(); + + String etag = "etag-from-the-last-response"; + EmailBlockInput block = new EmailBlockInput(EmailBlockTypes.DIVIDER, Map.of()); + RequestOptions options = RequestOptions.idempotencyKey(UUID.randomUUID().toString()); + + try { + + SingleEmailBlock response; + + try { + + response = emails.createBlock("123456", etag, block, options); + } catch (MailerLiteException networkError) { + + if (!(networkError.getCause() instanceof IOException)) { + + throw networkError; + } + + // Send the write again with the same key, the same etag and the same block. + response = emails.createBlock("123456", etag, block, options); + } + + System.out.println(response.block.id + " " + response.isIdempotentReplay()); + + } catch (MailerLiteException e) { + + e.printStackTrace(); + } + } +} +``` + ## Brand styles ### Get the brand styles diff --git a/src/main/java/com/mailerlite/sdk/MailerLiteResponse.java b/src/main/java/com/mailerlite/sdk/MailerLiteResponse.java index 54751f4..5931a05 100644 --- a/src/main/java/com/mailerlite/sdk/MailerLiteResponse.java +++ b/src/main/java/com/mailerlite/sdk/MailerLiteResponse.java @@ -44,4 +44,34 @@ public Boolean successful() { return responseStatusCode >= 200 && responseStatusCode < 300; } + + /** + * Tells if the API returned the stored response of an earlier request with the same idempotency key. + *

+ * The SDK finds the header Idempotent-Replayed without case, and compares its value with "true" without case. + * + * @return true if the response is a replay + */ + public boolean isIdempotentReplay() + { + if (headers == null) { + return false; + } + + for (Map.Entry> header : headers.entrySet()) { + + if (!"Idempotent-Replayed".equalsIgnoreCase(header.getKey()) || header.getValue() == null) { + continue; + } + + for (String value : header.getValue()) { + + if (value != null && "true".equalsIgnoreCase(value.trim())) { + return true; + } + } + } + + return false; + } } diff --git a/src/main/java/com/mailerlite/sdk/RequestOptions.java b/src/main/java/com/mailerlite/sdk/RequestOptions.java new file mode 100644 index 0000000..9fe5e99 --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/RequestOptions.java @@ -0,0 +1,66 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk; + +import com.mailerlite.sdk.exceptions.InvalidIdempotencyKeyException; +import com.mailerlite.sdk.util.RequestChecks; + +/** + * Options for one request, for example an idempotency key. + *

+ * The email writes, {@code Emails.createTemplate} and {@code Emails.createVersion} accept the options as the last + * argument. + */ +public final class RequestOptions { + + /** The idempotency key, or null. */ + private final String idempotencyKey; + + /** + * Instantiates new options. + * + * @param idempotencyKey the idempotency key, or null + */ + private RequestOptions(String idempotencyKey) + { + this.idempotencyKey = idempotencyKey; + } + + /** + * Makes options that send the key in the Idempotency-Key header. + *

+ * Use a new key for each write, and the same key when you send that write again. If the first request was a + * success, the API returns the stored response and does not do the write two times. Then + * {@link MailerLiteResponse#isIdempotentReplay()} is true. The API keeps a key for 24 hours. + *

+ * The key must have 1 to 255 printable ASCII characters (0x20 to 0x7E), with no space at the start or the end. + * A null key sends no header. + * + * @param key the idempotency key, or null for no key + * @return the options + * @throws InvalidIdempotencyKeyException if the key does not obey the rules + */ + public static RequestOptions idempotencyKey(String key) + { + if (key != null) { + RequestChecks.idempotencyKey(key); + } + + return new RequestOptions(key); + } + + /** + * Gets the idempotency key. + * + * @return the idempotency key, or null + */ + public String getIdempotencyKey() + { + return idempotencyKey; + } +} diff --git a/src/main/java/com/mailerlite/sdk/emails/Emails.java b/src/main/java/com/mailerlite/sdk/emails/Emails.java index 6f0aa06..85c800c 100644 --- a/src/main/java/com/mailerlite/sdk/emails/Emails.java +++ b/src/main/java/com/mailerlite/sdk/emails/Emails.java @@ -7,6 +7,7 @@ **************************************************/ package com.mailerlite.sdk.emails; +import java.util.Collections; import java.util.Map; import java.util.Objects; @@ -18,6 +19,7 @@ import com.mailerlite.sdk.MailerLite; import com.mailerlite.sdk.MailerLiteApi; import com.mailerlite.sdk.MailerLiteResponse; +import com.mailerlite.sdk.RequestOptions; import com.mailerlite.sdk.exceptions.MailerLiteException; import com.mailerlite.sdk.util.RequestChecks; @@ -33,6 +35,9 @@ * The methods check the IDs and the etag before they send a request. An empty or null ID throws * {@link com.mailerlite.sdk.exceptions.EmptyIdException}. An ID that contains a character other than the * digits 0 to 9 throws {@link com.mailerlite.sdk.exceptions.InvalidIdException}. + *

+ * The writes, createTemplate and createVersion have an overload with {@link RequestOptions} as the last argument. + * Use it to send an idempotency key, so that you can safely send a write again after a network error. */ public class Emails { @@ -112,11 +117,32 @@ public SingleEmailBlock getBlock(String emailId, String blockId) throws MailerLi * @throws IllegalArgumentException if the block type or the placement position is empty */ public SingleEmailBlock createBlock(String emailId, String etag, EmailBlockInput block) throws MailerLiteException + { + return createBlock(emailId, etag, block, null); + } + + /** + * Inserts a block into an email. + *

+ * Without a placement, the API puts the block at the end of the email. The response has the new block with + * its placement and the new etag. + * + * @param emailId the email ID + * @param etag the etag from the last response for the email + * @param block the block to insert + * @param options the request options, for example an idempotency key, or null for no options + * @return the new block and the new etag + * @throws MailerLiteException if the API returns an error or the request fails + * @throws NullPointerException if the block or its editor content is null + * @throws IllegalArgumentException if the block type or the placement position is empty + */ + public SingleEmailBlock createBlock(String emailId, String etag, EmailBlockInput block, RequestOptions options) + throws MailerLiteException { String endpoint = emailPath(emailId).concat("/blocks"); BlockBody body = blockBody(block, RequestChecks.etag(etag)); - return send("POST", endpoint, toJson(body), SingleEmailBlock.class); + return send("POST", endpoint, toJson(body), options, SingleEmailBlock.class); } /** @@ -135,6 +161,28 @@ public SingleEmailBlock createBlock(String emailId, String etag, EmailBlockInput */ public SingleEmailBlock updateBlock(String emailId, String blockId, String etag, Map editorContent) throws MailerLiteException + { + return updateBlock(emailId, blockId, etag, editorContent, null); + } + + /** + * Replaces the content of a block. The type and the position of the block do not change. + *

+ * The new editor content replaces the whole content of the block. The API removes the keys that you do not + * send. Read the block, change it, and send the full editor content. + * + * @param emailId the email ID + * @param blockId the block ID + * @param etag the etag from the last response for the email + * @param editorContent the new editor document of the block. The keys are camelCase. + * @param options the request options, for example an idempotency key, or null for no options + * @return the block and the new etag + * @throws MailerLiteException if the API returns an error or the request fails + * @throws NullPointerException if the editor content is null + */ + public SingleEmailBlock updateBlock(String emailId, String blockId, String etag, Map editorContent, + RequestOptions options) + throws MailerLiteException { String endpoint = blockPath(emailId, blockId); @@ -142,7 +190,7 @@ public SingleEmailBlock updateBlock(String emailId, String blockId, String etag, body.etag = RequestChecks.etag(etag); body.editorContent = Objects.requireNonNull(editorContent, "editorContent"); - return send("PATCH", endpoint, toJson(body), SingleEmailBlock.class); + return send("PATCH", endpoint, toJson(body), options, SingleEmailBlock.class); } /** @@ -155,10 +203,26 @@ public SingleEmailBlock updateBlock(String emailId, String blockId, String etag, * @throws MailerLiteException if the API returns an error or the request fails */ public EmailEtagResponse deleteBlock(String emailId, String blockId, String etag) throws MailerLiteException + { + return deleteBlock(emailId, blockId, etag, null); + } + + /** + * Deletes a block of an email. The API also deletes the blocks in it. + * + * @param emailId the email ID + * @param blockId the block ID + * @param etag the etag from the last response for the email + * @param options the request options, for example an idempotency key, or null for no options + * @return the new etag + * @throws MailerLiteException if the API returns an error or the request fails + */ + public EmailEtagResponse deleteBlock(String emailId, String blockId, String etag, RequestOptions options) + throws MailerLiteException { String endpoint = blockPath(emailId, blockId); - return send("DELETE", endpoint, etagBody(etag), EmailEtagResponse.class); + return send("DELETE", endpoint, etagBody(etag), options, EmailEtagResponse.class); } /** @@ -177,6 +241,28 @@ public EmailEtagResponse deleteBlock(String emailId, String blockId, String etag */ public SingleEmailBlock moveBlock(String emailId, String blockId, String etag, EmailBlockPosition placement) throws MailerLiteException + { + return moveBlock(emailId, blockId, etag, placement, null); + } + + /** + * Moves a block to a new position at the same level. The nesting of the block does not change. + *

+ * The positions {@link EmailBlockPositions#BEFORE} and {@link EmailBlockPositions#AFTER} need a block ID. + * + * @param emailId the email ID + * @param blockId the ID of the block to move + * @param etag the etag from the last response for the email + * @param placement the new position + * @param options the request options, for example an idempotency key, or null for no options + * @return the block with its new placement, and the new etag + * @throws MailerLiteException if the API returns an error or the request fails + * @throws NullPointerException if the placement is null + * @throws IllegalArgumentException if the placement position is empty + */ + public SingleEmailBlock moveBlock(String emailId, String blockId, String etag, EmailBlockPosition placement, + RequestOptions options) + throws MailerLiteException { String endpoint = blockPath(emailId, blockId).concat("/move"); @@ -184,7 +270,7 @@ public SingleEmailBlock moveBlock(String emailId, String blockId, String etag, E body.etag = RequestChecks.etag(etag); body.placement = placementBody(Objects.requireNonNull(placement, "placement")); - return send("POST", endpoint, toJson(body), SingleEmailBlock.class); + return send("POST", endpoint, toJson(body), options, SingleEmailBlock.class); } /** @@ -219,7 +305,31 @@ public EmailValidation validateBlock(EmailBlockInput block) throws MailerLiteExc * @throws MailerLiteException if the API returns an error or the request fails * @throws NullPointerException if the settings are null */ - public SingleEmailSettings updateSettings(String emailId, String etag, EmailSettings settings) throws MailerLiteException + public SingleEmailSettings updateSettings(String emailId, String etag, EmailSettings settings) + throws MailerLiteException + { + return updateSettings(emailId, etag, settings, null); + } + + /** + * Changes the design settings of an email. + *

+ * Set only the fields that you want to change. A null field is not sent and does not change. An empty string + * unsets the value. The API stores each value that you send, also a value that the email inherits from your + * account. So do not send back all the settings that you read. + *

+ * The SDK sends the etag at the top level of the body, next to the settings groups. + * + * @param emailId the email ID + * @param etag the etag from the last response for the email + * @param settings the settings to change + * @param options the request options, for example an idempotency key, or null for no options + * @return the settings after the update, the new etag, and the unsafe values that the API removed + * @throws MailerLiteException if the API returns an error or the request fails + * @throws NullPointerException if the settings are null + */ + public SingleEmailSettings updateSettings(String emailId, String etag, EmailSettings settings, RequestOptions options) + throws MailerLiteException { String endpoint = emailPath(emailId).concat("/settings"); String checkedEtag = RequestChecks.etag(etag); @@ -227,7 +337,7 @@ public SingleEmailSettings updateSettings(String emailId, String etag, EmailSett JsonObject body = settingsBody(settings); body.addProperty("etag", checkedEtag); - return send("PATCH", endpoint, TREE_GSON.toJson(body), SingleEmailSettings.class); + return send("PATCH", endpoint, TREE_GSON.toJson(body), options, SingleEmailSettings.class); } /** @@ -259,6 +369,22 @@ public EmailValidation validateSettings(String emailId, EmailSettings settings) * @throws IllegalArgumentException if the name is null or empty */ public SingleEmailTemplate createTemplate(String emailId, String name) throws MailerLiteException + { + return createTemplate(emailId, name, null); + } + + /** + * Copies an email to a new template. The API cannot delete templates. + * + * @param emailId the email ID + * @param name the name of the new template + * @param options the request options, for example an idempotency key, or null for no options + * @return the new template + * @throws MailerLiteException if the API returns an error or the request fails + * @throws IllegalArgumentException if the name is null or empty + */ + public SingleEmailTemplate createTemplate(String emailId, String name, RequestOptions options) + throws MailerLiteException { String endpoint = emailPath(emailId).concat("/templates"); @@ -269,7 +395,7 @@ public SingleEmailTemplate createTemplate(String emailId, String name) throws Ma TemplateBody body = new TemplateBody(); body.name = name; - return send("POST", endpoint, toJson(body), SingleEmailTemplate.class); + return send("POST", endpoint, toJson(body), options, SingleEmailTemplate.class); } /** @@ -296,7 +422,23 @@ public EmailVersionsRetriever versions(String emailId) */ public SingleEmailVersion createVersion(String emailId) throws MailerLiteException { - return send("POST", emailPath(emailId).concat("/versions"), null, SingleEmailVersion.class); + return createVersion(emailId, null); + } + + /** + * Saves the email as a new version. The request has no body. + *

+ * If nothing changed after the last version, the API saves no new version and returns the last version. + * {@link SingleEmailVersion#isCreated()} tells the two cases apart. + * + * @param emailId the email ID + * @param options the request options, for example an idempotency key, or null for no options + * @return the new version, or the last version + * @throws MailerLiteException if the API returns an error or the request fails + */ + public SingleEmailVersion createVersion(String emailId, RequestOptions options) throws MailerLiteException + { + return send("POST", emailPath(emailId).concat("/versions"), null, options, SingleEmailVersion.class); } /** @@ -311,11 +453,29 @@ public SingleEmailVersion createVersion(String emailId) throws MailerLiteExcepti * @throws MailerLiteException if the API returns an error or the request fails */ public SingleEmailDocument restoreVersion(String emailId, String versionId, String etag) throws MailerLiteException + { + return restoreVersion(emailId, versionId, etag, null); + } + + /** + * Puts an email back to a version, and returns the full email with the new etag. + *

+ * Before the restore, the API saves the current state as a version, so you can undo the restore. + * + * @param emailId the email ID + * @param versionId the version ID + * @param etag the etag from the last response for the email + * @param options the request options, for example an idempotency key, or null for no options + * @return the email and the new etag + * @throws MailerLiteException if the API returns an error or the request fails + */ + public SingleEmailDocument restoreVersion(String emailId, String versionId, String etag, RequestOptions options) + throws MailerLiteException { String endpoint = emailPath(emailId).concat("/versions/") .concat(RequestChecks.pathId(versionId, "versionId")).concat("/restore"); - return send("POST", endpoint, etagBody(etag), SingleEmailDocument.class); + return send("POST", endpoint, etagBody(etag), options, SingleEmailDocument.class); } /** @@ -431,7 +591,7 @@ private static String toJson(Object body) } /** - * Sends a request. + * Sends a request without options. * * @param the response type * @param method the HTTP method @@ -444,10 +604,34 @@ private static String toJson(Object body) private T send(String method, String endpoint, String body, Class responseClass) throws MailerLiteException { + return send(method, endpoint, body, null, responseClass); + } + + /** + * Sends a request. If the options have an idempotency key, the request has the Idempotency-Key header. + * + * @param the response type + * @param method the HTTP method + * @param endpoint the endpoint + * @param body the JSON body, or null + * @param options the request options, or null + * @param responseClass the response class + * @return the response + * @throws MailerLiteException if the API returns an error or the request fails + */ + private T send(String method, String endpoint, String body, RequestOptions options, + Class responseClass) throws MailerLiteException + { + Map headers = null; + + if (options != null && options.getIdempotencyKey() != null) { + headers = Collections.singletonMap("Idempotency-Key", options.getIdempotencyKey()); + } + MailerLiteApi api = new MailerLiteApi(); api.setToken(apiObjectReference.getToken()); - return api.sendRequest(method, endpoint, body, null, responseClass); + return api.sendRequest(method, endpoint, body, headers, responseClass); } /** diff --git a/src/test/java/com/mailerlite/sdk/tests/IdempotencyTest.java b/src/test/java/com/mailerlite/sdk/tests/IdempotencyTest.java new file mode 100644 index 0000000..e0dda4b --- /dev/null +++ b/src/test/java/com/mailerlite/sdk/tests/IdempotencyTest.java @@ -0,0 +1,216 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.function.Executable; + +import com.mailerlite.sdk.MailerLite; +import com.mailerlite.sdk.MailerLiteResponse; +import com.mailerlite.sdk.RequestOptions; +import com.mailerlite.sdk.emails.EmailBlockInput; +import com.mailerlite.sdk.emails.EmailBlockPosition; +import com.mailerlite.sdk.emails.EmailBlockPositions; +import com.mailerlite.sdk.emails.EmailBlockTypes; +import com.mailerlite.sdk.emails.Emails; +import com.mailerlite.sdk.emails.SingleEmailBlock; +import com.mailerlite.sdk.exceptions.InvalidIdempotencyKeyException; +import com.mailerlite.sdk.support.Fixtures; +import com.mailerlite.sdk.support.RecordingHttpClient; + +/** + * Tests the idempotency key option and the replay flag, with the recording HTTP client. + */ +public class IdempotencyTest { + + /** The fake HTTP client. */ + private RecordingHttpClient client; + + /** The email service. */ + private Emails emails; + + /** + * Sets the fake HTTP client. + */ + @BeforeEach + public void setupEach() + { + client = RecordingHttpClient.install(); + + MailerLite ml = new MailerLite(); + ml.setToken("test-token"); + emails = ml.emails(); + } + + /** + * Removes the fake HTTP client. + */ + @AfterEach + public void afterEach() + { + RecordingHttpClient.uninstall(); + } + + /** + * The 8 methods that accept an idempotency key, each with a name and a response. + * + * @return the name, the response fixture and the call for the options + */ + private Map writeMethods() + { + EmailBlockInput divider = new EmailBlockInput(EmailBlockTypes.DIVIDER, new LinkedHashMap()); + + Map methods = new LinkedHashMap(); + methods.put("createBlock", new Object[] {"email_block_created.json", + (Function) o -> () -> emails.createBlock("123", "42", divider, o)}); + methods.put("updateBlock", new Object[] {"email_block_updated.json", + (Function) o -> () -> emails.updateBlock("123", "57", "43", new HashMap(), o)}); + methods.put("deleteBlock", new Object[] {"email_etag.json", + (Function) o -> () -> emails.deleteBlock("123", "57", "44", o)}); + methods.put("moveBlock", new Object[] {"email_block_moved.json", + (Function) o -> () -> emails.moveBlock("123", "57", "45", + new EmailBlockPosition(EmailBlockPositions.START), o)}); + methods.put("updateSettings", new Object[] {"email_settings.json", + (Function) o -> () -> emails.updateSettings("123", "46", + EmailsTest.brandAndParagraphSettings(), o)}); + methods.put("createTemplate", new Object[] {"email_template.json", + (Function) o -> () -> emails.createTemplate("123", "Newsletter", o)}); + methods.put("createVersion", new Object[] {"email_version.json", + (Function) o -> () -> emails.createVersion("123", o)}); + methods.put("restoreVersion", new Object[] {"email.json", + (Function) o -> () -> emails.restoreVersion("123", "901", "47", o)}); + + return methods; + } + + /** + * Each of the 8 methods sends the Idempotency-Key header when the options have a key, and no header when the + * options are null or have a null key. + * + * @throws Throwable if a call fails + */ + @Test + @SuppressWarnings("unchecked") + public void writeMethodsSendTheKeyOnlyWhenSet() throws Throwable + { + for (Map.Entry method : writeMethods().entrySet()) { + + String name = method.getKey(); + String fixture = (String) method.getValue()[0]; + Function call = (Function) method.getValue()[1]; + + client.requests().clear(); + client.respond(200, Fixtures.read(fixture)).respond(200, Fixtures.read(fixture)).respond(200, Fixtures.read(fixture)); + + call.apply(RequestOptions.idempotencyKey("key-" + name)).execute(); + call.apply(null).execute(); + call.apply(RequestOptions.idempotencyKey(null)).execute(); + + List requests = client.requests(); + assertEquals("key-" + name, requests.get(0).header("Idempotency-Key"), name); + assertNull(requests.get(1).header("Idempotency-Key"), name); + assertNull(requests.get(2).header("Idempotency-Key"), name); + assertEquals(requests.get(0).bodyString(), requests.get(1).bodyString(), name); + } + } + + /** + * The validate methods have no options and never send the header. + * + * @throws Exception if a call fails + */ + @Test + public void validateMethodsSendNoKey() throws Exception + { + client.respond(200, Fixtures.read("email_block_validation_valid.json")) + .respond(200, Fixtures.read("email_settings_validation.json")); + + emails.validateBlock(new EmailBlockInput(EmailBlockTypes.DIVIDER, new LinkedHashMap())); + emails.validateSettings("123", EmailsTest.brandAndParagraphSettings()); + + assertNull(client.requests().get(0).header("Idempotency-Key")); + assertNull(client.requests().get(1).header("Idempotency-Key")); + } + + /** + * RequestOptions.idempotencyKey checks the key at once. + */ + @Test + public void invalidKeysThrowAtOnce() + { + for (String key : RequestChecksTest.INVALID_KEYS) { + assertThrows(InvalidIdempotencyKeyException.class, () -> RequestOptions.idempotencyKey(key), + "key of length " + key.length()); + } + + String longest = RequestChecksTest.repeat('k', 255); + assertEquals(longest, RequestOptions.idempotencyKey(longest).getIdempotencyKey()); + assertNull(RequestOptions.idempotencyKey(null).getIdempotencyKey()); + assertTrue(client.requests().isEmpty()); + } + + /** + * A response with the header Idempotent-Replayed: true is a replay. The SDK ignores case and spaces. + * + * @throws Exception if a call fails + */ + @Test + public void replayFlag() throws Exception + { + String[][] cases = { + {"Idempotent-Replayed", "true", "true"}, + {"Idempotent-Replayed", "True", "true"}, + {"idempotent-replayed", " TRUE ", "true"}, + {"Idempotent-Replayed", "false", "false"}, + }; + + for (String[] item : cases) { + + client.respond(201, Fixtures.read("email_block_created.json"), Collections.singletonMap(item[0], item[1])); + + SingleEmailBlock response = emails.createBlock("123", "42", + new EmailBlockInput(EmailBlockTypes.DIVIDER, new LinkedHashMap())); + + assertEquals(Boolean.parseBoolean(item[2]), response.isIdempotentReplay(), item[0] + ": " + item[1]); + } + + client.respond(201, Fixtures.read("email_block_created.json")); + assertFalse(emails.createBlock("123", "42", + new EmailBlockInput(EmailBlockTypes.DIVIDER, new LinkedHashMap())).isIdempotentReplay()); + } + + /** + * The replay flag also works with a header map that is case-sensitive, and with no header map. + */ + @Test + public void replayFlagWithPlainHeaderMap() + { + MailerLiteResponse response = new MailerLiteResponse(); + assertFalse(response.isIdempotentReplay()); + + Map> headers = new HashMap>(); + headers.put("idempotent-replayed", Collections.singletonList("true")); + response.headers = headers; + assertTrue(response.isIdempotentReplay()); + } +} diff --git a/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java b/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java index 107baee..ae6b184 100644 --- a/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java +++ b/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java @@ -11,6 +11,7 @@ import static org.junit.jupiter.api.DynamicTest.dynamicTest; import java.io.File; +import java.io.IOException; import java.lang.reflect.InvocationTargetException; import java.net.URL; import java.net.URLClassLoader; @@ -60,6 +61,7 @@ public class ReadmeExamplesTest { "com.mailerlite.sdk.emails.*", "com.mailerlite.sdk.exceptions.*", "com.mailerlite.sdk.files.*", + "java.io.*", "java.util.*"); /** @@ -218,6 +220,12 @@ private static List examples() .respond(201, Fixtures.read("campaign_simple_editor.json")) .expect("POST /campaigns")); + list.add(new Example("Retry a write with an idempotency key") + .fail(new IOException("connection reset")) + .respond(201, Fixtures.read("email_block_created.json"), Collections.singletonMap("Idempotent-Replayed", "true")) + .expect("POST /emails/123456/blocks") + .expect("POST /emails/123456/blocks")); + return list; } From f9c983fe2f3248945c6c85773ee205267b7904d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Thu, 29 Oct 2026 16:34:43 +0100 Subject: [PATCH 25/35] chore: bump the version to 0.1.0 Co-Authored-By: Claude Opus 5.5 --- README.md | 4 ++-- pom.xml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 5f33a85..08bfa5d 100644 --- a/README.md +++ b/README.md @@ -88,12 +88,12 @@ Using Maven: com.mailerlite mailerlite-java - 0.0.4 + 0.1.0 Using Gradle: - implementation 'com.mailerlite:mailerlite-java:0.0.4' + implementation 'com.mailerlite:mailerlite-java:0.1.0' # Usage diff --git a/pom.xml b/pom.xml index 67a0f2b..8608101 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 com.mailerlite mailerlite-java - 0.0.4 + 0.1.0 MailerLite Java SDK An SDK for MailerLite API https://github.com/mailerlite/mailerlite-java From 4acb5e2927ee30e124d0a588dd2b58c664ee86ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Thu, 29 Oct 2026 16:53:55 +0100 Subject: [PATCH 26/35] fix: keep a message for network errors without a message A network error such as a ConnectException or an InterruptedException can have a null message. Then the MailerLiteException also had a null message. Now the message is the class name and the message of the network error, for example "java.net.ConnectException". The cause and the interrupt flag do not change. Co-Authored-By: Claude Opus 5.5 --- .../com/mailerlite/sdk/MailerLiteApi.java | 5 +- .../sdk/tests/MailerLiteApiTest.java | 48 ++++++++++++++++++- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/mailerlite/sdk/MailerLiteApi.java b/src/main/java/com/mailerlite/sdk/MailerLiteApi.java index 67698da..c7b8cf6 100644 --- a/src/main/java/com/mailerlite/sdk/MailerLiteApi.java +++ b/src/main/java/com/mailerlite/sdk/MailerLiteApi.java @@ -346,7 +346,8 @@ public T sendRequest(String method, String endpoi /** - * Converts a network error to a MailerLiteException. + * Converts a network error to a MailerLiteException. The message is the class name and the message of the + * error, so it is not null when the error has no message. * For an InterruptedException, it also sets the interrupt flag of the thread again. * * @param e the network error @@ -359,7 +360,7 @@ private MailerLiteException networkError(Exception e) Thread.currentThread().interrupt(); } - return new MailerLiteException(e.getMessage(), e); + return new MailerLiteException(e.toString(), e); } diff --git a/src/test/java/com/mailerlite/sdk/tests/MailerLiteApiTest.java b/src/test/java/com/mailerlite/sdk/tests/MailerLiteApiTest.java index a931f17..be83008 100644 --- a/src/test/java/com/mailerlite/sdk/tests/MailerLiteApiTest.java +++ b/src/test/java/com/mailerlite/sdk/tests/MailerLiteApiTest.java @@ -16,6 +16,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.IOException; +import java.net.ConnectException; import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; @@ -101,7 +102,7 @@ public void networkErrorBecomesMailerLiteException() MailerLiteException e = assertThrows(MailerLiteException.class, method.getValue(), method.getKey()); assertSame(error, e.getCause(), method.getKey()); - assertEquals("connection reset", e.getMessage(), method.getKey()); + assertEquals("java.io.IOException: connection reset", e.getMessage(), method.getKey()); } } @@ -360,4 +361,49 @@ public NoDefaultConstructorResponse(int value) { } } + + /** + * A network error without a message gives a MailerLiteException with a message that names the error. + */ + @Test + public void networkErrorWithoutMessageKeepsTheErrorName() + { + for (Map.Entry method : oldRequestMethods().entrySet()) { + + ConnectException error = new ConnectException(); + client.fail(error); + + MailerLiteException e = assertThrows(MailerLiteException.class, method.getValue(), method.getKey()); + + assertSame(error, e.getCause(), method.getKey()); + assertTrue(e.getMessage() != null && e.getMessage().contains("ConnectException"), method.getKey() + ": " + e.getMessage()); + } + + ConnectException error = new ConnectException(); + client.fail(error); + + MailerLiteException e = assertThrows(MailerLiteException.class, + () -> api().sendRequest("POST", "/emails/123/versions", null, null, MailerLiteResponse.class)); + + assertSame(error, e.getCause()); + assertTrue(e.getMessage() != null && e.getMessage().contains("ConnectException"), e.getMessage()); + } + + /** + * An interrupt without a message gives a message that names the error, and the thread keeps its interrupt flag. + */ + @Test + public void interruptWithoutMessageKeepsTheErrorNameAndTheFlag() + { + InterruptedException error = new InterruptedException(); + client.fail(error); + + MailerLiteException e = assertThrows(MailerLiteException.class, + () -> api().sendRequest("GET", "/emails/123", null, null, MailerLiteResponse.class)); + + assertSame(error, e.getCause()); + assertTrue(e.getMessage() != null && e.getMessage().contains("InterruptedException"), e.getMessage()); + assertTrue(Thread.interrupted()); + } + } From 32c300fbbaf13396f6c36006b72e64866da8964b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Thu, 29 Oct 2026 16:54:30 +0100 Subject: [PATCH 27/35] test: lock the encoding of query values The SDK encodes each query value. A value that the caller encoded before is encoded again, so "summer%20sale" is sent as "summer%2520sale". A plus sign is sent as "%2B", so the API reads a literal plus sign. The tests keep this behavior. Co-Authored-By: Claude Opus 5.5 --- .../mailerlite/sdk/tests/ApiRequestTest.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/test/java/com/mailerlite/sdk/tests/ApiRequestTest.java b/src/test/java/com/mailerlite/sdk/tests/ApiRequestTest.java index 7a76cec..17baeeb 100644 --- a/src/test/java/com/mailerlite/sdk/tests/ApiRequestTest.java +++ b/src/test/java/com/mailerlite/sdk/tests/ApiRequestTest.java @@ -91,4 +91,29 @@ public void keepsSafeValues() assertEquals("?filter[status]=active", request.getQueryParameters()); } + + /** + * The SDK encodes each value, so a value that the caller encoded is encoded two times. Do not encode values. + */ + @Test + public void encodesAnEncodedValueAgain() + { + ApiRequest request = new ApiRequest(); + request.addQueryParameter("filter[name]", "summer%20sale"); + + assertEquals("?filter[name]=summer%2520sale", request.getQueryParameters()); + } + + /** + * A plus sign is sent as "%2B", so the API reads a literal plus sign, not a space. + */ + @Test + public void sendsPlusAsLiteralPlus() + { + ApiRequest request = new ApiRequest(); + request.addQueryParameter("filter[name]", "a+b"); + + assertEquals("?filter[name]=a%2Bb", request.getQueryParameters()); + } + } From 56a0056aff46225701280160d831526db603c058 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Thu, 29 Oct 2026 16:54:55 +0100 Subject: [PATCH 28/35] docs: add the changes to know before an upgrade to 0.1.0 Add the README section "Upgrading to 0.1.0". It says that the SDK now encodes query values, so callers must not encode them, and that a plus sign is sent as a literal plus sign. It says that EmailBase now has reply_to, editor and template_id, so subclasses must remove their own fields with these JSON names. It also says that a network error is now a MailerLiteException with the network error as the cause. Co-Authored-By: Claude Opus 5.5 --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index 08bfa5d..2edfd98 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ MailerLite Java SDK # Table of Contents - [Installation](#installation) +- [Upgrading to 0.1.0](#upgrading-to-010) - [Usage](#usage) - [Subscribers](#subscribers) - [Get a list of subscribers](#get-a-list-of-subscribers) @@ -96,6 +97,15 @@ Using Gradle: implementation 'com.mailerlite:mailerlite-java:0.1.0' +# Upgrading to 0.1.0 + +Version 0.1.0 changes some behavior of the SDK. Before you upgrade from 0.0.4, do these checks: + +- The SDK now encodes the query values, for example the values of `filter(...)`. Do not encode the values yourself. If you do, the SDK encodes them two times: `"summer%20sale"` is sent as `summer%2520sale`. Give the plain value, for example `"summer sale"`. +- The SDK now sends a `+` in a query value as a literal `+`. Before, the API read it as a space. +- `EmailBase` now has the fields `replyTo` (`reply_to`), `editor` and `templateId` (`template_id`). If your subclass of `EmailBase` has its own field with one of these JSON names, Gson throws an error ("declares multiple JSON fields named ..."), and `CampaignBuilder.create()` and `update()` fail. Remove these fields from your subclass, and use the fields of `EmailBase`. +- A network error is now a `MailerLiteException` with the network error as the cause. Before, the SDK threw a `ClassCastException`. To find a network error, examine `e.getCause()`, for example `e.getCause() instanceof IOException`. + # Usage Start using the sdk by creating an instance of the `MailerLite` object and passing your API token into it like this: From ac14594fb97fcb4d2fca46e3ee43f2d608493fdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Thu, 29 Oct 2026 17:04:56 +0100 Subject: [PATCH 29/35] docs: correct what an empty string does in a settings update The docs said that an empty string unsets any settings value. The API accepts an empty string only for a brand color. To unset a footer text, send an empty editor document. For the other values, for example other colors, sizes, button.borderRadius and brand.style, the API rejects an empty string with a 422 error. The Javadoc of the settings classes and of Emails.updateSettings, and the README, now say this. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +++- .../sdk/emails/EmailBrandSettings.java | 4 +++- .../sdk/emails/EmailButtonSettings.java | 3 ++- .../mailerlite/sdk/emails/EmailEditorText.java | 3 ++- .../sdk/emails/EmailFooterSettings.java | 3 ++- .../mailerlite/sdk/emails/EmailHeadingStyle.java | 3 ++- .../sdk/emails/EmailLayoutSettings.java | 3 ++- .../mailerlite/sdk/emails/EmailLinkStyle.java | 3 ++- .../sdk/emails/EmailParagraphStyle.java | 3 ++- .../com/mailerlite/sdk/emails/EmailSettings.java | 4 +++- .../mailerlite/sdk/emails/EmailTextStyle.java | 3 ++- .../java/com/mailerlite/sdk/emails/Emails.java | 16 ++++++++++------ 12 files changed, 35 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 2edfd98..cbe25f8 100644 --- a/README.md +++ b/README.md @@ -1301,7 +1301,9 @@ class Example { Set only the fields that you want to change. The groups are `brand`, `layout`, `typography`, `button` and `footer`. -- A `null` field is not sent and does not change. An empty string unsets the value, and the email then uses the value of your account. +- A `null` field is not sent and does not change. +- To unset a brand color (`backgroundColor`, `textColor` or `borderColor`), send an empty string. To unset a footer text (`companyName`, `address` or `infoText`), send an empty `editorContent` map, or a map with an empty `content` list. The email then uses the value of your account. +- Only a brand color accepts an empty string. For the other values, for example other colors, sizes, `button.borderRadius` and `brand.style`, the API rejects an empty string with a `422` error. - All style values are strings, for example `"16"` and not `16`. - The API stores each value that you send, also a value that the email inherits from your account. Then the email no longer follows your account for that value. So do not send back all the settings that you read. - `footer.socialLinks` replaces the whole list. An empty list removes all the links. Keep the `linkId` of each link to keep its click history. diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailBrandSettings.java b/src/main/java/com/mailerlite/sdk/emails/EmailBrandSettings.java index efe434e..8fefa32 100644 --- a/src/main/java/com/mailerlite/sdk/emails/EmailBrandSettings.java +++ b/src/main/java/com/mailerlite/sdk/emails/EmailBrandSettings.java @@ -13,7 +13,9 @@ * The palette and the font of an email. * * In an update, null is not sent and does not change the value. - * An empty string unsets the value, and the email then uses the value of your account. + * To unset a color (backgroundColor, textColor or borderColor), send an empty string. The email then uses the color + * of your account. Only these colors accept an empty string. For the other fields, for example style, the API + * rejects an empty string with a 422 error. */ public class EmailBrandSettings { diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailButtonSettings.java b/src/main/java/com/mailerlite/sdk/emails/EmailButtonSettings.java index 99768d1..5419db4 100644 --- a/src/main/java/com/mailerlite/sdk/emails/EmailButtonSettings.java +++ b/src/main/java/com/mailerlite/sdk/emails/EmailButtonSettings.java @@ -13,7 +13,8 @@ * The button style of an email. Send all values as strings, for example "4" and not 4. * * In an update, null is not sent and does not change the value. - * An empty string unsets the value, and the email then uses the value of your account. + * Do not send an empty string. The API rejects an empty string with a 422 error. Only a brand color accepts an + * empty string. */ public class EmailButtonSettings { diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailEditorText.java b/src/main/java/com/mailerlite/sdk/emails/EmailEditorText.java index 1adb19f..8a2ac82 100644 --- a/src/main/java/com/mailerlite/sdk/emails/EmailEditorText.java +++ b/src/main/java/com/mailerlite/sdk/emails/EmailEditorText.java @@ -13,7 +13,8 @@ import com.google.gson.annotations.SerializedName; /** - * A footer text as an editor document. In an update, an empty editorContent map unsets the text. + * A footer text as an editor document. In an update, an empty editorContent map, or a map with an empty "content" + * list, unsets the text. * The email then uses the text of your account. */ public class EmailEditorText { diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailFooterSettings.java b/src/main/java/com/mailerlite/sdk/emails/EmailFooterSettings.java index fce9f46..ca51a13 100644 --- a/src/main/java/com/mailerlite/sdk/emails/EmailFooterSettings.java +++ b/src/main/java/com/mailerlite/sdk/emails/EmailFooterSettings.java @@ -14,7 +14,8 @@ /** * The footer of an email. * - * In an update, a null field is not sent and does not change. + * In an update, a null field is not sent and does not change. To unset companyName, address or infoText, send an + * empty editor document. The email then uses the text of your account. */ public class EmailFooterSettings { diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailHeadingStyle.java b/src/main/java/com/mailerlite/sdk/emails/EmailHeadingStyle.java index e40e3f9..99c5c72 100644 --- a/src/main/java/com/mailerlite/sdk/emails/EmailHeadingStyle.java +++ b/src/main/java/com/mailerlite/sdk/emails/EmailHeadingStyle.java @@ -13,7 +13,8 @@ * The heading styles of an email. Send all values as strings, for example "700" and not 700. * * In an update, null is not sent and does not change the value. - * An empty string unsets the value, and the email then uses the value of your account. + * Do not send an empty string. The API rejects an empty string with a 422 error. Only a brand color accepts an + * empty string. */ public class EmailHeadingStyle { diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailLayoutSettings.java b/src/main/java/com/mailerlite/sdk/emails/EmailLayoutSettings.java index 269b5c5..ac1e985 100644 --- a/src/main/java/com/mailerlite/sdk/emails/EmailLayoutSettings.java +++ b/src/main/java/com/mailerlite/sdk/emails/EmailLayoutSettings.java @@ -13,7 +13,8 @@ * The layout of an email. * * In an update, null is not sent and does not change the value. - * An empty string unsets the value, and the email then uses the value of your account. + * Do not send an empty string. The API rejects an empty string with a 422 error. Only a brand color accepts an + * empty string. */ public class EmailLayoutSettings { diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailLinkStyle.java b/src/main/java/com/mailerlite/sdk/emails/EmailLinkStyle.java index 4b47f1c..f5af2da 100644 --- a/src/main/java/com/mailerlite/sdk/emails/EmailLinkStyle.java +++ b/src/main/java/com/mailerlite/sdk/emails/EmailLinkStyle.java @@ -13,7 +13,8 @@ * The link style of an email. * * In an update, null is not sent and does not change the value. - * An empty string unsets the value, and the email then uses the value of your account. + * Do not send an empty string. The API rejects an empty string with a 422 error. Only a brand color accepts an + * empty string. */ public class EmailLinkStyle { diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailParagraphStyle.java b/src/main/java/com/mailerlite/sdk/emails/EmailParagraphStyle.java index 3d5276b..b433406 100644 --- a/src/main/java/com/mailerlite/sdk/emails/EmailParagraphStyle.java +++ b/src/main/java/com/mailerlite/sdk/emails/EmailParagraphStyle.java @@ -13,7 +13,8 @@ * The paragraph styles of an email. Send all values as strings, for example "400" and not 400. * * In an update, null is not sent and does not change the value. - * An empty string unsets the value, and the email then uses the value of your account. + * Do not send an empty string. The API rejects an empty string with a 422 error. Only a brand color accepts an + * empty string. */ public class EmailParagraphStyle { diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailSettings.java b/src/main/java/com/mailerlite/sdk/emails/EmailSettings.java index 5d3f1b0..5a2365e 100644 --- a/src/main/java/com/mailerlite/sdk/emails/EmailSettings.java +++ b/src/main/java/com/mailerlite/sdk/emails/EmailSettings.java @@ -14,7 +14,9 @@ * * In a response, the settings show the values that apply to the email, also the values that the email * inherits from your account. In an update, set only the fields that you want to change. A null field is not sent. - * An empty string unsets a value. + * To unset a brand color, send an empty string. To unset a footer text, send an empty editor document. The email then + * uses the value of your account. For the other values, for example other colors, sizes, button.borderRadius and + * brand.style, the API rejects an empty string with a 422 error. * * Do not send back all the settings that you read. The API stores each value that you send, also an inherited * value. The email then no longer follows your account for that value. diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailTextStyle.java b/src/main/java/com/mailerlite/sdk/emails/EmailTextStyle.java index 2c16dcc..bc96149 100644 --- a/src/main/java/com/mailerlite/sdk/emails/EmailTextStyle.java +++ b/src/main/java/com/mailerlite/sdk/emails/EmailTextStyle.java @@ -13,7 +13,8 @@ * The color and the size of a text style. Send the size as a string, for example "16" and not 16. * * In an update, null is not sent and does not change the value. - * An empty string unsets the value, and the email then uses the value of your account. + * Do not send an empty string. The API rejects an empty string with a 422 error. Only a brand color accepts an + * empty string. */ public class EmailTextStyle { diff --git a/src/main/java/com/mailerlite/sdk/emails/Emails.java b/src/main/java/com/mailerlite/sdk/emails/Emails.java index 85c800c..3f0af15 100644 --- a/src/main/java/com/mailerlite/sdk/emails/Emails.java +++ b/src/main/java/com/mailerlite/sdk/emails/Emails.java @@ -292,9 +292,11 @@ public EmailValidation validateBlock(EmailBlockInput block) throws MailerLiteExc /** * Changes the design settings of an email. *

- * Set only the fields that you want to change. A null field is not sent and does not change. An empty string - * unsets the value. The API stores each value that you send, also a value that the email inherits from your - * account. So do not send back all the settings that you read. + * Set only the fields that you want to change. A null field is not sent and does not change. To unset a brand + * color, send an empty string. To unset a footer text, send an empty editor document. The email then uses the + * value of your account. For the other values, the API rejects an empty string with a 422 error. The API stores + * each value that you send, also a value that the email inherits from your account. So do not send back all the + * settings that you read. *

* The SDK sends the etag at the top level of the body, next to the settings groups. * @@ -314,9 +316,11 @@ public SingleEmailSettings updateSettings(String emailId, String etag, EmailSett /** * Changes the design settings of an email. *

- * Set only the fields that you want to change. A null field is not sent and does not change. An empty string - * unsets the value. The API stores each value that you send, also a value that the email inherits from your - * account. So do not send back all the settings that you read. + * Set only the fields that you want to change. A null field is not sent and does not change. To unset a brand + * color, send an empty string. To unset a footer text, send an empty editor document. The email then uses the + * value of your account. For the other values, the API rejects an empty string with a 422 error. The API stores + * each value that you send, also a value that the email inherits from your account. So do not send back all the + * settings that you read. *

* The SDK sends the etag at the top level of the body, next to the settings groups. * From 55bd2507176c7ab4875292c7cfe47e74d08412a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Thu, 29 Oct 2026 17:39:26 +0100 Subject: [PATCH 30/35] test: compare query parameters in any sequence Some tests compared the full URL of a request with more than one query parameter. The sequence of the parameters comes from the HashMap in ApiRequest, and the API gives no meaning to it. Add UrlAssert. It compares the path exactly, and the raw query pairs as a list in any sequence, with duplicates. The encoding is still checked exactly. ApiRequest does not change, so the recorded tapes still match. Co-Authored-By: Claude Opus 5.5 --- .../com/mailerlite/sdk/support/UrlAssert.java | 75 +++++++++++++++++++ .../com/mailerlite/sdk/tests/EmailsTest.java | 6 +- .../mailerlite/sdk/tests/MediaFilesTest.java | 2 +- .../sdk/tests/ReadmeExamplesTest.java | 14 +++- .../mailerlite/sdk/tests/UrlAssertTest.java | 63 ++++++++++++++++ 5 files changed, 154 insertions(+), 6 deletions(-) create mode 100644 src/test/java/com/mailerlite/sdk/support/UrlAssert.java create mode 100644 src/test/java/com/mailerlite/sdk/tests/UrlAssertTest.java diff --git a/src/test/java/com/mailerlite/sdk/support/UrlAssert.java b/src/test/java/com/mailerlite/sdk/support/UrlAssert.java new file mode 100644 index 0000000..02ca481 --- /dev/null +++ b/src/test/java/com/mailerlite/sdk/support/UrlAssert.java @@ -0,0 +1,75 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.support; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * Compares URLs. The path must be the same. The query parameters must be the same raw "key=value" pairs, with the + * same number of each pair, but in any sequence. The API gives no meaning to the sequence, and ApiRequest keeps the + * parameters in a HashMap. + */ +public final class UrlAssert { + + /** + * Do not make instances of this class. + */ + private UrlAssert() + { + } + + /** + * Tells if two URLs are the same, in any sequence of the query parameters. + * + * @param expected the expected URL + * @param actual the actual URL + * @return true if they are the same + */ + public static boolean sameUrl(String expected, String actual) + { + return normalize(expected).equals(normalize(actual)); + } + + /** + * Asserts that two URLs are the same, in any sequence of the query parameters. + * + * @param expected the expected URL + * @param actual the actual URL + */ + public static void assertUrlEquals(String expected, String actual) + { + if (!sameUrl(expected, actual)) { + assertEquals(expected, actual, "the URLs are different (the sequence of the query parameters is ignored)"); + } + } + + /** + * Makes a form of the URL that has the path and the sorted list of the raw query pairs. + * + * @param url the URL + * @return the normalized form + */ + private static String normalize(String url) + { + int mark = url.indexOf('?'); + + if (mark < 0) { + return url; + } + + List pairs = new ArrayList(Arrays.asList(url.substring(mark + 1).split("&", -1))); + Collections.sort(pairs); + + return url.substring(0, mark + 1) + pairs; + } +} diff --git a/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java b/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java index a66adaa..1720513 100644 --- a/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java +++ b/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java @@ -62,6 +62,7 @@ import com.mailerlite.sdk.support.Fixtures; import com.mailerlite.sdk.support.RecordingHttpClient; import com.mailerlite.sdk.support.RecordingHttpClient.RecordedRequest; +import com.mailerlite.sdk.support.UrlAssert; /** * Tests the email endpoints with the recording HTTP client. @@ -101,6 +102,7 @@ public void afterEach() /** * Asserts the one request that the client got: the method, the full URL, all the headers and the raw body. + * The sequence of the query parameters is ignored. * * @param client the fake HTTP client * @param method the HTTP method @@ -116,7 +118,7 @@ static RecordedRequest assertRequest(RecordingHttpClient client, String method, RecordedRequest request = client.lastRequest(); assertEquals(method, request.method); - assertEquals(BASE + path, request.uri.toString()); + UrlAssert.assertUrlEquals(BASE + path, request.uri.toString()); Set expectedHeaders = new TreeSet(String.CASE_INSENSITIVE_ORDER); expectedHeaders.addAll(Arrays.asList("Content-Type", "Accept", "Authorization")); @@ -971,7 +973,7 @@ public void versionsWithPageAndLimit() throws Exception emails.versions("123").page(2).limit(10).get(); - assertRequest(client, "GET", "/emails/123/versions?limit=10&page=2", null, null); + assertRequest(client, "GET", "/emails/123/versions?page=2&limit=10", null, null); } /** diff --git a/src/test/java/com/mailerlite/sdk/tests/MediaFilesTest.java b/src/test/java/com/mailerlite/sdk/tests/MediaFilesTest.java index b52009a..1b89740 100644 --- a/src/test/java/com/mailerlite/sdk/tests/MediaFilesTest.java +++ b/src/test/java/com/mailerlite/sdk/tests/MediaFilesTest.java @@ -130,6 +130,6 @@ public void allOptions() throws Exception .get(); EmailsTest.assertRequest(client, "GET", - "/files?filter[type]=image&limit=25&filter[name]=banner&sort=-size&page=2", null, null); + "/files?filter[type]=image&filter[name]=banner&sort=-size&page=2&limit=25", null, null); } } diff --git a/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java b/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java index ae6b184..0ee5e18 100644 --- a/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java +++ b/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java @@ -41,6 +41,7 @@ import com.mailerlite.sdk.support.ReadmeExampleBase; import com.mailerlite.sdk.support.RecordingHttpClient; import com.mailerlite.sdk.support.RecordingHttpClient.RecordedRequest; +import com.mailerlite.sdk.support.UrlAssert; /** * Compiles the examples of the new README sections and runs each one against the recording HTTP client. @@ -198,7 +199,7 @@ private static List examples() list.add(new Example("List the versions of an email") .respond(200, Fixtures.read("email_versions.json")) - .expect("GET /emails/123456/versions?limit=10&page=1")); + .expect("GET /emails/123456/versions?page=1&limit=10")); list.add(new Example("Save a version of an email") .respond(201, Fixtures.read("email_version.json")) @@ -214,7 +215,7 @@ private static List examples() list.add(new Example("Get a list of files") .respond(200, Fixtures.read("files.json")) - .expect("GET /files?filter[type]=image&limit=25&filter[name]=summer+sale&sort=-size&page=1")); + .expect("GET /files?filter[name]=summer+sale&filter[type]=image&sort=-size&page=1&limit=25")); list.add(new Example("Create a campaign with the simple editor") .respond(201, Fixtures.read("campaign_simple_editor.json")) @@ -307,7 +308,14 @@ private void run(ClassLoader loader, String className, Example example) throws T sent.add(request.method + " " + request.uri.toString().substring(BASE.length())); } - assertEquals(example.requests, sent, "requests of the example"); + assertEquals(example.requests.size(), sent.size(), "number of requests of the example: " + sent); + + for (int i = 0; i < sent.size(); i++) { + String[] expected = example.requests.get(i).split(" ", 2); + String[] actual = sent.get(i).split(" ", 2); + assertEquals(expected[0], actual[0], "method of request " + i); + UrlAssert.assertUrlEquals(expected[1], actual[1]); + } assertEquals(0, client.pendingResponses(), "the example must use all the queued responses"); } finally { RecordingHttpClient.uninstall(); diff --git a/src/test/java/com/mailerlite/sdk/tests/UrlAssertTest.java b/src/test/java/com/mailerlite/sdk/tests/UrlAssertTest.java new file mode 100644 index 0000000..ffb870f --- /dev/null +++ b/src/test/java/com/mailerlite/sdk/tests/UrlAssertTest.java @@ -0,0 +1,63 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.tests; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +import com.mailerlite.sdk.support.UrlAssert; + +/** + * Tests the URL comparison that ignores the sequence of the query parameters. + */ +public class UrlAssertTest { + + /** + * The sequence of the query parameters does not matter. + */ + @Test + public void ignoresTheSequenceOfTheParameters() + { + assertTrue(UrlAssert.sameUrl("/files?a=1&b=2", "/files?b=2&a=1")); + assertTrue(UrlAssert.sameUrl("/files", "/files")); + UrlAssert.assertUrlEquals("/files?filter[name]=a+b&page=2", "/files?page=2&filter[name]=a+b"); + } + + /** + * A parameter that is sent two times is not the same as a parameter that is sent one time. + */ + @Test + public void keepsDuplicateParameters() + { + assertFalse(UrlAssert.sameUrl("/files?a=1", "/files?a=1&a=1")); + } + + /** + * The values and the encoding must be the same. + */ + @Test + public void comparesTheRawValues() + { + assertFalse(UrlAssert.sameUrl("/files?a=1", "/files?a=2")); + assertFalse(UrlAssert.sameUrl("/files?a=a+b", "/files?a=a%20b")); + } + + /** + * The paths must be the same, and a URL without a query is not the same as a URL with a query. + */ + @Test + public void comparesThePathExactly() + { + assertFalse(UrlAssert.sameUrl("/files?a=1", "/emails?a=1")); + assertFalse(UrlAssert.sameUrl("/files", "/files?a=1")); + assertThrows(AssertionError.class, () -> UrlAssert.assertUrlEquals("/files?a=1&b=2", "/files?a=1&b=3")); + } +} From be4f2dfb63a75b58a781c2594c1cf521e0446f2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Thu, 29 Oct 2026 18:47:43 +0100 Subject: [PATCH 31/35] chore: remove redundant comments Remove the doc comments that only repeat the code in private members, test helpers and tests. Keep the public Javadoc, and shorten the comments that explain a reason. Co-Authored-By: Claude Opus 5.5 --- .../com/mailerlite/sdk/MailerLiteApi.java | 9 +- .../com/mailerlite/sdk/RequestOptions.java | 6 - .../sdk/brandstyles/BrandStyles.java | 1 - .../sdk/emails/EditorContentAdapter.java | 2 - .../sdk/emails/EmailBlockPositions.java | 3 - .../sdk/emails/EmailBlockTypes.java | 3 - .../sdk/emails/EmailBrandModes.java | 3 - .../mailerlite/sdk/emails/EmailEditors.java | 3 - .../sdk/emails/EmailErrorCodes.java | 3 - .../sdk/emails/EmailVersionsRetriever.java | 8 - .../com/mailerlite/sdk/emails/Emails.java | 120 +-------- .../sdk/exceptions/EmptyEtagException.java | 1 - .../sdk/exceptions/EmptyIdException.java | 1 - .../sdk/exceptions/InvalidIdException.java | 1 - .../InvalidIdempotencyKeyException.java | 1 - .../sdk/files/MediaFileRetriever.java | 1 - .../mailerlite/sdk/files/MediaFileSorts.java | 3 - .../mailerlite/sdk/files/MediaFileTypes.java | 3 - .../com/mailerlite/sdk/files/MediaFiles.java | 1 - .../mailerlite/sdk/util/RequestChecks.java | 9 - .../com/mailerlite/sdk/support/Fixtures.java | 12 - .../sdk/support/ReadmeExampleBase.java | 9 - .../sdk/support/RecordingHttpClient.java | 189 +------------- .../com/mailerlite/sdk/support/UrlAssert.java | 28 +- .../mailerlite/sdk/tests/ApiRequestTest.java | 24 -- .../mailerlite/sdk/tests/BrandStylesTest.java | 21 -- .../sdk/tests/CampaignSimpleEditorTest.java | 27 -- .../com/mailerlite/sdk/tests/EmailsTest.java | 240 ------------------ .../mailerlite/sdk/tests/IdempotencyTest.java | 38 --- .../sdk/tests/MailerLiteApiTest.java | 86 ------- .../mailerlite/sdk/tests/MediaFilesTest.java | 31 --- .../sdk/tests/ReadmeExamplesTest.java | 94 +------ .../sdk/tests/RecordingHttpClientTest.java | 20 -- .../sdk/tests/RequestChecksTest.java | 45 ---- .../mailerlite/sdk/tests/UrlAssertTest.java | 15 -- 35 files changed, 8 insertions(+), 1053 deletions(-) diff --git a/src/main/java/com/mailerlite/sdk/MailerLiteApi.java b/src/main/java/com/mailerlite/sdk/MailerLiteApi.java index c7b8cf6..5146ae5 100644 --- a/src/main/java/com/mailerlite/sdk/MailerLiteApi.java +++ b/src/main/java/com/mailerlite/sdk/MailerLiteApi.java @@ -345,14 +345,7 @@ public T sendRequest(String method, String endpoi } - /** - * Converts a network error to a MailerLiteException. The message is the class name and the message of the - * error, so it is not null when the error has no message. - * For an InterruptedException, it also sets the interrupt flag of the thread again. - * - * @param e the network error - * @return the exception to throw - */ + /** Uses e.toString() as the message, because e.getMessage() can be null. */ private MailerLiteException networkError(Exception e) { if (e instanceof InterruptedException) { diff --git a/src/main/java/com/mailerlite/sdk/RequestOptions.java b/src/main/java/com/mailerlite/sdk/RequestOptions.java index 9fe5e99..af25616 100644 --- a/src/main/java/com/mailerlite/sdk/RequestOptions.java +++ b/src/main/java/com/mailerlite/sdk/RequestOptions.java @@ -18,14 +18,8 @@ */ public final class RequestOptions { - /** The idempotency key, or null. */ private final String idempotencyKey; - /** - * Instantiates new options. - * - * @param idempotencyKey the idempotency key, or null - */ private RequestOptions(String idempotencyKey) { this.idempotencyKey = idempotencyKey; diff --git a/src/main/java/com/mailerlite/sdk/brandstyles/BrandStyles.java b/src/main/java/com/mailerlite/sdk/brandstyles/BrandStyles.java index 042cd7b..13674b3 100644 --- a/src/main/java/com/mailerlite/sdk/brandstyles/BrandStyles.java +++ b/src/main/java/com/mailerlite/sdk/brandstyles/BrandStyles.java @@ -16,7 +16,6 @@ */ public class BrandStyles { - /** The api object reference. */ private MailerLite apiObjectReference; /** diff --git a/src/main/java/com/mailerlite/sdk/emails/EditorContentAdapter.java b/src/main/java/com/mailerlite/sdk/emails/EditorContentAdapter.java index 72129e8..a21d07d 100644 --- a/src/main/java/com/mailerlite/sdk/emails/EditorContentAdapter.java +++ b/src/main/java/com/mailerlite/sdk/emails/EditorContentAdapter.java @@ -31,13 +31,11 @@ */ public class EditorContentAdapter extends TypeAdapter> { - /** The Gson object that reads and writes the map. */ private static final Gson GSON = new GsonBuilder() .setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE) .serializeNulls() .create(); - /** The adapter for the map. */ private static final TypeAdapter> DELEGATE = GSON.getAdapter(new TypeToken>() { }); diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailBlockPositions.java b/src/main/java/com/mailerlite/sdk/emails/EmailBlockPositions.java index 9f369b5..248548b 100644 --- a/src/main/java/com/mailerlite/sdk/emails/EmailBlockPositions.java +++ b/src/main/java/com/mailerlite/sdk/emails/EmailBlockPositions.java @@ -24,9 +24,6 @@ public final class EmailBlockPositions { /** After the block with the block ID. */ public static final String AFTER = "after"; - /** - * Do not make instances of this class. - */ private EmailBlockPositions() { } diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailBlockTypes.java b/src/main/java/com/mailerlite/sdk/emails/EmailBlockTypes.java index 67d38eb..070acac 100644 --- a/src/main/java/com/mailerlite/sdk/emails/EmailBlockTypes.java +++ b/src/main/java/com/mailerlite/sdk/emails/EmailBlockTypes.java @@ -51,9 +51,6 @@ public final class EmailBlockTypes { /** A video. */ public static final String VIDEO = "video"; - /** - * Do not make instances of this class. - */ private EmailBlockTypes() { } diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailBrandModes.java b/src/main/java/com/mailerlite/sdk/emails/EmailBrandModes.java index 8821e49..e861abb 100644 --- a/src/main/java/com/mailerlite/sdk/emails/EmailBrandModes.java +++ b/src/main/java/com/mailerlite/sdk/emails/EmailBrandModes.java @@ -21,9 +21,6 @@ public final class EmailBrandModes { /** Use the colors of the email. */ public static final String CUSTOM = "custom"; - /** - * Do not make instances of this class. - */ private EmailBrandModes() { } diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailEditors.java b/src/main/java/com/mailerlite/sdk/emails/EmailEditors.java index 4af8e33..93087dc 100644 --- a/src/main/java/com/mailerlite/sdk/emails/EmailEditors.java +++ b/src/main/java/com/mailerlite/sdk/emails/EmailEditors.java @@ -15,9 +15,6 @@ public final class EmailEditors { /** The simple editor. Change the email with the methods of {@link Emails}. */ public static final String SIMPLE = "simple"; - /** - * Do not make instances of this class. - */ private EmailEditors() { } diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailErrorCodes.java b/src/main/java/com/mailerlite/sdk/emails/EmailErrorCodes.java index f0ef593..7d895ab 100644 --- a/src/main/java/com/mailerlite/sdk/emails/EmailErrorCodes.java +++ b/src/main/java/com/mailerlite/sdk/emails/EmailErrorCodes.java @@ -18,9 +18,6 @@ public final class EmailErrorCodes { */ public static final String VERSION_CONFLICT = "version_conflict"; - /** - * Do not make instances of this class. - */ private EmailErrorCodes() { } diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailVersionsRetriever.java b/src/main/java/com/mailerlite/sdk/emails/EmailVersionsRetriever.java index 93c04a8..78df826 100644 --- a/src/main/java/com/mailerlite/sdk/emails/EmailVersionsRetriever.java +++ b/src/main/java/com/mailerlite/sdk/emails/EmailVersionsRetriever.java @@ -17,18 +17,10 @@ */ public class EmailVersionsRetriever extends CursorPaginatedRequest { - /** The api object reference. */ private MailerLite apiObjectReference; - /** The path of the versions, with the checked email ID. */ private String endpoint; - /** - * Instantiates a new retriever. Use {@link Emails#versions(String)}. - * - * @param apiReference the api reference - * @param endpoint the path of the versions - */ EmailVersionsRetriever(MailerLite apiReference, String endpoint) { this.apiObjectReference = apiReference; diff --git a/src/main/java/com/mailerlite/sdk/emails/Emails.java b/src/main/java/com/mailerlite/sdk/emails/Emails.java index 3f0af15..aa10096 100644 --- a/src/main/java/com/mailerlite/sdk/emails/Emails.java +++ b/src/main/java/com/mailerlite/sdk/emails/Emails.java @@ -41,16 +41,11 @@ */ public class Emails { - /** The Gson object for the request bodies. It does not send null fields. */ private static final Gson GSON = new GsonBuilder().disableHtmlEscaping().create(); - /** - * The Gson object that writes a JSON tree that GSON made. The tree has no null fields, except the null values - * in the editor content maps, so this object writes them. - */ + /** Writes the trees that GSON makes, with the null values of the editor content maps. */ private static final Gson TREE_GSON = new GsonBuilder().disableHtmlEscaping().serializeNulls().create(); - /** The api object reference. */ private MailerLite apiObjectReference; /** @@ -297,8 +292,6 @@ public EmailValidation validateBlock(EmailBlockInput block) throws MailerLiteExc * value of your account. For the other values, the API rejects an empty string with a 422 error. The API stores * each value that you send, also a value that the email inherits from your account. So do not send back all the * settings that you read. - *

- * The SDK sends the etag at the top level of the body, next to the settings groups. * * @param emailId the email ID * @param etag the etag from the last response for the email @@ -321,8 +314,6 @@ public SingleEmailSettings updateSettings(String emailId, String etag, EmailSett * value of your account. For the other values, the API rejects an empty string with a 422 error. The API stores * each value that you send, also a value that the email inherits from your account. So do not send back all the * settings that you read. - *

- * The SDK sends the etag at the top level of the body, next to the settings groups. * * @param emailId the email ID * @param etag the etag from the last response for the email @@ -415,7 +406,7 @@ public EmailVersionsRetriever versions(String emailId) } /** - * Saves the email as a new version. The request has no body. + * Saves the email as a new version. *

* If nothing changed after the last version, the API saves no new version and returns the last version. * {@link SingleEmailVersion#isCreated()} tells the two cases apart. @@ -430,7 +421,7 @@ public SingleEmailVersion createVersion(String emailId) throws MailerLiteExcepti } /** - * Saves the email as a new version. The request has no body. + * Saves the email as a new version. *

* If nothing changed after the last version, the API saves no new version and returns the last version. * {@link SingleEmailVersion#isCreated()} tells the two cases apart. @@ -482,36 +473,16 @@ public SingleEmailDocument restoreVersion(String emailId, String versionId, Stri return send("POST", endpoint, etagBody(etag), options, SingleEmailDocument.class); } - /** - * Checks the email ID and makes the path of the email. - * - * @param emailId the email ID - * @return the path - */ private static String emailPath(String emailId) { return "/emails/".concat(RequestChecks.pathId(emailId, "emailId")); } - /** - * Checks the email ID and the block ID, and makes the path of the block. - * - * @param emailId the email ID - * @param blockId the block ID - * @return the path - */ private static String blockPath(String emailId, String blockId) { return emailPath(emailId).concat("/blocks/").concat(RequestChecks.pathId(blockId, "blockId")); } - /** - * Checks a block input and makes the request body. - * - * @param block the block input - * @param etag the etag, or null for no etag - * @return the request body - */ private static BlockBody blockBody(EmailBlockInput block, String etag) { Objects.requireNonNull(block, "block"); @@ -530,13 +501,7 @@ private static BlockBody blockBody(EmailBlockInput block, String etag) } /** - * Checks a placement and makes a copy to send. - *

- * The API uses the block ID only for "before" and "after". So for these positions the block ID must be valid. - * For "start" and "end", the copy has no block ID. For another position, the block ID is checked if it is set. - * - * @param placement the placement of the caller - * @return the copy to send + * The API uses the block ID only for "before" and "after". So the copy has no block ID for "start" and "end". */ private static PlacementBody placementBody(EmailBlockPosition placement) { @@ -558,12 +523,6 @@ private static PlacementBody placementBody(EmailBlockPosition placement) return new PlacementBody(position, placement.blockId); } - /** - * Checks the etag and makes a request body that has only the etag. - * - * @param etag the etag - * @return the JSON body - */ private static String etagBody(String etag) { EtagBody body = new EtagBody(); @@ -572,57 +531,22 @@ private static String etagBody(String etag) return toJson(body); } - /** - * Converts the settings to a JSON tree without the null fields. - * - * @param settings the settings - * @return the JSON tree - */ private static JsonObject settingsBody(EmailSettings settings) { return GSON.toJsonTree(Objects.requireNonNull(settings, "settings")).getAsJsonObject(); } - /** - * Converts a request body to JSON. - * - * @param body the body - * @return the JSON - */ private static String toJson(Object body) { return GSON.toJson(body); } - /** - * Sends a request without options. - * - * @param the response type - * @param method the HTTP method - * @param endpoint the endpoint - * @param body the JSON body, or null - * @param responseClass the response class - * @return the response - * @throws MailerLiteException if the API returns an error or the request fails - */ private T send(String method, String endpoint, String body, Class responseClass) throws MailerLiteException { return send(method, endpoint, body, null, responseClass); } - /** - * Sends a request. If the options have an idempotency key, the request has the Idempotency-Key header. - * - * @param the response type - * @param method the HTTP method - * @param endpoint the endpoint - * @param body the JSON body, or null - * @param options the request options, or null - * @param responseClass the response class - * @return the response - * @throws MailerLiteException if the API returns an error or the request fails - */ private T send(String method, String endpoint, String body, RequestOptions options, Class responseClass) throws MailerLiteException { @@ -638,97 +562,61 @@ private T send(String method, String endpoint, St return api.sendRequest(method, endpoint, body, headers, responseClass); } - /** - * The request body of a block insert or a block validation. - */ private static class BlockBody { - /** The block type. */ @SerializedName("type") String type; - /** The editor document. */ @SerializedName("editor_content") @JsonAdapter(EditorContentAdapter.class) Map editorContent; - /** The placement, or null. */ @SerializedName("placement") PlacementBody placement; - /** The etag, or null. */ @SerializedName("etag") String etag; } - /** - * The request body of a block update. - */ private static class UpdateBlockBody { - /** The editor document. */ @SerializedName("editor_content") @JsonAdapter(EditorContentAdapter.class) Map editorContent; - /** The etag. */ @SerializedName("etag") String etag; } - /** - * A request body that has only the etag. - */ private static class EtagBody { - /** The etag. */ @SerializedName("etag") String etag; } - /** - * The request body of a block move. - */ private static class MoveBlockBody { - /** The placement. */ @SerializedName("placement") PlacementBody placement; - /** The etag. */ @SerializedName("etag") String etag; } - /** - * The request body of a template create. - */ private static class TemplateBody { - /** The name. */ @SerializedName("name") String name; } - /** - * The placement in a request body. - */ private static class PlacementBody { - /** The position. */ @SerializedName("position") final String position; - /** The block ID, or null. */ @SerializedName("block_id") final String blockId; - /** - * Instantiates a new placement body. - * - * @param position the position - * @param blockId the block ID, or null - */ PlacementBody(String position, String blockId) { this.position = position; diff --git a/src/main/java/com/mailerlite/sdk/exceptions/EmptyEtagException.java b/src/main/java/com/mailerlite/sdk/exceptions/EmptyEtagException.java index 8f8aea2..505681a 100644 --- a/src/main/java/com/mailerlite/sdk/exceptions/EmptyEtagException.java +++ b/src/main/java/com/mailerlite/sdk/exceptions/EmptyEtagException.java @@ -13,7 +13,6 @@ */ public class EmptyEtagException extends IllegalArgumentException { - /** The serial version UID. */ private static final long serialVersionUID = 1L; /** diff --git a/src/main/java/com/mailerlite/sdk/exceptions/EmptyIdException.java b/src/main/java/com/mailerlite/sdk/exceptions/EmptyIdException.java index abf2550..7fd78b1 100644 --- a/src/main/java/com/mailerlite/sdk/exceptions/EmptyIdException.java +++ b/src/main/java/com/mailerlite/sdk/exceptions/EmptyIdException.java @@ -13,7 +13,6 @@ */ public class EmptyIdException extends IllegalArgumentException { - /** The serial version UID. */ private static final long serialVersionUID = 1L; /** diff --git a/src/main/java/com/mailerlite/sdk/exceptions/InvalidIdException.java b/src/main/java/com/mailerlite/sdk/exceptions/InvalidIdException.java index 40d906e..9041517 100644 --- a/src/main/java/com/mailerlite/sdk/exceptions/InvalidIdException.java +++ b/src/main/java/com/mailerlite/sdk/exceptions/InvalidIdException.java @@ -13,7 +13,6 @@ */ public class InvalidIdException extends IllegalArgumentException { - /** The serial version UID. */ private static final long serialVersionUID = 1L; /** diff --git a/src/main/java/com/mailerlite/sdk/exceptions/InvalidIdempotencyKeyException.java b/src/main/java/com/mailerlite/sdk/exceptions/InvalidIdempotencyKeyException.java index 9812bfa..5c10e8c 100644 --- a/src/main/java/com/mailerlite/sdk/exceptions/InvalidIdempotencyKeyException.java +++ b/src/main/java/com/mailerlite/sdk/exceptions/InvalidIdempotencyKeyException.java @@ -13,7 +13,6 @@ */ public class InvalidIdempotencyKeyException extends IllegalArgumentException { - /** The serial version UID. */ private static final long serialVersionUID = 1L; /** diff --git a/src/main/java/com/mailerlite/sdk/files/MediaFileRetriever.java b/src/main/java/com/mailerlite/sdk/files/MediaFileRetriever.java index a230517..359fa3d 100644 --- a/src/main/java/com/mailerlite/sdk/files/MediaFileRetriever.java +++ b/src/main/java/com/mailerlite/sdk/files/MediaFileRetriever.java @@ -20,7 +20,6 @@ */ public class MediaFileRetriever extends CursorPaginatedRequest { - /** The api object reference. */ private MailerLite apiObjectReference; /** diff --git a/src/main/java/com/mailerlite/sdk/files/MediaFileSorts.java b/src/main/java/com/mailerlite/sdk/files/MediaFileSorts.java index 247a82f..0dc3608 100644 --- a/src/main/java/com/mailerlite/sdk/files/MediaFileSorts.java +++ b/src/main/java/com/mailerlite/sdk/files/MediaFileSorts.java @@ -30,9 +30,6 @@ public final class MediaFileSorts { /** The largest files first. */ public static final String SIZE_DESC = "-size"; - /** - * Do not make instances of this class. - */ private MediaFileSorts() { } diff --git a/src/main/java/com/mailerlite/sdk/files/MediaFileTypes.java b/src/main/java/com/mailerlite/sdk/files/MediaFileTypes.java index 6cfaefc..c102499 100644 --- a/src/main/java/com/mailerlite/sdk/files/MediaFileTypes.java +++ b/src/main/java/com/mailerlite/sdk/files/MediaFileTypes.java @@ -22,9 +22,6 @@ public final class MediaFileTypes { /** An EPUB book. */ public static final String EPUB = "epub"; - /** - * Do not make instances of this class. - */ private MediaFileTypes() { } diff --git a/src/main/java/com/mailerlite/sdk/files/MediaFiles.java b/src/main/java/com/mailerlite/sdk/files/MediaFiles.java index 75e1fac..ad4d948 100644 --- a/src/main/java/com/mailerlite/sdk/files/MediaFiles.java +++ b/src/main/java/com/mailerlite/sdk/files/MediaFiles.java @@ -14,7 +14,6 @@ */ public class MediaFiles { - /** The api object reference. */ private MailerLite apiObjectReference; /** diff --git a/src/main/java/com/mailerlite/sdk/util/RequestChecks.java b/src/main/java/com/mailerlite/sdk/util/RequestChecks.java index a8733cb..941aed5 100644 --- a/src/main/java/com/mailerlite/sdk/util/RequestChecks.java +++ b/src/main/java/com/mailerlite/sdk/util/RequestChecks.java @@ -18,12 +18,8 @@ */ public final class RequestChecks { - /** The maximum length of an idempotency key. */ private static final int MAX_IDEMPOTENCY_KEY_LENGTH = 255; - /** - * Do not make instances of this class. - */ private RequestChecks() { } @@ -104,11 +100,6 @@ public static String idempotencyKey(String key) return key; } - /** - * Makes the exception for an invalid idempotency key. - * - * @return the exception - */ private static InvalidIdempotencyKeyException invalidKey() { return new InvalidIdempotencyKeyException( diff --git a/src/test/java/com/mailerlite/sdk/support/Fixtures.java b/src/test/java/com/mailerlite/sdk/support/Fixtures.java index 721a741..e2e816c 100644 --- a/src/test/java/com/mailerlite/sdk/support/Fixtures.java +++ b/src/test/java/com/mailerlite/sdk/support/Fixtures.java @@ -12,24 +12,12 @@ import java.io.UncheckedIOException; import java.nio.charset.StandardCharsets; -/** - * Reads the JSON response fixtures in src/test/resources/responses. - */ public final class Fixtures { - /** - * Do not make instances of this class. - */ private Fixtures() { } - /** - * Reads a fixture. - * - * @param name the file name, for example "email.json" - * @return the file contents - */ public static String read(String name) { try (InputStream stream = Fixtures.class.getResourceAsStream("/responses/" + name)) { diff --git a/src/test/java/com/mailerlite/sdk/support/ReadmeExampleBase.java b/src/test/java/com/mailerlite/sdk/support/ReadmeExampleBase.java index 391502a..58514bf 100644 --- a/src/test/java/com/mailerlite/sdk/support/ReadmeExampleBase.java +++ b/src/test/java/com/mailerlite/sdk/support/ReadmeExampleBase.java @@ -9,17 +9,8 @@ import com.mailerlite.sdk.MailerLite; -/** - * The base class of the README examples in ReadmeExamplesTest. It gives getMailerLite(), as the - * Usage section of the README shows. - */ public class ReadmeExampleBase { - /** - * Gets a MailerLite object with a test token. - * - * @return the MailerLite object - */ public MailerLite getMailerLite() { MailerLite ml = new MailerLite(); diff --git a/src/test/java/com/mailerlite/sdk/support/RecordingHttpClient.java b/src/test/java/com/mailerlite/sdk/support/RecordingHttpClient.java index 1dd393a..aed59bd 100644 --- a/src/test/java/com/mailerlite/sdk/support/RecordingHttpClient.java +++ b/src/test/java/com/mailerlite/sdk/support/RecordingHttpClient.java @@ -41,23 +41,12 @@ import com.mailerlite.sdk.util.MailerLiteHttpClientFactory; -/** - * A fake HttpClient for tests. It keeps each request that it gets, and it returns the responses - * from a queue. It sends no request to the network. - */ public class RecordingHttpClient extends HttpClient { - /** The requests that the client got, in sequence. */ private final List requests = new ArrayList(); - /** The responses and the errors to return, in sequence. */ private final Deque responses = new ArrayDeque(); - /** - * Makes a new client and sets it in the MailerLiteHttpClientFactory. - * - * @return the new client - */ public static RecordingHttpClient install() { RecordingHttpClient client = new RecordingHttpClient(); @@ -66,34 +55,16 @@ public static RecordingHttpClient install() return client; } - /** - * Removes the client from the MailerLiteHttpClientFactory. - */ public static void uninstall() { MailerLiteHttpClientFactory.getInstance().setClient(null); } - /** - * Adds a response to the queue. - * - * @param status the status code - * @param body the response body - * @return this client - */ public RecordingHttpClient respond(int status, String body) { return respond(status, body, Collections.emptyMap()); } - /** - * Adds a response with headers to the queue. - * - * @param status the status code - * @param body the response body - * @param headers the response headers - * @return this client - */ public RecordingHttpClient respond(int status, String body, Map headers) { Map> headerMap = new LinkedHashMap>(); @@ -107,12 +78,7 @@ public RecordingHttpClient respond(int status, String body, Map return this; } - /** - * Adds an error to the queue. The client throws it for the next request. - * - * @param error an IOException or an InterruptedException - * @return this client - */ + /** The error must be an IOException or an InterruptedException. */ public RecordingHttpClient fail(Exception error) { responses.add(error); @@ -120,21 +86,11 @@ public RecordingHttpClient fail(Exception error) return this; } - /** - * Gets the requests that the client got. - * - * @return the requests - */ public List requests() { return requests; } - /** - * Gets the last request that the client got. - * - * @return the last request - */ public RecordedRequest lastRequest() { if (requests.isEmpty()) { @@ -144,26 +100,11 @@ public RecordedRequest lastRequest() return requests.get(requests.size() - 1); } - /** - * Gets the number of queued responses that no request used. - * - * @return the number of queued responses - */ public int pendingResponses() { return responses.size(); } - /** - * Keeps the request and returns the next queued response. - * - * @param the body type - * @param request the request - * @param responseBodyHandler the body handler - * @return the response - * @throws IOException if the queue has an IOException, or if the queue is empty - * @throws InterruptedException if the queue has an InterruptedException - */ @Override public HttpResponse send(HttpRequest request, BodyHandler responseBodyHandler) throws IOException, InterruptedException @@ -189,14 +130,6 @@ public HttpResponse send(HttpRequest request, BodyHandler responseBody return new FakeResponse(request, queued, readBody(queued, responseBodyHandler)); } - /** - * Converts the queued body with the body handler of the caller. - * - * @param the body type - * @param queued the queued response - * @param handler the body handler - * @return the body - */ private T readBody(QueuedResponse queued, BodyHandler handler) { BodySubscriber subscriber = handler.apply(new HttpResponse.ResponseInfo() { @@ -231,7 +164,6 @@ public void request(long n) @Override public void cancel() { - // nothing to cancel } }); @@ -241,29 +173,12 @@ public void cancel() return subscriber.getBody().toCompletableFuture().join(); } - /** - * Not supported. - * - * @param the body type - * @param request the request - * @param responseBodyHandler the body handler - * @return nothing - */ @Override public CompletableFuture> sendAsync(HttpRequest request, BodyHandler responseBodyHandler) { throw new UnsupportedOperationException("RecordingHttpClient supports only send()"); } - /** - * Not supported. - * - * @param the body type - * @param request the request - * @param responseBodyHandler the body handler - * @param pushPromiseHandler the push promise handler - * @return nothing - */ @Override public CompletableFuture> sendAsync(HttpRequest request, BodyHandler responseBodyHandler, PushPromiseHandler pushPromiseHandler) @@ -271,117 +186,66 @@ public CompletableFuture> sendAsync(HttpRequest request, Bod throw new UnsupportedOperationException("RecordingHttpClient supports only send()"); } - /** - * Cookie handler. - * - * @return an empty value - */ @Override public Optional cookieHandler() { return Optional.empty(); } - /** - * Connect timeout. - * - * @return an empty value - */ @Override public Optional connectTimeout() { return Optional.empty(); } - /** - * Follow redirects. - * - * @return NEVER - */ @Override public Redirect followRedirects() { return Redirect.NEVER; } - /** - * Proxy. - * - * @return an empty value - */ @Override public Optional proxy() { return Optional.empty(); } - /** - * Ssl context. - * - * @return null - */ @Override public SSLContext sslContext() { return null; } - /** - * Ssl parameters. - * - * @return null - */ @Override public SSLParameters sslParameters() { return null; } - /** - * Authenticator. - * - * @return an empty value - */ @Override public Optional authenticator() { return Optional.empty(); } - /** - * Version. - * - * @return HTTP_1_1 - */ @Override public Version version() { return Version.HTTP_1_1; } - /** - * Executor. - * - * @return an empty value - */ @Override public Optional executor() { return Optional.empty(); } - /** - * A request that the client got, with the raw bytes of its body. - */ public static class RecordedRequest { - /** The method. */ public final String method; - /** The full URI. */ public final URI uri; - /** The headers. */ public final HttpHeaders headers; /** True if the request has a body publisher. GET() and DELETE() give no publisher. */ @@ -390,14 +254,8 @@ public static class RecordedRequest { /** The content length of the body publisher, or -2 if there is no publisher. */ public final long contentLength; - /** The raw bytes of the body. The array is empty if there is no body. */ public final byte[] body; - /** - * Reads the request and its body. - * - * @param request the request - */ RecordedRequest(HttpRequest request) { this.method = request.method(); @@ -408,33 +266,16 @@ public static class RecordedRequest { this.body = request.bodyPublisher().map(RecordedRequest::readAll).orElse(new byte[0]); } - /** - * Gets the body as a UTF-8 string. - * - * @return the body - */ public String bodyString() { return new String(body, StandardCharsets.UTF_8); } - /** - * Gets the first value of a header, or null. - * - * @param name the header name - * @return the value or null - */ public String header(String name) { return headers.firstValue(name).orElse(null); } - /** - * Reads all the bytes of a body publisher. - * - * @param publisher the body publisher - * @return the bytes - */ private static byte[] readAll(HttpRequest.BodyPublisher publisher) { ByteArrayOutputStream bytes = new ByteArrayOutputStream(); @@ -475,27 +316,14 @@ public void onComplete() } } - /** - * A queued response. - */ private static class QueuedResponse { - /** The status. */ final int status; - /** The body. */ final String body; - /** The headers. */ final HttpHeaders headers; - /** - * Instantiates a new queued response. - * - * @param status the status - * @param body the body - * @param headers the headers - */ QueuedResponse(int status, String body, HttpHeaders headers) { this.status = status; @@ -504,29 +332,14 @@ private static class QueuedResponse { } } - /** - * The response that the client returns. - * - * @param the body type - */ private static class FakeResponse implements HttpResponse { - /** The request. */ private final HttpRequest request; - /** The queued response. */ private final QueuedResponse queued; - /** The body. */ private final T body; - /** - * Instantiates a new fake response. - * - * @param request the request - * @param queued the queued response - * @param body the body - */ FakeResponse(HttpRequest request, QueuedResponse queued, T body) { this.request = request; diff --git a/src/test/java/com/mailerlite/sdk/support/UrlAssert.java b/src/test/java/com/mailerlite/sdk/support/UrlAssert.java index 02ca481..c0e0990 100644 --- a/src/test/java/com/mailerlite/sdk/support/UrlAssert.java +++ b/src/test/java/com/mailerlite/sdk/support/UrlAssert.java @@ -14,38 +14,17 @@ import java.util.Collections; import java.util.List; -/** - * Compares URLs. The path must be the same. The query parameters must be the same raw "key=value" pairs, with the - * same number of each pair, but in any sequence. The API gives no meaning to the sequence, and ApiRequest keeps the - * parameters in a HashMap. - */ public final class UrlAssert { - /** - * Do not make instances of this class. - */ private UrlAssert() { } - /** - * Tells if two URLs are the same, in any sequence of the query parameters. - * - * @param expected the expected URL - * @param actual the actual URL - * @return true if they are the same - */ public static boolean sameUrl(String expected, String actual) { return normalize(expected).equals(normalize(actual)); } - /** - * Asserts that two URLs are the same, in any sequence of the query parameters. - * - * @param expected the expected URL - * @param actual the actual URL - */ public static void assertUrlEquals(String expected, String actual) { if (!sameUrl(expected, actual)) { @@ -53,12 +32,7 @@ public static void assertUrlEquals(String expected, String actual) } } - /** - * Makes a form of the URL that has the path and the sorted list of the raw query pairs. - * - * @param url the URL - * @return the normalized form - */ + /** Sorts the query pairs, because ApiRequest keeps the parameters in a HashMap with no fixed sequence. */ private static String normalize(String url) { int mark = url.indexOf('?'); diff --git a/src/test/java/com/mailerlite/sdk/tests/ApiRequestTest.java b/src/test/java/com/mailerlite/sdk/tests/ApiRequestTest.java index 17baeeb..f02c789 100644 --- a/src/test/java/com/mailerlite/sdk/tests/ApiRequestTest.java +++ b/src/test/java/com/mailerlite/sdk/tests/ApiRequestTest.java @@ -13,14 +13,8 @@ import com.mailerlite.sdk.util.ApiRequest; -/** - * Tests the query string of ApiRequest. - */ public class ApiRequestTest { - /** - * The SDK does not store a null value. - */ @Test public void skipsNullValues() { @@ -31,9 +25,6 @@ public void skipsNullValues() assertEquals("?page=2", request.getQueryParameters()); } - /** - * A null value removes a value that the caller set before. - */ @Test public void nullValueRemovesTheEarlierValue() { @@ -44,9 +35,6 @@ public void nullValueRemovesTheEarlierValue() assertEquals("?", request.getQueryParameters()); } - /** - * The SDK encodes a space as a plus sign and keeps the brackets of the key. - */ @Test public void encodesSpacesInValues() { @@ -56,9 +44,6 @@ public void encodesSpacesInValues() assertEquals("?filter[name]=summer+sale+banner", request.getQueryParameters()); } - /** - * The SDK encodes the characters that have a meaning in a query string, and characters that are not ASCII. - */ @Test public void encodesReservedAndNonAsciiCharacters() { @@ -68,9 +53,6 @@ public void encodesReservedAndNonAsciiCharacters() assertEquals("?filter[name]=a%26b%3Dc%23d%2F%C3%A9", request.getQueryParameters()); } - /** - * The SDK sends an empty value as "key=". - */ @Test public void sendsEmptyValue() { @@ -92,9 +74,6 @@ public void keepsSafeValues() assertEquals("?filter[status]=active", request.getQueryParameters()); } - /** - * The SDK encodes each value, so a value that the caller encoded is encoded two times. Do not encode values. - */ @Test public void encodesAnEncodedValueAgain() { @@ -104,9 +83,6 @@ public void encodesAnEncodedValueAgain() assertEquals("?filter[name]=summer%2520sale", request.getQueryParameters()); } - /** - * A plus sign is sent as "%2B", so the API reads a literal plus sign, not a space. - */ @Test public void sendsPlusAsLiteralPlus() { diff --git a/src/test/java/com/mailerlite/sdk/tests/BrandStylesTest.java b/src/test/java/com/mailerlite/sdk/tests/BrandStylesTest.java index e1db429..8f8a276 100644 --- a/src/test/java/com/mailerlite/sdk/tests/BrandStylesTest.java +++ b/src/test/java/com/mailerlite/sdk/tests/BrandStylesTest.java @@ -21,20 +21,12 @@ import com.mailerlite.sdk.support.Fixtures; import com.mailerlite.sdk.support.RecordingHttpClient; -/** - * Tests the brand styles endpoint with the recording HTTP client. - */ public class BrandStylesTest { - /** The fake HTTP client. */ private RecordingHttpClient client; - /** The MailerLite object. */ private MailerLite ml; - /** - * Sets the fake HTTP client. - */ @BeforeEach public void setupEach() { @@ -44,20 +36,12 @@ public void setupEach() ml.setToken("test-token"); } - /** - * Removes the fake HTTP client. - */ @AfterEach public void afterEach() { RecordingHttpClient.uninstall(); } - /** - * get sends GET /brand-styles and decodes the brand, the default palette and the presets. - * - * @throws Exception if the request fails - */ @Test public void getDecodesThePalettes() throws Exception { @@ -89,11 +73,6 @@ public void getDecodesThePalettes() throws Exception assertEquals("Courier New, monospace", data.presets[1].fontFamily); } - /** - * brand is null when the account has no brand styles. - * - * @throws Exception if the request fails - */ @Test public void getWithoutBrand() throws Exception { diff --git a/src/test/java/com/mailerlite/sdk/tests/CampaignSimpleEditorTest.java b/src/test/java/com/mailerlite/sdk/tests/CampaignSimpleEditorTest.java index c8aff6b..42b2e3d 100644 --- a/src/test/java/com/mailerlite/sdk/tests/CampaignSimpleEditorTest.java +++ b/src/test/java/com/mailerlite/sdk/tests/CampaignSimpleEditorTest.java @@ -21,20 +21,12 @@ import com.mailerlite.sdk.support.Fixtures; import com.mailerlite.sdk.support.RecordingHttpClient; -/** - * Tests the campaign create with the simple editor, with the recording HTTP client. - */ public class CampaignSimpleEditorTest { - /** The fake HTTP client. */ private RecordingHttpClient client; - /** The MailerLite object. */ private MailerLite ml; - /** - * Sets the fake HTTP client. - */ @BeforeEach public void setupEach() { @@ -45,20 +37,12 @@ public void setupEach() ml.setToken("test-token"); } - /** - * Removes the fake HTTP client. - */ @AfterEach public void afterEach() { RecordingHttpClient.uninstall(); } - /** - * Makes an email with a sender and a subject. - * - * @return the email - */ private EmailBase email() { EmailBase email = new EmailBase(); @@ -69,11 +53,6 @@ private EmailBase email() return email; } - /** - * A create without the new fields sends the same body as before. - * - * @throws Exception if the request fails - */ @Test public void createWithoutEditorSendsTheSameBodyAsBefore() throws Exception { @@ -89,12 +68,6 @@ public void createWithoutEditorSendsTheSameBodyAsBefore() throws Exception + "\"groups\":[],\"segments\":[],\"ab_settings\":{},\"resend_settings\":{}}", client.lastRequest().bodyString()); } - /** - * A create with the simple editor sends editor, template_id and reply_to, and no content. - * The response has the email of the simple editor. - * - * @throws Exception if the request fails - */ @Test public void createWithSimpleEditor() throws Exception { diff --git a/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java b/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java index 1720513..6ec0ed3 100644 --- a/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java +++ b/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java @@ -64,23 +64,14 @@ import com.mailerlite.sdk.support.RecordingHttpClient.RecordedRequest; import com.mailerlite.sdk.support.UrlAssert; -/** - * Tests the email endpoints with the recording HTTP client. - */ public class EmailsTest { - /** The base URL of the API. */ static final String BASE = "https://connect.mailerlite.com/api"; - /** The fake HTTP client. */ private RecordingHttpClient client; - /** The email service. */ private Emails emails; - /** - * Sets the fake HTTP client. - */ @BeforeEach public void setupEach() { @@ -91,26 +82,12 @@ public void setupEach() emails = ml.emails(); } - /** - * Removes the fake HTTP client. - */ @AfterEach public void afterEach() { RecordingHttpClient.uninstall(); } - /** - * Asserts the one request that the client got: the method, the full URL, all the headers and the raw body. - * The sequence of the query parameters is ignored. - * - * @param client the fake HTTP client - * @param method the HTTP method - * @param path the path and the query string, after the base URL - * @param body the exact raw body, or null for a request without a body publisher - * @param idempotencyKey the expected Idempotency-Key header, or null for no header - * @return the request - */ static RecordedRequest assertRequest(RecordingHttpClient client, String method, String path, String body, String idempotencyKey) { @@ -145,12 +122,6 @@ static RecordedRequest assertRequest(RecordingHttpClient client, String method, return request; } - /** - * Asserts that each empty and each invalid ID throws the correct exception, and that no request is sent. - * - * @param client the fake HTTP client - * @param call makes the call for an ID - */ static void assertRejectsIds(RecordingHttpClient client, Function call) { for (String id : Arrays.asList("", null)) { @@ -164,24 +135,12 @@ static void assertRejectsIds(RecordingHttpClient client, Function map(Map map, String key) { return (Map) map.get(key); } - /** - * get sends GET /emails/{email} and decodes the email, the settings and the blocks. - * - * @throws Exception if the request fails - */ @Test public void getDecodesTheEmail() throws Exception { @@ -265,20 +224,12 @@ public void getDecodesTheEmail() throws Exception assertEquals("paragraph", list.children[0].type); } - /** - * get checks the email ID and sends no request for an invalid ID. - */ @Test public void getRejectsInvalidIds() { assertRejectsIds(client, id -> () -> emails.get(id)); } - /** - * getHtml sends GET /emails/{email}/html and decodes the HTML. - * - * @throws Exception if the request fails - */ @Test public void getHtmlDecodesTheHtml() throws Exception { @@ -291,9 +242,6 @@ public void getHtmlDecodesTheHtml() throws Exception assertEquals("42", response.etag); } - /** - * getHtml throws a MailerLiteException with the status for a temporary 503 with a body that is not JSON. - */ @Test public void getHtmlServiceUnavailable() { @@ -305,9 +253,6 @@ public void getHtmlServiceUnavailable() assertEquals("Service Unavailable", e.responseBody); } - /** - * getHtml checks the email ID and sends no request for an invalid ID. - */ @Test public void getHtmlRejectsInvalidIds() { @@ -315,11 +260,6 @@ public void getHtmlRejectsInvalidIds() } - /** - * getBlock sends GET /emails/{email}/blocks/{block} and decodes the block and its placement. - * - * @throws Exception if the request fails - */ @Test public void getBlockDecodesTheBlock() throws Exception { @@ -337,9 +277,6 @@ public void getBlockDecodesTheBlock() throws Exception assertNull(response.block.children); } - /** - * getBlock checks both IDs and sends no request for an invalid ID. - */ @Test public void getBlockRejectsInvalidIds() { @@ -348,13 +285,6 @@ public void getBlockRejectsInvalidIds() } - /** - * Makes the editor content of a paragraph: {"attrs":{"textAlign":align},"content":[{"type":"text","text":text}]}. - * - * @param align the text alignment - * @param text the text - * @return the editor content - */ static Map paragraph(String align, String text) { Map attrs = new LinkedHashMap(); @@ -371,12 +301,6 @@ static Map paragraph(String align, String text) return content; } - /** - * Asserts that each write rejects an empty or null etag, and sends no request. - * - * @param client the fake HTTP client - * @param call makes the call for an etag - */ static void assertRejectsEtags(RecordingHttpClient client, Function call) { assertThrows(EmptyEtagException.class, call.apply("")); @@ -384,19 +308,6 @@ static void assertRejectsEtags(RecordingHttpClient client, Function - * The SDK checks blockId only for "before" and "after". For "start" and "end", it does not check blockId and - * does not send it. For another position, it checks blockId if it is set, and sends it. - * The SDK does not change the object of the caller. - * - * @param client the fake HTTP client - * @param response the response body to queue for each request - * @param call makes the call for a placement - * @param expectedBody makes the expected raw body from the JSON of the placement - * @throws Throwable if a call fails - */ static void assertPlacementRules(RecordingHttpClient client, String response, Function call, Function expectedBody) throws Throwable { @@ -444,11 +355,6 @@ static void assertPlacementRules(RecordingHttpClient client, String response, Fu assertTrue(client.requests().isEmpty(), "the SDK must send no request"); } - /** - * createBlock sends POST /emails/{email}/blocks with the block, the placement and the etag. - * - * @throws Exception if the request fails - */ @Test public void createBlockSendsTheBlock() throws Exception { @@ -471,12 +377,6 @@ public void createBlockSendsTheBlock() throws Exception assertNull(response.block.placement.before); } - /** - * createBlock sends an empty editor content and no placement when the caller sets none. - * A null value inside the editor content is sent as null. - * - * @throws Exception if the request fails - */ @Test public void createBlockWithoutPlacement() throws Exception { @@ -497,11 +397,6 @@ public void createBlockWithoutPlacement() throws Exception client.lastRequest().bodyString()); } - /** - * createBlock checks the placement: blockId only for "before" and "after". - * - * @throws Throwable if a call fails - */ @Test public void createBlockPlacementRules() throws Throwable { @@ -512,9 +407,6 @@ public void createBlockPlacementRules() throws Throwable }, json -> "{\"type\":\"divider\",\"editor_content\":{},\"placement\":" + json + ",\"etag\":\"42\"}"); } - /** - * createBlock checks the IDs, the etag and the required values, and sends no request when a check fails. - */ @Test public void createBlockRejectsInvalidInput() { @@ -539,11 +431,6 @@ public void createBlockRejectsInvalidInput() } - /** - * updateBlock sends PATCH /emails/{email}/blocks/{block} with the editor content and the etag. - * - * @throws Exception if the request fails - */ @Test public void updateBlockSendsTheEditorContent() throws Exception { @@ -560,11 +447,6 @@ public void updateBlockSendsTheEditorContent() throws Exception assertEquals("58", response.block.placement.before); } - /** - * A block that the SDK read is sent back with the same numbers and the same null values. - * - * @throws Exception if the request fails - */ @Test public void updateBlockSendsBackTheEditorContentExactly() throws Exception { @@ -580,9 +462,6 @@ public void updateBlockSendsBackTheEditorContentExactly() throws Exception assertEquals("{\"editor_content\":" + editorContent + ",\"etag\":\"43\"}", client.lastRequest().bodyString()); } - /** - * updateBlock checks the IDs, the etag and the editor content, and sends no request when a check fails. - */ @Test public void updateBlockRejectsInvalidInput() { @@ -598,11 +477,6 @@ public void updateBlockRejectsInvalidInput() } - /** - * deleteBlock sends DELETE /emails/{email}/blocks/{block} with the etag in the body, and returns the new etag. - * - * @throws Exception if the request fails - */ @Test public void deleteBlockSendsTheEtagInTheBody() throws Exception { @@ -615,9 +489,6 @@ public void deleteBlockSendsTheEtagInTheBody() throws Exception assertEquals("45", response.etag); } - /** - * deleteBlock checks the IDs and the etag, and sends no request when a check fails. - */ @Test public void deleteBlockRejectsInvalidInput() { @@ -627,11 +498,6 @@ public void deleteBlockRejectsInvalidInput() } - /** - * moveBlock sends POST /emails/{email}/blocks/{block}/move with the placement and the etag. - * - * @throws Exception if the request fails - */ @Test public void moveBlockSendsThePlacement() throws Exception { @@ -647,11 +513,6 @@ public void moveBlockSendsThePlacement() throws Exception assertEquals("1", response.block.placement.before); } - /** - * moveBlock checks the placement: blockId only for "before" and "after". - * - * @throws Throwable if a call fails - */ @Test public void moveBlockPlacementRules() throws Throwable { @@ -660,9 +521,6 @@ public void moveBlockPlacementRules() throws Throwable json -> "{\"placement\":" + json + ",\"etag\":\"45\"}"); } - /** - * moveBlock checks the IDs, the etag and the placement, and sends no request when a check fails. - */ @Test public void moveBlockRejectsInvalidInput() { @@ -678,11 +536,6 @@ public void moveBlockRejectsInvalidInput() } - /** - * validateBlock sends POST /emails/blocks/validate with the block and no etag, and decodes a valid result. - * - * @throws Exception if the request fails - */ @Test public void validateBlockValid() throws Exception { @@ -703,11 +556,6 @@ public void validateBlockValid() throws Exception assertNull(response.rejected); } - /** - * validateBlock returns an invalid result as a success, with the errors for each field. - * - * @throws Exception if the request fails - */ @Test public void validateBlockInvalid() throws Exception { @@ -721,11 +569,6 @@ public void validateBlockInvalid() throws Exception assertArrayEquals(new String[] {"The selected type is invalid."}, response.errors.get("type")); } - /** - * validateBlock checks the placement: blockId only for "before" and "after". - * - * @throws Throwable if a call fails - */ @Test public void validateBlockPlacementRules() throws Throwable { @@ -736,9 +579,6 @@ public void validateBlockPlacementRules() throws Throwable }, json -> "{\"type\":\"divider\",\"editor_content\":{},\"placement\":" + json + "}"); } - /** - * validateBlock checks the required values, and sends no request when a check fails. - */ @Test public void validateBlockRejectsInvalidInput() { @@ -750,11 +590,6 @@ public void validateBlockRejectsInvalidInput() } - /** - * Makes settings that change the brand mode, the background color and the paragraph size. - * - * @return the settings - */ static EmailSettings brandAndParagraphSettings() { EmailSettings settings = new EmailSettings(); @@ -769,11 +604,6 @@ static EmailSettings brandAndParagraphSettings() return settings; } - /** - * updateSettings sends PATCH /emails/{email}/settings with only the set fields, and the etag at the top level. - * - * @throws Exception if the request fails - */ @Test public void updateSettingsSendsOnlyTheSetFields() throws Exception { @@ -793,12 +623,6 @@ public void updateSettingsSendsOnlyTheSetFields() throws Exception assertArrayEquals(new String[] {"footer.social_links[0].url"}, response.rejected.keys); } - /** - * An empty string unsets a value, an empty list removes all the social links, an empty editor content unsets a - * footer text, and a null inside an editor content is kept. - * - * @throws Exception if the request fails - */ @Test public void updateSettingsSendsEmptyValues() throws Exception { @@ -827,11 +651,6 @@ public void updateSettingsSendsEmptyValues() throws Exception client.lastRequest().bodyString()); } - /** - * A social link keeps its link ID as a whole number. - * - * @throws Exception if the request fails - */ @Test public void updateSettingsKeepsTheLinkId() throws Exception { @@ -849,9 +668,6 @@ public void updateSettingsKeepsTheLinkId() throws Exception + "\"label\":\"Website\",\"link_id\":7}]},\"etag\":\"42\"}", client.lastRequest().bodyString()); } - /** - * updateSettings checks the ID, the etag and the settings, and sends no request when a check fails. - */ @Test public void updateSettingsRejectsInvalidInput() { @@ -866,11 +682,6 @@ public void updateSettingsRejectsInvalidInput() } - /** - * validateSettings sends POST /emails/{email}/settings/validate with the settings and no etag. - * - * @throws Exception if the request fails - */ @Test public void validateSettingsSendsTheSettingsWithoutEtag() throws Exception { @@ -888,9 +699,6 @@ public void validateSettingsSendsTheSettingsWithoutEtag() throws Exception assertArrayEquals(new String[] {"footer.social_links[0].url"}, response.rejected.keys); } - /** - * validateSettings checks the ID and the settings, and sends no request when a check fails. - */ @Test public void validateSettingsRejectsInvalidInput() { @@ -901,11 +709,6 @@ public void validateSettingsRejectsInvalidInput() } - /** - * createTemplate sends POST /emails/{email}/templates with the name, and decodes the template. - * - * @throws Exception if the request fails - */ @Test public void createTemplateSendsTheName() throws Exception { @@ -921,9 +724,6 @@ public void createTemplateSendsTheName() throws Exception assertEquals("2026-10-28 12:00:00", response.template.createdAt); } - /** - * createTemplate checks the ID and the name, and sends no request when a check fails. - */ @Test public void createTemplateRejectsInvalidInput() { @@ -935,11 +735,6 @@ public void createTemplateRejectsInvalidInput() } - /** - * versions().get() sends GET /emails/{email}/versions without a query string, and decodes the list. - * - * @throws Exception if the request fails - */ @Test public void versionsWithoutOptions() throws Exception { @@ -961,11 +756,6 @@ public void versionsWithoutOptions() throws Exception assertEquals(2, response.meta.total); } - /** - * versions() sends the page and the limit. - * - * @throws Exception if the request fails - */ @Test public void versionsWithPageAndLimit() throws Exception { @@ -976,11 +766,6 @@ public void versionsWithPageAndLimit() throws Exception assertRequest(client, "GET", "/emails/123/versions?page=2&limit=10", null, null); } - /** - * An empty page has null for meta.from and meta.to. The SDK reads them as 0. - * - * @throws Exception if the request fails - */ @Test public void versionsEmptyPageReadsNullFromAndToAsZero() throws Exception { @@ -995,9 +780,6 @@ public void versionsEmptyPageReadsNullFromAndToAsZero() throws Exception assertEquals(0, response.meta.total); } - /** - * versions() checks the ID, and sends no request for an invalid ID. - */ @Test public void versionsRejectsInvalidIds() { @@ -1005,12 +787,6 @@ public void versionsRejectsInvalidIds() } - /** - * createVersion sends POST /emails/{email}/versions with an empty body, not the JSON null. - * Status 201 means a new version. - * - * @throws Exception if the request fails - */ @Test public void createVersionNew() throws Exception { @@ -1029,11 +805,6 @@ public void createVersionNew() throws Exception assertTrue(response.version.current); } - /** - * Status 200 means that nothing changed, and the response has the last version. - * - * @throws Exception if the request fails - */ @Test public void createVersionUnchanged() throws Exception { @@ -1046,9 +817,6 @@ public void createVersionUnchanged() throws Exception assertEquals("903", response.version.id); } - /** - * createVersion checks the ID, and sends no request for an invalid ID. - */ @Test public void createVersionRejectsInvalidIds() { @@ -1056,11 +824,6 @@ public void createVersionRejectsInvalidIds() } - /** - * restoreVersion sends POST /emails/{email}/versions/{version}/restore with the etag, and decodes the email. - * - * @throws Exception if the request fails - */ @Test public void restoreVersionSendsTheEtag() throws Exception { @@ -1074,9 +837,6 @@ public void restoreVersionSendsTheEtag() throws Exception assertEquals(2, response.email.blocks.length); } - /** - * restoreVersion checks the IDs and the etag, and sends no request when a check fails. - */ @Test public void restoreVersionRejectsInvalidInput() { diff --git a/src/test/java/com/mailerlite/sdk/tests/IdempotencyTest.java b/src/test/java/com/mailerlite/sdk/tests/IdempotencyTest.java index e0dda4b..aa332af 100644 --- a/src/test/java/com/mailerlite/sdk/tests/IdempotencyTest.java +++ b/src/test/java/com/mailerlite/sdk/tests/IdempotencyTest.java @@ -38,20 +38,12 @@ import com.mailerlite.sdk.support.Fixtures; import com.mailerlite.sdk.support.RecordingHttpClient; -/** - * Tests the idempotency key option and the replay flag, with the recording HTTP client. - */ public class IdempotencyTest { - /** The fake HTTP client. */ private RecordingHttpClient client; - /** The email service. */ private Emails emails; - /** - * Sets the fake HTTP client. - */ @BeforeEach public void setupEach() { @@ -62,20 +54,12 @@ public void setupEach() emails = ml.emails(); } - /** - * Removes the fake HTTP client. - */ @AfterEach public void afterEach() { RecordingHttpClient.uninstall(); } - /** - * The 8 methods that accept an idempotency key, each with a name and a response. - * - * @return the name, the response fixture and the call for the options - */ private Map writeMethods() { EmailBlockInput divider = new EmailBlockInput(EmailBlockTypes.DIVIDER, new LinkedHashMap()); @@ -103,12 +87,6 @@ private Map writeMethods() return methods; } - /** - * Each of the 8 methods sends the Idempotency-Key header when the options have a key, and no header when the - * options are null or have a null key. - * - * @throws Throwable if a call fails - */ @Test @SuppressWarnings("unchecked") public void writeMethodsSendTheKeyOnlyWhenSet() throws Throwable @@ -134,11 +112,6 @@ public void writeMethodsSendTheKeyOnlyWhenSet() throws Throwable } } - /** - * The validate methods have no options and never send the header. - * - * @throws Exception if a call fails - */ @Test public void validateMethodsSendNoKey() throws Exception { @@ -152,9 +125,6 @@ public void validateMethodsSendNoKey() throws Exception assertNull(client.requests().get(1).header("Idempotency-Key")); } - /** - * RequestOptions.idempotencyKey checks the key at once. - */ @Test public void invalidKeysThrowAtOnce() { @@ -169,11 +139,6 @@ public void invalidKeysThrowAtOnce() assertTrue(client.requests().isEmpty()); } - /** - * A response with the header Idempotent-Replayed: true is a replay. The SDK ignores case and spaces. - * - * @throws Exception if a call fails - */ @Test public void replayFlag() throws Exception { @@ -199,9 +164,6 @@ public void replayFlag() throws Exception new EmailBlockInput(EmailBlockTypes.DIVIDER, new LinkedHashMap())).isIdempotentReplay()); } - /** - * The replay flag also works with a header map that is case-sensitive, and with no header map. - */ @Test public void replayFlagWithPlainHeaderMap() { diff --git a/src/test/java/com/mailerlite/sdk/tests/MailerLiteApiTest.java b/src/test/java/com/mailerlite/sdk/tests/MailerLiteApiTest.java index be83008..2de78ee 100644 --- a/src/test/java/com/mailerlite/sdk/tests/MailerLiteApiTest.java +++ b/src/test/java/com/mailerlite/sdk/tests/MailerLiteApiTest.java @@ -32,26 +32,16 @@ import com.mailerlite.sdk.support.RecordingHttpClient; import com.mailerlite.sdk.support.RecordingHttpClient.RecordedRequest; -/** - * Tests the HTTP layer of the SDK with the recording HTTP client. - */ public class MailerLiteApiTest { - /** The fake HTTP client. */ private RecordingHttpClient client; - /** - * Sets the fake HTTP client. - */ @BeforeEach public void setupEach() { client = RecordingHttpClient.install(); } - /** - * Removes the fake HTTP client. - */ @AfterEach public void afterEach() { @@ -59,11 +49,6 @@ public void afterEach() Thread.interrupted(); } - /** - * Makes an API object with a test token. - * - * @return the API object - */ private MailerLiteApi api() { MailerLiteApi api = new MailerLiteApi(); @@ -72,11 +57,6 @@ private MailerLiteApi api() return api; } - /** - * The old request methods, each with a name. - * - * @return the request methods - */ private Map oldRequestMethods() { Map methods = new LinkedHashMap(); @@ -88,9 +68,6 @@ private Map oldRequestMethods() return methods; } - /** - * A network error becomes a MailerLiteException with the error as the cause. - */ @Test public void networkErrorBecomesMailerLiteException() { @@ -106,9 +83,6 @@ public void networkErrorBecomesMailerLiteException() } } - /** - * An interrupt becomes a MailerLiteException, and the thread keeps its interrupt flag. - */ @Test public void interruptBecomesMailerLiteExceptionAndKeepsTheFlag() { @@ -124,9 +98,6 @@ public void interruptBecomesMailerLiteExceptionAndKeepsTheFlag() } } - /** - * An empty success body for a response class without a no-argument constructor gives a MailerLiteException. - */ @Test public void emptyBodyForResponseWithoutDefaultConstructorBecomesMailerLiteException() { @@ -138,11 +109,6 @@ public void emptyBodyForResponseWithoutDefaultConstructorBecomesMailerLiteExcept assertTrue(e.getCause() instanceof ReflectiveOperationException); } - /** - * Asserts the headers that sendRequest sends on every request. - * - * @param request the request - */ private void assertDefaultHeaders(RecordedRequest request) { assertEquals("application/json", request.header("Content-Type")); @@ -150,11 +116,6 @@ private void assertDefaultHeaders(RecordedRequest request) assertEquals("Bearer test-token", request.header("Authorization")); } - /** - * sendRequest sends a PATCH request with the body and the extra headers. - * - * @throws MailerLiteException if the request fails - */ @Test public void sendRequestSendsPatchWithBody() throws MailerLiteException { @@ -172,11 +133,6 @@ public void sendRequestSendsPatchWithBody() throws MailerLiteException assertEquals(200, response.responseStatusCode); } - /** - * sendRequest sends a DELETE request with a body when the caller gives one. - * - * @throws MailerLiteException if the request fails - */ @Test public void sendRequestSendsDeleteWithBody() throws MailerLiteException { @@ -191,11 +147,6 @@ public void sendRequestSendsDeleteWithBody() throws MailerLiteException assertNull(request.header("Idempotency-Key")); } - /** - * sendRequest sends GET and DELETE without a body publisher when the body is null. - * - * @throws MailerLiteException if the request fails - */ @Test public void sendRequestSendsGetAndDeleteWithoutBody() throws MailerLiteException { @@ -214,11 +165,6 @@ public void sendRequestSendsGetAndDeleteWithoutBody() throws MailerLiteException assertEquals(204, deleted.responseStatusCode); } - /** - * sendRequest sends a POST with an empty body, not the JSON null, when the body is null. - * - * @throws MailerLiteException if the request fails - */ @Test public void sendRequestSendsPostWithEmptyBody() throws MailerLiteException { @@ -235,9 +181,6 @@ public void sendRequestSendsPostWithEmptyBody() throws MailerLiteException assertEquals(201, response.responseStatusCode); } - /** - * sendRequest converts a network error to a MailerLiteException. - */ @Test public void sendRequestNetworkErrorBecomesMailerLiteException() { @@ -250,9 +193,6 @@ public void sendRequestNetworkErrorBecomesMailerLiteException() assertSame(error, e.getCause()); } - /** - * A 409 version conflict exposes the API code and the current etag. - */ @Test public void versionConflictExposesCodeAndEtag() { @@ -273,9 +213,6 @@ public void versionConflictExposesCodeAndEtag() assertTrue(e.errors.isEmpty()); } - /** - * A 409 without a code is not a version conflict, and the message has no prefix. - */ @Test public void conflictWithoutCodeIsNotVersionConflict() { @@ -291,9 +228,6 @@ public void conflictWithoutCodeIsNotVersionConflict() assertEquals("The campaign is not a draft.", e.getMessage()); } - /** - * A 422 with errors exposes the errors for each field. - */ @Test public void validationErrorExposesErrors() { @@ -310,9 +244,6 @@ public void validationErrorExposesErrors() assertArrayEquals(new String[] {"The editor content field must be present."}, e.errors.get("editor_content")); } - /** - * A 422 with only a message has no errors. - */ @Test public void validationErrorWithOnlyMessage() { @@ -327,9 +258,6 @@ public void validationErrorWithOnlyMessage() assertNull(e.errorCode); } - /** - * A 503 with an HTML body keeps the status and the raw body. - */ @Test public void serviceUnavailableWithHtmlBody() { @@ -347,24 +275,13 @@ public void serviceUnavailableWithHtmlBody() assertFalse(e.isVersionConflict()); } - /** - * A response class without a no-argument constructor. - */ public static class NoDefaultConstructorResponse extends MailerLiteResponse { - /** - * Instantiates a new response. - * - * @param value any value - */ public NoDefaultConstructorResponse(int value) { } } - /** - * A network error without a message gives a MailerLiteException with a message that names the error. - */ @Test public void networkErrorWithoutMessageKeepsTheErrorName() { @@ -389,9 +306,6 @@ public void networkErrorWithoutMessageKeepsTheErrorName() assertTrue(e.getMessage() != null && e.getMessage().contains("ConnectException"), e.getMessage()); } - /** - * An interrupt without a message gives a message that names the error, and the thread keeps its interrupt flag. - */ @Test public void interruptWithoutMessageKeepsTheErrorNameAndTheFlag() { diff --git a/src/test/java/com/mailerlite/sdk/tests/MediaFilesTest.java b/src/test/java/com/mailerlite/sdk/tests/MediaFilesTest.java index 1b89740..0c5ab00 100644 --- a/src/test/java/com/mailerlite/sdk/tests/MediaFilesTest.java +++ b/src/test/java/com/mailerlite/sdk/tests/MediaFilesTest.java @@ -22,20 +22,12 @@ import com.mailerlite.sdk.support.Fixtures; import com.mailerlite.sdk.support.RecordingHttpClient; -/** - * Tests the files endpoint with the recording HTTP client. - */ public class MediaFilesTest { - /** The fake HTTP client. */ private RecordingHttpClient client; - /** The MailerLite object. */ private MailerLite ml; - /** - * Sets the fake HTTP client. - */ @BeforeEach public void setupEach() { @@ -46,20 +38,12 @@ public void setupEach() ml.setToken("test-token"); } - /** - * Removes the fake HTTP client. - */ @AfterEach public void afterEach() { RecordingHttpClient.uninstall(); } - /** - * get() without options sends GET /files without a query string, and decodes the files. - * - * @throws Exception if the request fails - */ @Test public void listWithoutOptions() throws Exception { @@ -87,11 +71,6 @@ public void listWithoutOptions() throws Exception assertEquals(2, response.meta.total); } - /** - * A name filter with spaces keeps the key filter[name] and sends the spaces as "+". - * - * @throws Exception if the request fails - */ @Test public void nameFilterWithSpaces() throws Exception { @@ -100,11 +79,6 @@ public void nameFilterWithSpaces() throws Exception EmailsTest.assertRequest(client, "GET", "/files?filter[name]=summer+sale+banner", null, null); } - /** - * An empty filter value is sent as "key=", and a null value is not sent. - * - * @throws Exception if the request fails - */ @Test public void emptyAndNullFilterValues() throws Exception { @@ -113,11 +87,6 @@ public void emptyAndNullFilterValues() throws Exception EmailsTest.assertRequest(client, "GET", "/files?filter[name]=", null, null); } - /** - * The type filter, the sort, the page and the limit are sent. The filter accepts types that the SDK does not know. - * - * @throws Exception if the request fails - */ @Test public void allOptions() throws Exception { diff --git a/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java b/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java index 0ee5e18..af7bc44 100644 --- a/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java +++ b/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java @@ -43,18 +43,11 @@ import com.mailerlite.sdk.support.RecordingHttpClient.RecordedRequest; import com.mailerlite.sdk.support.UrlAssert; -/** - * Compiles the examples of the new README sections and runs each one against the recording HTTP client. - *

- * Each example must send the expected requests and use all the queued responses. A MailerLiteException - * in an example fails the test, because the test changes "e.printStackTrace();" to a throw. - */ public class ReadmeExamplesTest { - /** The base URL of the API. */ private static final String BASE = "https://connect.mailerlite.com/api"; - /** The imports that the README examples get. The README shows them as "// import ....". */ + /** The README shows these imports only as "// import ....", so the test adds them. */ private static final List IMPORTS = Arrays.asList( "com.mailerlite.sdk.*", "com.mailerlite.sdk.brandstyles.*", @@ -65,74 +58,36 @@ public class ReadmeExamplesTest { "java.io.*", "java.util.*"); - /** - * A README example: the heading of its section, the responses to queue and the expected requests. - */ private static class Example { - /** The heading of the README section, without "### ". */ final String heading; - /** The responses to queue. */ final List responses = new ArrayList(); - /** The expected requests, as "METHOD /path?query". */ final List requests = new ArrayList(); - /** - * Instantiates a new example. - * - * @param heading the heading - */ Example(String heading) { this.heading = heading; } - /** - * Queues a response. - * - * @param status the status - * @param body the body - * @return this example - */ Example respond(int status, String body) { return respond(status, body, Collections.emptyMap()); } - /** - * Queues a response with headers. - * - * @param status the status - * @param body the body - * @param headers the headers - * @return this example - */ Example respond(int status, String body, Map headers) { responses.add(new Object[] {status, body, headers}); return this; } - /** - * Queues a network error. - * - * @param error the error - * @return this example - */ Example fail(Exception error) { responses.add(new Object[] {error}); return this; } - /** - * Adds an expected request. - * - * @param request the request, as "METHOD /path?query" - * @return this example - */ Example expect(String request) { requests.add(request); @@ -140,11 +95,6 @@ Example expect(String request) } } - /** - * The examples to run, one for each new README section. - * - * @return the examples - */ private static List examples() { List list = new ArrayList(); @@ -230,12 +180,6 @@ private static List examples() return list; } - /** - * Compiles and runs the README examples. - * - * @return a test for each example - * @throws Exception if the README cannot be read or the examples do not compile - */ @TestFactory public List readmeExamplesRun() throws Exception { @@ -270,14 +214,6 @@ public List readmeExamplesRun() throws Exception return tests; } - /** - * Runs one compiled example and checks its requests. - * - * @param loader the class loader of the compiled examples - * @param className the class name - * @param example the example - * @throws Throwable if the example fails - */ private void run(ClassLoader loader, String className, Example example) throws Throwable { RecordingHttpClient client = RecordingHttpClient.install(); @@ -322,13 +258,6 @@ private void run(ClassLoader loader, String className, Example example) throws T } } - /** - * Finds the first Java code block of a README section. - * - * @param readme the README - * @param heading the heading of the section, without "### " - * @return the code - */ private static String extractCode(String readme, String heading) { List lines = Arrays.asList(readme.split("\n", -1)); @@ -361,13 +290,6 @@ private static String extractCode(String readme, String heading) throw new AssertionError("The README section \"### " + heading + "\" has no Java code block"); } - /** - * Makes a compilable class from a README example. - * - * @param code the example code - * @param className the class name - * @return the source - */ private static String toSource(String code, String className) { if (!code.contains("class Example {") || !code.contains("public void example()")) { @@ -388,13 +310,6 @@ private static String toSource(String code, String className) return source.toString(); } - /** - * Compiles the examples for Java 11. - * - * @param dir the output folder - * @param sources the source files - * @throws Exception if the compiler fails - */ private static void compile(Path dir, List sources) throws Exception { JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); @@ -420,13 +335,6 @@ private static void compile(Path dir, List sources) throws Exception } } - /** - * Gets the class path entry of a class. - * - * @param type the class - * @return the path of its folder or JAR file - * @throws Exception if the location is not a file - */ private static String location(Class type) throws Exception { return Paths.get(type.getProtectionDomain().getCodeSource().getLocation().toURI()).toString(); diff --git a/src/test/java/com/mailerlite/sdk/tests/RecordingHttpClientTest.java b/src/test/java/com/mailerlite/sdk/tests/RecordingHttpClientTest.java index fc823df..dd4ddf3 100644 --- a/src/test/java/com/mailerlite/sdk/tests/RecordingHttpClientTest.java +++ b/src/test/java/com/mailerlite/sdk/tests/RecordingHttpClientTest.java @@ -27,28 +27,16 @@ import com.mailerlite.sdk.support.RecordingHttpClient; import com.mailerlite.sdk.support.RecordingHttpClient.RecordedRequest; -/** - * Tests the fake HttpClient that the other tests use. - */ public class RecordingHttpClientTest { - /** The test URI. */ private static final URI TEST_URI = URI.create("https://connect.mailerlite.com/api/test"); - /** - * Removes the client from the factory. - */ @AfterEach public void afterEach() { RecordingHttpClient.uninstall(); } - /** - * The client keeps the raw body and returns the queued response. - * - * @throws Exception if the request fails - */ @Test public void keepsTheRawBodyAndReturnsTheQueuedResponse() throws Exception { @@ -72,11 +60,6 @@ public void keepsTheRawBodyAndReturnsTheQueuedResponse() throws Exception assertEquals(0, client.pendingResponses()); } - /** - * A request without a body publisher and a request with noBody() are different. - * - * @throws Exception if the request fails - */ @Test public void tellsNoPublisherFromAnEmptyBody() throws Exception { @@ -95,9 +78,6 @@ public void tellsNoPublisherFromAnEmptyBody() throws Exception assertArrayEquals(new byte[0], post.body); } - /** - * The client throws a queued error, and an IOException when the queue is empty. - */ @Test public void throwsQueuedErrorsAndFailsWhenTheQueueIsEmpty() { diff --git a/src/test/java/com/mailerlite/sdk/tests/RequestChecksTest.java b/src/test/java/com/mailerlite/sdk/tests/RequestChecksTest.java index 476fe3c..77f9613 100644 --- a/src/test/java/com/mailerlite/sdk/tests/RequestChecksTest.java +++ b/src/test/java/com/mailerlite/sdk/tests/RequestChecksTest.java @@ -34,26 +34,14 @@ import com.mailerlite.sdk.exceptions.InvalidIdempotencyKeyException; import com.mailerlite.sdk.util.RequestChecks; -/** - * Tests the shared input checks and the editor content adapter. - */ public class RequestChecksTest { - /** IDs that are not only ASCII digits. */ public static final List INVALID_IDS = Collections.unmodifiableList(Arrays.asList( "123/html", "123%2Fhtml", "123\n", "\u00b2", "\u0661\u0662\u0663", "12a", " 123", "-1", "1.5")); - /** Idempotency keys that the API rejects, or that HTTP changes. */ public static final List INVALID_KEYS = Collections.unmodifiableList(Arrays.asList( "", repeat('k', 256), "key\n", "key\t", "cl\u00e9", "key\u007f", " key", "key ")); - /** - * Makes a string of one character. - * - * @param c the character - * @param count the length - * @return the string - */ public static String repeat(char c, int count) { char[] chars = new char[count]; @@ -62,9 +50,6 @@ public static String repeat(char c, int count) return new String(chars); } - /** - * A valid ID is returned as it is. - */ @Test public void pathIdAcceptsAsciiDigits() { @@ -72,9 +57,6 @@ public void pathIdAcceptsAsciiDigits() assertEquals("0", RequestChecks.pathId("0", "emailId")); } - /** - * An empty or null ID throws EmptyIdException with the parameter name. - */ @Test public void pathIdRejectsEmptyIds() { @@ -85,11 +67,6 @@ public void pathIdRejectsEmptyIds() assertTrue(empty instanceof IllegalArgumentException); } - /** - * An ID that is not only ASCII digits throws InvalidIdException. - * - * @return a test for each ID - */ @TestFactory public List pathIdRejectsInvalidIds() { @@ -106,9 +83,6 @@ public List pathIdRejectsInvalidIds() return tests; } - /** - * An empty or null etag throws EmptyEtagException. - */ @Test public void etagRejectsEmptyValues() { @@ -118,9 +92,6 @@ public void etagRejectsEmptyValues() assertTrue(e instanceof IllegalArgumentException); } - /** - * A key of 1 to 255 printable ASCII characters is accepted. - */ @Test public void idempotencyKeyAcceptsPrintableAscii() { @@ -131,11 +102,6 @@ public void idempotencyKeyAcceptsPrintableAscii() assertEquals("retry 1 ~!@#$%^&*()", RequestChecks.idempotencyKey("retry 1 ~!@#$%^&*()")); } - /** - * An invalid key throws InvalidIdempotencyKeyException. - * - * @return a test for each key - */ @TestFactory public List idempotencyKeyRejectsInvalidKeys() { @@ -156,24 +122,16 @@ public List idempotencyKeyRejectsInvalidKeys() return tests; } - /** - * A class with an editor content field, like the SDK models. - */ public static class Holder { - /** The editor content. */ @SerializedName("editor_content") @JsonAdapter(EditorContentAdapter.class) public Map editorContent; - /** A field that stays absent when it is null. */ @SerializedName("other") public String other; } - /** - * The adapter reads whole numbers as Long, so the SDK sends them back without ".0". - */ @Test public void editorContentKeepsWholeNumbers() { @@ -190,9 +148,6 @@ public void editorContentKeepsWholeNumbers() assertEquals(json, new Gson().toJson(holder)); } - /** - * The adapter keeps null values in the map, and the other null fields stay absent. - */ @Test public void editorContentKeepsNullValues() { diff --git a/src/test/java/com/mailerlite/sdk/tests/UrlAssertTest.java b/src/test/java/com/mailerlite/sdk/tests/UrlAssertTest.java index ffb870f..40fd7f0 100644 --- a/src/test/java/com/mailerlite/sdk/tests/UrlAssertTest.java +++ b/src/test/java/com/mailerlite/sdk/tests/UrlAssertTest.java @@ -15,14 +15,8 @@ import com.mailerlite.sdk.support.UrlAssert; -/** - * Tests the URL comparison that ignores the sequence of the query parameters. - */ public class UrlAssertTest { - /** - * The sequence of the query parameters does not matter. - */ @Test public void ignoresTheSequenceOfTheParameters() { @@ -31,18 +25,12 @@ public void ignoresTheSequenceOfTheParameters() UrlAssert.assertUrlEquals("/files?filter[name]=a+b&page=2", "/files?page=2&filter[name]=a+b"); } - /** - * A parameter that is sent two times is not the same as a parameter that is sent one time. - */ @Test public void keepsDuplicateParameters() { assertFalse(UrlAssert.sameUrl("/files?a=1", "/files?a=1&a=1")); } - /** - * The values and the encoding must be the same. - */ @Test public void comparesTheRawValues() { @@ -50,9 +38,6 @@ public void comparesTheRawValues() assertFalse(UrlAssert.sameUrl("/files?a=a+b", "/files?a=a%20b")); } - /** - * The paths must be the same, and a URL without a query is not the same as a URL with a query. - */ @Test public void comparesThePathExactly() { From 42b00b2abfbaa1ce3886be999d824b4b26398e06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Fri, 30 Oct 2026 11:55:45 +0100 Subject: [PATCH 32/35] refactor: leave value checks to the API The SDK now checks only the IDs in the URL path before a request. It sends the etag, the placement, the idempotency key and the block and template values as the caller gives them, and the API returns the errors. Co-Authored-By: Claude Opus 5.5 --- README.md | 14 +- .../com/mailerlite/sdk/RequestOptions.java | 11 +- .../sdk/emails/EmailBlockInput.java | 4 +- .../sdk/emails/EmailBlockPosition.java | 7 +- .../com/mailerlite/sdk/emails/Emails.java | 116 +++------ .../sdk/exceptions/EmptyEtagException.java | 27 -- .../InvalidIdempotencyKeyException.java | 27 -- .../mailerlite/sdk/util/RequestChecks.java | 60 +---- .../com/mailerlite/sdk/tests/EmailsTest.java | 237 +++++++++++++----- .../mailerlite/sdk/tests/IdempotencyTest.java | 52 +++- .../sdk/tests/RequestChecksTest.java | 44 ---- 11 files changed, 262 insertions(+), 337 deletions(-) delete mode 100644 src/main/java/com/mailerlite/sdk/exceptions/EmptyEtagException.java delete mode 100644 src/main/java/com/mailerlite/sdk/exceptions/InvalidIdempotencyKeyException.java diff --git a/README.md b/README.md index cbe25f8..d6eb8bc 100644 --- a/README.md +++ b/README.md @@ -1038,7 +1038,8 @@ class Example { These methods read and change emails built with the simple editor. To make such an email, [create a campaign with the simple editor](#create-a-campaign-with-the-simple-editor). The ID of the email is in `campaign.emails[0].id`. - IDs are strings of the digits 0 to 9. An empty or `null` ID throws `EmptyIdException`. An ID with other characters throws `InvalidIdException`. Both extend `IllegalArgumentException`, and the SDK sends no request. -- Each write needs the etag from the last response for the email, and each write returns a new etag. The etag covers the whole email, so send the writes to one email one after another. An empty or `null` etag throws `EmptyEtagException`, and the SDK sends no request. +- The SDK sends all other values as you give them, and the API checks them. If a value is not valid, the write throws a `MailerLiteException`, for example with `code` `422` and the `errors` for each field. +- Each write needs the etag from the last response for the email, and each write returns a new etag. The etag covers the whole email, so send the writes to one email one after another. An empty etag gets a `422` error. - If the email changed after you read it, the write throws a `MailerLiteException` with `code` `409`, and `e.isVersionConflict()` is `true`. Then `e.etag` is the current etag. Get the email again, and then send the write again with the current etag. - The API error code, for example `version_conflict`, is in `e.errorCode`. The field `e.code` is the HTTP status. - The keys of `editorContent` are camelCase, for example `textAlign`. The SDK sends them as you give them. In a response, whole numbers are `Long` values. @@ -1140,9 +1141,9 @@ class Example { Use the block types in `EmailBlockTypes`. You cannot insert the `TITLE` type. `editorContent` is required, but it can be an empty map. The SDK sends the map as it is, also its `null` values. -Without `placement`, the API puts the block at the end of the email. The positions are in `EmailBlockPositions`. The `BEFORE` and `AFTER` positions also need a `blockId`. For `START` and `END`, the API does not use a `blockId`, so the SDK does not send it. +Without `placement`, the API puts the block at the end of the email. The positions are in `EmailBlockPositions`. The `BEFORE` and `AFTER` positions also need a `blockId`. The SDK sends the placement as you give it. -A `null` block or `editorContent` throws `NullPointerException`. An empty block type throws `IllegalArgumentException`. +A `null` block throws `NullPointerException`, and the SDK sends no request. ```java @@ -1266,7 +1267,7 @@ class Example { ### Validate a block -Checks a block before you insert it, and saves nothing. It needs no email and no etag. An invalid block is not an error: the response has `valid` set to `false` and the `errors` for each field. +Checks a block before you insert it, and saves nothing. It needs no email and no etag. An invalid block is not an error: the response has `valid` set to `false` and the `errors` for each field. A `null` block sends an empty body. ```java @@ -1348,7 +1349,7 @@ class Example { ### Validate the design settings -Checks a change with the rules of the update, and saves nothing. It needs no etag. Invalid settings are not an error: the response has `valid` set to `false` and the `errors` for each field. If the update would remove unsafe values, `rejected` has their paths. +Checks a change with the rules of the update, and saves nothing. It needs no etag. Invalid settings are not an error: the response has `valid` set to `false` and the `errors` for each field. If the update would remove unsafe values, `rejected` has their paths. `null` settings send an empty body. ```java @@ -1487,7 +1488,8 @@ If a write fails with a network error, you do not know if the API did the write. - A network error is a `MailerLiteException` with the network error as the cause. - Use a new key for each write. The API keeps a key for 24 hours. -- The key must be 1 to 255 printable ASCII characters, with no space at the start or the end. Otherwise `RequestOptions.idempotencyKey` throws `InvalidIdempotencyKeyException`, and the SDK sends no request. +- The SDK sends the key as you give it, and the API checks it. An invalid key gets a `400` error. A `null` or empty key sends no header. +- The Java HTTP client removes the spaces at the start and the end of the key. For a control character, for example a new line, it throws `IllegalArgumentException`. - The same key with different arguments gets a `422` error. If the first request is still running, the API returns `409` without an error code. - Without a key, a retry of a write that was a success gets `409` with `version_conflict`, because the etag is old. - These methods accept `RequestOptions` as the last argument: `createBlock`, `updateBlock`, `deleteBlock`, `moveBlock`, `updateSettings`, `createTemplate`, `createVersion` and `restoreVersion`. diff --git a/src/main/java/com/mailerlite/sdk/RequestOptions.java b/src/main/java/com/mailerlite/sdk/RequestOptions.java index af25616..03333d9 100644 --- a/src/main/java/com/mailerlite/sdk/RequestOptions.java +++ b/src/main/java/com/mailerlite/sdk/RequestOptions.java @@ -7,9 +7,6 @@ **************************************************/ package com.mailerlite.sdk; -import com.mailerlite.sdk.exceptions.InvalidIdempotencyKeyException; -import com.mailerlite.sdk.util.RequestChecks; - /** * Options for one request, for example an idempotency key. *

@@ -32,19 +29,13 @@ private RequestOptions(String idempotencyKey) * success, the API returns the stored response and does not do the write two times. Then * {@link MailerLiteResponse#isIdempotentReplay()} is true. The API keeps a key for 24 hours. *

- * The key must have 1 to 255 printable ASCII characters (0x20 to 0x7E), with no space at the start or the end. - * A null key sends no header. + * The SDK sends the key as you give it, and the API checks it. A null or empty key sends no header. * * @param key the idempotency key, or null for no key * @return the options - * @throws InvalidIdempotencyKeyException if the key does not obey the rules */ public static RequestOptions idempotencyKey(String key) { - if (key != null) { - RequestChecks.idempotencyKey(key); - } - return new RequestOptions(key); } diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailBlockInput.java b/src/main/java/com/mailerlite/sdk/emails/EmailBlockInput.java index b003610..9d0e159 100644 --- a/src/main/java/com/mailerlite/sdk/emails/EmailBlockInput.java +++ b/src/main/java/com/mailerlite/sdk/emails/EmailBlockInput.java @@ -17,12 +17,12 @@ */ public class EmailBlockInput { - /** The block type: one of the values in {@link EmailBlockTypes}, except TITLE. Required. */ + /** The block type: one of the values in {@link EmailBlockTypes}, except TITLE. The API needs it. */ @SerializedName("type") public String type; /** - * The editor document of the block. Required, but it can be an empty map, for example for a divider. + * The editor document of the block. The API needs it, but it can be an empty map, for example for a divider. * The keys are camelCase, for example "textAlign". The SDK sends the map as it is, also its null values. */ @SerializedName("editor_content") diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailBlockPosition.java b/src/main/java/com/mailerlite/sdk/emails/EmailBlockPosition.java index 2e6f60d..13aa8c7 100644 --- a/src/main/java/com/mailerlite/sdk/emails/EmailBlockPosition.java +++ b/src/main/java/com/mailerlite/sdk/emails/EmailBlockPosition.java @@ -14,16 +14,15 @@ */ public class EmailBlockPosition { - /** The position: one of the values in {@link EmailBlockPositions}. Required. */ + /** The position: one of the values in {@link EmailBlockPositions}. The API needs it. */ @SerializedName("position") public String position; /** * The ID of the block at the same level to put the block before or after. *

- * It is required for {@link EmailBlockPositions#BEFORE} and {@link EmailBlockPositions#AFTER}, and it must - * contain only the digits 0 to 9. For {@link EmailBlockPositions#START} and {@link EmailBlockPositions#END}, - * the API does not use it, so the SDK does not check it and does not send it. + * The API needs it for {@link EmailBlockPositions#BEFORE} and {@link EmailBlockPositions#AFTER}. The SDK + * sends the placement as you give it, and the API checks it. */ @SerializedName("block_id") public String blockId; diff --git a/src/main/java/com/mailerlite/sdk/emails/Emails.java b/src/main/java/com/mailerlite/sdk/emails/Emails.java index aa10096..eb86d3c 100644 --- a/src/main/java/com/mailerlite/sdk/emails/Emails.java +++ b/src/main/java/com/mailerlite/sdk/emails/Emails.java @@ -32,9 +32,10 @@ * {@link MailerLiteException#isVersionConflict()} set to true and the current etag in * {@link MailerLiteException#etag}. *

- * The methods check the IDs and the etag before they send a request. An empty or null ID throws + * The methods check the IDs in the URL path before they send a request. An empty or null ID throws * {@link com.mailerlite.sdk.exceptions.EmptyIdException}. An ID that contains a character other than the - * digits 0 to 9 throws {@link com.mailerlite.sdk.exceptions.InvalidIdException}. + * digits 0 to 9 throws {@link com.mailerlite.sdk.exceptions.InvalidIdException}. The SDK sends all other values + * as you give them, and the API checks them. If a value is not valid, the API returns an error. *

* The writes, createTemplate and createVersion have an overload with {@link RequestOptions} as the last argument. * Use it to send an idempotency key, so that you can safely send a write again after a network error. @@ -108,8 +109,7 @@ public SingleEmailBlock getBlock(String emailId, String blockId) throws MailerLi * @param block the block to insert * @return the new block and the new etag * @throws MailerLiteException if the API returns an error or the request fails - * @throws NullPointerException if the block or its editor content is null - * @throws IllegalArgumentException if the block type or the placement position is empty + * @throws NullPointerException if the block is null */ public SingleEmailBlock createBlock(String emailId, String etag, EmailBlockInput block) throws MailerLiteException { @@ -128,14 +128,13 @@ public SingleEmailBlock createBlock(String emailId, String etag, EmailBlockInput * @param options the request options, for example an idempotency key, or null for no options * @return the new block and the new etag * @throws MailerLiteException if the API returns an error or the request fails - * @throws NullPointerException if the block or its editor content is null - * @throws IllegalArgumentException if the block type or the placement position is empty + * @throws NullPointerException if the block is null */ public SingleEmailBlock createBlock(String emailId, String etag, EmailBlockInput block, RequestOptions options) throws MailerLiteException { String endpoint = emailPath(emailId).concat("/blocks"); - BlockBody body = blockBody(block, RequestChecks.etag(etag)); + BlockBody body = blockBody(Objects.requireNonNull(block, "block"), etag); return send("POST", endpoint, toJson(body), options, SingleEmailBlock.class); } @@ -152,7 +151,6 @@ public SingleEmailBlock createBlock(String emailId, String etag, EmailBlockInput * @param editorContent the new editor document of the block. The keys are camelCase. * @return the block and the new etag * @throws MailerLiteException if the API returns an error or the request fails - * @throws NullPointerException if the editor content is null */ public SingleEmailBlock updateBlock(String emailId, String blockId, String etag, Map editorContent) throws MailerLiteException @@ -173,7 +171,6 @@ public SingleEmailBlock updateBlock(String emailId, String blockId, String etag, * @param options the request options, for example an idempotency key, or null for no options * @return the block and the new etag * @throws MailerLiteException if the API returns an error or the request fails - * @throws NullPointerException if the editor content is null */ public SingleEmailBlock updateBlock(String emailId, String blockId, String etag, Map editorContent, RequestOptions options) @@ -182,8 +179,8 @@ public SingleEmailBlock updateBlock(String emailId, String blockId, String etag, String endpoint = blockPath(emailId, blockId); UpdateBlockBody body = new UpdateBlockBody(); - body.etag = RequestChecks.etag(etag); - body.editorContent = Objects.requireNonNull(editorContent, "editorContent"); + body.etag = etag; + body.editorContent = editorContent; return send("PATCH", endpoint, toJson(body), options, SingleEmailBlock.class); } @@ -224,6 +221,7 @@ public EmailEtagResponse deleteBlock(String emailId, String blockId, String etag * Moves a block to a new position at the same level. The nesting of the block does not change. *

* The positions {@link EmailBlockPositions#BEFORE} and {@link EmailBlockPositions#AFTER} need a block ID. + * The SDK sends the placement as you give it. * * @param emailId the email ID * @param blockId the ID of the block to move @@ -231,8 +229,6 @@ public EmailEtagResponse deleteBlock(String emailId, String blockId, String etag * @param placement the new position * @return the block with its new placement, and the new etag * @throws MailerLiteException if the API returns an error or the request fails - * @throws NullPointerException if the placement is null - * @throws IllegalArgumentException if the placement position is empty */ public SingleEmailBlock moveBlock(String emailId, String blockId, String etag, EmailBlockPosition placement) throws MailerLiteException @@ -244,6 +240,7 @@ public SingleEmailBlock moveBlock(String emailId, String blockId, String etag, E * Moves a block to a new position at the same level. The nesting of the block does not change. *

* The positions {@link EmailBlockPositions#BEFORE} and {@link EmailBlockPositions#AFTER} need a block ID. + * The SDK sends the placement as you give it. * * @param emailId the email ID * @param blockId the ID of the block to move @@ -252,8 +249,6 @@ public SingleEmailBlock moveBlock(String emailId, String blockId, String etag, E * @param options the request options, for example an idempotency key, or null for no options * @return the block with its new placement, and the new etag * @throws MailerLiteException if the API returns an error or the request fails - * @throws NullPointerException if the placement is null - * @throws IllegalArgumentException if the placement position is empty */ public SingleEmailBlock moveBlock(String emailId, String blockId, String etag, EmailBlockPosition placement, RequestOptions options) @@ -262,8 +257,8 @@ public SingleEmailBlock moveBlock(String emailId, String blockId, String etag, E String endpoint = blockPath(emailId, blockId).concat("/move"); MoveBlockBody body = new MoveBlockBody(); - body.etag = RequestChecks.etag(etag); - body.placement = placementBody(Objects.requireNonNull(placement, "placement")); + body.etag = etag; + body.placement = placement; return send("POST", endpoint, toJson(body), options, SingleEmailBlock.class); } @@ -273,15 +268,15 @@ public SingleEmailBlock moveBlock(String emailId, String blockId, String etag, E *

* An invalid block is not an error: the response has valid set to false and the errors for each field. * - * @param block the block to check + * @param block the block to check. A null block sends an empty body. * @return the result of the validation * @throws MailerLiteException if the API returns an error or the request fails - * @throws NullPointerException if the block or its editor content is null - * @throws IllegalArgumentException if the block type or the placement position is empty */ public EmailValidation validateBlock(EmailBlockInput block) throws MailerLiteException { - return send("POST", "/emails/blocks/validate", toJson(blockBody(block, null)), EmailValidation.class); + String body = block == null ? "{}" : toJson(blockBody(block, null)); + + return send("POST", "/emails/blocks/validate", body, EmailValidation.class); } /** @@ -327,10 +322,12 @@ public SingleEmailSettings updateSettings(String emailId, String etag, EmailSett throws MailerLiteException { String endpoint = emailPath(emailId).concat("/settings"); - String checkedEtag = RequestChecks.etag(etag); - JsonObject body = settingsBody(settings); - body.addProperty("etag", checkedEtag); + JsonObject body = settingsBody(Objects.requireNonNull(settings, "settings")); + + if (etag != null) { + body.addProperty("etag", etag); + } return send("PATCH", endpoint, TREE_GSON.toJson(body), options, SingleEmailSettings.class); } @@ -342,16 +339,17 @@ public SingleEmailSettings updateSettings(String emailId, String etag, EmailSett * update would remove unsafe values, the response has their paths in rejected. * * @param emailId the email ID - * @param settings the settings to check + * @param settings the settings to check. Null settings send an empty body. * @return the result of the validation * @throws MailerLiteException if the API returns an error or the request fails - * @throws NullPointerException if the settings are null */ public EmailValidation validateSettings(String emailId, EmailSettings settings) throws MailerLiteException { String endpoint = emailPath(emailId).concat("/settings/validate"); - return send("POST", endpoint, TREE_GSON.toJson(settingsBody(settings)), EmailValidation.class); + String body = settings == null ? "{}" : TREE_GSON.toJson(settingsBody(settings)); + + return send("POST", endpoint, body, EmailValidation.class); } /** @@ -361,7 +359,6 @@ public EmailValidation validateSettings(String emailId, EmailSettings settings) * @param name the name of the new template * @return the new template * @throws MailerLiteException if the API returns an error or the request fails - * @throws IllegalArgumentException if the name is null or empty */ public SingleEmailTemplate createTemplate(String emailId, String name) throws MailerLiteException { @@ -376,17 +373,12 @@ public SingleEmailTemplate createTemplate(String emailId, String name) throws Ma * @param options the request options, for example an idempotency key, or null for no options * @return the new template * @throws MailerLiteException if the API returns an error or the request fails - * @throws IllegalArgumentException if the name is null or empty */ public SingleEmailTemplate createTemplate(String emailId, String name, RequestOptions options) throws MailerLiteException { String endpoint = emailPath(emailId).concat("/templates"); - if (name == null || name.isEmpty()) { - throw new IllegalArgumentException("name must not be empty"); - } - TemplateBody body = new TemplateBody(); body.name = name; @@ -485,55 +477,26 @@ private static String blockPath(String emailId, String blockId) private static BlockBody blockBody(EmailBlockInput block, String etag) { - Objects.requireNonNull(block, "block"); - - if (block.type == null || block.type.isEmpty()) { - throw new IllegalArgumentException("block.type must not be empty"); - } - BlockBody body = new BlockBody(); body.type = block.type; - body.editorContent = Objects.requireNonNull(block.editorContent, "block.editorContent"); - body.placement = block.placement == null ? null : placementBody(block.placement); + body.editorContent = block.editorContent; + body.placement = block.placement; body.etag = etag; return body; } - /** - * The API uses the block ID only for "before" and "after". So the copy has no block ID for "start" and "end". - */ - private static PlacementBody placementBody(EmailBlockPosition placement) - { - String position = placement.position; - - if (position == null || position.isEmpty()) { - throw new IllegalArgumentException("placement.position must not be empty"); - } - - if (EmailBlockPositions.START.equals(position) || EmailBlockPositions.END.equals(position)) { - return new PlacementBody(position, null); - } - - if (EmailBlockPositions.BEFORE.equals(position) || EmailBlockPositions.AFTER.equals(position) - || placement.blockId != null) { - RequestChecks.pathId(placement.blockId, "placement.blockId"); - } - - return new PlacementBody(position, placement.blockId); - } - private static String etagBody(String etag) { EtagBody body = new EtagBody(); - body.etag = RequestChecks.etag(etag); + body.etag = etag; return toJson(body); } private static JsonObject settingsBody(EmailSettings settings) { - return GSON.toJsonTree(Objects.requireNonNull(settings, "settings")).getAsJsonObject(); + return GSON.toJsonTree(settings).getAsJsonObject(); } private static String toJson(Object body) @@ -552,7 +515,7 @@ private T send(String method, String endpoint, St { Map headers = null; - if (options != null && options.getIdempotencyKey() != null) { + if (options != null && options.getIdempotencyKey() != null && !options.getIdempotencyKey().isEmpty()) { headers = Collections.singletonMap("Idempotency-Key", options.getIdempotencyKey()); } @@ -572,7 +535,7 @@ private static class BlockBody { Map editorContent; @SerializedName("placement") - PlacementBody placement; + EmailBlockPosition placement; @SerializedName("etag") String etag; @@ -597,7 +560,7 @@ private static class EtagBody { private static class MoveBlockBody { @SerializedName("placement") - PlacementBody placement; + EmailBlockPosition placement; @SerializedName("etag") String etag; @@ -608,19 +571,4 @@ private static class TemplateBody { @SerializedName("name") String name; } - - private static class PlacementBody { - - @SerializedName("position") - final String position; - - @SerializedName("block_id") - final String blockId; - - PlacementBody(String position, String blockId) - { - this.position = position; - this.blockId = blockId; - } - } } diff --git a/src/main/java/com/mailerlite/sdk/exceptions/EmptyEtagException.java b/src/main/java/com/mailerlite/sdk/exceptions/EmptyEtagException.java deleted file mode 100644 index 505681a..0000000 --- a/src/main/java/com/mailerlite/sdk/exceptions/EmptyEtagException.java +++ /dev/null @@ -1,27 +0,0 @@ -/************************************************* - * MailerLite Java SDK - * https://github.com/mailerlite/mailerlite-java - * - * @author MailerLite - * https://mailerlite.com - **************************************************/ -package com.mailerlite.sdk.exceptions; - -/** - * A write has an empty or null etag. - * The SDK sends no request when it throws this exception. - */ -public class EmptyEtagException extends IllegalArgumentException { - - private static final long serialVersionUID = 1L; - - /** - * Makes the exception. - * - * @param message the message - */ - public EmptyEtagException(String message) - { - super(message); - } -} diff --git a/src/main/java/com/mailerlite/sdk/exceptions/InvalidIdempotencyKeyException.java b/src/main/java/com/mailerlite/sdk/exceptions/InvalidIdempotencyKeyException.java deleted file mode 100644 index 5c10e8c..0000000 --- a/src/main/java/com/mailerlite/sdk/exceptions/InvalidIdempotencyKeyException.java +++ /dev/null @@ -1,27 +0,0 @@ -/************************************************* - * MailerLite Java SDK - * https://github.com/mailerlite/mailerlite-java - * - * @author MailerLite - * https://mailerlite.com - **************************************************/ -package com.mailerlite.sdk.exceptions; - -/** - * An idempotency key is not 1 to 255 printable ASCII characters, or it has a space at the start or the end. - * The SDK sends no request when it throws this exception. - */ -public class InvalidIdempotencyKeyException extends IllegalArgumentException { - - private static final long serialVersionUID = 1L; - - /** - * Makes the exception. - * - * @param message the message - */ - public InvalidIdempotencyKeyException(String message) - { - super(message); - } -} diff --git a/src/main/java/com/mailerlite/sdk/util/RequestChecks.java b/src/main/java/com/mailerlite/sdk/util/RequestChecks.java index 941aed5..578e720 100644 --- a/src/main/java/com/mailerlite/sdk/util/RequestChecks.java +++ b/src/main/java/com/mailerlite/sdk/util/RequestChecks.java @@ -7,25 +7,21 @@ **************************************************/ package com.mailerlite.sdk.util; -import com.mailerlite.sdk.exceptions.EmptyEtagException; import com.mailerlite.sdk.exceptions.EmptyIdException; import com.mailerlite.sdk.exceptions.InvalidIdException; -import com.mailerlite.sdk.exceptions.InvalidIdempotencyKeyException; /** - * Checks the input of a request before the SDK sends it. + * Checks the IDs in the URL path of a request before the SDK sends it. * Each check throws an exception that extends IllegalArgumentException, and the SDK then sends no request. */ public final class RequestChecks { - private static final int MAX_IDEMPOTENCY_KEY_LENGTH = 255; - private RequestChecks() { } /** - * Checks an ID that goes into the path or the body of a request. + * Checks an ID that goes into the URL path of a request. *

* The ID must contain only the ASCII digits 0 to 9. URL encoding is not sufficient, because the server * decodes "%2F" to "/". Other Unicode digits, such as "²", are not accepted. @@ -53,56 +49,4 @@ public static String pathId(String value, String name) return value; } - - /** - * Checks the etag of a write. - * - * @param value the etag from the last response for the email - * @return the etag - * @throws EmptyEtagException if the etag is null or empty - */ - public static String etag(String value) - { - if (value == null || value.isEmpty()) { - throw new EmptyEtagException("etag must not be empty"); - } - - return value; - } - - /** - * Checks an idempotency key. - *

- * The key must have 1 to 255 characters. Each character must be printable ASCII (0x20 to 0x7E). - * The key must not start or end with a space, because HTTP removes these spaces and the API then gets - * a different key. - * - * @param key the key - * @return the key - * @throws InvalidIdempotencyKeyException if the key is null or does not obey these rules - */ - public static String idempotencyKey(String key) - { - if (key == null || key.isEmpty() || key.length() > MAX_IDEMPOTENCY_KEY_LENGTH - || key.charAt(0) == ' ' || key.charAt(key.length() - 1) == ' ') { - throw invalidKey(); - } - - for (int i = 0; i < key.length(); i++) { - - char c = key.charAt(i); - - if (c < 0x20 || c > 0x7E) { - throw invalidKey(); - } - } - - return key; - } - - private static InvalidIdempotencyKeyException invalidKey() - { - return new InvalidIdempotencyKeyException( - "the idempotency key must be 1 to 255 printable ASCII characters, with no space at the start or the end"); - } } diff --git a/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java b/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java index 6ec0ed3..ed78be7 100644 --- a/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java +++ b/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java @@ -41,6 +41,7 @@ import com.mailerlite.sdk.emails.EmailBrandSettings; import com.mailerlite.sdk.emails.EmailEditorText; import com.mailerlite.sdk.emails.EmailFooterSettings; +import com.mailerlite.sdk.emails.EmailLayoutSettings; import com.mailerlite.sdk.emails.EmailParagraphStyle; import com.mailerlite.sdk.emails.EmailSettings; import com.mailerlite.sdk.emails.EmailTextStyle; @@ -55,7 +56,6 @@ import com.mailerlite.sdk.emails.SingleEmailBlock; import com.mailerlite.sdk.emails.SingleEmailDocument; import com.mailerlite.sdk.emails.SingleEmailHtml; -import com.mailerlite.sdk.exceptions.EmptyEtagException; import com.mailerlite.sdk.exceptions.EmptyIdException; import com.mailerlite.sdk.exceptions.InvalidIdException; import com.mailerlite.sdk.exceptions.MailerLiteException; @@ -301,23 +301,46 @@ static Map paragraph(String align, String text) return content; } - static void assertRejectsEtags(RecordingHttpClient client, Function call) + /** + * The API checks the etag. The SDK sends an empty etag as it is, and sends no etag key for a null etag. + */ + static void assertSendsEtags(RecordingHttpClient client, String response, Function call, + Function expectedBody) throws Throwable { - assertThrows(EmptyEtagException.class, call.apply("")); - assertThrows(EmptyEtagException.class, call.apply(null)); - assertTrue(client.requests().isEmpty(), "the SDK must send no request"); + client.requests().clear(); + client.respond(200, response).respond(200, response); + + call.apply("").execute(); + assertEquals(expectedBody.apply(",\"etag\":\"\""), client.lastRequest().bodyString(), "empty etag"); + + call.apply(null).execute(); + assertEquals(expectedBody.apply(""), client.lastRequest().bodyString(), "null etag"); + assertEquals(2, client.requests().size(), "number of requests"); } - static void assertPlacementRules(RecordingHttpClient client, String response, Function call, - Function expectedBody) throws Throwable + /** + * The API checks the placement. The SDK sends the placement exactly as the caller gives it. + */ + static void assertSendsThePlacement(RecordingHttpClient client, String response, + Function call, Function expectedBody) throws Throwable { Object[][] sent = { - {EmailBlockPositions.END, "", "{\"position\":\"end\"}"}, - {EmailBlockPositions.START, "1/2", "{\"position\":\"start\"}"}, + {EmailBlockPositions.END, null, "{\"position\":\"end\"}"}, + {EmailBlockPositions.END, "", "{\"position\":\"end\",\"block_id\":\"\"}"}, + {EmailBlockPositions.START, "5", "{\"position\":\"start\",\"block_id\":\"5\"}"}, + {EmailBlockPositions.START, "1/2", "{\"position\":\"start\",\"block_id\":\"1/2\"}"}, {EmailBlockPositions.AFTER, "57", "{\"position\":\"after\",\"block_id\":\"57\"}"}, + {EmailBlockPositions.AFTER, null, "{\"position\":\"after\"}"}, + {EmailBlockPositions.AFTER, "", "{\"position\":\"after\",\"block_id\":\"\"}"}, + {EmailBlockPositions.AFTER, "\u00b2", "{\"position\":\"after\",\"block_id\":\"\u00b2\"}"}, {EmailBlockPositions.BEFORE, "1", "{\"position\":\"before\",\"block_id\":\"1\"}"}, + {EmailBlockPositions.BEFORE, null, "{\"position\":\"before\"}"}, + {EmailBlockPositions.BEFORE, "1/2", "{\"position\":\"before\",\"block_id\":\"1/2\"}"}, {"middle", "5", "{\"position\":\"middle\",\"block_id\":\"5\"}"}, {"middle", null, "{\"position\":\"middle\"}"}, + {"", "5", "{\"position\":\"\",\"block_id\":\"5\"}"}, + {null, "5", "{\"block_id\":\"5\"}"}, + {null, null, "{}"}, }; for (Object[] item : sent) { @@ -328,31 +351,11 @@ static void assertPlacementRules(RecordingHttpClient client, String response, Fu EmailBlockPosition placement = new EmailBlockPosition((String) item[0], (String) item[1]); call.apply(placement).execute(); + assertEquals(1, client.requests().size(), item[0] + " " + item[1]); assertEquals(expectedBody.apply((String) item[2]), client.lastRequest().bodyString(), item[0] + " " + item[1]); + assertEquals(item[0], placement.position, "the SDK must not change the placement of the caller"); assertEquals(item[1], placement.blockId, "the SDK must not change the placement of the caller"); } - - client.requests().clear(); - - Object[][] rejected = { - {EmailBlockPositions.AFTER, null, EmptyIdException.class}, - {EmailBlockPositions.AFTER, "", EmptyIdException.class}, - {EmailBlockPositions.BEFORE, null, EmptyIdException.class}, - {EmailBlockPositions.BEFORE, "1/2", InvalidIdException.class}, - {EmailBlockPositions.AFTER, "\u00b2", InvalidIdException.class}, - {"middle", "1/2", InvalidIdException.class}, - {"", "5", IllegalArgumentException.class}, - {null, "5", IllegalArgumentException.class}, - }; - - for (Object[] item : rejected) { - - @SuppressWarnings("unchecked") - Class type = (Class) item[2]; - assertThrows(type, call.apply(new EmailBlockPosition((String) item[0], (String) item[1])), item[0] + " " + item[1]); - } - - assertTrue(client.requests().isEmpty(), "the SDK must send no request"); } @Test @@ -398,9 +401,9 @@ public void createBlockWithoutPlacement() throws Exception } @Test - public void createBlockPlacementRules() throws Throwable + public void createBlockSendsThePlacementAsGiven() throws Throwable { - assertPlacementRules(client, Fixtures.read("email_block_created.json"), placement -> () -> { + assertSendsThePlacement(client, Fixtures.read("email_block_created.json"), placement -> () -> { EmailBlockInput block = new EmailBlockInput(EmailBlockTypes.DIVIDER, new LinkedHashMap()); block.placement = placement; emails.createBlock("123", "42", block); @@ -413,21 +416,44 @@ public void createBlockRejectsInvalidInput() EmailBlockInput divider = new EmailBlockInput(EmailBlockTypes.DIVIDER, new LinkedHashMap()); assertRejectsIds(client, id -> () -> emails.createBlock(id, "42", divider)); - assertRejectsEtags(client, etag -> () -> emails.createBlock("123", etag, divider)); NullPointerException noBlock = assertThrows(NullPointerException.class, () -> emails.createBlock("123", "42", null)); assertTrue(noBlock.getMessage().contains("block"), noBlock.getMessage()); - assertThrows(IllegalArgumentException.class, + assertTrue(client.requests().isEmpty(), "the SDK must send no request"); + } + + @Test + public void createBlockSendsTheEtagAsGiven() throws Throwable + { + EmailBlockInput divider = new EmailBlockInput(EmailBlockTypes.DIVIDER, new LinkedHashMap()); + + assertSendsEtags(client, Fixtures.read("email_block_created.json"), + etag -> () -> emails.createBlock("123", etag, divider), + etag -> "{\"type\":\"divider\",\"editor_content\":{}" + etag + "}"); + } + + @Test + public void createBlockSendsTheBlockValuesAsGiven() throws Exception + { + client.respond(422, "{\"message\":\"The given data was invalid.\"," + + "\"errors\":{\"type\":[\"The type field is required.\"]}}") + .respond(201, Fixtures.read("email_block_created.json")) + .respond(201, Fixtures.read("email_block_created.json")); + + MailerLiteException e = assertThrows(MailerLiteException.class, () -> emails.createBlock("123", "42", new EmailBlockInput("", new LinkedHashMap()))); - assertThrows(IllegalArgumentException.class, - () -> emails.createBlock("123", "42", new EmailBlockInput(null, new LinkedHashMap()))); - NullPointerException noContent = assertThrows(NullPointerException.class, - () -> emails.createBlock("123", "42", new EmailBlockInput(EmailBlockTypes.DIVIDER, null))); - assertTrue(noContent.getMessage().contains("editorContent"), noContent.getMessage()); + assertEquals("{\"type\":\"\",\"editor_content\":{},\"etag\":\"42\"}", client.lastRequest().bodyString()); + assertEquals(422, e.code); + assertArrayEquals(new String[] {"The type field is required."}, e.errors.get("type")); - assertTrue(client.requests().isEmpty(), "the SDK must send no request"); + emails.createBlock("123", "42", new EmailBlockInput(null, new LinkedHashMap())); + assertEquals("{\"editor_content\":{},\"etag\":\"42\"}", client.lastRequest().bodyString()); + + emails.createBlock("123", "42", new EmailBlockInput(EmailBlockTypes.DIVIDER, null)); + assertEquals("{\"type\":\"divider\",\"etag\":\"42\"}", client.lastRequest().bodyString()); + assertEquals(3, client.requests().size()); } @@ -469,11 +495,21 @@ public void updateBlockRejectsInvalidInput() assertRejectsIds(client, id -> () -> emails.updateBlock(id, "57", "43", content)); assertRejectsIds(client, id -> () -> emails.updateBlock("123", id, "43", content)); - assertRejectsEtags(client, etag -> () -> emails.updateBlock("123", "57", etag, content)); + } - NullPointerException e = assertThrows(NullPointerException.class, () -> emails.updateBlock("123", "57", "43", null)); - assertTrue(e.getMessage().contains("editorContent"), e.getMessage()); - assertTrue(client.requests().isEmpty(), "the SDK must send no request"); + @Test + public void updateBlockSendsTheValuesAsGiven() throws Throwable + { + assertSendsEtags(client, Fixtures.read("email_block_updated.json"), + etag -> () -> emails.updateBlock("123", "57", etag, new LinkedHashMap()), + etag -> "{\"editor_content\":{}" + etag + "}"); + + client.requests().clear(); + client.respond(200, Fixtures.read("email_block_updated.json")); + + emails.updateBlock("123", "57", "43", null); + + assertRequest(client, "PATCH", "/emails/123/blocks/57", "{\"etag\":\"43\"}", null); } @@ -494,7 +530,14 @@ public void deleteBlockRejectsInvalidInput() { assertRejectsIds(client, id -> () -> emails.deleteBlock(id, "57", "44")); assertRejectsIds(client, id -> () -> emails.deleteBlock("123", id, "44")); - assertRejectsEtags(client, etag -> () -> emails.deleteBlock("123", "57", etag)); + } + + @Test + public void deleteBlockSendsTheEtagAsGiven() throws Throwable + { + assertSendsEtags(client, Fixtures.read("email_etag.json"), + etag -> () -> emails.deleteBlock("123", "57", etag), + etag -> "{" + (etag.isEmpty() ? "" : etag.substring(1)) + "}"); } @@ -514,9 +557,9 @@ public void moveBlockSendsThePlacement() throws Exception } @Test - public void moveBlockPlacementRules() throws Throwable + public void moveBlockSendsThePlacementAsGiven() throws Throwable { - assertPlacementRules(client, Fixtures.read("email_block_moved.json"), + assertSendsThePlacement(client, Fixtures.read("email_block_moved.json"), placement -> () -> emails.moveBlock("123", "57", "45", placement), json -> "{\"placement\":" + json + ",\"etag\":\"45\"}"); } @@ -528,11 +571,23 @@ public void moveBlockRejectsInvalidInput() assertRejectsIds(client, id -> () -> emails.moveBlock(id, "57", "45", start)); assertRejectsIds(client, id -> () -> emails.moveBlock("123", id, "45", start)); - assertRejectsEtags(client, etag -> () -> emails.moveBlock("123", "57", etag, start)); + } - NullPointerException e = assertThrows(NullPointerException.class, () -> emails.moveBlock("123", "57", "45", null)); - assertTrue(e.getMessage().contains("placement"), e.getMessage()); - assertTrue(client.requests().isEmpty(), "the SDK must send no request"); + @Test + public void moveBlockSendsTheValuesAsGiven() throws Throwable + { + EmailBlockPosition start = new EmailBlockPosition(EmailBlockPositions.START); + + assertSendsEtags(client, Fixtures.read("email_block_moved.json"), + etag -> () -> emails.moveBlock("123", "57", etag, start), + etag -> "{\"placement\":{\"position\":\"start\"}" + etag + "}"); + + client.requests().clear(); + client.respond(200, Fixtures.read("email_block_moved.json")); + + emails.moveBlock("123", "57", "45", null); + + assertRequest(client, "POST", "/emails/123/blocks/57/move", "{\"etag\":\"45\"}", null); } @@ -570,9 +625,9 @@ public void validateBlockInvalid() throws Exception } @Test - public void validateBlockPlacementRules() throws Throwable + public void validateBlockSendsThePlacementAsGiven() throws Throwable { - assertPlacementRules(client, Fixtures.read("email_block_validation_valid.json"), placement -> () -> { + assertSendsThePlacement(client, Fixtures.read("email_block_validation_valid.json"), placement -> () -> { EmailBlockInput block = new EmailBlockInput(EmailBlockTypes.DIVIDER, new LinkedHashMap()); block.placement = placement; emails.validateBlock(block); @@ -580,13 +635,21 @@ public void validateBlockPlacementRules() throws Throwable } @Test - public void validateBlockRejectsInvalidInput() + public void validateBlockSendsAnInvalidBlock() throws Exception { - assertThrows(NullPointerException.class, () -> emails.validateBlock(null)); - assertThrows(IllegalArgumentException.class, - () -> emails.validateBlock(new EmailBlockInput("", new LinkedHashMap()))); - assertThrows(NullPointerException.class, () -> emails.validateBlock(new EmailBlockInput(EmailBlockTypes.DIVIDER, null))); - assertTrue(client.requests().isEmpty(), "the SDK must send no request"); + String invalid = Fixtures.read("email_block_validation_invalid.json"); + client.respond(200, invalid).respond(200, invalid).respond(200, invalid); + + EmailValidation response = emails.validateBlock(new EmailBlockInput("", new LinkedHashMap())); + assertEquals("{\"type\":\"\",\"editor_content\":{}}", client.lastRequest().bodyString()); + assertFalse(response.valid); + + emails.validateBlock(new EmailBlockInput(EmailBlockTypes.DIVIDER, null)); + assertEquals("{\"type\":\"divider\"}", client.lastRequest().bodyString()); + + emails.validateBlock(null); + assertEquals("{}", client.lastRequest().bodyString()); + assertEquals(3, client.requests().size()); } @@ -674,7 +737,6 @@ public void updateSettingsRejectsInvalidInput() EmailSettings settings = brandAndParagraphSettings(); assertRejectsIds(client, id -> () -> emails.updateSettings(id, "46", settings)); - assertRejectsEtags(client, etag -> () -> emails.updateSettings("123", etag, settings)); NullPointerException e = assertThrows(NullPointerException.class, () -> emails.updateSettings("123", "46", null)); assertTrue(e.getMessage().contains("settings"), e.getMessage()); @@ -682,6 +744,19 @@ public void updateSettingsRejectsInvalidInput() } + @Test + public void updateSettingsSendsTheEtagAsGiven() throws Throwable + { + EmailSettings settings = new EmailSettings(); + settings.layout = new EmailLayoutSettings(); + settings.layout.alignment = "left"; + + assertSendsEtags(client, Fixtures.read("email_settings.json"), + etag -> () -> emails.updateSettings("123", etag, settings), + etag -> "{\"layout\":{\"alignment\":\"left\"}" + etag + "}"); + } + + @Test public void validateSettingsSendsTheSettingsWithoutEtag() throws Exception { @@ -703,9 +778,16 @@ public void validateSettingsSendsTheSettingsWithoutEtag() throws Exception public void validateSettingsRejectsInvalidInput() { assertRejectsIds(client, id -> () -> emails.validateSettings(id, brandAndParagraphSettings())); + } - assertThrows(NullPointerException.class, () -> emails.validateSettings("123", null)); - assertTrue(client.requests().isEmpty(), "the SDK must send no request"); + @Test + public void validateSettingsWithNullSettingsSendsAnEmptyBody() throws Exception + { + client.respond(200, Fixtures.read("email_settings_validation.json")); + + emails.validateSettings("123", null); + + assertRequest(client, "POST", "/emails/123/settings/validate", "{}", null); } @@ -728,10 +810,24 @@ public void createTemplateSendsTheName() throws Exception public void createTemplateRejectsInvalidInput() { assertRejectsIds(client, id -> () -> emails.createTemplate(id, "Newsletter")); + } - assertThrows(IllegalArgumentException.class, () -> emails.createTemplate("123", "")); - assertThrows(IllegalArgumentException.class, () -> emails.createTemplate("123", null)); - assertTrue(client.requests().isEmpty(), "the SDK must send no request"); + @Test + public void createTemplateSendsTheNameAsGiven() throws Exception + { + client.respond(422, "{\"message\":\"The given data was invalid.\"," + + "\"errors\":{\"name\":[\"The name field is required.\"]}}") + .respond(422, "{\"message\":\"The given data was invalid.\"}"); + + MailerLiteException e = assertThrows(MailerLiteException.class, () -> emails.createTemplate("123", "")); + + assertRequest(client, "POST", "/emails/123/templates", "{\"name\":\"\"}", null); + assertEquals(422, e.code); + assertArrayEquals(new String[] {"The name field is required."}, e.errors.get("name")); + + assertThrows(MailerLiteException.class, () -> emails.createTemplate("123", null)); + assertEquals("{}", client.lastRequest().bodyString()); + assertEquals(2, client.requests().size()); } @@ -842,7 +938,14 @@ public void restoreVersionRejectsInvalidInput() { assertRejectsIds(client, id -> () -> emails.restoreVersion(id, "901", "47")); assertRejectsIds(client, id -> () -> emails.restoreVersion("123", id, "47")); - assertRejectsEtags(client, etag -> () -> emails.restoreVersion("123", "901", etag)); + } + + @Test + public void restoreVersionSendsTheEtagAsGiven() throws Throwable + { + assertSendsEtags(client, Fixtures.read("email.json"), + etag -> () -> emails.restoreVersion("123", "901", etag), + etag -> "{" + (etag.isEmpty() ? "" : etag.substring(1)) + "}"); } } diff --git a/src/test/java/com/mailerlite/sdk/tests/IdempotencyTest.java b/src/test/java/com/mailerlite/sdk/tests/IdempotencyTest.java index aa332af..49cf37f 100644 --- a/src/test/java/com/mailerlite/sdk/tests/IdempotencyTest.java +++ b/src/test/java/com/mailerlite/sdk/tests/IdempotencyTest.java @@ -13,6 +13,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.LinkedHashMap; @@ -34,7 +35,6 @@ import com.mailerlite.sdk.emails.EmailBlockTypes; import com.mailerlite.sdk.emails.Emails; import com.mailerlite.sdk.emails.SingleEmailBlock; -import com.mailerlite.sdk.exceptions.InvalidIdempotencyKeyException; import com.mailerlite.sdk.support.Fixtures; import com.mailerlite.sdk.support.RecordingHttpClient; @@ -125,17 +125,53 @@ public void validateMethodsSendNoKey() throws Exception assertNull(client.requests().get(1).header("Idempotency-Key")); } + /** + * The SDK gives the key to the HTTP client as it is, and the API checks it. The HTTP client removes the spaces + * and the tabs at the start and the end of a header value, as HTTP does. + */ @Test - public void invalidKeysThrowAtOnce() + public void keysAreSentAsGiven() throws Exception { - for (String key : RequestChecksTest.INVALID_KEYS) { - assertThrows(InvalidIdempotencyKeyException.class, () -> RequestOptions.idempotencyKey(key), - "key of length " + key.length()); + List keys = Arrays.asList(" key", "key ", "key\t", "cl\u00e9", RequestChecksTest.repeat('k', 256), "a"); + + for (String key : keys) { + + client.requests().clear(); + client.respond(201, Fixtures.read("email_version.json")); + + RequestOptions options = RequestOptions.idempotencyKey(key); + assertEquals(key, options.getIdempotencyKey()); + + emails.createVersion("123", options); + + assertEquals(1, client.requests().size(), "key of length " + key.length()); + assertEquals(key.trim(), client.lastRequest().header("Idempotency-Key"), "key of length " + key.length()); + } + } + + @Test + public void emptyKeySendsNoHeader() throws Exception + { + client.respond(201, Fixtures.read("email_version.json")); + + emails.createVersion("123", RequestOptions.idempotencyKey("")); + + assertEquals(1, client.requests().size()); + assertNull(client.lastRequest().header("Idempotency-Key")); + } + + @Test + public void keysThatHttpCannotSendReturnTheHttpError() + { + for (String key : Arrays.asList("key\n", "key\u007f")) { + + RequestOptions options = RequestOptions.idempotencyKey(key); + + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> emails.createVersion("123", options), "key " + key.replace("\n", "\\n")); + assertTrue(e.getMessage().contains("header"), e.getMessage()); } - String longest = RequestChecksTest.repeat('k', 255); - assertEquals(longest, RequestOptions.idempotencyKey(longest).getIdempotencyKey()); - assertNull(RequestOptions.idempotencyKey(null).getIdempotencyKey()); assertTrue(client.requests().isEmpty()); } diff --git a/src/test/java/com/mailerlite/sdk/tests/RequestChecksTest.java b/src/test/java/com/mailerlite/sdk/tests/RequestChecksTest.java index 77f9613..2a080c5 100644 --- a/src/test/java/com/mailerlite/sdk/tests/RequestChecksTest.java +++ b/src/test/java/com/mailerlite/sdk/tests/RequestChecksTest.java @@ -28,10 +28,8 @@ import com.google.gson.annotations.JsonAdapter; import com.google.gson.annotations.SerializedName; import com.mailerlite.sdk.emails.EditorContentAdapter; -import com.mailerlite.sdk.exceptions.EmptyEtagException; import com.mailerlite.sdk.exceptions.EmptyIdException; import com.mailerlite.sdk.exceptions.InvalidIdException; -import com.mailerlite.sdk.exceptions.InvalidIdempotencyKeyException; import com.mailerlite.sdk.util.RequestChecks; public class RequestChecksTest { @@ -39,9 +37,6 @@ public class RequestChecksTest { public static final List INVALID_IDS = Collections.unmodifiableList(Arrays.asList( "123/html", "123%2Fhtml", "123\n", "\u00b2", "\u0661\u0662\u0663", "12a", " 123", "-1", "1.5")); - public static final List INVALID_KEYS = Collections.unmodifiableList(Arrays.asList( - "", repeat('k', 256), "key\n", "key\t", "cl\u00e9", "key\u007f", " key", "key ")); - public static String repeat(char c, int count) { char[] chars = new char[count]; @@ -83,45 +78,6 @@ public List pathIdRejectsInvalidIds() return tests; } - @Test - public void etagRejectsEmptyValues() - { - assertEquals("42", RequestChecks.etag("42")); - assertThrows(EmptyEtagException.class, () -> RequestChecks.etag("")); - EmptyEtagException e = assertThrows(EmptyEtagException.class, () -> RequestChecks.etag(null)); - assertTrue(e instanceof IllegalArgumentException); - } - - @Test - public void idempotencyKeyAcceptsPrintableAscii() - { - String longest = repeat('k', 255); - - assertEquals(longest, RequestChecks.idempotencyKey(longest)); - assertEquals("a", RequestChecks.idempotencyKey("a")); - assertEquals("retry 1 ~!@#$%^&*()", RequestChecks.idempotencyKey("retry 1 ~!@#$%^&*()")); - } - - @TestFactory - public List idempotencyKeyRejectsInvalidKeys() - { - List keys = new ArrayList(INVALID_KEYS); - keys.add(null); - - List tests = new ArrayList(); - - for (String key : keys) { - String name = key == null ? "null" : key.length() > 20 ? "length " + key.length() : key; - tests.add(dynamicTest("rejects " + name.replace("\n", "\\n").replace("\t", "\\t"), () -> { - InvalidIdempotencyKeyException e = assertThrows(InvalidIdempotencyKeyException.class, - () -> RequestChecks.idempotencyKey(key)); - assertTrue(e instanceof IllegalArgumentException); - })); - } - - return tests; - } - public static class Holder { @SerializedName("editor_content") From 8682cabb44f0b6025f29857f63a9512fcd834311 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Fri, 30 Oct 2026 12:35:26 +0100 Subject: [PATCH 33/35] fix: reject idempotency keys that Java cannot send The Java HTTP client sends each character that is not ASCII as "?", so the API got a different key and ran the write. The SDK now rejects a key with a character outside 0x20 to 0x7E and sends no request. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 +- .../com/mailerlite/sdk/RequestOptions.java | 4 +++ .../com/mailerlite/sdk/emails/Emails.java | 6 ++-- .../InvalidIdempotencyKeyException.java | 28 ++++++++++++++++ .../mailerlite/sdk/util/RequestChecks.java | 33 ++++++++++++++++++- .../mailerlite/sdk/tests/IdempotencyTest.java | 24 +++++++++----- 6 files changed, 86 insertions(+), 12 deletions(-) create mode 100644 src/main/java/com/mailerlite/sdk/exceptions/InvalidIdempotencyKeyException.java diff --git a/README.md b/README.md index d6eb8bc..94668ed 100644 --- a/README.md +++ b/README.md @@ -1489,7 +1489,8 @@ If a write fails with a network error, you do not know if the API did the write. - A network error is a `MailerLiteException` with the network error as the cause. - Use a new key for each write. The API keeps a key for 24 hours. - The SDK sends the key as you give it, and the API checks it. An invalid key gets a `400` error. A `null` or empty key sends no header. -- The Java HTTP client removes the spaces at the start and the end of the key. For a control character, for example a new line, it throws `IllegalArgumentException`. +- The SDK rejects a key with a character that is not printable ASCII (`0x20` to `0x7E`), for example `é` or a new line. The Java HTTP client would send each character that is not ASCII as `?`, so the API would get a different key. The write method then throws `InvalidIdempotencyKeyException`, which extends `IllegalArgumentException`, and the SDK sends no request. +- The Java HTTP client removes the spaces at the start and the end of the key. - The same key with different arguments gets a `422` error. If the first request is still running, the API returns `409` without an error code. - Without a key, a retry of a write that was a success gets `409` with `version_conflict`, because the etag is old. - These methods accept `RequestOptions` as the last argument: `createBlock`, `updateBlock`, `deleteBlock`, `moveBlock`, `updateSettings`, `createTemplate`, `createVersion` and `restoreVersion`. diff --git a/src/main/java/com/mailerlite/sdk/RequestOptions.java b/src/main/java/com/mailerlite/sdk/RequestOptions.java index 03333d9..ce7255f 100644 --- a/src/main/java/com/mailerlite/sdk/RequestOptions.java +++ b/src/main/java/com/mailerlite/sdk/RequestOptions.java @@ -30,6 +30,10 @@ private RequestOptions(String idempotencyKey) * {@link MailerLiteResponse#isIdempotentReplay()} is true. The API keeps a key for 24 hours. *

* The SDK sends the key as you give it, and the API checks it. A null or empty key sends no header. + *

+ * The SDK rejects a key with a character that is not printable ASCII (0x20 to 0x7E), because the Java HTTP client + * would send each character that is not ASCII as "?". Then the write method throws + * {@link com.mailerlite.sdk.exceptions.InvalidIdempotencyKeyException} and sends no request. * * @param key the idempotency key, or null for no key * @return the options diff --git a/src/main/java/com/mailerlite/sdk/emails/Emails.java b/src/main/java/com/mailerlite/sdk/emails/Emails.java index eb86d3c..c0aebb6 100644 --- a/src/main/java/com/mailerlite/sdk/emails/Emails.java +++ b/src/main/java/com/mailerlite/sdk/emails/Emails.java @@ -515,8 +515,10 @@ private T send(String method, String endpoint, St { Map headers = null; - if (options != null && options.getIdempotencyKey() != null && !options.getIdempotencyKey().isEmpty()) { - headers = Collections.singletonMap("Idempotency-Key", options.getIdempotencyKey()); + String key = options == null ? null : RequestChecks.idempotencyKey(options.getIdempotencyKey()); + + if (key != null && !key.isEmpty()) { + headers = Collections.singletonMap("Idempotency-Key", key); } MailerLiteApi api = new MailerLiteApi(); diff --git a/src/main/java/com/mailerlite/sdk/exceptions/InvalidIdempotencyKeyException.java b/src/main/java/com/mailerlite/sdk/exceptions/InvalidIdempotencyKeyException.java new file mode 100644 index 0000000..0a4f80c --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/exceptions/InvalidIdempotencyKeyException.java @@ -0,0 +1,28 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.exceptions; + +/** + * An idempotency key contains a character that is not printable ASCII (0x20 to 0x7E). + * The Java HTTP client sends each character that is not ASCII as "?", so the API would get a different key. + * The SDK sends no request when it throws this exception. + */ +public class InvalidIdempotencyKeyException extends IllegalArgumentException { + + private static final long serialVersionUID = 1L; + + /** + * Makes the exception. + * + * @param message the message + */ + public InvalidIdempotencyKeyException(String message) + { + super(message); + } +} diff --git a/src/main/java/com/mailerlite/sdk/util/RequestChecks.java b/src/main/java/com/mailerlite/sdk/util/RequestChecks.java index 578e720..f7de987 100644 --- a/src/main/java/com/mailerlite/sdk/util/RequestChecks.java +++ b/src/main/java/com/mailerlite/sdk/util/RequestChecks.java @@ -9,9 +9,10 @@ import com.mailerlite.sdk.exceptions.EmptyIdException; import com.mailerlite.sdk.exceptions.InvalidIdException; +import com.mailerlite.sdk.exceptions.InvalidIdempotencyKeyException; /** - * Checks the IDs in the URL path of a request before the SDK sends it. + * Checks the IDs in the URL path and the idempotency key of a request before the SDK sends it. * Each check throws an exception that extends IllegalArgumentException, and the SDK then sends no request. */ public final class RequestChecks { @@ -49,4 +50,34 @@ public static String pathId(String value, String name) return value; } + + /** + * Checks an idempotency key. + *

+ * The key must contain only printable ASCII characters (0x20 to 0x7E). The Java HTTP client sends each character + * that is not ASCII as "?", so the API would get a different key, and two different keys could become the same + * key. The API checks the length and the other rules of the key. + * + * @param key the idempotency key, or null + * @return the key + * @throws InvalidIdempotencyKeyException if the key contains a character outside 0x20 to 0x7E + */ + public static String idempotencyKey(String key) + { + if (key == null) { + return null; + } + + for (int i = 0; i < key.length(); i++) { + + char c = key.charAt(i); + + if (c < 0x20 || c > 0x7E) { + throw new InvalidIdempotencyKeyException("idempotency key must contain only printable ASCII characters" + + " (0x20 to 0x7E), because the Java HTTP client changes the other characters"); + } + } + + return key; + } } diff --git a/src/test/java/com/mailerlite/sdk/tests/IdempotencyTest.java b/src/test/java/com/mailerlite/sdk/tests/IdempotencyTest.java index 49cf37f..22e3b41 100644 --- a/src/test/java/com/mailerlite/sdk/tests/IdempotencyTest.java +++ b/src/test/java/com/mailerlite/sdk/tests/IdempotencyTest.java @@ -35,6 +35,7 @@ import com.mailerlite.sdk.emails.EmailBlockTypes; import com.mailerlite.sdk.emails.Emails; import com.mailerlite.sdk.emails.SingleEmailBlock; +import com.mailerlite.sdk.exceptions.InvalidIdempotencyKeyException; import com.mailerlite.sdk.support.Fixtures; import com.mailerlite.sdk.support.RecordingHttpClient; @@ -126,13 +127,13 @@ public void validateMethodsSendNoKey() throws Exception } /** - * The SDK gives the key to the HTTP client as it is, and the API checks it. The HTTP client removes the spaces - * and the tabs at the start and the end of a header value, as HTTP does. + * The SDK gives a printable ASCII key to the HTTP client as it is, and the API checks it. The HTTP client removes + * the spaces at the start and the end of a header value, as HTTP does. */ @Test public void keysAreSentAsGiven() throws Exception { - List keys = Arrays.asList(" key", "key ", "key\t", "cl\u00e9", RequestChecksTest.repeat('k', 256), "a"); + List keys = Arrays.asList(" key", "key ", "k ~k", RequestChecksTest.repeat('k', 256), "a"); for (String key : keys) { @@ -160,16 +161,23 @@ public void emptyKeySendsNoHeader() throws Exception assertNull(client.lastRequest().header("Idempotency-Key")); } + /** + * The Java HTTP client sends each character that is not ASCII as "?", so "k-\u00e9-k" would reach the API as the + * valid key "k-?-k". The SDK rejects each character outside 0x20 to 0x7E and sends no request. + */ @Test - public void keysThatHttpCannotSendReturnTheHttpError() + public void keysOutsidePrintableAsciiAreRejected() { - for (String key : Arrays.asList("key\n", "key\u007f")) { + List keys = Arrays.asList("k-\u00e9-k", "cl\u00e9", "\u4e2d", "k\ud83d\ude00", "key\n", "key\t", "key\u007f", + "key\u001f"); + + for (String key : keys) { RequestOptions options = RequestOptions.idempotencyKey(key); - IllegalArgumentException e = assertThrows(IllegalArgumentException.class, - () -> emails.createVersion("123", options), "key " + key.replace("\n", "\\n")); - assertTrue(e.getMessage().contains("header"), e.getMessage()); + InvalidIdempotencyKeyException e = assertThrows(InvalidIdempotencyKeyException.class, + () -> emails.createVersion("123", options), "key of length " + key.length()); + assertTrue(e.getMessage().contains("0x20 to 0x7E"), e.getMessage()); } assertTrue(client.requests().isEmpty()); From 0c3fbfb6b6677bccc45fdb1191daebad387b1acb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Fri, 30 Oct 2026 12:36:24 +0100 Subject: [PATCH 34/35] refactor: remove the exceptions-to-emails package cycle MailerLiteException now has the VERSION_CONFLICT constant, and EmailErrorCodes.VERSION_CONFLICT refers to it. The exceptions package no longer imports the emails package. Co-Authored-By: Claude Opus 5.5 --- .../sdk/emails/EmailErrorCodes.java | 7 +- .../sdk/exceptions/MailerLiteException.java | 12 ++-- .../sdk/tests/MailerLiteExceptionTest.java | 69 +++++++++++++++++++ 3 files changed, 82 insertions(+), 6 deletions(-) create mode 100644 src/test/java/com/mailerlite/sdk/tests/MailerLiteExceptionTest.java diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailErrorCodes.java b/src/main/java/com/mailerlite/sdk/emails/EmailErrorCodes.java index 7d895ab..a0ab678 100644 --- a/src/main/java/com/mailerlite/sdk/emails/EmailErrorCodes.java +++ b/src/main/java/com/mailerlite/sdk/emails/EmailErrorCodes.java @@ -7,16 +7,19 @@ **************************************************/ package com.mailerlite.sdk.emails; +import com.mailerlite.sdk.exceptions.MailerLiteException; + /** - * The values of {@code MailerLiteException.errorCode} that the email endpoints send. + * The values of {@link MailerLiteException#errorCode} that the email endpoints send. */ public final class EmailErrorCodes { /** * The email changed after you read the etag that the write sent. The exception has the current etag. * Get the email again, and then send the write again with the current etag. + * The value is the same as {@link MailerLiteException#VERSION_CONFLICT}. */ - public static final String VERSION_CONFLICT = "version_conflict"; + public static final String VERSION_CONFLICT = MailerLiteException.VERSION_CONFLICT; private EmailErrorCodes() { diff --git a/src/main/java/com/mailerlite/sdk/exceptions/MailerLiteException.java b/src/main/java/com/mailerlite/sdk/exceptions/MailerLiteException.java index ba86be3..9fb90b9 100644 --- a/src/main/java/com/mailerlite/sdk/exceptions/MailerLiteException.java +++ b/src/main/java/com/mailerlite/sdk/exceptions/MailerLiteException.java @@ -9,13 +9,17 @@ import java.util.HashMap; -import com.mailerlite.sdk.emails.EmailErrorCodes; - /** * The Class MailerLiteException. */ public class MailerLiteException extends Exception { + /** + * The value of {@link #errorCode} for a version conflict: the email changed after you read the etag that the + * write sent. {@code EmailErrorCodes.VERSION_CONFLICT} has the same value. + */ + public static final String VERSION_CONFLICT = "version_conflict"; + /** The code. */ public int code; // the response code @@ -65,10 +69,10 @@ public MailerLiteException(String message, Throwable cause) { * Tells if the error is a version conflict: the email changed after you read the etag that the write sent. * Then {@link #etag} is the current etag of the email. * - * @return true if {@link #errorCode} is {@link EmailErrorCodes#VERSION_CONFLICT} + * @return true if {@link #errorCode} is {@link #VERSION_CONFLICT} */ public boolean isVersionConflict() { - return EmailErrorCodes.VERSION_CONFLICT.equals(this.errorCode); + return VERSION_CONFLICT.equals(this.errorCode); } } diff --git a/src/test/java/com/mailerlite/sdk/tests/MailerLiteExceptionTest.java b/src/test/java/com/mailerlite/sdk/tests/MailerLiteExceptionTest.java new file mode 100644 index 0000000..c5e4c2a --- /dev/null +++ b/src/test/java/com/mailerlite/sdk/tests/MailerLiteExceptionTest.java @@ -0,0 +1,69 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; + +import com.mailerlite.sdk.emails.EmailErrorCodes; +import com.mailerlite.sdk.exceptions.MailerLiteException; + +public class MailerLiteExceptionTest { + + /** The emails package uses the exceptions package, so the exceptions package must not use the emails package. */ + @Test + public void exceptionsPackageDoesNotImportEmailsPackage() throws IOException + { + List files; + + try (Stream paths = Files.list(Paths.get("src/main/java/com/mailerlite/sdk/exceptions"))) { + files = paths.filter(p -> p.toString().endsWith(".java")).collect(Collectors.toList()); + } + + assertFalse(files.isEmpty()); + + for (Path file : files) { + + String source = new String(Files.readAllBytes(file), StandardCharsets.UTF_8); + assertFalse(source.contains("com.mailerlite.sdk.emails"), file.getFileName().toString()); + } + } + + @Test + public void versionConflictCodeIsTheSameConstant() + { + assertEquals("version_conflict", MailerLiteException.VERSION_CONFLICT); + assertSame(MailerLiteException.VERSION_CONFLICT, EmailErrorCodes.VERSION_CONFLICT); + } + + @Test + public void isVersionConflictComparesTheErrorCode() + { + MailerLiteException e = new MailerLiteException("conflict"); + assertFalse(e.isVersionConflict()); + + e.errorCode = "other"; + assertFalse(e.isVersionConflict()); + + e.errorCode = new String("version_conflict"); + assertTrue(e.isVersionConflict()); + } +} From f77dcb0b572eedf0d02e3c79f499731972a88e95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Fri, 30 Oct 2026 12:37:32 +0100 Subject: [PATCH 35/35] fix: set the message field of MailerLiteException The public message field was always empty. It now has the message of the API error without the error code, and for other errors the text of getMessage(). Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + .../com/mailerlite/sdk/MailerLiteApi.java | 3 +- .../sdk/exceptions/MailerLiteException.java | 15 ++++- .../sdk/tests/MailerLiteApiTest.java | 67 +++++++++++++++++++ 4 files changed, 84 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 94668ed..092b079 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,7 @@ Version 0.1.0 changes some behavior of the SDK. Before you upgrade from 0.0.4, d - The SDK now sends a `+` in a query value as a literal `+`. Before, the API read it as a space. - `EmailBase` now has the fields `replyTo` (`reply_to`), `editor` and `templateId` (`template_id`). If your subclass of `EmailBase` has its own field with one of these JSON names, Gson throws an error ("declares multiple JSON fields named ..."), and `CampaignBuilder.create()` and `update()` fail. Remove these fields from your subclass, and use the fields of `EmailBase`. - A network error is now a `MailerLiteException` with the network error as the cause. Before, the SDK threw a `ClassCastException`. To find a network error, examine `e.getCause()`, for example `e.getCause() instanceof IOException`. +- The field `MailerLiteException.message` now has the message of the API error, without the error code. Before, it was always `""`. If the SDK cannot read the error body, or for a network error, it has the same text as `getMessage()`. `getMessage()` does not change. # Usage diff --git a/src/main/java/com/mailerlite/sdk/MailerLiteApi.java b/src/main/java/com/mailerlite/sdk/MailerLiteApi.java index 5146ae5..dca7760 100644 --- a/src/main/java/com/mailerlite/sdk/MailerLiteApi.java +++ b/src/main/java/com/mailerlite/sdk/MailerLiteApi.java @@ -414,7 +414,8 @@ private T handleApiResponse(HttpResponse } responseError = new MailerLiteException(message); - + + responseError.message = error.message == null ? "" : error.message; responseError.errors = error.errors; responseError.errorCode = error.code; responseError.etag = error.etag; diff --git a/src/main/java/com/mailerlite/sdk/exceptions/MailerLiteException.java b/src/main/java/com/mailerlite/sdk/exceptions/MailerLiteException.java index 9fb90b9..b69f5fb 100644 --- a/src/main/java/com/mailerlite/sdk/exceptions/MailerLiteException.java +++ b/src/main/java/com/mailerlite/sdk/exceptions/MailerLiteException.java @@ -23,7 +23,10 @@ public class MailerLiteException extends Exception { /** The code. */ public int code; // the response code - /** The message. */ + /** + * The {@code message} of the API error body, without the error code, or "" if the body has none. + * If the SDK cannot read the error body, or for a network error, the same text as {@link #getMessage()}. + */ public String message = ""; /** The response body. */ @@ -52,6 +55,11 @@ public class MailerLiteException extends Exception { public MailerLiteException(String message) { super(message); + + if (message != null) { + + this.message = message; + } } /** @@ -63,6 +71,11 @@ public MailerLiteException(String message) { public MailerLiteException(String message, Throwable cause) { super(message, cause); + + if (message != null) { + + this.message = message; + } } /** diff --git a/src/test/java/com/mailerlite/sdk/tests/MailerLiteApiTest.java b/src/test/java/com/mailerlite/sdk/tests/MailerLiteApiTest.java index 2de78ee..0df806c 100644 --- a/src/test/java/com/mailerlite/sdk/tests/MailerLiteApiTest.java +++ b/src/test/java/com/mailerlite/sdk/tests/MailerLiteApiTest.java @@ -26,6 +26,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.function.Executable; +import com.mailerlite.sdk.MailerLite; import com.mailerlite.sdk.MailerLiteApi; import com.mailerlite.sdk.MailerLiteResponse; import com.mailerlite.sdk.exceptions.MailerLiteException; @@ -320,4 +321,70 @@ public void interruptWithoutMessageKeepsTheErrorNameAndTheFlag() assertTrue(Thread.interrupted()); } + @Test + public void apiErrorSetsTheMessageField() + { + Map methods = oldRequestMethods(); + methods.put("sendRequest", () -> api().sendRequest("POST", "/emails/123/versions", null, null, MailerLiteResponse.class)); + methods.put("automation activity", () -> { + MailerLite ml = new MailerLite(); + ml.setToken("test-token"); + ml.automations().subscriberActivityRetriever().get("1"); + }); + + for (Map.Entry method : methods.entrySet()) { + + client.respond(404, "{\"message\":\"Resource not found.\"}"); + + MailerLiteException e = assertThrows(MailerLiteException.class, method.getValue(), method.getKey()); + + assertEquals("Resource not found.", e.message, method.getKey()); + assertEquals("Resource not found.", e.getMessage(), method.getKey()); + } + } + + @Test + public void apiErrorWithCodeSetsTheMessageFieldWithoutTheCode() + { + client.respond(409, "{\"message\":\"The email changed.\",\"code\":\"version_conflict\",\"etag\":\"43\"}"); + + MailerLiteException e = assertThrows(MailerLiteException.class, + () -> api().sendRequest("PATCH", "/emails/123/blocks/57", "{}", null, MailerLiteResponse.class)); + + assertEquals("The email changed.", e.message); + assertEquals("version_conflict: The email changed.", e.getMessage()); + } + + @Test + public void apiErrorWithoutMessageKeepsAnEmptyMessageField() + { + client.respond(409, "{\"code\":\"version_conflict\"}").respond(500, "{\"message\":null}"); + + MailerLiteException withCode = assertThrows(MailerLiteException.class, + () -> api().sendRequest("PATCH", "/emails/123/blocks/57", "{}", null, MailerLiteResponse.class)); + MailerLiteException withNull = assertThrows(MailerLiteException.class, + () -> api().getRequest("/groups", MailerLiteResponse.class)); + + assertEquals("", withCode.message); + assertEquals("", withNull.message); + } + + @Test + public void errorWithoutApiMessageSetsTheMessageFieldToGetMessage() + { + client.respond(503, "503"); + + MailerLiteException unreadable = assertThrows(MailerLiteException.class, + () -> api().getRequest("/groups", MailerLiteResponse.class)); + + assertEquals("Error parsing API response", unreadable.message); + + client.fail(new IOException("connection reset")); + + MailerLiteException network = assertThrows(MailerLiteException.class, + () -> api().sendRequest("GET", "/emails/123", null, null, MailerLiteResponse.class)); + + assertEquals("java.io.IOException: connection reset", network.message); + assertEquals(network.getMessage(), network.message); + } }