Databaser

En databas är en välorganiserad, det vill säga uppbyggd enligt nedskrivna regler, centraliserad, samling av data.

Data vs information.

Information är behandlad data - det vill säga data som någon kan använda för att tillföra något slags värde i någon slags process eller sammanhang.

Man kan säga att data blir information när någon tittar på eller behandlar den.

Typer av databaser

Det finns i dag två huvudsakliga typer av databaser: Relationsdatabaser och dokument/nod-baserade databaser. Andra ord som används för att skilja på dessa två typer av databaser är SQL (relationella) och NoSQL (dokument/nod-baserade).

I den här kursen kommer vi enbart använda oss av SQL-baserade databaser, det vill säga Relationsdatabaser.

Grundläggande terminologi

Databashanterare

En Databashanterare/Databashanteringssystem, eller DBMS (Database Management System), är mjukvara som gör det möjligt att på ett effektivt och säkert sätt definera, hämta och modifiera innehåll i en databas.

Man kan inte kommunicera direkt med en databas; all kommunikation sker via en databashanterare.

I den här kursen kommer vi använda oss av SQLite som DBMS.

Tabeller, rader och kolumner

En relationsdatabas består av en eller flera tabeller Varje tabell har ett namn, och består av rader och kolumner.

Table 1. Exempel på en tabell för att hålla koll på böcker

books

id

title

page_count

1

'Catch 22'

464

2

'To Kill a Mockingbird'

336

3

'1984'

328

4

'The Stranger'

123

Tabellen ovan har namnet books, och har 4 rader och 3 kolumner.

Entiteter och tabeller

En tabell i en databas kallas för en entitet

Tupler och rader

Varje rad i tabellen kallas för en tupel och beskriver en post i tabellen.

I books-tabellen innehåller varje tupel information om en bok.

Attribut och kolumner

Varje kolumn beskriver en egenskap, eller attribut för posten.

I books-entiteten är id, title och page_count attribut.

Attribut måste vara atomära

Attribut måste vara atomära, det vill säga i varje "cell" eller "fält" får det maximalt finnas ett (1) värde - man får t.ex inte lagra flera titlar i en och samma cell.

Table 2. En felaktig tabell med icke-atomära värden i phone_number kolumnen.

contacts

id

name

phone_number

1

'Hermione Granger'

"555-123 45 67", "555-234 56 78"

2

'Ron Weasly'

"555-234 56 78"

Tabeller och relationer

En relationsdatabas består av tabeller och relationerna mellan tabellerna.

Primärnyckel och unika rader

Varje rad i en tabell måste vara unik, det vill säga, i varje tabell får det inte förekomma två rader där samtliga kolumner är identiska.

Detta löses enklast genom att varje tabell har ett attribut som kallas för primärnyckel eller primary key.

När man skapar tabellen talar man om vilket av attributen som kommer vara primärnyckeln.

I tabellen ovan har den som skapat tabellen valt attributet id som primärnyckel. En primärnyckels värde måste vara unikt för tabellen och kolumnen.

Detta innebär att även om det skulle finnas två böcker som heter "The Stranger", och som båda råkar ha 123 sidor kommer raden fortfarande vara unik, eftersom den kommer få en unikt värde på primärnyckeln.

Table 3. Varje tupel är fortfarande unik, trots att det finns två böcker med samma titel och sidantal.

books

id

title

page_count

1

'Catch 22'

464

2

'To Kill a Mockingbird'

336

3

'1984'

328

4

'The Stranger'

123

5

'The Stranger'

123

Databashanterarens (SQLite) kontrollerar att varje primärnyckel/tupel är unik.

Namngivning av primärnyckeln

I den här kursen kommer vi:

  • alltid använda namnet id för primärnyckeln, men primärnyckeln kan heta vad som helst.

  • alltid använda automatiskt inkrementerande heltal för primärnyckeln, men primärnyckeln kan vara vad som helst.

Relationer

För att knyta samman två tabeller behöver vi skapa en relation, det vill säga, en koppling, mellan dem.

books

id

title

page_count

1

'Catch 22'

464

2

'To Kill a Mockingbird'

336

3

'1984'

328

4

'The Stranger'

123

5

'Closing Time'

382

6

'Animal Farm'

218

7

'The Plague '

312

8

'Coming Up for Air'

393

authors

id

name

nationality

birth_year

shoe_size

1

'Joseph Heller'

'American'

1923

42

2

'Harper Lee'

'American'

1926

36

3

'George Orwell'

'English'

1903

41

4

'Albert Camus'

'French'

1913

44

I exemplet med böcker och författare behöver vi skapa en en-till-många-relation mellan authors och books - en författare kan ha skrivit många böcker (men en bok kan bara ha en författare).

Främmande nyckel

För att skapa en relation mellan två tabeller använder man en främmande nyckel. Man skapar en främmande nyckel genom att kopiera in värdet från primärnyckeln i en-änden av relationen i en ny kolumn i många-änden av relationen.

I vårt fall innebär det att vi ska lägga till primärnyckeln från author-tabellen i books-tabellen (en för fattare kan ha skrivit flera böcker).

books

id

title

page_count

author_id

1

'Catch 22'

464

1

2

'To Kill a Mockingbird'

336

2

3

'1984'

328

3

4

'The Stranger'

123

4

5

'Closing Time'

382

1

6

'Animal Farm'

218

3

7

'The Plague '

312

4

8

'Coming Up for Air'

393

3

Namngivning av den främmande nyckeln

Den främmande nyckelns kolumn kan heta precis vad som helst, men i den här boken kommer den främmande nyckelns kolumn-namn alltid döpas enligt följande: namnet på en-ändens tabellnamn i singular följt av ett understreck och sen id (author_id)

ER-Diagram

För att på ett smidigt sätt kunna modellera, det vill säga designa relationsdatabaser finns Entity Relationship Diagrams (ER-diagram).

ER-diagram illustrerar en databas logiska uppbyggnad, det vill säga vilka entiteter, attribut och relationer som finns i databasen - det beskriver inte den faktiska datan som lagras i databasen.

Entitet

En entitet (engelska: entity) representerar en en typ av sak som lagras i databasen. Entiteter ritas som rektanglar, med namnet (i singular) i mitten.

Två entiteter i ett ER-diagram
Två entiteter

Attribut

Attribut (engelska: attribute) representerar en egenskap på något som lagras i databasen. Attribut ritas som en ovaler, med namnet i mitten. Alla attribut måste tillhöra en tabell, och man drar ett streck mellan entiteten och attributet för att visa vilken entitet ett attribut tillhör.

Om ett attribut på en entitet är unikt för den entiteten, det vill säga, det får i systemet som databasen modellerar inte finnas två saker som har samma värde på det attributet stryker man under det attributet.

Två entiteter med attribut i ett ER-diagram
Två entiteter med attribut

Primärnycklarna är alltid understrukna.

Främmande nycklar

Främmande nycklar ska aldrig ritas ut i ER-diagrammet, deras placering framgår av relationerna (se nästa rubrik)

Sambandstyp/Relation

Sambandstyper (engelska: relation) visar på kopplingar mellan två entiteter. Sambandstyper ritas som romber. I mitten av romben står ett ett eller flera ord som beskriver entiteternas samband (oftast från ena entitetens perspektiv).

Varje sambandstyp är kopplad med streck till de ingående entiteterna.

Kardinalitetsförhållanden

I varje ände av en sambandstyp framgår dess kardinalitet (engelska: cardinality), som mer exakt beskriver vad entiteterna har för kardinalitetsförhållanden eller samband. Det finns tre typer av kardinalitetsförhållanden:

Ett-till-ett-samband (eller 1:1-samband)

Ett 1:1-samband innebär att de ingående entiteterna kan höra ihop med ett exempel av entiteten i andra änden av sambandet.

Ett-till-många-samband (eller 1:*-samband)

Ett 1:*-samband innebär att de ingående entiteterna kan höra ihop med ett exempel av entiteten i andra änden av sambandet.

En sambandstyp där entiteten i den ena änden av sambandet kan höra ihop med flera exempel av entiten i den andra änden, men varje exempel i den andra änden hör bara ihop med ett exempel av den första entiteten.

Många-till-många-samband (eller *:*-samband)

Ett *:*-samband innebär att ett exempel av var och en av de ingående entiteterna kan höra ihop med flera exempel av de övriga ingående entiteterna.

Två entiteter med attribut och sambandstyp i ett ER-diagram
Två entiteter med attribut och sambandstyp

I exemplet ovan kan man utläsa att en författare kan skriva många (men minst 1) böcker, men en bok kan bara ha en författare. Det är med andra ord en en-till-många-samband.

Sambandstypen är i exemplet ovan är namngiven från författarens perspektiv ("wrote"), men skulle lika gärna kunna vara skriven från en boks perspektiv (t.ex "written by" eller "belongs to").

SQL

För att prata med databashanteraren använder man frågespråket SQL, Structured Query Language. SQL kan (på svenska) uttalas "ess-ku-ell", (på engelska) "es-queue-ell", som Engelska ordet "sequel" eller som "seek well".

SQL skiljer sig från programmeringsspråk som t.ex Ruby eller Javascript genom att man definerar vilken information man vill ha, och lämnar upp till databashanteraren att avgöra hur den på bästa (snabbaste och säkraste) sätt ska hämta efterfrågad data.

Man brukar säga att Ruby (och de flesta andra programmeringsspråk) är imperativa, dvs man säger till datorn hur och i vilken ordning den ska göra något, medan SQL är deklarativt, det vill säga man deklarerar vilket resultat man vill ha och lämnar till "datorn" att avgöra hur den ska uppnå resultatet.

Ruby - rad 8-14 beskriver hur datan ska hämtas (i det här fallet med hjälp av en while-loop).
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
titles = [ {name: 'BBQ Book', isbn: '123', page_count: 1337},
          {name: 'Grillboken', isbn: '234', page_count: 42},
          ...
        ]

long_titles = []

i = 0
while i < titles.length
    if titles[i][:page_count] > 500
        long_titles << books[i]
    end
    i += 1
end

p long_titles
SQL - hur databashanteraren ska hämta den efterfrågade informationen framgår inte i frågan
1
SELECT * FROM titles WHERE page_count > 500; (1)
1 Förutsatt att det i databasen finns en tabell vid namn titles

Select

Hämta alla poster från en tabell
SELECT * FROM fruits;
Hämta alla poster från tabell som uppfyller ett givet villkor
SELECT * FROM products WHERE price < 100;
Hämta alla poster från tabell som uppfyller flera villkor
SELECT * FROM songs WHERE play_count > 100 AND length < 120;

Select med join

För att hämta data från en tabell, samt tillhörande data från en tillhörande tabell behöver man använda sig av joins. En join är ett sätt att med en fråga få data från flera tabeller.

Kod-exemplen nedan utgår från fruits och ratings. Det finns en fruits-tabell och en ratings-tabell. I ratings-tabellen återfinns fruits-tabellens primärnyckel som en främmande nyckel:

joins example
De två tabellerna och deras koppling

När man ska skriva en SQL-join är det enklast om man utvecklar SQL-frågan rad för rad:

1. Hämta alla poster från primär-resursen
1
2
 SELECT *
 FROM fruits

Ovanstående fråga returnerar en array av arrayer:

1
2
3
4
5
6
[
    [1, "apple"],
    [2, "banana"],
    [3, "pear"],
    [4, "orange"]
]
2. Lägg till en join för den tillhörande tabellen
1
2
3
 SELECT *
 FROM fruits
 INNER JOIN ratings

Ovanstående fråga returnerar också en array av arrayer. De första två elementen i varje array är värdena från fruits-tabellen. De sista tre elementen i varje array är värden från ratings-tabellen (eftersom det i SQL-frågan först står fruits och sen ratings).

Man kan alltså visualisera att arrayen ser ut som följer:

[<fruits.id>,<fruits.name>,<ratings.id>,<ratings.score>,<ratings.fruit_id>].

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
[
    [1, "apple",  1, 3, 1],
    [1, "apple",  2, 3, 2],
    [1, "apple",  3, 3, 1],
    [1, "apple",  4, 4, 2],
    [1, "apple",  5, 5, 1],
    [1, "apple",  6, 7, 1],
    [1, "apple",  7, 8, 1],
    [1, "apple",  8, 9, 3],
    [2, "banana", 1, 3, 1],
    [2, "banana", 2, 3, 2],
    [2, "banana", 3, 3, 1],
    [2, "banana", 4, 4, 2],
    [2, "banana", 5, 5, 1],
    [2, "banana", 6, 7, 1],
    [2, "banana", 7, 8, 1],
    [2, "banana", 8, 9, 3],
    [3, "pear",   1, 3, 1],
    [3, "pear",   2, 3, 2],
    [3, "pear",   3, 3, 1],
    [3, "pear",   4, 4, 2],
    [3, "pear",   5, 5, 1],
    [3, "pear",   6, 7, 1],
    [3, "pear",   7, 8, 1],
    [3, "pear",   8, 9, 3],
    [4, "orange", 1, 3, 1],
    [4, "orange", 2, 3, 2],
    [4, "orange", 3, 3, 1],
    [4, "orange", 4, 4, 2],
    [4, "orange", 5, 5, 1],
    [4, "orange", 6, 7, 1],
    [4, "orange", 7, 8, 1],
    [4, "orange", 8, 9, 3]
]

Dessvärre innehåller den returnerade datan en massa nonsens: databashanteraren har, för varje rad i ratings-tabellen, "hittat på" en rad för varje rad i frukt-tabellen (vi har 8 ratings, och 4 frukter, 8 * 4 ⇒ 32).

Det vi behöver göra nu är att filtrera ut de värden som faktiskt finns i databasen, det vill säga de rader där den främmande nyckeln är samma som primärnyckeln i huvudtabellen.

I vårt fall är det alltså fruits_id i ratings-tabellen (det sista elementet i arrayen) som ska vara sammma som id i fruits-tabellen (det första elementet i arrayen).

De rader som faktiskt finns i databasen
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
[
    [1, "apple",  1, 3, 1],
    [1, "apple",  2, 3, 2],
    [1, "apple",  3, 3, 1],
    [1, "apple",  4, 4, 2],
    [1, "apple",  5, 5, 1],
    [1, "apple",  6, 7, 1],
    [1, "apple",  7, 8, 1],
    [1, "apple",  8, 9, 3],
    [2, "banana", 1, 3, 1],
    [2, "banana", 2, 3, 2],
    [2, "banana", 3, 3, 1],
    [2, "banana", 4, 4, 2],
    [2, "banana", 5, 5, 1],
    [2, "banana", 6, 7, 1],
    [2, "banana", 7, 8, 1],
    [2, "banana", 8, 9, 3],
    [3, "pear",   1, 3, 1],
    [3, "pear",   2, 3, 2],
    [3, "pear",   3, 3, 1],
    [3, "pear",   4, 4, 2],
    [3, "pear",   5, 5, 1],
    [3, "pear",   6, 7, 1],
    [3, "pear",   7, 8, 1],
    [3, "pear",   8, 9, 3],
    [4, "orange", 1, 3, 1],
    [4, "orange", 2, 3, 2],
    [4, "orange", 3, 3, 1],
    [4, "orange", 4, 4, 2],
    [4, "orange", 5, 5, 1],
    [4, "orange", 6, 7, 1],
    [4, "orange", 7, 8, 1],
    [4, "orange", 8, 9, 3]
]

Filtreringen gör vi genom att säga till vilka två kolumner som ska matcha med hjälp av ON:

1
2
3
4
SELECT *
FROM fruits
INNER JOIN ratings
ON ratings.fruit_id = fruits.id

Vilket returnerar den filtrerade listan:

1
2
3
4
5
6
7
8
9
[
    [1, "apple",  1, 3, 1],
    [2, "banana", 2, 3, 2],
    [1, "apple",  3, 3, 1],
    [2, "banana", 4, 4, 2],
    [1, "apple",  5, 5, 1],
    [1, "apple",  6, 7, 1],
    [1, "apple",  7, 8, 1],
    [3, "pear",   8, 9, 3]]

Om vi slutligen vill begränsa frågan till en specik frukt lägger vi till en WHERE sist i frågan:

1
2
3
4
5
db.execute('SELECT *
            FROM fruits
            INNER JOIN ratings
            ON ratings.fruit_id = fruits.id
            WHERE fruits.id = ?', 2)

Vilket returnerar enbart resultat för frukten med id 2:

1
2
3
4
[
    [2, "banana", 2, 3, 2],
    [2, "banana", 4, 4, 2]
]

Joins är jobbiga att komma underfund med, men om man gör dem steg för steg enligt ovan, och testkör frågan för varje steg är det enklare att förstå vad man ska skriva.

Insert

För att lagra data i tabeller används INSERT

INSERT INTO fruits (name, tastiness) VALUES ('pear', 8) (1)
1 Observera att id inte skickas med. Det skapar databasen själv, baserat på vilka id:n som är upptagna.

SQL-Injection

SQL är ett gammalt språk från 60-talet, långt innan internet slog igenom. Det är från en enklare tid, när man utgick från att de som kunde skicka data till databasen var välvilliga, och inte ville förstöra eller hämta ut data som de inte egentligen hade rättigheter att komma åt.

Därför innehåller SQL en stor säkerhetslucka som gör det möjligt för en illvillig person att köra godtycklig SQL-kod mot databasen (t.ex lista alla användare eller ta bort tabeller) genom en så kallad SQL-injection attack. Detta innebär att all data som kommer från en användare (dvs inte står skriven som statiska värden i källkoden) måste saneras innan de skickas till databashanteraren

SQLite3-biblioteket i Ruby löser saneringen genom att man skriver ? i SQL-strängen där man vill lägga in värden från användaren, och sen skicka en array av värden som kommer, på ett säkert sätt, ersätta frågetecknen:

En (1) parameter från användaren (id)
1
db.execute('SELECT * FROM fruits WHERE id = ?', id)
Flera parametrar från användaren (username, password)
1
db.execute('INSERT INTO users (username, password) VALUES (?,?)', username, password)

Vad kan hända om man interpolerar värden direkt från användaren?

Anta att du tar emot data från användaren, och datan lagras i variabeln name

NEDANSTÅENDE ARBETSSÄTT MED INTERPOLERING ÄR ABSOLUT FÖRBJUDET ATT ANVÄNDA SIG AV.

db.execute("SELECT * FROM users WHERE name = #{name}") (1)
1 name kommer från användaren och interpoleras in i SQL-strängen.

Frågan ser ofarlig ut, men om name innehåller en speciellt formaterad sträng kan vad som helst ske med databasen.

Exempel 1: Lista alla poster i tabellen
name = "Haxx0r OR 1 = 1" (1)
db.execute("SELECT * FROM users WHERE name = #{id}") (2)
#=> [[1, "user1", "password1"], [2, "user2", "password2"], [3, "user3", "password3"]] (3)
1 Strängen innehåller en or-statement där den högra jämförelsen alltid blir sann.
2 En SELECT med WHERE returnerar de rader där jämförelsen är true. Men eftersom strängen innehåller en or-statement, och i or-statements behöver bara ena sidan vara true, blir id = 3 OR 1 = 1 alltid true. Det databashanteraren ser är alltså SELECT * FROM USERS WHERE true
3 Eftersom true är true för alla rader returneras alla rader från tabellen.
Exempel 2: Ta bort en tabell
name = "Haxx0r'; DROP TABLE users; -- "
db.execute("SELECT * FROM users WHERE name = '#{name}';"
db.execute("SELECT * FROM users;" #=> eval error: no such table: users
Förtydligande kring hur SQL-koden tolkas
SELECT *
FROM users
WHERE name = 'Haxxor'; (1)
DROP TABLE users; (2)
-- ';(3)
1 Strängen innehåller Haxx0r';. ' gör att strängen avslutas, ; avslutar frågan (som kommer köras, men resultatet används inte)
2 Drop table statement som stod efter ; i strängen kommer köras
3 Strängen avslutas med en kommentar, '; är det som ursprungligen stod efter den interpolerade strängen.

I SQLite3-biblioteket till Ruby vi använder körs som standard alltid enbart den första frågan i en query, dvs eventuella frågor som står efter ett ; kommer ignoreras. Så exemplet ovan fungerar inte (men det finns en db.execute_batch som kör alla frågor med i queryn, och med execute_batch fungerar exemplet).

Men långt ifrån alla bibliotek och eller språk ignorerar eventuella extra frågor. I PHP fanns länge både funktionen mysql_escape_string och mysql_real_escape_string - den första var inte tillräckligt säker - och ingendera körde enbart den första frågan.

Requests & Routing

Webbapplikationer fungerar genom att en klient (webbläsare) kommunicerar med en server (i vårt fall Sinatra) med hjälp av protokollet HTTP.

I HTTP finns det två typer av meddelanden: HTTP Request och HTTP Response

Förenklat sker kommunikationen enligt följande:

  • En webbläsare skickar en HTTP request efter en viss resurs på servern.

  • Servern kollar sen om resursen finns.

    • Om resursen finns skickar servern en HTTP response med status 200 och vad nu resursen innerhåller för data (t.ex html).

    • Om resursen inte finns skickar servern en HTTP response med status 404 (och eventuellt html som visar ett fint felmeddelande).

  • Klienten tolkar sen responsen (t.ex ritar upp HTML på skärmen)

Resurs

En resurs kan vara t.ex en bild, en css- eller javascript-fil, men det kan också vara en "sida" på en site: t.ex. https://www.fruit-o-matic.com/fruits eller https://fruit-o-matic.com/fruits/apples/3/ där /fruits respektive /fruits/apples/3 är resursen. En resurs är "R:et" i URL: Universal Resource Locator.

En webbapplikation skickar aldrig information utan att den blivit ombedd. För att skicka information till en webbläsare måste webbläsaren först fråga efter informationen. Detta innebär att all kommunikation mellan en webbserver och en webbläsare alltid börjar med en Request från en klient (webbläsaren) och slutar med en Response från servern.

Sekvensdiagram

För att beskriva hur en HTTP Request/Response går till brukar man använda sekvensdiagram

Sekvensdiagram beskriver hur olika komponenter (t.ex webbläsare och sinatra-server) skickar "meddelanden" till varandra. Meddelanden visas som text ovanför pilar som går från sändare till mottagare.

Ett utförligt sekvensdiagram som beskriver en lyckad request efter resursen /fruits
Ett utförligt sekvensdiagram som beskriver en lyckad request efter resursen /fruits

Sekvensdiagrammet ovan är onödigt detaljerat, i fortsättningen kommmer vi inte visa de komponenter vi ändå inte har kontroll över. Ett sekvensdiagram för motsvarande aktivitet ser då ut som nedan:

Ett mer kompakt sekvensdiagram som beskriver en lyckad request efter resursen /fruits
Ett mer kompakt sekvensdiagram som beskriver en lyckad request efter resursen /fruits

Det finns två typer av HTTP Requests som en webbläsare kan skicka (det finns egentligen fler, men det är bara dessa två som är relevanta för kursen Webbserverprogrammering 1):

HTTP GET Request

En HTTP GET Request ber om att få en specifik resurs på servern, men kan inte skicka med någon data till servern.

HTTP POST Request

En HTTP POST Request skickar data (från t.ex. ett HTML-formulär) till servern.

Routing

Eftersom en webbserver aldrig gör något om den inte fått en Request är Requests centrala i en Sinatra-applikation.

I app.rb skrivar man sina routes, dvs regler som bestämmar vilka resurser som finns på servern och vad den ska göra om den får en HTTP Request (GET eller POST) till en specifik resurs.

app.rb
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class App < Sinatra::Base (1)

  get '/' do (2)
    erb :index
  end

  get '/fruits' do (3)
    erb :'fruits/index'
  end

end
1 Talar om att filen innehåller en Sinatra-applikation.
2 Konfigurerar en Route för resursen "/"
3 Konfigurerar en Route för resursen "/fruits"

När en Sinatra-applikation tar emot en request från en klient söker den efter en route som matchar requestens verb (GET eller POST) och dess resurs (t.ex /fruits). Koden i första routen som matchar kommer att köras, och resultatet läggs i ett HTTP Response och skickas tillbaks till klienten som skickat Requesten.

Man kan låtsas att routes i en Sinatra-app fungerar som en lång if-elsif-else-sats, enligt nedan:

pseudokod som beskriver en tankemodell för hur routing fungerar
1
2
3
4
5
6
7
8
9
if verb == "GET" && resource == "/"
  return erb(:index)
elsif verb == "GET" && resource == '/fruits'
  return erb(:'fruits/index')
elsif File.exist?("./public#{resource}")
  return File.read("./public#{resource}")
else
  return (404, "Not Found")
end

Det är inte så det är implementerat egentligen, men tankemodellen fungerar när man ska bestämma vilka routes man ska ha och i vilken ordning i de behöver finnas i.

Det finns två typer av routes:

Statiska Routes

En statisk route innehåller inga dynamiska bitar, det vill säga, den request som kommer måste matcha exakt mot routen som den är skriven i Sinatra.

Tre statiska routes
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
get '/' do (1)
  ... #Lämplig kod
end

get '/weather' do (2)
  ... #Lämplig kod
end

post '/weather' do (3)
  ... #Lämplig kod
end
1 Matchar HTTP GET Requests till /
2 Matchar HTTP GET Requests till /weather
3 Matchar HTTP POST Requests till /weather

Dynamiska Routes

En dynamisk route är en route där delar av routen kan skilja från den route som är skriven. En dynamisk route skapas genom att man ersätter den bit av routen som ska vara dynamisk med ett kolon (:) följt av ett beskrivande ord.

En dynamisk route
1
2
3
get '/weather/:city' do |city| (1)
  ... #Lämplig kod
end
1 Matchar HTTP GET Requests till /weather/VADSOMHELST/`, t.ex /weather/goteborg eller /weather/oslo eller /weather/3

Eftersom man antagligen vill veta vad det var för värde som låg på den dynamiska biten i requesten behöver man fånga upp den.

En dynamisk route som faktiskt fångar upp den dynamiska parametern
1
2
3
get '/weather/:city' do |city| (1)
  ... #Lämplig kod
end
1 Variabeln city innehåller nu vad som fanns på motsvarande ställe i requesten, text "goteborg", "oslo" eller "3".
Siffror i dynamiska routes

Det som lagras i variabeln som fångar upp en parameter från en dynamisk route kommer alltid vara en sträng, även om det innehåller siffror. Om applikationen förnväntar sig ett tal (integer) behöver man omvandla siffran till en integer med .to_i

En dynamisk route med två parametrar
1
2
3
get '/weather/:city/temperature/:date' do |location, date| (1)
  ... #Lämplig kod
end
1 När man har flera parametrar i en dynamisk route mappas parametrar till variabler i den ordning de kommer. Om requesten är GET /weather/goteborg/temperature/2023-09-23 kommer location innehålla 'goteborg' och date innehålla '2023-09-23'.
Namngivning av parametrar och variabler i dynamiska routes

Parametrar (:city) och variabler (location) behöver inte heta likadant, men det blir ofta förvirrande om de inte gör det.

Formulär och GET-POST-Redirect-Cykeln

Formulär skickar data via POST-requests. Men en POST-request ska inte användas för att visa data - det är vad GET-requests är till för.

För att visa resursen som skapats när en användare skickat in data via ett formulär måste man därför säga till användarens webbläsare vart den ska ta vägen (dvs göra en GET-request till) efter servern behandlat datan.

Detta görs genom att servern, efter den behandlat datan från formuläret, skickar tillbaks en Response som talar om vart webbläsaren ska skicka en GET-request. Webbläsaren skickar sen automagiskt en GET-request dit servern specificerat.

GET-POST-Redirect-cykeln i ett Sekvensdiagram
GET-POST-Redirect-cykeln i ett Sekvensdiagram
GET-POST-Redirect-cykeln som ett flödesschema
GET-POST-Redirect-cykeln som ett flödesschema

Formulärbehandlande routes i Sinatra

Givet följande formulär som skickas in:

1
2
3
4
5
<form action='/fruits' method="POST">
  <input type='text' name="fruit" placeholder="Fruit name"></input>
  <input type='number' name="rating"></input>
  <button type='submit'>Create new fruit rating</button>
</form>

För att ta emot datan från formuläret i Sinatra behöver vi skapa en route som matchar formulärets method och action - i det här fallet POST och /fruits.

Exempel på en route som behandlar data från formulär
1
2
3
4
5
6
7
post '/fruits' do  (1)
  name = params['fruit'] (2)
  rating = params['rating'].to_i (3)
  query = 'INSERT INTO fruits (name, rating) VALUES (?,?) RETURNING id'
  result = db.execute(query, name, rating).first (4)
  redirect "/fruits/#{result['id']}" (5)
end
1 Den route som datan från formuläret skickas till. Observera post.
2 Params innehåller 'fruit' eftersom formuläret har ett input-element med name="fruit"
3 All data i params är alltid strängar, om du vill ha något annat behöver man göra en typomvandling (t.ex to_i)
4 Datan från formuläret behandlas på lämpligt sätt (exemplet förutsätter t.ex. att det finns en fruits-tabell)
5 Servern skickar ett redirect-response. Klienten kommer automagiskt skicka en ny förfrågan efter resursen
Exempel på en route man kan redirectas till (eller surfa direkt till)
1
2
3
4
get '/fruits/:id' do |fruit_id|  (1)
  @fruit = db.execute('SELECT * FROM fruits WHERE id = ?', fruit_id) (2)
  erb :'fruits/show' (3)
end
1 En dynamisk route, som "råkar" matcha routen som redirecten ovan går till.
2 Gör lämplig behandling av parametrarna i routen
3 Rendera lämplig mall.

Routes för CRUD

De allra, allra flesta webbapplikationer är så kallade CRUD-applikationer, det vill säga man kan skapa (Create), visa (Read), ändra (Update), och ta bort (Delete) olika resurser.

Eftersom dessa operationer (CRUD) är så vanliga finns det en standard för hur man ska jobba med dem. Det finns 7 olika actions och varje action och resurs motsvarar en route i app.rb

I exemplet nedan utgår vi från en app som är ett digitalt klotterplank, där resursen är message.

Ett message består av ett id och content (textinnehållet i meddelandet).

I appen kan vem som helst kan skriva (Create) ett meddelande och meddelandena kan visas (Read), ändras (Update) och tas bort (Delete) av vem som helst. Följande actions och routes finns för message, men de routes och actions som visas gäller för alla sorters resurser.

Action: index route : /messages method: get

index visar en listning av alla förekomster av den aktuella resursen. Eventuellt kan listan vara filtrerad (t.ex skulle enbart meddelanden som tillhör den aktuella användaren kunna visas, om applikationen hade haft stöd för användare och inloggning).

messages#index route i app.rb
1
2
3
4
5
6
7
...
  # messages#index (1)
  get '/messages' do (2)
    @messages = db.execute('SELECT * FROM messages') (3)
    erb :'messages/index' (4)
  end
...
1 En kommentar som berättar vilken resurs och action routen motsvarar
2 Routen för index är /<resursens namn i plural>
3 Eftersom index ska visa en listning, hämtar vi alla messages från databasen
4 För att strukturera upp våra views skapar vi en mapp per resurs, och döper vy-filen till <action>.erb och sparar i mappen för resursen.
views/messages/index.erb
1
2
3
<% @messages.each do |message| %>
	<p><%= message['content'] %></p>
<% end %>

Action: show route: /messages/:id method: get

show visar en specifik instans av den aktuella resursen, identifierad av dess id. Detta förutsätter att alla instanser av den aktuella resursen har ett unikt id.

messages#show route i app.rb
1
2
3
4
5
6
7
...
  # messages#show
  get '/messages/:id' do |id| (1)
    @message = db.execute('SELECT * FROM messages WHERE id = ?', id).first (2)
    erb :'messages/show'
  end
...
1 Routen för show är /<resursens namn i plural>/<resursens id>. Plocka ut id-parametern med |id|
2 Eftersom vi alltid får en array från db.execute och vi bara vill ha en instans i värdet tar vi det första (och enda) elementet från arrayen.
views/messages/show.erb
1
<p><%= @message['content'] %></p>

Action: new route: /messages/new method: get

new visar ett formulär för att skapa en ny instans av den aktuella resursen. Eventuellt kan man istället/även visa formuläret på index eller någon annanstans.

messages#new route i app.rb
1
2
3
4
5
6
...
  # messages#new
  get '/messages/new' do (1)
    erb :'messages/new'
  end
...
1 Routen för new är /<resursens namn i plural>/new.

new-routen måste ligga innan show-routen i app.rb.

Varför då?

views/messages/new.erb
1
2
3
4
<form action='/messages/' method='post'> (1)
  <input type='text' name='content'></input> (2)
  <button type='submit'>Send</button>
</form>
1 action och method för new-formuläret motsvarar create-routen (se nedan)
2 name anger namnet på parametern som skickas till create-routen (se nedan)

Action: create route: /messages method: post

create är routen new-formuläret postar datan till. Behöver sedan redirecta användaren till lämplig adress.

messages#create route i app.rb
1
2
3
4
5
6
7
8
9
...
  # messages#create
  post '/messages/' do (1)
  	message = params['content'] (2)
    query = 'INSERT INTO messages (message) VALUES (?) RETURNING *'
    result = db.execute(query, message).first (3)
    redirect "/messages/#{result['id']}" (4)
  end
...
1 Routen för create är /<resursens namn i plural> med post som method.
2 Hämta ut datan som skickats från new-formuläret
3 Spara i databasen, och lagra resultatet
4 Gör en redirect till show för den nya instansen (eller gör en redirect nån annan route)

Action: edit route: /messages/:id/edit method: get

edit visar ett formulär för att uppdatera en befintlig instans av den aktuella resursen. Eventuellt kan man istället/även visa formuläret på show eller någon annanstans.

messages#edit route i app.rb
1
2
3
4
5
6
7
...
  # messages#new
  get '/messages/:id/edit' do |id| (1)
    @message = db.execute('SELECT * FROM messages WHERE id = ?', id.to_i).first
    erb :'messages/edit'
  end
...
1 Routen för edit är /<resursens namn i plural>/<resursens id>/edit.
views/messages/edit.erb
1
2
3
4
<form action='/messages/<%=@message['id']%>/update' method='post'> (1)
  <input type='text' name='content' value='<%= @message['content']%>'></input> (2)
  <button type='submit'>Send</button>
</form>
1 action och method för edit-formuläret motsvarar update-routen (se nedan)
2 value "förifyller" input-elementet med datan från backend).

Action: update route: /messages/:id/update method: post

update är routen edit-formuläret postar datan till. Behöver sedan redirecta användaren till lämplig adress.

messages#edit route i app.rb
1
2
3
4
5
6
7
8
...
  # messages#update
  post '/messages/:id/update' do |id| (1)
    message = params['content']
    db.execute('UPDATE messages SET content = ? WHERE id = ?', message, id)
    redirect "/messages/#{id}" (2)
  end
...
1 Routen för update är /<resursens namn i plural>/<resursens id>/update med post som method.
2 Redirect till lämpligt ställe

Action: delete route: `/messages/:id/delete method: `post

delete är routen delete-formuläret postar datan till. Delete-formuläret är antagligen bara ett minimalt formulär runt en knapp och ligger antagligen inte i en egen vy-fil (men kanske i index.erb eller show.erb):

views/messages/index eller views/messages/show eller annat lämpligt view-fil
1
2
3
<form action='/messages/<%= @message['id'] %>/delete' method='post'> (1)
  <button type="submit">DELETE</button>
</form>
1 action och method för delete-formuläret motsvarar delete-routen (se nedan)
messages#delete route i app.rb
1
2
3
4
5
6
7
...
  # messages#delete
  post '/messages/:id/delete' do |id| (1)
    db.execute('DELETE FROM messages WHERE id = ?', id)
		redirect "/messages/"
  end
...
1 Routen för delete är /<resursens namn i plural>/<resursens id>/delete med post som method.

Registrering & Inloggning

För att använda säker inloggning behöver man kunna hasha användares lösenord. Standardlösningen för Sinatra är att använda bcrypt:

Gemfile
1
2
3
4
5
6
7
source 'https://rubygems.org'

gem 'thin'
gem 'sinatra'
gem 'rerun'
gem 'sqlite3'
gem 'bcrypt'

Lägg till bcrypt i din Gemfile och kör bundle install.

Skapa en tabell för användare med åtminstone användarnamn och lösenord.

Registrering

Skapa ett formulär för registrering av användare.

Skapa en route för registreringsformuläret

I routen, använd bcrypt för att hasha lösenordet innan det lagras i databasen:

I registreringsrouten
1
2
3
4
5
...
  cleartext_password = params['password'] (1)
  hashed_password = BCrypt::Password.create(cleartext_password) (2)
  #spara användare och hashed_password till databasen
...
1 Hämta det inskrivna lösenordet från params
2 Hasha det inskrivna lösenordet.

Bcrypt kommer automagiskt salta lösenordet och lägga till salten i den hashade strängen.

Inloggning

Skapa ett formulär för inlogging

Skapa en route för inloggningsformuläret

I routen, använd bcrypt för att jämföra det inmatade lösenordet med det sparade saltade och hashade lösenordet:

I inloggningsrouten
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
...
  username = params['username']
  cleartext_password = params['password'] (1)

  #hämta användare och lösenord från databasen med hjälp av det inmatade användarnamnet.
  user = db.execute('SELECT * FROM users WHERE username = ?', username).first

  #omvandla den lagrade saltade hashade lösenordssträngen till en riktig bcrypt-hash
  password_from_db = BCrypt::Password.new(user['password'])

  #jämför lösenordet från databasen med det inmatade lösenordet
  if password_from_db == clertext_password (1)
    session[:user_id] = user['id'] (2)
    ... (3)
  else
    ... (4)
  end
...
1 Bcrypt kommer automagiskt extrahera salten från det lagrade databaset, salta & hasha klartextlösenordet och jämföra resultatet
2 Lösenordet stämde, lagra i sessionskakan.
3 Övrig kod som behöver ske.
4 Lösenordet stämde inte, gör något annat.

Autorisering (kontroll av användare och rättigheter)

I alla routes där du behöver veta om någon/vem är inloggad:
1
2
3
4
...
  user_id = session[:user_id] (1)
  # Kod för att kontrollera om användaren finns, och/eller har rättighet att se det som ska visas
...
1 Hämta användaren från sessionen

Utloggning

  1. Skapa ett formulär för utloggning (innehåller antagligen enbart en knapp).

  2. Skapa en route för utloggningsformuläret

I utloggningsrouten
1
2
3
...
  session.destroy
...

Kakor, kryptering och sessioner.

För att bygga säkra webbapplikationer behöver man hålla koll på 5 begrepp: kakor, sessioner, kryptering, hashning och saltning

HTTP är Stateless.

HTTP (och därmed webbservrar som Sinatra) är stateless, dvs de har inget "minne" av tidigare requests, dvs varje requests behandlas i separation från alla andra requests. Detta innebär att en webbläsare inte har något sätt att veta om requests kommer från samma webbläsare.

Man har valt att göra HTTP stateless eftersom det skulle ta för mycket resurser (minne) på servern att "komma ihåg" alla klienter som är uppkopplade mot servern - en webbserver kan ju ha miljontals klienter som skickar requests till den.

Kakor

För att kunna lagra state, trots att HTTP är stateless har man hittat på kakor ("cookies").

En kaka är data från servern som lagras i webbläsaren. Kakor skapas på servern och skickas till webbläsaren i ett HTTP-Response.

Kakor tillhör alltid en domän, t.ex fruitomatic.com, eller localhost:9292. Om webbläsaren har en kaka som tillhör den domän som webbläsaren skickar en HTTP-Request till kommer kakan alltid att skickas med, för varje request till den domänen.

På servern kan man sen kolla på kakan, och baserat på vad den innehåller, välja att hantera requesten olika.

Kakor tar plats.

Eftersom kakor skickas med varje request är det viktigt att inte lagra onödig data i kakan - dels blir det segare att skicka requesten, dels blir det mer data att tolka på servern. Oftast kommer man enbart lagra användarens id i kakan.

Kakor är okrypterade.

Innehållet i en kaka är i klartext ("okrypterad"). Detta innebär att användaren kan läsa all information som står i kakan.

En kaka med data i klartext
Kakor kan ändras av användaren.

Användaren kan i webbläsaren ändra allt innehåll i en kaka. Om man t.ex. lagrar användarens id i kakan för att hålla koll på vilken användare som är inloggad kan användaren lätt ändra till en annan användares id och då kommer servern anta att kommande requests kommer från den användaren istället.

En kakas värden kan ändras i webbläsaren

Kryptering

För att kunna lagra information i kakor, och samtidigt förhindra att användaren kan ändra (eller ens läsa) innehållet i kakan behöver vi kryptera innehållet i kakan.

Förenklat kan man säga att vi tar det vi vill lagra i kakan, t.ex strängen "favourite_fruit ⇒ "apple" och krypterar den till en annan sträng, t.ex "fofavovouroritote_fofroruitot ⇒ apoppoplole".

Krypterade kakor är fortfarande läsbara.

Om vi krypterar innehållet i kakan, kommer användaren fortfarande att kunna läsa innehållet i kakan, men hen kommer inte förstå vad den innehåller.

Användaren kan också fortfarande ändra kakans värde, men sannolikheten att hen skulle kunna lyckas ändra den krypterade strängen, till en annan korrekt krypterad sträng, är så liten att man helt kan bortse från den (förutsatt att vi valt en tillräckligt säker krypteringsalgoritm).

Sessioner

För att hålla koll på state för en besökare på en webapplikation använder man begreppet session.

När en webbläsare första gången skickar en request till servern upprättar man en session för den webbläsaren (användaren). I sessionen kan man sen lagra information om användaren.

I Sinatra använder man så kallade sessionskakor för att hålla koll på sessioner. En sessionskaka är en krypterad kaka som ska användas för att lagra information om användaren.

Konfigurera Sinatra att använda sessionskakor
1
2
3
4
5
6
7
class App < Sinatra::Base

    enable :sessions

    ...

end

Om man använder enable :sessions i Sinatra kommer varje besökare automagiskt få en krypterad kaka vid första besöket.

En kaka med data i klartext
En sessionskaka från Sinatra

För att läsa av och modifiera innehållet i en sessionskaka använder man variabeln session:

Använda sessionskakor i Sinatra
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class App < Sinatra::Base

  enable :sessions

  get '/' do
    session[:user_id] = 1 (1)
  end

  get '/home' do
    user_id = session[:user_id] (2)
    ... (3)
  end

end
1 Lagra data i sessionskakan.
2 Läs data från sessionskakan.
3 Använd datan från sessionskakan, t.ex för att hämta en användare från databasen.

Sinatra avkrypterar och krypterar automagiskt sessionskakorna. Observera återigen att kakan kommer skickas med från webbläsaren för varje request, så det är viktigt att lagra så lite information som möjligt i kakan.

Hashning

Hashning är ett kryptografiskt begrepp som innebär att man kan kryptera något, som sen inte går att avkryptera. Man kan se det som en envägskryptering. Skillnaden mellan kryptering och hashning är alltså att kryptering går att avkryptera (om man har rätt nyckel).

Sessionskakan måste vara krypterad så vi kan läsa av den, men om vi ska lagra användares lösenord ska de hashas - så vi inte kan läsa dem.

Men, kanske någon tänker nu, om vi inte kan läsa av lösenorden, hur ska vi då kunna kolla om någon anger rätt lösenord för att logga in? Svaret är att vi provar att hasha det de skrev in, och kontrollerar om resultatet av det hashade inskrivna lösenordet matchar det hashade lösenordet vi lagrat i databasen.

Salting

Eftersom samma input till en hashningsalgoritm alltid ger samma input räcker inte hashning av lösenorden. (t.ex skulle input "hemligt" till en tänkt hashningsalgoritm alltid ge "1a2806367d23c7884dfde273882ad6a9" som output).

För att kunna knäcka lösenord skulle man lätt skriva ett program som hashar alla vanliga lösenord, och sen skulle man bakvägen kunna se vilka lösenord som lagrats.

För att öka säkerheten använder man en så kallad password salt. En salt är en slumpad sträng som man lägger till på klartextlösenordet innan det hashas.

Exempelvis skulle vi kunna ta "hemligt", lägga till den slumpade strängen "abc123", få "hemligtabc123" och sen hasha den sammanslagna strängen och få resultatet "ced772b8bee4a1b208efb5bed6f86651". Om vi alltid slumpar olika salt kommer lösenorden, även om de är likadana, få olika hashingsresultat.

Nu har vi ett nytt problem: för att kunna kolla om ett inmatat lösenord matchar en lagrad saltad hash måste vi ju veta vad salten var. Lösningen är att lägga till salten i i hashen, t.ex "ced772b8bee4a1b208efb5bed6f86651+abc123". Vi kan då plocka ut salten ur hashen, lägga till den till det inmatade lösenordet, och sen se om den genererade hashen matchar den lagrade hashen utan den pålagda saltningen.

Skapande av hashade saltade lösenord
Skapande av hashade saltade lösenord
Kontroll av inmatade lösenord
Kontroll av inmatade lösenord

Templates

En template (svenska: mall) låter oss blanda text (i vårt fall HTML) och Ruby-kod. Man kan skriva templates i template-språket ERB (Embedded Ruby).

Variabler från routes

För att variabler definerade i en route ska vara tillgängliga i en mall behöver variabeln vara en så-kallad instansvariabel. En instansvariabel skapas genom att man sätter ett @ framför variabelns namn, t.ex @weather eller @city.

En route med instansvariabel
1
2
3
4
5
get '/weather/:city' do |city|
  @city = city (1)
  precipitation = 100 (2)
  erb :weather
end
1 @city kommer gå att använda inne i erb-mallen
2 precipitation kommer inte gå att använde i erb-mallen.

I en ERB-fil skriver man HTML som vanligt, och där man vill använda Ruby-kod omger man den med <%= din_kod_här %> eller <% din_kod_här %>.

Skillnaden mellan kodblock som börjar med <%= och <% är att resultatet av block som börjar med <%= kommer visas på sidan, medans resultatet av block som börjar med <% inte kommer visas på sidan, men koden kommer att köras.

En erb-mall med två kodblock
1
2
<h1>3 + 1 blir <%= 3 + 1 %></h1> (1)
<h1>3 + 1 blir <%  3 + 1 %></h1> (2)
1 Resultatet kommer synas på sidan.
2 Resultatet kommer inte synas på sidan.
Föregående erb-mall kommer renderas till följande HTML
1
2
<h1>3 + 1 blir 4</h1>
<h1>3 + 1 blir </h1>

<% %> (utan =) används framförallt för loopar och if-satser, där själva if-satsen eller loopen inte ska visas på sidan. Inuti loopen eller if-satsen använder man <%= %> för att visa innehåll

En erb-mall med en if-sats
1
2
3
4
<% if @weather == 'raining' %> (1)
  <img src='/img/rain.png'> (2)
  <span>Nederbörd: <%= @precipitation %> mm</span> (3)
<% end %> (4)
1 Själva if-satsen ska inte synas på sidan, därför används <% …​ %>.
2 Allt mellan if-blocket och end-blocket kommer visas på sidan om if-satsen är true. Om den är false, kommer inget visas.
3 <%= …​ %> används eftersom innehållet i variabeln ska visas.
4 end ska inte visas på sidan, därför används <% …​ %>.
Föregående mall renderad till HTML (om @weather är 'raining' och @precipitation är 3)
1
2
<img src='/img/rain.png'>
<span>Nederbörd: 3 mm</span>
En erb-mall med en each-loop
1
2
3
4
5
6
7
8
<ol>
  <% @cities.each do |city| %> (1)
    <li> (2)
      <h3><%= city.capitalize %></h3> (3)
        <img src='/img/<%= city.downcase %>.png'> (3)
      </li>
  <% end %> (4)
</ol>
1 Själva each-loopen ska inte synas på sidan, därför används <% …​ %>.
2 Alla element mellan each-blocket och end-blocket kommer visas på sidan för varje grej i @cities
3 <%= …​ %> används eftersom resultatet av koden ska visas.
4 end ska inte visas på sidan, därför används <% …​ %>.
Föregående mall renderad till HTML (om @cities är ['copenhagen', 'gothenburg', 'OSLO'])
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<ol>
  <li>
    <h3>Copenhagen</h3>
    <img src='/img/copenhagen.png' />
  </li>
  <li>
    <h3>Gothenburg</h3>
    <img src='/img/gothenburg.png' />
  </li>
  <li>
    <h3>Oslo</h3>
    <img src='/img/oslo.png' />
  </li>
</ol>

Formulär

För att skicka data från användaren till Servern används formulär.

Ett formulär
1
2
3
4
5
6
7
<!-- Övrig HTML -->
<form action='/fruits' method="POST">(1)
  <input type='text' name="fruit" placeholder="Fruit name"></input>(2)
  <input type='number' name="rating"></input>(2)
  <button type='submit'>Create new fruit rating</button>(3)
</form>
<!-- Övrig HTML -->
1 action är vilken route datan ska skickas till på servern, method är vilket HTTP-verb som ska användas (nästan alltid POST)
2 Ett text-input-element. name-attributet anger vilken parameter som kommer innehålla input-elementets värde
3 Ett submit-knapp-element. När knappen klickas kommer webbläsaren göra en POST-request till /fruits med datan från formuläret.

Formulär-element

För att skicka data med ett formulär behöver man olika element att "mata in" datan i.

Följande element är vanliga i formulär:

input

Ofta en ruta man kan mata in värden i. Det finns flera olika typer av input-element: text, number, password, email, search, etc

Några olika input-element
1
2
3
4
5
<!-- i ett form-element -->
<input type='text' name="fruit" placeholder="Fruit name"></input>
<input type='number' name="rating"></input>
<input type='email' name="email"></input>
<input type='search' name="query"></input>

checkbox

En ruta som kan vara eller inte vara ikryssad. Egentligen en typ av input-element.

Två checkboxes
1
2
3
<!-- i ett form-element -->
<input type="checkbox" name="hungry" />
<input type="checkbox" name="thirsty" checked />

Läs mer om checkbox på MDN

textarea

Ett text-input-element med flera rader

En textarea
1
2
<!-- i ett form-element -->
<textarea name="message" />

Läs mer om textarea på MDN

select och option

En select är en slags dropdown-lista, där alternativen ligger i option-element.

Ett select-element med 4 alternativ
1
2
3
4
5
6
7
8
<!-- i ett form-element -->
<select name="fruits">
  <option value="">--Välj en frukt--</option>
  <option value="banana">Banan</option>
  <option value="pear">Päron</option>
  <option value="apple">Äpple</option>
  <option value="orange">Apelsin</option>
</select>

Läs mer om select på MDN

radio button

Som en select, fast med knappar istället. Enbart en kan vara aktiv åt gången. Egentligen en variant av input.

En radio button med 4 alternativ
1
2
3
4
5
<!-- i ett form-element -->
<input type="radio" name="fruit" value="pear" checked />
<input type="radio" name="fruit" value="banana" />
<input type="radio" name="fruit" value="apple" />
<input type="radio" name="fruit" value="orange" />

Läs mer om radio button på MDN

datalist

En slags autocomplete-sök-lista (liknande en select, men man får skriva istället). Egentligen ett input-element som hämtar alternativen från ett datalist-element med options

En datalist med 4 alternativ
1
2
3
4
5
6
7
8
9
<!-- i ett form-element -->
<input list="fruits" name="fruit" />

<datalist id="fruits">
  <option value="Pear"></option>
  <option value="Banana"></option>
  <option value="Apple"></option>
  <option value="Orange"></option>
</datalist>

Läs mer om datalist på MDN

button

En knapp (för att skicka in formulär t.ex)

En submit-knapp
1
2
<!-- i ett form-element -->
<button type="submit"/>Skicka</button>

Läs mer om button på MDN

label

Labels ger etiketter till andra form-element. Genom att sätt for-attributet på en label till ett annat elements id-attribut kopplas label till element

Element med labels
1
2
3
4
5
6
<!-- i ett form-element -->
<label f [or="fruit">Frukt</label>
<input type="text" id="fruit" name="fruit">

<input type="checkbox" id="favourite" name="favourite">
<label for="favourite">Favorit?</label>

Läs mer om labels på MDN

Arbetsflöde

0. Skriv ner vilka resurser applikationen hanterar och vad de har för attribut.

Exemplet nedan utgår för en applikation där användare kan lägga upp och visa kod-snippets.

Följ sen för varje resurs följande steg:

1. Vilken resurs är det du ska jobba med?

snippets

2. Vad är basrouten för resursen?

/snippets

3. Vilken action är det du ska skapa?

show

4. Surfa till den adress som är aktuell för den resurs och action som ska ske.

Eftersom det är show som är action, behöver vi id för resursen. Vi testar med id 1 (detta förutsätter att resursen med id 1 redan finns i databasen, om inte, lägg till i seed.rb och seeda om.) Basrouten är /snippets alltså blir adressen vi ska surfa till /snippets/1

När du surfar till adressen ger Sinatra dig en kod du kan bygga vidare på.

5. Lägg till sinatras exempelkod i app.rb.
app.rb
1
2
3
4
5
...
  get '/snippets/1' do
    'Hello, World!'
  end
...
6. Testa om routen fungerar med exempelkoden genom att ladda om sidan i webbläsaren.

Ser du "Hello, World!" i webbläsaren?

Om inte: Är app.rb sparad? Finns det någon annan route tidigare som "krockar"?

7. Skapa en view för routen och uppdatera route-koden att använda view-en och visa exempeldatan där istället.

i views/snippets/show.erb

views/snippets/show.erb
1
  <%= @test %>

i app.rb

app.rb
1
2
3
4
5
6
...
  get '/snippets/1' do
    @test = "Working?"
    erb :'snippets/show'
  end
...

Ladda om webbsidan. Ser du "Working?"

8. I routen, lägg till den kod du tror du egentligen behöver.

i app.rb

app.rb
1
2
3
4
5
6
7
...
  # snippets#show
  get '/snippets/:id' do |id|
    @snippet = db.execute('SELECT * FROM snippets WHERE id = ?', id.to_i).first
    erb :'snippets/show'
  end
...
9. Om du pratar med databasen, skriv ut rå-datan i view-en innan du försöker jobba med den strukturerade datan.

i views/snippets/show.erb

views/snippets/show.erb
1
<%= @snippet.inspect %>

Ser datan ut som du förväntar dig? Om du förväntar dig ett element, är det då ett element, eller är det en array?

Om datan ser fel ut, gå tillbaks till routen och fundera på hur du behöver förändra hur du hämtar/manipulerar datan innan du visar den.

10. Iterera över/visa den strukturerade datan i HTML på rätt sätt.

i views/snippets/show.erb

views/snippets/show.erb
1
2
3
4
  <h1><%= @snippet['title'] %></h1>
  <pre> (1)
    <code><%= @snippet['code'] %></code> (2)
  </pre>
1 pre-element bevarar radbrytningar etc
2 code-element talar om för skärmläsare etc att innehållet är kod

Ser sidan ut som du förväntar dig? Gå vidare till nästa resurs/action.

Automatisk testning med Cypress

När man utvecklar en applikation testar man kontinuerligt att den fungerar som den ska, genom att manuellt ladda om sidan och se att det man just skapat fungerar som det ska. Detta är dock inte en hållbar lösning i längden, då det tar mycket tid och är lätt att missa något.

Det är också vanligt att man, när man gör en ändring som man tror inte påverkar något annat, råkar förstöra något annat i applikationen, utan att man märker det.

Därför är det viktigt att automatisera testningen av applikationen. Det finns många olika verktyg för att göra detta. I den här kursen kommer vi använda Cypress, som är ett verktyg för att skriva och köra tester på webbapplikationer.

Installation

Cypress ska installeras i Windows, inte i WSL, eftersom det inte fungerar att köra grafiska program i WSL. Alla instruktioner nedan ska alltså köras i Windows.

Installera Node.js

För att köra Cypress behöver du först installera node.js. Gå till nodejs.org/en/download och ladda ner och installera den senaste versionen av Node.js.

Om du är osäker på hur du ska installera Node.js kan du följa instruktionerna på www.geeksforgeeks.org/installation-of-node-js-on-windows/

Installera Cypress

När du har installerat Node.js kan du installera Cypress genom att köra följande kommando i terminalen:

npm install -g cypress

Starta Cypress

När du har installerat Cypress kan du starta det genom att köra följande kommando i terminalen (i src-mappen i ditt projekt):

cypress open

Cypress öppnar nu följande fönster:

1

Klicka på "browse manually" och leta reda på src-mappen för ditt projekt.

Välj E2E-testing (E2E betyger End-to-End, dvs att allt testas, från databas till användargränssnitt)

2

Klicka på "Continue"

3

Välj "Start E2E testing in Chrome"

4

Välj "Create new spec"

5

Klicka på "Run the spec"

6

Testet körs nu i Cypress-fönstret.

7

I din mappstruktur kommer det nu att skapas en mapp som heter cypress, testet som skapades i cypress-fönstret ligger i cypress/e2e. Om du öppnar filen i VS Code ser du testet som kördes:

describe('template spec', () => {
  it('passes', () => {
    cy.visit('https://example.cypress.io')
  })
})

Skriva egna tester

Ett test består av två delar: en describe-funktion och en it-funktion. describe-funktionen beskriver vad testet handlar om, och it-funktionen beskriver vad som ska testas.

I exemplet ovan beskriver describe-funktionen att det är ett test för en template, och it-funktionen beskriver att testet ska passera om sidan example.cypress.io laddas.

describe` gör inget mer än att det grupperar massa texter. Argumentet som skickas till describe används bara för att visa vilka test som hör ihop.

it är det som faktiskt testar något. Argumentet som skickas till it används för att visa vad som testas i cypress-fönstret.

8

Ett första test

beforeEach(() => { (1)
  cy.visit('http://localhost:9292/thesaurus')
})

describe('Thesaurus', () => { (2)

  it('shows a list of words', () => { (3)
    cy.get('li').should('exist')
  })

  it('has links to words', () => { (4)
    cy.get('li a').then(words => {
        const word = words[0]
        cy.visit(word.href)
    })
  })

  it('clicking a word shows a list of related words', () => { (5)
    cy.get('a').contains('abbey').click();
    cy.contains('church')
    cy.contains('monastery')
  })

})
1 beforeEach körs innan varje it. Här besöker vi sidan som vi vill testa.
2 describe-funktionen beskriver att det är ett test för thesaurus.
3 it-funktionen beskriver att testet ska passera om det finns en lista (förhoppningsvis med ord).
4 it-funktionen beskriver att testet ska passera om det finns en länk till ett ord
5 it-funktionen beskriver att testet ska passera om det går att klicka på ett ord och få upp en lista med relaterade ord

Vanliga saker att testa

Visas rätt sak på skärmen?

Det finns två sätt att testa om rätt sak visas på skärmen: cy.contains och cy.get.

cy.get tar en css-selektor som argument. Cypress kommer att leta efter ett element som matchar css-selektorn i DOM:en. Om det finns en matchning kommer testet att passera.

cy.contains tar en sträng som argument. Cypress kommer att leta efter en matchning för strängen i DOM:en. Om det finns en matchning kommer testet att passera.

Exempel
cy.contains('Login') (1)
cy.get('button').contains('Login') (2)
cy.get('nav form#logout button').should('have.text', 'Logout') (3)
cy.get('.item').should('have.length', 3) (4)
1 Testar om texten 'Login' finns någonstans på sidan
2 Testar om det finns en knapp med texten 'Login' någonstans på sidan
3 Testar om det finns en knapp med texten 'Logout' i en form med id logout i en nav
4 Testar om det finns tre element med klassen 'item' på sidan
Skapas rätt saker när man fyller i formulär?

För att fylla i formulär används cy.get och .type tillsammans. .type tar en sträng som argument och skriver in den i det element som matchar css-selektorn.

Exempel
cy.get('input[name="comment"]').type('This is my comment') (1)
cy.get('form').submit() (2)
cy.contains('This is my comment') (3)
1 Skriver in texten 'This is my comment' i ett input-element med namnet 'comment'
2 Skickar formuläret
3 Testar om texten 'This is my comment' finns någonstans på sidan (som man skickats till efter formuläret skickats)
Fungerar inloggnignsfunktionen?
Exempel
cy.get('input[name="username"]').type('admin')
cy.get('input[name="password"]').type('password')
cy.get('form').submit()
cy.contains('Welcome admin')
Exempel
cy.get('button.logout'].click()
cy.contains('Logga in')
Fungerar länkar?
Exempel
cy.get('a').contains('Home').click()
cy.contains('Welcome to our website')
cy.href().should('eq', 'http://localhost:9292/')
Fungerar rättighetskontroller?
Exempel
cy.get('input[name="username"]').type('notadmin')
cy.get('input[name="password"]').type('password')
cy.get('form').submit()

cy.visit('http://localhost:9292/admin')
cy.contains('You do not have permission to access this page')

Fil- och mappstruktur

Ett Sinatraprojekt innehåller följande filer och mappar:

fruit-o-Matic (1)
|
├── Gemfile
├── Gemfile.lock
├── app.rb
├── config.ru
├── public
│   ├── css
│   │   └── style.css
│   ├── img
│   │   └── fruit.png
│   └── js
│       └── script.js
└── views
    ├── fruits
    │   ├── index.erb
    │   └── show.erb
    ├── index.erb
    └── layout.erb
1 Varje Sinatraprojekt måste ligga i en egen mapp, i det här fallet fruit-o-matic.

Gemfile & Gemfile.lock

Gemfile berättar vilka bibliotek (i Ruby kallas biblioteken för "gems") vår applikation behöver.

För att installera biblioteken som finns i Gemfile kör man kommandot bundle install i terminalen i samma mapp som Gemfile ligger.

När man kör bundle install kommer filen Gemfile.lock skapas. Den innehåller vilken version av biblioteken som installerades.

Gemfile
source 'https://rubygems.org' (1)

gem 'thin' (2)
gem 'sinatra' (2)
gem 'rerun' (2)
1 Talar om för bundler var biblioteken vi ska använda finns.
2 Vilka gems (bibliotek) som ska installeras

app.rb

I app.rb skapar vi vår applikation och definerar våra routes.

app.rb
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class App < Sinatra::Base (1)

    get '/' do (2)
        erb :index
    end

    get '/fruits' do (3)
        erb :'fruits/index'
    end

end
1 Talar om att filen innehåller en Sinatra-applikation.
2 Gör så att innehållet i views/index.erb renderas när man surfar till root-url:en ('/').
3 Gör så att innehållet i views/fruit/index.erb renderas när man surfar till '/fruits'.

config.ru

config.ru nvänds av rackup för att starta applikationen (ru i config.ru står för rackup).

config.ru
require 'bundler' (1)
Bundler.require   (2)
require_relative 'app' (3)
run App (4)
1 Gör så vi kan använda bundler för att läsa in biblioteken i Gemfile.
2 Läser in biblioteken i Gemfile.
3 Läser in app.rb,
4 Säger till rackup att starta applikationen App från app.rb (se rad 1 i app.rb).

public-mappen

I public-mappen lägger man statiska resurser som bilder, css och js - oftast i separata undermappar så det inte blir för rörigt.

views-mappen

I views-mappen lägger man sina templates/mallar - ofta i separata undermappar så det inte blir för rörigt när man har flera resurser i sin app.

Moduler

Efter ett tag, när applikationen blir större, blir det rörigt att ha all kod i app.rb. Då behöver man bryta ut koden i moduler.

En modul är en fil som innehåller kod (funktioner) som hör ihop.

I Webbserverprogrammering 1 kommer vi att använda moduler för att bryta ut kod som pratar med databasen. Vi kommer använda en modul för varje tabell i databasen.

Genom att använda moduler kan vi gå från kod i app.rb som ser ut så här:

app.rb
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
class App < Sinatra::Base

    def db
        if @db == nil
            @db = SQLite3::Database.new('./db/db.sqlite')
            @db.results_as_hash = true
        end
        return @db
    end

    ...

    get '/fruits' do
        @user = db.execute('SELECT * FROM users WHERE id = ?', session[:user_id]).first
        @fruits = db.execute('SELECT * FROM fruits')
        erb :'fruits/index'
    end
end

Till kod som ser ut såhär:

app.rb
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
require_relative 'models/user'
require_relative 'models/fruit'

class App < Sinatra::Base

    get '/fruits' do
        @user = User.find(session[:user_id])
        @fruits = Fruit.all
        erb :'fruits/index'
    end
end

Detta gör att koden blir mer lättläst och underhållbar, man tappar inte bort sig bland alla SQL-frågor och databasoperationer i app.rb.

För att åstadkomma detta skapar vi en mapp som heter models och lägger våra moduler där (moduler som pratar med databasen kallas traditionellt för "models").

En modul är en fil som innehåller en module-deklaration:

models/fruit.rb
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
module Fruit (1)

    def self.all (2)
        db.execute('SELECT * FROM fruits')
    end

    def self.find(id) (2)
        db.execute('SELECT * FROM fruits WHERE id = ?', id).first
    end

    def self.most_popular(amount) (2)
        db.execute('SELECT * FROM fruits ORDER BY popularity DESC LIMIT ?', amount)
    end

    def self.db (3)
        if @db == nil
            @db = SQLite3::Database.new('./db/db.sqlite')
            @db.results_as_hash = true
        end
        return @db
    end

end
1 Skapar en modellen Fruit i modulen Fruit.
2 Skapar metoderna Fruit.all, Fruit.find(id) och Fruit.most_popular(amount), som vi kan anropa från app.rb (en metod är en funktion som är definerad i en modul).
3 Koden för att skapa databasanslutningen flyttas från app.rb.

Namnet på metoderna (all, find, most_popular) är godtyckliga, de kan heta vad som helst, men det är en bra idé att använda namn som beskriver vad metoden gör.

Linux

Kommandon

cat

Används för att visa innehållet i en fil cat app.rb

cd

Navigera mellan mappar. Fungerar som i Windows, men blanksteg krävs vid cd ..

ctrl+c

Avbryter den process som körs för stunden.

killall

Används för att avbryta en process som körs i bakgrunden: killall ruby.

ls

Lista filer i nuvarande mapp (som dir i Windows).

mkdir

Skapar en mapp i den aktuella sökvägen: mkdir grillkorv

pwd

Skriv ut sökvägen till aktuell mapp.

rm

Tar bort en fil (obs den hamnar EJ i papperskorgen): rm grillkorv.rb

su

Används för att byta användare: su wsl byter till användaren "wsl". Gör detta om du ser typ root@**:/mnt…​. i terminalen (då bör du se → wsp istället)

sudo

Används för att köra kommandom med root- (administratörs-)behörighet ex sudo killall ruby.

touch

Skapar en ny tom fil touch grillkorv.rb

Installation och konfigurering

Börja med att göra alla uppdateringar i Windows update.

Windows Terminal

Windows Terminal är en bättre terminal än Windows Command Prompt (cmd). Installera från Microsoft Store

WSL

I stort sett all webbserverprogrammering sker nuförtiden på Linux- eller linux-kompatibla operatisystem. Därför är de flesta guider och bibliotek (samlingar med kod som utvecklare kan använda) skrivna för Linux.

Eftersom Windows inte är ett linuxkompatibelt operativsystem kommer vi installera WSL - Windows Subsystem for Linux. WSL är ett sätt att köra virtuella Linuxmaskiner parallelt med Windows.

Ladda ner kursens Linux-image (finns även i Classroom). Spara filen som wsp.tar i mappen du skapat för kursen. Packa upp ZIP-filen till en TAR-fil.

Starta Windows Terminal som administratör (högerklicka på ikonen och väl "Starta som Administratör") Navigera till mappen i Windows Terminal och kör nedanstående kommandon, ett i taget.

Kan du inte starta som Administratör måste du lägga till ditt konto i Windows så du har rättigheter att köra som admin.

dism.exe /online /enable-feature /featurename:Microsoft-Windows-Subsystem-Linux /all /norestart (1)
dism.exe /online /enable-feature /featurename:VirtualMachinePlatform /all /norestart (2)

wsl --update (3)
wsl --set-default-version 2
wsl --unregister Ubuntu

wsl --import WSP . .\wsp.tar (4)

netsh interface portproxy add v4tov4 listenport=9292 listenaddress=0.0.0.0 connectport=9292 connectaddress=(wsl hostname -I) (5)
1 Aktivera WSL
2 Aktivera stöd för Virtuella Maskiner
3 Installera senaste versionen av WSL, ställ in rätt version + avinstallera ev. tidigare versioner av Ubuntu
4 Sökvägen till WSP-imagen
5 Gör så att datorn forwardar porten till WSP

Om wsl --update inte fungerar (märks genom att bara instruktionerna för hur wsl används skrivs ut) har du en för gammal version av Windows. Hämta uppdateringar genom att

  1. Se till att Hyper-V och WSL är igång: Kontrollpanelen > Program > Aktivera eller inaktivera Windowsfunktioner

  2. Öppna Inställningar > Updateringar & Säkerhet > Windows Update

  3. Klicka på "Avancerade Alternativ" (längst ner)

  4. Sätt på "Hämta uppdateringar för andra Microsoft-produkter när jag uppdaterar Windows"

  5. Gå tillbaks till föregående skärm i inställningar och Klicka på "Check for updates", "Ladda ner" och vänta på att uppdateringarna laddas ner och installeras.

  6. Starta om Windows en eller flera gånger allteftersom du blir ombedd. Detta kan ta låång tid och även misslyckas. Prata med mig i så fall.

Slutligen:

Starta den virtuella maskinen: wsl. Användarnamn och lösenord för WSL-imagen: wsl

Visual Studio Code

Installera följande extensions:

  • Ruby LSP (av Shopify)

  • erb (av Craig Maslowski)