GithubHelp home page GithubHelp logo

midas-framework / midas Goto Github PK

View Code? Open in Web Editor NEW
163.0 10.0 4.0 224 KB

A web framework for Gleam, Midas makes shiny things.

License: Apache License 2.0

Erlang 100.00%
framework web http erlang erlang-otp gleam hex elixir

midas's Introduction

Midas - web development with Gleam

Gleam brings type safety to erlang. This is a fantastic combination for rapidly building robust web applications. We've been doing it since Gleam 0.10.0 at plummail.co

This repo DOES NOT contain a framework for you to start building web applications. We started building a framework but found that it was not necessary (yet) and instead focused on contributing to back to other Gleam libraries.

Instead this repo is a guide to web development in Gleam.

Gleam & Mix

We use Mix because Elixir has some great libraries that are easy to call from Gleam, and it has been the easiest way to have a project with both languages.

  • Start a project using mix new my_app --sup
  • Add mix_gleam and follow their instructions.

A Server

Battle tested web servers are an Erlang speciality, there are Gleam wrappers for Cowboy, Elli and Plug.

Adding Cowboy to your supervision tree so that Mix will start it.

  children = [
    # ...
    %{
      id: :cowboy,
      start: {
        :gleam@http@cowboy,
        :start, [&:my_app@web@router.handle(&1, config), 8080]
      }
    }
  ]

Note :my_app@[email protected] is a function on a gleam module, we will cover it in the next section.

Routing, Action, Controllers

The gleam_http library defines request and response types for HTTP. The utilities in this library and the power of pattern matching is everything we use.

fn route(request, config) {
  case http.path_segments(request) {
    [] -> {
      io.debug("Do something for the homepage")
      http.response(200)
      |> Ok
    }
    ["user", user_id] -> {
      io.debug("Hello user")
      http.response(200)
      |> Ok
    }
    ["api" | rest] -> api_router.route(request, config)
  }
}

pub fn handle(request, config) {
  case route(request, config) {
    Ok(response) -> response
    Error(reason) -> todo("define your error response")
  }
}

We found it convenient to allow routes to return errors because it gives you early return when using the (extremely helpful) try syntax.

Handling input

We don't normally create a controller or action module. All parsing/rendering is done in the case statement show above and we call out to business logic functions. e.g.

// in case statement of router
["posts", "create"] -> {
  try json = parse_form(request)
  try params = create_post.params(json)
  try user_id = load_session(request, config.secret)
  try post = create_post.execute(topic, user_id)
  redirect(string.concat["posts", int.to_string(post.id)])
  |> Ok
}

Note all of our functions at this level return the same Error type. The Error type is defined by our application, functions like parse_form are wrappers around uri.parse_query (part of Gleam standard library) that transform errors into our application specific Error.

JSON

We maintain gleam_json, to handle JSON input we define application helpers than transform errors in the same way as parse_form

Views

Gleam does not (yet) have any string interpolation or templating, the easiest way we found to work around this was to use EExHTML and wrap calls as external function calls. Note this was not very convenient and we are currently not doing this because our service is just a JSON API.

Database Access

We use Postgresql and the pgo library, there is a gleam wrapper

This does not give us models, we haven't missed them. (I would argue models have less value in a functional world, but different projects might miss them).

All of our SQL is hand written queries and we have helpers to make sure that errors are wrapped in out application specific error type.

pub fn insert_user(email_address) {
  let sql = "
    INSERT INTO users (email_address)
    VALUES ($1)
    RETURNING id, email_address
  "

  let args = [pgo.text(email_address)]
  try [identifier] = run_sql(sql, args, row_to_user)
}

pub fn row_to_user(row) {
  assert Ok(id) = dynamic.element(row, 0)
  assert Ok(id) = dynamic.int(id)
  assert Ok(email_address) = dynamic.element(row, 1)
  assert Ok(email_address) = dynamic.string(email_address)
  Ok(User(id, email_address))
}

Development process

To start the application run iex -S mix.

There is no live reloading set up, we type recompile() in the shell. Between the assurances of the type system and our tests most of the time we start it up it's already working so manually typing recompile works for us.

Final Note

Scaffolding a project that included sessions/flashes/etc would be great. We think Midas will become a Gleam web framework it just hasn't yet. The rest of this repo contains experiments on some of the pieces that framework would need.

midas's People

Contributors

crowdhailer avatar lpil avatar meadsteve avatar shalokshalom avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

midas's Issues

CORS

Implement a library for CORS

Tracking changes in Gleam Language/Libraries

Things I would like to have but that need to come from Gleam, if you would like to help with Midas helping on these core issues would be just as useful

High Priority

  • gleam-lang/gleam#34
    Fixing this would make choosing Gleam much more compelling in general. It is also important to understand how this will effect error handing when let Ok(value) = risky() no longer works

  • Done 0.8.0 gleam-lang/gleam#474
    A Request in particular is a large record, having to individually ignore every field is painful

  • Done stdlib API docs. API reference, language documentation is limited to guides currently.

Medium Priority

  • Library for interacting with SQL database,
  • Library for working with JSON,
  • Templating library, HTML escaping
    Templating is likely to be implemented in the core language

Low Priority

  • Function capture with any number of arguments, including 0
    error: Invalid capture
    
      let start = server.start_link(_, _)
    
    
    The function capture syntax can only be used with a single _ argument,
    but this one uses 2. Rewrite this using the fn(a, b) { ... } syntax.
    

Parameterize type of body in Request type

type Request(body) {
...

In stead of requiring the body to be a String, it could also be an iolist or steam. Doing this would allow applications more choices when dealing with large requests.

Supervisors are give a child spec with a run function rather than a spawn_link function

Supervisors currently accept a spawn_link function, this however relies on the client to do it's own spawn call, remembering to link. There is no way to determine if the process is linked to the supervisor. (I think). This feels like duplication, can the supervisor just take the run function and handling spawn. Doing this removes the opportunity to do anything before starting the process. I can't think of any examples where I use that time other than just create a wrapper.
Making this change would allow supervisors to specify that they will only run processes they expect to last forever.

e.g.

Child {
  Permanent(fn(Receive(m)) -> Never)
  Transient(fn(Receive(m)) -> r)
}

Routing/Endpoint DSL

I think there is value in always having a simple interface to the server which consists of a single request-> response function.

However there is a lot of boiler plate in writing API's so what would a DSL look like.

Routing Tree suggestion

  Match(
    "users",
    [
      Get(fn() { "All users" }),
      Param(
        string([MinLength(14), MaxLength(14)]),
        [
          Get(fn(user_id) { "user 1" }),
          Match("update", [Post(fn(user_id) { "update user 1" })]),
          Match(
            "posts",
            [
              Param(
                integer([Max(999)]),
                [
                  QueryParam(
                    "published",
                    boolean,
                    [
                      Get(fn(user_id: String) {fn(post_id: Int) { fn(published: Bool) { todo } } } ),
                    ],
                  ),
                ],
              ),
            ],
          ),
        ],
      ),
    ],
  )

Key feature of this one is every new extraction, e.g. Param/QueryParam takes a list of next steps, so branching can be introduced at any point, this reduces duplication.

List of endpoints suggestion.

  Choice(
    [
      Segment("users", Get(fn() { "All users" })),
      Segment("users", Param(string([MinLength(14), MaxLength(14)], Get(fn(user_id) { "user 1" })))),
      Segment("users", Param(string([MinLength(14), MaxLength(14)], Match("update", Post(fn(user_id) { "update user 1" }))))),
      Segment("users", Param(string([MinLength(14), MaxLength(14)], Match("posts", Param(integer([Max(999)]), QueryParam("published", boolean, Get(fn(user_id: String) {fn(post_id: Int) { fn(published: Bool) { todo } } } ))))))),
    ],
  )

Formatter makes mess of above but assuming you reuse user_id declaration and handlers are defined elsewhere, routing can be simplified

  user_id = string([MinLength(14), MaxLength(14)]
  post_id = integer([Max(999)])
  get_user = fn(user_id) { "user 1" }
  update_user = fn(user_id) { "update user 1" }
  get_published_posts = fn(user_id: String) {fn(post_id: Int) { fn(published: Bool) { todo } } } 
  
  Choice(
    [
      Segment("users", Get(get_users)),
      Segment("users", Param(user_id, Get(get_user)))),
      Segment("users", Param(user_id, Match("update", Post(update_user))))),
      Segment("users", Param(user_id, Match("posts", Param(post_id, QueryParam("published", boolean, Get(get_published_posts))))))),
    ],
  )

Need to use an explicit Choice type when more than one route is available.
Has more duplication but the behaviour of reach route is clearer.

Notes

  • To implement both require handlers as curried functions. it's probably possible to have a curry helper but I think they would need to be the function curry2 curry3 etc.
  • Does the user need to be able to specify the difference between, this didn't match check next route vs this didn't match return an error. My opinion is it would be ok to have defaults for this. i.e. Segmenet match failure will look at next branch, Header failure doesnt

I think it's possible to have controller files.

//get_posts.gleam
fn handle(user_id: String, post_id: Int, published: Bool) {
  todo
}

fn endpoint(){
  Segment("users", Param(user_id, Match("posts", Param(post_id, QueryParam("published", boolean, Get(curry3(handle)))))))
}


// router.gleam
import my_app/get_posts

Choice[
  get_posts.endpoint
]

In summary the DSL is a bunch of error composition
the second option (particularly if grouped by controller) might as well be the clear fn call approach.

  fn get_published_posts(){
      try tuple(user_id, post_id) = web.match(request, Segment("users", Param(uuid, Segment("posts", Param(int, Done(tuple2))))))
      try published = web.query_param(request, "published", integer([Max(999)]))
  }

Can have a Response(serializer: fn(x) -> r, Get(return x))
// Could serialize just OK value

The best thing to do is to practise a parsing API on a smaller section of the problem, e.g. form/query params, where all raw values are strings, entries come as a list not map, etc.
And then expand it to requests if working well

  [
    Required("blah", int),
    Optional()
    Many("foo", int, [])
  ]

Non goals, for next major release

Many web applications can be categorized as API services behind a load balancer. The first version of Midas is optimised for them.

Probably useful

  • HEAD method functionality
  • Default Logging Option

required for server rendered apps

  • HTML templates
  • views/layouts/partials
  • Sessions
  • Static content
  • asset compilation

non-goals:

  • SSL
  • Large File storage
  • Serving static files
  • Streaming
  • HTTP/2
  • chunked transfer encoding
  • middleware,
  • different body types, i.e iolist or promise
  • Websockets

Using midas within a Phoenix project

I think one route for adopting midas for me is if I could start it from within a mix/phoenix project and have it receive all HTTP requests and pass any that are unrecognised on to Phoenix. I'm unfamiliar with the lower levels of the HTTP stack in all of these projects so I've no idea how possible this might be.

From my perspective it would be the lowest friction way of introducing midas to a project that I already have. I was once keen for this kind of solution for migrating from Django to Phoenix obviously they are different tech stacks and I couldn't figure out how to make Phoenix proxy unrecognised requests over to the Django process so I ended up splitting all traffic at the nginx layer which is just a bit more hassle.

Anyway, I wanted to share the thought here as it would open up possibilities for trying Gleam & Midas more easily in some of my projects. Perhaps there are other downsides though and a clean break would be preferable for the development of the ecosystem.

Does it seem possible?

Docker

  • Would like a gleam docker image.
  • Use host network for simplicity

Logging

Can be hardcoded as a first version.

Bug reporting

Not sure how best to do this. Would be great at the Gleam level, rather than midas so errors in background tasks where handled as well as they are in request handlers.

Enumerate the response types

Somewhere we need to have the information on messages for response types, however it's extensible

  1. Define all the types
response(OK)
response(Custom(422, "Something"))

or have functions that return status + reason phrase

response(ok())
response(tuple(422, "Something)

Document a way to run "mix task"

Might not be mix tasks

Tasks

  • in a shell there is r3:compile() that should recompile source.
  • start a shell with --start-clean to start no application and run task.
  • can run eval "erl code" in release, a way of doing tasks
  • task.sh
rel start
rel eval "myapp:"

Better typing for trapped exits and monitor messages

See this Elixir forum post for my explination of typed processes up to this point.

Background

  1. An important difference between links and monitor is that an exit message can come from a Pid that it didn't know about, for example the parent process that used start_link. A monitor message will always come from a monitor the process it's self instantiated.
  2. Processes define their own mailbox message type, this means that process's sending to a mailbox must know the type. In the case of pub sub when the pubsub broker might have may types of clients connected the client should subscribe with a translate function that is used to cast the published update to an acceptable message type. I think the same principle can be applied to trapping exits and handling monitors. To set up either monitor/link the process must indicate how it will handle those messages.

Exits.

A process traps exits by providing the function that will handle the message.
Start with a process with the following message type

pub type Message {
  Foo
  Bar
  OhDear(BarePid, Dynamic)
}

The Exit type is OhDear to make clear its a type defined by the client, it would I expect normally be called exit in practice.

Option 1
process.spawn_link(fn(receive) {
  process.trap_exit(receive, fn(pid, reason) { OhDear(pid, reason) })

  // continue ...
})

Signature of trap_exit function.

fn trap_exit(fn(Wait) -> m, fn(BarePid, Reason) -> m)
  • Calling this function sets the flag in the process.
  • receive is only passed in to get the message type correct.
  • the mapping function can be stored in the process dictionary
  • this API should only be called once, which cannot be enforced except by a runtime check.
Option 2
process.spawn_link(fn(receive, trap_exit) {
  trap_exit(fn(pid, reason) { OhDear(pid, reason) })

  // continue ...
})
  • does not need receive to be passed to the trap_exit function.
  • Still needs to be called only once, cannot be enforced.
Option 3
process.spawn_link(fn(receive) {
  // continue ...
    
}, Ok(fn(pid, reason) { Exit(pid, reason) }))
  • No possibility of setting trap exit twice.
  • Increased Noise to start a process without trapping exits, need to pass an Error as configuration argument. see below
process.spawn_link(fn(receive) {
  // continue ...
    
}, Error(Nil))

Monitors

Very similar except the mapping function is passed when the monitor is created.

process.monitor(pid, fn(pid, reason) { MyDown(pid) })

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.