CSharp Rest API
Introduction
This is a quick refresher for C# Rest API
Installation
I am now on SDK 8.0 and found issues with the setup. See CSharp_VSCode for Ubuntu 24.04 (Basically use apt)
Setting up Certificates
The next thing is run the project with F5. This warns you that you are not https. You fix this with the commands below but make sure you restart chrome.
dotnet dev-certs https --clean
dotnet dev-certs https --check --trust
Gosh I dislike the Microsoft approach as this failed for me. I found you can add --verbose to the commands e.g.
dotnet dev-certs https --clean --verbose
This told me the certutil was not installed. Like I wouldn't want to know that on failure
sudo apt install libnss3-tools
Next now that we can clean successfully to have
An error occurred while listing the certificates: System.Security.Cryptography.CryptographicException: Unix LocalMachine X509Store is limited to the Root and CertificateAuthority stores.
This turned out not to be a problem and no further action was taken
Create new Project
Basic Project
This is painless and can be done with
dotnet new webapi -n Catalog
Two Projects
For me, it was required to have two project. One project for the project and one for the unit tests
Launch
For me this caused problems because the appsettings.json was not in the the expected place. It also meant the Properties were not either. To fix this I used this launch.json in the root directory
{
"version": "0.2.0",
"configurations": [
{
"name": ".NET Core Launch (web)",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/Catalog.Api/bin/Debug/net8.0/Catalog.Api.dll",
"args": [],
"cwd": "${workspaceFolder}/Catalog.Api",
"stopAtEntry": false,
"serverReadyAction": {
"action": "openExternally",
"pattern": "\\bNow listening on:\\s+(https?://\\S+)"
},
"env": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"sourceFileMap": {
"/Views": "${workspaceFolder}/Views"
},
"launchSettingsProfile": "https"
},
{
"name": ".NET Core Attach",
"type": "coreclr",
"request": "attach"
},
{
"name": "Docker .NET Launch",
"type": "docker",
"request": "launch",
"preLaunchTask": "docker-run: debug",
"netCore": {
"appProject": "${workspaceFolder}/Catalog.Api.csproj"
}
}
]
}
Launch Settings
Note the reference to launchSettingsProfile which expects the file to be in the Properties directory of the working directory. Here was mine at the time.
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:25478",
"sslPort": 44378
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "http://localhost:5193",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "https://localhost:7168;http://localhost:5193",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
Building
In the root directory we have the build.proj. I cannot remember what went wrong at the time but pretty sure it took me a while to solve and issue with this so here it is
<Project Sdk="Microsoft.Build.Traversal/3.0.0"><PropertyGroup> <UserSecretsId>cfe2b025-33ab-4dc2-a72a-0e79c417ce17</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="**/*.*proj" />
</ItemGroup>
</Project>
AppSettings
Defining AppSettings
We all need to configure and for Microsoft they use the appsettings.json. This file must be in the root of the working directory to work. This should be copied as part of the build task. Here is a example of a multi content app settings. For me, I had maybe an unusual set up as I had two projects in one folder. A shown above I need to change the working directory in the launch.json to append the project name in the cwd. If you find your settings are null there a two key reasons
- You mispelt one of the Settings in either the file or the Code
- The cwd is not the folder for the project and there the appsettings.json is not found. Make sure cwd is the root of your project
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"MongoDbSettings": {
"Host": "localhost",
"Port": 27017,
"DatabaseName": "CatalogDb",
"User": "mongoadmin",
"Password": "MOVED TO USER SECRETS"
}
}
To make this work you make a class to hold the values you want to hold. It must match the names you have in the config. As mentioned this is a multi content to we are only going to do MongoDbSettings
namespace Catalog.Api.Settings;
public class MongoDbSettings
{
public required string Host { get; set; }
public required int Port { get; set; }
public required string User { get; set; }
public required string Db { get; set; }
public required string Password { get; set; }
public string ConnectionString => $"mongodb://{User}:{Password}@{Host}:{Port}{Db}?authSource=admin";
}
Finally you can have more settings in your class than in the appsettings.json just not the other way around. You can see that Password is mentioned but not in the appsettings.json file. Below reveals why
User Secrets
Some variables like passwords we dont want to be stored in appsettings.json. For development there is the dotnet user secrets. To use it we make sure we are in the project root directory. I my case I have a Unit Test project and an App Project. You need to be in the correct directory.
dotnet user-secrets init
dotnet user-secrets set MongoDbSettings:Password NotSaying!!!
Note the format of the name of the setting. This has to match the class and property. I really hate the hidden approach Microsoft was. We used MongoDbSettings because this is the section in appsettings.json. It magically reads the values from secrets manager. I would much prefer to have an indication of this somewhere.
Issues With Mongo UUIDs
I understand that the representation of UUID needs to be defined for it to work with the mongodb driver 3. For me this meant creating an extension to fix this.
public static class IServiceCollectionExtensions
{
public static void RegisterMongoDbSerializers(this IServiceCollection serviceCollection)
{
// Prior to v3 BsonDefaults.GuidRepresentationMode = GuidRepresentationMode.V3;
BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard));
BsonSerializer.RegisterSerializer(new DateTimeOffsetSerializer(BsonType.String));
}
}
Dependency Injection DI
This just points out how to inject one service into another. With the example below we only use the Repository once so creating it as its own system is a bit silly. Anyway this is how to do it. This was my first attempt
builder.Services.AddSingleton<IRepository>(provider =>
{
var mongoDbSettings = builder.Configuration.GetSection(nameof(MongoDbSettings)).Get<MongoDbSettings>();
var client = new MongoClient(mongoDbSettings.ConnectionString);
return new MongoDBItemsRepository(client);
});
builder.Services.AddSingleton<IItemService>(provider => new ItemService(provider.GetRequiredService<IRepository>()));
Now I know better, don't laugh. It looks a lot better now
// Register the MongoDB serializers
builder.Services.RegisterMongoDbSerializers();
// Register the MongoDB settings
builder.Services.Configure<MongoDbSettings>(builder.Configuration.GetSection(nameof(MongoDbSettings)));
// Build the Item Service
builder.Services.AddScoped<IItemService, ItemService>();
// Build the Mongo Db Context
builder.Services.AddScoped<IMongoDbContext, MongoDbContext>();
// Build the Repository
builder.Services.AddScoped<IRepository, MongoDBItemsRepository>();
API Versioning
Reading the book Coding clean they provide the following approach with look really simple. First install Asp.Versioning.Http
dotnet add Catalog.Api package Asp.Versioning.Http
And add the following
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.ReportApiVersions = true;
options.AssumeDefaultVersionWhenUnspecified = true;
options.ApiVersionReader = new HeaderApiVersionReader("api-version");
});
Now we can do this to have two versions
var versionSet = app.NewApiVersionSet()
.HasApiVersion(1.0)
.HasApiVersion(2.0)
.Build();
And we could then have
app.MapGet("/version", () => "Hello version 1").WithApiVersion
Set(versionSet).MapToApiVersion(1.0);
app.MapGet("/version", () => "Hello version 2").WithApiVersion
Set(versionSet).MapToApiVersion(2.0);
Express C# Style
This is an example of using the minimal api from Microsoft. The import thing is the minimal API is a new approach to reduce the code required to get going. The AddSingleton is a way to use Dependency Injection. I found the errors unreadable from the framework but maybe used to working at a lower level. Quite easy to see how to get going
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IItemService>(provider => new ItemService(new InMemItemsRepository()));
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
app.UseStatusCodePages(async statusCodeContext
=> await Results.Problem(statusCode: statusCodeContext.HttpContext.Response.StatusCode)
.ExecuteAsync(statusCodeContext.HttpContext));
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.MapGet("/items", (IItemService itemService) =>
TypedResults.Ok(itemService.GetItems()))
.WithName("GetItems")
.WithOpenApi();
app.MapGet("/items/{id}", Results<Ok<ItemDto>, NotFound> (IItemService itemService, Guid id) =>
itemService.GetItem(id) is { } itemDto
? TypedResults.Ok(itemDto)
: TypedResults.NotFound()
)
.WithName("GetItemById")
.WithOpenApi();
app.Run();
C# Controllers Gotchas
In my examples we are using the minimal api approach. Previously for C# we used the Controller approach. The framework, by default relies on names of functions and when implemented with, for instance GetItemAsync this would cause a problem as it expects GetItem. To make the framework work you need to add the following to the ConfigServices. To me this seems a bit of a shoddy approach to a framework.
services.AddControllers(option => {
options.SuppressAsyncSuffixInActionNames = false;
})
Health Checks
Next Health checks. Heartbeating etc
// Add Service
builder.Services.AddHealthChecks();
//HealthCheck Middleware
app.MapHealthChecks("/healthz");
For MongoDb we have
dotnet add package AspNetCore.HealthChecks.MongoDb
So for me the mongoDB driver was incompatiable with the HealtChecks package so downgraded to 2.28.0.0 but once working needed to adjust the code for the minimal api. The convention is to have a live and a ready endpoint like kubernetes.
Define the Read Check
This is the healthcheck the package defines. Note the ready tag we have added.
builder.Services.AddHealthChecks()
.AddMongoDb(mongoDbSettings.ConnectionString, name: "mongodb", timeout: TimeSpan.FromSeconds(3), tags: ["ready"]);
Simple live Define
We just return we are running and don't include any options
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = (_) => false
});
Ready Define
The main point is we only look at the healthoptions which are tagged ready. The Response writer part is how to format the response. For me copilot did all of the work.
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = (check) => check.Tags.Contains("ready"),
ResponseWriter = async (context, report) =>
{
var result = JsonSerializer.Serialize(
new
{
status = report.Status.ToString(),
checks = report.Entries.Select(entry => new
{
name = entry.Key,
status = entry.Value.Status.ToString(),
exception = entry.Value.Exception is not null ? entry.Value.Exception.Message : "none",
duration = entry.Value.Duration.ToString()
})
}
);
context.Response.ContentType = MediaTypeNames.Application.Json;
await context.Response.WriteAsync(result);
}
});
Other Health Checks
[Here] is a source of health checks you could use.
Authentication/Authorization
Always tricky matching the terms up. In this example we are going to validate the Bearer token provide before allowing the route. Like the Db Connection string we create a class to hold the settings. For convenience with other sample I have used the terminology from my spring example to allow me to match up the values
namespace Vehicles.Api.Settings;
public class Jwt
{
public required string JwsAlgorithms { get; set; }
public required string JwkSetUri { get; set; }
public required string IssuerUri { get; set; }
}
public class ResourceServerSettings
{
public required string Test { get; set; }
public required Jwt Jwt { get; set; }
}
We need to install the nuget package to handle the Jwt Token. I had forgotten how frustrating this is in Microsoft world. The plugin does not work in vscode insisting I install SDK 8.0.200 when only 8.0.011 is available. So it was off to the command line. Now we can define our authentication for our Jwt Bearer token which is extracted for us
# This does not work because the package manager does not check what version of the SDK you have
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
# So you need to do this
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer --version 8.0.0
Now we can add out Authentication
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
{
options.Authority = resourceServerSettings.Jwt.IssuerUri;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateAudience = false,
ValidateIssuer = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = resourceServerSettings.Jwt.JwkSetUri,
};
});
builder.Services.AddAuthorization();
...
app.UseAuthentication();
app.UseAuthorization();
...
app.MapGet("/", () => "Hello World!").RequireAuthorization();
C# Extensions
I never used this a lot in the first 7 years of C#. For the last 3 it was all the rage. Basically they are functions which take this as the first argument
namespace Catalog.Extensions;
using Catalog.Dtos;
using Catalog.Entities;
public static class Extensions
{
public static ItemDto AsDto(this Item item)
{
return new ItemDto
{
Id = item.Id,
Name = item.Name,
Price = item.Price,
CreatedDate = item.CreatedDate
};
}
}
So now you can do this
public IEnumerable<ItemDto> GetItems()
{
var itemDtos = repository.GetItems().Select(item => item.AsDto());
return itemDtos;
}
Dockerizing Our Stuff
This is not C# but I can never have enough examples of docker. Use it all day everyday and it just works so I never remember why. Here goes.
- Create Dockerfile
- Build Docker File
- Create Docker Network
- Restart MongoDB on network
- Start App in Docker
Create Dockerfile
I have never done this, this way before. You can use the vs code docker extension, answer some easy questions and hey presto. You probably should review the output but mine was pretty pretty good.
Build Docker File
You suggested but I used
# docker build -t catalog:v1 .
docker buildx build -t catalog:v1 .
Create Docker Network
docker network create restapicsharp
Restart MongoDB on network
docker run -d --rm --name mongo -p 27017:27017 -v mongodata -e MONGO_INITDB_ROOT_USERNAME=mongoadmin -e MONGO_INITDB_ROOT_PASSWORD=notsayagain!! --network restapicsharp mongo
Start App in Docker
docker run -it --rm -p 5193:5193 -e MongoDbSettings:Host=mongo -e MongoDbSettings:Password=nononono### --network restapicsharp catalog:v2
Running in Kubernetes
Introduction
I had expected to be able to make two pods and run this in kubernetes and we would be away. But it was not to be and I quite enjoyed learning a tad more kubernetes and mongodb.
Catalog C# Stuff
This was straight forward. Just make a service and pod and good to go. I guess I did learn about local repositories with microk8s, secrets and how to override the appsettings.json with kubernetes using the double underscores (dunder) to represent object name->key.
Secrets
Not too had
kubectl create secret generic catalog-secrets --from-literal=mongodb-password='nonono###'
Provision Service and Pod
apiVersion: apps/v1
kind: Deployment
metadata:
name: catalog
spec:
selector:
matchLabels:
app: catalog
template:
metadata:
labels:
app: catalog
spec:
containers:
- name: catalog
image: localhost:32000/catalog:v12
resources:
limits:
memory: "128Mi"
cpu: "500m"
ports:
- containerPort: 5193
env:
- name: MongoDbSettings__Host
value: mongodb-service
- name: MongoDbSettings__User
value: "mongoadmin"
- name: MongoDbSettings__Password
valueFrom:
secretKeyRef:
name: catalog-secrets
key: mongodb-password
livenessProbe:
httpGet:
path: /health/live
port: 5193
readinessProbe:
httpGet:
path: /health/ready
port: 5193
---
apiVersion: v1
kind: Service
metadata:
name: catalog-service
spec:
type: LoadBalancer
selector:
app: catalog
ports:
- port: 5193
targetPort: 5193
Mongodb
Introduction
Was a bit of a trial mainly because I have not done too much on the admin side for mongodb, only the usage of it. Add to that we are using stateful sets in kubernetes.
PV Persistent Volumnes
This was new to me too as I was all done for me previously. The PV are, in my case, provisioned on my machine using the microk8s.io/hostpath provider. We start off by making a storage class. This allows you to
- Say when to make (bind) the request
- Where to put the data
Pretty easy once you know.
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: ssd-hostpath
provisioner: microk8s.io/hostpath
reclaimPolicy: Delete
parameters:
pvDir: /opt/k8s/mongodb/data
volumeBindingMode: WaitForFirstConsumer
Now we have our class we are ready to deal with mongodb
Stateful Sets
This allows you, when using replicas, to ensure the resources are kept in sync. This is pretty much as you would expect after googling. I guess the storageClassName is pretty obvious. However
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: mongodb-statefulset
namespace: catalog
spec:
selector:
matchLabels:
app: mongo
serviceName: "mongodb-service"
replicas: 1
template:
metadata:
labels:
app: mongo
selector: mongo
spec:
containers:
- name: mongodb
image: mongo
ports:
- containerPort: 27017
env:
- name: MONOGO_INITDB_ROOT_USERNAME
value: "mongoadmin"
- name: MONOGO_INITDB_ROOT_PASSWORD
valueFrom:
secretKeyRef:
name: catalog-secrets
key: mongodb-password
volumeMounts:
- name: mongo-data
mountPath: /data/db
volumeClaimTemplates:
- metadata:
name: mongo-data
namespace: catalog
spec:
storageClassName: ssd-hostpath
accessModes: [ "ReadWriteOnce" ]
resources:
requests:
storage: 1Gi
---
apiVersion: v1
kind: Service
metadata:
name: mongodb-service
namespace: catalog
labels:
app: mongo
spec:
clusterIP: None
selector:
app: mongo
ports:
- protocol: TCP
port: 27017
targetPort: 27017
Problems
This is pretty much like the docker approach, a volume and all of the environment variables. One thing to note is the service is ClusterIP:None which means we can use the service name as the hostname in the connection string. The main issue is the MONOGO_INITDB_ROOT_USERNAME and MONOGO_INITDB_ROOT_PASSWORD which suggested we could to this
mongodb://mongoadmin:fred1y@mongodb-service:27017
And all would be well. It wasn't and reading up I thing the init script behaves differently in kubernetes. I don't know why is the answer. But I did learn some tricks. We can run a client in the network to test connecting with
apiVersion: apps/v1
kind: Deployment
metadata:
creationTimestamp: null
labels:
app: mongo-client
name: mongo-client
namespace: catalog
spec:
replicas: 1
selector:
matchLabels:
app: mongo-client
strategy: {}
template:
metadata:
creationTimestamp: null
labels:
app: mongo-client
spec:
containers:
- image: mongo
name: mongo-client
env:
- name: mongo-client_INITDB_ROOT_USERNAME
value: 'mongoadmin'
- name: mongo-client_INITDB_ROOT_PASSWORD
value: 'mypass'
Then we can use
microk8s kubectl exec deployment/mongo-client -it -- /bin/bash
mongosh mongodb://myuser:mypass@mongodb-service:27017
To fix this you need to create the admin user yourself through the client. Connect with no user or password
mongosh mongodb://mongdb-service:27017
And run the following
db.createUser(
{
user: "mongoadmin",
pwd: "mypass",
roles: [ "root" ]
}
)