Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

📖 A tutorial showing how to return different content for the same route based on accepts header. Build a Web App and JSON API!

License

NotificationsYou must be signed in to change notification settings

dwyl/phoenix-content-negotiation-tutorial

Repository files navigation

A tutorial showing how to returndifferent content (format)for thesame route based onAccepts header.

Build Statuscodecov.iocontributions welcomeHitCount

Why? 🤷

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.

Goal? 🎯

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.


What? 💡

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.

Context

In ourAppwe want to ensure thatall requests that can be made in theWeb UIhave 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.

WhatIs Content Negotiation? 💭

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

What Are We Building? ✨

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.


Try It! 💻

Before you attempt to follow the example,Try the Heroku example version so you know what to expect.

Browser 📱

Visit:https://phoenix-content-negotiation.herokuapp.com

wake-sleeping-heroku-app

You should see a random inspiring quote:

turn-you-face-toward-the-sun

Terminal ⬛

Run the following command:

curl -H "Accept: application/json" https://phoenix-content-negotiation.herokuapp.com

You should see a random quote asJSON:

anais-nain-quote-heroku-terminal


Who? 👤

This example is aimed atanyone building a Phoenix Appwho wants toautomatically have a REST API.
For us@dwylwho 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.


How? 💻

Prerequisites? ✅

This example assumes you haveElixir andPhoenixinstalled 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!


0. Run theFinished App ⬇️

We encourage everyone to"Begin With the End in Mind"so suggest that you run finished App on yourlocalhostbefore 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.

Clone the Repository 📋

In your terminal, clone the repo from GitHub:

git clone git@github.com:dwyl/phoenix-content-negotiation-tutorial.git

Install The Dependencies 📦

Change into the newly created directory and run themix command:

cd phoenix-content-negotiation-tutorialmix deps.get

Run the App 🚀

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

Test it in your Browser 🖥️

Visit:http://localhost:4000

You should see a random motivational quote like this:

gothe-spend-your-time-quote

Test it in your Terminal ⬛

In your terminal, run the followingcurl command:

curl -H "Accept: application/json" http://localhost:4000

You should see arandom quote:

enthusiasm-terminal-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!


1. Create New Phoenix App

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 creatingnew 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 .

1.1 Check That EverythingWorks

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):

phoenix-homepage-default

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

2. Add Quotes!

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.gmix.exs#L47

Then run:

mix deps.get

That will download thequotes package which contains thequotes.jsonfileand Elixir functions to interact with it.

2.1 Try It iniex!

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.


3. Generate theQuotes Controller, View, Templates and Tests

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 aContextabstraction 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

3.1 Add the Quotes Resources tolib/app_web/router.ex

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

Yourrouter.ex file should now look like this:router.ex#L20

3.2 Tidy Up: Delete Unused Files (Optional)

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


3.3 Compilation Error ... 🤷‍

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 toCtxand 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#3832chris-closes-issue
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.htmlassume 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.exsand 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.exand 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:

quotes-rendered-html-working

With tests passing again and a random quote rendering,let's attempt to make a JSON request to theHTML endpoint(and see it fail).


3.4 Content NegotiationFails

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!


4. Create a Content Negotiation Pipeline inrouter.ex

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:negotiatewhich 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


5. HandleJSON requests inQuotesController

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.Controllerjson/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 thecURLcommand:

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

image


5.1 Fix Failing Tests!

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.exnegotiate/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/2andAppWeb.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

Yourquotes_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

Yourrouter.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.

5.2 Test the JSON Request

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 ofauthor andtextis 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.

Yourtest/app_web/controllers/quotes_controller_test.exsfile should now look like this:quotes_controller_test.exs#L10-L20


6. Tidy Up The Project (Optional)

At this stage we have a working app that shows random quotations.But anyoneviewing the app will first be greeted by irrelevant noise:

home-page-irrelevant

The home page of the App is the default Phoenix oneand has no info about what the app actuallydoes.

quotes-page-noise

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

Nothing-works-unless-you-do

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:

you-can-always-begin-again

6.1 MakeQuotesController the Default Route Handler

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

See:router.ex#L21-L25

Now when we visit the home pagehttp://localhost:4000we see a quote:

dale-carnegie-quote

Now we just add a picture of sunrise from Unsplash:https://unsplash.com/photos/UweNcthlmDc

See:index.html.eex#L7-L23

And boom we have a motivational quote generator:

quote-with-baground-image

teach-what-you-need-to-learn

6.2 Fix Failing Tests

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!


But Wait! There's More ...

So far in the tutorial we have shownfrom first principalshow to renderHTML andJSONin thesame route/controllerusing content negotiation.

While this approach isfine for an MVP/tutorial,we feel we can domuch better!

Part 2

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.

7. Add thecontent Package tomix.exs

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)

8. Add the Plug torouter.ex

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/2functionwe can safely remove it from therouter.ex file.

Before:router.ex#L4-L19
After:router.ex

Simple, right? 😉

9. Use theContent.reply/5 inQuotesController

Finally in thelib/app_web/controllers/quotes_controller.exreplace 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:

  1. conn - thePlug.Conn where we get thereq_headers from.
  2. render/3 - thePhoenix.Controller.render/3 function,or your own implementation of a render function thattakesconn,template anddata as it's 3 params.
  3. template - the.html template to be renderedif theaccept header matches"html"; in this case"index.html"
  4. json/2 - thePhoenix.Controller.json/2 functionthat rendersjson data.Or your own implementation that accepts the two params:conn anddata corresponding to thePlug.Connand thejson data you want to return.
  5. data - in this case theq (orquote) we want to renderasHTML orJSON.

With thissingle line we can renderHTML orJSONdepending 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/1if 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.eexfile from:@quote.text to@data.text to reflect howContent.reply/5labels the data.

9.1 Re-Run The Tests!

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

10. ViewJSON in a Web Browser

Sometimes while you are testing,you want to view theJSON data in Web Browser.Thecontent package allows you to add.jsontoany 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/jsonand send back the data asJSON.

There are two steps to enable this:

  1. Create a "wildcard" route in yourrouter.ex file:
get"/*wildcard",QuotesController,:redirect

e.g:/lib/app_web/router.ex#L21

  1. 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:

json-viewed-in-firefox-web-browser


Done.

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!

image




Notes & Observations


Q: Is there anOfficial Way of Doing Content Negotiation?

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):josevalim-plug-router

Chris advises to usePhoenix.Controller.get_format and pattern matching:chris-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

Stars

Watchers

Forks

Contributors3

  •  
  •  
  •  

[8]ページ先頭

©2009-2025 Movatter.jp