dotnetskolen

đŸ« .NET-skolen

👋 Innledning

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.

đŸ’» FremgangsmĂ„te

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.

🚀 Kom i gang

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:

📍 Alternative startpunkter

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.

Oppsett av prosjekter, solution og pakkehÄndtering med .NET CLI

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:

Domenemodellering og enhetstester

Vil du lĂŠre mer om domenemodellering i F# og tilhĂžrende enhetstester, kan fĂžlge disse stegene:

API-kontrakter

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:

.NET 6 og minimal API

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:

Tilleggsoppgaver

Til slutt finnes det noen ekstraoppgaver, hvis du vil ha mer Ä bryne deg pÄ:

❓ SpĂžrsmĂ„l

Lurer du pĂ„ noe knyttet til kurset? Opprett gjerne en trĂ„d under “Discussions” i dette repoet:

💡 Tips og triks

Nyttige tips og triks finner du her

🔗 Nyttige lenker

👍👎 Tilbakemeldinger

Har du tilbakemeldinger til kurset? Opprett gjerne en trÄd for det her:

đŸ‘©đŸ‘š Medvirkende

🙌 Takk

📝 Lisens

All dokumentasjon (inkludert denne veiledningen) og kildekoden i dette repoet er Ă„pent tilgjengelig under MIT-lisensen.

📖 Innholdsfortegnelse

Steg

NÄ som du har installert alle verktÞyene du trenger er du klar til Ä begynne pÄ selve kurset!

Steg 1 - Opprette API

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.

.NET-versjon

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

.NET-prosjekter

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:

Dotnet new

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.

Maler

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.

Opprette API-prosjektet

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.

Prosjektfil

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

Programfilen

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

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

KjĂžre API-prosjektet

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#

Lagre endringer i Git (valgfritt)

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.

Se endringer

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”).

Legg til endringer i Git

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.

Lagre endringene

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
Se alle historiske endringer i repoet

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.

Se lĂžsningsforslag

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 - Opprette testprosjekter

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.

Dotnet new

I dette steget skal vi opprette to testprosjekter

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

Opprette enhetstestprosjekt

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
Prosjektfil

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

Vi ser nÊrmere pÄ hva NuGet-pakker er i steg 4.

Testfilen

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

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.

KjĂžre enhetstestprosjektet

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)

Opprette integrasjonstestprosjekt

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.

KjĂžre integrasjonstester

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 - Opprette solution

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.

Dotnet sln

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

Legge til prosjekter i solution

For Ă„ legge til referanser til prosjektene du har opprettet kan du kjĂžre fĂžlgende kommandoer

Legge til API-prosjekt
dotnet sln add src/api/NRK.Dotnetskolen.Api.fsproj
Project `src\api\NRK.Dotnetskolen.Api.fsproj` added to the solution.
Legge til enhetstestprosjekt
dotnet sln add test/unit/NRK.Dotnetskolen.UnitTests.fsproj
Project `test\unit\NRK.Dotnetskolen.UnitTests.fsproj` added to the solution.
Legge til integrasjonstestprosjekt
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

Solution i Visual Studio

Bildet under viser hvordan “Solution explorer” i Visual Studio viser lþsningen.

Solution explorer i Visual Studio

Steg 4 - PakkehÄndtering

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.

NuGet og Paket

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:

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.

Kilder

Sette opp Paket

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.

Opprette dotnet tool manifest

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
Legge til Paket som tool i dotnet

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"
      ]
    }
  }
}
Installere 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-filer

Paket bruker fÞlgende filer for Ä holde styr pÄ pakkene i en lÞsning:

Se forĂžvrig https://fsprojects.github.io/Paket/faq.html#What-files-should-I-commit for hvilke filer du skal inkludere i Git.

Migrere pakker fra NuGet til Paket

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 Ä bruke console-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 - Definere domenemodell

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:

Domenemodell i F#

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:

Vi Ä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 i ModulA.fs og modul B er definert i ModulB.fs, og modul A skal kunne Äpne modul B mÄ ModulB.fs ligge fÞr ModulA.fs i prosjektfilen.

Moduler i F# blir kompilert til det samme i CIL som statiske klasser i C#.

Opprette en EPG

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 - Enhetstester for domenemodell

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.

Regler i domenet vÄrt

Vi Þnsker Ä verifisere fÞlgende regler fra domenet vÄrt:

Tittel

La oss begynne med Ă„ verifisere at vi implementerer valideringsreglene for tittel riktig.

Enhetstester

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]
Implementere isTittelValid

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

I tillegg spesifiserer {5,100} at vi tillater 5-100 av tegnene i gruppen over.

Legge til prosjektreferanse

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>
Åpne modul

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

Kanal

Reglene for kanal er ganske enkle ettersom det kun er to gyldige kanaler, og disse kun kan skrives med store bokstaver.

Enhetstester

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
Implementasjon av 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)

Sendetidspunkter

Det siste vi skal validere i domenet vÄrt er at sluttidspunkt er etter starttidspunkt.

Enhetstester

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 benytter DateTimeOffset-objekter (som ikke er konstante ved kompilering) som input til areStartAndSluttidspunktValid, bruker vi derfor [<Fact>]-attributtet.

Implementasjon av areStartAndSluttidspunktValid

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)

Validere en sending

NĂ„ som vi har funksjoner for Ă„ validere de ulike feltene i en sending, kan vi lage en funksjon som validerer en hel sending.

Enhetstester

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

Implementasjon av isSendingValid

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 implementert isSendingValid, men det er ingenting som hindrer oss i Ä opprette en Sending-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 - Definere API-kontrakt

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/).

Operasjoner

For Ä begrense omfanget av API-et vÄrt skal vi ha kun én operasjon i det:

Responser

Responsen til denne operasjonen vil bestÄ av to lister med sendinger, én for hver kanal i domenet vÄrt, hvor hver sending har:

JSON Schema

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

OpenAPI-kontrakt

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:

NĂ„ 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 som ReDoc (brukt i steg 12) WebGUI og linting. Takk til @laat som poengterte det.

Grafisk fremstilling av Open-API-kontrakten

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 og openapi.json.

Steg 8 - Implementere kontraktstyper

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.

  1. For det fÞrste kan strukturen til typene i API-et vÊre annerledes enn i domenemodellen. Dette ser vi i vÄrt tilfelle hvor domenemodellen har alle sendinger, pÄ tvers av kanaler, i én liste, mens API-kontrakten har én liste med sendinger per kanal.
  2. I tillegg er vi begrenset til Ä representere data med tekst i API-et ettersom HTTP er en tekstbasert protokoll. For eksempel benytter vi en 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 - Sette opp skall for API

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.

Prosjekttyper

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

Modellen til .NET

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.

Host

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().

KjĂžre host

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

Du 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

Middleware pipeline

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.

KjĂžre web host

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

Ping

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:

  1. En tekststreng som spesifiserer hvilken sti i URL-en som leder til denne funksjonen. I dette tilfellet ping.
  2. En funksjon uten parametere som returnerer en tekststreng. I dette tilfellet pong.

Merk at som andre parameter til MapGet har vi oppgitt Func<string>(fun () -> "pong") som strengt tatt ikke er en funksjon. Func er .NET sin mĂ„te Ă„ opprette et Delegate 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 tar MapGet strengt tatt inn et Delegate-objekt som andre parameter, og mĂ„ten man oppretter et Delegate-objekt i F# pĂ„ er ved Ă„ kalle Func sin konstruktĂžr. I konstruktĂžren til Func sender vi inn den anonyme F#-funksjonen fun () -> "pong". <string> delen av Func<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 i Func<string> for det. Dersom den anonyme funksjonen hadde tatt inn et parameter av typen int, hadde kallet til Func 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/delegates

Du kan lese mer om “minimal APIs” her: https://docs.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis?view=aspnetcore-6.0

KjĂžre API-et

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.

Integrasjonstester

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

  1. KjÞre web-API-et vÄrt pÄ en webserver som kjÞrer i minnet under testen, en sÄkalt TestServer.
  2. Sende forespĂžrsler mot denne testserveren
  3. Verifisere at testserveren svarer med de verdiene vi forventer

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.

Legge til avhengigheter

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.

Microsoft.AspNetCore.Mvc.Testing

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
Referanse til API-prosjektet

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
KlargjĂžre API-et for testing
WebApplicationBuilder

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.

WebApplication

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.

Namespace og modul

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 modulen Program i kontekst av NRK.Dotnetskolen.Api, og gjÞr at nÄr vi skal referere til funksjonene createWebApplicationBuilder og createWebApplication mÄ vi Äpne NRK.Dotnetskolen.Api.Program.

Test for ping

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.

Definere modul

FĂžrst definerer vi en modul som heter Tests:

module Tests
Åpne namespaces

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
Funksjon for Ă„ kalle test med test-HTTP-klient

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 et task “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 og task. async kom fĂžrst, og er hittil det mest vanlige Ă„ bruke, mens task kom i F# 6 inkludert i .NET 6. Du kan lese mer om “computation expressions”, async og task her:

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.

Definere test

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! istedenfor let fÞr httpClient.GetStringAsync(/ping"). Ved Ä bruke let! venter vi pÄ at den asynkrone handlingen pÄ hÞyresiden av = (httpClient.GetStringAsync("/ping")) returnerer fÞr vi gÄr videre.

KjĂžr tester

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 - Implementere web-API

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.

Test 1 - Verifisere at endepunktet finnes

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().

Se at testen feiler

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.

Definere route fra API-kontrakt

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:

  1. URL-en /epg/{dato}
  2. At den er tilgjengelig gjennom HTTP GET-verbet

Dette 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 typen string, og returnerer en verdi av typen string.

KjĂžre API-et

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
Se at testen passerer

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)

Test 2 - Verifisere at dato valideres

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.

Se at testen feiler

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.

Implementere HTTP Handler for /epg/{dato}

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:

  1. Validere datoen som er oppgitt i URL-en, og returnere 400 Bad Request dersom den er ugyldig
  2. Hente sendinger for den oppgitte datoen
  3. Returnere EPG pÄ JSON-format som oppfyller API-kontrakten vÄr
Flytte HttpHandler til egen modul

La 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
Validere dato

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
KjĂžre API-et

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.

Se at testen passerer

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)

Test 3 - Verifisere format pÄ EPG-respons

I den siste testen skal vi verifisere at responsen API-et gir fÞlger formatet vi har spesifisert i OpenAPI-kontrakten vÄr.

JsonSchema.Net

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
JSON Schema for API-kontrakt

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:

Se at testen feiler

KjĂž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.

Dependency injection

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

Hente EPG

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")
Returnere JSON som oppfyller API-kontrakten

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!

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")
Implementere avhengigheter

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.

Implementere 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!

Implementere getAlleSendinger

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 til Sending-verdi og EpgEntity-verdi til Epg-verdi. Husk i den forbindelse Ä validére om Epg-verdien er gyldig i ettertid. Vi kan ikke garantere datakvaliteten til databasen.

Registrere avhengigheter

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

Benytte egne avhengigheter i integrasjonstester

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.

Implementere mock av getAlleSendinger

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.)
            }
        ]
Benytte mock av getAlleSendinger

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.

Ekstraoppgaver

Steg 11 - FĂžlge prinsipper i domenedrevet design

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.

Tittel

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.

Opprette egen type

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 fjerne isTittelValid-funksjonen som tidligere var definert i Domain.fs.

Oppdatere sending

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.

Fikse kompileringsfeil

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.

Kanal

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:

Start- og sluttidspunkt

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

Bruke Sendetidspunkt i Sending

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.

Fikse sendingEntityToDomain

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 funksjon f, og returnerer en liste med de interne verdiene til innslagene i listen hvor f returnerer Some. Ïd er en innebygd funksjon i F# som returnerer det den fÄr inn. Ved Ä kombinere List.choose med id-funksjonen oppnÄr vi det samme som vi gjorde med List.filter (fun s -> s.IsSome) og List.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.

Fikse fromDomain

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.

Fikse getEpgForDate

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.

Fikse enhetstester

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
Fikse integrasjonstester

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
  ]

Steg 12 - Grafisk fremstilling av OpenAPI-dokumentasjon

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:

redoc

Kort oppsummert er dette stegene vi skal gjÞre for Ä lage en egen ReDoc-side i API-et vÄrt:

  1. Flytte docs/epg.schema.json og docs/openapi.json til src/api/wwwroot/documentation
  2. Opprette HTML-fil openapi.html i src/api/wwwroot med innhold fra dokumentasjonen til ReDoc, og endre referansen til OpenAPI-dokumentet i openapi.html
  3. Konfigurere web-API-et til Ă„ serve statiske filer
Flytte API-dokumentasjon

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
Opprette HTML-fil

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>
...
Serve statiske filer

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.

Se dokumentasjonen

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:

redoc