From 015c9acc75729643c8e027e8d4abbca40c17fffc Mon Sep 17 00:00:00 2001 From: Anthony Smith <420061+anthonator@users.noreply.github.com> Date: Wed, 18 Oct 2023 15:44:22 -0400 Subject: [PATCH] feat: added support for OAuth2 --- lib/klaviyo.ex | 2 +- lib/klaviyo/oauth2.ex | 45 +++++++++++++++++++++++++ lib/klaviyo/opts.ex | 4 ++- lib/klaviyo/request.ex | 57 +++++++++++++++++++++++++++----- lib/klaviyo/request_operation.ex | 7 ++-- lib/klaviyo/response.ex | 2 +- 6 files changed, 102 insertions(+), 15 deletions(-) create mode 100644 lib/klaviyo/oauth2.ex diff --git a/lib/klaviyo.ex b/lib/klaviyo.ex index 8c360f7..7f3aaaf 100644 --- a/lib/klaviyo.ex +++ b/lib/klaviyo.ex @@ -24,7 +24,7 @@ defmodule Klaviyo do RequestOperation.t(), keyword ) :: response_t - def send(operation, opts) do + def send(operation, opts \\ []) do opts = Opts.new(opts) request = Request.new(operation, opts) diff --git a/lib/klaviyo/oauth2.ex b/lib/klaviyo/oauth2.ex new file mode 100644 index 0000000..8aa4a95 --- /dev/null +++ b/lib/klaviyo/oauth2.ex @@ -0,0 +1,45 @@ +defmodule Klaviyo.OAuth2 do + alias Klaviyo.RequestOperation + + @spec authorize_url(String.t(), Enum.t()) :: String.t() + def authorize_url(url \\ "https://www.klaviyo.com/oauth/authorize", params) do + params = + params + |> put_in([:response_type], "code") + |> put_in([:code_challenge_method], "S256") + + url + |> URI.parse() + |> Map.put(:query, URI.encode_query(params)) + |> URI.to_string() + end + + @spec code_challenge(String.t()) :: String.t() + def code_challenge(code_verifier) do + digest = :crypto.hash(:sha256, code_verifier) + + Base.encode64(digest, padding: false) + end + + @spec code_verifier(pos_integer) :: String.t() + def code_verifier(length \\ 128) do + symbols = Enum.concat([?0..?9, ?a..?z, ?A..?Z, ["_", ".", "-", "~"]]) + + Stream.repeatedly(fn -> Enum.random(symbols) end) + |> Enum.take(length) + |> List.to_string() + end + + @spec get_token(String.t(), String.t(), Enum.t()) :: RequestOperation.t() + def get_token(client_id, client_secret, params) do + basic = Base.encode64("#{client_id}:#{client_secret}") + + %RequestOperation{ + body: params, + encoding: :www_form, + headers: [{"authorization", "Basic #{basic}"}], + method: :post, + path: "/oauth/token" + } + end +end diff --git a/lib/klaviyo/opts.ex b/lib/klaviyo/opts.ex index 944c616..c6a3104 100644 --- a/lib/klaviyo/opts.ex +++ b/lib/klaviyo/opts.ex @@ -7,6 +7,7 @@ defmodule Klaviyo.Opts do @type t :: %__MODULE__{ + access_token: String.t(), api_key: String.t(), client: module, client_opts: keyword, @@ -21,7 +22,8 @@ defmodule Klaviyo.Opts do revision: String.t() } - defstruct api_key: nil, + defstruct access_token: nil, + api_key: nil, client: HTTP.Hackney, client_opts: [], headers: [], diff --git a/lib/klaviyo/request.ex b/lib/klaviyo/request.ex index 3a711e1..50a1e3d 100644 --- a/lib/klaviyo/request.ex +++ b/lib/klaviyo/request.ex @@ -23,8 +23,8 @@ defmodule Klaviyo.Request do """ @spec new(RequestOperation.t(), Opts.t()) :: t def new(operation, opts) do - body = opts.json_codec.encode!(Enum.into(operation.body, %{})) - headers = opts.headers + body = encode_body(operation.body, operation.encoding, opts) + headers = opts.headers ++ operation.headers method = operation.method url = RequestOperation.to_url(operation, opts) @@ -33,9 +33,39 @@ defmodule Klaviyo.Request do |> Map.put(:headers, headers) |> Map.put(:method, method) |> Map.put(:url, url) - |> put_header("authorization", "Klaviyo-API-Key #{opts.api_key}") - |> put_header("content-type", "application/json") + |> put_header("content-type", encoding(operation.encoding)) |> put_header("revision", opts.revision) + |> put_new_header("authorization", authentication_token(opts)) + end + + def authentication_token(%{access_token: access_token}) when not is_nil(access_token) do + "Bearer #{access_token}" + end + + def authentication_token(%{api_key: api_key}) when not is_nil(api_key) do + "Klaviyo-API-Key #{api_key}" + end + + def authentication_token(_) do + nil + end + + @spec encode_body(Enum.t(), atom, Opts.t()) :: String.t() + def encode_body(body, :json, opts) do + opts.json_codec.encode!(Enum.into(body, %{})) + end + + def encode_body(body, :www_form, _opts) do + URI.encode_query(body, :www_form) + end + + @spec encoding(atom) :: String.t() + def encoding(:json) do + "application/json" + end + + def encoding(:www_form) do + "application/x-www-form-urlencoded" end @doc """ @@ -44,12 +74,21 @@ defmodule Klaviyo.Request do """ @spec put_header(t, String.t(), String.t()) :: t def put_header(request, key, value) do - headers = - request.headers - |> Enum.into(%{}) - |> Map.put(key, value) - |> Enum.into([]) + header = {key, value} + + headers = request.headers ++ [header] %{request | headers: headers} end + + @spec put_new_header(t, String.t(), String.t()) :: t + def put_new_header(request, key, value) do + has_header = Enum.any?(request.headers, fn {name, _} -> name == key end) + + if has_header do + request + else + put_header(request, key, value) + end + end end diff --git a/lib/klaviyo/request_operation.ex b/lib/klaviyo/request_operation.ex index 7d653d2..b7c281c 100644 --- a/lib/klaviyo/request_operation.ex +++ b/lib/klaviyo/request_operation.ex @@ -7,18 +7,19 @@ defmodule Klaviyo.RequestOperation do expected by an endpoint. """ - alias Klaviyo.{HTTP} + alias Klaviyo.HTTP @type t :: %__MODULE__{ body: Enum.t(), - encoding: :json, + encoding: :json | :www_form, + headers: HTTP.headers_t(), method: HTTP.method_t(), query: Enum.t(), path: String.t() } - defstruct body: [], encoding: :json, method: nil, query: [], path: nil + defstruct body: [], encoding: :json, headers: [], method: nil, query: [], path: nil @doc """ Builds a URL string. diff --git a/lib/klaviyo/response.ex b/lib/klaviyo/response.ex index e6b57ed..412f0ac 100644 --- a/lib/klaviyo/response.ex +++ b/lib/klaviyo/response.ex @@ -39,7 +39,7 @@ defmodule Klaviyo.Response do defp do_decode(response, opts) do content_type = HTTP.Response.get_header(response, "content-type") - if content_type != nil && content_type =~ "application/vnd.api+json" do + if content_type != nil && String.ends_with?(content_type, "json") do opts.json_codec.decode!(response.body) else response.body