Mar — Simple Web in Elixir

I took lessons from the last discussion and cobbled together an example as a proof-of-concept.

Mar demonstrates a Flask-like web dev interface powered by Plug on Bandit.
use Mar immediately makes a module a route. The user modules don’t need to report to an entry-point plug in the app. Library handles routing.
Normal defs defines the actions to the requests. So you can compose them Elixir way. Router matches them by their names and the HTTP methods.

defmodule MyApp do
  use Mar

  def get(), do: "Hello, world!"
end

path can be set. Default is "/" otherwise.
params declares allowed parameters alongside path parameters with :. Later it takes matching keys from conn.params and puts it in the actions.
Actions can return a string, a map for JSON, or a tuple being {status, headers, body}.

defmodule MyApp do
  use Mar, path: "/post/:id", params: [:comment]

  def get(%{id: id}) do
    "You are reading #{id}"
  end

  def post(%{id: id, comment: comment}) do
    %{
      id: id,
      comment: comment
    }
  end

  def delete(%{id: _id}) do
    {301, [location: "/"], nil}
  end
end

Routes can interact with the library through Mar.Route protocol.

defmodule MyApp do
  use Mar

  def get(), do: "Hello, world!"

  defimpl Mar.Route do
    # Mar.Route.MyApp
    def before_action(route) do
      IO.inspect(route.conn.resp_body)
      # => nil
      route
    end

    def after_action(route) do
      IO.inspect(route.conn.resp_body)
      # => "Hello, world!"
      route
    end
  end
end

Intention

While there are many possible approaches to helping adoption and facilitating learning, the challenge I tackle here is to nicely encapsulate Plug and reduce cognitive load for the users. @taro lacks the technical capability for something production-ready, this project waxes on top of Bandit with an escape hatch in an attempt to help you envision a light and intuitive web framework for Elixir.

The insight and guidance from the community is much appreciated:

Design

This library relies on protocol consolidation to handle routes. use Mar injects a default defimpl of Mar.Route protocol. It lists up the user modules. At the same time, defstruct saves the path as a default struct value. Then the list of implementation maps to structs, which has information for path-matching.

case Mar.Route.__protocol__(:impls) do
  {:consolidated, modules} -> Enum.map(modules, &struct(&1))
  :not_consolidated -> []
end
# [ 
#  %MyApp{ path: "/", ...}, 
#  %MyApp.Post{ path: "/post/:id/", ...}, ,
#  ...
# ]

Mar.Router leaves escape hatches open with the Mar.Route protocol. The user modules redefine the functions with defimpl to access them.

# Mar.Router
def call(conn, _options) do
  # Match routes, load params
  route = Mar.Route.before_action(route)
  # Apply action
  route = Mar.Route.after_action(route)
  # Send response
end

Atom keys are preferred over string keys for the sake of nicer syntax. That’s also why params need to be declared so the library can prevent dynamic atom creation.

What do you think? I’m hoping to hear from you! :smile:

Reference

27 Likes

You got dragged a bit in the other thread but I think this really cool and a worthwhile addition to the library ecosystem

4 Likes

Thanks for the words of encouragement!
I’m looking forward to improve upon current version and expand the project further, including zero-to-hero guides and templating.

1 Like

Hi @taro!

Thank you for exploring new directions here. If your goal is to have something smaller, may I suggest something that builds on top of functions rather than modules? Modules impose more boilerplate than functions and relying on protocol consolidation means you can’t use Mar efficiently inside Mix.install/2 scripts (you have to disable consolidation or use a full-blown project).

Compare with the simplest hello world possible with Bandit:

Mix.install([:bandit])

Bandit.start_link(plug: fn conn, _opts ->
  Plug.Conn.send_resp(conn, 200, "hello world")
end)

There is probably a balance to be found between functions and modules here.

19 Likes

Hi @josevalim !
Thanks for your advice. It means a lot to me.

The goal is to make it ergonomic, and not the least LoC even though I’ve got the single .exss as the responses in the other thread.

What I’m trying is to maximise the semantics of Elixir in web development. Modules are a meaningful units in Elixir, usually saved in its own file. So I wanted to put some meaning to it. A path usually has dozens of actions. That’s how I came up with module-path, function-action analogy.

Do you think this approach has a chance if the least LoC is not the goal?

That was exactly what I wanted to solve next :sob: Is it hopeless then? I couldn’t find more information about how consolidation works under the hood and why Mix.install/2 breaks it.

I want to remove the need to reporting modules to the configurations of a library or a centralised router. Is this an anti-pattern or unnatural thing in Elixir? I see many people have tried this, ending up scanning all the modules and saving them in ETS or an Agent.

1 Like

I am most likely not paying attention well enough here but why did you go for protocols and not behaviours? Or even functions tagged with attributes?

Hi @dimitarvp !

Mar doesn’t have much to do with behaviour yet. I don’t know where to use them? I’m trying hard to remove one more thing that the user needs to do. Behaviour seems a lot of do this do that for the user. Tagging functions seems not much different. I’d appreciate it if you let me know what I can do!

Protocol is for inter-project communication. It can

  1. Register the module to the Mar.Route.
  2. Save information in the %MyApp{} struct.
  3. Interact with the library through Mar.Route.MyApp

Now, it removes and hides less important things. User modules don’t need to report themselves to a configuration because protocol know where they are. The library code can handle the routes polymorphically. User module can access them if they need to.

Or, I’m not seeing what your questions imply.
Is there an obviously better approach?

1 Like

As I said I am not even sure my question is good, it was borne out of how much I hate the defimpl blocks, they stick like sore thumbs for me. :smiley:

2 Likes

I ran across this myself when trying something in livebook. It’s a quick add to the install line: Mix.install([:mar], consolidate_protocols: false)

This post may be helpful: Why does Livebook require `consolidate_protocols: false`

2 Likes

I see, could you briefly share what’s wrong with them?
In Mar, I intended them to be optional escape hatches when the user module want the library to handle the route differently. You have direct access to conn in it.

Hi @jerdew
Thanks for the info!

The problem runs deeper than adding impls for this library though. It uses the list of consolidated modules as a registry. So it needs consolidation anyway. I thought I would find a way to reconsolidate or something. Or I have to make another way to keep modules with use Mar in one place.

Admittedly nothing, I only vaguely remembered potential problems with consolidation which just made me conclude “well, I’ll never use them if I can help it”.

And syntactically they are confusing to me – of course that’s subjective and is not a strong argument at all, I just don’t see how defmodule + a function or two and then defimpl embedded inside the module is intuitive or even indicative of anything. But again, syntax preferences of others aren’t something you should consider a strong signal when deciding on how to write your library. Probably only if you agree with them.

1 Like

Heh, if you hate defimpls:

(Disclaimer, self-plug)

7 Likes

Absolutely. They are only tools for the users to cope with the incompleteness of the library. So the maintainer can gradually follow up what users’ needs without obstructing the possibility of them. They are meant to be removed as soon as possible. Let’s take the ugliness as an indication of that.

1 Like

Some initial reactions, in no particular order:

  • the path-parsing captures \w+ for path parameters but that means that a path like the one to this very page (/t/mar-simple-web-in-elixir/63075) can’t capture mar-simple-web-in-elixir as a single parameter

  • there doesn’t seem to be a way to read request headers (other than digging them out of route.conn.headers in a before_action and putting them… ???). How would a user of Mar implement something like Basic auth, or use session cookies?

  • “one function per method” means the “standard” REST routing has to split into many modules:

    • one like MyApp above that supports show / update / delete
    • one for actions that don’t have an :id - GET /post (index), POST /post (create)
    • one for GET /post/new, the new post form
    • one for GET /post/:id/edit, the update post form
  • one thing new devs frequently struggle with when setting up the above is route priority - if there’s a route for GET /post/:id listed before GET /post/new, then the new action won’t ever get routed to. The ordering in Mar.Routes.route isn’t customizable, so what happens if that ordering is wrong? The check for “length of leading hard matches” should help, but it won’t apply to more-complicated paths like /post/:id/comments/new.

4 Likes

Thanks you so much for the feedback!
I’m grateful that you cared to try it out and take a look into code.

What a basic mistake! I tested poorly. I’ll fix this right away.

Headers and cookies were indeed out of scope for this demo, I thought I would wrap up with path, params and response this time. I’ll find a way to incorporate them in the design later.

True, I thought I would find a way to incorporate them in one or two routes in the future. Would that be possible?

I think I implemented the path priority for more and earlier hard matches. That took the most of my time :sob: is it not working as expected?

Thanks again for the kind feedback!

I didn’t mean smaller on the LoC size, I meant smaller on the conceptual/ergonomic side. The simplest abstraction in Elixir are functions. Asking the user to define modules per path or to customize routes is, in my opinion, pointing them towards the wrong (larger) abstraction. :slight_smile:

8 Likes

I see. I’ll keep that in mind.
Thanks for the kind advice!

Yeah, I think it could be expressed using macros that decorate individual functions, similar to what the attr macro does for a component in phoenix. I’m sort of thinking something like this:

defmodule MyApp do
  use Mar # require and import the macros

  get path: "/some/url" # get request macro
  before &MyApp.Blah.some_before_function/1 # optional macros that serve the same purpose the the Mar.Route protocol did
  after &MyApp.Blah.some_after_function/1
  def function_name_of_my_choice(_params) do
    """
    <!-- Some sort of html -->
    """
  end
end

I’m kind of leaning towards having a different macro for each of the different kinds of http requests versus a single response macros. Also, it could be useful for being able to couple different path requests within a single module, like if you were using htmx or something along those lines.

2 Likes

I just also wanted to chime in with some encouragement here. I think it’s a worthwhile effort, and I think your intuition is correct in that if you find it necessary then others would probably like it as well.

I’ve seen the likes of @wojtekmach get peppered with dissent about his Req library with questions like “why do we need another HTTP client?”, and now it’s becoming the defacto HTTP client and the only one I use.

Just food for though. Good luck going forward!

5 Likes