From 341b513fe3fe67154de5227ccd88e290f9d6dcff Mon Sep 17 00:00:00 2001 From: Nikola Begedin Date: Tue, 3 Jul 2018 15:56:20 +0200 Subject: [PATCH 1/4] Add Template.create, Template.update --- lib/template.ex | 99 +++++++++++++++++++++++++++++------ lib/template/options.ex | 16 ++++++ lib/template/response.ex | 12 +++++ test/data/createtemplate.json | 5 ++ test/data/updatetemplate.json | 5 ++ test/template_test.exs | 70 +++++++++++++++++++++++-- 6 files changed, 188 insertions(+), 19 deletions(-) create mode 100644 lib/template/options.ex create mode 100644 lib/template/response.ex create mode 100644 test/data/createtemplate.json create mode 100644 test/data/updatetemplate.json diff --git a/lib/template.ex b/lib/template.ex index 4c0b529..c060b07 100644 --- a/lib/template.ex +++ b/lib/template.ex @@ -6,17 +6,29 @@ defmodule SparkPost.Template do Check out the documentation for each function or use the [SparkPost API reference](https://developers.sparkpost.com/api/templates.html) for details. - Returned by `SparkPost.template.preview/2`. - - from - - email - - name - - subject - - reply_to - - text - - html - - headers + ## Struct Fields + + - id: Template identifier, auto-generated if not provided upon create. + - name: Editable template display name, auto-generated if not provided. At minimum, `:name` or `:id` is required, but not both + - content: Content that will be used to construct a message. Can be a `%SparkPost.Content.Inline` or a `%SparkPost.Content.Raw{}` + - published: Boolean indicating the published/draft state of the template. Defaults to false + - description: Detailed description of the template + - options: A `%SparkPost.Transmission.Options{}` struzct, but only `:open_tracking`, `:click_tracking` and `:transactional` are accepted when working with a template. + - shared_with_subaccounts: boolean indicating if the template is accessible to subaccounts. Defaults to false. + - has_draft: Read-only. Indicates if template has a draft version. + - has_published: Read-only. Indicates if template has a published version. """ + defstruct id: nil, + name: nil, + content: %SparkPost.Content.Inline{}, + published: false, + description: nil, + options: %SparkPost.Transmission.Options{}, + shared_with_subaccounts: false, + has_draft: nil, + has_published: nil + alias SparkPost.Endpoint @doc """ @@ -31,17 +43,74 @@ defmodule SparkPost.Template do - substitution_data: k,v map consisting of substituions. See the [SparkPost Substitutions Reference](https://developers.sparkpost.com/api/substitutions-reference.html) for more details. + + Response is a `%SparkPost.Content.Inline{}` consisting of + - from + - email + - name + - subject + - reply_to + - text + - html + - headers """ def preview(%SparkPost.Content.TemplateRef{} = template, substitution_data) do - qs = if is_nil(template.use_draft_template) do - "" - else - "?draft=#{template.use_draft_template}" - end + qs = + if is_nil(template.use_draft_template) do + "" + else + "?draft=#{template.use_draft_template}" + end + body = %{substitution_data: substitution_data} + :post |> Endpoint.request("templates/#{template.template_id}/preview#{qs}", body) |> Endpoint.marshal_response(SparkPost.Content.Inline) - |> SparkPost.Content.Inline.convert_from_field + |> SparkPost.Content.Inline.convert_from_field() + end + + @doc """ + Create a SparkPost Template + + ## Parameters + + - `%SparkPost.Template{}` + + ## Response + + - `%SparkPost.Template.Response{}` + """ + def create(%__MODULE__{} = template) do + :post + |> Endpoint.request("templates", template) + |> Endpoint.marshal_response(SparkPost.Template.Response) + end + + @doc """ + Update a SparkPost Template + + ## Parameters + + - `%SparkPost.Template{}` containing a valid `:id` as well as the updated content + - optional keyword list as a second argument, supporting the fields + - `:update_published` - defaults to false, specifies if the published version of the template should be directly updated, instead of storing the update as a draft + + ## Note on `:update_published` option, vs `:published` struct field + + Setting `published: true` on the struct itself performs the act of publishing a draft template. If the field is set to + `true`, the `:update_published` option is ingored completely. + """ + def update(%__MODULE{id: template_id, published: published} = template, options \\ [update_published: false]) do + qs = + if published != true && Keyword.get(options, :update_published, false) == true do + "?update_published=true" + else + "" + end + + :put + |> Endpoint.request("templates/#{template_id}#{qs}", template) + |> Endpoint.marshal_response(SparkPost.Template.Response) end end diff --git a/lib/template/options.ex b/lib/template/options.ex new file mode 100644 index 0000000..498280d --- /dev/null +++ b/lib/template/options.ex @@ -0,0 +1,16 @@ +defmodule SparkPost.Template.Options do + @moduledoc """ + Template options. + + Designed for use in `%SparkPost.Content.Template{options: ...}` + + ## Fields + - open_tracking: enable 'email open' tracking? + - click_tracking: enable 'link click' tracking? + - transactional: is this a transactional message? + """ + + defstruct open_tracking: true, + click_tracking: true, + transactional: nil +end diff --git a/lib/template/response.ex b/lib/template/response.ex new file mode 100644 index 0000000..9debc94 --- /dev/null +++ b/lib/template/response.ex @@ -0,0 +1,12 @@ +defmodule SparkPost.Template.Response do + @moduledoc """ + The response generated when SparkPost receives a Template request. + + Returned by `SparkPost.Template.create/1` + + ## Fields + - id: Unique id of the template, generated automatically or specified as part of the original request + """ + + defstruct id: nil +end diff --git a/test/data/createtemplate.json b/test/data/createtemplate.json new file mode 100644 index 0000000..9e91f98 --- /dev/null +++ b/test/data/createtemplate.json @@ -0,0 +1,5 @@ +{ + "results": { + "id": "TEMPLATE_ID" + } +} diff --git a/test/data/updatetemplate.json b/test/data/updatetemplate.json new file mode 100644 index 0000000..9e91f98 --- /dev/null +++ b/test/data/updatetemplate.json @@ -0,0 +1,5 @@ +{ + "results": { + "id": "TEMPLATE_ID" + } +} diff --git a/test/template_test.exs b/test/template_test.exs index e2477ea..0ec8854 100644 --- a/test/template_test.exs +++ b/test/template_test.exs @@ -8,6 +8,10 @@ defmodule SparkPost.TemplateTest do defmodule TestStruct do def basic_template do + %SparkPost.Template{id: "TEMPLATE_ID"} + end + + def basic_template_ref do %TemplateRef{template_id: "TEMPLATE_ID", use_draft_template: nil} end @@ -31,7 +35,7 @@ defmodule SparkPost.TemplateTest do fun = MockServer.mk_http_resp(200, MockServer.get_json("previewtemplate")) fun.(method, url, body, headers, opts) end] do - resp = Template.preview(TestStruct.basic_template(), TestStruct.substitution_data()) + resp = Template.preview(TestStruct.basic_template_ref(), TestStruct.substitution_data()) assert %Inline{} = resp end @@ -48,7 +52,7 @@ defmodule SparkPost.TemplateTest do test_with_mock "Template.preview fails with Endpoint.Error", HTTPoison, [request: MockServer.mk_fail] do - resp = Template.preview(TestStruct.basic_template(), TestStruct.substitution_data()) + resp = Template.preview(TestStruct.basic_template_ref(), TestStruct.substitution_data()) assert %Endpoint.Error{} = resp end @@ -58,7 +62,7 @@ defmodule SparkPost.TemplateTest do fun = MockServer.mk_http_resp(200, MockServer.get_json("previewtemplate")) fun.(method, url, body, headers, opts) end] do - resp = Template.preview(TestStruct.basic_template(), TestStruct.substitution_data()) + resp = Template.preview(TestStruct.basic_template_ref(), TestStruct.substitution_data()) assert %SparkPost.Address{ name: "Example Company Marketing", "email": "marketing@bounces.company.example" @@ -71,10 +75,68 @@ defmodule SparkPost.TemplateTest do fun = MockServer.mk_http_resp(200, MockServer.get_json("previewtemplate_simpleemail")) fun.(method, url, body, headers, opts) end] do - resp = Template.preview(TestStruct.basic_template(), TestStruct.substitution_data()) + resp = Template.preview(TestStruct.basic_template_ref(), TestStruct.substitution_data()) assert %SparkPost.Address{ name: nil, "email": "marketing@bounces.company.example" } == resp.from end + + test_with_mock "Template.create succeeds with Template.Response", HTTPoison, + [request: fn (method, url, body, headers, opts) -> + assert method == :post + assert url =~ "/templates" + fun = MockServer.mk_http_resp(200, MockServer.get_json("createtemplate")) + fun.(method, url, body, headers, opts) + end] do + assert Template.create(TestStruct.basic_template()) == + %SparkPost.Template.Response{id: "TEMPLATE_ID"} + end + + test_with_mock "Template.create fails with Endpoint.Error", HTTPoison, + [request: MockServer.mk_fail] do + resp = Template.create(TestStruct.basic_template()) + assert %Endpoint.Error{} = resp + end + + test_with_mock "Template.update succeeds with Template.Response", HTTPoison, + [request: fn (method, url, body, headers, opts) -> + assert method == :put + assert url =~ "/templates/TEMPLATE_ID" + fun = MockServer.mk_http_resp(200, MockServer.get_json("updatetemplate")) + fun.(method, url, body, headers, opts) + end] do + assert Template.update(TestStruct.basic_template()) == + %SparkPost.Template.Response{id: "TEMPLATE_ID"} + end + + test_with_mock "Template.update succeeds with update_published set", HTTPoison, + [request: fn (method, url, body, headers, opts) -> + assert method == :put + assert url =~ "/templates/TEMPLATE_ID?update_published=true" + fun = MockServer.mk_http_resp(200, MockServer.get_json("updatetemplate")) + fun.(method, url, body, headers, opts) + end] do + assert Template.update(TestStruct.basic_template(), update_published: true) == + %SparkPost.Template.Response{id: "TEMPLATE_ID"} + end + + test_with_mock "Template.update ignores update_published set if published field set", HTTPoison, + [request: fn (method, url, body, headers, opts) -> + assert method == :put + refute url =~ "/templates/TEMPLATE_ID?update_published=true" + assert url =~ "/templates/TEMPLATE_ID" + fun = MockServer.mk_http_resp(200, MockServer.get_json("updatetemplate")) + fun.(method, url, body, headers, opts) + end] do + template = %{TestStruct.basic_template() | published: true} + assert Template.update(template , update_published: true) == %SparkPost.Template.Response{id: "TEMPLATE_ID"} + end + + + test_with_mock "Template.update fails with Endpoint.Error", HTTPoison, + [request: MockServer.mk_fail] do + resp = Template.update(TestStruct.basic_template()) + assert %Endpoint.Error{} = resp + end end From f36c9962a17512b6b481afc6d159de15e1baf7e3 Mon Sep 17 00:00:00 2001 From: Nikola Begedin Date: Tue, 3 Jul 2018 16:26:55 +0200 Subject: [PATCH 2/4] Add Template.delete --- lib/endpoint.ex | 6 ++-- lib/template.ex | 19 ++++++++++++ test/data/templatedelete_fail_404.json | 9 ++++++ test/data/templatedelete_fail_409.json | 9 ++++++ test/template_test.exs | 42 ++++++++++++++++++++++++++ 5 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 test/data/templatedelete_fail_404.json create mode 100644 test/data/templatedelete_fail_409.json diff --git a/lib/endpoint.ex b/lib/endpoint.ex index 728c7ba..6790c43 100644 --- a/lib/endpoint.ex +++ b/lib/endpoint.ex @@ -15,7 +15,7 @@ defmodule SparkPost.Endpoint do - `:get` - `:head` - `:options` - - `:patch` + - `:patch` - `:post` - `:put` - `endpoint`: SparkPost API endpoint as string ("transmissions", "templates", ...) @@ -72,7 +72,7 @@ defmodule SparkPost.Endpoint do defp handle_response({:ok, %HTTPoison.Response{status_code: code, body: body}}, decode_results) when code >= 200 and code < 300 do decoded_body = decode_response_body(body) - if decode_results do + if decode_results && Map.has_key?(decoded_body, :results) do %SparkPost.Endpoint.Response{status_code: code, results: decoded_body.results} else %SparkPost.Endpoint.Response{status_code: code, results: decoded_body} @@ -98,7 +98,7 @@ defmodule SparkPost.Endpoint do } end - # Do not try to remove nils from an empty map + # Do not try to remove nils from an empty map defp encode_request_body(body) when is_map(body) and map_size(body) == 0, do: {:ok, ""} defp encode_request_body(body) do body |> Washup.filter |> Poison.encode diff --git a/lib/template.ex b/lib/template.ex index c060b07..3b08141 100644 --- a/lib/template.ex +++ b/lib/template.ex @@ -113,4 +113,23 @@ defmodule SparkPost.Template do |> Endpoint.request("templates/#{template_id}#{qs}", template) |> Endpoint.marshal_response(SparkPost.Template.Response) end + + @doc """ + Delete a SparkPost Template + + ## Parameters + + - a valid template id + + ## Response + + - `{:ok, %SparkPost.Endpoint.Response{}}` if successful + - `{:error, %SparkPost.Endpoint.Error{}}` if failure + """ + def delete(template_id) do + case Endpoint.request(:delete, "templates/#{template_id}") do + %SparkPost.Endpoint.Response{status_code: 200} = response -> {:ok, response} + other -> {:error, other} + end + end end diff --git a/test/data/templatedelete_fail_404.json b/test/data/templatedelete_fail_404.json new file mode 100644 index 0000000..34cd884 --- /dev/null +++ b/test/data/templatedelete_fail_404.json @@ -0,0 +1,9 @@ +{ + "errors": [ + { + "message": "resource not found", + "code": "1600", + "description": "Template does not exist" + } + ] +} diff --git a/test/data/templatedelete_fail_409.json b/test/data/templatedelete_fail_409.json new file mode 100644 index 0000000..3a5e918 --- /dev/null +++ b/test/data/templatedelete_fail_409.json @@ -0,0 +1,9 @@ +{ + "errors": [ + { + "message": "resource conflict", + "code": "1602", + "description": "Template is in use by msg generation" + } + ] +} diff --git a/test/template_test.exs b/test/template_test.exs index 0ec8854..260b885 100644 --- a/test/template_test.exs +++ b/test/template_test.exs @@ -139,4 +139,46 @@ defmodule SparkPost.TemplateTest do resp = Template.update(TestStruct.basic_template()) assert %Endpoint.Error{} = resp end + + test_with_mock "Template.delete succeeds with empty body", + HTTPoison, [request: fn (method, url, body, headers, opts) -> + assert method == :delete + assert url =~ "/templates/TEMPLATE_ID" + fun = MockServer.mk_http_resp(200, "{}") + fun.(method, url, body, headers, opts) + end] do + assert Template.delete("TEMPLATE_ID") == {:ok, %SparkPost.Endpoint.Response{results: %{}, status_code: 200}} + end + + test_with_mock "Template.delete fails with 404", + HTTPoison, [request: fn (method, url, body, headers, opts) -> + assert method == :delete + assert url =~ "/templates/TEMPLATE_ID" + fun = MockServer.mk_http_resp(404, MockServer.get_json("templatedelete_fail_404")) + fun.(method, url, body, headers, opts) + end] do + assert {:error, %Endpoint.Error{} = resp} = Template.delete("TEMPLATE_ID") + assert resp.status_code == 404 + assert resp.errors == [%{ + code: "1600", + description: "Template does not exist", + message: "resource not found" + }] + end + + test_with_mock "Template.delete fails with 409", + HTTPoison, [request: fn (method, url, body, headers, opts) -> + assert method == :delete + assert url =~ "/templates/TEMPLATE_ID" + fun = MockServer.mk_http_resp(409, MockServer.get_json("templatedelete_fail_409")) + fun.(method, url, body, headers, opts) + end] do + assert {:error, %Endpoint.Error{} = resp} = Template.delete("TEMPLATE_ID") + assert resp.status_code == 409 + assert resp.errors == [%{ + code: "1602", + description: "Template is in use by msg generation", + message: "resource conflict" + }] + end end From fa4e06b7e2d550f1a59bf67fb6bab85d0cc52897 Mon Sep 17 00:00:00 2001 From: Nikola Begedin Date: Tue, 3 Jul 2018 16:34:14 +0200 Subject: [PATCH 3/4] Add test clause for new endpoint behavior --- test/endpoint_test.exs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/test/endpoint_test.exs b/test/endpoint_test.exs index 2d44d60..29ea6ac 100644 --- a/test/endpoint_test.exs +++ b/test/endpoint_test.exs @@ -27,7 +27,7 @@ defmodule SparkPost.EndpointTest do end test "Endpoint.request succeeds with Endpoint.Response" do - with_mock HTTPoison, [request: fn(_, _, _, _, _) -> + with_mock HTTPoison, [request: fn(_, _, _, _, _) -> r = MockServer.mk_resp r.(nil, nil, nil, nil, nil) end] do @@ -112,4 +112,14 @@ defmodule SparkPost.EndpointTest do assert %Endpoint.Error{errors: [:timeout], status_code: nil, results: nil} == Endpoint.request(:post, "transmissions", %{}, %{}, []) end + + test_with_mock "Endpoint request can handle blank map as response", HTTPoison, + [request: fn (method, url, body, headers, opts) -> + fun = MockServer.mk_http_resp(200, "{}") + fun.(method, url, body, headers, opts) + end] + do + assert %Endpoint.Response{status_code: 200, results: %{}} == + Endpoint.request(:post, "transmissions", %{}, %{}, []) + end end From 83cfe7ffb2b34c55de0abb44a1ac169ff3dcabb8 Mon Sep 17 00:00:00 2001 From: Nikola Begedin Date: Tue, 3 Jul 2018 16:55:00 +0200 Subject: [PATCH 4/4] Correct error in update function signature --- lib/template.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/template.ex b/lib/template.ex index 3b08141..f0835e4 100644 --- a/lib/template.ex +++ b/lib/template.ex @@ -101,7 +101,7 @@ defmodule SparkPost.Template do Setting `published: true` on the struct itself performs the act of publishing a draft template. If the field is set to `true`, the `:update_published` option is ingored completely. """ - def update(%__MODULE{id: template_id, published: published} = template, options \\ [update_published: false]) do + def update(%__MODULE__{id: template_id, published: published} = template, options \\ [update_published: false]) do qs = if published != true && Keyword.get(options, :update_published, false) == true do "?update_published=true"