Skip to content

Core microservice for dispatching requests to the preferred microservice

License

Notifications You must be signed in to change notification settings

mu-semtech/mu-dispatcher

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

mu-dispatcher

Core microservice for dispatching requests to the preferred microservice.

The mu-dispatcher is one of the core elements in the mu.semte.ch architecture. This service will dispatch requests to other microservices based on the incoming request path. You can run the service through docker, but you probably want to configure it using mu-project so it uses your own configuration.

The Dispatcher runs as an application in which the Dispatcher module is overridden. It expects a dispatcher.ex file to exist on /config/dispatcher.ex when the dispatcher boots up.

Table of Contents

  1. Configuration
  2. Supported API
    1. Matcher
    2. Http Verbs
    3. define_accept_types
  3. forwarding requests
    1. Basic forwarding
    2. Forwarding paths
    3. Matching on verb
    4. Matching on host
    5. Matching Accept headers
    6. Layers
  4. Fallback routes and 404 pages
  5. Manipulating responses
  6. How-to / Extra information
    1. Host an EmberJS app
    2. External API CORS headers
    3. Provide 404 pages
  7. Architecture
    1. forwarding connections with plug_mint_proxy
    2. Wiring with Plug
    3. Header manipulation
    4. Matcher

Configuration

The disptacher is configured using the dispatcher.ex file in a mu-project.

The basic (default) configuration of the mu-dispatcher is an Elixir module named Dispatcher which uses the Matcher functionality.
An empty set of accept types is required (define_accept_types []).

defmodule Dispatcher do
  use Matcher
  define_accept_types []

  ...
end

Supported API

Use Matcher

The using Matcher macro sets up the matcher. It imports Matcher, send_resp and forward.

Http verbs

get, put, post, delete, patch, head, options, match

Implements a specific matcher on the http verb with the corresponding name. The match macro matches all verbs.

Accepts:

  • path: A string which is deconstructed into variables.
  • options: The options hash containing options to match on for this call:
    • accept: hash with all required accept shortforms analyzed through define_accept_types
    • last_call: set to true when searching for a fallback solution (for sending a clean 404)
  • block: Code block for processing and sending the request

Supplies:

  • conn: Plug connection to be forwarded or responded to
  • path: Often the path as input is set as "/something/*path" in which case the path variable contains unused path segments

define_accept_types

Provides a way to match the accept types to more readable terms so matching can happen in an easy and consistent manner. Receives a property array describing each of the keys that will be used and their corresponding accept headers. Wildcards are allowed in this specification.

Forwarding requests

Basic forwarding

You can proxy one path to another path using this service. In order to forward requests coming in on /sessions to http://sessionsservice/login, we can add the following.

defmodule Dispatcher do
  use Matcher
  define_accept_types []

  match "/sessions", _ do
    forward conn, [], "http://sessionsservice/login"
  end
end

This uses the match macro to match any verb, it ignores any extra info, and it forwards the connection to the sessionsservice. The body is just Elixir code, hence we can add any extra logic in here if need be. An example would be to log the conn we receive when forwarding.

defmodule Dispatcher do
  use Matcher
  define_accept_types []

  match "/sessions", _ do
    IO.inspect( conn, label: "conn for /sessions" )
    forward conn, [], "http://sessionsservice/login"
  end
end

Forwarding paths

In many cases it is desired to verbatim forward all subroutes of a route. A common case would be to dispatch the handling of some resource through mu-cl-resources. We can forward any call to widgets this way.

defmodule Dispatcher do
  use Matcher
  define_accept_types []

  match "/widgets/*path", _ do
    forward conn, path, "http://resource/widgets"
  end
end

This match will forward any verb on any path that begins with /widgets to the resource microservice.

Matching on verb

It is possible to explicitly match on an HTTP verb. Supported verbs are GET, PUT, POST, DELETE, HEAD, OPTIONS. Use the downcased name of the verb as a matching construct. In order to only forward POST requests, we would update our sample to the following:

defmodule Dispatcher do
  use Matcher
  define_accept_types []

  post "/sessions", _ do
    forward conn, [], "http://sessionsservice/login"
  end
end

Matching Accept headers

It is important to only dispatch to services which can formulate an acceptable response. This ensures the best response for the user is selected.

Clients can request content in many formats. These formats are expressed using MIME types. Common mime types on the web are are application/json, text/html, image/jpeg and (for {JSON:API}) application/vnd.api+json. Star patterns are allowed in mime types, allowing for image/* or just */* to be requested. A browser can supply many MIME types for a single request with differing preferences, allowing it to state "I would like to have an image, but a web page will do if you don't have the image."

When responding to a request, we should respond with applicable content. If the browser requests an image, we should yield an image rather than a json representation of that image. A non-functional example could look like this.

# THIS DOES NOT WORK!
defmodule Dispatcher do
  use Matcher
  define_accept_types []

  match "/images/*path", %{ accept: "image/jpeg" } do
    forward conn, path, "http://images/"
  end

  match "/images/*path", %{ accept: "application/json" } do
    forward conn, path, "http://resource/images/"
  end
end

In practice we tend to build abstractions on these MIME types. Resources responds with application/vnd.api+json which matches the specification for application/json. Hence, we'd want that service to respond to both of these. Abstractions of these settings are made using accept types abstractions as shown in the following example.

defmodule Dispatcher do
  use Matcher

  define_accept_types [
    json: [ "application/json", "application/vnd.api+json" ]
  ]

  match "/images/*path", %{ accept: %{ json: true } } do
    forward conn, path, "http://resource/images"
  end
end

A more convoluted example contains the hosting of images. We may have an image hosting/scaling/conversion service that supports JPEG and PNG, another service for handling GIFs. A full example would then look like the following:

defmodule Dispatcher do
  use Matcher

  define_accept_types [
    json: [ "application/json", "application/vnd.api+json" ],
    img: [ "image/jpg", "image/jpeg", "image/png" ],
    gif: [ "image/gif" ]
  ]

  get "/images/*path", %{ accept: %{ json: true } } do
    forward conn, path, "http://resource/images"
  end

  get "/images/*path", %{ accept: %{ img: true } } do
    forward conn, path, "http://images/images"
  end

  get "/images/*path", %{ accept: %{ gif: true } } do
    forward conn, path, "http://gifs/images"
  end
end

In this configuration the first case that matches wins. If the user prefers json that's what they'll get, the same for gif or jpeg.

Matching on host

Dispatching may occur based on a hostname. Both an array-format as well as a string-format are supported to match on a host. The string-format currently supports matching only, the array format also allows extraction.

In order to reply only to calls coming in for api.redpencil.io, you can use the rule:

  get "/employees", %{ host: ["io", "redpencil", "api"] } do
    ...
  end

Or, for simple matches like this, you can use a simplified API like:

  get "/employees", %{ host: "api.redpencil.io" } do
    ...
  end

This simplified syntax is internally converted into an array match. Wildcards are supported too:

  get "/employees", %{ host: "*.redpencil.io" } do
    ...
  end

This wildcard will match redpencil.io, api.redpencil.io, dev.api.redpencil.io, etc.

If you need to access a part of the API, revert back to the array syntax and define a variable:

  get "/employees", %{ host: ["io", "redpencil", subdomain | subsubdomains] }
    IO.inspect( subdomain, "First subdomain" )
    IO.inspect( subsubdomains, "Array of subdomains under subdomain" )
    ...
  end

This specific implementation does require at least one subdomain and it will thus not match redpencil.io.

Fallback routes and 404 pages

When no response can be given, a 404 page should be provided. This 404 page should only be offered when no other service could answer the request. Hence we should only serve the 404 page when all other services have had their go. The format of the 404 page follows the same accept header rules as the actual content so the same flow holds.

In order to provide a 404 page or other fallback, the last_call option is supplied on which you can filter. Extending our previous example with json, html, a 404 page in all sorts of images gives us the following result.

defmodule Dispatcher do
  use Matcher

  define_accept_types [
    text: [ "text/*" ],
    html: [ "text/html", "application/xhtml+html" ],
    json: [ "application/json", "application/vnd.api+json" ],
    img: [ "image/jpg", "image/jpeg", "image/png" ],
    gif: [ "image/gif" ],
    image: [ "image/*" ]
  ]

  get "/images/*path", %{ accept: %{ json: true } } do
    forward conn, path, "http://resource/images"
  end

  get "/images/*path", %{ accept: %{ img: true } } do
    forward conn, path, "http://images/images"
  end

  get "/images/*path", %{ accept: %{ gif: true } } do
    forward conn, path, "http://gifs/images"
  end

  get "/*_", %{ last_call: true, accept: %{ json: true } } do
    send_resp( conn, 404, "{ \"error\": { \"code\": 404, \"message\": \"Route not found.  See config/dispatcher.ex\" } }" )
  end

  get "/*_", %{ last_call: true, accept: %{ image: true } } do
    forward conn, [], "http://images/404"
  end

  get "/*_", %{ last_call: true, accept: %{ text: true } } do
    send_resp( conn, 404, "404 - page not found\n\nSee config/dispatcher.ex" )
  end

  get "/*_", %{ last_call: true, accept: %{ html: true } } do
    send_resp( conn, 404, "<html><head><title>404 - Not Found</title></head><body><h1>404 - Page not found</h1></body></html>" )
  end
end

Manipulating responses

The dispatcher is just code. As you start reusing the same properties more often, you may want to supply default values to clean things up. You can also add conditional logging, or manipulate the request before forwarding it to the client.

Although not considered a public API, it is possible to manipulate the request or to draft responses manually.

defmodule Dispatcher do
  use Matcher

  define_accept_types [
    json: [ "application/json", "application/vnd.api+json" ]
  ]

  @json %{ accept: %{ json: true } }

  match "/sessions/*path", @json do
    IO.inspect( conn, label: "Connection for sessions service." )
    forward conn, path, "http://sessions/login"
  end

  match "/images/*path", @json do
    forward conn, path, "http://resource/images"
  end

  match "/*_", %{ last_call: true, accept: %{ json: true } } do
    send_resp( conn, 404, "{ \"error\": { \"code\": 404, \"message\": \"Route not found.  See config/dispatcher.ex\" } }" )
  end
end

Layers

Layers help with organizing your forwarding rules.

You can define them like this: define_layers [ :api, :frontend ]!!!Order matters!!!

Lets look at an example:

define_layers [ :api, :frontend ]

define_accept_types [
    html: ["text/html", "application/xhtml+html"],
    json: ["application/json", "application/vnd.api+json"],
]

match "/*path", %{ accept: %{html: true}, layer: :api} do
    Proxy.forward conn, path, "http://frontend"
end

match "/*path", %{ accept: %{json: true}, layer: :api} do
    Proxy.forward conn, path, "http://api"
end

Lets say a request comes in from a browser looking for the index page. First the dispatcher will traverse the api layer (since its the first one defined). When nothing is found it will move on to the next layer (the html layer) where it will find a match

Extra information

This section contains various recipes to implement specific behaviour with basic explanation as to why it works.

Host an EmberJS app

The Ember application should be served on most calls when an HTML page is requested. The assets and styles should be served on the respective paths whenever they are requested.

defmodule Dispatcher do
  use Matcher

  define_accept_types [
    json: [ "application/json", "application/vnd.api+json" ],
    html: [ "text/html", "application/xhtml+html" ],
    any: [ "*/*" ]
  ]

  @html %{ accept: %{ html: true } }
  @json %{ accept: %{ json: true } }
  @any %{ accept: %{ any: true } }

  ... # your other rules belong here

  match "/assets/*path", @any do
    forward conn, path, "http://frontend/assets/"
  end

  match "/*_path", @html do
    # *_path allows a path to be supplied, but will not yield
    # an error that we don't use the path variable.
    forward conn, [], "http://frontend/index.html"
  end

  match "/*_", %{ last_call: true, accept: %{ json: true } } do
    send_resp( conn, 404, "{ \"error\": { \"code\": 404, \"message\": \"Route not found.  See config/dispatcher.ex\" } }" )
  end
end

External API CORS headers

When using the dispatcher with a frontend running on another domain, browsers need to know what headers they can pass to your service. In order to verify what headers are allowed, they send an options call. We can hook into this options call to set the necessary headers. Any 200 response makes the browsers accept those headers.

defmodule Dispatcher do
  use Matcher
  define_accept_types []

  options "*path", _ do
    conn
    |> Plug.Conn.put_resp_header( "access-control-allow-headers", "content-type,accept" )
    |> Plug.Conn.put_resp_header( "access-control-allow-methods", "*" )
    |> send_resp( 200, "{ \"message\": \"ok\" }" )
  end
end

Provide 404 pages

You can provide many types of 404 pages. If you use a Single Page Application, you may want to default to the 404 page using the single page app. You may want to provide a 404 json response in any case. The same holds for other formats.

The following example hosts 404 pages in various types assuming there is a static microservice that hosts static assets for you. Note that the HTML 404 option may be served by your backend instead.

defmodule Dispatcher do
  use Matcher

  define_accept_types [
    text: [ "text/*" ],
    html: [ "text/html", "application/xhtml+html" ],
    json: [ "application/json", "application/vnd.api+json" ],
    jpeg: [ "image/jpg", "image/jpeg" ],
    png: [ "image/png" ],
    gif: [ "image/gif" ],
  ]

  ... # other calls here

  get "/*_", %{ last_call: true, accept: %{ json: true } } do
    send_resp( conn, 404, "{ \"error\": { \"code\": 404, \"message\": \"Route not found.  See config/dispatcher.ex\" } }" )
  end

  get "/*_", %{ last_call: true, accept: %{ text: true } } do
    send_resp( conn, 404, "404 - page not found\n\nSee config/dispatcher.ex" )
  end

  get "/*_", %{ last_call: true, accept: %{ html: true } } do
    send_resp( conn, 404, "<html><head><title>404 - Not Found</title></head><body><h1>404 - Page not found</h1></body></html>" )
  end

  get "/*_", %{ last_call: true, accept: %{ jpeg: true } } do
    forward conn, [], "http://static/404.jpeg"
  end

  get "/*_", %{ last_call: true, accept: %{ png: true } } do
    forward conn, [], "http://static/404.png"
  end

  get "/*_", %{ last_call: true, accept: %{ gif: true } } do
    forward conn, [], "http://static/404.gif"
  end
end

Architecture

The Dispatcher offers support for forwarding connections and for dispatching connections.

Forwarding Connections

Forwarding connections is built on top of plug_mint_proxy which uses the Mint library for efficient creation of requests. Request accepting is based on Cowboy 2 which allows for http/2 support.

Wiring with Plug

Plug expects call to be matched using its own matcher and dispatcher. This library provides some extra support.
Although tying this in within Plug might be simple, the request is dispatched to our own matcher in plug_router_dispatcher.ex.

Header manipulation

The dispatcher knows about certain header manipulations to smoothen out configuration. These are configured using plug_mint_proxy's manipulators as seen in the Proxy module

  • Manipulators.AddXRewriteUrlHeader: Sets the x-rewrite-url header on the incoming request so backend services can figure out tho original request if needed.
  • Manipulators.RemoveAcceptEncodingHeader: Removes the accept_encoding header from the request as encryption is handled by the identifier and should not be hanlded by backend services.
  • Manipulators.AddVaryHeader: Adds the vary header with value "accept, cookie" so both of these are taken into account during incidental caching in between links or the user's browser.

Matcher

The Matcher module contains the bulk of the logic in this component. It parses the request Accept header and parses the accept types inside of it. It also parses the supplied accept types and searches for an optimal solution to dispatch to.

High-level the dispatching works as follows:

  1. Parse the accept header
  2. Group each score of the accept header (A)
  3. Match each (A) with the set of define_accept_types noting that a * wildmatch may occur in both, thus resulting in (B).
  4. For each (B) try to find a matched solution
  5. If a solution is found, return it
  6. If no solution is found, try to find a matched solution with the last_call option set to true