diff --git a/README.md b/README.md index c3d2123..092b079 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) @@ -40,11 +41,32 @@ 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) - [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) + - [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) + - [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) + - [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) + - [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) + - [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) @@ -67,14 +89,24 @@ 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' +# 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`. +- 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 Start using the sdk by creating an instance of the `MailerLite` object and passing your API token into it like this: @@ -796,6 +828,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 @@ -960,6 +1034,579 @@ class Example { } ``` +## Emails + +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. +- 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. +- 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(); + } + } +} +``` + +### 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(); + } + } +} +``` + +### 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(); + } + } +} +``` + +### 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`. The SDK sends the placement as you give it. + +A `null` block throws `NullPointerException`, and the SDK sends no request. + +```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(); + } + } +} +``` + +### 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(); + } + } +} +``` + +### 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(); + } + } +} +``` + +### 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(); + } + } +} +``` + +### 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. A `null` block sends an empty body. + +```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(); + } + } +} +``` + +### 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. +- 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. +- `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. + +```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(); + } + } +} +``` + +### 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. `null` settings send an empty body. + +```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(); + } + } +} +``` + +### 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(); + } + } +} +``` + +### 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(); + } + } +} +``` + +### 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(); + } + } +} +``` + +### 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(); + } + } +} +``` + +### 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 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 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`. + +```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 + +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(); + } + } +} +``` + +## 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/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 diff --git a/src/main/java/com/mailerlite/sdk/MailerLite.java b/src/main/java/com/mailerlite/sdk/MailerLite.java index 0d98a94..8939902 100644 --- a/src/main/java/com/mailerlite/sdk/MailerLite.java +++ b/src/main/java/com/mailerlite/sdk/MailerLite.java @@ -8,8 +8,11 @@ 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; +import com.mailerlite.sdk.files.MediaFiles; import com.mailerlite.sdk.forms.Forms; import com.mailerlite.sdk.groups.Groups; import com.mailerlite.sdk.segments.Segments; @@ -49,6 +52,15 @@ public class MailerLite /** The forms. */ protected Forms forms; + /** The emails. */ + protected Emails emails; + + /** The brand styles. */ + protected BrandStyles brandStyles; + + /** The files of the media library. */ + protected MediaFiles files; + /** * Campaigns. * @@ -173,6 +185,51 @@ public Forms forms() } + /** + * Emails built with the simple editor. + * + * @return the emails + */ + public Emails emails() + { + if (emails == null) { + emails = new Emails(this); + } + + return emails; + } + + + /** + * Brand styles. + * + * @return the brand styles + */ + public BrandStyles brandStyles() + { + if (brandStyles == null) { + brandStyles = new BrandStyles(this); + } + + return 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/MailerLiteApi.java b/src/main/java/com/mailerlite/sdk/MailerLiteApi.java index 9ebf90e..dca7760 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; @@ -100,9 +101,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 +160,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 +215,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 +269,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 +284,79 @@ 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); + } + + + /** Uses e.toString() as the message, because e.getMessage() can be null. */ + private MailerLiteException networkError(Exception e) + { + if (e instanceof InterruptedException) { + + Thread.currentThread().interrupt(); + } + + return new MailerLiteException(e.toString(), e); + } + + /** * Handles the response from the MailerLite API. It deserializes the JSON response into an object with the given type * @@ -340,9 +406,19 @@ 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.message = error.message == null ? "" : error.message; responseError.errors = error.errors; + responseError.errorCode = error.code; + responseError.etag = error.etag; } catch (Exception ex) { responseError = new MailerLiteException("Error parsing API response"); @@ -366,11 +442,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/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..ce7255f --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/RequestOptions.java @@ -0,0 +1,55 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk; + +/** + * 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 { + + private final String idempotencyKey; + + 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 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 + */ + public static RequestOptions idempotencyKey(String 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/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..13674b3 --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/brandstyles/BrandStyles.java @@ -0,0 +1,44 @@ +/************************************************* + * 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 { + + 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/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/EditorContentAdapter.java b/src/main/java/com/mailerlite/sdk/emails/EditorContentAdapter.java new file mode 100644 index 0000000..a21d07d --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/EditorContentAdapter.java @@ -0,0 +1,84 @@ +/************************************************* + * 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> { + + private static final Gson GSON = new GsonBuilder() + .setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE) + .serializeNulls() + .create(); + + 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/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/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/EmailBlockInput.java b/src/main/java/com/mailerlite/sdk/emails/EmailBlockInput.java new file mode 100644 index 0000000..9d0e159 --- /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. The API needs it. */ + @SerializedName("type") + public String type; + + /** + * 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") + @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/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/EmailBlockPosition.java b/src/main/java/com/mailerlite/sdk/emails/EmailBlockPosition.java new file mode 100644 index 0000000..13aa8c7 --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/EmailBlockPosition.java @@ -0,0 +1,58 @@ +/************************************************* + * 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}. 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. + *

+ * 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; + + /** + * 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..248548b --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/EmailBlockPositions.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 {@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"; + + 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..070acac --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/EmailBlockTypes.java @@ -0,0 +1,57 @@ +/************************************************* + * 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"; + + private EmailBlockTypes() + { + } +} 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..e861abb --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/EmailBrandModes.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 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"; + + 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..8fefa32 --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/EmailBrandSettings.java @@ -0,0 +1,48 @@ +/************************************************* + * 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. + * 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 { + + /** 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..5419db4 --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/EmailButtonSettings.java @@ -0,0 +1,44 @@ +/************************************************* + * 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. + * 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 { + + /** 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..8a2ac82 --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/EmailEditorText.java @@ -0,0 +1,26 @@ +/************************************************* + * 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, or a map with an empty "content" + * list, 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/EmailEditors.java b/src/main/java/com/mailerlite/sdk/emails/EmailEditors.java new file mode 100644 index 0000000..93087dc --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/EmailEditors.java @@ -0,0 +1,21 @@ +/************************************************* + * 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"; + + private EmailEditors() + { + } +} 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..a0ab678 --- /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; + +import com.mailerlite.sdk.exceptions.MailerLiteException; + +/** + * 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 = MailerLiteException.VERSION_CONFLICT; + + private EmailErrorCodes() + { + } +} 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/EmailFooterSettings.java b/src/main/java/com/mailerlite/sdk/emails/EmailFooterSettings.java new file mode 100644 index 0000000..ca51a13 --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/EmailFooterSettings.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.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. To unset companyName, address or infoText, send an + * empty editor document. The email then uses the text of your account. + */ +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..99c5c72 --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/EmailHeadingStyle.java @@ -0,0 +1,48 @@ +/************************************************* + * 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. + * 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 { + + /** 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/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/EmailLayoutSettings.java b/src/main/java/com/mailerlite/sdk/emails/EmailLayoutSettings.java new file mode 100644 index 0000000..ac1e985 --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/EmailLayoutSettings.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 layout of an email. + * + * In an update, null is not sent and does not change the value. + * 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 { + + /** 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..f5af2da --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/EmailLinkStyle.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 link style of an email. + * + * In an update, null is not sent and does not change the value. + * 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 { + + /** 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..b433406 --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/EmailParagraphStyle.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 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. + * 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 { + + /** 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/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/EmailSettings.java b/src/main/java/com/mailerlite/sdk/emails/EmailSettings.java new file mode 100644 index 0000000..5a2365e --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/EmailSettings.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 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. + * 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. + */ +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/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/EmailTextStyle.java b/src/main/java/com/mailerlite/sdk/emails/EmailTextStyle.java new file mode 100644 index 0000000..bc96149 --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/EmailTextStyle.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 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. + * 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 { + + /** 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/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/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..78df826 --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/EmailVersionsRetriever.java @@ -0,0 +1,60 @@ +/************************************************* + * 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 { + + private MailerLite apiObjectReference; + + private String endpoint; + + 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 new file mode 100644 index 0000000..c0aebb6 --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/emails/Emails.java @@ -0,0 +1,576 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.emails; + +import java.util.Collections; +import java.util.Map; +import java.util.Objects; + +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; +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; + +/** + * 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 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}. 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. + */ +public class Emails { + + private static final Gson GSON = new GsonBuilder().disableHtmlEscaping().create(); + + /** 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(); + + 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); + } + + /** + * 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); + } + + /** + * 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); + } + + /** + * 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 is null + */ + 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 is null + */ + public SingleEmailBlock createBlock(String emailId, String etag, EmailBlockInput block, RequestOptions options) + throws MailerLiteException + { + String endpoint = emailPath(emailId).concat("/blocks"); + BlockBody body = blockBody(Objects.requireNonNull(block, "block"), etag); + + return send("POST", endpoint, toJson(body), options, 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 + */ + 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 + */ + public SingleEmailBlock updateBlock(String emailId, String blockId, String etag, Map editorContent, + RequestOptions options) + throws MailerLiteException + { + String endpoint = blockPath(emailId, blockId); + + UpdateBlockBody body = new UpdateBlockBody(); + body.etag = etag; + body.editorContent = editorContent; + + return send("PATCH", endpoint, toJson(body), options, 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 + { + 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), options, 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. + * The SDK sends the placement as you give it. + * + * @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 + */ + 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. + * The SDK sends the placement as you give it. + * + * @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 + */ + public SingleEmailBlock moveBlock(String emailId, String blockId, String etag, EmailBlockPosition placement, + RequestOptions options) + throws MailerLiteException + { + String endpoint = blockPath(emailId, blockId).concat("/move"); + + MoveBlockBody body = new MoveBlockBody(); + body.etag = etag; + body.placement = placement; + + return send("POST", endpoint, toJson(body), options, 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. 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 + */ + public EmailValidation validateBlock(EmailBlockInput block) throws MailerLiteException + { + String body = block == null ? "{}" : toJson(blockBody(block, null)); + + return send("POST", "/emails/blocks/validate", body, 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. 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. + * + * @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 + { + 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. 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. + * + * @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"); + + 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); + } + + /** + * 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. Null settings send an empty body. + * @return the result of the validation + * @throws MailerLiteException if the API returns an error or the request fails + */ + public EmailValidation validateSettings(String emailId, EmailSettings settings) throws MailerLiteException + { + String endpoint = emailPath(emailId).concat("/settings/validate"); + + String body = settings == null ? "{}" : TREE_GSON.toJson(settingsBody(settings)); + + return send("POST", endpoint, body, 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 + */ + 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 + */ + public SingleEmailTemplate createTemplate(String emailId, String name, RequestOptions options) + throws MailerLiteException + { + String endpoint = emailPath(emailId).concat("/templates"); + + TemplateBody body = new TemplateBody(); + body.name = name; + + return send("POST", endpoint, toJson(body), options, 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")); + } + + /** + * 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. + * + * @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 createVersion(emailId, null); + } + + /** + * 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. + * + * @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); + } + + /** + * 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 + { + 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), options, SingleEmailDocument.class); + } + + private static String emailPath(String emailId) + { + return "/emails/".concat(RequestChecks.pathId(emailId, "emailId")); + } + + private static String blockPath(String emailId, String blockId) + { + return emailPath(emailId).concat("/blocks/").concat(RequestChecks.pathId(blockId, "blockId")); + } + + private static BlockBody blockBody(EmailBlockInput block, String etag) + { + BlockBody body = new BlockBody(); + body.type = block.type; + body.editorContent = block.editorContent; + body.placement = block.placement; + body.etag = etag; + + return body; + } + + private static String etagBody(String etag) + { + EtagBody body = new EtagBody(); + body.etag = etag; + + return toJson(body); + } + + private static JsonObject settingsBody(EmailSettings settings) + { + return GSON.toJsonTree(settings).getAsJsonObject(); + } + + private static String toJson(Object body) + { + return GSON.toJson(body); + } + + private T send(String method, String endpoint, String body, Class responseClass) + throws MailerLiteException + { + return send(method, endpoint, body, null, responseClass); + } + + private T send(String method, String endpoint, String body, RequestOptions options, + Class responseClass) throws MailerLiteException + { + Map headers = null; + + String key = options == null ? null : RequestChecks.idempotencyKey(options.getIdempotencyKey()); + + if (key != null && !key.isEmpty()) { + headers = Collections.singletonMap("Idempotency-Key", key); + } + + MailerLiteApi api = new MailerLiteApi(); + api.setToken(apiObjectReference.getToken()); + + return api.sendRequest(method, endpoint, body, headers, responseClass); + } + + private static class BlockBody { + + @SerializedName("type") + String type; + + @SerializedName("editor_content") + @JsonAdapter(EditorContentAdapter.class) + Map editorContent; + + @SerializedName("placement") + EmailBlockPosition placement; + + @SerializedName("etag") + String etag; + } + + private static class UpdateBlockBody { + + @SerializedName("editor_content") + @JsonAdapter(EditorContentAdapter.class) + Map editorContent; + + @SerializedName("etag") + String etag; + } + + private static class EtagBody { + + @SerializedName("etag") + String etag; + } + + private static class MoveBlockBody { + + @SerializedName("placement") + EmailBlockPosition placement; + + @SerializedName("etag") + String etag; + } + + private static class TemplateBody { + + @SerializedName("name") + String name; + } +} 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/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/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/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/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/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/main/java/com/mailerlite/sdk/exceptions/EmptyIdException.java b/src/main/java/com/mailerlite/sdk/exceptions/EmptyIdException.java new file mode 100644 index 0000000..7fd78b1 --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/exceptions/EmptyIdException.java @@ -0,0 +1,27 @@ +/************************************************* + * 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 { + + 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..9041517 --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/exceptions/InvalidIdException.java @@ -0,0 +1,27 @@ +/************************************************* + * 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 { + + 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..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/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 69310df..b69f5fb 100644 --- a/src/main/java/com/mailerlite/sdk/exceptions/MailerLiteException.java +++ b/src/main/java/com/mailerlite/sdk/exceptions/MailerLiteException.java @@ -14,10 +14,19 @@ */ 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 - /** 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. */ @@ -26,6 +35,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.

* @@ -34,5 +55,37 @@ public class MailerLiteException extends Exception { public MailerLiteException(String message) { super(message); + + if (message != null) { + + this.message = 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); + + if (message != null) { + + this.message = message; + } + } + + /** + * 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 #VERSION_CONFLICT} + */ + public boolean isVersionConflict() { + + return VERSION_CONFLICT.equals(this.errorCode); } } 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..359fa3d --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/files/MediaFileRetriever.java @@ -0,0 +1,114 @@ +/************************************************* + * 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 { + + 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..0dc3608 --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/files/MediaFileSorts.java @@ -0,0 +1,36 @@ +/************************************************* + * 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"; + + 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..c102499 --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/files/MediaFileTypes.java @@ -0,0 +1,28 @@ +/************************************************* + * 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"; + + 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..ad4d948 --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/files/MediaFiles.java @@ -0,0 +1,38 @@ +/************************************************* + * 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 { + + 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/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/main/java/com/mailerlite/sdk/util/RequestChecks.java b/src/main/java/com/mailerlite/sdk/util/RequestChecks.java new file mode 100644 index 0000000..f7de987 --- /dev/null +++ b/src/main/java/com/mailerlite/sdk/util/RequestChecks.java @@ -0,0 +1,83 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.util; + +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 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 { + + private RequestChecks() + { + } + + /** + * 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. + * + * @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 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/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/support/Fixtures.java b/src/test/java/com/mailerlite/sdk/support/Fixtures.java new file mode 100644 index 0000000..e2e816c --- /dev/null +++ b/src/test/java/com/mailerlite/sdk/support/Fixtures.java @@ -0,0 +1,35 @@ +/************************************************* + * 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; + +public final class Fixtures { + + private Fixtures() + { + } + + 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/ReadmeExampleBase.java b/src/test/java/com/mailerlite/sdk/support/ReadmeExampleBase.java new file mode 100644 index 0000000..58514bf --- /dev/null +++ b/src/test/java/com/mailerlite/sdk/support/ReadmeExampleBase.java @@ -0,0 +1,21 @@ +/************************************************* + * MailerLite Java SDK + * https://github.com/mailerlite/mailerlite-java + * + * @author MailerLite + * https://mailerlite.com + **************************************************/ +package com.mailerlite.sdk.support; + +import com.mailerlite.sdk.MailerLite; + +public class ReadmeExampleBase { + + public MailerLite getMailerLite() + { + MailerLite ml = new MailerLite(); + ml.setToken("test-token"); + + return ml; + } +} 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..aed59bd --- /dev/null +++ b/src/test/java/com/mailerlite/sdk/support/RecordingHttpClient.java @@ -0,0 +1,398 @@ +/************************************************* + * 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; + +public class RecordingHttpClient extends HttpClient { + + private final List requests = new ArrayList(); + + private final Deque responses = new ArrayDeque(); + + public static RecordingHttpClient install() + { + RecordingHttpClient client = new RecordingHttpClient(); + MailerLiteHttpClientFactory.getInstance().setClient(client); + + return client; + } + + public static void uninstall() + { + MailerLiteHttpClientFactory.getInstance().setClient(null); + } + + public RecordingHttpClient respond(int status, String body) + { + return respond(status, body, Collections.emptyMap()); + } + + 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; + } + + /** The error must be an IOException or an InterruptedException. */ + public RecordingHttpClient fail(Exception error) + { + responses.add(error); + + return this; + } + + public List requests() + { + return requests; + } + + public RecordedRequest lastRequest() + { + if (requests.isEmpty()) { + throw new AssertionError("The client got no request"); + } + + return requests.get(requests.size() - 1); + } + + public int pendingResponses() + { + return responses.size(); + } + + @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)); + } + + 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() + { + } + }); + + subscriber.onNext(Collections.singletonList(ByteBuffer.wrap(queued.body.getBytes(StandardCharsets.UTF_8)))); + subscriber.onComplete(); + + return subscriber.getBody().toCompletableFuture().join(); + } + + @Override + public CompletableFuture> sendAsync(HttpRequest request, BodyHandler responseBodyHandler) + { + throw new UnsupportedOperationException("RecordingHttpClient supports only send()"); + } + + @Override + public CompletableFuture> sendAsync(HttpRequest request, BodyHandler responseBodyHandler, + PushPromiseHandler pushPromiseHandler) + { + throw new UnsupportedOperationException("RecordingHttpClient supports only send()"); + } + + @Override + public Optional cookieHandler() + { + return Optional.empty(); + } + + @Override + public Optional connectTimeout() + { + return Optional.empty(); + } + + @Override + public Redirect followRedirects() + { + return Redirect.NEVER; + } + + @Override + public Optional proxy() + { + return Optional.empty(); + } + + @Override + public SSLContext sslContext() + { + return null; + } + + @Override + public SSLParameters sslParameters() + { + return null; + } + + @Override + public Optional authenticator() + { + return Optional.empty(); + } + + @Override + public Version version() + { + return Version.HTTP_1_1; + } + + @Override + public Optional executor() + { + return Optional.empty(); + } + + public static class RecordedRequest { + + public final String method; + + public final URI uri; + + 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; + + public final byte[] body; + + 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]); + } + + public String bodyString() + { + return new String(body, StandardCharsets.UTF_8); + } + + public String header(String name) + { + return headers.firstValue(name).orElse(null); + } + + 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(); + } + } + + private static class QueuedResponse { + + final int status; + + final String body; + + final HttpHeaders headers; + + QueuedResponse(int status, String body, HttpHeaders headers) + { + this.status = status; + this.body = body; + this.headers = headers; + } + } + + private static class FakeResponse implements HttpResponse { + + private final HttpRequest request; + + private final QueuedResponse queued; + + private final T 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/support/UrlAssert.java b/src/test/java/com/mailerlite/sdk/support/UrlAssert.java new file mode 100644 index 0000000..c0e0990 --- /dev/null +++ b/src/test/java/com/mailerlite/sdk/support/UrlAssert.java @@ -0,0 +1,49 @@ +/************************************************* + * 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; + +public final class UrlAssert { + + private UrlAssert() + { + } + + public static boolean sameUrl(String expected, String actual) + { + return normalize(expected).equals(normalize(actual)); + } + + 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)"); + } + } + + /** 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('?'); + + 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/ApiRequestTest.java b/src/test/java/com/mailerlite/sdk/tests/ApiRequestTest.java new file mode 100644 index 0000000..f02c789 --- /dev/null +++ b/src/test/java/com/mailerlite/sdk/tests/ApiRequestTest.java @@ -0,0 +1,95 @@ +/************************************************* + * 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; + +public class ApiRequestTest { + + @Test + public void skipsNullValues() + { + ApiRequest request = new ApiRequest(); + request.addQueryParameter("filter[type]", null); + request.addQueryParameter("page", "2"); + + assertEquals("?page=2", request.getQueryParameters()); + } + + @Test + public void nullValueRemovesTheEarlierValue() + { + ApiRequest request = new ApiRequest(); + request.addQueryParameter("filter[type]", "image"); + request.addQueryParameter("filter[type]", null); + + assertEquals("?", request.getQueryParameters()); + } + + @Test + public void encodesSpacesInValues() + { + ApiRequest request = new ApiRequest(); + request.addQueryParameter("filter[name]", "summer sale banner"); + + assertEquals("?filter[name]=summer+sale+banner", request.getQueryParameters()); + } + + @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()); + } + + @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()); + } + + @Test + public void encodesAnEncodedValueAgain() + { + ApiRequest request = new ApiRequest(); + request.addQueryParameter("filter[name]", "summer%20sale"); + + assertEquals("?filter[name]=summer%2520sale", request.getQueryParameters()); + } + + @Test + public void sendsPlusAsLiteralPlus() + { + ApiRequest request = new ApiRequest(); + request.addQueryParameter("filter[name]", "a+b"); + + assertEquals("?filter[name]=a%2Bb", request.getQueryParameters()); + } + +} 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..8f8a276 --- /dev/null +++ b/src/test/java/com/mailerlite/sdk/tests/BrandStylesTest.java @@ -0,0 +1,87 @@ +/************************************************* + * 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; + +public class BrandStylesTest { + + private RecordingHttpClient client; + + private MailerLite ml; + + @BeforeEach + public void setupEach() + { + client = RecordingHttpClient.install(); + + ml = new MailerLite(); + ml.setToken("test-token"); + } + + @AfterEach + public void afterEach() + { + RecordingHttpClient.uninstall(); + } + + @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); + } + + @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/CampaignSimpleEditorTest.java b/src/test/java/com/mailerlite/sdk/tests/CampaignSimpleEditorTest.java new file mode 100644 index 0000000..42b2e3d --- /dev/null +++ b/src/test/java/com/mailerlite/sdk/tests/CampaignSimpleEditorTest.java @@ -0,0 +1,92 @@ +/************************************************* + * 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; + +public class CampaignSimpleEditorTest { + + private RecordingHttpClient client; + + private MailerLite ml; + + @BeforeEach + public void setupEach() + { + client = RecordingHttpClient.install(); + client.respond(201, Fixtures.read("campaign_simple_editor.json")); + + ml = new MailerLite(); + ml.setToken("test-token"); + } + + @AfterEach + public void afterEach() + { + RecordingHttpClient.uninstall(); + } + + private EmailBase email() + { + EmailBase email = new EmailBase(); + email.from = "sender@example.com"; + email.fromName = "Sender"; + email.subject = "Hello"; + + return email; + } + + @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()); + } + + @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/EmailsTest.java b/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java new file mode 100644 index 0000000..ed78be7 --- /dev/null +++ b/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java @@ -0,0 +1,951 @@ +/************************************************* + * 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.assertNull; +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; +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.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.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.EmailLayoutSettings; +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.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; +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; +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; +import com.mailerlite.sdk.support.UrlAssert; + +public class EmailsTest { + + static final String BASE = "https://connect.mailerlite.com/api"; + + private RecordingHttpClient client; + + private Emails emails; + + @BeforeEach + public void setupEach() + { + client = RecordingHttpClient.install(); + + MailerLite ml = new MailerLite(); + ml.setToken("test-token"); + emails = ml.emails(); + } + + @AfterEach + public void afterEach() + { + RecordingHttpClient.uninstall(); + } + + 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); + UrlAssert.assertUrlEquals(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; + } + + 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"); + } + + @SuppressWarnings("unchecked") + static Map map(Map map, String key) + { + return (Map) map.get(key); + } + + @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); + } + + @Test + public void getRejectsInvalidIds() + { + assertRejectsIds(client, id -> () -> emails.get(id)); + } + + @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); + } + + @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); + } + + @Test + public void getHtmlRejectsInvalidIds() + { + assertRejectsIds(client, id -> () -> emails.getHtml(id)); + } + + + @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); + } + + @Test + public void getBlockRejectsInvalidIds() + { + assertRejectsIds(client, id -> () -> emails.getBlock(id, "57")); + assertRejectsIds(client, id -> () -> emails.getBlock("123", id)); + } + + + 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; + } + + /** + * 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 + { + 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"); + } + + /** + * 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, 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) { + + client.requests().clear(); + client.respond(200, response); + + 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"); + } + } + + @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); + } + + @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()); + } + + @Test + public void createBlockSendsThePlacementAsGiven() throws Throwable + { + 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); + }, json -> "{\"type\":\"divider\",\"editor_content\":{},\"placement\":" + json + ",\"etag\":\"42\"}"); + } + + @Test + public void createBlockRejectsInvalidInput() + { + EmailBlockInput divider = new EmailBlockInput(EmailBlockTypes.DIVIDER, new LinkedHashMap()); + + assertRejectsIds(client, id -> () -> emails.createBlock(id, "42", divider)); + + NullPointerException noBlock = assertThrows(NullPointerException.class, () -> emails.createBlock("123", "42", null)); + assertTrue(noBlock.getMessage().contains("block"), noBlock.getMessage()); + + 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()))); + + 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")); + + 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()); + } + + + @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); + } + + @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()); + } + + @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)); + } + + @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); + } + + + @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); + } + + @Test + public void deleteBlockRejectsInvalidInput() + { + assertRejectsIds(client, id -> () -> emails.deleteBlock(id, "57", "44")); + assertRejectsIds(client, id -> () -> emails.deleteBlock("123", id, "44")); + } + + @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)) + "}"); + } + + + @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); + } + + @Test + public void moveBlockSendsThePlacementAsGiven() throws Throwable + { + assertSendsThePlacement(client, Fixtures.read("email_block_moved.json"), + placement -> () -> emails.moveBlock("123", "57", "45", placement), + json -> "{\"placement\":" + json + ",\"etag\":\"45\"}"); + } + + @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)); + } + + @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); + } + + + @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); + } + + @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")); + } + + @Test + public void validateBlockSendsThePlacementAsGiven() throws Throwable + { + assertSendsThePlacement(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 + "}"); + } + + @Test + public void validateBlockSendsAnInvalidBlock() throws Exception + { + 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()); + } + + + 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; + } + + @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); + } + + @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()); + } + + @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()); + } + + @Test + public void updateSettingsRejectsInvalidInput() + { + EmailSettings settings = brandAndParagraphSettings(); + + assertRejectsIds(client, id -> () -> emails.updateSettings(id, "46", 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"); + } + + + @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 + { + 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); + } + + @Test + public void validateSettingsRejectsInvalidInput() + { + assertRejectsIds(client, id -> () -> emails.validateSettings(id, brandAndParagraphSettings())); + } + + @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); + } + + + @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); + } + + @Test + public void createTemplateRejectsInvalidInput() + { + assertRejectsIds(client, id -> () -> emails.createTemplate(id, "Newsletter")); + } + + @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()); + } + + + @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); + } + + @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?page=2&limit=10", null, null); + } + + @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); + } + + @Test + public void versionsRejectsInvalidIds() + { + assertRejectsIds(client, id -> () -> emails.versions(id).get()); + } + + + @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); + } + + @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); + } + + @Test + public void createVersionRejectsInvalidIds() + { + assertRejectsIds(client, id -> () -> emails.createVersion(id)); + } + + + @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); + } + + @Test + public void restoreVersionRejectsInvalidInput() + { + assertRejectsIds(client, id -> () -> emails.restoreVersion(id, "901", "47")); + assertRejectsIds(client, id -> () -> emails.restoreVersion("123", id, "47")); + } + + @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 new file mode 100644 index 0000000..22e3b41 --- /dev/null +++ b/src/test/java/com/mailerlite/sdk/tests/IdempotencyTest.java @@ -0,0 +1,222 @@ +/************************************************* + * 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.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; + +public class IdempotencyTest { + + private RecordingHttpClient client; + + private Emails emails; + + @BeforeEach + public void setupEach() + { + client = RecordingHttpClient.install(); + + MailerLite ml = new MailerLite(); + ml.setToken("test-token"); + emails = ml.emails(); + } + + @AfterEach + public void afterEach() + { + RecordingHttpClient.uninstall(); + } + + 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; + } + + @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); + } + } + + @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")); + } + + /** + * 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 ", "k ~k", 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")); + } + + /** + * 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 keysOutsidePrintableAsciiAreRejected() + { + 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); + + 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()); + } + + @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()); + } + + @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/MailerLiteApiTest.java b/src/test/java/com/mailerlite/sdk/tests/MailerLiteApiTest.java new file mode 100644 index 0000000..0df806c --- /dev/null +++ b/src/test/java/com/mailerlite/sdk/tests/MailerLiteApiTest.java @@ -0,0 +1,390 @@ +/************************************************* + * 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.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.net.ConnectException; +import java.util.Collections; +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.MailerLite; +import com.mailerlite.sdk.MailerLiteApi; +import com.mailerlite.sdk.MailerLiteResponse; +import com.mailerlite.sdk.exceptions.MailerLiteException; +import com.mailerlite.sdk.support.RecordingHttpClient; +import com.mailerlite.sdk.support.RecordingHttpClient.RecordedRequest; + +public class MailerLiteApiTest { + + private RecordingHttpClient client; + + @BeforeEach + public void setupEach() + { + client = RecordingHttpClient.install(); + } + + @AfterEach + public void afterEach() + { + RecordingHttpClient.uninstall(); + Thread.interrupted(); + } + + private MailerLiteApi api() + { + MailerLiteApi api = new MailerLiteApi(); + api.setToken("test-token"); + + return api; + } + + 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; + } + + @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("java.io.IOException: connection reset", e.getMessage(), method.getKey()); + } + } + + @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()); + } + } + + @Test + public void emptyBodyForResponseWithoutDefaultConstructorBecomesMailerLiteException() + { + client.respond(200, ""); + + MailerLiteException e = assertThrows(MailerLiteException.class, + () -> api().getRequest("/test", NoDefaultConstructorResponse.class)); + + assertTrue(e.getCause() instanceof ReflectiveOperationException); + } + + 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")); + } + + @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); + } + + @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")); + } + + @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); + } + + @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); + } + + @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()); + } + + @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()); + } + + @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()); + } + + @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")); + } + + @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); + } + + @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()); + } + + public static class NoDefaultConstructorResponse extends MailerLiteResponse { + + public NoDefaultConstructorResponse(int value) + { + } + } + + @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()); + } + + @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()); + } + + @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); + } +} 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()); + } +} 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..0c5ab00 --- /dev/null +++ b/src/test/java/com/mailerlite/sdk/tests/MediaFilesTest.java @@ -0,0 +1,104 @@ +/************************************************* + * 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; + +public class MediaFilesTest { + + private RecordingHttpClient client; + + private MailerLite ml; + + @BeforeEach + public void setupEach() + { + client = RecordingHttpClient.install(); + client.respond(200, Fixtures.read("files.json")); + + ml = new MailerLite(); + ml.setToken("test-token"); + } + + @AfterEach + public void afterEach() + { + RecordingHttpClient.uninstall(); + } + + @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); + } + + @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); + } + + @Test + public void emptyAndNullFilterValues() throws Exception + { + ml.files().retriever().nameFilter("").typeFilter(null).get(); + + EmailsTest.assertRequest(client, "GET", "/files?filter[name]=", null, null); + } + + @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&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 new file mode 100644 index 0000000..af7bc44 --- /dev/null +++ b/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java @@ -0,0 +1,342 @@ +/************************************************* + * 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.io.IOException; +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; +import com.mailerlite.sdk.support.UrlAssert; + +public class ReadmeExamplesTest { + + private static final String BASE = "https://connect.mailerlite.com/api"; + + /** 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.*", + "com.mailerlite.sdk.campaigns.*", + "com.mailerlite.sdk.emails.*", + "com.mailerlite.sdk.exceptions.*", + "com.mailerlite.sdk.files.*", + "java.io.*", + "java.util.*"); + + private static class Example { + + final String heading; + + final List responses = new ArrayList(); + + final List requests = new ArrayList(); + + Example(String heading) + { + this.heading = heading; + } + + Example respond(int status, String body) + { + return respond(status, body, Collections.emptyMap()); + } + + Example respond(int status, String body, Map headers) + { + responses.add(new Object[] {status, body, headers}); + return this; + } + + Example fail(Exception error) + { + responses.add(new Object[] {error}); + return this; + } + + Example expect(String request) + { + requests.add(request); + return this; + } + } + + 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")); + + list.add(new Example("Get the HTML of an email") + .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")); + + 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")); + + 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")); + + list.add(new Example("Delete a block") + .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")); + + list.add(new Example("Validate a block") + .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")); + + list.add(new Example("Validate the design settings") + .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")); + + list.add(new Example("List the versions of an email") + .respond(200, Fixtures.read("email_versions.json")) + .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")) + .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")); + + list.add(new Example("Get the brand styles") + .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[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")) + .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; + } + + @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; + } + + 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.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(); + } + } + + 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"); + } + + 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(); + } + + 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()); + } + } + } + + 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 new file mode 100644 index 0000000..dd4ddf3 --- /dev/null +++ b/src/test/java/com/mailerlite/sdk/tests/RecordingHttpClientTest.java @@ -0,0 +1,93 @@ +/************************************************* + * 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; + +public class RecordingHttpClientTest { + + private static final URI TEST_URI = URI.create("https://connect.mailerlite.com/api/test"); + + @AfterEach + public void afterEach() + { + RecordingHttpClient.uninstall(); + } + + @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()); + } + + @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); + } + + @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()); + } +} 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..2a080c5 --- /dev/null +++ b/src/test/java/com/mailerlite/sdk/tests/RequestChecksTest.java @@ -0,0 +1,128 @@ +/************************************************* + * 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.EmptyIdException; +import com.mailerlite.sdk.exceptions.InvalidIdException; +import com.mailerlite.sdk.util.RequestChecks; + +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 String repeat(char c, int count) + { + char[] chars = new char[count]; + Arrays.fill(chars, c); + + return new String(chars); + } + + @Test + public void pathIdAcceptsAsciiDigits() + { + assertEquals("123", RequestChecks.pathId("123", "emailId")); + assertEquals("0", RequestChecks.pathId("0", "emailId")); + } + + @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); + } + + @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; + } + + public static class Holder { + + @SerializedName("editor_content") + @JsonAdapter(EditorContentAdapter.class) + public Map editorContent; + + @SerializedName("other") + public String other; + } + + @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)); + } + + @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")); + } +} 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..40fd7f0 --- /dev/null +++ b/src/test/java/com/mailerlite/sdk/tests/UrlAssertTest.java @@ -0,0 +1,48 @@ +/************************************************* + * 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; + +public class UrlAssertTest { + + @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"); + } + + @Test + public void keepsDuplicateParameters() + { + assertFalse(UrlAssert.sameUrl("/files?a=1", "/files?a=1&a=1")); + } + + @Test + public void comparesTheRawValues() + { + assertFalse(UrlAssert.sameUrl("/files?a=1", "/files?a=2")); + assertFalse(UrlAssert.sameUrl("/files?a=a+b", "/files?a=a%20b")); + } + + @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")); + } +} 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" + } + ] + } +} 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 + } + ] + } +} 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" +} 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" +} 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" +} 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" +} 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" +} 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"}} 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"} 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"} 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" + ] + } +} 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"] + } +} 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"}} 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}} 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 + } +} 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 + } +}