sugar-framework / sugar

Modular web framework for Elixir
https://sugar-framework.github.io/
MIT License
430 stars 29 forks source link

Non EEx views #100

Closed SachsKaylee closed 3 years ago

SachsKaylee commented 4 years ago

Hello there!

Sugar looks like a really interesting and simple to use framework.

My current situation

I am interested in migrating a pure Plug project over to Sugar, but there is one big feature I'd need which I didn't see any documentation about and a quick glimpse in the source code looks like this isn't supported ATM:

Most endpoints in my application are API endpoints (since I use a frontend framework for the actual rendering), so all backend views output JSON (instead of HTML). While it is possible to send the JSON directly from the controller (this is what I am currently doing in Plug) this quickly becomes an unmaintainable mess, especially when two (or more) controllers should return the same data.

What I'd like to call

In my controller I'd like to be able to call render/4 (in addition to the current EEx syntax) like this:

# ...
conn |> render(MyApp.Views.CustomerView, "customers.json", customers: customer_data)

(The view name could be an optional parameter which would infer it from the name of the controller. But there should be a way to explicitly specify it)

defmodule MyApp.Views.CustomerView do
  def render("customers.json", customers: customer_data) do
    customer_data
    |> Enum.map(&(render("customer.json", customer: &1)))
  end

  def render("customer.json", customer: customer_data) do
    recent_contracts = MyApp.Customer.get_recent_contracts(customer_data)
    %{
      id: customer_data.id,
      name: customer_data.name,
      recent_contract_count: length(recent_contracts)
    }
  end
end

(The return value could either be a term, and Sugar would encode it automagically based on the file extension or users would have to manually encode it and return it as a {content_type, payload} tuple?)

The big point here is that the view does no longer have access to the conn,

Conclusion

I realize that this is trivial to implement on my own in my application, and I will most likely do so, but I believe that Sugar as a framework would benefit from seeing returned JSON also as a form of View. (Better decoupling, testability, feature parity with phoenix, ...)

This concept was heavily inspired by the way Phoenix does it, so I understand if you want so mix things up a bit though ;)

PRs welcome?

YellowApple commented 3 years ago

Howdy! Sorry for the delayed response here.

I take it you've already looked into json/2? It's designed for this exact use case of using Sugar for REST API backends (following code should work in theory, but my Elixir's a bit rusty at the moment :wink:):

defmodule MyApp.Controllers.Customer do
  use Sugar.Controller

  # ...

  def index(conn) do
    data = MyApp.Customer.get_all()  # or whatever
    conn |> json(data |> get_customers)
  end

  def show(conn, id) do
    data = MyApp.Customer.get(id)  # or whatever
    conn |> json(data |> get_customer)
  end

  # ...

  defp get_customer(data) do
    contracts = MyApp.Customer.get_recent_contracts(data)
    [ id: data.id,
      name: data.name,
      recent_contract_count: length(contracts) ]
  end

  defp get_customers(data) do
    data |> Enum.map(&(get_customer(&1)))
  end
end

render/4 is really less about "views" and more specifically about "templates" - namely, about generating actual HTML from template files (be they HTML w/ EEx or HAML). json/2 is (IMO) the better choice for this, since it's more intelligent about e.g. setting Content-Type to application/json and all that jazz.

You also probably want to keep things like MyApp.Customer.get_recent_contracts/1 in your controllers (per above) rather than your views from an MVC purism standpoint, but I'm just nitpicky like that šŸ˜„

SachsKaylee commented 3 years ago

Thanks for the response!

I ended up writing a small helper module that allows me to write pretty much the syntax posted in my initial post above. I didn't end up going with Sugar though since I noticed that with this feature in place I did not actually need a wrapper around plug.

I take it you've already looked into json/2? It's designed for this exact use case of using Sugar for REST API backends (following code should work in theory, but my Elixir's a bit rusty at the moment wink):

I did look into json/2 - The feature I was missing (or maybe I just missed it) was a way to declare the way my data should be converted to JSON. E.g. I have a MyApp.Customer struct and I want to send it as JSON. However the struct may have some fields that I don't want to send (maybe it contains sensitive information or just plain isn't required to be sent) or need to further transformation that I don't feel like it belongs in the controller.

The way you posted the example is pretty much how the app used to convert the data to JSON before the initial post. But this always seemed like a smell to me because it didn't seem to logically belong in the controller (or the "plug") and made it hard to re-use the functions between controllers (calling a conversion function from another plug always seemed wrong)

For this reason I rather like the ability of phoenix to create views that output JSON because you can declare once how e.g. a customer.json should look like and re-use this function many times. Otherwise I'd either have repeat this code/function call in every single controller that responds with the same data structure.

render/4 is really less about "views" and more specifically about "templates" - namely, about generating actual HTML from template files (be they HTML w/ EEx or HAML). json/2 is (IMO) the better choice for this, since it's more intelligent about e.g. setting Content-Type to application/json and all that jazz.

I fully understand this decision and the rationale behind this.

You also probably want to keep things like MyApp.Customer.get_recent_contracts/1 in your controllers (per above) rather than your views from an MVC purism standpoint, but I'm just nitpicky like that smile

You are absolutely right, that's much better :D

I'm going to close this issue since it's no longer an important topic for my use case, and sugar does support this feature, but just takes a different "philosophical" approach to it. :)

Thanks for your time!

YellowApple commented 3 years ago

Thanks for your time!

No problem! And glad to hear you were able to put together something that works for you :)

(I'd definitely be interested in seeing what you've got going for that helper module; it does indeed sound like we've got two different philosophies here, but if there's anything Sugar can do to help streamline that use case a bit, I figure it'd be useful to at least investigate šŸ˜„)

E.g. I have a MyApp.Customer struct and I want to send it as JSON. However the struct may have some fields that I don't want to send (maybe it contains sensitive information or just plain isn't required to be sent) or need to further transformation that I don't feel like it belongs in the controller.

Your feelings are probably correct here. That said, I ain't sure I'd put that logic in the view, either; this seems to be more the purview of a separate layer between the two, like a presenter or view model / binder. Ideally the view should be doing as little as possible beyond faithfully presenting exactly what it received from its supporting layers.

Looking at REST-API-specific frameworks like Grape, we could probably take inspiration from that ecosystem's concept of "entities", which seems to describe exactly what you seek: you "present" a model/struct using an entity, and that entity then does the necessary transformations and field trimming prior to JSON output.

SachsKaylee commented 3 years ago

Sure! My plugs currently looks something something like this (all modules have been simplified, they should work in this form):

defmodule MyApp.Endpoints.Customer.Controller do
  use Plug.Router
  use MyApp.Controller, view: MyApp.Endpoints.Customer.View
  require Logger

  plug :match
  plug :dispatch

  get "/customers" do
    customers = MyApp.Customer.get_customers()
    conn
    |> render("customers.json", customers: customers)
  end
end

The render/2 function is imported from the MyApp.Controller and in the background calls MyApp.Endpoints.Customer.View.render/2:

defmodule MyApp.Endpoints.Customer.View do
  def render("customers.json", customers: customers) do
    customers
    |> Enum.map(&render("customer.json", customer: &1))
  end

  def render("customer.json", customer: customer) do
    %{
      id: customer.id,
      name: customer.name,
      description: customer.description,
      support_level: customer.support_level
    }
  end
end

While the MyApp.Controller (simplified) looks like this:

defmodule MyApp.Controller do
  defmacro __using__(opts) do
    render_fn = quote do
      @doc """
      Renders the given data in the default view of this controller.
      """
      def render(conn, template, assigns), do: render(conn, unquote(view), template, assigns)
    end
    quote do
      import MyApp.Controller, only: [render: 4]

      unquote(render_fn)
    end
  end

  @doc """
  Renders the given data.
  """
  @spec render(conn :: Plug.Conn.t(), view :: module, template :: atom | String.t, term) :: Plug.Conn.t()
  def render(conn, view_module, template, assigns) do
    res = view_module.render(template, assigns)
    format = template_format(template)
    encoder = get_encoder!(format)
    data = encoder.(res)
    conn
    |> ensure_resp_content_type(MIME.type(format))
    |> Plug.Conn.send_resp(conn.status || :ok, data)
  end

  defp get_encoder!("json"), do: Jason
  defp get_encoder!(ext), do: raise ~s(no content type specified for extension "#{ext}")

  defp template_format(template) do
    case Path.extname(template) do
      "" -> "json"
      "." <> ext -> ext
    end
  end

  defp ensure_resp_content_type(%Plug.Conn{resp_headers: resp_headers} = conn, content_type) do
    if List.keyfind(resp_headers, "content-type", 0) do
      conn
    else
      content_type = content_type <> "; charset=utf-8"
      %Plug.Conn{conn | resp_headers: [{"content-type", content_type}|resp_headers]}
    end
  end
end

I hope that wasn't too big of a wall of code for a GitHub comment.

Your feelings are probably correct here. That said, I ain't sure I'd put that logic in the view, either; this seems to be more the purview of a separate layer between the two, like a presenter or view model / binder. Ideally the view should be doing as little as possible beyond faithfully presenting exactly what it received from its supporting layers.

You are most certainly correct here. I tend to over-use views a lot in my applications. I literally have views that generate e-mails :D This is working out really well from a productivity/iteration speed point of view since, but from a pure technical aspect the views may be impure and potentially have small side effects.

Looking at REST-API-specific frameworks like Grape, we could probably take inspiration from that ecosystem's concept of "entities", which seems to describe exactly what you seek: you "present" a model/struct using an entity, and that entity then does the necessary transformations and field trimming prior to JSON output.

This looks like an interesting approach! Especially since it looks very declarative. Also the somewhat object oriented approach can be very useful for expressing data structures since they often tend to share a lot of properties. My only concern is that it looks like it would introduce "yet another DSL". I think such a feature would probably warrant a separate library from Sugar since it would have a lot of uses on its own and probably "bloat" this minimalist framework too much.