Webbserverprogrammering 1 · område 8 · guide
Filmarkivet – driftsättning, API och externa data
Tjugoett steg som tar Filmarkivet från din dator till en riktig adress med HTTPS. På vägen hämtar det data från Wikipedia, och får ett eget API som svarar med JSON. Nästan ingen ny kod behövs – det mesta är inställningar, och att förstå varför de behövs. Guiden är uppdelad efter lektionerna, så att du kan slå upp den del du behöver.
Guiden går att följa på egen hand. Så här hänger stegen ihop med passen, och ungefär hur lång tid de tar om du gör dem själv:
- Lektion 57 (ons 14 apr) – steg 1 till steg 6, ungefär 60 minuter. Det är etapp 1, och den ska vara klar före tisdag. Hoppa inte över steg 6: det är där du ser att driften fungerar innan du går till Azure.
- Lektion 58 (tis 20 apr) – steg 7 till steg 12, ungefär 60 minuter om steg 6 gick igenom. Skaffa kontot i steg 7 så tidigt du kan – verifieringen kan ta tid.
- Lektion 59 (ons 21 apr) – steg 13 till steg 19, ungefär 75 minuter. Öppna ditt externa API i webbläsaren innan du skriver klassen.
- Arbetspasset 60 (tis 27 apr) – steg 20 och steg 21, och resten av etapp 3.
- Redovisningen 61 (ons 28 apr) – etapp 4 har de fem punkterna. Var du borta: säg till, så får du redovisa senare.
På din dator ser du varje fel i terminalen. På servern visar felsidan ingenting – det var hela poängen i område 7. Felet finns i loggen, och bara där. Steg 11 visar var den är. Lär dig hitta den innan något går fel.
Kodblocken på den här sidan går inte att markera, och det är med flit. Att skriva av koden för hand är där en stor del av inlärningen sker: du läser raden, håller den i huvudet och upptäcker vid tangentbordet vad du inte förstod. Klistrar du in den hoppar du över precis det steget. Utskrifter från terminalen och svar från API:er är undantag – de är figurer, inte kod att öva på.
Guide · Område 8 · Webbserverprogrammering 1
Vad du bygger här
Filmarkivet lämnar din dator. När guiden är slut har applikationen en riktig adress med HTTPS, där vem som helst kan se filmerna och du kan logga in. Varje filmsida har en ruta med text från Wikipedia, och applikationen har ett eget API som svarar med JSON i stället för HTML.
https://<ditt-namn>.azurewebsites.net
├── /filmer/{id} Filmsidan. Ny: rutan från Wikipedia
├── /api/filmer Ny. Alla filmer som JSON
├── /api/filmer/{id} Ny. En film som JSON, eller 404
└── … allt från område 7, oförändrat
Det färdiga projektet ligger i referensprojekt/Filmarkivet.
Det här området handlar om det som skiljer en körande applikation från en driftsatt. På din dator vet applikationen var databasen ligger, lösenorden ligger i user-secrets och du ser varje fel i terminalen. På en server finns inget av det – om du inte sett till att det gör det. Nästan ingen ny kod behövs. Det mesta är inställningar, och att förstå varför de behövs.
| Lektion | Vad | Steg |
|---|---|---|
| 57 | Från utvecklingsmiljö till drift | 1–6 |
| 58 | Driftsättning | 7–12 |
| 59 | JSON, API:er och samspelet klient–server | 13–19 |
| 60 | Arbetspass | 20–21 |
Vad du ska göra själv efteråt
Samma sak med ditt projekt. Det är Projekt 3, och kraven per betygsnivå står i uppgiften. Det här är vad guiden går igenom:
- Inställningarna i drift som miljövariabler, inte i koden
- Migrationerna körs i driftmiljön
- Projektet publicerat på Azure App Service, med HTTPS
- En sida som hämtar och visar data från ett externt API – och som fungerar också när API:et inte svarar
- En egen endpoint som returnerar JSON
- Inga hemligheter i repot, och driftsättningen dokumenterad så att någon annan kan upprepa den
Översättningstabellen
| Filmarkivet | Ditt projekt |
|---|---|
ConnectionStrings__Filmarkivet | samma nyckel med ditt namn på anslutningssträngen |
Anvandare__AdminLosenord, Anvandare__LasareLosenord | dina nycklar från område 7 |
Data/Databasstart.cs | samma fil – den är inte knuten till domänen |
Wikipedia, page/summary/{titel} | ditt externa API – Wikipedia, eller ett nyckelfritt API som passar din domän bättre |
IWikipediaKlient / WikipediaKlient | ditt gränssnitt och din typade klient |
Sammanfattning | det du visar av API:ets svar |
{titel} (film), sedan {titel} | hur du gör din sökterm träffsäker |
Rutan på /filmer/{id} | sidan där datan från API:et visas |
/api/filmer, /api/filmer/{id} | ditt eget API, på din huvudentitet |
FilmSvar | ditt svarsobjekt – de fält API:et ska visa |
filmarkivet-<namn>.azurewebsites.net | din adress |
Fyll i högerkolumnen innan du börjar. Två rader kräver ett beslut: vilket API du hämtar data från, och vilka fält ditt eget API ska lämna ut.
Innan du börjar
Ditt projekt från område 7: roller, lösenorden i user-secrets, och sidorna som ändrar data skyddade. Kontrollen tar tio sekunder:
dotnet run
Logga in som din admin och ändra en post. Logga ut, och försök nå samma sida – du ska hamna på inloggningen. Fungerar det är du redo.
Allt ska vara committat och pushat. Det du driftsätter ska vara det som ligger i repot, så att någon annan kan göra om det.
Begreppen i det här momentet
| Begrepp | Vad det är |
|---|---|
| Miljö | Development på din dator, Production på servern. Styrs av ASPNETCORE_ENVIRONMENT |
| Miljövariabel | En inställning som operativsystemet ger programmet när det startar. Ligger utanför koden |
dotnet publish | Bygger det som ska köras på servern – utan källkod, utan testprojekt |
| App Service | Azures tjänst för webbapplikationer. Du lämnar programmet, Azure kör det |
| Driftsättning | Att få en applikation att köra på en server, åtkomlig för andra |
| API | Application Programming Interface – ett gränssnitt som program anropar, inte människor |
| REST | Ett sätt att bygga API:er: adresser är resurser, HTTP-metoden säger vad som ska göras |
| JSON | Textformatet API:er oftast svarar med. {"titel":"Gökboet","ar":1975} |
| Statuskod | Siffran i svaret som säger hur det gick. 200 OK, 404 finns inte, 500 serverfel |
| Endpoint | En adress som svarar på förfrågningar. En sida är en, /api/filmer en annan |
HttpClient | Klassen som skickar förfrågningar från din server till en annan |
IHttpClientFactory | Håller i HttpClient åt dig, så att anslutningarna återanvänds |
| Typad klient | En egen klass som får en färdig HttpClient injicerad |
| Tidsgräns | Hur länge HttpClient väntar på ett svar innan den ger upp |
| Problem details | Standardformen för ett fel i ett JSON-API |
---
Steg 1 – Två miljöer
Vad vi gör. Ser vad som skiljer Development från Production – utan att ändra något.
Filerna. appsettings.json, appsettings.Development.json och Properties/launchSettings.json.
När du kör dotnet run läser applikationen launchSettings.json, och där står:
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
Det är den raden som gör din dator till en utvecklingsmiljö. På en server finns ingen launchSettings.json. Där är miljön Production, om ingen sagt något annat.
Varför det ser ut så. Tre saker i ditt projekt beter sig olika beroende på miljön, och alla tre har du redan sett:
| Development | Production | |
|---|---|---|
Ett undantag som ErrorBoundary inte fångar | Stackspåret visas i webbläsaren | Felsidan, Error.razor |
appsettings.Development.json | Läses, ovanpå appsettings.json | Läses inte |
| user-secrets | Läses | Läses inte |
Den sista raden är den som ställer till det. Dina lösenord från område 7 ligger i user-secrets. På servern finns de inte – där måste de komma någon annanstans ifrån. Det är nästa steg.
Kontrollera. Öppna launchSettings.json och hitta raden. Öppna sedan appsettings.Development.json: det som står där gäller bara på din dator.
I ditt projekt. Identiskt. Filerna finns redan.
Steg 2 – Inställningarna utanför koden
Vad vi gör. Ger applikationen en inställning utifrån, som en miljövariabel.
Allt i appsettings.json går att skriva över med en miljövariabel. Nyckeln skrivs med dubbla understreck där JSON-filen har nivåer:
I appsettings.json eller user-secrets | Som miljövariabel |
|---|---|
ConnectionStrings → Filmarkivet | ConnectionStrings__Filmarkivet |
Anvandare → AdminLosenord | Anvandare__AdminLosenord |
Wikipedia → Adress | Wikipedia__Adress |
Pröva i PowerShell, i projektmappen:
$env:ASPNETCORE_ENVIRONMENT = "Production"
dotnet run --no-launch-profile
--no-launch-profile behövs: annars sätter launchSettings.json tillbaka Development. Terminalen säger:
Now listening on: http://localhost:5000
Hosting environment: Production
Stoppa med Ctrl+C, och nollställ variabeln – den gäller annars resten av terminalfönstret:
$env:ASPNETCORE_ENVIRONMENT = $null
Varför det ser ut så. Samma kod ska kunna köras på din dator och på servern, med olika värden. Värdena kan inte stå i koden, för då är de samma överallt – och de hamnar i git. En miljövariabel ligger utanför både koden och repot. Ordningen som läses är: appsettings.json, sedan appsettings.{miljö}.json, sedan user-secrets (bara i Development), sedan miljövariabler. Det som läses sist vinner.
Dubbla understreck och inte kolon, eftersom kolon inte är tillåtet i namnet på en miljövariabel överallt. Dubbla understreck fungerar i alla miljöer.
Kontrollera. Hosting environment: Production i terminalen.
I ditt projekt. Identiskt. Skriv upp dina nycklar – namnen på din anslutningssträng och dina lösenord. Du behöver dem i steg 9.
Steg 3 – Migrationerna i driftmiljön
Vad vi gör. Låter applikationen köra sina migrationer själv när den startar – men bara när en inställning säger det.
Filen. Data/Databasstart.cs, ny:
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace Filmarkivet.Data;
// Migrationerna i driftmiljön. Ny i område 8.
//
// På den egna datorn kör du dotnet ef database update. På servern finns inget
// dotnet ef och ingen terminal du sitter vid - applikationen får göra det
// själv när den startar. Men bara när inställningen säger det:
//
// Databas__MigreraVidStart = true
//
// På den egna datorn är den avslagen, så att database update förblir ett steg
// du gör och ser.
public static class Databasstart
{
public static async Task MigreraAsync(IServiceProvider tjanster, IConfiguration konfiguration,
ILogger logg)
{
if (!konfiguration.GetValue<bool>("Databas:MigreraVidStart"))
{
return;
}
// SQLite skapar databasfilen om den saknas, men inte mappen den ska
// ligga i. I App Service ligger den i D:\home\data, som inte finns från
// början.
string? fil = new SqliteConnectionStringBuilder(
konfiguration.GetConnectionString("Filmarkivet")).DataSource;
string? mapp = Path.GetDirectoryName(Path.GetFullPath(fil));
if (!string.IsNullOrEmpty(mapp))
{
Directory.CreateDirectory(mapp);
}
var fabrik = tjanster.GetRequiredService<IDbContextFactory<FilmarkivetContext>>();
using var db = await fabrik.CreateDbContextAsync();
List<string> ejKorda = (await db.Database.GetPendingMigrationsAsync()).ToList();
await db.Database.MigrateAsync();
logg.LogInformation("Databasen {Fil}: {Antal} migration(er) körda {Namn}",
fil, ejKorda.Count, string.Join(", ", ejKorda));
}
}
Och i Program.cs, där användarna skapas – migrationerna först:
// Användarna som finns från start. Lösenord kan inte ligga i en migration -
// de ska hashas, och det gör UserManager. Sedan område 7 står de inte heller
// i koden: se Data/Anvandardata.cs.
//
// I drift finns inga tabeller förrän migrationerna körts, så de kommer först.
using (var omfang = app.Services.CreateScope())
{
await Databasstart.MigreraAsync(omfang.ServiceProvider, app.Configuration, app.Logger);
await Anvandardata.SkapaAsync(omfang.ServiceProvider, app.Configuration, app.Logger);
}
Varför det ser ut så. På din dator kör du dotnet ef database update. På servern finns inget dotnet ef, och ingen terminal du sitter vid. Applikationen får göra det själv.
Varför en inställning, och inte alltid? På din egen dator vill du fortsätta se database update som ett eget steg – det är där du märker att en migration saknas. I drift slås det på med Databas__MigreraVidStart = true.
Varför mappen skapas. SQLite skapar databasfilen om den saknas, men inte mappen den ska ligga i. I App Service ligger databasen i en mapp som inte finns från början.
Varför migrationerna före användarna. Anvandardata letar efter användare i AspNetUsers. I en ny databas finns inte tabellen förrän migrationerna körts.
Kontrollera. Görs i steg 6, med det publicerade programmet.
I ditt projekt. Kopiera filen och byt namnet på anslutningssträngen och ditt DbContext. Raderna i Program.cs hamnar där dina användare skapas.
Steg 4 – Mindre loggning i drift
Vad vi gör. Stänger av SQL-loggningen utanför din dator.
Filen. appsettings.json:
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"Filmarkivet": "Data Source=filmarkivet.db"
},
"Wikipedia": {
"Adress": "https://sv.wikipedia.org/api/rest_v1/"
}
}
Varför det ser ut så. SQL-loggningen från lektion 38 står i appsettings.Development.json. Men Default är Information, och EF Core loggar varje fråga på den nivån – också i Production, där ingen läser den. Raden "Microsoft.EntityFrameworkCore": "Warning" stänger av det.
På din dator gäller ändå SQL-loggningen: appsettings.Development.json har den mer precisa nyckeln Microsoft.EntityFrameworkCore.Database.Command, och den mest precisa nyckeln vinner. För samma sida blir det 7 rader SQL i Development och 0 i Production.
Wikipedia-raden är till steg 16.
Kontrollera. Kör i Development och öppna en sida – SQL i terminalen. Kör sedan som i steg 2 – ingen.
I ditt projekt. Samma rad.
Steg 5 – Publicera och zippa
Vad vi gör. Bygger det som ska köras på servern, och packar det.
I projektmappen:
dotnet publish -c Release -o ..\publicera
tar -a -c -f ..\filmarkivet.zip -C ..\publicera *
dotnet publish bygger en Release-version i mappen publicera, bredvid projektmappen. tar packar innehållet i mappen till en zip. Resultatet för Filmarkivet: 248 filer, 22,6 MB.
Varför det ser ut så.
**Varför ..\publicera, utanför projektmappen?** Ditt repo börjar i projektmappen. En publicera-mapp därinne hade kommit med i nästa git add . – och byggts in i nästa dotnet publish.
Varför innehållet och inte mappen? App Service packar upp zippen och letar efter Filmarkivet.dll och web.config direkt i roten. Ligger de i en undermapp hittas de inte. -C ..\publicera * betyder: gå in i mappen, packa det som ligger där.
**Varför tar och inte Compress-Archive?** QuickGrid har en fil med ett mycket långt namn i wwwroot/_content. Ligger publiceringen djupt i en mappstruktur – till exempel i OneDrive – blir sökvägen över 260 tecken, och Compress-Archive i Windows PowerShell ger upp: Det gick inte att hitta en del av sökvägen. tar har inte den gränsen. Det följer med Windows 10 och 11.
Vad följer med – och inte. Inga .cs-filer, inget testprojekt, ingen databasfil. web.config följer med: det är filen Windows-servern läser för att starta appen.
Kontrollera. Öppna ..\publicera. Filmarkivet.dll och web.config ska ligga direkt i mappen. Öppna zippen – samma sak.
I ditt projekt. Byt filmarkivet.zip mot ditt namn. Resten är identiskt.
Steg 6 – Prova driften på din egen dator
Vad vi gör. Kör det publicerade programmet i Production, med inställningarna som miljövariabler – så som servern kommer att köra det.
I PowerShell, från projektmappen:
cd ..\publicera
$env:ASPNETCORE_ENVIRONMENT = "Production"
$env:ConnectionStrings__Filmarkivet = "Data Source=C:\temp\drift\filmarkivet.db"
$env:Databas__MigreraVidStart = "true"
$env:Anvandare__AdminLosenord = "Provlosen-123"
$env:Anvandare__LasareLosenord = "Provlosen-456"
dotnet Filmarkivet.dll
Mappen C:\temp\drift behöver inte finnas. Bland raderna i terminalen:
Databasen C:\temp\drift\filmarkivet.db: 3 migration(er) körda 20260907122027_ForstaMigrationen, 20260923061112_GenreOchRelation, 20260923082206_Identity
Now listening on: http://localhost:5000
Hosting environment: Production
Den första raden är Databasstarts egen. EF Cores rader om varje migration syns inte – steg 4 dämpade dem. Öppna http://localhost:5000 och logga in som admin med lösenordet ovan.
En varning om att https-porten inte gick att avgöra är väntad: programmet lyssnar bara på http på din dator. På servern sköter Azure HTTPS.
Nollställ variablerna när du är klar, eller stäng terminalfönstret.
Varför det ser ut så. Det här är servern i miniatyr: ingen launchSettings, ingen user-secrets, ingen databas från början. Fungerar det här, har du uteslutit hälften av det som kan gå fel i Azure. Fungerar det inte här, fungerar det inte där.
En varning som ser farlig ut. Mitt i migrationerna står:
The migration operation 'PRAGMA foreign_keys = 0;' from migration 'GenreOchRelation'
cannot be executed in a transaction.
Den är väntad. SQLite kan inte slå av kontrollen av främmande nycklar inuti en transaktion, och migrationen från område 6 bygger om tabellen. Migrationen går igenom.
**Prova med dotnet run, och det ser trasigt ut.** dotnet run --no-launch-profile i Production, som i steg 2, ger en sida utan komponenternas CSS: filen Filmarkivet.….styles.css svarar 500. Den byggs bara ihop under utveckling. Det publicerade programmet har den. Prova driften med det du publicerat.
Kontrollera. Raden 3 migration(er) körda, inloggningen fungerar, och C:\temp\drift innehåller nu filmarkivet.db.
I ditt projekt. Byt nycklarna mot dina, och Filmarkivet.dll mot ditt projekts namn.
Steg 7 – Ett konto i Azure
Vad vi gör. Skaffar ett Azure-konto med studentkrediter.
Gå till Azure for Students och registrera dig med din skolmejl. Kontot kräver inget kreditkort. Du får krediter som räcker långt – och det här området använder gratisnivån, som inte drar några.
Varför det ser ut så. App Service kör din applikation på Microsofts servrar. Kontot är vem som äger den, och vem som får ändra den.
Kontrollera. Du kommer in i Azure-portalen, portal.azure.com, och ser din prenumeration Azure for Students.
I ditt projekt. Identiskt.
Steg 8 – Skapa webbappen
Vad vi gör. Skapar den plats på servern där Filmarkivet ska köra.
I portalen: Create a resource → Web App. Fyll i:
| Fält | Värde | Varför |
|---|---|---|
| Subscription | Azure for Students | – |
| Resource Group | ny, till exempel filmarkivet | En mapp för allt som hör till projektet. Tas den bort, tas allt bort |
| Name | filmarkivet-<ditt namn> | Blir adressen. Måste vara unikt i hela Azure |
| Publish | Code | Du laddar upp ett publicerat program, inte en container |
| Runtime stack | .NET 10 (LTS) | Samma version som ditt projekt |
| Operating System | Windows | Zippen går att dra in i webbläsaren – steg 10 |
| Region | Sweden Central | Nära, och datan stannar i Sverige |
| Pricing plan | Free F1 | Gratis. Räcker för ett skolprojekt |
Review + create, sedan Create.
Varför Windows? Uppladdningen i steg 10 – att dra zippen till webbläsaren – fungerar enligt Microsofts dokumentation bara på Windows-varianten. Ditt program bryr sig inte: dotnet publish gav en web.config just för Windows-servern.
Kontrollera. Go to resource. Överst står Default domain: filmarkivet-<ditt namn>.azurewebsites.net. Öppna den – en sida från Azure om att appen väntar på innehåll.
I ditt projekt. Ditt namn på resursgruppen och appen.
Steg 9 – Inställningarna
Vad vi gör. Lägger in miljövariablerna från steg 6 – med riktiga värden.
I appen: Settings → Environment variables → App settings. Add för varje:
| Name | Value |
|---|---|
ConnectionStrings__Filmarkivet | Data Source=D:\home\data\filmarkivet.db |
Databas__MigreraVidStart | true |
Anvandare__AdminLosenord | ett nytt lösenord |
Anvandare__LasareLosenord | ett nytt lösenord |
Apply, och Apply igen på sidan Environment variables. Appen startas om.
Varför det ser ut så. Det här är samma nycklar som i steg 6, nu satta av servern i stället för av dig. ASPNETCORE_ENVIRONMENT behövs inte – utan den är miljön Production.
**Varför D:\home?** Det mesta på servern kan skrivas över när appen startas om eller publiceras på nytt. D:\home är den del som finns kvar. Ligger databasen någon annanstans är filmerna borta efter nästa publicering.
Varför nya lösenord? Lösenordet du använt hittills står kanske i din git-historik, från Projekt 1 – steg 20 visar hur du tar reda på det. Ett lösenord som finns i historiken är inte hemligt längre.
Kontrollera. Fyra rader under App settings. Värdena är dolda – Show value visar dem.
I ditt projekt. Dina nycklar, från steg 2.
Steg 10 – Ladda upp zippen
Vad vi gör. Lägger det publicerade programmet på servern.
I appen: Development Tools → Advanced Tools → Go. En ny flik öppnas: Kudu, verktyget bakom App Service. Välj Tools → Zip Push Deploy. Dra filmarkivet.zip från Utforskaren till filytan på sidan.
En procentsiffra visar förloppet. Sista raden ska bli Deployment successful.
Varför det ser ut så. Kudu packar upp zippen i appens mapp och startar om applikationen. Nästa gång du publicerar gör du samma sak: dotnet publish, tar, dra zippen. Databasen i D:\home\data rörs inte.
Kontrollera. Deployment successful.
I ditt projekt. Identiskt.
Steg 11 – Kontrollera driften
Vad vi gör. Går igenom att det som fungerade i steg 6 också fungerar på servern.
Öppna https://filmarkivet-<ditt namn>.azurewebsites.net. Första förfrågan tar en stund – appen startar och kör sina migrationer.
- HTTPS. Hänglåset i adressfältet. Skriv adressen med
http://– du ska
hamna på https://.
- Filmerna.
/filmervisar de sex filmerna från frödatan. - Inloggningen. Logga in som admin med lösenordet från steg 9.
- Skyddet. Logga ut och försök nå
/admin/filmer– inloggningen.
Loggen. Monitoring → App Service logs: sätt Application logging (Filesystem) till On, nivå Information, Save. Den stänger av sig själv efter 12 timmar. Öppna sedan Monitoring → Log stream och ladda om sidan.
Varför det ser ut så. Punkt 1 är det du inte kunde se på din egen dator: certifikatet är Azures, och UseHsts skickar nu headern Strict-Transport-Security – den skickas aldrig till localhost. Punkt 3 visar att miljövariablerna kom fram. Punkt 4 visar att skyddet från område 7 följde med.
Kontrollera. Alla fyra punkterna. Dela adressen med någon – den fungerar från deras dator också.
I ditt projekt. Samma fyra kontroller, med dina sidor.
Steg 12 – När det går fel första gången
Vad vi gör. Går igenom det som oftast går fel, och var svaret finns.
Första driftsättningen går sällan igenom på första försöket. Det är normalt, och felen är nästan alltid desamma. Börja alltid i loggen, steg 11.
| Symptom | Trolig orsak | Gör så här |
|---|---|---|
| HTTP Error 500.30 – ASP.NET Core app failed to start | Applikationen kastade ett undantag när den startade | Log stream. Oftast anslutningssträngen eller en migration |
| Sidan från Azure om att appen väntar på innehåll | Zippen kom inte upp, eller ligger i en undermapp | Steg 5: innehållet i roten. Steg 10: Deployment successful |
| Inloggningen säger fel lösenord | Användaren skapades aldrig | Log stream: skapades inte. Kontrollera nycklarnas namn, med dubbla understreck |
| Filmerna är borta efter en ny publicering | Databasen ligger utanför D:\home | Steg 9, anslutningssträngen |
| Allt fungerar, men gamla versionen syns | Kudu kopierar bara filer vars tid ändrats | Publicera och zippa på nytt, och ladda upp igen |
| Första förfrågan tar lång tid | Gratisnivån stänger av appen efter en stund utan trafik | Normalt. Den vaknar vid nästa förfrågan |
Varför det ser ut så. Felsidan i drift visar inget stackspår – det var hela poängen i område 7. Därför är loggen det enda stället där felet syns. Den som vet var loggen finns löser de flesta problem på några minuter.
I ditt projekt. Tabellen är densamma. Skriv upp det som gick fel för dig – det hör hemma i driftdokumentationen, steg 21.
Steg 13 – REST i korthet
Vad vi gör. Tittar på Filmarkivets API i webbläsaren, innan koden.
Öppna /api/filmer:
[{"id":6,"titel":"Cykeltjuven","ar":1948,"langdMinuter":89,"betyg":4,"tillganglig":true,"genre":"Drama"}, …]
Samma filmer som på /filmer, men utan HTML. Svaret är till för ett program, inte för en människa.
Varför det ser ut så. Ett REST-API är byggt kring resurser – här filmer – och använder HTTP som det är tänkt:
| Del | Här | Betyder |
|---|---|---|
| Adressen | /api/filmer, /api/filmer/5 | Vilken resurs: alla filmer, eller film 5 |
| Metoden | GET | Vad som ska göras: läsa. POST, PUT och DELETE skapar, ändrar och tar bort |
| Statuskoden | 200, 404 | Hur det gick |
| Innehållet | JSON | Svaret, i ett format alla språk kan läsa |
Filmarkivets API läser bara. Att skapa och ändra via API:et hade krävt inloggning och antiforgery på ett annat sätt än i formulären – det ligger utanför kursen.
Kontrollera. /api/filmer/5 ger Gökboet. /api/filmer/99 ger 404 – steg 15.
I ditt projekt. Kommer i steg 14.
Steg 14 – Ett eget API
Vad vi gör. Två adresser som svarar med JSON.
Filen. Api/FilmApi.cs, ny:
using Filmarkivet.Models;
using Filmarkivet.Services;
namespace Filmarkivet.Api;
// Filmarkivets egna API. Nytt i område 8.
//
// Två adresser som svarar med JSON i stället för HTML. De går genom samma
// tjänst som sidorna - API:et är ett tredje sätt att visa samma data, inte
// ett nytt sätt att hämta den.
public static class FilmApi
{
public static void MapFilmApi(this WebApplication app)
{
// MapGroup ger båda adresserna samma början, /api/filmer.
RouteGroupBuilder filmer = app.MapGroup("/api/filmer");
filmer.MapGet("/", async (IFilmService arkivet) =>
{
List<Film> alla = await arkivet.HamtaAllaMedGenreAsync();
return alla.OrderBy(f => f.Titel).Select(FilmSvar.Fran);
});
// Results.Ok ger 200 med filmen. Saknas filmen blir det 404 - och
// statuskoden är en del av svaret: den som anropar ska inte behöva
// läsa texten för att veta att filmen saknades.
//
// Varför Results.Problem och inte Results.NotFound()? Program.cs gör om
// varje TOM 404 till Blazors inte-hittad-sida, som HTML. Rätt för en
// besökare, fel för ett program som väntar sig JSON. Ett svar med
// innehåll lämnas i fred - och problem details är standardformen för
// ett fel i ett API.
filmer.MapGet("/{id:int}", async (int id, IFilmService arkivet) =>
{
Film? film = await arkivet.HamtaMedGenreAsync(id);
return film is null
? Results.Problem(statusCode: StatusCodes.Status404NotFound,
title: "Filmen finns inte",
detail: $"Ingen film har id {id}.")
: Results.Ok(FilmSvar.Fran(film));
});
}
}
// Det API:et lämnar ut. Inte entiteten: Film har fält som inte hör hemma
// utanför applikationen, och ett nytt fält på Film ska inte hamna i API:et av
// sig självt. Genren skickas som namn, inte som ett helt objekt.
public record FilmSvar(int Id, string Titel, int Ar, int LangdMinuter, int Betyg,
bool Tillganglig, string? Genre)
{
public static FilmSvar Fran(Film film) =>
new(film.Id, film.Titel, film.Ar, film.LangdMinuter, film.Betyg,
film.Tillganglig, film.Genre?.Namn);
}
Och en rad i Program.cs, efter MapRazorComponents:
// Ny i område 8: /api/filmer och /api/filmer/{id}. Se Api/FilmApi.cs.
app.MapFilmApi();
Varför det ser ut så. MapGet kopplar en adress till en metod – samma sak som MapPost för utloggningen i område 6. Det metoden returnerar blir svaret. Är det ett objekt eller en lista görs den om till JSON, och egenskapernas namn blir camelCase: LangdMinuter blir langdMinuter.
Samma tjänst som sidorna. API:et går genom IFilmService. Det är ett tredje sätt att visa samma data – efter sidorna och formulären – inte ett nytt sätt att hämta den.
**Varför FilmSvar och inte Film?** Entiteten är databasens. Lägger du till ett fält på Film om ett år ska det inte hamna i API:et av sig självt. FilmSvar bestämmer vad API:et lämnar ut – och genren blir ett namn, inte ett helt objekt med en lista av filmer i.
Varför en egen fil? Program.cs är redan lång. MapFilmApi är en extension method: den ser ut som en metod på app, men står i en egen klass.
Kontrollera. /api/filmer och /api/filmer/5 i webbläsaren.
I ditt projekt. Ditt svarsobjekt, med de fält du vill lämna ut, och /api/ plus din huvudentitet i plural.
Steg 15 – 404 som JSON
Vad vi gör. Ser till att ett fel från API:et också är JSON.
Det enkla sättet att svara 404 är Results.NotFound(). Pröva det: /api/filmer/99 svarar då med 404 – och en HTML-sida, Filmarkivets inte hittad.
Varför det ser ut så. Program.cs har sedan område 3 raden UseStatusCodePagesWithReExecute("/not-found"). Den fångar varje tom 404 och visar sidan i stället. Rätt för en besökare – fel för ett program som väntar sig JSON och får HTML.
Results.Problem i steg 14 ger ett svar med innehåll, och det lämnas i fred:
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.5","title":"Filmen finns inte","status":404,"detail":"Ingen film har id 99."}
med Content-Type: application/problem+json. Det är problem details, standardformen för ett fel i ett API. Den som anropar kan läsa statuskoden, och en människa som felsöker kan läsa texten.
Kontrollera. /api/filmer/99 ger JSON, inte en sida. DevTools → Network visar statuskoden 404.
I ditt projekt. Samma sak för ditt id som inte finns.
Steg 16 – En klient mot Wikipedia
Vad vi gör. Registrerar en HttpClient som vet var Wikipedia finns.
Pröva API:et i webbläsaren först: https://sv.wikipedia.org/api/rest_v1/page/summary/Cykeltjuven. Svaret är JSON med ett trettiotal fält. Filmarkivet använder tre: title, extract och adressen till artikeln.
Filen. Program.cs, efter de andra tjänsterna:
// Ny i område 8: en typad HttpClient mot Wikipedia. Adressen står i
// appsettings.json, så att den går att byta utan att koden ändras - till
// exempel mot en adress som inte svarar, för att se vad sidan gör då.
//
// Tidsgränsen är tre sekunder. Förvalet är hundra: så länge hade filmsidan
// väntat på ett API som inte svarar.
builder.Services.AddHttpClient<IWikipediaKlient, WikipediaKlient>(klient =>
{
klient.BaseAddress = new Uri(builder.Configuration["Wikipedia:Adress"]!);
klient.Timeout = TimeSpan.FromSeconds(3);
// Wikipedia kräver att den som anropar säger vem den är.
klient.DefaultRequestHeaders.UserAgent.ParseAdd("Filmarkivet/1.0 (skolprojekt)");
});
Adressen står i appsettings.json, från steg 4.
Varför det ser ut så. AddHttpClient registrerar en typad klient: WikipediaKlient får en HttpClient i konstruktorn, redan inställd. Klassen skapar aldrig en själv.
**Varför inte new HttpClient()?** Varje HttpClient håller en anslutning öppen en stund efter att den använts. En ny per anrop lämnar en öppen anslutning efter sig varje gång. IHttpClientFactory – som AddHttpClient sätter upp – återanvänder dem.
Varför tre sekunder? Förvalet är 100. Svarar inte Wikipedia väntar filmsidan så länge innan den visas. Steg 19 visar vad tre sekunder betyder.
Varför User-Agent? Wikipedia vill veta vem som anropar. Det är en fråga om hyfs mot en gratis tjänst – och om något går fel vet de vem de ska höra av sig till.
Kontrollera. dotnet build. Klassen kommer i nästa steg.
I ditt projekt. Ditt API:s adress i appsettings.json. Kräver ditt API en nyckel – den ska inte stå där, utan i user-secrets och som miljövariabel, som lösenorden.
Steg 17 – Klassen som hämtar
Vad vi gör. Skriver klienten, bakom ett gränssnitt.
Filerna. Models/Sammanfattning.cs:
namespace Filmarkivet.Models;
// Det Filmarkivet använder av Wikipedias svar. Ny i område 8.
//
// Svaret från Wikipedia har ett trettiotal fält. Det här är de tre sidan
// visar. Resten läses aldrig in - System.Text.Json hoppar över fält som inte
// finns i klassen.
public record Sammanfattning(string Titel, string Text, string Adress);
Services/IWikipediaKlient.cs:
using Filmarkivet.Models;
namespace Filmarkivet.Services;
// Ny i område 8. Samma mönster som IFilmService: sidan ber om en
// sammanfattning och vet inte varifrån den kommer.
public interface IWikipediaKlient
{
// null när Wikipedia inte har någon artikel, eller inte svarar.
// Sidan ska fungera i båda fallen - filmen finns ju ändå.
Task<Sammanfattning?> HamtaAsync(string titel);
}
Services/WikipediaKlient.cs:
using System.Net;
using System.Text.Json.Serialization;
using Filmarkivet.Models;
namespace Filmarkivet.Services;
// Hämtar en sammanfattning från svenska Wikipedia. Ny i område 8.
//
// En typad klient: HttpClient kommer in i konstruktorn, färdig med adress,
// tidsgräns och User-Agent från Program.cs. Klassen skapar aldrig en egen.
// IHttpClientFactory håller i anslutningarna - en ny HttpClient per anrop
// hade lämnat en öppen anslutning efter sig varje gång.
public class WikipediaKlient : IWikipediaKlient
{
private readonly HttpClient _http;
private readonly ILogger<WikipediaKlient> _logg;
public WikipediaKlient(HttpClient http, ILogger<WikipediaKlient> logg)
{
_http = http;
_logg = logg;
}
public async Task<Sammanfattning?> HamtaAsync(string titel)
{
// "Gökboet" är romanen på Wikipedia. "Gökboet (film)" är filmen. Därför
// frågar vi efter filmen först, och tar det rena namnet om den saknas.
try
{
return await HamtaSidaAsync($"{titel} (film)")
?? await HamtaSidaAsync(titel);
}
catch (Exception fel) when (fel is HttpRequestException or TaskCanceledException)
{
// Nätverket nere, eller ingen svarade inom tidsgränsen. Undantaget
// fångas här och inte i HamtaSidaAsync: då görs inte den andra
// frågan, och sidan väntar ut tidsgränsen en gång i stället för två.
// Filmsidan visas ändå, utan rutan.
_logg.LogWarning("Wikipedia gick inte att nå för {Titel}: {Fel}", titel, fel.Message);
return null;
}
}
private async Task<Sammanfattning?> HamtaSidaAsync(string sida)
{
string adress = "page/summary/" + Uri.EscapeDataString(sida.Replace(' ', '_'));
using HttpResponseMessage svar = await _http.GetAsync(adress);
// 404 är inget fel: artikeln finns inte. Allt annat som inte är
// 200 loggas, så att det syns om API:et börjat svara konstigt.
if (svar.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
if (!svar.IsSuccessStatusCode)
{
_logg.LogWarning("Wikipedia svarade {Status} på {Sida}", (int)svar.StatusCode, sida);
return null;
}
WikipediaSvar? innehall = await svar.Content.ReadFromJsonAsync<WikipediaSvar>();
if (innehall?.Extract is null)
{
return null;
}
return new Sammanfattning(innehall.Title, innehall.Extract,
innehall.ContentUrls?.Desktop?.Page ?? "");
}
// Formen på svaret, så långt vi behöver den. JsonPropertyName där
// Wikipedias namn inte går att översätta till en C#-egenskap.
private sealed record WikipediaSvar(
string Title,
string? Extract,
[property: JsonPropertyName("content_urls")] Adresser? ContentUrls);
private sealed record Adresser(Sidadress? Desktop);
private sealed record Sidadress(string? Page);
}
Varför det ser ut så.
Gökboet är en roman. Wikipedias artikel Gökboet handlar om boken, och Rosemarys baby också. Filmerna heter Gökboet (film) och Rosemarys baby (film). Klienten frågar därför efter filmen först, och tar det rena namnet om den saknas. Det är vad ett externt API är: det vet inte vad du menar, bara vad du frågar efter.
404 är inget fel. En artikel som inte finns ger 404 – då returneras null, och nästa titel provas. Andra statuskoder loggas, så att det syns om API:et börjar svara konstigt.
**ReadFromJsonAsync** läser JSON till objekt, som EditForm läser ett formulär. Fält som inte finns i klassen hoppas över. JsonPropertyName behövs för content_urls, som inte går att skriva som en C#-egenskap.
Undantaget fångas en gång. Ett nätverksfel fångas i HamtaAsync, inte i HamtaSidaAsync. Svarar inte Wikipedia på den första frågan görs då inte den andra – sidan väntar ut tidsgränsen en gång i stället för två.
Kontrollera. dotnet build.
I ditt projekt. Din klass mot ditt API, och ditt objekt med de fält du ska visa. Behöver din sökterm göras träffsäker, som (film), är det här det görs.
Steg 18 – Rutan på filmsidan
Vad vi gör. Visar sammanfattningen under filmens beskrivning.
Filen. Components/Pages/Filmdetalj.razor. Överst:
@inject IWikipediaKlient Wikipedia
Under beskrivningen:
@* Ny i område 8. Rutan visas bara när Wikipedia svarade - saknas artikeln,
eller svarar inte Wikipedia, syns ingenting. Texten kodas som all annan
text: den kommer utifrån, och ingen på Filmarkivet har skrivit den. *@
@if (wikipedia is not null)
{
<aside class="ruta">
<p><b>Om @wikipedia.Titel på Wikipedia</b></p>
<p>@wikipedia.Text</p>
<p><a href="@wikipedia.Adress">Läs hela artikeln</a> · Text från svenska Wikipedia, CC BY-SA</p>
</aside>
}
I @code:
private Film? film;
private Sammanfattning? wikipedia;
private bool tillagd;
protected override async Task OnParametersSetAsync()
{
film = await Arkivet.HamtaMedGenreAsync(Id);
if (film is null)
{
Navigering.NotFound();
return;
}
wikipedia = await Wikipedia.HamtaAsync(film.Titel);
}
Varför det ser ut så. Sidan frågar tjänsten, som alltid. Svarar tjänsten null visas ingen ruta – ingen tom ruta, och inget felmeddelande. Filmen finns ju ändå; det är bara en extra upplysning som saknas.
Texten kodas. Den kommer från en annan sajt, och ingen på Filmarkivet har skrivit den. Blazor kodar den som all annan text – det du lärde dig om MarkupString i område 7 gäller här också.
Källan anges. Wikipedias text får användas om källan och licensen anges. Det är raden längst ned i rutan.
Kontrollera. /filmer/5: Om Gökboet (film) på Wikipedia, och en text om filmen från 1975 – inte romanen.
I ditt projekt. Din sida, din ruta. Kontrollera villkoren för ditt API: vad måste anges när du visar datan?
Steg 19 – När API:et inte svarar
Vad vi gör. Ser vad filmsidan gör när Wikipedia inte svarar – och testar det utan nätet.
Starta med en adress där ingen svarar, i PowerShell:
$env:Wikipedia__Adress = "http://10.255.255.1/"
dotnet run
Öppna /filmer/5. Sidan visas – efter drygt tre sekunder, utan rutan. Terminalen:
Wikipedia gick inte att nå för Gökboet: The request was canceled due to the
configured HttpClient.Timeout of 3 seconds elapsing.
Nollställ variabeln efteråt: $env:Wikipedia__Adress = $null.
Varför det ser ut så. Tre sekunder är tidsgränsen från steg 16. Utan den hade sidan väntat i 100 sekunder. Ett externt API kommer någon gång att vara nere, långsamt eller ändrat – din sida ska tåla det.
Testet. Att vänta på att Wikipedia går ner är ingen metod. HttpClient skickar sina förfrågningar genom en HttpMessageHandler, och i ett test kan den bytas mot en egen. Filen. Tester/WikipediaKlientTester.cs:
using System.Net;
using System.Text;
using Filmarkivet.Models;
using Filmarkivet.Services;
using Microsoft.Extensions.Logging.Abstractions;
namespace Filmarkivet.Tester;
// Tester av klienten mot Wikipedia, utan Wikipedia. Nya i område 8.
//
// HttpClient skickar sina förfrågningar genom en HttpMessageHandler. Byts den
// mot en egen går inget ut på nätet: testet bestämmer själv vad "Wikipedia"
// svarar. Så går det att testa det som är svårt att framkalla på riktigt - att
// API:et inte svarar alls.
public class WikipediaKlientTester
{
// En låtsas-Wikipedia. Funktionen får förfrågan och bestämmer svaret.
private sealed class FalskServer(Func<HttpRequestMessage, HttpResponseMessage> svara)
: HttpMessageHandler
{
public List<string> Fragade { get; } = new();
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage forfragan,
CancellationToken avbryt)
{
// Avkodad: klienten skickar (film) som %28film%29.
Fragade.Add(Uri.UnescapeDataString(forfragan.RequestUri!.AbsolutePath));
return Task.FromResult(svara(forfragan));
}
}
private static WikipediaKlient SkapaKlient(FalskServer server) =>
new(new HttpClient(server) { BaseAddress = new Uri("https://sv.wikipedia.org/api/rest_v1/") },
NullLogger<WikipediaKlient>.Instance);
private static HttpResponseMessage Json(string json) =>
new(HttpStatusCode.OK) { Content = new StringContent(json, Encoding.UTF8, "application/json") };
[Fact]
public async Task FilmartikelSaknas_TarArtikelnMedDetRenaNamnet()
{
var server = new FalskServer(forfragan =>
Uri.UnescapeDataString(forfragan.RequestUri!.AbsolutePath).EndsWith("(film)")
? new HttpResponseMessage(HttpStatusCode.NotFound)
: Json("""{"title":"Cykeltjuven","extract":"En italiensk film.","content_urls":{"desktop":{"page":"https://sv.wikipedia.org/wiki/Cykeltjuven"}}}"""));
Sammanfattning? svar = await SkapaKlient(server).HamtaAsync("Cykeltjuven");
Assert.NotNull(svar);
Assert.Equal("En italiensk film.", svar.Text);
Assert.Equal("https://sv.wikipedia.org/wiki/Cykeltjuven", svar.Adress);
Assert.Equal(new[] { "/api/rest_v1/page/summary/Cykeltjuven_(film)",
"/api/rest_v1/page/summary/Cykeltjuven" }, server.Fragade);
}
[Fact]
public async Task WikipediaSvararInte_GerNullOchFragarBaraEnGang()
{
var server = new FalskServer(_ => throw new HttpRequestException("Ingen svarade"));
Sammanfattning? svar = await SkapaKlient(server).HamtaAsync("Gökboet");
Assert.Null(svar);
Assert.Single(server.Fragade);
}
}
Det andra testet är det som säger att sidan väntar en gång och inte två: Assert.Single(server.Fragade).
Kontrollera. dotnet test Tester – elva tester, alla gröna.
I ditt projekt. Samma prov med din adress. Ett test av din klient med en låtsasserver räknas till testerna från område 7.
Steg 20 – Inga hemligheter i repot
Vad vi gör. Kontrollerar att inget lösenord ligger i repot – nu eller tidigare.
Nu:
git grep -n "Provlosen"
Byt Provlosen mot början av ett lösenord du använt. Ingen träff är målet.
Tidigare:
git log -S "Provlosen" --oneline
-S letar i hela historiken efter commits där texten lades till eller togs bort. Lägg till -- "*.cs" om du bara vill leta i koden. I kursens eget repo ger git log -S "Filmarkivet1" --oneline -- "*.cs" två commits: område 6, där lösenordet stod i Program.cs, och övningsappen i område 7, som har det med flit.
Varför det ser ut så. Att ta bort ett lösenord ur koden tar inte bort det ur git. Varje commit finns kvar, och vem som helst med tillgång till repot kan läsa den. Därför fick servern nya lösenord i steg 9. Ett lösenord som funnits i ett repo räknas som känt.
Kontrollera. Ingen träff med git grep. Träffar i git log -S är historik – kontrollera att det lösenordet inte används i drift.
I ditt projekt. Sök efter dina lösenord, och efter ordet password och losenord i koden. Några träffar är väntade: PasswordHash i migrationerna och Password.RequiredLength i Program.cs är namn, inte lösenord. Det hör hemma i driftdokumentationen att du kontrollerat det.
Steg 21 – Dokumentera driftsättningen
Vad vi gör. Skriver ned stegen så att någon annan kan göra om dem.
Filen. DRIFT.md, i repots rot. Uppgiften har mallen. Det som ska stå där är det du gjort i steg 5 till 11 – med dina namn, dina nycklar men inte dina värden.
Varför det ser ut så. En driftsättning som bara en person kan göra är en risk. Kan någon annan följa DRIFT.md och få samma resultat, är den dokumenterad. Det är examinationen i Projekt 3, tillsammans med den publicerade applikationen.
Kontrollera. Be en klasskamrat läsa DRIFT.md och säga vilket steg som är oklart.
I ditt projekt. Identiskt.
---
När något går fel
Slå upp felmeddelandet här innan du fastnar. Skriv en bit av det du ser.
10 rader
| Felmeddelande | Vad det betyder | Gör så här |
|---|---|---|
Hosting environment: Development fast du satt Production | launchSettings.json sätter tillbaka Development | dotnet run --no-launch-profile, steg 2 |
| Komponenternas CSS svarar 500 i Production | dotnet run i Production – filen finns bara under utveckling | Prova med det publicerade programmet, steg 6 |
Det gick inte att hitta en del av sökvägen från Compress-Archive | Sökvägen är längre än 260 tecken | tar, steg 5 |
SQLite Error 14: 'unable to open database file' | Mappen i anslutningssträngen finns inte, och migrationerna är avstängda | Databas__MigreraVidStart = true, steg 3 och 9 |
Användaren skapades inte – skapades inte: lösenordet saknas i konfigurationen | Miljövariabeln saknas eller är felstavad | Dubbla understreck. Jämför med nyckeln i user-secrets |
skapades inte: Passwords must have at least one digit | Lösenordet bryter mot reglerna | Minst åtta tecken, en siffra, en versal |
/api/filmer/99 ger en HTML-sida | Results.NotFound() – tom 404 som görs om till sidan | Results.Problem, steg 15 |
| Rutan syns aldrig | Adressen, eller svaret har andra fältnamn | Prova API-adressen i webbläsaren. Stämmer fältnamnen med ditt objekt? |
| Filmsidan tar lång tid att visa | Tidsgränsen saknas – förvalet är 100 sekunder | klient.Timeout, steg 16 |
| HTTP Error 500.30 i Azure | Appen kastade ett undantag vid start | Log stream, steg 11 och 12 |
Det som är lätt att missa när du gör det själv
- Miljövariabeln lever kvar.
$env:…gäller resten av terminalfönstret. Har du sattASPNETCORE_ENVIRONMENTtill Production och glömt det, är nästadotnet run --no-launch-profileockså i Production. - Ditt API är inte Wikipedia. Andra API:er svarar med andra fältnamn, andra statuskoder när något saknas, och har ibland en gräns för hur ofta man får fråga. Läs dokumentationen för ditt, och prova adressen i webbläsaren innan du skriver klassen.
- API-nyckeln är en hemlighet. Kräver ditt API en nyckel ska den behandlas som lösenorden: user-secrets på din dator, miljövariabel på servern, aldrig i repot.
- Databasen på servern är en annan databas. Poster du lagt till på din dator finns inte i Azure. Frödatan finns, och det du lägger till där.
- Testa efter varje publicering. Samma fyra kontroller som i steg 11. Det tar en minut.
Checklista
Guidens egen checklista. Den är inte betygskraven – de står i uppgiften och är uppdelade på E, C och A. Den här listan säger bara om du gjort klart det guiden går igenom.
Klart när allt är ikryssat
Kryssen sparas i din webbläsare.