Skip to main content

lib/ex_aws/sns.ex

defmodule ExAws.SNS do
  import ExAws.Utils, only: [camelize_key: 1, camelize_keys: 1]

  @moduledoc """
  Operations on AWS SNS

  http://docs.aws.amazon.com/sns/latest/api/API_Operations.html
  """

  ## Topics
  ######################

  @type topic_name   :: binary
  @type topic_arn    :: binary
  @type topic_attribute_name ::
    :policy |
    :display_name |
    :delivery_policy

  @doc "List topics"
  @spec list_topics() :: ExAws.Operation.Query.t
  @spec list_topics(opts :: [next_token: binary]) :: ExAws.Operation.Query.t
  def list_topics(opts \\ []) do
    opts = opts
    |> Map.new
    |> camelize_keys

    request(:list_topics, opts)
  end

  @doc "Create topic"
  @spec create_topic(topic_name :: topic_name) :: ExAws.Operation.Query.t
  def create_topic(topic_name) do
    request(:create_topic, %{"Name" => topic_name})
  end

  @doc "Get topic attributes"
  @spec get_topic_attributes(topic_arn :: topic_arn) :: ExAws.Operation.Query.t
  def get_topic_attributes(topic_arn) do
    request(:get_topic_attributes, %{"TopicArn" => topic_arn})
  end

  @doc "Set topic attributes"
  @spec set_topic_attributes(attribute_name :: topic_attribute_name,
                                   attribute_value :: binary,
                                   topic_arn :: topic_arn) :: ExAws.Operation.Query.t
  def set_topic_attributes(attribute_name, attribute_value, topic_arn) do
    request(:set_topic_attributes, %{
      "AttributeName" => attribute_name |> camelize_key,
      "AttributeValue" => attribute_value,
      "TopicArn" => topic_arn
    })
  end

  @doc "Delete topic"
  @spec delete_topic(topic_arn :: topic_arn) :: ExAws.Operation.Query.t
  def delete_topic(topic_arn) do
    request(:delete_topic, %{"TopicArn" => topic_arn})
  end

  @type message_attribute :: %{
    :name => binary,
    :data_type => :string | :number | :binary,
    :value => {:string, binary} | {:binary, binary}
  }
  @type publish_opts :: [
    {:message_attributes, [message_attribute]} |
    {:message_structure, :json} |
    {:subject, binary} |
    {:phone_number, binary} |
    {:target_arn, binary} |
    {:topic_arn, binary}]

  @doc """
  Publish message to a target/topic ARN

  You must set either :phone_number, :target_arn or :topic_arn but only one, via the options argument.

  Do NOT assume that because your message is a JSON blob that you should set
  message_structure: to :json. This has a very specific meaning, please see
  http://docs.aws.amazon.com/sns/latest/api/API_Publish.html for details.
  """
  @spec publish(message :: binary, opts :: publish_opts) :: ExAws.Operation.Query.t
  def publish(message, opts) do
    opts = opts |> Map.new

    message_attrs = opts
    |> Map.get(:message_attributes, [])
    |> build_message_attributes

    params = opts
    |> Map.drop([:message_attributes])
    |> camelize_keys
    |> Map.put("Message", message)
    |> Map.merge(message_attrs)

    request(:publish, params)
  end

  defp build_message_attributes(attrs) do
    attrs
    |> Stream.with_index
    |> Enum.reduce(%{}, &build_message_attribute/2)
  end

  def build_message_attribute({%{name: name, data_type: data_type, value: {value_type, value}}, i}, params) do
    param_root = "MessageAttributes.entry.#{i + 1}"
    value_type = value_type |> to_string |> String.capitalize

    params
    |> Map.put(param_root <> ".Name", name)
    |> Map.put(param_root <> ".Value.#{value_type}Value", value)
    |> Map.put(param_root <> ".Value.DataType", data_type |> to_string |> String.capitalize)
  end

  ## Platform
  ######################

  @type platform_application_arn:: binary

  @doc "Create plaform application"
  @spec create_platform_application(name :: binary, platform :: binary, attributes :: %{String.t => String.t}) :: ExAws.Operation.Query.t
  def create_platform_application(name, platform, attributes) do
    attributes =
      attributes
      |> build_kv_attrs
      |> Map.merge(%{
        "Name" => name,
        "Platform" => platform,
      })
    request(:create_platform_application, attributes)
  end

  @doc "Delete platform application"
  @spec delete_platform_application(platform_application_arn :: platform_application_arn) :: ExAws.Operation.Query.t
  def delete_platform_application(platform_application_arn) do
    request(:delete_platform_application, %{
      "PlatformApplicationArn" => platform_application_arn
    })
  end

  @doc "List platform applications"
  @spec list_platform_applications() :: ExAws.Operation.Query.t
  def list_platform_applications() do
    request(:list_platform_applications, %{})
  end

  @spec list_platform_applications(next_token :: binary) :: ExAws.Operation.Query.t
  def list_platform_applications(next_token) do
    request(:list_platform_applications, %{"NextToken" => next_token})
  end

  @doc "Create platform endpoint"
  @spec create_platform_endpoint(platform_application_arn :: platform_application_arn,
                                   token :: binary) :: ExAws.Operation.Query.t
  @spec create_platform_endpoint(platform_application_arn :: platform_application_arn,
                                   token :: binary,
                                   custom_user_data :: binary) :: ExAws.Operation.Query.t
  def create_platform_endpoint(platform_application_arn, token, custom_user_data \\ nil) do
    attrs = %{
      "PlatformApplicationArn" => platform_application_arn,
      "Token" => token
    }

    attrs = if custom_user_data do
      Map.put(attrs, "CustomUserData", custom_user_data)
    else
      attrs
    end

    request(:create_platform_endpoint, attrs)
  end

  @doc "Get platform application attributes"
  @spec get_platform_application_attributes(platform_application_arn :: platform_application_arn) :: ExAws.Operation.Query.t
  def get_platform_application_attributes(platform_application_arn) do
    request(:get_platform_application_attributes, %{"PlatformApplicationArn" => platform_application_arn})
  end

  ## Subscriptions
  ######################

  @type subscription_attribute_name :: :delivery_policy | :raw_message_delivery

  @doc "Create Subscription"
  @spec subscribe(topic_arn :: binary, protocol :: binary, endpoint :: binary) :: ExAws.Operation.Query.t
  def subscribe(topic_arn, protocol, endpoint) do
    request(:subscribe, %{
      "TopicArn" => topic_arn,
      "Protocol" => protocol,
      "Endpoint" => endpoint,
    })
  end

  @doc "Confirm Subscription"
  @spec confirm_subscription(topic_arn :: binary, token :: binary, authenticate_on_unsubscribe :: boolean) :: ExAws.Operation.Query.t
  def confirm_subscription(topic_arn, token, authenticate_on_unsubscribe \\ false) do
    request(:confirm_subscription, %{
      "TopicArn" => topic_arn,
      "Token" => token,
      "AuthenticateOnUnsubscribe" => to_string(authenticate_on_unsubscribe),
    })
  end

  @doc "List Subscriptions"
  @spec list_subscriptions() :: ExAws.Operation.Query.t
  def list_subscriptions() do
    request(:list_subscriptions, %{})
  end

  @spec list_subscriptions(next_token :: binary) :: ExAws.Operation.Query.t
  def list_subscriptions(next_token) do
    request(:list_subscriptions, %{"NextToken" => next_token})
  end

  @type list_subscriptions_by_topic_opt :: {:next_token, binary}

  @doc "List Subscriptions by Topic"
  @spec list_subscriptions_by_topic(topic_arn :: topic_arn) :: ExAws.Operation.Query.t
  @spec list_subscriptions_by_topic(topic_arn :: topic_arn, [list_subscriptions_by_topic_opt]) :: ExAws.Operation.Query.t
  def list_subscriptions_by_topic(topic_arn, opts \\ []) do
    params = case opts do
      [next_token: next_token] ->
        %{"TopicArn" => topic_arn, "NextToken" => next_token}
      _ ->
        %{"TopicArn" => topic_arn}
    end
    request(:list_subscriptions_by_topic, params)
  end

  @doc "Unsubscribe"
  @spec unsubscribe(subscription_arn :: binary) :: ExAws.Operation.Query.t
  def unsubscribe(subscription_arn) do
    request(:unsubscribe, %{
      "SubscriptionArn" => subscription_arn
    })
  end

  @doc "Get subscription attributes"
  @spec get_subscription_attributes(subscription_arn :: binary) :: ExAws.Operation.Query.t
  def get_subscription_attributes(subscription_arn) do
    request(:get_subscription_attributes, %{
      "SubscriptionArn" => subscription_arn
    })
  end

  @doc "Set subscription attributes"
  @spec set_subscription_attributes(attribute_name :: subscription_attribute_name,
                                    attribute_value :: binary,
                                    subscription_arn :: binary) :: ExAws.Operation.Query.t
  def set_subscription_attributes(attribute_name, attribute_value, subscription_arn) do
    request(:set_subscription_attributes, %{
      "AttributeName" => attribute_name |> camelize_key,
      "AttributeValue" => attribute_value,
      "SubscriptionArn" => subscription_arn
    })
  end

  @doc "List phone numbers opted out"
  @spec list_phone_numbers_opted_out() :: ExAws.Operation.Query.t
  def list_phone_numbers_opted_out() do
    request(:list_phone_numbers_opted_out, %{})
  end

  @spec list_phone_numbers_opted_out(next_token :: binary) :: ExAws.Operation.Query.t
  def list_phone_numbers_opted_out(next_token) do
    request(:list_phone_numbers_opted_out, %{"nextToken" => next_token})
  end

  @doc "Opt in phone number"
  @spec opt_in_phone_number(phone_number :: binary) :: ExAws.Operation.Query.t
  def opt_in_phone_number(phone_number) do
    request(:opt_in_phone_number, %{"phoneNumber" => phone_number})
  end

  ## Endpoints
  ######################

  @type endpoint_arn :: binary

  @type endpoint_attributes :: [
    {:token, binary}
    | {:enabled, boolean}
    | {:custom_user_data, binary}
  ]

  @doc "Get endpoint attributes"
  @spec get_endpoint_attributes(endpoint_arn :: endpoint_arn) :: ExAws.Operation.Query.t
  def get_endpoint_attributes(endpoint_arn) do
    request(:get_endpoint_attributes, %{"EndpointArn" => endpoint_arn})
  end

  @doc "Set endpoint attributes"
  @spec set_endpoint_attributes(endpoint_arn :: endpoint_arn, attributes :: endpoint_attributes) :: ExAws.Operation.Query.t
  def set_endpoint_attributes(endpoint_arn, attributes) do
    params =
      attributes
      |> build_attrs

    request(:set_endpoint_attributes, Map.put(params, "EndpointArn", endpoint_arn))
  end

  @doc "Delete endpoint"
  @spec delete_endpoint(endpoint_arn :: endpoint_arn) :: ExAws.Operation.Query.t
  def delete_endpoint(endpoint_arn) do
    request(:delete_endpoint, %{
      "EndpointArn" => endpoint_arn
    })
  end

  @type list_endpoints_by_platform_application_opt :: {:next_token, binary}

  @doc "List endpooints and endpoint attributes for devices in a supported push notification service"
  @spec list_endpoints_by_platform_application(topic_arn :: topic_arn) :: ExAws.Operation.Query.t
  @spec list_endpoints_by_platform_application(topic_arn :: topic_arn, [list_endpoints_by_platform_application_opt]) :: ExAws.Operation.Query.t
  def list_endpoints_by_platform_application(platform_application_arn, opts \\ []) do
    params = case opts do
              [next_token: next_token] ->
                %{ "PlatformApplicationArn" => platform_application_arn,
                   "NextToken" => next_token }
               _ ->
                 %{ "PlatformApplicationArn" => platform_application_arn }
            end
    request(:list_endpoints_by_platform_application, params)
  end

  ## Messages
  ######################

  @notification_params ["Message", "MessageId", "Subject", "Timestamp", "TopicArn", "Type"]
  @optional_notification_params ["Subject"]
  @confirmation_params ["Message", "MessageId", "SubscribeURL", "Timestamp", "Token", "TopicArn", "Type"]
  @signature_params ["SignatureVersion", "Signature", "SigningCertURL"]
  @message_types ["SubscriptionConfirmation", "UnsubscribeConfirmation", "Notification"]

  @doc "Verify message signature"
  @spec verify_message(message_params :: %{String.t => String.t}) :: [:ok | {:error, String.t}]
  def verify_message(message_params) do
    with :ok <- validate_message_params(message_params),
         :ok <- validate_signature_version(message_params["SignatureVersion"]),
         {:ok, public_key} <- ExAws.SNS.PublicKeyCache.get(message_params["SigningCertURL"]) do
      message_params
      |> get_string_to_sign
      |> verify(message_params["Signature"], public_key)
    end
  end

  defp validate_message_params(message_params) do
    with {:ok, required_params} <- get_required_params(message_params["Type"]) do
      case required_params -- Map.keys(message_params) do
        [] -> :ok
        missing_params -> {:error, "The following parameters are missing: #{inspect missing_params}"}
      end
    end
  end

  defp get_required_params(message_type) do
    case message_type do
      "Notification" -> {:ok, (@notification_params -- @optional_notification_params) ++ @signature_params}
      "SubscriptionConfirmation" -> {:ok, @confirmation_params ++ @signature_params}
      "UnsubscribeConfirmation" -> {:ok, @confirmation_params ++ @signature_params}
      type when is_binary(type) -> {:error, "Invalid Type, expected one of #{inspect @message_types}"}
      type when is_nil(type) -> {:error, "Missing message type parameter (Type)"}
      type -> {:error, "Invalid message type's type, expected a String, got #{inspect type}"}
    end
  end

  defp validate_signature_version(version) do
    case version do
      "1" -> :ok
      val when is_binary(val) -> {:error, "Unsupported SignatureVersion, expected \"1\", got #{version}"}
      _ -> {:error, "Invalid SignatureVersion format, expected a String, got #{version}"}
    end
  end

  defp get_string_to_sign(message_params) do
    message_params
    |> Map.take(get_params_to_sign(message_params["Type"]))
    |> Enum.map(fn {key, value} -> [to_string(key), "\n", to_string(value), "\n"] end)
    |> IO.iodata_to_binary
  end

  defp get_params_to_sign(message_type) do
    case message_type do
      "Notification" -> @notification_params
      "SubscriptionConfirmation" -> @confirmation_params
      "UnsubscribeConfirmation" -> @confirmation_params
    end
  end

  defp verify(message, signature, public_key) do
    case :public_key.verify(message, :sha, Base.decode64!(signature), public_key) do
      true -> :ok
      false -> {:error, "Signature is invalid"}
    end
  end

  ## Request
  ######################

  defp request(action, params) do
    action_string = action |> Atom.to_string |> Macro.camelize

    %ExAws.Operation.Query{
      path: "/",
      params: params |> Map.put("Action", action_string),
      service: :sns,
      action: action,
      parser: &ExAws.SNS.Parsers.parse/2
    }
  end

  defp build_attrs(attrs) do
    attrs
    |> Enum.with_index(1)
    |> Enum.map(&build_attr/1)
    |> Enum.reduce(%{}, &Map.merge(&1, &2))
  end

  defp build_attr({{name, value}, index}) do
    prefix = "Attributes.entry.#{index}."
    %{}
    |> Map.put(prefix <> "name",  format_param_key(name))
    |> Map.put(prefix <> "value", value)
  end

  defp build_kv_attrs(attrs) do
    attrs
    |> Enum.with_index(1)
    |> Enum.map(&build_kv_attr/1)
    |> Enum.reduce(%{}, &Map.merge(&1, &2))
  end

  defp build_kv_attr({{key, value}, index}) do
    prefix = "Attributes.entry.#{index}."
    %{}
    |> Map.put(prefix <> "key", key)
    |> Map.put(prefix <> "value", value)
  end

  defp format_param_key("*"), do: "*"
  defp format_param_key(key) do
    key
    |> Atom.to_string
    |> ExAws.Utils.camelize
  end
end