- Notifications
You must be signed in to change notification settings - Fork1
📖 A tutorial showing how to return different content for the same route based on accepts header. Build a Web App and JSON API!
License
dwyl/phoenix-content-negotiation-tutorial
Folders and files
Name | Name | Last commit message | Last commit date | |
---|---|---|---|---|
Repository files navigation
A tutorial showing how to returndifferent content (format)for thesame route based onAccepts
header.
As a small team of software engineers,we don't have resources (time)to maintain twoseparate applications(one for our App and another for an API)the way some larger companies do.We need to focus on buildingfeaturesthat peopleusing our products want/need.We want to be able to ship ourWeb UIand a corresponding feature-completeREST API in thesame Phoenix App.This way everyone using our Apphas a "default" UI/UX(the server-rendered client-enhanced Phoenix Web UI)whilesimultaneously giving peoplewho want/need API access,exactly what they need from day 1.We know fromexperience thatApps that focus on UIand leave the API for "later"end up producing a poor API experience.We want to avoid that at all costs.People who want to use the @dwyl APIexclusively and neverlook at the web UI,shouldalways be able to do that.If someone wants to use @dwyl from their CLIthey should be able to use 100% of the features.If they want to add items to their lists viaIFTTT orZapierthey should be able to do that without any obstacles.
Theonly way to achieve feature parity between our UI and APIis by making the API a"first class citizen"and requiring every feature we buildto render bothHTML
andJSON
.Building our app with Content Negotiation baked inguarantees thatanyone can use their creativityto buildany UI/UX to interface with their data.It also ensures that we have 100% accessibilitybecauseany device can access the data.We believe this is a moreinclusive way to build Appseven if it adds a 5-10% more "work" up-front,it's 100% worth it for achieving ourmission!Bycombining the Web UI and API into thesame Phoenix Application,we only have one thing to focus on, deploy, scale and maintain.
This tutorial shows how simple it isto turnany Phoenix Web App into a REST APIusing thesame routes as your Web UI.
Our goal is:to run thesame Phoenix Application for both our Web UI and REST APIand have thesame route handler (Controller)transparently return the appropriate content (HTML or JSON)based on theAccept
header.
So a request made in aWeb Browser will display HTMLwhereas a cURL command in a terminal(or request from any other Frontend-only App)will return JSON for thesame URL.
That way we ensure thatall routes in our Apphave the equivalent JSON responsesoevery action can be performedprogramatically.Which meansanyone can build theirown Frontend UI/UXfor the @dwyl App.We believe this iscrucial to the success of our product.We think the APIis ourProductand the Web UIis justone representation of what ispossibleto build with the API.
This tutorial shows how to do content negotiationin a Phoenix App from first principals.
If you just want to implementcontent negotiation in your projectas fast as possible see:github.com/dwyl/content.
We stillrecommend following this tutorialas it only takes 20 mins andwill ensure youunderstandhow to do it from scratch.
In ourAppwe want to ensure thatall requests that can be made in theWeb UI
have a correspondingJSON
responsewithout anyduplication ofeffort.Wedefinitely don't want to have torun/maintain twoseparate Phoenix Appsas we know (from experience)that the functionality will divergealmost immediatelyas a contributor who is building their own UIwill make an API-focussed addition andforgetto add the corresponding web UI (or vice versa).We don't want to have to "police" the PRsorforce anyone to have to write the same code twice.We want a JSON response to beautomatically availablefor every route and never have to think about it.We wantanyone to be able to build an App/UIusing our API.
Content negotiation is the process of selectingthe best representation for a given responsewhen there are multiple representations available."~RFC 2616/7231
The gist is that depending on theAccept
headerspecified by the requesting agent (e.g. a Web Browser or script),a differentrepresentation of the content can be returned.
If the concept of HTTP content negotiation is new to you,we suggest you read the detailed article on MDN (5 mins):https://developer.mozilla.org/en-US/docs/Web/HTTP/Content_negotiation
The aim of this tutorial is to demonstratecontent negotiation in a real-world scenario.
We are going to build a simple interface to displayfamous quotations, both a basic Web UI and REST API.
When we visit:/
in a browserwe see a random quotation rendered asHTML
.
When wecurl
thesame endpoint,we see aJSON
representation.
Before you attempt to follow the example,Try the Heroku example version so you know what to expect.
Visit:https://phoenix-content-negotiation.herokuapp.com
You should see a random inspiring quote:
Run the following command:
curl -H "Accept: application/json" https://phoenix-content-negotiation.herokuapp.com
You should see a random quote asJSON
:
This example is aimed atanyone building a Phoenix Appwho wants toautomatically have a REST API.
For us@dwyl
who are building our API and App Web UI simultaneously,it serves as a gentle intro to the topic.
If you get stuck or haveany questions,pleaseask.
This example assumes you haveElixir
andPhoenix
installed on your computerand that you have somebasic familiaritywith the language and framework respectively.If you are totally new to either of these,we recommend youfirst read:github.com/dwyl/learn-elixirandgithub.com/dwyl/learn-phoenix-framework
Ideally follow the "Chat" examplefor more detailed step-by-step introduction to Phoenix:github.com/dwyl/phoenix-chat-example
Once you are comfortable with Phoenix, proceed with this example!
We encourage everyone to"Begin With the End in Mind"so suggest that you run finished App on yourlocalhost
before attempting to build it.Seeing the Appworking on your machine willgive you confidence that we will achieve our objectives (defined above)and it's a good reference if you get stuck.
In your terminal, clone the repo from GitHub:
git clone git@github.com:dwyl/phoenix-content-negotiation-tutorial.git
Change into the newly created directory and run themix
command:
cd phoenix-content-negotiation-tutorialmix deps.get
Run the Phoenix app with the following command:
mix phx.server
You should see output similar to the following in your terminal:
[info] Running AppWeb.Endpoint with cowboy 2.7.0 at 0.0.0.0:4000 (http)[info] Access AppWeb.Endpoint at http://localhost:4000
Visit:http://localhost:4000
You should see a random motivational quote like this:
In your terminal, run the followingcurl
command:
curl -H "Accept: application/json" http://localhost:4000
You should see arandom quote:
Now that you know the end state of the tutorialworks,change out of the directory (cd ..
)and let's re-create it from scratch!
In your terminal, run the following command to create a new app:
mix phx.new app --no-ecto --no-webpack
When asked if you want toFetch and install dependencies? [Yn]
TypeY followed by theEnter key.
Note: This example only needs the bare minimum Phoenix;we don't need any JavaScript or Database.
For more info, see:https://hexdocs.pm/phoenix/Mix.Tasks.Phx.New.html
The beauty is that thissimple use-caseis identical to theadvanced one.Once you understand these basic principals,you "grock" how to use Content Negotiationin a more advanced app.
Note 2: We default to callingall our apps "App" for simplicity.Some people prefer other more elaborate names. We like this one.
Note 3: We have deliberately made this API "read only",again for simplicity.If you want toextend this tutorialto allow for creating
new
quotes both via UI and API,please open anissue.We think it could be agood idea to addPOST
endpoints as a "Bonus Level",but we don't want to complicate things for thefirst part of the tutorial.
Change into theapp
directory (cd app
)and open the project in your text editor (or IDE).
e.g:atom .
Before diving in to adding any features to our app,let's check that itworks.
Run the server in your terminal:
mix phx.server
Then visitlocalhost:4000
in your web browser.
You should see something like this (the default Phoenix home page):
Having confirmed that the UI works,let's run the tests:
mix test
You should see the following output in your terminal:
Generated app app...Finished in 0.02 seconds3 tests, 0 failures
In order to display quotes in the UI/API we need a source of quotes.Here's one we made earlier:https://hex.pm/packages/quotes
As per the instructions:https://github.com/dwyl/quotes#elixiradd thequotes
dependency tomix.exs
:
{:quotes,"~> 1.0.5"}
e.g
mix.exs#L47
Then run:
mix deps.get
That will download thequotes
package which contains thequotes.json
fileand Elixir functions to interact with it.
In your terminal type:
iex -S mix
In theiex
prompt type:Quotes.random()
you will see a random quote.
iex>Quotes.random()%{"author"=>"Lao Tzu","text"=>"If you would take, you must first give, this is the beginning of intelligence."}
Great! So we know our quotes library is loaded into our Phoenix App.
Quitiex
and let's get back to building the App.
mix phx.gen.html Ctx Quotes quotes author:string text:string tags:string source:string --no-schema --no-context
Note:
Ctx
is just an abbreviation forContext
.We willremove all references toCtx
in step 3.3 (below)because wereally don't need aContext
abstraction in asimple example like this. ✂️
In your terminal, you should see the following output:
* creating lib/app_web/controllers/quotes_controller.ex* creating lib/app_web/templates/quotes/edit.html.eex* creating lib/app_web/templates/quotes/form.html.eex* creating lib/app_web/templates/quotes/index.html.eex* creating lib/app_web/templates/quotes/new.html.eex* creating lib/app_web/templates/quotes/show.html.eex* creating lib/app_web/views/quotes_view.ex* creating test/app_web/controllers/quotes_controller_test.exsAdd the resource to your browser scope in lib/app_web/router.ex: resources "/quotes", QuotesController
Git commit of files created in this step:9a37b21
Let's follow the instructionsgiven by the output of themix phx.gen.html
commandto add the resources tolib/app_web/router.ex
.
Open therouter.ex
fileand locate thescope "/", AppWeb do
block:
scope"/",AppWebdopipe_through:browserget"/",PageController,:indexend
add the following line to the block:
resources"/quotes",QuotesController
Your
router.ex
file should now look like this:router.ex#L20
Themix phx.gen.html
command creates a bunch of filesthat are useful for "CRUD".In our case we are not going to be creating or editing any quotesas we already have our "bank" of quotes.For simplicity we don'twant to run a Database for this exampleso we can focus on rendering the content and not the "management".
Let'sdelete
the files we don'tneed so our project is tidy:
rm lib/app_web/templates/quotes/edit.html.eexrm lib/app_web/templates/quotes/form.html.eexrm lib/app_web/templates/quotes/new.html.eexrm lib/app_web/templates/quotes/show.html.eex
Commit:
2d4ca13
Sadly, thismix phx.gen
commanddoes not doexactly what we expect.
The--no-context
flag does notcreate acontext.ex
file,but thequotes_controller.ex#L4-L5still has references toCtx
and expects there to be an "implementation" of a Context.That means that if we attempt to run the tests now they will fail:
mixtest
You will see the following compilation error:
Compiling 18 files (.ex)== Compilation errorin file lib/app_web/controllers/quotes_controller.ex ==** (CompileError) lib/app_web/controllers/quotes_controller.ex:13:App.Ctx.Quotes.__struct__/1 is undefined, cannot expand struct App.Ctx.Quotes.Make sure the struct name is correct. If the struct name exists and is correctbut it still cannot be found, you likely have cyclic module usagein your code (stdlib 3.11.2) lists.erl:1354: :lists.mapfoldl/3 lib/app_web/controllers/quotes_controller.ex:12: (module) (stdlib 3.11.2) erl_eval.erl:680: :erl_eval.do_apply/6
We opened an issue to clarify the behaviour:phoenixframework/phoenix#3832
Turns out that "generators are first and foremost learning tools",fair enough.
If the generator doesn't doexactly what we expect,we just workaround it.
Let's make a few of quick updatesto thequotes_controller_test.exs
,quotes_controller.ex
andindex.html.eex
filesto avoid this compilation error.
The tests created bymix phx.gen.html
assume we are building a standard "CRUD" interface; we aren't.So we need todelete
those irrelevant testsand replace them.Open the filetest/app_web/controllers/quotes_controller_test.exs
and replace the contents with the following code:
defmoduleAppWeb.QuotesControllerTestdouseAppWeb.ConnCasedescribe"/quotes"dotest"shows a random quote",%{conn:conn}doconn=get(conn,Routes.quotes_path(conn,:index))asserthtml_response(conn,200)=~"Quote"endendend
Before:
quotes_controller_test.exs
After:quotes_controller_test.exs
Open thelib/app_web/controllers/quotes_controller.ex
and replace the contents with the following:
defmoduleAppWeb.QuotesControllerdouseAppWeb,:controller# transform map with keys as strings into keys as atoms!# https://stackoverflow.com/questions/31990134deftransform_string_keys_to_atoms(map)dofor{key,val}<-map,into:%{},do:{String.to_existing_atom(key),val}enddefindex(conn,_params)doq=Quotes.random()|>transform_string_keys_to_atomsrender(conn,"index.html",quote:q)endend
Before:
quotes_controller.ex
After:quotes_controller.ex
Finally, open thelib/app_web/templates/quotes/index.html.eex
fileand replace the contents with this code:
<h1>Quotes</h1><p>"<strong><em><%= @quote.text %></em></strong>" ~<%= @quote.author %></p>
Before:
quotes/index.html.eex
After:quotes/index.html.eex
Now re-run the tests:
mix test
You should see them pass:
Compiling 3 files (.ex)....Finished in 0.07 seconds4 tests, 0 failuresRandomized with seed 115090
Let's do a quick visual check.Run the Phoenix server:
mix phx.server
Then visitlocalhost:4000/quotes
in your web browser.
You should see a random quotation:
With tests passing again and a random quote rendering,let's attempt to make a JSON request to theHTML
endpoint(and see it fail).
At this stage if we run the server (mix phx.server
)and attempt to make a request to the/quotes
endpoint(in a different terminal window)with a JSONAccepts
header:
curl -i -H "Accept: application/json" http://localhost:4000/quotes
We will see the following error:
HTTP/1.1 406 Not Acceptablecache-control: max-age=0, private, must-revalidatecontent-length: 1915date: Fri, 15 May 2020 07:44:44 GMTserver: Cowboyx-request-id: Fg8j6sIqqtAKLiIAAAGB#Phoenix.NotAcceptableError at GET /quotesException:** (Phoenix.NotAcceptableError) no supported media type in accept header.Expected one of ["html"] but got the following formats: * "application/json" with extensions: ["json"]To accept custom formats, register them under the :mime libraryin your config/config.exs file: config :mime, :types, %{ "application/xml" => ["xml"] }And then run `mix deps.clean --build mime` to force it to be recompiled. (phoenix 1.5.1) lib/phoenix/controller.ex:1313: Phoenix.Controller.refuse/3 (app 0.1.0) AppWeb.Router.browser/2 (app 0.1.0) lib/app_web/router.ex:1: AppWeb.Router.__pipe_through0__/1 (phoenix 1.5.1) lib/phoenix/router.ex:347: Phoenix.Router.__call__/2 (app 0.1.0) lib/app_web/endpoint.ex:1: AppWeb.Endpoint.plug_builder_call/2 (app 0.1.0) lib/plug/debugger.ex:132: AppWeb.Endpoint."call (overridable 3)"/2 (app 0.1.0) lib/app_web/endpoint.ex:1: AppWeb.Endpoint.call/2 (phoenix 1.5.1) lib/phoenix/endpoint/cowboy2_handler.ex:64: Phoenix.Endpoint.Cowboy2Handler.init/4##Connection details###Params%{}###Request info* URI:http://localhost:4000/quotes* Query string:###Headers* accept: application/json* host: localhost:4000* user-agent: curl/7.64.1###Session%{}
And in the terminal running thephx.server
,you will see:
[debug]** (Phoenix.NotAcceptableError) no supported media type in accept header.Expected one of["html"] but got the following formats:* "application/json" with extensions:["json"]To accept custom formats, register them under the:mime libraryin your config/config.exs file:config :mime, :types, %{ "application/xml" => ["xml"]}And then run`mix deps.clean --build mime` to force it to be recompiled.(phoenix 1.5.1) lib/phoenix/controller.ex:1313: Phoenix.Controller.refuse/3(app 0.1.0) AppWeb.Router.browser/2(app 0.1.0) lib/app_web/router.ex:1: AppWeb.Router.__pipe_through0__/1(phoenix 1.5.1) lib/phoenix/router.ex:347: Phoenix.Router.__call__/2(app 0.1.0) lib/app_web/endpoint.ex:1: AppWeb.Endpoint.plug_builder_call/2(app 0.1.0) lib/plug/debugger.ex:132: AppWeb.Endpoint."call (overridable 3)"/2(app 0.1.0) lib/app_web/endpoint.ex:1: AppWeb.Endpoint.call/2(phoenix 1.5.1) lib/phoenix/endpoint/cowboy2_handler.ex:64: Phoenix.Endpoint.Cowboy2Handler.init/4
This is understandable given that the app doesn'thave any pipeline/route that accepts JSON requests.
Let's get on with the content negotiation part!
By default the Phoenix router separatesthe:browser
pipeline (which accepts"html"
)from the:api
(which accepts"json"
):
defmoduleAppWeb.RouterdouseAppWeb,:routerpipeline:browserdoplug:accepts,["html"]plug:fetch_sessionplug:fetch_flashplug:protect_from_forgeryplug:put_secure_browser_headersendpipeline:apidoplug:accepts,["json"]endscope"/",AppWebdopipe_through:browserget"/",PageController,:indexresources"/quotes",QuotesControllerend# Other scopes may use custom stacks.# scope "/api", AppWeb do# pipe_through :api# endend
By default the/api
scope is commented out.We arenot going to enable it,rather as per our goal (above)we want to have the API and UIhandled by thesame router pipeline.
Let'sreplace the code in therouter.ex
with the following:
defmoduleAppWeb.RouterdouseAppWeb,:routerpipeline:anydoplug:accepts,~w(html json)plug:negotiateenddefpnegotiate(conn,[])do{"accept",accept}=List.keyfind(conn.req_headers,"accept",0)ifaccept=~"json"do# don't do anything for JSON (API) requests:connelse# setup conn for HTML requests:conn|>fetch_session([])|>fetch_flash([])|>protect_from_forgery([])|>put_secure_browser_headers([])endendscope"/",AppWebdopipe_through:anyget"/",PageController,:indexresources"/quotes",QuotesControllerendend
In this code we are replacing the:browser
pipelinewith the:any
pipeline that handles all types of content.The:any
pipeline invokes:negotiate
which is defined immediately below.
Innegotiate/2
we simply check theaccept
headerinconn.req_headers
.If theaccept
header matches the string"json"
,we don't need to do any further setup,otherwise we assume the request expects anHTML
responseinvoke the appropriate plugs that were in the:browser
pipeline.
Note: weknow this is not "production" code.This is just an"MVP"for how to do content negotiation.We will improve it below!
At the end of this step, your router file should look like this:router.ex
Now that ourrouter.ex
pipelineis setup to acceptany content type,we need tohandle the request for JSON in our controller.
Open thelib/app_web/controllers/quotes_controller.ex
fileand update theindex/2
function with the following:
defindex(conn,_params)doq=Quotes.random()|>transform_string_keys_to_atoms{"accept",accept}=List.keyfind(conn.req_headers,"accept",0)ifaccept=~"json"dojson(conn,q)elserender(conn,"index.html",quote:q)endend
Here we use thePhoenix.Controller
json/2to sends a JSON response.
It uses the configured:json_library
(Jason
)under the:phoenix
applicationfor:json
to pick up the encoder module.
At this point our rudimentary content negotiation isworking.Try it: run the Phoenix server:
mix phx.server
In a different terminal window/tab, run thecURL
command:
curl -i -H"Accept: application/json" http://localhost:4000/quotes
You should see output similar to this:
HTTP/1.1 200 OKcache-control: max-age=0, private, must-revalidatecontent-length: 86content-type: application/json; charset=utf-8date: Sat, 16 May 2020 14:25:51 GMTserver: Cowboyx-request-id: Fg-IYvb_4_U9xvYAAASh{"author":"Johann Wolfgang von Goethe","text":"Knowing is not enough; we must apply!"}
If you prefer tojust have the JSON response, omit the-i
flag:
curl -H"Accept: application/json" http://localhost:4000/quotes
Now you will just see the quotetext
andauthor
(and where available,tags
andsource
):
{"author":"Ernest Hemingway","source":"https://www.goodreads.com/quotes/353013","tags":"listen, learn, learning","text":"I like to listen. I have learned a great deal from listening carefully. Most people never listen."}
Confirm that it still works in the browser:http://localhost:4000/quotes
While the content negotiation worksfor returningHTML
andJSON
,the changes we have made will break the tests.
If you try to run the tests now you will see them fail:
mix test
1) test /quotes shows a random quote (AppWeb.QuotesControllerTest) test/app_web/controllers/quotes_controller_test.exs:5 ** (MatchError) no match of right hand side value: nil code: |> get(Routes.quotes_path(conn, :index)) stacktrace: (app 0.1.0) lib/app_web/router.ex:9: AppWeb.Router.negotiate/2 (app 0.1.0) AppWeb.Router.any/2 (app 0.1.0) lib/app_web/router.ex:1: AppWeb.Router.__pipe_through0__/1
This fails because we are attempting to get the"accept"
headerin therouter.ex
negotiate/2
functionbut there are no headers defined in our test!
In Plug (and thus Phoenix) tests,no headers are set by default.
This is the output of inspecting theconn
(IO.inspect(conn)
):
%Plug.Conn{adapter:{Plug.Adapters.Test.Conn,:...},assigns:%{},before_send:[],body_params:%Plug.Conn.Unfetched{aspect::body_params},cookies:%Plug.Conn.Unfetched{aspect::cookies},halted:false,host:"www.example.com",method:"GET",owner:#PID<0.335.0>,params:%Plug.Conn.Unfetched{aspect::params},path_info:[],path_params:%{},port:80,private:%{phoenix_recycled:true,plug_skip_csrf_protection:true},query_params:%Plug.Conn.Unfetched{aspect::query_params},query_string:"",remote_ip:{127,0,0,1},req_cookies:%Plug.Conn.Unfetched{aspect::cookies},req_headers:[],request_path:"/",resp_body:nil,resp_cookies:%{},resp_headers:[{"cache-control","max-age=0, private, must-revalidate"}],scheme::http,script_name:[],secret_key_base:nil,state::unset,status:nil}
The important line is:
req_headers:[],
req_headers
is anempty List.
There are two ways of fixing this failing test:
a. We include the right"accept"
header in each test.
b. We set adefault
value if there is no"accept"
header defined.
If we go with the first option,we will need to add an accept header in the test:
test"shows a random quote",%{conn:conn}doconn=conn|>put_req_header("accept","text/html")|>get(Routes.quotes_path(conn,:index))asserthtml_response(conn,200)=~"Quote"end
This is fine in an individual case,but it will get old if we are using content negotiationin a more sophisticated app with dozens of routes.
Weprefer to create a helper functionthat sets a default value if noaccept
header is set.Open thelib/app_web/controllers/quotes_controller.ex
fileand add the following helper function:
@doc"""`get_accept_header/1` gets the "accept" header from req_headers.Defaults to "text/html" if no header is set."""defget_accept_header(conn)docaseList.keyfind(conn.req_headers,"accept",0)do{"accept",accept}->acceptnil->"tex/html"endend
We can nowuse this functionin both ourAppWeb.QuotesController.index/2
andAppWeb.Router.negotiate/2
functions:
With thelib/app_web/controllers/quotes_controller.ex
file still open,update theindex/2
function to:
defindex(conn,_params)doq=Quotes.random()|>transform_string_keys_to_atomsifget_accept_header(conn)=~"json"dojson(conn,q)elserender(conn,"index.html",quote:q)endend
Your
quotes_controller.ex
file should look like this:quotes_controller.ex#L10-L32
And inrouter.ex
update thenegotiate/2
function to:
defpnegotiate(conn,[])doifAppWeb.QuotesController.get_accept_header(conn)=~"json"doconnelseconn|>fetch_session([])|>fetch_flash([])|>protect_from_forgery([])|>put_secure_browser_headers([])endend
Your
router.ex
file should look like this:router.ex#L8-L18
Now re-run the tests and they will pass:
mix test
Expect to see:
Compiling 3 files (.ex)....Finished in 0.07 seconds4 tests, 0 failuresRandomized with seed 485
At this point we have functioning content negotiation in our little app.
At present we don't have a test that executes thejson
branch of our code.We know itworks from our terminal (manual cURL) testing,but we don't yet have anautomated test.Let's fix that!
Open thetest/app_web/controllers/quotes_controller_test.exs
fileand add the following test to it:
test"GET /quotes (JSON)",%{conn:conn}doconn=conn|>put_req_header("accept","application/json")|>get(Routes.quotes_path(conn,:index)){:ok,json}=Jason.decode(conn.resp_body)%{"author"=>author,"text"=>text}=jsonassertString.length(author)>2assertString.length(text)>10end
Note: we are asserting that the length of
author
andtext
is greater than a certain String length because we cannotmake any other assertions against arandom quotation.This is enough for our needs because we know that we were able toJason.decode
theconn.resp_body
indicating that it'svalidJSON
.
This will indirectly invoke theAppWeb.QuotesController.get_accept_header/1
functionthat extracts the"accept"
header fromconn.req_header
.So we should have full test coverage for our little project.
Your
test/app_web/controllers/quotes_controller_test.exs
file should now look like this:quotes_controller_test.exs#L10-L20
At this stage we have a working app that shows random quotations.But anyoneviewing the app will first be greeted by irrelevant noise:
The home page of the App is the default Phoenix oneand has no info about what the app actuallydoes.
The quotes route has the Phoenix Framework logo and Links to get Started,which areirrelevant to the person viewing the quote.
Let's start by removing the Phoenix Framework logo,"Get Started" and "LiveDashboard" links from the layout template.
Open thelib/app_web/templates/layout/app.html.eex
fileand replace the contents with the following:
<!DOCTYPE html><htmllang="en"><head><metacharset="utf-8"/><metahttp-equiv="X-UA-Compatible"content="IE=edge"/><metaname="viewport"content="width=device-width, initial-scale=1.0"/><linkrel="stylesheet"href="https://unpkg.com/tachyons@4.12.0/css/tachyons.min.css"/><title>Random Motivational Quotes App</title></head><bodyclass="container w-100 helvetica tc"><%= @inner_content %></body></html>
This is a good simplification of the layout template.The only addition is the Tachyons CSS libraryso that we can have easy control over the layout and typography.If you want to learn more see:/dwyl/learn-tachyons
Before:app.html.eex
After:app.html.eex
If you run the Phoenix App now:
mix phx.server
And visit the/quotes
This is alreadymuch tidier.
But we can take it a step further.Next we will remove the "Quotes" headingfrom the quotesindex
template.
Open the/lib/app_web/templates/quotes/index.html.eex
fileand replace the contents with:
<pclass="f1 pa2"> "<strong><em><%= @quote.text %></em></strong>"<br/> ~<%= @quote.author %></p>
Note: the only two things that might be unfamiliarif you are new to Tachyons CSS are the two classes on the
<p>
tag.Thef1
just means "font size 1" or (H1)andpa2
means "padding all sides 2 units".
The quotes page now looks like this:
At present the "homepage" of the App is thePageController
(see screenshot above with pink square outlining irrelevant content).The person wanting to see the quotes has to navigate to/quotes
.Let's change it so that the quotes are rendered as the home page.
Open thelib/app_web/router.ex
file and locate thescope "/"
section:
scope"/",AppWebdopipe_through:anyget"/",PageController,:indexresources"/quotes",QuotesControllerend
Replace the code block with this simplified version:
scope"/",AppWebdopipe_through:anyresources"/",QuotesControllerend
Now when we visit the home pagehttp://localhost:4000we see a quote:
Now we just add a picture of sunrise from Unsplash:https://unsplash.com/photos/UweNcthlmDc
And boom we have a motivational quote generator:
We made a few changes in the previous stepwhich break our tests.
If you runmix test
you will see thatpage_controller_test.exs
are failing:
Compiling 4 files (.ex).... 1) test GET / (AppWeb.PageControllerTest) test/app_web/controllers/page_controller_test.exs:4 Assertion with =~ failed code: assert html_response(conn, 200) =~ "Welcome to Phoenix!" left: "<!DOCTYPE html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\"/>\n <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\"/>\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"/>\n <link rel=\"stylesheet\" href=\"https://unpkg.com/tachyons@4.12.0/css/tachyons.min.css\"/>\n <title>Random Motivational Quotes App</title>\n </head>\n <body class=\"container w-100 helvetica\">\n\n <p class=\"f1 ph5 tl\">\n \"<strong class=\"fw9\"><em>One who gains strength by overcoming obstacles possesses the only strength which can overcome adversity.</em></strong>\"\n <span class=\"fr\"> ~ Albert Schweitzer</span>\n </p>\n\n <small class=\"fixed right-0 bottom-1 mr3 white\" style=\"font-size: 0.1em;\">\n Sunrise Photo by\n <a class=\"no-underline white\" href=\"https://unsplash.com/photos/UweNcthlmDc\">\n Alice Donovan Rouse on Unsplash\n </a>\n </small>\n\n<style>\n body {\n background-image: url(https://i.imgur.com/TIAf9Il.jpg);\n background-repeat: no-repeat;\n background-size: cover;\n width: 100%;\n height: 100%;\n opacity: .8;\n }\n</style>\n </body>\n</html>\n" right: "Welcome to Phoenix!" stacktrace: test/app_web/controllers/page_controller_test.exs:6: (test)Finished in 0.1 seconds5 tests, 1 failureRandomized with seed 305070
This test willnever pass againbecause we are no longerusingPageController
in our project.
So, let'sdelete
the controller, view, templateand the corresponding test files:
rm lib/app_web/controllers/page_controller.exrm lib/app_web/templates/page/index.html.eexrm lib/app_web/views/page_view.exrm test/app_web/controllers/page_controller_test.exsrm test/app_web/views/page_view_test.exs
Deleting code (and the corresponding tests)is an important part ofmaintenance in a software project.Don't be afraid of doing it.You canalways recover/restore deleted codebecause it's still there in yourgit
history.
See commit:dcc322a
Now when we runmix test
we see them pass (as expected):
Generated app app....Finishedin 0.1 seconds4 tests, 0 failuresRandomized with seed 746624
With the tests passing, we aredone!
So far in the tutorial we have shownfrom first principalshow to renderHTML
andJSON
in thesame route/controllerusing content negotiation.
While this approach isfine for an MVP/tutorial,we feel we can domuch better!
In the first part of this tutorial,we saw how to add Content Negotiationto a Phoenix App fromfirst principals.
In the next 2 mintues we willrefactorour Phoenix Appto use thecontent
package.
Open themix.exs
file,locate thedeps
definition and add the following line:
{:content,"~> 1.3.0"},
e.g:mix.exs#L52-L53
Install the dependency:
mix deps.get
You should see output similar to the following:
New: content 1.3.0* Getting content (Hex package)
Open thelib/app_web/router.ex
fileand replace the line that readplug :negotiate
with:
plugContent,%{html_plugs:[&fetch_session/2,&fetch_flash/2,&protect_from_forgery/2,&put_secure_browser_headers/2]}
Note: those
&
and/2
additions to the names of plugsare theElixir
way of passing functions by reference.The&
means "capture" and the/2
is the Arity of the functionwe are passing.We wouldobviously prefer if functions were just variableslike they are in some other programming languages,but this works.See:https://dockyard.com/blog/2016/08/05/understand-capture-operator-in-elixirand:https://culttt.com/2016/05/09/functions-first-class-citizens-elixir
As we havereplaced thenegotiate/2
functionwe can safely remove it from therouter.ex
file.
Before:router.ex#L4-L19
After:router.ex
Simple, right? 😉
Finally in thelib/app_web/controllers/quotes_controller.ex
replace the lines:
ifget_accept_header(conn)=~"json"dojson(conn,q)elserender(conn,"index.html",quote:q)end
With:
Content.reply(conn,&render/3,"index.html",&json/2,q)
TheContent.reply/5
takes the 5 argument:
conn
- thePlug.Conn
where we get thereq_headers
from.render/3
- thePhoenix.Controller.render/3
function,or your own implementation of a render function thattakesconn
,template
anddata
as it's 3 params.template
- the.html
template to be renderedif theaccept
header matches"html"
; in this case"index.html"
json/2
- thePhoenix.Controller.json/2
functionthat rendersjson
data.Or your own implementation that accepts the two params:conn
anddata
corresponding to thePlug.Conn
and thejson
data you want to return.data
- in this case theq
(orquote
) we want to renderasHTML
orJSON
.
With thissingle line we can renderHTML
orJSON
depending on theaccept
header.
We candelete
theget_accept_header/1
functionwe created in step 5.1 (above)as it's now baked into theContent.reply/5
.
Note: it's still available asContent.get_accept_header/1
if we ever need it in one of our our Controllers.
If you need finer grained control in your controller,you can still write code like this:
ifContent.get_accept_header(conn)=~"json"dodata=transform_data(q)json(conn,data)elserender(conn,"index.html",data:q)end
Commit:3e4f49d
Note: we also updated ourlib/app_web/templates/quotes/index.html.eex
file from:@quote.text
to@data.text
to reflect howContent.reply/5
labels the data.
To confirm that the refactor is successful,re-run the tests:
mixtest
Everything still passes:
Compiling 3 files (.ex)....Finished in 0.08 seconds4 tests, 0 failuresRandomized with seed 452478
Sometimes while you are testing,you want to view theJSON
data in Web Browser.Thecontent
package allows you to add.json
toany route directly in the browser's URL fieldand view theJSON
representation of that route.
Content
will automatically recognise the requestupdate the accept header to beapplication/json
and send back the data asJSON
.
There are two steps to enable this:
- Create a "wildcard" route in your
router.ex
file:
get"/*wildcard",QuotesController,:redirect
e.g:/lib/app_web/router.ex#L21
- Create the corresponding handler function in your Controller:
defredirect(conn,params)doContent.wildcard_redirect(conn,params,AppWeb.Router)end
e.g:/lib/app_web/controllers/quotes_controller.ex#L16-L18
You can now visithttp://localhost:4000/.jsonin your web browserto view a random quote inJSON
format:
In this tutorial we learned how to do Content Negotiationfrom first principals.
Then we saw how to use thecontent
Plugto simplify our code!
If you found this useful,please ⭐ the repo on GitHub!
While there is no "official" guide in the docsfor how to do content negotiation,there is an issue/thread where it is discussed:phoenix/issues/1054
BothJosé Valimthe creator ofElixir
andChris McCordcreator ofPhoenix
have given input in the issue.So we have a fairly good notion that this istheacceptable way of doing content negotiation in a Phoenix App.
José outlines the Plug approach(this is what we did instep 4 above):
Chris advises to usePhoenix.Controller.get_format
and pattern matching:
This is relevant for the general use case but is not to ourspecific one.
Chris also created a Gist:https://gist.github.com/chrismccord/31340f08d62de1457454
Which shows how to do content negotiation based onparams.format
.We have used this approach into our tutorial.
Note:this issuephoenix/issues/1054is a textbook example ofwhywe open issues to ask questions.
The thread shows the initial uncertainty of the original poster.
There is adiscussion for why content negotiation is necessaryand suggested approaches for doing it.
Finally there is a comment from a personwho discovered the issueyears laterand found the thread useful.
3 years later we are using it as the basis for our solution!
In the future others will stumble upon itand be grateful that it exists.
Conclusion:Open issues with questions!It's theright thing to do to learn and discuss all topics.
Both people in your team and complete strangers will benefit!
About
📖 A tutorial showing how to return different content for the same route based on accepts header. Build a Web App and JSON API!
Topics
Resources
License
Uh oh!
There was an error while loading.Please reload this page.
Stars
Watchers
Forks
Contributors3
Uh oh!
There was an error while loading.Please reload this page.