Suomen johtava Rails-talo Kisko järjestää kurssilaisille illanvieton pe 16.12. klo 16-18
Jos haluat mukaan, kysy ilmoittautumislinkkiä matti.luukkainen@helsinki.fi tai Discordissa kurssikanavalla tai @mluukkai
Jatkamme sovelluksen rakentamista siitä, mihin jäimme viikon 4 lopussa. Allaoleva materiaali olettaa, että olet tehnyt kaikki edellisen viikon tehtävät. Jos et tehnyt kaikkia tehtäviä, voit täydentää ratkaisusi tehtävien palautusjärjestelmän kautta näkyvän esimerkivastauksen avulla.
Suuri osa internetin palveluista hyödyntää nykyään joitain avoimia rajapintoja, joiden tarjoaman datan avulla sovellukset voivat rikastaa omaa toiminnallisuuttaan.
Myös oluihin liittyviä avoimia rajapintoja on tarjolla, ks. https://www.programmableweb.com/ hakusanalla beer
Käyttöömme valikoituu Beermapping API (ks. https://www.programmableweb.com/api/beer-mapping ja https://beermapping.com/api/), joka tarjoaa mahdollisuuden oluita tarjoilevien ravintoloiden tietojen etsintään.
Beermapingin API:a käyttävät sovellukset tarvitsevat yksilöllisen API-avaimen. Saat avaimen sivulta https://beermapping.com/api/ kirjauduttuasi ensin sivulle (kirjautumisen jälkeen vaihda selaimen osoiteriviltä osoite takaisin muotoon https://beermapping.com/api/). Vastaava käytäntö on olemassa hyvin suuressa osassa nykyään tarjolla olevissa avoimissa rajapinnoissa.
API:n tarjoamat palvelut on listattu sivulla https://beermapping.com/api/reference/
Saamme esim. selville tietyn paikkakunnan olutravintolat tekemällä HTTP-get-pyynnön osoitteeseen https://beermapping.com/webservice/loccity/[apikey]/[city]
Paikkakunta siis välitetään osana URL:ia.
Kyselyjen tekemistä voi kokeilla selaimella tai komentoriviltä curl-komennolla. Saamme esimerkiksi Espoon olutravintolat selville seuraavasti:
mluukkai@melkki$ curl https://beermapping.com/webservice/loccity/731955affc547174161dbd6f97b46538/espoo
<?xml version='1.0' encoding='utf-8' ?><bmp_locations><location><id>12411</id><name>Gallows Bird</name><status>Brewery</status><reviewlink>https://beermapping.com/location/12411</reviewlink><proxylink>http://beermapping.com/maps/proxymaps.php?locid=12411&d=5</proxylink><blogmap>http://beermapping.com/maps/blogproxy.php?locid=12411&d=1&type=norm</blogmap><street>Merituulentie 30</street><city>Espoo</city><state></state><zip>02200</zip><country>Finland</country><phone>+358 9 412 3253</phone><overall>91.66665</overall><imagecount>0</imagecount></location><location><id>21108</id><name>Captain Corvus</name><status>Beer Bar</status><reviewlink>https://beermapping.com/location/21108</reviewlink><proxylink>http://beermapping.com/maps/proxymaps.php?locid=21108&d=5</proxylink><blogmap>http://beermapping.com/maps/blogproxy.php?locid=21108&d=1&type=norm</blogmap><street>Suomenlahdentie 1</street><city>Espoo</city><state>Etela-Suomen Laani</state><zip>02230</zip><country>Finland</country><phone>+358 50 4441272</phone><overall>0</overall><imagecount>0</imagecount></location><location><id>21496</id><name>Olarin panimo</name><status>Brewery</status><reviewlink>https://beermapping.com/location/21496</reviewlink><proxylink>http://beermapping.com/maps/proxymaps.php?locid=21496&d=5</proxylink><blogmap>http://beermapping.com/maps/blogproxy.php?locid=21496&d=1&type=norm</blogmap><street>Pitkäniityntie 1</street><city>Espoo</city><state>Etela-Suomen Laani</state><zip>02810</zip><country>Finland</country><phone>045 6407920</phone><overall>0</overall><imagecount>0</imagecount></location><location><id>21516</id><name>Fat Lizard Brewing</name><status>Brewery</status><reviewlink>https://beermapping.com/location/21516</reviewlink><proxylink>http://beermapping.com/maps/proxymaps.php?locid=21516&d=5</proxylink><blogmap>http://beermapping.com/maps/blogproxy.php?locid=21516&d=1&type=norm</blogmap><street>Lämpömiehenkuja 3</street><city>Espoo</city><state>Etela-Suomen Laani</state><zip>02150</zip><country>Finland</country><phone>09 23165432</phone><overall>0</overall><imagecount>0</imagecount></location><location><id>21545</id><name>Simapaja</name><status>Brewery</status><reviewlink>https://beermapping.com/location/21545</reviewlink><proxylink>http://beermapping.com/maps/proxymaps.php?locid=21545&d=5</proxylink><blogmap>http://beermapping.com/maps/blogproxy.php?locid=21545&d=1&type=norm</blogmap><street>Kipparinkuja 2</street><city>Espoo</city><state>Etela-Suomen Laani</state><zip>02320</zip><country>Finland</country><phone></phone><overall>0</overall><imagecount>0</imagecount></location></bmp_locations>
Kuten huomaamme, vastaus tulee XML-muodossa. Käytänne on hieman vanhahtava, sillä tällä hetkellä ylivoimaisesti suosituin web-palveluiden välillä käytettävä tiedonvaihdon formaatti on json.
Selaimella näemme palautetun XML:n hieman ihmisluettavammassa muodossa:
HUOM1: älä käytä tässä näytettyä API-avainta vaan rekisteröi itsellesi oma avain.
HUOM2: syksyllä 2022 API ei löydä Espoosta yhtään baaria, kokeile joitan muuta kaupunkia! Suomen osalta API:n paikkatuntemus on heikko
Tehdään nyt sovellukseemme olutravintoloita etsivä toiminnallisuus.
Luodaan tätä varten sivu osoitteeseen places, eli määritellään route.rb:hen
get 'places', to: 'places#index'
ja luodaan kontrolleri:
class PlacesController < ApplicationController
def index
end
end
ja näkymä app/views/places/index.html.erb, joka aluksi ainoastaan näyttää hakuun tarvittavan lomakkeen:
<h1>Beer places search</h1>
<%= form_with url: places_path, method: :post do |form| %>
city <%= form.text_field :city %>
<%= form.submit "Search" %>
<% end %>
Lomake siis lähettää HTTP POST -kutsun places_path:iin. Määritellään tälle oma reitti routes.rb:hen
post 'places', to: 'places#search'
Päätimme siis että metodin nimi on search
. Laajennetaan kontrolleria seuraavasti:
class PlacesController < ApplicationController
def index
end
def search
render :index
end
end
Ideana on se, että search
-metodi hakee panimoiden listan beermapping API:sta, jonka jälkeen panimot listataan index.html:ssä eli tämän takia metodin search
lopussa renderöidään näkymätemplate index
.
Kontrollerista metodissa search
on siis tehtävä HTTP-kysely beermappin API:n sivulle. Paras tapa HTTP-kutsujen tekemiseen Rubyllä on HTTParty-gemin käyttö ks. https://github.com/jnunemaker/httparty. Lisätään seuraava Gemfileen:
gem 'httparty'
Otetaan uusi gem käyttöön suorittamalla komentoriviltä tuttu komento bundle install
Kokeillaan nyt etsiä konsolista käsin Helsingin ravintoloita (muista uudelleenkäynnistää konsoli):
> api_key = "731955affc547174161dbd6f97b46538"
> url = "http://beermapping.com/webservice/loccity/#{api_key}/"
> response = HTTParty.get "#{url}helsinki"
Kutsu palauttaa luokan HTTParty::Response
-olion. Dokumentaatiosta selviää, että oliolta voidaan kysyä esim. HTTP-pyynnön vastaukseen liittyvät headerit seuraavasti
> response = HTTParty.get "#{url}helsinki"
> response.headers
=> {"date"=>["Mon, 17 Sep 2018 20:43:11 GMT"],
"server"=>["Apache"],
"upgrade"=>["h2,h2c"],
"connection"=>["Upgrade, close"],
"set-cookie"=>["easylogin_session=eff28ad09a8f62046917a8c424e4b0b3; path=/"],
"expires"=>["Mon, 26 Jul 1997 05:00:00 GMT"],
"cache-control"=>
["no-store, no-cache, must-revalidate", "post-check=0, pre-check=0"],
"pragma"=>["no-cache"],
"last-modified"=>["Mon, 17 Sep 2018 20:43:11 GMT"],
"vary"=>["Accept-Encoding"],
"content-length"=>["972"],
"content-type"=>["text/xml;charset=UTF-8"]}
>
Headerit sisältävät HTTP-pyynnön vastaukseen liittyvää metadataa, esim. vastauksen muodon kertoo content-type
"content-type"=>["text/xml;charset=UTF-8"]
Eli vastaukseen liittyvä data on UTF-8-muodossa enkoodattua XML:ää.
HTTP-pyynnön statuskoodi selviää seuraavasti:
> response.code
=> 200
Statuskoodi ks. https://www.rfc-editor.org/rfc/rfc9110.html#name-successful-2xx on tällä kertaa 200 eli ok, kutsu on siis onnistunut.
Vastausolion metodi parsed_response
palauttaa metodin palauttaman datan Rubyn hashina:
> response.parsed_response
=> {"bmp_locations"=>
{"location"=>
[{"id"=>"6742",
"name"=>"Pullman Bar",
"status"=>"Beer Bar",
"reviewlink"=>"https://beermapping.com/location/6742",
"proxylink"=>"http://beermapping.com/maps/proxymaps.php?locid=6742&d=5",
"blogmap"=>"http://beermapping.com/maps/blogproxy.php?locid=6742&d=1&type=norm",
"street"=>"Kaivokatu 1",
"city"=>"Helsinki",
"state"=>nil,
"zip"=>"00100",
"country"=>"Finland",
"phone"=>"+358 9 0307 22",
"overall"=>"72.500025",
"imagecount"=>"0"},
{"id"=>"6743",
"name"=>"Belge",
"status"=>"Beer Bar",
"reviewlink"=>"https://beermapping.com/location/6743",
"proxylink"=>"http://beermapping.com/maps/proxymaps.php?locid=6743&d=5",
"blogmap"=>"http://beermapping.com/maps/blogproxy.php?locid=6743&d=1&type=norm",
"street"=>"Kluuvikatu 5",
"city"=>"Helsinki",
"state"=>nil,
"zip"=>"00100",
"country"=>"Finland",
"phone"=>"+358 10 766 35",
"overall"=>"67.499925",
"imagecount"=>"1"},
...
Vaikka palvelin siis palauttaa vastauksensa XML-muodossa, parsii HTTParty-gem vastauksen ja mahdollistaa sen käsittelyn suoraan miellyttävämmässä muodossa Rubyn hashinä.
Kutsun palauttamat ravintolat sisältävä taulukko saadaan seuraavasti:
> places = response.parsed_response['bmp_locations']['location']
> places.size => 12
Helsingistä tunnetaan siis 12 paikkaa. Tutkitaan ensimmäistä:
> places.first
=> {"id"=>"6742",
"name"=>"Pullman Bar",
"status"=>"Beer Bar",
"reviewlink"=>"https://beermapping.com/location/6742",
"proxylink"=>"http://beermapping.com/maps/proxymaps.php?locid=6742&d=5",
"blogmap"=>"http://beermapping.com/maps/blogproxy.php?locid=6742&d=1&type=norm",
"street"=>"Kaivokatu 1",
"city"=>"Helsinki",
"state"=>nil,
"zip"=>"00100",
"country"=>"Finland",
"phone"=>"+358 9 0307 22",
"overall"=>"72.500025",
"imagecount"=>"0"}
Luodaan panimoiden esittämiseen oma olio, kutsuttakoon sitä nimellä Place
. Sijoitetaan luokka models-hakemistoon.
class Place < OpenStruct
end
Koska luomme luokan olutravintolaa esittävän hashin perusteella, teemme luokan siten että perimme siihen Rubyn valmiin OpenStruct-luokan toiminnallisuuden.
OpenStructin avulla hash on helppo "kääriä" olioksi, joka mahdollistaa hashin kenttiin viittaamisen pistenotaatiolla.
Esim. jos meillä on normaali hash, joka on määritelty seuraavasti
baari_hash = {
"name"=>"Pullman Bar",
"status"=>"Beer Bar",
"city"=>"Helsinki"
}
joudumme viittaamaan sen kenttiin hakasulkeilla:
> baari_hash['name']
=> "Pullman Bar"
> baari_hash['city']
=> "Helsinki"
Jos "käärimme" hashin OpenStruct-olioksi:
> baari = OpenStruct.new baari_hash
pääsemme kaikkiin kenttiin käsiksi pistenotaatiolla:
baari.name
=> "Pullman Bar"
baari.city
=> "Helsinki"
ja näin saamme aikaan olion joka muistuttaa käyttötavaltaan normaaleja Railsin modeleja, kuten Beer, Brewery ym.
Emme kuitenkaan halua käyttää ohjelmassamme suoraan OpenStructeja ja siksi luomme olutpaikoille oman luokan Places joka perii OpenStructin:
class Place < OpenStruct
end
Oman luokan määritteleminen tekee koodista selkeämmän ja mahdollistaa tarvittaessa metodien liittämisen luokan olioille.
Luokkaamme siis käytetään siten, että annetaan sille konstruktoriparametriksi olutpaikkaa vastaava hash:
irb(main):011:0> baari = Place.new places.first
=> #<Place id="6742", name="Pullman Bar", status="Beer Bar", reviewlink="https://beermapping.com/location/6742", proxylink="http://beerma...
irb(main):012:0> baari.name
=> "Pullman Bar"
irb(main):013:0> baari.zip
=> "00100"
irb(main):014:0>
Kirjoitetaan sitten kontrolleriin alustava koodi. Kovakoodataan etsinnän tapahtuvan aluksi Helsingistä ja luodaan ainoastaan ensimmäisestä löydetystä paikasta Place-olio:
class PlacesController < ApplicationController
def index
end
def search
api_key = "731955affc547174161dbd6f97b46538"
url = "http://beermapping.com/webservice/loccity/#{api_key}/"
response = HTTParty.get "#{url}helsinki"
places_from_api = response.parsed_response["bmp_locations"]["location"]
@places = [ Place.new(places_from_api.first) ]
render :index, status: 418
end
end
Lisätään myös renderiin status-koodi 418, jotta Railsin käyttämä Turbo osaa renderöidä saman sivun uudestaan post-pyynnön jälkeen. Tämän statuskoodin avulla myös testit toimivat, sillä jos statuskoodiksi asettaisi esimerkiksi 303 testit hajoaisivat. Tämä hack on esimerkki huonosta koodista, mutta navigoidaksemme Turbon ja testien kanssa se on vaadittu.
Muokataan app/views/places/index.html.erb:tä siten, että se näyttää löydetyt ravintolat
<h1>Beer places search</h1>
<%= form_with url: places_path, method: :post do |form| %>
city <%= form.text_field :city %>
<%= form.submit "Search" %>
<% end %>
<% if @places %>
<ul>
<% @places.each do |place| %>
<li><%=place.name %></li>
<% end %>
</ul>
<% end %>
Koodi vaikuttaa toimivalta (huom. joudut uudelleenkäynnistämään Rails serverin jotta HTTParty-gem tulee ohjelman käyttöön).
Laajennetaan sitten koodi näyttämään kaikki panimot ja käyttämään lomakkeelta tulevaa parametria haettavana paikkakuntana:
def search
api_key = "731955affc547174161dbd6f97b46538"
url = "http://beermapping.com/webservice/loccity/#{api_key}/"
response = HTTParty.get "#{url}#{params[:city]}"
@places = response.parsed_response["bmp_locations"]["location"].map do | place |
Place.new(place)
end
render :index, status: 418
end
Sovellus toimii muuten, mutta jos haetulla paikkakunnalla ei ole ravintoloita, tapahtuu virhe.
Käyttämällä debuggeria huomaamme, että näissä tapauksissa API:n palauttama paikkojen lista näyttää seuraavalta:
{"id"=>nil, "name"=>nil, "status"=>nil, "reviewlink"=>nil, "proxylink"=>nil, "blogmap"=>nil, "street"=>nil, "city"=>nil, "state"=>nil, "zip"=>nil, "country"=>nil, "phone"=>nil, "overall"=>nil, "imagecount"=>nil}
Eli paluuarvona on hash. Jos taas haku löytää oluita paluuarvo on taulukko, jonka sisällä on hashejä. Virittelemme koodia ottamaan tämän huomioon. Koodi huomioi myös mahdollisuuden, jossa API palauttaa hashin, joka ei kuitenkaan vastaa olemassaolematonta paikkaa. Näin käy jos haetulla paikkakunnalla on vain yksi ravintola.
class PlacesController < ApplicationController
def index
end
def search
api_key = "731955affc547174161dbd6f97b46538"
url = "http://beermapping.com/webservice/loccity/#{api_key}/"
response = HTTParty.get "#{url}#{params[:city]}"
places_from_api = response.parsed_response["bmp_locations"]["location"]
if places_from_api.is_a?(Hash) && places_from_api['id'].nil?
redirect_to places_path, notice: "No places in #{params[:city]}"
else
places_from_api = [places_from_api] if places_from_api.is_a?(Hash)
@places = places_from_api.map do | location |
Place.new(location)
end
render :index, status: 418
end
end
end
Koodi on tällä hetkellä rumaa, mutta parantelemme sitä hetken kuluttua. Näytetään baareista enemmän tietoja sivulla. Määritellään näytettävät kentät Place-luokan staattisena metodina:
class Place < OpenStruct
def self.rendered_fields
[:id, :name, :status, :street, :city, :zip, :country, :overall ]
end
end
index.html.erb:n paranneltu koodi seuraavassa:
<h1>Beer places search</h1>
<p id="notice"><%= notice %></p>
<%= form_with url: places_path, method: :post do |form| %>
city <%= form.text_field :city %>
<%= form.submit "Search" %>
<% end %>
<% if @places %>
<table>
<thead>
<% Place.rendered_fields.each do |field| %>
<th><%= field %></th>
<% end %>
</thead>
<% @places.each do |place| %>
<tr>
<% Place.rendered_fields.each do |field| %>
<td><%= place.send(field) %></td>
<% end %>
</tr>
<% end %>
</table>
<% end %>
Ravintolat näytetään nyt HTML-taulukkona.
Taulukon rivit muodostava koodi on muodossa
<tr>
<% Place.rendered_fields.each do |field| %>
<td><%= place.send(field) %></td>
<% end %>
</tr>
Mistä tässä oikeastaan on kyse?
Ennen muutosta näkymä muodostettiin seuraavasti:
<% @places.each do |place| %>
<li><%= place.name %></li>
<% end %>
eli jokaisesta baarista näytettiin sen nimi eli place.name
Nykyinen koodimme saa aikaan saman kuin seuraava, helpommin ymmärrettävissä muodossa oleva koodi:
<tr>
<td><%= place.id %></td>
<td><%= place.name %></td>
<td><%= place.status %></td>
<td><%= place.street %></td>
<td><%= place.city %></td>
<td><%= place.zip %></td>
<td><%= place.country %></td>
<td><%= place.overall %></td>
</tr>
Rubyssä olioiden metodeja voidaan kutsua myös "epäsuoraan" käyttämällä metodia send
. Eli sen sijaan että sanomme place.name
voimme tehdä metdoikutsun syntaksilla place.send(:name)
. Olutpaikan rivin muodostaminen voidaan siis muuttaa muotoon:
<tr>
<td><%= place.send(:id) %></td>
<td><%= place.send(:name) %></td>
<td><%= place.send(:status) %></td>
<td><%= place.send(:street) %></td>
<td><%= place.send(:city) %></td>
<td><%= place.send(:zip) %></td>
<td><%= place.send(:country) %></td>
<td><%= place.send(:overall) %></td>
</tr>
Ja koska määrittelimme metodin Place.rendered_fields
palauttamaan listan [ :id, :name, :status, :street, :city, :zip, :country, :overall ]
, voimme generoida td-tagit each
-loopin avulla:
<tr>
<% Place.rendered_fields.each do |field| %>
<td><%= place.send(field) %></td>
<% end %>
</tr>
Kannattaako näin tehdä? Kyse on osittain makuasiasta. Määrittelemällä näytettävien kenttien listan saimme nyt tehtyä myös taulukon otsakerivin looppaamalla:
<thead>
<% Place.rendered_fields.each do |field| %>
<td><%= field %></td>
<% end %>
</thead>
Jos nyt päättäisimme lisätä tai poistaa jotain näytettäviä kenttiä, riittää kun muutamme luokan Places
määrittelemää listaa ja näkymään ei tarvitse erikseen koskea:
class Place < OpenStruct
def self.rendered_fields
[ :id, :name, :status, :street, :city, :zip, :country, :overall ]
end
end
Sovelluksessamme on vielä pieni ongelma Jos yritämme etsiä New Yorkin olutravintoloita on seurauksena virhe. Välilyönnit on korvattava URL:ssä koodilla %20. Korvaamista ei kannata tehdä itse 'käsin', välilyönti ei nimittäin ole ainoa merkki joka on koodattava URL:iin. Kuten arvata saattaa, on Railsissa tarjolla tarkoitusta varten valmis metodi ERB::Util.url_encode
. Kokeillaan metodia konsolista:
> ERB::Util.url_encode("St John's")
=> "St%20John%27s"
>
Tehdään nyt muutos koodiin korvaamalla HTTP GET -pyynnön tekevä rivi seuraavalla:
response = HTTParty.get "#{url}#{ERB::Util.url_encode(params[:city])}"
Tee edelläoleva koodi ohjelmaasi. Lisää myös navigointipalkkiin linkki olutpaikkojen hakusivulle
Railsissa kontrollereiden ei tulisi sisältää sovelluslogiikkaa. Ulkopuoleisen API:n käyttö onkin syytä eristää omaksi luokakseen. Luontevin paikka luokan koodille on hakemisto lib. Sijoitetaan siis seuraava tiedostoon lib/beermapping_api.rb:
class BeermappingApi
def self.places_in(city)
url = "http://beermapping.com/webservice/loccity/#{key}/"
response = HTTParty.get "#{url}#{ERB::Util.url_encode(city)}"
places = response.parsed_response["bmp_locations"]["location"]
return [] if places.is_a?(Hash) and places['id'].nil?
places = [places] if places.is_a?(Hash)
places.map do | place |
Place.new(place)
end
end
def self.key
"731955affc547174161dbd6f97b46538"
end
end
Luokka siis määrittelee stattisen metodin, joka palauttaa taulukon parametrina määritellystä kaupungista löydetyistä olutpaikoista. Jos paikkoja ei löydy, on taulukko tyhjä. API:n eristävä luokka ei ole vielä viimeiseen asti hiotussa muodossa, sillä emme vielä täysin tiedä mitä muita metodeja tarvitsemme.
Jotta lib-hakemistoon sijoitettu koodi toimisi (sekä omalla koneella, että Fly.io:ssa ja Herokussa), tulee tiedostoon config/application.rb lisätä seuraavat kaksi riviä
config.autoload_paths << Rails.root.join("lib")
config.eager_load_paths << Rails.root.join("lib")
lisäys tulee tehdä Application-luokan määrittelyn sisälle
module Ratebeer
class Application < Rails::Application
# ...
# lisäys tänne
end
end
Jotta muutokset tulevat voimaan täytyy sovellus käynnistää uudestaan.
Kontrollerista tulee nyt siisti:
class PlacesController < ApplicationController
def index
end
def search
@places = BeermappingApi.places_in(params[:city])
if @places.empty?
redirect_to places_path, notice: "No locations in #{params[:city]}"
else
render :index, status: 418
end
end
end
Tehdään seuraavaksi Rspec-testejä toteuttamallemme toiminnallisuudelle. Uusi toiminnallisuutemme käyttää siis hyväkseen ulkoista palvelua. Testit on kuitenkin syytä kirjoittaa siten, ettei ulkoista palvelua käytetä. Onneksi ulkoisen rajapinnan korvaaminen stub-komponentilla on Railsissa helppoa.
Päätämme jakaa testit kahteen osaan. Korvaamme ensin ulkoisen rajapinnan kapseloivan luokan BeermappingApi
toiminnallisuuden stubien avulla kovakoodatulla toiminnallisuudella. Testi siis testaa, toimiiko places-sivu oikein olettaen, että BeermappingApi
-komponentti toimii.
Testaamme sitten erikseen Rspecillä kirjoitettavilla yksikkötesteillä BeermappingApi
-komponentin toiminnan.
Aloitetaan siis web-sivun places-toiminnallisuuden testaamisesta. Tehdään testiä varten tiedosto /spec/features/places_spec.rb
require 'rails_helper'
describe "Places" do
it "if one is returned by the API, it is shown at the page" do
allow(BeermappingApi).to receive(:places_in).with("kumpula").and_return(
[ Place.new( name: "Oljenkorsi", id: 1 ) ]
)
visit places_path
fill_in('city', with: 'kumpula')
click_button "Search"
expect(page).to have_content "Oljenkorsi"
end
end
Testi alkaa heti mielenkiintoisella komennolla:
allow(BeermappingApi).to receive(:places_in).with("kumpula").and_return(
[ Place.new( name: "Oljenkorsi", id: 1 ) ]
)
Komento "kovakoodaa" luokan BeermappingApi
metodin places_in
vastaukseksi määritellyn yhden Place-olion sisältävän taulukon, jos metodia kutsutaan parametrilla "kumpula".
Kun nyt testissä tehdään HTTP-pyyntö places-kontrollerille, ja kontrolleri kutsuu API:n metodia places_in
, metodin todellisen koodin suorittamisen sijaan places-kontrollerille palautetaankin kovakoodattu vastaus.
Laajenna testiä kattamaan seuraavat tapaukset:
- jos API palauttaa useita olutpaikkoja, kaikki näistä näytetään sivulla
- jos API ei löydä paikkakunnalta yhtään olutpaikkaa (eli paluuarvo on tyhjä taulukko), sivulla näytetään ilmoitus "No locations in etsitty paikka"
Viikon 3 luku kirjautumisen hienosäätöä antaa vihjeitä toista kohtaa varten.
Siirrytään sitten luokan BeermappingApi
testaamiseen. Luokka siis tekee HTTP GET -pyynnön HTTParty-kirjaston avulla Beermapping-palveluun. Voisimme edellisen esimerkin tapaan stubata HTTPartyn get-metodin. Tämän on kuitenkin hieman ikävää, sillä metodi palauttaa HTTPartyResponse
-olion ja sellaisen muodostaminen stubauksen yhteydessä käsin ei välttämättä ole kovin mukavaa.
Parempi vaihtoehto onkin käyttää gemiä webmock https://github.com/bblimke/webmock/ sillä se mahdollistaa stubauksen HTTPartyn käyttämän kirjaston tasolla.
Otetaan gem käyttöön lisäämällä Gemfilen test-scopeen rivi gem 'webmock'
;
group :test do
# ...
gem 'webmock'
end
HUOM: webmock on määriteltävä ainoastaan test-scopeen, muuten se estää kaikki sovelluksen tekemät HTTP-pyynnöt!
Suoritetaan bundle install
.
Tiedostoon spec/rails_helper.rb
pitää vielä lisätä rivi:
require 'webmock/rspec'
Webmock-kirjaston käyttö on melko helppoa. Esim. seuraava komento stubaa jokaiseen URLiin (määritelty regexpillä /.*/
) tulevan GET-pyynnön palauttamaan 'Lapin kullan' tiedot XML-muodossa:
stub_request(:get, /.*/).to_return(body: "<beer><name>Lapin kulta</name><brewery>Hartwall</brewery></beer>", headers:{ 'Content-Type' => "text/xml" })
Eli jos kutsuisimme komennon tehtyämme esim. HTTParty.get("http://www.google.com")
olisi vastauksena
<beer>
<name>Lapin kulta</name>
<brewery>Hartwall</brewery>
</beer>
Tarvitsemme siis testiämme varten sopivan "kovakoodatun" datan, joka kuvaa Beermapping-palvelun HTTP GET -pyynnön palauttamaa XML:ää.
Eräs tapa testisyötteen generointiin on kysyä se rajapinnalta itseltään, eli tehdään komentoriviltä curl
-komennolla HTTP GET -pyyntö:
mluukkai@melkki$ curl http://beermapping.com/webservice/loccity/731955affc547174161dbd6f97b46538/turku
<?xml version='1.0' encoding='utf-8' ?><bmp_locations><location><id>18856</id><name>Panimoravintola Koulu</name><status>Brewpub</status><reviewlink>https://beermapping.com/location/18856</reviewlink><proxylink>http://beermapping.com/maps/proxymaps.php?locid=18856&d=5</proxylink><blogmap>http://beermapping.com/maps/blogproxy.php?locid=18856&d=1&type=norm</blogmap><street>Eerikinkatu 18</street><city>Turku</city><state></state><zip>20100</zip><country>Finland</country><phone>(02) 274 5757</phone><overall>0</overall><imagecount>0</imagecount></location></bmp_locations>
Nyt voimme copypastata HTTP-pyynnön palauttaman XML-muodossa olevan tiedon testiimme. Jotta saamme XML:n varmasti oikein sijoitetuksi merkkijonoon, käytämme hieman erikoista syntaksia
ks. http://blog.jayfields.com/2006/12/ruby-multiline-strings-here-doc-or.html jossa merkkijono sijoitetaan merkkien <<-END_OF_STRING
ja END_OF_STRING
väliin.
Seuraavassa tiedostoon spec/lib/beermapping_api_spec.rb sijoitettava testikoodi (päätimme sijoittaa koodin alihakemistoon lib koska testin kohde on lib-hakemistossa oleva apuluokka):
require 'rails_helper'
describe "BeermappingApi" do
it "When HTTP GET returns one entry, it is parsed and returned" do
canned_answer = <<-END_OF_STRING
<?xml version='1.0' encoding='utf-8' ?><bmp_locations><location><id>18856</id><name>Panimoravintola Koulu</name><status>Brewpub</status><reviewlink>https://beermapping.com/location/18856</reviewlink><proxylink>http://beermapping.com/maps/proxymaps.php?locid=18856&d=5</proxylink><blogmap>http://beermapping.com/maps/blogproxy.php?locid=18856&d=1&type=norm</blogmap><street>Eerikinkatu 18</street><city>Turku</city><state></state><zip>20100</zip><country>Finland</country><phone>(02) 274 5757</phone><overall>0</overall><imagecount>0</imagecount></location></bmp_locations>
END_OF_STRING
stub_request(:get, /.*turku/).to_return(body: canned_answer, headers: { 'Content-Type' => "text/xml" })
places = BeermappingApi.places_in("turku")
expect(places.size).to eq(1)
place = places.first
expect(place.name).to eq("Panimoravintola Koulu")
expect(place.street).to eq("Eerikinkatu 18")
end
end
Testi siis ensin määrittelee, että URL:iin joka loppuu merkkijonoon "turku" (määritelty regexpillä /.*turku/
) kohdistuvan HTTP GET -kutsun palauttamaan kovakoodatun XML:n, HTTP-kutsun palauttamaan headeriin määritellään, että palautettu tieto on XML-muodossa. Ilman tätä määritystä HTTParty-kirjasto ei osaa parsia HTTP-pyynnön palauttamaa dataa oikein.
Itse testi tapahtuu suoraviivaisesti tarkastelemalla BeermappingApi:n metodin places_in
palauttamaa taulukkoa.
Huom: stubasimme testissä ainoastaan merkkijonoon "turku" loppuviin URL:eihin (/.*turku
) kohdistuvat HTTP GET -kutsut. Jos testin suoritus aiheuttaa jonkin muunlaisen HTTP-kutsun, huomauttaa testi tästä:
) BeermappingApi When HTTP GET returns no entries, an empty array is returned
Failure/Error: places = BeermappingApi.places_in("kumpula")
WebMock::NetConnectNotAllowedError:
Real HTTP connections are disabled. Unregistered request: GET http://beermapping.com/webservice/loccity/731955affc547174161dbd6f97b46538/kumpula
You can stub this request with the following snippet:
stub_request(:get, "http://beermapping.com/webservice/loccity/731955affc547174161dbd6f97b46538/kumpula").
to_return(:status => 200, :body => "", :headers => {})
Kuten virheilmoitus antaa ymmärtää, voidaan komennon stub_request
avulla stubata myös merkkijonona määriteltyyn yksittäiseen URL:iin kohdistuva HTTP-kutsu. Sama testi voi myös sisältää useita stub_request
-kutsuja, jotka kaikki määrittelevät eri URLeihin kohdistuvien pyyntöjen vastaukset.
Laajenna testejä kattamaan seuraavat tapaukset
- HTTP GET ei palauta yhtään paikkaa, eli tällöin metodin
places_in
tulee palauttaa tyhjä taulukko- HTTP GET palauttaa useita paikkoja, eli tällöin metodin
places_in
tulee palauttaa kaikki HTTP-kutsun XML-muodossa palauttamat ravintolat taulukollisena Place-olioitaStubatut vastaukset kannattaa jälleen muodostaa curl-komennon avulla API:n tehdyillä kyselyillä
Muista käyttää debuggeria apuna testatessa
Erilaisten vale- ja lavastekomponenttien tekeminen eli metodien ja kokonaisten olioiden stubaus sekä mockaus on hyvin laaja aihe. Voit lukea aiheesta Rspeciin liittyen seuraavasta http://rubydoc.info/gems/rspec-mocks/
Nimityksiä stub- ja mock-olio tai "stubaaminen ja mockaaminen" käytetään usein varsin huolettomasti. Onneksi Rails-yhteisö käyttää termejä oikein. Lyhyesti ilmaistuna stubit ovat olioita, joihin on kovakoodattu valmiiksi metodien vastauksia. Mockit taas toimivat myös stubien tapaan kovakoodattujen vastausten antajana, mutta sen lisäksi mockien avulla voidaan määritellä odotuksia siitä miten niiden metodeja kutsutaan. Jos testattavana olevat oliot eivät kutsu odotetulla tavalla mockien metodeja, aiheutuu tästä testivirhe.
Mockeista ja stubeista lisää esim. seuraavassa: http://martinfowler.com/articles/mocksArentStubs.html
Tällä hetkellä sovelluksemme toimii siten, että se tekee kyselyn beermappingin palveluun aina kun jonkin kaupungin ravintoloita haetaan. Voisimme tehostaa sovellusta muistamalla viime aikoina suoritettuja hakuja.
Rails tarjoaa avain-arvopari-periaatteella toimivan hyvin helppokäyttöisen cachen eli välimuistin sovelluksen käyttöön.
Välimuisti on oletusarvoisesti poissa päältä. Saat sen päälle suorittamalla komentoriviltä komennon rails dev:cache
Muuta myös tiedostosta config/environments/development.rb rivit
config.cache_store = :memory_store
config.cache_store = :null_store
muotoon
config.cache_store = :file_store, 'tmp/cache_store'
sekä uudelleenkäynnistä konsoli ja sovellus.
Cacheen päästään käsiksi muuttujaan Rails.cache
talletetun olion kautta. Kokeillaan konsolista:
> Rails.cache.write "avain", "arvo"
=> true
> Rails.cache.read "avain"
=> "arvo"
> Rails.cache.read "kumpula"
=> nil
> Rails.cache.write "kumpula", Place.new(name: "Oljenkorsi")
=> true
> Rails.cache.read "kumpula"
=> #<Place:0x00000104628608 @name="Oljenkorsi">
Cacheen voi tallettaa melkein mitä vaan. Ja rajapinta on todella yksinkertainen, ks. http://api.rubyonrails.org/classes/ActiveSupport/Cache/Store.html
Ensimmäinen metodikutsu siis aiheuttaa tietokantahaun ja tallettaa olion välimuistiin. Seuraava kutsu saa avainta vastaavan olion suoraan välimuistista.
Oletusarvoisesti Railsin cache tallettaa avain-arvo-parit tiedostojärjestelmään. Cachen käyttämä talletustapa on kuitenkin konfiguroitavissa, ks. http://guides.rubyonrails.org/caching_with_rails.html#cache-stores
Tuotantokäytössä välimuistin datan tallettaminen tiedostojärjestelmään ei ole suorituskyvyn kannalta optimaalista. Parempi ratkaisu onkin esim. Memcached, ks. tarkemmin esim. https://devcenter.heroku.com/articles/building-a-rails-3-application-with-memcache
Huom: koska testimme alkavat pian testaamaan Rails.cachea hyväksikäyttävää koodia, kannattaa cache konfiguroida käyttämään testien aikana talletuspaikkanaan tiedostojärjestelmän sijaan keskusmuistia. Tämä tapahtuu lisäämällä tiedostoon config/environments/test.rb rivi
config.cache_store = :memory_store
Viritellään luokkaa BeermappingApi
siten, että se tallettaa tehtyjen kyselyjen tulokset välimuistiin. Jos kysely kohdistuu jo välimuistissa olevaan kaupunkiin, palautetaan tulos välimuistista.
class BeermappingApi
def self.places_in(city)
city = city.downcase
places = Rails.cache.read(city)
return places if places
places = get_places_in(city)
Rails.cache.write(city, places)
places
end
def self.get_places_in(city)
url = "http://beermapping.com/webservice/loccity/#{key}/"
response = HTTParty.get "#{url}#{ERB::Util.url_encode(city)}"
places = response.parsed_response["bmp_locations"]["location"]
return [] if places.is_a?(Hash) and places['id'].nil?
places = [places] if places.is_a?(Hash)
places.map do | place |
Place.new(place)
end
end
def self.key
"731955affc547174161dbd6f97b46538"
end
end
Avaimena käytetään pienillä kirjaimilla kirjoitettua kaupungin nimeä. Koodi on melko suoraviivainen, jos avainta vastaavat olutpaikat löytyvät cachesta (eli arvo ei ole nil), palautetaan ne. Jos taas cachessa ei ole kaupungin olutpaikkoja, haetaan ne metodilla get_places_in(city)
talletetaan cacheen ja palautetaan metodin kutsujalle.
Jos teemme nyt haun kaksi kertaa peräkkäin esim. New Yorkin oluista, huomaamme, että toisella kerralla vastaus tulee huomattavasti nopeammin.
Pääsemme sovelluksen välimuistiin tallettamaan dataan käsiksi myös konsolista:
> Rails.cache.read("helsinki").map(&:name)
=> ["Pullman Bar", "Belge", "Suomenlinnan Panimo", "St. Urho's Pub", "Kaisla", "Pikkulintu", "Bryggeri Helsinki", "Stadin Panimo", "Panimoravintola Bruuveri"]
>
Konsolista käsin on myös mahdollista tarvittaessa poistaa tietylle avaimelle talletettu data:
> Rails.cache.delete("helsinki")
=> true
> Rails.cache.read("helsinki")
=> nil
>
Voisimme yksinkertaistaa koodia hieman käyttämällä Rails.cachen metodia fetch
class BeermappingApi
def self.places_in(city)
city = city.downcase
Rails.cache.fetch(city) { get_places_in(city) }
end
def get_places_in(city)
# ...
end
end
Fetch toimii siten, että jos välimuiststa löytyy dataa sen parametrina olevalla avaimella, palauttaa metodi välimuistissa olevan datan. Jos välimuistissa ei ole avainta vastaavaa dataa, suoritetaan komennon mukana oleva koodilohko ja talletetaan koodilohkon paluuarvo välimuistiin. Myös itse komento fetch palauttaa lohkon saaman arvon.
Välimuistin käytön ongelmana on mahdollinen tiedon epäajantasaisuus. Eli jos joku lisää ravintoloita beermappingin sivuille, välimuistissamme säilyy edelleen vanha data. Jollain tavalla tulisi siis huolehtia, että välimuistiin ei pääse jäämään liian vanhaa dataa.
Yksi ratkaisu olisi aika ajoin nollata välimuistissa oleva data komennolla:
Rails.cache.clear
Tilanteeseemme paremmin sopiva ratkaisu on määritellä välimuistiin talletettavalle datalle enimmäiselinikä.
Määrittele välimuistiin talletettaville ravintolatiedoille enimmäiselinikä, esim. 1 viikko. Testatessasi tehtävän toimintaa, kannattaa kuitenkin käyttää pienempää elinikää, esim. yhtä minuuttia.
Tehtävän tekeminen ei edellytä kovin suuria muutoksia koodiisi, oikeastaan muutoksia tarvitaan vain yhdelle riville. Tarvittavat vihjeet löydät sivulta http://guides.rubyonrails.org/caching_with_rails.html#activesupport-cache-store Ajan käsittelyssä auttaa http://guides.rubyonrails.org/active_support_core_extensions.html#time
Huom: kuten aina, nytkin kannattaa testailla enimmäiseliniän asettamisen toimivuutta konsolista käsin!
Huom2: jos saat välimuistin sekaisin, muista
Rails.cache.clear
jaRails.cache.delete avain
Tehtävässä 3 teimme Webmock-gemin avulla testejä luokalle BeermappingApi
. On syytä huomioida, että välimuisti vaikuttaa myös testaamiseen, ja olisikin kenties parasta testata erikseen tilanne, jossa data ei löydy välimuistista (cache miss) sekä tilanne, jossa data on jo välimuistissa (cache hit):
require 'rails_helper'
describe "BeermappingApi" do
describe "in case of cache miss" do
before :each do
Rails.cache.clear
end
it "When HTTP GET returns one entry, it is parsed and returned" do
canned_answer = <<-END_OF_STRING
<?xml version='1.0' encoding='utf-8' ?><bmp_locations><location><id>18856</id><name>Panimoravintola Koulu</name><status>Brewpub</status><reviewlink>https://beermapping.com/location/18856</reviewlink><proxylink>http://beermapping.com/maps/proxymaps.php?locid=18856&d=5</proxylink><blogmap>http://beermapping.com/maps/blogproxy.php?locid=18856&d=1&type=norm</blogmap><street>Eerikinkatu 18</street><city>Turku</city><state></state><zip>20100</zip><country>Finland</country><phone>(02) 274 5757</phone><overall>0</overall><imagecount>0</imagecount></location></bmp_locations>
END_OF_STRING
stub_request(:get, /.*turku/).to_return(body: canned_answer, headers: { 'Content-Type' => "text/xml" })
places = BeermappingApi.places_in("turku")
expect(places.size).to eq(1)
place = places.first
expect(place.name).to eq("Panimoravintola Koulu")
expect(place.street).to eq("Eerikinkatu 18")
end
end
describe "in case of cache hit" do
before :each do
Rails.cache.clear
end
it "When one entry in cache, it is returned" do
canned_answer = <<-END_OF_STRING
<?xml version='1.0' encoding='utf-8' ?><bmp_locations><location><id>18856</id><name>Panimoravintola Koulu</name><status>Brewpub</status><reviewlink>https://beermapping.com/location/18856</reviewlink><proxylink>http://beermapping.com/maps/proxymaps.php?locid=18856&d=5</proxylink><blogmap>http://beermapping.com/maps/blogproxy.php?locid=18856&d=1&type=norm</blogmap><street>Eerikinkatu 18</street><city>Turku</city><state></state><zip>20100</zip><country>Finland</country><phone>(02) 274 5757</phone><overall>0</overall><imagecount>0</imagecount></location></bmp_locations>
END_OF_STRING
stub_request(:get, /.*turku/).to_return(body: canned_answer, headers: { 'Content-Type' => "text/xml" })
BeermappingApi.places_in("turku")
places = BeermappingApi.places_in("turku")
expect(places.size).to eq(1)
place = places.first
expect(place.name).to eq("Panimoravintola Koulu")
expect(place.street).to eq("Eerikinkatu 18")
end
end
end
Ensimmäisessä describe
-lohkossa oleva before :each
-lohko tyhjentää välimuistin ennen testien suorittamista, eli kun itse testi tekee metodikutsun BeermappingApi.places_in
, haetaan olutpaikkojen tiedot HTTP-pyynnöllä. Toisessa describe-lohkossa taas testeissä kutsutaan metodia BeermappingApi.places_in
kaksi kertaa. Ensimmäinen kutsu varmistaa, että haettavan paikan tiedot talletetaan välimuistiin. Toisen kutsun tulos tulee välimuistista ja tulosta testataan testikoodissa.
Testi sisältää nyt paljon toisteisuutta ja kaipaisi refaktorointia, mutta menemme kuitenkin eteenpäin.
Vielä uusi huomautus asiasta: koska testaamme Rails.cachea hyväksikäyttävää koodia, kannattaa cache konfiguroida käyttämään testien aikana talletuspaikkanaan tiedostojärjestelmän sijaan keskusmuistia. Tämä tapahtuu lisäämällä tiedostoon config/environments/test.rb rivi
config.cache_store = :memory_store
Koodissamme API-key on nyt kirjoitettu sovelluksen koodiin. Tämä ei tietenkään ole ollenkaan järkevää. Railsissa on useita mahdollisuuksia konfiguraatiotiedon tallentamiseen, ks. https://guides.rubyonrails.org/configuring.html
Ehkä paras vaihtoehto suhteellisen yksinkertaisen sovelluskohtaisen datan tallettamiseen ovat ympäristömuuttujat. Esimerkki seuraavassa:
Asetetaan ensin komentoriviltä ympäristömuuttujalle BEERMAPPING_APIKEY
mluukkai@melkki$ export BEERMAPPING_APIKEY="731955affc547174161dbd6f97b46538"
Rails-sovellus pääsee ympäristömuuttujiin käsiksi hash-tyyppisen muuttujan ENV
kautta:
> ENV['BEERMAPPING_APIKEY']
=> "731955affc547174161dbd6f97b46538"
>
Poistetaan kovakoodattu apiavain ja luetaan se ympäristömuuttujasta:
class BeermappingApi
# ...
def self.key
return nil if Rails.env.test? # testatessa ei apia tarvita, palautetaan nil
raise 'BEERMAPPING_APIKEY env variable not defined' if ENV['BEERMAPPING_APIKEY'].nil?
ENV.fetch('BEERMAPPING_APIKEY')
end
end
Koodiin on myös lisätty suoritettavaksi poikkeus tilanteessa, jossa apiavainta ei ole määritelty.
Ympäristömuuttujan arvon tulee siis olla määritelty jos käytät olutravintoloiden hakutoimintoa. Saat määriteltyä ympäristömuuttujan käynnistämällä sovelluksen seuraavasti:
mluukkai@melkki$ export BEERMAPPING_APIKEY="731955affc547174161dbd6f97b46538"
mluukkai@melkki$ rails s
tai määrittelemällä ympäristömuuttujan käynnistyskomennon yhteydessä:
mluukkai@melkki$ BEERMAPPING_APIKEY="731955affc547174161dbd6f97b46538" rails s
Voit myös määritellä ympäristömuuttujan arvon (export-komennolla) komentotulkin käynnistyksen yhteydessä suoritettavassa tiedostossa (.zshrc, .bashrc tai .profile komentotulkista riippuen).
Ympäristömuuttujille on helppo asettaa arvo myös Fly.io:ssa ks. https://fly.io/docs/reference/secrets/#setting-secrets ja Herokussa, ks. https://devcenter.heroku.com/articles/config-vars
HUOM Jos haluat pitää Github Actionsin toimintakunnossa, joudut määrittelemään ympäristömuuttujan workflown-konfiguraatioon ks. https://docs.github.com/en/actions/learn-github-actions/environment-variables
Tarkastellaan hieman tarkemmin kontrollerien show
-metodien toimintaperiaatetta. Seuraavaakin tehtävää silmälläpitäen kerrataan asiaa.
Tarkastellaan panimon kontrolleria. Yksittäisen panimon näyttämisestä vastaava kontrollerimetodi ei sisällä mitään koodia:
def show
end
oletusarvoisesti renderöityvä näkymätemplate app/views/breweries/show.html.erb kuitenkin viittaa muuttujaan @brewery
:
<h2><%= @brewery.name %></h2>
<p>
<em>Established year:</em>
<%= @brewery.year %>
</p>
eli miten muuttuja saa arvonsa? Arvo asetetaan kontrollerissa esifiltteriksi määritellyssä metodissa set_brewery
.
class BreweriesController < ApplicationController
before_action :set_brewery, only: [:show, :edit, :update, :destroy]
#...
def set_brewery
@brewery = Brewery.find(params[:id])
end
end
kontrolleri siis määrittelee, että aina ennen metodin show
suorittamista suoritetaan koodi
@brewery = Brewery.find(params[:id])
joka lataa panimo-olion muistista ja tallettaa sen näkymää varten muuttujaan.
Kuten koodista on pääteltävissä, kontrolleri pääsee käsiksi panimon id:hen params
-hashin kautta. Mihin tämä perustuu?
Kun katsomme sovelluksen routeja joko komennolla rails routes
tai selaimesta (menemällä mihin tahansa epävalidiin osoitteeseen kuten localhost:3000/foobar), huomaamme, että yksittäiseen panimoon liittyvä routetieto on seuraava
brewery_path GET /breweries/:id(.:format) breweries#show
eli yksittäisen panimon URL on muotoa breweries/42 missä lopussa oleva luku on panimon id. Kuten polkumäärittely vihjaa, sijoitetaan panimon id params
-hashin avaimen :id
arvoksi.
Voisimme määritellä 'parametrillisen' polun myös käsin. Jos lisäisimme routes.rb:hen seuraavan
get 'panimo/:id', to: 'breweries#show'
pääsisi yksittäisen panimon sivulle osoitteesta http://localhost:3000/panimo/42. Osoitteen käsittelisi edelleen kontrollerin metodi show
, joka pääsisi käsiksi id:hen tuttuun tapaan params
-hashin kautta.
Jos taas päättäisimme käyttää jotain muuta kontrollerimetodia, ja määrittelisimme reitin seuraavasti
get 'panimo/:panimo_id', to: 'breweries#nayta'
kontrollerimetodi voisi olla esim. seuraava:
def nayta
@brewery = Brewery.find(params[:panimo_id])
render :index
end
eli tällä kertaa routeissa määriteltiin, että panimon id:hen viitataan params
-hashin avaimella :panimo_id
.
Tee sovellukselle ominaisuus, jossa ravintolan nimeä klikkaamalla avautuu oma sivu, jossa on näkyvillä ravintolan yhteystiedot.
- ravintolan urliksi kannattaa vailta Rails-konvention mukainen places/:id, routes.rb voi näyttää esim. seuraavalta:
resources :places, only: [:index, :show] # mikä generoi samat polut kuin seuraavat kaksi # get 'places', to: 'places#index' # get 'places/:id', to: 'places#show' post 'places', to: ' places#search'
- HUOM: ravintolan tiedot löytyvät hieman epäsuorasti cachesta siinä vaiheessa kun ravintolan sivulle ollaan menossa. Jotta pääset tietoihin käsiksi on ravintolan id:n lisäksi "muistettava" kaupunki, josta ravintolaa etsittiin, tai edelliseksi tehdyn search-operaation tulos. Yksi tapa muistamiseen on käyttää sessiota, ks. https://github.com/mluukkai/WebPalvelinohjelmointi2022/blob/main/web/viikko3.md#k%C3%A4ytt%C3%A4j%C3%A4-ja-sessio
Toinen tapa toiminnallisuuden toteuttamiseen on sivulla http://beermapping.com/api/reference/ oleva "Locquery Service"
HUOM1 Koska Place ei ole ActiveRecord-luokka, ei seuraava toimi
link_to place.name, place
linkin kohdeosoite on määriteltävä pidemmässä muodossa
link_to place.name, place_path(place.id)
HUOM2 jos sinulla on vaikeuksia tehdä ravinotalan nimestä klikattava linkki, voit muuttaa taulukon send-metodia käyttävästä versiosta seuraavaan "karvalakkimalliin":
<table> <thead> <th>id</th> <th>name</th> <th>status</th> <th>street</th> <th>city</th> <th>zip</th> <th>country</th> <th>overall</th> </thead> <% @places.each do |place| %> <tr> <td><%= place.id %></td> <td><%= place.name %></td> <td><%= place.status %></td> <td><%= place.street %></td> <td><%= place.city %></td> <td><%= place.zip %></td> <td><%= place.country %></td> <td><%= place.overall %></td> </tr> <% end %> </table>Kokeile hajottaako ravintoloiden sivun lisääminen mitään olemassaolevaa testiä. Jos, niin voit yrittää korjata testit. Välttämätöntä se ei kuitenkaan tässä vaiheessa ole.
Tehtävän jälkeen sovelluksesi voi näyttää esim. seuraavalta:
Tällä hetkellä reittaukset luodaan erilliseltä sivulta, jolta reitattava olut valitaan erillisestä valikosta. Olisi luontevampaa, jos reittauksen voisi tehdä myös suoraan kunkin oluen sivulta.
Vaihtoehtoisia toteutustapoja on useita. Tutkitaan seuraavassa ehkä helpointa ratkaisua. Käytetään form_for
-helperiä, eli luodaan lomake pohjalla olevaa olia hyödyntäen. BeersControllerin metodiin show tarvitaan pieni muutos:
def show
@rating = Rating.new
@rating.beer = @beer
end
Eli siltä varalta, että oluelle tehdään reittaus, luodaan näykymätemplatea varten reittausolio, joka on jo liitetty tarkasteltavaan olioon. Reittausolio on luotu new:llä eli sitä ei siis ole talletettu kantaan, huomaa, että ennen metodin show
suorittamista on suoritettu esifiltterin avulla määritelty komento, joka hakee kannasta tarkasteltavan oluen: @beer = Beer.find(params[:id])
Näkymätemplatea /views/beers/show.html.erb muutetaan seuraavasti:
<p style="color: green"><%= notice %></p>
<%= render @beer %>
<% if current_user %>
<h4>give a rating:</h4>
<%= form_with(model: @rating) do |form| %>
<%= form.hidden_field :beer_id %>
score: <%= form.number_field :score %>
<%= form.submit "Create rating" %>
<% end %>
<div>
<%= link_to "Edit this beer", edit_beer_path(@beer) %>
<%= button_to "Destroy this beer", @beer, method: :delete %>
</div>
<% end %>
Jotta lomake lähettäisi oluen id:n, tulee beer_id
-kenttä lisätä lomakkeeseen. Emme kuitenkaan halua käyttäjän pystyvän manipuloimaan kenttää, joten kenttä on määritelty lomakkeelle hidden_field
:iksi.
Koska lomake on luotu form_with
-helperillä, tapahtuu sen lähettäminen automaattisesti HTTP POST -pyynnöllä ratings_path
:iin eli reittauskontrollerin create
-metodi käsittelee lomakkeen lähetyksen. Kontrolleri toimii ilman muutoksia!
Ratkaisussa on pieni ongelma. Jos reittauksessa yritetään antaa epävalidi pistemäärä:
renderöi kontrolleri (eli reittauskontrollerin metodi create
) oluen näkymän sijaan uuden reittauksen luomislomakkeen:
Ongelman voisi kiertää katsomalla mistä osoitteesta create-metodiin on tultu ja renderöidä sitten oikea sivu riippuen tulo-osoitteesta. Emme kuitenkaan tee nyt tätä muutosta.
Korjaamme ensin erään vielä vakavamman ongelman. Edellistä kahta kuvaa tarkastelemalla huomaamme että jos reittauksen (joka yritetään antaa oluelle Weihenstephaner Hefeweizen) validointi epäonnistuu, ei tehty oluen valinta ole enää tallessa (valittuna on Iso 3).
Ongelman syynä on se, että pudotusvalikon vaihtoehdot generoivalle metodille options_from_collection_for_select
ei ole kerrottu mikä vaihtoehdoista tulisi valita oletusarvoisesti, ja tälläisessä tilanteessa valituksi tulee kokoelman ensimmäinen olio. Oletusarvoinen valinta kerrotaan antamalla metodille neljäs parametri:
options_from_collection_for_select(@beers, :id, :to_s, selected: @rating.beer_id)
Eli muutetaan näkymätemplate app/views/ratings/new.html.erb seuraavaan muotoon:
<h2>Create new rating</h2>
<%= form_for(@rating) do |f| %>
<% if @rating.errors.any? %>
<div id="error_explanation">
<h2><%= pluralize(@rating.errors.count, "error") %> prohibited rating from being saved:</h2>
<ul>
<% @rating.errors.full_messages.each do |msg| %>
<li><%= msg %></li>
<% end %>
</ul>
</div>
<% end %>
<%= f.select :beer_id, options_from_collection_for_select(@beers, :id, :to_s, selected: @rating.beer_id) %>
score: <%= f.number_field :score %>
<%= f.submit %>
<% end %>
Sama ongelma itse asiassa vaivaa muutamia sovelluksemme lomakkeita, kokeile esim. mitä tapahtuu kun yrität luoda oluen jolle et anna nimeä. Korjaa lomake jos haluat.
Tee myös olutkerhoihin liittyminen mahdolliseksi suoraan olutkerhon sivulta.
Kannattaa noudattaa samaa toteutusperiaatetta kuin oluen sivulta tapahtuvassa reittaamisessa, eli lisää olutseuran sivulle lomake, jonka avulla voidaan luoda uusi
Membership
-olio, joka liittyy olutseuraan ja kirjautuneena olevaan käyttäjään. Lomakkeen hidden_field kenttiin voi asettaa arvot käyttämällävalue
-parametriä:<%= form_with(model: @membership) do |form| %> <%= form.hidden_field :beer_club_id, value: @beer_club.id %> <%= form.hidden_field :user_id, value: current_user.id %> <%= form.submit "Join the beer club" %> <% end %>
Hienosäädetään olutseuraan liittymistä
Tee ratkaisustasi sellainen, jossa liittymisnappia ei näytetä jos kukaan ei ole kirjautunut järjestelmään tai jos kirjautunut käyttäjä on jo seuran jäsen.
Muokkaa koodiasi siten (membership-kontrollerin sopivaa metodia), että olutseuraan liittymisen jälkeen selain ohjautuu olutseuran sivulle ja sivu näyttää allaolevan kuvan mukaisen ilmoituksen uuden käyttäjän liittymisestä.
Laajennetaan toiminnallisuutta vielä siten, että jäsenten on mahdollisuus erota olutseurasta.
Lisää olutseuran sivulle nappi, joka mahdollistaa seurasta eroamisen. Napin tulee olla näkyvillä vain jos kirjautunut käyttäjä menee sellaisen seuran sivulle, jossa hän on jäsenenä. Eroamisnappia painamalla jäsenyys tuhoutuu ja käyttäjä ohjautuu omalle sivulleen jolla tulee näyttää ilmoitus eroamisesta, allaolevat kuvat selventävät haluttua toiminnallisuutta.
Vihje: eroamistoiminnallisuuden voi toteuttaa liittymistoiminnalisuuden tapaan olutseuran sivulle sijoitettavalla lomakkeella. Lomakkeen käyttämäksi HTTP-metodiksi tulee määritellä delete:
<%= form_with(..., method: :delete) do |form| %> ... <%= form.hidden_field :beer_club_id, value: @beer_club.id %> <%= form.hidden_field :user_id, value: current_user.id %> <%= form.submit "End the membership" %> <% end %>Tehtävän toteuttamiseen on monta keinoa, yksi keino on saada selville käyttäjän
membership
olion id, jonka avulla voi käsin asettaa oikean id:n polkuun.Lomaketta käytettäessä on siis kontrollerissa asetettava muuttujan
@membership
arvoksi käyttäjän seuraan liittävä olio. Jos toteutat tehtävän käyttämällämembership
olion id:tä, on se myös päästettävä kontrollerissa läpimembership_params
metodissa.
Jos käyttäjä on seuran jäsen, näytetään seuran sivulla eroamisen mahdollistava painike:
Erottaessa seurasta tehdään uudelleenohjaus käyttäjän sivulle ja näytetään asianmukainen ilmoitus:
Olemme käyttäneet Railsin migraatioita jo ensimmäisestä viikosta alkaen. On aika syventyä aihepiiriin hieman tarkemmin.
Lue ajatuksella http://guides.rubyonrails.org/migrations.html
Laajenna sovellustasi siten, että oluttyyli ei ole enää merkkijono, vaan tyylit on talletettu tietokantaan. Jokaiseen oluttyyliin liittyy myös tekstuaalinen kuvaus. Tyylin kuvauksen tyypiksi kannattaa määritellä
text
, tyypinstring
avulla määritellyn sarakkeen oletuskoko on nimittäin vain 255 merkkiä.Muutoksen jälkeen oluen ja tyylin suhteen tulee olla seuraava
Huomaa, oluella nyt oleva attribuutti
style
tulee poistaa, jotta ei synnyt ristiriitaa assosiaation ansiosta generoitavan aksessorin ja vanhan kentän välille.Saattaa olla hieman haasteellista suorittaa muutos siten, että oluet linkitetään automaattisesti oikeisiin tyylitietokannan tauluihin. Tämäkin onnistuu, jos teet muutoksen useassa askeleessa, esim:
- luo tietokantataulu tyyleille
- tee tauluun rivi jokaista beers-taulusta löytyvää erinimistä tyyliä kohti (tämä onnistuu konsolista käsin)
- uudelleennimeä beers-taulun sarake style esim. old_style:ksi (tämä siis migraation avulla)
- tee beers-taulun viiteavain tyylejä varten (tämäkin migraation avulla, voit tehdä tämän ja edellisen askeleen samassa migraatiossa)
- liitä konsolista käsin oluet style-olioihin käyttäen hyväksi oluilla vielä olevaa old_style-saraketta
- tyylikkäämpää on tehdä myös tämä askel migraatiossa
- tuhoa oluiden taulusta migraation avulla old_style
Huomaa, että Fly.io/Heroku-instanssin ajantasaistaminen kannattaa tehdä samalla!
Vihje: voit harjoitella datamigraation tekemistä siten, että kopioit ennen migraation aloittamista tietokannan eli tiedoston db/development.sqlite3 ja jos migraatiossa menee jokin pieleen, voit palauttaa tilanteen ennalleen kopion avulla. Myös debuggeri (binding.break) saattaa osoittautua hyödylliseksi migraation kehittelemisessä.
Voit myös suorittaa siirtymisen uusiin tietokannassa oleviin tyyleihin suoraviivaisemmin eli poistamalla oluilta style-sarakkeen ja asettamalla oluiden tyylit esim. konsolista.
Muutoksen jälkeen uutta olutta luotaessa oluen tyyli valitaan panimoiden tapaan valmiilta listalta. Lisää myös tyylien sivulle vievä linkki navigaatiopalkkiin.
Tyylien sivulle kannattaa lisätä lista kaikista tyylin oluista.
HUOM1 Jos lisäät luokalle Beer määreen
belongs_to :style
et enää pääse käsiksi _style*-nimiseen merkkijonomuotoiseen attribuuttiin pistenotaatiolla beer.style, vaan joudut käyttämään muotoa beer['style']HUOM2 varmista, että uusien oluiden luominen toimii vielä laajennuksen jälkeen! Joudut muuttamaan muutamaakin kohtaa, näistä vaikein huomata lienee olutkontrollerin apumetodi
beer_params
.
Tehtävän jälkeen oluttyylin sivu voi näyttää esim. seuraavalta
Hyvä lista oluttyyleistä kuvauksineen löytyy osoitteesta http://beeradvocate.com/beer/style/
Tyylien tallettaminen tietokantaan hajottaa suuren osan testeistä. Ajantasaista testit. Huomaa, että myös FactoryBotin tehtaisiin on tehtävä muutoksia.
Vaikka hajonneita testejä on suuri määrä, älä mene paniikkiin. Selvitä ongelmat testi testiltä, yksittäinen ongelma kertautuu monteen paikkaan ja testien ajantasaistaminen ei ole loppujenlopuksi kovin vaikeaa.
HUOM voit poistaa railsin automaattisesti generoimat testit, esim. testin spec/views/styles/index.html.erb_spec.rb
Lisää olutpaikat näyttävälle sivulle paikan tämänhetkinen säätiedoitus. Säätiedoituksen tarjoavia palveluita on kymmeniä. Itse käytin https://weatherstack.com/:ta. Muista jälleen käsitellä koodissa apiavainta järkevästi!
Tehtävän jälkeen olutpaikkojen sivu voi näyttää esim. seuraavalta
Tehtävä 15 hajottaa osan testeitä. Korjaa testit. Voit merkata tämän tehtävän tehdyksi ainoastaan jos teet edellisen tehtävän.
Commitoi kaikki tekemäsi muutokset ja pushaa koodi GitHubiin. Deployaa myös uusin versio Fly.io:n tai Herokuun. Muista myös testata Rubocopilla, että koodisi noudattaa edelleen määriteltyjä tyylisääntöjä.
Tehtävät kirjataan palautetuksi osoitteeseen https://studies.cs.helsinki.fi/stats/courses/rails2022/