~afontaine/home

3a55eac00b0991dcd640d80a991b41e3d92554b6 — Andrew Fontaine 8 months ago 9d096b6
Add Webhook For the Creation of Blog Posts

/api/post/receive exists to receive blog posts via lists.sr.ht's
webhooks.

It utilizes a mail parsing library to fetch the post content, and maps
cleanly to our post object.
11 files changed, 156 insertions(+), 63 deletions(-)

R apps/blog/lib/blog/{blog.ex => content.ex}
R apps/blog/lib/blog/{blog/post.ex => content/post.ex}
M apps/blog/lib/blog/mail.ex
M apps/blog/lib/blog/mail/post.ex
A apps/blog/lib/types/message.ex
M apps/blog/mix.exs
R apps/blog/test/blog/{blog_test.exs => content_test.exs}
M apps/blog/test/blog/mail_test.exs
A apps/home_web/lib/home_web/controllers/receive_controller.ex
M apps/home_web/lib/home_web/router.ex
M mix.lock
R apps/blog/lib/blog/blog.ex => apps/blog/lib/blog/content.ex +5 -5
@@ 1,4 1,4 @@
defmodule Blog.Blog do
defmodule Blog.Content do
  @moduledoc """
  The Blog context.
  """


@@ 6,7 6,7 @@ defmodule Blog.Blog do
  import Ecto.Query, warn: false
  alias Blog.Repo

  alias Blog.Blog.Post
  alias Blog.Content.Post

  @doc """
  Returns the list of posts.


@@ 35,7 35,7 @@ defmodule Blog.Blog do
      ** (Ecto.NoResultsError)

  """
  def get_post!(id), do: Repo.get!(Post, id)
  def get_post!(slug), do: Repo.one!(from p in Post, where: p.slug == ^slug)

  @doc """
  Creates a post.


@@ 49,9 49,9 @@ defmodule Blog.Blog do
      {:error, %Ecto.Changeset{}}

  """
  def create_post(attrs \\ %{}) do
  def create_post(%{} = post) do
    %Post{}
    |> Post.changeset(attrs)
    |> Post.changeset(post)
    |> Repo.insert()
  end


R apps/blog/lib/blog/blog/post.ex => apps/blog/lib/blog/content/post.ex +2 -2
@@ 1,9 1,8 @@
defmodule Blog.Blog.Post do
defmodule Blog.Content.Post do
  use Ecto.Schema
  import Ecto.Changeset
  import Slugy

  @derive {Phoenix.Param, key: :slug}
  schema "posts" do
    field :message_id, :string
    field :subject, :string


@@ 18,6 17,7 @@ defmodule Blog.Blog.Post do
    post
    |> cast(attrs, [:subject, :text, :message_id])
    |> validate_required([:subject, :text, :message_id])
    |> unique_constraint(:message_id)
    |> slugify(:subject)
  end
end

M apps/blog/lib/blog/mail.ex => apps/blog/lib/blog/mail.ex +3 -2
@@ 4,7 4,6 @@ defmodule Blog.Mail do
  """

  import Ecto.Query, warn: false
  alias Blog.Repo

  alias Blog.Mail.Post



@@ 21,6 20,8 @@ defmodule Blog.Mail do

  """
  def create_post(attrs \\ %{}) do
    raise "TODO"
    %Post{}
    |> Post.changeset(attrs)
    |> Ecto.Changeset.apply_action(:insert)
  end
end

M apps/blog/lib/blog/mail/post.ex => apps/blog/lib/blog/mail/post.ex +21 -1
@@ 5,7 5,8 @@ defmodule Blog.Mail.Post do
  embedded_schema do
    field :message_id, :string
    field :subject, :string
    field :envelope, :string
    field :envelope, Blog.Types.Message
    field :text, :string
  end

  @doc false


@@ 13,5 14,24 @@ defmodule Blog.Mail.Post do
    post
    |> cast(attrs, [:message_id, :subject, :envelope])
    |> validate_required([:message_id, :subject, :envelope])
    |> put_body()
  end

  defp put_body(%Ecto.Changeset{valid?: true, changes: %{envelope: envelope}} = changeset) do
    body =
      envelope
      |> Mail.get_text()
      |> Map.get(:body, "")
      |> String.trim()
      |> String.split("\r\n-- \r\n")
      |> Enum.drop(-1)
      |> Enum.map(&String.trim/1)
      |> Enum.join("\n")
      |> String.trim()
      |> String.replace("\r\n", "\n")

    put_change(changeset, :text, body)
  end

  defp put_body(changeset), do: changeset
end

A apps/blog/lib/types/message.ex => apps/blog/lib/types/message.ex +21 -0
@@ 0,0 1,21 @@
defmodule Blog.Types.Message do
  use Ecto.Type

  def type, do: :map

  def cast(message) when is_binary(message), do: {:ok, Mail.Parsers.RFC2822.parse(message) }

  def cast(_), do: :error

  def load(data) when is_map(data) do
    data =
      for {key, val} <- data do
        {String.to_existing_atom(key), val}
      end

    {:ok, struct!(Mail.Message, data)}
  end

  def dump(%Mail.Message{} = message), do: {:ok, Map.from_struct(message)}
  def dump(_), do: :error
end

M apps/blog/mix.exs => apps/blog/mix.exs +3 -1
@@ 36,11 36,13 @@ defmodule Blog.MixProject do
  # Type `mix help deps` for examples and options.
  defp deps do
    [
      {:phoenix, "~> 1.5.8"},
      {:phoenix_pubsub, "~> 2.0"},
      {:ecto_sql, "~> 3.4"},
      {:postgrex, ">= 0.0.0"},
      {:jason, "~> 1.0"},
      {:slugy, "~> 4.1"}
      {:slugy, "~> 4.1"},
      {:mail, "~> 0.2.2"}
    ]
  end


R apps/blog/test/blog/blog_test.exs => apps/blog/test/blog/content_test.exs +19 -15
@@ 1,12 1,16 @@
defmodule Blog.BlogTest do
defmodule Blog.ContentTest do
  use Blog.DataCase

  alias Blog.Blog
  alias Blog.Content

  describe "posts" do
    alias Blog.Post
    alias Content.Post

    @valid_attrs %{message_id: "some message_id", subject: "some subject", text: "some text"}
    @valid_attrs %{
      message_id: "some message_id",
      subject: "some subject",
      text: "some text"
    }
    @update_attrs %{
      message_id: "some updated message_id",
      subject: "some updated subject",


@@ 18,35 22,35 @@ defmodule Blog.BlogTest do
      {:ok, post} =
        attrs
        |> Enum.into(@valid_attrs)
        |> Blog.create_post()
        |> Content.create_post()

      post
    end

    test "list_posts/0 returns all posts" do
      post = post_fixture()
      assert Blog.list_posts() == [post]
      assert Content.list_posts() == [post]
    end

    test "get_post!/1 returns the post with given id" do
      post = post_fixture()
      assert Blog.get_post!(post.id) == post
      assert Content.get_post!(post.slug) == post
    end

    test "create_post/1 with valid data creates a post" do
      assert {:ok, %Post{} = post} = Blog.create_post(@valid_attrs)
      assert {:ok, %Post{} = post} = Content.create_post(@valid_attrs)
      assert post.message_id == "some message_id"
      assert post.subject == "some subject"
      assert post.text == "some text"
    end

    test "create_post/1 with invalid data returns error changeset" do
      assert {:error, %Ecto.Changeset{}} = Blog.create_post(@invalid_attrs)
      assert {:error, %Ecto.Changeset{}} = Content.create_post(@invalid_attrs)
    end

    test "update_post/2 with valid data updates the post" do
      post = post_fixture()
      assert {:ok, %Post{} = post} = Blog.update_post(post, @update_attrs)
      assert {:ok, %Post{} = post} = Content.update_post(post, @update_attrs)
      assert post.message_id == "some updated message_id"
      assert post.subject == "some updated subject"
      assert post.text == "some updated text"


@@ 54,19 58,19 @@ defmodule Blog.BlogTest do

    test "update_post/2 with invalid data returns error changeset" do
      post = post_fixture()
      assert {:error, %Ecto.Changeset{}} = Blog.update_post(post, @invalid_attrs)
      assert post == Blog.get_post!(post.id)
      assert {:error, %Ecto.Changeset{}} = Content.update_post(post, @invalid_attrs)
      assert post == Content.get_post!(post.slug)
    end

    test "delete_post/1 deletes the post" do
      post = post_fixture()
      assert {:ok, %Post{}} = Blog.delete_post(post)
      assert_raise Ecto.NoResultsError, fn -> Blog.get_post!(post.id) end
      assert {:ok, %Post{}} = Content.delete_post(post)
      assert_raise Ecto.NoResultsError, fn -> Content.get_post!(post.slug) end
    end

    test "change_post/1 returns a post changeset" do
      post = post_fixture()
      assert %Ecto.Changeset{} = Blog.change_post(post)
      assert %Ecto.Changeset{} = Content.change_post(post)
    end
  end
end

M apps/blog/test/blog/mail_test.exs => apps/blog/test/blog/mail_test.exs +53 -34
@@ 6,8 6,53 @@ defmodule Blog.MailTest do
  describe "posts" do
    alias Blog.Mail.Post

    @valid_attrs %{}
    @update_attrs %{}
    @valid_attrs %{
      "id" => 153_012,
      "created" => "2021-03-27T17:31:05+00:00",
      "subject" => "Test Email Subject",
      "message_id" => "<87k0psv70b.fsf@example.com>",
      "parent_id" => nil,
      "thread_id" => nil,
      "sender" => %{
        "canonical_name" => "~afontaine",
        "name" => "afontaine"
      },
      "patchset" => nil,
      "is_patch" => false,
      "is_request_pull" => false,
      "replies" => 0,
      "participants" => 0,
      "envelope" =>
        Enum.join(
          [
            "From nobody Sat Mar 27 17:31:05 2021",
            "Authentication-Results: mail-b.sr.ht; dkim=pass header.d=afontaine.ca header.i=@afontaine.ca",
            "Received: from mail.example.com (mail.example.com [127.0.0.1])",
            "\tby mail-b.sr.ht (Postfix) with ESMTPS id 9FE5811EF0E",
            "\tfor <~afontaine/example@lists.sr.ht>; Sat, 27 Mar 2021 17:31:03 +0000 (UTC)",
            "Date: Sat, 27 Mar 2021 17:30:53 +0000",
            "To: ~afontaine/blog-testing@lists.sr.ht",
            "From: Andrew Fontaine <andrew@example.com>",
            "Reply-To: Andrew Fontaine <andrew@example.com>",
            "Subject: Test Email Subject",
            "Message-ID: <87k0psv70b.fsf@example.com>",
            "MIME-Version: 1.0",
            "Content-Type: text/plain; charset=utf-8",
            "Content-Transfer-Encoding: quoted-printable",
            "",
            "",
            "test body",
            "",
            "--=20",
            "",
            "Andrew Fontaine",
            "",
            ""
          ],
          "\r\n"
        )
    }

    @invalid_attrs %{}

    def post_fixture(attrs \\ %{}) do


@@ 19,44 64,18 @@ defmodule Blog.MailTest do
      post
    end

    test "list_posts/0 returns all posts" do
      post = post_fixture()
      assert Mail.list_posts() == [post]
    end

    test "get_post!/1 returns the post with given id" do
      post = post_fixture()
      assert Mail.get_post!(post.id) == post
    end

    test "create_post/1 with valid data creates a post" do
      assert {:ok, %Post{} = post} = Mail.create_post(@valid_attrs)
    end

    test "create_post/1 with invalid data returns error changeset" do
      assert {:error, %Ecto.Changeset{}} = Mail.create_post(@invalid_attrs)
    end

    test "update_post/2 with valid data updates the post" do
      post = post_fixture()
      assert {:ok, %Post{} = post} = Mail.update_post(post, @update_attrs)
    end
      assert %Post{text: body, subject: subject, message_id: message_id} = post

    test "update_post/2 with invalid data returns error changeset" do
      post = post_fixture()
      assert {:error, %Ecto.Changeset{}} = Mail.update_post(post, @invalid_attrs)
      assert post == Mail.get_post!(post.id)
      assert "Test Email Subject" = subject
      assert "<87k0psv70b.fsf@example.com>" = message_id
      assert "test body" = body
    end

    test "delete_post/1 deletes the post" do
      post = post_fixture()
      assert {:ok, %Post{}} = Mail.delete_post(post)
      assert_raise Ecto.NoResultsError, fn -> Mail.get_post!(post.id) end
    end

    test "change_post/1 returns a post changeset" do
      post = post_fixture()
      assert %Ecto.Changeset{} = Mail.change_post(post)
    test "create_post/1 with invalid data returns error changeset" do
      assert {:error, %Ecto.Changeset{}} = Mail.create_post(@invalid_attrs)
    end
  end
end

A apps/home_web/lib/home_web/controllers/receive_controller.ex => apps/home_web/lib/home_web/controllers/receive_controller.ex +21 -0
@@ 0,0 1,21 @@
defmodule HomeWeb.ReceiveController do
  use HomeWeb, :controller
  alias Blog.Mail
  alias Blog.Content

  def receive(conn, post) do
    case Mail.create_post(post) do
      {:ok, post} ->
        post
        |> Map.from_struct()
        |> Content.create_post()

      x ->
        x
    end

    conn
    |> put_status(:ok)
    |> text("")
  end
end

M apps/home_web/lib/home_web/router.ex => apps/home_web/lib/home_web/router.ex +7 -3
@@ 22,9 22,13 @@ defmodule HomeWeb.Router do
  end

  # Other scopes may use custom stacks.
  # scope "/api", HomeWeb do
  #   pipe_through :api
  # end
  scope "/api", HomeWeb do
    pipe_through :api

    scope "/post" do
      post "/receive", ReceiveController, :receive
    end
  end

  # Enables LiveDashboard only for development
  #

M mix.lock => mix.lock +1 -0
@@ 10,6 10,7 @@
  "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"},
  "gettext": {:hex, :gettext, "0.18.2", "7df3ea191bb56c0309c00a783334b288d08a879f53a7014341284635850a6e55", [:mix], [], "hexpm", "f9f537b13d4fdd30f3039d33cb80144c3aa1f8d9698e47d7bcbcc8df93b1f5c5"},
  "jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"},
  "mail": {:hex, :mail, "0.2.2", "b1d31beaa2a7b23d7b84b2794f037ef4dfdaba9e66d877142bedbaf0625b9c16", [:mix], [], "hexpm", "1c9d31548a60c44ded1806369e07a7dd4d05737eb47fa3238bbf2436b3da8a32"},
  "mime": {:hex, :mime, "1.5.0", "203ef35ef3389aae6d361918bf3f952fa17a09e8e43b5aa592b93eba05d0fb8d", [:mix], [], "hexpm", "55a94c0f552249fc1a3dd9cd2d3ab9de9d3c89b559c2bd01121f824834f24746"},
  "myxql": {:hex, :myxql, "0.4.5", "49784e6a3e4fc33088cc9004948ef255ee698b0d7b533fb1fa453cc99a3f9972", [:mix], [{:db_connection, "~> 2.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:geo, "~> 3.3", [hex: :geo, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "40a6166ab0a54f44a6e2c437aed6360ce51ce7f779557ae30d1cc4c4b4e7ad13"},
  "phoenix": {:hex, :phoenix, "1.5.8", "71cfa7a9bb9a37af4df98939790642f210e35f696b935ca6d9d9c55a884621a4", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.13", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.1.2 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "35ded0a32f4836168c7ab6c33b88822eccd201bcd9492125a9bea4c54332d955"},