Velkommen til .NET-skolen!
Dette er et kurs hvor du blir tatt gjennom prosessen av Ă„ sette opp, og implementere, en .NET-lĂžsning fra bunnen av, steg for steg. MĂ„let med kurset er Ă„ vise hvordan man kan utfĂžre oppgaver som er vanlige i etableringsfasen av et system, som Ă„:
Som en eksempel-applikasjon skal vi lage et web-API i F# for Ă„ hente ut elektronisk programguide (EPG) for NRK TV, med tilhĂžrende enhets- og integrasjonstester. Tanken er at API-et kunne levert datagrunnlaget til en programguide - f.eks. den som vises her: https://info.nrk.no/presse/tvguide/
Et sekundÊrt mÄl med dette repoet er at den ferdige eksempel-applikasjonen (som du finner i branchen ferdig) kan fungere som et referanse-repo for hvordan Ä sette opp et .NET-prosjekt.
Vi skal bruke .NET CLI til Ä opprette prosjekter, samt kjÞre koden og testene. I tillegg skal vi dokumentere web-API-et vÄrt ved hjelp av OpenAPI.
Overordnet kommer mappestrukturen til lÞsningen vÄr til Ä se slik ut:
âââ docs (kontrakt for web-API-et)
âââ src (kildekode til web-API-et)
âââ test (kildekode til enhets- og integrasjonstestene)
Det anbefales Ä fÞlge denne veiledningen pÄ GitHub, da visningen der stÞtter lenkene som er lagt inn, og har innholdsfortegnelse som alltid er synlig oppe til venstre nÄr man blar i veiledningen.
For Ä gjennomfÞre dette kurset trenger du .NET 6 SDK, en teksteditor og en terminal. NÄr du har dette, gÄ til Steg 1 - Opprette API og fÞlg veiledningen. For alternative startpunkter se alternative startpunkter.
Stegene i kurset gir veiledning, steg for steg, med anvisninger for kommandoer du kan kjĂžre og referanseimplementasjon av kode du kan kopiere. Enkelte steder er implementasjonen av koden imidlertid utelatt slik at du kan forsĂžke Ă„ implementere den selv. Disse stedene er markert med âïž. Les mer om hvordan du kan se fullstendig lĂžsningsforslag for hvert steg her.
Hvis du trenger mer detaljer om hvordan du gjÞr klar maskinen din til Ä gjennomfÞre kurset, se Detaljer om oppsett pÄ maskinen din.
Dersom du er helt ny til .NET kan det vĂŠre nyttig Ă„ begynne med Ă„ lese:
Denne workshopen dekker en del ulike temaer, og det kan ta litt tid Ä fullfÞre alle stegene. Heldigvis finnes det lÞsningsforslag for hvert steg i workshopen, som betyr at du kan starte pÄ et hvilket som helst steg ved Ä sjekke ut branchen med lÞsningsforslaget til steget fÞr du Þnsker Ä begynne pÄ, og fortsette derfra. Les mer om hvordan du kan klone dette repoet og sjekke ut lÞsningsforslag.
Under fĂžlger noen anbefalinger for alternative startpunkter, avhengig av hvilke temaer du Ăžnsker Ă„ lĂŠre mer om.
NB! Dersom du begynner pÄ steg 5 eller senere, mÄ du kjÞre
dotnet tool restore
fĂžr du fortsetter Ă„ fĂžlge veiledningen.
Dersom du er interessert i Ä lÊre mer om hvordan du kan bruke .NET CLI til Ä opprette prosjekter, solutions, og sette opp hÄndtering av NuGet-pakker med paket
, kan fĂžlge disse stegene:
Vil du lĂŠre mer om domenemodellering i F# og tilhĂžrende enhetstester, kan fĂžlge disse stegene:
Hvis du vil lĂŠre mer om hvordan du kan dokumentere API-et ditt vha. Open API, og modellere kontraktstyper, kan fĂžlge disse stegene:
Om du er interessert i .NET 6 sin hosting modell, âminimal APIsâ, og hvordan du kan teste API-et ditt med integrasjonstester, kan fĂžlge disse stegene:
Til slutt finnes det noen ekstraoppgaver, hvis du vil ha mer Ä bryne deg pÄ:
Lurer du pĂ„ noe knyttet til kurset? Opprett gjerne en trĂ„d under âDiscussionsâ i dette repoet:
Nyttige tips og triks finner du her
Har du tilbakemeldinger til kurset? Opprett gjerne en trÄd for det her:
All dokumentasjon (inkludert denne veiledningen) og kildekoden i dette repoet er Ă„pent tilgjengelig under MIT-lisensen.
NÄ som du har installert alle verktÞyene du trenger er du klar til Ä begynne pÄ selve kurset!
Steg 1 av 10 - đ GĂ„ til toppen ⏠Neste steg
I dette steget starter vi med en mappe helt uten kode, og bruker .NET CLI til Ä opprette vÄrt fÞrste prosjekt NRK.Dotnetskolen.Api
.
Siden denne veiledningen er skrevet for .NET 6, og det er mulig at du har flere .NET-versjoner installert pÄ maskinen din, mÄ vi instruere .NET CLI til Ä benytte .NET 6 nÄr vi kjÞrer kommandoene i veiledningen. Dette gjÞr vi ved Ä opprette en konfigurasjonsfil global.json
i roten av repoet med fĂžlgende innhold:
{
"sdk": {
"version": "6.0.0",
"rollForward": "latestMinor"
}
}
Her oppgir vi at vi i utgangspunktet Ăžnsker Ă„ bruke version 6.0.0
av .NET SDK. I tillegg sier vi gjennom rollForward: latestMinor
at vi Þnsker at den hÞyeste tilgjengelige versjonen av .NET 6 pÄ maskinen din skal brukes.
Du kan lese mer om
global.json
her: https://docs.microsoft.com/en-us/dotnet/core/tools/global-json
For Ä kunne organisere kode i .NET bruker man prosjekter. Et prosjekt er en samling med kildekodefiler, og eventuelle andre ressursfiler, og alle filene som inngÄr i prosjektet er referert til i en prosjektfil. For F#-prosjekter har slike prosjektfiler filendelsen .fsproj
.
NĂ„r man kompilerer .NET-prosjekter kan man velge mellom to typer output:
Som nevnt i innledningen er .NET CLI et kommandolinjeverktĂžy laget for Ă„ utvikle, bygge, kjĂžre og publisere .NET-applikasjoner. .NET CLI kjĂžres fra kommandolinjen med kommandoen dotnet
, og har mange underkommandoer og valg. For Ă„ se alle kan du kjĂžre kommandoen under, eller lese mer her: https://docs.microsoft.com/en-us/dotnet/core/tools/dotnet
dotnet --help
.NET SDK (6.0.101)
Usage: dotnet [runtime-options] [path-to-application] [arguments]
Execute a .NET application.
runtime-options:
--additionalprobingpath <path> Path containing probing policy and assemblies to probe for.
--additional-deps <path> Path to additional deps.json file.
--depsfile Path to <application>.deps.json file.
--fx-version <version> Version of the installed Shared Framework to use to run the application.
--roll-forward <setting> Roll forward to framework version (LatestPatch, Minor, LatestMinor, Major, LatestMajor, Disable).
--runtimeconfig Path to <application>.runtimeconfig.json file.
path-to-application:
The path to an application .dll file to execute.
Usage: dotnet [sdk-options] [command] [command-options] [arguments]
Execute a .NET SDK command.
sdk-options:
-d|--diagnostics Enable diagnostic output.
-h|--help Show command line help.
--info Display .NET information.
--list-runtimes Display the installed runtimes.
--list-sdks Display the installed SDKs.
--version Display .NET SDK version in use.
SDK commands:
add Add a package or reference to a .NET project.
build Build a .NET project.
build-server Interact with servers started by a build.
clean Clean build outputs of a .NET project.
format Apply style preferences to a project or solution.
help Show command line help.
list List project references of a .NET project.
msbuild Run Microsoft Build Engine (MSBuild) commands.
new Create a new .NET project or file.
nuget Provides additional NuGet commands.
pack Create a NuGet package.
publish Publish a .NET project for deployment.
remove Remove a package or reference from a .NET project.
restore Restore dependencies specified in a .NET project.
run Build and run a .NET project output.
sdk Manage .NET SDK installation.
sln Modify Visual Studio solution files.
store Store the specified assemblies in the runtime package store.
test Run unit tests using the test runner specified in a .NET project.
tool Install or manage tools that extend the .NET experience.
vstest Run Microsoft Test Engine (VSTest) commands.
workload Manage optional workloads.
Additional commands from bundled tools:
dev-certs Create and manage development certificates.
fsi Start F# Interactive / execute F# scripts.
sql-cache SQL Server cache command-line tools.
user-secrets Manage development user secrets.
watch Start a file watcher that runs a command when files change.
Run 'dotnet [command] --help' for more information on a command.
For Ă„ opprette API-prosjektet skal vi bruke new
-kommandoen i .NET CLI. dotnet new
oppretter .NET-prosjekter, og som fĂžrste parameter tar new
-kommandoen inn hva slags mal prosjektet man oppretter skal fÞlge. NÄr man installerer .NET SDK fÄr man med et sett med forhÄndsdefinerte prosjektmaler for vanlige formÄl. For Ä se malene som er installert pÄ din maskin kan du kjÞre dotnet new --list
slik:
dotnet new --list
These templates matched your input:
Template Name Short Name Language Tags
-------------------------------------------- ------------------- ---------- -------------------------------------
ASP.NET Core Empty web [C#],F# Web/Empty
ASP.NET Core gRPC Service grpc [C#] Web/gRPC
ASP.NET Core Web API webapi [C#],F# Web/WebAPI
ASP.NET Core Web App razor,webapp [C#] Web/MVC/Razor Pages
ASP.NET Core Web App (Model-View-Controller) mvc [C#],F# Web/MVC
ASP.NET Core with Angular angular [C#] Web/MVC/SPA
ASP.NET Core with React.js react [C#] Web/MVC/SPA
ASP.NET Core with React.js and Redux reactredux [C#] Web/MVC/SPA
Blazor Server App blazorserver [C#] Web/Blazor
Blazor WebAssembly App blazorwasm [C#] Web/Blazor/WebAssembly/PWA
Class Library classlib [C#],F#,VB Common/Library
Console App console [C#],F#,VB Common/Console
dotnet gitignore file gitignore Config
Dotnet local tool manifest file tool-manifest Config
EditorConfig file editorconfig Config
global.json file globaljson Config
MSTest Test Project mstest [C#],F#,VB Test/MSTest
MVC ViewImports viewimports [C#] Web/ASP.NET
MVC ViewStart viewstart [C#] Web/ASP.NET
NuGet Config nugetconfig Config
NUnit 3 Test Item nunit-test [C#],F#,VB Test/NUnit
NUnit 3 Test Project nunit [C#],F#,VB Test/NUnit
Protocol Buffer File proto Web/gRPC
Razor Class Library razorclasslib [C#] Web/Razor/Library/Razor Class Library
Razor Component razorcomponent [C#] Web/ASP.NET
Razor Page page [C#] Web/ASP.NET
Solution File sln Solution
Web Config webconfig Config
Windows Forms App winforms [C#],VB Common/WinForms
Windows Forms Class Library winformslib [C#],VB Common/WinForms
Windows Forms Control Library winformscontrollib [C#],VB Common/WinForms
Worker Service worker [C#],F# Common/Worker/Web
WPF Application wpf [C#],VB Common/WPF
WPF Class library wpflib [C#],VB Common/WPF
WPF Custom Control Library wpfcustomcontrollib [C#],VB Common/WPF
WPF User Control Library wpfusercontrollib [C#],VB Common/WPF
xUnit Test Project xunit [C#],F#,VB Test/xUnit
I tillegg til Ă„ styre hva slags type prosjekt man vil opprette med new
-kommandoen, har man mulighet til Ä styre ting som hvilket sprÄk man Þnsker prosjektet skal opprettes for, og i hvilken mappe prosjektet opprettes i. For Ä se alle valgene man har i dotnet new
kan du kjĂžre fĂžlgende kommando
dotnet new --help
Usage: new [options]
Options:
-h, --help Displays help for this command.
-l, --list Lists templates containing the specified name. If no name is specified, lists all templates.
-n, --name The name for the output being created. If no name is specified, the name of the current directory is used.
-o, --output Location to place the generated output.
-i, --install Installs a source or a template pack.
-u, --uninstall Uninstalls a source or a template pack.
--interactive Allows the internal dotnet restore command to stop and wait for user input or action (for example to complete authentication).
--nuget-source Specifies a NuGet source to use during install.
--type Filters templates based on available types. Predefined values are "project", "item" or "other".
--dry-run Displays a summary of what would happen if the given command line were run if it would result in a template creation.
--force Forces content to be generated even if it would change existing files.
-lang, --language Filters templates based on language and specifies the language of the template to create.
--update-check Check the currently installed template packs for updates.
--update-apply Check the currently installed template packs for update, and install the updates.
Som du ser av malene som er listet ut over, er det en innebygget mal for web-API som heter webapi
. For Ă„ komme raskt i gang med et prosjekt, eller se hvordan et default .NET API er satt opp, kan man bruke webapi
som mal. Vi kommer imidlertid til Ä opprette API-et vÄrt fra bunnen av ved Ä bruke malen console
for Ă„ lĂŠre mest mulig om de ulike bestanddelene.
KjĂžr fĂžlgende kommando for Ă„ opprette API-prosjektet
dotnet new console --language F# --output src/api --name NRK.Dotnetskolen.Api
The template "Console Application" was created successfully.
Processing post-creation actions...
Running 'dotnet restore' on src\api\NRK.Dotnetskolen.Api.fsproj...
Determining projects to restore...
Restored C:\Dev\nrkno@github.com\dotnetskolen\src\api\NRK.Dotnetskolen.Api.fsproj (in 101 ms).
Restore succeeded.
I kommandoen over brukte vi --language
-argumentet for Ă„ oppgi at vi Ăžnsket et F#-prosjekt. I tillegg brukte vi --output
for Ă„ oppgi hvor vi Ăžnsket at prosjektet skulle ligge relativt til der vi kjĂžrer kommandoen fra, og --name
til Ä styre navnet pÄ prosjektet.
Merk at istedenfor
--language
,--output
og--name
, kunne vi brukt forkortelsene-lang
,-o
og-n
.
Du skal nÄ ha en mappestruktur som ser slik ut
src
âââ api
âââ NRK.Dotnetskolen.Api.fsproj
âââ Program.fs
Som vi ser av diagrammet over opprettet .NET CLI mappene src
og src/api
, med NRK.Dotnetskolen.Api.fsproj
og Program.fs
i src/api
.
Merk at med mindre noe annet er spesifisert, er alle kommandoene i veiledningen skrevet med forutsetning om at du stÄr i samme mappe nÄr du kjÞrer dem. Dersom du har klonet Git-repoet til kurset er det rotmappen til repoet. Dersom du fÞlger kurset uten Ä bruke Git er det mappen du bestemmer deg for Ä kjÞre kommandoene i.
Ă
pne NRK.Dotnetskolen.Api.fsproj
for Ă„ se innholdet til prosjektfilen til prosjektet du nettopp opprettet:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net6.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Compile Include="Program.fs" />
</ItemGroup>
</Project>
Her ser vi at prosjektet:
exe
- prosjektet kompileres til Ă„ bli en kjĂžrbar filProgram.fs
For Ă„ se hva programmet gjĂžr kan vi Ă„pne Program.fs
og se pÄ koden:
// For more information see https://aka.ms/fsharp-console-apps
printfn "Hello from F#"
Malen la inn kun Ă©n linje i Program.fs
som skriver tekststrengen Hello world from F#
til output. Fra andre programmeringssprÄk er du kanskje vant til Ä se en main
-funksjon eller liknende, men det ser vi ikke her. Grunnen til det er at F# bruker et implisitt startpunkt som er pÄ toppen av filen. Deretter utfÞres koden linje for linje slik som spesifisert i filen. Det er ogsÄ mulig Ä bruke eksplisitte startpunkter i F#-programmer. Les mer om det her: https://docs.microsoft.com/en-us/dotnet/fsharp/language-reference/functions/entry-point#implicit-entry-point
Navnet til prosjektet
NRK.Dotnetskolen.Api.fsproj
fÞlger Microsoft sin navnekonvensjon for programmer og biblioteker i .NET. For Ä lese mer om denne, og andre navnekonvensjoner i .NET, kan du se her: https://docs.microsoft.com/en-us/dotnet/standard/design-guidelines/names-of-assemblies-and-dllsMappestrukturen over er ment som et forslag, og de videre stegene i kurset bygger pÄ denne. Hvis du bruker kurset som inspirasjon eller veiledning til Ä opprette ditt eget prosjekt, trenger du ikke fÞlge denne mappestrukturen. Hvordan du strukturerer mappene i ditt system er opp til deg, og er avhengig av aspekter som stÞrrelse pÄ systemet, antall prosjekter, og personlige preferanser.
For Ă„ kjĂžre prosjektet som ble opprettet over kan du kjĂžre fĂžlgende kommando
dotnet run --project src/api/NRK.Dotnetskolen.Api.fsproj
Hello world from F#
Alternativt kan du gÄ til mappen hvor prosjektet ligger, og kjÞre dotnet run
derfra, slik som vist under
cd src/api
dotnet run
Hello world from F#
NÄ som du har fullfÞrt det fÞrste steget i kurset er det en fin anledning til Ä lagre endringene du har gjort sÄ langt i Git.
Gitt at du fulgte veiledningen for Ă„ sette opp koden lokalt fĂžr du begynte Ă„ kode, kan du kjĂžre fĂžlgende kommando for Ă„ se hvilke endringer som er gjort i repoet:
git status
On branch <branchnavn>
Untracked files:
(use "git add <file>..." to include in what will be committed)
global.json
src/
nothing added to commit but untracked files present (use "git add" to track)
I outputen over ser vi at Git har oppdaget at det er opprettet en mappe src
og innhold i den, men Git overvĂ„ker ikke disse per nĂ„ (filene er âuntrackedâ).
For Ä fÄ Git til Ä overvÄke filene vi har opprettet, og deretter se status i Git kan du kjÞre fÞlgende kommandoer:
git add .
git status
Changes to be committed:
(use "git restore --staged <file>..." to unstage)
new file: global.json
new file: src/api/NRK.Dotnetskolen.Api.fsproj
new file: src/api/Program.fs
NÄ overvÄker Git filene.
For Ă„ lagre nĂ„vĂŠrende tilstand av filene i en âcommitâ i Git kan du kjĂžre fĂžlgende kommando:
git commit -m "Opprettet API-prosjekt"
[<branchnavn> 00d11c8] Opprettet API-prosjekt
2 files changed, 25 insertions(+)
create mode 100644 src/api/NRK.Dotnetskolen.Api.fsproj
create mode 100644 src/api/Program.fs
For Ä se alle commits i nÄvÊrende branch i Git, kan du kjÞre fÞlgende kommando:
git log
commit 00d11c82d0179f41883a55ce88e147a73ae60ee2 (HEAD -> <branchnavn>)
Author: Thomas Wolff <thomas.wolff@nrk.no>
Date: Fri Apr 16 13:43:40 2021 +0200
Opprettet API-prosjekt
...
đĄ Tips! Gjenta de tre stegene over med Ă„ se endringer, legge dem til, og lagre dem etter Ă„ ha fullfĂžrt hvert steg for Ă„ ha bedre oversikt over hva du har vĂŠrt gjennom i kurset.
Dersom du Þnsker Ä se den forventede tilstanden til repoet etter Ä ha utfÞrt de ulike stegene i kurset, kan du sjekke ut branchen med korresponderende navn som seksjonen du Þnsker Ä se pÄ. F.eks. hvis du vil se hvordan repoet ser ut etter fÞrste steg, kan du sjekke ut branchen steg-1
slik:
git checkout steg-1
Switched to branch 'steg-1'
Steg 2 av 10 - đ GĂ„ til toppen ⏠Forrige steg ⏠Neste steg
Tester er en viktig del av systemutvikling fordi de hjelper oss med Ă„ verifisere at systemet fungerer slik det skal. NĂ„r man skriver tester for kode opererer man ofte med to typer tester:
Enhetstester verifiserer at smÄ, isolerte deler av koden fungerer slik den skal. Gjerne én og én funksjon. I dette kurset skal vi bruke enhetstester til Ä verifisere valideringsregler i domenet vÄrt.
Integrasjonstester verifiserer at stĂžrre deler av systemet fungerer slik det skal, og kan til og med dekke samspill med andre systemer. I dette kurset skal vi bruke integrasjonstester til Ă„ verifisere at web-API-et oppfĂžrer seg i henhold til kontrakten vi definerer i steg 7.
I dette steget skal vi opprette to testprosjekter
NRK.Dotnetskolen.UnitTests
NRK.Dotnetskolen.IntegrationTests
For Ă„ opprette testprosjektene skal vi igjen bruke dotnet new
-kommandoen, men denne gangen velger vi en annen mal enn da vi opprettet API-prosjektet. NĂ„r man installerer .NET SDK fĂžlger det med flere maler for testprosjekter som korresponderer til ulike rammeverk som finnes for Ă„ detektere og kjĂžre tester:
I dette kurset kommer vi til Ä bruke xUnit. Dette valget er litt vilkÄrlig ettersom alle rammeverkene over vil vÊre tilstrekkelig til formÄlet vÄrt, som er Ä vise hvordan man kan sette opp testprosjekter og komme i gang med Ä skrive tester. Dersom du Þnsker Ä vite mer om de ulike testrammeverkene, kan du lese mer om dem her: https://docs.microsoft.com/en-us/dotnet/core/testing/#testing-tools
KjĂžr fĂžlgende kommando for Ă„ opprette enhetstestprosjektet
dotnet new xunit -lang F# -o test/unit -n NRK.Dotnetskolen.UnitTests
The template "xUnit Test Project" was created successfully.
Processing post-creation actions...
Running 'dotnet restore' on test/unit/NRK.Dotnetskolen.UnitTests.fsproj...
Determining projects to restore...
Restored C:\Dev\nrkno@github.com\dotnetskolen\test\unit\NRK.Dotnetskolen.UnitTests.fsproj (in 1.31 sec).
Restore succeeded.
Du skal nÄ ha fÞlgende mappestruktur
src
âââ api
âââ NRK.Dotnetskolen.Api.fsproj
âââ Program.fs
test
âââ unit
âââ NRK.Dotnetskolen.UnitTests.fsproj
âââ Program.fs
âââ Tests.fs
Ă
pne filen NRK.Dotnetskolen.UnitTests.fsproj
:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<IsPackable>false</IsPackable>
<GenerateProgramFile>false</GenerateProgramFile>
</PropertyGroup>
<ItemGroup>
<Compile Include="Tests.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.11.0" />
<PackageReference Include="xunit" Version="2.4.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.3">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="3.1.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
</Project>
I prosjektfilen kan vi se at enhetstestprosjektet:
Tests.fs
Program.fs
Microsoft.NET.Test.Sdk
- Pakke for Ă„ bygge .NET testprosjekterxunit
- Bibliotek for Ă„ skrive enhetstesterxunit.runner.visualstudio
- Pakke for Ă„ kjĂžre Xunit-tester i âTest explorerâ i Visual Studio https://docs.microsoft.com/en-us/visualstudio/test/run-unit-tests-with-test-explorer?view=vs-2019coverlet.collector
- bibliotek for Ä fÄ code coverage statistikk for prosjekter https://docs.microsoft.com/en-us/dotnet/core/testing/unit-testing-code-coverage?tabs=windowsVi ser nÊrmere pÄ hva NuGet-pakker er i steg 4.
Ă
pne filen Tests.fs
:
module Tests
open System
open Xunit
[<Fact>]
let ``My test`` () =
Assert.True(true)
Ăverst i filen blir det definert en F#-modul Tests
. I tillegg blir modulene System
og Xunit
Ă„pnet, som kommer fra hhv. basebiblioteket til Microsoft, og biblioteket Xunit. Videre blir det definert en test ``My test``
. MÄten vi ser at det er en test pÄ er ved Ä se at den er annotert med [<Fact>]
. Xunit opererer med to annotasjoner for tester:
[<Fact>]
[<Theory>]
Forskjellen pÄ disse blir nÊrmere forklart i steget om enhetstester.
Merk at
``<variabelnavn med mellomrom>``
er brukt for Ä kunne ha et variabelnavn som inneholder mellomrom. PÄ denne mÄten kan man ha et funksjonsnavn som beskriver testen og samtidig er lesbar for mennesker.
For Ă„ kjĂžre testen i enhetstestprosjektet kan du bruke fĂžlgende kommando
dotnet test test/unit/NRK.Dotnetskolen.UnitTests.fsproj
Determining projects to restore...
All projects are up-to-date for restore.
Unit -> C:\Dev\nrkno@github.com\dotnetskolen\test\unit\bin\Debug\net5.0\NRK.Dotnetskolen.UnitTests.dll
Test run for C:\Dev\nrkno@github.com\dotnetskolen\test\unit\bin\Debug\net5.0\NRK.Dotnetskolen.UnitTests.dll (.NETCoreApp,Version=v5.0)
Microsoft (R) Test Execution Command Line Tool Version 16.9.1
Copyright (c) Microsoft Corporation. All rights reserved.
Starting test execution, please wait...
A total of 1 test files matched the specified pattern.
Passed! - Failed: 0, Passed: 1, Skipped: 0, Total: 1, Duration: 2 ms - Unit.dll (net5.0)
PĂ„ lik linje med dotnet run
, kan du alternativt gÄ inn i mappen til enhetstestprosjektet, og kjÞre dotnet test
derfra:
cd test/unit
dotnet test
Determining projects to restore...
All projects are up-to-date for restore.
Unit -> C:\Dev\nrkno@github.com\dotnetskolen\test\unit\bin\Debug\net5.0\NRK.Dotnetskolen.UnitTests.dll
Test run for C:\Dev\nrkno@github.com\dotnetskolen\test\unit\bin\Debug\net5.0\NRK.Dotnetskolen.UnitTests.dll (.NETCoreApp,Version=v5.0)
Microsoft (R) Test Execution Command Line Tool Version 16.9.1
Copyright (c) Microsoft Corporation. All rights reserved.
Starting test execution, please wait...
A total of 1 test files matched the specified pattern.
Passed! - Failed: 0, Passed: 1, Skipped: 0, Total: 1, Duration: 2 ms - Unit.dll (net5.0)
For Ă„ opprette integrasjonstestprosjektet, kan du kjĂžre samme kommando som da du opprettet enhetstestprosjektet, men bytt ut Unit
med Integration
i navnet pÄ testprosjektet, som vist under:
dotnet new xunit -lang F# -o test/integration -n NRK.Dotnetskolen.IntegrationTests
The template "xUnit Test Project" was created successfully.
Processing post-creation actions...
Running 'dotnet restore' on test\integration\NRK.Dotnetskolen.IntegrationTests.fsproj...
Determining projects to restore...
Restored C:\Dev\nrkno@github.com\dotnetskolen\test\integration\NRK.Dotnetskolen.IntegrationTests.fsproj (in 580 ms).
Restore succeeded.
Du skal nÄ ha fÞlgende mappestruktur
src
âââ api
âââ NRK.Dotnetskolen.Api.fsproj
âââ Program.fs
test
âââ unit
âââ NRK.Dotnetskolen.UnitTests.fsproj
âââ Program.fs
âââ Tests.fs
âââ integration
âââ NRK.Dotnetskolen.IntegrationTests.fsproj
âââ Program.fs
âââ Tests.fs
ForelÞpig er prosjekt- og test-filene til integrasjonstestprosjektet helt like de fra enhetstestprosjektet (bortsett fra prosjektnavnet). Forskjellen pÄ enhets- og integrasjonstestene blir tydeligere nÄr vi skal skrive testene i hhv. steg 6 og steg 10.
For Ă„ kjĂžre testene i integrasjonstestprosjektet kan du bruke fĂžlgende kommando
dotnet test test/integration/NRK.Dotnetskolen.IntegrationTests.fsproj
Determining projects to restore...
All projects are up-to-date for restore.
Integration -> C:\Dev\nrkno@github.com\dotnetskolen\test\integration\bin\Debug\net5.0\NRK.Dotnetskolen.IntegrationTests.dll
Test run for C:\Dev\nrkno@github.com\dotnetskolen\test\integration\bin\Debug\net5.0\NRK.Dotnetskolen.IntegrationTests.dll (.NETCoreApp,Version=v5.0)
Microsoft (R) Test Execution Command Line Tool Version 16.9.1
Copyright (c) Microsoft Corporation. All rights reserved.
Starting test execution, please wait...
A total of 1 test files matched the specified pattern.
Passed! - Failed: 0, Passed: 1, Skipped: 0, Total: 1, Duration: 2 ms - Integration.dll (net5.0)
Steg 3 av 10 - đ GĂ„ til toppen ⏠Forrige steg ⏠Neste steg
Slik oppsettet er nÄ, har vi tre prosjekter som er uavhengige av hverandre. Annet enn at de ligger i samme mappe, er det ingenting som kobler dem sammen. For Ä kunne gjÞre operasjoner som Ä legge til felles pakker, og kjÞre alle testene for systemet vÄrt, kan vi knytte prosjektene sammen i en og samme lÞsning (solution). à ha alle prosjektene i en og samme lÞsning gir ogsÄ fordelen av at man kan Äpne alle prosjektene samlet i en IDE.
For Ă„ opprette en solution med dotnet
kan du kjĂžre fĂžlgende kommando:
dotnet new sln -n Dotnetskolen
The template "Solution File" was created successfully.
Du skal nÄ ha fÄtt filen Dotnetskolen.sln
slik som vist under
src
âââ api
âââ NRK.Dotnetskolen.Api.fsproj
âââ Program.fs
test
âââ unit
âââ NRK.Dotnetskolen.UnitTests.fsproj
âââ Program.fs
âââ Tests.fs
âââ integration
âââ NRK.Dotnetskolen.IntegrationTests.fsproj
âââ Program.fs
âââ Tests.fs
âââ Dotnetskolen.sln
Hvis vi ser pÄ innholdet i Dotnetskolen.sln
ser vi at det ikke er noen referanser til prosjektene vÄre enda
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 16
VisualStudioVersion = 16.0.30114.105
MinimumVisualStudioVersion = 10.0.40219.1
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
EndGlobal
For Ă„ legge til referanser til prosjektene du har opprettet kan du kjĂžre fĂžlgende kommandoer
dotnet sln add src/api/NRK.Dotnetskolen.Api.fsproj
Project `src\api\NRK.Dotnetskolen.Api.fsproj` added to the solution.
dotnet sln add test/unit/NRK.Dotnetskolen.UnitTests.fsproj
Project `test\unit\NRK.Dotnetskolen.UnitTests.fsproj` added to the solution.
dotnet sln add test/integration/NRK.Dotnetskolen.IntegrationTests.fsproj
Project `test\integration\NRK.Dotnetskolen.IntegrationTests.fsproj` added to the solution.
NĂ„ ser vi at Dotnetskolen.sln
inneholder referanser til prosjektene vÄre
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 16
VisualStudioVersion = 16.0.30114.105
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{E9AA01D7-D310-46F7-B383-06FE72E1AD22}"
EndProject
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "NRK.Dotnetskolen.Api", "src\api\NRK.Dotnetskolen.Api.fsproj", "{08E3BB6B-BCDF-46C6-BB13-85C3402D0BF7}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{66EF0E86-AB2C-4969-A41E-84D88D553785}"
EndProject
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "NRK.Dotnetskolen.UnitTests", "test\unit\NRK.Dotnetskolen.UnitTests.fsproj", "{CB49FB6A-9415-4F77-A438-479A056C96D6}"
EndProject
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "NRK.Dotnetskolen.IntegrationTests", "test\integration\NRK.Dotnetskolen.IntegrationTests.fsproj", "{4EFF064D-031F-4D0B-A6A9-560DDA1347C1}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{08E3BB6B-BCDF-46C6-BB13-85C3402D0BF7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{08E3BB6B-BCDF-46C6-BB13-85C3402D0BF7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{08E3BB6B-BCDF-46C6-BB13-85C3402D0BF7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{08E3BB6B-BCDF-46C6-BB13-85C3402D0BF7}.Release|Any CPU.Build.0 = Release|Any CPU
{CB49FB6A-9415-4F77-A438-479A056C96D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CB49FB6A-9415-4F77-A438-479A056C96D6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CB49FB6A-9415-4F77-A438-479A056C96D6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CB49FB6A-9415-4F77-A438-479A056C96D6}.Release|Any CPU.Build.0 = Release|Any CPU
{4EFF064D-031F-4D0B-A6A9-560DDA1347C1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4EFF064D-031F-4D0B-A6A9-560DDA1347C1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4EFF064D-031F-4D0B-A6A9-560DDA1347C1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4EFF064D-031F-4D0B-A6A9-560DDA1347C1}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{08E3BB6B-BCDF-46C6-BB13-85C3402D0BF7} = {E9AA01D7-D310-46F7-B383-06FE72E1AD22}
{CB49FB6A-9415-4F77-A438-479A056C96D6} = {66EF0E86-AB2C-4969-A41E-84D88D553785}
{4EFF064D-031F-4D0B-A6A9-560DDA1347C1} = {66EF0E86-AB2C-4969-A41E-84D88D553785}
EndGlobalSection
EndGlobal
Bildet under viser hvordan âSolution explorerâ i Visual Studio viser lĂžsningen.
Steg 4 av 10 - đ GĂ„ til toppen ⏠Forrige steg ⏠Neste steg
Siden vi har behov for Ä installere NuGet-pakker senere i kurset, skal vi sette opp pakkehÄndteringsverktÞyet Paket for lÞsningen.
Basebiblioteket i .NET inneholder mye grunnleggende funksjonalitet, men det inneholder ikke alt. For Ă„ slippe Ă„ skrive kode for mye brukt funksjonalitet pĂ„ nytt hver gang man trenger den, er det en fordel om utviklere over hele verden kan dele kode med hverandre. De facto mĂ„te Ă„ dele kode i .NET pĂ„ er via âNuGetâ. NuGet er bĂ„de et offentlig repo for kode utviklet av tredjeparter (tilgjengelig pĂ„ https://www.nuget.org/), og et verktĂžy for Ă„ laste opp og ned âNuGet-pakkerâ fra dette repoet.
NuGet som verktÞy for Ä hÄndtere pakker i et prosjekt har imidlertid noen utfordringer:
SomeNuGetPackage
, og SomeNuGetPackage
har en avhengighet til SomeOtherNuGetPackage
, er SomeOtherNuGetPackage
en transitiv avhengighet i prosjektet. NuGet skiller ikke transitive avhengigheter fra direkte avhengigheter i packages.config
. Dermed har man ikke kontroll pÄ hvilke avhengigheter i packages.config
som er direkte, og hvilke som er transitive.VerktĂžyet âPaketâ forsĂžker Ă„ lĂžse utfordringene nevnt over, og er mye brukt i NRK TV og NRK Radio. Derfor blir Paket brukt i dette kurset.
Merk at selv om man bruker Paket som verktÞy for Ä hÄndtere tredjepartsavhengigheter i en .NET-lÞsning, benytter man fortsatt NuGet sitt offentlige repo for Ä laste opp og ned avhengighetene.
Paket finnes som en utvidelse (ogsĂ„ kalt âtoolâ) til .NET CLI. Utvidelser i .NET CLI kan enten installeres som globale (tilgjengelig for alle .NET-lĂžsninger pĂ„ maskinen), eller lokale (kun for prosjektet utvidelsen blir installert i). I dette kurset installerer vi Paket lokalt for vĂ„r lĂžsning. Fordelen med dette er at versjonen av Paket vi installerer kun gjelder for denne lĂžsningen. Det gjĂžr at andre lĂžsninger pĂ„ samme maskin kan ha andre versjoner av Paket. Dersom lĂžsningen ligger pĂ„ Git, vil i tillegg andre som kloner repoet kunne kjĂžre dotnet tool restore
, og fÄ installert alle verktÞyene de trenger.
Lokale utvidelser av .NET CLI defineres i en egen fil dotnet-tools.json
som ligger i en mappe .config
. Ettersom denne filen ikke finnes enda, oppretter vi den ved Ă„ kjĂžre fĂžlgende kommando
dotnet new tool-manifest
The template "Dotnet local tool manifest file" was created successfully.
Du skal nÄ ha fÄtt dotnet-tools.json
-filen i .config
-mappen slik som vist under.
âââ .config
âââ dotnet-tools.json
src
âââ ...
test
âââ ...
âââ Dotnetskolen.sln
dotnet-tools.json
inneholder imidlertid ingen utvidelser til .NET CLI enda
{
"version": 1,
"isRoot": true,
"tools": {}
}
For Ă„ legge til Paket i listen over utvidelser lĂžsningen skal ha kan du kjĂžre fĂžlgende kommando
dotnet tool install paket
You can invoke the tool from this directory using the following commands: 'dotnet tool run paket' or 'dotnet paket'.
Tool 'paket' (version '5.257.0') was successfully installed. Entry is added to the manifest file C:\Dev\nrkno@github.com\dotnetskolen\.config\dotnet-tools.json.
NĂ„ ser vi at Paket er lagt til i listen over tools i dotnet-tools.json
{
"version": 1,
"isRoot": true,
"tools": {
"paket": {
"version": "6.2.1",
"commands": [
"paket"
]
}
}
}
For Ă„ installere Paket kan du kjĂžre fĂžlgende kommando
dotnet tool restore
Tool 'paket' (version '6.2.1') was restored. Available commands: paket
Restore was successful.
Paket bruker fÞlgende filer for Ä holde styr pÄ pakkene i en lÞsning:
paket.dependencies
- en flat liste over alle avhengigheter som inngÄr pÄ tvers av alle prosjektene i lÞsningen.<sti til prosjekt>/paket.references
- en flat liste over alle avhengigheter det gitte prosjektet har.paket.lock
- inneholder en oversikt over alle avhengigheter, bÄde direkte og transitive, og hvilken versjon av dem som er brukt i lÞsningen.Se forÞvrig https://fsprojects.github.io/Paket/faq.html#What-files-should-I-commit for hvilke filer du skal inkludere i Git.
Da vi opprettet testprosjektene i steg 2, ble det lagt til referanser til NuGet-pakker som testprosjektene er avhengige av. Malene i .NET SDK benytter NuGet som pakkehÄndteringssystem, og dermed ble disse prosjektreferansene lagt til i .fsproj
-filene til testprosjektene. Dette ser vi under i test/unit/NRK.Dotnetskolen.UnitTests.fsproj
. Det samme gjelder prosjektfilen til integrasjonstestprosjektet.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<IsPackable>false</IsPackable>
<GenerateProgramFile>false</GenerateProgramFile>
</PropertyGroup>
<ItemGroup>
<Compile Include="Tests.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.11.0" />
<PackageReference Include="xunit" Version="2.4.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.3">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="3.1.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
</Project>
Siden vi Þnsker Ä benytte Paket til Ä hÄndtere pakkene i lÞsningen vÄr, mÄ vi migrere disse pakkene fra NuGet til Paket. Dette kan vi gjÞre ved Ä bruke den innebygde kommandoen convert-from-nuget
i Paket, slik:
dotnet paket convert-from-nuget
NÄ skal alle pakkene som er brukt pÄ tvers av alle prosjektene i lÞsningen vÊre lagt til i en ny fil paket.dependencies
:
source https://api.nuget.org/v3/index.json
nuget FSharp.Core
nuget coverlet.collector 3.1.0
nuget Microsoft.NET.Test.Sdk 16.11.0
nuget xunit 2.4.1
nuget xunit.runner.visualstudio 2.4.3
I tillegg skal pakkereferansene i et prosjekt vĂŠre flyttet til en ny fil paket.references
i roten av prosjektmappen. Under ser vi hvordan paket.references
-filen ser ut for enhetstestprosjektet. Det samme gjelder integrasjonstestprosjektet.
Microsoft.NET.Test.Sdk
xunit
xunit.runner.visualstudio
coverlet.collector
FSharp.Core
Du kan lese mer om hvordan convert-from-nuget
-kommandoen fungerer her: https://fsprojects.github.io/Paket/convert-from-nuget-tutorial.html
NĂ„ er du klar til Ă„ legge til avhengigheter i prosjektet ditt!
Merk at vi kunne ha latt vĂŠre Ă„ opprette testprosjektene med malen
xunit
, og heller satt opp testprosjektene fra bunnen av ved Ă„ brukeconsole
-malen. Da hadde vi unngÄtt Ä mÄtte migrere NuGet-pakkene til Paket. Kurset er imidlertid lagt opp pÄ denne mÄten for Ä illustrere bruken av ulike maler i .NET SDK.
Takk til @laat som tipset om
convert-from-nuget
-kommandoen i Paket!
Steg 5 av 10 - đ GĂ„ til toppen ⏠Forrige steg ⏠Neste steg
Vi skal lage et API for Ă„ hente ut en forenklet elektronisk programguide (EPG) for ulike kanaler i NRK TV. Tanken er at dette API-et kunne levert datagrunnlaget til en programguide - f.eks. den som vises her: https://info.nrk.no/presse/tvguide/
Modellen vi bruker for EPG i dette kurset er forenklet sammenliknet med den som benyttes i PS API, og er kun brukt som eksempel.
En EPG kan sees pÄ som en liste med sendinger, og for vÄrt eksempel i dette kurset inneholder en sending fÞlgende felter:
, . : - !
NÄ som vi har spesifisert domenet vÄrt, kan vi modellere det i F#. Start med Ä opprett en ny fil Domain.fs
under src/api
:
âââ .config
âââ ...
src
âââ api
âââ Domain.fs
âââ NRK.Dotnetskolen.Api.fsproj
âââ Program.fs
test
âââ ...
âââ Dotnetskolen.sln
âââ paket.dependencies
Lim inn innholdet under i Domain.fs
:
namespace NRK.Dotnetskolen
module Domain =
open System
type Sending = {
Tittel: string
Kanal: string
Starttidspunkt: DateTimeOffset
Sluttidspunkt: DateTimeOffset
}
type Epg = Sending list
Over definerer vi en F#-modul Domain
i namespacet NRK.Dotnetskolen
. I Domain
-modulen definerer vi domenemodellen vÄr, som bestÄr av to typer:
Sending
- modellerer et enkelt innslag i EPG-en, og inneholder feltene som ble definert i forrige seksjon
Epg
- en liste med sendingerVi Äpnet ogsÄ modulen System
for Ä fÄ tilgang til typen DateTimeOffset
.
Legg merke til innrykket pÄ linjene etter
module Domain =
. Dette inntrykket er pÄkrevd av F# for at koden skal kompilere riktig.
Inkluder Domain.fs
i api-prosjektet ved Ă„ legge til <Compile Include="Domain.fs" />
i src/api/NRK.Dotnetskolen.Api.fsproj
slik som vist under:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net5.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Compile Include="Domain.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
</Project>
Merk at rekkefÞlgen filer blir inkludert i F#-prosjektfiler pÄ har betydning. Dersom
modul A
er definert iModulA.fs
ogmodul B
er definert iModulB.fs
, ogmodul A
skal kunne Ă„pnemodul B
mÄModulB.fs
ligge fĂžrModulA.fs
i prosjektfilen.Moduler i F# blir kompilert til det samme i CIL som statiske klasser i C#.
NĂ„ som vi har definert domenemodellen vĂ„r, skal vi se hvordan vi kan ta den i bruk. Ă
pne Program.fs
i web-API-prosjektet og erstatt innholdet med fĂžlgende kode:
open System
open NRK.Dotnetskolen.Domain
let epg = [
{
Tittel = "Dagsrevyen"
Kanal = "NRK1"
Starttidspunkt = DateTimeOffset.Parse("2021-04-16T19:00:00+02:00")
Sluttidspunkt = DateTimeOffset.Parse("2021-04-16T19:30:00+02:00")
}
]
printfn "%A" epg
Her oppretter vi en variabel epg
som er en liste med sendinger, slik vi definerte i Domain.fs
.
KjĂžr API-prosjektet igjen med fĂžlgende kommando, og se at epg
-verdien blir skrevet til terminalen.
dotnet run --project src/api/NRK.Dotnetskolen.Api.fsproj
[{ Tittel = "Dagsrevyen"
Kanal = "NRK1"
Starttidspunkt = 16.04.2021 19:00:00 +02:00
Sluttidspunkt = 16.04.2021 19:30:00 +02:00 }]
Merk at noen har rapportert om problemer med feilmeldinger i Rider etter Ă„ ha lagt til linjen
open NRK.Dotnetskolen.Domain
. Dersom du opplever det samme kan du hĂžyreklikke pĂ„ âSolutionâ-noden i Rider, og klikke pĂ„ âUnloadâ etterfulgt av âReloadâ. Dette skal forhĂ„pentligvis rette opp i problemet.
Steg 6 av 10 - đ GĂ„ til toppen ⏠Forrige steg ⏠Neste steg
Domenemodellen som ble innfÞrt i forrige steg inneholder bÄde strukturen til EPG-en, og valideringsreglene knyttet til dem. SÄ langt har vi kun modellert strukturen til domenemodellen i F# (at EPG bestÄr av en liste med sendinger, og hvilke felter hver sending inneholder). I dette steget skal vi implementere valideringsreglene i F#, og verifisere at vi har implementert dem riktig ved hjelp av enhetstester.
Vi Þnsker Ä verifisere fÞlgende regler fra domenet vÄrt:
, . : - !
NRK1
eller NRK2
.La oss begynne med Ă„ verifisere at vi implementerer valideringsreglene for tittel riktig.
Ettersom tittel har lengdebegrensninger er det viktig Ă„ teste grensetilfellene til lengden. I tillegg er det viktig Ă„ teste at kun gyldige tegn er lov. Erstatt den eksisterende testen i Tests.fs
i enhetstestprosjektet med testene under.
module Tests
open Xunit
[<Theory>]
[<InlineData("abc12")>]
[<InlineData(".,-:!")>]
[<InlineData("ABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJABCDEFGHIJ")>]
let ``isTittelValid valid tittel returns true`` (tittel: string) =
let isTittelValid = isTittelValid tittel
Assert.True isTittelValid
[<Theory>]
[<InlineData("abcd")>]
[<InlineData("@$%&/")>]
[<InlineData("abcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghija")>]
let ``isTittelValid invalid tittel returns false`` (tittel: string) =
let isTittelValid = isTittelValid tittel
Assert.False isTittelValid
Her har vi definert to enhetstester som begge tester funksjonen isTittelValid
. Den fĂžrste testen verifiserer at isTittelValid
returnerer true
nÄr tittelen er gyldig, mens den andre verifiserer det motsatte tilfellet. I xUnit annoterer man testfunksjoner med enten [<Fact>]
eller [<Theory>]
. Testfunksjoner annotert med [<Fact>]
vil kjĂžre Ă©n gang uten noen inputparametere, mens i testfunksjoner annotert med [<Theory>]
kan man ta inn parametere, og annotere testfunksjonen med [<InlineData>]
for Ă„ sende inn gitte inputparametere. Da vil testfunksjonen bli kjĂžrt Ă©n gang per annotering med [<InlineData>]
.
Hvis du forsÞker Ä kjÞre testene, vil du se at testprosjektet ikke kompilerer fordi vi verken har referanse til API-prosjektet (hvor domenet vÄrt er definert) eller har definert funksjonen isTittelValid
enda.
dotnet test test/unit/NRK.Dotnetskolen.UnitTests.fsproj
Determining projects to restore...
All projects are up-to-date for restore.
C:\Dev\nrkno@github.com\dotnetskolen\test\unit\Tests.fs(13,24): error FS0039: The value or constructor 'isTittelValid' is not defined. [C:\Dev\nrkno@github.com\dotnetskolen\test\unit\NRK.Dotnetskolen.UnitTests.fsproj]
C:\Dev\nrkno@github.com\dotnetskolen\test\unit\Tests.fs(26,24): error FS0039: The value or constructor 'isTittelValid' is not defined. [C:\Dev\nrkno@github.com\dotnetskolen\test\unit\NRK.Dotnetskolen.UnitTests.fsproj]
For Ă„ validere en tittel bruker vi et regulĂŠrt uttrykk som reflekterer reglene i domenet vĂ„rt. Ă
pne filen Domain.fs
i API-prosjektet, og legg til fĂžlgende open
-statement under open system
:
open System.Text.RegularExpressions
Lim deretter inn fÞlgende kode pÄ slutten av filen:
let isTittelValid (tittel: string) : bool =
let tittelRegex = Regex(@"^[\p{L}0-9\.,-:!]{5,100}$")
tittelRegex.IsMatch(tittel)
Det regulĂŠre uttrykket lister opp hvilke tegn som er gyldige i en gruppe (tegnene mellom mellom [
og ]
):
\p{L}
- syntaks for Ă„ spesifisere enhver bokstav i Unicode0-9
- tall\.,-:!
- spesialtegnene vi tillaterI tillegg spesifiserer {5,100}
at vi tillater 5-100 av tegnene i gruppen over.
For at enhetstestprosjektet skal fÄ tilgang til funksjonen vi nettopp definerte i Domain.fs
mÄ vi legge til en prosjektreferanse til API-prosjektet i enhetstestprosjektet. Det kan vi gjÞre vha. .NET CLI med fÞlgende kommando:
dotnet add ./test/unit/NRK.Dotnetskolen.UnitTests.fsproj reference ./src/api/NRK.Dotnetskolen.Api.fsproj
Reference `..\..\src\api\NRK.Dotnetskolen.Api.fsproj` added to the project.
Du kan se effekten av kommandoen over ved Ă„ Ă„pne test/unit/NRK.Dotnetskolen.UnitTests.fsproj
:
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<IsPackable>false</IsPackable>
<GenerateProgramFile>false</GenerateProgramFile>
</PropertyGroup>
<ItemGroup>
<Compile Include="Tests.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\api\NRK.Dotnetskolen.Api.fsproj" />
</ItemGroup>
<Import Project="..\..\.paket\Paket.Restore.targets" />
</Project>
I tillegg til Ä legge til en referanse til API-prosjektet i enhetstestprosjektet, mÄ vi Äpne NRK.Dotnetskolen.Domain
-modulen i Tests.fs
. Det kan du gjĂžre ved Ă„ legge til open NRK.Dotnetskolen.Domain
under open Xunit
i Tests.fs
:
module Tests
open Xunit
open NRK.Dotnetskolen.Domain
NĂ„ skal testene kjĂžre vellykket:
dotnet test test/unit/NRK.Dotnetskolen.UnitTests.fsproj
Determining projects to restore...
All projects are up-to-date for restore.
NRK.Dotnetskolen.Api -> C:\Dev\nrkno@github.com\dotnetskolen\src\api\bin\Debug\net5.0\NRK.Dotnetskolen.Api.dll
NRK.Dotnetskolen.UnitTests -> C:\Dev\nrkno@github.com\dotnetskolen\test\unit\bin\Debug\net5.0\NRK.Dotnetskolen.UnitTests.dll
Test run for C:\Dev\nrkno@github.com\dotnetskolen\test\unit\bin\Debug\net5.0\NRK.Dotnetskolen.UnitTests.dll (.NETCoreApp,Version=v5.0)
Microsoft (R) Test Execution Command Line Tool Version 16.9.1
Copyright (c) Microsoft Corporation. All rights reserved.
Starting test execution, please wait...
A total of 1 test files matched the specified pattern.
Passed! - Failed: 0, Passed: 6, Skipped: 0, Total: 6, Duration: 6 ms - NRK.Dotnetskolen.UnitTests.dll (net5.0)
Legg merke til at testrapporten viser at seks tester ble kjĂžrt. ForelĂžpig har vi kun definert to tester. Dette illustrerer at
xUnit
kjĂžrer tester en gang per annotasjon med[<InlineData>]
.
Reglene for kanal er ganske enkle ettersom det kun er to gyldige kanaler, og disse kun kan skrives med store bokstaver.
For Ä teste valideringsreglen for kanal trenger vi én positiv test per gyldige kanal, en negativ test for en kanal med smÄ bokstaver, og en negativ test for en ugyldig kanal. Utvid Tests.fs
i med fĂžlgende tester for kanal:
[<Theory>]
[<InlineData("NRK1")>]
[<InlineData("NRK2")>]
let ``isKanalValid valid kanal returns true`` (kanal: string) =
let isKanalValid = isKanalValid kanal
Assert.True isKanalValid
[<Theory>]
[<InlineData("nrk1")>]
[<InlineData("NRK3")>]
let ``isKanalValid invalid kanal returns false`` (kanal: string) =
let isKanalValid = isKanalValid kanal
Assert.False isKanalValid
FĂžr vi kjĂžrer testene igjen, definerer vi skallet for isKanalValid
i Domain.fs
:
let isKanalValid (kanal: string) : bool =
// Implementasjon her
âïž ImplementĂ©r isKanalValid
slik at enhetstestene passerer.
dotnet test ./test/unit/NRK.Dotnetskolen.UnitTests.fsproj
Determining projects to restore...
All projects are up-to-date for restore.
NRK.Dotnetskolen.Api -> C:\Dev\nrkno@github.com\dotnetskolen\src\api\bin\Debug\net5.0\NRK.Dotnetskolen.Api.dll
NRK.Dotnetskolen.UnitTests -> C:\Dev\nrkno@github.com\dotnetskolen\test\unit\bin\Debug\net5.0\NRK.Dotnetskolen.UnitTests.dll
Test run for C:\Dev\nrkno@github.com\dotnetskolen\test\unit\bin\Debug\net5.0\NRK.Dotnetskolen.UnitTests.dll (.NETCoreApp,Version=v5.0)
Microsoft (R) Test Execution Command Line Tool Version 16.9.1
Copyright (c) Microsoft Corporation. All rights reserved.
Starting test execution, please wait...
A total of 1 test files matched the specified pattern.
Passed! - Failed: 0, Passed: 10, Skipped: 0, Total: 10, Duration: 8 ms - NRK.Dotnetskolen.UnitTests.dll (net5.0)
Det siste vi skal validere i domenet vÄrt er at sluttidspunkt er etter starttidspunkt.
Under fĂžlger Ă©n enhetstest for validering av sendetidspunkter i Tests.fs
:
[<Fact>]
let ``areStartAndSluttidspunktValid start before end returns true`` () =
let starttidspunkt = DateTimeOffset.Now
let sluttidspunkt = starttidspunkt.AddMinutes 30.
let areStartAndSluttidspunktValid = areStartAndSluttidspunktValid starttidspunkt sluttidspunkt
Assert.True areStartAndSluttidspunktValid
Merk at du ogsÄ mÄ legge til fÞlgende open
-statement i Tests.fs
for at DateTimeOffset.Now
fra kodesnutten over skal fungere:
open System
âïž Legg til flere enhetstester du mener er nĂždvendig for Ă„ verifisere at validering av start- og sluttidspunkt er korrekt.
Merk at her bruker vi
[<Fact>]
-attributtet istedenfor[<Theory>]
.[<InlineData>]
-attributtet som man bruker med[<Theory>]
-attributtet krever verdier som er konstante ved kompilering. Ettersom vi benytterDateTimeOffset
-objekter (som ikke er konstante ved kompilering) som input tilareStartAndSluttidspunktValid
, bruker vi derfor[<Fact>]
-attributtet.
Funksjonen for Ä validere sendetidspunktene mÄ undersÞke om sluttidspunktet er stÞrre enn starttidspunktet. Lim inn skallet til areStartAndSluttidspunktValid
i Domain.fs
:
let areStartAndSluttidspunktValid (starttidspunkt: DateTimeOffset) (sluttidspunkt: DateTimeOffset) =
// Implementasjon her
âïž ImplementĂ©r areStartAndSluttidspunktValid
og fÄ enhetstestene til Ä passere.
dotnet test ./test/unit/NRK.Dotnetskolen.UnitTests.fsproj
Determining projects to restore...
All projects are up-to-date for restore.
NRK.Dotnetskolen.Api -> C:\Dev\nrkno@github.com\dotnetskolen\src\api\bin\Debug\net5.0\NRK.Dotnetskolen.Api.dll
NRK.Dotnetskolen.UnitTests -> C:\Dev\nrkno@github.com\dotnetskolen\test\unit\bin\Debug\net5.0\NRK.Dotnetskolen.UnitTests.dll
Test run for C:\Dev\nrkno@github.com\dotnetskolen\test\unit\bin\Debug\net5.0\NRK.Dotnetskolen.UnitTests.dll (.NETCoreApp,Version=v5.0)
Microsoft (R) Test Execution Command Line Tool Version 16.9.1
Copyright (c) Microsoft Corporation. All rights reserved.
Starting test execution, please wait...
A total of 1 test files matched the specified pattern.
Passed! - Failed: 0, Passed: 13, Skipped: 0, Total: 13, Duration: 10 ms - NRK.Dotnetskolen.UnitTests.dll (net5.0)
NĂ„ som vi har funksjoner for Ă„ validere de ulike feltene i en sending, kan vi lage en funksjon som validerer en hel sending.
Siden vi har skrevet enhetstester for valideringsfunksjonene til de ulike delene av en sending, kan enhetstestene for validering av hele sendingen vĂŠre ganske enkle.
âïž Skriv Ă©n positiv test for en gyldig sending, og Ă©n negativ test for en ugyldig sending i Tests.fs
som antar at det finnes en funksjon isSendingValid
i Domain.fs
Legg til fĂžlgende skall for isSendingValid
i Domain.fs
:
let isSendingValid (sending: Sending) : bool =
// Implementasjon her
âïž ImplementĂ©r isSendingValid
, og fÄ enhetstestene til Ä passere:
dotnet test ./test/unit/NRK.Dotnetskolen.UnitTests.fsproj
Determining projects to restore...
All projects are up-to-date for restore.
NRK.Dotnetskolen.Api -> C:\Dev\nrkno@github.com\dotnetskolen\src\api\bin\Debug\net5.0\NRK.Dotnetskolen.Api.dll
NRK.Dotnetskolen.UnitTests -> C:\Dev\nrkno@github.com\dotnetskolen\test\unit\bin\Debug\net5.0\NRK.Dotnetskolen.UnitTests.dll
Test run for C:\Dev\nrkno@github.com\dotnetskolen\test\unit\bin\Debug\net5.0\NRK.Dotnetskolen.UnitTests.dll (.NETCoreApp,Version=v5.0)
Microsoft (R) Test Execution Command Line Tool Version 16.9.1
Copyright (c) Microsoft Corporation. All rights reserved.
Starting test execution, please wait...
A total of 1 test files matched the specified pattern.
Passed! - Failed: 0, Passed: 15, Skipped: 0, Total: 15, Duration: 12 ms - NRK.Dotnetskolen.UnitTests.dll (net5.0)
Merk at domenemodellen, slik den er implementert i steg 5 og steg 6, har en svakhet i at man kan opprette en
Sending
-verdi som er ugyldig. Vi har implementertisSendingValid
, men det er ingenting som hindrer oss i Ă„ opprette enSending
-verdi uten Ä bruke den. I ekstraoppgaven i steg 11 blir en alternativ tilnÊrming som bruker prinsipper fra domenedrevet design presentert. De resterende stegene i dette kurset frem til og med steg 10 kommer til Ä basere seg pÄ domenemodellen slik den er definert her i steg 5 og steg 6 for Ä ikke innfÞre for mange prinsipper pÄ en gang, og holde fokus pÄ det kurset er ment for. Dersom du Þnsker mÄ du gjerne gÄ videre til steg 11 nÄ for Ä se hvordan det er gjort der. Husk at steg 11 er skrevet med forutsetning av at man har gjennomfÞrt kurset til og med steg 10 fÞrst.
Steg 7 av 10 - đ GĂ„ til toppen ⏠Forrige steg ⏠Neste steg
For Ä dokumentere hva API-et vÄrt tilbyr av operasjoner og responser skal vi lage en API-kontrakt. I NRK TV og NRK Radio definerer vi API-kontrakter ved bruk av OpenAPI (https://www.openapis.org/).
For Ä begrense omfanget av API-et vÄrt skal vi ha kun én operasjon i det:
Responsen til denne operasjonen vil bestÄ av to lister med sendinger, én for hver kanal i domenet vÄrt, hvor hver sending har:
FÞr vi definerer selve kontrakten til API-et i en OpenAPI-spesifikasjon, skal vi definere et JSON Schema for innholdet i responsen til operasjonen i API-et vÄrt. Dette er vist under.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"nrk1": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Sending"
}
},
"nrk2": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Sending"
}
}
},
"required": [
"nrk1",
"nrk2"
],
"components": {
"schemas": {
"Tittel": {
"type": "string",
"pattern": "^[\\p{L}0-9\\.,-:!]{5,100}$",
"example": "Dagsrevyen",
"description": "Programtittel"
},
"Sending": {
"type": "object",
"properties": {
"tittel": {
"$ref": "#/components/schemas/Tittel"
},
"starttidspunkt": {
"type": "string",
"format": "date-time",
"description": "Startdato- og tidspunkt for sendingen."
},
"sluttidspunkt": {
"type": "string",
"format": "date-time",
"description": "Sluttdato- og tidspunkt for sendingen. Er alltid stĂžrre enn sendingens startdato- og tidspunkt."
}
},
"required": [
"tittel",
"starttidspunkt",
"sluttidspunkt"
]
}
}
}
}
Her ser vi at responsen bestÄr av et objekt med to felter: nrk1
og nrk2
, som begge er en liste med sendingene pÄ de respektive kanalene. Hver sending inneholder en tittel, samt start- og sluttidspunkt. Hver av feltene er tekststrenger som fÞlger valideringsreglene vi har definert i domenet vÄrt. Tittel
har pattern
lik det regulĂŠre uttrykket vi benyttet i isTittelValid
i Domain.fs
. Starttidspunkt
og Sluttidspunkt
har format: "date-time"
, som fĂžlger datoformatet i RFC 3339.
ForelÞpig skal vi ikke gjÞre noe mer med JSON schemaet enn Ä ha det som dokumentasjon pÄ API-et vÄrt. Lag en ny mappe docs
i rotmappen din med en ny fil epg.schema.json
hvor du limer inn JSON schemaet over. Du skal nÄ ha fÞlgende mappehierarki:
âââ .config
âââ ...
âââ docs
âââ epg.schema.json
âââ src
âââ ...
âââ test
âââ ...
âââ Dotnetskolen.sln
âââ paket.dependencies
NÄ som vi har formatet pÄ innholdet i responsen vÄr, kan vi definere Open API-spesifikasjonen for API-et vÄrt. La oss starte med Ä opprett en ny fil openapi.json
i docs
-mappen. Du skal nÄ ha fÞlgende mappehierarki:
âââ .config
âââ ...
âââ docs
âââ epg.schema.json
âââ openapi.json
âââ src
âââ ...
test
âââ ...
âââ Dotnetskolen.sln
âââ paket.dependencies
La oss begynne med Ä definere litt metadata for kontrakten vÄr.
Lim inn fĂžlgende JSON i openapi.json
:
{
"openapi": "3.0.0",
"info": {
"title": "Dotnetskolen EPG-API",
"description": "API for Ă„ hente ut EPG for kanalene NRK1 og NRK2 i NRKTV",
"version": "0.0.1"
}
}
Her oppgir vi hvilken versjon av OpenAPI vi benytter, og litt metadata om API-et vÄrt. Fortsett med Ä legg til definisjon av hvilke URL-er som er eksponert i API-et vÄrt:
{
"openapi": "3.0.0",
"info": {
"title": "Dotnetskolen EPG-API",
"description": "API for Ă„ hente ut EPG for kanalene NRK1 og NRK2 i NRKTV",
"version": "0.0.1"
},
"paths": {
"/epg/{dato}": {
"get": {
}
}
}
}
Her har vi spesifisert at API-et vÄrt eksponerer URL-en /epg/{dato}
for HTTP GET
-forespĂžrsler. La oss fortsette med Ă„ spesifisere parameteret dato
:
{
"openapi": "3.0.0",
"info": {
"title": "Dotnetskolen EPG-API",
"description": "API for Ă„ hente ut EPG for kanalene NRK1 og NRK2 i NRKTV",
"version": "0.0.1"
},
"paths": {
"/epg/{dato}": {
"get": {
"parameters": [
{
"description": "Dato slik den er definert i [RFC 3339](https://tools.ietf.org/html/rfc3339#section-5.6). Eksempel: 2021-11-15.",
"in": "path",
"name": "dato",
"required": true,
"schema": {
"type": "string",
"format": "date"
},
"example": "2021-11-15"
}
]
}
}
}
}
Her har vi spesifisert dato
-parameteret vÄrt, og sagt at:
date
i OpenAPI2021-11-15
er et eksempel pÄ en gyldig datoNÄ kan vi legge til hvilke responser endepunktet har: 200 OK
med EPG eller 400 Bad Request
ved ugyldig dato.
{
"openapi": "3.0.0",
"info": {
"title": "Dotnetskolen EPG-API",
"description": "API for Ă„ hente ut EPG for kanalene NRK1 og NRK2 i NRKTV",
"version": "0.0.1"
},
"paths": {
"/epg/{dato}": {
"get": {
"parameters": [
{
"description": "Dato slik den er definert i [RFC 3339](https://tools.ietf.org/html/rfc3339#section-5.6). Eksempel: 2021-11-15.",
"in": "path",
"name": "dato",
"required": true,
"schema": {
"type": "string",
"format": "date"
},
"example": "2021-11-15"
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "./epg.schema.json"
}
}
},
"description": "OK"
},
"400": {
"content": {
"text/plain": {
"schema": {
"type": "string",
"example": "\"Ugyldig dato\""
}
}
},
"description": "Bad Request"
}
}
}
}
}
}
Til slutt legger vi til en ID for operasjonen, og en tekstlig beskrivelse av den.
{
"openapi": "3.0.0",
"info": {
"title": "Dotnetskolen EPG-API",
"description": "API for Ă„ hente ut EPG for kanalene NRK1 og NRK2 i NRKTV",
"version": "0.0.1"
},
"paths": {
"/epg/{dato}": {
"get": {
"parameters": [
{
"description": "Dato slik den er definert i [RFC 3339](https://tools.ietf.org/html/rfc3339#section-5.6). Eksempel: 2021-11-15.",
"in": "path",
"name": "dato",
"required": true,
"schema": {
"type": "string",
"format": "date"
},
"example": "2021-11-15"
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "./epg.schema.json"
}
}
},
"description": "OK"
},
"400": {
"content": {
"text/plain": {
"schema": {
"type": "string",
"example": "\"Ugyldig dato\""
}
}
},
"description": "Bad Request"
}
},
"operationId": "hentEpgPĂ„Dato",
"description": "Henter EPG for NRK1 og NRK 2 pÄ den oppgitte datoen. Returnerer 400 dersom dato er ugyldig. Listen med sendinger for en kanal er tom dersom det ikke finnes noen sendinger pÄ den gitte dagen."
}
}
}
}
Kontrakten over er validert ved hjelp av https://editor.swagger.io/
Merk at i OpenAPI-kontrakten over benytter vi versjon
3.0.0
av OpenAPI. I denne versjonen er det ikke full stÞtte for JSON Schema. Man kan derfor ikke bruke alle features i JSON Schema i OpenAPI-kontrakten. Kontrakten vÄr bruker imidlertid kun features i JSON Schema som er stÞttet.OpenAPI 3.1.0
ble lansert 16. februar 2021, som har full stĂžtte for alle features i JSON Schema. Det vil imidlertid ta noe tid fĂžr det er stĂžtte for denne i tooling somReDoc
(brukt i steg 12)WebGUI
og linting. Takk til @laat som poengterte det.
I steg 12 ser vi pÄ hvordan man kan sette opp en grafisk fremstilling av OpenAPI-dokumentasjonen som en egen HTML-side i API-et,. Merk at det forutsetter at du har utfÞrt steg 1-10 fÞrst. Dersom du Þnsker Ä se en grafisk fremstilling nÄ kan du lime inn koden under pÄ https://editor.swagger.io/.
Bare trykk âOKâ dersom du blir spurt om Ă„ gjĂžre om fra JSON til YAML.
{
"openapi": "3.0.0",
"info": {
"title": "Dotnetskolen EPG-API",
"description": "API for Ă„ hente ut EPG for kanalene NRK1 og NRK2 i NRKTV",
"version": "0.0.1"
},
"paths": {
"/epg/{dato}": {
"get": {
"parameters": [
{
"description": "Dato slik den er definert i [RFC 3339](https://tools.ietf.org/html/rfc3339#section-5.6). Eksempel: 2021-11-15.",
"in": "path",
"name": "dato",
"required": true,
"schema": {
"type": "string",
"format": "date"
},
"example": "2021-11-15"
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"nrk1": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Sending"
}
},
"nrk2": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Sending"
}
}
},
"required": [
"nrk1",
"nrk2"
]
}
}
},
"description": "OK"
},
"400": {
"content": {
"text/plain": {
"schema": {
"type": "string",
"example": "\"Ugyldig dato\""
}
}
},
"description": "Bad Request"
}
},
"operationId": "hentEpgPĂ„Dato",
"description": "Henter EPG for NRK1 og NRK 2 pÄ den oppgitte datoen. Returnerer 400 dersom dato er ugyldig. Listen med sendinger for en kanal er tom dersom det ikke finnes noen sendinger pÄ den gitte dagen."
}
}
},
"components": {
"schemas": {
"Tittel": {
"type": "string",
"pattern": "^[\\p{L}0-9\\.,-:!]{5,100}$",
"example": "Dagsrevyen",
"description": "Programtittel"
},
"Sending": {
"type": "object",
"properties": {
"tittel": {
"$ref": "#/components/schemas/Tittel"
},
"starttidspunkt": {
"type": "string",
"format": "date-time",
"description": "Startdato- og tidspunkt for sendingen."
},
"sluttidspunkt": {
"type": "string",
"format": "date-time",
"description": "Sluttdato- og tidspunkt for sendingen. Er alltid stĂžrre enn sendingens startdato- og tidspunkt."
}
},
"required": [
"tittel",
"starttidspunkt",
"sluttidspunkt"
]
}
}
}
}
Merk at https://editor.swagger.io/ ikke stÞtter at JSON Schema og Open-API-kontrakt er definert i to forskjellige filer. Derfor er kontrakten over en sammenslÄing av
epg.schema.json
ogopenapi.json
.
Steg 8 av 10 - đ GĂ„ til toppen ⏠Forrige steg ⏠Neste steg
I steg-5 definerte vi domenemodellen vÄr som en F#-type. Domenemodellen representerer EPG-en slik vi konseptuelt tenker pÄ den, bÄde nÄr det gjelder struktur og regler for gyldige tilstander. API-kontrakter er ikke nÞdvendigvis en-til-en med domenemodeller.
DateTimeOffset
til Ä representere start- og sluttidspunkt i domenemodellen vÄr, mens vi benytter string
i OpenAPI-kontrakten vĂ„r.For at vi skal kunne oversette domenemodellen til OpenAPI-kontrakten skal vi innfĂžre en egen F#-type som reflekterer typene i OpenAPI-kontrakten vĂ„r. Generelt blir typer som representerer dataene vĂ„re slik vi kommuniserer med andre systemer pĂ„ kalt âdata transfer objectsâ, eller âDTOâ.
Start med Ă„ opprett en fil Dto.fs
i mappen src/api
:
âââ .config
âââ ...
âââ docs
âââ ...
src
âââ api
âââ Domain.fs
âââ Dto.fs
âââ NRK.Dotnetskolen.Api.fsproj
âââ Program.fs
test
âââ ...
âââ Dotnetskolen.sln
âââ paket.dependencies
Lim inn innholdet under i Dto.fs
:
namespace NRK.Dotnetskolen
module Dto =
type SendingDto = {
Tittel: string
Starttidspunkt: string
Sluttidspunkt: string
}
type EpgDto = {
Nrk1: SendingDto list
Nrk2: SendingDto list
}
PÄ samme mÄte som da vi opprettet domenemodellen, mÄ vi legge til Dto.fs
i prosjektfilen til API-prosjektet:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net6.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Compile Include="Domain.fs" />
<Compile Include="Dto.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
</Project>
Steg 9 av 10 - đ GĂ„ til toppen ⏠Forrige steg ⏠Neste steg
I dette steget skal vi sette opp et skall for web-API-et, og verifisere at vi nÄr API-et ved Ä skrive en integrasjonstest. FÞr vi begynner Ä kode skal vi se pÄ et par relevante konsepter i .NET.
Fra og med .NET Core opererer .NET med ulike SDK-prosjekttyper avhengig av hva slags type applikasjon man Þnsker Ä utvikle. Via de ulike prosjekttype fÄr man tilgang til forskjellig funksjonalitet knyttet til kompilering og publisering av prosjektene. Da vi opprettet API- og testprosjektene fikk vi prosjekter med den grunnleggende prosjekttypen .NET SDK
. Siden vi i dette steget er avhengig av funksjonalitet som finnes i .NET Web SDK
skal vi endre prosjekttypene til API- og integrasjonstestprosjektene.
Ă
pne filen src/api/NRK.Dotnetskolen.Api.fsproj
, og endre Sdk
-attributtet pÄ Project
-elementet fra Microsoft.NET.Sdk
til Microsoft.NET.Sdk.Web
:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net6.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Compile Include="Domain.fs" />
<Compile Include="Dto.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
</Project>
Gjenta steget over for test/integration/NRK.Dotnetskolen.IntegrationTests.fsproj
for Ă„ endre SDK-prosjekttypen til integrasjonstestprosjektet:
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<IsPackable>false</IsPackable>
<GenerateProgramFile>false</GenerateProgramFile>
</PropertyGroup>
<ItemGroup>
<Compile Include="Tests.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
<Import Project="..\..\.paket\Paket.Restore.targets" />
</Project>
Du kan lese mer om de ulike prosjekttypene i .NET her: https://docs.microsoft.com/en-us/dotnet/core/project-sdk/overview
FÞr vi setter opp skallet til web-API-et, skal vi se pÄ noen grunnleggende konsepter som er brukt i .NET for Ä lage applikasjoner.
NÄr vi utvikler og kjÞrer en applikasjon har vi behov for tilgang til felles ressurser som konfigurasjon, avhengigheter og logging. I tillegg Þnsker vi Ä ha kontroll pÄ hvordan prosessen til applikasjonen vÄr starter og slutter. Microsoft tilbyr et objekt, IHost
, som holder styr pÄ disse tingene for oss. Typisk bygger man opp og starter et IHost
-objekt i Program.fs
. Det skal vi gjÞre nÄ ved Ä kalle en innebygd funksjon i Microsoft sitt bibliotek Host.CreateDefaultBuilder
.
Ă
pne Program.fs
i web-API-prosjektet og erstatt innholdet med fĂžlgende:
open Microsoft.Extensions.Hosting
Host.CreateDefaultBuilder().Build().Run()
Her Ă„pner vi Microsoft.Extensions.Hosting
for Ä fÄ tilgang til CreateDefaultBuilder
. CreateDefaultBuilder
kommer fra biblioteket til Microsoft, og sĂžrger for Ă„ lese konfigurasjon, sette opp grunnleggende logging, og setter filstien til ressursfilene til applikasjonen (ogsĂ„ kalt âcontent rootâ).
Til slutt bygger vi hosten vÄr, og starter den slik Host.CreateDefaultBuilder().Build().Run()
.
Du kan kjĂžre hosten med fĂžlgende kommando:
dotnet run --project ./src/api/NRK.Dotnetskolen.Api.fsproj
info: Microsoft.Hosting.Lifetime[0]
Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
Hosting environment: Production
info: Microsoft.Hosting.Lifetime[0]
Content root path: C:\Dev\nrkno@github.com\dotnetskolen\src\api
ForelÞpig gjÞr ikke hosten vÄr noen ting. Den bare starter, og kjÞrer helt til vi avslutter den ved Ä trykke Ctrl+C
i terminalen. I outputen over ser vi imidlertid tre logginnslag av typen info
som er blitt skrevet av hosten. Dette illustrerer at CreateDefaultBuilder
har satt opp logging til konsoll. Logginnslagene forteller at applikasjonen har startet, at miljĂžet er Production
, og hva filstien til âcontent rootâ er.
Trykk Ctrl+C
for Ă„ stoppe hosten:
// Trykker `Ctrl+C`
info: Microsoft.Hosting.Lifetime[0]
Application is shutting down...
Production
er default miljĂž i .NET med mindre annet er spesifisert. Du kan lese mer om miljĂžer i .NET her: https://docs.microsoft.com/en-us/aspnet/core/fundamentals/environments?view=aspnetcore-5.0Du kan lese mer om
Host
-konseptet og hva det innebĂŠrer her: https://docs.microsoft.com/en-us/aspnet/core/fundamentals/host/generic-host?view=aspnetcore-6.0
Microsoft har laget et rammeverk for web-applikasjoner i .NET, ASP.NET (ASP stĂ„r for âactive server pagesâ). Web-applikasjoner i ASP.NET er konfigurerbare og modulĂŠre, og gjennom Ă„ konfigurere modulene i den har man kontroll pĂ„ hvordan HTTP-forespĂžrsler blir prosessert helt fra de kommer inn til serveren, og til HTTP-responsen blir sendt tilbake til klienten. Modulene i denne sammenhengen kalles mellomvare (eller âmiddlewareâ pĂ„ engelsk), og de henger sammen i en lenket liste hvor HTTP-forespĂžrselen blir prosessert suksessivt av mellomvarene i listen. Denne lenkede listen blir omtalt som âmiddleware pipelineâ.
Du kan se en illustrasjon av hvordan mellomvarer henger sammen i ASP.NET her: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/middleware/?view=aspnetcore-6.0#create-a-middleware-pipeline-with-webapplication
Alle mellomvarer har i utgangspunktet anledning til Ä prosessere HTTP-forespÞrselen bÄde fÞr og etter den neste mellomvaren i listen prosesserer den, og kan pÄ den mÄten vÊre med Ä pÄvirke responsen som blir sendt tilbake til klienten. Enhver mellomvare har ansvar for Ä kalle den neste mellomvaren. PÄ denne mÄten kan en mellomvare stoppe videre prosessering av forespÞrselen ogsÄ. Et eksempel pÄ en slik mellomvare er autentisering, hvor man ikke sender forespÞrselen videre i pipelinen dersom den ikke er tilstrekkelig autentisert. Pga. denne kortslutningen ligger autentisering tidlig i listen over mellomvarer.
Hosten vi opprettet i forrige avsnitt er et utgangspunkt for hvilken som helst applikasjon. Det kan bli f.eks. en bakgrunnstjeneste eller en web-applikasjon. Siden vi skal lage et web-API skal vi gÄ videre med Ä tilpasse hosten til Ä bli en web-server. Microsoft har laget en spesiell funksjon akkurat til dette formÄlet: WebApplication.CreateBuilder
. Denne likner pÄ Host.CreateDefaultBuilder
som vi brukte i tidligere i avsnittet om host, bare at hosten den lager er en web-server som har mulighet til Ă„ konfigurere en âmiddleware pipelineâ. For Ă„ lage en web-applikasjon istedenfor en generisk applikasjon, Ă„pne Microsoft.AspNetCore.Builder
, og bytt ut linjen Host.CreateDefaultBuilder().Build().Run()
med WebApplication.CreateBuilder().Build().Run()
slik at Program.fs
i API-prosjektet nÄ ser slik ut:
open Microsoft.AspNetCore.Builder
WebApplication.CreateBuilder().Build().Run()
WebApplication.CreateBuilder
sÞrger bl.a. for Ä sette opp Kestrel som web-server for applikasjonen vÄr. I tillegg returnerer den et objekt av typen WebApplicationBuilder
som vi kan bruke til Ä konfigurere web-applikasjonen etter vÄre behov. Vi kaller umiddelbart pÄ WebApplicationBuilder
sin funksjon Build
for Ä bygge web-applikasjonen vÄr. Build
returnerer et objekt av typen WebApplication
, og vi kaller til slutt Run
-metoden pÄ WebApplication
-objektet for Ă„ starte web-applikasjonen.
Hvis du nÄ kjÞrer hosten igjen, vil du se to nye logginnslag:
dotnet run --project ./src/api/NRK.Dotnetskolen.Api.fsproj
info: Microsoft.Hosting.Lifetime[0]
Now listening on: http://localhost:5000
info: Microsoft.Hosting.Lifetime[0]
Now listening on: https://localhost:5001
...
Fra logginnslagene over ser vi at hosten vÄr nÄ lytter pÄ HTTP-forespÞrsler pÄ port 5000
og 5001
for hhv. HTTP og HTTPS. I og med at vi ikke har lagt til noen middlewares i pipelinen vÄr enda, svarer API-et med 404 Not Found
pÄ alle forespÞrsler. Det kan du verifisere ved Ä Äpne http://localhost:5000/ i en nettleser.
Du kan lese mer om middleware i .NET-web-applikasjoner her: https://docs.microsoft.com/en-us/aspnet/core/fundamentals/middleware/?view=aspnetcore-6.0
NÄ som vi har blitt kjent med noen grunnleggende konsepter i .NET-applikasjoner, kan vi starte Ä sette sammen vÄrt eget web-API. For Ä gjÞre det trenger vi en middleware pipeline som kan behandle HTTP-forespÞrslene som kommer inn til API-et vÄrt.
I .NET 6 innfĂžrte Microsoft âminimal APIsâ som er en rekke metoder som gjĂžr det enklere Ă„ komme i gang med Ă„ definere oppfĂžrslen til en host. For web-applikasjoner har Microsoft laget âminimal APIsâ som gjĂžr det enkelt Ă„ legge til funksjoner i âmiddleware pipelinenâ til en web-applikasjon som hĂ„ndterer innkommende HTTP-forespĂžrsler for en gitt sti. Dette kan vi bruke for Ă„ lage et âpingâ-endepunkt.
Ă
pne Program.fs
i API-prosjektet, og bytt ut innholdet i filen med koden under:
open System
open Microsoft.AspNetCore.Builder
let app = WebApplication.CreateBuilder().Build()
app.MapGet("/ping", Func<string>(fun () -> "pong")) |> ignore
app.Run()
Her har vi tatt vare pÄ WebApplication
-objektet, som WebApplication.CreateBuilder().Build()
returnerer, i en egen variabel app
. Dette har vi gjort for Ă„ fĂ„ tilgang til âminimal APIâ-metodene Microsoft har definert for WebApplication
. Videre har vi brukt Ă©n av dem, nemlig MapGet
, som tar inn to argumenter:
ping
.pong
.Merk at som andre parameter til
MapGet
har vi oppgittFunc<string>(fun () -> "pong")
som strengt tatt ikke er en funksjon.Func
er .NET sin mÄte Ä opprette etDelegate
pĂ„. Delegates er .NET sin mĂ„te Ă„ pakke inn funksjonskall som objekter pĂ„. Siden âMinimal APIsâ er skrevet for Ă„ fungere for hvilket som helst programmeringssprĂ„k i .NET, har Microsoft vĂŠrt nĂždt til Ă„ velge en modell som passer bĂ„de for bĂ„de det objektorienterte programmeringsparadigmet sĂ„ vel som det funksjonelle programmeringsparadigmet. Dermed tarMapGet
strengt tatt inn etDelegate
-objekt som andre parameter, og mÄten man oppretter etDelegate
-objekt i F# pÄ er ved Ä kalleFunc
sin konstruktĂžr. I konstruktĂžren tilFunc
sender vi inn den anonyme F#-funksjonenfun () -> "pong"
.<string>
delen avFunc<string>
definerer hva slags type returverdien til den anonyme funksjonen har. Ettersom den anonyme funksjonen ikke tar inn noen parametere er det ikke spesifisert noe mer iFunc<string>
for det. Dersom den anonyme funksjonen hadde tatt inn et parameter av typenint
, hadde kallet tilFunc
sett slik ut:Func<int, string>
. Du kan lese mer om delegates i F# her: https://docs.microsoft.com/en-us/dotnet/fsharp/language-reference/delegatesDu kan lese mer om âminimal APIsâ her: https://docs.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis?view=aspnetcore-6.0
Start API-et med fĂžlgende kommando:
dotnet run --project ./src/api/NRK.Dotnetskolen.Api.fsproj
info: Microsoft.Hosting.Lifetime[0]
Now listening on: https://localhost:5001
info: Microsoft.Hosting.Lifetime[0]
Now listening on: http://localhost:5000
info: Microsoft.Hosting.Lifetime[0]
Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
Hosting environment: Development
info: Microsoft.Hosting.Lifetime[0]
Content root path: C:\Dev\nrkno@github.com\dotnetskolen\src\api
Dette starter web-API-et pÄ http://localhost:5000
. Verifiser at API-et fungerer ved Ä gÄ til http://localhost:5000/ping i nettleseren din og se at svaret er pong
.
Merk at dersom du forsĂžker Ă„ Ă„pne applikasjonen pĂ„ https://localhost:5001 kan du fĂ„ beskjed om at nettleseren din ikke stoler pĂ„ sertifikatet. For Ă„ komme rundt dette mĂ„ man sette opp âself signedâ-sertifikat pĂ„ maskinen. Microsoft har skrevet en artikkel om hvordan Ă„ gjĂžre det her, men det Ă„ sette opp âself signedâ-sertifikat er ikke en del av dette kurset.
FÞr vi fortsetter med Ä implementere web-API-et skal vi sette opp en integrasjonstest som verifiserer at API-et er oppe og kjÞrer, og at det svarer pÄ HTTP-forespÞrsler. Det skal vi gjÞre ved Ä:
TestServer
.Siden vi gir hele web-API-et vÄrt som input til testserveren er responsene vi fÄr tilsvarende de web-API-et svarer med i et deployet miljÞ, og dermed kan vi vÊre trygge pÄ at API-et oppfyller kontrakten vi har definert ogsÄ nÄr det deployes.
Webserveren vi skal kjĂžre i integrasjonstestene er dokumentert her: https://docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.testhost.testserver?view=aspnetcore-6.0
Inspirasjonen til Ä skrive integrasjonstestene pÄ mÄten beskrevet over er fra et kurs som @erikly har arrangert.
En liknende metode er ogsÄ beskrevet i denne artikkelen skrevet av Microsoft: https://docs.microsoft.com/en-us/aspnet/core/test/integration-tests?view=aspnetcore-6.0. Artikkelen belager seg imidlertid pÄ konsepter fra objektorientert programmering, og siden dette kurset fokuserer pÄ F# og funksjonell programmering er det valgt Ä skrive integrasjonstestene med en mer funksjonell tilnÊrming.
For Ä kunne kjÞre integrasjonstestene vÄre er vi avhengig av et par NuGet-pakker og en prosjektreferanse til web-API-et. De fÞlgende avsnittene forklarer hvordan du legger dem til.
For Ä fÄ tilgang til testserverem vi skal kjÞre under integrasjonstestene er vi avhengig av NuGet-pakken Microsoft.AspNetCore.Mvc.Testing
.
KjĂžr fĂžlgende kommando fra rotenmappen din for Ă„ installere pakken:
dotnet paket add Microsoft.AspNetCore.Mvc.Testing --project ./test/integration/NRK.Dotnetskolen.IntegrationTests.fsproj
For Ä kunne referere til API-et vÄrt fra testprosjektet mÄ vi legge til en referanse til API-prosjektet fra integrasjonstestprosjektet.
GjĂžr dette ved Ă„ kjĂžr fĂžlgende kommando fra rotmappen din:
dotnet add ./test/integration/NRK.Dotnetskolen.IntegrationTests.fsproj reference ./src/api/NRK.Dotnetskolen.Api.fsproj
For Ä kunne lage en testserver som representerer API-et vÄrt nÄr vi kjÞrer testene mÄ vi konfiguerere API-et vÄrt til Ä bruke en testserver, men kun nÄr vi faktisk kjÞrer testene, og ikke nÄr API-et kjÞrer ellers. For Ä fÄ til dette mÄ vi kalle en funksjon pÄ WebApplicationBuilder
-objektet (som vi oppretter i main
-funksjonen i Program.fs
i API-prosjektet) nÄr vi setter opp testserveren i testene.
Husk at Program.fs
i API-prosjektet nÄ ser slik ut:
open System
open Microsoft.AspNetCore.Builder
let app = WebApplication.CreateBuilder().Build()
app.MapGet("/ping", Func<string>(fun () -> "pong")) |> ignore
app.Run()
For Ä fÄ tak i WebApplicationBuilder
-objektet som WebApplication.CreateBuilder
returnerer fra integrasjonstesten, trekker vi ut oppretting av WebApplicationBuilder
-objektet til en egen funksjon createWebApplicationBuilder
slik:
open System
open Microsoft.AspNetCore.Builder
let createWebApplicationBuilder () =
WebApplication.CreateBuilder()
let app = createWebApplicationBuilder().Build()
app.MapGet("/ping", Func<string>(fun () -> "pong")) |> ignore
app.Run()
Ved Ă„ bruke funksjonen createWebApplicationBuilder
fra integrasjonstestprosjektet kan vi konfiguerere WebApplicationBuilder
-objektet til Ä bruke testserveren nÄr testene kjÞrer.
I tillegg til Ă„ konfigurere WebApplicationBuilder
-objektet til Ä bruke en testserver trenger vi Ä fÄ tak i app
-objektet fra main
-funksjonen i API-prosjektet for Ä opprette en HTTP-klient som sender HTTP-forespÞrsler til testserveren. For Ä fÄ til dette trekker vi ut koden som oppretter og konfigurerer WebApplication
-objektet i API-et slik:
open System
open Microsoft.AspNetCore.Builder
let createWebApplicationBuilder () =
WebApplication.CreateBuilder()
let createWebApplication (builder: WebApplicationBuilder) =
let app = builder.Build()
app.MapGet("/ping", Func<string>(fun () -> "pong")) |> ignore
app
let builder = createWebApplicationBuilder()
let app = createWebApplication builder
app.Run()
Ved Ă„ bruke funksjonen createWebApplication
fra integrasjonstestprosjektet kan vi hente ut WebApplication
-objektet som representerer hele web-API-et vÄrt, og sende HTTP-forespÞrsler mot det fra integrasjonstestene vÄre.
For Ă„ kunne referere til de to nye funksjonene vi lagde i API-prosjektet, createWebApplicationBuilder
og createWebApplication
, fra integrasjonstestprosjektet mÄ vi legge dem i en egen modul, slik:
namespace NRK.Dotnetskolen.Api
module Program =
open System
open Microsoft.AspNetCore.Builder
let createWebApplicationBuilder () =
WebApplication.CreateBuilder()
let createWebApplication (builder: WebApplicationBuilder) =
let app = builder.Build()
app.MapGet("/ping", Func<string>(fun () -> "pong")) |> ignore
app
let builder = createWebApplicationBuilder()
let app = createWebApplication builder
app.Run()
Merk at vi her ogsÄ la til linjen
namespace NRK.Dotnetskolen.Api
Ăžverst. Dette setter modulenProgram
i kontekst avNRK.Dotnetskolen.Api
, og gjÞr at nÄr vi skal referere til funksjonenecreateWebApplicationBuilder
ogcreateWebApplication
mÄ vi ÄpneNRK.Dotnetskolen.Api.Program
.
NĂ„ er vi klare til Ă„ kunne sette opp integrasjonstestene. Ă
pne Tests.fs
i integrasjonstestprosjektet, og erstatt innholdet i filen med koden under:
module Tests
open System.Net.Http
open System.Threading.Tasks
open Xunit
open Microsoft.AspNetCore.TestHost
open NRK.Dotnetskolen.Api.Program
let runWithTestClient (test: HttpClient -> Task<unit>) =
task {
let builder = createWebApplicationBuilder()
builder.WebHost.UseTestServer() |> ignore
use app = createWebApplication builder
do! app.StartAsync()
let testClient = app.GetTestClient()
do! test testClient
}
[<Fact>]
let ``Get "ping" returns "pong"`` () =
runWithTestClient (fun httpClient ->
task {
let! response = httpClient.GetStringAsync("ping")
Assert.Equal(response, "pong")
}
)
La oss se litt nÊrmere pÄ hva denne koden gjÞr.
FĂžrst definerer vi en modul som heter Tests
:
module Tests
Deretter Ă„pner vi de namespacene vi er avhengige av:
open System.Net.Http
open System.Threading.Tasks
open Xunit
open Microsoft.AspNetCore.TestHost
open NRK.Dotnetskolen.Api.Program
System.Net.Http
for Ă„ ha tilgang til HttpClient
-typenSystem.Threading.Tasks
for Ă„ ha tilgang til Task
-typenXunit
for Ă„ ha tilgang til [<Fact>]
som attributt til test-funksjonene vÄreMicrosoft.AspNetCore.TestHost
for Ă„ ha tilgang til funksjonene UseTestServer
og GetTestClient
som lar hhv. lar oss konfigurere WebApplicationBuilder
til Ă„ bruke testserveren, samt hente ut en HttpClient
som sender forespĂžrsler til testserveren.NRK.Dotnetskolen.Api.Program
for Ă„ ha tilgang til funksjonene createWebApplicationBuilder
og createWebApplication
for Ă„ kunne hente ut hhv. WebApplicationBuilder
-objektet og WebApplication
-objektet til API-et vÄrt.Deretter definerer vi en funksjon runWithTestClient
. Hensikten med denne funksjonen er Ă„ samle koden som konfigurerer testserveren og henter ut HttpClient
-objektet som kan sende HTTP-forespĂžrsler til denne.
let runWithTestClient (test: HttpClient -> Task<unit>) =
task {
let builder = createWebApplicationBuilder()
builder.WebHost.UseTestServer() |> ignore
use app = createWebApplication builder
do! app.StartAsync()
let testClient = app.GetTestClient()
do! test testClient
}
runWithTestClient
kaller createWebApplicationBuilder
fra API-prosjektet, og konfigurerer WebHost
-objektet til Ă„ bruke testserveren.
Deretter kaller runWithTestClient
createWebApplication
med WebApplicationBuilder
som argument for Ä fÄ WebApplication
-objektet som representerer API-et vÄrt, og starter web-API-et.
Videre henter runWithTestClient
ut et HttpClient
-objekt fra WebApplication
-objektet. Det er dette HttpClient
-objektet som kan sende HTTP-forespĂžrsler til testserveren.
Til slutt kaller runWithTestClient
test
-funksjonen og sender med testClient
som parameter.
Merk at
runWithTestClient
lager ettask
âcomputation expressionâ (task {...}
). Med slike blokker kan vi sette i gang .NET tasks, som lar oss kjĂžre kode asynkront. F# har to typer âcomputation expressionsâ for Ă„ kjĂžre asynkron kode pĂ„:async
ogtask
.async
kom fĂžrst, og er hittil det mest vanlige Ă„ bruke, menstask
kom i F# 6 inkludert i .NET 6. Du kan lese mer om âcomputation expressionsâ,async
ogtask
her:
- https://docs.microsoft.com/en-us/dotnet/fsharp/language-reference/computation-expressions
- https://docs.microsoft.com/en-us/dotnet/fsharp/language-reference/async-expressions
- https://docs.microsoft.com/en-us/dotnet/fsharp/language-reference/task-expressions
Merk at vi bruker
use
-kodeordet nÄr vi oppretter test-HTTP-klienten. Dette sÞrger for at kompilatoren rydder opp ressursene som objektet bruker nÄr testen er ferdig.
Til slutt definerer vi en test Get "ping" returns "pong"
som kaller runWithTestClient
med en anonym funksjon. Den anonyme funksjonen tar inn HttpClient
-objektet som sender HTTP-forespÞrsler til testserveren vÄr. Deretter kaller den httpClient.GetStringAsync("/ping")
for Ă„ sende en HTTP GET til testserveren med ping
som sti i URL-en. Til slutt verifiserer den at responsen fra testserveren var pong
.
[<Fact>]
let ``Get "ping" returns "pong"`` () =
runWithTestClient (fun httpClient ->
task {
let! response = httpClient.GetStringAsync("/ping")
Assert.Equal(response, "pong")
}
)
Merk at her bruker vi
let!
istedenforlet
fĂžrhttpClient.GetStringAsync(/ping")
. Ved Ă„ brukelet!
venter vi pÄ at den asynkrone handlingen pÄ hÞyresiden av=
(httpClient.GetStringAsync("/ping")
) returnerer fÞr vi gÄr videre.
KjĂžr integrasjonstesten med fĂžlgende kommando:
dotnet test ./test/integration/NRK.Dotnetskolen.IntegrationTests.fsproj
Passed! - Failed: 0, Passed: 1, Skipped: 0, Total: 1, Duration: < 1 ms - NRK.Dotnetskolen.IntegrationTests.dll (net6.0)
Steg 10 av 10 - đ GĂ„ til toppen ⏠Forrige steg
I forrige steg opprettet vi et skall for web-API-et ved Ă„ legge til et ping
-endepunkt med en tilhÞrende integrasjonstest. I dette steget skal vi utvide web-API-et med endepunkt for Ä hente EPG. I tillegg skal vi skrive integrasjonstester for Ä verifisere at implementasjonen av web-API-et er i henhold til Open API-dokumentasjonen vÄr. Vi bruker en testdrevet tilnÊrming ved at vi skriver en integrasjonstest som feiler, og deretter gjÞr vi endringer i API-et slik at testen passerer. Slik fortsetter vi til vi har implementert ferdig API-et vÄrt.
I den fĂžrste integrasjonstesten skal vi sende en forespĂžrsel til API-et vĂ„rt som henter ut EPG-en for dagen i dag, og validere at vi fĂ„r 200 OK tilbake. Start med Ă„ legg til fĂžlgende âopenâ-statement fĂžr open System.Net.Http
i Tests.fs
-filen i integrasjonstestprosjektet.
open System
Legg deretter til fĂžlgende test etter ping
-testen i Tests.fs
-filen:
[<Fact>]
let ``Get EPG today returns 200 OK`` () =
runWithTestClient (fun httpClient ->
task {
let todayAsString = DateTimeOffset.Now.ToString "yyyy-MM-dd"
let url = $"/epg/{todayAsString}"
let! response = httpClient.GetAsync(url)
response.EnsureSuccessStatusCode() |> ignore
}
)
PÄ tilsvarende mÄte som ping
-testen vÄr, bruker vi runWithTestClient
-funksjonen til Ä fÄ en HTTP-klient som sender HTTP-forespÞrsler til testserveren vÄr. Deretter benytter vi HTTP-klienten til Ä sende en GET-forespÞrsel til /epg/<dagens dato>
. Vi forventer Ä fÄ 200 OK i respons, og verifiserer dette ved Ä kalle response.EnsureSuccessStatusCode()
.
KjĂžr integrasjonstesten med fĂžlgende kommando:
dotnet test ./test/integration/NRK.Dotnetskolen.IntegrationTests.fsproj
[xUnit.net 00:00:00.73] Tests.Get EPG today returns 200 OK [FAIL]
Failed Tests.Get EPG today returns 200 OK [102 ms]
Error Message:
System.Net.Http.HttpRequestException : Response status code does not indicate success: 404 (Not Found).
...
Failed! - Failed: 1, Passed: 1, Skipped: 0, Total: 2, Duration: 10 ms - NRK.Dotnetskolen.IntegrationTests.dll (net6.0)
Som vi ser over feiler testen forelĂžpig ettersom web-API-et returnerer 404 (Not Found)
. La oss endre API-et slik at integrasjonstesten passerer.
Dersom vi ser pÄ API-kontrakten vi definerte i steg 7 inneholder den én operasjon /epg/{dato}
som returnerer 200 OK
med den aktuelle EPG-en dersom alt er OK, og 400 Bad Request
dersom den ikke klarer Ă„ parse datoen:
...
"paths": {
"/epg/{dato}": {
"get": {
...
"responses": {
"200": {
...
"description": "OK"
},
"400": {
...
"description": "Bad Request"
}
}
...
"description": "Henter EPG for NRK1 og NRK 2 pÄ den oppgitte datoen. Returnerer 400 dersom dato er ugyldig. Listen med sendinger for en kanal er tom dersom det ikke finnes noen sendinger pÄ den gitte dagen."
}
}
}
}
Det er to ting som definerer operasjonen i API-et vÄrt:
/epg/{dato}
GET
-verbetDette kan vi bruke nÄr vi skal definere operasjonen i WebApplication
-objektet vÄrt. Utvid createWebApplication
i Program.fs
i API-prosjektet med linjen app.MapGet("/epg/{date}", Func<string, string>(fun (date) -> date)) |> ignore
slik:
let createWebApplication (builder: WebApplicationBuilder) =
let app = builder.Build()
app.MapGet("/ping", Func<string>(fun () -> "pong")) |> ignore
app.MapGet("/epg/{date}", Func<string, string>(fun date -> date)) |> ignore
app
Her spesifiserer vi at vi Ăžnsker Ă„ kjĂžre den anonyme funksjonen fun date -> date)
for HTTP GET
-forespĂžrsler til URL-en epg/{date}
, hvor {date}
matcher tekststrengen oppgitt i URL-en etter /epg/
.
Legg merke til bruken av delegates her ogsÄ gjennom
Func<string, string>(fun date -> date)
. Her ser vi at delegaten vÄr tar inn et parameter av typenstring
, og returnerer en verdi av typenstring
.
Start API-et igjen og se hva som skjer dersom du gÄr til http://localhost:5000/epg/2021-01-01 i nettleseren.
dotnet run --project ./src/api/NRK.Dotnetskolen.Api.fsproj
NÄ skal ogsÄ integrasjonstesten som verifiserer om API-et vÄrt svarer pÄ /epg/{dato}
passere. Det kan vi se ved Ă„ kjĂžre fĂžlgende kommando:
dotnet test ./test/integration/NRK.Dotnetskolen.IntegrationTests.fsproj
Passed! - Failed: 0, Passed: 2, Skipped: 0, Total: 2, Duration: 9 ms - NRK.Dotnetskolen.IntegrationTests.dll (net6.0)
I den neste testen skal vi verifisere at API-et validerer datoen som oppgis i URL-en. Utvid Tests.fs
i integrasjonstestprosjektet med fĂžlgende open
-statement og testfunksjon:
open System.Net
[<Fact>]
let ``Get EPG invalid date returns bad request`` () =
runWithTestClient (fun httpClient ->
task {
let invalidDateAsString = "2021-13-32"
let url = $"/epg/{invalidDateAsString}"
let! response = httpClient.GetAsync(url)
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode)
}
)
Her sender vi inn en ugyldig dato, og forventer Ä fÄ 400 Bad Request som respons.
KjĂžr integrasjonstestene igjen med fĂžlgende kommando:
dotnet test ./test/integration/NRK.Dotnetskolen.IntegrationTests.fsproj
[xUnit.net 00:00:00.81] Tests.Get EPG invalid date returns bad request [FAIL]
Failed Tests.Get EPG invalid date returns bad request [10 ms]
Error Message:
Assert.Equal() Failure
Expected: BadRequest
Actual: OK
...
Failed! - Failed: 1, Passed: 2, Skipped: 0, Total: 3, Duration: 37 ms - NRK.Dotnetskolen.IntegrationTests.dll (net6.0)
Den nye testen vi la til feiler fordi API-et ikke validerer den oppgitte datoen. La oss endre implementasjonen av web-API-et slik at testen passerer.
Den anonyme funksjonen som hÄndterer HTTP GET-forespÞrsler til /epg/{dato}
gir ikke sÄ mye verdi slik den stÄr nÄ. La oss gÄ videre med Ä implementere operasjonen slik den er definert i API-kontrakten vÄr. Overordnet Þnsker vi at funksjonen skal gjÞre fÞlgende:
400 Bad Request
dersom den er ugyldigLa oss starte med Ă„ trekke ut den anonyme funksjonen til en egen funksjon epgHandler
som vi legger i en ny modul HttpHandlers
. Opprett en ny fil HttpHandlers.fs
som du legger i mappen src/api
slik:
...
src
âââ api
âââ NRK.Dotnetskolen.Api.fsproj
âââ Domain.fs
âââ Dto.fs
âââ HttpHandlers.fs
âââ Program.fs
...
Husk Ă„ legg til HttpHandlers.fs
i prosjektfilen til API-prosjektet:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net6.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Compile Include="Domain.fs" />
<Compile Include="Dto.fs" />
<Compile Include="HttpHandlers.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
<Import Project="..\..\.paket\Paket.Restore.targets" />
</Project>
Legg til fĂžlgende kode i HttpHandlers.fs
:
namespace NRK.Dotnetskolen.Api
module HttpHandlers =
let epgHandler (dateAsString: string) =
dateAsString
Her oppretter vi en modul HttpHandlers
i namespacet NRK.Dotnetskolen.Api
. I modulen har vi en funksjon epgHandler
, som tar inn en tekststreng, og forelĂžpig returnerer funksjonen den samme tekststrengen. Returverdien av epgHandler
er forelĂžpig lik som den anonyme funksjonen vi hadde i Program.fs
, men nÄ har vi anledning til Ä utvide den uten at koden i Program.fs
blir uoversiktlig.
Ă
pne modulen HttpHandlers
i Program.fs
og kall funksjonen epgHandler
istedenfor den anonyme funksjonen vi hadde:
open NRK.Dotnetskolen.Api.HttpHandlers
let createWebApplication (builder: WebApplicationBuilder) =
let app = builder.Build()
app.MapGet("/ping", Func<string>(fun () -> "pong")) |> ignore
app.MapGet("/epg/{date}", Func<string, string>(fun date -> epgHandler date)) |> ignore
app
La oss fortsette med Ä validere datoen vi fÄr inn i epgHandler
-funksjonen. Lim inn fĂžlgende open
-statements, og parseAsDateTime
-funksjon fĂžr epgHandler
-funksjonen i HttpHandlers.fs
:
open System
open System.Globalization
open System.Threading.Tasks
let parseAsDateTime (dateAsString : string) : DateTimeOffset option =
try
let date = DateTimeOffset.ParseExact(dateAsString, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None)
Some date
with
| _ -> None
parseAsDateTime
-funksjonen forsÞker Ä parse tekststrengen den fÄr som parameter til en dato pÄ formatet yyyy-MM-dd
og returnerer en DateTimeOffset option
verdi som indikerer om det gikk bra eller ikke. parseAsDateTime
benytter DateTimeOffset.ParseExact
-funksjonen fra basebiblioteket til Microsoft. DateTimeOffset.ParseExact
kaster en Exception
dersom den oppgitte string
-verdien ikke matcher det oppgitte formatet. Derfor har vi en try/with
-blokk rundt kallet til funksjonen, og returnerer None
(ingen verdi) dersom DateTimeOffset.ParseExact
kaster Exception
, og Some date
dersom funksjonskallet lykkes.
NĂ„ kan vi bruke parseAsDateTime
-funksjonen i epgHandler
til Ă„ returnere 400 Bad Request
dersom datoen er ugyldig. Legg til fĂžlgende open
-statement, og endre implementasjonen av epgHandler
slik:
open Microsoft.AspNetCore.Http
let epgHandler (dateAsString: string) =
match (parseAsDateTime dateAsString) with
| Some date -> Results.Ok(date)
| None -> Results.BadRequest("Invalid date")
Her bruker vi et match
-statement i F# som sammenlikner resultatet av Ă„ kalle parseAsDateTime dateAsString
med Some date
(i tilfellet datoen ble vellykket parset som en dato pÄ formatet vi har spesifisert i parseAsDateTime
) eller None
i motsatt fall. Dersom datoen ble vellykket parset som en dato returnerer vi Results.Ok(date)
som setter statuskoden til 200 OK
og returnerer datoen. I motsatt fall returnerer vi Results.BadRequest("Invalid date")
som setter statuskoden til 400 Bad Request
, og returnerer teksten Invalid date
.
Siden vi nÄ har endret returtypen til epgHandler
fra string
til IResult
(samleinterface for blant annet Ok
og BadRequest
), mÄ vi ogsÄ endre typen til delegaten i MapGet("/epg/{date}"
. Ă
pne Microsoft.AspNetCore.Http
, og endre typen til delegaten slik:
open Microsoft.AspNetCore.Http
app.MapGet("/epg/{date}", Func<string, IResult>(fun date -> epgHandler date)) |> ignore
Start API-et igjen og se hva som skjer dersom du gÄr til http://localhost:5000/epg/2021-01-01 i nettleseren.
dotnet run --project ./src/api/NRK.Dotnetskolen.Api.fsproj
Det vi nÄ fÄr tilbake er ASP.NET sin serialisering av det parsede datoobjektet.
KjÞr integrasjonstestene pÄ nytt, og se at testen som verifiserer at API-et vÄrt responderer med 400 Bad Request
med en ugyldig dato passerer nÄ:
dotnet test ./test/integration/NRK.Dotnetskolen.IntegrationTests.fsproj
Passed! - Failed: 0, Passed: 3, Skipped: 0, Total: 3, Duration: 16 ms - NRK.Dotnetskolen.IntegrationTests.dll (net6.0)
I den siste testen skal vi verifisere at responsen API-et gir fÞlger formatet vi har spesifisert i OpenAPI-kontrakten vÄr.
For Ă„ kunne validere at responsen fra web-API-et er i henhold til OpenAPI-kontrakten, skal vi benytte NuGet-pakken JsonSchema.Net
. Installer denne pakken ved Ă„ kjĂžre fĂžlgende kommando fra rotmappen din:
dotnet paket add JsonSchema.Net --project ./test/integration/NRK.Dotnetskolen.IntegrationTests.fsproj
For Ä kunne verifisere at responsen fra API-et vÄrt fÞlger den definerte kontrakten, mÄ vi inkludere JsonSchema-et for responsen vÄr i integrasjonstestprosjektet. Det kan vi gjÞre ved Ä legge til fÞlgende i slutten av samme ItemGroup
som Program.fs
og Tests.fs
i prosjektfilen til integrasjonstestprosjektet:
<Content Include="../../docs/epg.schema.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
Legg deretter til fĂžlgende âopenâ-statement i Tests.fs
:
open Json.Schema
open System.Text.Json
Legg til slutt til fĂžlgende test i Test.fs
-klassen:
[<Fact>]
let ``Get EPG today return valid response`` () =
runWithTestClient (fun httpClient ->
task {
let todayAsString = DateTimeOffset.Now.ToString "yyyy-MM-dd"
let url = $"/epg/{todayAsString}"
let jsonSchema = JsonSchema.FromFile "./epg.schema.json"
let! response = httpClient.GetAsync(url)
response.EnsureSuccessStatusCode() |> ignore
let! bodyAsString = response.Content.ReadAsStringAsync()
let bodyAsJsonDocument = JsonDocument.Parse(bodyAsString).RootElement
let isJsonValid = jsonSchema.Validate(bodyAsJsonDocument, ValidationOptions(RequireFormatValidation = true)).IsValid
Assert.True(isJsonValid)
}
)
Denne testen bygger pÄ de foregÄende testene vi har skrevet, og validerer i tillegg at responsen fÞlger JsonSchema-et som vi definerte i OpenAPI-kontrakten:
let jsonSchema = JsonSchema.FromFile "./epg.schema.json"
oppretter en .NET-representasjon av JSON Schemaet vi definerte i kapittel 7let! bodyAsString = response.Content.ReadAsStringAsync()
henter ut innholdet i responsen som en string
let bodyAsJsonDocument = JsonDocument.Parse(bodyAsString).RootElement
oppretter en .NET-representasjon av JSON-dokumentet som API-et returnerer, og henter en referanse til rotelementet i JSON-dokumentetlet isJsonValid = jsonSchema.Validate(bodyAsJsonDocument, ValidationOptions(RequireFormatValidation = true)).IsValid
benytter JSON Schemaet vÄrt til Ä validere om JSON-objektet som web-API-et returnerte tilfredstiller API-kontraktenKjÞr integrasjonstestene igjen med fÞlgende kommando.
dotnet test ./test/integration/NRK.Dotnetskolen.IntegrationTests.fsproj
[xUnit.net 00:00:01.13] Tests.Get EPG today return valid response [FAIL]
Failed Tests.Get EPG today return valid response [98 ms]
Error Message:
Assert.True() Failure
Expected: True
Actual: False
...
Failed! - Failed: 1, Passed: 3, Skipped: 0, Total: 4, Duration: 408 ms - NRK.Dotnetskolen.IntegrationTests.dll (net5.0)
Testen feiler. La oss implementere ferdig API-et.
FĂžr vi koder videre skal vi ta en snartur innom et mye brukt prinsipp i programvareutvikling: âInversion of controlâ (IoC). Inversion of control gĂ„r kort fortalt ut pĂ„ at man lar kontrollen over implementasjonen av avhengighetene man har i koden sin ligge pĂ„ utsiden av der man har behov for avhengigheten. PĂ„ denne mĂ„ten kan man endre hva som implementerer avhengigheten man har, og man kan enklere enhetsteste koden sin fordi man kan sende inn fiktive implementasjoner av avhengighetene.
Et eksempel pÄ dette er dersom man har en funksjon isLoginValid
for Ă„ validere brukernavn og passord som kommer inn fra et innloggingsskjema, har man behov for Ă„ hente entiteten som korresponderer til det oppgitte brukernavnet fra brukerdatabasen. Ved Ă„ ta inn en egen funksjon getUser
i ValidateLogin
har man gitt kontrollen over hvordan getUser
skal implementeres til utsiden av ValidateLogin
-funksjonen.
let isLoginValid (getUser: string -> UserEntity) (username: string) (password: string) : bool ->
En mĂ„te Ă„ oppnĂ„ IoC pĂ„ er Ă„ bruke âdependency injectionâ (DI). Da sender man inn de nĂždvendige avhengighetene til de ulike delene av koden sin fra utsiden. Dersom en funksjon A
har avhengiheter funksjonene B
og C
, og B
og C
har hhv. avhengiheter til funksjonene D
og E
, mÄ man ha implementasjoner for B
, C
, D
og E
for Ă„ kunne kalle funksjon A
. Disse avhengighetene danner et avhengighetstre, og dersom man skal kalle en funksjon pÄ toppen av treet er man nÞdt til Ä ha implementasjoner av alle de interne nodene og alle lÞvnodene i avhengighetstreet. For hver toppnivÄfunksjon (slik som A
) man har i applikasjonen sin, vil man ha et avhengighetstre.
Den delen av applikasjonen som har ansvar for Ă„ tilfredsstille alle avhengighetene til alle toppnivĂ„funksjoner i applikasjonen kalles âcomposition rootâ.
Du kan lese mer om âdependency injectionâ her: https://docs.microsoft.com/en-us/aspnet/core/fundamentals/dependency-injection?view=aspnetcore-6.0
Neste steg i Ä implementere API-et nÄ er Ä hente EPG for den validerte datoen. Siden det Ä hente sendinger for en gitt dato kan implementeres pÄ flere mÄter (kalle web-tjeneste, spÞrre database, hente fra fil), benytter vi IoC-prinsippet, og sier at dette er en funksjon vi mÄ fÄ inn til epgHandler
. Vi definerer denne funksjonen som getEpgForDate: DateTimeOffset -> Epg
hvor Epg
er typen fra domenemodellen vÄr. Utvid epgHandler
i HttpHandlers.fs
med denne avhengigheten slik som vist under:
open NRK.Dotnetskolen.Domain
...
let epgHandler (getEpgForDate: DateTimeOffset -> Epg) (dateAsString: string) =
match (parseAsDateTime dateAsString) with
| Some date -> Results.Ok(date)
| None -> Results.BadRequest("Invalid date")
NĂ„ kan vi kalle getEpgForDate
med den validerte datoen for Ä fÄ alle sendingene for den gitte datoen slik som vist under:
let epgHandler (getEpgForDate: DateTimeOffset -> Epg) (dateAsString: string) =
match (parseAsDateTime dateAsString) with
| Some date ->
let epg = getEpgForDate date
Results.Ok(epg)
| None -> Results.BadRequest("Invalid date")
Det eneste som gjenstÄr i epgHandler
nÄ er Ä mappe fra domenemodellen til kontraktstypen vÄr, og returnere kontraktstypen som JSON.
Vi begynner med Ä mappe fra domenemodellen til kontraktstypen vÄr. Utvid Dto.fs
med en funksjon fromDomain
som tar inn et Epg
-objekt og returnerer et EpgDto
-objekt:
let fromDomain (domain: Domain.Epg) : EpgDto =
// Implementasjon her
âïž ImplementĂ©r fromDomain
-funksjonen.
đĄTips!
- For Ă„ konvertere en
DateTimeOffset
tilstring
pÄ riktig format, kan man brukeToString("o")
pÄ enDateTimeOffset
-verdi slik:let dateTimeOffsetAsString = myDateTimeOffset.ToString("o")
- Husk at
EpgDto
-typen har to felter: ett forNrk1
og ett forNrk2
, og at sendingene iEpg
-typen mÄ filtreres fÞr de settes i de to feltene. FunksjonenList.filter
kan brukes til Ă„ filtrere elementer i en liste.- Dersom man har en liste med sendinger for en gitt kanal, kan man bruke
List.map
til Ă„ mappe enSending
-verdi til enSendingDto
-verdi.
NĂ„ som vi har implementert fromDomain
-funksjonen kan vi bruke den i epgHandler
. Legg til fĂžlgende open
-statement, og bruk fromDomain
i epgHandler
i HttpHandlers.fs
slik:
open NRK.Dotnetskolen.Dto
...
let epgHandler (getEpgForDate: DateTimeOffset -> Epg) (dateAsString: string) =
match (parseAsDateTime dateAsString) with
| Some date ->
let epg = getEpgForDate date
let dto = fromDomain epg
Results.Ok(dto)
| None -> Results.BadRequest("Invalid date")
Skrevet med |>
-operatoren i F# ser epgHandler
-funksjonen slik ut:
let epgHandler (getEpgForDate: DateTimeOffset -> Epg) (dateAsString: string) =
match (parseAsDateTime dateAsString) with
| Some date ->
let response =
date
|> getEpgForDate
|> fromDomain
Results.Ok(response)
| None -> Results.BadRequest("Invalid date")
I steget hente EPG definerte vi at funksjonen epgHandler
hadde en avhengighet til en funksjon getEpgForDate: DateTimeOffset -> Epg
. Husk fra kapitlet om âdependency injectionâ at vi mĂ„ sĂžrge for at slike avhengigheter er tilfredsstilt nĂ„r vi kaller funksjonen.
epgHandler
-funksjonen blir kalt av MapGet
i createWebApplication
-funksjonen i Program.fs
i API-prosjektet. Dermed er det her vi mÄ sende inn implementasjonen av getEpgForDate
-funksjonen.
getEpgForDate
La oss begynne med Ă„ definere funksjonen getEpgForDate
i en ny fil Services.fs
:
...
src
âââ api
âââ NRK.Dotnetskolen.Api.fsproj
âââ Domain.fs
âââ Dto.fs
âââ HttpHandlers.fs
âââ Program.fs
âââ Services.fs
...
Husk Ă„ legg til Services.fs
i prosjektfilen til API-prosjektet:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net6.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Compile Include="Domain.fs" />
<Compile Include="Dto.fs" />
<Compile Include="Services.fs" />
<Compile Include="HttpHandlers.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
<Import Project="..\..\.paket\Paket.Restore.targets" />
</Project>
Legg til fĂžlgende kode i Services.fs
:
namespace NRK.Dotnetskolen.Api
module Services =
open System
open NRK.Dotnetskolen.Domain
let getEpgForDate (date : DateTimeOffset) : Epg =
[]
ForelĂžpig returnerer vi bare en tom liste slik at vi kan se hvordan vi kan benytte getEpgForDate
i epgHandler
.
Legg til fĂžlgende open
-statement i Program.fs
i API-prosjektet:
...
open NRK.Dotnetskolen.Api.Services
...
Send deretter inn getEpgForDate
fra NRK.Dotnetskolen.Api.Services
til epgHandler
i createWebApplication
-funksjonen i Program.fs
i API-prosjektet slik:
app.MapGet("/epg/{date}", Func<string, IResult>(fun date -> epgHandler getEpgForDate date)) |> ignore
KjÞr web-API-et med fÞlgende kommando, og gÄ til http://localhost:5000/epg/2021-04-23 for Ä se hva API-et returnerer.
dotnet run --project src/api/NRK.Dotnetskolen.Api.fsproj
La oss gÄ videre med Ä implementere getEpgForDate
i Services.fs
.
Oppgaven til getEpgForDate
er Ä filtrere sendinger pÄ den oppgitte datoen, men hvor skal den fÄ sendingene fra? PÄ tilsvarende mÄte som vi gjorde i epgHandler
-funksjonen i HttpHandlers
, kan vi her si at vi Ăžnsker Ă„ delegere ansvaret til Ă„ faktisk hente sendinger til noen andre. Dette kan vi gjĂžre ved Ă„ ta inn en funksjon getAlleSendinger: () -> Epg
i getEpgForDate
:
let getEpgForDate (getAlleSendinger : unit -> Epg) (date : DateTimeOffset) : Epg =
let alleSendinger = getAlleSendinger ()
âïž FullfĂžr implementasjonen for getEpgForDate
og sÞrg for at Epg-verdien som returneres kun har sendinger som starter pÄ den oppgitte datoen date
.
đĄTips!
List.filter
kan vĂŠre til hjelp for Ă„ filtrere sendingene fragetAlleSendinger
NĂ„ kan vi bestemme hvor vi skal hente sendinger fra. Skal vi hente dem fra en web-tjeneste, database, fil? getAlleSendinger
-funksjonen skjuler denne implementasjonsdetaljen fra resten av koden vÄr. For eksemplet vÄrt i dette kurset er det tilstrekkelig Ä definere sendinger i en egen fil DataAccess.fs
og implementere getAlleSendinger
der.
Opprett DataAccess.fs
i src/api
:
...
src
âââ api
âââ NRK.Dotnetskolen.Api.fsproj
âââ DataAccess.fs
âââ Domain.fs
âââ Dto.fs
âââ HttpHandlers.fs
âââ Program.fs
âââ Services.fs
...
Husk Ă„ legg til DataAccess.fs
i prosjektfilen til API-prosjektet:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net6.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Compile Include="Domain.fs" />
<Compile Include="DataAccess.fs" />
<Compile Include="Dto.fs" />
<Compile Include="Services.fs" />
<Compile Include="HttpHandlers.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
<Import Project="..\..\.paket\Paket.Restore.targets" />
</Project>
Vi later som at vi henter sendingene vÄre fra en database, og implementerer derfor egne typer som representerer hvordan sendingene og EPG-en er lagret i databasen:
namespace NRK.Dotnetskolen.Api
module DataAccess =
open System
type SendingEntity = {
Tittel: string
Kanal: string
Starttidspunkt: string
Sluttidspunkt: string
}
type EpgEntity = SendingEntity list
Deretter kan vi definere noen sendinger i en egen liste vi kaller database
:
let database =
[
{
Tittel = "Testprogram"
Kanal = "NRK1"
Starttidspunkt = "2021-04-12T13:00:00Z"
Sluttidspunkt = "2021-04-12T13:30:00Z"
}
{
Tittel = "Testprogram"
Kanal = "NRK2"
Starttidspunkt = "2021-04-12T14:00:00Z"
Sluttidspunkt = "2021-04-12T15:00:00Z"
}
{
Tittel = "Testprogram"
Kanal = "NRK3"
Starttidspunkt = "2021-04-12T14:00:00Z"
Sluttidspunkt = "2021-04-12T16:30:00Z"
}
]
NĂ„ kan vi implementere getAlleSendinger
-funksjonen ved Ă„ legge til fĂžlgende open
-statement, og funksjonen getAlleSendinger
pÄ slutten av DataAccess.fs
:
open NRK.Dotnetskolen.Domain
let getAlleSendinger () : Epg =
// Implementasjon her
Legg merke til at getAlleSendinger
-funksjonen skal returnere en verdi av typen Epg
fra Domain
-modulen.
âïž ImplementĂ©r getAlleSendinger
-funksjonen.
Tips: det kan vĂŠre lurt Ă„ skrive en eller flere funksjoner som mapper en
SendingEntity
-verdi tilSending
-verdi ogEpgEntity
-verdi tilEpg
-verdi. Husk i den forbindelse Ä validére omEpg
-verdien er gyldig i ettertid. Vi kan ikke garantere datakvaliteten til databasen.
Ettersom vi innfĂžrte getAlleSendinger
som en avhengighet til getEpgForDate
, mÄ vi endre createWebApplication
slik at getEpgForDate
fÄr inn denne avhengigheten.
Legg til fĂžlgende open
-statement, og utvid kallet til app.MapGet("/epg/{date}"
i createWebApplication
i Program.fs
i web-API-prosjektet slik:
open NRK.Dotnetskolen.Api.DataAccess
let createWebApplication (builder: WebApplicationBuilder) =
let app = builder.Build()
app.MapGet("/ping", Func<string>(fun () -> "pong")) |> ignore
app.MapGet("/epg/{date}", Func<string, IResult>(fun date -> epgHandler (getEpgForDate getAlleSendinger) date)) |> ignore
app
Merk at over har vi kalt getEpgForDate
med getAlleSendinger
, og fÄtt en ny funksjon i retur som tar inn en DateTimeOffset
og returnerer en Epg
-verdi. Det Ă„ sende inn et subsett av parameterene til en funksjon, og fĂ„ en funksjon i retur som tar inn de resterende parameterene kalles âpartial applicationâ. Du kan lese mer om âpartial applicationâ av funksjoner i F# her: https://docs.microsoft.com/en-us/dotnet/fsharp/language-reference/functions/#partial-application-of-arguments
KjÞr API-et med fÞlgende kommando, gÄ til http://localhost:5000/epg/2021-04-12, og se hva du fÄr i retur.
dotnet run --project src/api/NRK.Dotnetskolen.Api.fsproj
Et problem med integrasjonstestene vÄre slik de er nÄ er at vi ikke har kontroll pÄ avhengighetene til applikasjonen under kjÞringen av integrasjonstestene. Mer konkret brukte vi den faktiske dataaksessen til web-API-et da vi kjÞrte testene. I et faktisk system ville ikke dataene vÊre hardkodet i web-API-et, men heller lagret i den database eller liknende. For Ä slippe Ä vÊre avhengig av en database ved kjÞring av integrasjonstestene, kan vi endre hosten vi bruker i integrasjonstestene til Ä benytte et datalager vi spesifiserer i testene istedenfor Ä bruke det datalageret web-API-et er konfigurert til Ä bruke.
La oss implementere vÄr egen getAlleSendinger
-funksjon i integrasjonstestprosjektet, og fÄ API-et vÄrt til Ä bruke den istedenfor.
Opprett filen Mock.fs
i mappen /test/integration
:
...
test
âââ unit
âââ ...
âââ integration
âââ Mock.fs
âââ NRK.Dotnetskolen.IntegrationTests.fsproj
âââ Program.fs
âââ Tests.fs
âââ Dotnetskolen.sln
Husk Ă„ legg til Mock.fs
i prosjektfilen til integrasjonstestprosjektet:
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<IsPackable>false</IsPackable>
<GenerateProgramFile>false</GenerateProgramFile>
</PropertyGroup>
<ItemGroup>
<Compile Include="Mock.fs" />
<Compile Include="Tests.fs" />
<Compile Include="Program.fs" />
<Content Include="../../docs/epg.schema.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\api\NRK.Dotnetskolen.Api.fsproj" />
</ItemGroup>
<Import Project="..\..\.paket\Paket.Restore.targets" />
</Project>
Lim inn fĂžlgende kode i Mock.fs
hvor vi hardkoder noen sendinger som alltid har dagens dato:
namespace NRK.Dotnetskolen.IntegrationTests
module Mock =
open System
open NRK.Dotnetskolen.Domain
let getAlleSendinger () : Epg =
let now = DateTimeOffset.Now
[
// Sendinger tilbake i tid
{
Tittel = "Testprogram"
Kanal = "NRK1"
Starttidspunkt = now.AddDays(-10.)
Sluttidspunkt = now.AddDays(-10.).AddMinutes(30.)
}
{
Tittel = "Testprogram"
Kanal = "NRK2"
Starttidspunkt = now.AddDays(-10.)
Sluttidspunkt = now.AddDays(-10.).AddMinutes(30.)
}
// Sendinger i dag
{
Tittel = "Testprogram"
Kanal = "NRK1"
Starttidspunkt = now
Sluttidspunkt = now.AddMinutes(30.)
}
{
Tittel = "Testprogram"
Kanal = "NRK2"
Starttidspunkt = now
Sluttidspunkt = now.AddMinutes(30.)
}
// Sendinger frem i tid
{
Tittel = "Testprogram"
Kanal = "NRK1"
Starttidspunkt = now.AddDays(10.)
Sluttidspunkt = now.AddDays(10.).AddMinutes(30.)
}
{
Tittel = "Testprogram"
Kanal = "NRK2"
Starttidspunkt = now.AddDays(10.)
Sluttidspunkt = now.AddDays(10.).AddMinutes(30.)
}
]
NĂ„ har vi en egen implementasjon av getAlleSendinger
som vi Þnsker Ä bruke kun nÄr integrasjonstestene kjÞrer. Hvordan fÄr vi til det? La oss se nÞyere pÄ hvordan Program.fs
i API-prosjektet ser ut:
namespace NRK.Dotnetskolen.Api
module Program =
open System
open Microsoft.AspNetCore.Http
open Microsoft.AspNetCore.Builder
open NRK.Dotnetskolen.Api.Services
open NRK.Dotnetskolen.Api.DataAccess
open NRK.Dotnetskolen.Api.HttpHandlers
let createWebApplicationBuilder () =
WebApplication.CreateBuilder()
let createWebApplication (builder: WebApplicationBuilder) =
let app = builder.Build()
app.MapGet("/ping", Func<string>(fun () -> "pong")) |> ignore
app.MapGet("/epg/{date}", Func<string, IResult>(fun date -> epgHandler (getEpgForDate getAlleSendinger) date)) |> ignore
app
let builder = createWebApplicationBuilder()
let app = createWebApplication builder
app.Run()
Her ser vi at epgHandler
tar inn getEpgForDate
âpartially appliedâ med getAlleSendinger
som fĂžrste parameter. getEpgForDate
og getAlleSendinger
her er tatt fra hhv. Services
- og DataAccess
-modulene i API-prosjektet, men vi Þnsker Ä sende med egne implementasjoner av disse i integrasjonstestene slik at vi har kontroll pÄ avhengighetene til API-et under kjÞring av integrasjonstestene. Husk at runWithTestClient
-funksjonen i Tests.fs
i integrasjonstestprosjektet kaller createWebApplication
-funksjonen fra Program.fs
i API-prosjektet. Dersom vi hadde utvidet createWebApplication
-funksjonen til Ă„ ta inn getEpgForDate
som et eget parameter kunne vi sendt Ă©n implementasjon av funksjonen fra API-et, og en annen implementasjon fra integrasjonstestene. La oss gjĂžre det.
Legg til fĂžlgende open
-statement, og utvid createWebApplication
-funksjonen i Program.fs
i API-prosjektet med et parameter til getEpgForDate
, og send dette inn til epgHandler
slik:
open NRK.Dotnetskolen.Domain
let createWebApplication (builder: WebApplicationBuilder) (getEpgForDate: DateTimeOffset -> Epg) =
let app = builder.Build()
app.MapGet("/ping", Func<string>(fun () -> "pong")) |> ignore
app.MapGet("/epg/{date}", Func<string, IResult>(fun date -> epgHandler getEpgForDate date)) |> ignore
app
Send deretter getEpgForDate
fra Services
-modulen âpartially appliedâ med getAlleSendinger
fra DataAccess
-modulen inn som andre parameter til createWebApplication
, slik:
let app = createWebApplication builder (getEpgForDate getAlleSendinger)
Program.fs
i API-prosjektet skal nÄ se slik ut:
namespace NRK.Dotnetskolen.Api
module Program =
open System
open Microsoft.AspNetCore.Http
open Microsoft.AspNetCore.Builder
open NRK.Dotnetskolen.Domain
open NRK.Dotnetskolen.Api.Services
open NRK.Dotnetskolen.Api.DataAccess
open NRK.Dotnetskolen.Api.HttpHandlers
let createWebApplicationBuilder () =
WebApplication.CreateBuilder()
let createWebApplication (builder: WebApplicationBuilder) (getEpgForDate: DateTimeOffset -> Epg) =
let app = builder.Build()
app.MapGet("/ping", Func<string>(fun () -> "pong")) |> ignore
app.MapGet("/epg/{date}", Func<string, IResult>(fun date -> epgHandler getEpgForDate date)) |> ignore
app
let builder = createWebApplicationBuilder()
let app = createWebApplication builder (getEpgForDate getAlleSendinger)
app.Run()
NĂ„ som kan styre implementasjonen av getEpgForDate
fra utsiden av createWebApplication
-funksjonen kan vi lage en egen getEpgForDate
i integrasjonstestprosjektet som bruker mock-implementasjonen av getAlleSendinger
. Start med Ă„ Ă„pne Services
-modulen fra API-prosjektet, og Mock
-modulen fra integrasjonstestprosjektet i Tests.fs
i integrasjonstestprosjektet, slik:
open NRK.Dotnetskolen.Api.Services
open NRK.Dotnetskolen.IntegrationTests.Mock
Endre deretter kallet til createWebApplication
fra runWithTestClient
i Tests.fs
i integrasjonstestprosjektet til Ă„ sende med en âpartially appliedâ versjon av getEpgForDate
fra Services
med getAlleSendinger
fra Mock
-modulen slik:
use app = createWebApplication builder (getEpgForDate getAlleSendinger)
Hele runWithTestClient
-funksjonen skal nÄ se slik ut:
let runWithTestClient (test: HttpClient -> Task<unit>) =
task {
let builder = createWebApplicationBuilder()
builder.WebHost.UseTestServer() |> ignore
use app = createWebApplication builder (getEpgForDate getAlleSendinger)
do! app.StartAsync()
let testClient = app.GetTestClient()
do! test testClient
}
Dersom du kjĂžrer integrasjonstestene igjen, skal de fortsatt passere:
dotnet test test/integration/NRK.Dotnetskolen.IntegrationTests.fsproj
Passed! - Failed: 0, Passed: 4, Skipped: 0, Total: 4, Duration: 124 ms - NRK.Dotnetskolen.IntegrationTests.dll (net6.0)
Gratulerer! đ
Du har nÄ implementert et web-API i F#, med enhets- og integrasjonstester, API-dokumentasjon i OpenAPI, og gjort alt ved hjelp av .NET CLI.
Implementasjonen av domenemodellen slik vi gjorde det i steg 5 og steg 6 har en svakhet: det er ingen garanti for at verdier vi oppretter for Sending
og Epg
er gyldige. Det er kun epgEntityToDomain
-funksjonen i DataAccess.fs
som kaller isSendingValid
nÄr sendinger hentes. Det er ingen garanti for at alle opprettelser av Sending
- og Epg
-verdier kommer gjennom epgEntityToDomain
. I dette steget skal vi se hvordan vi kan endre domenemodellen vÄr slik at man ikke kan opprette Sending
- og Epg
-verdier uten at de er gyldige.
I steg 5 modellerte vi tittel og kanal som string
, og start- og sluttidspunktene som DateTimeOffset
. Utover at feltene har disse typene er det ingenting i Sending
-typen vÄr som sier hvilke regler som gjelder for dem. Det kan vi imidlertid gjÞre noe med.
La oss ta tittel som eksempel. Dersom vi oppretter en egen type for tittel Tittel
, og setter konstruktĂžren som private
er det ingen som kan opprette en Tittel
-verdi direkte. For Ă„ gjĂžre det mulig Ă„ opprette Tittel
-verdier kan vi lage en modul med samme navn som typen vÄr, Tittel
, med en create
-funksjon i. create
-funksjonen tar inn tittel som en string
, validerer om den er gyldig, og returnerer en Tittel option
avhengig av om tittelen er gyldig eller ikke. Dersom tittelen er gyldig returnerer create
-funksjonen Some (Tittel tittel)
, hvor tittel
er string
-verdien man sender inn til create
, Tittel
er konstruktĂžren til Tittel
-typen, og Some
er den ene konstruktĂžren til option
-verdier. Dersom tittelen imidlertid er ugyldig returnerer create
-funksjonen None
. PÄ tilsvarende mÄte som man er avhengig av create
-funksjonen for Ă„ opprette Tittel
-verdier, er vi ogsÄ avhengig av Ä ha en funksjon for Ä hente ut den indre verdien til en tittel, selve string
-verdien. Til det oppretter vi en value
-funksjon. La oss se hvordan det ser ut i kode.
Legg til koden under i Domain.fs
, mellom open
-statementene og type Sending
.
type Tittel = private Tittel of string
let isTittelValid (tittel: string) : bool =
let tittelRegex = Regex(@"^[\p{L}0-9\.,-:!]{5,100}$")
tittelRegex.IsMatch(tittel)
module Tittel =
let create (tittel: String) : Tittel option =
if isTittelValid tittel then
Tittel tittel
|> Some
else
None
let value (Tittel tittel) = tittel
Her ser vi at vi har definert tittel som en egen type Tittel
, som er en âsingle case unionâ-type med privat konstruktĂžr. Deretter har vi isTittelValid
-funksjonen slik vi definerte den i steg 6. Til slutt har vi Tittel
-modulen med create
- og value
-funksjonene.
Merk at
isTittelValid
-funksjonen over er den samme som tidligere, bare at den har byttet plass. Du kan fjerneisTittelValid
-funksjonen som tidligere var definert iDomain.fs
.
NĂ„ som vi har laget en egen type for tittel i en sending, kan vi ta den i bruk i Sending
-typen vÄr i Domain.fs
:
type Sending = {
Tittel: Tittel
Kanal: string
Starttidspunkt: DateTimeOffset
Sluttidspunkt: DateTimeOffset
}
Her ser vi at istedenfor Ă„ bruke string
for tittel, bruker vi den nye typen vi har opprettet, Tittel
.
Dersom vi forsÞker Ä kompilere API-prosjektet vÄrt nÄ, vil det feile fordi vi har endret typen til feltet Tittel
i Sending
-typen vÄr. La oss fikse kompileringsfeilene.
dotnet build ./src/api/NRK.Dotnetskolen.Api.fsproj
Build FAILED.
...
3 Error(s)
...
Det fĂžrste som feiler er isSendingValid
-funksjonen i Domain.fs
:
let isSendingValid (sending: Sending) : bool =
(isTittelValid sending.Tittel) &&
(isKanalValid sending.Kanal) &&
(areStartAndSluttidspunktValid sending.Starttidspunkt sending.Sluttidspunkt)
Her kaller vi isTittelvalid
med sending.Tittel
. Ettersom isTittelValid
tar inn et argument av typen string
, og sending.Tittel
nÄ har typen Tittel
feiler typesjekken. PĂ„ grunn av at vi har gjort konstruktĂžren til Tittel
privat, er den eneste mÄten Ä opprette en Tittel
-verdi pÄ ved Ä bruke create
-funksjonen i Tittel
-modulen. Siden create
-funksjonen kun returnerer en Tittel
-verdi dersom den oppgitte tittelen er gyldig, vet vi at Tittel
-feltet i en Sending
-verdi mÄ vÊre gyldig. Dermed kan vi fjerne sjekken pÄ om tittel er gyldig i isSendingValid
, slik:
let isSendingValid (sending: Sending) : bool =
(isKanalValid sending.Kanal) &&
(areStartAndSluttidspunktValid sending.Starttidspunkt sending.Sluttidspunkt)
Det neste som feiler er opprettelsen av en Sending
-verdi i DataAccess.fs
. Under er implementasjonen av funksjonen som mapper SendingEntity
til Sending
hentet fra lĂžsningsforslaget for kapittel 10.
let sendingEntityToDomain (sendingEntity: SendingEntity) : Sending =
{
Sending.Tittel = sendingEntity.Tittel
Kanal = sendingEntity.Kanal
Starttidspunkt = DateTimeOffset.Parse(sendingEntity.Starttidspunkt)
Sluttidspunkt = DateTimeOffset.Parse(sendingEntity.Sluttidspunkt)
}
Her forsĂžker vi Ă„ sette Sending.Tittel
direkte til Tittel
-feltet fra SendingEntity
-verdien. Siden Tittel
-feltet i SendingEntity
-typen er string
, og Sending.Tittel
er av typen Tittel
feiler typesjekken. For Ä fikse dette mÄ vi kalle Tittel.create
-funksjonen med SendingEntity
sin Tittel
som input, slik:
let sendingEntityToDomain (sendingEntity: SendingEntity) : Sending =
{
Sending.Tittel = (Tittel.create sendingEntity.Tittel).Value
Kanal = sendingEntity.Kanal
Starttidspunkt = DateTimeOffset.Parse(sendingEntity.Starttidspunkt)
Sluttidspunkt = DateTimeOffset.Parse(sendingEntity.Sluttidspunkt)
}
Ettersom Tittel.create
returnerer en Tittel option
, mÄ vi kalle .Value
-funksjonen pÄ returverdien av Tittel.create
for Ä fÄ ut Tittel
-verdien. Merk at dersom den oppgitte tittelen er ugyldig, vil kallet til .Value
kaste en System.NullReferenceException
.
Det neste som feiler er uthentingen av Tittel
-verdien fra Sending
i fromDomain
-funksjonen i Dto.fs
. Under er funksjonen fromDomain
vist slik den er implementert i lĂžsningsforslaget til steg 10.
let fromDomain (domain : Domain.Epg) : EpgDto =
let mapSendingerForKanal (kanal : string) =
domain
|> List.filter (fun s -> s.Kanal = kanal)
|> List.map (fun s -> {
Tittel = s.Tittel
Starttidspunkt = s.Starttidspunkt.ToString("o")
Sluttidspunkt = s.Sluttidspunkt.ToString("o")
})
{
Nrk1 = mapSendingerForKanal "NRK1"
Nrk2 = mapSendingerForKanal "NRK2"
}
Her forsĂžker vi Ă„ sette Tittel
-verdien til SendingDto
-typen til en Tittel
-verdi, men siden SendingDto.Tittel
er en string
feiler typesjekken. For Ă„ hente ut den indre string
-verdien til en Tittel
-verdi kan vi kalle Tittel.value
med Tittel
-verdien som input, slik:
let fromDomain (domain : Domain.Epg) : EpgDto =
let mapSendingerForKanal (kanal : string) =
domain
|> List.filter (fun s -> s.Kanal = kanal)
|> List.map (fun s -> {
Tittel = Domain.Tittel.value s.Tittel
Starttidspunkt = s.Starttidspunkt.ToString("o")
Sluttidspunkt = s.Sluttidspunkt.ToString("o")
})
{
Nrk1 = mapSendingerForKanal "NRK1"
Nrk2 = mapSendingerForKanal "NRK2"
}
Dersom du forsÞker Ä bygge API-prosjektet igjen nÄ, skal det lykkes:
dotnet build ./src/api/NRK.Dotnetskolen.Api.fsproj
Build succeeded.
...
Det gjenstÄr imidlertid Ä fikse kompileringsfeil i testprosjektene vÄre. Dersom du forsÞker Ä bygge lÞsningen, vil du se at kompilering av testprosjektene feiler:
dotnet build
Build FAILED.
...
8 Error(s)
...
Det fÞrste vi mÄ rette opp er opprettelsen av Sending
-verdier i Tests.fs
i enhetstestprosjektet. Her mÄ vi gjÞre det samme som vi gjorde i DataAccess.fs
, og kalle Tittel.create
-funksjonen for Ă„ opprette Tittel
-verdier i Sending
-typen.
âïž Fiks kompileringsfeilene i Tests.fs
i enhetstestprosjektet pÄ samme mÄte som vi gjorde for DataAccess.fs
.
Opprettelsen av Sending
-verdier i Mock.fs
i integrasjonstestprosjektet feiler av samme grunn som over.
âïž Fiks kompileringsfeilene pĂ„ samme mĂ„te.
NĂ„ som vi har sett hvordan vi kan implementere en egen type for Tittel
-feltet i Sending
-typen, kan vi gÄ videre til Ä fÞlge samme mÞnster for kanal ogsÄ.
âïž FĂžlg samme mĂžnster for kanal som vi gjorde for tittel. Husk fĂžlgende punkter:
Kanal
i Domain.fs
med privat konstruktĂžrKanal
i Domain.fs
med create
- og value
-funksjonerisKanalValid
-funksjonen mellom typen og modulen for Kanal
Kanal
-typen i Sending
-typen i Domain.fs
isSendingValid
-funksjonen i Domain.fs
Kanal
-verdier i sendingEntityToDomain
-funksjonen i DataAccess.fs
Kanal
-verdier i fromDomain
-funksjonen i Dto.fs
Sending
-verdier i Tests.fs
i enhetstestprosjektetSending
-verdier i Mock.fs
i integrasjonstestprosjektetVi kan fÞlge de samme prinsippene som for tittel og kanal for start- og sluttidspunkt ogsÄ, men ettersom man ikke kan si om start- og sluttidspunktene er gyldige med mindre man har begge to, mÄ vi lage en type som har begge feltene:
type Sendetidspunkt = private {
Starttidspunkt: DateTimeOffset
Sluttidspunkt: DateTimeOffset
}
let areStartAndSluttidspunktValid (starttidspunkt: DateTimeOffset) (sluttidspunkt: DateTimeOffset) =
starttidspunkt < sluttidspunkt
module Sendetidspunkt =
let create (starttidspunkt: DateTimeOffset) (sluttidspunkt: DateTimeOffset) : Sendetidspunkt option =
if areStartAndSluttidspunktValid starttidspunkt sluttidspunkt then
{
Starttidspunkt = starttidspunkt
Sluttidspunkt = sluttidspunkt
}
|> Some
else
None
let starttidspunkt (sendeTidspunkt: Sendetidspunkt) = sendeTidspunkt.Starttidspunkt
let sluttidspunkt (sendeTidspunkt: Sendetidspunkt) = sendeTidspunkt.Sluttidspunkt
Her har vi definert en samletype Sendetidspunkt
, som inneholder bÄde start- og sluttidspunkt. Legg merke til at create
-funksjonen tar inn begge disse, og bruker areStartAndSluttidspunktValid
-funksjonen til Ă„ undersĂžke om de er gyldige opp mot hverandre, fĂžr en Sendetidspunkt
-verdi opprettes. Merk at vi ikke har laget en value
-funksjon her, men istedenfor laget en starttidspunkt
- og en sluttidspunkt
-funksjon, som begge tar inn en Sendetidspunkt
-verdi, og returnerer den respektive verdien fra Sendetidspunkt
-verdien.
NĂ„ som vi har laget en egen type for start- og sluttidspunkt i en sending, kan vi ta dem i bruk i Sending
-typen vÄr:
type Sending = {
Tittel: Tittel
Kanal: Kanal
Sendetidspunkt: Sendetidspunkt
}
Her ser vi at vi bruker Sendetidspunkt
istedenfor DateTimeOffset
for start- og sluttidspunkt. Legg merke til at Sending
ikke har privat konstruktĂžr. Det er ikke nĂždvendig ettersom alle feltene i Sending
-typen mÄ opprettes gjennom deres create
-funksjoner. Dermed vil en Sending
-verdi alltid vĂŠre gyldig. Som en beleilighet for de som skal ta i bruk Sending
-typen kan vi likevel lage en create
-funksjon i en egen Sending
-modul, slik at man enklere kan lage en Sending
-verdi uten Ă„ kalle create
-funksjonene i modulen som korresponderer til typen til hvert felt.
module Sending =
let create (tittel: string) (kanal: string) (starttidspunkt: DateTimeOffset) (sluttidspunkt: DateTimeOffset) : Sending option =
let tittel = Tittel.create tittel
let kanal = Kanal.create kanal
let sendeTidspunkt = Sendetidspunkt.create starttidspunkt sluttidspunkt
if tittel.IsNone || kanal.IsNone || sendeTidspunkt.IsNone then
None
else
Some {
Tittel = tittel.Value
Kanal = kanal.Value
Sendetidspunkt = sendeTidspunkt.Value
}
Over ser vi Sending
-modulen med create
-funksjonen som tar inn verdier for alle feltene i en Sending
-verdi. create
-funksjonen til Sending
kaller create
-funksjonen til hver av typene som den bestÄr av, og returnerer en Sending
-verdi kun dersom alle verdiene ble vellykket opprettet.
For Ă„ oppsummere ser Domain.fs
nÄ slik ut:
namespace NRK.Dotnetskolen
module Domain =
open System
open System.Text.RegularExpressions
type Tittel = private Tittel of string
let isTittelValid (tittel: string) : bool =
let tittelRegex = Regex(@"^[\p{L}0-9\.,-:!]{5,100}$")
tittelRegex.IsMatch(tittel)
module Tittel =
let create (tittel: String) : Tittel option =
if isTittelValid tittel then
Tittel tittel
|> Some
else
None
let value (Tittel tittel) = tittel
type Kanal = private Kanal of string
let isKanalValid (kanal: string) : bool =
List.contains kanal ["NRK1"; "NRK2"]
module Kanal =
let create (kanal: string) : Kanal option =
if isKanalValid kanal then
Kanal kanal
|> Some
else
None
let value (Kanal kanal) = kanal
type Sendetidspunkt = private {
Starttidspunkt: DateTimeOffset
Sluttidspunkt: DateTimeOffset
}
let areStartAndSluttidspunktValid (starttidspunkt: DateTimeOffset) (sluttidspunkt: DateTimeOffset) =
starttidspunkt < sluttidspunkt
module Sendetidspunkt =
let create (starttidspunkt: DateTimeOffset) (sluttidspunkt: DateTimeOffset) : Sendetidspunkt option =
if areStartAndSluttidspunktValid starttidspunkt sluttidspunkt then
{
Starttidspunkt = starttidspunkt
Sluttidspunkt = sluttidspunkt
}
|> Some
else
None
let starttidspunkt (sendeTidspunkt: Sendetidspunkt) = sendeTidspunkt.Starttidspunkt
let sluttidspunkt (sendeTidspunkt: Sendetidspunkt) = sendeTidspunkt.Sluttidspunkt
type Sending = {
Tittel: Tittel
Kanal: Kanal
Sendetidspunkt: Sendetidspunkt
}
type Epg = Sending list
module Sending =
let create (tittel: string) (kanal: string) (starttidspunkt: DateTimeOffset) (sluttidspunkt: DateTimeOffset) : Sending option =
let tittel = Tittel.create tittel
let kanal = Kanal.create kanal
let sendeTidspunkt = Sendetidspunkt.create starttidspunkt sluttidspunkt
if tittel.IsNone || kanal.IsNone || sendeTidspunkt.IsNone then
None
else
Some {
Tittel = tittel.Value
Kanal = kanal.Value
Sendetidspunkt = sendeTidspunkt.Value
}
Legg merke til at isSendingValid
-funksjonen er fjernet, ettersom Sending.create
-funksjonen har overtatt dens ansvar.
Dersom du forsÞker Ä bygge lÞsningen nÄ, vil du se at det feiler:
dotnet build
Build FAILED.
...
La oss starte med sendingEntityToDomain
-funksjonen i DataAccess.fs
:
let sendingEntityToDomain (sendingEntity: SendingEntity) : Sending =
{
Sending.Tittel = (Tittel.create s.Tittel).Value
Kanal = sendingEntity.Kanal
Starttidspunkt = DateTimeOffset.Parse(sendingEntity.Starttidspunkt)
Sluttidspunkt = DateTimeOffset.Parse(sendingEntity.Sluttidspunkt)
}
Her forsĂžker vi Ă„ sette Starttidspunkt
og Sluttidspunkt
direkte, men disse er nÄ flyttet inn i feltet Sendetidspunkt
. Vi kunne ha brukt Sendetidspunkt.create
-funksjonen til Ä lÞse det pÄ tilsvarende vis som for Tittel
og Kanal
, men ettersom vi har innfĂžrt Sending.create
-funksjonen som kaller create
-funksjonen for alle de nye typene for oss, kan vi heller bruke den, slik:
let sendingEntityToDomain (sendingEntity: SendingEntity) : Sending option =
Sending.create sendingEntity.Tittel sendingEntity.Kanal (DateTimeOffset.Parse(sendingEntity.Starttidspunkt)) (DateTimeOffset.Parse(sendingEntity.Sluttidspunkt))
let epgEntityToDomain (epgEntity: EpgEntity) : Epg =
epgEntity
|> List.map sendingEntityToDomain
|> List.filter (fun s -> s.IsSome)
|> List.map (fun s -> s.Value)
Over kaller vi sendingEntityToDomain
for hver sending i EpgEntity
som vi fÄr inn til epgEntityToDomain
. sendingEntityToDomain
kaller igjen pÄ Sending.create
. Husk at Sending.create
-funksjonen returnerer en Sending option
, sÄ sendingEntityToDomain
vil returnere None
for ugyldige SendingEntity
-verdier. For Ă„ filtrere bort disse kan vi kalle List.filter (fun e -> e.IsSome)
etterfulgt av List.map (fun s -> s.Value)
for Ă„ hente ut selve Sending
-verdien fra Sending option
. Alternativt kan man kalle List.choose id
slik:
let epgEntityToDomain (epgEntity: EpgEntity) : Epg =
epgEntity
|> List.map sendingEntityToDomain
|> List.choose id
List.choose
tar inn en funksjonf
, og returnerer en liste med de interne verdiene til innslagene i listen hvorf
returnererSome
.ĂŹd
er en innebygd funksjon i F# som returnerer det den fÄr inn. Ved Ä kombinereList.choose
medid
-funksjonen oppnÄr vi det samme som vi gjorde medList.filter (fun s -> s.IsSome)
ogList.map (fun s -> s.Value)
etter hverandre.
Legg ogsÄ merke til at i koden over fjernet vi List.filter (fun d -> isSendingValid d)
, og pÄ den mÄten flyttet ansvaret for Ä validere en Sending
-verdi fra sendingEntityToDomain
-funksjonen i DataAccess.fs
til Sending.create
-funksjonen i Domain.fs
.
fromDomain
-funksjonen i Dto.fs
feiler ogsÄ ettersom den ikke fÄr hentet ut verdiene til Starttidspunkt
og Sluttidspunkt
i en Sending
-verdi slik den forventer.
let fromDomain (domain : Domain.Epg) : EpgDto =
let mapSendingerForKanal (kanal : string) =
domain
|> List.filter (fun s -> (Domain.Kanal.value s.Kanal) = kanal)
|> List.map (fun s -> {
Tittel = Domain.Tittel.value s.Tittel
Starttidspunkt = s.Starttidspunkt.ToString("o")
Sluttidspunkt = s.Sluttidspunkt.ToString("o")
})
{
Nrk1 = mapSendingerForKanal "NRK1"
Nrk2 = mapSendingerForKanal "NRK2"
}
Start- og sluttidspunkt er nÄ lagret i en samletype Sendetidspunkt
, sÄ uthentingen av start- og sluttidspunkt vil ikke fungere. Vi kan imidlertid bruke funksjonene vi definerte tidligere i dette steget til Ä hente ut de indre verdiene til Sendetidspunkt
slik:
open Domain
...
let fromDomain (domain: Domain.Epg): EpgDto =
let mapSendingerForKanal (kanal: string) =
domain
|> List.filter (fun s -> Kanal.value s.Kanal = kanal)
|> List.map (fun s ->
{ Tittel = Tittel.value s.Tittel
Starttidspunkt = (Sendetidspunkt.starttidspunkt s.Sendetidspunkt).ToString("o")
Sluttidspunkt = (Sendetidspunkt.sluttidspunkt s.Sendetidspunkt).ToString("o") })
{ Nrk1 = mapSendingerForKanal "NRK1"
Nrk2 = mapSendingerForKanal "NRK2" }
Vi henter start- og sluttidspunkt ved Ă„ kalle hhv. Sendetidspunkt.starttidspunkt
og Sendetidspunkt.sluttidspunkt
med s.Sendetidspunkt
som input.
I getEpgForDate
-funksjonen i Services.fs
filtrerer vi sendinger basert pÄ dato:
let getEpgForDate (getAlleSendinger : unit -> Epg) (date : DateTimeOffset) : Epg =
getAlleSendinger ()
|> List.filter (fun s -> s.Starttidspunkt.Date.Date = date.Date)
Ettersom vi har innfÞrt en ny mÄte Ä hente ut starttidspunkt fra en sending pÄ, mÄ vi oppdatere getEpgForDate
til Ă„ reflektere dette:
let getEpgForDate (getAlleSendinger : unit -> Epg) (date : DateTimeOffset) : Epg =
getAlleSendinger ()
|> List.filter (fun s -> (Sendetidspunkt.starttidspunkt s.Sendetidspunkt).Date.Date = date.Date)
Istedenfor Ă„ hente starttidspunktet direkte, kaller vi Sendetidspunkt.starttidspunkt
med s.Sendetidspunkt
som input.
I enhetstestprosjektet har vi tester for funksjonen isSendingValid
som vi hadde i Domain.fs
. Ettersom Sending.create
-funksjonen har tatt over ansvaret til isSendingValid
mÄ vi skrive om testene til Ä bruke Sending.create
-funksjonen istedenfor:
[<Fact>]
let ``Sending.create valid sending returns Some`` () =
let now = DateTimeOffset.Now
let sending = Sending.create "Dagsrevyen" "NRK1" now (now.AddMinutes 30.)
match sending with
| Some t ->
Assert.Equal("Dagsrevyen", Tittel.value t.Tittel)
Assert.Equal("NRK1", Kanal.value t.Kanal)
Assert.Equal(now, Sendetidspunkt.starttidspunkt t.Sendetidspunkt)
Assert.Equal(now.AddMinutes 30., Sendetidspunkt.sluttidspunkt t.Sendetidspunkt)
| None -> Assert.True false
[<Fact>]
let ``Sending.create invalid sending returns None`` () =
let now = DateTimeOffset.Now
let sending = Sending.create "@$%&/" "nrk3" now (now.AddMinutes 30.)
Assert.True sending.IsNone
I Mock
-modulen i integrasjonstestprosjektet opprettet vi Sending
-verdier for Ä ha kontroll pÄ dataaksessen under integrasjonstestene. NÄ som vi har en egen funksjon for Ä opprette Sending
-verdier, Sending.create
, kan vi bruke den istedenfor Ă„ opprette Sending
-verdier direkte, slik:
let getAlleSendinger () : Epg =
let now = DateTimeOffset.Now
let past = now.AddDays(-10.)
let future = now.AddDays(10.)
[
// Sendinger tilbake i tid
(Sending.create "Testprogram" "NRK1" past (past.AddMinutes(30.))).Value
(Sending.create "Testprogram" "NRK2" past (past.AddMinutes(30.))).Value
// Sendinger i dag
(Sending.create "Testprogram" "NRK1" now (now.AddMinutes(30.))).Value
(Sending.create "Testprogram" "NRK2" now (now.AddMinutes(30.))).Value
// Sendinger frem i tid
(Sending.create "Testprogram" "NRK1" future (future.AddMinutes(30.))).Value
(Sending.create "Testprogram" "NRK2" future (future.AddMinutes(30.))).Value
]
I steg 7 innfÞrte vi OpenAPI-kontrakt for API-et vÄrt, og la den i mappen /docs
. ForelÞpig er dokumentasjonen kun tilgjengelig for de som har tilgang til repoet til koden. For at de som skal integrere med API-et skal kunne se kontrakten, er det fint om den er publisert et sted. I dette steget skal vi se hvordan vi kan tilgjengeliggjÞre OpenAPI-kontrakten som en egen nettside i API-et ved hjelp av ReDoc. Med ReDoc kan vi kopiere en HTML-side fra dokumentasjonen deres og lime inn en referanse til OpenAPI-dokumentasjonen vÄr, sÄ fÄr vi en fin grafisk fremstilling av API-et vÄrt, som vist under:
Kort oppsummert er dette stegene vi skal gjÞre for Ä lage en egen ReDoc-side i API-et vÄrt:
docs/epg.schema.json
og docs/openapi.json
til src/api/wwwroot/documentation
openapi.html
i src/api/wwwroot
med innhold fra dokumentasjonen til ReDoc, og endre referansen til OpenAPI-dokumentet i openapi.html
I steg 7 la vi dokumentasjonen til API-et vÄrt i docs
-mappen. Ettersom vi nÄ skal eksponere den pÄ internett gjennom API-et vÄrt, mÄ vi legge den et sted som er tilgjengelig for webserveren. Opprett derfor en ny mappe wwwroot
med en ny mappe documentation
i src/api
slik:
...
âââ docs
âââ epg.schema.json
âââ openapi.json
âââ src
âââ api
âââ wwwroot
âââ documentation
...
Flytt deretter filene epg.schema.json
og openapi.json
fra docs
til src/api/wwwroot/documentation
:
...
âââ docs
âââ src
âââ api
âââ wwwroot
âââ documentation
âââ epg.schema.json
âââ openapi.json
...
Til slutt kan du slette mappen docs
:
...
âââ src
âââ api
âââ wwwroot
âââ documentation
âââ epg.schema.json
âââ openapi.json
...
I steg 9 la vi til en referanse til epg.schema.json
i prosjektfilen til integrasjonstestprosjektet. Siden vi har flyttet denne filen, mĂ„ vi oppdatere referansen. Ă
pne filen test/integration/NRK.Dotnetskolen.IntegrationTests.fsproj
, og endre referansen til JSON Schemaet:
...
<Content Include="../../src/api/wwwroot/documentation/epg.schema.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
...
Verifiser at integrasjonstestene kjĂžrer med fĂžlgende kommando:
dotnet test ./test/integration/NRK.Dotnetskolen.IntegrationTests.fsproj
Opprett filen openapi.html
i mappen src/api/wwwroot
, slik:
...
âââ src
âââ api
âââ wwwroot
âââ documentation
âââ epg.schema.json
âââ openapi.json
âââ openapi.html
...
Ă
pne openapi.html
, og lim inn innholdet vist i dokumentasjonen til ReDoc, slik:
<!DOCTYPE html>
<html>
<head>
<title>Redoc</title>
<!-- needed for adaptive design -->
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet">
<!--
Redoc doesn't change outer page styles
-->
<style>
body {
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<redoc spec-url='http://petstore.swagger.io/v2/swagger.json'></redoc>
<script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"> </script>
</body>
</html>
Legg merke til at linjen som starter med <redoc spec-url=
(nesten helt nederst i filen) refererer til en eksempel-dokumentasjon http://petstore.swagger.io/v2/swagger.json
. Denne skal vi nÄ endre til vÄr egen dokumentasjon. Endre spec-url
i denne linja til /documentation/openapi.json
, slik:
...
<redoc spec-url='/documentation/openapi.json'></redoc>
...
I utgangspunktet kan ikke web-applikasjonen slik vi har konfigurert den nÄ servere statiske filer (slik som openapi.html
som nettopp opprettet). For Ă„ kunne serve statiske filer mĂ„ vi legge til en egen âmiddlewareâ i âmiddleware pipelinenâ til web-API-et vĂ„rt som gjĂžr akkurat dette. For Ă„ legge til denne âmiddlewarenâ kaller vi app.UseStaticFiles()
pÄ WebApplication
-objektet som vi oppretter i createWebApplication
-funksjonen i Program.fs
i API-prosjektet vÄrt, slik:
let createWebApplication (builder: WebApplicationBuilder) (getEpgForDate: DateTimeOffset -> Epg) =
let app = builder.Build()
app.UseStaticFiles() |> ignore
app.MapGet("/ping", Func<string>(fun () -> "pong")) |> ignore
app.MapGet("/epg/{date}", Func<string, IResult>(fun date -> epgHandler getEpgForDate date)) |> ignore
app
Her kaller vi UseStaticFiles
-funksjonen, som sĂžrger for at statiske filer blir servet av webserveren. Som default konfigureres serveren til Ă„ se etter statiske filer i wwwroot
-mappen. Legg merke til at vi kaller UseStaticFiles
fĂžr MapGet
-funksjonene. Siden middlewares i .NET prosesserer innkommende forespĂžrsler i den rekkefĂžlgen de blir lagt til i âmiddleware pipelinenâ, legger vi til serving av statiske filer fĂžr hĂ„ndtering av andre HTTP-forespĂžrsler, slik at dersom det finnes en statisk fil identifisert av path-en i HTTP-forespĂžrselen returnerer vi den istedenfor Ă„ gĂ„ videre med Ă„ evaluere endepunktene vi har satt opp.
Dersom du nÄ starter web-API-et med dotnet run --project src/api/NRK.Dotnetskolen.Api.fsproj
, og Ă„pner http://localhost:5000/openapi.html skal du se noe liknende som skjermbildet under: